專案筆記

批次程式效能優化:真正省時間的只有一件事,其他加起來不到一成

2026-03-18 #.NET#C##效能優化#快取

一支跑了很多年的 .NET Framework 排程批次,單次執行 7.62 分鐘。優化後預估落在 30~60 秒。

拆開來看,貢獻度是這樣分布的:把迴圈裡的資料庫查詢搬到迴圈外,佔了八成以上。其餘那些看起來很專業的手法——Dictionary 取代 SingleOrDefault、HashSet 取代 Any()、快取反射結果、少呼叫幾次 DateTime.Now——全部加起來不到一成。

這篇記錄整個過程:怎麼量、改了什麼、哪些寫好了又拿掉、以及最後有一半的優化提案根本沒進主線。最後那點才是我覺得最值得寫下來的。

一、先量,再改

沒有數字就動手改效能,等於在賭。這次的量測分三層,成本由低到高。

第一層:主流程埋 Stopwatch,順便印記憶體。

1
2
3
4
5
6
7
8
9
10
11
12
13
var sw = Stopwatch.StartNew();
Process p = Process.GetCurrentProcess();

foreach (var file in files)
{
// ... 主要處理
}

sw.Stop();
Console.WriteLine($"執行時間:{sw.Elapsed.TotalMinutes:F4}");
Console.WriteLine($"平均每筆資料處理時間: {sw.Elapsed.TotalSeconds / totalCount:F4} 秒");
Console.WriteLine($"WorkingSet: {p.WorkingSet64 / 1024 / 1024} MB");
Console.WriteLine($"PrivateMemory: {p.PrivateMemorySize64 / 1024 / 1024} MB");

會印記憶體是有原因的:這類優化本質上是拿記憶體換時間,只看時間不看記憶體,就不知道自己把成本挪到哪去了。

第二層:基準線寫進程式碼註解。

1
2
// 原始跑法 : 7.62分鐘 (2026/02/11 09:03:00)
this.eodLog.InsertEOD_Log($"執行時間:{sw.Elapsed.TotalMinutes:F4}", "");

這行註解後來變成整個專案唯一可信的 before 數字。半年後回頭看,Excel 找不到、聊天記錄翻不到,只有程式碼還在。基準線寫在量測點旁邊,這個習慣值得養。

第三層:可疑熱點寫獨立的對照組。

不確定某個寫法到底慢不慢,就寫一支只比較兩種寫法的小程式,不接資料庫、不接業務邏輯:

1
2
3
4
5
6
7
8
9
10
11
// 舊寫法:O(n) 線性搜尋
for (int i = 0; i < iterations; i++)
foreach (var key in searchKeys)
testData.SingleOrDefault(o => o.FieldID.ToUpper() == key.ToUpper());

// 新寫法:Dictionary O(1)
var cache = testData.ToDictionary(f => f.FieldID.ToUpper(), f => f,
StringComparer.OrdinalIgnoreCase);
for (int i = 0; i < iterations; i++)
foreach (var key in searchKeys)
cache.TryGetValue(key.ToUpper(), out var result);

30 筆資料、7 個 key、跑 10,000 輪。這種 micro-benchmark 的價值不在數字本身,而在幫你決定要不要為這件事動主線程式碼——多數時候答案是不要。

二、真正的瓶頸:查詢寫在迴圈裡

profiling 完,瓶頸只有一個:逐筆處理的迴圈內,每一筆都執行 4 次資料庫查詢。

1
2
3
4
5
6
7
8
9
10
11
12
foreach (var row in rows)
{
// ...
using (var settingMsg = new PrivacySetting()) // ← 每筆 new 一次
{
var listA = settingMsg.GetSetting("A", "0", "3");
var listB = settingMsg.GetSetting("A", row.Type, "3");
var listA2 = settingMsg.GetSettingByCust("A", "0", "3");
var listB2 = settingMsg.GetSettingByCust("A", row.Type, "3");
// ... 用這四份清單做遮罩
}
}

單檔 2000 筆的話就是 8000 次資料庫往返。而這四次查詢回來的東西,在整批執行期間內容完全一樣——它們是設定檔,不是交易資料。

這是我認為最常見、也最容易忽視的效能問題:程式碼本身沒有錯,using 用得很標準,資源有正確釋放,每一行單獨看都符合規範。錯的是它待的位置。

三、Lazy<T> 放在建構式

改法是把這些參考資料提升到物件的建構式,用 Lazy<T> 宣告,第一次真正被存取時才載入:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
public class PrivacySetting : IDisposable
{
private readonly Lazy<List<Setting>> _settingCache;
private readonly Lazy<Dictionary<string, FieldDef>> _fieldCache;
private readonly Lazy<Dictionary<int, List<CustModule>>> _custCache;

public PrivacySetting()
{
_settingCache = new Lazy<List<Setting>>(() =>
{
using (var repo = new SettingRepository())
return repo.GetAll().ToList();
});

_fieldCache = new Lazy<Dictionary<string, FieldDef>>(() =>
{
using (var db = new DbContext())
return db.Set<FieldDef>().Where(e => e.RowStatus == 1)
.ToDictionary(e => e.FieldID);
});

_custCache = new Lazy<Dictionary<int, List<CustModule>>>(() =>
{
using (var db = new DbContext())
return db.Set<CustModule>().GroupBy(c => c.ModuleID)
.ToDictionary(g => g.Key, g => g.ToList());
});
}

public List<Setting> Query(string func, string type, string kind)
=> _settingCache.Value
.Where(x => x.Func == func && x.Type == type && x.Kind == kind)
.ToList();
}

