1. 项目背景

1.1 业务场景

订单系统在经历第一次大促后暴露出严重的耦合问题。用户下单的链路是这样:前端提交订单 → 后端创建订单 → 扣库存 → 发短信通知用户 → 推送站内信 → 写入操作日志 → 通知积分系统 → 返回下单成功。整个链路最长时达到 3.2 秒,其中短信服务偶尔超时(第三方接口 P99 达到 1.5 秒),导致订单接口的超时率飙到 5%。

运维团队给出的建议是:把短信通知、站内信推送、操作日志写入这些"通知类"动作从主链路上剥离,改为异步处理。但是团队目前没有接入 Kafka 或 RabbitMQ,也不想为这么简单的需求引入额外的中间件。

与此同时,产品经理要求在后台管理页面的首页展示"最新 100 条系统操作日志",让运营能实时看到谁在什么时候做了什么操作。如果每次都查 MySQL,操作日志表日增 50 万条,查询超过 1 秒。

1.2 痛点

需求 不用 Redis 的后果 用 Redis List 后的效果
订单异步通知 订单接口被慢服务拖慢,耦合严重 订单入队即返回,通知异步消费,接口延迟稳定
短信故障隔离 短信接口抖动直接导致下单失败 消息在队列中堆积,短信恢复后消费,订单不受影响
最新 100 条日志 每次查询 MySQL 大表,高峰期 QPS 撑不住 LPUSH + LTRIM 维护固定长度列表,O(1) 读取最新 N 条
用户最近浏览 每次查数据库历史表 List 维护最近 50 条浏览记录,毫秒级读取
失败重试 通知失败需要人工介入 失败任务进入重试队列,自动重试 3 次后进死信队列

List 是最简单的"异步化"数据结构,它只用 3-5 个命令就能搭建一个生产者和消费者模型。虽然它的可靠性不如 Stream(第 20 章),但在"允许少量数据丢失、允许人工补偿"的场景下,List 是成本最低的选择。

1.3 本章目标

掌握 Redis List 的核心操作和三种实战模式:

  • 任务队列模式:RPUSH + BLPOP/BRPOP 实现生产者-消费者。
  • 最新列表模式:LPUSH + LTRIM 维护固定长度。
  • 栈模式:LPUSH + LPOP 实现后进先出。

同时理解三个关键边界:

  • List 队列的消息可靠性短板——消费者宕机后消息丢失的补偿方案。
  • BLPOP 的超时机制与连接管理——阻塞期间连接是否会被断开。
  • List 与 Stream 的适用边界——什么场景必须上 Stream。

2. 项目设计

2.1 第一幕:为什么不用线程池而要入队?

小胖:"异步化不就是用线程池嘛?threadPool.submit(() -> sendSms(order)) 不就行了,为什么还要把任务写进 Redis 队列,再让另一个服务消费?这不是把问题搞复杂了吗?"

小白:"线程池能解决多线程异步问题,但它有几个根本缺陷。第一,服务重启后线程池里的未完成任务全部丢弃,没有任何持久化。第二,订单服务和通知服务必须部署在同一个 JVM 进程内——如果通知服务需要独立部署、独立扩容,线程池就无能为力。第三,消息积压时线程池的内存是有限的,队列满了你还是得丢任务。"

大师:"对。线程池是'同一个城池里的支线任务',Redis 队列是'跨城池的驿站接力'。我画个对比图:"

方案 A:线程池(进程内异步)
┌────────────────────────────┐
│ 订单服务 (JVM)              │
│  ┌─────────┐  submit()     │
│  │ 主线程   │──────▶ 线程池  │
│  │ 创建订单 │               │
│  └─────────┘               │
│  问题:                     │
│  1. 重启 → 任务丢失        │
│  2. 无法跨进程             │
│  3. 积压 → OOM            │
└────────────────────────────┘

方案 B:Redis List 队列(跨进程异步)
┌─────────────┐     RPUSH      ┌──────────┐     BLPOP     ┌─────────────┐
│ 订单服务     │ ──────────────▶│  Redis   │ ────────────▶ │ 通知服务     │
│ (生产者)     │                │  List    │               │ (消费者)     │
└─────────────┘                └──────────┘               └─────────────┘
  优势:                        优势:                        优势:
  1. 发完即返回                 1. 消息持久化                 1. 独立部署
  2. 不关心消费方               2. 积压可见 (LLEN)           2. 独立扩容
  3. 崩溃不影响消息             3. 多消费者可竞争             3. 可随时重启

