# 第 9 章：v1：一条可靠的通道

> 一条消息从 Ana 的手机到 Ben 的手机，v1 在路上加了哪些保证？每一样花了多少？

深入理解 IM 系统 · https://im.liko.page/zh/v1-reliable-channel/

第 3 章开头，用户的投诉是“我明明发了，对方说没收到”。从那以后的六章，每一章修一件事：消息会丢（第 3 章）、会重复（第 4 章）、会乱序（第 5 章）、离线回来要补齐（第 6 章）、App 被杀不能少消息（第 7 章）、照片不能堵住文字（第 8 章）。

这一章不做新的决定。它做四件事：跟着一条消息把 v1 从头走到尾；把六个决定和它们的代价放在一张表里；把整台服务器和一台手机的账从 v0 重算到 v1；最后说清楚 v1 还做不到什么。

规模没有变：还是 10 万日活用户，高峰 1 万人在线，每秒 139 条消息，一台服务器、一个数据库。v1 没有多一个用户，加的全是保证。

## 1. 一条消息，走一遍

晚上 7 点，Ana 在电梯里给 Ben 发“我到楼下了”。Ben 在地铁上，手机没有信号。

**Ana 的手机**

1. **先写进本地数据库。** App 给这条消息生成一个消息 ID（比如 128 位的随机数），在一个**事务**里往本地的 `messages` 表写一行，状态是“发送中”。事务就是一组写入，要么全部生效，要么一条都不生效。屏幕从数据库读到这一行，放在会话的最下面（第 4、5、7 章）。这一行就是发件箱：App 现在被杀，下次打开时它还在。
2. **发出去，等确认。** 消息走长连接发出，带着会话、消息 ID 和文字。电梯里连接断了，确认没回来；手机重新连上，退避着再发（模拟器里是第 0、1、3、7、15 秒；真实的 App 按时间算，没网时一直等着，加一点随机抖动），带的还是同一个消息 ID（第 3 章）。

**服务器：消息层**

3. **认出重复。** 先查内存里最近几分钟（模拟器里是 32 秒）见过的消息 ID。见过，就不再写，把第一次的确认原样再回一次（第 4 章）。
4. **一个事务写完。** 没见过，就在一个事务里做三件事：给这个会话取下一个 seq（第 5 章）；写消息；给会话的每个成员（这里是 Ana 和 Ben）的信箱各记一条，带上这个人自己的信箱序号（第 6 章）。消息表上有两个**唯一键**：同一个“发送者 + 消息 ID”、同一个“会话 + seq”，数据库都不让写第二行。撞上第一个，说明这是窗口没认出来的重复，比如 App 被杀、一小时后才从发件箱补发的那种；服务器就查出原来那条，回同样的确认（第 4 章）。
5. **提交以后才回确认**，带上这条消息的 seq。Ana 的手机用一条 `UPDATE` 把那一行改成“已发送”、填上 seq；消息按 seq 挪到它该在的位置（第 5、7 章）。

**分发层**

6. **推送。** 服务器查到 Ben 的连接，把消息连同它的 seq、Ben 的信箱序号推过去。Ben 在地铁里，连接早就断了，这次推送丢了。没关系：推送只是提醒，消息已经在 Ben 的信箱里（第 2、6 章）。

**Ben 的手机**

7. **出了地铁，同步。** 手机记着一个信箱游标，意思是“信箱里到这一号为止的，我都有了”。重新连上后，它带着游标去拉：“7,345 之后的，都给我。”每页 50 条；每一页和新的游标、各会话的 `last_seq` 在同一个事务里提交（第 6、7 章）。如果落下超过 10 页，或者游标比信箱保留的 7 天还旧，服务器只回会话列表；更早的消息在本地记成一个**洞**，也就是一个标记：“这一段还在服务器上，没拉下来”，Ben 翻到那里时再补。
8. **之后的推送。** 连接在的时候，推来的信箱序号正好是游标 + 1，就把消息和新游标一起提交；跳了号，先存下消息、游标不动，等 0.5 秒，还缺就从游标往后拉（第 5、6 章）。
9. **屏幕只读本地数据库。** Ben 点开和 Ana 的会话，不发请求，“我到楼下了”已经在那里（第 7 章）。

**如果发的是一张照片。** 第 1 步之前多一段：手机压缩照片，向服务器要一个上传地址，把文件分片传给对象存储前面的上传服务，状态是“上传中”，断了先问上传服务“收到哪了”再接着传；传完才发一条约 300 字节的小消息，带着文件 id、大小、哈希、宽高和占位图。从第 2 步起，这条小消息和文字走的是同一条路。Ben 先看占位图和缩略图，点开才从 CDN 下载原图（第 8 章）。

