优惠券秒杀
每个店铺都可以下单优惠券,当用户抢购优惠券的时候会写入一个优惠券订单表
为什么订单id不设置为自增长:id规律太明显,受单表数据量的限制(一直下单/取消,可能要分到多个表)
全局唯一ID
在分布式系统下生成全局唯一ID,要满足以下特性:
①全局唯一
②高可用
③高性能
④递增(方便数据库生成索引)
⑤安全性
正好可以用Redis中String的incr,再拼接一些别的类型形成数值类型(方便数据库建立索引)
这样64bit(8bytes)正好可以转换成Long
Redis中自增最大为2^64 - 1,为了防止我们的序列号超过32位,使用了在前缀拼接当前年:月:日的方法,这样也方便以后统计日/月/年订单总量
其他策略:
UUID(16位的字符串、没有自增特性)
snowflack算法
数据库自增(单拉一个表做自增)
添加和抢购优惠券
普通券随时都可以买不用设限制,特价券有数量限制和时间限制
抢购的基础过程没什么特别的,就是先查询在不在抢购时间内,再判断还有没有库存,然后更新特价券和优惠券订单表
库存超卖问题
高并发的场景下可能会出现超卖的情况,常用解决方案就是加锁
悲观锁
认为线程安全问题一定会发生,所以在执行相关代码(临界区)之前会先上锁,常见的比如Synchronize、Lock
乐观锁
认为线程安全问题不一定发生,因此不加锁,只在更新数据的时候去判断有没有其他线程对数据进行了修改,没有修改则认为是安全的正常更新;有修改则认为是不安全的,重试或者报异常
如何判断有没有其他线程对数据进行了修改?
①版本号法:给特价券加上版本号,每次更新的时候修改版本号,只能更新与查询时版本号一致的数据(利用update返回boolean判断)
②CAS法(简化版本号):直接用库存stock判断
弊端
大量线程同时执行,但是对于每个版本号都只会有一个线程成功,失败率很高
还是要大量访问数据库(为了校验订单请求是否合法),对数据库压力比较大
改进方案:每次更新的时候只判断stock是否大于0
实现一人一单
考虑实际情况,特价券是“赔本买吆喝”,为了不让一个人把所有的券买走要限制一人一单
如果只是在代码逻辑中按照优惠券号和用户ID限制的话,高并发下还是会一人下多单(因为在查询count = 1后发生了上下文切换)
解决方案:
1.给可能发生冲突的临界区加锁(因为是新增操作而不是修改操作无法用乐观锁)
锁可以使用用户的ID字段,记得用intern()来保证每个用户锁对象唯一
锁的范围必须是整个业务函数而不是业务代码块,因为@Transactional是在函数return后才提交到数据库,如果在这个之间有新的线程进来代码块还是有可能出现线程不安全
要锁住整个函数就必须要在别的函数中去调用,但是别的函数调用本质上是this.method(),而@transactional是生成了当前类的代理类来完成的,所以需要调用的是当前类的代理类中的这个方法
2.给订单表中 user_id + voucher_id 字段加唯一约束,再用try-catch捕捉异常
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
捕获异常之后需要显式回滚不然不会触发事务回滚 但是如果是MySQL集群并且分库分表的情况仍发生线程安全问题
集群下的并发安全问题
加锁的方案只能解决单体项目中的并发安全问题 因为一个JVM只会对其内部的锁监视
分布式锁
满足分布式系统或集群模式下多进程可见且互斥的锁
分布式锁误删
这样仍然可能发生线程安全问题
解决方式:加锁的时候给value赋一个JVM(可以用UUID生成)+线程name作为唯一的值,解锁的时候如果当前线程的value和锁中value不一致则不解锁
不一致的话应该把线程一的事务回滚,不然可能出现下两次单的情况,简单来说就是锁内的操作(临界区)必须是一致性的。可以考虑在开头查一遍库存量,把更新操作和返回订单看做一个原子性操作,更新的时候检查库存量与先前是否一致
分布式锁的原子性
if(name.equals(stringRedisTemplate.opsForValue().get(RedisConstants.SECKILL_USER_ID + id.toString()))){
stringRedisTemplate.delete(RedisConstants.SECKILL_USER_ID + id.toString());
}
这两个操作必须是原子性的,不然如果在判断相等后发生上下文切换的话,可能又会发生锁误删的情况
Lua脚本解决
Redis的Lua脚本允许在一个脚本中编辑多条Redis命令,确保原子性。Lua是一种编程语言
redisTemplate可以使用execute方法来执行lua脚本
if(redis.call('get', KEYS[1]) == ARGV[1]) then
return Redis.call('del', KEYS[1])
end
return 0
Redisson
目前的锁还存在一些问题:
①不可重入:同一个进程的两个方法不能获取同一把锁
②不可重试:尝试获取锁没有成功就直接返回false而不是重试
③超时释放:有一人多买的隐患
④主从一致性:主从同步存在延迟,可能存在安全隐患
Redisson是在Redis基础上实现的Java驻内存数据网络,提供了一系列常用的分布式对象及服务
可以直接替换我们之前自己作的SimplyLock
可重入原理
不仅记录当前获取锁的线程名,也记录当前锁被进入的次数(用hash结构)
可重试和超时释放的原理
先尝试一次,如果没有获取成功则更新剩余可等待时间,然后异步等待别人的释放锁信号,若收到释放锁的信号则减去等待时间再尝试获取锁,不断重复(同时也会判断锁是否过期)用消息订阅和信号量的方式,不是忙等待
leaseTime是分布式锁的初始有效期(-1表示永不过期),(如果是-1则)watchDog是每隔10s刷新一次锁的有效期(重置为30s),防止JVM宕机别的线程获取不了锁,同时也保证在业务期间锁持续生效
MultiLock原理(主从一致性)
一般而言主节点负责增删改 从节点负责查
把所有节点都变为主节点 获取锁在多数节点中都获取锁的标识才算成功
如果为了增加可用性也可以在多个主节点后面添加从节点,如果在同步的时候有一个主节点发生了宕机,那么这个时候有其他节点想进来获取锁也无法成功获取,只能等有锁的节点释放了才行
释放锁的时候向所有锁都发出释放信号
重要的是如何实现每个操作下如何维护可用锁和锁之间的同步
因为获取锁是一个长时间的过程(需要访问多个节点),在确认真的获取到锁后会给所有可用节点再统一刷新有效期或开启watchDog
Redis优化秒杀 异步秒杀
虽然通过Redisson的分布式锁解决了可重入和超时释放和主从一致性问题,但是高并发下每个线程还是要大量进行数据库操作,平均响应时间会比较长
可以把查询秒杀库存和查询一人一单这类耗时较短的任务交给一个线程做(可以把库存信息和一人一单放到Redis中加速),新增订单和减少库存的任务交给另外一个线程做,两个线程之间通过某种方式异步传递消息,这样就提高了效率
用String提前加载和扣减库存信息,set储存购买人信息
还需要考虑何时把Redis中的信息同步到本地数据库中(SpringTask)
因为Lua脚本保证了原子性,所以也不需要加分布式锁了,但还是要考虑到加入阻塞队列前服务宕机的情况(加入阻塞队列的操作如果使用消息队列会好一点,还要定时扫描已标记但未下单的用户)
阻塞队列 BlockingQueue
将优惠券id 用户id和订单id存入阻塞队列之后,由其他线程异步完成生成具体订单和付款操作
@PostConstruct
private void init(){
threadPool.submit(new voucherOrderHandler());
}
private class voucherOrderHandler implements Runnable{
@Override
@Transactional
public void run() {
while(true){
VoucherOrder voucherOrder = null;
try {
voucherOrder = orderTasks.take();
createVoucherOrder(voucherOrder);
} catch (Exception e) {
log.error(e.getMessage());
if(voucherOrder != null) orderTasks.add(voucherOrder);
}
}
}
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void createVoucherOrder(VoucherOrder voucherOrder){
save(voucherOrder);
seckillVoucherService.update()
.setSql("stock = stock - 1")
.eq("voucher_id", voucherOrder.getVoucherId())
.update();
}
但是使用阻塞队列会有很多问题:
1.
①内层事务完成以后外层事务不会提交(因为一直循环),占用数据库连接池
②如果删去外层@Transactional那么自调用而不是代理调用导致@Transactional并不会真正生效
③如果使用代理调用,代理对象初始存储在主线程的ThreadLocal中,因为是创建新线程而不是在主线程,ThreadLocal中的值为null【`voucherOrderHandler`是一个内部类(private class),并且它实现了Runnable接口。这个内部类并不是Spring管理的Bean,而`AopContext.currentProxy()`要求当前调用的对象必须是Spring代理对象(即被Spring容器管理的Bean)。】
④一个可行的方法是把createVoucherOrder这个方法独立出去成为一个被Bean管理的单独的类,这样就可以去掉外层循环的@Transactional也能正常调用了,但是这样会严重破坏类的高内聚和低耦合性,还是得寻找其他方法
2.JDK中的阻塞队列使用的是JVM的内存,极端情况下内存会溢出(订单丢失)
3.服务突然宕机或者重启会导致订单丢失
消息队列 MessageQueue
使用List结构模拟
利用BLPOP/RPUSH或者BRPOP/LPUSH的方式“先进先出”
好处:不依赖于JVM内存,可以实现持久化,可以满足消息有序性
缺点:无法解决消息丢失,只支持单消费者(一个消费者从队列里把消息拿走消息就没了)
利用PubSub结构模拟
优点:支持多生产者消费者
缺点:不支持持久化,无法避免消息丢失(无订阅通道),消息堆积有上限(缓存在消费者)
使用RabbitMQ
简单改造并且引入配置之后就可以将拥塞队列改造成MQ的形式
配置类
@Configuration
public class RabbitConfig {
@Bean
public MessageConverter jsonMessageConverter() {
return new Jackson2JsonMessageConverter();
}
@Bean("directExchange") //定义交换机
public Exchange exchange(){
return ExchangeBuilder.directExchange("amq.direct").build();
}
@Bean("voucherMessageQueue") //定义MQ
public Queue queue(){
return QueueBuilder.durable("voucherMessageQueue").build();
}
@Bean("binding")
public Binding binding(@Qualifier("directExchange") Exchange exchange,
@Qualifier("voucherMessageQueue") Queue queue){
return BindingBuilder
.bind(queue)
.to(exchange)
.with("voucher") //自定义的routingKey
.noargs();
}
}
方法
@Override
public Result seckillVoucher(Long voucherId){
SeckillVoucher seckillVoucher = seckillVoucherService.getById(voucherId);
if(seckillVoucher.getBeginTime().isAfter(LocalDateTime.now())){
return Result.fail("未到当前优惠券抢购时间");
}
if(seckillVoucher.getEndTime().isBefore(LocalDateTime.now())){
return Result.fail("当前优惠券抢购时间已经结束");
}
Long res = stringRedisTemplate.execute(SECKILL_SCRIPT,
Collections.emptyList(),
voucherId.toString(),
UserHolder.getUser().getId().toString()
);
if(res == null) return Result.fail("系统错误,请重试");
int r = res.intValue();
if(r == 0){
//如果为0 存入MQ 返回id
long orderId = redisIdWorker.nextId("order");
VoucherOrder voucherOrder = new VoucherOrder();
voucherOrder.setVoucherId(voucherId);
voucherOrder.setId(orderId);
voucherOrder.setUserId(UserHolder.getUser().getId());
String JSON = JSONUtil.toJsonStr(voucherOrder);
rabbitTemplate.convertAndSend("amq.direct", "voucher", JSON);
return Result.ok(orderId);
}
else if(r == 1){
Result.fail("当前优惠券库存不足");
}
return Result.fail("当前优惠卷一人一单");
}
@RabbitListener(queues = "voucherMessageQueue")
public void voucherListener(String message){
VoucherOrder voucherOrder = JSONUtil.toBean(message, VoucherOrder.class);
save(voucherOrder);
seckillVoucherService.update()
.setSql("stock = stock - 1")
.eq("voucher_id", voucherOrder.getVoucherId())
.update();
}
但是这样做还是有一个比较大的问题:如果数据库保存失败则消息会永远丢失,所以我们要设置手动ACK而不是自动ACK
yaml文件中加入这个开启手动ACK
listener:
simple:
acknowledge-mode: manual
修改后的消费者
@RabbitListener(queues = "voucherMessageQueue")
@Transactional
public void voucherListener(Message message, Channel channel, String str) throws IOException {
//System.out.println(str);
long deliveryTag = message.getMessageProperties().getDeliveryTag();
try {
VoucherOrder voucherOrder = JSONUtil.toBean(str, VoucherOrder.class);
save(voucherOrder);
seckillVoucherService.update()
.setSql("stock = stock - 1")
.eq("voucher_id", voucherOrder.getVoucherId())
.update();
channel.basicAck(deliveryTag, false);
} catch (Exception e) {
channel.basicNack(deliveryTag, false, true);
}
}
但是这样做仍然有可能造成消息的丢失:在RabbitMQ确认数据ACK后消息就会删除,然后离开try-catch语法块,但是这里事务还没有提交,如果就在这时数据库断开连接,那么事务回退就会造成消息的丢失
最好是在try-catch块中手写TransactionTemplate来完成事物的提交
死信队列 为了处理用户订单超时或者订单取消
配置类新增
@Bean("deadExchange") //创建死信交换机
public Exchange deadExchange(){
return ExchangeBuilder.directExchange("dead.direct").build();
}
@Bean("deadQueue") //创建死信队列
public Queue deadQueue(){
return QueueBuilder.durable("deadQueue").build();
}
@Bean("deadBinding")
public Binding Deadbinding(@Qualifier("deadExchange") Exchange exchange,
@Qualifier("deadQueue") Queue queue){
return BindingBuilder
.bind(queue)
.to(exchange)
.with("dl-voucher")
.noargs();
}
//直连通道新增
.deadLetterExchange("dead.direct") //指定死信交换机
.deadLetterRoutingKey("dl-voucher") //指定死信routingKey
.maxLength(10) //消息队列最长长度
.ttl(5000) //超过ttl ms则进入死信队列
新增处理死信队列的方法
@RabbitListener(queues = "deadQueue")
@Transactional
public void DeadVoucherListener(Message message, Channel channel, String str) throws IOException {
long deliveryTag = message.getMessageProperties().getDeliveryTag();
Map<String, Object> headers = message.getMessageProperties().getHeaders();
// 1. 获取 x-death 数组
List<Map<String, Object>> xDeath = (List<Map<String, Object>>) headers.get("x-death");
String reason = null;
if (xDeath != null && !xDeath.isEmpty()) {
// 2. 获取第一条死信记录(最新记录)
Map<String, Object> firstDeathRecord = xDeath.get(0);
// 3. 从记录中获取原因字段
reason = (String) firstDeathRecord.get("reason");
}
System.out.println("死信原因: " + reason); // 输出如: rejected/expired/maxlen
try {
VoucherOrder voucherOrder = JSONUtil.toBean(str, VoucherOrder.class);
save(voucherOrder);
seckillVoucherService.update()
.setSql("stock = stock - 1")
.eq("voucher_id", voucherOrder.getVoucherId())
.update();
channel.basicAck(deliveryTag, false);
} catch (Exception e) {
channel.basicNack(deliveryTag, false, true);
}
}
最后记得去开启最大重试次数(否则有不应该进入的信息进入管道会一直重试)
retry:
initial-interval: 1000ms
max-attempts: 3
multiplier: 2.0
enabled: true
default-requeue-rejected: false
但是springboot的重试机制是捕捉到异常后才会重试,直接使用Nack或者retry.enable = true会跳过这个机制
好像还得设置一些比较底层的东西,不是很能弄明白,只能说把原理弄明白了,这里放弃重试机制
可能直接重新投消息并在头部设置一个人为的retry值比较好吧,等到重试次数用尽直接reject