在實際工作中經常遇到對賬戶的操作(賬戶充值和賬戶消費),處理的邏輯如下:
// 1 查詢賬戶當前的金額
// 2 根據操作,計算操作后的金額
// 3 更新賬戶的金額
然而,在實際中經常會有並發操作的問題,下面通過在數據中執行SQL的方式,模擬下不做並發處理的情況:
數據庫是MySQL,隔離級別采用默認的可重復讀,表為t_money,只有兩列:id、money,只有一條記錄id=1, money=1000。分別起兩個客戶端,模擬並發操作的行為:
- 事務1,賬戶消費100元
- 事務2,賬戶充值200元
序號 | 事務1 | 事務2 |
---|---|---|
1 | start transaction; | |
2 | start transaction; | |
3 | select * from t_money where id=1; | |
4 | select * from t_money wehre id=1; | |
5 | update t_money set money=900 where id=1; | |
6 | update t_money set money=1200 where id=1; (不能執行,被阻塞) | |
7 | select * from t_money where id=1; | |
8 | commit; | (事務1執行commit后,被阻塞的update執行) |
9 | select * from t_money where id=1; | select * from t_money where id=1; |
10 | commit; | |
11 | select * from t_money where id=1; | select * from t_money where id=1; |
按照上面的步驟執行完成后,11步查出來賬戶id=1的money=1200。
按照業務的邏輯,消費和充值后,賬戶的金額應該為1100,而系統中id=1的賬戶金額居然為1200,這是絕對不能接受的!
解決方案
1. 利用MySQL的當前讀
將更新金額的語句,使用:
update t_money set money=money-100 where id=1;
update會使用“當前讀”,可以讀取到其它事物未提交的數據。當前讀遇到其它事務的寫操作時,會被阻塞,引起當前讀的語句:
select ... for update;
select ... lock in share mode;
update
delete
insert
2. redis並發鎖
也就是,操作前要獲得鎖,操作完成釋放鎖;沒有獲得鎖,不允許進行操作,直接返回並發錯誤。
在實際系統中,往往是分布式部署的,那么就需要加分布式鎖。最容易想到(本人)的就是使用redis,在redis中使用setnx,偽代碼如下:
if(redis.setnx(id)){
// 加鎖成功
// 賬戶操作
} else {
// 返回並發錯誤,由調用者處理后續邏輯(重試等)
}
2. 優雅的redis並發鎖
在方案1中,在加鎖失敗后,直接返回並發異常,調用方需要重試。實際上,第一次請求時,雖然不能獲得鎖,但是可能在1s之后就可以獲得鎖了,我們何不如稍微等待下再重試呢?
更加優雅的加鎖,偽代碼:
if (redis.setnx(id)) {
// 加鎖成功
// 賬戶操作
} else {
// 第一次加鎖失敗
Thread.sleep(1000); // 等待1s,也可以等待並指定多次重試
if (redis.setnx(id)) {
// 賬戶操作
} else {
// 返回並發錯誤
}
}
對於redis實現並發鎖,有很多可以研究的細節,比如:setnx成功后,系統掛了,后續加鎖就永遠不能成功了,該如何處理?更多細節,可以看看他人是如何用redis實現分布式並發鎖的。