新聞中心
并發(fā)場(chǎng)景下的冪等問(wèn)題-分布式鎖詳解
作者:阿里技術(shù) 2021-12-01 10:13:48
開發(fā)
開發(fā)工具
分布式 本文從釘釘實(shí)人認(rèn)證場(chǎng)景的一例數(shù)據(jù)重復(fù)問(wèn)題出發(fā),分析了其原因是因?yàn)椴l(fā)導(dǎo)致冪等失效,引出冪等的概念。

創(chuàng)新互聯(lián)制作網(wǎng)站網(wǎng)頁(yè)找三站合一網(wǎng)站制作公司,專注于網(wǎng)頁(yè)設(shè)計(jì),網(wǎng)站制作、做網(wǎng)站,網(wǎng)站設(shè)計(jì),企業(yè)網(wǎng)站搭建,網(wǎng)站開發(fā),建網(wǎng)站業(yè)務(wù),680元做網(wǎng)站,已為成百上千服務(wù),創(chuàng)新互聯(lián)網(wǎng)站建設(shè)將一如既往的為我們的客戶提供最優(yōu)質(zhì)的網(wǎng)站建設(shè)、網(wǎng)絡(luò)營(yíng)銷推廣服務(wù)!
寫在前面:本文討論的冪等問(wèn)題,均為并發(fā)場(chǎng)景下的冪等問(wèn)題。即系統(tǒng)本存在冪等設(shè)計(jì),但是在并發(fā)場(chǎng)景下失效了。
一 摘要
本文從釘釘實(shí)人認(rèn)證場(chǎng)景的一例數(shù)據(jù)重復(fù)問(wèn)題出發(fā),分析了其原因是因?yàn)椴l(fā)導(dǎo)致冪等失效,引出冪等的概念。
針對(duì)并發(fā)場(chǎng)景下的冪等問(wèn)題,提出了一種實(shí)現(xiàn)冪等可行的方法論,結(jié)合通訊錄加人業(yè)務(wù)場(chǎng)景對(duì)數(shù)據(jù)庫(kù)冪等問(wèn)題進(jìn)行了簡(jiǎn)單分析,就分布式鎖實(shí)現(xiàn)冪等方法展開了詳細(xì)討論。
分析了鎖在分布式場(chǎng)景下存在的問(wèn)題,包括單點(diǎn)故障、網(wǎng)絡(luò)超時(shí)、錯(cuò)誤釋放他人鎖、提前釋放鎖以及分布式鎖單點(diǎn)故障等,提出了對(duì)應(yīng)的解決方案,介紹了對(duì)應(yīng)方案的具體實(shí)現(xiàn)。
二 問(wèn)題
釘釘實(shí)人認(rèn)證業(yè)務(wù)存在數(shù)據(jù)重復(fù)的問(wèn)題。
1 問(wèn)題現(xiàn)象
正常情況下,數(shù)據(jù)庫(kù)中應(yīng)該只有一條實(shí)人認(rèn)證成功記錄,但是實(shí)際上某用戶有多條。
2 問(wèn)題原因
并發(fā)導(dǎo)致了不冪等。
我們先來(lái)回顧一下冪等的概念:
冪等(idempotent、idempotence)是一個(gè)數(shù)學(xué)與計(jì)算機(jī)學(xué)概念,常見于抽象代數(shù)中。
在編程中一個(gè)冪等操作的特點(diǎn)是其任意多次執(zhí)行所產(chǎn)生的影響均與一次執(zhí)行的影響相同。
--來(lái)自百度百科
實(shí)人認(rèn)證在業(yè)務(wù)上有冪等設(shè)計(jì),其一般流程為:
1)用戶選擇實(shí)人認(rèn)證后會(huì)在服務(wù)端初始化一條記錄;
2)用戶在釘釘移動(dòng)端按照指示完成人臉比對(duì);
3)比對(duì)完成后訪問(wèn)服務(wù)端修改數(shù)據(jù)庫(kù)狀態(tài)。
在第3步中,在修改數(shù)據(jù)庫(kù)狀態(tài)之前,會(huì)判斷「是否已經(jīng)初始化」、「是否已經(jīng)實(shí)人認(rèn)證」以及「智科是否返回認(rèn)證成功」以保證冪等。僅當(dāng)請(qǐng)求首次訪問(wèn)服務(wù)端嘗試修改數(shù)據(jù)庫(kù)狀態(tài)時(shí),才能滿足冪等的判斷條件并修改數(shù)據(jù)庫(kù)狀態(tài)。其余任意次請(qǐng)求將直接返回,對(duì)數(shù)據(jù)庫(kù)狀態(tài)無(wú)影響。請(qǐng)求多次訪問(wèn)服務(wù)端所產(chǎn)生的結(jié)果,和請(qǐng)求首次訪問(wèn)服務(wù)端一致。因此,在實(shí)人認(rèn)證成功的前提下,數(shù)據(jù)庫(kù)應(yīng)當(dāng)有且僅有一條認(rèn)證成功的記錄。
但是在實(shí)際過(guò)程中我們發(fā)現(xiàn),同一個(gè)請(qǐng)求會(huì)多次修改數(shù)據(jù)庫(kù)狀態(tài),系統(tǒng)并未按照我們預(yù)期的那樣實(shí)現(xiàn)冪等。究其原因,是因?yàn)檎?qǐng)求并發(fā)訪問(wèn),在首次請(qǐng)求完成修改服務(wù)端狀態(tài)前,并發(fā)的其他請(qǐng)求和首次請(qǐng)求都通過(guò)了冪等判斷,對(duì)數(shù)據(jù)庫(kù)狀態(tài)進(jìn)行了多次修改。
并發(fā)導(dǎo)致了原冪等設(shè)計(jì)失效。
并發(fā)導(dǎo)致了不冪等。
三 解決方案
解決并發(fā)場(chǎng)景下冪等問(wèn)題的關(guān)鍵,是找到唯一性約束,執(zhí)行唯一性檢查,相同的數(shù)據(jù)保存一次,相同的請(qǐng)求操作一次。
一次訪問(wèn)服務(wù)端的請(qǐng)求,可能產(chǎn)生以下幾種交互:
- 與數(shù)據(jù)源交互,例如數(shù)據(jù)庫(kù)狀態(tài)變更等;
- 與其他業(yè)務(wù)系統(tǒng)交互,例如調(diào)用下游服務(wù)或發(fā)送消息等;
一次請(qǐng)求可以只包含一次交互,也可以包含多次交互。例如一次請(qǐng)求可以僅僅修改一次數(shù)據(jù)庫(kù)狀態(tài),也可以在修改數(shù)據(jù)庫(kù)狀態(tài)后再發(fā)送一條數(shù)據(jù)庫(kù)狀態(tài)修改成功的消息。
于是我們可以得出一個(gè)結(jié)論:并發(fā)場(chǎng)景下,如果一個(gè)系統(tǒng)依賴的組件冪等,那么該系統(tǒng)在天然冪等。
以數(shù)據(jù)庫(kù)為例,如果一個(gè)請(qǐng)求對(duì)數(shù)據(jù)造成的影響是新增一條數(shù)據(jù),那么唯一索引可以是冪等問(wèn)題的解法。數(shù)據(jù)庫(kù)會(huì)幫助我們執(zhí)行唯一性檢查,相同數(shù)據(jù)不會(huì)重復(fù)落庫(kù)。
釘釘通訊錄加人就是通過(guò)數(shù)據(jù)庫(kù)的唯一索引解決了冪等問(wèn)題。以釘釘通訊錄加人為例,在向數(shù)據(jù)庫(kù)寫數(shù)據(jù)之前,會(huì)先判斷數(shù)據(jù)是否已經(jīng)存在于數(shù)據(jù)庫(kù)之中,如果不存在,加人請(qǐng)求最終會(huì)向數(shù)據(jù)庫(kù)的員工表插入一條數(shù)據(jù)。大量相同的并發(fā)的通訊錄加人請(qǐng)求讓系統(tǒng)的冪等設(shè)計(jì)失效成為可能。在一次加人請(qǐng)求中,(組織ID,工號(hào))可以唯一標(biāo)記一個(gè)請(qǐng)求,在數(shù)據(jù)庫(kù)中,也存在(組織ID,工號(hào))的唯一索引。因此我們可以保證,多次相同的加人請(qǐng)求,只會(huì)修改一次數(shù)據(jù)庫(kù)狀態(tài),即添加一條記錄。
如果所依賴的組件天然冪等,那么問(wèn)題就簡(jiǎn)單了,但是實(shí)際情況往往更加復(fù)雜。并發(fā)場(chǎng)景下,如果系統(tǒng)依賴的組件無(wú)法冪等,我們就需要使用額外的手段實(shí)現(xiàn)冪等。
一個(gè)常用的手段就是使用分布式鎖。分布式鎖的實(shí)現(xiàn)方式有很多,比較常用的是緩存式分布式鎖。
四 分布式鎖
在What is a Java distributed lock?中有這樣幾段話:
In computer science, locks are mechanisms in a multithreaded environment to prevent different threads from operating on the same resource. When using locking, a resource is "locked" for access by a specific thread, and can only be accessed by a different thread once the resource has been released. Locks have several benefits: they stop two threads from doing the same work, and they prevent errors and data corruption when two threads try to use the same resource simultaneously.
Distributed locks in Java are locks that can work with not only multiple threads running on the same machine, but also threads running on clients on different machines in a distributed system. The threads on these separate machines must communicate and coordinate to make sure that none of them try to access a resource that has been locked up by another.
這幾段話告訴我們,鎖的本質(zhì)是共享資源的互斥訪問(wèn),分布式鎖解決了分布式系統(tǒng)中共享資源的互斥訪問(wèn)的問(wèn)題。
java.util.concurrent.locks包提供了豐富的鎖實(shí)現(xiàn),包括公平鎖/非公平鎖,阻塞鎖/非阻塞鎖,讀寫鎖以及可重入鎖等。
我們要如何實(shí)現(xiàn)一個(gè)分布式鎖呢?
方案一
分布式系統(tǒng)中常見有兩個(gè)問(wèn)題:
1)單點(diǎn)故障問(wèn)題,即當(dāng)持有鎖的應(yīng)用發(fā)生單點(diǎn)故障時(shí),鎖將被長(zhǎng)期無(wú)效占有;
2)網(wǎng)絡(luò)超時(shí)問(wèn)題,即當(dāng)客戶端發(fā)生網(wǎng)絡(luò)超時(shí)但實(shí)際上鎖成功時(shí),我們無(wú)法再次正確的
獲取鎖。
要解決問(wèn)題1,一個(gè)簡(jiǎn)單的方案是引入過(guò)期時(shí)間(lease time),對(duì)鎖的持有將是有時(shí)效的,當(dāng)應(yīng)用發(fā)生單點(diǎn)故障時(shí),被其持有的鎖可以自動(dòng)釋放。
要解決問(wèn)題2,一個(gè)簡(jiǎn)單的方案是支持可重入,我們?yōu)槊總€(gè)獲取鎖的客戶端都配置一個(gè)不會(huì)重復(fù)的身份標(biāo)識(shí)(通常是UUID),上鎖成功后鎖將帶有該客戶端的身份標(biāo)識(shí)。當(dāng)實(shí)際上鎖成功而客戶端超時(shí)重試時(shí),我們可以判斷鎖已被該客戶端持有而返回成功。
綜上我們給出了一個(gè)lease-based distribute lock方案。出于性能考量,使用緩存作為鎖的存儲(chǔ)介質(zhì),利用MVCC(Multiversion concurrency control)機(jī)制解決共享資源互斥訪問(wèn)問(wèn)題,具體實(shí)現(xiàn)可見附錄代碼。
分布式鎖的一般使用方式如下
- 初始化分布式鎖的工廠
- 利用工廠生成一個(gè)分布式鎖實(shí)例
- 使用該分布式實(shí)例上鎖和解鎖操作
- @Test
- public void testTryLock() {
- //初始化工廠
- MdbDistributeLockFactory mdbDistributeLockFactory = new MdbDistributeLockFactory();
- mdbDistributeLockFactory.setNamespace(603);
- mdbDistributeLockFactory.setMtairManager(new MultiClusterTairManager());
- //獲得鎖
- DistributeLock lock = mdbDistributeLockFactory.getLock("TestLock");
- //上鎖解鎖操作
- boolean locked = lock.tryLock();
- if (!locked) {
- return;
- }
- try {
- //do something
- } finally {
- lock.unlock();
- }
- }
該方案簡(jiǎn)單易用,但是問(wèn)題也很明顯。例如,釋放鎖的時(shí)候只是簡(jiǎn)單的將緩存中的key失效,所以存在錯(cuò)誤釋放他人已持有鎖問(wèn)題。所幸只要鎖的租期設(shè)置的足夠長(zhǎng),該問(wèn)題出現(xiàn)幾率就足夠小。
我們借用Martin Kleppmann在文章How to do distributed locking中的一張圖說(shuō)明該問(wèn)題。
設(shè)想一種情況,當(dāng)占有鎖的Client 1在釋放鎖之前,鎖就已經(jīng)到期了,Client 2將獲取鎖,此時(shí)鎖被Client 2持有,但是Client 1可能會(huì)錯(cuò)誤的將其釋放。一個(gè)更優(yōu)秀的方案,我們給每個(gè)鎖都設(shè)置一個(gè)身份標(biāo)識(shí),在釋放鎖的時(shí)候,1)首先查詢鎖是否是自己的,2)如果是自己的則釋放鎖。受限于實(shí)現(xiàn)方式,步驟1和步驟2不是原子操作,在步驟1和步驟2之間,如果鎖到期被其他客戶端獲取,此時(shí)也會(huì)錯(cuò)誤的釋放他人的鎖。
方案二
借助Redis的Lua腳本,可以完美的解決存在錯(cuò)誤釋放他人已持有鎖問(wèn)題的。在Distributed locks with Redis這篇文章的 Correct implementation with a single instance 這一節(jié)中,我們可以得到我們想要的答案——如何實(shí)現(xiàn)一個(gè)分布式鎖。
當(dāng)我們想要獲取鎖時(shí),我們可以執(zhí)行如下方法
- SET resource_name my_random_value NX PX 30000
當(dāng)我們想要釋放鎖時(shí),我們可以執(zhí)行如下的Lua腳本
- if redis.call("get",KEYS[1]) == ARGV[1] then
- return redis.call("del",KEYS[1])
- else
- return 0
- end
方案三
在方案一和方案二的討論過(guò)程中,有一個(gè)問(wèn)題被我們反復(fù)提及:鎖的自動(dòng)釋放。
這是一把雙刃劍:
1)一方面它很好的解決了持有鎖的客戶端單點(diǎn)故障的問(wèn)題
2)另一方面,如果鎖提前釋放,就會(huì)出現(xiàn)鎖的錯(cuò)誤持有狀態(tài)
這個(gè)時(shí)候,我們可以引入Watch Dog自動(dòng)續(xù)租機(jī)制,我們可以參考以下Redisson是如何實(shí)現(xiàn)的。
在上鎖成功后,Redisson會(huì)調(diào)用renewExpiration()方法開啟一個(gè)Watch Dog線程,為鎖自動(dòng)續(xù)期。每過(guò)1/3時(shí)間續(xù)一次,成功則繼續(xù)下一次續(xù)期,失敗取消續(xù)期操作。
我們可以再看看Redisson是如何續(xù)期的。renewExpiration()方法的第17行renewExpirationAsync()方法是執(zhí)行鎖續(xù)期的關(guān)鍵操作,我們進(jìn)入到方法內(nèi)部,可以看到Redisson也是使用Lua腳本進(jìn)行鎖續(xù)租的:1)判斷鎖是否存在;2)如果存在則重置過(guò)期時(shí)間。
- private void renewExpiration() {
- ExpirationEntry ee = EXPIRATION_RENEWAL_MAP.get(getEntryName());
- if (ee == null) {
- return;
- }
- Timeout task = commandExecutor.getConnectionManager().newTimeout(timeout -> {
- ExpirationEntry ent = EXPIRATION_RENEWAL_MAP.get(getEntryName());
- if (ent == null) {
- return;
- }
- Long threadId = ent.getFirstThreadId();
- if (threadId == null) {
- return;
- }
- RFuture
future = renewExpirationAsync(threadId); - future.onComplete((res, e) -> {
- if (e != null) {
- log.error("Can't update lock " + getRawName() + " expiration", e);
- EXPIRATION_RENEWAL_MAP.remove(getEntryName());
- return;
- }
- if (res) {
- // reschedule itself
- renewExpiration();
- } else {
- cancelExpirationRenewal(null);
- }
- });
- }, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS);
- ee.setTimeout(task);
- }
- protected RFuture
renewExpirationAsync(long threadId) { - return evalWriteAsync(getRawName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
- "if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
- "redis.call('pexpire', KEYS[1], ARGV[1]); " +
- "return 1; " +
- "end; " +
- "return 0;",
- Collections.singletonList(getRawName()),
- internalLockLeaseTime, getLockName(threadId));
- }
方案四
借助Redisson的自動(dòng)續(xù)期機(jī)制,我們無(wú)需再擔(dān)心鎖的自動(dòng)釋放。但是討論到這里,我還是不得不面對(duì)一個(gè)問(wèn)題:分布式鎖本身不是一個(gè)分布式應(yīng)用。當(dāng)Redis服務(wù)器故障無(wú)法正常工作時(shí),整個(gè)分布式鎖也就無(wú)法提供服務(wù)。
更進(jìn)一步,我們可以看看Distributed locks with Redis這篇文章中提到的Redlock算法及其實(shí)現(xiàn)。
Redlock算法不是銀彈,關(guān)于它的好與壞,也有很多爭(zhēng)論:
How to do distributed locking:
https://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html
Is Redlock safe?:
http://antirez.com/news/101
Martin Kleppmann和Antirez關(guān)于Redlock的爭(zhēng)辯:
https://news.ycombinator.com/item
參考資料
What is a Java distributed lock?
https://redisson.org/glossary/java-distributed-lock.html
Distributed locks and synchronizers:
https://github.com/redisson/redisson/wiki/8.-distributed-locks-and-synchronizers
Distributed locks with Redis:
https://redis.io/topics/distlock?spm=ata.21736010.0.0.31f77e3aFs96rz
附錄
分布式鎖
- public class MdbDistributeLock implements DistributeLock {
- /**
- * 鎖的命名空間
- */
- private final int namespace;
- /**
- * 鎖對(duì)應(yīng)的緩存key
- */
- private final String lockName;
- /**
- * 鎖的唯一標(biāo)識(shí),保證可重入,以應(yīng)對(duì)put成功,但是返回超時(shí)的情況
- */
- private final String lockId;
- /**
- * 是否持有鎖。true:是
- */
- private boolean locked;
- /**
- * 緩存實(shí)例
- */
- private final TairManager tairManager;
- public MdbDistributeLock(TairManager tairManager, int namespace, String lockCacheKey) {
- this.tairManager = tairManager;
- this.namespace = namespace;
- this.lockName = lockCacheKey;
- this.lockId = UUID.randomUUID().toString();
- }
- @Override
- public boolean tryLock() {
- try {
- //獲取鎖狀態(tài)
- Result
getResult = null; - ResultCode getResultCode = null;
- for (int cnt = 0; cnt < DEFAULT_RETRY_TIMES; cnt++) {
- getResult = tairManager.get(namespace, lockName);
- getResultCode = getResult == null ? null : getResult.getRc();
- if (noNeedRetry(getResultCode)) {
- break;
- }
- }
- //重入,已持有鎖,返回成功
- if (ResultCode.SUCCESS.equals(getResultCode)
- && getResult.getValue() != null && lockId.equals(getResult.getValue().getValue())) {
- locked = true;
- return true;
- }
- //不可獲取鎖,返回失敗
- if (!ResultCode.DATANOTEXSITS.equals(getResultCode)) {
- log.error("tryLock fail code={} lock={} traceId={}", getResultCode, this, EagleEye.getTraceId());
- return false;
- }
- //嘗試獲取鎖
- ResultCode putResultCode = null;
- for (int cnt = 0; cnt < DEFAULT_RETRY_TIMES; cnt++) {
- putResultCode = tairManager.put(namespace, lockName, lockId, MDB_CACHE_VERSION,
- DEFAULT_EXPIRE_TIME_SEC);
- if (noNeedRetry(putResultCode)) {
- break;
- }
- }
- if (!ResultCode.SUCCESS.equals(putResultCode)) {
- log.error("tryLock fail code={} lock={} traceId={}", getResultCode, this, EagleEye.getTraceId());
- return false;
- }
- locked = true;
- return true;
- } catch (Exception e) {
- log.error("DistributedLock.tryLock fail lock={}", this, e);
- }
- return false;
- }
- @Override
- public void unlock() {
- if (!locked) {
- return;
- }
- ResultCode resultCode = tairManager.invalid(namespace, lockName);
- if (!resultCode.isSuccess()) {
- log.error("DistributedLock.unlock fail lock={} resultCode={} traceId={}", this, resultCode,
- EagleEye.getTraceId());
- }
- locked = false;
- }
- /**
- * 判斷是否需要重試
- *
- * @param resultCode 緩存的返回碼
- * @return true:不用重試
- */
- private boolean noNeedRetry(ResultCode resultCode) {
- return resultCode != null && !ResultCode.CONNERROR.equals(resultCode) && !ResultCode.TIMEOUT.equals(
- resultCode) && !ResultCode.UNKNOW.equals(resultCode);
- }
- }
分布式鎖工廠
- public class MdbDistributeLockFactory implements DistributeLockFactory {
- /**
- * 緩存的命名空間
- */
- @Setter
- private int namespace;
- @Setter
- private MultiClusterTairManager mtairManager;
- @Override
- public DistributeLock getLock(String lockName) {
- return new MdbDistributeLock(mtairManager, namespace, lockName);
- }
- }
文章名稱:并發(fā)場(chǎng)景下的冪等問(wèn)題-分布式鎖詳解
當(dāng)前地址:http://www.5511xx.com/article/cdsioeg.html


咨詢
建站咨詢
