【完全ガイド】ORA-04091: table is mutating の原因と解決方法|Compound Trigger・Autonomous Transaction まで徹底解説

【完全ガイド】ORA-04091: table is mutating の原因と解決方法|Compound Trigger・Autonomous Transaction まで徹底解説

Oracle のトリガー開発で必ず遭遇する厄介なエラー:

CREATE OR REPLACE TRIGGER emp_biu
AFTER UPDATE ON employees
FOR EACH ROW
DECLARE
  v_count NUMBER;
BEGIN
  SELECT COUNT(*) INTO v_count FROM employees WHERE status = 'ACTIVE';
END;
/

UPDATE employees SET status = 'ACTIVE' WHERE id = 100;
ORA-04091: table SCOTT.EMPLOYEES is mutating, trigger/function may not see it
ORA-06512: at "SCOTT.EMP_BIU", line 4
ORA-04088: error during execution of trigger 'SCOTT.EMP_BIU'

**「テーブルが変化中で、トリガー/関数から参照できない」という、一見すると意味不明なエラー。しかし、この裏にはOracle の Read Consistency(読み取り一貫性)**という重要な設計思想が隠れています。

現場では:

  • トリガーで集計・検証したいのに動かない
  • 監査ログを取ろうとしたら発生
  • ネットの情報が古すぎる(10g 時代の Package 変数の方法)
  • PRAGMA AUTONOMOUS_TRANSACTION で解決したはずが、実際は不整合が発生
  • Compound Trigger って何?
  • Rails のマイグレーションで発生
  • 本番リリース直前に発覚

このエラーの本質は、単純な設定ミスではなく、Oracle のトランザクション制御の設計上の制約にあります。日本語で本質を解説した記事はほぼなく、多くが「Autonomous Transaction を使え」という間違った解決策を提示。実は Autonomous Transaction はデータ不整合の温床で、正しくは Compound Trigger(Oracle 11g+) を使うべきです。

さらに、Compound Trigger の4つのタイミングポイントの使い分け、Package 変数を使った伝統的手法、Statement-level Trigger との比較など、正しく理解すればエレガントに解決できます。

本記事では、ORA-04091: table is mutating完全な原因と解決方法を、日本語圏で類のない深さで整理します。Mutating table の仕組み、Read Consistency との関連、4大解決策(Statement-level・Compound・Autonomous・Package)、Compound Trigger の4タイミングポイント詳解、各手法の落とし穴、実践シナリオ、予防のベストプラクティス、FAQまで完全網羅。この1本でトリガー設計を根本から改善できるようになります。


目次

結論:4つの解決策から選ぶ

時間がない方向けに、最速の対処を先に示します。

4大解決策 比較

#解決策Oracle バージョン難易度推奨度
Statement-level Trigger単純用途で最強
Compound Trigger11g+⭐⭐本命
Autonomous Transaction⚠️ 危険、避ける
Package Variable⭐⭐⭐11g 未満の伝統手法

現代の推奨(Oracle 11g 以降)

Compound Trigger を使う。以下がテンプレート:

CREATE OR REPLACE TRIGGER emp_compound
FOR UPDATE OF status ON employees
COMPOUND TRIGGER

  -- グローバル変数(statement 全体で共有)
  v_count NUMBER;

  BEFORE STATEMENT IS
  BEGIN
    v_count := 0;
  END BEFORE STATEMENT;

  AFTER EACH ROW IS
  BEGIN
    -- 各行の情報を集める
    NULL;
  END AFTER EACH ROW;

  AFTER STATEMENT IS
  BEGIN
    -- ここでは同一テーブル参照OK!
    SELECT COUNT(*) INTO v_count FROM employees WHERE status = 'ACTIVE';
    DBMS_OUTPUT.PUT_LINE('Active count: ' || v_count);
  END AFTER STATEMENT;

END emp_compound;
/

なぜ Autonomous Transaction を避けるか

-- ⚠️ 動くが不整合の温床
CREATE OR REPLACE TRIGGER bad_trigger
AFTER UPDATE ON employees FOR EACH ROW
DECLARE
  PRAGMA AUTONOMOUS_TRANSACTION;
BEGIN
  SELECT ... FROM employees ...;  -- 動くが古いデータを見る
  COMMIT;
END;
/

独立トランザクションで動くが、本トランザクションの変更が見えないため、古いデータを参照。結果、集計値が間違う。

詳細は以下で解説します。


まず理解する:Mutating Table とは

エラーメッセージの意味

ORA-04091: table SCOTT.EMPLOYEES is mutating, 
           trigger/function may not see it

翻訳:

  • table is mutating: 「テーブルが変化中(mutate = 変異する)」
  • may not see it: 「トリガー/関数から見ることはできない」

