<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>Database on AI2CORE - AI技術ブログ</title>
    <link>https://www.ai2core.com/tags/database/</link>
    <description>Recent content in Database on AI2CORE - AI技術ブログ</description>
    <generator>Hugo -- 0.146.4</generator>
    <language>ja</language>
    <lastBuildDate>Thu, 05 Mar 2026 09:16:00 +0900</lastBuildDate>
    <atom:link href="https://www.ai2core.com/tags/database/index.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>Kubernetes環境でDBスキーマ変更を止めずに進める：ゼロダウンタイム移行の実践戦略</title>
      <link>https://www.ai2core.com/posts/2026-03-05-kubernetes-zero-downtime-db-migration-strategy/</link>
      <pubDate>Thu, 05 Mar 2026 09:16:00 +0900</pubDate>
      <guid>https://www.ai2core.com/posts/2026-03-05-kubernetes-zero-downtime-db-migration-strategy/</guid>
      <description>本番KubernetesでDBマイグレーションを安全に実施するためのExpand-Contract戦略、手順、ロールバック設計を具体例つきで解説。</description>
      <content:encoded><![CDATA[<h1 id="kubernetes環境でdbスキーマ変更を止めずに進めるゼロダウンタイム移行の実践戦略">Kubernetes環境でDBスキーマ変更を止めずに進める：ゼロダウンタイム移行の実践戦略</h1>
<p>「カラムを追加するだけだから大丈夫」──この油断が、本番障害の入口になります。Kubernetes のように複数バージョンの Pod が同時に存在する環境では、DB スキーマ変更はアプリ変更よりも慎重に扱う必要があります。</p>
<p>本記事では、<strong>Expand-Contract パターン</strong>を中心に、ゼロダウンタイムを目指すための具体手順を解説します。実際の運用では、DDLの速さより「互換性のある期間をどう作るか」が勝負です。</p>
<h2 id="1-なぜkubernetesでdb移行が難しいのか">1. なぜKubernetesでDB移行が難しいのか</h2>
<p>Kubernetesでは、ローリングアップデート中に新旧Podが混在します。つまり次の状態が同時に発生します。</p>
<ul>
<li>新アプリは新スキーマを期待</li>
<li>旧アプリは旧スキーマしか知らない</li>
<li>DBは1つしかない</li>
</ul>
<p>このとき破綻するのが「破壊的変更を先に適用する」ケースです。たとえば旧カラムを即削除すると、旧Podがエラーを連発します。</p>
<h2 id="2-基本戦略expand--migrate--contract">2. 基本戦略：Expand → Migrate → Contract</h2>
<p>ゼロダウンタイム移行の原則はこの3段階です。</p>
<ol>
<li><strong>Expand</strong>: 互換性を壊さない変更を先に入れる（新カラム追加など）</li>
<li><strong>Migrate</strong>: アプリを段階的に切替え、データを移行する</li>
<li><strong>Contract</strong>: 旧仕様を最終削除する（十分な監視後）</li>
</ol>
<p>この順序なら、どの時点でも旧新どちらのアプリも動作可能にできます。</p>
<h2 id="3-具体例usersfull_name-を-first_name--last_name-へ分割">3. 具体例：<code>users.full_name</code> を <code>first_name</code> / <code>last_name</code> へ分割</h2>
<h3 id="31-expand-フェーズ">3.1 Expand フェーズ</h3>
<p>まず破壊的でないDDLを適用します。</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#66d9ef">ALTER</span> <span style="color:#66d9ef">TABLE</span> users <span style="color:#66d9ef">ADD</span> <span style="color:#66d9ef">COLUMN</span> first_name text;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">ALTER</span> <span style="color:#66d9ef">TABLE</span> users <span style="color:#66d9ef">ADD</span> <span style="color:#66d9ef">COLUMN</span> last_name text;
</span></span></code></pre></td></tr></table>
</div>
</div><p>この時点で旧アプリは <code>full_name</code> を使い続けられます。新アプリは新カラムに対応した実装を持っていても、まだ必須にしません。</p>
<h3 id="32-アプリを両対応にする">3.2 アプリを「両対応」にする</h3>
<p>書き込み時は両方へ保存（dual write）し、読み込み時は新カラム優先 + 旧カラムフォールバックにします。</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">11
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">12
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">13
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">14
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">15
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">16
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">17
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">18
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">save_user_name</span>(user_id: str, full_name: str):
</span></span><span style="display:flex;"><span>    first, last <span style="color:#f92672">=</span> split_name(full_name)
</span></span><span style="display:flex;"><span>    db<span style="color:#f92672">.</span>execute(
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#34;&#34;&#34;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">        UPDATE users
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">        SET full_name = </span><span style="color:#e6db74">%s</span><span style="color:#e6db74">,
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">            first_name = </span><span style="color:#e6db74">%s</span><span style="color:#e6db74">,
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">            last_name = </span><span style="color:#e6db74">%s</span><span style="color:#e6db74">
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">        WHERE id = </span><span style="color:#e6db74">%s</span><span style="color:#e6db74">
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">        &#34;&#34;&#34;</span>,
</span></span><span style="display:flex;"><span>        (full_name, first, last, user_id),
</span></span><span style="display:flex;"><span>    )
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">read_user_display_name</span>(row):
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> row<span style="color:#f92672">.</span>first_name <span style="color:#f92672">and</span> row<span style="color:#f92672">.</span>last_name:
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> <span style="color:#e6db74">f</span><span style="color:#e6db74">&#34;</span><span style="color:#e6db74">{</span>row<span style="color:#f92672">.</span>first_name<span style="color:#e6db74">}</span><span style="color:#e6db74"> </span><span style="color:#e6db74">{</span>row<span style="color:#f92672">.</span>last_name<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> row<span style="color:#f92672">.</span>full_name
</span></span></code></pre></td></tr></table>
</div>
</div><p>この両対応期間を作るのが、ゼロダウンタイムの本質です。</p>
<h3 id="33-バックフィル既存データ移行">3.3 バックフィル（既存データ移行）</h3>
<p>大規模テーブルでは一括更新を避け、チャンク更新します。</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">6
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">7
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#75715e">-- 疑似コードイメージ
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#66d9ef">UPDATE</span> users
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">SET</span> first_name <span style="color:#f92672">=</span> split_part(full_name, <span style="color:#e6db74">&#39; &#39;</span>, <span style="color:#ae81ff">1</span>),
</span></span><span style="display:flex;"><span>    last_name <span style="color:#f92672">=</span> split_part(full_name, <span style="color:#e6db74">&#39; &#39;</span>, <span style="color:#ae81ff">2</span>)
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">WHERE</span> id <span style="color:#f92672">&gt;</span> :last_id
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">AND</span> id <span style="color:#f92672">&lt;=</span> :last_id <span style="color:#f92672">+</span> <span style="color:#ae81ff">10000</span>
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">AND</span> (first_name <span style="color:#66d9ef">IS</span> <span style="color:#66d9ef">NULL</span> <span style="color:#66d9ef">OR</span> last_name <span style="color:#66d9ef">IS</span> <span style="color:#66d9ef">NULL</span>);
</span></span></code></pre></td></tr></table>
</div>
</div><p>ジョブ実装時のポイント:</p>
<ul>
<li>1チャンクごとに commit</li>
<li>再開可能なチェックポイント（last_id）を保存</li>
<li>実行時間帯を制御（ピーク時間回避）</li>
</ul>
<h3 id="34-contract-フェーズ">3.4 Contract フェーズ</h3>
<p>全Podが新実装になり、フォールバックが不要と判断できたら旧カラム削除へ進みます。</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#66d9ef">ALTER</span> <span style="color:#66d9ef">TABLE</span> users <span style="color:#66d9ef">DROP</span> <span style="color:#66d9ef">COLUMN</span> full_name;
</span></span></code></pre></td></tr></table>
</div>
</div><p>削除は必ず最後です。ここを急ぐとロールバック不能になります。</p>
<h2 id="4-マイグレーション実行方式の選び方">4. マイグレーション実行方式の選び方</h2>
<p>Kubernetesでは主に3パターンがあります。</p>
<h3 id="a-cicdで先行実行推奨">A. CI/CDで先行実行（推奨）</h3>
<p>デプロイ前に migration Job を走らせ、成功後にアプリを更新。</p>
<ul>
<li>メリット: 実行順序を固定しやすい</li>
<li>デメリット: 長時間 migration の扱いが難しい</li>
</ul>
<h3 id="b-initcontainer-実行">B. initContainer 実行</h3>
<p>Pod起動時に migration を走らせる方式。</p>
<ul>
<li>メリット: 実装が単純</li>
<li>デメリット: 複数Pod同時起動で競合しやすい</li>
</ul>
<h3 id="c-専用-migration-controller--job">C. 専用 Migration Controller / Job</h3>
<p>Argo Workflows などで明示的に管理。</p>
<ul>
<li>メリット: 大規模運用で監査しやすい</li>
<li>デメリット: 初期構築コストが高い</li>
</ul>
<p>中規模までなら A が最も事故が少ないです。</p>
<h2 id="5-alembicflyway運用の実務ポイント">5. Alembic/Flyway運用の実務ポイント</h2>
<h3 id="51-1-migration--1責務">5.1 1 migration = 1責務</h3>
<p>「追加 + データ移行 + 削除」を1ファイルに詰め込むと失敗時に戻しづらくなります。Expand/Migrate/Contract を別 migration に分けて、各段階を検証可能にしてください。</p>
<h3 id="52-lock-timeout-を設定する">5.2 lock timeout を設定する</h3>
<p>DDL はロック待ちでアプリを止めることがあります。PostgreSQL なら <code>lock_timeout</code> を設定して、危険な待機を避けます。</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#66d9ef">SET</span> lock_timeout <span style="color:#f92672">=</span> <span style="color:#e6db74">&#39;5s&#39;</span>;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">ALTER</span> <span style="color:#66d9ef">TABLE</span> ...;
</span></span></code></pre></td></tr></table>
</div>
</div><p>失敗時は即中断し、メンテナンス時間帯に再実行する判断が可能です。</p>
<h3 id="53-破壊的変更前に計測窓を設ける">5.3 破壊的変更前に計測窓を設ける</h3>
<p>旧カラム参照がゼロになったことを、メトリクスやログで一定期間確認してから削除します。目安として 7〜14日程度の観測期間を取ると安全です。</p>
<h2 id="6-ロールバック戦略">6. ロールバック戦略</h2>
<p>ゼロダウンタイム設計では、ロールバック可能性を最初に決めます。</p>
<ul>
<li>Expand段階: ほぼ即ロールバック可能</li>
<li>Migrate段階: dual write を維持していればロールバック可能</li>
<li>Contract段階: 削除後は復元コスト高（バックアップ依存）</li>
</ul>
<p>したがって、Contract 実施前に以下を満たす必要があります。</p>
<ul>
<li>直近スナップショット取得済み</li>
<li>復元手順を演習済み</li>
<li>影響範囲（API・バッチ・BI）を棚卸し済み</li>
</ul>
<h2 id="7-監視項目と成功判定">7. 監視項目と成功判定</h2>
<p>移行中は「成功したか」より「安全に進んでいるか」を見ます。</p>
<ul>
<li>APIエラー率（4xx/5xx）</li>
<li>DBロック待ち時間</li>
<li>スロークエリ件数</li>
<li>migration ジョブ進捗（処理済み件数、残件数）</li>
<li>旧カラム参照回数</li>
</ul>
<p>成功判定の例:</p>
<ol>
<li>新旧Pod混在中のエラー率に有意な悪化なし</li>
<li>バックフィル完了率100%</li>
<li>旧カラム参照0が連続7日</li>
<li>Contract後24時間で異常なし</li>
</ol>
<h2 id="8-よくある失敗と回避策">8. よくある失敗と回避策</h2>
<h3 id="失敗1-非null制約を早く付けすぎる">失敗1: 非NULL制約を早く付けすぎる</h3>
<p>バックフィル完了前に <code>NOT NULL</code> を付けると、旧データで失敗します。まずは nullable で追加し、データ移行後に制約追加が正解です。</p>
<h3 id="失敗2-インデックス作成で書き込み停止">失敗2: インデックス作成で書き込み停止</h3>
<p>大きいテーブルで通常インデックス作成を行うとロックが重くなります。PostgreSQL では <code>CREATE INDEX CONCURRENTLY</code> を使って影響を下げます。</p>
<h3 id="失敗3-migration-の実行主体が複数">失敗3: migration の実行主体が複数</h3>
<p>同時に2つのJobが走ると競合します。Kubernetes Job は排他制御（Lease/Lock）を持たせるか、CI側で単一実行を保証してください。</p>
<h2 id="9-実運用テンプレートmigration-job-と段階リリース">9. 実運用テンプレート：Migration Job と段階リリース</h2>
<p>最後に、現場でそのまま使える最小テンプレートを示します。ポイントは「DDLを一気にやらない」「アプリ側フラグで読取切替を制御する」の2点です。</p>
<h3 id="91-migration-jobexpand専用">9.1 Migration Job（Expand専用）</h3>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">11
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">12
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">13
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">14
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">15
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">16
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">apiVersion</span>: <span style="color:#ae81ff">batch/v1</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">kind</span>: <span style="color:#ae81ff">Job</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">metadata</span>:
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">name</span>: <span style="color:#ae81ff">users-expand-20260305</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">spec</span>:
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">backoffLimit</span>: <span style="color:#ae81ff">0</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">template</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">spec</span>:
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">restartPolicy</span>: <span style="color:#ae81ff">Never</span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">containers</span>:
</span></span><span style="display:flex;"><span>        - <span style="color:#f92672">name</span>: <span style="color:#ae81ff">migrate</span>
</span></span><span style="display:flex;"><span>          <span style="color:#f92672">image</span>: <span style="color:#ae81ff">ghcr.io/example/api:1.42.0</span>
</span></span><span style="display:flex;"><span>          <span style="color:#f92672">command</span>: [<span style="color:#e6db74">&#34;bash&#34;</span>, <span style="color:#e6db74">&#34;-lc&#34;</span>, <span style="color:#e6db74">&#34;alembic upgrade 20260305_expand_users_name&#34;</span>]
</span></span><span style="display:flex;"><span>          <span style="color:#f92672">envFrom</span>:
</span></span><span style="display:flex;"><span>            - <span style="color:#f92672">secretRef</span>:
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">name</span>: <span style="color:#ae81ff">api-db-secret</span>
</span></span></code></pre></td></tr></table>
</div>
</div><p>Expand 用 Job を分離しておけば、失敗時にアプリデプロイを止める判断が明確になります。<code>backoffLimit: 0</code> にして「失敗を隠さない」運用にするのも実務で有効です。</p>
<h3 id="92-段階リリース手順例">9.2 段階リリース手順（例）</h3>
<ol>
<li>Expand Job 実行（DDLのみ）</li>
<li>アプリ v1（dual write + fallback read）を10%配信</li>
<li>エラー率・ロック待ちを30分監視</li>
<li>50% → 100%へ段階拡大</li>
<li>バックフィル Job 実行</li>
<li>旧参照ゼロ確認後に Contract 実施</li>
</ol>
<p>Kubernetes では <code>kubectl rollout status deployment/api -n prod</code> を必ず監視に組み込み、配信完了判定を人間が明示的に確認する運用が安全です。</p>
<h2 id="まとめ">まとめ</h2>
<p>KubernetesでのDBマイグレーションは、DDLテクニックだけでは成功しません。重要なのは、</p>
<ul>
<li>互換性期間を意図的に作る</li>
<li>段階を分けて進める</li>
<li>ロールバック可能性を先に設計する</li>
<li>監視で「削除してよい」根拠を取る</li>
</ul>
<p>この4点です。Expand-Contract を守るだけで、移行の失敗率は目に見えて下がります。スキーマ変更は怖い作業ですが、手順化すれば再現可能な運用にできます。次回の変更から、ぜひこの流れで試してみてください。</p>
]]></content:encoded>
      <category>Tech</category>
      <category>Kubernetes</category>
      <category>Database</category>
      <category>Migration</category>
      <category>SRE</category>
      <category>DevOps</category>
    </item>
    <item>
      <title>PostgreSQL肥大化対策の実務：VACUUM/Autovacuum/Index再編成を止めずに回す運用プレイブック</title>
      <link>https://www.ai2core.com/posts/2026-03-04-postgresql-vacuum-bloat-control-playbook/</link>
      <pubDate>Wed, 04 Mar 2026 09:20:00 +0900</pubDate>
      <guid>https://www.ai2core.com/posts/2026-03-04-postgresql-vacuum-bloat-control-playbook/</guid>
      <description>PostgreSQLのテーブル・インデックス肥大化を本番停止なしで抑えるために、Autovacuum設計、監視指標、再編成手順、障害時対応を具体例つきで整理。</description>
      <content:encoded><![CDATA[<h1 id="postgresql肥大化対策の実務vacuumautovacuumindex再編成を止めずに回す運用プレイブック">PostgreSQL肥大化対策の実務：VACUUM/Autovacuum/Index再編成を止めずに回す運用プレイブック</h1>
<p>PostgreSQL を長期運用すると、遅かれ早かれぶつかるのが bloat（テーブル/インデックス肥大化）です。CPU やメモリを増やしても、実体は不要領域の蓄積なので、根本原因を処理しない限り性能は戻りません。</p>
<p>本記事では、<strong>サービス停止なしで bloat を抑える運用</strong>を目標に、Autovacuum 設計、監視、メンテ手順を実践ベースで解説します。</p>
<h2 id="1-なぜ肥大化が起きるのか">1. なぜ肥大化が起きるのか</h2>
<p>PostgreSQL は MVCC を採用しているため、UPDATE/DELETE で古い行バージョンが即時削除されません。不要バージョンは VACUUM で回収されますが、追いつかないと肥大化します。</p>
<p>肥大化が進むと以下が起こります。</p>
<ul>
<li>同じデータ量でも I/O が増える</li>
<li>インデックス探索が遅くなる</li>
<li>キャッシュ効率が落ち、p95 レイテンシが悪化</li>
<li>自動メンテの時間がさらに伸びる（悪循環）</li>
</ul>
<p>重要なのは、<strong>「遅くなってから対処」だと回復コストが高い</strong>という点です。</p>
<h2 id="2-最初に見るべき指標">2. 最初に見るべき指標</h2>
<p>運用でまず可視化するのは次の4つです。</p>
<ol>
<li><code>n_dead_tup</code>（死んだタプル数）</li>
<li><code>last_autovacuum</code>（最後に vacuum が走った時刻）</li>
<li>テーブルサイズ・インデックスサイズ推移</li>
<li><code>age(relfrozenxid)</code>（XID 消費進行）</li>
</ol>
<p>確認クエリ例:</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#66d9ef">SELECT</span>
</span></span><span style="display:flex;"><span>  schemaname,
</span></span><span style="display:flex;"><span>  relname,
</span></span><span style="display:flex;"><span>  n_live_tup,
</span></span><span style="display:flex;"><span>  n_dead_tup,
</span></span><span style="display:flex;"><span>  last_autovacuum,
</span></span><span style="display:flex;"><span>  last_vacuum
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">FROM</span> pg_stat_user_tables
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">ORDER</span> <span style="color:#66d9ef">BY</span> n_dead_tup <span style="color:#66d9ef">DESC</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">LIMIT</span> <span style="color:#ae81ff">20</span>;
</span></span></code></pre></td></tr></table>
</div>
</div><p>XID の健全性チェック:</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#66d9ef">SELECT</span>
</span></span><span style="display:flex;"><span>  datname,
</span></span><span style="display:flex;"><span>  age(datfrozenxid) <span style="color:#66d9ef">AS</span> xid_age
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">FROM</span> pg_database
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">ORDER</span> <span style="color:#66d9ef">BY</span> xid_age <span style="color:#66d9ef">DESC</span>;
</span></span></code></pre></td></tr></table>
</div>
</div><p><code>xid_age</code> が高いのに vacuum が遅れている場合は、緊急度が高いです。</p>
<h2 id="3-autovacuum-の基本パラメータ設計">3. Autovacuum の基本パラメータ設計</h2>
<p>デフォルト設定は小規模環境向けで、更新量が多い本番には不足しやすいです。まずは「全体設定 + ホットテーブル個別設定」に分けて調整します。</p>
<p>代表的パラメータ:</p>
<ul>
<li><code>autovacuum_max_workers</code></li>
<li><code>autovacuum_naptime</code></li>
<li><code>autovacuum_vacuum_cost_limit</code></li>
<li><code>autovacuum_vacuum_scale_factor</code></li>
<li><code>autovacuum_vacuum_threshold</code></li>
</ul>
<p>考え方:</p>
<ul>
<li>更新頻度が高いテーブルは <code>scale_factor</code> を下げる（例: 0.2 → 0.02）</li>
<li>小さなテーブルは threshold 主体、大きなテーブルは scale factor 主体</li>
<li>まず vacuum が「間に合う」状態を作る</li>
</ul>
<p>個別テーブル例:</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">6
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#66d9ef">ALTER</span> <span style="color:#66d9ef">TABLE</span> events
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">SET</span> (
</span></span><span style="display:flex;"><span>    autovacuum_vacuum_scale_factor <span style="color:#f92672">=</span> <span style="color:#ae81ff">0</span>.<span style="color:#ae81ff">01</span>,
</span></span><span style="display:flex;"><span>    autovacuum_vacuum_threshold <span style="color:#f92672">=</span> <span style="color:#ae81ff">5000</span>,
</span></span><span style="display:flex;"><span>    autovacuum_analyze_scale_factor <span style="color:#f92672">=</span> <span style="color:#ae81ff">0</span>.<span style="color:#ae81ff">02</span>
</span></span><span style="display:flex;"><span>  );
</span></span></code></pre></td></tr></table>
</div>
</div><h2 id="4-インデックス肥大化の見落としに注意">4. インデックス肥大化の見落としに注意</h2>
<p>テーブル側だけ見ていて、実際のボトルネックがインデックス側というケースは非常に多いです。特に更新頻度の高い B-Tree インデックスで顕著です。</p>
<p>実務では次を定期確認します。</p>
<ul>
<li>使用頻度が低い巨大インデックス</li>
<li>重複インデックス</li>
<li>インデックスサイズ増加率（週次）</li>
</ul>
<p>重複候補を探す SQL（簡易）:</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">6
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#66d9ef">SELECT</span>
</span></span><span style="display:flex;"><span>  indexrelid::regclass <span style="color:#66d9ef">AS</span> index_name,
</span></span><span style="display:flex;"><span>  indrelid::regclass <span style="color:#66d9ef">AS</span> <span style="color:#66d9ef">table_name</span>,
</span></span><span style="display:flex;"><span>  pg_get_indexdef(indexrelid)
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">FROM</span> pg_index
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">WHERE</span> indisvalid <span style="color:#f92672">=</span> <span style="color:#66d9ef">true</span>;
</span></span></code></pre></td></tr></table>
</div>
</div><p>実際は <code>pg_stat_user_indexes</code> と組み合わせ、<code>idx_scan</code> がほぼゼロのものを優先削減します。</p>
<h2 id="5-reindex-と-pg_repack-の使い分け">5. REINDEX と pg_repack の使い分け</h2>
<p>肥大化したインデックスを戻すには <code>REINDEX</code> が基本ですが、ロック影響を避けたい場合は <code>REINDEX CONCURRENTLY</code> を選びます。</p>
<ul>
<li>影響小で安全重視: <code>REINDEX INDEX CONCURRENTLY</code></li>
<li>まとめて再編成: <code>pg_repack</code>（導入・権限管理が必要）</li>
</ul>
<p>例:</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#66d9ef">REINDEX</span> <span style="color:#66d9ef">INDEX</span> CONCURRENTLY idx_orders_created_at;
</span></span></code></pre></td></tr></table>
</div>
</div><p>注意点:</p>
<ul>
<li>ディスク空き容量を事前確認（再構築時に追加領域が必要）</li>
<li>長時間トランザクションがあると完了しない</li>
<li>実行ウィンドウを決め、監視を付ける</li>
</ul>
<h2 id="6-vacuum-が進まない時の切り分け">6. vacuum が進まない時の切り分け</h2>
<p>「Autovacuum が動いているのに改善しない」時は、次の順で確認します。</p>
<ol>
<li>長時間トランザクションが残っていないか</li>
<li>レプリカ遅延や hot_standby_feedback で cleanup が妨げられていないか</li>
<li>I/O 飽和で vacuum が極端に遅くなっていないか</li>
<li>freeze 対象の backlog が巨大化していないか</li>
</ol>
<p>長時間トランザクション確認:</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#66d9ef">SELECT</span> pid, usename, <span style="color:#66d9ef">state</span>, xact_start, now() <span style="color:#f92672">-</span> xact_start <span style="color:#66d9ef">AS</span> tx_age, query
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">FROM</span> pg_stat_activity
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">WHERE</span> xact_start <span style="color:#66d9ef">IS</span> <span style="color:#66d9ef">NOT</span> <span style="color:#66d9ef">NULL</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">ORDER</span> <span style="color:#66d9ef">BY</span> xact_start <span style="color:#66d9ef">ASC</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">LIMIT</span> <span style="color:#ae81ff">20</span>;
</span></span></code></pre></td></tr></table>
</div>
</div><p><code>tx_age</code> が長い接続は、vacuum の前進を阻害する最優先要因です。</p>
<h2 id="7-実運用で効くスケジュール設計">7. 実運用で効くスケジュール設計</h2>
<p>本番では「毎晩まとめて重い処理」より、<strong>小さく高頻度に回す</strong>方が安定します。</p>
<ul>
<li>日中: autovacuum でこまめに回収</li>
<li>深夜: 重いテーブルの <code>VACUUM (ANALYZE)</code> を計画実行</li>
<li>週次: 重要インデックスの肥大化確認</li>
<li>月次: 上位肥大化テーブルの再編成計画レビュー</li>
</ul>
<p>ANALYZE を外すとプランが古くなるため、統計更新を一体運用にします。</p>
<h2 id="8-典型インシデントと復旧手順">8. 典型インシデントと復旧手順</h2>
<h3 id="ケースa-api-レイテンシ急上昇">ケースA: API レイテンシ急上昇</h3>
<p>兆候:</p>
<ul>
<li>CPU は高くないがクエリ時間が増加</li>
<li>特定テーブルの <code>n_dead_tup</code> が急増</li>
</ul>
<p>対処:</p>
<ol>
<li>長時間トランザクションを特定</li>
<li>対象テーブルに <code>VACUUM (VERBOSE, ANALYZE)</code></li>
<li>重度ならインデックス再構築を計画</li>
</ol>
<h3 id="ケースb-ストレージ逼迫">ケースB: ストレージ逼迫</h3>
<p>兆候:</p>
<ul>
<li>disk 使用率が短期間で増加</li>
<li>UPDATE 多発テーブルが存在</li>
</ul>
<p>対処:</p>
<ol>
<li>サイズ上位テーブル・インデックスを抽出</li>
<li>不要インデックス削除</li>
<li><code>REINDEX CONCURRENTLY</code> / <code>pg_repack</code> を段階実行</li>
</ol>
<h3 id="ケースc-wraparound-警告">ケースC: wraparound 警告</h3>
<p>兆候:</p>
<ul>
<li><code>autovacuum: preventing wraparound</code> ログ</li>
</ul>
<p>対処:</p>
<ol>
<li>緊急度を最優先に切替</li>
<li>長時間 TX を停止</li>
<li>freeze 対象テーブルを優先 vacuum</li>
</ol>
<h2 id="9-導入時チェックリスト">9. 導入時チェックリスト</h2>
<ul>
<li><input disabled="" type="checkbox"> 上位更新テーブルに個別 autovacuum パラメータがある</li>
<li><input disabled="" type="checkbox"> <code>n_dead_tup</code> と <code>last_autovacuum</code> を監視している</li>
<li><input disabled="" type="checkbox"> 長時間トランザクションのアラートがある</li>
<li><input disabled="" type="checkbox"> インデックス使用率 (<code>idx_scan</code>) を定期レビューしている</li>
<li><input disabled="" type="checkbox"> REINDEX 実行時の空き容量基準を定義している</li>
<li><input disabled="" type="checkbox"> wraparound 対応 runbook がある</li>
</ul>
<h2 id="10-30日改善プラン最短で効果を出す">10. 30日改善プラン（最短で効果を出す）</h2>
<h3 id="week-1">Week 1</h3>
<ul>
<li>現状計測（dead tuple、サイズ、xid age）</li>
<li>ホットテーブル上位10件を特定</li>
</ul>
<h3 id="week-2">Week 2</h3>
<ul>
<li>テーブルごとに autovacuum 個別設定</li>
<li>長時間 TX 監視アラート導入</li>
</ul>
<h3 id="week-3">Week 3</h3>
<ul>
<li>低利用/重複インデックス整理</li>
<li>対象インデックスを <code>REINDEX CONCURRENTLY</code></li>
</ul>
<h3 id="week-4">Week 4</h3>
<ul>
<li>実行後の p95 クエリ時間、ストレージ増加率を比較</li>
<li>設定の再チューニングと runbook 更新</li>
</ul>
<p>PostgreSQL の肥大化対策は、一発のメンテで終わる作業ではありません。<strong>観測 → 個別設定 → 段階的再編成 → 監視改善</strong>を繰り返すことで、停止なしでも安定して性能を維持できます。</p>
]]></content:encoded>
      <category>Tech</category>
      <category>PostgreSQL</category>
      <category>Database</category>
      <category>Performance</category>
      <category>SRE</category>
      <category>運用</category>
    </item>
    <item>
      <title>PostgreSQLデッドロック調査プレイブック：再現・可視化・恒久対策までの実践手順</title>
      <link>https://www.ai2core.com/posts/2026-03-02-postgresql-deadlock-troubleshooting-playbook/</link>
      <pubDate>Mon, 02 Mar 2026 09:12:00 +0900</pubDate>
      <guid>https://www.ai2core.com/posts/2026-03-02-postgresql-deadlock-troubleshooting-playbook/</guid>
      <description>本番で発生するPostgreSQLデッドロックの調査と対処を、再現SQL・ログ設定・アプリ修正パターンまで具体例付きで解説。</description>
      <content:encoded><![CDATA[<h1 id="postgresqlデッドロック調査プレイブック再現可視化恒久対策までの実践手順">PostgreSQLデッドロック調査プレイブック：再現・可視化・恒久対策までの実践手順</h1>
<p>本番運用で厄介なのは、エラーが「たまに」しか出ない障害です。PostgreSQL のデッドロックはその代表で、発生頻度は低くてもビジネス影響が大きいことが多いです。決済や在庫更新で発生すると、リトライが雪だるま式に増え、アプリ全体の遅延を引き起こします。</p>
<p>本記事では、デッドロック発生時に現場でそのまま使える手順を、<strong>初動対応・再現・恒久対策</strong>の順で整理します。</p>
<h2 id="1-まず理解すべき前提">1. まず理解すべき前提</h2>
<p>デッドロックは「どちらかが悪い」ではなく、<strong>ロック順序が循環したときに必ず起きる現象</strong>です。PostgreSQL は循環を検出すると、どちらか一方のトランザクションを強制中断します。</p>
<p>典型的な症状:</p>
<ul>
<li><code>ERROR: deadlock detected</code></li>
<li>API の一部がランダムに 500 を返す</li>
<li>リトライ実装により DB 負荷が上振れ</li>
</ul>
<p>ここで重要なのは、単純なタイムアウトと混同しないことです。タイムアウトは待ち時間超過、デッドロックは循環待ちです。対策が違います。</p>
<h2 id="2-初動でやること515分">2. 初動でやること（5〜15分）</h2>
<h3 id="2-1-エラーログの採取">2-1. エラーログの採取</h3>
<p>まず、DB 側ログに詳細を出す設定があるか確認します。</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#66d9ef">SHOW</span> log_lock_waits;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">SHOW</span> deadlock_timeout;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">SHOW</span> log_min_error_statement;
</span></span></code></pre></td></tr></table>
</div>
</div><p>推奨設定（本番）:</p>
<pre tabindex="0"><code class="language-conf" data-lang="conf">log_lock_waits = on
deadlock_timeout = &#39;1s&#39;
log_min_error_statement = error
</code></pre><p><code>deadlock_timeout</code> を短めにすることで、待ちが長引いたケースの追跡がしやすくなります。</p>
<h3 id="2-2-現在のロック状況を確認">2-2. 現在のロック状況を確認</h3>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">11
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">12
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">13
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">14
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#66d9ef">SELECT</span>
</span></span><span style="display:flex;"><span>  a.pid,
</span></span><span style="display:flex;"><span>  a.usename,
</span></span><span style="display:flex;"><span>  a.application_name,
</span></span><span style="display:flex;"><span>  a.<span style="color:#66d9ef">state</span>,
</span></span><span style="display:flex;"><span>  a.query,
</span></span><span style="display:flex;"><span>  l.locktype,
</span></span><span style="display:flex;"><span>  l.<span style="color:#66d9ef">mode</span>,
</span></span><span style="display:flex;"><span>  l.<span style="color:#66d9ef">granted</span>,
</span></span><span style="display:flex;"><span>  a.query_start
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">FROM</span> pg_stat_activity a
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">JOIN</span> pg_locks l <span style="color:#66d9ef">ON</span> a.pid <span style="color:#f92672">=</span> l.pid
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">WHERE</span> a.datname <span style="color:#f92672">=</span> current_database()
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">ORDER</span> <span style="color:#66d9ef">BY</span> a.query_start;
</span></span></code></pre></td></tr></table>
</div>
</div><p>見るべき点は「長く生きているトランザクション」と「<code>granted = false</code> が連鎖している箇所」です。</p>
<h2 id="3-再現手順を作る原因特定の最短ルート">3. 再現手順を作る（原因特定の最短ルート）</h2>
<p>デッドロックは再現しないと直せません。以下のような単純ケースをまず作ります。</p>
<h3 id="セッション-a">セッション A</h3>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#66d9ef">BEGIN</span>;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">UPDATE</span> accounts <span style="color:#66d9ef">SET</span> balance <span style="color:#f92672">=</span> balance <span style="color:#f92672">-</span> <span style="color:#ae81ff">100</span> <span style="color:#66d9ef">WHERE</span> id <span style="color:#f92672">=</span> <span style="color:#ae81ff">1</span>;
</span></span><span style="display:flex;"><span><span style="color:#75715e">-- ここで待機
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#66d9ef">UPDATE</span> accounts <span style="color:#66d9ef">SET</span> balance <span style="color:#f92672">=</span> balance <span style="color:#f92672">+</span> <span style="color:#ae81ff">100</span> <span style="color:#66d9ef">WHERE</span> id <span style="color:#f92672">=</span> <span style="color:#ae81ff">2</span>;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">COMMIT</span>;
</span></span></code></pre></td></tr></table>
</div>
</div><h3 id="セッション-b">セッション B</h3>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#66d9ef">BEGIN</span>;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">UPDATE</span> accounts <span style="color:#66d9ef">SET</span> balance <span style="color:#f92672">=</span> balance <span style="color:#f92672">-</span> <span style="color:#ae81ff">50</span> <span style="color:#66d9ef">WHERE</span> id <span style="color:#f92672">=</span> <span style="color:#ae81ff">2</span>;
</span></span><span style="display:flex;"><span><span style="color:#75715e">-- ここで待機
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#66d9ef">UPDATE</span> accounts <span style="color:#66d9ef">SET</span> balance <span style="color:#f92672">=</span> balance <span style="color:#f92672">+</span> <span style="color:#ae81ff">50</span> <span style="color:#66d9ef">WHERE</span> id <span style="color:#f92672">=</span> <span style="color:#ae81ff">1</span>;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">COMMIT</span>;
</span></span></code></pre></td></tr></table>
</div>
</div><p>更新順序が逆なので、容易に循環が起きます。</p>
<p>この再現が取れたら、アプリコード上でも「同じテーブルを複数行更新する順序」が一定かどうかを調べます。</p>
<h2 id="4-原因の8割は更新順序の不一致">4. 原因の8割は「更新順序の不一致」</h2>
<p>多くのプロダクトで見つかるのは次のパターンです。</p>
<ol>
<li>バッチ処理は <code>id ASC</code> で更新</li>
<li>API リクエストは受信順で更新</li>
<li>並行処理時に順序が逆転</li>
</ol>
<p>この場合、解決策は明確で、<strong>全経路でロック取得順序を統一</strong>します。</p>
<p>例（Node.js / TypeScript）:</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-ts" data-lang="ts"><span style="display:flex;"><span><span style="color:#66d9ef">const</span> <span style="color:#a6e22e">ids</span> <span style="color:#f92672">=</span> [<span style="color:#a6e22e">fromAccountId</span>, <span style="color:#a6e22e">toAccountId</span>].<span style="color:#a6e22e">sort</span>((<span style="color:#a6e22e">a</span>, <span style="color:#a6e22e">b</span>) <span style="color:#f92672">=&gt;</span> <span style="color:#a6e22e">a</span> <span style="color:#f92672">-</span> <span style="color:#a6e22e">b</span>);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">await</span> <span style="color:#a6e22e">tx</span>.<span style="color:#a6e22e">query</span>(<span style="color:#e6db74">&#39;SELECT id FROM accounts WHERE id = ANY($1) ORDER BY id FOR UPDATE&#39;</span>, [<span style="color:#a6e22e">ids</span>]);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// その後に更新
</span></span></span></code></pre></td></tr></table>
</div>
</div><p><code>FOR UPDATE</code> を先に順序付きで取得することで、アプリ層の揺らぎを DB で吸収できます。</p>
<h2 id="5-実装レベルの対策パターン">5. 実装レベルの対策パターン</h2>
<h3 id="5-1-トランザクションを短くする">5-1. トランザクションを短くする</h3>
<p>デッドロックは「ロック保持時間」が長いほど発生しやすくなります。トランザクション内で外部 API 呼び出しをしていないか確認してください。これは最優先で排除します。</p>
<h3 id="5-2-失敗時のリトライを制御する">5-2. 失敗時のリトライを制御する</h3>
<p>無制限リトライは障害増幅器です。指数バックオフ + 上限回数で制御します。</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">6
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">7
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">8
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-ts" data-lang="ts"><span style="display:flex;"><span><span style="color:#66d9ef">for</span> (<span style="color:#66d9ef">let</span> <span style="color:#a6e22e">attempt</span> <span style="color:#f92672">=</span> <span style="color:#ae81ff">1</span>; <span style="color:#a6e22e">attempt</span> <span style="color:#f92672">&lt;=</span> <span style="color:#ae81ff">3</span>; <span style="color:#a6e22e">attempt</span><span style="color:#f92672">++</span>) {
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">try</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> <span style="color:#66d9ef">await</span> <span style="color:#a6e22e">runTx</span>();
</span></span><span style="display:flex;"><span>  } <span style="color:#66d9ef">catch</span> (<span style="color:#a6e22e">e</span>: <span style="color:#66d9ef">any</span>) {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> (<span style="color:#f92672">!</span>String(<span style="color:#a6e22e">e</span>.<span style="color:#a6e22e">message</span>).<span style="color:#a6e22e">includes</span>(<span style="color:#e6db74">&#39;deadlock detected&#39;</span>) <span style="color:#f92672">||</span> <span style="color:#a6e22e">attempt</span> <span style="color:#f92672">===</span> <span style="color:#ae81ff">3</span>) <span style="color:#66d9ef">throw</span> <span style="color:#a6e22e">e</span>;
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">await</span> <span style="color:#a6e22e">sleep</span>(<span style="color:#ae81ff">50</span> <span style="color:#f92672">*</span> <span style="color:#ae81ff">2</span> <span style="color:#f92672">**</span> <span style="color:#a6e22e">attempt</span>);
</span></span><span style="display:flex;"><span>  }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></td></tr></table>
</div>
</div><h3 id="5-3-楽観ロックの導入">5-3. 楽観ロックの導入</h3>
<p>更新競合が多い領域では <code>version</code> カラムを使った楽観ロックが有効です。衝突時にアプリで再計算できます。</p>
<h2 id="6-監視運用面の改善">6. 監視・運用面の改善</h2>
<p>恒久対策を完了させるには、再発を検知できる状態を作る必要があります。</p>
<p>推奨メトリクス:</p>
<ul>
<li>deadlock 発生回数 / 分</li>
<li>lock wait 時間 p95</li>
<li>失敗リトライ回数</li>
<li>長時間トランザクション件数</li>
</ul>
<p>SQL 例（長時間 tx 検知）:</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#66d9ef">SELECT</span> pid, now() <span style="color:#f92672">-</span> xact_start <span style="color:#66d9ef">AS</span> tx_age, query
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">FROM</span> pg_stat_activity
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">WHERE</span> xact_start <span style="color:#66d9ef">IS</span> <span style="color:#66d9ef">NOT</span> <span style="color:#66d9ef">NULL</span>
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">AND</span> now() <span style="color:#f92672">-</span> xact_start <span style="color:#f92672">&gt;</span> interval <span style="color:#e6db74">&#39;30 seconds&#39;</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">ORDER</span> <span style="color:#66d9ef">BY</span> tx_age <span style="color:#66d9ef">DESC</span>;
</span></span></code></pre></td></tr></table>
</div>
</div><p>この結果を可視化し、閾値超過で通知するだけでも再発時の初動が速くなります。</p>
<h2 id="7-障害対応時の意思決定フレーム">7. 障害対応時の意思決定フレーム</h2>
<p>デッドロックが継続しているとき、現場は次の順序で判断すると迷いません。</p>
<ol>
<li>影響範囲（どの API / どの機能か）を確定</li>
<li>失敗処理を一時停止できるか判断（バッチ停止、機能フラグ）</li>
<li>ロック順序統一のホットフィックス可否</li>
<li>デプロイまでの間、リトライ制御で被害抑制</li>
</ol>
<p>「根本修正が間に合わない」ケースでも、リトライと負荷制御でユーザー影響を減らせます。</p>
<h2 id="8-よくあるアンチパターン">8. よくあるアンチパターン</h2>
<h3 id="アンチパターン1-serializable-に上げて解決した気になる">アンチパターン1: <code>SERIALIZABLE</code> に上げて解決した気になる</h3>
<p>隔離レベルを上げるだけでは、設計上の競合は消えません。むしろリトライ増加で負荷が悪化することがあります。</p>
<h3 id="アンチパターン2-select--for-update-を乱用する">アンチパターン2: <code>SELECT ... FOR UPDATE</code> を乱用する</h3>
<p>広範囲ロックは別の待ちを生みます。最小対象だけをロックし、順序を統一することが本質です。</p>
<h3 id="アンチパターン3-アプリログだけ見て-db-ログを見ない">アンチパターン3: アプリログだけ見て DB ログを見ない</h3>
<p>デッドロックの循環情報は DB 側ログにしか出ないことが多いです。必ず DB ログを一次情報として扱ってください。</p>
<h2 id="9-すぐ使えるチェックリスト">9. すぐ使えるチェックリスト</h2>
<ul>
<li><input disabled="" type="checkbox"> <code>deadlock detected</code> の実例ログを保存した</li>
<li><input disabled="" type="checkbox"> ロック状況を <code>pg_stat_activity</code> と <code>pg_locks</code> で取得した</li>
<li><input disabled="" type="checkbox"> 再現 SQL を作成し、原因パターンを確認した</li>
<li><input disabled="" type="checkbox"> 更新順序を全経路で統一した</li>
<li><input disabled="" type="checkbox"> リトライ回数とバックオフを制限した</li>
<li><input disabled="" type="checkbox"> 監視メトリクスを追加した</li>
</ul>
<h2 id="まとめ">まとめ</h2>
<p>PostgreSQL デッドロック対策は、魔法の設定値を探す作業ではありません。ポイントは一貫しており、</p>
<ul>
<li>ログで事実を取る</li>
<li>再現を作る</li>
<li>ロック順序を統一する</li>
</ul>
<p>この 3 ステップで大半の問題は改善します。障害対応時は焦って設定を増やすより、まず「どの順序で誰がロックを取ったか」を可視化することが最短ルートです。</p>
<h2 id="10-チーム運用に落とし込むためのルール化">10. チーム運用に落とし込むためのルール化</h2>
<p>技術対策ができても、運用ルールがないと再発します。特に効果が高いのは「PR テンプレートにロック観点を入れる」ことです。たとえば <code>複数行更新の順序は統一されているか</code>、<code>トランザクション内で外部I/Oをしていないか</code> をチェック項目にするだけで、設計時点で多くの問題を防げます。</p>
<p>さらに、負荷試験シナリオに競合ケース（同時更新）を追加してください。通常の性能試験は平均応答時間を見るだけで終わりがちですが、デッドロックは並行競合を作らないと検出できません。QA と開発が協調して「再現しにくい障害を再現するテスト」を用意できると、運用品質が一段上がります。</p>
]]></content:encoded>
      <category>Tech</category>
      <category>PostgreSQL</category>
      <category>Database</category>
      <category>Troubleshooting</category>
      <category>Backend</category>
    </item>
    <item>
      <title>PostgreSQLインデックス最適化の現場手順：遅いクエリを再現・診断・改善する実践プレイブック</title>
      <link>https://www.ai2core.com/posts/2026-02-27-postgresql-indexing-production-playbook/</link>
      <pubDate>Fri, 27 Feb 2026 13:00:00 +0900</pubDate>
      <guid>https://www.ai2core.com/posts/2026-02-27-postgresql-indexing-production-playbook/</guid>
      <description>EXPLAIN ANALYZEの読み方から複合/部分/式インデックスの使い分け、リリース手順までを実例ベースで解説。</description>
      <content:encoded><![CDATA[<h1 id="postgresqlインデックス最適化の現場手順遅いクエリを再現診断改善する実践プレイブック">PostgreSQLインデックス最適化の現場手順：遅いクエリを再現・診断・改善する実践プレイブック</h1>
<p>「CPUは余っているのに画面が遅い」「特定時間帯だけ API が詰まる」。この手の問題の多くは、アプリではなく SQL の実行計画に原因があります。特に PostgreSQL では、インデックス設計と統計情報の状態が性能をほぼ決めます。</p>
<p>本記事では、実務で使う手順に沿って、遅延クエリの改善を再現可能な形で解説します。単なる理論紹介ではなく、<strong>調査順序、判断基準、リリース時の注意点</strong>まで含めてまとめます。</p>
<h2 id="まず守るべき3原則">まず守るべき3原則</h2>
<ol>
<li><strong>推測でインデックスを作らない</strong>
体感で追加すると write 性能とストレージが悪化します。必ず実行計画を見てから判断します。</li>
<li><strong>改善前後を数値で比較する</strong>
P95、rows、shared read blocks を記録し、効果を証明します。</li>
<li><strong>本番反映は CONCURRENTLY を基本にする</strong>
テーブルロックで事故らないため、<code>CREATE INDEX CONCURRENTLY</code> を優先します。</li>
</ol>
<h2 id="ケース設定注文一覧apiが遅い">ケース設定：注文一覧APIが遅い</h2>
<p>次のクエリが遅いとします。</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">6
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">7
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#66d9ef">SELECT</span> id, user_id, status, total_amount, created_at
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">FROM</span> orders
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">WHERE</span> tenant_id <span style="color:#f92672">=</span> <span style="color:#960050;background-color:#1e0010">$</span><span style="color:#ae81ff">1</span>
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">AND</span> status <span style="color:#66d9ef">IN</span> (<span style="color:#e6db74">&#39;paid&#39;</span>, <span style="color:#e6db74">&#39;shipped&#39;</span>)
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">AND</span> created_at <span style="color:#f92672">&gt;=</span> NOW() <span style="color:#f92672">-</span> INTERVAL <span style="color:#e6db74">&#39;30 days&#39;</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">ORDER</span> <span style="color:#66d9ef">BY</span> created_at <span style="color:#66d9ef">DESC</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">LIMIT</span> <span style="color:#ae81ff">50</span>;
</span></span></code></pre></td></tr></table>
</div>
</div><p>データ量は <code>orders</code> 1.2億件、1テナントあたり数百万件。現象は「特定テナントだけ 3〜6 秒」です。</p>
<h2 id="手順1pg_stat_statementsで優先度をつける">手順1：pg_stat_statementsで優先度をつける</h2>
<p>まずは遅い順ではなく、**影響度順（総時間）**で見るのが現場では正解です。</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#66d9ef">SELECT</span> queryid, calls, total_exec_time, mean_exec_time, <span style="color:#66d9ef">rows</span>, query
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">FROM</span> pg_stat_statements
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">ORDER</span> <span style="color:#66d9ef">BY</span> total_exec_time <span style="color:#66d9ef">DESC</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">LIMIT</span> <span style="color:#ae81ff">20</span>;
</span></span></code></pre></td></tr></table>
</div>
</div><p>ここで対象クエリの <code>calls</code> が多く、<code>mean_exec_time</code> が高いことを確認。改善効果が大きいと判断できます。</p>
<h2 id="手順2explain-analyze-buffersでボトルネックを特定">手順2：EXPLAIN ANALYZE BUFFERSでボトルネックを特定</h2>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#66d9ef">EXPLAIN</span> (<span style="color:#66d9ef">ANALYZE</span>, BUFFERS, <span style="color:#66d9ef">VERBOSE</span>)
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">SELECT</span> ...;
</span></span></code></pre></td></tr></table>
</div>
</div><p>典型的な悪い例は次の通りです。</p>
<ul>
<li><code>Seq Scan on orders</code></li>
<li><code>Rows Removed by Filter</code> が極端に多い</li>
<li><code>Sort Method: external merge Disk</code>（メモリ不足でディスクソート）</li>
</ul>
<p>この状態では、絞り込み条件に合うインデックスが不足しています。</p>
<h2 id="手順3最小コストで効くインデックス設計">手順3：最小コストで効くインデックス設計</h2>
<p>今回の条件は <code>tenant_id</code>, <code>status</code>, <code>created_at</code> です。ORDER BY も <code>created_at DESC</code>。したがって候補は次です。</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#66d9ef">CREATE</span> <span style="color:#66d9ef">INDEX</span> CONCURRENTLY idx_orders_tenant_status_created_at_desc
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">ON</span> orders (tenant_id, status, created_at <span style="color:#66d9ef">DESC</span>);
</span></span></code></pre></td></tr></table>
</div>
</div><p>ここで順序が重要です。先頭列は等価条件（tenant_id）、次に低カーディナリティ条件（status）、最後に範囲・並び替え列（created_at）を置きます。</p>
<h3 id="部分インデックスの検討">部分インデックスの検討</h3>
<p><code>status</code> が多数あるが実際に使うのが paid/shipped だけなら、部分インデックスでさらに削減できます。</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#66d9ef">CREATE</span> <span style="color:#66d9ef">INDEX</span> CONCURRENTLY idx_orders_recent_paid_shipped
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">ON</span> orders (tenant_id, created_at <span style="color:#66d9ef">DESC</span>)
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">WHERE</span> status <span style="color:#66d9ef">IN</span> (<span style="color:#e6db74">&#39;paid&#39;</span>, <span style="color:#e6db74">&#39;shipped&#39;</span>);
</span></span></code></pre></td></tr></table>
</div>
</div><p>この方式はサイズが小さく、キャッシュ効率が高いのが利点です。</p>
<h2 id="手順4改善効果を検証">手順4：改善効果を検証</h2>
<p>同一条件で再度 <code>EXPLAIN ANALYZE</code> を実施します。</p>
<p>確認ポイント:</p>
<ul>
<li><code>Index Scan</code> か <code>Bitmap Heap Scan</code> に変わっているか</li>
<li>実行時間が目標値（例: 200ms 未満）に入ったか</li>
<li>shared read blocks が大幅に減ったか</li>
<li><code>rows=50</code> を早期に取り出せているか</li>
</ul>
<p>改善後に 4.2 秒 → 120ms 程度まで落ちるケースは珍しくありません。</p>
<h2 id="それでも遅い場合の追加施策">それでも遅い場合の追加施策</h2>
<h3 id="1-カバリングインデックスinclude">1) カバリングインデックス（INCLUDE）</h3>
<p>取得列が多いとテーブルアクセスが残ります。PostgreSQL では <code>INCLUDE</code> が使えます。</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#66d9ef">CREATE</span> <span style="color:#66d9ef">INDEX</span> CONCURRENTLY idx_orders_covering
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">ON</span> orders (tenant_id, status, created_at <span style="color:#66d9ef">DESC</span>)
</span></span><span style="display:flex;"><span>INCLUDE (total_amount, user_id);
</span></span></code></pre></td></tr></table>
</div>
</div><h3 id="2-統計情報の更新">2) 統計情報の更新</h3>
<p>データ偏りが強いと planner が誤判定します。</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#66d9ef">ANALYZE</span> orders;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">ALTER</span> <span style="color:#66d9ef">TABLE</span> orders <span style="color:#66d9ef">ALTER</span> <span style="color:#66d9ef">COLUMN</span> status <span style="color:#66d9ef">SET</span> <span style="color:#66d9ef">STATISTICS</span> <span style="color:#ae81ff">1000</span>;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">ANALYZE</span> orders;
</span></span></code></pre></td></tr></table>
</div>
</div><h3 id="3-パーティショニング">3) パーティショニング</h3>
<p>30日検索が多いなら、月次パーティションで読み取り範囲を削るのも有効です。既存移行はコストが高いので、まずは新規データから段階導入します。</p>
<h2 id="リリース時の安全手順">リリース時の安全手順</h2>
<p>本番では速度改善より安全性が優先です。次の順番を守ると事故が減ります。</p>
<ol>
<li>負荷が低い時間帯を選ぶ</li>
<li><code>CREATE INDEX CONCURRENTLY</code> を実行</li>
<li>進捗確認: <code>pg_stat_progress_create_index</code></li>
<li>完了後に代表クエリで実行計画を確認</li>
<li>監視（CPU、I/O、lock wait、replication lag）を 30 分観察</li>
<li>不要化した旧インデックスは別日に削除</li>
</ol>
<p>いきなり削除しない理由は、想定外クエリで回帰する可能性があるためです。1〜2日観測してから <code>DROP INDEX CONCURRENTLY</code> するのが安定運用です。</p>
<h2 id="アンチパターン集">アンチパターン集</h2>
<ul>
<li><code>LIKE '%keyword%'</code> に B-tree インデックスを貼る
<ul>
<li>→ pg_trgm + GIN を使う</li>
</ul>
</li>
<li>すべての列に単体インデックスを作る
<ul>
<li>→ planner が迷う、write コスト増</li>
</ul>
</li>
<li>UUID主キーだけ見て満足する
<ul>
<li>→ 実際の検索条件列を優先</li>
</ul>
</li>
<li>autovacuum 設定を放置
<ul>
<li>→ bloat 増で index scan が遅くなる</li>
</ul>
</li>
</ul>
<h2 id="計測テンプレート運用向け">計測テンプレート（運用向け）</h2>
<p>改善作業を属人化しないために、次のテンプレートで記録すると再利用できます。</p>
<ul>
<li>対象クエリID（pg_stat_statements）</li>
<li>改善前 mean/P95</li>
<li>改善前実行計画（テキスト保存）</li>
<li>追加したインデックスDDL</li>
<li>改善後 mean/P95</li>
<li>副作用（write増、ストレージ増、vacuum時間）</li>
<li>ロールバック手順</li>
</ul>
<p>このフォーマットをWiki化しておくと、次回の性能障害対応が非常に速くなります。</p>
<h2 id="まとめ">まとめ</h2>
<p>PostgreSQL の性能改善は、魔法のパラメータよりも「再現・診断・検証」の手順で決まります。特にインデックスは効果が大きい反面、副作用もあるため、実行計画と計測値で判断することが重要です。</p>
<p>遅延問題に直面したら、まず <code>pg_stat_statements</code> で対象を絞り、<code>EXPLAIN ANALYZE BUFFERS</code> で事実を取り、<code>CONCURRENTLY</code> で安全に改善する。この流れをチーム標準にすれば、DB運用の安定性は確実に上がります。</p>
<h2 id="現場で使うトラブルシュート手順夜間障害対応向け">現場で使うトラブルシュート手順（夜間障害対応向け）</h2>
<p>実際の障害対応では、理想的な調査順序を守れないことがあります。そこで夜間当番でも使える短縮手順を用意しておくと有効です。</p>
<ol>
<li>まず <code>pg_stat_activity</code> で待機イベントを確認（lock か I/O か）</li>
<li>次に <code>pg_locks</code> で競合トランザクションを特定</li>
<li>対象クエリの <code>EXPLAIN (ANALYZE, BUFFERS)</code> を取得</li>
<li>直近デプロイ差分（SQL/マイグレーション）を確認</li>
<li>即効性のある一時回避（statement timeout、read replica 振り分け）を実施</li>
</ol>
<p>短期回避後に恒久対策を行う、という二段運用が安定します。</p>
<h3 id="ロック競合の例">ロック競合の例</h3>
<p><code>ALTER TABLE</code> と長時間 SELECT が競合すると、アプリの体感遅延が一気に悪化します。マイグレーションは <code>LOCK TIMEOUT</code> を短く設定し、失敗時に即リトライしない設計にしましょう。</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#66d9ef">SET</span> lock_timeout <span style="color:#f92672">=</span> <span style="color:#e6db74">&#39;2s&#39;</span>;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">SET</span> statement_timeout <span style="color:#f92672">=</span> <span style="color:#e6db74">&#39;30s&#39;</span>;
</span></span></code></pre></td></tr></table>
</div>
</div><h3 id="クエリヒントが使えない前提での工夫">クエリヒントが使えない前提での工夫</h3>
<p>PostgreSQL は MySQL のようなヒント句が限定的なため、実行計画の誘導は以下で行います。</p>
<ul>
<li>統計情報を正しく更新</li>
<li>不要な関数適用を避ける（索引利用阻害）</li>
<li>OR 条件を UNION ALL 分割で単純化</li>
</ul>
<p>例えば <code>WHERE date(created_at) = CURRENT_DATE</code> は index を使いにくいため、次のように書き換えます。</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#66d9ef">WHERE</span> created_at <span style="color:#f92672">&gt;=</span> <span style="color:#66d9ef">CURRENT_DATE</span>
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">AND</span> created_at <span style="color:#f92672">&lt;</span> <span style="color:#66d9ef">CURRENT_DATE</span> <span style="color:#f92672">+</span> INTERVAL <span style="color:#e6db74">&#39;1 day&#39;</span>
</span></span></code></pre></td></tr></table>
</div>
</div><p>この1点だけで scan 範囲が激減することがあります。</p>
<h2 id="チーム運用に落とし込むためのルール">チーム運用に落とし込むためのルール</h2>
<p>最後に、性能改善を個人依存にしないための運用ルールを提案します。</p>
<ul>
<li>新規API追加時は必ず「想定SQL」と「必要インデックス案」を設計レビューに含める</li>
<li>週次で slow query 上位10件を確認し、改善オーナーを割り当てる</li>
<li>重要テーブルの index hit ratio と bloat 率を定期監視する</li>
</ul>
<p>この運用が回ると、障害対応だけでなく機能開発の速度も上がります。DB 性能は裏方ではなく、プロダクト体験の中心です。</p>
]]></content:encoded>
      <category>Tech</category>
      <category>PostgreSQL</category>
      <category>Performance</category>
      <category>Index</category>
      <category>Database</category>
    </item>
    <item>
      <title>Supabaseで構築するスケーラブルなデータベース基盤</title>
      <link>https://www.ai2core.com/posts/2026-02-23-supabase-db/</link>
      <pubDate>Mon, 23 Feb 2026 18:00:00 +0900</pubDate>
      <guid>https://www.ai2core.com/posts/2026-02-23-supabase-db/</guid>
      <description>Firebase代替としてのSupabaseの魅力と、Postgresの強力な機能。</description>
      <content:encoded><![CDATA[<h1 id="supabaseで構築するスケーラブルなデータベース基盤">Supabaseで構築するスケーラブルなデータベース基盤</h1>
<h2 id="はじめに">はじめに</h2>
<p>「バックエンドの開発速度を上げたい」「認証やリアルタイム機能を手軽に実装したい」——こうした要求に応えるBaaS (Backend as a Service) は、現代のアプリケーション開発において不可欠な存在です。その代表格であるFirebaseは、多くのプロジェクトで採用され、開発者に多大な恩恵をもたらしてきました。</p>
<p>しかし、プロジェクトが成長し、データ構造が複雑化するにつれて、このような課題に直面したことはないでしょうか？</p>
<ul>
<li>「Firebase (Firestore) のスキーマレスな性質が、逆にデータ整合性の維持を難しくしている…」</li>
<li>「複雑なデータ検索や集計を行いたいが、NoSQLのクエリでは表現力に限界がある…」</li>
<li>「ベンダーロックインが心配だ。将来的にインフラを移行する必要が出たときに、身動きが取れなくなるのではないか？」</li>
<li>「リレーショナルなデータを扱うには、Firestoreは最適とは言えないかもしれない…」</li>
</ul>
<p>もし、あなたがこれらの課題に少しでも心当たりがあるなら、この記事はあなたのためのものです。</p>
<p>本記事では、<strong>「オープンソースのFirebase代替」<strong>として注目を集める</strong>Supabase</strong>を取り上げます。Supabaseは、単なるFirebaseのクローンではありません。その核には、40年以上の歴史と絶大な信頼性を誇るリレーショナルデータベース<strong>PostgreSQL</strong>が据えられています。</p>
<p>この記事を読み終える頃には、あなたはSupabaseがなぜスケーラブルで堅牢なデータベース基盤を構築するための強力な選択肢となるのか、そしてPostgreSQLの力を最大限に活用して、高速な開発と長期的な運用性を両立させる方法を深く理解できるでしょう。</p>
<h2 id="なぜsupabaseが今注目されているのか---背景と課題">なぜSupabaseが今、注目されているのか？ - 背景と課題</h2>
<p>Supabaseの魅力を理解するためには、まずBaaS市場の変遷と、既存のサービスが抱える課題を理解する必要があります。</p>
<h3 id="baasの進化とfirebaseがもたらした革命">BaaSの進化とFirebaseがもたらした革命</h3>
<p>かつて、Webアプリケーションを開発するには、サーバーのプロビジョニング、データベースのセットアップ、APIサーバーの実装、認証システムの構築など、多くの定型的な作業が必要でした。</p>
<p>BaaSは、これらのバックエンド機能を汎用的なサービスとして提供することで、開発者がフロントエンドやアプリケーションのコアロジックに集中できるようにしました。中でもGoogleのFirebaseは、直感的なAPI、リアルタイムデータベース、強力な認証機能、ホスティングまでをワンストップで提供し、特にモバイルアプリやプロトタイピングの領域で圧倒的な支持を得ました。</p>
<h3 id="firebase-firestore-が抱えるスケーラビリティの課題">Firebase (Firestore) が抱えるスケーラビリティの課題</h3>
<p>Firebaseの成功は、その手軽さと開発速度にありました。しかし、プロジェクトが成長し、エンタープライズレベルの要件が求められるようになると、そのアーキテクチャに起因するいくつかの課題が顕在化します。</p>
<ol>
<li>
<p><strong>NoSQLデータベースの限界</strong>:
Firebaseの主要なデータベースであるFirestoreは、ドキュメント指向のNoSQLデータベースです。スキーマレスであるため初期開発は迅速ですが、データ間の複雑なリレーションを扱うのが苦手です。例えば、SNSアプリケーションで「ユーザー」と「投稿」と「コメント」と「いいね」が複雑に絡み合うようなデータモデルを考えたとき、正規化されたリレーショナルデータベースであればJOIN一発で取得できるデータも、Firestoreでは複数回のクエリやデータの非正規化といった工夫が必要になり、コードの複雑化やデータ冗長性を招きます。</p>
</li>
<li>
<p><strong>クエリの表現力不足</strong>:
SQLのように柔軟で強力なクエリ言語を持たないため、複雑な条件での絞り込み、集計、ソートといった操作に制限があります。<code>GROUP BY</code>や<code>HAVING</code>のような集計関数を使いたい場合、Cloud Functionsなどを駆使して自前で実装する必要があり、リアルタイム性やパフォーマンスが犠牲になることも少なくありません。</p>
</li>
<li>
<p><strong>ベンダーロックインへの懸念</strong>:
Firebaseは非常に優れたエコシステムですが、それはGoogle Cloud Platformに深く統合されています。一度Firebaseで大規模なシステムを構築すると、データベースの移行や、他のクラウドサービスとの連携が困難になる「ベンダーロックイン」のリスクが常に伴います。データのエクスポートは可能ですが、セキュリティルールやCloud Functionsで記述したビジネスロジックまで含めた完全な移行は、極めて困難です。</p>
</li>
</ol>
<p>これらの課題は、「開発の初期段階では最高のツールだが、長期的にスケールさせるには不安が残る」という評価につながっていました。</p>
<h3 id="rdbへの回帰とsupabaseの登場">RDBへの回帰とSupabaseの登場</h3>
<p>このような背景の中、開発者コミュニティでは、データの整合性、トランザクションの信頼性、そしてSQLという標準化された強力なクエリ言語を持つ<strong>リレーショナルデータベース (RDB) の価値</strong>が再評価されるようになります。</p>
<p>そこに登場したのがSupabaseです。Supabaseは、この流れを見事に捉えました。</p>
<p><strong>「世界で最も信頼されているオープンソースRDBであるPostgreSQLを使い、Firebaseのような開発者体験を提供する」</strong></p>
<p>このコンセプトが、多くの開発者の心を掴んだのです。Supabaseは、BaaSの手軽さと、RDBの堅牢性・柔軟性という、これまでトレードオフの関係にあると考えられていた2つの要素を、見事に両立させました。</p>
<h2 id="supabaseのアーキテクチャとpostgresqlの強力な機能">SupabaseのアーキテクチャとPostgreSQLの強力な機能</h2>
<p>Supabaseが単なるデータベースサービスではないことを理解するために、そのアーキテクチャを見ていきましょう。Supabaseは、既存の優れたオープンソースツール群をPostgreSQLを中心に統合した、いわば「バックエンドのオーケストラ」です。</p>
<pre tabindex="0"><code>                    +--------------------------------+
                    |       Your Application         |
                    | (Web, Mobile, etc.)            |
                    +--------------------------------+
                           |         |         |
                           | (SDK)   | (SDK)   |
  +------------------------+---------+---------+--------------------------+
  |                   Supabase Platform (Hosted or Self-hosted)             |
  |                                                                         |
  |  +-----------+   +-------------+   +-----------+   +---------+   +----------+
  |  |  Auth     |   | Realtime    |   |  Storage  |   | Edge    |   | REST API |
  |  | (GoTrue)  |   | (Realtime)  |   | (S3-comp) |   | Functions| |(PostgREST)|
  |  +-----------+   +-------------+   +-----------+   +---------+   +----------+
  |        |                 |               |               |           |
  |        +-----------------+---------------+---------------+-----------+
  |                                    |
  |                          +---------------------+
  |                          |     PostgreSQL      |  &lt;-- THE CORE
  |                          | (Database, RLS,     |
  |                          |  Functions, Exts)   |
  |                          +---------------------+
  +-------------------------------------------------------------------------+
</code></pre><ul>
<li><strong>PostgreSQL</strong>: すべての中心です。単なるデータストアではなく、認証情報、セキュリティポリシー、ビジネスロジック（関数）まで、すべてがここに集約されます。</li>
<li><strong>GoTrue</strong>: JWTベースの認証サーバー。ユーザー管理とアクセストークン発行を担当します。ユーザー情報はPostgresの<code>auth.users</code>テーブルに保存されます。</li>
<li><strong>PostgREST</strong>: データベーススキーマを読み取り、自動的にRESTful APIを生成します。テーブルやビューを作成するだけで、即座に対応するAPIエンドポイントが利用可能になります。</li>
<li><strong>Realtime</strong>: Postgresの論理レプリケーション機能を利用して、データベースの変更をリアルタイムにクライアントにWebSocket経由で配信します。</li>
<li><strong>Storage</strong>: S3互換のオブジェクトストレージ。Postgresを使って権限管理を行います。</li>
<li><strong>Edge Functions</strong>: Denoで書かれたサーバーレス関数。データベースに近い場所でカスタムロジックを実行できます。</li>
</ul>
<p>このアーキテクチャの最大のポイントは、<strong>すべてがPostgreSQLに根ざしている</strong>ことです。これにより、PostgreSQLが持つ強力な機能を最大限に活用できるのです。</p>
<h3 id="postgresqlがもたらすスケーラビリティと柔軟性">PostgreSQLがもたらすスケーラビリティと柔軟性</h3>
<p>SupabaseがFirebaseと一線を画すのは、このPostgreSQLの力です。具体的にどのようなメリットがあるのか見ていきましょう。</p>
<h4 id="1-厳密なスキーマとリレーション">1. 厳密なスキーマとリレーション</h4>
<p>NoSQLの柔軟性も魅力ですが、大規模なアプリケーションでは厳密なスキーマがデータの整合性を保証し、バグの温床を減らします。Supabaseでは、使い慣れたSQLでテーブルを定義できます。</p>
<p><strong>例: ユーザーと投稿テーブルの作成</strong></p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">11
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">12
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">13
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">14
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#75715e">-- ユーザーテーブル
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#66d9ef">CREATE</span> <span style="color:#66d9ef">TABLE</span> <span style="color:#66d9ef">public</span>.profiles (
</span></span><span style="display:flex;"><span>  id UUID <span style="color:#66d9ef">PRIMARY</span> <span style="color:#66d9ef">KEY</span> <span style="color:#66d9ef">REFERENCES</span> auth.users(id) <span style="color:#66d9ef">ON</span> <span style="color:#66d9ef">DELETE</span> <span style="color:#66d9ef">CASCADE</span>,
</span></span><span style="display:flex;"><span>  username TEXT <span style="color:#66d9ef">UNIQUE</span> <span style="color:#66d9ef">NOT</span> <span style="color:#66d9ef">NULL</span>,
</span></span><span style="display:flex;"><span>  updated_at TIMESTAMPTZ <span style="color:#66d9ef">DEFAULT</span> NOW()
</span></span><span style="display:flex;"><span>);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">-- 投稿テーブル
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#66d9ef">CREATE</span> <span style="color:#66d9ef">TABLE</span> <span style="color:#66d9ef">public</span>.posts (
</span></span><span style="display:flex;"><span>  id BIGINT <span style="color:#66d9ef">GENERATED</span> <span style="color:#66d9ef">BY</span> <span style="color:#66d9ef">DEFAULT</span> <span style="color:#66d9ef">AS</span> <span style="color:#66d9ef">IDENTITY</span> <span style="color:#66d9ef">PRIMARY</span> <span style="color:#66d9ef">KEY</span>,
</span></span><span style="display:flex;"><span>  user_id UUID <span style="color:#66d9ef">NOT</span> <span style="color:#66d9ef">NULL</span> <span style="color:#66d9ef">REFERENCES</span> <span style="color:#66d9ef">public</span>.profiles(id),
</span></span><span style="display:flex;"><span>  content TEXT <span style="color:#66d9ef">NOT</span> <span style="color:#66d9ef">NULL</span>,
</span></span><span style="display:flex;"><span>  created_at TIMESTAMPTZ <span style="color:#66d9ef">DEFAULT</span> NOW()
</span></span><span style="display:flex;"><span>);
</span></span></code></pre></td></tr></table>
</div>
</div><p>このように、<code>FOREIGN KEY</code>制約を使えば、存在しないユーザーからの投稿を防ぐなど、データベースレベルでデータの整合性を担保できます。</p>
<p>そして、リレーショナルデータベースの真骨頂である<code>JOIN</code>が、その威力を発揮します。</p>
<p><strong>例: 投稿とその投稿者のユーザー名を取得する (JavaScript SDK)</strong></p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">11
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">12
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">13
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">14
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">15
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">16
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">17
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">18
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">19
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">20
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">21
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">22
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">23
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">24
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">25
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">26
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">27
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">28
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-javascript" data-lang="javascript"><span style="display:flex;"><span><span style="color:#66d9ef">import</span> { <span style="color:#a6e22e">createClient</span> } <span style="color:#a6e22e">from</span> <span style="color:#e6db74">&#39;@supabase/supabase-js&#39;</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">const</span> <span style="color:#a6e22e">supabase</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">createClient</span>(<span style="color:#a6e22e">process</span>.<span style="color:#a6e22e">env</span>.<span style="color:#a6e22e">SUPABASE_URL</span>, <span style="color:#a6e22e">process</span>.<span style="color:#a6e22e">env</span>.<span style="color:#a6e22e">SUPABASE_ANON_KEY</span>);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">async</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">fetchPostsWithUsernames</span>() {
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">const</span> { <span style="color:#a6e22e">data</span>, <span style="color:#a6e22e">error</span> } <span style="color:#f92672">=</span> <span style="color:#66d9ef">await</span> <span style="color:#a6e22e">supabase</span>
</span></span><span style="display:flex;"><span>    .<span style="color:#a6e22e">from</span>(<span style="color:#e6db74">&#39;posts&#39;</span>)
</span></span><span style="display:flex;"><span>    .<span style="color:#a6e22e">select</span>(<span style="color:#e6db74">`
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">      id,
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">      content,
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">      created_at,
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">      profiles (
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">        username
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">      )
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    `</span>);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">if</span> (<span style="color:#a6e22e">error</span>) <span style="color:#66d9ef">throw</span> <span style="color:#a6e22e">error</span>;
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">console</span>.<span style="color:#a6e22e">log</span>(<span style="color:#a6e22e">data</span>);
</span></span><span style="display:flex;"><span>  <span style="color:#75715e">// 出力例:
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>  <span style="color:#75715e">// [
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>  <span style="color:#75715e">//   {
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>  <span style="color:#75715e">//     id: 1,
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>  <span style="color:#75715e">//     content: &#39;Hello Supabase!&#39;,
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>  <span style="color:#75715e">//     created_at: &#39;...&#39;,
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>  <span style="color:#75715e">//     profiles: { username: &#39;user1&#39; }
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>  <span style="color:#75715e">//   }, ...
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>  <span style="color:#75715e">// ]
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>}
</span></span></code></pre></td></tr></table>
</div>
</div><p>Supabaseのクライアントライブラリは、リレーションを直感的に扱えるように設計されており、<code>JOIN</code>の強力な機能を簡単に利用できます。Firestoreでこれと同じことをしようとすると、クライアント側で複数回の読み取りが必要になるケースがほとんどです。</p>
<h4 id="2-強力なセキュリティ-row-level-security-rls">2. 強力なセキュリティ: Row Level Security (RLS)</h4>
<p>Supabaseのキラー機能の一つが、PostgreSQLの**Row Level Security (RLS)**です。これは、データベースの行（レコード）単位で、誰がどの操作（SELECT, INSERT, UPDATE, DELETE）を行えるかを定義できるセキュリティ機能です。</p>
<p>FirebaseのセキュリティルールがJSONライクな独自構文で記述するのに対し、RLSは<strong>SQL</strong>でポリシーを記述します。これにより、極めて柔軟かつ強力なアクセスコントロールが実現できます。</p>
<p><strong>例1: 自分のプロフィール情報しか更新できないようにするポリシー</strong></p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">6
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">7
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">8
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#75715e">-- まずテーブルでRLSを有効化
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#66d9ef">ALTER</span> <span style="color:#66d9ef">TABLE</span> <span style="color:#66d9ef">public</span>.profiles ENABLE <span style="color:#66d9ef">ROW</span> <span style="color:#66d9ef">LEVEL</span> <span style="color:#66d9ef">SECURITY</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">-- UPDATEに対するポリシーを作成
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#66d9ef">CREATE</span> POLICY <span style="color:#e6db74">&#34;Users can update their own profile.&#34;</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">ON</span> <span style="color:#66d9ef">public</span>.profiles
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">FOR</span> <span style="color:#66d9ef">UPDATE</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">USING</span> ( auth.uid() <span style="color:#f92672">=</span> id ); <span style="color:#75715e">-- 現在認証中のユーザーのIDと、行のIDが一致する場合のみ許可
</span></span></span></code></pre></td></tr></table>
</div>
</div><p><strong>例2: ログインしているユーザーは全ての投稿を閲覧でき、自分の投稿のみ削除できるポリシー</strong></p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">11
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">12
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">13
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#66d9ef">ALTER</span> <span style="color:#66d9ef">TABLE</span> <span style="color:#66d9ef">public</span>.posts ENABLE <span style="color:#66d9ef">ROW</span> <span style="color:#66d9ef">LEVEL</span> <span style="color:#66d9ef">SECURITY</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">-- ログインユーザーは全件閲覧可能
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#66d9ef">CREATE</span> POLICY <span style="color:#e6db74">&#34;Allow logged-in users to read all posts.&#34;</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">ON</span> <span style="color:#66d9ef">public</span>.posts
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">FOR</span> <span style="color:#66d9ef">SELECT</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">USING</span> ( auth.<span style="color:#66d9ef">role</span>() <span style="color:#f92672">=</span> <span style="color:#e6db74">&#39;authenticated&#39;</span> );
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">-- 自分の投稿のみ削除可能
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#66d9ef">CREATE</span> POLICY <span style="color:#e6db74">&#34;Allow users to delete their own posts.&#34;</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">ON</span> <span style="color:#66d9ef">public</span>.posts
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">FOR</span> <span style="color:#66d9ef">DELETE</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">USING</span> ( auth.uid() <span style="color:#f92672">=</span> user_id );
</span></span></code></pre></td></tr></table>
</div>
</div><p>これらのポリシーはデータベース層で強制されるため、クライアントからどのようなリクエストが来ても、不正な操作はブロックされます。PostgRESTが生成するAPIは、このRLSポリシーを自動的に尊重するため、APIサーバー側で複雑な権限チェックロジックを実装する必要がほとんどありません。</p>
<h4 id="3-トランザクションとビジネスロジック">3. トランザクションとビジネスロジック</h4>
<p>複数のデータ更新を伴う処理、例えば「銀行振込（A口座から減算し、B口座に加算する）」のような処理では、全ての操作が成功するか、全て失敗するかのどちらかでなければなりません。これを保証するのが<strong>トランザクション</strong>です。</p>
<p>PostgreSQLは、ACID特性に準拠した強力なトランザクション機能を備えています。Supabaseでは、PostgreSQLの関数を<code>rpc()</code>（Remote Procedure Call）として呼び出すことで、トランザクションを安全に実行できます。</p>
<p><strong>例: 投稿に「いいね」をする関数（重複いいねを防ぐ）</strong>
まず、PostgreSQLに関数を作成します。</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">11
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">12
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">13
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">14
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">15
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">16
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">17
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">18
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">19
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">20
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#75715e">-- いいねテーブル
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#66d9ef">CREATE</span> <span style="color:#66d9ef">TABLE</span> <span style="color:#66d9ef">public</span>.likes (
</span></span><span style="display:flex;"><span>  post_id BIGINT <span style="color:#66d9ef">REFERENCES</span> <span style="color:#66d9ef">public</span>.posts(id) <span style="color:#66d9ef">ON</span> <span style="color:#66d9ef">DELETE</span> <span style="color:#66d9ef">CASCADE</span>,
</span></span><span style="display:flex;"><span>  user_id UUID <span style="color:#66d9ef">REFERENCES</span> <span style="color:#66d9ef">public</span>.profiles(id) <span style="color:#66d9ef">ON</span> <span style="color:#66d9ef">DELETE</span> <span style="color:#66d9ef">CASCADE</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">PRIMARY</span> <span style="color:#66d9ef">KEY</span> (post_id, user_id)
</span></span><span style="display:flex;"><span>);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">-- いいねを追加する関数
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#66d9ef">CREATE</span> <span style="color:#66d9ef">OR</span> <span style="color:#66d9ef">REPLACE</span> <span style="color:#66d9ef">FUNCTION</span> like_post (post_id_to_like BIGINT)
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">RETURNS</span> void
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">LANGUAGE</span> plpgsql
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">AS</span> <span style="color:#960050;background-color:#1e0010">$$</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">BEGIN</span>
</span></span><span style="display:flex;"><span>  <span style="color:#75715e">-- すでにいいねしている場合は何もしない
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>  <span style="color:#66d9ef">IF</span> <span style="color:#66d9ef">NOT</span> <span style="color:#66d9ef">EXISTS</span> (<span style="color:#66d9ef">SELECT</span> <span style="color:#ae81ff">1</span> <span style="color:#66d9ef">FROM</span> <span style="color:#66d9ef">public</span>.likes <span style="color:#66d9ef">WHERE</span> post_id <span style="color:#f92672">=</span> post_id_to_like <span style="color:#66d9ef">AND</span> user_id <span style="color:#f92672">=</span> auth.uid()) <span style="color:#66d9ef">THEN</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">INSERT</span> <span style="color:#66d9ef">INTO</span> <span style="color:#66d9ef">public</span>.likes (post_id, user_id)
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">VALUES</span> (post_id_to_like, auth.uid());
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">END</span> <span style="color:#66d9ef">IF</span>;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">END</span>;
</span></span><span style="display:flex;"><span><span style="color:#960050;background-color:#1e0010">$$</span>;
</span></span></code></pre></td></tr></table>
</div>
</div><p>この関数は、<code>likes</code>テーブルへの<code>INSERT</code>を試みますが、主キー制約により同じユーザーが同じ投稿に複数回いいねすることはできません。<code>IF</code>文で事前にチェックすることも可能です。</p>
<p>クライアントからは、この関数をシンプルに呼び出すだけです。</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">11
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-javascript" data-lang="javascript"><span style="display:flex;"><span><span style="color:#66d9ef">async</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">likePost</span>(<span style="color:#a6e22e">postId</span>) {
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">const</span> { <span style="color:#a6e22e">error</span> } <span style="color:#f92672">=</span> <span style="color:#66d9ef">await</span> <span style="color:#a6e22e">supabase</span>.<span style="color:#a6e22e">rpc</span>(<span style="color:#e6db74">&#39;like_post&#39;</span>, {
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">post_id_to_like</span><span style="color:#f92672">:</span> <span style="color:#a6e22e">postId</span>
</span></span><span style="display:flex;"><span>  });
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">if</span> (<span style="color:#a6e22e">error</span>) {
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">console</span>.<span style="color:#a6e22e">error</span>(<span style="color:#e6db74">&#39;Error liking post:&#39;</span>, <span style="color:#a6e22e">error</span>);
</span></span><span style="display:flex;"><span>  } <span style="color:#66d9ef">else</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">console</span>.<span style="color:#a6e22e">log</span>(<span style="color:#e6db74">&#39;Successfully liked post!&#39;</span>);
</span></span><span style="display:flex;"><span>  }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></td></tr></table>
</div>
</div><p>このように、複雑なビジネスロジックやアトミックな操作が必要な処理をデータベース関数としてカプセル化することで、クライアント側のコードはシンプルになり、セキュリティとデータ整合性も向上します。</p>
<h4 id="4-無限の拡張性-postgresql-extensions">4. 無限の拡張性: PostgreSQL Extensions</h4>
<p>PostgreSQLのもう一つの強みは、その広大なエコシステムと拡張機能（Extensions）です。Supabaseでは、ダッシュボードからワンクリックで様々な拡張機能を有効にできます。</p>
<ul>
<li><strong>PostGIS</strong>: 地理空間データを扱うためのデファクトスタンダード。位置情報を使った検索や分析がSQLで可能になります。</li>
<li><strong>pg_cron</strong>: 定期的なジョブ実行をデータベース内でスケジューリングできます（例: 毎晩0時に古いログを削除する）。</li>
<li><strong>pgvector</strong>: ベクトルデータを効率的に保存・検索するための拡張機能。AI/MLアプリケーションにおける類似検索（画像検索、推薦システムなど）に不可欠です。</li>
<li><strong>TimescaleDB</strong>: 時系列データを高速に処理するための拡張機能。IoTデータや金融データの分析基盤として利用できます。</li>
</ul>
<p>これらはほんの一例です。FirebaseではCloud Functionsなどを駆使して外部サービスと連携する必要があるような機能も、SupabaseならPostgreSQLの拡張機能として、データベースと一体化した形で実現できる可能性があります。</p>
<h2 id="supabase-vs-firebase-メリットとデメリットの徹底比較">Supabase vs Firebase: メリットとデメリットの徹底比較</h2>
<p>ここで、両者を客観的に比較し、それぞれのツールの強みと弱みを整理してみましょう。</p>
<table>
  <thead>
      <tr>
          <th style="text-align: left">項目</th>
          <th style="text-align: left">Supabase</th>
          <th style="text-align: left">Firebase</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td style="text-align: left"><strong>データベース</strong></td>
          <td style="text-align: left">PostgreSQL (リレーショナル)</td>
          <td style="text-align: left">Firestore (NoSQL), Realtime DB</td>
      </tr>
      <tr>
          <td style="text-align: left"><strong>スキーマ</strong></td>
          <td style="text-align: left">スキーマあり（厳密）</td>
          <td style="text-align: left">スキーマレス（柔軟）</td>
      </tr>
      <tr>
          <td style="text-align: left"><strong>クエリ</strong></td>
          <td style="text-align: left">SQL（JOIN, 集計など自由自在）</td>
          <td style="text-align: left">独自クエリ（制限あり、JOIN不可）</td>
      </tr>
      <tr>
          <td style="text-align: left"><strong>ベンダーロックイン</strong></td>
          <td style="text-align: left"><strong>低い</strong>（オープンソース, セルフホスト可）</td>
          <td style="text-align: left"><strong>高い</strong>（Google Cloudに依存）</td>
      </tr>
      <tr>
          <td style="text-align: left"><strong>セキュリティ</strong></td>
          <td style="text-align: left"><strong>RLS</strong> (SQLベース、行単位で強力)</td>
          <td style="text-align: left">Security Rules (JSONライク、パスベース)</td>
      </tr>
      <tr>
          <td style="text-align: left"><strong>ビジネスロジック</strong></td>
          <td style="text-align: left">DB関数 (RPC), Edge Functions</td>
          <td style="text-align: left">Cloud Functions</td>
      </tr>
      <tr>
          <td style="text-align: left"><strong>拡張性</strong></td>
          <td style="text-align: left"><strong>Postgres Extensions</strong></td>
          <td style="text-align: left">Google Cloudサービス連携</td>
      </tr>
      <tr>
          <td style="text-align: left"><strong>ローカル開発</strong></td>
          <td style="text-align: left">Dockerで完全な環境を再現可能</td>
          <td style="text-align: left">エミュレータスイート</td>
      </tr>
      <tr>
          <td style="text-align: left"><strong>学習コスト</strong></td>
          <td style="text-align: left">SQL/RDBの知識が必要</td>
          <td style="text-align: left">比較的容易に始められる</td>
      </tr>
  </tbody>
