Redis学习笔记--优惠券秒杀和分布式锁

优惠券秒杀

Redis实现全局唯一ID

  • 在各类购物App中,都会遇到商家发放的优惠券

  • 当用户抢购商品时,生成的订单会保存到tb_voucher_order表中,而订单表如果使用数据库自增ID就会存在一些问题

    1. id规律性太明显
    2. 受单表数据量的限制
  • 如果我们的订单id有太明显的规律,那么对于用户或者竞争对手,就很容易猜测出我们的一些敏感信息,例如商城一天之内能卖出多少单,这明显不合适

  • 随着我们商城的规模越来越大,MySQL的单表容量不宜超过500W,数据量过大之后,我们就要进行拆库拆表,拆分表了之后,他们从逻辑上讲,是同一张表,所以他们的id不能重复,于是乎我们就要保证id的唯一性

  • 那么这就引出我们的全局ID生成器了

    • 全局ID生成器是一种在分布式系统下用来生成全局唯一ID的工具,一般要满足一下特性
      • 唯一性
      • 高可用
      • 高性能
      • 递增性
      • 安全性
  • 为了增加ID的安全性,我们可以不直接使用Redis自增的数值,而是拼接一些其他信息

  • ID组成部分

    • 符号位:1bit,永远为0
    • 时间戳:31bit,以秒为单位,可以使用69年(2^31秒约等于69年)
    • 序列号:32bit,秒内的计数器,支持每秒传输2^32个不同ID
  • 那我们就根据我们分析的ID生成策略,来编写代码

    1
    2
    3
    4
    5
    6
    public static void main(String[] args) {
    //设置一下起始时间,时间戳就是起始时间与当前时间的秒数差
    LocalDateTime tmp = LocalDateTime.of(2022, 1, 1, 0, 0, 0);
    System.out.println(tmp.toEpochSecond(ZoneOffset.UTC));
    //结果为1640995200L
    }
  • 完整代码如下

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    @Component
    public class RedisIdWorker {
    @Autowired
    private StringRedisTemplate stringRedisTemplate;
    //设置起始时间,我这里设定的是2022.01.01 00:00:00
    public static final Long BEGIN_TIMESTAMP = 1640995200L;
    //序列号长度
    public static final Long COUNT_BIT = 32L;

    public long nextId(String keyPrefix){
    //1. 生成时间戳
    LocalDateTime now = LocalDateTime.now();
    long currentSecond = now.toEpochSecond(ZoneOffset.UTC);
    long timeStamp = currentSecond - BEGIN_TIMESTAMP;
    //2. 生成序列号
    String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
    long count = stringRedisTemplate.opsForValue().increment("inc:"+keyPrefix+":"+date);
    //3. 拼接并返回,简单位运算
    return timeStamp << COUNT_BIT | count;
    }
    }

添加优惠券

  • 每个店铺度可以发布优惠券,分为平价券和特价券,平价券可以任意购买,而特价券需要秒杀抢购
  • tb_voucher:优惠券的基本信息,优惠金额、使用规则等
Field Type Collation Null Key Default Extra Comment
id bigint unsigned (NULL) NO PRI (NULL) auto_increment 主键
shop_id bigint unsigned (NULL) YES (NULL) 商铺id
title varchar(255) utf8mb4_general_ci NO (NULL) 代金券标题
sub_title varchar(255) utf8mb4_general_ci YES (NULL) 副标题
rules varchar(1024) utf8mb4_general_ci YES (NULL) 使用规则
pay_value bigint unsigned (NULL) NO (NULL) 支付金额,单位是分。例如200代表2元
actual_value bigint (NULL) NO (NULL) 抵扣金额,单位是分。例如200代表2元
type tinyint unsigned (NULL) NO 0 0,普通券;1,秒杀券
status tinyint unsigned (NULL) NO 1 1,上架; 2,下架; 3,过期
create_time timestamp (NULL) NO CURRENT_TIMESTAMP DEFAULT_GENERATED 创建时间
update_time timestamp (NULL) NO CURRENT_TIMESTAMP DEFAULT_GENERATED on update CURRENT_TIMESTAMP 更新时间
  • tb_seckill_voucher:优惠券的库存、开始抢购时间,结束抢购时间。特价优惠券才需要填写这些信息
Field Type Collation Null Key Default Extra Comment
voucher_id bigint unsigned (NULL) NO PRI (NULL) 关联的优惠券的id
stock int (NULL) NO (NULL) 库存
create_time timestamp (NULL) NO CURRENT_TIMESTAMP DEFAULT_GENERATED 创建时间
begin_time timestamp (NULL) NO CURRENT_TIMESTAMP DEFAULT_GENERATED 生效时间
end_time timestamp (NULL) NO CURRENT_TIMESTAMP DEFAULT_GENERATED 失效时间
update_time timestamp (NULL) NO CURRENT_TIMESTAMP DEFAULT_GENERATED on update CURRENT_TIMESTAMP 更新时间
  • 平价卷由于优惠力度并不是很大,所以是可以任意领取
  • 而代金券由于优惠力度大,所以像第二种卷,就得限制数量,从表结构上也能看出,特价卷除了具有优惠卷的基本信息以外,还具有库存,抢购时间,结束时间等等字段
  • 添加优惠券的代码已经提供好了

