系统设计

设计一个景区签到系统

Designing a Scenic-Area Check-in System

摸鱼科技接了云栖山景区的项目,后端阿强以为是两张表三个接口的事:查附近一条 SQL,签到一条 INSERT,排行榜一条 ORDER BY。然后数据库在梦里开了口,七号公厕从地图上消失,手抖大爷屠了排行榜,榜一大哥在沙发上登顶了云栖阁。

一、「就仨功能,一天写完」 Famous Last Words

「五一上线。」

罗总把合同拍在桌上,笑得像刚在路上捡了钱包:「云栖山景区的智慧导览项目。人家要的不多,就仨功能:查附近、签到打卡、排行榜。先把云栖山跑通,年底铺全省。阿强,后端归你。」

阿强扫了一眼需求文档,心里已经把活干完了:两张表,三个接口,一天出工,剩下俩月带薪养生。

「查附近,一条 SQL;签到,一条 INSERT;排行榜,一条 ORDER BY。」他把椅子往后一仰,「罗总,这单接得值。」

饮水机旁边传来拧保温杯的声音。老蔡往杯里续了一撮枸杞,头也没抬:「景区的活,坑全在你想不到的地方。」

老蔡是公司最老的后端,四十多岁,工位常年飘着一股中药房的气息。江湖传闻他上一家是个大厂。哪个大厂?他从来不说。

阿强没接话。老人家嘛,总爱把话说得吓人。他当天下午就把「查附近」写完了,和天下所有人的第一版一模一样:

SELECT * FROM poi
WHERE lat BETWEEN 30.23 AND 30.27
  AND lng BETWEEN 120.13 AND 120.17;

经纬度各建一个索引——索引就是数据库提前排好的队,找东西不用翻全表——再加个组合索引保险。本地一跑,毫秒级。演示给产品经理小雅看,小雅很满意。

阿强看了眼饮水机的方向,心想:老蔡是真的老了。

二、数据库的深夜独白 A B-tree’s Confession

打脸来得比五一早得多。四月初,App 在云栖山开了试运营。上线第三天,甲方运营把他们攒了多年的全省文旅 POI 库也灌了进来。POI 就是「要标上地图的点」:打卡点、展馆、酒店、餐馆、加油站,足足几十万个。说是顺路做周边推荐,年底铺全省也用得上。

当晚十点,告警响了。「查附近」的响应时间从几毫秒涨到五百多毫秒。单看这个数不算要命,要命的是它还在涨:晚上正是游客回到酒店刷「周边推荐」的高峰,慢查询越积越多,数据库的连接全被它们占着,别的接口拿不到连接,跟着一起排队。十点半,超时开始成片出现——用户那头,打开地图就是转圈。

阿强从被窝里爬起来,远程连上服务器。慢查询日志里躺着的,全是他那条得意的 SQL。他让数据库把执行过程摊开来看(EXPLAIN),扫描行数十几万:索引在,但基本白扫。他先怀疑索引坏了,重建,没用;又连夜加了三种组合索引,一种比一种没用。凌晨两点,他趴在书桌上睡着了。

梦里有人跟他说话。声音又干又平,像磁盘转动。

「我是你的 B 树索引。我这辈子只会一件事:把东西排成一列,然后二分着找。你给我纬度,我把全世界按纬度排好——30.23 到 30.27,唰,一刀切出来,快得很。

「可你要的是云栖山边上一个圈。我给你的是什么?是一条横贯东西的带子。同纬度的成都、重庆、拉萨,全在这一刀里。经度那一维,我只能在带子里一行一行摸过去。

「你骂我笨。你换个姿势建组合索引,我还是这样——组合索引是先按纬度排队、纬度相同的再按经度排,跟字典先比第一个字一个道理。纬度一用范围条件,第一层就散了,经度那层排了也白排,只能一行行过滤。你以为你在查一个圈,其实你在把整条带子从头摸到尾。

「别怪我。你想想,平面上的点,谁能排成一列,还让空间上挨着的永远挨着? 二维塞进一维,总有地方要被撕开。你要么认命,要么——」

闹钟响了。阿强弹起来,后半句没听着。他把梦里那条带子画了下来(见图 1)——他要的圈,只是带子上的一小截。

纬度索引「看到」的候选集你要的查询框lat ∈ [30.23, 30.27]成都重庆拉萨整条带子都是候选,逐行过滤经度B 树只认一个排序按 lat 排:切纬度快,经度全靠过滤按 lng 排:切经度快,纬度全靠过滤两头不能同时快
图 1. B 树按纬度切出来的候选集,是一条横贯东西的带子。查询框只是带子上的一小截,其余全靠逐行过滤扔掉。
(组合索引同病:纬度用了范围条件,经度就只剩过滤)

第二天上午,他把这个梦讲给老蔡听,讲到「撕开」的时候,老蔡放下保温杯,难得地笑了。

