ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

SpringBoot集成MyBatis拦截器实现自动数据变更追踪

SpringBoot集成MyBatis拦截器实现自动数据变更追踪 自动数据变更追踪说白了就是给数据库的每一次增删改留下一份“案底”谁、在什么时候、把哪条记录的哪个字段从什么值改成了什么值。这个需求几乎每个业务系统都会遇到但直到被问住很多人才想起来还没做。我之前在一个订单系统里就吃过这样的亏运营反馈一个订单金额被改错了说是财务那边某段时间调整过结果所有同事都翻不到记录最后还是靠数据库日志一个表一个表地对折腾了两天才定位到人。那次之后我就在SpringBoot项目里落地了一套自动数据变更追踪方案今天把思路和完整实现拆开讲一讲。本文会从方案选型到代码实现完整记录这套方案的设计过程。它利用MyBatis拦截器在SQL执行的前后自动采集数据快照配合Spring事件机制异步落库全程不需要改业务Service代码。适合正在做审计平台、数据溯源系统或者想给老项目快速补上操作留痕的开发者参考。1. 数据变更追踪到底在解决什么问题很多人一听到“数据变更追踪”第一反应是“这不就是加个日志吗”。实际上它要解决的问题比普通日志复杂得多。系统上线时间越长数据被改来改去的次数就越多一旦出现“这个客户额度为什么被调高了”“这个商品的库存怎么少了50件”这类问题如果没有结构化、可检索的变更记录就只能靠翻日志、找聊天记录、甚至人肉记忆效率低还容易扯皮。1.1 审计日志不是简单的打印日志应用里最常见的做法是打一行日志比如“更新订单金额为500”。这种日志的问题很明显第一它没有记录变更前这条记录长什么样你只知道“改成了500”不知道“原来是不是300”第二它往往没有绑定操作人同一个接口被多个后台账号调用时根本分不清是谁干的第三它散落在各种应用文件里查询不方便没有索引没有结构化字段。真正的数据变更追踪至少要回答四个问题谁在什么时间对哪条记录做了什么样的变更。更进一步最好还能记录变更前后的完整数据快照、请求ID、操作入口哪个页面/接口这样以后排查问题时可以直接还原当时的现场。普通日志是面向开发者的变更追踪日志是面向业务和合规的。下面用表格对比一下对比维度普通应用日志数据变更追踪日志记录内容代码里手动写的文案表名、主键、变更前后JSON、操作人、时间是否包含旧值不保证必须包含查询方式日志文件里grep数据库表按字段检索业务可读性差好可以直接展示给非技术同学看记录完整性靠开发者自觉靠框架机制自动保证后面这套方案的目标就是让系统在每次业务SQL执行时自动生成第二行那种结构化记录而不是靠谁记得写一行log.info。1.2 常见业务场景数据变更追踪不是某个行业独有的需求只要数据有价值、会被人操作就值得做。我整理了几个高频场景运营后台审计管理员误操作、恶意操作时需要快速定位“谁改了哪个客户的价格”。这类场景最普遍权限系统只能控制“能不能改”控制不了“改了以后如何追责”。对账与纠纷处理积分、余额、订单金额、优惠券状态发生变动用户投诉或上下游对账时必须能说明每一笔变动的前因后果。多系统数据一致性多个系统共用同一份核心数据比如主数据、配置数据某个系统改了数据导致另一个系统出问题这时候必须能查到是谁改的、通过哪个接口改的。合规审计金融、医疗类项目经常有强制要求核心数据的变更记录必须留存一定周期且不可随意删除。这些场景的共性是你不仅要保证功能做了还要证明“确实做了、是按规则做的、是某人做的”。这已经不是业务逻辑问题而是基础设施能力。1.3 为什么要在框架层自动实现如果靠开发人员在每个Service方法里手动调用审计Service比如“先查旧值、再执行更新、再写一条日志”代码会非常冗余而且很容易漏。新来的同事可能根本不知道这个约定老代码里也可能因为复制粘贴漏掉几行。更麻烦的是不同人的写法五花八门有人记录完整快照有人只记录字段名有人忘了存操作人最后查数据时依然很痛苦。框架层自动实现的核心价值是“机制大于自觉”。业务代码里不需要关心审计逻辑只要把操作落到Mapper接口上拦截器自然会去采集数据。这样新增表、新增接口时审计能力天然就带上了不会因为人的疏忽而缺失。这也是标题里“自动”两个字的意义所在。2. 主流实现方案横向对比触发器、JPA审计、Binlog、MyBatis拦截器动手之前我先理了一遍市面上常用的几种方案。最初我甚至考虑过数据库触发器但反复权衡后还是放弃了。这里把几个方案的优劣势都列出来方便你根据自己的项目情况选型。2.1 数据库触发器强依赖数据库且难维护触发器方案是在每张需要追踪的表上创建AFTER INSERT、AFTER UPDATE、AFTER DELETE触发器把变更前后数据写到审计表。它的最大优势是任何客户端操作都会被记录哪怕有人直接连数据库执行SQL也逃不掉。但问题也很明显第一触发器逻辑里很难拿到“当前登录用户”通常只能通过数据库连接变量或者业务表里硬编码操作人字段来传递非常别扭第二触发器代码写在数据库里面不参与Git版本管理团队成员很难Review出了问题排查也费劲第三不同数据库的触发器语法差异很大MySQL写得好的触发器换到PostgreSQL基本要重写第四如果业务表结构变更触发器很容易忘记同步更新。我的结论是触发器适合运行已久、不允许改应用代码、而且只在一个小型单库里使用的场景。对于绝大多数正规SpringBoot项目尽量别把它当首选方案。2.2 Spring Data JPA审计与Envers如果项目用的是Spring Data JPA天然有CreatedDate、LastModifiedDate这类审计注解但这只能记录创建时间和最后修改时间拿不到“修改前是什么值”。想要完整的历史版本可以引入Hibernate Envers它会自动维护一张审计表记录每个实体的版本变化。Envers本身很成熟但限制在于它绑定JPA/Hibernate。如果你的SpringBoot项目像大多数国内项目一样使用MyBatis或MyBatis-Plus为了做审计硬塞一套JPA进去就太重了。而且Envers对复杂表关系、批量更新、自定义SQL的支持并不是万能的遇到一个连表更新就会比较尴尬。2.3 MySQL Binlog/Canal这是目前很多数据同步平台采用的方案通过Canal伪装成MySQL从库解析Binlog日志把每一次数据变更以类似MQ的消息形式发出来。它的最大优点是和应用完全解耦哪怕应用挂了、甚至有人绕过应用直接改数据库都能被记录到。缺点也很明显需要额外部署Canal、消息队列、目标存储运维成本一下就上来了对中小项目来说有点“大炮打蚊子”。另外Binlog方案里想要拿到变更前的完整旧值需要把MySQL的binlog_row_image参数设置为FULL否则Binlog里可能只有变更后的镜像。这一点在团队里如果没人懂配置错了整个方案就废了。所以Binlog/Canal更适合做跨系统数据同步、异构数据复制这类场景单纯为了记录审计日志而上这套有点得不偿失。2.4 MyBatis拦截器更适合SpringBoot项目MyBatis拦截器的工作原理是拦截Executor的update/query方法在SQL真正执行之前插入我们自己的逻辑。它运行在应用进程内拿到的东西非常直接执行的MappedStatement可以拿到SQL类型和Mapper接口信息参数对象可以拿到实体或Map里的值。更重要的是它处于Spring管理的事务上下文里可以通过JdbcTemplate、SecurityContext等获取操作人和请求信息。这个方案的缺点是只能拦截通过MyBatis执行的操作如果有人用JdbcTemplate裸写SQL那是覆盖不到的。但实际业务中SpringBoot MyBatis体系里绝大部分数据库操作都走Mapper接口所以这点影响可以接受。方案优点缺点适用场景数据库触发器不依赖应用任何SQL都记录跨库差、拿操作人难、维护难小规模单库、无法改应用代码JPA Envers自动维护版本官方方案绑定JPAMyBatis项目难用纯JPA项目Binlog/Canal与应用解耦最彻底部署重、成本高数据同步、数仓采集MyBatis拦截器侵入小、集成Spring容易、代码可控只覆盖MyBatis操作SpringBootMyBatis项目首选我当时在订单系统里最终选了MyBatis拦截器原因很简单我们是标准的SpringBootMyBatis-Plus技术栈希望以最小成本给核心表补上审计能力又不想引入一堆外部组件。下面进入正题。3. 选型确定后的核心设计用MyBatis拦截器采集变更确定了用MyBatis拦截器之后最需要考虑的是三个关键点怎么拦截到所有增删改、怎么拿到变更前的旧值、怎么把操作人这类上下文信息传递进去。3.1 拦截器的基本原理与SpringBoot注册方式MyBatis的四大核心对象里Executor负责执行所有SQL操作。我们在SpringBoot项目里自定义一个Interceptor通过Intercepts注解声明拦截Executor.update方法即可。这里说的update方法覆盖INSERT、UPDATE、DELETE三种SQL命令query方法则是SELECT。需要说明的是MyBatis拦截器不是直接实现某个接口就能被Spring识别的。通常有两种注册方式一种是在mybatis-config.xml里配置plugin标签另一种是在SpringBoot配置类里用ConfigurationCustomizer把拦截器加到MyBatis的Configuration中。我推荐后者因为拦截器本身需要注入JdbcTemplate等Spring Bean用配置类更方便。Configuration public class MybatisAuditConfig { private final JdbcTemplate jdbcTemplate; public MybatisAuditConfig(JdbcTemplate jdbcTemplate) { this.jdbcTemplate jdbcTemplate; } Bean public ConfigurationCustomizer dataChangeInterceptorCustomizer() { return configuration - configuration.addInterceptor(new DataChangeInterceptor(jdbcTemplate)); } }如果你的项目用的是MyBatis-Plus也可以通过MybatisPlusInterceptor添加自定义InnerInterceptor但原生Interceptor在老版本和新版本中兼容性更稳定我直接选择原生方案。3.2 从SqlCommandType识别Insert/Update/Delete拦截器的方法签名比较固定args[0]是MappedStatementargs[1]是SQL参数。第一步就是从MappedStatement里取出SqlCommandType判断当前操作是哪一种Override public Object intercept(Invocation invocation) throws Throwable { Object[] args invocation.getArgs(); MappedStatement ms (MappedStatement) args[0]; SqlCommandType commandType ms.getSqlCommandType(); if (commandType SqlCommandType.INSERT || commandType SqlCommandType.UPDATE || commandType SqlCommandType.DELETE) { // 执行采集逻辑 } return invocation.proceed(); }这里有个很容易踩的坑MyBatis-Plus的逻辑删除在代码层面是UPDATE语句SqlCommandType返回的也是UPDATE但业务语义是删除。这个问题我会在第6节专门讲先按统一方式处理。3.3 关键点如何拿到变更前的旧值这是整套方案的灵魂。只记录新值没有意义事后根本看不出“改了什么”。拿到旧值最直接的办法是在更新或删除SQL执行之前先根据主键查一次这条记录的当前值。由于拦截器是Spring容器里的Bean我可以注入JdbcTemplate在拦截器里执行这条额外查询。核心逻辑是从参数中解析出表名和主键值然后构造SELECT语句。private MapString, Object queryOldRow(DataChangeContext ctx) { if (ctx.getPrimaryKeyValue() null) { return null; } String sql SELECT * FROM ctx.getTableName() WHERE id ?; ListMapString, Object rows jdbcTemplate.queryForList(sql, ctx.getPrimaryKeyValue()); return rows.isEmpty() ? null : rows.get(0); }表名和主键的解析可以通过MyBatis-Plus的TableInfoHelper实现也可以简单通过反射读取TableName和TableId注解。对于普通MyBatis项目我建议约定实体类主键字段名为id这样解析逻辑最简单。扫描包太复杂反而容易出问题。这里必须注意一点额外查询旧值并不是物理数据库的“历史值”而是当前事务中能读到的最新值。如果你的业务方法开启了事务Transactional在拦截器执行时旧数据还没有被这个事务修改所以查到的就是真实旧值。这个方案成立的一个前提是业务代码必须开启事务。如果完全没有事务那查询旧值和执行更新是两条独立操作中间可能有并发问题但审计场景下可以接受。3.4 变更后数据快照Insert场景的新值很好拿参数对象本身就是新插入的实体。Update场景稍微麻烦一点如果Mapper方法传的是整个实体那么参数里所有字段都是希望更新后的值如果传的是Map或Param包装参数可能只有部分字段。这里我推荐一个做法Update的新值快照用“旧值Map为基础把参数中非空字段覆盖上去”这样拿到的newData是变更后的完整记录。private MapString, Object mergeNewRow(MapString, Object oldRow, Object parameter) { MapString, Object newRow oldRow null ? new HashMap() : new HashMap(oldRow); if (parameter instanceof Map) { Map?, ? paramMap (Map?, ?) parameter; for (Map.Entry?, ? entry : paramMap.entrySet()) { if (entry.getValue() ! null !param1.equals(entry.getKey())) { newRow.put(String.valueOf(entry.getKey()), entry.getValue()); } } } else { Field[] fields parameter.getClass().getDeclaredFields(); for (Field field : fields) { field.setAccessible(true); try { Object value field.get(parameter); if (value ! null) { newRow.put(field.getName(), value); } } catch (IllegalAccessException e) { // 忽略不阻断主流程 } } } return newRow; }这个merge方法在实战中非常好用它保证了newData一定是一行完整的记录而不是只有被修改的那几个字段。3.5 操作人、请求IP等上下文从哪里来数据变更追踪最麻烦的是拿操作人。我的项目里统一通过一个UserContext ThreadLocal保存当前登录用户在Web层拦截器或Spring Security过滤器里写入。拦截器采集数据时直接取即可。如果服务之间通过Feign调用还可以在请求头里传递userId在服务提供方解析。public class UserContextHolder { private static final ThreadLocalString USER_ID new ThreadLocal(); private static final ThreadLocalString USER_NAME new ThreadLocal(); private static final ThreadLocalString REQUEST_ID new ThreadLocal(); public static void set(String userId, String userName, String requestId) { USER_ID.set(userId); USER_NAME.set(userName); REQUEST_ID.set(requestId); } public static void clear() { USER_ID.remove(); USER_NAME.remove(); REQUEST_ID.remove(); } }使用ThreadLocal时一定要在请求结束的过滤器里调用clear()否则线程池复用会导致下一个请求带上上一个用户的数据这是Web开发最常见的坑没有之一。4. 完整实现用自定义注解标记要追踪的业务前面讲了采集原理现在落地代码。我不建议对所有的Mapper方法都启用追踪因为不是每张表都需要审计。最好的方式是做一层“白名单”通过自定义注解标记需要追踪的Mapper方法或实体类。4.1 自定义注解DataChangeTrack我定义了一个轻量注解放在Mapper接口方法上表示这个方法引发的数据库变更需要被记录下来。注解里可以带bizModule用来标记所属业务模块便于在审计列表里快速筛选。Target({ElementType.METHOD, ElementType.TYPE}) Retention(RetentionPolicy.RUNTIME) public interface DataChangeTrack { String bizModule() default default; }如果放在类上就表示这个Mapper接口下所有方法都追踪如果放在方法上就只追踪当前方法。这个规则比较直观。4.2 拦截器如何找到Mapper方法上的注解拦截器拿到MappedStatement后可以通过它的getId获取形如“com.demo.mapper.OrderMapper.updateById”的完整方法引用。要拿到方法上的注解需要反射加载这个Class然后找到对应的方法。这里有个实现细节Mapper接口方法可能有多个重载但实际项目中重载很少简单按方法名匹配就够了。private DataChangeTrack resolveAnnotation(MappedStatement ms) { String id ms.getId(); int dotIndex id.lastIndexOf(.); String className id.substring(0, dotIndex); String methodName id.substring(dotIndex 1); try { Class? mapperClass Class.forName(className); for (Method method : mapperClass.getMethods()) { if (method.getName().equals(methodName) method.isAnnotationPresent(DataChangeTrack.class)) { return method.getAnnotation(DataChangeTrack.class); } } } catch (ClassNotFoundException e) { // 忽略 } return null; }反射解析注解失败时我选择直接跳过追踪而不是抛异常因为追踪功能不能影响主业务。这也是这类中间件设计的一条原则审计是旁路逻辑不能因为旁路故障拖垮主链路。4.3 核心拦截逻辑完整代码完整拦截器代码需要把前面所有点串起来。这里给出精简过的核心结构你拿到手之后可以根据自己的表结构调整。public class DataChangeInterceptor implements Interceptor { private final JdbcTemplate jdbcTemplate; public DataChangeInterceptor(JdbcTemplate jdbcTemplate) { this.jdbcTemplate jdbcTemplate; } Override public Object intercept(Invocation invocation) throws Throwable { Object[] args invocation.getArgs(); MappedStatement ms (MappedStatement) args[0]; SqlCommandType commandType ms.getSqlCommandType(); Object parameter args[1]; DataChangeTrack track resolveAnnotation(ms); if (track null) { return invocation.proceed(); } DataChangeContext ctx buildContext(ms, parameter, track.bizModule()); if (ctx null) { return invocation.proceed(); } MapString, Object oldRow null; if (commandType SqlCommandType.UPDATE || commandType SqlCommandType.DELETE) { oldRow queryOldRow(ctx); } Object result invocation.proceed(); if (ctx.getPrimaryKeyValue() ! null) { DataChangeRecord record buildRecord(commandType, ctx, parameter, oldRow); publishRecord(record); } return result; } }这里buildContext主要负责解析参数中的实体对象、表名、主键值。注意insert时主键值在SQL执行前可能还没有生成所以需要在执行后从参数实体上再读一次主键。MyBatis-Plus的IdType.AUTO在insert后会把生成的主键回填到实体上因此要在invocation.proceed()之后再构建完整record。4.4 构建ChangeRecord对象的细节ChangeRecord是一个普通的POJO字段越全越好。我定义的常见字段如下字段说明tableName操作的表名recordId业务记录主键changeTypeINSERT/UPDATE/DELETEoldData变更前JSON快照newData变更后JSON快照changedFields字段级差异列表JSONoperatorId操作人IDoperatorName操作人姓名requestId请求IDbizModule业务模块operateTime操作时间字段级的差异列表是比较好用的设计。我通过一个compareMap方法遍历oldData和newData找出值不相等的字段转换成“字段名旧值新值”的小对象展示在后台时就不用再解析两个大JSON了。public static ListFieldChange compare(MapString, Object oldMap, MapString, Object newMap) { ListFieldChange changes new ArrayList(); if (oldMap null) oldMap Collections.emptyMap(); if (newMap null) newMap Collections.emptyMap(); SetString keys new HashSet(oldMap.keySet()); keys.addAll(newMap.keySet()); for (String key : keys) { Object oldValue oldMap.get(key); Object newValue newMap.get(key); boolean changed (oldValue null) ? newValue ! null : !oldValue.equals(newValue); if (changed) { changes.add(new FieldChange(key, oldValue, newValue)); } } return changes; }这个compare方法后续可以用来做“发生了哪些字段变化”的高亮展示业务方看了非常直观。5. 将变更记录可靠地写入历史表异步与事务的坑采集到变更记录后最忌讳的做法是直接在拦截器里同步INSERT一条历史记录。表面上看代码少实际上埋了三个雷事务一致性问题、性能问题、异常隔离问题。5.1 为什么不能直接在拦截器里写历史表假设业务方法里先更新了订单状态再调用远程接口远程接口失败后整个事务回滚。如果在拦截器里同步写了历史表这条历史记录也会跟着回滚这倒是没关系。但如果历史表写入用的是独立事务就会出现“业务回滚了历史记录却留下了”的错误状态。更麻烦的是如果历史表本身临时不可用同步写会直接导致原本正常的业务操作失败——审计变成了业务的前置依赖这是我们最不想看到的。5.2 利用Spring事务同步器等待事务提交正确做法是把写入动作挂到当前事务的afterCommit回调里。Spring提供了TransactionSynchronizationManager可以在事务提交后触发回调。private void publishRecord(DataChangeRecord record) { if (TransactionSynchronizationManager.isSynchronizationActive()) { TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { Override public void afterCommit() { eventPublisher.publishEvent(new DataChangeEvent(record)); } }); } else { eventPublisher.publishEvent(new DataChangeEvent(record)); } }这么做有几个好处第一事务回滚的消息根本不会发出去第二记录的过程被延迟到业务操作成功之后主链路及时返回第三Spring事件机制解耦了采集和落库。唯一注意的是afterCommit里的事务状态已经结束不能再使用原事务连接所以事件监听器里要开启新事务或者用异步线程写库。5.3 异步落库与事件监听器事件监听器我推荐用Async异步执行并配一个专门的线程池。审计写入有一个特点量可能不大但偶尔会有批量操作产生几十条记录所以线程池队列要设置合理避免默认无界队列堆积所有请求。Async(dataChangeExecutor) EventListener public void handleDataChangeEvent(DataChangeEvent event) { dataChangeRecordMapper.insert(event.getRecord()); }线程池配置不能图省事用Executors.newCachedThreadPool因为它的最大线程数是Integer.MAX_VALUE高并发下会创建大量线程导致OOM。建议用ThreadPoolExecutor显式指定核心线程数和最大线程数队列用有界队列。这里有一个取舍异步写入意味着极端情况下可能丢消息。如果对合规要求非常严格我建议改成同步写历史表但要用独立事务并做异常隔离。比如用REQUIRES_NEW传播级别历史表写入失败只catch打日志不影响主业务。同步写会拖慢一点响应但数据更可靠。我自己的做法是核心表同步写、普通表异步写折中以后两边的特点都照顾到了。5.4 历史表设计通用日志表还是影子表写历史记录需要一个载体。最省事的是一张通用变更日志表所有表和操作都往里面写。字段里带tableName、recordId等查询时按条件过滤。CREATE TABLE data_change_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, table_name VARCHAR(128) NOT NULL COMMENT 业务表名, record_id VARCHAR(64) NOT NULL COMMENT 业务主键, change_type VARCHAR(16) NOT NULL COMMENT INSERT/UPDATE/DELETE, old_data TEXT COMMENT 变更前JSON, new_data TEXT COMMENT 变更后JSON, changed_fields TEXT COMMENT 字段级差异JSON, operator_id VARCHAR(64) COMMENT 操作人ID, operator_name VARCHAR(64) COMMENT 操作人姓名, request_id VARCHAR(64) COMMENT 请求ID, biz_module VARCHAR(128) COMMENT 业务模块, operate_time DATETIME NOT NULL COMMENT 操作时间, KEY idx_table_record (table_name, record_id), KEY idx_operate_time (operate_time), KEY idx_operator (operator_id) ) COMMENT数据变更追踪日志表;另一种方案是影子表比如给order表建一张order_history表结构完全复制order表再加一个操作类型和操作时间字段。影子表对单表的查询性能很好但业务表数量一多历史表也要成倍增加表结构变更时还要同步改历史表维护成本比较高。从实用角度看我推荐第一版先用通用表。数据量大了以后可以按月份分区或者把历史数据归档到数仓。等到某张表的查询频繁到无法接受再单独给它拆一张影子表也不迟。6. 实测效果与踩坑记录方案写完不等于结束真正在项目里跑起来以后我踩了好几个坑。这些坑都比较隐蔽不实际操作很难发现。6.1 批量更新时参数解析的坑如果你在Mapper里写了一个批量更新方法参数可能是List 或者实体数组。拦截器里如果只把参数当成单个实体处理解析出来的主键会是null最终记录丢失。正确做法是先判断参数类型如果是Collection或者数组就遍历每一个元素分别解析上下文分别采集记录。批量更新时旧值查询也要注意不能只查一条主键要走IN查询一次性查出所有旧记录否则一个批量操作会产生多次单条SELECT性能差很多。if (parameter instanceof Collection) { Collection? collection (Collection?) parameter; for (Object item : collection) { DataChangeContext ctx buildContextByEntity(ms, item); // 对每个item单独记录 } }6.2 逻辑删除与changeType错乱这是MyBatis-Plus项目最容易踩的坑。实体上加了TableLogic后调用deleteById方法实际执行的SQL是UPDATE table SET deleted 1 WHERE id ?。拦截器看到的SqlCommandType是UPDATE于是记录里就成了“修改了deleted字段”业务方看的时候一脸懵。我的处理思路是在拦截器中读取实体的TableLogic字段如果当前更新的参数里这个字段被置为逻辑删除值一般是1就把这行记录的changeType改写为DELETE。虽然需要一点额外判断但业务语义就对了。如果不用MyBatis-Plus也可以通过解析BoundSql里的SQL片段看是否存在set deleted1来判断但那样比较脆弱不推荐。6.3 性能损耗与并发注意额外查询旧值必然带来性能损耗这是这套方案绕不开的成本。每执行一次UPDATE/DELETE都要多一次主键查询和两次JSON序列化。在一个后台管理系统里单次更新本来也就0.5ms左右加上审计逻辑以后会增加1ms左右整体可以接受。但如果在秒杀、交易主链路这种高QPS场景里对所有写操作开启追踪性能影响会被放大。建议第一版只对核心业务表和后台操作接口开启追踪并且确认旧值查询走的是主键索引。我自己的经验是对4C8G的单机服务单表简单更新启用追踪后接口平均RT从12ms涨到15ms左右感知不大但并发量一旦上百就会开始出现线程排队。6.4 后续扩展从变更记录做数据回放与对比变更记录积累起来以后能做的不只是审计。最实用的一个功能是“时间旅行”选定一条业务记录按operate_time倒序找出它的历史变更序列就能还原它在任意时间点上的完整状态。比如订单状态字段连续被改了五次每次操作时间和操作人都有记录运营再问“这个订单为什么变成已取消”直接把时间线截图扔过去就行。另外还可以把字段级差异聚合成“操作趋势”比如统计某个管理员一天内修改了多少条数据、哪个字段被修改得最频繁。这些数据对权限治理、风控都有参考价值。再往后如果要做数据同步比如把核心表变更推送到数仓或搜索索引这套记录本身就是一份可靠的数据源。6.5 上线前建议先盯住这几个指标最后结合我的实际经验给你一份上线前检查清单。照着过一遍能少踩很多坑是否确认了需要追踪的表范围避免所有Mapper方法都开启是否验证过插入后主键回填拿到了自增ID是否处理了批量参数List/数组的遍历逻辑是否针对逻辑删除字段做了changeType纠正是否在事务提交后才发布事件避免回滚残留线程池是否设置了合理的队列大小和拒绝策略历史表索引是否足够支撑按tableNamerecordId和operateTime查询是否预留了字段级差异展示的接口这套基于SpringBoot和MyBatis拦截器的自动数据变更追踪方案核心思路可以迁移到任何MyBatis项目里。你要做的不是背下来代码而是理解三个关键点拦截器负责采集、事务同步负责可靠性、事件异步负责解耦。把这条链路想通了后面再加字段、加表都是水到渠成的事。
返回列表