专业书籍精读 · System Design Interview 卷二 · 第 1 章
System Design Interview Vol 2 · Ch 1 · Alex Xu & Sahn Lam · 2022
你在点评类 App 上按下「附近的餐厅」,一秒之内它从两亿家店里挑出你周围那几十家。这一章问:这件事到底怎么做到?答案有点反直觉——难点不在「搜」,在怎么提前给地球编好门牌号。
系统其实从来不「量距离」。它不会拿你的坐标去跟两亿家店挨个算一遍谁远谁近。它早就把地球切成了一格一格、每格给了个编号,你按下按钮时它只问两句:你在哪一格?那一格里有谁?
位置是两个数——南北一个、东西一个。数据库很擅长按一个数排队找,就像字典按拼音排好了序;却很不擅长同时按两个数找。这就像在图书馆里找「书名在 A 到 C 之间、并且厚度在 3 到 5 厘米之间」的书:两条线索各自都能筛出一大堆,可要把两堆比对到一起,就得一本本过。两亿家店这么一过,慢得没法用。
① 把地球对折,折出门牌号。把世界对半分、再对半分、再对半分……每分一次就往编号后面添一个字。字越多,格子越小。妙处在于:编号开头一样,位置就一定挨得近——两个数就这样被压成了一串字,数据库又回到了它最擅长的「按一个键排队」。
② 半径不是用来算距离的,是用来挑格子大小的。你要找 500 米内的,就用小格子;找 20 公里内的,就换大格子。先用格子把候选从两亿家缩到几百家,真正的距离留到最后再算。
③ 顺手把邻居一起拿。你很可能正站在格子边上,最好吃的那家就在隔壁格里。所以系统从不只查你这一格,而是连着周围八格一起取,九格的店凑成一堆,再排序。
切格子有两派。一派是「全世界都切成一样大」——简单,商户开了关了改一行就行;另一派是「哪儿店多就在哪儿多切几刀」——市中心切得细、沙漠留一大格,每次取回来的数量更均匀,代价是店一变就得重算一大片。
代价还得再诚实一句:格子是方的,你要的范围是圆的——九宫格必然多捞进一些其实在半径之外的店,最后还是得挨个算一次距离筛掉。
「找附近」不是搜索题,是编门牌号题:把经纬度这一对二维坐标,提前压成一串一维的编号,让数据库用它最拿手的老办法(按顺序找、按开头找)去干活。剩下的功夫,都花在处理边界和「方格装圆」这两件小事上。
想进到具体机制、记号和示意图? → 切到精读版
你以为「附近的餐厅」是一道搜索题,其实是一道降维题:这一章从头到尾只在做一件事——把经纬度这对二维坐标压成一个一维的键(geohash 字符串,或 S2 的 64 位整数),好让 B-tree 索引、键值存储、缓存这些「只会按一个键排队」的老工具重新变得可用。围绕这条主线,本章还甩出两个反直觉的结论:半径不是查询参数,是索引精度参数;数据只有几十 GB 而读 QPS 有 5000,所以该加副本、不该分片。
wx4g 开头的」。B-tree 索引天生擅长这个。本章是卷二的开篇,也是全书「地理三连」的第一环:Ch1 附近服务(不会动的店)→ Ch2 附近的好友(每几秒换一次位置的活人)→ Ch3 Google Maps(路网与导航)。三章共用同一套空间索引,变的只是数据的更新频率——所以这一章是地基,后两章都建在它上面。现实里它对应 Yelp / 大众点评 / Google Maps 的「附近」标签页,以及任何「按位置找东西」的需求。
先把规模摆出来(书里的口径):1 亿日活、2 亿家商户,假设每人每天搜 5 次,搜索 QPS = 1 亿 × 5 ÷ 86400 ≈ 5000。功能上只要三件事:按(经度、纬度、半径)返回附近商户;商户主能增删改自己的店,且不要求实时生效(第二天可见就行);用户能看商户详情。
题眼藏在「不解决会怎样」里。最朴素的写法是一行 SQL:WHERE lat BETWEEN … AND lng BETWEEN …。它在 2 亿行上跑不动,原因不是没建索引,而是两个索引用不到一块儿去:数据库通常只能挑一个索引走,比如先按纬度筛出一条横跨整个地球的窄带,再逐行回表核对经度。中间结果动辄百万行,而这题的延迟要求是几百毫秒。所以必须在查询之前,就把二维的邻近关系预先编码进某个一维的键里。
最容易想到的补救是均匀网格:把地图切成边长固定的方格,每家店记下自己在哪一格。它确实做到了降维,但格子里的数量极不均匀——曼哈顿一格能塞几万家,太平洋上一格空着。
真正好用的方案都有同一个形状:把平面递归地对半切,再把「每一刀切在哪边」按顺序写成一串码。二维的邻近关系于是被编码进了一维键的前缀里——共同前缀越长,说明两点被同一批刀切在了同一侧,也就越近。
Geohash 是上面那把刀的标准实现:经度区间与纬度区间交替对半分,每分一次记一位,再把二进制串按 5 位一组编成 base32 字符——编码每长一个字符,格子面积缩到约 1/32。量级:码长 4 约 39 km × 20 km,码长 5 约 4.9 km × 4.9 km,码长 6 约 1.2 km × 0.6 km。
于是「半径 → 精度」就变成一张查表。书里给的对应是:0.5 km → 6、1 km → 5、2 km → 5、5 km → 4、20 km → 4。这正是那句「半径不是查询参数,是索引精度参数」的含义:你不拿半径去算距离,你拿它挑格子大小,先把候选集从 2 亿缩到几百,距离留到最后一步再精确算。
书里点名了两个必踩的坑,都必须当场解决:
① 边界问题。两个点可能只隔几米,却被一刀切在两个格子里,前缀完全不同——极端情况是跨赤道或本初子午线,第一个字符就不一样。所以「前缀相同 ⇒ 位置相近」成立,但反过来「前缀不同 ⇒ 位置远」是错的。解法很朴素:查询时把自己这一格 + 周围 8 格一起取,共 9 格。
② 凑不够数。半径内的商户太少(郊区、或者叠加了「营业中」这类过滤之后)。解法同样朴素:去掉编码的最后一个字符,格子放大约 32 倍再查一次,不够就再去一个字符。
Geohash 的格子是「按地球切」的,四叉树的格子是「按数据切」的:从整张地图起步,只要某个方块里的商户超过阈值(书里取 100 家),就把它切成四块,递归到每个叶子装不超过 100 家为止。曼哈顿会被切得很深,太平洋一个节点就够。好处是取回来的候选数量天然均匀,而且天然支持「返回最近的 k 家」——geohash 只回答「这格里有谁」,要 k 近邻还得自己一圈圈往外扩。
关键区别是:它不是数据库里的表,而是每台 LBS 服务器启动时在内存里建的一棵树。书里的内存账:2 亿商户 ÷ 100 = 200 万个叶子,内部节点约为叶子的 1/3、约 67 万个;每个叶子存 100 个 8 字节商户 id 即 800 字节,合计约 1.71 GB——一台机器装得下。
代价全出在更新上:商户增删要动树形,实时更新得加锁、很难做对。书里的选择是每晚整棵重建 + 灰度上线,一次只把一小批服务器摘出轮转、重建、放回——因为建一棵 2 亿商户的树是分钟级的事,这期间那台机器不能服务。这也解释了需求里那句「商户更新不要求实时生效」:那条让步正是为了买下这个方案。
S2 是 Google 的球面网格库,走的是第三条路:先把球面(而不是把地图当平面)投到一个外接立方体的六个面上,再用希尔伯特曲线——一条不断折返、走遍所有格子且相邻格子在曲线上也相邻的路径——把二维格子串成一条线,每个格子拿到一个 64 位整数 cell id。降维的目的和 geohash 一样,只是刀法更讲究:它在球面上做,避免了把地球当平面的形变。
它独有的能力是 region cover:任意形状的一片区域(一个行政区、一个配送范围)都能被一组大小不一的格子覆盖——大格子填中间,小格子贴边缘。这让「用户有没有进出某片不规则区域」这类地理围栏(geofencing)判断变得简单。代价是概念与实现都复杂得多,不是白板上现推得出来的东西。
表结构简单到出乎意料:一张 business 存商户详情,一张 geospatial_index 做空间索引,只有两列——geohash 和 business_id,一家店在一个 geohash 下就是一行。查询就是 WHERE geohash IN (那九个格子)。
接着书给出一个跟卷一前几章都反着来的判断:这张表根本不用分片。两列、2 亿行,几十 GB 量级,一台像样的机器放得下;真正的压力是 5000 QPS 的读,不是容量。所以答案是加读副本——写走主库,读扇到 N 台副本,加机器就线性抬读吞吐。容量和流量是两个独立的旋钮,别拿分片去拧流量那个。
缓存这一层有个极容易写错的细节:缓存键必须是 geohash,绝不能是用户的经纬度。用户坐标精确到小数点后六位、走一步就变,拿它当键命中率恒为 0;而 geohash 是「一格」,同一格里的所有人共享同一份答案——这是把缓存从摆设变成主力的一行之差。缓存两类:geohash → 商户 id 列表,以及 business_id → 商户详情。
整套架构因此长成两条几乎不相干的路:读路(用户 → LBS → Redis → 索引表读副本)与写路(商户主 → 商户服务 → 主库 → 异步复制到副本)。LBS 无状态、只读,按峰值横向加机器即可;商户主的写 QPS 低到可以忽略。复制滞后带来的「改完没马上生效」,正好落在需求允许的范围内。
表 1 · 四种做法的分野
| 做法 | 机制 | 强项 | 诚实代价 |
|---|---|---|---|
| 二维范围查询 | lat / lng 各建索引,SQL 里 BETWEEN | 零额外设施 | 两个索引合不起来,中间结果百万行级,2 亿数据上不可用 |
| 均匀网格 | 切成边长固定的方格,记格子号 | 实现最简单 | 分布极不均,且格子号之间没有天然远近关系 |
| Geohash | 递归对半切 + base32 编码,前缀即邻近 | 简单、好更新、直接支持半径查询 | 边界问题(要取 9 格);格子固定大小,密区照样撑爆 |
| 四叉树 | 按密度递归四分,叶子 ≤ 100 家 | 数量均匀、天然支持 k 近邻 | 常驻内存、要建树;更新极难,只能每晚重建 + 灰度上线 |
| S2 | 球面 + 希尔伯特曲线 → 64 位整数 | 任意区域覆盖、适合地理围栏 | 概念与实现最复杂,面试里现推不出来 |
表 2 · 决赛圈:geohash vs 四叉树(本章的主对照)
| Geohash | 四叉树 | |
|---|---|---|
| 住在哪里 | 数据库 / 键值存储里的一张表 | 每台服务器的内存(本章约 1.71 GB) |
| 实现难度 | 低,一个库 + 一列索引 | 要自己建树、自己管生命周期 |
| 增删一家店 | 删一行 / 加一行 | 动树形,实时更新要加锁;实践中每晚重建 |
| 格子大小 | 精度定死后固定 | 随密度自适应 |
| k 近邻 | 不直接支持,要一圈圈扩 | 天然支持 |
| 什么时候选它 | 商户变动频繁、要半径查询——本章的主推 | 密度极不均、且必须回答「最近的 k 家」 |
表 3 · 这一题为什么不分片
| 分片 | 加读副本(本章选择) | |
|---|---|---|
| 解决的是 | 单机装不下 | 单机读不过来 |
| 本题的事实 | 两列 × 2 亿行 ≈ 几十 GB,一台机器绰绰有余 | 5000 QPS 读、写几乎为零 |
| 代价 | 跨片查询、再平衡、热点片,全套复杂度白付 | 复制滞后——但需求已明说可以 |
把三张表拧成一句判据:商户会变、要半径查询 → geohash;密度悬殊、要 k 近邻 → 四叉树;要覆盖不规则区域 / 做地理围栏 → S2。而无论选哪个索引,最后一步都躲不掉:候选集取回来之后,必须按真实距离精确算一遍再排序——格子只负责把 2 亿缩到几百,不负责给答案。
这一题的价值在于它示范了一个通法:当查询维度和索引结构对不上时,先想办法降维,而不是换数据库。同一手法反复出现——全文检索把文档降成词的倒排表,向量检索用 LSH 把高维向量降成哈希桶。geohash 只是它在地理上的一个实例。
面试里,能主动说出「这里不该分片,该加副本」和「缓存键得用 geohash 不能用经纬度」这两句,比背下 geohash 的编码规则值钱得多——它们证明你在算负载,而不是在背方案。
① 一句话:「找附近」不是搜索题,是降维题——把经纬度压成一维的键,让普通索引和缓存重新可用。
② 规模的题眼:1 亿日活、2 亿商户、搜索 QPS ≈ 5000,延迟要求几百毫秒。
③ 朴素 SQL 死在两个索引合不起来:先按一维筛出百万行,再逐行核对另一维。
④ Geohash = 递归对半切 + base32;码长 4 ≈ 39 km、5 ≈ 4.9 km、6 ≈ 1.2 km,每多一个字符面积缩到约 1/32。
⑤ 半径是索引精度旋钮,不是距离参数:先查表定码长,再取九宫格,最后才算真实距离。
⑥ 两个必踩的坑:边界问题(取 8 个邻居)与凑不够数(去掉最后一个字符,放大重查)。
⑦ 四叉树按密度自适应、天然支持 k 近邻,代价是常驻内存约 1.71 GB 且更新极难,只能每晚重建 + 灰度上线。
⑧ 反直觉的两条工程判断:索引表加读副本而不是分片(几十 GB 装得下,压力在读);缓存键必须是 geohash 而非经纬度(否则命中率恒为 0)。