監査要件で「レコードの変更履歴を残したい」と言われて、困った経験はありませんか。作成日時と更新日時だけならJPA Auditingで十分ですが、「誰がいつ何をどう変えたか」まで求められると話が変わってきます。

手書きの履歴テーブルで頑張る運用は、カラムが増えるたびに破綻しがちですよね。この記事では Hibernate Envers を使って、全リビジョンの履歴を自動で残す方法を実装ベースで見ていきます。

JPA AuditingとEnversの違い

まず立ち位置を整理しておきましょう。@CreatedDate / @LastModifiedDate は「いつ作られ、最後にいつ更新されたか」という日時を1レコードに持たせるだけです。更新すると前の値は上書きされて消えます。

一方 Envers は、エンティティが変更されるたびにスナップショット(リビジョン)を別テーブルへ保存します。つまり任意の時点の値を後から復元できます。「変更前の値」「変更者」まで問われる監査証跡(audit trail)では、Envers のほうが素直です。

JPA Auditing の基礎はここでは繰り返しません。作成・更新日時の記録と Envers は併用できるので、役割分担として捉えてください。

依存関係の追加とEnversの有効化

spring-boot-starter-data-jpa を使っているなら、追加するのは Envers 本体だけです。バージョンは Spring Boot の BOM 管理下なので明示しません。

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.hibernate.orm:hibernate-envers'
}

特別な有効化設定は不要です。クラスパスに Envers があれば Hibernate が自動で検出し、@Audited の付いたエンティティを監査対象として扱ってくれます。

テーブル名の接尾辞などを変えたいときだけ、プロパティで調整します。

# 履歴テーブルの接尾辞(デフォルトは _AUD)
spring.jpa.properties.org.hibernate.envers.audit_table_suffix=_AUD
# REVTYPE=DEL 時に元の値も残すか
spring.jpa.properties.org.hibernate.envers.store_data_at_delete=true

@Auditedで履歴テーブルを自動生成する

あとはエンティティに @Audited を付けるだけです。

import org.hibernate.envers.Audited;

@Entity
@Audited
public class Product {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

    private BigDecimal price;

    // getter / setter
}

これで PRODUCT テーブルに対応する PRODUCT_AUD が生成されます。構造はおおよそ次のイメージです(型はDB/dialectで変わるので、あくまでイメージとして見てください)。

CREATE TABLE product_aud (
    id      BIGINT  NOT NULL,
    rev     INTEGER NOT NULL,   -- リビジョン番号
    revtype TINYINT,            -- 0=ADD, 1=MOD, 2=DEL
    name    VARCHAR(255),
    price   DECIMAL(19,2),
    PRIMARY KEY (id, rev)
);

CREATE TABLE revinfo (
    rev      INTEGER NOT NULL PRIMARY KEY,
    revtstmp BIGINT              -- リビジョンのタイムスタンプ
);

元エンティティのカラムに加えて REV(リビジョン番号)と REVTYPE(追加・更新・削除の区別)が入ります。リビジョン番号とタイムスタンプは REVINFO テーブルで一元管理され、複数エンティティの変更が同じトランザクションなら同じ REV を共有します。なお REV の採番は Envers が自前のジェネレータ(既定ではシーケンス)で行うため、DB側の IDENTITY に任せる設計にはしません。

AuditReaderで過去バージョンを取得する

保存された履歴は AuditReader から読み出します。EntityManager があれば取得できます。

@Service
public class ProductHistoryService {

    @PersistenceContext
    private EntityManager entityManager;

    public List<Number> revisions(Long productId) {
        AuditReader reader = AuditReaderFactory.get(entityManager);
        return reader.getRevisions(Product.class, productId);
    }

    public Product atRevision(Long productId, Number rev) {
        AuditReader reader = AuditReaderFactory.get(entityManager);
        return reader.find(Product.class, productId, rev);
    }
}

getRevisions() でそのレコードが変更されたリビジョン番号の一覧を、find() で特定リビジョン時点のエンティティを丸ごと復元できます。

もう少し凝った検索は AuditQuery を使います。あるエンティティの全リビジョンを、変更情報つきで取得してみましょう。

AuditReader reader = AuditReaderFactory.get(entityManager);
List<?> results = reader.createQuery()
        .forRevisionsOfEntity(Product.class, false, true)
        .add(AuditEntity.id().eq(productId))
        .getResultList();

forRevisionsOfEntity は各要素に「エンティティ・リビジョン情報・REVTYPE」を含む配列を返すので、隣り合うリビジョンを突き合わせれば変更前後の比較ができます。

「価格が変わったリビジョンだけ」のように特定カラムの変更で絞りたい場合は AuditEntity.property("price").hasChanged() が使えます。ただし hasChanged() は変更フラグ機能が有効なときだけ動く点に注意してください。spring.jpa.properties.org.hibernate.envers.global_with_modified_flag=true(または対象に @Audited(withModifiedFlag = true))を設定すると、_AUD テーブルに price_mod BOOLEAN のような変更フラグ列が追加され、これらのクエリが使えるようになります。フラグを有効にしていないと _MOD 列が存在せず実行時に失敗するので、有効化とDDL追加をセットで行いましょう。

「誰が」変更したかを記録する

Envers 標準の REVINFO にはタイムスタンプしかありません。操作者を残すには、カスタムのリビジョンエンティティを定義します。

@Entity
@RevisionEntity(UserRevisionListener.class)
public class UserRevisionEntity extends DefaultRevisionEntity {

