5 ポイント 投稿者 GN⁺ 2024-04-29 | 1件のコメント | WhatsAppで共有
  • Postgresのスキーママイグレーションは、ロック・テーブル再書き込み・レプリケーション遅延が本番障害につながる可能性があり、大規模なOLTP環境では特にリスクが高い
  • リスクは、DEFAULTNOT NULLの同時追加、CONCURRENTLYなしのインデックス作成、即時のカラム削除、安全でない型変更、検証なしの外部キー追加のような、全表スキャンと長時間ロックを引き起こす作業に集中している
  • PostgreSQL 11以降では一部のカラム追加コストは下がったが、インデックスはCREATE INDEX CONCURRENTLY、外部キーはNOT VALIDの後にVALIDATE CONSTRAINTのような、本番影響を抑える手順が必要
  • 大量変更は小さなバッチに分け、読み取りレプリカ・レプリケーション遅延・依存オブジェクト・既存アプリケーションインスタンスがそのカラムを参照しているかどうかまで含めて確認すべき
  • 本番規模のデータで事前テストを行い、破壊的な作業は多段階デプロイと検証済みのロールバック計画を用意してから進めるべき

スキーママイグレーションの前提

  • ここでいうDBマイグレーションはDBMSの移行ではなく、DBスキーマ変更を意味する
  • 対象となる変更には3つの性質がある
    • 各変更に固有の識別子と自動化された適用手順があるバージョン管理された変更
    • 本番適用後は修正せず、新しい変更だけを追加する不変の変更
    • データベーススキーマが段階的に進化する増分変更
  • 焦点はモバイル・WebアプリケーションのようなOLTPのユースケースであり、1秒を超えるクエリ実行は通常遅すぎるとみなされる
  • 小規模なデータベースと低いアクティビティ量では一部の問題は表面化しにくいが、およそ10TiB規模で毎秒10⁴〜10⁵トランザクションの負荷では、ほとんどの問題が発生しうる
  • Database Lab Engine はシン・クローンを使って開発とテストに利用され、10TiBのデータベースを10秒以内にクローンして、スキーマ変更のリスクをデプロイ前に確認できる
  • GitLab Migration Style Guide は、多数のPostgresスキーマ変更を自動デプロイした経験をまとめた参考資料である

カラム追加とテーブル再書き込み

  • DEFAULTNOT NULLを同時に持つカラム追加は、旧バージョンのPostgreSQLで特に危険
    • PostgreSQL 11以前では、テーブル全体の再書き込みが必要
    • 大きなテーブルでは数時間から数日かかることがあり、その間は書き込みロックが発生する
  • 危険な例は次のとおり
ALTER TABLE users ADD COLUMN status text DEFAULT 'active' NOT NULL;
  • より安全な手順は、カラム追加・データ更新・制約追加を分ける方法
    • まずNOT NULLなしでカラムを追加する
    • 必要なら既存行を更新する
    • その後NOT NULL制約を追加する
ALTER TABLE users ADD COLUMN status text DEFAULT 'active';

-- UPDATE users SET status = 'active' WHERE status IS NULL;

ALTER TABLE users ALTER COLUMN status SET NOT NULL;
  • PostgreSQL 11以降では、非volatileなDEFAULT値を持つカラム追加はもはやテーブル再書き込みを必要としない

インデックス作成と外部キー追加

  • CONCURRENTLYなしでインデックスを作成すると、通常のインデックス作成はテーブルに排他ロックを取得する
    • インデックス作成が終わるまで、すべての書き込みと一部の読み取りがブロックされる可能性がある
  • 危険な例は次のとおり
CREATE INDEX idx_users_email ON users(email);
  • 本番運用中はCREATE INDEX CONCURRENTLYの利用がより安全
CREATE INDEX CONCURRENTLY idx_users_email ON users(email);
  • CONCURRENTLYには制約がある
    • より長くかかるが、テーブルアクセスを止めない
    • トランザクションブロック内では使えない
    • 失敗すると、削除が必要な無効インデックスが残ることがある
  • 大きなテーブルに外部キー制約を直接追加すると、既存データ検証のために全表スキャンが発生し、長時間ロックを引き起こす
  • より安全な手順は、まずNOT VALIDで制約を追加し、その後トラフィックの少ない時間帯に検証する方法
ALTER TABLE orders
ADD CONSTRAINT fk_orders_user_id
FOREIGN KEY (user_id) REFERENCES users(id)
NOT VALID;

ALTER TABLE orders VALIDATE CONSTRAINT fk_orders_user_id;