なぜ Oracle は禁止するか

Read Consistency(読み取り一貫性) の保証のため。

DML が実行中は、テーブルの状態が一貫していない(一部の行だけ更新済み)。この状態で SELECT すると、不整合なデータを見てしまう。

時刻 T1: UPDATE 開始
時刻 T2: 100行のうち 50行だけ更新済み
時刻 T3: トリガーが同じテーブルを SELECT
        → 半端な状態を見てしまう
        → データの整合性が保証できない

Oracle はこの状態を検出し、ORA-04091 で防止。

「Mutating」の適用範囲

Row-level Trigger のみが対象:

  • FOR EACH ROW あり → Mutating 制約適用
  • FOR EACH ROW なし(Statement-level) → 制約なし

つまり、Statement-level Trigger なら同じテーブルを参照可能

適用される DML

  • INSERT
  • UPDATE
  • DELETE
  • MERGE

すべての DML で発生の可能性。

BEFORE vs AFTER

BEFORE トリガーでも AFTER トリガーでも発生。タイミングに関係なく、Row-level では同一テーブル参照禁止。


【解決策①】Statement-level Trigger(単純用途で最強)

発想

FOR EACH ROW を外せば mutating 制約は適用されない:

-- ❌ Row-level(mutating エラー)
CREATE TRIGGER emp_biu
AFTER UPDATE ON employees
FOR EACH ROW              -- ← これがある
DECLARE
  v_count NUMBER;
BEGIN
  SELECT COUNT(*) INTO v_count FROM employees;  -- ERROR
END;

-- ✅ Statement-level(動く)
CREATE TRIGGER emp_biu
AFTER UPDATE ON employees  -- FOR EACH ROW なし
DECLARE
  v_count NUMBER;
BEGIN
  SELECT COUNT(*) INTO v_count FROM employees;  -- OK
END;
/

動作の違い

種類発火頻度:NEW / :OLD
Row-level各行ごと使える
Statement-level文全体で1回使えない

使い分け

Statement-level が使えるケース:

  • 各行の情報が必要ない
  • 集計処理のみ
  • 監査ログ(変更行数の記録など)

Row-level が必要なケース:

  • 各行の :NEW / :OLD を使う
  • 行ごとに何か処理

実践例:更新後の集計

CREATE OR REPLACE TRIGGER emp_after_update
AFTER UPDATE OF status ON employees
DECLARE
  v_active_count NUMBER;
BEGIN
  SELECT COUNT(*) INTO v_active_count
  FROM employees WHERE status = 'ACTIVE';
  
  DBMS_OUTPUT.PUT_LINE('Active employees: ' || v_active_count);
END;
/

UPDATE employees SET status = 'ACTIVE' WHERE id = 100;
-- 成功、Active count 表示

メリット・デメリット

メリット:

  • シンプル
  • 全 Oracle バージョンで動作
  • 分かりやすい

デメリット:

  • :NEW / :OLD 使えない
  • 各行の情報を集められない

【解決策②】Compound Trigger(Oracle 11g+、本命)

概要

Oracle 11g(2007年)で導入された機能。Row-level と Statement-level を1つのトリガーに統合、しかも共有変数を持てる。

4つのタイミングポイント

CREATE OR REPLACE TRIGGER trigger_name
FOR <event> ON <table>
COMPOUND TRIGGER

  -- 共有変数(statement 全体で保持)
  <declare variables>

  BEFORE STATEMENT IS
  BEGIN
    -- statement 開始前に1回
  END BEFORE STATEMENT;

  BEFORE EACH ROW IS
  BEGIN
    -- 各行の直前
  END BEFORE EACH ROW;

  AFTER EACH ROW IS
  BEGIN
    -- 各行の直後
  END AFTER EACH ROW;

  AFTER STATEMENT IS
  BEGIN
    -- statement 完了後に1回
    -- ここでは同一テーブル参照OK!
  END AFTER STATEMENT;

END trigger_name;
/

実行順序

BEFORE STATEMENT (1回)
  ↓
[各行の処理]
  BEFORE EACH ROW → DML → AFTER EACH ROW
  BEFORE EACH ROW → DML → AFTER EACH ROW
  BEFORE EACH ROW → DML → AFTER EACH ROW
  ↓
AFTER STATEMENT (1回) ← ここでは mutating 制約なし

実践例:行情報を集めて集計

