同步历史邮件跑进 6000 天死循环的排查记录
在 Auto Email Sender 中,有一个核心体验设计: 把用户邮箱里过往跟导师的所有邮件往来完整拉到本地,这样当你在浏览某位教授的名片时,右侧能一目了然看到:“上周二发过自荐信”、“三天前教授回信表示名额已满”。
某天一个老用户升级版本后,在群里吐槽:
“你们这历史同步是不是坏了?我都挂机一整天了,日志看着一直在动,但导师详情页里的往来记录永远是空的!”
我立刻去拉本地数据库的同步状态。
离奇的一幕出现了:Worker 没有报错,CPU 占着,心跳也十分健康,数据库里的游标每次刷新都在按步长加 200:
scanned_count: 87,800- 过了一会儿刷新:
scanned_count: 88,600
它勤恳得让人心疼,但匹配到的导师邮件数量死死卡在 0。
再定睛一看当前正在扫描的 UID 字段:1,289,341,200。
一个十亿级别的天文数字!
拿着计算器简单一除:按照每秒扫几百个数字的速度,要想把这个邮箱扫到 0,大约需要 6,000 天(整整 16 年)!
“合着程序打算陪用户从本科一直读到博导退休……”
UID 根本不是数组下标
这个滑稽 bug 的根源,在于前人把 IMAP 协议里的 UID,当成了类似数据库自增 ID 或者数组下标来用。
通俗比喻:在荒漠里挨个敲门找 50 个人为什么程序会跑出 16 年的天文数字? 想象一条长达 12 亿米的漫长公路,上面总共只稀稀拉拉住了 50 户人家:
- 有的人家门牌号是 1 号,下一家突然跳到了 10 万号,再下一家是 12 亿号;
- 旧代码的蠢办法:它不管路上有没有房子,非要从 12 亿米开始,每隔 200 米停一次车,下车对着空气大喊“有人吗”,然后走回车里把计数器加 200,誓要把这 12 亿米一寸不落地走完;
- 正规的做法:直接去邮局(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,不就能把所有历史邮件翻完了吗?”
它扫描的根本不是真实存在的邮件,而是一片浩瀚无垠、空无一物的“数字荒原”!
几十万次 IMAP 请求发过去,服务器每次都礼貌地回复:“在这个区间里没有任何邮件哦”,程序毫不知情地记上一笔“已扫描 200”,然后继续在这片大沙漠里徒步!
| 概念名词 | 正确的协议事实 | 旧代码的天真幻想 |
|---|---|---|
| UID | 单调递增的稀疏离散标识 | 连续递增的序号(1, 2, 3...) |
| 最大 UID | 仅仅代表最新一封信的编号 | 误以为代表邮箱里有几十亿封信 |
| UID 区间 | 极大可能全为空洞的抽象数字段 | 误以为每次能固定拉回 200 封真实邮件 |
增量同步为什么能跑通?
有意思的是,同一个系统里的“增量同步”却一直表现良好。
因为增量同步写得很规矩:记住本地看过的最大水位线 last_seen_uid,然后直接向服务器发命令:SEARCH UID {last_seen_uid}:*。服务器只返回真实存在的 ID 列表,有几封就下几封。
但新安装的客户端面对的是**“从开天辟地到当前”**的历史数据。
如果无脑全量下载,很多用了十几年、存了几万封工作和垃圾邮件的老邮箱,当场就能把本地磁盘和内存撑爆。为了“优雅分批”,前人自作聪明地搞出了这个倒序区间遍历,最终酿成大祸。
UIDVALIDITY:悬在头顶的另一把达摩克利斯之剑在 IMAP 里,光看 UID 还会踩另一个巨坑:
UIDVALIDITY。 如果服务器重建了索引或迁移了数据库,文件夹的UIDVALIDITY就会变。此时所有的历史 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 在空转发热,**“有效产出率”与“真实处理的实体数”**才是衡量系统健康的唯一准绳;
- 逆向思维往往是性能瓶颈的解药: 当正向过滤代价太高时,不妨看看下游有哪些强约束集合(如已发件人),反向作为过滤条件,常常能让复杂度呈指数级下降。