大师:"你可以把 List 队列想象成快递驿站。订单服务把包裹放进驿站(RPUSH),通知服务从驿站取包裹(BLPOP),两人不需要直接见面。驿站如果关门了(Redis 宕机),包裹可能丢失——这就是 List 的可靠性短板。"

技术映射:List 队列实现了生产者-消费者的解耦。RPUSH 是放快递,BLPOP 是取快递并阻塞等待。

2.2 第二幕:消费者宕机了消息怎么办?

小胖:"取快递的人拿到包裹后,回家的路上被车撞了(崩溃)——那这个包裹就丢了?下一个取快递的人会再收到这个包裹吗?"

小白:"这正是 List 和 Stream 的最大区别。BLPOP 是'取走即删除'——消息从 Redis 里移除的那一刻起,就不再属于队列了。消费者处理到一半宕机,消息就真的丢了。"

大师:"对。所以用 List 做队列时,必须有一套补偿机制。最常见的做法是——重试次数 + 失败队列:"

正常流程:
  RPUSH queue:task → BLPOP queue:task → 处理成功 ✓

失败补偿:
  BLPOP queue:task → 处理失败 → RPUSH queue:task:retry (带 retry=1)
  → 再次失败 → RPUSH queue:task:retry (带 retry=2)
  → 再次失败 → RPUSH queue:task:dead  (retry>=3,人工介入)

极端情况(消费者宕机):
  BLPOP 取走消息 → 消费者宕机 → 消息永久丢失 ✗
  (List 无法解决, 这正是 Stream 的 XACK + Pending List 要解决的问题)

小胖:"那既然 List 会丢消息,为什么还要用它?直接用 Stream 不就好了?"

大师:"因为 List 的简单性本身就是巨大的价值。如果你的短信通知允许 0.1% 的丢失率(用户没收到短信可以自己查订单状态),那用 List 就足够了。Stream 带来了消费组、ACK 机制、Pending List 管理——如果你不需要这些,它反而增加了代码复杂度。技术选型不是'用最强的',而是'用最合适的'。"

技术映射:List 队列适用于"允许少量丢失+可人工补偿"的轻量异步场景(通知、日志、非关键状态同步)。Stream 适用于"需要确认+重投+消费组"的可靠消息场景(库存扣减、支付回调、订单事件)。

2.3 第三幕:最新 N 条——LPUSH + LTRIM 的妙用

小胖:"最新 100 条操作日志,不就是 SELECT * FROM op_log ORDER BY created_at DESC LIMIT 100 吗?为什么要 Redis?"

小白:"因为每次都 ORDER BY 查全表,索引扫描的成本和行数成正比。如果把日志写入 MySQL 的同时也写进 Redis,读取时直接从 Redis 的 List 里读——这个操作是 O(1) 的,因为 List 维护了头尾指针。"

大师:"关键是 LPUSH + LTRIM 这个组合:"

# 每次有新日志
LPUSH ops:latest "订单1001创建成功"
# → List 变成: [订单1001创建成功]

LPUSH ops:latest "订单1002支付成功"
# → List 变成: [订单1002支付成功, 订单1001创建成功]

LTRIM ops:latest 0 99
# → List 只保留前 100 条,多余的自动丢弃

大师:"这个组合的精妙之处在于——插入新数据的同时自动淘汰旧数据。无需定时任务、无需人工清理、永远只占 100 条的空间。这就像自动旋转的留言板——新留言贴在顶上,旧留言从底部掉出去。"

技术映射:LPUSH 是头插(O(1)),LTRIM 是按范围裁剪(O(N) 但 N 是裁剪掉的元素数,通常很小)。两者配合实现了固定容量的最新列表。

小白追问:"那如果日志量非常大,每分钟几千条,LTRIM 的频率会变高——每次 LPUSH 都 LTRIM 是不是太浪费了?"

大师:"可以降低 LTRIM 的频率。比如每 LPUSH 10 次做一次 LTRIM,或者每 5 秒做一次。这样 List 最多超出几十条,读取时用 LRANGE 0 99 也能拿到最新的 100 条。这是性能优化的细节,原理不变。"

