業(yè)務(wù)場景
用戶預(yù)存一定余額,可以用余額在平臺購買套餐商品,支付扣除余額需控制并發(fā),當(dāng)前采用的是樂觀鎖方式。即每個用戶的余額記錄都有一個版本號,更新記錄時,需要帶上版本號。版本號采用整數(shù)遞增。
問題
當(dāng)有兩個扣減余額的操作同時發(fā)生時,其中一個有幾率失敗。失敗結(jié)果直接返回給用戶,此時用戶操作重試即可,但會影響用戶體驗(yàn)。如果一直處于高并發(fā)狀態(tài),用戶可能會連續(xù)操作失敗多次。主要針對此扣款失敗場景進(jìn)行優(yōu)化。
方案演進(jìn)
增加失敗重試
int i = 0, max = 3;//最多嘗試3次
while (i < max && !success) {
//獲取余額記錄
AgentRechargeEntity arEntity = agentRechargeService.findByAgentId(context.getAdminUserEntity().getAgentId());
//版本記錄值,用于控制并發(fā)操作
Integer exceptTxVersion = arEntity.getTxVersion();
//修改金額計(jì)算
//更新余額
success = agentRechargeService.updateMoneyByExpectTxVersion(id, exceptTxVersion, money);
i++;
}
進(jìn)行上面重試修改之后,仍然存在失敗日志

通過分析日志可知,失敗時確實(shí)有三次重試,說明我們修改的代碼是生效的。問題在于,失敗后重新獲取的記錄值仍然是老的數(shù)據(jù),版本號expectTxVersion沒有變化。實(shí)際獲取上次更新記錄值如下。

懷疑是可能存在緩存,該方法使用的是mybatis框架,由于我們沒有人為增加緩存,會不會是mybatis的緩存。經(jīng)研究,mybatis默認(rèn)是開啟二級緩存的,于是通過在select方法上增加flushCache="true" useCache="false"配置去除緩存。

然后更新上線了,本以為就此結(jié)束,然而。。。還是一樣的失敗日志。
重新分析:更新失敗說明版本號已經(jīng)變更了,意味著其他修改已經(jīng)提交入庫了。
為什么沒有讀到其他事務(wù)的最新數(shù)據(jù)呢,研究一下事務(wù)的隔離級別。
查看mysql默認(rèn)的隔離級別:
select @@transaction_isolation;

默認(rèn)為:可重復(fù)讀,看下該級別的定義。
一個事務(wù)啟動的時候,能夠看到所有已經(jīng)提交的事務(wù)結(jié)果。但是之后,這個事務(wù)執(zhí)行期間,其他事務(wù)的更新對它不可見。
因?yàn)楂@取記錄操作是在事務(wù)中,所以重復(fù)獲取不能得到最新數(shù)據(jù)。
因此,可以將數(shù)據(jù)獲取排除到事務(wù)之外,主要用spring的事務(wù)傳遞管理,設(shè)置為Propagation.NOT_SUPPORTED:以非事務(wù)方式執(zhí)行操作,如果當(dāng)前存在事務(wù),就把當(dāng)前事務(wù)掛起。

再看日志,雖然也有失敗,但基本重試一次之后就成功。

至此,問題解決。
總結(jié)
本以為是一個簡單的重試優(yōu)化,逐漸引出mybatis二級緩存和數(shù)據(jù)庫的事務(wù)管理。任何一個點(diǎn)的遺漏都達(dá)不到想要的效果。平時的知識儲備是必要的,否則遇到問題時將花費(fèi)成倍的時間。