系统设计

设计一个图片分享 App:为什么 feed 里不存图、服务器不碰图?

Designing a Photo-Sharing App: The Feed and the Life of a Photo

图片分享 App 其实是两道题的合体:feed 是「读压倒写」的分发题,图片是「字节又大又沉」的搬运题。Twitter、Instagram、Facebook 的一手论文和工程博客里,藏着同一套答案——把计算搬到写端,把字节挡在服务器外。这篇把两条主线各自从最朴素的一版推到生产级,每一步都标好价钱:你的规模走到哪一版,就抄到哪一版。

先算一笔账。

你打开手机里那个图片 App,拇指往上一划,二十张图铺满屏幕,前后几百毫秒。这个动作你一天要做几十次,几亿人跟你一起做。Facebook 的照片系统巅峰时期是什么量级?他们自己论文里的数字:每天千亿上下的图片浏览,峰值每秒对外吐超过 100 万张图 [1]。而写那头呢?Twitter 2012 年公开过自家时间线的负载:读每秒 30 万次,写每秒峰值不过 7 千 [2]——差出四十来倍。

不过这道题真正难的地方还不是读写比,而是它其实是两道题的合体:feed 是一道分发题——谁该看到什么、按什么顺序、多快;图片是一道搬运题——几 MB 的字节怎么从快门底下走进机房,再走上几百万块屏幕。这两道题的负载形态、瓶颈、解法,长得完全不一样。面试里把它们搅在一起答,生产里把它们焊在一起建,是同一种死法。

所以问题就摆在这:feed 怎么让一亿人刷起来都秒开?一张图从按下快门到出现在别人 feed 里,中间要过几道关?以及最后那个更实在的问题——这些是 Instagram 和 Facebook 用几百亿张图喂出来的答案,你的系统到底该抄哪几条?

一、先把问题钉死:一道题里藏着两道题 Two problems in one

先圈需求,再画框。

功能上只做四件事:拍照上传、关注关系、按关注拉取的图片 feed、点赞评论。点赞评论只当元数据处理——元数据就是帖子附带的那些小字段(点赞数、评论数、作者、时间),本文不单独展开。私信、短视频、直播都不做。骨架就两条流水线——图怎么进来(写),feed 怎么出去(读)。

负载形态先钉死。Twitter 公布过自家时间线的负载:读每秒 30 万次,写每秒日常 5 千、峰值 7 千 [2]。Facebook 存关注、好友这类社交关系的系统叫 TAO,读写比更极端:读占 99.8%,写只有 0.2% [3]。图片分享是同一个形态——每人一天发一两张图,刷几十次 feed。读压倒写。所有的贵,都得从读路径上挪走。

接下来估量级。下面的数字全是假设,你换成自己的就行。

假设日活 1 亿,每人每天刷 10 次 feed,每次刷 3 页,每页 20 张图。乘起来:

也就是说,光「看图」这一件事,系统每秒要吐出 70 万张。好在看图是高度重复的:一张热门的图,几百万人看的都是同一张。这正是 CDN 的用武之地——所谓 CDN,就是铺在离用户近的地方的一层缓存服务器,同一张图只从你这里取一次,之后的请求都由它代答。Facebook 的图片存储系统 Haystack(第四节细讲)当年的实测,是九成浏览由 CDN 消化 [1]。照这个比例,真正落到你自己服务器(源站)的,每秒只有 7 万张。

写这头轻多了。每人每天平均发 0.2 张图,全站一天 2000 万张,折下来每秒 230 张;高峰就算放大五倍,也才一千出头。读每秒 70 万,写每秒 230,差了三个数量级(见图 1)。

每秒的读和写,按同一个比例画读(看图)70 万张/秒写(发图)230 张/秒——就是左边这条细线全部看图请求600 亿张/天 ≈ 70 万张/秒九成由 CDN 代答CDN缓存里直接返回源站约 7 万张/秒读写差三个数量级——所有设计的重心,都压在读这一头
图 1. 读和写按同一个比例画出来:写细得几乎看不见。再算上九成的读被 CDN 挡在门外,源站真正要扛的读是每秒约 7 万张——仍是写入量的三百倍。

feed 列表本身的请求也顺手算掉:1 亿人每天各刷 10 次,一天 10 亿次,平均每秒 1.2 万次。

最后算一笔钱,这笔账最容易被漏。一张 feed 缩略图按 50 KB 算,一天 600 亿次浏览,就是 3 PB 数据从机房流出去。流量是按字节收费的。以对象存储为例——就是 S3、COS 这类按量付费、容量近乎无限的云文件仓库,图片本体后面都会放在这里——它的出网挂牌价约 0.09 美元/GB [4],3 PB 就是一天 27 万美元。走 CDN 会便宜不少,但量级摆在这了。所以图片站的成本大头,往往不在存储,而在出网流量。压缩和 CDN 不是优化项,是这门生意能不能开张的前提。

题眼

一致性档位在这道题里只有一个正确答案:feed 全局最终一致,外加本人写后读。别人晚几秒看到你的图,没人在乎;你自己看不到自己刚发的图,是事故。谁上来就说「feed 要强一致」,这道题从第一步就跑偏了——强一致的 feed 既做不到,也不需要。

需求和量级都摆上桌了。流量几乎全压在读这头,那就先收拾 feed。

二、feed 这条线:计算放在读端还是写端 The feed: pull, push, hybrid

v1:读的时候现算

问题就一句话:用户下拉刷新,怎么在几百毫秒内给他一页「他关注的人的最新图」?人人第一反应都一样,一条 SQL 的事:

SELECT * FROM posts
WHERE author_id IN (
  SELECT followee_id FROM follows WHERE follower_id = ?
)
ORDER BY created_at DESC
LIMIT 20;

