SpringBoot 实现自动数据变更追踪
前两天晚上十一点多,我在公司楼下便利店等泡面,运维给我打电话,说业务那边要查一条订单: “这个订单金额谁改过?改了几次?原来是多少钱?”
这问题你们应该也遇到过,对吧。日志翻半天,查不到细节,只能模糊回答一句“应该是某次接口更新的”。人家业务不买账: “那我系统里每条数据到底谁动过,我能不能随时翻出来看?”
所以今天就聊聊,在 Spring Boot 里搞一个“自动的数据变更追踪”机制,用 Java 落地,做到尽量少改业务代码,就能记住:谁在什么时候,把哪条数据的哪些字段,从什么值改成了什么值。
先说清楚,要追踪什么
一般业务方要的无非这几件事:
谁改的(用户 ID / 用户名) 什么时候改的(时间) 改了哪一条(表名 + 主键) 改了哪些字段(字段名) 改前什么值,改后什么值
前两个比较好搞,用审计字段就够了;后面三个,就得上“变更日志表”这一套了。
所以可以拆成两个层次:
轻量级:每张业务表都有 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 ...
}
关键问题来了:这个日志谁来写?
常见有三种思路:
业务代码里手动写(很蠢,容易漏) 数据库里写触发器(改 DBA,开发不可控) 用 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