数据被误删或误更新之后:先停手、再取证、最后才谈恢复

2026-07-29

数据截至 2026-07,各产品的额度与报错口径以官方最新说明为准。

多数人把这类事故归错因了:他们以为问题出在”AI 写错了一条 SQL”,于是复盘时把力气全花在怎么让模型别写错上。真正决定损失大小的,是发现异常之后的头几分钟你干了什么。 我见过的绝大部分不可逆损失,都不是那条误删语句造成的,而是发现之后有人说”我再跑一遍试试”、“我从测试库导一份回来对齐一下”——这两句话下去,原本能靠回滚窗口捞回来的数据,就真的没了。

这篇只讲一件事:数据层出问题之后的处置顺序。站内另有一篇 AI 误删文件后的找回路径 讲的是工作区里的文件被删被覆盖怎么捞,靠的是版本控制和编辑器的本地历史;还有一篇 生产事故里 AI 的能力边界 讲的是事故当中该不该让模型上手、让它做什么。本篇夹在这两者中间:对象是数据库里的行,不是磁盘上的文件;关注的是顺序和判断,不是要不要用 AI。

一、第一分钟:先停手,别急着”再跑一遍”

先说结论:发现数据不对,第一个动作是让写入停下来,不是查原因,更不是修复。

原因有三层,一层比一层要命。

第一层,你还不知道错误是不是在持续发生。如果那段逻辑跑在定时任务、消息消费者或者一个还在重试的后台流程里,你查原因的这几分钟里它可能又跑了几轮。等你想明白,坏数据的范围已经和你刚才看到的不一样了。

第二层,很多恢复手段依赖”事故发生那一刻”的状态。基于时间点的恢复、事务日志重放、快照回滚,本质都是把库拨回到某个时刻。这些机制的可用范围取决于日志和快照的保留策略,各家云厂商和自建方案的规则不同且会调整,具体数值以官方最新说明和你自己的配置为准——但机制上有两个共同点,跟你拖多久直接相关。一个是保留窗口按时间往前滚:事故那一刻是固定的,窗口的尾巴却一直在往后挪,你耗掉的每一分钟都在把这个可恢复的时刻往窗口外推。另一个是日志空间通常也有容量上限,写入量越大,旧日志被轮转清掉得越快,重跑一次全量任务可能一口气吃掉本来还够用的余量。

顺着第二层还有一笔账不在窗口里:事故之后写进来的合法数据越多,你选择”把库拨回事故前”这条路要丢掉的东西就越多。停手不只是保住恢复能力,也是在压低后面每条路线的账单。

第三层,也是最容易被忽视的:重跑会破坏”证据链”。误更新最难的部分不是恢复,是知道哪些行被改了、原值是什么。如果你在没取证之前重跑,成功的那次会把失败那次留下的线索(更新时间戳、审计表里的记录、日志里那一批 ID)盖掉一部分,让你之后连”该恢复多少”都说不清。

停手的具体动作,按优先级排:暂停触发这段逻辑的调度或消费者;把 AI 助手、Agent 的自动执行关掉;如果是人工在终端里跑,就把那个会话留在原地别关(历史命令是证据);对外服务能降级就降级,别为了”看起来正常”让它继续写。

有一种情况例外:如果错误正在放大且你已经确认了触发点,那么关掉触发点本身就是止血,可以直接做。除此之外,任何带写入的动作在取证之前都不做

二、分因:先判断这是哪一类损坏

处置方式取决于损坏的类型,而不是损失的规模。下面这几类需要分清楚,它们的恢复路径完全不同。

误删(DELETE / DROP):行没了。好消息是”缺失”这件事本身容易识别,坏消息是原值只能从外部来源找回。

误更新(UPDATE 漏了 WHERE):行还在,值不对。这类最阴险,因为业务上看起来一切正常,可能几天后才被发现,而那时新的正常写入已经和坏数据混在一起。

误插入 / 重复写入:多出来的数据。听起来最轻,但如果下游做了聚合、发了通知、扣了库存,副作用已经外溢了。

结构变更(迁移脚本跑错环境):字段被改类型、被删、约束被加。这类往往伴随隐式的数据转换,转换本身就是有损的。

同步类事故:把测试数据同步进了生产,或者把生产的一部分覆盖成了别的环境的内容。这类的特征是范围整齐得反常——整表、整个时间段。

