Redis 数据类型全解 - Stream 深度:消费者组、Pending 队列与死信处理(十)

0 阅读20分钟

一句话:Stream 的价值不在 XADD 写得快,而在「消费者组 + PEL + 认领」这套投递语义;坑也全在这套状态机上——消息现在在谁手上、什么时候还回来、回来时还能不能用。

六章顺序:选型边界 → 一页跑通 → 三个数据结构 → 消费可靠性 → 容量与版本 → 坑点清单。

1. 定位与选型

1.1 它是三件东西拼起来的

1.png

关键区别:List 的队列状态活在数据本身(BRPOP 弹走就没了),Stream 的队列状态活在元数据里(消息始终在流中,PEL 记录谁拿着),读因此是非破坏性的。它不做五件事:不自动重投递(崩了停在 PEL,> 读不到)、不自动重平衡(无分区)、无消息级 TTL(整 key 过期会连组一起删)、无服务端过滤、不保证精确一次——最后一条最要落到代码上:存在认领,幂等就不是可选项。

1.2 与 List / Pub/Sub / 专业 MQ 的分工

2.png

对 List 补一句:BRPOP 弹出后崩溃会丢,但 BRPOPLPUSH / LMOVE 配「处理中 List」也能搭出可恢复队列。

1.3 什么时候不该用

按顺序问,任一环节否定就别硬上:
能不能丢(能丢只要解耦,List 更轻);
要不要确认与回溯;
单 key 容量够不够(按第 5 章估算);
能不能接受单 slot(跨机房多活、分区级扩展做不到);
有没有人维护幂等与巡检(没有人力,「至少一次」会变成「重复且无人接管」)。

成本对照最容易被漏掉:Stream 省下「部署一套 MQ」,付出的是主循环、Janitor 与选主、死信与重放、幂等与保序、outbox、容量监控,每项都得自己写、自己盯。折算后若接近接一个现成 MQ 的代价就该迁走:length 贴着 MAXLEN、lag 加消费者也降不下来、业务要「30 天可回放」、自建成本超过引入 MQ。迁移五步:双写 → 按业务主键对账差集 → 灰度切读 → 全量切读 → 停双写;多出的一步是对账,要迁的不只数据,还有位点与组状态。

2. 一页跑通

2.1 一套最小流程

完整 10 步脚本(含裁剪、幽灵 ID、认领三段)见附录 B,这里只走主干四步。固定 ID 是为了输出可判定,生产一律用 *。

# 先写两条显式 ID 的消息,后一条必须大于已有最大 ID
XADD demo:stream 1000-0 type created orderId A1
XADD demo:stream 1001-0 type paid orderId A1
# 建组并从头消费:0 表示历史两条也算未投递,生产上线通常用 $
XGROUP CREATE demo:stream g 0
# > 只投递从未投递给该组的新消息,进入 w1 名下 PEL,投递计数 = 1
XREADGROUP GROUP g w1 COUNT 10 STREAMS demo:stream >
# ACK 一条只清它的 PEL 引用,XLEN 仍是 2 —— 确认与删除是两件事
XACK demo:stream g 1001-0

四条要点:> 推进位点,0 只翻自己名下的 PEL;读 PEL 历史也让投递计数 +1(实测 1 → 2);XACK 不删消息,想让流有界必须另配 MAXLEN ~;建组用 $ 只吃新消息。

2.2 StackExchange.Redis 对照

签名按 IDatabase 主线接口核对,参数名与类型按声明写。

using StackExchange.Redis;

// 复用同一个 ConnectionMultiplexer:它自带连接池与多路复用,每次 new 都会漏连接
var muxer = ConnectionMultiplexer.Connect("localhost:6379");
// 拿到默认库(db 0);多库场景要显式传库号
var db = muxer.GetDatabase();
// RedisKey 与 string 可隐式转换,显式声明只为读起来知道这是 key
RedisKey key = "demo:stream";

// 建组:position 是 RedisValue?;StreamPosition.NewMessages 对应 $
try
{
    db.StreamCreateConsumerGroup(key, "g", StreamPosition.NewMessages, createStream: true);
}
catch (RedisServerException ex) when (ex.Message.Contains("BUSYGROUP"))
{
    // 组已存在:正常情况,绝不要在这里重建组或重置位点
}

// 写入:maxLength 是 long?,useApproximateMaxLength 对应 ~
var id = db.StreamAdd(key, new NameValueEntry[]
{
    new("type", "created"),
    new("orderId", "A1")
}, messageId: null, maxLength: 100000, useApproximateMaxLength: true);