关注表查一遍,帖子表按作者拉最新的,归并排序,返回。逻辑对,实现快,demo 阶段绰绰有余。

然后你上线了。翻车点不在正确性,在它把最贵的计算放在了发生频率最高的一端。用户平均关注几百人,每次下拉都是一次几百路的归并;而「下拉刷新」是整个 App 里频率最高的动作。读是写的几十倍 [2],意味着同一批帖子被反复现算几十遍,数据库整天在做重复劳动。索引救不了这个——索引加速的是查单个作者,救不了「一次要查几百个作者」这件事本身(见图 2)。

用户下拉刷新全 App 最高频动作feed 服务现场归并几百路按作者逐个查最新帖作者 1 的帖子作者 2 的帖子……作者 N 的帖子N = 平均关注数,几百同一批帖子,每天被重复归并几十亿次
图 2. 拉模式:一次下拉刷新,就是一次几百路的实时归并——查关注表、按每个作者取最新帖、排序取前 20。这套计算发生在整个 App 频率最高的动作上,每天被重复几十亿次。

到这一步,方向已经写在负载形态里了:读多写少,就把计算搬到写那头去。

先别急着把 v1 扔进垃圾桶。记住它,后面它会以另一个身份回来。

v2:写的时候先算好——收件箱

反转思路:不在读时归并,而在写时分发。你可以把它想成小区门口的快递柜:快递员(写端)提前把包裹放进每户的格子,取件(读端)就是开一下柜门的事——而不是每个人下楼后再挨个驿站现找。

具体做法:给每个用户维护一个「收件箱」——按时间倒序的 id 列表,一条 Redis list 就能起步。发帖流程变成:写 posts 表 → 扔进消息队列 → 由一个分发进程(fanout worker)查出粉丝列表,把这条帖子的 id逐个插进每个粉丝的收件箱。这个「写的时候挨家挨户分发」的动作,行话叫 fanout。这不是纸上方案,Instagram 早期官方口径就是这么干的:Redis 驱动 main feed,分发任务扔在 Gearman 队列里做 [5]。Twitter 同期给自己定的送达目标是 5 秒 [2]。这套设计还隐含一个承诺:发帖人人一样快——不管你有几个粉丝,发帖本身都只是写一条记录,分发在后台慢慢做。

读路径瞬间干净:取自己收件箱的前 20 个 id,批量查一次元数据(缩略图 URL、作者、点赞数),拼装返回。不归并,不 JOIN(见图 3)。

发帖人posts 表+ 消息队列分发进程查粉丝表,只发 id粉丝 A 收件箱:id 列表粉丝 B 收件箱:id 列表粉丝 C 收件箱:id 列表粉丝刷 feed读自己的收件箱前 20 个 id,再批量补元数据图片字节走另一条线:对象存储 + CDN(虚线,见第三、四节)
图 3. 推模式:发帖时由分发进程把帖子 id 送进每个粉丝的收件箱,读 feed 变成取自己收件箱前 20 个 id 再批量补元数据。注意图片字节从头到尾不进这条流水线——收件箱里只有指针,图在对象存储和 CDN 那条线上。
(写路径红、读路径绿)

这里有两个坑必须点死。

第一,收件箱存 id,不存图。一条收件箱记录十几个字节(帖子 id + 时间戳),一张图几 MB——差了五个数量级。图片本体放在对象存储那个云文件仓库里,feed 里只存一个指向它的 URL,真正的图走 CDN 下发。feed 系统只负责一件事:一份排好序的 id 列表。谁要是把图片本体塞进分发流程,等于发一条帖要往几万个收件箱里各复制一份几 MB 的文件,工作量凭空放大上百万倍。feed 系统和图片存储系统是两个系统,分得越干净,两边各自越好活。

第二,分发是异步的——那我自己刚发的图,要等好几秒才出现在自己 feed 里?用户不答应。解法便宜到几乎白送:发帖成功时,直接把这条 id 插进发帖人自己收件箱的头部,不等后台分发。第一节那句题眼——全局最终一致、本人写后读——就在这一行代码里兑现。

v2 看起来赢麻了。直到某天,一个千万粉的账号发了张自拍。

v3:大 V 击穿承诺

算笔账。一个 2 万粉的用户发一条帖,等于 Redis 集群里最多 2 万次插入 [2]。千万粉的明星发一张图,等于千万次写——这一个人的一次发帖,比全站普通用户几分钟的发帖总量还大。分发队列被他一个人堆爆,普通用户的帖子跟着排队,「发帖延迟与粉丝数无关」这个承诺被单点击穿。

Twitter 的官方解法很干脆:对头部大 V,干脆不分发了。帖子只写进大 V 自己的作品列表;粉丝读 feed 时,把这几个大 V 的最新帖实时合并进收件箱结果 [2]。也就是说:99% 的账号照旧「写时分发」——行话叫推(push);头部大 V 改回「读时现取」——行话叫拉(pull);读 feed 时把两路合起来(见图 4)。

普通账号发帖粉丝几百到几万照旧分发每个粉丝的收件箱大 V 发帖千万粉:不分发只写一份大 V 自己的作品列表粉丝读的时候来拉读 feed:两路归并收件箱 + K 个大 V 列表99% 的账号走推,头部走拉——推为多数人省读,拉为大 V 省写
图 4. 推拉混合:普通账号照旧分发进粉丝收件箱;大 V 只写自己的作品列表、一次都不分发;读 feed 时把收件箱和关注的大 V 列表实时合并。为多数人省读,为大 V 省写。

这不是 feed 层的临时补丁。TAO 在社交关系那层做了一模一样的事:一个账号的某类关系(比如粉丝)超过 6000 条,就不再缓存完整名单 [3]热点账号在每一层都要特殊对待——社交负载的头部效应是规律,不是意外。

