三十岁以前的往事(十四):工作后距离惹出p0线上事故最近的一次

mongodb movePrimary 的问题。要是丢了存在 mongodb 中的数据,搜索阿拉丁的建库通路至少有 70% 的流量会受影响,包含天气、金融、智能体等重要业务,且丢的数据没法恢复。现在回想起来都脊背发凉冷汗直冒。

问题发生

2025/10/28 晚 6 点半 ,用于存储架构元信息的 mongodb 集群单分片出现不稳定的情况,这个分片有三个副本,其中一个副本所在的机器负载较高,处于挂的边缘,另一个副本是新扩出来的,刚才开始同步数据,只有一个副本是正常的。排查时发现这个分片的读写流量较高,怀疑是这个分片是默认的 primary 分片导致的,因为这个集群中有很多表都未开启分片,还有一些表虽然开启了分片,但是分片键设置的不合理,大部分数据依旧位于一个分片上,也即和未开启分片的表一样,都在 primary 分片上。

完成了临时止损后,想着能否改一个 primary 分片的配置,让新创建的表都首先写到新的分片上。问了一下大模型,说是可以使用 movePrimary 命令,相关聊天记录如下:

当时已经是晚上七点多,脑子已经不转了,没细想,拿一个测试的 database 试了一下,执行的没报错,就直接在主库执行了。这里需要复盘的一个重要的点是,对于不熟悉的新命令,尤其是非只读命令,必须要去查官方文档,并分析其执行的流程,是否会对线上存量数据的读写造成影响。当前这些步骤都跳过了。噩梦开始了。我们假设老 primary 分片为 s1,新 primary 分片为 s5 。

第一次止损

在 mongo shell 中执行 movePrimary 后,等了三四分钟,发现还是没有执行完毕,猜测可能是集群负载还是有点高,执行命令慢,就直接 ctrl + c 终止了指令的执行,想着明天在执行看看,之后就开始忙别的事情了。大概到了快晚上九点,旁边的同事问我说现在 mongodb 是否还是有问题,因为我们维护的流式任务平台上的几乎所有 job 都无法展示了。

我在确认了集群状态是正常后,想到了之前执行的 movePrimary 命令。于是执行了 db.databases.find({ _id: "<database_name>" }) 进行确认,发现显示的分片还是位于 s1 ,但是在 s1 上查看流式任务平台存储 job 信息的表,发现没了,然后又去 s5 上查了一下,发现上面有 job 信息表的数据。由于 job 表没有开启分片,所以 movePrimary 将数据从 s1 移动至了 s5,但是由于路由中配置的 primary 为 s1,所以查不到数据了。当时吓出了一身冷汗,因为出了这个表外,还有一批存储着重要数据且未开启分片的表,包含 user 表存储有数据推送方的鉴权信息、xxxx_dispatch 表存储有建库链路的路由信息等。虽然线上服务在读取这些表的时候,都添加了降级机制,读不到数据不至于全面断流,但是大部分降级机制都是基于内存的,如果实例重启的话数据就没了。相关的管理平台也会对这些表的数据进行读写。万幸的是,其中特别重要的表都是用于存储配置信息的,所以基本是只读,数据状态比较好维护。当然也不是 100% 没有写,但是概率非常小。

首先先人工处理了 user 表和 job 表,恢复平台的使用。首先为了保险起见,使用 mongoexport 导出了 s5 上的 user 和 job 数据。要是数据因为后续的操作丢了或者脏了就彻底完蛋了。接下来人工在 s1 上重建了表的索引,最后让大模型写了一个导数据的脚本,将数据从 s5 上导回了 s1。s5 上的数据保留没动。注意这个伏笔,幸好没有删 s5 上的数据。

接下来需要将被从 s1 移动至 s5 的表在 s1 上重建索引以及数据。当时想出的方案如下:

  1. 首先列出所有开启分片的表,记为 curr_shard_col_list ,接着列出 s5 上的所有表,记为 curr_s5_col_list。那么对于只存在于 curr_s5_col_list 而不存在于 curr_shard_col_list 的表,那么大概率是因为本次迁移而导致的。因为之前 s1 为 primary shard ,未开启 shard 的表预期应该都位于 s1 上,s5 上预期只有开启 shard 的表。

  2. 根据上述规则生成了集合列表,然后让大模型写了一个重建索引以及数据传输的脚本,对这批有问题的表进行了批量处理。