新增普通券

也就只是将普通券的信息保存到表中

1
2
3
4
5
6
7
8
9
10
/**
* 新增普通券
* @param voucher 优惠券信息
* @return 优惠券id
*/
@PostMapping
public Result addVoucher(@RequestBody Voucher voucher) {
voucherService.save(voucher);
return Result.ok(voucher.getId());
}

新增秒杀券

主要看addSeckillVoucher中的业务逻辑

1
2
3
4
5
6
7
8
9
10
/**
* 新增秒杀券
* @param voucher 优惠券信息,包含秒杀信息
* @return 优惠券id
*/
@PostMapping("seckill")
public Result addSeckillVoucher(@RequestBody Voucher voucher) {
voucherService.addSeckillVoucher(voucher);
return Result.ok(voucher.getId());
}

秒杀券可以看做是一种特殊的普通券,将普通券信息保存到普通券表中,同时将秒杀券的数据保存到秒杀券表中,通过券的ID进行关联

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Override
@Transactional
public void addSeckillVoucher(Voucher voucher) {
// 保存优惠券
save(voucher);
// 保存秒杀信息
SeckillVoucher seckillVoucher = new SeckillVoucher();
// 关联普通券id
seckillVoucher.setVoucherId(voucher.getId());
// 设置库存
seckillVoucher.setStock(voucher.getStock());
// 设置开始时间
seckillVoucher.setBeginTime(voucher.getBeginTime());
// 设置结束时间
seckillVoucher.setEndTime(voucher.getEndTime());
// 保存信息到秒杀券表中
seckillVoucherService.save(seckillVoucher);
}
  • 由于这里并没有后台管理页面,所以我们只能用POSTMAN模拟发送请求来新增秒杀券,请求路径http://localhost:8081/voucher/seckill,请求方式POST,JSON数据如下

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    {
    "shopId":1,
    "title":"100元代金券",
    "subTitle":"周一至周五可用",
    "rules":"全场通用\\n无需预约\\n可无限叠加",
    "payValue":8000,
    "actualValue":10000,
    "type":1,
    "stock":100,
    "beginTime":"2022-01-01T00:00:00",
    "endTime":"2022-10-31T23:59:59"
    }
  • 效果如下
    img

实现秒杀下单

  • 我们点击限时抢购,然后查看发送的请求

    1
    2
    请求网址: http://localhost:8080/api/voucher-order/seckill/13
    请求方法: POST
  • 看样子是VoucherOrderController里的方法

    1
    2
    3
    4
    5
    6
    7
    8
    @RestController
    @RequestMapping("/voucher-order")
    public class VoucherOrderController {
    @PostMapping("seckill/{id}")
    public Result seckillVoucher(@PathVariable("id") Long voucherId) {
    return Result.fail("功能未完成");
    }
    }
  • 那我们现在来分析一下怎么抢优惠券

    • 首先提交优惠券id,然后查询优惠券信息
    • 之后判断秒杀时间是否开始
      • 开始了,则判断是否有剩余库存
        • 有库存,那么删减一个库存
          • 然后创建订单
        • 无库存,则返回一个错误信息
      • 没开始,则返回一个错误信息
  • 对应的流程图如下
    img

  • 那现在我们就根据我们刚刚的分析和流程图,来编写对应的代码

    • VoucherOrderController
    • IVoucherOrderService
    • VoucherOrderServiceImpl

    具体的业务逻辑我们还是放到Service层里写,在Service层创建seckillVoucher方法

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    @RestController
    @RequestMapping("/voucher-order")
    public class VoucherOrderController {
    @Autowired
    private ISeckillVoucherService seckillVoucherService;
    @PostMapping("seckill/{id}")
    public Result seckillVoucher(@PathVariable("id") Long voucherId) {

    return seckillVoucherService.seckillVoucher(voucherId);
    }
    }

