数据库并发场景里,锁机制永远是绕不开的话题,也是面试和技术方案设计里出镜率最高的几个点之一。很多人对乐观锁和悲观锁的认知停留在“乐观锁用版本号,悲观锁用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; -- 结果: id=1, stock=10, version=3 -- 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 思想在数据库中的体现
乐观锁其实对应的是CAS(Compare 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; -- 假设结果: id=100, like_count=520, version=5 -- 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预扣减这些更复杂的方案了。锁是解决并发问题的工具,不是目的,别为了用锁而用锁。