三个可复用的服务端组件:限流、幂等键与渐进发布
限流、幂等、灰度都能写成一个注解加一个切面,看起来半天就能完成。难的部分不在类结构,而在失败语义:并发时会不会超发、执行者超时后谁来接手、配置改错了怎么退回。模式是在这些问题都有答案之后,才出现的实现手段。
三个组件各做了一组实验,每组都有一个「看起来能用、实际上不对」的写法:
| 组件 | 看起来能用的写法 | 实测 |
|---|---|---|
| 限流 | 多个实例用 GET 判断后 INCR | 16 个实例、全局限额 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、固定的执行协议 | 哈希分桶、规则按优先级组合、配置校验 |
表的最后一行才出现模式:策略用来选算法和降级方式,装饰器用来加计量,状态机描述幂等记录的生命周期,组合描述多条规则的匹配。反过来从「我要用策略模式」开始,得到的只会是一个结构漂亮、失败语义没人想过的组件。
三、限流:原子性、边界与降级
3.1 算法决定窗口边界的突发
同一段流量:第 990ms 到达 100 个请求,第 1000ms 再到达 100 个,限额每秒 100:
| 算法 | 放行 | 任意 1 秒内最多放行 |
|---|---|---|
| 固定窗口 | 200 | 200 |
| 滑动日志 | 100 | 100 |
| 令牌桶(容量 100,每秒补 100) | 101 | 101 |
固定窗口在边界两侧各放行一整份;令牌桶的突发由容量决定。选哪种取决于下游能承受的突发,而不是哪种「更高级」。实验里的时钟是可控的,这也是限流组件的设计要求:时间源要能注入,否则窗口边界的行为测不出来。
3.2 判断与计数必须原子
| 场景 | 写法 | 限额 1000 时的放行 |
|---|---|---|
| 32 个线程共享本地计数器 | 先检查、后递增 | 1031 |
| 同上 | 比较并交换 | 1000 |
| 16 个实例共享 Redis | GET 判断后 INCR | 1012 |
| 同上 | 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:
INSERT一行PROCESSING(带租约和 token 1)取得执行权;- 键已存在时:请求摘要不同 → 422;已完成 → 重放保存的响应;执行中且租约未过期 → 409;租约已过期 → 用 token 比较并交换接管,token 加一;
- 执行业务,并把响应写回幂等记录。
实测 MySQL 8.4.11 上的基本行为:
| 场景 | 结果 |
|---|---|
| 20 个并发同键请求 | 执行 1 次,19 次返回 409,报名 1 条 |
| 完成后同一个键再发 5 次 | 全部重放「201 registration for alice」,报名仍为 1 条 |
| 同一个键、不同的请求内容 | 422 |
| 可重试的失败(下游超时) | 释放键,重试后执行,报名 1 条 |
| 不可重试的失败(参数错误) | 记录失败,同一个键再发重放 400,报名 0 条 |
失败分两类处理:参数错误重试也不会成功,记录下来重放;下游超时可能下次就好,释放键让客户端重试。
4.2 超时与崩溃:业务写入必须和幂等记录一起提交
租约是必须的:执行者崩溃后,键不能永远停在 PROCESSING。但租约过期不代表执行者真的停了。实测租约 500ms,执行者 A 的业务耗时 1.5 秒,B 在第 700ms 重试并接管:
| 提交方式 | A | B | 报名 |
|---|---|---|---|
| 业务写入与幂等记录分开提交 | 成功 | 成功 | 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 分桶」:不加盐时同一批用户命中所有开关。
小结
这三个组件的共同点,是它们的正确性都由失败路径决定:并发、超时、崩溃、配置错误和传播延迟。设计时先写下这些失败问题和对应的约束,再决定实现手段;策略、装饰器、状态机这些模式,只是把约束落到代码里的方式。
表结构和事件格式一起变化时,怎样保证能回退到旧版本,见 能重新部署旧版本,不等于能回滚。
配套实验
- codesphere-labs/design/adaptive-rate-limiter:三种算法的窗口边界、本地与多实例的原子性、配额平分、规则热更新、Redis 暂停时的降级(验证记录)
- codesphere-labs/design/idempotency-key:并发同键、重放、请求不一致、失败处理、租约接管与 fencing token、崩溃窗口(验证记录)
- codesphere-labs/design/progressive-delivery-rules:确定性分桶、优先级与冲突检测、配置校验与回滚、配置传播(验证记录)
参考资料