Junie's Blog

同步历史邮件跑进 6000 天死循环的排查记录

全文共 1784预计阅读 6 分钟

在 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 查他们的回信!
为什么这个算法极度高效?
  1. 数据量极小: 普通人两年内真正发出去的学术/业务邮件一般不过几百封,拉 Header 只要一两秒;
  2. 真实靶心: 我们只关心“联系过的导师是否有回音”,从未联系过的几千个导师根本不需要浪费一次收件箱检索;
  3. 告别空转: SEARCH SINCE 返回的是服务器底层真实存在的邮件 UID 数组,彻底消除了十亿级数字空洞。

总结

在面对老旧或复杂的网络协议时,进度条是最容易骗人的东西:

  1. 别把离散标识符当成连续空间: 无论是 IMAP UID、数据库 Snowflake ID 还是分布式分页 Cursor,它们只是“定位标签”,不是“物理集合”;
  2. 监控指标要面向业务事实: “已扫描数量”只能证明你的 CPU 在空转发热,**“有效产出率”与“真实处理的实体数”**才是衡量系统健康的唯一准绳;
  3. 逆向思维往往是性能瓶颈的解药: 当正向过滤代价太高时,不妨看看下游有哪些强约束集合(如已发件人),反向作为过滤条件,常常能让复杂度呈指数级下降。

评论