1. 程式人生 > 其它 >問題記錄:併發訪問導致的資料庫修改異常

問題記錄:併發訪問導致的資料庫修改異常

  • 問題描述:

產生問題的功能類似於,有一個固定數值的可分配量可以分配給某些使用者,在下發的過程中需要校驗當前剩餘的可分配量是否足夠本次分配。
我在程式碼中的校驗邏輯是首先讀取歷史數量,然後與當前分配量進行對比,數量足夠的情況下會進行資料庫修改。
這種邏輯在不會重複多次快速點選,並且類似於線性請求的情況下,是不會有問題的。我也就把所有計算和校驗都放在了記憶體中進行,然後寫表。

萬萬沒想到啊~ 前端兄弟他沒做防重校驗... ... 然後就破防了

問題原因

首先貼上問題虛擬碼:
    @Transactional(rollbackFor = Exception.class)
    public void assigningRewardAmount(Long id, Long proId, BigDecimal workload, String dateStr) {
        // ...
        // 問題主要出現在了獲取這個 file 類上 // 
        System.out.println(LocalDateTime.now()); // code1
        MwProjectDetail file = fileMapper.selectByPrimaryKey(proId); // code2
 
        // 在併發分配的時候,在這個校驗中讀取的資料都是一樣的,所以校驗都能通過 // 
        BigDecimal last = file.getReservedWorkload().subtract(file.getReservedWorkloadAssigned());
        if (last.compareTo(workload) < 0) { // code3
            throw new InternalHandlerException("剩餘可用量不足,僅剩數量[%s]", last.toString());
        }
        file.setReservedWorkloadAssigned(workload);
        file.fillingModifyOperatorInfo(AdminUtil.getAdminUid(), AdminUtil.getAdmin().getUsername(), new Date());

        // 在進行資料庫修改的時候,會覆寫掉上一次修改的值 // 
        fileMapper.updateByPrimaryKeySelected(file); // code4
       
        // ...
    }
因為通過controller訪問的時候,請求會併發進來所以產生以下問題:

在上面的程式碼中,當發生併發訪問的時候所有執行緒會在同一時間開啟事務並且讀取到資料庫中的 file 物件, code1 中列印時間後讀取 file 的時候還沒有
任何一個執行緒提交事務,所以所有執行緒 code2 讀取的 file 都是一樣的, 這就導致了後面的 code3 校驗都會通過,並且 code4 會互相覆寫掉上一次修改的值。
最後的結果就是隻要手夠快,就能分夠多的數量... ...

解決思路

因為作者比較菜並且過度相信前端兄弟,所以講累計形式的計算放到了記憶體中進行然後寫表,並且沒有做任何併發保護所以導致了這個問題。
那麼解決思路也非常簡單,將所有累計相關的修改和校驗都儘量放到資料庫中進行,並且通過 CAS 樂觀鎖的方式防止併發修改出現問題,

code3 中的校驗是不變的,因為如果數量已經不夠了可以快速反映給前端
code4 中的修改方式修改為新的修改方式,並且數量的校驗交給資料庫去做

// code4 的程式碼替換為
int cas = fileMapper.updateByPrimaryKeyCasAndCheckAmount(file);
if (cas != 1) {
    throw new CustomizeBusinessException("CAS 修改失敗");
}

在JDBC配置後面加上 useAffectedRows=true 可以開啟 update | insert 後返回真正的影響條數,而不是 where 條件命中條數
可以通過返回的影響條數來判斷CAS操作是否成功

  • update 操作進行CAS操作可以通過version欄位或者最後修改時間等欄位來控制,下面是一段虛擬碼
 update
  <include refid="tableSql"/>
  set a.`updated_user` = #{updatedUser}, a.`updated_user_id` = #{updatedUserId},
      a.`updated_date` = #{updatedDate, jdbcType=TIMESTAMP}, a.`version` = #{version},
      a.`reserved_workload_assigned` = (ifnull(a.`reserved_workload_assigned`, 0) + #{reservedWorkloadAssigned})
  where a.`id` = #{id} 
      <!-- 通過記憶體中的預期版本來做到 CAS -->
      and a.`version` = #{expectVersion}
      <!-- 通過資料庫來進行數量控制 -->
      and (ifnull(a.`reserved_workload_assigned`, 0) + #{reservedWorkloadAssigned}) <= a.`reserved_workload`
  • 如果是 insert 操作需要防止重複插入的話,可以通過 insert 後加 where 條件來進行判斷
insert `my_table`(`name`) select 'Shelby' from dual where [唯一判定條件];

那麼.....

那麼這次事故帶給我的教訓是什麼呢~ 第一:不要將累計的計算和重要校驗放到記憶體中。第二:當進行非定向修改的時候記得將併發問題考慮好。第三:希望我別這麼菜了