// 读新消息:position 用 StreamPosition.NewMessages,即 >
var entries = db.StreamReadGroup(key, "g", "w1",
    StreamPosition.NewMessages, count: 10);

// 逐条处理:取字段前先判空,处理完再 ACK
foreach (var entry in entries)
{
    // 防御性判空:用 0 读自己 PEL 历史时可能拿到 nil 消息体(3.3)
    if (entry.Values == null || entry.Values.Length == 0)
    {
        db.StreamAcknowledge(key, "g", entry.Id);
        continue;
    }
    // 索引器取字段:字段不存在时返回 RedisValue.Null,不抛异常,要自己判
    var orderId = entry["orderId"];
    var type = entry["type"];
    Console.WriteLine($"{entry.Id}: {orderId} {type}");
    // 演示用;生产的 ACK 收口见 4.4
    db.StreamAcknowledge(key, "g", entry.Id);
}

// 巡检认领:minIdleTimeInMs 是 long 毫秒,不是 TimeSpan
var result = db.StreamAutoClaim(key, "g", "w2",
    minIdleTimeInMs: 60000, startAtId: "0-0", count: 10);

// result.NextStartId 是下一轮扫描游标;ClaimedEntries / DeletedIds 都要处理
foreach (var e in result.ClaimedEntries)
{
    db.StreamAcknowledge(key, "g", e.Id);
}
// 第三段是幽灵 ID(7.0+):消息体已被裁掉,只能清 PEL,业务不要再跑
foreach (var ghost in result.DeletedIds)
{
    // 消息体已被裁剪,只清 PEL,不要重复跑业务
    db.StreamAcknowledge(key, "g", ghost);
}

// PEL 明细:第 4 个参数是 consumerName,不是起始 ID(见 2.2 第 4 条)
var detail = db.StreamPendingMessages(key, "g", 10,
    consumerName: RedisValue.Null);

五条最容易写错的,逐条对照签名:

  1. StreamAutoClaim 的 minIdleTimeInMs 是 long 毫秒,写 TimeSpan 编译不过;容易混的是 StreamReadGroup 的 claimMinIdleTime,那个才是 TimeSpan?(8.4 的 CLAIM)。
  2. StreamReadGroup 没有 blockMilliseconds,同步签名是 (key, groupName, consumerName, position, count, noAck, claimMinIdleTime, flags),要用 BLOCK 只能 db.Execute 或退避轮询(4.1)。
  3. StreamAutoClaimResult 三段:NextStartId / ClaimedEntries / DeletedIds;只处理中间那段会漏幽灵 ID,忽略游标则每轮重扫同一批。
  4. StreamPendingMessages 第 4 参是 consumerName,传消息 ID 查的是「该消费者名下」的 PEL、结果往往为空;按 ID 过滤用 minId / maxId,带 minIdleTimeInMs 的重载 2.8.16 上 CS1739。
  5. StreamCreateConsumerGroup 返回 bool、position 是 RedisValue?,组已存在抛 BUSYGROUP;判空写 entry.Values == null || entry.Values.Length == 0,别依赖 StreamEntry.IsNull(各版本实现不一致)。

3. 三个数据结构

3.1 rax 与 listpack

Stream 用一棵 rax(基数树) 按 ID 有序索引,每个节点挂一个 listpack 打包一小批连续消息:rax 负责定位,listpack 负责省内存。

  • 单个 listpack 到 stream-node-max-bytes(默认 4096 B)或 stream-node-max-entries(默认 100 条)就分裂,所以 radix-tree-keys 随条数增长;近似裁剪 ~ 正是靠整节点回收,这是它比精确裁剪快得多的原因(5.3 量化)。
  • 结构相同的消息,字段名在 listpack 里只存一次,所以消息同构、字段名短且稳定本身就是省内存手段。

3.2 ID 是时间索引,但不是业务时间

ID 首段是毫秒,rax 又按 ID 有序,所以 XRANGE <毫秒> + 不需要额外索引。两个陷阱:时钟回拨时 ID 会「停在未来」(Redis 为保单调沿用上一条的毫秒段并递增序号,按当前时间换算 ID 可能一条都查不到);ID 由实例生成,不是业务发生时间,补传、重试、跨机房延迟都会错位。要精确时间窗就在消息里存 eventTime 由消费侧过滤,MINID 只兜底控容量。

3.3 PEL:状态机、计数与幽灵

一条消息在某个组里的生命周期,「acked」只是移出该组 PEL,本体仍在流里直到被裁。

XADD
  -> new(流里的新消息,尚未投递给该组)

