首页 > 其他分享 >【产品经理修炼之道】- 优惠券系统从入门到精通(四十五)

【产品经理修炼之道】- 优惠券系统从入门到精通(四十五)

时间:2024-08-13 12:23:07浏览次数:9  
标签:优惠券 入门 四十五 可以 线程 key 超发 stock

在高并发情况下我是这样解决单用户超领优惠券问题

问题抛出

在近期的项目里面有一个功能是领取优惠券的功能,

问题描述:

每一个优惠券一共发行多少张,每个用户可以领取多少张:

如:A优惠券一共发行120张,每一个用户可以领取140张,当一个用户领取优惠券成功的时候,把领取的记录写入到另外一个表中(这张表我们暂且称为表B)

image-20220703223920444.png

image-20220703232455966.png


<!--减优惠券库存的SQL-->
<update id="reduceStock">
     update coupon set stock = stock - 1 where id = #{coupon_id}
</update>

上面的代码按照我们的逻辑是没有问题,我通过使用PostMan软件测试也是没有问题,但是上面的代码确实是有问题的。

**往往我们写的一些业务功能,在低并发的时候很多的问题会体现不出来。**所以这个领取优惠券的功能我通过Jmeter软件来进行压测。

image-20220703225039988.png

这里配置了一下,大概会发送500次请求,那来验证下优惠券会不会出现超发的问题

image-20220703225143294.png

执行结果,里面没有出现异常什么的,样本为500。包括在汇总报告里面也出现了一些返回信息是优惠券不足的信息,那来看下数据库里面的优惠券的总发行数量有没有变成负数呢?也就是有没有超发。

image-20220703225609089.png

在测试的时候是测试的id为19的这条数据,测试完之后这里的总发行数量(stock)居然变成了-1(也就是超发了一张)。

问题引发

在解决这个问题之前,先来看下这个问题是如何引发出来的。

2022-07-03.png

上面这张图是整个领取优惠券的流程(上图并没有使用流程图来画,我觉的这样画可能表达更清楚一些),在蓝色的框那里就是出现超扣减库存的时候。为啥这样说呢?

如果同时来了两个线程(你可以理解成是两个请求),比如先来的那个请求通过了检查(线程A),这时线程A还没有扣减库存,这时线程B经过一翻操作也通过了这个检查优惠券是否可领取的方法,然后线程A和线程B依次扣减库存或者是同时扣减库存,这样就会出现优惠券超领的情况。

image-20220703235217539.png

清楚了问题引发的原因,那就来看看如何解决它们。

解决方案一(Java代码加锁)

在引起超发原因的那张图内可以看出,导致这一问题的根本原因是多个线程同时访问这个领取优惠券的方法,那只要保证在同一段只有一个线程进入到这个方法就可以了。

上面贴的代码就可以改成下面这样:


synchronized (this){
    LoginUser loginUser = LoginInterceptor.threadLocal.get();
    CouponDO couponDO = couponMapper.selectOne(new QueryWrapper<CouponDO>()
                                    .eq("id", couponId)
                                    .eq("category", categoryEnum.name()));
    if(couponDO == null){
        throw new BizException(BizCodeEnum.COUPON_NO_EXITS);
    }
    this.checkCoupon(couponDO,loginUser.getId());

    //构建领券记录
    CouponRecordDO couponRecordDO = new CouponRecordDO();
    BeanUtils.copyProperties(couponDO,couponRecordDO);
    couponRecordDO.setCreateTime(new Date());
    couponRecordDO.setUseState(CouponStateEnum.NEW.name());
    couponRecordDO.setUserId(loginUser.getId());
    couponRecordDO.setUserName(loginUser.getName());
    couponRecordDO.setCouponId(couponDO.getId());
    couponRecordDO.setId(null);

    int row = couponMapper.reduceStock(couponId);
    if(row == 1){
        couponRecordMapper.insert(couponRecordDO);
    }else{
        log.info("发送优惠券失败:{},用户:{}",couponDO,loginUser);
    }
}

image-20220704094831815.png

这样,经过Jmeter的压测优惠券并没有出现超发的情况。

虽然这样可以解决超发的问题,但是在项目中我们不可以这样写,原因如下:

  • synchronized的作用范围是单个JVM实例,如果是集群部署系统这里的加锁你可以理解成失效
  • 在使用了synchronized加锁后,就会形成串行等待的问题,当一个线程A在领取优惠券方法内执行过久时,其它线程会等待直到线程A执行结束

解决方案二(Sql层面解决超发)


<update id="reduceStock">
     update coupon set stock = stock - 1 where id = #{coupon_id} and stock > 0
</update>

Mysql默认使用的是InnoDB引擎,使用InnoDB时在修改某一个记录的时候会将这条记录上锁,所以这个修改数据时不会出现多个线程同时修改数据。这样也可以避免优惠券超发。

如果在业务中只要有库存就可以发放优惠券的可以使用上面这种方式。

