NOTE

2.9 如何设计频控系统

通用频控系统设计笔记,公开版保留 Redis 原子计数、批量接口、热 Key、降级与压测方法,移除内部实现细节。

系统设计创建于 更新于 约 3 分钟读完historical

这是历史学习笔记,可能存在过时或不完整的理解。

1. 需求是什么

通用频控服务重构(内部需求链接已移除)

2. 为什么要做这个需求

原业务系统历经多次业务融合与发展,在架构、扩展性、复用性和维护性等方面存在一些问题,亟需对一些服务进行重构,此次需求是对频控逻辑重构,抽取成通用能力

3. 方案设计

3.1. 存储设计

存储选用 Redis, 数据结构为 string 类型:business_id_key 次数 Redis RateLimiter.md

3.2. 接口设计

enum ErrCode {
  ERR_CODE_OK = 0;
  ERR_CODE_TIMEOUT = 10001;
  ERR_CODE_KEY_NOT_FOUND = 10002;
}

enum TimeUnit {
  TIME_UNIT_SEC = 0;
  TIME_UNIT_DAY = 1;
}

message AddFreqReq {
    uint32 business_id = 1;
    string key = 2;
    uint32 time_span = 3;
    TimeUnit time_unit = 4;
}

message AddFreqRsp {
    int32 code = 1;
    string msg = 2;
    uint32 business_id = 3;
    string key = 4;
    uint32 freq = 5;
    uint32 ttl = 6;
}

message BatchAddFreqReq {
    repeated AddFreqReq requests = 1;
}

message BatchAddFreqRsp {
    repeated AddFreqRsp results = 1;
}

message GetFreqReq {
    uint32 business_id = 1;
    string key = 2;
}

message GetFreqRsp {
    int32 code = 1;
    string msg = 2;
    uint32 business_id = 3;
    string key = 4;
    uint32 freq = 5;
    uint32 ttl = 6;
}

message BatchGetFreqReq {
    repeated GetFreqReq requests = 1;
}

message BatchGetFreqRsp {
    repeated GetFreqRsp results = 1;
}

service FrequencyControl {
    rpc AddFreq(AddFreqReq) returns (AddFreqRsp);
    rpc BatchAddFreq(BatchAddFreqReq) returns (BatchAddFreqRsp);
    rpc GetFreq(GetFreqReq) returns (GetFreqRsp);
    rpc BatchGetFreq(BatchGetFreqReq) returns (BatchGetFreqRsp);
}

3.3. 架构设计

3.3.1. 架构图

频控

3.3.2. 时序图

频控系统时序图

3.3.3. 批量接口的设计

封装复杂度之批量接口_明明如月学长的博客-CSDN 博客_批量接口怎么设计

  1. 请求长度限制
  2. 参数校验:对所有请求都检验参数,只要有一个失败则返回整体失败,不做部分处理
  3. 部分失败:忽略掉失败的部分、整体失败、提供回滚

一方面直接嵌套了单个接口的 req 和 rsp,减少理解负担;另一方面批量接口的 rsp 加了 code 和 msg,即两层 code、msg 设计 如果批量请求中有写请求参数校验失败,那么整体返回失败,对外层的 code、msg 赋值。之所以这么设计是因为传参这种理应在开发调试阶段 fix 掉,同时也是为了简化代码处理 如果某些请求失败某些请求成功那么对里面的 code、msg 赋值

3.3.4. 自定义 code、msg 不使用 error

需要处理部分失败的情况

3.3.5. 自己调用 validate

无法校验整体参数

3.3.6. 内部错误码+外部错误码

3.3.7. 幂等性设计

可以加个 orderNo, 由业务方生成保证写操作重试时幂等. 但是没有必要,原因在于业务方一般都不会重试, 且频控要求其实并不是特别严格

3.3.8. 降级设计

写操作不降级, 业务方自行降级 读操作网络错误降级使用本地缓存

3.3.9. 为什么需要封装 Lua

Redis RateLimiter.md

3.3.10. Redis hot key

  • 如何发现 hot key
    • 存储侧监控统计访问 Top N 的 key
    • 服务侧周期性拉取热点 key 信息
  • 如何处理热 key
    • 对于访问次数达到阈值的热 key,可以通知各节点将其放入本地内存,并设置极短过期时间
    • 严格计数写路径不能依赖各节点独立缓存,否则会破坏全局频控语义

4. 压测

预估:IO 密集型应用的 QPS 取决于并发度和响应时间,可以用 QPS≈并发度/响应时间 做粗略估算,最终仍以压测为准。

第一次压测:打开 debug 日志。
第二次压测:关闭 debug 日志,效果提升明显。
第三次压测:使用 EvalSha 代替 Eval,效果提升不明显。

提高 QPS 有两条路:降低响应时间、提高有效并发度。定位时需要结合火焰图、trace、网络抓包和 Redis 指标。

4.1. AddFreq

  1. 第一次压测:CPU 成为瓶颈。火焰图显示 GC 和 Redis 客户端封装存在明显耗时;
  2. 调整 GC 策略后再次压测,GC 占比下降;
  3. 更换 Redis 客户端后再次压测,客户端封装耗时下降;
  4. 继续尝试网络层优化,性能没有明显提升;
  5. 使用 redis-benchmark 单独压测 Redis,确认最终瓶颈在 Redis CPU。

公开版不保留内部性能插件名称、机器规格、QPS 数字和生产监控截图。

4.2. BatchAddFreq

  1. 使用不同 Redis 客户端压测,吞吐差异不明显;
  2. 应用 CPU、内存和磁盘都没有打满,开始怀疑网络瓶颈;
  3. 使用 trace 观察网络等待;
  4. 从延迟、带宽、请求包大小几个方向排查;
  5. 使用 tcpdump / Wireshark 检查批量请求的数据包,减少 Lua 脚本传输量;
  6. 再通过本地 Redis 与 redis-benchmark 交叉验证,最终确认主要瓶颈仍然在 Redis 单节点 CPU,而不是应用 CPU 或链路带宽。

一个重要经验是:不要只看主从或 Proxy 架构的“平均 CPU”,平均值可能掩盖单节点已经打满。

4.3. GetFreq

同 AddFreq。

4.4. BatchGetFreq

同 BatchAddFreq。

5. 部署

一般使用限流服务的代码是这样的:

  1. 读频控
  2. 处理业务逻辑
  3. 写频控

步骤 3 可以异步执行,关键是优化读频控延时。

Redis 多地域复制常见有两种方案:

  1. Leader-Leader
    • 服务读 / 写存储都在本地地域,读写延迟低;
    • 问题是多写点可能发生冲突,冲突处理复杂。
  2. Leader-Follower
    • 写入主实例,读取就近从实例;
    • 适合读多写少且允许数据同步延迟的场景。

频控是否能读取副本取决于业务一致性要求。如果复制延迟可能导致突破严格上限,就仍然需要读取强一致事实源;如果只是保护性阈值并允许少量误差,可以考虑就近只读副本。

6. 参考

讨论

使用 GitHub 账号参与讨论,评论会保存在 GitHub Issues 中。在 GitHub 查看