一、前言:为什么需要限流?
微服务拆开后,流量入口变多、链路变长,系统暴露在两类风险下:
- 流量洪峰 :大促、热点事件,瞬间 QPS 打爆数据库连接池、把服务拖死,然后雪崩------一个服务挂了,依赖它的服务跟着全挂;
- 恶意刷接口:登录接口被脚本暴力撞库、短信接口被刷、爬虫高频抓取,不仅消耗资源,还有安全风险。
限流就是在流量进入系统前先设一道闸:超过阈值的请求直接拒绝,保证系统在能力范围内稳定运行。它和熔断(服务挂了快速失败)、降级(牺牲非核心功能)合称"高可用三板斧"。
但限流有个关键问题:在哪儿限、按什么维度限? 这正是本文要讲的------我们在网关层用 Sentinel 管"面",在业务层用 Redis+Lua 管"点"。
二、两层限流架构
2.1 架构总览
客户端请求
│
▼
┌─────────────────────────────────────────────┐
│ 网关层:Sentinel(管"面") │
│ 全局限流 all-api 2000 QPS │
│ 按服务 pmhub-auth 500 QPS │
│ 单接口 login-api 100 QPS │
│ 超限 → "请求超过最大数,请稍候再试" │
└─────────────────────────────────────────────┘
│ 放行
▼
┌─────────────────────────────────────────────┐
│ 业务层:Redis+Lua(管"点") │
│ L1 IP 限流 60s/20/IP → 挡单IP机器刷 │
│ L2 账号限流 60s/10/账号 → 挡换IP打账号 │
│ L3 账号锁定 错5次锁10分钟 → 挡撞库 │
└─────────────────────────────────────────────┘
2.2 为什么有 Sentinel,还要自己实现一层?
| 维度 | Sentinel(网关层) | Redis+Lua(业务层) |
|---|---|---|
| 管什么 | 流量"大小"(QPS/并发) | 流量"对象"(IP、账号) |
| 计数粒度 | 按资源(QPS 全局计数) | 按 IP/账号(业务 key) |
| 典型场景 | 防洪峰、保护下游 | 防暴力破解、防刷 |
| 规则管理 | 控制台 + Nacos 持久化,运维较重 | 注解/代码,灵活嵌入业务 |
| 依赖 | 独立运行,不依赖业务代码 | 依赖 Redis(后面会谈宕机风险) |
核心原因:登录防刷需要"业务语义"的维度,而 Sentinel 默认做不到。
- Sentinel 的
login-api限流是全局 QPS :100 QPS 就是"整个系统登录接口每秒最多 100 次",它分不清这 100 次是 1 个 IP 打的还是 100 个用户打的; - 而暴力破解的典型特征是单 IP 高频 和单账号高频:攻击者换着 IP 打同一个账号,Sentinel 的 QPS 规则毫无感知(每个 IP 的流量都远低于阈值);
- 所以要再下沉一层:Redis 计数天然支持任意维度的 key ------
rate_limit:login:{ip}、rate_limit:login:user:{username},想按什么维度限就按什么维度限。
一句话:Sentinel 管"面"(入口流量大小),Redis+Lua 管"点"(具体到 IP/账号),两层互补,缺一不可。
三、Sentinel 全局流控(网关层)
3.1 网关集成
网关引入 Sentinel Gateway 相关依赖后,配置两个数据源,规则持久化到 Nacos:
yaml
spring:
cloud:
sentinel:
transport:
dashboard: 127.0.0.1:8858 # 控制台,服务通过 8719 上报
datasource:
ds1: # 流控规则(gw-flow)
nacos: { dataId: sentinel-pmhub-gateway, rule-type: gw-flow }
ds2: # API 分组定义(gw-api-group)
nacos: { dataId: sentinel-pmhub-gateway-api-group, rule-type: gw-api-group }
3.2 规则:全局限流 + 单接口限流
Sentinel 网关限流的 resource 可以是路由 ID (按服务)或 API 分组(按自定义路径集合)。我们用 API 分组实现两个维度:
API 分组定义 (Nacos sentinel-pmhub-gateway-api-group):
json
[
{ "apiName": "all-api", "predicateItems": [ { "pattern": "/**", "matchStrategy": 1 } ] },
{ "apiName": "login-api", "predicateItems": [ { "pattern": "/auth/login", "matchStrategy": 0 } ] }
]
流控规则 (Nacos sentinel-pmhub-gateway,6 条):
| resource | count | 维度 |
|---|---|---|
| pmhub-auth / system / gen / job | 500 / 1000 / 200 / 300 | 按路由(服务级) |
| all-api | 2000 QPS | 全局限流(网关总入口) |
| login-api | 100 QPS | 单接口限流(/auth/login) |
超限请求由统一 fallback 处理,返回"请求超过最大数,请稍候再试"。规则在控制台可视化查看、在线修改,并同步回 Nacos 持久化。
四、Redis+Lua 业务层限流
4.1 三层防刷设计
L1 IP 维度限流 : 60 秒 / 20 次 / IP 固定窗口(Redis+Lua)
L2 账号维度限流 : 60 秒 / 10 次 / 账号 防"换 IP 打同一账号"
L3 账号失败锁定 : 连续错 5 次 → 锁 10 分钟 防撞库
4.2 L1:IP 限流 ------ 注解 + Lua 固定窗口
固定窗口的 Lua 脚本(Redis 原子执行 INCR + EXPIRE):
lua
local key = KEYS[1]
local count = tonumber(ARGV[1])
local time = tonumber(ARGV[2])
local current = redis.call('get', key)
if current and tonumber(current) > count then
return tonumber(current)
end
current = redis.call('incr', key)
if tonumber(current) == 1 then
redis.call('expire', key, time)
end
return tonumber(current)
业务侧通过注解声明,切面自动执行:
java
@RateLimiter(key = "rate_limit:login", time = 60, count = 20, limitType = LimitType.IP)
@PostMapping("login")
public AjaxResult login(@RequestBody LoginBody form) { ... }
4.3 L2:账号维度限流 ------ 防 IP 轮换
单 IP 限流挡不住攻击者换 IP,所以加账号维度。限流 key 需要从请求体里取 username,注解拿不到参数,直接在登录逻辑里用 Redis 原子自增:
java
// key: rate_limit:login:user:{username},60 秒内最多 10 次
String userRateKey = CacheConstants.RATE_LIMIT_KEY + "login:user:" + username;
Long userCount = redisService.increment(userRateKey);
if (userCount != null && userCount == 1) {
redisService.expire(userRateKey, 60, TimeUnit.SECONDS);
}
if (userCount != null && userCount > 10) {
throw new ServiceException("登录尝试过于频繁,请稍后再试");
}
4.4 L3:账号失败锁定(防撞库的兜底)
登录密码错误次数按账号计数,错 5 次锁 10 分钟------这是最后一道闸:即使前两层都被绕过,撞库者也拿不到密码。
4.5 三层如何联合
每个请求按顺序过三道闸,被上一层挡住的根本到不了下一层:
| 层 | 挡住(实测 1000 请求) | 响应文案 |
|---|---|---|
| L1 IP 限流 | 980 | 访问过于频繁,请稍候再试 |
| L2 账号限流 | 10(L1 放行的 20 个里) | 登录尝试过于频繁 |
| L3 密码/锁定 | 10(最后漏网) | 密码输入错误5次,帐户锁定10分钟 |
| 登录成功 | 0 | --- |
每一层都容忍少量漏网,因为下一层兜底------这就是"分层防御"的意义。
五、解决了什么问题
5.1 解决的问题
- 暴力破解:单 IP 高频刷 → L1;换 IP 打同一账号 → L2;撞库 → L3;
- 可观测:每层响应文案区分,限流命中打 WARN 日志(IP、key、计数),配合监控日志能看"拦截现场"。
5.2 jmeter 压测结果(实测)
场景:模拟单 IP 每分钟 1000 次暴力尝试(恒定吞吐量定时器 1000 次/分钟,54 秒跑完):
总请求: 1000
被限流拦截: 990(980 IP限流 + 10 账号限流)
其余: 10(密码错误/账号锁定,同样未成功)
════════════════════════════
直接限流拦截率: 99.0% ≥ 95% ✅
实际登录成功: 0


