
1. 先搞懂悲观锁MySQL 里那个“慢但稳”的方案1.1 悲观锁到底锁的是什么悲观锁这个概念名字已经把事情说得很直白了它默认这个操作一定会发生冲突所以在操作数据之前先把这行数据“锁死”让其他人谁也别想碰等自己操作完成、事务提交锁才释放。如果你用过SELECT ... FOR UPDATE那你已经在用悲观锁了。这个语句在 MySQL 的 InnoDB 存储引擎下会对查询命中的记录加排他锁X锁之后其他事务要更新、删除、甚至用SELECT ... FOR UPDATE再去查这行都会被阻塞直到持有锁的事务提交或者回滚。这里要特别强调一个我早期踩过的坑SELECT ... FOR UPDATE本身只是一个查询语句它不会帮你开启事务也不会帮你提交事务。锁的持有周期是从执行这条语句开始到当前事务结束为止。所以你在用悲观锁之前必须先保证自己在事务里并且事务最终要显式提交或者回滚否则锁就会一直开着直接把其他会话堵死。MySQL 默认的存储引擎是 InnoDB它支持行级锁。但要注意行锁的前提是查询条件必须能命中索引。如果你的查询条件没走索引InnoDB 会对全表的所有记录加锁这样整个表都被锁住了性能会非常难看。关于这一点后面讲锁等待问题时会再细说。1.2 SELECT FOR UPDATE 的实操细节与参数选择悲观锁最常见的用法就是针对单行或者少量行的更新操作。比如你在做一个库存扣减的功能操作流程是这样的-- 开启事务 START TRANSACTION; -- 锁定库存记录防止其他事务同时修改 SELECT stock FROM product WHERE id 1001 FOR UPDATE; -- 在业务代码里判断库存是否充足然后计算新库存 -- 假设新库存为 500 UPDATE product SET stock 500 WHERE id 1001; -- 提交事务释放锁 COMMIT;这段 SQL 里有几个点值得展开讲一下。FOR UPDATE除了标准的写法还有FOR UPDATE NOWAIT和FOR UPDATE SKIP LOCKED两种变体这在实际工程中非常有用。默认的FOR UPDATE在遇到锁冲突时会一直等待直到获取锁等待时间由innodb_lock_wait_timeout控制默认是 50 秒。如果你的系统对延时敏感等 50 秒显然是灾难所以更推荐用FOR UPDATE NOWAIT一旦发现锁被别人占用立刻报错返回然后你在业务层做重试或者友好提示。SKIP LOCKED则是直接跳过被锁定的行这种场景多用于任务队列领取——多个 worker 同时去抢一批任务已经被别人锁住的任务直接跳过而不是排队等。所以后来我做任务调度都优先用SKIP LOCKED而不是傻等。关于锁的范围还有一个容易搞混的地方SELECT ... FOR UPDATE并不只锁查询出来的行如果查询条件用到了索引范围比如WHERE id 100 AND id 200那这个范围里的所有记录都会被加上锁。这叫间隙锁或临键锁是 InnoDB 解决幻读问题的手段。当然这也会显著扩大锁的范围在写 SQL 时要把查询条件尽量精确到确定的行。1.3 悲观锁的核心优缺点悲观锁最明显的优点就是简单。你只要把FOR UPDATE加在查询语句后面数据的强一致性几乎就是数据库帮你保证的不需要业务代码里做太多的判断逻辑冲突时你就是等着就行不需要处理重试这类逻辑。但缺点同样明显最直接的就是性能差。每一把锁从获取到释放在并发量高的情况下就是一条单向车道后面的车只能排队等。我见过一个真实案例某业务系统的下单接口在高峰期因为大量并发请求在SELECT ... FOR UPDATE上排队接口平均耗时从 50ms 涨到了 3 秒就是这个原因。另外悲观锁还有一个隐藏成本锁是在事务内持有的一个事务如果耗时太长比如在数据库操作之外还调用了远程 RPC 或者消息队列那锁的持有时间会被无意义地拉长。这个属于设计问题但很多人会忽略。后面我会在实操章节专门讨论怎么缩短事务时间。2. 乐观锁的底层原理版本号与 CAS 是怎么配合的2.1 乐观锁不是锁而是一种冲突检测机制乐观锁就是一个名字里带“锁”但实际上不加数据库锁的方案。它的核心逻辑是默认数据不会冲突所以读的时候直接读但是在更新的时候要检查一下数据还是不是当初读到的那个版本。如果版本变了说明有人中途改过数据这次更新就失败然后你决定重试还是提示用户失败。最常见的实现方式就是版本号机制。在表里加一个字段通常是version或者update_time。每次更新的时候把版本号作为更新条件同时把版本号加一。如果影响行数是 0说明这次更新的条件已经不满足了也就是版本冲突了。这种方式底层用的其实是 MySQL 的原子更新和行锁所谓“乐观锁”并没有额外的数据库锁机制它本质是靠唯一更新条件和原子操作来保证并发安全。但它确实能规避长事务中悲观锁的等待问题因为整个更新操作就是一条 SQL执行得极快锁持有时间天然就短。它的判断过程类似于 CASCompare And Swap——比较当前值和目标值如果一致就更新否则放弃。MySQL 里做这一步能用一条 UPDATE 完成比你读出来再判断再更新要安全得多因为读-判断-更新之间如果有事务提交你拿到的判断结果就是过时的。直接把判断放到 UPDATE 的条件里就能避免这个竞态窗口。2.2 乐观锁更新语句的设计要点假设要实现一个文章点赞数的自增功能表结构里除了like_count还有一个version字段。那么更新语句应该这样写UPDATE article SET like_count like_count 1, version version 1 WHERE id 5001 AND version 12;这条 SQL 的作用是只有当前版本还是 12 的时候点赞数才加 1版本号变 13。如果另一个请求已经先执行了这条 SQL把版本号从 12 改成了 13那么这条语句的影响行数就会是 0因为 WHERE 条件里version 12已经不成立了。这里有几个字段设计上的细节。版本号字段的类型推荐用整数类型比如INT或者BIGINT。每次更新时直接version version 1不要用UPDATE ... SET version 老版本1这种先把值读出来再更新的写法因为中间可能有并发修改只有version version 1这种原子自增才是安全的。另一种常用的版本字段是update_time也就是最后修改时间。更新条件就写成WHERE update_time 上次读到的时间更新后把时间改成一个新值。我个人不太推荐用时间做版本原因有两个第一时间精度如果到秒两个并发请求可能拿到同一个秒级时间第二时间可以被业务代码绕过而专门用来做版本的字段不容易被误改。更新语句有几个常见的坑要注意。第一UPDATE 的条件里必须要带主键或者唯一索引。如果 WHERE 条件的列上没有索引InnoDB 会锁全表影响行数会变成全表扫描后的结果乐观锁的逻辑就被破坏了。第二更新语句的 SET 部分和 WHERE 条件部分要各自独立千万别写成SET like_count like_count 1, version 12这样版本号没有递增下一次并发更新就检测不到冲突了。第三执行完 UPDATE 之后必须判断影响行数。很多 ORM 框架对 UPDATE 影响行数为 0 的情况有自己的处理方式有的框架会把 0 当成失败有的框架会自动把输入参数同步回去如果不做这个判断等于白写了乐观锁。2.3 重试机制与 ABA 问题乐观锁更新失败之后不是只能把错误抛给用户。最常见的做法是循环重试。伪代码如下for (int i 0; i 3; i) { // 查询文章当前数据拿到最新版本号 Article article articleMapper.selectById(5001); // 基于最新数据做操作 int rows articleMapper.updateWithVersion( article.getId(), article.getLikeCount() 1, article.getVersion() ); if (rows 0) { break; // 更新成功 } // 否则进入下一轮循环重新读取最新版本 }这里的重试不是无脑重试每次重试必须重新读取最新数据基于新数据再执行更新的条件判断。如果重试超过指定次数仍然失败说明这个数据的更新频率非常高这时应该返回结果让用户稍后再试或者走另一个削峰方案比如把写操作放入队列异步处理。说到 ABA 问题这是 CAS 机制的一个经典缺陷。简单来说就是在线程 A 读取版本号后、执行更新前线程 B 先把这个数据从版本 1 改成版本 2然后又改回版本 1。线程 A 在更新时发现版本号还是 1就以为数据没有被改过于是更新成功。但实际数据已经被 B 折腾过一圈了A 的判断条件被绕过了。如果这个数据只关心最终值ABA 问题影响不大。但如果数据变化是有业务意义的比如账户余额从 100 变成 50 又变回 100这时其他线程基于 100 做的判断可能就是错的。解决办法是版本号只增不减用自增的数字版本就比较安全因为自增版本不会回退。如果用update_time作为版本就一定要保证时间戳不回退。3. 实战库存扣减与订单并发场景下的选型对比3.1 用悲观锁解决库存超卖库存超卖是电商场景里最经典的并发问题。假设商品表product里有一行 id 为 1001 的数据库存为 100。用户 A 和用户 B 同时下单都读取到库存 100然后都减 1最终库存会变成 99 而不是 98实际却卖出了 2 件商品这就是超卖。用悲观锁解决这个问题的标准写法是START TRANSACTION; SELECT stock FROM product WHERE id 1001 FOR UPDATE; -- 在业务层判断 stock 0然后计算新库存 -- 假如新库存是 50 UPDATE product SET stock 50 WHERE id 1001; COMMIT;这段逻辑里SELECT ... FOR UPDATE是关键。它保证同一时间只有一个事务能读到这行库存数据其他事务的FOR UPDATE查询会被阻塞直到第一个事务提交。所以第二个下单请求拿到的就是第一个请求修改后的库存不会再基于旧库存去做扣减。我在实际项目里给这个方案做过一次压测在 100 个并发请求下接口耗时从原来的平均值 70ms 涨到了 250ms原因很简单100 个请求基本都是串行拿锁。如果你做的是后台管理系统的简单扣减比如每天只有几百次调用这完全够用。但如果做的是高并发的下单接口这个方案就要慎重。这个方案能保证数据绝对正确代价是并发能力被锁机制限制住了。所以后来我在高并发场景下很少直接用SELECT ... FOR UPDATE而是把它留给了对一致性要求极高、并发量不高的内部接口。3.2 用乐观锁解决库存扣减同样解决库存超卖乐观锁的写法更轻量。在商品表里加一个version字段每次扣减库存的 SQL 是这样UPDATE product SET stock stock - 1, version version 1 WHERE id 1001 AND stock 0 AND version 5;这条 SQL 的逻辑是只有在当前库存大于 0且版本号还是 5 时才把库存减 1。两个并发请求同时执行这条 SQL数据库会根据行锁的规则让它们串行执行一个成功一个失败。成功的影响行数是 1失败的是 0通过影响行数就能判断是否抢到了库存。我在这里用stock 0作为条件而不是只依赖version是为了把库存不足的语义也融合进去。如果只判断version那么库存为 0 时仍可能执行stock stock - 1导致库存变成负数。加上stock 0这个条件就能一条 SQL 同时完成“版本检查”和“库存检查”。但乐观锁也有它的尴尬时刻。当并发冲突特别高时很多请求会因为版本不匹配而失败业务方收到失败提示后的体验并不好。比如秒杀场景下100 个人同时抢 10 件商品假设有 90 个人都读取到了版本号 1只有 10 个人更新成功剩下的 80 个人全部失败如果让他们立即重试又会有一批人因为版本号再次冲突而失败形成连环失败。这种情况下我一般会限制重试次数并且让重试之间做一个短暂的退避比如每轮重试间隔 50ms 的随机时间把集中冲突打散。3.3 选型建议读写比例与冲突强度决定用哪种锁很多人问我项目里到底该用乐观锁还是悲观锁我的回答都差不多先看你的并发冲突概率再看你的数据一致性要求。先说并发冲突概率。如果系统并发量很低冲突极少发生乐观锁体验最好因为省去了加锁的开销读操作完全无锁更新失败的概率很低。如果系统并发量很高冲突频繁乐观锁会导致大量更新失败用户体验会很差这种情况反而应该用悲观锁让请求排队执行虽然每个请求都要等待但至少稳定的等待时间能接受。再说数据一致性要求。悲观锁天然提供强一致性你读到的数据时刻都是最新值。乐观锁只能保证更新时发现冲突无法保证你在读数据时拿到的一定是最新值。如果你做的是转账、账户余额、订单状态这种高一致性场景优先考虑悲观锁或分布式锁。如果你做的是点赞数、阅读量、任务领取这种可以接受失败重试的场景乐观锁绰绰有余。从实现成本来看乐观锁通常只需要改表和 update 语句成本最低。悲观锁需要依赖事务和锁等待机制如果把握不好事务边界很容易把锁范围扩大引发线上故障。我建议新手优先用乐观锁练习把版本号、影响行数、重试机制都搞清楚再去碰悲观锁的锁等待和死锁问题。4. 常见的锁等待、死锁与字段设计问题排查实录4.1 死锁的形成原因与典型排查方法死锁指的是两个或两个以上事务互相持有对方需要的锁导致彼此都在等待无法执行下去。最常见的情况是事务 A 先锁了 id1 的行又想去锁 id2 的行事务 B 先锁了 id2 的行又想去锁 id1 的行。两边都在等对方释放就死锁了。在悲观锁场景下死锁现象特别常见。因为SELECT ... FOR UPDATE和UPDATE的加锁顺序如果不一样就很容易触发死锁。面试时如果你能把这个场景讲清楚面试官通常会认可你在并发处理上有实战经验。排查死锁最直接的方式是查看 MySQL 的错误日志执行SHOW ENGINE INNODB STATUS;找到LATEST DETECTED DEADLOCK部分。里面会记录死锁涉及的两个事务分别持有哪些锁、在等待哪些锁以及最后被回滚的事务信息。通过分析这个日志基本就能定位是哪两条 SQL 的顺序问题。避免死锁的通用做法有几种多个事务访问多个表时固定访问顺序。比如 A、B 两个事务都先锁 product 表再锁 order 表就不会形成环路。把大事务拆小。事务里处理的 SQL 越多加的锁越多死锁概率就越高。使用NOWAIT或SKIP LOCKED避免无限期等待。死锁还有一个特点MySQL 会自动检测并回滚其中一个事务所以死锁不会导致数据库卡死但会让部分请求失败。所以业务代码里要捕获死锁相关的异常比如Deadlock found when trying to get lock这样的报错然后做重试或者提示用户。4.2 锁等待超时为什么 SELECT FOR UPDATE 会卡很久SELECT ... FOR UPDATE执行到一半一直没返回或者报错Lock wait timeout exceeded这是悲观锁最常见的运维问题。报错后面的数字就是超时时间默认 50 秒。我曾经排查过一个真实问题一个批量任务在循环中反复执行SELECT ... FOR UPDATE由于某个事务异常没有提交导致其他任务全部卡在等待锁的状态最后大量报错。当时的解决办法是找到information_schema.innodb_trx表里运行时间最长的事务用trx_mysql_thread_id去KILL掉对应的连接。这条 SQL 在排查锁问题时非常有用SELECT * FROM information_schema.innodb_trx ORDER BY trx_started;查出长时间未提交的事务后再用KILL 线程ID杀掉它。这种问题多数不是锁本身设计错了而是事务边界没控制好比如事务里调用了外部接口没有异常处理或者忘了提交事务。另一个锁等待的常见原因是索引失效。前面已经提过FOR UPDATE的查询条件如果没有走索引InnoDB 会把全表行锁全部加上。这时候不仅性能差还会把正常的更新全部堵住。排查方式是执行EXPLAIN看执行计划确认 key 列有值而不是 NULL。一个常见的误用是用字符类型字段的隐式转换比如字段是 VARCHAR查询条件写WHERE tel 13800001111MySQL 会把字符串转成数字去比较导致索引失效这一点务必注意。4.3 乐观锁使用中常见的几个坑乐观锁代码不复杂但工程落地时还是有不少容易忽略的细节。版本号字段暴露给前端是最大的隐患。如果你做的是 Web 项目前端能直接看到版本号那你就能手动修改版本号绕过乐观锁检测。我见过有人把 version 字段直接返回给前端被用户抓包后手动改掉导致数据更新毫无保护。所以 version 字段必须只存在于服务端读写接口都不要返回给前端。更新 SQL 的条件必须和业务逻辑的隔离级别匹配。比如在REPEATABLE READ隔离级别下普通 SELECT 是快照读读到的数据是事务开始时的快照而不是最新版本。如果你在事务开始后先做了一次普通查询拿到一个旧版本号然后同事务内执行乐观锁更新很大概率会失败。这就是为什么我建议把乐观锁的查询和更新放在同一个原子操作或者尽量不用事务包裹乐观锁逻辑。还有一个关于UPDATE影响行数的问题。在 MySQL 中如果更新的值和当前值相同默认返回的影响行数是 0但实际并没有发生数据变化。这在乐观锁里可能造成误判你以为版本冲突导致更新失败实际是更新了个寂寞。解决方法是使用CLIENT_FOUND_ROWS标志或者在更新条件里额外加上版本号判断。这个坑比较隐蔽我建议做乐观锁时最好在业务层明确区分“没有更新”和“更新失败”两种情况。5. 面试常问的锁问题与思路拆解5.1 像“什么是乐观锁和悲观锁”这类问题的回答思路“MySQL 中的乐观锁和悲观锁是什么”这个问题属于数据库并发控制的入门级面试题但回答深度差别很大。如果你只是说“乐观锁不加锁悲观锁加锁”那面试官基本不会满意。更值得展开的是乐观锁为什么能做到不加锁悲观锁加的是什么锁底层为什么能保证正确性。我的回答思路一般是这样的先点出两者都是用来处理并发写冲突的手段然后明确悲观锁依赖数据库自身的行锁机制通过SELECT ... FOR UPDATE在事务中锁住记录保证整个读-判断-写过程串行乐观锁则不依赖数据库锁而是通过版本号或条件更新实现原子性更新时把版本号放入 WHERE 条件来保证不会覆盖别人的修改再用影响行数判断是否冲突。如果面试官继续追问 InnoDB 行锁的原理就要往聚簇索引、唯一索引、临键锁这些方向说。这里要特别注意InnoDB 的行锁必须建立在索引之上而且如果查询条件不能确定唯一记录锁的范围会扩展到间隙甚至整张表。能把「锁与索引的关系」讲清楚问题基本就过关了。其实面试官最想听的不是定义而是你真实项目里怎么选的。所以我在回答时一定会带一个自己的实践案例比如库存扣减在低并发下用乐观锁、高并发下改悲观锁或者反过来把选型理由说清楚。5.2 加分表达结合场景解释“为什么这样选”我特别建议在回答最后加一句“锁没有好坏之分只有适不适合当前业务场景。”然后马上给例子如果业务是扣费哪怕并发失败也不能接受账目对不上这类场景优先悲观锁用事务行锁强一致如果业务是统计类比如文章阅读量、点赞数偶尔丢一次更新也无所谓那乐观锁 重试就很合适性能好实现也简单。如果面试官追问“那你怎么判断某个场景该用哪种”你可以从三个维度回答冲突频率、一致性要求、实现成本。冲突频率低选乐观锁冲突频率高选悲观锁一致性要求极高选悲观锁允许最终一致可以用乐观锁开发团队对事务控制熟悉度不高用乐观锁更安全。另外可以提一下分布式场景。MySQL 的悲观锁只对单库单表生效一旦涉及分库分表或微服务跨库操作本地锁就不够用了需要引入 Redis 分布式锁或者 ZooKeeper 互斥锁。而乐观锁因为不依赖数据库的锁机制反而更容易跨库迁移自增版本号在分布式系统里也天然适用。5.3 容易被追问的细节这些锁到底锁什么、怎么释放面试中最容易被问倒的细节有两个第一SELECT ... FOR UPDATE在 MySQL 里到底锁住了什么第二锁什么时候释放。第一个问题如果查询条件命中主键索引InnoDB 会对主键索引对应的那条记录加锁如果命中二级索引InnoDB 会先锁二级索引再锁对应主键索引如果条件没有索引会锁整个表。在可重复读隔离级别下FOR UPDATE还会额外给扫描到的范围加间隙锁防止其他事务插入新的记录这也是悲观锁在高并发下死锁率高的一个原因。第二个问题锁的释放只在事务提交或回滚时发生。这意味着你执行完SELECT ... FOR UPDATE之后并没有马上释放锁锁还在数据库里待着直到代码走完COMMIT或ROLLBACK。如果忘写事务提交锁会一直留着所有相关请求都会被堵死。这段逻辑在代码里一定要加try-finally或try-with-resources保证事务一定会被关闭。其实锁释放还有一个细节在自动提交模式下单条UPDATE语句执行完成后会立即提交锁也立即释放。但SELECT ... FOR UPDATE加上显式事务后锁的释放就变成了人工控制。这一点一开始不习惯的话很容易出现“代码里查了一句锁结果后面一堆请求卡住”的线上问题。综合来看锁相关的问题说难不难说简单也不简单关键看你能不能把自己的实操经历和底层原理串起来讲。最终面试官记住的是你处理过真实问题而不是你背过多少个概念。6. 关于这两个锁我最后想分享的几个实操体会一开始接触这两个概念时我也觉得很简单一个加锁一个不加锁就这么回事。但真正在项目里处理并发问题踩过几次坑后才发现锁的选择往往不是概念本身而是业务场景、事务边界、索引设计和异常处理这些细节的组合。我自己的习惯是新项目里优先默认乐观锁因为它的侵入性最小只需要加个version字段改一下 update 语句基本不会影响现有代码结构。只有等压测数据明确显示冲突率偏高或者业务逻辑里存在“必须先读再写”且一致性要求极高的场景我才会改成悲观锁。这个顺序比较稳不至于一开始就把系统锁死。另外无论选哪种锁我都建议你加好监控。锁等待时间、死锁次数、更新失败重试次数这三个指标是我判断锁方案健康度的关键信号。没有监控的锁方案就像油门踩到底却不知道发动机温度的车迟早出问题。如果你准备动手实践建议先建一张商品表写一个带version字段的乐观锁扣减库存脚本再搭一个事务里执行SELECT ... FOR UPDATE的悲观锁版本直接对比一下两个方案在并发下的表现。相信我跑完一遍数据你对这两个概念的理解会比看十篇文章都深。