Spring Boot + MyBatis 业务数据审计留痕实施方案

1. 目标与适用范围

本文给出一套可直接落地到 Spring Boot + MyBatis + MySQL 8 的业务数据审计方案,覆盖:

  • 单表新增、修改、删除、恢复;
  • 一个业务操作修改多张表;
  • 批量更新、批量删除和数据导入;
  • 事务回滚、并发修改和幂等处理;
  • 用户操作、系统任务和接口调用的操作者记录;
  • 历史查询、差异展示和按历史版本恢复。

核心原则是:

  1. 业务表只保存当前状态;
  2. 审计表只追加,不更新、不删除;
  3. 业务变更和审计写入必须处于同一个数据库事务;
  4. Service 负责业务事务和业务语义,审计组件负责快照、差异和审计表写入;
  5. 不在每个 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;

快照约定:

  • CREATEbefore_snapshot = NULLafter_snapshot = 创建后的完整对象
  • UPDATE:同时保存修改前和修改后的完整对象;
  • DELETEbefore_snapshot = 删除前完整对象after_snapshot = NULL
  • RESTOREafter_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 恢复

恢复不能直接修改旧审计记录。流程应为:

  1. 查询历史版本;
  2. 校验历史数据仍符合当前规则;
  3. 把历史快照写回当前表;
  4. 当前版本号继续递增;
  5. 写入新的 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. 安全、不可篡改和归档

  • 审计表数据库账号只授予 INSERTSELECT,禁止普通应用更新、删除;
  • 关键审计数据可增加 prev_hashrow_hash,形成链式校验;
  • 高合规场景将审计数据复制到独立数据库或 WORM 对象存储;
  • 审计查询必须按租户、权限和数据范围过滤;
  • 设置冷热数据策略,历史表按月份归档或分区;
  • 审计表不要无限制保存大文件和大字段,文件保存摘要和对象地址即可。

13. 测试清单

至少覆盖:

  1. 新增、修改、逻辑删除、物理删除、恢复;
  2. 业务更新成功时审计必然存在;
  3. 审计插入失败时业务事务回滚;
  4. 多表操作任意一步失败时全部回滚;
  5. 两个请求同时修改时只有一个成功;
  6. 批量操作的成功、失败和部分失败策略;
  7. 定时任务和消息消费能记录系统操作者;
  8. 敏感字段脱敏或加密;
  9. 历史快照能还原指定版本;
  10. 恢复操作产生新版本,不修改旧记录;
  11. 同一请求的多条记录具有相同 operation_id
  12. 线程池和异步任务不会串用上一个请求的用户上下文。

14. 推荐落地顺序

  1. 先建立统一的 biz_audit_record 表和 AuditContext
  2. 为核心主表增加 version_no 和乐观锁;
  3. 实现 AuditServiceAuditTemplate 和快照差异工具;
  4. 先改造新增、修改、删除最多的 1 至 2 个核心模块;
  5. 再处理多表聚合、批量导入和恢复功能;
  6. 最后接入 Binlog/CDC 做全库变更归档和漏记检查。

该方案的关键边界是:Service 负责业务事务和审计对象的声明,审计组件负责统一落库;核心审计不能依赖异步消息,也不能依赖无法携带业务上下文的纯数据库触发器。