摸鱼科技接了云栖山景区的项目,后端阿强以为是两张表三个接口的事:查附近一条 SQL,签到一条 INSERT,排行榜一条 ORDER BY。然后数据库在梦里开了口,七号公厕从地图上消失,手抖大爷屠了排行榜,榜一大哥在沙发上登顶了云栖阁。
「五一上线。」
罗总把合同拍在桌上,笑得像刚在路上捡了钱包:「云栖山景区的智慧导览项目。人家要的不多,就仨功能:查附近、签到打卡、排行榜。先把云栖山跑通,年底铺全省。阿强,后端归你。」
阿强扫了一眼需求文档,心里已经把活干完了:两张表,三个接口,一天出工,剩下俩月带薪养生。
「查附近,一条 SQL;签到,一条 INSERT;排行榜,一条 ORDER BY。」他把椅子往后一仰,「罗总,这单接得值。」
饮水机旁边传来拧保温杯的声音。老蔡往杯里续了一撮枸杞,头也没抬:「景区的活,坑全在你想不到的地方。」
老蔡是公司最老的后端,四十多岁,工位常年飘着一股中药房的气息。江湖传闻他上一家是个大厂。哪个大厂?他从来不说。
阿强没接话。老人家嘛,总爱把话说得吓人。他当天下午就把「查附近」写完了,和天下所有人的第一版一模一样:
SELECT * FROM poi
WHERE lat BETWEEN 30.23 AND 30.27
AND lng BETWEEN 120.13 AND 120.17;
经纬度各建一个索引——索引就是数据库提前排好的队,找东西不用翻全表——再加个组合索引保险。本地一跑,毫秒级。演示给产品经理小雅看,小雅很满意。
阿强看了眼饮水机的方向,心想:老蔡是真的老了。
打脸来得比五一早得多。四月初,App 在云栖山开了试运营。上线第三天,甲方运营把他们攒了多年的全省文旅 POI 库也灌了进来。POI 就是「要标上地图的点」:打卡点、展馆、酒店、餐馆、加油站,足足几十万个。说是顺路做周边推荐,年底铺全省也用得上。
当晚十点,告警响了。「查附近」的响应时间从几毫秒涨到五百多毫秒。单看这个数不算要命,要命的是它还在涨:晚上正是游客回到酒店刷「周边推荐」的高峰,慢查询越积越多,数据库的连接全被它们占着,别的接口拿不到连接,跟着一起排队。十点半,超时开始成片出现——用户那头,打开地图就是转圈。
阿强从被窝里爬起来,远程连上服务器。慢查询日志里躺着的,全是他那条得意的 SQL。他让数据库把执行过程摊开来看(EXPLAIN),扫描行数十几万:索引在,但基本白扫。他先怀疑索引坏了,重建,没用;又连夜加了三种组合索引,一种比一种没用。凌晨两点,他趴在书桌上睡着了。
梦里有人跟他说话。声音又干又平,像磁盘转动。
「我是你的 B 树索引。我这辈子只会一件事:把东西排成一列,然后二分着找。你给我纬度,我把全世界按纬度排好——30.23 到 30.27,唰,一刀切出来,快得很。
「可你要的是云栖山边上一个圈。我给你的是什么?是一条横贯东西的带子。同纬度的成都、重庆、拉萨,全在这一刀里。经度那一维,我只能在带子里一行一行摸过去。
「你骂我笨。你换个姿势建组合索引,我还是这样——组合索引是先按纬度排队、纬度相同的再按经度排,跟字典先比第一个字一个道理。纬度一用范围条件,第一层就散了,经度那层排了也白排,只能一行行过滤。你以为你在查一个圈,其实你在把整条带子从头摸到尾。
「别怪我。你想想,平面上的点,谁能排成一列,还让空间上挨着的永远挨着? 二维塞进一维,总有地方要被撕开。你要么认命,要么——」
闹钟响了。阿强弹起来,后半句没听着。他把梦里那条带子画了下来(见图 1)——他要的圈,只是带子上的一小截。
第二天上午,他把这个梦讲给老蔡听,讲到「撕开」的时候,老蔡放下保温杯,难得地笑了。
「你这索引说得对。后半句我替它补上:要么认命,要么造一个『大部分时候挨着的还挨着』的排序,再单独处理被撕开的那一小撮。」他顿了顿,「Geohash、四叉树、S2、H3——你在面经里背过的所有地理索引,名字千奇百怪,干的都是这一件事:给地球排个序,尽量让挨着的还挨着。」
「怎么排?」
「你老家的地址怎么写?」
「浙江省、金华市、兰溪市、某某街道……这跟地址有什么关系?」
「关系大了。地址就是现成的答案:前面相同的部分越长,两家离得越近。 省相同,是老乡;省市都相同,是同城;写到街道还相同,那就是邻居。查『附近的人』,不用挨个算距离——把地址前几截跟你相同的挑出来就行。」
老蔡在白板上画了个方框,当地图:「Geohash 干的就是这件事:给地球上每个位置编一个地址。把地图对半切,你在左半边还是右半边,记一笔;选中的那半再对半切,再记一笔……切一刀记一笔,切得越多,圈得越小。攒下来的这串记号,就是你的地址。」
「那经度、纬度俩数,怎么编成一串?」
老蔡没说话,抓起椅背上的外套,捏住拉链头,慢慢拉上。
「竖着切一刀,切的是经度;横着切一刀,切的是纬度。轮着来——跟拉链一样,左边一个齿,右边一个齿。」
地址取 6 位,圈出来的格子约 1.2 公里 × 0.6 公里;取到 7 位,约 153 米见方[12]。景区打卡点,7 位正合适。「查附近」从此变成「查地址前几位跟我相同的点」——排序加前缀匹配,正是 B 树的老本行。
「这不是什么民间偏方。」老蔡把外套扔回椅背,「Redis 的 GEO 你用过吧?GEOADD 压根没有新数据结构——它就是把这套『切一刀记一笔』攒出来的地址拧成一个数,塞进一个自动按大小排队的集合里(Redis 管它叫有序集合)[10]。GEOSEARCH 查半径,就是在这串排好队的地址上圈一段、从头扫到尾[11]。你以为的黑科技,就是给地球编了本地址簿。」
阿强当天下午把 POI 表切到 geohash 方案上线,「查附近」最慢的那批请求也掉回了十几毫秒,告警安静了。他给小雅发消息:搞定了。
小雅回了个「收到」。三天后,她带着测试同学美琪,用一个公共厕所把阿强钉在了耻辱柱上。
美琪是去云栖山实地测试的。她站在七号公厕门口,打开 App,搜「附近的卫生间」,半径五百米。
结果列表里有三百米外的六号,有四百米外的九号,唯独没有她眼前这一个。她拍了张照发到群里:人和厕所的合影,配文「它就在我脸前面」。
阿强对着日志查了一个小时,脸越来越绿。七号公厕的坐标没错,索引没坏,查询也没写错。他把问题端给了老蔡。
老蔡只问了一句:「你的『查附近』,到底查的是什么?」
「查地址前几位跟我相同的点——就是查我所在的这个格子。」
「问题就在这。在同一个格子里,说明离得近;可离得近,不一定在同一个格子里。 格线是对半切地图切出来的,它不长眼,说不定正好从谁脚边过。」老蔡在白板上比划,「美琪站的地方,在一条格线的左边;七号公厕在她三米外,格线的右边——俩人的地址,从半路就分了家。你只查『跟美琪同格』的点:六号、九号恰好跟她同格,一个三百米一个四百米,都进了结果;七号地址对不上,第一轮就被筛掉了,连参加算距离的资格都没有。」
阿强反应过来了:「这不就是行政区划吗。隔一堵墙的邻居,户口本上写着不同的区——查『我们区的人』,永远查不到墙那边的。」
「对。所以怎么修?」
「把格子调大点?相当于……把区并成市?」
「并成市,市界还在。你把家搬到市界的墙根底下,隔壁市的老王照样查不到——线调不没,只会挪地方,而总有人住在线边上。 而且区并得越大还有另一笔账:『同区的人』越多。你要找的是五百米内的邻居,结果把全市的人都端上来挨个算距离,累死。」
「那……」
「反过来想。你就算住在区界的墙根下,你方圆五百米的邻居,最远能跑到哪?也就隔壁区——只要区本身比五百米大,你的圈子再歪,最多压到相邻的区,蹦不过去。所以修法是:自己的区,加上周围挨着的一圈区,全查一遍,再挨个量真实距离。 落到格子上,就是你脚下这格加周围 8 个邻格,一共 9 个;格子按查询半径取大一号就行。七号公厕就在隔壁格里,这回捞上来了——一算,三米,排第一。」
「这不是我发明的。Redis 官方文档写得明明白白:GEOSEARCH 为了盖住查询形状,检查中心格加 8 个邻格共 9 个区域,逐格做区间扫描,最后按真实距离过滤[10]。搜索引擎 Elasticsearch 的老版本干脆为这事做了个专门的开关[13]。」
美琪的七号公厕,就卡在图 2 那条格线上。
代价明摆着:候选放大到 9 格,查完还要逐点算真实距离。
这里还藏着一笔更隐蔽的账。地址是一串数,能从小到大排。把全省的格子按地址排成一队,就相当于拿一支笔,在地图上把格子一个个连起来。「轮着切」切出来的地址,笔迹是横一步、竖一步的「之」字——这种走法行话叫 Z 序。它有个毛病:一片走完,笔要跳到下一片的开头,像看书换行,一行读到头,眼睛唰地飞回下一行最左边,那一下跳过了大半页。
亏就亏在跳的那一下:编号紧挨着的两个格子,地上可能隔着半张地图。 而查询恰恰是按编号扫一段,一扫就把这些远处的点捞进来白算一遍——所以「最后拿真实距离再筛一遍」这一步,在所有 Geohash 系方案里都省不掉。
阿强修完边界,第二个坑自己冒了出来。他看了眼全省 POI 的分布:省城商圈一个 7 位格里挤着 80 个点,山区三个格才摊上 1 个。
「格长是全球统一的,这就是 Geohash 第二个毛病。」老蔡这次直接开了白板课,「热闹地段一格全是候选,荒地查个大半径要扫一大片空格。固定网格对密度不均匀的世界,怎么选都不对。可行政区划早就把答案演给你看了:上海人挤人,切到区、切到街道;可可西里没什么人,一大片就一个地名。格子别固定,按密度切——点多的地方切细,点少的地方放粗。」
他在白板上画了一个矩形,一分四,其中一块又一分四:「四叉树,1974 年的原始论文,就是把这套区划逻辑自动化:哪个格子装满了,就地一分四;装满再分,一路分下去[14]。查的时候顺着来:大块不挨你的圈,整块跳过;挨着的,进去接着看小块。
固定格子和自适应格子的差别,摆在一起一眼就懂(见图 3)。
「四叉树是思路。真把它铺到整个地球上的,市面上有两家成品:Google 的 S2,Uber 的 H3。别背参数,我给你出两道真题——你搞明白它们各自是被什么问题逼出来的,参数自然就记住了。
「第一道题,Google 的。假设你的 App 做到了全国,一个用户在漠河搜『附近一公里的加油站』——就是咱们做过一百遍的查询。你猜怎么着,到了漠河,它不好使了。」
「为啥?」
「Geohash 的格子是顺着经纬线切的。经线这东西,赤道上离得最开,越往北收得越拢。所以同样 7 位的格子,在广州横竖差不多是块方砖;到了漠河那个纬度,宽度缩水近一半,方砖成了竖长条。Elasticsearch 的官方精度表专门标着『赤道口径』四个字[12],就是在提醒这件事。格子的大小形状各地不一样,『方圆一公里查几个格』就没有统一答案——参数在广州调好了,到漠河就不对。
「还有一笔账。就算在广州,用户查的要是『方圆三公里』呢?三公里的圆,外面套个六公里见方的框。六公里的边,除以 153 米一块的小格,一条边能排三十九块;三十九乘三十九——罩住这个圈,得查一千五百多个小格。」
这两笔账画出来是这样的(见图 4):
「S2 把这两个毛病一起治。第一个动作,治『格子各地不一样』:不顺着经纬线切了,给地球套个骰子,六个面各当一张平地图铺方砖,再鼓上球面。这么切出来的格子,全球大小都差不多——官方保证同一层里最大和最小差不出 1.5 倍[15][16]。
「第二个动作,治『大圈太贵』:三公里的圈不再数一千五百个小格,改成铺地砖——中间用大砖整铺,贴边换小砖描边,几十块就盖住了[17]。
「第三个动作,治『看书换行那一跳』。S2 把笔迹换成另一种走法,叫 Hilbert:像耕地,一垄走到头不抬笔,贴着拐回来接着走下一垄,几乎不跳[15]——编号挨着的格子,地上也基本挨着,扫出来的候选干净得多。」
「所以 S2 是给『全球生意 + 大圈查询』准备的方砖:骰子、地砖、蛇形路线。」阿强在本子上记,顺手画了三个小图(见图 5)。
「对。第二道题,Uber 的。Uber 天天要回答的根本不是『查附近』,是『这一片跟隔壁比,忙多少』——武林广场这格有十个人叫车、只有三辆空车,隔壁湖滨那格反过来,运力往哪调?价格要不要浮动?这种『跟周围邻居比一比』的算术,它每一片、每一分钟都在做[18]。
「这道题上,方砖露怯了:方砖的邻居有八个,贴边的四个近、沾角的四个远——『周围一圈』每家离你的距离不一样,比出来就不公平。所以 Uber 把格子换成六角砖,蜂窝那种:六个邻居个个贴边、一样远,以我为中心一圈圈往外扩,每一圈都公平[18]。
「不过六角砖的便宜不是白占的,它吃亏在两个地方。头一个,切不干净:方砖切四块能拼回原样;大六角砖你试试——小六角砖怎么摆,边上都是这里缺一角、那里凸一块,永远拼不回去——所以在 H3 里,『小格属于哪个大格』只能算个大概[18]。第二个,铺不满球面:光用六边形包不住一个球,得缝 12 块五边形——看看足球,黑色那几块就是,正好 12 块,没有它们球就缝不起来。Uber 挑了个角度,把这 12 块补丁全藏进大洋里[18]。城市街区那个粒度用第 9 档,一格约 0.1 平方公里[19]。
邻居的远近、拼不回的毛病、足球上的补丁——画出来一目了然(见图 6)。
「Elasticsearch 又是另一路:干脆不预先画格子。前面这些都是先画好区划、再往里填人;它反过来,先看人住哪,再下刀——数据从中间劈一半,每半再劈,劈到每小块只剩千把个点,一块存成磁盘上的一页,这套结构叫 BKD[20]。省多少?官方给过一个数:同一个 8 顶点的多边形,旧的四叉树方案得用 110 万个小格子去拼它的形状,新方案直接存 8 个三角形[21]——马赛克拼画和木条钉框的差别。」
先画格和先看数据这两条路,摆在一起就是图 7。
老蔡把满天飞的名词收进白板角落的一张表里:「记不住细节没关系,分歧就三个——格子是画死的还是跟着密度切的;编号路线走之字还是蛇形;格子是方的还是蜂窝的。」
| 方案 | 切格思路 | 邻近查询 | 阿喀琉斯之踵 | 谁在用(一手源) |
|---|---|---|---|---|
| Geohash | 轮着切一刀记一笔,编成地址 | 中心格 + 8 邻格 | 边界漏点、格长全球统一 | Redis GEO[10] |
| 四叉树 | 装满就地一分四 | 大块不挨就整块跳过 | 点挤一边时树长歪 | 1974 原始论文[14] |
| S2 | 方砖党:套骰子、蛇形编号 | 大砖铺中间、小砖描边 | 骰子棱边附近覆盖虚胖[17] | Google 官方库[15] |
| H3 | 六角砖党:邻居公平 | 一圈圈往外扩 | 小砖拼不回大砖 | Uber[18] |
| BKD | 先看数据再下刀 | 圈子和块求交 | 绑定 Lucene(一套开源检索内核) | Elasticsearch[21] |
「数据库侧也有现成的。」老蔡补了两行字。PostgreSQL 装个 PostGIS 插件,空间索引是现成的,一句 ST_DWithin 就能查「方圆多少米」,边界那些破事全替你处理好了[22][23];MongoDB 也内置球面查询,结果按距离从近到远排好送回来[24]。
面经里常见一句「MongoDB 的 2dsphere 基于 Google S2」。现行 MongoDB 官方文档里没有这句话;能查到的最硬证据,是它源码树里完整内嵌的 third_party/s2 目录[25]。面试这么答没问题,写进技术评审文档,请注明证据强度——「源码层面使用 S2」和「官方文档承诺基于 S2」不是一回事。
阿强把整块白板拍了照,摩拳擦掌:「所以咱们上 S2 还是 H3?」
老蔡收起笔帽,说出了那天最重要的一句话:
「都不上。云栖山这一单,几百个 POI,全量塞进内存,来一个请求逐个算距离,都够用。」
「???」
「白板上这些,是百万级、亿级 POI、密度悬殊到跨大洲的时候才值得请的重武器。你几百个点就上 S2,等于给自行车装弹射座椅。方案要跟着规模走——真到了全省平台那天,Redis GEO 或者 PostGIS 伺候,边界情况人家都替你处理完了。」他敲了敲白板,「但拉链和格线的原理你必须今天就懂。不懂原理的人做不出降档的决定,只会把见过的最重的方案原样搬来。」
读路径至此收工。阿强觉得,这个项目最难的部分已经过去了。
后来他复盘,「最难的部分已经过去了」这个念头,每回冒出来,都是出事的前兆。
试运营的第一个完整周末过完,周一早上小雅把一张截图拍在阿强桌上。
排行榜第一名:用户「湖畔徐大爷」,单日签到 47 次。同一个打卡点。
「你们排行榜是按手速排的吗?」
阿强拉日志。徐大爷没开挂,也没写脚本——他就是个真大爷,在山顶信号最差的观景台,按一次签到,圈圈转半天没反应,再按一次。App 的网络库很「贴心」,超时自动重试。一来二去,47 条记录整整齐齐躺在库里。
「用户手抖,关我后端什么事……」阿强嘟囔着要去改前端按钮防连点。
「站住。」老蔡叫住他,「防连点治标。你想清楚一件事:客户端超时,不等于服务端失败。 那条请求很可能已经写进库了,只是响应死在了回程的弱网里。这时候客户端怎么办?不重试,万一真没写进去,大爷的『到此一游』就丢了;重试,库里就是两条。丢和重之间,没有两头都占的第三条路——除非服务端能认出『这两次是同一次操作』。」
「怎么认?」
「给每次操作发个身份证。」老蔡调出一篇文章,「Stripe 把这套做成了行业范本:客户端为每次操作生成一个唯一键,随请求带上;服务端把首次执行的状态码和响应体存下来,之后凡是同一个键的请求,不再执行,直接重放存好的响应——失败的响应也照存照放[26]。键最长 255 字符,官方建议用一串几乎不可能重复的随机号(V4 UUID),至少保留 24 小时[27]。客户端重试,间隔一次比一次拉长、再掺点随机——行话叫指数退避加抖动——免得千军万马同一秒撞回来[26]。同一次操作,做一遍和做十遍,结果一个样——这就是幂等;那张身份证,就叫幂等键。」
有无幂等键,两种时序摆在一起就清楚了(见图 8)。
「签到比支付还多一层运气。」老蔡继续,「你们的玩法规则本来就是『一人、一点、一天,只算一次』——这句话翻译成数据库语言,就是一个唯一索引。」
-- A:通用幂等键,防「同一次操作被重放」
CREATE UNIQUE INDEX ON checkins (idem_key);
-- B:业务唯一键,防「同一个事实被记两次」
CREATE UNIQUE INDEX ON checkins (user_id, poi_id, checkin_date);
INSERT INTO checkins ... ON CONFLICT DO NOTHING;
两个键管的事不一样:A 管技术层,同一次点击的三次重试只算一次;B 管业务层,中午签过傍晚再来,规则说不算就是不算。B 打底、A 按需加——遇到「每次入园都能签」的点位,「一人一点一天」这条规则不存在了,唯一索引没得建,就得靠通用幂等键顶上。
阿强本来打算在应用层「先查一下签没签过,没签再插」,被老蔡一票否决:「两个并发请求同时查到『没签过』,就双双插进去了。兜底永远放在数据库的唯一索引上,别放在先查后插上。」
改完上线,徐大爷的排行榜纪录清零重算。阿强顺手在客户端把幂等键连同原始时间戳一起存了本地——老蔡说的,「这个设计以后有惊喜」。他没多问。
惊喜要到五一那天才揭晓。在那之前,还有一个鬼故事。
四月三十号晚上,全员留守。罗总请了披萨,在会议室白板上写了四个大字「五一必胜」,转头问阿强:「能扛住吗?」
阿强早有准备,白板笔一拔。头部景区什么量级?西湖 2025 年五一单日 95.49 万人,官方通报的数[1];云栖山按三分之一算,一天 30 万。六成游客用 App——门票二维码就在里面,想不用都难;每人按在 6 个打卡点签到、查 15 次「附近有什么」算:
「景区白天开放十来个小时,108 万摊进去,平均每秒 30 次。查附近多算 2.5 倍,每秒 75 次。」他笔帽一扣,「单机都绰绰有余。」
「平均值是骗人的。」老蔡在白板角落写了两个时刻:19:59:59 和 20:00:00。「故宫的票,参观前 7 天晚上八点整放,一天 8 万张额度[2][3]。这两秒之间隔着几个数量级。景区流量全是这种脉冲:整点演出散场、闭园前最后一波、放票那一瞬。读写都是脉冲,峰值得按均值十倍留——签到每秒 300,查询每秒 750。你哪一环按每秒 300 准备过?」
他在「五一必胜」旁边画了两条线(见图 9):一条是阿强的平均值,一条是真实的一天。
阿强顺着签到链路在心里过了一遍,越过越虚:签到落库之后要更新排行榜、算徽章、跑风控,全捆在一笔事务里——要么全办成、要么全不算的那种捆法——一环慢,环环慢。
老蔡看出来了,泡上枸杞,给他讲了个行业鬼故事。
「主角叫 Foursquare,美国公司,『签到打卡』这个玩法就是它带火的——你们这套产品往上数,祖宗就是它。2010 年 10 月 4 日,Foursquare 全站宕机,11 个小时[28]。事后 MongoDB 官方复盘写得很细[8]。签到库按用户 id 切成 200 块,摊在两台 66 GB 内存的机器上,设计预期一边一半——每台机器分到的那一堆,行话叫一个分片。但用户不均匀,某一片用户恰好更活跃,热点分片一路长到 67 GB,超出内存 1 GB。就这 1 GB,查询开始落盘,磁盘比内存慢几个数量级,请求越积越多,全站拖垮。
「救火的时候往第三台机器迁走 5% 的数据,没用。为什么?内存是按『页』整页整页占的,一页 4 KB,一条签到才 300 字节[8]——就像书架按整层出租,签到是层里的小册子,抽走几本,那一层照样算你占着。删了 5% 的数据,稀疏了的页一页都没退回来。最后只能把整个架子下架重码——修库跑了约四个小时,连测试带恢复上线约五个小时。数据没丢,站停了半天[8][28]。」
「这故事……是让我今晚别睡了的意思吗?」
「是三条教训的意思。」老蔡伸出三根手指。
「一,切得平均,不等于长得平均。按用户切,切的那天看着挺匀;可签到是跟着活跃度走的,重度用户的每一条新签到,都往同一个分片上堆。挑分片键,别问『现在匀不匀』,要问『新来的往哪儿堆』。
「二,容量别盯平均还剩多少,盯最热那台还剩多少。67 比 66,差 1 GB,躺 11 个小时。
「三,删数据不等于腾内存。还是那个书架:抽走几本小册子,架子还是满的;想真腾出地方,得整层重码。」
「还有一点,」他补了一句,「这场事故不是一下子垮的,是慢慢垮的。你回头看那条链子:先是查询变慢;一慢,请求就开始排队;队伍越排越长,把上游也拖住;最后整个站一起动不了。排队看着像在缓冲,其实是把『慢』一层层往上传。 所以要给写入定个时限,超了就算失败,马上返回;同一个分片连着超时几次,就先把它摘下来,别再往它那儿送请求。失败不可怕——客户端拿着幂等键,歇一会儿再试就是了。可怕的是不死不活地耗着,把所有人都耗进去。」
他顺手把那晚的链条画了出来——上半是怎么垮的,下半是怎么剪断(见图 10)。
那天夜里,阿强照单做了三件事。
第一件,给同步路径减肥。同步路径就是用户按下签到、站在原地干等响应的那条路,这条路上只留三样:幂等检查、落库、返回。徽章计算、连续签到结算、排行榜更新、风控评分,全部扔上一条后台传送带——消息队列,慢慢消化。他本来担心「排行榜不实时会不会被骂」,翻了翻业界规则就踏实了:Swarm 的「市长」是过去 30 天签到最多的人,每天只计一次,平局归现任[29];连续签到按周结算[30]。玩法天生就是按窗口结算的——慢慢算,名正言顺。 排行榜本身是 Redis 有序集合的送分题,ZADD 还有个 GT 选项,只在新分数更大时更新,「只记最好成绩」一个参数搞定[31]。
第二件,把降级顺序写死,贴在墙上,出事不开会。真扛不住了,先停排行榜刷新;还不行,就让「查附近」的结果在缓存里多待一会儿——反正打卡点不会动,存一分钟和存十分钟差别不大;再不行,徽章晚点发。签到写入永远最后动。
哪些数据必须立刻准、哪些可以慢慢准,也顺手定了规矩。自己的签到必须立刻见:签完那格立刻变绿,用户看不到自己刚签的到,是事故。排行榜和别人的足迹,晚几分钟没关系——市长本来就是按月结算的[29]。打卡点的信息慢几分钟更没人在乎:Yelp 自家的订单搜索,从写进主库到搜索里能查到,隔着 10 到 20 秒,官方博客白纸黑字,业务照跑[41]。
第三件,挂限流。老蔡拦住了他写「每分钟计数器清零」的手:「整点清零的计数器有个漏洞——上一分钟最后一秒冲一波、这一分钟头一秒再冲一波,两边各自都不超标,合起来翻倍,全放进来了。抄 Cloudflare 的滑动窗口近似。上一分钟来了 42 个;这一分钟才过了 15 秒,来了 18 个。『最近一分钟』这扇窗,还压着上一分钟的最后 45 秒——所以上一分钟那 42 个按 45/60 打个折,再加这一分钟的 18 个:42 × 45/60 + 18 ≈ 49.5 个。每个计数器只用存『42』和『18』两个数。他们拿 27 万个源站的 4 亿请求实测过,误判 0.003%[39]。分层参考 Stripe:速率限流之外,还有并发限流和『非关键流量超八成配额即拒』的负载卸除[40]。」
那个 45/60 画在时间轴上就明白了(见图 11)。
凌晨三点,阿强把最终的架构图画在了白板上(见图 12)。绿色是读,红色是写,中间一条虚线,把「用户在等的」和「用户不用等的」隔开。
五一当天上午 10 点 07 分,索道口的整点演出散场,签到曲线拉出一根笔直的尖刺。队列深度涨了一阵,排行榜停更了四分钟,然后一切回落。签到,一条没丢,一条没重。
阿强盯着监控看了十分钟,长出一口气,转头想跟老蔡炫耀。老蔡正在接运营的电话,眉头越皱越紧。
「阿强,」他挂了电话,「排行榜出了个大神。凌晨两点,在相距 18 公里的两个打卡点,间隔 40 秒,各签了一次。」
这位「榜一大哥」的签到记录非常敬业:连续七天,每天凌晨横扫全部打卡点,从山门到云栖阁,最快的两次签到间隔 40 秒——按直线距离算,时速 1600 公里。战斗机都没他快。
「模拟定位。」阿强很快搞明白了,「开发者选项里点两下,人在沙发上,坐标在塔尖上。GPS 坐标根本不是我们观测的,是他的手机自己报的。」
他撸起袖子要写规则:服务端算上报坐标和打卡点的距离,超过 200 米,拒绝签到。
「慢着。」老蔡又一次按住了他,「这道闸门两头漏水。你先看误伤那头。GPS 在开阔天空下典型精度 4.9 米,官方文档同一页写着:建筑、桥梁、树木附近显著变差[32]——景区恰好全是山体、密林和人墙。Yelp 拿自家签到做过实测:2012 年 6 月的全量数据里,Android 设备有 7.5% 的签到,上报位置离商户超过 2 英里;iPhone 也有 4.5%[33]。三公里级的漂移,占比百分之几。你这一刀 200 米切下去,每一百个真游客里错杀好几个——人就站在打卡点前,手机说他不在。」
「那……漏水的另一头?」
「就是你的榜一大哥。模拟定位给的坐标可以精确落在打卡点圆心上,距离检查对它毫无办法。Android 官方给了个 Location.isMock() 标记[34],聊胜于无——那还是设备自己报的,拿到系统最高权限(俗称 root)的机器连这个都能抹。有人会想到让手机自己划圈:预先在打卡点周围画个圈,手机一进圈就自动报到,这招叫地理围栏。可它一样是手机自说自话,而且还不勤快:Android 8 以后,后台围栏事件是分钟级的,静止时最长能到 6 分钟[35];iOS 一个 App 只给 20 个圈的配额[36],几百个打卡点根本不够分。判定必须在服务端做,而服务端拿到的每个信号都是软的——哪一个都当不了铁证。」
「软信号,硬决定,」阿强挠头,「这不是死结吗?」
「十五年前就有人解开了,解法反直觉。」老蔡翻出一篇 2010 年的老博客——Foursquare 官方谈作弊的文章[37]。「他们管这套逻辑叫 cheater code:系统用 GPS 验证你在不在场,验证不过,签到照样收下、照样记录,只是积分、市长头衔、徽章、商家优惠一概不发。后来的规则页把细节补齐了:几分钟和 24 小时两层滑动窗口限频——跟限流那套一样,只是这回数的是签到次数——超了不发奖励;移动速度快于系统预估的行程,触发;持续远程签到,触发[38]。」
「收下但不奖励……」阿强琢磨了一会儿,「像考勤机和工资条:打卡记录照收,加班费给不给,另一条流程说了算。」
「就是这个意思。想明白为什么这样更高明,你就出师了。」老蔡竖起三根手指头。
「一,误判成本不对称。拒绝错杀的是真游客,人在现场签不上,扭头就卸载;收下但不发奖励,错待的只是作弊者的一条空记录。同样是判错,代价差着量级。
「二,奖励是派生数据,可以重算。签到记录是事实,发没发徽章是对事实的解释。风控模型下个月升级了,把历史签到重放一遍,该追回追回、该补发补发——前提是你当初把记录留下来了。拒绝掉的请求,连重算的机会都没有。
「三,信号可以慢慢攒。离打卡点偏多远、移动速度合不合常理、签得密不密、设备带没带模拟标记——单拎哪个都不能定罪,扔进队列后面的风控里合并打分就够了。它用不着在签到那一毫秒里当场宣判。」
他顺手把这套「记录照收、奖励另算」画给阿强看(见图 13)。
代价老蔡也没瞒着:结算窗口内,排行榜里可能短暂混着作弊记录,「刷了就是刷了」的洁癖满足不了。换来的是真实用户零误伤,和一份完整可回溯的事实数据。「这笔账,」他说,「我眼睛都不眨就认。」
榜一大哥的四十多条凌晨签到最终一条没删,但他的名字从排行榜上消失了——奖励结算时被风控拦在了门外。另外有一条规则是小雅加的:景区医务室不进任何玩法。这条是跟 Foursquare 学的,他们对医疗场所一律不发币[38]——签到数据会暴露行踪,敏感地点连竞赛的资格都不该有。
五一假期平安落地。复盘会上,罗总红光满面,宣布要拿这个案子去谈全省平台,然后大手一挥:「阿强,全省的方案你出。这回咱们一步到位,把白板上那些 S2、H3 全用上,做个终局架构!」
阿强这回没敢吹,先算了笔大账。文旅部的数:2025 年国庆 8 天,全国出游 8.88 亿人次[4],一天 1.1 亿。全省平台哪怕只吃到一成用户、人均还按 6 个打卡点算,一天也是 6600 万次签到。这个量什么概念?祖师爷 Foursquare 2011 年官方自报,签到每秒只有几十次[5];全盛期累计签到从 2017 年的 120 亿涨到 2018 年的 130 亿——一年涨 10 亿,摊到每天也就两三百万[6][7]。全省假期档,是它巅峰的二十几倍。他咽了口唾沫:「蔡工,这量……要不真上 S2?」
「量是不小,但 S2 治的是『点多』,不是『写多』。」老蔡摆摆手,「全省的点撑死百万级——Foursquare 做全球地点生意,收遍世界也才 1 亿个 POI[9]。写多的那半你五一已经会了:队列、分片、限流。怕就怕『一步到位』这四个字。」他慢悠悠讲了最后一个故事。
「做『查附近』做得最久的公司之一是 Yelp,他们的商户搜索换过三代。最早是自己搭的:一台机器管写入,一群机器管回答查询,新数据打包了逐台分发——查询那头要过几分钟才看得到更新,每次发版要几个小时[42]。2017 年迁到 Elasticsearch,按地理位置把索引拆开,旧金山和芝加哥各归各的,发版从几小时降到几分钟[42]。结果呢,2021 年他们又把 Elasticsearch 换掉了——嫌每台副本机器都得把同一份数据各自重新整理一遍,太费钱;自研的 Nrtsearch 改成一台整理好、其余直接抄现成的,延迟改善三到五成,部分用例成本最高降四成,注意是『最高』[43]。
「自研,换开源,再自研。连 Yelp 都没有终局架构,每一步都是被当时的具体痛点逼的。罗总,咱们第一天就抄人家最后一代,抄到手的是别人痛点的解药,和实打实要养的复杂度。」
会后,老蔡把这番话缩成一张表发到群里,让大家对号入座:
| 你的规模 | 查附近 | 签到 | 防作弊 |
|---|---|---|---|
| 单景区,几百个点 | 全放内存,来一个请求挨个算距离;进数据库也行——PostGIS 官方说法,几千行以上才值得建空间索引[22] | 一张签到表,加个唯一索引;排行榜交给 Redis | 服务端算距离、卡频率,够了 |
| 城市到全省,几万到百万个点 | 地点库交给 PostGIS[23],前面再挡一层 Redis GEO 当缓存[11] | 队列传送带削峰;按用户切分片,顺手抄一份按地点排的副本;滑动窗口限流[39] | 风控挪到后台打分;奖励发错了能追回 |
| 全国到全球,上亿个点 | S2 / H3 / BKD——动手前把 Yelp 那三段路再读一遍 | 抄 Stripe 的分层限流[40] | 该养专职风控团队了 |
表底下还有四行小字,是跟规模没关系、头一天就该做的四件事——不为别的,就因为它们不要钱,而且拖晚了都得加倍还:
散会前,罗总还想听点总结,点了阿强的名。阿强想了想,说这三个月,他就记住了三句话。
「第一句,这活儿是两道题,别搅在一起做。地点是一小堆不怎么动的数据,难在怎么查;签到是一阵一阵扎过来的流量,难在不丢、不重。当初我把它们当一道题做,才翻了那么多车。
「第二句,地理索引的名词再多,本质就一句:一维装不下二维,这笔账谁都躲不掉,区别只是付在哪。Geohash 付在查询时,多查八个邻格;S2 和 H3 付在建格时,骰子、蛇形、蜂窝,全是提前下的功夫;BKD 付在数据侧,先看数据再下刀。
「第三句,写这头,什么都别信。网络不可信,所以幂等;流量不可信,所以削峰、降级;坐标不可信,所以记录照收、奖励另算。这些招没有一个消灭了『不可信』,只是把它挪到伤害最小的地方。」
会议室安静了几秒。老蔡难得地补了第四句。后来这句话被小雅打印出来,贴在了会议室墙上:
「查附近」考你怎么给地球排序,「到此一游」考你怎么给不可信的世界记账。前者的答案在几何里,后者的答案在制度里。网格选错了,回头还能换;记录和奖励没拆开,想重算都没得算。
五月中旬,阿强替产品去云栖山做验收。在后山那个完全没信号的亭子里,他按下签到,圈圈转了两圈,App 弹了行小字:「已存好,联网后自动补交。」
下山走到索道口,手机震了一下。那条签到自己蹦了出来,时间戳还是亭子里的时间——客户端本地存好的幂等键和原始时间,网络一恢复就原样补发,服务端一行代码都没为此改过。
阿强截了张图发给老蔡:「离线队列第一单。」
老蔡回了一个字:「稳。」
翻原文、跟人讨论时得对得上暗号,这张表管翻译: