Backend

同步历史邮件时陷入 6000 天的死循环,排查记录

1625 字约 6 分钟

摘要历史邮件同步的日志一直在跳动,“已扫描 8 万封”的数字不断增加,匹配记录却始终为 0。按当前速度估算,跑完需要 16 年。问题出在把稀疏的 UID 当成连续序列来遍历。

在 Auto Email Sender 中,有一个核心功能:把用户邮箱里与导师的全部往来邮件拉到本地。这样在浏览某位教授的名片时,右侧就能看到“上周二发过自荐信”“三天前教授回信,表示名额已满”之类的记录。

某天,一位老用户升级版本后在群里反馈:

“你们这历史同步是不是坏了?我都挂机一整天了,日志看着一直在动,但导师详情页里的往来记录永远是空的!”

我先去查本地数据库的同步状态。

Worker 没有报错,CPU 占用正常,心跳也正常。数据库里的游标每次刷新都增加 200:

  • scanned_count: 87,800
  • 过了一会儿刷新:scanned_count: 88,600

扫描一直在推进,但匹配到的导师邮件数量始终是 0。

再看当前正在扫描的 UID 字段,是 1,289,341,200。

这是十亿级的数字。按每秒扫几百个数字的速度估算,要把这个邮箱扫完,大约需要 6,000 天,也就是 16 年。

“合着程序打算陪用户从本科一直读到博导退休……”

UID 不是数组下标

这个 bug 的根源是:旧代码把 IMAP 协议中的 UID 当成了数据库自增 ID 或数组下标来用。

通俗比喻:在荒漠里挨个敲门找 50 个人

为什么程序要跑 16 年? 想象一条长 12 亿米的公路,上面只零星住着 50 户人家:

  • 有的人家门牌号是 1 号,下一家突然是 10 万号,再下一家是 12 亿号;
  • 旧代码的做法:不管路上有没有房子,从 12 亿米开始,每隔 200 米停车,下车对着空气喊“有人吗”,再把计数器加 200,然后一直走到头;
  • 正确的做法:直接问邮局(IMAP 服务器)要花名册:“把这条路上住人的门牌号全部告诉我”。邮局 1 秒钟就给出 50 个号码,直接去这 50 家敲门就行。
什么是 IMAP 中的 UID?

在 IMAP 协议(RFC 3501)中,UID 是邮件在某个文件夹内的唯一标识符。 服务器保证它严格单调递增,但不保证它是连续的。 比如一个邮箱里可能只有 50 封邮件,它们的 UID 却可能是 [1, 15, 100000, 1000000042]。 网易、腾讯或某些旧版 Exchange 邮箱,UID 可能直接采用时间戳、分布式 Snowflake 算法或高位偏移,一开始就是几十亿。

旧代码的假设是: “既然当前最新一封信的 UID 是 12 亿,那每次往前推 200 个数字(比如从 1200000000 查到 1199999800),一直查到 0,就能翻完所有历史邮件。”

这段循环扫过的大部分数字并不对应真实邮件,而是大片的空号段。服务器每次都回复“这个区间里没有邮件”,程序照常记下“已扫描 200”,然后接着往下查。几十万次 IMAP 请求都是这样空转过去的。

概念协议中的实际含义旧代码的假设
UID单调递增、但不连续的标识符连续递增的序号(1, 2, 3...)
最大 UID只表示最新一封邮件的编号误以为代表邮箱里有几十亿封邮件
UID 区间大多是空号段误以为每次都能拿到 200 封真实邮件

增量同步为什么正常

同一个系统里的增量同步一直运行正常。

增量同步会记住本地已看过的最大水位线 last_seen_uid,然后向服务器发送 SEARCH UID {last_seen_uid}:*。服务器只返回真实存在的 UID,有几封就返回几封。

问题出在新安装的客户端上。它面对的是从第一封邮件到现在的全部历史。

如果直接全量下载,用了十几年、存有几万封工作邮件和垃圾邮件的老邮箱,很可能撑爆本地磁盘和内存。为了分批处理,旧代码采用了倒序区间遍历,结果导致了这次的问题。

UIDVALIDITY:另一个需要注意的地方

只看 UID 还会遇到另一个问题:UIDVALIDITY。 如果服务器重建了索引或迁移了数据,文件夹的 UIDVALIDITY 会改变,所有历史 UID 都会重新分配。此时如果还用旧水位线查询,可能漏掉所有新邮件,或者把邮件对应错。

不再全量扫描,从已发送邮件反向查找

既然不能在稀疏的 UID 空间里空转,怎样才能既不占满网络,又能精准拉到导师的往来邮件?

我考虑过两个极端方案:

  • 极端 1:遍历本地所有导师的邮箱,逐个去云端搜索。 不可行。一个用户的数据库里,通过批量抓取可能收集了 3,000 个导师的公开邮箱。对 3,000 个邮箱逐个发 IMAP SEARCH,耗时和连接数都很高,还容易被服务商的风控拦截。
  • 极端 2:完整拉取收件箱。 同样不可行。收件箱里有大量广告、验证码和外卖推销,全部拉下来没有意义。

最终采用的方案是:以“已发送箱(Sent Items)”为入口。

1. 锁定 Sent 文件夹 ──> 仅按时间过滤最近两年的邮件 (SEARCH SINCE 01-Jan-2025)
2. 拿到真实 UID 列表 ──> 只批量抓取 HEADER (From, To, Date, Message-ID)
3. 本地比对 ──> 筛选出“我确实给哪些导师发过信” (通常只有几十人)
4. 精准查询收件箱 ──> 仅针对这几十个确实发过信的导师,去 Inbox 查他们的回信
为什么这个方法效率高?
  1. 数据量小: 普通人两年内真正发出的学术或业务邮件通常只有几百封,拉取 Header 只需一两秒;
  2. 目标明确: 只关心“联系过的导师有没有回复”,没联系过的几千个导师不需要占用一次收件箱检索;
  3. 不再空转: SEARCH SINCE 返回的是服务器上真实存在的邮件 UID,避开了十亿级的数字空洞。

总结

在处理老旧或复杂的网络协议时,进度条很容易误导人:

  1. 不要把离散标识符当成连续空间: 无论是 IMAP UID、数据库 Snowflake ID 还是分布式分页的 Cursor,它们都只是定位标签,不是连续的集合;
  2. 监控指标要对应业务结果: “已扫描数量”只能说明 CPU 在空转,有效产出率和实际处理的实体数才能反映系统是否健康;
  3. 先从约束强的一端入手: 正向过滤代价太高时,可以看看下游有哪些强约束的集合(比如已发件人),反过来作为过滤条件,复杂度常常能大幅降低。

评论