超卖问题

  • 我们之前的代码其实是有问题的,当遇到高并发场景时,会出现超卖现象,我们可以用Jmeter开200个线程来模拟抢优惠券的场景,URL为 localhost:8081/voucher-order/seckill/13,请求方式为POST

    注意使用Jmeter进行压测时,需要携带我们登录的token
    img

  • 测试完毕之后,查看数据库中的订单表,我们明明只设置了100张优惠券,却有166条数据,去优惠券表查看,库存为-66,超卖了66张
    img

  • 那么如何解决这个问题呢?先来看看我们的代码中是怎么写的

    1
    2
    3
    4
    5
    6
    7
    8
    9
    //4. 判断库存是否充足
    if (seckillVoucher.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也过来查询库存,发现库存数也大于1,那么这两个线程都会进行扣减库存操作,最终相当于是多个线程都进行了扣减库存,那么此时就会出现超卖问题

  • 超卖问题是典型的多线程安全问题,针对这一问题的常见解决方案就是加锁:而对于加锁,我们通常有两种解决方案

    1. 悲观锁
      • 悲观锁认为线程安全问题一定会发生,因此在操作数据之前先获取锁,确保线程串行执行
      • 例如Synchronized、Lock等,都是悲观锁
    2. 乐观锁
      • 乐观锁认为线程安全问题不一定会发生,因此不加锁,只是在更新数据的时候再去判断有没有其他线程对数据进行了修改
        • 如果没有修改,则认为自己是安全的,自己才可以更新数据
        • 如果已经被其他线程修改,则说明发生了安全问题,此时可以重试或者异常
  • 悲观锁:悲观锁可以实现对于数据的串行化执行,比如syn,和lock都是悲观锁的代表,同时,悲观锁中又可以再细分为公平锁,非公平锁,可重入锁,等等

  • 乐观锁:乐观锁会有一个版本号,每次操作数据会对版本号+1,再提交回数据时,会去校验是否比之前的版本大1 ,如果大1 ,则进行操作成功,这套机制的核心逻辑在于,如果在操作过程中,版本号只比原来大1 ,那么就意味着操作过程中没有人对他进行过修改,他的操作就是安全的,如果不大1,则数据被修改过,当然乐观锁还有一些变种的处理方式比如cas

  • 乐观锁的典型代表:就是cas,利用cas进行无锁化机制加锁,var5 是操作前读取的内存值,while中的var1+var2 是预估值,如果预估值 == 内存值,则代表中间没有被人修改过,此时就将新值去替换 内存值

    1
    2
    3
    4
    5
    int var5;
    do {
    var5 = this.getIntVolatile(var1, var2);
    } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));
    return var5;
  • 其中do while是为了操作失败时,再次进行自旋操作,即把之前的逻辑再操作一次

  • 该项目中的具体解决方式

  • 这里并不需要真的来指定一下版本号,完全可以使用stock来充当版本号,在扣减库存时,比较查询到的优惠券库存和实际数据库中优惠券库存是否相同

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    @Override
    public Result seckillVoucher(Long voucherId) {
    LambdaQueryWrapper<SeckillVoucher> queryWrapper = new LambdaQueryWrapper<>();
    //1. 查询优惠券
    queryWrapper.eq(SeckillVoucher::getVoucherId, voucherId);
    SeckillVoucher seckillVoucher = seckillVoucherService.getOne(queryWrapper);
    //2. 判断秒杀时间是否开始
    if (LocalDateTime.now().isBefore(seckillVoucher.getBeginTime())) {
    return Result.fail("秒杀还未开始,请耐心等待");
    }
    //3. 判断秒杀时间是否结束
    if (LocalDateTime.now().isAfter(seckillVoucher.getEndTime())) {
    return Result.fail("秒杀已经结束!");
    }
    //4. 判断库存是否充足
    if (seckillVoucher.getStock() < 1) {
    return Result.fail("优惠券已被抢光了哦,下次记得手速快点");
    }
    //5. 扣减库存
    boolean success = seckillVoucherService.update()
    .setSql("stock = stock - 1")
    .eq("voucher_id", voucherId)
    + .eq("stock",seckillVoucher.getStock())
    .update();
    if (!success) {
    return Result.fail("库存不足");
    }
    //6. 创建订单
    VoucherOrder voucherOrder = new VoucherOrder();
    //6.1 设置订单id
    long orderId = redisIdWorker.nextId("order");
    //6.2 设置用户id
    Long id = UserHolder.getUser().getId();
    //6.3 设置代金券id
    voucherOrder.setVoucherId(voucherId);
    voucherOrder.setId(orderId);
    voucherOrder.setUserId(id);
    //7. 将订单数据保存到表中
    save(voucherOrder);
    //8. 返回订单id
    return Result.ok(orderId);
    }
  • 以上逻辑的核心含义是:只要我扣减库存时的库存和之前我查询到的库存是一样的,就意味着没有人在中间修改过库存,那么此时就是安全的,但是以上这种方式通过测试发现会有很多失败的情况,失败的原因在于:在使用乐观锁过程中假设100个线程同时都拿到了100的库存,然后大家一起去进行扣减,但是100个人中只有1个人能扣减成功,其他的人在处理时,他们在扣减时,库存已经被修改过了,所以此时其他线程都会失败
    img

  • 那么我们继续完善代码,修改我们的逻辑,在这种场景,我们可以只判断是否有剩余优惠券,即只要数据库中的库存大于0,都能顺利完成扣减库存操作

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    @Override
    public Result seckillVoucher(Long voucherId) {
    LambdaQueryWrapper<SeckillVoucher> queryWrapper = new LambdaQueryWrapper<>();
    //1. 查询优惠券
    queryWrapper.eq(SeckillVoucher::getVoucherId, voucherId);
    SeckillVoucher seckillVoucher = seckillVoucherService.getOne(queryWrapper);
    //2. 判断秒杀时间是否开始
    if (LocalDateTime.now().isBefore(seckillVoucher.getBeginTime())) {
    return Result.fail("秒杀还未开始,请耐心等待");
    }
    //3. 判断秒杀时间是否结束
    if (LocalDateTime.now().isAfter(seckillVoucher.getEndTime())) {
    return Result.fail("秒杀已经结束!");
    }
    //4. 判断库存是否充足
    if (seckillVoucher.getStock() < 1) {
    return Result.fail("优惠券已被抢光了哦,下次记得手速快点");
    }
    //5. 扣减库存
    boolean success = seckillVoucherService.update()
    .setSql("stock = stock - 1")
    .eq("voucher_id", voucherId)
    - .eq("stock",seckillVoucher.getStock())
    + .gt("stock", 0)
    .update();
    if (!success) {
    return Result.fail("库存不足");
    }
    //6. 创建订单
    VoucherOrder voucherOrder = new VoucherOrder();
    //6.1 设置订单id
    long orderId = redisIdWorker.nextId("order");
    //6.2 设置用户id
    Long id = UserHolder.getUser().getId();
    //6.3 设置代金券id
    voucherOrder.setVoucherId(voucherId);
    voucherOrder.setId(orderId);
    voucherOrder.setUserId(id);
    //7. 将订单数据保存到表中
    save(voucherOrder);
    //8. 返回订单id
    return Result.ok(orderId);
    }
  • 重启服务器,继续使用Jmeter进行测试,这次就能顺利将优惠券刚好抢空了

