Redis Streams 深度剖析¶
为什么选择 Redis Streams¶
mindmap
root((Redis Streams))
持久化
["消息落盘不丢失"]
["AOF / RDB 持久化"]
消费组
["Consumer Group 竞争消费"]
["自动负载均衡"]
范围查询
["按 ID 范围读取历史"]
["支持 XRANGE / XREVRANGE"]
ACK 机制
["PEL 待确认列表"]
["未 ACK 自动重投"]
高性能
["单分区 O(1) 写入"]
["支持批量读取"]
Key Schema 版本:v1 / v2(单机 / 集群兼容)¶
本页下面「完整 Redis Key 参考」列出的 key 都是默认的 v1 格式——不带任何 Redis Cluster hash tag,只在单机 Redis
下保证正确。切到 Redis Cluster 部署(参见部署指南 · 集群部署)时,SDK 会切换成
v2 格式:给同一实体(如同一个 session_id / worker_id / trace_id)下的相关 key 统一加上
hash tag,保证它们落在同一个
slot,多 key 操作(MULTI/EXEC、Lua 脚本等)才不会触发 CROSSSLOT 错误。
为什么 v1 格式在 Cluster 下会出问题¶
Redis Cluster 把 keyspace 切成 16384 个 slot,一条命令只能在一个 slot 上运行;跨 slot 的多 key 事务会被直接拒绝。
v1 格式的 key(比如 byai_gateway:registry:worker:online:{worker_id},这里的花括号只是本文档用来表示"实际值填在这里"
的占位符写法,不是 Redis key 里的字面字符)落在哪个 slot 完全由 key 的完整字符串哈希决定,同一个 worker 下的多个
相关 key(在线租约、执行记录、心跳统计……)大概率会散落在不同 slot 上。只要业务代码里有把它们放进同一个 pipeline /
事务的路径,就会在 Cluster 下报 CROSSSLOT Keys in request don't hash to the same slot。
v2 格式:用 hash tag 把同一实体的 key 锁进同一个 slot¶
v2 格式给 key 加上统一前缀 byai_gateway:v2:,并且给"同一实体"(same-entity)分组的 key 用 { } 包住共同的 ID 部分——
Redis 计算 slot 时只看花括号里的内容,两个 key 只要花括号内容相同就必定落在同一个 slot。以 worker_online_lease 为例:
| Key Schema | 实际 key(worker_id="worker-07") |
|---|---|
| v1(默认) | byai_gateway:registry:worker:online:worker-07 |
| v2(Cluster) | byai_gateway:v2:registry:worker:{worker-07}:online |
三语言的 key 工厂方法(Python RedisKeys.* / TS QueueNames.*、RegistryKeys.* / Java Constants.*)内部已经把这条
拼装规则统一收口,业务代码永远不应该手写字面量拼 Redis key——一是容易在两种 schema 下漏加/错加 hash tag,二是升级到
v2 之后手写的字面量会一直指向旧的 v1 key,SDK 实际写入的是 v2 key,读永远读到空数据且不会报错,非常难排查。
不是所有 key 都需要 hash tag¶
只有"同一实体下有多个相关 key、且确实存在把它们放进同一个 pipeline/事务的代码路径"才需要 hash tag(上表归类为
same-entity);只有一个变量维度、从不与其他 key 组合操作的 key(single-key,比如 agent_type 控制流)在 v2 下只加统一
前缀,不需要额外的花括号分组。判断某个自定义 key 要不要加 hash tag,看的是"它会不会和别的 key 一起出现在同一次
MULTI/EXEC 或 Lua 脚本里",而不是"它属于哪个功能域"。
如何启用¶
设置 REDIS_CLUSTER_HOST 即可——SDK 会同时把部署形态切到 cluster、把 key schema 切到 v2,不需要分别配置。
如果只是想在单机部署下先验证 v2 key 格式本身是否有问题(不切集群),可以单独显式设置 REDIS_KEY_SCHEMA_VERSION=v2。
详见部署指南 · 集群部署。
v1 → v2 是唯一涉及存量数据的破坏性变更
v2 格式落地后,同一批 key 的物理名字全部改变,旧 key 下的历史数据在新代码里读不到。多数 key 带 TTL, 部署新版本后旧 key 自然过期即可;只有极少数无 TTL 的 key 需要一次性迁移脚本。升级前务必先在测试环境走一遍完整流程。
完整 Redis Key 参考¶
Stream Keys(消息队列)¶
| Key 模式 | 类型 | 用途 |
|---|---|---|
byai_gateway:ctrl:agent_type:{agent_type} |
Stream | Agent type 控制流,Worker 竞争消费 |
byai_gateway:ctrl:worker:{worker_id} |
Stream | Worker 定向控制流,单 Worker 消费 |
byai_gateway:session:{session_id}:data_stream |
Stream | 会话级数据流,Worker 输出通道 |
Registry Keys(注册中心)¶
| Key 模式 | 类型 | 用途 |
|---|---|---|
byai_gateway:registry:workers |
Set | 所有已知 Worker ID 集合 |
byai_gateway:registry:worker:online:{worker_id} |
String | Worker 在线租约(TTL:Python 30s,Java / TypeScript 15s —— 三语言常量当前不一致,见下方提示) |
byai_gateway:registry:worker:lock:{worker_id} |
String | Worker 启动互斥锁(TTL 60s) |
byai_gateway:registry:worker:agent_types:{worker_id} |
Set | Worker 声明的 Agent type 集合 |
byai_gateway:registry:agent_type:workers:{agent_type} |
Set | Agent type 的成员 Worker 集合 |
byai_gateway:registry:worker:status:{worker_id} |
String | Worker 当前状态 |
byai_gateway:registry:worker:admin:{worker_id} |
Hash | WorkerManager 管理的生命周期状态(lifecycle/reason/updated_at),无 TTL |
Worker 在线租约 TTL 三语言不一致
Python 的 WORKER_DEFAULT_LEASE_TTL_SECONDS 是 30,Java / TypeScript 都是 15,而三者的心跳间隔
(WORKER_DEFAULT_HEARTBEAT_INTERVAL_SECONDS)统一是 5——也就是说 Python worker 要连续错过 6 次
心跳才会被判定离线,Java / TypeScript worker 只需错过 3 次。这看起来更像历史遗留的实现漂移,而不是
有意为之的设计差异,混部场景下需要注意这个差异带来的离线判定时间不一致。
Execution Keys(执行追踪)¶
执行记录不是存成独立的顶层 key,而是作为 Hash 字段挂在会话级聚合注册表和 Worker 级 ZSET 下:
| Key 模式 | 类型 | 用途 |
|---|---|---|
byai_gateway:session:{session_id}:registry |
Hash | 会话级聚合注册表;内部按字段前缀细分两类数据:exec:{execution_id} 字段存单次执行详情(JSON),msg_map:{message_id} 字段存消息 ID → 执行 ID 的映射 |
byai_gateway:registry:worker:executions:{worker_id} |
Sorted Set | Worker 处理过的执行 ID,score 为更新时间 |
byai_gateway:registry:worker:active_execution_index:{worker_id} |
Sorted Set | Worker 当前活跃(未终态)执行 ID,score 为更新时间 |
byai_gateway:registry:worker:active_snapshots:{worker_id} |
Hash | 活跃执行 ID → 轻量快照 |
byai_gateway:registry:worker:history_snapshots:{worker_id} |
Hash | 历史执行 ID → 轻量快照 |
Task Group Keys(任务组)¶
| Key 模式 | 类型 | 用途 |
|---|---|---|
byai_gateway:task_group:{group_id} |
Hash | 任务组计数器 (total / completed) |
byai_gateway:task_group:{group_id}:results |
Hash | 任务组结果集(TTL 86400s) |
Service Discovery Keys(服务发现)¶
| Key 模式 | 类型 | 用途 |
|---|---|---|
byai_gateway:sd:services |
Set | 全局服务名称索引 |
byai_gateway:sd:active:{service_name} |
Sorted Set | 活跃实例(score = 心跳时间戳) |
byai_gateway:sd:instances:{service_name} |
Hash | 实例详情(host, port, metadata) |
Consumer Group¶
| Key 模式 | 说明 |
|---|---|
byai_gateway:consumer_group:agent_engines |
默认消费者组名 |
Consumer Group 路由架构¶
graph TB
Client["GatewayClient"]
subgraph AgentTypeRoute["Agent Type 路由 (默认)"]
direction TB
S1["byai_gateway:ctrl:agent_type:chat_agent"]
CG["Consumer Group<br/>byai_gateway:consumer_group:agent_engines"]
W1["Worker-1"]
W2["Worker-2"]
W3["Worker-3"]
S1 --> CG
CG -->|"竞争消费"| W1
CG -->|"竞争消费"| W2
CG -->|"竞争消费"| W3
end
subgraph DirectRoute["Worker 定向路由"]
direction TB
S2["byai_gateway:ctrl:worker:worker-2"]
W2D["Worker-2"]
S2 -->|"独占消费"| W2D
end
Client -->|"targetAgentType"| S1
Client -.->|"targetWorkerId"| S2
Agent Type 路由 (默认)¶
- 消息写入
byai_gateway:ctrl:agent_type:{agent_type} - 在
requireOnlineWorker=true时验证是否存在在线 Worker - 同一 Agent type 的所有 Worker 通过 Consumer Group 竞争消费
Worker 定向路由¶
- 传入
targetWorkerId后消息写入byai_gateway:ctrl:worker:{worker_id} - 适合 debug、取消任务或定向控制
消息确认机制¶
stateDiagram-v2
[*] --> Pending: XADD 写入
Pending --> Delivered: XREADGROUP 分发
Delivered --> Processing: Worker 开始处理
Processing --> Acknowledged: XACK 确认
Processing --> Redelivered: 超时未 ACK
Redelivered --> Delivered: 重新分发
Acknowledged --> [*]: 从 PEL 中移除
- Pending: 消息已写入 Stream
- Delivered: 被 Consumer Group 分发给某个 Worker
- Processing: Worker 正在处理
- Acknowledged: Worker 发送
XACK,消息从 PEL (Pending Entries List) 移除 - Redelivered: 未及时 ACK 时自动重新分发
消费者组配置¶
Key 命名空间规范¶
所有 Redis Key 均以 byai_gateway: 为前缀,按功能域划分子命名空间:
graph LR
Root["byai_gateway:"]
Root --> ctrl["ctrl:<br/>控制流"]
Root --> session["session:<br/>会话数据"]
Root --> registry["registry:<br/>注册中心"]
Root --> sd["sd:<br/>服务发现"]
Root --> task_group["task_group:<br/>任务组"]
Root --> consumer_group["consumer_group:<br/>消费者组"]
ctrl --> ctrl_at["agent_type:{type}"]
ctrl --> ctrl_wk["worker:{id}"]
session --> sess_ds["{sid}:data_stream"]
session --> sess_reg["{sid}:registry"]
registry --> reg_workers["workers"]
registry --> reg_worker["worker:*"]
registry --> reg_at["agent_type:*"]
registry --> reg_exec["execution:*"]