最近在做項目的時候碰到這樣一個問題,做一個用戶餘額的需求。具體如下:
類似這樣一張表:
CREATE TABLE `test_insert` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`token` varchar(10) NOT NULL DEFAULT '0' COMMENT '用戶標誌-唯一索引',
`remark` varchar(20) NOT NULL DEFAULT '' COMMENT '備註描述',
`balance` int(10) NOT NULL DEFAULT '0' COMMENT '餘額',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '創建時間',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改時間',
PRIMARY KEY (`id`),
UNIQUE KEY `idx_a` (`token`)
)
用戶每次下單需要根據token查看用戶在該表中是否有數據,若果有,就把它的balance增加相應的金額amount;如果沒有就要在該表新增一條該用戶的數據添加相應的餘額。
但是在編碼的時候注意到一個問題,由於我們系統中的併發是相當大的(日均達到近千萬筆交易),對於一個用戶也有比較高的併發情況。假設某個用戶在表中沒有記錄,首筆交易還是並發過來的,都去進行insert操作會出問題。爲防止這種情況,最開始的編碼如下:
@Service
public class TestServiceImpl implements TestService {
private static final String REDISKEY = "balance.insert.";
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Autowired
private TestInsertDAO testInsertDAO;
@Override
public boolean addBalance(String token, Integer amount) {
TestInsertDO testInsertDO = testInsertDAO.selectByToken(token);
Long update = 0L;
if (testInsertDO == null) {
Boolean aBoolean = stringRedisTemplate.opsForValue().setIfAbsent(REDISKEY + token, "1");
stringRedisTemplate.expire(REDISKEY + token, 5, TimeUnit.SECONDS);
if (aBoolean) {
testInsertDO = new TestInsertDO();
testInsertDO.setToken(token);
testInsertDO.setRemark("111");
testInsertDO.setBalance(amount);
update = testInsertDAO.insert(testInsertDO); //不存在就插入,balance是該筆訂單的amount
stringRedisTemplate.delete(REDISKEY + token);
} else {
update = testInsertDAO.updateByToken(token, amount); //拿不到鎖就直接更新
}
} else {
update = testInsertDAO.updateByToken(token, amount); //存在就直接更新
}
return update > 0;
}
}
注意這裏的updateByToken方法是在sql上做加操作:
<operation name="updateByToken" paramtype="primitive" >
UPDATE
test_insert
SET
BALANCE = BALANCE + #{amount}
WHERE
token = #{token,jdbcType=VARCHAR}
</operation>
這種方式只是利用了一下redis的分佈式鎖NX鎖
Boolean aBoolean = stringRedisTemplate.opsForValue().setIfAbsent(REDISKEY + token, “1”);
來防止有兩個線程同時去做插入操作,但是這個寫法問題非常多:
- 這個鎖不是阻塞的,併發時有一個拿到了鎖去走insert,另一個沒拿到走了update,如果update走的比insert快就會造成update操作更新不到數據
- stringRedisTemplate.delete(REDISKEY + token);這個解鎖操作。如果解鎖了後,正巧有個線程這個時候去拿鎖,那它拿到了鎖後又去執行insert操作,由於token有唯一索引會報錯!
併發10筆的情況下,我們來看一下併發執行結果:
數據庫只加了9次,並且代碼有一次因爲唯一索引重複插入問題的報錯!
對於這種方式,想了不少方法直接來改進但是都不是太好的方式,需要線程休眠或者其他的鎖類型!
下面介紹幾種方式來做優化:
1、利用mysql的on duplicate key update
網上百度了一下差不多都在推薦這種方式,只需要把insert語句改一下
<operation name="insert" paramtype="object" remark="insert:TEST_INSERT">
INSERT INTO TEST_INSERT(
TOKEN
,REMARK
,BALANCE
)VALUES(
#{token,jdbcType=INTEGER}
, #{remark,jdbcType=VARCHAR}
, #{balance,jdbcType=INTEGER}
)on duplicate key update BALANCE = BALANCE + #{amount}
</operation>
這種方式的確最簡單,但是在實踐開發中並不建議使用這樣的sql語句,首先這個是mysql特有的一個語句,可能有的持久層框架並不支持,另外這樣的語句並不適合DBA來維護,其次,這種寫法也有出現死鎖的風險,詳情參見:https://blog.csdn.net/pml18710973036/article/details/78452688
2、利用成熟的阻塞鎖,比如redisson的
上面利用Jedis的分佈式鎖做的併發處理,其實最大的問題就是Jedis的NX鎖是非阻塞的。利用redisson的阻塞鎖可以更好地解決這個問題,編碼如下:
public boolean addBalance(String token, Integer amount) {
TestInsertDO testInsertDO = testInsertDAO.selectByToken(token);
Long update = 0L;
if (testInsertDO == null) {
RLock lock = redisson.getLock("balanceinsert" + token);
lock.lock(5, TimeUnit.SECONDS);
testInsertDO = testInsertDAO.selectByToken(token); //雙重檢查;防止加鎖過程中被插入數據
if (testInsertDO == null) {
testInsertDO = new TestInsertDO();
testInsertDO.setToken(token);
testInsertDO.setRemark("加餘額");
testInsertDO.setBalance(amount);
update = testInsertDAO.insert(testInsertDO); //不存在就插入,balance是該筆訂單的amount
} else {
update = testInsertDAO.updateByToken(token, amount); //有數據了就更新
}
lock.unlock();
} else {
update = testInsertDAO.updateByToken(token, amount); //存在就直接更新
}
return update > 0;
}
這裏在獲得鎖之後再去檢查一遍數據庫的信息,防止在拿鎖的過程中有信息被插入了!
關於redisson的分佈式鎖可以這個:https://github.com/redisson/redisson/wiki/8.-distributed-locks-and-synchronizers
3、利用唯一索引先插入默認值
這種方式不需要額外的工具來處理。上面的inert的時候把當前訂單的餘額一起插入了。現在我們分兩步,先insert一個默認值,然後再update上該筆訂單的amount,把insert異常捕捉,那麼在併發時由於唯一索引的存在,一定是隻有一條插入成功,其他的報錯,捕捉異常後繼續走update。
public boolean addBalance(String token, Integer amount) {
TestInsertDO testInsertDO = testInsertDAO.selectByToken(token);
Long update = 0L;
if (testInsertDO == null) {
try {
testInsertDO = new TestInsertDO();
testInsertDO.setToken(token);
testInsertDO.setBalance(0);
testInsertDO.setRemark("加餘額");
update = testInsertDAO.insert(testInsertDO); //不存在就插入,balance是該筆訂單的amount
} catch (Exception e) {
e.printStackTrace();
}
}
update = testInsertDAO.updateByToken(token, amount); //存在就直接更新
return update > 0;
}
併發10條執行結果:
其實這種方式最簡單易行,我們最後也採用的這種方式!其實就是把一步拆爲2步。像這種併發的解決方案其實經常會遇到,多思考一下方案,跟同事多交流一下,發現代碼世界還是無窮無盡的!