Claude Media
Claude Codeでbigintへ主キー移行 — int枯渇に備える段階手順

Claude Codeでbigintへ主キー移行 — int枯渇に備える段階手順

PostgreSQLのint主キーが上限21億に近づいたとき、シーケンスの残量確認から新列追加・バックフィル・切替までをClaude Codeに段階で任せる手順と、止める設定を示します。

PostgreSQLのinteger主キーは、2,147,483,647を超えると採番できなくなります。bigintへ変える方法は一つではありません。型変更を一文で済ませる方法はテーブル全体の書き換えを伴い、大きなテーブルでは長いロックになります。ここでは新しい列を足して埋め、最後に入れ替える段階手順を、Claude Codeにどこまで任せて、どこで止めるかまで含めて組み立てます。

まず残量を測る — pg_sequencesで枯渇までの距離を出す

採番の残量は、シーケンスの現在値と上限の比で分かります。pg_sequencesビューにはdata_type・max_value・last_valueが並んでいます。

psql "$REPLICA_URL" -c "
SELECT schemaname, sequencename, data_type, last_value, max_value,
       round(100.0 * last_value / max_value, 1) AS used_pct
FROM pg_sequences
ORDER BY used_pct DESC NULLS LAST
LIMIT 20;"

読むときの注意が三つあります。

  • last_valueは、キャッシュを使っていると実際に払い出された最後の値より大きくなることがある。
  • シーケンスがまだ一度も読まれていない場合や、現在のユーザーにUSAGEまたはSELECT権限がない場合はNULLになる。NULLS LASTで後ろに回しても、権限不足で隠れた行は残る。
  • ロールバックされた採番も消費されるので、行数とlast_valueは一致しない。

この比率は列の型を見ていません。シーケンスがbigintでも、紐づく列がintegerのままなら、列の上限で先に詰まります。\d テーブル名で列の型を確かめてから、上限に近い順に対象を決めます。

比率だけでは「いつ尽きるか」が分かりません。同じ問い合わせを一週間おきに記録して増分を出すと、日あたりの採番数から枯渇日を割り出せます。この計算はClaude Codeに任せやすい仕事です。結果のCSVを渡して、増加ペースと到達日を出させます。

型変更を直接かけるか、列を足すか

ALTER TABLE ... ALTER COLUMN ... TYPE bigintは、既存列の型を変える標準的な書き方です。PostgreSQLの手順書には、列の型変更は通常テーブルとインデックスの全体書き換えを起こすとあります。ALTER TABLEは特に注記がない限りACCESS EXCLUSIVEロックを取ります。つまり書き換えの間は読み取りも止まります。

方法書き換え向く場面
型を直接変更書き換えテーブルと索引を全体書き換え向く場面数万行のテーブル、メンテナンス時間が取れる場合
新列を足して埋め、入れ替える書き換え列の追加は書き換えなし。埋めるのは小分けの更新向く場面数億行、止められないテーブル

後者は工程が多く、間違える箇所も増えます。そのぶん、各工程が小さく、確認用の問い合わせを挟めます。AIに段階で任せる作業としては、後者のほうが区切りが明確です。

参照先を洗い出す — 主キーを指す外部キーが先

主キーをbigintにするなら、その主キーを参照する外部キー列も同じ型にそろえる必要があります。参照元を見落とすと、切替の直前になって型の不一致に気づきます。pg_constraintのcontype = 'f'が外部キー制約で、confrelidが参照先テーブルです。

SELECT conrelid::regclass AS referencing_table,
       conname,
       pg_get_constraintdef(oid) AS definition
FROM pg_constraint
WHERE contype = 'f'
  AND confrelid = 'public.orders'::regclass;

この結果が「同時に直すテーブルの一覧」になります。外部キーを持たずに、アプリケーション側でorder_idを保持している列もあります。そちらは制約から見えないので、コードの検索で別に拾います。これは、リポジトリ全体を読めるClaude Codeに向いた仕事です。

