第2章:审计拦截器工作原理
审计拦截器到底怎么工作的?说白了,就是在SQL语句真正执行前插一脚,把它拦下来看一眼,再决定放不放行。我在工程里见过不少团队把这层想得太玄乎,其实核心就三个东西:拦截点、代理模式、反射机制。今天咱们一个一个拆开讲。
2.1 拦截点:在哪儿下手
拦截点决定了你的审计逻辑挂在SQL生命周期的哪个阶段。选错了位置,要么漏审,要么性能爆炸。我个人习惯把拦截点分成三层:
| 拦截层次 | 典型位置 | 特点 | 我见过的坑 |
|---|---|---|---|
| 应用层 | ORM 框架(MyBatis、JPA)拦截器 | 可拿到完整的 SQL 和参数 | 容易拦截到已拼接的字符串,误判多 |
| 连接池层 | Druid、HikariCP 的 Filter | 在连接复用前统一处理 | 拦截粒度粗,只能看到原始 SQL |
| 数据库驱动层 | JDBC Proxy / P6Spy | 最底层的原生协议包 | 性能损耗大,配置复杂 |
你想想看,为什么大部分人选应用层?因为那里能拿到最终要执行的SQL文本,还有参数列表、执行时间、调用栈。我记得有一次线上频繁报慢查询,查到最后发现是MyBatis拦截器里做了字段级的脱敏,但写在了应用层之前的拦截点,导致脱敏没生效。嗯,这就是拦截点选错位的典型。
2.2 代理模式:怎么插进去
拦截点定好了,怎么在不改源码的前提下把拦截器塞进去?答案就是代理模式。Java里主流做法是JDK动态代理或者CGLIB。我曾接手过一个老项目,所有DAO都是手写JDBC,没有框架。那时候我怎么加的审计?写了个ConnectionProxy,把原始的Connection对象包一层,在prepareStatement方法里做手脚。
public class AuditConnectionProxy implements InvocationHandler {
private final Connection target;
public AuditConnectionProxy(Connection target) {
this.target = target;
}
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
if ("prepareStatement".equals(method.getName())) {
String sql = (String) args[0];
// 在这里拦截SQL,记录日志
System.out.println("[AUDIT] " + sql);
}
return method.invoke(target, args);
}
}
为什么要用代理?因为拦截器必须是无侵入的。业务代码里不能出现任何审计相关的API,否则哪天想升级或者换掉拦截器,改代码能改到吐血。代理就是一层透明包装,业务方感知不到,但所有SQL经过时都会被我记一笔。
核心思路: 代理对象持有真实对象,重写关键方法(如executeQuery、executeUpdate),在调用前后插入审计逻辑。这样既不改源码,又能保证所有SQL入口都被覆盖。
这里有个容易被忽略的细节:代理模式要处理好链式调用。比如你代理了Connection,获取Statement时返回的也是代理对象,否则后续的SQL会跳过审计。我见过有人只代理了Connection,结果Statement拿到的是原始对象,审计漏了一半。
Proxy.newProxyInstance,但第二个参数interfaces漏写了数据库驱动接口,导致运行时强转失败。后来我养成习惯:写单元测试时先断言proxy instanceof Statement。
2.3 反射机制:怎么取信息
拦截到了SQL,还要拿到执行上下文——谁调用的?哪个方法?用了什么参数?这些动态信息靠硬编码不行,必须靠反射。反射在审计拦截器里主要干三件事:
- 获取调用栈:
Thread.currentThread().getStackTrace(),定位到业务方法的包名、类名、行号。 - 获取注解信息:比如从
Method对象上读取@AuditSensitive,判断这条SQL是否需要脱敏。 - 获取参数值:通过
Method.getParameters()拿到参数名和实际值,用于敏感词匹配。
public static String getCallerMethod(int depth) {
StackTraceElement[] stack = Thread.currentThread().getStackTrace();
// 跳过自己的代理层和框架层
return stack[depth].getMethodName();
}
public static boolean hasAuditAnnotation(Method method) {
return method.isAnnotationPresent(AuditSensitive.class);
}
为什么会用到反射而不是直接传参?因为拦截器是通用的,它不知道业务代码里哪个方法是DAO、哪个是Service。你把getStackTrace放在哪一层,决定了能拿到什么粒度的信息。我习惯把拦截点设在Service层的外围,这样拿到的方法名是人能读懂的(比如createOrder),而不是MyBatis生成的generateMapper_0。
getStackTrace 每次调用会生成整个调用栈数组,频繁调用影响很大。我通常在拦截器里缓存同一个请求的栈信息:用ThreadLocal保存第一次获取到的栈顶5层,后续SQL复用。
2.4 三者如何配合?一张图说清楚
下面这张SVG把拦截点、代理模式、反射机制串联起来了。你可以看到SQL从应用层到底层的完整路径,以及拦截器在哪个环节插了手。
从图上能看出来,最推荐的做法是:在应用层ORM拦截器中用代理模式包裹连接对象,再用反射拿到调用上下文。这样既不会穿透到底层影响性能,又能拿到最完整的审计信息。我当时的项目就是这么干的,线上跑了两年,审计日志从没漏过一条。
2.5 避坑总结
- 拦截点不要重叠。 两个拦截器都拦截同一批SQL,日志会翻倍,而且可能互相阻塞。我在一个微服务里见过Druid Filter和MyBatis拦截器同时生效,结果每次执行SQL都被审计两次,性能下降30%。
- 代理对象要递归代理。 Connection→Statement→ResultSet,每一层的代理都要跟进去,否则总有漏网之鱼。
- 反射性能是瓶颈。 尤其是
getStackTrace和invoke,不要在每个SQL里都调。缓存调用者路径,只对第一次执行做反射分析。 - 别忘了处理异常。 审计逻辑本身不能影响业务。我曾经遇到过拦截器里反射抛
InvocationTargetException,没有捕获,直接把业务请求炸了。那时候真是冷汗直冒。
理解了这三个机制,你就掌握了审计拦截器的骨架。下一层就是在骨架上填规则引擎、脱敏算法、日志管道。不过那是后面的事,咱们先把原理吃透再说。