カラム削除と型変更

  • 本番環境でカラムを即座に削除すると、アプリケーションコードがまだそのカラムを参照している場合にアプリケーションエラーが発生する可能性がある
  • カラム削除は多段階で進めるべき
    • そのカラムを使わないアプリケーションコードを先にデプロイする
    • 古いアプリケーションインスタンスがすべて置き換わるまで待つ
    • 別のマイグレーションでカラムを削除する
  • カラム型の変更はテーブル再書き込みや互換性問題を生む可能性がある
    • ダウンタイム、データ損失、アプリケーションエラーにつながりうる
  • 問題になりうる例は次のとおり
ALTER TABLE users ALTER COLUMN id TYPE bigint;
ALTER TABLE users ALTER COLUMN email TYPE varchar(100);
  • integerからbigintへの変更では、新しいカラムを使った多段階手順が必要
  • varcharの長さを短くする場合は、先にデータを確認し、その変更が本当に必要かを検討すべき

大量変更、レプリケーション、依存オブジェクト

  • 1つのトランザクションで過剰な量のデータを変更するマイグレーションは避けるべき
    • ロック競合とメモリ使用量が増える
    • 問題発生時の復旧時間が長くなる
    • レプリケーション遅延が大きくなる可能性がある
  • 大規模なデータマイグレーションは、小さなバッチに分けるほうが安全
  • マイグレーションが読み取りレプリカとレプリケーション遅延に与える影響も合わせて確認すべき
    • 大きなマイグレーションは顕著なレプリケーション遅延を生むことがある
    • 読み取りレプリカの性能に影響する可能性がある
  • 変更対象のカラムやテーブルに依存するオブジェクトも確認が必要
    • ビュー、関数、トリガーなどの依存オブジェクトを見落とすと、連鎖的な障害や追加の手動介入が必要になることがある

テストとロールバック計画

  • 小さな開発用データセットだけでマイグレーションをテストしても、大規模データセットの性能特性を確認しにくい
  • 本番規模データのクローンでテストすべきであり、Database Lab Engineのようなツールを利用できる
  • 問題発生時にマイグレーションを巻き戻す方法がなければ、本番障害が長期的なダウンタイムにつながる可能性がある
  • 特に破壊的な作業には、検証済みのロールバック計画が必要
  • 安全なスキーマ変更の基本は次のとおり
    • 本番規模データでテストする
    • 危険な作業には多段階アプローチを使う
    • CONCURRENTLYNOT VALIDのようなPostgreSQL機能を活用する
    • 性能とレプリケーション影響を監視する
    • 常にロールバック計画を準備する

