天天看點

Java并發問題分析與處理指南

Java并發問題分析與處理指南

好像挺久沒有寫部落格了,趁着這段時間比較閑,特來總結一下在業務系統開發過程中遇到的并發問題及解決辦法,希望能幫到大家 😁

問題複現

1. “裝置Aの奇怪分身”

時間回到很久很久以前的一個深夜,那時我開發的多媒體廣告播放控制系統剛剛投産上線,公司開出的第一家線下生鮮店裡,幾十個大大小小的多媒體硬體裝置正常聯網後,正由我一台一台的注冊及接入到已經上線的多媒體廣告播控系統中。

注冊過程簡述如下:

Java并發問題分析與處理指南

每一個裝置注冊到系統中後,相應的在資料庫裝置表中都會新增一條記錄,來存儲這個裝置的各項資訊。

本來一切都有條不紊的進行着,直到裝置A的注冊打破了這默契的甯靜……

裝置A注冊完成後,我突然發現,資料庫裝置表中,新增了兩條記錄,而且是兩條一模一樣的記錄!

我開始以為自己眼花了……

仔細一看,确确實實是新增了兩條,而且連裝置唯一辨別(劃橫線,後面要考)和建立時間都一模一樣!

看着螢幕,我陷入了沉思……

為什麼會有兩條呢?

在我的注冊邏輯裡,落庫之前會先查一遍資料庫該裝置是否已存在,如果存在就更新已有的,不存在才新增。

是以我百思不得其解,按這個邏輯,第二條一模一樣的資料是哪來的?

2. 真相背後的并發請求

經過一番排查及思考,我發現問題可能就出在注冊請求上。

裝置A在向雲端發送http注冊請求時,可能會同時發送多個相同請求。

雲伺服器當時部署在多台Docker容器上,通過檢視日志發現,有兩台容器同時收到了來自裝置A的注冊請求。

由此,我推測:

裝置A同時發送了兩個注冊請求,這兩個請求分别在同一時間打到了雲端的不同容器上,按照我的注冊邏輯,這兩個容器接收到注冊請求後,同時去查詢了資料庫的裝置表,這時候裝置表裡還沒有裝置A的記錄,是以兩台容器都執行了新增的操作,因為速度很快,是以這兩條新增記錄在精确到秒的建立時間上,并沒有展現出差别。

3. 并發新增的延伸

既然并發的新增操作會産生問題,那麼并發的更新操作是否會有問題呢?

解決方法

解決并發新增

1. 資料庫唯一索引(UNIQUE INDEX)

在資料庫建表的時候,通過對具有唯一性的字段(比如上述的裝置唯一辨別)建立唯一索引,或對組合起來後就具備唯一性的幾個字段建立聯合唯一索引。

這樣在并發新增時,隻要有一個新增成功,其他的新增操作都會因為資料庫抛出的異常(java.sql.SQLIntegrityConstraintViolationException)而失敗,我們隻需要處理好新增失敗的情況就行了。

注意唯一索引的字段需要非空,因為字段值為空時會導緻唯一索引限制失效

2. java分布式鎖

通過在程式中引入分布式鎖,在進行新增操作前需要先擷取分布式鎖,擷取成功才能繼續,否則新增失敗。

這樣也能解決并發插入帶來的資料重複問題,隻是引入分布式鎖的同時也增加了系統的複雜性,如果要落庫的資料上有唯一性字段的話,還是推薦采用唯一索引的方法。

在建構分布式鎖的過程中,我們需要用到Redis,這裡以裝置注冊時使用的分布式鎖為例。

分布式鎖簡單問答:

Q:鎖究竟是什麼?

A:鎖實質上是存儲在Redis中,基于特定規則生成的一個字元串(示例裡是固定字首+裝置唯一辨別),相當于每個裝置注冊的時候都有自己對應的一把鎖,因為鎖隻有一把,即使該裝置有多個相同的注冊請求同時到來,也隻有其中擷取到那把鎖的那一個請求能成功走下去。

Q:什麼是擷取鎖?

A:同一個裝置,基于相同的規則生成的字元串(後文以Key代稱該字元串)總是相同的,在執行新增操作前,先去Redis中查詢這個Key是否存在,如果已存在,就意味着擷取鎖失敗;如果不存在,就将這個Key現存到Redis中,如果存儲成功,表示擷取鎖成功,如果存儲失敗,還是意味着擷取鎖失敗。

Q:鎖是怎麼工作的?

A:前面說過,同一個裝置,基于相同的規則生成的字元串(Key)總是相同的,在目前線程執行新增操作前,先在Redis中查詢這個Key是否存在,如果已存在,表示此時已經有别的線程成功擷取了鎖,正在做目前線程想要做的新增操作,則目前線程不需要進行後續操作了(是的,你是多餘的)

當這個Key不存在時,表示現在還沒有其他線程獲得鎖,則目前線程可以繼續進行下一步操作——在Redis中趕緊存入這個Key,當這個Key存儲失敗時,意味着有别的線程搶先存入了Key成功擷取了鎖,目前線程晚了一步,想做的工作被别人搶先做了(目前線程可以退下了)

當且僅當在Redis中存入這個Key也成功時,表示目前線程終于擷取鎖成功,可以安心進行後面的新增操作了,期間别的想做相同新增操作的線程因為擷取不到鎖,隻能全都退場拜拜👋,目前線程執行完後要記得釋放鎖(從Redis中删除這個Key)。

注冊時使用的分布式鎖代碼如下:

public class LockUtil {

    // 對redis底層set/get方法進行了簡單封裝的工具類
    @Autowired
    private RedisService redisService;

