# 第 3 章：确认与重传

> 手机显示“已发送”，消息却没到，问题出在哪？

深入理解 IM 系统 · https://im.liko.page/zh/acks-and-retries/

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. 看它坏掉

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

默认这一组里，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 章“实践中”说过，内存里的东西重启就丢）。
- **确认（ACK）**：服务器把消息**存好以后**才回一个确认。第 0 章说过：消息写进去、存下了，才算“收下”，这时才回 ACK；所以“已发送”的意思是“服务器存下了”，不是“对方收到了”。“存好”到底指存到哪一步，第 28 章再细讲。如果服务器自己知道出错了（写库失败、过载），就该直接回一个“出错了”，而不是不出声：暂时的错误让手机稍后重试，永久的错误（消息被拒、太大、被拉黑）直接标失败、不再重试。超时只是最后一道兜底：什么回应都没等到时才用。
- **重发**：等一会儿还没收到确认，就按上面的时间表再发一次。连接还在，就在这条连接上重发；等不到确认往往是连接已经断了，这时的“重发”是先重新连上，再把发件箱里没确认的消息重发一遍，退避的时间表管的其实是重连的节奏。模拟器省掉了重连这一步，用每条消息自己的计时器代替。重发也保不住顺序：5 号已经确认、4 号还在重发，4 号就会排在 5 号后面，第 5 章讲。1 秒的超时是为了让模拟器看得清；真实的移动网络上，信号差时一个来回就可能超过 1 秒，超时通常要设好几秒，否则会白白多发、多出重复。间隔翻倍只是让重试**放慢**；同一刻断网的几百万台手机，如果都按同样的时间表重试，还是会在同一秒一起回来，所以还要给每次的等待加一点**随机抖动**，让它们错开（AWS 的[这篇文章](https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/)讲得很清楚；第 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`](https://core.telegram.org/mtproto/service_messages_about_messages)），客户端和服务器互相确认收到了哪些消息。
- **XMPP 的流管理（[XEP-0198](https://xmpp.org/extensions/xep-0198.html)）**：给每个收发的单元计数、互相确认，连接断了重连时就知道该补发哪些。

## 7. 这一章的决定

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

**决策卡**

- 问题：移动网络上，消息可能在路上丢了，手机却显示已发送。
- 选择：消息先进发件箱；服务器存好后回 ACK；没收到 ACK 就重连并按退避（加抖动）重发；一直不成功才标为失败。
- 代价：确认丢了会重复存（约 10% 的消息存了不止一份）；重发会晚一点；发件箱要存在磁盘上；“失败”只表示没等到确认。
- 什么时候重新考虑：重复（第 4 章）；“存好”指存到哪一步（第 28 章）；断网恢复时的重连风暴（第 23 章）。
- 其他答案：只靠 TCP（不够）；MTProto 的 msgs_ack；XMPP 的 XEP-0198。

下一章：Ben 看到同一句话出现了两次。
