大 JSON 放进 Redis 之后,如何用 Stream 解耦耗时任务?
系列第二篇:上一篇讨论"大 JSON 直接压缩,还是把数据放进 Redis"。这一篇继续往下走:数据已经外置,任务如何提交、排队、执行,以及在失败后恢复?
本文使用虚构的"业务系统"和"计算服务",所有名称、Key、接口、字段值均为教学示例,不对应任何真实业务系统。代码用于说明职责和执行顺序,不是可直接投入生产的完整框架。
当一个计算请求需要处理大量明细,并且计算可能持续几分钟时,通常有两个问题需要分别解决:
- 输入太大,不适合反复塞进 HTTP 请求或消息体。
- 计算太久,不适合让调用方一直同步等待。
上一篇的数据外置方案处理了第一个问题。这一篇引入 Redis Stream,把任务受理和实际计算进一步拆开。
整个过程可以先记成四步:
text
① 业务系统把完整输入写入 Redis
task:{task-001}:input
② 业务系统请求计算服务的 POST /tasks
携带 taskId="task-001" 等轻量参数
③ 计算服务 API 校验通过,将任务消息加入 Redis Stream
task:queue:compute
④ 后台 worker 领取任务消息
根据 taskId 读取完整输入,执行计算
接下来把这四步拆开,看看 Redis 是怎样成为消息队列的。
一、先分清楚:输入、状态、队列、结果不是四个队列
第一次看到下面的结构,很容易以为 Redis 中创建了四个队列:
text
Redis
│
├── task:{task-001}:input String:完整输入 JSON
├── task:{task-001}:meta Hash:任务参数与状态
├── task:queue:compute Stream:多个任务共用的队列
└── task:{task-001}:output String:结果 JSON
实际上,这是四个不同的 Key,只有第三个是队列。
可以把它们理解为一个办公室里的四样东西:
| 数据 | 通俗理解 | 主要内容 |
|---|---|---|
| input | 材料袋 | 这次计算所需的完整数据 |
| meta | 进度卡 | 等待中、计算中、已成功、已失败,以及执行参数 |
| Stream | 公共待办清单 | 有哪些任务需要领取处理 |
| output | 结果袋 | 计算完成后产生的结果 |
如果同时来了三个任务,结构会变成:
text
每个任务各自保存材料:
task:{task-001}:input
task:{task-002}:input
task:{task-003}:input
每个任务各自记录状态:
task:{task-001}:meta
task:{task-002}:meta
task:{task-003}:meta
多个任务共用一个计算队列:
task:queue:compute
├── task-001
├── task-002
└── task-003
任务完成后,各自保存结果:
task:{task-001}:output
task:{task-002}:output
task:{task-003}:output
这里的 String、Hash、Stream,是 Key 对应值的数据类型:
- String 保存一整段文本或字节,例如序列化后的 JSON。
- Hash 按字段记录数据,例如
status=RUNNING、timeoutSeconds=300。 - Stream 保存一条条带 ID 的消息,本例用它实现任务队列。
{task-001} 中的花括号也是实际 Key 的一部分。在 Redis Cluster 中,这种 hash tag 可以让同一任务的 input、meta、output 落到同一个槽;公共队列不因此自动与所有任务数据同槽。
二、第一步:先把完整输入保存到 Redis
假设业务系统需要对一批订单做汇总分析。它生成唯一的业务任务编号:
text
taskId = task-001
完整输入可能是几万条、几十万条明细。为了便于阅读,这里只展示两条:
json
{
"schemaVersion": "1",
"records": [
{"category": "A", "quantity": 100},
{"category": "B", "quantity": 200}
],
"rules": {
"groupBy": "category"
}
}
业务系统将这个对象序列化为 JSON 字符串,保存到:
text
Key:task:{task-001}:input
类型:String
值:完整 JSON 字符串
对应的 Python 示意代码:
python
import json
def input_key(task_id):
return f"task:{{{task_id}}}:input"
raw_json = json.dumps(payload, ensure_ascii=False)
redis_client.set(
input_key("task-001"),
raw_json,
ex=7 * 24 * 60 * 60,
)
本例约定保存裸 UTF-8 JSON,不做 GZIP,也不做 Base64。如果需要压缩,可以沿用上一篇的方案,但双方必须明确编码协议。
这一步只保存数据,尚未提交计算任务。
为什么一定要先写输入?因为后面的任务请求主要携带任务编号。接收方拿到编号时,必须能找到对应的计算材料。
同一个逻辑任务的输入也应保持稳定:重试受理请求时,不要偷偷覆盖同一 taskId 对应的材料。如果数据发生变化,应创建新任务或采用明确的输入版本协议。
三、第二步:HTTP 只提交计算申请
输入写入成功后,业务系统调用:
http
POST /tasks
Content-Type: application/json
请求体只包含少量字段:
json
{
"taskId": "task-001",
"taskType": "compute",
"requester": "business-app",
"timeoutSeconds": 300
}
| 字段 | 含义 |
|---|---|
| taskId | 业务任务编号,用来关联输入、状态和结果 |
| taskType | 任务类型,用于路由到对应队列或执行逻辑 |
| requester | 调用来源标识,可用于路由结果通知,不是身份认证凭证 |
| timeoutSeconds | 单次执行的限时参数,具体执行方式由计算服务实现 |
计算服务可以按约定,根据 taskId 拼出输入位置:
text
taskId = task-001
↓
task:{task-001}:input
因此 HTTP 中不必再传整份 records,也不必由调用方传任意 Redis Key。
API 在受理阶段主要做这些工作:
- 校验参数、调用权限和任务归属。
- 检查输入是否存在,必要时核验版本、大小或摘要。
- 检查当前是否还能接收更多任务。
- 按 taskId 识别重复提交。
- 登记任务状态,并把轻量消息写入队列。
它不在这次 HTTP 请求中完成实际计算。
首次正常受理后,可以返回:
http
HTTP/1.1 202 Accepted
json
{
"accepted": true,
"taskId": "task-001",
"status": "PENDING"
}
这个响应表示"已受理,等待处理",并不表示"计算已经成功"。空闲 worker 可能很快领取任务,因此客户端查询时也可能已经看到 RUNNING。
四、第三步:计算服务把轻量任务消息写进 Stream
1. 一个 Stream Key 可以保存很多条消息
计算队列的教学名称统一为:
text
task:queue:compute
它的内容可以理解为:
text
消息ID 消息字段
1730000000000-0 taskId=task-001 taskType=compute requester=business-app
1730000000001-0 taskId=task-002 taskType=compute requester=business-app
1730000000002-0 taskId=task-003 taskType=compute requester=business-app
这是一个 Stream 对象中的三条消息,不是三个独立的顶层 Redis Key。
每条消息内部有字段和值,但它们也不是单独的 Redis Hash。Stream 的字段结构由 Redis 原生管理。
2. 使用 XADD 入队
示意命令:
redis
XADD task:queue:compute * taskId task-001 taskType compute requester business-app
含义是:向这个 Stream 追加一条消息,* 表示由 Redis 自动生成消息ID。返回值例如:
text
1730000000000-0
Python 调用类似:
python
message_id = redis_client.xadd(
"task:queue:compute",
{
"taskId": "task-001",
"taskType": "compute",
"requester": "business-app",
},
)
XADD 的条目结构和自动 ID 规则,见 Redis 官方文档。
这里有两个不同的 ID:
| ID | 由谁生成 | 用途 |
|---|---|---|
| task-001 | 业务系统 | 关联材料、状态、结果、日志 |
| 1730000000000-0 | Redis | 定位 Stream 消息,用于确认、删除、接管 |
任务状态中可以保存 streamId,把两者关联起来:
text
task:{task-001}:meta
taskId task-001
status PENDING
taskType compute
timeoutSeconds 300
streamId 1730000000000-0
3. 为什么队列里不放完整 JSON?
队列只负责登记"有哪个任务需要处理"。完整输入已经存在 input 中,重复塞进 Stream 会增加队列存储、读取和恢复时需要处理的数据量。
这样分开后,任务重新投递主要处理轻量消息,数据的保留和读取可以独立管理。
但要注意:外置输入没有消除网络传输。数据依然需要从业务系统写入 Redis,再由计算服务读出。
五、第四步:worker 通过消费组领取任务
worker 是计算服务中的后台进程,不是 Redis 内部的一段计算程序。
Redis 保存和分配消息;worker 运行代码、读取材料、执行计算。
1. 消费组让多个 worker 分工
本例创建一个消费组:
text
Stream:task:queue:compute
消费组:compute-workers
可以在初始化阶段执行:
redis
XGROUP CREATE task:queue:compute compute-workers 0 MKSTREAM
这里选择 0,表示消费组从已有消息的起点开始读取;MKSTREAM 表示 Stream 不存在时创建空流。消费组只需创建一次,重复启动时应识别并忽略"组已存在"错误。
如果使用 $,含义是从创建时 Stream 的末尾之后读取,已有消息不会作为该组的新消息交付。两者不能不加区分地复制使用。官方说明
同一个组里可以运行多个消费者:
text
消费组 compute-workers
├── worker-A
├── worker-B
└── worker-C
正常情况下,新消息在组内分配,不会广播给每一个 worker。不同 worker 应使用不同名字。
2. 使用 XREADGROUP 领取
worker-A 领取任务的命令类似:
redis
XREADGROUP GROUP compute-workers worker-A COUNT 1 BLOCK 5000 STREAMS task:queue:compute >
| 参数 | 作用 |
|---|---|
| GROUP compute-workers | 使用这个消费组 |
| worker-A | 当前消费者的名字 |
| COUNT 1 | 最多领取一条 |
| BLOCK 5000 | 没有新消息时,最多等待五秒 |
| STREAMS task:queue:compute | 指定读取的 Stream |
| > | 领取还没有分配给该组任何消费者的新消息 |
队列有消息时可以立即返回,五秒不是固定轮询间隔。
简化 Python 代码如下,假设客户端已配置 decode_responses=True:
python
messages = redis_client.xreadgroup(
groupname="compute-workers",
consumername="worker-A",
streams={"task:queue:compute": ">"},
count=1,
block=5000,
)
for stream_name, entries in messages:
for message_id, fields in entries:
task_id = fields["taskId"]
handle_task(task_id, message_id)
如果客户端使用字节响应,需要先解码字段和值。队列为空时继续等待下一轮。
3. 领取以后,再读取完整材料
worker 拿到 taskId=task-001 后,才按约定读取:
python
raw_json = redis_client.get("task:{task-001}:input")
payload = json.loads(raw_json)
然后执行计算:
text
领取 task-001
↓
检查任务状态与执行条件
↓
更新为 RUNNING
↓
读取 input,解析 JSON
↓
调用计算函数
↓
保存 output,更新终态
到这里,最初的四步已经全部串起来:材料放在 input,HTTP 提交申请,API 把消息入队,worker 领取后读取材料计算。
六、为什么领取消息后不能立即删除?
假设 worker 刚领取任务就删除消息,然后在计算过程中退出:
text
领取 → 删除 → 计算到一半退出
队列里已经找不到这条消息,如果没有额外恢复机制,这笔任务就可能被遗忘。
Stream 消费组提供了"待确认"的机制:
text
领取 → 记录为待确认 → 执行 → 保存结论 → 确认
1. PEL 记录已领取但未确认的消息
worker-A 通过 XREADGROUP 领取后,Redis 会记录消息的消费者归属和待确认状态。这些信息称为 PEL,即 Pending Entries List。
逻辑上可以理解为:
text
Stream消息:
消息001:task-001
消息002:task-002
消费组 compute-workers:
新消息领取进度:已分配到消息001
待确认记录:
消息001 → worker-A,尚未确认
PEL 是消费组内部的结构,不是应用另外创建的名为 PEL 的 Redis Key。消息领取后仍保留在 Stream 中。官方消费组说明
2. XACK 与 XDEL 是两件事
处理结束后:
redis
XACK task:queue:compute compute-workers 1730000000000-0
这表示移除该组的待确认记录,不会删除 Stream 消息内容。官方 XACK 说明
如果本例只用一个消费组,并且希望清理已完成的消息,可以再执行:
redis
XDEL task:queue:compute 1730000000000-0
因此本例采用:
text
保存结果和终态 → XACK → XDEL
如果一个 Stream 有多个独立消费组,不能照搬"本组完成就立即 XDEL"的策略,否则可能影响其他组读取。消息保留与清理策略需要按消费模型设计。
3. 确认完成不一定等于业务计算成功
如果输入校验失败,并且已经保存明确的失败原因,这条消息也可以结束处理:
text
保存 FAILED 与原因 → XACK → 按策略清理消息
但网络暂时不可达、结果尚未可靠保存等问题,不能一律确认后丢弃。否则"消息处理完了",业务结果却没有留下。
尤其不要把 XACK 无条件放进 finally:那会让所有异常都变成"已经处理结束"。
七、任务完成后,如何让调用方拿到结果?
计算服务将结果写到:
text
task:{task-001}:output
例如:
json
{
"taskId": "task-001",
"summary": [
{"category": "A", "totalQuantity": 100},
{"category": "B", "totalQuantity": 200}
]
}
并将 meta 更新为终态:
text
status SUCCEEDED
outputKey task:{task-001}:output
调用方可以查询状态,也可以接收完成通知。通知仍然只传轻量信息:
json
{
"taskId": "task-001",
"status": "SUCCEEDED",
"outputKey": "task:{task-001}:output"
}
如果采用"先登记待通知记录,再确认队列消息"的设计,回调失败可以独立补发,不必为了补一次通知而重新计算整笔任务。
完整链路如下:
text
业务系统 Redis 计算服务
│ │ │
├── 写完整input ───────→│ │
│ │ │
├── HTTP POST /tasks ─────────────────────────────→ API
│ │←── 登记meta、XADD入队 ────┤
│←────────────────────── HTTP 202,已受理 ──────────┤
│ │ │
│ │←── XREADGROUP领取 ────── worker
│ ├── 消息taskId ────────────→│
│ │←── 根据taskId读取input ───┤
│ │ ├── 计算
│ │←── 写output和终态 ────────┤
│ │←── XACK、按策略清理 ──────┤
│←────────────────────────── 完成通知 ──────────────┤
├── 读取output ────────→│ │
八、这套设计到底解耦了什么?
它主要分开了三类工作:
| 工作 | 谁负责 | 完成什么后可以结束当前阶段 |
|---|---|---|
| 准备和保存材料 | 业务系统 | 输入成功写入共享存储 |
| 受理和登记任务 | 计算服务 API | 完成校验、任务登记与入队 |
| 耗时计算 | 后台 worker | 保存结果与处理结论 |
第一,输入数据不再跟着每次任务通知重复传输。HTTP 和 Stream 只携带编号和必要参数。
第二,HTTP 生命周期与计算生命周期分开。调用方收到202后,可以查询进度,而不用维持一个几分钟的同步请求。
第三,接收请求和消耗算力分开。API 接收任务后,worker 根据可用计算资源逐笔处理。短时积压可以进入队列,但仍应设置容量上限与拒绝策略,避免无限堆积。
第四,任务执行和结果通知可以独立恢复。结果已经保存时,补发通知不需要重复执行计算。
不过双方依然依赖 taskId、Key 规则、输入结构和 Redis 可用性。这里是职责与执行时序上的解耦,不是两个系统完全没有依赖。
九、真实落地时,还需要处理哪些失败窗口?
下面这些问题,不能仅靠一句"用了 Redis Stream"自动解决。
1. 输入写好了,HTTP 通知失败
调用方保存待提交记录,使用同一个 taskId 重试。输入需要保留足够时间,不能因为一次请求失败就立即删除。
如果调用方超时,实际受理却已经完成,重试也应先查询或命中幂等逻辑,而不是无条件新建计算任务。
2. meta 登记成功,XADD 却失败
任务状态与公共 Stream 可能位于不同的 Redis Cluster 槽,不能默认一个事务或 Lua 能把全部动作包成原子操作。
一种处理方式是保留可枚举的待提交记录,并记录 Stream 消息ID;补偿程序检查"任务存在但未正确入队"的情况,执行补投或落失败状态。
补投本身也可能产生重复消息,因此任务执行仍需要幂等保护。
3. worker 领取任务后退出
未确认消息会留在 PEL 中,但 Redis 不会自行替应用重新执行计算代码。
恢复进程可以使用 XAUTOCLAIM 接管空闲时间超阈值的待确认消息,再按任务状态决定重试、跳过或失败收尾。官方说明
长任务还需要心跳和续期。否则计算正常运行了几分钟,也可能被恢复程序误判为失联。
4. 计算已经完成,确认消息之前退出
接管后可能再次收到同一业务任务。
如果终态与结果已经可靠保存,worker 可以识别已完成状态,跳过计算,只补做消息确认和清理。
但仅用"先读状态,再设RUNNING"不能防止所有并发重复执行。多个 worker 同时处理同一 taskId 时,需要原子状态转换、执行租约或带令牌的任务占有机制;外部业务写入也要按 taskId 等唯一键做幂等。
因此,应按"消息可能重复投递"设计。Stream 加上恢复逻辑支持至少一次处理思路,但不天然保证严格只执行一次,更不能脱离 Redis 持久化、淘汰和故障切换配置承诺绝不丢失。
5. 数据过期了,任务还没开始
input 的有效期应覆盖排队、执行、重试和补偿时间。output 也应保留到调用方有足够时间读取。
公共 Stream 不宜随手设置很短的 Key 级 TTL。那会让整个队列及相关消费组信息一起消失,不是只清理一条旧任务。
队列截断也需要谨慎:按固定长度粗暴清理,可能删掉尚未处理的消息。
6. Redis 自己重启或发生故障
worker 重启后还能找回消息,与 Redis 故障后还能恢复数据,是两层不同的问题。
后者取决于 RDB/AOF、复制、故障切换和存储配置。Redis 的 官方持久化说明 分别介绍了快照与追加日志的恢复方式。对于不能丢失的业务任务,还应评估使用持久化任务表或其他可靠记录进行对账与恢复。
十、如何观察这套队列是否正常?
以下命令用于观察,不会主动领取消息或推进消费进度:
redis
# 查看Key类型
TYPE task:queue:compute
# 查看前10条现存消息
XRANGE task:queue:compute - + COUNT 10
# 查看现存消息条目数
XLEN task:queue:compute
# 查看消费组
XINFO GROUPS task:queue:compute
# 查看组内消费者
XINFO CONSUMERS task:queue:compute compute-workers
# 查看已领取但未确认的消息
XPENDING task:queue:compute compute-workers
# 查看一笔任务的业务进度
HGETALL task:{task-001}:meta
要区分三个视角:
XRANGE看消息内容。XPENDING看哪些消息已领取但尚未确认。meta看业务计算到底进行到哪一步。
XLEN 统计的是尚未删除的 Stream 条目。若运行中的消息仍保留在 Stream 中,这些条目也会被计入,不能直接等同于"尚未开始计算的任务数"。
十一、几个容易混淆的问题
input、meta、Stream、output 是四个队列吗?
不是。只有 Stream 是本例的任务队列。其他 Key 分别保存输入、状态和结果。
POST /tasks 传了 taskId,计算服务就知道处理哪笔输入了吗?
是。双方按规则把 taskId 映射成 input Key。API 先检查材料,worker 执行时再读取完整内容。
Stream 中存的是完整 HTTP 请求吗?
不是。本例只追加 taskId、taskType、requester 等轻量字段。限时等其他执行参数放在 meta,完整材料放在 input。
worker 是 Redis 内部的线程吗?
不是。它是独立运行的应用进程,通过 Redis 客户端读取消息,再调用自己的计算函数。
Stream 就是 Redis List 吗?
不是。二者是不同的数据类型。Stream 原生提供消息ID、消费组和待确认记录。本篇采用的是 Stream 消费组模式。
Stream 底层是不是一个大 JSON 数组?
不是。它是 Redis 原生对象。Redis 官方将其底层编码描述为由 radix tree 组织的 listpack;应用通过专用命令操作,不自行维护整段数组。官方编码说明
使用队列后,任务就一定按顺序完成吗?
不能保证。即使消息按顺序分配,多 worker 的执行耗时和故障重试也可能不同,完成顺序可能改变。
十二、回到最初的四步
text
① 业务系统写 input
把"计算材料"放好。
② 业务系统调用 POST /tasks
用 taskId 告诉计算服务:"请处理这一笔材料"。
③ 计算服务 API 写 Stream
把"需要处理的任务"登记到公共待办清单。
④ worker 领取消息、读取 input、执行计算
完成后保存 output 和终态,再确认消息。
上一篇讨论数据怎样离开大报文通道;这一篇补上了任务如何进入后台执行流程。
外置输入让通知只传引用,HTTP 受理让调用方尽快结束等待,Stream 将待办任务交给 worker。再配合状态、确认、幂等、恢复与通知补偿,才能把"数据已经放进 Redis"变成一条可追踪、可恢复的异步任务链路。