Part 3 优惠券秒杀等

优惠券秒杀

1.全局唯一ID

全局ID生成器的特性:

  1. 唯一性
  2. 高可用
  3. 递增性
  4. 安全性
  5. 高性能

ID组成部分:

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

全局唯一ID生成策略:

● UUID
● Redis自增
● 雪花算法
● 数据库自增

Redis自增ID策略:

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

2.实现优惠券秒杀下单

请求路径:vouvher-order/seckill/{id}
下单时需要判断两点:

● 秒杀是否开始或者结束
● 库存是否充足,否则无法下单
在这里插入图片描述

重点:

//1.提交优惠券id  
//2.查询优惠券信息  
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);  
//3.判断秒杀  
if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {  
    //3.1 没开始  
    return Result.fail("秒杀尚未开始!");  
}  
if (voucher.getEndTime().isBefore(LocalDateTime.now())) {  
    //3.2 结束了  
    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("创建订单失败");  
}  
  
VoucherOrder voucherOrder = new VoucherOrder();  
//订单号  
long orderId = redisWorker.nextId("order");  
voucherOrder.setId(orderId);  
//用户名  
Long userId = UserHolder.getUser().getId();  
voucherOrder.setUserId(userId);  
//赏金券id  
voucherOrder.setVoucherId(voucherId);  
save(voucherOrder);  
//返回  
return Result.ok(orderId);

3.库存超卖问题

悲观锁:
认为线程安全问题一定会发生,因此在操作数据之前先获取锁,确保线程串行执行。例如Sycchronized,Lock都属于悲观锁
乐观锁:
认为线程安全问题不一定会发生,因此不加锁,只是在更新数据时去判断有没有其他线程对数据做了修改

● 如果没有修改则认为是安全的,自己才更新数据
● 如果已经被其他线程修改说明发生了安全问题,此时可以重试或异常

乐观锁

常见方式

● 版本号法
● CAS法

解决方法:

  1. 悲观锁:添加同步锁,让线程串行执行
    ○ 优点:简单粗暴
    ○ 缺点:性能一般
  2. 乐观锁:不加锁,在更新时判断是否有其他线程在修改
    ○ 优点:性能好
    ○ 缺点:存在成功率低的问题
    重点:
boolean success = seckillVoucherService.update()  
        .setSql("stock = stock - 1")  
        .eq("voucher_id", voucherId)  
        .gt("stock", 0)  
        .update();

4.一人一单

需求要求: 每个优惠券一个人只能买一张
逻辑:在这里插入图片描述

重点:

Long userId = UserHolder.getUser().getId();  
synchronized (userId.toString().intern()) {  
    //获取代理对象  
    IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();  
    return proxy.createVoucherOrder(voucherId);  
}
  1. 要用代理对象调用的函数才能使用@Transactional, 默认是this, 所以不行
  2. 要暴露代理对象要在主程序上面开启@EnableAspectJAutoProxy(exposeProxy = true)
  3. 这么写是因为要加锁的对象是userId, 实现对createVoucherOrder函数的加锁

一人一单的并发安全问题

启动两个springboot服务,在上面Ctrl+D即可,VM options写-Dserver.port=8082
使用分布式锁

● MySQL
● Redis
● Zookeeper

5.基于Redis的分布式锁

两个基本方法:

● 获取锁
○ 互斥:确保只能有一个线程获取锁
○ 非阻塞:尝试一次,成功返回true,失败返回false

# NX是互斥,EX是设置超时时间
SET lock thread NX EX 10

● 释放锁
○ 手动释放
○ 超时释放,获取锁的时候添加一个超时时间
修改业务逻辑代码:

//返回
Long userId = UserHolder.getUser().getId();

//获取锁  
SimpleRedisLock lock = new SimpleRedisLock(stringRedisTemplate, "order:" + userId);  
boolean isLock = lock.tryLock(1200);  
if (!isLock) {  
  return Result.fail("不能多次下单!");  
}  
try {  
  //获取代理对象  
  IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();  
  return proxy.createVoucherOrder(voucherId);  
} finally {  
  lock.unlock();  
}

Redis分布式锁误删问题

极端情况:在这里插入图片描述

解决方法:

  1. 在获取锁的时候存入线程表示
  2. 在释放锁的时候先获取锁中的线程标识,判断是否与当前线程标识一致
    重点:
//获取锁  
SimpleRedisLock lock = new SimpleRedisLock(stringRedisTemplate, "order:" + userId);  
boolean isLock = lock.tryLock(1200);  
if (!isLock) {  
    return Result.fail("不能多次下单!");  
}  
try {  
    //获取代理对象  
    IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();  
    return proxy.createVoucherOrder(voucherId);  
} finally {  
    lock.unlock();  
}

Lua脚本解决多条命令原子性问题(基于setnx)

EVAL script numkeys key [key ...] arg [arg ...]
脚本调用的时候使用KEYS[1]和ARGV[1]

释放锁的业务流程:

  1. 获取锁中的线程标识
  2. 判断是否与指定的标识(当前线程标识)一致
  3. 如果一致则释放锁, (删除)
  4. 如果不一致什么都不做
    重点:
public 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 void unlock() {  
    //调用脚本  
    stringRedisTemplate.execute(UNLOCK_SCRIPT,  
            Collections.singletonList(KEY_PREFIX + name),  
            ID_PREFIX + Thread.currentThread().getId());  
}

锁优化

基于setnx实现的分布式锁存在问题:

  1. 不可重入
  2. 不可重试
  3. 超时释放
  4. 主从一致性

Redisson的可重入锁的原理

可重入锁在redis中另外存储一个hash表示重入次数
在可重入锁中unlock不能直接删除锁
逻辑:在这里插入图片描述

6.秒杀优化

业务逻辑:

  1. 查询优惠券
  2. 判断秒杀库存
  3. 查询订单
  4. 校验一人一单
  5. 减库存
  6. 创建订单
    数据库并发能力差,如何优化呢?
    在这里插入图片描述

把判断库存和校验一人一单交给redis来完成

6.1 改进异步处理

需求:

  1. 新增秒杀优惠券的同时,将优惠券信息保存到redis中
  2. 基于Lua脚本,判断秒杀库存、一人一单,决定用户是否抢购成功
  3. 如果抢购成功,将优惠券id和用户id封装后存入阻塞队列
  4. 开启线程任务,不断从阻塞队列中获取信息,实现异步下单功能
    重点:
seckill.lua脚本:
--- 参数列表  
local voucherId = ARGV[1]  
local userId = ARGV[2]  
local stockKey = 'seckill:stock:' .. voucherId  
local orderKey = 'seckill:order:' .. voucherId  
  
-- 判断库存是否充足  
if (tonumber(redis.call('get', stockKey)) <= 0)  
then  
    return 1  
end  
  
--判断用户是否下单SISMEMBER key  
if (tonumber(redis.call('sismember', orderKey, userId)) == 1)  
then  
    return 2  
end  
  
redis.call('incrby', stockKey, -1)  
redis.call('sadd', orderKey, userId)  
return 0

优化思路:

  1. 先利用redis完成库存余量,一人一单判断
  2. 将下单业务放入阻塞队列,利用独立线程异步下单

7.消息队列实现

消息队列,存放消息的队列

● 消息队列:存储和管理消息,也称为消息代理
● 生产者:发送消息到消息队列
● 消费者:从消息队列获取消息并处理消息

7.1 基于List

7.2 基于PubSub(发布订阅)

PubSub,消费者可以订阅一个或多个channel

● SUBSCRIBE channel[channel]: 订阅一个或多个频道
● PUBLISH channel msg: 向一个频道发送消息
● PSUBSCRIBE pattern[pattern]: 订阅与pattern格式匹配的频道, 支持?,* , []
优点:
● 采用发布订阅模型, 支持多生产, 多消费
缺点:
● 不支持数据持久化
● 无法避免消息丢失
● 消息堆积邮上限,超出时数据丢失

7.3基于Stream的消息队列

Stream是Redis5.0引入的一种新数据类型,可以实现一个功能比较完善的消息队列
命令稍微有点小复杂
在这里插入图片描述

单消费者

● 消息可回溯
● 一个消息可以被多个消费者读取
● 可以阻塞读取
● 有消息漏读的风险

消费者组

消费者组:将多个消费者划分到一个组中,监听同一个队列

  1. 消息分流
  2. 消息标识:解决了漏读问题
  3. 消息确认
XGROUP CREATE key groupName ID [MKSTREAM]
-key:队列名称
-groupName:消费者组名称
-ID:起始ID标识,$代表队列中最后一个消息, 0代表队列中第一个消息
-MKSTREAM: 队列不存在时自动创建队列

重点:

private class VoucherOrderHandler implements Runnable {  
    String queueName = "stream.orders";  
  
    @Override  
    public void run() {  
        while (true) {  
            try {  
                //获取pending list中的订单信息  
                List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read(  
                        Consumer.from("g1", "c1"),  
                        StreamReadOptions.empty().count(1).block(Duration.ofSeconds(2)),  
                        StreamOffset.create(queueName, ReadOffset.lastConsumed())  
                );  
                //判断信息获取是否成功  
                if (list == null || list.isEmpty()) {  
                    break;  
                }  
                //解析订单信息  
                MapRecord<String, Object, Object> mapRecord = list.get(0);  
                Map<Object, Object> map = mapRecord.getValue();  
                VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(map, new VoucherOrder(), true);  
                //获取成功,可以下单  
                handleVoucherOrder(voucherOrder);  
                //ACK确认  
                stringRedisTemplate.opsForStream().acknowledge(queueName, "g1", mapRecord.getId());  
            } catch (Exception e) {  
                log.error("处理订单异常!");  
            }  
        }  
    }  
}
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

一只桃子z

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值