还有一种Sql的方式,可以将stock自身做为乐观锁。


<update id="reduceStock">
     update product set stock=stock-1 where stock=#{上一次的库存}  and id = 1 and stock>0
</update>

上面这种方式会存在ABA的问题,当然如果业务不在意ABA问题可以使用上面的sql,不过性能可能差一点,如果stock不匹配,这条sql也就失效了。

如果业务在意ABA问题的话也可以在表中加一个version的字段,每次修改数据的时候这个字段会加1,这样就可以避免ABA问题


<update id="reduceStock">
     update product set stock=stock-1,versioin = version+1 where  id = 1 and stock>0 and version=#{上一次的版本号}
</update>

上面的这三条Sql层面的代码都可以解决优惠券超发的问题,具体使用那种就根据业务来选择了

解决方案三(通过Redis分布式锁来解决问题)

引入Redis后,当领取优惠券时会先去Redis里面去获取锁,当锁获取成功后才可以对数据库进行操作

image-20220704105202270.png

在分布式锁中我们应该考滤如下:

  • 排他性,在分布式集群中,同一个方法,在同一个时间只能被某一台机器上的一个线程执行
  • 容错性,当一个线程上锁后,如果机器突然的宕机,如果不释放锁,此时这条数据将会被锁死
  • 还要注意锁的粒度,锁的开销
  • 满足高可用,高性能,可重入

我们可以使用Redis里面的setnx命令来设置锁,因为setnx是原子性的操作不可被打断

image-20220704111134426.png

当这个命令执行成功的时候会返回1,执行失败会返回0,我们就可以通过这个特性来判断是否获取到了锁。

先看下伪代码:

String key = "lock:coupon:" + couponId;
try{
	if(setnx(key,"1")){
		//获取到锁
		//设置Key的时期时间
		exp(key,30,TimeUnit.MILLISECONDS);
		try{
			//业务逻辑
		}finally{
			del(key);
		}
    }else{
        //获取锁失败,递归调用这个方法,或者使用for进行自旋获取锁
    }
}

这方法里面设置key的过期时间的原因是,当机器突然的宕机后,即使没有释放掉锁,他也会在一段时间后将这个锁释放,避免导致死锁。

虽然看上面的代码是没有问题的,但是它是存在一个误删除key的问题

image-20220704114713612.png

为了避免这个问题,可以将setnx命令设置的那个值,设置成当前线程的ID,在删除的时候判断这个线程ID是不是与当前线程的Id相同就可以了。


String key = "lock:coupon:" + couponId;
String threadId = Thread.currentThread().getId();
try{
	if(setnx(key,threadId)){
		//获取到锁
		//设置Key的时期时间
		exp(key,30,TimeUnit.MILLISECONDS);
		try{
			//业务逻辑
		}finally{
			if(get(key) == threadId){
				del(key);
			}
		}
    }else{
        //获取锁失败,递归调用这个方法,或者使用for进行自旋获取锁
    }
}

通过上面这种方法就可以解决误删除key的问题。

在finally中的这个判断和删除key的代码不是原子性的,我们可以通过lua脚本的方式来实现它们之间的原子性,将删除key的代码修改成如下:


String script = "if redis.call('get',KEYS[1]) == ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end";

redisTemplate.execute(new DefaultRedisScript<>(script, Integer.class), Arrays.asList(key), threadId);

这里的threadId其实也可以不用,写成uuid也可以,但是在上面setnx的时候,那个值也要写成uuid

但是这样还要存在一个锁自动续期的问题,你可以开一个守护线程,每隔多久给他续期一次,或者是直接将这个过期时间延长一些。

在Redis中也有一些官方推荐的分布式锁的方式。我最后是使用的这种方式

解决方案四(使用Redis推荐的方式)

官网地址:redis.io/docs/refere…

这个有多种实现方式,比如:Golang,Java,Php

引入Redisson包


<dependency>
   <groupId>org.redisson</groupId>
   <artifactId>redisson</artifactId>
   <version>3.17.4</version>
</dependency>  

配置RedissoneClient


@Configuration
public class AppConfig {

    @Value("${spring.redis.host}")
    private String redisHost;

    @Value("${spring.redis.port}")
    private String redisPort;

    @Bean
    public RedissonClient redisson(){
        Config config = new Config();
        config.useSingleServer().setAddress("redis://" + redisHost + ":" + redisPort);
        return Redisson.create(config);
    }
}

配置好RedissonClient后,通过getLock方法获取到锁对象后,在我们的Service层中就可以通过lock和unlock来进行加锁和释放锁了,这样还是很方便的。


public JsonData addCoupon(long couponId, CouponCategoryEnum categoryEnum) {
    String key = "lock:coupon:" + couponId;
    RLock rLock = redisson.getLock(key);
    LoginUser loginUser = LoginInterceptor.threadLocal.get();
    rLock.lock();
    try{
       //业务逻辑
    }finally {
        rLock.unlock();
    }
    return JsonData.buildSuccess();
}

