全局唯一ID
每个店铺都可以发布优惠券:当用户抢购时,就会生成订单并保存到tb_voucher_order这张表中,而订单表如果使用数据库自增ID就存在一些问题。
- id的规律性太明显
- 受单表数据量的限制
全局ID生成器
全局ID生成器,是一种在分布式系统下用来生成全局唯一ID的工具,一般要满足下列特性:
- 唯一性
- 高可用
- 高性能
- 递增性
- 安全性
全局唯一ID生成策略:
- UUID
- Redis自增
- snowflake算法(雪花算法)
- 数据库自增
Redis自增ID策略:
- 每天一个key,方便统计订单量
- ID构造是 时间戳 + 计数器
实现优惠券秒杀下单
每个店铺都可以发布优惠券,分为平价券和特价券。平价券可以任意购买,而特价券需要秒杀抢购。
数据表的关系:
- tb_voucher:优惠券的基本信息,优惠金额、使用规则等
- tb_seckill_voucher:优惠券的库存、开始抢购时间、结束抢购时间
实现:
- 在VoucherController中提供了一个接口,可以添加秒杀优惠券
实现优惠券秒杀的下单功能 -> 下单时需要判断两点:
- 秒杀是否开始或结束,如果尚未开始或已经结束则无法下单
- 库存是否充足,不足无法下单
超卖问题
超卖问题是典型的多线程安全问题,针对这一问题的常见解决方案就是加锁:
乐观锁
乐观锁的关键是判断之前查询得到的数据是否有被修改过,常见的方式有两种:
- 版本号法
- CAS法 -> 用数据本身是否变化来进行判断
- 直接查库存,如果查询的值和原来查到的不一样,就不进行扣减
- -> 压力测试显示有问题,很多线程无效,因此修改:
- 更改库存操作时,加的条件不必是“当前查到的库存数和更改前查到的库存值一致”,只需要让更改的条件为查到的库存数大于0即可。
总结
超卖这样的线程安全问题,解决方案有哪些?
- 悲观锁:添加同步锁,让线程串行执行
- 优点:简单粗暴
- 缺点:性能一般
- 乐观锁:不加锁,在更新时判读是否有其他线程在修改
- 优点:性能好
- 缺点:存在成功率低的问题
一人一单
需求:修改秒杀业务,要求同一个优惠券,一个用户只能下一单。
分布式锁
前面添加同步锁的方式,无法在集群模式下保证线程安全,所以需要引入分布式锁。
分布式锁:满足分布式系统或集群模式下多进程可见并且互斥的锁。
分布式锁的实现
基于Redis的分布式锁
实现分布式锁时需要实现两个基本方法:
- 获取锁
- 互斥:确保只能有一个线程获取锁(原子操作)
- 添加锁,利用setnx的互斥特性
- 添加锁过期时间,避免服务宕机引起死锁
- 非阻塞:尝试一次,成功返回true,失败返回false
- 互斥:确保只能有一个线程获取锁(原子操作)
- 释放锁
- 手动释放
- 超时释放:获取锁时添加一个超时时间
- 释放锁,删除即可
解决释放别人的锁的问题 -> 改进redis的分布式锁:
- 在获取锁时存入线程标识(可以用UUID表示)
- 在释放锁时先获取锁中的线程标识,判断与当前线程标识是否一致
- 如果一致,则释放锁
- 如果不一致,则不释放锁
Redis优化
Redis的Lua脚本:
Redis提供了Lua脚本功能,在一个脚本中编写多条Redis命令,确保多条命令执行时的原子性。(Lua是一种编程语言)
写好脚本以后,需要用Redis命令来调用脚本。
如果脚本中的key、value不想写死,可以作为参数传递。key类型参数会放入KEYS数组,其他参数会放入ARGV数组,在脚本中可以从KEYS和ARGV数组获取这些参数。
基于Redis的分布式锁 -> 释放锁的业务流程:
- 获取锁中的线程标识
- 判断是否与指定的标识(当前线程标识)一致
- 如果一致则释放锁(删除)
- 如果不一致则什么都不做
用Lua脚本来表示:
再次改进Redis的分布式锁 -> 基于Lua脚本实现分布式锁的释放锁逻辑 -> RedisTemplate调用Lua脚本的API。
分布式锁总结
基于Redis的分布式锁实现思路:
- 利用set nx ex获取锁,并设置过期时间,保存线程标识
- 释放锁时先判断线程标识是否与自己一致,一致则删除锁
特性:
- 利用set nx满足互斥行
- 利用set ex保证故障时锁依然能释放,避免死锁,提高安全性
- 利用Redis集群保证高可用和高并发特性
但是 -> 基于setnx实现的分布式锁存在下面的问题:
- 不可重入: 同一个线程无法多次获取同一把锁
- 不可重试: 获取锁只尝试一次就返回false,没有重试机制
- 超时释放: 锁超时释放虽然可以避免死锁,但如果是业务执行耗时较长,也会导致锁释放,存在安全隐患
- 主从一致性: 如果Redis提供了主从集群,主从同步存在延迟,当主宕机时,如果从未同步主中的锁数据,则会出现安全问题
Redisson
Redisson是一个在Redis的基础上实现的Java驻内存数据网络(In-Memory Data Grid)(分布式工具的集合)。它不仅提供了一系列的分布式的Java常用对象,还提供了许多分布式服务,其中就包含了各种分布式锁的实现。
Redisson可重入锁原理
- 原理
- 可重入:利用hash结构、记录线程标识id和重入次数
- 利用watchDog延续锁时间
- 利用信号量控制锁重试等待
- 缺点
- Redis宕机引起锁失效问题
利用Lua脚本实现
Redisson分布式锁原理:
- 可重入:利用hash结构记录线程id和重入次数
- 可重试:利用信号量和PubSub功能实现等待、唤醒,获取锁失败的重试机制
- 超时续约:利用wactchDog,每隔一段时间(releaseTime/3),重置超时时间
Redisson分布式锁主从一致性问题 -> Redisson的multiLock连锁
- 原理
- 多个独立的Redis节点,必须在所有节点都获取重入锁,才算获取锁成功
- 缺陷
- 运维成本高、实现复杂
- 主从一致性问题
- 单个主节点,锁失效问题 -> 不再有主从节点
- 多个主节点保证锁,一个主节点宕机了,其他线程只能获得一个新主节点的锁,获取不到其他两个锁,还会获取失败
- 连锁multiLock策略:不再有主从节点,都获取成功才能获取锁成功,有一个节点获取锁不成功就获取锁失败
- 主要防止主节点宕机后,其他线程获得主节点的锁,引起线程安全问题
主从节点
三个独立节点
秒杀优化
原来的秒杀
异步秒杀
-
判断秒杀库存 -> string
-
校验一人一单 -> set
改进秒杀业务,提高并发性能
- 新增秒杀优惠券的同时,将优惠券信息保存到Redis中
- 基于Lua脚本,判断秒杀库存、一人一单,决定用户是否抢购成功
- 如果抢购成功,将优惠券id和用户id封装后存入阻塞队列
- 开启线程任务,不断从阻塞队列中获取信息,实现异步下单功能
秒杀业务的优化思路是什么?
- 先利用Redis完成库存余量、一人一单判断,完成抢单业务
- 再将下单业务放入阻塞队列,利用独立线程异步下单
基于阻塞队列的异步秒杀存在哪些问题?
- 内存限制问题
- 数据安全问题