Redis Streams 消息总线:XADD/XREADGROUP/XACK/XAUTOCLAIM、consumer group、毒消息
用 Redis Streams 搭一条消息总线,理解 consumer group 怎么让多个 worker 分摊消费,以及怎么处理反复失败的"毒消息"。
今日目标
- 能用 XADD 写入消息、用 XREADGROUP 以 consumer group 方式消费一条消息并 XACK
- 能解释 XACK 和 XAUTOCLAIM 分别解决什么问题
- 能设计一个简单的毒消息(反复失败的消息)隔离机制
昨天把服务劈成了接入层(Gateway)、消息总线、执行层(Worker)三段,中间那条总线还只是架构图上的一个方框:POST /chat 把一次执行(run)写进 runs 表就返回了,落完库谁去执行,没人回答。今天把那个方框填上——本章结束时,D8 到 D9 的完整链路第一次真的跑通。读完回到页面顶部把三条目标勾掉。
小白版讲解
落完库之后,谁去干活
小区门口的快递驿站。快递车到了,快递员不会拿着每个包裹挨家挨户等你开门——他把包裹上架,登记一个货架号就走,取件是另一批人的事。上架和派送被这个架子彻底分开:车可以一直卸货,架子上某个一直没人来取的件也不会卡住后面的人。
D8 结束时,你的服务正好卡在"没有货架"这个状态。POST /chat 做完鉴权、限流、写库、返回 runId 就结束了。最直接的想法是:请求已经在手里,干脆就地执行完再返回。D8 算过这条路的账——一个慢执行会把整个进程的事件循环拖住——这里再补三条更硬的理由。
第一,流量的形状对不上。 用户是一阵一阵来的,早高峰十分钟的量可能顶平峰一小时。接入层必须按峰值配容量,执行层只要按平均值配、让积压的活在架子上排队。挤在一个进程里,你被迫给执行层也配峰值容量,钱花在了大半天空转的进程上。
第二,故障的边界对不上。 模型抖动、工具超时、一次执行跑满 40 秒,都是执行层的日常。和"接受请求"共用一个进程,一次执行的失败就会波及正在握手的连接——用户看到"网站挂了",实际上只是某个模型慢了。
第三,扩容的单位对不上。 接入层缺连接数和内存,执行层缺并发和出口带宽,合在一起你只能两边一起加。
那能不能不上 Redis,直接把 runs 表当队列?select ... for update skip locked 是真能跑的,每秒十几条完全够用。它的问题在于轮询:间隔设 1 秒,用户白等最多 1 秒;设 50 毫秒,一台空闲的库上就凭空多了每秒 20 次无效查询乘以 Worker 数。消息总线的价值是推——XREADGROUP 的 BLOCK 参数让 Worker 挂在那儿等,有消息立刻醒。
代价同样要说清:多了一个必须运维的组件,多了一跳延迟,而且消息一定会被重复投递——这是本章第四节的主题。
代码上今天的改动只有两行:落库之后把 id 投出去。
// 依赖:ioredis 5.x。createRun 就是 D8 那条 insert,返回 inserted 告诉你有没有真的插进去
const { runId, inserted } = await store.createRun({ sessionId, idempotencyKey, input: text })
// 撞了 idempotency_key 的唯一约束:这次是客户端重试,已经有人在跑了,不要再投一遍
if (!inserted) return reply.send({ runId, duplicated: true })
// 消息体只放 id,不放正文:总线是路由,不是数据库
const streamId = await redis.xadd('koda:runs', '*', 'runId', runId, 'sessionId', sessionId)
reply.send({ runId, streamId })# 依赖:redis 5.x 的 redis.asyncio。xadd 直接吃一个 dict,比手写扁平数组舒服
run_id, inserted = await store.create_run(session_id, idempotency_key, text)
# 撞了 idempotency_key 的唯一约束:这次是客户端重试,不要再投一遍
if not inserted:
return {"runId": run_id, "duplicated": True}
# 消息体只放 id,不放正文:总线是路由,不是数据库
stream_id = await redis.xadd("koda:runs", {"runId": run_id, "sessionId": session_id})
return {"runId": run_id, "streamId": stream_id}// 依赖:Lettuce(同步 API)。executeUpdate 返回 0 就代表 on conflict do nothing 生效了
var created = store.createRun(sessionId, idempotencyKey, text);
if (!created.inserted()) {
return new SubmitResult(created.runId(), true, null);
}
// Lettuce 的 xadd 收一个 Map,键值都是 String,正好对上 Redis 的字段模型
var fields = Map.of("runId", created.runId(), "sessionId", sessionId);
String streamId = redis.xadd("koda:runs", fields);
return new SubmitResult(created.runId(), false, streamId);// 依赖:RediStack。它没有为 Streams 准备专用方法,直接发原生命令即可——
// 参数顺序和你在 redis-cli 里敲的完全一致,反而更容易和文档对照
let created = try await store.createRun(sessionId: sessionId, key: idempotencyKey, input: text)
guard created.inserted else {
return SubmitResult(runId: created.runId, duplicated: true, streamId: nil)
}
// RESPValue 不是字符串字面量类型,所以先按 redis-cli 的参数顺序写成 [String],再统一转一次
let args = ["koda:runs", "*", "runId", created.runId, "sessionId", sessionId]
.map(RESPValue.init(from:))
let streamId = try await redis.send(command: "XADD", with: args).get().string
return SubmitResult(runId: created.runId, duplicated: false, streamId: streamId)注意这两步的顺序:先落库、再投递。反过来的话,Worker 完全可能在 runs 那一行还没提交时就把消息捞走,读到一个不存在的 runId。
顺序对了还剩一个洞:落库成功、XADD 却失败了,这个 run 会永远停在 pending,没人执行也没人报错。省事的解法是一个定时任务扫"创建超过 30 秒还停在 pending"的 run 重新投递。只要总线和数据库是两个系统,这个洞就一定存在,你只能补偿它,不能消灭它。 面试时主动说出这一条,比把 XADD 讲得多熟都管用。
那货架具体怎么用?Redis Streams 一共只有四个命令要记,而它们凑在一起恰好是一整套投递语义。
四个命令,一套投递语义
回到驿站。一个包裹在架子上的完整生命周期是四个动作:上架、被某个快递员取走并登记进"派送中"清单、签收后销掉记录,以及某个快递员出事故没回来、他手上的件被别人接手。Redis Streams 的四个命令一一对应:
| 驿站动作 | 命令 | 干了什么 |
|---|---|---|
| 包裹上架 | XADD | 往流尾部追加一条消息,返回一个自增 id |
| 取件并登记派送中 | XREADGROUP | 把消息分给某个消费者,同时记进 pending 清单 |
| 签收,销掉记录 | XACK | 把消息从 pending 清单里删掉 |
| 接手别人手上超时的件 | XAUTOCLAIM | 把闲置太久的 pending 消息改判给另一个消费者 |
在 redis-cli 里走一遍,比看十段解释都清楚:
127.0.0.1:6509> XGROUP CREATE koda:runs workers 0 MKSTREAM
OK
127.0.0.1:6509> XADD koda:runs * runId run-1 sessionId s1
"1757000000123-0"
127.0.0.1:6509> XREADGROUP GROUP workers worker-a COUNT 10 STREAMS koda:runs >
1) 1) "koda:runs"
2) 1) 1) "1757000000123-0"
2) 1) "runId" 2) "run-1" 3) "sessionId" 4) "s1"
127.0.0.1:6509> XPENDING koda:runs workers
1) (integer) 1 # 有 1 条已投递未确认
2) "1757000000123-0"
3) "1757000000123-0"
4) 1) 1) "worker-a" 2) "1"
127.0.0.1:6509> XACK koda:runs workers 1757000000123-0
(integer) 1 # 真的销掉了 1 条
127.0.0.1:6509> XACK koda:runs workers 1757000000123-0
(integer) 0 # 再 ack 一次返回 0:XACK 天生幂等三个细节值得记住。第一,消息 id 的形状是"毫秒时间戳 + 同毫秒内的序号",天然单调递增,所以"把某个时间点之后的消息全部重放"是免费的能力。第二,XREADGROUP 结尾那个大于号是命令的一部分,含义是"给我从没投递给本组任何人的新消息";换成 0 就变成"重读我自己 pending 清单里的那些",新消息永远读不到,而且不报任何错。第三,XACK 返回的是"真的销掉了几条",重复 ack 同一条返回 0,所以补 ack 永远安全。
工程代价只有一条,但很硬:流只增不减。 XACK 删的是 pending 清单里的记录,消息本体还留在流里。一条消息约 100 字节,每天 100 万条就是 100MB,一个月 3GB——Redis 是内存数据库,这个账必须算。解法是 XADD 时带上 MAXLEN,或者定时 XTRIM。
一条流,三个 Worker 分着吃
同一个货架前站着三个快递员。加入同一个班组时,每个件只会被其中一个人取走,效率相加;各拿一本自己的登记本、互不通气的话,同一个件会被三个人各送一遍。前者是工作队列,后者是发布订阅。
Redis Streams 用消费组(consumer group)这一个概念同时支持两种:同一个组内的多个消费者分摊消息,不同的组各自都能拿到全量。 本课只用一个组,名字固定叫 workers。
┌──────────────── consumer group: workers ─────────────────┐
│ │
koda:runs 流 │ worker-1 ──┐ │
[m1][m2][m3][m4] ─┼─▶ worker-2 ──┼─▶ 每条消息只进一个 worker │
(只增不减) │ worker-3 ──┘ 已投递未 XACK 的躺在 pending 清单里 │
└──────────────────────────────────────────────────────────┘那张 pending 清单是整套机制的枢纽,Redis 里叫 PEL(pending entries list)。它记的是三件事:这条消息归谁、被投递过几次、最后一次投递在什么时刻。"被投递过几次"这一列很关键,第五节的毒消息判定直接读它,不需要你另建一张计数表。
Worker 的消费循环因此长成固定的形状:先捡别人掉在地上的,再取新的。
// 顺序不能反:先 XAUTOCLAIM 再 XREADGROUP。反过来的话,一条被遗弃的消息
// 会永远排在新消息后面,忙的时候永远轮不到它
const [, claimed] = await redis.xautoclaim('koda:runs', 'workers', me, 30000, '0', 'COUNT', 10)
const fresh = claimed.length
? []
: await redis.xreadgroup('GROUP', 'workers', me, 'COUNT', 10, 'BLOCK', 5000, 'STREAMS', 'koda:runs', '>')
for (const msg of normalize(claimed, fresh)) {
try {
await execute(msg.fields.runId)
// XACK 必须是最后一步。提前销号 = 进程一崩这条消息就人间蒸发
await redis.xack('koda:runs', 'workers', msg.id)
} catch (err) {
// 什么都不做:消息留在 pending 里,等 XAUTOCLAIM 把它交给下一个空闲 Worker
log.warn({ err, id: msg.id }, '处理失败,留在 pending 等重投')
}
}# 顺序不能反:先 XAUTOCLAIM 再 XREADGROUP,否则被遗弃的消息永远排在新消息后面
_, claimed, _ = await redis.xautoclaim("koda:runs", "workers", me, min_idle_time=30_000, start_id="0", count=10)
fresh = [] if claimed else await redis.xreadgroup(
"workers", me, {"koda:runs": ">"}, count=10, block=5_000
)
for msg_id, fields in normalize(claimed, fresh):
try:
await execute(fields["runId"])
# XACK 必须是最后一步,提前销号等于把消息弄丢
await redis.xack("koda:runs", "workers", msg_id)
except Exception:
# 什么都不做:留在 pending 里,等 XAUTOCLAIM 交给下一个空闲 Worker
log.warning("处理失败,留在 pending 等重投: %s", msg_id, exc_info=True)// Lettuce 用 Consumer.from + XAutoClaimArgs 描述这件事,比手拼参数不容易出错
var consumer = Consumer.from("workers", me);
var claimArgs = XAutoClaimArgs.Builder.xautoclaim(consumer, Duration.ofSeconds(30), "0").count(10);
var claimed = redis.xautoclaim("koda:runs", claimArgs).getMessages();
// 有接手来的积压就先消化完,这一轮不读新消息;没有才去阻塞读
var fresh = claimed.isEmpty()
? redis.xreadgroup(consumer, XReadArgs.Builder.count(10).block(Duration.ofSeconds(5)),
XReadArgs.StreamOffset.lastConsumed("koda:runs"))
: List.<StreamMessage<String, String>>of();
for (var msg : Stream.concat(claimed.stream(), fresh.stream()).toList()) {
try {
execute(msg.getBody().get("runId"));
// XACK 必须是最后一步
redis.xack("koda:runs", "workers", msg.getId());
} catch (Exception err) {
// 什么都不做:留在 pending 里,等 XAUTOCLAIM 交给下一个空闲 Worker
log.warn("处理失败,留在 pending 等重投: {}", msg.getId(), err);
}
}// RediStack 没有 Streams 专用方法,直接发原生命令。好处是参数顺序与官方文档一致,
// 坏处是回包要自己解,所以把解析收进 normalize 一次写完
func resp(_ parts: [String]) -> [RESPValue] { parts.map(RESPValue.init(from:)) }
let reclaimed = try normalize(await redis.send(command: "XAUTOCLAIM",
with: resp(["koda:runs", "workers", me, "30000", "0", "COUNT", "10"])).get())
// 有接手来的积压就先消化完,这一轮不读新消息
let fresh: [BusMessage] = reclaimed.isEmpty
? try normalize(await redis.send(command: "XREADGROUP",
with: resp(["GROUP", "workers", me, "COUNT", "10", "BLOCK", "5000",
"STREAMS", "koda:runs", ">"])).get())
: []
for msg in reclaimed + fresh {
// 字典下标返回 String?。消息体里没有 runId 属于「坏消息」,和「执行失败」是两回事:
// 重投多少次都不会变好,直接销号,别让它在 pending 里反复占位
guard let runId = msg.fields["runId"] else {
_ = try await redis.send(command: "XACK", with: resp(["koda:runs", "workers", msg.id])).get()
continue
}
do {
try await execute(runId)
// XACK 必须是最后一步
_ = try await redis.send(command: "XACK", with: resp(["koda:runs", "workers", msg.id])).get()
} catch {
// 什么都不做:留在 pending 里,等 XAUTOCLAIM 交给下一个空闲 Worker
logger.warning("处理失败,留在 pending 等重投: \(msg.id)")
}
}这里有个容易被忽略的选择:消费者的名字。 容器每次重启取一个随机名,上一个名字下那些没 ack 的消息就成了没人认领的孤儿,只有 XAUTOCLAIM 能捡回来。所以要么让名字稳定(用有状态部署给的序号),要么依赖 XAUTOCLAIM 兜底并定期 XGROUP DELCONSUMER 清理死名字。本课选后者。
XAUTOCLAIM 的空闲阈值同样要想清楚。给 30 秒是因为一次执行的正常耗时远小于它;给 100 毫秒的话,一个还在正常处理的消息就会被别人抢走,同一个 run 被跑两遍。但把阈值调大只是降低概率,不能消灭重复——真正兜底的是下一节那两道闸门。
至少一次:同一条消息你一定会收到两遍
Worker 处理完了、正准备 XACK 的那一瞬间进程被 kill。业务已经做完,pending 清单里那条记录还在。等空闲阈值一到,XAUTOCLAIM 把它交给另一个 Worker,于是同一件事被做了第二遍。
这个窗口消不掉:写业务(Postgres)和销号(Redis)是两个系统的两次写,除非塞进同一个事务,中间就永远有一个可以崩溃的间隙。三种语义因此是这么排的:
- at-most-once:先
XACK再干活。代价是进程一崩就直接丢单——除了埋点日志这类"丢一条无所谓"的场景,别用。 - at-least-once:先干活再
XACK。至少一次、可能多次。这是 Redis Streams、Kafka、SQS 全都提供的语义,也是本课的选择。 - exactly-once:需要把两次写合成一次原子提交。Kafka 的事务能在"读 Kafka 写 Kafka"的闭环里做到,一旦下游是数据库或第三方 API 就又退回至少一次。
所以面试时正确的说法是:exactly-once 不是总线给你的,是消费端幂等做出来的效果。 只说"业务要幂等"是空话,得指着具体的约束说它挡在哪。本课的两道闸门都在 D8 定稿的表上:
-- 闸门一:客户端重试 POST /chat。同一个意图重试多少遍,只会产生一个 run
insert into runs (id, session_id, status, idempotency_key, input)
values ($1, $2, 'pending', $3, $4)
on conflict (idempotency_key) do nothing
returning id;
-- 返回空行 = 撞了唯一约束 = 这次是重试,Gateway 直接返回已有的 runId,不再 XADD
-- 闸门二:同一条总线消息被投递两遍。同一个 run 的同一个序号只能有一条回复
insert into messages (id, session_id, run_id, role, content, seq)
values ($1, $2, $3, 'assistant', $4, 1)
on conflict (run_id, seq) do nothing;
-- 就算两个 Worker 真的同时跑完了同一个 run,用户也只会看到一条回复第一道挡的是重复提交,第二道挡的是重复执行。中间还可以加一道便宜的短路:Worker 捞到消息后先读一次 runs,发现状态已经是 done 就直接补一个 XACK 走人——这一步省下的是一次模型调用的钱,是优化;真正的正确性靠那两个唯一约束。
毒消息:一条坏消息能拖垮整条流
有一条消息无论谁来处理都会失败:输入里带着一个已经被删掉的订单号,或者当初写进去的字段格式压根不对。按上一节的规矩,失败就不 XACK、留在 pending 等重投,于是它进入死循环:投递、失败、闲置超时、被接手、再失败。它永远好不了,还一直占用 Worker 的处理能力。一条坏消息拖垮整条流,这就是毒消息(poison message)。
驿站里的做法很朴素:一个地址永远送不到的件,不能让快递员每天都去试一次,得挪到"问题件区"由专人处理。
本课的规则固定成三个数:
- 判定依据是 PEL 自己记的投递次数,不用另建计数表。
- 阈值是 3:投递 3 次仍失败就隔离。
- 隔离动作是搬到
koda:runs:dead这条死信流,然后对原流XACK,同时把 run 标成failed并写入错误原因。
三个动作缺一不可。只搬不 XACK,它还躺在 pending 里等着被接手;只 XACK 不搬,消息和失败原因一起消失,用户那边永远停在"正在思考"。
const POISON_THRESHOLD = 3
async function onFailure(msg, err) {
// 够不到阈值:什么都不做。消息留在 pending 里,等 XAUTOCLAIM 交给下一个 Worker
if (msg.deliveryCount < POISON_THRESHOLD) return
// 够了:搬走、销号、标记失败,三件事缺一不可
await redis.xadd('koda:runs:dead', '*',
'runId', msg.fields.runId, 'originalId', msg.id,
'deliveries', String(msg.deliveryCount), 'reason', err.message)
await redis.xack('koda:runs', 'workers', msg.id)
await store.markFailed(msg.fields.runId, err.message)
}POISON_THRESHOLD = 3
async def on_failure(msg: BusMessage, err: Exception) -> None:
# 够不到阈值:什么都不做。消息留在 pending 里,等 XAUTOCLAIM 交给下一个 Worker
if msg.delivery_count < POISON_THRESHOLD:
return
# 够了:搬走、销号、标记失败,三件事缺一不可
await redis.xadd("koda:runs:dead", {
"runId": msg.fields["runId"],
"originalId": msg.id,
"deliveries": msg.delivery_count,
"reason": str(err),
})
await redis.xack("koda:runs", "workers", msg.id)
await store.mark_failed(msg.fields["runId"], str(err))static final int POISON_THRESHOLD = 3;
// BusMessage 是个 record,deliveryCount 直接来自 XPENDING 读到的那一列
void onFailure(BusMessage msg, Exception err) {
// 够不到阈值:什么都不做。消息留在 pending 里,等 XAUTOCLAIM 交给下一个 Worker
if (msg.deliveryCount() < POISON_THRESHOLD) return;
// 够了:搬走、销号、标记失败,三件事缺一不可。
// getMessage() 常常是 null(NPE、IllegalStateException 都可能不带 message),
// 而 Map.of 不接受 null 值——直接塞进去,这三步会在第一步就抛异常,
// 毒消息反而永远留在 pending 里,正好和这段代码的目的相反
var reason = err.getMessage() != null ? err.getMessage() : err.getClass().getSimpleName();
var dead = Map.of(
"runId", msg.fields().get("runId"),
"originalId", msg.id(),
"deliveries", String.valueOf(msg.deliveryCount()),
"reason", reason);
redis.xadd("koda:runs:dead", dead);
redis.xack("koda:runs", "workers", msg.id());
store.markFailed(msg.fields().get("runId"), reason);
}let poisonThreshold = 3
func onFailure(_ msg: BusMessage, _ err: Error) async throws {
// guard 提前退出是 Swift 的惯用写法:够不到阈值就什么都不做,
// 消息留在 pending 里,等 XAUTOCLAIM 交给下一个 Worker
guard msg.deliveryCount >= poisonThreshold else { return }
// 够了:搬走、销号、标记失败,三件事缺一不可。runId 只解包一次,
// 免得同一个函数里一处 ?? ""、一处直接传 optional,两种口径
guard let runId = msg.fields["runId"] else { return }
let fields = ["runId", runId, "originalId", msg.id,
"deliveries", String(msg.deliveryCount), "reason", "\(err)"]
_ = try await redis.send(command: "XADD", with: resp(["koda:runs:dead", "*"] + fields)).get()
_ = try await redis.send(command: "XACK", with: resp(["koda:runs", "workers", msg.id])).get()
try await store.markFailed(runId: runId, error: "\(err)")
}今天实验里那条 boom 消息的完整轨迹长这样:
[worker-c] run=run-6 第 1 次失败,留在 pending 等重投
[worker-c] XAUTOCLAIM 接手 1757000000475-1(第 2 次投递)
[worker-c] run=run-6 第 2 次失败,留在 pending 等重投
[worker-c] XAUTOCLAIM 接手 1757000000475-1(第 3 次投递)
[worker-c] run=run-6 投递 3 次仍失败 → 搬去 koda:runs:dead
[5/5] 毒消息隔离:✅ 失败 3 次,死信流 1 条,run.status=failed,pending 0阈值定 3 是个权衡:定 1 意味着一次网络抖动就把本来能成功的消息判死,定 10 意味着在一条必死的消息上浪费十次执行的钱和时间。另外注意这里没有指数退避:Redis Streams 的重投时机由空闲阈值决定,想退避就得自己把消息重新 XADD 并带上"下次可执行时间",那已经是在实现延迟队列——本课不做,但要知道这是 Streams 相比专业消息中间件的一个缺口。
Redis Streams 还是 Kafka
这题的出现频率高得惊人,而正确答案不是"看数据量"。
| 维度 | Redis Streams | Kafka |
|---|---|---|
| 数据放在哪 | 内存为主,AOF/RDB 落盘 | 磁盘为主,顺序写,天生适合长期保留 |
| 保留策略 | 靠你自己 MAXLEN / XTRIM 裁剪 | 按时间或大小配置,保留几周很常见 |
| 顺序保证 | 单条流内有序,消费组内分配是随机的 | 分区(partition)内有序,同 key 落同分区 |
| 消费者伸缩 | 一个组里加多少消费者都行 | 受分区数限制,消费者多于分区就有人空转 |
| 回放 | 按消息 id 从任意时间点重读 | 按 offset 重置,成熟得多 |
| 运维成本 | 你大概已经有 Redis 了,零新增 | 至少一个集群加协调组件,需要专人 |
判据是这两句话:消息需要保留多久,以及会不会有第二类消费方。 生命周期是"执行一次就没用了"、且只有 Worker 一个消费方,Streams 完全够用,还省掉一整套运维。需要"三个月内任意时间点重放"、或者同一份数据要同时喂给实时执行、离线数仓、风控三条链路,那就该上 Kafka。
还有一条必须诚实说出口的话:Redis 的持久化是有损的。 AOF 默认每秒刷盘,最坏丢掉最后 1 秒的写入;主从异步复制,故障切换时未同步的消息会消失。所以本课的架构里,真相之源永远是 Postgres 的 runs 表,流只是一个触发器——丢了一条消息,那个 run 还停在 pending,补投任务会把它捡回来。把总线当唯一数据源,是这套架构里最危险的误用。
顺带留一句给部署:流名要带 dev: 或 prod: 前缀(取自 APP_ENV),否则本机调试的消息会被线上的 Worker 捞走执行。这个细节 D14 会正式用到。
至此链路通了,但消费组埋了一颗雷:它把消息分给哪个 Worker,完全取决于谁先来问。 对今天的 echo 无所谓,对"同一个用户连发两句话"就是灾难——两句话被两个 Worker 同时处理,谁先写完 messages 表谁就排在前面,用户看到的是颠倒的对话。这就是明天要正面解决的问题。
源码导读
动手实验
MOCK=1 下不需要 Redis、不需要 Docker、也不需要任何 API key:src/infra/memory-bus.ts 是一份内存实现而不是打桩,读游标、pending 清单、投递计数都真的写了出来,离线跑到的现象和真 Redis 一模一样。想验证这一点,docker compose up -d 起一个 redis:7-alpine,把 MOCK=1 换成 REDIS_URL 再跑一次——同一份业务代码,五项自检同样全绿。业务处理今天刻意只做 echo,把 worker.ts 里的 generateReply 换成 D4 的模型调用层,链路其余部分一行都不用改。卡住了先看 README 的"常见坑"。
- 先
MOCK=1 SELFTEST=1 pnpm start跑一次starter/,记住"2/5 通过"这个数字和三条 ❌ 的文案。 - 练习 1:把
shared/store.ts的createRun做成按幂等键on conflict do nothing,让第 3 项变绿——你会看到 run 总数从 4 回到 3。 - 练习 2 和 3:在
infra/memory-bus.ts里把投递登记进 pending 清单(记下消费者、投递次数、投递时刻),再实现autoClaim按空闲时长改判归属并把投递次数加一。第 4 项变绿,日志里会出现XAUTOCLAIM 接手那一行。 - 练习 4:在
worker/worker.ts里补上死信搬运——投递次数够到 3 就publish到koda:runs:dead、对原流XACK、把 run 标成failed。第 5 项变绿。 - 起真 Redis 再跑一遍自检,并用
redis-cli的XLEN、XINFO GROUPS、XPENDING把自检打印的三个数字逐一对上。
面试题
今天 5 道题在下方题库区,侧重 at-least-once 与 exactly-once 的边界、Streams 与 Kafka 的选型、以及死信处理。展开后先看"分析过程"再看要点——第 3 题(幂等)是本章最容易被追问到底的地方,别跳过。标注"国内高频 / 海外高频"方便按目标市场取舍。
检查清单与明日预告
- 能用 XADD 写入消息、用 XREADGROUP 以 consumer group 方式消费一条消息并 XACK
- 能解释 XACK 和 XAUTOCLAIM 分别解决什么问题
- 能设计一个简单的毒消息(反复失败的消息)隔离机制
- 能说清消费组里 pending 清单记了哪三件事,以及为什么毒消息判定不用另建计数表
- 能指着两个唯一约束说清"至少一次投递"分别被挡在哪一步,而不是只说"业务要幂等"
- 实验的 5 条验收标准全部通过,真 Redis 上也跑过一遍
- 5 道面试题不看要点也能答出至少 4 道
明天(D10)我们解决今天最后留下的那颗雷:消费组把消息随机分给任意一个空闲 Worker,同一个用户的两句话就会被两个 Worker 同时处理,回复顺序颠倒。做法是把用户按 id 哈希到 256 个固定分片上,每个分片同一时刻只有一个 Worker 持有租约。为什么先总线再分片?因为不先把消息推起来,你根本看不到乱序这个现象;而如果一上来就讲分片,你会以为它是给 Redis 加锁——它其实是为了保住同一个用户的对话顺序。
面试题库
Redis Streams 的 consumer group 是怎么工作的?为什么它既能做工作队列又能做发布订阅?How does a Redis Streams consumer group work, and why can it serve both as a work queue and as pub/sub?
国内高频海外高频基础#message-bus#redis-streams分析过程 · 先想清楚再作答
- 这题是概念题,区分度在于你有没有把「组」和「消费者」两层分清。只答「多个消费者一起消费」会被追着问「那同一条消息会不会被消费两次」,而这正是两层的区别所在。
- 先给两层结构:流本身只增不减,组挂在流上、维护一个读游标和一份 pending 清单,消费者挂在组上、只是组内的一个名字。同一个组内的消费者分摊消息(一条只进一个人),不同的组各自都能读到全量——工作队列和发布订阅就是这一个数据结构的两种用法。
- 接着点出 pending 清单(PEL)记了哪三件事:这条消息归哪个消费者、被投递过几次、最后一次投递在什么时刻。这三列分别对应「谁在处理」「要不要判成毒消息」「能不能被别人接手」,答出来就说明你真的读过文档而不只是抄过示例。
- 结论要落到分配规则上:组把消息分给谁,完全取决于谁先来问,没有任何亲和性。所以 consumer group 天然不保证「同一个用户的多条消息按顺序被同一个人处理」——这一句是把话题引向自己准备好的深水区。
- 可以预期的追问一:消费者的名字该怎么取?答:随机名会让进程重启后老名字下的未确认消息变成孤儿,只能靠 XAUTOCLAIM 捡回来,所以要么用有状态部署给的稳定序号,要么就必须依赖 XAUTOCLAIM 兜底,并定期用 XGROUP DELCONSUMER 清理不会再回来的名字。
- 可以预期的追问二:怎么保住同一个用户的顺序?答:在总线之上做分片——把用户 id 哈希到固定数量的分片,每个分片同一时刻只由一个消费者持有,顺序就回来了。消费组本身解决不了这件事。
How to reason about it · think before answering
- This is a concept question; the discriminator is whether you separate the group layer from the consumer layer. Saying only 'several consumers read together' invites 'so is a message processed twice?' — and that is exactly what the two layers settle.
- Give the structure: the stream is append-only; a group sits on the stream and owns a read cursor plus a pending list; a consumer is just a name inside a group. Consumers in one group share the messages (each message goes to exactly one of them), while separate groups each see the full stream — one data structure, both a work queue and pub/sub.
- Then name the three things the pending entries list records: which consumer owns the message, how many times it has been delivered, and when it was last delivered. Those map to 'who is working on it', 'is it poison yet' and 'can someone else take over' — knowing them signals you read the docs, not just a snippet.
- Land on the dispatch rule: a group hands a message to whoever asks first, with no affinity at all. So a consumer group does not keep multiple messages from the same user in order on the same worker — say this yourself and you steer into ground you have prepared.
- Expect: how do you name consumers? Random names orphan the unacked messages of the previous name after a restart, recoverable only via XAUTOCLAIM. Either use stable ordinals from a stateful deployment, or rely on XAUTOCLAIM and periodically prune dead names with XGROUP DELCONSUMER.
- Expect: how do you preserve per-user order? Shard above the bus — hash the user id onto a fixed number of shards and let one consumer own a shard at a time. The consumer group cannot do this for you.
答题要点
- 流只增不减;组挂在流上,维护读游标和 pending 清单;消费者是组内的一个名字
- 同组内消息被分摊(一条只进一个消费者),不同组各自拿到全量,所以同一个结构同时支持工作队列和发布订阅
- pending 清单记三件事:归属的消费者、投递次数、最后一次投递时刻,分别用于接手、毒消息判定和超时检测
- 分配没有亲和性,谁先来问给谁,所以不保证同一个用户的多条消息顺序,要在总线之上做分片
- 消费者名字随机会在重启后留下孤儿消息,要么名字稳定,要么依赖 XAUTOCLAIM 并清理死名字
Key points
- The stream is append-only; a group holds a read cursor and a pending list; a consumer is a name within a group
- Within a group messages are split (one message, one consumer); separate groups each get everything, so one structure covers both work queue and pub/sub
- The pending list records owner, delivery count and last-delivery time — used for takeover, poison detection and timeouts
- Dispatch has no affinity, so per-user ordering is not guaranteed and needs sharding above the bus
- Random consumer names orphan unacked messages after a restart; use stable names or rely on XAUTOCLAIM plus XGROUP DELCONSUMER cleanup
XACK 和 XAUTOCLAIM 分别解决什么问题?XACK 放在业务处理之前和之后有什么区别?What problems do XACK and XAUTOCLAIM each solve, and what changes if you XACK before instead of after doing the work?
国内高频海外高频进阶#message-bus#redis-streams#error-handling分析过程 · 先想清楚再作答
- 题眼在后半句。前半句背文档就能答,后半句在考你知不知道 ack 的时机直接决定了整个系统的投递语义——答不出这一点,面试官会判定你没在生产里管过队列。
- 先把两个命令的分工说清:XACK 是「销号」,把消息从 pending 清单里删掉,代表这件事真的做完了;XAUTOCLAIM 是「接手」,把闲置超过阈值的 pending 消息改判给另一个消费者,代表原来那个人可能已经死了。一个负责正常收尾,一个负责异常兜底。
- 然后回答时机问题,用一句话定性:先 ack 再干活是 at-most-once,先干活再 ack 是 at-least-once。前者进程一崩消息就人间蒸发,pending 清单里查不到、XAUTOCLAIM 也捡不回来;后者最坏是重复执行,而重复可以用幂等挡掉,丢单挡不掉。所以除了埋点日志这类丢一条无所谓的场景,一律先干活再 ack。
- 补一个大多数人漏掉的点:处理失败时正确的动作是**什么都不做**,让消息留在 pending 里等 XAUTOCLAIM。很多人会在 catch 里顺手 ack 掉,那等于把失败的消息静默丢弃,比不重试更糟——因为你连丢了什么都不知道。
- 再补一条 XAUTOCLAIM 的参数取舍:空闲阈值要大于「一次正常处理的耗时上限」。给太小会把还在正常处理的消息抢走,同一件事被跑两遍;给太大则故障恢复变慢。但要说清,调大阈值只降低重复概率,不消灭重复,兜底始终是消费端的唯一约束。
- 可以预期的追问:为什么用 XAUTOCLAIM 而不是 XCLAIM?答:XCLAIM 要你先 XPENDING 查出候选 id 再点名认领,两步之间还有竞态;XAUTOCLAIM 自己扫 pending 并返回游标,一条命令搞定,是 Redis 6.2 之后的推荐做法。
How to reason about it · think before answering
- The hinge is the second half. The first half is documentation; the second asks whether you know that ack timing decides the delivery semantics of the whole system.
- Split the two commands: XACK clears a message from the pending list, meaning the work is genuinely finished; XAUTOCLAIM reassigns a pending message that has been idle past a threshold, meaning its previous owner may be dead. One is the normal path, the other is the failure path.
- Then answer the timing question categorically: ack-then-work is at-most-once, work-then-ack is at-least-once. In the first, a crash makes the message vanish — it is not in the pending list, so XAUTOCLAIM cannot recover it. In the second, the worst case is duplicate execution, and duplicates can be blocked by idempotency while lost work cannot. Always work first, except for fire-and-forget telemetry.
- Add the point most people miss: on failure the correct action is to do nothing and leave the message pending for XAUTOCLAIM. Acking inside the catch block silently discards failures, which is worse than no retry because you no longer know what you lost.
- Add the parameter trade-off: the idle threshold must exceed the worst-case normal processing time. Too small and a healthy in-flight message gets stolen and executed twice; too large and recovery is slow. Be explicit that tuning it only lowers the probability of duplicates — the real backstop is a uniqueness constraint on the consumer side.
- Expect: why XAUTOCLAIM rather than XCLAIM? XCLAIM needs an XPENDING scan first and then a named claim, with a race in between; XAUTOCLAIM scans and returns a cursor in one command, and is the recommended approach since Redis 6.2.
答题要点
- XACK 负责正常收尾:把消息从 pending 清单里销号,代表这件事真的做完了;重复 ack 返回 0,天生幂等
- XAUTOCLAIM 负责异常兜底:把闲置超过阈值的 pending 消息改判给另一个消费者,解决「消费者死了它手上的消息怎么办」
- 先 ack 再干活是 at-most-once,崩溃就丢单;先干活再 ack 是 at-least-once,最坏是重复,可以用幂等挡
- 处理失败时不要 ack,让消息留在 pending 里等接手;在 catch 里顺手 ack 等于静默丢弃失败
- 空闲阈值要大于正常处理耗时的上限,但调大只降低重复概率,兜底仍是消费端唯一约束
Key points
- XACK is the happy-path close-out: it clears the message from the pending list; repeat acks return 0, so it is naturally idempotent
- XAUTOCLAIM is the failure backstop: it reassigns pending messages idle past a threshold, answering 'what happens to work held by a dead consumer'
- Ack-before-work is at-most-once and loses work on a crash; work-before-ack is at-least-once and at worst duplicates, which idempotency can absorb
- Never ack on failure — leave the message pending for takeover; acking in the catch block silently discards failures
- The idle threshold should exceed worst-case processing time, but tuning it only reduces duplicates; uniqueness constraints are the real guarantee
什么是 at-least-once?既然消息会被重复投递,业务上到底要怎么保证幂等?What is at-least-once delivery, and given that messages get redelivered, how do you actually make the business side idempotent?
国内高频海外高频深入#message-bus#idempotency#reliability分析过程 · 先想清楚再作答
- 这题是本章最容易被追到底的一道。绝大多数人能说出「至少一次,所以业务要幂等」,然后就没有下文了——面试官等的恰恰是下文:幂等具体落在哪一行代码上。答不出具体落点,前半句就是背的。
- 先解释为什么消费不掉这个重复:写业务和销号是两个系统的两次写(比如 Postgres 加 Redis),处理完成到 XACK 之间必然存在一个可以崩溃的窗口,崩在那里消息就会被重投。这个窗口只能变小,不能消失,所以 exactly-once 不是总线给你的语义。
- 由此得到一句可以直接说出口的结论:exactly-once 是消费端幂等做出来的**效果**,不是中间件提供的**能力**。Kafka 的事务能在「读 Kafka 写 Kafka」的闭环里做到,一旦下游是数据库或第三方 API 就又退回至少一次。
- 然后给具体落点,两道闸门要分清各自挡什么:第一道是 runs 表 idempotency_key 上的唯一约束,配 insert on conflict do nothing,挡的是**客户端重复提交**——冲突时接入层直接返回已有的 runId,连总线都不投第二遍;第二道是 messages 表的 unique(run_id, seq),同样 on conflict do nothing,挡的是**同一条总线消息被执行两遍**,就算两个消费者真的同时跑完,用户也只会看到一条回复。中间还可以加一道便宜的短路:捞到消息先看 run 是不是已经 done,是就直接补一个 XACK 走人——但那是省钱的优化,正确性靠的是那两个唯一约束。
- 接着讲最容易做错的一步:幂等键怎么取。它必须能从「同一个意图」稳定推出来。客户端每次重试都新生成一个 uuid 是最常见的错法,那每次都是新意图,唯一约束一次都命中不了,闸门形同虚设。正确做法是客户端生成一次、重试复用同一个值,服务端兜底可以用「会话 id 加消息内容哈希加秒级时间戳」。
- 可以预期的追问:不可逆的副作用怎么办,比如发一次退款?答:把外部调用也变成带幂等键的(大多数支付网关都支持 idempotency key 头),并且先在本地库里落一条「已发起」记录再调用,用同一个键去重;实在不支持的接口就只能靠本地状态机加人工对账,这时要主动说出「这类操作我会把它挪出重试路径」。
How to reason about it · think before answering
- This is the question that gets probed hardest. Most candidates say 'at-least-once, so make the business idempotent' and stop — but the follow-up is exactly what matters: which line of code enforces it.
- Explain why the duplicate cannot be removed: committing the business write and acking are two writes to two systems (say Postgres and Redis), so there is always a crash window between finishing the work and XACK. The window can shrink but not disappear, which is why exactly-once is not something the bus gives you.
- That yields a sentence worth saying out loud: exactly-once is an effect produced by consumer-side idempotency, not a capability provided by the broker. Kafka transactions achieve it inside a read-Kafka-write-Kafka loop, but the moment the sink is a database or third-party API you are back to at-least-once.
- Now name the concrete guards and what each one blocks. First, a unique constraint on runs.idempotency_key with insert ... on conflict do nothing, which blocks duplicate submissions: on conflict the gateway returns the existing run id and never publishes a second bus message. Second, unique(run_id, seq) on the messages table, also on conflict do nothing, which blocks duplicate execution: even if two consumers finish the same run simultaneously the user sees one reply. A cheap short-circuit can sit in between — read the run first and just re-ack if it is already done — but that saves money; correctness comes from the two constraints.
- Then the step people get wrong: deriving the key. It must be reproducible from the same intent. Generating a fresh uuid on every retry is the classic mistake, because every retry becomes a new intent and the constraint never fires. The client should mint the key once and reuse it across retries; a server-side fallback can hash session id plus message body plus a second-resolution timestamp.
- Expect: what about irreversible side effects such as issuing a refund? Push the idempotency key into the external call (most payment gateways accept an idempotency key header), and record an 'initiated' row locally before calling so the same key deduplicates. For APIs with no such support, fall back to a local state machine plus reconciliation, and say plainly that you would move such operations off the automatic retry path.
答题要点
- at-least-once:消息至少被处理一次、可能多次,因为业务提交和 XACK 是两个系统的两次写,中间的崩溃窗口消不掉
- exactly-once 是消费端幂等做出来的效果,不是中间件的能力;下游只要是数据库或第三方 API 就退回至少一次
- 闸门一:runs.idempotency_key 唯一约束 + on conflict do nothing,挡客户端重复提交,冲突时不再投递总线消息
- 闸门二:messages 表 unique(run_id, seq) + on conflict do nothing,挡同一条消息被执行两遍,用户只会看到一条回复
- 幂等键必须从同一个意图稳定推导,客户端重试要复用同一个值;每次重试新生成 uuid 等于没有幂等
- 不可逆副作用要把幂等键透传给外部接口,并先落一条本地记录再调用
Key points
- At-least-once means a message is processed one or more times, because the business commit and the XACK are two writes to two systems with an unavoidable crash window
- Exactly-once is an effect of consumer-side idempotency, not a broker feature; any database or third-party sink puts you back at at-least-once
- Guard one: a unique constraint on runs.idempotency_key with on conflict do nothing blocks duplicate submissions and skips publishing a second bus message
- Guard two: unique(run_id, seq) on messages with on conflict do nothing blocks duplicate execution, so the user sees exactly one reply
- The idempotency key must be derivable from the same intent and reused across retries; minting a new uuid per retry defeats the whole mechanism
- For irreversible side effects, pass the idempotency key through to the external API and record an initiated row locally before calling
Redis Streams 和 Kafka 该怎么选?什么情况下 Streams 明显不够用?How do you choose between Redis Streams and Kafka, and when is Streams clearly not enough?
国内高频海外高频进阶#message-bus#redis-streams#architecture分析过程 · 先想清楚再作答
- 这题的坏答案是「看数据量」。吞吐从来不是第一判据——单机 Redis 每秒几万条 XADD 毫无压力,绝大多数业务的量级根本碰不到天花板。答成「量小用 Streams、量大用 Kafka」会被认为没做过选型。
- 换成两个真正的判据来推:一、这些消息需要保留多久;二、会不会有第二类消费方。生命周期是「执行一次就没用了」、且只有执行层这一个消费方,Streams 完全够用,还省掉一整套运维;需要「三个月内任意时间点重放」、或者同一份数据要同时喂给实时执行、离线数仓、风控三条链路,那就该上 Kafka。
- 再补三条结构性差异:Streams 是内存为主、保留全靠你自己 MAXLEN 或 XTRIM,Kafka 是磁盘顺序写、保留几周是常态;Streams 一个组里加多少消费者都行,Kafka 的消费者数受分区数限制,多了就有人空转;顺序保证的粒度不同,Streams 是单条流内有序而组内分配随机,Kafka 是同 key 落同分区、分区内有序。
- 然后主动说出那条最能体现深度的话:Redis 的持久化是有损的。AOF 默认每秒刷盘,最坏丢最后一秒的写入;主从异步复制,故障切换时未同步的消息会消失。所以用 Streams 时架构上必须有一个真相之源——本课是 Postgres 的 runs 表,流只是触发器,丢了消息那个 run 还停在 pending,补投任务会把它捡回来。把总线当唯一数据源是最危险的误用。
- 结论落成一句可复用的判断:Streams 适合「触发执行」,Kafka 适合「数据管道」。前者的消息是一次性的命令,后者的消息是需要被多方反复读取的事实。
- 可以预期的追问:那 RabbitMQ、SQS 呢?答:RabbitMQ 强在复杂路由和延迟队列(Streams 没有原生延迟投递,要自己带「下次可执行时间」重投);SQS 强在零运维,代价是没有回放、也没有严格顺序(FIFO 队列另算)。把判据说成「保留时长、消费方数量、路由复杂度、运维预算」四条,比背产品参数强得多。
How to reason about it · think before answering
- The bad answer is 'it depends on volume'. Throughput is never the first criterion — a single Redis node handles tens of thousands of XADDs per second, and most workloads never approach that ceiling. 'Small volume Streams, large volume Kafka' reads as never having run a real evaluation.
- Use two real criteria instead: how long the messages must be retained, and whether a second class of consumer will appear. If a message is useless once executed and the execution layer is the only consumer, Streams is plenty and saves an entire operational surface. If you need replay from any point in the last three months, or the same data must feed real-time execution, an offline warehouse and a risk engine, choose Kafka.
- Add three structural differences: Streams is memory-first with retention you enforce yourself via MAXLEN or XTRIM, while Kafka does sequential disk writes and keeps weeks by default; a Streams group takes any number of consumers, while Kafka consumers are capped by partition count and extras idle; ordering granularity differs — Streams orders a single stream but dispatches randomly within a group, Kafka pins a key to a partition and orders within it.
- Then volunteer the line that shows real depth: Redis persistence is lossy. AOF fsyncs once per second by default, so the last second of writes can vanish, and replication is asynchronous, so a failover can drop unreplicated messages. Using Streams therefore requires a source of truth elsewhere — here the Postgres runs table, with the stream acting only as a trigger; a lost message leaves the run pending and a sweeper republishes it. Treating the bus as the only datastore is the dangerous misuse.
- Land on a reusable rule: Streams suits triggering work, Kafka suits data pipelines. One carries one-shot commands, the other carries facts that many parties re-read.
- Expect: what about RabbitMQ or SQS? RabbitMQ wins on complex routing and delayed delivery (Streams has no native delay, you republish with a next-eligible timestamp); SQS wins on zero operations at the cost of replay and strict ordering (FIFO queues aside). Framing the criteria as retention, number of consumers, routing complexity and operational budget beats reciting product specs.
答题要点
- 第一判据不是吞吐,是「消息要保留多久」和「会不会有第二类消费方」
- 只有执行层一个消费方、消息执行完即失效:Streams 够用,且大概率你已经有 Redis,零新增运维
- 需要长期保留与任意时间点回放、或多条下游链路共用同一份数据:选 Kafka
- 结构差异:Streams 内存为主、保留靠自己裁剪、组内分配随机;Kafka 磁盘顺序写、按 key 分区且分区内有序、消费者数受分区限制
- Redis 持久化有损(AOF 每秒刷盘、异步复制),所以真相之源必须是数据库,流只当触发器,靠补投任务兜底
- 一句话判断:Streams 适合触发执行,Kafka 适合数据管道
Key points
- The first criterion is not throughput but retention length and whether a second class of consumer will exist
- One consumer class and messages that expire on execution: Streams is enough, and you probably already run Redis
- Long retention with arbitrary replay, or one dataset feeding several downstream pipelines: pick Kafka
- Structural differences: Streams is memory-first with self-managed trimming and random in-group dispatch; Kafka is sequential-disk, key-partitioned with in-partition ordering, and caps consumers at partition count
- Redis persistence is lossy (per-second AOF fsync, async replication), so the database must be the source of truth with the stream as a trigger plus a republish sweeper
- One-line rule: Streams triggers work, Kafka moves data
一条消息反复处理失败怎么办?请设计一个毒消息隔离机制。What do you do with a message that keeps failing? Design a poison-message isolation mechanism.
国内高频海外高频进阶#message-bus#error-handling#reliability分析过程 · 先想清楚再作答
- 这题在考你有没有踩过「一条坏消息拖垮整条流」。判断标准很简单:你的回答里有没有出现一个具体的阈值和一个具体的落地位置,没有就是在讲概念。
- 先把故障模式说清楚:按 at-least-once 的规矩,失败就不 ack、留在 pending 等重投,于是一条无论谁来都会失败的消息进入死循环——投递、失败、闲置超时、被接手、再失败。它自己永远好不了,还持续占用消费者的处理能力。
- 然后给机制,三个动作缺一不可:一、判定依据用 pending 清单自己记的投递次数,不要另建计数表;二、超过阈值(本课固定 3 次)就把消息搬到一条死信流,字段里带上原始消息 id、投递次数和失败原因;三、对原流 XACK,同时把这次执行标成失败并写入错误原因。只搬不 ack,它还躺在 pending 里等着被接手;只 ack 不搬,消息和失败原因一起消失,用户永远停在「正在思考」。
- 阈值的取值要给出权衡:定 1 会让一次网络抖动就把本来能成功的消息判死;定 10 会在一条必死的消息上浪费十次执行的钱和时间。3 次配合每次之间的空闲阈值,足够熬过绝大多数瞬时故障。
- 还要主动说出一个缺口:Redis Streams 没有原生的指数退避,重投时机由空闲阈值决定。想要退避就得自己把消息重新投递并带上「下次可执行时间」,那已经是在实现延迟队列了——这一条能体现你知道 Streams 的边界在哪。
- 可以预期的追问:死信流建完就完了吗?答:不。死信条数必须接进告警,它从 0 变成非 0 通常意味着有一类输入你的代码处理不了,是真 bug 而不是运气差;还要留一个重放入口——把死信里的字段原样投回原流即可,因为幂等键还在,重放不会产生重复执行。见过团队把死信建起来半年没打开过,那等于把故障静音了。
How to reason about it · think before answering
- This question probes whether you have ever watched one bad message stall an entire stream. The test is simple: does your answer contain a concrete threshold and a concrete place where isolation happens? If not, you are talking theory.
- Describe the failure mode first: under at-least-once you do not ack on failure, so the message stays pending and gets redelivered. A message that fails for everyone therefore loops forever — delivered, failed, idle timeout, claimed, failed — never recovering while continuously consuming worker capacity.
- Then give the mechanism, three actions and all of them required. One, use the delivery count the pending list already tracks rather than building a counter table. Two, past the threshold (three deliveries in this course) move the message to a dead-letter stream carrying the original id, delivery count and failure reason. Three, XACK the original stream and mark the run failed with the error recorded. Moving without acking leaves it pending for another takeover; acking without moving makes both the message and its reason disappear, leaving the user stuck on 'thinking'.
- Justify the threshold: one delivery kills messages that a single network blip would have let through; ten wastes ten executions of money and time on a message that can never succeed. Three deliveries, spaced by the idle threshold, survives almost all transient faults.
- Volunteer a limitation: Redis Streams has no native exponential backoff — redelivery timing is governed by the idle threshold. Backoff requires republishing the message with a next-eligible timestamp, which means building a delay queue yourself. Naming this shows you know where Streams ends.
- Expect: is creating the dead-letter stream the end of it? No. Its depth must be alerted on, since going from zero to non-zero usually means a class of input your code cannot handle — a real bug, not bad luck. Keep a replay path too: republish the stored fields back to the original stream, and because the idempotency key is preserved, replay cannot cause duplicate execution. Teams that build a dead-letter stream and never open it have simply muted their failures.
答题要点
- 故障模式:at-least-once 下失败不 ack,一条永远失败的消息会无限重投并持续占用消费者
- 判定依据用 pending 清单里记的投递次数,不需要另建计数表
- 阈值固定 3 次:定 1 会误杀瞬时故障,定 10 会在必死消息上浪费十次执行成本
- 隔离动作三件缺一不可:搬到死信流(带原始 id、投递次数、失败原因)、对原流 XACK、把这次执行标成失败并写入原因
- Redis Streams 没有原生指数退避,重投时机由空闲阈值决定,要退避得自己实现延迟投递
- 死信流要接告警并留重放入口;幂等键还在,重放不会导致重复执行
Key points
- Failure mode: under at-least-once you do not ack on failure, so an always-failing message is redelivered forever and keeps consuming worker capacity
- Use the delivery count already tracked in the pending list rather than a separate counter table
- Fix the threshold at three deliveries: one kills transient failures, ten wastes ten executions on a message that can never succeed
- Isolation needs all three actions: move to a dead-letter stream with original id, delivery count and reason; XACK the original stream; mark the run failed with the error stored
- Redis Streams has no native exponential backoff — redelivery timing follows the idle threshold, so backoff means implementing delayed republishing yourself
- Alert on dead-letter depth and keep a replay path; the idempotency key survives, so replay cannot duplicate execution
评论
登录后即可参与讨论
还没有评论,来说第一句。