凌晨00点23分,回车按下。屏幕滚过一行"Query OK, 2000000 rows affected"。我的睡意瞬间没了,脑子只有一个念头:库里完了。
标题里说的"为省5-10美元差点毁库",是真的。我是那个创始人,也是那个按下回车的人。一个每月省下不到10美元的决定,叠加一条AI助手生成的删除指令,让200万条核心数据消失,网站停摆整整24小时。事后我在内部复盘会上说了三遍"全是我的错",不是甩锅给AI,是因为回头看每一步,其实都有拦下来的机会,是我自己一步步走过去,还顺手把护栏拆了。
这篇文章把整个事故从头到尾摊开写:降成本的决定怎么埋雷、AI那条SQL为什么会错得这么离谱、24小时里我们怎么找数据、后来用什么流程防再炸。如果你是独立开发者、初创小团队的技术负责人,或者手头有任何一台跑着生产库的服务器,这篇值得看完。特别是最后那套自查清单,我要是早点照着做一遍,后来那些事根本不会发生。
1. 事故源头:省5美元还是交付压力?
表面上这是一个"贪便宜"事故,但我复盘以后必须说实话:不是贪,是累。节省预算的动机在那一刻看起来理性无比,实际上是被交付压力逼出来的仓促决定。
1.1 降配决策:数字看起来太正确了
当时我们的产品是一个带实时互动功能的社区站点,日活不算高,但有一张核心活动记录表,里面躺着大约200万条用户互动数据。这个量级在任何正经数据库里都不算大,问题出在我们用了某云厂商的低端入门档实例,CPU、内存、IO都有硬限制。
月底拉账单的时候,我看到数据库实例的费用在整个云成本里占比越来越高。按当时用量,升一档规格每月要多掏大概五六美元,退一档规格每月少付七八美元。我盯着这个数字算了一晚上:用户增长短期内不会有爆发,活动记录表大部分是冷数据,高峰期读写压力不算极端……于是做了一个决定——把实例降一档,每月省那几美元。
这个决定窗口期是凌晨1点,我刚在预发环境修完一个数据问题,连续两天没睡够。现在让我评价:在精神状态正常、有充分测试的情况下,降配不是绝对不能做,但必须至少观察一周的监控曲线,确认CPU、IO、连接数都有余量。我当时只看了一眼平均负载,根本没看峰值打满的时间段,就把实例规格降了。
1.2 一个隐藏的技术债:我们关了自动备份
降配之后真正致命的是另一个决定——为了压缩存储成本,我们关闭了数据库的自动备份,理由是"反正每天都有业务快照,真要出问题可以从上游消息队列重新灌数据"。
这句话听起来像有备份策略,实际上是个大坑。业务快照确实在跑,但那是针对用户资料表的;而那张200万条的活动记录表,属于"删了也可以从行为数据再算出来"的类型。注意,是"理论上可以算出来",不是"算出来不费劲"。我当时把这两件事混为一谈了。
更离谱的是,因为实例降配后磁盘空间缩水,数据库可用空间一度只剩不到15%。我当时的想法不是扩容,而是"该清理一波旧数据了"。清理数据本身没错,错的是我没有任何清理规范和备份兜底,就想当然地让AI助手帮忙生成删除语句。这就像家里的电线老化了不想换,先想的是"把耗电的电器扔了"。
2. 翻车的那条SQL:它"聪明"到了错误的程度
这里必须把那条SQL的事故细节拆开讲,因为很多人以为事故原因是"AI生成了离谱的DROP语句",实际根本不是。那条SQL看起来太正常、太聪明了,聪明到让我忽略了它底层的数据分布假设是错的。
2.1 当时的对话和生成的命令
我当时的原始诉求是这样的:
"帮我看下这个表,把已经过期、或者过期时间还没填充的那些活动记录清理掉,条件带上来源标记,别误删。"
AI助手给出的SQL大概是:
DELETE FROM event_logs WHERE (expires_at < NOW() OR expires_at IS NULL) AND source = 3;乍看没有任何问题。过期了删掉,没填充过期时间的也删掉,来源限定为3,这不是很严谨吗?我当时甚至觉得它考虑得比我周全,连"过期时间为NULL"这种边界都想到了。
问题恰恰出在"过期时间为NULL"这个边界上。
2.2 "IS NULL清理"如何变成"全部清空"
我们这张event_logs表,早期版本根本没有expires_at这个字段。后来产品加了一个"过期时间"的概念,但老数据的expires_at全部是NULL,新数据也只有一部分按新逻辑写入了具体时间。
我知道老数据多,但我不知道具体比例。我以为老数据占30%、新功能上线后大多数数据都有了过期时间,执行清NULL是安全的。实际执行时的数据分布是这样:全表200万行里,expires_at为NULL的行数是195万,加上正常过期条件命中的部分,一次性删掉了998750条……不对,是整表9成以上。
更糟的是,source = 3这个条件也不是我以为的含义。那张表里source字段是个整数枚举,由于历史版本枚举值定义变过两次,生产环境里source=3的数据根本不是当初文档里说的"测试来源",而是某一整批真实用户行为数据。
两个条件叠加,AI正确地执行了我描述的逻辑,但执行在了一堆我不知道的数据假设之上:假设NULL是少量异常,实际是常态;假设source=3是过滤条件,实际是全命中。200万条数据,执行完剩了不到20万条。
2.3 为什么执行前没有拦住
这是我至今最痛的一点。这条SQL不是偷偷摸摸跑的,是我自己复制到生产环境执行的,执行前我还有足足两分钟可以拦下来。
怎么拦?最简单的:先把DELETE改成SELECT COUNT,跑一遍看影响范围。我当时连这一步都没做,因为太信任AI生成的结果格式了——它甚至用了一对反引号把表名标出来,看起来非常规范。而且这条SQL是在一个事务里运行的?不是。我们用的是自动提交模式,DELETE执行完就直接提交了。
对生产库执行大量DELETE的致命性,我在培训文档里读过无数次:锁表、阻塞读、无谓放大。但那一刻这些知识全被"AI写得应该没问题"的信任感盖过去了。这是非常危险的认知偏差。
3. 从零点到午夜:恢复的完整时间线
事故发生后24小时里,我们经历了最漫长的一天。这里不写成小说,只把每个时间节点和真实动作列出来,希望同行在同样处境下至少能少走一点弯路。
3.1 凌晨00:30到01:00:监控比人先清醒
DELETE执行完大约两分钟后,监控告警先响了:错误率飙升,登录接口大面积失败。原因很快定位——活动记录表是整个信息流和登录校验链路的下游,一张表空了,依赖它的服务几乎全部不可用。
我第一时间想的是"回滚"。把刚才的事务回滚一下就行,对吧?不行。自动提交模式下DELETE已经落盘,事务早就关了。我又想从备份恢复,于是登录云控制台找到备份列表,屏幕上的"无可用备份"四个字,是我近几年看到的最冷冰冰的文字。
3.2 清晨2:00到6:00:确认没有备份后的挣扎
没有备份,不代表数据完全没救。数据库的binlog还在,里面记录了每一条DELETE语句的原始行数据变更,理论上可以把删除前的数据通过逆向binlog拼接出来。我们立刻开启binlog解析,发现这个方案有两个现实障碍:
binlog只保留最近24小时,而我们这种低配实例的binlog默认保留周期更短,好在事故发生后还来得及抢救,但解析工具要求逐条还原,200万条被删数据转换成几百万条INSERT语句,执行到一半就会把本来就不多的磁盘余量打满。
这个方向没放弃,但我们同时准备第二条路:从别的数据系统里"拼"出这200万条记录。用户画像表里有部分活动的聚合结果,行为上报的原始消息队列虽然在我们的低配实例上经常积压,但幸运的是积压的这批数据居然覆盖了大部分时间范围。一个个对账、补全、回填,实际是从凌晨忙到白天。
3.3 白天12:00到22:00:从业务日志和异步数据里"缝补"
数据恢复的中段最煎熬的,是"能恢复多少"始终是个不确定数。我们已经知道,通过binlog能恢复大概60%,通过消息队列和其他旁路数据能补到85%,剩下15%涉及一张早已被覆盖的历史维表,可能永远补不回来。
为了那85%,我们写了一堆临时脚本:从用户行为日志里解析会话、从消息队列里重新消费、从聚合报表反推明细。每一步都有坑,比如消息队列里的数据可能和原表里的数据存在唯一键冲突,导致恢复脚本反复报错;比如部分生产服务在网站半停摆状态下继续写入脏数据,加了隔离条件才处理干净。
到晚上22点左右,核心数据恢复到了可用状态,网站恢复访问。表面上是"24小时恢复",实际上恢复的并不是原封不动的200万条数据,而是一套通过多来源拼合出来的数据,最关键的活跃会话数据经过对账后基本完整,但历史详情型数据的严谨性,我没法跟用户保证百分之百。
4. 复盘:我把它归罪于AI一个月,但根因不是它
事发之后第一个月,团队内部讨论时我总会不自觉地说"那条SQL是AI写的"。后来我强迫自己换了一个角度重看,发现把责任推给AI既不公平,也掩盖了真正应该解决的管理问题。
4.1 五个"如果"的可能性
我把事故回放了一遍,列了五道如果:
- 如果降配前看了峰值监控:实例不会在事故发生前就处于临界状态,磁盘告警可能提早几天触发,我们会更从容地扩容。
- 如果关闭自动备份前做过恢复演练:哪怕只有一次演练,就会发现"从消息队列重新算"根本不具备短期恢复能力。
- 如果清理数据前先让AI生成的是SELECT:影响范围一眼就能看到。
- 如果在生产库上设置了大事务锁或DELETE上限:查询要么失败,要么至少提醒我这笔操作异常巨大。
- 如果当天不是凌晨加班状态:我大概率会在执行前多问一句"这条件到底会命中多少行"。
没有任何一条"如果"需要高级技术,全是基础工程纪律。AI只是把这些纪律缺失集中引爆了——它让一个不熟悉数据分布的人,也可以生成一条看似专业的、能直接执行在生产库上的危险语句。
4.2 代价昂贵后的制度修补
复盘会的结论是,第一责任人是我。作为创始人兼技术负责人,我既没有建立变更流程,又违反了"生产环境禁止手动执行大范围UPDATE/DELETE"这条最基础的规范,还让团队在成本压力下做出了关闭备份的决定。这些决策的每一环,都是可以在当时用一个简单动作拦下来的。
我们没有引入复杂的平台工具,而是补了几条务实的制度:
第一,所有涉及DELETE的SQL必须通过一个"影响预估"步骤:先以SELECT COUNT估算目标行数,超过某个阈值时需要至少两位同学复核。第二,备份策略改成"必须能通过恢复演练验证",不能只是说有备份。第三,AI生成的SQL我仍然用,但它的输出只作为草稿,作为生产环境执行前必须由人工重新审视条件语义。
5. 后来我们怎么防止"AI + 数据库"再炸一次
制度是纸面的,真正能防止事故的是一套可执行的流程。下面这些是我们现阶段真实在用的做法,不一定适合所有团队,但至少能帮你在类似情况下多一道保险。
5.1 给AI建立边界:只让它生成SELECT
我现在让AI写SQL时的固定要求是:先给我SELECT,不要直接给我DELETE。需要清理数据时,也让它先输出影响行数的预估查询,并且把这个查询先跑完,看到数字了,再由人来决定是否转成DELETE。你可以直接在提示词里写清楚:
"只输出SELECT COUNT语句,不要输出DELETE/DROP/UPDATE。如果我要删除的数据涉及多表,先把每张表的预估行数分别查出来。"
这个限制直接堵住了最危险的自动提交DELETE。AI仍然能帮你分析条件、评估逻辑,但最后一步的危险动作必须由人肉触发。
5.2 给数据库建立护栏:权限、事务、快速回滚
我们对生产库的账号权限做了收紧。日常使用账号只拥有DML权限,没有DDL权限;执行批量DML时,强制要求:
-- 第一步:影响预估 SELECT COUNT(*) FROM event_logs WHERE (expires_at < NOW() OR expires_at IS NULL) AND source = 3; -- 第二步:把要删的数据备份到一张临时表 CREATE TABLE event_logs_bak_20260214 AS SELECT * FROM event_logs WHERE (expires_at < NOW() OR expires_at IS NULL) AND source = 3; -- 第三步:分批删除,每次LIMIT 5000,事务内执行 BEGIN; DELETE FROM event_logs WHERE id IN ( SELECT id FROM event_logs WHERE (expires_at < NOW() OR expires_at IS NULL) AND source = 3 LIMIT 5000 ); -- 人工确认影响行数符合预期后再COMMIT COMMIT;不要小看LIMIT分批这一步。200万条记录一次DELETE和一个DELETE只删5000行,在锁粒度、日志量、回滚代价上完全是两回事。真的执行错了,至少可以等这一批事务结束前及时回滚。
5.3 给"省钱"定规矩:预算红线不该是技术决策
最后说回那5到10美元。我现在制定了明确的预算策略:存储、备份、数据库规格这类基础设施的支出,不属于"随手优化"的范畴,任何变更至少提前一个迭代周期做容量评估,并且必须带监控数据和回退方案。省下的钱如果是以可用性为代价,那这笔钱本质上是负债,不是节省。
如果你是小团队,我的建议是:把备份成本当成必选项,不要为了省一点存储费关掉它。如果真要节省成本,先从那些不影响核心路径的、有冗余可言的资源开始,而不是拿生产数据库做实验。
回顾这次事故,我能给出的最诚实的总结只有一句:技术上的错误可以用流程修复,认知上的侥幸才是真正需要警惕的东西。每次当我再想"应该没事"的时候,就会想起凌晨那行"2000000 rows affected",然后老老实实把SELECT先跑一遍。