# 第 8 章：图片与文件

> 一张照片是一条文字消息的一千倍。它该和文字走同一条路吗？

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

到第 7 章，文字消息在手机上已经不丢、不重、不乱，App 被杀也不会少。可聊天里不只有文字。

Ana 爬到山顶，给 Ben 发了一张照片，接着又发了三句话：“到了！”“风好大”“下山找你吃饭”。山顶只有一格信号。

前面几章的机制都是按 200 字节的消息设计的。这一章要回答的是：**照片能不能也走这条路？不能的话，走哪条？**

## 1. 一张照片有多大

手机相机拍的一张照片，按 **4 MB** 算；App 发出去之前一般会缩小、重新压缩，按 **200 KB** 算。这两个数都是这一章的假设，不同的手机和 App 差得很远，但数量级是对的。

- 压缩后的照片是一条文字消息（200 字节）的 200,000 ÷ 200 = **1,000 倍**；原图是 **20,000 倍**。
- 上传要多久：山顶这种弱网，假设上行 2 Mbit/s，也就是每秒 250 KB。原图要 **16 秒**，压缩后的 **0.8 秒**，一条文字不到 1 毫秒。网络好一些，8 Mbit/s，原图 4 秒，压缩后 0.2 秒。
- 旁支 W1 给消息连接的一帧设了 64 KiB 的上限。原图是它的 **61 倍**，压缩后的照片也有 **3.1 倍**。想让原图走消息连接，就得把上限提到 4 MiB 左右；按 W1 的那张表算最坏的情况，v1 的 1 万条连接每条都卡着半条消息，就是 10,000 × 4 MiB ≈ **41.9 GB** 的缓冲，而 64 KiB 时只有 0.66 GB。

先说公道话：只发压缩过的照片、网络又好时，照片在连接上只占 0.2 秒，塞进消息连接也能用，而且第 3、4 章的发件箱、确认、重发、去重全都现成。麻烦在于用户还会发原图、视频、几十 MB 的文件，信号也常常不好。下面就看难的那种：4 MB 的原图，弱网，中间断一次。

## 2. 看它坏掉

Ana 先发照片（原图，4 MB），然后在第 3、7、10 秒各发一句话。上行每秒 250 KB。网络在第 12 秒断掉 2 秒，再加上第 2 章说的 0.6 秒握手，连接才回来。

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

默认是“照片走消息连接”：照片是一条 4 MB 的大消息，和文字共用一条连接。

- **文字排在照片后面。** 一条连接上的字节是按顺序发的（旁支 W1：TCP 是一条字节流，一帧发完才能发下一帧），发件箱也按顺序发。三句话在第 3、7、10 秒就按了发送，却只能等照片发完。这叫**队头阻塞**：排在最前面的一个大家伙，堵住了后面所有的人。
- **断一次，照片从头再传。** 断网时已经发出去 250 KB/s × 12 s = **3 MB**，可服务器手里是半条消息，没有用；连接回来以后，发件箱把 4 MB 从第一个字节重新发一遍。文字也跟着再等一遍。
- 结果：照片在第 **30.7** 秒才存好，三句话分别等了 **27.7、23.7、20.7** 秒；为了这张照片，手机一共发了 3 + 4 = **7 MB**，其中 3 MB 白发了。

切到“时断时续”（每 8 秒断一次）：每次连接回来只剩 5.4 秒，不够发完 16 秒的照片。照片永远发不完，三句话一句也出不去，60 秒里 Ben 什么都没收到，手机却已经发了 **13.6 MB**。在火车上、电梯里，用户的感受不是“照片慢”，而是“整个聊天都卡死了”。

## 3. 估算

**断在 70% 时，要重传多少。** 原图在弱网上要 16 秒；传到 70%，也就是第 11.2 秒、发出 2.8 MB 时断了。

- **一次传完**：服务器拿到的半个文件没有用，连接回来后从头再传 **4 MB，16 秒**；已经发的 2.8 MB 白发了。
- **分片上传**：每片 512 KiB（524,288 字节，和 Telegram 允许的最大一片一样），服务器每收齐一片就存下。断的时候收齐了 5 片（2.62 MB），正在传的第 6 片丢了 179 KB。连接回来后先问一下服务器“收到哪了”（一个来回，0.2 秒），再传剩下的 **1.38 MB，5.51 秒**。不管断在哪，最多丢一片，也就是 **2.1 秒**的上传。