一人一单

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

  • 具体操作逻辑如下:我们在判断库存是否充足之后,根据我们保存的订单数据,判断用户订单是否已存在

    • 如果已存在,则不能下单,返回错误信息
    • 如果不存在,则继续下单,获取优惠券
  • 初步代码

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    	@Override
    public Result seckillVoucher(Long voucherId) {
    LambdaQueryWrapper<SeckillVoucher> queryWrapper = new LambdaQueryWrapper<>();
    //1. 查询优惠券
    queryWrapper.eq(SeckillVoucher::getVoucherId, voucherId);
    SeckillVoucher seckillVoucher = seckillVoucherService.getOne(queryWrapper);
    //2. 判断秒杀时间是否开始
    if (LocalDateTime.now().isBefore(seckillVoucher.getBeginTime())) {
    return Result.fail("秒杀还未开始,请耐心等待");
    }
    //3. 判断秒杀时间是否结束
    if (LocalDateTime.now().isAfter(seckillVoucher.getEndTime())) {
    return Result.fail("秒杀已经结束!");
    }
    //4. 判断库存是否充足
    if (seckillVoucher.getStock() < 1) {
    return Result.fail("优惠券已被抢光了哦,下次记得手速快点");
    }
    + // 一人一单逻辑
    + Long userId = UserHolder.getUser().getId();
    + int count = query().eq("voucherId", voucherId).eq("userId", userId).count();
    + if (count > 0){
    + return Result.fail("你已经抢过优惠券了哦");
    + }
    //5. 扣减库存
    boolean success = seckillVoucherService.update()
    .setSql("stock = stock - 1")
    .eq("voucher_id", voucherId)
    .gt("stock", 0)
    .update();
    if (!success) {
    return Result.fail("库存不足");
    }
    //6. 创建订单
    VoucherOrder voucherOrder = new VoucherOrder();
    //6.1 设置订单id
    long orderId = redisIdWorker.nextId("order");
    //6.2 设置用户id
    Long id = UserHolder.getUser().getId();
    //6.3 设置代金券id
    voucherOrder.setVoucherId(voucherId);
    voucherOrder.setId(orderId);
    voucherOrder.setUserId(id);
    //7. 将订单数据保存到表中
    save(voucherOrder);
    //8. 返回订单id
    return Result.ok(orderId);
    }
  • 存在问题:还是和之前一样,如果这个用户故意开多线程抢优惠券,那么在判断库存充足之后,执行一人一单逻辑之前,在这个区间如果进来了多个线程,还是可以抢多张优惠券的,那我们这里使用悲观锁来解决这个问题

  • 初步代码,我们把一人一单逻辑之后的代码都提取到一个createVoucherOrder方法中,然后给这个方法加锁

  • 不管哪一个线程(例如线程A),运行到这个方法时,都要检查有没有其它线程B(或者C、 D等)正在用这个方法(或者该类的其他同步方法),有的话要等正在使用synchronized方法的线程B(或者C 、D)运行完这个方法后再运行此线程A,没有的话,锁定调用者,然后直接运行。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    private Result createVoucherOrder(Long voucherId) {
    // 一人一单逻辑
    Long userId = UserHolder.getUser().getId();
    int count = query().eq("voucherId", voucherId).eq("userId", userId).count();
    if (count > 0) {
    return Result.fail("你已经抢过优惠券了哦");
    }
    //5. 扣减库存
    boolean success = seckillVoucherService.update()
    .setSql("stock = stock - 1")
    .eq("voucher_id", voucherId)
    .gt("stock", 0)
    .update();
    if (!success) {
    return Result.fail("库存不足");
    }
    //6. 创建订单
    VoucherOrder voucherOrder = new VoucherOrder();
    //6.1 设置订单id
    long orderId = redisIdWorker.nextId("order");
    //6.2 设置用户id
    Long id = UserHolder.getUser().getId();
    //6.3 设置代金券id
    voucherOrder.setVoucherId(voucherId);
    voucherOrder.setId(orderId);
    voucherOrder.setUserId(id);
    //7. 将订单数据保存到表中
    save(voucherOrder);
    //8. 返回订单id
    return Result.ok(orderId);
    }
  • 但是这样加锁,锁的细粒度太粗了,在使用锁的过程中,控制锁粒度是一个非常重要的事情,因为如果锁的粒度太大,会导致每个线程进来都会被锁住,现在的情况就是所有用户都公用这一把锁,串行执行,效率很低,我们现在要完成的业务是一人一单,所以这个锁,应该只加在单个用户上,用户标识可以用userId

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    @Transactional
    public Result createVoucherOrder(Long voucherId) {
    // 一人一单逻辑
    Long userId = UserHolder.getUser().getId();
    synchronized (userId.toString().intern()) {
    int count = query().eq("voucherId", voucherId).eq("userId", userId).count();
    if (count > 0) {
    return Result.fail("你已经抢过优惠券了哦");
    }
    //5. 扣减库存
    boolean success = seckillVoucherService.update()
    .setSql("stock = stock - 1")
    .eq("voucher_id", voucherId)
    .gt("stock", 0)
    .update();
    if (!success) {
    return Result.fail("库存不足");
    }
    //6. 创建订单
    VoucherOrder voucherOrder = new VoucherOrder();
    //6.1 设置订单id
    long orderId = redisIdWorker.nextId("order");
    //6.2 设置用户id
    Long id = UserHolder.getUser().getId();
    //6.3 设置代金券id
    voucherOrder.setVoucherId(voucherId);
    voucherOrder.setId(orderId);
    voucherOrder.setUserId(id);
    //7. 将订单数据保存到表中
    save(voucherOrder);
    //8. 返回订单id
    return Result.ok(orderId);
    }
    //执行到这里,锁已经被释放了,但是可能当前事务还未提交,如果此时有线程进来,不能确保事务不出问题
    }
  • 由于toString的源码是new String,所以如果我们只用userId.toString()拿到的也不是同一个用户,需要使用

    intern(),如果字符串常量池中已经包含了一个等于这个string对象的字符串(由equals(object)方法确定),那么将返回池中的字符串。否则,将此String对象添加到池中,并返回对此String对象的引用。

    1
    2
    3
    4
    5
    6
    7
    8
    public static String toString(long i) {
    if (i == Long.MIN_VALUE)
    return "-9223372036854775808";
    int size = (i < 0) ? stringSize(-i) + 1 : stringSize(i);
    char[] buf = new char[size];
    getChars(i, size, buf);
    return new String(buf, true);
    }
  • 但是以上代码还是存在问题,问题的原因在于当前方法被Spring的事务控制,如果你在内部加锁,可能会导致当前方法事务还没有提交,但是锁已经释放了,这样也会导致问题,所以我们选择将当前方法整体包裹起来,确保事务不会出现问题

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    @Override
    public Result seckillVoucher(Long voucherId) {
    LambdaQueryWrapper<SeckillVoucher> queryWrapper = new LambdaQueryWrapper<>();
    //1. 查询优惠券
    queryWrapper.eq(SeckillVoucher::getVoucherId, voucherId);
    SeckillVoucher seckillVoucher = seckillVoucherService.getOne(queryWrapper);
    //2. 判断秒杀时间是否开始
    if (LocalDateTime.now().isBefore(seckillVoucher.getBeginTime())) {
    return Result.fail("秒杀还未开始,请耐心等待");
    }
    //3. 判断秒杀时间是否结束
    if (LocalDateTime.now().isAfter(seckillVoucher.getEndTime())) {
    return Result.fail("秒杀已经结束!");
    }
    //4. 判断库存是否充足
    if (seckillVoucher.getStock() < 1) {
    return Result.fail("优惠券已被抢光了哦,下次记得手速快点");
    }
    Long userId = UserHolder.getUser().getId();
    synchronized (userId.toString().intern()) {
    return createVoucherOrder(voucherId);
    }
    }
  • 但是以上做法依然有问题,因为你调用的方法,其实是this.的方式调用的,事务想要生效,还得利用代理来生效,所以这个地方,我们需要获得原始的事务对象, 来操作事务,这里可以使用AopContext.currentProxy()

    来获取当前对象的代理对象,然后再用代理对象调用方法,记得要去IVoucherOrderService

    中创建createVoucherOrder方法

    1
    2
    3
    4
    5
    Long userId = UserHolder.getUser().getId();
    synchronized (userId.toString().intern()) {
    IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
    return proxy.createVoucherOrder(voucherId);
    }
  • 但是该方法会用到一个依赖,我们需要导入一下

    1
    2
    3
    4
    <dependency>
    <groupId>org.aspectj</groupId>
    <artifactId>aspectjweaver</artifactId>
    </dependency>
  • 同时在启动类上加上

    1
    @EnableAspectJAutoProxy(exposeProxy = true)

    注解

    1
    2
    3
    4
    5
    6
    7
    8
    9
    @MapperScan("com.hmdp.mapper")
    @SpringBootApplication
    @EnableAspectJAutoProxy(exposeProxy = true)
    public class HmDianPingApplication {
    public static void main(String[] args) {
    SpringApplication.run(HmDianPingApplication.class, args);
    }

    }
  • 重启服务器,再次使用Jmeter测试,200个线程并发,但是只能抢到一张优惠券,目的达成