大模型写的脚本有不少 bug ,调了半天。数据传输是根据 _id 进行 upsert 操作。我这里默认数据基本不会发生非预期变更。总共 100 多张表,在自己开发机上运行时,并发度调的是 20 ,但是发现速度很慢,后面想到,可能是因为存在跨机房数据传输,因为数据库所在的机房是阳泉,而开发机所在的机房是苏州。于是找了一个位于阳泉机房的公用物理机来跑程序,发现速度快了很多。

等忙完已经是晚上十点半了,在导数据的同时还在修一个建库监控 exporter 的 bug ,修到了晚上十一点。接着上面提到的不稳定分片 s1 中的一个副本所在的机器彻底挂了,也即只有一个正常的副本和一个在同步数据的副本,而如果后者直接从正常的主副本同步数据,会增加额外的压力,使得集群更不稳定。被逼的没办法只能打电话给 op ,拜托其发单对机器进行重启,等机器完成重启后再人工拉起其上的实例。等处理完这个问题,已经晚上十一点半了,打车回家。

晚上十二点多洗完澡,看了一下工作群,发现还是有定时任务执行超时的报警。定时任务的执行状态也会存储在 mongodb 中,我怀疑是不是漏了啥表。找到定时任务的代码库,捋了一下代码,找到了对应的表,发现它也是存在于 s5 中但不在 s1 中,不过不在第一次止损的列表中。我当时仅仅觉得可能是筛选脚本写的有问题,重新让大模型生成了一版,又筛出了十几张表。此时我还是没有意识到,movePrimary 触发的任务可能并没有终止。在执行迁移的时候,我没想到其中有一个表有 3000w+ 的数据,所以执行程序时没有用 nohup 挂起,执行了一个多小时才 20% ,又不想重跑,就调了电脑的休眠时间,放到一个墙角边上挂着跑。

那天晚上是两点多睡了,毕竟是线上事故,所以心理压力比较大,也没睡好觉,中间醒了一次,早上八点不到就起了。

第二次止损

第二天到公司,以为没事了,就忙其他的事情了。其中有一个事情是,我发现最近同事有例行任务会在 mongodb 建表,每天建一批,虽然这些表开启了 shard,但是因为没有使用 hash 索引,所以导致数据分布不均,数据全都写到了 primary shard 上。下午的时候找同事对建表逻辑进行了调整,修改为了 hash 索引。但是在调试的时候发现了新的问题。

首先,当然预期 primary shard 还是 s1 。按照同事程序的执行逻辑,首先是建表,然后往里面写入一条数据验证是否成功,接着执行创建索引,最后设置开启分片。由于开启分片的代码写的有问题,需要频繁进行调试。在重新执行前,同事会在 mongo 可视化界面上操作删除表。此时发现,表总是删不干净,现象为通过 mongos 或者可视化界面查询时,表已经查不到了,s1 上也没有了,但是它依然存在于 s5 上。重试了几次发现都是这个现象。另外发现,建表成功后第一次写入的数据位于 s1 上。之前提到,对于未开启分片的表,都会默认建到 primary shard 上,所以这是符合预期的。

又执行了一次 db.databases.find({ _id: "<database_name>" }) ,但是发现主库对应的 primary shard 已经变成 s5 了。wtf????这时我才意识到,之前 ctrl + c 获取并没有完全终止 movePrimary 。这个配置估计是 movePrimary 进行设置的。那么为什么建表成功后第一次写入的数据位于 s1 上,莫非是 mongos 上缓存的数据没更新?发单重启了 mongos 之后,定时任务执行超时的报警又被触发了。

所以实际上从昨天晚上到现在,一直都是 s1 承接未开启分片的读写流量,但是在重启完 mongos 之后,现在变成 s5 了,但是期间 s5 的数据未更新。幸好在第一次止损的时候,在从 s5 往 s1 导入数据后,没有清理 s5 的数据,否则的话又读不到数据了。