CREATE OR REPLACE TRIGGER emp_salary_compound
FOR UPDATE OF salary ON employees
COMPOUND TRIGGER

  -- 共有変数(コレクション)
  TYPE t_ids IS TABLE OF NUMBER;
  v_changed_ids t_ids := t_ids();
  v_total_diff NUMBER := 0;

  BEFORE STATEMENT IS
  BEGIN
    -- 初期化
    v_changed_ids.DELETE;
    v_total_diff := 0;
  END BEFORE STATEMENT;

  AFTER EACH ROW IS
  BEGIN
    -- 各行の情報を集める
    v_changed_ids.EXTEND;
    v_changed_ids(v_changed_ids.COUNT) := :NEW.id;
    v_total_diff := v_total_diff + (:NEW.salary - :OLD.salary);
  END AFTER EACH ROW;

  AFTER STATEMENT IS
  BEGIN
    -- ここで同一テーブル参照OK!
    DBMS_OUTPUT.PUT_LINE('Changed ' || v_changed_ids.COUNT || ' rows');
    DBMS_OUTPUT.PUT_LINE('Total salary diff: ' || v_total_diff);
    
    -- 監査ログ書き込み等
    INSERT INTO audit_log(action, rows_affected, total_change)
    VALUES ('SALARY_UPDATE', v_changed_ids.COUNT, v_total_diff);
  END AFTER STATEMENT;

END emp_salary_compound;
/

全タイミング使用の完全例

CREATE OR REPLACE TRIGGER emp_full_compound
FOR INSERT OR UPDATE OR DELETE ON employees
COMPOUND TRIGGER

  v_op VARCHAR2(10);
  v_start_time TIMESTAMP;
  v_row_count NUMBER := 0;

  BEFORE STATEMENT IS
  BEGIN
    -- statement の種類を判定
    IF INSERTING THEN v_op := 'INSERT';
    ELSIF UPDATING THEN v_op := 'UPDATE';
    ELSIF DELETING THEN v_op := 'DELETE';
    END IF;
    
    v_start_time := SYSTIMESTAMP;
    DBMS_OUTPUT.PUT_LINE('=== ' || v_op || ' start at ' || v_start_time || ' ===');
  END BEFORE STATEMENT;

  BEFORE EACH ROW IS
  BEGIN
    -- 各行の直前検証
    IF UPDATING AND :NEW.salary < :OLD.salary * 0.5 THEN
      RAISE_APPLICATION_ERROR(-20001, '給与を50%以上下げられません');
    END IF;
  END BEFORE EACH ROW;

  AFTER EACH ROW IS
  BEGIN
    v_row_count := v_row_count + 1;
  END AFTER EACH ROW;

  AFTER STATEMENT IS
    v_total_active NUMBER;
  BEGIN
    -- ここで同一テーブル参照OK
    SELECT COUNT(*) INTO v_total_active
    FROM employees WHERE status = 'ACTIVE';
    
    DBMS_OUTPUT.PUT_LINE(v_op || ' finished: ' || v_row_count || ' rows');
    DBMS_OUTPUT.PUT_LINE('Total active: ' || v_total_active);
  END AFTER STATEMENT;

END emp_full_compound;
/

メリット

  • 単一トリガーで完結(複雑さを1箇所に集約)
  • 共有変数が便利
  • Mutating 制約を回避
  • AFTER STATEMENT で同一テーブル参照OK

デメリット

  • Oracle 11g+ 必要
  • 学習コストがある

【解決策③】Autonomous Transaction(⚠️ 避けるべき)

動作

PRAGMA AUTONOMOUS_TRANSACTION独立トランザクションを作成:

CREATE OR REPLACE TRIGGER bad_trigger
AFTER UPDATE ON employees
FOR EACH ROW
DECLARE
  PRAGMA AUTONOMOUS_TRANSACTION;
  v_count NUMBER;
BEGIN
  SELECT COUNT(*) INTO v_count FROM employees;  -- 動く!
  COMMIT;   -- 必須
END;
/

エラーは出ず、動く。しかし…

問題点

メインの UPDATE 中の変更が見えない:

-- 現在: Active 100人
UPDATE employees SET status = 'ACTIVE' WHERE id = 200;
-- トリガー内で SELECT COUNT(*)
-- → 100 が返る(201 ではない!)
-- 理由: Autonomous Transaction は本トランザクション未完了の変更を見ない

具体的な不整合

-- 業務要件:Active 数を集計してログ
CREATE TRIGGER emp_audit
AFTER UPDATE OF status ON employees FOR EACH ROW
DECLARE
  PRAGMA AUTONOMOUS_TRANSACTION;
  v_count NUMBER;
BEGIN
  SELECT COUNT(*) INTO v_count FROM employees WHERE status = 'ACTIVE';
  INSERT INTO audit_log(active_count, timestamp) VALUES (v_count, SYSDATE);
  COMMIT;
END;
/

-- 初期: 100 active
UPDATE employees SET status = 'ACTIVE' WHERE id = 200;
COMMIT;

