为什么你的秒杀系统总超卖?转账偶尔对不上账?很可能------你选错了锁。
- 悲观锁:适合冲突多、不能出错的场景(转账、抢票)
- 乐观锁:适合冲突少、允许失败的场景(点赞、浏览量)
- 没有最好,只有最合适
先说结论:两种完全不同的思路
悲观锁:先占着,你们等着
就像占座位,我先坐上去,你们想坐?等我起来再说。
sql
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: 现在可以了
乐观锁:先干活,冲突了再说
就像写文档,大家都可以改,但提交的时候检查一下有没有人抢先改过。
sql
-- 看一眼当前版本号
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%失败,疯狂重试
真实做法:混合
- Redis先筛出1000人
- 这1000人用悲观锁扣库存
flowchart LR
A[10万请求] --> B[Redis预扣减]
B --> C[1000人通过]
B --> D[99000人直接返回售罄]
C --> E[悲观锁扣DB库存]
E --> F[生成订单]
场景2:银行转账
你给朋友转500块,绝对不能错。
必须用悲观锁
sql
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
python
# Redis直接加1,毫秒级
redis.incr("post:999:likes")
# 后台慢慢同步到MySQL
10001个赞和10005个赞,用户根本看不出来,但"点了半天没反应"用户能感受到。
场景4:演唱会抢票
一个座位只能卖一次,不能超卖。
用悲观锁+预锁定
sql
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;
定时任务释放超时的:
sql
-- 15分钟没付款?释放座位
UPDATE seats
SET status = '可售'
WHERE status = '锁定'
AND lock_time < 15分钟前;
四个经典的坑
坑1:死锁
两个人互相等对方,谁也动不了。
错误:
- 张三转李四:先锁123,再锁456
- 李四转张三:先锁456,再锁123
- 结果:互相等,死锁
正确:
sql
-- 不管谁转谁,都按ID从小到大锁
SELECT * FROM accounts
WHERE user_id IN (123, 456)
ORDER BY user_id
FOR UPDATE;
坑2:ABA问题
库存100 → 99 → 100,你以为没变,其实变过。
解决办法:用版本号
sql
UPDATE products
SET stock = 99, version = version + 1
WHERE id = 1 AND version = 旧版本号;
坑3:锁错了,锁了整张表
sql
-- name没索引,结果把整张表锁了
SELECT * FROM users WHERE name = '张三' FOR UPDATE;
必须保证WHERE条件走索引。
坑4:疯狂重试
失败了立马重试,CPU直接100%。
正确做法:等一会儿再重试
java
int retry = 0;
while (retry < 5) {
if (更新成功) return true;
Thread.sleep(10 * (1 << retry)); // 10ms, 20ms, 40ms...
retry++;
}
性能测试数据
100万数据,1000线程同时改余额:
| 方案 | 每秒处理 | 平均耗时 | 失败率 |
|---|---|---|---|
| 悲观锁 | 1200次 | 350ms | 0% |
| 乐观锁(不重试) | 8500次 | 50ms | 95% |
| 乐观锁(重试3次) | 3200次 | 180ms | 12% |
| 混合策略 | 4500次 | 120ms | 3% |
混合策略最均衡。
怎么选?三个问题
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
问自己:
- 冲突多不多? 多就悲观,少就乐观
- 能接受失败吗? 不能就悲观,能就乐观
- 速度重要吗? 重要就乐观+Redis
总结
悲观锁适合:
- 转账、支付
- 抢票、抢购
- 库存扣减
- 不能出错的操作
乐观锁适合:
- 点赞、浏览量
- 统计数据
- 读多写少
- 对性能要求高
真实项目往往混着用:
- 看商品详情 → 乐观锁(快)
- 真下单 → 悲观锁(准)
别纠结哪个"更好",懂业务,选对场景就行。