《Spring Boot + JPA 实战》第17招 数据变更要留痕
01、痛点
我是老李,早上刚坐下,运营群里就炸出一条消息:订单 O20241120001 十分钟前还是待支付,现在怎么变成已发货了?钱到底有没有收到?客服查了后台,只看到 status=SHIPPED,update_time=2024-11-20 14:32:11。至于之前是什么状态、谁点的发货、为什么点发货,全都不见了。
这不是个例。很多业务系统做到最后,订单表里只有一个 updated_at,勉强知道它最近被动过;有的加上了 updated_by,但只在少数入口写,批量脚本、定时任务、支付回调经常漏掉;更麻烦的是状态被覆盖后,旧值被直接冲掉,想还原现场得靠猜。结果就是系统知道自己“现在是什么”,却不知道“谁在什么时候把它改成这样”,更不知道“为什么这样改”。
等出了问题再翻 binlog、翻日志、找 DBA 还原,往往已经错过最佳处理时间。于是我们开始思考:到底要补齐哪些机制,才能让订单的创建、支付、发货全过程都可追溯?
02、是什么
先厘清几个容易混淆的概念。
Spring Data Auditing 提供了一组字段级注解:@CreatedDate、@LastModifiedDate、@CreatedBy、@LastModifiedBy。它们配合 JPA 的实体监听器,在插入和更新时自动填充创建时间、修改时间、创建人、修改人。注意,它只维护当前行上的字段,一旦 UPDATE 执行,旧值就被覆盖,历史版本不会保留。
Envers 是 Hibernate 的审计模块。用 @Audited 标注实体后,Envers 会自动生成对应的 _AUD 表,每次提交事务时写入一个修订版本。它能记录每一次变更前后的快照,还能通过 REVINFO 知道变更发生的时间和修订号。Envers 回答的是“这个实体在某一刻长什么样”。
业务历史表则是团队自己设计的领域流水表,通常叫 order_status_history 或类似名字。每发生一次关键状态变化,就插入一条记录,包含 from_status、to_status、reason、operator、event_type、occurred_at 等。它不追求保留整行的所有字段,而是保留业务动作的含义。
三者放在一起看:Spring Data Auditing 管当前行的审计元数据;Envers 管实体的版本快照;业务历史表管业务状态变迁的原因。前两者偏技术,最后一个偏领域。
03、为什么
你可能会问:只加 updated_at、updated_by 不够吗?为什么还要 Envers 和业务历史表?
先看一个真实场景:支付回调把订单从 CREATED 改成 PAID,仓储系统十分钟后把订单从 PAID 改成 SHIPPED。订单表只留下 updated_at 和 updated_by,你能看到的只是最后修改人是 wms_system,时间是 14:32:11。可是如果客户投诉“我没收到货,订单却显示已发货”,你无法知道支付回调是否成功、订单是否经过 PAID、是否有人从 CREATED 直接跳到 SHIPPED。
Envers 可以帮你还原:假设订单被改了三次,Envers 里会有三个版本,你能看到 CREATED 的版本、PAID 的版本、SHIPPED 的版本。但 Envers 仍然回答不了“为什么从 PAID 到 SHIPPED”——是仓储正常发货,还是客服手工强制发货?是用户修改了地址后重新发货,还是系统误触发?这些业务意图,藏在当时操作人的脑袋里,或者藏在另一张工单、另一条日志里。
业务历史表的必要性就在这里。订单状态变化不只是数据更新,而是一个业务事件。事件需要记录触发原因、操作类型、操作人、来源系统、关联单号。比如 PAID 的原因可能是“支付宝异步通知,交易号 2024112000123456”;SHIPPED 的原因可能是“仓库 WMS 出库单 OUT202411200099”。有了这些信息,排障、合规、客服对客解释都能快速完成。
一句话:技术审计让你知道“谁改过”,领域审计让你知道“为什么变”。真实系统如果只有前者,业务追溯会非常痛苦。
04、架构
要把审计做好,不能只靠一个工具。我的建议是三层组合。
第一层,实体审计字段。订单表等核心实体增加 created_at、updated_at、created_by、updated_by 四个字段,由 Spring Data Auditing 自动填充。这一层成本最低,先解决“当前行是谁最后碰过”的问题。
第二层,实体版本审计。对 orders 主表开启 Envers,自动生成 orders_AUD 和 REVINFO。这样任何一次 UPDATE 都会留下历史快照,用于回溯纠纷、恢复误操作、检查字段变更轨迹。
第三层,领域状态历史。为订单状态机单独建 order_status_history 表,每次状态变化写入一条带原因的事件。注意,状态历史表不要试图保存整个订单的所有字段,它只记录业务状态流转的上下文。
数据库 Trigger 放在哪里?Trigger 可以在数据库层无侵入地记录行级变更,适合旧系统、多写入口、无法统一改造应用层的场景。但 Trigger 的缺点是只能看到数据变化,不知道业务意图,还容易造成性能开销和隐蔽逻辑。JPA Auditing 适合应用层能统一管理的系统,业务审计表适合需要表达领域语义的场景。
可以用一个简单对比理解:
- 数据库 Trigger:适合兜底、跨应用、快速上线;弱点是业务语义弱、维护成本高。
- JPA Auditing:适合统一应用入口、需要轻量审计字段;弱点是只有当前值,无历史版本。
- Envers:适合实体变更历史、版本回溯;弱点是偏技术快照,缺少业务原因。
- 业务审计表:适合状态机、关键业务流转;弱点是需要业务建模和代码纪律。
真实架构通常不是四选一,而是按需组合:关键表三层都上,普通表只用审计字段,旧系统用 Trigger 兜底。
05、实战
下面以 Order 为例,把创建人、修改人、创建时间、更新时间和订单状态历史补齐。
第一步,给订单实体加审计字段。
@Entity
@Table(name = "orders")
@EntityListeners(AuditingEntityListener.class)
public class Order {
@Id
private String orderNo;
@Enumerated(EnumType.STRING)
private OrderStatus status;
@CreatedDate
private LocalDateTime createdAt;
@LastModifiedDate
private LocalDateTime updatedAt;
@CreatedBy
private String createdBy;
@LastModifiedBy
private String updatedBy;
@Version
private Long version;
// 省略 getter/setter
}
第二步,开启审计并配置操作人来源。
@Configuration
@EnableJpaAuditing(auditorAwareRef = "auditorProvider")
public class JpaConfig {
@Bean
public AuditorAware<String> auditorProvider() {
return () -> Optional.ofNullable(SecurityContextHolder.getContext())
.map(ctx -> ctx.getAuthentication())
.map(auth -> auth.getName())
.or(() -> Optional.of(getDefaultUser()));
}
private String getDefaultUser() {
return "system";
}
}
这里的 getDefaultUser 可以在定时任务、支付回调等没有登录用户的场景返回系统账号。
第三步,定义订单状态和事件类型。
public enum OrderStatus {
CREATED, PAID, SHIPPED, CANCELLED
}
public enum OrderEventType {
CREATE, PAY, SHIP, CANCEL
}
第四步,建立订单状态历史表。
@Entity
@Table(name = "order_status_history")
public class OrderStatusHistory {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String orderNo;
@Enumerated(EnumType.STRING)
private OrderStatus fromStatus;
@Enumerated(EnumType.STRING)
private OrderStatus toStatus;
@Enumerated(EnumType.STRING)
private OrderEventType eventType;
private String reason;
private String operator;
private LocalDateTime occurredAt;
// 省略 getter/setter
}
第五步,把状态变更收敛到一个服务里,保证状态历史与状态变更同事务。
@Service
public class OrderStateService {
private final OrderRepository orderRepository;
private final OrderStatusHistoryRepository historyRepository;
public OrderStateService(OrderRepository orderRepository,
OrderStatusHistoryRepository historyRepository) {
this.orderRepository = orderRepository;
this.historyRepository = historyRepository;
}
@Transactional
public void ship(String orderNo, String operator, String reason) {
Order order = orderRepository.findByOrderNo(orderNo)
.orElseThrow();
OrderStatus fromStatus = order.getStatus();
if (fromStatus != OrderStatus.PAID) {
throw new IllegalStateException();
}
order.setStatus(OrderStatus.SHIPPED);
orderRepository.save(order);
OrderStatusHistory history = new OrderStatusHistory();
history.setOrderNo(orderNo);
history.setFromStatus(fromStatus);
history.setToStatus(OrderStatus.SHIPPED);
history.setEventType(OrderEventType.SHIP);
history.setReason(reason);
history.setOperator(operator);
history.setOccurredAt(LocalDateTime.now());
historyRepository.save(history);
}
}
关键点不是保存动作本身,而是把状态变更和历史写入放在同一个事务里。这样一旦历史写失败,订单状态也会回滚,不会出现状态变了但历史没记的情况。
第六步,查询订单全链路。
SELECT from_status, to_status, event_type, reason, operator, occurred_at
FROM order_status_history
WHERE order_no = 'O20241120001'
ORDER BY occurred_at ASC;
预期结果:
CREATED -> PAID | PAY | 支付宝回调,交易号 2024112000123456 | payment_gateway | 2024-11-20 14:20:11
PAID -> SHIPPED | SHIP | 仓库 WMS 出库单 OUT202411200099 | wms_system | 2024-11-20 14:32:11
这样从 CREATED 到 PAID 再到 SHIPPED 的全过程,每一步都有操作人、原因和时间,追溯链路完整。
06、洞见
技术审计和领域审计的分界线,常常被忽视。
技术审计回答“谁改了记录”。例如 updated_by=wangwu,updated_at=2024-11-20 14:32:11,这只能说明王五最后碰过这条记录。至于他为什么改,是客户投诉、领导指令、系统自动触发还是误操作,技术审计本身不提供答案。
领域审计回答“为什么业务状态发生改变”。它把状态变化建模成事件:从什么到什么,由哪个业务动作触发,关联了哪些单据,原因是什么。比如“PAID -> CANCELLED,原因是用户申请退款,退款单 R123,客服 wangwu 审批通过”。这条信息不只是审计证据,更是业务知识。
如果只做技术审计,系统会在排障时频繁出现“改了但不知道为什么改”的情况。更糟糕的是,随着时间推移,当事人离职、记忆模糊,历史原因就彻底丢失。领域审计的价值之一,就是把这些业务上下文沉淀下来,变成可查询的资产。
订单状态尤其典型。CREATED、PAID、SHIPPED 不只是数据库字段值,它们对应商务流程的关键节点。付款、发货、取消,每个节点的原因都可能涉及资金、库存、客户承诺。因此,订单状态历史应该按事件建模,而不是只记一个修改痕迹。
07、行动
要让订单从 CREATED 到 PAID 再到 SHIPPED 全过程可追溯,我建议按下面顺序动手。
第一,先给订单表补上 created_at、updated_at、created_by、updated_by 四个字段。存量数据如果无法准确回填,可以先统一置为 system,后续再逐步修正。
第二,开启 Spring Data Auditing 和 AuditorAware,把当前登录用户、系统来源接入进来。支付回调、仓储回传、定时任务必须显式传入系统账号,不能留空。
第三,用 Envers 给 orders 表开启审计,生成版本快照。这样即使状态历史表漏记,也还有实体快照可以回溯。
第四,设计 order_status_history 表。字段至少包含 order_no、from_status、to_status、event_type、reason、operator、occurred_at,最好再加 request_id 或 trace_id,方便关联整条调用链。
第五,把订单状态变更全部收敛到 OrderStateService,不允许在业务代码里直接 update 状态。所有入口先校验状态机,再更新订单,最后写历史,保证同一事务。
第六,对支付、发货、取消等关键节点,逐一补上业务原因。不要只写一个“系统操作”,要写清楚来源单号、通知流水号、操作依据。
第七,加一条端到端测试:创建订单后支付、再发货,断言 status 为 SHIPPED,且 order_status_history 中恰好有 CREATED->PAID 和 PAID->SHIPPED 两条记录。这样以后谁把状态机写坏,测试会直接失败。
第八,如果老系统短期改不动,可以先用数据库 Trigger 记录行级变更兜底。但要明确 Trigger 只是过渡方案,业务语义最终还是要靠领域审计表。
08、小结
审计不是给开发增加负担,而是给系统增加可解释性。
created_at 和 updated_at 告诉你记录的生命周期;created_by 和 updated_by 告诉你最后是谁碰过它;Envers 让你能回看任意历史版本;order_status_history 则让你知道订单为什么从一个状态走到另一个状态。四样东西放在一起,才是一条完整的追溯链。
真实业务系统不能只知道“现在是什么”,还要知道“谁在什么时候把它改成这样”,更要知道“为什么这样改”。技术审计和领域审计不是替代关系,而是搭档关系。前者负责记录事实,后者负责解释业务。
09、链接与概念说明
- Spring Data Auditing:Spring Data 提供的基础能力,可通过 @CreatedDate、@LastModifiedDate、@CreatedBy、@LastModifiedBy 与 AuditorAware 自动填充审计字段。详细用法见 Spring Data JPA 官方文档。
- Envers:Hibernate ORM 的实体审计模块。使用 @Audited 后自动生成 _AUD 历史表和 REVINFO 修订表,适合做实体版本快照。详细配置见 Hibernate Envers 官方指南。
- 业务历史表:由团队按业务状态机设计的流水表,核心字段建议包含 from_status、to_status、event_type、reason、operator、occurred_at、request_id。
- 数据库触发器:数据库级别的自动动作,可在 INSERT、UPDATE、DELETE 时触发,适合旧系统或跨应用的行级变更兜底。缺点是业务语义弱,通常不作为主要领域审计方案。
- 技术审计与领域审计:技术审计关注记录和字段的修改事实;领域审计关注业务状态变化的触发原因与上下文。本文用订单状态历史展示两者如何协同。

浙公网安备 33010602011771号