iT邦幫忙

2026 iThome 鐵人賽

DAY 16
0
ChatGPT & Codex

AI 救得了祖傳系統嗎?30 天實戰企業 Legacy System × AI 協作開發系列 第 16 篇

Day 16|80 筆資料存五分鐘:慢的到底是 SQL、Dataset,還是 UI?

  • 分享至 

  • xImage
  •  

前言/情境導入

前幾篇,我一直在處理 Legacy System Debug 裡的「正確性」。

資料為什麼變成 0?

程式到底走進哪一個 Branch?

找到的 Code,真的是使用者實際走到的流程嗎?

但祖傳系統還有另一種問題,它不一定會算錯,也不一定會跳 Exception。

它只是——慢。

而且慢到你會開始懷疑,到底是 SQL、Delphi,還是自己的電腦在跟你作對。

最近我處理一個 Delphi 舊系統的儲存效能問題。

使用者在畫面上新增一批明細後按下儲存,如果只有幾筆資料,問題並不明顯。

但當資料累積到 80 筆以上,整個儲存流程竟然可能要跑將近:

5 分鐘。

更麻煩的是,這不是一個「按下去立刻噴錯」的 Bug。

畫面沒有當掉。

資料庫也沒有直接報錯。

程式甚至還在正常執行。

只是使用者按下儲存之後,只能盯著畫面等。

這種問題丟給 AI,最容易得到的第一批答案通常是:

  • SQL 太慢,要加 Index。
  • Dataset 太慢,減少 Post。
  • UI 一直 Refresh,要用 DisableControls。
  • Loop 裡不要一直查資料庫。
  • 可以改成 Batch Update。

每一個聽起來都有道理。

問題是:

到底哪一個才是這次五分鐘的主要來源?

如果沒有 Evidence,我可以花半天加 Index,最後發現 SQL 每次只跑 20ms。

也可以重構整個 Dataset 流程,最後才發現真正的問題是某個事件每 Post 一次,就偷偷再查一次資料庫。

所以這次我沒有先問 AI:

「這段程式要怎麼最佳化?」

而是先問另一個問題:

這五分鐘,到底花在哪裡?

而且 Performance Debug 還要再多問一句:

除了每一次有多慢,它到底被執行了幾次?


核心技術解析

「很慢」不是一個可以直接修的 Bug

功能錯誤至少通常還有一個明確結果。

例如:

預期金額: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。

目的只有一個:

不要再用「感覺」猜哪裡慢。


程式裡的 Timer 還不夠:再找一份外部 Evidence

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」問題。

這個差異很重要。


第一刀:先停止不必要的 UI 更新

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 開始執行。


第三刀:值沒有變,就不要急著 Post

某些計算流程最後不論結果有沒有改變,都會:

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 拿掉。


第四個檢查點:確認 Transaction Boundary

查到 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 狀態

假設某個計算函式重新掃描 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 的原因。

不知道,就量。

不要猜。


Performance Debug 其實也是 Trace

做到這裡,我才發現 Day 13 到 Day 16,其實是在做同一件事。

Day 13 的 Data Trace:

資料在哪裡第一次變了?

Day 14 的 Branch Trace:

程式實際走到哪一條路?

Day 15 再往前追:

現在看到的 Code,
真的是當時產生問題的 Code 嗎?

而 Day 16 的 Performance Trace:

時間到底消失在哪裡?

它們背後都是同一個原則:

不要看到結果就猜原因,要把執行過程變成可以觀察的 Evidence。


Delphi 自己其實也想過這個問題

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。


從 Delphi Fat Client 回頭看現代 Web 架構

很多傳統桌面系統屬於典型的 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 最麻煩的地方,就是這些邊界往往已經纏在同一條事件鏈裡。


可以直接使用的 Prompt 範本

你現在的角色是 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 來說,真正困難的從來不是單純「讓它跑得更快」。

而是:

讓它跑得更快之後,還是同一套系統。


上一篇
Day 15|AI 很有自信地找到 Bug——而我差點真的相信它
下一篇
Day 17|為了讓畫面不要卡,多加這一行 Code,可能讓儲存流程更危險
系列文
AI 救得了祖傳系統嗎?30 天實戰企業 Legacy System × AI 協作開發 共 17 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言