最近在做项目的时候碰到这样一个问题,做一个用户余额的需求。具体如下:
类似这样一张表:
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步。像这种并发的解决方案其实经常会遇到,多思考一下方案,跟同事多交流一下,发现代码世界还是无穷无尽的!