「你这索引说得对。后半句我替它补上:要么认命,要么造一个『大部分时候挨着的还挨着』的排序,再单独处理被撕开的那一小撮。」他顿了顿,「Geohash、四叉树、S2、H3——你在面经里背过的所有地理索引,名字千奇百怪,干的都是这一件事:给地球排个序,尽量让挨着的还挨着。

「怎么排?」

「你老家的地址怎么写?」

「浙江省、金华市、兰溪市、某某街道……这跟地址有什么关系?」

「关系大了。地址就是现成的答案:前面相同的部分越长,两家离得越近。 省相同,是老乡;省市都相同,是同城;写到街道还相同,那就是邻居。查『附近的人』,不用挨个算距离——把地址前几截跟你相同的挑出来就行。」

老蔡在白板上画了个方框,当地图:「Geohash 干的就是这件事:给地球上每个位置编一个地址。把地图对半切,你在左半边还是右半边,记一笔;选中的那半再对半切,再记一笔……切一刀记一笔,切得越多,圈得越小。攒下来的这串记号,就是你的地址。」

「那经度、纬度俩数,怎么编成一串?」

老蔡没说话,抓起椅背上的外套,捏住拉链头,慢慢拉上。

「竖着切一刀,切的是经度;横着切一刀,切的是纬度。轮着来——跟拉链一样,左边一个齿,右边一个齿。」

地址取 6 位,圈出来的格子约 1.2 公里 × 0.6 公里;取到 7 位,约 153 米见方[12]。景区打卡点,7 位正合适。「查附近」从此变成「查地址前几位跟我相同的点」——排序加前缀匹配,正是 B 树的老本行。

「这不是什么民间偏方。」老蔡把外套扔回椅背,「Redis 的 GEO 你用过吧?GEOADD 压根没有新数据结构——它就是把这套『切一刀记一笔』攒出来的地址拧成一个数,塞进一个自动按大小排队的集合里(Redis 管它叫有序集合)[10]。GEOSEARCH 查半径,就是在这串排好队的地址上圈一段、从头扫到尾[11]。你以为的黑科技,就是给地球编了本地址簿。」

阿强当天下午把 POI 表切到 geohash 方案上线,「查附近」最慢的那批请求也掉回了十几毫秒,告警安静了。他给小雅发消息:搞定了。

小雅回了个「收到」。三天后,她带着测试同学美琪,用一个公共厕所把阿强钉在了耻辱柱上。

三、拉链、格线,和消失的七号公厕 Grid Lines and a Missing Toilet

美琪是去云栖山实地测试的。她站在七号公厕门口,打开 App,搜「附近的卫生间」,半径五百米。

结果列表里有三百米外的六号,有四百米外的九号,唯独没有她眼前这一个。她拍了张照发到群里:人和厕所的合影,配文「它就在我脸前面」。

阿强对着日志查了一个小时,脸越来越绿。七号公厕的坐标没错,索引没坏,查询也没写错。他把问题端给了老蔡。

老蔡只问了一句:「你的『查附近』,到底查的是什么?」

「查地址前几位跟我相同的点——就是查我所在的这个格子。」

「问题就在这。在同一个格子里,说明离得近;可离得近,不一定在同一个格子里。 格线是对半切地图切出来的,它不长眼,说不定正好从谁脚边过。」老蔡在白板上比划,「美琪站的地方,在一条格线的左边;七号公厕在她三米外,格线的右边——俩人的地址,从半路就分了家。你只查『跟美琪同格』的点:六号、九号恰好跟她同格,一个三百米一个四百米,都进了结果;七号地址对不上,第一轮就被筛掉了,连参加算距离的资格都没有。

阿强反应过来了:「这不就是行政区划吗。隔一堵墙的邻居,户口本上写着不同的区——查『我们区的人』,永远查不到墙那边的。」

「对。所以怎么修?」

「把格子调大点?相当于……把区并成市?」

「并成市,市界还在。你把家搬到市界的墙根底下,隔壁市的老王照样查不到——线调不没,只会挪地方,而总有人住在线边上。 而且区并得越大还有另一笔账:『同区的人』越多。你要找的是五百米内的邻居,结果把全市的人都端上来挨个算距离,累死。」

「那……」

「反过来想。你就算住在区界的墙根下,你方圆五百米的邻居,最远能跑到哪?也就隔壁区——只要区本身比五百米大,你的圈子再歪,最多压到相邻的区,蹦不过去。所以修法是:自己的区,加上周围挨着的一圈区,全查一遍,再挨个量真实距离。 落到格子上,就是你脚下这格加周围 8 个邻格,一共 9 个;格子按查询半径取大一号就行。七号公厕就在隔壁格里,这回捞上来了——一算,三米,排第一。」

