troubleshooting.json 118 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520
  1. [
  2. {
  3. "id": "01",
  4. "group": "影响、日志与观测",
  5. "title": "先确认影响范围,再选择止损动作",
  6. "summary": "得到包含实际影响、动作前提和恢复证据的事故记录,而不是先重启一遍所有服务。",
  7. "objectives": [
  8. "分别检查商品、健康与导出路径",
  9. "把观察事实和待验证原因分开",
  10. "恢复后追踪旧任务与新任务去向"
  11. ],
  12. "concepts": [
  13. {
  14. "title": "错误数量不能单独决定影响",
  15. "body": "回到“建立影响矩阵”采集的证据再看。一个导出接口失败和全部商品读取失败有不同影响。记录受影响操作、用户范围、开始时间、成功率和延迟,再确定处理优先级。"
  16. },
  17. {
  18. "title": "先区分事实与假设",
  19. "body": "回到“为每次动作写四个字段”采集的证据再看。“新增请求都超时”是观测,“数据库挂了”是待验证假设。每次只改变一个因素,避免多个操作互相覆盖证据。"
  20. },
  21. {
  22. "title": "恢复服务与定位根因分开记录",
  23. "body": "回到“核对止损后仍有无遗漏任务”采集的证据再看。止损可以是暂停大导出受理、回退本次配置或缩小流量;它不自动证明根因。恢复后继续核对已受理任务和数据一致性。"
  24. }
  25. ],
  26. "steps": [
  27. {
  28. "title": "建立独立演练目录与时间基线",
  29. "body": "本模块在部署模块建立的 shop-service 隔离副本上演练;Linux 单元为 shop-api.service,基础端点为 /live(进程存活)与 /ready(依赖就绪)。先在副本新建 diagnostics_lab。Linux/WSL2 命令只针对隔离目标;容器部署从 Compose 目录使用 docker compose ps 定位 service,不猜容器名。第 11 课的 SSE/WS 是 API 模块扩展路由,须先确认已加入副本。",
  30. "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",
  31. "lang": "bash",
  32. "expected": "记录到 UTC 时间、实际服务 PID 与 live/ready 的 HTTP 状态和耗时,diagnostics_lab 目录存在。",
  33. "explanation": "这些结果建立当前副本的基线;进程、依赖就绪与业务完成是不同观察层,先知道正常值才有比较依据。"
  34. },
  35. {
  36. "title": "建立影响矩阵",
  37. "body": "保存 incident.md,把每格填成实际观测。下表是待测模板,不是真实事故结论。持久导出沿异步任务模块的 tasks_lab 命令和 export_jobs 表核对;第 01 课内存 POST /exports 只是协议练习,并未自动加入部署 API。如果另行实现了持久 HTTP 适配层,再添加对应路由;202 只表示受理。流式扩展未加入本次部署时填不适用。",
  38. "code": "| 操作 | 当前观测 | 受影响范围 | 最后一次正常时间 |\n|---|---|---|---|\n| GET /live | 待测 | 待测 | 待测 |\n| GET /ready | 待测 | 待测 | 待测 |\n| GET /products | 待测 | 待测 | 待测 |\n| submit_snapshot 提交持久导出 | 待测 | 待测 | 待测 |\n| export_jobs 状态与结果文件 | 待测 | 待测 | 待测 |\n| SSE /stream 与 WS /ws | 待测 | 待测 | 待测 |",
  39. "lang": "text",
  40. "filename": "incident.md",
  41. "expected": "incident.md 各行填实际状态、范围与最后正常时间;未整合的流式或 HTTP 导出入口标不适用。",
  42. "explanation": "影响矩阵区分部分能力受损和全站不可用;导出受理成功并不意味着 Worker 已执行或文件可读。"
  43. },
  44. {
  45. "title": "为每次动作写四个字段",
  46. "body": "先用只读查询验证假设,再决定是否在隔离副本中操作。比如只暂停本地导出受理,预期减小新增积压;回滚方式是恢复之前配置。当前代码若没有受理开关,先增加并测试该开关,不能把未实现的操作写成已执行。",
  47. "code": "{\n \"timestamp_utc\": \"执行时填写\",\n \"observation\": \"填写实际现象\",\n \"hypothesis\": \"填写待验证原因\",\n \"action\": \"planned\",\n \"expected_change\": \"填写应变化的指标\",\n \"rollback\": \"填写恢复原配置的方法\",\n \"result\": \"not_run\"\n}",
  48. "lang": "json",
  49. "filename": "action-record.json",
  50. "expected": "每个候选动作都有观测、假设、预期变化和恢复方式,未实施时 result 保持 not_run。",
  51. "explanation": "把原因先写成假设,才能用检查反驳它;预计结果与实际结果分开,避免记录反过来证明自己的猜测。"
  52. },
  53. {
  54. "title": "核对止损后仍有无遗漏任务",
  55. "body": "只读查询数据库任务状态与最老等待。恢复判定至少覆盖健康、商品读取、一个新导出完成、旧任务去向;不能仅以进程重新启动作为结束标准。",
  56. "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;",
  57. "lang": "sql",
  58. "expected": "可读到各任务状态数量、最老年龄和未发事件数;恢复后新任务完成,旧任务有明确状态去向。",
  59. "explanation": "积压可能在恢复进程后继续存在,甚至有旧任务遗漏;业务恢复需要追到持久状态和文件而非只看进程。"
  60. },
  61. {
  62. "title": "维护带因果关系的时间线",
  63. "body": "每条记录包含时间、执行人、目标、动作和结果。日志或截图引用需先脱敏;记录中的时间使用UTC,操作系统和应用时钟偏差单独注明。最后保留尚未解释的事实。",
  64. "code": "# incident.md 时间线模板\n# UTC时间 | 目标实例/任务ID | 实际观测 | 动作 | 结果 | 下一验证\n# 未执行动作统一标planned,不用预计结果填入实际结果列。",
  65. "lang": "text",
  66. "filename": "incident.md",
  67. "expected": "时间线按 UTC 串起目标、动作、指标变化和下一验证,未解释事实仍保留。",
  68. "explanation": "时间先后帮助建立可检验的因果链,但不能仅因两事件接近就认定原因;时钟偏差需要单独说明。"
  69. }
  70. ],
  71. "checks": [
  72. "影响范围按具体用户操作列出,而非只写服务异常。",
  73. "每次变更有目标、预期、回滚和实际结果。",
  74. "恢复门槛包含业务请求与既有任务去向,未执行项保持not_run。"
  75. ],
  76. "pitfalls": [
  77. {
  78. "symptom": "看到500立即重启所有组件",
  79. "cause": "未判断影响位置,重启可能扩大故障并丢掉现场。",
  80. "fix": "先保存进程、接口和依赖观测,仅对已验证目标采取有界动作。"
  81. },
  82. {
  83. "symptom": "健康检查成功就宣布恢复",
  84. "cause": "健康检查可能只确认进程存活,没有覆盖数据库和队列。",
  85. "fix": "增加真实读取与导出完成验收,区分liveness和业务可用性。"
  86. }
  87. ],
  88. "exercise": {
  89. "task": "“/live正常、/products正常、导出202正常但10分钟未完成”应怎样描述影响?",
  90. "hint": "把用户完成时间与请求受理状态分开。",
  91. "answer": "描述为导出执行链路延迟或积压,读取功能仍可用;检查outbox、队列、worker、租约,不能仅凭202判定业务正常,也不能直接推断数据库整体不可用。"
  92. },
  93. "refs": [
  94. {
  95. "label": "Google SRE事故管理",
  96. "url": "https://sre.google/sre-book/managing-incidents/"
  97. },
  98. {
  99. "label": "Google SRE有效排障",
  100. "url": "https://sre.google/sre-book/effective-troubleshooting/"
  101. }
  102. ],
  103. "production": {
  104. "scenario": "用户反馈导出一直等待,但商品列表仍正常。",
  105. "decisions": [
  106. "先降低用户影响,再定位根因,两者分别留证据。",
  107. "一次只调整一个因素,限定实例和任务ID。",
  108. "业务恢复要求验证旧任务和新请求,不能只看进程状态。"
  109. ],
  110. "failureDrill": "只暂停本地练习worker,提交一条导出,按模板判断影响,再恢复worker核对任务完成。",
  111. "acceptance": [
  112. "时间线区分事实、假设与操作。",
  113. "没有盲目批量重启或终止进程。",
  114. "恢复结论有业务验证支持。"
  115. ]
  116. },
  117. "intro": {
  118. "scenario": "用户说商品清单一直导不出来,但商品页面可能仍能打开。先确认影响哪些操作,再决定动哪一处。",
  119. "startingPoint": [
  120. "已完成 Docker 或 Linux 部署,并在独立副本验证商品查询、持久导出和结果文件。",
  121. "选择一条实际部署路线,核对 /live、/ready 以及真实任务提交入口;所有演练只在隔离副本执行。"
  122. ],
  123. "outcome": "得到包含实际影响、动作前提和恢复证据的事故记录,而不是先重启一遍所有服务。"
  124. }
  125. },
  126. {
  127. "id": "02",
  128. "group": "影响、日志与观测",
  129. "title": "用结构化日志与 request_id 关联一次请求",
  130. "summary": "从响应拿到请求编号,再用同一编号找到必要日志,并验证测试秘密未被自定义日志记录。",
  131. "objectives": [
  132. "把实际响应与一条 JSON 日志关联",
  133. "验证非法编号被替换且敏感值不入日志",
  134. "把受理请求编号连到持久导出 job_id"
  135. ],
  136. "concepts": [
  137. {
  138. "title": "请求ID用于关联,不代表身份",
  139. "body": "回到“注册并验证响应头”采集的证据再看。接收可信网关生成的ID或自行生成;客户端输入需要限制长度和字符,不能直接拼入日志行。request_id只帮助定位请求,不能用于权限判断。"
  140. },
  141. {
  142. "title": "记录路由模板和必要字段",
  143. "body": "回到“用合成记录练习检索”采集的证据再看。优先记录GET、/products/{product_id}、状态、耗时、实例与版本。完整URL可能包含查询凭据,商品描述可能包含敏感内容,排障不要求默认记录全部数据。"
  144. },
  145. {
  146. "title": "HTTP完成与业务完成不同",
  147. "body": "回到“把任务日志接到同一业务ID”采集的证据再看。HTTP响应完成可以记录访问耗时,导出任务另记job_id和业务阶段。SSE长连接需按首段和整体生命周期分别观察,不能把202访问日志当成导出完成日志。"
  148. }
  149. ],
  150. "steps": [
  151. {
  152. "title": "编写JSON日志中间件",
  153. "body": "在隔离 shop-service 根目录新建 request_logging.py,保存以下包装器;它与 app 目录并列,可供 app/main.py 导入。先通读输入编号的限制和日志字段,再保存,下一步才注册到现有应用。",
  154. "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))",
  155. "lang": "python",
  156. "filename": "request_logging.py",
  157. "expected": "request_logging.py 能导入,包装器仅处理 HTTP,代码在发送响应头时添加请求编号,在结束时写一条 JSON。",
  158. "explanation": "ASGI 的 send 是应用向服务器交响应的位置;在这里观察真实状态,流式响应也不会被提前当成普通短响应结束。"
  159. },
  160. {
  161. "title": "注册并验证响应头",
  162. "body": "在现有 app/main.py 的 app = FastAPI(...) 之后注册下面中间件,不创建第二个 app。若已有统一日志配置则合并 message 格式,不覆盖其他 logger。按已选择部署方式重启本次隔离 API,再执行代码末尾的 curl。",
  163. "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",
  164. "lang": "python",
  165. "filename": "app/main.py",
  166. "expected": "正常请求响应头含 X-Request-ID: drill-001,实际日志有同一 request_id;非法或过长编号被替换为新编号。",
  167. "explanation": "编号用于检索一条请求经过的记录,不代表调用者身份。只接受有限字符也避免任意输入污染日志格式。"
  168. },
  169. {
  170. "title": "用合成记录练习检索",
  171. "body": "以下仅为示例日志。保存到sample.jsonl后用rg 'drill-001'查找。真实日志中应出现实际状态和耗时,不把下例数值作为当前服务性能结论。",
  172. "code": "{\"event\":\"http_request\",\"request_id\":\"drill-001\",\"method\":\"GET\",\"route\":\"/products/{product_id}\",\"status\":404,\"duration_ms\":4.2,\"error_type\":null}",
  173. "lang": "json",
  174. "filename": "sample.jsonl",
  175. "expected": "rg 找到 drill-001 的一行合成记录,其 route 是 /products/{product_id}、状态 404;没有把示例 4.2 ms 当作实测。",
  176. "explanation": "结构化字段使检索不依赖自然语言报错,路由模板把同类请求归在一起,同时不暴露具体商品编号。"
  177. },
  178. {
  179. "title": "验证脱敏和异常路径",
  180. "body": "对本地请求添加测试用Authorization和带敏感占位词的查询字符串,确认自定义日志没有这些值。只使用fake-secret,不用真实凭据。再触发404与受控500,核对request_id和error_type;网关/Uvicorn默认访问日志需要另行检查查询字符串记录策略。",
  181. "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访问日志不包含测试秘密。",
  182. "lang": "bash",
  183. "expected": "实际自定义 JSON 日志搜索 fake-secret 无匹配;404 可关联到请求,受控异常日志含实际 error_type。",
  184. "explanation": "自定义日志只列必要字段,因而不会自动输出 Authorization 或查询串;其他访问日志仍有独立策略,需分别核对。"
  185. },
  186. {
  187. "title": "把任务日志接到同一业务ID",
  188. "body": "提交导出时记录request_id和job_id的对应关系,worker记录job_id、message_id和lease_token。不要把整个request对象塞进Celery消息;跨进程关联下一课后续用trace上下文补充。",
  189. "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 }))",
  190. "lang": "python",
  191. "filename": "request_logging.py",
  192. "expected": "提交记录包含 request_id 与真实 job_id;Worker 记录能按同一 job_id 找到对应投递和执行。",
  193. "explanation": "HTTP 请求结束后任务仍继续,业务编号把跨进程的长期工作串起来,message_id 与执行版本进一步区分重投和接管。"
  194. }
  195. ],
  196. "checks": [
  197. "合法request_id能在响应头和JSON访问日志中对应。",
  198. "不存在路径使用unmatched,不记录完整查询串。",
  199. "fake-secret不进入自定义日志,任务受理与HTTP请求能够关联。"
  200. ],
  201. "pitfalls": [
  202. {
  203. "symptom": "日志不是一行一个JSON",
  204. "cause": "异常正文或字符串拼接引入未转义换行。",
  205. "fix": "用json.dumps构造完整记录,按日志系统约定处理堆栈字段。"
  206. },
  207. {
  208. "symptom": "自定义日志脱敏但代理日志仍泄露token",
  209. "cause": "只检查应用logger,没有检查访问日志链路。",
  210. "fix": "逐层审查代理、Uvicorn与应用日志,URL不承载长期凭据。"
  211. }
  212. ],
  213. "exercise": {
  214. "task": "传入超过64字符的X-Request-ID,确认服务器生成新ID而不是直接回显。",
  215. "hint": "测试输入只用重复字母,不需要任何真实秘密。",
  216. "answer": "返回ID长度应为生成的32位hex,并可在日志关联;非法输入不会进入日志字段。"
  217. },
  218. "refs": [
  219. {
  220. "label": "Python logging",
  221. "url": "https://docs.python.org/3.11/library/logging.html"
  222. },
  223. {
  224. "label": "ASGI HTTP事件",
  225. "url": "https://asgi.readthedocs.io/en/latest/specs/www.html"
  226. },
  227. {
  228. "label": "OWASP日志安全",
  229. "url": "https://cheatsheetseries.owasp.org/cheatsheets/Logging_Cheat_Sheet.html"
  230. }
  231. ],
  232. "production": {
  233. "scenario": "某个用户操作失败,需要从响应定位一次请求,又不能泄露商品和密钥。",
  234. "decisions": [
  235. "关联ID、业务ID和错误类型足以支持多数定位。",
  236. "日志字段采用允许列表,敏感数据不靠事后人工删除保障。",
  237. "请求完成与异步任务完成使用不同事件名。"
  238. ],
  239. "failureDrill": "对本地接口发送fake-secret、非法ID及受控错误,检索全部日志层的实际输出。",
  240. "acceptance": [
  241. "请求能关联,凭据不会被自定义日志保存。",
  242. "日志是可解析的结构化记录。",
  243. "示例日志与真实观测明确区分。"
  244. ]
  245. },
  246. "intro": {
  247. "scenario": "同一时刻有很多商品请求,某位用户遇到的错误混在日志中;先让一次请求留下可关联的编号。",
  248. "startingPoint": [
  249. "完成第 01 课,隔离副本有正常健康与商品请求基线。",
  250. "能编辑实际 FastAPI 应用并在该项目虚拟环境运行;中间件文件与 app.main 的导入路径需保持一致。"
  251. ],
  252. "outcome": "从响应拿到请求编号,再用同一编号找到必要日志,并验证测试秘密未被自定义日志记录。"
  253. }
  254. },
  255. {
  256. "id": "03",
  257. "group": "影响、日志与观测",
  258. "title": "定义 SLI、SLO 与低基数指标",
  259. "summary": "从真实请求产生计数与耗时指标,按路由模板检查标签,并给商品读取写出可计算目标。",
  260. "objectives": [
  261. "在请求完成时更新计数和耗时",
  262. "验证不同商品编号复用同一路由标签",
  263. "把成功比例和时间目标对应到实际查询"
  264. ],
  265. "concepts": [
  266. {
  267. "title": "SLI先定义分母与好事件",
  268. "body": "回到“写出查询与业务目标”采集的证据再看。例如商品读取的有效请求中成功完成的比例,必须说明是否排除主动取消和客户端无效请求。SLO是时间窗口内目标,不能把单次健康检查通过当作达标。"
  269. },
  270. {
  271. "title": "标签组合决定时间序列数量",
  272. "body": "回到“验证基数与告警触发条件”采集的证据再看。request_id、job_id、用户ID和原始URL不适合作为指标标签。使用method、路由模板、状态类别;具体请求通过日志与trace定位。"
  273. },
  274. {
  275. "title": "分位数需要正确聚合",
  276. "body": "回到“写出查询与业务目标”采集的证据再看。Histogram按桶计数,可在服务实例间聚合;平均耗时可能掩盖慢请求。低流量时分位数和错误率波动大,应同时看样本量,不能通过人为补成功请求掩盖空窗口。"
  277. }
  278. ],
  279. "steps": [
  280. {
  281. "title": "安装指标库并定义有限标签",
  282. "body": "进入隔离 shop-service 根目录并激活它的虚拟环境,执行 python -m pip install prometheus-client,将实际版本记录回练习依赖文件。新建 metrics_lab.py 保存下面定义;本课先使用单个 Uvicorn 进程,多 worker 汇总需另外配置。",
  283. "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)",
  284. "lang": "python",
  285. "filename": "metrics_lab.py",
  286. "expected": "metrics_lab.py 定义请求计数和耗时直方图,标签只含有限 method、route、status_class。",
  287. "explanation": "计数累计发生次数,直方图按区间累计耗时样本;每种标签组合都会产生序列,因此不能把每个商品或任务编号当标签。"
  288. },
  289. {
  290. "title": "在实际请求完成时观测",
  291. "body": "在 request_logging.py 的 import 区加入下面指标导入,在类之前定义 record_metrics;然后在第 02 课 __call__ 的 finally 内取消末行调用示例的注释并加入调用。不要重复注册中间件。SSE 本课统计整条连接时长,首段到达另在第 11 课测。",
  292. "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)",
  293. "lang": "python",
  294. "filename": "request_logging.py",
  295. "expected": "普通请求完成后调用 record_metrics;/metrics 自己不增加业务计数,路由未匹配时使用 unmatched。",
  296. "explanation": "记录完成时的实际状态和耗时能让失败请求也进入统计;排除抓取请求避免监控本身改变被观察的业务比例。"
  297. },
  298. {
  299. "title": "暴露本地抓取入口",
  300. "body": "在现有app注册一次/metrics,使用Response直接返回指标文本。正式环境应限制观测端点访问范围。抓取后连续请求不同product_id,确认标签仍是/products/{product_id}。",
  301. "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})",
  302. "lang": "python",
  303. "filename": "app/main.py",
  304. "expected": "/metrics 返回指标文本,访问多个商品编号后标签仍显示 /products/{product_id},不是各个实际 URL。",
  305. "explanation": "统一路由模板让同类请求共用有限指标系列;进程内变量只覆盖本进程,多 worker 不能直接假定共享计数。"
  306. },
  307. {
  308. "title": "写出查询与业务目标",
  309. "body": "在本地Prometheus配置抓取目标后使用查询。下面示例看5xx比例和P95,均需先有流量。为GET /products另写目标:例如最近7天有效读取99.5%在300ms内成功;数值是练习目标,是否适合业务需按实际基线确定。",
  310. "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)",
  311. "lang": "promql",
  312. "filename": "metrics-queries.promql",
  313. "expected": "有本地抓取与真实流量时查询返回数值;无流量或无抓取时记录无数据。目标明确规定有效请求、时间窗口与成功耗时。",
  314. "explanation": "SLI 是按分母计算的实际表现,SLO 是对其设定目标;P95 需从可合并的直方图桶计算,不能平均各实例 P95。"
  315. },
  316. {
  317. "title": "验证基数与告警触发条件",
  318. "body": "用20个不同ID访问同一路由,检查/metrics没有20个原始路径标签。无请求的窗口标无数据;告警同时要求有效样本和持续时间。导出积压另由状态表采样为Gauge,不能把每个job作为标签。",
  319. "code": "curl -sS http://127.0.0.1:8000/metrics\n# 指标标签只允许有限方法、路由模板与状态类别。\n# 不应出现 request_id=、job_id=、user_id= 或带查询串的route。",
  320. "lang": "bash",
  321. "expected": "20 个不同 ID 没有带来 20 个原始路径标签,无 request_id/job_id/user_id 标签;告警要求样本和持续窗口。",
  322. "explanation": "没有样本不是 0% 错误或 100% 成功。有限标签保护监控系统容量,持续窗口避免单个偶发样本触发误判。"
  323. }
  324. ],
  325. "checks": [
  326. "指标能被Prometheus解析,计数随真实请求增加。",
  327. "不同product_id共享同一路由标签,没有高基数ID标签。",
  328. "SLI写明分母与窗口,SLO数值被标为练习目标。"
  329. ],
  330. "pitfalls": [
  331. {
  332. "symptom": "指标内存持续增长",
  333. "cause": "把原始URL、用户或任务ID用作标签。",
  334. "fix": "换成有限路由模板,具体对象放日志和trace。"
  335. },
  336. {
  337. "symptom": "平均耗时正常却有用户一直等待",
  338. "cause": "平均值掩盖尾部延迟,导出202也没有覆盖完成时间。",
  339. "fix": "同时看P95/分桶和业务受理到完成耗时。"
  340. }
  341. ],
  342. "exercise": {
  343. "task": "写一个300ms内请求比例查询,并说明它还缺什么条件才能叫“快速且成功”。",
  344. "hint": "Histogram的le=0.3桶只看时长,本课时长指标未附带成功标签。",
  345. "answer": "sum(rate(..._bucket{le='0.3'}[5m]))/sum(rate(..._count[5m]))得到时长比例;若要快速且成功,应另记录成功请求的时长桶或专用好事件计数,不能直接把独立成功率与时长比例相乘。"
  346. },
  347. "refs": [
  348. {
  349. "label": "Prometheus标签命名",
  350. "url": "https://prometheus.io/docs/practices/naming/"
  351. },
  352. {
  353. "label": "Prometheus Histogram",
  354. "url": "https://prometheus.io/docs/practices/histograms/"
  355. },
  356. {
  357. "label": "Prometheus Python客户端",
  358. "url": "https://prometheus.github.io/client_python/"
  359. }
  360. ],
  361. "production": {
  362. "scenario": "接口平均延迟没有明显变化,但部分用户查询和导出等待越来越久。",
  363. "decisions": [
  364. "成功率、尾部耗时和异步完成时间分别建模。",
  365. "指标保持低基数,日志和trace承接单请求细节。",
  366. "SLO依据基线与用户需求制定,空窗口不伪造100%成功。"
  367. ],
  368. "failureDrill": "在本地只让少数请求延迟,再比较平均值与耗时桶;以不同ID请求验证时间序列数量稳定。",
  369. "acceptance": [
  370. "统计口径可复算。",
  371. "标签数量受控。",
  372. "告警有样本量与持续时间条件,不由一条慢请求直接推导事故。"
  373. ]
  374. },
  375. "intro": {
  376. "scenario": "能找到单次错误了,还需要知道“最近五分钟是不是普遍变慢”,并避免为每个商品制造一套指标。",
  377. "startingPoint": [
  378. "完成第 02 课,中间件会在请求结束时记录实际状态与耗时。",
  379. "在隔离项目虚拟环境、单个 Uvicorn 进程验证;Prometheus 查询步骤需本地抓取实例,无实例时先完成 /metrics 验证并注明。"
  380. ],
  381. "outcome": "从真实请求产生计数与耗时指标,按路由模板检查标签,并给商品读取写出可计算目标。"
  382. }
  383. },
  384. {
  385. "id": "04",
  386. "group": "影响、日志与观测",
  387. "title": "把 trace 贯穿 HTTP、数据库与 Celery",
  388. "summary": "让一次正常与一次延迟派发的导出都能关联 HTTP、数据库、消息与 Worker 阶段。",
  389. "objectives": [
  390. "给商品读取加有限操作名和耗时",
  391. "把允许的追踪上下文随待发记录保存",
  392. "核对恢复派发后仍能关联原业务任务"
  393. ],
  394. "concepts": [
  395. {
  396. "title": "trace显示阶段关系与耗时",
  397. "body": "回到“给API和数据库操作加span”采集的证据再看。一个请求可能包含数据库查询、HTTP调用和消息发布。span支持观察每段耗时,但采样trace不是全部请求计数,不能直接替代SLI指标。"
  398. },
  399. {
  400. "title": "跨进程需要传播上下文",
  401. "body": "回到“在publisher与worker恢复上下文”采集的证据再看。HTTP请求头和Celery消息头可以携带trace上下文;outbox把发布延迟到另一个进程后,需要保存允许的追踪字段,再在publisher恢复。不能把Authorization或整个请求头带过去。"
  402. },
  403. {
  404. "title": "异步任务不一定延续请求寿命",
  405. "body": "回到“比较同步与延迟发布链路”采集的证据再看。长延迟任务可以用span link关联原请求。为本地演示先使用标准traceparent传播,生产需按采样与链路保留策略决定父子关系或链接。"
  406. }
  407. ],
  408. "steps": [
  409. {
  410. "title": "安装并配置本地trace输出",
  411. "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 保存下方函数;控制台输出只用于隔离调试,分享前脱敏。",
  412. "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",
  413. "lang": "python",
  414. "filename": "trace_setup.py",
  415. "expected": "trace_setup.py 可导入,各进程调用 setup_trace 后控制台出现带 service.name 的 span 输出。",
  416. "explanation": "一个 span 描述一段工作及耗时,trace 通过上下文把多段工作关联;控制台输出适合隔离观察,仍可能含需脱敏的元信息。"
  417. },
  418. {
  419. "title": "给API和数据库操作加span",
  420. "body": "在应用启动配置中调用setup_trace一次,instrument现有app。数据库span只记录有限操作名和行数,不把商品描述或SQL参数设成属性;HTTPX自动插桩也需核对URL是否包含敏感查询参数。 traced_read接收现有SQLAlchemy AsyncSession,由调用者管理事务并await此函数;不要把同步PyMySQL调用直接放入异步路由。",
  421. "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",
  422. "lang": "python",
  423. "filename": "app/main.py",
  424. "expected": "一次商品读取出现 HTTP 与命名的数据库操作 span,属性只有有限操作类型和行数,没有 SQL 参数或商品正文。",
  425. "explanation": "嵌套 span 帮助区分时间花在入口还是读库;异步数据库调用继续使用现有 AsyncSession,插桩不能把同步阻塞变为异步。"
  426. },
  427. {
  428. "title": "把允许的追踪字段存入outbox",
  429. "body": "通过审核后的MySQL迁移为export_outbox新增trace_context JSON列。先确认列不存在;ALTER TABLE会隐式提交,不包在业务受理事务里。提交事件时,在同一业务事务中将json.dumps(carrier_for_outbox())作为参数绑定到该列。只保存标准追踪字段,不复制任意baggage或请求头。",
  430. "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())",
  431. "lang": "python",
  432. "filename": "trace_carrier.py",
  433. "expected": "隔离库新增可空 trace_context 列,新待发记录保存允许的 traceparent/tracestate 字段;无上下文时可为空。",
  434. "explanation": "请求结束后内存上下文会消失,把允许字段与派发意图同事务保存,才能在稍后发送时恢复关联;任意请求头不应随消息持久化。"
  435. },
  436. {
  437. "title": "在publisher与worker恢复上下文",
  438. "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。",
  439. "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()",
  440. "lang": "python",
  441. "filename": "trace_worker.py",
  442. "expected": "发布器从领取结果解析 trace_context 后 attach,在结束时 detach;Worker 子进程有自己的 provider,发送与消费 span 可关联。",
  443. "explanation": "上下文必须跨进程传递,不能靠同名全局变量。prefork 子进程需要在自己的生命周期初始化采集资源。"
  444. },
  445. {
  446. "title": "比较同步与延迟发布链路",
  447. "body": "发送一条本地导出请求,记录request_id→job_id映射和trace_id。先正常发布,再暂停publisher后恢复,观察上下文仍可关联。日志保留job_id,不能在trace采样丢弃时失去业务定位能力。",
  448. "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",
  449. "lang": "text",
  450. "filename": "trace-verification.md",
  451. "expected": "正常和暂停 publisher 后恢复的任务都有 request_id→job_id 映射与可核对 trace 关系,任务最终完成。",
  452. "explanation": "异步延迟不应切断业务定位线索;trace 可能被采样丢弃,因此持久 job_id 与日志关联仍需独立保留。"
  453. }
  454. ],
  455. "checks": [
  456. "API内部数据库span可定位并未记录商品描述。",
  457. "outbox只保存允许的追踪字段。",
  458. "publisher恢复上下文,worker链路可以关联,日志仍保留业务ID。"
  459. ],
  460. "pitfalls": [
  461. {
  462. "symptom": "HTTP有trace,worker却变成另一条无关联链路",
  463. "cause": "outbox异步发布丢了当前请求上下文,或producer未插桩。",
  464. "fix": "保存并恢复标准trace字段,初始化publisher和worker插桩。"
  465. },
  466. {
  467. "symptom": "每次任务执行都初始化TracerProvider",
  468. "cause": "重复初始化造成警告或多个导出器。",
  469. "fix": "每个进程只初始化一次,prefork在子进程启动时设置。"
  470. }
  471. ],
  472. "exercise": {
  473. "task": "删除一条实验outbox事件的trace_context后发布,说明业务任务是否应该失败。",
  474. "hint": "可观测性关联与业务输入是不同职责。",
  475. "answer": "任务仍按job_id正常执行,只失去原trace关联;publisher可开始新trace并通过job_id日志定位,不能因缺少trace上下文拒绝已受理业务。"
  476. },
  477. "refs": [
  478. {
  479. "label": "OpenTelemetry Python",
  480. "url": "https://opentelemetry.io/docs/languages/python/instrumentation/"
  481. },
  482. {
  483. "label": "FastAPI插桩",
  484. "url": "https://opentelemetry-python-contrib.readthedocs.io/en/latest/instrumentation/fastapi/fastapi.html"
  485. },
  486. {
  487. "label": "Celery插桩",
  488. "url": "https://opentelemetry-python-contrib.readthedocs.io/en/latest/instrumentation/celery/celery.html"
  489. }
  490. ],
  491. "production": {
  492. "scenario": "一次请求很快返回202,但导出很久才完成,需要定位等待发生在哪里。",
  493. "decisions": [
  494. "同时关联request_id、job_id、消息ID与trace。",
  495. "outbox只持久必要追踪字段,外部输入按信任边界处理。",
  496. "采样策略不能代替任务状态与指标,缺trace不阻断业务。"
  497. ],
  498. "failureDrill": "暂停本地publisher后恢复,比较保留和缺少trace_context两种事件的链路关联。",
  499. "acceptance": [
  500. "可以区分API执行、待发布、排队和worker执行阶段。",
  501. "凭据与商品描述没有进入trace属性。",
  502. "缺少追踪上下文仍能根据业务ID恢复任务。"
  503. ]
  504. },
  505. "intro": {
  506. "scenario": "一个导出先经过 API,过一会儿由另一个进程派发,再由 Worker 执行;请求编号之外还要看耗时分在哪。",
  507. "startingPoint": [
  508. "完成第 02—03 课,以及任务模块持久 outbox 链路;保留一条正常任务的 job_id。",
  509. "只在可回退的隔离库增加追踪列,API、publisher、worker 使用各自实际启动入口,输出分享前脱敏。"
  510. ],
  511. "outcome": "让一次正常与一次延迟派发的导出都能关联 HTTP、数据库、消息与 Worker 阶段。"
  512. }
  513. },
  514. {
  515. "id": "05",
  516. "group": "进程、内存与依赖",
  517. "title": "区分 CPU 忙、事件循环阻塞与线程池等待",
  518. "summary": "对照 CPU、事件循环延迟和相同请求耗时,解释两个阻塞实验并验证恢复。",
  519. "objectives": [
  520. "先定位正确进程并记录基线",
  521. "对照有限 sleep 与计算实验",
  522. "一次只改一个因素验证请求恢复"
  523. ],
  524. "concepts": [
  525. {
  526. "title": "CPU低不代表应用没有阻塞",
  527. "body": "回到“只在隔离副本复现两种阻塞”采集的证据再看。async路由中time.sleep或同步网络等待会阻塞事件循环,但CPU可能很低。CPU持续高则需进一步区分Python计算、序列化与底层库工作。"
  528. },
  529. {
  530. "title": "事件循环延迟显示调度是否及时",
  531. "body": "回到“在隔离应用记录事件循环延迟”采集的证据再看。周期协程实际恢复时间晚于预期,说明事件循环无法及时调度。它是现象指标,仍要结合堆栈、CPU与依赖耗时定位原因。"
  532. },
  533. {
  534. "title": "线程池排队有独立等待成本",
  535. "body": "回到“记录修复前后同样负载”采集的证据再看。阻塞函数移到线程后,线程数不足仍可能排队;无限增加线程会扩大连接和内存消耗。任务数量、工作时间与下游额度共同决定容量。"
  536. }
  537. ],
  538. "steps": [
  539. {
  540. "title": "采集一个明确进程的基线",
  541. "body": "先按部署名称读取MainPID,再确认PID非0、对应本次shop-service。pidstat来自sysstat,若未安装可先用ps。命令只读,不输出环境变量或完整命令行。",
  542. "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",
  543. "lang": "bash",
  544. "expected": "API_PID 非 0 且确认为本次服务,得到 CPU、线程数与运行时间的基线,没有输出环境变量。",
  545. "explanation": "性能数字只有关联到正确进程才有意义;多 worker 时主进程数字还需与真实处理请求的子进程区分。"
  546. },
  547. {
  548. "title": "在隔离应用记录事件循环延迟",
  549. "body": "在隔离 app 的辅助模块保存 watch_loop,并在已有 lifespan 中按代码末尾示例创建和取消任务,保留原数据库等资源管理。使用 contextlib.suppress 前加 import contextlib。不要另建第二个 app 或覆盖原 lifespan 的初始化、清理代码。",
  550. "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",
  551. "lang": "python",
  552. "filename": "loop_watch.py",
  553. "expected": "正常时每轮输出实际 event_loop_lag,停止应用后监测任务被取消并等待结束。",
  554. "explanation": "该值测计划唤醒与实际被调度之间的差,体现事件循环是否及时处理其他任务;它不是某个请求本身的总耗时。"
  555. },
  556. {
  557. "title": "只在隔离副本复现两种阻塞",
  558. "body": "添加受开关控制的实验路由,默认404。开启LAB_ENABLE_FAULTS=1只用于本地副本。分别请求mode=sleep与mode=cpu,同时观察/live、CPU和loop lag;每次工作上限0.5秒,不启动无限循环。",
  559. "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}",
  560. "lang": "python",
  561. "filename": "app/main.py",
  562. "expected": "sleep 和 cpu 实验都可能推高 loop lag 与同时发出的 /live 耗时,cpu 模式通常还出现明显 CPU 使用。",
  563. "explanation": "异步路由里同步 sleep 占着事件循环却不忙算,因此低 CPU 也能阻塞;计算循环既占执行时间也占 CPU。"
  564. },
  565. {
  566. "title": "记录修复前后同样负载",
  567. "body": "sleep改为await asyncio.sleep;同步SDK按异步任务课使用受限线程,CPU工作分块或移到受限进程。保持同样请求数与参数比较,不同时调整worker数量和代码。若需要堆栈,先在隔离环境预先启用faulthandler,不能向未注册信号处理的进程随意发送信号。",
  568. "code": "# 在同样的低并发负载下各记录:\n# CPU%、loop lag、HTTP耗时、线程数量、工作队列等待。\n# 恢复后移除/lab/block,关闭LAB_ENABLE_FAULTS。\n# 不把“线程数增加”本身当作修复证据。",
  569. "lang": "text",
  570. "filename": "cpu-verification.md",
  571. "expected": "相同有限负载下记录修复前后 CPU、loop lag、请求耗时与线程等待,恢复后故障开关关闭且实验路由移除。",
  572. "explanation": "只改一项才能判断改善来自实现变化。受限线程适合同步 I/O,CPU 计算需要不同隔离,单纯增加线程数不构成修复证据。"
  573. }
  574. ],
  575. "checks": [
  576. "sleep阻塞与CPU循环都可增加loop lag,但CPU表现不同。",
  577. "修复使用可等待I/O后,同样负载下/live不再被同步sleep拖住。",
  578. "线程池容量与排队情况有记录,未用无限并发掩盖问题。"
  579. ],
  580. "pitfalls": [
  581. {
  582. "symptom": "看到CPU高就直接增加Uvicorn worker",
  583. "cause": "可能超过CPU配额或放大数据库连接。",
  584. "fix": "先判断可并行部分与资源预算,再做单变量容量实验。"
  585. },
  586. {
  587. "symptom": "对生产进程直接发送SIGUSR1期待堆栈",
  588. "cause": "目标可能未注册处理器,信号行为未确认。",
  589. "fix": "仅在已配置并验证的隔离进程使用诊断信号,优先读取现有指标与日志。"
  590. }
  591. ],
  592. "exercise": {
  593. "task": "描述“CPU低、loop lag高、请求都一起卡住”的首要检查方向。",
  594. "hint": "寻找async调用路径中的同步等待,而非先假定算力不足。",
  595. "answer": "检查time.sleep、同步HTTP/数据库调用、同步文件操作;结合当前堆栈和依赖耗时验证,再改为异步调用或受限线程。"
  596. },
  597. "refs": [
  598. {
  599. "label": "Python asyncio调试",
  600. "url": "https://docs.python.org/3.11/library/asyncio-dev.html"
  601. },
  602. {
  603. "label": "Python faulthandler",
  604. "url": "https://docs.python.org/3.11/library/faulthandler.html"
  605. },
  606. {
  607. "label": "Python执行器",
  608. "url": "https://docs.python.org/3.11/library/concurrent.futures.html"
  609. }
  610. ],
  611. "production": {
  612. "scenario": "高峰期所有API偶尔停顿,主机CPU却没有满。",
  613. "decisions": [
  614. "事件循环延迟与CPU一起观察。",
  615. "诊断信号和分析工具先在隔离环境验证权限与开销。",
  616. "修复时限制线程、进程和下游连接总量。"
  617. ],
  618. "failureDrill": "本地分别注入0.5秒sleep和CPU循环,比较指标后恢复可等待实现。",
  619. "acceptance": [
  620. "能够用证据区分计算和等待。",
  621. "相同负载下修复前后可比较。",
  622. "故障路由默认关闭且最终移除。"
  623. ]
  624. },
  625. "intro": {
  626. "scenario": "商品请求变慢,CPU 却不一定高;用两个半秒实验区分“忙于计算”和“把异步处理卡住”。",
  627. "startingPoint": [
  628. "完成第 02—04 课,已有日志、请求耗时和隔离服务基线。",
  629. "已确认当前 API 进程属于本次副本;故障路由默认关闭,只有本地练习才开启 LAB_ENABLE_FAULTS=1。"
  630. ],
  631. "outcome": "对照 CPU、事件循环延迟和相同请求耗时,解释两个阻塞实验并验证恢复。"
  632. }
  633. },
  634. {
  635. "id": "06",
  636. "group": "进程、内存与依赖",
  637. "title": "区分 Python 分配、RSS 与容器 OOM",
  638. "summary": "比较 Python 分配、进程内存与容器限制,结合退出证据判断下一步排查方向。",
  639. "objectives": [
  640. "为每个内存数值标注来源和单位",
  641. "运行有限分配并比较释放前后快照",
  642. "结合 OOM 标记和计数判断退出原因"
  643. ],
  644. "concepts": [
  645. {
  646. "title": "RSS不等于Python活对象大小",
  647. "body": "回到“运行有限Python分配实验”采集的证据再看。RSS包含解释器、原生库、内存分配器和映射等;对象释放后RSS不一定立即下降。tracemalloc跟踪其启用后可追踪的Python内存分配,不能解释所有原生内存。"
  648. },
  649. {
  650. "title": "容器限制与主机余量不同",
  651. "body": "回到“关联OOM事件与进程退出”采集的证据再看。容器可能因自身memory.max触发OOM,即使主机仍有可用内存。查看cgroup事件和容器退出状态,不能只看free输出。"
  652. },
  653. {
  654. "title": "诊断也需要容量预算",
  655. "body": "回到“设计增长复现与修复对照”采集的证据再看。堆快照、频繁采样和大量日志会增加开销。本课只分配约10MiB演示,不故意耗尽宿主机内存,也不通过drop_caches改变现场。"
  656. }
  657. ],
  658. "steps": [
  659. {
  660. "title": "读取进程与容器口径",
  661. "body": "Linux systemd 环境使用第 05 课确认的 API_PID。若使用 Docker,在部署模块的 Compose 目录运行容器命令,先用 api service 获取唯一容器 ID;多副本时逐个选择。docker inspect 仅取所需字段,不导出含环境变量的完整配置。容器 PID 与宿主 PID 不可直接互换。Docker stats 与 RSS 口径不同,记录时保留来源。",
  662. "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",
  663. "lang": "bash",
  664. "expected": "每条观测标明进程 RSS/VSZ 或 Docker stats/限制,容器 inspect 的 OOMKilled 与 ExitCode 单独记录。",
  665. "explanation": "RSS、容器统计与 Python 分配跟踪覆盖不同范围,单位也可能不同;混在同一曲线会导致错误比较。"
  666. },
  667. {
  668. "title": "运行有限Python分配实验",
  669. "body": "新建memory_probe.py,只在隔离环境执行。tracemalloc在分配前启动;先比before和during,再删除对象观察after。打印分配位置和大小,不打印对象内容。",
  670. "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)",
  671. "lang": "python",
  672. "filename": "memory_probe.py",
  673. "expected": "during 比 before 多出约 10 MiB 的受控分配,释放并回收后对应 Python 分配下降;实际快照数值以本次运行记录为准。",
  674. "explanation": "tracemalloc 跟踪 Python 管理的分配位置,释放对象后进程 RSS 不一定立刻同比下降,解释器或底层分配器可能保留内存页。"
  675. },
  676. {
  677. "title": "关联OOM事件与进程退出",
  678. "body": "在cgroup v2容器内部检查memory.current、memory.max和memory.events;不同部署挂载方式可能不同,先确认cgroup路径。ExitCode=137也可能是人为SIGKILL,需结合OOMKilled和oom_kill计数,不单凭137下结论。",
  679. "code": "# 在已确认使用cgroup v2的隔离容器内:\ncat /sys/fs/cgroup/memory.current\ncat /sys/fs/cgroup/memory.max\ncat /sys/fs/cgroup/memory.events\n# 只读取;不写drop_caches,不修改宿主机内存限制。",
  680. "lang": "bash",
  681. "expected": "读取到实际 cgroup memory.current、memory.max 与 oom_kill 计数,和退出时间/OOMKilled 一起保存。",
  682. "explanation": "137 也可能由人工 SIGKILL 造成;限制、计数增量和 Docker 标记共同支持内存超限判断,宿主有余量不代表容器没触顶。"
  683. },
  684. {
  685. "title": "设计增长复现与修复对照",
  686. "body": "对同样的有限请求批次做多轮快照,观察相同分配位置是否持续增加。若tracemalloc稳定但RSS增长,进一步查原生库、连接缓冲和子进程;不要直接判为Python对象泄漏。修复后比较稳态与峰值。",
  687. "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}",
  688. "lang": "json",
  689. "filename": "memory-record.json",
  690. "expected": "多轮同样请求批次有可比较的 RSS、Python 当前/峰值与 OOM 计数,未采集项保留空而非补预期值。",
  691. "explanation": "持续增长位置支持进一步查引用持有;若 Python 分配稳定而 RSS 上涨,应扩大到原生库、缓冲或子进程,不先断言 Python 泄漏。"
  692. }
  693. ],
  694. "checks": [
  695. "实验分配约10MiB,释放后可追踪活跃分配明显下降。",
  696. "RSS、tracemalloc与容器限制分别记录,未混用单位。",
  697. "OOM结论有事件或状态证据,非仅依据退出码。"
  698. ],
  699. "pitfalls": [
  700. {
  701. "symptom": "删除对象后RSS没降就认定泄漏",
  702. "cause": "分配器可能保留内存供复用,RSS不等于活对象。",
  703. "fix": "比较多轮稳态和tracemalloc增长来源,再判断是否持续无界增长。"
  704. },
  705. {
  706. "symptom": "容器反复退出但主机free正常",
  707. "cause": "容器自身内存上限被触发。",
  708. "fix": "检查memory.max和memory.events,结合OOMKilled与具体负载调整。"
  709. }
  710. ],
  711. "exercise": {
  712. "task": "如果tracemalloc当前值稳定、RSS持续增长,应先收集哪两类信息?",
  713. "hint": "考虑不被tracemalloc完整覆盖的分配与进程范围。",
  714. "answer": "检查原生库/网络缓冲及映射,核对是否包含子进程或容器总内存;同时记录同一负载下的增长曲线,不能直接用gc.collect当修复。"
  715. },
  716. "refs": [
  717. {
  718. "label": "Python tracemalloc",
  719. "url": "https://docs.python.org/3.11/library/tracemalloc.html"
  720. },
  721. {
  722. "label": "Linux cgroup v2",
  723. "url": "https://www.kernel.org/doc/html/latest/admin-guide/cgroup-v2.html"
  724. },
  725. {
  726. "label": "Docker内存限制",
  727. "url": "https://docs.docker.com/engine/containers/resource_constraints/"
  728. }
  729. ],
  730. "production": {
  731. "scenario": "导出运行一段时间后容器重启,需要确认是内存泄漏、峰值超限还是其他终止。",
  732. "decisions": [
  733. "对齐进程、容器和主机三种口径。",
  734. "诊断实验有严格内存上限,禁止耗尽宿主资源。",
  735. "修复依据增长来源,内存上限调整不能替代无界队列修复。"
  736. ],
  737. "failureDrill": "仅运行本课10MiB有限分配脚本,对比快照与RSS;OOM部分使用已有隔离演练证据或标not_run。",
  738. "acceptance": [
  739. "没有人为触发宿主OOM或清理缓存干扰现场。",
  740. "每个内存指标注明单位和范围。",
  741. "结论区分可追踪Python分配与原生/容器内存。"
  742. ]
  743. },
  744. "intro": {
  745. "scenario": "商品服务运行一段时间内存变高,甚至被容器终止;先辨清看的是什么数字,再判断是否泄漏。",
  746. "startingPoint": [
  747. "完成第 05 课,能核对实际 API_PID;容器路线先在正确 Compose 目录定位唯一 api 容器。",
  748. "只做有限 10 MiB Python 分配与只读观测,不修改宿主内存限制或清理系统缓存。"
  749. ],
  750. "outcome": "比较 Python 分配、进程内存与容器限制,结合退出证据判断下一步排查方向。"
  751. }
  752. },
  753. {
  754. "id": "07",
  755. "group": "进程、内存与依赖",
  756. "title": "分开排查 DNS、TCP、TLS 与连接池等待",
  757. "summary": "用同一组本地请求分别观察等待连接名额、连接失败和读取超时,并恢复基线。",
  758. "objectives": [
  759. "从实际调用位置核对名字与端口",
  760. "一次只改一个超时配置观察异常类别",
  761. "用阶段耗时定位 TLS 与网络问题"
  762. ],
  763. "concepts": [
  764. {
  765. "title": "不同阶段失败需要不同证据",
  766. "body": "回到“区分池等待与连接失败”采集的证据再看。DNS失败还没有获得目标地址,TCP连接拒绝通常没有进入HTTP,TLS错误发生在加密连接建立阶段,HTTP504可能来自网关或应用。不能把所有错误都叫网络慢。"
  767. },
  768. {
  769. "title": "localhost属于当前网络命名空间",
  770. "body": "回到“核对解析、监听与目标端口”采集的证据再看。宿主、容器和WSL中的127.0.0.1可能指向不同位置。任务broker宿主映射6384,容器内通常通过broker:6379;缓存宿主6380,不能混用端口。"
  771. },
  772. {
  773. "title": "连接池耗尽也表现为等待",
  774. "body": "回到“限制连接数并分开阶段超时”采集的证据再看。HTTPX PoolTimeout是等待池连接超时,不表示目标服务器连接失败。限制max_connections,并复用客户端,避免每请求建立独立连接池。"
  775. }
  776. ],
  777. "steps": [
  778. {
  779. "title": "核对解析、监听与目标端口",
  780. "body": "命令在发生问题的同一容器或WSL环境执行。getent使用实际依赖主机名;ss只看必要监听与汇总信息。不要打印包含密码的完整DATABASE_URL或Celery URL。",
  781. "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",
  782. "lang": "bash",
  783. "expected": "在正确容器网络能解析 broker,监听与端口符合实际部署;宿主探针记录连接时间与总耗时。",
  784. "explanation": "相同名字在不同网络环境可能无法解析,每个环境的 localhost 也不同;必须在失败调用发生的位置测量。"
  785. },
  786. {
  787. "title": "先准备一个有界的本地慢响应",
  788. "body": "仅在隔离副本 app/main.py 的现有 app 定义之后加入下方临时路由。默认返回 404;按当前部署配置把 LAB_ENABLE_FAULTS=1 只给此练习进程并重启。先运行 curl --max-time 3 \"http://127.0.0.1:8000/slow?delay=1\";若已有同路径路由,先核对并复用等价的有界异步等待,不重复注册。",
  789. "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",
  790. "lang": "python",
  791. "filename": "app/main.py",
  792. "expected": "开关未启用时是 404;仅隔离进程开启后,delay=1 约一秒返回 200 和 {\"delay\":1},超出允许范围得到 422。",
  793. "explanation": "这里先有可重复、时间有界的慢响应,下一步才可能让连接持续占用到池等待超时。await sleep 不阻塞事件循环,因此能同时容纳两个在途测试请求。"
  794. },
  795. {
  796. "title": "限制连接数并分开阶段超时",
  797. "body": "在隔离 shop-service 根目录新建 http_budget.py,使用同一虚拟环境运行 python http_budget.py。先确认上一阶段 /slow?delay=1 成功,脚本仅向本机发五个请求。正式应用复用客户端时应放入已有 lifespan 并在退出关闭,本课先独立运行观察异常类型。",
  798. "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())",
  799. "lang": "python",
  800. "filename": "http_budget.py",
  801. "expected": "五个并发请求中只有最多两个占用连接,等待超过 pool=0.1 的请求可返回 PoolTimeout,已取得连接的请求返回实际状态。",
  802. "explanation": "连接池给连接数量设上限,排队也有独立超时;请求可能尚未连接任何远端就因拿不到名额失败。"
  803. },
  804. {
  805. "title": "区分池等待与连接失败",
  806. "body": "先运行上一步,超过两个同时请求时可能出现PoolTimeout。再把独立脚本base_url改成本地确认未监听的端口,观察ConnectError;恢复后把read改0.1秒观察ReadTimeout。一次只改一项,记录实际异常类型。",
  807. "code": "# 演练记录字段:\n# phase: pool / connect / read\n# configuration_changed:\n# exception_type_observed:\n# elapsed_seconds:\n# restored_to_baseline: false\n# 上例是记录模板,不是已观察到的结果。",
  808. "lang": "text",
  809. "filename": "network-record.md",
  810. "expected": "分别记录 PoolTimeout、未监听端口的 ConnectError,以及过短读取预算产生的 ReadTimeout;每次实验后还原参数。",
  811. "explanation": "不同异常指向不同等待阶段。把所有超时统一加长可能只增加排队,无法修复错误地址或下游响应慢。"
  812. },
  813. {
  814. "title": "只读取必要网络证据",
  815. "body": "若必须定位TLS,用无凭据的测试请求核对证书主机名与链路;不要以verify=False作为修复。抓包只在隔离且无敏感流量的环境按最小范围使用,本模块默认以阶段耗时和连接统计定位,不导出完整HTTP载荷。 完成本课后移除临时 /slow 路由、关闭 LAB_ENABLE_FAULTS 并重启隔离 API,再确认 /live 恢复基线。",
  816. "code": "# curl阶段耗时,可用于已确认的无凭据测试URL:\n# -w 'dns=%{time_namelookup} connect=%{time_connect} tls=%{time_appconnect} first_byte=%{time_starttransfer} total=%{time_total}'\n# 这些是从开始累计的时间,不是可以直接相加的独立阶段耗时。",
  817. "lang": "text",
  818. "filename": "network-observations.md",
  819. "expected": "受控无凭据请求保留 DNS/connect/TLS/首字节时间及真实证书校验结果,不关闭验证。",
  820. "explanation": "curl 时间从开始累计,阶段耗时通常需作差,不能直接相加;TLS 主机名/链错误与 TCP 连接成功可以同时存在。"
  821. }
  822. ],
  823. "checks": [
  824. "错误能定位到解析、连接、读响应或池等待阶段。",
  825. "HTTPX客户端有连接上限且在生命周期结束时关闭。",
  826. "宿主与容器端口映射清楚,日志没有输出带密码的URL。"
  827. ],
  828. "pitfalls": [
  829. {
  830. "symptom": "容器用127.0.0.1:6384连接broker失败",
  831. "cause": "6384是宿主映射,容器localhost不是broker服务。",
  832. "fix": "在同网络容器使用broker:6379,在宿主使用127.0.0.1:6384。"
  833. },
  834. {
  835. "symptom": "PoolTimeout后不断扩容下游",
  836. "cause": "真正等待的是本进程连接池,未确认下游是否瓶颈。",
  837. "fix": "检查连接泄漏、流响应是否关闭、并发需求与池预算,再逐项调整。"
  838. }
  839. ],
  840. "exercise": {
  841. "task": "在两个连接额度下发五个耗时1秒请求,解释为什么pool=0.1可能先失败。",
  842. "hint": "池等待发生在获得连接前,与read=3不是同一个计时阶段。",
  843. "answer": "前两个请求占用连接,后续请求等待超过0.1秒可触发PoolTimeout,不能因此认定下游超过3秒未响应。"
  844. },
  845. "refs": [
  846. {
  847. "label": "HTTPX阶段超时",
  848. "url": "https://www.python-httpx.org/advanced/timeouts/"
  849. },
  850. {
  851. "label": "HTTPX连接限制",
  852. "url": "https://www.python-httpx.org/advanced/resource-limits/"
  853. },
  854. {
  855. "label": "curl时间变量",
  856. "url": "https://curl.se/docs/manpage.html"
  857. }
  858. ],
  859. "production": {
  860. "scenario": "导出调用存储接口时偶发超时,扩容后错误反而增加。",
  861. "decisions": [
  862. "先区分命名空间、网络阶段和连接池等待。",
  863. "连接预算与worker并发一起规划。",
  864. "TLS校验和凭据保密不是排障时可以随意关闭的步骤。"
  865. ],
  866. "failureDrill": "仅对本地/slow控制池上限与read超时,分别观察PoolTimeout与ReadTimeout并恢复配置。",
  867. "acceptance": [
  868. "每种异常有对应证据。",
  869. "客户端连接和流资源会关闭。",
  870. "未通过泄露凭据抓包或禁用证书校验得出结论。"
  871. ]
  872. },
  873. "intro": {
  874. "scenario": "商品服务调用依赖时只显示超时,可能是名字找不到、连接建不起来,也可能根本还没轮到连接。",
  875. "startingPoint": [
  876. "完成第 05—06 课,隔离 API 的 /live 正常,可在同一执行环境运行 HTTPX。",
  877. "本课先增加默认关闭的有界 /slow 练习端点,再只请求本机地址;容器服务名检测必须在对应网络内执行。"
  878. ],
  879. "outcome": "用同一组本地请求分别观察等待连接名额、连接失败和读取超时,并恢复基线。"
  880. }
  881. },
  882. {
  883. "id": "08",
  884. "group": "进程、内存与依赖",
  885. "title": "定位慢 SQL、锁等待与连接池耗尽",
  886. "summary": "复现一次有界行锁等待,读取查询计划与应用池状态,再确认回滚后业务恢复。",
  887. "objectives": [
  888. "用两会话定位谁在等谁",
  889. "分别查看执行计划和池占用",
  890. "恢复后确认事务、等待与商品请求都正常"
  891. ],
  892. "concepts": [
  893. {
  894. "title": "数据库耗时包含多个阶段",
  895. "body": "回到“观察应用连接池预算”采集的证据再看。应用先等连接池,再发送SQL,数据库还可能等锁或I/O。只看到接口慢,不足以判断需要加索引。"
  896. },
  897. {
  898. "title": "连接空闲也可能持有事务",
  899. "body": "回到“恢复后核对锁和业务请求”采集的证据再看。SHOW PROCESSLIST中的Sleep表示当前没有执行语句,不能证明事务已结束。INFORMATION_SCHEMA.INNODB_TRX关联MySQL连接ID;performance_schema.data_lock_waits与data_locks关联InnoDB行锁,metadata_locks用于表结构等元数据锁。"
  900. },
  901. {
  902. "title": "超时作用范围不同",
  903. "body": "回到“在两会话复现有界锁等待”采集的证据再看。innodb_lock_wait_timeout约束InnoDB行锁等待,不是所有锁或所有SQL的总超时。max_execution_time主要约束适用的只读SELECT。EXPLAIN ANALYZE会实际执行查询,只在受控小数据上使用,不把诊断当成零成本。"
  904. }
  905. ],
  906. "steps": [
  907. {
  908. "title": "读取活动会话和等待信息",
  909. "body": "在MySQL8.4练习实例用经授权的诊断账户执行,宿主3307。交互客户端使用-p提示输入密码或权限受控的选项文件。跨账户会话/Performance Schema可见性取决于权限,权限不足不等于没有阻塞;不要为排障把管理员权限授给API。保存连接ID、事务开始时间和等待关系,SQL正文分享前脱敏。",
  910. "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;",
  911. "lang": "sql",
  912. "expected": "能列出自己的连接、活动事务与锁等待;权限不足时记录可见性限制,不把空结果视为完全无阻塞。",
  913. "explanation": "诊断视角受账号权限影响。事务开始时间、持有连接与等待关系比单看 SQL 报错更能定位阻塞来源。"
  914. },
  915. {
  916. "title": "在两会话复现有界锁等待",
  917. "body": "选本机专用商品ID,会话A锁定一行,会话B设置1秒InnoDB锁等待预算再请求同一行。第三个诊断会话可先将B预算改为5秒观察等待关系,之后恢复1秒。默认锁等待超时通常仅回滚失败语句,不能当作事务已经释放;实验结束A/B均显式ROLLBACK。死锁可能回滚整笔事务,重试仍以完整业务事务为边界。",
  918. "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,释放行锁。",
  919. "lang": "sql",
  920. "expected": "会话 B 等待约 1 秒后出现锁等待超时;A 回滚释放锁,实验结束 A/B 都显式 ROLLBACK。",
  921. "explanation": "FOR UPDATE 把同一行更新资格留在事务中。锁等待超时通常只回滚失败语句,不能推断整个事务已结束。"
  922. },
  923. {
  924. "title": "先看计划,再在隔离数据上测执行",
  925. "body": "先看非执行计划。需要EXPLAIN ANALYZE时,只在隔离小表执行只读SELECT并设置会话max_execution_time;该变量不是任意写SQL的硬超时。记录rows、实际迭代次数、索引及访问路径,不能仅凭使用索引就断定足够快。",
  926. "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;",
  927. "lang": "sql",
  928. "expected": "普通 EXPLAIN 返回访问计划,隔离 EXPLAIN ANALYZE 返回实际迭代与耗时,记录索引及行数。",
  929. "explanation": "计划描述打算怎样访问,ANALYZE 还会真实执行查询;即便使用索引,返回量或循环次数过大仍可能慢。"
  930. },
  931. {
  932. "title": "观察应用连接池预算",
  933. "body": "API复用SQLAlchemy AsyncAdaptedQueuePool,每个进程各自拥有池。以下函数只记录数字,不输出连接串;由已有监控采样逻辑调用。池等待需在实际获取连接周围单独计时,checkedout只能反映当前占用。同步任务模块按短事务打开PyMySQL连接,不虚构存在一个任务连接池。",
  934. "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 + 诊断/备份保留连接。",
  935. "lang": "python",
  936. "filename": "pool_observation.py",
  937. "expected": "池快照给出 size、checked_out、overflow 数字,连接获取等待另在实际获取边界计时。",
  938. "explanation": "池占用告诉你连接名额被使用多少,却不等于某条 SQL 执行多久;每个 API 进程都有独立池预算。"
  939. },
  940. {
  941. "title": "恢复后核对锁和业务请求",
  942. "body": "A/B回滚后确认对应连接没有活跃InnoDB事务;再查询行锁等待与未授予元数据锁。Sleep本身不是错误,Sleep且有长事务才需要定位持有者。未经确认不终止他人连接;恢复门槛同时包含业务请求成功和等待消失。",
  943. "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';",
  944. "lang": "sql",
  945. "expected": "演练连接不再持有活跃事务,相关行锁/元数据等待消失,商品读取与修改探针恢复。",
  946. "explanation": "Sleep 只描述连接当前未运行命令,仍可能持有事务;恢复证据应同时包括等待消除与业务成功。"
  947. }
  948. ],
  949. "checks": [
  950. "会话B在受控时间内超时,能关联会话A为阻塞者。",
  951. "回滚后锁释放,读取恢复。",
  952. "应用池等待与数据库锁等待分别记录,没有盲目增大连接数。"
  953. ],
  954. "pitfalls": [
  955. {
  956. "symptom": "API慢但MySQL中没有相应执行语句",
  957. "cause": "请求可能仍在应用连接池等待,尚未向数据库发送SQL。",
  958. "fix": "观察同一API进程的池等待与借还连接,核对总连接预算和持有事务的上游调用。"
  959. },
  960. {
  961. "symptom": "锁等待超时后其他连接仍被阻塞",
  962. "cause": "MySQL默认通常只回滚超时语句,原事务和先前获得的锁可能仍存在。",
  963. "fix": "应用异常路径显式回滚完整业务事务,再以幂等和总预算决定是否重试;检查innodb_rollback_on_timeout实际设置。"
  964. }
  965. ],
  966. "exercise": {
  967. "task": "说明为什么一个API进程池max_size=10,扩成4个进程后数据库连接可能接近40。",
  968. "hint": "进程之间通常不共享同一个连接池对象。",
  969. "answer": "每个进程拥有自己的上限,总预算需加上Celery、publisher、recovery和管理连接;扩worker前先核对数据库容量。"
  970. },
  971. "refs": [
  972. {
  973. "label": "MySQL8.4:InnoDB事务监控",
  974. "url": "https://dev.mysql.com/doc/refman/8.4/en/innodb-information-schema-transactions.html"
  975. },
  976. {
  977. "label": "MySQL8.4:锁等待关系",
  978. "url": "https://dev.mysql.com/doc/refman/8.4/en/performance-schema-data-lock-waits-table.html"
  979. },
  980. {
  981. "label": "MySQL8.4:EXPLAIN",
  982. "url": "https://dev.mysql.com/doc/refman/8.4/en/explain.html"
  983. },
  984. {
  985. "label": "SQLAlchemy连接池",
  986. "url": "https://docs.sqlalchemy.org/en/20/core/pooling.html"
  987. }
  988. ],
  989. "production": {
  990. "scenario": "发布后商品读取突然变慢,数据库CPU却不高。",
  991. "decisions": [
  992. "先查池等待和锁关系,再评估SQL计划。",
  993. "诊断操作设超时,锁实验限定专门商品和可回滚事务。",
  994. "连接上限按全进程总量规划。"
  995. ],
  996. "failureDrill": "使用两条本地数据库连接持锁与超时,再回滚并确认恢复,不终止其他会话。",
  997. "acceptance": [
  998. "等待关系可解释。",
  999. "所有演练事务最终回滚。",
  1000. "没有将EXPLAIN ANALYZE描述为纯静态分析。"
  1001. ]
  1002. },
  1003. "intro": {
  1004. "scenario": "商品更新卡住了,可能是 SQL 本身慢,也可能在等另一笔事务;不要先给所有连接加大超时。",
  1005. "startingPoint": [
  1006. "完成第 07 课,能区分建立连接前的等待与响应读取等待。",
  1007. "准备 MySQL 8.4 独立练习库、已存在的专用商品 ID,以及有观测权限的诊断账号;不使用生产商品或他人连接。"
  1008. ],
  1009. "outcome": "复现一次有界行锁等待,读取查询计划与应用池状态,再确认回滚后业务恢复。"
  1010. }
  1011. },
  1012. {
  1013. "id": "09",
  1014. "group": "队列、实时通信与恢复",
  1015. "title": "排查 Redis 热点、淘汰与缓存降级",
  1016. "summary": "在有限读取下观察缓存失效的数据库增量,并验证回源超时能明确返回而不无限排队。",
  1017. "objectives": [
  1018. "只读核对缓存容量策略与延迟",
  1019. "为回源设置并发和时间上限",
  1020. "对比故障前后命中、数据库请求与错误量"
  1021. ],
  1022. "concepts": [
  1023. {
  1024. "title": "缓存与broker不能用同一恢复策略",
  1025. "body": "回到“安全连接指定缓存实例”采集的证据再看。缓存可按业务降级回源,broker承载待交付消息,不能通过清空键空间恢复。模块统一缓存宿主6380、独立任务broker6384;先核对用途再操作。"
  1026. },
  1027. {
  1028. "title": "累计指标需要按窗口取增量",
  1029. "body": "回到“以增量比较缓存故障前后”采集的证据再看。keyspace_hits和keyspace_misses是累计计数,计算窗口命中率要使用增量。evicted_keys增加表示内存策略淘汰,expired_keys增加表示TTL到期,两者原因不同。"
  1030. },
  1031. {
  1032. "title": "回源必须有容量上限",
  1033. "body": "回到“给缓存访问配置受限回源”采集的证据再看。缓存不可用后所有请求直接打数据库可能造成级联故障。设置缓存短超时、回源并发限制和必要的过载拒绝;多进程总量不能只按单进程Semaphore理解。"
  1034. }
  1035. ],
  1036. "steps": [
  1037. {
  1038. "title": "安全连接指定缓存实例",
  1039. "body": "在Linux/WSL2终端通过read隐藏输入本地缓存密码,不把密码写进-a参数。以下只读命令指向6380;broker6384需要其独立密码,不能复用缓存故障动作。",
  1040. "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",
  1041. "lang": "bash",
  1042. "expected": "缓存 6380 返回 PONG,INFO 可读出命中、未命中、淘汰和内存累计值,未触碰 6384 broker。",
  1043. "explanation": "缓存与任务交付实例职责不同;缓存清空可能仅影响性能,队列清空会破坏待执行工作,必须先核对目标。"
  1044. },
  1045. {
  1046. "title": "检查策略与延迟来源",
  1047. "body": "CONFIG GET是只读但可能被ACL禁止;权限不足需要按观测权限配置处理,不绕过认证。SLOWLOG可能包含命令参数,先查长度;只有无敏感数据的隔离实例才查看具体条目。不要运行KEYS *或MONITOR扫描实际业务内容。",
  1048. "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",
  1049. "lang": "bash",
  1050. "expected": "记录 maxmemory/policy、延迟事件和慢日志长度;ACL 拒绝时记录权限限制,不尝试绕过。",
  1051. "explanation": "淘汰策略决定内存满时移除什么,慢日志只覆盖某些执行慢命令;客户端等待还可能发生在网络或连接阶段。"
  1052. },
  1053. {
  1054. "title": "给缓存访问配置受限回源",
  1055. "body": "下面函数使用已有redis异步客户端和read_db异步函数,客户端需设置socket_connect_timeout/socket_timeout,例如各0.2秒。缺失或连接失败时回源,缓存写失败不改变已成功读取的数据。此示例只用于单进程容量实验;真实权限必须进入缓存key或查询范围。",
  1056. "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",
  1057. "lang": "python",
  1058. "filename": "cache_fallback.py",
  1059. "expected": "缓存命中直接返回;缺失或缓存异常时最多 4 个本进程回源并发,获取名额与读库合计受 1 秒预算控制。",
  1060. "explanation": "缓存作为可选加速层,故障可回源但不能无限挤占数据库;Semaphore 只约束当前进程,多个进程需重新核算总量。"
  1061. },
  1062. {
  1063. "title": "以增量比较缓存故障前后",
  1064. "body": "仅停止本地缓存实例,保持broker和数据库运行,低并发读取相同商品。记录缓存异常、数据库请求数、池等待和HTTP状态;恢复缓存后观察命中回升。回源超时应由API转换为明确503,不返回虚构商品或无限等待。",
  1065. "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}",
  1066. "lang": "json",
  1067. "filename": "cache-drill.json",
  1068. "expected": "停缓存后 miss/异常与数据库请求增量上升;容量不足时明确 503,恢复后命中回升,任务 broker 持续工作。",
  1069. "explanation": "INFO 多为累计值,应比较同长度窗口增量。允许部分请求快速失败可保护数据库,而虚构商品或无限等都掩盖真实状态。"
  1070. }
  1071. ],
  1072. "checks": [
  1073. "实例地址与用途明确,6384broker未被缓存故障动作影响。",
  1074. "窗口命中率使用计数增量,能区分TTL到期与内存淘汰。",
  1075. "缓存故障时回源有时间与并发预算,过载明确失败。"
  1076. ],
  1077. "pitfalls": [
  1078. {
  1079. "symptom": "缓存故障后数据库也被打满",
  1080. "cause": "无上限回源造成级联放大。",
  1081. "fix": "先限制回源并发和请求速率,必要时拒绝非关键读取。"
  1082. },
  1083. {
  1084. "symptom": "对broker执行缓存清理",
  1085. "cause": "混淆实例用途,可能删除未处理消息。",
  1086. "fix": "独立实例与凭据,先核对地址和角色,不以flushdb/purge作为排障默认动作。"
  1087. }
  1088. ],
  1089. "exercise": {
  1090. "task": "命中100、未命中20增至命中140、未命中30,计算该窗口命中率。",
  1091. "hint": "使用变化量40和10,不使用启动以来累计值。",
  1092. "answer": "窗口命中率=40/(40+10)=80%;同时记录窗口请求量50,不能把低样本波动误判为长期趋势。"
  1093. },
  1094. "refs": [
  1095. {
  1096. "label": "Redis INFO",
  1097. "url": "https://redis.io/docs/latest/commands/info/"
  1098. },
  1099. {
  1100. "label": "Redis淘汰策略",
  1101. "url": "https://redis.io/docs/latest/develop/reference/eviction/"
  1102. },
  1103. {
  1104. "label": "Redis延迟排查",
  1105. "url": "https://redis.io/docs/latest/operate/oss_and_stack/management/optimization/latency/"
  1106. }
  1107. ],
  1108. "production": {
  1109. "scenario": "热门商品读取突然变慢,缓存命中下降且数据库连接开始排队。",
  1110. "decisions": [
  1111. "先确认是过期、淘汰、热点还是Redis不可用。",
  1112. "缓存降级需要保护数据库容量。",
  1113. "任务broker与缓存使用独立实例和恢复手册。"
  1114. ],
  1115. "failureDrill": "只暂停本地缓存6380,以有限并发请求观察回源,再恢复并核对命中和数据库负载。",
  1116. "acceptance": [
  1117. "没有清空broker或扫描敏感键值。",
  1118. "降级不突破数据库预算。",
  1119. "缓存恢复后业务结果仍与数据库一致。"
  1120. ]
  1121. },
  1122. "intro": {
  1123. "scenario": "缓存停止后商品仍可能从数据库读到,但大量请求一起回源会把数据库拖慢。",
  1124. "startingPoint": [
  1125. "完成 Redis 缓存课程和本模块第 08 课,缓存6380与任务 broker6384已分开。",
  1126. "准备同一组练习商品与正常命中基线,只使用缓存实例的独立密码,不对任务 broker 执行缓存故障操作。"
  1127. ],
  1128. "outcome": "在有限读取下观察缓存失效的数据库增量,并验证回源超时能明确返回而不无限排队。"
  1129. }
  1130. },
  1131. {
  1132. "id": "10",
  1133. "group": "队列、实时通信与恢复",
  1134. "title": "定位 Celery 积压、重试风暴与租约恢复",
  1135. "summary": "沿一条持久任务定位积压阶段,按有限恢复机制推进,并观察到任务与文件真正完成。",
  1136. "objectives": [
  1137. "并排核对派发记录、队列和租约",
  1138. "区分消息重复到达与真正再次执行",
  1139. "修复原因后受控恢复并持续检查"
  1140. ],
  1141. "concepts": [
  1142. {
  1143. "title": "积压可能发生在broker之前",
  1144. "body": "回到“把数据库阶段并排查询”采集的证据再看。export_outbox未发布也属于用户等待;只看Celery active会漏掉publisher故障。queued任务即使暂时没有pending outbox,也可能已在broker或存在消息丢失窗口,需要派发记录和恢复规则判断。"
  1145. },
  1146. {
  1147. "title": "重试与重投有不同来源",
  1148. "body": "回到“按一条任务核对重试链”采集的证据再看。任务主动retry可能带退避;broker重复交付可能携带旧消息。持久attempts、next_attempt_at、deadline和领取CAS必须一起限制,不能只看Celery request.retries。"
  1149. },
  1150. {
  1151. "title": "恢复先处理原因,再处理队列",
  1152. "body": "回到“恢复一个有证据的本地故障”采集的证据再看。下游仍不可用时增加worker或批量重放会放大负载。先停止错误输入或恢复依赖,再按有限批次处理到期任务;不要默认purge清空消息或强杀所有worker。"
  1153. }
  1154. ],
  1155. "steps": [
  1156. {
  1157. "title": "读取worker与路由状态",
  1158. "body": "使用部署时相同的配置来源。以下 Bash 函数用于 Linux/systemd 路线,由 systemd 读取 /etc/shop-tasks.env;若采用 Docker,在 Compose 工程目录用注释中的函数替换 run_task 定义,再执行相同检查。保持此终端供本课恢复步骤复用。inspect 可能包含任务参数,分享前核对输出。",
  1159. "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",
  1160. "lang": "bash",
  1161. "expected": "inspect 的注册任务、活动队列与部署设置一致,能分别定位 fast/bulk 和派发/恢复角色。",
  1162. "explanation": "Worker 在线只说明消费者可响应检查,不证明消息已发出或队列路由正确;必须沿实际配置来源检查。"
  1163. },
  1164. {
  1165. "title": "把数据库阶段并排查询",
  1166. "body": "使用SQL同时看业务状态、待发布事件和过期租约。queued很老只是排查线索,不足以直接断言消息已经丢失;按异步任务模块的派发记录与恢复门槛处理。",
  1167. "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<UTC_TIMESTAMP(6)\nORDER BY lease_until LIMIT 20;",
  1168. "lang": "sql",
  1169. "expected": "同屏记录 queued/running 等状态数量、最老未发事件与过期租约,能判断首先该追哪一阶段。",
  1170. "explanation": "待发记录可在消息进入 broker 前积压,queued 老也可能是发布、路由或恢复条件问题,不能直接下“消息丢了”结论。"
  1171. },
  1172. {
  1173. "title": "按一条任务核对重试链",
  1174. "body": "从实际失败日志选定一个本地job_id,查询其事件ID、attempts和状态转换。区分多次消息到达与多次成功领取;重复消息到达不应绕过next_attempt_at或为活跃租约增加执行副作用。不要只看同一task_id的最终缓存值。",
  1175. "code": "-- 将参数绑定为本次演练job_id,避免拼接外部输入。\n-- SELECT id,status,attempts,deadline_at,lease_token,lease_until,error_code\n-- FROM export_jobs WHERE id=%s;\n-- SELECT id,job_id,attempts,sent_at,available_at\n-- FROM export_outbox WHERE job_id=%s ORDER BY created_at;\n-- 如果已应用任务模块派发与退避字段,同时核对相应时间条件。",
  1176. "lang": "sql",
  1177. "expected": "一条 job 的事件、attempts、deadline、lease_token 与日志相互对应,可分清消息多次到达和真正领取次数。",
  1178. "explanation": "交付重投与业务执行重试来源不同;重复消息不应绕过退避或让活跃租约多执行一次副作用。"
  1179. },
  1180. {
  1181. "title": "恢复一个有证据的本地故障",
  1182. "body": "先确认下游恢复、容量可用和代码修复,再运行任务模块有限批次recovery与publisher。单步脚本不常驻;部署中的循环wrapper有独立节奏与停机管理。失败耗尽任务仅在确认修复后用replay_job逐条生成新job,保留旧失败证据。 在第一步已定义 run_task 的同一 Bash 终端执行;新终端先重新定义所选部署路线的函数,确保配置和工作目录一致。",
  1183. "code": "run_task recovery.py\nrun_task publisher.py\n# 对已修复且明确的 failed 任务:\n# run_task replay_job.py <本次已核对的失败job_id>\n# 不运行 celery purge,不通过重置 attempts 解除失败限制。",
  1184. "lang": "bash",
  1185. "expected": "有限 recovery/publisher 正常结束,符合条件的任务继续推进;耗尽失败任务只有确认修复后才逐条生成新 job。",
  1186. "explanation": "恢复先消除下游故障,再受容量和次数限制推进。清空队列或重置 attempts 会丢失证据并绕过保护。"
  1187. },
  1188. {
  1189. "title": "验证恢复不是短暂下降",
  1190. "body": "恢复后持续观察最老等待、完成速率、重试次数和下游错误,覆盖至少一个任务截止时间与恢复扫描周期。增加worker后若数据库池等待上升,应停止扩大并发并重新核算预算。",
  1191. "code": "# 恢复验收记录:\n# 影响队列:\n# 下游修复证据:\n# 受控恢复任务ID:\n# 最老等待变化:\n# 重试/重投变化:\n# 新导出完成结果:\n# 未恢复任务及原因:\n# result: not_run",
  1192. "lang": "text",
  1193. "filename": "queue-recovery.md",
  1194. "expected": "覆盖截止时间与扫描周期后,最老等待持续下降、完成速率恢复、重试未继续放大,新文件验收成功。",
  1195. "explanation": "一次队列长度下降可能只是转入重试或正在执行,不能说明恢复稳定;需要持久状态、下游错误和产出一起看。"
  1196. }
  1197. ],
  1198. "checks": [
  1199. "能判断积压发生在outbox、broker、worker还是业务失败隔离。",
  1200. "重投和重试没有绕过持久尝试预算与退避时间。",
  1201. "恢复有下游正常和最终结果证据,旧失败记录被保留。"
  1202. ],
  1203. "pitfalls": [
  1204. {
  1205. "symptom": "worker在线但没有任务执行",
  1206. "cause": "路由队列不匹配、publisher未发布或任务未注册。",
  1207. "fix": "核对active_queues、registered和outbox,再查执行容量。"
  1208. },
  1209. {
  1210. "symptom": "重试风暴时继续增加worker",
  1211. "cause": "下游不可用,更多worker只产生更多失败。",
  1212. "fix": "先止住重复输入或恢复依赖,保留退避、总预算和入口容量。"
  1213. }
  1214. ],
  1215. "exercise": {
  1216. "task": "outbox数量上升、worker空闲、broker可连接时,优先查哪两个位置?",
  1217. "hint": "消息可能没有被publisher成功发送,或发到了无人消费的队列。",
  1218. "answer": "检查publisher错误/领取租约/标记过程,以及outbox queue_name与worker active_queues是否一致;不能直接判断worker算力不足。"
  1219. },
  1220. "refs": [
  1221. {
  1222. "label": "Celery监控",
  1223. "url": "https://docs.celeryq.dev/en/stable/userguide/monitoring.html"
  1224. },
  1225. {
  1226. "label": "Celery优化",
  1227. "url": "https://docs.celeryq.dev/en/stable/userguide/optimizing.html"
  1228. },
  1229. {
  1230. "label": "Celery Redis交付",
  1231. "url": "https://docs.celeryq.dev/en/stable/getting-started/backends-and-brokers/redis.html"
  1232. }
  1233. ],
  1234. "production": {
  1235. "scenario": "任务队列积压,团队准备增加worker,但导出持续重试。",
  1236. "decisions": [
  1237. "按阶段定位,数据库状态为业务真源。",
  1238. "下游恢复前不通过并发扩容放大失败。",
  1239. "恢复按持久租约、退避和次数预算执行,重放生成新业务ID。"
  1240. ],
  1241. "failureDrill": "仅暂停本地publisher后提交,再恢复;另让隔离worker一次失败,对照两种积压阶段和恢复路径。",
  1242. "acceptance": [
  1243. "未清空消息掩盖故障。",
  1244. "重复交付不能越过状态约束。",
  1245. "恢复后新旧导出均有明确结果或失败去向。"
  1246. ]
  1247. },
  1248. "intro": {
  1249. "scenario": "商品导出长时间排队,Worker 看似在线;先判断堵在派发前、等待消费,还是失败后等待恢复。",
  1250. "startingPoint": [
  1251. "完成任务模块最终状态机与部署整合,至少保留一个真实 job_id 和正常导出结果。",
  1252. "已完成本模块日志和数据库等待课程,选定 Linux/systemd 或 Docker 配置来源,所有恢复限定到隔离任务。"
  1253. ],
  1254. "outcome": "沿一条持久任务定位积压阶段,按有限恢复机制推进,并观察到任务与文件真正完成。"
  1255. }
  1256. },
  1257. {
  1258. "id": "11",
  1259. "group": "队列、实时通信与恢复",
  1260. "title": "端到端排查 SSE 缓冲与 WebSocket 断开",
  1261. "summary": "把延迟定位到应用、代理或浏览器一层,并验证主动停止后不再重连或占用服务资源。",
  1262. "objectives": [
  1263. "测量第一条业务数据到达时间",
  1264. "用相同请求比较直连和代理",
  1265. "检查升级、消息、关闭与服务器清理"
  1266. ],
  1267. "concepts": [
  1268. {
  1269. "title": "流式发送不保证每段立刻被用户看到",
  1270. "body": "回到“保持请求不变对比代理”采集的证据再看。服务端生成、ASGI发送、代理转发、客户端读取和页面渲染都可能缓冲。先比较各段链路,再修改对应配置。"
  1271. },
  1272. {
  1273. "title": "SSE与WebSocket的代理要求不同",
  1274. "body": "回到“只修改隔离代理中的对应location”采集的证据再看。SSE是持续HTTP响应,关注缓冲、压缩和读超时;WebSocket在HTTP/1.1握手时需要正确升级头,之后关注空闲超时与心跳。不能用普通GET成功证明WebSocket链路正常。"
  1275. },
  1276. {
  1277. "title": "断开与重连属于业务行为",
  1278. "body": "回到“核对主动停止与真实清理”采集的证据再看。用户停止后应关闭连接和定时器,不自动重新生成同一内容。网络断开可有限退避重连,但不自动重发未经确认的业务消息。"
  1279. }
  1280. ],
  1281. "steps": [
  1282. {
  1283. "title": "直连API测首段到达",
  1284. "body": "新建stream_probe.py,只发送hello测试文本。按实际到达data行记录时间,不能用curl time_starttransfer代替首个业务片段,因为响应头可能很早已发送。",
  1285. "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\")",
  1286. "lang": "python",
  1287. "filename": "stream_probe.py",
  1288. "expected": "直连输出 HTTP 状态、text/event-stream 类型及每条 data 的实际到达秒数;404 时先回到端点整合检查。",
  1289. "explanation": "HTTP 响应头到达可能早于第一条业务事件,首字节时间不能替代用户何时开始看到内容。"
  1290. },
  1291. {
  1292. "title": "保持请求不变对比代理",
  1293. "body": "按Docker/Linux模块的实际代理端口修改base_url;下面8080仅表示本地演练映射。若直连逐段、代理一次到达,检查代理缓冲;若两者正常而浏览器延后,查response.text/json或渲染缓存。",
  1294. "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;代理端口以本地部署配置为准。",
  1295. "lang": "bash",
  1296. "expected": "同一请求保留直连与代理两组到达记录;只有代理批量到达时,问题范围才缩小到代理及其传输行为。",
  1297. "explanation": "控制请求内容和客户端读取方式才能比较;如果两条路径都正常,浏览器一次性读完整响应也会产生假缓冲现象。"
  1298. },
  1299. {
  1300. "title": "只修改隔离代理中的对应location",
  1301. "body": "以下片段放入已有nginx实验配置。示例代理与API同宿主;容器部署用实际API服务名替换127.0.0.1。先nginx -t通过,再按部署模块重载隔离代理。proxy_read_timeout是两次读取间隔限制,不是总请求时长。",
  1302. "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# }",
  1303. "lang": "nginx",
  1304. "filename": "nginx-lab.conf",
  1305. "expected": "nginx -t 通过后重载,SSE location 关闭缓冲,WS location 带升级头,实际 upstream 对应当前部署位置。",
  1306. "explanation": "SSE 继续使用 HTTP 响应,WS 需要协议升级;空闲读取预算与心跳应配合,不能用一个通用超时解释两类连接。"
  1307. },
  1308. {
  1309. "title": "在浏览器核对升级和关闭",
  1310. "body": "在隔离页面Console连接已确认的代理WS地址,观察Network中的握手与Frames/Messages。HTTPS页面使用wss。收到open后发送hello和ping,再主动关闭;按API课程有限退避逻辑验证重连。",
  1311. "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\");",
  1312. "lang": "javascript",
  1313. "filename": "浏览器Console",
  1314. "expected": "Network 显示 WS 握手 101,hello/ping 得到实际应用响应,主动关闭后能记录关闭码及原因。",
  1315. "explanation": "握手成功只证明升级建立,之后消息协议、心跳与关闭都有独立规则;HTTPS 页面还必须使用安全连接地址。"
  1316. },
  1317. {
  1318. "title": "核对主动停止与真实清理",
  1319. "body": "SSE收到done后关闭EventSource;fetch用AbortController停止读取,服务端取消后释放资源。WebSocket主动停止后等待超过最大重连间隔,确认无新连接。恢复所有临时代理配置,再跑一次完整SSE和echo。",
  1320. "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",
  1321. "lang": "text",
  1322. "filename": "stream-verification.md",
  1323. "expected": "主动停止后超过最大重连间隔没有新连接,服务端释放相关资源;最终完整 SSE 与 echo 再次成功。",
  1324. "explanation": "客户端不再显示消息不代表服务器工作已停止;取消与清理需要两端证据,重连策略也应区分主动停止和异常断线。"
  1325. }
  1326. ],
  1327. "checks": [
  1328. "直连、代理、浏览器三段各有独立到达记录。",
  1329. "WebSocket能完成握手与echo/pong,不仅是普通HTTP返回200。",
  1330. "主动停止后没有意外重连或重复生成,服务端清理可观察。"
  1331. ],
  1332. "pitfalls": [
  1333. {
  1334. "symptom": "终端逐段显示,网页一次显示全部",
  1335. "cause": "前端调用了response.text/json或等待完整文本再渲染。",
  1336. "fix": "使用流式读取和按协议边界解析,分段更新UI。"
  1337. },
  1338. {
  1339. "symptom": "WebSocket连接约固定时间断开",
  1340. "cause": "代理空闲超时或心跳缺失可能触发,但需结合日志验证。",
  1341. "fix": "核对代理读超时与实际消息间隔,采用有界应用心跳和停止规则。"
  1342. }
  1343. ],
  1344. "exercise": {
  1345. "task": "如果response headers立刻到达、三条data都在1.2秒同时出现,能断言服务器没有yield吗?",
  1346. "hint": "中间层或客户端可能缓冲,先做直连对照。",
  1347. "answer": "不能。比较直连data到达时间与代理结果,再检查生成器、代理缓冲和客户端读取;响应头时间不证明每段业务数据的传输情况。"
  1348. },
  1349. "refs": [
  1350. {
  1351. "label": "Nginx代理缓冲",
  1352. "url": "https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_buffering"
  1353. },
  1354. {
  1355. "label": "Nginx WebSocket",
  1356. "url": "https://nginx.org/en/docs/http/websocket.html"
  1357. },
  1358. {
  1359. "label": "HTTPX流读取",
  1360. "url": "https://www.python-httpx.org/quickstart/"
  1361. },
  1362. {
  1363. "label": "MDN EventSource",
  1364. "url": "https://developer.mozilla.org/en-US/docs/Web/API/EventSource"
  1365. }
  1366. ],
  1367. "production": {
  1368. "scenario": "本地API流式正常,部署后文本一次出现或WebSocket频繁断开。",
  1369. "decisions": [
  1370. "按直连、代理、客户端逐段定位。",
  1371. "配置变更限定相关location,测试语法后再重载隔离实例。",
  1372. "重连不会自动重复不可逆业务消息。"
  1373. ],
  1374. "failureDrill": "只在隔离代理开启/关闭响应缓冲做对照,随后测试空闲与主动停止两类断开。",
  1375. "acceptance": [
  1376. "到达时间来自真实业务片段。",
  1377. "代理与浏览器行为分别验证。",
  1378. "故障配置恢复,停止逻辑无残留连接。"
  1379. ]
  1380. },
  1381. "intro": {
  1382. "scenario": "导出进度直连会逐条刷新,过代理却一次出现;WebSocket 也可能握手成功后反复断开。",
  1383. "startingPoint": [
  1384. "完成 Linux 第 06 课或同等代理配置,隔离 API 已整合 /stream 和 /ws,直连客户端曾成功。",
  1385. "核对真实代理端口和路由,示例 8080 不能代替实际地址;只发送 hello/ping,不使用真实凭据或商品内容。"
  1386. ],
  1387. "outcome": "把延迟定位到应用、代理或浏览器一层,并验证主动停止后不再重连或占用服务资源。"
  1388. }
  1389. },
  1390. {
  1391. "id": "12",
  1392. "group": "队列、实时通信与恢复",
  1393. "title": "完成故障演练、恢复门槛与 runbook",
  1394. "summary": "完成限定故障的时间线、恢复验证与证据索引,并产出可执行改进项。",
  1395. "objectives": [
  1396. "为每次演练写开始与停止条件",
  1397. "一次只注入一个故障并检查恢复",
  1398. "把复盘发现变成可验证的修改工作"
  1399. ],
  1400. "concepts": [
  1401. {
  1402. "title": "演练要有开始与结束条件",
  1403. "body": "回到“定义停止与恢复门槛”采集的证据再看。每个故障限定实例、任务ID、持续时间和恢复方法。达到预定风险边界就停止,不能为了复现而无限增加负载。"
  1404. },
  1405. {
  1406. "title": "恢复门槛应覆盖业务与数据",
  1407. "body": "回到“按时间线只执行一个故障”采集的证据再看。服务恢复包括新请求正常、积压受控、已受理任务去向清楚、无错误发布结果。重启成功或监控颜色变绿只是部分证据。"
  1408. },
  1409. {
  1410. "title": "复盘区分触发因素与系统缺口",
  1411. "body": "回到“输出复盘行动项与证据索引”采集的证据再看。记录什么触发、为什么扩大、哪些控制有效、哪里缺少观测或恢复能力。行动项要有负责人、验收条件和优先级,不只写“加强监控”。"
  1412. }
  1413. ],
  1414. "steps": [
  1415. {
  1416. "title": "准备隔离演练清单",
  1417. "body": "每个案例用全新测试数据,先保存无故障基线。下面矩阵是待执行计划,不代表已经发生事故或完成测试。禁止把生产凭据、真实商品描述放入公开材料。",
  1418. "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时间对照 | 恢复缓冲配置 |",
  1419. "lang": "text",
  1420. "filename": "drill-plan.md",
  1421. "expected": "演练矩阵列出单一受限故障、主要证据与恢复动作,每项先有当前基线,新测试数据与真实业务隔离。",
  1422. "explanation": "先能恢复再注入,才不会把演练变成无边界试错;模板描述计划,不代表故障或测试已经发生。"
  1423. },
  1424. {
  1425. "title": "定义停止与恢复门槛",
  1426. "body": "将以下阈值改为本地机器可承受值,再执行。示例阈值只用于练习,不作为线上SLO建议。超出范围停止故障注入并回到基线,不继续加压寻找更大异常。",
  1427. "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}",
  1428. "lang": "json",
  1429. "filename": "drill-boundary.json",
  1430. "expected": "请求数、持续时间和分配量上限改成机器可承受值,目标不明或恢复失败等停止条件明确。",
  1431. "explanation": "开始、停止与恢复三条边界限制影响范围;碰到停止条件应撤回本次故障,不继续加压寻找更严重现象。"
  1432. },
  1433. {
  1434. "title": "按时间线只执行一个故障",
  1435. "body": "每次记录UTC时间、目标、request_id/job_id、配置前值、操作、指标变化和恢复前值。日志示例标synthetic,实际输出标observed。任何未执行项保留not_run,不能根据理论预计填写成功。",
  1436. "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}",
  1437. "lang": "json",
  1438. "filename": "drill-result.json",
  1439. "expected": "每次时间线只含一个正在注入的故障,实际输出标 observed,示例标 synthetic,未做项目保持 not_run。",
  1440. "explanation": "分开证据类型可避免把教材预期误作实测结论;记录配置前值让恢复动作有明确目标。"
  1441. },
  1442. {
  1443. "title": "用固定结构编写runbook",
  1444. "body": "每条runbook提供症状、只读检查、分支判断、限定动作、回滚和验收。命令中的PID、容器名、数据库和端口必须先核对;不要放“重启全部”“清空队列”之类无范围操作。",
  1445. "code": "# Runbook:导出已受理但长期未完成\n## 识别\n记录job_id、受理时间、当前数据库状态。\n## 只读检查\noutbox未发布?队列有人消费?租约有效?attempts/deadline是否耗尽?\n## 分支\n未发布→查publisher;无人消费→查路由;租约过期→按恢复规则处理。\n## 动作\n只处理已确认的本地实例/任务,保持次数、退避与容量约束。\n## 回滚\n恢复本次修改前配置,停止新故障输入。\n## 验收\n核对新任务、旧任务与结果文件;未恢复项明确列出。",
  1446. "lang": "text",
  1447. "filename": "runbook-export.md",
  1448. "expected": "runbook 有症状、只读检查、判断分支、限定动作、回滚与验收,示例任务能沿派发/消费/租约路径定位。",
  1449. "explanation": "手册不是命令列表;分支依据把观察到的事实连到有范围的动作,避免同一症状被统一重启或清队列处理。"
  1450. },
  1451. {
  1452. "title": "输出复盘行动项与证据索引",
  1453. "body": "复盘不只记录最后一个报错。为触发原因、扩大因素、恢复机制和观测缺口分别填写证据,并把修复项写成可验收工作。示例中的行动项需要实际演练支持后才采用。",
  1454. "code": "| 类型 | 发现 | 证据 | 行动项 | 验收 |\n|---|---|---|---|---|\n| 触发 | 待实际观测 | 时间线链接 | 待定 | 可重复复现 |\n| 扩大因素 | 待实际观测 | 指标/trace | 待定 | 相同故障影响受限 |\n| 恢复缺口 | 待实际观测 | 任务状态 | 待定 | 恢复时间可测 |\n| 观测缺口 | 待实际观测 | 缺失字段 | 待定 | 可定位具体阶段 |",
  1455. "lang": "text",
  1456. "filename": "postmortem-actions.md",
  1457. "expected": "触发、扩大因素、恢复缺口与观测缺口都有证据链接,行动项有可重复的验收标准。",
  1458. "explanation": "修复最后一个报错未必阻止下次扩大;复盘要改变可验证的系统行为,无法由本次证据支持的结论保留待查。"
  1459. }
  1460. ],
  1461. "checks": [
  1462. "每次故障均有范围、停止条件和实际恢复记录。",
  1463. "恢复验证覆盖新请求、旧任务与输出一致性。",
  1464. "runbook有分支与验收,复盘行动项能被具体检查。"
  1465. ],
  1466. "pitfalls": [
  1467. {
  1468. "symptom": "演练结束但临时故障开关还开着",
  1469. "cause": "没有记录配置前值和恢复清单。",
  1470. "fix": "每次演练最后核对开关、代理配置、进程和事务,跑无故障回归。"
  1471. },
  1472. {
  1473. "symptom": "复盘只写某人误操作",
  1474. "cause": "忽略缺少权限边界、幂等、容量或观测导致的扩大。",
  1475. "fix": "把触发因素和系统控制缺口分别写清,并给可验收修复项。"
  1476. }
  1477. ],
  1478. "exercise": {
  1479. "task": "把“加强监控”改成一个可验收行动项。",
  1480. "hint": "指定指标、范围、触发条件、关联信息和验证方式。",
  1481. "answer": "例如:为export_outbox最老未发布年龄增加告警,限定服务标签,持续超过本地设定阈值时提供publisher日志入口;用暂停本地publisher的演练验证触发与恢复,数值按实际业务基线设置。"
  1482. },
  1483. "refs": [
  1484. {
  1485. "label": "Google SRE复盘",
  1486. "url": "https://sre.google/sre-book/postmortem-culture/"
  1487. },
  1488. {
  1489. "label": "Google SRE事故管理",
  1490. "url": "https://sre.google/sre-book/managing-incidents/"
  1491. },
  1492. {
  1493. "label": "Google SRE有效排障",
  1494. "url": "https://sre.google/sre-book/effective-troubleshooting/"
  1495. }
  1496. ],
  1497. "production": {
  1498. "scenario": "将shop-service交付维护,希望另一位开发者能按文档定位并恢复常见故障。",
  1499. "decisions": [
  1500. "演练在独立环境,目标和恢复动作先明确。",
  1501. "证据与预计行为分开,未运行不标通过。",
  1502. "复盘行动项落实到观测、容量、一致性和恢复能力。"
  1503. ],
  1504. "failureDrill": "逐项执行本课矩阵,每次恢复基线后才进入下一项,最终检查所有临时开关与事务已关闭。",
  1505. "acceptance": [
  1506. "交接者可按runbook走到具体分支。",
  1507. "已受理任务没有不明去向。",
  1508. "所有完成声明有实际记录,未执行部分明确列出。"
  1509. ]
  1510. },
  1511. "intro": {
  1512. "scenario": "把前面学过的定位方法变成别人能照做的故障手册,需要证明既能复现问题,也能完整恢复。",
  1513. "startingPoint": [
  1514. "完成第 01—11 课中实际采用的能力,并记录哪些扩展未安装。",
  1515. "保留无故障的商品读写、导出和文件基线;使用全新练习数据、独立副本和明确恢复命令。"
  1516. ],
  1517. "outcome": "完成限定故障的时间线、恢复验证与证据索引,并产出可执行改进项。"
  1518. }
  1519. }
  1520. ]