深入理解 IM 系统

第 6 章

离线同步

Ben 的手机离线了 10 分钟,他有 200 个会话。重新连上时,手机该问服务器什么?

这一章是初稿,会在写完前八章后再修订。

Ben 坐了 10 分钟地铁,一路没有信号。出了站,手机重新连上服务器。这 10 分钟里,别人给他发的消息都没推到:连接不在,推送只是提醒(第 2 章)。现在要靠一次拉取补回来。

问题是:手机拿什么去问?

先说两个词。游标是手机记着的“我已经拿到哪了”;seq 是一条消息在它的会话里的编号,1、2、3……(第 5 章)。

到第 5 章为止,手机重连时用的是第 1 章的 after=<id>:“id 比我手上最大的那个大的消息,都给我。”它一直能用,但有个洞:id 在插入时就分好了,别人却要等提交后才看得见。两条消息同时写,101 号还没提交、102 号先提交了,这时来拉的手机把 after 记成 102,101 号就被跳过了。

Ben 有 200 个会话。这一章要决定:回来的时候,手机带什么去问。

1. 三种问法

先说清楚:下面三种问法都能把消息补齐,在 v1 的规模下没有哪一种会坏。差别在代价,以及它们能说清楚哪些事。

  1. 一个全站的 id(现在的做法):after=1016,手机只带一个数,可数据库仍要翻遍 Ben 的 200 个会话,上面那个洞也还在。
  2. 每个会话一个游标:手机在一个请求里带上 200 对“(会话,我手上最后的 seq)”,每对 12 字节,一共 2.4 KB。服务器看每个会话最新的 seq,比手机的大,就把差的给它。没有洞:seq 在一个会话里按提交的先后分配(第 5 章)。
  3. 每个人一个信箱信箱inbox每人一份的收件索引,记着“有哪些新消息要给你”,有自己按人递增的序号。写扩散时写它,设备按同步游标从它这里拉。在术语表里查看:消息写入时,给每个收件人的信箱记一条“会话 X 有了 seq s”,信箱有自己连续的序号。手机只记一个数,信箱里同步到了哪(同步游标同步游标sync cursor每台设备一个,记着这台设备在自己的信箱里同步到了哪里。设备回来时,从游标之后接着拉。在术语表里查看)。回来时问:“我的信箱里 7,345 之后的,都给我。”

第 1 种和第 2 种都要看 Ben 的每一个会话,所以真正要比的是第 2 种和第 3 种。下面的模拟器里,Ben 的 200 个会话排成格子,最活跃的在左上角;颜色越深,离线时来的新消息越多。

Ben 离线 10 分钟后回来,手机带着 200 个会话的游标去问。服务器要看 200 个会话,你猜其中有几个有新消息?

有新消息(越深越多)服务器读了这个会话的最新 seq

–

每个会话一个游标一个信箱游标
请求(一个接一个)––
手机上传––
手机下载––
服务器读––
多写的信箱条目––
留在服务器上––

按全书的数字,一条消息平均送到 10 台设备,每人平均 1.5 台,也就是 10 ÷ 1.5 ≈ 6.67 个人;一天 400 万条消息、10 万个用户,Ben 一天收 4,000,000 × 6.67 ÷ 100,000 ≈ 267 条。它们不是平均分的:假设第 k 活跃的会话分到 1/k 的份额。

Ben 离线多久 新消息 有新消息的会话(共 200 个)
10 分钟 1.9 条 1.8 个
两次打开 App 之间(一天打开 20 次) 13.3 条 10.7 个
1 天 267 条 91 个

按这张表,10 分钟后回来,200 次查看里 99.1% 什么也没有;两次打开之间,仍有 94.6%。默认那一组,Ben 离线 10 分钟,来了 2 条消息,在 2 个会话里。带着 200 个游标去问,服务器看了 200 个会话,198 次什么也没有;切到“一个信箱游标”,服务器只读了信箱里的 2 条。切到“热闹的晚上”:三个群分别来了 120、80、30 条。每次回复最多 50 条,两种问法都是 ⌈230 ÷ 50⌉ = 5 个请求。两种做法让手机下载的消息一样多、请求一样多,不一样的是手机上传了什么、服务器读了什么。

2. 估算

服务器。 10 万人,每人每天同步 20 次,一天 200 万次;高峰按平均的 3 倍,每秒 2,000,000 ÷ 86,400 × 3 ≈ 69 次。

  • 每个会话一个游标:每次看 200 个会话,高峰每秒 69 × 200 ≈ 13,900 次读。按主键查一行,多半在内存里,一台数据库扛得住。可它和 Ben 有多少会话成正比,和来了多少消息无关:每人 2,000 个会话,就是每秒 139,000 次。
  • 一个信箱游标:每次读一段信箱,高峰每秒 69 次,读多少条就是来了多少条。