集群环境下的并发问题

  • 通过加锁可以解决在单机情况下的一人一单安全问题,但是在集群模式下就不行了
    1. 我们将服务启动两份,端口分别为8081和8082
    2. 然后修改nginx的config目录下的nginx.conf文件,配置反向代理和负载均衡(默认轮询就行)
  • 具体操作,我们使用POSTMAN发送两次请求,header携带同一用户的token,尝试用同一账号抢两张优惠券,发现是可行的。
  • 失败原因分析:由于我们部署了多个Tomcat,每个Tomcat都有一个属于自己的jvm,那么假设在服务器A的Tomcat内部,有两个线程,即线程1和线程2,这两个线程使用的是同一份代码,那么他们的锁对象是同一个,是可以实现互斥的。但是如果在Tomcat的内部,又有两个线程,但是他们的锁对象虽然写的和服务器A一样,但是锁对象却不是同一个,所以线程3和线程4可以实现互斥,但是却无法和线程1和线程2互斥
    img
  • 这就是集群环境下,syn锁失效的原因,在这种情况下,我们需要使用分布式锁来解决这个问题,让锁不存在于每个jvm的内部,而是让所有jvm公用外部的一把锁(Redis)

分布式锁

基本原理和实现方式对比

  • 分布式锁:满足分布式系统或集群模式下多线程课件并且可以互斥的锁

  • 分布式锁的核心思想就是让大家共用同一把锁,那么我们就能锁住线程,不让线程进行,让程序串行执行,这就是分布式锁的核心思路
    img

  • 那么分布式锁应该满足一些什么条件呢?

    1. 可见性:多个线程都能看到相同的结果。

      注意:这里说的可见性并不是并发编程中指的内存可见性,只是说多个进程之间都能感知到变化的意思

    2. 互斥:互斥是分布式锁的最基本条件,使得程序串行执行

    3. 高可用:程序不已崩溃,时时刻刻都保证较高的可用性

    4. 高性能:由于加锁本身就让性能降低,所以对于分布式锁需要他较高的加锁性能和释放锁性能

    5. 安全性:安全也是程序中必不可少的一环

  • 常见的分布式锁有三种

    1. MySQL:MySQL本身就带有锁机制,但是由于MySQL的性能一般,所以采用分布式锁的情况下,使用MySQL作为分布式锁比较少见
    2. Redis:Redis作为分布式锁是非常常见的一种使用方式,现在企业级开发中基本都是用Redis或者Zookeeper作为分布式锁,利用SETNX这个方法,如果插入Key成功,则表示获得到了锁,如果有人插入成功,那么其他人就回插入失败,无法获取到锁,利用这套逻辑完成互斥,从而实现分布式锁
    3. Zookeeper:Zookeeper也是企业级开发中较好的一种实现分布式锁的方案,但本文是学Redis的,所以这里就不过多阐述了
