后端开发中,数据库并发问题是无法回避的挑战。无论是秒杀扣库存、账户余额变动,还是订单状态更新,只要多用户同时操作同一数据,稍有不慎便会引发数据覆盖、超卖或金额错乱。
解决此类问题的核心方案,在于合理运用 MySQL 的两大锁机制:悲观锁与乐观锁。
为什么需要锁?
以一个经典的并发 Bug 为例:用户 A 和用户 B 同时查询某商品库存,均显示剩余 1 件。若两个请求同时执行“库存减 1"的逻辑,最终结果将是库存变为 0,却卖出了 2 单,导致严重超卖。
其根本原因在于:查询与更新是两条独立的 SQL 语句,中间存在时间窗口。若无冲突校验机制,多事务可并行修改数据,从而产生脏写。悲观锁与乐观锁正是为了杜绝此类并发问题、保障数据一致性而诞生的。
悲观锁:先上锁,再操作
悲观锁假设并发冲突必然发生。因此在操作数据前必须先加锁,强制其他事务排队等待,直至锁释放后方可继续操作。通俗类比如同公共卫生间:进门即反锁,他人只能在外等候。
两种实现方式
1. 排他锁(写锁)FOR UPDATE
这是业务写操作中最常用的方式。在事务内对查询结果加锁后,其他事务无法对该数据进行修改或删除。
begin;
-- 对 id=1 的数据加行排他锁
select * from user where id = 1 for update;
-- 执行数据更新
update user set balance = balance - 100 where id = 1;
commit; -- 事务提交,锁释放
锁规则:
- 其他事务执行 UPDATE、DELETE 或 FOR UPDATE 时会被阻塞等待。
- 普通 SELECT 采用快照读(MVCC 机制),不受锁影响。
2. 共享锁(读锁)LOCK IN SHARE MODE
适用于多读场景。多个事务可同时持有读锁,但只要存在读锁,任何事务都无法修改该数据。
begin;
select * from user where id = 1 lock in share mode;
commit;
悲观锁的致命陷阱
1. 无索引导致锁升级
使用 FOR UPDATE 时必须命中索引。若未命中,MySQL 无法定位具体行,将直接升级为表锁,彻底拖垮并发性能。
2. 长事务引发高阻塞
锁在事务开启时生效,仅在提交或回滚时释放。若事务中包含耗时逻辑,会导致大量请求阻塞甚至超时。
3. 死锁风险
在多资源竞争场景下,若加锁顺序不一致,极易引发死锁。
乐观锁:不上锁,提交时校验
乐观锁假设并发冲突极少发生,因此数据库层面不加任何锁,读写完全并行。仅在数据更新时校验数据是否被他人修改过。该机制无阻塞、无等待,性能极高,完全由业务代码实现(MySQL 无原生乐观锁语法)。
标准实现:版本号机制
在数据表中新增版本字段(如 version INT DEFAULT 0)。执行逻辑如下:
- 查询数据,获取当前版本号;
- 更新数据时,携带旧版本号进行原子校验;
- 若版本号匹配则更新成功并将版本号 +1;若不匹配则更新失败,表明数据已被修改。
正确的 SQL 写法(原子执行):
update user
set balance = balance - 100, version = version + 1
where id = 1 and version = 旧版本号;
判断规则:根据 SQL 受影响行数(affected_rows)判定:
- 返回 1:更新成功,无冲突;
- 返回 0:数据已被修改,发生并发冲突,需重试。
绝对错误的写法:
-- 错误!两条 SQL 存在时间窗口
select version from user where id=1;
update user set balance=balance-100 where id=1;
将查询与更新拆分为两条独立 SQL,中间可能被其他线程插入修改,导致数据覆盖,使乐观锁彻底失效。核心原则是:校验与更新必须在一条 SQL 中原子完成。
在高并发场景下,单次更新失败直接返回会影响用户体验。生产环境的最佳实践是搭配有限次数的自动重试机制。
<?php
use app\model\User;
/**
* 乐观锁扣余额 + 自动重试
* @param int $userId 用户 ID
* @param int $amount 扣减金额
* @param int $maxRetry 最大重试次数
* @return array
*/
function deductBalance(int $userId, int $amount, int $maxRetry = 3): array
{
// 有限次数重试,避免死循环
for ($i = 0; $i < $maxRetry; $i++) {
$user = User::where('id', $userId)->find();
if (!$user) return [0, '用户不存在'];
if ($user['balance'] < $amount) return [0, '余额不足'];
// 原子更新:版本号校验 + 数据修改
$affectRows = User::where('id', $userId)
->where('version', $user['version'])
->update([
'balance' => $user['balance'] - $amount,
'version' => $user['version'] + 1
]);
// 更新成功直接返回
if ($affectRows > 0) {
return [1, '扣减成功'];
}
// 冲突休眠,降低 CPU 消耗
usleep(10000);
}
// 重试耗尽,返回繁忙
return [0, '操作繁忙,请稍后再试'];
}
?>
悲观锁 VS 乐观锁:核心对比
| 对比维度 | 悲观锁 | 乐观锁 |
|---|---|---|
| 实现方式 | 数据库行锁(FOR UPDATE) | 业务版本号校验 |
| 并发假设 | 默认一定会冲突 | 默认很少冲突 |
| 阻塞情况 | 会阻塞,存在锁等待 | 无阻塞,冲突则重试 |
| 性能表现 | 写多场景性能差 | 读多写少性能极佳 |
| 死锁风险 | 有 | 无 |
| 适用场景 | 高写冲突、资金交易 | 读多写少、普通更新 |
生产环境最终选型方案
1. 首选乐观锁
适用于绝大多数业务场景,如用户资料修改、商品数据更新、普通订单操作等(读多写少)。其优势在于高性能、无阻塞、无死锁且运维成本低。
2. 选用悲观锁
适用于核心资金流转、高并发秒杀、库存扣减等写冲突极高的场景。使用时必须确保索引生效、事务极短,坚决杜绝长事务。

