Redis解决秒杀微服务抢购代金券超卖和同一个用户多次抢购
作者:共饮一杯无
之前的博客,我通过 传统的数据库方式实现秒杀按照正常逻辑来走,通过压力测试发现会有超卖合同一用户可以多次抢购同一代金券的问题。本文我将讲述通过redis来解决超卖和同一用户多次抢购问题。
超卖和同一用户多次抢购问题分析
/** * 抢购代金券 * * @param voucherId 代金券 ID * @param accessToken 登录token * @Para path 访问路径 */ public ResultInfo doSeckill(Integer voucherId, String accessToken, String path) { // 基本参数校验 AssertUtil.isTrue(voucherId == null || voucherId < 0, "请选择需要抢购的代金券"); AssertUtil.isNotEmpty(accessToken, "请登录"); // 判断此代金券是否加入抢购 SeckillVouchers seckillVouchers = seckillVouchersMapper.selectVoucher(voucherId); AssertUtil.isTrue(seckillVouchers == null, "该代金券并未有抢购活动"); // 判断是否有效 AssertUtil.isTrue(seckillVouchers.getIsValid() == 0, "该活动已结束"); // 判断是否开始、结束 Date now = new Date(); AssertUtil.isTrue(now.before(seckillVouchers.getStartTime()), "该抢购还未开始"); AssertUtil.isTrue(now.after(seckillVouchers.getEndTime()), "该抢购已结束"); // 判断是否卖完 AssertUtil.isTrue(seckillVouchers.getAmount() < 1, "该券已经卖完了"); // 获取登录用户信息 String url = oauthServerName + "user/me?access_token={accessToken}"; ResultInfo resultInfo = restTemplate.getForObject(url, ResultInfo.class, accessToken); if (resultInfo.getCode() != ApiConstant.SUCCESS_CODE) { resultInfo.setPath(path); return resultInfo; } // 这里的data是一个LinkedHashMap,SignInDinerInfo SignInDinerInfo dinerInfo = BeanUtil.fillBeanWithMap((LinkedHashMap) resultInfo.getData(), new SignInDinerInfo(), false); // 判断登录用户是否已抢到(一个用户针对这次活动只能买一次) VoucherOrders order = voucherOrdersMapper.findDinerOrder(dinerInfo.getId(), seckillVouchers.getId()); AssertUtil.isTrue(order != null, "该用户已抢到该代金券,无需再抢"); // 扣库存 int count = seckillVouchersMapper.stockDecrease(seckillVouchers.getId()); AssertUtil.isTrue(count == 0, "该券已经卖完了"); // 下单 VoucherOrders voucherOrders = new VoucherOrders(); voucherOrders.setFkDinerId(dinerInfo.getId()); voucherOrders.setFkSeckillId(seckillVouchers.getId()); voucherOrders.setFkVoucherId(seckillVouchers.getFkVoucherId()); String orderNo = IdUtil.getSnowflake(1, 1).nextIdStr(); voucherOrders.setOrderNo(orderNo); voucherOrders.setOrderType(1); voucherOrders.setStatus(0); count = voucherOrdersMapper.save(voucherOrders); AssertUtil.isTrue(count == 0, "用户抢购失败"); return ResultInfoUtil.buildSuccess(path, "抢购成功"); }
高并发环境下会导致上图的判断出现错误。在高并发环境下,会有多个线程拿到的库存值都大于0,实际的继续往下执行的线程会高于实际的库存值,继续执行会导致卖出的订单量超过库存本身的数量,导致库存超卖。
同理同一用户多次发起,同时到达这一步也会错判,在还没有获取到最新的存储结果时,都会判定成是未抢购过,导致同一用户可以重复抢购问题。
解决库存超卖问题
添加相关枚举
在redis键的枚举类中添加如下枚举:
分布式锁的key来约束同一用户只能抢购一次。
添加RedisTemplate配置类
/** * RedisTemplate配置类 * @author zjq */ @Configuration public class RedisTemplateConfiguration { /** * redisTemplate 序列化使用的jdkSerializeable, 存储二进制字节码, 所以自定义序列化类 * * @param redisConnectionFactory * @return */ @Bean public RedisTemplate<Object, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) { RedisTemplate<Object, Object> redisTemplate = new RedisTemplate<>(); redisTemplate.setConnectionFactory(redisConnectionFactory); // 使用Jackson2JsonRedisSerialize 替换默认序列化 Jackson2JsonRedisSerializer jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer(Object.class); ObjectMapper objectMapper = new ObjectMapper(); objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY); jackson2JsonRedisSerializer.setObjectMapper(objectMapper); // 设置key和value的序列化规则 redisTemplate.setValueSerializer(jackson2JsonRedisSerializer); redisTemplate.setKeySerializer(new StringRedisSerializer()); redisTemplate.setHashKeySerializer(new StringRedisSerializer()); redisTemplate.setHashValueSerializer(jackson2JsonRedisSerializer); redisTemplate.afterPropertiesSet(); return redisTemplate; } @Bean public DefaultRedisScript<Long> stockScript() { DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>(); //放在和application.yml 同层目录下 redisScript.setLocation(new ClassPathResource("stock.lua")); redisScript.setResultType(Long.class); return redisScript; } }
改造原先添加代金券逻辑
原先添加代金券的逻辑如下:
现在需要把跟数据库交互的部分改成和redis交互,改造后代码如下:
// 采用 Redis 实现 String key = RedisKeyConstant.seckill_vouchers.getKey() + seckillVouchers.getFkVoucherId(); // 验证 Redis 中是否已经存在该券的秒杀活动 Map<String, Object> map = redisTemplate.opsForHash().entries(key); AssertUtil.isTrue(!map.isEmpty() && (int) map.get("amount") > 0, "该券已经拥有了抢购活动"); // 插入 Redis seckillVouchers.setIsValid(1); seckillVouchers.setCreateDate(now); seckillVouchers.setUpdateDate(now); redisTemplate.opsForHash().putAll(key, BeanUtil.beanToMap(seckillVouchers));
执行测试,新建秒杀代金券活动存储到Redis中:
可以看到数据已经存储到redis中。
改造下单逻辑
调整数据库相关为redis
原先关系型数据库下单逻辑:
/** * 抢购代金券 * * @param voucherId 代金券 ID * @param accessToken 登录token * @Para path 访问路径 */ public ResultInfo doSeckill(Integer voucherId, String accessToken, String path) { // 基本参数校验 AssertUtil.isTrue(voucherId == null || voucherId < 0, "请选择需要抢购的代金券"); AssertUtil.isNotEmpty(accessToken, "请登录"); // 判断此代金券是否加入抢购 SeckillVouchers seckillVouchers = seckillVouchersMapper.selectVoucher(voucherId); AssertUtil.isTrue(seckillVouchers == null, "该代金券并未有抢购活动"); // 判断是否有效 AssertUtil.isTrue(seckillVouchers.getIsValid() == 0, "该活动已结束"); // 判断是否开始、结束 Date now = new Date(); AssertUtil.isTrue(now.before(seckillVouchers.getStartTime()), "该抢购还未开始"); AssertUtil.isTrue(now.after(seckillVouchers.getEndTime()), "该抢购已结束"); // 判断是否卖完 AssertUtil.isTrue(seckillVouchers.getAmount() < 1, "该券已经卖完了"); // 获取登录用户信息 String url = oauthServerName + "user/me?access_token={accessToken}"; ResultInfo resultInfo = restTemplate.getForObject(url, ResultInfo.class, accessToken); if (resultInfo.getCode() != ApiConstant.SUCCESS_CODE) { resultInfo.setPath(path); return resultInfo; } // 这里的data是一个LinkedHashMap,SignInDinerInfo SignInDinerInfo dinerInfo = BeanUtil.fillBeanWithMap((LinkedHashMap) resultInfo.getData(), new SignInDinerInfo(), false); // 判断登录用户是否已抢到(一个用户针对这次活动只能买一次) VoucherOrders order = voucherOrdersMapper.findDinerOrder(dinerInfo.getId(), seckillVouchers.getId()); AssertUtil.isTrue(order != null, "该用户已抢到该代金券,无需再抢"); // 扣库存 int count = seckillVouchersMapper.stockDecrease(seckillVouchers.getId()); AssertUtil.isTrue(count == 0, "该券已经卖完了"); // 下单 VoucherOrders voucherOrders = new VoucherOrders(); voucherOrders.setFkDinerId(dinerInfo.getId()); voucherOrders.setFkSeckillId(seckillVouchers.getId()); voucherOrders.setFkVoucherId(seckillVouchers.getFkVoucherId()); String orderNo = IdUtil.getSnowflake(1, 1).nextIdStr(); voucherOrders.setOrderNo(orderNo); voucherOrders.setOrderType(1); voucherOrders.setStatus(0); count = voucherOrdersMapper.save(voucherOrders); AssertUtil.isTrue(count == 0, "用户抢购失败"); return ResultInfoUtil.buildSuccess(path, "抢购成功"); }
查询、扣库存和下单逻辑调整成Redis:
// 扣库存 redis没有自减方法,数值传负数表示自减 long count = redisTemplate.opsForHash().increment(key, "amount", -1); AssertUtil.isTrue(count <= 0, "该券已经卖完了");
订单信息还是保存到数据库中
// 下单 VoucherOrders voucherOrders = new VoucherOrders(); voucherOrders.setFkDinerId(dinerInfo.getId()); //redis中不需要维护该外键信息 // voucherOrders.setFkSeckillId(seckillVouchers.getId()); voucherOrders.setFkVoucherId(seckillVouchers.getFkVoucherId()); String orderNo = IdUtil.getSnowflake(1, 1).nextIdStr(); voucherOrders.setOrderNo(orderNo); voucherOrders.setOrderType(1); voucherOrders.setStatus(0); count = voucherOrdersMapper.save(voucherOrders);
JMeter发起3000个线程,2000个用户并发请求,查看库存情况,目前还是超卖的:
订单的数量是正确的:
因为这一步判定是单线程的
long count = redisTemplate.opsForHash().increment(key, "amount", -1); AssertUtil.isTrue(count <= 0, "该券已经卖完了");
是不是先下单然后再扣库存就可以了?当然不行,如果上面位置调整下会导致库存数量不对,订单数量也不对😰😰😰。
我们继续在先下单后扣库存的方法上添加一个事务:@Transactional(rollbackFor = Exception.class)。
执行发现订单数量正常了,库存还是负数:
为什么会有这个问题呢,因为redisTemplate.opsForHash().increment(key, "amount", -1)
这一步操作在redis中实际执行的是先查询再减少的操作,在高并发场景下会有问题。我们需要保证这两步的原子性。
Redis + Lua 解决超卖问题
在yml配置文件同级目录添加lua脚本,脚本内容如下:
if (redis.call('hexists', KEYS[1], KEYS[2]) == 1) then local stock = tonumber(redis.call('hget', KEYS[1], KEYS[2])); if (stock > 0) then redis.call('hincrby', KEYS[1], KEYS[2], -1); return stock; end; return 0; end;
在RedisTemplate配置类中添加如下配置bean并注入lua脚本:
@Bean public DefaultRedisScript<Long> stockScript() { DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>(); //放在和application.yml 同层目录下 redisScript.setLocation(new ClassPathResource("stock.lua")); redisScript.setResultType(Long.class); return redisScript; }
扣库存逻辑调整如下:
// 采用 Redis + Lua 解决超卖问题 // 扣库存 List<String> keys = new ArrayList<>(); keys.add(key); keys.add("amount"); Long amount = (Long) redisTemplate.execute(defaultRedisScript, keys); AssertUtil.isTrue(amount == null || amount < 1, "该券已经卖完了");
重置数据后执行JMeter执行5000个线程,两千个用户并发下单测试,结果如下:
库存0,订单100,超卖问题解决。
JMeter的结果值有中文乱码,进入JMeter安装位置,调整jmeter.properties文件中的sampleresult.default.encoding
为UTF-8。重启JMeter再测试不再乱码。
解决同一用户多次抢购问题
问题描述
用JMeter测试同一用户并发抢购:
查看数据库发现同一用户下单了多次:
Redisson 分布式锁解决同一用户多次下单
什么是Redisson
上图就是redission官方网站首页。
首页可以看出来,Redisson可以实现很多东西,在Redis的基础上,Redisson做了超多的封装,我们看一下,例如说
Spring Cache,TomcatSession,Spring Session,可排序的Set,还有呢Sortedsort,下面还有各种队列,包括这种双端
队列,还有map,这些是数据结构,下面就是各种锁,读写锁,这里面的锁还包含,可重入锁,还有CountDownLantch,这个是在多线程的时候使用的,比如说我启动很多个线程,去执行某个任务,然后把任务进行切分,都完成之后有一个等待,等待所有线程都达到这里之后,在一起往下走,把异步再变成同步,下边是一些线程池,还有订阅的各种功能,ScheduleService来做调度的一个任务,所以Redisson是非常强大的,然后我们在右上角有一个documentation,我们可以打开它,Redisson官方也提供了中文文档:https://github.com/redisson/redisson/wiki/目录。
问题解决
同一用户可以多次抢购本质上是一个用户在抢购的某个商品的时候没有加锁,导致同一用户的多个线程同时进入抢购,接下来通过Redisson分布式锁来解决同一用户多次下单的问题。
锁的对象为用户id和代金券活动id,表示同一用户只能抢购一次某活动。改造后代码如下:
/** * 抢购代金券 * * @param voucherId 代金券 ID * @param accessToken 登录token * @Para path 访问路径 */ @Transactional(rollbackFor = Exception.class) public ResultInfo doSeckill(Integer voucherId, String accessToken, String path) { // 基本参数校验 AssertUtil.isTrue(voucherId == null || voucherId < 0, "请选择需要抢购的代金券"); AssertUtil.isNotEmpty(accessToken, "请登录"); // 采用 Redis String key = RedisKeyConstant.seckill_vouchers.getKey() + voucherId; Map<String, Object> map = redisTemplate.opsForHash().entries(key); SeckillVouchers seckillVouchers = BeanUtil.mapToBean(map, SeckillVouchers.class, true, null); // 判断是否开始、结束 Date now = new Date(); AssertUtil.isTrue(now.before(seckillVouchers.getStartTime()), "该抢购还未开始"); AssertUtil.isTrue(now.after(seckillVouchers.getEndTime()), "该抢购已结束"); // 判断是否卖完 AssertUtil.isTrue(seckillVouchers.getAmount() < 1, "该券已经卖完了"); // 获取登录用户信息 String url = oauthServerName + "user/me?access_token={accessToken}"; ResultInfo resultInfo = restTemplate.getForObject(url, ResultInfo.class, accessToken); if (resultInfo.getCode() != ApiConstant.SUCCESS_CODE) { resultInfo.setPath(path); return resultInfo; } // 这里的data是一个LinkedHashMap,SignInDinerInfo SignInDinerInfo dinerInfo = BeanUtil.fillBeanWithMap((LinkedHashMap) resultInfo.getData(), new SignInDinerInfo(), false); // 判断登录用户是否已抢到(一个用户针对这次活动只能买一次) VoucherOrders order = voucherOrdersMapper.findDinerOrder(dinerInfo.getId(), seckillVouchers.getId()); AssertUtil.isTrue(order != null, "该用户已抢到该代金券,无需再抢"); // 使用 Redis 锁一个账号只能购买一次 String lockName = RedisKeyConstant.lock_key.getKey() + dinerInfo.getId() + ":" + voucherId; long expireTime = seckillVouchers.getEndTime().getTime() - now.getTime(); // Redisson 分布式锁 RLock lock = redissonClient.getLock(lockName); try { // Redisson 分布式锁处理 boolean isLocked = lock.tryLock(expireTime, TimeUnit.MILLISECONDS); if (isLocked) { // 下单 VoucherOrders voucherOrders = new VoucherOrders(); voucherOrders.setFkDinerId(dinerInfo.getId()); //redis中不需要维护该外键信息 // voucherOrders.setFkSeckillId(seckillVouchers.getId()); voucherOrders.setFkVoucherId(seckillVouchers.getFkVoucherId()); String orderNo = IdUtil.getSnowflake(1, 1).nextIdStr(); voucherOrders.setOrderNo(orderNo); voucherOrders.setOrderType(1); voucherOrders.setStatus(0); long count = voucherOrdersMapper.save(voucherOrders); AssertUtil.isTrue(count == 0, "用户抢购失败"); // 采用 Redis + Lua 解决超卖问题 // 扣库存 List<String> keys = new ArrayList<>(); keys.add(key); keys.add("amount"); Long amount = (Long) redisTemplate.execute(defaultRedisScript, keys); AssertUtil.isTrue(amount == null || amount < 1, "该券已经卖完了"); } } catch (Exception e) { // 手动回滚事务 TransactionAspectSupport.currentTransactionStatus().setRollbackOnly(); // Redisson 解锁 lock.unlock(); if (e instanceof ParameterException) { return ResultInfoUtil.buildError(0, "该券已经卖完了", path); } } return ResultInfoUtil.buildSuccess(path, "抢购成功"); }
JMeter测试验证,同一用户并发请求某一活动,只能下单一次:
库存剩99,订单1条,完美。
到此这篇关于Redis解决秒杀微服务抢购代金券超卖和同一个用户多次抢购的文章就介绍到这了,更多相关Redis超卖和同一个用户多次抢购内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!