-- audit_log に active_count = 100 と記録される
-- 期待: 101
-- → 不整合発生!

なぜ多くの記事が推奨するか

  • エラーが消える(見た目上動く)
  • 一見シンプル
  • 短時間の情報検索で「解決した」と誤解

でも実は業務ロジックが壊れている

いつなら使ってもいい?

同一テーブルを参照しない用途なら OK:

  • 別テーブルへのログ書き込み
  • 外部システム呼び出し
  • メール送信

同一テーブルを参照するなら絶対に避ける


【解決策④】Package Variable(伝統的、11g 未満)

発想

セッション単位の Package 変数にデータを保存、後で使う。

手順

1. Package 定義:

CREATE OR REPLACE PACKAGE emp_pkg AS
  TYPE t_ids IS TABLE OF NUMBER;
  g_changed_ids t_ids := t_ids();
  
  PROCEDURE add_id(p_id NUMBER);
  PROCEDURE process_all;
END emp_pkg;
/

CREATE OR REPLACE PACKAGE BODY emp_pkg AS
  PROCEDURE add_id(p_id NUMBER) IS
  BEGIN
    g_changed_ids.EXTEND;
    g_changed_ids(g_changed_ids.COUNT) := p_id;
  END;
  
  PROCEDURE process_all IS
    v_count NUMBER;
  BEGIN
    SELECT COUNT(*) INTO v_count FROM employees WHERE status = 'ACTIVE';
    DBMS_OUTPUT.PUT_LINE('Active: ' || v_count);
    g_changed_ids.DELETE;  -- クリア
  END;
END emp_pkg;
/

2. Row-level Trigger でデータ収集:

CREATE OR REPLACE TRIGGER emp_row_trg
AFTER UPDATE ON employees FOR EACH ROW
BEGIN
  emp_pkg.add_id(:NEW.id);
END;
/

3. Statement-level Trigger で処理:

CREATE OR REPLACE TRIGGER emp_stmt_trg
AFTER UPDATE ON employees
BEGIN
  emp_pkg.process_all;
END;
/

メリット

  • 11g 未満で使える
  • 柔軟な処理

デメリット

  • 複雑(Package + 2 Trigger)
  • セッション変数管理の面倒
  • Oracle 11g+ なら Compound Trigger でシンプルに

発生パターン別対処

パターン1: 集計値を計算したい

-- ❌ Mutating エラー
CREATE TRIGGER emp_row_trg
AFTER UPDATE ON employees FOR EACH ROW
DECLARE v_total NUMBER;
BEGIN
  SELECT SUM(salary) INTO v_total FROM employees;
END;
/

対処: Compound Trigger の AFTER STATEMENT 部で。

パターン2: 制約検証(給与合計上限など)

-- 業務要件: 部署の給与合計は100万まで

-- ❌ Mutating
CREATE TRIGGER dept_check_trg
AFTER UPDATE OF salary ON employees FOR EACH ROW
DECLARE v_total NUMBER;
BEGIN
  SELECT SUM(salary) INTO v_total 
  FROM employees WHERE dept_id = :NEW.dept_id;
  IF v_total > 1000000 THEN
    RAISE_APPLICATION_ERROR(-20001, '部署給与上限超過');
  END IF;
END;
/

対処:

CREATE OR REPLACE TRIGGER dept_check_compound
FOR UPDATE OF salary ON employees
COMPOUND TRIGGER

  TYPE t_depts IS TABLE OF NUMBER INDEX BY PLS_INTEGER;
  v_depts t_depts;

  AFTER EACH ROW IS
  BEGIN
    v_depts(:NEW.dept_id) := 1;  -- 影響部署を記録
  END AFTER EACH ROW;

  AFTER STATEMENT IS
    v_dept_id NUMBER;
    v_total NUMBER;
  BEGIN
    v_dept_id := v_depts.FIRST;
    WHILE v_dept_id IS NOT NULL LOOP
      SELECT SUM(salary) INTO v_total
      FROM employees WHERE dept_id = v_dept_id;
      IF v_total > 1000000 THEN
        RAISE_APPLICATION_ERROR(-20001, '部署 ' || v_dept_id || ' 給与上限超過');
      END IF;
      v_dept_id := v_depts.NEXT(v_dept_id);
    END LOOP;
  END AFTER STATEMENT;

END dept_check_compound;
/

パターン3: 監査ログ

-- ❌ Mutating
CREATE TRIGGER audit_row
AFTER UPDATE ON employees FOR EACH ROW
BEGIN
  INSERT INTO audit_log 
  SELECT SYSDATE, COUNT(*) FROM employees;  -- Mutating
END;
/