判别表如下,用现象倒推成因:

现象大概率成因怎么验证处置动作
某张表行数骤降,主键连续段缺失带条件的 DELETE,条件写宽了对比缺失 ID 的分布是否与某个业务字段区间重合;查该表的更新/删除审计记录停写;确定缺失区间;从快照或时间点恢复到临时库再回填
整张表为空但表结构在TRUNCATE 或无条件 DELETE查逐行变更日志:无条件 DELETE 会为每一行留下删除记录,TRUNCATE 一般只留一条表级操作。自增序列有没有被重置要看引擎(MySQL 的 TRUNCATE 会重置 AUTO_INCREMENT,PostgreSQL 默认保留序列),别当通用判据停写;若确认是 TRUNCATE,逐行回放这条路多半走不通,直接走快照或时间点恢复
表不存在了DROP,多半来自迁移脚本或建表脚本跑错库查迁移记录表与 CI 日志,确认哪次发布在哪个环境执行停发布;确认目标环境;从备份恢复结构与数据
行数没变,但某个字段大面积同值UPDATE 漏 WHERE按该字段分组计数,看是否出现异常集中;若 updated_at 由触发器或 ORM 自动维护,再看这批行的时间戳是否挤在很窄的区间里。注意反向不成立:裸 SQL 没显式写这个字段时它不会变,「时间戳没动」不等于「没被改过」停写;时间戳圈得出来就用它圈,圈不出来退回逐行变更日志或审计表;再从历史版本取原值比对回写
只有一小撮行不对,且分布随机应用逻辑 bug 或并发写覆盖抽样看这些行的关联记录是否也异常;查是否有并发的两个写入路径不急着批量修;先定位代码路径,否则修完还会再来
数据”多”了,出现重复业务单据重试没有幂等,或消费者重复消费按业务唯一键分组找重复;看重复记录的创建时间间隔是否接近重试间隔(有退避策略的话,间隔会逐次拉长而不是恒定)停消费;先补幂等键,再清理重复,顺序反了会边清边生
数值型字段整体精度丢失或截断迁移改了字段类型对比迁移脚本与当前表结构;抽查小数位结构层面的转换通常不可逆,直接走备份取原值

这张表不是让你对号入座就完事,它的用处是:在你动手之前,逼你先说清楚”我认为发生了什么”以及”我用什么证据支持这个判断”。说不出来就别动手。

三、取证:恢复前必须先固定下来的几样东西

取证的目标只有一个——把”现在的状态”和”我知道的线索”冻结住,让后面的任何操作都是可撤销的。

第一,做一份现场副本。 不是备份策略里的那种备份,是此刻这个坏掉的状态的副本。听起来反直觉:坏数据为什么还要留?因为恢复过程本身可能出错,而坏状态里包含着”哪些行被动过”的信息,一旦被覆盖就找不回来了。

文件型数据(导出文件、SQLite 单文件、对象存储里的落地文件)直接整份复制走:

cp -a ./data ./data.incident-$(date +%Y%m%d-%H%M%S)

这里有个前提别搞混:对还在运行的数据库实例,直接 cp 它的数据目录拿到的是一份撕裂的副本——复制过程中文件还在被改写,恢复时大概率起不来。实例还活着就别走文件复制这条路,用数据库自己的备份/快照机制,或者先把实例停下来再复制。

关系库做一份逻辑导出,只导受影响的库或表就够,别为了求全把时间拖长:

pg_dump -Fc -d "$DATABASE_URL" -t public.orders -f incident-orders-$(date +%s).dump

第二,固定影响范围。 把”受影响集合”落成一个可复现的查询,而不是记在脑子里。最省事的圈法是靠时间戳:那批被误更新的行,updated_at 往往挤在一个很窄的区间里。但这条路有个前提——这个字段得由数据库触发器或 ORM 自动维护。如果误操作是在客户端里直接跑的一条裸 SQL,而那条语句没有显式写 updated_at,它就不会变,此时”时间戳看着正常”完全不能证明数据没被动过。

