某天下午,PM 跟我說:「使用者反應下拉選單改了品牌,存了之後沒有變。」
我看了一下,想說這能有多難?前端選品牌、傳 API、存資料庫,三步路。大概是前端忘了帶值吧。十分鐘收工。
結果追了大半天。
症狀:一切看起來都正常,但就是沒存進去
打開瀏覽器 DevTools,操作一遍:選了新品牌,按儲存,Network 面板顯示 API 回了 200 OK。
好,那去資料庫看看——品牌欄位還是舊值。
嗯?
再試一次,仔細看 Request Payload——新品牌的 TrademarkId 有帶,值是對的。Response 也沒有錯誤訊息。API 說「我處理完了」,資料庫說「我沒被改過」。
到底誰在說謊?
第一輪排查:前端和 API 都沒問題
先懷疑前端。打開 React 那邊的 handleSave,確認 state 裡品牌值有更新、API call 有帶新值。沒問題。
再看 API Controller。打個中斷點,request body 確實帶了新的 TrademarkId。AutoMapper 也正常把 DTO 映射到 DataModel 上了。Model 裡的 TrademarkId 確實是新值。
然後呼叫 baseDal.Update(model),也沒 throw exception。
到這邊我開始覺得不太對勁了。前面每一步都正確,但最後結果就是錯的。這種 bug 最噁心——沒有任何明確的錯誤訊息告訴你哪裡出了問題。
第二輪排查:盯著 SQL 看
掛上 SQL Profiler,再做一次操作。看到 ORM 產生的 SQL 大概長這樣:
1 | UPDATE [SeriesInfo] |
等等。
WHERE 裡面的 TrademarkId 值是 新值,不是舊值。
它在用「你要改成的值」去找「你要改的那筆資料」。當然找不到——那筆資料的 TrademarkId 還是舊的啊!
結果就是 0 rows affected。ORM 很安靜地跟你說「好的,我更新了零筆資料」,不報錯,不 throw,你以為成功了,其實什麼都沒做。
根本原因:複合主鍵 + AutoMapper = 定時炸彈
回頭看 DataModel 的定義:
1 | public class SeriesInfo |
TrademarkId 和 SeriesId 組成複合主鍵。
ORM 做 Update 的時候,用 Model 物件上的 PK 值組 WHERE 條件。但 AutoMapper 在映射 DTO → Model 的時候,已經把 TrademarkId 從舊值改成新值了。所以 WHERE 條件用的是新的 TrademarkId,去資料庫找自然找不到——因為資料庫裡存的還是舊的。
整理一下:
- 使用者操作:把品牌從 A 改成 B
- AutoMapper:把 Model 的
TrademarkId從 A 改成 B - ORM Update:
WHERE TrademarkId = 'B' AND SeriesId = 'xxx' - 資料庫:「我這邊只有
TrademarkId = 'A'的資料,找不到你說的那筆」 - 結果:0 rows affected,靜默「成功」
flowchart TD
A[前端傳入新品牌 B] --> B[AutoMapper 映射]
B --> C[Model.TrademarkId = B]
C --> D{TrademarkId 是 PK 嗎?}
D -->|是| E[ORM: WHERE PK = B]
E --> F[資料庫: 找不到 PK=B 的資料]
F --> G[0 rows affected 靜默成功]
D -->|否| H[ORM: WHERE PK = 原值]
H --> I[正常更新]
style G fill:#e74c3c,color:#fff
style I fill:#2ecc71,color:#fff
問題的核心:當你修改的欄位本身就是 PK 的一部分,ORM 的 Update 機制就會壞掉。 因為它用「已經被改過的 PK」去找「還沒被改的資料」。
解法:PK 變更走 Delete + Insert
想清楚之後,解法其實蠻直覺的。既然 PK 變了就等於是另一筆資料,那就不該用 Update,而是 Delete 舊的 + Insert 新的。
但要注意保留原始的 CreatorId 和 CreateTime——不能因為重新 Insert 就把這些歷史紀錄弄丟。
在 Controller 上,加了一個 query parameter 讓前端傳舊的品牌值:
1 | [] |
BLL 裡面的 DeleteAndReCreate 做的事情就是:
- 用舊的 PK 把資料撈出來
- 保留
CreatorId、CreateTime - 刪除舊資料
- 用新的 PK 插入新資料,帶上保留的欄位
決策流程整理:
flowchart TD
A[收到 Update 請求] --> B{有傳 originalTrademarkId?}
B -->|沒有| C[正常 Update]
B -->|有| D{新舊 TrademarkId 相同?}
D -->|相同| C
D -->|不同| E[Delete 舊資料]
E --> F[保留 CreatorId / CreateTime]
F --> G[Insert 新資料]
style C fill:#2ecc71,color:#fff
style G fill:#4a9eff,color:#fff
ORM 靜默失敗是最噁心的 Bug 類型
回想起來,這個 bug 之所以難追,核心原因是 ORM 的靜默失敗。
0 rows affected 不報錯、不 throw、不給你任何提示。API 回 200,前端沒收到錯誤,使用者操作完覺得存好了,實際上什麼都沒發生。這種 bug 可能存在好幾個月都不會被發現——直到某個使用者終於注意到「欸這個值怎麼一直沒變?」
比起直接噴 exception 的 bug,靜默失敗恐怖多了。至少 exception 會告訴你出事了,你可以去修。靜默失敗是你連「出事了」都不知道。
預防措施
踩完這個坑之後,我在 DAL 層加了一個 convention:
Update 之後一律檢查 affected rows。 如果是 0,至少要 log warning。在某些業務場景下,0 rows affected 應該直接 throw exception——因為如果你預期要更新一筆資料但實際上沒有任何資料被更新,這十有八九是有問題的。
1 | var affectedRows = await _context.SaveChangesAsync(); |
另外,code review 的時候我會特別注意有 [Key] 標記的 DataModel。如果前端有 UI 可以改動 PK 欄位的值,那 Update 就一定要特別處理。
教訓
這個 bug 教會我幾件事:
- 複合 PK 的 DataModel,在寫 Update 邏輯之前,先搞清楚哪些欄位是
[Key]。 如果使用者可以改那些欄位,你就不能用標準的 Update - AutoMapper 是好東西,但它不管你的欄位是不是 PK。 它只負責映射,不負責語意。PK 變更的語意你得自己處理
- ORM 的「成功」不一定是真的成功。 養成檢查 affected rows 的習慣,不要假設「沒 throw exception 就是 OK」
- SQL Profiler 是你的好朋友。 當應用層看起來一切正常的時候,去看實際送出的 SQL,通常幾秒內就能定位問題
說到底,ORM 給了我們很大的便利,但它也藏了很多隱含行為。「靜默失敗」就是其中最讓人頭痛的一個。遇到「明明沒報錯但就是不對」的情況,先別懷疑人生——打開 SQL Profiler 看看實際的 WHERE 條件,答案可能就在那裡。