Redis实战篇--优惠券秒杀

文章目录

  • Redis实战篇--优惠券秒杀
    • 全局唯一ID
    • 实现优惠券秒杀下单
    • 超卖问题
    • 一人一单
    • 分布式锁
      • 基于redis的分布式锁

Redis实战篇–优惠券秒杀

全局唯一ID

为什么需要全局唯一id?
优惠券抢购需要确保订单的唯一性

当用户抢购时,就会生成订单并保存到tb voucher order这张表中,而订单表如果使用数据库自增ID就存在一些问题

  • id的规律性太明显
  • 受单表数据量的限制

全局ID生成器,是一种在分布式系统下用来生成全局唯一ID的工具,一般要满足下列特性:
Redis实战篇--优惠券秒杀_第1张图片
这里使用redis的自增 加上拼接时间戳来达到以上特性
Redis实战篇--优惠券秒杀_第2张图片ID的组成部分

  • 符号位:bit,永远为0
  • 时间戳:31bit,以秒为单位,可以使用69年
  • 序列号:32bit,秒内的计数器,支持每秒产生2^32个不同ID

实现代码

@Component
public class RedisIdWorker {

    //设立开始时间戳
    //开始  时间戳 2022 01 01 00:00
    private static final long BEGIN_TIMESTAMP = 1640995200L;
    private static final int COUNT_BITS = 32;

    //利用构造器注入StringRedisTemplate
    private StringRedisTemplate stringRedisTemplate;
    public RedisIdWorker(StringRedisTemplate stringRedisTemplate) {
        this.stringRedisTemplate = stringRedisTemplate;
    }

    public long nextId(String keyPrefix){
        //1.生成时间戳
        //获取到当前的秒数
        LocalDateTime now = LocalDateTime.now();
        //转换成秒数
        long newSecond = now.toEpochSecond(ZoneOffset.UTC);
        long timestamp = newSecond - 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.拼接并返回
        return timestamp << COUNT_BITS | count;
    }

//    public static void main(String[] args) {
//        LocalDateTime time = LocalDateTime.of(2022, 1, 1, 0, 0);
//        long second = time.toEpochSecond(ZoneOffset.UTC);
//        System.out.println("second = " + second);
//    }
}

总结
全局唯一ID生成策略:

  • UUID
  • Redis自增
  • snowflake算法
  • 数据库自增

Redis白增ID策略:

  • 每天一个key,方便统计订单量
  • ID构造是时间戳+计数器

实现优惠券秒杀下单

首先需要新增秒杀券

在VoucherController中有添加秒杀券的接口

 /**
     * 新增秒杀券
     * @param voucher 优惠券信息,包含秒杀信息
     * @return 优惠券id
     */
    @PostMapping("seckill")
    public Result addSeckillVoucher(@RequestBody Voucher voucher) {
        voucherService.addSeckillVoucher(voucher);
        return Result.ok(voucher.getId());
    }
/**
具体实现方法
*/
@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实战篇--优惠券秒杀_第3张图片
接下来需要实现下单功能
下单时需要进行以下几点判断

  • 1 秒杀是否开始
  • 2 库存是否充足

具体的业务流程如下:
Redis实战篇--优惠券秒杀_第4张图片
代码:

 public Result seckillVoucher(Long voucherId) {
        //获取优惠券相关信息
        SeckillVoucher seckillVoucher = seckillVoucherService.getById(voucherId);
        //判断活动是否开始
        if (LocalDateTime.now().isBefore(seckillVoucher.getBeginTime())) {
            return Result.fail("活动尚未开始!");
        }
        if (LocalDateTime.now().isAfter(seckillVoucher.getEndTime())) {
            return Result.fail("活动时间已经截至!");
        }
        //判断是否还有库存
        Integer stock = seckillVoucher.getStock();
        if (stock <= 0) {
            return Result.fail("很遗憾,优惠券已被抢光!");
        }
        //获取用户
        UserDTO user = UserHolder.getUser();
        //生成订单
        VoucherOrder voucherOrder = new VoucherOrder();
        // 全局唯一id生成器生成订单id
        long orderId = redisIdWorker.nextId("order");
        voucherOrder.setId(orderId);
        //用户id
        voucherOrder.setUserId(user.getId());
        //代金券id
        voucherOrder.setVoucherId(voucherId);
		//保存
        save(voucherOrder);
        //返回
        return Result.ok(orderId);
    }

超卖问题

以上知识完成了下单功能,还有许多问题需要解决,例如优惠券的超卖问题
如下图所示:
当库存剩余为1时,线程1与线程2几乎同时进行库存查询且结果都为1,完成判断库存大于0后都要开始生成订单并减去库存 此时的库存就变成了-1,变成了超卖。(为什么会有超卖问题)
Redis实战篇--优惠券秒杀_第5张图片
超卖问题时经典的多线程安全问题,常见的解决方案就是加锁。
悲观锁与乐观锁
Redis实战篇--优惠券秒杀_第6张图片
为了提高性能这里本项目采用了乐观锁的解决方案。

