首页 / 知识库 / python服务端进阶 / 数据库锁详解

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 的并发杀手锏