温馨提示建议在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锁失效了这两次请求都能成功获取锁从而出现一人多单的并发安全问题。