寫(xiě)在前面:本文討論的冪等問(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)冪等方法展開(kāi)了詳細(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é)概念旦万,常見(jiàn)于抽象代數(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ù)寫(xiě)數(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)霜运,包括公平鎖/非公平鎖脾歇,阻塞鎖/非阻塞鎖,讀寫(xiě)鎖以及可重入鎖等淘捡。
我們要如何實(shí)現(xiàn)一個(gè)分布式鎖呢介劫?
方案一
分布式系統(tǒng)中常見(jiàn)有兩個(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)可見(jià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() 方法開(kāi)啟一個(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<Boolean> 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<Boolean> 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?:
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<DataEntry> 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);
}
}