<?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>Limit on Tarragon</title><link>https://tarrragon.github.io/blog/tags/limit/</link><description>Recent content in Limit 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/limit/index.xml" rel="self" type="application/rss+xml"/><item><title>SQL.12 分頁的排序鍵與接續標記：同分的列、全序與 OFFSET 的代價</title><link>https://tarrragon.github.io/blog/backend/01-database/sql/pagination-needs-a-total-order/</link><pubDate>Wed, 02 Sep 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/sql/pagination-needs-a-total-order/</guid><description>&lt;p>分頁把一批列切成好幾次取回，而&lt;strong>每一頁是一次獨立的查詢&lt;/strong>——這是無狀態介面帶進來的前提，不是 SQL 的性質。資料庫伺服器端開一個&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/cursor/" data-link-title="Cursor（游標、查詢結果的讀取物件、接續標記）" data-link-desc="在程式碼、API 或文件裡看到 cursor 這個字，要分辨它指的是畫面上的游標、資料庫逐筆讀取查詢結果的物件，還是分頁回應裡記錄從哪裡接續的標記時">查詢結果的讀取物件（cursor）&lt;/a>、一次執行逐批取回的時候，下面這一整篇的問題不會發生。把翻頁包在同一個交易裡則不夠：它擋得住的只有插入造成的位移，而且要 &lt;code>REPEATABLE READ&lt;/code> 以上的隔離等級，同分的列在同一個交易裡逐頁取，先後照樣會變（原因在〈排序鍵不唯一時同分的列的先後〉）。&lt;/p>
&lt;p>在每一頁各查一次的前提底下，兩次查詢之間伺服器不持有任何狀態，能拿來對齊兩頁的只有排序本身。&lt;/p>
&lt;p>而 &lt;code>ORDER BY&lt;/code> 只規定排序鍵分得出高下的列之間的先後；排序鍵的值相同的列（下面叫同分的列）彼此誰在前面，它沒有規定。那個規定怎麼運作、空值為什麼比不出大小，在 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/relations-have-no-order/" data-link-title="SQL.11 查詢結果的列序：沒有 ORDER BY 時的來源與 ORDER BY 的規定範圍" data-link-desc="同一段查詢在建索引前後回不同順序的機制、ORDER BY 在求值順序的位置，以及空值落在哪一端各家的差別">SQL.11 查詢結果的列序：沒有 ORDER BY 時的來源與 ORDER BY 的規定範圍&lt;/a>。整批取回的時候，同分的列先後不定無害；逐頁取回的時候，它變成同一筆資料出現兩次、另一筆一次都不出現。&lt;/p>
&lt;p>本篇處理三件事：那個機制、把順序補到全序（任兩列都比得出先後）的判斷標準，以及位置式與值式兩種接續標記（cursor）各自成立什麼。&lt;strong>對外要選哪一種分頁還牽涉到跳頁與總筆數要不要放棄、接續標記怎麼編碼、以及一致性承諾怎麼寫進契約，那些不在本篇裡&lt;/strong>：&lt;a href="https://tarrragon.github.io/blog/backend/11-api-design/pagination-debate/" data-link-title="分頁之爭：offset 與 keyset 的定位機制、接續標記的表示法與不透明性條款" data-link-desc="定位機制、一致性模型與表示法各自的取捨：offset 與 keyset 的成本與能力差、不透明接續標記給服務端的自由、未宣告的接續標記性質會被誰依賴、offset 留下來的條件，以及選型的判定順序">分頁之爭：offset 與 keyset 的定位機制、接續標記的表示法與不透明性條款&lt;/a> 的〈選型的順序：跳頁與總數需求、定位機制、表示法〉一節給選型的三步（先問跳頁與總數、再選定位機制、最後定表示法）與各步的出口，〈不透明接續標記隱含的承諾與條款清單〉一節寫接續標記要對外承諾哪些條款，〈offset 與 keyset 定位機制的成本曲線與能力差〉一節另列出快照分頁與時間視窗這兩條本篇沒有的路；接續標記式的複雜度對照與 tiebreaker 設計在 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/keyset-pagination/" data-link-title="Keyset Pagination" data-link-desc="用上一頁最後一筆的 key 當下一頁起點、避開 OFFSET 大表時的線性退化">Keyset Pagination&lt;/a>。&lt;/p>
&lt;p>本篇沿用 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/relations-have-no-order/" data-link-title="SQL.11 查詢結果的列序：沒有 ORDER BY 時的來源與 ORDER BY 的規定範圍" data-link-desc="同一段查詢在建索引前後回不同順序的機制、ORDER BY 在求值順序的位置，以及空值落在哪一端各家的差別">SQL.11 查詢結果的列序：沒有 ORDER BY 時的來源與 ORDER BY 的規定範圍&lt;/a> 那一批訂單：在&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/sample-bookstore-database/" data-link-title="SQL 各篇共用的範例資料庫：一家小書店的顧客、訂單與評價表" data-link-desc="SQL 各篇共用的顧客、訂單與評價表的建表語句與初始資料，以及各篇輸出所用的引擎版本、錯誤訊息與列序的閱讀約定">共用資料庫&lt;/a>上把 101 的金額改成 700、再加 103 到 105 三張，讓五張訂單裡有三張同樣是 500 元。這三張同分的訂單正是分頁要處理的對象。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="k">UPDATE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">SET&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">金額&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">700&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">WHERE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單編號&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">101&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">INSERT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">INTO&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="err">訂單編號&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">金額&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">VALUES&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">103&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">500&lt;/span>&lt;span class="p">),&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">104&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">300&lt;/span>&lt;span class="p">),&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">105&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">500&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1">-- 101 是 700 元；102、103、105 是 500 元；104 是 300 元
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="c1">-- 新加的三張沒有填顧客與下單日，本篇的查詢只用到訂單編號與金額&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="排序鍵不唯一時同分的列的先後">排序鍵不唯一時同分的列的先後&lt;/h2>
&lt;p>&lt;code>ORDER BY 金額 DESC&lt;/code> 說的是金額大的在前。三張 500 元的訂單彼此之間沒有被規定，所以它們的先後與沒有 &lt;code>ORDER BY&lt;/code> 時一樣，跟著計畫走：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1">-- SQLite 3.51，逐頁取一列：第 n 頁是 OFFSET n-1
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單編號&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">ORDER&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">BY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">金額&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">DESC&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">LIMIT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">OFFSET&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="p">;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1">-- OFFSET 1 到 OFFSET 4 各跑一次同一段查詢
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="c1">-- 沒有索引：五頁依序是 101 102 103 105 104
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">CREATE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">INDEX&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">ix&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">ON&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">訂單&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="err">金額&lt;/span>&lt;span class="p">);&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1">-- 建了索引之後，同樣五段查詢依序是 101 105 103 102 104&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>整批看的時候這個差別無害——五張訂單一張不多一張不少，只是中間三張換了位置。&lt;strong>分頁把它變成錯誤，因為每一頁是一次獨立的查詢&lt;/strong>：兩次查詢之間換了計畫的話，前一頁的最後一列可能在下一頁再出現一次，而另一列一次都不出現。計畫不換也會發生：PostgreSQL 依 &lt;code>LIMIT&lt;/code> 加 &lt;code>OFFSET&lt;/code> 的大小選排序方法，第一頁走 top-N heapsort、後面的頁走 quicksort，兩種方法對同分列給出不同的先後——一次 PostgreSQL 18 上的實測，一千列、同分很多，在同一個交易裡逐頁取完只拿到 757 個不同的列。&lt;/p>
&lt;p>另一種形態不必換計畫就會發生：一邊翻頁一邊有人寫入，排在前面的列多了或少了，後面每一列的位置就跟著移動。下面叫它翻頁途中的寫入造成的位移。&lt;/p></description><content:encoded><![CDATA[<p>分頁把一批列切成好幾次取回，而<strong>每一頁是一次獨立的查詢</strong>——這是無狀態介面帶進來的前提，不是 SQL 的性質。資料庫伺服器端開一個<a href="/blog/backend/knowledge-cards/cursor/" data-link-title="Cursor（游標、查詢結果的讀取物件、接續標記）" data-link-desc="在程式碼、API 或文件裡看到 cursor 這個字，要分辨它指的是畫面上的游標、資料庫逐筆讀取查詢結果的物件，還是分頁回應裡記錄從哪裡接續的標記時">查詢結果的讀取物件（cursor）</a>、一次執行逐批取回的時候，下面這一整篇的問題不會發生。把翻頁包在同一個交易裡則不夠：它擋得住的只有插入造成的位移，而且要 <code>REPEATABLE READ</code> 以上的隔離等級，同分的列在同一個交易裡逐頁取，先後照樣會變（原因在〈排序鍵不唯一時同分的列的先後〉）。</p>
<p>在每一頁各查一次的前提底下，兩次查詢之間伺服器不持有任何狀態，能拿來對齊兩頁的只有排序本身。</p>
<p>而 <code>ORDER BY</code> 只規定排序鍵分得出高下的列之間的先後；排序鍵的值相同的列（下面叫同分的列）彼此誰在前面，它沒有規定。那個規定怎麼運作、空值為什麼比不出大小，在 <a href="/blog/backend/01-database/sql/relations-have-no-order/" data-link-title="SQL.11 查詢結果的列序：沒有 ORDER BY 時的來源與 ORDER BY 的規定範圍" data-link-desc="同一段查詢在建索引前後回不同順序的機制、ORDER BY 在求值順序的位置，以及空值落在哪一端各家的差別">SQL.11 查詢結果的列序：沒有 ORDER BY 時的來源與 ORDER BY 的規定範圍</a>。整批取回的時候，同分的列先後不定無害；逐頁取回的時候，它變成同一筆資料出現兩次、另一筆一次都不出現。</p>
<p>本篇處理三件事：那個機制、把順序補到全序（任兩列都比得出先後）的判斷標準，以及位置式與值式兩種接續標記（cursor）各自成立什麼。<strong>對外要選哪一種分頁還牽涉到跳頁與總筆數要不要放棄、接續標記怎麼編碼、以及一致性承諾怎麼寫進契約，那些不在本篇裡</strong>：<a href="/blog/backend/11-api-design/pagination-debate/" data-link-title="分頁之爭：offset 與 keyset 的定位機制、接續標記的表示法與不透明性條款" data-link-desc="定位機制、一致性模型與表示法各自的取捨：offset 與 keyset 的成本與能力差、不透明接續標記給服務端的自由、未宣告的接續標記性質會被誰依賴、offset 留下來的條件，以及選型的判定順序">分頁之爭：offset 與 keyset 的定位機制、接續標記的表示法與不透明性條款</a> 的〈選型的順序：跳頁與總數需求、定位機制、表示法〉一節給選型的三步（先問跳頁與總數、再選定位機制、最後定表示法）與各步的出口，〈不透明接續標記隱含的承諾與條款清單〉一節寫接續標記要對外承諾哪些條款，〈offset 與 keyset 定位機制的成本曲線與能力差〉一節另列出快照分頁與時間視窗這兩條本篇沒有的路；接續標記式的複雜度對照與 tiebreaker 設計在 <a href="/blog/backend/knowledge-cards/keyset-pagination/" data-link-title="Keyset Pagination" data-link-desc="用上一頁最後一筆的 key 當下一頁起點、避開 OFFSET 大表時的線性退化">Keyset Pagination</a>。</p>
<p>本篇沿用 <a href="/blog/backend/01-database/sql/relations-have-no-order/" data-link-title="SQL.11 查詢結果的列序：沒有 ORDER BY 時的來源與 ORDER BY 的規定範圍" data-link-desc="同一段查詢在建索引前後回不同順序的機制、ORDER BY 在求值順序的位置，以及空值落在哪一端各家的差別">SQL.11 查詢結果的列序：沒有 ORDER BY 時的來源與 ORDER BY 的規定範圍</a> 那一批訂單：在<a href="/blog/backend/01-database/sql/sample-bookstore-database/" data-link-title="SQL 各篇共用的範例資料庫：一家小書店的顧客、訂單與評價表" data-link-desc="SQL 各篇共用的顧客、訂單與評價表的建表語句與初始資料，以及各篇輸出所用的引擎版本、錯誤訊息與列序的閱讀約定">共用資料庫</a>上把 101 的金額改成 700、再加 103 到 105 三張，讓五張訂單裡有三張同樣是 500 元。這三張同分的訂單正是分頁要處理的對象。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">UPDATE</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="k">SET</span><span class="w"> </span><span class="err">金額</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">700</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="err">訂單編號</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">101</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span><span class="k">INSERT</span><span class="w"> </span><span class="k">INTO</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="p">(</span><span class="err">訂單編號</span><span class="p">,</span><span class="w"> </span><span class="err">金額</span><span class="p">)</span><span class="w"> </span><span class="k">VALUES</span><span class="w"> </span><span class="p">(</span><span class="mi">103</span><span class="p">,</span><span class="w"> </span><span class="mi">500</span><span class="p">),</span><span class="w"> </span><span class="p">(</span><span class="mi">104</span><span class="p">,</span><span class="w"> </span><span class="mi">300</span><span class="p">),</span><span class="w"> </span><span class="p">(</span><span class="mi">105</span><span class="p">,</span><span class="w"> </span><span class="mi">500</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w"></span><span class="c1">-- 101 是 700 元；102、103、105 是 500 元；104 是 300 元
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1">-- 新加的三張沒有填顧客與下單日，本篇的查詢只用到訂單編號與金額</span></span></span></code></pre></div><h2 id="排序鍵不唯一時同分的列的先後">排序鍵不唯一時同分的列的先後</h2>
<p><code>ORDER BY 金額 DESC</code> 說的是金額大的在前。三張 500 元的訂單彼此之間沒有被規定，所以它們的先後與沒有 <code>ORDER BY</code> 時一樣，跟著計畫走：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1">-- SQLite 3.51，逐頁取一列：第 n 頁是 OFFSET n-1
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="err">訂單編號</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="k">ORDER</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="err">金額</span><span class="w"> </span><span class="k">DESC</span><span class="w"> </span><span class="k">LIMIT</span><span class="w"> </span><span class="mi">1</span><span class="w"> </span><span class="k">OFFSET</span><span class="w"> </span><span class="mi">0</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w"></span><span class="c1">-- OFFSET 1 到 OFFSET 4 各跑一次同一段查詢
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1">-- 沒有索引：五頁依序是 101  102  103  105  104
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"></span><span class="w">
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="w"></span><span class="k">CREATE</span><span class="w"> </span><span class="k">INDEX</span><span class="w"> </span><span class="n">ix</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="err">訂單</span><span class="w"> </span><span class="p">(</span><span class="err">金額</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="w"></span><span class="c1">-- 建了索引之後，同樣五段查詢依序是 101  105  103  102  104</span></span></span></code></pre></div><p>整批看的時候這個差別無害——五張訂單一張不多一張不少，只是中間三張換了位置。<strong>分頁把它變成錯誤，因為每一頁是一次獨立的查詢</strong>：兩次查詢之間換了計畫的話，前一頁的最後一列可能在下一頁再出現一次，而另一列一次都不出現。計畫不換也會發生：PostgreSQL 依 <code>LIMIT</code> 加 <code>OFFSET</code> 的大小選排序方法，第一頁走 top-N heapsort、後面的頁走 quicksort，兩種方法對同分列給出不同的先後——一次 PostgreSQL 18 上的實測，一千列、同分很多，在同一個交易裡逐頁取完只拿到 757 個不同的列。</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">-- ORDER BY 金額 DESC LIMIT 2
</span></span><span class="line"><span class="ln">2</span><span class="cl">第 1 頁（OFFSET 0）              101, 102
</span></span><span class="line"><span class="ln">3</span><span class="cl">   ← 這時插入 106，金額 900
</span></span><span class="line"><span class="ln">4</span><span class="cl">第 2 頁（OFFSET 2）              102, 103
</span></span><span class="line"><span class="ln">5</span><span class="cl">第 3 頁（OFFSET 4）              105, 104</span></span></code></pre></div><p>102 出現在兩頁上。<code>OFFSET 2</code> 的意思是「跳過排好之後的前兩列」，而新插入的 106 排在最前面，把所有列往後推了一格。原本在第二個位置的 102 被推到第三個，於是它同時是第一頁的最後一列與第二頁的第一列。</p>
<p>翻完三頁之後讀者手上是 101、102、102、103、105、104——<strong>六列裡有一列重複，而金額最高的那一筆 106 一次都沒出現</strong>。兩種偏差都不帶任何訊號：每一頁單獨看都是正確的查詢結果。查詢照跑、結果形狀正確而答案錯了，這一類錯誤共同的性質在 <a href="/blog/backend/01-database/sql/well-formed-is-not-correct/" data-link-title="SQL.13 查詢的合法性與答案的正確性：引擎檢查的範圍、答案錯掉的成因與查證方法" data-link-desc="合法與問對了分別由誰負責、答案錯掉時引擎為什麼不報錯，以及靜態分析、約束與反例資料各自判得出哪些錯">SQL.13 查詢的合法性與答案的正確性：引擎檢查的範圍、答案錯掉的成因與查證方法</a> 的〈答案錯掉的成因分類與引擎不報錯的原因〉。</p>
<h2 id="把排序鍵補到全序以及用排序鍵的值當接續標記">把排序鍵補到全序，以及用排序鍵的值當接續標記</h2>
<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">-- ORDER BY 金額 DESC, 訂單編號   全表順序：101,102,103,105,104
</span></span><span class="line"><span class="ln">2</span><span class="cl">OFFSET 0 → 101,102     OFFSET 2 → 103,105     OFFSET 4 → 104</span></span></code></pre></div><p>同一段查詢跑幾次都是這個結果，換計畫也是。<strong>判斷標準是排序鍵的組合在「排序用的那條比較規則下」兩兩可分</strong>，這個性質叫全序：任兩列都比得出先後——「值不重複」還不夠，有兩種形態會讓不重複的鍵仍然排不出先後。一種是空值：一欄掛著 <code>UNIQUE</code> 而三列都是 <code>NULL</code>，約束成立而 <code>NULL</code> 之間比不出高下，SQLite 3.51 上建了索引之後同一段查詢回的順序就翻轉。另一種是比較規則把不同的值判成相等：MySQL 預設的 collation 底下 <code>Anna</code>／<code>anna</code>／<code>ANNA</code>／<code>Ánna</code> 四個值 <code>COUNT(DISTINCT BINARY 姓名)</code> 是 4 而 <code>COUNT(DISTINCT 姓名)</code> 是 1。看起來唯一的字串鍵因此可能不唯一，那條比較規則住在哪一層、各家預設是什麼，在 <a href="/blog/backend/01-database/sql/string-comparison-and-collation/" data-link-title="SQL.15 字串比較與 collation：相等、排序、LIKE 與索引可用性" data-link-desc="同一段等值條件在各家回不同的列的原因、LIKE 與等號何時分岔、索引與條件的比較規則怎麼對上，以及規則該寫在哪一層">SQL.15 字串比較與 collation：相等、排序、LIKE 與索引可用性</a>。</p>
<p>把主鍵接在排序鍵最後面之所以省事，是因為它同時避開了空值與比較規則這兩種形態——主鍵<a href="/blog/backend/01-database/sql/knowledge-cards/constraint/" data-link-title="Constraint（約束）" data-link-desc="看到「這個保證要來自約束而不是來自習慣」，或想確認某一欄會不會出現 NULL 時，查這一層由誰擋">約束</a>保證它不重複也不是 <code>NULL</code>，而數值型別的比較規則不會把兩個不同的數判成相等。SQLite 在這裡是例外：只有宣告成 <code>INTEGER PRIMARY KEY</code> 的那一欄擋得住 <code>NULL</code>，寫成 <code>INT PRIMARY KEY</code> 的主鍵照樣收得下好幾個 <code>NULL</code>，要當決勝鍵就得另外宣告 <code>NOT NULL</code>。所以排序鍵分不分得出高下，多半在寫 <code>CREATE TABLE</code> 的那一刻就定了：那一欄有沒有唯一約束、決勝鍵有沒有現成的欄位可用。<a href="/blog/backend/01-database/design-decisions-price-every-query/" data-link-title="1.16 Schema 設計決定的查詢代價：可空性、排序鍵唯一性、表寬、一對多的切法、比較規則與外鍵執法" data-link-desc="建表時每一個決定有哪些選法、選了之後查詢要多做什麼、代價在什麼條件下才浮現、改回來要付的遷移成本，以及設計當下就能問的問題；輸出為 SQLite 與 PostgreSQL 的實測">backend 1.16 Schema 設計決定的查詢代價</a> 的「這個鍵唯不唯一」一節走那個決定，並示範補決勝鍵治得好同分的列造成的重複與遺漏、治不好翻頁途中的寫入造成的位移。</p>
<p>補上唯一鍵治好了同分的列先後不定，治不好翻頁途中的寫入造成的位移。<code>OFFSET</code> 數的是位置，而位置會被前面新增或刪除的列改變。要讓分頁不受<strong>排在接續標記前面的</strong>寫入影響，接續標記得從位置換成值——記下上一頁最後一列的排序鍵，下一頁從那個值之後接著取。（排在接續標記後面的新插入照樣會進到後面的頁上；要讓整趟翻頁看到同一個時點的資料是另一回事，那需要快照，<a href="/blog/backend/11-api-design/pagination-debate/" data-link-title="分頁之爭：offset 與 keyset 的定位機制、接續標記的表示法與不透明性條款" data-link-desc="定位機制、一致性模型與表示法各自的取捨：offset 與 keyset 的成本與能力差、不透明接續標記給服務端的自由、未宣告的接續標記性質會被誰依賴、offset 留下來的條件，以及選型的判定順序">分頁之爭</a> 的「定位機制的成本曲線與能力差」一節寫它。）</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1">-- 上一頁最後一列是 (金額 500, 訂單編號 102)
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="err">訂單編號</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="err">訂單</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w"></span><span class="k">WHERE</span><span class="w"> </span><span class="err">金額</span><span class="w"> </span><span class="o">&lt;</span><span class="w"> </span><span class="mi">500</span><span class="w"> </span><span class="k">OR</span><span class="w"> </span><span class="p">(</span><span class="err">金額</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">500</span><span class="w"> </span><span class="k">AND</span><span class="w"> </span><span class="err">訂單編號</span><span class="w"> </span><span class="o">&gt;</span><span class="w"> </span><span class="mi">102</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w"></span><span class="k">ORDER</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="err">金額</span><span class="w"> </span><span class="k">DESC</span><span class="p">,</span><span class="w"> </span><span class="err">訂單編號</span><span class="w"> </span><span class="k">LIMIT</span><span class="w"> </span><span class="mi">2</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="w"></span><span class="c1">-- 103,105</span></span></span></code></pre></div><p>這個條件描述的是「排在那一列後面」，而它由排序鍵自己構成，所以中間插進來的 900 元訂單不影響它——那一列排在接續標記前面，本來就不該出現在後面的頁上。</p>
<p><strong>條件要跟著排序方向寫。</strong> SQL 有一種列值比較的簡寫 <code>WHERE (金額, 訂單編號) &lt; (500, 102)</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">展開式  WHERE 金額 &lt; 500 OR (金額 = 500 AND 訂單編號 &gt; 102)   → 103,105
</span></span><span class="line"><span class="ln">2</span><span class="cl">列值式  WHERE (金額, 訂單編號) &lt; (500, 102)                    → 104</span></span></code></pre></div><p>列值式回的是另一頁，而它照樣執行、照樣回一批形狀正確的列。兩個排序鍵都寫成遞減的時候列值式才對得上：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1">-- 兩個排序鍵都遞減，全表順序是 101,105,103,102,104
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1">-- 上一頁最後一列是 (金額 500, 訂單編號 105)
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="err">訂單編號</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="err">訂單</span><span class="w">
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w"></span><span class="k">WHERE</span><span class="w"> </span><span class="p">(</span><span class="err">金額</span><span class="p">,</span><span class="w"> </span><span class="err">訂單編號</span><span class="p">)</span><span class="w"> </span><span class="o">&lt;</span><span class="w"> </span><span class="p">(</span><span class="mi">500</span><span class="p">,</span><span class="w"> </span><span class="mi">105</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="w"></span><span class="k">ORDER</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="err">金額</span><span class="w"> </span><span class="k">DESC</span><span class="p">,</span><span class="w"> </span><span class="err">訂單編號</span><span class="w"> </span><span class="k">DESC</span><span class="w"> </span><span class="k">LIMIT</span><span class="w"> </span><span class="mi">2</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="w"></span><span class="c1">-- 103,102，與同一個排序下 OFFSET 2 LIMIT 2 取到的那一頁相同</span></span></span></code></pre></div><h2 id="offset-的代價與頁數的關係">OFFSET 的代價與頁數的關係</h2>
<p><code>OFFSET n</code> 要引擎按順序產出前 n 列再把它們丟掉，所以翻得越後面做的功越多。<strong>貴的是「用序數指位置」這個介面形狀，不是「找到第九十萬零一列」這件事</strong>——下面那張表的最後一列就是同一個深度用值定位，耗時與第一頁相同。一百萬列的表、排序鍵上有覆蓋索引，每次取二十列：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">CREATE</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="n">t</span><span class="w"> </span><span class="p">(</span><span class="n">id</span><span class="w"> </span><span class="nb">INTEGER</span><span class="w"> </span><span class="k">PRIMARY</span><span class="w"> </span><span class="k">KEY</span><span class="p">,</span><span class="w"> </span><span class="n">k</span><span class="w"> </span><span class="nb">INTEGER</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span><span class="k">CREATE</span><span class="w"> </span><span class="k">INDEX</span><span class="w"> </span><span class="n">ix_k</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="n">t</span><span class="w"> </span><span class="p">(</span><span class="n">k</span><span class="p">,</span><span class="w"> </span><span class="n">id</span><span class="p">);</span><span class="w">   </span><span class="c1">-- 排序鍵 k, id 整組都在索引裡，取 id 不必回表
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"></span><span class="w">
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w"></span><span class="c1">-- OFFSET 式：先走過並丟掉排在前面的 n 列
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="n">id</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">t</span><span class="w"> </span><span class="k">ORDER</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="n">k</span><span class="p">,</span><span class="w"> </span><span class="n">id</span><span class="w"> </span><span class="k">LIMIT</span><span class="w"> </span><span class="mi">20</span><span class="w"> </span><span class="k">OFFSET</span><span class="w"> </span><span class="mi">900000</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="w"></span><span class="c1">-- 接續標記式：括號裡填上一頁最後一列的 (k, id)；兩個排序鍵同方向，列值比較可以直接用
</span></span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="c1"></span><span class="k">SELECT</span><span class="w"> </span><span class="n">id</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">t</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="p">(</span><span class="n">k</span><span class="p">,</span><span class="w"> </span><span class="n">id</span><span class="p">)</span><span class="w"> </span><span class="o">&gt;</span><span class="w"> </span><span class="p">(:</span><span class="err">上一頁的</span><span class="n">k</span><span class="p">,</span><span class="w"> </span><span class="p">:</span><span class="err">上一頁的</span><span class="n">id</span><span class="p">)</span><span class="w"> </span><span class="k">ORDER</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="n">k</span><span class="p">,</span><span class="w"> </span><span class="n">id</span><span class="w"> </span><span class="k">LIMIT</span><span class="w"> </span><span class="mi">20</span><span class="p">;</span></span></span></code></pre></div>




<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">-- SQLite 3.51，五次的實測，單位秒
</span></span><span class="line"><span class="ln">2</span><span class="cl">OFFSET 0        0.001  0.000  0.001  0.000  0.001
</span></span><span class="line"><span class="ln">3</span><span class="cl">OFFSET 100000   0.002  0.002  0.002  0.003  0.001
</span></span><span class="line"><span class="ln">4</span><span class="cl">OFFSET 500000   0.007  0.005  0.005  0.005  0.005
</span></span><span class="line"><span class="ln">5</span><span class="cl">OFFSET 900000   0.009  0.010  0.012  0.009  0.008
</span></span><span class="line"><span class="ln">6</span><span class="cl">接續標記式（深度 900000） 0.000  0.000  0.000  0.000  0.001</span></span></code></pre></div><p>絕對值在這個規模上都很小，要看的是形狀：<code>OFFSET</code> 那一欄隨深度線性成長，接續標記式那一列與深度無關——它直接在索引上找到起點。所以「第一頁很快，翻到後面越來越慢」這個症狀有一個不必量測就成立的解釋：<code>OFFSET</code> 要先走過並丟掉排在前面的每一列，頁數越深要丟的越多。而它與索引建得好不好無關——上面那張表就是在索引已經覆蓋排序鍵的前提下量的。</p>
<p><code>OFFSET</code> 的成長是代價問題的一個實例，而代價的一般形狀由資料與索引決定、不由寫法決定。代價的一般形狀在 <a href="/blog/backend/01-database/sql/cost-lives-in-the-plan/" data-link-title="SQL.17 查詢的代價：資料分布、索引與寫法各自的影響" data-link-desc="找重複值的分組、自連接與 EXISTS 在有無索引與不同重複次數下的實測時間與計畫，以及比較查詢快慢時要補上的資料量、分布、索引與統計資訊">SQL.17 查詢的代價</a>，而 <code>OFFSET</code> 這一條特別之處在於它的成長與索引建得好不好無關。</p>
]]></content:encoded></item></channel></rss>