2.4 第四幕:BLPOP 的超时与连接管理

小胖:"BLPOP queue:task 30——这个 30 秒是什么意思?如果 30 秒内没消息,会发生什么?"

大师:"BLPOP 的参数是超时秒数。三种情况:"

情况一:队列有数据
  BLPOP queue 10 → 立即返回 (queue, "task1")

情况二:队列空,10 秒内有数据到达
  BLPOP queue 10 → 在 5 秒时数据到达,立即返回

情况三:队列空,10 秒内一直没数据
  BLPOP queue 10 → 10 秒后返回 (nil)

小白:"超时返回 nil 后,消费者是重新 BLPOP 还是退出?如果一直循环 BLPOP,会不会占用连接?"

大师:"标准的消费者模型是无限循环 + 超时重连:"

while (running.get()) {
    try {
        // 最多等 30 秒,返回 null 就继续循环
        String task = redis.brpop(30, "queue:order:notice");
        if (task == null) {
            continue;  // 超时,重新阻塞等待
        }
        processTask(task);
    } catch (Exception e) {
        // 连接断开等异常,短暂休眠后重试
        Thread.sleep(1000);
    }
}

大师:"关键点:BLPOP 超时返回 nil 不需要重新创建连接——连接还是同一个,只是命令返回了。而如果 Redis 宕机,BLPOP 会抛出连接异常,这时需要在 catch 中休眠重试,避免空转刷爆 CPU。"

技术映射:BLPOP 的超时是一个优雅的设计——消费者大部分时间处于阻塞等待状态(几乎不消耗 CPU),有消息时立即唤醒。这比轮询(LPOP + sleep)高效得多。


3. 项目实战

3.1 环境准备

docker exec -it redis-lab redis-cli -a redis123

3.2 实战一:订单异步通知队列

步骤 1:生产者写入任务

# RPUSH:从右侧入队(队尾追加)
127.0.0.1:6379> RPUSH queue:order:notice '{"orderId":1001,"userId":2001,"type":"sms","phone":"13800000001"}'
(integer) 1

127.0.0.1:6379> RPUSH queue:order:notice '{"orderId":1002,"userId":2002,"type":"push","token":"abc123"}'
(integer) 2

127.0.0.1:6379> RPUSH queue:order:notice '{"orderId":1003,"userId":2003,"type":"sms","phone":"13800000003"}'
(integer) 3

# 查看队列长度
127.0.0.1:6379> LLEN queue:order:notice
(integer) 3

步骤 2:消费者取出任务

# LPOP:从左侧出队(队头取出)
127.0.0.1:6379> LPOP queue:order:notice
"{\"orderId\":1001,\"userId\":2001,\"type\":\"sms\",\"phone\":\"13800000001\"}"

# 再从右侧入队、左侧出队就是先进先出(FIFO)的队列
127.0.0.1:6379> RPUSH queue:order:notice '{"orderId":1004,"userId":2004,"type":"sms"}'
127.0.0.1:6379> LPOP queue:order:notice
"{\"orderId\":1002,\"userId\":2002,\"type\":\"push\",\"token\":\"abc123\"}"
# ↑ 这是最早进入的任务(先进先出 ✓)

步骤 3:阻塞弹出(BLPOP)

# BLPOP:队列为空时阻塞等待,最多等 10 秒
127.0.0.1:6379> BLPOP queue:order:notice 10
1) "queue:order:notice"
2) "{\"orderId\":1003,\"userId\":2003,\"type\":\"sms\",\"phone\":\"13800000003\"}"

# 再次 BLPOP——队列已空,10 秒后超时返回 nil
127.0.0.1:6379> BLPOP queue:order:notice 10
(nil)
(10.08s)    # 等待了约 10 秒

# 验证 BLPOP 的阻塞效果——开两个终端
# 终端 A: BLPOP queue:test 30
# 终端 B: RPUSH queue:test "hello"
# → 终端 A 会立即收到 "hello"

阻塞验证演示(双终端):

# 终端 A(先执行)
127.0.0.1:6379> BLPOP queue:demo 60
# 此时光标闪烁,表示阻塞等待中...

