程序员老鬼

SpringBoot 实现自动数据变更追踪

前两天晚上十一点多,我在公司楼下便利店等泡面,运维给我打电话,说业务那边要查一条订单: “这个订单金额谁改过?改了几次?原来是多少钱?”

这问题你们应该也遇到过,对吧。日志翻半天,查不到细节,只能模糊回答一句“应该是某次接口更新的”。人家业务不买账: “那我系统里每条数据到底谁动过,我能不能随时翻出来看?”

所以今天就聊聊,在 Spring Boot 里搞一个“自动的数据变更追踪”机制,用 Java 落地,做到尽量少改业务代码,就能记住:谁在什么时候,把哪条数据的哪些字段,从什么值改成了什么值。


先说清楚,要追踪什么

一般业务方要的无非这几件事:

  1. 谁改的(用户 ID / 用户名)
  2. 什么时候改的(时间)
  3. 改了哪一条(表名 + 主键)
  4. 改了哪些字段(字段名)
  5. 改前什么值,改后什么值

前两个比较好搞,用审计字段就够了;后面三个,就得上“变更日志表”这一套了。

所以可以拆成两个层次:

  • 轻量级:每张业务表都有 created_at / updated_at / created_by / updated_by
  • 进阶版:单独建一张 data_change_log,里面详细记录每次修改的差异(diff)

下面一步一步来整。


先把基础审计字段补齐

这个是 Spring Data JPA 自带的能力,用起来比较顺:

步骤大概三步:

1)引入依赖(如果你已经用 JPA,可以跳过)

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>

2)开启 JPA 审计

写一个配置类:

@Configuration
@EnableJpaAuditing
publicclassJpaAuditConfig{

// 告诉审计框架当前是哪个用户
@Bean
public AuditorAware<String> auditorProvider(){
return () -> {
// 正常项目这里从登录信息里拿,比如 SecurityContext
// 这里为了演示写死
            String currentUser = UserContext.getCurrentUsername(); // 你自己封装
return Optional.ofNullable(currentUser).or(() -> Optional.of("system"));
        };
    }
}

UserContext 这种一般是你自己搞个 ThreadLocal 存登录态,或者从 Spring Security 里取。

3)抽一个带审计字段的父类

@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
publicabstractclassBaseAuditEntity
{

@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
publicclassOrderextendsBaseAuditEntity{

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

private BigDecimal amount;

private String status;

// ...
}

这样一搞,谁创建、谁修改、啥时候改的,数据库里就都有了,不用手动赋值。


上强度:把“改了啥”记下来

很多时候只知道“更新过”不够,要知道“改动内容”。比如:

  • 金额从 100 改到 150
  • 状态从 PENDING 改到 PAID

一般做法是再建一张“变更日志表”。

可以设计成这样:

@Entity
@Table(name = "data_change_log")
publicclassDataChangeLog{

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

// 表名
private String tableName;

// 记录主键
private String bizId;

// 操作人
private String operator;

// 操作时间
private LocalDateTime operateTime;

// 操作类型:INSERT / UPDATE / DELETE
private String operateType;

// 变化前内容(JSON)
@Lob
private String beforeData;

// 变化后内容(JSON)
@Lob
private String afterData;

// 只记录变化字段的 diff(可选)
@Lob
private String diffData;

// 业务备注,方便查
private String remark;

// getter / setter ...
}

关键问题来了:这个日志谁来写?

常见有三种思路:

  1. 业务代码里手动写(很蠢,容易漏)
  2. 数据库里写触发器(改 DBA,开发不可控)
  3. 用 Spring / JPA 的事件机制自动写(推荐)

下面用 JPA 的实体监听,把 INSERT / UPDATE / DELETE 都拦住。


用 EntityListener 自动落变更日志

JPA 支持在实体上挂监听器,监听生命周期事件,比如:

  • @PostPersist:插入之后
  • @PostUpdate:更新之后
  • @PostRemove:删除之后
  • @PostLoad:查出来之后

我们搞一个通用的监听器:

1)定义一个接口,标记“需要追踪”的实体

publicinterfaceTrackable{

/**
     * 返回业务主键,比如订单号、用户ID
     */

String getBizId();

/**
     * 返回表名,用于日志里标记
     */

default String getTableName(){
returnthis.getClass().getSimpleName();
    }
}

