只在并行时才出现的 bug:四台 server 在零点几秒内被检查了两次
2026-10-07 之前,Cloudflare 的 cron 每五分钟调用一次探测器,这次调用领取到期的 server,一台接一台地检查。那天我们把它改成了并行:这次调用现在还会往队列里发 19 条消息,每条消息启动一个独立的调用,最多领取 5 台 server;每个调用同时检查最多 3 台。
第一个并行版本在 20:53 UTC 上线。它的第一轮 43 秒就跑完了,原来要三分钟左右。可它也把两台 server 各检查了两次,第二次都在第一次刚结束后零点几秒;下一轮又对另外两台做了同样的事。部署后七分半钟,我们回滚了。这个 bug 比那一天更早就在代码里,只是要等到有第二个进程,才会被撞上。
「领取」是什么意思
每台 server 都有一个下次检查时间。时间一过,它就到期了。每一轮通过调用一个数据库函数来领取到期的 server:函数挑出它们,为每台建一行任务记录。这一行就是领取本身,记下 server 何时被领走,之后再记下工作何时结束。
函数会跳过已有未结束任务的 server,也就是没有结束时间、且是 15 分钟内建的任务。这道在途守卫防止两轮同时检查同一台 server。函数还会加一把锁,任何时刻只有一次领取在运行。
一个任务结束时,探测器要写两样东西:把任务标为已结束;把这台 server 的下次检查时间往后推,大多数 server 推 12 小时。这是对数据库的两次独立请求,不在同一个事务里。
两次写入之间的空档
原来的顺序是先标记任务结束,再推下次检查时间。两次写入之间,这台 server 没有未结束的任务,下次检查时间又已经过了。在领取函数看来,它和一台还没人检查过的 server 一模一样。
只有一个循环时,这个空档里没有人来看。做这两次写入的循环就是唯一的领取方,而它要等当前这批任务全部写完两次,才去领下一批。并行版本上线前的七天里,没有任何 server 在十分钟内出现两个任务。
改成扇出之后,每轮有 20 个调用(cron 那一次,加上队列来的 19 次),谁有空位谁就去领取。那把锁能保证两次领取不重叠,却管不了另一种情况:一次领取恰好落在另一个调用的两次写入之间。这个空档大约只有一次网络往返那么宽。
| 轮次(UTC) | 任务 | run | server | 第二次领取距第一个任务结束 |
|---|---|---|---|---|
| 20:55 | 24 | 24 | 22 | 0.168 秒、0.236 秒 |
| 21:00 | 24 | 24 | 22 | 0.118 秒、0.112 秒 |
这四台都是目录里还没有维护者认领的 server。我们写给运营者的页面告诉他们:每 12 小时一轮,每轮最多 8 个请求。它们每台都在几秒之内收到了两轮。
我们怎么发现的
每次部署探测器之后,我们都会从数据库里把最初几轮读回来核对。大多数检查比的是总数:任务数对 run 数、缺少 attestation envelope 的 run、仍未结束的任务。这些全都对得上,因为每个重复的任务都产出了自己完整的 run。抓到问题的是一项按 server 计的检查:任何 server 都不该在十分钟内有两个任务。20:55 那一轮,24 个任务只覆盖了 22 台 server。
为什么先回滚,而不是往前修
部署之前,发布计划就写好了两条退路:回到上一个版本,或者把扇出关掉。两条都和部署一起获批。回滚只是一条命令,21:00:52 UTC 执行。
往前修意味着改代码、写一条会失败的测试、复审、再部署,最后用了 72 分钟。这段时间里任何一轮都可能再把几台 server 检查两次;21:00 那一轮就在回滚生效前不到 15 秒内又出现了两次。一边是我们的轮次慢一个小时,一边是别人的 server 收到重复流量。我们选了前者。
回滚没有动数据库。那四个多出来的 run 是真实的观测,只是彼此相隔几秒,我们保留了它们。
修复
修复把两次写入换了顺序:现在先推下次检查时间,再标记任务结束。第一次写入进行时,任务仍未结束,守卫照样有效;写入完成后,server 已经不到期了。任何时刻,server 都不会既到期、又没有未结束的任务。
推下次检查时间失败时,探测器重试一次;再失败,仍然把任务标为结束,本次调用在这一轮里不再领取,并记下错误。旧的空档只可能在这条出错路径上重新出现。如果这次更新一行也没匹配上,说明 server 的状态或套餐在这一轮中途变了,任务照常结束,server 按新的节奏重新检查,这是设计内的行为。
现在如果探测器停在两次写入之间,server 已经有了下次检查时间,任务则停在未结束状态。不会有 server 被检查两次,这个未结束的任务会显示在 /zh/status 上。
修复带了两条测试。一条记录数据库调用的顺序,断言推时间在前;另一条在两次写入之间插入第二个领取方,断言它什么也领不到。把顺序换回去,两条都会失败。
更干净的做法是把两次写入放进同一个数据库事务。那需要一次迁移,已经排进计划;调换顺序是当天就能堵上空档的最小改动。修好的版本在 22:13:24 UTC 上线,它的前两轮各有 21 个任务、21 个 run、21 台 server。
我们的收获
只要所有领取都由一个循环来做,原来的顺序就没有问题。代码其实防过「任务刚结束、server 被立刻领回」这件事,但只防同一个调用,而且只在第二次写入失败时才生效。打破「只有一个循环」这个前提的,是两行配置:每轮的队列消息数从 0 改为 19,每个调用同时检查的 server 数从 1 改为 3。空档所在的代码,这两行都没有碰。
一样东西改成并行时,要去找那些只有放在一起才说得通的成对写入。串行系统里,它们之间不会有别的东西运行;并行系统里,迟早会有。
我们怎么核的
范围是 2026-10-07 22:13:24 UTC(修好的版本上线)到 2026-10-08 13:15:31 UTC 之间请求的全部探测任务,共 3,851 个,涉及 2,980 台 server。重复领取的定义是:一个任务的请求时间,距同一台 server 上一个任务的请求时间不到十分钟。我们最密的检查节奏是每 15 分钟一次,所以这样的一对不会是计划内的检查;维护者在一轮中途认领某台 server 时,按设计会立即重新检查它,这种情况也会被算进来,所以这个定义只会多算。结果是 0 对。同一台 server 相邻两个任务,最近的一次相隔 13 分 35 秒。用同一个定义,并行版本上线前的七天里是 0 对,它在线期间是 4 对。表中的间隔是第二个任务的请求时间减去第一个任务的结束时间。所有数字都来自对生产数据库的只读查询。