前提不成立时,按这个顺序退:先找业务侧的审计表或变更流水;没有就翻数据库的逐行变更日志(MySQL 的 row 格式 binlog、PostgreSQL 的逻辑解码输出),它们记的是每一行的改动而不是语句本身,是最硬的证据。这条路有两道事先条件,事故当天补不了:一是这类日志得在事故发生前就已经开着——MySQL 要开二进制日志且格式是 row,PostgreSQL 的逻辑解码要求 WAL 级别到位并且当时已经存在可读的复制槽或订阅,事后再去改配置,改之前那段时间的改动是拿不到的;二是”旧值记不记得全”还取决于另一组开关,MySQL 看 binlog 的行镜像设置,PostgreSQL 的 UPDATE 想拿到完整旧值需要表上有相应的复制标识设置,默认配置未必给你原值。这两件事都该在平时确认,不该在事故当中才第一次去查;再没有,就只能拿一份事故前的备份恢复到临时实例,和现状做全表比对,用差异反推范围。三条路都是把范围变成一个可以重跑的查询或一份主键清单文件,导出来存好,它是你后面对账的基准,也是你向别人解释”我改了哪些行”的唯一依据。

第三,抓变更来源。 是谁、在哪、执行了什么。如果是发布带来的,翻代码:

git log --oneline -20 -- migrations/
git show <commit> -- migrations/

如果是 AI 助手或 Agent 执行的,去看它的执行记录和会话历史,确认它实际跑了什么——不是它说它跑了什么。这两者经常不一致,相关的坑我在 Agent 谎报执行成功 里单独写过。

第四,记时间线。 开一个文本文件,从发现异常那一刻开始,每做一个动作写一行,带时间。事故处置里最常见的混乱是两个人同时在修,互相覆盖。有一份共享的时间线,至少能保证第二个人接手时知道前面发生过什么。

第五,确认恢复资源是否真的存在。 在决定走哪条路之前,先去看:备份任务最近一次成功是什么时候,快照列表里有没有可用项,时间点恢复的可用区间覆不覆盖事故时刻。很多团队是在这一步才发现备份任务已经静默失败了很久。这一步必须在动手前做,因为它决定了后面所有选项。

四、恢复:按代价从低到高排的四条路

顺序很重要,永远先试代价低、可逆的那条。

路线一:从历史版本直接取原值回写。 适用于误更新且你有审计表、变更流水或事件溯源记录的情况。做法是把原值从历史里取出来,比对受影响集合,逐条回写。优点是范围精确,不影响其他数据;前提是你的系统真的记了历史。

路线二:恢复到临时实例再回捞。 这是最推荐的通用路线:把备份或时间点恢复的结果恢复到一个新实例,而不是覆盖生产。然后在临时实例上跑对账、导出你要的那部分,再回写生产。它的关键价值是可逆——中间任何一步判断错了,生产还是原样。

路线三:整库回滚到事故前的时间点。 只有在损坏范围极大、且事故后到现在的正常写入可以承受丢失时才考虑。回滚意味着你要丢掉事故之后所有的合法写入,这笔账要算清楚:那段时间有没有用户下单、有没有对外发过回调。回滚之前必须把这段时间的增量数据导出来,否则你是用一个事故换另一个事故。

路线四:从上游或下游重建。 数据仓库、日志系统、消息队列的存档、对账文件、甚至对方系统的回执,都可能保留着足够的信息把数据重建出来。这条路慢,但在前三条都断掉时是唯一的活路。

无论走哪条路,回写生产之前做三件事:在临时实例上先跑一遍验证;回写用事务并且先在小范围试一批;准备好这次回写本身的撤销方案。回写脚本自己写错的概率,比你想象的高。

顺带说一句 AI 的位置:让它帮你写对账 SQL、解释一段迁移脚本、把导出文件比对成差异清单,是合适的;让它直接连生产执行修复,不合适。它看不到你没告诉它的上下文,而事故处置里最贵的信息恰恰都在上下文里。权限该怎么收,可以看 Agent 权限给太大之后

五、什么情况下别再折腾了

有几个信号出现,就该停下来换条路,或者升级到更高层级去决策,而不是继续在终端里试。

信号一:你连”受影响的是哪些行”都说不清。 范围不明的情况下做批量修复,等于用一个未知覆盖另一个未知。这时候正确的动作是回到取证,把范围查清楚,哪怕多花两小时。

信号二:同一个修复方向你已经试了两次没成。 第三次多半也不会成。人在事故里很容易陷入一种越修越窄的思维惯性,只在同一个假设下换参数,这个状态我在 AI 修不好时的思维循环 里描述过,人自己也一样会掉进去。停下来重新问:我最初的成因判断,有没有可能是错的?

