专业书籍精读 · System Design Interview 卷二 · 第 2 章
System Design Interview Vol 2 · Ch 2 · Alex Xu & Sahn Lam · 2022
微信有「附近的人」,苹果手机有「查找」,Facebook 当年有个功能叫 Nearby Friends:打开地图,就能看见哪几个朋友现在离你不远,旁边还标着「3 分钟前更新」。这一章就是从零把这个功能造出来。它和上一章的「找附近的餐厅」看着是同一类题,做法却几乎完全相反。
上一章找餐厅,办法是把全世界的店都编好号、做成一本按地址排序的大账本,你来了就翻账本。这一章要找的是人——人每半分钟就换个位置,账本每秒要改三百多万处,改都改不过来。更怪的是:这一章的正确答案,不是把账本做得更快,而是干脆不做账本。
如果照老办法来,你的手机每半分钟就得问服务器一次「我那几百个朋友现在都在哪」,服务器再挨个去账本里翻。一亿人一起这么问,服务器忙不过来,而你的手机电池撑不到中午——Uber 早年就是这么干的,他们后来公开说:老让手机主动去问,最先垮掉的是电量。
把每个人想象成一个小广播频道。你和你的几百个朋友各开一个频道,你订了他们的,他们也订了你的。谁挪了位置,就在自己频道里喊一声「我到这儿了」——只有订了他频道的人听得见。没人再去翻账本,位置自己会送上门。
那距离谁来量?听见的那一端自己量。朋友喊了一声,你这边的服务器听见了,拿尺子一比:他离我五英里以内吗?在就显示,不在就当没听见。这样系统不必回答「全城谁离谁近」这种大问题,只需要一遍遍回答「他离我近吗」这种小问题。
位置写在会褪色的便签上。每个人的当前位置只写一张便签,四十秒就自己消失。好处很妙:一个人关掉 App、进了电梯、手机没电,他那张便签四十秒后自动没了,朋友的地图上他就自然淡出——不需要任何人专门去打扫。
这套办法成立的全部前提是:你要找的那几百个人是谁,事先就知道。所以它只管得了「好友」。要是你想找身边的陌生人——那是谁事先没人知道的——就还得搬回上一章那本大账本。真实产品里两套是并存的。
代价也很实在:手机得和服务器一直连着一根线,连接本身就要占资源,连服务器发个新版本都得等旧的线慢慢散场。
找朋友在哪,别去搜——让每个人开一个广播频道,谁动了自己喊,听见的人自己量距离。位置写在四十秒自动消失的便签上,人一掉线就自然从地图上淡出。找陌生人,才需要上一章那本大账本。
想进到具体机制、量级和示意图? → 切到精读版
你以为「附近的好友」是上一章「附近服务」换个数据源,其实两章方向正好相反:上一章把不动的兴趣点建成 geohash 网格索引、等你来查;这一章的对象每 30 秒动一次,索引刚建好就过期,于是整章的答案是根本不搜。它抓住一个前提——好友是事先已知、只有几百人的集合——把地理搜索降级成沿好友关系的发布订阅(pub/sub):位置主动扇出,距离在收端算。第二个反直觉点:这里的位置数据一到就该烂掉,写进带过期时间的缓存、丢了也不补——Kafka 那种可靠队列摆在主链路上反而是负资产。
本章是《System Design Interview》卷二第 2 章,紧接第 1 章「附近服务」:上一章处理不动的东西(餐厅、加油站),本章处理动的人,第 3 章「Google Maps」再往路网与导航走。现实对照物是 Apple 的「查找」、Uber 乘客端上那辆一直在挪的车、Discord 头像上的绿点。这也是全书第一次把有状态的长连接层摆上台面。
需求很小:打开 App,看到 5 英里内的好友、各自的距离和「几分钟前更新」,位置每 30 秒刷新。规模不小:10 亿注册用户,按 10% 的人用这个功能算,1 亿活跃用户。
先把分量算出来。每人每 30 秒上报一次:1亿 ÷ 30 ≈ 每秒 330 万次位置更新。对比一下,上一章的附近服务读 QPS 是 5000 量级、写几乎没有——这一章的写入量比上一章高三个数量级,且每一次写都要立刻扩散给一群人。沿用老办法会撞上三堵墙:
其一,索引扛不住。geohash 网格索引能用的前提是被索引的东西很少动;每秒 330 万次坐标变更等于每秒 330 万次索引条目的删加插,任何 B-tree 都会被写放大压垮。
其二,轮询扛不住。让客户端每 30 秒问一次「好友都在哪」,1 亿客户端就是每秒 330 万次查询,每次还要拉几百个好友的坐标。Uber 早年正是这么做的,他们后来公开说这条路最先垮掉的不是服务器,是手机电池。
其三,也最容易被忽略:服务端需要主动说话的权利。好友位置变了是服务端先知道,而 HTTP 的形状是「客户端问、服务端答」,服务端没有开口的资格。想做到「朋友一动我这边就更新」,就得先有一条服务端能主动写入的通道。
上一章的题面是「我周围有什么餐厅」——候选集未知,可能是任何一家店,所以必须先把全世界建成索引。这一章的题面看着像「我周围有哪些好友」,其实等价于一句性质完全不同的话:在我这几百个好友里,谁现在离我近。候选集事先就知道,而且只有几百,不是几十亿。
看清这一点,整道题就换了性质:不需要地理索引,只需要一张好友关系表(本来就有)和一条把位置送过去的路。搜索问题被降级成投递问题——这是本章唯一的核心技巧,后面所有组件都只是在把这条路修好。代价要提前说清:它对陌生人完全失效,你没法预先订阅一个还不认识的人。
第一个组件是 WebSocket 服务器:每个在线用户与它保持一条不断开的双向通道,服务端因此能随时往下推。代价是这一层有状态——「用户 B 的连接握在第 7 号机手里」只存在于第 7 号机的内存里。三笔账:
第二个组件是把位置送出去的路:给每个用户开一个频道 channel:{user_id},含义是「这个人的位置广播」;A 的每个在线好友由自己所在的 WS 服务器代为订阅。一次更新于是走成图 2 的样子:手机 A 上报坐标 → 服务器一边写缓存、一边往 channel:A 发布 → 订阅了该频道的各台 WS 服务器各收一份 → 每台对手上的相关连接逐个算距离,5 英里内的才往下推。
为什么距离要在收端算。半径是每个接收者自己的事:B 关心「A 离 B 多远」,C 关心「A 离 C 多远」,同一条广播对不同人有不同答案。放到收端算,发布者就不必知道谁在哪,中间的 pub/sub 层退化成一根纯粹的管道——它不理解地理,也不需要理解。
扇出的量级。一条位置更新要复制给发布者的每一个在线好友。按每人几百个好友、约一成同时在线粗估,扇出倍数在几十的量级,乘上每秒 330 万次更新,全系统的投递量落在每秒上亿条这个刻度上。这个数字既解释了为什么 pub/sub 必须做成集群,也解释了为什么这条链路上每一步都不能太重——多一次落盘、多一跳网络,乘以上亿都是灾难。
第三个组件是存储,它分成性质截然不同的两半。
热的那半只是一个键值缓存:user_id → (纬度, 经度, 时间戳),用 Redis,带 TTL,过期时间比刷新间隔略长(30 秒的间隔配 40 秒的 TTL)。这里藏着本章最漂亮的细节:TTL 一个机制同时解决两个问题——让陈旧位置自动作废(你不会看到两小时前的坐标被当成「现在」),也让掉线检测变成免费的。用户关掉 App、进电梯、手机没电,没有任何组件需要察觉,也不需要一条「下线」消息,他的条目 40 秒后自己蒸发,好友那边就自然把他从地图上抹掉。不需要写的下线逻辑,是不会出错的下线逻辑。
冷的那半是位置历史,写进 Cassandra 这类追加写极快的库(分区键取 user_id、聚簇键取时间戳)。关键是它必须离开关键路径:历史对实时展示毫无用处,只服务于回放与分析,所以走异步、允许延迟,绝不能让它的抖动传导回推送链路——工程上通常先落 Kafka 再消费入库。顺带一个成本感:每秒 330 万条、每条几十字节,一天就是十几 TB 的原始写入,所以「历史存不存、存多久」在这里是个必须回答的产品问题。
单台 Redis 处理不了每秒 330 万次 PUBLISH 和背后上亿次投递,所以 pub/sub 必须是集群。分片单位是频道:用一致性哈希把 channel:{user_id} 打散到 N 台服务器,再用服务发现(etcd 或 ZooKeeper)记录归属,WS 服务器订阅前先查一次。
成本模型和直觉不一样:频道只是「频道 → 订阅者列表」映射里的一项,没人订就根本不存在——所以哪怕系统里有 10 亿个用户 ID,真实存在的频道数只等于在线且被订阅的那一批。开销全在 CPU:每条消息要遍历订阅者列表逐份投递,扇出越大越贵,频道多不多反而无关紧要。
扩容和故障时频道要搬家,窗口里可能丢几条消息。这里可以坦然接受丢消息,理由很硬:位置是幂等的全量快照而不是增量,下一个 30 秒的上报会把状态自动修好,丢一条的后果只是某个小圆点晚半分钟挪一次。这个判断直接决定了下一节的选型。
需求若扩展成「显示附近的任何用户,不限好友」,本章这套就不够了——你没法预先订阅一个不认识的人。正确做法是两套并存:好友走 pub/sub 推送,陌生人回到上一章的网格索引——缓存里维护「geohash 格子 → 当前在此格内的在线用户集合」,查询时取本格及相邻格的并集。因为只收在线用户、集合项同样带 TTL,这个索引比「全体用户」小得多。
表 1 · 服务端推送 vs 客户端轮询
| 长连接推送(本章方案) | 客户端定时轮询 | |
|---|---|---|
| 延迟 | 好友一动即到,秒级以内 | 取决于轮询间隔,30 秒起跳 |
| 服务端成本 | 要维持上亿条长连接,机器数由连接数定 | 无连接状态,但每秒 330 万次查询直击数据层 |
| 客户端成本 | 一条连接加心跳,省电 | 每次唤醒无线电,电池第一个垮掉 |
| 运维复杂度 | 高:有状态、发版要 drain、有重连风暴 | 低:无状态,随便扩缩 |
| 何时选它 | 实时性是卖点、在线用户多 | 更新可以慢、用户量小、或只是低频入口 |
表 2 · 位置消息走什么通道
| Redis Pub/Sub(本章方案) | 消息队列(Kafka 等) | 服务间直接 RPC 扇出 | |
|---|---|---|---|
| 语义 | 发完即忘,不落盘,没人订就丢 | 持久化、可重放、有消费位点 | 点对点调用,无中间层 |
| 延迟 | 亚毫秒级 | 毫秒到几十毫秒,多一次落盘 | 低,但路由要自己维护 |
| 代价 | 丢消息不可恢复 | 为用不上的可靠性付吞吐与延迟;每用户一个 topic 更不可行 | 发送方须知道每个接收方在哪台机器,耦合极重 |
| 本场景判断 | 位置是幂等快照,丢了 30 秒自愈——可靠性在这里是负资产 | 只该用在历史旁路上 | 规模一大退化成 N×N 连接 |
| 适合什么 | 状态广播:在线状态、实时位置、光标 | 计费、订单、聊天——丢一条就出错 | 少量固定服务之间 |
表 3 · 距离在哪一端算
| 发端算(发布前先筛) | 中心服务算 | 收端算(本章方案) | |
|---|---|---|---|
| 可行性 | 发布者得先知道所有好友的位置 → 反向依赖,每次发布先拉几百个坐标 | 需要一份全局「谁在哪」的实时视图,热点集中、天然单点 | 只需知道自己这条连接对应的用户位置,天然并行 |
| 副作用 | pub/sub 层被迫理解地理 | 半径一变就得改中心逻辑 | 每个订阅者可以有自己的半径 |
表 4 · 两种「附近」的查询范式:本章 vs 上一章
| 附近的好友(本章) | 附近的兴趣点 / 陌生人(上一章) | |
|---|---|---|
| 候选集 | 已知,几百人;每 30 秒变一次,写 330 万 QPS | 未知,可能是任何对象;几乎不变,读 5000 QPS 量级 |
| 做法 | 关系扇出 + 收端过滤,无读放大 | geohash 网格索引 + 读时查本格及相邻 8 格 |
| 主要代价 | 有状态连接层的运维负担 | 索引维护成本,随对象移动速度飙升 |
这一章真正可迁移的不是「附近的好友」这个产品,而是状态广播的通用形状:一群客户端保持长连接,某个实体的状态一变就沿一张已知的关系图扇出,接收端各自决定要不要展示。把「位置」换成「在线状态」就是微信头像上的绿点,换成「订单状态」就是外卖软件上那个一直在挪的骑手,换成「文档光标」就是协同编辑里的彩色小旗。
面试里这一章的高分点很固定:先把 330 万 QPS 这笔账算出来,说明为什么不能沿用上一章;主动点破候选集已知,把搜索题转成投递题;说清 TTL 顺手解决了掉线检测;以及敢于论证这里不需要可靠投递——最后这条最能区分「背过架构图」和「真的想过」。
① 一句话:候选集已知就别搜索——把「附近的好友」从地理查询翻转成沿好友关系的发布订阅。
② 规模决定形状:1 亿活跃用户 × 每 30 秒一次 = 每秒 330 万次位置更新,索引和轮询两条老路同时出局。
③ 必须有长连接:HTTP 不给服务端主动开口的权利,于是有了 WebSocket 层——它有状态,机器数由连接数而非 QPS 决定。
④ 一人一频道:channel:{user_id},好友所在的服务器代为订阅;pub/sub 层是纯管道,不理解地理。
⑤ 距离在收端算:同一条广播对不同接收者答案不同,收端算才能让每人有自己的半径,也才能让中间层保持无知。
⑥ TTL 是语义不是清理:位置带 40 秒过期,一并解决陈旧作废与掉线检测——不需要写的下线逻辑不会出错。存储由此分两半,历史流异步落库、离开关键路径。
⑦ 这里不需要可靠投递:位置是幂等快照,丢了 30 秒自愈,Kafka 摆在主链路上是负资产——换成聊天消息,这个判断立刻反转。
⑧ pub/sub 按频道一致性哈希成集群、靠服务发现记录归属;开销在 CPU 扇出,不在频道数量。找陌生人则要换回上一章的 geohash 网格,真实产品两套并存。