Day13 後半のゴール
「“INSERT をきっかけに履歴・ログを残す”設計を自分で組めるようにする」
前半で、BEFORE INSERT トリガーと NEW を使って「入ってくる値を整える」話をしました。
後半では、もう一歩踏み込んで、
INSERT をきっかけに監査ログ・履歴テーブルを自動更新する
AFTER INSERT トリガーの典型パターンを押さえる
トリガーの“やりすぎ”で起きる事故・落とし穴を知る
ここまでを目標にします。
監査ログ用テーブルを用意する
「“何がいつ作られたか”を別テーブルに残す」
まずは、よくある監査ログの形からいきます。
例えば、posts テーブルに対して、
誰が
いつ
どんなタイトルの投稿を作ったか
を別テーブルに記録したいとします。
CREATE TABLE posts (
id INT AUTO_INCREMENT PRIMARY KEY,
title VARCHAR(255) NOT NULL,
body TEXT NOT NULL,
created_at DATETIME NOT NULL
) ENGINE=InnoDB;
CREATE TABLE post_audit_logs (
id INT AUTO_INCREMENT PRIMARY KEY,
post_id INT NOT NULL,
title VARCHAR(255) NOT NULL,
created_at DATETIME NOT NULL,
note VARCHAR(255) NOT NULL
) ENGINE=InnoDB;
SQLpost_audit_logs は、「投稿が作られた履歴」を残すためのテーブルです。
ここに、INSERT のたびに1行ずつ自動で追記していきます。
AFTER INSERT トリガーで監査ログを残す
「INSERT が確定したあとに“別テーブルへ書く”のが王道」
posts への INSERT をフックして、post_audit_logs に1行追加するトリガーはこう書けます。
DELIMITER //
CREATE TRIGGER log_post_insert
AFTER INSERT ON posts
FOR EACH ROW
BEGIN
INSERT INTO post_audit_logs (
post_id,
title,
created_at,
note
) VALUES (
NEW.id,
NEW.title,
NOW(),
'POST_CREATED'
);
END //
DELIMITER ;
SQLここでのポイントを整理します。
AFTER INSERT
投稿がテーブルに書き込まれた「後」に動くので、NEW.id には確定した主キーが入っている。
NEW.id / NEW.title
挿入された投稿のIDとタイトルを、そのまま監査ログにコピーしている。
NOW()
監査ログ側の created_at は、「ログが記録された時刻」として NOW() を使っている。
note
「どういうイベントか」を文字列で残しておくと、後で検索しやすい。
このトリガーがあると、アプリ側は何も意識せずに
INSERT INTO posts (title, body, created_at)
VALUES ('タイトル', '本文', NOW());
SQLとするだけで、
裏で必ず post_audit_logs に1行追加されます。
「履歴テーブル」という考え方
「元テーブルは“最新状態”、履歴テーブルは“過去のスナップショット”」
監査ログや履歴テーブルを設計するとき、
よく出てくる考え方がこれです。
元テーブル(posts)
今の状態だけを持つ
履歴テーブル(post_audit_logs)
「いつ、どんな状態だったか」のスナップショットを積み上げる
INSERT 時だけでなく、UPDATE / DELETE 時にもトリガーを仕込めば、
作られたときの状態
変更されたときの状態
削除されたときの状態
を全部履歴として残すこともできます。
Day13 は INSERT に絞っていますが、
「履歴テーブル」という発想は、
UPDATE / DELETE トリガーにもそのままつながります。
セキュリティ・コンプライアンスの視点
「“消せないログ”をDB側で確保する」
監査ログをトリガーで残す一番の意味は、
「アプリがサボっても、ログだけは必ず残る」ことです。
アプリ側で
「投稿が作られたら、ログテーブルにもINSERTしてね」
と書いておくのは簡単ですが、
一部のコードパスで書き忘れる
バグでログINSERTだけ失敗する
悪意ある開発者がログINSERTをコメントアウトする
といったリスクがあります。
トリガーでDB側に埋め込んでおけば、
posts に INSERT した瞬間に、
必ず post_audit_logs にも1行入る
という状態を作れます。
さらに、権限設計を工夫すれば、
アプリユーザーには post_audit_logs の DELETE 権限を与えない
管理者だけが参照できる
といった構成も取れます。
これは、
不正アクセス調査やコンプライアンス対応で
「ログが消されていないか?」を確認するうえで、とても重要です。
トリガーの落とし穴①:見えない副作用
「アプリからは“INSERTしただけ”に見える」
トリガーの怖さの1つは、
アプリ側から見ると「何が起きているか見えない」ことです。
開発者がコードを読んでいても、
INSERT INTO posts ...
としか書いていないのに、
実際には裏で
監査ログにINSERT
別テーブルの集計をUPDATE
さらに別のトリガーが連鎖して…
といったことが起きている可能性があります。
これが行き過ぎると、
「このINSERT、なんでこんなに遅いの?」
「なんでこのテーブルの行数が勝手に増えてるの?」
といった“謎挙動”に見えてしまいます。
Day13 の段階で意識しておきたいのは、
トリガーは強力なぶん、「どこに何があるか」をちゃんと共有・ドキュメント化しないと、チーム全体の理解からズレていく
ということです。
トリガーの落とし穴②:パフォーマンスと連鎖
「INSERT 1回で“どれだけの処理”が走るかを意識する」
もう1つの落とし穴は、パフォーマンスです。
1回の INSERT で、
トリガーAが動く
→ その中で別テーブルにINSERT
→ そのテーブルにもトリガーBが付いている
→ さらに別の処理が…
という連鎖が起きると、
アプリ側からは「INSERT 1回」のつもりでも、
DBの中ではかなりの処理が走っていることがあります。
特に、
大量INSERT(バルクインサート)
バッチ処理で何万行もINSERT
のような場面では、
トリガーの中身が重いと、一気にDBが悲鳴を上げます。
実務では、
トリガーの中で外部APIを叩かない
トリガーの中で時間のかかる集計をしない
必要以上に多くのテーブルを触らない
といった「トリガーの中でやることの上限」を決めておくのが安全です。
どこまでトリガーに任せるか
「“最低限守りたいルール”だけをDB側に置く」
ここまでの話を、設計の視点で一言にまとめるとこうなります。
トリガーに任せるべきなのは、
必ず守りたい最低限のルール
(監査ログ、created_at の自動セットなど)
アプリごとに実装がバラけると困る処理
(共通の履歴テーブル更新など)
逆に、トリガーに入れすぎるとつらくなるのは、
画面ごとの細かいビジネスロジック
重い集計や外部連携
です。
Day13 の時点では、
トリガー=「DBが自分を守るための、自動ガードレール」
くらいの位置づけで捉えておくと、
バランスが取りやすくなります。
Day13 後半のまとめ
AFTER INSERT トリガーは、「INSERT が確定したあとに別テーブルへログや履歴を自動で書き込む」用途に向いており、CREATE TRIGGER log_post_insert AFTER INSERT ON posts FOR EACH ROW ... のように定義して NEW.id や NEW.title を使えば、投稿が作られるたびに post_audit_logs のような監査テーブルへ「いつ・どの投稿が作られたか」を確実に記録できる。
こうした履歴テーブルは、元テーブルが「現在の状態」だけを持つのに対し、「いつ・どんな状態だったか」というスナップショットを積み上げる役割を持ち、トリガーで自動更新することで、アプリがログINSERTをサボっても「最低限の監査情報だけは必ず残る」状態をDB側で保証できる一方、トリガーはアプリから見えない副作用になりやすく、連鎖や重い処理を入れすぎると1回のINSERTで大量の処理が走ってパフォーマンス問題や“謎挙動”の原因になるため、「監査・最低限ルールだけをトリガーに任せ、重いビジネスロジックや外部連携はアプリやバッチ側に出す」という線引きが実務では重要になる。
