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 适用与不适用场景
适用场景:
- 订单异步通知——短信、推送、邮件发送。
- 最新 N 条动态——最近操作日志、最近浏览记录、最新评论。
- 轻量任务队列——允许少量丢失的非关键异步任务。
- 简单栈操作——撤销/重做栈、页面返回历史栈。
- 工作流缓冲区——作为流量削峰的缓冲队列。
不适用场景:
- 金融级消息——支付回调、交易状态同步(必须用 Stream 或专业消息队列)。
- 需要消息回溯的场景——历史事件重放、审计追踪(需 Stream)。
- 多消费者需要独立进度的场景——如多个下游系统各自消费(需 Stream 消费组)。
- 需要广播给所有消费者的场景——
BLPOP只会有一个消费者拿到消息(应使用 Pub/Sub)。 - 海量列表的随机翻页——
LRANGE大偏移量时性能退化严重。 - 需要精确时间的延时任务——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 思考题
-
设计题:你需要设计一个"订单超时未支付自动取消"的功能。订单创建后 30 分钟如果未支付,系统自动取消并释放库存。你会使用 Redis List 还是其他数据结构?请说明理由,并给出关键实现步骤。
-
排查题:线上监控发现
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 实战:标签、好友关系与共同关注。

微信公众号: 架构师日常笔记 欢迎关注!
浙公网安备 33010602011771号