乐观锁与悲观锁选型
并发控制有两种主要策略:悲观锁假设冲突经常发生,提前加锁;乐观锁假设冲突很少,只在提交时检查。选错策略会导致性能瓶颈(悲观锁过度使用)或数据错误(乐观锁漏检)。
两种策略的核心区别
悲观锁(Pessimistic Locking):
读取时就加锁 → 其他事务必须等待 → 提交时释放锁
"我确信会有冲突,所以先占坑"
乐观锁(Optimistic Locking):
读取时不加锁 → 提交时检查有没有被修改 → 如果有冲突则回滚重试
"我认为冲突很少,如果真的冲突了再处理"
| 维度 | 悲观锁 | 乐观锁 |
|---|---|---|
| 冲突假设 | 冲突频繁 | 冲突罕见 |
| 加锁时机 | 读取时 | 提交时 |
| 性能影响 | 高并发下锁等待多 | 无锁等待,但冲突时重试有开销 |
| 实现方式 | SELECT FOR UPDATE | version 列或 updated_at 检查 |
| 适用场景 | 库存扣减、余额转账 | 用户资料编辑、配置更新 |
悲观锁:SELECT FOR UPDATE
-- 场景:多个用户并发购买同一商品,库存只剩 1 件
-- ✅ 正确:悲观锁确保库存不被超卖
CREATE TABLE inventory (
product_id INTEGER PRIMARY KEY,
available INTEGER NOT NULL CHECK (available >= 0)
);
-- 购买逻辑(在事务中)
BEGIN;
-- 1. 加锁读取库存(FOR UPDATE 阻止其他事务同时修改)
SELECT available
FROM inventory
WHERE product_id = 42
FOR UPDATE;
-- 2. 应用层检查库存(假设可用量为 1)
-- 3. 扣减库存
UPDATE inventory
SET available = available - 1
WHERE product_id = 42;
-- 4. 创建订单
INSERT INTO orders (product_id, user_id, quantity) VALUES (42, 101, 1);
COMMIT;
-- 并发安全:第二个事务会在 FOR UPDATE 处等待,
-- 等第一个事务提交后,读到 available=0,然后失败退出
SELECT FOR UPDATE NOWAIT
-- NOWAIT:不等待,如果锁不可用立即报错
BEGIN;
SELECT * FROM inventory WHERE product_id = 42 FOR UPDATE NOWAIT;
-- 如果被锁:ERROR: could not obtain lock on row in relation "inventory"
-- 应用层捕获错误,提示用户"稍后重试"
-- SKIP LOCKED:跳过被锁定的行,适合任务队列
BEGIN;
SELECT id FROM job_queue
WHERE status = 'pending'
LIMIT 10
FOR UPDATE SKIP LOCKED;
-- 多个 worker 可以并发取任务,互不干扰
乐观锁:版本号方案
-- 在表中添加 version 列(整数递增)
CREATE TABLE documents (
id BIGSERIAL PRIMARY KEY,
title TEXT NOT NULL,
content TEXT,
version INTEGER NOT NULL DEFAULT 1, -- 乐观锁版本号
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- 读取文档(记录当前版本号)
SELECT id, title, content, version FROM documents WHERE id = 1;
-- 结果:version = 5
-- 应用层编辑内容...
-- 提交更新时,用 version 做冲突检测
UPDATE documents
SET
title = '新标题',
content = '新内容',
version = version + 1, -- 版本号递增
updated_at = NOW()
WHERE id = 1
AND version = 5; -- 关键:只有 version 仍为 5 时才更新
-- 检查影响行数
-- 返回 1:更新成功(没有其他人在我们读取后修改过)
-- 返回 0:冲突!别人已经修改了文档,需要重新读取并重试
在应用层处理乐观锁冲突
# Python 示例(伪代码)
def update_document(doc_id, new_title, new_content, max_retries=3):
for attempt in range(max_retries):
# 1. 读取当前版本
doc = db.fetchone(
"SELECT id, title, content, version FROM documents WHERE id = %s",
[doc_id]
)
current_version = doc['version']
# 2. 应用层处理...(用户编辑等)
# 3. 尝试更新
affected = db.execute("""
UPDATE documents
SET title = %s, content = %s, version = version + 1
WHERE id = %s AND version = %s
""", [new_title, new_content, doc_id, current_version])
if affected == 1:
return {"success": True} # 更新成功
# 冲突:重新读取并提示用户
if attempt < max_retries - 1:
time.sleep(0.1 * (2 ** attempt)) # 指数退避
return {"success": False, "reason": "conflict"}
乐观锁:updated_at 时间戳方案
-- 如果不想添加 version 列,可以用 updated_at 时间戳
-- 注意:精度要足够高,避免同毫秒的并发冲突
CREATE TABLE settings (
key TEXT PRIMARY KEY,
value JSONB NOT NULL,
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- 读取时记录 updated_at
SELECT key, value, updated_at FROM settings WHERE key = 'theme';
-- updated_at = '2024-03-22 10:30:00.123456+08'
-- 更新时校验
UPDATE settings
SET
value = '{"mode": "dark"}',
updated_at = NOW()
WHERE key = 'theme'
AND updated_at = '2024-03-22 10:30:00.123456+08'; -- 精确匹配
-- ⚠️ 注意:updated_at 方案在高并发下有极小概率失效
-- (两个请求恰好在同一微秒内读取)
-- 生产环境更推荐 version 整数方案
选型决策:何时用哪种锁
graph TD
A["并发写入同一行"] --> B{"冲突发生频率?"}
B --> |高(每秒多次)| C{"操作可以等待吗?"}
B --> |低(偶尔)| D["乐观锁\n(version 列)"]
C --> |可以等待| E["悲观锁\nSELECT FOR UPDATE"]
C --> |不能等待| F["悲观锁\nSELECT FOR UPDATE NOWAIT\n+ 应用层提示重试"]
D --> G{"重试代价?"}
G --> |低(无副作用)| H["标准乐观锁\n+ 指数退避重试"]
G --> |高(有副作用)| I["考虑悲观锁\n或队列化处理"]
具体场景对照
✅ 悲观锁适合:
- 商品库存扣减(超卖代价极高)
- 账户余额转账(金融一致性要求)
- 任务队列 worker 取任务(需要 SKIP LOCKED)
- 长事务中的复杂检查-修改操作
✅ 乐观锁适合:
- 用户编辑自己的资料(冲突极少)
- 文档/配置的版本管理(CMS 场景)
- 后台管理界面的表单提交(低并发)
- 分布式系统中避免全局锁的场景
实战:抢购秒杀场景的完整方案
-- 秒杀:高并发下限量商品的库存控制
-- 方案一:悲观锁(简单,但高并发下串行化严重)
BEGIN;
SELECT stock FROM flash_sale WHERE id = 1 FOR UPDATE;
-- 检查 stock > 0
UPDATE flash_sale SET stock = stock - 1 WHERE id = 1;
INSERT INTO flash_orders (user_id, sale_id) VALUES (101, 1);
COMMIT;
-- 方案二:乐观锁 + CHECK 约束(推荐,利用数据库约束防超卖)
CREATE TABLE flash_sale (
id INTEGER PRIMARY KEY,
stock INTEGER NOT NULL CHECK (stock >= 0) -- 负库存直接报错
);
-- 直接用原子 UPDATE,让数据库的 CHECK 约束防超卖
-- 不用显式加锁,效率更高
UPDATE flash_sale
SET stock = stock - 1
WHERE id = 1 AND stock > 0;
-- 返回 0 行:说明库存不足(或已被他人抢走)
-- 返回 1 行:扣减成功,然后 INSERT 订单
-- 方案三:Redis 预扣减(超高并发场景)
-- 先用 Redis DECR 原子扣减,成功后异步写数据库
-- Redis stock 到 0 后立即拒绝,减轻数据库压力
-- (实现细节属于系统设计,超出本书范围)
下一章:全文搜索与扩展插件——PostgreSQL 内置的全文搜索引擎,不需要单独部署 Elasticsearch,就能实现中英文分词、相关性排序和高亮显示。