对于几个重要的表,又用之前导数据的脚本进行了数据导入,只不过现在改成了从 s1 导至 s5 。这时也不管是否有提前重启的 mongos 让新的数据写入到 s5 的情况了。在完成数据导入后,在业务群里发了通告,如果从昨晚到现在操作过流式任务变更,然后需要人工再 check 一下。

本来想着应该问题都解决了,但是这个时候同事跟我说,他之前建的一些开启了分片的表在 mongo 可视化界面上查不到了。我顿时脑子嗡了一下,如果开启分片的表也收到了影响,丢失了数据,那影响面就更大了。在 mongo shell 中确认了一下,发现虽然通过 show collections 之类的查询指令查不到一些表,并且 db.xxx.getIndexes() 也是空的,但是通过 db.xxx.find() 是可以查到值的,db.xxx.count() 也有值。查询了 config 数据库中 collection 的分片信息,也是存在的,执行 db.xxx.getShardDistribuition() 是有值的。我怀疑是这次 movePrimary 操作导致了部分表的元信息丢失。万幸的是,执行在 s1 上执行 show collectionsdb.xxx.getIndexes() 是正常的,所以修复的方式为:

  1. 挑选了其中一个不重要但是数据量大的分片表,尝试再次执行 db.createCollection(...)db.xxxx.createIndex(...) 命令,确认了数据不会丢失,以及索引可以快速创建。因为如果给一个量级大的表建新索引,建索引的命令会卡很长时间。

  2. 让大模型写了一个脚本,首先导出 s1 中所有表的索引信息,然后根据这个信息修复 s2 的索引,prompt 如下。在进行了单表验证后,执行脚本重建了所有有问题的表。

1
2
# 索引数据导出脚本
编写 python2 代码,dump mongodb 一个数据库中所有的表名以及其索引信息,并以 json 的格式保存在文件中
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
# 数据重建脚本
当前 mongo 集群 aladdin 数据库存在表信息缺失的问题,你需要编写 python2 脚本对其进行修复。
首先,读取 dump 的历史表信息文件
其次,列出问题数据库当前存在的所有表,和历史表信息文件进行 diff,如果历史表信息文件中的表不存在于问题数据库中且执行 db.xxxx.count() 的值大于 0,则表示需要进行重建,进行记录
然后,对于第二步筛选出来的表,执行表创建及索引创建的操作
最后,对于问题集群中存量的表,比较其和历史表中索引信息的差异,如果有缺失,则进行索引的创建。

需要支持可读模式,也即只输出执行计划,不实际执行。
历史 dump 数据的样例如下:

{
"host": "10.182.62.16:17018",
"collections": [
{
"name": "sql_commit_offset",
"indexes": [
{
"key": [
[
"_id",
1
]
],
"background": false,
"sparse": false,
"unique": false,
"expireAfterSeconds": null,
"name": "_id_"
},
// ...
]
}
// ....
]

总结

当完成这篇文章时,时间是 2025/10/29 晚上十点,距离我惹出的故障已经过去一天多的时间了,这里再做一个总结:

  • 对于不熟悉的新命令,尤其是非只读命令,必须要去查官方文档。我事后查了一下官方文档,上面用红字明确写了在执行指令时,数据库最好对外停止服务,以及执行完毕后,需要刷 mongos 的缓存 。线上操作无小事,神经必须时刻紧绷。

  • 这次还是比较幸运的。一是这次出问题的集群主要是只读的,重要表的写流量基本没有。二来第二天协同同事调整 mongo 表设置分片的逻辑时发现了未恢复的故障,进行了及时的二次止损。当前,不确定是否还有别的问题,我最担心的就是在两次导数据期间,有一些更新可能被覆盖,这个后面再观察一周看一下,希望别是提前开香槟 … (二次更新:一周内暂未发现问题)

  • 会建设 mongodb 重要数据及索引信息天级导出及恢复机制,这个安排给另一个同事做了,预期这个 Q 会完成。