    // 生成鎖的固定字首,從配置檔案讀取值
    @Value("${redis.register.prefix}")
    private String REDIS_REGISTER_KEY_PREFIX;

    // 鎖過期時間:即擷取鎖後線程能進行操作的最長時間,超過該時間後鎖自動被釋放(失效),别人可以重新開始擷取鎖進行對應操作
    // 設定鎖過期時間是為了防止某線程成功擷取鎖後在執行任務過程中發生意外挂掉了造成鎖永遠無法被釋放
    @Value("${redis.register.timeout}")
    private Long REDIS_REGISTER_TIMEOUT;

    /**
     * 擷取裝置注冊時的分布式鎖
     * @param deviceMacAddress 裝置的Mac位址
     * @return
     */
    public boolean getRegisterLock(String deviceMacAddress) {
        if (StringUtils.isEmpty(deviceMacAddress)) {
            return false;
        }

        // 擷取裝置對應鎖的字元串(Key)
        String redisKey = getRegisterLockKey(deviceMacAddress);

        // 開始嘗試擷取鎖
        // 如果目前任務鎖key已存在,則表示目前時間内有其他線程正在對該裝置執行任務,目前線程可以退下了
        if (redisService.exists(redisKey)){
            return false;
        }

        // 開始嘗試加鎖,注意此處需使用SETNX指令(因為可能存在多個線程同時到達這一步開始加鎖,使用SETNX來確定有且僅有一個設定成功傳回)
        boolean setLock = redisService.setNX(redisKey, null);

        // 開始嘗試設定鎖過期時間,到了過期時間線程還沒有釋放鎖的話,由儲存鎖的Redis來確定鎖最終被釋放,以免出現死鎖
        // 鎖過期時間的設定上,可以評估線程執行任務的正常用時,在正常用時的基礎上稍微再大一點
        boolean setExpire = redisService.expire(redisKey, REDIS_REGISTER_TIMEOUT);

        // 設定鎖和設定過期時間均成功時才認為目前線程擷取鎖成功,否則認為擷取鎖失敗
        if (setLock && setExpire) {
            return true;
        }

        // 當發生設定鎖成功,但設定過期時間失敗的情況時,手動清除剛剛設定的鎖Key
        redisService.del(redisKey);
        return false;
    }

    /**
     * 删除裝置注冊時的分布式鎖
     * @param deviceMacAddress 裝置的Mac位址
     */
    public void delRegisterLock(String deviceMacAddress) {
        redisService.del(getRegisterLockKey(deviceMacAddress));
    }

    /**
     * 擷取裝置注冊時分布式鎖的key
     * @param deviceMacAddress 裝置mac位址(每個裝置的mac位址都是唯一的)
     * @return
     */
    private String getRegisterLockKey(String deviceMacAddress) {
        return REDIS_REGISTER_KEY_PREFIX + "_" + deviceMacAddress;
    }
}
           

在正常的注冊邏輯中使用鎖的示例如下:

public ReturnObj registry(@RequestBody String device){
        Devices deviceInfo = JSON.parseObject(device, Devices.class);

        // 開始注冊前加鎖
        boolean registerLock = lockUtil.getRegisterLock(deviceInfo.getMacAddress());
        if (!registerLock) {
            log.info("擷取裝置注冊鎖失敗,目前注冊請求失敗!");
            return ReturnObj.createBussinessErrorResult();
        }

        // 加鎖成功,開始注冊裝置
        ReturnObj result = registerDevice(deviceInfo);

        // 注冊裝置完成,删除鎖
        lockUtil.delRegisterLock(deviceInfo.getMacAddress());

        return result;
    }
           

解決并發更新

1. 并發更新真的會引發問題嗎?

當發生同時更新或一前一後更新的情況對業務并無影響的時候,那就無需進行任何處理,免得徒勞增加系統複雜度。

2. 樂觀鎖

通過樂觀鎖的方式可以避免重複更新,即:在資料庫表中加入一個“版本号”(version)的字段,在做更新操作前先查詢記錄,記下查詢出的版本号,之後在實際更新操作的時候判斷此前查詢出的版本号是否與目前資料庫中該條記錄的版本号一緻,如果一緻,說明在目前線程從查詢到更新這段時間裡,沒有其他線程更新這條記錄;如果不一緻,說明再此期間已經有其他線程更改了這條記錄,目前線程的更新操作已經不安全了,隻能放棄。

判斷SQL示例:

update a_table set name=test1, age=12, version=version+1 where id = 3 and version = 1
           

樂觀鎖通過版本号的方式,在最後更新的關頭才判斷自己之前從資料庫讀取的資料有沒有被别人修改,其效率高于悲觀鎖,因為在目前線程查詢和最後更新前的這段時間裡,其他線程可以照常讀取這同一條記錄,且可以搶先更新。

悲觀鎖

悲觀鎖與樂觀鎖恰好相反,在目前線程查詢這條待更新的資料時,就鎖住了這條資料,不允許在自己更新完成前有其他線程修改資料。

通過使用

select … for update

來告訴資料庫“我馬上要更新這條資料,把它給我鎖起來”。

注意:FOR UPDATE 僅适用于InnoDB,且必須在事務中才能生效,當查詢條件有明确主鍵且有此記錄時為行鎖定(row lock,隻鎖定根據查詢條件定位到的這一行資料),查詢條件無主鍵或主鍵不明确時為表鎖定(table lock,鎖定全表,會造成全表的資料在鎖定期都無法被更改),是以使用悲觀鎖時查詢條件最好能明确定位到某一行或幾行,不要引發全表鎖定

繼續閱讀