让需要追踪的实体实现它:

@Entity
@EntityListeners(DataChangeEntityListener.class)
publicclassOrderextendsBaseAuditEntityimplementsTrackable
{

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

private BigDecimal amount;

private String status;

// 例子中就用 id 当 bizId
@Override
public String getBizId(){
return id == null ? null : id.toString();
    }
}

2)写监听器,把实体的“前后状态”转成 JSON 存日志表

难点是:@PostUpdate 的时候,只拿得到“更新后的对象”,旧值怎么拿? 一个比较实用、又不太绕的办法是:在 @PostLoad 的时候,先把当前对象快照一份存起来,更新时拿旧快照跟新对象对比。

示例代码:

@Component
publicclassDataChangeEntityListener{

// 用一个 Map 存快照(演示用,生产可以用更安全的方式)
privatestaticfinal Map<String, String> SNAPSHOT_CACHE = new ConcurrentHashMap<>();

privatestatic ObjectMapper objectMapper = new ObjectMapper();

privatestatic DataChangeLogRepository changeLogRepository;

// 监听器本身是由 JPA 创建的,不能直接 @Autowired
// 静态注入一下仓库
@Autowired
publicvoidsetChangeLogRepository(DataChangeLogRepository repo){
        DataChangeEntityListener.changeLogRepository = repo;
    }

@PostLoad
publicvoidpostLoad(Object entity){
if (!(entity instanceof Trackable trackable)) {
return;
        }
try {
            String key = buildKey(trackable);
            String json = objectMapper.writeValueAsString(entity);
            SNAPSHOT_CACHE.put(key, json);
        } catch (Exception e) {
// 打个日志就行,别影响主流程
        }
    }

@PostPersist
publicvoidpostPersist(Object entity){
if (!(entity instanceof Trackable trackable)) {
return;
        }
        saveLog(trackable, "INSERT", null, toJson(entity));
    }

@PostUpdate
publicvoidpostUpdate(Object entity){
if (!(entity instanceof Trackable trackable)) {
return;
        }
        String key = buildKey(trackable);
        String before = SNAPSHOT_CACHE.get(key);
        String after = toJson(entity);
        String diff = buildDiff(before, after);
        saveLog(trackable, "UPDATE", before, after, diff);
// 更新快照
        SNAPSHOT_CACHE.put(key, after);
    }

@PostRemove
publicvoidpostRemove(Object entity){
if (!(entity instanceof Trackable trackable)) {
return;
        }
        String key = buildKey(trackable);
        String before = SNAPSHOT_CACHE.remove(key);
        saveLog(trackable, "DELETE", before, null);
    }

privatevoidsaveLog(Trackable trackable,
                         String type,
                         String before,
                         String after)
{
        saveLog(trackable, type, before, after, null);
    }

privatevoidsaveLog(Trackable trackable,
                         String type,
                         String before,
                         String after,
                         String diff)
{
if (changeLogRepository == null) {
return;
        }
        DataChangeLog log = new DataChangeLog();
        log.setTableName(trackable.getTableName());
        log.setBizId(trackable.getBizId());
        log.setOperateType(type);
        log.setBeforeData(before);
        log.setAfterData(after);
        log.setDiffData(diff);
        log.setOperateTime(LocalDateTime.now());
        log.setOperator(
                Optional.ofNullable(UserContext.getCurrentUsername())
                        .orElse("system")
        );
        changeLogRepository.save(log);
    }

private String buildKey(Trackable trackable){
return trackable.getTableName() + ":" + trackable.getBizId();
    }

private String toJson(Object obj){
try {
return objectMapper.writeValueAsString(obj);
        } catch (Exception e) {
returnnull;
        }
    }

/**
     * 简单的 diff,只比对字段是否相等,真实项目可以用更复杂的方案
     */

private String buildDiff(String before, String after){
if (before == null || after == null) {
returnnull;
        }
try {
            JsonNode beforeNode = objectMapper.readTree(before);
            JsonNode afterNode = objectMapper.readTree(after);

            ObjectNode diffNode = objectMapper.createObjectNode();
            Iterator<String> fieldNames = afterNode.fieldNames();
while (fieldNames.hasNext()) {
                String field = fieldNames.next();
                JsonNode b = beforeNode.get(field);
                JsonNode a = afterNode.get(field);
if (!Objects.equals(b, a)) {
                    ObjectNode item = objectMapper.createObjectNode();
                    item.set("before", b);
                    item.set("after", a);
                    diffNode.set(field, item);
                }
            }
return diffNode.toString();
        } catch (Exception e) {
returnnull;
        }
    }
}