**断得频繁时。** 假设断网随机发生，平均每 T 秒一次；每断一次，连同重连要耽误 R = 2.6 秒，然后从头再来。一段要传 u 秒的数据，一次尝试不被打断的机会是 e^(−u/T)，所以平均要尝试 e^(u/T) 次；算上每次失败浪费的时间和 R，平均要 (T + R)(e^(u/T) − 1) 秒才能传完（这是“出错就重来”的任务的标准结果）。

- T = 30 秒：一次传完 16 秒的原图，平均要 **23.0 秒**（16 秒内不断的机会是 e^(−16/30) ≈ 59%）；分片上传，每片约 2 秒，平均 **18.0 秒**。
- T = 8 秒：一次传完平均要 **67.7 秒**，因为不断的机会只剩 e^(−2) ≈ 14%，几乎每次都白传；分片上传 **24.2 秒**。

一次要传的越短，越容易赶在下一次断网之前传完。

**服务器上多出来的字节。** 假设 v1 每天 400 万条消息里有 **5%** 是照片（这是一个假设）：

- 每天 400 万 × 5% = **20 万张**照片，× 200 KB = **40 GB**。全部文字一天只有 400 万 × 200 字节 = **0.8 GB**。照片只占消息条数的 5%，字节却是文字的 **50 倍**。
- 一年是 **14.6 TB**，按全书存 3 份，**43.8 TB**；文字一年 292 GB，3 份 0.88 TB。
- 高峰每秒 139 条消息，其中 6.95 张照片：上传 **11.1 Mbit/s**。下载更多：一条消息平均送到 10 台设备，如果每台都把照片完整下载下来，是 **111 Mbit/s**。第 2 章算过，高峰时推送全部文字只要 **2.2 Mbit/s**。

消息很小、要快、要按顺序；照片很大、写一次就不再改、可以缓存：它们值得放在不同的地方，而不是让消息表一天多长 40 GB、消息服务器的网卡大部分时间在搬照片。

## 4. 修好它：照片走自己的路，消息里只放地址

发一张照片变成这样几步：

