首页
学习
活动
专区
圈层
工具
发布

SpringBoot 实现自动数据变更追踪

我先直接说结论啊:SpringBoot 里搞“自动数据变更追踪”,核心就是两件事—— 一是把变更这件事标准化(表结构、字段、谁干的、什么时候干的), 二是想办法在业务代码“顺手”做这件事,而不是每个地方手写一堆日志 if else。

昨晚十一点多,测试同学给我发消息: “有个用户余额被改了,但是谁改的、改前多少、改后多少,全都查不到,你帮我看下呗?” 那一瞬间我就下定决心,不能再靠到处 grep 日志这种原始方式了,老老实实搞一套“自动追踪”的。

你想象一下,一个正常的审计记录,大概会关心这些东西:

哪张表被动了

哪一行(业务主键)

哪几个字段变了

之前的值是啥,现在的值是啥

谁改的(用户、系统)

什么时候改的

是新增、修改还是删除

这个操作属于哪条业务链路(traceId / 订单号之类)

只要这些都能落在一张表里,后面无论是排查问题、做审计、还是回放数据,其实都挺舒服的。

数据表先定死,不然后面都没法聊

我先给你看一个比较落地的“变更日志表”设计,名字随便叫一张,比如:data_change_log。

CREATE TABLE data_change_log (

  id              BIGINT PRIMARY KEY AUTO_INCREMENT,

  biz_type        VARCHAR(64)   NOT NULL,      -- 业务类型,比如 user、order

  biz_id          VARCHAR(64)   NOT NULL,      -- 业务主键,比如用户ID、订单ID

  field_name      VARCHAR(128)  NOT NULL,      -- 变更的字段名

  old_value       TEXT          NULL,          -- 旧值(字符串存一把,简单粗暴)

  new_value       TEXT          NULL,          -- 新值

  change_type     VARCHAR(16)   NOT NULL,      -- CREATE / UPDATE / DELETE

  operator        VARCHAR(64)   NULL,          -- 谁改的(用户名、userId、系统名)

  operator_ip     VARCHAR(64)   NULL,          -- 操作IP,排查问题有时很有用

  trace_id        VARCHAR(128)  NULL,          -- 链路追踪ID(比如从网关透传过来的)

  created_at      DATETIME      NOT NULL,      -- 什么时候改的

  extra           JSON          NULL           -- 预留点扩展信息

) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

这个表就一条原则:只追加,不修改。 谁动了什么,就插一条(或者多条)记录,永远不去 Update 这张表。

对应的 JPA 实体写一个也挺简单的:

@Entity

@Table(name = "data_change_log")

public class DataChangeLog {

  @Id

  @GeneratedValue(strategy = GenerationType.IDENTITY)

  private Long id;

  private String bizType;

  private String bizId;

  private String fieldName;

  @Column(columnDefinition = "TEXT")

  private String oldValue;

  @Column(columnDefinition = "TEXT")

  private String newValue;

  private String changeType;

  private String operator;

  private String operatorIp;

  private String traceId;

  private LocalDateTime createdAt;

  @Column(columnDefinition = "JSON")

  private String extra;

  // getter / setter 略

}

再配个 Repository:

public interface DataChangeLogRepository extends JpaRepository<DataChangeLog, Long> {

}

这样存储层就算定好了。

“谁改的 / 什么时候改的”这俩,直接用 Spring Data 的一套

很多人搞数据追踪,会在每张业务表上也加:created_at / updated_at / created_by / updated_by这些字段,这样你一眼就知道这行数据最近一次是谁动的。

这个 Spring Boot 自带就帮你搞好了,用 Spring Data JPA 的审计功能就行。

第一步,启用审计功能:

@EnableJpaAuditing

@SpringBootApplication

public class DemoApplication {

  public static void main(String[] args) {

      SpringApplication.run(DemoApplication.class, args);

  }

}

第二步,实现一个AuditorAware,告诉框架“当前是谁”:

@Component

public class SecurityAuditorAware implements AuditorAware<String> {

  @Override