XREADGROUP GROUP g c COUNT 10 STREAMS key >
  -> delivered(进入 c 名下 PEL;位点推进;投递计数 = 1)
       |
       +-- XACK --------------------> acked(移出 PEL,该组完成)
       |
       +-- 崩溃 / 忘记 ACK ---------> 长期 pending(> 读不到,需别人认领)
       |
       +-- XREADGROUP 0 ------------> 重读自己 PEL 历史(投递计数 +1)
       |
       +-- XCLAIM / XAUTOCLAIM -----> claimed(换归属,仍 in-flight,计数 +1)
       |
       +-- 消息体被 XTRIM / XDEL --> 幽灵引用(7.0+ 认领时自动清 PEL)
       |
       +-- XGROUP DELCONSUMER ------> PEL 记录被直接删除(该组永久丢失)

PEL 按组维护、按消费者归属(同一流被两组消费各有独立 PEL,组内一条消息只有一个归属者);delivered 不等于 consumed,只有 XACK 才算完成;pending 不会自动重投递,这是与 RabbitMQ 最大的差别,也是 Janitor 存在的全部理由。

PEL 不存消息体,只存 ID、归属消费者、上次投递时间、投递计数,所以膨胀要单独算内存(实测约 170 B/条,比常被引用的「几十字节」高一截)。消息体一旦被裁,PEL 就成了指向空处的引用。

投递计数的四个来源与一个例外:

3.png

启动恢复最容易踩的坑:用 while 反复 XREADGROUP ... 0 且失败不 ACK,每轮给同一批 +1,几轮就无辜触发「重试 5 次进死信」。记住投递计数是「交到消费者手上几次」,不是「业务失败几次」(4.4)。

两个「看着能用其实无效」的写法:只要 ID 不是 >,BLOCK 与 NOACK 都被忽略(实测显式 ID 配 NOACK 重读历史,计数照样 1 → 2;配 BLOCK 2000 在 30 余毫秒内返回),所以「用 0 做恢复再加 BLOCK 省轮询」是无效写法;CLAIM 是另一回事,它是 8.4 的服务端参数,7.4 连语法都没有,客户端的 claimMinIdleTime 是库自己补发 XCLAIM、不受 position 影响。

幽灵 ID 的两条读取路径表现完全不同:

4.png

两个观测口径要钉住:lag 与 pending 独立,且 lag 是 entries_added - entries_read 的启发式结果、可能为 NULL——用任意 ID 建组、位点与末端之间有消息被删都会失效。entries-read 是「位点在写入序列里的位置」而非「本组投递过多少条」:实测被裁到 100 条的流(entries-added 已 200)上组从 0 建、只投 5 条,报 105 与 lag 95——别拿它对账。

4. 消费可靠性

这章是交付主体:从「读懂命令」到「崩溃后不丢消息」。下面三段 C# 是同一个消费者类的成员片段,约定类里已有 IDatabase db、RedisKey key、string group、ILogger logger、失败计数组件 failureCounter 与 MaxBusinessFailures / HardCeiling 两个常量。

4.1 主消费循环

四件事:批量读 >、逐条处理、空结果退避、取消时停止拉取。ACK 与死信统一在 ProcessSafelyAsync 收口。

using StackExchange.Redis;
using Microsoft.Extensions.Logging;

// 主循环只做两件事:批量读、逐条安全处理;ACK 与死信统一在 ProcessSafelyAsync 收口
private async Task ConsumeLoopAsync(IDatabase db, RedisKey key, string group,
                                    string consumer, CancellationToken ct)
{
    // ct 触发后不再开新一轮;未处理的消息留在 PEL,交给 Janitor
    while (!ct.IsCancellationRequested)
    {
        try
        {
            // 一次取 32 条减少往返;position 用 > 即只取未投递的
            var entries = await db.StreamReadGroupAsync(
                key, group, consumer, StreamPosition.NewMessages, count: 32);

            if (entries.Length == 0)
            {
                // 空结果退避,避免空轮询打满 CPU 与 Redis
                await Task.Delay(TimeSpan.FromMilliseconds(200), ct);
                continue;
            }

            foreach (var entry in entries)
            {
                // 取消后不再处理新读到的消息:未处理的留在 PEL,等 Janitor
                if (ct.IsCancellationRequested) break;

                // 防御性判空:改读历史会拿到 nil 消息体,届时不要跑业务(3.3)
                if (entry.Values == null || entry.Values.Length == 0)
                {
                    await db.StreamAcknowledgeAsync(key, group, entry.Id);
                    continue;
                }

                // 单条处理:成功即 ACK;失败留在 PEL 并累计应用侧失败次数(4.4)
                await ProcessSafelyAsync(entry);
            }
        }
        catch (OperationCanceledException) when (ct.IsCancellationRequested)
        {
            // 正常退出
            break;
        }
        catch (RedisException ex)
        {
            // 连接抖动 / 主从切换:退避重试,别把异常抛到外层让进程退出
            logger.LogError(ex, "stream read failed, backing off");
            // 这条退避故意不带 ct:停机时最多多等 1 秒
            await Task.Delay(TimeSpan.FromSeconds(1));
        }
    }
}