代价说清楚:读路径从纯 O(1) 变成「收件箱 + K 个大 V 列表」的归并,关注了一堆大 V 的用户读变贵了;大 V 名单要维护,阈值是个运营参数,不是常数。推为多数人省读,拉为大 V 省写——推拉混合的本质是承认没有单边解。

v4:收件箱是窗口,不是归档

这回的问题不是规模砸出来的,是 v2 自己带来的:收件箱只进不出,会一直长。一亿用户,每人一条只增不减的列表,内存迟早撑不住。

Twitter 的三条纪律 [2]:每条时间线只留最多 800 条;3 副本放不同机器;只有 30 天内登录过的活跃用户常驻内存,冷用户读时重建。

翻译过来:收件箱是滑动窗口,不是全量归档。翻页翻穿 800 条怎么办?回退到拉模式,按关注列表现查各作者近期帖再归并。冷用户仨月没登录、收件箱早清了怎么办?还是拉模式,现场重建。所以 v1 没死——它降级成了兜底路径(见图 5)。没人翻 feed 翻到第 800 条,为这种极少发生的情况常年占着内存不划算,真发生时付一次现算的延迟就够了。

一条收件箱:窗口内常驻,窗口外不存窗口:最多 800 条,活跃用户常驻内存最新第 800 条更早更早窗口外:直接不存翻穿第 800 条 / 冷用户重建时拉模式兜底(就是 v1)按关注列表现查各作者近期帖,再合并为极少发生的情况,付一次现算的延迟,而不是常年占着内存
图 5. 收件箱是滑动窗口:只留最近 800 条,只给 30 天内活跃的用户常驻内存。翻页翻穿窗口、或冷用户回归时,回退到拉模式按关注列表现查——v1 没死,它成了兜底路径。

容量当场估(数字全是假设,换成你自己的量级再算): 内存,几十台大内存机器的量级。贵,但可控——前提是你守住了「只存 id」和「只留窗口」两条纪律。存储引擎本身也有后话:Instagram 后来把 feed 收件箱从 Redis 迁到 Cassandra,换上 RocksDB 引擎后,生产集群最慢那 1% 请求的读延迟(P99)从 60ms 降到 20ms [6]——但那是内存账单大到必须落盘之后才需要操心的事。

翻页用 cursor,ID 是分页协议的一半

feed 是高频插入的列表,这直接判了 offset 分页死刑。两份官方定罪:Slack 工程博客写得很直白——LIMIT/OFFSET 不可扩展,offset 越大数据库要先读 offset+count 行再扔掉,而且高频写入下页窗口会漂移,跳帖或重复 [7];Facebook 的 Graph API 文档干脆说 cursor 分页最高效、能用就该用 [8](见图 6)。

offset:按位置数cursor:按锚点接着取第一页:取前 3 条ABCDE新帖 N 插到最前面:NABCDE第二页:跳过前 3 条,再取 3 条CDEC 又出现了页窗口被新帖推着漂移:重复或跳帖第一页:取前 3 条,记住锚点 = CABCDE↑ 锚点新帖 N 插到最前面(无所谓):NABCD第二页:从锚点 C 往后取DE锚点不动:翻页不重、不漏
图 6. 同一份高频插入的列表翻第二页:offset 按「位置」数,新帖一插进来,页窗口整体后移,上一页的尾巴在第二页又出现(删帖则反过来跳帖);cursor 记的是「上一页最后一条」这个锚点,新帖怎么插都不影响下一页从哪开始。

cursor 就是「上一页最后一条的锚点」,下一页从锚点往后取,新帖插进来也不影响你的窗口。锚点用什么?Instagram 的 ID 设计在这里闭环:64 位 = 41 位毫秒时间戳 + 13 位分片编号(记录这条数据落在哪台数据库上)+ 10 位序列号,官方列的第一条需求就是「光看 ID 就能按时间排序,不必再回数据库查一次」 [9]。ID 自带时间序,id 本身就是 cursor。所以 ID 生成方案要在建库前定——它不是实现细节,它是分页协议的一半。

时间序还是算法排序:先别急着上模型

到这里的收件箱天然按时间倒序,cursor 干净,行为可解释。第一天就该发布这个版本。

但要知道终局在哪。Instagram 2016 年放弃纯时间序,官方复盘给的动因很硬:当时用户平均错过 feed 里 70% 的帖子,包括近半来自亲密关系的帖子 [10]。时间序对重度关注者是灾难——刷得慢的人,永远只能看到发得勤的人的最新几条。

关键是算法排序的正确姿势不是推倒收件箱,而是在它上面加一层重排:取收件箱最近 N 条当候选,按信号打分后重排。Instagram 官方口径的信号就四类:帖子信息、发帖者信息、你的活动、你和发帖者的互动史 [10]。收件箱管「有什么可看」,排序层管「先看什么」,两层解耦。

代价有两笔。一,排序打碎了纯时间 cursor——「第 21 条」不再由时间定义,cursor 得改成记住「这次排好的序列里,你已经看到第几条」。二,用户会造反:Instagram 2022 年被迫加回 Following 时间序视图 [11]。所以逃生门从架构上就要留着——反正时间序收件箱一直在,排序只是它上面的一层。排序是第 365 天的事,收件箱是第 1 天的事。

这条线收个账

feed 主线从头到尾只在做一件事:在读和写之间搬运计算。v1 全放读端,被几十倍的读写比压死;v2 全搬写端,被大 V 的百万倍写放大压死;v3 按账号粒度往回搬一部分;v4 承认预计算的产物只能是个窗口,窗口外交还给读端现算。没有哪一版是终点,只有「你的规模把计算逼到了哪一头」。

至于图片本身?它从头到尾没进过这条流水线。它有自己的一生,下面单独讲。

三、一张图的一生(上):别让字节流过你的服务器 A photo’s life: capture & upload

你以为你拿到的是一张 JPEG

