第2章:审计拦截器工作原理

审计拦截器到底怎么工作的?说白了,就是在SQL语句真正执行前插一脚,把它拦下来看一眼,再决定放不放行。我在工程里见过不少团队把这层想得太玄乎,其实核心就三个东西:拦截点、代理模式、反射机制。今天咱们一个一个拆开讲。

2.1 拦截点:在哪儿下手

拦截点决定了你的审计逻辑挂在SQL生命周期的哪个阶段。选错了位置,要么漏审,要么性能爆炸。我个人习惯把拦截点分成三层:

拦截层次 典型位置 特点 我见过的坑
应用层 ORM 框架(MyBatis、JPA)拦截器 可拿到完整的 SQL 和参数 容易拦截到已拼接的字符串,误判多
连接池层 Druid、HikariCP 的 Filter 在连接复用前统一处理 拦截粒度粗,只能看到原始 SQL
数据库驱动层 JDBC Proxy / P6Spy 最底层的原生协议包 性能损耗大,配置复杂

你想想看,为什么大部分人选应用层?因为那里能拿到最终要执行的SQL文本,还有参数列表、执行时间、调用栈。我记得有一次线上频繁报慢查询,查到最后发现是MyBatis拦截器里做了字段级的脱敏,但写在了应用层之前的拦截点,导致脱敏没生效。嗯,这就是拦截点选错位的典型。

个人建议: 优先在应用层 ORM 拦截器下手。如果数据安全等级高,再加一层连接池层的旁路审计。两层不要互相覆盖,否则日志翻倍。

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,还要拿到执行上下文——谁调用的?哪个方法?用了什么参数?这些动态信息靠硬编码不行,必须靠反射。反射在审计拦截器里主要干三件事:

  1. 获取调用栈:Thread.currentThread().getStackTrace(),定位到业务方法的包名、类名、行号。
  2. 获取注解信息:比如从Method对象上读取@AuditSensitive,判断这条SQL是否需要脱敏。
  3. 获取参数值:通过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 (MyBatis/JPA拦截器) 连接池层 (Druid/HikariCP Filter) 数据库驱动层 (JDBC Proxy / P6Spy) 代理模式 JDK动态代理/CGLIB 透明包装原始对象 重写prepareStatement 反射机制 getStackTrace定位调用者 注解解析 (Meta-Data) 参数值获取 拦截点选择 应用层(推荐) 连接池层(辅助) 驱动层(备选) 三者配合流程 ① 应用层拦截器(代理)捕获SQL ② 反射获取调用栈、方法注解、参数值 ③ 审计规则引擎判断是否放行/脱敏/阻断 ④ 放行后传给连接池层或驱动层继续执行 ⑤ 执行结果回传给应用层,日志完成

从图上能看出来,最推荐的做法是:在应用层ORM拦截器中用代理模式包裹连接对象,再用反射拿到调用上下文。这样既不会穿透到底层影响性能,又能拿到最完整的审计信息。我当时的项目就是这么干的,线上跑了两年,审计日志从没漏过一条。

2.5 避坑总结

  • 拦截点不要重叠。 两个拦截器都拦截同一批SQL,日志会翻倍,而且可能互相阻塞。我在一个微服务里见过Druid Filter和MyBatis拦截器同时生效,结果每次执行SQL都被审计两次,性能下降30%。
  • 代理对象要递归代理。 Connection→Statement→ResultSet,每一层的代理都要跟进去,否则总有漏网之鱼。
  • 反射性能是瓶颈。 尤其是getStackTrace和invoke,不要在每个SQL里都调。缓存调用者路径,只对第一次执行做反射分析。
  • 别忘了处理异常。 审计逻辑本身不能影响业务。我曾经遇到过拦截器里反射抛InvocationTargetException,没有捕获,直接把业务请求炸了。那时候真是冷汗直冒。

理解了这三个机制,你就掌握了审计拦截器的骨架。下一层就是在骨架上填规则引擎、脱敏算法、日志管道。不过那是后面的事,咱们先把原理吃透再说。