基于SpringBoot + MyBatis-Plus高效实现数据变更记录

 更新时间:2026年02月05日 09:20:05   作者:(farerboy)  
在应用开发过程中,在某些情况下,需要实现数据的变更记录,以便于在未来进行数据操作变更的回溯,那有什么办法高效实现数据操作变更记录,而不需要一个一个地方去加呢?答案当然是有,今天小编就给大家介绍下如何基于SpringBoot + MyBatis-Plus高效实现数据的变更记录,

前言

在应用开发过程中,在某些情况下,需要实现数据的变更记录。以便于在未来进行数据操作变更的回溯。

常规的做法是在对应修改数据的 service 方法中手动记录数据的变更。这种方式实现起来简单,不用费什么脑子。但却并不是最高效的。

public void updateOrder(Order order) {
    Order oldOrder = orderMapper.selectById(order.getId()); // 第一次查询
    // ... 一堆业务逻辑 ...
    orderMapper.updateById(order); // 业务更新
    logService.saveChangeLog(oldOrder, order); 
}

试想一下,如果修改数据的 service 方法很多,或者项目在开发快结束的时候临时决定需要额外添加数据变更记录。此时一个个的手动添加无疑是一件费时费力切低效的操作。这样做的后果是 严重侵入业务、重复代码泛滥、事务边界混乱、性能极差。

那有什么办法高效实现数据操作变更记录,而不需要一个一个地方去加呢?答案当然是有,今天小编就给大家介绍下如何高效实现数据的变更记录。

实现原理

我们主要通过拦截器 + 事件驱动架构实现。通过 MyBatis-Plus 的拦截器插件拦截新增、修改、删除操作,发布事件通知,由事件消费者进行消费,并记录到变更记录表中。

该方案的优势:

  • 高效,只需要编写拦截器与事件消费者即可,不需要一个个方法修改,避免漏写、写错的情况。
  • 无业务代码侵入,完全解耦。
  • 高性能,因为是通过异步消息实现记录,不会阻塞原有业务方法。

代码实战

第一步:自定义 MyBatis 拦截器(捕获变更时机)

这是核心钩子,用于在数据发生变更时发布一个事件,而非直接记录。

@Intercepts({
    @Signature(type = Executor.class, method = "update", 
               args = {MappedStatement.class, Object.class})
})
@Component
@Slf4j
public class DataChangeInterceptor implements Interceptor {
    
    @Autowired
    private ApplicationEventPublisher eventPublisher; // 事件发布器
    
    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        MappedStatement ms = (MappedStatement) invocation.getArgs()[0];
        Object parameter = invocation.getArgs()[1];
        
        // 1. 仅拦截增删改操作
        SqlCommandType commandType = ms.getSqlCommandType();
        if (commandType == SqlCommandType.INSERT || 
            commandType == SqlCommandType.UPDATE || 
            commandType == SqlCommandType.DELETE) {
            
            // 2. 获取实体信息(MyBatis-Plus增强)
            if (parameter instanceof Map) {
                // 处理Wrapper等复杂参数,提取实体
            } else if (parameter != null) {
                // 3. 关键:在操作执行前,根据ID查询旧数据(仅UPDATE需要)
                Object oldData = null;
                if (commandType == SqlCommandType.UPDATE) {
                    oldData = fetchOldData(parameter, ms); // 根据主键查旧数据
                }
                
                // 4. 执行原始SQL操作
                Object result = invocation.proceed();
                
                // 5. 异步发布变更事件(不阻塞主流程)
                if ((int)result > 0) {
                    DataChangeEvent event = new DataChangeEvent(
                            this, 
                            commandType, 
                            oldData, 
                            parameter, // 新数据
                            ThreadLocalUtil.getCurrentOperator() // 操作人从线程上下文获取
                    );
                    eventPublisher.publishEvent(event); // 异步处理
                }
                return result;
            }
        }
        return invocation.proceed();
    }
    
    private Object fetchOldData(Object entity, MappedStatement ms) {
        // 利用MyBatis-Plus的TableInfo工具类,反射获取主键值和实体类型
        TableInfo tableInfo = TableInfoHelper.getTableInfo(entity.getClass());
        if (tableInfo != null) {
            Object idValue = tableInfo.getPropertyValue(entity, tableInfo.getKeyProperty());
            return sqlSessionTemplate.selectOne(ms.getId() + "_selectById", idValue); // 复用Mapper查询
        }
        return null;
    }
}

