先说结论。这套系统跑在月费 5 美元起的 Cloudflare Workers 计划上,从 5 个 AWS 区域同时发压,读路径持续顶住约 85 万 RPS、峰值 88 万,worker 错误为零。而且没压垮,是发压端先到了瓶颈。
听着离谱,但它不靠堆机器。这篇讲清楚它靠什么,也讲清楚它栽过的两次跟头:第一次我们以为修好了,第二次才挖到真根因。
为什么要自己造一个 KV
业务是逐球开奖。每开出一个球写一次结果,这条数据的有效期只有 1 秒,同时有海量用户每秒轮询这个结果。Cloudflare 自带的 Workers KV 是最终一致的,写入传播要按秒甚至几十秒算,对"1 秒就过期"的数据没法用。所以我们在 Workers + Durable Objects 上自己搭了一个。
把瓶颈当成一致性的来源
Durable Object 最招人嫌的一点是单线程:每个对象同一时刻只处理一个请求,其余排队。直觉反应是绕开它。
我们反着来。单线程意味着同一个 key 的所有读写天然串行,没有竞态,这正是开奖数据要的一致性。于是设计变成:认下这个单线程对象当唯一真相源,然后在它前面叠一套防御,把真正打到它的流量压到它扛得住的量。
四层防御各吃掉一段流量:
- 边缘缓存(
caches.default):每个 Cloudflare 机房一份共享缓存,多数读在离用户最近的节点就返回了。 - single-flight:同一个 isolate 里,对同一个 key 的并发回源用一个
Map<key, Promise>合并成一次。 - Stale-While-Revalidate:缓存过期先把旧值给用户,后台慢慢刷新,用户不等。
- null 占位 + 降级空响应:不存在的 key 写短命占位符防打穿;真扛不住时读路径也不吐错误码,一律 200 空响应加降级标头。前端把任何非 200 都弹给用户看,这条是被用户反馈教出来的。
另有两个不起眼但重要的决定。一是双 TTL:逻辑新鲜度(开奖键 1 秒)管"这个值算不算旧",存储 TTL(900 秒)管"缓存条目本身留多久",两者分开,旧值才能一直兜底。二是冷热分离:热点 key 按哈希走专属分片,和普通 key 隔开,热点的风暴不会溅到别人。
第一次事故:写被读踩死
某晚开奖,写路径集体翻车:SET 成功率掉到 4%,最慢一次写挂了 17.3 秒才失败。复盘发现当时缓存回源没有合并,开奖瞬间几万个读请求各自去敲同一个单线程 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 的同一条队列里。读多到一定程度,写就得死。
解法:读写分门,把"强一致"重新说清楚
我们把方案拿去让 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 秒够它落好位。
代价要摊开说。用户读的新鲜度从"名义上的 1 秒"变成"有上界的 1 到 2 秒"。之前那套 caches.default 加 SWR 本来也做不到严格的 read-after-write,只是没人把话说破。现在把承诺改写成两句实话:写侧强一致,每一笔写要么成功要么明确失败;读侧 1 到 2 秒有界陈旧。业务确认能接受,这个架构才算闭环。
上线第一晚:数据砍半,根还剩一口气
读写分离上线当晚的开奖,和前一晚同窗对比:
| 指标(开奖窗 40 分钟) | 改造前一晚 | 上线当晚 |
|---|---|---|
| DO 错误合计 | 6,738 | 325(-95%) |
| DO 调用峰值 | 12.7 万/分 | 6.1 万/分(-52%) |
| 写失败 | 2 笔,16.0s | 1 笔,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 秒各补投一次。
只有开奖热点键走这条路。history 这类要留很久的数据照旧刻完石板再应答,一个字都没改。机房存储高峰卡死本身是平台的病,工单照提,但我们的写不再陪它一起病了。
诚实复盘:修好的和还没修的
修好的:读路径 single-flight;读写超时加重试;4xx 和 5xx 的告警分级(之前客户端拼错一个 key 也炸红色告警);热点 DO 定时预热。
还没收敛的:更早一版用 8 个中心 DO 加 WebSocket 长连接撑两千并发,复杂度高,现在还剩尾巴没删完;用 key 里含不含 open、current 来识别热点,糙但一直没出错;机房存储高峰卡死的工单还欠平台一张。
回头看,这套系统对的地方不在任何一个组件,在几个判断:单线程对象慢、会被平台随手迁走,但它串行,串行就是一致性,顺着它设计而不是对抗它;读涨几十倍时,让成本基本不动的办法是把读挡在边缘;监控不用开重型日志,业务事件推成持久卡片,加上平台自带的分钟级指标,两次事故都是靠这两样查清的。
第一次事故我们修完就宣布了胜利,第二次才发现根还在。一期把数据砍半,二期把读侧压到常态的四十分之一,可每晚还是剩一两笔 30 秒的写失败,直到行车记录仪拍到硬盘才收网。
然后是内存账本上线后的第一场开奖:当晚流量是这六个晚上里第二高的,写失败 0,慢操作 0,DO 错误从三位数掉到 1,最慢的一笔写 1.68 秒,还是一个走同步落盘的 history 键。开奖热点键全部亚秒。硬盘那晚卡没卡?不知道,也不需要知道了,这正是解耦的意义。
零攒够了。第二晚,流量还是接近峰值的 47 万每分钟,写失败 0,慢操作 0,这回连 DO 错误和 worker 错误也都是 0,所有指标第一次一个不剩地落零。
从写崩盘那晚算起,二十几天,动了三次刀:先把读合并,再把读写分门,最后把写确认从硬盘手里解出来。每一刀下去之前,我们都以为上一刀已经够了;每一刀的理由,都是当晚的生产数据给的。现在,连续两晚干净。这一段,写完了。

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