删库跑路这件事,终究是轮到我了

工作两年多,终于亲手‘体验’了一次传说中的删库跑路。一次测试环境数据迁移演练中,误将删除旧字段的SQL执行到了生产环境。事故发生后通过业务止血,恢复字段,数据恢复等一系列措施,最终恢复了误删字段及历史数据

文章目录

删库跑路这件事,终究是轮到我了

“删库跑路”这个词,我以前只在各种程序员段子、梗图里见过。作为一个稳健的后端开发,平时也常把“敬畏生产环境”挂在嘴边,甚至还特意把 Navicat 中生产数据库的连接改成了醒目的红色,用来警示自己不要误操作。

理论上是这样的。

直到一个平常的傍晚,我正在测试环境演练支付服务新版本的升级流程,流程本身并不复杂:加字段、迁数据、升级服务,删除废弃的旧字段。一路演练下来十分顺利,我已经在思考晚饭吃什么了。

是啊吃什么

做到删除旧字段这一步时,刚好有人找我排查一个问题,于是我就顺手打开了生产数据库的连接查了几条数据,发现问题并不在我这,于是调侃了对面几句。

聊完之后,我继续刚才被打断的任务:“该到删除旧字段环节了”。于是我复制 SQL,粘贴,看了一眼,点击执行。转眼间,SQL 行云流水地跑完了。哟,这升级方案居然一次就跑通了,不愧是我!然后视线扫到了左边那个红色的数据库连接:这是生产环境。此时时间是17:51。

沉默是今晚的康桥。

我有些不可置信地看了看刚刚跑完的SQL:ALTER TABLE ... DROP COLUMN ...,那一刻,“删库跑路”这四个字从段子照进了现实。虽然严格来说我没有删库,也没有跑路,而是删了几个字段,但此时这两者对我来说没有什么区别。

It was at that moment, he knew, he f**ked up.

终于反应过来之后,大量报错也开始涌入告警群里。新支付系统虽刚正式上线不久,但已经推动了一个存量业务对接使用了,此时有不少真实的支付流量正打进来!

于是,本该是一次普通上线演练的傍晚,从此刻开始正式变成了生产事故处理现场。

先救业务,紧急止血

虽然当时的第一反应是“完了”,但事故显然不会因为我懵了几秒就暂停。我突然想起早上和+1讨论升级方案时,提到目前接入新支付系统的存量业务还保留着原来的支付实现,并且做了一个切流开关。一旦新支付系统出现问题,可以直接切回他们原有的实现。

没想到这个开关这么快就用得上了,此时时间是17:53,距离误操作已经过去了大概两分钟,我赶紧联系了对应负责人让他切一下开关,对方很快操作了开关,并告诉我这个配置有缓存,不能立即生效,还要等几分钟。

趁着这个时候,我赶紧找领导说明了情况,毕竟做错承认,挨打站稳。领导的反应倒是比我想象中平静得多,大概是这种场面见得多了。他让我想确认其他业务有没有受到影响,同时联系值班运维准备数据恢复。事实证明,事故现场保持淡定还是很有用的。

随后我挨个联系其他业务确认情况,确认影响面没有继续扩大。18:01,缓存终于失效,告警群暂时恢复了宁静。

此时数据库还没真正恢复,但是没有支付流量进来了,接下来可以慢慢处理数据库的问题。首先是恢复表结构,将误删的字段重新加回生产表,到这一步支付服务已经恢复了支付能力,但历史数据还处于不可用状态。毕竟 ADD COLUMN 可以加回字段,但无法加回丢失的数据。真正麻烦的部分,才刚刚开始。

几十G备份,慢慢下吧

好消息是,我们有备份。

运维上来之后,告诉我数据库开启了定时全量备份,最近一次全量备份是当天下午3点,配合3点后的 binlog 可以做到数据无损恢复。

那么理论上的恢复方案就很明确了:

下载全量备份及 binlog

恢复数据库

找到对应的 binlog 起始位置

依次重放后续 binlog

停在误操作发生之前

得到 17:51 前的数据库状态

方案有了,那就开工。但坏消息是,备份大小有几十个 G。

于是事故处理进入了戏剧性阶段:等下载

等待的过程中,我开始琢磨一个问题:阿里云 RDS 的备份机制如此完善,难道这种“将数据库恢复到某个时间点”这种操作,还需要用户自己把几十个 G 数据下载下来,再手动恢复、找重放起点、重放 binlog 吗?这不对吧?

于是我开始查阿里云的文档,还真让我查到了。阿里云 RDS 本身就提供了按时间点恢复数据库的功能。只需指定一个时间,平台会自动创建一个新的 RDS 实例,恢复指定时间之前最近的全量备份,再把后续 binlog 重放到指定时间点。