第二步:设计领域事件(封装变更内容)

事件对象应携带变更的所有元数据。

@Data
public class DataChangeEvent {
    private final SqlCommandType changeType; // 操作类型
    private final Object oldData;            // 变更前数据(JSON字符串或实体)
    private final Object newData;            // 变更后数据
    private final String operator;           // 操作人(从ThreadLocal或安全上下文获取)
    private final LocalDateTime changeTime = LocalDateTime.now();
    private final String entityClassName;    // 实体类名
    
    // 关键:将数据转换为JSON,避免后续序列化问题
    public String getOldDataJson() {
        return JSON.toJSONString(oldData);
    }
    
    public String getNewDataJson() {
        return JSON.toJSONString(newData);
    }
}

第三步:异步事件监听器(真正执行记录)

这是性能关键,必须异步化,且要有降级策略。

@Component
@Slf4j
public class DataChangeEventListener {
    
    @Async("dataChangeExecutor") // 指定独立线程池,不占用业务资源
    @EventListener
    @Transactional(propagation = Propagation.REQUIRES_NEW) // 新事务,与业务事务分离
    public void handleDataChangeEvent(DataChangeEvent event) {
        try {
            // 1. 构建变更记录实体
            ChangeLog changeLog = new ChangeLog();
            changeLog.setEntityClass(event.getEntityClassName());
            changeLog.setChangeType(event.getChangeType().name());
            changeLog.setOldData(event.getOldDataJson());
            changeLog.setNewData(event.getNewDataJson());
            changeLog.setOperator(event.getOperator());
            
            // 2. 计算具体变更的字段(精细化记录)
            if (event.getChangeType() == SqlCommandType.UPDATE) {
                Map<String, Object> fieldChanges = DiffUtil.diff(
                    event.getOldDataJson(), 
                    event.getNewDataJson()
                );
                changeLog.setChangedFields(JSON.toJSONString(fieldChanges));
            }
            
            // 3. 持久化到数据库(或发送到消息队列)
            changeLogMapper.insert(changeLog);
            
        } catch (Exception e) {
            // 4. 降级策略:记录失败时,至少打印日志或存入死信队列
            log.error("数据变更记录失败,事件内容:{}", JSON.toJSONString(event), e);
            // 可在此处将事件发送至Redis或Kafka进行重试
        }
    }
}

第四步:关键配置与优化

1. 独立线程池

防止监听器阻塞影响主业务。

spring:
  task:
    execution:
      pool:
        data-change-executor:
          core-size: 2
          max-size: 5
          queue-capacity: 1000 # 缓冲区,抗瞬时峰值

2. 变更日志表设计

CREATE TABLE `change_log` (
  `id` bigint NOT NULL COMMENT '主键',
  `entity_class` varchar(255) NOT NULL COMMENT '实体类名',
  `entity_id` varchar(64) NOT NULL COMMENT '实体ID', -- 从数据中提取
  `change_type` varchar(10) NOT NULL COMMENT '操作类型',
  `changed_fields` json DEFAULT NULL COMMENT '变更的字段(JSON)', -- 快速定位
  `old_data` json DEFAULT NULL COMMENT '完整旧数据',
  `new_data` json DEFAULT NULL COMMENT '完整新数据',
  `operator` varchar(64) DEFAULT NULL COMMENT '操作人',
  `create_time` datetime NOT NULL COMMENT '创建时间',
  PRIMARY KEY (`id`),
  KEY `idx_entity` (`entity_class`,`entity_id`), -- 按实体查询
  KEY `idx_time` (`create_time`) -- 按时间范围查询
) COMMENT='数据变更记录表';

3. 操作人信息自动注入

public class OperatorContext {
    private static final ThreadLocal<String> CURRENT_OPERATOR = new ThreadLocal<>();
    
    public static void setOperator(String operatorId) {
        CURRENT_OPERATOR.set(operatorId);
    }
    // 在拦截器或Spring Security过滤器中设置
}

