>데이터 베이스 >Redis >Redis 쿠폰 플래시 세일 기능 구현 방법

Redis 쿠폰 플래시 세일 기능 구현 방법

王林
王林앞으로
2023-06-03 15:39:331017검색

    1. 글로벌 고유 ID

    1. 글로벌 ID 생성기

    각 상점에서 쿠폰을 발행할 수 있습니다.

    Redis 쿠폰 플래시 세일 기능 구현 방법

    사용자가 급히 구매하려고 하면 주문이 생성되어 tb_voucher_order 테이블에 저장됩니다. 주문 테이블은 데이터베이스 자동 증가 ID를 사용하므로 몇 가지 문제가 있습니다.

    • ID의 규칙성이 너무 분명합니다.

    • 단일 테이블의 데이터 양에 의해 제한됩니다

    그래서 기본 tb_voucher_order 테이블의 키는 자동 증가 ID를 사용할 수 없습니다.

    create table tb_voucher_order
    (
        id          bigint                                        not null comment '主键'
            primary key,
        user_id     bigint unsigned                               not null comment '下单的用户id',
        voucher_id  bigint unsigned                               not null comment '购买的代金券id',
        pay_type    tinyint(1) unsigned default 1                 not null comment '支付方式 1:余额支付;2:支付宝;3:微信',
        status      tinyint(1) unsigned default 1                 not null comment '订单状态,1:未支付;2:已支付;3:已核销;4:已取消;5:退款中;6:已退款',
        create_time timestamp           default CURRENT_TIMESTAMP not null comment '下单时间',
        pay_time    timestamp                                     null comment '支付时间',
        use_time    timestamp                                     null comment '核销时间',
        refund_time timestamp                                     null comment '退款时间',
        update_time timestamp           default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '更新时间'
    );

    Global ID Generator는 분산 시스템에서 전역적으로 고유한 ID를 생성하는 데 사용되는 도구입니다.

    Redis 쿠폰 플래시 세일 기능 구현 방법

    이를 위해서는 일반적으로 다음 특성을 충족해야 합니다. ID 보안을 높이면 직접 사용할 수 없습니다. Redis는 자동으로 값을 증가시키지만 다른 정보를 결합합니다:

    Redis 쿠폰 플래시 세일 기능 구현 방법

    D 구성 요소:

    • 서명 비트: 1비트, 항상 0, 양수를 나타냅니다.

    • 타임 스탬프: 31비트, 초 단위로 69년 동안 사용할 수 있습니다.

    • 일련 번호: 32비트, 초 단위 카운터, 초당 2^32개의 서로 다른 ID 생성 지원

    전역 ID 생성기 코드 작성:

    @Component
    public class RedisIdWorker {
        /**
         * 开始时间戳,以2022.1.1为基准计算时间差
         */
        private static final long BEGIN_TIMESTAMP = 1640995200L;
        /**
         * 序列号的位数
         */
        private static final int COUNT_BITS = 32;
    
        private StringRedisTemplate stringRedisTemplate;
    
        public RedisIdWorker(StringRedisTemplate stringRedisTemplate) {
            this.stringRedisTemplate = stringRedisTemplate;
        }
    
        /**
         * 生成带有业务前缀的redis自增id
         * @param keyPrefix
         * @return
         */
        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.拼接并返回
            // 用于是数字类型的拼接,所以不能像拼接字符串那样处理,而是通过位运算将高32位存 符号位+时间戳,低32位存 序列号
            return timestamp << COUNT_BITS | count;
        }
    
        public static void main(String[] args) {
            LocalDateTime time = LocalDateTime.of(2022, 1, 1, 0, 0, 0);
            long second = time.toEpochSecond(ZoneOffset.UTC);
            System.out.println(second);// 1640995200
        }
    }

    전역 ID 생성기 테스트:

    @SpringBootTest
    class HmDianPingApplicationTests {
    	@Resource
        private RedisIdWorker redisIdWorker;
    
        private ExecutorService executorService = Executors.newFixedThreadPool(500);
    
    	@Test
        void testIdWorker() throws InterruptedException {
            CountDownLatch latch = new CountDownLatch(300);
    
            // 每个线程生成100个id
            Runnable task = () -> {
                for (int i = 0; i < 100; i++) {
                    long id = redisIdWorker.nextId("order");
                    System.out.println("id = " + id);
                }
                latch.countDown();
            };
            // 300个线程
            long begin = System.currentTimeMillis();
            for (int i = 0; i < 300; i++) {
                executorService.submit(task);
            }
            latch.await();
            long end = System.currentTimeMillis();
            System.out.println("time = " + (end - begin));
        }
    }

    테스트 결과:

    Redis 쿠폰 플래시 세일 기능 구현 방법

    Redis 쿠폰 플래시 세일 기능 구현 방법

    2. 전역 고유 ID 생성 전략

    • UUID(증분 아님)

    • Redis auto-increment

    • Snowflake Algorithm (snowflake)

    • Database auto-increment (자동 증가 ID를 저장하기 위해 별도의 테이블을 구축하고, 서브 데이터베이스를 분할한 후 테이블에 할당) into tables)

    3. Redis 자동 증가 ID 전략

    • 날짜를 접두사 키로 사용하여 주문량 계산에 편리함

    • 자체 증가 ID 구조: 타임스탬프 + 카운터

    2. 쿠폰 즉시판매 주문 시행

    1. 쿠폰 추가

    각 매장에서 발행 가능 쿠폰은 특가쿠폰과 특별쿠폰으로 나누어집니다. 저렴한 쿠폰은 마음대로 구매 가능하며, 특별 쿠폰은 반짝 세일 필요:

    Redis 쿠폰 플래시 세일 기능 구현 방법

    쿠폰 테이블 정보:

    • tb_voucher: 쿠폰의 기본 정보, 할인 금액, 사용 규칙 등(유형 필드) tb_voucher 테이블은 일반 쿠폰 또는 플래시 세일 쿠폰입니다)

    • tb_seckill_voucher: 쿠폰 재고, 시작 시간, 종료 시간(플래시 세일 쿠폰에는 이 정보가 필요합니다.) 동시에 보안 쿠폰에는 일반 쿠폰의 기본 정보가 포함됩니다. (보안 쿠폰 테이블의 기본 키 ID tb_seckill_voucher 일반 쿠폰 테이블의 ID tb_voucher가 바인딩됨)

    create table tb_voucher
    (
        id           bigint unsigned auto_increment comment &#39;主键&#39;
            primary key,
        shop_id      bigint unsigned                               null comment &#39;商铺id&#39;,
        title        varchar(255)                                  not null comment &#39;代金券标题&#39;,
        sub_title    varchar(255)                                  null comment &#39;副标题&#39;,
        rules        varchar(1024)                                 null comment &#39;使用规则&#39;,
        pay_value    bigint(10) unsigned                           not null comment &#39;支付金额,单位是分。例如200代表2元&#39;,
        actual_value bigint(10)                                    not null comment &#39;抵扣金额,单位是分。例如200代表2元&#39;,
        type         tinyint(1) unsigned default 0                 not null comment &#39;0,普通券;1,秒杀券&#39;,
        status       tinyint(1) unsigned default 1                 not null comment &#39;1,上架; 2,下架; 3,过期&#39;,
        create_time  timestamp           default CURRENT_TIMESTAMP not null comment &#39;创建时间&#39;,
        update_time  timestamp           default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment &#39;更新时间&#39;
    );
    create table tb_seckill_voucher
    (
        voucher_id  bigint unsigned                     not null comment &#39;关联的优惠券的id&#39;
            primary key,
        stock       int(8)                              not null comment &#39;库存&#39;,
        create_time timestamp default CURRENT_TIMESTAMP not null comment &#39;创建时间&#39;,
        begin_time  timestamp default CURRENT_TIMESTAMP not null comment &#39;生效时间&#39;,
        end_time    timestamp default CURRENT_TIMESTAMP not null comment &#39;失效时间&#39;,
        update_time timestamp default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment &#39;更新时间&#39;
    )
        comment &#39;秒杀优惠券表,与优惠券是一对一关系&#39;;

    2 플래시 세일 쿠폰 추가를 위한 인터페이스 작성

    메인 코드:

    @RestController
    @RequestMapping("/voucher")
    public class VoucherController {
    
        @Resource
        private IVoucherService voucherService;
    
        /**
         * 新增秒杀券
         * @param voucher 优惠券信息,包含秒杀信息
         * @return 优惠券id
         */
        @PostMapping("seckill")
        public Result addSeckillVoucher(@RequestBody Voucher voucher) {
            voucherService.addSeckillVoucher(voucher);
            return Result.ok(voucher.getId());
        }
    }
    @Service
    public class VoucherServiceImpl extends ServiceImpl<VoucherMapper, Voucher> implements IVoucherService {
    
        @Resource
        private ISeckillVoucherService seckillVoucherService;
    
        @Override
        @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 쿠폰 플래시 세일 기능 구현 방법

    테스트 결과:

    Redis 쿠폰 플래시 세일 기능 구현 방법

    3. 깜짝 세일 주문을 하려면

    Redis 쿠폰 플래시 세일 기능 구현 방법

    주문할 때 두 가지 점을 판단해야 합니다.

    • 플래시 여부 세일이 시작되지 않았거나 종료된 경우 주문이 불가능합니다

    • 재고가 충분하면 주문이 불가능합니다

    Redis 쿠폰 플래시 세일 기능 구현 방법

    메인코드 :

    @RestController
    @RequestMapping("/voucher-order")
    public class VoucherOrderController {
        @Resource
        private IVoucherOrderService voucherOrderService;
    
        @PostMapping("seckill/{id}")
        public Result seckillVoucher(@PathVariable("id") Long voucherId) {
            return voucherOrderService.seckillVoucher(voucherId);
        }
    }
    @Service
    public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
    
        @Resource
        private ISeckillVoucherService seckillVoucherService;
    
        @Resource
        private RedisIdWorker redisIdWorker;
    
        @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,扣减库存
            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);
    
        }
    }

    간단 테스트 즉시 종료 성공:

    Redis 쿠폰 플래시 세일 기능 구현 방법

    재고 차감 성공:

    Redis 쿠폰 플래시 세일 기능 구현 방법

    4, 슈퍼셀링 문제

    동시에 접속 요청이 많은 경우 , 과매도 문제가 발생합니다

    Redis 쿠폰 플래시 세일 기능 구현 방법

    Redis 쿠폰 플래시 세일 기능 구현 방법

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

    Redis 쿠폰 플래시 세일 기능 구현 방법

    1. 加锁方式 - 乐观锁

    乐观锁的关键是判断之前查询得到的数据是否有被修改过,常见的方式有两种:

    (1)版本号法

    Redis 쿠폰 플래시 세일 기능 구현 방법

    (2)CAS法

    • 用库存代替了版本号,可以少加一个字段

    • 扣库存时,与查询时的库存比较,没被修改则可以扣减库存

    Redis 쿠폰 플래시 세일 기능 구현 방법

    2. 乐观锁解决超卖问题

    乐观锁方式,通过CAS判断前后库存是否一致,解决超卖问题:

    // 之前的代码
    boolean success = seckillVoucherService.update()
                    .setSql("stock= stock -1")
                    .eq("voucher_id", voucherId).update();
                    
    // 乐观锁方式,通过CAS判断前后库存是否一致,解决超卖问题                
    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个线程同时请求,但通过CAS判断后,只有一个线程能扣减库存成功,其余99个线程全部失败

    • 此时,库存剩余99,但是实际业务可以满足其余99个线程扣减库存

    • 虽然能解决超卖问题,但是设计不合理

    所以为了解决失败率高的问题,需要进一步改进:

    通过CAS 不再 判断前后库存是否一致,而是判断库存是否大于0

    boolean success = seckillVoucherService.update()
                    .setSql("stock= stock -1")
                    .eq("voucher_id", voucherId).gt("stock",0).update(); // where id = ? and stock > 0

    3. 小结

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

    • 优点:简单粗暴

    • 缺点:性能一般

    (2)乐观锁:不加锁,在更新时判断是否有其它线程在修改

    • 优点:性能相对悲观锁好(但是仍然需要同时查数据库,影响性能)

    • 缺点:存在成功率低的问题(可以采用分段锁方式提高成功率)

    五、一人一单问题

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

    Redis 쿠폰 플래시 세일 기능 구현 방법

    在扣减库存之前,加上一人一单的逻辑:

    // 5.一人一单逻辑
    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("用户已经购买过一次!");
      }

    此处仍会出现并发问题,当同一用户模拟大量请求同时查询是否下过单时,如果正好都查询出count为0,就会跳过判断继续执行扣减库存的逻辑,此时就会出现一人下多单的问题

    解决方法:

    • 由于是判断查询的数据是否存在,而不是像之前判断查询的数据是否修改过

    • 所以这里只能加悲观锁

    1. 加锁分析

    • 首先将一人一单之后的逻辑全部加锁,所以将一人一单之后的逻辑抽取出一个方法进行加锁,public Result createVoucherOrder(Long voucherId)

    • 如果直接在方法上加锁,则锁的是this对象,锁的对象粒度过大,就算是不同的人执行都会阻塞住,影响性能,public synchronized Result createVoucherOrder(Long voucherId)

    • 所以将锁的对象改为userId,但是不能直接使用synchronized (userId),因为每次执行Long userId = UserHolder.getUser().getId();虽然值一样,但是对象不同,因此需要这样加锁 synchronized (userId.toString().intern()),intern()表示每次从字符串常量池中获取,这样值相同时,对象也相同

    • 为了防止事务还没提交就释放锁的问题,则不能将锁加在createVoucherOrder方法内部,例如:

    @Transactional
    public Result createVoucherOrder(Long voucherId) {
    	synchronized (userId.toString().intern()) {
    		。。。
    	}
    }

    而是需要等事务提交完再释放锁,例如:

    synchronized (userId.toString().intern()) {
     	// 获取代理对象(事务)
        IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
        return proxy.createVoucherOrder(voucherId);
    }

    2. 事务分析

    由于只有一人一单之后的逻辑涉及到修改数据库,所以只需对该方法加事务
    @Transactional
    public Result createVoucherOrder(Long voucherId)

    由于只对createVoucherOrder方法加了事务,而该方法是在seckillVoucher方法中被调用,seckillVoucher方法又没有加事务,为了防止事务失效,则不能直接在seckillVoucher方法调用createVoucherOrder方法,例如:

    @Override
    public Result seckillVoucher(Long voucherId) {
    	。。。。
    	synchronized (userId.toString().intern()) {
            return this.createVoucherOrder(voucherId);
        }
    }

    而是需要通过代理对象调用createVoucherOrder方法,因为@Transactional事务注解的原理是通过获取代理对象执行目标对象的方法,进行AOP操作,所以需要这样:

    @Override
    public Result seckillVoucher(Long voucherId) {
    	。。。。
    	// 获取代理对象(事务)
        IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
        return proxy.createVoucherOrder(voucherId);
    }

    并且还要引入依赖:

    <dependency>
        <groupId>org.aspectj</groupId>
        <artifactId>aspectjweaver</artifactId>
    </dependency>

    还要开启注解暴露出代理对象:

    @EnableAspectJAutoProxy(exposeProxy = true)
    @MapperScan("com.hmdp.mapper")
    @SpringBootApplication
    public class HmDianPingApplication {
        public static void main(String[] args) {
            SpringApplication.run(HmDianPingApplication.class, args);
        }
    }

    完整VoucherOrderServiceImpl代码:

    @Service
    public class VoucherOrderServiceImpl extends ServiceImpl implements IVoucherOrderService {
    
        @Resource
        private ISeckillVoucherService seckillVoucherService;
    
        @Resource
        private RedisIdWorker redisIdWorker;
    
        @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("库存不足!");
            }
    
            Long userId = UserHolder.getUser().getId();
            synchronized (userId.toString().intern()) {
                // 获取代理对象(事务)
                IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
                return proxy.createVoucherOrder(voucherId);
            }
            
        }
    
        @Transactional
        public Result createVoucherOrder(Long voucherId) {
    
            // 5.一人一单逻辑
            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,扣减库存
            // 乐观锁方式,通过CAS判断库存是否大于0,解决超卖问题:
            boolean success = seckillVoucherService.update()
                    .setSql("stock= stock -1")
                    .eq("voucher_id", voucherId).gt("stock",0).update(); // where id = ? and stock > 0
    
            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);
    
            // 8.返回订单id
            return Result.ok(orderId);
        }
        
    }

    六、集群模式下并发安全问题

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

    我们将服务启动两份,端口分别为8081和8082:

    Redis 쿠폰 플래시 세일 기능 구현 방법

    然后修改nginx的conf目录下的nginx.conf文件,配置反向代理和负载均衡:

    Redis 쿠폰 플래시 세일 기능 구현 방법

    修改完后,重新加载nginx配置文件:

    Redis 쿠폰 플래시 세일 기능 구현 방법

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

    访问8081端口的线程进入了synchronized中

    Redis 쿠폰 플래시 세일 기능 구현 방법

    访问8082端口的线程也进入了synchronized中

    Redis 쿠폰 플래시 세일 기능 구현 방법

    最终同一个用户下了2单扣了2个库存,所以在集群模式下,出现了一人多单的问题:

    Redis 쿠폰 플래시 세일 기능 구현 방법

    分析:

    • 锁的原理是每个JVM中都有一个Monitor作为锁对象,所以当对象相同时,获取的就是同一把锁

    • 但是不同的JVM中的Monitor不同,所以获取的不是同一把锁

    • 因此集群模式下,加synchronized锁也会出现并发安全问题,需要加分布式锁

    Redis 쿠폰 플래시 세일 기능 구현 방법

    위 내용은 Redis 쿠폰 플래시 세일 기능 구현 방법의 상세 내용입니다. 자세한 내용은 PHP 중국어 웹사이트의 기타 관련 기사를 참조하세요!

    성명:
    이 기사는 yisu.com에서 복제됩니다. 침해가 있는 경우 admin@php.cn으로 문의하시기 바랍니다. 삭제