「这不是我发明的。Redis 官方文档写得明明白白:GEOSEARCH 为了盖住查询形状,检查中心格加 8 个邻格共 9 个区域,逐格做区间扫描,最后按真实距离过滤[10]。搜索引擎 Elasticsearch 的老版本干脆为这事做了个专门的开关[13]。」

美琪的七号公厕,就卡在图 2 那条格线上。

轮着切、轮着记:经度一笔、纬度一笔,攒成一串「地址」lng: 1 1 0 1 0 …lat: 1 0 1 1 1 …1110011101地址前缀相同 ⇒ 同一个格子;地址越长 ⇒ 格子越小(7 位 ≈ 153m × 152m)只查中心格:红点 3 米外,漏掉中心格 + 8 邻格:全收,再按距离筛
图 2. 上:经度一笔、纬度一笔轮着记,攒出每个位置的「地址」。下:查询圆压在格线上,只查中心格必漏边界点——所以 Redis 查的是中心格加 8 邻格,最后按真实距离过滤。
(这套编号顺序在格与格之间会突然跳远,地址挨着的两格地上未必挨着,「最后算一次真实距离」永远省不掉)

代价明摆着:候选放大到 9 格,查完还要逐点算真实距离。

这里还藏着一笔更隐蔽的账。地址是一串数,能从小到大排。把全省的格子按地址排成一队,就相当于拿一支笔,在地图上把格子一个个连起来。「轮着切」切出来的地址,笔迹是横一步、竖一步的「之」字——这种走法行话叫 Z 序。它有个毛病:一片走完,笔要跳到下一片的开头,像看书换行,一行读到头,眼睛唰地飞回下一行最左边,那一下跳过了大半页。

亏就亏在跳的那一下:编号紧挨着的两个格子,地上可能隔着半张地图。 而查询恰恰是按编号扫一段,一扫就把这些远处的点捞进来白算一遍——所以「最后拿真实距离再筛一遍」这一步,在所有 Geohash 系方案里都省不掉。

阿强修完边界,第二个坑自己冒了出来。他看了眼全省 POI 的分布:省城商圈一个 7 位格里挤着 80 个点,山区三个格才摊上 1 个。

「格长是全球统一的,这就是 Geohash 第二个毛病。」老蔡这次直接开了白板课,「热闹地段一格全是候选,荒地查个大半径要扫一大片空格。固定网格对密度不均匀的世界,怎么选都不对。可行政区划早就把答案演给你看了:上海人挤人,切到区、切到街道;可可西里没什么人,一大片就一个地名。格子别固定,按密度切——点多的地方切细,点少的地方放粗。

他在白板上画了一个矩形,一分四,其中一块又一分四:「四叉树,1974 年的原始论文,就是把这套区划逻辑自动化:哪个格子装满了,就地一分四;装满再分,一路分下去[14]。查的时候顺着来:大块不挨你的圈,整块跳过;挨着的,进去接着看小块。

固定格子和自适应格子的差别,摆在一起一眼就懂(见图 3)。

固定网格:一个格长包打天下一格几百点大片空格四叉树 / S2:密处切细,疏处放粗查询圈 = 大格铺心 + 小格描边树的深度跟着密度走:西湖边钻到第 20 层,荒野停在第 10 层
图 3. 左:固定网格对密度不均的世界怎么选都不对——热闹处一格全是点,荒野格全空。右:四叉树按密度自适应切分;S2 进一步用「大格铺心、小格描边」的不等大格子拼出查询圈。

「四叉树是思路。真把它铺到整个地球上的,市面上有两家成品:Google 的 S2,Uber 的 H3。别背参数,我给你出两道真题——你搞明白它们各自是被什么问题逼出来的,参数自然就记住了。

「第一道题,Google 的。假设你的 App 做到了全国,一个用户在漠河搜『附近一公里的加油站』——就是咱们做过一百遍的查询。你猜怎么着,到了漠河,它不好使了。」

「为啥?」

「Geohash 的格子是顺着经纬线切的。经线这东西,赤道上离得最开,越往北收得越拢。所以同样 7 位的格子,在广州横竖差不多是块方砖;到了漠河那个纬度,宽度缩水近一半,方砖成了竖长条。Elasticsearch 的官方精度表专门标着『赤道口径』四个字[12],就是在提醒这件事。格子的大小形状各地不一样,『方圆一公里查几个格』就没有统一答案——参数在广州调好了,到漠河就不对。

「还有一笔账。就算在广州,用户查的要是『方圆三公里』呢?三公里的圆,外面套个六公里见方的框。六公里的边,除以 153 米一块的小格,一条边能排三十九块;三十九乘三十九——罩住这个圈,得查一千五百多个小格。」

这两笔账画出来是这样的(见图 4):

