资讯动态

MySQL并发控制实战:SELECT FOR UPDATE与乐观锁版本号选型

发布时间:2026/10/20 8:07:20 来源:尧图企业网站定制
做后端开发的同学十有八九会遇到并发控制的问题。我第一次处理库存扣减时下意识写了SELECT ... FOR UPDATE看起来逻辑没问题结果一压测就出现了锁等待超时还被同事问了一句“你的查询真的走了索引吗”这才发现这个看似简单的语法背后藏着一堆细节。无论你是在处理订单、余额、库存还是任何需要保证并发数据一致性场景SELECT ... FOR UPDATE都是绕不开的工具但它到底锁住了什么、什么时候锁不住、和乐观锁怎么配合很多人其实没有完全吃透。这篇文章把这块掰开揉碎讲清楚从原理到实操再到排查配合上乐观锁的版本号和CAS思路你看完就能在项目里做出合理的选择。1. 先弄清楚并发控制要解决的问题1.1 丢失更新和超卖现象从哪来要理解锁先得理解问题本身。假设商品表里库存只剩1件两个用户同时下单事务A读出库存为1事务B也读出库存为1A把库存改成0B也把库存改成0。最终结果看起来是0但订单却生成了两笔——超卖了。更隐蔽的是“丢失更新”用户A和B同时修改同一行后提交的覆盖了先提交的中间某个合法操作白白丢掉了。这类问题的本质是多个事务在没有约束的情况下基于同一份旧数据做独立计算。你以为是先到先得实际是各自为政。生活里更容易理解两个人同时看到电梯里还剩一个空位都觉得能挤进去结果门关不上。这时候就需要一个机制要么让第二个人先等一下要么在关门瞬间重新确认还有没有位置——前者是悲观锁的思路后者是乐观锁的思路。1.2 悲观锁和乐观锁是两种不同的世界观悲观锁的出发点是“冲突一定会发生那我先把资源占住再说”。在MySQL里SELECT ... FOR UPDATE就是这种思想的典型实现事务查询数据时直接把目标行锁住其他事务想改这行会被阻塞直到当前事务提交或回滚。你拿着钥匙进了房间别人只能在门口等着。乐观锁则相反它默认“冲突不太常见更新的时候再校验一下就行”。不锁数据库资源而是在更新时检查数据版本是否还是原来读到的那样。如果发现别的事务已经改过了这次更新就不生效需要业务方重试或者报错。现实中的例子像健身房里的自由器械没有人给你预约锁定你去的时候如果看到器械上有别人的毛巾就知道此路不通换个时段再来。这两种思路没有绝对优劣只看业务愿意为一致性付出多少代价。2. SELECT ... FOR UPDATE 究竟锁了什么2.1 语法拆解和最基本的执行条件最基本的写法长这样SELECT * FROM product WHERE id 1 FOR UPDATE;这行语句会在事务内对product表id1的记录加一把排他锁。注意几个硬前提必须事务内执行因为锁要跟随事务提交或回滚才释放存储引擎必须是InnoDBMyISAM不支持行锁也不支持事务查询条件最好能命中索引否则可能锁住整张表。很多人会忽略“自动提交”的影响。如果你在MySQL命令行里直接执行了FOR UPDATE但没开启是一个事务体验就跟普通查询一样锁基本等于没加。不能指望单条语句自带的锁能保护后续好几步操作。正确的姿势是先BEGIN或者START TRANSACTION再执行FOR UPDATE最后COMMIT。2.2 行锁、表锁和间隙锁的边界我们常说SELECT ... FOR UPDATE是行级锁但这取决于条件。如果查询条件是主键id1InnoDB能明确锁定这一行如果条件是普通索引列除了锁住命中的记录还会对索引项之间的“间隙”加锁也就是间隙锁或Next-Key Lock用来防止幻读。间隙锁的本意是保护范围查询的一致性但也很容易成为死锁或锁等待的导火索。最危险的情况是条件列上没有索引。InnoDB没法精确定位到具体记录只能全表扫描结果就是给整张表的所有记录都加上锁等价于表锁。这个问题在线上偶尔会遇到明明只是更新一条数据却把整个表堵死。所以排查FOR UPDATE失效或性能问题时第一步永远是看执行计划里有没有走索引。EXPLAIN里的type字段出现ALL或者key为NULL基本就是锁表的前兆。2.3 普通SELECT和FOR UPDATE的区别普通SELECT在InnoDB默认隔离级别下走的是快照读也叫一致性非锁定读。它读的是历史版本不会阻塞其他事务的写操作也不需要等待别的锁。FOR UPDATE走的是当前读读到的永远是最新已提交版本并且会对目标记录加锁。两者最大的区别是FOR UPDATE会产生锁和等待普通SELECT不会。这个区别经常被误解。有人在FOR UPDATE之外还写了普通SELECT去判断状态结果发现读到的是旧数据。其实你应该只用FOR UPDATE的结果做业务判断因为它在锁的保护下是当前最新的。普通SELECT适合报表、列表这类对实时性要求不高的场景两者的使用边界要想清楚。3. 悲观锁的完整实操库存扣减怎么不超卖3.1 标准流程事务内三步走一个经典的扣库存场景可以这样拆-- 1. 开启事务 START TRANSACTION; -- 2. 锁定目标行读取当前库存 SELECT stock FROM product WHERE id 1 FOR UPDATE; -- 3. 业务判断库存是否充足然后更新 UPDATE product SET stock stock - 1 WHERE id 1; -- 4. 提交 COMMIT;第二步是关键。FOR UPDATE执行后这条记录上的锁会一直持有到事务结束。其他事务想对同一行做更新或FOR UPDATE只能排队等待。所以从你读取库存到更新库存这个窗口期不会有人插队自然也就不会超卖。第三步的UPDATE不需要再带条件判断stock 0吗从逻辑上讲在事务内你已经拿到了锁并且在第二步做了判断第三步直接扣减是安全的。但是如果代码复杂最好还是把判断和扣减放在同一条UPDATE里用UPDATE自带的“当前读”保证原子性这样即使忘记第二步也不至于把库存扣成负数。3.2 真实项目中的细节锁顺序、超时和重试实操中容易被坑的是多个资源的加锁顺序。假设一次下单要锁库存和锁账户余额事务A先锁库存再锁余额事务B先锁余额再锁库存两个事务就会互相等对方释放锁最终形成死锁。MySQL检测到死锁后会回滚其中一个事务但你的业务代码会收到死锁异常。所以项目里要约定统一的加锁顺序比如按表名拼音排序或者按业务主键ID排序让所有事务都以相同的顺序获取资源。另一个实际问题是被阻塞的时间。默认的锁等待超时是50秒这对用户来说太久了。你可以通过设置innodb_lock_wait_timeout来调整比如把它设成5秒。同时业务侧要捕获“Lock wait timeout exceeded”异常做有限次数的重试或者直接提示用户稍后再试。不要认为加了FOR UPDATE就万事大吉它只是把并发问题变成了等待问题而等待是有限度的。3.3 一个我踩过的坑事务没有提交我早期做过一个活动系统扣减优惠券用FOR UPDATE锁定了行但代码里把业务逻辑封装在一个自调用的Service里外层事务因为异常没有回滚内层事务却提前提交了。结果锁倒是释放了但是外层业务发现数据异常回滚后内层已经被扣减的数据无法恢复。更典型的坑是事务已经开启FOR UPDATE也执行了但由于某个条件判断直接return了没有执行COMMIT或ROLLBACK连接上的事务一直挂着导致锁一直不释放。这种情况不会立刻报错但后续所有请求都卡在锁等待上。排查这类问题有个技巧查询information_schema.innodb_trx看有没有长时间未结束的事务。如果是自己写的代码记得用try/finally或者编程语言里的事务注解保证事务一定会提交或回滚。4. 乐观锁实现不加锁的并发控制4.1 版本号方案乐观锁最常见的实现是加一个version字段。每次更新的时候对版本号做一次检查以“UPDATE返回受影响行数”作为是否冲突的判定依据。例如商品表里加一个version整数列初始为0。更新逻辑变成UPDATE product SET stock stock - 1, version version 1 WHERE id 1 AND version 0;如果同时有两个请求都读到了version0第一个请求执行UPDATE把version改成1第二个请求再执行这条SQL时WHERE条件里的version0不成立update影响的行数为0。业务代码看到影响行数为0就知道更新失败需要重新读取最新version再做一次扣减。整个流程不需要显式的SELECT ... FOR UPDATE也没有数据库锁等待。4.2 状态CAS方案版本号不是唯一思路。如果你的更新条件本身就是一个状态字段可以直接拿状态做乐观锁。比如订单需要从“待支付”改成“已支付”UPDATE orders SET status 已支付, pay_time NOW() WHERE id 123 AND status 待支付;这种写法叫Compare And Set比较再更新。它的优点是省掉了一个version字段因为状态字段天然就是版本。但要注意状态值必须业务上唯一且稳定比如只能用“待支付”作为比较条件而不能用可能重复的其他字段。4.3 乐观锁的适用边界乐观锁听起来比悲观锁轻巧但它的代价在冲突频繁时非常大。假设一个热点商品的抢购每秒几千个请求都基于同一个version去更新绝大多数请求都会失败然后业务侧不断重试数据库压力反而比FOR UPDATE还要大。因为FOR UPDATE是让请求排队一次性串行执行乐观锁是让大量请求同时尝试大部分无功而返然后重新查询再尝试。所以乐观锁适合读多写少、冲突概率低、业务允许失败重试的场景。比如后台修改文章、用户更新个人资料这种操作基本不会同时争抢同一行。而秒杀、库存扣减、账户扣款这类高冲突业务乐观锁并不一定是最好的主力方案。5. 悲观锁和乐观锁怎么选5.1 业务一致性要求与响应耗时选择这两者本质上是在一致性、可用性和性能之间做权衡。如果业务要求强一致性比如资金账务那悲观锁更直接锁住一行从读取到更新完全串行不存在中间状态被破坏。代价就是吞吐量下降、响应时间因为锁等待而变长。如果业务允许偶尔失败重试比如签到、点赞、信息修改乐观锁更合适它能保持更高的并发度代码实现也简单。用表格总结一下维度悲观锁FOR UPDATE乐观锁版本号/CAS并发冲突多更适合排队处理重试压力大可能恶化并发冲突少锁开销不划算很适合性能好实现复杂度要处理事务和锁等待要处理重试和版本字段一致性保障强读取即锁定弱一些靠更新时校验是否阻塞会阻塞其他事务不阻塞自然竞争5.2 混用先版本后锁定实际项目里经常两边都上不冲突。比如在处理订单时先用SELECT ... FOR UPDATE锁住这笔订单但更新语句里仍然带上version条件。这相当于悲观锁保证“这个事务执行期间别人进不来”乐观锁保证“即使因为某些原因锁没生效或者读到了旧版本更新也不会覆盖别人的新数据”。这种双保险不会增加太多成本却能把FOR UPDATE潜在的风险兜住。具体实现顺序是先开启事务执行SELECT ... FOR UPDATE读取当前数据拿到version值然后业务处理中执行UPDATE时带上version条件最后提交。如果update影响行数为0说明存在极端情况回滚事务并重试。5.3 通用选型建议我一般优先看三个问题这个操作冲突概率高不高冲突后是否允许用户重试数据库瓶颈是不是集中在同一行上。对热点行的高并发强一致悲观锁更稳。对偶尔冲突、允许失败的场景乐观锁更省心。此外不要在一张表的所有操作上都统一用同一种锁。不同业务方法热度不同完全可以针对热点方法用悲观锁针对低频方法用乐观锁。6. 常见问题与排查技巧实录6.1 FOR UPDATE不生效的几种原因第一个原因是“自动提交没有关”。如果你在会话中执行了FOR UPDATE但事务一直没提交锁会一直存在但如果你压根没开启事务每条语句自动提交FOR UPDATE执行完就把锁释放了后续的更新还是裸奔。第二个原因是条件列没有索引InnoDB只能锁表。第三个原因是连接串里设置了autocommit1同时又期望跨多条语句保持事务。第四个原因是你在两个连接里用不同的顺序加锁导致死锁而其中一个事务被自动回滚后你以为锁没生效其实是被死锁择出来了。遇到“锁不生效”的问题先别急着怀疑MySQL先确认你真正跑的事务上下文是什么。可以通过SELECT autocommit查看如果是1手动BEGIN之后再执行FOR UPDATE。6.2 锁等待超时的排查锁等待超时的错误信息会提示“Lock wait timeout exceeded”。排查时先到information_schema库里看三张表SELECT * FROM information_schema.innodb_trx\G SELECT * FROM information_schema.innodb_lock_waits\G SELECT * FROM information_schema.innodb_locks\Ginnodb_trx可以看有哪些事务还在运行trx_started是事务开始时间trx_rows_locked能看到锁了多少行。如果发现有事务已经跑了很久而业务上却不应该这么久很可能是代码忘了提交。再看innodb_lock_waits里面会列出正在等待锁的事务和被等待的事务ID。定位到具体事务后找到对应的进程用KILL杀掉或者等它自然结束。6.3 死锁避免的经验死锁和锁等待有点像但不同锁等待是别人拿着锁你不放死锁是互相拿着对方想要的锁。避免死锁最有效的办法是统一加锁顺序。我习惯在代码注释里直接标注资源顺序比如“先锁订单后锁库存”然后在涉及多个锁的地方都遵守同一个枚举顺序。另一个办法是把大事务拆小要锁的行数越少持有锁的时间越短死锁概率越低。还有一种情况使用范围条件时间隙锁非常容易造成死锁例如用WHERE id BETWEEN 1 AND 100 FOR UPDATE同时锁了一堆间隙。业务允许的话尽量改成等值条件。7. 最后分享一点自己的体会做MySQL并发控制最直接的经验是“别把锁当万能药”。SELECT ... FOR UPDATE确实是强一致的好工具但它只解决“同一行数据的并发覆盖”问题解决不了所有业务问题也替代不了合理的索引设计和事务拆分。我多次见过生产事故不是因为锁用错了而是该用乐观锁的场景用了悲观锁导致吞吐崩掉或者该用悲观锁的场景用了乐观锁导致数据落错版本。每次上线前最好把事务内所有的SQL过一遍确认锁的范围、锁的时长、锁的顺序再用压测工具模拟两个并发请求同时操作同一行看看最终结果是否符合预期。另外多学一点InnoDB锁机制像当前读和快照读的区别这些基础几乎每天都能用到。踩过几次坑以后你会发现数据库并不复杂复杂的是我们总想在并发世界里寻找一个绝对简单的方案。

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

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

免费获取报价 →
↑