三条优雅退出规则:取消后只停拉取,不补 ACK——已读到没处理完的留在 PEL 由 Janitor 认领,为「优雅」把它们 ACK 掉就是真丢;8.8 起可用 XNACK ... SILENT 立即释放回 PEL,省掉等 min-idle-time;调用方 cts.Cancel() 后 await loop 即可。

BLOCK 要单独说:.NET 的 StreamReadGroup 没这个参数,只能 db.Execute,且必须处理三件事——BLOCK 0 会占住连接,要给它独立的多路复用器;毫秒数要小于客户端、代理与负载均衡的空闲超时;取消语义比轮询复杂,服务端可能已写入 PEL 而客户端没收到回复,只能靠 Janitor 找回。除非对尾部延迟真的敏感,用轮询版就够。

4.2 启动恢复与一条会静默丢消息的红线

「启动时循环 XREADGROUP ... 0 读到空为止」有两个问题:每轮递增投递计数,只要有失败消息就永不退出。推荐每次启动用唯一消费者名,恢复全交给 Janitor:

// 进程级唯一:重启后是新名字,旧名字留下的 PEL 由 Janitor 按 idle 统一认领
// 三段分别是机器名(定位实例)、进程号(区分同机多进程)、GUID(区分重启)
string consumer = $"{Environment.MachineName}-{Environment.ProcessId}-{Guid.NewGuid():N}";

好处是启动即进主循环,没有恢复阶段、也不会反复读历史推高计数;代价是未 ACK 消息要等一个 min-idle-time 才被接手。

清理历史消费者名是一条会静默丢消息的红线:别因为 XINFO CONSUMERS 里名字变多就定期跑 XGROUP DELCONSUMER。官方原文是「any pending messages that the consumer had will become unclaimable after it was deleted」,src/t_stream.c 的 streamDelConsumer() 对该消费者每条 PEL 执行 raxRemove + streamFreeNACK,即从组的全局 PEL 彻底删除:消息体还在,但位点已推进、> 不再投递、XAUTOCLAIM 也扫不到,该组永久丢失且无告警。这套「唯一名」方案正依赖旧名字的 PEL 被认领,清理任务抢在 Janitor 之前跑一次那批消息就没了;最安全的是根本不删。

确实要删时顺序不能反:先 XAUTOCLAIM 或 XACK,用 XINFO CONSUMERS 确认 PendingMessageCount == 0,再用 StreamPendingMessages(..., consumerName: oldConsumer) 二次确认(只能收窄竞态),最后才 StreamDeleteConsumerAsync,且只清「已下线且 idle 远超阈值」的名字。

坚持固定名的话,恢复只扫一次:StreamPendingMessages 只拿 ID(不递增计数),用首末条 ID 作 StreamRange 范围取回本体,count 留余量(区间里可能夹着别人已 ACK 的消息),再用 HashSet 只处理自己名下的。这条路天然跳过幽灵 ID。

4.3 Janitor:常驻、游标迭代、单实例

// 必须大于业务处理耗时的 P99,建议取其 3 到 5 倍
private const long IdleThresholdMs = 60_000;
// 单次认领上限:太大拉长单轮,太小多扫很多轮
private const int ClaimBatch = 100;
// 两轮间隔:决定崩溃后多久被接管
private static readonly TimeSpan SweepInterval = TimeSpan.FromSeconds(15);

// Janitor:常驻单实例,用游标把整个 PEL 扫一遍,认领超时未 ACK 的消息
private async Task JanitorLoopAsync(IDatabase db, RedisKey key, string group,
                                   string janitorConsumer, CancellationToken ct)
{
    while (!ct.IsCancellationRequested)
    {
        try
        {
            // 游标从 0-0 起,必须用 NextStartId 续扫,否则重复首批
            RedisValue cursor = "0-0";
            do
            {
                var result = await db.StreamAutoClaimAsync(
                    key, group, janitorConsumer,
                    minIdleTimeInMs: IdleThresholdMs,
                    startAtId: cursor,
                    count: ClaimBatch);

                // 第一段是下一轮游标
                cursor = result.NextStartId;

                foreach (var entry in result.ClaimedEntries)
                {
                    // ProcessSafelyAsync 内部统一收口 ACK / 死信
                    await ProcessSafelyAsync(entry);
                }

                foreach (var ghost in result.DeletedIds)
                {
                    // 消息体已被裁剪:记审计,只清 PEL
                    logger.LogWarning("stream ghost id {Id} cleaned from PEL", ghost);
                    await db.StreamAcknowledgeAsync(key, group, ghost);
                }
            }
            // 扫完一轮或收到停机信号就退出内层循环
            while (cursor != "0-0" && !ct.IsCancellationRequested);
        }
        catch (OperationCanceledException) when (ct.IsCancellationRequested)
        {
            break;
        }
        catch (Exception ex)
        {
            // 单轮失败不能让常驻任务退出
            logger.LogError(ex, "janitor sweep failed");
        }

        // 固定间隔再来一轮;停机时立刻跳出,不再等满一轮
        try { await Task.Delay(SweepInterval, ct); }
        catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; }
    }
}

