Redis实战-优惠券秒杀(Redisson、Redis消息队列)

一、全局ID生成器

1.1 概念

订单表的订单号使用数据库自增id会存在一些问题
(1)id规律性太明显,会暴露一些信息如下单数量。
(2)受表单数据量影响(时间长了几百万、几千万的订单)。

全局ID生成器,是一种在分布式系统下用来生成全局唯一ID的工具。具有以下特点:
(1)唯一性;(2)高可用(稳定);(3)高性能(生成快);(4)递增性(有利于数据库创建索引);(5)安全性(不会暴露信息)。

使用Redis来满足上面的要求
(1)使用redis的string的自增(incr)来保证唯一性。
(2)使用redis的集群、哨兵策略来保证高可用。
(3)redis本身性能高。
(4)使用redis的string的自增(incr)。
(5)使用redis保证安全性的方案如下(使用String的Long型):
在这里插入图片描述
当然,redis不是全局ID的唯一方案,还有其他很多方案。

1.2 Redis自增实现

在这里插入图片描述

@Component
public class RedisIdWorker {
   
   
    private static final long BEGIN_TIMESTAM= 1640995200L;

    private StringRedisTemplate stringRedisTemplate;
    /*
     *序列号位数
     */
    private  final int COUNT_BITS = 32;

    public RedisIdWorker(StringRedisTemplate stringRedisTemplate){
   
   
        this.stringRedisTemplate=stringRedisTemplate;
    }
    public long nextId(String keyPrefix){
   
   
        //1.生成时间戳
        LocalDateTime now = LocalDateTime.now();
        long nowSecond = now.toEpochSecond(ZoneOffset.UTC);
        long timestamp = nowSecond-BEGIN_TIMESTAM;

        //2.生成序列号
        String date = now.format(DateTimeFormatter.ofPattern("yyyyMMdd"));
         long count = stringRedisTemplate.opsForValue().increment("icr:"+keyPrefix+":"+date);
        //3.拼接并返回
        return timestamp << COUNT_BITS | count;

    }
    
}

其他的全局唯一ID生成策略
UUID
snowflake算法
数据库自增

二、线程并发安全问题

2.1 秒杀下单功能(一般实现)

2.1.1 功能点描述

在这里插入图片描述
在这里插入图片描述

2.1.2 代码实现

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
   
   

    @Resource
    private ISeckillVoucherService seckillVoucherService;

    @Resource
    private RedisIdWorker redisIdWorker;

    @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 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);
        // 7.3.代金券id
        voucherOrder.setVoucherId(voucherId);
        save(voucherOrder);

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

启动服务测试:
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

补充:使用jmeter模拟真实秒杀场景进行测试

上面的测试方法是在前端页面手动点击的,但现实场景中会有多个并发请求,可以使用jmeter进行模拟。
在这里插入图片描述
在这里插入图片描述
注意:要在请求头里添加authorization
可以查到刚才请求的:
在这里插入图片描述
在这里插入图片描述

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

在这里插入图片描述

在这里插入图片描述
在这里插入图片描述
问题分析:
正常情况下:
在这里插入图片描述
异常情况:
在这里插入图片描述
引发了并发安全问题,解决方法是锁机制

2.1.3 悲观锁与乐观锁

在这里插入图片描述
悲观锁方法简单,下面说明实现乐观锁的方法,常见的两种方法是版本号法(表有一个字段为版本号,将版本号的值作为更新的条件)和CAS法(用库存数量代替版本号字段,原理与版本号相同)。
在这里插入图片描述

  boolean success = seckillVoucherService.update().setSql("stock=stock-1").
                eq("voucher_id",voucherId).eq("stock",voucher.getStock()).update();

重启服务,删除数据库数据进行重试:
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
结果:
在这里插入图片描述
原因:因版本号字段前后不一致更新失败的线程下单失败,失败率非常高
解决失败率高的问题:
把版本号前后一致的条件改为库存大于0.
在这里插入图片描述
重启服务,删除数据库数据进行测试:
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

补充:乐观锁的缺点

乐观锁要访问数据库,对数据库的压力还是很大的,对于高并发场景还是有缺点的。

2.2 一人一单功能

目标:一个用户只能抢购一张优惠券。

2.2.1 实现思路

在这里插入图片描述

2.2.2 代码实现

在这里插入图片描述

        // 一人一单
        Long userId = UserHolder.getUser().getId();
        int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
        // 5.2.判断是否存在
        if (count > 0) {
   
   
            // 用户已经购买过了
            return Result.fail("用户已经购买过一次!");
        }

重启服务,删除数据库数据进行测试:
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
还是发生了线程安全问题。但这里不能使用乐观锁方案,只能使用悲观锁方案,将“是否下过单,更新库存,下单”的过程上锁。

2.2.3 解决线程安全问题(悲观锁)

在这里插入图片描述

@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);
        // 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("库存不足!");
        }
        return createVoucherOrder(voucherId);
    }

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

        synchronized (userId.toString().intern()){
   
   //intern()可以保证对象唯一(toString方法底层是new一个String,即时值相同也不是同一个对象),去字符串常量池找值一样的字符串的地址返回
            int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
            // 5.2.判断是否存在
            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");
            voucherOrder.setId(orderId);
            // 6.2.用户id
            //long userId = UserHolder.getUser().getId();
            voucherOrder.setUserId(userId);
            // 7.3.代金券id
            voucherOrder.setVoucherId(voucherId);
            save(voucherOrder);

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

    }


}

但上述代码还是有问题,因为createVoucherOrder()里的锁是在方法执行完,事务提交前释放的,所以可能出现一个线程释放了锁没提交事务(未写入数据库),另一个线程查询不到更新结果导致线程安全问题。

补充:悲观锁写法优化

在这里插入图片描述
但上面的写法会发生事务失效的问题,这里把老师原话写在下面(是的,我还不懂):
createVoucherOrder()方法加了事务,但外边的seckillVoucher()没有加事务,createVoucherOrder()方法调用拿到的是当前的VoucherOrderServiceImpl对象,而不是它的代理对象。事务要想生效是因为spring对类做了动态代理,获取了类(这里是VoucherOrderServiceImpl)的代理对象,用代理对象做的事务处理,当前的VoucherOrderServiceImpl对象不是代理对象,无事务功能。
举个例子可能会理解一些:
不会失效的例子1:

@Service
public class OrderService {
   
   
    
    @Autowired
    private UserService userService; // 这里注入的是代理对象
    
    public void placeOrder() {
   
   
        userService.saveUser(); // 通过代理调用
    }
}

@Service
public class UserService {
   
   
    
    @Transactional
    public void saveUser() {
   
   
        // 业务逻辑
    }
}

会失效的例子1:

public interface IOrderService {
   
   
    
    public void placeOrder();
}

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值