対処: audit_log テーブルは別、employees の SELECT が問題:

  • Compound Trigger で AFTER STATEMENT で処理
  • または、行数を Row-level で数えて Statement-level で書き込む

パターン4: Function 経由の間接参照

-- ❌ 間接的に mutating
CREATE FUNCTION get_active_count RETURN NUMBER IS
  v_count NUMBER;
BEGIN
  SELECT COUNT(*) INTO v_count FROM employees;
  RETURN v_count;
END;
/

CREATE TRIGGER emp_trg
AFTER UPDATE ON employees FOR EACH ROW
DECLARE v_count NUMBER;
BEGIN
  v_count := get_active_count;  -- 内部で employees SELECT → Mutating
END;
/

対処: Compound Trigger の AFTER STATEMENT で関数呼び出し。

パターン5: Cascade Trigger

-- テーブル A の trigger が B を更新
-- B の trigger が A を SELECT → Mutating

対処: トリガー設計を見直し、Compound Trigger で1箇所に集約。


実践シナリオ

シナリオ1:シンプルな集計

-- 更新後、部門のアクティブ人数をログ
CREATE OR REPLACE TRIGGER dept_active_log
FOR UPDATE OF status ON employees
COMPOUND TRIGGER

  TYPE t_depts IS TABLE OF PLS_INTEGER INDEX BY PLS_INTEGER;
  v_depts t_depts;

  AFTER EACH ROW IS
  BEGIN
    v_depts(:NEW.dept_id) := 1;
  END AFTER EACH ROW;

  AFTER STATEMENT IS
    v_dept_id NUMBER;
    v_count NUMBER;
  BEGIN
    v_dept_id := v_depts.FIRST;
    WHILE v_dept_id IS NOT NULL LOOP
      SELECT COUNT(*) INTO v_count 
      FROM employees 
      WHERE dept_id = v_dept_id AND status = 'ACTIVE';
      
      INSERT INTO dept_status_log(dept_id, active_count, log_time)
      VALUES (v_dept_id, v_count, SYSTIMESTAMP);
      
      v_dept_id := v_depts.NEXT(v_dept_id);
    END LOOP;
  END AFTER STATEMENT;

END dept_active_log;
/

シナリオ2:業務ルール検証

-- 業務: 給与を50%以上下げてはいけない、
--       部署予算超過は不可

CREATE OR REPLACE TRIGGER emp_business_rules
FOR UPDATE OF salary ON employees
COMPOUND TRIGGER

  TYPE t_depts IS TABLE OF PLS_INTEGER INDEX BY PLS_INTEGER;
  v_affected_depts t_depts;

  BEFORE EACH ROW IS
  BEGIN
    -- 個別行チェック(同一テーブル参照なし)
    IF :NEW.salary < :OLD.salary * 0.5 THEN
      RAISE_APPLICATION_ERROR(-20001, 
        '給与を50%以上下げられません: ID ' || :NEW.id);
    END IF;
  END BEFORE EACH ROW;

  AFTER EACH ROW IS
  BEGIN
    v_affected_depts(:NEW.dept_id) := 1;
  END AFTER EACH ROW;

  AFTER STATEMENT IS
    v_dept_id NUMBER;
    v_total NUMBER;
    v_budget NUMBER;
  BEGIN
    v_dept_id := v_affected_depts.FIRST;
    WHILE v_dept_id IS NOT NULL LOOP
      SELECT SUM(salary) INTO v_total
      FROM employees WHERE dept_id = v_dept_id;
      
      SELECT budget INTO v_budget
      FROM departments WHERE id = v_dept_id;
      
      IF v_total > v_budget THEN
        RAISE_APPLICATION_ERROR(-20002,
          '部署 ' || v_dept_id || ' 予算超過: ' || v_total || ' > ' || v_budget);
      END IF;
      
      v_dept_id := v_affected_depts.NEXT(v_dept_id);
    END LOOP;
  END AFTER STATEMENT;

END emp_business_rules;
/

シナリオ3:親子テーブル同期

-- orders テーブル更新で、customers テーブルの last_order_date を更新

CREATE OR REPLACE TRIGGER order_sync_customer
FOR INSERT OR UPDATE ON orders
COMPOUND TRIGGER

  TYPE t_cust_dates IS TABLE OF DATE INDEX BY PLS_INTEGER;
  v_cust_dates t_cust_dates;

  AFTER EACH ROW IS
  BEGIN
    IF NOT v_cust_dates.EXISTS(:NEW.customer_id) OR
       v_cust_dates(:NEW.customer_id) < :NEW.order_date THEN
      v_cust_dates(:NEW.customer_id) := :NEW.order_date;
    END IF;
  END AFTER EACH ROW;

  AFTER STATEMENT IS
    v_cust_id NUMBER;
  BEGIN
    v_cust_id := v_cust_dates.FIRST;
    WHILE v_cust_id IS NOT NULL LOOP
      UPDATE customers 
      SET last_order_date = v_cust_dates(v_cust_id)
      WHERE id = v_cust_id;
      
      v_cust_id := v_cust_dates.NEXT(v_cust_id);
    END LOOP;
  END AFTER STATEMENT;