九步里，每一步都可能出事：电梯里的连接、丢掉的确认、地铁里的推送、在半路被杀的 App。每一种出事，都有后面的某一步兜住，而且兜住它的不是“再小心一点”，而是一条规则。

## 2. 关掉一样，会回来什么

每一章的模拟器都还在那一章里，可以回去自己关掉看。下面是它们各自跑出来的数字，都是全书 10% 丢包下的结果；第 3 章说过，这比真实网络高得多，是为了让问题看得见。

| 关掉 | 回来的问题（那一章的模拟器里） |
|---|---|
| 确认和重发 | [第 3 章](/zh/acks-and-retries/)：手机显示“已发送”，服务器却没存，10,000 条里 **10.1%** |
| 消息 ID | [第 4 章](/zh/duplicates/)：Ben 看到重复，10,000 条里 **1,113 条**（11.1%）。只留最近 ID 的窗口、不要唯一键，一小时后才补发的那条仍会重复 |
| 会话 seq | [第 5 章](/zh/ordering/)：两块屏幕顺序不同，每 100 条 **19.6 对**；推送丢了没人发现，每 100 条 **10.0 条**（有了 seq，只剩 0.01 条） |
| 信箱 | [第 6 章](/zh/offline-sync/)：什么也不会丢，换回每个会话一个游标，消息一样补得齐。回来的只是代价，每次同步看 200 个会话，高峰每秒约 **13,900 次**读，而信箱只要 **69 次**；每次同步上传 2.4 KB，而不是 8 字节 |
| 同一个事务 | [第 7 章](/zh/local-database/)：App 被杀，游标跑到消息前面。游标先存、消息逐条提交时，热闹的晚上在第 1.0 秒被杀，少了 **74 条**，不会再被拉回来；**85.3%** 的时刻被杀都会少（整页提交 4.7%，同一个事务 0） |
| 单独上传 | [第 8 章](/zh/media-messages/)：文字排在照片后面，三句话分别等了 **27.7、23.7、20.7 秒**；网络每 8 秒断一次时，60 秒里一句也发不出去 |

要说句公道话的是信箱那一行：信箱不是修一个 bug，而是一笔交换。每条消息给收到它的每个人各写一行信箱，平均 6.67 行（一条消息平均送到 10 台设备，每人 1.5 台，10 ÷ 1.5 ≈ 6.67 个人，第 6 章），换每次同步只读真正来了的那几条，外加能说清“Ben 被拉进了一个新群”这种不属于任何一个会话的事。v1 的规模下，两种做法都说得过去。

## 3. 两头对着做

把九步按位置分开看，v1 是两半，每一半都是手机和服务器对着做：

- **发的一半**：手机的发件箱、消息 ID、重发，对着消息层的去重、seq、确认。合起来，Ana 发出的每一条，在服务器上恰好存一份，在会话里有一个确定的位置。
- **收的一半**：分发层带信箱序号的推送，对着手机的同步：信箱游标、跳号补拉、一页一个事务地写进本地数据库。合起来，Ben 的手机最后一条不少。

两半用的是同样三条规则，每一条都在两端各出现一次：

- **唯一键认重复。** 服务器上是“发送者 + 消息 ID”（第 4 章）；手机的本地数据库里也是这个唯一键，同一条消息从推送、同步、确认来几次，都落到同一行上（第 7 章）。
- **号码和它代表的东西一起提交。** 服务器在写消息的事务里取 seq 和信箱序号，所以号码的先后就是提交的先后（第 5、6 章）；手机在写消息的事务里挪游标，所以游标不会跑到消息前面（第 7 章）。
- **推送只是提醒，拉取才是依据。** 推送丢了、晚了、乱了，都由按号码的拉取补上（第 2、5、6 章）。

常说的“恰好一次”，在这里其实是两件事拼起来的：**至少送一次**（重发、同步），加上**认得出重复**（唯一键）。网络上做不到只送一次，能做到的是重复了也无害。

## 4. v1 的全图

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

和第 2 章的 v0 比，还是一个程序、一台服务器，只在旁边多了一条媒体路径。v1 的变化几乎都不在盒子上，而在盒子之间的规则上：上面九步说的就是这些规则。

## 5. v1 的六个决定

