三、优惠券秒杀
1. 优惠券秒杀下单
(1)全局唯一ID
实现逻辑
全局ID生成器,是一种在分布式系统下来生成全局唯一ID的工具,一般需要考虑的特性:
①唯一性②高可用③高性能④递增性(便于数据统计)⑤安全性
为了增强ID的安全性,我们使用拼接的一些信息实现:
符号位:1bit,表示0;时间戳:31bit,以秒为单位表示当前时间戳;序列号:Redis自增实现(每天才开始自增)
全局唯一ID生成策略:UUID、Redis自增、snowflake算法(信息拼接,时间戳达到毫秒级)、数据库自增
代码实现
@Component
public class RedisIdWorker {
/**
* 开始时间戳
*/
private static final long BEGIN_TIMESTAMP = 1640995200L;
/**
* 序列号的位数
*/
private static final int COUNT_BITS = 32;
private StringRedisTemplate stringRedisTemplate;
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_TIMESTAMP;
// 2.生成序列号
// 2.1.获取当前日期,精确到天
String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
// 2.2.自增长
long count = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + ":" + date);
// 3.拼接并返回
return timestamp << COUNT_BITS | count;
}
}
(2)秒杀下单功能实现
实现流程
①用户id、优惠券的id; ②判断是否在抢购时间内;③优惠券id查找库存是否充足,充足余量-1;④将订单写入数据库,返回优惠券的订单id。
代码实现
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);
}
(3)乐观锁解决超卖问题
问题描述
查询库存是否充足和扣减库存是非同时进行,多线程情况下会出现多线程一起扣减出现超卖现象。
解决方案
悲观锁(Synchronized、Lock) 每次操作时需要用户获取锁,防止线程安全问题的发生。
认为线程安全问题一定会发生,因此在操作数据之前先获取锁,确保线程串行执行。
乐观锁:在修改数据库时需要确认当前的数据有被其它线程修改过,未修改过才能进行数据修改。
认为线程安全问题不一定会发生,因此不加锁,只是在更新数据时去判断有没有其它线程对数据做了修改。 如果没有修改则认为是安全的,自己才更新数据。 如果已经被其它线程修改说明发生了安全问题,此时可以重试或异常。
乐观锁的实现方式
版本号法
数据库中的数据引入版本号,当被某个线程修改数据后版本号+1。
CAS法
记录查询时的数据,在修改时当前数据与之前数据一致才能修改。
代码实现
在扣减库存同时判断是否超卖,用一句sql语句实现。
boolean success = seckillVoucherService.update()
.setSql("stock = stock - 1") // set stock = stock - 1
.eq("voucher_id", voucherId).eq("stock", voucher.getStock()) // where id = ? and stock > 0
.update();
if (!success) { // 扣减失败
return Result.fail("库存不足!");
}
boolean success = seckillVoucherService.update()
.setSql("stock = stock - 1") // set stock = stock - 1
.eq("voucher_id", voucherId).gt("stock", 0) // where id = ? and stock > 0
.update();
if (!success) { // 扣减失败
return Result.fail("库存不足!");
}
乐观锁方法一实现弊端:成功率低。当100个线程同时进入时,可能有超过百分之六七十的线程判断数据被修改过,而修改数据失败。因为通过方式二对代码进行改进,只需判断库存大于0即可。
2. 一人一单
(1)一人一单基础实现
实现逻辑
①在购买之前查询优惠券的订单列表,看当前用户是否购买过。
②为了防止多线程的线程安全问题,需要加锁来实现同一个用户只有一个线程进行购买
③添加事务注解,保证查询、判断、和下单的原子性
代码实现
@Transactional
public Result createVoucherOrder(Long voucherId) {
// 5.一人一单
Long userId = UserHolder.getUser().getId();
synchronized (userId.toString().intern()) {
// 5.1.查询订单
int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
// 5.2.判断是否存在
if (count > 0) {
// 用户已经购买过了
return Result.fail("用户已经购买过一次!");
}
// 6.扣减库存
boolean success = seckillVoucherService.update()
.setSql("stock = stock - 1") // set stock = stock - 1
.eq("voucher_id", voucherId).gt("stock", 0) // where id = ? and stock > 0
.update();
if (!success) {
// 扣减失败
return Result.fail("库存不足!");
}
// 7.创建订单
VoucherOrder voucherOrder = new VoucherOrder();
// 7.1.订单id
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
// 7.2.用户id
voucherOrder.setUserId(userId);
// 7.3.代金券id
voucherOrder.setVoucherId(voucherId);
save(voucherOrder);
// 7.返回订单id
return Result.ok(orderId);
}
}
(2)互斥锁
乐观锁是在更新数据时使用的,一人一单问题是插入数据,所以需要悲观锁来实现。
a.锁加在哪里?
方法上(作用范围是当前对象,线程是安全的,但是任何一个用户都使用同一把锁,效率低)
代码块上(效率高)
b.以什么做为锁的标识
给用户id加锁,提升效率
为什么需亚奥userId.toString().intern()
原因:每一个字符串都是一个全新的对象,toString()源码中又创建了新的字符串,因此需要intern,返回字符串常量的规范表示,也就是在常量池中查找和当前字符串值一样的值的地址。
synchronized (userId.toString().intern()){}
c.为了事务生效,需要代理对象进行调用。
事务是在释放锁之后进行提交,要是此时其它线程进来,可能新增的订单还未写入数据库,可能存在线程安全问题。因此锁需要加在整个函数的外面。
引申问题:事务失效了怎么办?如何解决事务失效的问题?
①pom文件中导入依赖坐标 ②在启动类上面加入@EnableAspectJAutoProxy(exposeProxy = true)注解③获取代理对象,通过代理对象来调用添加了@Transactional注解的函数。
<dependency>
<groupId>org.aspectj</groupId>
<artifactId>aspectjrt</artifactId>
</dependency>
<dependency>
<groupId>org.aspectj</groupId>
<artifactId>aspectjweaver</artifactId>
</dependency>
//事务使用的是代理对象进行的,所以要获取代理对象再进行调用对应的与事务有关的函数
synchronized (userId.toString().intern()){
IVoucherOrderService proxy =(IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
}
@EnableAspectJAutoProxy(exposeProxy = true)
@MapperScan("com.hmdp.mapper")
@SpringBootApplication
public class HmDianPingApplication {
(3)分布式锁
分布式锁的核心是实现多线程之间的互斥。需要具有多线程可见、互斥、高可用(大多数情况下获取锁都是成功的)、高性能、安全性(死锁之类的问题)的特性。
(4)服务器集群下的线程安全问题
每个服务器都有独立的锁监视器,synchronized是通过锁监视器来控制线程的。因此在集群模式下synchronized互斥锁仍会出现线程安全问题。
解决方案
基于Redis的setnx实现分布式锁。
获取锁:①setnx实现互斥 ②添加锁过期时间,避免服务宕机引起的死锁 ③非阻塞的,获取失败后不再进行尝试获取(需要根据业务需求来定)
释放锁:①手动释放delete ②过期后自动释放锁
代码实现
①分布式锁版本一——基础实现--获取锁和释放锁
注意:线程id需要保存,并且为了安全性,需要通过UUID加锁前缀
public class SimpleRedisLock implements ILock {
private String name;
private StringRedisTemplate stringRedisTemplate;
public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
this.name = name;
this.stringRedisTemplate = stringRedisTemplate;
}
private static final String KEY_PREFIX = "lock:";
private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";
@Override
public boolean tryLock(long timeoutSec) {
// 获取线程标示
String threadId = ID_PREFIX + Thread.currentThread().getId();
// 获取锁
Boolean success = stringRedisTemplate.opsForValue()
.setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS);
return Boolean.TRUE.equals(success);
}
//当前线程只能删除当前线程的锁
@Override
public void unlock() {
// 释放锁
stringRedisTemplate.delete(KEY_PREFIX + name);
}
②一人一单问题实现
@Transactional
public Result createVoucherOrder(Long voucherId) {
// 5.一人一单
Long userId = UserHolder.getUser().getId();
// 创建锁对象
SimpleRedisLock redisLock = new SimpleRedisLock("order:" + userId, stringRedisTemplate);
// 尝试获取锁
boolean isLock = redisLock.tryLock(1200);
// 判断
if(!isLock){
// 获取锁失败,直接返回失败或者重试
return Result.fail("不允许重复下单!");
}
try {
// 5.1.查询订单
int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
// 5.2.判断是否存在
if (count > 0) {
// 用户已经购买过了
return Result.fail("用户已经购买过一次!");
}
// 6.扣减库存
boolean success = seckillVoucherService.update()
.setSql("stock = stock - 1") // set stock = stock - 1
.eq("voucher_id", voucherId).gt("stock", 0) // where id = ? and stock > 0
.update();
if (!success) {
// 扣减失败
return Result.fail("库存不足!");
}
// 7.创建订单
VoucherOrder voucherOrder = new VoucherOrder();
// 7.1.订单id
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
// 7.2.用户id
voucherOrder.setUserId(userId);
// 7.3.代金券id
voucherOrder.setVoucherId(voucherId);
save(voucherOrder);
// 7.返回订单id
return Result.ok(orderId);
} finally {
// 释放锁
redisLock.unlock();
}
}
③分布式锁版本二——基于分布式锁误由于业务阻塞导致的误删问题
线程一在执行业务的时候发生的阻塞导致锁被超时释放。
线程二此时来获取锁,执行业务。
线程一执行完毕后,释放了线程二的锁。
线程三来了,获取锁成功,出现了线程安全问题。
释放锁的优化版本
@Override
public void unlock() {
// 获取线程标示
String threadId = ID_PREFIX + Thread.currentThread().getId();
// 获取锁中的标示
String id = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);
// 判断标示是否一致
if(threadId.equals(id)) {
// 释放锁
stringRedisTemplate.delete(KEY_PREFIX + name);
}
}
④分布式锁版本三——基于lua脚本实现
当线程一在判断是自己的线程id后,发生了阻塞导致锁超时释放。
线程二获取锁之后执行业务,锁再次被线程一误删。可能出现线程安全的问题。
基于lua脚本来实现多条Redis语句的原子性
lua脚本
- 示例 EVAL "redis.call('set',KEYS[1],ARGV[1])" 1 name rose (1表示key类型的参数个数,它后面跟的几个数都是key的值)
- lua脚本实现释放锁的功能
-- 比较线程标示与锁中的标示是否一致
if(redis.call('get', KEYS[1]) == ARGV[1]) then
-- 释放锁 del key
return redis.call('del', KEYS[1])
end
return 0
- Java代码执行lua脚本(创建一个RedisScript脚本,配置位置和返回类型,通过stringRedisTemplte.execute来执行该脚本)
public class SimpleRedisLock implements ILock {
private String name;
private StringRedisTemplate stringRedisTemplate;
public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
this.name = name;
this.stringRedisTemplate = stringRedisTemplate;
}
private static final String KEY_PREFIX = "lock:";
private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";
private 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 boolean tryLock(long timeoutSec) {
// 获取线程标示
String threadId = ID_PREFIX + Thread.currentThread().getId();
// 获取锁
Boolean success = stringRedisTemplate.opsForValue()
.setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS);
return Boolean.TRUE.equals(success);
}
@Override
public void unlock() {
// 调用lua脚本
stringRedisTemplate.execute(
UNLOCK_SCRIPT,
Collections.singletonList(KEY_PREFIX + name),
ID_PREFIX + Thread.currentThread().getId());
//stringRedisTemplate.delete(KEY_PREFIX + name);
}
/*@Override
public void unlock() {
// 获取线程标示
String threadId = ID_PREFIX + Thread.currentThread().getId();
// 获取锁中的标示
String id = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);
// 判断标示是否一致
if(threadId.equals(id)) {
// 释放锁
stringRedisTemplate.delete(KEY_PREFIX + name);
}
}*/
}
总结
基于Redis的分布式锁实现思路:①通过setnx ex实现互斥,并设置过期时间(保证在故障时锁依然可以释放,避免死锁,提升安全性),保存线程标识。
②释放锁时需要判断是否与当前id一致,再进行释放锁操作。lua脚本保证原子性。
3. 基于Redis的分布式锁优化——Redisson
问题分析
基于setnx实现的分布式锁存在下面的问题
不可重入:同一个线程无法多次获取同一把锁
不可重试:获取锁只尝试一次,返回false,没有重试机制
超时释放:锁超时释放虽然避免死锁,但如果是业务执行耗时较长,也会导致锁释放,存在安全隐患。
主从一致性:如果Redis提供了主从集群,主从同步存在延迟,当主机宕机时,如果从未同步主中的锁数据,则会出现锁实现。
Redisson是一个在Redis的基础上实现Java驻内存数据网络。它提供了一系列的分布式的Java常用对象,还提供了许多分布式服务,其中就包含了各种分布式锁的实现。
(1)Redisson入门
- 导入依赖
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.13.6</version>
</dependency>
- 配置Ression客户端(Redisson Client定义成了一个Bean,方便后续使用)
@Configuration
public class RedisConfig {
//通过无参构造器来生成一个RedissonClient Bean
@Bean
public RedissonClient redissonClient(){
//配置类
Config config = new Config();
//添加redis地址,这里添加了单点地址
config.useSingleServer().setAddress("redis://localhost:6379").setPassword("123456");
//创建客户端
return Redisson.create(config);
}
}
- 使用Redisson的分布式锁
@Resource
private RedissonClient redissonClient;
void testRedisson() throws InterruptedException{
//1.创建锁对象
RLock lock = redissonClient.getLock("anyLock");
//2.尝试获取锁;阻塞式的,最大等待时间时1s,超时释放时间是10s,时间单位是s
boolean isLock = lock.tryLock(1, 10, TimeUnit.SECONDS);
//3.获取锁是否成功
if(isLock){
try {
//4.业务在这里有可能出现阻塞
System.out.println("执行业务逻辑");
} finally {
//5.最终都要释放锁
lock.unlock();
}
}
}
(2)Redisson可重入锁的原理
基于Redis的hash结构,key记录锁的名称,field记录线程id,value记录重入次数。
获取锁
传入3个参数:key,线程id,有效期;
①判断当前key的锁是否存在,不存在,保存当前线程的数据,设置有效期,value设置为1
②当前key存在,看持有锁是不是自己线程,否,获取锁失败。③是,value+1,重置有效期
释放锁
传入3个参数:key,线程id,有效期;
①判断当前的线程id与锁持有者的线程id是否一致,不一致返回异常信息②是,value-1。③判断value是否为0,为0,delete。
(3)Redisson的锁重试和WatchDog
获取锁
①尝试获取锁,获取锁成功,保存数据。②获取锁失败,判断剩余获取锁等待时间是否大于0,大于,等待其它线程释放锁的消息。③等待超时,返回false ④等待到了,判断剩余时间,重试获取锁。⑤获取锁成功后判断锁过期时间是否为-1 ⑥为-1,开启看门狗机制。⑦返回获取锁成功或者在指定时间内获取锁失败的信号。
释放锁
①判断是否与当前线程保持一致。②一致释放锁。③若超时释放时间为默认值,关闭看门狗机制。④返回。
可重试:利用信号量和PubSub功能实现等待、唤醒,获取锁失败的重试机制。
超时续约:利用watchDog,每隔一段时间(release/3),重置超时时间。
(4)Redisson分布式锁主从一致性问题--MultiLock
基本原理:多个独立的Redis节点,必须在所有节点都获取重入锁,才算获取锁成功。
实现机制:底层是一个ArrayList, 在获取锁时,每获取到一个锁,都会加入到list集合中,只有所有 的锁都获取到才会说锁获取成功;若每个锁获取失败,则会删除已经获取存放在list集合中的锁。
会不会重试由外部设置得到,如果重试,在某一个锁获取失败后,清空已经获取的锁,将指针放在第一个,重新开始循环进行尝试。
在获取锁成功后,如果手动设置了超时释放时间,会遍历锁集合,重置超市释放时间。
lock=redissonClient.getMultiLock(lock1,lock2,lock3);
4. 异步秒杀优化
(1)流程优化思路
秒杀业务流程:①查询优惠券信息②判断库存是否充足③判断该用户是否购买了该产品④创建订单 ⑤扣减库存 ⑥订单信息写入数据库。
分析:用户端下单时间,既需要依赖于前期购买资格的判断,又依赖后面操作数据库时间。实质上购买资格确定,就可以进行下单操作了。因此,可以将购买资格判断和操作数据库异步分离。
设计:购买资格判断可以在Redis中功能实现、lua脚本代码实现。①库存--Redis中String数据结构 ②一人一单--Redis中Set数据结构。
注意:在Redis购买资格判断后也需要做数据更新操作。
实现步骤:
(2)数据预热
新增优惠券同时加入Redis
@Override
@Transactional
public void addSeckillVoucher(Voucher voucher) {
// 保存优惠券
save(voucher);
// 保存秒杀信息
SeckillVoucher seckillVoucher = new SeckillVoucher();
seckillVoucher.setVoucherId(voucher.getId());
seckillVoucher.setStock(voucher.getStock());
seckillVoucher.setBeginTime(voucher.getBeginTime());
seckillVoucher.setEndTime(voucher.getEndTime());
seckillVoucherService.save(seckillVoucher);
//保存到Redis当中
stringRedisTemplate.opsForValue().set(SECKILL_STOCK_KEY+voucher.getId(),voucher.getStock().toString());
}
(3)基于Lua脚本购买资格判断
参数传入:用户id、优惠券id、订单id
核心代码分析:①判断优惠券库存是否充足,不充足返回false ②判断用户id是否在已购买用户的集合中,存在,返回false ③不存在,添加用户到集合中,并完成扣减库存的操作。④添加用户id、优惠券id、订单id到消息队列中(下文会详细说明)。
--判断库存是否充足需要传入参数 优惠券id
--判断该用户是否下单需要传入参数 用户id
local voucherId = ARGV[1]
local userId = ARGV[2]
local orderId = ARGV[3]
--1.库存和订单在redis中的key分别是什么
local stockKey="seckill:stock:".. voucherId
local orderKey="seckill:order:"..voucherId
--2.判断库存是否充足
local stockCount = redis.call('get', stockKey)
if stockCount == false or stockCount == nil then
redis.log(redis.LOG_NOTICE, "Stock key does not exist: " .. stockKey)
return 11 -- 库存不足
elseif tonumber(stockCount) <= 0 then
redis.log(redis.LOG_NOTICE, "Insufficient stock for key: " .. stockKey)
return 12 -- 库存不足
end
--3.判断该用户是否已经下过单了
if(redis.call('sismember',orderKey,userId)==1) then
--已经下过单的,返回2
return 2
end
--表示可以下单了
--4.减库存
redis.call('incrby',stockKey,-1)
--5.添加用户id到集合中
redis.call('sadd',orderKey,userId)
--6.发送消息到队列中,XADD stream.orders * k1 v1 k2 v2
redis.call('xadd','stream.orders','*','userId',userId,'voucherId',voucherId,'id',orderId)
return 0
(4)基于阻塞队列的异步秒杀
阻塞队列:如果队列中没有元素,线程会被阻塞,直到有元素,线程才会被唤醒,执行业务逻辑。
整体秒杀逻辑
需要在lua脚本成功后,添加voucherOrder到阻塞队列
/**
* 基于阻塞队列的秒杀业务逻辑
* @param voucherId
* @return
*/
@Override
public Result seckillVoucher(Long voucherId){
//1.执行lua脚本
Long userId= UserHolder.getUser().getId();
Long result = stringRedisTemplate.execute(SECKILL_SCRIPT,
Collections.emptyList(),voucherId.toString(),userId.toString());
//2.判断结果是否为0
//3.如果不为0,返回异常信息
int r = result.intValue();
if(r!=0){
if(r==1){
return Result.fail("库存不足");
}else if(r==2){
return Result.fail("每人只限购一单");
}
}
//4.为0,将信息添加到阻塞队列里面
//4.生成订单
VoucherOrder voucherOrder=new VoucherOrder();
//4.1 订单id
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
//4.2 用户id
voucherOrder.setUserId(userId);
//4.3 代金券id
voucherOrder.setVoucherId(voucherId);
orderTasks.add(voucherOrder);
//5.获取代理对象
proxy=(IVoucherOrderService) AopContext.currentProxy();
//5.返回订单id
return Result.ok(orderId);
}
创建阻塞队列,创建线程池
//线程池
private static final ExecutorService SECKILL_ORDER_EXECUTOR= Executors.newSingleThreadExecutor();
//类初始化之后就开始执行任务
@PostConstruct //该注解表示在类初始化完毕就执行
private void init(){
SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHandler());
}
private class VoucherOrderHandler implements Runnable{
@Override
public void run() {
//执行线程任务
return;
}
}
说明:线程任务需要在类初始化之后立即执行,所以需要@PostConstruct注解,实现任务的提交
编写线程任务
private class VoucherOrderHandler implements Runnable{
@Override
public void run() {
while(true){
//1.获取队列中的订单信息
try {
//take函数获取和删除该队列的头部,如果需要则等待直到元素可用,所以没有元素,会直接阻塞在这里
VoucherOrder voucherOrder = orderTasks.take();
//2.创建订单
handleVoucherOrder(voucherOrder);
} catch (Exception e) {
log.error("处理订单异常:",e);
}
}
}
}
创建订单
/**
* 处理订单写入数据库
* @param voucherOrder
* @return
*/
@Autowired
private IVoucherOrderService proxy;
private Result handleVoucherOrder(VoucherOrder voucherOrder) {
long userId=voucherOrder.getUserId();
//1.获取锁
RLock lock = redissonClient.getLock("lock:order" + userId);
boolean isLock = lock.tryLock();
//2.获取锁失败,下单失败
if(!isLock){
//获取锁失败,返回错误或重试
log.error("不允许重复下单");
return null;
}
try {
//该线程是子线程,无法在该处获取到事务的对象,因为需要提前获取
//IVoucherOrderService proxy=(IVoucherOrderService) AopContext.currentProxy();
proxy.createVoucherOrder(voucherOrder);
return Result.ok();
} finally {
lock.unlock();
}
}
注意:该函数由子线程进行调用,因此①在获取用户id,无法从ThreadLocal中获取。②获取代理对象本质也是在ThreadLocal中获取,因此需要将其提前定义成成员变量。
@Transactional
public void createVoucherOrder(VoucherOrder voucherOrder){
//5.一人一单
long userId= voucherOrder.getUserId();
long voucherId=voucherOrder.getVoucherId();
//5.1 查询订单
int count=query().eq("user_id",userId).eq("voucher_id",voucherId).count();
//5.2 判断是否存在
if(count>0){
log.error("用户已经购买过一次了");
return;
}
//6.扣减库存
// boolean success = iSeckillVoucherService.update()
// .setSql("stock=stock-1")
// .eq("voucher_id", voucherId)
// .eq("stock",stock)
// .update();
//解决效率低的问题
boolean success = iSeckillVoucherService.update()
.setSql("stock=stock-1")
.eq("voucher_id", voucherId)
.gt("stock",0)
.update();
if(!success){
log.error("优惠券已经抢完了");
return;
}
save(voucherOrder);
}
这里乐观锁只是再操作数据库时再次做双重的保障。
基于阻塞队列的异步秒杀问题
①阻塞队列是基于JVM内存,同时添加了很多订单,存在内存限制问题
②数据安全信息:依赖于JVM内存,可能存在数据丢失。
5. 消息队列
(1)消息队列简介
消息队列,字面意思就是存放消息的队列。最简单的消息队列模型包括3个角色:
- 消息队列:存储和管理消息,也就是消息代理。
- 生产者:发送消息到消息队列
- 消费者:从消息队列中获取消息并处理消息
特点:①消息队列是独立于JVM以外的服务,是独立内存,不受JVM内存的限制 ②消息队列会做数据持久化,需要消费者确认,确保数据安全。
(2)基于List结构模拟消息队列
- List这里是一个双向链表:使用Redis中的List的LPUSH和BRPOP实现消息队列
(B表示带有阻塞效果,而不是没有元素就返回null值无法再进行尝试,也可以设置阻塞等待的最大时长)
- 基于List的消息队列有哪些优缺点?
优点:①利用Redis存储,不受限于JVM内存上限②基于Redis的持久化机制,数据安全性有保障。③可以满足消息有序性。
缺点:①无法避免消息丢失 ②只支持单消费者(当前数据pop出去后无法再提供给其它消费者)
(3)基于PubSub的消息队列
- PubSub(发布订阅)是Redis2.0版本引入的消息传递模型。消费者可以订阅一个或多个channel,生产者向对应channel发送消息后,所有订阅者能收到消息。
- 基于PubSub的消息队列有哪些优缺点?
优点:采用发布订阅模型,支持多生产、多消费
缺点:①不支持数据持久化②无法避免消息丢失 (因为PubSub消息队列是负责发消息的,数据不在Redis中保存,如果没有消费者接收消息,消息可能会丢失)③消息堆积上限(消费者),超出时数据丢失。
(4)基于Stream的消息队列--基础
Stream是Redis 5.0 引入的一种新数据类型(具有持久化的功能),可以实现一个功能非常完善的消息队列。
发布消息
XADD user(channel名称) *(消息id的编号方式,默认时间戳-递增数字)name jack age 21(后面是消息体,key-value键值对)
接收消息
XREAD COUNT 1(读取的个数)BLOCK 1000(设置为阻塞队列,最大阻塞等待时间为1s,0为永久阻塞)STREAMS users (频道) $(表示读取最新的消息,其实也可以指定读取哪一个)
XREAD命令特点
①消息可回溯 ②一个消息可以被多个消费者读取 ③可以阻塞读取 ④有消息漏读的风险
(5)基于Stream的消息队列——消费者组
消费者组的特点
消息分流:队列中的消息会分流给组内的不同消费者,加快消息处理速率
消息标示:消费者组会维护一个标示,记录最后一个被处理的消息,哪怕消费者宕机重启,还会从标示之后读取消息,确保每一个消息被消费,不会出现消息漏读的情况。
消息确认:消费者获取消息后,消息处于pending状态(待确认状态),并存入一个pending-list。当处理完成后需要通过XACK来确认消息,才会被pending-list移除。
命令实现
消费者监听消息的基本思路
(6)基于Stream队列的异步秒杀
创建Stream类的消息队列
在Redis客户端直接创建
更改lua脚本,添加消息队列中消息
在原有逻辑判断购买资格时执行
--判断库存是否充足需要传入参数 优惠券id
--判断该用户是否下单需要传入参数 用户id
local voucherId = ARGV[1]
local userId = ARGV[2]
local orderId = ARGV[3]
--1.库存和订单在redis中的key分别是什么
local stockKey="seckill:stock:".. voucherId
local orderKey="seckill:order:"..voucherId
--2.判断库存是否充足
local stockCount = redis.call('get', stockKey)
if stockCount == false or stockCount == nil then
redis.log(redis.LOG_NOTICE, "Stock key does not exist: " .. stockKey)
return 11 -- 库存不足
elseif tonumber(stockCount) <= 0 then
redis.log(redis.LOG_NOTICE, "Insufficient stock for key: " .. stockKey)
return 12 -- 库存不足
end
--3.判断该用户是否已经下过单了
if(redis.call('sismember',orderKey,userId)==1) then
--已经下过单的,返回2
return 2
end
--表示可以下单了
--4.减库存
redis.call('incrby',stockKey,-1)
--5.添加用户id到集合中
redis.call('sadd',orderKey,userId)
--6.发送消息到队列中,XADD stream.orders * k1 v1 k2 v2
redis.call('xadd','stream.orders','*','userId',userId,'voucherId',voucherId,'id',orderId)
return 0
获取stream队列中消息
①创建线程池 ②在初始化后执行线程任务 ③编写线程任务 ④不断读取消息队列,因此是死循环。⑤获取消息,失败后continue继续获取⑥成功,写入数据库,进行消息确认⑦如抛出异常,处理pendinglist中的消息。
//线程池
private static final ExecutorService SECKILL_ORDER_EXECUTOR= Executors.newSingleThreadExecutor();
//类初始化之后就开始执行任务
@PostConstruct //该注解表示在类初始化完毕再去执行
private void init(){
SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHandler());
}
private class VoucherOrderHandler implements Runnable{
@Override
public void run() {
String queueName="stream.orders";
while(true){
try {
//1.获取消息队列中的订单信息
//XREADGROUP GROUP g1 c1 COUNT 1 BLOCK 2000 STREAMS s1 >
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())
);
//2.获取不成功,重试或者返回
if(list==null||list.isEmpty()){
continue;
}
//3.获取成功,写入数据库
MapRecord<String, Object, Object> record = list.get(0);
Map<Object, Object> value = record.getValue();
VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(value, new VoucherOrder(), true);
proxy.createVoucherOrder(voucherOrder);
//4.ack确认
stringRedisTemplate.opsForStream().acknowledge(queueName,"g1",record.getId());
} catch (Exception e) {
log.error("处理订单异常:",e);
try {
handlePengdingList();
} catch (InterruptedException interruptedException) {
interruptedException.printStackTrace();
}
}
}
}
}
pendinglist中消息处理
①获取pendinglist中的消息,这里需要将XREADGROUP中的最后一个参数设置为0 ②若消息为空,break,表明已经没有待确认的消息了 ③否则,将订单信息写入数据库 ④确认消息 ⑤在消息处理中出现异常,continue。
private void handlePengdingList() throws InterruptedException {
String queueName="stream.orders";
while(true){
try {
//1.获取pengding-list消息队列中的订单信息
//XREADGROUP GROUP g1 c1 COUNT 1 BLOCK 2000 STREAMS s1 0
List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read(
Consumer.from("g1", "c1"),
StreamReadOptions.empty().count(1),
StreamOffset.create(queueName, ReadOffset.from("0"))
);
//2.获取不成功,重试或者返回
if(list==null||list.isEmpty()){
//如果获取失败,说明pengding-list没有异常消息,跳出循环
break;
}
//3.获取成功,写入数据库
MapRecord<String, Object, Object> record = list.get(0);
Map<Object, Object> value = record.getValue();
VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(value, new VoucherOrder(), true);
proxy.createVoucherOrder(voucherOrder);
//4.ack确认
stringRedisTemplate.opsForStream().acknowledge(queueName,"g1",record.getId());
} catch (Exception e) {
log.error("处理订单异常:",e);
Thread.sleep(10);
}
}
}