一支跑了很多年的 .NET Framework 排程批次,單次執行 7.62 分鐘。優化後預估落在 30~60 秒。
拆開來看,貢獻度是這樣分布的:把迴圈裡的資料庫查詢搬到迴圈外,佔了八成以上。其餘那些看起來很專業的手法——Dictionary 取代 SingleOrDefault、HashSet 取代 Any()、快取反射結果、少呼叫幾次 DateTime.Now——全部加起來不到一成。
這篇記錄整個過程:怎麼量、改了什麼、哪些寫好了又拿掉、以及最後有一半的優化提案根本沒進主線。最後那點才是我覺得最值得寫下來的。
一、先量,再改
沒有數字就動手改效能,等於在賭。這次的量測分三層,成本由低到高。
第一層:主流程埋 Stopwatch,順便印記憶體。
1 | var sw = Stopwatch.StartNew(); |
會印記憶體是有原因的:這類優化本質上是拿記憶體換時間,只看時間不看記憶體,就不知道自己把成本挪到哪去了。
第二層:基準線寫進程式碼註解。
1 | // 原始跑法 : 7.62分鐘 (2026/02/11 09:03:00) |
這行註解後來變成整個專案唯一可信的 before 數字。半年後回頭看,Excel 找不到、聊天記錄翻不到,只有程式碼還在。基準線寫在量測點旁邊,這個習慣值得養。
第三層:可疑熱點寫獨立的對照組。
不確定某個寫法到底慢不慢,就寫一支只比較兩種寫法的小程式,不接資料庫、不接業務邏輯:
1 | // 舊寫法:O(n) 線性搜尋 |
30 筆資料、7 個 key、跑 10,000 輪。這種 micro-benchmark 的價值不在數字本身,而在幫你決定要不要為這件事動主線程式碼——多數時候答案是不要。
二、真正的瓶頸:查詢寫在迴圈裡
profiling 完,瓶頸只有一個:逐筆處理的迴圈內,每一筆都執行 4 次資料庫查詢。
1 | foreach (var row in rows) |
單檔 2000 筆的話就是 8000 次資料庫往返。而這四次查詢回來的東西,在整批執行期間內容完全一樣——它們是設定檔,不是交易資料。
這是我認為最常見、也最容易忽視的效能問題:程式碼本身沒有錯,using 用得很標準,資源有正確釋放,每一行單獨看都符合規範。錯的是它待的位置。
三、Lazy<T> 放在建構式
改法是把這些參考資料提升到物件的建構式,用 Lazy<T> 宣告,第一次真正被存取時才載入:
1 | public class PrivacySetting : IDisposable |
呼叫端只要把「每筆 new 一次」改成「建構式建一次、整批共用」:
1 | private readonly PrivacySetting _settingCache; // 欄位 |
資料流變成這樣:
flowchart LR
A([批次啟動]) --> B[建構共用物件
宣告 Lazy 快取]
B --> C{逐筆處理 N 筆}
C -->|第 1 筆| D[首次存取
觸發載入]
D --> DB[(資料庫)]
DB --> E[記憶體字典]
C -->|第 2..N 筆| E
E --> F[遮罩 / 檢核]
F --> C
C -->|完成| G([結束])
用 Lazy<T> 而不是在建構式裡直接查,差別在於:用不到就不會付出載入成本。同一個類別被別支程式引用時,可能根本不走到遮罩那段,直接查就白花一次全表掃描。
七張參考資料表用同一套模式處理完,結構依存取樣態選:
| 存取樣態 | 結構 |
|---|---|
| 單鍵精確查找 | Lazy<Dictionary<string, T>> |
| 一對多分組查找 | Lazy<Dictionary<int, List<T>>> |
| 只判斷存在 / 全集掃描 | Lazy<HashSet<T>> |
四、快取全表之前,先把全表變小
這是我覺得比 Lazy<T> 本身更值得記的一點。
把一張表整個載進記憶體之前,先問「我真的需要全部嗎」。這次有兩張表都不需要:
假日表——業務上只會查到近幾年,那就只載近三年:
1 | select * from 假日表 |
地區代碼對照表——有生效起訖日,只有當下生效的那批有意義:
1 | _zipCache = new Lazy<Dictionary<string, List<ZipCode>>>(() => |
同一張表,載入條件加對了,記憶體佔用和載入時間可能差一個數量級。「快取」和「整表載入」不是同義詞,這兩者被混為一談時,最後通常是記憶體先出事。
那個 .ToList() 的位置也是刻意的:先讓資料庫做篩選、把結果拉回本地,再做 GroupBy。少了它,GroupBy 可能被翻譯成 SQL 送進資料庫,得到一句你沒預期的查詢。
五、跨執行緒的那份要用 static,但要想清楚
假日表在多個地方被共用,寫成了 static:
1 | private static readonly Lazy<HashSet<Holiday>> _offDateList = |
第二個參數 true 不能省。Lazy<T> 預設雖然是執行緒安全的,但顯式寫出來是給下一個人看的——尤其當你在同一份程式碼裡還留著平行處理的痕跡時(下一節)。
static 的代價要講清楚:這份快取的生命週期等於 AppDomain。批次程式跑完就結束,這是它最適合 static 快取的原因;同樣的程式碼搬進長駐的 Web 應用,就變成「設定改了要重啟才生效」的經典事故。
這也是整篇最重要的邊界條件:以上所有做法成立的前提,是「行程生命週期短」加上「這些資料在執行期間不會變」。少任何一個,都要改用有 TTL 的快取機制。
六、寫好了又拿掉的:平行化
主流程曾經改成平行處理,分批 400 筆、MaxDegreeOfParallelism = 4:
1 | var batches = Batch(rows, 400); |
寫得很完整——thread-local 累加、最後 lock 合併,該有的都有。然後整段被註解掉,改回單執行緒:
1 | // 單線程處理方式 |
順序很關鍵:把每筆 4 次資料庫往返消掉之後,剩下的工作量已經不值得為它承擔平行化的複雜度。平行處理在這裡要面對共用連線、交易邊界、錯誤累加順序、以及最麻煩的——輸出順序不穩定會讓「新舊版比對」這件事直接做不了。
留著註解掉的程式碼是刻意的,那個 Batch<T>() helper 也沒刪。它記錄了「這條路試過、被否決」,比刪乾淨有價值。下一個接手的人不會再走一次。
七、剩下那一成
其餘手法列在這,每一項的性質不同:
| 手法 | 原本 | 改法 | 性質 |
|---|---|---|---|
| 查參考欄位 | SingleOrDefault O(n) |
Dictionary.TryGetValue O(1) |
資料集小,實際影響有限 |
| 判斷重複鍵值 | list.Any(o => o.Id == x) O(n) |
HashSet.Add() 回傳值 |
隨資料量放大才明顯 |
| 錯誤訊息組裝 | 先排序整份再過濾 | 先過濾再排序 | 過濾率高時差距明顯 |
| 字串組裝 | + 串接 |
StringBuilder 重用 + Clear() |
GC 壓力 |
| 重複計算 | 迴圈內 int.Parse、DateTime.Now |
迴圈外算一次 | 幾乎可忽略 |
其中「先過濾再排序」是唯一在演算法層級有實質意義的:OrderBy 是 O(n log n),先把 n 變小再排,和排完再丟掉九成,差距隨資料量拉開。其他幾項比較接近整潔度,順手改可以,專程為它們動主線不划算。
HashSet 判重那項有個容易忽略的細節——Add() 的回傳值本身就是「是否為新元素」,不必先 Contains 再 Add:
1 | if (processedIds.Add(row.Id)) |
八、一半的優化提案沒有進主線
這是我最想寫下來的部分。
整個優化過程留下了三份設計稿和三支 benchmark,加起來一千多行。它們放在方案目錄裡,沒有列入 csproj,不會被編譯。同時,共用函式庫的 README 裡有一節「效能優化」,把幾項手法連同「提升 100 倍」「記憶體節省 99.9%」的數字寫得像已完成。
實際比對主線程式碼:
| 項目 | 文件的說法 | 主線的實況 |
|---|---|---|
| 參考資料表快取 | 已完成 | 已完成 |
| 反射結果快取 | 已完成 | 未採用,仍是每筆 GetType().GetProperties() |
| 遮罩查找索引化 | 已完成 | 未採用,仍是多次 FirstOrDefault 線性掃描 |
| 迴圈整體重寫 | 已完成 | 未採用,設計稿獨立存在 |
會這樣不難理解:核心那項做完,效能已經達標,剩下的自然沒有動力做。問題不在沒做,在文件寫得像做完了。
半年後有人為了「已經優化過了」而放心加東西進那個迴圈,或是為了找 PropertyInfoCache 翻遍整個 repo,成本都要那時候的人付。
所以現在的處理方式是:文件裡明確分兩區,「已落地」與「評估過未採用」分開列,未採用的要寫原因。設計稿留著沒關係,但檔頭第一行要寫清楚它是提案。
九、數字要標明來源
最後回到數字本身。這次留下的所有數字裡,只有一個是實測:
| 數字 | 來源 | 性質 |
|---|---|---|
| 原始 7.62 分鐘 | 埋點實跑,寫進程式碼註解 | 實測 |
| 2000 筆 × 4 = 8000 次往返 | 依程式碼結構推算 | 推算 |
| 目標 30~60 秒、提升 85~90% | 設計稿的預估 | 預估 |
| 「快 100 倍」「省 99.9% 記憶體」 | README | 預估 |
after 的實測沒有留下紀錄。正確性倒是驗過了——新舊版跑同一批資料比對輸出,確認一致,這件事有寫進 commit。但「快了多少」始終停在預估。
before 都量了,after 沒量,這其實比完全不量還糟:它讓一個預估值長得像結論。驗證正確性和驗證效能是兩件事,做完一件不等於做完另一件。
如果要把這篇壓成三句話:
- 先量,把基準線寫在量測點旁邊。 半年後只有程式碼還在。
- 迴圈裡的 I/O 是唯一值得優先處理的事,資料結構層級的微優化順手改就好,別為它動主線。
- 文件只寫做到的。 沒落地的提案單獨列,並寫下為什麼沒做。
同分類的其他實作紀錄可以往 專案筆記 看。
留言