# 终端 B(在另一个终端执行)
127.0.0.1:6379> RPUSH queue:demo "wake up!"
(integer) 1

# 终端 A 立即返回:
1) "queue:demo"
2) "wake up!"
(2.34s)     # 只等了 2.34 秒

步骤 4:Java 生产者和消费者完整实现

生产者代码:

public class OrderEventProducer {

    private static final String QUEUE_KEY = "queue:order:notice";

    public void publishOrderEvent(Order order) {
        OrderEvent event = OrderEvent.builder()
            .orderId(order.getId())
            .userId(order.getUserId())
            .type(order.getNoticeType())  // sms / push / email
            .phone(order.getPhone())
            .timestamp(System.currentTimeMillis())
            .build();

        String json = objectMapper.writeValueAsString(event);
        redis.rpush(QUEUE_KEY, json);

        log.info("订单事件入队: orderId={}, type={}", order.getId(), order.getNoticeType());
    }
}

消费者代码:

public class OrderEventConsumer implements Runnable {

    private static final String QUEUE_KEY = "queue:order:notice";
    private static final String RETRY_QUEUE = "queue:order:notice:retry";
    private static final String DEAD_QUEUE = "queue:order:notice:dead";
    private static final int MAX_RETRY = 3;

    private final AtomicBoolean running = new AtomicBoolean(true);

    @Override
    public void run() {
        while (running.get()) {
            try {
                // 阻塞等待任务,超时 30 秒
                String taskJson = redis.brpop(30, QUEUE_KEY);

                if (taskJson == null) {
                    continue;  // 超时,继续等待
                }

                OrderEvent event = objectMapper.readValue(taskJson, OrderEvent.class);
                processEvent(event);

            } catch (ConnectionException e) {
                log.error("Redis 连接异常,5 秒后重连", e);
                safeSleep(5000);
            } catch (Exception e) {
                log.error("任务处理异常", e);
                // 处理失败的消息重新入队(带重试次数)
                handleFailedTask(taskJson, 0);
            }
        }
    }

    private void processEvent(OrderEvent event) {
        switch (event.getType()) {
            case "sms":
                smsService.send(event.getPhone(), buildSmsContent(event));
                break;
            case "push":
                pushService.push(event.getUserId(), buildPushContent(event));
                break;
            default:
                log.warn("未知的通知类型: {}", event.getType());
        }
        log.info("通知发送成功: orderId={}", event.getOrderId());
    }
}

3.3 实战二:失败重试机制

步骤 1:手动模拟失败队列

# 写入任务(带重试次数)
127.0.0.1:6379> RPUSH queue:order:notice '{"orderId":1001,"retry":0,"type":"sms"}'
(integer) 1

# 取出任务
127.0.0.1:6379> LPOP queue:order:notice
"{\"orderId\":1001,\"retry\":0,\"type\":\"sms\"}"

# 模拟处理失败 → 递增 retry 后放回
127.0.0.1:6379> RPUSH queue:order:notice '{"orderId":1001,"retry":1,"type":"sms"}'
(integer) 1

# 如果 retry >= 3 → 放失败死信队列
127.0.0.1:6379> RPUSH queue:order:notice:dead '{"orderId":1001,"retry":3,"type":"sms","reason":"sms timeout"}'
(integer) 1

# 查看死信队列
127.0.0.1:6379> LRANGE queue:order:notice:dead 0 -1
1) "{\"orderId\":1001,\"retry\":3,\"type\":\"sms\",\"reason\":\"sms timeout\"}"

步骤 2:Java 失败处理代码

private void handleFailedTask(String taskJson, int retry) {
    OrderEvent event = objectMapper.readValue(taskJson, OrderEvent.class);

    if (retry >= MAX_RETRY) {
        // 超过最大重试次数 → 进入死信队列,等待人工处理
        redis.rpush(DEAD_QUEUE, taskJson);
        log.error("任务进入死信队列: orderId={}, retry={}", event.getOrderId(), retry);
        // 触发告警
        alertService.send("订单通知任务失败超过3次: " + event.getOrderId());
    } else {
        // 未超限 → 递增 retry 后重新入队
        event.setRetry(retry + 1);
        String newJson = objectMapper.writeValueAsString(event);
        redis.rpush(RETRY_QUEUE, newJson);
        log.warn("任务重试入队: orderId={}, retry={}", event.getOrderId(), event.getRetry());
    }
}

