面试 Q&A — 全链路容错设计#
覆盖:Fail-open 哲学、DLQ、任务重试、Worker 崩溃恢复、健康检查、Postgres 事务模式、可观测性。
一、Fail-open 哲学#
Q1: 举个具体例子,凌晨 3 点 Redis 被 OOM Killer 干掉了,系统会怎样?#
场景:凌晨 3 点,宿主机内存不足,Redis 被 OOM Killer 杀掉。此时 Worker-2 正在跑一个 deep 诊断(已跑 2 分钟),队列里还有 15 个任务排着,Alertmanager 仍在持续推新告警。
四件事同时发生,靠不同机制各自兜底:
1. 正在跑的诊断(Worker-2):LLM 调用不依赖 Redis,诊断本身可能跑完。但并发槽心跳断了——30 秒没续期,90 秒 TTL 后槽自动回收。Redis Stream 里的消息停在 PEL(Pending Entries List),Worker-2 无法 ACK。Redis 恢复后,其他 Worker 的 XAUTOCLAIM 发现这条消息空闲超过 15 分钟(诊断超时 + 安全边际),认领过来重试。连续 3 次失败 → 进 DLQ 等人工处理。
2. 新告警入账:限流 → Redis 不可用 → _redis() 返回 None → fail-open 放行。入队 → XADD 失败 → API 返回 500。告警已落 Postgres(事务已提交),但诊断任务没入队。关键洞察:限流和队列用同一个 Redis,放行了也入不了队,LLM 不会被打爆。
3. 排队中的 15 个任务:在 Redis Stream 里,Redis 挂了就读不到。但 Postgres 里的任务状态还在(status=pending),数据不丢。Redis 恢复后可以通过运维 API 或定时扫描补偿入队。
4. Redis 恢复瞬间:限流计数器已过期,短暂窗口内所有请求放行,1 个窗口后重新生效。并发槽 ZSET 已过期重建,受 ZCARD < 32 限制不会超并发。Worker 恢复消费,XAUTOCLAIM 接管遗留任务。
| 组件 | Redis 挂了 | Redis 恢复后 |
|---|---|---|
| 限流 | fail-open 放行 | 1 个窗口后恢复正常 |
| 并发槽 | fail-open(__fail_open__ token) | ZSET 重建,正常抢槽 |
| 队列入队 | XADD 失败 → 500 | 新任务正常入队 |
| Worker 消费 | 读不到任务,空转 | XAUTOCLAIM 接管遗留 + 恢复消费 |
| Postgres | 不受影响 | 作为事实源补偿恢复 |
核心哲学:所有 Redis 依赖组件 fail-open,Postgres 作为事实源兜底。Redis 是缓冲层不是数据层——挂了丢的是排队顺序和计数器,不丢业务数据。
Q2: 你的系统在 Redis 挂了的时候会怎样?#
系统所有依赖 Redis 的组件都采用 Fail-open 策略——Redis 不可用时降级放行,而不是硬失败:
| 组件 | Redis 正常 | Redis 挂了 |
|---|---|---|
| IP 限流 | 正常计数,超限 429 | 放行所有请求 (warn 日志) |
| 分布式并发槽 | 正常抢槽/释放 | 返回 __fail_open__ token,直接放行 |
| 队列入队 | XADD 成功 | 入队失败,API 返回 500 |
| Worker 消费 | XREADGROUP 正常 | Worker 读不到任务,空转 |
关键洞察:限流和并发槽 fail-open 看起来危险,但队列也用同一个 Redis。Redis 挂了 → 入队失败 → 请求到不了 Worker → LLM 不会被打爆。所以 fail-open 在这个架构下是安全的。
Q3: 面试追问:Redis 恢复后会不会出现流量突发?#
会,但有限:
- Redis 恢复的瞬间,限流计数器为 0(key 已过期),短时间内所有请求都能通过。但限流窗口通常是 1 秒 (webhook) 或 60 秒 (manual),1 个窗口后限流重新生效。
- 队列恢复后 Worker 开始消费,但受并发槽限制(32),不会同时涌入 LLM。
如果对这个瞬时窗口不放心,可以在 Redis 恢复后加一个 warm-up 期——头 10 秒把限额降到正常值的 50%,逐步恢复。但目前没做,因为运维平台的流量本身不是交易级的。
Q4: Fail-open 的哲学依据是什么?什么时候该用 Fail-closed?#
Fail-open 适用条件:保护组件挂了之后,被保护资源本身也不可用了。
限流 (Redis) → 队列 (Redis) → Worker (读 Redis) → LLM
↑ ↑ ↑
└── 同一个 Redis ──┘ │
挂了全挂,放行也没关系 │
↓
反正到不了这里plaintextFail-closed 适用条件:保护组件挂了但被保护资源还能被直接访问。比如:
- API Gateway 的认证模块挂了 → 后端 API 还能直接访问 → 必须 fail-closed
- 防火墙挂了 → 网络还通 → 必须 fail-closed
二、任务重试与 DLQ#
Q5: 诊断任务失败了怎么办?#
三次重试 + 死信队列:
任务执行 → 失败
│
├─ attempts < 3 → 标记 pending + 重新入队 (同优先级)
│ └─ 下次 Worker 领到后再试
│
└─ attempts >= 3 → 标记 failed + 移入 DLQ
└─ 人工排查plaintext# diagnosis_worker.py — _handle_failure()
if attempts >= max_attempts:
await incident_repository.mark_task_failed(task_id, error)
await incident_queue.dead_letter(message_id=message_id, item=item, reason=error)
else:
await incident_repository.mark_task_retry_pending(task_id, error)
await self._reenqueue_task(task_id, item, task)python重新入队保持原始优先级——critical 任务重试后还是 critical,不会被降级。
Q6: DLQ 里的消息怎么处理?#
DLQ 是一条独立的 Redis Stream(aiops:incident:dlq),保留了完整的原始消息 + 失败原因:
# redis_streams.py — dead_letter()
dlq_id = await client.xadd(
settings.incident_queue_dlq_stream,
fields={
"original_message_id": message_id,
"reason": reason[:2000], # 截断防止太长
"task_id": task_id,
"incident_group_id": ...,
# ... 保留所有原始字段
},
maxlen=settings.incident_queue_maxlen,
)
# 原消息 ACK 掉 (不再被其他 Worker 消费)
await self.ack(message_id, stream=item.get("__stream__"))python目前没有自动重试 DLQ 的机制——进了 DLQ 说明连续 3 次失败,大概率是系统性问题(LLM 超时、MCP 工具不可用),自动重试没意义。运维通过 /api/v1/queue/status 看 dlq_depth 字段发现积压。
Q7: 面试追问:为什么没有指数退避 (exponential backoff)?#
当前实现是 立即重新入队,没有退避。原因:
- 任务回到队列后不会被立即消费——前面可能还有其他任务排着。队列本身就是一种天然的退避。
- Worker 有并发槽限制,不会所有 Worker 同时去重试同一个任务。
- 最常见的失败原因是 LLM API 超时,等几秒通常也不会恢复。真正的恢复需要等 LLM 服务端修复。
但你说得对,加指数退避是更严谨的做法——可以在重新入队前 asyncio.sleep(min(2^attempt, 60)) 秒。
三、Worker 崩溃恢复#
Q8: Worker 进程被 kill -9 了,它正在处理的任务怎么办?#
四层兜底:
Worker 被杀
│
├─ [1] 并发槽 TTL 过期 (90s) → 自动回收,不会永久泄漏
│
├─ [2] Redis Streams pending → 消息留在 PEL (Pending Entries List)
│ └─ 其他 Worker 用 XAUTOCLAIM 认领
│
├─ [3] Postgres 任务状态停在 running
│ └─ 超时检查 (600s) 后标记 failed → 重新入队
│
└─ [4] DLQ 兜底 → 重试 3 次仍失败 → 进死信队列plaintextQ9: XAUTOCLAIM 是怎么工作的?#
每个 Worker 在消费循环里,先尝试认领崩溃 Worker 的遗留任务,再读新任务:
# diagnosis_worker.py — _claim_stale_tasks_once()
tasks = await incident_queue.claim_stale_tasks(
consumer_name=self.consumer_name,
min_idle_ms=settings.diagnosis_worker_reclaim_idle_ms, # 15 分钟
count=5,
)python# redis_streams.py — claim_stale_tasks()
for stream in [critical, high, normal, low]:
result = await client.xautoclaim(
stream, group, consumer_name,
min_idle_ms=min_idle_ms, # 只认领空闲超过 15 分钟的消息
start_id="0-0",
count=count,
)pythonmin_idle_ms=900000 (15 分钟) = 诊断超时 10 分钟 + 5 分钟安全边际。如果一条消息在 PEL 里超过 15 分钟没被 ACK,说明消费它的 Worker 大概率挂了。
Q10: 为什么不用 Redlock 做分布式锁?#
Redlock 解决的是 “多个 Worker 抢同一个资源” 的问题。我用 ZSET 并发槽解决的是 “控制全局并发数量” 的问题,角度不同:
| 维度 | Redlock | ZSET 并发槽 |
|---|---|---|
| 语义 | 互斥锁 (只有一个能拿到) | 计数信号量 (N 个能拿到) |
| 过期恢复 | 需要续期 (看门狗) | TTL + 心跳 (类似) |
| 实现复杂度 | 需要 3-5 个 Redis 实例才安全 | 单 Redis Lua 脚本 |
| 适用场景 | ”只能有一个在执行" | "最多 32 个在执行” |
如果用 Redlock 来限制 32 并发,要维护 32 个锁 + 每个的续期,比 ZSET 复杂得多。
四、健康检查#
Q11: Liveness 和 Readiness 为什么分开?#
# health.py
@router.get("") # Liveness: 进程活着就返回 200
async def liveness():
return {"status": "alive"}
@router.get("/ready") # Readiness: 所有依赖都就绪才返回 200
async def readiness():
postgres_ok = await postgres_health() # SELECT 1
vector_ok = await pg_vector_store.is_ready()
redis_ok = await redis_client.ping()
if not all([postgres_ok, vector_ok, redis_ok]):
return JSONResponse(status_code=503, content={"status": "not_ready"})pythonK8s 用法:
- livenessProbe → liveness:检查进程是否死循环。失败 → K8s 重启容器。
- readinessProbe → readiness:检查依赖是否就绪。失败 → K8s 从 Service 摘掉这个 Pod(不接流量),但不杀。
如果把 Readiness 逻辑放到 Liveness 里:Postgres 短暂不可用 → Liveness 失败 → K8s 杀容器 → 重启 → Postgres 还没恢复 → 又被杀 → 无限重启循环。
Q12: Worker 的存活怎么检测?#
Worker 不暴露 HTTP 端口,用 Redis 心跳 key 做存活检测:
# redis_streams.py — heartbeat()
key = f"aiops:worker:{group}:{name}:heartbeat"
await client.set(key, "1", ex=ttl_sec) # 每 10s 写一次, TTL 30spython# redis_streams.py — status() 里检查
alive = await client.exists(heartbeat_key)
workers.append({"name": name, "alive": bool(alive), "pending": pending_count})python30 秒没心跳 → alive=False → /api/v1/queue/status 报告 alive_workers 减少 → 运维发现。
五、数据库事务#
Q13: 告警入库为什么要用事务?#
一条告警的入库涉及 5 张表的 upsert:
# repository.py — ingest_alertmanager_alert()
async with conn.transaction():
await self._upsert_alert(conn, normalized) # alerts 表
await self._upsert_incident_group(conn, ...) # incident_groups 表
await self._upsert_incident(conn, ...) # incidents 表
await conn.execute("INSERT INTO incident_group_alerts ...") # 关联表
await self._create_or_get_task(conn, ...) # diagnosis_tasks 表python如果不用事务,可能出现:alert 写成功了但 task 写失败了 → 告警已落库但没有对应的诊断任务 → 这个告警永远不会被诊断。
事务保证 全部成功或全部回滚,不会出现半截状态。
Q14: 多个 uvicorn worker 同时建表 (DDL) 不会冲突吗?#
用 Postgres 会话级 advisory lock 序列化 DDL:
# postgres.py — init_incident_schema()
await conn.execute("SELECT pg_advisory_lock(990001)") # 抢锁
# ... CREATE TABLE IF NOT EXISTS ...
await conn.execute("SELECT pg_advisory_unlock(990001)") # 放锁python4 个 uvicorn worker + 3 个 diagnosis worker 同时启动时,只有第一个抢到锁的进程执行 DDL,其余等待。CREATE TABLE IF NOT EXISTS + advisory lock = 幂等 + 互斥。
Q15: 向量库的文档替换为什么把 embedding 放在事务外?#
# pg_vector_store.py — replace_doc()
embeddings = await embed(chunks) # ⬅ 事务外: 调 API (可能失败/超时)
async with conn.transaction():
await conn.execute("DELETE FROM kb_chunks WHERE doc_id = $1", doc_id)
await conn.executemany("INSERT INTO kb_chunks ...", rows)python如果 embedding 在事务内:embedding API 超时 30 秒 → Postgres 连接被占 30 秒 → 连接池耗尽 → 其他查询全卡住。
放在事务外:embedding 失败 → 事务没开 → 旧数据完好。只有 embedding 成功后才开始短事务 DELETE + INSERT。
六、降级策略汇总#
Q16: 系统里有多少个降级点?#
| 组件 | 正常 | 降级 | 触发条件 |
|---|---|---|---|
| IP 限流 | Redis 计数 | 放行 | Redis 不可用 |
| 并发槽 | ZSET 原子抢槽 | 放行 | Redis 不可用 |
| BM25 检索 | ParadeDB 全文搜索 | 纯向量检索 | pg_search 扩展不可用 |
| Rerank | Cross-Encoder 精排 | 粗排前 N 条 | DashScope/FlagEmbedding 超时 |
| LLM | DashScope 主模型 | Ollama 本地模型 | TCP probe 探测主模型不可达 |
| MCP 工具 | 正常调用 | 返回错误文本 | 单工具异常不影响其他工具 |
| Planner | LLM 结构化输出 | 通用两步兜底计划 | LLM 超时/解析失败 |
| Skill Router | LLM 选 Skill | 关键词规则匹配 | LLM 超时 |
| Query Rewrite | LLM 改写 | 用原始 query | LLM 超时 |
原则:每个 LLM/外部 API 调用都有降级路径。没有任何单次外部调用失败能让整个诊断崩溃。
Q17: LLM 主备切换是怎么做的?#
TCP 级别探测,不消耗 token:
# llm_health.py
async def _tcp_probe(host, port, timeout=3.0):
# 只建 TCP 连接, 不发 HTTP 请求, 不消耗 API 配额
reader, writer = await asyncio.wait_for(
asyncio.open_connection(host, port), timeout=timeout
)
writer.close()
return Truepython探测结果缓存 30 秒。主模型 (DashScope) 不可达时自动切到本地模型 (Ollama)。切换日志会打 warning,可观测。
七、可观测性#
Q18: 系统有哪些监控手段?#
三层可观测:
实时状态 — /api/v1/queue/status:
{
"depth": 215,
"by_level": {"critical": 50, "high": 56, "normal": 53, "low": 56},
"pending": 3,
"dlq_depth": 0,
"slots": {"manual_diagnosis": {"used": 0, "limit": 16}, "worker_diagnosis": {"used": 3, "limit": 32}},
"alive_workers": 3,
"workers": [{"name": "worker-1", "alive": true, "pending": 1}, ...]
}json审计日志 — Postgres:
agent_runs表:每次诊断的 token 消耗、耗时、状态tool_calls表:每次工具调用的名称、参数、返回值、耗时evidence表:每条证据的来源、内容
结构化日志 — Loguru:
- 每条日志带 request_id(跨进程可追踪)
- 日志文件按天轮转 + 压缩,保留 14 天
- 限流、入队、槽位、Worker 消费等关键节点都有
logger.info
Q19: 如果让你加 Prometheus 监控,你会暴露哪些指标?#
# API 层
api_request_duration_seconds{method, endpoint, status}
api_rate_limited_total{scope, identity}
# 队列层
queue_depth{level}
queue_pending_total
queue_dlq_depth
# 执行层
slot_usage{resource}
diagnosis_duration_seconds{mode}
diagnosis_task_status_total{status} # pending/running/succeeded/failed
# LLM 层
llm_request_duration_seconds{model}
llm_tokens_total{model, direction} # input/output
llm_failover_total{from_model, to_model}
# Worker 层
worker_alive{name}
worker_task_consumed_total{name}plaintext最重要的告警规则:
queue_depth > 1000 for 5m→ 队列堆积严重dlq_depth increasing→ 任务持续失败alive_workers == 0→ Worker 全挂slot_usage / slot_limit > 0.9 for 10m→ 并发即将打满
八、设计取舍#
Q20: 这套容错设计最大的亮点和最大的缺陷?#
亮点:Fail-open 的一致性——限流、并发槽、MCP 工具、Rerank、LLM 都走 fail-open。不是随便做的,而是因为它们底层共享 Redis,fail-open 不会导致雪崩。
缺陷:Redis 单点。说了这么多容错,最大的单点故障就是 Redis。所有 fail-open 的前提是”Redis 挂了整条链路都不工作”,但如果 Redis 只是变慢(不是挂了),限流放行 + 并发槽放行 + 入队成功但很慢 → 请求积压在 Redis 侧 → 有可能引发连锁问题。生产必须上 Sentinel 或 Cluster。
Q21: 如果给你一个月补生产就绪的短板,你优先做什么?#
- Redis Sentinel (第 1 周):消除最大单点
- Worker 自动重连 + 进程管理 (第 2 周):用 supervisor 或 K8s 自动重启
- Prometheus + Grafana (第 3 周):可观测从
/queue/status升级到真实监控 - 混沌工程测试 (第 4 周):用
chaos-mesh或手动 kill 进程,验证每个故障场景的恢复行为