优惠券秒杀
1.全局唯一ID
全局ID生成器的特性:
- 唯一性
- 高可用
- 递增性
- 安全性
- 高性能
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法
解决方法:
- 悲观锁:添加同步锁,让线程串行执行
○ 优点:简单粗暴
○ 缺点:性能一般 - 乐观锁:不加锁,在更新时判断是否有其他线程在修改
○ 优点:性能好
○ 缺点:存在成功率低的问题
重点:
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);
}
- 要用代理对象调用的函数才能使用@Transactional, 默认是this, 所以不行
- 要暴露代理对象要在主程序上面开启@EnableAspectJAutoProxy(exposeProxy = true)
- 这么写是因为要加锁的对象是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分布式锁误删问题
极端情况:
解决方法:
- 在获取锁的时候存入线程表示
- 在释放锁的时候先获取锁中的线程标识,判断是否与当前线程标识一致
重点:
//获取锁
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]
释放锁的业务流程:
- 获取锁中的线程标识
- 判断是否与指定的标识(当前线程标识)一致
- 如果一致则释放锁, (删除)
- 如果不一致什么都不做
重点:
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实现的分布式锁存在问题:
- 不可重入
- 不可重试
- 超时释放
- 主从一致性
Redisson的可重入锁的原理
可重入锁在redis中另外存储一个hash表示重入次数
在可重入锁中unlock不能直接删除锁
逻辑:
6.秒杀优化
业务逻辑:
- 查询优惠券
- 判断秒杀库存
- 查询订单
- 校验一人一单
- 减库存
- 创建订单
数据库并发能力差,如何优化呢?
把判断库存和校验一人一单交给redis来完成
6.1 改进异步处理
需求:
- 新增秒杀优惠券的同时,将优惠券信息保存到redis中
- 基于Lua脚本,判断秒杀库存、一人一单,决定用户是否抢购成功
- 如果抢购成功,将优惠券id和用户id封装后存入阻塞队列
- 开启线程任务,不断从阻塞队列中获取信息,实现异步下单功能
重点:
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
优化思路:
- 先利用redis完成库存余量,一人一单判断
- 将下单业务放入阻塞队列,利用独立线程异步下单
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引入的一种新数据类型,可以实现一个功能比较完善的消息队列
命令稍微有点小复杂
单消费者
● 消息可回溯
● 一个消息可以被多个消费者读取
● 可以阻塞读取
● 有消息漏读的风险
消费者组
消费者组:将多个消费者划分到一个组中,监听同一个队列
- 消息分流
- 消息标识:解决了漏读问题
- 消息确认
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("处理订单异常!");
}
}
}
}