先从最省事的写法开始:客户端拍完照,把原图 POST 到自家 API,服务器落盘、存个路径进数据库,读的时候原样吐回去。十行代码,demo 能跑。叫它 v1,看它怎么死——这回一天翻两次车。

第一次翻车,发生在图还没出手机的时候。你以为拿到的是「一张 JPEG」,其实不一定。iOS 11 之后,iPhone 默认拍出来的是 HEIC——苹果自家的图片格式,官方说法是画质相同、体积更小 [12],可老 Android 和不少浏览器根本打不开。原样上传,一部分用户看到的就是一张裂图。

方向是第二个坑。每张照片文件里都夹着一小段说明信息,叫 EXIF——拍摄时间、镜头方向、GPS 位置都记在里面。不少手机竖着拍的时候,存下的其实是横着的像素,只在 EXIF 里记一笔「显示时请转 90 度」。看图的软件认这一笔,图就是正的;不认,图就是横的。Android 官方文档也明说:相机给出的方向信息,要由用它的一方自己处理 [13]。你不处理,就会收获那个经典 bug:「上传之后照片是横的」。

还有更要命的:EXIF 里的 GPS。你在家里拍张猫,坐标就是你家的位置。Android 10 之后,系统给应用读相册时默认就把位置信息抹掉,想拿原始坐标得单独申请权限 [13]。连操作系统都把它当敏感数据,你原样传上公网,等于替用户广播住址。

所以上传之前,客户端要做一遍预处理。先解码,把 HEIC 这类格式读出来;再缩小,屏幕展示用不着几千万像素——Instagram 就把分享的照片统一缩到最宽 1080px [14];然后转成更省流量的通用格式,比如 WebP——Google 官方的压缩研究测过,肉眼画质相当时比 JPEG 小 25% 到 34% [15];最后清理 EXIF,方向转正、位置抹掉。四步走完,几 MB 的原图变成几百 KB。图小了一个数量级,弱网下的上传时间也跟着掉一个数量级。

踩坑

「方向转正」只能做一头:要么把像素真正转正、同时删掉 EXIF 里那笔旋转说明,要么像素和说明都不动。只转像素、不删说明,看图软件会照着说明再转一次,图又横了。位置信息则默认删掉——真需要定位的功能,让用户明确授权后把坐标单独传,别藏在图片文件里。

第二次翻车在服务器上

预处理解决的只是图本身的问题。等图真往外传,v1 的第二个问题就来了:上传流量全部要过应用服务器。机器的网卡和 CPU 大头都耗在搬运字节上,高峰期把正经业务请求一起拖死。这不是我吓唬你,AWS 官方博客把「代理上传」列为反面教材,还附赠一条硬限制:走它家网关(API Gateway)中转,单个请求最大 10 MB [16]——手机拍的 4800 万像素原图,门都进不去。弱网断线?从头重传。

服务器不该碰图片的字节。它该干的活像海关官员:盖章放行,货不过它的办公室。

修复思路是发通行证。客户端先向服务器要一张限时的「上传许可」,术语叫 presigned URL:上面写清了传到哪个位置、传多大的什么文件、几分钟内有效。客户端拿着它,把字节直接交给对象存储,全程不再经过应用服务器 [16](见图 7)。

v1:字节全过应用服务器客户端几 MB 字节应用服务器网卡 CPU 全在搬字节对象存储网关中转上限 10 MB · 断线从头重传高峰期把正经业务一起拖死v2:presigned URL 直传客户端应用服务器只签字、只记账1. 请求上传2. 发回上传许可3. 字节分片直传(可断点续传)对象存储4. 落盘事件通知字节走最短的路,服务器不碰一个
图 7. 同一次上传的两种走法。左:字节全部流过应用服务器——网卡和 CPU 耗在搬运上,还有网关 10 MB 的硬上限,断线只能从头来。右:服务器只签发一次带过期时间的上传许可,几 MB 的字节直连对象存储,断了从断点接着传。
(服务器从「搬运工」降级成「签字的」)

三个细节决定这一版能不能上生产。

一,文件名(key)必须由服务端起。对象存储里,同名上传会直接覆盖旧文件 [4]——让客户端自己起名,等于任何人猜个名字就能覆盖别人的图。文件类型、大小上限也写死在许可里,有效期给短。

二,断点续传靠会话,不靠运气。思路是把大文件切成几十片、一片一片传:断在哪片,回头只补那一片,绝不从头来。对象存储的原生方案叫 multipart upload——开传前先领一个会话编号(UploadId),各片独立上传,同一片重复传就是覆盖,重试多少次结果都一样,怎么试都不会传坏 [4]。自建上传服务就用 tus 协议,语义等价:客户端先问服务器「上次传到第几个字节了」,再从那个位置接着传 [17]。共同的要点是把会话编号存进手机本地——App 被杀、重启之后,凭它接着传,而不是从头来。

三,弱网重试别自己写循环。系统自带的机制比你手写的靠谱:iOS 交给 background URLSession,App 被切到后台,系统进程也接着传 [12];Android 交给 WorkManager,等有网了再传、失败了拉长间隔重试,设备重启任务还在 [13]。对了,记得配一条自动清理规则,把传了一半就没下文的分片定期删掉,不然它们躺在存储桶里默默收你的钱 [4]

把等待藏进填 caption 的间隙

到这里上传已经稳了。但还能更快——快到用户感觉不到。

Instagram 早期「点 Share 秒发」不是玄学,一手出处是联合创始人 Mike Krieger 2011 年的演讲:滤镜一确定就后台开始上传,用户填 caption、选地点的那十几秒被拿来传字节,点 Share 时只提交元数据。用户取消怎么办?把传了的字节扔掉。他原话是这在工程上不最优,但 “It’s worth it even if you throw the photo away” [18](见图 8)。