五条约束:游标用 result.NextStartId;用独立消费者名;min-idle-time > P99 处理耗时,否则处理中的消息会被抢走;单实例或选主(并发认领本身原子,但会抢 idle 边缘消息、放大重复执行,还让存活监控失真);Janitor 自己要被监控(认领数、幽灵数、单轮耗时、心跳)。任何认领都带来重复,业务幂等兜底。

XAUTOCLAIM 还有两处容易写错:游标是「下一个待扫描的 PEL 位置」不是「下一条消息 ID」,扫完回 0-0;COUNT 是尝试认领上限(默认 100,内部最多扫 COUNT x 10 条)。XCLAIM 的 FORCE 与 LASTID 给 AOF / 复制传播用,业务别碰。7.0 以下不自动清幽灵 ID,只能 XACK 手工清。

高版本能省掉 Janitor 吗?不能全免。8.4 的 XREADGROUP ... CLAIM 在读 > 时顺带认领 idle 最久的 PEL,但只有「有人持续读 >」才触发,整组停摆时没人调用;且 COUNT 是总回复上限(官方例子:20 条 idle + 200 条新消息,COUNT 100 返回 20 + 80)。8.8 的 XNACK 把消息释放回 PEL:SILENT 计数减 1(适合优雅退出)、FAIL 保持不变、FATAL 置 LLONG_MAX(毒消息)。它们能缩短恢复延迟,但「独立巡检 + 挂了能告警」的职责仍在。

4.4 死信:投递计数不是重试次数

failureCounter 是应用侧组件(去重表或 Redis Hash),按业务主键记真实失败次数;返回 true 表示已收口(成功已 ACK,或已写死信并 ACK)。

// 统一收口:成功 → ACK;失败 → 计数,到阈值 → 写死信再 ACK
private async Task<bool> ProcessSafelyAsync(StreamEntry entry)
{
    try
    {
        // 内部按 orderId + eventType 幂等
        await HandleAsync(entry);
        // 先成功再 ACK:顺序反了会在崩溃时丢消息
        await db.StreamAcknowledgeAsync(key, group, entry.Id);
        return true;
    }
    catch (Exception ex)
    {
        // 按 ID 精确查这条的投递计数;第 4 参是 consumerName,传 Null 表示不限消费者
        var detail = await db.StreamPendingMessagesAsync(key, group, 1,
            consumerName: RedisValue.Null, minId: entry.Id, maxId: entry.Id);

        // delivery-count 只作上限保护,真正的失败次数由应用侧计数
        int delivered = detail.Length == 0 ? 0 : detail[0].DeliveryCount;
        int businessFailures = await failureCounter.GetAsync(entry["orderId"], entry["eventType"]);

        if (businessFailures >= MaxBusinessFailures || delivered >= HardCeiling)
        {
            // 顺序不能反:先成功写死信,再 ACK 原消息
            await db.StreamAddAsync("order:events:dlq", new NameValueEntry[]
            {
                // 原始 ID:死信按它去重,避免同一条消息重复入死信
                new("originalId", entry.Id.ToString()),
                // 拍平成字符串:排查不必回原流取
                new("payload", string.Join(";", entry.Values.Select(v => $"{v.Name}={v.Value}"))),
                // 最后一条异常信息;完整堆栈进日志,不进死信
                new("error", ex.Message),
                // 应用侧累计失败次数
                new("failures", businessFailures.ToString()),
                // UTC 时间:跨时区排查不要用本地时间
                new("deadAt", DateTimeOffset.UtcNow.ToString("O"))
            }, maxLength: 100000, useApproximateMaxLength: true);

            await db.StreamAcknowledgeAsync(key, group, entry.Id);
            return true;
        }

        // 未到阈值:累加一次失败,然后留在 PEL 等 Janitor 重新认领
        await failureCounter.IncrementAsync(entry["orderId"], entry["eventType"]);
        // 留在 PEL,等 Janitor 认领
        return false;
    }
}

