SpringBoot+AOP+自定义注解:实现数据变更追踪的完整方案

发布时间:2026/9/10 1:39:34
SpringBoot+AOP+自定义注解:实现数据变更追踪的完整方案 1. 为什么我最终选择了 AOP 注解来做数据变更追踪数据变更追踪说白了就是回答三个问题谁在什么时间改了什么数据、改之前是什么样、改之后变成什么样。很多团队一开始会觉得这东西简单不就是写几个方法在保存和更新的时候把新旧值打出来嘛。但真正做起来才发现如果每个业务方法里都手动写一套变更记录的逻辑代码会脏到没法看而且很容易漏记、错记。我做过几个数据审计相关的项目最早是直接用拦截器在 MyBatis 层做后来换成过数据库触发器最后沉淀下来的一套方案就是 SpringBoot AOP 自定义注解。这个组合的好处非常直接业务代码零侵入你不需要在 Service 或 Controller 里写任何跟审计相关的代码只需要在需要追踪的方法上贴一个注解剩下的全部交给切面去处理。这个方案适合谁适合所有用 SpringBoot 做后端、有数据审计需求的团队不管是内部管理系统、低代码平台还是涉及到合规要求的业务系统。哪怕你只有一个单表这套思路也值得了解一下因为它的扩展性足够好后面加表、加字段、加操作类型都只需要做配置级的改动。先说一下我遇到的真实场景系统里有客户信息、订单、合同这些核心业务数据用户操作频繁业务方经常需要知道某条客户数据是谁改的、改了哪些字段、旧值是什么、新值是什么。当时接到的需求是所有核心表的增删改操作都要记录字段要精确到列而且要比对旧值和新值。如果按照常规思路每个 Service 的 update 方法里先查旧值、再更新、再对比、再写记录这一个流程下来每个方法要多出十几行代码几十个方法就是几百行重复代码而且维护起来非常痛苦。后来我用 AOP 做了统一切面用注解标记需要追踪的方法一个月跑下来这套方案稳定记录了几十万条变更日志线上没有出过一次漏记的问题。2. 整体设计思路把“记录变更”这件事从业务代码里彻底剥离2.1 三种常见方案对比为什么 AOP 最合适在动手写代码之前我先把市面上的主流方案盘了一遍。第一种是 MyBatis 拦截器方案通过拦截 Executor 的 update 方法拿到 SQL再解析 SQL 里的参数。这种方案的优点是能覆盖到所有 MyBatis 操作缺点也很明显你拿到的最终 SQL 是解析后的参数要判断某个字段到底改没改必须先执行一次查询拿到旧值而且 SQL 解析本身有兼容性风险尤其遇到复杂的动态 SQL 时容易出 Bug。第二种是数据库触发器方案在数据库层面做审计表。这个方案性能好、可靠性高数据库事务和审计事务天然一致。但缺点是数据库耦合太深一旦换了数据库类型触发器脚本全要重写而且触发器里拿不到当前的登录用户信息还得通过 session 变量或者应用层额外传入用起来很别扭。第三种就是我最终采用的 AOP 自定义注解方案。它基于 Spring 的代理机制在方法调用前后织入增强逻辑。优点非常突出和业务代码解耦、可以拿到方法入参、可以注入 Service 查旧值、可以拿到当前登录用户、可以精确控制哪些方法需要记录。缺点也有比如自调用不经过代理导致切面失效但这些都有成熟的规避方案后面我会详细讲。2.2 系统架构四个核心模块各司其职我设计的这套方案分四个核心模块每个模块职责单一配合起来非常顺手。第一个是自定义注解模块负责声明“哪些方法需要记录变更”第二个是 AOP 切面模块负责拦截方法调用、解析注解、执行新旧值比对第三个是核心服务模块负责把比对结果组装成变更记录并落库第四个是扩展支持模块负责处理一些复杂场景比如集合字段的比较、JSON 序列化等。模块之间通过 Spring 容器解耦AOP 切面不直接操作数据库而是调用核心服务接口。这样做的好处是后期如果要把变更记录发送到消息队列或者同步到 Elasticsearch只需要替换核心服务的实现类不用动切面代码。结合这张图来理解整个流程客户端发起请求进入 ControllerController 调用 ServiceService 方法上标注了DataChangeTrack注解。Spring AOP 会在方法执行前拦截记录当前时间和入参方法执行成功后切面拿到返回值或重新查询的数据和之前缓存的数据做对比最后把差异字段组装成记录列表异步写入数据库。2.3 设计原则通用性、扩展性、低侵入性我在设计这套方案时给自己定了三个原则。第一个原则是通用性。所有核心表共用一套逻辑不能每张表写一套记录代码。所以我把“表名”和“业务主键”都设计成注解的参数由使用方在注解上声明切面通过反射读取。第二个原则是扩展性。业务字段的中文描述、是否纳入比较范围这些不能硬编码。我在字段注解里提供了别名扩展机制可以通过动态配置中心下发字段映射关系这样可以做到修改字段名称不用改代码。第三个原则是低侵入性。加了注解的业务方法除了要保证入参能被正确解析其他业务逻辑一行都不用改。同时切面不能影响主业务流程的执行所以我把异常处理做了隔离切面里抛出的任何异常都不会影响业务方法的正常返回。注意低侵入性不代表零侵入。使用这个方案的前提是你的业务方法入参中要能找到“旧值”的来源。最常见的做法是通过业务主键查询旧值所以你需要在注解上指定主键字段名或者在切面里约定从参数对象中获取主键。3. 自定义注解设计小而美的核心枢纽3.1 注解定义一张表一个标记一个字段一份元数据要说这套方案里最体现设计功底的地方我觉得是自定义注解的设计。它不只是简单标记“这里需要记录”更重要的是让注解成为元数据的载体。我定义了两个注解一个是类级别的DataChangeTrack作用在 Service 方法上声明该方法需要数据变更追踪另一个是字段级别的ChangeField作用在实体对象的字段上用于补充字段的元数据信息。DataChangeTrack中有几个关键属性tableName表示对应的数据库表名operation表示操作类型businessIdField表示业务主键字段名后面在切面中要根据这个字段名查询旧值。ChangeField中我定义了一个name属性用来声明字段的中文含义。比如实体中有一个字段叫customerName在记录变更时我们希望在日志里显示成“客户名称”而不是英文的customerName这个时候就通过ChangeField(name 客户名称)来标记。Target(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) public interface DataChangeTrack { String tableName() default ; String operation() default UPDATE; String businessIdField() default id; }Target(ElementType.FIELD) Retention(RetentionPolicy.RUNTIME) public interface ChangeField { String name() default ; boolean ignore() default false; }这里有个细节我建议特别注意注解的RetentionPolicy一定要设成RUNTIME否则运行期通过反射拿不到注解信息。这个是在实际开发中很容易踩的坑写完注解发现切面里读不到排查半天发现 Retention 设置成了SOURCE。3.2 字段中文名称的动态解析上面定义注解时提到了name属性如果使用方每写一个实体类都要在字段上标注中文名称虽然直观但对于老系统改造来说工作量很大。所以我在实现时加了一个兜底策略如果字段上标注了ChangeField(name xx)优先取注解值如果注解没有配置 name则尝试从字段名做驼峰转中文的映射如果还不行就用字段原名。这个方法在代码里看起来很简单但在实际项目中非常实用尤其是接手老系统时几百个字段你要一个个补中文名称是不现实的。我的建议是核心表字段全部用注解标注非核心表走兜底策略这样既保证了核心数据的可读性又避免了开发初期的重复劳动。3.3 注解设计时的三个注意点第一个注意事项是注解只是元数据不包含逻辑。你可能会看到网上有人把比较逻辑写在注解处理器里这是错误的设计。注解本身是静态声明动态逻辑应该在切面中实现两者分开职责才清晰。第二个注意事项是注解属性要精简。我之前设计过包含十几个属性的注解使用方看了头皮发麻搞不清哪些属性是必须的。后来砍到只剩必填项使用成本大大降低。真正复杂的配置可以放到SpEL表达式或 properties 文件中不要让注解承载太多配置压力。第三个注意事项是注解的默认值要合理。比如操作类型由于大多数数据变更场景是更新操作我把默认值设成了UPDATE这样对于大多数方法只需要关心表名。这个设计虽然小但实际使用中确实减少了大量冗余代码。4. AOP 切面核心实现从拦截到比对的关键链路4.1 切点表达式的选择注解驱动优于类级别通配在定义切面之前首先要解决切点表达式的问题。我有两种方案第一种是execution(* com.xxx.service..*.*(..))匹配所有 Service 方法第二种是annotation(com.xxx.annotation.DataChangeTrack)只匹配标注了注解的方法。我最终选择的是注解驱动原因很简单如果对 Service 里所有方法都做数据变更追踪首先性能上会有开销其次像查询方法、统计方法根本不需要记录。注解驱动的方式让使用方决定哪些方法需要追踪精准控制范围。切面类的核心结构如下Aspect Component public class DataChangeAspect { Around(annotation(dataChangeTrack)) public Object around(ProceedingJoinPoint joinPoint, DataChangeTrack dataChangeTrack) throws Throwable { // 前置处理获取入参按业务主键查询旧值 Object oldObject queryOldValue(joinPoint, dataChangeTrack); // 执行原方法 Object result joinPoint.proceed(); // 后置处理获取新值比对并生成变更记录 Object newObject queryNewValue(joinPoint, dataChangeTrack, result); generateChangeRecords(oldObject, newObject, dataChangeTrack); return result; } }这里的关键点在于annotation(dataChangeTrack)这个写法Spring AOP 会自动将方法上的注解实例绑定到dataChangeTrack参数上你不需要自己通过反射去获取注解非常方便。4.2 查询旧值方法执行前的一步关键操作获取旧值是整个链路中最重要的一步。我的做法是在切面的前置阶段从方法入参中解析出业务主键值然后通过通用 Mapper 或 JdbcTemplate 去查询旧数据。这里有一个细节入参可能是实体对象也可能是单个主键 ID还可能是多个参数。我设计了一个参数解析器支持三种情况第一种入参是一个实体对象且该对象包含了主键字段。比如updateCustomer(Customer customer)通过反射拿到customer.getId()。 第二种入参是一个主键值。比如updateCustomer(Long id, String name)这时候切面通过参数下标配置获取主键。 第三种入参是一个 DTODTO 中嵌套了实体。这种场景我用SpEL表达式来提取主键使用方在注解的businessIdSpEL属性中配置表达式。private Object extractBusinessId(JoinPoint joinPoint, DataChangeTrack dataChangeTrack) { Object[] args joinPoint.getArgs(); if (args null || args.length 0) { return null; } // 第一种情况入参是实体对象 Object firstArg args[0]; if (isEntityObject(firstArg)) { Field field findField(firstArg.getClass(), dataChangeTrack.businessIdField()); if (field ! null) { field.setAccessible(true); return field.get(firstArg); } } // 第二种情况入参是主键值 if (isSimpleType(firstArg)) { return firstArg; } return null; }如果extractBusinessId返回 null我建议直接放行业务方法不做变更记录同时打一条 warn 日志提示开发人员检查注解配置。这里有个很重要的原则切面不能因为审计逻辑的缺陷而影响正常业务流的执行所以我给所有异常都做了捕获处理只记录错误日志不向上抛。4.3 查询新值优先使用返回值没有返回值就重新查询方法执行成功后获取新值有两种路径。最理想的情况是方法返回了更新后的实体对象比如Customer updateCustomer(Customer customer)的返回值就是新数据直接作为新值。但有些更新方法返回的是void或者boolean这时候就需要重新查询。重新查询是使用旧值查询时相同的查询条件因为此时数据库里的数据已经是最新状态查出来的自然就是新值。这里需要注意的一个点是如果业务操作是物理删除方法执行后重新查询是查不到数据的。对于删除操作的追踪我的处理方式是前置阶段先完整查询一遍数据把旧值缓存起来方法执行成功后直接以缓存数据作为旧值新值置空表示记录已被删除。4.4 新旧值比对逐字段反射比对忽略不属于追踪范围的字段获取到新旧值之后最核心的部分就是字段比对。我写的比对逻辑是递归遍历对象的字段列表对每个字段做三层判断第一层判断字段是否标记了ChangeField(ignore true)如果标记了直接跳过。 第二层判断新旧值是否都为空如果是空值就不记录避免产生大量无意义的变更记录。 第三层判断值是否发生了变化。注意这里我做了类型兼容比如数字类型的Integer和Long虽然类型不同但数值相等也不算变更。private ListChangeRecord compareFields(Object oldObj, Object newObj, DataChangeTrack track) { ListChangeRecord records new ArrayList(); if (oldObj null || newObj null) { return records; } Field[] fields oldObj.getClass().getDeclaredFields(); for (Field field : fields) { ChangeField changeField field.getAnnotation(ChangeField.class); if (changeField ! null changeField.ignore()) { continue; } field.setAccessible(true); Object oldValue field.get(oldObj); Object newValue field.get(newObj); if (oldValue null newValue null) { continue; } if (!Objects.equals(oldValue, newValue)) { records.add(new ChangeRecord(...)); } } return records; }4.5 变更记录的保存异步落库不影响主流程性能比对生成变更记录后我通过 Spring 的事件机制做异步落库。具体做法是发布一个DataChangeEvent然后监听器加上Async注解异步处理避免写库操作阻塞主流程。定时任务批量合并写入、消息队列异步消费这些都是后续可以做的优化方向。我在第一版实现中是基于Async做异步落库并把线程池单独配置了核心线程数和队列容量避免默认线程池资源不足影响业务吞吐。5. 版本适配细节SpringBoot 2.x 和 3.x 下 AOP 的差异5.1 SpringBoot 3.x 为什么必须用 aspectjweaver老项目用 SpringBoot 2.x 的时候AOP 依赖相对简单引入spring-boot-starter-aop就可以直接使用。到了 SpringBoot 3.x项目基础换成了 Spring Framework 6 和 Jakarta EE 9很多底层命名空间都迁移了AOP 相关的依赖也必须同步升级。我在从 SpringBoot 2.3 升级到 3.2 的时候第一次启动直接就报ClassNotFoundException: org.aspectj.lang.annotation.Aspect。原因排查到最后是spring-boot-starter-aop在 3.x 中不再默认传递 aspectj 依赖需要手动添加aspectjweaver。这是很多升到高版本 SpringBoot 的人都会遇到的问题。dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-aop/artifactId /dependency dependency groupIdorg.aspectj/groupId artifactIdaspectjweaver/artifactId /dependency5.2 SpringBoot 3.x 中切面失效的三个隐形原因很多人在 SpringBoot 3.x 下遇到“切面不生效”的问题第一反应是依赖或者配置有问题但实际上有三个非常隐性的原因。第一个原因是事务注解和 AOP 的顺序问题。在 SpringBoot 3.x 中事务切面的 order 默认是最低优先级如果你定义了一个自定义切面且没有指定 order自定义切面默认也处于相同优先级这时候可能出现事务还没提交自定义切面就执行了后置逻辑导致新值查询查到了旧数据。解决方法是给切面设置 order让数据变更切面在事务提交之后执行Order(Ordered.LOWEST_PRECEDENCE - 1) Component Aspect public class DataChangeAspect { ... }第二个原因是自调用问题。同一个类中的方法直接调用不会经过 AOP 代理。如果你在一个 Service 方法中调用了同类中标注了DataChangeTrack的另一个方法切面是拦截不到的。第三个原因是类加载问题。SpringBoot 3.x 默认使用 CGLIB 代理如果你的业务类不是 public 的或者切点匹配的方法不是 public 的CGLIB 代理可能无法正常拦截。我把所有参与追踪的方法都强制要求是 public 且非 final。5.3 版本适配升级清单如果你正在从 SpringBoot 2.x 升级到 SpringBoot 3.x数据变更追踪这部分建议按以下清单检查检查javax.persistence或javax.validation依赖全部替换成jakarta.*命名空间否则启动阶段解析实体类的注解会报错。检查 Jackson 序列化配置SpringBoot 3.x 中 Jackson 2.15 对某些 Java 类型的序列化行为有调整可能导致旧值新值在转换为 JSON 时出现字段丢失。检查事务监听器如果你使用了TransactionalEventListener注意它在 SpringBoot 3.x 中的语义和 2.x 也有细微差异建议用phase TransactionPhase.AFTER_COMMIT显式声明。检查 AOP 代理方式SpringBoot 3.x 移除了spring.aop.proxy-target-class配置默认强制 CGLIB。如果你的切面逻辑里使用了 JDK 动态代理的相关知识需要做适配。6. 完整实战从零实现一个数据变更追踪模块6.1 数据库表设计变更记录表持久化层我设计了一张变更记录主表和一张变更详情子表。主表记录一次操作的基本信息子表记录该次操作涉及的字段级变更。两张表通过change_id关联。CREATE TABLE data_change_log ( id bigint NOT NULL AUTO_INCREMENT, table_name varchar(128) NOT NULL COMMENT 操作表名, business_id varchar(64) NOT NULL COMMENT 业务主键值, operation varchar(16) NOT NULL COMMENT 操作类型 INSERT/UPDATE/DELETE, operator varchar(64) DEFAULT NULL COMMENT 操作人, change_time datetime NOT NULL COMMENT 操作时间, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT数据变更主表; CREATE TABLE data_change_item ( id bigint NOT NULL AUTO_INCREMENT, change_id bigint NOT NULL COMMENT 变更主表ID, field_name varchar(128) NOT NULL COMMENT 字段名, field_label varchar(128) DEFAULT NULL COMMENT 字段中文名, old_value text COMMENT 旧值, new_value text COMMENT 新值, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT数据变更明细表;新旧值字段我用的是text类型而不是varchar因为有些字段内容可能很长比如备注、描述等。另外我加了索引在business_id和table_name上因为查历史变更记录时最常用的方式是按业务主键维度查询。6.2 引入 Maven 依赖创建工程时我只引入了一个核心依赖就是spring-boot-starter-aop它会带入spring-aop和aspectjweaver。我还额外引入了com.alibaba.fastjson2:fastjson2做 JSON 序列化以及commons-beanutils做属性的便捷操作。dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-aop/artifactId /dependency dependency groupIdcom.alibaba.fastjson2/groupId artifactIdfastjson2/artifactId version2.0.43/version /dependency dependency groupIdcommons-beanutils/groupId artifactIdcommons-beanutils/artifactId version1.9.4/version /dependency6.3 自定义注解定义这部分和前面设计一致两个注解类。在定义时我加入了一个Order注解的注意事项如果你的系统中已经有其他 AOP 切面比如日志切面、限流切面建议通过Order控制优先级保证数据变更切面和业务日志不互相干扰。6.4 AOP 切面实现切面类是整个模块的心脏我将核心逻辑写成了一个独立的ChangeRecorder组件AOP 切面只做拦截和调度不嵌入具体业务逻辑。这样的好处有两点第一后续如果想实现 RPC 接口级别的变更追踪可以直接调用ChangeRecorder切面复用第二单元测试可以直接测ChangeRecorder不需要启动 Spring 容器。Component public class ChangeRecorder { public void recordChange(Object oldObj, Object newObj, DataChangeTrack track) { if (oldObj null newObj null) { return; } String tableName track.tableName(); String businessId extractBusinessId(oldObj ! null ? oldObj : newObj, track); ListChangeItem items compareAndBuildItems(oldObj, newObj); if (items.isEmpty()) { return; } DataChangeLog log new DataChangeLog(); log.setTableName(tableName); log.setBusinessId(businessId); log.setOperation(track.operation()); log.setOperator(getCurrentOperator()); log.setChangeTime(new Date()); dataChangeLogMapper.insert(log); for (ChangeItem item : items) { item.setChangeId(log.getId()); dataChangeItemMapper.insert(item); } } }6.5 Controller 和 Service 的使用示例先看使用方代码。比如有一个客户更新接口原来长这样PostMapping(/customer/update) public RVoid update(RequestBody CustomerUpdateRequest request) { customerService.updateCustomer(request); return R.ok(); }改造后什么代码都不用加只需要在 Service 实现类的方法上加一个注解Service public class CustomerServiceImpl implements CustomerService { Override DataChangeTrack(tableName customer, operation UPDATE) public void updateCustomer(CustomerUpdateRequest request) { // 原有业务代码 customerMapper.updateById(request.toCustomer()); } }这里有一个非常重要的点DataChangeTrack加在实现类的方法上而不是接口的方法上。很多初学者把注解加在接口方法上发现切面不生效。原因是 Spring AOP 默认基于 CGLIB 代理代理的是实现类注解添加在接口上时在某些配置下是读不到接口注解的。正确的做法是注解直接加在实现类方法上或者同时加在接口和实现类上不加在接口上是不会生效的。6.6 查询旧值和新值的通用实现查询旧值的方法我用了一个通用 Mapper 组件是公司自研的基于 MyBatis 的轻量级封装。核心思路是根据tableName和businessId拼一个简单的 Repository通过 JdbcTemplate 执行查询。这里为什么要用 JdbcTemplate 而不是直接注入实体对应的 Mapper因为切面是通用组件它不应该知道具体的实体类否则就失去了通用性。用 JdbcTemplate 可以做到“表和实体的动态映射”用BeanPropertyRowMapper将查询结果动态映射成 LinkedHashMap然后再通过反射和实体类的ChangeField注解比对。private MapString, Object queryById(String tableName, Object id) { String sql SELECT * FROM tableName WHERE id ?; return jdbcTemplate.queryForObject(sql, new BeanPropertyRowMapper(Map.class), id); }这里需要一提的是直接用表名拼 SQL 有 SQL 注入风险所以在实现里tableName做了白名单校验只允许预定义好的表名集合不允许从外部传入任意字符串拼接。6.7 变更记录查看接口有了表结构和记录逻辑最后还需要一个给前端调用的查询接口。这个接口非常简单就是一个通过businessId和tableName查询data_change_log然后关联查data_change_item的汇总接口。我把返回结构组装成了时间线形式每条记录包含操作人、操作时间、操作类型下方是字段级变更明细列表。前端拿到这个结构直接渲染成一个历史上修改记录的时间轴组件效果非常直观。7. 实操中高频踩坑与解决方案7.1 JSON 序列化导致循环引用在记录某张表和另一张表存在关联关系时实体中出现了 A 对象包含 B 对象B 对象又包含 A 对象的情况。在将新旧值转 JSON 存储时直接报StackOverflowError。解决方案是在序列化时配置FastJson2的循环引用检测或者把关联字段标记ChangeField(ignore true)。我的建议是变更追踪关注的是业务字段的改动而不是对象图的完整快照所以关联对象默认全部忽略只追踪当前表的主干字段。7.2 注解param报错问题这里要补充一个常见问题有同学在自定义注解的interface中定义了名为param的属性结果在 IDEA 中一直标红报错。原因是param虽然不是 Java 的保留关键字但在某些上下文中有特殊含义不建议用作注解属性名。如果已经用了会怎么样编译可能通过但在使用注解时写DataChangeTrack(param xx)会导致代码提示异常。我建议自定义注解的属性名统一命名为value、name、key这类通用词汇避免踩坑。7.3 事务注解和 AOP 切面的执行顺序这是我在实际项目中调整过最多的地方。最初的实现中数据变更切面没有设置Order导致切面的后置逻辑在事务提交前执行。如果此时切面中重新查询新值由于事务还没有提交查询到的是旧数据于是就会出现“明明数据已经改成功了但变更记录里新值和旧值一样”的奇怪现象。解决方案就是给切面设置 order保证它在事务提交之后执行Aspect Component Order(Ordered.LOWEST_PRECEDENCE) public class DataChangeAspect { ... }如果你希望精确控制“事务提交后再执行后置逻辑”还可以使用TransactionalEventListener(phase TransactionPhase.AFTER_COMMIT)但这会让实现更复杂。我推荐先用Order控制顺序实测下来足够解决大部分问题。7.4 自调用导致切面失效这是一个每年都会有人问的问题。同一个 Service 类中方法 A 调用方法 B而方法 B 标注了DataChangeTrack切面是不会拦截方法 B 的。这是因为 Spring AOP 的代理机制基于对象引用方法 A 内部的this.methodB()调用的是原始对象的方法而没有经过代理对象。解决方法有三种第一种把方法 B 拆分到另一个 Service 类中通过注入的代理对象调用。这是最推荐的方式。 第二种在方法 A 中通过AopContext.currentProxy()获取代理对象再调用方法 B。 第三种方法 A 自己也加上DataChangeTrack让外部调用直接经过代理。实际项目中我用得最多的是第一种把切面方法拆到独立 Service既避免自调用问题也保障了代码的清晰性。7.5 SQL 查询时数字类型的精度丢失在将新旧值转 JSON 时如果字段是Long类型且值超过 17 位比如雪花 ID前端 JS 解析 JSON 时会发生精度丢失。这个问题在数据变更记录中也存在虽然记录里的 ID 存的是字符串但如果实体中的主键是 Long 类型在通过 JSON 序列化到日志中时就会变成精度丢失后的字符串。我的解决方法是在ChangeRecorder中拼接变更明细时将主键字段单独转为字符串处理不经过 JSON 自动序列化。字段值统一使用String.valueOf(field.get(obj))获取避免 Long 的精度问题。7.6Configuration中切面 Bean 重复定义还有一次我在项目中同时引入了自定义切面和某个第三方组件结果出现了“只有一个 ProceedingJoinPoint 被多次执行”的问题。排查到最后是第三方组件也定义了一个切面类类名和 Spring Bean 名冲突加上我们自己定义的切面类上标注了Component导致 Spring 容器中出现了两个同类型切面重复匹配。解决方法是给自定义切面类指定一个唯一的 Bean 名称比如Component(dataChangeAspect)并且在切点表达式中定义更精确的匹配规则。8. 进阶扩展从单表追踪到全链路审计8.1 支持删除操作的变更追踪常规记录更新操作很简单但删除操作的追踪需要特殊处理。物理删除后数据没了无法在方法执行后查新值所以我在标注删除方法时前端阶段强制先查询旧值缓存方法执行后直接以缓存旧值生成变更记录新值置空。我的具体实现如下if (DELETE.equals(operation)) { Object oldObj queryById(tableName, businessId); ListChangeItem items compareAndBuildItems(oldObj, null); // 保存记录newValue 为空 } else { Object oldObj queryById(tableName, businessId); Object result joinPoint.proceed(); Object newObj (result ! null) ? result : queryById(tableName, businessId); ListChangeItem items compareAndBuildItems(oldObj, newObj); // 保存记录 }8.2 支持批量操作的数据变更追踪例如批量更新updateBatch(ListCustomer customers)这种情况下业务主键不是一个而是一组 ID。我的做法是扩展注解增加一个batchField属性切面通过该属性从入参中提取列表数据然后循环处理每一条数据的变更。批量场景下还有一个性能问题如果有 500 条数据每条都产生 N 条变更明细一次操作可能产生几千条记录。落库时如果是一条条 insert性能很差。我的优化方案是采用JdbcTemplate.batchUpdate批量插入或者使用 MyBatis 的批量 insert 语法一次性绑定多条记录。8.3 审计日志与操作日志合并实际系统中往往既有“数据变更追踪”的需求又有“用户操作日志”的需求比如记录谁在什么时候点了哪个按钮。这两类需求可以共用一套底层的审计事件模型但展示维度不同。我目前的设计是数据变更追踪产生字段级变更明细操作日志记录行为级概要两者都写入一张统一的audit_event表通过event_type区分。这样可以避免两张表存储大量冗余的共性字段。9. 性能调优与线上运行经验9.1 异步落库的线程池配置异步写日志时默认的SimpleAsyncTaskExecutor不推荐使用因为它每个任务都会创建一个新线程高并发下可能会耗尽线程资源。我调整为自定义线程池核心线程数 4最大线程数 16队列容量 1000。这个配置下在日千万级流量的场景中观察日志写库的线程池从来没有出现任务积压的情况。Bean(dataChangeExecutor) public Executor dataChangeExecutor() { ThreadPoolTaskExecutor executor new ThreadPoolTaskExecutor(); executor.setCorePoolSize(4); executor.setMaxPoolSize(16); executor.setQueueCapacity(1000); executor.setThreadNamePrefix(data-change-); executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); executor.initialize(); return executor; }CallerRunsPolicy这个参数很关键当线程池满时不会丢弃任务而是由调用者线程来执行保证日志不丢失。9.2 大对象字段的变更对比优化如果一张表包含content这种大型文本字段全量比对时每次都要读取并反射效率很低。我的优化思路是对比时不直接读值而是先判断字段的 hash 值是否变化。也就是把oldValue和newValue的 hash 值提前存到内存里先比对 hash如果 hash 不一致再做全量反射比对。在具体实现中可以这样简化初次读取时计算 md5第二次读取时计算 md5然后比对 md5。若 md5 相同直接跳过该字段不生成变更记录。9.3 日志数据归档策略变更记录表增长很快一年下来可能积累上亿行。如果不做归档查询会越来越慢。我的方案是按月份做分表data_change_log_202501、data_change_log_202502这样。查询时如果业务方要查历史记录带上时间范围直接路由到对应分表。同时变更明细表虽然数据量大但基本没有更新很适合存到 ClickHouse 等列式存储中。如果团队规模不大可以先按 MySQL 分表 定期归档处理等真的到了瓶颈再迁移。10. 从一次事故中总结的三个经验我在实施这套方案的过程中印象最深的是一次线上真实事故。当时因为自调用问题切面没有拦截到某些更新方法导致业务方查不到几天前的某次关键操作记录差点引发客诉。排查过程非常曲折。先看了日志发现没有打出变更记录的日志怀疑是切面没生效于是检查切点表达式发现表达式没问题再检查方法上的注解发现注解也加了最后想到是不是自调用立即查看了相关 Service 的调用链果然是 Service 内部调用另一个方法时直接用了 this 调用没走代理。经历这次事故之后我总结出三条经验分享给正在使用或即将使用这套方案的同学。第一切面的生效范围必须通过测试保证。建议在开发环境专门建一个测试接口覆盖三种调用场景外部请求直接调用、同类内部调用、不同类之间调用用断言方式验证切面是否正常拦截。第二变更记录是业务数据的一部分。如果变更记录丢失正常的业务数据还可能在但审计链就断了。所以切面中的逻辑要保证在异常情况下不丢失数据。我的做法是异步任务失败时落一张本地失败记录表由定时任务扫描重试。第三不要试图用一套代码覆盖所有变更场景。不同业务的变更追踪需求差异很大比如资金类数据可能要求强一致、高实时而内容运营类数据可能只需要每日汇总。面对不同的业务场景要灵活调整实现方案机械地套用一套模板反而不合适。在我个人的实际使用中这套 SpringBoot AOP 注解的方案已经稳定跑了接近一年。项目从最初的几十个方法追踪扩展到现在的几百个方法追踪改造的成本几乎为零每次新增需求只要在方法上增加注解、在实体字段上补充中文标签就完成了接入。如果你正在为数据审计而头疼这套方案值得一试。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询