Redis学习日记(四) Redis实战篇优惠劵秒杀1.全局唯一ID每个店铺都可以发布优惠券当用户抢购时就会生成订单并保存到tb_voucher_order这张表中而订单表如果使用数据库自增ID就存在一些问题1id的规律性太明显2受单表数据量的限制1场景分析场景分析一如果我们的id具有太明显的规则用户或者说商业对手很容易猜测出来我们的一些敏感信息比如商城在一天时间内卖出了多少单这明显不合适。场景分析二随着我们商城规模越来越大mysql的单表的容量不宜超过500W数据量过大之后我们要进行拆库拆表但拆分表了之后他们从逻辑上讲他们是同一张表所以他们的id是不能一样的 于是乎我们需要保证id的唯一性。2全局ID生成器是一种在分布式系统下用来生成全局唯一ID的工具一般要满足下列特性为了增加ID的安全性我们可以不直接使用Redis自增的数值而是拼接一些其它信息ID的组成部分符号位1bit永远为0时间戳31bit以秒为单位可以使用69年序列号32bit秒内的计数器支持每秒产生2^32个不同ID2.Redis实现全局唯一Id1代码实现Component public class RedisIdWorker { /** * 开始时间戳 */ private static final long BEGIN_TIMESTAMP 1640995200L; /** * 序列号的位数 */ private static final int COUNT_BITS 32; ​ private StringRedisTemplate stringRedisTemplate; ​ public RedisIdWorker(StringRedisTemplate stringRedisTemplate) { this.stringRedisTemplate stringRedisTemplate; } ​ public long nextId(String keyPrefix) { // 1.生成时间戳 LocalDateTime now LocalDateTime.now(); long nowSecond now.toEpochSecond(ZoneOffset.UTC); long timestamp nowSecond - BEGIN_TIMESTAMP; ​ // 2.生成序列号 // 2.1.获取当前日期精确到天 String date now.format(DateTimeFormatter.ofPattern(yyyy:MM:dd)); // 2.2.自增长 long count stringRedisTemplate.opsForValue().increment(icr: keyPrefix : date); ​ // 3.拼接并返回 return timestamp COUNT_BITS | count; } }测试类知识小贴士关于countdownlatchcountdownlatch名为信号枪主要的作用是同步协调在多线程的等待于唤醒问题我们如果没有CountDownLatch 那么由于程序是异步的当异步程序没有执行完时主线程就已经执行完了然后我们期望的是分线程全部走完之后主线程再走所以我们此时需要使用到CountDownLatchCountDownLatch 中有两个最重要的方法1、countDown2、awaitawait 方法 是阻塞方法我们担心分线程没有执行完时main线程就先执行所以使用await可以让main线程阻塞那么什么时候main线程不再阻塞呢当CountDownLatch 内部维护的 变量变为0时就不再阻塞直接放行那么什么时候CountDownLatch 维护的变量变为0 呢我们只需要调用一次countDown 内部变量就减少1我们让分线程和变量绑定 执行完一个分线程就减少一个变量当分线程全部走完CountDownLatch 维护的变量就是0此时await就不再阻塞统计出来的时间也就是所有分线程执行完后的时间。Test void testIdWorker() throws InterruptedException { CountDownLatch latch new CountDownLatch(300); ​ Runnable task () - { for (int i 0; i 100; i) { long id redisIdWorker.nextId(order); System.out.println(id id); } latch.countDown(); }; long begin System.currentTimeMillis(); for (int i 0; i 300; i) { es.submit(task); } latch.await(); long end System.currentTimeMillis(); System.out.println(time (end - begin)); }3.添加优惠卷每个店铺都可以发布优惠券分为平价券和特价券。平价券可以任意购买而特价券需要秒杀抢购tb_voucher优惠券的基本信息优惠金额、使用规则等 tb_seckill_voucher优惠券的库存、开始抢购时间结束抢购时间。特价优惠券才需要填写这些信息平价卷由于优惠力度并不是很大所以是可以任意领取而代金券由于优惠力度大所以像第二种卷就得限制数量从表结构上也能看出特价卷除了具有优惠卷的基本信息以外还具有库存抢购时间结束时间等等字段新增普通卷代码VoucherControllerPostMapping public Result addVoucher(RequestBody Voucher voucher) { voucherService.save(voucher); return Result.ok(voucher.getId()); }新增秒杀卷代码VoucherControllerPostMapping(seckill) public Result addSeckillVoucher(RequestBody Voucher voucher) { voucherService.addSeckillVoucher(voucher); return Result.ok(voucher.getId()); }VoucherServiceImplOverride Transactional public void addSeckillVoucher(Voucher voucher) { // 保存优惠券 save(voucher); // 保存秒杀信息 SeckillVoucher seckillVoucher new SeckillVoucher(); seckillVoucher.setVoucherId(voucher.getId()); seckillVoucher.setStock(voucher.getStock()); seckillVoucher.setBeginTime(voucher.getBeginTime()); seckillVoucher.setEndTime(voucher.getEndTime()); seckillVoucherService.save(seckillVoucher); // 保存秒杀库存到Redis中 stringRedisTemplate.opsForValue().set(SECKILL_STOCK_KEY voucher.getId(), voucher.getStock().toString()); }4.实现秒杀下单下单核心思路当我们点击抢购时会触发右侧的请求我们只需要编写对应的controller即可秒杀下单应该思考的内容下单时需要判断两点秒杀是否开始或结束如果尚未开始或已经结束则无法下单库存是否充足不足则无法下单下单核心逻辑分析当用户开始进行下单我们应当去查询优惠卷信息查询到优惠卷信息判断是否满足秒杀条件比如时间是否充足如果时间充足则进一步判断库存是否足够如果两者都满足则扣减库存创建订单然后返回订单id如果有一个条件不满足则直接结束。VoucherOrderServiceImplOverride public Result seckillVoucher(Long voucherId) { // 1.查询优惠券 SeckillVoucher voucher seckillVoucherService.getById(voucherId); // 2.判断秒杀是否开始 if (voucher.getBeginTime().isAfter(LocalDateTime.now())) { // 尚未开始 return Result.fail(秒杀尚未开始); } // 3.判断秒杀是否已经结束 if (voucher.getEndTime().isBefore(LocalDateTime.now())) { // 尚未开始 return Result.fail(秒杀已经结束); } // 4.判断库存是否充足 if (voucher.getStock() 1) { // 库存不足 return Result.fail(库存不足); } //5扣减库存 boolean success seckillVoucherService.update() .setSql(stock stock -1) .eq(voucher_id, voucherId).update(); if (!success) { //扣减库存 return Result.fail(库存不足); } //6.创建订单 VoucherOrder voucherOrder new VoucherOrder(); // 6.1.订单id long orderId redisIdWorker.nextId(order); voucherOrder.setId(orderId); // 6.2.用户id Long userId UserHolder.getUser().getId(); voucherOrder.setUserId(userId); // 6.3.代金券id voucherOrder.setVoucherId(voucherId); save(voucherOrder); ​ return Result.ok(orderId); ​ }5.库存超卖问题分析有关超卖问题分析在我们原有代码中是这么写的if (voucher.getStock() 1) { // 库存不足 return Result.fail(库存不足); } //5扣减库存 boolean success seckillVoucherService.update() .setSql(stock stock -1) .eq(voucher_id, voucherId).update(); if (!success) { //扣减库存 return Result.fail(库存不足); }假设线程1过来查询库存判断出来库存大于1正准备去扣减库存但是还没有来得及去扣减此时线程2过来线程2也去查询存发现这个数量一定也大于1那么这两个线程都会去扣减库存最终多个线程相当于一起去扣减库存此时就会出现库存的超卖问题。超卖问题是典型的多线程安全问题针对这一问题的常见解决方案就是加锁而对于加锁我们通常有两种解决方案见下图悲观锁悲观锁可以实现对于数据的串行化执行比如syn和lock都是悲观锁的代表同时悲观锁中又可以再细分为公平锁非公平锁可重入锁等等乐观锁乐观锁会有一个版本号每次操作数据会对版本号1再提交回数据时会去校验是否比之前的版本大1 如果大1 则进行操作成功这套机制的核心逻辑在于如果在操作过程中版本号只比原来大1 那么就意味着操作过程中没有人对他进行过修改他的操作就是安全的如果不大1则数据被修改过当然乐观锁还有一些变种的处理方式比如cas乐观锁的典型代表就是cas利用cas进行无锁化机制加锁var5 是操作前读取的内存值while中的var1var2 是预估值如果预估值 内存值则代表中间没有被人修改过此时就将新值去替换 内存值其中do while 是为了在操作失败时再次进行自旋操作即把之前的逻辑再操作一次。int var5; do { var5 this.getIntVolatile(var1, var2); } while(!this.compareAndSwapInt(var1, var2, var5, var5 var4)); ​ return var5;课程中的使用方式课程中的使用方式是没有像cas一样带自旋的操作也没有对version的版本号1 他的操作逻辑是在操作时对版本号进行1 操作然后要求version 如果是1 的情况下才能操作那么第一个线程在操作后数据库中的version变成了2但是他自己满足version1 所以没有问题此时线程2执行线程2 最后也需要加上条件version 1 但是现在由于线程1已经操作过了所以线程2操作时就不满足version1 的条件了所以线程2无法执行成功。6.乐观锁解决超卖问题修改代码方案一、VoucherOrderServiceImpl 在扣减库存时改为boolean success seckillVoucherService.update() .setSql(stock stock -1) //set stock stock -1 .eq(voucher_id, voucherId).eq(stock,voucher.getStock()).update(); //where id and stock ?以上逻辑的核心含义是只要我扣减库存时的库存和之前我查询到的库存是一样的就意味着没有人在中间修改过库存那么此时就是安全的但是以上这种方式通过测试发现会有很多失败的情况失败的原因在于在使用乐观锁过程中假设100个线程同时都拿到了100的库存然后大家一起去进行扣减但是100个人中只有1个人能扣减成功其他的人在处理时他们在扣减时库存已经被修改过了所以此时其他线程都会失败。修改代码方案二、之前的方式要修改前后都保持一致但是这样我们分析过成功的概率太低所以我们的乐观锁需要变一下改成stock大于0 即可boolean success seckillVoucherService.update() .setSql(stock stock -1) .eq(voucher_id, voucherId).update().gt(stock,0); //where id ? and stock 0知识小扩展针对cas中的自旋压力过大我们可以使用Longaddr这个类去解决Java8 提供的一个对AtomicLong改进后的一个类LongAdder大量线程并发更新一个原子性的时候天然的问题就是自旋会导致并发性问题当然这也比我们直接使用syn来的好所以利用这么一个类LongAdder来进行优化如果获取某个值则会对cell和base的值进行递增最后返回一个完整的值7.优惠券秒杀-一人一单需求修改秒杀业务要求同一个优惠券一个用户只能下一单现在的问题在于优惠卷是为了引流但是目前的情况是一个人可以无限制的抢这个优惠卷所以我们应当增加一层逻辑让一个用户只能下一个单而不是让一个用户下多个单具体操作逻辑如下比如时间是否充足如果时间充足则进一步判断库存是否足够然后再根据优惠卷id和用户id查询是否已经下过这个订单如果下过这个订单则不再下单否则进行下单VoucherOrderServiceImpl初步代码增加一人一单逻辑​ Override public Result seckillVoucher(Long voucherId) { // 1.查询优惠券 SeckillVoucher voucher seckillVoucherService.getById(voucherId); // 2.判断秒杀是否开始 if (voucher.getBeginTime().isAfter(LocalDateTime.now())) { // 尚未开始 return Result.fail(秒杀尚未开始); } // 3.判断秒杀是否已经结束 if (voucher.getEndTime().isBefore(LocalDateTime.now())) { // 尚未开始 return Result.fail(秒杀已经结束); } // 4.判断库存是否充足 if (voucher.getStock() 1) { // 库存不足 return Result.fail(库存不足); } // 5.一人一单逻辑 // 5.1.用户id Long userId UserHolder.getUser().getId(); int count query().eq(user_id, userId).eq(voucher_id, voucherId).count(); // 5.2.判断是否存在 if (count 0) { // 用户已经购买过了 return Result.fail(用户已经购买过一次); } ​ //6扣减库存 boolean success seckillVoucherService.update() .setSql(stock stock -1) .eq(voucher_id, voucherId).update(); if (!success) { //扣减库存 return Result.fail(库存不足); } //7.创建订单 VoucherOrder voucherOrder new VoucherOrder(); // 7.1.订单id long orderId redisIdWorker.nextId(order); voucherOrder.setId(orderId); ​ voucherOrder.setUserId(userId); // 7.3.代金券id voucherOrder.setVoucherId(voucherId); save(voucherOrder); ​ return Result.ok(orderId); ​ }存在问题现在的问题还是和之前一样并发过来查询数据库都不存在订单所以我们还是需要加锁但是乐观锁比较适合更新数据而现在是插入数据所以我们需要使用悲观锁操作注意在这里提到了非常多的问题我们需要慢慢的来思考首先我们的初始方案是封装了一个createVoucherOrder方法同时为了确保他线程安全在方法上添加了一把synchronized 锁Transactional public synchronized Result createVoucherOrder(Long voucherId) { ​ Long userId UserHolder.getUser().getId(); // 5.1.查询订单 int count query().eq(user_id, userId).eq(voucher_id, voucherId).count(); // 5.2.判断是否存在 if (count 0) { // 用户已经购买过了 return Result.fail(用户已经购买过一次); } ​ // 6.扣减库存 boolean success seckillVoucherService.update() .setSql(stock stock - 1) // set stock stock - 1 .eq(voucher_id, voucherId).gt(stock, 0) // where id ? and stock 0 .update(); if (!success) { // 扣减失败 return Result.fail(库存不足); } ​ // 7.创建订单 VoucherOrder voucherOrder new VoucherOrder(); // 7.1.订单id long orderId redisIdWorker.nextId(order); voucherOrder.setId(orderId); // 7.2.用户id voucherOrder.setUserId(userId); // 7.3.代金券id voucherOrder.setVoucherId(voucherId); save(voucherOrder); ​ // 7.返回订单id return Result.ok(orderId); }但是这样添加锁锁的粒度太粗了在使用锁过程中控制锁粒度是一个非常重要的事情因为如果锁的粒度太大会导致每个线程进来都会锁住所以我们需要去控制锁的粒度以下这段代码需要修改为 intern() 这个方法是从常量池中拿到数据如果我们直接使用userId.toString() 他拿到的对象实际上是不同的对象new出来的对象我们使用锁必须保证锁必须是同一把所以我们需要使用intern()方法Transactional public Result createVoucherOrder(Long voucherId) { Long userId UserHolder.getUser().getId(); synchronized(userId.toString().intern()){ // 5.1.查询订单 int count query().eq(user_id, userId).eq(voucher_id, voucherId).count(); // 5.2.判断是否存在 if (count 0) { // 用户已经购买过了 return Result.fail(用户已经购买过一次); } ​ // 6.扣减库存 boolean success seckillVoucherService.update() .setSql(stock stock - 1) // set stock stock - 1 .eq(voucher_id, voucherId).gt(stock, 0) // where id ? and stock 0 .update(); if (!success) { // 扣减失败 return Result.fail(库存不足); } ​ // 7.创建订单 VoucherOrder voucherOrder new VoucherOrder(); // 7.1.订单id long orderId redisIdWorker.nextId(order); voucherOrder.setId(orderId); // 7.2.用户id voucherOrder.setUserId(userId); // 7.3.代金券id voucherOrder.setVoucherId(voucherId); save(voucherOrder); ​ // 7.返回订单id return Result.ok(orderId); } }但是以上代码还是存在问题问题的原因在于当前方法被spring的事务控制如果你在方法内部加锁可能会导致当前方法事务还没有提交但是锁已经释放也会导致问题所以我们选择将当前方法整体包裹起来确保事务不会出现问题如下在seckillVoucher 方法中添加以下逻辑这样就能保证事务的特性同时也控制了锁的粒度但是以上做法依然有问题因为你调用的方法其实是this.的方式调用的事务想要生效还得利用代理来生效所以这个地方我们需要获得原始的事务对象 来操作事务8.集群环境下的并发问题通过加锁可以解决在单机情况下的一人一单安全问题但是在集群模式下就不行了。1、我们将服务启动两份端口分别为8081和80822、然后修改nginx的conf目录下的nginx.conf文件配置反向代理和负载均衡有关锁失效原因分析由于现在我们部署了多个tomcat每个tomcat都有一个属于自己的jvm那么假设在服务器A的tomcat内部有两个线程这两个线程由于使用的是同一份代码那么他们的锁对象是同一个是可以实现互斥的但是如果现在是服务器B的tomcat内部又有两个线程但是他们的锁对象写的虽然和服务器A一样但是锁对象却不是同一个所以线程3和线程4可以实现互斥但是却无法和线程1和线程2实现互斥这就是 集群环境下syn锁失效的原因在这种情况下我们就需要使用分布式锁来解决这个问题。