Redis 从 0 到 1(十八):List、Pub/Sub、Stream 为什么生产只用 Stream?Go 实操

我不会起名字322 · 后端 / 算法 / 数据库

📘 技术栈 | 🧩 力扣 Hot100 | 🐹 Go 项目 | 🟥 Redis | 🐬 MySQL

文章目录

Redis 从 0 到 1(十八):List、Pub/Sub、Stream 为什么生产只用 Stream?Go 实操

上一篇《Redis 从 0 到 1(十七):订单 30 分钟不支付自动关闭------ZSet 延迟队列 Go 实现》我们实现了定时任务,链接:https://blog.csdn.net/2501_92769340/article/details/167399800 。

本篇处理"实时消息":服务间异步通知、事件分发。Redis 有 3 种消息方案,生产环境的选择出奇地一致。

一、两种"用起来挺好,出事就傻眼"的方案

List 当队列 (第 3 篇演示过):LPUSH 生产、BRPOP 阻塞消费。演示完美------消费进程崩了呢?BRPOP 把消息弹出给客户端后,消息就在客户端内存里,进程一崩,消息凭空蒸发,没有任何痕迹。

Pub/Sub :PUBLISH 广播给所有订阅者,实时性极好。但它是"fire and forget":订阅者断线期间的消息直接丢弃,Redis 不为任何人保存消息。发布者也不知道到底有没有人收到。

特性 List 队列 Pub/Sub Stream
消息持久化 消费即删(崩溃丢) 无 有(按 ID 追加,独立保留)
断线续传 不可能 不可能 消费组 + pending 列表可以
多消费者负载均衡 不行(抢消息语义) 不行(广播语义) 消费组内竞争消费
消息确认 无 无 XACK 显式确认

Stream(Redis 5.0+)把"消息"和"消费进度"分开存储:消息按 ID 永久追加在 stream 里(可设 MAXLEN 封顶),消费组各自记录自己读到哪、哪些没 ACK。

二、Stream 核心模型(收藏点 1)

复制代码
XADD stream:orders * order_id 1001 action create
  → 追加一条消息,* 表示由 Redis 生成毫秒时间戳+序号的全局递增 ID
  → 消息本体存在 stream 里,与任何消费者无关

XGROUP CREATE stream:orders g1 0
  → 创建消费组 g1,从 ID 0(队首)开始读
  → 消费组的状态(last_delivered_id、pending)独立保存

XREADGROUP GROUP g1 c1 COUNT 10 STREAMS stream:orders >
  → 消费者 c1(属组 g1)读 10 条**新**消息(> 表示组里没人读过的)
  → 读到的消息进入 c1 的 pending 列表(已投递未确认)
  → 同一组内其他消费者再读 > 不会拿到同一条(组内竞争)

XACK stream:orders g1 <id>     → 处理完确认,从 pending 移除
XPENDING ...                   → 看 pending 列表(谁欠了多少条)
XAUTOCLAIM ...                 → 把超时未 ACK 的消息转给其他消费者(故障转移)

可靠性语义:至少一次投递。消息进 pending 后只有 XACK 才删除,处理失败/消费者崩溃的消息靠 XAUTOCLAIM 被别的实例接管------这正是 List 和 Pub/Sub 永远给不了的。

三、Go 实操:生产 + 消费组(收藏点 2)

go 复制代码
package main

import (
	"context"
	"fmt"
	"time"

	"github.com/redis/go-redis/v9"
	"redis-0to1/redisx"
)

func main() {
	rdb, err := redisx.NewClient(redisx.Config{Addr: "127.0.0.1:6379", Password: "redis123", PoolSize: 10})
	if err != nil {
		panic(err)
	}
	ctx := context.Background()
	stream, group := "stream:orders", "order-workers"

	// 消费组幂等创建(已存在会报错,忽略即可)
	_ = rdb.XGroupCreate(ctx, stream, group, "0").Err()

	// 1. 生产者:XADD,* 自动生成 ID
	for i := 1; i <= 5; i++ {
		id, err := rdb.XAdd(ctx, &redis.XAddArgs{
			Stream: stream,
			MaxLen: 100000, // 封顶防内存膨胀
			Values: map[string]any{"order_id": 1000 + i, "action": "create"},
		}).Result()
		if err != nil {
			panic(err)
		}
		fmt.Println("已生产:", id)
	}

	// 2. 消费者:组内竞争消费 + ACK
	for {
		msgs, err := rdb.XReadGroup(ctx, &redis.XReadGroupArgs{
			Group:    group,
			Consumer: "consumer-1",
			Streams:  []string{stream, ">"}, // > = 只读新消息
			Count:    10,
			Block:    2 * time.Second, // 没有新消息阻塞 2 秒
		}).Result()
		if err != nil {
			if err.Error() == "redis: nil" { // 超时无消息
				continue
			}
			panic(err)
		}
		for _, m := range msgs {
			for _, msg := range m.Messages {
				fmt.Println("消费:", msg.ID, msg.Values)
				// 业务处理(幂等:order_id 去重)
				if err := rdb.XAck(ctx, stream, group, msg.ID).Err(); err != nil {
					fmt.Println("ACK 失败(将留在 pending,可被接管):", err)
				}
			}
		}
		break // 演示:消费一轮就退出
	}

	// 3. 运维三件套:积压、认领、封顶
	pend, _ := rdb.XPending(ctx, stream, group).Result()
	fmt.Println("组内 pending 概况:", pend)
	// XAutoClaim:把 idle 超过 60s 的消息转给消费者 consumer-2
	// rdb.XAutoClaim(ctx, &redis.XAutoClaimArgs{...})
}

