04 - 乐观锁与悲观锁
这是本系列最实用的一章。乐观锁和悲观锁不是数据库提供的两种具体的锁,而是两种处理并发冲突的思路/策略。理解它们,你就知道实战中「先查后改」的场景该怎么写。
一句话先记住:悲观锁「先锁再改,假设一定会冲突」;乐观锁「先改再检查,假设通常不冲突」。
4.1 先回顾要解决的问题:丢失更新
还记得第 01 章的「丢失更新」吗?两个人同时「读库存 → 减一 → 写回」,其中一个的扣减被覆盖了:
事务A 事务B
读库存 = 10 读库存 = 10
算出 9 算出 9
写回 9 写回 9 ← A 的扣减没了!
结果:卖了 2 个,库存只减 1(超卖!)
乐观锁和悲观锁,就是解决这类「先查后改」问题的两种思路。
4.2 悲观锁:先锁住,别人别想动
思路:我假设「一定会有人和我抢」,所以读的时候就把它锁住,改完再放开。
在 PostgreSQL 里,就是用 SELECT ... FOR UPDATE:
BEGIN;
-- 读的同时就锁住这行,别人想改必须等我
SELECT stock FROM products WHERE id = 1 FOR UPDATE;
-- 此时事务B 执行同样的 FOR UPDATE 会被阻塞,乖乖等着
-- 我从容地判断、计算、修改
UPDATE products SET stock = stock - 1 WHERE id = 1;
COMMIT; -- 释放锁,B 才能继续
事务A 事务B
SELECT ... FOR UPDATE │
🔒 锁住 id=1 │ SELECT ... FOR UPDATE
计算、扣减 │ ⏳ 被阻塞,等待...
UPDATE │
COMMIT 🔓 │ → 拿到锁,读到最新的库存 9
│ 基于 9 继续扣减,正确!
- 优点:简单、绝对安全,不会丢失更新
- 缺点:并发低——大家排队等锁,锁持有期间别人干等
适合场景:写多、冲突频繁的场景(比如热门商品秒杀,大家都在抢同一行)。
4.3 乐观锁:先改,提交时检查有没有被人动过
思路:我假设「大概率没人和我抢」,所以不加锁,正常读、正常算。只在最后写回时,检查一下这条数据有没有被别人改过。如果被改过,说明冲突了,就重试。
实现方式:给表加一个版本号字段 version(或用 updated_at)。
-- 1. 读数据,同时记住当前版本号
SELECT stock, version FROM products WHERE id = 1;
-- 假设读到 stock=10, version=5
-- 2. 在应用里计算新值(stock 变成 9)
-- 3. 写回时,带上「我读到的版本号」作为条件
UPDATE products
SET stock = 9, version = version + 1
WHERE id = 1 AND version = 5; -- ★ 关键:只有版本还是 5 才更新
关键在第 3 步的结果:
情况一:没人动过(version 还是 5)
→ UPDATE 影响 1 行 → 成功!版本变成 6
情况二:有人在我之前改了(version 已经变成 6)
→ WHERE version = 5 匹配不到 → UPDATE 影响 0 行 → 冲突!
→ 应用层发现「影响 0 行」,重新读最新数据,再试一遍(重试)
事务A 事务B
读 version = 5 读 version = 5
改成功,version → 6
UPDATE ... WHERE version=5 │
→ 影响 0 行(版本对不上) │
→ 检测到冲突,重试: │
重新读 version = 6 │
再 UPDATE ... WHERE version=6 │
→ 成功 │
- 优点:不加锁,并发高,读写不互相阻塞
- 缺点:冲突时要重试,代码更复杂;冲突特别频繁时,重试很多次反而更慢
适合场景:读多写少、冲突较少的场景(大部分业务都是这样)。
4.4 核心对比(重点)
| 维度 | 悲观锁 | 乐观锁 |
|---|---|---|
| 基本假设 | 一定会冲突 | 通常不冲突 |
| 怎么做 | 先加锁再操作 | 不加锁,提交时用版本号检测 |
| PG 实现 | SELECT ... FOR UPDATE | 版本号字段 + WHERE version = ? |
| 并发性能 | 低(排队等锁) | 高(不阻塞) |
| 冲突处理 | 天然避免(别人进不来) | 检测到冲突 → 重试 |
| 适合场景 | 写多、冲突频繁(秒杀热点) | 读多写少、冲突少(大多数业务) |
| 代码复杂度 | 简单 | 需要处理重试逻辑 |
一句话选择:
- 冲突不激烈(大多数业务)→ 乐观锁,性能好
- 冲突很激烈(热点争抢)→ 悲观锁,避免大量重试反而更稳
4.5 一个常见误区
❌ 误区:以为「乐观锁」是数据库里一种真的锁。
✅ 真相:乐观锁根本没加锁!它只是「用版本号检测冲突 + 重试」的一种策略。
名字里有"锁",但它的精髓恰恰是"不锁"。
❌ 误区:直接 UPDATE products SET stock = stock - 1 也不会超卖吧?
✅ 真相:这一条 SQL 内部是原子的,确实不会超卖(数据库对这行加了行锁)。
问题出在「先在应用里读出来、算好、再写回」这种「读-改-写」分两步的场景,
才需要乐观锁/悲观锁来保证中间没人插队。
如果能用一条
UPDATE ... SET x = x - 1 WHERE ...搞定,就别自己「先查后改」——把计算交给数据库,天然原子、最省事。乐观/悲观锁是为「不得不先查后改」的复杂场景准备的。
本章小结
| 你要记住 | 要点 |
|---|---|
| 本质 | 两种处理并发冲突的策略,不是具体的锁 |
| 悲观锁 | 先锁再改,FOR UPDATE,适合冲突频繁 |
| 乐观锁 | 版本号检测冲突 + 重试,不加锁,适合冲突少 |
| 选择 | 冲突少用乐观(性能好),冲突多用悲观(更稳) |
| 误区 | 乐观锁其实”不锁”;能用一条 UPDATE 就别先查后改 |
下一章预告:前面老提到 PostgreSQL「普通读不加锁」,靠的是 MVCC。这一利器到底怎么做到「读写不互相阻塞」的?下一章揭晓。
上一章 ← 03 - 按模式分:共享锁与排他锁 | 下一章 → 05 - MVCC:PostgreSQL 的并发杀手锏