1件のコメント

 
GN⁺ 2024-04-29
Hacker News の意見
  • Postgres は本当に好きだが、この記事の大半は避けられるもので、注意に値する内容だと思う。ただし Postgres の最悪なところはロール管理だと思っている。
    機能は強力なので、うまく使えれば素晴らしいはずだが、実際に動くようにする過程は黒魔術のように感じる。インターフェースのあちこちが、期待どおりに動くのか分からない難解な呪文のようで、これほど重要なものを管理するにはひどい方式だ。
    この部分のマニュアルも薄く、狭いユースケースでだいたいどう動くべきかを示す程度にとどまっている。期待どおりにいかなければ、試行錯誤で何を間違えたのか探すしかなく、正しいやり方はいまだにつかめない。複雑なユーザー権限を持つ DB をマイグレーションしようとすると本当に苦労する。
    1か月ほどかけて cookbook を書くべきだと感じた。たとえ1人でも、それを読んで泣きながら眠りにつかずに済むなら価値があるはずだ。

    • PostgreSQL の IAM が複雑だという点には同意する。複雑な理由は、オブジェクト階層が Database、Schema、Tables の3段階であり、DB オブジェクトの所有者に暗黙的に付与される権限もあるためだ。
      テーブルで SELECT するには Database の CONNECT、Schema の USAGE が必要で、Schema の所有者には暗黙的に付与される。Table の SELECT も必要で、テーブル所有者には暗黙的に付与される。
      権限を見るには、grantee=privilege-abbreviation[]/grantor: 形式の ACL エントリを理解する必要がある。Database の権限は \l+、Schema の権限は \dn+、Table の権限は \dp+ で確認できる。
      権限の一覧は here にある。たとえば user=arwdDxt/postgres は、postgres ロールがユーザーにすべての権限を与えている状態だ。
      あるオブジェクトの grantee 列が空なら、デフォルトの所有者権限、つまりすべての権限を意味する場合もあれば、存在するすべてのロールである PUBLIC ロールへの権限を意味する場合もある。例は =r/postgres だ。
      public Schema を使うとさらに混乱する。Schema に CREATE 権限があるため、データを参照する同じユーザーでテーブルを作ると、所有者権限がデフォルトで付いてすぐに参照できてしまう。
    • 認証をロールに依存する postgREST のドキュメントも、それほど詳しくは見えない: https://postgrest.org/en/v12/explanations/db_authz.html
      Postgres のロールに関する cookbook を本気で書いて Kickstarter のようなものを始めるなら、真っ先に支援する人の1人になると思う。
    • 「動くようにするのが黒魔術みたいだ」という表現には同意する。去年、行レベルセキュリティを付けたシンプルな postgREST サーバーを実装したが、そこに至るまでの道のりはかなり大変だった。
      それでもいったん動き出すと本当に魔法のようで、関連する仕組み自体は意外にもかなり単純だった。
    • そういう記事があれば読むと思う。ロール管理には推測が多く入り、その結果ロールに過剰な権限が付くことがあまりにも多い。
    • ぜひ書いてほしい。その程度の内容なら20ドルくらい喜んで払える。
  • 本番環境で Schema マイグレーションを走らせるなら、lock_timeout を使うべきだ。
    外部キーのあるテーブルの削除や外部キーの削除のように、見た目には無害でテストではほぼ即時に終わる変更でも、トラフィックの多い本番 DB では既存のトランザクションや autovacuum のせいでロック競合に遭遇し得る。
    その ALTER は最初のトランザクションのロックを待ちながら ACCESS EXCLUSIVE ロックを取ることになり、そうするとロックされたテーブルへのクエリがすべてブロックされる。
    ある程度の規模で Postgres を運用していれば、こうした競合は時間の問題だ。lock_timeout を設定しておけば、ほかのすべてのクエリをブロックしたまま待つ代わりに、制限時間を過ぎるとマイグレーションが失敗する。

    • statement_timeout はロック待ち時間まで含むため、忙しいテーブルへの影響をよりよく見積もれる。
      タイムアウトを5秒に設定すれば、総停止時間が最大5秒だと分かり、その後のトランザクションは継続する。lock_timeout だけを使うと、ロックを取得した後の作業にどれだけ時間がかかるか制御できず、同時トラフィック次第で速くも遅くもなり得る。
    • Postgres のバージョンによって、特定の DML クエリが排他ロックを取るかどうかはかなり大きく変わる。
      クエリを解析して、どの種類のロックを取るのか教えてくれる良い方法があるのか気になる。確信が持てないときは、いつもドキュメントを読み直すやり方でやってきた。
    • 良い助言だ。ただ技術的には、すでに ACCESS EXCLUSIVE ロックを取得して待っているのではなく、ロックキューのために待っているのだと理解していた。
      ALTERACCESS EXCLUSIVE より低いロックが解放されるのを待っている状態だ。
    • そうすると ALTER が永久に実行されない可能性もある。そのテーブルに十分なトラフィックがあれば起こり得る。
      こうした場合、アプリが復旧可能なら、ALTER を妨げているほかの進行中クエリを kill するのが最善だと思う。
  • Fly.io の Safe Migrations in Ecto ガイドを、週に何度も参照している。Ecto は Elixir の DB アダプターだ。
    デフォルトのマイグレーションで十分か、それとももっと複雑な手順が必要かを素早く確認するのに非常に役立つ参考資料だ。
    https://fly.io/phoenix-files/safe-ecto-migrations/

  • 初心者のころ、Postgres のインデックスでいちばん驚いたのは、UNIQUE インデックスが追加のロックによって同時実行クエリの結果に影響し得ることだった
    INSERT INTO foo (bar) (SELECT max(bar) + 1 FROM foo); のようなクエリは、デフォルトモードで同時に実行すると重複した bar 値を挿入できてしまう。あるトランザクションが、別のトランザクションによって作られた新しい最大値を見られないことがあるため
    UNIQUE インデックスを追加すると「負けた」トランザクションが制約違反エラーを受け取るように思えるが、実際には両方のトランザクションが成功し、競合状態もなくなる

    • それは事実ではない。インデックス競合で負けたサブトランザクションは中断される
      =# INSERT INTO foo (bar) (SELECT max(bar) + 1 FROM foo);
      ERROR: duplicate key value violates unique constraint "foo_bar_idx"
      DETAIL: Key (bar)=(2) already exists.
    • UNIQUE インデックスがある状態でも 2 つの挿入がどちらも成功し、最終的に重複値が入るという意味なら、もし本当ならそれはバグだ
    • 勘違いでなければ、通常のインデックスを CONCURRENTLY で作成し、検査されていない UNIQUE 制約を作成すれば、無停止で実現できる
      その制約は新しい INSERT/UPDATE にだけ適用される。その後、制約に対して VALIDATE を実行すれば、完全な UNIQUE 制約になる
    • それが驚きに感じられるなら、命令型言語に触れすぎているからだと思う
      よくあることだという点には同意するが、問題は Postgres というよりソフトウェア開発全般にある
    • それはどの分離レベルでの話?
  • こうした落とし穴があるため、無停止スキーママイグレーションの自動化を目指して Reshape [0] を作った
    すべての問題を回避できるとは言えないが、それを目標にした新しい製品を作っている。この領域、特に Postgres に関心があるなら連絡してほしい: fabian@reshapedb.com
    [0] https://github.com/fabianlindfors/reshape

    • crdb でも動く可能性はある?
  • よく見るもう一つのミスは、テーブルを複製するときにインデックスを入れ忘れること
    CREATE TABLE SELECT * FROM WHERE <> はそのようには動作しない。バックアップテーブルを作ったり、大量削除をしようとしたりするときに、人々はよくこうする

    • バックアップテーブルを作る場合、つまりすぐに予測不能な形で壊れる可能性がある複雑で曖昧な作業をしようとしているなら、インデックスや制約条件はまったく気にしない
      DB バックアップと WAL から復元しなくて済むように、使うことはないだろうが、すぐそこに存在するデータのコピーが欲しいだけだ。インデックスを作るのはサーバー時間とディスク容量の無駄である
      物事がこじれたり本当に必要になったりしたら、後でそのインデックスを作ればよい
    • では適切な方法が何なのかもあわせて教えてくれる?
  • 「Case 2. IF [NOT] EXISTS の誤用」の部分は、良い誤用例を示していない
    そして実際にはそう使うのが正しい。きれいで単純で、隠れた落とし穴もない。テーブルが数個しかないなら、スキーママイグレーションツールは過剰な負担だ

    • 落とし穴は単純だ。「ロジックで問題を覆い隠し、異常状態のリスクを増やす」ということだ
      悪いデータに絆創膏を貼っても問題は解決せず、隠れるだけだ。問題の種類によっては、後になって予想外の形で、最悪のタイミングで噴き出すことがある
      この場合の「悪いデータ」とは、存在すべき、または存在すべきでないのに逆に存在しているテーブル、カラム、ビューのことだ。なぜまだ存在してはいけないテーブルが存在しているのか? 削除に失敗したのか? 既存テーブルのスキーマは正しいのか? 同じマイグレーションが誤って 2 回実行されたのか?
      各マイグレーション後のスキーマは正確な状態でなければならない。マイグレーションに IF [NOT] EXISTS が入っているなら、前のマイグレーション後にスキーマが正確な状態で残らなかったということだ。スキーマの状態を確信できないのはよくない
    • 記事は誤用についてかなりうまく説明していたと思う。要点は、別経路でのスキーマ変更はプロセスとワークフローの問題なので、直接解決すべきだということだ
      既に存在するテーブルのカラムが、マイグレーションで作ろうとしているものと異なっていたらどうするのか? IF EXISTS はマイグレーションを成功させてしまうが、スキーマは悪い状態のまま残る。こういう場合は、マイグレーションが早く失敗するほうがよい
  • int4 を代理主キーとして使う部分への細かい指摘
    重要なのはテーブルサイズではなくインデックスサイズではないか? テーブルサイズにはすでに 23 バイトのヘッダーとアライメント用パディングがあるので、4 バイトの差は大きな影響がない。しかし、インデックスをより多くメモリに載せられるなら利点があるかもしれない。インデックスエントリには 8 バイトのヘッダーがある
    また、例に出ている 10 億行は int4 の最大値にかなり近くて不安だ
    それでも記事は素晴らしい

    • その通り。インデックスサイズもあるし、ディスクサイズもある。Postgres はディスク上ではテーブル行を詰めてパックするが、RAM 上ではそうではない
      それは、ディスク上の 8KB ページが RAM 上では 8KB より大きくなることもあるという意味か?
      テーブル行データの作業メモリにだけ影響するように思える。それでも重要ではある。特に Postgres は行がランダムな順序なので、範囲クエリの局所性がひどい。ただし決定的な洞察というほどではないと思う
  • DB 関連の問題からおおむね守られてきた開発者だ。Django の中では、マイグレーションの作成、モデルテーブルの作成、ORM でのクエリは分かるが、内部で起きている多くのことは黒魔術のように感じる
    これから会社を始めるにあたり、こうした問題に直面して一人で解決しなければならないのではないかと不安だ。開発環境で何をすべきか学ぶには、どうアプローチすればよいだろう?

    • 失敗して、その失敗から学べばいい。あるいは開発者を雇って、一緒に失敗し、一緒に学べばいい
  • Postgres は好きだが、組み込みのバッチ更新/削除方法がない点は本当に嫌いだ
    いちばん腹立たしい部分で、壁にぶつかるたびに、ほぼ毎月バッチ処理を書く羽目になる