资讯动态

MySQL悲观锁与乐观锁实战:从FOR UPDATE到索引死锁全解析

发布时间:2026/10/20 17:25:38 来源:尧图企业网站定制
做后端这几年库存扣减、余额变更、订单状态流转几乎每个需要控制并发的业务都绕不开锁。MySQL 里SELECT ... FOR UPDATE是教科书和面试题里的常客可真正把它写进生产环境很多人会踩到同一个坑要么锁没生效要么锁范围比预期大要么干脆死锁。这篇文章我会从悲观锁和乐观锁的选择讲起再把FOR UPDATE的底层行为、落地步骤、索引要求、死锁排查一次性说清楚适合正在做订单、库存、账务类系统的后端开发也适合那些刚接手高并发改造、想搞明白“锁到底锁住了什么”的人。1. 先分清两类锁悲观锁和乐观锁根本不是同一层面的东西很多人一听到“悲观锁”就默认等于SELECT ... FOR UPDATE这个理解不能说错但太粗糙。它俩一个是“锁方案”一个是“避免锁的方案”选择逻辑完全不同。我在实际项目里经常看到有人把所有并发统一交给FOR UPDATE结果锁冲突严重接口耗时从几十毫秒涨到几秒最后还得回头改成乐观锁。所以在聊 SQL 之前先把这两个概念的边界画清楚。1.1 悲观锁默认就会有人来抢悲观锁的核心假设是“冲突一定会发生”。在同一份数据上如果两个事务同时操作一定会出问题所以必须先拿锁把资源占住再安心做业务操作。这个“先锁后用”的思路在数据库层最常见的落地方式就是SELECT ... FOR UPDATE。比如扣库存一个事务先执行SELECT stock FROM t_sku_stock WHERE sku_id SKU-1001 FOR UPDATE这条语句会立刻给 sku_id 对应的记录加上排他锁。在持有锁期间其他事务再对同一行执行FOR UPDATE、UPDATE、DELETE都会被阻塞在锁等待状态直到当前事务提交或回滚。这个锁由 InnoDB 存储引擎在索引层面维护不依赖应用层做分布式互斥单库场景下可靠性很高。悲观锁最大的优点是控制力强业务逻辑写起来很直观先锁、再判断、再修改整个流程是串行化的。缺点也同样明显锁持有时间越长并发吞吐就越低一旦客户端拿到锁之后没提交也没回滚后面所有请求都会排队甚至触发锁等待超时。1.2 乐观锁靠版本号避免冲突乐观锁则完全反过来它假设大多数情况下没人跟你抢所以读数据的时候不加锁只在最后更新那一刻做一次校验。经典的实现方式是给表加一个version字段每次更新都把版本号当成条件带进UPDATE语句。大概长这样UPDATE t_sku_stock SET stock stock - 1, version version 1 WHERE sku_id SKU-1001 AND version 3;如果执行后受影响行数是 1说明更新成功期间没有别人动过这一行如果受影响行数是 0说明 version 已经变化数据被其他事务抢先修改了这时需要决定是重试、报错还是走补偿逻辑。乐观锁不会阻塞其他事务适合读多写少、冲突概率低的场景。但你要明白它本质上不是数据库锁而是“CASCompare And Swap思想”在业务层的落地。真正的判断发生在UPDATE的 WHERE 条件里能不能生效取决于你写的条件是否足够精确。为了直观对比我把两个方案的差异整理成一张表对比维度悲观锁乐观锁核心思想认为冲突必然发生先加锁再操作认为冲突概率低更新时再做校验常见实现SELECT ... FOR UPDATE、UPDATE行锁version 字段、状态字段、条件更新锁粒度数据库行锁持锁期间阻塞他人无锁靠语句执行结果判断冲突冲突成本锁等待、排队、超时、死锁更新失败后重试或补偿适用场景写冲突频繁、强一致要求高的场景读多写少、冲突概率低的场景吞吐表现并发高时容易成为瓶颈并发高但冲突少时表现更好这一节的结论很简单不要因为题目里有FOR UPDATE就把悲观锁当成默认答案。先判断业务冲突频率再选方案。2. SELECT ... FOR UPDATE 的真实语义与底层原理很多教程对SELECT ... FOR UPDATE的讲解停在“给查询结果加锁”这层可一旦深入生产环境你需要知道它加的是什么类型的锁、锁在索引上还是数据行上、在什么隔离级别下会扩大到间隙锁。搞清楚这些你才不会把一个普通的查询改成一条“锁全表”的定时炸弹。2.1 它锁的到底是什么先要理解 InnoDB 的 MVCC 机制。普通SELECT走的是快照读不会加锁读取的是某个历史版本的数据而SELECT ... FOR UPDATE属于当前读它必须读取最新已提交版本并且对命中的记录加排他锁。这里的“命中记录”需要进一步区分。InnoDB 的锁是加在索引上的索引命中才能实现行级锁。如果你用主键或者唯一索引作为 WHERE 条件锁的范围就是一个明确的记录锁如果你用的是普通二级索引作为条件锁的范围不但包含命中的记录还可能在REPEATABLE READ隔离级别下产生间隙锁或临键锁用来防止幻读和防止其他事务在间隙里插入数据。举个例子SELECT * FROM t_order WHERE order_no 202501010001 FOR UPDATE;如果order_no是唯一索引事务拿到的是单行锁范围非常精确。如果order_no只是普通索引InnoDB 会锁住所有 order_no 值相等的记录同时在相邻记录之间的间隙也加上锁这意味着即使某条记录现在不存在其他事务想在这个间隙内插入也会被阻塞。另外还有一个容易被忽略的点FOR UPDATE只对 InnoDB 引擎有效。MyISAM 不支持事务也没有行级锁你在 MyISAM 表上写FOR UPDATE它只会沉默地忽略掉根本起不到任何并发保护作用。2.2 为什么必须在事务里用SELECT ... FOR UPDATE产生的锁默认要等到事务提交或回滚才会释放。如果不在事务中执行MySQL 会按照 autocommit 模式把每条语句当成独立事务自动提交锁还没发挥作用就没了。所以正确写法一定是这样START TRANSACTION; SELECT stock FROM t_sku_stock WHERE sku_id SKU-1001 FOR UPDATE; -- 执行业务判断例如判断库存是否充足 -- 执行更新操作 UPDATE t_sku_stock SET stock stock - 1 WHERE sku_id SKU-1001; COMMIT;在应用层你通常依赖事务框架来管理边界。比如在 Spring 里事务注解会保证同一个事务内的所有 SQL 走同一个数据库连接锁才能持续持有到方法结束。如果框架配置不当一个事务内先后从连接池拿了两个连接锁在第二个连接上可感知不到。2.3 索引和隔离级别决定了锁的范围锁的范围不是一个固定值它和 WHERE 条件是否走索引、索引是否唯一、当前事务隔离级别都有关系。最大概率“翻车”的场景就是 WHERE 条件没有走索引。InnoDB 在无法通过索引定位记录时只能全表扫描。这时候你写一个FOR UPDATE它会对扫描过程中遇到的每一行都加上锁表面上你还以为只锁了某个条件下的数据实际已经把所有记录全锁上了。后果就是所有针对这张表的写操作都被阻塞和表锁没有本质区别。要提前判断请养成查执行计划的习惯EXPLAIN SELECT stock FROM t_sku_stock WHERE sku_id SKU-1001 FOR UPDATE;重点看type列和key列。type是const、eq_ref、range这类值说明索引利用得比较好如果看到ALL基本可以确定这条语句在做全表扫描锁的范围会远大于预期。还有两种情况会让索引失效对索引列做函数运算或者数据类型不匹配触发隐式转换。比如sku_id是 varchar写条件时却传了数字MySQL 可能放弃索引锁的范围瞬间变大。3. 从需求出发什么时候该用悲观锁什么时候该用乐观锁聊完底层原理我们把问题拉回业务。同样是“扣库存”这个经典需求悲观锁和乐观锁都能做但体验差很多。3.1 库存扣减场景对比用悲观锁流程是这样的START TRANSACTION; SELECT stock FROM t_sku_stock WHERE sku_id SKU-1001 FOR UPDATE; -- 业务层判断 stock 是否大于 0 -- 如果不够直接回滚 UPDATE t_sku_stock SET stock stock - 1 WHERE sku_id SKU-1001; COMMIT;这段逻辑能保证同一时间只有一个事务在操作 SKU-1001 的库存不会出现超卖。但如果这个 SKU 同时被几万个用户抢大家都会在SELECT ... FOR UPDATE这一步排队系统吞吐量会明显下降。用乐观锁做同样的事逻辑会轻很多SELECT stock, version FROM t_sku_stock WHERE sku_id SKU-1001; -- 业务层判断 stock 是否大于 0 UPDATE t_sku_stock SET stock stock - 1, version version 1 WHERE sku_id SKU-1001 AND version 3 AND stock 1;这条UPDATE把version 3和stock 1同时作为条件相当于一次原子校验版本没变、库存够才允许扣减。如果影响行数为 0说明这行数据已经被其他请求改过了业务层可以选择重试。3.2 选型判断标准我一般会从四个维度判断冲突概率。冲突概率高比如秒杀、抢购悲观锁的排队机制反而能避免大量无效重试冲突概率低比如普通商品下单乐观锁更合适。锁持有时间。如果拿到锁之后要做大量非数据库操作比如远程调用、消息发送千万别用悲观锁。锁持有时间长后面的请求全部排队非常容易拖垮系统。一致性要求。账务、库存这类对准确性要求极严的场景悲观锁更直观可控日志、计数、状态流转类操作乐观锁足够。失败成本。乐观锁更新失败后需要重试或补偿如果业务上不允许失败重试逻辑又复杂那就要慎重考虑。需要特别提醒一点不要在小流量系统里盲目追求乐观锁。一个每天只有几百笔交易的内部系统用悲观锁一点问题都没有反而代码更简单。引入乐观锁、重试、版本回滚这些机制会让你牺牲可读性去换一个当前阶段用不上的吞吐量。技术选型永远要对着业务规模来。4. 实操一个悲观锁扣库存的完整落地下面的例子我直接用“某商城库存表”来说。真实项目里的表往往更复杂但核心结构基本大同小异关键在索引设计和一个严格的事务流程。4.1 建表与索引设计CREATE TABLE t_sku_stock ( id BIGINT PRIMARY KEY AUTO_INCREMENT, sku_id VARCHAR(32) NOT NULL, stock INT UNSIGNED NOT NULL DEFAULT 0, version INT UNSIGNED NOT NULL DEFAULT 0, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_sku_id (sku_id) ) ENGINE InnoDB DEFAULT CHARSET utf8mb4;这里最核心的设计是把sku_id建成唯一索引。为什么强调唯一因为在FOR UPDATE查询时唯一索引能让 InnoDB 明确锁住单行记录锁范围最小。如果你只建普通索引哪怕sku_id是等值条件InnoDB 也可能在REPEATABLE READ级别下锁住一段范围引起不必要的阻塞。4.2 标准事务流程完整的扣减流程分为五步开启事务。用SELECT ... FOR UPDATE锁定目标 SKU 的库存行。在应用层判断库存是否充足。执行UPDATE扣减库存。提交事务如果库存不足回滚事务。对应 SQL 如下START TRANSACTION; SELECT stock FROM t_sku_stock WHERE sku_id SKU-1001 FOR UPDATE; -- 代码里判断如果 stock 1ROLLBACK -- 如果 stock 1执行下面的 UPDATE UPDATE t_sku_stock SET stock stock - 1 WHERE sku_id SKU-1001; COMMIT;这里有一个很容易被忽略的细节SELECT FOR UPDATE锁住的记录和后续UPDATE想要修改的记录必须是同一条。如果在第一步用sku_id定位第二步却用id去更新就可能出现锁行和更新行不一致的情况导致更新被其他事务阻塞或者更新后当前事务又去抢另一行锁凭空增加死锁概率。所以建议 WHERE 条件始终保持一致。4.3 灭霸级兜底原子条件更新有了悲观锁其实还可以给UPDATE再加一道原子条件把“判断库存”和“扣库存”合并到一条语句里UPDATE t_sku_stock SET stock stock - 1 WHERE sku_id SKU-1001 AND stock 1;这条语句本身就具备了防超卖能力即使没有FOR UPDATE它也能保证不会把库存扣成负数。在用了悲观锁的情况下再加这个条件属于防洪堤外的第二道保险用于避免应用层判断和实际执行之间出现意外。我做项目时习惯“先用 SELECT FOR UPDATE 锁行再用 UPDATE 的 stock 1 兜底”双重保障虽然有些冗余但对账务类、库存类核心业务来说这种冗余是值的。上线时再顺手EXPLAIN一遍确认sku_id走的是uk_sku_id锁范围没有问题。5. 乐观锁实战版本号加上重试逻辑乐观锁没有FOR UPDATE那种明显阻塞代码写起来也很清爽但真正的难点在冲突后的处理。下面这套是我在项目里总结出来的标准做法。5.1 版本号更新语句先读数据SELECT stock, version FROM t_sku_stock WHERE sku_id SKU-1001;在内存里把库存算好然后带着旧版本号执行更新UPDATE t_sku_stock SET stock stock - 1, version version 1 WHERE sku_id SKU-1001 AND version 5 AND stock 1;注意stock 1这个条件不能省。如果库存已经被扣到 0后面的请求就算 version 匹配也会因为不满足库存条件而更新失败影响行数为 0这比单纯靠 version 判断更能兜住“库存不足”的情况。5.2 重试与补偿乐观锁更新失败不代表业务就可以直接失败。大多数时候用户点了一次下单你总不能让用户反复重试。更合理的做法是在应用层做有限次重试比如最多重试三次每次都重新读取最新 version再执行更新。伪代码如下for i in 1..3: row select stock, version from t_sku_stock where sku_id SKU-1001 if row.stock 需要扣减的数量: return 库存不足 affected update t_sku_stock set stock stock - 扣减数量, version version 1 where sku_id SKU-1001 and version row.version and stock 扣减数量 if affected 0: return 扣减成功 // 如果没有 affected 行说明 version 被其他事务改了继续下一轮重试 return 请求繁忙请稍后再试这里最容易做错的是“无脑重试”。如果你判断库存不足后仍然继续重试系统会因为重复查询和更新把数据库压力打满。重试只应该针对 version 冲突不应该针对库存不足这是两条完全不同的失败路径务必分开处理。如果业务对一致性要求极高还可以把失败记录写入消息队列由异步任务做补偿。不过补偿逻辑本身就是另一套复杂度规模没到一定阶段不要主动引入。6. 避坑指南与死锁排查SELECT ... FOR UPDATE 用得深了免不了会碰到锁等待超时和死锁。很多新手遇到Deadlock found就慌其实只要抓住规律问题并不难排查。6.1 死锁的典型来源死锁最常见的原因是多个事务持有锁的顺序不一致。比如有两个 SKUA 和 B。事务 1 先锁 A再锁 B 事务 2 先锁 B再锁 A。事务 1 等待 B事务 2 等待 A两边都持有一把对方需要的锁谁也等不到谁InnoDB 检测到死锁后会主动回滚其中一方。避免死锁最重要的原则是让所有事务按照固定顺序访问资源。在应用层如果同时需要操作多个 SKU先把 SKU 编号排序再逐个FOR UPDATE。这样事务 1 和事务 2 都先锁 A 再锁 B就不会形成环。批量更新更是重灾区。比如UPDATE t_sku_stock SET stock stock - 1 WHERE sku_id IN (B, A, C);如果另一条 SQL 用IN (A, C, B)执行两次更新加锁顺序完全不同就可能互相等待。写代码的时候最好把集合排序后再拼 SQL从源头消除顺序差异。6.2 事务隔离级别的影响MySQL 默认隔离级别是REPEATABLE READ这个级别下SELECT ... FOR UPDATE对非唯一索引的范围查询会产生间隙锁阻塞其他事务在范围内插入新记录。好处是彻底防幻读坏处是并发度下降而且更容易和别人发生锁冲突。如果业务上允许可以考虑把隔离级别改成READ COMMITTED。在这个级别下InnoDB 对普通查询和更新主要使用记录锁间隙锁的触发场景大大减少锁范围更小、等待概率更低。很多互联网业务最终选择READ COMMITTED核心目的就是减少间隙锁造成的无谓阻塞。但隔离级别的调整会影响全局不能一拍脑袋就改。比如REPEATABLE READ能保证同一事务内多次读取结果一致READ COMMITTED则每次读到的都是最新已提交数据。如果业务对可重复读有依赖改级别反而可能破坏业务逻辑。6.3 现场排查命令线上碰到锁等待超时第一件事不是重启应用而是查看当前事务和锁状态。先看正在运行的事务SELECT * FROM information_schema.INNODB_TRX\G重点观察trx_started、trx_state、trx_query判断哪些事务长时间未提交。再看锁等待关系SELECT * FROM information_schema.INNODB_LOCK_WAITS; SELECT * FROM information_schema.INNODB_LOCKS;如果 MySQL 版本较新字段名会略有变化但思路一致先找到持有锁的事务再找到等待锁的事务最后看等待事务在执行的 SQL 是否真的需要锁那么多记录。发生死锁时最直接的证据在SHOW ENGINE INNODB STATUS;输出里找到LATEST DETECTED DEADLOCK这段里面会列出两个事务分别持有的锁、等待的锁以及对应的 SQL 语句。我之前排查过一个线上死锁就是因为两个服务在同一个事务里更新了三张表但表顺序不一样后来把公共更新工具统一了一下死锁当场消失。7. 常见问题速查表最后把我在工作中被问到最多的几个问题整理成速查表适合直接收藏。问题可能原因建议处理方式FOR UPDATE 好像没生效表是 MyISAM、没开事务、autocommit 为 1确认引擎为 InnoDB并用START TRANSACTION包住 SQL明明只查一行却感觉锁了全表WHERE 条件没走索引或发生隐式转换用EXPLAIN查看type确认索引被使用查询条件相同两个事务却互相阻塞查询走非唯一索引产生了间隙锁尽量用唯一索引定位或调整隔离级别锁等待超时持有锁的事务执行时间过长或持有锁后未提交缩短事务、避免在事务中做远程调用、检查连接池配置出现 Deadlock found多个事务加锁顺序不一致固定多个资源的加锁顺序避免批量更新顺序差异乐观锁更新失败频繁冲突确实多或者重试策略不合理评估是否该改用悲观锁并区分 version 冲突和库存不足FOR UPDATE 查询的记录不存在锁范围可能扩到间隙阻塞其他插入确认业务上是否能接受这种阻塞或考虑用唯一索引这张表里出现的每一个问题我都实际踩过或帮同事排查过。插一条个人经验很多锁问题并不是 SQL 写错而是代码里的事务边界过长。有人喜欢在事务里调用外部接口一等就是几百毫秒外部接口超时再等重试锁就这样被拽住不放。所以我对事务内操作有一条铁律事务里只做数据库操作任何 RPC、外部调用、消息发送都移出事务外。最后再分享一个小习惯每次写完一条带锁的 SQL我都会顺手执行EXPLAIN检查执行计划再看一眼事务里有没有非数据库操作。这两步加起来不过一分钟但能挡住绝大多数并发事故。锁这个东西结构对了、索引对了、事务边界对了剩下的问题基本都是可预期的真正可怕的是你以为自己在锁单行实际却把整张表锁成了一条单车道。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