一个切面,两条过滤器链,
pjp.proceed()有且仅有一次;限流、日志、指标、告警都是链上可插拔的过滤器。引入一个依赖,什么都不配就生效。
一、我的项目里,接口治理是这么"长"出来的
先交代背景。我负责的服务里,治理能力是一年多里陆续加的,长出来这么一串:
-
一个
ApiLogAspect:@Around包住所有 Controller,记日志、算耗时; -
有接口被刷了,加了一个限流切面,方法上贴注解;
-
要接 Prometheus,又加了一层 Micrometer 埋面;
-
领导要看慢方法,再叠一个切面。
四个 @Around 叠在同一个方法上,能跑,但我被坑过几次:
-
执行顺序靠猜。四个
@Order数字,出了问题没人说得清"限流和日志到底谁先执行",包括写代码的我自己。 -
pjp.proceed()被调了 N 次。每一层都调一次,我在其中一层改逻辑时手滑多调了一次 proceed,那次的排查经历不想回忆。 -
能力不可插拔。想临时关掉日志验证一个问题?改代码重新发版。治理能力和业务代码长在了一起。
-
指标互相污染。耗时统计包在最外层,被限流拒绝的请求也进了耗时分布——监控上 P99 突然变高,排查半天,其实只是限流生效了。
排查第 4 个问题的那晚我想明白了:问题不在哪个切面写得不好,而在于我把一个"集合"硬拆成了一堆孤立切面。
后来想起 Servlet 有 Filter 链,Spring Cloud Gateway 有 GatewayFilter,Dubbo 有 Filter 链——处理"请求依次经过一组可插拔的处理单元"这件事,业界早就验证过答案:管道。于是我花了几周把这套治理重写成了管道,就是这篇文章要分享的 Starter。
二、核心设计:一个切面 + 两条过滤器链
做法是只保留一个 AOP 切面作为唯一入口,切面内部把一次请求的完整生命周期拆成两条过滤器链:
Controller 请求
│
前置链:信息采集(1) → 流量统计(100) → 限流判断(200) → 自定义
│ │ 任一过滤器返回 false 即短路,业务方法不执行
业务方法(pjp.proceed() 有且仅有一次)
│
后置链:耗时统计(400) → 日志记录(500) → 自定义
│
响应
重构完回头看,上面被坑的四个问题分别变成了这样:
1. 顺序是结构决定的,不用再记。 前置链按 @Order 从小到大,后置链同理。限流(200)在统计(100)之后、日志(500)在耗时(400)之后,每个位置写在代码里。
2. proceed() 有且仅有一次,在两条链之间。过滤器只做决策:返回 true 放行,返回 false 短路拒绝(可以自定义 400/429/503)。"哪层切面忘了放行"这类事故面直接消失了。
3. 一切皆插件。 实现一个 PreFilter / PostFilter 注册为 Bean 就自动入链:
@Component
@Order(300)
public class ParamCheckFilter implements PreFilter {
@Override
public boolean doFilter(FilterContext ctx) {
if (ctx.getArgs() == null || ctx.getArgs().length == 0) {
ctx.setRejectStatus(400);
ctx.setRejectReason("参数缺失");
return false; // 短路:业务方法不会执行
}
return true;
}
}
5 个内置过滤器也都能通过 api.governance.filters.* 单独开关,或注册同类型 Bean 覆盖。自定义过滤器还能直接拿到真实请求上下文:getRequestUri()(路径变量实际值)、getClientIp()(X-Forwarded-For → X-Real-IP → remoteAddr)。
4. 指标不被污染是白捡的。 耗时统计(400)在后置链上,只有真正执行了的请求才会走到这里——被限流拒绝的请求在(200)就短路了,Micrometer Timer 里天然没有它们。
得承认,管道模式不是我发明的,Servlet 和 Gateway 早就用烂了。这次重构的价值在于把它落到方法级治理这个场景——落地时踩的坑比想象中多,下面挑最有意思的说。
三、30 秒上手
引入依赖:
<dependency>
<groupId>io.github.biglv666</groupId>
<artifactId>api-governance-spring-boot-starter</artifactId>
<version>0.5.0</version>
</dependency>
写一个普通的 Controller,不加任何注解、不改任何代码:
@RestController
@RequestMapping("/api/users")
public class UserController {
@GetMapping("/{id}")
public User get(@PathVariable Long id) {
return userService.findById(id);
}
}
每个请求会自动输出:
[API] GET /api/users/{id} - com.x.UserController#get - 成功 - 耗时: 12ms
管理接口 GET /api-governance/metrics 已经能查到调用次数、成功率、慢方法明细。需要控制时,全项目只有三个注解:
@RateLimit(limit = 100) // 类级默认限流
@RateLimit(limit = 5, window = 60, key = "#request.username") // 按参数独立配额
@NoLog // 关日志,统计仍保留
@Skip // 完全放行(健康检查之类)
下面聊管道里最有意思的那个过滤器:限流。
四、分布式限流过滤器:我踩过的三个坑
本地限流没什么好讲的,ConcurrentHashMap + 惰性过期就够。有意思的是 Redis 分布式限流,我踩了三个坑。
坑一:窗口判定不能用客户端时间
滑动窗口最直觉的实现:Lua 脚本里用 ARGV 传入应用服务器的时间戳,ZREMRANGEBYSCORE 清理窗口外的 member,再 ZCARD 计数。我就是这么写的,自测全过。
直到部署了两个实例联调才发现计数不对:两台机器时钟差了 200ms,对"当前时间"的判断不一样,窗口边界上的计数就不准了——而分布式限流恰恰是为了多实例一致才存在的,用客户端时间等于自废武功。
正确做法是在 Lua 脚本里用 TIME 命令取 Redis 服务器时间,窗口判定和令牌补充都基于它。但 TIME 是非确定性命令,Redis 4.x 及之前的逐字复制模式下,主从复制的是整段脚本而不是效果,从库重放时会产生不同的结果。这个细节我是翻了 Redis 文档才搞明白的,脚本开头必须显式调用:
redis.replicate_commands() -- 切换为效果复制,Redis 3.2~4.x 可用
local t = redis.call('TIME')
local now = t[1] + t[2] / 1000000
Redis 5+ 默认就是效果复制,这个调用幂等无害。
坑二:同毫秒并发,ZSET 的 member 会互相覆盖
滑动窗口用 Sorted Set 计数时,member 得保证唯一。我最开始用「毫秒时间戳 + 线程 ID」,写测试时发现计数偏松——同一毫秒内、线程池复用导致的同线程两次请求,member 完全相同,后一次 ZADD 把前一次覆盖了。换成 UUID 才彻底消除。这个 bug 单测很难抓,压测 + 对账才暴露出来。
坑三:Redis 挂了,限流过滤器该放行还是拒绝?
这是管道上最典型的一个"决策类过滤器"该回答的问题。我纠结了很久,最后把它做成了配置项,因为两种选择都成立:
-
fail-open(放行):可用性优先,Redis 故障时退化为不限流,但业务不受影响; -
fail-close(拒绝):配额优先,Redis 故障时全部返回 503——注意是 503 而不是 429,429 语义是"你请求太快",503 语义是"我这边限流组件故障",运维看监控能一眼区分,而不是把系统故障误判成用户刷接口。
无论哪种策略,故障都会触发 RATE_LIMITER_FAILURE 告警(可以直接推钉钉群)。
五、SpEL 按参数限流:好用,但有个安全边界你得知道
@RateLimit(key = "#id") 一个注解就能按参数值独立配额:
@GetMapping("/users/{id}")
@RateLimit(limit = 10, key = "#id")
public User get(@PathVariable Long id) { ... }
实现上用的是受限的 SimpleEvaluationContext:不允许类型引用、构造器调用和 Bean 引用,表达式解析失败自动回退接口级限流,只打 warn 不影响业务。这套防御是为了杜绝 SpEL 注入——StandardEvaluationContext 加上客户端可控输入,是可以直接打出 RCE 的。
但这里有一个更隐蔽的问题,是 review 的同事提醒我的:当表达式结果由客户端可控输入派生时(用户名、IP、任意 ID),每个新取值都从满配额开始。攻击者只要遍历一万个不同的 id,就等于拥有一万个独立的 10 次配额。所以:
-
「按参数限流」的正确用途是租户间公平性(防止一个租户耗尽共享配额);
-
它不能作为防爆破等安全边界使用;
-
防爆破场景应该叠加接口级总配额,或者实现
RateLimitKeyResolverBean 组合 IP 等低基数维度。
另外高基数 key 还有内存风险,本机限流器的键数量上限(max-entries)就是为这个准备的,键超限会淘汰。
六、管道之外:可观测性这条"暗线"
管道负责决策,治理还差另一半:看得见。
-
Micrometer 桥接:
api.governance.requests(Counter,outcome 分 success/error/reject)、api.governance.request.duration(Timer)、api.governance.apis.tracked(Gauge)。api标签是全限定类名#方法名,基数上限就是 Controller 方法数,不会标签膨胀,对接 Prometheus/Grafana 零配置。 -
告警:慢方法 / 限流拒绝 / 限流器故障 / 异步任务被拒四类事件,内置 Webhook 通知器基于 JDK HttpClient(零第三方依赖),钉钉支持加签(HMAC-SHA256)。同一个
(类型, apiKey)默认 10 秒内只发一次,防告警风暴把群刷爆。 -
内存指标防膨胀:每个 API 的最近记录是有界滑动窗口(条数 + 时长双上限),全局 API 数量 LRU 淘汰。这个不做,内存型指标组件运行久了就是一个 OOM 定时炸弹。
-
异步钩子(0.5.0):
@AsyncAction+@AsyncHandler可以给任意 Spring Bean 方法挂四阶段旁路任务(写日志、发通知之类),带启动期校验——Handler 引用了不存在的 action 直接启动失败,拼写错误不再静默失效。
七、写在最后
这次重构让我对"横切关注点"有了新的理解:它们不该是 N 个并行的切面,而是一条有确定顺序的管道。
完整代码和文档(中文注释、升级指南、最小可运行示例工程都在):
Maven Central 可直接引入,坐标见上文。欢迎提 issue 和 PR,也欢迎在评论区指正设计里考虑不周的地方。
最后留两个问题,评论区聊聊:
-
你项目里的治理能力(日志/限流/指标)现在是独立切面还是统一管道?叠过几个
@Around? -
你们线上的限流故障策略是 fail-open 还是 fail-close?有没有因为选错吃过亏?
下一篇计划把这条管道的扩展机制拆开讲:怎么写一个生产级的自定义过滤器(含 FilterContext 的完整能力清单和一个脱敏过滤器的完整实现),感兴趣的关注一下不迷路。