<?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>Order-By on Tarragon</title><link>https://tarrragon.github.io/blog/tags/order-by/</link><description>Recent content in Order-By 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/order-by/index.xml" rel="self" type="application/rss+xml"/><item><title>SQL.11 查詢結果的列序：沒有 ORDER BY 時的來源與 ORDER BY 的規定範圍</title><link>https://tarrragon.github.io/blog/backend/01-database/sql/relations-have-no-order/</link><pubDate>Wed, 02 Sep 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/sql/relations-have-no-order/</guid><description>&lt;p>一份 &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;a href="https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/query-plan/" data-link-title="Query Plan（執行計畫）" data-link-desc="想知道一段查詢實際會怎麼被執行、或被要求「先看計畫」時">執行計畫&lt;/a>走到最後剛好留下的形狀。&lt;code>ORDER BY&lt;/code> 是把順序加回去的那個子句，也是唯一的一個——而它要寫在&lt;strong>最外層&lt;/strong>才算數。寫在衍生表或視圖裡面的 &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">-- MySQL 8.4
&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">CREATE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">TABLE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">t&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">id&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">INT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">PRIMARY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">KEY&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">k&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">INT&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="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="n">t&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">1&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">30&lt;/span>&lt;span class="p">),&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">2&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">10&lt;/span>&lt;span class="p">),&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">3&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">20&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">4&lt;/span>&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">group_concat&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">id&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">id&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">t&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="n">k&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">d&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">6&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1">-- 1,2,3 主鍵序；衍生表裡的 ORDER BY k 被丟掉了
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&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="n">id&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">id&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">k&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">t&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">d&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="n">k&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">8&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1">-- 2、3、1 ORDER BY 寫在最外層才照 k 排&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這個語言用一套規則決定哪些列會出現在結果裡（那套規則叫&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/semantic-model/" data-link-title="Semantic Model（語意模型）" data-link-desc="文章說「模型定死」「符合語意模型」時，查那套規則是什麼、由誰保證">語意模型&lt;/a>），而輸出的順序是它管得最少的一項：&lt;strong>規則只到「排序鍵分得出高下」為止，分不出高下的那些它沒有規定&lt;/strong>。&lt;/p>
&lt;p>本篇交代順序從哪裡來、&lt;code>ORDER BY&lt;/code> 看得到什麼、以及比不出大小的值落在哪一端。&lt;strong>&lt;code>ORDER BY&lt;/code> 沒有規定同分的列誰先誰後，這件事在什麼時候變成錯誤，在 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/pagination-needs-a-total-order/" data-link-title="SQL.12 分頁的排序鍵與接續標記：同分的列、全序與 OFFSET 的代價" data-link-desc="每一頁各查一次時同分的列與翻頁途中的寫入怎麼造成重複與遺漏、排序鍵補到兩兩可分的條件、展開式與列值式的接續標記條件在排序方向上的差別，以及 OFFSET 隨頁數成長的代價">SQL.12 分頁的排序鍵與接續標記&lt;/a>&lt;/strong>——整批看的時候同分的列換位置沒有後果，每一頁各查一次的時候它變成重複與遺漏；那一篇寫排序鍵要補到兩兩可分的判斷標準，以及把接續標記（cursor）從位置換成值的寫法。&lt;/p>
&lt;p>本篇範圍之外的兩件事各有專篇。哪些列會進到這份結果由條件與連接決定，順序管不到那一層：條件擺哪一邊決定留下哪些列在 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/on-describes-where-filters/" data-link-title="SQL.5 ON 與 WHERE：連接條件與篩選條件各自的職責" data-link-desc="條件寫在連接的配對階段與寫在關係組完之後的篩選階段的差別、外連接的保護在 WHERE 被抵銷的機制，以及判斷一個條件該放哪一邊的測試">SQL.5 ON 與 WHERE：連接條件與篩選條件各自的職責&lt;/a>，連接怎麼改變列數在 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/join-changes-rows-and-nulls/" data-link-title="SQL.6 連接之後的列數與空缺：列數膨脹、外連接補的 NULL 與三值邏輯" data-link-desc="連接讓聚合多算的機制與計數、加總、平均、中位數各自的修法，以及外連接補出來的 NULL 在 count、等號比較、NOT IN 與 NOT EXISTS 上的行為">SQL.6 連接之後的列數與空缺&lt;/a>。視窗函數的 &lt;code>OVER (ORDER BY ...)&lt;/code> 是另一個順序，它決定計算時誰算相鄰、不決定輸出怎麼排，兩者可以不同，&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/window-keeps-rows-grouping-collapses/" data-link-title="SQL.10 分組與視窗函數：各自的產出、選用的依據與 LAG、LEAD 的相鄰列" data-link-desc="GROUP BY 與視窗函數算完之後原本的列還在不在、依結果的一列代表什麼選用哪一個、視窗函數的值怎麼拿來當篩選條件，以及 LAG 取相鄰列時分區、排序鍵與距離條件各自要確認的事">SQL.10 分組與視窗函數：各自的產出、選用的依據與 LAG、LEAD 的相鄰列&lt;/a> 的〈排序相鄰與問題要的相鄰：分區、排序鍵與距離條件〉一節寫那一種。&lt;/p>
&lt;p>本篇的查詢跑在&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、再加三張，讓五張訂單裡有三張同樣是 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="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">2&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;2026-03-10&amp;#39;&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">2&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;2026-03-11&amp;#39;&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>&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="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">3&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;2026-03-12&amp;#39;&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">4&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;/code>&lt;/pre>&lt;/div>&lt;h2 id="沒有-order-by-時列序的來源">沒有 ORDER BY 時列序的來源&lt;/h2>
&lt;p>同一段查詢，同一批資料，中間只多建了一個索引：&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
&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">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">&amp;gt;=&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>&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,102,103,104,105 計畫：SCAN 訂單
&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 class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&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="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">6&lt;/span>&lt;span class="cl">&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="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">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">&amp;gt;=&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>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1">-- 104,102,103,105,101 計畫：SEARCH 訂單 USING INDEX ix (金額&amp;gt;?)
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">9&lt;/span>&lt;span class="cl">&lt;span class="c1">-- 索引照金額排：300 的 104、500 的三張（102、103、105）、700 的 101&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>查詢的文字一個字都沒改，回來的順序整個換掉了。沒有索引的那一次照資料存放的先後走，建了索引的那一次照索引上的先後走——&lt;strong>兩種都正確&lt;/strong>，因為那份關係本來就沒有規定過順序。&lt;/p></description><content:encoded><![CDATA[<p>一份 <a href="/blog/backend/01-database/sql/knowledge-cards/relation/" data-link-title="Relation（關係）" data-link-desc="看到 SQL 教材說「組成一個關係」而不確定它指的是不是資料表時">relation</a> 是集合，而集合的成員沒有先後。所以一段查詢算出來的那份關係本身不帶順序，回來的那一批列排成什麼樣，是<a href="/blog/backend/01-database/sql/knowledge-cards/query-plan/" data-link-title="Query Plan（執行計畫）" data-link-desc="想知道一段查詢實際會怎麼被執行、或被要求「先看計畫」時">執行計畫</a>走到最後剛好留下的形狀。<code>ORDER BY</code> 是把順序加回去的那個子句，也是唯一的一個——而它要寫在<strong>最外層</strong>才算數。寫在衍生表或視圖裡面的 <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">-- MySQL 8.4
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><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">INT</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">INT</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="k">INSERT</span><span class="w"> </span><span class="k">INTO</span><span class="w"> </span><span class="n">t</span><span class="w"> </span><span class="k">VALUES</span><span class="w"> </span><span class="p">(</span><span class="mi">1</span><span class="p">,</span><span class="w"> </span><span class="mi">30</span><span class="p">),</span><span class="w"> </span><span class="p">(</span><span class="mi">2</span><span class="p">,</span><span class="w"> </span><span class="mi">10</span><span class="p">),</span><span class="w"> </span><span class="p">(</span><span class="mi">3</span><span class="p">,</span><span class="w"> </span><span class="mi">20</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></span><span class="line"><span class="ln">5</span><span class="cl"><span class="w"></span><span class="k">SELECT</span><span class="w"> </span><span class="n">group_concat</span><span class="p">(</span><span class="n">id</span><span class="p">)</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="p">(</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">d</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">-- 1,2,3    主鍵序；衍生表裡的 ORDER BY k 被丟掉了
</span></span></span><span class="line"><span class="ln">7</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="p">(</span><span class="k">SELECT</span><span class="w"> </span><span class="n">id</span><span class="p">,</span><span class="w"> </span><span class="n">k</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">t</span><span class="p">)</span><span class="w"> </span><span class="n">d</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></span><span class="line"><span class="ln">8</span><span class="cl"><span class="w"></span><span class="c1">-- 2、3、1  ORDER BY 寫在最外層才照 k 排</span></span></span></code></pre></div><p>這個語言用一套規則決定哪些列會出現在結果裡（那套規則叫<a href="/blog/backend/01-database/sql/knowledge-cards/semantic-model/" data-link-title="Semantic Model（語意模型）" data-link-desc="文章說「模型定死」「符合語意模型」時，查那套規則是什麼、由誰保證">語意模型</a>），而輸出的順序是它管得最少的一項：<strong>規則只到「排序鍵分得出高下」為止，分不出高下的那些它沒有規定</strong>。</p>
<p>本篇交代順序從哪裡來、<code>ORDER BY</code> 看得到什麼、以及比不出大小的值落在哪一端。<strong><code>ORDER BY</code> 沒有規定同分的列誰先誰後，這件事在什麼時候變成錯誤，在 <a href="/blog/backend/01-database/sql/pagination-needs-a-total-order/" data-link-title="SQL.12 分頁的排序鍵與接續標記：同分的列、全序與 OFFSET 的代價" data-link-desc="每一頁各查一次時同分的列與翻頁途中的寫入怎麼造成重複與遺漏、排序鍵補到兩兩可分的條件、展開式與列值式的接續標記條件在排序方向上的差別，以及 OFFSET 隨頁數成長的代價">SQL.12 分頁的排序鍵與接續標記</a></strong>——整批看的時候同分的列換位置沒有後果，每一頁各查一次的時候它變成重複與遺漏；那一篇寫排序鍵要補到兩兩可分的判斷標準，以及把接續標記（cursor）從位置換成值的寫法。</p>
<p>本篇範圍之外的兩件事各有專篇。哪些列會進到這份結果由條件與連接決定，順序管不到那一層：條件擺哪一邊決定留下哪些列在 <a href="/blog/backend/01-database/sql/on-describes-where-filters/" data-link-title="SQL.5 ON 與 WHERE：連接條件與篩選條件各自的職責" data-link-desc="條件寫在連接的配對階段與寫在關係組完之後的篩選階段的差別、外連接的保護在 WHERE 被抵銷的機制，以及判斷一個條件該放哪一邊的測試">SQL.5 ON 與 WHERE：連接條件與篩選條件各自的職責</a>，連接怎麼改變列數在 <a href="/blog/backend/01-database/sql/join-changes-rows-and-nulls/" data-link-title="SQL.6 連接之後的列數與空缺：列數膨脹、外連接補的 NULL 與三值邏輯" data-link-desc="連接讓聚合多算的機制與計數、加總、平均、中位數各自的修法，以及外連接補出來的 NULL 在 count、等號比較、NOT IN 與 NOT EXISTS 上的行為">SQL.6 連接之後的列數與空缺</a>。視窗函數的 <code>OVER (ORDER BY ...)</code> 是另一個順序，它決定計算時誰算相鄰、不決定輸出怎麼排，兩者可以不同，<a href="/blog/backend/01-database/sql/window-keeps-rows-grouping-collapses/" data-link-title="SQL.10 分組與視窗函數：各自的產出、選用的依據與 LAG、LEAD 的相鄰列" data-link-desc="GROUP BY 與視窗函數算完之後原本的列還在不在、依結果的一列代表什麼選用哪一個、視窗函數的值怎麼拿來當篩選條件，以及 LAG 取相鄰列時分區、排序鍵與距離條件各自要確認的事">SQL.10 分組與視窗函數：各自的產出、選用的依據與 LAG、LEAD 的相鄰列</a> 的〈排序相鄰與問題要的相鄰：分區、排序鍵與距離條件〉一節寫那一種。</p>
<p>本篇的查詢跑在<a href="/blog/backend/01-database/sql/sample-bookstore-database/" data-link-title="SQL 各篇共用的範例資料庫：一家小書店的顧客、訂單與評價表" data-link-desc="SQL 各篇共用的顧客、訂單與評價表的建表語句與初始資料，以及各篇輸出所用的引擎版本、錯誤訊息與列序的閱讀約定">共用資料庫</a>的訂單表上，並把 101 的金額改成 700、再加三張，讓五張訂單裡有三張同樣是 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="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">2</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;2026-03-10&#39;</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">2</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;2026-03-11&#39;</span><span class="p">,</span><span class="w"> </span><span class="mi">300</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="p">(</span><span class="mi">105</span><span class="p">,</span><span class="w"> </span><span class="mi">3</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;2026-03-12&#39;</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">4</span><span class="cl"><span class="w"></span><span class="c1">-- 101 是 700 元，102、103、105 是 500 元，104 是 300 元</span></span></span></code></pre></div><h2 id="沒有-order-by-時列序的來源">沒有 ORDER BY 時列序的來源</h2>
<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">-- SQLite 3.51
</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">WHERE</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">300</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,102,103,104,105    計畫：SCAN 訂單
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"></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">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="p">(</span><span class="err">金額</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="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">WHERE</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">300</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="w"></span><span class="c1">-- 104,102,103,105,101    計畫：SEARCH 訂單 USING INDEX ix (金額&gt;?)
</span></span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="c1">--   索引照金額排：300 的 104、500 的三張（102、103、105）、700 的 101</span></span></span></code></pre></div><p>查詢的文字一個字都沒改，回來的順序整個換掉了。沒有索引的那一次照資料存放的先後走，建了索引的那一次照索引上的先後走——<strong>兩種都正確</strong>，因為那份關係本來就沒有規定過順序。</p>
<p>這件事在開發時多半看不見。一張小表、一種計畫，看到的順序穩定得像是有保證，而讓它變動的條件（建了索引、資料長大、統計更新、換一家引擎）沒有一項在查詢的文字裡。</p>
<h2 id="order-by-在求值順序裡的位置與它用得到的別名">ORDER BY 在求值順序裡的位置與它用得到的別名</h2>
<p><code>ORDER BY</code> 排的是 <code>SELECT</code> 已經算完的那份結果，所以 <code>SELECT</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="k">SELECT</span><span class="w"> </span><span class="err">金額</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="mi">2</span><span class="w"> </span><span class="k">AS</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="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="c1">-- 三家都接受：1400、1000、1000、1000、600
</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="k">SELECT</span><span class="w"> </span><span class="err">金額</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="mi">2</span><span class="w"> </span><span class="k">AS</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">WHERE</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">1000</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">-- PostgreSQL：column &#34;兩倍&#34; does not exist
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1">-- MySQL：ERROR 1054 Unknown column &#39;兩倍&#39; in &#39;where clause&#39;
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="c1">-- SQLite：收下，回 1400</span></span></span></code></pre></div><p><code>WHERE</code> 那一步發生在算出 <code>兩倍</code> 之前，所以那個名字在那裡還不存在。SQLite 收下同一段，這是它的寬鬆度而不是標準行為（求值順序的完整推導與這條寬鬆度的其他實例在 <a href="/blog/backend/01-database/sql/clause-evaluation-order/" data-link-title="SQL.2 子句的求值順序：每一步可用的欄位、語意模型擋下的寫法與各家引擎放寬的寫法" data-link-desc="從 FROM 到 LIMIT 每一步可用的欄位與值、聚合放進 WHERE、別名用在 WHERE 與列的條件放進 HAVING 在各家引擎上的結果，以及這幾種寫法換引擎時的表現">SQL.2 子句的求值順序：每一步可用的欄位、語意模型擋下的寫法與各家引擎放寬的寫法</a>）。</p>
<h2 id="null-在排序結果裡的位置與各家引擎的預設">NULL 在排序結果裡的位置與各家引擎的預設</h2>
<p>排序鍵含<a href="/blog/backend/01-database/sql/knowledge-cards/null/" data-link-title="NULL（空值）" data-link-desc="查 NULL 在比較、計數與分組裡各自怎麼表現時">空值</a>的時候，<code>NULL</code> 與任何值都比不出大小，所以它落在哪一端由引擎自己規定。會同時是排序鍵又可能為空的欄位不少——選填的折扣金額、還沒完成的那些列的完成時間，都是拿來排序的常見對象。下面的表格與查詢把 103 的金額清成 <code>NULL</code>、刪掉 105，留下四列：</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="k">NULL</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">103</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">DELETE</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">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">105</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 是 500、103 是 NULL、104 是 300</span></span></span></code></pre></div><table>
  <thead>
      <tr>
          <th>引擎</th>
          <th><code>ORDER BY 金額</code></th>
          <th><code>ORDER BY 金額 DESC</code></th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>SQLite 3.51</td>
          <td>NULL 排最前</td>
          <td>NULL 排最後</td>
      </tr>
      <tr>
          <td>MySQL 8.4</td>
          <td>NULL 排最前</td>
          <td>NULL 排最後</td>
      </tr>
      <tr>
          <td>PostgreSQL 18</td>
          <td>NULL 排最後</td>
          <td>NULL 排最前</td>
      </tr>
      <tr>
          <td>DuckDB v0.10.3</td>
          <td>NULL 排最後</td>
          <td>NULL 排最後</td>
      </tr>
  </tbody>
</table>
<p>三家的規定各自自洽：SQLite 與 MySQL 把 <code>NULL</code> 當成比任何值都小，PostgreSQL 當成比任何值都大，DuckDB v0.10.3 則不論升冪降冪都把 <code>NULL</code> 排在最後——它的預設是 <code>NULLS LAST</code>，不隨 <code>DESC</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="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="n">NULLS</span><span class="w"> </span><span class="k">LAST</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="c1">-- PostgreSQL 18、SQLite 3.51、DuckDB v0.10.3：104,102,101,103
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1">-- MySQL 8.4：ERROR 1064 語法錯誤
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"></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">-- 先排一個布林值：金額是 NULL 的列 (金額 IS NULL) 為真，排在為假的列之後
</span></span></span><span class="line"><span class="ln">6</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="p">(</span><span class="err">金額</span><span class="w"> </span><span class="k">IS</span><span class="w"> </span><span class="k">NULL</span><span class="p">),</span><span class="w"> </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">-- PostgreSQL 18、SQLite 3.51、MySQL 8.4、DuckDB v0.10.3：104,102,101,103</span></span></span></code></pre></div><p><code>NULL</code> 是比不出大小的那一種值；字串是比得出、而比法由另一條規則決定的那一種。排序鍵換成姓名時，大小寫算不算相同、重音字母排在哪裡，各家預設不同，同一批名字排出來的順序也不同——<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>
]]></content:encoded></item><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>