段階手順 — 追加・同期・埋め戻し・索引・切替

対象をorders(id integer)、参照元をorder_items.order_idとします。実行順は固定で、各段階の終わりに確認の問い合わせを置きます。

1. 新列を追加する

ALTER TABLE orders ADD COLUMN id_new bigint;

デフォルトなしで足す列はNULLで埋められ、テーブルの書き換えは起きません。一方、IDENTITY列や揮発性のデフォルトを付けると全体書き換えになります。ここでは型だけを足します。

2. 新規行を新列にも書くトリガーを置く

埋め戻しの最中にも行は増えます。BEFORE INSERTのトリガーで、新しい行のid_newにidを写します。

CREATE FUNCTION orders_sync_id_new() RETURNS trigger AS $$
BEGIN
  NEW.id_new := NEW.id;
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;
 
CREATE TRIGGER orders_sync_id_new
  BEFORE INSERT ON orders
  FOR EACH ROW EXECUTE FUNCTION orders_sync_id_new();

BEFOREトリガーは挿入される行そのものを書き換えられます。この性質で、アプリケーションを変えずに新列が埋まっていきます。

3. 既存行を小分けに埋め戻す

一度のUPDATEで全行を触ると、長いトランザクションと大量の不要タプルを生みます。主キーの範囲で区切り、一回ごとにコミットします。

UPDATE orders SET id_new = id
WHERE id BETWEEN 1 AND 100000 AND id_new IS NULL;

範囲を進めながら繰り返すループは、スクリプトにします。Claude Codeに書かせるときは、区間の大きさと待ち時間を引数にし、id_new IS NULLの条件で何度流しても結果が変わらない形にしてもらいます。

4. 一意索引を並行して作り、主キーに昇格させる

CREATE UNIQUE INDEX CONCURRENTLY orders_id_new_idx ON orders (id_new);

CONCURRENTLYは書き込みを止めずに索引を作る代わりに、テーブルを二度走査し、トランザクションブロックの中では実行できません。途中で失敗するとINVALIDの索引が残るので、落としてからやり直します。この性質はPostgresのマイグレーションをレビューする記事でロックレベルとあわせて扱っています。Flywayでは1ファイルに1文だけ置く、といった道具側の制約はFlywayの記事、Alembicの記事にあります。

NOT NULLは、先にCHECK制約をNOT VALIDで足して検証しておくと、SET NOT NULLの全件走査を省けます。

ALTER TABLE orders ADD CONSTRAINT orders_id_new_nn
  CHECK (id_new IS NOT NULL) NOT VALID;
ALTER TABLE orders VALIDATE CONSTRAINT orders_id_new_nn;
ALTER TABLE orders ALTER COLUMN id_new SET NOT NULL;

VALIDATE CONSTRAINTはSHARE UPDATE EXCLUSIVEロックで走るため、通常の読み書きと並行できます。

5. 参照元にも同じ処理をする

order_items.order_idにもorder_id_new bigintを足し、同期・埋め戻しを行います。外部キーはNOT VALIDで足してからVALIDATE CONSTRAINTにかけます。ADD FOREIGN KEYは参照先にもSHARE ROW EXCLUSIVEロックを取るので、混雑する時間帯は避けます。

6. 切替は一つのトランザクションに収める

最後に列名を入れ替え、旧主キーを外し、新しい索引を主キーに昇格させます。ADD CONSTRAINT ... PRIMARY KEY USING INDEXは既存の一意索引をそのまま使うので速く終わります。シーケンスはALTER SEQUENCE ... AS bigintで型を変えます。最大値が旧型の上限のままだった場合は、新しい型の上限へ自動で広がります。

