スタートアップのためのPostgres生存戦略:トラブルを未然に防ぐ鉄則ガイド
The startup's Postgres survival guide
The startup's Postgres survival guide
スタートアップの成長スピードにPostgresを最適化させるための、現場で役立つ生存戦略をまとめました。パフォーマンス低下や予期せぬ障害を回避するためのベストプラクティスを網羅しています。
Postgresに依存するスタートアップの初期から関わってきた身として言わせてもらうと、この投稿はモニタリングとアラート設定への注力が足りないと思う。Postgresには「絶対に」避けるべき致命的な障害パターンがいくつかあって、アラートを活用すれば、それが起きる前に早期警告を受けることができるんだ。
例えば、AWSはXIDの周回(wraparound)が近づくとメールを送ってくるけど、スタートアップでは、特にボクシングデー(祝日)なんかに送られてきた場合、そのメールを見逃す可能性が「非常に」高い。AWSが監視している情報が、ちゃんとポケベルや通知サービスに繋がっている状態にしておくべきだよ。
データベースを扱う上で最初にやるべきことって、バックアップ戦略を立てることじゃないの?初期段階では高可用性(HA)は「あればいいもの」だとしても、本番環境のDBを動かしているなら、バックアップとリストアの計画は生存ガイドの必須項目でしょ?それすらここに書かれていないみたいだね。
みんなはpgのバックアップに何を使ってる?Barman(https://pgbarman.org/ )は今でも主流なのかな?(新しいpgインスタンスをしばらくデプロイしてないんだけど、新規プロジェクトで検討中なんだ)
Postgresは一番好きな技術なんだけど、リーンで質素なブートストラップ環境だと、コストが高すぎて手が出ないんだよね。
結局、DynamoDBやS3、S3上のDuckDB、SQLiteみたいなサーバーレスストレージの組み合わせに落ち着いちゃう。
これって変かな?Postgresをまともに運用しつつ、月額100ドル未満(そう、質素っていうのは本当に質素って意味で…無料枠を活用したいソロ創業者みたいな感じ)に抑える方法ってある?NeonやSupabaseは知ってるけど、以前試した時はスケールした後に密結合な依存関係になって、結局コストが増えてAuroraやRDSに移行することになったんだよね(笑)。
追記:セルフホストの選択肢も知ってるけど、設定にかかる管理工数を考えると、上述のツールを組み合わせる方が早くて安い気がする。DBAとしてのスキルが足りないだけかもしれないし、もっと勉強が必要かな。まあ、今はAIもいるし、最後に触ったのから随分経ったし、もう一度挑戦してみるのもいいかも。
(HatchetのMattです)
ちょっとした補足だけど、1つの複雑怪奇なクエリを実行する代わりに、メモリ上で結合(JOIN)処理を行う方法でかなりの成功を収めた事例がいくつかあるよ。以前は「データベースへのラウンドトリップ(往復回数)を減らすべき」というアドバイスをよく聞いた(実際良いアドバイスだ!)。でも、これがやりすぎになると、複雑なJOINやUNIONロジック、CASE文が入り混じった過剰に複雑なクエリになってしまうことがあるんだ。
うちのコードベースでも、2つ以上のシンプルなクエリを独立して投げて、その結果をループで回しながらマップを使って該当行を突き合わせる場所がいくつかある。従来の常識だと、データベースへのラウンドトリップが増える上にループ処理も入るからパフォーマンスが悪化すると言われがちだけど、クエリプランナーの動作が予測しやすくなるから、実はこの方がうまくいくケースも多い。この手はあまり乱用しない方がいいけど、いざという時には役に立つよ。
注意点として、一部のORMはバックグラウンドで勝手にこれをやってくれることもある。ただ、それが必ずしも推奨できるとは限らないし、単一のクエリで書くのが現実的じゃない時の最終手段として使うのがいいと思う。
全体的には良い記事だけど、いくつかコメントを。
データベースの整合性と正確性が重要な低ボリュームのテーブルには、カスケード削除付きの外部キーを使用する。ただし、大容量テーブルでは注意。
個人的な意見かもしれないけど、僕はカスケード削除が大嫌いなんだ。理由は単純で、多くの現場ではエンジニアの大半がDBそのものよりも、Python/Node/Goといったアプリケーションレイヤーで「生きている」から。カスケード削除(や更新)は実質的に魔法のようなもので、「なぜテーブルAの行を消すとテーブルBの内容が勝手に消えるのか」を理解するのが非常に難しい。特に誰かが設定を間違えていた場合は最悪!長期的なメンテナンス性を考えるなら、DELETE文を明示的に発行する方がいいと思う。外部キーを正しく使えば、DBの整合性の問題は防げるからね。
大規模テーブルのマイグレーションのコツ
書かれている落とし穴と回避策は正しいけど、これを自動で管理してくれる既存のツール[0]があることは指摘しておきたい。大規模テーブルの変更はコマンドを叩くだけ(そしてデータがコピーされるのを心臓バクバクで24時間監視するだけ)にするのが理想だ。
他に考慮すべきこと:
アプリとDBのデプロイを分離することに早めに慣れておく。スキーマ変更とアプリのデプロイをトランザクション的に同時に行うのは不可能で、必ずバージョンの不一致が発生する期間が生じる。DB側の変更はうまくいったのにアプリ側の変更でコケる、という状況には必ず直面する。本番稼働後は、常に「後方互換性のあるスキーマ変更」を行う習慣をつけること。新しいカラムはすべてNULL許容かデフォルト値設定ありにする、テーブルやカラムの名前は変更しない、など。
同様に、スキーマ管理戦略を早めに決めること。「シニアエンジニアが自分のPCから手動でDDLを実行する」なんてデプロイプロセスは絶対避けるべき。僕はLiquibaseが使い慣れているから好きだけど、Flywayみたいなツールもあるよ。
いくつかコメントと訂正を:
UUID(通常はv4)ではなくuuidv7を使うこと。
ロックされるレコードを最小限にするだけでなく、すべてのクエリでロック順を決定論的に(例えば常にID昇順で)揃えるようにすること。そうしないとデッドロックが発生する(もっとも、Postgresはデッドロック検知が非常に優秀なので、運が良ければ単にエラーになるだけで済むけれど)。
常に「explain (generic_plan)」を使うこと。a) パラメータのプレースホルダーが入った状態のクエリをそのままコピペして検証できるし、b) Postgresが特定のパラメータ値を見通せない時にクエリがどう最適化されるかを確認できるから。
クエリプランのテストをする時は「set seqscan = off」を使うこと。特にテーブルが空かそれに近い状態の時、シーケンシャルスキャンが安くなくなった時にインデックスが使われるか確認できる。
みんなデフォルトでB-treeインデックスを使うけど、これは重くてインデックスの肥大化を招く。単にカラムやIDで検索するだけで、ソートや不等号検索が必要ないならハッシュインデックスの利用を検討すべき。ユニークハッシュインデックスは作れないけど、「exclude using hash」制約を使えば同じ効果を得られるよ(マルチカラムのユニークインデックスは無理だけど)。
GIN(とGIST)インデックスについて学ぶこと。MySQL出身者には意外かもしれないけど、新しい構文を使わなくても一般的なクエリを高速化できる。例えば、Plain Janeの「%foo%」検索みたいなクエリも、FTS(全文検索)に切り替えなくてもこれで速くできるよ。
良いアドバイスだけど、僕が一緒に働いてきたどのスタートアップも、これらよりももっと根本的なところで躓いていた。スケーリングの問題というより、組織の問題だね。大抵は以下を守るだけで解決する。
コネクション管理は地雷が多いから、pgbouncerのような外部コネクションプーラーは最高だ!何らかの理由で導入できないなら、インメモリのコネクションプーラーが優れた次善の策になる。例えば、Hatchetはオープンソースだから、全ユーザーがコネクションプーラーを使っているとは限らない。だからその目的でpgxpool(Go用のインメモリコネクションプーラー)を使っているんだ。
コネクションプーリングの実装には、大きな分かれ道があることはあまり知られていない。
ほとんどのアプリ向けコネクションプーラーはFIFO(先入れ先出し)アルゴリズムに従っている。実装が簡単で、アプリから常にDBへの接続を利用できるようにするのに十分だからだ。低遅延で、アプリから見ればうまく動く。問題は、過剰な接続を排除するメカニズムに乏しいこと。アプリが常にすべての接続を「温かい」状態に保ってしまうからだ。
PgBouncerやごく一部の外部プーラーは、逆のLIFO(後入れ先出し)という考え方をとっている。これはPostgresに到達する接続数を減らすことでスループットを最適化するものだ。最初はクレイジーに聞こえるかもしれない(最後に使った接続が一番最初に再利用される)が、このアルゴリズムは過剰な接続を自動的に冷やしてクローズしてくれる。
新しいアプリを始める時は(1)で十分だけど、スケールしてきたら、どこかのタイミングで(2)の利用が推奨される。数百もの接続を開いたままにするのはPostgresのパフォーマンスに悪影響を与えるから、PgBouncerなどで90%カットできるならやるべきだ。Postgresの「1接続につき1プロセス」という設計は、到達する接続数が少ないほど圧倒的に効率が良くなるんだ。
僕からのアドバイス:
クエリを「シーケンシャルスキャンをするか、しないか」のバイナリで捉えるのが有用だと思う理由は、クエリをマイクロ最適化すればするほど、クエリプランナーが暴走するリスクが高まるからだ。プライマリキーとインデックスに基づくクエリに固執すれば、クエリプランナーは格段に仕事がしやすくなる。
あと重要なのは、クエリプランナーは「平均的なケース」に最適化されるけれど、アプリ開発者にとっては「最悪のケース」を最適化してくれた方がいい場合が多い、という点だ。ただ、前者の方がはるかに解きやすい問題だから、今の実装になっているのも不思議じゃない。
以前、クエリプランナーと戦ったことがあるよ。あるテーブルで数行しか入っていない一般的なユーザーには最適化されて10ms以下で返るインデックスを選んでいたんだけど、ヘビーユーザーが同じクエリを投げると、パラメータ次第で最悪1秒以上かかることがあった。だから、平均的には遅くなるけれど、最悪の場合でも100ms以下に収まるような、別のインデックスを通すための複雑なクエリを書かざるを得なかったんだ。僕の会社にとっては、平均で10ms速くなることよりも、タイムアウトを避ける方が遥かに重要だったからね。