<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Query-Design on Tarragon</title><link>https://tarrragon.github.io/blog/tags/query-design/</link><description>Recent content in Query-Design on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Tue, 22 Sep 2026 00:00:00 +0800</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/query-design/index.xml" rel="self" type="application/rss+xml"/><item><title>SQL.0 一段查詢從需求到上線的決定：追問需求、設定判斷標準、選寫法、補前提、量代價、對邊界與交付</title><link>https://tarrragon.github.io/blog/backend/01-database/sql/decisions-from-requirement-to-shipping/</link><pubDate>Tue, 22 Sep 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/sql/decisions-from-requirement-to-shipping/</guid><description>&lt;p>這篇整理一段 SQL 查詢從業務的一句話走到上線之間要做的決定——追問需求、設定判斷標準、選寫法、補前提、量代價、對邊界、交付——每一個決定要決定什麼、決錯之後看到什麼症狀，以及它背後的機制在哪一篇展開。範圍從拿到需求的那一刻開始，到查詢上線、交給下一個環境為止。&lt;/p>
&lt;h2 id="各個決定的內容決錯之後的症狀與機制所在的篇章">各個決定的內容、決錯之後的症狀與機制所在的篇章&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>決定&lt;/th>
 &lt;th>要決定的&lt;/th>
 &lt;th>決錯之後的症狀&lt;/th>
 &lt;th>機制在&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>追問需求&lt;/td>
 &lt;td>這句話真正問的是哪一個資料問題&lt;/td>
 &lt;td>查詢寫對了，而它答的是另一題&lt;/td>
 &lt;td>答案要去問提出需求的人&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>設定判斷標準&lt;/td>
 &lt;td>什麼樣的輸出算對&lt;/td>
 &lt;td>改完查詢不知道有沒有改好&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/well-formed-is-not-correct/" data-link-title="SQL.13 查詢的合法性與答案的正確性：引擎檢查的範圍、答案錯掉的成因與查證方法" data-link-desc="合法與問對了分別由誰負責、答案錯掉時引擎為什麼不報錯，以及靜態分析、約束與反例資料各自判得出哪些錯">SQL.13 查詢的合法性與答案的正確性：引擎檢查的範圍、答案錯掉的成因與查證方法&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>選寫法&lt;/td>
 &lt;td>除了判斷有沒有，還要不要用到被比對那張表的欄位&lt;/td>
 &lt;td>列數不是預期的，而後面的加總跟著錯&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/in-exists-join/" data-link-title="SQL.8 IN、EXISTS 與 JOIN：列數、可取用的欄位與 NULL 的處理" data-link-desc="同一個「找出有對應資料的那些」的問題用成員判斷、存在性判斷與連接各寫一次的結果，選用的判斷標準，以及子查詢含 NULL 時 NOT IN 與 NOT EXISTS 的分岔">SQL.8 IN、EXISTS 與 JOIN：列數、可取用的欄位與 NULL 的處理&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/table-occurrence-and-alias/" data-link-title="SQL.7 表的出現與別名：表名的指稱、別名的作用範圍與自連接" data-link-desc="FROM 裡的表名指的是什麼、同名的兩次出現為什麼被引擎擋下、自連接的前後配對、同組比較與層級關係，以及別名取名的依據">SQL.7 表的出現與別名：表名的指稱、別名的作用範圍與自連接&lt;/a>、&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;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>補前提&lt;/td>
 &lt;td>這段寫法默默假設了什麼&lt;/td>
 &lt;td>資料長成某個樣子之後答案就錯，而不報錯&lt;/td>
 &lt;td>&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 連接之後的列數與空缺：列數膨脹、外連接補的 NULL 與三值邏輯&lt;/a>、&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 分頁的排序鍵與接續標記：同分的列、全序與 OFFSET 的代價&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>量代價&lt;/td>
 &lt;td>這段要跑多久、跑幾次&lt;/td>
 &lt;td>開發時很快，正式資料上拖垮請求&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/cost-lives-in-the-plan/" data-link-title="SQL.17 查詢的代價：資料分布、索引與寫法各自的影響" data-link-desc="找重複值的分組、自連接與 EXISTS 在有無索引與不同重複次數下的實測時間與計畫，以及比較查詢快慢時要補上的資料量、分布、索引與統計資訊">SQL.17 查詢的代價：資料分布、索引與寫法各自的影響&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/knowledge-cards/sargable/" data-link-title="Sargable（可走索引的條件形狀）" data-link-desc="索引存在而計畫仍走全表掃描時，查條件的寫法有沒有擋住那個索引">Sargable&lt;/a> 條件的形狀決定索引用不用得上&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>對邊界&lt;/td>
 &lt;td>需求沒說而資料會有的那些&lt;/td>
 &lt;td>平常都對，特定幾筆資料上靜默算錯&lt;/td>
 &lt;td>&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/well-formed-is-not-correct/" data-link-title="SQL.13 查詢的合法性與答案的正確性：引擎檢查的範圍、答案錯掉的成因與查證方法" data-link-desc="合法與問對了分別由誰負責、答案錯掉時引擎為什麼不報錯，以及靜態分析、約束與反例資料各自判得出哪些錯">SQL.13 查詢的合法性與答案的正確性：引擎檢查的範圍、答案錯掉的成因與查證方法&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>交付&lt;/td>
 &lt;td>換引擎、換資料量、換讀的人之後還成不成立&lt;/td>
 &lt;td>會報錯的差異搬家當天就浮現，不報錯的要等某一筆資料才現形或始終不出聲&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/engine-leniency-and-portability/" data-link-title="SQL.19 引擎寬鬆度與可攜性：各家對同一組寫法的差異、分級與處理時機" data-link-desc="SQLite、DuckDB、PostgreSQL 與 MySQL 在未分組欄位、WHERE 用別名、同表未取別名、字串比對與空值排序上的實測對照，差異從當場報錯到完全靜默的分級，以及依目標組態數決定哪幾級差異在寫的當下處理">SQL.19 引擎寬鬆度與可攜性：各家對同一組寫法的差異、分級與處理時機&lt;/a> 管換引擎、&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/declared-intent-vs-behaviour/" data-link-title="SQL.20 關鍵字的宣告與引擎的行為：宣告落空的形態與查證" data-link-desc="LEFT JOIN、CROSS JOIN、DISTINCT、COALESCE 與表的別名各自對讀的人宣告了什麼、宣告落空時下一個修改的人付出什麼代價，以及把關鍵字換成最弱的版本再跑一次的查證動作">SQL.20 關鍵字的宣告與引擎的行為：宣告落空的形態與查證&lt;/a> 管換讀的人&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>&lt;strong>決錯之後的症狀有一個共同點：查詢照跑而答案不對。&lt;/strong> 只有兩個決定決錯時會當場出聲：量代價決錯時查詢慢得看得出來；交付時換了引擎，有一類差異第一次執行就報錯。&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/engine-leniency-and-portability/" data-link-title="SQL.19 引擎寬鬆度與可攜性：各家對同一組寫法的差異、分級與處理時機" data-link-desc="SQLite、DuckDB、PostgreSQL 與 MySQL 在未分組欄位、WHERE 用別名、同表未取別名、字串比對與空值排序上的實測對照，差異從當場報錯到完全靜默的分級，以及依目標組態數決定哪幾級差異在寫的當下處理">SQL.19 引擎寬鬆度與可攜性：各家對同一組寫法的差異、分級與處理時機&lt;/a> 把跨引擎的差異按發聲的位置分級，第一次執行就報錯的叫當場報錯，其餘的差異要麼等某一筆資料才報錯、要麼始終靜默。其餘的決定決錯時欄位正常、列數合理，沒有任何東西提示要去檢查。&lt;/p>
