最近在看一段比較舊的 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 通常會慢上不少;同樣的邏輯,如果使用集合運算(JOINCROSS JOINCTE)寫,通常會比 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 的情境。