トランザクショナルCRUDアプリにおけるPostgreSQLとMariaDBの比較
PostgreSQLとMariaDB:インデックス、マイグレーション、JSON、クエリ機能について、プロトタイプを超えたCRUDアプリで実際に問題になる点を実務的に比較します。

プロトタイプを超えたときのCRUDアプリ
プロトタイプのCRUDアプリはデータが少なく、チームも小さく、トラフィックが予測可能なので速く感じがちです。単純なクエリ、いくつかのインデックス、手動のスキーマ調整で乗り切れます。ところが、実際のユーザー、実際のワークフロー、実際の締め切りが発生すると状況は変わります。
成長に伴いワークロードが変わります。リストやダッシュボードが一日中開かれるようになり、同じレコードを複数人が編集します。バックグラウンドジョブがバッチ書き込みを始めます。そうなると「昨日は動いていた」が、遅いページ、ランダムなタイムアウト、ピーク時のロック待ちに変わっていきます。
次のような状態なら境界線を超えています:ページ20あたりからリストが遅くなる、リリースにデータのバックフィルが含まれる(新しい列だけでない)、メタデータや統合ペイロード用の「フレックスフィールド」が増える、あるいは混雑時に「保存に時間がかかる」というサポートチケットが届く。
その時点でPostgreSQLとMariaDBの比較は単なるブランドの好みではなく実務的な問題になります。トランザクショナルなCRUDワークロードでは、結果を左右するのはインデックスの選択肢(クエリが複雑になったとき)、大きなテーブルでのマイグレーションの安全性、JSONの保存と問合せ、そしてアプリ側の処理を減らすクエリ機能です。
ここではそうしたデータベースの振る舞いに焦点を当てます。サーバーサイズ、クラウド価格、ベンダー契約について深掘りするわけではありません。重要ですが、スキーマやクエリスタイルほど後から変えにくいわけではありません。
アプリ要件から始める(ブランドからではない)
出発点として優先すべきは「PostgreSQL対MariaDB」ではなく、あなたのアプリの日常動作です:レコードを作成し、いくつかのフィールドを更新し、フィルタした結果を一覧表示し、多数のユーザーが同時に操作しても正しく動くこと。
一番忙しい画面で何をしているかを書き出してください。読み取りは書き込みに対して何倍あるか。スパイクはいつ起きるか(朝のログイン、月末のレポート、大量インポートなど)。どのフィルタとソートが必要かを正確に捕まえておきましょう。これが後のインデックス設計とクエリパターンを決めます。
続いて、譲れない要件を定義します。多くのチームにとっては、金銭や在庫に関する厳密な整合性、誰が何を変更したかの監査トレイル、そしてスキーマが進化しても壊れないレポートクエリなどが含まれます。
運用上の現実も同じくらい重要です。マネージドDBにするかセルフホストにするか、バックアップからどれだけ速く復旧する必要があるか、メンテナンスウィンドウにどれだけ耐えられるかを決めてください。
最後に「十分に速い」をいくつかの明確な目標で定義します。例:通常負荷時のp95 APIレイテンシ 200–400 ms、ピーク同時性でのp95(通常の2倍など)、更新時の最大許容ロック待ち100 ms未満、バックアップと復元の時間限度など。
CRUD速度を左右するインデックスの基本
多くのCRUDアプリは、テーブルが数百万行になり、すべての画面が「フィルタ付きリストとソート」になるまでは速く感じます。その時点でインデックスが50 msのクエリと5秒のタイムアウトを分けます。
BツリーインデックスはPostgreSQLとMariaDBの両方で基本の働き者です。カラムでフィルタするとき、キーで結合するとき、ORDER BY がインデックス順と合致するときに役立ちます。実際の性能差は選択性(どれだけの行がマッチするか)と、フィルタとソートの両方を走査せずにインデックスで満たせるかどうかに依存します。
アプリが成熟するにつれて、単一カラムより複合インデックスが重要になります。よくあるパターンはマルチテナントのフィルタ、ステータス、時間でソートするもので、例えば (tenant_id, status, created_at) のようにします。最も一貫して使われるフィルタ(多くは tenant_id)を先に置き、次に他のフィルタ、最後にソート列を置くと、オプティマイザが効率よく利用できることが多いです。
違いが出るのは「賢い」インデックスです。PostgreSQLは部分インデックスや式インデックスをサポートしており、例えば「open なチケットのみ」をインデックスするような狙いを絞った画面に有効です。ただし、クエリが述語と完全に一致しないと期待外れになることがあるので注意が必要です。
インデックスは無料ではありません。挿入や更新のたびに各インデックスも更新されるため、ある画面を速くする代わりに書き込み全体を静かに遅くしてしまうことがよくあります。
管理のための簡単な方針:
- 実際のクエリパス(名前の付けられる画面やAPI)に対してのみインデックスを追加する。
- 多くの重なり合うインデックスより、ひとつのよい複合インデックスを優先する。
- 機能変更後にインデックスを見直し、不要なものは削除する。
- メンテナンス計画を立てる:PostgreSQLは定期的なvacuum/analyzeが必要、MariaDBも統計とクリーンアップが重要。
- 直感に頼らず、追加前後で測定する。
実際の画面向けインデックス:リスト、検索、ページネーション
多くのCRUDアプリは、フィルタ付きリスト、検索ボックス、詳細ページに時間を費やします。データベースの選択よりも、インデックスがこれらの画面に合っているかが重要ですが、テーブルが大きくなると両方のエンジンで使えるツールに差が出ます。
リストページでは「フィルタ→ソート→ページネーション」の順で考えてください。よくあるパターンは「アカウントXのチケット、statusが(open, pending)、新しい順」といったものです。フィルタ列で始まりソート列で終わる複合インデックスが有利です。
ページネーションは特に注意が必要です。OFFSET ページネーション(OFFSET 380 のように)はスクロールが進むほど遅くなります。キ―セットページネーションは安定します:最後に見た値(例:created_at と id)を渡して「その前の20件」を取る方式です。新しい行が入っても重複や抜けが減ります。
PostgreSQLには INCLUDE を使った「カバリング」インデックスがあり、visibility mapが許せばインデックスオンリースキャンが可能になります。MariaDBでもカバリング読み取りはできますが、通常は必要なカラムをインデックス定義に直接入れて実現するため、インデックスがより幅広になり維持コストが上がることがあります。
次のようなケースが出たらインデックスを見直す必要があります:テーブルが大きくなっても20〜50行しか返さないのにリストエンドポイントが遅くなる、ORDER BY を外さないとソートが遅い、単純なフィルタでI/Oが跳ね上がる。長いクエリは混雑時のロック待ちも増やします。
例:customer_id と status でフィルタし created_at でソートする注文画面なら、通常 (customer_id, status, created_at) で始まるインデックスが有効です。後で「注文番号で検索」を追加すると、それは通常別のインデックスになります。
マイグレーション:データが増えてもリリースを安全にする
マイグレーションはすぐに「テーブルを変える」だけではなくなります。本番データと履歴があると、データのバックフィル、制約の強化、古いデータ形状のクリーンアップをアプリを壊さずに行う必要があります。
安全なデフォルトは拡張、バックフィル、収縮です。既存のコードを妨げない形で追加し、小さなステップでデータをコピー/計算し、最後に古い経路を削除します。
実践的には、新しい nullable 列やテーブルを追加し、バッチでバックフィルして書き込みを一貫性のあるまま保ち、後で NOT NULL や外部キー、ユニーク制約で検証し、その後に古い列やインデックス、コード経路を削除します。
すべてのスキーマ変更が同じリスクとは限りません。列の追加は比較的低リスクですが、大きなテーブルでのインデックス追加は高コストなので閑散時間に計画し測定してください。列型の変更はデータを書き換えたり書き込みをブロックすることがあり、最もリスクの高い作業です。安全なパターンは新しい型の列を作りバックフィルしてから読み書きを切り替えることです。
ロールバックの意味もスケールで変わります。スキーマのロールバックは比較的簡単でも、データのロールバックはしばしば難しいです。破壊的な削除や情報損失を伴う変換が含まれる場合は、何が元に戻せるかを明確にしておきましょう。
JSONサポート:将来の手戻りを避ける柔軟なフィールド
JSONフィールドは素早くリリースするには魅力的です:追加フォームフィールド、統合ペイロード、ユーザー設定、外部システムのメモなどをスキーマ変更なしで入れられます。問題はどれをJSONに入れ、どれを実列にするかの判断です。
PostgreSQLとMariaDBの両方で、JSONはほとんどフィルタされず表示中心で使われる場合に最も良く機能します。デバッグ保存、ユーザーやテナントごとの設定、報告に使わない小さな任意属性などです。
JSONのインデックス化でチームは驚くことがあります。JSONキーを1回だけ問合せするのは簡単ですが、大きなテーブルでそれをフィルタやソートに使うと性能が崩れることがあります。PostgreSQLはJSONパスのインデックス化に強力な選択肢がありますが、それでもフィルタするキーを選んでインデックス化し、残りは未インデックスのペイロードにしておく規律が必要です。MariaDBもJSONを問合せできますが、複雑な「JSON内部を検索する」パターンは脆弱になりやすく、高速に保つのが難しいことがあります。
JSONは制約を弱めます。未構造化のバイナリ内で「ある値でなければならない」や「常に存在する」を強制するのは難しく、レポートツールは一般に型付き列を好みます。
運用でスケールさせるルール:不明な属性はまずJSONに入れ、(1) フィルタやソートに使う、(2) 制約が必要になる、(3) ダッシュボードに毎週現れるようになったら列や子テーブルに正規化してください。例えば注文の配送APIのフルレスポンスをJSONで保存しておくのは多くの場合問題ありませんが、delivery_status や carrier のようなフィールドはサポートやレポートに依存するなら実列にするべきです。
成熟したアプリで出てくるクエリ機能
初期段階では多くのCRUDアプリは単純な SELECT、INSERT、UPDATE、DELETE で動きます。そこからアクティビティフィード、監査ビュー、管理レポート、即時感のある検索が必要になると選択は機能のトレードオフになります。
CTEやサブクエリは複雑なクエリを読みやすく保つのに役立ちます(注文をフィルタし、支払いを結合し、合計を計算するような手順)。ただし読みやすさがコストを隠すことがあります。クエリが遅くなったらCTEをサブクエリや結合に書き換えて実行計画を再確認する必要が出てきます。
ウィンドウ関数は「顧客を支出順にランク付けする」「累積合計を表示する」「チケットごとの最新ステータスを出す」といった要求が出たときに重要になります。アプリ側でのループを置き換え、クエリ数を減らすのに有効です。
冪等な書き込みは成長した環境で必要になります。リトライが発生する場合(モバイル、バックグラウンドジョブ)に重複作成を避けるためにアップサートを使いましょう:
- PostgreSQL:
INSERT ... ON CONFLICT - MariaDB:
INSERT ... ON DUPLICATE KEY UPDATE
検索はチームに忍び寄る機能です。組み込みの全文検索はカタログやナレッジベース、サポートノートに使えます。トライグラム的検索はタイプアヘッドや誤字許容に役立ちます。もし検索がコアになり、複雑なランキングや多くのフィルタ、高いトラフィックが必要になったら、外部の検索エンジンを導入する価値があります。
例:注文ポータルは最初は「注文を一覧表示する」だけです。1年後には「顧客ごとの最新注文を表示し、月間支出でランク付けし、誤字のある名前で検索する」といった要求が出てきます。これらはUIだけでなくデータベース機能の問題になります。
トランザクション、ロック、負荷下での同時実行
トラフィックが少ないときはどのデータベースも問題ないように見えます。負荷が増えたときの差は生の速さではなく、同じデータに対する同時更新をどう扱うかです。PostgreSQLもMariaDBもトランザクショナルなCRUDワークロードを動かせますが、競合に備えた設計は必要です。
平易な言葉での分離レベル
トランザクションは一連の操作をまとめて成功させるためのものです。分離はその間に他のセッションが何を見られるかを制御します。高い分離は驚きの少ない読み取りになりますが待ち時間は増えます。多くのアプリはデフォルトで始め、カード決済や注文更新など本当に必要なフローだけで分離を強めます。
実際にロック痛を引き起こすもの
CRUDアプリのロック問題は大抵、次のような原因からです:みんなが更新するホット行、頻繁に変わるカウンタ、複数のワーカーが同じ「次のジョブ」を取り合うジョブキュー、そして長時間ロックを保持する長いトランザクション(ユーザー操作やネットワーク待ちを含む)。
競合を減らすには、トランザクションを短く保ち、必要なカラムだけ更新し、トランザクション内のネットワーク呼び出しを避けてください。
役立つ習慣は競合時にリトライすることです。たとえば2人のサポート担当が同じチケットを同時に保存したら、単に失敗させずに競合を検出して最新行を再読み込みし、ユーザーに変更を再適用させます。
問題を早く見つけるにはデッドロック、長時間トランザクション、実行時間より待ち時間が多いクエリを監視してください。スロークエリログを日常のルーチンに組み込み、特に新しい画面やバックグラウンドジョブを追加した後は注意深く見ましょう。
ローンチ後に重要になる運用
ローンチ後はクエリ速度だけでなく、復旧、安全な変更、予測できる性能を最適化します。
次の一歩としてレプリカを追加することが多いです。プライマリは書き込みを担当し、レプリカはダッシュボードやレポートのような読み取り重視のページをさばきます。これによりデータの新鮮さについて考え方が変わります:一部の読み取りは数秒遅れても許容できるかどうかを決め、どの画面はプライマリから読むべきかをアプリが知る必要があります(例:「注文直後の確認」はプライマリ)。
バックアップは仕事の半分に過ぎません。本当に重要なのは迅速かつ正確に復元できるかどうかです。別環境への定期的なテスト復元をスケジュールし、アプリが接続できるか、主要テーブルが存在するか、重要なクエリが期待どおりの結果を返すかを検証してください。多くのチームは復元時間がダウンタイム予算を大きく超えていることに遅れて気づきます。
アップグレードも「クリックして終わり」ではなくなります。メンテナンスウィンドウを計画し、互換性ノートを読み、プロダクションデータのコピーでアップグレード経路をテストしてください。マイナーなバージョンアップでもクエリプランやインデックス、JSON関数の挙動が変わることがあります。
シンプルな可観測性は早期に役立ちます。まずはスロークエリログと合計時間順の上位クエリ、接続飽和、レプリケーション遅延(レプリカを使う場合)、キャッシュヒット率とI/Oプレッシャー、ロック待ちとデッドロックイベントを監視してください。
選び方:実践的な評価プロセス
迷っているなら、機能リストを読むのをやめて自分のワークロードで小さな試験を行ってください。目標は完璧なベンチマークではなく、テーブルが数百万行になりリリースサイクルが速くなったときに驚きを避けることです。
1) 本番に似せたミニテストを作る
実際に問題になりそうなスライス(1〜2個の主要テーブル、いくつかの画面、背後の書き込みパス)を選び、上位のクエリ(リスト、詳細、バックグラウンドジョブ)を収集します。現実的な行数(プロトタイプの少なくとも100倍、形状は似せる)を用意し、必要と思われるインデックスを入れて同じフィルタ・ソートでクエリを実行しタイミングを取ります。書き込みが同時に行われる状況でも繰り返してください(簡単な挿入・更新スクリプトで十分です)。
簡単な例として、ステータスでフィルタし名前で検索し最終活動でソートしてページネーションする「顧客一覧」があります。これだけでインデックスやプランナーの振る舞いが将来に耐えられるかを示します。
2) 実際のリリースのようにマイグレーションを通す
データセットのステージングコピーを作り、予定している変更(列追加、型変更、バックフィル、インデックス追加)を演習します。どれくらい時間がかかるか、書き込みをブロックするか、データが既に変わっているときのロールバックはどうなるかを測定してください。
3) シンプルなスコアカードを使う
テスト後に各オプションを、あなたの実際のクエリでの性能、正確性と安全性(制約、トランザクション、エッジケース)、マイグレーションリスク(ロック、ダウンタイム、復旧手段)、運用工数(バックアップ/復元、レプリケーション、監視)、そしてチームの習熟度で評価してください。
次の12か月でリスクを減らす方を選んでください。一つのマイクロテストに勝った方ではなく、長期的に安全な方を。
よくある間違いと罠
高コストのデータベース問題はしばしば「すぐ効く改善」から始まります。どちらのデータベースでもトランザクショナルなCRUDアプリは動きますが、間違った習慣はデータとトラフィックが増えたときにどちらでも悪影響を及ぼします。
よくある罠はJSONをすべての近道として扱うことです。柔軟な「extras」フィールドは真に任意のデータにはOKですが、ステータス、タイムスタンプ、外部キーなどのコアフィールドは実カラムとして維持してください。そうしないとフィルタの遅さ、検証の困難さ、レポートが重要になったときの辛いリファクタが待っています。
インデックスにも罠があります:画面で見るすべてのフィルタにインデックスを追加することです。インデックスは読み取りを速くしますが、書き込みを遅くし、マイグレーションを重くします。実際にユーザーが使うものだけをインデックス化し、ロードで検証してください。
マイグレーションはテーブルをロックしてしまうと厄介です。大きな列を書き換える、デフォルト付きの NOT NULL を追加する、大きなインデックスを一気に作ると数分間書き込みをブロックすることがあります。リスクの高い変更は分割し、アプリが静かな時間に行ってください。
またORMのデフォルトにずっと頼らないでください。リストビューが1,000行から1,000万行に増えたら実行計画を読み、欠けているインデックスを見つけ、遅い結合を直す必要があります。
注意サイン:主要フィルタやソートに使われるJSONフィールド、測定せずに増え続けるインデックス数、デプロイで大きなテーブルを書き換えるマイグレーション、安定したソートがないページネーション(欠落や重複につながる)など。
決断する前のクイックチェックリスト
決める前に、もっとも忙しい画面とリリースプロセスに基づく現実チェックを行ってください。
- 主要画面はピーク時でも速いか?実際のフィルタ、ソート、ページネーションで最も遅いリストページをテストし、インデックスがそのクエリに合っていることを確認する。
- 大きなスキーマ変更を安全に出せるか?次に来そうな破壊的変更について拡張→バックフィル→収縮の計画を書き出す。
- JSONと列のルールはあるか?どのJSONキーを検索やソートできるようにするか、どれが本当に柔軟か明確にする。
- 特定のクエリ機能に依存していないか?アップサート動作、ウィンドウ関数、CTEの振る舞い、関数的・部分インデックスが必要かを確認する。
- ローンチ後に運用できるか?バックアップからの復元ができるか、スロークエリを測定できるか、レイテンシとロック待ちのベースラインを取っているかを証明する。
例:シンプルな注文管理から多忙な顧客ポータルへ
顧客ポータルを想像してください。最初は顧客がログインして注文を見て請求書をダウンロードしサポートチケットを開くという簡単なものです。最初の週はどんなトランザクショナルDBでも大抵問題ありません。ページは速く、スキーマも小さい。
数か月後、成長の瞬間が現れます。顧客は「過去30日以内に発送、カードで支払い、部分返金あり」のようなフィルタを求めます。サポートは週次レビュー用に素早いCSVエクスポートを望みます。財務は監査トレイルを求めます:誰がいつ請求書のステータスを何から何に変えたか。クエリパターンは元の画面より広範になります。
ここで判断は具体的な機能と実負荷での挙動についてになります。
柔軟なフィールド(配送指示、カスタム属性、チケットメタデータ)を追加するならJSONサポートは重要です。ただしチームがJSONパスをインデックス化し、形を検証し、JSONが大きくなっても性能を予測可能に保てるか正直に考えてください。
レポーティングは別の圧力点です。注文、請求書、支払い、チケットを大量のフィルタで結合する瞬間に複合インデックス、クエリプランニング、ダウンタイムなしでインデックスを進化させる容易さが重要になります。マイグレーションは「金曜にスクリプトを走らせる」作業ではなくなり、小さなスキーマ変更が数百万行に影響するため、すべてのリリースの一部になります。
実践的な進め方:6か月後に必要になりそうな5つの実画面とエクスポートを書き出し、早期に監査履歴テーブルを含め、実際に遅いクエリで現実的なデータサイズでベンチマークし(hello-worldではなく)、チームのJSON利用、インデックス、マイグレーションのルールを文書化してください。
最後に、手作業で全てを組み立てずに速く動きたいなら、AppMaster (appmaster.io) は視覚的モデルから本番対応のバックエンド、ウェブアプリ、ネイティブモバイルアプリを生成できます。画面やフィルタ、ビジネスプロセスを早期に実際のクエリワークロードとして扱うよう促してくれるので、インデックスやマイグレーションのリスクを本番前に発見しやすくなります。
よくある質問
まずは、最も使われるリスト画面、フィルタ、ソート、ピーク時の書き込み経路を紙に書き出してください。どちらもCRUDを動かせますが、安全なのは今後1年間でどうやってインデックスを作り、マイグレーションを行い、クエリを実行するかに合う方です。名前に慣れているかどうしでは決めないでください。
深いページに進むとリストページが遅くなるなら、OFFSETスキャンのコストを払っている可能性があります。保存が混雑時にたびたび遅くなるならロック競合や長いトランザクションが原因かもしれません。リリースにバックフィルや大きなインデックスが含まれるようになったら、マイグレーションが信頼性の問題になっています。
重要な画面ごとに、最初は1つの複合インデックスを基本にしてください。最も一貫したフィルタを先頭に置き、ソート列を最後に置きます。例えばマルチテナントの一覧なら通常 (tenant_id, status, created_at) のような順序がフィルタと並べ替えを効率的にします。
OFFSETページネーションは、先の行を踏み越えて進むためページが深くなるほど遅くなります。代わりにキ―セットページネーション(最後に見た created_at と id を使う)は、スクロールが進んでも性能が安定し、途中で新しい行が入っても重複や抜けが減ります。
インデックスは画面やAPIコールのために追加地点が特定できる場合のみ追加してください。重複や過剰なインデックスはすべての挿入や更新を遅くします。リリース後も必ず再確認して、不要なものは削除しましょう。
拡張 → バックフィル → 収縮のパターンを使ってください。互換性のある形で新しい列やテーブルを追加し、小さなバッチでバックフィルして検証(NOT NULL や外部キー、ユニーク制約など)を行い、それから古いパスを削除します。これにより大きなテーブルでも安全にリリースできます。
JSONは表示中心のペイロードやデバッグ保存、ユーザーやテナントごとの設定、小さな任意属性に向いています。フィルタやソート、レポートに頻繁に使うなら、JSONのキーは列や子テーブルに正規化してください。JSON内での制約は弱く、レポートは型付き列を好みます。
リトライが日常になる場合はアップサートが必須です(モバイル回線、バックグラウンドジョブ、タイムアウトなど)。PostgreSQLでは INSERT ... ON CONFLICT、MariaDBでは INSERT ... ON DUPLICATE KEY UPDATE を使います。いずれの場合も、リトライで重複が生じないようにユニークキーを慎重に定義してください。
トランザクションを短く保ち、トランザクション内でネットワーク呼び出しを避け、皆が更新する“ホット行”(共通のカウンタなど)を減らすことが重要です。競合が発生したらリトライするか、ユーザーに最新行を読み直して再適用してもらうなどの処理を明示的に行ってください。
読み取り負荷の高いページ(ダッシュボードやレポート)にレプリカを使うのは有効ですが、多少の遅延を許容できるかを見極めてください。直後の読み取りが重要な画面(たとえば注文直後の確認)はプライマリから読み、複製遅延を監視して表示が古くならないようにしましょう。


