MySQL InnoDB 的并发问题,通常不是“有没有加锁”这么简单,而是要先分清:当前查询看到的是哪个版本、是否需要阻止其他事务修改,以及索引范围是否会影响后续插入。把这几个问题拆开,事务、MVCC、隔离级别和锁机制就能串成一条清晰的决策链。
先建立一个整体模型:快照读与当前读
InnoDB 的事务并发控制可以理解为两条路径:普通 SELECT 通常走一致性非锁定读,也就是快照读;SELECT ... FOR UPDATE、SELECT ... FOR SHARE 以及 UPDATE、DELETE 则属于需要锁定的当前读或写操作。前者尽量不阻塞读写,后者负责在业务需要“读完马上改”时保护数据。
这一区分比单独记忆某个锁名更实用。例如,订单扣库存时,先普通查询再更新,两个事务可能同时读到同一库存;如果业务要求读取后立即占有修改资格,就应在同一事务里使用当前读,或直接让更新语句完成条件判断。相反,报表查询通常不需要为了读取而阻塞写入,快照读更合适。
事务仍然是边界。开启 autocommit 时,一条语句通常就是一个短事务;使用 START TRANSACTION 后,多条语句共享事务上下文,提交前获得的锁也不会因为某条 SQL 执行结束而自动释放。长事务会延长锁持有时间,还会让旧版本清理滞后,因此在线业务应尽量缩短事务范围。
四种隔离级别怎么影响结果
InnoDB 支持 READ UNCOMMITTED、READ COMMITTED、REPEATABLE READ 和 SERIALIZABLE。选择隔离级别,本质是在数据可重复性、并发能力和锁冲突之间做取舍。
- READ UNCOMMITTED:允许读取其他事务尚未提交的数据,可能出现脏读。除非明确接受不一致结果,否则不适合核心交易流程。
- READ COMMITTED:每次一致性读都建立或读取当前语句对应的新快照,同一事务内重复查询可能看到不同结果。写操作通常主要锁定索引记录,范围内新插入数据仍可能出现,因此幻读风险需要由业务或其他机制处理。
- REPEATABLE READ:InnoDB 的默认级别。事务中的普通一致性读通常基于同一读视图,重复读取更稳定;对于锁定读、更新和删除,范围扫描可能使用间隙锁或临键锁,抑制范围内的并发插入。
- SERIALIZABLE:进一步强化读操作的隔离,适合少量对串行化要求很高的场景,但更容易造成等待与吞吐下降,不宜把它当作解决所有并发问题的默认方案。
隔离级别不是越高越好。后台报表更关心不影响在线交易,可能倾向 READ COMMITTED;库存、余额等需要稳定判断并防止竞争写入的流程,通常要结合默认隔离级别与明确的锁定读;真正需要调整前,应先用并发时序复现问题,而不是只修改全局配置。
MVCC 解决了什么,又没有解决什么
MVCC 的核心作用是保存行的历史版本,让符合可见性规则的普通查询能够读取一个一致视图,而不必对正在被修改的记录加读锁。这样,读多写少或读写并行的业务可以减少互相等待。
但 MVCC 不是“所有读都不会阻塞”,也不是“事务永远看到最新数据”。普通快照读和 FOR UPDATE 等当前读使用的可见性规则不同:快照读关注事务视图,当前读需要基于最新版本判断并取得锁。一个事务里混用两种读,如果没有明确理解它们的语义,就可能出现“普通查询看到旧值、随后更新却按新值加锁”的认知落差。
工程上还要关注长事务。事务持续时间越长,旧版本越难及时清理,历史版本链和锁等待也可能扩大。排查时可以结合活动事务、锁等待、慢 SQL 和执行计划一起看,不能只盯着某一条被阻塞的语句。
锁不是只锁一行:索引决定锁的范围
InnoDB 的行锁实际落在索引记录上。使用唯一索引做等值查询时,通常可以精确锁住命中的索引记录;如果是非唯一索引或范围条件,扫描到的索引范围可能涉及记录锁、间隙锁和临键锁。
临键锁可以理解为“索引记录锁加上记录之前的间隙锁”。在默认的 REPEATABLE READ 下,范围锁定读可能阻止其他事务向被覆盖的区间插入新记录,这正是防止部分幻读的机制。于是,下面两种写法的并发影响可能完全不同:
-- 唯一键等值条件,通常更容易形成精确的记录锁
SELECT * FROM orders WHERE id = 1001 FOR UPDATE;
-- 范围条件,需要评估索引与扫描范围,可能锁住区间
SELECT * FROM orders
WHERE user_id = 42 AND status = 'pending'
FOR UPDATE;如果条件没有合适索引,数据库可能扫描更多记录,锁冲突范围也会随之扩大。线上遇到“明明只改一行却阻塞很多请求”,应优先检查执行计划、索引顺序、条件选择性和事务是否及时提交。
常见业务场景的落地方式
库存、余额与配额
这类流程的关键不是先查出一个数字,而是保证“判断和修改”处在可控的并发边界内。可以在一个短事务中使用条件更新:
UPDATE stock
SET available = available - 1
WHERE sku_id = 1001 AND available > 0;随后检查受影响行数,再决定是否下单成功。相比先查询、在应用层判断、再执行更新,条件更新减少了竞态窗口。若还需要读取多条关联记录,才考虑在事务中使用合适的锁定读。
队列消费与任务领取
多个消费者领取任务时,需要明确是否允许跳过已被占用的任务,以及是否需要等待。可以结合 FOR UPDATE、NOWAIT 或 SKIP LOCKED 等能力设计领取策略,但要根据 MySQL 版本、索引和失败重试方式验证,不能仅凭 SQL 表面语义判断吞吐量。
报表与后台查询
报表通常不应长时间持有业务锁。优先缩短事务、控制扫描范围,并使用合适的隔离级别;如果一致性要求是“某一时刻的数据快照”,应明确快照边界,而不是依靠默认行为碰运气。
排查锁等待的实用顺序
- 先确认事务是否长时间未提交,尤其是连接池借出后把事务带到业务外层的情况。
- 查看阻塞与被阻塞 SQL 的完整条件,确认双方访问索引的顺序是否一致。
- 用
EXPLAIN检查是否走了预期索引,范围扫描是否过大。 - 判断语句是快照读还是当前读,是否真的需要
FOR UPDATE。 - 结合隔离级别确认间隙锁、临键锁和幻读行为是否符合预期。
- 将事务拆短,并让多个事务以一致顺序访问资源,降低死锁概率。
怎么选:给云上 MySQL 的建议
在云数据库或自建 MySQL 上,隔离级别与锁行为都属于数据库内核规则,不能靠扩大实例规格替代正确的事务设计。实例升级可以缓解 CPU、IO 或连接压力,却不能消除一个没有索引的范围更新,也不能修复迟迟不提交的事务。
更稳妥的做法是:默认使用经过验证的隔离级别;核心写流程采用短事务和条件更新;需要“读后修改”时明确使用当前读;对范围锁定查询补齐索引并评估锁覆盖范围;最后通过压测和线上监控验证等待时间、死锁次数与回滚情况。这样才能在一致性和并发之间取得可解释的平衡。