Spring Boot + MyBatis 业务数据审计留痕实施方案
1. 目标与适用范围
本文给出一套可直接落地到 Spring Boot + MyBatis + MySQL 8 的业务数据审计方案,覆盖:
- 单表新增、修改、删除、恢复;
- 一个业务操作修改多张表;
- 批量更新、批量删除和数据导入;
- 事务回滚、并发修改和幂等处理;
- 用户操作、系统任务和接口调用的操作者记录;
- 历史查询、差异展示和按历史版本恢复。
核心原则是:
- 业务表只保存当前状态;
- 审计表只追加,不更新、不删除;
- 业务变更和审计写入必须处于同一个数据库事务;
- Service 负责业务事务和业务语义,审计组件负责快照、差异和审计表写入;
- 不在每个 Service 中重复编写审计 SQL,也不在 Mapper 层猜测业务含义。
2. 总体架构
HTTP/RPC 请求
|
v
AuditContextFilter
|
v
业务 Service (@Transactional)
|
+-- Mapper:修改当前表
|
+-- AuditTemplate:读取前后快照
|
+-- AuditService
|
+-- biz_audit_record(追加式审计表)Service 是事务入口,但不直接拼装审计表 SQL。简单 CRUD 使用 AuditTemplate,复杂聚合操作显式传入多个审计项。JPA Envers 不在本文范围内。
3. 数据库设计
3.1 当前业务表
每个需要审计的当前表建议增加版本号和更新时间:
ALTER TABLE person_profile
ADD COLUMN version_no BIGINT NOT NULL DEFAULT 1,
ADD COLUMN status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
ADD COLUMN created_by VARCHAR(64),
ADD COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
ADD COLUMN updated_by VARCHAR(64),
ADD COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3);version_no 同时用于乐观锁和审计版本,不使用 SELECT MAX(version_no) + 1 分配版本号,因为并发下会产生冲突。
3.2 通用审计表
CREATE TABLE biz_audit_record (
id BIGINT NOT NULL AUTO_INCREMENT,
operation_id VARCHAR(64) NOT NULL,
tenant_id VARCHAR(64) NULL,
biz_type VARCHAR(64) NOT NULL,
biz_id VARCHAR(64) NOT NULL,
version_no BIGINT NOT NULL,
op_type VARCHAR(20) NOT NULL,
op_time DATETIME(3) NOT NULL,
operator_id VARCHAR(64) NOT NULL,
operator_name VARCHAR(128) NULL,
source VARCHAR(32) NOT NULL DEFAULT 'WEB',
client_ip VARCHAR(64) NULL,
request_id VARCHAR(64) NULL,
reason VARCHAR(500) NULL,
before_snapshot JSON NULL,
after_snapshot JSON NULL,
changed_fields JSON NULL,
prev_hash VARCHAR(64) NULL,
row_hash VARCHAR(64) NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_biz_version (biz_type, biz_id, version_no),
KEY idx_biz_time (biz_type, biz_id, op_time),
KEY idx_operation (operation_id),
KEY idx_operator_time (operator_id, op_time),
KEY idx_tenant_time (tenant_id, op_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;快照约定:
CREATE:before_snapshot = NULL,after_snapshot = 创建后的完整对象;UPDATE:同时保存修改前和修改后的完整对象;DELETE:before_snapshot = 删除前完整对象,after_snapshot = NULL;RESTORE:after_snapshot = 恢复后的完整对象,并产生新的版本;changed_fields保存字段级差异,用于历史页面展示,不能替代完整快照。
如果一个业务操作修改多个实体,所有记录使用相同的 operation_id。如果系统是多租户,所有审计查询必须强制带 tenant_id 条件。
4. Java 基础组件
4.0 依赖建议
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
</dependencies>若未使用 Spring Security,可用自有认证组件替换 SecurityContextHolder 的取值逻辑,但 AuditContextHolder 的写入和清理方式不变。
4.1 操作类型与上下文
public enum AuditOpType {
CREATE, UPDATE, DELETE, RESTORE
}public record AuditContext(
String tenantId,
String operatorId,
String operatorName,
String source,
String clientIp,
String requestId
) {}public final class AuditContextHolder {
private static final ThreadLocal<AuditContext> HOLDER = new ThreadLocal<>();
private AuditContextHolder() {
}
public static void set(AuditContext context) {
HOLDER.set(context);
}
public static AuditContext getRequired() {
AuditContext context = HOLDER.get();
if (context == null) {
throw new IllegalStateException("AuditContext is not initialized");
}
return context;
}
public static void clear() {
HOLDER.remove();
}
}Web 请求通过 OncePerRequestFilter 设置上下文,并在 finally 中清理:
@Component
public class AuditContextFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
try {
Authentication authentication =
SecurityContextHolder.getContext().getAuthentication();
AuditContextHolder.set(new AuditContext(
resolveTenantId(request),
resolveOperatorId(authentication),
resolveOperatorName(authentication),
"WEB",
request.getRemoteAddr(),
resolveRequestId(request)
));
chain.doFilter(request, response);
} finally {
AuditContextHolder.clear();
}
}
}不要信任请求体传入的操作者 ID。操作者应来自认证上下文。定时任务、消息消费和数据修复脚本必须显式设置 SYSTEM 或具体服务账号。
4.2 审计实体和 Mapper
public class BizAuditRecord {
private Long id;
private String operationId;
private String tenantId;
private String bizType;
private String bizId;
private Long versionNo;
private String opType;
private LocalDateTime opTime;
private String operatorId;
private String operatorName;
private String source;
private String clientIp;
private String requestId;
private String reason;
private String beforeSnapshot;
private String afterSnapshot;
private String changedFields;
private String prevHash;
private String rowHash;
// getter/setter 省略
}@Mapper
public interface BizAuditMapper {
@Insert("""
INSERT INTO biz_audit_record (
operation_id, tenant_id, biz_type, biz_id, version_no,
op_type, op_time, operator_id, operator_name, source,
client_ip, request_id, reason, before_snapshot,
after_snapshot, changed_fields, prev_hash, row_hash
) VALUES (
#{operationId}, #{tenantId}, #{bizType}, #{bizId}, #{versionNo},
#{opType}, #{opTime}, #{operatorId}, #{operatorName}, #{source},
#{clientIp}, #{requestId}, #{reason},
CAST(#{beforeSnapshot} AS JSON),
CAST(#{afterSnapshot} AS JSON),
CAST(#{changedFields} AS JSON),
#{prevHash}, #{rowHash}
)
""")
int insert(BizAuditRecord record);
List<BizAuditRecord> selectHistory(
@Param("bizType") String bizType,
@Param("bizId") String bizId);
}如果数据库驱动或 MyBatis 版本不支持 CAST(NULL AS JSON),可以使用 XML 动态 SQL,在值为空时直接写 NULL。
4.3 快照与差异工具
@Component
@RequiredArgsConstructor
public class AuditSnapshotUtil {
private final ObjectMapper objectMapper;
public String snapshot(Object value) {
if (value == null) {
return null;
}
try {
return objectMapper.writeValueAsString(value);
} catch (JsonProcessingException e) {
throw new IllegalStateException("审计快照序列化失败", e);
}
}
public String changedFields(Object before, Object after) {
if (before == null || after == null) {
return "{}";
}
try {
JsonNode oldNode = objectMapper.valueToTree(before);
JsonNode newNode = objectMapper.valueToTree(after);
ObjectNode changes = objectMapper.createObjectNode();
newNode.fieldNames().forEachRemaining(field -> {
JsonNode oldValue = oldNode.get(field);
JsonNode newValue = newNode.get(field);
if (!Objects.equals(oldValue, newValue)) {
ObjectNode item = changes.putObject(field);
item.set("before", oldValue);
item.set("after", newValue);
}
});
return objectMapper.writeValueAsString(changes);
} catch (JsonProcessingException e) {
throw new IllegalStateException("审计差异计算失败", e);
}
}
}敏感字段不能因为使用 JSON 就直接明文保存。身份证号、手机号、地址等字段应按合规要求脱敏、加密或采用审计专用序列化 DTO。不要把密码、访问令牌和密钥写入快照。
4.4 统一审计服务
@Service
@RequiredArgsConstructor
public class AuditService {
private final BizAuditMapper auditMapper;
private final AuditSnapshotUtil snapshotUtil;
public void append(
String operationId,
String bizType,
String bizId,
long versionNo,
AuditOpType opType,
Object before,
Object after,
String reason) {
AuditContext context = AuditContextHolder.getRequired();
BizAuditRecord record = new BizAuditRecord();
record.setOperationId(operationId);
record.setTenantId(context.tenantId());
record.setBizType(bizType);
record.setBizId(bizId);
record.setVersionNo(versionNo);
record.setOpType(opType.name());
record.setOpTime(LocalDateTime.now());
record.setOperatorId(context.operatorId());
record.setOperatorName(context.operatorName());
record.setSource(context.source());
record.setClientIp(context.clientIp());
record.setRequestId(context.requestId());
record.setReason(reason);
record.setBeforeSnapshot(snapshotUtil.snapshot(before));
record.setAfterSnapshot(snapshotUtil.snapshot(after));
record.setChangedFields(snapshotUtil.changedFields(before, after));
if (auditMapper.insert(record) != 1) {
throw new IllegalStateException("审计记录写入失败");
}
}
}审计写入失败必须抛异常,让外层业务事务回滚。不能吞异常,也不能只打印日志后继续提交业务修改。
5. AuditTemplate:避免审计代码散落在 Service
5.1 模板接口
@FunctionalInterface
public interface AuditVersionResolver {
long resolve(Object before, Object after);
}public record AuditSpec(
String operationId,
String bizType,
String bizId,
AuditOpType opType,
String reason,
AuditVersionResolver versionResolver
) {
public static AuditSpec create(
String operationId,
String bizType,
String bizId,
String reason) {
return new AuditSpec(
operationId, bizType, bizId, AuditOpType.CREATE, reason,
(before, after) -> ((VersionedEntity) after).getVersionNo()
);
}
public static AuditSpec update(
String operationId,
String bizType,
String bizId,
String reason) {
return new AuditSpec(
operationId, bizType, bizId, AuditOpType.UPDATE, reason,
(before, after) -> ((VersionedEntity) after).getVersionNo()
);
}
public static AuditSpec delete(
String operationId,
String bizType,
String bizId,
String reason) {
return new AuditSpec(
operationId, bizType, bizId, AuditOpType.DELETE, reason,
(before, after) -> after == null
? ((VersionedEntity) before).getVersionNo() + 1
: ((VersionedEntity) after).getVersionNo()
);
}
}public interface VersionedEntity {
long getVersionNo();
}@Component
@RequiredArgsConstructor
public class AuditTemplate {
private final AuditService auditService;
public <B, A> void execute(
AuditSpec spec,
Supplier<B> beforeLoader,
Runnable action,
Supplier<A> afterLoader) {
B before = beforeLoader.get();
if (before == null && spec.opType() != AuditOpType.CREATE) {
throw new IllegalArgumentException("待审计数据不存在");
}
action.run();
A after = afterLoader.get();
long version = spec.versionResolver().resolve(before, after);
auditService.append(
spec.operationId(),
spec.bizType(),
spec.bizId(),
version,
spec.opType(),
before,
after,
spec.reason()
);
}
}VersionedEntity 只是一种最简单的约束方式。若当前项目实体未统一实现该接口,可以在 AuditSpec 中改为自定义版本号解析 lambda。
5.2 为什么事务仍然放在 Service
@Transactional
public void update(Long id, ProfileUpdateCommand command) {
auditTemplate.execute(
AuditSpec.update(
UUID.randomUUID().toString(),
"PERSON_PROFILE",
String.valueOf(id),
command.reason()
),
() -> mapper.selectById(id),
() -> {
int affected = mapper.updateByIdAndVersion(
id, command, command.versionNo());
if (affected != 1) {
throw new OptimisticLockingFailureException(
"个人档案已被其他用户修改");
}
},
() -> mapper.selectById(id)
);
}事务必须包住“读取旧值、修改当前表、读取新值、写审计表”全过程。AuditTemplate 不应自行开启独立事务,否则审计可能提交而业务回滚,或者业务提交而审计回滚。
6. 单表操作
6.1 新增
@Transactional
public Long create(ProfileCreateCommand command) {
PersonProfile profile = command.toEntity();
profile.setVersionNo(1L);
profileMapper.insert(profile);
auditService.append(
UUID.randomUUID().toString(),
"PERSON_PROFILE",
String.valueOf(profile.getId()),
1L,
AuditOpType.CREATE,
null,
profile,
command.reason()
);
return profile.getId();
}6.2 修改与乐观锁
UPDATE person_profile
SET name = #{name},
phone = #{phone},
version_no = version_no + 1,
updated_by = #{operatorId},
updated_at = CURRENT_TIMESTAMP(3)
WHERE id = #{id}
AND version_no = #{versionNo}
AND status <> 'DELETED';影响行数不是 1 时必须抛出并发冲突异常。不能直接重新查询后覆盖,因为这样会丢失其他用户的修改。
6.3 删除
优先使用逻辑删除:
UPDATE person_profile
SET status = 'DELETED',
version_no = version_no + 1,
updated_by = #{operatorId},
updated_at = CURRENT_TIMESTAMP(3)
WHERE id = #{id}
AND version_no = #{versionNo}
AND status <> 'DELETED';审计记录中保存删除前对象,版本号使用删除后的版本号。若必须物理删除,应先读取对象、写入审计,最后执行物理删除,三者处于同一个事务中。
6.4 恢复
恢复不能直接修改旧审计记录。流程应为:
- 查询历史版本;
- 校验历史数据仍符合当前规则;
- 把历史快照写回当前表;
- 当前版本号继续递增;
- 写入新的
RESTORE审计记录。
7. 多表业务操作
例如修改个人档案,同时修改地址表和联系人表:
@Transactional
public void updateProfile(ProfileAggregateCommand command) {
String operationId = UUID.randomUUID().toString();
PersonProfile oldProfile = profileMapper.selectById(command.profileId());
List<PersonAddress> oldAddresses =
addressMapper.selectByProfileId(command.profileId());
updateProfileWithVersionCheck(command);
replaceAddresses(command);
PersonProfile newProfile = profileMapper.selectById(command.profileId());
List<PersonAddress> newAddresses =
addressMapper.selectByProfileId(command.profileId());
auditService.append(
operationId, "PERSON_PROFILE",
String.valueOf(command.profileId()),
newProfile.getVersionNo(), AuditOpType.UPDATE,
oldProfile, newProfile, command.reason());
auditService.append(
operationId, "PERSON_ADDRESS_SET",
String.valueOf(command.profileId()),
nextAddressVersion(command.profileId()), AuditOpType.UPDATE,
oldAddresses, newAddresses, command.reason());
}设计要点:
- 所有表更新、审计记录共用一个
@Transactional; - 所有审计记录使用同一个
operation_id; - 每个实体或实体集合有独立的
biz_type和版本号; - 任意一步失败,所有业务修改和审计记录一起回滚;
- 不能只审计主表而忽略子表,否则历史无法完整还原。
如果子表是独立业务实体,例如联系人以后可以单独修改,应使用联系人真实 ID 作为 biz_id,每个联系人分别写审计记录,而不是把所有联系人拼成一个 JSON。
8. 批量操作、导入和导出
8.1 批量修改
批量修改时不能只写一条“批量修改成功”日志。推荐:
- 一个批次使用一个
operation_id; - 每个实际修改成功的业务对象写一条审计记录;
- 使用 JDBC batch 或 MyBatis
<foreach>提升性能; - 如果部分对象失败,明确采用“整体回滚”或“逐条成功”策略。
强一致模式:一个对象失败,全部回滚。适用于审批、权限、财务数据。
逐条模式:每个对象单独事务或保存点,并在批次表中记录成功/失败明细。适用于大批量数据导入,但必须明确告知调用方哪些记录成功。
8.2 数据导入
导入应创建一个批次号,并把批次号作为 operation_id 或关联字段:
导入批次 202607310001
- 档案 10001:CREATE
- 档案 10002:UPDATE
- 档案 10003:失败,手机号格式错误文件本身建议保存文件摘要、上传人和文件地址,不要把整份文件重复塞进每条审计记录。
8.3 导出和查询
导出通常不改变业务数据,不需要写数据版本审计,但涉及敏感数据时建议写“访问审计”:记录操作者、查询条件摘要、导出范围、文件摘要和下载时间。访问审计可以使用独立的 biz_access_log 表。
9. 事务、异常和并发
9.1 事务要求
@Transactional(
propagation = Propagation.REQUIRED,
rollbackFor = Exception.class
)
public void update(...) {
// 当前表更新和审计写入必须在此事务内
}注意:
@Transactional应放在被 Spring 代理的 public Service 方法上;- 同一个类内部直接调用另一个
@Transactional方法不会触发代理; - 不要给审计写入使用
REQUIRES_NEW,否则业务回滚后审计仍会保留,造成状态不一致; - 外部 HTTP、消息发送不应直接放在数据库事务中等待完成。
9.2 异步通知
强制审计记录必须同步写入当前数据库事务。若需要把审计同步到 Kafka、ES 或对象存储,应采用 Outbox:
业务表更新 + 审计表/Outbox 写入
| 同一个事务
v
Debezium/后台发布器
|
v
Kafka、ES、归档库不能先提交业务,再异步写唯一的审计记录,否则进程崩溃时会出现无法追溯的业务变更。
10. AOP、模板和显式调用如何选择
推荐分层:
- 简单单表 CRUD:
@BizAudit+ AOP; - 核心主数据:
AuditTemplate或 Service 显式调用; - 多表聚合操作:Service 显式创建
operation_id,逐个登记实体; - 不要在 Mapper 方法上做通用审计拦截;
- 不要依赖触发器作为唯一审计来源。
AOP 适合固定的“一个方法对应一个实体”的场景;复杂业务必须显式声明审计对象,否则切面无法知道哪些子表属于同一次业务操作。
11. 历史查询和恢复接口
建议提供以下接口:
GET /api/person-profiles/{id}/history
GET /api/person-profiles/{id}/history/{version}
POST /api/person-profiles/{id}/restore/{version}历史接口只读审计表,按 version_no DESC 查询。恢复接口必须经过权限校验,恢复动作本身产生新的审计版本,不能修改原始历史。
12. 安全、不可篡改和归档
- 审计表数据库账号只授予
INSERT和SELECT,禁止普通应用更新、删除; - 关键审计数据可增加
prev_hash、row_hash,形成链式校验; - 高合规场景将审计数据复制到独立数据库或 WORM 对象存储;
- 审计查询必须按租户、权限和数据范围过滤;
- 设置冷热数据策略,历史表按月份归档或分区;
- 审计表不要无限制保存大文件和大字段,文件保存摘要和对象地址即可。
13. 测试清单
至少覆盖:
- 新增、修改、逻辑删除、物理删除、恢复;
- 业务更新成功时审计必然存在;
- 审计插入失败时业务事务回滚;
- 多表操作任意一步失败时全部回滚;
- 两个请求同时修改时只有一个成功;
- 批量操作的成功、失败和部分失败策略;
- 定时任务和消息消费能记录系统操作者;
- 敏感字段脱敏或加密;
- 历史快照能还原指定版本;
- 恢复操作产生新版本,不修改旧记录;
- 同一请求的多条记录具有相同
operation_id; - 线程池和异步任务不会串用上一个请求的用户上下文。
14. 推荐落地顺序
- 先建立统一的
biz_audit_record表和AuditContext; - 为核心主表增加
version_no和乐观锁; - 实现
AuditService、AuditTemplate和快照差异工具; - 先改造新增、修改、删除最多的 1 至 2 个核心模块;
- 再处理多表聚合、批量导入和恢复功能;
- 最后接入 Binlog/CDC 做全库变更归档和漏记检查。
该方案的关键边界是:Service 负责业务事务和审计对象的声明,审计组件负责统一落库;核心审计不能依赖异步消息,也不能依赖无法携带业务上下文的纯数据库触发器。
评论
0成为第一个留下想法的人。