竞态条件排查:从读改写到原子操作

一、竞态不是高并发才有的问题

很多人以为「竞态条件」是秒杀系统、分布式服务才需要关心的事。实际上一个日 IP 几千的个人站点同样会撞上:后台的定时任务和用户请求同时改同一行数据、两个管理员同时点保存、缓存重建在多个请求里同时触发、日志清理脚本在写日志时把文件截断。这些场景并发量都不高,但一旦踩中,表现就是「偶发」「不可复现」「数据看起来莫名其妙」——最难查的那一类 bug。

本文不打算讲教科书里的互斥理论,而是按「在自己服务器上怎么认出竞态、怎么用最少的改动挡住它、挡不住时怎么留证据」来讲。例子用 PHP + MySQL 和 shell 脚本,思路对 Python、Node 一样适用。

二、先学会认出它:竞态的四种典型形态

形态一,读-改-写(check-then-act)。 最常见也最经典。逻辑是「先查再写」:

$views = $db->fetchOne("SELECT views FROM post WHERE id = ?", [$id]);
$db->exec("UPDATE post SET views = ? WHERE id = ?", [$views + 1, $id]);

两个请求同时读到 100,各自加 1 写回 101,两万次浏览最后统计出一万八。这个 bug 不会报错,只会让数据缓慢失真。判断方法很简单:凡是「先 SELECT 出来、在应用层算、再写回去」的代码,都是候选。

形态二,文件系统层面的 check-then-act。 定时任务防止重入的常见写法:

if [ ! -f /tmp/backup.lock ]; then
  touch /tmp/backup.lock
  run_backup
  rm -f /tmp/backup.lock
fi

! -f 与 touch 之间存在窗口。两个 cron 同时启动(比如部署了新节点、或者上次任务还没结束)就会同时通过检查。更糟的是脚本中途崩溃,锁文件残留,任务从此永远不跑,而没人知道。

形态三,缓存击穿式的重复计算。 缓存过期的一瞬间,N 个请求同时发现 miss,同时去查数据库、同时回写。数据没错,但数据库被打了一波峰值。日志里表现为「每隔一段时间就有一次尖峰」。

形态四,临时文件与原子替换。 写配置文件时直接 file_put_contents 覆盖,另一个进程正好在此时读取,就读到半个文件。这一条在「生成 nginx 配置并 reload」的自动化脚本里特别致命:半截配置进 nginx -t 报错,reload 失败,而脚本已经把原文件覆盖了。

三、数据库侧:把判断交给唯一的执行者

拦截读-改-写最直接的办法是不要把它拆成两步。计数器类更新直接让数据库算:

UPDATE post SET views = views + 1 WHERE id = ?;

一条语句在数据库内部是原子的(行锁保护),不需要应用层加任何锁。这一行改动就能解决绝大多数统计类竞态,且比加锁方案快得多。

需要「先判断条件再更新」的场景,用条件更新(乐观锁)代替「查了再改」:

UPDATE stock SET qty = qty - 1 WHERE id = ? AND qty >= 1;
-- 受影响行数 0 表示竞争失败,业务层重试或返回售罄

关键是检查受影响行数。PDO 用 rowCount(),很多框架封装后的 exec() 返回值可能被改过,要确认它返回的是 affected rows 而不是别的。

带版本号的乐观锁适合「读出整条记录、业务改很多字段、再整体写回」的场景:

SELECT id, title, content, version FROM post WHERE id = ?;
-- 应用层修改……
UPDATE post SET title = ?, content = ?, version = version + 1
 WHERE id = ? AND version = ?;
-- rowCount() == 0 → 记录在读取后被别人改了,提示用户重新编辑

这个方案的价值不只在于「防覆盖」,更在于它把冲突暴露出来了。两个管理员同时编辑一篇文章,后者会收到「内容已被修改」的提示,而不是默默把前者的修改覆盖掉。

唯一约束是另一个常被忽略的武器。想「用户不存在就创建」时,先查再插一定有窗口,正确做法是直接插,靠唯一索引兜住:

INSERT INTO subscriber (email, created_at) VALUES (?, NOW());
-- 捕获 1062 Duplicate entry,说明并发下已经被别人插了,按已存在处理

注意:被唯一索引拒绝的那次 INSERT 会消耗一个自增 ID(如果用自增),在高并发下会造成 ID 空洞。对个人站点无所谓,对需要连续编号的场景要提前想清楚。

四、脚本侧:用原子操作替代「检查后创建」

shell 里做互斥,mkdir 和 flock 都比「检查文件是否存在」可靠得多,因为它们的检查与创建是同一个系统调用。

方案一,mkdir 锁(最简单,跨机器也适用):

LOCK=/var/lock/backup.lockdir
if mkdir "$LOCK" 2>/dev/null; then
  trap 'rmdir "$LOCK"' EXIT
  run_backup
else
  echo "另一实例正在运行,退出"
  exit 0
fi

mkdir 在锁目录已存在时返回非 0,这一判断是原子的。trap ... EXIT 保证正常退出和收到信号时都会清理。缺点是进程被 kill -9 时 trap 不执行,会留下残留锁——所以脚本里还要加一条「锁目录年龄超过 N 小时就认为失效」的兜底。