&lt;h2 id="最常被跳過的決定追問需求設定判斷標準與對邊界">最常被跳過的決定：追問需求、設定判斷標準與對邊界&lt;/h2>
&lt;p>&lt;strong>追問需求與設定判斷標準。&lt;/strong> 這兩個決定發生在打開編輯器之前，所以不留下任何痕跡——跳過了的查詢跟沒跳過的長得一模一樣。而跳過追問需求的代價是整段查詢答錯題，跳過設定判斷標準的代價是後面每一次修改都不知道有沒有改好。&lt;/p>
&lt;p>&lt;strong>對邊界。&lt;/strong> 一句需求描述的是資料正常時的樣子，而正式資料裡會出現提出需求的人沒想到、所以沒說的情形。以「哪幾天氣溫比前一天高」為例：同一個測站同一天有兩筆紀錄（感測器重送、補登、換設備之後兩套系統各寫一筆）、觀測日存的是哪一個時區的日期、三個月後才補進來的舊資料讓昨天的答案今天變了、同一個測站代號在某一天之後指向另一個地點。對邊界這個決定，就是在查詢寫好之後把這些情形逐一列出來，並決定每一種出現時查詢該回什麼。&lt;/p>
&lt;p>這份清單沒有地方可以查。需求裡沒有它，schema 也只記下有哪些欄位、各是什麼型別，不記下資料在現實中會怎麼偏離預期。列得出幾種，取決於看過多少這個領域的資料：看過感測器重送，才會想到同一天可能有兩筆；看過補登，才會想到歷史答案會變。所以這一步的能力靠經驗累積，每在正式資料上遇到一種新的情形，就把它加進這個領域的查詢的檢查清單。&lt;/p>
&lt;p>跳過它的症狀最難察覺：查詢在大部分資料上都對，只在碰到那幾筆特殊資料時算錯，而且不報錯。那幾筆往往要到有人拿結果去對帳，才被發現。&lt;/p>
&lt;h2 id="決定的前提建表時定下的表的形狀">決定的前提：建表時定下的表的形狀&lt;/h2>
&lt;p>從追問需求到交付，每一個決定都做在一組已經建好的表上，而表的形狀是&lt;strong>建表時的決定&lt;/strong>造出來的：欄位允不允許為空、鍵唯不唯一、長欄位放哪一張表、常一起取的資料切在幾張表、字串的比較規則寫在哪一層、外鍵生不生效。選寫法、補前提的時候，鍵唯不唯一、欄位允不允許為空已經定了；量代價的時候，長欄位放哪一張表、常一起取的資料切在幾張表也已經定了。這些建表時的決定替往後每一段查詢定了價——手上要交的是一份 schema 設計而不是一段查詢時，問題落在追問需求之前。以鍵唯不唯一為例：「哪幾天氣溫比前一天高」用自連接、視窗函數與相關子查詢三種寫法，共同押的前提，是同一個測站同一天只有一筆；表上有 &lt;code>UNIQUE (測站, 觀測日)&lt;/code> 時這個前提由資料庫保證，沒有的話每一段查詢都得自己處理重複，否則自連接會把一列配成多列。每一個決定各自替查詢定了什麼代價，實測在 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/design-decisions-price-every-query/" data-link-title="1.16 Schema 設計決定的查詢代價：可空性、排序鍵唯一性、表寬、一對多的切法、比較規則與外鍵執法" data-link-desc="建表時每一個決定有哪些選法、選了之後查詢要多做什麼、代價在什麼條件下才浮現、改回來要付的遷移成本，以及設計當下就能問的問題；輸出為 SQLite 與 PostgreSQL 的實測">backend 1.16 Schema 設計決定的查詢代價&lt;/a>。&lt;/p>
&lt;h2 id="在哪幾天氣溫比前一天高這句需求上做完全部決定的示範">在「哪幾天氣溫比前一天高」這句需求上做完全部決定的示範&lt;/h2>
&lt;p>&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/requirement-to-shipped-query/" data-link-title="SQL.22 查詢設計的完整示範：把「哪幾天氣溫比前一天高」從需求做到上線" data-link-desc="以「哪幾天氣溫比前一天高」為例，把一句業務的話追問成可驗收的資料問題，比較自連接、視窗函數與相關子查詢各自押的前提與實測代價，以及需求沒說而資料會有的重複紀錄、時區與補登">SQL.22 查詢設計的完整示範：把「哪幾天氣溫比前一天高」從需求做到上線&lt;/a> 拿「哪幾天氣溫比前一天高」這句需求從頭做完這些決定：把「前一天」追問成日曆上的前一天還是資料上的前一筆；用自連接、視窗函數與相關子查詢各寫一次，找出三者共同押的前提（同一個測站同一天只有一筆）；看漏掉相隔天數與漏掉分區的兩個錯誤版本：漏掉相隔天數的多報，漏掉分區的錯在哪個方向要看同一天兩個測站誰排在前面；把自連接再分成日期差與等值兩種寫法，在同一組資料上量這兩種自連接、視窗函數與相關子查詢的時間，差到三個量級，其中一種加了索引反而變慢；最後列出重複紀錄、時區、補登與測站搬家這幾種需求沒說的情形。&lt;/p></description><content:encoded><![CDATA[<p>這篇整理一段 SQL 查詢從業務的一句話走到上線之間要做的決定——追問需求、設定判斷標準、選寫法、補前提、量代價、對邊界、交付——每一個決定要決定什麼、決錯之後看到什麼症狀，以及它背後的機制在哪一篇展開。範圍從拿到需求的那一刻開始，到查詢上線、交給下一個環境為止。</p>
<h2 id="各個決定的內容決錯之後的症狀與機制所在的篇章">各個決定的內容、決錯之後的症狀與機制所在的篇章</h2>
<table>
  <thead>
      <tr>
          <th>決定</th>
          <th>要決定的</th>
          <th>決錯之後的症狀</th>
          <th>機制在</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>追問需求</td>
          <td>這句話真正問的是哪一個資料問題</td>
          <td>查詢寫對了，而它答的是另一題</td>
          <td>答案要去問提出需求的人</td>
      </tr>
      <tr>
          <td>設定判斷標準</td>
          <td>什麼樣的輸出算對</td>
          <td>改完查詢不知道有沒有改好</td>
          <td><a href="/blog/backend/01-database/sql/well-formed-is-not-correct/" data-link-title="SQL.13 查詢的合法性與答案的正確性：引擎檢查的範圍、答案錯掉的成因與查證方法" data-link-desc="合法與問對了分別由誰負責、答案錯掉時引擎為什麼不報錯，以及靜態分析、約束與反例資料各自判得出哪些錯">SQL.13 查詢的合法性與答案的正確性：引擎檢查的範圍、答案錯掉的成因與查證方法</a></td>
      </tr>
      <tr>
          <td>選寫法</td>
          <td>除了判斷有沒有，還要不要用到被比對那張表的欄位</td>
          <td>列數不是預期的，而後面的加總跟著錯</td>
          <td><a href="/blog/backend/01-database/sql/in-exists-join/" data-link-title="SQL.8 IN、EXISTS 與 JOIN：列數、可取用的欄位與 NULL 的處理" data-link-desc="同一個「找出有對應資料的那些」的問題用成員判斷、存在性判斷與連接各寫一次的結果，選用的判斷標準，以及子查詢含 NULL 時 NOT IN 與 NOT EXISTS 的分岔">SQL.8 IN、EXISTS 與 JOIN：列數、可取用的欄位與 NULL 的處理</a>、<a href="/blog/backend/01-database/sql/table-occurrence-and-alias/" data-link-title="SQL.7 表的出現與別名：表名的指稱、別名的作用範圍與自連接" data-link-desc="FROM 裡的表名指的是什麼、同名的兩次出現為什麼被引擎擋下、自連接的前後配對、同組比較與層級關係，以及別名取名的依據">SQL.7 表的出現與別名：表名的指稱、別名的作用範圍與自連接</a>、<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></td>
      </tr>
      <tr>
          <td>補前提</td>
          <td>這段寫法默默假設了什麼</td>
          <td>資料長成某個樣子之後答案就錯，而不報錯</td>
          <td><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 連接之後的列數與空缺：列數膨脹、外連接補的 NULL 與三值邏輯</a>、<a href="/blog/backend/01-database/sql/pagination-needs-a-total-order/" data-link-title="SQL.12 分頁的排序鍵與接續標記：同分的列、全序與 OFFSET 的代價" data-link-desc="每一頁各查一次時同分的列與翻頁途中的寫入怎麼造成重複與遺漏、排序鍵補到兩兩可分的條件、展開式與列值式的接續標記條件在排序方向上的差別，以及 OFFSET 隨頁數成長的代價">SQL.12 分頁的排序鍵與接續標記：同分的列、全序與 OFFSET 的代價</a></td>
      </tr>
      <tr>
          <td>量代價</td>
          <td>這段要跑多久、跑幾次</td>
          <td>開發時很快，正式資料上拖垮請求</td>
          <td><a href="/blog/backend/01-database/sql/cost-lives-in-the-plan/" data-link-title="SQL.17 查詢的代價：資料分布、索引與寫法各自的影響" data-link-desc="找重複值的分組、自連接與 EXISTS 在有無索引與不同重複次數下的實測時間與計畫，以及比較查詢快慢時要補上的資料量、分布、索引與統計資訊">SQL.17 查詢的代價：資料分布、索引與寫法各自的影響</a>、<a href="/blog/backend/01-database/sql/knowledge-cards/sargable/" data-link-title="Sargable（可走索引的條件形狀）" data-link-desc="索引存在而計畫仍走全表掃描時，查條件的寫法有沒有擋住那個索引">Sargable</a> 條件的形狀決定索引用不用得上</td>
      </tr>
      <tr>
          <td>對邊界</td>
          <td>需求沒說而資料會有的那些</td>
          <td>平常都對，特定幾筆資料上靜默算錯</td>
          <td><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/well-formed-is-not-correct/" data-link-title="SQL.13 查詢的合法性與答案的正確性：引擎檢查的範圍、答案錯掉的成因與查證方法" data-link-desc="合法與問對了分別由誰負責、答案錯掉時引擎為什麼不報錯，以及靜態分析、約束與反例資料各自判得出哪些錯">SQL.13 查詢的合法性與答案的正確性：引擎檢查的範圍、答案錯掉的成因與查證方法</a></td>
      </tr>
      <tr>
          <td>交付</td>
          <td>換引擎、換資料量、換讀的人之後還成不成立</td>
          <td>會報錯的差異搬家當天就浮現，不報錯的要等某一筆資料才現形或始終不出聲</td>
          <td><a href="/blog/backend/01-database/sql/engine-leniency-and-portability/" data-link-title="SQL.19 引擎寬鬆度與可攜性：各家對同一組寫法的差異、分級與處理時機" data-link-desc="SQLite、DuckDB、PostgreSQL 與 MySQL 在未分組欄位、WHERE 用別名、同表未取別名、字串比對與空值排序上的實測對照，差異從當場報錯到完全靜默的分級，以及依目標組態數決定哪幾級差異在寫的當下處理">SQL.19 引擎寬鬆度與可攜性：各家對同一組寫法的差異、分級與處理時機</a> 管換引擎、<a href="/blog/backend/01-database/sql/declared-intent-vs-behaviour/" data-link-title="SQL.20 關鍵字的宣告與引擎的行為：宣告落空的形態與查證" data-link-desc="LEFT JOIN、CROSS JOIN、DISTINCT、COALESCE 與表的別名各自對讀的人宣告了什麼、宣告落空時下一個修改的人付出什麼代價，以及把關鍵字換成最弱的版本再跑一次的查證動作">SQL.20 關鍵字的宣告與引擎的行為：宣告落空的形態與查證</a> 管換讀的人</td>
      </tr>
  </tbody>
