前言
本文是笔者学习完黑马点评中秒杀接口后的总结,会梳理整个接口的设计,以及实现逻辑。但是受限于黑马教程,本接口不适用于Redis集群模式 部署,而且后续的异步请求使用了单线程的线程池,消费者组同样仅有一组,因此在性能上还有很大改进空间,生产环境下慎用本文的接口实现。
实现流程
1.根据优惠券ID得到优惠券信息 ->
2.执行Lua脚本 ->
3.开启新的线程处理消息 ->
4.更新数据库 -> over
Lua脚本
使用Lua脚本一次性完成对redis的原子操作,避免了线程安全问题。
java
public static final DefaultRedisScript<Long> SECKILL_SCRIPT;
static {
SECKILL_SCRIPT = new DefaultRedisScript<>(); // 创建 Redis 脚本对象
SECKILL_SCRIPT.setLocation(new ClassPathResource("seckill.lua")); // 指定脚本文件位置
SECKILL_SCRIPT.setResultType(Long.class); // 设置脚本返回值类型
}
/**
* 秒杀优惠券
* @param voucherId
* @return
*/@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 userId = UserHolder.getUser().getId();
long orderId = redisIdWorker.nextId("order");
//执行Lua脚本
Long result = stringRedisTemplate.execute(
SECKILL_SCRIPT,
Collections.emptyList(), // KEYS(必须是List,使用singletonList创建一个只包含单个元素的不可变列表)
voucherId.toString(), userId.toString(), String.valueOf(orderId) // ARGV(可变参数)
);
//判断结果是否为0(0有购买资格)
int r = result.intValue();
if (r != 0) {
return Result.fail(r == 1 ? "库存不足" : "没有购买资格");
}
return Result.ok(orderId);
}
运行逻辑:
- 根据voucherId拿到seckillVoucher实体,然后做前置判断,不通过直接返回fail。
- 获取脚本参数并运行脚本:
lua
-- 1.参数列表
-- 1.1.优惠券id
local voucherId = ARGV[1]
-- 1.2.用户id
local userId = ARGV[2]
-- 1.3.订单id
local orderId = ARGV[3]
-- 2.数据key
-- 2.1.库存key
local stockKey = 'seckill:stock:' .. voucherId
-- 2.2.订单key
local orderKey = 'seckill:order:' .. voucherId
-- 3.脚本业务
-- 3.1.判断库存是否充足 get stockKeyif(tonumber(redis.call('get', stockKey)) <= 0) then
-- 3.2.库存不足,返回1
return 1
end
-- 3.2.判断用户是否下单 SISMEMBER orderKey userIdif(redis.call('sismember', orderKey, userId) == 1) then
-- 3.3.存在,说明是重复下单,返回2
return 2
end
-- 3.4.扣库存 incrby stockKey -1redis.call('incrby', stockKey, -1)
-- 3.5.下单(保存用户)sadd orderKey userId
redis.call('sadd', orderKey, userId)
-- 3.6.发送消息到队列中, XADD stream.orders * k1 v1 k2 v2 ...redis.call('xadd', 'stream.orders', '*', 'userId', userId, 'voucherId', voucherId, 'id', orderId)
return 0
脚本逻辑:库存不足返回1,用户已经下过一次订单返回2(确保一人一单)。库存充足且用户未下过单,符合下单条件,扣减Redis中的库存并将当前用户id加入指定set集合,最后写入消息队列并返回0。
- 根据脚本结果响应请求:
- 1:库存不足
- 2:没有购买资格
- 0:有购买资格,直接返回ok,后续操作由其他线程完成。
注意事项:
- Lua脚本文件一般放在静态资源目录下,new ClassPathResource("seckill.lua")可在Resource目录下直接找到名为seckill.lua的脚本。
- Lua脚本返回的值为数字,所以 Spring Data Redis 会将其映射为 Long 类型,可以使用intValue()方法将其转换为int类型。
异步处理
Lua脚本已经将符合条件的订单信息发送到了消息队列(需提前在Redis中创建名为stream.orders的Stream消息队列),接下来就可以在一个单线程的线程池中处理消息。优点是后续请求都会串行执行,规避了很多线程安全问题,缺点是性能受限。
java
//创建线程池
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() {
while (true){
消费消息...
}
}
}
运行逻辑:
- 声明一个单线程的线程池,后续线程都从这里获取。
- 声明具体的线程任务VoucherOrderHandler,该方法实现了Runnable接口,并将run方法重写为消费消息的逻辑,体现了面向函数编程思想。
- 声明初始化方法init,在方法体中提交线程任务。在方法上添加@PostConstruct注解,确保Spring初始化完成之后立即进行初始化,即程序启动时便可处理消息队列。
处理消息队列的逻辑:
typescript
private class VoucherOrderHandler implements Runnable{
String queueName = "stream.orders";
@Override
public void run() {
while (true){
try {
//1.获取消息队列中的订单信息 xreadgroup group g1 ci count 1 block 2000 streams stream.order >
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> values = record.getValue();
VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(values, new VoucherOrder(), true);
try {
// use proxy to ensure transactional proxy is applied
proxy.createVoucherOrder(voucherOrder);
// 只有在成功落库后才 ACK 确认消息
stringRedisTemplate.opsForStream().acknowledge(queueName, "g1", record.getId());
} catch (Exception ex) {
// 处理失败,不 ACK,保留在 pending 由重试处理
log.error("下单处理失败,消息保留在 Pending 以重试 userId={}", voucherOrder.getUserId(), ex);
}
} catch (Exception e) {
//处理PendingList中的消息
handlePendingList();
log.error("处理订单异常", e);
}
}
}
}
- 读取消息队列中的最新数据,(因为此处只有一个线程,所以只设计了一个消费者组,并写死了组名和消费者名)
- 若未读到数据,说明目前没有消息需要处理,直接continue进入下一次循环
- 若读到数据,则要对数据进行解析,封装为voucherOrder。
- 调用
createVoucherOrder()方法,将订单保存到数据库:
less
@Transactional
@Override
public void createVoucherOrder(VoucherOrder voucherOrder) {
Long voucherId = voucherOrder.getVoucherId();
//扣减库存
boolean success = seckillVoucherService.update()
.setSql("stock = stock - 1")
.eq("voucher_id", voucherId).gt("stock", 0)
.update();
if (!success){
log.error("库存不足");
// 抛出运行时异常以便调用方知道失败,且事务会回滚
throw new RuntimeException("库存不足");
}
save(voucherOrder);
}
- 如果第4部执行时出现意外,则该处理中的消息不会被ACK确认,滞留在PendingList中,此时就会进入catch代码块中执行
handlePendingList()方法(VoucherOrderHandler类的内部方法)来处理滞留在PendingList中的消息:
typescript
/**
* 处理PendingList中的异常消息
*/
private void handlePendingList() {
while (true){
try {
//1.获取PendingList中的订单信息 xreadgroup group g1 ci count 1 block 2000 streams stream.order 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()) {
//获取失败,PendingList没有异常消息,结束循环
break;
}
//3.解析消息队列中的数据
MapRecord<String, Object, Object> record = list.get(0);
Map<Object, Object> values = record.getValue();
VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(values, new VoucherOrder(), true);
//获取成功,可以下单
try {
proxy.createVoucherOrder(voucherOrder);
stringRedisTemplate.opsForStream().acknowledge(queueName, "g1", record.getId());
} catch (Exception ex) {
log.error("Pending 消息处理失败,保留继续重试 userId={}", voucherOrder.getUserId(), ex);
}
} catch (Exception e) {
log.error("处理PendingList异常", e);
}
}
}
逻辑与处理正常消息时略有差别:
- 读取的是未ACK的消息,如果读取不到消息则说明没有异常消息,直接break; 跳出循环。
- 如果将PendingList中的消息加入数据库时依然出现异常(概率很小),可以记录错误日志,如果日后异常频发,再通过日志追踪解决问题。
注意事项:
stringRedisTemplate.opsForStream().read()方法读取的结果是一个list集合,因为发送消息时可以发送多条消息,由于在Lua脚本中只发送了一条消息,我们确认得到的List集合只有一个元素,因此使用list.get(0)来获取数据并封装。createVoucherOrder()带有事务注解@Transactional,要想该事务生效,必须使用代理对象,因此要使用
java
@Autowired
private IVoucherOrderService proxy;
注入proxy代理对象,并由代理对象调用createVoucherOrder()方法。