配套的仓库很简单:

publicinterfaceDataChangeLogRepositoryextendsJpaRepository<DataChangeLog, Long> {
}

这样,只要你的实体:

  • 实现了 Trackable
  • 标了 @EntityListeners(DataChangeEntityListener.class)

所有的插入、更新、删除,都会自动写日志表。


说几个容易踩的坑

这种“自动追踪”一旦上了生产,坑还是不少的,类似 Spring Boot 默认配置那一堆坑,不改迟早翻车

1)事务问题

一定要保证写业务数据和写变更日志在一个事务里,不然就会出现:

  • 数据改成功,日志没写;
  • 或者日志有了,数据没改成功。

如果你用的是 JPA + Spring @Transactional,那上面的监听器默认就是在同一个事务里的,问题不大。 但如果中间你又用了多数据源、异步操作,就得小心了。

2)性能问题

变更日志表很容易长得飞快:

  • 每次 UPDATE 一条日志
  • 大表高频更新,几个月下来就是几千万行

建议:

  • 必须加索引:如 (table_name, biz_id, operate_time)
  • 按时间做分表或归档,老数据定期转冷存储
  • beforeData / afterData 用 JSON 存的时候,字段别太多,必要时只保留关键字段

3)敏感字段别乱记

比如用户密码、身份证号、银行卡号,这种字段:

  • 要么别进追踪日志
  • 要么进之前做脱敏 / 加密

不然审计是有了,安全又出新坑。

buildDiff 那里可以加一个“敏感字段列表”,遇到这些字段就只记“***”。

4)非 JPA 更新

如果你项目里 JPA + MyBatis 混用,或者直接写 JdbcTemplate 执行 SQL,那上面这套监听器是感知不到的。

这种情况有俩选项:

  • 要么约定:所有需要追踪的表,只能通过 JPA 操作
  • 要么对 MyBatis 层再做一层拦截,比如用拦截器记录 update / delete 的 where 条件 & old value(会复杂不少)

一般业务里,先搞一版“只追踪关键表 + 只通过 JPA 改”的就够用了,后面再慢慢补。


怎么查这些变更记录

前面都是“写”,最后简单说一下“看”。

可以给运营/风控做一个简单的查询页面,大概长这样:

  • 条件:

    • 表名(下拉)
    • 业务 ID(比如订单号)
    • 操作人
    • 时间区间
  • 列表:

    • 操作时间
    • 操作人
    • 操作类型
    • 变更概要(比如“amount: 100 -> 150, status: PENDING -> PAID”)
  • 点开一条:

    • 左边“变更前”JSON
    • 右边“变更后”JSON
    • 中间高亮差异字段

接口很简单,按表名 + bizId 查就行:

publicinterfaceDataChangeLogRepositoryextendsJpaRepository<DataChangeLog, Long> {

List<DataChangeLog> findByTableNameAndBizIdOrderByOperateTimeDesc(String tableName, String bizId);
}

Controller 就是普通的分页查询,这里就不展开了。


整体串一下,思路就是:

  • 先用 Spring Data JPA 的审计把“谁”和“什么时候”解决掉;
  • 再用实体监听 + 快照 + JSON diff,把“改了啥”落到专门的变更日志表;
  • 最后用一个简单后台页面,把这些日志查出来给业务看。

这样,下次有人半夜问你: “这个订单是谁在几点几分,把金额从 100 改成 150 的?”

你就不用再去翻半天日志了,直接按表名+主键查一眼就能说清楚。

行了,差不多先聊到这,我去泡杯咖啡,等会还有个接口要改…

-END-

我为大家打造了一份RPA教程,完全免费:songshuhezi.com/rpa.html

最后给大家分享一份不错的副业资料,点击下方公众号,回复关键字: 副业 领,也可以链接我微信:hls404