Redis的优惠券秒杀问题之全局唯一ID秒杀下单超卖问题一人一单问题以及集群下的问题

Posted 爱上口袋的天空

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Redis的优惠券秒杀问题之全局唯一ID秒杀下单超卖问题一人一单问题以及集群下的问题相关的知识,希望对你有一定的参考价值。

目录

一、全局唯一ID 

1、场景分析 

2、不能用自增的原因

id的规律性太明显

受单表数据量的限制

3、全局唯一ID的条件

4、全局唯一ID的Redis实现

5、代码实现

二、添加优惠卷

 1、** 新增普通卷代码: **VoucherController

2、新增秒杀卷代码:VoucherController

 3、实现秒杀下单

存在问题

 4、库存超卖问题分析

使用Jmeter进行压测 

5、解决方案  

悲观锁与乐观锁

6、版本号法

代码实现 

三、一人一单问题

问题描述 

流程设计

1. intern()

2. 事务失效问题 currentProxy()

四、在集群模式下的问题

问题描述

(1)IDEA启动镜像 

我们可以使用IDEA自带的镜像,启动多个实例,来模拟集群!

​编辑 

(2)修改nginx配置 

(3)验证nginx是否启动成功

BUG复现 

(1)获取Token 

(2)使用Postman 发请求

(3)锁“失效”发生

问题分析 

解决思路 


一、全局唯一ID 

1、场景分析 

首先,我们依照黑马的项目来进行分析,在什么情况下要使用到这个全局唯一ID。

在黑马点评这个项目中,使用的商品其实也就是优惠券

当用户抢购时,就会生成订单并保存到 tb_voucher_order 这张表中 

CREATE TABLE `tb_voucher_order`  (
  `id` bigint NOT NULL COMMENT '主键',
  `user_id` bigint UNSIGNED NOT NULL COMMENT '下单的用户id',
  `voucher_id` bigint UNSIGNED NOT NULL COMMENT '购买的代金券id',
  `pay_type` tinyint UNSIGNED NOT NULL DEFAULT 1 COMMENT '支付方式 1:余额支付;2:支付宝;3:微信',
  `status` tinyint UNSIGNED NOT NULL DEFAULT 1 COMMENT '订单状态,1:未支付;2:已支付;3:已核销;4:已取消;5:退款中;6:已退款',
  `create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '下单时间',
  `pay_time` timestamp NULL DEFAULT NULL COMMENT '支付时间',
  `use_time` timestamp NULL DEFAULT NULL COMMENT '核销时间',
  `refund_time` timestamp NULL DEFAULT NULL COMMENT '退款时间',
  `update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = COMPACT;

 但是,这张SQL表里面的主键id,是不可以使用自增的!!! 

2、不能用自增的原因

id的规律性太明显

如果使用自增的话,用户可以根据两笔订单的ID,来判断这段时间内订单的量。 

受单表数据量的限制

订单的数据量一般很大,一天可能会有几百万,如果使用自增ID,就很难分库分表了!

3、全局唯一ID的条件

全局ID生成器,是一种在分布式系统下用来生成全局唯一ID的工具,一般要满足下列特性:

4、全局唯一ID的Redis实现

 为了增加ID的安全性,我们可以不直接使用Redis自增的数值,而是拼接一些其它信息 

 

符号位:1bit,永远为0 

时间戳:31bit,以秒为单位,可以使用69年

序列号:32bit,秒内的计数器,支持每秒产生2^32个不同ID 

5、代码实现

RedisIdWorker-Redis全局ID生成器工具类

/**
 * Redis的全局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;
    
 
    /**
     * 生成全局ID
     * Long 类型 8个字节 64个bit
     * 符号位(1bit) + 时间戳(31bit) + 序列号(32bit)
     * @param keyPrefix
     * @return
     */
    public long nextId(String keyPrefix) 
        // 1.生成时间戳
        LocalDateTime now = LocalDateTime.now();
        long nowSecond = now.toEpochSecond(ZoneOffset.UTC);
        // ID的时间戳
        long timestamp = nowSecond - BEGIN_TIMESTAMP;
 
        // 2.生成序列号
        // 2.1.获取当前日期,精确到天
        String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
 
        // 2.2.自增长
        // icr表示自增长,keyPrefix表示业务类型,一天一个key
        // 例如: icr:order:2022:11:16
        long count = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + ":" + date);
 
        // 3.拼接并返回 (位运算)
        return timestamp << COUNT_BITS | count;
    

 


