同步历史邮件时陷入 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 查他们的回信为什么这个方法效率高?
- 数据量小: 普通人两年内真正发出的学术或业务邮件通常只有几百封,拉取 Header 只需一两秒;
- 目标明确: 只关心“联系过的导师有没有回复”,没联系过的几千个导师不需要占用一次收件箱检索;
- 不再空转:
SEARCH SINCE返回的是服务器上真实存在的邮件 UID,避开了十亿级的数字空洞。
总结
在处理老旧或复杂的网络协议时,进度条很容易误导人:
- 不要把离散标识符当成连续空间: 无论是 IMAP UID、数据库 Snowflake ID 还是分布式分页的 Cursor,它们都只是定位标签,不是连续的集合;
- 监控指标要对应业务结果: “已扫描数量”只能说明 CPU 在空转,有效产出率和实际处理的实体数才能反映系统是否健康;
- 先从约束强的一端入手: 正向过滤代价太高时,可以看看下游有哪些强约束的集合(比如已发件人),反过来作为过滤条件,复杂度常常能大幅降低。