四个容易出错的点:投递计数含「读历史」与「认领」,不等于失败次数,只看它会把消息过早送进死信;先写死信、再 ACK,反序时写死信失败而原消息已确认就真丢,正序最坏是死信重复(用 originalId 去重);死信流同样 MAXLEN ~ 并配重放入口,重放是往主流 XADD 而非改原 ID;幽灵 ID 不写死信,只记审计并清 PEL。

4.5 保序、outbox 与多组

单流有序不等于处理有序:两条命令都按 ID 顺序,但多消费者并行时完成顺序不保证——created 还在 A 进程跑,paid 已被 B 写库;Janitor 认领还会把旧消息交给另一进程。三种方案通常要组合:

5.png

单进程实现时注意 finally 释放闸门(否则该 key 永久卡死),字典也不会自动回收,得按最后访问时间清理。幂等防重复,保序防乱序,不是同一个问题。

生产端原子性得自己补:先写库再 XADD 会丢消息,先 XADD 再写库会丢记录。不能丢的业务用事务性 outbox:业务事务内同时写业务表与 outbox 行,投递任务按 sent_at IS NULL 取、XADD 成功后 UPDATE sent_at,消费侧按 aggregate_id + event_type 幂等。取舍很明确:XADD 成功但 UPDATE 失败会重投,由幂等吸收;不能先标 sent 再 XADD。

多组共享一份数据时(各建一组,各自位点与 PEL),三条边界:组间看到的是同一份数据,「某组只看特定类型」只能写消费侧过滤或拆流;组数与 PEL 元数据线性相关;慢组拖累保留策略——MAXLEN 是整流的,一个组掉线一周,裁剪就把它的消息剪了。8.2 起的 XACKDEL ... ACKED 把「按确认回收」交给服务端:全组确认返回 1 并删除,仍有组未确认返回 2,再配定时 XTRIM ... ACKED 兜底。但 ACKED 不改变裁剪目标长度(官方原文:引用条目数超过 MAXLEN 时裁剪仍停在限额),不能取消 5.3 的阈值规划;某组永久下线 = 流不再回收。

4.6 监控、持久化与三个场景

6.png

阈值别抄别人的数:先记一周基线再设告警。usec_per_call 是平均耗时,不能当 P99,尾部延迟看 SLOWLOG。

异步复制下 XADD 成功只代表主库写入完成,主库宕机且该写未同步时消息消失,WAIT 只能降低概率;AOF 的 everysec 有一秒窗口;组状态随 key 一起复制,切换后 PEL 通常还在,但「主库已 ACK、副本未同步」的窗口会让消息重投一次;只读副本不能承载消费者组。可靠性是「不丢已投递消息的追踪状态」,不是「不丢消息本身」。

三个场景压成三句:订单事件要幂等 + 保序 + outbox + 死信,MAXLEN 按大促峰值算(按平时速率定会剪掉慢消费者的消息);站内通知按用户拆 key 天然有界、XREVRANGE + - 取最新 N 条,「已读」另用 Hash / Set;物联网遥测允许丢旧数据就 MAXLEN ~ 足够,逐条 XADD 往返太大要网关侧聚合后批量写,浮点转字符串必须指定 InvariantCulture(否则某些区域输出 23,5)。

5. 容量、性能与版本

5.1 四块构成与差值法

# 单 key 的内存构成(量级参考,口径见下)
单条消息 = 固定开销(约 40 到 80 B:rax 分摊 + 16 B ID + listpack 元数据)
         + sum(字段名字节 + 值字节 + 每项约 1 到 3 B 编码)

# 整个 key = 消息体 + 组元数据 + PEL + 余量;余量覆盖复制缓冲、碎片与 dict 扩容
整个 key = 单条消息 x 当前条数
         + 各消费者组 x (约 100 B + 消费者数 x 常数)
         + 未确认条数 x 约 150 到 200 B(PEL 记录,实测值)
         + 余量(复制缓冲、内存碎片、dict 扩容,按 1.2 到 1.5 倍预留)

这些是量级估算,随版本、字段名长度、SDS 对齐、listpack 编码浮动。做容量决策必须实测,但别只信 MEMORY USAGE:7.4.2 上同一个 1000 条的小流,MEMORY USAGE ... SAMPLES 0 报约 39 KB,而 used_memory 差值折算约 130 KB,差 3 倍多——它只算到 listpack 那部分,rax 节点与分配器开销没进去。