不预上传:传输排在点 Share 之后挑图 / 滤镜填 caption(十几秒)上传中…转圈等发布点 Share,开始干等预上传:传输藏进填写的间隙挑图 / 滤镜填 caption(十几秒)后台上传(和填写同时进行)只提交元数据发布点 Share,字节已经传完——体感秒发用户取消?把传了的字节扔掉
图 8. 同一次发图的两条时间线:上面把上传排在点 Share 之后,用户对着转圈等几秒;下面在填 caption 的同时后台就把字节传完,点 Share 只提交元数据。传输量一点没少,等待感没了。

这招的代价要配套认领:蜂窝网络和省流量模式下要降级;用户取消后那些白传的文件,要设自动过期时间(TTL),到点删掉;没发布的内容在服务端存多久、怎么删,要对用户说得清楚。感知性能和真实性能一样值钱,但它不是免费的。

字节稳稳落进对象存储了。可一个新问题冒出来:字节没经过你的服务器,你怎么知道它传完了、完整不完整、能不能给人看?

四、一张图的一生(下):先占位,就绪才可见 Pipeline, storage, delivery

草稿翻转:全系统唯一的可见性开关

答案是「先占位、后翻转」。发放上传许可的同时,先在数据库里记一条草稿(draft),它不出现在任何列表里,对外相当于不存在。等对象存储发来事件通知、确认字节真的落了盘(别只信客户端自己上报),该做的检查也都过了,再把草稿翻转成正式发布。Cloudflare Images 的 direct creator upload 就是这个模式的一手例证:draft 状态的图对外不存在 [19]

翻转之前要过安全检查。检查分两类,摆的位置也不一样。第一类,查「已知的违规图」(最典型是儿童性虐待内容,CSAM):给每张图算一枚「指纹」——术语叫感知哈希,图被裁剪、压缩过也认得出来——拿去跟违规图指纹库比对。这一步又快又准,直接放在上传路径上,命中就拒收。Discord 公开过做法:指纹命中即删图、封号、上报 NCMEC [20]。第二类,用模型判断「像不像违规」,比如涉黄识别。模型会误判,所以放到后台慢慢跑,结果用来降权、打码、送人工复核,不拦着发布。同步路径只放又快又准的检查,会误判的模型一律放后台——这条线画错,要么拦不住该拦的,要么用户发一张图要等玄学般的好几秒。

多分辨率:预生成还是现做

先说清楚为什么要多分辨率。同一张图,feed 列表里要小图,点开看要大图,不同尺寸的屏幕还各要一档——一张原图背后,得备好几个缩小版。问题是:这些缩小版是上传时一口气全做出来(预生成),还是等有人要的时候现做?

直觉是全都预生成。Flickr 当年就是这么干的:每张图预生成 11 种尺寸,结果这些缩小版占掉的存储,几乎等于把原图再存一遍,其中九成来自 640px 以上的大尺寸 [21]。2015 年他们改了打法:只留最大的一份缩小版(通常 2048px 宽)当底版,中大尺寸等有人要时用 GPU 现场缩——2048 缩到 1600px 不到 16 毫秒,旧的 CPU 方案要 225 毫秒以上 [21]。此后 Cloudflare、imgix、AWS 的官方方案收敛到同一个形态:只存一份底图,要什么尺寸在 CDN 边缘现场变,变过一次的存进 CDN 缓存下次直接用 [19]

普通团队抄混合版就行:feed 缩略图这类一定会被要的热门尺寸,上传时就先做好,保住首屏速度;冷门尺寸等有人要再现做,做一次缓存一次。但「现做」有个前提——只开放几个固定宽度,比如 Cloudflare 默认的 320/768/960/1200px [19],谁来都从这几档里挑。要是放任前端随便要 517px、518px,每个宽度都得单独现做一张,缓存就白搭了。

存储:一张图存一个文件,能撑到几百亿张吗

存储这层,直觉方案是一张图存成一个文件,扔进文件系统。Facebook 试过,死状记录在他们自家图片存储系统 Haystack 的论文里——第一节提过一嘴的那个 Haystack,现在正式登场。慢在「找」上:文件系统读一个文件,要先查目录、再查这个文件的登记信息(inode),最后才真正碰到图片数据——最初读一张图要 10 多次磁盘操作,优化到极限还是要 3 次 [1]。三次里只有最后一次在干正事,前两次都在找位置。拖垮读速度的是找文件的开销,不是读数据本身。

顺着这个结论想:把「每个文件在哪」提前记在内存里、跳过查找,行不行?直接记,账算不过来。文件系统给每个文件的登记信息有几百字节,几百亿张图就是几十 TB 的清单,内存装不下。Haystack 的破法是给这份清单狠狠减肥:一张图只记三样——在哪个大文件里、从第几个字节开始、有多长,合计约 10 字节 [1];文件名、权限、时间戳,通通不要。清单从几十 TB 缩到几百 GB,摊到每台存储机头上只剩零头,内存轻松放下。

配套的另一半是:图不再一张占一个文件,而是全部往约 100 GB 的大文件末尾追加着写。于是读一张图变成一个动作——查内存索引拿到「第几个字节、多长」,一次磁盘读直达图片本身 [1](见图 9)。打个比方:把一万张散装信封里的便签全贴进一本厚账本,手里只捏一张「第几页第几行」的目录。这套东西当年撑住的规模:20 PB、每周 10 亿张新照片、峰值每秒超 100 万张 [1]

