Skip to content

三个可复用的服务端组件:限流、幂等键与渐进发布 ​

限流、幂等、灰度都能写成一个注解加一个切面,看起来半天就能完成。难的部分不在类结构,而在失败语义:并发时会不会超发、执行者超时后谁来接手、配置改错了怎么退回。模式是在这些问题都有答案之后,才出现的实现手段。

三个组件各做了一组实验,每组都有一个「看起来能用、实际上不对」的写法:

组件看起来能用的写法实测
限流多个实例用 GET 判断后 INCR16 个实例、全局限额 1000:放行 1012
幂等键业务写入与幂等记录分开提交执行者超时被接管后:同一个请求写入 2 条报名
渐进发布用随机数决定是否命中 10%同一个用户两次请求,1 万人中 1,772 人结果不同

本文用 JDK 21、Redis 8.10.1 和 MySQL 8.4.11 的实测,按「需求 → 失败问题 → 约束 → 手段」的顺序分析这三个组件。

一、先说结论 ​

  • 先写失败问题,再选模式。 三个组件的类结构都很普通,决定正确性的是并发、超时、崩溃和配置错误时的行为。
  • 限流:判断与计数必须是一次原子操作。 实测本地计数器先检查后递增放行 1031 次,多实例 GET 后 INCR 放行 1012 次,原子脚本与比较并交换都正好 1000;存储不可用时的放行、拒绝、本地兜底要提前选定。
  • 幂等键:它是一个带租约的状态机,并且要和业务写在同一个事务里。 实测 20 个并发同键请求只执行 1 次;执行者超时被接管、或在提交与标记之间崩溃时,只有「同一事务 + 校验 token」能保证报名只写 1 条。
  • 渐进发布:分桶必须是确定性的,并且以开关名加盐。 实测按哈希分桶时放量单调、重载后 10 万人结果不变;不加盐时两个开关命中的是同一批人。
  • 三个组件都需要版本化的配置和加载时校验:非法规则被拒绝并保留旧版本,回滚就是发布旧版本的规则。

二、同一套推导方法 ​

组件必须回答的失败问题由此选择的手段限流并发下会不会超发?存储挂了放不放?规则改错了怎么办?原子脚本 · 策略选降级方式规则版本 + 校验 · 装饰器计量幂等键同键并发、执行中、失败后能否重试?执行者超时或崩溃之后呢?状态机 · 租约 + fencing token与业务写入同一事务 · 模板固定协议渐进发布同一用户结果稳定吗?放量单调吗?配置冲突、传播、回滚呢?确定性哈希 · 规则按优先级组合加载时校验冲突 · 版本化配置
图 1 · 三个组件都先定义失败语义,再选实现手段:模式只出现在最后一列,而且每个都对应一个前面写出来的约束
步骤限流幂等键渐进发布
需求保护下游,按规则限制调用量重试不产生重复效果新功能按租户、用户、比例逐步放开
失败问题并发超发、窗口边界、存储故障、规则错误并发同键、执行中、失败、超时、崩溃结果不稳定、规则冲突、配置传播、回滚
约束判断与计数原子;降级策略显式执行权唯一且可接管;结果与业务一起提交分桶确定;配置版本化;加载时校验
手段原子脚本、算法与降级策略可选、装饰器计量状态机、租约与 fencing token、固定的执行协议哈希分桶、规则按优先级组合、配置校验

表的最后一行才出现模式:策略用来选算法和降级方式,装饰器用来加计量,状态机描述幂等记录的生命周期,组合描述多条规则的匹配。反过来从「我要用策略模式」开始,得到的只会是一个结构漂亮、失败语义没人想过的组件。

三、限流:原子性、边界与降级 ​

3.1 算法决定窗口边界的突发 ​

同一段流量:第 990ms 到达 100 个请求,第 1000ms 再到达 100 个,限额每秒 100:

算法放行任意 1 秒内最多放行
固定窗口200200
滑动日志100100
令牌桶(容量 100,每秒补 100)101101

固定窗口在边界两侧各放行一整份;令牌桶的突发由容量决定。选哪种取决于下游能承受的突发,而不是哪种「更高级」。实验里的时钟是可控的,这也是限流组件的设计要求:时间源要能注入,否则窗口边界的行为测不出来。

