乐观锁和悲观锁,到底该怎么选?

6 阅读4分钟

为什么你的秒杀系统总超卖?转账偶尔对不上账?很可能——你选错了锁。

  • 悲观锁:适合冲突多、不能出错的场景(转账、抢票)
  • 乐观锁:适合冲突少、允许失败的场景(点赞、浏览量)
  • 没有最好,只有最合适

先说结论:两种完全不同的思路

悲观锁:先占着,你们等着

就像占座位,我先坐上去,你们想坐?等我起来再说。

START TRANSACTION;

-- 锁住这条记录
SELECT balance FROM accounts WHERE user_id = 123 FOR UPDATE;

-- 扣钱
UPDATE accounts SET balance = balance - 100 WHERE user_id = 123;

COMMIT; -- 释放锁

优点:不会出错
缺点:慢,大家排队等

sequenceDiagram
    participant A as 用户A
    participant DB as 数据库
    participant B as 用户B
    
    A->>DB: 加锁并读数据
    B->>DB: 想读数据
    Note over B: 只能等着
    A->>DB: 修改完,提交
    DB->>B: 现在可以了

乐观锁:先干活,冲突了再说

就像写文档,大家都可以改,但提交的时候检查一下有没有人抢先改过。

-- 看一眼当前版本号
SELECT stock, version FROM products WHERE id = 456;
-- 结果:库存100,版本号5

-- 减库存,但要求版本号还是5
UPDATE products 
SET stock = 99, version = 6 
WHERE id = 456 AND version = 5;

-- 如果version变了,这条SQL不生效,说明有人抢先了

优点:快,不用等
缺点:冲突多了失败率高

sequenceDiagram
    participant A as 用户A
    participant DB as 数据库
    participant B as 用户B
    
    A->>DB: 读数据(版本5)
    B->>DB: 读数据(版本5)
    A->>DB: 更新(要求版本5)
    Note over DB: 成功,版本改成6
    B->>DB: 更新(要求版本5)
    Note over DB: 失败,版本已经是6了

四个真实场景

场景1:淘宝秒杀

10万人抢1000台iPhone,冲突超级大。

  • 纯悲观锁?排队排死
  • 纯乐观锁?99%失败,疯狂重试

真实做法:混合

  1. Redis先筛出1000人
  2. 这1000人用悲观锁扣库存
flowchart LR
    A[10万请求] --> B[Redis预扣减]
    B --> C[1000人通过]
    B --> D[99000人直接返回售罄]
    C --> E[悲观锁扣DB库存]
    E --> F[生成订单]

场景2:银行转账

你给朋友转500块,绝对不能错。

必须用悲观锁

START TRANSACTION;

-- 同时锁住两个账户(按ID顺序,防止死锁)
SELECT balance FROM accounts 
WHERE user_id IN (123, 456) 
ORDER BY user_id 
FOR UPDATE;

-- 扣钱、加钱、记账
UPDATE accounts SET balance = balance - 500 WHERE user_id = 123;
UPDATE accounts SET balance = balance + 500 WHERE user_id = 456;
INSERT INTO transactions (...) VALUES (...);

COMMIT;

关键点:

  • 一定要按顺序加锁
  • WHERE条件要走索引,不然会锁整张表

场景3:微博点赞

一条热门微博,每秒几千人点赞。

用乐观锁+Redis

# Redis直接加1,毫秒级
redis.incr("post:999:likes")

# 后台慢慢同步到MySQL

10001个赞和10005个赞,用户根本看不出来,但"点了半天没反应"用户能感受到。

场景4:演唱会抢票

一个座位只能卖一次,不能超卖。

用悲观锁+预锁定

START TRANSACTION;

-- 锁住座位
SELECT status FROM seats 
WHERE concert_id = 100 AND seat_no = 'A-12' 
FOR UPDATE;

-- 锁定15分钟,等你付款
UPDATE seats 
SET status = '锁定', user_id = 你的ID, lock_time = NOW()
WHERE concert_id = 100 AND seat_no = 'A-12';

COMMIT;

定时任务释放超时的:

-- 15分钟没付款?释放座位
UPDATE seats 
SET status = '可售' 
WHERE status = '锁定' 
AND lock_time < 15分钟前;

四个经典的坑

坑1:死锁

两个人互相等对方,谁也动不了。

错误:

  • 张三转李四:先锁123,再锁456
  • 李四转张三:先锁456,再锁123
  • 结果:互相等,死锁

正确:

-- 不管谁转谁,都按ID从小到大锁
SELECT * FROM accounts 
WHERE user_id IN (123, 456) 
ORDER BY user_id 
FOR UPDATE;

坑2:ABA问题

库存100 → 99 → 100,你以为没变,其实变过。

解决办法:用版本号

UPDATE products 
SET stock = 99, version = version + 1
WHERE id = 1 AND version = 旧版本号;

坑3:锁错了,锁了整张表

-- name没索引,结果把整张表锁了
SELECT * FROM users WHERE name = '张三' FOR UPDATE;

必须保证WHERE条件走索引。

坑4:疯狂重试

失败了立马重试,CPU直接100%。

正确做法:等一会儿再重试

int retry = 0;
while (retry < 5) {
    if (更新成功) return true;
    Thread.sleep(10 * (1 << retry)); // 10ms, 20ms, 40ms...
    retry++;
}

性能测试数据

100万数据,1000线程同时改余额:

方案每秒处理平均耗时失败率
悲观锁1200次350ms0%
乐观锁(不重试)8500次50ms95%
乐观锁(重试3次)3200次180ms12%
混合策略4500次120ms3%

混合策略最均衡。


怎么选?三个问题

flowchart TD
    A[你的场景] --> B{冲突多吗?}
    B -->|很多| C[悲观锁]
    B -->|不多| D[乐观锁]
    
    C --> E{能等吗?}
    E -->|能| F[直接用悲观锁]
    E -->|不能| G[混合策略]
    
    D --> H{失败能重试吗?}
    H -->|能| I[乐观锁+重试]
    H -->|不能| J[改用悲观锁]
    
    style F fill:#FFD700
    style I fill:#90EE90
    style G fill:#87CEEB

问自己:

  1. 冲突多不多? 多就悲观,少就乐观
  2. 能接受失败吗? 不能就悲观,能就乐观
  3. 速度重要吗? 重要就乐观+Redis

总结

悲观锁适合:

  • 转账、支付
  • 抢票、抢购
  • 库存扣减
  • 不能出错的操作

乐观锁适合:

  • 点赞、浏览量
  • 统计数据
  • 读多写少
  • 对性能要求高

真实项目往往混着用:

  • 看商品详情 → 乐观锁(快)
  • 真下单 → 悲观锁(准)

别纠结哪个"更好",懂业务,选对场景就行。