1. **压缩。** 手机先把照片缩小、重新压缩（4 MB → 200 KB），算出它的 SHA-256 哈希、宽和高，再生成一个很小的占位图。用户勾了“原图”就跳过压缩。
2. **要一个上传地址。** 手机在消息连接上问服务器：“我要传一个 4 MB 的文件。”服务器先做业务上的检查（Ana 能不能发文件、有没有超过大小上限），然后分配一个文件 id，回一个上传地址。地址指向下面的上传服务，带着一个限时有效、由服务器签名的令牌：谁拿到它都能往那里传这一个文件，不需要别的身份。这和 S3 的[预签名 URL](https://docs.aws.amazon.com/AmazonS3/latest/userguide/PresignedUrlUploadObject.html) 是同一个思路。
3. **分片上传，断了接着传。** 手机另开一条连接，把文件一片片传上去。记住“收到了哪几片”的，是一个**上传服务**：它在对象存储前面，收齐以后把这些片拼成完整的文件存进去。断网以后，手机先问上传服务“收到哪了”，再从那里接着传。这就是断点续传。[tus](https://tus.io/protocols/resumable-upload) 是一个现成的开放协议：`POST` 建一个上传，`PATCH` 带着 `Upload-Offset` 往里写，断了以后 `HEAD` 一下，就知道服务器收到了多少。
4. **发一条小消息。** 文件传完，手机在消息连接上发一条普通的消息，只是内容变成了对文件的引用：

```json
{ "type": "image", "file_id": "f_7Kq2…", "size": 4000000, "sha256": "9f2c…",
  "width": 4032, "height": 3024, "mime": "image/jpeg", "blurhash": "LKO2?U%2Tw=w]~RBVZRi};RPxuwH" }
```

   这条消息约 **300 字节**，和一句话差不多大。它走的就是第 3 到第 7 章修好的那条路：发件箱、确认、重发、去重、seq、同步，一样也不少。消息服务器收到它，检查这个文件 id 真的存在、是 Ana 传的。大小和哈希只有在服务器自己算过时才可信：上传服务收齐以后自己算一遍；或者签发预签名 URL 时把大小和 SHA-256 签进去，S3 收到文件会[自己再算一遍校验和](https://docs.aws.amazon.com/AmazonS3/latest/userguide/checking-object-integrity-upload.html)，对不上就拒绝。不然一个客户端可以谎报哈希。
5. **Ben 按需下载。** 消息推到 Ben 的手机上。聊天气泡先按宽高留出位置，把 `blurhash` 解成一张模糊的占位图（[BlurHash](https://blurha.sh/) 用 20 到 30 个字符就能描述一张图大致的颜色）；接着下载一张约 10 KB 的缩略图；Ben 点开，才下载完整的照片。下载走 CDN：文件一旦传完就不会再改，CDN 和手机都可以放心地缓存它。

在上面的模拟器里切到中间的“单独上传，一次传完”：三句话各自只用了 **0.1 秒**，不再排在照片后面；可照片断了还是从头来，第 **30.9** 秒才存好。再切到“单独上传，分片续传”：照片在第 **20.6** 秒存好，早了 10.3 秒；为它发出去的字节是 **4.18 MB**，只白发了 **0.18 MB**。切到“时断时续”：一次传完的照片 60 秒里永远传不完，分片的在第 **28.5** 秒传完。

不断网的时候，单独上传比走消息连接慢了一点：照片在第 17.1 秒存好，而不是第 16.1 秒。多出来的是要地址的一个来回（图里的“要地址”）、上传连接的一次握手，和最后那条小消息。这是两条路的固定开销，一张照片约 1 秒。

**按需下载省多少。** 假设 30% 的接收者会点开看大图（又一个假设）。服务器一天的下载，从每台设备都下完整照片的 200 万次 × 200 KB = **400 GB**，降到 200 万 × (10 KB + 30% × 200 KB) = **140 GB**，高峰从 111 降到 **39 Mbit/s**，大部分由 CDN 承担。Ben 一天收到 267 × 5% ≈ 13 张照片，下载从 **2,670 KB** 降到 **935 KB**；全部文字只有 53 KB。

## 5. 发件箱多了一步

以前，发件箱里的一条消息只有一步：发出去，等确认。现在照片消息有两步：先上传，再发送。Ana 的屏幕上就多了几个状态：

- **上传中 37%**：照片显示在她按下发送的位置，上面一个进度；
- **发送中**：文件传完了，小消息在等确认；
- **✓ 已发送**，或者 **! 失败**。失败也要分清是哪一步：上传失败，重试要接着传文件；发送失败，文件已经在服务器上了，重试只要再发那条 300 字节的小消息。

这些状态都存在第 7 章的本地数据库里：`messages` 那一行的 `state` 多了一个 `'uploading'`；另有一张 `uploads` 表，记着本地文件的路径、哈希、文件 id、上传地址和它的过期时间，以及已经确认传上去的片数（`parts_done`）。

第 7 章的规则照样用：每一次状态变化，和它代表的事实在同一个事务里提交。文件传完时，`state` 改成 `'sending'` 和记下 `file_id` 是同一个事务。`parts_done` 只是手机自己的笔记，真正算数的是上传服务的回答：App 在上传到一半时被杀，重新打开后找到 `'uploading'` 的那一行，问上传服务“收到哪了”，从那里接着传。上传地址过期了，就重新要一个。

**接收的一边**，照片放在 App 的缓存目录里，数据库只记文件 id 和路径；照片一年约 **487 MB**（每张都下载完整的是 **1.12 GB**），文字才 33.6 MB，所以聊天 App 都有“清理缓存”。

**同一个文件只传一次？** 转发时消息里还是那个文件 id，服务器检查转发的人确实看得到这个文件就行，一个字节也不用传。按哈希在**所有用户**之间去重（“这个哈希的文件已经有了，不用传”）也能省上传，但它会告诉任何人“有没有别人传过这个文件”，是一个泄露信息的侧信道（[Harnik 等，2010](https://doi.org/10.1109/MSP.2010.187)）。要不要这样去重、怎样做才安全，是另一个决定，这一章不展开。

## 6. 代价

- **两条路要对得上。** 文件和消息分开走，就会出现一边成功、一边没成功：
  - 文件传完了，消息却没发出去（App 被删了、用户取消了）：对象存储里多了一个没有人引用的文件。要定期清理：比如上传后几天内没有消息引用它，就删掉。
  - 消息先到了，文件却没有：Ben 看到一张打不开的图。所以手机一定要等上传确认以后再发消息，服务器收到消息时也要检查文件真的在。
- **顺序会变。** 照片消息要等文件传完才发，它拿到的 seq（第 5 章）就排在上传期间发出的三句话后面。Ana 的屏幕上是 [照片、到了！、风好大、…]，Ben 看到的是 [到了！、风好大、…、照片]。要么在确认回来时把 Ana 那边的照片挪到后面，和 Ben 一致；要么接受两边看到的顺序不同。还有一种做法是先发一条**占位消息**（“Ana 正在发送一张照片”），按下发送时就占住 seq，传完再把它改成真正的照片；这就用上了“修改一条已发的消息”，第 16 章再讲。
- **谁能下载。** 文件 id 和下载地址本身就是访问权限：拿到它的人就能下载。常见的做法是签名、限时的下载地址：CDN 在边缘节点上先验签名和有效期，通过了再从缓存里取同一份文件，比如 [CloudFront 的签名 URL](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/private-content-signed-urls.html)。代价是检查很粗：有效期内谁拿到地址都能下载，Ben 被移出会话以后，手里的地址要到过期才失效。Matrix 在 1.11 版改成了[要求身份的下载](https://spec.matrix.org/latest/client-server-api/#content-repository)（`/_matrix/client/v1/media/download/…`），原来不带身份的 `/_matrix/media/v3/download` 标为弃用；但它只检查是否登录，不检查是否在房间里。
- **多了一套系统要管。** 上传服务、对象存储、CDN、上传地址的签发、过期文件的清理。存储一年 43.8 TB，还在不停地长，要决定照片留多久。

## 7. 其他答案

- **还走同一条连接，但把照片切成小帧，和文字交错发。** HTTP/2 就是这样：一条连接上有很多个流，不同流的帧可以[交错着发](https://www.rfc-editor.org/rfc/rfc9113#section-5)，大文件不会堵住小请求。这能解决队头阻塞，也能做续传。代价是所有照片的字节仍然经过消息服务器：上面那 40 GB 一天、111 Mbit/s 的高峰都还在它身上。
- **Telegram**：文件用 MTProto 的 `upload.saveFilePart` 一片片地传，按[文档](https://core.telegram.org/api/files)每片最大 512 KiB，并建议放在单独的会话和连接上，不和别的调用混在一起：同一个思路，用自己的协议。消息里带一张“极低分辨率的缩略图”（`photoStrippedSize`），作用和 BlurHash 一样。
- **Matrix**：它的[内容仓库](https://spec.matrix.org/latest/client-server-api/#content-repository)就是一个单独的上传服务。客户端先 `POST /_matrix/media/v3/upload`，拿回一个 `mxc://<服务器名>/<媒体 id>` 形式的地址；`m.image` 消息里只放这个地址，加上 `info` 里的宽、高、类型、大小和缩略图。
- **直接传给 S3**：S3 的分片上传[每片至少 5 MiB](https://docs.aws.amazon.com/AmazonS3/latest/userguide/qfacts.html)，文档建议对象到了 100 MB 左右再考虑分片；所以一张 4 MB 的照片直接传 S3 就是一次 `PUT`，断了从头来。想要小片续传，就得在前面放一个自己的上传服务。

## 8. 这一章的决定

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

**决策卡**

- 问题：照片是一条文字消息的 1,000 倍（原图 20,000 倍）。放进消息连接里发，弱网上后面的文字全要排队；断一次，整个文件从头再传；时断时续时永远传不完。照片的字节是文字的 50 倍，会占满消息服务器的磁盘和网卡。
- 选择：文件走自己的路：手机先要一个上传地址，把文件分片传给对象存储前面的上传服务，断了先问进度再接着传；传完才发一条约 300 字节的小消息，带文件 id、大小、哈希、宽高和占位图。接收方先显示占位图和缩略图，点开才从 CDN 下载原图。发件箱多一个“上传中”的状态，每一步和本地数据库的状态在同一个事务里提交。
- 代价：两条路要对得上：孤儿文件要清理，消息不能先于文件；照片消息的 seq 排在上传期间发出的文字后面；文件地址就是访问权限，签名地址在过期前撤不回来；多了上传服务、对象存储、CDN 和一年 43.8 TB 还在长的存储。
- 什么时候重新考虑：500 人的群里一张照片会被 750 台设备看到，按需下载更要紧（v2 的容量表，第 18 章）；换设备时照片要不要跟过去（第 14 章）；先发占位消息再更新（第 16 章）；端到端加密以后服务器看不到文件内容（第 41 章）。
- 其他答案：切成小帧在同一条连接上交错发（HTTP/2 的做法，字节仍经过消息服务器）；Telegram 的 upload.saveFilePart；Matrix 的内容仓库（mxc://）；tus；S3 的预签名 URL 和分片上传。

到这里，v1 的每一块都有了。下一章把它们放在一起：跟着一条消息把整条路走一遍，再把第 3–8 章的机制一样样关掉，用各章自己的模拟器看哪个毛病回来；最后把服务器和手机的账从 v0 重算到 v1。