3.2 判断与计数必须原子 ​

场景写法限额 1000 时的放行
32 个线程共享本地计数器先检查、后递增1031
同上比较并交换1000
16 个实例共享 RedisGET 判断后 INCR1012
同上Lua:INCR 后判断,首次设置过期1000

超发只发生在计数接近上限的那一刻,数量取决于那一刻有多少并发请求读到了同一个旧值,所以低并发的测试发现不了它。Redis 上的原子性见 Redis 原子性边界。

另一种常见做法是把全局配额平分给每个实例,各自本地限流。实测每个实例 250、流量按 70%/10%/10%/10% 分布时,只放行了 850 个,而热点实例拒绝了 1150 个。平分配额要求流量均匀,负载均衡做不到这一点时,要么用共享存储,要么按实例的实际流量动态分配。

3.3 规则变更与存储故障 ​

规则热更新的要求是:新规则先校验,失败就保留旧版本并记录。实测发布一个限额为 -5 的规则被拒绝,继续使用版本 1;第 600 个请求时限额从 1000 降到 800,同一窗口最终放行 800。

Redis 被暂停时,4 个实例共 2000 个请求(命令超时 50ms,首次失败后熔断):

降级策略放行适用
放行(fail-open)2000限流只是保护,放行的风险低于拒绝服务
拒绝(fail-closed)0下游被打爆的代价高于暂时不可用
本地兜底(每实例 250)1000需要继续保护下游,接受按实例平分的误差

每个实例只有第一次判定等了约 50ms 的超时,之后熔断器直接走降级。没有熔断时,每个请求都要等满超时,限流组件自己就成了延迟来源。

四、幂等键:带租约的状态机 ​

4.1 协议 ​

幂等记录一个键一行,状态为 PROCESSING、SUCCEEDED 或 FAILED:

  1. INSERT 一行 PROCESSING(带租约和 token 1)取得执行权;
  2. 键已存在时:请求摘要不同 → 422;已完成 → 重放保存的响应;执行中且租约未过期 → 409;租约已过期 → 用 token 比较并交换接管,token 加一;
  3. 执行业务,并把响应写回幂等记录。

实测 MySQL 8.4.11 上的基本行为:

场景结果
20 个并发同键请求执行 1 次,19 次返回 409,报名 1 条
完成后同一个键再发 5 次全部重放「201 registration for alice」,报名仍为 1 条
同一个键、不同的请求内容422
可重试的失败(下游超时)释放键,重试后执行,报名 1 条
不可重试的失败(参数错误)记录失败,同一个键再发重放 400,报名 0 条

失败分两类处理:参数错误重试也不会成功,记录下来重放;下游超时可能下次就好,释放键让客户端重试。

4.2 超时与崩溃:业务写入必须和幂等记录一起提交 ​

AB取得键,token 1业务处理中(租约 500ms 已过期)700ms 接管,token 2写入并提交A 提交分开提交A、B 都写入:报名 2 条同一事务 + WHERE token = ?A 更新 0 行 → 回滚:报名 1 条同样的结构也挡住了「业务已提交、幂等记录未更新就崩溃」:实测分开提交时重试又写了一条,同一事务时只有 1 条
图 2 · 租约 500ms,A 的业务耗时 1.5 秒,B 在第 700ms 重试并接管(token 1 → 2)。业务写入与幂等记录分开提交时,A 和 B 都写入了报名;放在同一个事务里并校验 token 时,A 的提交因为 token 不匹配而回滚

租约是必须的:执行者崩溃后,键不能永远停在 PROCESSING。但租约过期不代表执行者真的停了。实测租约 500ms,执行者 A 的业务耗时 1.5 秒,B 在第 700ms 重试并接管:

提交方式AB报名
业务写入与幂等记录分开提交成功成功2 条
同一事务,并以 WHERE token = ? 标记成功更新 0 行,回滚成功1 条

另一个窗口是「业务已提交、幂等记录还没更新就崩溃」:

提交方式租约过期后重试报名
分开提交重新执行2 条
同一事务崩溃时一起回滚,重新执行1 条

