测试不要碰生产数据库:一次 Cloudflare D1 清库事故复盘
· 开发 · Cloudflare · 数据库
[!NOTE] 本文由 GPT-5(Codex)编写,基于本次事故的实际排查记录整理。

先说结论
这次事故最后留下的最重要结论,不是「以后运行测试时小心一点」,而是更硬的一条:默认不做任何测试,尤其不让测试接触生产数据库。
新的逻辑如果写错了一小块,通常只是局部缺陷;代码改动有提交、有 diff,就可以回滚。可一旦测试拥有生产库的写权限,验证动作就可能把整个系统带进事故状态,而且往往比线上 bug 更难察觉。
这次 Cloudflare D1 的 Users 表异常,就是一个很典型的例子:测试文件里本来只是为了每个用例清理数据,配置却把测试绑定到了生产 D1。清理逻辑没有越权,也没有报错,它只是忠实地执行了自己被要求执行的 SQL。

发生了什么
某个时间点,远端 D1 的 Users 表突然只剩下几十行,用户 ID 和创建时间也呈现出明显的「重建后重新注册」特征。依赖账号库的功能开始连锁出问题:登录状态不完整、资产找回流程异常,管理员本应收到的找回申请邮件也没有送达。
邮件问题看起来像 SMTP 故障,实际上更早就失败了。找回申请的通知链路是:
生图后端提交申请
↓
通知 Auth Worker
↓
按 UID 查询管理员邮箱
↓
SMTP 发信
管理员通知发给 UID 1。数据库被清空后,Auth Worker 查询不到这个用户,于是返回「用户不存在」,邮件发送根本没有走到 SMTP。也就是说,后面的邮件故障只是前面数据损坏的一个表现,并不是邮件服务先坏了。
是什么证据指向了测试配置
排查时最有价值的线索不是某一条错误日志,而是数据库的查询统计。D1 Insights 记录到事故窗口附近出现了多次整表操作,其中包括:
DELETE FROM users
DELETE FROM posts
DELETE FROM settings
DELETE FROM comments、likes、sessions 等
而仓库里的 test/index.spec.ts 恰好有一个 resetTestData(),每个测试开始前会按同样的思路清理这些表。它的初衷是让用例之间互不影响,但清理 SQL 没有区分「本地 D1」和「生产 D1」。
真正致命的是配置链:测试运行器读取了生产的 wrangler.jsonc,而这个 Worker 配置中的 D1 binding 明确写着 remote: true。于是测试不是在一个临时 SQLite 数据库里跑,而是在 Cloudflare 远端的 forum-db 上跑。
这也解释了事故为什么隐蔽:
- 测试命令本身看起来完全正常;
- 每个
DELETE 都是测试代码预期的动作;
- 没有 SQL 拼错,没有异常堆栈,也没有「生产环境」字样出现在测试文件里;
- 只有把测试代码、Vitest 配置、Wrangler 配置和 D1 查询统计放在一起,才能看出完整链路。
为什么没有把责任归给最近一次部署
事故调查很容易先盯着最近的一次 Worker 部署,但时间线并不支持这个结论。D1 异常发生在前,最近一次 Worker 部署在后;部署版本本身也没有包含「清空整张 Users 表」的业务路径。
生产 Worker 中的删号逻辑是按单个用户 ID 删除,并不是无条件的 DELETE FROM users。而测试重置恰好是无条件清理。两者从 SQL 形状上就不同。
因此更准确的表述是:我们找到了极强的触发链和代码来源,但没有 Cloudflare Audit Logs 权限,不能仅凭现有证据点名具体执行命令的人。 事故复盘应该区分「谁写了危险逻辑」「谁运行了它」和「为什么系统允许它发生」这三个问题。
数据是怎么救回来的
Cloudflare D1 的 Time Travel 提供了事故前的恢复点。先把数据库回滚到异常发生前的书签,再对事故后产生的用户行做合并:
- 历史账号优先保留,避免覆盖原有密码、验证状态和账号资料;
- 事故后新注册、且邮箱和 ID 都没有冲突的账号才合并回去;
- 邮箱重复的重建行不重复创建;
- 生图库里只有资产、账号库里没有用户的 UID,继续走人工找回,不凭资产自动伪造邮箱或密码;
sqlite_sequence 的用户 ID 高水位抬到不低于生图库中的最大 UID,防止新注册复用孤儿资产的 ID。
最终账号表从异常状态恢复到完整历史数据,并合并了事故后的少量新账号。恢复之后还需要重新检查外键引用、重复邮箱、重复 ID、管理员账号和通知开关,不能只看「Users 行数变多了」就宣布结束。
新的硬契约
事故之后,我们没有选择「下次测试时记得小心」。那种规则依赖每个人当时有没有想起来看配置,正是这次事故的成因之一。
现在的约定是:
- 不做任何测试。 默认只做静态代码审查、
git diff、rg、配置核对和必要的静态类型分析。
- 如果用户明确要求测试,必须先检查 Worker 配置文件。 要核对实际生效的配置路径、D1 binding、
remote 值、环境覆盖和命令行参数。
- 只有确认独立本地数据库且
remote: false,才允许测试。 不能凭文件名猜测「这是本地配置」,也不能看到命令里没有 --remote 就默认安全。
- 不恢复测试目录和远程测试 harness。 这次事故已经证明,危险不一定藏在生产代码里,也可能藏在「只用于验证」的目录里。
- 小提交、可回滚。 新逻辑先保持改动边界足够小;发现缺陷时回滚提交,比让一个有写权限的测试环境替我们发现问题安全得多。
这不是反对验证,而是把验证动作的权限降到它应有的范围:代码可以被阅读、类型可以被检查、配置可以被审计,但任何验证动作都不能拥有删库权限。
最后
数据库事故最可怕的地方,通常不是那条 SQL 有多复杂,而是它看起来太普通了。resetTestData() 是普通名字,DELETE FROM users 是普通语句,remote: true 也是普通配置;它们单独看都不够惊人,串在一起却足以让登录、找回和邮件通知同时崩掉。
所以这次复盘最后只保留一句话:生产数据库不是测试工具的输入,也不是测试失败后的清理场。