顧名思義,行鎖就是針對(duì)數(shù)據(jù)表中行記錄的鎖。這很好理解,比如事務(wù) A 更新了一行,而這時(shí)候事務(wù) B 也要更新同一行,則必須等事務(wù) A 的操作完成后才能進(jìn)行更新。
在實(shí)際情況下,SQL是千變?nèi)f化、條數(shù)不定的,數(shù)據(jù)庫(kù)很難在事務(wù)中判定什么是加鎖階段,什么是解鎖階段。于是引入了S2PL(Strict-2PL),即: 在事務(wù)中只有提交(commit)或者回滾(rollback)時(shí)才是解鎖階段,其余時(shí)間為加鎖階段。
舉個(gè)例子。在下面的操作序列中,事務(wù) B 的 update 語(yǔ)句執(zhí)行時(shí)會(huì)是什么現(xiàn)象呢?假設(shè)字段 id 是表 t 的主鍵。
實(shí)際上事務(wù) B 的 update 語(yǔ)句會(huì)被阻塞,直到事務(wù) A 執(zhí)行 commit 之后,事務(wù) B 才能繼續(xù)執(zhí)行。事務(wù) A 持有的兩個(gè)記錄的行鎖,都是在 commit 的時(shí)候才釋放的。在 InnoDB 事務(wù)中,行鎖是在需要的時(shí)候才加上的,但并不是不需要了就立刻釋放,而是要等到事務(wù)結(jié)束時(shí)才釋放。這個(gè)就是兩階段鎖協(xié)議。
兩階段加鎖對(duì)性能的影響,下面兩種不同的扣減庫(kù)存的方案:
方案1:begin;// 扣減庫(kù)存update t_inventory set count=count-5 where id= ${id} and count>=5;// 鎖住用戶賬戶表select * from t_user_account where user_id=123 for update;// 插入訂單記錄insert into t_trans; commit;
方案2:begin;// 鎖住用戶賬戶表select * from t_user_account where user_id=123 for update;// 插入訂單記錄insert into t_trans;// 扣減庫(kù)存update t_inventory set count=count-5 where id=${id} and count>=5;commit;
兩者方案的時(shí)序如下圖所示:
由于庫(kù)存往往是最重要的熱點(diǎn),是整個(gè)系統(tǒng)的瓶頸。那么如果采用第二種方案的話,tps應(yīng)該理論上能夠提升3rt/rt=3倍。這還僅僅是業(yè)務(wù)就只有三條SQL的情況下,多一條sql就多一次rt,就多一倍的時(shí)間。
根據(jù)兩階段鎖協(xié)議,不論你怎樣安排語(yǔ)句順序,所有的操作需要的行鎖都是在事務(wù)提交的時(shí)候才釋放的。所以,如果你把更新庫(kù)存 安排在最后,那么庫(kù)存這一行的鎖時(shí)間就最少。這就最大程度地減少了事務(wù)之間的鎖等待,提升了并發(fā)度。
值得注意的是:
在更新到數(shù)據(jù)庫(kù)的那個(gè)時(shí)間點(diǎn)才算鎖成功,提交到數(shù)據(jù)庫(kù)的時(shí)候才算解鎖成功,這兩個(gè)round_trip的前半段是不會(huì)計(jì)算在內(nèi)的:
從上面的例子中,可以看出,需要把最熱點(diǎn)的記錄,放到事務(wù)最后,這樣可以顯著的提高吞吐量。更進(jìn)一步:越熱點(diǎn)記錄離事務(wù)的終點(diǎn)越近(無(wú)論是commit還是rollback)
避免死鎖
這也是任何SQL加鎖不可避免的。上文提到了按照記錄Key的熱度在事務(wù)中倒序排列。 那么寫代碼的時(shí)候任何可能并發(fā)的SQL都必須按照這種順序來(lái)處理,不然會(huì)造成死鎖。如下圖所示:?
但是當(dāng)業(yè)務(wù)場(chǎng)景復(fù)雜,依然會(huì)有死鎖的可能,當(dāng)出現(xiàn)死鎖以后,有兩種策略:
一種策略是,直接進(jìn)入等待,直到超時(shí)。這個(gè)超時(shí)時(shí)間可以通過參數(shù) innodb_lock_wait_timeout 來(lái)設(shè)置。
另一種策略是,發(fā)起死鎖檢測(cè),發(fā)現(xiàn)死鎖后,主動(dòng)回滾死鎖鏈條中的某一個(gè)事務(wù),讓其他事務(wù)得以繼續(xù)執(zhí)行。將參數(shù) innodb_deadlock_detect 設(shè)置為 on,表示開啟這個(gè)邏輯。
在 InnoDB 中,innodb_lock_wait_timeout 的默認(rèn)值是 50s,意味著如果采用第一個(gè)策略,當(dāng)出現(xiàn)死鎖以后,第一個(gè)被鎖住的線程要過 50s 才會(huì)超時(shí)退出,然后其他線程才有可能繼續(xù)執(zhí)行。對(duì)于在線服務(wù)來(lái)說,這個(gè)等待時(shí)間往往是無(wú)法接受的。但是,我們又不可能直接把這個(gè)時(shí)間設(shè)置成一個(gè)很小的值,比如 1s。這樣當(dāng)出現(xiàn)死鎖的時(shí)候,確實(shí)很快就可以解開,但如果不是死鎖,而是簡(jiǎn)單的鎖等待呢?所以,超時(shí)時(shí)間設(shè)置太短的話,會(huì)出現(xiàn)很多誤傷。
所以,正常情況下我們還是要采用第二種策略,即:主動(dòng)死鎖檢測(cè),而且 innodb_deadlock_detect 的默認(rèn)值本身就是 on。主動(dòng)死鎖檢測(cè)在發(fā)生死鎖的時(shí)候,是能夠快速發(fā)現(xiàn)并進(jìn)行處理的,但是它也是有額外負(fù)擔(dān)的。
你可以想象一下這個(gè)過程:每當(dāng)一個(gè)事務(wù)被鎖的時(shí)候,就要看看它所依賴的線程有沒有被別人鎖住,如此循環(huán),最后判斷是否出現(xiàn)了循環(huán)等待,也就是死鎖。
當(dāng)有熱點(diǎn)數(shù)據(jù)被并發(fā)更新的話,每個(gè)新來(lái)的被堵住的線程,都要判斷會(huì)不會(huì)由于自己的加入導(dǎo)致了死鎖,雖然最終檢測(cè)的結(jié)果是沒有死鎖,但是這期間要消耗大量的 CPU 資源。因此,你就會(huì)看到 CPU 利用率很高,但是每秒?yún)s執(zhí)行不了幾個(gè)事務(wù)。
熱點(diǎn)行更新的解決策略: 降低并發(fā)度 :
1. 拆行,一行拆多行,減少鎖的沖突,以賬戶為例,可以考慮放在多條記錄上,比如 10 個(gè)記錄,賬戶總額等于這 10 個(gè)記錄的值的總和。這樣每次要給賬戶加金額的時(shí)候,隨機(jī)選其中一條記錄來(lái)加。這樣每次沖突概率變成原來(lái)的 1/10,可以減少鎖等待個(gè)數(shù),也就減少了死鎖檢測(cè)的 CPU 消耗。但其實(shí)這類方案需要根據(jù)業(yè)務(wù)邏輯做詳細(xì)設(shè)計(jì)。如果賬戶余額可能會(huì)減少,比如退款邏輯,那么這時(shí)候就需要考慮當(dāng)一部分行記錄變成 0 的時(shí)候,代碼要有特殊處理。
?2. Server 層限流,限制同一時(shí)間進(jìn)入更新的線程數(shù) ,比如在應(yīng)用網(wǎng)關(guān)層限流,或是使用sentinel之類的組件
3. 關(guān)閉死鎖監(jiān)測(cè)(關(guān)閉的弊端是可能超時(shí)較多)