END order_sync_customer;
/

シナリオ4:Rails マイグレーションでのトリガー作成

class CreateOrderSyncTrigger < ActiveRecord::Migration[8.0]
  def up
    execute <<-SQL
      CREATE OR REPLACE TRIGGER order_sync_customer
      FOR INSERT OR UPDATE ON orders
      COMPOUND TRIGGER
        -- ... 上記のロジック
      END;
    SQL
  end

  def down
    execute "DROP TRIGGER order_sync_customer"
  end
end

Rails 8 系の詳細は Rails 8 アップグレードガイドの記事、rails db:migrate 使い方の記事、Solid Queue 使い方の記事、find/find_by/where の記事も参照してください。

シナリオ5:Docker Oracle 開発環境

# Docker で開発中
docker exec -it oracle-xe sqlplus scott/tiger

# トリガー作成後テスト
UPDATE employees SET status='ACTIVE' WHERE id=100;

Docker 関連は docker daemon 接続エラーの記事、Docker no space left on device の記事も参照してください。

シナリオ6:Kamal デプロイでのトリガー配置

# config/deploy.yml
# デプロイ時にマイグレーションでトリガー作成

Kamal デプロイの詳細は Kamal 2 デプロイの記事、Kamal 使用時のジョブ処理は Solid Queue 使い方の記事、キャッシュ設定は Solid Cache 使い方の記事も参照してください。

シナリオ7:階層データの整合性チェック

-- 組織の階層構造、循環参照を防ぐ

CREATE OR REPLACE TRIGGER org_hierarchy_check
FOR INSERT OR UPDATE OF parent_id ON organizations
COMPOUND TRIGGER

  TYPE t_ids IS TABLE OF NUMBER;
  v_changed_ids t_ids := t_ids();

  AFTER EACH ROW IS
  BEGIN
    v_changed_ids.EXTEND;
    v_changed_ids(v_changed_ids.COUNT) := :NEW.id;
  END AFTER EACH ROW;

  AFTER STATEMENT IS
    v_count NUMBER;
  BEGIN
    -- 各変更行から親をたどって循環しないかチェック
    FOR i IN 1 .. v_changed_ids.COUNT LOOP
      SELECT COUNT(*)
      INTO v_count
      FROM organizations
      START WITH id = v_changed_ids(i)
      CONNECT BY NOCYCLE PRIOR parent_id = id;
      -- 循環検出時の処理
    END LOOP;
  END AFTER STATEMENT;

END org_hierarchy_check;
/

シナリオ8:在庫と注文の整合

CREATE OR REPLACE TRIGGER inventory_check
FOR UPDATE OF quantity ON order_items
COMPOUND TRIGGER

  TYPE t_prods IS TABLE OF PLS_INTEGER INDEX BY PLS_INTEGER;
  v_affected_prods t_prods;

  AFTER EACH ROW IS
  BEGIN
    v_affected_prods(:NEW.product_id) := 1;
  END AFTER EACH ROW;

  AFTER STATEMENT IS
    v_prod_id NUMBER;
    v_ordered NUMBER;
    v_stock NUMBER;
  BEGIN
    v_prod_id := v_affected_prods.FIRST;
    WHILE v_prod_id IS NOT NULL LOOP
      SELECT SUM(quantity) INTO v_ordered
      FROM order_items WHERE product_id = v_prod_id;
      
      SELECT stock INTO v_stock
      FROM products WHERE id = v_prod_id;
      
      IF v_ordered > v_stock THEN
        RAISE_APPLICATION_ERROR(-20003, 
          '商品 ' || v_prod_id || ' 在庫不足: ' || v_ordered || ' > ' || v_stock);
      END IF;
      
      v_prod_id := v_affected_prods.NEXT(v_prod_id);
    END LOOP;
  END AFTER STATEMENT;

END inventory_check;
/

シナリオ9:本番リリース前のトリガー整合性チェック

-- 既存のトリガー一覧
SELECT trigger_name, triggering_event, table_name, status
FROM user_triggers
ORDER BY table_name, trigger_name;

-- Mutating の可能性があるトリガーを目視確認
SELECT trigger_name, trigger_body
FROM user_triggers
WHERE trigger_type LIKE '%EACH ROW%'
  AND UPPER(trigger_body) LIKE '%SELECT%';

