天天看點

Java微服務與Mysql鎖相關知識總結

作者:架構淺水灣

Java微服務與Mysql鎖相關知識總結

Java微服務與Mysql鎖相關知識總結

鎖的定義

在計算機程式中鎖用于獨占資源,擷取到鎖才可以操作對應的資源。

鎖的實作

鎖在計算機底層的實作,依賴于CPU提供的CAS指令(compare and swsp),對于一個記憶體位址,會比較原值以及嘗試去修改的值,通過值是否修改成功,來表示是否強占到了這個鎖。

JVM中的鎖

jvm中,有2個常用的鎖

synchronized

synchronized是java提供的關鍵字鎖,可以鎖對象,類,方法。

在JDK1.6以後,對synchronized進行了優化,增加了偏向鎖和輕量鎖模式,現在synchronized鎖的運作邏輯如下:

  1. 在初始加鎖時,會增加偏向鎖,即“偏向上一次擷取該鎖的線程”,在偏向鎖下,會直接CAS擷取該鎖。該模式大大提高了單線程反複擷取同一個鎖的吞吐情況,在Java官方看來,大部分鎖的争搶都發生在同個線程上。
  2. 如果偏向鎖CAS擷取失敗,說明目前線程與偏向鎖偏向的線程不同,偏向鎖就會更新成輕量鎖,輕量鎖的特點就是通過自旋CAS去擷取鎖。
  3. 如果自旋擷取失敗,那麼鎖就會更新成重量鎖,所有等待鎖的線程将被JVM挂起,在鎖釋放後,再由JVM統一通知喚醒,再去嘗試CAS鎖,如果失敗,繼續挂起。

很顯然,偏向鎖設計的目的是“在Java官方看來,對同一個鎖的争搶大部分都發生在同個線程上”。

輕量鎖設計的目的是“在短期内,鎖的争搶通過自旋CAS就可以擷取到,短時間内的CPU自旋消耗小于線程挂起再喚醒的消耗”。

重量鎖就是最初優化前的synchronized的邏輯了。

ReentrantLock

說到ReentrantLock,就不得不說到JUC裡的AQS了。

AQS全稱AbstractQueueSynchronizer,幾乎JUC裡所有的工具類,都依賴AQS實作。

AQS在java裡,是一個抽象類,但是本質上是一種思路在java中的實作而已。

AQS的實作邏輯如下:

  1. 構造一個隊列
  2. 隊列中維護需要等待鎖的線程
  3. 頭結點永遠是持有鎖(或持有資源)的節點,等待的節點在頭結點之後依次連接配接。
  4. 頭結點釋放鎖後,會按照順序去喚醒那些等待的節點,然後那些節點會再次去嘗試擷取鎖。

在synchronized鎖優化以後,AQS的本質與synchronized并沒有太大不同,兩者的性能也并沒有太大差距了,是以AQS現在的特點是:

  1. 是在java api層面實作的鎖,是以可以實作各種并發工具類,操作也更加靈活
  2. 因為提供了逾時時間等機制,操作靈活,是以不易死鎖。(相同的,如果發生死鎖,将更難排查,因為jstack裡将不會有deadlock辨別)。
  3. 可以實作公平鎖,而synchronized必定是非公平鎖。
  4. 因為是JavaApi層實作的鎖,是以可以響應中斷。

到這裡你會發現,其實ReentrantLock可以說是synchronized在JavaApi層的實作。

Mysql 鎖

共享鎖(S) 與排它鎖(X)

作用範圍

這兩種鎖都包括行級鎖和表級鎖。

擷取共享鎖時,如果該資料被其他事務的排它鎖鎖住,則無法擷取,需要等待排它鎖釋放。

意向鎖

作用範圍

意向鎖為表鎖,在擷取表鎖之前,一定會檢查意向鎖。

意圖鎖定協定如下:

在事務獲得表中某行的共享鎖之前,它必須首先獲得表上的 IS 鎖或更強的鎖。

在事務獲得表中行的排他鎖之前,它必須首先獲得表的 IX 鎖。

在擷取任意表鎖的共享鎖或排它鎖之前,一定會檢查該表上的共享鎖。

表鎖以及意向鎖的互斥規則如下:

X IX S IS

X Conflict Conflict Conflict Conflict

IX Conflict Compatible Conflict Compatible

S Conflict Conflict Compatible Compatible

IS Conflict Compatible Compatible Compatible

意向鎖的作用在于:在擷取表鎖時,可以通過意向鎖來快速判斷能否擷取。

因為擷取行級鎖時,會先擷取對應的意向鎖,這樣另外的事務在擷取表鎖時就可以通過意向鎖快速的判斷,而不需要每行去掃描。

特别注意的是,意向鎖是可以疊加的,即會存在多個,如T1事務擷取了意向鎖IX1和行級鎖X1,T2事務依舊可以擷取意向鎖IX2和行級鎖X2,是以僅在擷取表級鎖之前,才會檢查意向鎖。

記錄鎖

記錄鎖生效在索引上,用以在SELECT c1 FROM t WHERE c1 = 10 FOR UPDATE時保護該行資料不被其他事務更改。

記錄鎖在沒有索引時依舊會生效,因為innodb會為每張表建立一個隐藏的索引。

記錄鎖是最基本的行鎖。

間隙鎖

間隙鎖生效在索引上,用于鎖定索引值後的行,防止插入,在select from table where index=? for update時會生效,例如index=1,則會鎖住index=1索引節點相關的行,防止其他事務插入資料。

