[ { "id": "01", "group": "影响、日志与观测", "title": "先确认影响范围,再选择止损动作", "summary": "得到包含实际影响、动作前提和恢复证据的事故记录,而不是先重启一遍所有服务。", "objectives": [ "分别检查商品、健康与导出路径", "把观察事实和待验证原因分开", "恢复后追踪旧任务与新任务去向" ], "concepts": [ { "title": "错误数量不能单独决定影响", "body": "回到“建立影响矩阵”采集的证据再看。一个导出接口失败和全部商品读取失败有不同影响。记录受影响操作、用户范围、开始时间、成功率和延迟,再确定处理优先级。" }, { "title": "先区分事实与假设", "body": "回到“为每次动作写四个字段”采集的证据再看。“新增请求都超时”是观测,“数据库挂了”是待验证假设。每次只改变一个因素,避免多个操作互相覆盖证据。" }, { "title": "恢复服务与定位根因分开记录", "body": "回到“核对止损后仍有无遗漏任务”采集的证据再看。止损可以是暂停大导出受理、回退本次配置或缩小流量;它不自动证明根因。恢复后继续核对已受理任务和数据一致性。" } ], "steps": [ { "title": "建立独立演练目录与时间基线", "body": "本模块在部署模块建立的 shop-service 隔离副本上演练;Linux 单元为 shop-api.service,基础端点为 /live(进程存活)与 /ready(依赖就绪)。先在副本新建 diagnostics_lab。Linux/WSL2 命令只针对隔离目标;容器部署从 Compose 目录使用 docker compose ps 定位 service,不猜容器名。第 11 课的 SSE/WS 是 API 模块扩展路由,须先确认已加入副本。", "code": "mkdir -p diagnostics_lab\ndate -u +%FT%TZ\nsystemctl show shop-api.service -p ActiveState -p SubState -p MainPID\ncurl -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\ncurl -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", "lang": "bash", "expected": "记录到 UTC 时间、实际服务 PID 与 live/ready 的 HTTP 状态和耗时,diagnostics_lab 目录存在。", "explanation": "这些结果建立当前副本的基线;进程、依赖就绪与业务完成是不同观察层,先知道正常值才有比较依据。" }, { "title": "建立影响矩阵", "body": "保存 incident.md,把每格填成实际观测。下表是待测模板,不是真实事故结论。持久导出沿异步任务模块的 tasks_lab 命令和 export_jobs 表核对;第 01 课内存 POST /exports 只是协议练习,并未自动加入部署 API。如果另行实现了持久 HTTP 适配层,再添加对应路由;202 只表示受理。流式扩展未加入本次部署时填不适用。", "code": "| 操作 | 当前观测 | 受影响范围 | 最后一次正常时间 |\n|---|---|---|---|\n| GET /live | 待测 | 待测 | 待测 |\n| GET /ready | 待测 | 待测 | 待测 |\n| GET /products | 待测 | 待测 | 待测 |\n| submit_snapshot 提交持久导出 | 待测 | 待测 | 待测 |\n| export_jobs 状态与结果文件 | 待测 | 待测 | 待测 |\n| SSE /stream 与 WS /ws | 待测 | 待测 | 待测 |", "lang": "text", "filename": "incident.md", "expected": "incident.md 各行填实际状态、范围与最后正常时间;未整合的流式或 HTTP 导出入口标不适用。", "explanation": "影响矩阵区分部分能力受损和全站不可用;导出受理成功并不意味着 Worker 已执行或文件可读。" }, { "title": "为每次动作写四个字段", "body": "先用只读查询验证假设,再决定是否在隔离副本中操作。比如只暂停本地导出受理,预期减小新增积压;回滚方式是恢复之前配置。当前代码若没有受理开关,先增加并测试该开关,不能把未实现的操作写成已执行。", "code": "{\n \"timestamp_utc\": \"执行时填写\",\n \"observation\": \"填写实际现象\",\n \"hypothesis\": \"填写待验证原因\",\n \"action\": \"planned\",\n \"expected_change\": \"填写应变化的指标\",\n \"rollback\": \"填写恢复原配置的方法\",\n \"result\": \"not_run\"\n}", "lang": "json", "filename": "action-record.json", "expected": "每个候选动作都有观测、假设、预期变化和恢复方式,未实施时 result 保持 not_run。", "explanation": "把原因先写成假设,才能用检查反驳它;预计结果与实际结果分开,避免记录反过来证明自己的猜测。" }, { "title": "核对止损后仍有无遗漏任务", "body": "只读查询数据库任务状态与最老等待。恢复判定至少覆盖健康、商品读取、一个新导出完成、旧任务去向;不能仅以进程重新启动作为结束标准。", "code": "SELECT status,count(*) AS jobs,\n TIMESTAMPDIFF(SECOND,MIN(created_at),UTC_TIMESTAMP(6)) AS oldest_age_seconds\nFROM export_jobs GROUP BY status;\n\nSELECT count(*) AS unpublished FROM export_outbox WHERE sent_at IS NULL;", "lang": "sql", "expected": "可读到各任务状态数量、最老年龄和未发事件数;恢复后新任务完成,旧任务有明确状态去向。", "explanation": "积压可能在恢复进程后继续存在,甚至有旧任务遗漏;业务恢复需要追到持久状态和文件而非只看进程。" }, { "title": "维护带因果关系的时间线", "body": "每条记录包含时间、执行人、目标、动作和结果。日志或截图引用需先脱敏;记录中的时间使用UTC,操作系统和应用时钟偏差单独注明。最后保留尚未解释的事实。", "code": "# incident.md 时间线模板\n# UTC时间 | 目标实例/任务ID | 实际观测 | 动作 | 结果 | 下一验证\n# 未执行动作统一标planned,不用预计结果填入实际结果列。", "lang": "text", "filename": "incident.md", "expected": "时间线按 UTC 串起目标、动作、指标变化和下一验证,未解释事实仍保留。", "explanation": "时间先后帮助建立可检验的因果链,但不能仅因两事件接近就认定原因;时钟偏差需要单独说明。" } ], "checks": [ "影响范围按具体用户操作列出,而非只写服务异常。", "每次变更有目标、预期、回滚和实际结果。", "恢复门槛包含业务请求与既有任务去向,未执行项保持not_run。" ], "pitfalls": [ { "symptom": "看到500立即重启所有组件", "cause": "未判断影响位置,重启可能扩大故障并丢掉现场。", "fix": "先保存进程、接口和依赖观测,仅对已验证目标采取有界动作。" }, { "symptom": "健康检查成功就宣布恢复", "cause": "健康检查可能只确认进程存活,没有覆盖数据库和队列。", "fix": "增加真实读取与导出完成验收,区分liveness和业务可用性。" } ], "exercise": { "task": "“/live正常、/products正常、导出202正常但10分钟未完成”应怎样描述影响?", "hint": "把用户完成时间与请求受理状态分开。", "answer": "描述为导出执行链路延迟或积压,读取功能仍可用;检查outbox、队列、worker、租约,不能仅凭202判定业务正常,也不能直接推断数据库整体不可用。" }, "refs": [ { "label": "Google SRE事故管理", "url": "https://sre.google/sre-book/managing-incidents/" }, { "label": "Google SRE有效排障", "url": "https://sre.google/sre-book/effective-troubleshooting/" } ], "production": { "scenario": "用户反馈导出一直等待,但商品列表仍正常。", "decisions": [ "先降低用户影响,再定位根因,两者分别留证据。", "一次只调整一个因素,限定实例和任务ID。", "业务恢复要求验证旧任务和新请求,不能只看进程状态。" ], "failureDrill": "只暂停本地练习worker,提交一条导出,按模板判断影响,再恢复worker核对任务完成。", "acceptance": [ "时间线区分事实、假设与操作。", "没有盲目批量重启或终止进程。", "恢复结论有业务验证支持。" ] }, "intro": { "scenario": "用户说商品清单一直导不出来,但商品页面可能仍能打开。先确认影响哪些操作,再决定动哪一处。", "startingPoint": [ "已完成 Docker 或 Linux 部署,并在独立副本验证商品查询、持久导出和结果文件。", "选择一条实际部署路线,核对 /live、/ready 以及真实任务提交入口;所有演练只在隔离副本执行。" ], "outcome": "得到包含实际影响、动作前提和恢复证据的事故记录,而不是先重启一遍所有服务。" } }, { "id": "02", "group": "影响、日志与观测", "title": "用结构化日志与 request_id 关联一次请求", "summary": "从响应拿到请求编号,再用同一编号找到必要日志,并验证测试秘密未被自定义日志记录。", "objectives": [ "把实际响应与一条 JSON 日志关联", "验证非法编号被替换且敏感值不入日志", "把受理请求编号连到持久导出 job_id" ], "concepts": [ { "title": "请求ID用于关联,不代表身份", "body": "回到“注册并验证响应头”采集的证据再看。接收可信网关生成的ID或自行生成;客户端输入需要限制长度和字符,不能直接拼入日志行。request_id只帮助定位请求,不能用于权限判断。" }, { "title": "记录路由模板和必要字段", "body": "回到“用合成记录练习检索”采集的证据再看。优先记录GET、/products/{product_id}、状态、耗时、实例与版本。完整URL可能包含查询凭据,商品描述可能包含敏感内容,排障不要求默认记录全部数据。" }, { "title": "HTTP完成与业务完成不同", "body": "回到“把任务日志接到同一业务ID”采集的证据再看。HTTP响应完成可以记录访问耗时,导出任务另记job_id和业务阶段。SSE长连接需按首段和整体生命周期分别观察,不能把202访问日志当成导出完成日志。" } ], "steps": [ { "title": "编写JSON日志中间件", "body": "在隔离 shop-service 根目录新建 request_logging.py,保存以下包装器;它与 app 目录并列,可供 app/main.py 导入。先通读输入编号的限制和日志字段,再保存,下一步才注册到现有应用。", "code": "import json\nimport logging\nimport re\nimport time\nfrom uuid import uuid4\n\nlogger = logging.getLogger(\"shop.requests\")\n\nclass RequestLogMiddleware:\n def __init__(self, app):\n self.app = app\n\n async def __call__(self, scope, receive, send):\n if scope[\"type\"] != \"http\":\n return await self.app(scope, receive, send)\n headers = dict(scope.get(\"headers\", []))\n supplied = headers.get(b\"x-request-id\", b\"\").decode(\"ascii\", \"ignore\")\n request_id = supplied if re.fullmatch(r\"[A-Za-z0-9_-]{1,64}\", supplied) else uuid4().hex\n scope.setdefault(\"state\", {})[\"request_id\"] = request_id\n started = time.perf_counter()\n status = 500\n error_type = None\n\n async def observe_send(message):\n nonlocal status\n if message[\"type\"] == \"http.response.start\":\n status = message[\"status\"]\n clean = [(k, v) for k, v in message.get(\"headers\", []) if k.lower() != b\"x-request-id\"]\n message = {**message, \"headers\": clean + [(b\"x-request-id\", request_id.encode())]}\n await send(message)\n\n try:\n await self.app(scope, receive, observe_send)\n except Exception as exc:\n error_type = type(exc).__name__\n raise\n finally:\n route = getattr(scope.get(\"route\"), \"path\", \"unmatched\")\n logger.info(json.dumps({\n \"event\": \"http_request\", \"request_id\": request_id,\n \"method\": scope[\"method\"], \"route\": route, \"status\": status,\n \"duration_ms\": round((time.perf_counter()-started)*1000, 2),\n \"error_type\": error_type,\n }, ensure_ascii=False))", "lang": "python", "filename": "request_logging.py", "expected": "request_logging.py 能导入,包装器仅处理 HTTP,代码在发送响应头时添加请求编号,在结束时写一条 JSON。", "explanation": "ASGI 的 send 是应用向服务器交响应的位置;在这里观察真实状态,流式响应也不会被提前当成普通短响应结束。" }, { "title": "注册并验证响应头", "body": "在现有 app/main.py 的 app = FastAPI(...) 之后注册下面中间件,不创建第二个 app。若已有统一日志配置则合并 message 格式,不覆盖其他 logger。按已选择部署方式重启本次隔离 API,再执行代码末尾的 curl。", "code": "import logging\nfrom request_logging import RequestLogMiddleware\n\nlogging.basicConfig(level=logging.INFO, format=\"%(message)s\")\napp.add_middleware(RequestLogMiddleware)\n\n# 启动后:\n# curl -i -H \"X-Request-ID: drill-001\" http://127.0.0.1:8000/live", "lang": "python", "filename": "app/main.py", "expected": "正常请求响应头含 X-Request-ID: drill-001,实际日志有同一 request_id;非法或过长编号被替换为新编号。", "explanation": "编号用于检索一条请求经过的记录,不代表调用者身份。只接受有限字符也避免任意输入污染日志格式。" }, { "title": "用合成记录练习检索", "body": "以下仅为示例日志。保存到sample.jsonl后用rg 'drill-001'查找。真实日志中应出现实际状态和耗时,不把下例数值作为当前服务性能结论。", "code": "{\"event\":\"http_request\",\"request_id\":\"drill-001\",\"method\":\"GET\",\"route\":\"/products/{product_id}\",\"status\":404,\"duration_ms\":4.2,\"error_type\":null}", "lang": "json", "filename": "sample.jsonl", "expected": "rg 找到 drill-001 的一行合成记录,其 route 是 /products/{product_id}、状态 404;没有把示例 4.2 ms 当作实测。", "explanation": "结构化字段使检索不依赖自然语言报错,路由模板把同类请求归在一起,同时不暴露具体商品编号。" }, { "title": "验证脱敏和异常路径", "body": "对本地请求添加测试用Authorization和带敏感占位词的查询字符串,确认自定义日志没有这些值。只使用fake-secret,不用真实凭据。再触发404与受控500,核对request_id和error_type;网关/Uvicorn默认访问日志需要另行检查查询字符串记录策略。", "code": "curl -sS -H 'Authorization: Bearer fake-secret' 'http://127.0.0.1:8000/live?token=fake-secret'\nrg -n 'fake-secret|Authorization|password' sample.jsonl\n# 对实际演练日志重复检查,预期自定义JSON访问日志不包含测试秘密。", "lang": "bash", "expected": "实际自定义 JSON 日志搜索 fake-secret 无匹配;404 可关联到请求,受控异常日志含实际 error_type。", "explanation": "自定义日志只列必要字段,因而不会自动输出 Authorization 或查询串;其他访问日志仍有独立策略,需分别核对。" }, { "title": "把任务日志接到同一业务ID", "body": "提交导出时记录request_id和job_id的对应关系,worker记录job_id、message_id和lease_token。不要把整个request对象塞进Celery消息;跨进程关联下一课后续用trace上下文补充。", "code": "def log_export_accepted(request_id: str, job_id: str):\n logger.info(json.dumps({\n \"event\": \"export_accepted\",\n \"request_id\": request_id,\n \"job_id\": job_id,\n }))", "lang": "python", "filename": "request_logging.py", "expected": "提交记录包含 request_id 与真实 job_id;Worker 记录能按同一 job_id 找到对应投递和执行。", "explanation": "HTTP 请求结束后任务仍继续,业务编号把跨进程的长期工作串起来,message_id 与执行版本进一步区分重投和接管。" } ], "checks": [ "合法request_id能在响应头和JSON访问日志中对应。", "不存在路径使用unmatched,不记录完整查询串。", "fake-secret不进入自定义日志,任务受理与HTTP请求能够关联。" ], "pitfalls": [ { "symptom": "日志不是一行一个JSON", "cause": "异常正文或字符串拼接引入未转义换行。", "fix": "用json.dumps构造完整记录,按日志系统约定处理堆栈字段。" }, { "symptom": "自定义日志脱敏但代理日志仍泄露token", "cause": "只检查应用logger,没有检查访问日志链路。", "fix": "逐层审查代理、Uvicorn与应用日志,URL不承载长期凭据。" } ], "exercise": { "task": "传入超过64字符的X-Request-ID,确认服务器生成新ID而不是直接回显。", "hint": "测试输入只用重复字母,不需要任何真实秘密。", "answer": "返回ID长度应为生成的32位hex,并可在日志关联;非法输入不会进入日志字段。" }, "refs": [ { "label": "Python logging", "url": "https://docs.python.org/3.11/library/logging.html" }, { "label": "ASGI HTTP事件", "url": "https://asgi.readthedocs.io/en/latest/specs/www.html" }, { "label": "OWASP日志安全", "url": "https://cheatsheetseries.owasp.org/cheatsheets/Logging_Cheat_Sheet.html" } ], "production": { "scenario": "某个用户操作失败,需要从响应定位一次请求,又不能泄露商品和密钥。", "decisions": [ "关联ID、业务ID和错误类型足以支持多数定位。", "日志字段采用允许列表,敏感数据不靠事后人工删除保障。", "请求完成与异步任务完成使用不同事件名。" ], "failureDrill": "对本地接口发送fake-secret、非法ID及受控错误,检索全部日志层的实际输出。", "acceptance": [ "请求能关联,凭据不会被自定义日志保存。", "日志是可解析的结构化记录。", "示例日志与真实观测明确区分。" ] }, "intro": { "scenario": "同一时刻有很多商品请求,某位用户遇到的错误混在日志中;先让一次请求留下可关联的编号。", "startingPoint": [ "完成第 01 课,隔离副本有正常健康与商品请求基线。", "能编辑实际 FastAPI 应用并在该项目虚拟环境运行;中间件文件与 app.main 的导入路径需保持一致。" ], "outcome": "从响应拿到请求编号,再用同一编号找到必要日志,并验证测试秘密未被自定义日志记录。" } }, { "id": "03", "group": "影响、日志与观测", "title": "定义 SLI、SLO 与低基数指标", "summary": "从真实请求产生计数与耗时指标,按路由模板检查标签,并给商品读取写出可计算目标。", "objectives": [ "在请求完成时更新计数和耗时", "验证不同商品编号复用同一路由标签", "把成功比例和时间目标对应到实际查询" ], "concepts": [ { "title": "SLI先定义分母与好事件", "body": "回到“写出查询与业务目标”采集的证据再看。例如商品读取的有效请求中成功完成的比例,必须说明是否排除主动取消和客户端无效请求。SLO是时间窗口内目标,不能把单次健康检查通过当作达标。" }, { "title": "标签组合决定时间序列数量", "body": "回到“验证基数与告警触发条件”采集的证据再看。request_id、job_id、用户ID和原始URL不适合作为指标标签。使用method、路由模板、状态类别;具体请求通过日志与trace定位。" }, { "title": "分位数需要正确聚合", "body": "回到“写出查询与业务目标”采集的证据再看。Histogram按桶计数,可在服务实例间聚合;平均耗时可能掩盖慢请求。低流量时分位数和错误率波动大,应同时看样本量,不能通过人为补成功请求掩盖空窗口。" } ], "steps": [ { "title": "安装指标库并定义有限标签", "body": "进入隔离 shop-service 根目录并激活它的虚拟环境,执行 python -m pip install prometheus-client,将实际版本记录回练习依赖文件。新建 metrics_lab.py 保存下面定义;本课先使用单个 Uvicorn 进程,多 worker 汇总需另外配置。", "code": "from prometheus_client import Counter, Histogram\n\nREQUESTS = Counter(\n \"shop_http_requests_total\", \"HTTP requests\",\n [\"method\", \"route\", \"status_class\"],\n)\nDURATION = Histogram(\n \"shop_http_request_duration_seconds\", \"HTTP request lifecycle\",\n [\"method\", \"route\"],\n buckets=(0.01, 0.05, 0.1, 0.3, 1, 3, 10),\n)", "lang": "python", "filename": "metrics_lab.py", "expected": "metrics_lab.py 定义请求计数和耗时直方图,标签只含有限 method、route、status_class。", "explanation": "计数累计发生次数,直方图按区间累计耗时样本;每种标签组合都会产生序列,因此不能把每个商品或任务编号当标签。" }, { "title": "在实际请求完成时观测", "body": "在 request_logging.py 的 import 区加入下面指标导入,在类之前定义 record_metrics;然后在第 02 课 __call__ 的 finally 内取消末行调用示例的注释并加入调用。不要重复注册中间件。SSE 本课统计整条连接时长,首段到达另在第 11 课测。", "code": "from metrics_lab import REQUESTS, DURATION\n\ndef record_metrics(scope, status, elapsed):\n if scope.get(\"path\") == \"/metrics\":\n return\n method = scope.get(\"method\", \"OTHER\")\n if method not in {\"GET\",\"POST\",\"PUT\",\"PATCH\",\"DELETE\",\"OPTIONS\",\"HEAD\"}:\n method = \"OTHER\"\n route = getattr(scope.get(\"route\"), \"path\", \"unmatched\")\n group = f\"{status // 100}xx\"\n REQUESTS.labels(method, route, group).inc()\n DURATION.labels(method, route).observe(elapsed)\n\n# RequestLogMiddleware的finally中:\n# record_metrics(scope, status, time.perf_counter()-started)", "lang": "python", "filename": "request_logging.py", "expected": "普通请求完成后调用 record_metrics;/metrics 自己不增加业务计数,路由未匹配时使用 unmatched。", "explanation": "记录完成时的实际状态和耗时能让失败请求也进入统计;排除抓取请求避免监控本身改变被观察的业务比例。" }, { "title": "暴露本地抓取入口", "body": "在现有app注册一次/metrics,使用Response直接返回指标文本。正式环境应限制观测端点访问范围。抓取后连续请求不同product_id,确认标签仍是/products/{product_id}。", "code": "from fastapi import Response\nfrom prometheus_client import generate_latest, CONTENT_TYPE_LATEST\n\n@app.get(\"/metrics\", include_in_schema=False)\ndef metrics():\n return Response(content=generate_latest(), headers={\"Content-Type\": CONTENT_TYPE_LATEST})", "lang": "python", "filename": "app/main.py", "expected": "/metrics 返回指标文本,访问多个商品编号后标签仍显示 /products/{product_id},不是各个实际 URL。", "explanation": "统一路由模板让同类请求共用有限指标系列;进程内变量只覆盖本进程,多 worker 不能直接假定共享计数。" }, { "title": "写出查询与业务目标", "body": "在本地Prometheus配置抓取目标后使用查询。下面示例看5xx比例和P95,均需先有流量。为GET /products另写目标:例如最近7天有效读取99.5%在300ms内成功;数值是练习目标,是否适合业务需按实际基线确定。", "code": "sum(rate(shop_http_requests_total{status_class=\"5xx\"}[5m]))\n/\nsum(rate(shop_http_requests_total[5m]))\n\nhistogram_quantile(\n 0.95,\n sum by (le, route) (rate(shop_http_request_duration_seconds_bucket[5m]))\n)", "lang": "promql", "filename": "metrics-queries.promql", "expected": "有本地抓取与真实流量时查询返回数值;无流量或无抓取时记录无数据。目标明确规定有效请求、时间窗口与成功耗时。", "explanation": "SLI 是按分母计算的实际表现,SLO 是对其设定目标;P95 需从可合并的直方图桶计算,不能平均各实例 P95。" }, { "title": "验证基数与告警触发条件", "body": "用20个不同ID访问同一路由,检查/metrics没有20个原始路径标签。无请求的窗口标无数据;告警同时要求有效样本和持续时间。导出积压另由状态表采样为Gauge,不能把每个job作为标签。", "code": "curl -sS http://127.0.0.1:8000/metrics\n# 指标标签只允许有限方法、路由模板与状态类别。\n# 不应出现 request_id=、job_id=、user_id= 或带查询串的route。", "lang": "bash", "expected": "20 个不同 ID 没有带来 20 个原始路径标签,无 request_id/job_id/user_id 标签;告警要求样本和持续窗口。", "explanation": "没有样本不是 0% 错误或 100% 成功。有限标签保护监控系统容量,持续窗口避免单个偶发样本触发误判。" } ], "checks": [ "指标能被Prometheus解析,计数随真实请求增加。", "不同product_id共享同一路由标签,没有高基数ID标签。", "SLI写明分母与窗口,SLO数值被标为练习目标。" ], "pitfalls": [ { "symptom": "指标内存持续增长", "cause": "把原始URL、用户或任务ID用作标签。", "fix": "换成有限路由模板,具体对象放日志和trace。" }, { "symptom": "平均耗时正常却有用户一直等待", "cause": "平均值掩盖尾部延迟,导出202也没有覆盖完成时间。", "fix": "同时看P95/分桶和业务受理到完成耗时。" } ], "exercise": { "task": "写一个300ms内请求比例查询,并说明它还缺什么条件才能叫“快速且成功”。", "hint": "Histogram的le=0.3桶只看时长,本课时长指标未附带成功标签。", "answer": "sum(rate(..._bucket{le='0.3'}[5m]))/sum(rate(..._count[5m]))得到时长比例;若要快速且成功,应另记录成功请求的时长桶或专用好事件计数,不能直接把独立成功率与时长比例相乘。" }, "refs": [ { "label": "Prometheus标签命名", "url": "https://prometheus.io/docs/practices/naming/" }, { "label": "Prometheus Histogram", "url": "https://prometheus.io/docs/practices/histograms/" }, { "label": "Prometheus Python客户端", "url": "https://prometheus.github.io/client_python/" } ], "production": { "scenario": "接口平均延迟没有明显变化,但部分用户查询和导出等待越来越久。", "decisions": [ "成功率、尾部耗时和异步完成时间分别建模。", "指标保持低基数,日志和trace承接单请求细节。", "SLO依据基线与用户需求制定,空窗口不伪造100%成功。" ], "failureDrill": "在本地只让少数请求延迟,再比较平均值与耗时桶;以不同ID请求验证时间序列数量稳定。", "acceptance": [ "统计口径可复算。", "标签数量受控。", "告警有样本量与持续时间条件,不由一条慢请求直接推导事故。" ] }, "intro": { "scenario": "能找到单次错误了,还需要知道“最近五分钟是不是普遍变慢”,并避免为每个商品制造一套指标。", "startingPoint": [ "完成第 02 课,中间件会在请求结束时记录实际状态与耗时。", "在隔离项目虚拟环境、单个 Uvicorn 进程验证;Prometheus 查询步骤需本地抓取实例,无实例时先完成 /metrics 验证并注明。" ], "outcome": "从真实请求产生计数与耗时指标,按路由模板检查标签,并给商品读取写出可计算目标。" } }, { "id": "04", "group": "影响、日志与观测", "title": "把 trace 贯穿 HTTP、数据库与 Celery", "summary": "让一次正常与一次延迟派发的导出都能关联 HTTP、数据库、消息与 Worker 阶段。", "objectives": [ "给商品读取加有限操作名和耗时", "把允许的追踪上下文随待发记录保存", "核对恢复派发后仍能关联原业务任务" ], "concepts": [ { "title": "trace显示阶段关系与耗时", "body": "回到“给API和数据库操作加span”采集的证据再看。一个请求可能包含数据库查询、HTTP调用和消息发布。span支持观察每段耗时,但采样trace不是全部请求计数,不能直接替代SLI指标。" }, { "title": "跨进程需要传播上下文", "body": "回到“在publisher与worker恢复上下文”采集的证据再看。HTTP请求头和Celery消息头可以携带trace上下文;outbox把发布延迟到另一个进程后,需要保存允许的追踪字段,再在publisher恢复。不能把Authorization或整个请求头带过去。" }, { "title": "异步任务不一定延续请求寿命", "body": "回到“比较同步与延迟发布链路”采集的证据再看。长延迟任务可以用span link关联原请求。为本地演示先使用标准traceparent传播,生产需按采样与链路保留策略决定父子关系或链接。" } ], "steps": [ { "title": "安装并配置本地trace输出", "body": "在同一隔离虚拟环境执行 python -m pip install opentelemetry-api opentelemetry-sdk opentelemetry-instrumentation-fastapi opentelemetry-instrumentation-httpx opentelemetry-instrumentation-celery,并用 python -m pip freeze 记录实际兼容版本。根目录新建 trace_setup.py 保存下方函数;控制台输出只用于隔离调试,分享前脱敏。", "code": "from opentelemetry import trace\nfrom opentelemetry.sdk.resources import Resource\nfrom opentelemetry.sdk.trace import TracerProvider\nfrom opentelemetry.sdk.trace.export import BatchSpanProcessor, ConsoleSpanExporter\n\ndef setup_trace(service_name: str):\n provider = TracerProvider(resource=Resource.create({\"service.name\": service_name}))\n provider.add_span_processor(BatchSpanProcessor(ConsoleSpanExporter()))\n trace.set_tracer_provider(provider)\n return provider", "lang": "python", "filename": "trace_setup.py", "expected": "trace_setup.py 可导入,各进程调用 setup_trace 后控制台出现带 service.name 的 span 输出。", "explanation": "一个 span 描述一段工作及耗时,trace 通过上下文把多段工作关联;控制台输出适合隔离观察,仍可能含需脱敏的元信息。" }, { "title": "给API和数据库操作加span", "body": "在应用启动配置中调用setup_trace一次,instrument现有app。数据库span只记录有限操作名和行数,不把商品描述或SQL参数设成属性;HTTPX自动插桩也需核对URL是否包含敏感查询参数。 traced_read接收现有SQLAlchemy AsyncSession,由调用者管理事务并await此函数;不要把同步PyMySQL调用直接放入异步路由。", "code": "from opentelemetry import trace\nfrom opentelemetry.instrumentation.fastapi import FastAPIInstrumentor\nfrom opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor\nfrom trace_setup import setup_trace\n\nsetup_trace(\"shop-service-api\")\nFastAPIInstrumentor.instrument_app(app, excluded_urls=\"live,ready,metrics\")\nHTTPXClientInstrumentor().instrument()\ntracer = trace.get_tracer(\"shop-service\")\n\nfrom sqlalchemy import text\n\nasync def traced_read(db, product_id):\n with tracer.start_as_current_span(\"db.products.read\") as span:\n row = (await db.execute(\n text(\"SELECT id,name,price_cents FROM products WHERE id=:id\"), {\"id\": product_id}\n )).mappings().one_or_none()\n span.set_attribute(\"app.rows_returned\", 1 if row else 0)\n return row", "lang": "python", "filename": "app/main.py", "expected": "一次商品读取出现 HTTP 与命名的数据库操作 span,属性只有有限操作类型和行数,没有 SQL 参数或商品正文。", "explanation": "嵌套 span 帮助区分时间花在入口还是读库;异步数据库调用继续使用现有 AsyncSession,插桩不能把同步阻塞变为异步。" }, { "title": "把允许的追踪字段存入outbox", "body": "通过审核后的MySQL迁移为export_outbox新增trace_context JSON列。先确认列不存在;ALTER TABLE会隐式提交,不包在业务受理事务里。提交事件时,在同一业务事务中将json.dumps(carrier_for_outbox())作为参数绑定到该列。只保存标准追踪字段,不复制任意baggage或请求头。", "code": "import json\nfrom opentelemetry.propagate import inject\n\ndef carrier_for_outbox():\n carrier = {}\n inject(carrier)\n return {key: value for key,value in carrier.items()\n if key in {\"traceparent\", \"tracestate\"}}\n\n# 只在迁移中执行一次,先检查实际列结构:\n# ALTER TABLE export_outbox\n# ADD COLUMN trace_context JSON NOT NULL DEFAULT (JSON_OBJECT());\n# submit_snapshot的outbox INSERT增加trace_context占位符,参数传:\n# json.dumps(carrier_for_outbox())", "lang": "python", "filename": "trace_carrier.py", "expected": "隔离库新增可空 trace_context 列,新待发记录保存允许的 traceparent/tracestate 字段;无上下文时可为空。", "explanation": "请求结束后内存上下文会消失,把允许字段与派发意图同事务保存,才能在稍后发送时恢复关联;任意请求头不应随消息持久化。" }, { "title": "在publisher与worker恢复上下文", "body": "publisher领取事件时返回trace_context,在send_task外围attach/extract,finally detach。Celery消费者使用官方插桩;prefork子进程在worker_process_init中初始化provider。producer也在其进程初始化CeleryInstrumentor,避免重复初始化。 PyMySQL读取JSON列通常得到字符串,先json.loads再交给propagator;缺失值按空上下文处理。事件领取SELECT必须包含trace_context。", "code": "import json\nfrom opentelemetry import context\nfrom opentelemetry.propagate import extract\n\ndef publish_with_context(event, celery_app):\n raw = event.get(\"trace_context\")\n carrier = json.loads(raw) if isinstance(raw,str) else (raw or {})\n if not isinstance(carrier,dict):\n raise ValueError(\"invalid trace context\")\n carrier = {k:v for k,v in carrier.items() if k in {\"traceparent\",\"tracestate\"} and isinstance(v,str)}\n token = context.attach(extract(carrier))\n try:\n return celery_app.send_task(\n \"products.export\", args=[str(event[\"job_id\"])],\n task_id=str(event[\"id\"]), queue=event[\"queue_name\"], retry=False,\n )\n finally:\n context.detach(token)\n\n# worker进程初始化模块,确保被Celery导入:\nfrom celery.signals import worker_process_init\nfrom opentelemetry.instrumentation.celery import CeleryInstrumentor\nfrom trace_setup import setup_trace\n\n@worker_process_init.connect(weak=False)\ndef init_worker_trace(**kwargs):\n setup_trace(\"shop-service-worker\")\n CeleryInstrumentor().instrument()", "lang": "python", "filename": "trace_worker.py", "expected": "发布器从领取结果解析 trace_context 后 attach,在结束时 detach;Worker 子进程有自己的 provider,发送与消费 span 可关联。", "explanation": "上下文必须跨进程传递,不能靠同名全局变量。prefork 子进程需要在自己的生命周期初始化采集资源。" }, { "title": "比较同步与延迟发布链路", "body": "发送一条本地导出请求,记录request_id→job_id映射和trace_id。先正常发布,再暂停publisher后恢复,观察上下文仍可关联。日志保留job_id,不能在trace采样丢弃时失去业务定位能力。", "code": "# 待填写的演练字段\n# request_id:\n# job_id:\n# outbox_id:\n# api_trace_id:\n# publish_trace_id:\n# worker_trace_id:\n# relation_observed:\n# result: not_run", "lang": "text", "filename": "trace-verification.md", "expected": "正常和暂停 publisher 后恢复的任务都有 request_id→job_id 映射与可核对 trace 关系,任务最终完成。", "explanation": "异步延迟不应切断业务定位线索;trace 可能被采样丢弃,因此持久 job_id 与日志关联仍需独立保留。" } ], "checks": [ "API内部数据库span可定位并未记录商品描述。", "outbox只保存允许的追踪字段。", "publisher恢复上下文,worker链路可以关联,日志仍保留业务ID。" ], "pitfalls": [ { "symptom": "HTTP有trace,worker却变成另一条无关联链路", "cause": "outbox异步发布丢了当前请求上下文,或producer未插桩。", "fix": "保存并恢复标准trace字段,初始化publisher和worker插桩。" }, { "symptom": "每次任务执行都初始化TracerProvider", "cause": "重复初始化造成警告或多个导出器。", "fix": "每个进程只初始化一次,prefork在子进程启动时设置。" } ], "exercise": { "task": "删除一条实验outbox事件的trace_context后发布,说明业务任务是否应该失败。", "hint": "可观测性关联与业务输入是不同职责。", "answer": "任务仍按job_id正常执行,只失去原trace关联;publisher可开始新trace并通过job_id日志定位,不能因缺少trace上下文拒绝已受理业务。" }, "refs": [ { "label": "OpenTelemetry Python", "url": "https://opentelemetry.io/docs/languages/python/instrumentation/" }, { "label": "FastAPI插桩", "url": "https://opentelemetry-python-contrib.readthedocs.io/en/latest/instrumentation/fastapi/fastapi.html" }, { "label": "Celery插桩", "url": "https://opentelemetry-python-contrib.readthedocs.io/en/latest/instrumentation/celery/celery.html" } ], "production": { "scenario": "一次请求很快返回202,但导出很久才完成,需要定位等待发生在哪里。", "decisions": [ "同时关联request_id、job_id、消息ID与trace。", "outbox只持久必要追踪字段,外部输入按信任边界处理。", "采样策略不能代替任务状态与指标,缺trace不阻断业务。" ], "failureDrill": "暂停本地publisher后恢复,比较保留和缺少trace_context两种事件的链路关联。", "acceptance": [ "可以区分API执行、待发布、排队和worker执行阶段。", "凭据与商品描述没有进入trace属性。", "缺少追踪上下文仍能根据业务ID恢复任务。" ] }, "intro": { "scenario": "一个导出先经过 API,过一会儿由另一个进程派发,再由 Worker 执行;请求编号之外还要看耗时分在哪。", "startingPoint": [ "完成第 02—03 课,以及任务模块持久 outbox 链路;保留一条正常任务的 job_id。", "只在可回退的隔离库增加追踪列,API、publisher、worker 使用各自实际启动入口,输出分享前脱敏。" ], "outcome": "让一次正常与一次延迟派发的导出都能关联 HTTP、数据库、消息与 Worker 阶段。" } }, { "id": "05", "group": "进程、内存与依赖", "title": "区分 CPU 忙、事件循环阻塞与线程池等待", "summary": "对照 CPU、事件循环延迟和相同请求耗时,解释两个阻塞实验并验证恢复。", "objectives": [ "先定位正确进程并记录基线", "对照有限 sleep 与计算实验", "一次只改一个因素验证请求恢复" ], "concepts": [ { "title": "CPU低不代表应用没有阻塞", "body": "回到“只在隔离副本复现两种阻塞”采集的证据再看。async路由中time.sleep或同步网络等待会阻塞事件循环,但CPU可能很低。CPU持续高则需进一步区分Python计算、序列化与底层库工作。" }, { "title": "事件循环延迟显示调度是否及时", "body": "回到“在隔离应用记录事件循环延迟”采集的证据再看。周期协程实际恢复时间晚于预期,说明事件循环无法及时调度。它是现象指标,仍要结合堆栈、CPU与依赖耗时定位原因。" }, { "title": "线程池排队有独立等待成本", "body": "回到“记录修复前后同样负载”采集的证据再看。阻塞函数移到线程后,线程数不足仍可能排队;无限增加线程会扩大连接和内存消耗。任务数量、工作时间与下游额度共同决定容量。" } ], "steps": [ { "title": "采集一个明确进程的基线", "body": "先按部署名称读取MainPID,再确认PID非0、对应本次shop-service。pidstat来自sysstat,若未安装可先用ps。命令只读,不输出环境变量或完整命令行。", "code": "API_PID=$(systemctl show shop-api.service -p MainPID --value)\nps -p \"$API_PID\" -o pid,ppid,comm,etime,%cpu,%mem,nlwp\npidstat -p \"$API_PID\" 1 5\nps -L -p \"$API_PID\" -o pid,tid,comm,pcpu", "lang": "bash", "expected": "API_PID 非 0 且确认为本次服务,得到 CPU、线程数与运行时间的基线,没有输出环境变量。", "explanation": "性能数字只有关联到正确进程才有意义;多 worker 时主进程数字还需与真实处理请求的子进程区分。" }, { "title": "在隔离应用记录事件循环延迟", "body": "在隔离 app 的辅助模块保存 watch_loop,并在已有 lifespan 中按代码末尾示例创建和取消任务,保留原数据库等资源管理。使用 contextlib.suppress 前加 import contextlib。不要另建第二个 app 或覆盖原 lifespan 的初始化、清理代码。", "code": "import asyncio\nimport json\n\nasync def watch_loop():\n loop = asyncio.get_running_loop()\n while True:\n expected = loop.time() + 0.1\n await asyncio.sleep(0.1)\n lag = max(0, loop.time() - expected)\n print(json.dumps({\"event\": \"event_loop_lag\", \"seconds\": lag}))\n\n# 现有lifespan中:\n# monitor = asyncio.create_task(watch_loop())\n# try:\n# yield\n# finally:\n# monitor.cancel()\n# with contextlib.suppress(asyncio.CancelledError):\n# await monitor", "lang": "python", "filename": "loop_watch.py", "expected": "正常时每轮输出实际 event_loop_lag,停止应用后监测任务被取消并等待结束。", "explanation": "该值测计划唤醒与实际被调度之间的差,体现事件循环是否及时处理其他任务;它不是某个请求本身的总耗时。" }, { "title": "只在隔离副本复现两种阻塞", "body": "添加受开关控制的实验路由,默认404。开启LAB_ENABLE_FAULTS=1只用于本地副本。分别请求mode=sleep与mode=cpu,同时观察/live、CPU和loop lag;每次工作上限0.5秒,不启动无限循环。", "code": "import os\nimport time\nfrom typing import Literal\nfrom fastapi import HTTPException\n\n@app.get(\"/lab/block\")\nasync def block_demo(mode: Literal[\"sleep\", \"cpu\"] = \"sleep\"):\n if os.getenv(\"LAB_ENABLE_FAULTS\") != \"1\":\n raise HTTPException(404, \"not enabled\")\n if mode == \"sleep\":\n time.sleep(0.5)\n else:\n until = time.perf_counter() + 0.5\n while time.perf_counter() < until:\n pass\n return {\"mode\": mode}", "lang": "python", "filename": "app/main.py", "expected": "sleep 和 cpu 实验都可能推高 loop lag 与同时发出的 /live 耗时,cpu 模式通常还出现明显 CPU 使用。", "explanation": "异步路由里同步 sleep 占着事件循环却不忙算,因此低 CPU 也能阻塞;计算循环既占执行时间也占 CPU。" }, { "title": "记录修复前后同样负载", "body": "sleep改为await asyncio.sleep;同步SDK按异步任务课使用受限线程,CPU工作分块或移到受限进程。保持同样请求数与参数比较,不同时调整worker数量和代码。若需要堆栈,先在隔离环境预先启用faulthandler,不能向未注册信号处理的进程随意发送信号。", "code": "# 在同样的低并发负载下各记录:\n# CPU%、loop lag、HTTP耗时、线程数量、工作队列等待。\n# 恢复后移除/lab/block,关闭LAB_ENABLE_FAULTS。\n# 不把“线程数增加”本身当作修复证据。", "lang": "text", "filename": "cpu-verification.md", "expected": "相同有限负载下记录修复前后 CPU、loop lag、请求耗时与线程等待,恢复后故障开关关闭且实验路由移除。", "explanation": "只改一项才能判断改善来自实现变化。受限线程适合同步 I/O,CPU 计算需要不同隔离,单纯增加线程数不构成修复证据。" } ], "checks": [ "sleep阻塞与CPU循环都可增加loop lag,但CPU表现不同。", "修复使用可等待I/O后,同样负载下/live不再被同步sleep拖住。", "线程池容量与排队情况有记录,未用无限并发掩盖问题。" ], "pitfalls": [ { "symptom": "看到CPU高就直接增加Uvicorn worker", "cause": "可能超过CPU配额或放大数据库连接。", "fix": "先判断可并行部分与资源预算,再做单变量容量实验。" }, { "symptom": "对生产进程直接发送SIGUSR1期待堆栈", "cause": "目标可能未注册处理器,信号行为未确认。", "fix": "仅在已配置并验证的隔离进程使用诊断信号,优先读取现有指标与日志。" } ], "exercise": { "task": "描述“CPU低、loop lag高、请求都一起卡住”的首要检查方向。", "hint": "寻找async调用路径中的同步等待,而非先假定算力不足。", "answer": "检查time.sleep、同步HTTP/数据库调用、同步文件操作;结合当前堆栈和依赖耗时验证,再改为异步调用或受限线程。" }, "refs": [ { "label": "Python asyncio调试", "url": "https://docs.python.org/3.11/library/asyncio-dev.html" }, { "label": "Python faulthandler", "url": "https://docs.python.org/3.11/library/faulthandler.html" }, { "label": "Python执行器", "url": "https://docs.python.org/3.11/library/concurrent.futures.html" } ], "production": { "scenario": "高峰期所有API偶尔停顿,主机CPU却没有满。", "decisions": [ "事件循环延迟与CPU一起观察。", "诊断信号和分析工具先在隔离环境验证权限与开销。", "修复时限制线程、进程和下游连接总量。" ], "failureDrill": "本地分别注入0.5秒sleep和CPU循环,比较指标后恢复可等待实现。", "acceptance": [ "能够用证据区分计算和等待。", "相同负载下修复前后可比较。", "故障路由默认关闭且最终移除。" ] }, "intro": { "scenario": "商品请求变慢,CPU 却不一定高;用两个半秒实验区分“忙于计算”和“把异步处理卡住”。", "startingPoint": [ "完成第 02—04 课,已有日志、请求耗时和隔离服务基线。", "已确认当前 API 进程属于本次副本;故障路由默认关闭,只有本地练习才开启 LAB_ENABLE_FAULTS=1。" ], "outcome": "对照 CPU、事件循环延迟和相同请求耗时,解释两个阻塞实验并验证恢复。" } }, { "id": "06", "group": "进程、内存与依赖", "title": "区分 Python 分配、RSS 与容器 OOM", "summary": "比较 Python 分配、进程内存与容器限制,结合退出证据判断下一步排查方向。", "objectives": [ "为每个内存数值标注来源和单位", "运行有限分配并比较释放前后快照", "结合 OOM 标记和计数判断退出原因" ], "concepts": [ { "title": "RSS不等于Python活对象大小", "body": "回到“运行有限Python分配实验”采集的证据再看。RSS包含解释器、原生库、内存分配器和映射等;对象释放后RSS不一定立即下降。tracemalloc跟踪其启用后可追踪的Python内存分配,不能解释所有原生内存。" }, { "title": "容器限制与主机余量不同", "body": "回到“关联OOM事件与进程退出”采集的证据再看。容器可能因自身memory.max触发OOM,即使主机仍有可用内存。查看cgroup事件和容器退出状态,不能只看free输出。" }, { "title": "诊断也需要容量预算", "body": "回到“设计增长复现与修复对照”采集的证据再看。堆快照、频繁采样和大量日志会增加开销。本课只分配约10MiB演示,不故意耗尽宿主机内存,也不通过drop_caches改变现场。" } ], "steps": [ { "title": "读取进程与容器口径", "body": "Linux systemd 环境使用第 05 课确认的 API_PID。若使用 Docker,在部署模块的 Compose 目录运行容器命令,先用 api service 获取唯一容器 ID;多副本时逐个选择。docker inspect 仅取所需字段,不导出含环境变量的完整配置。容器 PID 与宿主 PID 不可直接互换。Docker stats 与 RSS 口径不同,记录时保留来源。", "code": "# Linux systemd 环境:\nps -p \"$API_PID\" -o pid,comm,rss,vsz,%mem\ncat /proc/\"$API_PID\"/cgroup\n\n# Docker Compose 环境:在当前练习 Compose 目录执行。\nAPI_CONTAINER=$(docker compose ps -q api)\nif [ -z \"$API_CONTAINER\" ]; then\n printf \"api service has no running container\\n\" >&2\nelse\n docker stats --no-stream \"$API_CONTAINER\"\n docker inspect --format '{{.State.Status}} OOMKilled={{.State.OOMKilled}} ExitCode={{.State.ExitCode}}' \"$API_CONTAINER\"\nfi", "lang": "bash", "expected": "每条观测标明进程 RSS/VSZ 或 Docker stats/限制,容器 inspect 的 OOMKilled 与 ExitCode 单独记录。", "explanation": "RSS、容器统计与 Python 分配跟踪覆盖不同范围,单位也可能不同;混在同一曲线会导致错误比较。" }, { "title": "运行有限Python分配实验", "body": "新建memory_probe.py,只在隔离环境执行。tracemalloc在分配前启动;先比before和during,再删除对象观察after。打印分配位置和大小,不打印对象内容。", "code": "import gc\nimport tracemalloc\n\ntracemalloc.start(10)\nbefore = tracemalloc.take_snapshot()\nbuffers = [bytearray(512 * 1024) for _ in range(20)]\nduring = tracemalloc.take_snapshot()\nfor stat in during.compare_to(before, \"lineno\")[:5]:\n print(stat)\ndel buffers\ngc.collect()\nafter = tracemalloc.take_snapshot()\ncurrent, peak = tracemalloc.get_traced_memory()\nprint({\"current_bytes\": current, \"peak_bytes\": peak})\nfor stat in after.compare_to(before, \"lineno\")[:5]:\n print(stat)", "lang": "python", "filename": "memory_probe.py", "expected": "during 比 before 多出约 10 MiB 的受控分配,释放并回收后对应 Python 分配下降;实际快照数值以本次运行记录为准。", "explanation": "tracemalloc 跟踪 Python 管理的分配位置,释放对象后进程 RSS 不一定立刻同比下降,解释器或底层分配器可能保留内存页。" }, { "title": "关联OOM事件与进程退出", "body": "在cgroup v2容器内部检查memory.current、memory.max和memory.events;不同部署挂载方式可能不同,先确认cgroup路径。ExitCode=137也可能是人为SIGKILL,需结合OOMKilled和oom_kill计数,不单凭137下结论。", "code": "# 在已确认使用cgroup v2的隔离容器内:\ncat /sys/fs/cgroup/memory.current\ncat /sys/fs/cgroup/memory.max\ncat /sys/fs/cgroup/memory.events\n# 只读取;不写drop_caches,不修改宿主机内存限制。", "lang": "bash", "expected": "读取到实际 cgroup memory.current、memory.max 与 oom_kill 计数,和退出时间/OOMKilled 一起保存。", "explanation": "137 也可能由人工 SIGKILL 造成;限制、计数增量和 Docker 标记共同支持内存超限判断,宿主有余量不代表容器没触顶。" }, { "title": "设计增长复现与修复对照", "body": "对同样的有限请求批次做多轮快照,观察相同分配位置是否持续增加。若tracemalloc稳定但RSS增长,进一步查原生库、连接缓冲和子进程;不要直接判为Python对象泄漏。修复后比较稳态与峰值。", "code": "{\n \"sample_type\": \"planned_memory_observation\",\n \"round\": null,\n \"request_count\": null,\n \"rss_bytes\": null,\n \"tracemalloc_current_bytes\": null,\n \"tracemalloc_peak_bytes\": null,\n \"cgroup_oom_kill_delta\": null,\n \"result\": \"not_run\"\n}", "lang": "json", "filename": "memory-record.json", "expected": "多轮同样请求批次有可比较的 RSS、Python 当前/峰值与 OOM 计数,未采集项保留空而非补预期值。", "explanation": "持续增长位置支持进一步查引用持有;若 Python 分配稳定而 RSS 上涨,应扩大到原生库、缓冲或子进程,不先断言 Python 泄漏。" } ], "checks": [ "实验分配约10MiB,释放后可追踪活跃分配明显下降。", "RSS、tracemalloc与容器限制分别记录,未混用单位。", "OOM结论有事件或状态证据,非仅依据退出码。" ], "pitfalls": [ { "symptom": "删除对象后RSS没降就认定泄漏", "cause": "分配器可能保留内存供复用,RSS不等于活对象。", "fix": "比较多轮稳态和tracemalloc增长来源,再判断是否持续无界增长。" }, { "symptom": "容器反复退出但主机free正常", "cause": "容器自身内存上限被触发。", "fix": "检查memory.max和memory.events,结合OOMKilled与具体负载调整。" } ], "exercise": { "task": "如果tracemalloc当前值稳定、RSS持续增长,应先收集哪两类信息?", "hint": "考虑不被tracemalloc完整覆盖的分配与进程范围。", "answer": "检查原生库/网络缓冲及映射,核对是否包含子进程或容器总内存;同时记录同一负载下的增长曲线,不能直接用gc.collect当修复。" }, "refs": [ { "label": "Python tracemalloc", "url": "https://docs.python.org/3.11/library/tracemalloc.html" }, { "label": "Linux cgroup v2", "url": "https://www.kernel.org/doc/html/latest/admin-guide/cgroup-v2.html" }, { "label": "Docker内存限制", "url": "https://docs.docker.com/engine/containers/resource_constraints/" } ], "production": { "scenario": "导出运行一段时间后容器重启,需要确认是内存泄漏、峰值超限还是其他终止。", "decisions": [ "对齐进程、容器和主机三种口径。", "诊断实验有严格内存上限,禁止耗尽宿主资源。", "修复依据增长来源,内存上限调整不能替代无界队列修复。" ], "failureDrill": "仅运行本课10MiB有限分配脚本,对比快照与RSS;OOM部分使用已有隔离演练证据或标not_run。", "acceptance": [ "没有人为触发宿主OOM或清理缓存干扰现场。", "每个内存指标注明单位和范围。", "结论区分可追踪Python分配与原生/容器内存。" ] }, "intro": { "scenario": "商品服务运行一段时间内存变高,甚至被容器终止;先辨清看的是什么数字,再判断是否泄漏。", "startingPoint": [ "完成第 05 课,能核对实际 API_PID;容器路线先在正确 Compose 目录定位唯一 api 容器。", "只做有限 10 MiB Python 分配与只读观测,不修改宿主内存限制或清理系统缓存。" ], "outcome": "比较 Python 分配、进程内存与容器限制,结合退出证据判断下一步排查方向。" } }, { "id": "07", "group": "进程、内存与依赖", "title": "分开排查 DNS、TCP、TLS 与连接池等待", "summary": "用同一组本地请求分别观察等待连接名额、连接失败和读取超时,并恢复基线。", "objectives": [ "从实际调用位置核对名字与端口", "一次只改一个超时配置观察异常类别", "用阶段耗时定位 TLS 与网络问题" ], "concepts": [ { "title": "不同阶段失败需要不同证据", "body": "回到“区分池等待与连接失败”采集的证据再看。DNS失败还没有获得目标地址,TCP连接拒绝通常没有进入HTTP,TLS错误发生在加密连接建立阶段,HTTP504可能来自网关或应用。不能把所有错误都叫网络慢。" }, { "title": "localhost属于当前网络命名空间", "body": "回到“核对解析、监听与目标端口”采集的证据再看。宿主、容器和WSL中的127.0.0.1可能指向不同位置。任务broker宿主映射6384,容器内通常通过broker:6379;缓存宿主6380,不能混用端口。" }, { "title": "连接池耗尽也表现为等待", "body": "回到“限制连接数并分开阶段超时”采集的证据再看。HTTPX PoolTimeout是等待池连接超时,不表示目标服务器连接失败。限制max_connections,并复用客户端,避免每请求建立独立连接池。" } ], "steps": [ { "title": "核对解析、监听与目标端口", "body": "命令在发生问题的同一容器或WSL环境执行。getent使用实际依赖主机名;ss只看必要监听与汇总信息。不要打印包含密码的完整DATABASE_URL或Celery URL。", "code": "getent hosts broker\nss -lnt\nss -s\n# 在宿主检查本地映射;不带任何凭据:\ncurl -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", "lang": "bash", "expected": "在正确容器网络能解析 broker,监听与端口符合实际部署;宿主探针记录连接时间与总耗时。", "explanation": "相同名字在不同网络环境可能无法解析,每个环境的 localhost 也不同;必须在失败调用发生的位置测量。" }, { "title": "先准备一个有界的本地慢响应", "body": "仅在隔离副本 app/main.py 的现有 app 定义之后加入下方临时路由。默认返回 404;按当前部署配置把 LAB_ENABLE_FAULTS=1 只给此练习进程并重启。先运行 curl --max-time 3 \"http://127.0.0.1:8000/slow?delay=1\";若已有同路径路由,先核对并复用等价的有界异步等待,不重复注册。", "code": "import asyncio\nimport os\nfrom fastapi import HTTPException, Query\n\n@app.get(\"/slow\", include_in_schema=False)\nasync def slow_probe(delay: float = Query(default=1, ge=0.1, le=1)):\n if os.getenv(\"LAB_ENABLE_FAULTS\") != \"1\":\n raise HTTPException(status_code=404)\n await asyncio.sleep(delay)\n return {\"delay\": delay}\n", "lang": "python", "filename": "app/main.py", "expected": "开关未启用时是 404;仅隔离进程开启后,delay=1 约一秒返回 200 和 {\"delay\":1},超出允许范围得到 422。", "explanation": "这里先有可重复、时间有界的慢响应,下一步才可能让连接持续占用到池等待超时。await sleep 不阻塞事件循环,因此能同时容纳两个在途测试请求。" }, { "title": "限制连接数并分开阶段超时", "body": "在隔离 shop-service 根目录新建 http_budget.py,使用同一虚拟环境运行 python http_budget.py。先确认上一阶段 /slow?delay=1 成功,脚本仅向本机发五个请求。正式应用复用客户端时应放入已有 lifespan 并在退出关闭,本课先独立运行观察异常类型。", "code": "import asyncio\nimport httpx\n\nasync def main():\n limits = httpx.Limits(max_connections=2, max_keepalive_connections=2)\n timeout = httpx.Timeout(connect=1, read=3, write=3, pool=0.1)\n async with httpx.AsyncClient(\n base_url=\"http://127.0.0.1:8000\", limits=limits, timeout=timeout\n ) as client:\n async def one():\n try:\n response = await client.get(\"/slow\", params={\"delay\": 1})\n return response.status_code\n except httpx.HTTPError as exc:\n return type(exc).__name__\n print(await asyncio.gather(*(one() for _ in range(5))))\n\nif __name__ == \"__main__\":\n asyncio.run(main())", "lang": "python", "filename": "http_budget.py", "expected": "五个并发请求中只有最多两个占用连接,等待超过 pool=0.1 的请求可返回 PoolTimeout,已取得连接的请求返回实际状态。", "explanation": "连接池给连接数量设上限,排队也有独立超时;请求可能尚未连接任何远端就因拿不到名额失败。" }, { "title": "区分池等待与连接失败", "body": "先运行上一步,超过两个同时请求时可能出现PoolTimeout。再把独立脚本base_url改成本地确认未监听的端口,观察ConnectError;恢复后把read改0.1秒观察ReadTimeout。一次只改一项,记录实际异常类型。", "code": "# 演练记录字段:\n# phase: pool / connect / read\n# configuration_changed:\n# exception_type_observed:\n# elapsed_seconds:\n# restored_to_baseline: false\n# 上例是记录模板,不是已观察到的结果。", "lang": "text", "filename": "network-record.md", "expected": "分别记录 PoolTimeout、未监听端口的 ConnectError,以及过短读取预算产生的 ReadTimeout;每次实验后还原参数。", "explanation": "不同异常指向不同等待阶段。把所有超时统一加长可能只增加排队,无法修复错误地址或下游响应慢。" }, { "title": "只读取必要网络证据", "body": "若必须定位TLS,用无凭据的测试请求核对证书主机名与链路;不要以verify=False作为修复。抓包只在隔离且无敏感流量的环境按最小范围使用,本模块默认以阶段耗时和连接统计定位,不导出完整HTTP载荷。 完成本课后移除临时 /slow 路由、关闭 LAB_ENABLE_FAULTS 并重启隔离 API,再确认 /live 恢复基线。", "code": "# curl阶段耗时,可用于已确认的无凭据测试URL:\n# -w 'dns=%{time_namelookup} connect=%{time_connect} tls=%{time_appconnect} first_byte=%{time_starttransfer} total=%{time_total}'\n# 这些是从开始累计的时间,不是可以直接相加的独立阶段耗时。", "lang": "text", "filename": "network-observations.md", "expected": "受控无凭据请求保留 DNS/connect/TLS/首字节时间及真实证书校验结果,不关闭验证。", "explanation": "curl 时间从开始累计,阶段耗时通常需作差,不能直接相加;TLS 主机名/链错误与 TCP 连接成功可以同时存在。" } ], "checks": [ "错误能定位到解析、连接、读响应或池等待阶段。", "HTTPX客户端有连接上限且在生命周期结束时关闭。", "宿主与容器端口映射清楚,日志没有输出带密码的URL。" ], "pitfalls": [ { "symptom": "容器用127.0.0.1:6384连接broker失败", "cause": "6384是宿主映射,容器localhost不是broker服务。", "fix": "在同网络容器使用broker:6379,在宿主使用127.0.0.1:6384。" }, { "symptom": "PoolTimeout后不断扩容下游", "cause": "真正等待的是本进程连接池,未确认下游是否瓶颈。", "fix": "检查连接泄漏、流响应是否关闭、并发需求与池预算,再逐项调整。" } ], "exercise": { "task": "在两个连接额度下发五个耗时1秒请求,解释为什么pool=0.1可能先失败。", "hint": "池等待发生在获得连接前,与read=3不是同一个计时阶段。", "answer": "前两个请求占用连接,后续请求等待超过0.1秒可触发PoolTimeout,不能因此认定下游超过3秒未响应。" }, "refs": [ { "label": "HTTPX阶段超时", "url": "https://www.python-httpx.org/advanced/timeouts/" }, { "label": "HTTPX连接限制", "url": "https://www.python-httpx.org/advanced/resource-limits/" }, { "label": "curl时间变量", "url": "https://curl.se/docs/manpage.html" } ], "production": { "scenario": "导出调用存储接口时偶发超时,扩容后错误反而增加。", "decisions": [ "先区分命名空间、网络阶段和连接池等待。", "连接预算与worker并发一起规划。", "TLS校验和凭据保密不是排障时可以随意关闭的步骤。" ], "failureDrill": "仅对本地/slow控制池上限与read超时,分别观察PoolTimeout与ReadTimeout并恢复配置。", "acceptance": [ "每种异常有对应证据。", "客户端连接和流资源会关闭。", "未通过泄露凭据抓包或禁用证书校验得出结论。" ] }, "intro": { "scenario": "商品服务调用依赖时只显示超时,可能是名字找不到、连接建不起来,也可能根本还没轮到连接。", "startingPoint": [ "完成第 05—06 课,隔离 API 的 /live 正常,可在同一执行环境运行 HTTPX。", "本课先增加默认关闭的有界 /slow 练习端点,再只请求本机地址;容器服务名检测必须在对应网络内执行。" ], "outcome": "用同一组本地请求分别观察等待连接名额、连接失败和读取超时,并恢复基线。" } }, { "id": "08", "group": "进程、内存与依赖", "title": "定位慢 SQL、锁等待与连接池耗尽", "summary": "复现一次有界行锁等待,读取查询计划与应用池状态,再确认回滚后业务恢复。", "objectives": [ "用两会话定位谁在等谁", "分别查看执行计划和池占用", "恢复后确认事务、等待与商品请求都正常" ], "concepts": [ { "title": "数据库耗时包含多个阶段", "body": "回到“观察应用连接池预算”采集的证据再看。应用先等连接池,再发送SQL,数据库还可能等锁或I/O。只看到接口慢,不足以判断需要加索引。" }, { "title": "连接空闲也可能持有事务", "body": "回到“恢复后核对锁和业务请求”采集的证据再看。SHOW PROCESSLIST中的Sleep表示当前没有执行语句,不能证明事务已结束。INFORMATION_SCHEMA.INNODB_TRX关联MySQL连接ID;performance_schema.data_lock_waits与data_locks关联InnoDB行锁,metadata_locks用于表结构等元数据锁。" }, { "title": "超时作用范围不同", "body": "回到“在两会话复现有界锁等待”采集的证据再看。innodb_lock_wait_timeout约束InnoDB行锁等待,不是所有锁或所有SQL的总超时。max_execution_time主要约束适用的只读SELECT。EXPLAIN ANALYZE会实际执行查询,只在受控小数据上使用,不把诊断当成零成本。" } ], "steps": [ { "title": "读取活动会话和等待信息", "body": "在MySQL8.4练习实例用经授权的诊断账户执行,宿主3307。交互客户端使用-p提示输入密码或权限受控的选项文件。跨账户会话/Performance Schema可见性取决于权限,权限不足不等于没有阻塞;不要为排障把管理员权限授给API。保存连接ID、事务开始时间和等待关系,SQL正文分享前脱敏。", "code": "SELECT CONNECTION_ID(),DATABASE(),@@transaction_isolation,@@session.time_zone;\nSHOW FULL PROCESSLIST;\nSELECT trx_id,trx_state,trx_started,trx_mysql_thread_id,trx_rows_locked\nFROM information_schema.innodb_trx ORDER BY trx_started;\nSELECT w.REQUESTING_ENGINE_TRANSACTION_ID AS waiting_trx,\n w.BLOCKING_ENGINE_TRANSACTION_ID AS blocking_trx,\n rt.PROCESSLIST_ID AS waiting_connection,\n bt.PROCESSLIST_ID AS blocking_connection\nFROM performance_schema.data_lock_waits w\nLEFT JOIN performance_schema.threads rt ON rt.THREAD_ID=w.REQUESTING_THREAD_ID\nLEFT JOIN performance_schema.threads bt ON bt.THREAD_ID=w.BLOCKING_THREAD_ID;", "lang": "sql", "expected": "能列出自己的连接、活动事务与锁等待;权限不足时记录可见性限制,不把空结果视为完全无阻塞。", "explanation": "诊断视角受账号权限影响。事务开始时间、持有连接与等待关系比单看 SQL 报错更能定位阻塞来源。" }, { "title": "在两会话复现有界锁等待", "body": "选本机专用商品ID,会话A锁定一行,会话B设置1秒InnoDB锁等待预算再请求同一行。第三个诊断会话可先将B预算改为5秒观察等待关系,之后恢复1秒。默认锁等待超时通常仅回滚失败语句,不能当作事务已经释放;实验结束A/B均显式ROLLBACK。死锁可能回滚整笔事务,重试仍以完整业务事务为边界。", "code": "-- 会话A:替换为专用商品ID\nSTART TRANSACTION;\nSELECT id FROM products WHERE id=1 FOR UPDATE;\n-- 暂不提交;完成B的观察后执行ROLLBACK。\n\n-- 会话B\nSET SESSION innodb_lock_wait_timeout=1;\nSTART TRANSACTION;\nSELECT id FROM products WHERE id=1 FOR UPDATE;\n-- 出现1205后执行:\nROLLBACK;\n-- 最后回到A执行ROLLBACK,释放行锁。", "lang": "sql", "expected": "会话 B 等待约 1 秒后出现锁等待超时;A 回滚释放锁,实验结束 A/B 都显式 ROLLBACK。", "explanation": "FOR UPDATE 把同一行更新资格留在事务中。锁等待超时通常只回滚失败语句,不能推断整个事务已结束。" }, { "title": "先看计划,再在隔离数据上测执行", "body": "先看非执行计划。需要EXPLAIN ANALYZE时,只在隔离小表执行只读SELECT并设置会话max_execution_time;该变量不是任意写SQL的硬超时。记录rows、实际迭代次数、索引及访问路径,不能仅凭使用索引就断定足够快。", "code": "EXPLAIN FORMAT=TREE SELECT id,name,price_cents FROM products WHERE id=1;\n-- 仅隔离练习,客户端设置语句预算:\nSET SESSION max_execution_time=2000;\nEXPLAIN ANALYZE SELECT id,name,price_cents FROM products WHERE id=1;\nSET SESSION max_execution_time=0;", "lang": "sql", "expected": "普通 EXPLAIN 返回访问计划,隔离 EXPLAIN ANALYZE 返回实际迭代与耗时,记录索引及行数。", "explanation": "计划描述打算怎样访问,ANALYZE 还会真实执行查询;即便使用索引,返回量或循环次数过大仍可能慢。" }, { "title": "观察应用连接池预算", "body": "API复用SQLAlchemy AsyncAdaptedQueuePool,每个进程各自拥有池。以下函数只记录数字,不输出连接串;由已有监控采样逻辑调用。池等待需在实际获取连接周围单独计时,checkedout只能反映当前占用。同步任务模块按短事务打开PyMySQL连接,不虚构存在一个任务连接池。", "code": "from app.db import engine\n\ndef pool_snapshot():\n pool = engine.sync_engine.pool\n return {\n \"size\": pool.size(),\n \"checked_out\": pool.checkedout(),\n \"overflow\": pool.overflow(),\n }\n\n# 由每个API进程的现有监控采样逻辑调用;不要另启动新进程观察一个空池。\n# 总预算 = API实例 × worker × (pool_size + max_overflow)\n# + Celery执行进程 + publisher/recovery + 诊断/备份保留连接。", "lang": "python", "filename": "pool_observation.py", "expected": "池快照给出 size、checked_out、overflow 数字,连接获取等待另在实际获取边界计时。", "explanation": "池占用告诉你连接名额被使用多少,却不等于某条 SQL 执行多久;每个 API 进程都有独立池预算。" }, { "title": "恢复后核对锁和业务请求", "body": "A/B回滚后确认对应连接没有活跃InnoDB事务;再查询行锁等待与未授予元数据锁。Sleep本身不是错误,Sleep且有长事务才需要定位持有者。未经确认不终止他人连接;恢复门槛同时包含业务请求成功和等待消失。", "code": "SELECT t.trx_mysql_thread_id AS connection_id,t.trx_state,t.trx_started,\n p.COMMAND,p.TIME,p.STATE\nFROM information_schema.innodb_trx t\nLEFT JOIN information_schema.PROCESSLIST p ON p.ID=t.trx_mysql_thread_id;\nSELECT OBJECT_SCHEMA,OBJECT_NAME,LOCK_TYPE,LOCK_DURATION,OWNER_THREAD_ID\nFROM performance_schema.metadata_locks\nWHERE OBJECT_SCHEMA='shop_lab' AND LOCK_STATUS='PENDING';", "lang": "sql", "expected": "演练连接不再持有活跃事务,相关行锁/元数据等待消失,商品读取与修改探针恢复。", "explanation": "Sleep 只描述连接当前未运行命令,仍可能持有事务;恢复证据应同时包括等待消除与业务成功。" } ], "checks": [ "会话B在受控时间内超时,能关联会话A为阻塞者。", "回滚后锁释放,读取恢复。", "应用池等待与数据库锁等待分别记录,没有盲目增大连接数。" ], "pitfalls": [ { "symptom": "API慢但MySQL中没有相应执行语句", "cause": "请求可能仍在应用连接池等待,尚未向数据库发送SQL。", "fix": "观察同一API进程的池等待与借还连接,核对总连接预算和持有事务的上游调用。" }, { "symptom": "锁等待超时后其他连接仍被阻塞", "cause": "MySQL默认通常只回滚超时语句,原事务和先前获得的锁可能仍存在。", "fix": "应用异常路径显式回滚完整业务事务,再以幂等和总预算决定是否重试;检查innodb_rollback_on_timeout实际设置。" } ], "exercise": { "task": "说明为什么一个API进程池max_size=10,扩成4个进程后数据库连接可能接近40。", "hint": "进程之间通常不共享同一个连接池对象。", "answer": "每个进程拥有自己的上限,总预算需加上Celery、publisher、recovery和管理连接;扩worker前先核对数据库容量。" }, "refs": [ { "label": "MySQL8.4:InnoDB事务监控", "url": "https://dev.mysql.com/doc/refman/8.4/en/innodb-information-schema-transactions.html" }, { "label": "MySQL8.4:锁等待关系", "url": "https://dev.mysql.com/doc/refman/8.4/en/performance-schema-data-lock-waits-table.html" }, { "label": "MySQL8.4:EXPLAIN", "url": "https://dev.mysql.com/doc/refman/8.4/en/explain.html" }, { "label": "SQLAlchemy连接池", "url": "https://docs.sqlalchemy.org/en/20/core/pooling.html" } ], "production": { "scenario": "发布后商品读取突然变慢,数据库CPU却不高。", "decisions": [ "先查池等待和锁关系,再评估SQL计划。", "诊断操作设超时,锁实验限定专门商品和可回滚事务。", "连接上限按全进程总量规划。" ], "failureDrill": "使用两条本地数据库连接持锁与超时,再回滚并确认恢复,不终止其他会话。", "acceptance": [ "等待关系可解释。", "所有演练事务最终回滚。", "没有将EXPLAIN ANALYZE描述为纯静态分析。" ] }, "intro": { "scenario": "商品更新卡住了,可能是 SQL 本身慢,也可能在等另一笔事务;不要先给所有连接加大超时。", "startingPoint": [ "完成第 07 课,能区分建立连接前的等待与响应读取等待。", "准备 MySQL 8.4 独立练习库、已存在的专用商品 ID,以及有观测权限的诊断账号;不使用生产商品或他人连接。" ], "outcome": "复现一次有界行锁等待,读取查询计划与应用池状态,再确认回滚后业务恢复。" } }, { "id": "09", "group": "队列、实时通信与恢复", "title": "排查 Redis 热点、淘汰与缓存降级", "summary": "在有限读取下观察缓存失效的数据库增量,并验证回源超时能明确返回而不无限排队。", "objectives": [ "只读核对缓存容量策略与延迟", "为回源设置并发和时间上限", "对比故障前后命中、数据库请求与错误量" ], "concepts": [ { "title": "缓存与broker不能用同一恢复策略", "body": "回到“安全连接指定缓存实例”采集的证据再看。缓存可按业务降级回源,broker承载待交付消息,不能通过清空键空间恢复。模块统一缓存宿主6380、独立任务broker6384;先核对用途再操作。" }, { "title": "累计指标需要按窗口取增量", "body": "回到“以增量比较缓存故障前后”采集的证据再看。keyspace_hits和keyspace_misses是累计计数,计算窗口命中率要使用增量。evicted_keys增加表示内存策略淘汰,expired_keys增加表示TTL到期,两者原因不同。" }, { "title": "回源必须有容量上限", "body": "回到“给缓存访问配置受限回源”采集的证据再看。缓存不可用后所有请求直接打数据库可能造成级联故障。设置缓存短超时、回源并发限制和必要的过载拒绝;多进程总量不能只按单进程Semaphore理解。" } ], "steps": [ { "title": "安全连接指定缓存实例", "body": "在Linux/WSL2终端通过read隐藏输入本地缓存密码,不把密码写进-a参数。以下只读命令指向6380;broker6384需要其独立密码,不能复用缓存故障动作。", "code": "read -rsp 'Local cache password: ' REDISCLI_AUTH\nexport REDISCLI_AUTH\nredis-cli -h 127.0.0.1 -p 6380 PING\nredis-cli -h 127.0.0.1 -p 6380 INFO stats\nredis-cli -h 127.0.0.1 -p 6380 INFO memory\nredis-cli -h 127.0.0.1 -p 6380 INFO clients", "lang": "bash", "expected": "缓存 6380 返回 PONG,INFO 可读出命中、未命中、淘汰和内存累计值,未触碰 6384 broker。", "explanation": "缓存与任务交付实例职责不同;缓存清空可能仅影响性能,队列清空会破坏待执行工作,必须先核对目标。" }, { "title": "检查策略与延迟来源", "body": "CONFIG GET是只读但可能被ACL禁止;权限不足需要按观测权限配置处理,不绕过认证。SLOWLOG可能包含命令参数,先查长度;只有无敏感数据的隔离实例才查看具体条目。不要运行KEYS *或MONITOR扫描实际业务内容。", "code": "redis-cli -h 127.0.0.1 -p 6380 CONFIG GET maxmemory maxmemory-policy\nredis-cli -h 127.0.0.1 -p 6380 LATENCY LATEST\nredis-cli -h 127.0.0.1 -p 6380 SLOWLOG LEN\n# 在无敏感数据的隔离实例才进一步检查SLOWLOG内容。\nunset REDISCLI_AUTH", "lang": "bash", "expected": "记录 maxmemory/policy、延迟事件和慢日志长度;ACL 拒绝时记录权限限制,不尝试绕过。", "explanation": "淘汰策略决定内存满时移除什么,慢日志只覆盖某些执行慢命令;客户端等待还可能发生在网络或连接阶段。" }, { "title": "给缓存访问配置受限回源", "body": "下面函数使用已有redis异步客户端和read_db异步函数,客户端需设置socket_connect_timeout/socket_timeout,例如各0.2秒。缺失或连接失败时回源,缓存写失败不改变已成功读取的数据。此示例只用于单进程容量实验;真实权限必须进入缓存key或查询范围。", "code": "import asyncio\nimport json\nfrom redis.exceptions import ConnectionError as RedisConnectionError\nfrom redis.exceptions import TimeoutError as RedisTimeoutError\n\ndb_slots = asyncio.Semaphore(4)\n\nasync def cached_product(product_id, cache, read_db):\n key = f\"shop:lab:products:{product_id}\"\n try:\n raw = await cache.get(key)\n if raw is not None:\n return json.loads(raw)\n except (RedisConnectionError, RedisTimeoutError, json.JSONDecodeError):\n pass\n async with asyncio.timeout(1):\n async with db_slots:\n row = await read_db(product_id)\n if row is not None:\n try:\n await cache.set(key, json.dumps(row), ex=30)\n except (RedisConnectionError, RedisTimeoutError):\n pass\n return row", "lang": "python", "filename": "cache_fallback.py", "expected": "缓存命中直接返回;缺失或缓存异常时最多 4 个本进程回源并发,获取名额与读库合计受 1 秒预算控制。", "explanation": "缓存作为可选加速层,故障可回源但不能无限挤占数据库;Semaphore 只约束当前进程,多个进程需重新核算总量。" }, { "title": "以增量比较缓存故障前后", "body": "仅停止本地缓存实例,保持broker和数据库运行,低并发读取相同商品。记录缓存异常、数据库请求数、池等待和HTTP状态;恢复缓存后观察命中回升。回源超时应由API转换为明确503,不返回虚构商品或无限等待。", "code": "{\n \"sample_type\": \"cache_drill_template\",\n \"window_seconds\": null,\n \"hits_delta\": null,\n \"misses_delta\": null,\n \"evicted_keys_delta\": null,\n \"db_requests_delta\": null,\n \"http_503_count\": null,\n \"result\": \"not_run\"\n}", "lang": "json", "filename": "cache-drill.json", "expected": "停缓存后 miss/异常与数据库请求增量上升;容量不足时明确 503,恢复后命中回升,任务 broker 持续工作。", "explanation": "INFO 多为累计值,应比较同长度窗口增量。允许部分请求快速失败可保护数据库,而虚构商品或无限等都掩盖真实状态。" } ], "checks": [ "实例地址与用途明确,6384broker未被缓存故障动作影响。", "窗口命中率使用计数增量,能区分TTL到期与内存淘汰。", "缓存故障时回源有时间与并发预算,过载明确失败。" ], "pitfalls": [ { "symptom": "缓存故障后数据库也被打满", "cause": "无上限回源造成级联放大。", "fix": "先限制回源并发和请求速率,必要时拒绝非关键读取。" }, { "symptom": "对broker执行缓存清理", "cause": "混淆实例用途,可能删除未处理消息。", "fix": "独立实例与凭据,先核对地址和角色,不以flushdb/purge作为排障默认动作。" } ], "exercise": { "task": "命中100、未命中20增至命中140、未命中30,计算该窗口命中率。", "hint": "使用变化量40和10,不使用启动以来累计值。", "answer": "窗口命中率=40/(40+10)=80%;同时记录窗口请求量50,不能把低样本波动误判为长期趋势。" }, "refs": [ { "label": "Redis INFO", "url": "https://redis.io/docs/latest/commands/info/" }, { "label": "Redis淘汰策略", "url": "https://redis.io/docs/latest/develop/reference/eviction/" }, { "label": "Redis延迟排查", "url": "https://redis.io/docs/latest/operate/oss_and_stack/management/optimization/latency/" } ], "production": { "scenario": "热门商品读取突然变慢,缓存命中下降且数据库连接开始排队。", "decisions": [ "先确认是过期、淘汰、热点还是Redis不可用。", "缓存降级需要保护数据库容量。", "任务broker与缓存使用独立实例和恢复手册。" ], "failureDrill": "只暂停本地缓存6380,以有限并发请求观察回源,再恢复并核对命中和数据库负载。", "acceptance": [ "没有清空broker或扫描敏感键值。", "降级不突破数据库预算。", "缓存恢复后业务结果仍与数据库一致。" ] }, "intro": { "scenario": "缓存停止后商品仍可能从数据库读到,但大量请求一起回源会把数据库拖慢。", "startingPoint": [ "完成 Redis 缓存课程和本模块第 08 课,缓存6380与任务 broker6384已分开。", "准备同一组练习商品与正常命中基线,只使用缓存实例的独立密码,不对任务 broker 执行缓存故障操作。" ], "outcome": "在有限读取下观察缓存失效的数据库增量,并验证回源超时能明确返回而不无限排队。" } }, { "id": "10", "group": "队列、实时通信与恢复", "title": "定位 Celery 积压、重试风暴与租约恢复", "summary": "沿一条持久任务定位积压阶段,按有限恢复机制推进,并观察到任务与文件真正完成。", "objectives": [ "并排核对派发记录、队列和租约", "区分消息重复到达与真正再次执行", "修复原因后受控恢复并持续检查" ], "concepts": [ { "title": "积压可能发生在broker之前", "body": "回到“把数据库阶段并排查询”采集的证据再看。export_outbox未发布也属于用户等待;只看Celery active会漏掉publisher故障。queued任务即使暂时没有pending outbox,也可能已在broker或存在消息丢失窗口,需要派发记录和恢复规则判断。" }, { "title": "重试与重投有不同来源", "body": "回到“按一条任务核对重试链”采集的证据再看。任务主动retry可能带退避;broker重复交付可能携带旧消息。持久attempts、next_attempt_at、deadline和领取CAS必须一起限制,不能只看Celery request.retries。" }, { "title": "恢复先处理原因,再处理队列", "body": "回到“恢复一个有证据的本地故障”采集的证据再看。下游仍不可用时增加worker或批量重放会放大负载。先停止错误输入或恢复依赖,再按有限批次处理到期任务;不要默认purge清空消息或强杀所有worker。" } ], "steps": [ { "title": "读取worker与路由状态", "body": "使用部署时相同的配置来源。以下 Bash 函数用于 Linux/systemd 路线,由 systemd 读取 /etc/shop-tasks.env;若采用 Docker,在 Compose 工程目录用注释中的函数替换 run_task 定义,再执行相同检查。保持此终端供本课恢复步骤复用。inspect 可能包含任务参数,分享前核对输出。", "code": "# Linux/systemd 路线,在管理员的 Bash 终端定义:\nrun_task() {\n sudo systemd-run --quiet --wait --collect --pipe \\\n --property=User=shop --property=Group=shop \\\n --property=WorkingDirectory=/srv/shop-service/current/tasks_lab \\\n --property=EnvironmentFile=/etc/shop-tasks.env \\\n /srv/shop-service/current/.venv/bin/python \"$@\"\n}\n# Docker 路线:在 Compose 工程目录,用以下定义替换上面的函数:\n# run_task() {\n# docker compose --env-file .env.compose run --rm -T --no-deps worker \\\n# python /app/deploy/run_role.py python \"$@\"\n# }\nrun_task -m celery -A celery_app:app status\nrun_task -m celery -A celery_app:app inspect active\nrun_task -m celery -A celery_app:app inspect reserved\nrun_task -m celery -A celery_app:app inspect scheduled\nrun_task -m celery -A celery_app:app inspect active_queues", "lang": "bash", "expected": "inspect 的注册任务、活动队列与部署设置一致,能分别定位 fast/bulk 和派发/恢复角色。", "explanation": "Worker 在线只说明消费者可响应检查,不证明消息已发出或队列路由正确;必须沿实际配置来源检查。" }, { "title": "把数据库阶段并排查询", "body": "使用SQL同时看业务状态、待发布事件和过期租约。queued很老只是排查线索,不足以直接断言消息已经丢失;按异步任务模块的派发记录与恢复门槛处理。", "code": "SELECT status,count(*) AS total,\n TIMESTAMPDIFF(SECOND,MIN(created_at),UTC_TIMESTAMP(6)) AS oldest_seconds\nFROM export_jobs GROUP BY status;\n\nSELECT queue_name,count(*) AS pending,\n TIMESTAMPDIFF(SECOND,MIN(created_at),UTC_TIMESTAMP(6)) AS oldest_seconds\nFROM export_outbox WHERE sent_at IS NULL GROUP BY queue_name;\n\nSELECT id,attempts,lease_until,error_code\nFROM export_jobs WHERE status='running' AND lease_until\n# 不运行 celery purge,不通过重置 attempts 解除失败限制。", "lang": "bash", "expected": "有限 recovery/publisher 正常结束,符合条件的任务继续推进;耗尽失败任务只有确认修复后才逐条生成新 job。", "explanation": "恢复先消除下游故障,再受容量和次数限制推进。清空队列或重置 attempts 会丢失证据并绕过保护。" }, { "title": "验证恢复不是短暂下降", "body": "恢复后持续观察最老等待、完成速率、重试次数和下游错误,覆盖至少一个任务截止时间与恢复扫描周期。增加worker后若数据库池等待上升,应停止扩大并发并重新核算预算。", "code": "# 恢复验收记录:\n# 影响队列:\n# 下游修复证据:\n# 受控恢复任务ID:\n# 最老等待变化:\n# 重试/重投变化:\n# 新导出完成结果:\n# 未恢复任务及原因:\n# result: not_run", "lang": "text", "filename": "queue-recovery.md", "expected": "覆盖截止时间与扫描周期后,最老等待持续下降、完成速率恢复、重试未继续放大,新文件验收成功。", "explanation": "一次队列长度下降可能只是转入重试或正在执行,不能说明恢复稳定;需要持久状态、下游错误和产出一起看。" } ], "checks": [ "能判断积压发生在outbox、broker、worker还是业务失败隔离。", "重投和重试没有绕过持久尝试预算与退避时间。", "恢复有下游正常和最终结果证据,旧失败记录被保留。" ], "pitfalls": [ { "symptom": "worker在线但没有任务执行", "cause": "路由队列不匹配、publisher未发布或任务未注册。", "fix": "核对active_queues、registered和outbox,再查执行容量。" }, { "symptom": "重试风暴时继续增加worker", "cause": "下游不可用,更多worker只产生更多失败。", "fix": "先止住重复输入或恢复依赖,保留退避、总预算和入口容量。" } ], "exercise": { "task": "outbox数量上升、worker空闲、broker可连接时,优先查哪两个位置?", "hint": "消息可能没有被publisher成功发送,或发到了无人消费的队列。", "answer": "检查publisher错误/领取租约/标记过程,以及outbox queue_name与worker active_queues是否一致;不能直接判断worker算力不足。" }, "refs": [ { "label": "Celery监控", "url": "https://docs.celeryq.dev/en/stable/userguide/monitoring.html" }, { "label": "Celery优化", "url": "https://docs.celeryq.dev/en/stable/userguide/optimizing.html" }, { "label": "Celery Redis交付", "url": "https://docs.celeryq.dev/en/stable/getting-started/backends-and-brokers/redis.html" } ], "production": { "scenario": "任务队列积压,团队准备增加worker,但导出持续重试。", "decisions": [ "按阶段定位,数据库状态为业务真源。", "下游恢复前不通过并发扩容放大失败。", "恢复按持久租约、退避和次数预算执行,重放生成新业务ID。" ], "failureDrill": "仅暂停本地publisher后提交,再恢复;另让隔离worker一次失败,对照两种积压阶段和恢复路径。", "acceptance": [ "未清空消息掩盖故障。", "重复交付不能越过状态约束。", "恢复后新旧导出均有明确结果或失败去向。" ] }, "intro": { "scenario": "商品导出长时间排队,Worker 看似在线;先判断堵在派发前、等待消费,还是失败后等待恢复。", "startingPoint": [ "完成任务模块最终状态机与部署整合,至少保留一个真实 job_id 和正常导出结果。", "已完成本模块日志和数据库等待课程,选定 Linux/systemd 或 Docker 配置来源,所有恢复限定到隔离任务。" ], "outcome": "沿一条持久任务定位积压阶段,按有限恢复机制推进,并观察到任务与文件真正完成。" } }, { "id": "11", "group": "队列、实时通信与恢复", "title": "端到端排查 SSE 缓冲与 WebSocket 断开", "summary": "把延迟定位到应用、代理或浏览器一层,并验证主动停止后不再重连或占用服务资源。", "objectives": [ "测量第一条业务数据到达时间", "用相同请求比较直连和代理", "检查升级、消息、关闭与服务器清理" ], "concepts": [ { "title": "流式发送不保证每段立刻被用户看到", "body": "回到“保持请求不变对比代理”采集的证据再看。服务端生成、ASGI发送、代理转发、客户端读取和页面渲染都可能缓冲。先比较各段链路,再修改对应配置。" }, { "title": "SSE与WebSocket的代理要求不同", "body": "回到“只修改隔离代理中的对应location”采集的证据再看。SSE是持续HTTP响应,关注缓冲、压缩和读超时;WebSocket在HTTP/1.1握手时需要正确升级头,之后关注空闲超时与心跳。不能用普通GET成功证明WebSocket链路正常。" }, { "title": "断开与重连属于业务行为", "body": "回到“核对主动停止与真实清理”采集的证据再看。用户停止后应关闭连接和定时器,不自动重新生成同一内容。网络断开可有限退避重连,但不自动重发未经确认的业务消息。" } ], "steps": [ { "title": "直连API测首段到达", "body": "新建stream_probe.py,只发送hello测试文本。按实际到达data行记录时间,不能用curl time_starttransfer代替首个业务片段,因为响应头可能很早已发送。", "code": "from time import perf_counter\nimport httpx\n\ndef probe(base_url):\n started = perf_counter()\n with httpx.Client(base_url=base_url, timeout=5) as client:\n with client.stream(\"GET\", \"/stream\", params={\"prompt\": \"hello\"}) as response:\n print(\"status\", response.status_code, response.headers.get(\"content-type\"))\n response.raise_for_status()\n for line in response.iter_lines():\n if line.startswith(\"data:\"):\n print(round(perf_counter()-started, 3), line, flush=True)\n\nif __name__ == \"__main__\":\n probe(\"http://127.0.0.1:8000\")", "lang": "python", "filename": "stream_probe.py", "expected": "直连输出 HTTP 状态、text/event-stream 类型及每条 data 的实际到达秒数;404 时先回到端点整合检查。", "explanation": "HTTP 响应头到达可能早于第一条业务事件,首字节时间不能替代用户何时开始看到内容。" }, { "title": "保持请求不变对比代理", "body": "按Docker/Linux模块的实际代理端口修改base_url;下面8080仅表示本地演练映射。若直连逐段、代理一次到达,检查代理缓冲;若两者正常而浏览器延后,查response.text/json或渲染缓存。", "code": "curl -N --max-time 5 'http://127.0.0.1:8000/stream?prompt=hello'\ncurl -N --max-time 5 'http://127.0.0.1:8080/stream?prompt=hello'\n# Windows终端用curl.exe;代理端口以本地部署配置为准。", "lang": "bash", "expected": "同一请求保留直连与代理两组到达记录;只有代理批量到达时,问题范围才缩小到代理及其传输行为。", "explanation": "控制请求内容和客户端读取方式才能比较;如果两条路径都正常,浏览器一次性读完整响应也会产生假缓冲现象。" }, { "title": "只修改隔离代理中的对应location", "body": "以下片段放入已有nginx实验配置。示例代理与API同宿主;容器部署用实际API服务名替换127.0.0.1。先nginx -t通过,再按部署模块重载隔离代理。proxy_read_timeout是两次读取间隔限制,不是总请求时长。", "code": "# server内部的SSE location\nlocation /stream {\n proxy_pass http://127.0.0.1:8000;\n proxy_http_version 1.1;\n proxy_buffering off;\n proxy_read_timeout 65s;\n}\n\n# http级别:\n# map $http_upgrade $connection_upgrade {\n# default upgrade;\n# '' close;\n# }\n# server内部的WebSocket location:\n# location /ws {\n# proxy_pass http://127.0.0.1:8000;\n# proxy_http_version 1.1;\n# proxy_set_header Upgrade $http_upgrade;\n# proxy_set_header Connection $connection_upgrade;\n# proxy_read_timeout 65s;\n# }", "lang": "nginx", "filename": "nginx-lab.conf", "expected": "nginx -t 通过后重载,SSE location 关闭缓冲,WS location 带升级头,实际 upstream 对应当前部署位置。", "explanation": "SSE 继续使用 HTTP 响应,WS 需要协议升级;空闲读取预算与心跳应配合,不能用一个通用超时解释两类连接。" }, { "title": "在浏览器核对升级和关闭", "body": "在隔离页面Console连接已确认的代理WS地址,观察Network中的握手与Frames/Messages。HTTPS页面使用wss。收到open后发送hello和ping,再主动关闭;按API课程有限退避逻辑验证重连。", "code": "const socket = new WebSocket(\"ws://127.0.0.1:8080/ws\");\nsocket.onopen = () => {\n socket.send(\"hello\");\n socket.send(\"ping\");\n};\nsocket.onmessage = event => console.log(JSON.parse(event.data));\nsocket.onerror = () => console.log(\"socket error\");\nsocket.onclose = event => console.log(\"closed\", event.code, event.reason);\n// 收到消息后主动停止:socket.close(1000, \"drill complete\");", "lang": "javascript", "filename": "浏览器Console", "expected": "Network 显示 WS 握手 101,hello/ping 得到实际应用响应,主动关闭后能记录关闭码及原因。", "explanation": "握手成功只证明升级建立,之后消息协议、心跳与关闭都有独立规则;HTTPS 页面还必须使用安全连接地址。" }, { "title": "核对主动停止与真实清理", "body": "SSE收到done后关闭EventSource;fetch用AbortController停止读取,服务端取消后释放资源。WebSocket主动停止后等待超过最大重连间隔,确认无新连接。恢复所有临时代理配置,再跑一次完整SSE和echo。", "code": "# 记录实际观测,勿填写预计时间:\n# direct_first_data_seconds:\n# proxy_first_data_seconds:\n# browser_first_render_seconds:\n# websocket_handshake_result:\n# stop_followed_by_reconnect: 未测\n# server_cleanup_observed: 未测\n# result: not_run", "lang": "text", "filename": "stream-verification.md", "expected": "主动停止后超过最大重连间隔没有新连接,服务端释放相关资源;最终完整 SSE 与 echo 再次成功。", "explanation": "客户端不再显示消息不代表服务器工作已停止;取消与清理需要两端证据,重连策略也应区分主动停止和异常断线。" } ], "checks": [ "直连、代理、浏览器三段各有独立到达记录。", "WebSocket能完成握手与echo/pong,不仅是普通HTTP返回200。", "主动停止后没有意外重连或重复生成,服务端清理可观察。" ], "pitfalls": [ { "symptom": "终端逐段显示,网页一次显示全部", "cause": "前端调用了response.text/json或等待完整文本再渲染。", "fix": "使用流式读取和按协议边界解析,分段更新UI。" }, { "symptom": "WebSocket连接约固定时间断开", "cause": "代理空闲超时或心跳缺失可能触发,但需结合日志验证。", "fix": "核对代理读超时与实际消息间隔,采用有界应用心跳和停止规则。" } ], "exercise": { "task": "如果response headers立刻到达、三条data都在1.2秒同时出现,能断言服务器没有yield吗?", "hint": "中间层或客户端可能缓冲,先做直连对照。", "answer": "不能。比较直连data到达时间与代理结果,再检查生成器、代理缓冲和客户端读取;响应头时间不证明每段业务数据的传输情况。" }, "refs": [ { "label": "Nginx代理缓冲", "url": "https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_buffering" }, { "label": "Nginx WebSocket", "url": "https://nginx.org/en/docs/http/websocket.html" }, { "label": "HTTPX流读取", "url": "https://www.python-httpx.org/quickstart/" }, { "label": "MDN EventSource", "url": "https://developer.mozilla.org/en-US/docs/Web/API/EventSource" } ], "production": { "scenario": "本地API流式正常,部署后文本一次出现或WebSocket频繁断开。", "decisions": [ "按直连、代理、客户端逐段定位。", "配置变更限定相关location,测试语法后再重载隔离实例。", "重连不会自动重复不可逆业务消息。" ], "failureDrill": "只在隔离代理开启/关闭响应缓冲做对照,随后测试空闲与主动停止两类断开。", "acceptance": [ "到达时间来自真实业务片段。", "代理与浏览器行为分别验证。", "故障配置恢复,停止逻辑无残留连接。" ] }, "intro": { "scenario": "导出进度直连会逐条刷新,过代理却一次出现;WebSocket 也可能握手成功后反复断开。", "startingPoint": [ "完成 Linux 第 06 课或同等代理配置,隔离 API 已整合 /stream 和 /ws,直连客户端曾成功。", "核对真实代理端口和路由,示例 8080 不能代替实际地址;只发送 hello/ping,不使用真实凭据或商品内容。" ], "outcome": "把延迟定位到应用、代理或浏览器一层,并验证主动停止后不再重连或占用服务资源。" } }, { "id": "12", "group": "队列、实时通信与恢复", "title": "完成故障演练、恢复门槛与 runbook", "summary": "完成限定故障的时间线、恢复验证与证据索引,并产出可执行改进项。", "objectives": [ "为每次演练写开始与停止条件", "一次只注入一个故障并检查恢复", "把复盘发现变成可验证的修改工作" ], "concepts": [ { "title": "演练要有开始与结束条件", "body": "回到“定义停止与恢复门槛”采集的证据再看。每个故障限定实例、任务ID、持续时间和恢复方法。达到预定风险边界就停止,不能为了复现而无限增加负载。" }, { "title": "恢复门槛应覆盖业务与数据", "body": "回到“按时间线只执行一个故障”采集的证据再看。服务恢复包括新请求正常、积压受控、已受理任务去向清楚、无错误发布结果。重启成功或监控颜色变绿只是部分证据。" }, { "title": "复盘区分触发因素与系统缺口", "body": "回到“输出复盘行动项与证据索引”采集的证据再看。记录什么触发、为什么扩大、哪些控制有效、哪里缺少观测或恢复能力。行动项要有负责人、验收条件和优先级,不只写“加强监控”。" } ], "steps": [ { "title": "准备隔离演练清单", "body": "每个案例用全新测试数据,先保存无故障基线。下面矩阵是待执行计划,不代表已经发生事故或完成测试。禁止把生产凭据、真实商品描述放入公开材料。", "code": "| 案例 | 限定故障 | 主要证据 | 恢复动作 |\n|---|---|---|---|\n| 请求阻塞 | 本地0.5秒同步sleep | loop lag、CPU、HTTP耗时 | 恢复await实现 |\n| 数据库锁 | 两会话同一演练商品 | blocked_by、lock_timeout | 两会话ROLLBACK |\n| 缓存不可用 | 只停缓存6380 | 回源量、503、池等待 | 恢复缓存 |\n| 发布积压 | 只停publisher | outbox最老等待 | 恢复有界publisher |\n| worker租约到期 | 一个记录过的job | token、attempts、状态 | 有界恢复扫描 |\n| SSE缓冲 | 隔离代理location | 首data时间对照 | 恢复缓冲配置 |", "lang": "text", "filename": "drill-plan.md", "expected": "演练矩阵列出单一受限故障、主要证据与恢复动作,每项先有当前基线,新测试数据与真实业务隔离。", "explanation": "先能恢复再注入,才不会把演练变成无边界试错;模板描述计划,不代表故障或测试已经发生。" }, { "title": "定义停止与恢复门槛", "body": "将以下阈值改为本地机器可承受值,再执行。示例阈值只用于练习,不作为线上SLO建议。超出范围停止故障注入并回到基线,不继续加压寻找更大异常。", "code": "{\n \"scope\": \"isolated shop-service only\",\n \"max_test_requests\": 20,\n \"max_fault_duration_seconds\": 30,\n \"max_controlled_python_allocation_mib\": 10,\n \"stop_if\": [\"出现非演练数据写入风险\", \"无法确定目标实例\", \"恢复命令失败\"],\n \"recovery_gate\": [\n \"live、ready与商品读取按基线返回\",\n \"一个新导出完成且文件符合快照\",\n \"旧演练任务均有结果或明确失败原因\",\n \"outbox和租约等待回到可解释范围\"\n ],\n \"result\": \"not_run\"\n}", "lang": "json", "filename": "drill-boundary.json", "expected": "请求数、持续时间和分配量上限改成机器可承受值,目标不明或恢复失败等停止条件明确。", "explanation": "开始、停止与恢复三条边界限制影响范围;碰到停止条件应撤回本次故障,不继续加压寻找更严重现象。" }, { "title": "按时间线只执行一个故障", "body": "每次记录UTC时间、目标、request_id/job_id、配置前值、操作、指标变化和恢复前值。日志示例标synthetic,实际输出标observed。任何未执行项保留not_run,不能根据理论预计填写成功。", "code": "{\n \"record_type\": \"template\",\n \"case\": \"db_lock_wait\",\n \"started_at_utc\": null,\n \"target\": null,\n \"request_id\": null,\n \"job_id\": null,\n \"observations\": [],\n \"restored_at_utc\": null,\n \"recovery_checks\": [],\n \"result\": \"not_run\"\n}", "lang": "json", "filename": "drill-result.json", "expected": "每次时间线只含一个正在注入的故障,实际输出标 observed,示例标 synthetic,未做项目保持 not_run。", "explanation": "分开证据类型可避免把教材预期误作实测结论;记录配置前值让恢复动作有明确目标。" }, { "title": "用固定结构编写runbook", "body": "每条runbook提供症状、只读检查、分支判断、限定动作、回滚和验收。命令中的PID、容器名、数据库和端口必须先核对;不要放“重启全部”“清空队列”之类无范围操作。", "code": "# Runbook:导出已受理但长期未完成\n## 识别\n记录job_id、受理时间、当前数据库状态。\n## 只读检查\noutbox未发布?队列有人消费?租约有效?attempts/deadline是否耗尽?\n## 分支\n未发布→查publisher;无人消费→查路由;租约过期→按恢复规则处理。\n## 动作\n只处理已确认的本地实例/任务,保持次数、退避与容量约束。\n## 回滚\n恢复本次修改前配置,停止新故障输入。\n## 验收\n核对新任务、旧任务与结果文件;未恢复项明确列出。", "lang": "text", "filename": "runbook-export.md", "expected": "runbook 有症状、只读检查、判断分支、限定动作、回滚与验收,示例任务能沿派发/消费/租约路径定位。", "explanation": "手册不是命令列表;分支依据把观察到的事实连到有范围的动作,避免同一症状被统一重启或清队列处理。" }, { "title": "输出复盘行动项与证据索引", "body": "复盘不只记录最后一个报错。为触发原因、扩大因素、恢复机制和观测缺口分别填写证据,并把修复项写成可验收工作。示例中的行动项需要实际演练支持后才采用。", "code": "| 类型 | 发现 | 证据 | 行动项 | 验收 |\n|---|---|---|---|---|\n| 触发 | 待实际观测 | 时间线链接 | 待定 | 可重复复现 |\n| 扩大因素 | 待实际观测 | 指标/trace | 待定 | 相同故障影响受限 |\n| 恢复缺口 | 待实际观测 | 任务状态 | 待定 | 恢复时间可测 |\n| 观测缺口 | 待实际观测 | 缺失字段 | 待定 | 可定位具体阶段 |", "lang": "text", "filename": "postmortem-actions.md", "expected": "触发、扩大因素、恢复缺口与观测缺口都有证据链接,行动项有可重复的验收标准。", "explanation": "修复最后一个报错未必阻止下次扩大;复盘要改变可验证的系统行为,无法由本次证据支持的结论保留待查。" } ], "checks": [ "每次故障均有范围、停止条件和实际恢复记录。", "恢复验证覆盖新请求、旧任务与输出一致性。", "runbook有分支与验收,复盘行动项能被具体检查。" ], "pitfalls": [ { "symptom": "演练结束但临时故障开关还开着", "cause": "没有记录配置前值和恢复清单。", "fix": "每次演练最后核对开关、代理配置、进程和事务,跑无故障回归。" }, { "symptom": "复盘只写某人误操作", "cause": "忽略缺少权限边界、幂等、容量或观测导致的扩大。", "fix": "把触发因素和系统控制缺口分别写清,并给可验收修复项。" } ], "exercise": { "task": "把“加强监控”改成一个可验收行动项。", "hint": "指定指标、范围、触发条件、关联信息和验证方式。", "answer": "例如:为export_outbox最老未发布年龄增加告警,限定服务标签,持续超过本地设定阈值时提供publisher日志入口;用暂停本地publisher的演练验证触发与恢复,数值按实际业务基线设置。" }, "refs": [ { "label": "Google SRE复盘", "url": "https://sre.google/sre-book/postmortem-culture/" }, { "label": "Google SRE事故管理", "url": "https://sre.google/sre-book/managing-incidents/" }, { "label": "Google SRE有效排障", "url": "https://sre.google/sre-book/effective-troubleshooting/" } ], "production": { "scenario": "将shop-service交付维护,希望另一位开发者能按文档定位并恢复常见故障。", "decisions": [ "演练在独立环境,目标和恢复动作先明确。", "证据与预计行为分开,未运行不标通过。", "复盘行动项落实到观测、容量、一致性和恢复能力。" ], "failureDrill": "逐项执行本课矩阵,每次恢复基线后才进入下一项,最终检查所有临时开关与事务已关闭。", "acceptance": [ "交接者可按runbook走到具体分支。", "已受理任务没有不明去向。", "所有完成声明有实际记录,未执行部分明确列出。" ] }, "intro": { "scenario": "把前面学过的定位方法变成别人能照做的故障手册,需要证明既能复现问题,也能完整恢复。", "startingPoint": [ "完成第 01—11 课中实际采用的能力,并记录哪些扩展未安装。", "保留无故障的商品读写、导出和文件基线;使用全新练习数据、独立副本和明确恢复命令。" ], "outcome": "完成限定故障的时间线、恢复验证与证据索引,并产出可执行改进项。" } } ]