我先直接说结论啊: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 改了啥,清清楚楚。
新人来维护代码,不用提前接受“每次改数据记得补日志”的口头传承,框架级别帮他兜底了。
当然,线上真要用的话,还可以再往前走一步,比如接上可视化界面、做字段中文名映射、支持按人按时间导出变更记录之类的,这里就不展开了,我先去喝口水,有空再跟你聊怎么把这套变更日志接到审计平台里。