ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Spring Boot 3.x下Envers审计表生成失败排查与修复

Spring Boot 3.x下Envers审计表生成失败排查与修复 上周把一个老项目从 Spring Boot 2.7 往 3.2 上迁其他模块都算顺利唯一卡住我的是 Envers 审计日志这块。现象很怪应用正常启动业务接口和数据写入都好但数据库里就是没有Audited标注后应自动出现的user_AUD表也没有REVINFO表。一开始我以为是ddl-auto没配对翻了一圈配置发现根本不是那么回事。如果你也在 Spring Boot 3.x 上遇到过同样的现象大概率不是运气差而是 Hibernate 6 之后 Envers 的集成方式、依赖坐标、默认行为一起变了。这篇文章把我踩过的坑、排查路径和最终可落地的方案完整记录下来给正在迁移或者第一次在 Spring Boot 3.x 上接 Envers 的朋友一个直接能抄的参考。1. 先弄清楚Envers 的审计表到底是怎么被创建的1.1 两个容易被搞混的审计很多同学在项目里加过EnableJpaAuditing也见过created_by、updated_at这种字段自动填充就以为审计这件事已经会了。但 Spring Data JPA 的审计功能和 Hibernate Envers 完全是两码事。Spring Data JPA 的EnableJpaAuditing配合CreatedBy、LastModifiedDate这些注解只是在实体持久化时帮你把某些字段赋值它不生成任何额外的表。而 Hibernate Envers 是 ORM 层面的完整审计方案你给实体加一个Audited它会在数据库里自动生成一张影子表比如user_AUD再配一张全局修订表REVINFO每次增删改都往这两个表里写历史记录。这俩概念混在一起之后最容易出现的情况就是项目里明明把EnableJpaAuditing打开了也看到created_by字段在填值理所当然以为 Envers 的表也应该自动出现。实际上 Spring Data JPA 根本不会去管 Envers 的表结构更不会去建_AUD表。1.2 一条从 Audited 到建表的执行链要理解自动生成失败得先知道 Envers 表是怎么来的。整个链路是这样的实体类上标注Audited。Hibernate 在构建 SessionFactory 时通过 ServiceLoader 机制发现 classpath 里的 hibernate-envers 集成器把它注册进集成点。Envers 读取所有被Audited标注的实体映射在 Hibernate 元模型里额外注册一组审计实体映射比如user_AUD、REVINFO。当 Hibernate 执行 SchemaManagement 工具也就是hibernate.hbm2ddl.auto对应的 create/update 流程时会把普通表和 Envers 审计表一起导出为 DDL 并执行。这里有两个完全独立的环节Envers 是否被成功集成是第一步Schema 是否被导出是第二步。两个环节缺一个你的数据库里都看不到表。这也解释了为什么很多人会碰到启动不报错、代码都能跑、但表就是没有的怪事如果 Envers 没被集成Hibernate 不会额外建审计表但你不会看到任何异常如果ddl-auto配置本身不会触发建表同样也是静默失败。1.3 Envers 默认会生成什么表以最常见的User实体为例Entity Table(name user) Audited public class User { Id GeneratedValue(strategy GenerationType.IDENTITY) private Long id; Column(nullable false) private String username; private Integer age; }启动后默认应该会多出两张表user_AUD和REVINFO。表定义大致是这样表名核心字段说明user_AUDid、username、ageREV、REVTYPE原实体的所有字段加上修订号、修订类型REVINFOREV、REVTSTMP每次变更生成一条修订记录REV 自增主键REVTYPE有三个值0 代表新增1 代表修改2 代表删除。user_AUD的主键是(id, REV)这样同一个业务主键的多条历史版本可以共存。2. 依赖坐标变化Spring Boot 3.x 下 Envers 的隐形门槛2.1 从 org.hibernate 到 org.hibernate.orm 的坐标迁移陷阱Spring Boot 2.x 管理的是 Hibernate 5.x那时hibernate-envers的 Maven 坐标是dependency groupIdorg.hibernate/groupId artifactIdhibernate-envers/artifactId /dependencySpring Boot 3.x 升级到 Hibernate 6.xgroupId 也改了。从 6.0 开始Hibernate 官方把模块坐标统一调整到了org.hibernate.orm下dependency groupIdorg.hibernate.orm/groupId artifactIdhibernate-envers/artifactId /dependency这里最坑的是Spring Boot 3 的依赖管理 BOM 已经完全切换到了新坐标你在 Spring Boot 3 项目里如果写了老坐标IDEA 甚至还能帮你把版本解析出来因为它能拉到 Hibernate 5.6 的旧包。但一旦启动轻则类加载冲突重则直接抛异常。2.2 旧坐标带来的崩溃形态我把项目从 2.7 迁到 3.2 时pom 里一开始就是旧坐标。启动后报的错是java.lang.NoClassDefFoundError: javax/persistence/Entity原因很直白Spring Boot 3 全部切换到jakarta.persistence包而 Hibernate 5.6 的 Envers 还是用javax.persistence编译的。新老两套 JPA 规范同时出现在 classpath 下Hibernate 6 在加载实体时找不到它期望的 Jakarta 注解。还有一种更隐蔽的形态旧坐标会把 Hibernate 5.6 的hibernate-core也带进依赖树项目里同时存在两个hibernate-core版本Maven 仲裁后可能选了 5.6导致 Spring Boot 3 的 JPA 自动配置初始化出问题各种 SessionFactory 创建失败。2.3 检查依赖树的命令遇到类似问题先别急着改代码用 Maven 看一眼依赖树mvn dependency:tree -Dincludesorg.hibernate.orm,org.hibernate正常情况应该只出现org.hibernate.orm:hibernate-core和org.hibernate.orm:hibernate-envers版本号和 Spring Boot BOM 一致。如果看到org.hibernate:hibernate-core:5.6.15.Final这类旧版本基本可以确定是坐标写错了。3. 表结构没出现的五个典型配置原因3.1 非内嵌数据库的 ddl-auto 默认值是 none这是我在实际排查中遇到频率最高、也最容易忽视的一条。Spring Boot 对spring.jpa.hibernate.ddl-auto有一套默认但不直观的逻辑如果你没有显式配置且当前连接的是内嵌数据库H2、HSQLDB、Derby默认值是create-drop如果连的是外置数据库MySQL、PostgreSQL、SQL Server 等默认值是none。换句话说很多团队开发环境用的是 Docker 里的 MySQL 而不是 H2然后项目里也没写ddl-autoJPA 实体表能出现完全是靠其他迁移工具或者历史遗留。这种情况你给实体加上Audited重启之后当然看不到_AUD表因为 Hibernate 的 Schema 导出工序根本没执行。解决方法很直接开发环境配置里显式打开spring: jpa: hibernate: ddl-auto: update注意update模式下 Hibernate 只会新增表不会修改已存在表的结构。如果你第一次启动时建表失败、或者生成的列类型不完整后续修改实体字段也不会触发 ALTER这是另一个坑。3.2 jakarta 包名迁移不干净另一个很常见的坑是项目从 javax 往 jakarta 迁移时部分实体类仍然写着javax.persistence.Entity。在 Spring Boot 3 里Hibernate 6 只认jakarta.persistence注解如果你实体用的是旧包名它不会被当作 JPA 实体注册Envers 自然也不会审计到它。这种情况往往很隐蔽如果数据库里恰好已经手工建了同名的表项目启动也不会报错但所有注解都没生效。排查时扫一遍 import 语句// 错误 import javax.persistence.Entity; import javax.persistence.Id; import javax.persistence.Table; // 正确 import jakarta.persistence.Entity; import jakarta.persistence.Id; import jakarta.persistence.Table;顺便强调一下Audited、AuditOverride这些 Envers 注解不受这次迁移影响它们仍然在org.hibernate.envers包下。3.3 多数据源或动态数据源下 Envers 只生效一半如果项目配置了多数据源或者用了类似 baomidou 的 dynamic-datasource 做读写分离也会出现审计表只生成了一半的情况。Envers 的集成是挂在每个 SessionFactory 上的。也就是说你配置了几个LocalContainerEntityManagerFactoryBean就有几个独立的 SessionFactoryEnvers 会在每一个里面尝试注册。常见问题是团队习惯把 Envers 的启动配置写在主数据源的 JPA Properties 里比如在Primary的那个LocalContainerEntityManagerFactoryBean上设置了hibernate.integration.envers.enabledtrue但另外的数据源没设置。结果就是主库实体加了Audited后表正常生成从库或其他业务库的实体怎么都生成不出来。如果你用的是AbstractRoutingDataSource这种只有一个 EntityManagerFactory 的方案情况更要留意Envers 只绑定到默认的数据源。动态切库后实际写入是在另一个库但审计表和 REVINFO 只建在默认库里数据入库时要么报表不存在要么历史记录全部落到同一个库里。3.4 自定义 RevisionEntity 让 REVINFO 表换了名字网上很多项目为了记录当前操作人是谁都会自定义修订实体类似这样Entity RevisionEntity(MyRevisionListener.class) public class CustomRevEntity { Id GeneratedValue RevisionNumber private Long id; RevisionTimestamp private Long ts; }如果你没有给这个实体显式指定Table(name REVINFO)那么 Hibernate 生成的表名很可能不是REVINFO而是custom_rev_entity取决于你的命名策略。这时你在数据库里找 REVINFO 找不到但审计功能其实已经正常工作了。这种情况严格说不算自动生成失败但它非常容易让人误判。如果你手工建表脚本按默认 REVINFO 表名写了启动后会发现表对不上、外键找不到等问题。3.5 审计表后缀或字段名被全局配置改过Envers 允许通过配置修改审计表的后缀和修订字段名spring: jpa: properties: org: hibernate: envers: audit_table_suffix: _LOG revision_field_name: REV_ID revision_type_field_name: REV_TYPE只要这些配置存在你的审计表名就不再叫user_AUD而是user_LOG修订字段也变成了REV_ID而不是REV。排查的时候如果一直盯着默认命名找表会绕很大一圈。建议先查一下项目里有没有这类全局配置再决定按什么名字去找表。4. 从日志到代码一条完整的自动排查链路4.1 先开日志确认 Schema 导出阶段到底发生了什么遇到表没生成的问题第一件事不是猜原因而是打开日志重建现场。推荐把 Hibernate 的 schema 生成日志和 SQL 日志一起打开logging: level: org: hibernate: SQL: DEBUG tool: schema: TRACE如果 Envers 正常工作并且ddl-auto处于update或create模式日志里应该能看到类似这样的输出create table revinfo (REV integer not null, REVTSTMP bigint, primary key (REV)) create table user_aud (id bigint not null, username varchar(255) not null, age integer, REV integer not null, REVTYPE smallint not null, primary key (id, REV))如果日志里只有业务实体的建表语句没有任何_AUD或REVINFO相关语句说明 Envers 集成根本没生效。如果连业务实体的建表语句也没有问题大概率在ddl-auto默认值或 Schema 导出配置上。4.2 用一行代码确认 Envers 是否真的被集成日志不够直观的话可以用一段启动检查代码直接在应用里验证 Envers 服务状态Component public class EnversStartupCheck implements ApplicationRunner { private final EntityManagerFactory entityManagerFactory; public EnversStartupCheck(EntityManagerFactory entityManagerFactory) { this.entityManagerFactory entityManagerFactory; } Override public void run(ApplicationArguments args) { SessionFactory factory entityManagerFactory.unwrap(SessionFactory.class); EnversService enversService factory.getServiceRegistry().getService(EnversService.class); boolean enabled enversService ! null enversService.isEnabled(); System.out.println(Envers service enabled: enabled); } }EnversService在org.hibernate.envers.boot.internal包下依赖里只要引入了hibernate-envers编译期就能找到。这个检查能帮你快速区分问题到底出在Envers 没启动还是Envers 启动了但表没建。4.3 按全无表 / 缺审计表 / 缺 REVINFO分类对症结合日志和代码检查结果我通常把问题分成三类处理方式完全不同现象大概率原因处理方向业务表和审计表全都没有ddl-auto是 noneSchema 导出没执行显式配置update或create业务表有但_AUD、REVINFO都没有Envers 没被集成或者实体没被扫描到检查依赖坐标、jakarta 注解、多数据源配置有user_AUD但找不到 REVINFO自定义审计实体表名不叫 REVINFO加Table(name REVINFO)对齐命名表都在但启动时报结构校验错误ddl-auto: validate与实际结构不一致手工补表或改用迁移脚本管理这四类情况覆盖了我接触过的九成以上 Envers 表结构异常。5. 三个高频故障场景的复盘与修复5.1 场景A旧坐标依赖把 Hibernate 6 搞挂复现过程Spring Boot 3.2 项目原本只用了spring-boot-starter-data-jpa为了加审计引入了 Envers。pom 里写的是dependency groupIdorg.hibernate/groupId artifactIdhibernate-envers/artifactId version5.6.15.Final/version /dependency启动直接报java.lang.NoClassDefFoundError: javax/persistence/Entity。修复方式删掉version把坐标改成新路径dependency groupIdorg.hibernate.orm/groupId artifactIdhibernate-envers/artifactId /dependencySpring Boot 3 的 BOM 会自动把这个依赖的版本控制到和hibernate-core一致不需要自己填版本号。改完之后用第 2 节提到的 dependency:tree 命令确认依赖树里没有旧坐标残留。5.2 场景B自定义审计实体导致 REVINFO 建不出来复现过程项目里自定义了审计实体目标是想在每次修订时把当前登录用户的用户名写进 REVINFO。代码有以下问题Entity RevisionEntity(MyRevisionListener.class) public class RevEntity { Id GeneratedValue RevisionNumber private Long id; RevisionTimestamp private Long timestamp; }启动后数据库里只生成了rev_entity表没有默认的REVINFO表。由于其他表的外键约束是按REVINFO建的导致插入审计数据时报错。修复要点有两个。第一用Table(name REVINFO)把表名固定下来第二用Column(name REV)、Column(name REVTSTMP)把修订号和时间戳列名固定下来避免被命名策略改成其他名字Entity RevisionEntity(MyRevisionListener.class) Table(name REVINFO) public class RevEntity { Id GeneratedValue RevisionNumber Column(name REV) private Long id; RevisionTimestamp Column(name REVTSTMP) private Long timestamp; Column(name USERNAME) private String username; // getter setter }这样建出来的表就是标准的 REVINFO 结构加了一个USERNAME字段存操作人。5.3 场景C开发环境 H2 换生产 MySQL 后类型不一致复现过程开发时用的是 H2 内嵌数据库ddl-auto默认 create-drop一切正常。部署到 MySQL 时项目里显式配置了ddl-auto: update结果应用能启动但 Envers 执行变更记录时报错。原因是 Envers 自动生成的REVTSTMP列在 H2 里可以用 timestamp 类型换成 MySQL 后如果表结构之前已经建过update模式不会修改已有列类型不兼容导致写入失败。特别是同一张REVINFO表如果既有 H2 提交的结构又有 MySQL 的建表脚本很容易埋雷。我的建议是跨数据库开发的团队直接把REVTSTMP定义成BIGINT存 epoch 毫秒时间戳统一类型避免方言差异。在 Flyway 脚本里这样写MySQLCREATE TABLE IF NOT EXISTS REVINFO ( REV BIGINT NOT NULL AUTO_INCREMENT, REVTSTMP BIGINT, PRIMARY KEY (REV) ) ENGINE InnoDB;PostgreSQLCREATE TABLE IF NOT EXISTS REVINFO ( REV BIGSERIAL PRIMARY KEY, REVTSTMP BIGINT );对应实体字段保持Long类型不受数据库方言影响。这也是为什么我在 5.2 里强调自定义审计实体时RevisionTimestamp最好放在Long上而不是Date上。6. 让 Envers 表可靠自动生成与生产兜底方案6.1 一份能跑的 Spring Boot 3.x 配置模板依赖部分dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-jpa/artifactId /dependency dependency groupIdorg.hibernate.orm/groupId artifactIdhibernate-envers/artifactId /dependency配置文件spring: jpa: hibernate: ddl-auto: update properties: org: hibernate: envers: audit_table_suffix: _AUD revision_field_name: REV revision_type_field_name: REVTYPE revision_table: REVINFO revision_timestamp_field: REVTSTMP实体上正常加Audited启动后看日志确认create table输出再进数据库确认表字段。6.2 生产环境用迁移脚本管理审计表生产环境我强烈建议关掉ddl-auto把表结构交给 Flyway 或 Liquibase 管理。原因有两个一是update模式永远不会自动修改已存在表的列结构线上最容易出现代码升级了表结构没跟上的情况二是 Envers 自动生成的 DDL 受方言和版本影响改了 Hibernate 版本后建出来的列可能和线上不一致。Flyway 脚本示例简化版只体现核心结构-- V1001__create_envers_tables.sql CREATE TABLE IF NOT EXISTS REVINFO ( REV BIGINT NOT NULL AUTO_INCREMENT, REVTSTMP BIGINT, PRIMARY KEY (REV) ) ENGINE InnoDB; CREATE TABLE IF NOT EXISTS user_AUD ( id BIGINT NOT NULL, username VARCHAR(255) NOT NULL, age INT, REV BIGINT NOT NULL, REVTYPE SMALLINT NOT NULL, PRIMARY KEY (id, REV), CONSTRAINT fk_user_aud_rev FOREIGN KEY (REV) REFERENCES REVINFO (REV) );如果实体字段很多手写审计表脚本会有点痛苦一个取巧的办法是开发环境用ddl-auto: update让 Envers 自动把表建出来然后通过数据库工具的导出建表语句功能拿到 DDL 作为 Flyway 基线。注意导出后要核对列名大小写和索引定义很多数据库工具默认加了反引号或双引号需要清理。6.3 别忘了操作人信息Listener 注入失效问题很多项目为了方便在审计记录里查是谁改的会用RevisionListener去填充用户名。这时候有一个跟表结构无关、但和 Envers 日常使用强相关的坑RevisionListener实现类是由 Hibernate 内部实例化的不在 Spring 容器里所以你没法用Autowired注入任何 Bean。我见过挺多人在这里踩坑启动不报错但每次变更记录的时候 NPE。正确的做法是直接在当前线程里取上下文public class AuditRevisionListener implements RevisionListener { Override public void newRevision(Object revisionEntity) { RevEntity rev (RevEntity) revisionEntity; Authentication auth SecurityContextHolder.getContext().getAuthentication(); if (auth ! null) { rev.setUsername(auth.getName()); } } }如果是异步线程、定时任务这类没有认证上下文的场景就得用UserContext之类的 ThreadLocal 或者静态 ApplicationContext 手动获取用户信息不能依赖 SecurityContext 里的内容。我在实际项目里的做法是把当前用户 ID 塞进一个基于 ThreadLocal 的上下文工具类RevisionListener里直接读这个工具类没有值就存SYSTEM。这样既避免 Spring 注入问题也能兜住系统任务的审计场景。最后再说两句迁移到 Spring Boot 3.x 之后Envers 的坑大多不在 Envers 本身而在 Hibernate 6 的依赖体系和 Schema 生成机制。我现在的习惯是开发环境用ddl-auto: update快速建表拿到完整 DDL 后作为 Flyway 基线提交生产环境一律ddl-auto: none表结构变更走迁移脚本。这样既能享受 Envers 的开发效率也不用担心哪天 Hibernate 小版本升级后建表行为变化把线上环境搞坏。如果你现在正被自动生成失败卡住建议按第 4 节的排查顺序走一遍大概率能在半小时内找到根因。
返回列表