二、添加优惠卷

每个店铺都可以发布优惠券,分为平价券和特价券。平价券可以任意购买,而特价券需要秒杀抢购:

tb_voucher:优惠券的基本信息,优惠金额、使用规则等
tb_seckill_voucher:优惠券的库存、开始抢购时间,结束抢购时间。特价优惠券才需要填写这些信息

平价卷由于优惠力度并不是很大,所以是可以任意领取

而代金券由于优惠力度大,所以像第二种卷,就得限制数量,从表结构上也能看出,特价卷除了具有优惠卷的基本信息以外,还具有库存,抢购时间,结束时间等等字段

 1、** 新增普通卷代码: **VoucherController

@PostMapping
public Result addVoucher(@RequestBody Voucher voucher) 
    voucherService.save(voucher);
    return Result.ok(voucher.getId());

2、新增秒杀卷代码:VoucherController

@PostMapping("seckill")
public Result addSeckillVoucher(@RequestBody Voucher voucher) 
    voucherService.addSeckillVoucher(voucher);
    return Result.ok(voucher.getId());

 VoucherServiceImpl:

@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);

 3、实现秒杀下单

下单核心思路:当我们点击抢购时,会触发右侧的请求,我们只需要编写对应的 controller 即可

 

秒杀下单应该思考的内容:

下单时需要判断两点:

  • 秒杀是否开始或结束,如果尚未开始或已经结束则无法下单
  • 库存是否充足,不足则无法下单

下单核心逻辑分析:

        当用户开始进行下单,我们应当去查询优惠卷信息,查询到优惠卷信息,判断是否满足秒杀条件,比如时间是否充足,如果时间充足,则进一步判断库存是否足够,如果两者都满足,则扣减库存,创建订单,然后返回订单 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,扣减库存
    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);

存在问题

上述代码是写好了,运行起来看起来页没有什么问题,但是在多线程,高并发的场景下就会出现大问题,100%会发生超卖的情况!!

 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("库存不足!");
   

有请求过来,只要库存充足,就进行减库存,生成订单的操作。

但是这样子在多线程高并发的场景下,一定会出现问题。

使用Jmeter进行压测 

我们可以用 jmeter工具 复现一下场景,具体配置如下

配置authorization  

运行程序,登入用户后,打开F12,获取authorization 的值 

 

启动 Jmeter,进行压测,我们这里开了200个线程

我们这里设定有100个库存,所以正常的情况应该会有一半的线程(100个)的HTTP请求出现异常,但是这里显然不是! 

查看数据库 tb_sckill_voucher表

stock为负数超卖问题发生! 

订单表 tb_voucher_order表 也是如此  

 

发生超卖问题原因分析 

 

5、解决方案  

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

所以我们现在要研究是就是要加什么类型的锁?要怎么加锁?在哪里加锁?

悲观锁与乐观锁

先提一下,并不是说有一种锁叫乐观锁、叫悲观锁,“悲观”、“乐观”只是用来形容一种思想,一种方式!!!

 

 相较于悲观锁而言, 乐观锁机制采取了更加宽松的加锁机制。自然性能方面会优于悲观锁!

在这个问题中我们用乐观锁来解决!最常见的方式有两个“版本号”、“CAS”

6、版本号法

 

但是,显然,在之前数据库设计的时候,没有version这个字段

我们如果想要用这个方案也可以,但是比较麻烦!

代码实现 

@Resource
private ISeckillVoucherService seckillVoucherService;
 
@Resource
private RedisIdWorker redisIdWorker;
 
@Override
public Result seckillVoucher(Long voucherId) 
    // 1. 查询优惠券
    SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
    LocalDateTime nowTime = LocalDateTime.now();
 
    // 2. 判断秒杀是否开始
    if (nowTime.isBefore(voucher.getBeginTime())) 
        return Result.fail("活动未开始!");
    
 
    // 3. 判断秒杀是否结束
    if (nowTime.isAfter(voucher.getEndTime())) 
        return Result.fail("活动已结束!");
    
 
    // 4. 判断库存
    if (voucher.getStock() < 1) 
        return Result.fail("已买完!");
    
 
    // 5. 减库存
    boolean success = seckillVoucherService.update()
            .setSql("stock = stock - 1")
            .eq("voucher_id", voucherId).gt("stock", 0)  // CAS方案(乐观锁)!
            .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);

 核心是我们在减库存的时候,判断一下stock是否还大于0。

