本文整理自黑马点评课程「Redis 进阶」的课堂笔记,是上一篇《优惠券秒杀》笔记的姊妹篇。上篇我们讲了 ID 安全 / 超卖 / 一人一单 ,本篇我们继续往前走:把单 JVM 的
synchronized升级为 分布式锁 ,再用 Redis 消息队列 把同步的下单流程异步化,把单 Tomcat 的吞吐能力榨到极限。适合正在做黑马点评秒杀模块、需要理解 Redisson 和 Stream 消息队列的同学。
本文目录
- 什么是分布式锁?
- [基于 Redis 的分布式锁:三次演进](#基于 Redis 的分布式锁:三次演进)
- [Redis 分布式锁的四个问题 & Redisson](#Redis 分布式锁的四个问题 & Redisson)
- [Redis 优化秒杀:异步下单 + 阻塞队列](#Redis 优化秒杀:异步下单 + 阻塞队列)
- [Redis 消息队列:List / PubSub / Stream](#Redis 消息队列:List / PubSub / Stream)
- [Stream 消息队列:消费者组](#Stream 消息队列:消费者组)
- 三种消息队列对比
- 总结:秒杀模块的完整演进
- 踩坑清单
- 写在最后
一、什么是分布式锁?
分布式锁 :满足分布式系统或集群模式下多进程可见、并且互斥的锁。
判断一把「分布式锁」是否合格,业界有 4~5 个硬指标:
| 特性 | 含义 |
|---|---|
| 多进程可见 | 不只是 JVM 内部可见,跨进程、跨机器也要能拿同一把锁 |
| 互斥 | 任意时刻只能有一个客户端持有锁 |
| 高可用 | 锁服务挂了要能快速恢复,不能因为锁服务挂掉导致整个业务瘫痪 |
| 高性能 | 加锁/释放锁的延迟要低,不能成为瓶颈 |
| 安全性 | 释放锁的语义要正确,不能误删别人的锁 |
而要同时满足这 5 个指标,单纯用 synchronized / Lock 是绝对不行的------它们是 JVM 进程内 的锁。集群下 JVM1 拿到的锁,JVM2 是看不见的。
所以我们需要一把 「跨进程可见 + 互斥 + 高可用 + 高性能 + 安全性」 的锁。常见的实现方式有:
- 基于 MySQL (
select ... for update、unique key唯一索引) - 基于 Redis (
SETNX演进到 Redisson) - 基于 ZooKeeper(临时顺序节点)
黑马点评选择 Redis ,因为它本身就在秒杀链路里,不必额外引入新组件。下面我们就从最原始的 SETNX 一步步演进。
二、基于 Redis 的分布式锁:三次演进
2.1 第一版:最朴素的 SETNX
要实现分布式锁,最核心就两件事------获取锁 、释放锁。
获取锁必须满足:
- 互斥 :同一时刻只能有一个线程拿到锁 →
SET key value NX - 非阻塞 :尝试一次,成功返回 true,失败返回 false → 不加
PX/EX重试参数
释放锁有两种方式:
- 手动释放 →
DEL key - 超时释放 →
SET ... EX 10设置过期时间
最简版伪代码:
# 添加锁,NX 是互斥、EX 是设置超时时间
SET lock thread1 NX EX 10
Java 端用 StringRedisTemplate 也很简单:
private StringRedisTemplate stringRedisTemplate;
public boolean tryLock(String key, long timeoutSec) {
Boolean ok = stringRedisTemplate.opsForValue()
.setIfAbsent(key, Thread.currentThread().getName(), timeoutSec, TimeUnit.SECONDS);
return Boolean.TRUE.equals(ok);
}
public void unlock(String key) {
stringRedisTemplate.delete(key);
}
业务流程:
开始 → 尝试获取锁 → 拿不到 → 获取锁失败(返回)
↘ 拿到 → 获取锁成功 → 执行业务 → 释放锁
↘ 业务超时/服务宕机 → 自动释放锁

但是这版有两个致命问题:
- 锁误删 :线程 A 拿到锁后业务执行了 15 秒,锁已经被超时自动释放了 。线程 B 此时拿到锁开始执行业务。线程 A 终于执行完,去
DEL key,把 B 的锁删了 → B 还在执行业务,锁却没了 → 别的线程又开始抢,逻辑全乱。 - 释放的非原子性:判断 + 删除是两个操作,中间可能被插入别的逻辑(具体下面会说)。
2.2 第二版:加 UUID 线程标识
解决「误删」问题的核心思路是:释放锁之前,先确认这个锁是不是我加的。
需求拆解:
- 获取锁时存入线程标识(可以用 UUID 表示);
- 释放锁时先获取锁中的线程标识 ,判断是否与当前线程一致:
- 一致 → 释放锁(删除)
- 不一致 → 不释放
代码改造:
public boolean tryLock(String key, long timeoutSec) {
// value 用 UUID 标识线程
String threadId = UUID.randomUUID().toString();
Boolean ok = stringRedisTemplate.opsForValue()
.setIfAbsent(key, threadId, timeoutSec, TimeUnit.SECONDS);
return Boolean.TRUE.equals(ok);
}
public void unlock(String key) {
// 1. 取出锁的 value(线程标识)
String value = stringRedisTemplate.opsForValue().get(key);
// 2. 判断是不是当前线程加的
if (value != null && value.equals(currentThreadId)) {
stringRedisTemplate.delete(key);
}
}
但是! 这版依然有 bug。get → 判断 → delete 这三步是分开的,不具备原子性:
时间线:
T1 线程A:get(key) → value = A
T2 线程A:判断 === A → true,准备删
T3 锁自动过期
T4 线程B:setIfAbsent(key, B) → 拿到锁
T5 线程A:delete(key) → 把B的锁删了!
所以我们必须把「比较 + 删除 」打包成原子操作------这就要用到 Redis 的 Lua 脚本。
2.3 第三版:用 Lua 脚本保证原子性
Redis 的 Lua 脚本 :Redis 提供了 Lua 脚本功能,可以在一个脚本里编写多条 Redis 命令,确保多条命令执行时的原子性。
2.3.1 Lua 调用 Redis 的基本语法
-- 调用函数格式
redis.call('命令名称', 'key', '其它参数', ...)
-- 例如:set name jack
redis.call('set', 'name', 'jack')
-- 多条命令
redis.call('set', 'name', 'jack')
local name = redis.call('get', 'name')
return name
2.3.2 在 Redis 里执行 Lua 脚本
# 最简单的形式
EVAL "return redis.call('set', 'name', 'jack')" 0
# ↑ 脚本内容 ↑ key 类型的参数个数
# 进阶:把 key / value 作为参数传进去
EVAL "return redis.call('set', KEYS[1], ARGV[1])" 1 name Rose
# ↑ ↑ ↑
# numkeys key value
注意 :
KEYS数组接收 key 类型的参数;其它参数进ARGV数组。这样脚本里就不写死具体的 key/value 了。
2.3.3 释放锁的 Lua 脚本
我们的需求是:
- 获取锁中的线程标识
- 判断是否与指定标识(当前线程标识)一致
- 一致则释放锁(删除)
- 不一致则什么都不做
写成 Lua 脚本:
-- 这里的 KEYS[1] 就是锁的 key,ARGV[1] 就是当前线程标识
-- 获取锁中的标识,判断是否与当前线程标识一致
if (redis.call('GET', KEYS[1]) == ARGV[1]) then
-- 一致,则删除锁
return redis.call('DEL', KEYS[1])
end
-- 不一致,则直接返回
return 0
2.3.4 Spring Data Redis 调用 Lua 脚本
RedisTemplate 提供了一个 execute(RedisScript<T> script, List<K> keys, Object... args) 方法,对应 Redis 客户端的 EVAL 命令:
// 1. 初始化脚本对象(用 classpath: 加载 *.lua 文件)
DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>();
redisScript.setLocation(new ClassPathResource("unlock.lua"));
redisScript.setResultType(Long.class);
// 2. 释放锁
public void unlock(String key, String threadId) {
stringRedisTemplate.execute(
redisScript,
List.of(key), // KEYS
threadId // ARGV
);
}
把 unlock.lua 放到 resources/unlock.lua:
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
end
return 0
2.3.5 三版总结
基于 Redis 的分布式锁实现思路:
- 利用
SET NX EX获取锁,并设置过期时间,保存线程标识 - 释放锁时先判断线程标识是否与自己一致,一致则删除锁
特性:
- 利用
SET NX满足互斥性 - 利用
SET EX保证故障时锁依然能释放,避免死锁,提高安全性 - 利用 Redis 集群保证高可用和高并发特性
三、Redis 分布式锁的四个问题 & Redisson
上面的 Lua 脚本方案已经能覆盖大多数业务场景,但在生产里仍然有 4 个绕不开的问题。
3.1 四个问题
| # | 问题 | 现象 | 后果 |
|---|---|---|---|
| 01 | 不可重入 | 同一个线程无法多次获取同一把锁 | 业务方法互相调用直接死锁 |
| 02 | 不可重试 | 获取锁只尝试一次就返回 false,没有重试机制 | 大量请求直接失败,浪费流量 |
| 03 | 超时释放 | 锁超时释放虽然可以避免死锁,但如果业务执行耗时较长,也会导致锁被提前释放 | 别人插队执行 → 数据错乱 |
| 04 | 主从一致性 | Redis 主从集群,主从同步存在延迟,当主宕机时,从同步主中的锁数据失败 | 出现「锁失效」问题 |
这 4 个问题中任何一个,在秒杀场景下都可能造成「重复下单 / 漏单 / 数据错乱」。
解决思路 :直接用 Redisson。
3.2 Redisson 是什么?
Redisson 是一个在 Redis 的基础上实现的 Java 驻内存数据网格 (In-Memory Data Grid)。它不仅提供了一系列分布式的 Java 常用对象,还提供了许多分布式服务,重点是把上面 4 个问题全部解决了。
Redisson 提供了非常丰富的分布式对象:
| 类别 | 对象 | 说明 |
|---|---|---|
| 锁 | RLock(可重入锁) |
替代 synchronized / Lock |
| 锁 | RFairLock(公平锁) |
按申请顺序排队获取 |
| 锁 | RReadWriteLock(读写锁) |
读多写少场景 |
| 锁 | MultiLock(联锁) |
多个锁同时加,原子性 |
| 锁 | RedLock(红锁) |
多 Redis 节点主从一致 |
| 同步器 | RSemaphore(信号量) |
限流场景 |
| 同步器 | RPermitExpirableSemaphore |
信号量 + 过期 |
| 同步器 | RCountDownLatch(闭锁) |
等待多个任务完成 |
3.3 Redisson 入门
3.3.1 引入依赖
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.13.6</version>
</dependency>
3.3.2 配置 Redisson 客户端
@Configuration
public class RedissonConfig {
@Bean
public RedissonClient redissonClient() {
Config config = new Config();
// 单点 Redis
config.useSingleServer()
.setAddress("redis://192.168.150.101:6379")
.setPassword("123321");
// 集群写法:config.useClusterServers().addNodeAddress("redis://...");
return Redisson.create(config);
}
}
3.3.3 使用 Redisson 的分布式锁
@Resource
private RedissonClient redissonClient;
@Test
void testRedisson() throws InterruptedException {
// 获取锁(可重入),指定锁的名称
RLock lock = redissonClient.getLock("anyLock");
// 尝试获取锁,参数:最大等待时间(期间会重试)、锁自动释放时间、时间单位
boolean isLock = lock.tryLock(1, 10, TimeUnit.SECONDS);
if (isLock) {
try {
System.out.println("执行业务");
} finally {
lock.unlock(); // 千万别忘
}
}
}
这一行 tryLock(1, 10, TimeUnit.SECONDS),Redisson 已经把上面 4 个问题全解决了:
- 可重入:内部维护一个「重入次数」计数器,同一个线程可以多次 lock;
- 可重试:参数 1 表示「最多等 1 秒」,期间会用 Lua 脚本不断重试;
- 超时自动续期 (看门狗):参数 10 是「锁自动释放时间」,Redisson 会启动一个
WatchDog后台线程,每隔releaseTime / 3(默认 10/3 ≈ 3 秒)自动续期; - 主从一致性 :可以通过
MultiLock/RedLock同时向多个独立 Redis 节点加锁,超过半数成功才算拿到锁。
3.4 把 Redisson 应用到「一人一单」
把上篇笔记里的 synchronized 升级成 Redisson 的版本:
@Override
public Result createVoucherOrder(Long voucherId) {
Long userId = UserHolder.getUser().getId();
// 锁粒度:按用户加锁,不同用户不互斥
RLock lock = redissonClient.getLock("lock:order:" + userId);
boolean isLock = lock.tryLock(); // 不传参 = 不重试,看门狗 30s 自动续期
if (!isLock) {
return Result.fail("不允许重复下单");
}
try {
// === 业务逻辑(与上篇 synchronized 版本一致)===
int count = query().eq("user_id", userId)
.eq("voucher_id", voucherId).count();
if (count > 0) return Result.fail("你已经抢过该优惠券");
boolean success = seckillVoucherService.update()
.setSql("stock = stock - 1")
.eq("voucher_id", voucherId)
.gt("stock", 0).update();
if (!success) return Result.fail("库存不足");
VoucherOrder order = new VoucherOrder();
order.setId(redisIdWorker.nextId("order"));
order.setUserId(userId);
order.setVoucherId(voucherId);
save(order);
return Result.ok(order.getId());
} finally {
lock.unlock();
}
}
四、Redis 优化秒杀:异步下单 + 阻塞队列
到目前为止,我们已经能「正确」地完成一次秒杀:校验 → 扣库存 → 创建订单。但在「几万 QPS」的瞬间,Tomcat 还是扛不住的------因为它在同步地写 MySQL。
优化的核心思路是:让下单的主流程尽可能短,把耗时的「写 MySQL」异步化。
4.1 整体架构
手机 → NGINX → Tomcat 集群
│
▼
Redis(判断秒杀库存 + 校验一人一单 + 扣减库存)
│
▼
保存 优惠券id、用户id、订单id 到阻塞队列
│
▼
独立线程异步读取队列信息,完成下单
(写 MySQL)

关键改造点:
- 秒杀请求先打 Redis(库存判断 + 一人一单校验),全在内存里跑;
- 通过校验后,把
{voucherId, userId, orderId}推入 Redis 的阻塞队列; - 立刻返回
orderId给前端; - 后台启动独立线程,循环
BLPOP阻塞队列,拿到消息后异步写 MySQL。
这样做的好处:
- 用户视角:响应极快,秒杀链路只跑了「Redis」部分;
- 服务视角:写 MySQL 的耗时被异步化,Tomcat 的并发能力被释放;
- 数据库视角:所有请求被队列「削峰填谷」,MySQL 不会被打爆。
4.2 秒杀前置逻辑(Lua 脚本)
Redis 里的「判断库存 + 校验一人一单 + 扣减库存」必须一次性完成------分两步的话,还是会被人钻空子。所以我们把它写成一个 Lua 脚本。
数据准备:
# 1. 库存 hash / string
SET stock:vid:7 100
# 2. 已经下单的用户 set
SADD order:vid:7 1 2 3 4
Lua 脚本(seckill.lua):
-- 1. 参数:优惠券id、用户id
-- 2. 数据 key:stock:vid:{voucherId}、order:vid:{voucherId}
-- 3. 业务:判断库存是否充足、判断用户是否下过单、扣减库存、记录用户
local voucherId = ARGV[1]
local userId = ARGV[2]
local stockKey = 'stock:vid:' .. voucherId -- KEYS[1]
local orderKey = 'order:vid:' .. voucherId -- KEYS[2]
-- 判断库存是否充足(redis.call 返回字符串,转成 number)
if (tonumber(redis.call('GET', stockKey)) <= 0) then
return 1 -- 库存不足
end
-- 判断用户是否下过单
if (redis.call('SISMEMBER', orderKey, userId) == 1) then
return 2 -- 重复下单
end
-- 扣减库存 + 记录用户
redis.call('INCRBY', stockKey, -1)
redis.call('SADD', orderKey, userId)
return 0
Java 端调用:
@Override
public Result seckillVoucher(Long voucherId) {
Long userId = UserHolder.getUser().getId();
// 1. 执行 Lua 脚本
Long result = stringRedisTemplate.execute(
SECKILL_SCRIPT,
List.of("stock:vid:" + voucherId, "order:vid:" + voucherId),
voucherId.toString(), userId.toString()
);
int r = result.intValue();
if (r != 0) {
return r == 1 ? Result.fail("库存不足") : Result.fail("不能重复下单");
}
// 2. 通过校验 → 生成订单 id → 推入阻塞队列
long orderId = redisIdWorker.nextId("order");
VoucherOrder order = new VoucherOrder();
order.setId(orderId);
order.setUserId(userId);
order.setVoucherId(voucherId);
// 阻塞队列:保存订单信息,交给后台线程异步消费
rabbitTemplate.convertAndSend("seckill.order", order);
// 或者用 Redis 的 List 做简单版:
// stringRedisTemplate.opsForList().leftPush("seckill:order:queue", order);
// 3. 立刻返回订单 id(订单实际还没创建)
return Result.ok(orderId);
}

4.3 阻塞队列的局限
这样改造后,秒杀的性能瓶颈从 Tomcat 转移到了 Redis,看起来很美好,但阻塞队列本身有两个问题:
- 内存限制问题:JVM 堆内存是有上限的,订单量一大,队列直接把堆撑爆 → OOM;
- 数据安全问题:JVM 一重启,队列里的订单全没了 → 出现「用户已经抢到券,但订单没生成」的资损。
要解决这两个问题,必须把队列从「JVM 内存」搬到「独立的消息中间件」里。黑马点评的进阶方案就是 Redis 消息队列。
📍 图片位置 总结:秒杀优化思路 + 阻塞队列的两个问题(内存限制 / 数据安全)
五、Redis 消息队列:List / PubSub / Stream
消息队列(Message Queue):字面意思就是存放消息的队列。最简单的 MQ 模型包括 3 个角色:
- 消息队列(Message Broker):存储和管理消息,也叫消息代理;
- 生产者:发送消息到消息队列;
- 消费者:从消息队列获取消息并处理消息。
Redis 提供了 3 种不同的方式实现消息队列:
| 方式 | 模型 | 特点 |
|---|---|---|
| List 结构 | 基于 List 模拟 | 阻塞队列效果,类似 BlockingQueue |
| PubSub | 发布 / 订阅 | 基本点对点消息模型 |
| Stream | 较完善的消息队列模型 | 5.0+ 引入,支持消费者组、消息确认、消息回溯 |
📍 图片位置 消息队列 3 角色 + Redis 3 种实现方式图
5.1 基于 List 模拟消息队列
Redis 的 List 是一个双向链表,天然支持 FIFO 队列。入口和出口不在一边:
生产者 ── LPUSH ──▶ [ msg1, msg2, msg3 ] ── RPOP ──▶ 消费者
关键命令:
| 命令 | 说明 |
|---|---|
LPUSH key element... |
从左侧入队 |
RPUSH key element... |
从右侧入队 |
RPOP key |
从右侧出队(非阻塞,无元素返回 nil) |
LPOP key |
从左侧出队(非阻塞) |
BRPOP key timeout |
阻塞版 RPOP,没元素会阻塞 |
BLPOP key timeout |
阻塞版 LPOP |
一定要用阻塞版本(B*POP)!否则消费者要不停循环 poll,浪费 CPU。
优缺点:
- ✅ 优点:基于 Redis 存储,不受限于 JVM 内存上限;基于持久化,数据安全有保证;可以满足消息有序性;
- ❌ 缺点:无法避免消息丢失 (
BRPOP拿到消息后消费者崩溃,消息就没了);只支持单消费者(不支持发布订阅)。
5.2 基于 PubSub 的消息队列
PubSub(发布订阅) 是 Redis 2.0 引入的消息传递模型。消费者可以订阅一个或多个 channel,生产者向对应 channel 发送消息后,所有订阅者都能收到。
关键命令:
SUBSCRIBE channel [channel] # 订阅一个或多个频道
PUBLISH channel msg # 向一个频道发送消息
PSUBSCRIBE pattern [pattern] # 订阅与 pattern 格式匹配的所有频道
优缺点:
- ✅ 优点:采用发布订阅模型,支持多生产、多消费;
- ❌ 缺点:不支持数据持久化 ;无法避免消息丢失;消息堆积有上限(受消费者缓冲区大小限制),超出时数据丢失。
因为不能持久化,PubSub 在生产里基本不用做主链路 MQ,一般只做「实时通知」类的弱一致性场景。
5.3 基于 Stream 的消息队列(重点)
Stream 是 Redis 5.0 引入的全新数据类型,可以做到一个功能非常完善的消息队列------它补齐了 List 和 PubSub 所有的短板。
5.3.1 发送消息:XADD
# 语法
XADD key [NOMKSTREAM] [MAXLEN|MINID [=|~] threshold [LIMIT count]] *|ID field value [field value ...]
# 简单示例
127.0.0.1:6379> XADD users * name jack age 21
"1644805700523-0" # 消息唯一 id:时间戳-递增数字
参数说明:
key:队列名称(如果队列不存在,Redis 默认自动创建);MAXLEN/MINID:限制消息队列的最大消息数;* | ID:消息的唯一 id,*表示由 Redis 自动生成(推荐)。
5.3.2 阻塞读取:XREAD
# 阻塞 1000ms,读取 users 队列的最新 1 条消息
127.0.0.1:6379> XREAD COUNT 1 BLOCK 1000 STREAMS users $
(nil)
(1.07s) # 阻塞 1 秒后返回
在业务开发中,通常循环调用 XREAD 阻塞方式持续监听:
while (true) {
// 尝试读取队列中的消息,最多阻塞 2 秒
Object msg = redis.execute("XREAD COUNT 1 BLOCK 2000 STREAMS users $");
if (msg == null) continue;
// 处理消息
handleMessage(msg);
}
⚠️ 注意 :当我们指定起始 ID 为
$时,代表只读取最新消息 。如果处理过程中又有超过 1 条以上的新消息到达,下次也只能获取最新一条,会出现漏读问题。解决漏读:使用消费者组。
六、Stream 消息队列:消费者组(重点)
消费者组(Consumer Group):将多个消费者划分到一个组中,监听同一个队列。
它有 3 个关键能力:
| # | 能力 | 说明 |
|---|---|---|
| 01 | 消息分流 | 队列中的消息会分给组内不同消费者处理,而不是重复消费,加快处理速度 |
| 02 | 消息标示 | 消费者组会维护一个标示,记录最后一个被处理的消息;消费者宕机重启后,会从标示之后读取 |
| 03 | 消息确认 | 消费者获取消息后,消息处于 pending 状态并存入 pending-list 。处理完后通过 XACK 确认,才会从 pending-list 移除 |
有了消息确认机制, 每个消息至少被消费一次------这就是生产级 MQ 的核心要求。
6.1 创建消费者组
# 语法
XGROUP CREATE key groupName ID [MKSTREAM]
# key 队列名称
# groupName 消费者组名称
# ID 起始 ID 标示:$ 代表最后一个消息,0 代表第一个消息
# MKSTREAM 队列不存在时自动创建
其它常见命令:
XGROUP DESTROY key groupName # 删除指定消费者组
XGROUP CREATECONSUMER key groupname name # 给指定消费者组添加消费者
XGROUP DELCONSUMER key groupname name # 删除消费者组中的指定消费者
6.2 从消费者组读取消息:XREADGROUP
XREADGROUP GROUP group consumer [COUNT count] [BLOCK milliseconds] [NOACK] STREAMS key [key ...] ID [ID ...]
参数:
group:消费者组名称consumer:消费者名称(不存在会自动创建)count:本次查询最大数量BLOCK milliseconds:没有消息时最长等待时间NOACK:无需手动 ACK(不推荐!)STREAMS key:指定队列ID:起始 ID>:从下一个未消费的消息开始0、其它:从 pending-list 中获取已消费但未确认的消息
示例:
# 消费者 c1 读取 users 队列的 1 条消息(消费者组 g1)
XREADGROUP GROUP g1 c1 COUNT 1 STREAMS users >
6.3 消息确认:XACK
# 确认已处理
XACK users g1 1644805700523-0
未确认的消息会一直留在 pending-list ,下次启动时可以用 XREADGROUP ... 0 把它们捞起来重试。
6.4 消费者组 vs 普通 XREAD
| 维度 | XREAD | XREADGROUP |
|---|---|---|
| 消息可回溯 | ❌($ 只能拿最新) | ✅(可指定 ID 重新读) |
| 多消费者争抢 | ❌(每人读一遍) | ✅(一条消息只被一个消费者处理) |
| 阻塞读取 | ✅ | ✅ |
| 漏读风险 | 有 | 无 |
| 消息确认 | 无 | 有(XACK) |
总结一句话: 生产环境用 Stream 消息队列 + 消费者组,是 Redis 消息队列唯一靠谱的姿势。
七、三种消息队列对比
最后把 3 种实现方式摆在一起对比:
| 维度 | List | PubSub | Stream |
|---|---|---|---|
| 消息持久化 | 支持 | 不支持 | 支持 |
| 阻塞读取 | 支持 | 支持 | 支持 |
| 消息堆积处理 | 受限于内存空间,可以多消费者 | 受限于消费者缓冲区 | 受限于队列长度,可以用消费者组提速 |
| 消息确认机制 | 不支持 | 不支持 | 支持(XACK) |
| 消息回溯 | 不支持 | 不支持 | 支持 |
结论 :在 3 种方式中, Stream 是功能最完善、唯一适合做主链路 MQ 的方案。List 适合做单机版「异步队列」快速验证;PubSub 适合「实时通知」类弱一致场景。
八、总结:秒杀模块的完整演进
把两篇笔记串起来,黑马点评的秒杀模块是这样一个完整链路:
[1] 生成全局唯一 ID
Redis Incr + 时间戳 + 序列号 → 解决 ID 规律性问题
↓
[2] 防止超卖
乐观锁:WHERE stock > 0 → 解决数量安全问题
↓
[3] 防止重单(单 JVM)
synchronized(userId) → 解决身份安全问题
↓
[4] 防止重单(多 JVM)
Redisson 分布式锁 → 跨进程互斥
↓
[5] 异步下单
Lua 脚本在 Redis 一次完成 库存判断 + 一人一单校验
→ 返回订单 id → 消息队列 → 异步线程写 MySQL
↓
[6] 队列选择
简单:JVM BlockingQueue
生产:Redis Stream + 消费者组
这套模板再去做「限时抢购、票务、预约」类业务会非常顺手。
九、踩坑清单(强烈建议收藏)
| 现象 | 原因 | 解决 |
|---|---|---|
| 锁被别的线程误删 | get → 判断 → delete 不是原子的 |
用 Lua 脚本「比较 + 删除」 |
| 锁被超时自动释放,业务还没跑完 | Redis EX 过期时间到了 |
Redisson 看门狗自动续期 |
| 同一线程内递归调用死锁 | 锁不可重入 | 用 Redisson 的 RLock(可重入锁) |
| 拿不到锁直接返回失败 | 不可重试 | tryLock(1, 10, SECONDS) 带重试 |
| Redis 主从切换锁丢失 | 主从同步延迟 | RedLock 多 Redis 节点过半成功 |
| 秒杀把数据库打爆 | 同步写 MySQL | Redis Lua + 消息队列异步下单 |
| 队列订单被吞 | JVM 堆内存重启 | Redis Stream 持久化 |
| Stream 漏读 | XREAD $ 只拿最新 |
用消费者组 XREADGROUP |
十、写在最后
分布式锁和消息队列是「高并发」面试的必考题 。黑马点评这套笔记覆盖了从 SETNX → Lua → Redisson 的完整演进,以及从 JVM BlockingQueue → Redis List → PubSub → Stream 的 3 种消息队列。
把这套模板记住,任何高并发场景都能套 :电商秒杀、票务抢购、活动预约、外卖抢单......本质上都是「分布式锁 + 消息队列」的组合。