基于MyBatis拦截器实现数据变更记录的技术方案
作者:蒙眼过河
本文介绍了在日常应用开发中记录业务数据变更的需求与实现方法,设计了整体架构,实现自定义注解、数据变更记录实体、事务同步机制和拦截器注册方案,并通过示例展示了使用方法及常见问题解决方案,最后总结了方案的优势和注意事项,需要的朋友可以参考下
一、背景与需求
在日常应用开发中,数据变更记录是一个常见需求。我们需要记录业务数据的每一次变更,包括:
- 字段级别的变更记录
- 变更前后的值
- 变更人、变更时间
- 支持INSERT/UPDATE/DELETE操作
- 支持单条和批量操作
- 确保数据一致性和准确性
二、整体架构设计
2.1 核心组件
┌─────────────────────────────────────┐
│ Controller/Service层 │
└───────────────┬─────────────────────┘
│
▼
┌─────────────────────────────────────┐
│ Mapper接口(带注解) │
│ @DataChangeLog(businessType) │
└───────────────┬─────────────────────┘
│
▼
┌─────────────────────────────────────┐
│ MyBatis Executor.update() │
│ 拦截器拦截点 │
└───────────────┬─────────────────────┘
│
▼
┌─────────────────────────────────────┐
│ ImprovedDataChangeInterceptor │
│ ┌─────────────────────────────┐ │
│ │ 1. 获取注解/判断是否需要记录 │ │
│ │ 2. 查询原始数据 │ │
│ │ 3. 执行SQL操作 │ │
│ │ 4. 事务提交后查询新数据 │ │
│ │ 5. 对比差异并保存记录 │ │
│ └─────────────────────────────┘ │
└───────────────┬─────────────────────┘
│
▼
┌─────────────────────────────────────┐
│ sys_data_change_log │
│ 数据变更记录表 │
└─────────────────────────────────────┘2.2 数据表设计
CREATE TABLE sys_data_change_log (
id bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
business_type varchar(50) NOT NULL COMMENT '业务类型',
business_id varchar(50) NOT NULL COMMENT '业务数据ID',
field_name varchar(50) NOT NULL COMMENT '字段名称',
field_comment varchar(100) DEFAULT NULL COMMENT '字段注释',
old_value text COMMENT '变更前值',
new_value text COMMENT '变更后值',
change_type varchar(20) DEFAULT 'UPDATE' COMMENT '变更类型',
change_by varchar(50) NOT NULL COMMENT '变更人',
change_time datetime NOT NULL COMMENT '变更时间',
PRIMARY KEY (id),
KEY idx_business (business_type, business_id),
KEY idx_change_time (change_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='数据变更记录表';三、核心代码实现
3.1 自定义注解
package com.demo.framework.aspectj.lang.annotation;
import java.lang.annotation.*;
/**
* 数据变更记录注解
* 标记在Mapper方法上,表示该方法需要记录数据变更
*/
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface DataChangeLog {
/**
* 业务类型
*/
String businessType() default "";
/**
* 是否记录详情(字段级别)
*/
boolean detail() default true;
/**
* 忽略的字段
*/
String[] ignoreFields() default {"createTime", "updateTime", "createBy", "updateBy"};
}3.2 数据变更记录实体
package com.demo.system.domain;
import com.demo.common.annotation.Excel;
import java.util.Date;
/**
* 数据变更记录对象 sys_data_change_log
*/
public class SysDataChangeLog {
private static final long serialVersionUID = 1L;
/** 主键ID */
private Long id;
/** 业务类型 */
@Excel(name = "业务类型")
private String businessType;
/** 业务数据ID */
@Excel(name = "业务数据ID")
private String businessId;
/** 字段名称 */
@Excel(name = "字段名称")
private String fieldName;
/** 字段注释 */
@Excel(name = "字段注释")
private String fieldComment;
/** 变更前值 */
@Excel(name = "变更前值")
private String oldValue;
/** 变更后值 */
@Excel(name = "变更后值")
private String newValue;
/** 变更类型 */
@Excel(name = "变更类型")
private String changeType;
/** 变更人 */
@Excel(name = "变更人")
private String changeBy;
/** 变更时间 */
@Excel(name = "变更时间", dateFormat = "yyyy-MM-dd HH:mm:ss")
private Date changeTime;
// getters and setters...
}
四、数据提交后再记录日志的方案
4.1 事务同步机制
使用Spring的TransactionSynchronization实现事务提交后执行记录操作:
/**
* 注册事务提交后的任务
* 核心:确保数据真正写入数据库后才记录变更日志
*/
private void registerAfterCommitTask(Runnable task) {
if (TransactionSynchronizationManager.isSynchronizationActive()) {
// 事务中:注册事务同步器,事务提交后执行
TransactionSynchronizationManager.registerSynchronization(
new TransactionSynchronization() {
@Override
public void afterCommit() {
task.run();
}
@Override
public void afterCompletion(int status) {
if (status == STATUS_ROLLED_BACK) {
log.debug("事务回滚,不记录变更");
}
}
}
);
} else {
// 无事务:直接执行
log.debug("无事务环境,直接执行");
task.run();
}
}
4.2 更新操作处理流程
/**
* 处理更新操作
* 三步走策略:
* 1. 更新前查询原始数据
* 2. 执行更新操作
* 3. 事务提交后查询新数据并对比记录
*/
private Object handleUpdate(Invocation invocation, MappedStatement ms,
Object parameter, DataChangeLog dataChangeLog) throws Throwable {
log.debug("处理更新操作,业务类型:{}", dataChangeLog.businessType());
// 1. 更新前查询原始数据
Object beforeData = queryDataById(parameter, ms);
log.debug("更新前数据:{}", JSON.toJSONString(beforeData));
// 2. 缓存操作信息
OperationInfo opInfo = new OperationInfo();
opInfo.setBusinessType(dataChangeLog.businessType());
opInfo.setIgnoreFields(dataChangeLog.ignoreFields());
opInfo.setParameter(parameter);
opInfo.setSqlCommandType(SqlCommandType.UPDATE);
opInfo.setMapperId(ms.getId());
opInfo.setBeforeData(beforeData);
OPERATION_INFO_THREAD_LOCAL.set(opInfo);
// 3. 执行更新操作
Object result = invocation.proceed();
// 4. 如果更新成功,在事务提交后处理变更记录
if (Integer.parseInt(result.toString()) > 0) {
registerAfterCommitTask(() -> processUpdateAfterCommit(opInfo));
}
return result;
}
/**
* 事务提交后处理更新操作
*/
private void processUpdateAfterCommit(OperationInfo opInfo) {
try {
// 1. 更新后查询最新数据
Object afterData = queryDataById(opInfo.getParameter(), opInfo.getMapperId());
log.debug("更新后数据:{}", JSON.toJSONString(afterData));
if (opInfo.getBeforeData() != null && afterData != null) {
// 2. 对比数据差异
List<SysDataChangeLog> changeLogs = compareData(
opInfo.getBeforeData(),
afterData,
opInfo.getBusinessType(),
opInfo.getIgnoreFields()
);
// 3. 批量保存变更记录
if (!changeLogs.isEmpty()) {
dataChangeLogService.insertBatch(changeLogs);
log.info("保存{}条更新变更记录", changeLogs.size());
}
}
} catch (Exception e) {
log.error("处理更新后变更记录失败", e);
}
}
4.3 数据对比工具方法
/**
* 对比数据差异
* 支持日期、数字类型的精确比较
*/
private List<SysDataChangeLog> compareData(Object beforeData, Object afterData,
String businessType, String[] ignoreFields) {
List<SysDataChangeLog> changeLogs = new ArrayList<>();
try {
Map<String, Object> beforeMap = convertToMap(beforeData);
Map<String, Object> afterMap = convertToMap(afterData);
String businessId = extractBusinessId(afterMap);
// 遍历更新后的数据
for (Map.Entry<String, Object> entry : afterMap.entrySet()) {
String fieldName = entry.getKey();
// 忽略指定字段
if (shouldIgnore(fieldName, ignoreFields)) {
continue;
}
Object beforeValue = beforeMap.get(fieldName);
Object afterValue = entry.getValue();
// 比较值是否变化
if (!isValueEquals(beforeValue, afterValue)) {
SysDataChangeLog changeLog = createChangeLog(
businessType, businessId, fieldName,
getFieldComment(afterData, fieldName),
beforeValue, afterValue, "UPDATE"
);
changeLogs.add(changeLog);
}
}
} catch (Exception e) {
log.error("对比数据差异失败", e);
}
return changeLogs;
}
/**
* 判断两个值是否相等(处理各种数据类型)
*/
private boolean isValueEquals(Object v1, Object v2) {
if (v1 == null && v2 == null) return true;
if (v1 == null || v2 == null) return false;
// 处理日期类型
if (v1 instanceof Date && v2 instanceof Date) {
return ((Date) v1).getTime() == ((Date) v2).getTime();
}
// 处理数字类型(避免精度问题)
if (v1 instanceof Number && v2 instanceof Number) {
return new BigDecimal(v1.toString())
.compareTo(new BigDecimal(v2.toString())) == 0;
}
return v1.equals(v2);
}
五、拦截器未注册到Spring容器的解决方案
5.1 问题分析
在实际项目中,经常遇到拦截器配置了@Component注解,但MyBatis并没有自动注册拦截器的情况。主要原因:
- MyBatis自动配置顺序问题:拦截器注册时机过早
- 多数据源配置:需要为每个SqlSessionFactory手动注册
- 自定义MyBatis配置:覆盖了默认配置
5.2 解决方案一:手动注册拦截器(推荐)
package com.demo.framework.config;
import com.demo.framework.interceptor.ImprovedDataChangeInterceptor;
import org.apache.ibatis.plugin.Interceptor;
import org.apache.ibatis.session.SqlSessionFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Lazy;
import javax.annotation.PostConstruct;
import java.util.List;
/**
* 拦截器手动注册配置
* 解决拦截器未自动注册到MyBatis的问题
*/
@Configuration
public class InterceptorManualConfig {
private static final org.slf4j.Logger log =
org.slf4j.LoggerFactory.getLogger(InterceptorManualConfig.class);
@Autowired
private List<SqlSessionFactory> sqlSessionFactories;
@Autowired
@Lazy // 避免循环依赖
private ImprovedDataChangeInterceptor dataChangeInterceptor;
@PostConstruct
public void manualRegisterInterceptor() {
log.info("========== 开始手动注册数据变更拦截器 ==========");
log.info("发现 {} 个SqlSessionFactory", sqlSessionFactories.size());
for (int i = 0; i < sqlSessionFactories.size(); i++) {
SqlSessionFactory sqlSessionFactory = sqlSessionFactories.get(i);
org.apache.ibatis.session.Configuration configuration =
sqlSessionFactory.getConfiguration();
// 检查是否已经注册
boolean alreadyRegistered = false;
for (Interceptor interceptor : configuration.getInterceptors()) {
if (interceptor instanceof ImprovedDataChangeInterceptor) {
alreadyRegistered = true;
log.info("SqlSessionFactory[{}] 拦截器已存在,跳过注册", i);
break;
}
}
if (!alreadyRegistered) {
log.info("注册拦截器到 SqlSessionFactory[{}]: {}",
i, sqlSessionFactory);
configuration.addInterceptor(dataChangeInterceptor);
}
}
// 验证注册结果
verifyInterceptorRegistration();
log.info("========== 拦截器手动注册完成 ==========");
}
/**
* 验证拦截器注册结果
*/
private void verifyInterceptorRegistration() {
for (int i = 0; i < sqlSessionFactories.size(); i++) {
SqlSessionFactory sqlSessionFactory = sqlSessionFactories.get(i);
List<Interceptor> interceptors =
sqlSessionFactory.getConfiguration().getInterceptors();
log.info("SqlSessionFactory[{}] 已注册拦截器数量: {}", i, interceptors.size());
for (Interceptor interceptor : interceptors) {
log.info(" - {}", interceptor.getClass().getSimpleName());
}
}
}
}
5.3 解决方案二:使用MyBatis配置类
package com.demo.framework.config;
import com.demo.framework.interceptor.ImprovedDataChangeInterceptor;
import org.apache.ibatis.plugin.Interceptor;
import org.apache.ibatis.session.SqlSessionFactory;
import org.mybatis.spring.SqlSessionFactoryBean;
import org.mybatis.spring.boot.autoconfigure.MybatisAutoConfiguration;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.AutoConfigureAfter;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Configuration;
import javax.annotation.PostConstruct;
/**
* 通过MyBatis配置类注册拦截器
*/
@Configuration
@AutoConfigureAfter(MybatisAutoConfiguration.class)
public class MyBatisInterceptorConfig {
@Autowired
private ApplicationContext applicationContext;
@Autowired
private ImprovedDataChangeInterceptor dataChangeInterceptor;
@PostConstruct
public void init() {
// 获取所有SqlSessionFactory
String[] sqlSessionFactoryNames =
applicationContext.getBeanNamesForType(SqlSessionFactory.class);
for (String name : sqlSessionFactoryNames) {
SqlSessionFactory sqlSessionFactory =
applicationContext.getBean(name, SqlSessionFactory.class);
// 获取当前的拦截器列表
org.apache.ibatis.session.Configuration configuration =
sqlSessionFactory.getConfiguration();
// 添加拦截器(如果不存在)
boolean exists = false;
for (Interceptor interceptor : configuration.getInterceptors()) {
if (interceptor instanceof ImprovedDataChangeInterceptor) {
exists = true;
break;
}
}
if (!exists) {
configuration.addInterceptor(dataChangeInterceptor);
}
}
}
}
5.4 解决方案三:验证拦截器是否注册
package com.demo.framework.config;
import org.apache.ibatis.plugin.Interceptor;
import org.apache.ibatis.session.SqlSessionFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;
import java.util.List;
/**
* 启动后验证拦截器注册情况
*/
@Component
public class InterceptorVerifyRunner implements CommandLineRunner {
private static final org.slf4j.Logger log =
org.slf4j.LoggerFactory.getLogger(InterceptorVerifyRunner.class);
@Autowired
private List<SqlSessionFactory> sqlSessionFactories;
@Override
public void run(String... args) throws Exception {
log.info("========== 数据变更拦截器注册验证 ==========");
for (int i = 0; i < sqlSessionFactories.size(); i++) {
SqlSessionFactory factory = sqlSessionFactories.get(i);
List<Interceptor> interceptors = factory.getConfiguration().getInterceptors();
boolean hasInterceptor = false;
for (Interceptor interceptor : interceptors) {
String className = interceptor.getClass().getName();
if (className.contains("DataChangeInterceptor")) {
hasInterceptor = true;
log.info("✅ SqlSessionFactory[{}] 已注册拦截器: {}",
i, className);
}
}
if (!hasInterceptor) {
log.error("❌ SqlSessionFactory[{}] 未找到数据变更拦截器!", i);
}
}
log.info("==========================================");
}
}
5.5 调试用REST接口
package com.demo.web.controller.monitor;
import com.demo.common.core.controller.BaseController;
import com.demo.common.core.domain.AjaxResult;
import org.apache.ibatis.plugin.Interceptor;
import org.apache.ibatis.session.SqlSessionFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 拦截器调试控制器
*/
@RestController
@RequestMapping("/monitor/interceptor")
public class InterceptorDebugController extends BaseController {
@Autowired
private List<SqlSessionFactory> sqlSessionFactories;
/**
* 查看拦截器注册状态
*/
@GetMapping("/status")
public AjaxResult status() {
List<Map<String, Object>> result = new ArrayList<>();
for (int i = 0; i < sqlSessionFactories.size(); i++) {
SqlSessionFactory factory = sqlSessionFactories.get(i);
Map<String, Object> factoryInfo = new HashMap<>();
factoryInfo.put("factoryIndex", i);
factoryInfo.put("factoryName", factory.getClass().getSimpleName());
List<Interceptor> interceptors = factory.getConfiguration().getInterceptors();
List<String> interceptorNames = new ArrayList<>();
for (Interceptor interceptor : interceptors) {
interceptorNames.add(interceptor.getClass().getSimpleName());
}
factoryInfo.put("interceptorCount", interceptors.size());
factoryInfo.put("interceptors", interceptorNames);
factoryInfo.put("hasDataChangeInterceptor",
interceptorNames.stream().anyMatch(name -> name.contains("DataChange")));
result.add(factoryInfo);
}
return success(result);
}
/**
* 测试拦截器是否工作
*/
@GetMapping("/test")
public AjaxResult test() {
// 返回提示信息,实际测试需要调用Mapper方法
return success("请调用实际的Mapper方法测试拦截器,如:/system/user/update");
}
}
六、使用示例
6.1 Mapper接口配置
package com.demo.system.mapper;
import com.demo.framework.aspectj.lang.annotation.DataChangeLog;
import com.demo.system.domain.SysUser;
import org.apache.ibatis.annotations.Param;
import java.util.List;
public interface SysUserMapper {
/**
* 修改用户信息
*/
@DataChangeLog(
businessType = "user.update",
ignoreFields = {"createTime", "updateTime", "password"}
)
public int updateUser(SysUser user);
/**
* 新增用户
*/
@DataChangeLog(businessType = "user.insert")
public int insertUser(SysUser user);
/**
* 删除用户
*/
@DataChangeLog(businessType = "user.delete")
public int deleteUserById(Long userId);
/**
* 批量删除用户
*/
@DataChangeLog(businessType = "user.batchDelete")
public int deleteUserByIds(@Param("userIds") List<Long> userIds);
/**
* 查询用户(用于获取变更前后的数据)
*/
public SysUser selectUserById(Long userId);
}
6.2 Service层调用
@Service
public class SysUserServiceImpl implements ISysUserService {
@Autowired
private SysUserMapper userMapper;
@Override
@Transactional(rollbackFor = Exception.class)
public int updateUser(SysUser user) {
// 直接调用Mapper,拦截器会自动处理数据变更记录
// 记录会在事务提交后自动写入
return userMapper.updateUser(user);
}
}
七、常见问题与解决方案
7.1 拦截器未生效排查清单
| 问题 | 检查点 | 解决方案 |
|---|---|---|
| 拦截器类未扫描 | @Component注解是否存在 | 添加注解或手动注册Bean |
| MyBatis未注册 | 查看configuration.getInterceptors() | 手动注册拦截器 |
| 多数据源问题 | 所有SqlSessionFactory都检查 | 为每个数据源注册拦截器 |
| 事务问题 | 事务提交前查询不到新数据 | 使用afterCommit回调 |
| 注解未识别 | Mapper方法上是否有@DataChangeLog | 检查注解是否存在 |
7.2 性能优化建议
/**
* 批量操作优化
*/
private static final int BATCH_THRESHOLD = 100; // 批量阈值
// 分批保存变更记录
if (allChangeLogs.size() > BATCH_THRESHOLD) {
List<List<SysDataChangeLog>> partitions = partitionList(allChangeLogs, BATCH_THRESHOLD);
for (List<SysDataChangeLog> partition : partitions) {
dataChangeLogService.insertBatch(partition);
}
} else {
dataChangeLogService.insertBatch(allChangeLogs);
}
7.3 日志配置
# application.yml
logging:
level:
com.demo.framework.interceptor: DEBUG
org.apache.ibatis: INFO八、总结
8.1 方案优势
- 数据一致性:事务提交后才记录日志,确保数据准确性
- 性能优化:异步处理变更记录,不影响主业务流程
- 字段级对比:精确记录每个字段的变化
- 易于集成:通过注解配置,对业务代码无侵入
- 支持批量:完善的批量操作处理机制
8.2 注意事项
- 查询方法必须存在:需要提供根据ID查询数据的方法
- 事务管理:确保在事务环境中使用
- 大字段处理:考虑对BLOB等大字段的特殊处理
- 数据清理:定期清理历史变更记录
8.3 扩展建议
- 支持异步记录(使用消息队列)
- 支持变更记录查询和导出
- 支持自定义字段转换器
- 支持敏感数据脱敏
以上就是基于MyBatis拦截器实现数据变更记录的技术方案的详细内容,更多关于MyBatis拦截器数据变更记录的资料请关注脚本之家其它相关文章!