</table>
<p><strong>決錯之後的症狀有一個共同點：查詢照跑而答案不對。</strong> 只有兩個決定決錯時會當場出聲：量代價決錯時查詢慢得看得出來；交付時換了引擎，有一類差異第一次執行就報錯。<a href="/blog/backend/01-database/sql/engine-leniency-and-portability/" data-link-title="SQL.19 引擎寬鬆度與可攜性：各家對同一組寫法的差異、分級與處理時機" data-link-desc="SQLite、DuckDB、PostgreSQL 與 MySQL 在未分組欄位、WHERE 用別名、同表未取別名、字串比對與空值排序上的實測對照，差異從當場報錯到完全靜默的分級，以及依目標組態數決定哪幾級差異在寫的當下處理">SQL.19 引擎寬鬆度與可攜性：各家對同一組寫法的差異、分級與處理時機</a> 把跨引擎的差異按發聲的位置分級，第一次執行就報錯的叫當場報錯，其餘的差異要麼等某一筆資料才報錯、要麼始終靜默。其餘的決定決錯時欄位正常、列數合理，沒有任何東西提示要去檢查。</p>
<h2 id="最常被跳過的決定追問需求設定判斷標準與對邊界">最常被跳過的決定：追問需求、設定判斷標準與對邊界</h2>
<p><strong>追問需求與設定判斷標準。</strong> 這兩個決定發生在打開編輯器之前，所以不留下任何痕跡——跳過了的查詢跟沒跳過的長得一模一樣。而跳過追問需求的代價是整段查詢答錯題，跳過設定判斷標準的代價是後面每一次修改都不知道有沒有改好。</p>
<p><strong>對邊界。</strong> 一句需求描述的是資料正常時的樣子，而正式資料裡會出現提出需求的人沒想到、所以沒說的情形。以「哪幾天氣溫比前一天高」為例：同一個測站同一天有兩筆紀錄（感測器重送、補登、換設備之後兩套系統各寫一筆）、觀測日存的是哪一個時區的日期、三個月後才補進來的舊資料讓昨天的答案今天變了、同一個測站代號在某一天之後指向另一個地點。對邊界這個決定，就是在查詢寫好之後把這些情形逐一列出來，並決定每一種出現時查詢該回什麼。</p>
<p>這份清單沒有地方可以查。需求裡沒有它，schema 也只記下有哪些欄位、各是什麼型別，不記下資料在現實中會怎麼偏離預期。列得出幾種，取決於看過多少這個領域的資料：看過感測器重送，才會想到同一天可能有兩筆；看過補登，才會想到歷史答案會變。所以這一步的能力靠經驗累積，每在正式資料上遇到一種新的情形，就把它加進這個領域的查詢的檢查清單。</p>
<p>跳過它的症狀最難察覺：查詢在大部分資料上都對，只在碰到那幾筆特殊資料時算錯，而且不報錯。那幾筆往往要到有人拿結果去對帳，才被發現。</p>
<h2 id="決定的前提建表時定下的表的形狀">決定的前提：建表時定下的表的形狀</h2>
<p>從追問需求到交付，每一個決定都做在一組已經建好的表上，而表的形狀是<strong>建表時的決定</strong>造出來的：欄位允不允許為空、鍵唯不唯一、長欄位放哪一張表、常一起取的資料切在幾張表、字串的比較規則寫在哪一層、外鍵生不生效。選寫法、補前提的時候，鍵唯不唯一、欄位允不允許為空已經定了；量代價的時候，長欄位放哪一張表、常一起取的資料切在幾張表也已經定了。這些建表時的決定替往後每一段查詢定了價——手上要交的是一份 schema 設計而不是一段查詢時，問題落在追問需求之前。以鍵唯不唯一為例：「哪幾天氣溫比前一天高」用自連接、視窗函數與相關子查詢三種寫法，共同押的前提，是同一個測站同一天只有一筆；表上有 <code>UNIQUE (測站, 觀測日)</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>
<h2 id="在哪幾天氣溫比前一天高這句需求上做完全部決定的示範">在「哪幾天氣溫比前一天高」這句需求上做完全部決定的示範</h2>
<p><a href="/blog/backend/01-database/sql/requirement-to-shipped-query/" data-link-title="SQL.22 查詢設計的完整示範：把「哪幾天氣溫比前一天高」從需求做到上線" data-link-desc="以「哪幾天氣溫比前一天高」為例，把一句業務的話追問成可驗收的資料問題，比較自連接、視窗函數與相關子查詢各自押的前提與實測代價，以及需求沒說而資料會有的重複紀錄、時區與補登">SQL.22 查詢設計的完整示範：把「哪幾天氣溫比前一天高」從需求做到上線</a> 拿「哪幾天氣溫比前一天高」這句需求從頭做完這些決定：把「前一天」追問成日曆上的前一天還是資料上的前一筆；用自連接、視窗函數與相關子查詢各寫一次，找出三者共同押的前提（同一個測站同一天只有一筆）；看漏掉相隔天數與漏掉分區的兩個錯誤版本：漏掉相隔天數的多報，漏掉分區的錯在哪個方向要看同一天兩個測站誰排在前面；把自連接再分成日期差與等值兩種寫法，在同一組資料上量這兩種自連接、視窗函數與相關子查詢的時間，差到三個量級，其中一種加了索引反而變慢；最後列出重複紀錄、時區、補登與測站搬家這幾種需求沒說的情形。</p>
]]></content:encoded></item><item><title>SQL.22 查詢設計的完整示範：把「哪幾天氣溫比前一天高」從需求做到上線</title><link>https://tarrragon.github.io/blog/backend/01-database/sql/requirement-to-shipped-query/</link><pubDate>Tue, 22 Sep 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/sql/requirement-to-shipped-query/</guid><description>&lt;p>本篇的對象是一句需求——「&lt;strong>想知道哪幾天氣溫比前一天高&lt;/strong>」——以及它到一段跑在正式資料上的查詢之間要做的每一個決定。涵蓋從這句話開始、到查詢交付為止，從追問需求到交付全部在同一個需求上走完；每個決定背後的機制在這裡只用到為止，展開在各自的專篇。&lt;/p>
&lt;p>這句需求離一段可以執行的查詢還隔著幾個決定，而那些決定的答案都不在資料庫裡，也不在 SQL 裡。&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>——氣溫資料需要缺測與多測站才顯示得出要講的那幾件事。輸出都是實際跑出來的，引擎是 SQLite 3.51.0。&lt;/p>
&lt;h2 id="寫查詢之前的追問結果的用途前一天的定義缺測與測站數">寫查詢之前的追問：結果的用途、「前一天」的定義、缺測與測站數&lt;/h2>
&lt;p>下面四個追問，答案要在寫下任何一段查詢之前拿到，因為每一個的答案都會換掉後面全部的東西：&lt;/p>
&lt;p>&lt;strong>這個結果要餵給哪一個決定。&lt;/strong> 是要在超過門檻時發告警、是月報上的一張圖、還是模型的一個特徵。答案決定它跑幾次——一次性的查詢、每天排程一次、還是每次有人開頁面就跑一次。而跑的次數決定代價預算，代價預算決定後面代價量測那張表要看哪一欄。&lt;/p>
&lt;p>&lt;strong>「前一天」是日曆上的前一天，還是資料上的前一筆。&lt;/strong> 這兩個是不同的問題，而&lt;strong>資料完整的時候它們的答案相同&lt;/strong>——所以在開發用的乾淨資料上分不出來，要等到缺測發生。&lt;/p>
&lt;p>&lt;strong>缺一天的時候要什麼答案。&lt;/strong> 三種都合理：跳過（沒有前一天就不算）、視為斷點（明確標示這裡量不到）、或者補值（用前後內插）。三種對應三段不同的查詢。&lt;/p>
&lt;p>&lt;strong>一個測站還是幾百個。&lt;/strong> 一個的時候整批資料就是一條時間序列；幾百個的時候「前一天」必須限定在同一個測站之內，而漏掉這個限定不會報錯。&lt;/p>
&lt;p>這些追問的答案&lt;strong>不在資料庫裡，也不在 SQL 裡，要去問人&lt;/strong>。而不問的話，每一個追問都會有一個預設答案被偷偷選走：多數人預設這是一次性的查詢、不去想它以後要跑幾次，預設寫成「資料上的前一筆」、預設跳過缺測、預設只有一個測站。&lt;/p>
&lt;h2 id="可驗收的需求敘述與帶缺測的驗收資料">可驗收的需求敘述與帶缺測的驗收資料&lt;/h2>
&lt;p>追問的答案決定了要寫的是哪一段查詢。本篇往下取這一組：要餵給每天一次的排程報表、「前一天」指日曆上的前一天、缺測的那一天不算、而且有很多個測站。&lt;/p>
&lt;p>翻成一句話：&lt;/p>
&lt;blockquote>
&lt;p>對每一個測站，找出所有「同測站、日期剛好早一天」的那筆觀測存在、而且氣溫比它高的那些觀測。&lt;/p>&lt;/blockquote>
&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 的 DROP 一次只收一張表）
&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">DROP&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">TABLE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">IF&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">EXISTS&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>&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">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="err">氣溫&lt;/span>&lt;span class="w"> &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="err">紀錄編號&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">INTEGER&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>&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="err">測站&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">TEXT&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="err">觀測日&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">TEXT&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="err">攝氏&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">INT&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="p">);&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1">-- 台北的 3/05 缺測（感測器當天離線），其餘每天一筆
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">&lt;span class="c1">&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="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>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;台北&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="s1">&amp;#39;2026-03-01&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="mi">18&lt;/span>&lt;span class="p">),(&lt;/span>&lt;span class="s1">&amp;#39;台北&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="s1">&amp;#39;2026-03-02&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="mi">22&lt;/span>&lt;span class="p">),(&lt;/span>&lt;span class="s1">&amp;#39;台北&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="s1">&amp;#39;2026-03-03&amp;#39;&lt;/span>&lt;span class="p">,&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">13&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;台北&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="s1">&amp;#39;2026-03-04&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="mi">15&lt;/span>&lt;span class="p">),&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;台北&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="s1">&amp;#39;2026-03-06&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="mi">25&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">14&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;台北&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="s1">&amp;#39;2026-03-07&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="mi">24&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">15&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;高雄&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="s1">&amp;#39;2026-03-01&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="mi">26&lt;/span>&lt;span class="p">),(&lt;/span>&lt;span class="s1">&amp;#39;高雄&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="s1">&amp;#39;2026-03-02&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="mi">25&lt;/span>&lt;span class="p">),(&lt;/span>&lt;span class="s1">&amp;#39;高雄&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="s1">&amp;#39;2026-03-03&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="mi">28&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">16&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;高雄&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="s1">&amp;#39;2026-03-04&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="mi">27&lt;/span>&lt;span class="p">),(&lt;/span>&lt;span class="s1">&amp;#39;高雄&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="s1">&amp;#39;2026-03-05&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="mi">29&lt;/span>&lt;span class="p">),(&lt;/span>&lt;span class="s1">&amp;#39;高雄&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="s1">&amp;#39;2026-03-06&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="mi">30&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">17&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;高雄&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="s1">&amp;#39;2026-03-07&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="mi">28&lt;/span>&lt;span class="p">);&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>照上面那句話逐筆判，正確答案是四筆：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">台北 2026-03-02 22 &amp;gt; 18
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">高雄 2026-03-03 28 &amp;gt; 25
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">高雄 2026-03-05 29 &amp;gt; 27
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">高雄 2026-03-06 30 &amp;gt; 29&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>台北的 3/06（25 度）不算——它的前一天是 3/05，而那一天沒有觀測。&lt;strong>這一筆是整組資料的判斷標準&lt;/strong>：任何一段回它的查詢，都答錯了上面那句話。&lt;/p>
&lt;h2 id="自連接視窗函數與相關子查詢各自押的前提">自連接、視窗函數與相關子查詢各自押的前提&lt;/h2>
&lt;p>&lt;strong>自連接&lt;/strong>把同一張表當成兩次出現，一次扮演「今天」、一次扮演「昨天」（&lt;a href="https://tarrragon.github.io/blog/backend/01-database/sql/table-occurrence-and-alias/" data-link-title="SQL.7 表的出現與別名：表名的指稱、別名的作用範圍與自連接" data-link-desc="FROM 裡的表名指的是什麼、同名的兩次出現為什麼被引擎擋下、自連接的前後配對、同組比較與層級關係，以及別名取名的依據">SQL.7 表的出現與別名：表名的指稱、別名的作用範圍與自連接&lt;/a>）：&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">SELECT&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 class="err">今&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="err">觀測日&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">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">氣溫&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">今&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">JOIN&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">氣溫&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">昨&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="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="w"> &lt;/span>&lt;span class="o">=&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="w"> &lt;/span>&lt;span class="c1">-- 限定在同一個測站之內
&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 class="k">AND&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="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">date&lt;/span>&lt;span class="p">(&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 class="s1">&amp;#39;-1 day&amp;#39;&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c1">-- 「前一天」是日曆上的前一天
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">WHERE&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="w"> &lt;/span>&lt;span class="o">&amp;gt;&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="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">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="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="err">觀測日&lt;/span>&lt;span class="p">;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>date(今.觀測日, '-1 day')&lt;/code> 是 SQLite 的日期運算，把字串日期往前推一天。別名 &lt;code>今&lt;/code> 與 &lt;code>昨&lt;/code> 各自標示這一次出現扮演什麼角色——欄位一定要帶前綴，兩次出現共用同一個表名的時候不帶前綴會直接被擋下（&lt;code>ambiguous column name: 測站&lt;/code>）。&lt;/p>
&lt;p>&lt;strong>視窗函數&lt;/strong>沿著排好的順序往前取一列（&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;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">SELECT&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="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="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">SELECT&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="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>&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="n">LAG&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="n">OVER&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">w&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&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>&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="n">julianday&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="o">-&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">julianday&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">LAG&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="n">OVER&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">w&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">相隔&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">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">氣溫&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="n">WINDOW&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">w&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">PARTITION&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">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="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="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="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="err">前溫&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AND&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">1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">9&lt;/span>&lt;span class="cl">&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="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">觀測日&lt;/span>&lt;span class="p">;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>WINDOW w AS (...)&lt;/code> 把窗的定義取個名字，讓兩個 &lt;code>LAG&lt;/code> 共用同一個窗；&lt;code>julianday()&lt;/code> 把日期換成可以相減的數值，兩筆相差幾天由它算出來。&lt;code>相隔 = 1&lt;/code> 這個條件是必要的——&lt;code>LAG&lt;/code> 交出的是&lt;strong>排序後的相鄰一列&lt;/strong>，而需求要的是&lt;strong>日曆上的前一天&lt;/strong>，兩者只在資料連續時相同。&lt;/p></description><content:encoded><![CDATA[<p>本篇的對象是一句需求——「<strong>想知道哪幾天氣溫比前一天高</strong>」——以及它到一段跑在正式資料上的查詢之間要做的每一個決定。涵蓋從這句話開始、到查詢交付為止，從追問需求到交付全部在同一個需求上走完；每個決定背後的機制在這裡只用到為止，展開在各自的專篇。</p>
<p>這句需求離一段可以執行的查詢還隔著幾個決定，而那些決定的答案都不在資料庫裡，也不在 SQL 裡。</p>
<p>本篇的查詢跑在自己的表上，不用<a href="/blog/backend/01-database/sql/sample-bookstore-database/" data-link-title="SQL 各篇共用的範例資料庫：一家小書店的顧客、訂單與評價表" data-link-desc="SQL 各篇共用的顧客、訂單與評價表的建表語句與初始資料，以及各篇輸出所用的引擎版本、錯誤訊息與列序的閱讀約定">共用的範例資料庫</a>——氣溫資料需要缺測與多測站才顯示得出要講的那幾件事。輸出都是實際跑出來的，引擎是 SQLite 3.51.0。</p>
<h2 id="寫查詢之前的追問結果的用途前一天的定義缺測與測站數">寫查詢之前的追問：結果的用途、「前一天」的定義、缺測與測站數</h2>
<p>下面四個追問，答案要在寫下任何一段查詢之前拿到，因為每一個的答案都會換掉後面全部的東西：</p>
<p><strong>這個結果要餵給哪一個決定。</strong> 是要在超過門檻時發告警、是月報上的一張圖、還是模型的一個特徵。答案決定它跑幾次——一次性的查詢、每天排程一次、還是每次有人開頁面就跑一次。而跑的次數決定代價預算，代價預算決定後面代價量測那張表要看哪一欄。</p>
<p><strong>「前一天」是日曆上的前一天，還是資料上的前一筆。</strong> 這兩個是不同的問題，而<strong>資料完整的時候它們的答案相同</strong>——所以在開發用的乾淨資料上分不出來，要等到缺測發生。</p>
<p><strong>缺一天的時候要什麼答案。</strong> 三種都合理：跳過（沒有前一天就不算）、視為斷點（明確標示這裡量不到）、或者補值（用前後內插）。三種對應三段不同的查詢。</p>
<p><strong>一個測站還是幾百個。</strong> 一個的時候整批資料就是一條時間序列；幾百個的時候「前一天」必須限定在同一個測站之內，而漏掉這個限定不會報錯。</p>
<p>這些追問的答案<strong>不在資料庫裡，也不在 SQL 裡，要去問人</strong>。而不問的話，每一個追問都會有一個預設答案被偷偷選走：多數人預設這是一次性的查詢、不去想它以後要跑幾次，預設寫成「資料上的前一筆」、預設跳過缺測、預設只有一個測站。</p>
<h2 id="可驗收的需求敘述與帶缺測的驗收資料">可驗收的需求敘述與帶缺測的驗收資料</h2>
<p>追問的答案決定了要寫的是哪一段查詢。本篇往下取這一組：要餵給每天一次的排程報表、「前一天」指日曆上的前一天、缺測的那一天不算、而且有很多個測站。</p>
<p>翻成一句話：</p>
<blockquote>
<p>對每一個測站，找出所有「同測站、日期剛好早一天」的那筆觀測存在、而且氣溫比它高的那些觀測。</p></blockquote>
<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 的 DROP 一次只收一張表）
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="k">DROP</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="k">IF</span><span class="w"> </span><span class="k">EXISTS</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"> 3</span><span class="cl"><span class="w"></span><span class="k">CREATE</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="err">氣溫</span><span class="w"> </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="err">紀錄編號</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></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="w">  </span><span class="err">測站</span><span class="w">     </span><span class="nb">TEXT</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="err">觀測日</span><span class="w">   </span><span class="nb">TEXT</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="err">攝氏</span><span class="w">     </span><span class="nb">INT</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="w"></span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="w"></span><span class="c1">-- 台北的 3/05 缺測（感測器當天離線），其餘每天一筆
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="c1"></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="err">攝氏</span><span class="p">)</span><span class="w"> </span><span class="k">VALUES</span><span class="w">
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="w"> </span><span class="p">(</span><span class="s1">&#39;台北&#39;</span><span class="p">,</span><span class="s1">&#39;2026-03-01&#39;</span><span class="p">,</span><span class="mi">18</span><span class="p">),(</span><span class="s1">&#39;台北&#39;</span><span class="p">,</span><span class="s1">&#39;2026-03-02&#39;</span><span class="p">,</span><span class="mi">22</span><span class="p">),(</span><span class="s1">&#39;台北&#39;</span><span class="p">,</span><span class="s1">&#39;2026-03-03&#39;</span><span class="p">,</span><span class="mi">20</span><span class="p">),</span><span class="w">
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="w"> </span><span class="p">(</span><span class="s1">&#39;台北&#39;</span><span class="p">,</span><span class="s1">&#39;2026-03-04&#39;</span><span class="p">,</span><span class="mi">15</span><span class="p">),</span><span class="w">                          </span><span class="p">(</span><span class="s1">&#39;台北&#39;</span><span class="p">,</span><span class="s1">&#39;2026-03-06&#39;</span><span class="p">,</span><span class="mi">25</span><span class="p">),</span><span class="w">
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="w"> </span><span class="p">(</span><span class="s1">&#39;台北&#39;</span><span class="p">,</span><span class="s1">&#39;2026-03-07&#39;</span><span class="p">,</span><span class="mi">24</span><span class="p">),</span><span class="w">
</span></span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="w"> </span><span class="p">(</span><span class="s1">&#39;高雄&#39;</span><span class="p">,</span><span class="s1">&#39;2026-03-01&#39;</span><span class="p">,</span><span class="mi">26</span><span class="p">),(</span><span class="s1">&#39;高雄&#39;</span><span class="p">,</span><span class="s1">&#39;2026-03-02&#39;</span><span class="p">,</span><span class="mi">25</span><span class="p">),(</span><span class="s1">&#39;高雄&#39;</span><span class="p">,</span><span class="s1">&#39;2026-03-03&#39;</span><span class="p">,</span><span class="mi">28</span><span class="p">),</span><span class="w">
</span></span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="w"> </span><span class="p">(</span><span class="s1">&#39;高雄&#39;</span><span class="p">,</span><span class="s1">&#39;2026-03-04&#39;</span><span class="p">,</span><span class="mi">27</span><span class="p">),(</span><span class="s1">&#39;高雄&#39;</span><span class="p">,</span><span class="s1">&#39;2026-03-05&#39;</span><span class="p">,</span><span class="mi">29</span><span class="p">),(</span><span class="s1">&#39;高雄&#39;</span><span class="p">,</span><span class="s1">&#39;2026-03-06&#39;</span><span class="p">,</span><span class="mi">30</span><span class="p">),</span><span class="w">
</span></span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="w"> </span><span class="p">(</span><span class="s1">&#39;高雄&#39;</span><span class="p">,</span><span class="s1">&#39;2026-03-07&#39;</span><span class="p">,</span><span class="mi">28</span><span class="p">);</span></span></span></code></pre></div><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">台北  2026-03-02    22 &gt; 18
</span></span><span class="line"><span class="ln">2</span><span class="cl">高雄  2026-03-03    28 &gt; 25
</span></span><span class="line"><span class="ln">3</span><span class="cl">高雄  2026-03-05    29 &gt; 27
</span></span><span class="line"><span class="ln">4</span><span class="cl">高雄  2026-03-06    30 &gt; 29</span></span></code></pre></div><p>台北的 3/06（25 度）不算——它的前一天是 3/05，而那一天沒有觀測。<strong>這一筆是整組資料的判斷標準</strong>：任何一段回它的查詢，都答錯了上面那句話。</p>
<h2 id="自連接視窗函數與相關子查詢各自押的前提">自連接、視窗函數與相關子查詢各自押的前提</h2>
<p><strong>自連接</strong>把同一張表當成兩次出現，一次扮演「今天」、一次扮演「昨天」（<a href="/blog/backend/01-database/sql/table-occurrence-and-alias/" data-link-title="SQL.7 表的出現與別名：表名的指稱、別名的作用範圍與自連接" data-link-desc="FROM 裡的表名指的是什麼、同名的兩次出現為什麼被引擎擋下、自連接的前後配對、同組比較與層級關係，以及別名取名的依據">SQL.7 表的出現與別名：表名的指稱、別名的作用範圍與自連接</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="k">SELECT</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 class="err">今</span><span class="p">.</span><span class="err">觀測日</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">FROM</span><span class="w"> </span><span class="err">氣溫</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">JOIN</span><span class="w"> </span><span class="err">氣溫</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">ON</span><span class="w"> </span><span class="err">昨</span><span class="p">.</span><span class="err">測站</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="err">今</span><span class="p">.</span><span class="err">測站</span><span class="w">                                  </span><span class="c1">-- 限定在同一個測站之內
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"></span><span class="w"> </span><span class="k">AND</span><span class="w"> </span><span class="err">昨</span><span class="p">.</span><span class="err">觀測日</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nb">date</span><span class="p">(</span><span class="err">今</span><span class="p">.</span><span class="err">觀測日</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;-1 day&#39;</span><span class="p">)</span><span class="w">              </span><span class="c1">-- 「前一天」是日曆上的前一天
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1"></span><span class="k">WHERE</span><span class="w"> </span><span class="err">今</span><span class="p">.</span><span class="err">攝氏</span><span class="w"> </span><span class="o">&gt;</span><span class="w"> </span><span class="err">昨</span><span class="p">.</span><span class="err">攝氏</span><span class="w">
</span></span></span><span class="line"><span class="ln">7</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="p">.</span><span class="err">測站</span><span class="p">,</span><span class="w"> </span><span class="err">今</span><span class="p">.</span><span class="err">觀測日</span><span class="p">;</span></span></span></code></pre></div><p><code>date(今.觀測日, '-1 day')</code> 是 SQLite 的日期運算，把字串日期往前推一天。別名 <code>今</code> 與 <code>昨</code> 各自標示這一次出現扮演什麼角色——欄位一定要帶前綴，兩次出現共用同一個表名的時候不帶前綴會直接被擋下（<code>ambiguous column name: 測站</code>）。</p>
<p><strong>視窗函數</strong>沿著排好的順序往前取一列（<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>





<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="p">,</span><span class="w"> </span><span class="err">觀測日</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </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">SELECT</span><span class="w"> </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="err">攝氏</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="n">LAG</span><span class="p">(</span><span class="err">攝氏</span><span class="p">)</span><span class="w">   </span><span class="n">OVER</span><span class="w"> </span><span class="n">w</span><span class="w"> </span><span class="k">AS</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">4</span><span class="cl"><span class="w">         </span><span class="n">julianday</span><span class="p">(</span><span class="err">觀測日</span><span class="p">)</span><span class="w"> </span><span class="o">-</span><span class="w"> </span><span class="n">julianday</span><span class="p">(</span><span class="n">LAG</span><span class="p">(</span><span class="err">觀測日</span><span class="p">)</span><span class="w"> </span><span class="n">OVER</span><span class="w"> </span><span class="n">w</span><span class="p">)</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="err">相隔</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">FROM</span><span class="w"> </span><span class="err">氣溫</span><span class="w">
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="w">  </span><span class="n">WINDOW</span><span class="w"> </span><span class="n">w</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="p">(</span><span class="n">PARTITION</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">ORDER</span><span class="w"> </span><span class="k">BY</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="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="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="err">前溫</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">=</span><span class="w"> </span><span class="mi">1</span><span class="w">
</span></span></span><span class="line"><span class="ln">9</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="p">,</span><span class="w"> </span><span class="err">觀測日</span><span class="p">;</span></span></span></code></pre></div><p><code>WINDOW w AS (...)</code> 把窗的定義取個名字，讓兩個 <code>LAG</code> 共用同一個窗；<code>julianday()</code> 把日期換成可以相減的數值，兩筆相差幾天由它算出來。<code>相隔 = 1</code> 這個條件是必要的——<code>LAG</code> 交出的是<strong>排序後的相鄰一列</strong>，而需求要的是<strong>日曆上的前一天</strong>，兩者只在資料連續時相同。</p>
<p><strong>相關子查詢</strong>對每一列去問一次「我的前一天是幾度」（<a href="/blog/backend/01-database/sql/in-exists-join/" data-link-title="SQL.8 IN、EXISTS 與 JOIN：列數、可取用的欄位與 NULL 的處理" data-link-desc="同一個「找出有對應資料的那些」的問題用成員判斷、存在性判斷與連接各寫一次的結果，選用的判斷標準，以及子查詢含 NULL 時 NOT IN 與 NOT EXISTS 的分岔">SQL.8 IN、EXISTS 與 JOIN：列數、可取用的欄位與 NULL 的處理</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="k">SELECT</span><span class="w"> </span><span class="err">測站</span><span class="p">,</span><span class="w"> </span><span class="err">觀測日</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">FROM</span><span class="w"> </span><span class="err">氣溫</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">&gt;</span><span class="w"> </span><span class="p">(</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="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="err">昨</span><span class="p">.</span><span class="err">測站</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="err">今</span><span class="p">.</span><span class="err">測站</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">AND</span><span class="w"> </span><span class="err">昨</span><span class="p">.</span><span class="err">觀測日</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nb">date</span><span class="p">(</span><span class="err">今</span><span class="p">.</span><span class="err">觀測日</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;-1 day&#39;</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="k">ORDER</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="err">測站</span><span class="p">,</span><span class="w"> </span><span class="err">觀測日</span><span class="p">;</span></span></span></code></pre></div><p>前一天不存在的時候括號裡那一段回零列，純量子查詢因此給出 <code>NULL</code>，而拿任何數字跟 <code>NULL</code> 比大小的答案是未知，那一列被 <code>WHERE</code> 丟掉（<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 連接之後的列數與空缺：列數膨脹、外連接補的 NULL 與三值邏輯</a>）。<strong>台北 3/06 正是靠這個機制被排除的</strong>——排除它的是空值的比較落在真、假之外的「未知」上，而查詢裡沒有任何一個條件在擋它。</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">台北  2026-03-02
</span></span><span class="line"><span class="ln">2</span><span class="cl">高雄  2026-03-03
</span></span><span class="line"><span class="ln">3</span><span class="cl">高雄  2026-03-05
</span></span><span class="line"><span class="ln">4</span><span class="cl">高雄  2026-03-06</span></span></code></pre></div><p>三者押的前提不同。自連接押「同一個日期在同一個測站上只有一筆」——有兩筆的時候一列會配到多列，結果多出來（<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 連接之後的列數與空缺：列數膨脹、外連接補的 NULL 與三值邏輯</a>）。視窗函數押「排序鍵在每個分區裡分得出唯一的次序」——並列的時候「前一列」是哪一列沒有定義（<a href="/blog/backend/01-database/sql/pagination-needs-a-total-order/" data-link-title="SQL.12 分頁的排序鍵與接續標記：同分的列、全序與 OFFSET 的代價" data-link-desc="每一頁各查一次時同分的列與翻頁途中的寫入怎麼造成重複與遺漏、排序鍵補到兩兩可分的條件、展開式與列值式的接續標記條件在排序方向上的差別，以及 OFFSET 隨頁數成長的代價">SQL.12 分頁的排序鍵與接續標記：同分的列、全序與 OFFSET 的代價</a>）。相關子查詢押「括號裡那一段最多回一列」——而<strong>回多列的時候兩家引擎的反應相反</strong>：PostgreSQL 18.6 報 <code>more than one row returned by a subquery used as an expression</code>，SQLite 3.51.0 靜默取其中一列往下算。這一條落在 <a href="/blog/backend/01-database/sql/engine-leniency-and-portability/" data-link-title="SQL.19 引擎寬鬆度與可攜性：各家對同一組寫法的差異、分級與處理時機" data-link-desc="SQLite、DuckDB、PostgreSQL 與 MySQL 在未分組欄位、WHERE 用別名、同表未取別名、字串比對與空值排序上的實測對照，差異從當場報錯到完全靜默的分級，以及依目標組態數決定哪幾級差異在寫的當下處理">SQL.19 引擎寬鬆度與可攜性：各家對同一組寫法的差異、分級與處理時機</a> 的引擎差異上。</p>
<p><strong>三個前提說的是同一件事</strong>：<code>(測站, 觀測日)</code> 這一組唯不唯一。那是 schema 上的一條約束，而三段查詢沒有一段查得到它有沒有成立——<a href="/blog/backend/01-database/sql/foreign-key-and-referential-integrity/" data-link-title="SQL.18 外鍵與參照完整性：宣告、生效與查詢得到的保證" data-link-desc="外鍵擋下哪些寫入與 NULL 的放行、SQLite、PostgreSQL 與 MySQL 讓宣告與生效分開的位置與查法、約束生效後查詢省掉的判斷、父列刪除時子列的去向，以及外鍵涵蓋不到的寫入與規則">外鍵與約束要去系統目錄查</a>，設計側的完整推導在 <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>
<h2 id="漏掉相隔天數與漏掉分區的錯誤版本">漏掉相隔天數與漏掉分區的錯誤版本</h2>
<p>上面那個 <code>相隔 = 1</code> 與 <code>PARTITION BY 測站</code>，漏掉任何一個查詢都照跑。</p>
<p><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">SELECT</span><span class="w"> </span><span class="err">測站</span><span class="p">,</span><span class="w"> </span><span class="err">觀測日</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </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">SELECT</span><span class="w"> </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="err">攝氏</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="n">LAG</span><span class="p">(</span><span class="err">攝氏</span><span class="p">)</span><span class="w"> </span><span class="n">OVER</span><span class="w"> </span><span class="p">(</span><span class="n">PARTITION</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">ORDER</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="err">觀測日</span><span class="p">)</span><span class="w"> </span><span class="k">AS</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">FROM</span><span class="w"> </span><span class="err">氣溫</span><span class="w">
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="w"></span><span class="p">)</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="err">前溫</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">台北  2026-03-02
</span></span><span class="line"><span class="ln">2</span><span class="cl">台北  2026-03-06      ← 多出來的
</span></span><span class="line"><span class="ln">3</span><span class="cl">高雄  2026-03-03
</span></span><span class="line"><span class="ln">4</span><span class="cl">高雄  2026-03-05
</span></span><span class="line"><span class="ln">5</span><span class="cl">高雄  2026-03-06</span></span></code></pre></div><p>五筆。台北 3/06 拿 3/04 當「前一天」比，25 大於 15，於是被選中——<strong>它跨過了缺測的那一天，而缺測在結果裡沒有留下任何痕跡</strong>。</p>
<p><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">SELECT</span><span class="w"> </span><span class="err">測站</span><span class="p">,</span><span class="w"> </span><span class="err">觀測日</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </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">SELECT</span><span class="w"> </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="err">攝氏</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="n">LAG</span><span class="p">(</span><span class="err">攝氏</span><span class="p">)</span><span class="w">   </span><span class="n">OVER</span><span class="w"> </span><span class="p">(</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="p">)</span><span class="w"> </span><span class="k">AS</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">4</span><span class="cl"><span class="w">         </span><span class="n">julianday</span><span class="p">(</span><span class="err">觀測日</span><span class="p">)</span><span class="w"> </span><span class="o">-</span><span class="w"> </span><span class="n">julianday</span><span class="p">(</span><span class="n">LAG</span><span class="p">(</span><span class="err">觀測日</span><span class="p">)</span><span class="w"> </span><span class="n">OVER</span><span class="w"> </span><span class="p">(</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="p">))</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="err">相隔</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">FROM</span><span class="w"> </span><span class="err">氣溫</span><span class="w">
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="w"></span><span class="p">)</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="err">前溫</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">=</span><span class="w"> </span><span class="mi">1</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">高雄  2026-03-05</span></span></code></pre></div><p>一筆，少了三筆。沒有分區的時候，<code>ORDER BY 觀測日</code> 把兩個測站同一天的列排在相鄰的位置，而同一天台北與高雄誰排在前面，這個排序鍵沒有決定——這正是視窗函數押的前提「排序鍵分得出唯一的次序」被打破的情形。SQLite 在這份資料上把台北排在前面，於是高雄 3/05 以外的高雄各列，前一列都是台北同一天的觀測，相隔天數是零，被 <code>相隔 = 1</code> 擋掉；台北 3/02 起的各列，前一列都是高雄前一天的觀測，相隔天數是一，比的卻是高雄的氣溫，而這組資料裡高雄每一天都比台北暖，台北一列都選不上。剩下的高雄 3/05 是因為台北 3/05 缺測，它的前一列才落在高雄 3/04。</p>
<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">-- 漏掉分區的查詢，窗的排序鍵補上測站，把同一天兩個測站的先後寫死
</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="p">,</span><span class="w"> </span><span class="err">觀測日</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </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">SELECT</span><span class="w"> </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="err">攝氏</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="n">LAG</span><span class="p">(</span><span class="err">攝氏</span><span class="p">)</span><span class="w">   </span><span class="n">OVER</span><span class="w"> </span><span class="n">w</span><span class="w"> </span><span class="k">AS</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"> 5</span><span class="cl"><span class="w">         </span><span class="n">julianday</span><span class="p">(</span><span class="err">觀測日</span><span class="p">)</span><span class="w"> </span><span class="o">-</span><span class="w"> </span><span class="n">julianday</span><span class="p">(</span><span class="n">LAG</span><span class="p">(</span><span class="err">觀測日</span><span class="p">)</span><span class="w"> </span><span class="n">OVER</span><span class="w"> </span><span class="n">w</span><span class="p">)</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="err">相隔</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">FROM</span><span class="w"> </span><span class="err">氣溫</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="w">  </span><span class="n">WINDOW</span><span class="w"> </span><span class="n">w</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="p">(</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="p">,</span><span class="w"> </span><span class="err">測站</span><span class="p">)</span><span class="w">          </span><span class="c1">-- 同一天台北排在高雄前面
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="c1"></span><span class="p">)</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="err">前溫</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">=</span><span class="w"> </span><span class="mi">1</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 9</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="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">10</span><span class="cl"><span class="w"></span><span class="c1">-- ORDER BY 觀測日, 測站：      高雄 3/05，一筆（與不寫死次序時 SQLite 回的相同）
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="c1">-- ORDER BY 觀測日, 測站 DESC： 高雄 3/02、3/03、3/04、3/05、3/06、3/07，六筆
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="c1">--   高雄 3/02、3/04、3/07 的前一列是台北前一天，比的是台北的氣溫：多報
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="c1">--   台北 3/02 的前一列是高雄同一天，相隔天數是零：少報</span></span></span></code></pre></div><table>
  <thead>
      <tr>
          <th>版本</th>
          <th>回幾筆</th>
          <th>方向</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>三種正解</td>
          <td>4</td>
          <td>—</td>
      </tr>
      <tr>
          <td>漏掉相隔天數</td>
          <td>5</td>
          <td>多報</td>
      </tr>
      <tr>
          <td>漏掉 <code>PARTITION BY</code></td>
          <td>1 或 6</td>
          <td>看同一天兩個測站誰排在前面：少報，或多報與少報同時發生</td>
      </tr>
  </tbody>
</table>
<p><strong>兩個錯版都不報錯、輸出的欄位也完全正常。</strong> 漏掉相隔天數的版本每次都多報台北 3/06；漏掉分區的版本錯在哪個方向，由引擎怎麼排同一天的兩列決定，在本篇的資料上 SQLite 給的是少報。少報尤其危險——多報的還有機會被人發現數字怪，少報的看起來只是「那幾天沒有升溫」。</p>
<p>分辨它們要靠寫查詢之前就定下的正確答案。漏掉相隔天數的版本回了台北 3/06，撞上寫成可驗收的那句話時定下的「台北 3/06 不該出現」這個判斷標準；漏掉分區的版本在兩種排法下都沒有回台北 3/06，要拿四筆的正確答案逐筆比對才看得出錯。<strong>那份正確答案要在寫查詢之前就決定好</strong>，寫完再回頭挑資料的人，會挑到讓自己通過的那一組。</p>
<h2 id="代價日期差自連接等值自連接視窗函數與相關子查詢的實測">代價：日期差自連接、等值自連接、視窗函數與相關子查詢的實測</h2>
<p>三種寫法都答對了，而「哪個比較快」要向引擎問（<a href="/blog/backend/01-database/sql/cost-lives-in-the-plan/" data-link-title="SQL.17 查詢的代價：資料分布、索引與寫法各自的影響" data-link-desc="找重複值的分組、自連接與 EXISTS 在有無索引與不同重複次數下的實測時間與計畫，以及比較查詢快慢時要補上的資料量、分布、索引與統計資訊">SQL.17 查詢的代價：資料分布、索引與寫法各自的影響</a>）。把資料放大到二十個測站、兩年、每隔九十七天缺一天，共 14460 列：</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 的 DROP 一次只收一張表）
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="k">DROP</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="k">IF</span><span class="w"> </span><span class="k">EXISTS</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"> 3</span><span class="cl"><span class="w"></span><span class="k">CREATE</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="err">氣溫</span><span class="w"> </span><span class="p">(</span><span class="err">紀錄編號</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="err">測站</span><span class="w"> </span><span class="nb">TEXT</span><span class="p">,</span><span class="w"> </span><span class="err">觀測日</span><span class="w"> </span><span class="nb">TEXT</span><span class="p">,</span><span class="w"> </span><span class="err">攝氏</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"> 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="c1">-- d 產生 0 到 729 的天數位移，s 產生 1 到 20 的測站編號
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="c1">-- (i+j) % 97 &lt;&gt; 0 這個條件讓每個測站隔一段時間缺一天
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="c1"></span><span class="k">WITH</span><span class="w"> </span><span class="k">RECURSIVE</span><span class="w"> </span><span class="n">d</span><span class="p">(</span><span class="n">i</span><span class="p">)</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="p">(</span><span class="k">SELECT</span><span class="w"> </span><span class="mi">0</span><span class="w"> </span><span class="k">UNION</span><span class="w"> </span><span class="k">ALL</span><span class="w"> </span><span class="k">SELECT</span><span class="w"> </span><span class="n">i</span><span class="o">+</span><span class="mi">1</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">d</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">i</span><span class="w"> </span><span class="o">&lt;</span><span class="w"> </span><span class="mi">729</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="n">s</span><span class="p">(</span><span class="n">j</span><span class="p">)</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="p">(</span><span class="k">SELECT</span><span class="w"> </span><span class="mi">1</span><span class="w"> </span><span class="k">UNION</span><span class="w"> </span><span class="k">ALL</span><span class="w"> </span><span class="k">SELECT</span><span class="w"> </span><span class="n">j</span><span class="o">+</span><span class="mi">1</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">s</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">j</span><span class="w"> </span><span class="o">&lt;</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"> 9</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="err">攝氏</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="w"></span><span class="k">SELECT</span><span class="w"> </span><span class="s1">&#39;站&#39;</span><span class="o">||</span><span class="n">j</span><span class="p">,</span><span class="w"> </span><span class="nb">date</span><span class="p">(</span><span class="s1">&#39;2024-01-01&#39;</span><span class="p">,</span><span class="w"> </span><span class="n">i</span><span class="o">||</span><span class="s1">&#39; day&#39;</span><span class="p">),</span><span class="w"> </span><span class="mi">10</span><span class="w"> </span><span class="o">+</span><span class="w"> </span><span class="p">(</span><span class="n">i</span><span class="o">*</span><span class="mi">7</span><span class="o">+</span><span class="n">j</span><span class="o">*</span><span class="mi">13</span><span class="p">)</span><span class="w"> </span><span class="o">%</span><span class="w"> </span><span class="mi">25</span><span class="w">
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="w"></span><span class="k">FROM</span><span class="w"> </span><span class="n">s</span><span class="p">,</span><span class="w"> </span><span class="n">d</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="p">(</span><span class="n">i</span><span class="o">+</span><span class="n">j</span><span class="p">)</span><span class="w"> </span><span class="o">%</span><span class="w"> </span><span class="mi">97</span><span class="w"> </span><span class="o">&lt;&gt;</span><span class="w"> </span><span class="mi">0</span><span class="p">;</span></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">-- 以下是同一段自連接的 ON 子句，兩種寫法只差這一行，其餘逐字相同。
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1">-- 這個區塊是片段，不能單獨執行——完整的自連接查詢在〈自連接、視窗函數與相關子查詢各自押的前提〉那一節。
</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">-- 日期差自連接：等號兩邊的欄位都包進函式，算日期差
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"></span><span class="k">ON</span><span class="w"> </span><span class="err">昨</span><span class="p">.</span><span class="err">測站</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="err">今</span><span class="p">.</span><span class="err">測站</span><span class="w"> </span><span class="k">AND</span><span class="w"> </span><span class="n">julianday</span><span class="p">(</span><span class="err">今</span><span class="p">.</span><span class="err">觀測日</span><span class="p">)</span><span class="w"> </span><span class="o">-</span><span class="w"> </span><span class="n">julianday</span><span class="p">(</span><span class="err">昨</span><span class="p">.</span><span class="err">觀測日</span><span class="p">)</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">1</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">-- 等值自連接：等值比較，函式只作用在「今」那一側，「昨」那一側是索引上找得到的值
</span></span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="c1"></span><span class="k">ON</span><span class="w"> </span><span class="err">昨</span><span class="p">.</span><span class="err">測站</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="err">今</span><span class="p">.</span><span class="err">測站</span><span class="w"> </span><span class="k">AND</span><span class="w"> </span><span class="err">昨</span><span class="p">.</span><span class="err">觀測日</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nb">date</span><span class="p">(</span><span class="err">今</span><span class="p">.</span><span class="err">觀測日</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;-1 day&#39;</span><span class="p">)</span></span></span></code></pre></div><p>量的是兩種自連接加上視窗函數與相關子查詢這四段（<code>count(*)</code> 版本，不帶 <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="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 class="err">觀測日</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></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w"></span><span class="c1">-- 拿掉它再量「沒有索引」那一欄：
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1">-- DROP INDEX ix_站日;</span></span></span></code></pre></div><p>計畫用 <code>EXPLAIN QUERY PLAN</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">EXPLAIN</span><span class="w"> </span><span class="n">QUERY</span><span class="w"> </span><span class="n">PLAN</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">SELECT</span><span class="w"> </span><span class="k">count</span><span class="p">(</span><span class="o">*</span><span class="p">)</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="err">氣溫</span><span class="w"> </span><span class="err">今</span><span class="w"> </span><span class="k">JOIN</span><span class="w"> </span><span class="err">氣溫</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">ON</span><span class="w"> </span><span class="err">昨</span><span class="p">.</span><span class="err">測站</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="err">今</span><span class="p">.</span><span class="err">測站</span><span class="w"> </span><span class="k">AND</span><span class="w"> </span><span class="n">julianday</span><span class="p">(</span><span class="err">今</span><span class="p">.</span><span class="err">觀測日</span><span class="p">)</span><span class="w"> </span><span class="o">-</span><span class="w"> </span><span class="n">julianday</span><span class="p">(</span><span class="err">昨</span><span class="p">.</span><span class="err">觀測日</span><span class="p">)</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">1</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="err">今</span><span class="p">.</span><span class="err">攝氏</span><span class="w"> </span><span class="o">&gt;</span><span class="w"> </span><span class="err">昨</span><span class="p">.</span><span class="err">攝氏</span><span class="p">;</span></span></span></code></pre></div><p>四種形態各跑三次取最小值。<strong>計時在行程內</strong>——不是 <code>sqlite3</code> 的 <code>.timer</code>，那個量的是整個 CLI 行程；這裡是在一支宿主程式裡繞迴圈（Python 的 <code>sqlite3</code> 模組加 <code>time.perf_counter()</code>），因為行程啟動是個大常數，會把十五倍壓成不到兩倍。表格之後另外對日期差自連接做了一輪七次的重複性量測，用來確認那個反直覺的結果不是離群值：</p>
<table>
  <thead>
      <tr>
          <th>寫法</th>
          <th>沒有索引</th>
          <th>有 <code>(測站, 觀測日)</code> 索引</th>
          <th>答案</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>日期差自連接</td>
          <td>739 ms</td>
          <td><strong>1045 ms</strong></td>
          <td>10297</td>
      </tr>
      <tr>
          <td>等值自連接</td>
          <td>10 ms</td>
          <td>6 ms</td>
          <td>10297</td>
      </tr>
      <tr>
          <td>視窗函數</td>
          <td>13 ms</td>
          <td>12 ms</td>
          <td>10297</td>
      </tr>
      <tr>
          <td>相關子查詢</td>
          <td>3971 ms</td>
          <td>8 ms</td>
          <td>10297</td>
      </tr>
  </tbody>
</table>
<p>四段查詢的答案完全相同，而時間橫跨三個量級。其中四個現象要逐個看。</p>
<p><strong>日期差自連接與等值自連接的差別只在條件的形狀，而它是七十倍。</strong> 同一個自連接、同一個演算法，把 <code>julianday(今.觀測日) - julianday(昨.觀測日) = 1</code> 換成 <code>昨.觀測日 = date(今.觀測日, '-1 day')</code> 就從 739 毫秒掉到 10 毫秒。原因是日期差自連接把「今」與「昨」的觀測日都包進函式裡，引擎沒有辦法拿索引上的值去比；等值自連接的函式只作用在「今」的觀測日上，引擎在「昨」裡要找的是一個算好的值（<a href="/blog/backend/01-database/sql/knowledge-cards/sargable/" data-link-title="Sargable（可走索引的條件形狀）" data-link-desc="索引存在而計畫仍走全表掃描時，查條件的寫法有沒有擋住那個索引">Sargable</a>）。在幾十列的開發資料上，兩種形狀都是零毫秒，所以這個差別要到資料量上來之後才看得到。</p>
<p><strong>日期差自連接加了索引之後變慢。</strong> 739 毫秒變成 1045 毫秒。這一條反直覺，所以單獨對它再跑一輪七次量測確認：兩段的範圍不重疊（735–780 對上 955–1252），不是離群值。計畫說明了原因：</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">沒有索引    SCAN 今
</span></span><span class="line"><span class="ln">2</span><span class="cl">            SEARCH 昨 USING AUTOMATIC COVERING INDEX (測站=?)
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl">有索引      SCAN 今
</span></span><span class="line"><span class="ln">5</span><span class="cl">            SEARCH 昨 USING INDEX ix_站日 (測站=?)</span></span></code></pre></div><p>沒有索引的時候 SQLite 當場替這段查詢建了一個<strong>覆蓋</strong>索引，內層要的欄位全部在索引裡。有了 <code>ix_站日</code> 之後它改用那一個，而那個索引只含 <code>(測站, 觀測日)</code>、不含 <code>攝氏</code>，於是每一列內層都要多回表一次取攝氏。<strong>手動建的那個索引比引擎當場建的差，所以加了之後變慢</strong>——這件事沒有辦法從查詢的文字推出來，只能問計畫。</p>
<p><strong>相關子查詢從四段裡最慢的變成僅次於等值自連接。</strong> 沒有索引時 3971 毫秒，有索引時 8 毫秒，差了五百倍。兩份計畫的差別在子查詢底下那一行：</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">沒有索引    SCAN 今
</span></span><span class="line"><span class="ln">2</span><span class="cl">            CORRELATED SCALAR SUBQUERY 1
</span></span><span class="line"><span class="ln">3</span><span class="cl">              SCAN 昨                                            ← 「今」的每一列都把「昨」整張掃一遍
</span></span><span class="line"><span class="ln">4</span><span class="cl">
</span></span><span class="line"><span class="ln">5</span><span class="cl">有索引      SCAN 今
</span></span><span class="line"><span class="ln">6</span><span class="cl">            CORRELATED SCALAR SUBQUERY 1
</span></span><span class="line"><span class="ln">7</span><span class="cl">              SEARCH 昨 USING INDEX ix_站日 (測站=? AND 觀測日=?)   ← 「今」的每一列在索引上查一次</span></span></code></pre></div><p>子查詢逐列執行這個結構沒有變，變的是每一次的單價。<strong>所以「相關子查詢慢」這個說法要加一個條件</strong>：它慢在那個逐列查找沒有索引可用的時候。</p>
<p><strong>視窗函數對索引幾乎無感</strong>（13 對 12 毫秒），而計畫上有變化（只列產生窗那一層）：</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">沒有索引    SCAN 氣溫
</span></span><span class="line"><span class="ln">2</span><span class="cl">            USE TEMP B-TREE FOR ORDER BY       ← 先把整張表排成 PARTITION BY 測站 ORDER BY 觀測日 的順序
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl">有索引      SCAN 氣溫 USING INDEX ix_站日       ← 沿索引讀出來就是這個順序，排序那一行消失</span></span></code></pre></div><p>索引本身就提供了窗要的順序，省下的排序成本在這個資料量上還看不出來。</p>
<p>回到「<strong>這個結果要餵給哪一個決定</strong>」那個追問。每天排程跑一次的報表，四種形態都可以接受，那就挑最好讀的；每次開頁面就跑一次的話，日期差自連接那一列的數字會直接變成使用者等待的時間。</p>
<h2 id="需求沒說而資料會有的情形同日重複時區補登與測站搬家">需求沒說而資料會有的情形：同日重複、時區、補登與測站搬家</h2>
<p>「對每一個測站，找出所有同測站、日期剛好早一天的那筆觀測存在、而且氣溫比它高的那些觀測」這句可驗收的話管住了缺測。下面這幾件需求同樣沒說，而資料遲早會有。</p>
<p><strong>同一個測站、同一天有兩筆。</strong> 感測器重送、補登、或者換了設備之後兩套系統各寫一筆。這打破的是三種寫法共同押的那個前提——<code>(測站, 觀測日)</code> 唯一——而三種寫法被打破之後的反應各不相同：自連接的一列會配到多列，結果多出來；視窗函數的「前一列」在並列之間沒有定義，同一段查詢可以給出不同答案；相關子查詢在 PostgreSQL 上報錯、在 SQLite 上靜默取其中一列。<strong>三種寫法對同一件事的反應完全不同，而在本篇用的 SQLite 上沒有一種會出聲</strong>——會出聲的那一種要換到 PostgreSQL 才看得到。 要決定的是哪一筆算數（最後寫入的、還是某個來源優先），而這個決定屬於 schema——一條 <code>UNIQUE (測站, 觀測日)</code> 約束把它前移到寫入的那一刻。</p>
<p><strong>時區。</strong> <code>觀測日</code> 存的是哪個時區的日期。跨時區的測站各自用當地日期的話，「前一天」在資料上是對的而在比較上不可跨站；統一存 UTC 日期的話，當地的「一天」會被切開。這件事在單一時區的開發環境裡量不出來。</p>
<p><strong>補登的資料改變歷史答案。</strong> 台北 3/05 的觀測三個月後補進來了，同一段查詢在同一段日期上回的列跟著變——<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="c1">-- 台北 3/05 補登，當天 12 度
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></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="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="s1">&#39;台北&#39;</span><span class="p">,</span><span class="s1">&#39;2026-03-05&#39;</span><span class="p">,</span><span class="mi">12</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></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="w"></span><span class="c1">-- 〈自連接、視窗函數與相關子查詢各自押的前提〉那一節的自連接，原樣再跑一次
</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="err">今</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="err">觀測日</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">FROM</span><span class="w"> </span><span class="err">氣溫</span><span class="w"> </span><span class="err">今</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="w"></span><span class="k">JOIN</span><span class="w"> </span><span class="err">氣溫</span><span class="w"> </span><span class="err">昨</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><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="w"> </span><span class="o">=</span><span class="w"> </span><span class="err">今</span><span class="p">.</span><span class="err">測站</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="w"> </span><span class="k">AND</span><span class="w"> </span><span class="err">昨</span><span class="p">.</span><span class="err">觀測日</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nb">date</span><span class="p">(</span><span class="err">今</span><span class="p">.</span><span class="err">觀測日</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;-1 day&#39;</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="w"></span><span class="k">WHERE</span><span class="w"> </span><span class="err">今</span><span class="p">.</span><span class="err">攝氏</span><span class="w"> </span><span class="o">&gt;</span><span class="w"> </span><span class="err">昨</span><span class="p">.</span><span class="err">攝氏</span><span class="w">
</span></span></span><span class="line"><span class="ln">11</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="p">.</span><span class="err">測站</span><span class="p">,</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">12</span><span class="cl"><span class="w"></span><span class="c1">-- 補登之前：四筆
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="c1">-- 補登 12 度之後：五筆，多出台北 2026-03-06（25 &gt; 12），它現在有前一天了
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="c1">-- 補登的是 17 度的話：六筆，台北 2026-03-05（17 &gt; 15）也被選中</span></span></span></code></pre></div><p>報表若把結果存下來，存的那一份與重跑的那一份會不同。要決定的是這份報表回答的是「現在資料庫怎麼說」還是「當時我們知道什麼」，兩種要的儲存方式不同。</p>
<p><strong>測站搬家。</strong> 同一個 <code>測站</code> 值在某一天之後指的是另一個地點。<code>PARTITION BY 測站</code> 會把搬家前後接成同一條序列，而那一天的「比前一天高」比的是兩個地方。這一類問題不在查詢裡，在<strong>識別碼的語意有沒有隨時間改變</strong>。</p>
<p>四件事有一個共同點：<strong>它們都讓查詢繼續回出形狀正確的列</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>追問、判斷標準、寫法、前提、代價與邊界都處理過之後，這段查詢會跑、答案對、代價也量過了。剩下的三件事有一個共同點：<strong>它們全部不在這段文字裡</strong>——資料會變、計畫會重選、方言會換，而查詢的字面對這三件事完全沉默。</p>
<p><strong>答案變了要不要有人知道。</strong> 台北 3/05 補登那種情形會讓同一段日期的答案改變。要不要偵測，取決於「餵給哪一個決定」那個追問——告警要知道，月報的圖不必。</p>
<p><strong>資料量長大之後排名會重排。</strong> 代價量測那張表是 14460 列上的結果。列數再大一個量級，日期差自連接與相關子查詢的數字會變，而等值自連接與視窗函數未必。判斷要重問一次計畫，而且<strong>要在改動索引之後再問一次</strong>——日期差自連接已經示範過加索引可以讓事情更糟。</p>
<p><strong>換一家引擎，這四段都要改寫。</strong> 四段全部用了 SQLite 的日期函式——兩種自連接與相關子查詢用 <code>date(x, '-1 day')</code> 或 <code>julianday()</code>，視窗函數用 <code>julianday()</code>。同一個日期運算，PostgreSQL 要先把欄位轉成日期，寫成 <code>CAST(觀測日 AS DATE) - 1</code>（本篇的觀測日宣告成 <code>TEXT</code>，直接寫 <code>觀測日 - INTERVAL '1 day'</code> 會回 <code>operator does not exist: text - interval</code>）；MySQL 寫成 <code>DATE_SUB(觀測日, INTERVAL 1 DAY)</code>，文字欄會被隱含轉成日期；<code>WINDOW w AS (...)</code> 這個具名窗各家支援程度不同。這一層的差異第一次執行就會報錯，屬於當場報錯的那一級、最省事——而同一段查詢還有不報錯的差異，分級在 <a href="/blog/backend/01-database/sql/engine-leniency-and-portability/" data-link-title="SQL.19 引擎寬鬆度與可攜性：各家對同一組寫法的差異、分級與處理時機" data-link-desc="SQLite、DuckDB、PostgreSQL 與 MySQL 在未分組欄位、WHERE 用別名、同表未取別名、字串比對與空值排序上的實測對照，差異從當場報錯到完全靜默的分級，以及依目標組態數決定哪幾級差異在寫的當下處理">SQL.19 引擎寬鬆度與可攜性：各家對同一組寫法的差異、分級與處理時機</a>。</p>
<h2 id="各個決定在這一題上的做法對照">各個決定在這一題上的做法對照</h2>
<p>每個決定要決定什麼、決錯之後的症狀、背後的機制在哪一篇，都在 <a href="/blog/backend/01-database/sql/decisions-from-requirement-to-shipping/" data-link-title="SQL.0 一段查詢從需求到上線的決定：追問需求、設定判斷標準、選寫法、補前提、量代價、對邊界與交付" data-link-desc="每個決定要決定什麼、決錯之後查詢出現什麼症狀、機制在哪一篇展開，以及這些決定共同依賴的建表時的決定">SQL.0 一段查詢從需求到上線的決定：追問需求、設定判斷標準、選寫法、補前提、量代價、對邊界與交付</a>；下表只對照這一題在每個決定上做了什麼：</p>
<table>
  <thead>
      <tr>
          <th>決定</th>
          <th>這一題在這個決定上做了什麼</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>追問需求</td>
          <td>結果餵給哪一個決定、「前一天」指日曆還是資料、缺測那天怎麼算、有幾個測站</td>
      </tr>
      <tr>
          <td>設定判斷標準</td>
          <td>寫成可驗收的一句話，並指定台北 3/06 當判斷標準</td>
      </tr>
      <tr>
          <td>選寫法</td>
          <td>自連接、視窗函數、相關子查詢並排</td>
      </tr>
      <tr>
          <td>補前提</td>
          <td>三種寫法押的前提收斂到那一組鍵唯不唯一，再用兩個漏掉前提的錯版驗收</td>
      </tr>
      <tr>
          <td>量代價</td>
          <td>兩種自連接加上視窗函數與相關子查詢，各量有索引與沒有索引</td>
      </tr>
      <tr>
          <td>對邊界</td>
          <td>同日重複、時區、補登、測站搬家</td>
      </tr>
      <tr>
          <td>交付</td>
          <td>答案變了要不要有人知道、資料量長大後重問計畫、換引擎要改寫的日期函式</td>
      </tr>
  </tbody>
</table>
<h2 id="查詢之外的接點schema-設計計畫工具與查詢預算">查詢之外的接點：schema 設計、計畫工具與查詢預算</h2>
<p>交付之後，這一題就交出去了。而從需求到上線的這些決定裡，有三個各自連到查詢之外的一個領域。</p>
<p>選寫法與補前提的接點在<strong>設計那一側</strong>。三種寫法押的同一個前提是 <code>(測站, 觀測日)</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>量代價的接點在<strong>工具那一側</strong>。本篇讀的計畫只有三四行，真實系統的計畫有巢狀節點與估計列數，而且要分得出「估計錯了」與「真的很慢」。<a href="/blog/backend/01-database/vendors/postgresql/query-optimization/" data-link-title="PostgreSQL Query Optimization：EXPLAIN、auto_explain、pg_hint_plan 與 planner 選錯 plan 的 production case" data-link-desc="PG query 慢的根因常是 *planner 選錯 plan 或 statistics 過時*。本文從 production case 開場（seq scan vs index / hash vs nested loop / 多 column 統計缺 / parallel query 沒觸發）、走 EXPLAIN / EXPLAIN ANALYZE / auto_explain、pg_hint_plan extension 跟 planner GUC 取捨、production 踩雷（ANALYZE 過時 / multi-column statistics / random_page_cost 沿用 HDD 預設 / effective_cache_size 不對齊 RAM / parallel query 配置）、跟 MySQL query-optimization sibling 對比">PostgreSQL Query Optimization</a> 給 <code>EXPLAIN</code> / <code>EXPLAIN ANALYZE</code> / <code>auto_explain</code> 三層工具的分工，以及統計過時讓計畫選錯的實際案例。</p>
<p>追問需求的接點在<strong>預算那一側</strong>。追問需求時問的是這份結果餵給哪一個下游用途，而那個答案決定它跑幾次；一個請求裡跑幾段查詢則有另一套預算，兩者相乘才是真正的代價。<a href="/blog/backend/01-database/query-anti-patterns/" data-link-title="1.13 應用層查詢反模式與 Query 預算" data-link-desc="整理 N&#43;1、select *、缺索引、ORM lazy load、long transaction 等查詢反模式與每請求的 query 預算判讀">backend 1.13 應用層查詢反模式與 Query 預算</a> 處理一個請求裡跑幾段查詢的那一套預算。</p>
<p>換一個題目的話，這些決定照樣要做，而追問需求得到的答案與需求沒說的那些邊界會全部不同——那正是為什麼它們是追問而不是清單。</p>
]]></content:encoded></item></channel></rss>