MySQL Redis Zookeeper
互斥 利用mysql本身的互斥锁机制 利用setnx这样的互斥命令 利用节点的唯一性和有序性实现互斥
高可用
高性能 一般 一般
安全性 断开连接,自动释放锁 利用锁超时时间,到期释放 临时节点,断开连接自动释放

Redis分布式锁的实现核心思路

  • 实现分布式锁时需要实现两个基本方法

    1. 获取锁

      • 互斥:确保只能有一个线程获取锁

      • 非阻塞:尝试一次,成功返回true,失败返回false

        1
        SET lock thread01 NX EX 10
    2. 释放锁

      • 手动释放

      • 超时释放:获取锁的时候添加一个超时时间

        1
        DEL lock
  • 核心思路

    • 我们利用redis的SETNX方法,当有多个线程进入时,我们就利用该方法来获取锁。第一个线程进入时,redis 中就有这个key了,返回了1,如果结果是1,则表示他抢到了锁,那么他去执行业务,然后再删除锁,退出锁逻辑,没有抢到锁(返回了0)的线程,等待一定时间之后重试

实现分布式锁

  • 锁的基本接口

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    public interface ILock {
    /**
    * 尝试获取锁
    *
    * @param timeoutSec 锁持有的超时时间,过期自动释放
    * @return true表示获取锁成功,false表示获取锁失败
    */
    boolean tryLock(long timeoutSec);

    /**
    * 释放锁
    */
    void unlock();
    }
  • 然后创建一个SimpleRedisLock类实现接口

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    public class SimpleRedisLock implements ILock {
    //锁的前缀
    private static final String KEY_PREFIX = "lock:";
    //具体业务名称,将前缀和业务名拼接之后当做Key
    private String name;
    //这里不需要@Autowired,因为该对象是我们使用构造函数手动new出来的
    private StringRedisTemplate stringRedisTemplate;

    public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
    this.name = name;
    this.stringRedisTemplate = stringRedisTemplate;
    }

    @Override
    public boolean tryLock(long timeoutSec) {
    //获取线程标识
    long threadId = Thread.currentThread().getId();
    //获取锁,使用SETNX方法进行加锁,同时设置过期时间,防止死锁
    Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(KEY_PREFIX + name, threadId + "", timeoutSec, TimeUnit.SECONDS);
    //自动拆箱可能会出现null,这样写更稳妥
    return Boolean.TRUE.equals(success);
    }

    @Override
    public void unlock() {
    //通过DEL来删除锁
    stringRedisTemplate.delete(KEY_PREFIX + name);
    }
    }
  • 修改业务代码

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    @Override
    public Result seckillVoucher(Long voucherId) {
    LambdaQueryWrapper<SeckillVoucher> queryWrapper = new LambdaQueryWrapper<>();
    //1. 查询优惠券
    queryWrapper.eq(SeckillVoucher::getVoucherId, voucherId);
    SeckillVoucher seckillVoucher = seckillVoucherService.getOne(queryWrapper);
    //2. 判断秒杀时间是否开始
    if (LocalDateTime.now().isBefore(seckillVoucher.getBeginTime())) {
    return Result.fail("秒杀还未开始,请耐心等待");
    }
    //3. 判断秒杀时间是否结束
    if (LocalDateTime.now().isAfter(seckillVoucher.getEndTime())) {
    return Result.fail("秒杀已经结束!");
    }
    //4. 判断库存是否充足
    if (seckillVoucher.getStock() < 1) {
    return Result.fail("优惠券已被抢光了哦,下次记得手速快点");
    }
    Long userId = UserHolder.getUser().getId();
    // 创建锁对象
    SimpleRedisLock redisLock = new SimpleRedisLock("order:" + userId, stringRedisTemplate);
    // 获取锁对象
    boolean isLock = redisLock.tryLock(120);
    // 加锁失败,说明当前用户开了多个线程抢优惠券,但是由于key是SETNX的,所以不能创建key,得等key的TTL到期或释放锁(删除key)
    if (!isLock) {
    return Result.fail("不允许抢多张优惠券");
    }
    try {
    // 获取代理对象
    IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
    return proxy.createVoucherOrder(voucherId);
    } finally {
    // 释放锁
    redisLock.unlock();
    }
    }
  • 使用Jmeter进行压力测试,请求头中携带登录用户的token,最终只能抢到一张优惠券

