NOTE
2.9 如何设计频控系统
通用频控系统设计笔记,公开版保留 Redis 原子计数、批量接口、热 Key、降级与压测方法,移除内部实现细节。
这是历史学习笔记,可能存在过时或不完整的理解。
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 博客_批量接口怎么设计
- 请求长度限制
- 参数校验:对所有请求都检验参数,只要有一个失败则返回整体失败,不做部分处理
- 部分失败:忽略掉失败的部分、整体失败、提供回滚
一方面直接嵌套了单个接口的 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
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
- 第一次压测:CPU 成为瓶颈。火焰图显示 GC 和 Redis 客户端封装存在明显耗时;
- 调整 GC 策略后再次压测,GC 占比下降;
- 更换 Redis 客户端后再次压测,客户端封装耗时下降;
- 继续尝试网络层优化,性能没有明显提升;
- 使用 redis-benchmark 单独压测 Redis,确认最终瓶颈在 Redis CPU。
公开版不保留内部性能插件名称、机器规格、QPS 数字和生产监控截图。
4.2. BatchAddFreq
- 使用不同 Redis 客户端压测,吞吐差异不明显;
- 应用 CPU、内存和磁盘都没有打满,开始怀疑网络瓶颈;
- 使用 trace 观察网络等待;
- 从延迟、带宽、请求包大小几个方向排查;
- 使用 tcpdump / Wireshark 检查批量请求的数据包,减少 Lua 脚本传输量;
- 再通过本地 Redis 与 redis-benchmark 交叉验证,最终确认主要瓶颈仍然在 Redis 单节点 CPU,而不是应用 CPU 或链路带宽。
一个重要经验是:不要只看主从或 Proxy 架构的“平均 CPU”,平均值可能掩盖单节点已经打满。
4.3. GetFreq
同 AddFreq。
4.4. BatchGetFreq
同 BatchAddFreq。
5. 部署
一般使用限流服务的代码是这样的:
- 读频控
- 处理业务逻辑
- 写频控
步骤 3 可以异步执行,关键是优化读频控延时。
Redis 多地域复制常见有两种方案:
- Leader-Leader
- 服务读 / 写存储都在本地地域,读写延迟低;
- 问题是多写点可能发生冲突,冲突处理复杂。
- Leader-Follower
- 写入主实例,读取就近从实例;
- 适合读多写少且允许数据同步延迟的场景。
频控是否能读取副本取决于业务一致性要求。如果复制延迟可能导致突破严格上限,就仍然需要读取强一致事实源;如果只是保护性阈值并允许少量误差,可以考虑就近只读副本。
讨论
使用 GitHub 账号参与讨论,评论会保存在 GitHub Issues 中。在 GitHub 查看