bigywater.tech
RabbitMQ · 17

stream 队列:追加日志与偏移

stream 队列不是“队列”,而是一个追加日志:消息读完还在,消费者用 offset 决定从哪读。它能做什么、不能做什么、保留策略怎么删?这篇用普通 AMQP 客户端在 RabbitMQ 4.3.6 上把这些都过一遍。

读完仍在
两个消费者先后从头读同一个 stream,都读到 m0~m9
540 / 406
basic_get 与自动确认被拒的 AMQP 错误码,以及不设 prefetch 的 406
first_offset=44778
限制 20 MB 后,60000 条里最老的 44778 条所在的段被整段删除

追加日志

实测 · 本机执行输出
  消费者 A(offset=first)读到并 ack: ['m0', 'm1', 'm2', 'm3', 'm4', 'm5', 'm6', 'm7', 'm8', 'm9'];带的 offset 头: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
  消费者 B 随后也从头读: ['m0', 'm1', 'm2', 'm3', 'm4', 'm5', 'm6', 'm7', 'm8', 'm9'](消息还在)
  rabbitmq-streams stream_status s1: offset=9, role=writer, epoch=1, committed_offset=9, first_offset=0, readers=0, segments=1

stream 队列把消息追加在日志里,每条有一个递增的 offset(从 0 开始),投递时放在 x-stream-offset 头里。ack 不删除任何东西,同一批消息可以被很多消费者重复读取。

从哪里开始读

实测 · 本机执行输出
  offset=first           → 收到 30 条,第一条 m0(offset 0),最后一条 m29
  offset=last            → 收到  1 条,第一条 m29(offset 29),最后一条 m29
  offset=next            → 收到  0 条,第一条 -(offset -),最后一条 -
  offset=数字 20           → 收到 10 条,第一条 m20(offset 20),最后一条 m29
  offset=时间戳(第 15 条之后)   → 收到 15 条,第一条 m15(offset 15),最后一条 m29
  offset=不设              → 收到  0 条,第一条 -(offset -),最后一条 -
  不设 offset 时的默认行为是 next(只收订阅之后新到的),所以 0 条

消费时用参数 x-stream-offset 指定:first、last(最后一个 chunk)、next(只要新的,也是不设时的默认)、一个数字 offset、或一个时间戳。

位置自己存

实测 · 本机执行输出
  先读 5 条(最后一条 offset=4),下次用 offset=5 继续,读到 15 条: 从 m5 到 m19

AMQP 0-9-1 没有消费者位置的存储,消费者要自己记住处理到的 offset,下次用上次 + 1 继续。

不支持的操作

实测 · 本机执行输出
  basic_get:            ConnectionClosedByBroker: (540, "NOT_IMPLEMENTED - basic.get not supported by stream queues queue 's1' in vhost '/'")
  不设 prefetch 消费:    ChannelClosedByBroker: (406, "PRECONDITION_FAILED - consumer prefetch count is not set for stream queue 's1' in vhost '/'")
  durable=False:        ConnectionClosedByBroker: (541, 'INTERNAL_ERROR - Feature `transient_nonexcl_queues` is deprecated.\nBy default, this feature is not permitted anymore.\nThe feature will be removed from a future m
  exclusive=True:       ChannelClosedByBroker: (406, "PRECONDITION_FAILED - invalid property 'exclusive-owner' for queue 'sx' in vhost '/'")
  auto_ack 消费:        ConnectionClosedByBroker: (540, "NOT_IMPLEMENTED - automatic acknowledgement not supported by stream queue 's1' in vhost '/'")

basic_get、自动确认、非持久、exclusive 队列、不设 prefetch 的消费,都被 broker 拒绝。

保留

实测 · 本机执行输出
  发了 60000 条;第一条还能读到的 offset = 44779;stream_status: first_offset=44778 committed_offset=60012 segments=4
  数据目录里的 .segment 文件: [('00000000000000000000.segment', 0), ('00000000000000000000.segment', 0), ('00000000000000000000.segment', 0), ('00000000000000044778.segment', 4), ('00000000000000049258.segment', 4), ('00000000000000053729.segment', 4), ('00000000000000058196.segment', 1)](名字是这个段第一条消息的 offset,第二项是 MiB);.index 文件 7 个,每段一个

用 x-max-length-bytes 或按时间的 x-max-age 限制,段大小由 x-stream-max-segment-size-bytes 决定;超出限制时最老的段整段删除。

吞吐

实测 · 本机执行输出
  stream 读 100000 条: 924 ms(可重复读 3 次,每次都是同样的 100000 条)
  classic 读 100000 条: 903 ms(读完就没了,只能读 1 次)

用 AMQP 读 100000 条的耗时与 classic 队列相当(924 对 903 ms);stream 的价值在语义。

分步演示

stream 队列
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / RabbitMQ 4.3.6 / Erlang/OTP 28.5 / pika 1.4.4(Homebrew 与临时虚拟环境);所有节点都是本机自己起的 RabbitMQ 进程(127.0.0.1,端口 9500 以上,每个实验独立的数据目录与节点名,不连任何外部服务);命令输出来自真实的 rabbitmqctl 与管理 API,发布与消费用 pika 的真实连接;计时用 time.perf_counter,数字每次运行会有小幅抖动。启用 rabbitmq_stream 插件,但全部实验仍通过 AMQP 0-9-1(pika)读写,没有用 stream 专用二进制协议;stream 状态来自 rabbitmq-streams stream_status --formatter json;消费者需手动 ack 且设了 prefetch;第五节的 .segment 文件列表里名字为 0 的三个文件属于前面几个小 stream,s5 自己的是名字 44778 起的四个
    • 没有验证:stream 专用协议(端口 5552)的客户端、offset 存储与去重发布;超级流(super stream)与分区;x-max-age 按时间保留;多节点 stream 的副本与选举;过滤(Bloom filter);单消息很大时的 chunk 行为。last 的 chunk 大小每次运行不同(见过 1、2、4 条),没有总结规律
    京ICP备2026067502号-1京公网安备11010502063487号