一、从一个真实的并发问题说起
你写了一个扣款功能,代码逻辑很简单:
第一步:查出用户余额 → 100 元
第二步:余额减去 100,写回数据库
看起来没毛病。但假设同一个账户同时收到两笔扣款请求,会发生什么?
css
时间线:
T1:请求A 发起扣款 → 查出余额 = 100
T2:请求B 发起扣款 → 查出余额 = 100 (也查到了 100!两个请求几乎同时执行了 SELECT)
T3:请求A 写入余额 = 0
T4:请求B 写入余额 = 0 (把 A 的更新覆盖了!)
结果:余额从 100 变成了 0,但实际扣了两次 100,业务直接出错。
问题根源:两个请求同时读到了旧数据,各自基于旧数据计算,后写的覆盖了先写的。
这就是并发冲突。为了解决它,数据库领域诞生了两种核心思想------悲观锁 和乐观锁。
先记住 :悲观锁、乐观锁不是 MySQL 自带的某种固定锁,而是两种并发控制的设计思想。前者靠数据库锁机制实现,后者靠业务逻辑实现。
二、悲观锁:先锁住,再操作
核心思路
悲观,就是默认"一定会有人来抢"。
所以在操作数据之前,先把它锁死。锁没释放之前,别人不准动。等我操作完、提交事务,锁才放开,别人再操作。
生活比喻:你去卫生间,进去就反锁。外面的人只能排队。你出来开门,下一个才能进。
在 MySQL 中怎么写
关键语法:SELECT ... FOR UPDATE。这条 SQL 会对查出来的行加排他锁,别人既不能改、也不能再加排他锁。
sql
-- 1. 手动开启事务(必须!)
BEGIN;
-- 2. 查出余额,同时锁定这行数据
SELECT balance FROM user_balance WHERE user_id = 1001 FOR UPDATE;
-- 3. 执行业务扣款
UPDATE user_balance SET balance = balance - 100 WHERE user_id = 1001;
-- 4. 提交事务,锁自动释放
COMMIT;
回到刚才的并发场景,加了悲观锁之后:
sql
T1:请求A BEGIN → FOR UPDATE 锁定 user_id=1001
T2:请求B FOR UPDATE → 被阻塞,排队等锁
T3:请求A UPDATE 扣款 → COMMIT 释放锁
T4:请求B 拿到锁,读到余额 = 0 → 扣款失败(余额不足)
问题解决。
悲观锁的两个大坑
坑一:FOR UPDATE 必须写在事务里
MySQL 默认每条 SQL 都是一个独立事务(autocommit=1)。如果你不手动 BEGIN,FOR UPDATE 执行完事务就自动提交了,锁瞬间释放,等于白加。
坑二:WHERE 条件必须命中索引,否则锁全表
sql
-- user_id 有索引 → 只锁这一行 ✅
SELECT * FROM user_balance WHERE user_id = 1001 FOR UPDATE;
-- user_id 没索引 → 锁整张表!❌
SELECT * FROM user_balance WHERE user_id = 1001 FOR UPDATE;
常见的索引失效场景:字段做运算(WHERE id + 1 = 10)、隐式类型转换(字符串列用数字查)、LIKE 前缀模糊('%张三')等,都会导致行锁退化为表锁。
小结
| 优点 | 缺点 |
|---|---|
| 数据强一致,绝对不会并发覆盖 | 阻塞其他请求,并发性能差 |
| 适合扣款、下单、支付等核心业务 | 事务过长容易锁等待甚至死锁 |
三、乐观锁:不锁,最后校验一下
核心思路
乐观,就是默认"大家很少同时改同一条数据,冲突是小概率事件"。
所以全程不加锁,所有人都可以自由读取、自由尝试修改。只在最后更新的一瞬间,校验一下:这条数据在我读完之后,有没有被别人改过?
- 没被改 → 更新成功
- 被改过 → 更新失败,放弃或重试
生活比喻:图书馆里,所有人可以自由翻书。你想改书上的内容时,先对一下版本------还是你看到的那版就改,已经被别人改过就不动。
主流实现:版本号机制
在数据表中加一个 version 字段:
- 数据每修改一次,
version自增 1 - 更新时,WHERE 条件必须匹配你查询时拿到的旧版本号
sql
-- 表结构(在原有基础上加一个 version 字段)
-- article(id, read_num, version)
-- 第一步:查询,拿到当前版本号(普通 SELECT,无锁)
SELECT read_num, version FROM article WHERE id = 1;
-- 结果:read_num = 100, version = 2
-- 第二步:更新,版本号对上了才执行
UPDATE article
SET read_num = 101, version = version + 1
WHERE id = 1 AND version = 2;
-- 成功 → affected_rows = 1
-- 失败 → affected_rows = 0(被别人抢先改了,version 已经变成 3 了)
关键 :
version的判断和version + 1必须在同一条 SQL 里。MySQL 保证单条 UPDATE 中的 WHERE 判断和 SET 赋值是原子的,不会被打断。拆成两条 SQL 就失效了。
回到并发场景:乐观锁怎么跑的?
用文章浏览量更新来演示(乐观锁不适合做余额扣款,那个该用悲观锁):
ini
数据初始:read_num = 100, version = 1
T1:请求A SELECT → read_num=100, version=1
T2:请求B SELECT → read_num=100, version=1 (两个请求同时读到旧数据)
T3:请求A UPDATE SET read_num=101, version=2 WHERE version=1 → ✅ 成功(version 变成 2)
T4:请求B UPDATE SET read_num=101, version=2 WHERE version=1 → ❌ 失败(version 已经是 2 了,匹配不到 1)
结果:请求A更新成功,请求B失败------虽然没锁,但数据没被覆盖。
对比悲观锁 :悲观锁下请求B在 T2 就阻塞了,乐观锁下请求B在 T4 才失败。一个在入口处 拦,一个在出口处验。
失败了怎么办?真实代码长这样
当 affected_rows = 0 时,说明数据已被别人修改。用 PHP 伪代码演示三种策略:
策略一:直接返回失败(最简单,适合用户操作)
php
// 用户点"编辑文章"→修改→保存
$row = $db->query("SELECT content, version FROM article WHERE id = ?", [1])->fetch();
// 用户修改 content...
$result = $db->exec("UPDATE article SET content = ?, version = version + 1
WHERE id = ? AND version = ?", ['新内容', 1, $row['version']]);
if ($result->rowCount() === 0) {
echo "数据已被他人修改,请刷新后重试";
}
策略二:自动重试(适合后台自动任务)
php
$maxRetry = 3;
for ($i = 0; $i < $maxRetry; $i++) {
$row = $db->query("SELECT read_num, version FROM article WHERE id = ?", [1])->fetch();
$result = $db->exec("UPDATE article SET read_num = ?, version = version + 1
WHERE id = ? AND version = ?", [$row['read_num'] + 1, 1, $row['version']]);
if ($result->rowCount() > 0) {
break; // 成功,退出
}
// 失败,重新查最新数据再试
}
策略三:随机退避重试(高并发防雪崩)
php
$maxRetry = 3;
for ($i = 0; $i < $maxRetry; $i++) {
$row = $db->query("SELECT read_num, version FROM article WHERE id = ?", [1])->fetch();
$result = $db->exec("UPDATE article SET read_num = ?, version = version + 1
WHERE id = ? AND version = ?", [$row['read_num'] + 1, 1, $row['version']]);
if ($result->rowCount() > 0) {
break;
}
// 随机等待 10~100 毫秒再重试,避免所有请求同时重试挤爆数据库
usleep(rand(10000, 100000));
}
另外两种实现(了解即可)
| 方式 | 原理 | 问题 |
|---|---|---|
| 版本号 | version 字段自增校验 |
✅ 最推荐,无 ABA 问题 |
| 时间戳 | 用 update_time 校验 |
⚠️ 秒级时间戳同一秒内可能误判 |
| 全字段校验 | WHERE 匹配所有旧值 | ❌ SQL 冗长,不推荐 |
四、补充:什么是 ABA 问题?
这是新手最容易和"正常并发冲突"混淆的概念。
正常并发冲突(乐观锁的正常功能):
ini
你读到 version=2,更新时 WHERE version=2
别人抢先改了,version 变成 3
你匹配不到 version=2 → 更新失败
这不是 bug,这是乐观锁设计上就该做的事。
ABA 问题(真正的漏洞):
你读到 浏览量=100
别人先改成 99,又改回 100
你看到还是 100,以为没人动过 → 更新成功(实际中间被篡改了)
为什么版本号能杜绝 ABA?因为数据值可以改来改去变回原值,但版本号只会递增:
ini
初始:浏览量=100, version=2
改成 99: version=3
改回 100: version=4
你拿着 version=2 去更新 → 匹配不到 4 → 失败。版本号只增不减的特性,让 ABA 根本不可能发生。
五、悲观锁 vs 乐观锁,到底怎么选?
| 对比维度 | 悲观锁 | 乐观锁 |
|---|---|---|
| 核心思路 | 先锁住,再操作 | 不锁,最后校验 |
| 实现方式 | SELECT ... FOR UPDATE |
WHERE version = 旧版本号 |
| 并发性能 | 低(阻塞排队) | 高(冲突低时几乎无开销) |
| 数据一致性 | 强一致 | 单次写入强一致,冲突时需重试 |
| 适合场景 | 扣款、下单、支付 | 浏览量、点赞、文章编辑 |
⚠️ 乐观锁的"高性能"前提是冲突率低。如果 10 个人同时改同一行,9 个人都返回失败要重试,那还不如直接用悲观锁排队。
选型口诀
- 涉及钱、库存、订单 → 悲观锁,安全第一
- 浏览量、点赞、后台编辑 → 乐观锁,性能第一
- 冲突特别频繁(乐观锁一直失败) → 换悲观锁,或加消息队列削峰
六、总结
sql
悲观锁 = 先锁后做,排队串行,牺牲性能换强一致
乐观锁 = 不锁只验,冲突重试,牺牲少量重试换高并发
核心区别:一个靠数据库锁(FOR UPDATE),一个靠业务逻辑(version 字段)
共同前提:悲观锁必须走索引 + 开事务;乐观锁必须把判断和更新写同一条 SQL
本文基于 MySQL 8.0 + InnoDB 引擎编写。