把限流阈值从代码里拽出来之后,我们设计了一套 DSL,运营活动期间自己改配置就行
有几次凌晨两点被报警电话叫醒,我盯着屏幕上的限流日志,发现阈值还是三个月前定的 500 QPS —— 运营那边临时上了个秒杀入口,流量打了三倍进来,下游差点被打穿。改配置、走审批、灰度发布,等真正生效已经是当天下午的事了。
这事儿不能每次都靠运维救火。
我们决定把限流规则从代码里拆出来,做一套运营自己能改的 DSL,活动期间直接在管理后台调阈值,不用开发介入、不用走发布流程。
阈值写死在代码里的代价有多大
我们统计了过去半年 17 次限流阈值变更的记录,平均每次从提出需求到生效需要 4.2 小时。其中有 11 次发生在非工作时间,有 3 次因为紧急发布引入其他变更导致了额外故障。
最惨的一次,双十一预热期间营销临时加了个「整点秒杀」入口,流量峰值从预估的 1200 QPS 冲到 3800 QPS。限流阈值写在 Apollo 配置里没错,但改配置需要走完整的变更审批——等灰度验证完推到生产,已经错过了两轮整点,下游服务被拖慢,用户看到的全是「系统繁忙」。
问题其实很明确:限流规则变更的决策链路过长,信息损耗严重。运营知道什么时候会有流量高峰,但他们没有权限改;开发有权限改,但不知道什么时候需要改。中间要靠工单、群消息、口头沟通来传递,信息在传递过程中必然失真。
所以核心矛盾不是「阈值怎么存」,而是「决策权和执行权能不能合一」——让最清楚流量特征的人直接控制限流参数。
为什么不用现成的配置中心
一开始有人提议,阈值放 Apollo 或 Nacos 里不就行了?改配置又不用重新发布。
但实际情况是,我们生产环境的配置变更同样需要审批流。配置中心解决了「不改代码」的问题,但没解决「决策链路长」的问题。而且运营同学根本不会去用 Apollo——界面太技术化,字段名称像 coupon.service.limit.qps 这种,配错一个字符就是事故。
另一个被否掉的方案是用 Sentinel 的 dashboard 动态改规则。Sentinel 1.8.4 确实支持推模式动态规则,但 dashboard 的操作权限模型太粗粒度,没法精确控制到「运营只能改自己负责的那几个接口的阈值」。让运营直接登 dashboard,风险不可控。
我们需要的是一层抽象:把技术细节屏蔽掉,只暴露运营看得懂的业务概念,同时权限收敛到最小粒度。
DSL 设计:把业务语义从技术参数里剥离出来
设计这套 DSL 之前,我们先梳理了运营侧的真实诉求。跟营销、商品、会员三个业务线的运营聊了一圈,发现他们关心的不是 QPS 或线程数,而是三个东西:
- 哪个活动、哪个页面、哪个入口 —— 业务定位
- 什么时候开始限流、什么力度 —— 时间计划
- 限流之后用户看到什么 —— 体验控制
所以我们把 DSL 拆成三层抽象:
活动(Campaign)
└── 场景(Scene)
└── 规则(Rule)
活动层对应运营日历里的营销事件,比如「618 大促」「双十二返场」。活动有生效时间段,过期自动失效,不需要手动关。
场景层对应具体的流量入口,比如「领券按钮」「商品详情页推荐位」「支付确认页」。场景绑定了一个或多个 API path,运营不需要知道 path 是什么,选场景名就行。
规则层是具体的限流策略,包含阈值、时间窗口、限流后行为。这里我们只暴露了三个参数:
- 每秒允许多少请求(运营看到的是「每秒允许多少人」)
- 超出后等待还是直接拒绝
- 拒绝时展示什么文案
下面是一条实际在用的 DSL 配置,JSON 格式,运营在后台表单里填完自动生成:
{
"campaign": "2025-spring-festival",
"name": "春节红包雨",
"startTime": "2025-01-28T00:00:00+08:00",
"endTime": "2025-02-05T23:59:59+08:00",
"scenes": [
{
"name": "红包领取入口",
"apiPaths": ["/api/redpacket/receive", "/api/redpacket/open"],
"rules": [
{
"type": "qps",
"threshold": 2000,
"windowMs": 1000,
"action": "reject",
"fallbackMessage": "领红包的人太多了,稍后再来试试~"
}
]
},
{
"name": "红包结果查询",
"apiPaths": ["/api/redpacket/result"],
"rules": [
{
"type": "qps",
"threshold": 5000,
"windowMs": 1000,
"action": "reject",
"fallbackMessage": "查询太频繁了,请稍后刷新"
}
]
}
]
}
运营填表单的时候,看到的是「红包领取入口 —— 每秒 2000 人 —— 超出提示语」,提交后后台生成这段 JSON,写入独立的限流规则存储(我们用 Redis 存热数据,MySQL 做持久化)。规则引擎实时监听变更,秒级生效。
这里有个细节值得说:apiPaths 是开发配置的,运营只选场景名。我们把「哪些接口属于哪个场景」这个映射关系放在了另一个配置里,只有开发有权限改。这样运营改阈值不会影响路由,降低了配错的风险。
规则引擎实现:如何做到秒级生效且不丢流量
引擎的实现用了经典的「热加载 + 原子替换」模式。
规则存储分两层:Redis 里存当前生效的全部规则,用 Hash 结构,field 是场景名,value 是序列化后的规则对象。MySQL 里存历史版本,每次修改追加一条记录,方便回溯和审计。
引擎启动时从 Redis 加载全量规则到内存,构建一个 ConcurrentHashMap<String, SceneRule>。同时订阅 Redis 的 key-space notification,一旦有规则变更,收到通知后重新加载对应场景的规则,原子替换内存中的对象。
核心代码大概长这样:
public class RuleEngine {
private final ConcurrentHashMap<String, SceneRule> ruleCache = new ConcurrentHashMap<>();
private final RedisTemplate<String, String> redisTemplate;
@PostConstruct
public void init() {
// 全量加载
Map<String, String> allRules = redisTemplate.opsForHash()
.entries("ratelimit:rules");
allRules.forEach((scene, json) -> {
SceneRule rule = JSON.parseObject(json, SceneRule.class);
ruleCache.put(scene, rule);
});
// 订阅变更
redisTemplate.getConnectionFactory().getConnection()
.subscribe((message, pattern) -> {
String scene = new String(message.getBody()).split(":")[2];
reloadScene(scene);
}, "__keyspace@0__:ratelimit:rules".getBytes());
}
private void reloadScene(String scene) {
String json = (String) redisTemplate.opsForHash()
.get("ratelimit:rules", scene);
if (json != null) {
ruleCache.put(scene, JSON.parseObject(json, SceneRule.class));
} else {
ruleCache.remove(scene);
}
}
public SceneRule getRule(String scene) {
return ruleCache.get(scene);
}
}
限流器本身用 Guava RateLimiter + 滑动窗口计数器混合实现。QPS 类型用 RateLimiter 的 tryAcquire() 做快速判断,超过阈值直接返回拒绝。需要更精确控制的场景用滑动窗口,对时间敏感度更高。
限流判断的拦截器放在网关层,从请求 path 反查所属场景,拿到规则后执行限流逻辑。整个链路增加的开销在 0.3ms 以内,对正常请求几乎无感。
权限模型:运营只能改自己负责的
管理后台的权限设计是落地的关键。我们用的是 RBAC 模型,但做了两层细化:
第一层按活动隔离。运营 A 负责营销活动,就只能看到和修改标记为「营销」的活动及其场景规则。运营 B 负责会员活动,互不干扰。
第二层按操作类型隔离。同一个活动内,场景的 apiPaths 映射只有开发能改,运营只能改 threshold、action、fallbackMessage 这三个字段。
权限校验在后端做,不在前端做。每次保存规则时,网关层校验 token 的角色和资源归属,不匹配直接拒绝。这个校验逻辑写在了一个独立的 Policy Engine 里,避免权限判断散落在业务代码各处。
上线三个月后回头看,规则变更的平均生效时间从 4.2 小时降到了 27 秒——基本就是运营填完表单点保存的时间。误操作次数为零,因为权限收敛和字段校验把可能出错的路径堵死了。期间经历了两次突发流量,一次是运营临时加了明星直播入口,一次是商品侧紧急上了爆款补货,两次都是运营自己调的阈值,开发全程没介入。
还有哪些坑
方案落地过程中踩了几个坑,值得单独说说。
规则冲突问题。一个 API path 可能同时属于多个场景,比如 /api/coupon/receive 既属于「领券入口」又属于「新人专享」。当两个场景的限流阈值不同时,以哪个为准?
我们的处理方式是:场景有优先级字段,数字越小优先级越高。限流拦截器在匹配到多个场景时,选优先级最高的规则执行。优先级由开发和运营协商确定,一般业务核心链路优先级更高。
规则回滚。运营误操作把阈值从 2000 改成 200 怎么办?我们在后台留了最近 10 个版本的操作记录,运营可以一键回滚到任意历史版本。回滚操作本身也会被记录,形成完整的操作链路。
活动过期后的清理。活动到了 endTime,规则引擎会自动标记场景为 expired,但不会立即删除。保留 7 天观察期,期间如果运营延长活动时间,规则可以无缝恢复。7 天后定时任务清理 Redis 和内存缓存。
常见问题
Q: 运营配错了阈值导致服务被打挂怎么办?
每个场景设置了硬上限(hard limit),由开发在场景映射配置里设定,运营只能在这个上限以内调整阈值。比如开发设定红包领取入口的 hard limit 为 5000 QPS,运营填表单时阈值只能填 1-5000 之间的值。这个限制在后端校验,前端拦不住的情况下后端也会拒绝。
Q: 规则变更真的不需要重启服务吗?
不需要。规则存储在 Redis,引擎通过订阅 key-space notification 感知变更,内存中原子替换规则对象。从 Redis 写入到引擎生效,延迟通常在 100ms 以内。如果 Redis 不可用,引擎会降级使用内存中最近一次加载的规则快照,不会导致限流失效。
Q: 这套 DSL 能处理更复杂的限流场景吗,比如按用户维度限流?
当前版本只支持接口级别的 QPS 限流,按用户 ID、IP、设备维度的限流需要扩展规则模型。我们在设计时预留了 rule.type 字段,后续可以扩展 userId、ip 等类型。但按用户维度限流对存储和计算的要求更高,需要引入 Redis 计数器或令牌桶,这是下一个迭代要解决的问题。