可用口径是差值法:造数前后各取一次 used_memory 相减除以条数得单条开销;再对同一批消息全量投递不 ACK 后取差值,得 PEL 单条开销(实测约 170 B/条)。PEL 记录与业务字段无关,这一项可跨业务复用。

5.2 两组演算

演算 A:100 万条订单事件。 按实测口径(3 字段小消息)写入侧约 130 B/条,100 万条约 130 MB;一个组挂 5 万条未确认时 PEL 额外约 8.5 MB;加 1.3 倍余量按约 180 MB 规划。字段更多、值更长按字节线性加。

演算 B:日增一千万条行为事件。 每条平均 200 B,不裁剪每天约 2 GB,留 7 天约 14 GB;按「留 3 天」反推常驻约 3000 万条、约 6 GB,已远超单实例合理容量——这个量级不该放 Redis,或必须按时间桶拆 key 把老桶外置。

PEL 是第二杠杆:100 万条全未 ACK 时额外约 170 MB,比消息体还贵,靠 ACK 与 Janitor 控制。结论:MAXLEN 是第一杠杆,PEL 第二。

5.3 保留阈值与 ~ 的精度

Stream 只追加,不裁剪一定涨到内存上限,修剪不是优化,是生存必需。

# 保留条数:按峰值速率算,不要按平时
保留条数 N >= 峰值写入速率(条/秒) x 最大允许消费延迟(秒) x 安全系数(2 到 3)
# 条数够了不代表窗口够:还要覆盖最慢组的停机时间与运维止血时间

裁剪不看 PEL:被裁掉但仍未 ACK 的消息变成幽灵引用,等于永久丢失。所以 N 不是「想留多少」,而是「最慢的消费者最久能掉线多久」的函数。

~ 的精度要量化:官方写「may have a few tens of additional entries over the threshold」,因为 ~ 只在能整块回收宏节点时才停手。实测默认 stream-node-max-entries 为 100,写 200 条配 MAXLEN ~ 100 后 XLEN 恰好 100——落在节点边界;不落边界时最多多留一个节点的量。LIMIT 只在近似裁剪时有意义,精确裁剪带 LIMIT 报 ERR Invalid stream ID specified as stream command argument(实测)。用 XLEN - N 监控超出量。

死信流同样要设界;不要用 EXPIRE 做保留期——整 key 过期会把消息、位点与 PEL 一起删掉。

5.4 可复现的实测方法

别引用别人的吞吐数字,用自己的 payload 和版本压一遍。下面两条一条造数、一条取内存,按业务字段替换即可。

# 造 10 万条:MAXLEN '~' 200000 压测期间也设上限,别把测试实例内存打满
# 内层逐条 XADD 是故意用最笨的写法,量出「单条往返」的成本下限
for i in $(seq 1 100000); do
  redis-cli XADD bench:stream MAXLEN '~' 200000 '*' type OrderCreated orderId "A${i}" eventTime 1762646400 > /dev/null
done
# 结构摘要:长度、节点数、首尾 ID
redis-cli XINFO STREAM bench:stream
# 内存差值才是可信口径,MEMORY USAGE 只作趋势参考
redis-cli INFO memory | grep used_memory:

记录时填实测值不要填预期值:版本与部署形态、payload 结构、条数与未确认条数、差值法折算的单条字节、SLOWLOG 尾部样本。redis-benchmark 覆盖有限,有状态命令用真实客户端压。

5.5 版本基线、大 key 与集群

按四个分界版本记就够:6.2.0 补齐 NOMKSTREAM / MINID / LIMIT、XGROUP CREATECONSUMER、XPENDING IDLE 与 XAUTOCLAIM(5.0 巡检只能 XPENDING + XCLAIM);7.0.0 给 XINFO 加 entries-added / entries-read / lag,并让认领返回幽灵 ID;7.2 / 7.4 前者改 idle 语义并加 inactive / active-time,后者给 XREAD 加 +;8.2 起 支持 KEEPREF / DELREF / ACKED,8.4 加 CLAIM,8.8 加 XNACK,8.6 加幂等生产(IDMP 解决生产者重复写入,与「消费者重复执行」是两件事)。写代码前先按版本筛一遍,标了更高版本的参数会直接报语法错。

一条容易读反的注释:XTRIM 页用一句话对比另两条命令(原文「Unlike the XDELEX and XACKDEL commands where one of these options is required, here they are optional」),容易让人以为那两条必须显式指定策略;查各自命令页可知三策略同样 Optional 且默认 KEEPREF,三条都不必指定。返回值也别混:XACKDEL ... ACKED 的 2 是「已确认但未删除」,XDELEX ... ACKED 的 2 还包括「没有消费者组」。

