冪等的雙倍快樂,你值得擁有
hello, 這是有態(tài)度馬甲的第xxx篇原創(chuàng)口水文。有趣指數(shù)5顆星,有用指數(shù)5顆星。
????本文是國(guó)外技術(shù)網(wǎng)站medium上點(diǎn)贊超過200+的翻譯/筆記文,有關(guān)規(guī)避/解決冪等請(qǐng)求的編程指南。
1. 軟件領(lǐng)域二次請(qǐng)求無(wú)法避免
我們生活的每時(shí)每刻都是獨(dú)一無(wú)二的,事情/動(dòng)作可能不會(huì)相同的形式再次發(fā)生。
在軟件領(lǐng)域,同一動(dòng)作請(qǐng)求并不總會(huì)只產(chǎn)生一次,這可能會(huì)帶來(lái)一些問題: 想象你月底發(fā)薪,公司的轉(zhuǎn)賬指令錯(cuò)誤的觸發(fā)了2次,這豈不是雙倍快樂。
為什么冪等性很重要?
-
網(wǎng)絡(luò)不可靠:客戶端超時(shí)后,可以放心地重試冪等的請(qǐng)求(如PUT, DELETE),而不用擔(dān)心產(chǎn)生意外后果。
-
分布式系統(tǒng):在微服務(wù)架構(gòu)中,服務(wù)間的重試機(jī)制依賴于冪等性來(lái)保證數(shù)據(jù)一致性。
| 二次請(qǐng)求的來(lái)源 | 能避免出現(xiàn)嗎? | 怎么避免出現(xiàn)? |
|---|---|---|
| 前端的頻繁點(diǎn)擊提交 | 能 | 提交后置灰按鈕/提交后切換頁(yè)面/防誤觸來(lái)解決 |
| 客戶端/中間服務(wù)器的重試動(dòng)作 | 不能 | - |

