【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策略实现示例

      java 复制代码
        @Component
        public class RedisIDWorker {
            private static final long BASE_TIMESTAMP = 1767225600L;//基本时间戳,从2026.1.1 00:00:00开始
            private static final long COUNT_BITS = 32;//序列号位数
            private StringRedisTemplate stringRedisTemplate;
            public RedisIDWorker(StringRedisTemplate stringRedisTemplate){
                this.stringRedisTemplate = stringRedisTemplate;
            }
            
            public long nextId(String prefix){
                LocalDateTime now = LocalDateTime.now();
                //获取时间戳
                long nowSecond = now.toEpochSecond(ZoneOffset.UTC);
                long timestamp = nowSecond - BASE_TIMESTAMP;
                //获取当前日期(年月日)
                String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
                //生成序列号(自增长),若没有这个key,则会自动创建,并从0开始自增
                long increment = stringRedisTemplate.opsForValue().increment("icr:" + prefix + ":" + date);
                return timestamp << COUNT_BITS | increment;
            }
        }
      • 测试代码如下:

        java 复制代码
          @Resource
          private RedisIDWorker redisIDWorker;
          
          ExecutorService executor = Executors.newFixedThreadPool(500);
          @Test
          public void testIdWorker() throws InterruptedException {
              CountDownLatch latch = new CountDownLatch(300);//闭锁,计数器,计数器为0时,所有线程开始执行(Java8新特性)
          
              Runnable task = () -> {
                  for(int i=0; i<100; i++){
                      long id = redisIDWorker.nextId("order");
                      System.out.println("id = " + id);
                  }
                  latch.countDown();//减1
              };
          
              long begin = System.currentTimeMillis();
              for(int i=0; i<300; i++){
                  executor.execute(task);
              }
              latch.await();//等待闭锁为0
              long end = System.currentTimeMillis();
              System.out.println("time = " + (end - begin));
          }
  • 实现优惠券秒杀下单

    • 每个店铺都可以发布优惠券,分为平价券和特价券。平价券可以任意购买,而特价券需要秒杀抢购:
    • 表关系如下:
      • tb_voucher:优惠券的基本信息,优惠金额、使用规则等。
      • tb_seckill_voucher:优惠券的库存、开始抢购时间,结束抢购时间。特价优惠券才需要填写这些信息。
    • 优惠券秒杀的下单功能流程图
        • 下单时需要判断两点:
          • 秒杀是否开始或结束,如果尚未开始或已经结束则无法下单。
          • 库存是否充足,不足则无法下单。
  • 超卖问题

    • 超卖问题是典型的多线程安全问题,针对这一问题的常见解决方案就是加锁:

      • 悲观锁

        • 认为线程安全问题一定会发生,因此在操作数据之前先获取锁,确保线程串行执行。例如Synchronized、Lock都属于悲观锁。
      • 乐观锁

        • 认为线程安全问题不一定会发生,因此不加锁,只是在更新数据时去判断有没有其它线程对数据做了修改。
          • 如果没有修改则认为是安全的,自己才更新数据。
          • 如果已经被其它线程修改说明发生了安全问题,此时可以重试或异常。
    • 乐观锁(更新数据时使用)

      • 乐观锁的关键是判断之前查询得到的数据是否有被修改过,常见的方式有两种:
        • 版本号法(初始库存和版本号都为1)

        • CAS法(初始库存为1)

            • 补充:但是这种方法存在请求成功率低的问题。例如在库存修改之前,有多个线程查询了最初的库存值,然后其中某一个线程修改了库存,剩下的线程在更新时发现库存值发生了变化,所以都会返回错误信息。(解决办法:此场景针对库存而言,不需要保证库存与之前查到的完全一致,只需要保证库存大于0即可)
    • 小结

      • 超卖这样的线程安全问题,解决方案有哪些?
        • 1、悲观锁:添加同步锁,让线程串行执行

          • 优点:简单粗暴
          • 缺点:性能一般
        • 2、乐观锁:不加锁,在更新时判断是否有其它线程在修改

          • 优点:性能好
          • 缺点:存在成功率低的问题
  • ---人---单

    • 需求:修改秒杀业务,要求同一个优惠券,一个用户只能下一单。

    • 流程图

    • 关键代码示例

      java 复制代码
        // pom.xml:
        <!--aspectj-->
        <dependency>
            <groupId>org.aspectj</groupId>
            <artifactId>aspectjweaver</artifactId>
        </dependency>
        
        // HmDianPingApplication:(在启动类上添加下面的注解)
        @EnableAspectJAutoProxy(exposeProxy = true)// 暴露代理对象
            
        // VoucherOrderServiceImpl:
        Long userId = UserHolder.getUser().getId();
        // 一人一单
        // 必须在createOrder方法完成之后才能释放锁,这样才能保证事务已经提交,新增的订单才会被插入数据库中
        // userId.toString()虽然会将userId转换成字符串,但是转换成字符串时每次都会创建新的对象(即使内容一样),这样就不能确保同一个用户上的是同一把锁
        // .intern()会创建一个字符串常量池,如果字符串常量池中已经存在该字符串,那么就会返回该字符串,否则就会创建一个新的字符串,并放入字符串常量池中,这样就能确保锁的是同一个用户
        synchronized (userId.toString().intern()){
            //获取当前代理对象
            IVoucherOrderService currentProxy = (IVoucherOrderService) AopContext.currentProxy();
            // 如果直接调用createOrder方法,相当于this.createOrder(),即调用的是目标对象的createOrder方法,而不是代理对象的,但是由于事务是spring拿着代理对象做的,因此事务会失效
            // 因此需要自己手动地获取代理对象调用createOrder方法,事务才能生效
            return currentProxy.createOrder(voucherId);
        }
        
        @Transactional
        public Result createOrder(Long voucherId) {
            //一人一单
            Long userId = UserHolder.getUser().getId();
            // 判断该用户是否已经下过单
            Integer orderCount = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
            if(orderCount > 0){
                return Result.fail("该用户已经下过单");
            }
            // 扣减库存,乐观锁,在更新库存时,判断库存是否大于0
            boolean success = seckillVoucherService.update().setSql("stock = stock - 1").eq("voucher_id", voucherId).gt("stock", 0).update();
            if(!success){
                log.info("库存不足");
                return Result.fail("库存不足");
            }
            //生成订单号
            long orderId = redisIDWorker.nextId("order");
            log.info("成功扣减库存,订单号为:{}", orderId);
            // 创建订单
            VoucherOrder voucherOrder = new VoucherOrder();
            voucherOrder.setVoucherId(voucherId);
            voucherOrder.setUserId(userId);
            voucherOrder.setId(orderId);
            // 保存订单
            save(voucherOrder);
            // 返回订单号
            return Result.ok(orderId);
        }
      • 关键点
        • 1、加什么锁?

          • 由于乐观锁用于更新数据,而当前需求是插入数据,所以无法使用乐观锁,最终选择使用悲观锁。
        • 2、锁加在哪?

          • 要实现一人一单,就要保证在事务提交(数据库已经插入新订单数据)之后才能释放锁,所以最终选择将操作数据库的那部分代码单独抽出成一个方法(createOrder),在这个方法上加上事务注解,最后在上层调用这个方法的地方加锁,这样就可以保证事务提交之后才释放锁。
        • 3、锁的对象是谁?

          • 参考上面VoucherOrderServiceImpl中synchronized代码块上方的注释。
        • 4、事务失效

          • 参考上面VoucherOrderServiceImpl中synchronized代码块中的注释。
    • 一人一单的并发安全问题

      • 通过加锁可以解决在单机情况下的一人一单安全问题,但是在集群模式下就不行了。

      • 模拟集群模式:

        • 1、我们将服务启动两份(Idea提供的功能),端口分别为8081和8082:

          • 步骤说明:使用ctrl+d复制一份启动项,在编辑配置中加入虚拟机选项,并设置端口号:
        • 2、然后修改nginx的conf目录下的nginx.conf文件,配置反向代理和负载均衡:

          • 更改nginx的配置文件后需要在命令行窗口中执行以下指令来重新加载配置文件:

            cmd 复制代码
              nginx.exe -s reload
          • 最后重启一下nginx。

        • 3、现在,用户请求会在这两个节点上负载均衡,再次测试下是否存在线程安全问题。

    • 一人一单的并发安全问题分析

      • 当项目以集群的方式部署在多台服务器上时,每台服务器都是一个单独的JVM,每个JVM内部都拥有自己的锁监视器,所以当同一个用户的两次相同请求被发送到不同的两台服务器时,synchronized锁失效了,这两次请求都能成功获取锁,从而出现一人多单的并发安全问题。
相关推荐
DIY源码阁1 小时前
JavaSwing宿舍管理系统 - MySQL版
java·数据库·mysql·eclipse
cfm_29142 小时前
MySQL8.0 InnoDB Cluster
数据库·mysql
kTR2hD1qb2 小时前
Claude Code Skill的介绍与使用
java·前端·数据库·人工智能
一 乐2 小时前
汽车租赁|基于SprinBoot+vue的汽车租赁管理系统(源码+数据库+文档)
数据库·vue.js·spring boot·汽车·论文·毕设·汽车租赁管理系统
zandy10112 小时前
衡石科技 NL2Metrics 技术深度解析(2026):ChatBI 准确度破局的关键路径
数据库·科技·oracle
Elastic 中国社区官方博客2 小时前
Elasticsearch 如何通过 synthetic _id 和 Bloom filters 将时序存储降低 34%
大数据·数据库·elasticsearch·搜索引擎·serverless·全文检索·时序数据库
better_liang2 小时前
每日Java面试场景题知识点之-如何设计分布式锁
java·redis·zookeeper·面试·分布式锁