信号三:修复动作本身开始产生新的不一致。 比如你回写了主表但没同步关联表,或者补了数据但缓存和搜索索引没刷新。出现这种迹象说明你的修复范围没框全,继续下去会积累出更难查的问题。

信号四:损失已经外溢到系统之外。 通知发出去了、钱扣了、对方系统已经收到并处理了。到这一步技术恢复解决不了全部问题,必须同时启动业务侧的处置,闷头改库反而会让两边对账更困难。

信号五:时间成本超过了重建成本。 如果这批数据能从上游在可接受的时间内重跑出来,就别在恢复上耗着。工程判断不是”必须把原来那份救回来”,是”用最小代价回到可信状态”。

止损点最好在事故开始时就定:给自己一个时间盒,到点没进展就切换路线并且叫人。事故当中的人是最不适合判断”我还差一点就成了”的。

六、避坑清单

坑一:发现异常后第一反应是重跑。 为什么会踩——重跑是最省脑子的动作,而且很多时候确实有效,这个习惯是在日常调试里养成的。怎么避——把”数据层异常一律先停写”写进值班手册,并且和调试场景明确区分开:调试可以重跑,生产数据不对不能重跑。

坑二:直接在生产库上做恢复。 为什么会踩——省事,而且大家心里默认”恢复是往好处走的”。怎么避——固定一条规则:恢复目标永远是新实例,回写生产只走经过验证的最小差异集。

坑三:以为有备份就等于能恢复。 为什么会踩——备份任务配好之后没人再看,失败告警可能早就被静音了。怎么避——定期做恢复演练,真正把备份恢复到一个实例跑起来,而不是只看任务状态是绿的。没演练过的备份不能算数。

坑四:给 AI 助手的数据库连接串是可写的。 为什么会踩——配一个只读账号要多花十分钟,而且写权限”偶尔需要”。怎么避——默认给只读连接,写操作走单独的、需要显式切换的通道。这不只是防误删,也是把数据外流的口子一并收窄。

坑五:迁移脚本没有回滚方向。 为什么会踩——写正向迁移时业务压力大,回滚脚本”以后补”。怎么避——把回滚脚本作为迁移合入的必要条件,并且在预发环境真的执行一次回滚。改类型、删字段这类有损操作,正确做法是拆成”先加新字段、双写、再切读、最后删”,每一步都可停。

坑六:修复过程没有留痕。 为什么会踩——事故当中觉得记录浪费时间。怎么避——一开始就开一个时间线文件,命令和结论都往里贴。它在两个时刻救你:交接的时候,和事后复盘发现”某一步做错了要往回推”的时候。

坑七:只修数据不修触发它的路径。 为什么会踩——数据修好了,压力解除了,代码那边就拖着了。怎么避——把”补上防护”作为事故关闭的条件之一,通常是三样:危险操作的条件校验、执行前的影响行数确认、写权限的收窄。少了这些,同一个事故还会再来一次。

坑八:把恢复出来的数据当成已核对过。 为什么会踩——恢复动作完成后有种任务结束的松弛感。怎么避——回写后必须做一次独立对账,用和恢复不同的口径去数:行数、关键金额汇总、和上游系统的交叉核对,至少两项对得上才算完。

收束:一份可以贴在值班手册上的自检

数据事故的处置能力,不体现在你多会写恢复 SQL,而体现在你能不能忍住不动手。顺序本身就是技术:停手保住了可选项,取证保住了判断依据,恢复到临时实例保住了可逆性。三者都做到,最坏情况也只是慢,而不是不可逆。

发现数据不对时,从上往下过一遍:

  1. 写入停了吗?调度、消费者、Agent 的自动执行,都停了吗?
  2. 现场副本做了吗?坏状态本身也留了吗?
  3. 受影响集合能用一条可复现的查询说清楚吗?
  4. 变更来源确认了吗——是谁、在哪个环境、执行了什么?
  5. 备份和时间点恢复的可用范围,去核实过了吗,不是假设?
  6. 恢复目标是新实例吗?回写生产的是经过验证的最小差异集吗?
  7. 回写脚本自己的撤销方案,准备了吗?
  8. 对账用了和恢复不同的口径吗?
  9. 时间盒定了吗,到点谁来叫停?
  10. 触发路径上的防护补了吗,还是只修了数据?

十条里有一条答不上来,就说明还没到动手的时候。

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。