但是并不會防止update語句,哪怕update的資料不存在。

Next-Key Locks

這個鎖是記錄鎖和間隙鎖的組合,簡而言之在select from table where index=? for update時,既會有間隙鎖防止insert,也會有記錄鎖在index上防止這一條資料的update和delete。這個Next-key隻是對這兩種鎖的一種概括,因為這兩種鎖在select for update時通常會一起出現。

Insert Intention Locks

插入意向鎖,和意向鎖類似。不過是特殊的間隙鎖,并不發生在select for update,而是在同時發生insert時産生,例如在兩個事務同時insert索引區間為[4,7]時,同時獲得該區間的意向鎖,此時事務不會阻塞,例如A:insert-5,B:insert-7,此時不會阻塞兩個事務。

插入意向鎖是一個特殊的間隙鎖,是為了防止正常間隙鎖鎖區間的情況下,insert頻繁阻塞而設計的,例如A:insert-5,B:insert-7,如果沒有插入意向鎖,那麼5和7都要去嘗試擷取間隙鎖,此時第二個事務就會被阻塞,但是通過插入意向鎖,第二個事務就不會被阻塞,隻有到插入的行确實沖突,才會被阻塞。

AUTO-INC Locks

自增鎖,這個鎖很明顯是表級insert鎖,為了保證自增主鍵的表的主鍵保持原子自增。

對于鎖這個東西,大家應該多去了解各種鎖設計運作的原理和模型,這樣在加深了解後,在使用起來才會更加深入和透徹。

常見鎖使用的場景和用法

double check

衆所周知,mysql的事務對防止重複插入并沒有什麼卵用,唯一索引又存在很多缺點,業務上最好不要使用,是以一般來說防止重複插入的通用做法就是使用分布式鎖,這就有一種比較常用的寫法。

final WeekendNoticeReadCountDO weekendNoticeReadCountDO = weekendNoticeReadRepositoryService.selectByNoticeId(noticeRequestDTO.getNoticeId());
if (weekendNoticeReadCountDO == null) {
    final String lockKey = RedisConstant.LOCK_WEEKEND_READ_COUNT_INSERT + ":" + noticeRequestDTO.getNoticeId();
    ClusterLock lock = clusterLockFactory.getClusterLockRedis(
        RedisConstant.REDIS_KEY_PREFIX,
        lockKey
    );
    if (lock.acquire(RedisConstant.REDIS_LOCK_DEFAULT_TIMEOUT)) {
        //double check
        final WeekendNoticeReadCountDO weekendNoticeReadCountDO = weekendNoticeReadRepositoryService.selectByNoticeId(noticeRequestDTO.getNoticeId());
        if (weekendNoticeReadCountDO == null) {
            try {
                lock.execute(() -> {
                    WeekendNoticeReadCountDO readCountDO = new WeekendNoticeReadCountDO();
                    readCountDO.setNoticeId(noticeRequestDTO.getNoticeId());
                    readCountDO.setReadCount(1L);
                    readCountDO.setCreateTime(new Date());
                    readCountDO.setUpdateTime(new Date());
                    weekendNoticeReadRepositoryService.insert(readCountDO);
                    return true;
                });
            } catch (ApiException err) {
                throw err;
            } catch (Exception e) {
                log.error("插入", e);
                throw new ApiException(ErrorEnum.SERVER_ERROR.getCode(), "服務端出錯");
            }
        } else {
            weekendNoticeReadRepositoryService.noticeCountAdd(weekendNoticeReadCountDO);
        }
    } else {
        log.warn("redis鎖擷取逾時,key:{}", lockKey);
        throw new ApiException(ErrorEnum.SERVER_ERROR.getCode(), "伺服器繁忙,請稍後重試");
    }
} 
           

在擷取到鎖之後,可能是經過等待才擷取到的鎖,此時上一個釋放鎖的線程可能已經插入了資料了,是以在鎖内部,依舊要再次校驗一下資料是否存在。

這種寫法适合大多數需要唯一性的寫場景。

避免死鎖

如何避免死鎖?最簡單有效的方法就是:**不要在鎖裡再去擷取鎖,簡而言之就是鎖最好單獨使用,不要套娃。

也要注意一些隐性鎖,比如資料庫。

事務A:

  1. 插入[5,7],插入意向鎖。
  2. select for update更新[100,150],間隙鎖。

    事務B:

  3. select for update更新[90,120],間隙鎖。
  4. 插入[4,6],插入意向鎖。

此時在并發場景下,就可能會出現A持有了[5,7]的間隙鎖,在等待事務B[90,120]的間隙鎖,事務B也一樣,就死鎖了。

**

順帶談談并發場景下常見的問題

讀寫混亂

在寫業務代碼,定義一些工具類或者緩存類的時候,很容易疏忽而發生類似的問題。

比如建構一個static緩存,沒有使用ConcurrentHashMap中的putIfAbsent等方法,也沒有加鎖去建構,導緻上面的線程剛put了,下面的線程就删掉了,或者重複建構2次緩存。

Redis或者一些并發操作釋放鎖或者資源,沒有檢查是否是目前線程持有

這點在Redis鎖的示例代碼也講到了。

線程A擷取到鎖,此時B,C在等待,然後A執行時間過長,導緻鎖逾時被自動釋放了,此時B擷取到了鎖,在快樂的執行,然後A執行完了之後,釋放鎖時沒有判斷是否還是自己持有,導緻B持有的鎖被删除了,此時C又擷取到了鎖,BC同時在執行

繼續閱讀