シナリオ10:Trigger を無効化してデータメンテナンス

-- メンテナンス中のみトリガー無効化
ALTER TRIGGER emp_business_rules DISABLE;

-- 大量データ投入
INSERT INTO employees SELECT * FROM staging_employees;

-- 再有効化
ALTER TRIGGER emp_business_rules ENABLE;

予防のベストプラクティス

1. まずトリガーの必要性を検討

制約で表現できるなら CHECK 制約や外部キー:

-- Trigger より
-- ✅ CHECK 制約
ALTER TABLE employees 
  ADD CONSTRAINT chk_salary CHECK (salary >= 0);

-- ✅ 外部キー
ALTER TABLE orders 
  ADD CONSTRAINT fk_customer 
  FOREIGN KEY (customer_id) REFERENCES customers(id);

制約 > トリガーが原則。

2. Row-level Trigger では同一テーブル参照禁止

-- ❌ 避ける
CREATE TRIGGER t
AFTER UPDATE ON emp FOR EACH ROW
BEGIN
  SELECT ... FROM emp ...;  -- Mutating の温床
END;

3. Compound Trigger を第一選択に

Oracle 11g+ なら:

CREATE OR REPLACE TRIGGER t
FOR UPDATE ON emp
COMPOUND TRIGGER
  -- ... 
END;

4. Autonomous Transaction は原則禁止

不整合の温床。同一テーブル参照する用途では絶対に使わない

5. 監査は Oracle 純正 AUDIT 機能

トリガーで自作せず:

AUDIT UPDATE ON employees BY ACCESS;

メンテナンスコストが激減。

6. トリガーの単体テスト

-- テストデータで動作確認
BEGIN
  UPDATE employees SET salary = 100000 WHERE id = 1;
  ROLLBACK;
END;
/

7. トリガー数の制限

1テーブルに10個以上のトリガーは要注意。メンテナンス困難

8. Cascade Trigger の避け方

トリガー A から B、B から A の循環はデッドロックのリスク。

デッドロック関連は、ORA-00060 デッドロックエラーの記事も参照してください。


トラブルシューティング

Autonomous Transaction で解決した「はず」

動くが不整合発生。集計値が期待と違う場合、Autonomous 使用を疑う:

SELECT trigger_name, trigger_body
FROM user_triggers
WHERE UPPER(trigger_body) LIKE '%AUTONOMOUS%';

該当があれば Compound Trigger に書き換え。

Compound Trigger でも Mutating

BEFORE EACH ROW / AFTER EACH ROW で同一テーブル参照するとエラー:

-- ❌ NG
AFTER EACH ROW IS
BEGIN
  SELECT COUNT(*) INTO v_count FROM employees;  -- Mutating!
END AFTER EACH ROW;

AFTER STATEMENT のみで同一テーブル参照OK

RAISE_APPLICATION_ERROR が効かない

AFTER STATEMENT で発生したエラーは、トランザクション全体がロールバックされる。想定内の動作。

Trigger 内でループ

再帰トリガーで無限ループの可能性:

-- 対策: SESSION 変数でガード
CREATE PACKAGE session_pkg IS
  g_in_trigger BOOLEAN := FALSE;
END;
/

CREATE TRIGGER t ...
BEGIN
  IF NOT session_pkg.g_in_trigger THEN
    session_pkg.g_in_trigger := TRUE;
    -- ロジック
    session_pkg.g_in_trigger := FALSE;
  END IF;
END;
/

PL/SQL コンパイルエラー

SELECT text 
FROM user_errors 
WHERE name = 'MY_TRIGGER'
ORDER BY sequence;

エラー詳細を確認。

トリガー無効化

-- 個別
ALTER TRIGGER emp_business_rules DISABLE;

-- テーブルの全トリガー
ALTER TABLE employees DISABLE ALL TRIGGERS;

-- 再有効化
ALTER TABLE employees ENABLE ALL TRIGGERS;

よくある質問(FAQ)

Q1. Compound Trigger は 10g で使える?

11g(2007年) 以降のみ。10g では Package + 2 Trigger の伝統手法が必要。

Q2. Autonomous Transaction は絶対ダメ?

同一テーブル参照では絶対ダメ。別テーブルへのログ書き込みなら OK。

Q3. BEFORE と AFTER の Mutating 違い

関係なし。Row-level Trigger なら、BEFORE でも AFTER でも Mutating エラー発生。

Q4. INSTEAD OF Trigger では発生する?

ビューへの INSTEAD OF Triggerでは発生しない(実体テーブルを直接操作するため)。

Q5. MERGE 文でも発生

MERGE も DML なので発生の可能性:

MERGE INTO employees ...
-- Row-level Trigger 内で employees SELECT → Mutating

