Redis5、全局ID、乐观锁修改,被关锁新增、事务失效、redisson工具类、redis消息队列,Ctrl+P:查看方法参数Ctrl+Q:查看类.方法.属性注释

一、全局ID生成器 

每个店铺都可以发布优惠券:

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

id自增的问题:

  • id的规律性太明显:今天下单id=1,明天下单id=100,就暴露了销售额信息等
  • 受单表数据量的限制:数据量大的时候,单表不能保存如此多的数据。如果分表的话,每张表就会计算自己的自增长,ID就会出现重复,而订单是唯一的(违背了唯一性)

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

  • 唯一性
  • 高可用:基本不会down机
  • 高性能:速度快
  • 递增性:变大的
  • 安全性

利用Redis的incr命令,为了增加ID的安全性,我们可以不直接使用Redis自增的数值,而是拼接一些其他信息---数值类型,java里的Long类型,8个字节共64位

ID的组成部分:(时间相同的情况下,序列号不一样)

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

代码实现

utils包下定义一个类 RedisIdWorker (基于Redis的Id生成器

注意:该自增是利用Redis的自增方法,我们位移后获取的是二进制因为我们左移了,32+32,二进制的低32位就是我们的count,高32位就是时间戳,如果高并发下时间戳一样,看起来就是自增的,如:

  • 97354934231498754、2022-09-20 08:26:52
  • 97354934231498755、2022-09-20 08:26:52
  • 这两个id的创建时间都是一样的,只不过序列号不一样,序列号利用Redis自增,如果时间戳不一样,那十进制看起来区别就比较大了,如:97354960001302534,正式这种方式,更好

如:

  • count=6
  • 时间戳+count左移位前=22667218 6
  • 时间戳+count左移位后的二进制:
    • 0000 0001 0101 1001 1101 1111 1101 0010 0000 0000 0000 0000 0000 0000 0000 0110
  • 移位后的二进制转十进制(整体二进制转十进制)
    • id=97354960001302534
@Component
@Slf4j
public class RedisIdWorker {
    /**
     * 开始时间戳
     */
    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
     */
    public long nextId(String keyPrefix) {
        /**
         * 生成时间戳
         * 31位的数字,单位秒,他的值是要有一个 基础的时间 作为开始时间
         * 时间戳(秒数) = 当前时间 - 基础时间,
         * 即:从开始时间 隔了多少秒
         */
        LocalDateTime now = LocalDateTime.now();
        long nowSecond = now.toEpochSecond(ZoneOffset.UTC);
        // 当前时间 - 基础时间 = 时间戳
        long timestamp = nowSecond - BEGIN_TIMESTAMP;

        /**
         * 生成序列号
         * 利用Redis的自增长 用字符串结构 默认一次自增 1,
         * 不同的业务有不同的key,
         * stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + ":");
         * 这样不可以:
         * 1、这样的话默认就是整个订单业务都是一个key,不管过了多少年,随着业务的订单越来越多
         *  ,而redis 单个key的自增长是有上限的,是2的64次方,虽然大也是有上限的
         * 2、而且key里边真正用来记录序列号的,只有32个bit位,如果说将来key,超过了32个bit,那就存不下了。
         * 所以,哪怕是同一个业务,也不能使用同一个key
         * 解决:
         * 我们可以在后边拼一个当期日期 比如20220910,到了第二号,就是一个新的key了20220911
         * 这样就还有一个统计的效果
         */
        // 获取当前日期,自定义格式化, 这样就还可以统计 年 月 日的单
        String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));

        // 这里用基本类型,后面要做运算,如果key不存在,会自动创建的,不会有空指针
        // 注意,插到库里这个key 是一个,value就是count 回覆盖之前
        long count = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + ":" + date);
        log.info("count={}", count);
        /**
         * 拼接并返回,利用位运算,
         * 前面说了就当做long类型8个字节,
         * 全局id = 0 + 时间戳 + 序列号
         * 将时间的戳的值左移32位,然后空出来的32位,
         * 利用 | 运算去将序列号填充即可
         *
         * 或运算| 一个为真即为真,现在后面的32位都是0,
         * 而count的值 可能为0 可能为1,我们希望不管为0还是1都需要填充到后32位
         * 0|0 = 0
         * 0|1 = 1
         * 即:count值将来是什么,后32位就保留什么了
         */
        return timestamp << COUNT_BITS | count;
    }

    /**
     * 计算基础(当前)时间秒数
     */
    public static void main(String[] args) {
        // of 方法可以指定年月日
        LocalDateTime now = LocalDateTime.of(2022, 1, 1, 0, 0, 0);
        // 接收时期
        long second = now.toEpochSecond(ZoneOffset.UTC);
        System.out.println(second);
    }
}

注意:每天一个key,方便我们去统计每天的订单量

总结

全局唯一ID生成策略:

  • UUID,是一长串16进制的,没有自增长无规律,是字符串
  • Redis自增,有规律的,例如我们的案例,是数字类型,long类型的64位数字
  • snowflak(雪花算法,也是long类型的64位数字,性能理论上来讲比Redis好,但依赖于时钟,)
  • 数据库自增,单独整一张表,N张表用的是同一张表的自增ID

Redis自增ID策略:(Redis保存key需要注意)

  • 每天一个key,方便统计订单量(每天的,月的,年的)、限定key自增的值不会让key太大,以至于超过自增的上限
  • ID结构是:时间戳+计数器

测试类:

private ExecutorService es = Executors.newFixedThreadPool(500);

    @Test
    void testIdWorker() throws InterruptedException {
        CountDownLatch latch = new CountDownLatch(300);

        Runnable task = () -> {
            for (int i = 0; i < 100; i++) {
                long id = redisIdWorker.nextId("order");
                System.out.println("id = " + id);
            }
            latch.countDown();
        };
        long begin = System.currentTimeMillis();
        for (int i = 0; i < 300; i++) {
            es.submit(task);
        }
        latch.await();
        long end = System.currentTimeMillis();
        System.out.println("time = " + (end - begin));
    }

二、实现优惠券的秒杀下单

实现优惠券秒杀下单

在VoucherController中提供了一个接口,可以添加秒杀优惠券;

http://localhost:8081/voucher/seckill

添加秒杀券也是优惠券,只不过在t_voucher实体类里已经把秒杀券的信息拿到了都,

{
"shopId":1,
"title":"100元代金券",
"subTitle":"周一至周五均可使用",
"rules":"全场通用\\n无需预约\\n可无限叠加\\不兑现、不找零\\n仅限堂食",
"payValue":8000,
"actualValue":10000,
"type":1,
"stock":100
}

 2、实现下单接口,完成抢购功能

实现优惠券秒杀的下单功能:

下单时需要判断两点

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

 基本功能实现:

@Resource
    private ISeckillVoucherService seckillVoucherService; // 秒杀券的业务层

    @Resource
    private IVoucherOrderService voucherOrderService; // 订单业务层

    @Resource
    private RedisIdWorker redisIdWorker; // 全局区域ID工具类

    /**
     * 当有两张表以上的操作时,要加上事务,出现问题会及时回滚,
     * 否则无法回滚的
     * @param voucherId
     * @return
     */
    @Override
    @Transactional
    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 isUpdate = seckillVoucherService.update().setSql("stock = stock-1").eq("voucher_id", voucherId).update();
        if (!isUpdate) {
            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);

        // 6.4 保存订单信息
        voucherOrderService.save(voucherOrder);

        // 7、返回订单 id
        return Result.ok(orderId);
    }

以上代码有超卖问题

三、库存超卖问题

利用JMeter模拟高并发

/voucher-order/seckill/10

注意:这里我们需要在JMeter里加一个请求头,即:用户token的请求头,否则拦截器过不去,redis里找的:authorization:6a44cf0d7b564030906ad8ed9577285c

模拟结果,order表出现109条数据,库存是-9,

这样就出现了问题,我们要卖100个券,实际却卖了109张券

出现抢占资源的(并发安全)问题

 加锁:悲观锁&乐观锁,这两个所只是一种理念

悲观锁:还有像数据库的互斥锁也是悲观锁

乐观锁:比如我查DB的优惠券库存,要更新了,更新之前我去判断一下,有没有别人修改库存,

乐观锁方式:

  • 1、版本号机制
    • 修改前判断版本号有无变化,where条件 如果查到了,那么就表示没人操作,我就可以修改,where条件如果没有查到,就说明有人操作了,重试或异常
    • 说白了,版本号法是用版本来标识数据有没有变化,我们在第一步查到的版本,和更新时的版本一致,证明就没有人更新

  • 2、CAS(比较 and set)机制
    • 在版本号的基础上做了一些简化,我们这个业务,查数据的时候,库存是要查的,更新的时候,库存也要更新,库存和版本所做的事是一样的,so可以用库存来代替版本,实现方式同理
    • 即用数据本身有无变化(库存)去判断线程是否安全

修改代码:然后继续执行JMeter,我们发现并不好用,库存只卖出了21件,订单也是21个

 /**
         * 扣减库存
         * 修改代码,添加乐观锁,CAS机制
         * 添加where条件让stock等于我们上边查到的stock值
         * 如果查到了,说明没人修改,如果没查到说明有人修改了
         * 光加这一个的话,库存并没有到0,数据库还有79个
         */
        boolean isUpdate = seckillVoucherService.update()
                .setSql("stock=stock-1")
                .eq("voucher_id", voucherId)
                .eq("stock", seckillVoucher.getStock())
                .update();

券还没卖完,就返回没有库存了,这就是设计乐观锁的一个弊端,当一个线程修改成功了,其余线程也在执行,发现没有查到,就修改失败了,这就浪费了一次线程任务,虽然修改失败了,但是并没有线程安全问题,我们需要做处理,不用不等于就报错,可以直接让stock只要大于0就好

 / * 我们修改一下,只要stock>0即可,不一定要必须等于,ge大于的意思

         */
        boolean isUpdate = seckillVoucherService.update()
                .setSql("stock=stock-1")
                .eq("voucher_id", voucherId)
                .gt("stock", 0)
                .update();

四、一人一单(同一个用户只能下一单)

解决办法就在tb_voucher_order里userId和VoucherId 联合查询,就能确定是同一个用户

解决思路:

 代码片段:

Long userId = UserHolder.getUser().getId();
        /**
         * 新添加一人一单
         */
        int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
        if (count>0) {
            return Result.fail("用户已经买过了"+count+"次!");
        }

        /**
         * 以下的就继续走
         */
        boolean isFlag = seckillVoucherService.update().
                setSql("stock = stock-1").
                eq("voucher_id", seckillVoucher.getVoucherId())
                .gt("stock", 0)
                .update();
        if (!isFlag){
            return Result.fail("库存不足");
        }

跑批后发现数据库数据没有对上,200个线程,同一用户,却跑出了10个订单,以前是一人下100单,现在是一人下了10单,问题依旧存在。即:多线程并发的情况下,上述代码,大家都去查询,查询的count都是0,然后都往下走,去创建订单了,这就又出现多线程并发问题

注意: 这里不能用乐观锁了,乐观锁是在更新数据的时候用的,而我们这块的代码是新增,只能用悲观锁了

而锁的对象是不应该是this整个对象,而应该是一人一锁,即:是用户ID

public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
    @Resource
    private ISeckillVoucherService seckillVoucherService;
    @Resource
    private RedisIdWorker redisIdWorker;

    @Override
    public Result seckillVoucher(Long voucherId) {
        /*
         1、判断是否开始
         2、判断是否结束
         3、判断是否有库存
         4、下单
         5、减库存
         6、返回
         */
        SeckillVoucher seckillVoucher = seckillVoucherService.getById(voucherId);
        if (seckillVoucher.getBeginTime().isAfter(LocalDateTime.now())) {
            return Result.fail("活动尚未开始");
        }
        if (seckillVoucher.getEndTime().isBefore(LocalDateTime.now())) {
            return Result.fail("活动已结束");
        }
        Integer stock = seckillVoucher.getStock();
        if (stock<1) {
            return Result.fail("库存不足");
        }

        /**
         * 每个人,即:每个用户自己有独立的一把锁,
         * 但是,要知道每次请求来的时候,我们创建的这个userId都是一个全新的对象,因此对象变了,锁也就没有意思了。
         * 要求值是一样的,所以我们toString,转成字符串,锁的是值
         * 而toString的底层代码里,也是return new String(buf, true); new了一个字符串
         * 所以每调一次toString,也是一个全新字符串对象,锁对象也还是会变的,
         * 每次来,虽然都是1011,即使转成字符串也是一个全新的对象,还是不可以,所以我们调用
         * 调用toString.intern() 方法,
         * 简单理解为去常量池里查,如果字符串常量池里有一个equals比较为true的,那就返回池子
         * 中的字符串,不会new新的字符串了,这样锁的就是同一个用户了,而不同的用户就不会被锁定,这样性能就提高了
         * 但是,有个问题,
         * 我们开启事务开始执行,执行之后,先释放锁,才会提交事务,
         * 而事务是有spring管理的
         * 就是这个函数方法执行完后,由spring去做提交,而这个锁在 synchronized{} 大括号结束后已经释放了,
         * 锁释放了,就意味着其他线程可以进来了,而此时事务尚未提交,那有其他线程进来查询操作,我们新增的这个订单可能还没有写入数据库
         * 这个时候别的线程去查询可能依然不存在,存在并发安全问题,因此在里边锁的情况,锁的范围就有点小了,
         * 应该是事务提交之后我们再去释放锁,我们应该把整个方法锁起来
         *
         * 事务失效:非public 目标对象
         * 我们的事务是在另一个方法开启的,而不是在调用它的方法开启的,这就会导致spring管理的事务失效
         * 在方法里调用别的方法,会导致事务失效,即:目标对象,而不是代理对象了
         *
         * 这里需要去拿到事务的代理对象才可以
         * 借助一个API
         *
         */

        // return createVoucherOrder(voucherId);
        Long userId = UserHolder.getUser().getId();
        synchronized (userId.toString().intern()) {
            /**
             * 借助一个API 通过这个方法去拿到当前对象的代理对象 我们称为 普绕SEI,
             * 而 当前代理对象就是他的service - > IVoucherOrderService
             */

            // 这样我们就拿到了当前代理对象了
            // 获取代理对象,和事务有关的代理对象,然后再去调用方法函数就没有问题了
            IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
            /*
             用代理对象去调用该函数,而不是用this调用,这样的话这个函数就会被spring管理了
             因为proxy 这个对象是由spring创建的,所以proxy.createVoucherOrder2(voucherId, userId);
             他是带有事务的这样一个函数

             函数不存在的原因是因为IVoucherOrderService接口不存在这个函数,我们创建就好了
             我们是在实现类里做的,我们创建一下就好了,
             IVoucherOrderService接口有了,我们才能基于接口去做调用

             现在我们的事务才能生效

             这么做的话还需要做两件事
             1、新添加依赖,aspectj包下的aspectjweaver 这样一依赖,(动态代理的模式)
             2、启动类添加一个注解:去暴露这个代理对象:
                @EnableAspectJAutoProxy(exposeProxy = true) 默认值是false
                将默认值改为true,false是不会暴露的,不暴露的去获取是获取不到的,

             一但暴露设置好了,我们这样就可以拿到代理对象了
             IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();

             */
            return proxy.createVoucherOrder2(voucherId, userId);
        }
    }

    @Transactional
    public Result createVoucherOrder1(Long voucherId, Long userId) {
        /**
         * 每个人,即:每个用户自己有独立的一把锁,
         * 但是,要知道每次请求来的时候,我们创建的这个userId都是一个全新的对象,因此对象变了,锁也就没有意思了。
         * 要求值是一样的,所以我们toString,转成字符串,锁的是值
         * 而toString的底层代码里,也是return new String(buf, true); new了一个字符串
         * 所以每调一次toString,也是一个全新字符串对象
  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值