KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA
00 · 一个 Redis,三种角色:全局地图 — keel 龙骨
这章是地图。后面六章分别深入其中一条路。地图要回答一个核心问题:同一个 Redis,凭什么能同时当队列、状态库和事件日志,还互不干扰?
这章是地图。后面六章分别深入其中一条路。地图要回答一个核心问题:同一个 Redis,凭什么能同时当队列、状态库和事件日志,还互不干扰?
把 Redis 只当缓存用的人,看到生产系统把「任务调度」「运行状态」「事件推送」全压在一个 Redis 上会觉得不可思议。但只要分清三种角色用不同 key、不同结构,其实非常清晰。
一、三种角色一张表
设想一个「后台任务系统」——你提交一个长任务(比如生成一份报告),前端要实时看到进度和中间结果。它在 Redis 里留下三组 key:
| 角色 | 数据结构 | key 示例 | 谁写 | 谁读 |
|---|---|---|---|---|
| ① 任务队列(broker) | 任务队列结构(zset/hash/stream) | arq:stream:report 之类 |
接入层投递 | Worker 消费 |
| ② 运行态 | Hash | run:{run_id} |
接入层 create,Worker update | 两边都读(判状态 / 判取消) |
| ③ 事件日志 | Stream | run:stream:{run_id} |
Worker 每事件 XADD | 接入层 SSE XREAD |
三者用不同的 key、不同的数据结构,互不干扰。Redis 单线程执行命令,天然免锁;key 前缀(run: / run:stream: / lock: / arq:)就是它们的「命名空间」。
二、三种角色各自为什么选那个结构
① 队列为什么是专门结构? 任务队列的核心诉求是「一条任务只被一个 Worker 处理 + 处理失败可重试」。裸 Stream 也能做,但需要自己管消费组和 pending(第 1、6 章会看到 ARQ 正是这么做的);裸 List 最简单但 ack 要自己写。关键是语义要对口。
② 状态为什么用 Hash? 状态是「当前值」——pending/running/completed,旧值没有意义。Hash 能覆盖写、单字段更新(HSET status running)、全量读(HGETALL),正好。用 Stream 存状态,得读完整个流才知道现状;用 String 存多字段,每次改一个字段就要序列化整个对象全量写回——Hash 的「字段级读写」刚刚好。
③ 事件为什么用 Stream? 事件是「历史」——要保序、要持久、要能从任意位置补读。前端断线 5 秒,重连后把 5 秒内的事件补上,这个可能性完全建立在「事件留在 Redis 里没被删」上。Hash 只有最新值做不到,Pub/Sub 不落盘更做不到(第 2 章详述)。
一句话记:队列求「一条只消费一次」,状态求「最新值」,事件求「全过程可回放」——三种诉求对应三种结构。
三、一张接力图
一个任务的运行全生命周期里,三角色如何协作:
① 队列 接入层 enqueue ──► [Redis 队列] ──► Worker 消费 (毫秒级交接)
② 状态 接入层 create(pending) → Worker update(running→completed) (覆盖演进)
③ 事件 Worker XADD ──► [Stream] ──► SSE XREAD (追加/回放)
取消信号是三角色协同的浓缩案例——没有用任何 RPC,只是一个 Hash 字段当信箱、一个 Stream 当广播:
HTTP stop ──► HSET run:{id} stop_triggered=true (② 状态 Hash 当信箱)
Worker 事件循环 ──► HGET stop_triggered → 取消当前任务
↓ 产出 CancelledEvent
XADD run:stream:{id} (③ 事件 Stream 当广播)
SSE 读到终态事件 → 关闭连接
四、成本与边界(先立后破)
- Redis 成了关键依赖:三角色合一意味着 Redis 挂 = 三功能全挂。缓解:主从 + 持久化 + 监控;架构上也可以拆实例(队列 / 状态 / 事件各一套)。
- 内存是硬约束:Stream 要加 maxlen + TTL(第 1 章),状态要加 TTL,任务结果要设过期——所有写都要有过期策略,这是 Redis 当存储的纪律。
- 不是强一致:状态和事件都是「最终一致」——Worker 写完事件,SSE 要毫秒级才读到,中间没有事务。
↓ 下一步:01 章 · Stream 原生命令 —— 先把最硬核的 Stream 吃透。