步骤 3:死信队列定时扫描与人工处理

@Scheduled(fixedDelay = 60000)  // 每分钟扫描一次
public void scanDeadQueue() {
    Long size = redis.llen(DEAD_QUEUE);
    if (size > 0) {
        log.warn("死信队列积压: {} 条, 请及时处理", size);
        // 可以在这里发送钉钉/企微通知
        // 管理员可以通过管理后台查看死信队列内容并手动重放或丢弃
    }
}

3.4 实战三:最新 100 条操作日志

步骤 1:写入并限制长度

# 模拟操作日志写入
127.0.0.1:6379> LPUSH ops:latest "2024-07-18 10:00:01 [小胖] 创建订单 #1001"
(integer) 1
127.0.0.1:6379> LPUSH ops:latest "2024-07-18 10:00:05 [小白] 审核订单 #1001"
(integer) 2
127.0.0.1:6379> LPUSH ops:latest "2024-07-18 10:00:12 [大师] 退款订单 #0998"
(integer) 3

# LTRIM 保留前 100 条(索引 0-99),超出部分自动删除
127.0.0.1:6379> LTRIM ops:latest 0 99
OK

# 读取最新 10 条(头部是最新的)
127.0.0.1:6379> LRANGE ops:latest 0 9
1) "2024-07-18 10:00:12 [大师] 退款订单 #0998"
2) "2024-07-18 10:00:05 [小白] 审核订单 #1001"
3) "2024-07-18 10:00:01 [小胖] 创建订单 #1001"

# 验证长度控制——写入 105 条后,List 只有 100 条
127.0.0.1:6379> LLEN ops:latest
(integer) 3

步骤 2:模拟大量写入并验证长度控制

# 批量写入 150 条测试数据(使用 redis-cli 的 --pipe 或脚本)
# 这里用循环模拟
# 在 bash 中执行(进入容器):
docker exec -it redis-lab bash

# 循环写入 150 条日志
for i in $(seq 1 150); do
  redis-cli -a redis123 LPUSH ops:latest "log-$i" > /dev/null
done

# LTRIM 保留 100 条
redis-cli -a redis123 LTRIM ops:latest 0 99 > /dev/null

# 验证长度
redis-cli -a redis123 LLEN ops:latest
# 期望输出: (integer) 100

步骤 3:优化——批量 LTRIM 减少命令次数

public class OpLogService {
    private static final String OPS_KEY = "ops:latest";
    private static final int MAX_SIZE = 100;
    private int pendingCount = 0;
    private static final int TRIM_INTERVAL = 10;  // 每 10 条做一次 LTRIM

    public void addLog(String log) {
        redis.lpush(OPS_KEY, formatLog(log));
        pendingCount++;

        // 每 10 条或超时执行一次 LTRIM
        if (pendingCount >= TRIM_INTERVAL) {
            redis.ltrim(OPS_KEY, 0, MAX_SIZE - 1);
            pendingCount = 0;
        }
    }

    public List<String> getLatestLogs(int count) {
        return redis.lrange(OPS_KEY, 0, Math.min(count, MAX_SIZE) - 1);
    }

    private String formatLog(String log) {
        return LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)
            + " " + log;
    }
}

3.5 实战四:List 的其他玩法

步骤 1:栈模式(后进先出)

# LPUSH + LPOP = 后进先出 (LIFO) 栈
127.0.0.1:6379> LPUSH stack:tasks "task1"
(integer) 1
127.0.0.1:6379> LPUSH stack:tasks "task2"
(integer) 2
127.0.0.1:6379> LPUSH stack:tasks "task3"
(integer) 3

127.0.0.1:6379> LPOP stack:tasks
"task3"    # 最后入栈的最先出栈 ✓
127.0.0.1:6379> LPOP stack:tasks
"task2"
127.0.0.1:6379> LPOP stack:tasks
"task1"

步骤 2:阻塞右弹出(BRPOP)和可靠队列变种

# BRPOP:从右侧阻塞弹出(和 RPUSH 配合实现可靠 FIFO)
# 场景:RPUSH 入队,BRPOP 出队 —— 消费者从队尾取

