主题
限流体系
仓库里存在两套互不相干的计数限流:一套是 2016 年的
RedisRateLimiter(固定时间片 + INCR,跑在主 Redis 上,目前只用于短信上行),另一套是高频规则体系(Lua + 桶分片滑动窗口,跑在独立的第二套 Redis 上,才是线上真正挡量的机制)。
1. 四道防线全景
| 层 | 机制 | 计数单位 | 存储 | 超限表现 | 是否阻断 |
|---|---|---|---|---|---|
| L1 代理层 | nginx limit_req(可选,仓库未提供配置) | 连接/请求速率 | nginx 内存 | 503 | 阻断 |
| L2 准入层 | 名单拦截链(见 04 篇) | 无计数,纯查表 | 主 Redis | 418 NOT_ALLOWED | 阻断 |
| L3 接口计数 | RedisRateLimiter | 固定时间片累计 | 主 Redis StringRedisTemplate | 业务自行处理(当前静默丢弃) | 阻断/降级 |
| L4 高频规则 | FrequencyMonitorRedisService + FrequencyOpServiceImpl | 桶分片滑动窗口 | 独立第二套 Redis | 420 CONTEXT_VALIDATE_ERROR + hitType=20100 | 阻断并写黑名单 |
L3 与 L4 的边界要认清:L3 是「接口级防打爆」,只有代码里显式调用的地方才生效;L4 是「行为级防刷」,挂在校验规则链上,可配置任意维度与阈值。
2. RedisRateLimiter 原理(以代码为准)
java
// captcha-server/src/main/java/com/netease/is/nc/server/limiter/RedisRateLimiter.java:61
private long accumulate(String key, TimeUnit unit, int interval) {
long now = System.currentTimeMillis();
long ts = now - now % unit.toMillis(interval); // 窗口起点:按 wall-clock 对齐
key = String.format("rl:%s:%s", key, ts); // 最终键 rl:{bizKey}:{窗口起点}
long result = -1;
try {
result = stringRedisTemplate.opsForValue().increment(key, 1); // INCR,非 Lua
} catch (Exception e) {
log.error("redis访问出错", e); // 异常 → result 保持 -1 → 必然不超限
} finally {
if (result < 3) { // 仅前 3 次访问设置 TTL
stringRedisTemplate.expire(key, interval * 2, unit);
}
}
return result;
}| 特征 | 实现 | 位置 |
|---|---|---|
| 算法 | 固定窗口(按 wall-clock 对齐),非滑动窗口、非令牌桶 | RedisRateLimiter.java:63 |
| Redis key | rl:{bizKey}:{窗口起点毫秒},bizKey 由调用方拼 | RedisRateLimiter.java:64 |
| TTL | interval * 2,且只在 INCR 结果 < 3 时设置,用于省 Redis 调用 | RedisRateLimiter.java:71-74 |
| 原子性 | 无 Lua,INCR 与 EXPIRE 是两次往返;进程在两者之间崩溃 → 该 key 永不过期 | RedisRateLimiter.java:67、:73 |
| 判定 | accumulate > limit(严格大于,等于阈值仍放行) | RedisRateLimiter.java:45 |
| 故障语义 | Redis 异常 → 返回 -1 → 不超限 → fail-open | RedisRateLimiter.java:68-70 |
| 连接来源 | 主 Redis,Spring StringRedisTemplate(base.properties 的 redis.*) | RedisRateLimiter.java:26 |
除计数外还有一对「风险告警抑制」方法:键 risk:hourly:{captchaId}:{小时起点},setRiskExpireResult 写入并设 RISK_EXPIRE_TIME = 60 分钟(RedisRateLimiter.java:31、:89、:118)。它不参与限流,作用是让 StatHourly 同一小时内只给同一产品发一次超量告警(captcha-server/src/main/java/com/netease/is/nc/server/service/request/StatHourly.java:288、:316)。注意字段注释写「风险报警抑制时间为1小时」,常量名与单位(分钟)与注释不一致,改前务必按代码为准。
唯一调用点:短信上行回调 /api/v1/sms,按手机号做 1 小时限额(captcha-server/src/main/java/com/netease/is/nc/server/api/sms/UplinkSMSController.java:61):
java
final int HOURLY_LIMIT = sysConfLoader.getSmsPhoneNumHourlyLimit(); // key=sms.phone.hourly.limit,默认 10
boolean isExceedLimit = redisRateLimiter.isExceedLimit(uplink.getPhone(), 1, TimeUnit.HOURS, HOURLY_LIMIT);
if (isExceedLimit) {
JSONObject j = new JSONObject();
j.put("e", CaptchaError.OK.getVal()); // 运营商不关心结果,仍返回成功
j.put("m", "execeed limit.");
return j;
}阈值来源 sys_conf 的 sms.phone.hourly.limit,默认 10(captcha-base/src/main/java/com/netease/is/nc/base/sys/conf/SysConfLoader.java:489-490)。返回 e=0 而不是错误码,是因为运营商上行回调不校验响应体,报错没有意义。
3. frequency.properties 逐项解析
文件名容易误导:这里没有任何业务阈值,全是独立 Redis 的连接参数加上一个刷新间隔。
| 配置项 | 本仓库取值 | 含义 | 缺省行为 |
|---|---|---|---|
redis.type | 4 | 连接形态:1 单机 / 2 sentinel / 3 sharding / 4 cluster / 5 ncr | 缺省 1(单机) |
redis.nodes | 10.44.45.14:6001,6002,6003 | 节点列表,逗号分隔 host:port | 缺失直接 AssertUtils.notBlank 抛错 |
redis.timeout | 1000 | 连接/读写超时毫秒 | 缺省 2000 |
redis.server.pwd | 明文口令 | 支持 Enc(...) 密文,读取时透明解密 | 空 = 无密码 |
redis.sentinel.monitor.name | 空 | 仅 sentinel 模式用 | 空串 |
frequency.refresh.interval | 1 | 高频规则缓存刷新周期,单位分钟 | 缺省 2 |
以下键在 ConfigManager 中有读取逻辑,但 frequency.properties 里并未出现,运行时全部走默认值:storage.type(1)、memory.maximumSize(2000000)、redis.pool.maxTotal(100)、maxIdle(100)、maxWaitMillis(30000)、timeBetweenEvictionRunsMillis(30000)、minEvictableIdleTimeMillis(30000)、testOnBorrow/testOnReturn/testWhileIdle(true)(captcha-server/src/main/java/com/netease/is/nc/server/service/rules/frequency/ConfigManager.java:138-201)。
frequency.refresh.interval 的真实消费者是缓存基类:AbstractCacheService.getRefreshIntervalMinutes() → ConfigManager.getRefreshInterval(),驱动 scheduleAtFixedRate 周期性重建规则缓存(captcha-server/src/main/java/com/netease/is/nc/server/abst/AbstractCacheService.java:106、:120、:224)。即:后台改高频规则,最迟 1 分钟生效。
业务阈值不在这里,落在两处:
- 规则本身(阈值
frequency、监控周期monitor_interval、维度key_str、动作对象、黑名单库类型与时长)在 DB 的高频规则表,由FrequencyCache全量加载并按productId/captchaId/ 全局三档归并(FrequencyCache.java:405); - 全局开关
isFrequencyEnabled()/isSwitchOverEnabled()在sys_conf(SysConfLoader.java:404、:414),30s 轮询。
4. 高频规则统计引擎
规则命中判定在 FrequencyOpServiceImpl.getHit(captcha-server/src/main/java/com/netease/is/nc/server/service/rules/frequency/FrequencyOpServiceImpl.java:127):
| 步骤 | 内容 | 位置 |
|---|---|---|
| 1 | 拼 Redis key:fq:{typeId}:{frequencyId}:{fieldValue...},字段值来自规则 key_str 引用的 FrequencyMeta.metaField;任一字段为空则跳过该规则;值超长先 md5 | FrequencyOpServiceImpl.java:176-200 |
| 2 | monitorInterval(规则毫秒 → 秒)与 threshold = frequency 从规则取 | :133-134 |
| 3 | CalType.SET → hitBySimpleSet(去重计数);否则 → hitByCount(累计计数) | :138、:151-153 |
| 4 | 命中判定 stat.getSum() > threshold,sum == -1(未统计)不算命中 | :160 |
| 5 | 回填 sum / statStart / statEnd 供日志与监控 | :156-158 |
两种模式的底层都是 Lua(calcu_count.lua / calcu_simple_set.lua),一次 EVAL 完成「清理过期桶 + 累加 + 求和」:
| 模式 | 数据结构 | 窗口语义 | 典型用途 |
|---|---|---|---|
hitByCount | Hash,field = 桶下标 | 桶分片滑动窗口,bucketSpan = ttl*2 + bufferNum*bucketInterval | 单位时间总次数(最常用) |
hitBySimpleSet | Set,元素 = 业务值 | fixed window,Lua 内首次写入才设一次 EXPIRE,到点整体清空 | 单位时间不同元素数量(如同一 IP 触达多少账号) |
桶宽度按监控周期自适应,平衡精度与内存(captcha-server/src/main/java/com/netease/is/nc/server/service/rules/frequency/FrequencyMonitorRedisService.java:95):
ttl 范围 | 桶宽 bucketInterval | 乱序缓冲 bufferNum |
|---|---|---|
| ≤ 60s | 1s | 5 |
| ≤ 600s | 60s | 3 |
| ≤ 1800s | ttl/10 | 2 |
| > 1800s | ttl/10 | 1 |
另有乱序保护:线程内记录上一条消息时间,延迟超过 1000 * bucketInterval * bufferNum 毫秒则丢弃统计并累加当日计数器 fq:messagedelayed:{yyyyMMdd}(FrequencyMonitorRedisService.java:42、:127、:236)。这意味着高频判定对乱序流量是「放弃计数」而不是「算错」,宁可漏也不误拦。
5. 为什么是独立的第二套 Redis
| 维度 | 主 Redis(base.properties) | 高频 Redis(frequency.properties) |
|---|---|---|
| 客户端 | Spring Data Redis StringRedisTemplate | 网易内部 JedisClientFactory + JedisClientOp(原生 Jedis) |
| 配置解析 | PropertyPlaceholderConfigurer 注入 | ConfigManager 单例直接读 classpath /frequency.properties(ConfigManager.java:27、:78) |
| 用途 | token 会话、名单、上下文、短信码 | 只有 fq:* 计数 key 与延迟计数器 |
| 数据模型 | Hash / String / Set,读多写少 | 每请求 1 次 EVAL,写放大明显 |
分库的三个理由,按重要性:
- 故障域隔离。高频统计是
check主链路上的同步调用,高峰期 QPS 与业务同阶。若与 token 会话共用实例,计数压力会直接拖慢取图与二次校验——一次 Redis 抖动就会同时打挂「发题」与「验题」。 - 淘汰策略不能混。
fq:*是典型的可丢弃数据(丢了只是少算一次),而pri:token:*丢了就等于验证码失效。同一个实例只能配一种maxmemory-policy,混放必然牺牲一方。 - 连接池特征不同。计数走短平快的
EVAL,池要小、超时短;会话走多样命令。分离后各自的maxTotal/timeout可独立调(JedisUtils.java:54-65)。
代价也要认清:这套连接绕过了 Spring 的事务/序列化体系,且 JedisUtils 用静态内部类单例(JedisUtils.java:30),配置改完必须重启,不能热更新。
6. 限流维度与超限响应
| 维度 | 现状 | 落地位置 |
|---|---|---|
| 手机号 | 已落地,1 小时窗口,默认 10 次 | UplinkSMSController.java:61 |
| 规则自定义维度 | 由规则 key_str 引用的 FrequencyMeta.metaField 决定,可从请求对象反射取 captchaId / captchaType / ip / user / 指纹等(ConditionContext.get 反射 + 字段值缓存) | FrequencyOpServiceImpl.java:70-72、:176 |
| 产品/应用级 | 规则候选按「全局 → productId → captchaId」三档归并,等价于支持产品/应用级限流 | FrequencyCache.java:405 |
| 接口级 | 未落地。get / check / getconf 没有 RedisRateLimiter 调用,全靠 nginx 或上游网关 | — |
超限响应差异:
| 场景 | 对外表现 | 代码 |
|---|---|---|
| 短信上行超限 | {"e":0,"m":"execeed limit."},运营商无感 | UplinkSMSController.java:63-66 |
| 高频规则命中(check) | result=false,error=420 CONTEXT_VALIDATE_ERROR,hitType=20100,validate="" | CaptchaContextCheckServiceImpl.java:248、CheckCaptchaService.java:226 |
| 高频规则命中(同时写名单) | 按规则 blacklist_lib_type 把 IP / 用户 / 指纹写入对应黑名单库,时长取 blacklist_duration | CaptchaContextCheckServiceImpl.java:200 |
7. 与拦截链的先后关系
text
check 请求
│
├─① 指纹黑名单(Redis 查表) → 413 + hitBlacklist=1 [最先最便宜]
├─② 名单拦截链 IP / 用户(Redis 查表) → 418 + hitBlacklist=2/3
├─③ 类型校验(答案阈值比对)
├─④ 上下文 validators 链(UA/referer/轨迹/耗时/模型)
└─⑤ 高频规则(Redis EVAL 计数) → 420 + hitType=20100 + 写名单
↓
sum > threshold 且非白名单 IP顺序上的三个要点:
- 查表在前、计数在后。名单命中不消耗任何计数资源,避免被封 IP 继续污染高频统计口径。
- 高频统计不是「只读」。
check传increment=1(写),get传increment=0(只读同一 key 的当前和),用于「上一轮 GET 已高频 → 本轮切换验证码类型」(FrequencyChecker.java:37、:51、FrequencyOpServiceImpl.java:101)。所以get也会读计数,但不累加。 - 白名单 IP 被随机重写。白名单来源的计数会被改写以避免污染阈值(
CaptchaContextCheckServiceImpl.java:172、:271)。
8. 调优建议
| 建议 | 理由 |
|---|---|
给 accumulate 换成单条 Lua(INCR+EXPIRE 原子) | 现实现「前 3 次才设 TTL」,若进程在 INCR 后崩溃,rl:* key 永久驻留,长期累积会吃内存 |
| 窗口改滑动窗口或至少双桶加权 | 固定窗口在窗口边界可打出 2 倍瞬时流量 |
想限 IP / 接口就别复用 RedisRateLimiter | 它没有维度内建逻辑,key 全由调用方拼;接口级限流建议落在 nginx limit_req(burst + nodelay),与应用解耦 |
高频规则的 key_str 优先用服务端可控字段 | 用客户端控制的 user 作维度能被换值绕过 |
别把 frequency.refresh.interval 调成 0 或负数 | 会退化成高频重建规则缓存,DB 压力上升;1~2 分钟是合理区间 |
monitor_interval 尽量 ≤ 10 分钟 | 长窗口下桶宽变大、精度下降,且 bucketSpan = 2*ttl + buffer*桶宽 的内存占用随之上升 |
高频 Redis 独立监控 fq:messagedelayed:* | 该计数增长说明上游乱序或时钟漂移,此时高频判定处于「少算」状态,需先修链路再谈阈值 |
| 名单 Redis 与高频 Redis 不要合池 | 二者淘汰语义、写放大、超时要求完全不同 |
回归时同时看 hitType=20100 与 error=420 | 高频命中对外只暴露 420,归因必须靠 hitType(HitType.java:71) |
9. 变更自检清单
- 新增限流点:先确认是否需要「写计数」;
increment=0只读会让规则永不命中。 - 改
FREQ_*类型的 key 前缀(fq:)必须同步清理线上存量 key,否则新旧规则各算一份。 - 阈值语义是
sum > threshold(严格大于),配 10 表示第 11 次才拦。 - 高频命中写黑名单是副作用,灰度新规则时务必把
blacklist_lib_type先置空。 frequency.properties支持Enc(...)密文口令,明文口令不要提交到仓库副本。