我喜出望外,赶紧和运维确认了一下新方案,确认可行后,也没理由继续和几十 G 的下载进度条培养感情了。

阿里云已经造好了轮子

既然决定换方案,接下来的事情就简单多了。在阿里云工程师的远程指导下,我们成功进入了数据恢复页面,现在的问题从“怎么恢复”变成了另一个看起来简单得多的问题:

恢复到几点?

从 Navicat 的历史执行记录来看,那条删字段的 SQL 是在 17:51:00 执行的,最理想的情况是恢复到执行前一秒,也就是 17:50:59,这样几乎不会损失任何正常数据,也便于我们后续定位 binlog。但想了一下,还是没敢这么玩。考虑到本地电脑和服务器之间可能有时间差,恢复时间点卡得这么严,万一把 DROP COLUMN 也一起重放进去,那就很有节目效果了。

所以我们最终保守地选择了 17:50:20。

19:20,运维在阿里云技术人员的协助下开始执行按时间点恢复。然后有到了熟悉的环节:等!

不过这次就快多了,不到半小时,新的临时 RDS 实例就创建好了,数据库数据回到了 17:50:20。

这四十秒里发生了什么

数据库虽然已经成功恢复到了 17:50:20,但距离误操作发生还有大约四十秒。

如果这期间有正常的支付请求写入数据库,那么恢复出来的快照里就少了这部分数据。理论上,我们还需要找到这段时间对应的 binlog,把误操作之前的正常变更重放回去。

于是接下来要解决的问题变成了:

17:50:20 到 17:51:00,这四十秒里到底发生了什么?

20:15 左右,我们开始下载事故时间点附近的 binlog,随后完成 dump。

打开文件之后,那种无力的感觉又回来了:大量事件、position、时间戳,以及事务信息和 SQL 混在一起。真要靠人力一点点找出哪些操作属于支付系统,哪些操作需要重放,多少有种大海捞针的味道…

不过,时代变了,大人!将 dump 后的 binlog 交给“天才程序员”,告诉它时间范围,涉及的数据库表,以及我们的目标:找到 17:50:20 到误删字段之间所有需要重放的正常业务操作。原本需要人工在大量日志中来回搜索校对的工作,变成了先由 AI 帮忙快速筛选归类,再人工对筛选结果进行确认。

不得不说,这种任务太适合交给 AI 了。(不过后面才想到,可以利用工具将 binlog 转为原始 sql,过滤掉删字段及之后的操作,再进行重放)

最终分析结果比预想中简单很多:该时间区间内,没有支付系统相关的数据变更需要重放。

至此,问题就简单很多了。剩下的事情,就是把恢复后的数据重新塞回生产环境。

把数据塞回去

现在我们手里已经有了两份数据:

  • 生产环境:除了被我删掉的几个字段之外,其他数据都是最新的
  • 临时 RDS:删字段时间点之前的完整快照

经过简单思考,我决定采用以下方案:

  • 导出临时 RDS 的数据
  • 在生产环境中新建一个 backup 临时库,将这份数据导进去
  • 使用 UPDATE JOIN 语句通过主键跨库联表,完成缺失字段的回填

简化后的回填 SQL 类似如下:

UPDATE prod.t1 AS pt
JOIN backup.t1 AS bt
ON pt.id = bt.id
SET pt.deleted_column = bt.deleted_column;

确认无误后执行回补,之后再对记录数、关键字段值等数据进行校验。

20:47,数据恢复完成。

从 17:51 按下那个执行按钮,到现在过去了将近三个小时,至此这段闹剧总算告一段落。

后续复盘和整改

事后复盘时,误操作的原因其实并不复杂:人的注意力是有限的,在多个任务中切换时,“上下文”难免会混乱,导致本该跑在测试环境的 SQL 跑到了生产。

再谨慎的人,也有犯迷糊的时候;把“敬畏生产环境”建立在人不会犯错的前提下,显然不太靠谱。所以真正需要整改的,其实是长期以来生产数据库的访问权限过于宽松。

这次事故之后,这个口子被正式收紧了。生产数据库原本开放给开发人员使用的内网转发端口被收回,只能通过跳板机提供的中转链接进行访问。新的连接方式除了能留下完整的审计日志之外,也从权限层面限制了危险操作,类似 DDL 以及部分危险 DML,会被跳板机直接拒绝。

从这个角度看,这次事故也算推动了一件早该做的事:

“敬畏生产环境”,从依赖开发人员自觉,变成了程序帮你管控。

至于我自己,在这次事故中也算获得了一个“难得”的经验:

在有完善备份机制的前提下,如何将数据库恢复到某个时间点。