资讯动态

乐观锁与悲观锁原理、实现细节及业务选型实战指南

发布时间:2026/10/19 5:52:08 来源:尧图企业网站定制
数据库并发场景里锁机制永远是绕不开的话题也是面试和技术方案设计里出镜率最高的几个点之一。很多人对乐观锁和悲观锁的认知停留在“乐观锁用版本号悲观锁用for update”这个层面但真到线上排查问题或者设计高并发接口的时候才发现这两个概念背后的细节远比想象中复杂。这篇文章我用实际项目里的经验把两种锁的原理、实现、适用场景和坑位一次讲清楚。先给出最简单的理解框架悲观锁是“我锁住数据你们谁都别动”乐观锁是“你们随便动提交时对账对不上就重来”。这两句话背后对应的是完全不同的并发控制策略也决定了它们在不同业务场景下的表现天差地别。1. 并发控制的核心矛盾要理解锁先得搞清楚锁到底在解决什么问题。我们都知道数据库要保证事务的ACID特性其中一致性Consistency和隔离性Isolation在并发场景下直接冲突。多个事务同时对同一行数据做读写操作时如果没有控制机制就会出现丢失更新、脏读、不可重复读、幻读这些问题。1.1 一个典型的并发写入事故拿最常见的库存扣减来说。假设商品表里有一件商品的库存是10件两个用户同时下单购买各自扣减1件。理想情况下库存应该变成8件但如果两个事务同时读到库存为10各自在内存里减1然后再写回9最终库存就是9而不是8。这个过程中损失的1件库存就是并发写入造成的丢失更新问题。这类事故在业务真正跑起来之前很难被发现因为单用户测试、功能测试阶段根本不会触发并发写入路径只有压测或者线上流量进来之后才会暴露。1.2 锁的两个维度数据库层面的并发控制主要看两个维度锁的粒度表锁、页锁、行锁和锁的模式共享锁、排他锁。乐观锁和悲观锁并不是数据库底层的锁模式而是业务层面实现并发控制的两种策略。换句话说乐观锁和悲观锁更像是“设计模式”底层的实现还是要借助数据库提供的锁机制或者事务隔离级别。这个认知很重要。很多初学者以为乐观锁就是不加锁这其实是误解。乐观锁在读取阶段确实不加锁但在更新阶段它依赖“原子更新操作”来保证一致性本质上是一种权衡——用业务的失败重试换取更高的并发吞吐。2. 悲观锁数据库层面的硬控制悲观锁的思想是“我假设一定会发生冲突所以在我读取数据的时候就直接锁定直到事务结束才释放”。MySQL里实现悲观锁最常用的方式就是SELECT ... FOR UPDATE。2.1 SELECT FOR UPDATE 的使用细节SELECT FOR UPDATE是InnoDB引擎下的一种行级排他锁。执行这条语句时MySQL会对查询命中的索引记录加锁其他事务对这些记录的读操作普通读不受影响但写操作和FOR UPDATE的读操作都会被阻塞直到当前事务提交或回滚。-- 事务A START TRANSACTION; SELECT stock FROM product WHERE id 1 FOR UPDATE; -- 拿到结果后执行扣减 UPDATE product SET stock stock - 1 WHERE id 1; COMMIT;这个过程中如果事务B也执行同样的SELECT ... FOR UPDATE它会被阻塞住直到事务A提交后才会拿到锁并读取最新的库存数据。这个就是悲观锁的核心流程——通过数据库底层的锁机制保证同一时刻只有一个事务能操作这条数据。2.2 悲观锁适用场景悲观锁非常合适写冲突比较频繁的业务。比如库存扣减、账户余额扣减、优惠券领取这类业务绝大多数并发请求都在竞争同一份数据悲观锁直接串行化处理逻辑最简单也最容易保证数据一致。悲观锁还有一个隐性优势读取即锁定的语义非常明确代码阅读者一眼就能看出这段逻辑是有并发控制的。相比乐观锁需要在更新后检查影响行数、判断是否需要重试悲观锁的业务代码看起来更“傻瓜式”。2.3 悲观锁的代价悲观锁的问题也很明显锁等待会直接降低吞吐量。并发量大时大量事务阻塞在FOR UPDATE上数据库的连接池和事务资源被长时间占用系统整体的响应时间会变长。如果某个持锁事务执行了慢查询或者长时间不提交后面排队的事务会越积越多甚至拖垮数据库。另外需要特别注意的是FOR UPDATE锁是事务级的锁必须在事务提交或回滚后才释放。如果应用层忘记提交事务或者事务里混入了耗时的外部调用比如调用第三方接口、远程缓存操作锁的持有时间会被无限拉长这是线上最常见的悲观锁事故来源。注意SELECT FOR UPDATE 只在 InnoDB 引擎下有效MyISAM 不支持事务和行锁。使用前确认你的表引擎是 InnoDB。3. 乐观锁应用层的对账机制乐观锁的思想正好相反——它假设冲突发生概率很低所以读取数据时不加锁允许多个事务同时读取和操作但在提交更新时校验数据是否被其他事务抢先修改过。如果发现已经变了就拒绝本次更新或者重试。3.1 版本号机制最经典的乐观锁实现是版本号机制。业务表里加一个version字段每次更新数据时把版本号加1更新条件里带上版本号。如果更新影响的行数为0说明版本号不匹配数据已经被别人改过。-- 1. 读取数据拿到当前版本号 SELECT id, stock, version FROM product WHERE id 1; -- 结果: id1, stock10, version3 -- 2. 扣减库存条件里带上版本号 UPDATE product SET stock stock - 1, version version 1 WHERE id 1 AND version 3; -- 影响行数为1表示更新成功 -- 影响行数为0表示版本号被其他事务修改过需要重试为什么条件是version 3而不是stock 10因为版本号是专门为并发控制设计的字段值只会被本机制修改不会受到其他业务逻辑的干扰。如果用库存值做条件万一有人直接改了库存而不动版本号判断就会失效。3.2 乐观锁的时间戳方案除了版本号还可以用时间戳实现乐观锁。表中加一个update_time字段更新时校验这个时间戳和之前读取到的一致才允许更新。这种做法本质上和版本号一样但时间戳存在精度问题——如果并发操作发生在同一毫秒内两个事务读到相同的时间戳就都校验通过了。高并发场景下我建议优先用版本号时间戳只适合并发量很低的场景。3.3 CAS 思想在数据库中的体现乐观锁其实对应的是CASCompare And Swap比较并交换思想。先比较再更新比较失败就重试。数据库层面虽然没有直接提供CAS操作但通过“条件更新影响行数判断”完全可以模拟出这个流程。这里有一个很容易被忽略的细节UPDATE语句本身是原子操作。也就是说即使多个事务同时执行带版本号条件的UPDATE数据库也会串行执行这些UPDATE最终最多只有一个事务能匹配到当前的版本号。这个原子性保证是乐观锁能够成立的基础。3.4 乐观锁的适用场景乐观锁最合适读多写少的场景。比如文章点赞数统计、商品浏览计数、个人信息更新这类业务冲突概率低即使更新失败重试的开销也很小。相比悲观锁乐观锁没有阻塞等待系统的吞吐量更高尤其适合读操作远远大于写操作的互联网业务。但乐观锁不适合冲突激烈的场景。如果你用乐观锁做秒杀库存扣减大量请求同时提交绝大部分更新都会失败然后被迫重试。重试次数多了数据库压力反而更大业务响应时间也上去了体验很差。4. 两种锁的对比与选型逻辑选型不是听别人说哪个好就用哪个而是要根据实际业务的读写比例、冲突频率、一致性和性能要求来决策。4.1 关键维度对比维度悲观锁乐观锁并发控制点读取数据时即加锁提交更新时校验底层依赖InnoDB行锁条件更新影响行数判断冲突处理阻塞等待锁释放更新失败后重试或拒绝系统吞吐量低锁等待消耗资源高无阻塞实现复杂度简单语义明确需要额外的版本字段和重试逻辑合适场景写冲突激烈读多写少4.2 一致性对比悲观锁的一致性是最强的。它通过数据库的锁机制实现了严格的串行化访问只要锁没有放错数据就不会出问题。而乐观锁的一致性依赖于业务代码的更新条件如果条件写得不严谨就会产生并发漏洞。举个例子如果乐观锁的更新条件写成WHERE stock 0而不是WHERE version ?两个并发事务都能通过库存大于0的校验最终库存可能变成负数。这种情况下乐观锁就形同虚设了。所以用乐观锁必须保证更新条件里包含正确的校验字段。4.3 从业务实际出发选型从我做过的一些项目经验来看选型可以按照下面这个思路来走首先判断冲突概率。同一份数据被并发修改的概率高不高如果高优先考虑悲观锁因为重试代价太大。其次评估性能要求。如果系统对吞吐量非常敏感而且冲突概率确实低乐观锁是更好的选择。再考虑一致性要求。凡是涉及资金、库存这类对一致性要求极高的数据哪怕冲突概率不高也要认真评估乐观锁是否可靠或者考虑用更严格的悲观锁甚至队列化处理。当然还有第三种方案不依赖数据库锁直接通过Redis分布式锁或者消息队列串行化处理。这种方案在超高并发场景下更常见但那是另一个话题了不在本文讨论范围内。5. 实战案例与踩坑记录前面讲了原理和选型这一节我结合实际的代码场景把两种锁的完整实现和容易踩的坑展开讲一讲。5.1 用悲观锁实现库存扣减先看一个标准的悲观锁库存扣减。任务是把商品库存减1并且记录扣减明细。START TRANSACTION; -- 1. 锁定库存记录 SELECT stock FROM product WHERE id 1 FOR UPDATE; -- 2. 拿到当前库存假设是10 -- 业务判断库存是否大于0不够则回滚 UPDATE product SET stock stock - 1 WHERE id 1; -- 3. 写入扣减明细 INSERT INTO stock_log(product_id, change_amount) VALUES (1, -1); COMMIT;这段逻辑看起来简单但有几个细节值得注意。第一FOR UPDATE必须放在事务里。如果没有显式开启事务SELECT FOR UPDATE的执行结果会在语句结束后自动提交锁马上就释放了后续的UPDATE根本没有锁保护整个方案失效。这是最容易踩的坑。第二FOR UPDATE要命中索引。如果WHERE条件没有命中索引InnoDB会先把全表记录都锁住也就是退化为表锁。并发量上来以后所有针对这张表的写操作全部阻塞性能会急剧下降。所以在应用悲观锁之前先检查WHERE条件的字段有没有合适的索引。第三锁的粒度要尽量精细。SELECT FOR UPDATE锁的是索引记录如果业务只需要锁商品id这一条记录不要用模糊查询或者范围查询否则锁定的范围可能比预期大很多导致无关事务也被阻塞。注意FOR UPDATE 在当前事务提交或回滚后释放锁。如果事务在执行过程中出现异常必须显式回滚否则锁会持续持有到超时后面的请求全部排队等待。5.2 用乐观锁实现点赞功能再看一个乐观锁的经典应用——文章点赞计数。这个场景读多写少点赞冲突概率低非常适合乐观锁。-- 1. 读取文章点赞数 SELECT id, like_count, version FROM article WHERE id 100; -- 假设结果: id100, like_count520, version5 -- 2. 点赞时执行条件更新 UPDATE article SET like_count like_count 1, version version 1 WHERE id 100 AND version 5; -- 3. 判断影响行数 -- 影响1行点赞成功 -- 影响0行version 已经被其他请求改掉说明有人在你读取之后也点赞了第三步的影响行数判断是整个方案的灵魂。很多人写完UPDATE以后不做影响行数检查直接返回成功那乐观锁就等于没加。正确的业务逻辑是这样影响行数为0时要么提示用户稍后重试要么在代码里自动重试。5.3 乐观锁重试机制的两种写法重试分为本地重试和循环重试两类。本地重试是指拿到最新版本号后再次执行条件更新循环重试是写一个循环限定最大次数超过次数后返回失败。# 伪代码示例Python MySQL for attempt in range(3): # 读取当前数据 row db.query(SELECT id, like_count, version FROM article WHERE id %s, article_id) new_version row[version] 1 affected db.execute( UPDATE article SET like_count like_count 1, version %s WHERE id %s AND version %s, new_version, article_id, row[version] ) if affected 1: return True # 3次都失败返回错误提示 return False注意这个重试逻辑里的版本号变化。有些人的实现是version version 1有些人是显式传入new_version。两种写法效果一样但显式传入的好处是逻辑清晰方便打日志排查。另外重试次数要限制否则极端情况下一直冲突会让单次请求卡在循环里占用应用服务器的线程资源拖垮整个服务。5.4 乐观锁在MyBatis里的实现写法如果在Java项目里用MyBatis乐观锁的写法通常是这样的。先查询拿到版本号然后在Mapper里写条件更新。// 实体类中包含 version 字段 // Mapper 接口方法定义 int deductStock(Param(id) Long id, Param(expectedVersion) Integer expectedVersion, Param(newStock) Integer newStock); // XML 或注解形式的 SQL UPDATE product SET stock #{newStock}, version version 1 WHERE id #{id} AND version #{expectedVersion}这里有个细节条件更新里的version #{expectedVersion}是传入的旧版本号而SET version version 1是把版本号递增。这两者的值在执行时是同一个字段的不同时刻值——先比较旧值再递增新值依赖的就是UPDATE语句的原子性。5.5 悲观锁和事务隔离级别的耦合用FOR UPDATE时还需要注意事务隔离级别。在默认的可重复读REPEATABLE READ隔离级别下FOR UPDATE锁定的记录在本事务内是可见的不会出现读取不到自己更新数据的问题。但如果在读已提交READ COMMITTED级别下某些情况下逻辑需要额外小心。更常见的问题是在一个大事务里混合了普通查询和FOR UPDATE查询有可能出现同一行数据在同一事务内读取值不一致的困惑。解决方法是尽量保持事务边界清晰所有加锁查询都集中在事务中靠近写操作的位置减少锁的持有时间。6. 两种锁之外的进阶思路乐观锁和悲观锁只是并发控制的地基。实际项目里我见过不少需求单纯用一种锁很难两全这时候就需要一些变体方案。6.1 分段锁与分桶思想冲突集中在一行数据时乐观锁的重试代价大悲观锁的阻塞严重可以考虑把单行数据拆分成多个桶。比如一个商品的库存拆到10个库存桶里每次扣减随机选择一个桶这样冲突概率直接降为原来的十分之一。代价是业务逻辑变复杂查询剩余库存要汇总所有桶。6.2 队列化串行处理针对高冲突场景把请求放入队列由一个消费者串行处理所有扣减操作就不存在锁竞争了。这个方案把并发问题转移成了队列积压问题好处是逻辑简单坏处是实时性稍差。对于秒杀这类场景排队本身就是一种常见体验——用户在页面上等待“正在处理中”。6.3 乐观锁配合Redis预扣减更接近生产环境的方案是Redis预扣减数据库最终一致性。比如先用Redis的原子递减操作扣减库存超卖的流量在Redis这一层就被拦截了只有成功扣减的请求才打到数据库落单。这种做法能大幅降低数据库层的并发压力但引入了缓存与数据库一致性的问题需要额外的补偿机制。这些变体方案都值得单独写一篇文章展开这里点到为止。了解了基础锁机制后再去扩展这些思路会容易很多。7. 常见问题排查速查表最后整理一份实战中高频问题的速查清单遇到类似问题可以直接参考。问题现象可能原因排查与解决FOR UPDATE 不生效数据仍然被并发修改事务没开启/提前提交检查事务注解或手动BEGIN/COMMIT的位置所有写操作都在排队数据库CPU飙升WHERE条件未命中索引退化为表锁用EXPLAIN看执行计划补索引乐观锁更新影响行数恒为0版本号每次读取后发生了变化打日志确认读取的版本号与更新时传入的版本号是否一致重试次数过多数据库压力大冲突率超出预期乐观锁选型失误考虑切换悲观锁或引入Redis队列事务长时间运行锁一直不释放事务内包含外部调用或长查询缩短事务边界外部调用放到事务外相同代码压测时好时坏锁等待超时设置过短调大 innodb_lock_wait_timeout但优先排查锁持有时间过长的问题这组问题都是我在实际业务中碰到过的。多数情况不是锁机制本身选错了而是事务边界没控制好、索引没建对、或者重试逻辑写得不严谨。我自己的经验是悲观锁和乐观锁各有千秋真正的高手在于知道“什么时候不选它们”。如果并发冲突极低直接用数据库的原子更新就够如果冲突极低但失败容忍度也低用乐观锁如果冲突率高用悲观锁如果冲突率极高同时吞吐要求极大就要考虑分桶、队列、Redis预扣减这些更复杂的方案了。锁是解决并发问题的工具不是目的别为了用锁而用锁。

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

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

免费获取报价 →
↑