    private String username;
    private String clientIp;

    // getter / setter
}

RevisionListener で、リビジョンが作られる直前に値をセットします。認証ユーザーは SecurityContext から取り出します。

public class UserRevisionListener implements RevisionListener {

    @Override
    public void newRevision(Object revisionEntity) {
        UserRevisionEntity rev = (UserRevisionEntity) revisionEntity;
        Authentication auth = SecurityContextHolder.getContext().getAuthentication();
        rev.setUsername(auth != null ? auth.getName() : "system");

        ServletRequestAttributes attrs =
            (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
        if (attrs != null) {
            rev.setClientIp(attrs.getRequest().getRemoteAddr());
        }
    }
}

これで REVINFOusernameclient_ip カラムが増え、リビジョンごとに操作者が残ります。カスタムの @RevisionEntity はデフォルトの revinfo を差し替える形になるため、既存の履歴がある場合はテーブル移行が必要で、@RevisionEntity はアプリ内に1つしか定義できない点も覚えておきましょう。

注意したいのは非同期処理やバッチです。別スレッドでは SecurityContextRequestContextHolder が空になりがちなので、"system" などのフォールバックを必ず用意しておきましょう。バッチジョブなら実行ユーザーを明示的に詰め替えるのが安全です。

リレーションとコレクションの監査

関連エンティティを履歴に含めたいときは、両側に @Audited が必要です。片方だけだと期待どおりに追えません。

逆に、履歴に残したくないフィールドや関連には @NotAudited を付けます。

@Entity
@Audited
public class Order {

    @OneToMany(mappedBy = "order")
    private List<OrderLine> lines;   // OrderLine側も@Auditedが必要

    @NotAudited
    @ManyToOne
    private Warehouse warehouse;     // 監査対象外を参照
}

監査対象のエンティティから監査対象外のエンティティを参照していると、過去バージョン復元時に RELATION_NOT_FOUND エラーになることがあります。その場合は次の設定で挙動を緩められます。

spring.jpa.properties.org.hibernate.envers.global_relation_not_found_legacy_flag=false

これは参照先が見つからないときに例外を投げる従来挙動から、null として扱う緩和挙動へ切り替える設定です。手軽ですが、本来あるべき関連が黙って欠落するリスクもあるので、対象を絞って使ってください。エンティティのリレーションマッピングを複雑に組んでいるほど、どこまで監査するかは早めに線引きしておくとよいですよ。

Flywayで_AUDテーブルのDDLを管理する

開発中は hbm2ddl.auto=update で自動生成されて便利ですが、本番でこれに頼るのはおすすめしません。生成タイミングや差分が読めず、監査テーブルほど「勝手に変わってほしくない」ものはないからです。

Flywayを使っているなら、_AUDREVINFO の DDL もマイグレーションに明示的に含めましょう。

-- V2__create_product_aud.sql
CREATE TABLE revinfo (
    rev       INTEGER   NOT NULL PRIMARY KEY,
    revtstmp  BIGINT,
    username  VARCHAR(100),
    client_ip VARCHAR(45)
);

CREATE TABLE product_aud (
    id      BIGINT  NOT NULL,
    rev     INTEGER NOT NULL REFERENCES revinfo(rev),
    revtype TINYINT,
    name    VARCHAR(255),
    price   DECIMAL(19,2),
    PRIMARY KEY (id, rev)
);

ここで大事なのは、rev の採番は Envers 側のジェネレータ(既定ではシーケンス)が担うという点です。DDL で IDENTITY を指定してDB任せにすると採番方式が食い違うので、revinforev はIDENTITYにせず、必要ならシーケンスも合わせて用意します。いちばん確実なのは、ローカルで一度 Hibernate に DDL を出力させ、それをたたき台にマイグレーションへ落とし込むやり方です。エンティティにカラムを足したら _AUD 側も追随する、というルールをチームで決めておくと事故が減ります。

履歴テーブル肥大化への対策

監査テーブルは追記専用なので、長期運用では必ず膨らみます。先回りしておきましょう。

まずは書き込む量を減らすこと。全カラム・全エンティティを監査対象にせず、本当に追跡が必要なものに絞ります。頻繁に更新される割に監査価値の低いフィールドは @NotAudited で外すだけでもかなり効きます。

そのうえで、量が読める段階になったら次を検討します。

  • アーカイブ は、保持期間を過ぎた古いリビジョンを別テーブルや低コストストレージへ退避する方法です。
  • パーティショニング は、REV やタイムスタンプでレンジパーティションを切り、古い区画を丸ごと切り離せるようにする方法です。
  • パージ は、監査要件で決まった保持期間に基づき、期限切れリビジョンを定期削除する方法です。

どれを選ぶかは「何年分残す義務があるか」から逆算するのが筋です。技術より先に、保持期間という要件を確定させてください。

まとめ

Hibernate Envers を使えば、@Audited を付けるだけで全リビジョンの履歴を自動記録できます。あとは AuditReader で過去の値を復元し、カスタム RevisionEntity で操作者を残し、肥大化には保持期間ベースで対策する、という流れでした。

JPA Auditing は「作成・更新日時」、Envers は「全変更履歴」と役割が分かれます。似たテーマの論理削除(ソフトデリート)は「削除の表現」が目的で、履歴保全とはまた別物なので、あわせて整理しておくと設計が迷いにくくなりますよ。関連するJPA AuditingFlywayによるマイグレーションの記事もどうぞ。