手机。 每次同步上传 200 × 12 = 2,400 字节,一天 20 次是 48 KB;Ben 一天收的消息才 267 × 200 字节 ≈ 53 KB。信箱游标只要 8 字节。

信箱的账。 每条消息要给每个收件人记一条,平均 6.67 条。这种“写的时候给每个人各写一份”的做法叫写扩散写扩散write fan-out发消息时给每个接收人的信箱各写一条,读的时候只看自己的信箱。写得多、读得快,适合人数不多的会话。在术语表里查看;反过来,只存一份、每个人读的时候自己去取,叫读扩散读扩散read fan-out消息只在会话的时间线里存一份,每个人读的时候自己去取。写得少、读得多,适合大群。在术语表里查看。第 2 种问法就是读扩散。名字先记下,什么时候该用哪个是第 10 章的事。

  • 写:一天 4,000,000 × 6.67 ≈ 2,670 万条;高峰每秒 139 × 6.67 ≈ 927 次,原来只有 139 条消息的写入。
  • 磁盘:每条 32 字节(用户、信箱序号、会话、seq),一天 2,670 万 × 32 字节 ≈ 853 MB,比消息本身(4,000,000 × 200 字节 = 800 MB)还多。

所以这是一笔交换:每次同步只读真正来了的那几条,换每条消息多写 6.67 行。 按 v1 的数字两边都不大,选哪个都说得过去。

游标说不清的事。 让天平倾斜的是另一件事:Ben 离线时,有人把他拉进了一个新群。手机带着 200 个游标去问,可这个群不在 200 个里,手机不知道它存在,也就不会问。要补上,就得再加一个按人记的版本号:“我的会话列表同步到哪了”。别的设备上的已读(第 14 章)、被移出群、置顶,也都不属于哪个会话的 seq,都是“Ben 这个人身上发生的事”。按会话的游标走到这一步,旁边已经养了一个小信箱。

3. 修好它

每个人一个信箱,有自己连续的序号

在第 5 章写消息的那个事务里,再给每个收件人的信箱记一条:

UPDATE conversations SET last_seq = last_seq + 1 WHERE id = :conv RETURNING last_seq;
INSERT INTO messages (conversation_id, seq, sender_id, msg_id, text) VALUES (:conv, :seq, ...);
-- 每个成员,按用户 id 从小到大:
UPDATE users SET inbox_seq = inbox_seq + 1 WHERE id = :member RETURNING inbox_seq;
INSERT INTO inbox (user_id, inbox_seq, conversation_id, seq) VALUES (:member, :inbox_seq, :conv, :seq);
COMMIT;

为什么这样就没有洞?看两条同时给 Ben 的消息:Ana 的私聊 A,和徒步群里的 C。

  1. A 的事务先锁住 Ben 那一行,拿到信箱序号 7,346;
  2. C 的事务也要改 Ben 那一行,只能等;
  3. A 提交,锁放开;C 才拿到 7,347,再提交。

7,347 不可能比 7,346 先提交,所以在一个人的信箱里,序号的先后就是提交的先后,after=7345 不会跳过任何一条。加锁按用户 id 从小到大,是因为一条消息要锁好几个人:如果一个事务先锁 Ben 再锁 Carl,另一个先锁 Carl 再锁 Ben,两边会永远等着对方(死锁);大家都按同一个顺序锁,就不会。

Ben 被拉进新群,群里会多一条系统消息“Ben 加入了”,它有自己的 seq,写进信箱就是普通的一条(Y,s),信箱表不用多一列。被移出群也一样。

手机记两样东西:一个信箱游标,重连时、打开 App 时从它之后拉,每页 50 条;每个会话最后的 seq,排序、发现缺口,照旧(第 5 章)。

推送也带上信箱序号。手上是 7,345,推来 7,346,就接上;推来 7,348,说明缺了,不管缺的是哪个会话的,从 7,345 之后拉一次就都回来。推送是在各自提交之后发的,7,347 可能比 7,346 先到;所以和第 5 章一样,先等一小会儿,还缺再拉,多拉一次也无害。第 5 章说过,“最后一条丢了推送”要等这个会话的下一条消息才发现;现在等 Ben 的下一条就行,不管来自哪个会话,平均 1,440 ÷ 267 ≈ 5.4 分钟一条。

离线太久