乐观锁的关键就是判断之前查询得到的数据是否配修改过

乐观锁主要有两种方法

  • 版本号发
  • CAS法

版本号法
一般是说在数据表中加上一个数据库版本号version字段,在表述数据被修改的次数当数据被修改时,它的version 值会加1。

当然线程A需要更新数据值时,在读取数据的同时也会读取 version 值,在提交更新时,若刚才读取到的 version 值为当前数据库中的 version 值相等时才更新,否则重试更新操作,直到更新成功。
Redis实战篇--优惠券秒杀_第7张图片
这里使用版本号法来实现 因为stock字段与version时同时变化的的就省去了添加version字段.

代码实现:

/**
由于是库存系统 所以只需在减少库存时判断stock是否大于0即可
*/
boolean success = seckillVoucherService.update()
                .setSql("stock = stock - 1")
                .eq("voucher_id", voucherId).gt("stock", 0)
                .update();
        if (!success) {
            return Result.fail("很遗憾,优惠券已被抢光!");
        }

CAS法
CAS(compare and swap) 比较并交换,有三个操作数,内存地址V ,预期值B,要替换得到的目标子A。

CAS指令执行时,比较内存地址V与预期值B是否相等,若相等则将A赋给B,(不相等则会循环比较直到相等)整个比较赋值操作是一个原子操作。

CAS缺点:

(1)循环时间开销大:当内存地址V与预期值B不相等时会一直循环比较直到相等;

(2)只能保证一个共享变量的原子操作;

(3)如果一个变量V初次读取的时候是A值,并且在准备赋值的时候检查到它仍然是A值,那么我们就能说明它的值没有被其他线程修改过吗?很明显不是,因为在这段时间内它的值可能被改为其他值,然后又被改回A,那CAS操作就会认为它从来没被改过,这个问题就被称为 CAS 操作的“ABA” 问题;

关于乐观锁与悲观锁的详情可以去下面的链接学习
https://zhuanlan.zhihu.com/p/95296289

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

  • 优点:简单粗暴
  • 缺点:性能一般

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

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

一人一单

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

业务流程Redis实战篇--优惠券秒杀_第8张图片
代码实现:只需要经行简单的判断即可完成

//一人一单
        UserDTO user = UserHolder.getUser();

        //订单查询
        Integer count = query().eq("user_id", user.getId()).eq("voucher_id", voucherId).count();

        if (count > 0) {
            return Result.fail("该用户已经购买过了");
        }

理论上是可以达到一人一单的效果,但当同一用户的多个线程同时进行访问时,会出现线程安全问题。

一位用户没有购券记录,多个线程先后几乎同时完成历史订单查询后,还是会经行下单操作,不能达到一人一单的效果。

解决方法:加锁 保证订单查询与生成订单的唯一性

将查询与生成订单抽取成一个方法并声明成事务,在seckillVoucher中调用并加锁。

 @Override
    public Result seckillVoucher(Long voucherId) {
        //获取优惠券相关信息
        SeckillVoucher seckillVoucher = seckillVoucherService.getById(voucherId);
        //判断活动是否开始
        if (LocalDateTime.now().isBefore(seckillVoucher.getBeginTime())) {
            return Result.fail("活动尚未开始!");
        }
        if (LocalDateTime.now().isAfter(seckillVoucher.getEndTime())) {
            return Result.fail("活动时间已经截至!");
        }
        //判断是否还有库存
        Integer stock = seckillVoucher.getStock();
        if (stock <= 0) {
            return Result.fail("很遗憾,优惠券已被抢光!");
        }
        //获取用户
        UserDTO user = UserHolder.getUser();
		synchronized (user.getId().toString().intern()) {
//          //return this.createVoucherOrder(voucherId); 不具有事务功能呢
		 //获取代理
         IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
          return proxy.createVoucherOrder(voucherId);
        }
}

@Transactional
    public Result createVoucherOrder(Long voucherId) {
        //一人一单
        UserDTO user = UserHolder.getUser();

        //订单查询
        Integer count = query().eq("user_id", user.getId()).eq("voucher_id", voucherId).count();

        if (count > 0) {
            return Result.fail("该用户已经购买过了");
        }


        boolean success = seckillVoucherService.update()
                .setSql("stock = stock - 1")
                .eq("voucher_id", voucherId).gt("stock", 0)
                .update();
        if (!success) {
            return Result.fail("很遗憾,优惠券已被抢光!");
        }


        VoucherOrder voucherOrder = new VoucherOrder();
        // 全局唯一id生成器生成订单id
        long orderId = redisIdWorker.nextId("order");
        voucherOrder.setId(orderId);
        //用户id
        voucherOrder.setUserId(user.getId());
        //代金券id
        voucherOrder.setVoucherId(voucherId);
        save(voucherOrder);
        return Result.ok(orderId);
    }

