客戶反饋:「入金審核通過,但可用資金未增加」。排查發現:入金記錄狀態正常,代碼邏輯無誤,日志無異常,**卻偏偏少了一筆 10 萬元**。這類「無錯誤日志、業務數據不一致」是併發 Bug 的典型特徵。
MoneyUser moneyUser = moneyUserMapper.selectByPrimaryKey(uid); // 1. 讀取舊值
moneyUser.setFrozen(moneyUser.getFrozen().subtract(amount)); // 2. 內存計算
moneyUser.setTotalPay(moneyUser.getTotalPay().add(amount)); // 2. 內存計算
moneyUserMapper.updateByPrimaryKey(moneyUser); // 3. 全字段更新
兩筆並發出金請求(金額 10、100)同時進入:
| 時間 | 線程 A (出金 10) | 線程 B (出金 100) | 數據庫狀態 |
|---|---|---|---|
| T1 | select → frozen=1000, total=5000 | select → frozen=1000, total=5000 | frozen=1000, total=5000 |
| T2 | 計算:frozen=990, total=5010 | 計算:frozen=900, total=5100 | 無變化 |
| T3 | update 全字段 | update 全字段 | 以最後提交者為準,丟失一筆變更 |
結果:數據庫最終僅反映其中一筆交易,另一筆「憑空消失」。
-- 嘗試 1:先 INSERT 再 UPDATE
START TRANSACTION;
INSERT INTO record VALUES(1,123);
UPDATE money SET money = money + 10; COMMIT;
-- 嘗試 2:先 UPDATE 再 INSERT
START TRANSACTION;
UPDATE money SET money = money + 10;
INSERT INTO record VALUES(1,123); COMMIT;
**結論:事務只能保證單線程原子性,無法解決多線程「讀取-計算-寫回」的競態條件。**
<update id="transferOut" parameterType="map">
UPDATE money_user
SET frozen = frozen - #{amount,jdbcType=DECIMAL},
total_pay = total_pay + #{amount,jdbcType=DECIMAL}
WHERE uid = #{uid,jdbcType=INTEGER}
</update>
| 維度 | Java 端計算 + 全字段更新 | SQL 原子更新 |
|---|---|---|
| 併發安全性 | ❌ 競態條件,丟失更新 | ✅ 行鎖串行化,數學上不可分割 |
| 性能 | 一次 SELECT + 一次 UPDATE | 單次 UPDATE,無額外 SELECT |
| 業務邏輯位置 | 分散在應用層 | 集中在數據層,易審計 |
| 適用場景 | 非併發、極簡單邏輯 | 所有資金、庫存、計數器類併發更新 |
UPDATE ... SET col = col + val WHERE pk = ? 會在聚集索引記錄上加 **排他鎖 (X Lock)**,併發事務按順序獲取鎖、執行計算、釋放鎖,天然串行化,無需應用層鎖。
updateByPrimaryKey(entity)、saveOrUpdate(entity) 涉及金額/庫存字段的調用點。「領導早就建議在 SQL 裡加運算,我當初沒聽進去」——這句話道盡了無數開發者的遲來覺悟。