深入理解 IM 系统

第 8 章

图片与文件

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

这一章是初稿,会在写完前八章后再修订。

到第 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 秒握手,连接才回来。

Ana 先发一张 4 MB 的原图,照片还在路上时又发了三条文字。照片传到四分之三时,网断了 2 秒。照片和文字走的是同一条消息连接:这三条文字什么时候到服务器?

发出的字节被断网截断、要重传的 文字在排队断网和重连

文字最多等了
–
照片消息存好
–
为照片上传了
–

默认是“照片走消息连接”:照片是一条 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 是同一个思路。
  3. 分片上传,断了接着传。 手机另开一条连接,把文件一片片传上去。记住“收到了哪几片”的,是一个上传服务:它在对象存储对象存储object storage按名字存取整个文件的存储服务(例如 S3),图片和文件放在这里,而不是消息表里。手机拿到一个限时的上传地址,直接传给它;下载通常再经过 CDN。在术语表里查看前面,收齐以后把这些片拼成完整的文件存进去。断网以后,手机先问上传服务“收到哪了”,再从那里接着传。这就是断点续传断点续传resumable upload把文件分成几片上传,服务器记住收到了哪些;断网以后先问一下进度,只传剩下的,而不是从头再来。在术语表里查看。tus 是一个现成的开放协议:POST 建一个上传,PATCH 带着 Upload-Offset 往里写,断了以后 HEAD 一下,就知道服务器收到了多少。
  4. 发一条小消息。 文件传完,手机在消息连接上发一条普通的消息,只是内容变成了对文件的引用:
{ "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 收到文件会自己再算一遍校验和,对不上就拒绝。不然一个客户端可以谎报哈希。 5. Ben 按需下载。 消息推到 Ben 的手机上。聊天气泡先按宽高留出位置,把 blurhash 解成一张模糊的占位图(BlurHash 用 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)。要不要这样去重、怎样做才安全,是另一个决定,这一章不展开。

6. 代价

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

7. 其他答案

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

8. 这一章的决定

第 8 章加的一块:消息路径旁边多了一条媒体路径。Ana 先向服务器要一个上传地址,把照片分片传给对象存储前面的上传服务,传完才发一条只带地址、大小、哈希和缩略信息的小消息;Ben 先看缩略图,点开才从 CDN 下载原图。
Ana发件箱(本地库)Ben游标(本地库)消息 → ← ACK推送带 seq、信箱序号;重连从游标拉一个程序连接层持有长连接;记着 用户 → 连接消息层(收)取 seq;一个事务写消息和信箱;回 ACK分发层(发)推送带信箱序号;按信箱游标补齐业务层(旁路)Ana 在这个会话里吗存储层唯一键与信箱表发送者+消息ID会话+seq会话.last_seq信箱:用户+序号照片分片上传(经上传服务存进对象存储)上传地址对象存储 + CDN按文件 id 存整份文件Ben 先看缩略图,点开才下载原图

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