Redis分布式锁误删情况说明

  • ```
    逻辑说明
    1
    2
    3
    4
    5
    6
    7
    8

    - 持有锁的线程1在锁的内部出现了阻塞,导致他的锁TTL到期,自动释放
    - 此时线程2也来尝试获取锁,由于线程1已经释放了锁,所以线程2可以拿到
    - 但是现在线程1阻塞完了,继续往下执行,要开始释放锁了
    - 那么此时就会将属于线程2的锁释放,这就是误删别人锁的情况

    - ```
    解决方案
    • 解决方案就是在每个线程释放锁的时候,都判断一下这个锁是不是自己的,如果不属于自己,则不进行删除操作。
    • 假设还是上面的情况,线程1阻塞,锁自动释放,线程2进入到锁的内部执行逻辑,此时线程1阻塞完了,继续往下执行,开始删除锁,但是线程1发现这把锁不是自己的,所以不进行删除锁的逻辑,当线程2执行到删除锁的逻辑时,如果TTL还未到期,则判断当前这把锁是自己的,于是删除这把锁
      img

解决Redis分布式锁误删问题

  • 需求:修改之前的分布式锁实现

  • 满足:在获取锁的时候存入线程标识(用UUID标识,在一个JVM中,ThreadId一般不会重复,但是我们现在是集群模式,有多个JVM,多个JVM之间可能会出现ThreadId重复的情况),在释放锁的时候先获取锁的线程标识,判断是否与当前线程标识一致

    • 如果一致则释放锁
    • 如果不一致则不释放锁
  • 核心逻辑:在存入锁的时候,放入自己的线程标识,在删除锁的时候,判断当前这把锁是不是自己存入的

    • 如果是,则进行删除
    • 如果不是,则不进行删除
  • 具体实现代码如下

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";
    @Override
    public boolean tryLock(long timeoutSec) {
    // 获取线程标识
    String threadId = ID_PREFIX + Thread.currentThread().getId();
    // 获取锁
    Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS);
    return Boolean.TRUE.equals(success);
    }

    @Override
    public void unlock() {
    // 获取当前线程的标识
    String threadId = ID_PREFIX + Thread.currentThread().getId();
    // 获取锁中的标识
    String id = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);
    // 判断标识是否一致
    if (threadId.equals(id)) {
    // 释放锁
    stringRedisTemplate.delete(KEY_PREFIX + name);
    }
    }

