
并发编程和数据库开发里“乐观锁和悲观锁”绝对算得上高频概念。很多新人第一次听到这两个词脑子里冒出来的画面是“程序里抢资源用的锁”但真到了线上环境看到一条SELECT ... FOR UPDATE或者在数据库表里多了一个version字段又容易犯迷糊这俩到底解决什么问题为什么不同团队选型完全不同写这篇东西是想把这两个思路彻底说透从底层原理、代码实现到真实业务中怎么选型、怎么踩坑一次性讲清楚。适合刚接触并发控制的开发、准备面试的求职者以及已经在项目里用过锁但没想清楚细节的人。1. 先搞清楚并发下到底在争什么聊乐观锁和悲观锁之前得先回到一个最原始的问题为什么要加锁因为多个线程或者多个请求同时操作同一份数据时会互相覆盖导致最后落库的结果不符合预期。1.1 一个库存扣减的真实场景假设商品表里有一条记录库存数是 10。用户A和用户B几乎同时下单各自扣减 1 件。理想情况下两个人操作完后库存应该变成 8。但如果没有并发控制就可能发生下面这种情况用户A读到库存 10算好要更新成 9。用户B也读到库存 10也算好要更新成 9。用户A先提交库存变 9。用户B后提交库存也写成 9。结果就是两个人各买了一件库存却只少了 1 件也就是常说的“丢失更新”。这个问题在电商、库存、账户余额、优惠券领取这些场景里非常典型。如果不加控制损失的不是一行数据是真金白银。更麻烦的是这类问题不是每次都出现而是依赖严格的时序。在低并发时怎么跑都没事一旦遇到大促、秒杀、集中放量问题就像定时炸弹一样爆发。所以做事后补偿不如事前控制而事前控制的两个主流方案就是乐观锁和悲观锁。1.2 并发下的三种经典异常要理解锁的设计得先认识并发事务产生的常见问题脏读一个事务读到了另一个事务还没提交的数据。如果对方回滚这次读到的就是无效数据。不可重复读同一个事务里同样一条 SELECT 语句多次执行结果不一样因为中间有其他事务修改并提交了。幻读同一个事务里同一个查询条件多次查询返回的行数不一样因为其他事务插入了新数据。悲观锁的思路就是从一开始就阻止别人动数据把上面这些问题尽量挡在门外。乐观锁的思路则是先放行提交时再检查有没有人插队。两者的取舍不同代价也不同。2. 悲观锁先锁门再进门悲观锁的核心思想一句话概括我默认一定会有人跟我抢所以在读数据的时候就先锁住不让别人碰。2.1 悲观锁的本质与生活类比如果你把一个共享资源比作一间只有一个座位的自习室悲观锁的思路就是管他有没有人来先把门锁上再说。别人想进就得在外边排队等里面的人出来。在编程世界里这种“排队”就是线程阻塞。悲观锁最常见的载体是数据库的行锁、表锁以及编程语言层面的同步锁。所有的操作都串行化了数据一致性当然有保证代价是并发度被压得比较低。这种方案适合什么心态的人就是宁可我慢一点也不能出错。对于强一致、写多读少、冲突特别频繁的核心数据悲观锁往往是第一选择。2.2 数据库悲观锁操作SELECT ... FOR UPDATE 到底怎么用关系数据库里实现悲观锁最典型的写法是使用 SELECT ... FOR UPDATE。看一段最朴素的库存扣减逻辑-- 在事务里执行 BEGIN; SELECT stock FROM product WHERE id 100 FOR UPDATE; -- 应用层拿到 stock 10再判断库存是否充足 UPDATE product SET stock stock - 1 WHERE id 100; COMMIT;关键就在 FOR UPDATE。这一句执行后数据库会对 id100 这条记录加上排他锁X锁。在事务提交或回滚之前其他事务如果也想对这条记录执行 FOR UPDATE 或者 UPDATE就会一直阻塞等待。这就保证了用户A扣减库存的整个事务期间用户B无法读到这条库存记录的旧值来执行扣减必须等事务结束。丢失更新的问题从根源上被消除了。从实现细节上看FOR UPDATE 生效还依赖一个前提——查询条件能走索引。如果 product 表的 id 是主键那没问题但如果开发时用了一个没有索引的字段做条件数据库为了保证正确性可能会从行锁升级成表锁直接把整张表的并发度打掉。提示FOR UPDATE 只是“告诉数据库你要锁这些行”真正的锁是在事务提交或者回滚时才释放。不要把锁的生命周期和应用层代码的生命周期搞混。2.3 两阶段锁与锁粒度问题数据库的悲观锁遵循的是两阶段锁协议事务里可以加多个锁但一旦开始释放锁就不允许再加新锁。实际表现就是锁的持有时间等于事务里第一个加锁语句到事务结束这段区间。这带来一个常见的性能杀手事务范围过大会导致锁持有太久。比如开发者在事务里先做了一次远程调用等外部门反应了 500ms 才提交那这 500ms 里所有竞争同一行数据的请求全部排队。数据库连接池一旦被这种慢事务占满整个服务的吞吐量就会断崖式下跌。锁粒度也需要小心。InnoDB 的行锁只在索引存在时生效没有索引就是表锁。哪怕有索引如果查询条件是一个范围也会锁住这个范围内的多个行。谨慎控制锁的范围是悲观锁用得好的前提。2.4 悲观锁的优缺点与适用场景悲观锁的优点非常明确实现简单对应用代码侵入小SQL 上直接加 FOR UPDATE 就行。一致性最强冲突检测机制天然规避脏写。重试逻辑简单等锁就行不需要应用层处理失败后的重放。它的缺点也摆在明面上并发度低高流量场景容易堆积等待。死锁风险高多个事务按不同顺序锁同一批资源时可能互相等待。锁开销大即使没有冲突也会阻塞其他读请求。所以悲观锁更适合冲突率非常高的写场景。比如商品库存扣减、账户余额扣减、优惠券核销这些“每笔操作都动同一行”的业务用悲观锁反而能获得稳定的响应时间。3. 乐观锁先动手冲突再回头乐观锁的名字很容易误导人因为它的实现方式压根没有“锁”数据库对象。它的核心思想是默认不会有人跟我抢所以我先随便改改完之后再检查一下有没有人抢先一步。3.1 乐观锁的实现原理为什么叫“锁”乐观锁的本质是版本号校验常用的一种形式就是 CASCompare And Swap比较并交换。它不靠数据库的锁机制而是靠一个版本字段。比如商品表加一列 version初始值为 0。每次更新库存时SQL 写成这样UPDATE product SET stock stock - 1, version version 1 WHERE id 100 AND version 0;执行后应用程序检查更新影响的行数。如果影响行数是 1说明更新期间没有其他事务动过这条记录提交成功。如果影响行数是 0说明 version 已经被别人改成其他值了这次更新失败需要重新读取最新数据再试一次。这里的关键在于WHERE version 0条件保证只有在你读到版本号的那一刻数据还是原样时才允许更新。数据库的原子性保证了“比较替换”不会被分割所以不需要额外的数据库锁。3.2 版本号、时间戳与 CAS三个落地方案除了整数版本号还有个常见做法是时间戳把 version 字段换成 update_time。每次更新时带上之前查到的 update_time 作为条件UPDATE product SET stock stock - 1, update_time NOW() WHERE id 100 AND update_time 2025-01-01 12:00:00;这个方案看起来简单但有个潜在问题时间戳的精度可能不够。如果并发量高同一毫秒内两次更新时间相同就可能出现“明明冲突了却还是更新成功”的情况。所以精度不够的数据库字段或者没有可靠时钟的环境都不建议用时间戳做乐观锁。整体可靠性比单调递增的整数版本号差。在编程语言层面乐观锁的典型代表是原子变量。比如 Java 的 AtomicInteger底层就是通过 CPU 的 CAS 指令实现的。它不加操作系统级别的锁但在并发环境下多个线程都认为自己的旧值是准确的总有一个线程会成功其他线程根据返回值决定要不要重试。分布式缓存 Redis 里的 WATCH 命令也是乐观锁思想的体现。WATCH 监视一个 key在事务执行前如果 key 被其他客户端修改过事务就会被取消。3.3 乐观锁的 ABA 问题为什么版本号能彻底解决CAS 有个著名的陷阱叫 ABA 问题线程A读到值是 1线程B把值改成 2再改回 1然后线程A执行 CAS 时发现值还是 1认为没人动过就继续操作。但实际上数据已经被改过两轮了。如果这份数据对中间过程的变更不敏感ABA 可能不是问题但有些场景下中间状态决定了整个业务逻辑是否合法。比如账户状态从“待审核”变成“已通过”又被人改回“待审核”如果只看最后状态可能漏掉重要的审核记录。变量版本号就是对付 ABA 的利器。因为 version 每次更新都会递增哪怕业务值回到了初始的值version 也不会倒退。上面那个例子变成线程A读到的 version1线程B先改成 version2再改回 version3线程A执行 CAS 时发现 version 对不上就会失败。所以实务必用整型版本号而不是单纯比较业务字段。3.4 乐观锁的优缺点与适用场景乐观锁的优点并发度高读操作不会阻塞写冲突后再处理即可。没有死锁问题资源竞争只体现在更新失败后的重试。实现对数据库侵入小只需要一条带条件更新的 SQL。乐观锁的缺点冲突率高时大量请求会失败重试反而会放大压力。需要应用层自行处理失败逻辑代码复杂度上升。需要额外维护版本字段设计表结构时要提前规划。所以乐观锁的甜区是“读多写少、冲突不激烈”的场景。典型例子包括博客文章点赞数更新、用户资料修改、下单时对订单状态机做流转。4. 实战选型库存、状态、资金分别怎么选我现在见到很多团队一碰到并发问题就条件反射地加分布式锁其实想清楚业务后乐观锁或者数据库悲观锁往往更合适。下面从几个维度对比一下方便你直接套用。下表是我常用的一张选型对照表对比维度悲观锁乐观锁加锁时机读取数据时立即加锁更新数据时校验版本冲突处理阻塞等待失败后重试实现复杂依赖数据库 FOR UPDATE简单需要额外版本字段和相关逻辑并发性能冲突多时稳定冲突少时浪费冲突少时高冲突多时反而变差死锁风险较高基本没有重试成本无需重试等待即可需要重新查询更新最适合场景写多读少冲突率极高读多写少冲突率低4.1 商品库存扣减推荐悲观锁表面上库存也是典型的写场景不少文章推荐乐观锁。但我的实际经验是秒杀和集中促销期间多个请求大概率同时命中同一件商品乐观锁的重试会成倍地打到数据库上。最初在一个模拟电商项目里我们用乐观锁处理库存扣减接口用的逻辑是“读取库存→判断充足→按版本更新”被打满时数据库 CPU 飙升大量更新影响行为 0用户体验就是疯狂重试然后提示失败。后来改成SELECT stock FROM product WHERE id ? FOR UPDATE配合事务直接把并发排队交给数据库。虽然吞吐量不像想象中那么好但胜在稳定不会导致雪崩。4.2 订单状态更新推荐乐观锁订单状态一般是从“待支付”到“已支付”到“已发货”中间可以多次流转。一次订单并发修改的概率比库存低得多。给订单表加个 version 字段每次 UPDATE 都带WHERE version ?能极大简化逻辑UPDATE order SET status PAID, version version 1 WHERE id 123 AND version 3;如果影响行数为 0说明订单状态被其他请求改过应用层重新读取最新状态再判断即可。这里不需要死锁处理也不需要长时间持有数据库锁代码逻辑很干净。4.3 资金账户需要谨慎锁或可考虑队列化账户余额属于高频写、强一致、不可超扣的场景。我建议优先用悲观锁或者在应用层做串行化处理。还有个思路是“账户级队列化”同一个账户的操作放进一个独立的队列里按顺序执行天然避免了冲突。这种场景如果硬用乐观锁重试次数多且会导致用户体验极差。4.4 分布式环境下别混淆概念有时候分布式缓存里的锁也被叫做“分布式锁”很多人会把它和乐观锁、悲观锁放在一起比较。我理解的实际是数据库悲观锁解决的是同一个数据库实例内的事务并发问题。分布式锁解决的是多个服务节点之间的互斥问题。分布式锁的实现依赖于共享存储比如 Redis 的 SETNX、数据库唯一索引、ZooKeeper 的临时顺序节点。它更像一个“分布式世界里的悲观锁”因为它也是先获得排队资格再执行操作。理解这一点能避免把两者混为一谈。5. 常见问题与排查经验无论选哪种锁真正落地时都会遇到一堆问题。这里整理几个我实际踩过或者帮别人排查过的坑。5.1 悲观锁死锁了怎么办死锁的典型场景事务1锁了 id1 再锁 id2事务2锁了 id2 再锁 id1两边互相等对方释放。排查时先在数据库里查看当前锁等待和死锁的信息。以 MySQL 为例可以用SHOW ENGINE INNODB STATUS查看最近一次死锁的详细信息里面会明确写出持有锁和等待锁的事务编号、具体操作语句。解决思路通常有几个统一加锁顺序比如不管哪个事务都按 id 升序来锁定多个资源。缩小事务范围尽早提交事务降低锁持有的时间。降低事务隔离级别比如从可重复读降为提交读能减少一部分锁。5.2 乐观锁重试会不会把数据库打垮极端情况下1000 个并发更新同一行乐观锁可能只有几个成功其余全部失败。如果每个失败请求都立刻重新查询再更新相当于 1000 个请求变成了 3000 次数据库操作冲突越严重数据库越难支持。实操上我习惯给重试设置上限比如最多重试 3 次超过就直接返回“系统繁忙”。同时配合一个小技巧重试时可以考虑指数退避第一次失败后等 10ms第二次等 100ms给高峰期的数据库一个喘息空间。注意乐观锁不适合在写冲突率超过 20% 的场景下使用。比如一个促销活动如果预估 100 个请求里有 20 个以上都命中同一行不如直接考虑悲观锁或者队列。5.3 事务范围对锁有决定性影响有人说悲观锁性能差其实很多时候不是锁的错是事务边界没控制好。我在代码里见过这样的写法开启事务调用外部 HTTP 接口更新数据库提交事务这基本是给并发场景埋雷。外部接口耗时不固定锁被白白占着。正确的做法是把外部依赖放在事务之前事务里只做必要的数据库读写和轻量计算。5.4 乐观锁的版本字段被忽略了最容易被忽视的是表结构设计。很多团队上线初期没有加 version 字段后来发现并发问题又不得不开发一个“更新回放”机制。合理的做法是只要表可能被并发更新建表时就预留 version 字段类型选无符号整数。哪怕当前不需要未来某个需求也能用得上。另外UPDATE 里不要写成SET stock stock - 1, version #{version}这样的 version 没有自增多事务一起提交时都满足 WHERE version oldVersion但是影响行数可能都为 1乐观锁就失效了。要写成version version 1。5.5 两个方案结合使用行不行可以。实际项目中我用过“乐观锁为主 悲观锁兜底”的方案。比如订单流程走乐观锁但针对特殊的管理后台操作人工退款、强制关单用悲观锁保护。只要锁的语义清晰不互相嵌套就没有问题。反而能发挥两者的优势。6. 更进一步的思考乐观锁用了就一定好吗乐观锁不是银弹它的成功依赖于“冲突率低”这个假设。系统设计里还有一种“免锁”思路核心是业务上绕开共享写。6.1 用幂等设计替代部分锁有些场景其实不需要锁。比如用户重复提交下单请求你只要在订单表里使用“用户 id 商品 id 业务单号”做唯一索引重复插入时数据库自己会拒绝。这种靠唯一约束来防重的方式完全不阻塞读也不会有重试压力。6.2 用队列串行化如果同一账户的高频资金操作是主要矛盾与其用锁硬扛不如在账户维度做分区串行化。把同一个账户的请求投递到同一队列由一个消费者按顺序处理。这样每个写操作都是单线程执行乐观锁、悲观锁都不需要了。代价是增加了异步链路需要接受“最终一致”。6.3 统一框架让锁对业务透明在稍微成熟一点的项目里我倾向于做一个简单的抽象层。业务层只声明“这个操作需要并发保护、冲突率大概多少”框架层决定是用悲观锁还是乐观锁。这样后续切换成本和业务代码是隔离的。不过要注意过度抽象也会增加复杂度适合团队规模较大的时候引入。最后分享两个我自己的实操心得第一个是最重要的任何时候都要先确认锁的生效范围。我曾经在处理一个高并发改造时把 SQL 写成了SELECT * FROM product WHERE price 100 FOR UPDATE;结果因为 price 没有索引数据库对全表加锁正常业务直接卡死。排查的时候EXPLAIN一看访问类型变成了 ALL行锁变表锁问题一目了然。乐观锁也类似一定要确认 version 条件真正参与了索引扫描而不是全表刷了一遍。第二个是并发不只存在于数据库层面。应用层如果用了多线程也要考虑同时读写共享对象。比如一个线程池里多个线程共享同一个业务对象单纯依赖数据库乐观锁可能缸里的数据已经改好了但内存里的对象还是旧的。这种情况下需要考虑把数据复制到局部变量或者对内存操作用安全的工具类。我见过不少项目数据库锁解决了但应用内缓存却因为并发写被写坏排查半天才看出是内存共享的问题。锁的选型是系统工程从应用到数据库每一层都要想清楚。理解乐观锁和悲观锁是一个后端工程师从“会写SQL”走向“会设计高并发系统”的必经门槛。花点时间跟着这篇文章把原理和场景摸透自己去写一个库存扣减的 Demo多模拟几次并发收益比看十篇泛泛的面试文章都大。