BEGIN;
SET LOCAL lock_timeout = '3s';
DROP TRIGGER orders_sync_id_new ON orders;
ALTER TABLE orders DROP CONSTRAINT orders_pkey CASCADE;
ALTER TABLE orders RENAME COLUMN id TO id_old;
ALTER TABLE orders RENAME COLUMN id_new TO id;
ALTER TABLE orders ADD CONSTRAINT orders_pkey PRIMARY KEY USING INDEX orders_id_new_idx;
ALTER SEQUENCE orders_id_seq AS bigint;
ALTER TABLE orders ALTER COLUMN id SET DEFAULT nextval('orders_id_seq');
ALTER SEQUENCE orders_id_seq OWNED BY orders.id;
ALTER TABLE orders ALTER COLUMN id_old DROP DEFAULT;
ALTER TABLE orders ALTER COLUMN id_old DROP NOT NULL;
-- 参照元の列名入れ替えと外部キーの付け替えをここに置く
COMMIT;

列名を入れ替えると、トリガー関数のNEW.id_newは存在しない列を指すため、トリガーは先に外します。CASCADEは参照している外部キーも一緒に消します。ここで消える制約の名前は、先に洗い出した一覧と一致するか確かめてから流します。DEFAULT nextval(...)の付け直しは、同じトランザクションの中で行います。COMMITのあとに回すと、その間に来たINSERTは新しいidがNULLになり、NOT NULL違反で失敗します。シーケンスはserialの既定で旧列にOWNED BYで紐づいたままなので、ALTER SEQUENCE ... OWNED BY orders.idで所有先を新しいidへ移します。これを忘れて旧列を落とすと、シーケンスも一緒に消えます。旧列のDEFAULTとNOT NULLを外しているのは、旧列が残ったまま採番を二重に消費したり、値を入れない挿入が失敗したりするのを防ぐためです。order_items.order_idは外部キー列で、シーケンスもDEFAULTも持たないため、この付け替えは不要です。旧列は、アプリケーションの動作を見届けてから別の変更で落とします。

上限に達すると何が起きるか — 猶予の見積もり

CREATE SEQUENCEの既定はNO CYCLEです。最大値に達したあとのnextvalはエラーを返し、先頭の値へ戻って重複を起こすことはありません。症状は「INSERTが突然すべて失敗する」という形で現れます。CYCLEを指定したシーケンスは最大値の次に最小値を返すので、主キーでは既存の行と衝突します。主キーのシーケンスにCYCLEを付けるのは避けます。

この性質から、猶予は「残り件数÷日あたりの採番数」で見積もります。残り日数が、この手順の所要日数に余裕を足した日数を切っているなら、段階手順を始める前に、書き込みの少ないテーブルから順に着手するか、メンテナンス窓での直接変更に切り替えます。

新しく作るテーブルは、最初からbigintにします。手順書も、生涯で2^31個を超える識別子を使う見込みならbigserialを使うとしています。serialはinteger列を作る記法なので、これを続けると次の移行対象を増やすだけです。

Claude Codeに任せる範囲を設定で決める

ここまでの手順には、戻せない操作が含まれます。DROP ... CASCADEと列名の入れ替えです。記事冒頭の「段階で任せる」を、お願いではなく設定で担保します。

作業の規約はCLAUDE.mdに、例外のない禁止は設定に

# bigint移行の規約
- 本番DBへは接続しない。調査はレプリカ(REPLICA_URL)の読み取りだけ。
- 各段階の最後に、その段階の確認クエリを実行して出力を貼る。
- DROP と RENAME を含む切替SQLは db/migrations/ 以外に書かない。
- 1ファイル1段階。埋め戻しスクリプトは何度流しても結果が変わらない形にする。

公式のベストプラクティスは、CLAUDE.mdはガイドラインで、Claudeが適用しないと判断できる余地がある、と述べています。例外のない規則は、フックか拒否ルールに移します。denyルールは、bypassPermissionsを含むどの許可モードでも効きます。

{
  "permissions": {
    "deny": ["Read(./.env)", "Read(./.env.production)", "Bash(psql *PROD*)"]
  }
}

Bashのルールは、Claudeが書いたコマンド文字列に対して照合されます。/usr/bin/psqlやsh -cで包んだ形までは止まりません。接続先の秘密情報を読めなくするReadの拒否と、本番用の資格情報をそもそも作業環境に置かない運用を組み合わせるのが前提です。本番を止める本命は、資格情報の側にあります。