</table>
<h3 id="supabaseのメリット">Supabaseのメリット</h3>
<ul>
<li><strong>データ整合性と信頼性</strong>: RDBの特性により、データの整合性を担保しやすい。</li>
<li><strong>クエリの表現力</strong>: SQLが使えるため、複雑なデータ取得や分析が容易。</li>
<li><strong>脱ベンダーロックイン</strong>: オープンソースであり、<code>pg_dump</code>一発でデータをエクスポート可能。最悪の場合、自分でPostgreSQLをホストすることもできます。</li>
<li><strong>PostgreSQLエコシステムの活用</strong>: 長年培われてきたPostgreSQLのツール、知見、拡張機能という巨人の肩の上に立つことができます。</li>
<li><strong>コスト効率</strong>: 複雑なクエリをDB層で実行できるため、Cloud Functionsのように読み書きの回数で課金が増大するのを避けられる可能性があります。</li>
</ul>
<h3 id="supabaseのデメリット注意点">Supabaseのデメリット・注意点</h3>
<ul>
<li><strong>RDBの知識</strong>: スキーマ設計、正規化、インデックスの知識など、RDBに関する基本的な理解が求められます。</li>
<li><strong>スケーリングの考え方</strong>: Firestoreは水平スケーリングを前提に設計されていますが、PostgreSQLのスケールは基本的には垂直スケーリング（サーバーのスペックアップ）が中心となります。もちろん、リードレプリカなどでスケールアウトも可能ですが、Firestoreとは思想が異なります。ただし、ほとんどのアプリケーションにとってPostgreSQLのパフォーマンスは十分すぎるほど高性能です。</li>
<li><strong>エコシステムの成熟度</strong>: Firebaseに比べると、コミュニティの規模やサードパーティ製のライブラリ、学習資料などはまだ発展途上な面もあります（しかし、急速に成長しています）。</li>
</ul>
<h2 id="現場で使える実践的なtips">現場で使える実践的なTips</h2>
<p>Supabaseをプロダクションで活用するための、より実践的なヒントをいくつか紹介します。</p>
<h3 id="1-データベースマイグレーションはcliで管理する">1. データベースマイグレーションはCLIで管理する</h3>
<p>Supabaseのダッシュボード上でGUIでテーブルを操作するのは手軽ですが、プロダクション環境ではスキーマの変更履歴をバージョン管理することが不可欠です。Supabaseは強力なCLIツールを提供しています。</p>
<p><strong>基本的なマイグレーションフロー:</strong></p>
<ol>
<li>
<p><strong>ローカル環境と連携:</strong></p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>supabase login
</span></span><span style="display:flex;"><span>supabase link --project-ref &lt;your-project-id&gt;
</span></span></code></pre></td></tr></table>
</div>
</div></li>
<li>
<p><strong>ローカルDBのスキーマ変更をダンプ:</strong>
リモート（本番）DBのスキーマ変更をローカルに反映します。</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>supabase db pull
</span></span></code></pre></td></tr></table>
</div>
</div></li>
<li>
<p><strong>ローカルでテーブル変更などを行い、差分からマイグレーションファイルを作成:</strong></p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span><span style="color:#75715e"># 例: postsテーブルに &#34;title&#34; カラムを追加したとする</span>
</span></span><span style="display:flex;"><span>supabase db diff -f add_title_to_posts
</span></span></code></pre></td></tr></table>
</div>
</div><p>これにより、<code>supabase/migrations</code>ディレクトリにSQLファイルが生成されます。</p>
</li>
<li>
<p><strong>マイグレーションをリモートDBに適用:</strong></p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>supabase db push
</span></span></code></pre></td></tr></table>
</div>
</div></li>
</ol>
<p>このフローをCI/CDパイプラインに組み込むことで、データベーススキーマの変更を安全かつ自動的に管理できます。</p>
<h3 id="2-パフォーマンスチューニングの勘所-インデックスと実行計画">2. パフォーマンスチューニングの勘所: インデックスと実行計画</h3>
<p>アプリケーションのパフォーマンスが低下してきたら、まずクエリを疑います。</p>
<ul>
<li>
<p><strong>インデックスの作成</strong>: <code>WHERE</code>句で頻繁に検索するカラムや、<code>JOIN</code>の結合キーとなるカラムにはインデックスを作成しましょう。Supabaseのダッシュボードには、遅いクエリを検出し、インデックス作成を推奨してくれる機能もあります。</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#75715e">-- postsテーブルのuser_idカラムにインデックスを作成
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#66d9ef">CREATE</span> <span style="color:#66d9ef">INDEX</span> idx_posts_on_user_id <span style="color:#66d9ef">ON</span> <span style="color:#66d9ef">public</span>.posts(user_id);
</span></span></code></pre></td></tr></table>
</div>
</div></li>
<li>
<p><strong>実行計画の確認</strong>: <code>EXPLAIN ANALYZE</code>を使うと、PostgreSQLがどのようにクエリを実行しているか（実行計画）を確認できます。<code>Seq Scan</code>（全件スキャン）が発生している箇所は、インデックスが効いていない可能性があり、チューニングの対象となります。</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#66d9ef">EXPLAIN</span> <span style="color:#66d9ef">ANALYZE</span> <span style="color:#66d9ef">SELECT</span> <span style="color:#f92672">*</span> <span style="color:#66d9ef">FROM</span> <span style="color:#66d9ef">public</span>.posts <span style="color:#66d9ef">WHERE</span> user_id <span style="color:#f92672">=</span> <span style="color:#e6db74">&#39;...&#39;</span>;
</span></span></code></pre></td></tr></table>
</div>
</div></li>
</ul>
<h3 id="3-ビュー-view-を活用してapiをシンプルに保つ">3. ビュー (VIEW) を活用してAPIをシンプルに保つ</h3>
<p>複数のテーブルを<code>JOIN</code>した複雑なデータ構造を頻繁にクライアントから要求される場合、<strong>ビュー</strong>を作成するのが効果的です。ビューは、保存されたクエリ結果を仮想的なテーブルとして扱える機能です。</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#66d9ef">CREATE</span> <span style="color:#66d9ef">VIEW</span> <span style="color:#66d9ef">public</span>.posts_with_username <span style="color:#66d9ef">AS</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">SELECT</span>
</span></span><span style="display:flex;"><span>  p.id,
</span></span><span style="display:flex;"><span>  p.content,
</span></span><span style="display:flex;"><span>  p.created_at,
</span></span><span style="display:flex;"><span>  u.username
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">FROM</span>
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">public</span>.posts p
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">JOIN</span>
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">public</span>.profiles u <span style="color:#66d9ef">ON</span> p.user_id <span style="color:#f92672">=</span> u.id;
</span></span></code></pre></td></tr></table>
</div>
</div><p>こうすることで、クライアントからはあたかも<code>posts_with_username</code>という単一のテーブルがあるかのように見え、シンプルなクエリでデータを取得できます。RLSポリシーもビューに対して設定可能です。</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-javascript" data-lang="javascript"><span style="display:flex;"><span><span style="color:#75715e">// クライアント側のコードがシンプルになる
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#66d9ef">const</span> { <span style="color:#a6e22e">data</span>, <span style="color:#a6e22e">error</span> } <span style="color:#f92672">=</span> <span style="color:#66d9ef">await</span> <span style="color:#a6e22e">supabase</span>
</span></span><span style="display:flex;"><span>  .<span style="color:#a6e22e">from</span>(<span style="color:#e6db74">&#39;posts_with_username&#39;</span>)
</span></span><span style="display:flex;"><span>  .<span style="color:#a6e22e">select</span>(<span style="color:#e6db74">&#39;*&#39;</span>);
</span></span></code></pre></td></tr></table>
</div>
</div><h3 id="4-security-definer-関数で権限昇格を安全に行う">4. <code>security definer</code> 関数で権限昇格を安全に行う</h3>
<p>通常、<code>rpc</code>で呼び出される関数は、実行したユーザーの権限で動作します（<code>security invoker</code>）。しかし、時には関数の定義者（通常は管理者）の権限で特定の操作を行いたい場合があります。例えば、「ユーザー登録時に、プロフィールテーブルにもレコードを自動で作成する」といったケースです。</p>
<div class="highlight"><div style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
<table style="border-spacing:0;padding:0;margin:0;border:0;"><tr><td style="vertical-align:top;padding:0;margin:0;border:0;">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">11
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">12
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">13
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">14
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">15
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">16
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">17
</span><span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">18
</span></code></pre></td>
<td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
<pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#75715e">-- `auth.users`テーブルに新しい行が挿入されるたびにトリガーされる関数
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#66d9ef">CREATE</span> <span style="color:#66d9ef">OR</span> <span style="color:#66d9ef">REPLACE</span> <span style="color:#66d9ef">FUNCTION</span> <span style="color:#66d9ef">public</span>.handle_new_user()
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">RETURNS</span> <span style="color:#66d9ef">TRIGGER</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">LANGUAGE</span> plpgsql
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">SECURITY</span> <span style="color:#66d9ef">DEFINER</span> <span style="color:#75715e">-- この関数を定義者の権限で実行する
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#66d9ef">SET</span> search_path <span style="color:#f92672">=</span> <span style="color:#66d9ef">public</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">AS</span> <span style="color:#960050;background-color:#1e0010">$$</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">BEGIN</span>
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">INSERT</span> <span style="color:#66d9ef">INTO</span> <span style="color:#66d9ef">public</span>.profiles (id, username)
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">VALUES</span> (<span style="color:#66d9ef">new</span>.id, <span style="color:#66d9ef">new</span>.raw_user_meta_data<span style="color:#f92672">-&gt;&gt;</span><span style="color:#e6db74">&#39;username&#39;</span>);
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">RETURN</span> <span style="color:#66d9ef">new</span>;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">END</span>;
</span></span><span style="display:flex;"><span><span style="color:#960050;background-color:#1e0010">$$</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">-- `auth.users`テーブルへのINSERTをトリガーにする
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#66d9ef">CREATE</span> <span style="color:#66d9ef">TRIGGER</span> on_auth_user_created
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">AFTER</span> <span style="color:#66d9ef">INSERT</span> <span style="color:#66d9ef">ON</span> auth.users
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">FOR</span> <span style="color:#66d9ef">EACH</span> <span style="color:#66d9ef">ROW</span> <span style="color:#66d9ef">EXECUTE</span> <span style="color:#66d9ef">PROCEDURE</span> <span style="color:#66d9ef">public</span>.handle_new_user();
</span></span></code></pre></td></tr></table>
</div>
</div><p>この<code>SECURITY DEFINER</code>を使うと、ユーザーは<code>profiles</code>テーブルへの直接の<code>INSERT</code>権限を持っていなくても、サインアップするだけで自動的にプロフィールが作成される、という挙動を実現できます。ただし、強力な機能であるため、SQLインジェクションなどの脆弱性を作り込まないよう細心の注意が必要です。</p>
<h2 id="まとめ">まとめ</h2>
<p>本記事では、Supabaseが単なる「Firebaseの代替」ではなく、<strong>信頼と実績のあるPostgreSQLを核とした、スケーラブルで堅牢なデータベース基盤</strong>であることを、そのアーキテクチャと具体的な機能を通して解説しました。</p>
<ul>
<li><strong>BaaSの手軽さ</strong>: 認証、リアルタイム、ストレージといったバックエンド機能をすぐに利用開始できます。</li>
<li><strong>RDBの堅牢性</strong>: スキーマ、リレーション、トランザクションにより、データの整合性を高いレベルで保証します。</li>
<li><strong>SQLの表現力</strong>: 複雑なデータ取得や分析も、強力で標準化されたSQLで実現できます。</li>
<li><strong>強力なセキュリティ</strong>: Row Level Security (RLS)により、データベース層で行レベルのきめ細やかなアクセスコントロールが可能です。</li>
<li><strong>オープンソース</strong>: ベンダーロックインのリスクが低く、PostgreSQLの広大なエコシステムを最大限に活用できます。</li>
</ul>
<p><strong>どのようなプロジェクトにSupabaseは向いているでしょうか？</strong></p>
<ul>
<li><strong>データ整合性が最重要</strong>なアプリケーション（金融、業務システムなど）</li>
<li><strong>複雑なクエリやデータ分析</strong>が必要なサービス（SaaS、分析ツールなど）</li>
<li><strong>長期的な運用とスケーラビリティ</strong>を見据えたプロジェクト</li>
<li>開発チームが<strong>SQLとRDBに慣れ親しんでいる</strong>場合</li>
</ul>
<p>一方で、スキーマが固まらない超初期のプロトタイピングや、とにかく高速にシンプルなアプリを立ち上げたい場合には、依然としてFirebaseのスキーマレスなアプローチに分があるかもしれません。</p>
<p>しかし、今日のアプリケーション開発において、データの価値はますます高まっています。その大切なデータを、場当たり的な設計ではなく、堅牢な基盤の上で長期的に育てていきたいと考えるならば、Supabaseは検討すべき非常に有力な選択肢です。</p>
<p>Supabaseは、私たち開発者に「BaaSの利便性」と「本格的なデータベース管理」の二者択一を迫るのではなく、その両方を手に入れる道を示してくれました。ぜひ、次のプロジェクトでこのパワフルなデータベース基盤を体験してみてください。</p>
]]></content:encoded>
      <category>Backend</category>
      <category>Supabase</category>
      <category>PostgreSQL</category>
      <category>Database</category>
    </item>
  </channel>
</rss>