通过这种方法也可以解决优惠券超发的问题 ,这也是Rediss官网推荐的一种方式。

使用这种方式也无需关心key过期时间续期的问题,因为在Redisson一旦加锁成功,就会启动一个watch dog,你可以将它理解成一个守护线程,它默认会每隔30秒检查一下,如果当前客户端还占有这把锁,它会自动对这个锁的过期时间进行延长。

也可以通过下面的方法设置watch dog的检测时间间隔


Config config = new Config();
config.setLockWatchdogTimeout();

如上就是我在解决优惠券超发时的一个思路。

邀您共同加入产品经理修炼之路:

标签:优惠券,入门,四十五,可以,线程,key,超发,stock
From: https://blog.csdn.net/xiaoli8748/article/details/141140624

相关文章

  • 算法入门
    首先,了解一下集成学习及Boosting算法。集成学习归属于机器学习,他是一种“训练思路”,并不是某种具体的方法或者算法。现实生活中,大家都知道“人多力量大”,“3个臭皮匠顶个诸葛亮”。而集成学习的核心思路就是“人多力量大”,它并没有创造出新的算法,而是把已有的算法进行结合,从而......
  • 【图像去噪】论文复现:新手入门必看!DnCNN的Pytorch源码训练测试全流程解析!为源码做详细
    第一次来请先看【专栏介绍文章】:源码只提供了noiselevel为25的DnCNN-S模型文件。本文末尾有完整代码和训练好的σ=15,25,50的DnCNN-S、σ∈[0,55]的DnCNN-B和CDnCNN-B、DnCNN-3共6个模型文件!读者可以自行下载!本文亮点:以官方Pytorch源代码为基础,在DnCNN-S的基础上,增添Dn......
  • 游戏安全入门-扫雷分析&远程线程注入
    前言无论学习什么,首先,我们应该有个目标,那么入门windows游戏安全,脑海中浮现出来的一个游戏--扫雷,一款家喻户晓的游戏,虽然已经被大家分析的不能再透了,但是我觉得自己去分析一下还是极好的,把它作为一个小目标再好不过了。我们编写一个妙妙小工具,工具要求实现以下功能:时间暂停、修......
  • TypeScript 入门
    0x01概述TypeScript官网TypeScript是一种基于JavaScript构建的强类型编程语言JavaScript的超集,即JavaScript是TypeScript的子集JavaScript类型化,即在JavaScript基础上添加类型系统优势:提供可读性和可维护性在编译阶段检查并报错环境:安装NodeJ......
  • C ++ 也可以搭建Web?高性能的 C++ Web 开发框架 CPPCMS + MySQL 实现快速入门案例
    什么是CPPCMS?CppCMS是一个高性能的C++Web开发框架,专为构建快速、动态的网页应用而设计,特别适合高并发和低延迟的场景。其设计理念类似于Python的Django或RubyonRails,但针对C++提供了更细粒度的控制和更高效的性能。主要特点和优点1.高性能与并发处理​Cp......
  • Java基础入门18:File、IO 流1(方法递归、字符集、IO流-字节流)
    File和IO流FileFile是java.io.包下的类,File类的对象,用于代表当前操作系统的文件(可以是文件、或文件夹)。IO流用于读写数据的(可以读写文件,或网络中的数据...)File代表文件IO流用来读写数据File创建对象创建File类的对象注意:File对象既可以代表文件、也可以代表文......
  • Java入门基础16:集合框架1(Collection集合体系、List、Set)
    集合体系结构Collection是单列集合的祖宗,它规定的方法(功能)是全部单列集合都会继承的。collection集合体系Collection的常用方法packagecom.itchinajie.d1_collection;importjava.util.ArrayList;importjava.util.HashSet;/**目标:认识Collection体系的特点。*......
  • python入门语法
    python入门语法前言python概述:是一门以面向对象为基础的面向函数式编程语言,强调的解决问题本身,而不是语言的结构等.特点:简单,易学,开源,免费,可移植性,丰富的库.1.变量变量介绍概述:用于临时存储数据的,存储在内存中,程序执行结束,数据就丢失了.......
  • Ubuntu基础入门指南
    简介        Ubuntu是一个基于Debian的Linux发行版,以其易用性和强大的社区支持而闻名。无论你是初学者还是有经验的用户,Ubuntu都能提供丰富的功能和友好的用户体验。本博客将带你了解Ubuntu的基础知识,帮助你快速入门。安装Ubuntu        安装Ubuntu相对简......
  • AWS Lambda 十年回顾:功能总览、更新记录与入门指南
    这次,我为2014年11月发布的AWSLambda创建了一个历史时间表。AWSLambda是一项无服务器、全托管的代码执行服务,今年2024年11月将迎来其宣布发布的十周年纪念。虽然提前了一些,但为了提前庆祝这一重要时刻,我写了这篇文章。文章中,我从AWSLambda的诞生到功能的增加和更新进行了追......