18.2 故障演练:模型超时/DB 宕机/队列积压
18.1 的清单验证了「系统该有的都有」。但「有」不等于「有效」——超时写了不代表真的会止损,探针配了不代表真的会摘流量,队列有界不代表真的会背压。唯一能证明它们有效的方式,是主动把系统弄坏,看它坏得优不优雅。
本节把 ResearchCopilot 推进到「可演练」:设计模型超时、DB 不可达、队列积压三个受控故障,逐个在本机真跑,验证 8.3 的超时止损、2.3 的降级链、readiness 摘流量与背压告警确实生效,并给出一份演练复盘模板。
18.2.1 演练的规矩:受控、可恢复、留证据
故障演练不是「搞垮服务」,而是在受控条件下制造一次故障,观察系统反应,然后恢复。三条铁律:
- 受控:注入的是本机自有环境——自己起的假模型、不存在的端口、自己的队列键。绝不碰共享的
ab-pg/ab-redis容器本身,只连它们的库,或干脆连一个没人监听的端口。 - 可恢复:每个演练都有明确的恢复动作,做完把环境还原(换回模型、改回连接串、清空队列键)。
- 留证据:记录注入方式、预期、实际输出、耗时——没有证据的演练等于没做。
三个演练共用卷一的类型契约(2.3.2)。下面这段公共定义,后面三段脚本都接在它之后运行:
# 三个演练共用的公共定义(卷一的类型契约,见 2.3.2)
from dataclasses import dataclass, field
from typing import Any, Literal
Role = Literal["system", "user", "assistant", "tool"]
@dataclass
class ToolCall:
id: str
name: str
arguments: dict[str, Any]
@dataclass
class Message:
role: Role
content: str = ""
tool_calls: list[ToolCall] = field(default_factory=list)
tool_call_id: str | None = None
@dataclass
class ModelResponse:
message: Message
stop_reason: str = "end_turn"
usage: dict[str, int] = field(default_factory=dict)
18.2.2 演练①:模型超时
| 环节 | 内容 |
|---|---|
| 注入方式 | 用 SlowModel(delay=5.0) 替换主模型,模拟上游卡住不返回 |
| 预期表现 | 主模型在 1.0s 被超时止损,降级链落到备用模型,请求总耗时 ≈ 1.0s 而非 5.0s |
| 观测点 | 降级 trace(主 STOP / 备用 OK)、请求总耗时、降级计数指标 |
| 恢复动作 | 演练后换回正常模型;真实环境确认上游恢复后再放回链首 |
| 复盘问题 | 1.0s 超时合理吗?超时后仍在后台跑的那次调用会泄漏吗? |
# 接 18.2.1 的公共定义之后运行
import concurrent.futures as cf
import time
class SlowModel:
"""假模型:每次 complete 睡 delay 秒,模拟上游卡住。"""
def __init__(self, name: str, delay: float) -> None:
self.name, self.delay = name, delay
def complete(self, messages, tools) -> ModelResponse:
time.sleep(self.delay)
return ModelResponse(message=Message(role="assistant", content=f"[{self.name}] 正常回答"))
class InstantModel:
def __init__(self, name: str, text: str) -> None:
self.name, self.text = name, text
def complete(self, messages, tools) -> ModelResponse:
return ModelResponse(message=Message(role="assistant", content=self.text))
_POOL = cf.ThreadPoolExecutor(max_workers=8) # 常驻池:超时不阻塞后续调用
def call_with_timeout(model, messages, tools, timeout: float) -> ModelResponse:
fut = _POOL.submit(model.complete, messages, tools)
try:
return fut.result(timeout=timeout)
except cf.TimeoutError:
raise TimeoutError(f"{model.name} 超过 {timeout:.1f}s 未返回") from None
class TimeoutRouter:
"""带单次调用超时的降级链:超时/失败即换下一个后端。"""
def __init__(self, backends, timeout: float) -> None:
self.backends, self.timeout = backends, timeout
self.trace: list[tuple[str, str]] = []
def complete(self, messages, tools) -> ModelResponse:
last = None
for b in self.backends:
t0 = time.perf_counter()
try:
resp = call_with_timeout(b, messages, tools, self.timeout)
except Exception as e:
self.trace.append((b.name, f"STOP {type(e).__name__} {time.perf_counter()-t0:.2f}s"))
last = e
continue
self.trace.append((b.name, f"OK {time.perf_counter()-t0:.2f}s"))
return resp
raise last
msgs = [Message(role="user", content="GIL 下 CPU 密集怎么并发?")]
print("== 无超时保护:直接调主模型 ==")
t0 = time.perf_counter()
r = SlowModel("primary", 5.0).complete(msgs, [])
print(f"返回: {r.message.content} 耗时 {time.perf_counter()-t0:.2f}s")
print("\n== 有超时 + 降级:timeout=1.0s ==")
router = TimeoutRouter(
[SlowModel("primary", 5.0),
InstantModel("fallback-haiku", "(降级)主模型超时,改由备用模型作答。")],
timeout=1.0)
t0 = time.perf_counter()
resp = router.complete(msgs, [])
print("最终回答:", resp.message.content)
for name, outcome in router.trace:
print(f" {name:<16} {outcome}")
print(f"请求总耗时 {time.perf_counter()-t0:.2f}s 成功: {resp.message.content != ''}")
真跑输出(venv Python 3.14.6):
== 无超时保护:直接调主模型 ==
返回: [primary] 正常回答 耗时 5.00s
== 有超时 + 降级:timeout=1.0s ==
最终回答: (降级)主模型超时,改由备用模型作答。
primary STOP TimeoutError 1.01s
fallback-haiku OK 0.00s
请求总耗时 1.01s 成功: True
三个数字说明 8.3 的止损确实生效:无保护时 5.00s、有超时后 1.01s,一次卡住的调用没有拖垮整个请求;降级链上主模型记 STOP、备用记 OK,轨迹完整。但复盘问题也暴露了:那个超时后仍在前台的 primary 线程并没有被取消,它会继续跑完 5 秒才退出——真实场景里要确保超时后调用被真正取消(asyncio 里靠 task.cancel()),否则并发一高就会堆积大量僵尸调用。
18.2.3 演练②:DB 不可达
| 环节 | 内容 |
|---|---|
| 注入方式 | 把连接串指向不存在的端口 15436(不触碰真实容器 ab-pg) |
| 预期表现 | readiness 探测瞬间失败、返回 not-ready;请求立即返回 503 而非挂到超时 |
| 观测点 | 探测的 ready / elapsed、HTTP 状态码、readiness 摘流量是否生效 |
| 恢复动作 | 连接串改回 15435,探针恢复 ready,流量重新放回 |
| 复盘问题 | 为什么是「连接被拒」而非「超时」?端口通但 DB 卡死时探针会怎样? |
# 接 18.2.1 的公共定义之后运行(本演练不依赖 Message 等,可独立跑)
import time
import psycopg
GOOD = "postgresql://postgres:postgres@localhost:15435/research"
BAD = "postgresql://postgres:postgres@localhost:15436/research" # 端口无人监听
def probe(conninfo: str, timeout: int = 2) -> tuple[bool, str | None, float]:
t0 = time.perf_counter()
try:
with psycopg.connect(conninfo, connect_timeout=timeout) as c:
with c.cursor() as cur:
cur.execute("SELECT 1")
cur.fetchone()
return True, None, time.perf_counter() - t0
except Exception as e:
head = f"{type(e).__name__}: {str(e).splitlines()[0]}"
return False, head, time.perf_counter() - t0
def handle_request(ready: bool, ready_err: str | None) -> tuple[int, dict]:
"""就绪检查失败时立即返回 503,而不是挂死。"""
if not ready:
return 503, {"error": "service_unavailable",
"detail": "数据库暂不可达,请稍后重试",
"cause": ready_err.split(":")[0]}
return 200, {"status": "ok"}
print("== 健康态:连真实容器(15435)==")
ok, err, dt = probe(GOOD)
print(f"ready={ok} error={err} elapsed={dt:.2f}s")
print("\n== 故障态:连不存在的端口(15436)==")
ok, err, dt = probe(BAD)
print(f"ready={ok} elapsed={dt:.2f}s")
print(f"error: {err}")
print("\n== 探针摘流量后的请求行为 ==")
for label, (ok, err, _) in (("DB 正常", probe(GOOD)), ("DB 宕机", probe(BAD))):
code, body = handle_request(ok, err)
print(f"{label:<8} -> HTTP {code} {body}")
真跑输出:
== 健康态:连真实容器(15435)==
ready=True error=None elapsed=0.06s
== 故障态:连不存在的端口(15436)==
ready=False elapsed=0.00s
error: OperationalError: connection failed: connection to server at "127.0.0.1", port 15436 failed: could not receive data from server: Connection refused
== 探针摘流量后的请求行为 ==
DB 正常 -> HTTP 200 {'status': 'ok'}
DB 宕机 -> HTTP 503 {'error': 'service_unavailable', 'detail': '数据库暂不可达,请稍后重试', 'cause': 'OperationalError'}
关键点:故障态在 0.00s 就返回了,而不是等满 2s 超时——因为端口无人监听,TCP 立刻回 Connection refused,psycopg 秒级失败。探针据此把实例标记为 not-ready,负载均衡不再往里送流量;请求处理器拿到 not-ready 后立即返回 503,而不是把请求挂在那里等数据库超时。这就是 16.3 里「readiness 摘流量 + 明确错误」的落地。复盘问题也很尖锐:如果故障换成「端口通、但 DB 卡死不响应」,探测就会退化成满 2s 超时——那才是最危险的故障,说明探针还需要查询级超时与更短的健康检查周期。
18.2.4 演练③:队列积压
| 环节 | 内容 |
|---|---|
| 注入方式 | 往 Redis 队列一口气塞 500 个任务(远超消费能力),消费端每批 50 个 |
| 预期表现 | 入队深度 500 > 告警阈值 100 触发告警;进程内有界队列把峰值深度钉在 maxsize |
| 观测点 | 队列深度(LLEN)、告警是否触发、消费耗时、进程内峰值深度 |
| 恢复动作 | 消费完队列后 DEL 清空演练键,确认键已删除 |
| 复盘问题 | 500 积压为何没崩?跨进程 Redis 队列是无界的,靠什么背压? |
# 接 18.2.1 的公共定义之后运行(本演练用 Redis + asyncio,可独立跑)
import asyncio
import time
import redis
r = redis.Redis.from_url("redis://localhost:16380/0", decode_responses=True)
QKEY = "ab:drill18:queue"
ALERT_THRESHOLD, PRODUCE, BATCH, BATCH_LATENCY = 100, 500, 50, 0.05
r.delete(QKEY)
for i in range(PRODUCE): # ① 生产者:一口气塞 500 个
r.rpush(QKEY, f"run-{i}")
depth = r.llen(QKEY)
print(f"入队 {PRODUCE} 个任务,队列深度 = {depth}")
print(f"告警阈值 = {ALERT_THRESHOLD} 触发告警 = {depth > ALERT_THRESHOLD}")
consumed, t0 = 0, time.perf_counter() # ② 消费者:每批 50 个,慢于生产
while consumed < PRODUCE:
for _ in range(BATCH):
if r.lpop(QKEY) is None:
break
consumed += 1
time.sleep(BATCH_LATENCY)
print(f"消费完 {consumed} 个任务耗时 {time.perf_counter()-t0:.2f}s,剩余深度 = {r.llen(QKEY)}")
async def bounded_demo() -> None: # ③ 进程内背压:有界队列
q: asyncio.Queue = asyncio.Queue(maxsize=20)
peak = 0
async def producer():
nonlocal peak
for i in range(200):
await q.put(i) # 队列满则阻塞 —— 背压
peak = max(peak, q.qsize())
await q.put(None)
async def consumer():
while True:
item = await q.get()
if item is None:
break
await asyncio.sleep(0.002)
await asyncio.gather(producer(), consumer())
print(f"有界队列 maxsize=20,生产 200 个,峰值深度 = {peak}(被钉死在 maxsize)")
print()
asyncio.run(bounded_demo())
r.delete(QKEY) # ④ 清理
print(f"清理完成,队列键存在 = {bool(r.exists(QKEY))}")
真跑输出(Redis 7.4.11):
入队 500 个任务,队列深度 = 500
告警阈值 = 100 触发告警 = True
消费完 500 个任务耗时 2.37s,剩余深度 = 0
有界队列 maxsize=20,生产 200 个,峰值深度 = 20(被钉死在 maxsize)
清理完成,队列键存在 = False
两个事实被验证:其一,积压确实触发了告警——深度 500 远超阈值 100,监控据此该报警并要求扩容或限流;其二,进程内的有界队列把峰值深度钉死在 maxsize——生产 200 个、峰值只有 20,多出来的生产者在 await q.put() 上被阻塞,积压被转成了背压。但复盘问题点出了要害:跨进程的 Redis 队列是无界的,500 个能全塞进去、2000 个也能,内存涨、延迟涨。所以真正的背压不能只靠内存队列,必须配合「队列长度监控 + 拒绝新任务」——队列深度超阈值时,入口层主动返回 429,而不是继续往一个无界队列里灌。
18.2.5 演练复盘模板
每个演练做完,填一张这样的表。它是把「演练」变成「改进」的桥:
| 字段 | 填写 |
|---|---|
| 演练名称 | 模型超时 / DB 不可达 / 队列积压 |
| 注入方式 | 具体到参数(delay、端口、数量) |
| 预期 vs 实际 | 两者不一致的地方就是发现 |
| 观测到的现象 | 日志、耗时、状态码、队列深度 |
| 发现的问题 | 每条可执行,避免「要注意」式空话 |
| 改进项(负责人 / 期限) | 谁、什么时候修 |
| 是否可复现 | 复现步骤写清楚,便于回归 |
| 下次演练计划 | 什么时候、演练什么 |
18.2.6 验收清单与真实环境
三个演练的验收:
- 模型超时:超时确实止损,请求耗时从 5.00s 降到 1.01s,降级链有轨迹
- DB 不可达:readiness 秒级摘流量,请求立即 503,不挂死
- 队列积压:深度超阈值触发告警,有界队列把峰值钉在
maxsize - 每个演练都留了真实证据(日志 / 耗时 / 深度),并做了清理
- 每个发现的问题都有带负责人的改进项
真实环境里会怎样:真实故障不会像本节这样干净。其一,故障会叠加——模型超时的同时 DB 也在抖,两个降级链一起触发,系统压力陡增;其二,故障是渐进的——DB 不会「啪」地拒绝连接,而是先变慢、再超时、最后崩溃,readiness 探针要有梯度;其三,演练本身有风险——生产环境注入故障可能真的伤到用户,所以要在隔离环境或低峰期做,且永远准备好「一键恢复」。本节把演练放在本机自有环境,正是这个原则的最小实践。
系统经得起「被弄坏」,才谈得上交付。下一节收尾:上线、观察、迭代,以及可选的真实 API 端到端冒烟。先看 18.3 上线、观察与迭代 + 真实 API 端到端冒烟(可选) 。
阅读导航:上一节:18.1 容量与架构评审清单 · 下一节:18.3 上线、观察与迭代 + 真实 API 端到端冒烟(可选) 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。