.gt("stock", 0) 

 我们这边是只要库存是大于0的就都可以购买,所以对于这个点,乐观锁的判断可以适当放宽,只对库存为0时的“减库存”操作加锁,对应的SQL:

update tb_seckill_voucher 
set stock = stock - 1 
where voucher_id = 18 and stock > 0

 


三、一人一单问题

问题描述 

什么是一人一单问题?简单的来说就是模拟为了防止黄牛”屯“货而设计的,每一个用户ID,只能下一单!如下图,同一个用户下了很多单!!!

 所以我们要修改秒杀业务,要求同一个优惠券,一个用户只能下一单 

流程设计

解决方案 

我们先获取一下用户的id,如果是相同的用户id“同时”执行到这里,只能允许一个进入该逻辑,执行减库存,生成订单的逻辑!其它的必须在此阻塞

代码实现 

@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);
        LocalDateTime nowTime = LocalDateTime.now();
 
        // 2. 判断秒杀是否开始
        if (nowTime.isBefore(voucher.getBeginTime())) 
            return Result.fail("活动未开始!");
        
 
        // 3. 判断秒杀是否结束
        if (nowTime.isAfter(voucher.getEndTime())) 
            return Result.fail("活动已结束!");
        
 
        // 4. 判断库存
        if (voucher.getStock() < 1) 
            return Result.fail("已买完!");
        
 
        Long userId = UserHolder.getUser().getId();
        // 如果字符常量池中已经包含一个等于此String对象的字符串,则返回常量池中字符串的引用
        // 对于任意两个字符串 s 和 t,当且仅当 s.equals(t) 为 true 时,s.intern() == t.intern() 才为 true
        synchronized (userId.toString().intern()) 
            IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
            return proxy.createVoucherOrder(voucherId);
        
    
 
    @Transactional
    public Result createVoucherOrder(Long voucherId) 
        // 一人一单
        Long userId = UserHolder.getUser().getId();
 
        Integer count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
        if (count > 0)
            return Result.fail("用户已经购买过一次了!");
        
 
        // 5. 减库存
        boolean success = seckillVoucherService.update()
                .setSql("stock = stock - 1")
                .eq("voucher_id", voucherId).gt("stock", 0)  // CAS方案(乐观锁)!
                .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
        voucherOrder.setUserId(userId);
        // 6.3代金券id
        voucherOrder.setVoucherId(voucherId);
        save(voucherOrder);
        return Result.ok(orderId);
    

 代码中技术点分析 

1. intern()

Long userId = UserHolder.getUser().getId();
synchronized (userId.toString().intern()) 
    ...      

intern() 方法返回字符串对象的规范化表示形式

对于任意两个字符串 s 和 t,当且仅当 s.equals(t) 为 true 时,s.intern() == t.intern() 才为 true

String.intern()是一个Native方法,它的作用是:如果字符常量池中已经包含一个等于此String对象的字符串,则返回常量池中字符串的引用(不会新new一个),否则,将新的字符串放入常量池,并返回新字符串的引用。

在该业务场景中,会有多个线程会同时执行该逻辑。会生成多个userId,这里是要给相同的userId加锁!但是toString()方法会生成一个新的字符串对象。

如果不使用 intern() ,尽管这些字符串的值相同,它们的内存地址也会不同!!!所以并不会把它们认定为相同的字符串!
————————————————
版权声明:本文为CSDN博主「面向架构编程」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/weixin_43715214/article/details/127914721

2. 事务失效问题 currentProxy()

如果我们这里不生成其代理对象,则会导致事务失效

synchronized (userId.toString().intern()) 
    return createVoucherOrder(voucherId);

在Spring中,事务的实现方式,是对当前类(VoucherOrderServiceImpl)做了动态代理!用其代理对象去做事务处理

但是这里如果是上述代码, 实际上是this.createVoucherOrder(voucherId),这个this指的是VoucherOrderServiceImpl,是非代理对象,是没有事务功能的!!!所以如果代码这样子写,@Transactional标注的事务会失效!!!
————————————————
版权声明:本文为CSDN博主「面向架构编程」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/weixin_43715214/article/details/127914721

解决办法

synchronized (userId.toString().intern()) 
    IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
    return proxy.createVoucherOrder(voucherId);