一图一文件:3 次磁盘 IO读一张图① 读目录元数据② 读 inode③ 读文件本体几百亿张图 = 元数据塞不进内存磁盘 IO 全烧在找文件上Haystack:至多 1 次磁盘 IO内存索引每图约 10 字节:偏移 + 大小一次寻址直达约 100 GB 物理卷(追加写)目标图元数据全在内存,磁盘 IO 只花在字节本身
图 9. 一图一文件 vs Haystack:左边每次读要 3 次磁盘操作(目录元数据、inode、文件本体),海量小图的元数据塞不进内存;右边把小图追加进约 100 GB 的物理卷,内存索引每图约 10 字节记住偏移和大小,一次寻址直达字节。

四年后的 f4 论文补了第二课:图是有温度的。刚发的图人人都在看,一年前的图几乎没人碰——实测里,发布不到 1 天的内容,请求量是 1 年前内容的 100 倍以上 [22]。访问量差一百倍的东西,不该用同一种存法。

先说热图为什么存三份。磁盘是会坏的,图只存一份,盘一坏就永远没了,所以至少多存几份保命。存的是三份完整副本,还有个附带好处:三台机器都能对外出图,访问压力摊到三处。代价也明摆着——存 1 份的内容,花 3 份的磁盘钱。

冷图就不值得这么伺候了。它几乎没人访问,不需要三台机器一起出图,只需要「坏了不丢」。这时有个更省的办法,叫纠删码:把一份数据切成 10 小块,再用数学方法额外算出 4 块「备用块」,14 块分开放在不同磁盘上;以后不管坏掉哪 4 块,都能用剩下的 10 块把原数据完整算回来。保命效果够用,磁盘只花 1.4 份的钱(见图 10)。多存副本,买的是「扛访问」;改用纠删码,省的是「磁盘钱」——热图买前者,冷图选后者。落到论文数字上:Haystack 那套热存储,三副本再叠上磁盘层自身的冗余,存 1 字节实际占 3.6 字节;f4 的冷存储用 RS(10,4) 纠删码加跨机房容灾,压到 2.1 字节 [22]

热图:三份完整副本一张图完整副本 1可对外出图完整副本 2可对外出图完整副本 3可对外出图磁盘开销:3 份三处同时出图,扛得住访问量(Haystack 叠上磁盘冗余后,实际 3.6 份)冷图:纠删码 RS(10,4)一张图12345678910备1备2备3备410 块数据 + 4 块备用块,分放在不同磁盘红叉示意坏块:最多任意坏 4 块,都能用剩下的算回原样磁盘开销:1.4 份没有多余的完整副本可出图——冷图无所谓(f4 加跨机房容灾后,实际 2.1 份)
图 10. 同一份数据的两种保命方式。左:存三份完整副本,花 3 份磁盘钱,换来三台机器同时出图;右:切成 10 块数据、算出 4 块备用块,任意坏 4 块都能算回原样,只花 1.4 份磁盘钱——但没有多余的完整副本可以出图。热图买左边,冷图选右边。

这事还有个 2021 年的续集,Tectonic。冷热拆成两套系统之后,毛病出来了:磁盘是整块买的,买它的「出图能力」就必须连它的容量一起买。热存储那边为了凑够出图能力越买越多,多出来的容量全空着,实际占用反而涨到 5.3 字节;冷存储那边正相反,容量塞得满满的,出图能力却常年闲置。两套系统各浪费一半,最后 Facebook 干脆自研了一套新的存储系统 Tectonic,把两边都替换掉了 [23]。注意:他们换的是架构,不是路线——那个量级,自建没得选,但具体架构十年里换了一代又一代。架构是有保质期的,别把 2010 年的论文当教条。

盘一盘这一节你被迫学会的东西:给元数据减肥、把小图并进大文件、分冷热、上纠删码,还得攒够海量磁盘来摊热点——还记得第一节算的源站每秒 7 万张吗?一块机械硬盘一秒只能随机读约 120 次 [24],除一下,光扛住读就得六百块盘同时转,还没算冗余和峰值。这些活,2026 年都有人替你打包干完了:对象存储卖的就是这一整套——你给它一个文件名,它负责字节存在哪、存几份、坏了怎么补,数据被打散在上百万块盘上,热点自然摊薄 [24],按量付费。

所以默认答案很无聊,三块现成的各管一段:字节交给对象存储;「这张图是谁的、发没发、URL 指向哪」这类业务记录,对象存储不管(它只认文件名),放一个普通的元数据数据库;读的大头交给 CDN——第一节已经算过,九成浏览它挡掉。这一节讲的所有精巧,都是在教你识货,不是教你自建。真到要自建的那天,参照系是 Dropbox:数百 PB 数据、负载稳定可预测,投了约两年半的工程才从 S3 迁回自建 [25]。你不在那个量级,就别在这上面立功。

最后一公里在 CDN 上

读路径的大头从来不在源站。Haystack 时代,九成图片浏览由 CDN 消化,后端只接一成 [1]。但同一篇论文也留了警告:CDN 只护得住热门的图;偶尔才被翻出来的老图,CDN 缓存里早就没有了,请求照样打回后端——光堆缓存救不了后端,这才是他们当年要自建 Haystack 的原因。

CDN 侧三件事照官方蓝本抄。第一件,管住「回源」——CDN 缓存里没有的图,要回你的源站去取,这个动作叫回源。CDN 节点成千上万,要是每个节点缺图都直接找源站要,源站等于被几千个客户端围着打;所以边缘节点缺图先问自己的上层节点,只有上层的少数节点有资格回源站 [19]。第二件,私密图要上锁。图片链接带上签名和过期时间,过点就失效;签名只能由服务端生成,别人伪造不了,外站也盗不了链。第三件,格式挑最省的给。浏览器请求时会自报认识哪些图片格式,边缘节点照单发货:认识 AVIF 给 AVIF——等质量下比 JPEG 能小一半上下 [26]——不认识就退到 WebP,再退到 JPEG。AVIF 唯一的毛病是压起来慢,所以适合压一次、缓存反复用,不适合每次现压。

五、交汇处:发一张图,它什么时候才「存在」 Where the two lines meet