  public Optional<String> getCurrentAuditor() {

      // 这里根据你们实际情况取:SecurityContext、ThreadLocal、请求头…

      // 简单点先写死,方便演示

      return Optional.ofNullable(UserContext.getCurrentUsername());

  }

}

第三步,搞一个公共的基类实体,所有需要审计的表继承一下:

@MappedSuperclass

@EntityListeners(AuditingEntityListener.class)

public abstract class BaseAuditEntity {

  @CreatedDate

  @Column(updatable = false)

  private LocalDateTime createdAt;

  @LastModifiedDate

  private LocalDateTime updatedAt;

  @CreatedBy

  @Column(updatable = false)

  private String createdBy;

  @LastModifiedBy

  private String updatedBy;

  // getter / setter 略

}

你自己的业务实体比如用户,就这样写:

@Entity

@Table(name = "user_info")

public class UserInfo extends BaseAuditEntity {

  @Id

  @GeneratedValue(strategy = GenerationType.IDENTITY)

  private Long id;

  private String username;

  private BigDecimal balance;

  private Integer status;

  // getter / setter 略

}

这样一来,“谁改的、什么时候改的”自动帮你填了,我们只需要再搞“改了什么”。

关键部分来了:怎么在不打扰业务代码的情况下做字段 diff

要做到“自动”,基本就是两条路:

依托 ORM 的生命周期回调(JPA EntityListener、Hibernate Interceptor)

用 Spring AOP 在 Service 层插一刀

我更喜欢第二种,因为逻辑都在 Service 层,思路很直观,也好调试。下面我用 AOP 的方案给你走一遍。

先定义一个标记用的注解,意思是“这个方法里的数据变更要被追踪”:

@Target(ElementType.METHOD)

@Retention(RetentionPolicy.RUNTIME)

public @interface TraceDataChange {

  // 业务类型,比如 "user"、"order"

  String bizType();

  // 从哪个参数拿业务对象下的主键字段

  String idField() default "id";

  // 只对哪些字段做追踪,不写就默认全部

  String[] includeFields() default {};

}

然后我们约定一个简单接口,只要实体实现了它,就说明它是“可追踪的”:

public interface ChangeTraceable {

  /**

   * 业务主键,可以用 id,也可以用业务号

   */

  String bizId();

  /**

   * 用于做 diff 的字段快照,key=字段名,value=字段值

   */

  Map<String, Object> toChangeSnapshot();

}

在实体上实现一下:

@Entity

@Table(name = "user_info")

public class UserInfo extends BaseAuditEntity implements ChangeTraceable {

  @Id

  @GeneratedValue(strategy = GenerationType.IDENTITY)

  private Long id;

  private String username;

  private BigDecimal balance;

  private Integer status;

  @Override

  public String bizId() {

      return String.valueOf(id);

  }

  @Override

  public Map<String, Object> toChangeSnapshot() {

      Map<String, Object> map = new LinkedHashMap<>();

      map.put("username", username);

      map.put("balance", balance);

      map.put("status", status);

      return map;

  }

  // getter / setter 略

}

你看,其实就是挑几个“关心的字段”放进一个 Map 里,后面 AOP 的时候拿这个 Map 做前后对比就行。

AOP 切一刀:更新前拍一张“旧照片”,更新后再拍一张“新照片”

大致思路:

进入方法的时候,把实体的快照拍下来(深拷贝一下 Map)。

方法执行完成后,再拍一张新的。

前后两个 Map 做 diff,找到真正变更的字段。

拼成DataChangeLog,丢给 Repository 保存。

代码我压一压,不写得太啰嗦:

@Aspect

@Component

public class DataChangeTraceAspect {

  private final DataChangeLogRepository logRepository;

  public DataChangeTraceAspect(DataChangeLogRepository logRepository) {

      this.logRepository = logRepository;

  }

  @Around("@annotation(traceDataChange)")

