# 第 6 章：离线同步

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

深入理解 IM 系统 · https://im.liko.page/zh/offline-sync/

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

问题是：**手机拿什么去问？**

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

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

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

## 1. 两种问法

先看现在的做法。第 1 章的 `after=<id>` 用的是一个全站的 id：手机只带一个数，在一台服务器、一张表上完全够用。它的毛病是上面那个洞，而且它说不出“这个会话缺了哪条”。第 5 章已经给了每个会话一个连续的 seq，回来时该按什么问，就有了两种选择。

先说清楚：**这两种问法都能把消息补齐**，在 v1 的规模下哪一种都不会坏。差别在代价，以及它们能说清楚哪些事。

1. **每个会话一个游标**：手机在一个请求里带上 200 对“（会话，我手上最后的 seq）”，每对 12 字节，一共 2.4 KB。服务器看每个会话最新的 seq，比手机的大，就把差的给它。没有洞：seq 在一个会话里按提交的先后分配（第 5 章）。
2. **每个人一个信箱**：消息写入时，给每个收件人的信箱记一条“会话 X 有了 seq s”，信箱有自己连续的序号。手机只记一个数，信箱里同步到了哪（同步游标）。回来时问：“我的信箱里 7,345 之后的，都给我。”

下面的模拟器里，Ben 的 200 个会话排成格子，最活跃的在左上角；颜色越深，离线时来的新消息越多。

*（互动图：请在网页上查看 https://im.liko.page/zh/offline-sync/）*

按全书的数字，一条消息平均送到 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 条。这种“写的时候给每个人各写一份”的做法叫写扩散；反过来，只存一份、每个人读的时候自己去取，叫读扩散。第一种问法（每个会话一个游标）就是读扩散。名字先记下，什么时候该用哪个是第 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 章写消息的那个事务里，再给每个收件人的信箱记一条：

```sql
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。它和**会话的人数**成正比：私聊一条消息写 2 行，500 人的徒步群里一条就是 500 行；群里一个下午聊 1,000 条，就是 50 万行，不管这些人回不回来看。
  按会话的问法也会被放大，只是放大在读的一边：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**：[文档](https://core.telegram.org/api/updates)说，私聊和普通小群共用每个用户一条事件序列（`pts`），频道和超级群各有自己的序列。启动时只调用一次 `updates.getDifference`，结果里的 `updateChannelTooLong` 指出哪些频道还要单独补；比保留的还旧，得到 `differenceTooLong`，客户端重新取最新状态。
- **Matrix 的 `/sync`**：[规范](https://spec.matrix.org/latest/client-server-api/#syncing)里，客户端带上次的 `next_batch` 作为 `since`，是一个按人的游标；新事件太多时，房间的时间线标成 `limited`，只给最近的几条和一个 `prev_batch`，要看更早的再往回翻。[MSC4186](https://github.com/matrix-org/matrix-spec-proposals/pull/4186) 写道，它随房间数和离线时间变慢，几千个房间的账号第一次同步可能要几十分钟。
- **Facebook Messenger 的 Iris**（[2014](https://engineering.fb.com/2014/10/09/production-engineering/building-mobile-first-infrastructure-for-messenger/)）：一个全序的更新队列，装着新消息、已读状态的变化等；一个指针记着已经发到你手机的位置，手机离线时它停住，新的更新照样排进来。
- **微信的 seqsvr**（[InfoQ, 2016](https://www.infoq.cn/article/wechat-serial-number-generator-architecture)）：每个用户一个递增的序列号，客户端带着已经同步到的最大值来，服务器把更新的给它。只要求递增、不要求连续。这正是本章信箱的另一种取舍：号可以由单独的服务成批发出，不用锁住每个人的信箱行，也容易分到多台机器上；代价是跳号不再说明缺了消息，“最新序号 − 游标”也算不出落下了多少条。
- **只推通知，再来拉**（推拉结合）：推送只说“你的信箱到 7,346 了”，手机自己来拉。代价是每条多一个来回；这一章的推送仍然带着消息。

## 6. 这一章的决定

*（互动图：请在网页上查看 https://im.liko.page/zh/offline-sync/）*

**决策卡**

- 问题：Ben 离线 10 分钟，有 200 个会话。按会话去问，每次同步都要看 200 个会话，大多什么也没有，而且问不到手机还不知道的会话（比如刚被拉进的群）。
- 选择：每个人一个信箱：消息写入的同一个事务里，给每个收件人的信箱记一条，按用户 id 的顺序加锁，取这个人连续的信箱序号。手机只记一个信箱游标，从它之后拉，每页 50 条；推送带上信箱序号，跳号就拉。落下超过 10 页、或游标比信箱保留的 7 天还旧，服务器只回会话列表，手机把游标挪到最新。
- 代价：每条消息多写 6.67 行（高峰每秒多 927 次，一天多 853 MB），和会话人数成正比；给一个人的消息都排在他那一行的锁上（每秒最多约 200 条）；手机要记两种游标；“太多了”这条路很少走，要专门测。
- 什么时候重新考虑：手机上的历史有洞、翻到再补（第 7 章）；群大到写不起信箱怎么办（第 10 章）；一个人有几台设备，每台一个游标（第 14 章）；信箱的写入要不要等到确认之后（第 28 章）。
- 其他答案：每个会话一个游标（不多写，但每次看所有会话）；Telegram（按人的 pts 加每个频道的 pts，太旧就重取最新状态）；Matrix /sync（since 游标，limited 时间线加 prev_batch）；Facebook Iris（按人的有序更新队列）；微信 seqsvr（按人递增）；只推通知再来拉（多一个来回）。

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