前言
在我们业务开发过程中,总会遇到这种情况,就是插入了多条重复数据,或者在更新数据的时候出现了数据错乱,在执行多次的时候,结果总是不一样的,与我们的预期不符。我们引入一个概念叫做“幂等”,幂等其实是一个数学概念,在编程中一个幂等操作的特点是其任意多次执行所产生的影响均与一次执行的影响相同,这也是我们所期望的,那么下面我们详细介绍一下几种实现幂等的方式。
select + insert
先从数据库查询记录是否存在,不存在插入,存在更新。
public Users insert(Users users) {
LambdaQueryWrapper<Users> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(Users::getUsername, users.getUsername());
Users usersOrig = usersMapper.selectOne(queryWrapper);
if (usersOrig == null) {
usersMapper.insert(users);
} else {
users.setId(usersOrig.getId());
BeanUtils.copyProperties(users, usersOrig);
usersMapper.update(users);
}
return users;
}
这种方式在并发不高的情况下可以使用,在高并发下两个线程过来同时查询,都查不出数据,判断数据为空,同时插入,出现重复数据。
实验:
使用jmeter模拟多线程(后面几种幂等方式验证也使用此例子)
模拟3个线程同时请求
出现重复数据
高并发场景下不建议单独使用,但是可以结合分布式锁使用(在性能要求不是很高,数据要严格幂等的话–例如支付,转账等,推荐此方式)
数据库加悲观锁
这种方式适合更新带有计算的幂等,例如:amount为用户账户余额
update users set amount = amount - 10 where id = 138
如果不加幂等,高并发场景下很有可能将amount减为负数。
假如场景:用户A账户余额有20元,现在有3个线程同时进行请求扣减金额10元,正常会有一个线程因余额不足扣减失败,最后账户余额为0。
@Transactional
public void updateAmount(Users users) {
Users usersOrig = usersMapper.queryById(users.getId());
if (usersOrig != null && usersOrig.getAmount() >= users.getAmount()) {
// 余额大于扣减金额
usersMapper.updateAmount(users);
} else {
log.info("账户余额不足");
}
}
账户余额变成了-10
使用数据库悲观锁可以解决这一问题,在查询余额时锁住这一行
select * from users where id = 138 for update
最后账户余额为0,成功解决。
注意:必须使用事物,没有事物锁会失效,查询条件ID必须是主键或者唯一索引,要不然会锁整个表。
@Transactional
public void updateAmount(Users users) {
log.info("当前线程pos1={}", Thread.currentThread().getName());
Users usersOrig = usersMapper.queryById(users.getId());
log.info("当前线程pos2={}", Thread.currentThread().getName());
if (usersOrig != null && usersOrig.getAmount() >= users.getAmount()) {
// 余额大于扣减金额
usersMapper.updateAmount(users);
} else {
log.info("账户余额不足");
}
log.info("当前线程pos3={}", Thread.currentThread().getName());
}
由于悲观锁是在事物中锁住一行数据,就是其他线程要等待正在处理的线程执行完所用事物操作,才会执行(见上图示例)。也就是说如果整个事物处理的很慢,会有大量的线程出于等待状态,会严重影响接口性能,不建议使用。
数据库加乐观锁
乐观锁可以解决悲观锁性能问题,即在表中加一个版本号version字段,每次更新对version+1。假如有2个线程同时请求
首先查询金额时,带出version
select
id, amount, version
from users
where id = 138
更新时,id与version做条件,更新amount,version+1
update users set amount = amount - 10, version = version + 1 where id = 138 and version = 1
然后判断本次 update 操作的影响行数,如果大于 0,则说明本次更新成功,如果等于 0,则说明本次更新没有让数据变更。
public void updateAmount(Users users) {
Users usersOrig = usersMapper.queryById(users.getId());
if (usersOrig != null && usersOrig.getAmount() >= users.getAmount()) {
// 余额大于扣减金额
users.setVersion(usersOrig.getVersion());
int i = usersMapper.updateAmount(users);
if (i > 0) {
log.info(Thread.currentThread().getName() + ":扣减成功");
}
} else {
log.info("账户余额不足");
}
}
其实不管是悲观锁还是乐观锁可以防止多个不同用户去扣减同一个账户余额,造成多扣余额变为负数的情况,但是如果是同一个用户,由于某种原因连续点了多次扣减(有可能前端没有做防重复提交),比如用户想扣除10,结果扣了20,与预期不符。像这种情况可以通过下面方式解决:
-
前端做好防抖处理
-
后端做好放重复提交
-
极端情况下同一用户,同时提交2个相同的请求
分两种情况解决:
- 用户是操作自己的账户
前端先查询本账户的版本号version,然后调扣减操作时将此版本号传给后台(查询version和扣减操作要不在一个按钮操作下),如果此版本号与后台数据库中版本号对比,如果相同则进行扣减操作。
- 用户是操作的公共账户
如果用户是操作的公共账户,那就有可能别人再操作,那用版本号这种方式就有问题了,可能有人就没有扣减成功。可以使用下面的第5种方式,建防重表,如果业务中有类似功能的表可以不用另建,如果插入防重表成功,则请求成功,插入失败,请求无效。
加唯一索引
对于防止有重复记录,使用这种方式最简单,比如在users表中的username字段加唯一索引,即使有多个相同的请求过来,也会只存一条记录,其它做好异常处理就好。
建防重表
如果业务表中不具有加唯一索引的条件,可以额外建立一个防重表,专门来创建一个唯一索引,且表中只包含主键和唯一索引。如果插入防重表成功则可继续执行下面的业务操作。
根据业务表中某个状态
比如订单状态有1-待支付、2-待发货、3-待收货、4-已完成,这些状态是顺序改变的,如果当前订单状态是待支付,这时用户支付,则要把订单改为待发货
update order_info set order_status = 2 where order_id = 123 and order_status = 1
当第一个请求过来,将订单状态由2更新为1,第二个请求再执行,订单状态已经变为2了,再执行相同的sql,则影响的行数为0了,更新失败。类似于乐观锁方式
分布式锁
加唯一索引和防重表本质上也是分布式锁,只不过是数据库层面的,并发性能不高,可以采用redis作为分布式锁,性能会更高。
采用redis中setnx命令或者直接使用redisson分布式锁框架
setnx命令方式:
public void insertUsers(Users users) {
// 声明一个线程ID,用于后面判断是否是该线程持有锁
String threadId = UUID.randomUUID().toString();
log.info("线程{}执行插入操作", threadId);
// 设置锁,注意一定要设置一个超时时间,否则如果服务挂掉或重启,锁将永远存在
try {
Boolean aBoolean = redisTemplate.opsForValue().setIfAbsent("user:clock:" + users.getUsername(), threadId, 60, TimeUnit.SECONDS);
if (aBoolean) {
// 加锁成功,保存用户
insert(users);
}
} finally {
if (threadId.equals(redisTemplate.opsForValue().get("user:clock:" + users.getUsername()))) {
// 判断是当前线程持有的锁,则进行锁释放
redisTemplate.delete("user:clock:" + users.getUsername());
}
}
}
public Users insert(Users users) {
LambdaQueryWrapper<Users> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(Users::getUsername, users.getUsername());
Users usersOrig = usersMapper.selectOne(queryWrapper);
if (usersOrig == null) {
usersMapper.insert(users);
} else {
users.setId(usersOrig.getId());
BeanUtils.copyProperties(users, usersOrig);
usersMapper.update(users);
}
return users;
}
成熟框架redisson:
public void insertUsers(Users users) {
String lockKey = "lockKey";
RLock rLock = redisson.getLock(lockKey);
try {
rLock.lock();
// 加锁成功,保存用户
insert(users);
} finally {
rLock.unlock();
}
}
可以看出redisson非常简洁完成分布式锁
redis+token机制(不推荐)
需要2次请求才能完成一次操作
- 第一次请求去后台拿token
- 后台生成token(全局唯一),存到redis中,注意要设置过期时间
- 前端拿到token,通过第二次请求(实际业务)传给后台,一般通过head传递
后台通过传过来的token验证是否存在,存在说明是第一次,执行成功,然后删除token,如果同时有另外一个相同的请求过来,token为空,判断执行失败,为无效操作,实现幂等。
因为redis+token机制需要执行2两步请求,而且如果处理不好,两次操作都同时执行了这2步,两次一样都可以成功,所以不建议使用这种方式。
总结
在实际业务场景中,基本上就3种情况的幂等:
-
重复记录,比如同时插入两条相同的订单记录。
-
更新数据出现多更或者少更,比如扣减商品库存,高并发下多个线程同时扣减造成多扣库存。
-
只有一方对自己的数据进行更新操作,比如扣减自己账户余额,造成重复扣钱。
首先前后端都要做好防抖处理,再做好防抖处理的同时
针对第一种
- 添加唯一索引,这是最简单的方式
- 如果不唯一索引不方便添加,使用防重表
- 分布式锁搭配select+insert方式,如上面第7个案例
针对第二种
- 防重表(如果有类似业务表也可以使用)+ 使用分布式锁redisson
针对第三种
- 防重表(如果有类似业务表也可以使用)
- 如上面第2个案例中使用version