127.0.0.1:6379> RPUSH q:reliable task1 task2 task3
(integer) 3

# BRPOP 从右侧弹出(后入先出?不是!RPUSH + BRPOP 是 FIFO!)
127.0.0.1:6379> BRPOP q:reliable 5
1) "q:reliable"
2) "task3"    # 最后入队的先出 ← 这可不是 FIFO

# 如果需要 FIFO: RPUSH + LPOP(队尾进队头出)或 LPUSH + BRPOP(队头进队尾出)
# 注意:BRPOP 返回的是最右边的元素,BPOP 不存在

步骤 3:LRANGE 分页读取

# 写入 30 条数据
127.0.0.1:6379> DEL timeline
(integer) 1
127.0.0.1:6379> RPUSH timeline "msg1" "msg2" "msg3" "msg4" "msg5" "msg6" "msg7" "msg8" "msg9" "msg10"
(integer) 10
# ... 略,继续写入到 30 条

# 分页读取(每页 10 条)
# 第 1 页:索引 0-9
127.0.0.1:6379> LRANGE timeline 0 9
# 第 2 页:索引 10-19
127.0.0.1:6379> LRANGE timeline 10 19
# 第 3 页:索引 20-29
127.0.0.1:6379> LRANGE timeline 20 29

# 注意:LRANGE 的复杂度是 O(S+N),其中 S 是 start 偏移量
# start 越靠后,性能越差——不适合做"翻到第 1000 页"

步骤 4:LINSERT 和 LREM

# LINSERT:在指定元素前/后插入
127.0.0.1:6379> RPUSH tasks "A" "B" "D"
(integer) 3
127.0.0.1:6379> LINSERT tasks BEFORE "D" "C"
(integer) 4
127.0.0.1:6379> LRANGE tasks 0 -1
1) "A"
2) "B"
3) "C"
4) "D"

# LREM:删除匹配的元素
127.0.0.1:6379> RPUSH items "x" "y" "x" "z" "x"
(integer) 5
127.0.0.1:6379> LREM items 2 "x"    # 从头部开始,删除 2 个 "x"
(integer) 2
127.0.0.1:6379> LRANGE items 0 -1
1) "y"
2) "z"
3) "x"    # 还剩一个 "x"

3.6 实战五:List 的性能陷阱

步骤 1:LLEN vs LRANGE 0 -1

# LLEN 是 O(1)——List 内部维护了长度计数器
127.0.0.1:6379> LLEN ops:latest
(integer) 100

# LRANGE 0 -1 是 O(N)——需要遍历所有节点
# 对 100000 条的 List 执行 LRANGE 0 -1 会阻塞 Redis
127.0.0.1:6379> LRANGE ops:latest 0 -1
# ← 如果 List 很大,这会返回大量数据并阻塞主线程

# 正确做法:用 LLEN 知长度,用 LRANGE start end 分页限制返回

步骤 2:观察 List 的内存占用

# 写入 1000 条数据
127.0.0.1:6379> DEL memtest
(integer) 1
# (略去实际写入 1000 条的循环)

# 查看 List 的编码和内存
127.0.0.1:6379> OBJECT ENCODING memtest
"quicklist"    # Redis 3.2+ 统一使用 quicklist(linkedlist + ziplist 的混合)
127.0.0.1:6379> MEMORY USAGE memtest
(integer) 8472  # 约 8KB 存储 1000 个短字符串

3.7 实战六:List 阻塞弹出的连接管理

演示 BLPOP 期间连接被中断的情况:

# 终端 A: 阻塞等待
127.0.0.1:6379> BLPOP queue:block 300

# 终端 B: 查看客户端连接(可以看见 A 的状态)
127.0.0.1:6379> CLIENT LIST
# id=5 addr=127.0.0.1:xxxxx fd=8 name= age=45 idle=44 flags=b
# ↑ flags=b 表示该客户端正在阻塞等待("b" = blocked)

# 如果在 300 秒内没有数据到达:
# BLPOP 会返回 (nil)
# 连接保持活跃,可以继续使用

关键认知:

  • BLPOP 阻塞期间连接不会断开,Redis 会维护这个阻塞状态。
  • 如果 Redis 设置了 timeout(空闲连接超时),BLPOP 阻塞不算空闲——它是一直在等待。
  • 客户端需要处理 BLPOP 返回 nil 的情况(超时),以及连接异常的情况(Redis 宕机或网络中断)。

