学完这篇你能得到:两套能直接抄进项目的锁方案(SQL 写法和代码判断逻辑),以及一条不靠感觉的判断标准——什么时候该锁住行、什么时候只该比对版本号。
先一句话区分:悲观锁假设「一定会撞车」,所以在读的时候就锁住行,别人必须排队;乐观锁假设「大概率不会撞车」,先改,改的时候校验有没有被别人动过,被动了就重来。
第一步:先把测试表准备好
两种方案都基于同一张表,用「余额扣减」这种最典型的并发场景来演示:
CREATE TABLE user_wallet (
id INT PRIMARY KEY,
balance DECIMAL(10,2) NOT NULL,
version INT NOT NULL DEFAULT 0
) ENGINE=InnoDB;
注意:悲观锁必须用支持事务的引擎(InnoDB),MyISAM 上写 FOR UPDATE 只会有表锁,性能会难看到你怀疑人生。
第二步:悲观锁——SELECT ... FOR UPDATE
悲观锁的全部动作都在一个事务里完成,靠数据库自己排队:
BEGIN;
SELECT balance FROM user_wallet WHERE id = 100 FOR UPDATE; -- 锁住这一行
UPDATE user_wallet SET balance = balance - 50 WHERE id = 100;
COMMIT;
关键点有三个:
FOR UPDATE 必须写在事务内,事务提交(COMMIT)或回滚(ROLLBACK)后锁才释放。
- 锁的粒度取决于
WHERE 条件。走主键或唯一索引 = 行锁;走普通索引 = 间隙锁 + 行锁;条件列没有索引 = 锁全表。
- 别的会话再来读这一行,会一直等,等到
innodb_lock_wait_timeout(默认 50 秒)超时报错。
如果只是「读的时候不想被别人改,但我自己也不打算改」,用 LOCK IN SHARE MODE(MySQL 8.0 写作 FOR SHARE)加共享锁,多个共享锁可共存,但排斥排他锁。
注意:SELECT ... FOR UPDATE 在自动提交(autocommit=1)模式下会立刻释放锁,等于没锁。很多人踩这个坑,是因为在客户端工具里单条执行,看不出问题。
第三步:乐观锁——版本号比对
乐观锁不锁任何东西,只在更新语句上加一个「版本必须没变」的条件,靠 UPDATE 的影响行数判断成败:
-- ① 先查(不加锁)
SELECT balance, version FROM user_wallet WHERE id = 100;
-- 假设查到 balance=200, version=7
-- ② 带版本条件更新
UPDATE user_wallet
SET balance = 150, version = version + 1
WHERE id = 100 AND version = 7;
然后看这条 UPDATE 影响了几行:
- 返回
1:抢到了,本次修改生效。
- 返回
0:说明在你查完到更新之间,有人已经把 version 改掉了,本次失败。
失败后由应用层决定:重试(重新查一遍再更新,一般限制 3 次以内)、提示用户「数据已变更请刷新」、或者直接放弃。
没有 version 字段也能做,把「期望的旧值」写进条件即可,比如 WHERE id = 100 AND balance >= 50——这本质是同一个思路,只是用业务值代替版本号,适合 balance = balance - 50 这种自带原子性的运算。
注意:UPDATE ... WHERE version = ? 的影响行数,用 MySQL 的 affected_rows 取。有些驱动默认返回「匹配行数」而不是「实际修改行数」,如果新旧值恰好相同,会误判成更新成功,务必确认驱动的 affected_rows 行为。
第四步:按场景挑,别按喜好挑
一张对照表,直接照着选:
| 判断条件 | 选悲观锁 | 选乐观锁 |
|---|
| 冲突频率 | 高(同一行经常被抢) | 低(偶发冲突) |
| 事务时长 | 短(毫秒级) | 可长可短 |
| 一致性要求 | 强,必须立刻生效 | 可以容忍重试 |
| 典型场景 | 库存扣减、余额支付、抢占名额、工单派单 | 帖子编辑、用户资料修改、后台配置保存、文章阅读数 |
落到论坛系统里:
- 悬赏帖「采纳答案 + 把冻结金额打给回答者 + 通知」,涉及多表且必须原子完成,用悲观锁锁住悬赏行和双方钱包行。
- 用户改自己的个人简介、改帖子标题,一天也撞不上一次,用乐观锁最省事,不用开事务、不用担心中途卡住。
第五步:最容易出事的四个坑
注意:长事务。悲观锁最怕事务里夹着慢查询、外部 HTTP 请求、文件读写。锁一持就是几百毫秒,QPS 一上来直接排队雪崩。事务里只放数据库操作。
注意:死锁。两个事务以相反顺序锁两行(A 锁 1 再锁 2,B 锁 2 再锁 1)就会死锁,MySQL 会杀掉其中一个并报 Deadlock found。解决办法很土但有效:所有事务按同一顺序(比如按主键升序)访问行。
注意:没走索引。FOR UPDATE 的 WHERE 用了函数、类型隐式转换、前导模糊匹配,都会导致锁范围扩大到全表,线上表现是「改一个人的数据,全站都卡住」。
注意:乐观锁重试风暴。高并发下乐观锁失败率会陡增,重试次数一多,CPU 全耗在重试上。冲突一旦超过某个比例(经验值 20% 以上),就该换成悲观锁或加排队。
小结
- 悲观锁 =
SELECT ... FOR UPDATE + 事务,靠数据库排队,实现简单、一致性强,代价是等待和死锁风险。
- 乐观锁 =
WHERE version = ? + 判断影响行数,无锁无等待、吞吐高,代价是要写重试逻辑。
- 判断只看两件事:冲突频率和事务时长。冲突高、事务短 → 悲观锁;冲突低 → 乐观锁。
- 悲观锁的三个雷:长事务、加锁顺序不一致、条件列没索引。
- 乐观锁的雷:把「匹配行数」当成「修改行数」,以及无上限重试。