面试知识库

面试 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 恢复后会不会出现流量突发?#

会,但有限:

  1. Redis 恢复的瞬间,限流计数器为 0(key 已过期),短时间内所有请求都能通过。但限流窗口通常是 1 秒 (webhook) 或 60 秒 (manual),1 个窗口后限流重新生效。
  2. 队列恢复后 Worker 开始消费,但受并发槽限制(32),不会同时涌入 LLM。

如果对这个瞬时窗口不放心,可以在 Redis 恢复后加一个 warm-up 期——头 10 秒把限额降到正常值的 50%,逐步恢复。但目前没做,因为运维平台的流量本身不是交易级的。

Q4: Fail-open 的哲学依据是什么?什么时候该用 Fail-closed?#

Fail-open 适用条件:保护组件挂了之后,被保护资源本身也不可用了。

限流 (Redis) → 队列 (Redis) → Worker (读 Redis) → LLM
   ↑                ↑                ↑
   └── 同一个 Redis ──┘                │
       挂了全挂,放行也没关系             │

                                   反正到不了这里
plaintext

Fail-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/statusdlq_depth 字段发现积压。

Q7: 面试追问:为什么没有指数退避 (exponential backoff)?#

当前实现是 立即重新入队,没有退避。原因:

  1. 任务回到队列后不会被立即消费——前面可能还有其他任务排着。队列本身就是一种天然的退避。
  2. Worker 有并发槽限制,不会所有 Worker 同时去重试同一个任务。
  3. 最常见的失败原因是 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 次仍失败 → 进死信队列
plaintext

Q9: 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,
    )
python

min_idle_ms=900000 (15 分钟) = 诊断超时 10 分钟 + 5 分钟安全边际。如果一条消息在 PEL 里超过 15 分钟没被 ACK,说明消费它的 Worker 大概率挂了。

Q10: 为什么不用 Redlock 做分布式锁?#

Redlock 解决的是 “多个 Worker 抢同一个资源” 的问题。我用 ZSET 并发槽解决的是 “控制全局并发数量” 的问题,角度不同:

维度RedlockZSET 并发槽
语义互斥锁 (只有一个能拿到)计数信号量 (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"})
python

K8s 用法:

  • 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 30s
python
# redis_streams.py — status() 里检查
alive = await client.exists(heartbeat_key)
workers.append({"name": name, "alive": bool(alive), "pending": pending_count})
python

30 秒没心跳 → 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)")  # 放锁
python

4 个 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 扩展不可用
RerankCross-Encoder 精排粗排前 N 条DashScope/FlagEmbedding 超时
LLMDashScope 主模型Ollama 本地模型TCP probe 探测主模型不可达
MCP 工具正常调用返回错误文本单工具异常不影响其他工具
PlannerLLM 结构化输出通用两步兜底计划LLM 超时/解析失败
Skill RouterLLM 选 Skill关键词规则匹配LLM 超时
Query RewriteLLM 改写用原始 queryLLM 超时

原则:每个 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 True
python

探测结果缓存 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 监控,你会暴露哪些指标?#

最重要的告警规则:

  • 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: 如果给你一个月补生产就绪的短板,你优先做什么?#

  1. Redis Sentinel (第 1 周):消除最大单点
  2. Worker 自动重连 + 进程管理 (第 2 周):用 supervisor 或 K8s 自动重启
  3. Prometheus + Grafana (第 3 周):可观测从 /queue/status 升级到真实监控
  4. 混沌工程测试 (第 4 周):用 chaos-mesh 或手动 kill 进程,验证每个故障场景的恢复行为