呼叫端只要把「每筆 new 一次」改成「建構式建一次、整批共用」:

1
2
3
4
5
6
private readonly PrivacySetting _settingCache;   // 欄位

public JobFlow()
{
this._settingCache = new PrivacySetting(); // 整個批次共用一份
}

資料流變成這樣:

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
2
3
select * from 假日表
where 假日日期 > DATEADD(Year, -@year, GETDATE())
order by 假日日期

地區代碼對照表——有生效起訖日,只有當下生效的那批有意義:

1
2
3
4
5
6
7
8
9
10
11
12
_zipCache = new Lazy<Dictionary<string, List<ZipCode>>>(() =>
{
using (var repo = new ZipRepository())
{
return repo.GetAll()
.Where(x => (x.EffStart <= today && x.EffEnd >= today)
|| x.EffStart == tomorrow)
.ToList()
.GroupBy(x => x.ZipID)
.ToDictionary(g => g.Key, g => g.ToList());
}
});

同一張表,載入條件加對了,記憶體佔用和載入時間可能差一個數量級。「快取」和「整表載入」不是同義詞,這兩者被混為一談時,最後通常是記憶體先出事。

那個 .ToList() 的位置也是刻意的:先讓資料庫做篩選、把結果拉回本地,再做 GroupBy。少了它,GroupBy 可能被翻譯成 SQL 送進資料庫,得到一句你沒預期的查詢。

五、跨執行緒的那份要用 static,但要想清楚

假日表在多個地方被共用,寫成了 static:

1
2
3
4
5
6
private static readonly Lazy<HashSet<Holiday>> _offDateList =
new Lazy<HashSet<Holiday>>(() =>
{
var repo = new HolidayRepository();
return new HashSet<Holiday>(repo.GetRecentYears(3));
}, true); // isThreadSafe: true

第二個參數 true 不能省。Lazy<T> 預設雖然是執行緒安全的,但顯式寫出來是給下一個人看的——尤其當你在同一份程式碼裡還留著平行處理的痕跡時(下一節)。

static 的代價要講清楚:這份快取的生命週期等於 AppDomain。批次程式跑完就結束,這是它最適合 static 快取的原因;同樣的程式碼搬進長駐的 Web 應用,就變成「設定改了要重啟才生效」的經典事故。

這也是整篇最重要的邊界條件:以上所有做法成立的前提,是「行程生命週期短」加上「這些資料在執行期間不會變」。少任何一個,都要改用有 TTL 的快取機制。

六、寫好了又拿掉的:平行化

主流程曾經改成平行處理,分批 400 筆、MaxDegreeOfParallelism = 4:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var batches = Batch(rows, 400);
Parallel.ForEach(
batches,
new ParallelOptions { MaxDegreeOfParallelism = 4 }, // 建議不要超過 4
() => new Result(),
(batch, state, localResult) =>
{
var r = this.Check(batch, fieldDef, bMethod);
return this.AddResult(localResult, r);
},
localResult =>
{
lock (lockObj) { res = this.AddResult(res, localResult); }
});

寫得很完整——thread-local 累加、最後 lock 合併,該有的都有。然後整段被註解掉,改回單執行緒:

1
2
// 單線程處理方式
Result resOri = this.Check(rows, fieldDef, bMethod);

順序很關鍵:把每筆 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
2
3
4
if (processedIds.Add(row.Id))
res.RightData.Add(row); // 新的
else
res.Errors.Add(/* 重複 */); // 已存在

八、一半的優化提案沒有進主線

這是我最想寫下來的部分。

整個優化過程留下了三份設計稿和三支 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 沒量,這其實比完全不量還糟:它讓一個預估值長得像結論。驗證正確性和驗證效能是兩件事,做完一件不等於做完另一件。


如果要把這篇壓成三句話:

  1. 先量,把基準線寫在量測點旁邊。 半年後只有程式碼還在。
  2. 迴圈裡的 I/O 是唯一值得優先處理的事,資料結構層級的微優化順手改就好,別為它動主線。
  3. 文件只寫做到的。 沒落地的提案單獨列,並寫下為什麼沒做。

同分類的其他實作紀錄可以往 專案筆記 看。

Creative Commons 姓名標示 非商業性

本文採用 CC BY-NC 4.0 授權

歡迎轉載與引用,請標明出處(批次程式效能優化:真正省時間的只有一件事,其他加起來不到一成 — mur mur);禁止用於商業用途。

商業使用或合作提案,歡迎來信洽談:[email protected]

留言
分享

留言