① 同一段经度,越往北越窄广州一带漠河一带方砖,约 153 米宽宽度缩水近一半② 三公里的圈,两种铺法小格硬数:39 × 39 ≈ 1500 块铺地砖:几十块
图 4. 真题一的两笔账。左:经线越往北收得越拢,同一段经度圈出的格子,在广州还是块方砖,到漠河缩成竖长条——格子大小形状各地不一样,参数没法全国通用。右:在广州查方圆三公里,153 米的小格要数 39 × 39 约 1500 块;换成大小砖混铺,几十块就盖住。

「S2 把这两个毛病一起治。第一个动作,治『格子各地不一样』:不顺着经纬线切了,给地球套个骰子,六个面各当一张平地图铺方砖,再鼓上球面。这么切出来的格子,全球大小都差不多——官方保证同一层里最大和最小差不出 1.5 倍[15][16]

「第二个动作,治『大圈太贵』:三公里的圈不再数一千五百个小格,改成铺地砖——中间用大砖整铺,贴边换小砖描边,几十块就盖住了[17]

「第三个动作,治『看书换行那一跳』。S2 把笔迹换成另一种走法,叫 Hilbert:像耕地,一垄走到头不抬笔,贴着拐回来接着走下一垄,几乎不跳[15]——编号挨着的格子,地上也基本挨着,扫出来的候选干净得多。」

「所以 S2 是给『全球生意 + 大圈查询』准备的方砖:骰子、地砖、蛇形路线。」阿强在本子上记,顺手画了三个小图(见图 5)。

① 套个骰子地球立方体六个面,各管一片一个面:画好格,鼓上球面② 地砖混铺大格铺心 · 小格描边,盖住查询圈③ 编号的笔迹跳远!Z 序:走之字,会突然跳远Hilbert:蛇形贴着走,不跳
图 5. S2 应付「全球生意 + 大圈查询」的三个动作。①格子不顺经纬线切:套个骰子,六个面各当一张平地图铺砖再鼓上球面,全球格子大小都差不多。②三公里大圈不再数一千五百个小格:大砖铺中间、小砖描边上,几十块盖住。③编号的笔迹:Z 序像看书换行、行末会跳远,Hilbert 像耕地、贴着拐弯几乎不跳。
(第 13 层格子约 1.27 平方公里,第 20 层约 77 平方米)

「对。第二道题,Uber 的。Uber 天天要回答的根本不是『查附近』,是『这一片跟隔壁比,忙多少』——武林广场这格有十个人叫车、只有三辆空车,隔壁湖滨那格反过来,运力往哪调?价格要不要浮动?这种『跟周围邻居比一比』的算术,它每一片、每一分钟都在做[18]

「这道题上,方砖露怯了:方砖的邻居有八个,贴边的四个近、沾角的四个远——『周围一圈』每家离你的距离不一样,比出来就不公平。所以 Uber 把格子换成六角砖,蜂窝那种:六个邻居个个贴边、一样远,以我为中心一圈圈往外扩,每一圈都公平[18]

「不过六角砖的便宜不是白占的,它吃亏在两个地方。头一个,切不干净:方砖切四块能拼回原样;大六角砖你试试——小六角砖怎么摆,边上都是这里缺一角、那里凸一块,永远拼不回去——所以在 H3 里,『小格属于哪个大格』只能算个大概[18]。第二个,铺不满球面:光用六边形包不住一个球,得缝 12 块五边形——看看足球,黑色那几块就是,正好 12 块,没有它们球就缝不起来。Uber 挑了个角度,把这 12 块补丁全藏进大洋里[18]。城市街区那个粒度用第 9 档,一格约 0.1 平方公里[19]

邻居的远近、拼不回的毛病、足球上的补丁——画出来一目了然(见图 6)。

① 邻居的远近实线近 · 虚线远,两种距离六个邻居,全一样远② 拼不回原样缺一角、凸一块,拼不回③ 足球的补丁黑色 = 五边形,正好 12 块
图 6. H3:六角砖占一个便宜,吃两个亏。①便宜:方砖的邻居分远近两种,六角砖的六个邻居全一样远。②亏一:小六角砖怎么摆都拼不回大六角砖,缺一角、凸一块,小格归大格只能算个大概。③亏二:光用六边形铺不满球面,得缝 12 块五边形——足球上黑色那几块就是。

「Elasticsearch 又是另一路:干脆不预先画格子。前面这些都是先画好区划、再往里填人;它反过来,先看人住哪,再下刀——数据从中间劈一半,每半再劈,劈到每小块只剩千把个点,一块存成磁盘上的一页,这套结构叫 BKD[20]。省多少?官方给过一个数:同一个 8 顶点的多边形,旧的四叉树方案得用 110 万个小格子去拼它的形状,新方案直接存 8 个三角形[21]——马赛克拼画和木条钉框的差别。」

先画格和先看数据这两条路,摆在一起就是图 7。

