最近在看一段比較舊的 T-SQL 程式碼,遇到一個還算滿常見的效能議題:用 Cursor 逐筆寫入時,從同一張暫存表讀資料、又寫回去同一張表;乍看之下邏輯沒問題、結果也正確,但只要資料量稍微一多,整段 SQL 就會跑得非常慢;這篇筆記想整理一下背後的原理,以及為什麼這種寫法在 T-SQL 中通常不太建議。
簡單情境
用一個跟業務無關的例子來說明,假設有一個「會員 × 月份的消費紀錄初始化」的需求:
- 有一張
#member_base,裡面是會員基本資料(會員編號、姓名、等級) - 有 12 個月份,需要幫每個會員產生 12 筆「初始為 0」的消費紀錄
- 預期結果:會員數 × 12 筆資料
一個比較直覺、但其實會有問題的寫法可能會長這樣:
1DECLARE month_cursor CURSOR FOR
2 SELECT month_id FROM dim_month; -- 1 ~ 12 月
3
4OPEN month_cursor;
5FETCH NEXT FROM month_cursor INTO @month_id;
6
7WHILE @@FETCH_STATUS = 0
8BEGIN
9 INSERT INTO #member_base (member_id, member_name, member_level, month_id, amount)
10 SELECT DISTINCT member_id, member_name, member_level, @month_id, 0
11 FROM #member_base; -- 注意:從 #member_base 讀,又寫回 #member_base
12
13 FETCH NEXT FROM month_cursor INTO @month_id;
14END
直覺上看起來是:「就是跑 12 圈,每圈把所有會員複製一份、補上月份而已。」但實際跑起來,資料量會以指數的速度成長。
狀況一:資料量會變成 2^N 的成長
關鍵在 INSERT INTO X ... SELECT FROM X 這個寫法,假設 #member_base 一開始有 1,000 個會員:
| 跑完月份 | #member_base 資料量 |
|---|---|
| 初始 | 1,000 |
| 1 月 | 2,000 |
| 2 月 | 4,000 |
| 3 月 | 8,000 |
| 4 月 | 16,000 |
| 5 月 | 32,000 |
| 6 月 | 64,000 |
| 7 月 | 128,000 |
| 8 月 | 256,000 |
| 9 月 | 512,000 |
| 10 月 | 1,024,000 |
| 11 月 | 2,048,000 |
| 12 月 | 4,096,000 |
預期是 12,000 筆(1,000 會員 × 12 月),實際卻長到 400 萬筆,為什麼會這樣?因為每跑一輪,新塞進去的資料下一輪會被 SELECT FROM 讀到,整張表就翻一倍,這是 O(2^N) 的成長,不是 O(N);如果月份是 20 個、會員是 1 萬人,跑完就會有 100 億筆,通常 tempdb 撐不到那個時候就會先出狀況。
狀況二:Cursor 本身就比較慢
即便沒有自我參照的問題,Cursor 在 T-SQL 中通常也不是效能最好的選擇,主要有三個原因:
T-SQL 比較適合做集合運算
SQL 的設計思路是「描述要什麼結果」,由 query optimizer 決定怎麼做;Cursor 則是「逐筆告訴 SQL Server 怎麼做」,這樣 query optimizer 比較難幫忙做平行處理、重組執行計畫、或選擇 hash join 這類比較有效率的演算法。
每次 FETCH 都會有額外的開銷
Cursor 需要維護自己的位置狀態、處理鎖定、做型別轉換,每抓一筆就是一次額外開銷,10 萬筆就是 10 萬次。
逐筆 INSERT 的 transaction log 寫入比較沒效率
每次 INSERT 都會寫一筆 transaction log,比起一次 INSERT 10 萬筆(log 可以做合併最佳化),逐筆寫 10 萬次 INSERT 通常會慢上不少;同樣的邏輯,如果使用集合運算(JOIN、CROSS JOIN、CTE)寫,通常會比 Cursor 快不少,資料量越大差距越明顯。
狀況三:每輪都在做 DISTINCT 全表掃描
回到剛剛那段 Cursor:
1SELECT DISTINCT member_id, member_name, member_level, @month_id, 0
2FROM #member_base
每跑一個月份都要:
- 全表掃描:暫存表通常沒有索引,每次都是 heap scan
- 執行
DISTINCT:需要排序或建 hash table 去除重複資料,對 CPU 負擔較重 - 寫入結果:寫完之後下一輪要掃的資料又變多
跟「狀況一」的指數成長疊在一起,第 12 輪要掃的資料量是第 1 輪的 2,048 倍,每一輪都會比上一輪慢,跑到後面整段 SQL 就會看起來像沒在動。
三個狀況疊起來的效果
把這三個問題畫在一起看:
時間成本大概是: O(2^N) × Cursor 開銷 × DISTINCT 排序成本
↑ ↑ ↑
資料爆炸 逐筆處理 每輪全表掃描
所以這種寫法跑起來會慢,通常不是單一原因,而是這三個狀況互相加乘的結果。
回頭看這段 SQL 能夠發現,效能問題其實不是 Cursor 單獨造成的,而是:
- Cursor 的逐筆處理成本
INSERT INTO X ... SELECT FROM X的自我參照- 每輪都重新做
DISTINCT與全表掃描
三個因素互相加乘,最後讓資料量呈現指數成長,也讓每一輪執行都比前一輪更慢;理解原因之後,下一個問題就是:如果真的接手這種舊程式,可以怎麼修改?
下一篇將整理幾種修改幅度不同的方式,包括拆出來源表、改用 CROSS JOIN、以及適合使用 CTE 的情境。