黑马点评——分布式锁与 Redis 消息队列

本文整理自黑马点评课程「Redis 进阶」的课堂笔记,是上一篇《优惠券秒杀》笔记的姊妹篇。上篇我们讲了 ID 安全 / 超卖 / 一人一单 ,本篇我们继续往前走:把单 JVM 的 synchronized 升级为 分布式锁 ,再用 Redis 消息队列 把同步的下单流程异步化,把单 Tomcat 的吞吐能力榨到极限。

适合正在做黑马点评秒杀模块、需要理解 Redisson 和 Stream 消息队列的同学。

本文目录

  1. 什么是分布式锁?
  2. [基于 Redis 的分布式锁:三次演进](#基于 Redis 的分布式锁:三次演进)
  3. [Redis 分布式锁的四个问题 & Redisson](#Redis 分布式锁的四个问题 & Redisson)
  4. [Redis 优化秒杀:异步下单 + 阻塞队列](#Redis 优化秒杀:异步下单 + 阻塞队列)
  5. [Redis 消息队列:List / PubSub / Stream](#Redis 消息队列:List / PubSub / Stream)
  6. [Stream 消息队列:消费者组](#Stream 消息队列:消费者组)
  7. 三种消息队列对比
  8. 总结:秒杀模块的完整演进
  9. 踩坑清单
  10. 写在最后

一、什么是分布式锁?

分布式锁 :满足分布式系统或集群模式下多进程可见、并且互斥的锁。

判断一把「分布式锁」是否合格,业界有 4~5 个硬指标:

特性 含义
多进程可见 不只是 JVM 内部可见,跨进程、跨机器也要能拿同一把锁
互斥 任意时刻只能有一个客户端持有锁
高可用 锁服务挂了要能快速恢复,不能因为锁服务挂掉导致整个业务瘫痪
高性能 加锁/释放锁的延迟要低,不能成为瓶颈
安全性 释放锁的语义要正确,不能误删别人的锁

而要同时满足这 5 个指标,单纯用 synchronized / Lock 是绝对不行的------它们是 JVM 进程内 的锁。集群下 JVM1 拿到的锁,JVM2 是看不见的。

所以我们需要一把 「跨进程可见 + 互斥 + 高可用 + 高性能 + 安全性」 的锁。常见的实现方式有:

  • 基于 MySQLselect ... for updateunique key 唯一索引)
  • 基于 RedisSETNX 演进到 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);
}

业务流程

复制代码
开始 → 尝试获取锁 → 拿不到 → 获取锁失败(返回)
                  ↘ 拿到 → 获取锁成功 → 执行业务 → 释放锁
                                    ↘ 业务超时/服务宕机 → 自动释放锁

但是这版有两个致命问题:

  1. 锁误删 :线程 A 拿到锁后业务执行了 15 秒,锁已经被超时自动释放了 。线程 B 此时拿到锁开始执行业务。线程 A 终于执行完,去 DEL key,把 B 的锁删了 → B 还在执行业务,锁却没了 → 别的线程又开始抢,逻辑全乱。
  2. 释放的非原子性:判断 + 删除是两个操作,中间可能被插入别的逻辑(具体下面会说)。

2.2 第二版:加 UUID 线程标识

解决「误删」问题的核心思路是:释放锁之前,先确认这个锁是不是我加的

需求拆解:

  1. 获取锁时存入线程标识(可以用 UUID 表示);
  2. 释放锁时先获取锁中的线程标识 ,判断是否与当前线程一致:
    • 一致 → 释放锁(删除)
    • 不一致 → 不释放

代码改造:

复制代码
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 脚本

我们的需求是:

  1. 获取锁中的线程标识
  2. 判断是否与指定标识(当前线程标识)一致
  3. 一致则释放锁(删除)
  4. 不一致则什么都不做

写成 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(闭锁) 等待多个任务完成

官网:https://redisson.org

GitHub:GitHub - redisson/redisson: Redisson: Valkey & Redis Java Client and Real-Time Data Platform. Sync/Async/RxJava/Reactive API. Over 50 Valkey and Redis based Java objects and services: Set, Multimap, SortedSet, Map, List, Queue, Deque, Semaphore, Lock, AtomicLong, Map Reduce, Bloom filter, Spring, Tomcat, Scheduler, JCache API, Hibernate, RPC, local cache.. · GitHub

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)

关键改造点

  1. 秒杀请求先打 Redis(库存判断 + 一人一单校验),全在内存里跑;
  2. 通过校验后,把 {voucherId, userId, orderId} 推入 Redis 的阻塞队列
  3. 立刻返回 orderId 给前端;
  4. 后台启动独立线程,循环 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,看起来很美好,但阻塞队列本身有两个问题

  1. 内存限制问题:JVM 堆内存是有上限的,订单量一大,队列直接把堆撑爆 → OOM;
  2. 数据安全问题: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

十、写在最后

分布式锁和消息队列是「高并发」面试的必考题 。黑马点评这套笔记覆盖了从 SETNXLuaRedisson 的完整演进,以及从 JVM BlockingQueueRedis ListPubSubStream 的 3 种消息队列。

把这套模板记住,任何高并发场景都能套 :电商秒杀、票务抢购、活动预约、外卖抢单......本质上都是「分布式锁 + 消息队列」的组合。

相关推荐
0566461 小时前
RAG 向量检索:从“查字“到“查意“
数据库·人工智能·学习·oracle
bug嘛我经常写1 小时前
dbf文件UTF-8转GBK编码,以及两编码文件互转
数据库·python
知行合一。。。1 小时前
LangGraph--03--本地服务与 Studio 调试
数据库
你不是我我2 小时前
【AI 测评】PostgreSQL主从流复制实战:数据同步、状态验证与故障切换
数据库·postgresql
月落归舟2 小时前
Redis 三种消息队列实现方案
数据库·redis·list
智购科技智能售货柜2 小时前
自动售货机商品识别YOLO模型训练实战:从6万张图片到98%识别率的完整复盘~YH
运维·服务器·数据库·人工智能·redis·物联网·yolo
ACP广源盛139246256732 小时前
2026 PCIe互连芯片@ACP#国产替代格局解析:芯动科技领跑高端交换芯片赛道
大数据·网络·数据库·人工智能·分布式·嵌入式硬件
代码代码快快显灵2 小时前
MYSQL-DAY2
数据库·mysql
隔窗听雨眠2 小时前
Oracle到PostgreSQL迁移实战:pg_hint_plan执行计划控制完全指南
数据库·postgresql·oracle
creator_Li3 小时前
Kafka深入刨析-Consumer
分布式·kafka