| 章 | 决定 | 代价 | 什么时候重新考虑 |
|---|---|---|---|
| [3 确认与重传](/zh/acks-and-retries/) | 消息先进发件箱；服务器存好才回 ACK；没确认就重连、按退避（加抖动）重发；一直不成才标失败 | 确认丢了会重复存（10% 丢包时约 10% 的消息）；重发的要多等；发件箱要存在磁盘上 | “存好”是存到哪一步（第 28 章）；断网恢复时的重连风暴（第 23 章） |
| [4 去重](/zh/duplicates/) | 手机在第一次发送前生成消息 ID；服务器记住最近的 ID；“发送者 + 消息 ID”唯一键兜底 | 多一个唯一索引，重复时多一次查询；ID 要由手机保证不撞 | 消息服务拆成多台（第 27 章）；先写日志再确认（第 28 章） |
| [5 顺序](/zh/ordering/) | 每个会话一个计数器，在写入的事务里取 seq；手机按 seq 排，发送中的放最下面，跳号补拉 | 一个会话的写入排队（5 毫秒一次时每秒最多 200 条）；自己的消息会挪位置；最后一条丢了推送要等下一条 | 一台服务器给所有会话发号、发不过来时（第 27 章） |
| [6 离线同步](/zh/offline-sync/) | 每人一个信箱，写消息的事务里给每个收件人记一条；手机只记一个信箱游标；落下太多只回会话列表 | 每条消息多写 6.67 行（高峰每秒 927 次，一天 853 MB）；一个人的消息排在他那一行的锁上 | 群大到写不起信箱（第 10 章）；一个人几台设备（第 14 章）；信箱的写入等不等确认（第 28 章） |
| [7 本地数据库](/zh/local-database/) | 手机上一个 SQLite；界面只读它；每一页、每条推送、每个确认，和它推动的游标或状态在一个事务里提交 | 两份真相，客户端要会自己修；一年约 34 MB 文字；每次升级要迁移；事务的纪律 | 新设备拉多深（第 14 章）；会话列表（第 17 章）；网页端（第 26 章）；一套 SDK 给所有平台（第 29 章） |
| [8 图片与文件](/zh/media-messages/) | 文件先分片传给上传服务、存进对象存储，再发一条约 300 字节的小消息；接收方先看缩略图，点开才从 CDN 下载 | 两条路要对得上（孤儿文件、打不开的图）；照片的 seq 排在上传期间发的文字后面；文件地址就是访问权限；多一套上传服务、对象存储和 CDN，一年 43.8 TB 的存储 | 500 人的群里一张照片被 750 台设备看到（第 18 章）；换设备（第 14 章）；先发占位再更新（第 16 章）；端到端加密（第 41 章） |

六个代价里，最先疼的会是第 6 章那一行：信箱的写入和会话的人数成正比。一对一的聊天，一条消息是两行；v2 的徒步群有 500 人，就是 500 行。

## 6. v1 的账

和第 2 章一样，把整台服务器和一台手机的账都算一遍。两列用同一套假设：v0 是第 2 章末尾的推送方案，手机不存消息，每打开一个会话就问服务器要最近 50 条（第 7 章开头的做法）；v1 这一列的每个数，都来自括号里的那一章。

**服务器（1 万人在线，高峰）**

两列都有的一笔：每次打开 App 拉一次，10 万人每天 20 次，高峰每秒 2,000,000 ÷ 86,400 × 3 ≈ **69 次**。v0 多一笔：每天 10 万 × 60 = 600 万次打开会话，高峰每秒约 **208 次**，每次回 50 × 200 字节 = 10 KB。

| 资源 | v0（第 2 章末） | v1 |
|---|---|---|
| CPU | 每秒约 1,389 帧推送；69 次拉取；208 次打开会话的查询；重启时集中做握手（1 万台约 5 秒） | 推送不变；69 次同步（只是换成按信箱游标拉）；打开会话的 208 次查询没有了；收到的发送和回的确认，不丢包时每秒 139 个；按全书 10% 的丢包，每条消息平均发 1.23 次、每次 90% 能到，服务器每秒收到 139 × 1.23 × 0.9 ≈ 154 次，其中约 15 次是重复（第 3、4 章），每次都回一个确认；约 7 次要上传地址（第 8 章）；每条消息的事务从写 1 行变成写消息、会话和 6.67 条信箱 |
| 内存 | 每条连接约 30 KB，1 万条约 300 MB；“用户 → 连接” | 同上；加上最近 ID 的窗口：32 秒约 4,400 个、142 KB，按 5 分钟算也只有 1.3 MB（第 4 章） |
| 网络带宽 | 推送 1,389 × 200 字节 ≈ 2.2 Mbit/s；打开会话 208 × 10 KB ≈ 16.7 Mbit/s；共约 19 Mbit/s | 约 2.2 Mbit/s，照片消息也只有约 300 字节；照片本身不经过消息服务器：上传 11.1 Mbit/s 进上传服务，下载约 39 Mbit/s，大部分由 CDN 承担（第 8 章） |
| 网卡（包量） | 推送每帧加一个确认，约 2 个包：每秒约 2,800 个；打开会话每秒 208 个请求，每个 10 KB 的回复约 7 个包（每包最多约 1,460 字节，不算 TCP 确认），再多约 1,700 个：共约 4,400 个 | 每秒多 139 个确认帧，约 280 个包，打开会话的包没有了：不丢包时共约 3,100 个 |
| 磁盘容量 | 消息约 0.8 GB/天，一年约 292 GB | 消息不变，多两个唯一索引；信箱每天 853 MB，留 7 天约 6.0 GB（第 6 章）；照片在对象存储里，每天 40 GB，一年 14.6 TB，存 3 份 43.8 TB（第 8 章） |
| 磁盘 IO | 写：高峰每秒约 139 次插入，每次一次提交，要等 fsync（把数据真正写到盘上）；读：每秒 69 次拉取、208 次打开会话的查询 | 提交次数不变，仍是每秒约 139 次（第 5 章）；每次提交写的行多了，信箱每秒多 927 行（第 6 章）；读：每秒 69 次按游标读一段信箱，加上少量补拉 |