关于synchronized位置的解释
1.首先不能直接加在createVoucherOrder函数上,应为只需要对统一用户的多次请求进行加锁,如果加在createVoucherOrder函数上的意识就是任何用户的请求都会被加上所,严重影响性能。
2.不能加在createVoucherOrder函数内部,原因是该函数被事务管理,函数内部释放锁时,事务还未提交,其他线程又能获取到锁,进行下单操作,达不到一人一单的效果。

关于事务失效的解释
如果直接 return createVoucherOrder(voucherId);
这里调用该函数的对象是 this就是 VoucherOrderServiceImpl 本身

因为spring声明式事物是基于AOP实现的,是使用动态代理来达到事物管理的目的,当前类调用的方法上面加@Transactional 这个是没有任何作用的,因为调用这个方法的是this,没有经过 Spring 的代理类。

事务失效的解决方法

 事务失效的解决办法
1.对当前类做动态代理,拿到代理对象用代理对象做事务处理
	IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
2.添加依赖
    
        aspectj
        aspectjweaver
        1.5.4
    
3.启动类上添加 @EnableAspectJAutoProxy(exposeProxy = true) 注解

分布式锁

前面一人一单的实现方式在单体项目上的多线程测试时能够完美的保证一人一单的功能实现。jvm内部实现监视,做到线程互斥。

但是在分布式项目中就无法实现一人一单。
原因很简单:
每启动一个tomact服务器,就会有一个独立的jvm,jvm中维护了一个锁的监视器对象,userId在常量池中,所以只要id相同,获取的永远是同一把锁。当另一个tonmcat服务的请求打过来是,他查询的是自己jvm中的锁的监视器发现是空的,就继续进行下单操作。
如图所示:
Redis实战篇--优惠券秒杀_第9张图片
什么是分布式锁?
分布式锁:满足分布式系统或集群模式下多进程可见并且互斥的锁。

特性:

  • 多进程可见
  • 互斥
  • 高可用
  • 高性能
  • 安全性

分布式锁的实现
Redis实战篇--优惠券秒杀_第10张图片

基于redis的分布式锁

实现分布式锁的两种基本方法:

  • 获取锁
    - 互斥确保只能有一个线程获取锁
    - SETNX lock threa1 ##添加锁
    - EXPIRE lock 10 ##设置过期时间
    - 确保原子性 合二为一
    - SET lock thread1 NX EX 10
  • 释放锁
    - 手动释放
    - DEL key

具体流程
Redis实战篇--优惠券秒杀_第11张图片

代码实现

/**********接口***************/
public interface ILock {


    /**
     * 尝试获取锁
     * @param timeoutSec 锁持有的过期时间,超时自动释放
     * @return true 代表获取成功  false 代表失败
     */
    boolean tryLock(long timeoutSec);

    void unLock();
}

/*******实现类****************/
public class SimpleRedisLock implements ILock {

    private String name;
    private StringRedisTemplate stringRedisTemplate;

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

    private static final String KEY_PREFIX = "lock:";
    private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";

    // 提前加载脚本 减少IO流
    private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;
    static {
        UNLOCK_SCRIPT = new DefaultRedisScript<>();
        UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
        UNLOCK_SCRIPT.setResultType(Long.class);
    }


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

/*********调用**********/
		//redis 创建锁对象
        SimpleRedisLock lock = new SimpleRedisLock("order:" + user.getId(), stringRedisTemplate);
        //获取锁
        boolean isLock = lock.tryLock(1200);
        //判断是否获取锁成功
        if(!isLock){
            //获取锁失败,返回错误或者重试
            return Result.fail("不允许重复下单");
        }
        try {
            IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
            return proxy.createVoucherOrder(voucherId);
        } finally {
            lock.unLock();
        }

使用lua脚本保证原子性

-- 比较线程标识与锁中的标识是否一致
if(redis.call('get', KEYS[1]) == ARGV[1]) then
    -- 释放锁
    return redis.call('del',KEYS[1])
end
return 0

优化代码:

public class SimpleRedisLock implements ILock {

    private String name;
    private StringRedisTemplate stringRedisTemplate;

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

    private static final String KEY_PREFIX = "lock:";
    private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";

    // 提前加载脚本 减少IO流
    private static final DefaultRedisScript UNLOCK_SCRIPT;
    static {
        UNLOCK_SCRIPT = new DefaultRedisScript<>();
        UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
        UNLOCK_SCRIPT.setResultType(Long.class);
    }


    @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() {
      // 调用Lua脚本
        stringRedisTemplate.execute(
                UNLOCK_SCRIPT,
                Collections.singletonList(KEY_PREFIX + name),
                ID_PREFIX + Thread.currentThread().getId());
    }

你可能感兴趣的:(Redis,redis,java,数据库)