六、后续思考:Redis 宕机了怎么办?
我们的业务层限流强依赖 Redis,而 Redis 本身是单点------限流组件反而成了新的故障源,这是必须考虑的问题:
| 方案 | 思路 | 权衡 |
|---|---|---|
| 本地兜底降级 | Redis 不可用时,降级到 Guava RateLimiter 本地限流 | 实现简单,但多实例下各自计数、不精确;作为"兜底"够用 |
| 限流开关 | 配置中心加开关,Redis 故障时先关闭限流保可用 | 可用性优先,代价是防刷暂时失效 |
| Redis 高可用 | 哨兵/集群模式 | 基础设施升级,复杂度上升 |
| 多级限流 | 网关 Sentinel 不依赖 Redis,天然兜底 | 我们已有此结构:Redis 挂了下游还有 Sentinel 挡入口流量 |
| 失败不阻断 | 限流组件异常时返回"放行"并告警,而不是报错 | 避免限流组件故障拖垮业务 |
我的取舍 :结合本项目的两层架构------Redis 宕机时,网关 Sentinel 仍然在挡入口流量(它不依赖 Redis),业务层再降级到本地计数兜底,并把故障记日志告警。"限流组件本身也是高危单点,必须设计降级路径"。
七、总结:收获与感想
收获
- 概念 → 落地的距离:固定窗口、滑动窗口、令牌桶这些概念背得再熟,不写代码不压测都是空的。
- 分层设计思维:Sentinel 管面(入口 QPS)、Redis+Lua 管点(IP/账号),每层容忍漏网、下一层兜底。
- 考虑故障:限流救系统,但限流组件挂了怎么办?想清楚降级路径,才能说"可上线"。
感想
一个"限流"功能,从表面看是加个注解、写段 Lua,但真正做完才发现它横跨了网关、缓存、业务、压测、故障设计 五个层面。这也印证了那句话:微服务没有银弹,但每一个看似简单的组件,认真做都能挖出深度。