1. 把事务和索引拆开看:它们到底在解决什么问题
先讲个我在实际项目中遇到的场景。去年帮朋友排查一个电商后台的订单接口,用户下单后页面一直转圈,数据库CPU直接飙到100%。查了半天,发现是两个程序员写代码时对同一张订单表做了不同操作:一个在事务里先更新订单状态再扣库存,另一个反过来先扣库存再更新状态,两边互相等对方释放锁,直接死锁。业务量一大,整个下单接口就卡死了。
这类问题的解决思路,就是事务和索引这两样东西。很多刚接触MySQL的朋友总把它们当成两个独立的知识点来背,背完就忘,遇到问题依然抓瞎。实际上事务和索引是一对配合紧密的机制:事务管的是“多条SQL要么全成功、要么全失败”的原子性,以及并发环境下彼此隔离不乱套;索引管的是“数据能不能被快速找到”,它决定SQL执行时是一行行扫全表还是直接定位目标,也决定事务加锁时锁住的是一行还是一个表。
用个生活化的类比。事务就好比汽车的安全带,解决的是“万一出事人别飞出去”的问题;索引就是发动机里的涡轮增压,解决的是“车能不能跑得快”的问题。安全带不让你开得更快,但能保命;涡轮不保命,但能让你超车时不憋屈。数据库里如果只有事务没有索引,并发安全是保证了,但每条SQL都全表扫描,遇到几百万行的表,一条更新语句能把数据库拖到罢工;只有索引没有事务,单条SQL是快了,可一旦中途出错,数据说丢就丢,业务逻辑根本没法叠上去。
这篇文章不打算按教科书那样从存储引擎结构讲起,而是用“安全带”和“加速器”这两个视角,把事务的隔离级别、锁机制、MVCC,和索引的数据结构、建索引原则、执行计划分析串起来,全程用大家熟悉的“订单系统”“用户表”“商品库存”这类业务场景举例,每一步对应到真实SQL命令和排查方法。适合正在学MySQL的学生、刚转行做后端开发的初级工程师,以及写了好几年业务代码但一直没系统捋过数据库底层机制的开发朋友。
需要说明的是,我讲的都是基于MySQL 8.x版本,InnoDB存储引擎。这是目前最主流的组合,也是你生产环境大概率在用的组合。MyISAM那种老古董不支持事务,不在讨论范围内。
2. 事务这条“安全带”:原理、隔离级别和实战写法
2.1 事务的ACDI四个核心性质,逐个说透
先说一个最容易被忽略的事实:MySQL里每条单独的SQL语句,默认就是一个事务,自动提交(autocommit)默认是开启的。你执行一条UPDATE,MySQL自动帮你在语句前后套上了BEGIN和COMMIT。所以日常开发里感觉不到事务的存在,只有当你手动写BEGIN…COMMIT多语句操作时,才真正进入事务管理的范畴。
事务的核心是ACID四个特性,教科书通常按“原子性、一致性、隔离性、持久性”来排,但实际排查问题时我更倾向于按“一致性是目标,其他三个是手段”来理解。
原子性(Atomicity)最直观,就是一组SQL要么全部成功,要么全部回滚。比如转账:扣款成功,入账失败,整个操作必须撤销。MySQL用undo log实现这个能力,执行事务时记录操作前的数据快照,回滚时按快照恢复原始状态。这个机制在内部叫“回滚日志”,和我后面讲的MVCC强相关。
一致性(Consistency)说的是事务执行前后,数据必须始终满足业务规则和约束,比如订单金额不能为负、库存不能超卖。这是业务层的概念,数据库只能提供约束工具,真正的一致性逻辑要开发者在事务里写对。比如扣库存时必须加条件“库存 > 0才update”,如果忘了这个条件,两个并发事务就可能同时读到库存为1,各自扣成0,超卖问题由此而来。数据库层面无法替你判断这种业务一致性。
隔离性(Isolation)解决并发问题。多个事务同时读写同一行数据时,可能会出现脏读(读到别人未提交的数据)、不可重复读(同一条记录两次读值不同)、幻读(同样的查询条件两次查出不同条数)。隔离性允许事务互相“假装看不见对方”,程度越严格,数据越安全,但并发性能越低。
持久性(Durability)最简单,事务提交后数据不能丢。MySQL用redo log实现,提交事务时先写重做日志到磁盘,再异步刷新数据页。崩溃恢复时会重放redo log,保证已经提交的事务不丢失。
2.2 四个隔离级别到底怎么选:从读未提交到串行化
MySQL的InnoDB支持四个隔离级别,从松到严分别是:读未提交、读已提交、可重复读、串行化。默认是第三个“可重复读”,这个选择本身就有讲究。
读未提交(READ UNCOMMITTED)基本没人用,因为允许读取其他事务未提交的数据,脏读风险太高。说实话我在生产环境从没见人用过。
读已提交(READ COMMITTED)是Oracle和SQL Server的默认级别,解决脏读,但存在不可重复读:同一条记录,事务A先读是100,事务B修改成200并提交,事务A再读就是200。对大多数业务来说,这个级别够用,且由于锁范围小,并发性能通常比可重复读更好。MySQL 8.x也支持,阿里云等一些云数据库甚至推荐生产环境用这个级别来降低死锁概率。
可重复读(REPEATABLE READ)是MySQL的默认级别,它通过MVCC保证事务内多次读取同一行结果一致。同时InnoDB在可重复读级别下还解决了一个标准SQL里没解决的问题——幻读,用的是间隙锁+MVCC的组合,后面我会细说。
串行化(SERIALIZABLE)最安全,事务完全排队执行,等于把并发变成了串行,但性能损失极大。除非是资金结算那种低并发高安全场景,否则慎用。
怎么选没有绝对标准,我的经验是:内部管理系统、报表查询这类场景,保持默认的可重复读即可;高并发的交易类系统,可以考虑读已提交,但前提是团队对并发冲突有充分的测试覆盖。改隔离级别的方式是:
-- 查看当前会话级别 SELECT @@transaction_isolation; -- 设置会话级别,仅对当前连接生效 SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED; -- 设置全局级别,新连接生效 SET GLOBAL TRANSACTION ISOLATION LEVEL READ COMMITTED;2.3 手把手写一个标准事务:五个关键步骤
实际开发中建议用显式事务而不是依赖自动提交。写事务的标准姿势分五步:开启事务、执行业务SQL、检查结果、提交或回滚、处理异常。
以经典的“订单扣库存”为例,完整逻辑是:
-- 步骤1:开启事务 START TRANSACTION; -- 步骤2:执行核心操作,先锁定库存行 UPDATE inventory SET stock = stock - 1 WHERE sku_id = 'A001' AND stock > 0; -- 步骤3:检查上一步影响行数。如果影响行数为0,说明库存不足,直接回滚 -- 这一步在应用层判断:if (rowsAffected == 0) { rollback(); } -- 步骤4:插入订单记录 INSERT INTO orders (order_no, sku_id, quantity, amount) VALUES ('20250201001', 'A001', 1, 99.00); -- 步骤5:提交 COMMIT;这段代码最关键的细节是AND stock > 0。很多学生和初级开发容易漏掉这个条件,只写UPDATE inventory SET stock = stock - 1 WHERE sku_id = 'A001'。没有这个条件时,两个并发请求同时读到库存为0,各自执行更新,库存变成-1,超卖问题发生。有了条件,行锁生效时第二个事务会等待第一个事务提交,然后重新评估条件,发现stock已经是0,影响行数为0,从而回滚。
在代码层面,Java里用Spring的@Transactional注解时也容易犯一个错误:事务里调用self.invoke()之类的方法,导致注解失效。因为Spring的事务代理默认只对通过代理对象调用的方法生效,类内部直接调用绕过代理,事务就不会启动。
2.4 事务日志满、长事务和死锁:三个新手必踩的坑
热搜词里有条特别真实的报错:“数据库的事务日志已满”。这个报错在MySQL 8.x里常见于一个场景:你执行了一个特别大的事务,比如一次性往几百个表里插入几百万行数据,或者循环几万次执行UPDATE但没及时提交。InnoDB收到“事务日志已满”通常不是指磁盘空间真的满了,而是指事务占用的undo log和redo log超过了限制,或者磁盘空间不足导致无法扩展日志文件。
解决思路分三层:第一层,确认磁盘空间,df -h看一眼磁盘是否满了;第二层,查是否有未提交的超长事务SELECT * FROM information_schema.innodb_trx\G,有就把对应进程KILL掉;第三层,如果业务确实需要大批量操作,拆分成小事务分批提交,比如每次commit 1000条。
死锁问题是事务并发的经典难题。我先说结论:死锁不是靠配置解决的,而是靠排查和预防。MySQL检测到死锁会自动回滚其中一个事务,你会在应用日志里看到“Deadlock found when trying to get lock”。排查思路是开启死锁日志并分析:
-- 查看最近的死锁信息 SHOW ENGINE INNODB STATUS\G重点关注日志里“LATEST DETECTED DEADLOCK”部分,它会打印两个事务各自的SQL和持有的锁。绝大多数死锁的根因是事务以不同顺序访问资源。比如事务A先更新order表再更新inventory表,事务B先更新inventory表再更新order表,两者在交集处互相等待。解决方式是制定一个全局统一的访问顺序,所有事务都按“先order后inventory”的顺序操作,死锁基本消失。
还有一个体验相关的问题:长事务。一个事务开着迟迟不提交,会导致两件事:一是它持有的锁一直不释放,其他事务被卡住,接口越来越慢;二是MVCC需要保留该事务开始前的老版本数据,undo log膨胀,表空间剧增,查询也可能变慢。排查方式是在information_schema.innodb_trx里查长事务的trx_started字段,再配合performance_schema.events_statements_current看它正在执行的SQL。生产经验是:合理设置innodb_lock_wait_timeout(默认50秒,可调到30秒甚至更低),避免一个事务把全链路拖死。
3. 索引这条“加速器”:B+树原理与建索引的底层逻辑
3.1 索引为什么快:从B+树说起
索引的本质是一种数据结构,把“值”映射到“数据行的物理位置”。MySQL InnoDB的索引底层是B+树,理解B+树的三个特点就足够用:数据只存在叶子节点、非叶子节点只存键值用于路由、叶子节点用双向链表串起来。
这三个特性决定了查询路径:比如你要查“用户名为zhangsan”的记录,如果没有索引,InnoDB只能按主键顺序把整张表的每一行都读一遍,这叫全表扫描,复杂度O(N)。有了索引,从根节点开始每次二分查找,一层层往下,复杂度变成O(logN)。一张百万行记录的表,全表扫描可能要读十万个数据页,走索引可能只需要读三四个数据页,这就是索引最核心的加速原理。
还有个细节你可能没注意:InnoDB的主键索引就是数据本身,也叫聚簇索引。表数据按主键顺序物理存储,叶子节点直接保存整行记录。其他索引叫二级索引,叶子节点保存的是“索引列的值+对应的主键值”。所以当你用二级索引查数据时,会先查到主键值,再通过主键回到聚簇索引里取整行数据,这叫回表。回表有额外开销,减少回表次数是我后面要讲的覆盖索引的核心动机。
3.2 聚簇索引、二级索引和联合索引:怎么建才对
建索引前先搞清楚索引的类型和适用场景。我见过太多人不管三七二十一,给每个字段都建一个索引,结果索引比数据还大,写入性能差到离谱。建索引的核心原则就一句话:索引设计必须跟着SQL查询模式走,不是跟着表结构走。
单列索引最基础,适合查询条件只有一个字段的场景。比如用户表经常按手机号查用户,那就在手机号列建普通二级索引:
ALTER TABLE users ADD INDEX idx_phone (phone);联合索引(也叫复合索引)是两个或多个字段组合成一个索引,它遵循“最左前缀原则”:查询条件必须从联合索引的最左列开始,才能用上该索引。举例来说,如果建了(a, b)联合索引,那么WHERE a = 1和WHERE a = 1 AND b = 2都能命中索引,但只看WHERE b = 2是无法命中索引的。我在实际面试中经常考这个点,还真是很多人答不清楚。
所以联合索引的建法有讲究:第一,把等值查询的字段放最左边;第二,把区分度高的字段放前面;第三,考虑范围查询字段放最后。比如订单表最常用的查询是“按用户ID查订单,再按时间排序”,那就该建(user_id, order_time)联合索引,既能加速查用户订单,又能让排序直接走索引,避免额外的filesort操作。
3.3 一张表到底建几个索引合适:数量控制的建议
关于索引数量,不少入门者的理解是“越多越好”。真实情况是:每个索引在插入、更新、删除时都要同步维护,B+树节点可能分裂、合并,写性能会打折;索引还占磁盘空间,缓存池也放不下那么多索引页。我的经验守则是:单表索引数量一般控制在5个以内;单列索引能合并成联合索引的,尽量用联合索引替代;经常不用的索引要定期清理。
判断索引是否被使用,可以通过MySQL的索引统计信息来看:
-- 查看表索引 SHOW INDEX FROM orders; -- 查看各索引的使用频率 SELECT * FROM sys.schema_unused_indexes;第二步那个视图会直接列出从未被使用的索引,非常实用。前阵子帮一个项目做优化,发现一张表有8个索引,其中3个从上线以来从未命中过,直接删掉后,写入性能提升了约18%。
3.4 索引失效的六种典型场景,照单自查
建了索引不代表SQL一定会走索引。我总结过新手最常踩的六种索引失效场景,建议背下来:
第一,对索引列做函数计算或隐式类型转换。比如WHERE DATE(create_time) = '2025-02-01',因为对列做了函数操作,索引直接失效。正确做法是WHERE create_time >= '2025-02-01' AND create_time < '2025-02-02'。另外如果索引列是字符串类型,查询条件传数字,MySQL会做隐式类型转换,同样导致索引失效。
第二,前导模糊查询。比如WHERE name LIKE '%张%',由于左模糊不确定起点,B+树无法按顺序查找。如果业务必须支持,考虑全文索引或者ES这类搜索中间件。
第三,联合查询不是左前缀。上面说的最左前缀原则,一旦查询条件没有从联合索引的第一列开始,索引无法命中。
第四,查询条件里对索引列做表达式运算,比如WHERE age + 1 = 30。这个和第一条类似,索引优化器无法反向推导,干脆放弃索引。把表达式改写为WHERE age = 29就行。
第五,OR连接非索引列。比如WHERE id = 1 OR status = 'closed',如果status没有索引,优化器可能选择全表扫描,因为要同时满足两边的取数逻辑。一个折中方案是把OR改写为UNION,让两段分别使用各自的索引。
第六,NOT IN、!=等否定条件。这类条件通常导致优化器认为扫描大部分行,不划算,从而放弃索引。如果业务必须用,通常会把否定条件拆出来,结合数据和范围再评估。
排查索引是否生效的利器是EXPLAIN,我下面专门展开。
3.5 用EXPLAIN看懂执行计划:三个必看字段
遇到慢SQL时,第一件事永远是丢一个EXPLAIN上去。我给团队定的规矩是:上线前所有涉及查询的SQL必须过EXPLAIN,重点看三个字段。
type字段是访问类型的口诀,从好到差依次是:const>eq_ref>ref>range>index>ALL。ALL就是全表扫描,慢SQL的头号元凶;index是扫描全部索引树,比全表扫描好一点但也不理想;range是范围扫描,比如IN、BETWEEN、LIKE右模糊后就是range,可以接受;ref是普通等值匹配;eq_ref是联表查询时用了主键或唯一索引,表现很好;const是通过主键或唯一索引直接定位到一行,最优。
key字段显示实际用到的索引名,possible_keys显示可能用到的索引,两者对照能看出优化器有没有选错索引。如果possible_keys不为空但key为空,说明索引没被选中,多半是上一节讲的失效场景之一。
rows是预测扫描的行数,它不是真实行数,是优化器估算的。这个数字越大说明成本越高,结合Extra字段里的Using filesort、Using temporary可以快速判断排序是否为额外开销。
举个实际的例子,一条慢查询的EXPLAIN结果:
type = ALL possible_keys = idx_user_id key = NULL rows = 1200000 Extra = Using wheretype=ALL加上key=NULL,说明虽然有用户ID索引,但这条SQL没有走,120万行全表扫了。回头检查SQL,发现是WHERE user_id = ? AND create_date BETWEEN ? AND ?,但联合索引建的是(create_date, user_id),最左前缀不满足,索引失效。改成(user_id, create_date)联合索引后,type变range,rows降到几千,响应时间从2.3秒降到30毫秒。
4. 事务和索引怎么配合:锁、MVCC和性能之间的博弈
4.1 为什么事务的隔离级别不等同于加锁强度
很多人觉得事务的隔离级别越严,加的锁就一定越多,其实是个常见误区。可重复读和读已提交这两个级别下,普通查询(SELECT)走的是MVCC快照读,根本不加锁。真正的锁发生在UPDATE、DELETE、INSERT这类写操作,以及你手动加FOR UPDATE和LOCK IN SHARE MODE的SELECT上。
MVCC的全称是多版本并发控制,核心机制是:每行记录在更新时产生新版本,旧版本通过undo log保留。事务执行普通SELECT时,根据事务开始时的视图(read view)读取“当时已提交”的某个版本,而不是读取最新数据。这就是可重复读的本质:同一个事务内看到的快照是固定的,第二次读到的还是第一次读时的旧版本,所以别人提交的新数据被“看不见”。因为读的是快照不需要加锁,所以并发读的性能几乎不损失,这就是为什么MySQL能在可重复读级别下还保持不错的并发性能。
但加了对“幻读”的解释就需要引入锁了。可重复读下,事务执行SELECT * FROM orders WHERE status = 'pending' FOR UPDATE时,InnoDB除了锁住满足条件的所有记录,还会在记录之间的间隙加一个间隙锁(gap lock),禁止其他事务在范围内插入新记录。间隙锁和它前面那条记录上的行锁合起来叫next-key lock。所以幻读在可重复读级别下其实是靠“行锁+间隙锁”共同封堵住的,这也是和Oracle的区别:Oracle在可重复读下仍可能幻读,MySQL直接用锁把这个口子堵死了。
4.2 索引直接影响锁的粒度:建错索引锁全表
这一点我觉得值得单独拿出来强调,因为它把事务和索引串起来了:InnoDB的锁是建立在索引记录上的,索引的粒度决定了锁的粒度。走主键索引更新一行,通常只锁那一行;走了辅助索引更新若干行,会锁命中行以及必要的间隙;如果查询条件无法命中任何索引,InnoDB只能全表扫描找目标行,那么它在扫描过程中会把扫过的每一行都加锁,最终表现为锁定了整个表。
想象一下,线上有个千万级订单表,一个UPDATE语句的WHERE条件没走索引,执行时就要锁住扫过的所有记录,期间所有其他事务对这个表的写操作哪怕是一行也得排队。更麻烦的是,很多ORM自动生成的SQL并不会实际命中你以为的索引,所以上线前用EXPLAIN确认每条写SQL的访问路径,是必修课。
4.3 如何利用索引优化事务性能:三点实用经验
第一点是让写事务的WHERE条件尽可能命中唯一或精确索引,减少锁的行数。比如批量更新时,UPDATE orders SET status = 'paid' WHERE order_no IN (...),只要order_no有唯一索引,锁的就只有那几行;没有索引就直接升级成全表锁。
第二点是事务里尽量把读取操作放前面,写操作放后面。因为锁通常在执行写操作时才获取,先读后写可以让锁持有时间尽可能短。事务持有锁的时间越短,其他事务等待的时间越短,整个库的并发能力就越好。
第三点是长事务配合索引查询也可能导致undo log膨胀。前面说过,长事务会保持老版本数据,索引数据页里可能有多个版本的记录,查询要跳更多的版本链,整体变慢。给长事务设置超时提醒很有必要,我一般用如下脚本定期检查活动事务数量和时间,超过30秒的事务自动告警:
SELECT trx_id, trx_started, trx_query, TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS running_seconds FROM information_schema.innodb_trx WHERE TIMESTAMPDIFF(SECOND, trx_started, NOW()) > 30;4.4 案例复盘:索引缺失导致的分布式事务僵局
聊一个朋友公司发生的真实故障,很能说明问题。他们的订货系统改造成微服务后,订单服务和库存服务分开部署,采用分布式事务方案。某个凌晨大促,下单接口突然大面积超时,监控显示数据库活跃连接数打满。
排查过程先看的APM链路,发现是库存服务的数据库update等待锁超时。再查innodb_trx,发现一个已运行20多分钟的订单事务持有库存表某行的锁不放。链路日志显示它卡在下游的库存回写接口上,而回写接口的SQL长这样:
UPDATE inventory SET stock = stock - 1 WHERE product_id = ? AND warehouse_id = ?当时这张表300万行,但(product_id, warehouse_id)上没有联合索引,只有product_id单列索引。MySQL查出大量目标行,锁了超大范围,加上大促并发高,多个事务互相等待,锁等待雪崩。
修复方案其实不复杂:给(product_id, warehouse_id)建联合索引,锁的范围从几百行缩到一行;同时把分布式事务中不必要的数据库事务范围缩小,只把真正的写操作包在事务里,之前因为建房失误把远程调用(如发MQ、调用外部API)也放进数据库事务一把梭,拖大了锁持有时间。恢复后接口超时率从18%降到0.3%。
这个案例说明一个问题:分布式事务的一致性不由MySQL单独决定,但MySQL事务提供的锁和隔离能力是地基;索引不好,地基上再花哨的分布式方案也白搭。
5. 新手必看:从建表到优化的完整实操清单
5.1 建表时就要考虑索引和事务的配合
很多朋友建表时随手写完字段就上线,等慢查询出现才回头补索引,这个习惯很不好。我建议建表时遵循几条硬约束:必须有主键,且主键尽量用自增整数或雪花生成的趋势递增整数,不要用UUID这种无序字符串,因为无序主键会导致B+树频繁页分裂,写入性能下降明显。
字段类型尽量精炼。能TINYINT不INT,能VARCHAR(32)不VARCHAR(255),越短的字段建索引后索引树越矮,查询越快。日期字段必须用DATETIME或TIMESTAMP,别用VARCHAR存日期,否则范围查询没法利用索引。
如果明确某列将来会被频繁作为查询条件,比如手机号、用户ID、订单号、状态和时间组合,建表阶段就加上合适索引。等到上线后数据多了再补索引,虽然MySQL 8支持在线DDL,但大表上执行ALTER TABLE仍然会触发大量内部操作,生产环境做一次不轻松。
5.2 上线前必做的三条索引与事务联查
我给团队的SQL走查清单固定有这么几条:
用EXPLAIN确认每一条可能高频执行的查询都能命中索引,且type至少是range;对联合索引做脑内最左前缀验证,写SQL时从联合索引最左侧列开始带条件;对UPDATE和DELETE语句,确认WHERE条件走的是唯一索引或组合索引,防止锁范围扩大。
还有一个常被忽略的点:索引列不要加不必要的函数和隐式转换。这个在SQL走查中用EXPLAIN看key字段有没有从possible_keys里消失,就能快速识别。
5.3 慢查询日志定位:从全局到单条
如果不想上线前逐条核查,可以在测试环境或预发环境开启慢查询日志,把执行时间超过阈值的SQL全部捕获:
-- 查看当前状态 SHOW VARIABLES LIKE 'slow_query_log%'; SHOW VARIABLES LIKE 'long_query_time'; -- 临时开启慢查询日志 SET GLOBAL slow_query_log = ON; SET GLOBAL long_query_time = 1;配合mysqldumpslow或者直接用performance_schema里的events_statements_summary_by_digest视图做聚合,找出查询次数多、平均耗时长的SQL,一条条过EXPLAIN和索引优化。这个流程比凭感觉猜慢SQL高效得多。
5.4 常见问题速查表:直接对着排查
我把自己踩过和帮别人排查过的高频问题整理成了一张速查表,贴在团队Wiki里,也分享在这里:
| 症状 | 可能原因 | 排查/解决方式 |
|---|---|---|
| SQL没问题但越来越慢 | 索引失效或数据量大 | 跑EXPLAIN看type和key,优化SQL让索引命中 |
| 更新某行时其他查询卡住 | WHERE条件没走索引 | 确认索引命中,避免锁扫描范围扩大 |
| 报“事务日志已满” | 长事务或大事务占满undo/redo空间 | 查innodb_trx杀掉长事务,拆批提交 |
| 偶发死锁 | 多事务访问资源顺序不一致 | 看SHOW ENGINE INNODB STATUS,统一访问顺序 |
| 同一条SQL有时快有时慢 | 缓存失效、统计信息不准确 | 定期ANALYZE TABLE,优化SQL消除回表 |
| 联合索引没生效 | 查询条件不满足最左前缀 | 调整索引列顺序或改写查询条件 |
另外有个很容易忽视的坑:EXPLAIN显示用了索引,但实际慢在排序或回表上。Extra字段看到Using filesort时,如果业务经常按某个字段排序,可以考虑把它加进联合索引的末尾,让排序直接在索引树上完成,省掉额外的排序阶段。如果看到Using index condition(索引条件下推),说明MySQL 5.6以后的索引下推优化在生效,这是好事,不用处理。
6. 写在最后:根据我实际排障积累的几个锦囊
这篇文章不是让你把事务和索引的原理背下来的,更重要的是一套面对问题时的判断顺序。我自己在多年排障中形成了一个固定套路,遇到数据库相关故障,先看是不是长事务或死锁,再查是不是索引失效,最后再看SQL本身写法有没有问题。这个顺序能定位到90%以上的日常问题。
动手之前先确认版本,MySQL 5.7和8.x在事务行为、索引下推、优化器细节上都有差别。网上很多文章不标注版本就直接给结论,抄作业容易翻车。
索引不是万能的:区分度低的列,真不建索引可能比建了还快,因为优化器扫描索引后还要回表,成本反而更高。比如性别字段只有“男”“女”两个值,等值查询可能命中大量数据,不如全表扫。判断一个列是否适合建索引,用这个SQL算一下区分度:
SELECT COUNT(DISTINCT column_name) / COUNT(*) AS selectivity FROM table_name;区分度在0.1以下,我通常不会单独建索引,而是考虑和其他高频条件组合成联合索引。
最后一个小技巧:修改表结构或者批量刷数据的脚本,尽量写成可重入的幂等操作。比如扣减库存前先查锁,更新订单前判断状态机是否合法。幂等配合事务,才能真正意义上保证数据不出错。我见过太多生产问题不是事务没开,而是业务代码不具备幂等性,重试一次就重复扣款。
这套“事务兜底、索引提速”的组合拳,掌握好了,很多数据库相关的疑难杂症在你眼里就会清晰很多。希望能对正在学MySQL的朋友们有一点帮助。