先看数据,再下刀密处多切几刀,疏处少切——每小块存成磁盘一页存一个八角形旧法:110 万块小格子拼新法:8 个三角形钉框官方实测:1,105,889 个 → 8 个
图 7. BKD 的两个点。左:不预先画格子,先看点长在哪、再下刀——从数据中间劈,密处多切几刀,一直劈到每小块只剩一小撮,一块存成磁盘一页。右:存一个八角形,旧法用小格子当马赛克去拼、要 110 万块;新法从中心拉线,8 个三角形钉个框。

老蔡把满天飞的名词收进白板角落的一张表里:「记不住细节没关系,分歧就三个——格子是画死的还是跟着密度切的;编号路线走之字还是蛇形;格子是方的还是蜂窝的。

方案切格思路邻近查询阿喀琉斯之踵谁在用(一手源)
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 伺候,边界情况人家都替你处理完了。」他敲了敲白板,「但拉链和格线的原理你必须今天就懂。不懂原理的人做不出降档的决定,只会把见过的最重的方案原样搬来。

读路径至此收工。阿强觉得,这个项目最难的部分已经过去了。

后来他复盘,「最难的部分已经过去了」这个念头,每回冒出来,都是出事的前兆。

四、手抖大爷屠榜事件 The Double-Tap Incident

试运营的第一个完整周末过完,周一早上小雅把一张截图拍在阿强桌上。

排行榜第一名:用户「湖畔徐大爷」,单日签到 47 次。同一个打卡点。

「你们排行榜是按手速排的吗?」

阿强拉日志。徐大爷没开挂,也没写脚本——他就是个真大爷,在山顶信号最差的观景台,按一次签到,圈圈转半天没反应,再按一次。App 的网络库很「贴心」,超时自动重试。一来二去,47 条记录整整齐齐躺在库里。

「用户手抖,关我后端什么事……」阿强嘟囔着要去改前端按钮防连点。

「站住。」老蔡叫住他,「防连点治标。你想清楚一件事:客户端超时,不等于服务端失败。 那条请求很可能已经写进库了,只是响应死在了回程的弱网里。这时候客户端怎么办?不重试,万一真没写进去,大爷的『到此一游』就丢了;重试,库里就是两条。丢和重之间,没有两头都占的第三条路——除非服务端能认出『这两次是同一次操作』。」

「怎么认?」

「给每次操作发个身份证。」老蔡调出一篇文章,「Stripe 把这套做成了行业范本:客户端为每次操作生成一个唯一键,随请求带上;服务端把首次执行的状态码和响应体存下来,之后凡是同一个键的请求,不再执行,直接重放存好的响应——失败的响应也照存照放[26]。键最长 255 字符,官方建议用一串几乎不可能重复的随机号(V4 UUID),至少保留 24 小时[27]。客户端重试,间隔一次比一次拉长、再掺点随机——行话叫指数退避加抖动——免得千军万马同一秒撞回来[26]。同一次操作,做一遍和做十遍,结果一个样——这就是幂等;那张身份证,就叫幂等键。」

有无幂等键,两种时序摆在一起就清楚了(见图 8)。

A:裸 INSERT客户端服务端签到INSERT ①响应丢了 ✕超时重试INSERT ②库里:两条「到此一游」B:带幂等键客户端服务端签到 key=k1执行 + 存响应响应丢了 ✕重试 key=k1认出 k1不再执行重放首次响应库里:一条,响应也一致
图 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 按需加——遇到「每次入园都能签」的点位,「一人一点一天」这条规则不存在了,唯一索引没得建,就得靠通用幂等键顶上。

阿强本来打算在应用层「先查一下签没签过,没签再插」,被老蔡一票否决:「两个并发请求同时查到『没签过』,就双双插进去了。兜底永远放在数据库的唯一索引上,别放在先查后插上。

改完上线,徐大爷的排行榜纪录清零重算。阿强顺手在客户端把幂等键连同原始时间戳一起存了本地——老蔡说的,「这个设计以后有惊喜」。他没多问。

惊喜要到五一那天才揭晓。在那之前,还有一个鬼故事。

五、上线前夜,老蔡讲了个鬼故事 A Ghost Story Before Launch

四月三十号晚上,全员留守。罗总请了披萨,在会议室白板上写了四个大字「五一必胜」,转头问阿强:「能扛住吗?」

阿强早有准备,白板笔一拔。头部景区什么量级?西湖 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):一条是阿强的平均值,一条是真实的一天。

每秒签到9:0012:0015:0018:00阿强的账:平均每秒 30 次10:07 演出散场整点又一波闭园前差着十倍
图 9. 平均值是骗人的。把一天摊平,每秒 30 次,单机绰绰有余——绿色虚线。真实的一天是红色那条:散场、整点、闭园前,尖刺一根根立起来,每根都是平均值的十倍上下。系统要按尖刺设防,不按平均。

