用 ORDER BY 排序#
ORDER BY 廣為人知,因為 SQL 不保證任何查詢結果集的順序——除非你使用 order by 子句。最簡單的形式是依資料模型中的一或多個欄位排序,某些情況下 PostgreSQL 甚至能循著既有索引直接以正確順序回傳資料:
select year, url
from seasons
order by year desc
limit 3;結果本身不算有趣,有趣的是它的執行計畫——PostgreSQL 反向走訪主鍵索引來回傳最近的三筆:
explain (costs off)
select year, url
from seasons
order by year desc
limit 3; QUERY PLAN
══════════════════════════════════════════════════════════════
Limit
-> Index Scan Backward using idx_57708_primary on seasons
(2 rows)order by 也可以引用查詢別名與計算值;在 PostgreSQL 中甚至接受複雜運算式與子查詢。例如用 CASE 條件式控制賽果排序:先依名次、再依圈數,最後依狀態排序,並加一條特殊規則——Power Unit 故障優先排前(規則本身完全是任意的,但你可能正替車隊調查某個故障硬體):
select drivers.code, drivers.surname,
position,
laps,
status
from results
join drivers using(driverid)
join status using(statusid)
where raceid = 972
order by position nulls last,
laps desc,
case when status = 'Power Unit'
then 1
else 2
end;raceid = 972 的排序結果
code │ surname │ position │ laps │ status
══════╪════════════╪══════════╪══════╪════════════
BOT │ Bottas │ 1 │ 52 │ Finished
VET │ Vettel │ 2 │ 52 │ Finished
RAI │ Räikkönen │ 3 │ 52 │ Finished
HAM │ Hamilton │ 4 │ 52 │ Finished
...
WEH │ Wehrlein │ 16 │ 50 │ +2 Laps
RIC │ Ricciardo │ ¤ │ 5 │ Brakes
ALO │ Alonso │ ¤ │ 0 │ Power Unit
PAL │ Palmer │ ¤ │ 0 │ Collision
GRO │ Grosjean │ ¤ │ 0 │ Collision
(20 rows)看著這份結果,幾乎就像親眼看了那場比賽。
kNN 排序與 GiST 索引#
order by 的另一個用途是實作 k 最近鄰(k nearest neighbours, kNN) 搜尋。找出離巴黎(經度 2.349014、緯度 48.864716)最近的十個賽道,就是 k = 10 的 kNN 搜尋:
select name, location, country
from circuits
order by point(lng,lat) <-> point(2.349014, 48.864716)
limit 10;結果除了法國各地的賽道,還包括比利時與英國的幾條(Rouen-Les-Essarts、Reims-Gueux、Magny-Cours、Le Mans、Nivelles-Baulers、Dijon-Prenois、Charade、Brands Hatch、Zolder、Spa-Francorchamps)。
point 資料型別是 PostgreSQL 很實用的擴充。上面的 point 是從原始欄位動態計算的;更道地的做法是在 circuits 表加一個 point 型別欄位並建 GiST 索引:
begin;
alter table f1db.circuits add column position point;
update f1db.circuits set position = point(lng,lat);
create index on f1db.circuits using gist(position);
commit;改用新欄位查詢後結果集當然相同——索引在任何情況下都不允許改變查詢結果;如果改變了,那叫 bug,或者是資料損毀。看看有 GiST 索引後的執行計畫:
explain (costs off, buffers, analyze)
select name, location, country
from circuits
order by position <-> point(2.349014, 48.864716)
limit 10; QUERY PLAN
══════════════════════════════════════════════════════════════════
Limit (actual time=0.039..0.061 rows=10 loops=1)
Buffers: shared hit=7
-> Index Scan using circuits_position_idx on circuits
(actual time=0.038..0.058 rows=10 loops=1)
Order By: ("position" <-> '(2.349014,48.864716)'::point)
Buffers: shared hit=7
Planning time: 0.129 ms
Execution time: 0.105 ms
(7 rows)PostgreSQL 樂於使用 GiST 索引,甚至整個 kNN 搜尋都在索引內完成。相較之下,動態運算式 point(lng,lat) 的版本只能 Seq Scan 加 top-N heapsort,執行時間 0.344 ms 對上索引版的 0.105 ms。
距離運算子
<->預設只對幾何資料型別定義;pg_trgm等擴充能擴大適用範圍,讓like甚至正規表達式運算子~的查詢也能受益於 kNN 索引查找。本書稍後會再談 PostgreSQL 的正規表達式。
Top-N 排序:LIMIT#
想列出每個年代贏最多場比賽的前三名車手,可以結合 PostgreSQL 的日期函式與 lateral join。以下是經典的 top-N 實作——經典在於透過 lateral 子查詢完成;不那麼經典的是我們 join 的對象是計算出來的資料:年代(decade)不在資料模型裡,得從 races.date 欄位萃取。
with decades as
(
select extract('year' from date_trunc('decade', date)) as decade
from races
group by decade
)
select decade,
rank() over(partition by decade
order by wins desc)
as rank,
forename, surname, wins
from decades
left join lateral
(
select code, forename, surname, count(*) as wins
from drivers
join results
on results.driverid = drivers.driverid
and results.position = 1
join races using(raceid)
where extract('year' from date_trunc('decade', races.date))
= decades.decade
group by decades.decade, drivers.driverid
order by wins desc
limit 3
)
as winners on true
order by decade asc, wins desc;查詢的結構:
- 先在
WITH引入的共同資料表運算式(Common Table Expression, CTE)中萃取年代清單,之後在 from 子句中把它當成資料來源使用——from 子句處理的是關聯(relation),而關聯完全可以是動態計算出的資料集。 - lateral join 允許子查詢「對一個資料集逐列執行迴圈」:對每個年代,lateral 子查詢找出該年代的前三名贏家。
- 子查詢只關心勝利成績,所以把
results.position = 1推進 join 條件;「只算當前年代的勝場」則實作在子查詢的 where 子句,這正是 lateral 子查詢的運作方式。 - 也因為 join 條件已經以 where 形式注入子查詢,外層的 join 子句寫成
on true。子查詢內部再以order by wins desc加limit 3取出前三名。
各年代前三名車手結果
decade │ rank │ forename │ surname │ wins
════════╪══════╪═══════════╪════════════╪══════
1950 │ 1 │ Juan │ Fangio │ 24
1950 │ 2 │ Alberto │ Ascari │ 13
1950 │ 3 │ Stirling │ Moss │ 12
1960 │ 1 │ Jim │ Clark │ 25
1960 │ 2 │ Graham │ Hill │ 14
1960 │ 3 │ Jack │ Brabham │ 11
1970 │ 1 │ Niki │ Lauda │ 17
1970 │ 2 │ Jackie │ Stewart │ 16
1970 │ 3 │ Emerson │ Fittipaldi │ 14
1980 │ 1 │ Alain │ Prost │ 39
1980 │ 2 │ Nelson │ Piquet │ 20
1980 │ 2 │ Ayrton │ Senna │ 20
1990 │ 1 │ Michael │ Schumacher │ 35
1990 │ 2 │ Damon │ Hill │ 22
1990 │ 3 │ Ayrton │ Senna │ 21
2000 │ 1 │ Michael │ Schumacher │ 56
2000 │ 2 │ Fernando │ Alonso │ 21
2000 │ 3 │ Kimi │ Räikkönen │ 18
2010 │ 1 │ Lewis │ Hamilton │ 45
2010 │ 2 │ Sebastian │ Vettel │ 40
2010 │ 3 │ Nico │ Rosberg │ 23
(21 rows)別用 OFFSET:分頁的正確實作#
SQL 標準提供 FETCH 指令來取代 PostgreSQL 的 limit/offset 變體。無論如何,offset 子句對查詢效能非常糟糕,我們強烈建議不要用。請抽空閱讀 Markus Winand 的《Paging Through Results》——作者自認不會解釋得比他更好。另外:永遠別再用 offset 了!
書中仍給出重點:offset 會讓查詢計畫照樣讀出所有結果,然後丟棄大部分資料直到到達 offset 數。分頁翻得越深,每多取一頁就越沒效率。
正確的分頁實作是使用索引查找(index lookup);若排序子句包含多個欄位,可以用
row()建構式做到。
以 laptimes 表示範(包含每場比賽每位車手的每圈時間;raceid 972 有 828 列)。為節省版面,每頁取三列。第一頁如預期,這裡用的是 SQL 標準的 limit 拼法:
select lap, drivers.code, position,
milliseconds * interval '1ms' as laptime
from laptimes
join drivers using(driverid)
where raceid = 972
order by lap, position
fetch first 3 rows only; lap │ code │ position │ laptime
═════╪══════╪══════════╪═════════════════════
1 │ BOT │ 1 │ @ 2 mins 5.192 secs
1 │ VET │ 2 │ @ 2 mins 7.101 secs
1 │ RAI │ 3 │ @ 2 mins 10.53 secs
(3 rows)關鍵在於:應用程式必須記住目前顯示到 lap = 1、position = 3,下一頁查詢就以此為起點:
select lap, drivers.code, position,
milliseconds * interval '1ms' as laptime
from laptimes
join drivers using(driverid)
where raceid = 972
and row(lap, position) > (1, 3)
order by lap, position
fetch first 3 rows only;第二頁正確地從 lap 1、position 4 開始(HAM、VER、MAS)。
只要你在乎查詢時間,就請永遠不要再使用 offset!