1. 什麼是冪等性

冪等性就是指:一個冪等操作任其執行多次所產生的影響均與一次執行的影響相同。用數學的概念表達是這樣的: f(f(x)) = f(x).就像 nx1 = n 一樣, x1 就是一個冪等操作。無論是乘以多少次結果都一樣。

2. 常見的冪等性問題

冪等性問題經常會是由網路問題引起的,還有重複操作引起的。

場景一:比如點贊功能,一個用戶只能對同一片文章點贊一次,重複點贊提示已經點過贊了。

示例代碼:

public void like(Article article,User user) {
//檢查是否點過贊
if (checkIsLike(article,user)) {
//點過贊了
throw new ApiException(CodeEnums.SYSTEM_ERR);
} else {
//保存點贊
saveLike(article,user);
}
}
</pre>

看上去好像沒有什麼問題,保存點贊之前已經檢查過是否點贊了,理論上同一個人不會對同一篇文章重複點贊。但實際不是這樣的。因為網路請求不是排隊進來的,而是一窩蜂湧進來的。

某些時候,用戶網路不好,可能很短的時間內點擊了多次,由於網路傳輸問題,這些請求可能會同時來到我們的伺服器。

  • 第一個請求 checkIsLike() 返回 false , 正在執行 saveLike() 操作,還沒來的及提交事務
  • 第二個請求過來了 ,checkIsLike() 返回 也是 false , 並去 執行了 saveLike() 操作

這樣子,就造成了一個用戶同時對一篇文章進行了多次點贊操作。

這就是典型的冪等性問題, 操作了一次和操作了兩次結果不一樣,因為你多點了一次贊,按照冪等性原則 不管你點擊了多少次結果都一樣,只點了一次贊。

很多場景都是這樣造成的,比如用戶重複下單,重複評論,重複提交表單等。

那怎麼解決呢?假設網路的請求是排隊進來的就不會出現這個問題了。

於是我們可以改成這樣:

public synchronized void like(Article article,User user) {
//檢查是否點過贊
if (checkIsLike(article,user)) {
//點過贊了
throw new ApiException(CodeEnums.SYSTEM_ERR);
} else {
//保存點贊
saveLike(article,user);
}
}
</pre>

synchronized 同步鎖 這樣我們的請求就會乖乖的排隊進來了。

PS :這樣做是效率比較低的做法,不建議這麼做,只是舉例子,synchronized 也不適合分散式集羣場景。

場景二 : 第三方回調

我們系統經常需要和第三方系統打交道,比如微信充值,支付寶充值什麼的,微信和支付寶常常會以回調你的介面通知你支付結果。為了保證你能收到回調,往往可能會回調多次。

有時候我們也為了保證數據的準確性會有個定時器去查詢支付結果未知的流水,並執行響應的處理。

如果定時器的輪訓和回調剛好是在同時進行,這可能又出BUG了,又進行了兩次重複操作。

那麼問題來了:假設我是一個充值操作, 回調回來的時候 ,會做業務處理,成功了給用戶賬戶加錢。這是後就要保證冪等性了, 假設微信同一筆交易給你回調了兩次,如果你給用戶充值了兩次,這顯然不合理(我是老闆肯定扣你工資),所以要保證 不管微信回調你多少次 ,同一筆交易你只能給用戶充一次錢。這就冪等性。

解決冪等性問題方案

  • synchronized 適合單機應用,不追求性能 ,不追求並發。
  • 分散式鎖 但是往往我們的應用是分散式的集羣,並且很講究性能,並發,所以我們需要用到 分散式鎖 來解決這個問題。

Redis 分散式鎖:

/**
* setNx
*
* @param key
* @param value
* @return
*/
public Boolean setNx(String key,Object value) {
return redisTemplate.opsForValue().setIfAbsent(key,value);
}
/**
* @param key 鎖
* @param waitTime 等待時間 毫秒
* @param expireTime 超時時間 毫秒
* @return
*/
public Boolean lock(String key,long waitTime,long expireTime) {
String vlaue = UUIDUtil.mongoObjectId();
Boolean flag = setNx(key,vlaue);
//嘗試獲取鎖 成功返回
if (flag) {
redisTemplate.expire(key,expireTime,TimeUnit.MILLISECONDS);
return flag;
} else {
//失敗
//現在時間
long newTime = System.currentTimeMillis();
//等待過期時間
long loseTime = newTime + waitTime;
//不斷嘗試獲取鎖成功返回
while (System.currentTimeMillis() < loseTime) {
Boolean testFlag = setNx(key,vlaue);
if (testFlag) {
redisTemplate.expire(key,expireTime,TimeUnit.MILLISECONDS);
return testFlag;
}
//休眠100毫秒
try {
Thread.sleep(100);
}
catch (InterruptedException e) {
e.printStackTrace();
}
}
}
return false;
}
/**
* @param key
* @return
*/
public Boolean lock(String key) {
return lock(key,1000L,60 * 1000L);
}
/**
* @param key
*/
public void unLock(String key) {
remove(key);
}
</pre>

利用Redis 分散式鎖 我們的代碼可以改成這樣:

public void like(Article article,User user) {
String key = "key:like" + article.getId() + ":" + user.getUserId();
// 等待鎖的時間 0 , 過期時間 一分鐘防止死鎖
Boolean flag = redisService.lock(key,0,60 * 1000L);
if(!flag) {
//獲取鎖失敗 說明前面的請求已經獲取了鎖
throw new ApiException(CodeEnums.SYSTEM_ERR);
}
//檢查是否點過贊
if (checkIsLike(article,user)) {
//點過贊了
throw new ApiException(CodeEnums.SYSTEM_ERR);
} else {
//保存點贊
saveLike(article,user);
}
//刪除鎖
redisService.unLock(key);
}
</pre>

key 的設計也很講究:

數據不衝突的兩個業務場景,key不能衝突,不同人的key也不一樣,不同的文章Key也不一樣。

根據場景業務設定。一個原則: 儘可能的縮小key的範圍。 這樣才能增強我們的並發。首先我們先獲取鎖,獲取鎖成功 執行完操作,保存數據 ,刪除鎖。獲取不到鎖返回失敗。設置過期時間是為了防止『死鎖』,比如機器獲取到了 鎖,沒有設置過期時間,但是他死機了,沒有刪除釋放鎖。
  • 版本號控制CAS 演算法: CAS有3個操作數,內存值V,舊的預期值A,要修改的新值B。當且僅當預期值A和內存值V相同時,將內存值V修改為B,否則什麼都不做。這個比較繁雜,有興趣的大家可以去看看。

推薦閱讀:

相關文章