阿强顺着签到链路在心里过了一遍,越过越虚:签到落库之后要更新排行榜、算徽章、跑风控,全捆在一笔事务里——要么全办成、要么全不算的那种捆法——一环慢,环环慢。

老蔡看出来了,泡上枸杞,给他讲了个行业鬼故事。

「主角叫 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)。

热点分片长过红线内存 66 GB分片 165 GB分片 267 GB查询落到磁盘一下变慢请求排队越排越长慢传到上游跟着堵死全站瘫痪11 小时剪断链条:宁可快点失败,不许慢慢耗着写入设时限超时就算失败,马上返回连着超时几次摘掉病分片,不再送请求客户端拿着幂等键歇一会儿再试,一条不丢
图 10. Foursquare 那晚的完整链条。上:热点分片长过内存红线,只差 1 GB,查询落盘变慢、请求排队、慢一层层往上传,全站瘫了 11 个小时。下:剪断链条的三把剪刀——写入设时限、连续超时摘掉病分片、客户端拿幂等键歇会儿重试。
(图中分片高度做了夸张,真实差距就是 67 GB 比 66 GB)

那天夜里,阿强照单做了三件事。

第一件,给同步路径减肥。同步路径就是用户按下签到、站在原地干等响应的那条路,这条路上只留三样:幂等检查、落库、返回。徽章计算、连续签到结算、排行榜更新、风控评分,全部扔上一条后台传送带——消息队列,慢慢消化。他本来担心「排行榜不实时会不会被骂」,翻了翻业界规则就踏实了: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)。

上一分钟:来了 42 个这一分钟:才过 15 秒,来了 18 个现在「最近一分钟」这扇窗:从「现在」往回罩 60 秒左边压着上一分钟的 45 秒 → 42 × 45/60 ≈ 31.5 个右边 15 秒 → 18 个31.5 + 18 ≈ 49.5 个-60s0+60s
图 11. 滑动窗口的算术。「最近一分钟」这扇窗从「现在」往回罩 60 秒:左边压着上一分钟的最后 45 秒,那 42 个按 45/60 折成约 31.5 个;右边是这一分钟已到的 18 个。加起来约 49.5,就是当下的估算——每个计数器只用存「42」和「18」两个数。

凌晨三点,阿强把最终的架构图画在了白板上(见图 12)。绿色是读,红色是写,中间一条虚线,把「用户在等的」和「用户不用等的」隔开。

App查附近 / 签到附近服务无状态,随便加副本按格子缓存的结果Redis GEO / 按格缓存POI 库PostGIS,小而静miss 回源签到服务幂等检查 → 落库 → 返回签到表(分片)唯一索引兜底幂等消息队列削峰 + 解耦风控评分奖励 / 徽章排行榜·有序集合← 同步半区异步半区 →
图 12. 全局架构:读路径(绿)从附近服务走格子索引与缓存;写路径(红)在签到服务完成幂等检查后落库、立即返回,奖励、风控、排行榜全部推到队列后面异步做。
(同步半区越瘦,假期越稳——这条分界线是整个写路径设计的主轴)

五一当天上午 10 点 07 分,索道口的整点演出散场,签到曲线拉出一根笔直的尖刺。队列深度涨了一阵,排行榜停更了四分钟,然后一切回落。签到,一条没丢,一条没重。

阿强盯着监控看了十分钟,长出一口气,转头想跟老蔡炫耀。老蔡正在接运营的电话,眉头越皱越紧。

「阿强,」他挂了电话,「排行榜出了个大神。凌晨两点,在相距 18 公里的两个打卡点,间隔 40 秒,各签了一次。」

六、沙发上的云栖阁 Summiting from the Couch

这位「榜一大哥」的签到记录非常敬业:连续七天,每天凌晨横扫全部打卡点,从山门到云栖阁,最快的两次签到间隔 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)。

签到请求坐标不可信一律收下签到表事实层:只进不拒异步风控打分距离偏差 · 移动速度频率双窗口 · 设备标记单个不定罪,合并打分发奖励积分 · 徽章 · 榜不发奖励记录仍保留模型升级后可重放历史记录,追回或补发——因为拒绝从没发生在记录层
图 13. 记录与奖励分离:签到一律收下落库,风控在异步半区合并各路软信号打分,分数只决定奖励发不发。拒绝从不发生在记录层——误判可逆,是这套设计的全部要点。

代价老蔡也没瞒着:结算窗口内,排行榜里可能短暂混着作弊记录,「刷了就是刷了」的洁癖满足不了。换来的是真实用户零误伤,和一份完整可回溯的事实数据。「这笔账,」他说,「我眼睛都不眨就认。」

榜一大哥的四十多条凌晨签到最终一条没删,但他的名字从排行榜上消失了——奖励结算时被风控拦在了门外。另外有一条规则是小雅加的:景区医务室不进任何玩法。这条是跟 Foursquare 学的,他们对医疗场所一律不发币[38]——签到数据会暴露行踪,敏感地点连竞赛的资格都不该有。