大 key 有四重代价:全量同步拉长 RDB 传输与切主窗口;Cluster 迁移 slot 长尾超时;RDB 序列化与 AOF 重写放大单次耗时;XINFO STREAM FULL COUNT 0、大范围 XRANGE、精确裁剪在主线程跑很久。XDEL 还是标记删除,只有整个宏节点都被标记才回收,零散 XDEL 会让流变碎——治理原则:尽早设界、按业务拆 key、全量扫描不进生产巡检。

6. 坑点与上生产

6.1 十二条坑点

7.png

另外几条:lag 为 NULL 是「用任意 ID 建组」或位点之间有消息被删(7.0+ 可用 ENTRIESREAD 补);XACK 返回 0 说明该 ID 不在这个组 PEL 里;XDEL 后内存不降、PEL 还有 ID,是标记删除 + 不清引用(8.2+ 用 DELREF);XREAD BLOCK ... $ 反复用 $ 会漏掉两次调用之间到达的消息。

6.2 两个复盘

以下数字均为合成案例,只说明故障链路。

复盘一:PEL 静默堆积。 pending 从 0 涨到 12 万、lag 正常、业务无报错。根因是成功路径调了 XACK,异常分支直接 return,失败消息留在 PEL,且没有 Janitor。修复是把 ACK 收口到结果判定之后,补巡检与 pending 告警,用幂等表兜住重复。

复盘二:裁剪吃掉慢消费者的消息。 离线分析组每周一出现数据缺口,DeletedIds 非零。根因是 MAXLEN 按平时速率定值,周末停机维护超过保留窗口。修复是按峰值速率与最长停机时间重算 N,给慢组拆流,幽灵 ID 数设为强告警。

6.3 十二项清单

契约与生产端:确认服务端版本并核对参数起始版本(6.2 / 7.0 / 7.2 / 8.2);明确丢失容忍度并让业务方签字;生产者一律 XADD ... *,消息带业务主键、事件类型、eventTime 与版本号,结构同构、大对象外置;关键链路用 outbox。

消费端:组只在部署期建一次并捕获 BUSYGROUP;每进程用唯一 consumer 名;主循环批量读 >、空结果与异常都退避;幂等靠数据库唯一约束,ACK 只在成功路径收口;按主键保序并留版本校验兜底。

接管与死信:禁止把 XGROUP DELCONSUMER 当常规清理;Janitor 常驻且单实例选主、用 NextStartId 游标、阈值按 P99 留 3 到 5 倍;DeletedIds 记审计并清 PEL,幽灵数非 0 即视为窗口不足;死信判定用应用侧计数,先写死信再 ACK 并按 originalId 去重。

容量与部署:按 4.6 的七项指标建告警并区分 NULL 与 0;用 idle 判存活,不按 inactive(从未活动返回 -1);组命令走主库并授予 @write;Cluster 下确认单 slot 归属,用附录 B 在新环境复跑一遍。

附录 A. 五个最容易被中文资料带偏的说法

论证都在正文里(4.2、4.6、3.3、4.3、5.5),本表只做索引。读过正文就把它当速查,别当独立论据。

8.png

附录 B. 可判定验证脚本

在 redis-cli 交互模式下粘贴,避免 shell 把 > 当重定向符;开头 DEL 保证可重复执行,2.1 的示例是本脚本主干四步。

# 步骤 1:清空现场,保证可重复执行
DEL demo:stream
# 步骤 2:显式 ID 写入第一条
XADD demo:stream 1000-0 type created orderId A1
# 步骤 3:显式 ID 写入第二条,必须大于已有最大 ID
XADD demo:stream 1001-0 type paid orderId A1
# 步骤 4:建组并从 0 起,历史两条都算未投递
XGROUP CREATE demo:stream g 0
# 步骤 5:> 投递两条,进入 w1 的 PEL,投递计数 = 1
XREADGROUP GROUP g w1 COUNT 10 STREAMS demo:stream >
# 步骤 6:PEL 明细,两条、计数为 1
XPENDING demo:stream g - + 10
# 步骤 7:用 0 读自己 PEL 历史,不推进位点
XREADGROUP GROUP g w1 COUNT 10 STREAMS demo:stream 0
# 步骤 8:计数变成 2 —— 读历史也会 +1
XPENDING demo:stream g - + 10
# 步骤 9:精确裁剪到 1 条,删掉较老的 1000-0,制造幽灵引用
XTRIM demo:stream MAXLEN 1
# 步骤 10:再认领,7.0+ 把幽灵 ID 从 PEL 清出并放在第三段;末尾清理现场
XAUTOCLAIM demo:stream g w2 0 0-0 COUNT 10
DEL demo:stream

9.png