赞
踩
↑↑↑请在文章开头处下载测试项目源代码↑↑↑
Redis实战系列文章:
Redis从入门到精通(四)Redis实战(一)短信登录
Redis从入门到精通(五)Redis实战(二)商户查询缓存
Redis从入门到精通(六)Redis实战(三)优惠券秒杀
如上图所示,线程1查询库存,判断当前库存是1,正准备扣减库存,但还没来得及扣减完成,此时线程2也过来查询库存,线程2的查询结果也必然是1,因此也去扣减库存。最终结果是,线程1和线程2都扣减库存,但总库存只有1,从而出现库存超卖问题。
库存超卖问题是典型的多线程安全问题,针对这一问题的常见解决方案就是加锁。
常见的锁分为悲观锁和乐观锁:
乐观锁一般有一个版本号,每次操作数据都会对版本号+1,提交数据后,会校验版本号是否比之前大1,如果是则说明操作成功,如果不是则说明数据还被其他线程修改过,则操作失败。如下图:
本项目采用的是校验库存是否被修改过。 修改后的代码如下:
// com.star.redis.dzdp.service.impl.VoucherOrderServiceImpl#seckillVoucher()
// 4.扣减库存
// boolean update = seckillVoucherService.update().setSql("stock = stock - 1")
// .eq("voucher_id", voucherId).update();
// 修改方案一
// where voucher_id = ? and stock = ?
boolean update = seckillVoucherService.update().setSql("stock = stock - 1")
.eq("voucher_id", voucherId)
.eq("stock", seckillVoucher.getStock())
.update();
以上代码的含义是,在扣减库存时需要校验库存是否和查询时的库存一致,一致的话则说明没有其他人修改过库存,是安全的,可以进行扣减;否则不能进行扣减。
但以上代码还是有一点问题的,假设有100个线程同时拿到了100个库存,然后同时进行库存扣减,正常来讲所有线程都可以成功扣减,但使用以上代码时只有一个线程可以成功扣减(where voucher_id = ? and stock = 100),其余99个线程都会失败。这就导致失败率太高。
我们可以做如下修改:
// com.star.redis.dzdp.service.impl.VoucherOrderServiceImpl#seckillVoucher()
// 修改方案二
// where voucher_id = ? and stock > 0
boolean update = seckillVoucherService.update().setSql("stock = stock - 1")
.eq("voucher_id", voucherId)
.gt("stock", 0)
.update();
以上代码中,不管其他线程是否扣减库存,只要判断出当前库存还大于0,就说明是安全的,当前线程就可以进行扣减。 这样也可以解决库存超卖问题。
现在有一个需求:同一个秒杀优惠券,一个用户只能下一单。
目前情况下,一个用户可以无限制地抢优惠券,因此要实现一人一单,就需要增加以下逻辑:在秒杀已开始、且库存充足的情况下,根据优惠券ID和用户ID查询是否已有订单,如果已有订单,则不能再下单。 如下图:
在VoucherOrderServiceImpl实现类的seckillVoucher()
方法中增加一人一单逻辑:
// com.star.redis.dzdp.service.impl.VoucherOrderServiceImpl#seckillVoucher()
// 3.判断库存是否充足...
// 增加一人一单规则
// 根据优惠券ID和用户ID查询订单是否已存在
int count = query().eq("voucher_id", voucherId).eq("user_id", userId).count();
log.info("old order count = {}", count);
if(count > 0) {
// 该用户已下过单
return BaseResult.setFail("每个帐号只能抢购一张优惠券!");
}
// 4.扣减库存...
简单测试下,调用/voucher/seckill/order
接口:
假设一个线程1过来,根据优惠券ID和用户ID查询订单不存在,准备进行扣减库存和创建订单的动作,但还没来得及完成,另一个线程2也进来了,线程2根据优惠券ID和用户ID查询订单的结果也是不存在,也进行扣减库存和创建订单的动作。最终结果是,创建了同一用户的两个订单。
我们可以在创建订单处打一个断点,调用/voucher/seckill/order
接口,下单id=12的优惠券。如日志显示,线程2依次查询秒杀活动是否存在及在有效期内、判断该用户是否重复下单、扣减库存,最终停在断点处:
[http-nio-8081-exec-2] 开始秒杀下单...voucherId = 12, userId = 1012 // 查秒杀活动是否存在及在有效期内 [http-nio-8081-exec-2] ==> Preparing: SELECT voucher_id,stock,create_time,begin_time,end_time,update_time FROM tb_seckill_voucher WHERE voucher_id=? [http-nio-8081-exec-2] ==> Parameters: 12(Long) [http-nio-8081-exec-2] <== Total: 1 [http-nio-8081-exec-2] SeckillVoucher(voucherId=12, stock=999, createTime=Fri Apr 05 18:57:23 CST 2024, beginTime=Fri Apr 05 14:00:00 CST 2024, endTime=Sat Apr 06 18:00:00 CST 2024, updateTime=Fri Apr 05 19:01:44 CST 2024) // 判断该用户是否重复下单 [http-nio-8081-exec-2] ==> Preparing: SELECT COUNT( * ) FROM tb_voucher_order WHERE (voucher_id = ? AND user_id = ?) [http-nio-8081-exec-2] ==> Parameters: 12(Long), 1012(Long) [http-nio-8081-exec-2] <== Total: 1 // 扣减库存 [http-nio-8081-exec-2] ==> Preparing: UPDATE tb_seckill_voucher SET stock = stock - 1 WHERE (voucher_id = ? AND stock > ?) [http-nio-8081-exec-2] ==> Parameters: 12(Long), 0(Integer) [http-nio-8081-exec-2] <== Updates: 1 [http-nio-8081-exec-2] update result = true [http-nio-8081-exec-2] get orderId = 7354337750083960833
此时再次调用/voucher/seckill/order
接口,下单id=12的优惠券。日志限制,新线程5仍然查询订单不存在,会直接创建订单:
[http-nio-8081-exec-5] 开始秒杀下单...voucherId = 12, userId = 1012 // 查秒杀活动是否存在及在有效期内 [http-nio-8081-exec-5] ==> Preparing: SELECT voucher_id,stock,create_time,begin_time,end_time,update_time FROM tb_seckill_voucher WHERE voucher_id=? [http-nio-8081-exec-5] ==> Parameters: 12(Long) [http-nio-8081-exec-5] <== Total: 1 [http-nio-8081-exec-5] SeckillVoucher(voucherId=12, stock=999, createTime=Fri Apr 05 18:57:23 CST 2024, beginTime=Fri Apr 05 14:00:00 CST 2024, endTime=Sat Apr 06 18:00:00 CST 2024, updateTime=Fri Apr 05 19:01:44 CST 2024) // 判断该用户是否重复下单,仍然是没有 [http-nio-8081-exec-5] ==> Preparing: SELECT COUNT( * ) FROM tb_voucher_order WHERE (voucher_id = ? AND user_id = ?) [http-nio-8081-exec-5] ==> Parameters: 12(Long), 1012(Long) [http-nio-8081-exec-5] <== Total: 1 // 扣减库存 [http-nio-8081-exec-5] ==> Preparing: UPDATE tb_seckill_voucher SET stock = stock - 1 WHERE (voucher_id = ? AND stock > ?) [http-nio-8081-exec-5] ==> Parameters: 12(Long), 0(Integer) [http-nio-8081-exec-5] <== Updates: 1 [http-nio-8081-exec-5] update result = true // 创建订单 [http-nio-8081-exec-5] get orderId = 7354337754378928129 [http-nio-8081-exec-5] ==> Preparing: INSERT INTO tb_voucher_order ( id, user_id, voucher_id, pay_time ) VALUES ( ?, ?, ?, ? ) [http-nio-8081-exec-5] ==> Parameters: 7354337754378928129(Long), 1012(Long), 12(Long), 2024-04-05 19:06:33.4(Timestamp) [http-nio-8081-exec-5] <== Updates: 1
放开断点,原线程2继续创建订单:
// 线程2继续创建订单
[http-nio-8081-exec-2] ==> Preparing: INSERT INTO tb_voucher_order ( id, user_id, voucher_id, pay_time ) VALUES ( ?, ?, ?, ? )
[http-nio-8081-exec-2] ==> Parameters: 7354337750083960833(Long), 1012(Long), 12(Long), 2024-04-05 19:06:32.351(Timestamp)
[http-nio-8081-exec-2] <== Updates: 1
此时数据库订单表有两条订单记录:
乐观锁比较适合更新数据,此处是插入数据问题,因此可以使用悲观锁来处理。*我们可以把查询订单、扣减库存、创建订单这三步封装为一个方法,并在该方法上添加一把synchronized锁。
// com.star.redis.dzdp.service.impl.VoucherOrderServiceImpl // 方法上添加synchronized锁 public synchronized BaseResult<Long> checkAndCreateVoucherOrder(Long voucherId, Long userId) { log.info("begin checkAndCreateVoucherOrder... voucherId = {}, userId = {}", voucherId, userId); // 1.增加一人一单规则 int count = query().eq("voucher_id", voucherId).eq("user_id", userId).count(); log.info("old order count = {}", count); if(count > 0) { // 该用户已下过单 return BaseResult.setFail("每个帐号只能抢购一张优惠券!"); } // 2.扣减库存 boolean update = seckillVoucherService.update().setSql("stock = stock - 1") .eq("voucher_id", voucherId) .gt("stock", 0) .update(); log.info("update result = {}", update); if(!update) { // 扣减库存失败,返回抢券失败 return BaseResult.setFail("库存不足,抢券失败!"); } // 3.创建订单 VoucherOrder voucherOrder = new VoucherOrder(); Long orderId = RedisIdWorker.nextId(stringRedisTemplate, "voucher_order"); log.info("get orderId = {}", orderId); voucherOrder.setId(orderId); voucherOrder.setUserId(userId); voucherOrder.setVoucherId(voucherId); voucherOrder.setPayTime(new Date()); voucherOrderService.save(voucherOrder); // 4.返回订单ID return BaseResult.setOkWithData(orderId); }
再次以相同的步骤进行测试,日志打印如下:
[http-nio-8081-exec-5] 开始秒杀下单...voucherId = 13, userId = 1012 [http-nio-8081-exec-5] ==> Preparing: SELECT voucher_id,stock,create_time,begin_time,end_time,update_time FROM tb_seckill_voucher WHERE voucher_id=? [http-nio-8081-exec-5] ==> Parameters: 13(Long) [http-nio-8081-exec-5] <== Total: 1 [http-nio-8081-exec-5] SeckillVoucher(voucherId=13, stock=996, createTime=Fri Apr 05 19:30:37 CST 2024, beginTime=Fri Apr 05 14:00:00 CST 2024, endTime=Sat Apr 06 18:00:00 CST 2024, updateTime=Fri Apr 05 19:38:06 CST 2024) // 线程5进入锁方法 [http-nio-8081-exec-5] begin checkAndCreateVoucherOrder... voucherId = 13, userId = 1012 [http-nio-8081-exec-5] ==> Preparing: SELECT COUNT( * ) FROM tb_voucher_order WHERE (voucher_id = ? AND user_id = ?) [http-nio-8081-exec-5] ==> Parameters: 13(Long), 1012(Long) [http-nio-8081-exec-5] <== Total: 1 [http-nio-8081-exec-5] old order count = 0 [http-nio-8081-exec-5] ==> Preparing: UPDATE tb_seckill_voucher SET stock = stock - 1 WHERE (voucher_id = ? AND stock > ?) [http-nio-8081-exec-5] ==> Parameters: 13(Long), 0(Integer) [http-nio-8081-exec-5] <== Updates: 1 [http-nio-8081-exec-5] update result = true [http-nio-8081-exec-5] get orderId = 7354346232644370433 [http-nio-8081-exec-5] ==> Preparing: INSERT INTO tb_voucher_order ( id, user_id, voucher_id, pay_time ) VALUES ( ?, ?, ?, ? ) [http-nio-8081-exec-5] ==> Parameters: 7354346232644370433(Long), 1012(Long), 13(Long), 2024-04-05 19:39:27.61(Timestamp) [http-nio-8081-exec-5] <== Updates: 1 // 线程5结束 // 线程6开始 [http-nio-8081-exec-6] 开始秒杀下单...voucherId = 13, userId = 1012 [http-nio-8081-exec-6] ==> Preparing: SELECT voucher_id,stock,create_time,begin_time,end_time,update_time FROM tb_seckill_voucher WHERE voucher_id=? [http-nio-8081-exec-6] ==> Parameters: 13(Long) [http-nio-8081-exec-6] <== Total: 1 [http-nio-8081-exec-6] SeckillVoucher(voucherId=13, stock=995, createTime=Fri Apr 05 19:30:37 CST 2024, beginTime=Fri Apr 05 14:00:00 CST 2024, endTime=Sat Apr 06 18:00:00 CST 2024, updateTime=Fri Apr 05 19:39:27 CST 2024) // 线程6进入锁方法 [http-nio-8081-exec-6] begin checkAndCreateVoucherOrder... voucherId = 13, userId = 1012 [http-nio-8081-exec-6] ==> Preparing: SELECT COUNT( * ) FROM tb_voucher_order WHERE (voucher_id = ? AND user_id = ?) [http-nio-8081-exec-6] ==> Parameters: 13(Long), 1012(Long) [http-nio-8081-exec-6] <== Total: 1 // 线程6查询发现订单已存在,不再继续往下执行 [http-nio-8081-exec-6] old order count = 1
查看此时的数据库,只有1条voucher_id=13的优惠券订单:
可见,加synchronized锁之后,只有一个线程可以进入checkAndCreateVoucherOrder()
方法,也就是只有一个线程可以顺利地创建订单。等锁释放后,其他线程会发现订单已创建,而直接返回错误信息。
通过加synchronized锁可以解决在单机情况下的“一人一单”安全问题,但是在集群模式下就不行了。 如下图:
集群模式下,由于们部署了多个tomcat,每个tomcat都有一个属于自己的jvm。假设在服务器A的tomcat内部,有两个线程,这两个线程由于使用的是同一份代码,它们的锁对象是同一个,是可以实现互斥的。
但是如果服务器B的tomcat内部,又有两个线程,它们的锁对象写的内容虽然和服务器A一样,但是由于是不同的jvm所以锁对象却不是同一个,所以线程3和线程4可以实现互斥,但是却无法和线程1和线程2实现互斥。
这就是集群环境下,synchronized锁失效的原因,在这种情况下,就需要使用分布式锁来解决这个问题。
分布式锁即满足分布式系统或集群模式下多进程可见并且互斥的锁。它的核心思想就是,让所有线程都使用同一把锁,从而让线程串行执行。 如图:
分布式锁一般需要满足以下条件:
常见的分布式锁有三种:
SETNX
方法,如果插入Key成功,则表示获得到了锁,其他线程则无法获得到锁。本案例使用Redis分布式锁。
如上图所示,利用Redis的SETNX
方法。当第一个线程进入时,Redis中没有"lock"这个Key,则SETNX
方法返回true,表示成功获取到了锁,该线程继续执行其他业务逻辑,最后释放锁。
在释放锁之前,如果有第二个线程进来,由于Redis中已经存在"lock"这个Key,所以SETNX
方法返回false,表示没有获取到锁,则等待一段时间后继续重试。
首先创建一个ILock接口,定义加锁和解锁的两个基本方法:
// com.star.redis.dzdp.utils.ILock public interface ILock { /** * 尝试获取锁 * @author hsgx * @since 2024/4/5 21:07 * @param timeout 超时时间 * @return boolean */ boolean tryLock(long timeout); /** * 释放锁 * @author hsgx * @since 2024/4/5 21:07 * @param * @return void */ void unlock(); }
然后创建一个SimpleRedisLock类实现ILock接口,重写基本方法:
// com.star.redis.dzdp.utils.SimpleRedisLock @Slf4j public class SimpleRedisLock implements ILock { private String key; private StringRedisTemplate stringRedisTemplate; public SimpleRedisLock(String key, StringRedisTemplate stringRedisTemplate) { this.key = key; this.stringRedisTemplate = stringRedisTemplate; } @Override public boolean tryLock(long timeout) { // 1.获取线程ID long threadId = Thread.currentThread().getId(); // 2.获取锁,并设置超时时间 Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent("lock:" + key, threadId + "", timeout, TimeUnit.SECONDS); log.info("set to Redis : Key = {}, Value = {}. set result = {}", "lock:" + key, threadId, flag); // 3.返回 return BooleanUtil.isTrue(flag); } @Override public void unlock() { // 1.释放锁 Boolean flag = stringRedisTemplate.delete("lock:" + key); log.info("del from to Redis : Key = {}. del result = {}", "lock:" + key, flag); } }
最后修改业务代码:
// com.star.redis.dzdp.service.impl.VoucherOrderServiceImpl @Override public BaseResult<Long> seckillVoucher(Long voucherId, Long userId) { log.info("开始秒杀下单...voucherId = {}, userId = {}", voucherId, userId); // 1.查询秒杀优惠券信息 SeckillVoucher seckillVoucher = seckillVoucherService.getById(voucherId); // 2.判断秒杀活动是否开启或结束 if(seckillVoucher == null) { // 秒杀活动不存在 return BaseResult.setFail("秒杀活动不存在!"); } else if(seckillVoucher.getBeginTime().after(new Date())) { // 秒杀活动未开始 log.info("beginTime = {}", seckillVoucher.getBeginTime()); return BaseResult.setFail("秒杀尚未开始!"); } else if(seckillVoucher.getEndTime().before(new Date())) { // 秒杀活动已结束 log.info("endTime = {}", seckillVoucher.getEndTime()); return BaseResult.setFail("秒杀已结束!"); } log.info("{}", seckillVoucher.toString()); // 3.判断库存是否充足 if(seckillVoucher.getStock() < 1) { // 库存不足 return BaseResult.setFail("库存不足,抢券失败!"); } // 创建锁对象 SimpleRedisLock simpleRedisLock = new SimpleRedisLock("voucher_order:" + userId, stringRedisTemplate); // 尝试获取锁 boolean lock = simpleRedisLock.tryLock(1200); // 加锁失败,则说明该用户已有一条线程 if(!lock) { return BaseResult.setFail("每个帐号只能抢购一张优惠券!"); } // 加锁成功,则执行业务代码 try { return checkAndCreateVoucherOrder(voucherId, userId); } finally { // 释放锁 simpleRedisLock.unlock(); } }
下面模拟有3个线程同时到达,其日志打印如下:
// 用户ID=1012的线程10 [http-nio-8081-exec-10] token from client => ccedd4ea-c73e-42cd-b9f2-3a637cbcca9b [http-nio-8081-exec-10] user from redis => {"id":1012,"phone":"18922102124","password":"","nickName":"18922102124","icon":"","createTime":1712041918000,"updateTime":1712041918000} [http-nio-8081-exec-10] 开始秒杀下单...voucherId = 14, userId = 1012 [http-nio-8081-exec-10] ==> Preparing: SELECT voucher_id,stock,create_time,begin_time,end_time,update_time FROM tb_seckill_voucher WHERE voucher_id=? // 用户ID=1012的线程1 [http-nio-8081-exec-1] token from client => ccedd4ea-c73e-42cd-b9f2-3a637cbcca9b [http-nio-8081-exec-10] ==> Parameters: 14(Long) // 用户ID=1012的线程9 [http-nio-8081-exec-9] token from client => ccedd4ea-c73e-42cd-b9f2-3a637cbcca9b [http-nio-8081-exec-9] user from redis => {"id":1012,"phone":"18922102124","password":"","nickName":"18922102124","icon":"","createTime":1712041918000,"updateTime":1712041918000} [http-nio-8081-exec-1] user from redis => {"id":1012,"phone":"18922102124","password":"","nickName":"18922102124","icon":"","createTime":1712041918000,"updateTime":1712041918000} [http-nio-8081-exec-10] <== Total: 1 [http-nio-8081-exec-10] SeckillVoucher(voucherId=14, stock=999, createTime=Fri Apr 05 19:36:15 CST 2024, beginTime=Fri Apr 05 14:00:00 CST 2024, endTime=Sat Apr 06 18:00:00 CST 2024, updateTime=Fri Apr 05 21:27:47 CST 2024) [http-nio-8081-exec-1] 开始秒杀下单...voucherId = 14, userId = 1012 // 线程10拿到了互斥锁 [http-nio-8081-exec-10] set to Redis : Key = lock:voucher_order:1012, Value = 42. set result = true [http-nio-8081-exec-9] 开始秒杀下单...voucherId = 14, userId = 1012 [http-nio-8081-exec-10] begin checkAndCreateVoucherOrder... voucherId = 14, userId = 1012 [http-nio-8081-exec-1] ==> Preparing: SELECT voucher_id,stock,create_time,begin_time,end_time,update_time FROM tb_seckill_voucher WHERE voucher_id=? [http-nio-8081-exec-9] ==> Preparing: SELECT voucher_id,stock,create_time,begin_time,end_time,update_time FROM tb_seckill_voucher WHERE voucher_id=? [http-nio-8081-exec-1] ==> Parameters: 14(Long) [http-nio-8081-exec-9] ==> Parameters: 14(Long) [http-nio-8081-exec-10] ==> Preparing: SELECT COUNT( * ) FROM tb_voucher_order WHERE (voucher_id = ? AND user_id = ?) [http-nio-8081-exec-10] ==> Parameters: 14(Long), 1012(Long) [http-nio-8081-exec-1] <== Total: 1 [http-nio-8081-exec-1] SeckillVoucher(voucherId=14, stock=999, createTime=Fri Apr 05 19:36:15 CST 2024, beginTime=Fri Apr 05 14:00:00 CST 2024, endTime=Sat Apr 06 18:00:00 CST 2024, updateTime=Fri Apr 05 21:27:47 CST 2024) [http-nio-8081-exec-10] <== Total: 1 [http-nio-8081-exec-9] <== Total: 1 [http-nio-8081-exec-10] old order count = 0 [http-nio-8081-exec-9] SeckillVoucher(voucherId=14, stock=999, createTime=Fri Apr 05 19:36:15 CST 2024, beginTime=Fri Apr 05 14:00:00 CST 2024, endTime=Sat Apr 06 18:00:00 CST 2024, updateTime=Fri Apr 05 21:27:47 CST 2024) // 线程1没有拿到互斥锁 [http-nio-8081-exec-1] set to Redis : Key = lock:voucher_order:1012, Value = 33. set result = false // 线程9没有拿到互斥锁 [http-nio-8081-exec-9] set to Redis : Key = lock:voucher_order:1012, Value = 41. set result = false // 最终只有线程10进行扣减库存和创建订单 [http-nio-8081-exec-10] ==> Preparing: UPDATE tb_seckill_voucher SET stock = stock - 1 WHERE (voucher_id = ? AND stock > ?) [http-nio-8081-exec-10] ==> Parameters: 14(Long), 0(Integer) [http-nio-8081-exec-10] <== Updates: 1 [http-nio-8081-exec-10] update result = true [http-nio-8081-exec-10] get orderId = 7354374484939243521 [http-nio-8081-exec-10] ==> Preparing: INSERT INTO tb_voucher_order ( id, user_id, voucher_id, pay_time ) VALUES ( ?, ?, ?, ? ) [http-nio-8081-exec-10] ==> Parameters: 7354374484939243521(Long), 1012(Long), 14(Long), 2024-04-05 21:29:05.933(Timestamp) [http-nio-8081-exec-10] <== Updates: 1 [http-nio-8081-exec-10] del from to Redis : Key = lock:voucher_order:1012. del result = true
可见,三条线程只有一条线程可以拿到锁,并执行扣减库存和创建订单的逻辑,其余两条线程均拿不到锁,也就无法扣减库存和创建订单。
…
本节完,更多内容请查阅分类专栏:Redis从入门到精通
感兴趣的读者还可以查阅我的另外几个专栏:
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。