第 3 章
确认与重传
手机显示“已发送”,消息却没到,问题出在哪?
v0 跑起来了,用户也多了,投诉跟着来了:“我明明看到发出去了,对方说没收到。”
Ana 在电梯里给 Ben 发“我到楼下了”。她的手机显示已发送,Ben 一直没收到。没有报错,没有提示,这条消息就这么没了。
这一章开始 v1:在手机上不出错。v1 要一件件解决手机网络上的老毛病:消息会丢(这一章)、会重复(第 4 章)、会乱序(第 5 章)、离线回来要补齐(第 6 章)、App 自己也要把消息存好(第 7 章)。第 2 章讲的是收这一边(推送只是提醒,拉取才是依据),这一章讲发这一边。第一件事,是弄清楚“已发送”到底是什么意思。
1. 现在的写法
第 1 章里,Ana 发消息是一个 POST /messages 请求,服务器写进表以后回复消息的 id:这个回复其实就是一个“确认”。第 2 章有了长连接以后,发消息也挪到了这条连接上,不用再另发请求。这时最容易写出来的代码是:把消息写进连接,write() 没报错,就在屏幕上标“已发送”。原来那个请求的回复,就这样悄悄丢掉了。
第 2 章讲推送时说过服务器那边的坑:write() 成功,只说明字节进了本机的发送缓冲区。同一个坑反过来,在手机上也一样:字节还在手机自己的缓冲区里,不说明它们离开了手机,更不说明服务器存下了。如果连接这时断了(在电梯里、过隧道、切换网络),缓冲区里的东西就跟着没了,App 什么也不知道。就算还是用 POST,它虽然有确认,却没有发件箱,也不会自动重发:请求超时、App 被杀掉时,不知道到底到没到,也没留下东西可以重发。
网络断了只是一种情况。消息从 Ana 按下发送,到服务器把它存好,一路上哪一步出事都会丢:
- 手机这边:还没发出去,App 就被系统杀了,或者手机没电了。
- 网络:连接在半路断了,电梯、隧道、Wi-Fi 和 4G 来回切换。
- 服务器这边:程序内部出错;写数据库失败或超时;服务器过载,把来不及处理的请求丢了;发布新版本时进程重启,正好赶在“收到了、还没存好”的那一刻。
这些失败五花八门,但对手机来说都一样:等不到“存好了”。所以下面的修法只用一个确认,就把它们全盖住了,不用一种一种地处理。
先说清楚模拟器怎么模拟“丢”。TCP 自己会重传丢掉的包,所以一条活着的连接上,数据不会悄悄少一块;手机上真正发生的,是连接在数据还在路上时断了。模拟器把这些都抽象成:每一程(消息上去、确认下来)都有一定的概率丢掉。全书假设是 10%,这比真实系统高得多,是故意的:好让 8 条消息里就能看到问题。滑块可以一直拖到 0。
2. 看它坏掉
丢包 10%,Ana 发 8 条。手机上显示“已发送”、服务器却没存下的,会有几条?
发出的一次×这一程丢了 ✓显示已发送 ✓显示已发送,其实没存 服务器存下 没存下
- 服务器存下
- –
- 显示已发送却没存
- –
- 多存的重复
- –
- 显示失败
- –
- 一共发了几次
- –
默认这一组里,4 号消息在路上丢了,Ana 的手机却在写出去的那一刻就打上了 ✓。服务器从来没见过它,Ben 也永远收不到,而 Ana 以为他收到了。
这比“发送失败”更糟:失败的消息,用户会重发;显示成功的消息,没人会再管它。
3. 估算
按每一程丢 10% 算:
- 不确认:每条消息有 10% 在路上丢了,手机照样显示已发送。长期下来,大约十条里一条是假的“已发送”。模拟 10,000 条,是 10.1%(随机波动)。
- 比例小,数量不小:真实系统丢的比例远低于 10%,但 v1 每天有 400 万条消息。只丢 0.1%,每天就是 4,000 条“显示已发送、其实没到”;丢 0.01%,也有 400 条。对用户来说,每一条都是“我明明发了”。
- 等确认:一次发送要去程和回程都成功,服务器的确认才能回到手机:0.9 × 0.9 = 0.81。平均每条消息要发 1 ÷ 0.81 ≈ 1.23 次。
- 最多发 5 次:一次不成功的概率是 1 − 0.81 = 0.19,5 次都不成功是 0.19⁵ ≈ 0.025%,一万条里约 2.5 条。这些消息会如实显示“失败”,让用户决定要不要重发,而不是撒谎说已发送。
- 什么时候重发:不是一秒一次地狂发,而是越等越久:模拟器里在第 0、1、3、7、15 秒各发一次,再等 16 秒还没有确认,就在第 31 秒放弃。
4. 修好它:发件箱、确认、重发
在上面的模拟器里切到“等确认,没确认就重发”:
- 发件箱:每条消息先放进手机上的发件箱,直到收到确认才拿出来。发件箱要存在磁盘上,而不是内存里:App 被系统杀掉、手机重启以后,没发出去的消息还得在(第 7 章讲本地数据库;第 1 章“实践中”说过,内存里的东西重启就丢)。
- 确认(ACKACK(确认)接收方回给发送方的“收到了”。在本书里,服务端的 ACK 表示消息已经写进消息日志,不表示对方已经收到。在术语表里查看):服务器把消息存好以后才回一个确认。第 0 章说过:消息写进去、存下了,才算“收下”,这时才回 ACK;所以“已发送”的意思是“服务器存下了”,不是“对方收到了”。“存好”到底指存到哪一步,第 28 章再细讲。如果服务器自己知道出错了(写库失败、过载),就该直接回一个“出错了”,而不是不出声:暂时的错误让手机稍后重试,永久的错误(消息被拒、太大、被拉黑)直接标失败、不再重试。超时只是最后一道兜底:什么回应都没等到时才用。
- 重发:等一会儿还没收到确认,就按上面的时间表再发一次。连接还在,就在这条连接上重发;等不到确认往往是连接已经断了,这时的“重发”是先重新连上,再把发件箱里没确认的消息重发一遍,退避的时间表管的其实是重连的节奏。模拟器省掉了重连这一步,用每条消息自己的计时器代替。重发也保不住顺序:5 号已经确认、4 号还在重发,4 号就会排在 5 号后面,第 5 章讲。1 秒的超时是为了让模拟器看得清;真实的移动网络上,信号差时一个来回就可能超过 1 秒,超时通常要设好几秒,否则会白白多发、多出重复。间隔翻倍只是让重试放慢;同一刻断网的几百万台手机,如果都按同样的时间表重试,还是会在同一秒一起回来,所以还要给每次的等待加一点随机抖动,让它们错开(AWS 的这篇文章讲得很清楚;第 23 章会专门讲重连风暴)。
- 失败:模拟器里 5 次都不行就标失败。真实的 App 往往更宽容:没网的时候一直保持“发送中”,只在有网时才计算重试,并且按时间(比如一分钟左右)而不只是按次数判失败,免得一段 40 秒的电梯就让消息变红。还要记住,“失败”只表示“没等到确认”,消息可能其实已经存下了(比如最后一次的确认丢了),这时用户点重发,就又多一份,第 4 章会处理。
- 屏幕上的状态:发送中 → ✓ 已发送 → ! 失败(点一下重发)。
同样的 8 条消息,现在 8 条都存下了,没有一条是假的“已发送”。4 号第一次在路上丢了,1 秒后重发就到了。
5. 代价:重复
但服务器那一行多了一个红色的 ×2。7 号消息第一次就送到了,可服务器回的确认在路上丢了;手机以为没送到,1 秒后又发了一次,服务器就存了两份。
- 首次发送里,“消息到了、确认丢了”的概率是 0.9 × 0.1 = 9%;后面的重发也可能再一次“到了、确认又丢了”,所以合起来,约 10% 的消息会被存不止一份,平均每 100 条消息多存约 11 份(有的被存了三份)。模拟 10,000 条,正是 10% 和 11.1 份。
- Ben 会看到同一句话出现两三次。第 4 章用一个消息 ID 把它解决:服务器认出“这条我见过”,再回一次确认,但不再存。
还有一个小代价:需要重发的消息,至少要多等一个超时。
6. 其他答案
- “TCP 不是会重传吗,为什么还要自己确认?” TCP 只管一条活着的连接。连接断了、App 被杀了,TCP 缓冲区里的东西也就没了;就算 TCP 确认了,也只说明服务器的内核收到了这些字节,不说明服务器的程序把消息存下了。只有应用自己知道它想发什么、存没存下。可靠性要在两端自己保证,这就是常说的“端到端原则”。
- Telegram 的 MTProto:确认本身也是一种消息(
msgs_ack),客户端和服务器互相确认收到了哪些消息。 - XMPP 的流管理(XEP-0198):给每个收发的单元计数、互相确认,连接断了重连时就知道该补发哪些。
7. 这一章的决定
下一章:Ben 看到同一句话出现了两次。