分布式锁的原子性问题

  • 更为极端的误删逻辑说明
  • 假设线程1已经获取了锁,在判断标识一致之后,准备释放锁的时候,又出现了阻塞(例如JVM垃圾回收机制)
  • 于是锁的TTL到期了,自动释放了
  • 那么现在线程2趁虚而入,拿到了一把锁
  • 但是线程1的逻辑还没执行完,那么线程1就会执行删除锁的逻辑
  • 但是在阻塞前线程1已经判断了标识一致,所以现在线程1把线程2的锁给删了
  • 那么就相当于判断标识那行代码没有起到作用
  • 这就是删锁时的原子性问题
  • 因为线程1的拿锁,判断标识,删锁,不是原子操作,所以我们要防止刚刚的情况

img

Lua脚本解决多条命令原子性问题

  • Redis提供了Lua脚本功能,在一个脚本中编写多条Redis命令,确保多条命令执行时的原子性。

  • Lua是一种编程语言,它的基本语法可以上菜鸟教程看看,链接:https://www.runoob.com/lua/lua-tutorial.html

  • 这里重点介绍Redis提供的调用函数,我们可以使用Lua去操作Redis,而且还能保证它的原子性,这样就可以实现拿锁判断标识删锁是一个原子性动作了

  • Redis提供的调用函数语法如下

    1
    redis.call('命令名称','key','其他参数', ...)
  • 例如我们要执行set name Kyle,则脚本是这样

    1
    redis.call('set', 'name', 'Kyle')
  • 例如我我们要执行set name David,在执行get name,则脚本如下

    1
    2
    3
    4
    5
    6
    # 先执行set name David
    redis.call('set', 'name', 'David')
    # 再执行get name
    local name = redis.call('get', 'name')
    # 返回
    return name
  • 写好脚本以后,需要用Redis命令来调用脚本,调用脚本的常见命令如下

    1
    EVAL script numkeys key [key ...] arg [arg ...]
  • 例如,我们要调用redis.call(‘set’, ‘name’, ‘Kyle’) 0这个脚本,语法如下

    1
    EVAL "return redis.call('set', 'name', 'Kyle')" 0
  • 如果脚本中的key和value不想写死,可以作为参数传递,key累心参数会放入KEYS数组,其他参数会放入ARGV数组,在脚本中可以从KEYS和ARGV数组中获取这些参数

    注意:在Lua中,数组下标从1开始

    1
    EVAL "return redis.call('set', KEYS[1], ARGV[1])" 1 name Lucy
  • 那现在我们来使用Lua脚本来代替我们释放锁的逻辑

原逻辑

1
2
3
4
5
6
7
8
9
10
11
12
@Override
public void unlock() {
// 获取当前线程的标识
String threadId = ID_PREFIX + Thread.currentThread().getId();
// 获取锁中的标识
String id = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);
// 判断标识是否一致
if (threadId.equals(id)) {
// 释放锁
stringRedisTemplate.delete(KEY_PREFIX + name);
}
}

改写为Lua脚本01

1
2
3
4
5
6
7
8
9
10
11
12
-- 线程标识
local threadId = "UUID-31"
-- 锁的key
local key = "lock:order:userId"
-- 获取锁中线程标识
local id = redis.call('get', key)
-- 比较线程标识与锁的标识是否一致
if (threadId == id) then
-- 一致则释放锁 del key
return redis.call('del', key)
end
return 0

改写为Lua脚本02

但是现在是写死了的,我们可以通过传参的方式来变成动态的Lua脚本

1
2
3
4
5
6
7
8
-- 这里的KEYS[1]就是传入锁的key
-- 这里的ARGV[1]就是线程标识
-- 比较锁中的线程标识与线程标识是否一致
if (redis.call('get', KEYS[1]) == ARGV[1]) then
-- 一致则释放锁
return redis.call('del', KEYS[1])
end
return 0

Redis学习笔记--优惠券秒杀和分布式锁
https://yztldxdz.top/2023/03/01/Redis学习笔记--优惠券秒杀和分布式锁/
发布于
2023年3月1日
许可协议