结论是:幂等记录最好和业务数据放在同一个数据库、同一个事务里,并用 token 做 fencing,这和 Redis 原子性边界 里的 fencing token 是同一个思路。做不到时(比如幂等记录放在 Redis、业务在 MySQL),就要在业务写入上加唯一约束或条件更新作为最后一道保护。下单接口的幂等见 订单、库存与数据一致性。

接入方式(注解、过滤器、网关)只是这个协议的外壳。键的范围(按用户还是按租户)、请求摘要包含哪些字段、响应保存多久,都是协议的一部分,要在接口文档里写明。

五、渐进发布:确定性分桶与版本化规则 ​

5.1 分桶 ​

用 SHA-256(开关名:用户ID) 取前 4 字节对 10000 取模,得到 0—9999 的桶,比例 p% 命中桶号小于 p×100 的用户。实测 10 万用户:

检查结果
10% 规则命中10,066 人
20% 规则命中19,977 人
10% 命中的用户在 20% 时仍命中是(放量单调)
重新加载同一配置100,000 人结果一致
改用随机数:1 万人各请求两次1,772 人两次结果不同
两个 10% 的开关同时命中以开关名加盐:1,027 人;不加盐:10,085 人

最后一行容易被忽略:只用用户 ID 求哈希时,所有开关的前 10% 都是同一批用户,这批人同时承担了所有新功能的风险,实验结果也互相干扰。

5.2 规则、校验与回滚 ​

规则按优先级从高到低匹配,第一条命中的决定结果,例如「拒绝某租户」优先于「允许某租户」,再优先于「按比例」。加载配置时检查:

  • 比例在 0—100 之间,实测 120 被拒绝,继续使用版本 1;
  • 优先级相同、条件可能重叠、结果相反的两条规则,实测在加载时被拒绝,而不是运行时按顺序碰运气。

回滚就是用旧版本的规则发布一个新版本。因为分桶是确定性的,实测版本 2(50%)改变了 39,935 人的结果,回滚后 10 万人与版本 1 完全一致。

5.3 配置传播 ​

配置不会同时到达所有节点。实测节点 B 比 A 晚 30 秒拿到 10% → 50% 的新配置,用户请求轮流打到两个节点:1 万个用户中 3,961 人的结果来回变化。受影响的正是新旧比例之间的那约 40% 用户,他们在新节点上命中、在旧节点上不命中;原来 10% 的用户在两个节点上都命中,因为放量是单调的,他们不受影响。需要严格一致时,把配置版本写进响应并让客户端或网关固定版本,或者在会话开始时计算一次结果并保存。

六、共同的工程要求 ​

  • 失败语义写进接口:限流的降级策略、幂等的 409/422 与重放、灰度的默认结果,都是调用方要依赖的契约。
  • 配置版本化:每次决策都带上规则版本,便于审计和排查「为什么这个用户看到了新功能」。
  • 可观测:放行与拒绝的原因、幂等的重放与接管次数、灰度的命中规则,都要能在指标和日志里看到。
  • 测试覆盖失败路径:可控时钟、并发竞争、暂停存储、模拟崩溃,比正常路径的测试更能说明组件是否可用。

七、常见误区 ​

  • 「限流用 Redis 计数就行」:GET 后 INCR 在多实例下超发,实测放行 1012。
  • 「平分配额最简单」:流量不均时少放行,实测 850 / 1000,热点实例拒绝 1150 个。
  • 「幂等就是加一个唯一索引」:唯一索引解决了并发同键,但执行中、失败、超时、崩溃都需要状态与租约。
  • 「租约过期就说明执行者挂了」:实测执行者只是慢,接管后两个都写入了。
  • 「灰度用随机数就好」:同一个用户两次结果不同,实测 1 万人里 1,772 人。
  • 「所有开关都按用户 ID 分桶」:不加盐时同一批用户命中所有开关。

小结 ​

这三个组件的共同点,是它们的正确性都由失败路径决定:并发、超时、崩溃、配置错误和传播延迟。设计时先写下这些失败问题和对应的约束,再决定实现手段;策略、装饰器、状态机这些模式,只是把约束落到代码里的方式。

表结构和事件格式一起变化时,怎样保证能回退到旧版本,见 能重新部署旧版本,不等于能回滚。


配套实验

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权