资讯详情

资讯详情

MySQL并发控制:乐观锁与悲观锁的底层原理、选型与实战避坑

平时聊到MySQL并发控制绕不开两个词乐观锁、悲观锁。不少人在面试时能背出定义——悲观锁是先拿锁再操作乐观锁是提交时看版本号——但真回到项目里问题就复杂了。库存扣减到底该用哪种锁订单状态流转用版本号怎么处理重试为什么FOR UPDATE明明只更新一行隔壁请求却排起了长队这篇文章我把两个锁的底层原理、SQL写法、使用场景和排坑经验全部梳理清楚。核心解决三件事让你能说清两种锁各自解决了什么问题给出可以直接抄的SQL和事务写法把死锁、锁等待超时、版本冲突这些实战坑提前帮你踩掉。内容对刚接手并发业务的后端开发者很友好也适合准备面试需要把概念讲深讲透的朋友。1. 并发场景下的锁到底解决什么问题1.1 数据不一致从哪来锁要堵住哪条路先看一个最经典的库存场景。商品表里 stock 10两个用户同时下单每个用户都要先查库存再执行扣减。如果两个请求同时读到 stock 10各自执行UPDATE ... SET stock stock - 1最后库存会变成 9而不是 8。这就是典型的丢失更新问题。数据库默认的隔离级别只保证了事务之间看不到中间状态比如可重复读但控制不了两个事务基于同一份旧数据做计算。要堵住这条先读后写的路核心思路是把一组读和一组写串行化让多个事务不能同时进入同一个关键区域。数据库的锁机制干的就是这件事。这里我要特别强调一个认知锁不是数据库自己加的规矩而是业务为了数据一致性主动选择的工具。MySQL的InnoDB默认会加一些必要的锁来保证自身事务的隔离性但像读库存、判断是否充足、扣减、回写这种业务上的读改写序列需要业务代码显式加锁数据库不知道你的意图。很多初学者把锁理解成数据库自动保证的结果一遇到并发数据错乱就懵了其实从查询到更新这段空窗期全靠开发者自己判断该上什么锁。1.2 乐观锁和悲观锁的底层思维完全不同如果把两个锁放到一个比喻里看差别就很直观了。悲观锁像会议室预约。你想用会议室先在前台登记并拿到钥匙其他人只能排队等。你用完归还下一个人再用。好处是永远不会有两个人同时进一个房间坏处是只要有人占用哪怕房间空着别人也进不去等待时间不可控。乐观锁像共享文件的版本管理。大家都能同时编辑不提前锁定任何人的权限。每次保存时系统检查你基于的版本是否还是最新版不是的话就拒绝保存让你拉取最新内容重新改。好处是大部分人编辑时不受限制坏处是冲突发生时你需要重做一遍。对应的数据库机制是这样的悲观锁用SELECT ... FOR UPDATE这类显式行锁把并发直接串行化乐观锁不锁资源只在 UPDATE 时通过版本号或条件判断发现数据被改过就返回失败或提示重试。两者背后是对冲突概率和重试成本的不同假设没有谁绝对碾压谁。1.3 锁粒度从行锁到表锁再到间隙锁要聊这两种锁还得先提一个底层概念锁粒度。悲观锁的 FOR UPDATE 在 InnoDB 里默认锁的是行但这不是绝对的判断标准是查询条件是否走索引。如果 WHERE 条件命中一条明确的索引记录InnoDB 会锁住那一行其他事务更新同一行会阻塞更新其他行不受影响。但如果条件没有索引MySQL 只能扫描全表才能定位数据这时候 InnoDB 会对扫描过程中碰到的所有记录加锁表现上就跟锁表一样。这也是很多项目里明明只锁一行却卡死全表的根本原因。另外在可重复读隔离级别下InnoDB 还会用间隙锁和 next-key lock 来防止幻读也就是锁住一个区间而不只是单条记录。这些细节在悲观锁的实操里面特别重要后面我会展开说。理解锁粒度是判断一个并发方案到底行不行的前提。2. 悲观锁的工作机制与实操要点2.1 FOR UPDATE 怎么用锁的到底是什么悲观锁在 MySQL 里最常见的落地方式是SELECT ... FOR UPDATE。它和普通 SELECT 最大的区别普通 SELECT 是快照读不加任何锁FOR UPDATE 是当前读会对命中的记录加排他锁X锁并且事务结束前不会自动释放。最简单的正确用法长这样BEGIN; SELECT stock FROM product WHERE id 1001 FOR UPDATE; -- 业务逻辑判断 stock 是否足够 UPDATE product SET stock stock - 1 WHERE id 1001; COMMIT;这个事务里SELECT FOR UPDATE 先把商品行的锁拿到期间其他事务如果也想对这行执行 FOR UPDATE 或者 UPDATE就会卡住直到第一个事务 COMMIT 或 ROLLBACK 释放锁。这样能避免两个订单同时读到相同的 stock 值。这里有一个必须说明的点FOR UPDATE 必须和事务配合。如果客户端执行完SELECT ... FOR UPDATE之后立刻提交了事务锁就在 COMMIT 时被释放了等于白锁。更常见的错误是用连接池时事务边界被一些 ORM 框架自动提交导致锁了等于没锁。我在排查中遇到过类似的问题某服务方法上没有标注事务注解FOR UPDATE 语句执行完就自动提交库存照样超卖。这个坑排查起来非常隐蔽因为单看 SQL 没什么问题问题出在事务边界没有覆盖住锁的持有区间。2.2 间隙锁、next-key lock 什么情况下会冒出来很多人以为 FOR UPDATE 锁的是单行但在 RR 隔离级别下事情更复杂一点。如果查询条件没有用唯一索引精确匹配InnoDB 会在扫描到的索引区间上加上间隙锁或 next-key lock锁住的不只是命中的行还有这个区间内不存在的行。比如说某表里 id 有 1、5、10 三条记录你执行SELECT * FROM product WHERE id BETWEEN 3 AND 8 FOR UPDATE虽然没有 id 为 3 和 8 的记录InnoDB 为了阻止其他事务向这个区间插入数据引发幻读会锁住 (1,5]、(5,10] 这些边界范围。结果其他事务连往 id7 插入新记录都会被阻塞明明这条新记录和你的更新毫无关系。理解这个机制的目的是排查莫名其妙的锁等待。不止一次看到定位慢 SQL 时发现是其他事务插入记录被间隙锁挡住。遇到这种问题通常可以考虑把隔离级别调整为读已提交RC或者把查询条件改成能精确命中唯一索引的写法。需要说明的是RC 级别下 InnoDB 会退化使用简单的行锁大部分业务在 RC 行锁的组合下已经足够。用哪一级别要根据业务对幻读的容忍度决定不能盲目照搬。2.3 悲观锁的三个常被低估的成本与坑第一个坑是事务里不要做外部调用。事务内发起 HTTP 请求、调用 RPC、等外部接口返回锁的持有时间会指数级拉长。别人都在排队等你这把锁外部服务一抖动整个系统的锁等待就爆了。悲观锁场景下事务应该只保留纯数据库操作和轻量判断。这个原则比很多人想象的更重要我见过一个接口在事务里调了两三次远程服务压测时每秒并发只到几十就把数据库连接池打满了问题根源不是 SQL 慢而是锁长时间不释放。第二个坑是查询条件必须走索引。前面已经说了索引和表锁的关系还有一个补充细节即使走了索引如果索引的选择性很差比如区分度很低的字段MySQL 优化器可能还是选择全表扫描照样触发大范围锁。所以要用 FOR UPDATE 时优先用主键或唯一索引。第三个坑是死锁。两个事务分别锁住不同的行然后互相请求对方手里的行InnoDB 检测到死锁会回滚其中一个事务抛出 Deadlock found 错误。悲观锁由于持有时间长死锁概率比乐观锁高。第5章我会专门说怎么排查。总之悲观锁用起来简单但前提是把事务边界、索引条件、锁持有时间这些基本功做好否则它带来的系统卡顿往往比并发问题本身更严重。3. 乐观锁的两种实现与落地细节3.1 版本号机制一条 UPDATE 搞定并发校验乐观锁最常见的实现是版本号。业务表里加一个 version 字段每次更新成功就把 version 加 1。更新时用 WHERE 条件带上读过的最新版本号如果期间数据被别人改过版本号对不上UPDATE 影响行数就是 0程序据此判断冲突。核心 SQL 长这样UPDATE product SET stock stock - 1, version version 1 WHERE id 1001 AND version 3;如果返回的影响行数是 1说明这次更新刚好基于最新版本操作成功。如果返回 0说明在读取 stock 之后、更新之前有人已经改过这行数据version 已经不是 3 了。此时应用层需要重试——重新查询最新版本重新走一遍业务逻辑。实现上还要注意一次 UPDATE 内同时完成扣减与版本号递增避免拆成两条 SQL 执行。如果你先 UPDATE stock 再单独 UPDATE version中间一旦出现其他事务插入版本号校验就失去意义了。为什么要版本号而不是直接交叉读取因为 UPDATE 是原子的数据库在锁定这一行执行更新时带版本号的 WHERE 条件可以精准判定更新是否合法。版本号天然堵住了 ABA 问题——即使某个值从 1 变成 1version 也已经递增过不会被旧版本误判为没有变化。3.2 CAS 操作不带版本号的乐观锁版本号是最稳的方式但不是必须的。很多场景里可以简化成 CASCompare And Set直接在 UPDATE 条件里写比较逻辑。拿库存来说最简单的 CAS 写法UPDATE product SET stock stock - 1 WHERE id 1001 AND stock 1;这句 SQL 的含义是只有当当前库存还大于 0 时才扣减。由于 UPDATE 语句自带锁与原子性多个并发请求同时执行时数据库会把它们串行执行只有库存真正 ≥ 1 的事务能成功其余影响行数为 0。这样一来无需版本号字段也能避免读到的库存充足但实际已经被扣完了的漏洞。CAS 的适用场景有限它只能用在变更条件可以用现有字段表达的操作上。比如库存、余额这种数值递减场景条件就是 stock 扣减量。如果业务是对状态做多步流转比如订单从待支付到已支付再到已发货或者要做防重放判断光用 CAS 就说不清了必须回到版本号。我在选型时有一个经验能用 CAS 一句话写清楚的场景就别为了炫技去引入版本号字段反过来状态机类的场景也别硬用 CAS 凑合版本号多一列字段的成本非常低。3.3 ABA 问题CAS 最需要防的一个暗坑CAS 一个经典暗坑是 ABA 问题。简单说事务读到的值经过一系列变化又回到最初的值从当前值角度看数据似乎没变但实际上已经发生过多次被其他事务修改的过程这些中间过程可能带着业务副作用。举个例子后台系统中一个资源数量是 1事务 A 读到 1准备把它减到 0。事务 B 也读到 1先把 1 改成 2再改回 1然后提交。事务 A 执行UPDATE ... WHERE id ? AND resource_count 1发现当前值又是 1认为没人动过成功修改。这个场景里事务 A 没有被阻止但资源的归属状态可能已经被 B 改变了。虽然在这个简单计数例子中影响不大但放到订单状态流或权限变更场景就可能产生无法回滚的脏逻辑。版本号机制天然免疫 ABA因为每次变更都伴随 version 递增即使值恢复原样版本号也变了旧版本的号对不上。所以在状态机、审批流这类对中间过程敏感的业务里我建议直接用版本号而不是简单 CAS。这个选择不是性能问题而是正确性问题。4. 选型原则与三个实战场景对照4.1 判断标准冲突频率和重试成本两种锁没有绝对的高下只有和业务场景的匹配度差距。我把选型归结成两个问题。第一个问题你的业务并发冲突概率高不高如果两个用户同时改同一条数据的概率高悲观锁能直接把并发串行化杜绝冲突重试的震荡如果冲突概率很低悲观锁会造成大量无意义的锁等待反而不如乐观锁轻快。第二个问题冲突后重试的成本高不高乐观锁冲突后需要应用层重新查询、重新计算如果这个重试过程很贵比如依赖外部接口、涉及多条记录的强一致更新那宁可悲观锁一上来就排好队。反过来如果冲突失败直接提示用户稍后再试就能接受乐观锁最合适。用一张表快速对照对比维度悲观锁乐观锁核心思路先锁后操作冲突直接排队操作后校验冲突则重试数据库锁资源长时间占用行锁、间隙锁仅在 UPDATE 瞬间使用数据库内置锁并发吞吐低受锁等待限制高无显式锁等待实现复杂度低SQL 直观中需要版本号与重试逻辑死锁风险相对高极低典型场景账户动账、强一致资源抢占订单状态流转、低冲突库存扣减这个表格不是让你背结论而是帮你建立选型框架。任何跟你说乐观锁就是比悲观锁好或者反过来的人大概率没经历过足够多样化的业务。先把冲突概率和重试成本想清楚选型就是水到渠成的事。4.2 实操场景一电商库存扣减库存扣减是锁问题的高频战场。先说结论低到中等并发场景CAS 条件更新或版本号都够用秒杀类超高并发场景MySQL 锁本身会成为瓶颈不适合硬扛。CAS 写法前面给过了一条 UPDATE 就能完成扣减冲突时返回 0应用层对失败的请求做降级提示或者短期重试。版本号写法更严谨一点先把 version 读出来再带着 version UPDATE冲突后重试。两个方案在并发量不大的后台系统里表现都够稳。实际使用中要注意取舍如果库存表本身已经有 version 字段直接沿用如果什么都没有给库存扣减加 version 的收益很有限CAS 条件就够了。秒杀场景要跳出只盯着 MySQL 锁的思维。大量请求同时打到数据库无论乐观锁还是悲观锁数据库都要逐条处理 UPDATE锁竞争和磁盘 IO 都会被放大。常见的做法是把请求量先在前面削掉比如用消息队列做异步排队或者用一种更轻量的存储做预扣减MySQL 只处理最终真实扣减的那一批。这里不展开中间件细节但一定要认识到锁解决的是数据一致性问题解决不了流量洪峰问题。4.3 实操场景二订单状态流转订单的状态机是版本号乐观锁最典型的用武之地。一笔订单从待支付变成已支付再变成已发货每步都要确保在正确的当前状态下流转。UPDATE orders SET status paid, version version 1 WHERE id 20001 AND status pending AND version 7;这样写的好处是状态和版本号双重校验杜绝了两个支付回调同时把一个待支付订单改成已支付的隐患。即便支付系统发来并发回调只有一个 UPDATE 能成功另一个影响行数为 0应用层可以安全丢弃重复通知。这是我特别推荐的地方订单状态流转最怕的不是慢而是重复回调导致状态乱跳版本号和状态条件联合起来等于给状态变更加了两道闸。把订单状态流转换成悲观锁也不是不行写个 FOR UPDATE 也可以但代价是每个读订单详情的请求也会被锁牵制这在订单这类读多写少的场景里性价比很低。这也是状态机场景几乎都选乐观锁的原因。记住一个直觉读多写少、冲突概率低、重试能接受的场景乐观锁天然占优。4.4 实操场景三账户余额与金额动账金额动账这类业务我的倾向非常明确用悲观锁或者带数据库事务的严格校验慎用裸 CAS 加重试。原因在于金额操作必须强一致重试逻辑会让失败重试变成重复扣款的隐患。比如对账户执行扣减CAS 条件可以写成 balance 扣减额但由于后续可能还要记录流水、更新汇总表如果只在应用层重试成功就继续重试失败就退出容易忽略中间已经写了一半的流水。正确做法是事务内先 FOR UPDATE 锁住账户行再校验余额再扣减再写流水最后一起提交。持有锁的时间很短对性能的影响完全可接受。还有个容易被忽视的点异步复制的场景里要特别小心乐观锁判断如果依赖从库读到的值主从延迟会让版本判断失真。遇到线上集群读主写且主从延迟不可控的情况尽量把校验放在主库完成。这个点我在某个活动系统里踩过活动结束后对账发现少部分用户被重复发放了奖励根因就在于状态判断走了从库版本号读到的还是旧值。这类问题排查起来要人命因为它不是每时每刻必现只在主从延迟的某个区间爆发。5. 常见问题与排查技巧实录5.1 死锁排查别怕数据库帮你处理了一半死锁在悲观锁场景里更容易碰到。报错信息通常是Deadlock found when trying to get lock; try restarting transaction。InnoDB 不会硬等死锁出现它内部有死锁检测机制发现环路后会自动回滚代价较小的事务让另一个事务继续执行。排查死锁的方法很固定。遇到报错后执行SHOW ENGINE INNODB STATUS;重点看 LATEST DETECTED DEADLOCK 这一段。里面会标明两个事务各自持有哪些锁、等待哪些锁以及对应的 SQL 语句。我第一次排查死锁时就是从这段记录里看到事务 A 锁了用户表某行又在等订单表某行事务 B 正好反过来锁的顺序反了。这个输出看起来有点复杂但核心信息就是两个事务的锁等待图你只需要找到互相等待的那组锁即可。要减少死锁常用的手段多个事务访问多个表时保持相同的加锁顺序尽量把一次业务操作涉及的更新收敛到一张表或一个事务里事务尽量短缩锁时间。代码层面还可以对更新操作增加重试但重试必须限定次数不能无脑循环。这里有个细节InnoDB 检测到死锁回滚的是它认为代价较小的事务所以如果你的事务执行了比较多的工作要预留好被回滚后的失败处理逻辑。5.2 锁等待超时先找谁在占着锁遇到Lock wait timeout exceeded; try restarting transaction时实质是事务等锁超过了 innodb_lock_wait_timeout 参数设定的时长默认 50 秒。这个报错不一定代表死锁更可能是有人长时间没提交事务。排查第一步是看当前有哪些事务在跑SELECT * FROM information_schema.innodb_trx;这个视图能列出未结束的事务、开始时间、执行了多长时间的 SQL以及它等待或持有了哪些锁。我惯用的排查组合是先用这条 SQL 看长事务找到 trx_started 时间很旧的会话再根据 trx_mysql_thread_id 反查对应的连接确认它卡在哪里。有时候你会直接看到一条业务 SQL 因为另一个连接持锁没释放导致后面的会话全部排队。如果发现是某个批量任务在长事务里执行了几分钟解决办法一般不是调大锁等待时间而是把批量任务拆小批次或者把事务外查询和写操作分离。调高 innodb_lock_wait_timeout 只是让症状晚一点暴露换来的是更长的系统阻塞。我见过有人直接把 wait_timeout 调到 120 秒结果就是下游接口等待数据库的链路全线超时问题反而更严重。5.3 乐观锁失效的隐蔽场景乐观锁看起来简单踩坑的姿势一点都不少。先看一个最基础的错误代码里只把 version 查出来了UPDATE 时却忘了把它写进 WHERE 条件。这种低级错误出现频率比我预期高得多好在对账测试时能暴露但线上已经影响数据了。另一个隐蔽场景是更新条件本身覆盖了太多行。之前有项目在更新某个状态字段时WHERE 只用了 statuspending没带唯一 id结果是所有待审核的数据被同一批更新版本号虽然加了范围锁也扩大了并发性能直接崩掉。正确做法是业务更新要精确到主键其他维度只能作为附加校验。这里的原则和悲观锁的场景完全一致锁的粒度越精确并发能力越强。乐观锁的重试逻辑也要小心幂等。客户端收到版本冲突后如果只是简单重新查询再提交但其实之前那次 UPDATE 已经成功了一部分就可能造成重复写入。比如扣库存的重试必须先确认本次扣减操作本身是否已经生效。保险做法是在业务表加唯一流水单据号重试时先查单据是否已存在存在就直接跳过。这属于防重治理但和乐观锁的重试放在一起实践才能既保证并发又保证最终一致性。最后一个容易被忽略的重试次数和退避策略。无限重试会让失败请求把系统压力放大合理的做法是限制重试 2-3 次并在失败后加入随机延迟避免大量冲突请求同频重试导致锁竞争越滚越烈。策略要看业务属性超时敏感的场景可以直接提示失败后台异步任务可以考虑退避重试。乐观锁本身不复杂复杂的是重试失败后怎么做才不产生脏数据。我个人在实际项目里的判断习惯是先问冲突概率再问重试成本。库存扣减这类对单一数值做递减的操作我偏爱 CAS 条件更新简单高效订单状态流转这种多步骤状态机我会坚持版本号天然免疫 ABA账户金额之类强一致操作我宁可回到悲观锁也不赌失败重试的幂等性。没有万能的锁只有适不适合场景的锁。最后再补一句经验不管选哪种锁先把索引和事务边界理清楚锁只是保证一致性的最后一道闸真正决定系统并发上限的往往是那把锁背后藏着的 SQL。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →