1、关于顾客下单的订单号
为了保证顾客下单订单号唯一性以及不暴露给顾客一些敏感信息(例如:第几个下单的),所以不能采用单纯自增的方式。
这里采用redis自增id策略,id为时间戳+计数器。
需要说明的是 在redis保存的key+计数器,注意,这里的值是计数器,并非id;
key是icr:order:2025:05:21 (当天下单时间),id是当前时间戳-设定的起始时间戳+自增count,一起合成id,这是代码
package com.hmdp.utils;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
@Component
public class RedisIdWorker {
@Resource
private StringRedisTemplate stringRedisTemplate;
// 开始时间戳(2022-01-01 00:00:00 UTC 对应的秒数)
private static final long BEGIN_TIMESTAMP = 1640995200L;
// 序列号占 32 位(意味着每天最多支持 2^32 = 42亿个 ID)
private static final int COUNT_BITS = 32;
public long nextId(String keyPrefix) {
// 第一步:当前时间戳(单位:秒)
LocalDateTime now = LocalDateTime.now();
long nowSecond = now.toEpochSecond(ZoneOffset.UTC); // 当前秒
long timestamp = nowSecond - BEGIN_TIMESTAMP; // 距离起始时间的秒数
// 第二步:自增序列号
String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd")); // 例如 "2025:05:21"
String redisKey = "icr:" + keyPrefix + ":" + date; // Redis key: icr:order:2025:05:21 一天一个key,便于统计订单量
long count = stringRedisTemplate.opsForValue().increment(redisKey); // 使用 Redis 原子自增
// 第三步:拼接返回
// 高32位:timestamp;低32位:count 时间戳+自增id
return (timestamp << COUNT_BITS) | count;
}
}
2、秒杀下单
前端发送这样一个请求:
需要考虑库存+下单时间是否在优惠券有效期内
初始代码实现:
@Override
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("库存不足!");
}
//5,扣减库存
boolean success = seckillVoucherService.update()
.setSql("stock= stock -1")
.eq("voucher_id", voucherId).update();
if (!success) {
//扣减库存
return Result.fail("库存不足!");
}
//6.创建订单
VoucherOrder voucherOrder = new VoucherOrder();
// 6.1.订单id
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
// 6.2.用户id
Long userId = UserHolder.getUser().getId();
voucherOrder.setUserId(userId);
// 6.3.代金券id
voucherOrder.setVoucherId(voucherId);
save(voucherOrder);
return Result.ok(orderId);
}
不过存在一个问题,在多线程并发情况下,比如现在库存就剩8个了,来了18个线程同时判断库存是否充足,此时,都是充足的,都减去库存,结果库存出现-10这个负数,这就是超卖问题。
3、超卖问题的解决之乐观锁
乐观锁:会有一个版本号,每次操作数据会对版本号+1,再提交回数据时,会去校验是否比之前的版本大1 ,如果大1 ,则进行操作成功,这套机制的核心逻辑在于,如果在操作过程中,版本号只比原来大1 ,那么就意味着操作过程中没有人对他进行过修改,他的操作就是安全的,如果不大1,则数据被修改过。
第一步:数据库表中加一个 version
字段
你的 seckill_voucher
表结构应该如下:
ALTER TABLE seckill_voucher ADD COLUMN version INT DEFAULT 0;
第二步:查询时读取 stock
和 version
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
Integer stock = voucher.getStock();
Integer version = voucher.getVersion();
第三步:使用版本号控制更新,只有在 version
没变的情况下,才允许更新
boolean success = seckillVoucherService.update()
.set("stock", stock - 1)
.set("version", version + 1)
.eq("voucher_id", voucherId)
.eq("version", version) // 乐观锁核心条件
.update();
这句 SQL 会被翻译为:
UPDATE seckill_voucher
SET stock = stock - 1, version = version + 1
WHERE voucher_id = ? AND version = ?
只有在 当前版本号没被别的线程改动的前提下才会更新成功,从而防止并发情况下多个线程同时扣减。
// 1. 查询库存和版本
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
if (voucher.getStock() < 1) {
return Result.fail("库存不足");
}
Integer version = voucher.getVersion();
// 2. 乐观锁更新库存
boolean success = seckillVoucherService.update()
.set("stock", voucher.getStock() - 1)
.set("version", version + 1)
.eq("voucher_id", voucherId)
.eq("version", version)
.update();
if (!success) {
return Result.fail("没抢到");
}
// 3. 创建订单
当然乐观锁还有一些变种的处理方式比如CAS,不过可能出现ABA问题,当然咱们这个场景不会出现ABA问题,因为咱只有减,库存只有不会增加,当然你说,万一有人退单咋办,但是这里并不影响我们的业务,具体业务需要具体分析CAS导致的ABA问题。
这里扩展一下:
ABA 问题:某个线程读取了一个值 A,在更新前的检查中发现它还是 A,于是认为值没有变就放心地去修改了,但实际上这个值曾经被改成过 B 又改回了 A —— 这种中间状态的变化对当前线程是不可见的。用版本号方法可以解决CAS出现的ABA问题。
这里给出CAS解决方案就是:在更新数据据库中的数据再加上一个库存是否大于0的判断条件,从而避免超卖问题。
boolean success = seckillVoucherService.update()
.setSql("stock = stock-1")
.eq("voucher_id", voucherId)
.gt("stock", 0)
.update() ;
// 秒杀 悲观锁和乐观锁(版本号法(不会出现ABA问题)和CAS法(可能出现ABA问题)),秒杀不会发生ABA问题,因为库存只会减,不会增
if(!success)
{
log.error("库存不足");
}
不过,乐观锁,基于数据库实现,高并发数据库压力大,适合并发量中低的场景,不适合 “秒杀” 这种高并发场景。 而且乐观锁只能解决写写冲突( 两个线程同时修改同一份数据,可能相互覆盖)。
线程并发冲突时,可能的解决方案有:
4、一人一单问题
现在要求每人只能下一单,修改业务逻辑初步为:
@Override
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("库存不足!");
}
// 5.一人一单逻辑
// 5.1.用户id
Long userId = UserHolder.getUser().getId();
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")
.eq("voucher_id", voucherId).update();
if (!success) {
//扣减库存
return Result.fail("库存不足!");
}
//7.创建订单
VoucherOrder voucherOrder = new VoucherOrder();
// 7.1.订单id
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
voucherOrder.setUserId(userId);
// 7.3.代金券id
voucherOrder.setVoucherId(voucherId);
save(voucherOrder);
return Result.ok(orderId);
}
假设,一个人开一个挂,编写一个脚本,也变成了一个人的高并发问题,如果这些线程同时到达判断订单表是否存在关于这个用户和该商品id的订单,结果发现都没有,这又会引起一个用户重复下单问题。
怎么解决呢?有人说,不是刚刚学习了乐观锁吗,用它,用它,用它啊!!!
令人遗憾的是,先查询是否有,再插入的逻辑,乐观锁根本无法控制,只有那种更新数据库同一条数据的时候才可以用乐观锁并发控制。
行吧,到这里我们使用悲观锁解决,就是说在查询是否已下单和建立订单数据同时进行即可,当然,你可以在之前再判断一下库存是否充足,这样就通过悲观锁解决了超卖问题,不过这里用乐观锁解决超卖问题。
插个题外话,在高并发下,数据库唯一索引可以杜绝重复下单问题,这里我们没有选择这种方式解决。
5、一人一单问题悲观锁解决
解决方案有synchronized、setnx机制锁、Redisson分布式锁、Redis+Lua脚本+队列异步下单。
5.1 synchronized锁细节
synchronized锁一般不用,因为它是基于JVM,当存在多服务器的时候,多个JVM,就失效了,不过我们需要研究其实现的细节。
public Result seckillVoucher(Long voucherId) {
// 1.查询优惠劵
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
// 如果查不到结果(即没有匹配的记录),这个方法会直接返回 null。
if (voucher == null) {
return Result.fail("优惠券不存在");
}
// 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("库存不足");
}
UserDTO user = UserHolder.getUser();
Long userId = user.getId();
//1. 本地上锁
// 在单服务器(本地)可以使用这种方式进行上锁
synchronized (userId.toString().intern())
{
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
}
}
@Transactional()
public Result createVoucherOrder(Long voucherId)
{
// 5.一个人只能抢购一个优惠券
UserDTO user = UserHolder.getUser();
Long userId = user.getId();
// .count不会返回null,也可以用int来接
Integer count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
if(count>0)
{
return Result.fail("用户已经购买过一次!");
}
// 6.扣减库存
/* 相当于
UPDATE seckill_voucher
SET stock = stock - 1
WHERE voucher_id = #{voucherId}
*/
boolean success = seckillVoucherService.update()
.setSql("stock = stock-1")
.eq("voucher_id", voucherId)
.gt("stock", 0)
.update() ;
// 秒杀 悲观锁和乐观锁(版本号法(不会出现ABA问题)和CAS法(可能出现ABA问题)),秒杀不会发生ABA问题,因为库存只会减,不会增
if(!success)
{
return Result.fail("库存不足");
}
// 7.创建订单 订单表
VoucherOrder voucherOrder = new VoucherOrder();
// 7.1 订单id
long voucherOrderId = redisIdWorker.nextId("voucherOrder");//传入的是前缀
voucherOrder.setId(voucherOrderId);
// 7.2 用户id 拦截器获取
/* if(user==null)
{
return Result.fail("用户没有登陆,无法抢购优惠劵");
}*/ // 也可以在拦截器中实现 如果使用拦截器进行了登陆验证,就不用判null,因为肯定有啦,此处这里实现
voucherOrder.setUserId(user.getId());
// 7.3 代金券id
voucherOrder.setVoucherId(voucherId);
save(voucherOrder);
// 8.返回订单id
return Result.ok(voucherOrderId);
}
解析:
1、synchronized (userId.toString().intern())
的作用是给每个用户加一把“本地互斥锁”,防止同一个用户同时发起多个抢购请求。.intern()
会让 Java 把这个字符串放进 字符串常量池,所以 相同的 userId(比如 123)会得到相同的锁对象,保证同一个用户加的是同一把锁。
2、@Transactional
为什么要加?
本质作用:保证“扣减库存”和“创建订单”两个操作要么都成功,要么都失败,保持业务的一致性。
3、为什么使用代理对象执行事务方法?
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
这是为了让 @Transactional
生效!
原因如下:
Spring 的 @Transactional
是通过 AOP代理 实现的。
如果你在 同一个类内部直接调用自己类中的 @Transactional 方法,它会绕过代理,事务不会生效。
正确做法:用 AopContext.currentProxy() 获取当前的代理对象,再去调用目标方法,事务才能被 Spring 拦截并生效。
4、锁要加在事务外面,保证事务提交后才解开锁!!!
5.2 setnx机制实现锁(少用)
虽然少用,但是帮助我们理解原理!!!
@Override
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("库存不足!");
}
Long userId = UserHolder.getUser().getId();
//创建锁对象(新增代码)
SimpleRedisLock lock = new SimpleRedisLock("order:" + userId, stringRedisTemplate);
//获取锁对象
boolean isLock = lock.tryLock(1200);
//加锁失败
if (!isLock) {
return Result.fail("不允许重复下单");
}
try {
//获取代理对象(事务)
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
} finally {
//释放锁
lock.unlock();
}
}
细节:
order: + userId 作为key,线程 id 作为值,保证只有一个用户能拿到锁。
存在的问题:锁误删问题
持有锁的线程在锁的内部出现了阻塞,导致他的锁自动释放,这时其他线程,线程2来尝试获得锁,就拿到了这把锁,然后线程2在持有锁执行过程中,线程1反应过来,继续执行,而线程1执行过程中,走到了删除锁逻辑,此时就会把本应该属于线程2的锁进行删除,这就是误删别人锁的情况。
解决方案:删除锁的时候,判断是否为自己的,不是自己的不删除。
一个细节,在存入Redis线程ID是一个JVM唯一前缀+线程·ID,保证不同JVM相同线程id相同的时候也能保持不同,同一个JVM,前缀是相同的!!!如何实现呢??? 用static final 实现,在类加载到虚拟机时就进行初始化,保证了一个JVM有一个前缀ID,你可能会问了万一另外一台服务器的生成的前缀和他相同咋办,哈哈,几乎不可能,不需要考虑哈。
private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";
public boolean tryLock(long timeoutSec) {
// 获取线程标示
String threadId = ID_PREFIX + Thread.currentThread().getId();
// 获取锁
Boolean success = stringRedisTemplate.opsForValue()
.setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS);
// 可能返回是null,这里要加一个拆箱判断,如果是true,返回true;如果是false或者不存在,返回false
return Boolean.TRUE.equals(success);
}
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);
}
}
现在出现一个极端情况:
线程1现在持有锁之后,在执行业务逻辑过程中,他正准备删除锁,而且已经走到了条件判断的过程中,比如他已经拿到了当前这把锁确实是属于他自己的,正准备删除锁,但是此时他的锁到期了,那么此时线程2进来,但是线程1他会接着往后执行,当他卡顿结束后,他直接就会执行删除锁那行代码,相当于条件判断并没有起到作用,这就是删锁时的原子性问题,之所以有这个问题,是因为线程1的拿锁,比锁,删锁,实际上并不是原子性的。
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语句,Lua脚本孕育而生!!
5.3 Lua脚本的使用(实现释放锁的原子性)
package com.hmdp.utils;
import cn.hutool.core.lang.UUID;
import org.springframework.core.io.ClassPathResource;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import java.util.Collections;
import java.util.concurrent.TimeUnit;
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) + "-";
// 释放锁的Lua脚本
private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;
// Lua脚本静态初始化
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);
// 可能返回是null,这里要加一个拆箱判断,如果是true,返回true;如果是false或者不存在,返回false
return Boolean.TRUE.equals(success);
}
@Override
public void unlock() {
// 调用lua脚本
stringRedisTemplate.execute(
UNLOCK_SCRIPT,
Collections.singletonList(KEY_PREFIX + name),
ID_PREFIX + Thread.currentThread().getId());
}
/*@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脚本:
-- 比较线程标示与锁中的标示是否一致
if(redis.call('get', KEYS[1]) == ARGV[1]) then
-- 释放锁 del key
return redis.call('del', KEYS[1])
end
return 0
扩展:
如果多个key和value
List<String> keys = Arrays.asList("lock:order:1", "lock:order:2");
Object[] args = new Object[] { "UUID-1", "UUID-2" };
stringRedisTemplate.execute(
LUA_SCRIPT,
keys,
args
)
如果你使用的是 Redisson 提供的分布式锁(如 RLock),就不需要你再手动用 Lua 脚本解锁了。Redisson 会自动帮你处理锁释放、线程标识比对、原子性、安全性等一整套机制!!!所以,有点白雪,不过理解加深了!!!
5.4 Redisson分布式锁
基本使用:
RLock lock = redissonClient.getLock("lock:order:123");
lock.lock(); // 加锁
try {
// 业务逻辑
} finally {
lock.unlock(); // 自动比对标识 + 释放
}
一人一单问题代码:
@Resource
private RedissonClient redissonClient;
@Override
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("库存不足!");
}
Long userId = UserHolder.getUser().getId();
//创建锁对象 这个代码不用了,因为我们现在要使用分布式锁
//SimpleRedisLock lock = new SimpleRedisLock("order:" + userId, stringRedisTemplate);
RLock lock = redissonClient.getLock("lock:order:" + userId);
//获取锁对象
boolean isLock = lock.tryLock();
//加锁失败
if (!isLock) {
return Result.fail("不允许重复下单");
}
try {
//获取代理对象(事务)
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
} finally {
//释放锁
lock.unlock();
}
}
5.5 Redisson锁+Lua脚本+队列异步下单
总体思路:
事先将订单信息存放在Redis中,商品ID(key)+库存量(value),当用户下单之后,判断库存是否充足只需要根据key找对应的value是否大于0即可,如果不充足,则直接结束,如果充足,继续在redis中判断用户是否可以下单,如果set集合中没有这条数据,说明他可以下单,并将商品ID(key)+用户ID(value) 存放在Set集合中,整个过程需要保证是原子性的,我们可以使用lua来操作。
队列选择: 阻塞队列,Redis的Stream流队列,中间件(Kafka、RocketMQ),阻塞队列不推荐!!!
以下是对比:
OK,基于Stream流实现伪代码,大家主要是品尝这个过程!!!
1、Lua脚本
-- 1.判断库存
local stock = redis.call('get', KEYS[1])
if (not stock) or (tonumber(stock) <= 0) then
return 1 -- 库存不足
end
-- 2.判断用户是否下过单
if redis.call('sismember', KEYS[2], ARGV[1]) == 1 then
return 2 -- 重复下单
end
-- 3.扣减库存 & 记录用户
redis.call('decr', KEYS[1])
redis.call('sadd', KEYS[2], ARGV[1])
-- 4.写入Stream队列
redis.call('xadd', KEYS[3], '*',
'userId', ARGV[1],
'voucherId', ARGV[2],
'orderId', ARGV[3])
return 0 -- 成功
简要说明,
xadd: 向Redis Stream添加数据结构的命令,
KEYS[3]:表示Stream队列的名称
‘*’:表示让Redis自动生成消息ID(时间戳-序列号格式)
后面是三个键值对,构成消息内容:
‘userId’, ARGV[1]:用户ID,值来自参数数组第1个元素
‘voucherId’, ARGV[2]:优惠券ID,值来自参数数组第2个元素
‘orderId’, ARGV[3]:订单ID,值来自参数数组第3个元素
2、秒杀接口逻辑
@Override
public Result seckillVoucher(Long voucherId) {
Long userId = UserHolder.getUser().getId();
long orderId = redisIdWorker.nextId("order");
// 执行 Lua 脚本(库存key、订单记录key、消息流)
Long result = stringRedisTemplate.execute(
unlockScript,
Arrays.asList(
"seckill:stock:" + voucherId,
"seckill:order:" + voucherId,
"stream.orders"
),
userId.toString(), voucherId.toString(), String.valueOf(orderId)
);
if (result == 1L) return Result.fail("库存不足!");
if (result == 2L) return Result.fail("不能重复下单!");
return Result.ok(orderId); // 异步处理
}
3、异步处理订单的线程
@Scheduled(fixedDelay = 1000)
public void handleVoucherOrder() {
List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream()
.read(Consumer.from("group1", "consumer1"),
StreamReadOptions.empty().count(1).block(Duration.ofSeconds(2)),
StreamOffset.create("stream.orders", ReadOffset.lastConsumed()));
if (list == null || list.isEmpty()) return;
for (MapRecord<String, Object, Object> record : list) {
Map<Object, Object> values = record.getValue();
Long userId = Long.valueOf(values.get("userId").toString());
Long voucherId = Long.valueOf(values.get("voucherId").toString());
Long orderId = Long.valueOf(values.get("orderId").toString());
try {
createOrder(voucherId, userId, orderId);
stringRedisTemplate.opsForStream().acknowledge("stream.orders", "group1", record.getId());
} catch (Exception e) {
log.error("处理订单异常", e);
// 可选:进入 dead letter 或重新投递
}
}
}
补充:
Pending 列表 是 Redis Stream 消费组中,已经被某个消费者读取但尚未确认(ack) 的消息集合。
也叫做:PEL(Pending Entries List)或 待确认消息队列!
发回ack,消息会从Pending列表中移除。
4、异步线程后台,减库存它没写
@Transactional
public void createOrder(Long voucherId, Long userId, Long orderId) {
// 再次保证幂等(可加唯一索引 user_id+voucher_id)
int count = lambdaQuery().eq(VoucherOrder::getUserId, userId)
.eq(VoucherOrder::getVoucherId, voucherId).count();
if (count > 0) return;
// 写入数据库
VoucherOrder order = new VoucherOrder();
order.setId(orderId);
order.setUserId(userId);
order.setVoucherId(voucherId);
save(order);
}