四、生产检查清单(收藏点 3)

  1. MaxLen 必须设 :Stream 只增不删,不设上限就是内存泄漏;XADD MAXLEN ~ 100000 的 ~ 表示近似裁剪(不阻塞主线程),第 22 篇会解释为什么精确裁剪危险;
  2. 消费必须 ACK + 幂等:至少一次投递意味着重复消息必然存在(ACK 后崩溃重投),业务用唯一键/状态机去重;
  3. pending 监控告警 :XPENDING 的 pending 数持续上涨 = 有消费者处理失败或崩溃,配合 XAUTOCLAIM 自动接管;
  4. 消费者命名 :consumer 名带实例标识(consumer-1@pod-3),排查 pending 时一眼定位;
  5. 别用 XREAD 代替消费组:XREAD 没有 ACK/pending 语义,只是把 List 的问题换了个壳。

五、命令清单

命令 作用
XADD key [MAXLEN ~ n] * f v 生产消息
XGROUP CREATE key g id 建消费组(id=0 从头读,$ 只读新)
XREADGROUP GROUP g c COUNT n BLOCK ms STREAMS key > 组内消费
XACK / XPENDING / XAUTOCLAIM 确认 / 查积压 / 故障接管
XLEN / XTRIM key MAXLEN ~ n 长度 / 手动裁剪
XINFO STREAM key 看流状态(首末 ID、消费组列表)

六、排查步骤:消息丢了从哪查

  1. 先分责任:生产没发出(业务日志)还是消费没确认(pending 列表);
  2. XINFO STREAM 看消息总量和分组,定位消息卡在哪个组;
  3. XPENDING key group 按消费者列出 pending------卡死的消息 idle 时间会暴露崩溃的消费者;
  4. XAUTOCLAIM 接管后检查业务幂等性,确认不是重复消费问题。

七、总结

  1. List 崩溃丢消息、Pub/Sub 断线丢消息,两者都不能承担生产消息语义;
  2. Stream = 持久化消息 + 消费组进度 + ACK/pending,实现至少一次投递;
  3. 至少一次 = 必然重复,业务幂等是入场券;
  4. MaxLen 封顶 + pending 监控,两个必须配齐。

下一篇预告:消息链路打通了,下一篇回到每个系统都有的需求------登录态。下一篇《JWT 还是 Redis Session?登录态存储的 Go 实现》,我们把"踢人下线、滑动续期、多端登录"一次讲清。

相关推荐
codigger1 小时前
Redis 为什么快?把 "存内存" 这句话拆开看
redis·后端·性能优化·程序员
你看我名字1 小时前
Redis 高可用、持久化与故障切换:从缓存组件到云上基础设施
数据库·redis·缓存
孔令飞2 小时前
AI 岗位类别、要求、技能及学习路线
云原生·go·ai编程
秋饼2 小时前
Redis 全面解析:从数据结构到主从/哨兵/集群、分布式锁与面试高频考点,代码到原理一次讲透
数据库·redis·分布式·面试·后端开发
二月夜2 小时前
Redis 高频面试题全解:缓存三大问题、大 key 治理与分布式锁之争(2026 招聘 JD 真题复盘)
redis·分布式·后端·缓存
樱花落木兰2 小时前
Redis 消息队列的可靠性演进:从 List、Pub/Sub 到 Stream 消费者组,以及 8.x 版本改变了什么
java·开发语言·经验分享·redis·分布式·python·软件工程
IT规划师3 小时前
Redis 数据类型全解 - Set 的三个真相:去重、标签、共同好友(四)
redis·.net
夕除19 小时前
redis--内存淘汰
redis