方案二,flock(推荐,内核级、自动释放):

#!/bin/bash
exec 9>/var/lock/backup.lock
if ! flock -n 9; then
  echo "已有实例在跑,跳过"
  exit 0
fi
run_backup

flock -n 拿不到锁立即返回失败,不阻塞。-w 30 可以改成最多等 30 秒。最大的优点是进程一退出锁就自动释放,kill -9 也一样,不会残留。用它替代所有「touch 一个 pid 文件」的写法,能消掉一大类「任务卡死不跑」的运维事故。

用在 crontab 里就是一行:

*/10 * * * * flock -n /var/lock/crawl.lock -c '/usr/bin/php /www/app/crawl.php' >> /var/log/crawl.log 2>&1

五、文件写入必须是原子的

任何「可能被别的进程同时读取」的文件,写的时候都要走「临时文件 + rename」。rename 在同一文件系统内是原子的,读方要么看到旧文件、要么看到新文件,绝不会看到半个。

tmp=$(mktemp /etc/nginx/conf.d/site.conf.XXXXXX)
nginx_config_generator > "$tmp"
nginx -t -c /etc/nginx/nginx.conf || { rm -f "$tmp"; exit 1; }
mv "$tmp" /etc/nginx/conf.d/site.conf
nginx -s reload

顺序的三个要点:

  • 临时文件必须与目标在同一文件系统,否则 mv 变成跨设备复制,失去原子性。mktemp 放在同目录下就对了。
  • 先 nginx -t 再 mv。如果先覆盖再测试,配置有错时旧配置已经没了,reload 会失败但服务仍跑在旧进程上,下一次重启就起不来。
  • 保留备份。批量改配置前先 cp -a 整个目录,改坏了能秒回。

PHP 里对应的是 file_put_contents($tmp, $data) 后 rename($tmp, $target),不要直接写目标路径。日志文件另当别论——日志是追加写,但「截断日志」这件事本身是竞态源:> app.log 会把其他进程正在写的偏移清掉,导致后续写入出现大量空字节(文件看起来有几 GB 但实际没内容)。正确方式是 logrotate 配合 copytruncate 或者让应用自己 reopen(发 USR1 信号)。

六、缓存重建:用一次「谁抢到谁算」

缓存击穿的解法不是把所有请求排队,而是让只有一个请求去重建,其余请求短暂等待或返回旧值:

$v = $cache->get($key);
if ($v !== null) return $v;

$lockKey = "rebuild:{$key}";
// SET NX EX 是原子的:只有第一个请求能设置成功
if ($redis->set($lockKey, '1', ['nx', 'ex' => 10])) {
    try {
        $v = $db->query(...);           // 真正的重建
        $cache->set($key, $v, 3600);
    } finally {
        $redis->del($lockKey);          // 必须放 finally,否则异常后锁不释放
    }
} else {
    usleep(50000);
    $v = $cache->get($key) ?? [];       // 拿不到就返回空/旧值,不查库
}
return $v;

三个细节决定这个方案是否真的有效:

  • 锁必须带过期时间(ex),否则重建进程崩溃后锁永远不释放,缓存再也建不起来;
  • 释放锁放 finally,异常路径也要清;
  • 没抢到锁的请求要有一条确定性出口(返回旧值或空),不能无限递归重试,否则尖峰变成雪崩。

七、竞态 bug 怎么查:日志要能定位到具体时间点

竞态的特点是偶发,所以排查手段必须建立在「事后能还原时序」上。三条实践:

  • 记录毫秒级时间戳和请求 ID。日志里一行的格式至少是「时间(含毫秒) 请求ID 事件 关键参数」,同一个请求 ID 的所有日志能串起来。没有毫秒精度,两条几乎同时的日志排序是随机的。
  • 在竞态发生的关键位置打点。比如「读到 version=7」「尝试更新 version=7」「更新影响 0 行」,三行日志放在一起,谁抢在前面一目了然。
  • 用 MySQL 的 SHOW ENGINE INNODB STATUS 看最近一次死锁。读-改-写在并发下退化成死锁时,这个输出会给出两个事务各自持有的锁和等待的锁,比任何应用层日志都准。现象是 1213 Deadlock found,说明已经进入锁等待阶段,问题比丢更新更严重。

还有一个反直觉的经验:竞态在测试环境几乎不可复现,因为并发量不够。想主动复现,用 ab 或 wrk 对单个接口打几十个并发就够,不用造大规模流量。这比在线上碰运气等它出现强得多。

八、小结

竞态处理的优先级很明确:能改成原子操作的就不要加锁(views = views + 1、条件 UPDATE、唯一索引),必须判断的情况下用乐观锁把冲突暴露给业务层,脚本互斥一律用 flock 或 mkdir,文件写入一律「临时文件 + rename」。这四条覆盖了个人站长日常遇到的九成场景,改动量小、没有引入新组件,也不需要理解复杂的事务隔离级别。剩下的那一成——真正需要悲观锁、需要分布式协调的场景——再考虑更重的方案。

Last modification:October 31st, 2026 at 12:25 pm

Leave a Comment