第 9 章
v1:一条可靠的通道
一条消息从 Ana 的手机到 Ben 的手机,v1 在路上加了哪些保证?每一样花了多少?
这一章是初稿,会在写完前八章后再修订。
第 3 章开头,用户的投诉是“我明明发了,对方说没收到”。从那以后的六章,每一章修一件事:消息会丢(第 3 章)、会重复(第 4 章)、会乱序(第 5 章)、离线回来要补齐(第 6 章)、App 被杀不能少消息(第 7 章)、照片不能堵住文字(第 8 章)。
这一章不做新的决定。它做四件事:跟着一条消息把 v1 从头走到尾;把六个决定和它们的代价放在一张表里;把整台服务器和一台手机的账从 v0 重算到 v1;最后说清楚 v1 还做不到什么。
规模没有变:还是 10 万日活用户,高峰 1 万人在线,每秒 139 条消息,一台服务器、一个数据库。v1 没有多一个用户,加的全是保证。
1. 一条消息,走一遍
晚上 7 点,Ana 在电梯里给 Ben 发“我到楼下了”。Ben 在地铁上,手机没有信号。
Ana 的手机
- 先写进本地数据库。 App 给这条消息生成一个消息 ID消息 IDmessage ID客户端在发送之前给每条消息生成的唯一 ID,重发时不变。服务端靠它认出重发的消息。在术语表里查看(比如 128 位的随机数),在一个事务里往本地的
messages表写一行,状态是“发送中”。事务就是一组写入,要么全部生效,要么一条都不生效。屏幕从数据库读到这一行,放在会话的最下面(第 4、5、7 章)。这一行就是发件箱发件箱outbox客户端里还没等到 ACK 的消息。断线或重启之后,从这里重发。在术语表里查看:App 现在被杀,下次打开时它还在。 - 发出去,等确认。 消息走长连接发出,带着会话、消息 ID 和文字。电梯里连接断了,确认没回来;手机重新连上,退避着再发(模拟器里是第 0、1、3、7、15 秒;真实的 App 按时间算,没网时一直等着,加一点随机抖动),带的还是同一个消息 ID(第 3 章)。
服务器:消息层
- 认出重复。 先查内存里最近几分钟(模拟器里是 32 秒)见过的消息 ID。见过,就不再写,把第一次的确认原样再回一次(第 4 章)。
- 一个事务写完。 没见过,就在一个事务里做三件事:给这个会话取下一个 seq(第 5 章);写消息;给会话的每个成员(这里是 Ana 和 Ben)的信箱各记一条,带上这个人自己的信箱序号(第 6 章)。消息表上有两个唯一键:同一个“发送者 + 消息 ID”、同一个“会话 + seq”,数据库都不让写第二行。撞上第一个,说明这是窗口没认出来的重复,比如 App 被杀、一小时后才从发件箱补发的那种;服务器就查出原来那条,回同样的确认(第 4 章)。
- 提交以后才回确认,带上这条消息的 seq。Ana 的手机用一条
UPDATE把那一行改成“已发送”、填上 seq;消息按 seq 挪到它该在的位置(第 5、7 章)。
分发层
- 推送。 服务器查到 Ben 的连接,把消息连同它的 seq、Ben 的信箱序号推过去。Ben 在地铁里,连接早就断了,这次推送丢了。没关系:推送只是提醒,消息已经在 Ben 的信箱信箱inbox每人一份的收件索引,记着“有哪些新消息要给你”,有自己按人递增的序号。写扩散时写它,设备按同步游标从它这里拉。在术语表里查看里(第 2、6 章)。
Ben 的手机
- 出了地铁,同步。 手机记着一个信箱游标同步游标sync cursor每台设备一个,记着这台设备在自己的信箱里同步到了哪里。设备回来时,从游标之后接着拉。在术语表里查看,意思是“信箱里到这一号为止的,我都有了”。重新连上后,它带着游标去拉:“7,345 之后的,都给我。”每页 50 条;每一页和新的游标、各会话的
last_seq在同一个事务里提交(第 6、7 章)。如果落下超过 10 页,或者游标比信箱保留的 7 天还旧,服务器只回会话列表;更早的消息在本地记成一个洞,也就是一个标记:“这一段还在服务器上,没拉下来”,Ben 翻到那里时再补。 - 之后的推送。 连接在的时候,推来的信箱序号正好是游标 + 1,就把消息和新游标一起提交;跳了号,先存下消息、游标不动,等 0.5 秒,还缺就从游标往后拉(第 5、6 章)。
- 屏幕只读本地数据库。 Ben 点开和 Ana 的会话,不发请求,“我到楼下了”已经在那里(第 7 章)。
如果发的是一张照片。 第 1 步之前多一段:手机压缩照片,向服务器要一个上传地址,把文件分片传给对象存储前面的上传服务,状态是“上传中”,断了先问上传服务“收到哪了”再接着传;传完才发一条约 300 字节的小消息,带着文件 id、大小、哈希、宽高和占位图。从第 2 步起,这条小消息和文字走的是同一条路。Ben 先看占位图和缩略图,点开才从 CDN 下载原图(第 8 章)。
九步里,每一步都可能出事:电梯里的连接、丢掉的确认、地铁里的推送、在半路被杀的 App。每一种出事,都有后面的某一步兜住,而且兜住它的不是“再小心一点”,而是一条规则。
2. 关掉一样,会回来什么
每一章的模拟器都还在那一章里,可以回去自己关掉看。下面是它们各自跑出来的数字,都是全书 10% 丢包下的结果;第 3 章说过,这比真实网络高得多,是为了让问题看得见。
| 关掉 | 回来的问题(那一章的模拟器里) |
|---|---|
| 确认和重发 | 第 3 章:手机显示“已发送”,服务器却没存,10,000 条里 10.1% |
| 消息 ID | 第 4 章:Ben 看到重复,10,000 条里 1,113 条(11.1%)。只留最近 ID 的窗口、不要唯一键,一小时后才补发的那条仍会重复 |
| 会话 seq | 第 5 章:两块屏幕顺序不同,每 100 条 19.6 对;推送丢了没人发现,每 100 条 10.0 条(有了 seq,只剩 0.01 条) |
| 信箱 | 第 6 章:什么也不会丢,换回每个会话一个游标,消息一样补得齐。回来的只是代价,每次同步看 200 个会话,高峰每秒约 13,900 次读,而信箱只要 69 次;每次同步上传 2.4 KB,而不是 8 字节 |
| 同一个事务 | 第 7 章:App 被杀,游标跑到消息前面。游标先存、消息逐条提交时,热闹的晚上在第 1.0 秒被杀,少了 74 条,不会再被拉回来;85.3% 的时刻被杀都会少(整页提交 4.7%,同一个事务 0) |
| 单独上传 | 第 8 章:文字排在照片后面,三句话分别等了 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 的全图
和第 2 章的 v0 比,还是一个程序、一台服务器,只在旁边多了一条媒体路径。v1 的变化几乎都不在盒子上,而在盒子之间的规则上:上面九步说的就是这些规则。
5. v1 的六个决定
| 章 | 决定 | 代价 | 什么时候重新考虑 |
|---|---|---|---|
| 3 确认与重传 | 消息先进发件箱;服务器存好才回 ACK;没确认就重连、按退避(加抖动)重发;一直不成才标失败 | 确认丢了会重复存(10% 丢包时约 10% 的消息);重发的要多等;发件箱要存在磁盘上 | “存好”是存到哪一步(第 28 章);断网恢复时的重连风暴(第 23 章) |
| 4 去重 | 手机在第一次发送前生成消息 ID;服务器记住最近的 ID;“发送者 + 消息 ID”唯一键兜底 | 多一个唯一索引,重复时多一次查询;ID 要由手机保证不撞 | 消息服务拆成多台(第 27 章);先写日志再确认(第 28 章) |
| 5 顺序 | 每个会话一个计数器,在写入的事务里取 seq;手机按 seq 排,发送中的放最下面,跳号补拉 | 一个会话的写入排队(5 毫秒一次时每秒最多 200 条);自己的消息会挪位置;最后一条丢了推送要等下一条 | 一台服务器给所有会话发号、发不过来时(第 27 章) |
| 6 离线同步 | 每人一个信箱,写消息的事务里给每个收件人记一条;手机只记一个信箱游标;落下太多只回会话列表 | 每条消息多写 6.67 行(高峰每秒 927 次,一天 853 MB);一个人的消息排在他那一行的锁上 | 群大到写不起信箱(第 10 章);一个人几台设备(第 14 章);信箱的写入等不等确认(第 28 章) |
| 7 本地数据库 | 手机上一个 SQLite;界面只读它;每一页、每条推送、每个确认,和它推动的游标或状态在一个事务里提交 | 两份真相,客户端要会自己修;一年约 34 MB 文字;每次升级要迁移;事务的纪律 | 新设备拉多深(第 14 章);会话列表(第 17 章);网页端(第 26 章);一套 SDK 给所有平台(第 29 章) |
| 8 图片与文件 | 文件先分片传给上传服务、存进对象存储,再发一条约 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 整个再走一遍。