返回博客

「每日取消上限 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: COMMIT

MySQL 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 已是最新值,阈值检查才真正有效。

额外:用 whereBetweenTime 替代 whereDay

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 让「先查后写」变成原子检查:并发请求必须串行排队,而不是各读各的旧快照。