完了条件を確認クエリにして、Stopフックで強制する

Claudeは作業が終わったように見えた時点で止まります。公式は、合否が返る確認手段を与えると自分でループを閉じられる、と説明しています。段階ごとの確認クエリを一つのスクリプトにまとめます。

#!/bin/bash
# scripts/check-bigint-stage.sh — 失敗の内容は標準エラー出力へ
INPUT=$(cat)
if [ "$(echo "$INPUT" | jq -r '.stop_hook_active')" = "true" ]; then exit 0; fi
n=$(psql "$REPLICA_URL" -Atc "SELECT count(*) FROM orders WHERE id_new IS NULL")
if [ "$n" != "0" ]; then
  echo "orders.id_new が未設定の行が ${n} 件あります" 1>&2
  exit 2
fi
{
  "hooks": {
    "Stop": [
      { "hooks": [ { "type": "command", "command": "./scripts/check-bigint-stage.sh" } ] }
    ]
  }
}

終了コード2が停止を止め、標準エラー出力の内容がClaudeへ理由として戻ります。stop_hook_activeの確認は、同じブロックが連続したときの暴走を避けるためのもので、公式は連続8回で上書きされると説明しています。ここで置く確認は「未設定が0件」のような、一文で合否が出るものに限ります。

途中で止めたいときの戻し方

この手順は、切替までは旧列がそのまま動いています。切替前なら、巻き戻しは追加したものを逆順に外すだけで済みます。

  • トリガーと関数を削除する(DROP TRIGGER、DROP FUNCTION)。
  • CREATE INDEX CONCURRENTLYが失敗して残ったINVALIDの索引を落とす。
  • 新列を落とす。

切替後は事情が変わります。旧列をid_oldとして残しておけば、切替直後のしばらくは値の突き合わせに使えます。ただし、切替後に入った行のid_oldは埋まらないので、元に戻す経路としては使えません。戻す手順を用意するより、切替前の確認段階で止める設計のほうが安全です。この考え方が、Stopフックの確認を各段階の終わりに置く理由です。

切替後に確認すること

切替が終わったら、次の五点を確かめます。

  1. pg_sequencesでorders_id_seqのdata_typeがbigintになり、max_valueが広がっている。
  2. 新しいid列にDEFAULT nextval('orders_id_seq')が付き、シーケンスの所有先(pg_depend、またはpsqlの\d+ orders_id_seqのOwned by)がorders.idになっている。旧列にはDEFAULTが残っていない。
  3. 主キー索引が有効で、INVALIDの索引が残っていない。
  4. pg_constraintで外部キーのconvalidatedが真になっている。
  5. アプリケーションとドライバが、2,147,483,647を超える値を扱える。

五点目は見落としやすい項目です。DBがbigintを返しても、JavaScriptのnumberは2^53を超える整数を正確に保持できませんし、固定長の整数を前提にしたシリアライズ層や、連携先のスキーマがint32のままのこともあります。値がまだ21億に届かないうちは症状が出ないので、上限をまたぐ値をテスト用に入れて往復させるテストを一つ用意しておきます。

まとめ

int主キーの枯渇対策は、残量の測定、参照元の洗い出し、新列の追加と埋め戻し、索引の並行作成、切替の順で進めます。Claude Codeには、測定、参照元の列挙、埋め戻しスクリプトの作成、各段階の確認を任せやすい一方、切替のDROP ... CASCADEは人が読んで流す工程として切り分けます。規約はCLAUDE.md、例外のない禁止はdenyルール、段階の完了判定はStopフックと、置き場所を分けておくと、止めたい場所で確実に止まります。

参照した一次ソース

この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。

  1. code.claude.com/docs/en/best-practices
  2. postgresql.org/docs/current/sql-altertable.html
  3. postgresql.org/docs/current/view-pg-sequences.html
この記事を共有:XはてブLinkedIn