【Redis实战篇】秒杀实现及优化方案(以优惠券秒杀为例)
温馨提示建议在PC端浏览~优惠券秒杀全局唯一ID每个店铺都可以发布优惠券当用户抢购时就会生成订单并保存到tb_voucher_order这张表中而订单表如果使用数据库自增ID就存在一些问题id的规律性太明显受单表数据量的限制全局ID生成器全局ID生成器是一种在分布式系统下用来生成全局唯一ID的工具一般要满足下列特性唯一性高可用高性能递增性安全性由这些特性我们可以联想到Redis的String类型其中非普通字符串类型的数据可以通过INCRBY做自增操作当然实现全局唯一ID不止Redis这一种方案。但为了增加ID的安全性我们可以不直接使用Redis自增的数值而是拼接一些其它信息ID的组成部分符号位1bit永远为0时间戳31bit以秒为单位可以使用69年序列号32bit秒内的计数器支持每秒产生2^32个不同ID全局唯一ID生成策略后三者在企业实际开发中使用较多UUID使用较少不满足自增返回值是字符串类型Redis自增snowflake算法雪花算法数据库自增不是简单的插入数据时ID自增而是单独维护一张自增表多个表的数据共用这张自增表从而保证全局ID的唯一性Redis自增ID策略每天一个key方便统计订单量ID构造是时间戳计数器Redis自增ID策略实现示例ComponentpublicclassRedisIDWorker{privatestaticfinallongBASE_TIMESTAMP1767225600L;//基本时间戳从2026.1.1 00:00:00开始privatestaticfinallongCOUNT_BITS32;//序列号位数privateStringRedisTemplatestringRedisTemplate;publicRedisIDWorker(StringRedisTemplatestringRedisTemplate){this.stringRedisTemplatestringRedisTemplate;}publiclongnextId(Stringprefix){LocalDateTimenowLocalDateTime.now();//获取时间戳longnowSecondnow.toEpochSecond(ZoneOffset.UTC);longtimestampnowSecond-BASE_TIMESTAMP;//获取当前日期年月日Stringdatenow.format(DateTimeFormatter.ofPattern(yyyy:MM:dd));//生成序列号(自增长)若没有这个key则会自动创建并从0开始自增longincrementstringRedisTemplate.opsForValue().increment(icr:prefix:date);returntimestampCOUNT_BITS|increment;}}测试代码如下ResourceprivateRedisIDWorkerredisIDWorker;ExecutorServiceexecutorExecutors.newFixedThreadPool(500);TestpublicvoidtestIdWorker()throwsInterruptedException{CountDownLatchlatchnewCountDownLatch(300);//闭锁计数器计数器为0时所有线程开始执行Java8新特性Runnabletask()-{for(inti0;i100;i){longidredisIDWorker.nextId(order);System.out.println(id id);}latch.countDown();//减1};longbeginSystem.currentTimeMillis();for(inti0;i300;i){executor.execute(task);}latch.await();//等待闭锁为0longendSystem.currentTimeMillis();System.out.println(time (end-begin));}实现优惠券秒杀下单每个店铺都可以发布优惠券分为平价券和特价券。平价券可以任意购买而特价券需要秒杀抢购表关系如下tb_voucher优惠券的基本信息优惠金额、使用规则等。tb_seckill_voucher优惠券的库存、开始抢购时间结束抢购时间。特价优惠券才需要填写这些信息。优惠券秒杀的下单功能流程图下单时需要判断两点秒杀是否开始或结束如果尚未开始或已经结束则无法下单。库存是否充足不足则无法下单。超卖问题超卖问题是典型的多线程安全问题针对这一问题的常见解决方案就是加锁悲观锁认为线程安全问题一定会发生因此在操作数据之前先获取锁确保线程串行执行。例如Synchronized、Lock都属于悲观锁。乐观锁认为线程安全问题不一定会发生因此不加锁只是在更新数据时去判断有没有其它线程对数据做了修改。如果没有修改则认为是安全的自己才更新数据。如果已经被其它线程修改说明发生了安全问题此时可以重试或异常。乐观锁更新数据时使用乐观锁的关键是判断之前查询得到的数据是否有被修改过常见的方式有两种版本号法初始库存和版本号都为1CAS法初始库存为1补充但是这种方法存在请求成功率低的问题。例如在库存修改之前有多个线程查询了最初的库存值然后其中某一个线程修改了库存剩下的线程在更新时发现库存值发生了变化所以都会返回错误信息。解决办法此场景针对库存而言不需要保证库存与之前查到的完全一致只需要保证库存大于0即可小结超卖这样的线程安全问题解决方案有哪些1、悲观锁添加同步锁让线程串行执行优点简单粗暴缺点性能一般2、乐观锁不加锁在更新时判断是否有其它线程在修改优点性能好缺点存在成功率低的问题—人—单需求修改秒杀业务要求同一个优惠券一个用户只能下一单。流程图关键代码示例// pom.xml!--aspectj--dependencygroupIdorg.aspectj/groupIdartifactIdaspectjweaver/artifactId/dependency// HmDianPingApplication在启动类上添加下面的注解EnableAspectJAutoProxy(exposeProxytrue)// 暴露代理对象// VoucherOrderServiceImplLonguserIdUserHolder.getUser().getId();// 一人一单// 必须在createOrder方法完成之后才能释放锁这样才能保证事务已经提交新增的订单才会被插入数据库中// userId.toString()虽然会将userId转换成字符串但是转换成字符串时每次都会创建新的对象即使内容一样这样就不能确保同一个用户上的是同一把锁// .intern()会创建一个字符串常量池如果字符串常量池中已经存在该字符串那么就会返回该字符串否则就会创建一个新的字符串并放入字符串常量池中这样就能确保锁的是同一个用户synchronized(userId.toString().intern()){//获取当前代理对象IVoucherOrderServicecurrentProxy(IVoucherOrderService)AopContext.currentProxy();// 如果直接调用createOrder方法相当于this.createOrder()即调用的是目标对象的createOrder方法而不是代理对象的但是由于事务是spring拿着代理对象做的因此事务会失效// 因此需要自己手动地获取代理对象调用createOrder方法事务才能生效returncurrentProxy.createOrder(voucherId);}TransactionalpublicResultcreateOrder(LongvoucherId){//一人一单LonguserIdUserHolder.getUser().getId();// 判断该用户是否已经下过单IntegerorderCountquery().eq(user_id,userId).eq(voucher_id,voucherId).count();if(orderCount0){returnResult.fail(该用户已经下过单);}// 扣减库存,乐观锁在更新库存时判断库存是否大于0booleansuccessseckillVoucherService.update().setSql(stock stock - 1).eq(voucher_id,voucherId).gt(stock,0).update();if(!success){log.info(库存不足);returnResult.fail(库存不足);}//生成订单号longorderIdredisIDWorker.nextId(order);log.info(成功扣减库存订单号为{},orderId);// 创建订单VoucherOrdervoucherOrdernewVoucherOrder();voucherOrder.setVoucherId(voucherId);voucherOrder.setUserId(userId);voucherOrder.setId(orderId);// 保存订单save(voucherOrder);// 返回订单号returnResult.ok(orderId);}关键点1、加什么锁由于乐观锁用于更新数据而当前需求是插入数据所以无法使用乐观锁最终选择使用悲观锁。2、锁加在哪要实现一人一单就要保证在事务提交数据库已经插入新订单数据之后才能释放锁所以最终选择将操作数据库的那部分代码单独抽出成一个方法createOrder在这个方法上加上事务注解最后在上层调用这个方法的地方加锁这样就可以保证事务提交之后才释放锁。3、锁的对象是谁参考上面VoucherOrderServiceImpl中synchronized代码块上方的注释。4、事务失效参考上面VoucherOrderServiceImpl中synchronized代码块中的注释。一人一单的并发安全问题通过加锁可以解决在单机情况下的一人一单安全问题但是在集群模式下就不行了。模拟集群模式1、我们将服务启动两份Idea提供的功能端口分别为8081和8082步骤说明使用ctrld复制一份启动项在编辑配置中加入虚拟机选项并设置端口号2、然后修改nginx的conf目录下的nginx.conf文件配置反向代理和负载均衡更改nginx的配置文件后需要在命令行窗口中执行以下指令来重新加载配置文件nginx.exe -s reload最后重启一下nginx。3、现在用户请求会在这两个节点上负载均衡再次测试下是否存在线程安全问题。一人一单的并发安全问题分析当项目以集群的方式部署在多台服务器上时每台服务器都是一个单独的JVM每个JVM内部都拥有自己的锁监视器所以当同一个用户的两次相同请求被发送到不同的两台服务器时synchronized锁失效了这两次请求都能成功获取锁从而出现一人多单的并发安全问题。解决一人一单并发安全问题需要引入分布式锁关于分布式锁请参考另一篇文章https://blog.csdn.net/YL20040426/article/details/161523601?spm1001.2014.3001.5501Redis优化秒杀优化思路分析思考既然要在Redis中判断是否有购买资格判断秒杀库存、校验一人一单那么Redis中肯定是要存储对应的库存信息和用户信息的那么我们选择什么数据结构来存储这些信息呢对于库存信息只是一个单个的数值我们可以直接使用String结构来存储也方便预减库存而对于用户信息我们可以使用set结构来存储用户id每次校验时只要判断当前用户id是否存在就可以知道这个用户是否购买过当前这个秒杀券。如下图优化后的秒杀业务流程图改进秒杀业务提高并发性能需求1、新增秒杀优惠券的同时将优惠券信息保存到Redis中。2、基于Lua脚本判断秒杀库存、一人一单决定用户是否抢购成功。3、如果抢购成功将优惠券id和用户id封装后存入阻塞队列。4、开启线程任务不断从阻塞队列中获取信息实现异步下单功能。代码实现seckill.lua-- 准备ARGVlocalvoucherIdARGV[1]localuserIdARGV[2]-- 准备KEYSLua脚本中用..做拼接localstockKeyseckill:stock:..voucherIdlocalorderKeyseckill:order:..voucherId-- 判断库存if(tonumber(redis.call(get,stockKey))0)then-- 库存不足返回1return1end-- 判断用户是否重复下单if(redis.call(sismember,orderKey,userId)1)then-- 用户重复下单返回2return2end-- 用户有购买资格扣减库存加入集合返回0redis.call(incrby,stockKey,-1)redis.call(sadd,orderKey,userId)return0VoucherOrderServiceImpl.javaSlf4jServicepublicclassVoucherOrderServiceImplextendsServiceImplVoucherOrderMapper,VoucherOrderimplementsIVoucherOrderService{ResourceprivateISeckillVoucherServiceseckillVoucherService;ResourceprivateRedisIDWorkerredisIDWorker;ResourceprivateStringRedisTemplatestringRedisTemplate;ResourceprivateRedissonClientredissonClient;privatestaticfinalDefaultRedisScriptLongSECKILL_SCRIPT;static{SECKILL_SCRIPTnewDefaultRedisScript();SECKILL_SCRIPT.setLocation(newClassPathResource(seckill.lua));SECKILL_SCRIPT.setResultType(Long.class);}privateIVoucherOrderServicecurrentProxy;// 阻塞队列privateBlockingQueueVoucherOrderorderTasksnewArrayBlockingQueue(1024*1024);// 线程池privatestaticfinalExecutorServiceSECKILL_ORDER_EXECUTORExecutors.newSingleThreadExecutor();PostConstruct// 标记在某个方法上表示该方法在Bean的依赖注入完成后自动执行且只会执行一次privatevoidinit(){// 开启线程任务不断从阻塞队列获取信息实现异步下单SECKILL_ORDER_EXECUTOR.submit(newVoucherOrderHandler());}// 线程任务privateclassVoucherOrderHandlerimplementsRunnable{Overridepublicvoidrun(){while(true){try{// 获取队列中的订单信息VoucherOrdervoucherOrderorderTasks.poll();if(voucherOrder!null){// 处理订单handleVoucherOrder(voucherOrder);}}catch(Exceptione){log.info(处理订单异常);}}}}/** * 秒杀券下单 * param voucherId * return */OverridepublicResultseckillVoucher(LongvoucherId){// 查询秒杀券信息SeckillVoucherseckillVoucherseckillVoucherService.getById(voucherId);// 判断是否在秒杀时间段内if(LocalDateTime.now().isBefore(seckillVoucher.getBeginTime())){returnResult.fail(秒杀未开始);}if(LocalDateTime.now().isAfter(seckillVoucher.getEndTime())){returnResult.fail(秒杀已结束);}//获取当前用户idLonguserIdUserHolder.getUser().getId();// 执行Lua脚本获取结果LongresultstringRedisTemplate.execute(SECKILL_SCRIPT,Collections.emptyList(),voucherId.toString(),userId.toString());intresresult.intValue();// 判断Lua脚本执行结果0-成功 1-库存不足 2-重复下单if(res!0){returnResult.fail(res1?库存不足:不允许重复下单);}longorderIdredisIDWorker.nextId(order);// 获取当前代理对象currentProxy(IVoucherOrderService)AopContext.currentProxy();// 封装优惠券id、用户id订单id并加入阻塞队列// 创建订单VoucherOrdervoucherOrdernewVoucherOrder();voucherOrder.setVoucherId(voucherId);voucherOrder.setUserId(userId);voucherOrder.setId(orderId);// 将订单加入阻塞队列orderTasks.add(voucherOrder);// 返回订单idreturnResult.ok(orderId);}publicvoidhandleVoucherOrder(VoucherOrdervoucherOrder){// 创建锁RLocklockredissonClient.getLock(lock:order:voucherOrder.getUserId());// 尝试获取锁booleanisLocklock.tryLock();if(!isLock){//获取锁失败说明该用户已经成功下单过当前请求属于非法请求直接返回错误信息log.info(不允许重复下单);return;}try{currentProxy.createOrder(voucherOrder);}catch(IllegalStateExceptione){log.info(处理订单异常);}finally{//释放锁lock.unlock();}}TransactionalpublicvoidcreateOrder(VoucherOrdervoucherOrder){//获取用户idLonguserIdvoucherOrder.getUserId();//获取优惠券idLongvoucherIdvoucherOrder.getVoucherId();//一人一单// 判断该用户是否已经下过单IntegerorderCountquery().eq(user_id,userId).eq(voucher_id,voucherId).count();if(orderCount0){log.info(该用户已经下过单);return;}// 扣减库存,乐观锁在更新库存时判断库存是否大于0booleansuccessseckillVoucherService.update().setSql(stock stock - 1).eq(voucher_id,voucherId).gt(stock,0).update();if(!success){log.info(库存不足);return;}// 保存订单log.info(成功扣减库存订单号为{},voucherOrder.getId());save(voucherOrder);}}PostConstruct核心作用标记在某个方法上表示该方法在 Bean 的依赖注入完成后自动执行且只会执行一次。常用于初始化操作如加载缓存、初始化数据、检查必要配置等。执行时机1、构造方法执行完毕。2、依赖注入完成Autowired、Resource 等属性已赋值。3、Bean 完全初始化前在 PostConstruct 之后InitializingBean.afterPropertiesSet() 或自定义 init-method 之前执行。注意事项方法必须无参、无返回值void。方法不能抛出已检查异常运行时异常可以但会导致 Bean 初始化失败。一个类中可以有多个 PostConstruct 方法但执行顺序不确定不推荐。如果 Bean 是原型prototype作用域每次获取都会调用很少这样用。需要确保项目中包含 javax.annotation-api 依赖JDK 9 需要手动添加。小结秒杀业务的优化思路是什么1、先利用Redis完成库存余量、一人一单判断完成抢单业务。2、再将下单业务放入阻塞队列利用独立线程异步下单。基于阻塞队列的异步秒杀存在哪些问题内存限制问题现在我们使用的是JDK里面的阻塞队列而这个阻塞队列使用的是JVM的内存若不加以限制在高并发情况下可能有无数订单对象需要创建并放入阻塞队列在将来可能出现内存溢出问题。所以我们在创建阻塞队列时设置了队列的长度但若阻塞队列存满了而此时还有新的订单需要放入阻塞队列的话就放不进去了所以就存在内存限制问题。数据安全问题第一我们现在都是基于内存中存储这些订单数据若服务宕机内存中的数据都会丢失就会导致用户那边显示成功下单了但却查不到订单信息的情况第二若子线程从阻塞队列中取出一个订单去处理但是在处理的过程中出现了问题没能完成订单的处理但由于此订单已经从阻塞队列中移除最终这个订单数据就丢失了。解决内存限制问题和数据安全问题的方法使用消息队列。课程中讲解的是Redis消息队列由于不常使用故直接跳过之后去学习RabbitMQ 或 Kafaka 等其他消息中间件