<?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>PostgreSQL on AI2CORE - AI技術ブログ</title>
    <link>https://www.ai2core.com/tags/postgresql/</link>
    <description>Recent content in PostgreSQL on AI2CORE - AI技術ブログ</description>
    <generator>Hugo -- 0.146.4</generator>
    <language>ja</language>
    <lastBuildDate>Sat, 07 Mar 2026 11:10:00 +0900</lastBuildDate>
    <atom:link href="https://www.ai2core.com/tags/postgresql/index.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>FastAPI &#43; SQLAlchemy性能改善プレイブック: 遅いAPIを計測ベースで高速化する</title>
      <link>https://www.ai2core.com/posts/2026-03-07-fastapi-sqlalchemy-performance-tuning-playbook/</link>
      <pubDate>Sat, 07 Mar 2026 11:10:00 +0900</pubDate>
      <guid>https://www.ai2core.com/posts/2026-03-07-fastapi-sqlalchemy-performance-tuning-playbook/</guid>
      <description>FastAPIとSQLAlchemyのAPI性能を、N&#43;1解消・クエリ最適化・接続プール設定・負荷検証まで含めて具体的に改善する実践手順を解説。</description>
      <content:encoded><![CDATA[<h1 id="fastapi--sqlalchemy性能改善プレイブック-遅いapiを計測ベースで高速化する">FastAPI + SQLAlchemy性能改善プレイブック: 遅いAPIを計測ベースで高速化する</h1>
<p>FastAPIの初期実装は非常に快適です。しかし運用フェーズに入ると、次のような症状が出てきます。</p>
<ul>
<li>一覧APIのレスポンスが急に遅くなる</li>
<li>同時接続が増えるとp95が跳ねる</li>
<li>CPUは余っているのにタイムアウトが増える</li>
<li>DB接続数が上限に張り付く</li>
</ul>
<p>こうした問題の多くは「Pythonが遅い」のではなく、<strong>SQLAlchemyの使い方とDBアクセス設計</strong> に起因します。</p>
<p>本記事では、FastAPI + SQLAlchemy + PostgreSQL構成を前提に、実際の改善手順を計測ベースで整理します。</p>
<h2 id="1-最初に測るべき指標">1. 最初に測るべき指標</h2>
<p>最適化は、体感ではなく数値で進めます。最低限、以下を可視化します。</p>
<ul>
<li>APIのp50/p95/p99レイテンシ</li>
<li>エンドポイント別SQL発行回数</li>
<li>1リクエストあたりのDB滞在時間</li>
<li>connection pool待ち時間</li>
<li>slow query件数（200ms以上など）</li>
</ul>
<p>OpenTelemetryやNew Relicを使っているなら、アプリspanとDB spanを必ず紐付けてください。これだけでボトルネック特定速度が上がります。</p>
<h2 id="2-n1問題を最優先で潰す">2. N+1問題を最優先で潰す</h2>
<p>最も頻出するのがN+1です。例えばユーザー一覧でプロフィールを参照すると、ユーザー数分の追加クエリが発行されます。</p>
<h3 id="21-悪い例">2.1 悪い例</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></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>users <span style="color:#f92672">=</span> session<span style="color:#f92672">.</span>query(User)<span style="color:#f92672">.</span>limit(<span style="color:#ae81ff">100</span>)<span style="color:#f92672">.</span>all()
</span></span><span style="display:flex;"><span>result <span style="color:#f92672">=</span> []
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">for</span> u <span style="color:#f92672">in</span> users:
</span></span><span style="display:flex;"><span>    result<span style="color:#f92672">.</span>append({
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#34;id&#34;</span>: u<span style="color:#f92672">.</span>id,
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#34;name&#34;</span>: u<span style="color:#f92672">.</span>name,
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#34;profile&#34;</span>: u<span style="color:#f92672">.</span>profile<span style="color:#f92672">.</span>bio,
</span></span><span style="display:flex;"><span>    })
</span></span></code></pre></td></tr></table>
</div>
</div><h3 id="22-改善例joinedloadselectinload">2.2 改善例（joinedload/selectinload）</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></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:#f92672">from</span> sqlalchemy.orm <span style="color:#f92672">import</span> selectinload
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>users <span style="color:#f92672">=</span> (
</span></span><span style="display:flex;"><span>    session<span style="color:#f92672">.</span>query(User)
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">.</span>options(selectinload(User<span style="color:#f92672">.</span>profile))
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">.</span>limit(<span style="color:#ae81ff">100</span>)
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">.</span>all()
</span></span><span style="display:flex;"><span>)
</span></span></code></pre></td></tr></table>
</div>
</div><p><code>joinedload</code> と <code>selectinload</code> はデータ量で使い分けます。</p>
<ul>
<li>1対1/少量: <code>joinedload</code></li>
<li>1対多/件数多め: <code>selectinload</code></li>
</ul>
<p>闇雲に <code>joinedload</code> を増やすと行爆発が起きるため、EXPLAINで確認しながら適用します。</p>
<h2 id="3-sqlalchemy-2xスタイルへ揃える">3. SQLAlchemy 2.xスタイルへ揃える</h2>
<p>旧query APIと新APIが混在すると可読性と最適化精度が落ちます。2.xスタイルへ統一しましょう。</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></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:#f92672">from</span> sqlalchemy <span style="color:#f92672">import</span> select
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>stmt <span style="color:#f92672">=</span> (
</span></span><span style="display:flex;"><span>    select(Order)
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">.</span>where(Order<span style="color:#f92672">.</span>status <span style="color:#f92672">==</span> <span style="color:#e6db74">&#34;paid&#34;</span>)
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">.</span>order_by(Order<span style="color:#f92672">.</span>created_at<span style="color:#f92672">.</span>desc())
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">.</span>limit(<span style="color:#ae81ff">50</span>)
</span></span><span style="display:flex;"><span>)
</span></span><span style="display:flex;"><span>orders <span style="color:#f92672">=</span> session<span style="color:#f92672">.</span>execute(stmt)<span style="color:#f92672">.</span>scalars()<span style="color:#f92672">.</span>all()
</span></span></code></pre></td></tr></table>
</div>
</div><p>この形式は、<code>EXPLAIN</code> の追跡や再利用がしやすく、レビュー品質も上がります。</p>
<h2 id="4-必要な列だけ取る過剰フェッチの削減">4. 必要な列だけ取る（過剰フェッチの削減）</h2>
<p>ORMは便利ですが、何も考えずモデル全体を取ると不要データまで転送されます。特にJSONカラムやTEXTが重い場合、ここが効きます。</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-python" data-lang="python"><span style="display:flex;"><span>stmt <span style="color:#f92672">=</span> select(User<span style="color:#f92672">.</span>id, User<span style="color:#f92672">.</span>name, User<span style="color:#f92672">.</span>email)<span style="color:#f92672">.</span>where(User<span style="color:#f92672">.</span>active<span style="color:#f92672">.</span>is_(<span style="color:#66d9ef">True</span>))
</span></span><span style="display:flex;"><span>rows <span style="color:#f92672">=</span> session<span style="color:#f92672">.</span>execute(stmt)<span style="color:#f92672">.</span>all()
</span></span></code></pre></td></tr></table>
</div>
</div><p>一覧APIはDTO用の軽量SELECTを使い、詳細APIでのみ重いカラムを取得する設計が安定します。</p>
<h2 id="5-接続プール設定を環境に合わせる">5. 接続プール設定を環境に合わせる</h2>
<p><code>pool_size</code> を適当に増やすだけでは逆効果です。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></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>engine <span style="color:#f92672">=</span> create_engine(
</span></span><span style="display:flex;"><span>    DB_URL,
</span></span><span style="display:flex;"><span>    pool_size<span style="color:#f92672">=</span><span style="color:#ae81ff">20</span>,
</span></span><span style="display:flex;"><span>    max_overflow<span style="color:#f92672">=</span><span style="color:#ae81ff">10</span>,
</span></span><span style="display:flex;"><span>    pool_timeout<span style="color:#f92672">=</span><span style="color:#ae81ff">10</span>,
</span></span><span style="display:flex;"><span>    pool_recycle<span style="color:#f92672">=</span><span style="color:#ae81ff">1800</span>,
</span></span><span style="display:flex;"><span>    pool_pre_ping<span style="color:#f92672">=</span><span style="color:#66d9ef">True</span>,
</span></span><span style="display:flex;"><span>)
</span></span></code></pre></td></tr></table>
</div>
</div><p>設計の目安:</p>
<ul>
<li>DB max_connections = 300</li>
<li>API Pod = 6</li>
<li>1 Podあたりpool_size 20</li>
</ul>
<p>この時点で理論最大120接続。バッチや管理接続も見込み、余白を残すのが安全です。</p>
<h2 id="6-トランザクション境界を短くする">6. トランザクション境界を短くする</h2>
<p>長いトランザクションはロック競合とスループット低下を招きます。</p>
<p>悪い例:</p>
<ol>
<li>DB更新</li>
<li>外部API呼び出し</li>
<li>メール送信</li>
<li>commit</li>
</ol>
<p>この順は危険です。外部I/Oをトランザクション外へ逃がします。</p>
<p>改善例:</p>
<ol>
<li>DB更新 + commit</li>
<li>外部通知は非同期ジョブで実行</li>
</ol>
<p>これだけで同時処理性能が目に見えて改善します。</p>
<h2 id="7-インデックス設計をapi単位で見直す">7. インデックス設計をAPI単位で見直す</h2>
<p>「インデックスはある」だけでは不足です。実際のWHERE + ORDER BYに合っているかが重要です。</p>
<p>例: 注文履歴API</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> id, 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> user_id <span style="color:#f92672">=</span> <span style="color:#960050;background-color:#1e0010">$</span><span style="color:#ae81ff">1</span> <span style="color:#66d9ef">AND</span> status <span style="color:#f92672">=</span> <span style="color:#e6db74">&#39;paid&#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>この場合、次の複合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">CREATE</span> <span style="color:#66d9ef">INDEX</span> CONCURRENTLY idx_orders_user_status_created_at_desc
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">ON</span> orders (user_id, status, created_at <span style="color:#66d9ef">DESC</span>);
</span></span></code></pre></td></tr></table>
</div>
</div><p>単独indexを乱立させるより、アクセスパターンに合わせた複合indexを厳選した方が効きます。</p>
<h2 id="8-キャッシュ導入は遅い理由の解決後に行う">8. キャッシュ導入は「遅い理由の解決後」に行う</h2>
<p>キャッシュは万能ではありません。N+1やスロークエリを放置したまま載せると、整合性事故の温床になります。</p>
<p>導入順序:</p>
<ol>
<li>SQL最適化</li>
<li>接続プール調整</li>
<li>必要なエンドポイントに限定してRedis cache</li>
</ol>
<p>キャッシュキーは <code>resource:id:version</code> 形式にし、更新時の無効化戦略を先に定義してください。</p>
<h2 id="9-負荷試験シナリオk6例">9. 負荷試験シナリオ（k6例）</h2>
<p>最適化の成果は負荷試験で確認します。最低3シナリオが必要です。</p>
<ul>
<li>steady: 通常トラフィック</li>
<li>burst: 短時間ピーク</li>
<li>soak: 長時間連続実行（リーク検知）</li>
</ul>
<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></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">http</span> <span style="color:#a6e22e">from</span> <span style="color:#e6db74">&#39;k6/http&#39;</span>;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">import</span> { <span style="color:#a6e22e">check</span>, <span style="color:#a6e22e">sleep</span> } <span style="color:#a6e22e">from</span> <span style="color:#e6db74">&#39;k6&#39;</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">export</span> <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">options</span> <span style="color:#f92672">=</span> {
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">stages</span><span style="color:#f92672">:</span> [
</span></span><span style="display:flex;"><span>    { <span style="color:#a6e22e">duration</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;2m&#39;</span>, <span style="color:#a6e22e">target</span><span style="color:#f92672">:</span> <span style="color:#ae81ff">50</span> },
</span></span><span style="display:flex;"><span>    { <span style="color:#a6e22e">duration</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;5m&#39;</span>, <span style="color:#a6e22e">target</span><span style="color:#f92672">:</span> <span style="color:#ae81ff">50</span> },
</span></span><span style="display:flex;"><span>    { <span style="color:#a6e22e">duration</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;1m&#39;</span>, <span style="color:#a6e22e">target</span><span style="color:#f92672">:</span> <span style="color:#ae81ff">200</span> },
</span></span><span style="display:flex;"><span>    { <span style="color:#a6e22e">duration</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;2m&#39;</span>, <span style="color:#a6e22e">target</span><span style="color:#f92672">:</span> <span style="color:#ae81ff">0</span> },
</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">export</span> <span style="color:#66d9ef">default</span> <span style="color:#66d9ef">function</span> () {
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">res</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">http</span>.<span style="color:#a6e22e">get</span>(<span style="color:#e6db74">&#39;https://api.example.com/orders?limit=50&#39;</span>);
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">check</span>(<span style="color:#a6e22e">res</span>, { <span style="color:#e6db74">&#39;status 200&#39;</span><span style="color:#f92672">:</span> (<span style="color:#a6e22e">r</span>) =&gt; <span style="color:#a6e22e">r</span>.<span style="color:#a6e22e">status</span> <span style="color:#f92672">===</span> <span style="color:#ae81ff">200</span> });
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">sleep</span>(<span style="color:#ae81ff">1</span>);
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></td></tr></table>
</div>
</div><p>改善前後で <code>p95</code>, SQL回数, DB CPU を比較し、定量で判断します。</p>
<h2 id="10-本番での改善手順テンプレート">10. 本番での改善手順テンプレート</h2>
<ol>
<li>ボトルネックendpointの特定</li>
<li>SQLログとEXPLAIN ANALYZE取得</li>
<li>N+1解消</li>
<li>必要列取得へ変更</li>
<li>インデックス追加（CONCURRENTLY）</li>
<li>pool設定調整</li>
<li>負荷試験再実施</li>
<li>段階リリース（10%→50%→100%）</li>
</ol>
<p>この手順を運用チーム全体でテンプレ化すると、パフォーマンス問題への対応速度が上がります。</p>
<h2 id="まとめ">まとめ</h2>
<p>FastAPI + SQLAlchemyの性能改善は、派手なテクニックより <strong>計測→原因分離→小さく改善</strong> の積み重ねが効きます。</p>
<ul>
<li>N+1解消</li>
<li>過剰フェッチ削減</li>
<li>接続プール最適化</li>
<li>インデックスの再設計</li>
<li>負荷試験で再検証</li>
</ul>
<p>この5点を回せば、遅いAPIは高確率で改善できます。まずは「1リクエストあたりのSQL発行数」を可視化するところから始めるのが最短です。</p>
<h2 id="付録-改善施策の優先順位最短で効く順">付録: 改善施策の優先順位（最短で効く順）</h2>
<p>時間が限られる現場では、まず「SQL発行回数削減 → インデックス最適化 → connection pool調整」の順で着手すると効果が出やすいです。特に、N+1解消だけでp95が半減するケースは珍しくありません。改善後は必ず同一負荷条件で再計測し、数字で効果を残しておくと、次の改善投資判断が通りやすくなります。</p>
]]></content:encoded>
      <category>Tech</category>
      <category>FastAPI</category>
      <category>SQLAlchemy</category>
      <category>PostgreSQL</category>
      <category>Python</category>
      <category>Performance</category>
    </item>
    <item>
      <title>PostgreSQL PITR復旧訓練ガイド: バックアップがあるのに戻せないを防ぐ実践手順</title>
      <link>https://www.ai2core.com/posts/2026-03-06-postgresql-pitr-drill-production-guide/</link>
      <pubDate>Fri, 06 Mar 2026 09:02:00 +0900</pubDate>
      <guid>https://www.ai2core.com/posts/2026-03-06-postgresql-pitr-drill-production-guide/</guid>
      <description>PostgreSQLのPoint-in-Time Recoveryを本番運用で成立させるために、バックアップ設計・復旧手順・訓練設計を具体例で解説。</description>
      <content:encoded><![CDATA[<h1 id="postgresql-pitr復旧訓練ガイド-バックアップがあるのに戻せないを防ぐ実践手順">PostgreSQL PITR復旧訓練ガイド: バックアップがあるのに戻せないを防ぐ実践手順</h1>
<p>PostgreSQL運用で最も危険なのは「バックアップがある」という安心感です。実際の障害では、バックアップ自体より <strong>復旧手順の不整合</strong> で時間を失います。たとえば、WAL保管期間が足りず目標時刻に戻せない、暗号鍵が見つからず復号できない、復旧後の整合性確認が曖昧で再開判断ができない、といった問題です。</p>
<p>本記事では、PostgreSQLの Point-in-Time Recovery（PITR）を、机上ではなく本番レベルで回すための実装手順を解説します。<code>pgBackRest</code> を例にしていますが、考え方は他ツールでも共通です。</p>
<h2 id="1-pitrの前提-3つ揃わないと復旧できない">1. PITRの前提: 3つ揃わないと復旧できない</h2>
<p>PITRは次の3要素で成立します。</p>
<ol>
<li><strong>ベースバックアップ</strong>（フルまたは差分）</li>
<li><strong>WALアーカイブ</strong>（継続的）</li>
<li><strong>目標時刻情報</strong>（いつまで戻すか）</li>
</ol>
<p>どれか1つでも欠けると成立しません。特に本番で多いのは「WALが途中で消えていた」ケースです。S3保存していても、ライフサイクル設定や権限変更で欠落することがあります。</p>
<h2 id="2-まず決めるべきrtorpo">2. まず決めるべきRTO/RPO</h2>
<p>技術論の前に、業務要件を決めます。</p>
<ul>
<li><strong>RTO</strong>（復旧に許容される時間）: 例 60分</li>
<li><strong>RPO</strong>（失ってよいデータ時間）: 例 5分</li>
</ul>
<p>この2つで設計が変わります。</p>
<ul>
<li>RPO 5分以内ならWALアーカイブ遅延監視が必須</li>
<li>RTO 60分以内なら復旧訓練を定期実施し、手順を自動化する必要あり</li>
</ul>
<p>要件不明のまま「毎日バックアップ」だけ実施しても、障害時に役立たないことが多いです。</p>
<h2 id="3-推奨アーキテクチャ単一リージョンの最小構成">3. 推奨アーキテクチャ（単一リージョンの最小構成）</h2>
<ul>
<li>DBサーバ: PostgreSQL 15/16</li>
<li>バックアップツール: pgBackRest</li>
<li>保存先: S3互換ストレージ（バージョニングON）</li>
<li>監視: Prometheus + Alertmanager</li>
<li>復旧先: 別ホスト（本番と同一ネットワーク）</li>
</ul>
<p>重要なのは、<strong>本番DBと別ホストで実際に復旧できること</strong> を定期検証する点です。</p>
<h2 id="4-実装手順pgbackrest">4. 実装手順（pgBackRest）</h2>
<h3 id="41-postgresql設定">4.1 PostgreSQL設定</h3>
<p><code>postgresql.conf</code> 例:</p>
<pre tabindex="0"><code class="language-conf" data-lang="conf">wal_level = replica
archive_mode = on
archive_command = &#39;pgbackrest --stanza=main archive-push %p&#39;
max_wal_senders = 10
wal_compression = on
</code></pre><p><code>archive_command</code> は失敗時に非0を返す必要があります。ここが曖昧だとWAL欠落に気づけません。</p>
<h3 id="42-pgbackrest設定">4.2 pgBackRest設定</h3>
<p><code>/etc/pgbackrest/pgbackrest.conf</code> 例:</p>
<pre tabindex="0"><code class="language-conf" data-lang="conf">[global]
repo1-type=s3
repo1-path=/pgbackrest
repo1-s3-bucket=prod-db-backup
repo1-s3-endpoint=s3.ap-northeast-1.amazonaws.com
repo1-s3-region=ap-northeast-1
repo1-retention-full=14
start-fast=y
process-max=4
compress-type=zst

[main]
pg1-path=/var/lib/postgresql/16/main
</code></pre><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></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>pgbackrest --stanza<span style="color:#f92672">=</span>main stanza-create
</span></span><span style="display:flex;"><span>pgbackrest --stanza<span style="color:#f92672">=</span>main --type<span style="color:#f92672">=</span>full backup
</span></span></code></pre></td></tr></table>
</div>
</div><h3 id="43-wal到達の監視">4.3 WAL到達の監視</h3>
<ul>
<li><code>pg_stat_archiver</code> の <code>failed_count</code></li>
<li>最終成功時刻と現在時刻の差分</li>
<li>repoの最新WALタイムスタンプ</li>
</ul>
<p>例: 10分以上WALが更新されない場合にCriticalアラート。</p>
<h2 id="5-復旧訓練drillを標準化する">5. 復旧訓練（Drill）を標準化する</h2>
<p>運用で差が出るのはここです。訓練の目的は「復旧できること」ではなく、<strong>予測可能な時間で安全に復旧できること</strong> です。</p>
<h3 id="51-月次ドリル手順テンプレート">5.1 月次ドリル手順（テンプレート）</h3>
<ol>
<li>目標時刻を決める（例: 当日 08:35:00 JST）</li>
<li>復旧専用ホストを初期化</li>
<li>バックアップ + WALからリストア</li>
<li>DB起動後、整合性チェックを実行</li>
<li>アプリのスモークテストを実行</li>
<li>RTO実測値と課題を記録</li>
</ol>
<h3 id="52-実コマンド例">5.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></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"># 復旧先でデータディレクトリを準備</span>
</span></span><span style="display:flex;"><span>systemctl stop postgresql
</span></span><span style="display:flex;"><span>rm -rf /var/lib/postgresql/16/main/*
</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>pgbackrest --stanza<span style="color:#f92672">=</span>main <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span><span style="color:#ae81ff"></span>  --type<span style="color:#f92672">=</span>time <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span><span style="color:#ae81ff"></span>  --target<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;2026-03-06 08:35:00+09&#34;</span> <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span><span style="color:#ae81ff"></span>  --delta <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span><span style="color:#ae81ff"></span>  restore
</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>systemctl start postgresql
</span></span></code></pre></td></tr></table>
</div>
</div><p>PostgreSQL 12+では <code>recovery.conf</code> ではなく <code>postgresql.auto.conf</code> + <code>standby.signal</code> 形式に変わっている点に注意してください。</p>
<h2 id="6-復旧後の整合性チェック項目">6. 復旧後の整合性チェック項目</h2>
<p>「起動したからOK」は危険です。最低限、次を確認します。</p>
<ul>
<li>主要テーブル件数（基準値との差分）</li>
<li>直近トランザクション時刻</li>
<li>重要集計値（売上、注文、在庫など）</li>
<li>アプリの read/write スモークテスト</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></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> now();
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">SELECT</span> <span style="color:#66d9ef">max</span>(created_at) <span style="color:#66d9ef">FROM</span> orders;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">SELECT</span> <span style="color:#66d9ef">count</span>(<span style="color:#f92672">*</span>) <span style="color:#66d9ef">FROM</span> users;
</span></span></code></pre></td></tr></table>
</div>
</div><p>件数だけでなく「業務上重要な指標」を入れるのが実務的です。</p>
<h2 id="7-失敗しがちなポイントと対策">7. 失敗しがちなポイントと対策</h2>
<h3 id="71-wal保持期間が短すぎる">7.1 WAL保持期間が短すぎる</h3>
<ul>
<li>対策: RPO/RTOに基づき、最低でも7〜14日保持</li>
<li>削除はバックアップ整合性確認後に実行</li>
</ul>
<h3 id="72-暗号鍵認証情報の保管ミス">7.2 暗号鍵・認証情報の保管ミス</h3>
<ul>
<li>対策: KMS/Secret Managerに分離保管</li>
<li>緊急時アクセス手順をRunbook化</li>
</ul>
<h3 id="73-手順が個人依存">7.3 手順が個人依存</h3>
<ul>
<li>対策: RunbookをGit管理</li>
<li>ドリル時に“当番以外”が実行して再現性を検証</li>
</ul>
<h3 id="74-復旧はできるが遅すぎる">7.4 復旧はできるが遅すぎる</h3>
<ul>
<li>対策: 差分バックアップ頻度見直し</li>
<li>復旧先マシンスペックの最低保証</li>
<li>リストア並列度（<code>process-max</code>）調整</li>
</ul>
<h2 id="8-自動化の実装例ciでドリルを回す">8. 自動化の実装例（CIでドリルを回す）</h2>
<p>本番同等データを使えない場合は、匿名化済みスナップショットで定期検証します。</p>
<ul>
<li>毎週日曜 03:00 に復旧ジョブ起動</li>
<li>復旧後にSQLチェック + APIスモーク</li>
<li>結果をSlack/Discordへ通知</li>
<li>失敗時は翌営業日のSRE定例でレビュー</li>
</ul>
<p>この「半自動ドリル」を導入すると、障害時の初動が劇的に安定します。</p>
<h2 id="9-監査対応のための証跡">9. 監査対応のための証跡</h2>
<p>監査や顧客説明で必要になるのは、次の証跡です。</p>
<ul>
<li>バックアップ成功ログ（日次）</li>
<li>WAL連続性の証跡</li>
<li>復旧ドリルの実行記録（日時、担当、RTO実測）</li>
<li>改善アクション履歴</li>
</ul>
<p>「やっています」ではなく「この月にこの結果でした」と示せる状態を作っておきましょう。</p>
<h2 id="10-現場向けチェックリスト">10. 現場向けチェックリスト</h2>
<ul>
<li><input disabled="" type="checkbox"> フルバックアップ成功率 99%以上</li>
<li><input disabled="" type="checkbox"> WAL遅延アラートが有効</li>
<li><input disabled="" type="checkbox"> 目標時刻指定の復旧手順がRunbook化</li>
<li><input disabled="" type="checkbox"> 月次ドリル実施済み</li>
<li><input disabled="" type="checkbox"> 復旧後整合性チェックSQLが整備済み</li>
<li><input disabled="" type="checkbox"> KMS/鍵管理手順が文書化済み</li>
</ul>
<p>このチェックリストを満たして初めて「PITR対応」と言えます。</p>
<h2 id="まとめ">まとめ</h2>
<p>PITRは設定項目の話ではなく、<strong>復旧可能性を継続的に検証する運用</strong> です。</p>
<ul>
<li>RTO/RPOを先に決める</li>
<li>バックアップ + WAL + 監視をセットで設計する</li>
<li>月次ドリルで実測し、Runbookを改善する</li>
<li>復旧後整合性チェックまで標準化する</li>
</ul>
<p>バックアップがあることはゴールではありません。障害時に「何分で、どこまで戻せるか」を言える状態こそが、プロダクション品質です。</p>
<h2 id="付録-実運用で使えるpitrドリル手順90分版">付録: 実運用で使えるPITRドリル手順（90分版）</h2>
<p>以下は、現場でそのまま回せる最小ドリル手順です。月次で固定化すると、障害対応の心理的負荷を下げられます。</p>
<ol>
<li><strong>開始宣言（5分）</strong>
<ul>
<li>担当者、開始時刻、目標RTO/RPOをチケットに記録</li>
</ul>
</li>
<li><strong>復旧環境準備（15分）</strong>
<ul>
<li>復旧先PostgreSQLを起動</li>
<li>バックアップ世代と目標復旧時刻（例: 10:32:00 JST）を確定</li>
</ul>
</li>
<li><strong>リストア実行（25分）</strong>
<ul>
<li><code>pg_restore</code> もしくはベースバックアップ展開</li>
<li>WAL適用完了ログを保存</li>
</ul>
</li>
<li><strong>整合性検証（20分）</strong>
<ul>
<li>件数チェックSQL、外部キー整合性、直近注文IDの連続性を確認</li>
<li>APIスモーク（ログイン/一覧/作成）を実施</li>
</ul>
</li>
<li><strong>振り返り（25分）</strong>
<ul>
<li>実測RTO/RPOを記録</li>
<li>失敗ポイントをRunbookに即反映</li>
</ul>
</li>
</ol>
<p>重要なのは「成功したか」だけではなく、<strong>どこで何分消費したか</strong>を毎回残すことです。これを3回続けるだけで、復旧手順のボトルネックがかなり明確になります。</p>
]]></content:encoded>
      <category>Tech</category>
      <category>PostgreSQL</category>
      <category>Backup</category>
      <category>PITR</category>
      <category>SRE</category>
      <category>Disaster Recovery</category>
    </item>
    <item>
      <title>PostgreSQL接続プール枯渇の実戦対処：再発防止までつなげる調査・改善プレイブック</title>
      <link>https://www.ai2core.com/posts/2026-03-05-postgresql-connection-pool-exhaustion-playbook/</link>
      <pubDate>Thu, 05 Mar 2026 09:12:00 +0900</pubDate>
      <guid>https://www.ai2core.com/posts/2026-03-05-postgresql-connection-pool-exhaustion-playbook/</guid>
      <description>本番で頻発するPostgreSQL接続枯渇を、発生時の初動から原因切り分け、設定改善、監視強化まで具体手順で解説。</description>
      <content:encoded><![CDATA[<h1 id="postgresql接続プール枯渇の実戦対処再発防止までつなげる調査改善プレイブック">PostgreSQL接続プール枯渇の実戦対処：再発防止までつなげる調査・改善プレイブック</h1>
<p>本番障害でよくあるのが、<code>too many clients already</code> や <code>remaining connection slots are reserved</code> です。アプリ側から見ると「急にDBに繋がらない」、ユーザー側から見ると「全機能が遅い・失敗する」という最悪の体験になります。</p>
<p>厄介なのは、接続枯渇が「DBサーバー性能不足」だけで起こるわけではない点です。リーク、タイムアウト設定、長時間トランザクション、プールサイズ不整合など、複数要因が重なって起きます。</p>
<p>この記事では、接続枯渇に対して <strong>発生時の初動 → 根本原因の特定 → 恒久対策</strong> の順で、手順を実務レベルでまとめます。</p>
<h2 id="1-まず初動サービス継続を優先する">1. まず初動：サービス継続を優先する</h2>
<p>障害対応では、完璧な原因究明より「止血」が先です。以下を順番に実施します。</p>
<ol>
<li>直近リリース有無を確認（機能フラグ含む）</li>
<li>アプリの接続数・待機数・エラー率を確認</li>
<li>DB側で <code>pg_stat_activity</code> を取得</li>
<li>長時間実行クエリを必要に応じて停止</li>
<li>一時的にアプリ Pod 数を制限して雪だるま増幅を止める</li>
</ol>
<p><code>pg_stat_activity</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></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>  pid,
</span></span><span style="display:flex;"><span>  usename,
</span></span><span style="display:flex;"><span>  application_name,
</span></span><span style="display:flex;"><span>  client_addr,
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">state</span>,
</span></span><span style="display:flex;"><span>  wait_event_type,
</span></span><span style="display:flex;"><span>  wait_event,
</span></span><span style="display:flex;"><span>  now() <span style="color:#f92672">-</span> query_start <span style="color:#66d9ef">AS</span> query_duration,
</span></span><span style="display:flex;"><span>  now() <span style="color:#f92672">-</span> xact_start  <span style="color:#66d9ef">AS</span> xact_duration,
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">left</span>(query, <span style="color:#ae81ff">120</span>) <span style="color:#66d9ef">AS</span> query_head
</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> 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> xact_start NULLS <span style="color:#66d9ef">LAST</span>, query_start NULLS <span style="color:#66d9ef">LAST</span>;
</span></span></code></pre></td></tr></table>
</div>
</div><p>ここで見るべきは、<code>state='idle in transaction'</code> と異常に長い <code>xact_duration</code> です。これがあるとコネクションを握ったまま解放されず、枯渇の引き金になります。</p>
<h2 id="2-典型原因を4パターンで切り分ける">2. 典型原因を4パターンで切り分ける</h2>
<h3 id="パターンa-アプリ接続プールサイズが過大">パターンA: アプリ接続プールサイズが過大</h3>
<p>よくあるのが、以下のような構成です。</p>
<ul>
<li>Pod 20個</li>
<li>各Podのプール max 20</li>
<li>理論最大接続 400</li>
<li>PostgreSQL <code>max_connections=300</code></li>
</ul>
<p>この時点で設計破綻です。さらに管理接続やメンテ接続を引くと余裕ゼロになります。</p>
<p><strong>対策:</strong></p>
<ul>
<li>プールサイズ設計は「全インスタンス合計」で管理</li>
<li><code>max_connections</code> の70〜80%以内に通常運用を収める</li>
<li>ピーク時はワーカ数/Pod数で制御</li>
</ul>
<h3 id="パターンb-コネクションリーク">パターンB: コネクションリーク</h3>
<p><code>finally</code> で close していない、ORMセッションの寿命が長い、例外時に返却されない、といった実装ミスです。</p>
<p>Python（SQLAlchemy）の悪い例:</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-python" data-lang="python"><span style="display:flex;"><span>session <span style="color:#f92672">=</span> SessionLocal()
</span></span><span style="display:flex;"><span>user <span style="color:#f92672">=</span> session<span style="color:#f92672">.</span>query(User)<span style="color:#f92672">.</span>filter(User<span style="color:#f92672">.</span>id <span style="color:#f92672">==</span> user_id)<span style="color:#f92672">.</span>first()
</span></span><span style="display:flex;"><span><span style="color:#75715e"># 例外時にcloseされない可能性</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">return</span> user
</span></span></code></pre></td></tr></table>
</div>
</div><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><span 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-python" data-lang="python"><span style="display:flex;"><span><span style="color:#f92672">from</span> contextlib <span style="color:#f92672">import</span> contextmanager
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">@contextmanager</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">get_session</span>():
</span></span><span style="display:flex;"><span>    session <span style="color:#f92672">=</span> SessionLocal()
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">try</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">yield</span> session
</span></span><span style="display:flex;"><span>        session<span style="color:#f92672">.</span>commit()
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">except</span> <span style="color:#a6e22e">Exception</span>:
</span></span><span style="display:flex;"><span>        session<span style="color:#f92672">.</span>rollback()
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">raise</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">finally</span>:
</span></span><span style="display:flex;"><span>        session<span style="color:#f92672">.</span>close()
</span></span></code></pre></td></tr></table>
</div>
</div><p>FastAPI なら dependency でセッションスコープを統一し、endpointごとに確実に返却させるのが安全です。</p>
<h3 id="パターンc-長時間トランザクション">パターンC: 長時間トランザクション</h3>
<p>バッチ処理で 1トランザクションに大量更新を詰め込むと、ロック競合と接続占有が同時発生します。</p>
<p><strong>対策:</strong></p>
<ul>
<li>バッチをチャンク分割（例: 500件単位）</li>
<li><code>statement_timeout</code>、<code>idle_in_transaction_session_timeout</code> を設定</li>
<li>長時間処理はキュー化して非同期実行</li>
</ul>
<h3 id="パターンd-プールとdbのタイムアウト不一致">パターンD: プールとDBのタイムアウト不一致</h3>
<p>アプリの接続再利用時間が長すぎると、DB側で切断済み接続を使って失敗し、リトライで更に接続圧を上げます。</p>
<p><strong>対策:</strong></p>
<ul>
<li>プールの <code>maxLifetime</code> を DB/NLB timeout より短く</li>
<li>接続取得待ち時間（acquire timeout）を短くし、早めに失敗させる</li>
<li>失敗時リトライは指数バックオフ + ジッター</li>
</ul>
<h2 id="3-具体的な設定例pgbouncer--postgresql">3. 具体的な設定例（PgBouncer + PostgreSQL）</h2>
<p>高トラフィック環境では、アプリ直結より PgBouncer を挟むのが安定します。</p>
<p><code>pgbouncer.ini</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></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-ini" data-lang="ini"><span style="display:flex;"><span><span style="color:#66d9ef">[pgbouncer]</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">listen_addr</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">0.0.0.0</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">listen_port</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">6432</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">auth_type</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">md5</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">pool_mode</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">transaction</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">max_client_conn</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">5000</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">default_pool_size</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">80</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">reserve_pool_size</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">20</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">server_reset_query</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">DISCARD ALL</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">server_idle_timeout</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">30</span>
</span></span></code></pre></td></tr></table>
</div>
</div><p>PostgreSQL 側の最小設定例:</p>
<pre tabindex="0"><code class="language-conf" data-lang="conf">max_connections = 300
shared_buffers = 4GB
idle_in_transaction_session_timeout = 60000
statement_timeout = 30000
log_min_duration_statement = 1000
</code></pre><p><code>pool_mode=transaction</code> は接続効率が高い一方、セッション依存機能（一時テーブルや session variable）の扱いに注意が必要です。導入前に該当クエリを洗い出してください。</p>
<h2 id="4-kubernetes運用での落とし穴">4. Kubernetes運用での落とし穴</h2>
<p>Kubernetes では、HPA がスケールアウトすると同時に接続数が急増しやすいです。</p>
<h3 id="41-設計式を最初に決める">4.1 設計式を最初に決める</h3>
<p><code>最大接続見積もり = maxPods × perPodPoolMax + 管理余白</code></p>
<p>例:</p>
<ul>
<li>maxPods=30</li>
<li>perPodPoolMax=8</li>
<li>管理余白=30</li>
<li>合計 270 → <code>max_connections=320</code> なら許容</li>
</ul>
<p>この計算を IaC へコメントで残しておくと、後任が壊しにくくなります。</p>
<h3 id="42-readiness-に-db接続必須チェックを入れすぎない">4.2 readiness に DB接続必須チェックを入れすぎない</h3>
<p>Pod起動時に全Podが同時にDBへ接続テストすると、再起動時にスパイクが起きます。readiness は軽量化し、重い初期化はバックグラウンドへ逃がすのが無難です。</p>
<h2 id="5-監視項目再発防止の最低ライン">5. 監視項目：再発防止の最低ライン</h2>
<p>以下を可視化し、閾値アラートを設定します。</p>
<ul>
<li><code>numbackends / max_connections</code></li>
<li>接続待ち時間（アプリメトリクス）</li>
<li><code>idle in transaction</code> セッション数</li>
<li>95/99パーセンタイルのクエリ時間</li>
<li>DBエラー率（connection refused, timeout）</li>
</ul>
<p>推奨アラート例:</p>
<ul>
<li><code>numbackends &gt; 85%</code> が 5分継続で Warning</li>
<li><code>numbackends &gt; 92%</code> が 2分継続で Critical</li>
<li><code>idle in transaction &gt; 10</code> が 3分継続で調査開始</li>
</ul>
<h2 id="6-障害後レビューポストモーテムで必ず決めること">6. 障害後レビュー（ポストモーテム）で必ず決めること</h2>
<p>「接続が足りませんでした」で終えると再発します。次の観点を明文化します。</p>
<ol>
<li>なぜ早期検知できなかったか</li>
<li>どの設定が設計値と乖離していたか</li>
<li>コード修正・設定修正・監視修正の担当と期限</li>
<li>次回同様インシデント時の runbook 更新点</li>
</ol>
<p>運用改善は「学びをコード化する」ことです。ドキュメントだけでなく、Terraform や Helm values に反映して初めて再発率が下がります。</p>
<h2 id="7-実務向けチェックリスト">7. 実務向けチェックリスト</h2>
<p>最後に、現場で使いやすいチェックリストを置いておきます。</p>
<ul>
<li><input disabled="" type="checkbox"> <code>max_connections</code> と総プール上限の関係を計算済み</li>
<li><input disabled="" type="checkbox"> 接続リーク防止（close/return保証）が実装されている</li>
<li><input disabled="" type="checkbox"> 長時間トランザクションに timeout が設定済み</li>
<li><input disabled="" type="checkbox"> PgBouncer の pool_mode が要件に適合している</li>
<li><input disabled="" type="checkbox"> <code>numbackends</code> と接続待ち時間のアラートがある</li>
<li><input disabled="" type="checkbox"> 障害時 runbook に SQL コマンドと判断基準が書かれている</li>
</ul>
<h2 id="まとめ">まとめ</h2>
<p>PostgreSQL 接続枯渇は、単なるパラメータ不足ではなく、アプリ実装・スケーリング設計・監視不足が重なって起こる複合障害です。だからこそ、</p>
<ul>
<li>初動で止血する</li>
<li>パターン別に切り分ける</li>
<li>設定と実装を同時に直す</li>
<li>監視と runbook に落とし込む</li>
</ul>
<p>この流れを徹底するだけで、同じ障害の再発率は大きく下げられます。次に障害が起きたとき、慌てず順番に潰せる状態を今日作っておくのが最善です。</p>
]]></content:encoded>
      <category>Tech</category>
      <category>PostgreSQL</category>
      <category>Performance</category>
      <category>SRE</category>
      <category>Troubleshooting</category>
      <category>Backend</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>