  public Object doTrace(ProceedingJoinPoint pjp, TraceDataChange traceDataChange) throws Throwable {

      // 1. 找出方法参数里第一个 ChangeTraceable

      ChangeTraceable target = findTargetEntity(pjp.getArgs());

      Map<String, Object> before = null;

      if (target != null) {

          before = new LinkedHashMap<>(target.toChangeSnapshot());

      }

      // 2. 执行业务逻辑

      Object result = pjp.proceed();

      // 3. 再拍一张快照

      if (target != null) {

          Map<String, Object> after = target.toChangeSnapshot();

          List<DataChangeLog> logs = buildChangeLogs(

                  traceDataChange.bizType(),

                  target.bizId(),

                  before,

                  after,

                  determineChangeType(before, after)

          );

          if (!logs.isEmpty()) {

              logRepository.saveAll(logs);

          }

      }

      return result;

  }

  private ChangeTraceable findTargetEntity(Object[] args) {

      if (args == null) {

          return null;

      }

      for (Object arg : args) {

          if (arg instanceof ChangeTraceable) {

              return (ChangeTraceable) arg;

          }

      }

      return null;

  }

  private String determineChangeType(Map<String, Object> before, Map<String, Object> after) {

      if (before == null || before.isEmpty()) {

          return "CREATE";

      }

      if (after == null || after.isEmpty()) {

          return "DELETE";

      }

      return "UPDATE";

  }

  private List<DataChangeLog> buildChangeLogs(String bizType,

                                              String bizId,

                                              Map<String, Object> before,

                                              Map<String, Object> after,

                                              String changeType) {

      List<DataChangeLog> list = new ArrayList<>();

      if (after == null) {

          after = Collections.emptyMap();

      }

      if (before == null) {

          before = Collections.emptyMap();

      }

      for (Map.Entry<String, Object> entry : after.entrySet()) {

          String field = entry.getKey();

          Object newVal = entry.getValue();

          Object oldVal = before.get(field);

          // 都为 null 或 equals 就没变化

          if (Objects.equals(oldVal, newVal)) {

              continue;

          }

          DataChangeLog log = new DataChangeLog();

          log.setBizType(bizType);

          log.setBizId(bizId);

          log.setFieldName(field);

          log.setOldValue(oldVal == null ? null : String.valueOf(oldVal));

          log.setNewValue(newVal == null ? null : String.valueOf(newVal));

          log.setChangeType(changeType);

          log.setOperator(UserContext.getCurrentUsername());

          log.setOperatorIp(UserContext.getCurrentIp());

          log.setTraceId(TraceContext.getTraceId());

          log.setCreatedAt(LocalDateTime.now());

          list.add(log);

      }

      return list;

  }

}

你大概看一下就能懂:

ChangeTraceable决定“哪些字段要被追踪”

@TraceDataChange决定“哪些方法需要追踪”

切面负责“拿快照 + 比较 + 保存日志”

业务代码里基本就只需要加一个注解。

业务方法长什么样?

比如我们有个更新用户信息的接口,正常代码可能是这样:

@Service

public class UserService {

  @Autowired

  private UserInfoRepository userInfoRepository;

  @TraceDataChange(bizType = "user")

  @Transactional

  public void changeBalance(Long userId, BigDecimal delta) {

      UserInfo user = userInfoRepository.findById(userId)

              .orElseThrow(() -> new IllegalArgumentException("用户不存在"));

      // 改余额

      user.setBalance(user.getBalance().add(delta));

      // 其他字段随便改

  }

}

整个方法里面你完全不用管“怎么记录变更”,你只要正常改对象,事务提交的时候 JPA 帮你 flush 到数据库,同时我们的切面已经把前后的快照对比完,并且往data_change_log写好了。

查询的时候,你再写个简单查询接口,就可以看到类似这种效果:

测试同学问“谁把钱冲多了”,你就直接查这张表。

新增、删除要不要追踪?

要不要,其实看你们业务场景,有些公司要求“所有数据操作都要留痕”,那就顺手也支持了。

新增的情况:

方法参数里的ChangeTraceable是个新对象,before可以直接认为是空 Map,changeType用"CREATE"。

那日志里 old_value 就全是 null,new_value 是当前值,很直观。

删除的情况有两种玩法:

逻辑删除:你本质上是把deleted = 1之类的字段改一下,那本来就是 UPDATE,上一套逻辑照样生效。

物理删除:

可以在删除之前,先查出实体,生成一条"DELETE"的日志,再删。

或者写一个deleteUser(UserInfo user)的方法,一样打上@TraceDataChange,AOP 里把 after 快照当成空 Map。

大概例子这样:

@TraceDataChange(bizType = "user")

@Transactional

public void deleteUser(UserInfo user) {

  userInfoRepository.delete(user);

}

determineChangeType里根据after是否为空来判断就是删除。

做完这套之后,几个必须提前想好的坑

说人话就是:别等上线了再发现这些点会把库打爆、把接口拖慢。

别什么字段都记

比如大文本、JSON、图片 URL 列表之类的,没必要每次都存 old/new。

在toChangeSnapshot()里只放真正需要的核心字段。

字符串长度要保守一点

有些值可能很长(例如备注),可以考虑截断存一部分,或者放到extra的 JSON 里,用压缩格式。

写日志这件事最好异步

上面 demo 是直接saveAll,在一些高并发场景下建议丢到 MQ 或者独立线程池里异步落库,避免影响主链路。

实在不想太复杂,至少可以用 Spring 的@Async包一层。

和事务绑在一起

记得切面方法本身要跑在事务里,要么复用业务方法上的@Transactional,要么自己声明事务边界。

否则主业务回滚了,变更日志却已经写进去了,那就尴尬了。

敏感字段别乱记明文

比如密码、身份证号这类,建议直接过滤掉,或者只存掩码 / hash。

跨多张表的一次操作

可以考虑统一生成一个traceId,放到 ThreadLocal 里,链路里的每一条DataChangeLog都带上这个 traceId,事后排查的时候一查就全串起来了。

简单扩展一下:用注解控制粒度

如果你们的实体多,而且每个实体要追踪的字段不太一样,还可以加一层“小修饰”:

比如定义一个标记字段的注解:

@Target(ElementType.FIELD)

@Retention(RetentionPolicy.RUNTIME)

public @interface TraceField {

  /**

   * 可选:显示名称,方便给运营看

   */

  String name() default "";

}

实体里这样标:

public class UserInfo extends BaseAuditEntity implements ChangeTraceable {

  @Id

  @GeneratedValue(strategy = GenerationType.IDENTITY)

  private Long id;

  @TraceField(name = "用户名")

  private String username;

  @TraceField(name = "余额")

  private BigDecimal balance;

  private Integer status;

  @Override

  public Map<String, Object> toChangeSnapshot() {

      Map<String, Object> map = new LinkedHashMap<>();

      for (Field field : this.getClass().getDeclaredFields()) {

          if (!field.isAnnotationPresent(TraceField.class)) {

              continue;

          }

          field.setAccessible(true);

          try {

              map.put(field.getName(), field.get(this));

          } catch (IllegalAccessException e) {

              // 忽略或者打个日志

          }

      }

      return map;

  }

}

这样你就不用手动一个个 put 了,想追踪哪个字段,就给哪个字段加个@TraceField就好了。

最后随便唠一句

这套东西写完,你会发现两个好处特别明显:

排查问题的时候再也不用各种翻业务日志、看谁调用了哪个接口了,一条 SQL 改了啥,清清楚楚。

新人来维护代码,不用提前接受“每次改数据记得补日志”的口头传承,框架级别帮他兜底了。

当然,线上真要用的话,还可以再往前走一步,比如接上可视化界面、做字段中文名映射、支持按人按时间导出变更记录之类的,这里就不展开了,我先去喝口水,有空再跟你聊怎么把这套变更日志接到审计平台里。

  • 发表于:
  • 原文链接https://page.om.qq.com/page/OpSB7p_Qh3U1iVT5xTsc34pg0
  • 腾讯「腾讯云开发者社区」是腾讯内容开放平台帐号(企鹅号)传播渠道之一,根据《腾讯内容开放平台服务协议》转载发布内容。
  • 如有侵权,请联系 cloudcommunity@tencent.com 删除。
领券