基于 MyBatis 拦截器的透明数据审计实现
业务代码零侵入,一个
@EnableAudit注解即可开启全量数据变更追踪。
一、设计目标
核心命题:如何让审计功能对业务开发者完全透明?
- 业务代码不需要调用任何审计 API
- 不需要修改任何 Service / Mapper 代码
- 不需要关心数据是 lambdaUpdate、updateById 还是自定义 XML 写入的
- 只需在实体类上加一个
@EnableAudit注解,审计自动生效
最终效果:
// 实体类:加一个注解即可@EnableAudit(moduleCode="FLEET")@TableName("biz_fleet")publicclassBizFleetextendsBaseEntity{...}// 业务代码:完全无感知,三种写法全部自动审计bizFleetMapper.updateBizFleet(bizFleet);// 自定义 XMLthis.lambdaUpdate().set(BizFleet::getCarCount,2).eq(...).update();// MP lambdaUpdatethis.updateById(bizFleet);// MP 内置方法二、整体架构
┌─────────────────────────────────────────────────────┐ │ 业务代码层 │ │ lambdaUpdate / updateById / 自定义 XML Mapper │ └──────────────────────┬──────────────────────────────┘ │ 所有写操作统一经过 ▼ ┌─────────────────────────────────────────────────────┐ │ MyBatis Executor.update() │ │ ← AuditInterceptor 在此拦截 │ │ │ │ ┌─────────────┐ ┌──────────────┐ ┌────────────┐ │ │ │ 解析表名 │→│ 检查 @EnableAudit │→│ 构建 SELECT │ │ │ │ (JSqlParser)│ │ (注解扫描缓存) │ │ (SQL 重写) │ │ │ └─────────────┘ └──────────────┘ └────────────┘ │ │ │ │ │ ┌────────────────────────────────────────────────┐ │ │ │ 参数解析(核心难点) │ │ │ │ BoundSql.additionalParameters │ │ │ │ + Wrapper.getParamNameValuePairs() │ │ │ │ + ParameterMapping.property 路径导航 │ │ │ └────────────────────────────────────────────────┘ │ │ │ │ │ ┌────────────┐ ┌────────────┐ ┌──────────────┐ │ │ │ Before Image│→│ 执行原 SQL │→│ After Image │ │ │ │ (SELECT) │ │ (proceed) │ │ (SELECT) │ │ │ └────────────┘ └────────────┘ └──────────────┘ │ │ │ │ │ ┌────────────────────────────────────────────────┐ │ │ │ Diff 计算 + 审计日志保存 │ │ │ │ 独立事务(REQUIRES_NEW) 不随业务回滚 │ │ │ └────────────────────────────────────────────────┘ │ └─────────────────────────────────────────────────────┘三、关键技术节点
3.1 拦截层选择:为什么是Executor.update()
MyBatis 提供了四个可拦截入口:
| 拦截层 | 能力 | 本方案选择 |
|---|---|---|
Executor | 拦截 SQL 执行,能拿到完整参数对象 | 选择 |
StatementHandler | 拦截 JDBC Statement,只能拿到 SQL 字符串 | 不选 |
ParameterHandler | 只负责设置参数 | 不选 |
ResultSetHandler | 只负责结果集映射 | 不选 |
选择Executor.update()的关键原因:
- 所有写操作的统一入口:INSERT / UPDATE / DELETE 最终都经过
Executor.update() - 能拿到原始参数对象:包括 MP 的 Wrapper 对象、实体对象、Map 参数,这是后续参数解析的基础
- 能在 SQL 执行前后插入逻辑:实现 Seata AT 模式的 Before Image / After Image
@Intercepts({@Signature(type=Executor.class,method="update",args={MappedStatement.class,Object.class})})publicclassAuditInterceptorimplementsInterceptor{...}3.2 表名解析:JSqlParser SQL 解析引擎
拦截到 SQL 后,第一步是从 SQL 中提取表名。
使用 JSqlParser 将 SQL 字符串解析为 AST(抽象语法树),支持 UPDATE / DELETE / INSERT 三种语句:
Statementstatement=CCJSqlParserUtil.parse(boundSql.getSql());if(statementinstanceofUpdate){StringtableName=((Update)statement).getTable().getName();}为什么不用正则或字符串截取?
- SQL 可能有子查询、别名、多表 JOIN
- JSqlParser 是标准 SQL 解析器,能正确处理所有合法 SQL
- 后续还需要提取 WHERE 子句用于 SQL 重写
3.3 注解驱动的审计控制:@EnableAudit
@Target(ElementType.TYPE)@Retention(RetentionPolicy.RUNTIME)public@interfaceEnableAudit{StringmoduleCode()default"";// 业务模块编码String[]includeFields()default{};// 只审计这些字段(空=全部)String[]excludeFields()default{};// 排除这些字段booleanignoreNull()defaulttrue;// 新旧值都为 null 时跳过}初始化时机:拦截器首次触发时,扫描 MyBatis Configuration 中所有已注册的实体类,检查@TableName+@EnableAudit注解组合,构建表名 → AuditMeta缓存。
// 延迟初始化:首次拦截时扫描privatevoidensureInitialized(Configurationconfiguration){for(TableInfotableInfo:TableInfoHelper.getTableInfos()){Class<?>entityClass=tableInfo.getEntityType();EnableAuditannotation=entityClass.getAnnotation(EnableAudit.class);if(annotation!=null){auditMetaCache.put(tableName,newAuditMeta(annotation,entityClass,tableInfo));}}}没有注解的表直接跳过,零性能损耗。
3.4 SQL 重写:UPDATE/DELETE → SELECT
审计需要查询数据的 Before Image 和 After Image,本质是把写操作转为读操作。
核心思路:提取原 SQL 的 WHERE 子句,构造 SELECT 语句。
原始 SQL:UPDATE biz_fleet SET car_count = ? WHERE (id = ?) 重写 SQL:SELECT * FROM biz_fleet WHERE (id = ?) LIMIT 1000实现步骤:
- JSqlParser 解析原始 SQL,提取表名和 WHERE 子句
- 拼接
SELECT * FROM {表名} WHERE {WHERE 子句} LIMIT 1000 - 统计 WHERE 子句中
?占位符数量,用于后续参数映射
privateSqlBuildResultbuildSelectSql(StringoriginalSql){Statementstatement=CCJSqlParserUtil.parse(originalSql);// 提取表名和 WHERE 子句StringtableName=((Update)statement).getTable().getName();StringwhereClause=((Update)statement).getWhere().toString();// 构造 SELECTintwherePlaceholderCount=countPlaceholders(whereClause);returnnewSqlBuildResult("SELECT * FROM "+tableName+" WHERE "+whereClause,wherePlaceholderCount);}WHERE 参数提取:UPDATE SQL 中 SET 参数在前,WHERE 参数在后。通过占位符数量从ParameterMapping列表末尾截取 WHERE 对应的参数映射:
ParameterMappings: [SET_car_count, SET_name, ..., WHERE_id] ↑ 截取最后 N 个 WHERE 占位符数 = 1 → 取最后 1 个 ParameterMapping3.5 参数解析:最核心的难点(重点)
SQL 重写后,需要将 WHERE 子句的?绑定到实际参数值。这是整个实现中最复杂的部分,因为不同写操作方式的参数结构完全不同。
三种场景的参数结构对比
| 场景 | parameter 类型 | ParameterMapping.property | 参数值位置 |
|---|---|---|---|
| 自定义 XML | 实体对象 | id、name等简单属性 | 实体字段反射 |
| MP updateById | Map{et=entity} | et.id、et.name | Map → et → 反射 |
| MP lambdaUpdate | Map{ew=Wrapper} | ew.paramNameValuePairs.MPGENVAL1 | Wrapper 内部 Map |
lambdaUpdate 的参数传递链路(核心)
这是最复杂的场景,也是踩坑最多的地方。完整链路:
1. 业务代码 this.lambdaUpdate().set(BizFleet::getCarCount, 2).eq(BizFleet::getId, 1).update() 2. MP 内部生成 SQL 模板:UPDATE biz_fleet SET car_count=#{ew.paramNameValuePairs.MPGENVAL1} WHERE (id = #{ew.paramNameValuePairs.MPGENVAL2}) Wrapper 内部:paramNameValuePairs = {MPGENVAL1=2, MPGENVAL2=1} 3. BoundSql 构建 SQL:UPDATE biz_fleet SET car_count=? WHERE (id = ?) ParameterMappings: [0] property = "ew.paramNameValuePairs.MPGENVAL1" [1] property = "ew.paramNameValuePairs.MPGENVAL2" additionalParameters: "ew" → Wrapper 对象 "ew.paramNameValuePairs" → {MPGENVAL1=2, MPGENVAL2=1} 4. 拦截器拿到 parameter = Map{ew=Wrapper对象} boundSql.getParameterMappings() = 上述列表参数解析的五层查找策略
privateObjectresolveParameterValue(Objectparameter,ParameterMappingpm,BoundSqlboundSql){Stringproperty=pm.getProperty();// 例如 "ew.paramNameValuePairs.MPGENVAL2"// 第 1 层:BoundSql 附加参数(简单 key 场景)if(boundSql.hasAdditionalParameter(property)){returnboundSql.getAdditionalParameter(property);}// 第 2 层:MP Wrapper 专属 —— 从 paramNameValuePairs Map 直接取值// property = "ew.paramNameValuePairs.MPGENVAL2"// 提取 lastKey = "MPGENVAL2"// 从 BoundSql 获取 "ew.paramNameValuePairs" → {MPGENVAL1=2, MPGENVAL2=1}// 然后 get("MPGENVAL2") → 1if(property.startsWith("ew.paramNameValuePairs.")){StringlastKey=property.substring("ew.paramNameValuePairs.".length());ObjectpairsObj=boundSql.getAdditionalParameter("ew.paramNameValuePairs");if(pairsObjinstanceofMap){return((Map)pairsObj).get(lastKey);}// 兜底:反射调用 Wrapper.getParamNameValuePairs()Objectwrapper=((Map)parameter).get("ew");Objectpairs=invokeGetMethod(wrapper,"getParamNameValuePairs");if(pairsinstanceofMap){return((Map)pairs).get(lastKey);}}// 第 3 层:通用路径导航(支持任意深度的点分隔路径)Objectvalue=navigatePath(parameter,property);if(value!=null)returnvalue;// 第 4 层:反射获取实体字段值Fieldfield=parameter.getClass().getDeclaredField(property);returnfield.get(parameter);}踩坑记录:paramNameValuePairs不是 Wrapper 的字段
最初我们尝试用路径导航解析ew.paramNameValuePairs.MPGENVAL1:
第 1 步:从 Map 取 "ew" → 得到 Wrapper 对象 ✓ 第 2 步:从 Wrapper 取 "paramNameValuePairs" → 反射找不到!✗原因:paramNameValuePairs是AbstractWrapper方法内的局部变量,不是类的成员字段。它通过boundSql.setAdditionalParameter("ew.paramNameValuePairs", map)注册到了 BoundSql 的附加参数中。
解决方案:不走反射,直接从boundSql.getAdditionalParameter("ew.paramNameValuePairs")获取整个 Map,再用lastKey取值。
3.6 JDBC 直接查询:事务内可见性
Before/After Image 的查询必须在当前事务的 Connection 上执行,否则:
- Before Image 查不到未提交的数据
- After Image 在事务提交前查不到变更后的数据
// 获取当前事务的 Connectionjava.sql.Connectionconn=executor.getTransaction().getConnection();// 在当前 Connection 上执行 SELECT(复用事务上下文)ps=conn.prepareStatement(selectSql);注意:不关闭 Connection(属于事务),只关闭 PreparedStatement 和 ResultSet。
3.7 Diff 计算:字段级差异对比
Before Image 和 After Image 都是List<Map<String, Object>>(行数据),按主键值建立索引后逐行对比:
// 按主键索引Map<String,Map<String,Object>>beforeMap=indexByPk(beforeImage,meta);Map<String,Map<String,Object>>afterMap=indexByPk(afterImage,meta);// 逐行逐字段对比for(StringpkValue:allKeys){Map<String,Object>oldData=beforeMap.getOrDefault(pkValue,emptyMap());Map<String,Object>newData=afterMap.getOrDefault(pkValue,emptyMap());List<Map<String,Object>>diffList=computeDiff(oldData,newData,meta);}Diff 计算会:
- 跳过系统字段(createBy、createTime、updateBy、updateTime)
- 遵守
@EnableAudit的 includeFields / excludeFields 配置 - 通过
TableInfo的列名→字段名映射,输出业务可读的字段名
3.8 SPI 扩展点设计
审计插件通过 SPI 接口实现完全可插拔:
| SPI 接口 | 职责 | 默认实现 |
|---|---|---|
AuditOperatorProvider | 获取当前操作人信息 | SecurityContext 实现 |
AuditDataSerializer | 数据序列化方式 | JSON 实现 |
AuditFilter | 过滤哪些操作需要审计 | 全量审计 |
AuditStorageStrategy | 审计记录存储策略 | 数据库存储(独立事务) |
业务方可替换任意 SPI 实现,无需修改拦截器代码。
3.9 循环依赖的规避
将审计拦截器注册到SqlSessionFactory时,如果直接注入SqlSessionFactory作为@Bean方法参数,会触发 Spring 的循环依赖:
AuditAutoConfiguration → SqlSessionFactory → sqlSessionTemplate → AuditLogMapper → AuditAutoConfiguration解决方案:
auditInterceptorRegistrar改为ApplicationListener<ContextRefreshedEvent>,在所有 Bean 创建完成后才注册拦截器,回调内通过ApplicationContext.getBean()延迟获取AuditLogMapper参数加@Lazy,注入懒代理打破循环链
四、数据流全景
以lambdaUpdate().set(carCount, 2).eq(id, 1)为例:
1. 业务调用 bizFleetService.lambdaUpdate().set(BizFleet::getCarCount, 2).eq(BizFleet::getId, 1).update() │ 2. MP 生成 BoundSql SQL: UPDATE biz_fleet SET car_count=? WHERE (id = ?) ParameterMappings: [ew.paramNameValuePairs.MPGENVAL1, ew.paramNameValuePairs.MPGENVAL2] additionalParameters: {"ew.paramNameValuePairs": {MPGENVAL1=2, MPGENVAL2=1}} │ 3. AuditInterceptor.intercept() 拦截 ├── parseTableName() → "biz_fleet" ├── auditMetaCache.get("biz_fleet") → AuditMeta(有 @EnableAudit) │ 4. 构建 SELECT SQL buildSelectSql() → "SELECT * FROM biz_fleet WHERE (id = ?) LIMIT 1000" extractWhereParameterMappings() → 取最后 1 个 ParameterMapping │ 5. 解析 WHERE 参数值 resolveParameterValue(property="ew.paramNameValuePairs.MPGENVAL2") → boundSql.getAdditionalParameter("ew.paramNameValuePairs") → {MPGENVAL1=2, MPGENVAL2=1} → map.get("MPGENVAL2") → 1 │ 6. 查询 Before Image SELECT * FROM biz_fleet WHERE (id = 1) → [{id=1, car_count=0, name="顺畅车队", ...}] │ 7. 执行原 SQL UPDATE biz_fleet SET car_count=2 WHERE (id = 1) → Updates: 1 │ 8. 查询 After Image SELECT * FROM biz_fleet WHERE (id = 1) → [{id=1, car_count=2, name="顺畅车队", ...}] │ 9. 计算 Diff car_count: 0 → 2 ← 唯一差异字段 │ 10. 保存审计日志(独立事务) sys_operation_audit_log: {module="FLEET", type=UPDATE, operator="admin", ...} sys_operation_audit_log_detail: {table="biz_fleet", old_data=..., new_data=..., diff=[car_count]}五、关键技术栈
| 技术 | 版本 | 用途 |
|---|---|---|
| MyBatis-Plus | 3.5.16 | ORM 框架,提供 TableInfo、Wrapper 等核心 API |
| JSqlParser | 4.x | SQL 解析引擎,提取表名和 WHERE 子句 |
| Spring Boot | 3.x | @AutoConfiguration、@Lazy、ContextRefreshedEvent |
| Java Reflection | JDK 21 | 实体字段值提取、Wrapper 方法调用 |
六、总结
透明审计的本质是在 MyBatis 执行层做一个"透明代理":
- 拦截层选择决定了能否拿到完整参数对象 →
Executor.update() - SQL 解析决定了能否正确处理任意 SQL → JSqlParser AST 解析
- 参数解析决定了能否正确绑定 WHERE 条件 → 五层查找策略 + BoundSql 附加参数
- 事务可见性决定了能否查到正确的前后镜像 → 复用事务 Connection
- 注解驱动决定了是否对业务透明 →
@EnableAudit按需开启
最终实现:业务代码零改动,加一个注解,三种写法全支持。