前幾篇,我一直在處理 Legacy System Debug 裡的「正確性」。
資料為什麼變成 0?
程式到底走進哪一個 Branch?
找到的 Code,真的是使用者實際走到的流程嗎?
但祖傳系統還有另一種問題,它不一定會算錯,也不一定會跳 Exception。
它只是——慢。
而且慢到你會開始懷疑,到底是 SQL、Delphi,還是自己的電腦在跟你作對。
最近我處理一個 Delphi 舊系統的儲存效能問題。
使用者在畫面上新增一批明細後按下儲存,如果只有幾筆資料,問題並不明顯。
但當資料累積到 80 筆以上,整個儲存流程竟然可能要跑將近:
5 分鐘。
更麻煩的是,這不是一個「按下去立刻噴錯」的 Bug。
畫面沒有當掉。
資料庫也沒有直接報錯。
程式甚至還在正常執行。
只是使用者按下儲存之後,只能盯著畫面等。
這種問題丟給 AI,最容易得到的第一批答案通常是:
Post。DisableControls。每一個聽起來都有道理。
問題是:
到底哪一個才是這次五分鐘的主要來源?
如果沒有 Evidence,我可以花半天加 Index,最後發現 SQL 每次只跑 20ms。
也可以重構整個 Dataset 流程,最後才發現真正的問題是某個事件每 Post 一次,就偷偷再查一次資料庫。
所以這次我沒有先問 AI:
「這段程式要怎麼最佳化?」
而是先問另一個問題:
這五分鐘,到底花在哪裡?
而且 Performance Debug 還要再多問一句:
除了每一次有多慢,它到底被執行了幾次?
功能錯誤至少通常還有一個明確結果。
例如:
預期金額:12,500
實際金額:0
我可以沿著資料一路往前追,找到數值第一次改變的位置。
但 Performance 問題不一樣。
使用者只會告訴你:
「這邊存檔很慢。」
「慢」本身沒有告訴我們任何 Root Cause。
因為從使用者按下按鈕,到畫面重新恢復操作,中間可能經過:
Save Button
↓
Dataset Loop
↓
SQL Query
↓
計算
↓
Dataset.Edit
↓
Dataset.Post
↓
BeforePost / AfterPost
↓
其他 Dataset 查詢
↓
UI Refresh
↓
下一筆
只要其中某一個步驟被放進 Loop,成本就可能被資料筆數放大。
更麻煩的是:
真正的效能問題,常常不是某一行特別慢,而是一個不算慢的動作,被執行了太多次。
例如一個 SQL 查詢只花 30ms。
單看完全不值得最佳化。
但如果 100 筆資料,每筆又執行 3 次:
30ms × 100 × 3 = 9,000ms
光這一小段就已經 9 秒。
如果 Post 又觸發其他事件、其他查詢、其他 UI 更新,時間就會繼續往上疊。
所以面對 Performance 問題,我這次仍然沿用前幾篇的原則:
先建立 Evidence,再決定要改什麼。
我一開始重新閱讀儲存流程時,最值得注意的不是某一條 SQL。
而是:
dtDetail.First;
while not dtDetail.Eof do
begin
// 檢查資料
// 查詢其他 Dataset
// 計算金額
// Edit
// Post
dtDetail.Next;
end;
這種 Code 在 Delphi Legacy System 裡非常常見。
它本身也不代表有問題。
如果每一筆明細本來就必須分別計算,那 Loop 完全合理。
真正該問的是:
Loop 裡面到底放了什麼?
假設有 80 筆資料。
Loop 裡的任何動作,理論上都可能被執行 80 次。
如果裡面還有:
dtBudget.Close;
dtBudget.Open;
那就不只是「查一次資料」。
而是:
80 筆
×
每筆重新 Query
如果另一個檢查也使用相同模式:
dtProject.Close;
dtProject.Open;
就又多了一組。
所以 Performance Debug 的第一個 Evidence,不一定是毫秒數。
有時候反而是:
Execution Count。
一條 SQL 跑 200ms,執行一次,跟一條 SQL 跑 20ms,執行 500 次,是完全不同的問題。
如果只在儲存前後放一個 Timer:
StartTime := GetTickCount;
// 整個儲存流程
ShowMessage(
'耗時:' +
IntToStr(GetTickCount - StartTime) +
' ms'
);
最後可能得到:
298423 ms
很好。
現在我知道真的跑了快五分鐘。
然後呢?
我還是不知道慢在哪裡。
所以這次我把一次儲存拆成幾個可以觀察的區段:
總儲存時間
│
├─ 預算資料檢查
├─ 專案資料檢查
├─ 其他資料來源查詢
├─ Dataset Post
└─ 其他流程
接著才分別累計時間。
例如:
SD_START := GetTickCount;
dtBudget.Close;
dtBudget.Open;
Inc(
SD_CHECK_BUDGET_MS,
GetTickCount - SD_START
);
Post 也另外計時與計數:
SD_START := GetTickCount;
dtDetail.Post;
Inc(
SD_POST_MS,
GetTickCount - SD_START
);
Inc(SD_POST_COUNT);
這些欄位不是正式功能。
它們只是 Debug Instrumentation。
目的只有一個:
不要再用「感覺」猜哪裡慢。
GetTickCount 可以告訴我某一段 Delphi Code 花了多少時間。
但它仍然是從 Client 端看世界。
如果懷疑:
SQL 到底執行了幾次?
同一條 Query 是否重複送出?
等待時間到底在 Client 還是 Database?
還可以從資料庫端取得另一份 Evidence。
以 SQL Server 為例,可以視環境與權限使用 Profiler 或 Extended Events,觀察實際送進資料庫的 SQL、執行次數與時間。
這時就有兩份 Evidence:
Delphi Instrumentation
↓
Client 認為哪一段很慢
Database Trace
↓
DB 實際收到什麼
如果 Delphi 的 Counter 認為某個 Lookup 跑了 80 次,而 DB Trace 也真的看到相同查詢大量重複出現,判斷就比單純閱讀 Source Code 更可靠。
這也是我現在越來越重視的一件事:
能從系統外部驗證的 Evidence,通常比「我覺得這段會很慢」更有價值。
這次第一輪調整,可以整理成幾個方向。
| 類型 | 處理對象 | 要避免的問題 | 這次關注的效益 |
|---|---|---|---|
| 修改 | DisableControls |
Dataset 移動造成 UI 重複更新 | 降低 Data-Aware UI 成本 |
| 修改 | IsEmpty |
沒資料仍進入不必要流程 | 提早結束 |
| 修改 | 減少不必要 Post |
無效更新仍觸發事件 | 降低重複處理 |
| 檢查 | Transaction Boundary | 批次異動的交易邊界不清 | 確認一致性與可能的 Commit 成本 |
| 修改 | Lookup Cache | Loop 內重複查相同資料 | 減少重複 SQL Call |
| 保護 | Bookmark | 批次處理改變 Dataset 游標 | 維持原本操作語意 |
其中 Transaction 跟其他幾項稍微不同。
Transaction 是這次應該確認的效能邊界,不代表我已經證明原程式存在「80 次 Auto-Commit」問題。
這個差異很重要。
Delphi 的 Dataset 與 Data-Aware Control 綁得很緊。
當 Dataset 移動:
dtDetail.Next;
畫面上的 DBGrid、DBEdit、DBText 等元件,也可能跟著更新。
如果一次處理 80、100,甚至 148 筆資料,使用者根本不需要看到 Grid 一列一列跳。
所以第一個相對低風險的處理,是:
dtDetail.DisableControls;
try
dtDetail.First;
while not dtDetail.Eof do
begin
// 批次處理資料
dtDetail.Next;
end;
finally
dtDetail.EnableControls;
end;
這個修改真正做的是:
批次處理 Dataset 時,不要讓 Data-Aware UI 跟著每一次 Record Navigation 重複更新。
而且一定要使用 try...finally。
否則中途發生 Exception,EnableControls 沒有被執行,畫面反而可能進入更奇怪的狀態。
如果 Dataset 本來就沒有資料,有些前置流程根本不需要繼續執行。
在確認商業規則允許之後,可以先擋:
if dtDetail.IsEmpty then
Exit;
這不是什麼高深的效能技巧。
但 Performance Optimization 很多時候就是這樣:
不要讓不需要執行的 Code 開始執行。
某些計算流程最後不論結果有沒有改變,都會:
dtDetail.Edit;
dtDetail.FieldByName('AMOUNT').AsFloat := NewAmount;
dtDetail.Post;
如果數值根本沒有改變,這次 Post 是否真的必要,就值得重新確認。
因為 Legacy System 裡的 Post 很可能不只是 Post。
它後面可能掛著:
BeforePost
AfterPost
OnDataChange
重新計算
其他 Dataset 查詢
UI 更新
第一個直覺可能會寫成:
if dtDetail.FieldByName('AMOUNT').AsFloat <> NewAmount then
begin
...
end;
但這裡還有一個 Delphi 常見的小陷阱:
AsFloat 是浮點數,不適合毫無容許誤差地直接判斷是否完全相等。
因此如果這個欄位的商業語意允許以浮點數處理,可以使用 Math.SameValue,並依實際欄位精度決定容許誤差:
uses
Math;
if not SameValue(
dtDetail.FieldByName('AMOUNT').AsFloat,
NewAmount,
AMOUNT_EPSILON
) then
begin
dtDetail.Edit;
dtDetail.FieldByName('AMOUNT').AsFloat := NewAmount;
dtDetail.Post;
end;
這裡的 AMOUNT_EPSILON 不應該隨便指定。
如果資料庫欄位實際精度是六位小數,或系統另有 RoundAmount、幣別小數位等規則,就應該使用既有的 Business Rule。
所以這一刀真正的判斷其實有兩層:
這個值真的改變了嗎?
↓
這次 Post 還有沒有其他必要副作用?
兩個答案都確認之後,才適合把不必要的 Post 拿掉。
查到 Post 之後,還有另一個容易被忽略的問題:
這些資料異動到底在哪一個 Transaction 裡?
如果一次儲存處理 80 筆:
Save
│
├─ Post #1
├─ Post #2
├─ Post #3
├─ ...
└─ Post #80
這 80 次資料異動,是共用同一個明確 Transaction?
還是由底層資料存取元件依自己的設定處理?
又或者真正的 Transaction Boundary 其實藏在更外層?
在沒有確認 Connection 與 Dataset 行為以前,我不會直接說:
「80 次 Post 就等於 80 次 Commit。」
但我會把它列進 Trace Checklist。
如果商業規則要求這批異動:
全部成功,或全部失敗。
那 Transaction 本來就是 Business Logic 的一部分。
概念上可能像:
Connection.StartTransaction;
try
// 批次資料異動
Connection.Commit;
except
Connection.Rollback;
raise;
end;
真正需要確認的是:
目前的 Transaction Boundary 是否符合這批資料的商業語意?它又對實際效能造成多少影響?
不是看到慢,就直接在 Loop 外面硬包一層 Transaction。
假設每筆資料都根據某個代碼查詢基礎資料:
dtLookup.Close;
dtLookup.ParamByName('CODE').AsString := Code;
dtLookup.Open;
如果 148 筆資料中有很多筆使用相同 Code,相同 Query 就可能一再執行。
這時可以考慮在一次批次作業期間建立 Memory Cache。
概念上像:
if not LookupCache.TryGetValue(Code, ResultValue) then
begin
dtLookup.Close;
dtLookup.ParamByName('CODE').AsString := Code;
dtLookup.Open;
ResultValue :=
dtLookup.FieldByName('DESCRIPTION').AsString;
LookupCache.Add(Code, ResultValue);
end;
但真正重要的不是 Dictionary 怎麼寫,而是:
Cache Key 到底應該包含什麼?
如果同一個 Code 在不同公司或案件下可能代表不同資料,那:
Code
可能根本不夠。
實際需要的 Key 可能是:
Company + Project + Code
Key 粒度錯了,速度可能真的變快。
只是拿到的是錯的資料。
所以 Cache 最少要確認三件事:
Key 是否完整?
資料在 Cache 生命週期內是否可能改變?
Cache 應該活多久?
不要問資料庫五十次同一個問題,但也不要記住一個已經失效的答案。
假設某個計算函式重新掃描 Dataset:
dtDetail.First;
while not dtDetail.Eof do
begin
...
dtDetail.Next;
end;
執行完之後,使用者原本停留的位置可能已經不見了。
所以批次處理前,可以考慮保存 Bookmark:
Bmk := dtDetail.GetBookmark;
try
dtDetail.DisableControls;
try
dtDetail.First;
while not dtDetail.Eof do
begin
// 計算
dtDetail.Next;
end;
finally
dtDetail.EnableControls;
end;
finally
if dtDetail.BookmarkValid(Bmk) then
dtDetail.GotoBookmark(Bmk);
dtDetail.FreeBookmark(Bmk);
end;
但 Bookmark 也不是萬能保險。
如果批次處理期間做了:
Delete
Close / Open
Refresh
重新建立 Dataset
原本的 Bookmark 就可能失效。
如果流程本身會重新開啟 Dataset,比起強行使用 Bookmark,更適合保存 Primary Key,重新 Open 後再 Locate。
這一刀真正想保護的不是 Bookmark 本身。
而是:
最佳化效能之後,使用者原本的 Dataset 狀態與操作語意不要一起被改掉。
第一輪調整後,我用 148 筆資料重新測試。
原本 80 多筆資料可能要等待接近五分鐘的流程,第一版最佳化後,148 筆測試資料已經可以壓到大約:
50 秒左右。
這個結果還不代表「最佳化完成」。
50 秒仍然不是一個我會滿意的最終體驗。
而且這裡還有一個很重要的限制:
前後兩次測試的資料量不同,因此不能直接把「5 分鐘 → 50 秒」當成精確的效能提升比例。
80 多筆與 148 筆並不是完全相同的 A/B Test。
它能證明的是:
第一輪修改已經明顯改變了原本的效能量級。
但如果要精確回答到底快了幾倍,仍然應該使用相同資料、相同環境與相同操作流程重新建立 Baseline。
這裡我也刻意沒有寫:
SQL:240 → 12 次
Post:80 → 15 次
UI:80 → 0 次
因為這些如果沒有實際 Counter 或 Trace,就只是看起來很漂亮的數字。
Performance Debug 最怕的,就是一邊強調 Evidence,一邊自己補出沒有量過的 Evidence。
這次真正能確認的是:
| 測試階段 | 資料量 | 儲存時間 |
|---|---|---|
| 原始問題 | 80+ 筆 | 約 5 分鐘 |
| 第一輪調整 | 148 筆 | 約 50 秒 |
而 SQL Call、Post Count、各檢查階段耗時,就是我另外加入 Instrumentation 的原因。
不知道,就量。
不要猜。
做到這裡,我才發現 Day 13 到 Day 16,其實是在做同一件事。
Day 13 的 Data Trace:
資料在哪裡第一次變了?
Day 14 的 Branch Trace:
程式實際走到哪一條路?
Day 15 再往前追:
現在看到的 Code,
真的是當時產生問題的 Code 嗎?
而 Day 16 的 Performance Trace:
時間到底消失在哪裡?
它們背後都是同一個原則:
不要看到結果就猜原因,要把執行過程變成可以觀察的 Evidence。
Delphi 生態其實早就有像 TClientDataSet 這種 In-Memory Dataset 的設計。
概念上可以讓使用者先在 Client 端累積異動,再透過 Provider 與 ApplyUpdates 將變更送往後端。
也就是從:
使用者修改一筆
↓
立即碰 Database
變成比較接近:
UI 修改
↓
ClientDataSet / Delta
↓
ApplyUpdates
↓
Database
這不代表 ApplyUpdates 底層一定只會產生一次 SQL,或一定只需要一次 Network Round Trip。
實際行為仍然取決於 Provider、Data Access Layer 與設定。
我也不會因為看到現在的 Legacy Code 很慢,就直接把整套系統改成 TClientDataSet。
那已經不是 Performance Patch,而是 Architecture Refactoring。
但它提醒了一件事:
UI 的每一次狀態變化,本來就不一定應該立即變成一次 Database Operation。
很多傳統桌面系統屬於典型的 Fat Client。
同一個 Form 裡,很容易同時存在:
UI Control
↕
Dataset
↕
Dataset Event
↕
Business Logic
↕
SQL / Database
使用者只是在 Grid 上移動一筆資料,都可能觸發 Dataset Event。
程式只是執行一次 Post,也可能一路牽動:
資料更新
→ Event
→ Query
→ UI Refresh
如果換成現在常見的 Web 架構,通常會比較明確地分成:
Request
↓
Validate
↓
Business Logic
↓
Transaction
↓
Persistence
但這不代表 Web 天生就比較快。
如果 Backend 收到 80 筆後還是:
for 每一筆
Query DB
Query DB
Update DB
一樣可以做出漂亮的 N+1 Query。
真正的差異不是:
Delphi 慢,Web 快。
而是:
當 UI、Business Logic 與 Persistence 的責任切得比較清楚時,我們通常更容易知道成本發生在哪一層。
而 Legacy Fat Client 最麻煩的地方,就是這些邊界往往已經纏在同一條事件鏈裡。
你現在的角色是 Legacy System Performance Debug 協作者。
目前現象:
- 使用者按下儲存後等待時間過長。
- 少量資料時不明顯。
- 約 80 筆以上時可能需要數分鐘。
- 目前尚未確認瓶頸位於 SQL、Dataset、事件、Transaction 或 UI。
在提出任何最佳化修改前,請先:
1. 找出儲存流程的 Entry Point。
2. 列出所有 Loop。
3. 列出 Loop 內的:
- SQL Query
- Dataset.Open / Close
- Edit / Post
- First / Next / Locate
- UI Refresh
- 可能觸發的 Dataset Event
4. 推算 N 筆資料時,各操作可能執行的次數。
5. 確認目前可觀察到的 Transaction Boundary。
6. 區分:
- 已確認的效能問題
- 合理懷疑
- 仍需 Instrumentation 驗證
7. 建議應加入哪些:
- Timing
- Counter
- Log
8. 若有資料庫端 Trace,將 Client Instrumentation
與 Database Trace 交叉比對。
9. 在沒有量測證據前,不要直接假設 SQL 是主要瓶頸。
10. 提出修改時,說明是否可能改變:
- Business Logic
- Dataset Event 行為
- Transaction 語意
- 使用者目前 Dataset 狀態
最後請用以下格式整理:
Baseline
→ Instrumentation
→ Bottleneck
→ Patch
→ Result
→ Regression Check
這種 Prompt 的目的不是叫 AI:
幫我把程式變快。
而是要求它:
先幫我證明時間花在哪裡。
80 筆資料存五分鐘,第一個嫌疑人很容易是 SQL。
但實際進入 Legacy System 之後,我反而更不敢這麼快下結論。
因為一個儲存按鈕背後可能同時存在:
SQL
Dataset
Event
UI
Transaction
Business Logic
真正拖慢系統的,也可能不是其中某一個特別慢,而是它們被放進 Loop 之後彼此疊加。
這次我最後留下來的 Debug 流程是:
Baseline
↓
Instrumentation
↓
Timing + Counter
↓
Database Trace
↓
找出重複成本
↓
最小修改
↓
重新量測
↓
Regression Check
其中最重要的一步,甚至不只是 Timing。
而是:
Counter。
因為一條 20ms 的 SQL 不一定值得處理。
但如果它在一次儲存裡執行了 500 次,問題就完全不同。
同樣地,一次 Post 看起來沒什麼。
但如果每次 Post 都觸發 Dataset Event、重新查詢與 UI 更新,它真正的成本就不能只看那一行。
真正的問題可能不是 Slow SQL,而是 Too Many SQL Calls。
所以 Performance Debug 最後真正要回答的,不只是:
「哪一行最慢?」
而是:
「哪一個成本,被重複了最多次?」
AI 可以幫我找 Loop、整理 Query、統計 Post、追 Dataset Event,甚至提出 Cache、Transaction 或架構重構的方向。
但最後仍然需要工程師判斷:
哪些重複工作可以安全拿掉?
以及更重要的:
哪些看似多餘的動作,其實藏著祖傳系統十幾年累積下來的商業規則?
速度從五分鐘降到五十秒,當然很有感。
但對 Legacy System 來說,真正困難的從來不是單純「讓它跑得更快」。
而是:
讓它跑得更快之後,還是同一套系統。