4. 项目总结

4.1 核心命令速查

命令 用途 复杂度 注意事项
LPUSH key val 从左侧插入(队头) O(1) 可一次插入多个
RPUSH key val 从右侧插入(队尾) O(1) 可一次插入多个
LPOP key 从左侧弹出(非阻塞) O(1) 队列空返回 nil
RPOP key 从右侧弹出(非阻塞) O(1) 队列空返回 nil
BLPOP key timeout 左侧阻塞弹出 O(1) 队列空时阻塞最多 timeout 秒
BRPOP key timeout 右侧阻塞弹出 O(1) 同上
LLEN key 获取 List 长度 O(1) 内部计数器
LRANGE key start end 范围读取 O(S+N) S=start 偏移,大 start 性能差
LTRIM key start end 裁剪到指定范围 O(N) N=裁剪掉的元素数
LINSERT key BEFORE\|AFTER pivot val 在元素前/后插入 O(N) 需要遍历找到 pivot
LREM key count val 删除匹配元素 O(N) count>0 从头删,<0 从尾删

4.2 List vs Stream 对比

维度 List Stream
消息确认 无(取走即删) XACK 机制,消费后显式确认
消息重投 需要手动重入队 Pending List + XCLAIM 自动重投
消费组 不支持 支持,可并行消费
消息持久化 取走后消失 消费后仍保留(直到 MAXLEN 裁剪)
消息回溯 不支持 支持按消息 ID 或时间范围读取
命令复杂度 3-5 个命令上手 10+ 个命令,概念更多
适用场景 轻量异步任务、最新列表 可靠事件流、多消费者、重放
学习成本 低 中

4.3 适用与不适用场景

适用场景:

  1. 订单异步通知——短信、推送、邮件发送。
  2. 最新 N 条动态——最近操作日志、最近浏览记录、最新评论。
  3. 轻量任务队列——允许少量丢失的非关键异步任务。
  4. 简单栈操作——撤销/重做栈、页面返回历史栈。
  5. 工作流缓冲区——作为流量削峰的缓冲队列。

不适用场景:

  1. 金融级消息——支付回调、交易状态同步(必须用 Stream 或专业消息队列)。
  2. 需要消息回溯的场景——历史事件重放、审计追踪(需 Stream)。
  3. 多消费者需要独立进度的场景——如多个下游系统各自消费(需 Stream 消费组)。
  4. 需要广播给所有消费者的场景——BLPOP 只会有一个消费者拿到消息(应使用 Pub/Sub)。
  5. 海量列表的随机翻页——LRANGE 大偏移量时性能退化严重。
  6. 需要精确时间的延时任务——List 不支持延迟消费,需要用 ZSet 的时间戳排序(第 7 章)。

4.4 常见踩坑经验

案例 表象 根因 解法
BLPOP 连接超时断开 消费者日志频繁报 "Connection reset" Redis 服务端配置了 timeout 300,BLPOP 阻塞超过 300 秒被视为空闲连接被断开 将 BLPOP 的超时参数设为小于 timeout 的值(如 60 秒),超时后重新 BLPOP
消息被重复消费 用户收到两条相同的短信 BLPOP 取出消息后,处理逻辑非幂等;重试机制把任务重新入队后又被消费 为每条消息添加唯一 ID(如 UUID),消费端检查是否已处理(用 SET NX 做幂等检查)
队列无限增长 Redis 内存报警,LLEN 显示 120 万条 消费者宕机了但没有人知道,生产者还在一直 RPUSH 监控 LLEN 指标,设置积压告警阈值(如 >10000 条发告警);消费者端加健康检查和自动重启
LRANGE 导致主线程阻塞 慢查询突然增多,P99 延迟暴涨 运营管理后台执行 LRANGE 0 -1 查看 List 某大 key(200 万条),返回数据量巨大 前端做分页限制,每次只查 20-50 条;后端对 LRANGE 做返回数量上限校验;用 LLEN 先判断大小
消息丢失 用户投诉"订单成功了但没收到短信" 消费者 BLPOP 取走消息后宕机,消息已从 Redis 删除 ① 改用 BRPOPLPUSH 做可靠队列(备份队列);② 或改用 Stream 的 ACK 机制;③ 或容忍少量丢失+人工补偿