信箱序号连续,还有一个白来的好处:服务器用“信箱最新的序号 − 手机的游标”,不读一行就知道 Ben 落下了多少条。落下太多(这一章定为超过 10 页,500 条),或者游标比信箱保留的还旧(这一章留 7 天,2,670 万条一天,约 6.0 GB),服务器就不翻页了,回一句“太多了”,附上会话列表:每个会话最新的 seq 和最新的一条。顺序要对:先取信箱最新的序号,再取列表;反过来,中间提交的一条会两边都漏掉。先取序号时多拿到的,只是重复,无害。手机把信箱游标挪到这个序号,更早的消息等 Ben 打开那个会话时再往回拉;手机上的历史因此有洞,第 7 章处理。这和 Telegram 的 differenceTooLong、Matrix 的 limited 时间线是同一个办法。点模拟器里的“1 周”:全部下载是 1,869 条、⌈1,869 ÷ 50⌉ = 38 个请求、约 374 KB,而且信箱从旧往新翻,最新的消息最后才到;只回列表是 ⌈200 ÷ 50⌉ = 4 个请求、200 × (200 + 12) 字节 ≈ 42 KB。

4. 代价

  • 写入多了好几倍。 每条消息多写 6.67 行:高峰每秒多 927 次,一天多 853 MB。它和会话的人数成正比,在 500 人的徒步群里,一条消息就是 500 行。群大到写不起信箱时怎么办,是第 10 章的问题。
  • 锁。 一条消息要锁住每个收件人的那一行;而给同一个人的消息,不管来自哪个会话,都排在这个人的锁上。一次写入 5 毫秒,一个人每秒最多收 1 ÷ 0.005 = 200 条。普通人碰不到;收消息特别多的账号,比如客服号、机器人,会先碰到。
  • 两种游标。 手机要同时记信箱游标和每个会话的 seq,还要处理“太多了”这条很少走的路,它最容易在真出事时才发现是坏的,要专门测。
资源 每个会话一个游标 一个信箱游标
服务器磁盘 IO 每次同步读 200 行会话(高峰每秒约 13,900 次,多在内存里) 每次读一段信箱(高峰每秒 69 次);每条消息多写 6.67 行(高峰每秒多 927 次)
服务器磁盘容量 不变 每天多 853 MB,留 7 天约 6.0 GB
手机数据 每次同步上传 2.4 KB,一天 48 KB 每次 8 字节

5. 其他答案

  • 就用每个会话一个游标:会话不多、写入又贵时,这是合理的选择:不多写一行,也没有信箱要过期;代价是每次同步都看一遍所有会话,再加一个按人的版本号管会话列表。
  • Telegram:文档说,私聊和普通小群共用每个用户一条事件序列(pts),频道和超级群各有自己的序列。启动时只调用一次 updates.getDifference,结果里的 updateChannelTooLong 指出哪些频道还要单独补;比保留的还旧,得到 differenceTooLong,客户端重新取最新状态。
  • Matrix 的 /sync:规范里,客户端带上次的 next_batch 作为 since,是一个按人的游标;新事件太多时,房间的时间线标成 limited,只给最近的几条和一个 prev_batch,要看更早的再往回翻。MSC4186 写道,它随房间数和离线时间变慢,几千个房间的账号第一次同步可能要几十分钟。
  • Facebook Messenger 的 Iris(2014):一个全序的更新队列,装着新消息、已读状态的变化等;一个指针记着已经发到你手机的位置,手机离线时它停住,新的更新照样排进来。
  • 微信的 seqsvr(InfoQ, 2016):每个用户一个递增的序列号,客户端带着已经同步到的最大值来,服务器把更新的给它。只要求递增、不要求连续。
  • 只推通知,再来拉(推拉结合):推送只说“你的信箱到 7,346 了”,手机自己来拉。代价是每条多一个来回;这一章的推送仍然带着消息。

6. 这一章的决定

第 6 章加的一块:每个人一个信箱表。消息层写消息的同一个事务里,给每个成员的信箱各记一条,带上这个人自己的信箱序号;手机只记一个信箱游标,重连时从它之后拉。
Ana发件箱Ben信箱游标消息 → ← ACK推送带 seq、信箱序号;重连从游标拉一个程序连接层持有长连接;记着 用户 → 连接消息层(收)取 seq;一个事务写消息和信箱;回 ACK分发层(发)推送带信箱序号;按信箱游标补齐业务层(旁路)Ana 在这个会话里吗存储层唯一键与信箱表发送者+消息ID会话+seq会话.last_seq信箱:用户+序号

到这里,Ben 不管离线多久,回来都能补齐。可补回来的消息放在哪?App 被杀掉再打开,又得从头拉一遍。下一章:手机上的本地数据库。