术核 AI · 后端工程课件
建立本地事故演练记录,区分接口故障、导出积压和实例不可用。
一个导出接口失败和全部笔记读取失败有不同影响。记录受影响操作、用户范围、开始时间、成功率和延迟,再确定处理优先级。
“新增请求都超时”是观测,“数据库挂了”是待验证假设。每次只改变一个因素,避免多个操作互相覆盖证据。
止损可以是暂停大导出受理、回退本次配置或缩小流量;它不自动证明根因。恢复后继续核对已受理任务和数据一致性。
本模块在部署模块建立的 notes-service 隔离副本上演练;Linux 单元为 notes-api.service,基础端点为 /live(进程存活)与 /ready(依赖就绪)。先在副本新建 diagnostics_lab。Linux/WSL2 命令只针对隔离目标;容器部署从 Compose 目录使用 docker compose ps 定位 service,不猜容器名。第 11 课的 SSE/WS 是 API 模块扩展路由,须先确认已加入副本。
文件:终端 / 当前课时
mkdir -p diagnostics_lab
date -u +%FT%TZ
systemctl show notes-api.service -p ActiveState -p SubState -p MainPID
curl -sS --connect-timeout 1 --max-time 3 -o /dev/null -w "live status=%{http_code} total=%{time_total}\n" http://127.0.0.1:8000/live
curl -sS --connect-timeout 1 --max-time 3 -o /dev/null -w "ready status=%{http_code} total=%{time_total}\n" http://127.0.0.1:8000/ready
保存 incident.md,把每格填成实际观测。下表是待测模板,不是真实事故结论。持久导出沿异步任务模块的 tasks_lab 命令和 export_jobs 表核对;第 01 课内存 POST /exports 只是协议练习,并未自动加入部署 API。如果另行实现了持久 HTTP 适配层,再添加对应路由;202 只表示受理。流式扩展未加入本次部署时填不适用。
文件:incident.md
| 操作 | 当前观测 | 受影响范围 | 最后一次正常时间 |
|---|---|---|---|
| GET /live | 待测 | 待测 | 待测 |
| GET /ready | 待测 | 待测 | 待测 |
| GET /notes | 待测 | 待测 | 待测 |
| submit_snapshot 提交持久导出 | 待测 | 待测 | 待测 |
| export_jobs 状态与结果文件 | 待测 | 待测 | 待测 |
| SSE /stream 与 WS /ws | 待测 | 待测 | 待测 |
先用只读查询验证假设,再决定是否在隔离副本中操作。比如只暂停本地导出受理,预期减小新增积压;回滚方式是恢复之前配置。当前代码若没有受理开关,先增加并测试该开关,不能把未实现的操作写成已执行。
文件:action-record.json
{
"timestamp_utc": "执行时填写",
"observation": "填写实际现象",
"hypothesis": "填写待验证原因",
"action": "planned",
"expected_change": "填写应变化的指标",
"rollback": "填写恢复原配置的方法",
"result": "not_run"
}
只读查询数据库任务状态与最老等待。恢复判定至少覆盖健康、笔记读取、一个新导出完成、旧任务去向;不能仅以进程重新启动作为结束标准。
文件:终端 / 当前课时
SELECT status,count(*) AS jobs,
extract(epoch FROM now()-min(created_at)) AS oldest_age_seconds
FROM export_jobs GROUP BY status;
SELECT count(*) AS unpublished FROM export_outbox WHERE sent_at IS NULL;
每条记录包含时间、执行人、目标、动作和结果。日志或截图引用需先脱敏;记录中的时间使用UTC,操作系统和应用时钟偏差单独注明。最后保留尚未解释的事实。
文件:incident.md
# incident.md 时间线模板
# UTC时间 | 目标实例/任务ID | 实际观测 | 动作 | 结果 | 下一验证
# 未执行动作统一标planned,不用预计结果填入实际结果列。
用户反馈导出一直等待,但笔记列表仍正常。
只暂停本地练习worker,提交一条导出,按模板判断影响,再恢复worker核对任务完成。
原因:未判断影响位置,重启可能扩大故障并丢掉现场。
处理:先保存进程、接口和依赖观测,仅对已验证目标采取有界动作。
原因:健康检查可能只确认进程存活,没有覆盖数据库和队列。
处理:增加真实读取与导出完成验收,区分liveness和业务可用性。
“/live正常、/notes正常、导出202正常但10分钟未完成”应怎样描述影响?
提示:把用户完成时间与请求受理状态分开。
参考答案:
描述为导出执行链路延迟或积压,读取功能仍可用;检查outbox、队列、worker、租约,不能仅凭202判定业务正常,也不能直接推断数据库整体不可用。
建立低泄露风险的访问日志,追踪状态、耗时和异常类型。
接收可信网关生成的ID或自行生成;客户端输入需要限制长度和字符,不能直接拼入日志行。request_id只帮助定位请求,不能用于权限判断。
优先记录GET、/notes/{note_id}、状态、耗时、实例与版本。完整URL可能包含查询凭据,笔记正文可能包含敏感内容,排障不要求默认记录全部数据。
HTTP响应完成可以记录访问耗时,导出任务另记job_id和业务阶段。SSE长连接需按首段和整体生命周期分别观察,不能把202访问日志当成导出完成日志。
在隔离副本建立request_logging.py。使用ASGI包装send观察响应完成,适用于普通响应与流。只接收有限字符的X-Request-ID,否则生成新值;响应头返回该ID。
文件:request_logging.py
import json
import logging
import re
import time
from uuid import uuid4
logger = logging.getLogger("notes.requests")
class RequestLogMiddleware:
def __init__(self, app):
self.app = app
async def __call__(self, scope, receive, send):
if scope["type"] != "http":
return await self.app(scope, receive, send)
headers = dict(scope.get("headers", []))
supplied = headers.get(b"x-request-id", b"").decode("ascii", "ignore")
request_id = supplied if re.fullmatch(r"[A-Za-z0-9_-]{1,64}", supplied) else uuid4().hex
scope.setdefault("state", {})["request_id"] = request_id
started = time.perf_counter()
status = 500
error_type = None
async def observe_send(message):
nonlocal status
if message["type"] == "http.response.start":
status = message["status"]
clean = [(k, v) for k, v in message.get("headers", []) if k.lower() != b"x-request-id"]
message = {**message, "headers": clean + [(b"x-request-id", request_id.encode())]}
await send(message)
try:
await self.app(scope, receive, observe_send)
except Exception as exc:
error_type = type(exc).__name__
raise
finally:
route = getattr(scope.get("route"), "path", "unmatched")
logger.info(json.dumps({
"event": "http_request", "request_id": request_id,
"method": scope["method"], "route": route, "status": status,
"duration_ms": round((time.perf_counter()-started)*1000, 2),
"error_type": error_type,
}, ensure_ascii=False))
在现有FastAPI app上注册一次,不创建第二个app。logging格式只输出message以保持单行JSON;如果部署已经统一日志配置,应合并配置而不是覆盖所有logger。
文件:app/main.py
import logging
from request_logging import RequestLogMiddleware
logging.basicConfig(level=logging.INFO, format="%(message)s")
app.add_middleware(RequestLogMiddleware)
# 启动后:
# curl -i -H "X-Request-ID: drill-001" http://127.0.0.1:8000/live
以下仅为示例日志。保存到sample.jsonl后用rg 'drill-001'查找。真实日志中应出现实际状态和耗时,不把下例数值作为当前服务性能结论。
文件:sample.jsonl
{"event":"http_request","request_id":"drill-001","method":"GET","route":"/notes/{note_id}","status":404,"duration_ms":4.2,"error_type":null}
对本地请求添加测试用Authorization和带敏感占位词的查询字符串,确认自定义日志没有这些值。只使用fake-secret,不用真实凭据。再触发404与受控500,核对request_id和error_type;网关/Uvicorn默认访问日志需要另行检查查询字符串记录策略。
文件:终端 / 当前课时
curl -sS -H 'Authorization: Bearer fake-secret' 'http://127.0.0.1:8000/live?token=fake-secret'
rg -n 'fake-secret|Authorization|password' sample.jsonl
# 对实际演练日志重复检查,预期自定义JSON访问日志不包含测试秘密。
提交导出时记录request_id和job_id的对应关系,worker记录job_id、message_id和lease_token。不要把整个request对象塞进Celery消息;跨进程关联下一课后续用trace上下文补充。
文件:request_logging.py
def log_export_accepted(request_id: str, job_id: str):
logger.info(json.dumps({
"event": "export_accepted",
"request_id": request_id,
"job_id": job_id,
}))
某个用户操作失败,需要从响应定位一次请求,又不能泄露笔记和密钥。
对本地接口发送fake-secret、非法ID及受控错误,检索全部日志层的实际输出。
原因:异常正文或字符串拼接引入未转义换行。
处理:用json.dumps构造完整记录,按日志系统约定处理堆栈字段。
原因:只检查应用logger,没有检查访问日志链路。
处理:逐层审查代理、Uvicorn与应用日志,URL不承载长期凭据。
传入超过64字符的X-Request-ID,确认服务器生成新ID而不是直接回显。
提示:测试输入只用重复字母,不需要任何真实秘密。
参考答案:
返回ID长度应为生成的32位hex,并可在日志关联;非法输入不会进入日志字段。
为请求成功率、耗时和导出等待建立指标,避免每个用户或任务产生新时间序列。
例如笔记读取的有效请求中成功完成的比例,必须说明是否排除主动取消和客户端无效请求。SLO是时间窗口内目标,不能把单次健康检查通过当作达标。
request_id、job_id、用户ID和原始URL不适合作为指标标签。使用method、路由模板、状态类别;具体请求通过日志与trace定位。
Histogram按桶计数,可在服务实例间聚合;平均耗时可能掩盖慢请求。低流量时分位数和错误率波动大,应同时看样本量,不能通过人为补成功请求掩盖空窗口。
在隔离虚拟环境安装prometheus-client。新建metrics_lab.py,单进程Uvicorn验证本课;多worker的指标汇总须另按Prometheus Python多进程说明配置,不能直接假定全局变量共享。
文件:metrics_lab.py
from prometheus_client import Counter, Histogram
REQUESTS = Counter(
"notes_http_requests_total", "HTTP requests",
["method", "route", "status_class"],
)
DURATION = Histogram(
"notes_http_request_duration_seconds", "HTTP request lifecycle",
["method", "route"],
buckets=(0.01, 0.05, 0.1, 0.3, 1, 3, 10),
)
将record_metrics加入第02课ASGI中间件finally,并传scope和状态;这里提供独立函数避免重复注册另一套访问中间件。SSE会记录整个连接时长,应另外统计首段到达时间。
文件:request_logging.py
from metrics_lab import REQUESTS, DURATION
def record_metrics(scope, status, elapsed):
if scope.get("path") == "/metrics":
return
method = scope.get("method", "OTHER")
if method not in {"GET","POST","PUT","PATCH","DELETE","OPTIONS","HEAD"}:
method = "OTHER"
route = getattr(scope.get("route"), "path", "unmatched")
group = f"{status // 100}xx"
REQUESTS.labels(method, route, group).inc()
DURATION.labels(method, route).observe(elapsed)
# RequestLogMiddleware的finally中:
# record_metrics(scope, status, time.perf_counter()-started)
在现有app注册一次/metrics,使用Response直接返回指标文本。正式环境应限制观测端点访问范围。抓取后连续请求不同note_id,确认标签仍是/notes/{note_id}。
文件:app/main.py
from fastapi import Response
from prometheus_client import generate_latest, CONTENT_TYPE_LATEST
@app.get("/metrics", include_in_schema=False)
def metrics():
return Response(content=generate_latest(), headers={"Content-Type": CONTENT_TYPE_LATEST})
在本地Prometheus配置抓取目标后使用查询。下面示例看5xx比例和P95,均需先有流量。为GET /notes另写目标:例如最近7天有效读取99.5%在300ms内成功;数值是练习目标,是否适合业务需按实际基线确定。
文件:metrics-queries.promql
sum(rate(notes_http_requests_total{status_class="5xx"}[5m]))
/
sum(rate(notes_http_requests_total[5m]))
histogram_quantile(
0.95,
sum by (le, route) (rate(notes_http_request_duration_seconds_bucket[5m]))
)
用20个不同ID访问同一路由,检查/metrics没有20个原始路径标签。无请求的窗口标无数据;告警同时要求有效样本和持续时间。导出积压另由状态表采样为Gauge,不能把每个job作为标签。
文件:终端 / 当前课时
curl -sS http://127.0.0.1:8000/metrics
# 指标标签只允许有限方法、路由模板与状态类别。
# 不应出现 request_id=、job_id=、user_id= 或带查询串的route。
接口平均延迟没有明显变化,但部分用户查询和导出等待越来越久。
在本地只让少数请求延迟,再比较平均值与耗时桶;以不同ID请求验证时间序列数量稳定。
原因:把原始URL、用户或任务ID用作标签。
处理:换成有限路由模板,具体对象放日志和trace。
原因:平均值掩盖尾部延迟,导出202也没有覆盖完成时间。
处理:同时看P95/分桶和业务受理到完成耗时。
写一个300ms内请求比例查询,并说明它还缺什么条件才能叫“快速且成功”。
提示:Histogram的le=0.3桶只看时长,本课时长指标未附带成功标签。
参考答案:
sum(rate(..._bucket{le='0.3'}[5m]))/sum(rate(..._count[5m]))得到时长比例;若要快速且成功,应另记录成功请求的时长桶或专用好事件计数,不能直接把独立成功率与时长比例相乘。
用 OpenTelemetry 关联API内部阶段和队列执行,保留outbox跨进程的上下文。
一个请求可能包含数据库查询、HTTP调用和消息发布。span支持观察每段耗时,但采样trace不是全部请求计数,不能直接替代SLI指标。
HTTP请求头和Celery消息头可以携带trace上下文;outbox把发布延迟到另一个进程后,需要保存允许的追踪字段,再在publisher恢复。不能把Authorization或整个请求头带过去。
长延迟任务可以用span link关联原请求。为本地演示先使用标准traceparent传播,生产需按采样与链路保留策略决定父子关系或链接。
安装opentelemetry-api、opentelemetry-sdk、opentelemetry-instrumentation-fastapi、opentelemetry-instrumentation-httpx、opentelemetry-instrumentation-celery,并freeze记录兼容版本。新建trace_setup.py,Console仅供隔离调试;输出可能包含运行元信息,分享前仍需脱敏。
文件:trace_setup.py
from opentelemetry import trace
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor, ConsoleSpanExporter
def setup_trace(service_name: str):
provider = TracerProvider(resource=Resource.create({"service.name": service_name}))
provider.add_span_processor(BatchSpanProcessor(ConsoleSpanExporter()))
trace.set_tracer_provider(provider)
return provider
在应用启动配置中调用setup_trace一次,instrument现有app。数据库span只记录有限操作名和行数,不把笔记正文或SQL参数设成属性;HTTPX自动插桩也需核对URL是否包含敏感查询参数。
文件:app/main.py
from opentelemetry import trace
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor
from trace_setup import setup_trace
setup_trace("notes-service-api")
FastAPIInstrumentor.instrument_app(app, excluded_urls="live,ready,metrics")
HTTPXClientInstrumentor().instrument()
tracer = trace.get_tracer("notes-service")
def traced_read(conn, note_id):
with tracer.start_as_current_span("db.notes.read") as span:
row = conn.execute("SELECT id,title FROM notes WHERE id=%s", (note_id,)).fetchone()
span.set_attribute("app.rows_returned", 1 if row else 0)
return row
给export_outbox加trace_context jsonb列,默认{}。在提交事务中调用carrier_for_outbox并把返回值作为Jsonb写入同一事件。只保存标准追踪字段,不保存任意baggage或请求头。
文件:trace_carrier.py
from opentelemetry.propagate import inject
def carrier_for_outbox():
carrier = {}
inject(carrier)
return {key: value for key, value in carrier.items()
if key in {"traceparent", "tracestate"}}
# SQL:
# ALTER TABLE export_outbox ADD COLUMN IF NOT EXISTS
# trace_context jsonb NOT NULL DEFAULT '{}';
# INSERT outbox时将carrier_for_outbox()写入该列。
publisher领取事件时返回trace_context,在send_task外围attach/extract,finally detach。Celery消费者使用官方插桩;prefork子进程在worker_process_init中初始化provider。producer也在其进程初始化CeleryInstrumentor,避免重复初始化。
文件:trace_worker.py
from opentelemetry import context
from opentelemetry.propagate import extract
def publish_with_context(event, celery_app):
token = context.attach(extract(event.get("trace_context") or {}))
try:
return celery_app.send_task(
"notes.export", args=[str(event["job_id"])],
task_id=str(event["id"]), queue=event["queue_name"], retry=False,
)
finally:
context.detach(token)
# worker进程初始化模块,确保被Celery导入:
from celery.signals import worker_process_init
from opentelemetry.instrumentation.celery import CeleryInstrumentor
from trace_setup import setup_trace
@worker_process_init.connect(weak=False)
def init_worker_trace(**kwargs):
setup_trace("notes-service-worker")
CeleryInstrumentor().instrument()
发送一条本地导出请求,记录request_id→job_id映射和trace_id。先正常发布,再暂停publisher后恢复,观察上下文仍可关联。日志保留job_id,不能在trace采样丢弃时失去业务定位能力。
文件:trace-verification.md
# 待填写的演练字段
# request_id:
# job_id:
# outbox_id:
# api_trace_id:
# publish_trace_id:
# worker_trace_id:
# relation_observed:
# result: not_run
一次请求很快返回202,但导出很久才完成,需要定位等待发生在哪里。
暂停本地publisher后恢复,比较保留和缺少trace_context两种事件的链路关联。
原因:outbox异步发布丢了当前请求上下文,或producer未插桩。
处理:保存并恢复标准trace字段,初始化publisher和worker插桩。
原因:重复初始化造成警告或多个导出器。
处理:每个进程只初始化一次,prefork在子进程启动时设置。
删除一条实验outbox事件的trace_context后发布,说明业务任务是否应该失败。
提示:可观测性关联与业务输入是不同职责。
参考答案:
任务仍按job_id正常执行,只失去原trace关联;publisher可开始新trace并通过job_id日志定位,不能因缺少trace上下文拒绝已受理业务。
用进程指标、事件循环延迟和有界故障对照,定位Python服务变慢的位置。
async路由中time.sleep或同步网络等待会阻塞事件循环,但CPU可能很低。CPU持续高则需进一步区分Python计算、序列化与底层库工作。
周期协程实际恢复时间晚于预期,说明事件循环无法及时调度。它是现象指标,仍要结合堆栈、CPU与依赖耗时定位原因。
阻塞函数移到线程后,线程数不足仍可能排队;无限增加线程会扩大连接和内存消耗。任务数量、工作时间与下游额度共同决定容量。
先按部署名称读取MainPID,再确认PID非0、对应本次notes-service。pidstat来自sysstat,若未安装可先用ps。命令只读,不输出环境变量或完整命令行。
文件:终端 / 当前课时
API_PID=$(systemctl show notes-api.service -p MainPID --value)
ps -p "$API_PID" -o pid,ppid,comm,etime,%cpu,%mem,nlwp
pidstat -p "$API_PID" 1 5
ps -L -p "$API_PID" -o pid,tid,comm,pcpu
把watch_loop作为现有lifespan管理的任务,启动时create_task保存引用,退出时cancel并await。不要另建第二个FastAPI实例覆盖已有路由。下面只定义监测函数,日志数值来自实际运行。
文件:loop_watch.py
import asyncio
import json
async def watch_loop():
loop = asyncio.get_running_loop()
while True:
expected = loop.time() + 0.1
await asyncio.sleep(0.1)
lag = max(0, loop.time() - expected)
print(json.dumps({"event": "event_loop_lag", "seconds": lag}))
# 现有lifespan中:
# monitor = asyncio.create_task(watch_loop())
# try:
# yield
# finally:
# monitor.cancel()
# with contextlib.suppress(asyncio.CancelledError):
# await monitor
添加受开关控制的实验路由,默认404。开启LAB_ENABLE_FAULTS=1只用于本地副本。分别请求mode=sleep与mode=cpu,同时观察/live、CPU和loop lag;每次工作上限0.5秒,不启动无限循环。
文件:app/main.py
import os
import time
from typing import Literal
from fastapi import HTTPException
@app.get("/lab/block")
async def block_demo(mode: Literal["sleep", "cpu"] = "sleep"):
if os.getenv("LAB_ENABLE_FAULTS") != "1":
raise HTTPException(404, "not enabled")
if mode == "sleep":
time.sleep(0.5)
else:
until = time.perf_counter() + 0.5
while time.perf_counter() < until:
pass
return {"mode": mode}
sleep改为await asyncio.sleep;同步SDK按异步任务课使用受限线程,CPU工作分块或移到受限进程。保持同样请求数与参数比较,不同时调整worker数量和代码。若需要堆栈,先在隔离环境预先启用faulthandler,不能向未注册信号处理的进程随意发送信号。
文件:cpu-verification.md
# 在同样的低并发负载下各记录:
# CPU%、loop lag、HTTP耗时、线程数量、工作队列等待。
# 恢复后移除/lab/block,关闭LAB_ENABLE_FAULTS。
# 不把“线程数增加”本身当作修复证据。
高峰期所有API偶尔停顿,主机CPU却没有满。
本地分别注入0.5秒sleep和CPU循环,比较指标后恢复可等待实现。
原因:可能超过CPU配额或放大数据库连接。
处理:先判断可并行部分与资源预算,再做单变量容量实验。
原因:目标可能未注册处理器,信号行为未确认。
处理:仅在已配置并验证的隔离进程使用诊断信号,优先读取现有指标与日志。
描述“CPU低、loop lag高、请求都一起卡住”的首要检查方向。
提示:寻找async调用路径中的同步等待,而非先假定算力不足。
参考答案:
检查time.sleep、同步HTTP/数据库调用、同步文件操作;结合当前堆栈和依赖耗时验证,再改为异步调用或受限线程。
用小规模内存实验理解不同观测口径,避免把缓存保留或原生内存误判为Python泄漏。
RSS包含解释器、原生库、内存分配器和映射等;对象释放后RSS不一定立即下降。tracemalloc跟踪其启用后可追踪的Python内存分配,不能解释所有原生内存。
容器可能因自身memory.max触发OOM,即使主机仍有可用内存。查看cgroup事件和容器退出状态,不能只看free输出。
堆快照、频繁采样和大量日志会增加开销。本课只分配约10MiB演示,不故意耗尽宿主机内存,也不通过drop_caches改变现场。
Linux systemd 环境使用第 05 课确认的 API_PID。若使用 Docker,在部署模块的 Compose 目录运行容器命令,先用 api service 获取唯一容器 ID;多副本时逐个选择。docker inspect 仅取所需字段,不导出含环境变量的完整配置。容器 PID 与宿主 PID 不可直接互换。Docker stats 与 RSS 口径不同,记录时保留来源。
文件:终端 / 当前课时
# Linux systemd 环境:
ps -p "$API_PID" -o pid,comm,rss,vsz,%mem
cat /proc/"$API_PID"/cgroup
# Docker Compose 环境:在当前练习 Compose 目录执行。
API_CONTAINER=$(docker compose ps -q api)
if [ -z "$API_CONTAINER" ]; then
printf "api service has no running container\n" >&2
else
docker stats --no-stream "$API_CONTAINER"
docker inspect --format '{{.State.Status}} OOMKilled={{.State.OOMKilled}} ExitCode={{.State.ExitCode}}' "$API_CONTAINER"
fi
新建memory_probe.py,只在隔离环境执行。tracemalloc在分配前启动;先比before和during,再删除对象观察after。打印分配位置和大小,不打印对象内容。
文件:memory_probe.py
import gc
import tracemalloc
tracemalloc.start(10)
before = tracemalloc.take_snapshot()
buffers = [bytearray(512 * 1024) for _ in range(20)]
during = tracemalloc.take_snapshot()
for stat in during.compare_to(before, "lineno")[:5]:
print(stat)
del buffers
gc.collect()
after = tracemalloc.take_snapshot()
current, peak = tracemalloc.get_traced_memory()
print({"current_bytes": current, "peak_bytes": peak})
for stat in after.compare_to(before, "lineno")[:5]:
print(stat)
在cgroup v2容器内部检查memory.current、memory.max和memory.events;不同部署挂载方式可能不同,先确认cgroup路径。ExitCode=137也可能是人为SIGKILL,需结合OOMKilled和oom_kill计数,不单凭137下结论。
文件:终端 / 当前课时
# 在已确认使用cgroup v2的隔离容器内:
cat /sys/fs/cgroup/memory.current
cat /sys/fs/cgroup/memory.max
cat /sys/fs/cgroup/memory.events
# 只读取;不写drop_caches,不修改宿主机内存限制。
对同样的有限请求批次做多轮快照,观察相同分配位置是否持续增加。若tracemalloc稳定但RSS增长,进一步查原生库、连接缓冲和子进程;不要直接判为Python对象泄漏。修复后比较稳态与峰值。
文件:memory-record.json
{
"sample_type": "planned_memory_observation",
"round": null,
"request_count": null,
"rss_bytes": null,
"tracemalloc_current_bytes": null,
"tracemalloc_peak_bytes": null,
"cgroup_oom_kill_delta": null,
"result": "not_run"
}
导出运行一段时间后容器重启,需要确认是内存泄漏、峰值超限还是其他终止。
仅运行本课10MiB有限分配脚本,对比快照与RSS;OOM部分使用已有隔离演练证据或标not_run。
原因:分配器可能保留内存供复用,RSS不等于活对象。
处理:比较多轮稳态和tracemalloc增长来源,再判断是否持续无界增长。
原因:容器自身内存上限被触发。
处理:检查memory.max和memory.events,结合OOMKilled与具体负载调整。
如果tracemalloc当前值稳定、RSS持续增长,应先收集哪两类信息?
提示:考虑不被tracemalloc完整覆盖的分配与进程范围。
参考答案:
检查原生库/网络缓冲及映射,核对是否包含子进程或容器总内存;同时记录同一负载下的增长曲线,不能直接用gc.collect当修复。
把一次外部调用拆成阶段,核对客户端连接预算和部署网络命名空间。
DNS失败还没有获得目标地址,TCP连接拒绝通常没有进入HTTP,TLS错误发生在加密连接建立阶段,HTTP504可能来自网关或应用。不能把所有错误都叫网络慢。
宿主、容器和WSL中的127.0.0.1可能指向不同位置。任务broker宿主映射6384,容器内通常通过broker:6379;缓存宿主6380,不能混用端口。
HTTPX PoolTimeout是等待池连接超时,不表示目标服务器连接失败。限制max_connections,并复用客户端,避免每请求建立独立连接池。
命令在发生问题的同一容器或WSL环境执行。getent使用实际依赖主机名;ss只看必要监听与汇总信息。不要打印包含密码的完整DATABASE_URL或Celery URL。
文件:终端 / 当前课时
getent hosts broker
ss -lnt
ss -s
# 在宿主检查本地映射;不带任何凭据:
curl -sS --connect-timeout 1 --max-time 3 -o /dev/null -w 'code=%{http_code} connect=%{time_connect} total=%{time_total}\n' http://127.0.0.1:8000/live
新建http_budget.py。HTTPX异步客户端在应用lifespan创建并关闭;以下脚本独立运行以观察异常类型。只请求本地/slow,不传真实凭据。
文件:http_budget.py
import asyncio
import httpx
async def main():
limits = httpx.Limits(max_connections=2, max_keepalive_connections=2)
timeout = httpx.Timeout(connect=1, read=3, write=3, pool=0.1)
async with httpx.AsyncClient(
base_url="http://127.0.0.1:8000", limits=limits, timeout=timeout
) as client:
async def one():
try:
response = await client.get("/slow", params={"delay": 1})
return response.status_code
except httpx.HTTPError as exc:
return type(exc).__name__
print(await asyncio.gather(*(one() for _ in range(5))))
if __name__ == "__main__":
asyncio.run(main())
先运行上一步,超过两个同时请求时可能出现PoolTimeout。再把独立脚本base_url改成本地确认未监听的端口,观察ConnectError;恢复后把read改0.1秒观察ReadTimeout。一次只改一项,记录实际异常类型。
文件:network-record.md
# 演练记录字段:
# phase: pool / connect / read
# configuration_changed:
# exception_type_observed:
# elapsed_seconds:
# restored_to_baseline: false
# 上例是记录模板,不是已观察到的结果。
若必须定位TLS,用无凭据的测试请求核对证书主机名与链路;不要以verify=False作为修复。抓包只在隔离且无敏感流量的环境按最小范围使用,本模块默认以阶段耗时和连接统计定位,不导出完整HTTP载荷。
文件:network-notes.md
# curl阶段耗时,可用于已确认的无凭据测试URL:
# -w 'dns=%{time_namelookup} connect=%{time_connect} tls=%{time_appconnect} first_byte=%{time_starttransfer} total=%{time_total}'
# 这些是从开始累计的时间,不是可以直接相加的独立阶段耗时。
导出调用存储接口时偶发超时,扩容后错误反而增加。
仅对本地/slow控制池上限与read超时,分别观察PoolTimeout与ReadTimeout并恢复配置。
原因:6384是宿主映射,容器localhost不是broker服务。
处理:在同网络容器使用broker:6379,在宿主使用127.0.0.1:6384。
原因:真正等待的是本进程连接池,未确认下游是否瓶颈。
处理:检查连接泄漏、流响应是否关闭、并发需求与池预算,再逐项调整。
在两个连接额度下发五个耗时1秒请求,解释为什么pool=0.1可能先失败。
提示:池等待发生在获得连接前,与read=3不是同一个计时阶段。
参考答案:
前两个请求占用连接,后续请求等待超过0.1秒可触发PoolTimeout,不能因此认定下游超过3秒未响应。
从 pg_stat_activity 进入具体等待关系,使用短事务和隔离锁实验验证原因。
应用先等连接池,再发送SQL,数据库还可能等锁或I/O。只看到接口慢,不足以判断需要加索引。
pg_stat_activity显示状态、事务年龄和wait_event,pg_blocking_pids定位阻塞来源。先看进程与关系,再决定是否需要查看脱敏SQL。
EXPLAIN ANALYZE会真的执行查询,写操作还会产生副作用。先用EXPLAIN;隔离环境才能用已限制范围的ANALYZE,并设置合理statement_timeout和lock_timeout。
连接PostgreSQL16 notes_lab,app_user,宿主5433。通过安全配置的DATABASE_URL或pgpass连接,避免把密码贴入命令行。普通账户可见信息受权限限制,不把缺失字段当作没有活动。
文件:终端 / 当前课时
SELECT pid,usename,application_name,state,
now()-xact_start AS transaction_age,
now()-query_start AS query_age,
wait_event_type,wait_event,
pg_blocking_pids(pid) AS blocked_by
FROM pg_stat_activity
WHERE datname='notes_lab' AND pid<>pg_backend_pid()
ORDER BY xact_start NULLS LAST;
只选本地演练笔记ID。会话A开启事务并锁定该行;会话B设置1秒lock_timeout后尝试锁同一行。观察锁等待错误,随后两个会话都ROLLBACK,释放故障条件。
文件:终端 / 当前课时
-- 会话A:将1替换为专门用于本次演练的笔记ID
BEGIN;
SELECT id FROM notes WHERE id=1 FOR UPDATE;
-- 暂不提交,在会话B执行下面语句后回到A:ROLLBACK;
-- 会话B
BEGIN;
SET LOCAL lock_timeout='1s';
SELECT id FROM notes WHERE id=1 FOR UPDATE;
-- 超时后:ROLLBACK;
下面EXPLAIN不实际执行SELECT。若需要ANALYZE,先确认只读、数据规模有限且本地可承受,再以事务级statement_timeout执行。不要在事故中直接对未知大表运行昂贵诊断。
文件:终端 / 当前课时
EXPLAIN (COSTS, VERBOSE)
SELECT id,title FROM notes WHERE id=1;
-- 仅隔离练习:
BEGIN;
SET LOCAL statement_timeout='2s';
EXPLAIN (ANALYZE, BUFFERS)
SELECT id,title FROM notes WHERE id=1;
ROLLBACK;
若使用psycopg_pool,记录get_stats中的等待、请求和连接数,字段按安装版本核对。连接预算需乘以API worker和Celery进程数;不要只把每个池的max_size调大。等待连接时数据库可能没有相应新查询。
文件:pool_observation.py
# 使用现有psycopg_pool对象,只记录统计数字:
# stats = pool.get_stats()
# logger.info({"event": "db_pool_stats", "stats": stats})
# 检查应用事务边界:
# 获取连接 → 短SQL/事务 → 提交或回滚 → 归还连接
# 不把网络上传、长await或CPU导出放在持有事务的区间。
回滚会话A后重试读取,应恢复正常;再次查询活动会话确认没有本次idle in transaction。记录谁持锁、谁等待、锁何时释放。未经确认不要终止任意数据库backend。
文件:终端 / 当前课时
SELECT pid,state,now()-xact_start AS transaction_age
FROM pg_stat_activity
WHERE datname='notes_lab' AND state='idle in transaction';
发布后笔记读取突然变慢,数据库CPU却不高。
使用两条本地数据库连接持锁与超时,再回滚并确认恢复,不终止其他会话。
原因:请求可能还在应用连接池排队。
处理:检查连接归还、池等待统计和进程总连接预算。
原因:PostgreSQL事务已进入失败状态。
处理:先ROLLBACK,再重新开始需要的事务;不要忽略异常后继续使用。
说明为什么一个API进程池max_size=10,扩成4个进程后数据库连接可能接近40。
提示:进程之间通常不共享同一个连接池对象。
参考答案:
每个进程拥有自己的上限,总预算需加上Celery、publisher、recovery和管理连接;扩worker前先核对数据库容量。
先确认实例用途,再观察命中率、内存和延迟,保护数据库避免缓存故障扩散。
缓存可按业务降级回源,broker承载待交付消息,不能通过清空键空间恢复。模块统一缓存宿主6380、独立任务broker6384;先核对用途再操作。
keyspace_hits和keyspace_misses是累计计数,计算窗口命中率要使用增量。evicted_keys增加表示内存策略淘汰,expired_keys增加表示TTL到期,两者原因不同。
缓存不可用后所有请求直接打数据库可能造成级联故障。设置缓存短超时、回源并发限制和必要的过载拒绝;多进程总量不能只按单进程Semaphore理解。
在Linux/WSL2终端通过read隐藏输入本地缓存密码,不把密码写进-a参数。以下只读命令指向6380;broker6384需要其独立密码,不能复用缓存故障动作。
文件:终端 / 当前课时
read -rsp 'Local cache password: ' REDISCLI_AUTH
export REDISCLI_AUTH
redis-cli -h 127.0.0.1 -p 6380 PING
redis-cli -h 127.0.0.1 -p 6380 INFO stats
redis-cli -h 127.0.0.1 -p 6380 INFO memory
redis-cli -h 127.0.0.1 -p 6380 INFO clients
CONFIG GET是只读但可能被ACL禁止;权限不足需要按观测权限配置处理,不绕过认证。SLOWLOG可能包含命令参数,先查长度;只有无敏感数据的隔离实例才查看具体条目。不要运行KEYS *或MONITOR扫描实际业务内容。
文件:终端 / 当前课时
redis-cli -h 127.0.0.1 -p 6380 CONFIG GET maxmemory maxmemory-policy
redis-cli -h 127.0.0.1 -p 6380 LATENCY LATEST
redis-cli -h 127.0.0.1 -p 6380 SLOWLOG LEN
# 在无敏感数据的隔离实例才进一步检查SLOWLOG内容。
unset REDISCLI_AUTH
下面函数使用已有redis异步客户端和read_db异步函数,客户端需设置socket_connect_timeout/socket_timeout,例如各0.2秒。缺失或连接失败时回源,缓存写失败不改变已成功读取的数据。此示例只用于单进程容量实验;真实权限必须进入缓存key或查询范围。
文件:cache_fallback.py
import asyncio
import json
from redis.exceptions import ConnectionError as RedisConnectionError
from redis.exceptions import TimeoutError as RedisTimeoutError
db_slots = asyncio.Semaphore(4)
async def cached_note(note_id, cache, read_db):
key = f"lab:notes:{note_id}"
try:
raw = await cache.get(key)
if raw is not None:
return json.loads(raw)
except (RedisConnectionError, RedisTimeoutError, json.JSONDecodeError):
pass
async with asyncio.timeout(1):
async with db_slots:
row = await read_db(note_id)
if row is not None:
try:
await cache.set(key, json.dumps(row), ex=30)
except (RedisConnectionError, RedisTimeoutError):
pass
return row
仅停止本地缓存实例,保持broker和数据库运行,低并发读取相同笔记。记录缓存异常、数据库请求数、池等待和HTTP状态;恢复缓存后观察命中回升。回源超时应由API转换为明确503,不返回虚构笔记或无限等待。
文件:cache-drill.json
{
"sample_type": "cache_drill_template",
"window_seconds": null,
"hits_delta": null,
"misses_delta": null,
"evicted_keys_delta": null,
"db_requests_delta": null,
"http_503_count": null,
"result": "not_run"
}
热门笔记读取突然变慢,缓存命中下降且数据库连接开始排队。
只暂停本地缓存6380,以有限并发请求观察回源,再恢复并核对命中和数据库负载。
原因:无上限回源造成级联放大。
处理:先限制回源并发和请求速率,必要时拒绝非关键读取。
原因:混淆实例用途,可能删除未处理消息。
处理:独立实例与凭据,先核对地址和角色,不以flushdb/purge作为排障默认动作。
命中100、未命中20增至命中140、未命中30,计算该窗口命中率。
提示:使用变化量40和10,不使用启动以来累计值。
参考答案:
窗口命中率=40/(40+10)=80%;同时记录窗口请求量50,不能把低样本波动误判为长期趋势。
把受理、outbox发布、broker排队和worker执行分开检查,再选择恢复动作。
export_outbox未发布也属于用户等待;只看Celery active会漏掉publisher故障。queued任务即使暂时没有pending outbox,也可能已在broker或存在消息丢失窗口,需要派发记录和恢复规则判断。
任务主动retry可能带退避;broker重复交付可能携带旧消息。持久attempts、next_attempt_at、deadline和领取CAS必须一起限制,不能只看Celery request.retries。
下游仍不可用时增加worker或批量重放会放大负载。先停止错误输入或恢复依赖,再按有限批次处理到期任务;不要默认purge清空消息或强杀所有worker。
在tasks_lab工作目录,读取正确Celery环境:独立broker宿主6384,容器内broker:6379,入口celery_app:app。inspect可能包含任务参数,本模块后期只传job_id;分享输出前核对。
文件:终端 / 当前课时
python -m celery -A celery_app:app status
python -m celery -A celery_app:app inspect active
python -m celery -A celery_app:app inspect reserved
python -m celery -A celery_app:app inspect scheduled
python -m celery -A celery_app:app inspect active_queues
使用SQL同时看业务状态、待发布事件和过期租约。queued很老只是排查线索,不足以直接断言消息已经丢失;按异步任务模块的派发记录与恢复门槛处理。
文件:终端 / 当前课时
SELECT status,count(*) AS total,
extract(epoch FROM now()-min(created_at)) AS oldest_seconds
FROM export_jobs GROUP BY status;
SELECT queue_name,count(*) AS pending,
extract(epoch FROM now()-min(created_at)) AS oldest_seconds
FROM export_outbox WHERE sent_at IS NULL GROUP BY queue_name;
SELECT id,attempts,lease_until,error_code
FROM export_jobs WHERE status='running' AND lease_until<now()
ORDER BY lease_until LIMIT 20;
从实际失败日志选定一个本地job_id,查询其事件ID、attempts和状态转换。区分多次消息到达与多次成功领取;重复消息到达不应绕过next_attempt_at或为活跃租约增加执行副作用。不要只看同一task_id的最终缓存值。
文件:终端 / 当前课时
-- 将参数绑定为本次演练job_id,避免拼接外部输入。
-- SELECT id,status,attempts,deadline_at,lease_token,lease_until,error_code
-- FROM export_jobs WHERE id=%s;
-- SELECT id,job_id,attempts,sent_at,available_at
-- FROM export_outbox WHERE job_id=%s ORDER BY created_at;
-- 如果已应用任务模块派发与退避字段,同时核对相应时间条件。
先确认下游恢复、容量可用和代码修复,再运行任务模块有限批次recovery与publisher。单步脚本不常驻;部署中的循环wrapper有独立节奏与停机管理。失败耗尽任务仅在确认修复后用replay_job逐条生成新job,保留旧失败证据。
文件:终端 / 当前课时
python recovery.py
python publisher.py
# 对已修复且明确的failed任务:
# python replay_job.py <本次已核对的失败job_id>
# 不运行celery purge,不通过重置attempts解除失败限制。
恢复后持续观察最老等待、完成速率、重试次数和下游错误,覆盖至少一个任务截止时间与恢复扫描周期。增加worker后若数据库池等待上升,应停止扩大并发并重新核算预算。
文件:queue-recovery.md
# 恢复验收记录:
# 影响队列:
# 下游修复证据:
# 受控恢复任务ID:
# 最老等待变化:
# 重试/重投变化:
# 新导出完成结果:
# 未恢复任务及原因:
# result: not_run
任务队列积压,团队准备增加worker,但导出持续重试。
仅暂停本地publisher后提交,再恢复;另让隔离worker一次失败,对照两种积压阶段和恢复路径。
原因:路由队列不匹配、publisher未发布或任务未注册。
处理:核对active_queues、registered和outbox,再查执行容量。
原因:下游不可用,更多worker只产生更多失败。
处理:先止住重复输入或恢复依赖,保留退避、总预算和入口容量。
outbox数量上升、worker空闲、broker可连接时,优先查哪两个位置?
提示:消息可能没有被publisher成功发送,或发到了无人消费的队列。
参考答案:
检查publisher错误/领取租约/标记过程,以及outbox queue_name与worker active_queues是否一致;不能直接判断worker算力不足。
对比直连、代理和浏览器,分别验证首段到达、消息边界和连接生命周期。
服务端生成、ASGI发送、代理转发、客户端读取和页面渲染都可能缓冲。先比较各段链路,再修改对应配置。
SSE是持续HTTP响应,关注缓冲、压缩和读超时;WebSocket在HTTP/1.1握手时需要正确升级头,之后关注空闲超时与心跳。不能用普通GET成功证明WebSocket链路正常。
用户停止后应关闭连接和定时器,不自动重新生成同一内容。网络断开可有限退避重连,但不自动重发未经确认的业务消息。
新建stream_probe.py,只发送hello测试文本。按实际到达data行记录时间,不能用curl time_starttransfer代替首个业务片段,因为响应头可能很早已发送。
文件:stream_probe.py
from time import perf_counter
import httpx
def probe(base_url):
started = perf_counter()
with httpx.Client(base_url=base_url, timeout=5) as client:
with client.stream("GET", "/stream", params={"prompt": "hello"}) as response:
print("status", response.status_code, response.headers.get("content-type"))
response.raise_for_status()
for line in response.iter_lines():
if line.startswith("data:"):
print(round(perf_counter()-started, 3), line, flush=True)
if __name__ == "__main__":
probe("http://127.0.0.1:8000")
按Docker/Linux模块的实际代理端口修改base_url;下面8080仅表示本地演练映射。若直连逐段、代理一次到达,检查代理缓冲;若两者正常而浏览器延后,查response.text/json或渲染缓存。
文件:终端 / 当前课时
curl -N --max-time 5 'http://127.0.0.1:8000/stream?prompt=hello'
curl -N --max-time 5 'http://127.0.0.1:8080/stream?prompt=hello'
# Windows终端用curl.exe;代理端口以本地部署配置为准。
以下片段放入已有nginx实验配置。示例代理与API同宿主;容器部署用实际API服务名替换127.0.0.1。先nginx -t通过,再按部署模块重载隔离代理。proxy_read_timeout是两次读取间隔限制,不是总请求时长。
文件:nginx-lab.conf
# server内部的SSE location
location /stream {
proxy_pass http://127.0.0.1:8000;
proxy_http_version 1.1;
proxy_buffering off;
proxy_read_timeout 65s;
}
# http级别:
# map $http_upgrade $connection_upgrade {
# default upgrade;
# '' close;
# }
# server内部的WebSocket location:
# location /ws {
# proxy_pass http://127.0.0.1:8000;
# proxy_http_version 1.1;
# proxy_set_header Upgrade $http_upgrade;
# proxy_set_header Connection $connection_upgrade;
# proxy_read_timeout 65s;
# }
在隔离页面Console连接已确认的代理WS地址,观察Network中的握手与Frames/Messages。HTTPS页面使用wss。收到open后发送hello和ping,再主动关闭;按API课程有限退避逻辑验证重连。
文件:浏览器Console
const socket = new WebSocket("ws://127.0.0.1:8080/ws");
socket.onopen = () => {
socket.send("hello");
socket.send("ping");
};
socket.onmessage = event => console.log(JSON.parse(event.data));
socket.onerror = () => console.log("socket error");
socket.onclose = event => console.log("closed", event.code, event.reason);
// 收到消息后主动停止:socket.close(1000, "drill complete");
SSE收到done后关闭EventSource;fetch用AbortController停止读取,服务端取消后释放资源。WebSocket主动停止后等待超过最大重连间隔,确认无新连接。恢复所有临时代理配置,再跑一次完整SSE和echo。
文件:stream-verification.md
# 记录实际观测,勿填写预计时间:
# direct_first_data_seconds:
# proxy_first_data_seconds:
# browser_first_render_seconds:
# websocket_handshake_result:
# stop_followed_by_reconnect: 未测
# server_cleanup_observed: 未测
# result: not_run
本地API流式正常,部署后文本一次出现或WebSocket频繁断开。
只在隔离代理开启/关闭响应缓冲做对照,随后测试空闲与主动停止两类断开。
原因:前端调用了response.text/json或等待完整文本再渲染。
处理:使用流式读取和按协议边界解析,分段更新UI。
原因:代理空闲超时或心跳缺失可能触发,但需结合日志验证。
处理:核对代理读超时与实际消息间隔,采用有界应用心跳和停止规则。
如果response headers立刻到达、三条data都在1.2秒同时出现,能断言服务器没有yield吗?
提示:中间层或客户端可能缓冲,先做直连对照。
参考答案:
不能。比较直连data到达时间与代理结果,再检查生成器、代理缓冲和客户端读取;响应头时间不证明每段业务数据的传输情况。
把排障步骤整理为可交接流程,保存证据、恢复条件和后续修复项。
每个故障限定实例、任务ID、持续时间和恢复方法。达到预定风险边界就停止,不能为了复现而无限增加负载。
服务恢复包括新请求正常、积压受控、已受理任务去向清楚、无错误发布结果。重启成功或监控颜色变绿只是部分证据。
记录什么触发、为什么扩大、哪些控制有效、哪里缺少观测或恢复能力。行动项要有负责人、验收条件和优先级,不只写“加强监控”。
每个案例用全新测试数据,先保存无故障基线。下面矩阵是待执行计划,不代表已经发生事故或完成测试。禁止把生产凭据、真实笔记正文放入公开材料。
文件:drill-plan.md
| 案例 | 限定故障 | 主要证据 | 恢复动作 |
|---|---|---|---|
| 请求阻塞 | 本地0.5秒同步sleep | loop lag、CPU、HTTP耗时 | 恢复await实现 |
| 数据库锁 | 两会话同一演练笔记 | blocked_by、lock_timeout | 两会话ROLLBACK |
| 缓存不可用 | 只停缓存6380 | 回源量、503、池等待 | 恢复缓存 |
| 发布积压 | 只停publisher | outbox最老等待 | 恢复有界publisher |
| worker租约到期 | 一个记录过的job | token、attempts、状态 | 有界恢复扫描 |
| SSE缓冲 | 隔离代理location | 首data时间对照 | 恢复缓冲配置 |
将以下阈值改为本地机器可承受值,再执行。示例阈值只用于练习,不作为线上SLO建议。超出范围停止故障注入并回到基线,不继续加压寻找更大异常。
文件:drill-boundary.json
{
"scope": "isolated notes-service only",
"max_test_requests": 20,
"max_fault_duration_seconds": 30,
"max_controlled_python_allocation_mib": 10,
"stop_if": ["出现非演练数据写入风险", "无法确定目标实例", "恢复命令失败"],
"recovery_gate": [
"live、ready与笔记读取按基线返回",
"一个新导出完成且文件符合快照",
"旧演练任务均有结果或明确失败原因",
"outbox和租约等待回到可解释范围"
],
"result": "not_run"
}
每次记录UTC时间、目标、request_id/job_id、配置前值、操作、指标变化和恢复前值。日志示例标synthetic,实际输出标observed。任何未执行项保留not_run,不能根据理论预计填写成功。
文件:drill-result.json
{
"record_type": "template",
"case": "db_lock_wait",
"started_at_utc": null,
"target": null,
"request_id": null,
"job_id": null,
"observations": [],
"restored_at_utc": null,
"recovery_checks": [],
"result": "not_run"
}
每条runbook提供症状、只读检查、分支判断、限定动作、回滚和验收。命令中的PID、容器名、数据库和端口必须先核对;不要放“重启全部”“清空队列”之类无范围操作。
文件:runbook-export.md
# Runbook:导出已受理但长期未完成
## 识别
记录job_id、受理时间、当前数据库状态。
## 只读检查
outbox未发布?队列有人消费?租约有效?attempts/deadline是否耗尽?
## 分支
未发布→查publisher;无人消费→查路由;租约过期→按恢复规则处理。
## 动作
只处理已确认的本地实例/任务,保持次数、退避与容量约束。
## 回滚
恢复本次修改前配置,停止新故障输入。
## 验收
核对新任务、旧任务与结果文件;未恢复项明确列出。
复盘不只记录最后一个报错。为触发原因、扩大因素、恢复机制和观测缺口分别填写证据,并把修复项写成可验收工作。示例中的行动项需要实际演练支持后才采用。
文件:postmortem-actions.md
| 类型 | 发现 | 证据 | 行动项 | 验收 |
|---|---|---|---|---|
| 触发 | 待实际观测 | 时间线链接 | 待定 | 可重复复现 |
| 扩大因素 | 待实际观测 | 指标/trace | 待定 | 相同故障影响受限 |
| 恢复缺口 | 待实际观测 | 任务状态 | 待定 | 恢复时间可测 |
| 观测缺口 | 待实际观测 | 缺失字段 | 待定 | 可定位具体阶段 |
将notes-service交付维护,希望另一位开发者能按文档定位并恢复常见故障。
逐项执行本课矩阵,每次恢复基线后才进入下一项,最终检查所有临时开关与事务已关闭。
原因:没有记录配置前值和恢复清单。
处理:每次演练最后核对开关、代理配置、进程和事务,跑无故障回归。
原因:忽略缺少权限边界、幂等、容量或观测导致的扩大。
处理:把触发因素和系统控制缺口分别写清,并给可验收修复项。
把“加强监控”改成一个可验收行动项。
提示:指定指标、范围、触发条件、关联信息和验证方式。
参考答案:
例如:为export_outbox最老未发布年龄增加告警,限定服务标签,持续超过本地设定阈值时提供publisher日志入口;用暂停本地publisher的演练验证触发与恢复,数值按实际业务基线设置。