audit log や変更履歴テーブルの jsonb 列に、ユーザー入力や外部 API の payload をそのまま保存するのは危険です。巨大な JSON が 1 件混ざるだけでも、一覧 API、管理画面、WAL、レプリケーション、バックアップに影響します。
PostgreSQL には TOAST があり、大きな値を圧縮したり、通常の行とは別の領域へ保存したりできます。しかし、TOAST は巨大な値を「保存しやすくする仕組み」であり、アプリケーションが安全に扱えるようにする仕組みではありません。
巨大な JSON を読み出せば API レスポンスは大きくなり、画面で pretty print すれば大量の文字列をブラウザが描画します。そのため、audit log へ書き込む前にサイズ上限を設け、超過した payload は省略マーカーへ置き換えます。
{
"kind": "audit_payload_truncated",
"original_size": 123456
}
ただし、法務・監査要件として完全な記録が必要なシステムでは、単純に内容を捨てることはできません。その場合は、完全な payload をオブジェクトストレージなどへ退避し、audit log には参照先、元サイズ、ハッシュなどを残す設計を検討します。
巨大な JSON が入り込む経路
audit log では、操作前後の状態を before と after に保存する実装がよく使われます。
await pool.query(
`INSERT INTO audit_logs (operation, target, before, after)
VALUES ($1, $2, $3, $4)`,
[
operation,
JSON.stringify(target),
JSON.stringify(before),
JSON.stringify(after),
],
);
「誰が、いつ、何を、どの状態からどの状態へ変更したか」を記録する方針自体は妥当です。しかし、保存対象がユーザー入力や外部 API 由来の場合、次のような経路でサイズが膨らみます。
- ユーザーがフォームに大きな JSON を貼り付ける
- カスタム属性の map が数 MB に増える
- 一括処理の対象行をすべて snapshot として保存する
- 外部 API の payload を加工せずに記録する
PostgreSQL が保存できるサイズと、APIやUIが実用的に扱えるサイズは異なります。DB の上限へ達するより先に、レスポンスサイズや画面描画、運用コストが問題になります。
JSONB と TOAST だけでは不十分
PostgreSQL の jsonb は、JSON を検索や比較に適したバイナリ形式で保存する型です。JSON 内部を検索でき、GIN index も利用できるため、audit log や変更履歴にも適しています。
ただし、大きな値を保存すれば、テーブル本体だけでなく、更新時の WAL、レプリケーション、バックアップも増えます。audit log は件数が増え続けやすいため、1 件あたりのサイズ差が長期的な運用負荷につながります。
TOAST によって値が圧縮または行外保存されても、SELECT で列を取得すればアプリケーションには元の JSON が返ります。したがって、一覧 API で全列を取得すると数十 MB のレスポンスになったり、詳細画面で JSON を展開したときにブラウザが重くなったりする問題は残ります。
書き込み前にサイズを制限する
基本方針は、JSON を文字列化して UTF-8 のバイト数を測り、上限を超えた場合は内容を保存しないことです。
const MAX_BYTES = 64 * 1024;
function capPayload(raw: unknown): unknown {
if (raw === null || raw === undefined) return null;
let json: string;
try {
json = JSON.stringify(raw);
} catch {
return {
kind: "audit_payload_truncated",
reason: "stringify_failed",
};
}
const size = Buffer.byteLength(json, "utf8");
if (size > MAX_BYTES) {
return {
kind: "audit_payload_truncated",
original_size: size,
};
}
return raw;
}
64 * 1024 は一例です。上限はDBが保存できるサイズではなく、audit パネルで展開してもブラウザの応答が重くなりにくいサイズを基準に決めます。データとUIの構成に応じて、32 KB、64 KB、128 KB などに調整します。
サイズは文字数ではなくバイト数で測ります。JavaScript の string.length は UTF-8 のバイト数ではないため、日本語などを含む payload では Buffer.byteLength を使います。
JSON.stringify は、循環参照や BigInt などによって失敗することがあります。この場合も audit log の書き込み処理全体を例外にするのではなく、失敗を表す省略マーカーへ置き換えます。
途中まで保存せず、省略マーカーに置き換える
上限を超えた JSON の先頭だけを保存する方法もあります。しかし、途中で切った JSON は構造が壊れたり、重要な部分だけが欠けたりするため、audit log では扱いにくくなります。
そのため、中身は保存せず、元サイズなどのメタデータだけを残します。
{
"kind": "audit_payload_truncated",
"original_size": 123456
}
JSON.stringify に失敗してサイズを測れない場合は、理由だけを残します。
{
"kind": "audit_payload_truncated",
"reason": "stringify_failed"
}
省略された値を確実に判別するため、専用の kind を持たせます。truncated: true だけで判定すると、元データに同じキーが含まれていた場合に誤判定する可能性があります。
多くのシステムでは、audit log は主処理を補助する記録です。audit log に完全な payload を保存できないことを理由にユーザー操作全体を失敗させるより、省略マーカーを残して主処理を継続する方が、best-effort の設計として扱いやすい場合があります。
audit log の書き込み処理へ組み込む
before と after を保存する前に capPayload を通します。
export async function writeAuditLog(
pool: Pool,
params: WriteAuditLogParams,
): Promise<void> {
const cappedBefore = capPayload(params.before);
const cappedAfter = capPayload(params.after);
await pool.query(
`INSERT INTO audit_logs (operation, target, before, after)
VALUES ($1, $2::jsonb, $3::jsonb, $4::jsonb)`,
[
params.operation,
JSON.stringify(params.target),
cappedBefore !== null ? JSON.stringify(cappedBefore) : null,
cappedAfter !== null ? JSON.stringify(cappedAfter) : null,
],
);
}
これにより、before と after に保存される値は、上限以内の元データか、省略理由を表すマーカーのどちらかになります。UI 側では kind を確認し、意図的に省略された payload として表示します。
target にもユーザー入力や大きなオブジェクトが含まれる可能性があるなら、同じ制限を適用する必要があります。
DB 側にも最後の防衛線を置く
アプリケーションで制限していても、別のバッチ処理や管理スクリプトがテーブルへ直接書き込む可能性があります。可能であれば、DB 側にも CHECK 制約を置きます。
ALTER TABLE audit_logs
ADD CONSTRAINT audit_logs_payload_size_check
CHECK (
(before IS NULL OR octet_length(before::text) <= 65536)
AND
(after IS NULL OR octet_length(after::text) <= 65536)
);
この制約は、省略マーカーへの変換を忘れた巨大 payload が保存されることを防ぐ最後の防衛線です。
ただし、JSON.stringify した文字列と jsonb::text では、キー順や空白などの表現が変わるため、バイト数が完全には一致しません。DB 側の上限をアプリケーション側より少し大きくする設計も考えられます。
octet_length(jsonb::text) は、JSON をテキストとして展開したときのバイト数を見る方法です。API レスポンスやUI表示の負荷に近い指標になります。一方、pg_column_size は PostgreSQL 内部での保存サイズを見る関数であり、圧縮後のサイズが反映される場合があります。両者は目的が異なります。
既存データに上限を超える値があると制約の追加に失敗します。先にデータを整理するか、大きなテーブルでは NOT VALID で追加してから検証します。
ALTER TABLE audit_logs
ADD CONSTRAINT audit_logs_payload_size_check
CHECK (
(before IS NULL OR octet_length(before::text) <= 65536)
AND
(after IS NULL OR octet_length(after::text) <= 65536)
)
NOT VALID;
ALTER TABLE audit_logs
VALIDATE CONSTRAINT audit_logs_payload_size_check;
既存の巨大 payload を整理する
新しい書き込みを制限しても、すでに保存された巨大 JSON は残ります。まず dry-run のクエリで対象を確認します。
SELECT
id,
octet_length(before::text) AS before_size,
octet_length(after::text) AS after_size
FROM audit_logs
WHERE octet_length(before::text) > 65536
OR octet_length(after::text) > 65536
ORDER BY
GREATEST(
octet_length(before::text),
octet_length(after::text)
) DESC
LIMIT 100;
対象を確認したうえで、省略マーカーへ置き換えます。
UPDATE audit_logs
SET before = jsonb_build_object(
'kind', 'audit_payload_truncated',
'original_size', octet_length(before::text)
)
WHERE octet_length(before::text) > 65536;
after も同様です。
UPDATE audit_logs
SET after = jsonb_build_object(
'kind', 'audit_payload_truncated',
'original_size', octet_length(after::text)
)
WHERE octet_length(after::text) > 65536;
大量の行を一度に更新すると、WAL が増え、レプリケーション遅延や長時間トランザクションの原因になります。件数の多いテーブルでは、主キーや作成日時の範囲を使って batch に分けます。
入力側と読み出し側でも制限する
audit log 直前の cap は、ログテーブルを守るための防衛です。巨大な入力そのものを受け付けないため、上流にも制限を置きます。
- API gateway や reverse proxy で request body の上限を設定する
- framework の middleware で JSON body の上限を決める
- フォームや設定項目ごとに入力サイズを制限する
- 一括処理では全件分の snapshot を 1 件の audit log に入れない
また、一覧 API では before と after を常に取得しない設計も有効です。一覧では操作名、対象、実行者、日時、省略状態だけを返し、JSON 本文は詳細 API で必要になったときだけ取得します。
上流の入力制限、audit log 書き込み時の cap、DB の CHECK 制約、読み出しAPIの分離を組み合わせることで、想定外の経路から巨大 payload が流れ込んだ場合の影響を抑えられます。
完全な記録が必要な場合
監査や法務上、元の payload を欠損なく保存する必要がある場合は、省略マーカーだけでは要件を満たせません。
その場合は、完全な payload をオブジェクトストレージなどへ保存し、audit log には次の情報を残します。
- 保存先の識別子
- 元のバイト数
- ハッシュ値
- Content-Type
- 圧縮方式
- 保持期限
- 暗号化やアクセス権限に関する情報
DB の一覧処理では小さなメタデータだけを扱い、完全な payload は権限を確認したうえで必要なときだけ取得します。これにより、完全性を保ちながら、通常の audit log API と管理画面を巨大データから分離できます。