<?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>Query-Optimizer on Tarragon</title><link>https://tarrragon.github.io/blog/tags/query-optimizer/</link><description>Recent content in Query-Optimizer on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Wed, 02 Sep 2026 00:00:00 +0800</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/query-optimizer/index.xml" rel="self" type="application/rss+xml"/><item><title>Cardinality 與 Selectivity（基數與選擇率）</title><link>https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/cardinality-and-selectivity/</link><pubDate>Wed, 02 Sep 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/cardinality-and-selectivity/</guid><description>&lt;p>基數是一個集合裡有幾個成員。這個詞在 SQL 裡落在兩個地方：一份 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/relation/" data-link-title="Relation（關係）" data-link-desc="看到 SQL 教材說「組成一個關係」而不確定它指的是不是資料表時">relation&lt;/a> 的基數是它有幾列，一個欄位的基數是它有幾個相異值。兩者是同一個定義用在不同的集合上——欄位的基數就是把那一欄單獨取出來、去掉重複之後的列數。&lt;/p>
&lt;p>&lt;strong>「基數」這個譯名在站內還有另外三個所指。&lt;/strong> 資料建模那一側說的是兩張表之間的關係屬於一對一、一對多還是多對多；查詢結果那一側說的是一段查詢回幾列（&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/cardinality-explosion/" data-link-title="Query Cardinality Explosion" data-link-desc="Query 結果行數因 join / cross product / 條件缺失爆炸性放大的反模式">Cardinality Explosion&lt;/a>）；監控那一側說的是一個指標有幾種標籤組合。本卡與最佳化器讀的都是上一段那個計數義，其餘幾個只是共用一個譯名。&lt;/p>
&lt;p>選擇率是一個條件留下多少比例的列。它是條件的屬性而非欄位的屬性：&lt;code>狀態 = '已完成'&lt;/code> 與 &lt;code>狀態 = '已取消'&lt;/code> 寫在同一欄上，而兩者的選擇率可以差一個量級以上——下一節那組資料裡是十八倍。&lt;/p>
&lt;p>兩者在等值條件上有一個關係：值分布均勻的時候，&lt;code>欄 = 某值&lt;/code> 的選擇率大約是相異值個數的倒數。分布不均勻的時候這個估算失準，而那正是&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/query-statistics/" data-link-title="Query Statistics（統計資訊）" data-link-desc="看到「先跑 ANALYZE」、或同一段查詢沒改而某天變慢時，查這一份資料是什麼、誰在用它">統計資訊&lt;/a>要另外記下最常出現的幾個值的原因。&lt;/p>
&lt;h2 id="同一個索引最佳化器對不同的值做相反的決定">同一個索引，最佳化器對不同的值做相反的決定&lt;/h2>
&lt;p>二十萬列的訂單表，&lt;code>狀態&lt;/code> 只有兩個值（已完成佔九成五、已取消佔半成），&lt;code>顧客編號&lt;/code> 有五萬個值。兩欄各建一個索引，跑過 &lt;code>ANALYZE&lt;/code>（收集統計的那道指令）之後問同一種等值條件：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">-- PostgreSQL 18
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">SELECT 備註 FROM 訂單 WHERE 狀態 = &amp;#39;已完成&amp;#39;; -- Seq Scan（索引沒被用）
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">SELECT 備註 FROM 訂單 WHERE 狀態 = &amp;#39;已取消&amp;#39;; -- Index Scan using ix狀態
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">SELECT 備註 FROM 訂單 WHERE 顧客編號 = 12345; -- Index Scan using ix顧客&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>同一個 &lt;code>ix狀態&lt;/code>，同一種條件形狀，兩個值得到相反的決定。走索引要先讀索引拿到位置、再回原表取 &lt;code>備註&lt;/code> 這一欄，每一列付兩次；命中九成五的列時，這筆帳輸給從頭讀一遍。&lt;/p>
&lt;p>引擎的依據寫在統計裡，而那份摘要是看得到的：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">SELECT attname, n_distinct, most_common_freqs FROM pg_stats WHERE tablename = &amp;#39;訂單&amp;#39;;
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> 狀態 | 2 | {0.94996667,0.050033335}
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> 顧客編號 | -0.25113 |&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>n_distinct&lt;/code> 是基數，&lt;code>most_common_freqs&lt;/code> 是那幾個值各自的選擇率。&lt;strong>這兩欄是抽樣估出來的，每跑一次 &lt;code>ANALYZE&lt;/code> 都會在小數後幾位變動&lt;/strong>——真值是 0.95 與 0.05，而印出來的從來不是整數。&lt;code>顧客編號&lt;/code> 那一格的負數是另一種寫法——絕對值小於一時代表「相異值個數是總列數的這個比例」，0.25 乘二十萬約等於五萬。&lt;/p>
&lt;p>&lt;strong>這兩個數是估計而不是事實。&lt;/strong> 它們來自上一次收集統計的那一刻，所以同一段查詢在資料變動之後可能換一個計畫，而查詢的文字一個字都沒改（&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/query-statistics/" data-link-title="Query Statistics（統計資訊）" data-link-desc="看到「先跑 ANALYZE」、或同一段查詢沒改而某天變慢時，查這一份資料是什麼、誰在用它">Query Statistics&lt;/a>）。&lt;/p>
&lt;h2 id="同一份統計各家的取捨不同">同一份統計，各家的取捨不同&lt;/h2>
&lt;p>上面那組表搬到 SQLite 3.51，&lt;code>ANALYZE&lt;/code> 跑過之後三段查詢全部走索引，包含命中九成五的那一段。SQLite 的 &lt;code>sqlite_stat1&lt;/code> 記下的數字與 PostgreSQL 量到的是同一件事：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">訂單 / ix狀態 → 200000 100000 -- 二十萬列，每個相異值平均十萬列
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">訂單 / ix顧客 → 200000 4 -- 二十萬列，每個相異值平均四列&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>所以「基數低的欄位上索引沒用」這句話要分兩半聽：低基數確實讓多數值的選擇率變高，而&lt;strong>要不要因此放棄索引是最佳化器的判斷，各家的門檻不同&lt;/strong>。可攜的說法是選擇率，不是計畫。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>&lt;strong>這兩關的成本模型建在一個前提上：資料按列存放，而索引是另一份按鍵排好的結構，走它要回原表取其餘欄位。&lt;/strong> 欄式引擎（DuckDB、以及讀 parquet 這一類）不是這個形狀——它按欄讀、靠區塊的最大最小值跳過整段，沒有逐列回表那筆帳，所以「命中九成五就別走索引」在那裡不成立，對應的手段是資料的排序與分區。下面兩關針對的是前一種。&lt;/p>
&lt;p>選擇率與 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/sargable/" data-link-title="Sargable（可走索引的條件形狀）" data-link-desc="索引存在而計畫仍走全表掃描時，查條件的寫法有沒有擋住那個索引">sargable&lt;/a> 是索引派不派得上用場的兩個獨立條件。sargable 問的是條件的&lt;strong>形狀&lt;/strong>能不能翻成索引上的一次查找——欄位被函式包住就翻不了。&lt;strong>這一關過不了不等於索引沒用&lt;/strong>：條件裡用到的欄位都在索引上的時候，引擎仍然可能整個掃過索引再逐列過濾，省下的是回原表那筆帳（PostgreSQL 印成 &lt;code>Index Only Scan&lt;/code> 加一個 &lt;code>Filter&lt;/code>，SQLite 印成 &lt;code>SCAN ... USING COVERING INDEX&lt;/code>）。所以兩關管的是不同的索引用法，而不是同一種用法的兩道閘。選擇率問的是通過查找之後&lt;strong>還剩多少列&lt;/strong>——形狀對而留下的列佔了大半，最佳化器仍然會選擇不走它。兩關都過，&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/indexing/" data-link-title="Index（索引）" data-link-desc="看到執行計畫寫著走索引或建索引、想知道那是什麼時">索引&lt;/a>才換得到速度。&lt;/p>
&lt;p>基數也決定連接之後的列數。低基數的欄位當連接鍵時，一列會配到對面同一個值的所有列，而那個數量隨表一起長——這是&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/cartesian-product/" data-link-title="Cartesian Product（笛卡兒積）" data-link-desc="看到 CROSS JOIN、或結果的列數遠多於預期時，查這個運算什麼時候會被展開">笛卡兒積&lt;/a>那張卡分辨「積」與「膨脹」時用的同一個量。&lt;/p>
&lt;h2 id="往下走">往下走&lt;/h2>
&lt;p>這兩個數怎麼被收集、多久失效一次，在 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/query-statistics/" data-link-title="Query Statistics（統計資訊）" data-link-desc="看到「先跑 ANALYZE」、或同一段查詢沒改而某天變慢時，查這一份資料是什麼、誰在用它">Query Statistics（統計資訊）&lt;/a>。&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/query-optimizer/" data-link-title="Query Optimizer（查詢最佳化器）" data-link-desc="想知道是誰決定了執行順序、或為什麼同一段查詢換個時間變慢時">query optimizer&lt;/a> 拿它們估代價的過程，以及估錯時計畫長什麼樣，在 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/query-plan/" data-link-title="Query Plan（執行計畫）" data-link-desc="想知道一段查詢實際會怎麼被執行、或被要求「先看計畫」時">Query Plan（執行計畫）&lt;/a>。同一道題三種寫法的排名怎麼隨索引重排，在 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/cost-lives-in-the-plan/" data-link-title="SQL.17 查詢的代價：資料分布、索引與寫法各自的影響" data-link-desc="找重複值的分組、自連接與 EXISTS 在有無索引與不同重複次數下的實測時間與計畫，以及比較查詢快慢時要補上的資料量、分布、索引與統計資訊">SQL.17 查詢的代價&lt;/a>。production 上結果集大小怎麼治理，在 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/cardinality-explosion/" data-link-title="Query Cardinality Explosion" data-link-desc="Query 結果行數因 join / cross product / 條件缺失爆炸性放大的反模式">Cardinality Explosion&lt;/a>。&lt;/p></description><content:encoded><![CDATA[<p>基數是一個集合裡有幾個成員。這個詞在 SQL 裡落在兩個地方：一份 <a href="/blog/backend/01-database/sql/knowledge-cards/relation/" data-link-title="Relation（關係）" data-link-desc="看到 SQL 教材說「組成一個關係」而不確定它指的是不是資料表時">relation</a> 的基數是它有幾列，一個欄位的基數是它有幾個相異值。兩者是同一個定義用在不同的集合上——欄位的基數就是把那一欄單獨取出來、去掉重複之後的列數。</p>
<p><strong>「基數」這個譯名在站內還有另外三個所指。</strong> 資料建模那一側說的是兩張表之間的關係屬於一對一、一對多還是多對多；查詢結果那一側說的是一段查詢回幾列（<a href="/blog/backend/knowledge-cards/cardinality-explosion/" data-link-title="Query Cardinality Explosion" data-link-desc="Query 結果行數因 join / cross product / 條件缺失爆炸性放大的反模式">Cardinality Explosion</a>）；監控那一側說的是一個指標有幾種標籤組合。本卡與最佳化器讀的都是上一段那個計數義，其餘幾個只是共用一個譯名。</p>
<p>選擇率是一個條件留下多少比例的列。它是條件的屬性而非欄位的屬性：<code>狀態 = '已完成'</code> 與 <code>狀態 = '已取消'</code> 寫在同一欄上，而兩者的選擇率可以差一個量級以上——下一節那組資料裡是十八倍。</p>
<p>兩者在等值條件上有一個關係：值分布均勻的時候，<code>欄 = 某值</code> 的選擇率大約是相異值個數的倒數。分布不均勻的時候這個估算失準，而那正是<a href="/blog/backend/01-database/sql/knowledge-cards/query-statistics/" data-link-title="Query Statistics（統計資訊）" data-link-desc="看到「先跑 ANALYZE」、或同一段查詢沒改而某天變慢時，查這一份資料是什麼、誰在用它">統計資訊</a>要另外記下最常出現的幾個值的原因。</p>
<h2 id="同一個索引最佳化器對不同的值做相反的決定">同一個索引，最佳化器對不同的值做相反的決定</h2>
<p>二十萬列的訂單表，<code>狀態</code> 只有兩個值（已完成佔九成五、已取消佔半成），<code>顧客編號</code> 有五萬個值。兩欄各建一個索引，跑過 <code>ANALYZE</code>（收集統計的那道指令）之後問同一種等值條件：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">-- PostgreSQL 18
</span></span><span class="line"><span class="ln">2</span><span class="cl">SELECT 備註 FROM 訂單 WHERE 狀態 = &#39;已完成&#39;;     -- Seq Scan（索引沒被用）
</span></span><span class="line"><span class="ln">3</span><span class="cl">SELECT 備註 FROM 訂單 WHERE 狀態 = &#39;已取消&#39;;     -- Index Scan using ix狀態
</span></span><span class="line"><span class="ln">4</span><span class="cl">SELECT 備註 FROM 訂單 WHERE 顧客編號 = 12345;    -- Index Scan using ix顧客</span></span></code></pre></div><p>同一個 <code>ix狀態</code>，同一種條件形狀，兩個值得到相反的決定。走索引要先讀索引拿到位置、再回原表取 <code>備註</code> 這一欄，每一列付兩次；命中九成五的列時，這筆帳輸給從頭讀一遍。</p>
<p>引擎的依據寫在統計裡，而那份摘要是看得到的：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">SELECT attname, n_distinct, most_common_freqs FROM pg_stats WHERE tablename = &#39;訂單&#39;;
</span></span><span class="line"><span class="ln">2</span><span class="cl"> 狀態     |       2    | {0.94996667,0.050033335}
</span></span><span class="line"><span class="ln">3</span><span class="cl"> 顧客編號 | -0.25113   |</span></span></code></pre></div><p><code>n_distinct</code> 是基數，<code>most_common_freqs</code> 是那幾個值各自的選擇率。<strong>這兩欄是抽樣估出來的，每跑一次 <code>ANALYZE</code> 都會在小數後幾位變動</strong>——真值是 0.95 與 0.05，而印出來的從來不是整數。<code>顧客編號</code> 那一格的負數是另一種寫法——絕對值小於一時代表「相異值個數是總列數的這個比例」，0.25 乘二十萬約等於五萬。</p>
<p><strong>這兩個數是估計而不是事實。</strong> 它們來自上一次收集統計的那一刻，所以同一段查詢在資料變動之後可能換一個計畫，而查詢的文字一個字都沒改（<a href="/blog/backend/01-database/sql/knowledge-cards/query-statistics/" data-link-title="Query Statistics（統計資訊）" data-link-desc="看到「先跑 ANALYZE」、或同一段查詢沒改而某天變慢時，查這一份資料是什麼、誰在用它">Query Statistics</a>）。</p>
<h2 id="同一份統計各家的取捨不同">同一份統計，各家的取捨不同</h2>
<p>上面那組表搬到 SQLite 3.51，<code>ANALYZE</code> 跑過之後三段查詢全部走索引，包含命中九成五的那一段。SQLite 的 <code>sqlite_stat1</code> 記下的數字與 PostgreSQL 量到的是同一件事：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">訂單 / ix狀態 → 200000 100000     -- 二十萬列，每個相異值平均十萬列
</span></span><span class="line"><span class="ln">2</span><span class="cl">訂單 / ix顧客 → 200000 4          -- 二十萬列，每個相異值平均四列</span></span></code></pre></div><p>所以「基數低的欄位上索引沒用」這句話要分兩半聽：低基數確實讓多數值的選擇率變高，而<strong>要不要因此放棄索引是最佳化器的判斷，各家的門檻不同</strong>。可攜的說法是選擇率，不是計畫。</p>
<h2 id="概念位置">概念位置</h2>
<p><strong>這兩關的成本模型建在一個前提上：資料按列存放，而索引是另一份按鍵排好的結構，走它要回原表取其餘欄位。</strong> 欄式引擎（DuckDB、以及讀 parquet 這一類）不是這個形狀——它按欄讀、靠區塊的最大最小值跳過整段，沒有逐列回表那筆帳，所以「命中九成五就別走索引」在那裡不成立，對應的手段是資料的排序與分區。下面兩關針對的是前一種。</p>
<p>選擇率與 <a href="/blog/backend/01-database/sql/knowledge-cards/sargable/" data-link-title="Sargable（可走索引的條件形狀）" data-link-desc="索引存在而計畫仍走全表掃描時，查條件的寫法有沒有擋住那個索引">sargable</a> 是索引派不派得上用場的兩個獨立條件。sargable 問的是條件的<strong>形狀</strong>能不能翻成索引上的一次查找——欄位被函式包住就翻不了。<strong>這一關過不了不等於索引沒用</strong>：條件裡用到的欄位都在索引上的時候，引擎仍然可能整個掃過索引再逐列過濾，省下的是回原表那筆帳（PostgreSQL 印成 <code>Index Only Scan</code> 加一個 <code>Filter</code>，SQLite 印成 <code>SCAN ... USING COVERING INDEX</code>）。所以兩關管的是不同的索引用法，而不是同一種用法的兩道閘。選擇率問的是通過查找之後<strong>還剩多少列</strong>——形狀對而留下的列佔了大半，最佳化器仍然會選擇不走它。兩關都過，<a href="/blog/backend/01-database/sql/knowledge-cards/indexing/" data-link-title="Index（索引）" data-link-desc="看到執行計畫寫著走索引或建索引、想知道那是什麼時">索引</a>才換得到速度。</p>
<p>基數也決定連接之後的列數。低基數的欄位當連接鍵時，一列會配到對面同一個值的所有列，而那個數量隨表一起長——這是<a href="/blog/backend/01-database/sql/knowledge-cards/cartesian-product/" data-link-title="Cartesian Product（笛卡兒積）" data-link-desc="看到 CROSS JOIN、或結果的列數遠多於預期時，查這個運算什麼時候會被展開">笛卡兒積</a>那張卡分辨「積」與「膨脹」時用的同一個量。</p>
<h2 id="往下走">往下走</h2>
<p>這兩個數怎麼被收集、多久失效一次，在 <a href="/blog/backend/01-database/sql/knowledge-cards/query-statistics/" data-link-title="Query Statistics（統計資訊）" data-link-desc="看到「先跑 ANALYZE」、或同一段查詢沒改而某天變慢時，查這一份資料是什麼、誰在用它">Query Statistics（統計資訊）</a>。<a href="/blog/backend/01-database/sql/knowledge-cards/query-optimizer/" data-link-title="Query Optimizer（查詢最佳化器）" data-link-desc="想知道是誰決定了執行順序、或為什麼同一段查詢換個時間變慢時">query optimizer</a> 拿它們估代價的過程，以及估錯時計畫長什麼樣，在 <a href="/blog/backend/01-database/sql/knowledge-cards/query-plan/" data-link-title="Query Plan（執行計畫）" data-link-desc="想知道一段查詢實際會怎麼被執行、或被要求「先看計畫」時">Query Plan（執行計畫）</a>。同一道題三種寫法的排名怎麼隨索引重排，在 <a href="/blog/backend/01-database/sql/cost-lives-in-the-plan/" data-link-title="SQL.17 查詢的代價：資料分布、索引與寫法各自的影響" data-link-desc="找重複值的分組、自連接與 EXISTS 在有無索引與不同重複次數下的實測時間與計畫，以及比較查詢快慢時要補上的資料量、分布、索引與統計資訊">SQL.17 查詢的代價</a>。production 上結果集大小怎麼治理，在 <a href="/blog/backend/knowledge-cards/cardinality-explosion/" data-link-title="Query Cardinality Explosion" data-link-desc="Query 結果行數因 join / cross product / 條件缺失爆炸性放大的反模式">Cardinality Explosion</a>。</p>
]]></content:encoded></item></channel></rss>