1. 项目概述:Redis Stream不是“队列增强版”,而是时间序列事件总线
Redis Stream 是 Redis 5.0 引入的原生数据结构,但它常被误读为“比 List 更可靠的队列”或“轻量级 Kafka 替代”。这种理解偏差直接导致大量线上项目在消息保序、消费确认、重放能力等关键环节踩坑。我参与过三个不同规模的实时事件处理系统重构——从某电商订单履约链路的异步解耦,到某IoT平台设备心跳聚合,再到某内容中台的用户行为埋点分发——全部用 Stream 替代了早期基于 Pub/Sub + Lua 脚本的手动 ACK 机制。实测下来,Stream 的核心价值根本不在“消息不丢”,而在于它天然支持按时间戳精确寻址、多消费者组并行消费、无状态消费者容错重拉、以及毫秒级窗口回溯。这四个能力组合起来,构成了一条真正可审计、可调试、可灰度的事件总线。
标题里“如何遍历”这个说法本身就有误导性。Stream 不是数组,不能像for i in range(len(stream))那样线性遍历;它也不是日志文件,不能tail -f实时追加读。它的“遍历”本质是时间轴上的游标移动:你指定一个起始 ID(可以是0-0、$、某个具体时间戳 ID,甚至1623456789000-0),然后向后拉取 N 条,再把新拿到的最后一条 ID 当作下一次请求的起点。整个过程不依赖客户端维护偏移量,而是由 Redis 服务端通过XREADGROUP的>符号或XREAD的ID参数隐式管理。这也是为什么很多新手写完XREAD STREAMS mystream $就卡住——他们以为$是“读最新”,其实$是“从当前最新开始监听”,它不会返回历史数据,只阻塞等待新消息到来。真正的“遍历历史”必须用XREAD STREAMS mystream 0-0或配合COUNT限流分页。
关键词“redis 流 stream”在实际工程中往往对应三类典型需求:第一类是事件归档与离线分析,比如每天凌晨把过去24小时所有支付成功事件导出到数仓;第二类是消费者组故障恢复,比如某消费节点宕机3小时,重启后需要精准补消费这期间漏掉的所有消息;第三类是调试与审计,比如运营反馈“用户A在14:23:15点击了按钮但没触发推送”,你需要快速定位该时间点前后5秒内所有相关事件。这三类场景对“遍历”的要求截然不同:归档要全量、高吞吐;恢复要精准、低延迟;调试要灵活、可跳转。本文所有实操细节和参数选择,都围绕这三类真实诉求展开,不讲抽象概念,只说怎么在命令行、Python、Java 里写出能上线的代码。
2. 核心设计逻辑:为什么 Stream 的遍历必须分“单次拉取”和“持续消费”两种模式
2.1 单次拉取:解决“我要看过去N条”的刚性需求
单次拉取的本质是快照式查询,适用于离线任务、问题排查、数据核对等场景。它的设计逻辑非常朴素:你告诉 Redis “我要从某个位置开始,拿最多M条”,Redis 返回结果后连接就断开。这里的关键约束是:不能无限拉取,必须设上限。因为 Stream 是内存+磁盘混合存储,单次请求若不限制COUNT,可能触发大范围扫描,拖慢整个实例。我们曾在线上遇到过一个错误脚本:XREAD STREAMS mylog 0-0没加COUNT,结果把一个存了3个月日志的 Stream 全部加载进内存,导致主从同步延迟飙升到2分钟。所以所有单次拉取操作,必须遵循“三定原则”:定起点、定数量、定超时。
起点选择有明确优先级:
- 调试场景首选绝对时间戳 ID,例如
1623456789000-0(对应 2021-06-12 14:26:29.000),这是最精准的定位方式。Redis Stream ID 的格式是毫秒时间戳-序号,前13位就是 Unix 时间戳毫秒值,你可以用date -d "2021-06-12 14:26:29" +%s%3N快速转换。 - 归档场景用
0-0,表示从流最开头拉取,但必须搭配COUNT 1000,避免 OOM。 - 恢复场景用消费者组已知的 pending ID,例如
XPENDING mystream mygroup - + 10查到的某个 ID,直接XREAD STREAMS mystream <id>即可续读。
提示:
XREAD命令的BLOCK参数对单次拉取无效。BLOCK 0只在XREAD无数据时永久阻塞,而单次拉取的目标是“拿完就走”,所以一律不加BLOCK。如果想实现“有数据就读,没数据就退出”,直接用默认非阻塞模式即可。
2.2 持续消费:解决“我要一直听着,新来一条处理一条”的长连接需求
持续消费才是 Stream 的主战场,它对应的是生产环境中的消费者服务。这里的设计逻辑核心是状态分离:消息存储在 Stream 里,消费进度保存在消费者组(Consumer Group)的PEL(Pending Entries List)中,两者完全解耦。这意味着你可以随时启停消费者,甚至换语言重写消费者逻辑,只要组名和消费者名不变,Redis 就能自动续上上次中断的位置。这种设计彻底规避了传统队列中“消息被取走就消失,宕机即丢失”的风险。
但持续消费的陷阱在于“确认机制”。很多开发者以为XREADGROUP GROUP mygroup myconsumer STREAMS mystream >中的>就是自动 ACK,其实>只表示“读取尚未分配给任何消费者的最新消息”,读到的消息会立刻进入该消费者的 PEL,但不会自动从 PEL 移除。必须显式调用XACK mystream mygroup <id>才算真正消费完成。我们有个项目曾因忘记写XACK,导致 PEL 积压了20万条未确认消息,消费者组监控显示pending持续上涨,但业务日志却一切正常——因为消息确实被读取并处理了,只是 Redis 认为它还在“待确认”状态,下次重启还会重推。所以持续消费的代码骨架必须是铁三角:XREADGROUP→ 处理业务逻辑 →XACK,缺一不可。
注意:
XACK是幂等操作,重复执行同一个 ID 不会报错。但XCLAIM(抢占其他消费者卡住的消息)必须谨慎,它会修改 PEL 中的 owner 字段,可能引发多消费者竞争。除非监控发现某消费者idle时间超过阈值(如30分钟),否则不要主动XCLAIM。
2.3 两种模式的底层差异:内存访问路径完全不同
单次拉取走的是streamIterator路径,Redis 直接在 Stream 的 radix tree 结构上做范围扫描,时间复杂度接近 O(log N + M),其中 N 是 Stream 总长度,M 是本次拉取条数。而持续消费走的是consumerGroup路径,Redis 先查该消费者在 PEL 中的最小未确认 ID,再从 Stream 中定位该 ID 对应节点,然后向后拉取。这个过程多了一层 PEL 查找,但好处是跳过了所有已确认消息的扫描,实际性能反而更稳定。我们做过压测:一个 100 万条消息的 Stream,单次XREAD STREAMS s 0-0 COUNT 1000平均耗时 8ms;而同个 Stream 的消费者组,XREADGROUP GROUP g c STREAMS s > COUNT 1000平均耗时 5ms,且波动极小。这是因为 PEL 本身是跳表(skiplist)结构,查找最小 ID 是 O(log P),P 是 pending 条数,通常远小于 N。
3. 实操要点拆解:从命令行到 Python,每一步都带避坑注释
3.1 命令行调试:用XRANGE和XREVRANGE快速定位,别只会XREAD
很多开发者一上来就XREAD,结果发现没数据就懵了。其实XRANGE才是调试的黄金命令。它不改变任何状态,纯只读查询,语法简单:XRANGE mystream start_id end_id [COUNT N]。start_id和end_id支持-(开头)和+(结尾),例如XRANGE mystream - + COUNT 5拿前5条,XRANGE mystream 1623456789000-0 + COUNT 10拿指定时间之后的10条。更关键的是,XRANGE支持REV变体:XREVRANGE mystream + - COUNT 5,它会倒序返回,特别适合“查最后5条”这种高频需求——不用先XLEN再算偏移量。
但XRANGE有个隐藏陷阱:它返回的是 ID 到字段的完整映射,不是纯 ID 列表。比如XRANGE mystream - + COUNT 1返回:
1) 1) "1623456789000-0" 2) 1) "field1" 2) "value1" 3) "field2" 4) "value2"如果你只想提取 ID 做后续处理,得用awk或jq解析。我们团队统一用这个 shell 函数:
get_stream_ids() { redis-cli XRANGE "$1" "$2" "$3" COUNT "$4" | \ awk '/^1\) / {id=$2; gsub(/"/, "", id); print id}' } # 用法:get_stream_ids mystream 0-0 + 100这个函数自动剥离引号和包装结构,输出干净 ID 列表,可直接用于XDEL或XACK。
提示:
XINFO STREAM mystream是必查命令,它返回 Stream 的元信息:length(当前长度)、radix-tree-keys(内存占用关键指标)、groups(关联的消费者组数)。如果length很大但radix-tree-keys很小,说明 Stream 经常被XTRIM,可能存在误删风险;如果groups为 0 但业务明明在用消费者组,说明组创建失败或名称拼错。
3.2 Python 实现:用redis-py的xread和xreadgroup方法,但必须手动处理阻塞超时
redis-py库对 Stream 的封装很直接,但文档没强调一个致命细节:xread方法的block参数单位是毫秒,不是秒。很多人写r.xread({'mystream': '$'}, block=1),以为阻塞1秒,其实是阻塞1毫秒,几乎立即返回空。正确写法是block=1000(1秒)或block=0(永久阻塞)。
以下是经过生产验证的 Python 持续消费模板:
import redis import time r = redis.Redis(host='localhost', port=6379, db=0) def consume_stream(): # 创建消费者组(如果不存在) try: r.xgroup_create('mystream', 'mygroup', id='0-0', mkstream=True) except redis.exceptions.ResponseError as e: if 'BUSYGROUP' not in str(e): raise consumer_name = f'worker_{int(time.time())}' # 动态命名,避免冲突 while True: try: # 拉取消息,阻塞1秒 messages = r.xreadgroup( 'mygroup', consumer_name, {'mystream': '>'}, # '>' 表示新消息 count=10, # 每次最多10条 block=1000 ) if not messages: continue # 无新消息,继续循环 # 解析消息:messages 是 [(stream_name, [(id, {fields})])] for stream_name, msg_list in messages: for msg_id, fields in msg_list: try: # 处理业务逻辑(此处省略具体实现) process_message(fields) # 确认消费 r.xack(stream_name, 'mygroup', msg_id) except Exception as e: # 处理失败:记录日志,但不要 ACK,让其他消费者重试 print(f"Process failed for {msg_id}: {e}") # 可选:XCLAIM 抢占,但需加锁防重复处理 except redis.exceptions.ConnectionError: print("Redis connection lost, retrying...") time.sleep(1) continue except Exception as e: print(f"Unexpected error: {e}") time.sleep(0.1) def process_message(fields): # 示例:处理订单事件 order_id = fields.get(b'order_id', b'').decode() status = fields.get(b'status', b'').decode() print(f"Processing order {order_id} with status {status}")这个模板的关键经验:
xgroup_create的mkstream=True必须加上,否则 Stream 不存在时会报错。consumer_name动态生成,避免多个进程用相同名字导致消息分配混乱。xack放在try内部,确保只有处理成功才确认。失败时不ACK,消息会留在 PEL 中,30分钟后(默认idle时间)被其他消费者XCLAIM。ConnectionError单独捕获,因为网络抖动是常态,不能让整个消费进程退出。
3.3 Java 实现:用 Lettuce 客户端,利用StatefulRedisStreamCommands的异步流式处理
Lettuce 是 Java 生态最推荐的 Redis 客户端,它对 Stream 的支持比 Jedis 更完善,尤其是异步和响应式编程。核心是StatefulRedisStreamCommands接口,它提供了xread、xreadgroup等方法的异步变体。
以下是一个 Spring Boot 风格的消费者配置:
@Configuration public class RedisStreamConfig { @Bean public RedisClient redisClient() { return RedisClient.create(RedisURI.create("redis://localhost:6379")); } @Bean public StatefulRedisStreamCommands<String, String> streamCommands(RedisClient client) { StatefulRedisConnection<String, String> connection = client.connect(); return connection.sync(); // 同步命令,简单直观 } } @Service public class StreamConsumerService { @Autowired private StatefulRedisStreamCommands<String, String> streamCommands; @PostConstruct public void initConsumerGroup() { // 创建消费者组,id="0-0" 表示从头开始 try { streamCommands.xgroupCreate("mystream", "mygroup", "0-0", true); } catch (Exception e) { if (!e.getMessage().contains("BUSYGROUP")) { throw e; } } } @Scheduled(fixedDelay = 1000) // 每秒轮询一次 public void pollMessages() { Map<String, StreamReadOptions> streams = new HashMap<>(); streams.put("mystream", StreamReadOptions.empty().count(10).block(1000)); List<StreamMessage<String, String>> messages = streamCommands.xreadgroup( "mygroup", "java-consumer-" + System.currentTimeMillis(), streams ); for (StreamMessage<String, String> msg : messages) { try { processMessage(msg.getBody()); // 确认消费 streamCommands.xack("mystream", "mygroup", msg.getId()); } catch (Exception e) { log.error("Process failed for {}", msg.getId(), e); // 不 ACK,让其他消费者重试 } } } private void processMessage(Map<String, String> fields) { String orderId = fields.get("order_id"); String status = fields.get("status"); log.info("Processing order {} with status {}", orderId, status); } }Lettuce 的关键优势在于它原生支持响应式(Reactive),如果项目用 WebFlux,可以用ReactiveRedisCommands实现真正的非阻塞消费:
// 响应式版本,无需 @Scheduled public Flux<StreamMessage<String, String>> reactiveConsume() { return redisReactiveCommands.xreadgroup( "mygroup", "reactive-consumer", Collections.singletonMap("mystream", StreamReadOptions.empty().count(10)) ).flatMapMany(Flux::fromIterable); }这样消费逻辑可以完全融入响应式链路,避免线程阻塞。
4. 核心参数详解:COUNT、BLOCK、ID的取值逻辑与性能影响
4.1COUNT参数:不是越大越好,要根据消息体积和网络带宽动态调整
COUNT控制单次拉取的最大消息条数,但它直接影响两个关键指标:内存峰值和网络传输时间。假设每条消息平均 2KB,COUNT=1000就意味着单次最多传输 2MB 数据。在千兆内网中这没问题,但如果消费者部署在云上,跨可用区调用,2MB 可能导致 RT 从 5ms 升到 50ms。我们线上实践得出的经验公式:
最优 COUNT = min(100, 1000000 / 单条消息平均字节数)即保证单次传输不超过 1MB。对于日志类小消息(<100B),COUNT=10000是安全的;对于含图片 Base64 的大消息(>10KB),COUNT=100更稳妥。
另一个陷阱是COUNT与BLOCK的组合。当BLOCK > 0时,COUNT是“最多拉取条数”,不是“必须拉满”。例如XREADGROUP GROUP g c STREAMS s > COUNT 100 BLOCK 5000,如果5秒内只来了3条消息,Redis 也会立即返回这3条,不会等到100条。这点和 Kafka 的max.poll.records不同,Kafka 会严格等够数量或超时,而 Redis Stream 更“佛系”。
注意:
XLEN mystream返回的是当前 Stream 长度,但它不等于XINFO STREAM mystream中的length字段。XLEN是 O(1) 的近似值(基于 radix tree 的 key 数),而XINFO的length是精确计数,但需要遍历。高并发写入时,XLEN可能比真实值少几条,但误差可控。
4.2BLOCK参数:超时时间设置不当,会导致消费延迟或资源浪费
BLOCK是持续消费的生命线,但它不是越长越好。BLOCK=0(永久阻塞)看似高效,实则危险:一旦 Redis 连接闪断,客户端会永远卡在read()系统调用上,无法响应任何信号。我们曾因此导致一个消费者进程僵死,监控告警失灵。所以生产环境必须设有限超时,推荐BLOCK=1000(1秒)。
但 1 秒也有问题:如果消息产生频率是每 500ms 一条,那么BLOCK=1000会导致每次拉取后都要空等最多 1 秒,平均延迟 500ms。这时应该用BLOCK=500,让消费节奏跟上生产节奏。更智能的做法是动态调整BLOCK:初始设为 100ms,如果连续 3 次拉取都拿到满COUNT条,说明消息洪峰来了,BLOCK加倍;如果连续 3 次都只拿到 1 条,说明流量低谷,BLOCK减半。这个逻辑在 Python 模板里很容易实现,只需维护一个current_block变量。
4.3ID参数:$、>、0-0、时间戳 ID 的语义差异与适用场景
ID是 Stream 遍历的灵魂,不同取值决定数据流向:
$:监听模式。只返回ID > 当前最大 ID的新消息,不返回历史。适用于实时告警、监控大屏等“只关心最新”的场景。XREAD STREAMS s $是最常用的监听命令。>:消费者组新消息模式。等价于$,但仅用于XREADGROUP。它确保每个消息只被组内一个消费者获取,是负载均衡的基础。0-0:全量模式。从 Stream 开头拉取,适用于首次初始化、数据迁移、全量归档。必须配COUNT,否则风险极高。1623456789000-0:精确时间模式。最强大的定位方式,适用于审计、调试、按时间切片分析。注意:ID 是字符串比较,1623456789000-0会匹配所有1623456789000-*的消息,包括1623456789000-1、1623456789000-2等。
一个经典误区是认为XRANGE s 1623456789000-0 +能拿到“1623456789000 这一秒内的所有消息”,其实不能。因为1623456789000-0是该毫秒的第一条,+是无穷大,所以它拿的是该毫秒及之后所有消息。要拿精确一秒,得用XRANGE s 1623456789000-0 1623456789999-18446744073709551615(18446744073709551615是 Redis 内部最大序号)。但更实用的方法是用XREVRANGE倒查:先XREVRANGE s + - COUNT 1拿到最后一条 ID,再解析其时间戳,反推起始 ID。
5. 常见问题与实战排障:从“读不到消息”到“消息重复”,全是血泪教训
5.1 问题速查表:高频故障现象、原因与解决方案
| 现象 | 可能原因 | 快速验证命令 | 解决方案 |
|---|---|---|---|
XREAD STREAMS s $一直阻塞,无返回 | Stream 为空,或生产者没写入 | XLEN s查长度;XRANGE s - + COUNT 1查是否有数据 | 检查生产者逻辑,确认XADD是否成功执行 |
XREADGROUP GROUP g c STREAMS s >返回空,但XRANGE能查到数据 | 消费者组未创建,或id参数错误 | XINFO GROUPS s查组是否存在;XINFO STREAM s看groups字段 | 用XGROUP CREATE s g 0-0 MKSTREAM创建组 |
消费者处理完消息却不生效,XPENDING显示大量 pending | 忘记XACK,或XACK的 ID 写错 | XPENDING s g - + 10查 pending 列表;对比XACK参数 | 在业务处理成功后,严格调用XACK s g <id> |
| 同一消息被多个消费者重复处理 | XCLAIM被误用,或消费者名重复 | XPENDING s g看idle和delivery-count字段 | 确保消费者名唯一;XCLAIM前先检查idle是否超阈值 |
XLEN s返回 0,但XINFO STREAM s显示length > 0 | Stream 被XTRIM或XDEL删除了部分消息 | XINFO STREAM s看first-entry和last-entry | 检查是否有定时XTRIM任务,调整MAXLEN策略 |
5.2 “读不到消息”的深度排查:从网络到权限的七层穿透
这是最常被问的问题。我们总结了一个七步排查法,覆盖从物理层到应用层:
第一步:确认 Redis 连接正常redis-cli PING返回PONG,且redis-cli INFO replication看role:master,排除主从切换导致的写入到从库。
第二步:确认 Stream 存在且有数据redis-cli EXISTS mystream返回1;redis-cli XLEN mystream返回大于 0 的数字。如果EXISTS返回 0,说明XADD根本没执行,检查生产者代码。
第三步:确认XADD的 key 名和XREAD一致
大小写敏感!XADD MyStream和XREAD STREAMS mystream是两个不同 Stream。用redis-cli KEYS "my*"查看所有匹配 key。
第四步:确认消费者组已创建且id正确redis-cli XINFO GROUPS mystream输出应包含name: mygroup。如果为空,运行redis-cli XGROUP CREATE mystream mygroup 0-0 MKSTREAM。
第五步:确认XREADGROUP的>符号位置正确
必须是XREADGROUP GROUP mygroup myconsumer STREAMS mystream >,>是独立参数,不能写成STREAMS mystream >或STREAMS mystream ">"。
第六步:确认客户端没有缓存旧连接
某些 SDK(如早期 Jedis)会复用连接,如果之前连过其他 Redis 实例,可能命令发错地方。用redis-cli CLIENT LIST查当前连接,看addr字段是否是你的目标 IP。
第七步:确认防火墙和 ACL 权限
Redis 6+ 支持 ACL,如果用户没有read权限,XREAD会静默失败。用redis-cli ACL GETUSER default查权限,确保有on、+read、+@stream。
5.3 “消息重复”的根因分析:不是 Redis Bug,而是消费逻辑缺陷
消息重复从来不是 Redis 的问题,而是客户端 ACK 时机错误。我们遇到过三种典型重复场景:
场景一:ACK 在业务处理前
伪代码:
msg = r.xreadgroup(...) r.xack(...) # 错!这里就 ACK 了 process_message(msg) # 如果这里崩溃,消息已丢失确认正确顺序必须是process_message成功后才xack。
场景二:ACK 的 ID 和读取的 ID 不一致
比如循环处理时,变量名写错:
for msg_id, fields in msg_list: r.xack('s', 'g', 'wrong_id') # 错!用了字符串 literal应该用r.xack('s', 'g', msg_id)。
场景三:消费者组idle时间设置过短
默认idle是 0,即永不超时。但有些业务设了XGROUP SETID mystream mygroup 1623456789000-0,然后idle设为 60000(60秒),导致消息处理稍慢就被其他消费者XCLAIM。解决方案是XGROUP SETID时用IDLE 300000(5分钟),给足处理缓冲。
实战心得:在
XACK后,立即XPENDING mystream mygroup - + 1查 pending 数量,如果为 0,说明 ACK 成功;如果不为 0,说明 ACK 的 ID 有误。这个检查可以在测试环境自动化。
6. 进阶技巧:用XAUTOCLAIM实现无锁故障转移,用XTRIM控制成本
6.1XAUTOCLAIM:替代XCLAIM的更优雅故障转移方案
XCLAIM需要手动指定要抢占的 ID,而XAUTOCLAIM能自动扫描 PEL 中idle超时的消息,并批量转移给新消费者。它的语法是:
XAUTOCLAIM mystream mygroup newconsumer 0 1623456789000-0其中0是最小 idle 时间(毫秒),1623456789000-0是起始 ID。它会返回所有idle > 0且ID >= 1623456789000-0的消息,并把它们的 owner 改为newconsumer。相比XCLAIM,XAUTOCLAIM的优势是:
- 无锁:不需要先
XPENDING查 ID 再XCLAIM,一步到位。 - 批量:一次调用可转移多条,减少网络往返。
- 安全:只转移
idle超时的消息,不会误抢正在处理的消息。
我们在一个金融对账服务中用它实现了秒级故障转移:当监控发现某消费者idle> 300000ms,自动触发XAUTOCLAIM,新消费者启动后直接从断点继续,整个过程对账延迟 < 1 秒。
6.2XTRIM:用MAXLEN和MINID策略平衡存储与可追溯性
Stream 不会自动清理,必须用XTRIM。但XTRIM有两种策略,选择错误会导致数据丢失或存储爆炸:
XTRIM mystream MAXLEN 1000000:保留最新 100 万条。简单粗暴,但可能把重要的老消息(如用户注册事件)删掉。XTRIM mystream MINID 1623456789000-0:删除所有ID < 1623456789000-0的消息。精准按时间清理,但需要定期计算MINID。
我们采用混合策略:对日志类 Stream,用MAXLEN 1000000;对事件溯源类 Stream,用MINID。计算MINID的脚本如下:
# 获取 7 天前的时间戳 ID SEVEN_DAYS_AGO=$(($(date +%s%3N) - 7*24*3600*1000)) redis-cli XTRIM mystream MINID ${SEVEN_DAYS_AGO}-0这个脚本每天凌晨执行,确保只保留最近 7 天的完整事件链。
注意:
XTRIM是 O(M) 复杂度,M 是被删除的消息数。如果一次删 10 万条,可能阻塞 Redis 100ms。所以XTRIM要分散执行,比如每分钟删 1000 条,用--approx参数启用近似删除(Redis 7.0+)。
7. 性能与容量规划:单实例 Stream 的吞吐极限与横向扩展方案
7.1 单实例基准测试:不同消息体积下的 QPS 与延迟
我们在 AWS c5.4xlarge(16vCPU, 32GB RAM)上对 Redis 7.0 进行了压测,结果如下(网络延迟 < 0.1ms):
| 消息体积 | XADDQPS | XREADGROUPQPS | 99% 延迟 | 关键瓶颈 |
|---|---|---|---|---|
| 100B | 120,000 | 95,000 | 1.2ms | CPU(加密/序列化) |
| 1KB | 45,000 | 38,000 | 2.5ms | 内存带宽 |
| 10KB | 8,000 | 6,500 | 15ms | 网络传输 |
结论很清晰:Stream 的性能天花板由消息体积决定,而非并发数。当消息 > 1KB 时,网络和序列化开销成为主要瓶颈。所以架构设计时,要严格控制单条消息体积:日志类消息用 JSON 压缩;事件类消息只传 ID 和关键字段,详情查 DB;大文件用 URL 替代内容。
7.2 横向扩展:用XREAD分片 + 应用层聚合,绕过单 Stream 容量限制
单个 Stream 有理论长度限制(2^64-1),但实际中,当XLEN > 1000 万时,XRANGE的扫描延迟会明显上升。这时不能硬扛,要用分片。我们的方案是:按业务维度哈希分片,用XREAD聚合消费。
例如订单事件,按order_id % 16分成 16 个 Stream:order_stream_0到order_stream_15。生产者写入时:
shard_id = hash(order_id) % 16 r.xadd(f'order_stream_{shard_id}', order_data) ``