图片分享 App 其实是两道题的合体:feed 是「读压倒写」的分发题,图片是「字节又大又沉」的搬运题。Twitter、Instagram、Facebook 的一手论文和工程博客里,藏着同一套答案——把计算搬到写端,把字节挡在服务器外。这篇把两条主线各自从最朴素的一版推到生产级,每一步都标好价钱:你的规模走到哪一版,就抄到哪一版。
先算一笔账。
你打开手机里那个图片 App,拇指往上一划,二十张图铺满屏幕,前后几百毫秒。这个动作你一天要做几十次,几亿人跟你一起做。Facebook 的照片系统巅峰时期是什么量级?他们自己论文里的数字:每天千亿上下的图片浏览,峰值每秒对外吐超过 100 万张图 [1]。而写那头呢?Twitter 2012 年公开过自家时间线的负载:读每秒 30 万次,写每秒峰值不过 7 千 [2]——差出四十来倍。
不过这道题真正难的地方还不是读写比,而是它其实是两道题的合体:feed 是一道分发题——谁该看到什么、按什么顺序、多快;图片是一道搬运题——几 MB 的字节怎么从快门底下走进机房,再走上几百万块屏幕。这两道题的负载形态、瓶颈、解法,长得完全不一样。面试里把它们搅在一起答,生产里把它们焊在一起建,是同一种死法。
所以问题就摆在这:feed 怎么让一亿人刷起来都秒开?一张图从按下快门到出现在别人 feed 里,中间要过几道关?以及最后那个更实在的问题——这些是 Instagram 和 Facebook 用几百亿张图喂出来的答案,你的系统到底该抄哪几条?
先圈需求,再画框。
功能上只做四件事:拍照上传、关注关系、按关注拉取的图片 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)。
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。
问题就一句话:用户下拉刷新,怎么在几百毫秒内给他一页「他关注的人的最新图」?人人第一反应都一样,一条 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)。
到这一步,方向已经写在负载形态里了:读多写少,就把计算搬到写那头去。
先别急着把 v1 扔进垃圾桶。记住它,后面它会以另一个身份回来。
反转思路:不在读时归并,而在写时分发。你可以把它想成小区门口的快递柜:快递员(写端)提前把包裹放进每户的格子,取件(读端)就是开一下柜门的事——而不是每个人下楼后再挨个驿站现找。
具体做法:给每个用户维护一个「收件箱」——按时间倒序的 id 列表,一条 Redis list 就能起步。发帖流程变成:写 posts 表 → 扔进消息队列 → 由一个分发进程(fanout worker)查出粉丝列表,把这条帖子的 id逐个插进每个粉丝的收件箱。这个「写的时候挨家挨户分发」的动作,行话叫 fanout。这不是纸上方案,Instagram 早期官方口径就是这么干的:Redis 驱动 main feed,分发任务扔在 Gearman 队列里做 [5]。Twitter 同期给自己定的送达目标是 5 秒 [2]。这套设计还隐含一个承诺:发帖人人一样快——不管你有几个粉丝,发帖本身都只是写一条记录,分发在后台慢慢做。
读路径瞬间干净:取自己收件箱的前 20 个 id,批量查一次元数据(缩略图 URL、作者、点赞数),拼装返回。不归并,不 JOIN(见图 3)。
这里有两个坑必须点死。
第一,收件箱存 id,不存图。一条收件箱记录十几个字节(帖子 id + 时间戳),一张图几 MB——差了五个数量级。图片本体放在对象存储那个云文件仓库里,feed 里只存一个指向它的 URL,真正的图走 CDN 下发。feed 系统只负责一件事:一份排好序的 id 列表。谁要是把图片本体塞进分发流程,等于发一条帖要往几万个收件箱里各复制一份几 MB 的文件,工作量凭空放大上百万倍。feed 系统和图片存储系统是两个系统,分得越干净,两边各自越好活。
第二,分发是异步的——那我自己刚发的图,要等好几秒才出现在自己 feed 里?用户不答应。解法便宜到几乎白送:发帖成功时,直接把这条 id 插进发帖人自己收件箱的头部,不等后台分发。第一节那句题眼——全局最终一致、本人写后读——就在这一行代码里兑现。
v2 看起来赢麻了。直到某天,一个千万粉的账号发了张自拍。
算笔账。一个 2 万粉的用户发一条帖,等于 Redis 集群里最多 2 万次插入 [2]。千万粉的明星发一张图,等于千万次写——这一个人的一次发帖,比全站普通用户几分钟的发帖总量还大。分发队列被他一个人堆爆,普通用户的帖子跟着排队,「发帖延迟与粉丝数无关」这个承诺被单点击穿。
Twitter 的官方解法很干脆:对头部大 V,干脆不分发了。帖子只写进大 V 自己的作品列表;粉丝读 feed 时,把这几个大 V 的最新帖实时合并进收件箱结果 [2]。也就是说:99% 的账号照旧「写时分发」——行话叫推(push);头部大 V 改回「读时现取」——行话叫拉(pull);读 feed 时把两路合起来(见图 4)。
这不是 feed 层的临时补丁。TAO 在社交关系那层做了一模一样的事:一个账号的某类关系(比如粉丝)超过 6000 条,就不再缓存完整名单 [3]。热点账号在每一层都要特殊对待——社交负载的头部效应是规律,不是意外。
代价说清楚:读路径从纯 O(1) 变成「收件箱 + K 个大 V 列表」的归并,关注了一堆大 V 的用户读变贵了;大 V 名单要维护,阈值是个运营参数,不是常数。推为多数人省读,拉为大 V 省写——推拉混合的本质是承认没有单边解。
这回的问题不是规模砸出来的,是 v2 自己带来的:收件箱只进不出,会一直长。一亿用户,每人一条只增不减的列表,内存迟早撑不住。
Twitter 的三条纪律 [2]:每条时间线只留最多 800 条;3 副本放不同机器;只有 30 天内登录过的活跃用户常驻内存,冷用户读时重建。
翻译过来:收件箱是滑动窗口,不是全量归档。翻页翻穿 800 条怎么办?回退到拉模式,按关注列表现查各作者近期帖再归并。冷用户仨月没登录、收件箱早清了怎么办?还是拉模式,现场重建。所以 v1 没死——它降级成了兜底路径(见图 5)。没人翻 feed 翻到第 800 条,为这种极少发生的情况常年占着内存不划算,真发生时付一次现算的延迟就够了。
容量当场估(数字全是假设,换成你自己的量级再算): 内存,几十台大内存机器的量级。贵,但可控——前提是你守住了「只存 id」和「只留窗口」两条纪律。存储引擎本身也有后话:Instagram 后来把 feed 收件箱从 Redis 迁到 Cassandra,换上 RocksDB 引擎后,生产集群最慢那 1% 请求的读延迟(P99)从 60ms 降到 20ms [6]——但那是内存账单大到必须落盘之后才需要操心的事。
feed 是高频插入的列表,这直接判了 offset 分页死刑。两份官方定罪:Slack 工程博客写得很直白——LIMIT/OFFSET 不可扩展,offset 越大数据库要先读 offset+count 行再扔掉,而且高频写入下页窗口会漂移,跳帖或重复 [7];Facebook 的 Graph API 文档干脆说 cursor 分页最高效、能用就该用 [8](见图 6)。
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 承认预计算的产物只能是个窗口,窗口外交还给读端现算。没有哪一版是终点,只有「你的规模把计算逼到了哪一头」。
至于图片本身?它从头到尾没进过这条流水线。它有自己的一生,下面单独讲。
先从最省事的写法开始:客户端拍完照,把原图 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)。
三个细节决定这一版能不能上生产。
一,文件名(key)必须由服务端起。对象存储里,同名上传会直接覆盖旧文件 [4]——让客户端自己起名,等于任何人猜个名字就能覆盖别人的图。文件类型、大小上限也写死在许可里,有效期给短。
二,断点续传靠会话,不靠运气。思路是把大文件切成几十片、一片一片传:断在哪片,回头只补那一片,绝不从头来。对象存储的原生方案叫 multipart upload——开传前先领一个会话编号(UploadId),各片独立上传,同一片重复传就是覆盖,重试多少次结果都一样,怎么试都不会传坏 [4]。自建上传服务就用 tus 协议,语义等价:客户端先问服务器「上次传到第几个字节了」,再从那个位置接着传 [17]。共同的要点是把会话编号存进手机本地——App 被杀、重启之后,凭它接着传,而不是从头来。
三,弱网重试别自己写循环。系统自带的机制比你手写的靠谱:iOS 交给 background URLSession,App 被切到后台,系统进程也接着传 [12];Android 交给 WorkManager,等有网了再传、失败了拉长间隔重试,设备重启任务还在 [13]。对了,记得配一条自动清理规则,把传了一半就没下文的分片定期删掉,不然它们躺在存储桶里默默收你的钱 [4]。
到这里上传已经稳了。但还能更快——快到用户感觉不到。
Instagram 早期「点 Share 秒发」不是玄学,一手出处是联合创始人 Mike Krieger 2011 年的演讲:滤镜一确定就后台开始上传,用户填 caption、选地点的那十几秒被拿来传字节,点 Share 时只提交元数据。用户取消怎么办?把传了的字节扔掉。他原话是这在工程上不最优,但 “It’s worth it even if you throw the photo away” [18](见图 8)。
这招的代价要配套认领:蜂窝网络和省流量模式下要降级;用户取消后那些白传的文件,要设自动过期时间(TTL),到点删掉;没发布的内容在服务端存多久、怎么删,要对用户说得清楚。感知性能和真实性能一样值钱,但它不是免费的。
字节稳稳落进对象存储了。可一个新问题冒出来:字节没经过你的服务器,你怎么知道它传完了、完整不完整、能不能给人看?
答案是「先占位、后翻转」。发放上传许可的同时,先在数据库里记一条草稿(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]。
四年后的 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]。
这事还有个 2021 年的续集,Tectonic。冷热拆成两套系统之后,毛病出来了:磁盘是整块买的,买它的「出图能力」就必须连它的容量一起买。热存储那边为了凑够出图能力越买越多,多出来的容量全空着,实际占用反而涨到 5.3 字节;冷存储那边正相反,容量塞得满满的,出图能力却常年闲置。两套系统各浪费一半,最后 Facebook 干脆自研了一套新的存储系统 Tectonic,把两边都替换掉了 [23]。注意:他们换的是架构,不是路线——那个量级,自建没得选,但具体架构十年里换了一代又一代。架构是有保质期的,别把 2010 年的论文当教条。
盘一盘这一节你被迫学会的东西:给元数据减肥、把小图并进大文件、分冷热、上纠删码,还得攒够海量磁盘来摊热点——还记得第一节算的源站每秒 7 万张吗?一块机械硬盘一秒只能随机读约 120 次 [24],除一下,光扛住读就得六百块盘同时转,还没算冗余和峰值。这些活,2026 年都有人替你打包干完了:对象存储卖的就是这一整套——你给它一个文件名,它负责字节存在哪、存几份、坏了怎么补,数据被打散在上百万块盘上,热点自然摊薄 [24],按量付费。
所以默认答案很无聊,三块现成的各管一段:字节交给对象存储;「这张图是谁的、发没发、URL 指向哪」这类业务记录,对象存储不管(它只认文件名),放一个普通的元数据数据库;读的大头交给 CDN——第一节已经算过,九成浏览它挡掉。这一节讲的所有精巧,都是在教你识货,不是教你自建。真到要自建的那天,参照系是 Dropbox:数百 PB 数据、负载稳定可预测,投了约两年半的工程才从 S3 迁回自建 [25]。你不在那个量级,就别在这上面立功。
读路径的大头从来不在源站。Haystack 时代,九成图片浏览由 CDN 消化,后端只接一成 [1]。但同一篇论文也留了警告:CDN 只护得住热门的图;偶尔才被翻出来的老图,CDN 缓存里早就没有了,请求照样打回后端——光堆缓存救不了后端,这才是他们当年要自建 Haystack 的原因。
CDN 侧三件事照官方蓝本抄。第一件,管住「回源」——CDN 缓存里没有的图,要回你的源站去取,这个动作叫回源。CDN 节点成千上万,要是每个节点缺图都直接找源站要,源站等于被几千个客户端围着打;所以边缘节点缺图先问自己的上层节点,只有上层的少数节点有资格回源站 [19]。第二件,私密图要上锁。图片链接带上签名和过期时间,过点就失效;签名只能由服务端生成,别人伪造不了,外站也盗不了链。第三件,格式挑最省的给。浏览器请求时会自报认识哪些图片格式,边缘节点照单发货:认识 AVIF 给 AVIF——等质量下比 JPEG 能小一半上下 [26]——不认识就退到 WebP,再退到 JPEG。AVIF 唯一的毛病是压起来慢,所以适合压一次、缓存反复用,不适合每次现压。
两条线各自能跑之后,真正容易出问题的是它们的衔接处:一张图从「传完了」到「出现在别人 feed 里」,中间有一串必须按顺序拨的开关(见图 11)。
这段衔接要守三条规矩,比图本身更重要:
分发的容错单独交代一句。投递按「宁可重复、不能丢」设计:分发进程崩了,消息队列会把没确认的消息再发一遍;收件箱按帖子 id 去重,同一条帖插几次也只出现一次。那真漏投了怎么办?不用专门修。拉模式那条兜底路径读的是作者列表这份权威数据,跟收件箱漏没漏无关;而收件箱本来就只承诺最终一致——第一节把档位定在那里,就是为了让这类小概率问题不算事故。
走完两条线,回到开头那个最实在的问题:这些是千亿浏览量喂出来的答案,你手上那点量级,到底该抄多少?
答案其实前两条线已经写好了:你的规模走到哪一版,就抄到哪一版——免费的第一天就做对,贵的等撞上痛再上(见图 12)。
几条容易选错档的,多啰嗦一句:
我看过不少系统设计答卷在这条线上堆满自研组件——自研存储、自研队列、自研 CDN 调度。但 senior 和 junior 的差距恰恰反着来:这道题里每一个「不做」的决定,都比「做」的决定值钱。
把两条线摊开看,「设计一个图片分享 App」真正在考的是三层,一层比一层深。
第一层,你能不能看出这是两道题。分发题和搬运题,负载形态完全不同:一个是每秒几十万次的小对象读,一个是每天几千万个的大对象写。把它们搅在一起——比如把图片字节塞进 feed 流水线,或者让 feed 服务顺手代理上传——答案就已经输了一半。拆得干净的标志是那两句话:feed 里只有指针,服务器不碰字节。
第二层,feed 那半考的是在读和写之间搬运计算的功力。读写比决定搬运方向,热点决定搬运粒度,窗口决定搬运成本。只画「一切顺利」那条路径的,和张口就「全量分发」的,犯的是同一个错:没算账。
第三层,图片那半考的是你知不知道什么不该做。字节不过应用服务器、可见性只留一个开关、同步路径只放便宜且确定的检查、几百亿张图之前不自建存储。这些「不做」全都有一手源背书——连 Facebook 都把 Haystack 换掉了(换上的 Tectonic 依然是自建,那个量级没得选);你要是照着 2010 年的论文自建,抄到手的只是人家已经淘汰的那一代。
下次碰到 feed 类的题,先问三件事:读写比多少?最大的账号有多少粉丝?用户看不到自己刚发的内容,算不算事故?碰到媒体类的题,再问另外三件:最大的文件多大?弱网断了怎么续?「传完了」和「可见了」之间隔着什么?这六个问题问完,架构大体也就定了。
面试如此,生产更如此。这道题的满分答案,从来不是你搭了多少东西,而是你挡住了多少东西——字节挡在服务器外,大 V 挡在分发外,强一致挡在需求外。知道哪些字节不该经过自己的服务器,比知道怎么处理它们值钱得多。
正文我用的是大白话,这里跟一手源里的正经术语对齐一下——你翻原文、跟人讨论时好对得上词: