郭立 (leeguoo)

# 5 美元一个月,扛住 86 万 RPS 还没到顶——一个热点 KV 的架构思路与诚实复盘

一套跑在 Cloudflare Workers(月费 5 美元起)上的秒级热点 KV:多区域压测读路径顶到 86 万+ RPS、worker 零错误,发压端先到了瓶颈。本文讲它的设计思路、两次真实事故的完整排查(第一次以为修好了,第二次才挖到真根因:读和写挤在同一个单线程对象的同一条队列里),以及最后的解法:读写分离,外加把"强一致"这个词重新说清楚。

2026年6月18日 · 文章 · 公开

先说结论。这套系统跑在月费 5 美元起的 Cloudflare Workers 计划上,从 5 个 AWS 区域同时发压,读路径持续顶住约 85 万 RPS、峰值 88 万,worker 错误为零。而且没压垮,是发压端先到了瓶颈。

听着离谱,但它不靠堆机器。这篇讲清楚它靠什么,也讲清楚它栽过的两次跟头:第一次我们以为修好了,第二次才挖到真根因。

为什么要自己造一个 KV

业务是逐球开奖。每开出一个球写一次结果,这条数据的有效期只有 1 秒,同时有海量用户每秒轮询这个结果。Cloudflare 自带的 Workers KV 是最终一致的,写入传播要按秒甚至几十秒算,对"1 秒就过期"的数据没法用。所以我们在 Workers + Durable Objects 上自己搭了一个。

把瓶颈当成一致性的来源

Durable Object 最招人嫌的一点是单线程:每个对象同一时刻只处理一个请求,其余排队。直觉反应是绕开它。

我们反着来。单线程意味着同一个 key 的所有读写天然串行,没有竞态,这正是开奖数据要的一致性。于是设计变成:认下这个单线程对象当唯一真相源,然后在它前面叠一套防御,把真正打到它的流量压到它扛得住的量。

读洪流被四层防御逐层吸收,只剩一滴落到打着小伞的 DO 上
读的雨从天上浇下来,四层雨棚接掉绝大部分,落到那个紧张的小 DO 头上的只剩一滴。写从侧门溜进去。

四层防御各吃掉一段流量:

  • 边缘缓存(caches.default:每个 Cloudflare 机房一份共享缓存,多数读在离用户最近的节点就返回了。
  • single-flight:同一个 isolate 里,对同一个 key 的并发回源用一个 Map<key, Promise> 合并成一次。
  • Stale-While-Revalidate:缓存过期先把旧值给用户,后台慢慢刷新,用户不等。
  • null 占位 + 降级空响应:不存在的 key 写短命占位符防打穿;真扛不住时读路径也不吐错误码,一律 200 空响应加降级标头。前端把任何非 200 都弹给用户看,这条是被用户反馈教出来的。

另有两个不起眼但重要的决定。一是双 TTL:逻辑新鲜度(开奖键 1 秒)管"这个值算不算旧",存储 TTL(900 秒)管"缓存条目本身留多久",两者分开,旧值才能一直兜底。二是冷热分离:热点 key 按哈希走专属分片,和普通 key 隔开,热点的风暴不会溅到别人。

海量用户读 压测峰值 ~88 万 RPS 边缘缓存 caches.default 每个 PoP 一份 · 逻辑 2s / 存储 900s single-flight(isolate 内同 key 并发回源合并成一次) Stale-While-Revalidate(先回旧值 · 后台刷新节流 ≤1次/s) null 占位 + 降级空响应(防穿透 · 防雪崩) 85 万 RPS 读,DO 只多 1~2 万次/分 单线程 Durable Object 唯一真相源 · 串行即一致 逐球写 直达,不经缓存
读路径分层:四层防御自上而下逐级吸收,写单独走右侧直达 DO。

第一次事故:写被读踩死

某晚开奖,写路径集体翻车:SET 成功率掉到 4%,最慢一次写挂了 17.3 秒才失败。复盘发现当时缓存回源没有合并,开奖瞬间几万个读请求各自去敲同一个单线程 DO,把队列塞满,逐球写排在队尾轮不到。

修复前 DO 被读群殴、写被弹开;修复后读被漏斗合并成一条,写顺利进门
左边:DO 被读围殴到冒烟,写在门外被弹开。右边:single-flight 把读合并成一条,DO 缓过来了。

修复就是补上 single-flight,配合把开奖键的逻辑 TTL 收到 1 秒。同负载复测:

指标(同负载、同 key、各 5 分钟)修复前修复后
SET 成功率4%100%
写后 5 秒内读到新值6%99.7%
最慢一次写约 17 秒亚秒

上线后第一场真实开奖,流量比事故当晚还大 58%,DO 错误从 7 归零。当时我们认为这事结了。

5 美元的底座,86 万 RPS 的压测

系统跑在 Workers 付费计划上,5 美元起步,按用量计费,没有一台常驻机器。便宜的原因不是省,是读的 RPS 和成本被解耦了:读在边缘缓存就被吃掉,真正穿透到按调用计费的 DO 的量小到可以忽略。压测时读吞吐拉到 85 万每秒,DO 那边每分钟只多了一两万次调用。

所以 86 万不是这套架构的上限,只是我们那晚发压能力的上限。发多少,边缘就服务多少,曲线一直在涨。

第二次事故:以为修好了

single-flight 上线后的一周里,每场开奖还是会冒出一两张写失败卡,耗时精确地卡在 16050 毫秒。这个数字太整齐了:我们给 DO 调用加过 8 秒超时加一次重试,8000 + 50 + 8000,两次都超时。也就是说那个 DO 整整 16 秒不理人。

先后试了三招,都没除根:

  • 给读写都加超时和重试。失败从每场 5 张降到 2 张,30 秒的卡死变成 16 秒封顶,但重试打的还是同一个堵死的 DO。
  • 定时预热。我们发现热点 DO 空闲 2 秒左右就会被平台驱逐,于是加了 cron,开奖窗口内每 2.5 秒 ping 一遍全部热点 DO。预热确实生效了(能在监控里看到基线抬升),失败照旧。
  • 把测试环境的 DO 打到一分钟重启 10 次,想复现掉连接。600 并发把延迟压到 23 秒,请求还是全成功,单机复现不出来。

把两个失败现场的分钟级数据摆在一起,才看清是两种触发条件、同一个病根:

现场当时负载DO 错误说明
晚场开奖worker 42 万/分,DO 调用 12.7 万/分失败那两分钟分别 2725、4012读洪流灌满单 DO 队列,写排队尾超时
日间开奖全站约 60 RPS,接近空载那一分钟 183平台迁移/重启了这个 DO,恰好撞上写

空载也炸,说明第二种触发跟流量无关,纯粹是平台把 DO 挪了个窝,在途请求被掐断。而高负载那种,洪流的主力也不是缓存 miss:缓存条目存 900 秒,几乎不会真 miss。是 SWR 的后台刷新。值每 1 秒变陈旧一次,每个机房的每个 isolate 每秒都各自发一次后台回源,single-flight 只能合并单个 isolate 内部的并发,挡不住几百个 isolate 每秒一轮的齐射。

病根就一条:一个热点 key 的所有读和写,排在同一个单线程 DO 的同一条队列里。读多到一定程度,写就得死。

左边读和写排同一条队、写在队尾看表;右边读走缓存柜台、写独享一扇门
左边:写排在一长串读后面看表,16 秒。右边:读去柜台,写有自己的门。

解法:读写分门,把"强一致"重新说清楚

我们把方案拿去让 GPT 当外部评审批了一遍。它否掉了两个候选:放宽 TTL 只算止血,读还是会进写 DO 的队列;做 K 个同步副本更糟,写要等 K 个 ACK,尾延迟和失败率都会涨,最后等于自己重新发明 quorum。它给的主线跟我们的判断对上了,原话是:不要给 SET 加更多超时和重试,要让 SET 永远不用和用户读排同一条队。

落地分三步:

  • TTL 从 1 秒放到 2 秒。刷新风暴的频率等于 1/TTL,一行改动直接减半。
  • 用户读几乎不再同步打 DO。缓存有值(哪怕陈旧)直接返回;后台刷新加节流。真正的全空 miss(冷机房首读,罕见)做一场 1.2 秒的限时赛跑:DO 健康就直接给真数据,停摆就退化成空响应、后台继续回填。这里我们踩过一个坑,最初返回 503 让前端弹了错误框,用户教育了我们:降级要降到调用方已经会处理的语义里,而不是发明一个新错误码。DO 从此只见"限频的后台刷新加写",写独占队列。
  • 写重试间隔从 50 毫秒改成 1.5 秒。50 毫秒后重试基本还是撞上迁移中的同一个 DO,1.5 秒够它落好位。
用户读 边缘缓存 caches.default 逻辑 2s / 存储 900s HIT / STALE → 直接返回(≤2s 旧) 真 miss → 1.2s 限时赛跑,超时回空值 后台刷新(机房级锁 ≤1次/s/键 + single-flight 合并) 单线程 DO 写独占队列 只见「限频刷新 + 写」 逐球写 8s 超时 · 1.5s 退避重试一次 · 幂等
目标数据流:用户读止步于边缘缓存;DO 的入口只剩限频后台刷新和写,两者不再抢队。

代价要摊开说。用户读的新鲜度从"名义上的 1 秒"变成"有上界的 1 到 2 秒"。之前那套 caches.default 加 SWR 本来也做不到严格的 read-after-write,只是没人把话说破。现在把承诺改写成两句实话:写侧强一致,每一笔写要么成功要么明确失败;读侧 1 到 2 秒有界陈旧。业务确认能接受,这个架构才算闭环。

上线第一晚:数据砍半,根还剩一口气

读写分离上线当晚的开奖,和前一晚同窗对比:

指标(开奖窗 40 分钟)改造前一晚上线当晚
DO 错误合计6,738325(-95%)
DO 调用峰值12.7 万/分6.1 万/分(-52%)
写失败2 笔,16.0s1 笔,17.5s
慢操作4 笔1 笔

方向全对,幅度砍半,但我们给自己定的验收线是"写失败归零、DO 调用塌一个数量级",都没到。查下来剩两个口子。

第一个:节流的粒度错了。"每个 isolate 每秒最多刷一次"看着挺紧,但开奖时 6000 RPS 会让 Cloudflare 起成百上千个 isolate,其中大量是刚出生的,节流表全空,第一拍就放行一次刷新。实际打到 DO 的刷新量等于 isolate 数乘键数,isolate 一多,节流形同虚设。修法是把锁从 isolate 级升到机房级:用 caches.default(它本来就是每个机房一份)放一把 1 秒的锁,同一机房同一个 key 每秒只有抢到锁的那个 isolate 真正回源。刷新量从"isolate 数 × 键数"压到"机房数 × 键数"。

第二个:三条老接口绕过了整个防御。这套系统演进过几版,留下了几条"直连 DO、不走缓存"的旁路读接口。主路修得再好,只要有消费方经旁路轮询热点键,读还是会挤进写 DO 的队列。修法是让旁路对热点键也缓存优先:有值(哪怕 2 秒内的旧值)直接返回,真没有才放行原来的同步回源。响应结构一字不改,不会弄坏任何现有调用方。

顺手把写重试从两次加到三次,退避 1.5 秒、3 秒递增,给正在迁移的 DO 更长的落位窗口,最坏 28.5 秒仍在平台 30 秒墙之内。

真凶落网:卡住写的不是流量,是硬盘

二期上线后,读侧彻底安静了:开奖最高峰,全系统打到 DO 的只剩每分钟三千次,是改造前的四十分之一。但每晚还是有一两笔写卡满重试预算后失败。谁干的?我们给写失败装了个"行车记录仪":失败的瞬间,自动向同一个 DO 发一次 ping,把它的状态拍下来附在告警卡上。

第二天就拍到了。两笔 30 秒的失败,ping 全部超时,错误信息写着:storage operation exceeded timeout

这行字的分量要解释一下。ping 不碰硬盘,只是问一句"你活着吗",连这一句都没人应。原因在 Durable Object 的一条内部规矩:只要有一个硬盘操作还没做完,它就闭门谢客,任何新请求都不接(官方叫 input gate,为的是保证数据一致)。所以真相是:DO 活得好好的,它是被一次卡住的硬盘操作堵在了门里。开奖高峰那一刻,DO 所在机房的存储层会卡上 20 到 60 秒。我们的流量早就洗清嫌疑了,这一次连 DO 本身也洗清了,剩下的只有它脚下的硬盘。

然后是这次排查里最值钱的一个顿悟。这个系统的一致性,从头到尾靠的是"只有一个店员":所有写排队经过同一个单线程,顺序天然不会乱。刻不刻上石板,从来不影响账本的权威性。可我们的写确认,却一直被绑在"硬盘那一秒健不健康"上。这才是要拆的绑定。

改造前店员必须等石匠刻完石板才应答,队伍堵死;改造后店员先记进手边账本立刻应答,石匠慢慢刻
改造前:石匠(硬盘)一卡壳,店员干等,全店冻结。改造后:店员先记进手边的账本就喊"好了",石匠慢慢刻,刻好为止。

改造用小白话说就三句:

  • 店员手边放个账本。写请求到了,先记进 DO 自己的内存,立刻回"成功"。账本就是权威,因为店员只有一个,记账顺序不可能乱。
  • 石匠慢慢刻。落盘转到后台(Cloudflare 原生支持,allowUnconfirmed),硬盘卡 60 秒也不影响前面应答,等它恢复了自然刻上。
  • 立两条保险的规矩。晚到的旧账不许盖新账(每次写带时间戳守卫,防止延迟的补写把新一球盖回旧一球);万一 DO 整个失联,外层还有一个带守卫的后台补写,失败后 4 秒、12 秒、24 秒各补投一次。
改造前:确认要等刻碑 写请求 DO(单线程店员) 等硬盘期间闭门谢客 等刻完才能应答 ⏳ 硬盘 高峰会卡 20~60s → 写陪着卡 30s,失败 硬盘一卡,DO 连"你活着吗"都不应(input gate),后续请求全堵门外。 改造后:账本即权威 写请求 DO 内存账本 单线程串行 = 权威 亚秒回"成功"(不等硬盘) 后台慢慢刻,刻好为止 硬盘 卡 60s 也不碍事 🛡️ 两条保险:旧账不盖新账(时间戳守卫);DO 失联时外层 4s/12s/24s 带守卫补写。 代价:确认 = "已进账本、马上刻"。唯一丢失窗口是刻完前 DO 整个崩溃,而这数据 1~2 秒后就被下一球覆盖。
写确认与落盘解耦:一致性来自单线程串行,从来不来自"刻上石板"。

只有开奖热点键走这条路。history 这类要留很久的数据照旧刻完石板再应答,一个字都没改。机房存储高峰卡死本身是平台的病,工单照提,但我们的写不再陪它一起病了。

诚实复盘:修好的和还没修的

一台贴满胶带的破机器,WEBSOCKET 和 8x DO 被划掉,中间的 IDEA 灯泡亮着,配字 UGLY BUT RIGHT
零件换了好几轮,中间那个想法没换过。

修好的:读路径 single-flight;读写超时加重试;4xx 和 5xx 的告警分级(之前客户端拼错一个 key 也炸红色告警);热点 DO 定时预热。

还没收敛的:更早一版用 8 个中心 DO 加 WebSocket 长连接撑两千并发,复杂度高,现在还剩尾巴没删完;用 key 里含不含 opencurrent 来识别热点,糙但一直没出错;机房存储高峰卡死的工单还欠平台一张。

回头看,这套系统对的地方不在任何一个组件,在几个判断:单线程对象慢、会被平台随手迁走,但它串行,串行就是一致性,顺着它设计而不是对抗它;读涨几十倍时,让成本基本不动的办法是把读挡在边缘;监控不用开重型日志,业务事件推成持久卡片,加上平台自带的分钟级指标,两次事故都是靠这两样查清的。

第一次事故我们修完就宣布了胜利,第二次才发现根还在。一期把数据砍半,二期把读侧压到常态的四十分之一,可每晚还是剩一两笔 30 秒的写失败,直到行车记录仪拍到硬盘才收网。

然后是内存账本上线后的第一场开奖:当晚流量是这六个晚上里第二高的,写失败 0,慢操作 0,DO 错误从三位数掉到 1,最慢的一笔写 1.68 秒,还是一个走同步落盘的 history 键。开奖热点键全部亚秒。硬盘那晚卡没卡?不知道,也不需要知道了,这正是解耦的意义。

零攒够了。第二晚,流量还是接近峰值的 47 万每分钟,写失败 0,慢操作 0,这回连 DO 错误和 worker 错误也都是 0,所有指标第一次一个不剩地落零。

从写崩盘那晚算起,二十几天,动了三次刀:先把读合并,再把读写分门,最后把写确认从硬盘手里解出来。每一刀下去之前,我们都以为上一刀已经够了;每一刀的理由,都是当晚的生产数据给的。现在,连续两晚干净。这一段,写完了。

← 上一篇
让 Claude Code 自己把图画了:chatgpt-imagegen 的初心与原理
下一篇 →
江ノ岛 通宵攻略:凌晨境川河口 → 天亮大堤防(6/19 周五)

评论

评论发布后会立即公开,如触发规则可能被审核下架。

最多 1000 字。