总结

  • 业务零侵入:业务开发无需关心记录逻辑,专注核心功能。
  • 性能无损:主流程仅增加一次事件发布(内存操作),记录过程完全异步化。
  • 数据完整:通过拦截器在执行前后捕获数据,保证变更前后的完整快照。
  • 架构扩展:事件驱动架构允许你轻松扩展,如:
    1. 将变更记录同时写入Elasticsearch供快速检索。
    2. 将重大变更发送消息通知相关系统。
    3. 实现操作回放功能(通过旧数据+新数据反向操作)。

以上就是基于SpringBoot + MyBatis-Plus高效实现数据变更记录的详细内容,更多关于SpringBoot MyBatis-Plus数据变更记录的资料请关注脚本之家其它相关文章!

相关文章

  • spring通过filter,Interceptor统一处理ResponseBody的返回值操作

    spring通过filter,Interceptor统一处理ResponseBody的返回值操作

    这篇文章主要介绍了spring通过filter,Interceptor统一处理ResponseBody的返回值操作,具有很好的参考价值,希望对大家有所帮助。一起跟随小编过来看看吧
    2020-09-09
  • Java面向对象和内存分析图文详解

    Java面向对象和内存分析图文详解

    这篇文章主要给大家介绍了关于Java面向对象和内存分析的相关资料,文章可以让初学者顺利的分析内存,更加容易的体会程序执行过程中内存的变化,需要的朋友可以参考下
    2021-05-05
  • 封装了一个Java数据库访问管理类

    封装了一个Java数据库访问管理类

    刚刚试着用JDBC,仿着原来C#的写法写了这段代码,自己觉得还是挺粗糙的,还烦请路过的朋友推荐一个写得较好较完整的相关例程以便学习。谢谢!
    2009-02-02
  • mybatis-plus多表查询操作方法

    mybatis-plus多表查询操作方法

    这篇文章主要介绍了mybatis-plus多表查询操作方法,本文通过实例代码给大家介绍的非常详细,对大家的学习或工作具有一定的参考借鉴价值,需要的朋友参考下吧
    2023-12-12
  • Java网络IO模型详解(BIO、NIO、AIO)

    Java网络IO模型详解(BIO、NIO、AIO)

    Java支持BIO、NIO和AIO三种网络IO模型,BIO是同步阻塞模型,适用于连接数较少的场景,NIO是同步非阻塞模型,适用于处理多个连接,支持自JDK1.4起,AIO是异步非阻塞模型,适用于异步操作多的场景,文中通过代码介绍的非常详细,需要的朋友可以参考下
    2024-10-10
  • Spring中的循环依赖问题分析(概念、原因与解决方案)

    Spring中的循环依赖问题分析(概念、原因与解决方案)

    循环依赖是一个常见但复杂的问题,尤其对于新手开发者来说,本文将详细介绍循环依赖的定义、成因、Spring的处理方式及解决策略,并通过示例代码帮助读者更好地理解这一概念,感兴趣的朋友一起看看吧
    2025-07-07
  • Spring Boot实战之发送邮件示例代码

    Spring Boot实战之发送邮件示例代码

    本篇文章主要介绍了Spring Boot实战之发送邮件示例代码,具有一定的参考价值,有兴趣的可以了解一下。
    2017-03-03
  • Maven配置单仓库与多仓库的实现(Nexus)

    Maven配置单仓库与多仓库的实现(Nexus)

    本文主要介绍了Maven配置单仓库与多仓库的实现(Nexus),文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友们下面随着小编来一起学习学习吧
    2023-01-01
  • 为何HashSet中使用PRESENT而不是null作为value

    为何HashSet中使用PRESENT而不是null作为value

    这篇文章主要介绍了为何HashSet中使用PRESENT而不是null作为value,具有很好的参考价值,希望对大家有所帮助。如有错误或未考虑完全的地方,望不吝赐教
    2022-10-10
  • 详解mybatis-plus配置找不到Mapper接口路径的坑

    详解mybatis-plus配置找不到Mapper接口路径的坑

    这篇文章主要介绍了详解mybatis-plus配置找不到Mapper接口路径的坑,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友们下面随着小编来一起学习学习吧
    2020-10-10

最新评论