上周把一个老项目从 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_AUD | id、username、age+REV、REVTYPE | 原实体的所有字段加上修订号、修订类型 |
REVINFO | REV、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> <groupId>org.hibernate</groupId> <artifactId>hibernate-envers</artifactId> </dependency>Spring Boot 3.x 升级到 Hibernate 6.x,groupId 也改了。从 6.0 开始,Hibernate 官方把模块坐标统一调整到了org.hibernate.orm下:
<dependency> <groupId>org.hibernate.orm</groupId> <artifactId>hibernate-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 -Dincludes=org.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-auto,JPA 实体表能出现完全是靠其他迁移工具或者历史遗留。这种情况你给实体加上@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,就有几个独立的 SessionFactory,Envers 会在每一个里面尝试注册。
常见问题是:团队习惯把 Envers 的启动配置写在主数据源的 JPA Properties 里,比如在@Primary的那个LocalContainerEntityManagerFactoryBean上设置了hibernate.integration.envers.enabled=true,但另外的数据源没设置。结果就是:主库实体加了@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是 none,Schema 导出没执行 | 显式配置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> <groupId>org.hibernate</groupId> <artifactId>hibernate-envers</artifactId> <version>5.6.15.Final</version> </dependency>启动直接报java.lang.NoClassDefFoundError: javax/persistence/Entity。
修复方式:删掉<version>,把坐标改成新路径:
<dependency> <groupId>org.hibernate.orm</groupId> <artifactId>hibernate-envers</artifactId> </dependency>Spring 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 脚本里这样写:
MySQL:
CREATE TABLE IF NOT EXISTS REVINFO ( REV BIGINT NOT NULL AUTO_INCREMENT, REVTSTMP BIGINT, PRIMARY KEY (REV) ) ENGINE = InnoDB;PostgreSQL:
CREATE 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> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> </dependency> <dependency> <groupId>org.hibernate.orm</groupId> <artifactId>hibernate-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 节的排查顺序走一遍,大概率能在半小时内找到根因。