singleflight、MQ 与事件流
Category
database / cache 子树。节点定位:三类「让请求绕开直接路径」的机制——singleflight 合并回源、MQ 削峰、事件流扇出——的共同账本:用语义换吞吐。
Definition
这一篇讨论三类“让请求绕开直接路径”的机制:singleflight 把并发的重复回源合并成一次,消息队列把“当下就做完”改成“排队慢慢做”,事件流则把一次业务动作扩散成多份下游消费。它们的共同点是都在为系统争取时间或换取解耦,共同的风险是语义被模糊化——谁在什么顺序、以什么次数消费了什么,往往说不清。
Problem
合并回源、排队延后、扇出消费,都在为系统争取时间或换取解耦,共同风险是语义被模糊化:谁在什么顺序、以什么次数消费了什么,往往说不清。热点 key 击穿把一次失效放大成一次事故;队列把「做完」从毫秒变成队列深度除以消费速度,积压还不可见;至少一次投递意味着重复是必然——幂等不是可选项,是消费端的基础设施。
热点 key 击穿为什么是放大器问题
缓存失效本身不可怕,可怕的是失效发生在热点上。一个每秒被几万次访问的 key 过期的瞬间,所有请求同时发现没有缓存,同时去打源头。源头平时只需要承接百分之一的流量,那一刻却要承接全部,过载又拖慢回源,回源变慢又让更多请求堆积。击穿的本质不是“查询变慢”,而是一次失效被并发放大成一次事故。
常见的防护思路:
- 只允许一个请求回源,其余请求等它把缓存填回来。
- 热点 key 逻辑上不过期,由后台异步刷新。
- 发布或开服前预热,不给击穿留出窗口。
- 对不存在的数据也缓存一个短命的空值,避免反复穿透到源头。
思路之间并不冲突,通常按热点程度叠加使用。
singleflight 的思想:合并回源而不是拒绝请求
singleflight 的核心动作很朴素:同一个 key 的并发回源只放一个出去,其他人共享它的结果。它不丢请求、不报错,只是把“一万次相同的查询”折叠成“一次查询加一万次结果分发”。
这个思想可以离开具体库单独成立,凡是存在重复工作的地方都适用:
- 同一个玩家的档案在几百个请求里同时回源。
- 同一份配置被几十个进程同时拉取。
- 同一条聚合结果被多个定时器同时重算。
它的边界也同样值得记住:合并只对完全相同的查询有效,参数稍有不同就各回各的源;共享结果的等待方把延迟绑在第一个请求身上,第一个请求失败,等待方一起失败,所以等待方通常要有超时和兜底读。把这条思想铺到缓存之外的高重复路径上,收益往往比只护住一个热点 key 更大。
削峰的本质:把瞬时延迟换成长期稳定
消息队列最常被引用的价值是削峰。流量洪峰来时,请求不再直接压到处理方,而是先落进队列,处理方按自己的节奏消费。峰被削平了,但代价同样真实:操作完成的时间从毫秒变成“队列深度除以消费速度”。
所以削峰是否划算,取决于业务对“做完”这件事的时间容忍度:
- 埋点、日志、成就统计,晚几秒甚至几分钟通常无感,适合入队。
- 发奖、订单、货币变动,玩家盯着屏幕等结果,往往不适合把确认路径放进队列。
- 介于两者之间的操作,常见做法是同步完成关键一步,其余入队异步补齐。
队列还有一笔隐性成本是积压不可见。平时消费快于生产,所有人都以为队列是瞬时的;某天生产反超,积压悄悄涨到几百万条,被发现时往往已经错过了从容处理的时间段。
解耦的语义:发布方不再知道也不关心谁在消费
解耦的价值在于发布方和消费方可以独立演进:发布方只声明“发生了什么”,不关心谁在意;消费方增删也不牵动发布方的发布节奏。这让跨团队协作的接口面积大大缩小,也是事件驱动架构真正的组织学收益。
但解耦同时把责任切开了。发布方不再能保证“下游做完了”,几个问题随之浮出:
- 消费失败谁负责?重试几次、失败后进死信还是直接丢弃。
- 消息会不会重复投递?几乎一定会,至少在重试和消费者组 rebalance 的时候。
- 消费方能不能跟上?跟不上时积压告警发给谁。
成熟团队通常的约定是:发布方保证消息可靠投出,消费方保证幂等消费,中间用监控把积压、失败和延迟都变成显式指标。哪一环没有约定,哪一环就会在某个凌晨变成事故。
事件流的顺序为什么难保证
事件流把一次动作展开成多个消费者各自处理,顺序问题随之而来。发布端有并发,队列有分区,消费端有重试,任何一环都可能让“先扣款后发货”变成“先发货后扣款”。
常见的顺序陷阱:
- 按玩家做分区键能保证单玩家有序,跨玩家的顺序则不再有保证。
- 消费失败重试时,后面的消息已经处理完,重试的那条变成迟到的旧事。
- 同一实体的状态被两个消费者各自维护,各自看到的顺序还不一致。
常见做法不是追求全局有序——全局有序往往意味着吞吐上限——而是把顺序需求收窄到“同一实体”范围内,用分区键或实体内版本号来满足。剩下真正需要跨实体排序的场景,通常说明这两个动作应该合并成一次处理,而不是指望队列替它们排队。
幂等是消费端的责任
消息系统一般承诺“至少一次投递”,这句话翻译过来就是“可能会重复”。所以幂等不是可选的锦上添花,而是消费端的基础设施:同一条消息处理两次,结果要和处理一次相同。
实现幂等的常见抓手:
- 用业务单号或事件 ID 做去重键,处理前先查“是否做过”。
- 状态更新带前置条件,已经推进过的状态直接跳过。
- 资金和资产类操作落库时带唯一约束,让数据库做最后兜底。
容易出问题的地方往往不在主流程而在补偿流程:手工补消息、重放历史分区、消费者扩容引发 rebalance,都可能把几天前的旧消息重新送进消费逻辑。幂等设计要按“这条消息可能在任何时候再来一次”来假设,而不是按“正常情况只来一次”。
常见误区
- 把削峰当成免费收益,玩家可感知的关键路径也被塞进队列排队。
- 假设队列既不重复也不乱序,把去重和排序的责任留给了运气。
- singleflight 只用在缓存回源,配置拉取、定时聚合这些同样高重复的路径各干各的。
- 消费失败无限重试,一条毒消息卡住整个分区的消费进度。
这一组机制都在用语义换吞吐,换之前值得想清楚的是:哪些语义是业务真正依赖的,哪些只是实现顺手带出来的假设。
Used By
热点 key 防护方案的叠加(singleflight + 逻辑过期 + 预热 + 空值缓存);链路的同步/异步分级(哪些能入队、哪些必须同步确认);消费端幂等与顺序收窄(分区键、实体内版本号)设计。
Related
关系链:热点击穿 → 合并回源(singleflight)→ 削峰入队 → 解耦扇出 → 顺序收窄 → 幂等兜底。上游:cache/01(缓存边界)。下游:networking/ipc 子树(主外围判别与幂等顺序的展开,节点 ipc/02、ipc/05)。
Reference
singleflight 的公开参考实现见 Go 扩展库 golang.org/x/sync/singleflight;本页其余为工程经验归纳。