乐观锁 vs 悲观锁:并发控制的取舍
多用户同时改同一数据怎么办?乐观锁(版本号/CAS,适合低冲突)与悲观锁(SELECT FOR UPDATE,适合高冲突),各有适用。本文讲清两种锁的原理、实现、适用场景,以及"先试乐观锁,冲突高了再上悲观锁"的工程判断。
本文要点
- 主题聚焦:DevOps
- 适合人群:独立开发者、全栈工程师、AI 应用开发者
- 核心价值:快速理解背景、实践路径、常见误区和工程建议
乐观锁 vs 悲观锁:并发控制的取舍
"两个用户同时改同一条数据"——这是每个后端迟早踩的坑。最朴素的写法是"读出来、改、写回",看似没问题,并发一上来,后到的请求静悄悄地覆盖了先到的修改,数据就错了。这种错不报错、不崩溃,只在某天对账时才被发现——是最隐蔽也最贵的那类 bug。
修这个坑只有两条路:乐观锁(假设没冲突,提交时检查)和悲观锁(假设有冲突,读时就锁)。选哪个?答案是看冲突率。这篇文章把两种锁的原理、实现、适用场景一次讲清,并给出我实战中的默认选型:先乐观,不行再悲观。
一、问题先讲清楚:丢更新是怎么发生的
假设 accounts 表里 id=1 的余额是 500。两个请求几乎同时到达,都要扣 100。最朴素的代码:
-- 第一步:读
SELECT balance FROM accounts WHERE id = 1; -- 都读到 500
-- 第二步:应用层计算 500 - 100 = 400
-- 第三步:写回
UPDATE accounts SET balance = 400 WHERE id = 1;
两个事务的时间线:
T1: SELECT balance = 500
T2: SELECT balance = 500 ← 都读到了旧值
T1: 计算 400,UPDATE 写 400
T2: 计算 400,UPDATE 写 400 ← 覆盖 T1
COMMIT / COMMIT
正确结果应该是 500 - 100 - 100 = 300,实际却是 400。100 块凭空消失了。这就是丢更新(Lost Update)——后写的覆盖了先写的,先到的修改像从没发生过。
很多人以为换个隔离级别就没事了,但 PG/MySQL 默认的 READ COMMITTED 挡不住它,因为两次读各自都是"已提交的最新值",逻辑上不冲突。即便升到 SERIALIZABLE,也只是把它变成"abort 后让你重试",你仍然要在应用层处理冲突。所以丢更新不能靠隔离级别自动解决,必须靠锁或版本检查主动防御。 防御手段就两种:乐观锁和悲观锁。
二、乐观锁:不加锁,提交时才检查
乐观锁的思路是"我乐观地认为没人跟我抢"。读的时候不加任何锁,只在写回的瞬间检查"这行数据自打我读完后,有没有被别人动过"。怎么检查?加一个版本字段:
-- 读(不锁)
SELECT id, balance, version FROM accounts WHERE id = 1;
-- 假设读到 balance=500, version=7
-- 写回时把 version 作为条件
UPDATE accounts
SET balance = 400, version = version + 1
WHERE id = 1 AND version = 7;
-- 影响行数 = 1:没人改过,成功
-- 影响行数 = 0:version 已变,说明被别人改过 → 冲突,重试
关键就在 WHERE id = 1 AND version = 7 这一句。如果在你读和写之间,有人把 version 从 7 改成了 8,你的 UPDATE 就匹配不到这一行,影响行数为 0——数据库告诉你"你基于的版本过期了"。应用层发现影响 0 行,就走重试逻辑:重新读、重新计算、再试一次。
整个过程中没有任何行锁、没有任何等待。读是普通读,写是带条件的写,失败就重来。所以乐观锁的吞吐很高——只要冲突少,几乎跟没加锁一样快。
三、悲观锁:读时就锁住,别人等着
悲观锁的思路相反:"我悲观地认为一定有人来抢,那我先锁住再说。"在事务里读数据时,就用 SELECT ... FOR UPDATE 显式加行锁:
BEGIN;
SELECT balance FROM accounts WHERE id = 1 FOR UPDATE;
-- 这一行被当前事务锁住
-- 应用层读到 500,计算 400
UPDATE accounts SET balance = 400 WHERE id = 1;
COMMIT; -- 锁在这里才释放
第二个事务跑到 SELECT ... FOR UPDATE 时,会因为这一行被锁住而阻塞等待,直到第一个事务 COMMIT 释放锁,才能读到最新值(已经是 400),继续往下算出 300。两次扣款被强制串行化,谁也不会覆盖谁。
代价显而易见:第二个请求必须等。悲观锁防住了冲突,但直接拉低了并发度——同一时刻只有一个事务能改这一行,其余全部排队。锁持有越久,排队越长,吞吐越低。(FOR UPDATE 的四种锁强度、NOWAIT/SKIP LOCKED 等细节在前一篇《PostgreSQL 锁机制与死锁》里讲过,这里不重复。)
四、适用场景:冲突率是唯一的分水岭
选乐观还是悲观,只看一个指标:冲突率(同一行被并发修改的概率)。两类典型场景对比如下:
| 乐观锁 | 悲观锁 | |
|---|---|---|
| 适合 | 冲突少 | 冲突多 |
| 典型场景 | 读多写少、用户分散(改个人资料、改订单备注) | 热点行、强一致(扣库存、秒杀、账户扣款) |
| 失败代价 | 冲突时重试(CPU 浪费) | 锁等待(并发下降) |
| 性能 | 无冲突时接近无锁,吞吐高 | 持续串行,吞吐受限 |
| 实现复杂度 | 需要写重试逻辑 | 写起来直观,但要防死锁 |
一句话:冲突少,乐观锁赢(不阻塞);冲突多,悲观锁赢(避免雪崩式重试)。 极端情况——秒杀扣库存,同一商品几百人同时抢——乐观锁会让绝大多数请求重试失败、白白烧 CPU,这时候悲观锁(甚至把扣减挪进队列串行化)才是正解。
五、乐观锁的实现:version 字段、CAS 与 ABA
乐观锁的实现有几个细节值得抠。
用 version 字段是最稳的做法。一个自增整数,每次 UPDATE 都 version = version + 1,WHERE 带上读到的 version。version 必须单调递增,所以几乎不会有 ABA 问题。给它建个普通索引,WHERE version = X 才走得快。
用 updated_at 时间戳也能做,但坑多。WHERE 条件换成 WHERE updated_at = :read_ts,语义一样。但时间戳精度不够(毫秒级在快速更新下会撞同值)、不同机器时钟可能漂移、ORM 自动更新 updated_at 会偷偷改值——任何一个都能让你莫名其妙地匹配不到。能用 version 就别用时间戳,后者只在没有 version 字段、又改不了表结构时凑合用。
这就是 CAS 语义。乐观锁本质就是 Compare-And-Swap:比较 version,匹配就换新值。这跟并发编程里的原子操作(Java 的 AtomicInteger.compareAndSet、Go 的 sync/atomic)是同一个思想,只不过这里"比较和交换"是用一条 SQL 完成的——数据库保证 UPDATE 的 WHERE 和 SET 原子执行,中间不会被插队。所以你不需要额外的锁来保护这个 CAS,SQL 本身就是原子的。
重试循环长这样(伪代码):
for attempt in range(MAX_RETRY): # 一般 3 次
row = db.query("SELECT id, balance, version FROM accounts WHERE id = ?", id)
new_balance = row.balance - 100
affected = db.execute(
"UPDATE accounts SET balance = ?, version = version + 1 WHERE id = ? AND version = ?",
new_balance, id, row.version)
if affected == 1:
return success
# affected == 0 → 冲突,backoff 一下再重试
sleep(random_backoff())
return conflict # 重试次数用尽,返回冲突
ABA 问题。有人担心"version 从 7 → 8 → 7"会骗过检查。但只要 version 是单调递增的整数(永远 +1,不复位),就不会出现 A→B→A 回到原值的情况。所以乐观锁用自增 version 天然免疫 ABA——这比无锁数据结构里的 ABA 简单多了,不用过度担心。
六、悲观锁的实现:FOR UPDATE 与死锁
悲观锁的实现核心是 SELECT ... FOR UPDATE,但有几个实战要点。
必须包在事务里,且事务要短。FOR UPDATE 的锁持有到 COMMIT/ROLLBACK 才释放。所以整个"读-算-写"必须在一个事务里完成,而且事务越短越好。前一篇讲过铁律:事务里绝不能调用外部 HTTP、发邮件、做耗时计算——这些把事务拖长,锁就挂着,所有并发请求都在排队,接口直接假死。
两个修饰符要会用。NOWAIT:拿不到锁立刻报错,适合幂等检查、用户重复点击(宁可失败也别排队);SKIP LOCKED:跳过被锁的行,是任务队列抢任务的神器(WHERE status='pending' FOR UPDATE SKIP LOCKED LIMIT 10,多 worker 并行不撞车)。
死锁是悲观锁最该警惕的副作用。经典场景:事务 A 先锁行 1 再锁行 2,事务 B 先锁行 2 再锁行 1,互相等对方放锁——死锁。PG/MySQL 都有死锁检测,会杀掉其中一个事务报错(前一篇详细讲过),但治本是固定加锁顺序:所有事务都按主键升序加锁,就不会形成循环等待。批量改多行时,WHERE id IN (...) ORDER BY id 让加锁有序,死锁基本绝迹。
别锁太多、别锁太久。锁粒度越大、持有越久,并发越差。能用行锁别用表锁,能用单行锁别锁一批,事务一结束就 COMMIT。悲观锁的工程师德行就八个字:晚加早放,粒度最小。
七、工程判断:默认乐观,冲突高了再悲观
理论讲完,实战怎么选?我的默认策略很简单:新功能先上乐观锁,跑一段时间看冲突率,高了再换悲观锁。
理由有三。第一,大部分业务冲突率本来就低。Web 应用里读多写少、用户分散改各自的数据,两个请求撞同一行的概率很小,乐观锁几乎不重试、吞吐拉满。第二,悲观锁的代价是确定性的。哪怕没冲突,它也持续降并发——本来无锁 1000 QPS 的接口,加了 FOR UPDATE 可能只剩 200 QPS,因为所有请求都在排队等那一行。没必要的悲观锁,是白白牺牲性能买了一份用不上的保险。第三,乐观锁随时能升级。发现某张表冲突率高(retry 频繁、冲突日志飙升),把它单独换成悲观锁即可,改动只在那一个写路径,不影响别的。
反过来——别一上来就悲观锁。我见过太多人"为了安全"给所有写操作都套 FOR UPDATE,结果并发上不去,排查半天才发现是锁等待。安全是有成本的,默认加锁等于默认降并发。除非你一开始就知道是热点行(比如库存、余额),否则乐观锁起步,用数据说话再决定。
监控什么?冲突率(乐观锁 UPDATE 影响行数 = 0 的比例)和重试次数分布。冲突率稳定低于 5%,乐观锁完全够用;某张表冲突率飙到 30% 以上、每个请求平均重试两三次,就该考虑悲观锁了。一个折中方案:乐观锁 + 重试 N 次,仍冲突再降级到悲观锁——绝大多数请求享受无锁的高吞吐,极少数高冲突情况走串行,两层兜底。
八、跨进程怎么办:数据库行锁 vs 分布式锁
最后一个常见疑问:多个服务进程、甚至多个服务要协调同一资源,用什么锁?
如果资源在数据库里,首选数据库行锁(SELECT FOR UPDATE)。PG/MySQL 的行锁对所有连接生效——不管你是同一个进程的连接池,还是十台机器上的不同服务,只要连的是同一个库,FOR UPDATE 就是可靠的仲裁者,数据库自己保证一致性。这是最简单、最稳的跨进程锁,不需要引入任何额外组件。
如果资源不在数据库里(比如"同一时刻只能有一个任务在跑"),才用 Redis 分布式锁。Redis 锁的要点是 SET NX EX 加唯一 token、用 Lua 脚本原子释放(防误解锁),这部分在前一篇《Redis 在分布式系统的角色》里详细讲过。这里只提醒一句:Redis 锁是"高性能 + 最佳努力",不是绝对正确性保证——强一致场景(金融扣款)别用 Redis 锁,用数据库行锁或 etcd。
判别很简单:资源本身在 DB → DB 行锁;资源在 DB 外、又确实要跨进程互斥 → Redis 分布式锁。别把数据库行能解决的问题推给 Redis,也别指望 Redis 锁去保护数据库的强一致——各管一摊。
小结
- 丢更新是并发写最隐蔽的 bug:两个事务"读-算-写"交错,后写的静默覆盖先写的,默认隔离级别挡不住,必须靠乐观锁或悲观锁主动防御。
- 乐观锁靠版本检查:version 字段 +
WHERE version=X UPDATE,影响 0 行即冲突,应用层重试。全程无锁无等待,冲突少时吞吐最高;用自增 version 天然免疫 ABA。 - 悲观锁靠 FOR UPDATE:读时就加行锁,强制串行,防冲突但降并发。代价是确定性的,所以只在冲突率高时才值;注意短事务、固定加锁顺序以避死锁。
- 冲突率是唯一分水岭:冲突少选乐观锁(读多写少、用户分散),冲突多选悲观锁(热点行、扣库存、秒杀)。
- 默认乐观,冲突高了再悲观:别为"安全"无脑套 FOR UPDATE;先上乐观锁,监控冲突率,retry 频繁再升级——这是性能和正确性的最优解。跨进程资源优先用数据库行锁,资源在 DB 外才上 Redis 分布式锁。
一句话收尾:乐观锁赌没人抢,悲观锁怕有人抢——赌对了就快,怕得对才稳。先赌,赌不赢再怕,这是并发控制最务实的姿态。
Related
相关推荐
文章
Docker 部署 Next.js 社区项目的注意事项
围绕 DevOps、Next.js 的技术趋势、工程实践和独立开发视角。
文章
OpenTelemetry 如何帮助你观察 AI 应用?
围绕 DevOps、AI 的技术趋势、工程实践和独立开发视角。
文章
长运行工具调用:Operation、Poll 与 Cancel 的终态协议
初始请求超时后,工具可能仍在服务端运行;发出取消也不等于副作用已停止。本文为单次长运行工具调用建立 operation 身份、轮询水位、取消竞态和唯一终态接受点。
文章
Agent 记忆删除传播:从用户遗忘请求到可验证清除
删除主表一行不会清掉向量、摘要、缓存和恢复后的备份。本文用删除状态机、代际墓碑、派生血缘与端到端 canary 验证真正遗忘。