4.5 BRPOPLPUSH 可靠队列模式(进阶)

这是 List 里一个被低估的命令,可以解决"消费者崩溃导致消息丢失"的问题:

# BRPOPLPUSH:原子地从 source 右弹出,并推入 destination 左端
# 用法:BRPOPLPUSH source destination timeout

# 主队列 + 备份队列
127.0.0.1:6379> RPUSH queue:main task1 task2 task3
(integer) 3

# 消费者从主队列取任务,同时备份到 processing 队列
127.0.0.1:6379> BRPOPLPUSH queue:main queue:processing 10
"task3"

# 如果消费者崩溃了——任务还在 queue:processing 里!
# 监控程序可以把 queue:processing 中的超时任务迁移回 queue:main
127.0.0.1:6379> RPOPLPUSH queue:processing queue:main
"task3"

4.6 思考题

  1. 设计题:你需要设计一个"订单超时未支付自动取消"的功能。订单创建后 30 分钟如果未支付,系统自动取消并释放库存。你会使用 Redis List 还是其他数据结构?请说明理由,并给出关键实现步骤。

  2. 排查题:线上监控发现 queue:order:notice 的 LLEN 在 10 分钟内从 100 涨到了 80000。消费者日志显示消费速率正常(每秒 50 条),但队列仍在持续增长。请分析至少 3 种可能的根因,并给出最可能的诊断步骤。

(答案提示将在下一章末尾给出。)

上一章思考题答案提示(第4章):

第1题(用户权益中心设计):不建议单一大 Hash。方案:① 基础权益放在 user:profile:{uid}(Hash),长期保存,整体设 TTL 1 小时;② 临时权益按类型拆分——限时 VIP 放在 user:vip:{uid}(String,设对应过期时间),优惠券标记放在 user:coupon:{uid}:{couponId}(String,按优惠券独立过期),风控标签放在 user:risk:{uid}(Hash,使用 8.x HEXPIRE 或单独 key 设短 TTL)。拆分的理由:不同生命周期的数据放在一起,会被整体 TTL 限制。

第2题(统计 SKU=2001 的购物车情况):不能用 HSCAN 扫描 100 万个 key——这需要 100 万次网络往返。方案:在写入购物车时,使用 SADD cart:sku:2001:users {userId} 维护一个 Set 记录哪些用户的购物车含此 SKU。统计时直接 SCARD cart:sku:2001:users,O(1) 返回。当用户清空该 SKU 时,SREM 移除。这是"写时维护索引,读时直接取值"的经典 Redis 模式。

延伸阅读与资源

Redis 8 实战精讲:从 CRUD 到源码,构建高可用缓存系统
Java 工程师进阶:从 JVM 生产排障到OpenJDK原理
Nacos 3.x 注册配置中心实战修炼:从入门到源码扩展
Elasticsearch从入门到进阶的实战之旅
MySQL Server 9从入门到进阶的实战之旅
RabbitMQ从入门到进阶的实战之旅:从单机到大促高可用架构
Celery 入门到进阶之路:从异步任务到自研调度平台
LangGraph 生产级实战进阶:从零到生产级Agent工作流开发
Dify 从入门到源码:LLM 应用平台实战修炼
从零到生产级:FastAPI 异步高并发、源码与 SRE 实战
实战SQLAlchemy 2.0: 从 CRUD 到生产级架构
从零打造企业级 AI 助手:LangChain RAG、Agent 与生产实战
后端工程师 AI 转型课:Ollama 私有化大模型从入门到生产
MongoDB 实战进阶与内核修炼
NumPy 从入门到生产落地:全链路实战指南(科学计算/向量化/性能调优)
Milvus向量数据库实战修炼:从 0 到 1 精通向量检索与生产落地
Python 3实战精进:从脚本到高并发订单引擎
python入门:Rquests从菜鸟脚本到企业级SDK的网络实战圣经

下一章预告:第6章——集合 Set 实战:标签、好友关系与共同关注。

posted on 2026-10-30 14:38  一天不进步,就是退步  阅读(14)  评论(0)    收藏  举报