Q6. パフォーマンスへの影響

Compound Trigger は Row-level Trigger より遅い(各行で保存する処理あり)。ただし通常は許容範囲。

Q7. Rails ActiveRecord での対応

class Employee < ApplicationRecord
  # Trigger より Rails のバリデーション/callback で処理
  before_update :validate_business_rule
end

Rails 開発の詳細は Rails 8 アップグレードガイドの記事、find/find_by/where の記事、includes/preload/eager_load の記事も参照してください。

Q8. Materialized View での代替

集計トリガー代わりに MV:

CREATE MATERIALIZED VIEW mv_dept_summary
REFRESH FAST ON COMMIT
AS SELECT dept_id, COUNT(*) cnt FROM employees GROUP BY dept_id;

トリガーより高速でメンテナンス楽

Q9. Autonomous DB での違い

Autonomous DB でも Compound Trigger 使用可。挙動は同じ。

Q10. RAC 環境での挙動

RAC でも Mutating 制約は同じ。インスタンス関係なし。

Q11. Trigger のパフォーマンス測定

SELECT trigger_name, trigger_type
FROM user_triggers WHERE table_name = 'EMPLOYEES';

-- 実行計画(トリガー内 SQL)
SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY_CURSOR(...));

Q12. Compound Trigger の全タイミング必要?

必要な部分だけ書けば OK:

CREATE TRIGGER t
FOR UPDATE ON emp
COMPOUND TRIGGER
  AFTER STATEMENT IS
  BEGIN
    -- ここだけ
    NULL;
  END AFTER STATEMENT;
END;
/

参考リンク

Oracle 公式


まとめ

ORA-04091: table is mutating の解決、要点を再整理します。

エラーの本質

Row-level Trigger (FOR EACH ROW) 内で 
  同一テーブルを SELECT または DML
→ Read Consistency 保証のため Oracle が禁止
→ ORA-04091

4大解決策 比較

#手法推奨度注意点
Statement-level Trigger⭐⭐⭐⭐:NEW/:OLD 使えない
Compound Trigger⭐⭐⭐⭐⭐本命、11g+
Autonomous Transaction⚠️不整合の温床
Package Variable⭐⭐11g 未満用

現代の推奨:Compound Trigger

CREATE OR REPLACE TRIGGER my_trigger
FOR UPDATE ON my_table
COMPOUND TRIGGER
  -- 共有変数
  v_count NUMBER;

  BEFORE STATEMENT IS BEGIN v_count := 0; END BEFORE STATEMENT;
  AFTER EACH ROW IS BEGIN NULL; END AFTER EACH ROW;
  AFTER STATEMENT IS
  BEGIN
    SELECT COUNT(*) INTO v_count FROM my_table WHERE ...;
    -- 同一テーブル参照OK!
  END AFTER STATEMENT;
END;
/

4つのタイミングポイント

  1. BEFORE STATEMENT: statement 開始前(1回)
  2. BEFORE EACH ROW: 各行の直前
  3. AFTER EACH ROW: 各行の直後
  4. AFTER STATEMENT: statement 完了後(1回)← 同一テーブル参照OK

Autonomous Transaction の罠

✅ エラーが消える
❌ 本トランザクションの変更が見えない
❌ 古いデータで集計 → 不整合

同一テーブル参照には絶対に使わない

予防策

  • 制約で表現できるならトリガー不要
  • Row-level では同一テーブル参照しない
  • Compound Trigger を第一選択
  • Autonomous Transaction は避ける
  • 監査は Oracle 純正 AUDIT 機能
  • 単体テストで動作確認
  • 1テーブルのトリガー数を制限
  • Cascade Trigger を避ける

事故防止

  • エラーが消えたら OK ではない(Autonomous の罠)
  • 業務ロジックの一貫性を検証
  • Compound Trigger で綺麗に書く
  • トリガーはドキュメント化

モダンな代替

  • Materialized View: 集計代替
  • CHECK 制約: 単純ルール
  • 外部キー: 参照整合性
  • AUDIT: 監査
  • アプリ側処理: Rails callback 等

これらの知識は、Oracle トリガー開発・ビジネスルール実装・監査ログ・データ整合性保証・Rails / Kamal 環境での DB 設計・レガシー Oracle 保守など、あらゆる場面で活用できます。本記事をブックマークしておけば、ORA-04091 に出会ってもエレガントに解決できるようになります。


本記事は2026年6月時点の情報をもとに、Oracle Database 19c〜23ai での動作確認・公式ドキュメントに基づき作成しています。Oracle のバージョンにより挙動が異なる場合があるため、最新の情報は Oracle 公式ドキュメント(docs.oracle.com)もあわせてご確認ください。