根據(jù)雙將軍理論,即使A/B將軍不斷確認(rèn)收到對(duì)方的上一條信息, 也沒辦法確保對(duì)方與自己達(dá)成(同一時(shí)間攻擊的共識(shí))。
兩將軍問題是無(wú)解的,間歇性重試是一種工程解。 (還有散彈打鳥)
:我們一直發(fā)送相同的服務(wù)請(qǐng)求,直到我們確定收到它(雖然可能會(huì)多次收到), 這就叫至少一次交付。
但是我們不希望被扣款兩次,那我們就必須確保多次處理相同的請(qǐng)求不會(huì)改變最初的應(yīng)用狀態(tài), 這是冪等請(qǐng)求的重點(diǎn)。
除此之外,重試還可能帶來(lái) 重試風(fēng)暴、資源雪崩等衍生問題。
2. 某些請(qǐng)求天然冪等,你不需要做什么
想象你正在銀行開戶。
public sealed class Account
{
public Guid Id { get; }
public decimal Balance { get; private set; }
public Account(Guid id, decimal balance)
{
if (id == default)
throw new InvalidOperationException("Account id must be provided");
if (balance < 0)
throw new InvalidOperationException("Balance cannot be negative");
Id = id;
Balance = balance;
}
// 取錢
public void Withdraw(decimal amount)
{
if (amount < 0)
throw new InvalidOperationException("Cannot withdraw negative amount");
if (amount > Balance)
throw new InvalidOperationException("Cannot withdraw more than existing balance");
Balance -= amount;
}
// 存錢
public void Deposit(decimal amount)
{
if (amount < 0)
throw new InvalidOperationException("Cannot deposit negative amount");
Balance += amount;
}
}
前端發(fā)起的開戶請(qǐng)求OpenAccountRequest是冪等的, 只需要在開戶邏輯里面檢查 數(shù)據(jù)表是不是存在這個(gè)AccountId。
你甚至可在數(shù)據(jù)庫(kù)設(shè)置AccountId為唯一索引,讓重試動(dòng)作爆出異常。
public async Task HandleAsync(OpenAccountRequest request, CancellationToken token = default)
{
var account = new Account(request.AccountId, request.Balance);
try
{
await _repository.InsertAsync(account, token);
}
catch (DuplicateKeyException)
{
//Ignore
}
}
對(duì)于存錢(WithDraw)取錢(Deposit)就不行了,如果因?yàn)榫W(wǎng)絡(luò)原因而重試了2次存錢請(qǐng)求(deposit),豈不就是雙倍快樂。
3. 樂觀鎖的介入一定合理嗎?
一種處理重復(fù)請(qǐng)求的方式是質(zhì)詢實(shí)體的狀態(tài),嚴(yán)格意義來(lái)講, 這個(gè)方案是來(lái)解決更大敘事背景(樂觀鎖)下的方案。
首先我們知道高并發(fā)場(chǎng)景下,有一個(gè)叫樂觀鎖的并發(fā)控制機(jī)制,樂觀地認(rèn)為數(shù)據(jù)在操作時(shí)不會(huì)沖突, 因此在操作前不加鎖,在提交時(shí)檢查數(shù)據(jù)是否被修改。
文中一開始: 讓前端在請(qǐng)求時(shí)帶上需要保護(hù)的Balance,
在更新時(shí)利用AccountId+原Balance來(lái)定位并更新賬戶。
// 下面的前端DTO需要帶上賬戶余額,(二次請(qǐng)求也是這個(gè)值)。
public sealed class DepositToAccountRequest
{
public Guid AccountId { get; }
public decimal Amount { get; } // 操作金額
public decimal AccountBalance { get; }
public DepositToAccountRequest(Guid accountId, decimal amount, decimal accountBalance)
{
AccountId = accountId;
Amount = amount;
AccountBalance = accountBalance;
}
}
public async Task HandleAsync(DepositToAccountRequest request, CancellationToken token = default)
{
var account = await _repository.GetAsync(request.AccountId, token) ??
throw new EntityNotFoundException();
account.Deposit(request.Amount);
await _repository.UpdateAsync(account, request.AccountBalance, token);
public sealed class AccountRepository : IAccountRepository
{
//....
public async Task UpdateAsync(Account account, decimal expectedBalance, CancellationToken token = default)
{
var sql = "UPDATE Accounts SET Balance = @Balance WHERE Id = @Id AND Balance = @ExpectedBalance";
var sqlParams = new
{
Id = account.Id,
Balance = account.Balance, // 新余額
ExpectedBalance = expectedBalance // 原余額
};
await using var connection = new SqlConnection(_connectionString);
await connection.OpenAsync(token);
var rowsAffected = await connection.ExecuteAsync(sql, sqlParams);
if (rowsAffected == 0)
throw new InvalidStateException();
}
//....
}
讀者肯定也發(fā)現(xiàn)了:
① 這個(gè)方式不靈活,如果不是Balance,或者不只是Balance, 那么這個(gè)sql邏輯就得變化;
② 另一方面,這個(gè)方式歸根到底不識(shí)別重復(fù)請(qǐng)求,不知道這是重復(fù)請(qǐng)求,還是底層的數(shù)據(jù)真的發(fā)生了變化。
想象你被觸發(fā)了第二次取錢請(qǐng)求, 若此時(shí)剛好有人給你存了一筆錢(剛好等于你第一次取錢金額),促使你的第二次取錢請(qǐng)求成功了,這豈不是新的雙倍悲傷。
3.1 適用于更新Put請(qǐng)求的狀態(tài)版本方案
所以文中提出了基于宏達(dá)敘事的正經(jīng)方案: 前端介入 + 狀態(tài)版本
在前端DTO請(qǐng)求帶上AccountVersion,每次更新時(shí)用AccoundId+原AccountVersion去定位、更新狀態(tài)版本, 如果where條件失敗說(shuō)明實(shí)體狀態(tài)已經(jīng)變化,需要報(bào)錯(cuò)給到前端,讓前端重新拉取數(shù)據(jù), 如果where條件成功,則說(shuō)明狀態(tài)版本無(wú)變更,遞增version,并給到前端。
public async Task UpdateAsync(Account account, int expectedVersion, CancellationToken token = default)
{
var sql = "UPDATE Accounts SET Balance = @Balance, Version = @Version WHERE Id = @Id AND Version = @ExpectedVersion";
var sqlParams = new
{
Id = account.Id,
Balance = account.Balance,
Version = account.Version,
ExpectedVersion = expectedVersion
};
await using var connection = new SqlConnection(_connectionString);
await connection.OpenAsync(token);
var rowsAffected = await connection.ExecuteAsync(sql, sqlParams);
if (rowsAffected == 0)
throw new InvalidStateException();
}
grafana 修改數(shù)據(jù)源的示例
curl 'https://grafana-chinese.observe.dev.eks.gainetics.io/api/datasources/uid/tempo' \
-X 'PUT' \
-H 'content-type: application/json' \
--data-raw '{"id":2,"uid":"tempo","orgId":1,"name":"Tempo","type":"tempo","typeLogoUrl":"public/plugins/tempo/img/tempo_logo.svg","access":"proxy","url":"http://tempo:3200","user":"","database":"","basicAuth":false,"basicAuthUser":"","withCredentials":false,"isDefault":true,"jsonData":{"pdcInjected":false,"tracesToLogsV2":{"customQuery":false,"datasourceUid":"opensearch","filterBySpanID":true,"filterByTraceID":true,"spanEndTimeShift":"1m","spanStartTimeShift":"-1m","tags":[{"key":"beast","value":""}]}},"secureJsonFields":{},"version":18,"readOnly":false,"accessControl":{"alert.instances.external:read":true,"alert.instances.external:write":true,"alert.notifications.external:read":true,"alert.notifications.external:write":true,"alert.rules.external:read":true,"alert.rules.external:write":true,"datasources.id:read":true,"datasources:delete":true,"datasources:query":true,"datasources:read":true,"datasources:write":true},"apiVersion":""}'
里面有一個(gè)version就是狀態(tài)版本,每次前端嘗試去更細(xì)時(shí), 會(huì)帶上version,去后端定位。
ds = &datasources.DataSource{
ID: cmd.ID,
OrgID: cmd.OrgID,
.....
Version: cmd.Version + 1,
.....
}
var updateSession *xorm.Session
if cmd.Version != 0 {
// the reason we allow cmd.version > db.version is make it possible for people to force
// updates to datasources using the datasource.yaml file without knowing exactly what version
// a datasource have in the db.
updateSession = sess.Where("id=? and org_id=? and version < ?", ds.ID, ds.OrgID, ds.Version)
} else {
updateSession = sess.Where("id=? and org_id=?", ds.ID, ds.OrgID)
}
affected, err := updateSession.Update(ds)
if err != nil {
return err
}

這種樂觀鎖的思想去解決冪等問題有一個(gè)小弊端, 因?yàn)闃酚^鎖的思想本是針對(duì)并發(fā)控制,它解決了并發(fā)請(qǐng)求中的重復(fù)請(qǐng)求這一子集場(chǎng)景,但是帶來(lái)的副作用就是高并發(fā)時(shí),很多請(qǐng)求會(huì)被拒絕(重試請(qǐng)求會(huì)被拒絕,并發(fā)請(qǐng)求也會(huì)被拒絕),效率變低,但數(shù)據(jù)不一致問題沒有了,雙倍悲傷也不會(huì)有。
以上是”用于更新的PUT請(qǐng)求“,restful規(guī)范強(qiáng)烈要求冪等性,通常用”狀態(tài)版本“實(shí)現(xiàn),
POST 的冪等性是強(qiáng)烈推薦的,但它不能使用狀態(tài)版本,而應(yīng)該使用”冪等鍵“(Idempotency Key) 或業(yè)務(wù)唯一標(biāo)識(shí)來(lái)實(shí)現(xiàn)。
4. 用冪等鍵實(shí)現(xiàn)Post請(qǐng)求冪等
put更新請(qǐng)求,冪等性可以用 狀態(tài)版本保證, 是因?yàn)樵谡?qǐng)求時(shí)已經(jīng)有 “狀態(tài)版本” 來(lái)定義了實(shí)體快照,
Post新增請(qǐng)求,一開始并沒有實(shí)體, 我們需要一個(gè)在創(chuàng)建動(dòng)作發(fā)生前就生成的唯一標(biāo)識(shí),來(lái)保證整個(gè)創(chuàng)建過程的唯一性。
① 客戶端在發(fā)起創(chuàng)建資源的POST請(qǐng)求時(shí),在HTTP頭(如 Idempotency-Key: <unique_key>)或請(qǐng)求體中生成并攜帶一個(gè)全局唯一的冪等鍵。
② 服務(wù)器收到 新增的動(dòng)作,利用這個(gè)冪等鍵 從redis或者數(shù)據(jù)庫(kù)定位是不是已經(jīng)存在該冪等鍵,存在則返回關(guān)聯(lián)的實(shí)體;
如果不存在, 則用事務(wù)插入冪等鍵和關(guān)聯(lián)實(shí)體。
③ 這個(gè)冪等鍵的保存可以設(shè)置過期時(shí)間,或者自動(dòng)清理機(jī)制來(lái)刪除。
一張表來(lái)存儲(chǔ) 客戶端產(chǎn)生的全局requestId, 這個(gè)表保證requestId唯一。
那么通過事務(wù): requestId 插入歷史記錄表 & 實(shí)際的請(qǐng)求實(shí)體,便可以真實(shí)解決冪等問題, 這是真的冪等, 因?yàn)檫@個(gè)事務(wù)真正識(shí)別出了重復(fù)請(qǐng)求。
public sealed class AccountRepository : IAccountRepository
{
//....
public async Task UpdateAsync(Account account, Guid requestId, CancellationToken token = default)
{
var requestSql = "INSERT INTO RequestIds VALUES (@Id)";
var requestSqlParams = new
{
Id = requestId.ToString()
};
var accountSql = "UPDATE Accounts SET Balance = @Balance WHERE Id = @Id";
var accountSqlParams = new
{
Id = account.Id,
Balance = account.Balance
};
await using var connection = new SqlConnection(_connectionString);
await connection.OpenAsync(token);
await using var transaction = await connection.BeginTransactionAsync(token);
try
{
await connection.ExecuteAsync(requestSql, requestSqlParams);
}
catch (Exception e) when (IsDuplicateKeyException(e))
{
throw new DuplicateKeyException();
}
await connection.ExecuteAsync(accountSql, accountSqlParams);
await transaction.CommitAsync(token);
}
//....
}
總結(jié)
-
沒有最佳的方式去處理冪等,只有最合適的。
-
有些業(yè)務(wù)天然冪等, 使用簡(jiǎn)單的全局唯一id就可以定位出二次請(qǐng)求。
-
如果你的實(shí)體更新的不頻繁, 可以考慮使用基于樂觀鎖的版本狀態(tài)來(lái)解決(總體上樂觀鎖是更宏達(dá)敘事的一個(gè)思路,在頻繁更新場(chǎng)景下能處理冪等問題,但體驗(yàn)不佳,是一味猛藥)。
-
更常見的冪等解決方式是:基于客戶端產(chǎn)生的冪等鍵, 構(gòu)建請(qǐng)求的唯一性,利用redis鍵值對(duì)或mysql事務(wù)識(shí)別出二次請(qǐng)求, 是真正的實(shí)現(xiàn)了冪等語(yǔ)義。
????????????
https://medium.com/swlh/retry-requests-fearlessly-with-idempotence-f6bc23f1c721
本文來(lái)自博客園,作者:{有態(tài)度的馬甲},轉(zhuǎn)載請(qǐng)注明原文鏈接:http://www.rzrgm.cn/JulianHuang/p/19150319
歡迎關(guān)注我的原創(chuàng)技術(shù)、職場(chǎng)公眾號(hào), 加好友談天說(shuō)地,一起進(jìn)化

浙公網(wǎng)安備 33010602011771號(hào)