「每日取消上限 3 次」被并发击穿:SELECT FOR UPDATE 才是正解
某后台管理系统上线了一个「每日取消订单次数上限」功能,配置值为 3。单测串行跑完全正常,上线第二天就有用户反映连续取消了 5 次。排查后发现:双击按钮触发两个并发请求,两个都通过了次数检查,都成功写入。限流逻辑形同虚设。
现象
- 串行操作完全正常,限流精确命中;
- 快速双击 / 前端发出两个并发请求时,上限被突破 1-2 次;
- 同样的模式在优惠券限领(每人 1 张)、退款防重(同一笔订单只允许一笔进行中的退款)场景下也复现。
- 压测:并发 10 个请求同时触发,超额数量与并发数成正比。
根因
问题出在「先查后写」(check-then-act)这个经典竞态模式上。
T1: SELECT count(*) → 2 (未达上限 3)
T2: SELECT count(*) → 2 (未达上限 3)
T1: INSERT log row → count 变 3
T2: INSERT log row → count 变 4 ← 越界!
T1: COMMIT
T2: COMMITMySQL InnoDB 默认隔离级别是 REPEATABLE READ。在同一个事务内多次读同一快照是一致的,但两个不同事务开启时都拿到了「count=2」的快照,各自判断「未达上限」,然后分别写入——这不违反任何隔离级别语义,却违反了业务约束。
仅仅把代码包进 startTrans() ... commit() 是不够的。 事务保证了原子性和崩溃恢复,但不阻止另一个事务的读操作拿到旧快照并绕过检查。
很多人认为「加了事务就不会并发问题了」。但 REPEATABLE READ 快照读(SELECT 不加锁)对其他事务的写操作是不可见的——两个事务可以同时读到同一个旧 count,然后都通过阈值检查,最终都写入。只有显式加行锁(SELECT ... FOR UPDATE)才会让第二个事务在第一个未 COMMIT 时阻塞,从而串行化这两个操作。
正解
在事务内,把「计数查询」改为 SELECT ... FOR UPDATE(ThinkPHP 里是 ->lock(true)),锁住被计数的行,让并发请求串行等待。
场景一:「每日最多 N 次」限频
$db->startTrans();
try {
// lock(true) → SELECT ... FOR UPDATE,锁住今天的计数行
// 第二个并发请求会在这里阻塞,直到第一个 COMMIT/ROLLBACK
$count = $db->name('order_process_log')
->where('operator_id', $operatorId)
->where('process_type', 8)
->whereBetweenTime('create_time', $today . ' 00:00:00', $today . ' 23:59:59')
->lock(true)
->count();
if ($count >= $limit) {
$db->rollback();
$this->error('今日操作次数已达上限');
}
$db->name('order_process_log')->insert([...]);
$db->name('order')->where('id', $orderId)->update([...]);
$db->commit();
} catch (\Throwable $e) {
$db->rollback();
throw $e;
}场景二:「同一记录只允许一笔进行中」防重
锁父行(订单行),然后在锁内重新检查子表是否已有进行中的记录:
$db->startTrans();
try {
// 锁父行,让并发的退款请求串行化
$db->table('order')->where('id', $orderId)->lock(true)->find();
$existing = $db->table('order_refund')
->where('order_id', $orderId)
->whereIn('status', [0, 1])
->find();
if ($existing) {
$db->rollback();
$this->error('该订单已有进行中的退款申请');
}
$db->table('order_refund')->insert([...]);
$db->commit();
} catch (\Throwable $e) {
$db->rollback();
throw $e;
}SELECT ... FOR UPDATE 会在被扫描的行上加排他锁。第二个并发事务试图执行同一查询时,必须等第一个事务释放锁(COMMIT 或 ROLLBACK)后才能继续——此时它看到的 count 已是最新值,阈值检查才真正有效。
whereDay() 在某些框架版本下对时区敏感,会在服务器时区与数据库时区不一致时产生边界偏差。显式写 whereBetweenTime('create_time', $today . ' 00:00:00', $today . ' 23:59:59') 更安全,也方便日后切换时区配置。
何时不该用悲观锁
- 约束可以表达为 UNIQUE INDEX 时:用唯一索引 +
INSERT ... ON DUPLICATE KEY UPDATE,让引擎自己串行化,无需手动加锁。 - 跨服务 / 跨数据库时:InnoDB 行锁跨不了服务边界,改用 Redis
SETNX/ Redlock。 - 锁内包了外部 HTTP 调用:锁持有时间过长会堆积等待,把外部调用移到事务外。
一句话外卖
SELECT ... FOR UPDATE让「先查后写」变成原子检查:并发请求必须串行排队,而不是各读各的旧快照。