两条线各自能跑之后,真正容易出问题的是它们的衔接处:一张图从「传完了」到「出现在别人 feed 里」,中间有一串必须按顺序拨的开关(见图 11)。

① 签发上传许可 + 建 draft 占位不出现在任何列表② 字节直传对象存储可预上传:藏进填 caption 的间隙③ 落盘事件通知确认别只信客户端回调④ 同步安全检查感知哈希匹配,命中即拒⑤ 热尺寸生成完毕feed 缩略图必须先就绪⑥ 翻转可见draft → published:唯一开关⑦ 本人收件箱回填写后读在这里兑现⑧ 异步分发进粉丝收件箱大 V 不分发,读时合并⑨ 异步长尾ML 分类 · 长尾尺寸 · 排序信号翻转之前失败:停在 draft、TTL 清理(用户侧「发布中 / 重试」);翻转之后失败:队列重放补齐
图 11. 发一张图的完整时序:翻转可见是全系统唯一的可见性开关——它前面的步骤(占位、直传、落盘确认、同步哈希检查、热尺寸生成)一个都不能少,它后面的(本人回填、粉丝分发、ML 分类、长尾尺寸)全部可以异步。
(灰色 = 对外不可见,绿色 = 可见之后)

这段衔接要守三条规矩,比图本身更重要:

分发的容错单独交代一句。投递按「宁可重复、不能丢」设计:分发进程崩了,消息队列会把没确认的消息再发一遍;收件箱按帖子 id 去重,同一条帖插几次也只出现一次。那真漏投了怎么办?不用专门修。拉模式那条兜底路径读的是作者列表这份权威数据,跟收件箱漏没漏无关;而收件箱本来就只承诺最终一致——第一节把档位定在那里,就是为了让这类小概率问题不算事故。

六、2026 年,你该抄到哪一步 What to copy at your scale

走完两条线,回到开头那个最实在的问题:这些是千亿浏览量喂出来的答案,你手上那点量级,到底该抄多少?

答案其实前两条线已经写好了:你的规模走到哪一版,就抄到哪一版——免费的第一天就做对,贵的等撞上痛再上(见图 12)。

大厂的重型机械你的起步姿势时间有序 ID + cursor 分页同款,建库前就定免费 · ID 是分页协议的一半presigned 直传 + draft 开关服务器不碰字节同款 + 客户端转码 / 转正 / 剥 GPS免费 · 第一天就该做对收件箱分发 + 推拉混合拉模式一条 SQL 起步读写比真压死了再上推 · 大 V 特判等有了大 V 再说断点续传 + 预上传multipart / tus · 秒发体验单次 PUT 起步弱网投诉 / 大文件出现再上续传 · 产品要秒发再预上传算法排序 feed四类信号打分重排时间序收件箱排序是第 365 天的事 · 时间序视图永远留着Haystack / f4 自建存储needle · RS(10,4) 纠删码对象存储 + CDN,别自建那是几百亿张图的答案 · 连 Facebook 都换了两代
图 12. 降档对照:左边是大厂被规模逼出来的重型机械,右边是普通团队的起步姿势。绿色是免费或近乎免费、第一天就该做对的;橙色是撞上对应的痛再上的;灰色是几百亿张图才需要的答案,先别碰。

几条容易选错档的,多啰嗦一句:

我看过不少系统设计答卷在这条线上堆满自研组件——自研存储、自研队列、自研 CDN 调度。但 senior 和 junior 的差距恰恰反着来:这道题里每一个「不做」的决定,都比「做」的决定值钱。

七、这题到底在考你什么 What this problem is really testing

把两条线摊开看,「设计一个图片分享 App」真正在考的是三层,一层比一层深。

第一层,你能不能看出这是两道题。分发题和搬运题,负载形态完全不同:一个是每秒几十万次的小对象读,一个是每天几千万个的大对象写。把它们搅在一起——比如把图片字节塞进 feed 流水线,或者让 feed 服务顺手代理上传——答案就已经输了一半。拆得干净的标志是那两句话:feed 里只有指针,服务器不碰字节。

第二层,feed 那半考的是在读和写之间搬运计算的功力。读写比决定搬运方向,热点决定搬运粒度,窗口决定搬运成本。只画「一切顺利」那条路径的,和张口就「全量分发」的,犯的是同一个错:没算账。

第三层,图片那半考的是你知不知道什么不该做。字节不过应用服务器、可见性只留一个开关、同步路径只放便宜且确定的检查、几百亿张图之前不自建存储。这些「不做」全都有一手源背书——连 Facebook 都把 Haystack 换掉了(换上的 Tectonic 依然是自建,那个量级没得选);你要是照着 2010 年的论文自建,抄到手的只是人家已经淘汰的那一代。

下次碰到 feed 类的题,先问三件事:读写比多少?最大的账号有多少粉丝?用户看不到自己刚发的内容,算不算事故?碰到媒体类的题,再问另外三件:最大的文件多大?弱网断了怎么续?「传完了」和「可见了」之间隔着什么?这六个问题问完,架构大体也就定了。

面试如此,生产更如此。这道题的满分答案,从来不是你搭了多少东西,而是你挡住了多少东西——字节挡在服务器外,大 V 挡在分发外,强一致挡在需求外。知道哪些字节不该经过自己的服务器,比知道怎么处理它们值钱得多。

附:这些坑和招,论文和文档里叫什么 A glossary

正文我用的是大白话,这里跟一手源里的正经术语对齐一下——你翻原文、跟人讨论时好对得上词:

参考文献 References

  1. Doug Beaver, Sanjeev Kumar, Harry C. Li, Jason Sobel, Peter Vajgel. Finding a needle in Haystack: Facebook’s photo storage. OSDI 2010.(NFS 下 10 次 / 3 次磁盘操作、元数据 IO 结论、约 100 GB volume、每图约 10 字节内存索引、至多 1 次磁盘 IO、20 PB / 每周 10 亿张 / 峰值每秒超 100 万张、CDN 消化约九成浏览)
  2. Raffi Krikorian. Timelines at Scale. QCon San Francisco 2012 演讲。(读 300K QPS / 写日均 5K/s、峰值 7K/s;fanout 5 秒送达目标;2 万粉 = 2 万次插入;大 V 不 fanout、读时 merge;时间线上限 800 条、3 副本、30 天活跃窗口)
  3. Nathan Bronson et al. TAO: Facebook’s Distributed Data Store for the Social Graph. USENIX ATC 2013.(读占 99.8% / 写 0.2%;同一类型边超过 6000 条的高度数对象不缓存完整边表)
  4. AWS S3 官方文档与定价页。(同 key 上传覆盖语义;multipart upload:UploadId、分片重传幂等、100 MB 以上建议启用;AbortIncompleteMultipartUpload 生命周期规则;出网流量首档约 0.09 美元/GB,2026-07 口径)
  5. Instagram Engineering. What Powers Instagram: Hundreds of Instances, Dozens of Technologies.(Redis 驱动 main feed;feed fanout 在 Gearman 队列)
  6. Instagram Engineering. Open-sourcing a 10x reduction in Apache Cassandra tail latency. 2018.(换 RocksDB 引擎后生产集群 P99 读延迟 60ms 降到 20ms)
  7. Slack Engineering. Evolving API Pagination at Slack.(LIMIT/OFFSET 不可扩展;高频写入下页窗口漂移、跳帖或重复)
  8. Facebook Graph API 文档. Paginated Results.(cursor-based pagination 最高效、可用时应优先使用)
  9. Instagram Engineering. Sharding & IDs at Instagram. 2011.(64 位 = 41 位毫秒时间戳 + 13 位分片 + 10 位序列;第一条需求是 ID 可按时间排序)
  10. Adam Mosseri. Shedding More Light on How Instagram Works. Instagram 官方博客, 2021.(2016 年切换排序时用户平均错过 feed 里 70% 的帖子、含近半亲密关系帖子;排序信号四类)
  11. Meta Newsroom. Two New Ways to Control Your Instagram Feed. 2022-03.(Favorites 与 Following 两种视图、均按时间倒序;时间序视图回归)
  12. Apple 官方文档。(Using HEIF or HEVC media on Apple devices:iOS 11 起默认 HEIC;URL Loading System:background URLSession 由系统进程在 App 挂起后继续传输)
  13. Android Developers 官方文档。(CameraX:落盘 JPEG 的方向在 EXIF Orientation、实时分析帧的方向通过 rotationDegrees 暴露并由消费端应用;shared storage:位置元数据默认脱敏、需 ACCESS_MEDIA_LOCATION 权限;WorkManager:网络约束、指数退避、重启存活)
  14. Instagram Help Center.(分享的照片归一化到最宽 1080px)
  15. Google WebP 官方文档. WebP Compression Study.(同等 SSIM 质量下比 JPEG 小 25%–34%)
  16. AWS Compute Blog. Uploading to Amazon S3 directly from a web or mobile application (2020);Patterns for building an API to upload files to Amazon S3 (2023).(代理上传的反模式论述;API Gateway 请求上限 10 MB;presigned URL 直传模式)
  17. tus.io. tus resumable upload protocol 1.0.(HEAD 查询 offset、PATCH 断点续写)
  18. Mike Krieger. Secrets to Lightning Fast Mobile Design. Warm Gun 2011 演讲(Speaker Deck 及 LukeW 现场笔记)。(滤镜确定即后台预上传;“It’s worth it even if you throw the photo away”)
  19. Cloudflare 官方文档。(Images direct creator upload:draft 状态对外不存在;Tiered Cache:仅上层节点回源;Image Resizing 默认断点 320/768/960/1200px;单一源图 + 边缘变换 + CDN 缓存变体的形态)
  20. Discord 官方安全博客。(PhotoDNA 感知哈希匹配已知 CSAM:命中即删除、封号、上报 NCMEC)
  21. Flickr Engineering (code.flickr.net). Real-time Resizing of Flickr Images Using GPUs. 2015.(11 种预生成尺寸的存储量约等于原图再存一遍、近 90% 来自 640px 以上尺寸;保留最大衍生图作缩放源;GPU 缩放 2048→1600px 低于 16ms,旧 GraphicsMagick 方案 225ms 以上)
  22. Subramanian Muralidhar et al. f4: Facebook’s Warm BLOB Storage System. OSDI 2014.(不到 1 天的内容请求率是 1 年前内容的 100 倍以上;有效副本系数 3.6 → 2.1,RS(10,4) + 跨数据中心 XOR)
  23. Satadru Pan et al. Facebook’s Tectonic Filesystem: Efficiency from Exascale. FAST 2021.(Haystack 实际有效系数涨到 5.3、f4 的 IOPS 闲置;两套系统被 Tectonic 合并替换)
  24. Andy Warfield. Building and operating a pretty big storage system called S3. All Things Distributed, 2023.(单块硬盘约 120 随机 IOPS;热点靠把数据打散到海量磁盘上摊薄)
  25. Dropbox 技术博客. Scaling to exabytes and beyond. 2016.(Magic Pocket:约两年半工程、数百 PB 迁出 S3)
  26. Netflix TechBlog. AVIF for Next-Generation Image Coding(逐图对比显示同等感知质量下显著小于 JPEG);web.dev 关于 AVIF 的文章(定性结论:压缩显著优于 JPEG/WebP);Jake Archibald 的独立对比测试(等质量下约为 JPEG 的一半或更小)。
#系统设计#Feed 流#Instagram#对象存储#图片上传