获取到当前对象的代理对象 

AopContext.currentProxy()  

然后再用该代理对象来调用方法

proxy.createVoucherOrder(voucherId);  

这么改完之后还要在启动类上面加上注解,用来暴露代理对象

   @EnableAspectJAutoProxy(exposeProxy = true) // 默认是关闭的

@EnableAspectJAutoProxy(exposeProxy = true) // 暴露代理对象!
@MapperScan("com.hmdp.mapper")
@SpringBootApplication
public class HmDianPingApplication 
    public static void main(String[] args) 
        SpringApplication.run(HmDianPingApplication.class, args);
    

四、在集群模式下的问题

问题描述

(1)IDEA启动镜像 

我们可以使用IDEA自带的镜像,启动多个实例,来模拟集群!

 

-Dserver.port=8082

 然后将这两个实例都启动起来。

(2)修改nginx配置 

再打开nginx的配置文件——nginx.conf  

完整配置如下: 

worker_processes  1;
 
events 
    worker_connections  1024;

 
http 
    include       mime.types;
    default_type  application/json;
 
    sendfile        on;
    
    keepalive_timeout  65;
 
    server 
        listen       8080;
        server_name  localhost;
        # 指定前端项目所在的位置
        location / 
            root   html/hmdp;
            index  index.html index.htm;
        
 
        error_page   500 502 503 504  /50x.html;
        location = /50x.html 
            root   html;
        
 
 
        location /api   
            default_type  application/json;
            #internal;  
            keepalive_timeout   30s;  
            keepalive_requests  1000;  
            #支持keep-alive  
            proxy_http_version 1.1;  
            rewrite /api(/.*) $1 break;  
            proxy_pass_request_headers on;
            #more_clear_input_headers Accept-Encoding;  
            proxy_next_upstream error timeout;  
            # 打开集群模式
            # proxy_pass http://127.0.0.1:8081;
            proxy_pass http://backend;
        
    
 
    # 打开集群模式 backend
    upstream backend 
        server 127.0.0.1:8081 max_fails=5 fail_timeout=10s weight=1;
        server 127.0.0.1:8082 max_fails=5 fail_timeout=10s weight=1;
      

proxy_pass http://backend;

......
upstream backend
        server 127.0.0.1:8081 max_fails=5 fail_timeout=10s weight=1;
        server 127.0.0.1:8082 max_fails=5 fail_timeout=10s weight=1;

重启nginx.exe

nginx.exe -s reload

(3)验证nginx是否启动成功

因为默认是采用轮询的模式,再刷新一下上面的网页,8081和8082都是看到日志信息

HmDianPingApplication1 

 HmDianPingApplication2

到这里,说明我们的模拟集群已经配置成功了!接下来就是复现这个BUG 

BUG复现 

(1)获取Token 

先登入一下,从页面中拿到这个token(authorization

我们在代码中打上一个断点,这段代码中是有加锁的!即如果是同一个用户ID是不能同时进来的! 

(2)使用Postman 发请求

然后,将authorization配置到postman的请求头中,分别用两个postman实例去发送请求! 

 

但是,在下面的两个实例中,显然同一个用户ID请求都进去了,就出现了锁“失效”的问题!

(3)锁“失效”发生

两个请求,分别进入到不同的IDEA实例中(8081、8082),但是这一块内容我们是上锁的!也就是说在集群模式下,JVM级别synchronized锁是起不了作用的!

HmDianPingApplication1 

HmDianPingApplication2

 

问题分析 

JVM级别synchronized锁失效

 换而言之,就是系统部署在不同的服务器中,那它们的JVM是不一样的,而 synchronized 只能锁当前JVM中的线程,是不能操作其它JVM的!

解决思路 

要解决这个问题,就必须要借助别的工具!——分布式锁就孕育而生!

 

 

以上是关于Redis的优惠券秒杀问题之全局唯一ID秒杀下单超卖问题一人一单问题以及集群下的问题的主要内容,如果未能解决你的问题,请参考以下文章

Redis进阶学习03---Redis完成秒杀和Redis分布式锁的应用

利用Redis一步步实现优惠券的最终秒杀方案

Redis之秒杀下单优化以及认识redis消息队列

Redis之秒杀下单优化以及认识redis消息队列

秒杀架构设计问题以及思考

Redis场景拓展秒杀问题-全局唯一ID生成策略