七、复盘会 The Postmortem

五一假期平安落地。复盘会上,罗总红光满面,宣布要拿这个案子去谈全省平台,然后大手一挥:「阿强,全省的方案你出。这回咱们一步到位,把白板上那些 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 弹了行小字:「已存好,联网后自动补交。」

下山走到索道口,手机震了一下。那条签到自己蹦了出来,时间戳还是亭子里的时间——客户端本地存好的幂等键和原始时间,网络一恢复就原样补发,服务端一行代码都没为此改过。

阿强截了张图发给老蔡:「离线队列第一单。」

老蔡回了一个字:「稳。」

附:把白话翻回文档用语 A Glossary

翻原文、跟人讨论时得对得上暗号,这张表管翻译:

参考文献 References

  1. 杭州师范大学官网转载潮新闻. 西湖单日客流创”五一”历史新高. 2025-05-02.(单日 95.49 万人次,西湖管委会数据)
  2. 故宫博物院官网. 参观须知.(参观前 7 日 20:00 统一放票、不售当日票、分上下午两时段)dpm.org.cn
  3. 故宫博物院官网. 故宫博物院公布试行每天限流 8 万人次实施方案. 2015.(每日限流 8 万、实名制售票:网络预售每订单至多 5 张、现场每人每次至多 5 张)dpm.org.cn
  4. 文化和旅游部官网. 2025 年国庆中秋假期文化和旅游市场情况. 2025-10.(8 天全国国内出游 8.88 亿人次,文旅部数据中心测算)mct.gov.cn
  5. Foursquare Engineering. Welcome to the foursquare Engineering Blog! 2011-01(Wayback 存档).(数百万用户、每秒数千请求、签到每秒几十次;Scala + MongoDB + AWS,官方自报)
  6. Foursquare(官方 Medium). Prepping Foursquare Swarm for the next 12B check-ins. 2017-08(Wayback 存档).(累计签到 120 亿,官方自报)
  7. Foursquare 官方博客. Introducing Swarm 6.0. 2018-10.(累计签到超 130 亿,官方自报)
  8. Eliot Horowitz (10gen). Foursquare outage post mortem. mongodb-user 邮件组, 2010-10.(200 chunk 按用户 id 分两台 66GB 机、热点分片长到约 67GB、签到文档约 300 字节、4KB 页稀疏不释放、repairDatabase 约 4 小时)
  9. Foursquare 官方博客. Foursquare Open Source Places. 2024-11.(1 亿+ POI 数据集开源,Apache 2.0)
  10. Redis 官方文档. GEOADD(How it works / Earth model). redis.io.(52-bit geohash 交织进 sorted set score;查询覆盖中心格加 8 邻格共 9 区域;纬度限 ±85.05112878°;Haversine 距离最坏误差上限 0.5%)
  11. Redis 官方文档. GEOSEARCH. redis.io.(BYRADIUS/BYBOX,复杂度 O(N+log(M)),取代已废弃的 GEORADIUS)
  12. Elasticsearch 官方文档. Geohash grid aggregation 精度表. elastic.co.(6 字符 ≈ 1.2km × 609.4m;7 字符 ≈ 152.9m × 152.4m,赤道口径)
  13. Elasticsearch 2.4 官方文档. Geohash Cell Query. elastic.co.(neighbors 选项,边界点需查邻格)
  14. R. A. Finkel, J. L. Bentley. Quad trees: a data structure for retrieval on composite keys. Acta Informatica 4, 1–9, 1974.(满则四分、区域检索递归下钻;插入平均时间对树规模呈对数)
  15. S2 Geometry 官方文档. S2 Cell Hierarchy. s2geometry.io.(立方体六面投影、level 0–30、Hilbert 曲线编号、64-bit CellId 结构)
  16. S2 Geometry 官方文档. S2 Cell Statistics. s2geometry.io.(level 13 平均约 1.27 km²、level 20 平均约 77.3 m²;同级面积极值控制在 1.5 倍因子内)
  17. S2 Geometry 官方文档. Example: Coverings. s2geometry.io.(RegionCoverer 与 max_cells 的贴合度交换;立方体面边缘最坏 4 cell 覆盖约为目标区域 10 倍)
  18. Uber Engineering Blog. H3: Uber’s Hexagonal Hierarchical Spatial Index. uber.com/blog/h3.(六边形邻居距离唯一;16 档分辨率、每细一档面积 1/7;父子包含为近似;12 个五边形落海)
  19. H3 官方文档. Tables of Cell Statistics Across Resolutions. h3geo.org.(res 9 ≈ 0.105 km²,城市街区级)
  20. Elastic 官方博客. Multi-dimensional points, coming in Apache Lucene 6.0.(BKD:主体驻磁盘块、内存小树定位;叶块默认至多 1024 点;边界块逐点精测、内含块整块收集)
  21. Elastic 官方博客. BKD-backed geo_shapes in Elasticsearch.(geo_point 自 6.0、geo_shape 自 7.0 默认 BKD;8 顶点多边形旧方案 1,105,889 词项 vs 新方案 8 个三角形,官方自报)
  22. PostGIS 官方文档. Data Management: Spatial Indexes. postgis.net.(“an R-Tree index implemented on top of GiST”;表超几千行建议建空间索引,纯属性查询例外;另有 BRIN、SP-GiST 备选)
  23. PostGIS 官方文档. ST_DWithin. postgis.net.(内置可走索引的外包框预筛;geography 类型单位为米)
  24. MongoDB 官方文档. 2dsphere Indexes 与 near 查询. mongodb.com.(球面查询;near 按距离从近到远排序返回,且必须有地理索引)
  25. mongodb/mongo 源码仓库. src/third_party/s2. github.com/mongodb/mongo.(内嵌完整 S2 库;现行官方文档未明文声明 2dsphere 基于 S2)
  26. Stripe Blog. Designing robust and predictable APIs with idempotency. stripe.com/blog/idempotency.(客户端生成唯一键;服务端存首次状态码与响应体并原样重放,含失败响应;指数退避 + 抖动)
  27. Stripe API Reference. Idempotent requests. docs.stripe.com.(键至多 255 字符、建议 V4 UUID、至少保留 24 小时后清除)
  28. Foursquare 官方博客. So, that was a bummer. 2010-10-05(Wayback 存档).(全站宕机约 11 小时,未丢数据,官方自报)
  29. Foursquare 官方帮助中心. Fun with Swarm: Coins, Mayorship….(市长 = 过去 30 天该地点签到最多者;每天只计一次;平局归现任)
  30. Foursquare 官方帮助中心. Your Foursquare Swarm Profile.(streak 按类目、每周至少一签续算)
  31. Redis 官方文档. ZADD / ZRANGE. redis.io.(ZADD O(log N);GT 选项仅在新分数更大时更新)
  32. GPS.gov. GPS Accuracy.(开阔天空下智能手机典型精度约 4.9 米半径;建筑、桥梁、树木附近变差)
  33. Yelp Engineering. GPS vs WiFi: The Battle for Location Accuracy Using Yelp Check-Ins. 2012-08.(2012-06 全量签到分析:Android 7.5%、iPhone 4.5% 的签到距商户超 2 英里)
  34. Android Developers. Location API 参考(isMock).(API 31 起 Location.isMock(),系统标记模拟定位)
  35. Android Developers. Create and monitor geofences.(最小半径建议 100–150 米;Wi-Fi 下精度通常 20–50 米;8.0+ 后台围栏事件分钟级、静止时最长约 6 分钟;每应用每设备用户上限 100 围栏)
  36. Apple Developer. Monitoring the user’s proximity to geographic regions.(单 App 同时监控条件(含 region)不超过 20 个)
  37. Foursquare 官方博客. On foursquare, cheating, and claiming mayorships from your couch… 2010-04(Wayback 存档).(cheater code:GPS 验证在场,不在场则不发积分、市长、徽章与商家优惠,签到本身照收)
  38. Foursquare 官方帮助中心. Checkin Issues.(Rapid Fire:过去几分钟 + 过去 24 小时双滑动窗口限频;移动速度快于预估行程触发;医疗场所不发币)
  39. Cloudflare Blog. How we built rate limiting capable of scaling to millions of domains.(滑动窗口近似:上窗计数 × 剩余时间占比 + 当前计数;27 万源站 4 亿请求实测误判 0.003%,官方自报;固定窗口放过边界突刺)
  40. Stripe Blog. Scaling your API with rate limiters.(请求速率限流、并发请求限流、非关键流量超 80% 配额即拒的负载卸除等四层,官方自报)
  41. Yelp Engineering. Fast Order Search Using Yelp’s Data Pipeline and Elasticsearch. 2018-06.(主库提交到 ES 可见滞后 10–20 秒,官方自述)
  42. Yelp Engineering. Moving Yelp’s Core Business Search to Elasticsearch. 2017-06.(Lucene 自研 master/slave、索引快照走 S3、查询侧几分钟可见、发布数小时;geosharding + microsharding;迁移后发布降到几分钟,官方自报)
  43. Yelp Engineering. Nrtsearch: Yelp’s Fast, Scalable and Cost Effective Search Engine. 2021-09.(ES 文档级复制成本高;NRT 段复制;P50/P95/P99 延迟改善 30–50%、部分用例成本最高降 40%,官方自报;已开源)
#系统设计#地理索引#Geohash#Foursquare#Yelp#幂等