文件描述符和 v0 一样：1 万条连接就是 1 万个；照片的上传连接连的是上传服务，不占消息服务器的。

一句话：v1 让消息服务器多做的，是**写**：每条消息多 6.67 行信箱。少做的，是**读**：手机自己存了一份，每秒 208 次打开会话的查询、约 17 Mbit/s 的流量都没有了。大数字在旁边那条路上：照片每天 40 GB，是全部文字的 50 倍，这也是它们必须走另一条路的原因（第 8 章）。

**Ben 的手机（一天）**

v1 的变化大多按天才看得出来（每次打开同步一次、每天打开多少个会话），所以这张表算一天，不像第 2 章那样算一小时。v0 的手机和上面一样不存消息；v0 也还没有照片。

| 资源 | v0 | v1 |
|---|---|---|
| 请求 | 每次打开 App 拉一次（20 次）；每打开一个会话，问一次最近 50 条（60 次）；发 40 条 | 每次打开同步一次（20 次；两次打开之间平均来 13.3 条，一页就够，第 6 章）；打开会话不发请求；发 40 条，每条等一个确认；照片：上传 2 张，下载约 13 张缩略图、4 张原图（第 8 章） |
| 流量 | 下行：推送 53 KB，打开会话 600 KB，共约 650 KB | 下行：文字 53 KB，照片 935 KB（缩略图，加上点开的 30%）；上行多了照片 400 KB；同步只带一个 8 字节的游标 |
| 电量 | 每来一条推送唤醒一次无线电；每打开一个会话，再等一个来回 | 推送不变；打开会话不用网络；信号差时多几次重发（10% 丢包时平均每条发 1.23 次）；离线一周（1,869 条，超过 10 页）回来，服务器只回会话列表：4 个请求、约 42 KB（第 6 章） |
| 后台 | 连接被系统关掉；没有发件箱，写出去就算发了 | 连接照样被关掉；发件箱和上传进度在本地数据库里，下次打开接着发、接着传；但还没有东西能叫醒手机（第 15、25 章） |
| 存储 | 几乎不用 | 文字一天约 92 KB，一年约 34 MB（第 7 章）；照片缓存一年约 487 MB，可以清理（第 8 章） |

手机多花的是存储和照片的流量；省下的是重复下载：v0 每天打开会话的那 600 KB 没有了。

## 7. v1 还做不到的

v1 只有一件事做得好：**两个人之间，一条消息不丢、不重、不乱，App 被杀也不少。**下面这些，它还不会：

- **500 人的群。** 信箱照写，一条消息就是 500 行；徒步群一天 1,000 条，就是 50 万行。写一份还是写 500 份，是第 10 章。谁在群里、进出群的那一刻消息给谁，是第 11 章。
- **谁能给谁发。** v1 的业务层还只是一句“Ana 在这个会话里吗”。陌生人、拉黑、买家和卖家，是第 12 章。
- **未读数和已读。** v1 知道 Ben 的手机拿到了哪，不知道 Ben 读到了哪（第 13 章）。
- **好几台设备。** 全书平均每人 1.5 台，Ana 有手机和电脑。信箱可以给每台设备各记一个游标，可“读到哪了”要几台设备合起来算，新设备第一次同步该拉多深，都还没有答案（第 14 章）。
- **叫醒一台关着的手机。** App 退到后台，连接就没了，消息在信箱里等着，Ben 不知道（第 15 章）。
- **撤回和编辑**（第 16 章），以及第一屏的**会话列表**（第 17 章）。v1 的本地数据库只记了每个会话的最后一条。

这些都是 v2（第 10–18 章）的事。更远一点，v1 还是一台服务器：连接再多就装不下，每次发布都会断掉所有人，那是 v3。

下一章，第 10 章：Ana 在 500 人的徒步群里发一句话，服务器该写一份，还是写 500 份？v2 的八章之后，第 18 章会像这一章一样，把 v2 整个再走一遍。
