troubleshooting-lessons.md 72 KB

线上问题排查

术核 AI · 后端工程课件

01 先确认影响范围,再选择止损动作

建立本地事故演练记录,区分接口故障、导出积压和实例不可用。

本节目标

  • 用用户可观察行为定义影响范围
  • 记录统一时间线和已验证事实
  • 为止损动作写出预期、回滚与恢复门槛

必要概念

错误数量不能单独决定影响

一个导出接口失败和全部笔记读取失败有不同影响。记录受影响操作、用户范围、开始时间、成功率和延迟,再确定处理优先级。

先区分事实与假设

“新增请求都超时”是观测,“数据库挂了”是待验证假设。每次只改变一个因素,避免多个操作互相覆盖证据。

恢复服务与定位根因分开记录

止损可以是暂停大导出受理、回退本次配置或缩小流量;它不自动证明根因。恢复后继续核对已受理任务和数据一致性。

动手实现

1. 建立独立演练目录与时间基线

本模块在部署模块建立的 notes-service 隔离副本上演练;Linux 单元为 notes-api.service,基础端点为 /live(进程存活)与 /ready(依赖就绪)。先在副本新建 diagnostics_lab。Linux/WSL2 命令只针对隔离目标;容器部署从 Compose 目录使用 docker compose ps 定位 service,不猜容器名。第 11 课的 SSE/WS 是 API 模块扩展路由,须先确认已加入副本。

文件:终端 / 当前课时

mkdir -p diagnostics_lab
date -u +%FT%TZ
systemctl show notes-api.service -p ActiveState -p SubState -p MainPID
curl -sS --connect-timeout 1 --max-time 3 -o /dev/null -w "live status=%{http_code} total=%{time_total}\n" http://127.0.0.1:8000/live
curl -sS --connect-timeout 1 --max-time 3 -o /dev/null -w "ready status=%{http_code} total=%{time_total}\n" http://127.0.0.1:8000/ready

2. 建立影响矩阵

保存 incident.md,把每格填成实际观测。下表是待测模板,不是真实事故结论。持久导出沿异步任务模块的 tasks_lab 命令和 export_jobs 表核对;第 01 课内存 POST /exports 只是协议练习,并未自动加入部署 API。如果另行实现了持久 HTTP 适配层,再添加对应路由;202 只表示受理。流式扩展未加入本次部署时填不适用。

文件:incident.md

| 操作 | 当前观测 | 受影响范围 | 最后一次正常时间 |
|---|---|---|---|
| GET /live | 待测 | 待测 | 待测 |
| GET /ready | 待测 | 待测 | 待测 |
| GET /notes | 待测 | 待测 | 待测 |
| submit_snapshot 提交持久导出 | 待测 | 待测 | 待测 |
| export_jobs 状态与结果文件 | 待测 | 待测 | 待测 |
| SSE /stream 与 WS /ws | 待测 | 待测 | 待测 |

3. 为每次动作写四个字段

先用只读查询验证假设,再决定是否在隔离副本中操作。比如只暂停本地导出受理,预期减小新增积压;回滚方式是恢复之前配置。当前代码若没有受理开关,先增加并测试该开关,不能把未实现的操作写成已执行。

文件:action-record.json

{
  "timestamp_utc": "执行时填写",
  "observation": "填写实际现象",
  "hypothesis": "填写待验证原因",
  "action": "planned",
  "expected_change": "填写应变化的指标",
  "rollback": "填写恢复原配置的方法",
  "result": "not_run"
}

4. 核对止损后仍有无遗漏任务

只读查询数据库任务状态与最老等待。恢复判定至少覆盖健康、笔记读取、一个新导出完成、旧任务去向;不能仅以进程重新启动作为结束标准。

文件:终端 / 当前课时

SELECT status,count(*) AS jobs,
       extract(epoch FROM now()-min(created_at)) AS oldest_age_seconds
FROM export_jobs GROUP BY status;

SELECT count(*) AS unpublished FROM export_outbox WHERE sent_at IS NULL;

5. 维护带因果关系的时间线

每条记录包含时间、执行人、目标、动作和结果。日志或截图引用需先脱敏;记录中的时间使用UTC,操作系统和应用时钟偏差单独注明。最后保留尚未解释的事实。

文件:incident.md

# incident.md 时间线模板
# UTC时间 | 目标实例/任务ID | 实际观测 | 动作 | 结果 | 下一验证
# 未执行动作统一标planned,不用预计结果填入实际结果列。

验收结果

  • 影响范围按具体用户操作列出,而非只写服务异常。
  • 每次变更有目标、预期、回滚和实际结果。
  • 恢复门槛包含业务请求与既有任务去向,未执行项保持not_run。

生产约束与故障演练

用户反馈导出一直等待,但笔记列表仍正常。

实现决策

  • 先降低用户影响,再定位根因,两者分别留证据。
  • 一次只调整一个因素,限定实例和任务ID。
  • 业务恢复要求验证旧任务和新请求,不能只看进程状态。

隔离环境演练

只暂停本地练习worker,提交一条导出,按模板判断影响,再恢复worker核对任务完成。

恢复验收

  • 时间线区分事实、假设与操作。
  • 没有盲目批量重启或终止进程。
  • 恢复结论有业务验证支持。

常见错误

看到500立即重启所有组件

原因:未判断影响位置,重启可能扩大故障并丢掉现场。

处理:先保存进程、接口和依赖观测,仅对已验证目标采取有界动作。

健康检查成功就宣布恢复

原因:健康检查可能只确认进程存活,没有覆盖数据库和队列。

处理:增加真实读取与导出完成验收,区分liveness和业务可用性。

扩展练习

“/live正常、/notes正常、导出202正常但10分钟未完成”应怎样描述影响?

提示:把用户完成时间与请求受理状态分开。

参考答案:

描述为导出执行链路延迟或积压,读取功能仍可用;检查outbox、队列、worker、租约,不能仅凭202判定业务正常,也不能直接推断数据库整体不可用。

技术文档


02 用结构化日志与 request_id 关联一次请求

建立低泄露风险的访问日志,追踪状态、耗时和异常类型。

本节目标

  • 为每个HTTP请求提供可返回的request_id
  • 用JSON结构记录路由模板与状态
  • 避免日志输出凭据、正文和任意请求头

必要概念

请求ID用于关联,不代表身份

接收可信网关生成的ID或自行生成;客户端输入需要限制长度和字符,不能直接拼入日志行。request_id只帮助定位请求,不能用于权限判断。

记录路由模板和必要字段

优先记录GET、/notes/{note_id}、状态、耗时、实例与版本。完整URL可能包含查询凭据,笔记正文可能包含敏感内容,排障不要求默认记录全部数据。

HTTP完成与业务完成不同

HTTP响应完成可以记录访问耗时,导出任务另记job_id和业务阶段。SSE长连接需按首段和整体生命周期分别观察,不能把202访问日志当成导出完成日志。

动手实现

1. 编写JSON日志中间件

在隔离副本建立request_logging.py。使用ASGI包装send观察响应完成,适用于普通响应与流。只接收有限字符的X-Request-ID,否则生成新值;响应头返回该ID。

文件:request_logging.py

import json
import logging
import re
import time
from uuid import uuid4

logger = logging.getLogger("notes.requests")

class RequestLogMiddleware:
    def __init__(self, app):
        self.app = app

    async def __call__(self, scope, receive, send):
        if scope["type"] != "http":
            return await self.app(scope, receive, send)
        headers = dict(scope.get("headers", []))
        supplied = headers.get(b"x-request-id", b"").decode("ascii", "ignore")
        request_id = supplied if re.fullmatch(r"[A-Za-z0-9_-]{1,64}", supplied) else uuid4().hex
        scope.setdefault("state", {})["request_id"] = request_id
        started = time.perf_counter()
        status = 500
        error_type = None

        async def observe_send(message):
            nonlocal status
            if message["type"] == "http.response.start":
                status = message["status"]
                clean = [(k, v) for k, v in message.get("headers", []) if k.lower() != b"x-request-id"]
                message = {**message, "headers": clean + [(b"x-request-id", request_id.encode())]}
            await send(message)

        try:
            await self.app(scope, receive, observe_send)
        except Exception as exc:
            error_type = type(exc).__name__
            raise
        finally:
            route = getattr(scope.get("route"), "path", "unmatched")
            logger.info(json.dumps({
                "event": "http_request", "request_id": request_id,
                "method": scope["method"], "route": route, "status": status,
                "duration_ms": round((time.perf_counter()-started)*1000, 2),
                "error_type": error_type,
            }, ensure_ascii=False))

2. 注册并验证响应头

在现有FastAPI app上注册一次,不创建第二个app。logging格式只输出message以保持单行JSON;如果部署已经统一日志配置,应合并配置而不是覆盖所有logger。

文件:app/main.py

import logging
from request_logging import RequestLogMiddleware

logging.basicConfig(level=logging.INFO, format="%(message)s")
app.add_middleware(RequestLogMiddleware)

# 启动后:
# curl -i -H "X-Request-ID: drill-001" http://127.0.0.1:8000/live

3. 用合成记录练习检索

以下仅为示例日志。保存到sample.jsonl后用rg 'drill-001'查找。真实日志中应出现实际状态和耗时,不把下例数值作为当前服务性能结论。

文件:sample.jsonl

{"event":"http_request","request_id":"drill-001","method":"GET","route":"/notes/{note_id}","status":404,"duration_ms":4.2,"error_type":null}

4. 验证脱敏和异常路径

对本地请求添加测试用Authorization和带敏感占位词的查询字符串,确认自定义日志没有这些值。只使用fake-secret,不用真实凭据。再触发404与受控500,核对request_id和error_type;网关/Uvicorn默认访问日志需要另行检查查询字符串记录策略。

文件:终端 / 当前课时

curl -sS -H 'Authorization: Bearer fake-secret' 'http://127.0.0.1:8000/live?token=fake-secret'
rg -n 'fake-secret|Authorization|password' sample.jsonl
# 对实际演练日志重复检查,预期自定义JSON访问日志不包含测试秘密。

5. 把任务日志接到同一业务ID

提交导出时记录request_id和job_id的对应关系,worker记录job_id、message_id和lease_token。不要把整个request对象塞进Celery消息;跨进程关联下一课后续用trace上下文补充。

文件:request_logging.py

def log_export_accepted(request_id: str, job_id: str):
    logger.info(json.dumps({
        "event": "export_accepted",
        "request_id": request_id,
        "job_id": job_id,
    }))

验收结果

  • 合法request_id能在响应头和JSON访问日志中对应。
  • 不存在路径使用unmatched,不记录完整查询串。
  • fake-secret不进入自定义日志,任务受理与HTTP请求能够关联。

生产约束与故障演练

某个用户操作失败,需要从响应定位一次请求,又不能泄露笔记和密钥。

实现决策

  • 关联ID、业务ID和错误类型足以支持多数定位。
  • 日志字段采用允许列表,敏感数据不靠事后人工删除保障。
  • 请求完成与异步任务完成使用不同事件名。

隔离环境演练

对本地接口发送fake-secret、非法ID及受控错误,检索全部日志层的实际输出。

恢复验收

  • 请求能关联,凭据不会被自定义日志保存。
  • 日志是可解析的结构化记录。
  • 示例日志与真实观测明确区分。

常见错误

日志不是一行一个JSON

原因:异常正文或字符串拼接引入未转义换行。

处理:用json.dumps构造完整记录,按日志系统约定处理堆栈字段。

自定义日志脱敏但代理日志仍泄露token

原因:只检查应用logger,没有检查访问日志链路。

处理:逐层审查代理、Uvicorn与应用日志,URL不承载长期凭据。

扩展练习

传入超过64字符的X-Request-ID,确认服务器生成新ID而不是直接回显。

提示:测试输入只用重复字母,不需要任何真实秘密。

参考答案:

返回ID长度应为生成的32位hex,并可在日志关联;非法输入不会进入日志字段。

技术文档


03 定义 SLI、SLO 与低基数指标

为请求成功率、耗时和导出等待建立指标,避免每个用户或任务产生新时间序列。

本节目标

  • 用用户行为定义可测量SLI
  • 实现Prometheus计数与耗时分布
  • 用路由模板等有限标签控制基数

必要概念

SLI先定义分母与好事件

例如笔记读取的有效请求中成功完成的比例,必须说明是否排除主动取消和客户端无效请求。SLO是时间窗口内目标,不能把单次健康检查通过当作达标。

标签组合决定时间序列数量

request_id、job_id、用户ID和原始URL不适合作为指标标签。使用method、路由模板、状态类别;具体请求通过日志与trace定位。

分位数需要正确聚合

Histogram按桶计数,可在服务实例间聚合;平均耗时可能掩盖慢请求。低流量时分位数和错误率波动大,应同时看样本量,不能通过人为补成功请求掩盖空窗口。

动手实现

1. 安装指标库并定义有限标签

在隔离虚拟环境安装prometheus-client。新建metrics_lab.py,单进程Uvicorn验证本课;多worker的指标汇总须另按Prometheus Python多进程说明配置,不能直接假定全局变量共享。

文件:metrics_lab.py

from prometheus_client import Counter, Histogram

REQUESTS = Counter(
    "notes_http_requests_total", "HTTP requests",
    ["method", "route", "status_class"],
)
DURATION = Histogram(
    "notes_http_request_duration_seconds", "HTTP request lifecycle",
    ["method", "route"],
    buckets=(0.01, 0.05, 0.1, 0.3, 1, 3, 10),
)

2. 在实际请求完成时观测

将record_metrics加入第02课ASGI中间件finally,并传scope和状态;这里提供独立函数避免重复注册另一套访问中间件。SSE会记录整个连接时长,应另外统计首段到达时间。

文件:request_logging.py

from metrics_lab import REQUESTS, DURATION

def record_metrics(scope, status, elapsed):
    if scope.get("path") == "/metrics":
        return
    method = scope.get("method", "OTHER")
    if method not in {"GET","POST","PUT","PATCH","DELETE","OPTIONS","HEAD"}:
        method = "OTHER"
    route = getattr(scope.get("route"), "path", "unmatched")
    group = f"{status // 100}xx"
    REQUESTS.labels(method, route, group).inc()
    DURATION.labels(method, route).observe(elapsed)

# RequestLogMiddleware的finally中:
# record_metrics(scope, status, time.perf_counter()-started)

3. 暴露本地抓取入口

在现有app注册一次/metrics,使用Response直接返回指标文本。正式环境应限制观测端点访问范围。抓取后连续请求不同note_id,确认标签仍是/notes/{note_id}。

文件:app/main.py

from fastapi import Response
from prometheus_client import generate_latest, CONTENT_TYPE_LATEST

@app.get("/metrics", include_in_schema=False)
def metrics():
    return Response(content=generate_latest(), headers={"Content-Type": CONTENT_TYPE_LATEST})

4. 写出查询与业务目标

在本地Prometheus配置抓取目标后使用查询。下面示例看5xx比例和P95,均需先有流量。为GET /notes另写目标:例如最近7天有效读取99.5%在300ms内成功;数值是练习目标,是否适合业务需按实际基线确定。

文件:metrics-queries.promql

sum(rate(notes_http_requests_total{status_class="5xx"}[5m]))
/
sum(rate(notes_http_requests_total[5m]))

histogram_quantile(
  0.95,
  sum by (le, route) (rate(notes_http_request_duration_seconds_bucket[5m]))
)

5. 验证基数与告警触发条件

用20个不同ID访问同一路由,检查/metrics没有20个原始路径标签。无请求的窗口标无数据;告警同时要求有效样本和持续时间。导出积压另由状态表采样为Gauge,不能把每个job作为标签。

文件:终端 / 当前课时

curl -sS http://127.0.0.1:8000/metrics
# 指标标签只允许有限方法、路由模板与状态类别。
# 不应出现 request_id=、job_id=、user_id= 或带查询串的route。

验收结果

  • 指标能被Prometheus解析,计数随真实请求增加。
  • 不同note_id共享同一路由标签,没有高基数ID标签。
  • SLI写明分母与窗口,SLO数值被标为练习目标。

生产约束与故障演练

接口平均延迟没有明显变化,但部分用户查询和导出等待越来越久。

实现决策

  • 成功率、尾部耗时和异步完成时间分别建模。
  • 指标保持低基数,日志和trace承接单请求细节。
  • SLO依据基线与用户需求制定,空窗口不伪造100%成功。

隔离环境演练

在本地只让少数请求延迟,再比较平均值与耗时桶;以不同ID请求验证时间序列数量稳定。

恢复验收

  • 统计口径可复算。
  • 标签数量受控。
  • 告警有样本量与持续时间条件,不由一条慢请求直接推导事故。

常见错误

指标内存持续增长

原因:把原始URL、用户或任务ID用作标签。

处理:换成有限路由模板,具体对象放日志和trace。

平均耗时正常却有用户一直等待

原因:平均值掩盖尾部延迟,导出202也没有覆盖完成时间。

处理:同时看P95/分桶和业务受理到完成耗时。

扩展练习

写一个300ms内请求比例查询,并说明它还缺什么条件才能叫“快速且成功”。

提示:Histogram的le=0.3桶只看时长,本课时长指标未附带成功标签。

参考答案:

sum(rate(..._bucket{le='0.3'}[5m]))/sum(rate(..._count[5m]))得到时长比例;若要快速且成功,应另记录成功请求的时长桶或专用好事件计数,不能直接把独立成功率与时长比例相乘。

技术文档


04 把 trace 贯穿 HTTP、数据库与 Celery

用 OpenTelemetry 关联API内部阶段和队列执行,保留outbox跨进程的上下文。

本节目标

  • 配置本地trace导出并观察父子span
  • 为数据库操作增加不含正文的span
  • 跨outbox保存并恢复必要trace上下文

必要概念

trace显示阶段关系与耗时

一个请求可能包含数据库查询、HTTP调用和消息发布。span支持观察每段耗时,但采样trace不是全部请求计数,不能直接替代SLI指标。

跨进程需要传播上下文

HTTP请求头和Celery消息头可以携带trace上下文;outbox把发布延迟到另一个进程后,需要保存允许的追踪字段,再在publisher恢复。不能把Authorization或整个请求头带过去。

异步任务不一定延续请求寿命

长延迟任务可以用span link关联原请求。为本地演示先使用标准traceparent传播,生产需按采样与链路保留策略决定父子关系或链接。

动手实现

1. 安装并配置本地trace输出

安装opentelemetry-api、opentelemetry-sdk、opentelemetry-instrumentation-fastapi、opentelemetry-instrumentation-httpx、opentelemetry-instrumentation-celery,并freeze记录兼容版本。新建trace_setup.py,Console仅供隔离调试;输出可能包含运行元信息,分享前仍需脱敏。

文件:trace_setup.py

from opentelemetry import trace
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor, ConsoleSpanExporter

def setup_trace(service_name: str):
    provider = TracerProvider(resource=Resource.create({"service.name": service_name}))
    provider.add_span_processor(BatchSpanProcessor(ConsoleSpanExporter()))
    trace.set_tracer_provider(provider)
    return provider

2. 给API和数据库操作加span

在应用启动配置中调用setup_trace一次,instrument现有app。数据库span只记录有限操作名和行数,不把笔记正文或SQL参数设成属性;HTTPX自动插桩也需核对URL是否包含敏感查询参数。

文件:app/main.py

from opentelemetry import trace
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor
from trace_setup import setup_trace

setup_trace("notes-service-api")
FastAPIInstrumentor.instrument_app(app, excluded_urls="live,ready,metrics")
HTTPXClientInstrumentor().instrument()
tracer = trace.get_tracer("notes-service")

def traced_read(conn, note_id):
    with tracer.start_as_current_span("db.notes.read") as span:
        row = conn.execute("SELECT id,title FROM notes WHERE id=%s", (note_id,)).fetchone()
        span.set_attribute("app.rows_returned", 1 if row else 0)
        return row

3. 把允许的追踪字段存入outbox

给export_outbox加trace_context jsonb列,默认{}。在提交事务中调用carrier_for_outbox并把返回值作为Jsonb写入同一事件。只保存标准追踪字段,不保存任意baggage或请求头。

文件:trace_carrier.py

from opentelemetry.propagate import inject

def carrier_for_outbox():
    carrier = {}
    inject(carrier)
    return {key: value for key, value in carrier.items()
            if key in {"traceparent", "tracestate"}}

# SQL:
# ALTER TABLE export_outbox ADD COLUMN IF NOT EXISTS
# trace_context jsonb NOT NULL DEFAULT '{}';
# INSERT outbox时将carrier_for_outbox()写入该列。

4. 在publisher与worker恢复上下文

publisher领取事件时返回trace_context,在send_task外围attach/extract,finally detach。Celery消费者使用官方插桩;prefork子进程在worker_process_init中初始化provider。producer也在其进程初始化CeleryInstrumentor,避免重复初始化。

文件:trace_worker.py

from opentelemetry import context
from opentelemetry.propagate import extract

def publish_with_context(event, celery_app):
    token = context.attach(extract(event.get("trace_context") or {}))
    try:
        return celery_app.send_task(
            "notes.export", args=[str(event["job_id"])],
            task_id=str(event["id"]), queue=event["queue_name"], retry=False,
        )
    finally:
        context.detach(token)

# worker进程初始化模块,确保被Celery导入:
from celery.signals import worker_process_init
from opentelemetry.instrumentation.celery import CeleryInstrumentor
from trace_setup import setup_trace

@worker_process_init.connect(weak=False)
def init_worker_trace(**kwargs):
    setup_trace("notes-service-worker")
    CeleryInstrumentor().instrument()

5. 比较同步与延迟发布链路

发送一条本地导出请求,记录request_id→job_id映射和trace_id。先正常发布,再暂停publisher后恢复,观察上下文仍可关联。日志保留job_id,不能在trace采样丢弃时失去业务定位能力。

文件:trace-verification.md

# 待填写的演练字段
# request_id:
# job_id:
# outbox_id:
# api_trace_id:
# publish_trace_id:
# worker_trace_id:
# relation_observed:
# result: not_run

验收结果

  • API内部数据库span可定位并未记录笔记正文。
  • outbox只保存允许的追踪字段。
  • publisher恢复上下文,worker链路可以关联,日志仍保留业务ID。

生产约束与故障演练

一次请求很快返回202,但导出很久才完成,需要定位等待发生在哪里。

实现决策

  • 同时关联request_id、job_id、消息ID与trace。
  • outbox只持久必要追踪字段,外部输入按信任边界处理。
  • 采样策略不能代替任务状态与指标,缺trace不阻断业务。

隔离环境演练

暂停本地publisher后恢复,比较保留和缺少trace_context两种事件的链路关联。

恢复验收

  • 可以区分API执行、待发布、排队和worker执行阶段。
  • 凭据与正文没有进入trace属性。
  • 缺少追踪上下文仍能根据业务ID恢复任务。

常见错误

HTTP有trace,worker却变成另一条无关联链路

原因:outbox异步发布丢了当前请求上下文,或producer未插桩。

处理:保存并恢复标准trace字段,初始化publisher和worker插桩。

每次任务执行都初始化TracerProvider

原因:重复初始化造成警告或多个导出器。

处理:每个进程只初始化一次,prefork在子进程启动时设置。

扩展练习

删除一条实验outbox事件的trace_context后发布,说明业务任务是否应该失败。

提示:可观测性关联与业务输入是不同职责。

参考答案:

任务仍按job_id正常执行,只失去原trace关联;publisher可开始新trace并通过job_id日志定位,不能因缺少trace上下文拒绝已受理业务。

技术文档


05 区分 CPU 忙、事件循环阻塞与线程池等待

用进程指标、事件循环延迟和有界故障对照,定位Python服务变慢的位置。

本节目标

  • 记录进程CPU与线程数量
  • 观察事件循环调度延迟
  • 区分阻塞I/O、纯Python计算与执行器排队

必要概念

CPU低不代表应用没有阻塞

async路由中time.sleep或同步网络等待会阻塞事件循环,但CPU可能很低。CPU持续高则需进一步区分Python计算、序列化与底层库工作。

事件循环延迟显示调度是否及时

周期协程实际恢复时间晚于预期,说明事件循环无法及时调度。它是现象指标,仍要结合堆栈、CPU与依赖耗时定位原因。

线程池排队有独立等待成本

阻塞函数移到线程后,线程数不足仍可能排队;无限增加线程会扩大连接和内存消耗。任务数量、工作时间与下游额度共同决定容量。

动手实现

1. 采集一个明确进程的基线

先按部署名称读取MainPID,再确认PID非0、对应本次notes-service。pidstat来自sysstat,若未安装可先用ps。命令只读,不输出环境变量或完整命令行。

文件:终端 / 当前课时

API_PID=$(systemctl show notes-api.service -p MainPID --value)
ps -p "$API_PID" -o pid,ppid,comm,etime,%cpu,%mem,nlwp
pidstat -p "$API_PID" 1 5
ps -L -p "$API_PID" -o pid,tid,comm,pcpu

2. 在隔离应用记录事件循环延迟

把watch_loop作为现有lifespan管理的任务,启动时create_task保存引用,退出时cancel并await。不要另建第二个FastAPI实例覆盖已有路由。下面只定义监测函数,日志数值来自实际运行。

文件:loop_watch.py

import asyncio
import json

async def watch_loop():
    loop = asyncio.get_running_loop()
    while True:
        expected = loop.time() + 0.1
        await asyncio.sleep(0.1)
        lag = max(0, loop.time() - expected)
        print(json.dumps({"event": "event_loop_lag", "seconds": lag}))

# 现有lifespan中:
# monitor = asyncio.create_task(watch_loop())
# try:
#     yield
# finally:
#     monitor.cancel()
#     with contextlib.suppress(asyncio.CancelledError):
#         await monitor

3. 只在隔离副本复现两种阻塞

添加受开关控制的实验路由,默认404。开启LAB_ENABLE_FAULTS=1只用于本地副本。分别请求mode=sleep与mode=cpu,同时观察/live、CPU和loop lag;每次工作上限0.5秒,不启动无限循环。

文件:app/main.py

import os
import time
from typing import Literal
from fastapi import HTTPException

@app.get("/lab/block")
async def block_demo(mode: Literal["sleep", "cpu"] = "sleep"):
    if os.getenv("LAB_ENABLE_FAULTS") != "1":
        raise HTTPException(404, "not enabled")
    if mode == "sleep":
        time.sleep(0.5)
    else:
        until = time.perf_counter() + 0.5
        while time.perf_counter() < until:
            pass
    return {"mode": mode}

4. 记录修复前后同样负载

sleep改为await asyncio.sleep;同步SDK按异步任务课使用受限线程,CPU工作分块或移到受限进程。保持同样请求数与参数比较,不同时调整worker数量和代码。若需要堆栈,先在隔离环境预先启用faulthandler,不能向未注册信号处理的进程随意发送信号。

文件:cpu-verification.md

# 在同样的低并发负载下各记录:
# CPU%、loop lag、HTTP耗时、线程数量、工作队列等待。
# 恢复后移除/lab/block,关闭LAB_ENABLE_FAULTS。
# 不把“线程数增加”本身当作修复证据。

验收结果

  • sleep阻塞与CPU循环都可增加loop lag,但CPU表现不同。
  • 修复使用可等待I/O后,同样负载下/live不再被同步sleep拖住。
  • 线程池容量与排队情况有记录,未用无限并发掩盖问题。

生产约束与故障演练

高峰期所有API偶尔停顿,主机CPU却没有满。

实现决策

  • 事件循环延迟与CPU一起观察。
  • 诊断信号和分析工具先在隔离环境验证权限与开销。
  • 修复时限制线程、进程和下游连接总量。

隔离环境演练

本地分别注入0.5秒sleep和CPU循环,比较指标后恢复可等待实现。

恢复验收

  • 能够用证据区分计算和等待。
  • 相同负载下修复前后可比较。
  • 故障路由默认关闭且最终移除。

常见错误

看到CPU高就直接增加Uvicorn worker

原因:可能超过CPU配额或放大数据库连接。

处理:先判断可并行部分与资源预算,再做单变量容量实验。

对生产进程直接发送SIGUSR1期待堆栈

原因:目标可能未注册处理器,信号行为未确认。

处理:仅在已配置并验证的隔离进程使用诊断信号,优先读取现有指标与日志。

扩展练习

描述“CPU低、loop lag高、请求都一起卡住”的首要检查方向。

提示:寻找async调用路径中的同步等待,而非先假定算力不足。

参考答案:

检查time.sleep、同步HTTP/数据库调用、同步文件操作;结合当前堆栈和依赖耗时验证,再改为异步调用或受限线程。

技术文档


06 区分 Python 分配、RSS 与容器 OOM

用小规模内存实验理解不同观测口径,避免把缓存保留或原生内存误判为Python泄漏。

本节目标

  • 读取进程RSS与容器内存限制
  • 用tracemalloc比较Python分配来源
  • 通过OOM证据区分内存终止与其他退出

必要概念

RSS不等于Python活对象大小

RSS包含解释器、原生库、内存分配器和映射等;对象释放后RSS不一定立即下降。tracemalloc跟踪其启用后可追踪的Python内存分配,不能解释所有原生内存。

容器限制与主机余量不同

容器可能因自身memory.max触发OOM,即使主机仍有可用内存。查看cgroup事件和容器退出状态,不能只看free输出。

诊断也需要容量预算

堆快照、频繁采样和大量日志会增加开销。本课只分配约10MiB演示,不故意耗尽宿主机内存,也不通过drop_caches改变现场。

动手实现

1. 读取进程与容器口径

Linux systemd 环境使用第 05 课确认的 API_PID。若使用 Docker,在部署模块的 Compose 目录运行容器命令,先用 api service 获取唯一容器 ID;多副本时逐个选择。docker inspect 仅取所需字段,不导出含环境变量的完整配置。容器 PID 与宿主 PID 不可直接互换。Docker stats 与 RSS 口径不同,记录时保留来源。

文件:终端 / 当前课时

# Linux systemd 环境:
ps -p "$API_PID" -o pid,comm,rss,vsz,%mem
cat /proc/"$API_PID"/cgroup

# Docker Compose 环境:在当前练习 Compose 目录执行。
API_CONTAINER=$(docker compose ps -q api)
if [ -z "$API_CONTAINER" ]; then
  printf "api service has no running container\n" >&2
else
  docker stats --no-stream "$API_CONTAINER"
  docker inspect --format '{{.State.Status}} OOMKilled={{.State.OOMKilled}} ExitCode={{.State.ExitCode}}' "$API_CONTAINER"
fi

2. 运行有限Python分配实验

新建memory_probe.py,只在隔离环境执行。tracemalloc在分配前启动;先比before和during,再删除对象观察after。打印分配位置和大小,不打印对象内容。

文件:memory_probe.py

import gc
import tracemalloc

tracemalloc.start(10)
before = tracemalloc.take_snapshot()
buffers = [bytearray(512 * 1024) for _ in range(20)]
during = tracemalloc.take_snapshot()
for stat in during.compare_to(before, "lineno")[:5]:
    print(stat)
del buffers
gc.collect()
after = tracemalloc.take_snapshot()
current, peak = tracemalloc.get_traced_memory()
print({"current_bytes": current, "peak_bytes": peak})
for stat in after.compare_to(before, "lineno")[:5]:
    print(stat)

3. 关联OOM事件与进程退出

在cgroup v2容器内部检查memory.current、memory.max和memory.events;不同部署挂载方式可能不同,先确认cgroup路径。ExitCode=137也可能是人为SIGKILL,需结合OOMKilled和oom_kill计数,不单凭137下结论。

文件:终端 / 当前课时

# 在已确认使用cgroup v2的隔离容器内:
cat /sys/fs/cgroup/memory.current
cat /sys/fs/cgroup/memory.max
cat /sys/fs/cgroup/memory.events
# 只读取;不写drop_caches,不修改宿主机内存限制。

4. 设计增长复现与修复对照

对同样的有限请求批次做多轮快照,观察相同分配位置是否持续增加。若tracemalloc稳定但RSS增长,进一步查原生库、连接缓冲和子进程;不要直接判为Python对象泄漏。修复后比较稳态与峰值。

文件:memory-record.json

{
  "sample_type": "planned_memory_observation",
  "round": null,
  "request_count": null,
  "rss_bytes": null,
  "tracemalloc_current_bytes": null,
  "tracemalloc_peak_bytes": null,
  "cgroup_oom_kill_delta": null,
  "result": "not_run"
}

验收结果

  • 实验分配约10MiB,释放后可追踪活跃分配明显下降。
  • RSS、tracemalloc与容器限制分别记录,未混用单位。
  • OOM结论有事件或状态证据,非仅依据退出码。

生产约束与故障演练

导出运行一段时间后容器重启,需要确认是内存泄漏、峰值超限还是其他终止。

实现决策

  • 对齐进程、容器和主机三种口径。
  • 诊断实验有严格内存上限,禁止耗尽宿主资源。
  • 修复依据增长来源,内存上限调整不能替代无界队列修复。

隔离环境演练

仅运行本课10MiB有限分配脚本,对比快照与RSS;OOM部分使用已有隔离演练证据或标not_run。

恢复验收

  • 没有人为触发宿主OOM或清理缓存干扰现场。
  • 每个内存指标注明单位和范围。
  • 结论区分可追踪Python分配与原生/容器内存。

常见错误

删除对象后RSS没降就认定泄漏

原因:分配器可能保留内存供复用,RSS不等于活对象。

处理:比较多轮稳态和tracemalloc增长来源,再判断是否持续无界增长。

容器反复退出但主机free正常

原因:容器自身内存上限被触发。

处理:检查memory.max和memory.events,结合OOMKilled与具体负载调整。

扩展练习

如果tracemalloc当前值稳定、RSS持续增长,应先收集哪两类信息?

提示:考虑不被tracemalloc完整覆盖的分配与进程范围。

参考答案:

检查原生库/网络缓冲及映射,核对是否包含子进程或容器总内存;同时记录同一负载下的增长曲线,不能直接用gc.collect当修复。

技术文档


07 分开排查 DNS、TCP、TLS 与连接池等待

把一次外部调用拆成阶段,核对客户端连接预算和部署网络命名空间。

本节目标

  • 区分名称解析、连接建立与HTTP响应失败
  • 配置HTTPX连接池和阶段超时
  • 避免凭据进入抓包或命令输出

必要概念

不同阶段失败需要不同证据

DNS失败还没有获得目标地址,TCP连接拒绝通常没有进入HTTP,TLS错误发生在加密连接建立阶段,HTTP504可能来自网关或应用。不能把所有错误都叫网络慢。

localhost属于当前网络命名空间

宿主、容器和WSL中的127.0.0.1可能指向不同位置。任务broker宿主映射6384,容器内通常通过broker:6379;缓存宿主6380,不能混用端口。

连接池耗尽也表现为等待

HTTPX PoolTimeout是等待池连接超时,不表示目标服务器连接失败。限制max_connections,并复用客户端,避免每请求建立独立连接池。

动手实现

1. 核对解析、监听与目标端口

命令在发生问题的同一容器或WSL环境执行。getent使用实际依赖主机名;ss只看必要监听与汇总信息。不要打印包含密码的完整DATABASE_URL或Celery URL。

文件:终端 / 当前课时

getent hosts broker
ss -lnt
ss -s
# 在宿主检查本地映射;不带任何凭据:
curl -sS --connect-timeout 1 --max-time 3 -o /dev/null -w 'code=%{http_code} connect=%{time_connect} total=%{time_total}\n' http://127.0.0.1:8000/live

2. 限制连接数并分开阶段超时

新建http_budget.py。HTTPX异步客户端在应用lifespan创建并关闭;以下脚本独立运行以观察异常类型。只请求本地/slow,不传真实凭据。

文件:http_budget.py

import asyncio
import httpx

async def main():
    limits = httpx.Limits(max_connections=2, max_keepalive_connections=2)
    timeout = httpx.Timeout(connect=1, read=3, write=3, pool=0.1)
    async with httpx.AsyncClient(
        base_url="http://127.0.0.1:8000", limits=limits, timeout=timeout
    ) as client:
        async def one():
            try:
                response = await client.get("/slow", params={"delay": 1})
                return response.status_code
            except httpx.HTTPError as exc:
                return type(exc).__name__
        print(await asyncio.gather(*(one() for _ in range(5))))

if __name__ == "__main__":
    asyncio.run(main())

3. 区分池等待与连接失败

先运行上一步,超过两个同时请求时可能出现PoolTimeout。再把独立脚本base_url改成本地确认未监听的端口,观察ConnectError;恢复后把read改0.1秒观察ReadTimeout。一次只改一项,记录实际异常类型。

文件:network-record.md

# 演练记录字段:
# phase: pool / connect / read
# configuration_changed:
# exception_type_observed:
# elapsed_seconds:
# restored_to_baseline: false
# 上例是记录模板,不是已观察到的结果。

4. 只读取必要网络证据

若必须定位TLS,用无凭据的测试请求核对证书主机名与链路;不要以verify=False作为修复。抓包只在隔离且无敏感流量的环境按最小范围使用,本模块默认以阶段耗时和连接统计定位,不导出完整HTTP载荷。

文件:network-notes.md

# curl阶段耗时,可用于已确认的无凭据测试URL:
# -w 'dns=%{time_namelookup} connect=%{time_connect} tls=%{time_appconnect} first_byte=%{time_starttransfer} total=%{time_total}'
# 这些是从开始累计的时间,不是可以直接相加的独立阶段耗时。

验收结果

  • 错误能定位到解析、连接、读响应或池等待阶段。
  • HTTPX客户端有连接上限且在生命周期结束时关闭。
  • 宿主与容器端口映射清楚,日志没有输出带密码的URL。

生产约束与故障演练

导出调用存储接口时偶发超时,扩容后错误反而增加。

实现决策

  • 先区分命名空间、网络阶段和连接池等待。
  • 连接预算与worker并发一起规划。
  • TLS校验和凭据保密不是排障时可以随意关闭的步骤。

隔离环境演练

仅对本地/slow控制池上限与read超时,分别观察PoolTimeout与ReadTimeout并恢复配置。

恢复验收

  • 每种异常有对应证据。
  • 客户端连接和流资源会关闭。
  • 未通过泄露凭据抓包或禁用证书校验得出结论。

常见错误

容器用127.0.0.1:6384连接broker失败

原因:6384是宿主映射,容器localhost不是broker服务。

处理:在同网络容器使用broker:6379,在宿主使用127.0.0.1:6384。

PoolTimeout后不断扩容下游

原因:真正等待的是本进程连接池,未确认下游是否瓶颈。

处理:检查连接泄漏、流响应是否关闭、并发需求与池预算,再逐项调整。

扩展练习

在两个连接额度下发五个耗时1秒请求,解释为什么pool=0.1可能先失败。

提示:池等待发生在获得连接前,与read=3不是同一个计时阶段。

参考答案:

前两个请求占用连接,后续请求等待超过0.1秒可触发PoolTimeout,不能因此认定下游超过3秒未响应。

技术文档


08 定位慢 SQL、锁等待与连接池耗尽

从 pg_stat_activity 进入具体等待关系,使用短事务和隔离锁实验验证原因。

本节目标

  • 读取活动会话与阻塞PID
  • 区分SQL执行慢和等待连接/锁
  • 用参数化EXPLAIN与事务级超时限制诊断

必要概念

数据库耗时包含多个阶段

应用先等连接池,再发送SQL,数据库还可能等锁或I/O。只看到接口慢,不足以判断需要加索引。

等待者与阻塞者要关联

pg_stat_activity显示状态、事务年龄和wait_event,pg_blocking_pids定位阻塞来源。先看进程与关系,再决定是否需要查看脱敏SQL。

诊断SQL也可能执行实际工作

EXPLAIN ANALYZE会真的执行查询,写操作还会产生副作用。先用EXPLAIN;隔离环境才能用已限制范围的ANALYZE,并设置合理statement_timeout和lock_timeout。

动手实现

1. 读取活动会话和等待信息

连接PostgreSQL16 notes_lab,app_user,宿主5433。通过安全配置的DATABASE_URL或pgpass连接,避免把密码贴入命令行。普通账户可见信息受权限限制,不把缺失字段当作没有活动。

文件:终端 / 当前课时

SELECT pid,usename,application_name,state,
       now()-xact_start AS transaction_age,
       now()-query_start AS query_age,
       wait_event_type,wait_event,
       pg_blocking_pids(pid) AS blocked_by
FROM pg_stat_activity
WHERE datname='notes_lab' AND pid<>pg_backend_pid()
ORDER BY xact_start NULLS LAST;

2. 在两会话复现有界锁等待

只选本地演练笔记ID。会话A开启事务并锁定该行;会话B设置1秒lock_timeout后尝试锁同一行。观察锁等待错误,随后两个会话都ROLLBACK,释放故障条件。

文件:终端 / 当前课时

-- 会话A:将1替换为专门用于本次演练的笔记ID
BEGIN;
SELECT id FROM notes WHERE id=1 FOR UPDATE;
-- 暂不提交,在会话B执行下面语句后回到A:ROLLBACK;

-- 会话B
BEGIN;
SET LOCAL lock_timeout='1s';
SELECT id FROM notes WHERE id=1 FOR UPDATE;
-- 超时后:ROLLBACK;

3. 先看计划,再在隔离数据上测执行

下面EXPLAIN不实际执行SELECT。若需要ANALYZE,先确认只读、数据规模有限且本地可承受,再以事务级statement_timeout执行。不要在事故中直接对未知大表运行昂贵诊断。

文件:终端 / 当前课时

EXPLAIN (COSTS, VERBOSE)
SELECT id,title FROM notes WHERE id=1;

-- 仅隔离练习:
BEGIN;
SET LOCAL statement_timeout='2s';
EXPLAIN (ANALYZE, BUFFERS)
SELECT id,title FROM notes WHERE id=1;
ROLLBACK;

4. 观察应用连接池预算

若使用psycopg_pool,记录get_stats中的等待、请求和连接数,字段按安装版本核对。连接预算需乘以API worker和Celery进程数;不要只把每个池的max_size调大。等待连接时数据库可能没有相应新查询。

文件:pool_observation.py

# 使用现有psycopg_pool对象,只记录统计数字:
# stats = pool.get_stats()
# logger.info({"event": "db_pool_stats", "stats": stats})

# 检查应用事务边界:
# 获取连接 → 短SQL/事务 → 提交或回滚 → 归还连接
# 不把网络上传、长await或CPU导出放在持有事务的区间。

5. 恢复后核对锁和业务请求

回滚会话A后重试读取,应恢复正常;再次查询活动会话确认没有本次idle in transaction。记录谁持锁、谁等待、锁何时释放。未经确认不要终止任意数据库backend。

文件:终端 / 当前课时

SELECT pid,state,now()-xact_start AS transaction_age
FROM pg_stat_activity
WHERE datname='notes_lab' AND state='idle in transaction';

验收结果

  • 会话B在受控时间内超时,能关联会话A为阻塞者。
  • 回滚后锁释放,读取恢复。
  • 应用池等待与数据库锁等待分别记录,没有盲目增大连接数。

生产约束与故障演练

发布后笔记读取突然变慢,数据库CPU却不高。

实现决策

  • 先查池等待和锁关系,再评估SQL计划。
  • 诊断操作设超时,锁实验限定专门笔记和可回滚事务。
  • 连接上限按全进程总量规划。

隔离环境演练

使用两条本地数据库连接持锁与超时,再回滚并确认恢复,不终止其他会话。

恢复验收

  • 等待关系可解释。
  • 所有演练事务最终回滚。
  • 没有将EXPLAIN ANALYZE描述为纯静态分析。

常见错误

接口慢但pg_stat_activity没有对应查询

原因:请求可能还在应用连接池排队。

处理:检查连接归还、池等待统计和进程总连接预算。

锁超时后继续在同一事务执行又报错

原因:PostgreSQL事务已进入失败状态。

处理:先ROLLBACK,再重新开始需要的事务;不要忽略异常后继续使用。

扩展练习

说明为什么一个API进程池max_size=10,扩成4个进程后数据库连接可能接近40。

提示:进程之间通常不共享同一个连接池对象。

参考答案:

每个进程拥有自己的上限,总预算需加上Celery、publisher、recovery和管理连接;扩worker前先核对数据库容量。

技术文档


09 排查 Redis 热点、淘汰与缓存降级

先确认实例用途,再观察命中率、内存和延迟,保护数据库避免缓存故障扩散。

本节目标

  • 区分缓存实例与任务broker
  • 读取命中、淘汰、连接与内存指标
  • 给缓存失效设计受限回源路径

必要概念

缓存与broker不能用同一恢复策略

缓存可按业务降级回源,broker承载待交付消息,不能通过清空键空间恢复。模块统一缓存宿主6380、独立任务broker6384;先核对用途再操作。

累计指标需要按窗口取增量

keyspace_hits和keyspace_misses是累计计数,计算窗口命中率要使用增量。evicted_keys增加表示内存策略淘汰,expired_keys增加表示TTL到期,两者原因不同。

回源必须有容量上限

缓存不可用后所有请求直接打数据库可能造成级联故障。设置缓存短超时、回源并发限制和必要的过载拒绝;多进程总量不能只按单进程Semaphore理解。

动手实现

1. 安全连接指定缓存实例

在Linux/WSL2终端通过read隐藏输入本地缓存密码,不把密码写进-a参数。以下只读命令指向6380;broker6384需要其独立密码,不能复用缓存故障动作。

文件:终端 / 当前课时

read -rsp 'Local cache password: ' REDISCLI_AUTH
export REDISCLI_AUTH
redis-cli -h 127.0.0.1 -p 6380 PING
redis-cli -h 127.0.0.1 -p 6380 INFO stats
redis-cli -h 127.0.0.1 -p 6380 INFO memory
redis-cli -h 127.0.0.1 -p 6380 INFO clients

2. 检查策略与延迟来源

CONFIG GET是只读但可能被ACL禁止;权限不足需要按观测权限配置处理,不绕过认证。SLOWLOG可能包含命令参数,先查长度;只有无敏感数据的隔离实例才查看具体条目。不要运行KEYS *或MONITOR扫描实际业务内容。

文件:终端 / 当前课时

redis-cli -h 127.0.0.1 -p 6380 CONFIG GET maxmemory maxmemory-policy
redis-cli -h 127.0.0.1 -p 6380 LATENCY LATEST
redis-cli -h 127.0.0.1 -p 6380 SLOWLOG LEN
# 在无敏感数据的隔离实例才进一步检查SLOWLOG内容。
unset REDISCLI_AUTH

3. 给缓存访问配置受限回源

下面函数使用已有redis异步客户端和read_db异步函数,客户端需设置socket_connect_timeout/socket_timeout,例如各0.2秒。缺失或连接失败时回源,缓存写失败不改变已成功读取的数据。此示例只用于单进程容量实验;真实权限必须进入缓存key或查询范围。

文件:cache_fallback.py

import asyncio
import json
from redis.exceptions import ConnectionError as RedisConnectionError
from redis.exceptions import TimeoutError as RedisTimeoutError

db_slots = asyncio.Semaphore(4)

async def cached_note(note_id, cache, read_db):
    key = f"lab:notes:{note_id}"
    try:
        raw = await cache.get(key)
        if raw is not None:
            return json.loads(raw)
    except (RedisConnectionError, RedisTimeoutError, json.JSONDecodeError):
        pass
    async with asyncio.timeout(1):
        async with db_slots:
            row = await read_db(note_id)
    if row is not None:
        try:
            await cache.set(key, json.dumps(row), ex=30)
        except (RedisConnectionError, RedisTimeoutError):
            pass
    return row

4. 以增量比较缓存故障前后

仅停止本地缓存实例,保持broker和数据库运行,低并发读取相同笔记。记录缓存异常、数据库请求数、池等待和HTTP状态;恢复缓存后观察命中回升。回源超时应由API转换为明确503,不返回虚构笔记或无限等待。

文件:cache-drill.json

{
  "sample_type": "cache_drill_template",
  "window_seconds": null,
  "hits_delta": null,
  "misses_delta": null,
  "evicted_keys_delta": null,
  "db_requests_delta": null,
  "http_503_count": null,
  "result": "not_run"
}

验收结果

  • 实例地址与用途明确,6384broker未被缓存故障动作影响。
  • 窗口命中率使用计数增量,能区分TTL到期与内存淘汰。
  • 缓存故障时回源有时间与并发预算,过载明确失败。

生产约束与故障演练

热门笔记读取突然变慢,缓存命中下降且数据库连接开始排队。

实现决策

  • 先确认是过期、淘汰、热点还是Redis不可用。
  • 缓存降级需要保护数据库容量。
  • 任务broker与缓存使用独立实例和恢复手册。

隔离环境演练

只暂停本地缓存6380,以有限并发请求观察回源,再恢复并核对命中和数据库负载。

恢复验收

  • 没有清空broker或扫描敏感键值。
  • 降级不突破数据库预算。
  • 缓存恢复后业务结果仍与数据库一致。

常见错误

缓存故障后数据库也被打满

原因:无上限回源造成级联放大。

处理:先限制回源并发和请求速率,必要时拒绝非关键读取。

对broker执行缓存清理

原因:混淆实例用途,可能删除未处理消息。

处理:独立实例与凭据,先核对地址和角色,不以flushdb/purge作为排障默认动作。

扩展练习

命中100、未命中20增至命中140、未命中30,计算该窗口命中率。

提示:使用变化量40和10,不使用启动以来累计值。

参考答案:

窗口命中率=40/(40+10)=80%;同时记录窗口请求量50,不能把低样本波动误判为长期趋势。

技术文档


10 定位 Celery 积压、重试风暴与租约恢复

把受理、outbox发布、broker排队和worker执行分开检查,再选择恢复动作。

本节目标

  • 按任务阶段定位积压位置
  • 区分业务任务重试与重复交付
  • 基于持久状态与租约进行受控恢复

必要概念

积压可能发生在broker之前

export_outbox未发布也属于用户等待;只看Celery active会漏掉publisher故障。queued任务即使暂时没有pending outbox,也可能已在broker或存在消息丢失窗口,需要派发记录和恢复规则判断。

重试与重投有不同来源

任务主动retry可能带退避;broker重复交付可能携带旧消息。持久attempts、next_attempt_at、deadline和领取CAS必须一起限制,不能只看Celery request.retries。

恢复先处理原因,再处理队列

下游仍不可用时增加worker或批量重放会放大负载。先停止错误输入或恢复依赖,再按有限批次处理到期任务;不要默认purge清空消息或强杀所有worker。

动手实现

1. 读取worker与路由状态

在tasks_lab工作目录,读取正确Celery环境:独立broker宿主6384,容器内broker:6379,入口celery_app:app。inspect可能包含任务参数,本模块后期只传job_id;分享输出前核对。

文件:终端 / 当前课时

python -m celery -A celery_app:app status
python -m celery -A celery_app:app inspect active
python -m celery -A celery_app:app inspect reserved
python -m celery -A celery_app:app inspect scheduled
python -m celery -A celery_app:app inspect active_queues

2. 把数据库阶段并排查询

使用SQL同时看业务状态、待发布事件和过期租约。queued很老只是排查线索,不足以直接断言消息已经丢失;按异步任务模块的派发记录与恢复门槛处理。

文件:终端 / 当前课时

SELECT status,count(*) AS total,
       extract(epoch FROM now()-min(created_at)) AS oldest_seconds
FROM export_jobs GROUP BY status;

SELECT queue_name,count(*) AS pending,
       extract(epoch FROM now()-min(created_at)) AS oldest_seconds
FROM export_outbox WHERE sent_at IS NULL GROUP BY queue_name;

SELECT id,attempts,lease_until,error_code
FROM export_jobs WHERE status='running' AND lease_until<now()
ORDER BY lease_until LIMIT 20;

3. 按一条任务核对重试链

从实际失败日志选定一个本地job_id,查询其事件ID、attempts和状态转换。区分多次消息到达与多次成功领取;重复消息到达不应绕过next_attempt_at或为活跃租约增加执行副作用。不要只看同一task_id的最终缓存值。

文件:终端 / 当前课时

-- 将参数绑定为本次演练job_id,避免拼接外部输入。
-- SELECT id,status,attempts,deadline_at,lease_token,lease_until,error_code
-- FROM export_jobs WHERE id=%s;
-- SELECT id,job_id,attempts,sent_at,available_at
-- FROM export_outbox WHERE job_id=%s ORDER BY created_at;
-- 如果已应用任务模块派发与退避字段,同时核对相应时间条件。

4. 恢复一个有证据的本地故障

先确认下游恢复、容量可用和代码修复,再运行任务模块有限批次recovery与publisher。单步脚本不常驻;部署中的循环wrapper有独立节奏与停机管理。失败耗尽任务仅在确认修复后用replay_job逐条生成新job,保留旧失败证据。

文件:终端 / 当前课时

python recovery.py
python publisher.py
# 对已修复且明确的failed任务:
# python replay_job.py <本次已核对的失败job_id>
# 不运行celery purge,不通过重置attempts解除失败限制。

5. 验证恢复不是短暂下降

恢复后持续观察最老等待、完成速率、重试次数和下游错误,覆盖至少一个任务截止时间与恢复扫描周期。增加worker后若数据库池等待上升,应停止扩大并发并重新核算预算。

文件:queue-recovery.md

# 恢复验收记录:
# 影响队列:
# 下游修复证据:
# 受控恢复任务ID:
# 最老等待变化:
# 重试/重投变化:
# 新导出完成结果:
# 未恢复任务及原因:
# result: not_run

验收结果

  • 能判断积压发生在outbox、broker、worker还是业务失败隔离。
  • 重投和重试没有绕过持久尝试预算与退避时间。
  • 恢复有下游正常和最终结果证据,旧失败记录被保留。

生产约束与故障演练

任务队列积压,团队准备增加worker,但导出持续重试。

实现决策

  • 按阶段定位,数据库状态为业务真源。
  • 下游恢复前不通过并发扩容放大失败。
  • 恢复按持久租约、退避和次数预算执行,重放生成新业务ID。

隔离环境演练

仅暂停本地publisher后提交,再恢复;另让隔离worker一次失败,对照两种积压阶段和恢复路径。

恢复验收

  • 未清空消息掩盖故障。
  • 重复交付不能越过状态约束。
  • 恢复后新旧导出均有明确结果或失败去向。

常见错误

worker在线但没有任务执行

原因:路由队列不匹配、publisher未发布或任务未注册。

处理:核对active_queues、registered和outbox,再查执行容量。

重试风暴时继续增加worker

原因:下游不可用,更多worker只产生更多失败。

处理:先止住重复输入或恢复依赖,保留退避、总预算和入口容量。

扩展练习

outbox数量上升、worker空闲、broker可连接时,优先查哪两个位置?

提示:消息可能没有被publisher成功发送,或发到了无人消费的队列。

参考答案:

检查publisher错误/领取租约/标记过程,以及outbox queue_name与worker active_queues是否一致;不能直接判断worker算力不足。

技术文档


11 端到端排查 SSE 缓冲与 WebSocket 断开

对比直连、代理和浏览器,分别验证首段到达、消息边界和连接生命周期。

本节目标

  • 区分HTTP响应头到达与首个业务片段到达
  • 定位代理缓冲与客户端整包读取
  • 核对WebSocket升级、空闲超时和重连行为

必要概念

流式发送不保证每段立刻被用户看到

服务端生成、ASGI发送、代理转发、客户端读取和页面渲染都可能缓冲。先比较各段链路,再修改对应配置。

SSE与WebSocket的代理要求不同

SSE是持续HTTP响应,关注缓冲、压缩和读超时;WebSocket在HTTP/1.1握手时需要正确升级头,之后关注空闲超时与心跳。不能用普通GET成功证明WebSocket链路正常。

断开与重连属于业务行为

用户停止后应关闭连接和定时器,不自动重新生成同一内容。网络断开可有限退避重连,但不自动重发未经确认的业务消息。

动手实现

1. 直连API测首段到达

新建stream_probe.py,只发送hello测试文本。按实际到达data行记录时间,不能用curl time_starttransfer代替首个业务片段,因为响应头可能很早已发送。

文件:stream_probe.py

from time import perf_counter
import httpx

def probe(base_url):
    started = perf_counter()
    with httpx.Client(base_url=base_url, timeout=5) as client:
        with client.stream("GET", "/stream", params={"prompt": "hello"}) as response:
            print("status", response.status_code, response.headers.get("content-type"))
            response.raise_for_status()
            for line in response.iter_lines():
                if line.startswith("data:"):
                    print(round(perf_counter()-started, 3), line, flush=True)

if __name__ == "__main__":
    probe("http://127.0.0.1:8000")

2. 保持请求不变对比代理

按Docker/Linux模块的实际代理端口修改base_url;下面8080仅表示本地演练映射。若直连逐段、代理一次到达,检查代理缓冲;若两者正常而浏览器延后,查response.text/json或渲染缓存。

文件:终端 / 当前课时

curl -N --max-time 5 'http://127.0.0.1:8000/stream?prompt=hello'
curl -N --max-time 5 'http://127.0.0.1:8080/stream?prompt=hello'
# Windows终端用curl.exe;代理端口以本地部署配置为准。

3. 只修改隔离代理中的对应location

以下片段放入已有nginx实验配置。示例代理与API同宿主;容器部署用实际API服务名替换127.0.0.1。先nginx -t通过,再按部署模块重载隔离代理。proxy_read_timeout是两次读取间隔限制,不是总请求时长。

文件:nginx-lab.conf

# server内部的SSE location
location /stream {
    proxy_pass http://127.0.0.1:8000;
    proxy_http_version 1.1;
    proxy_buffering off;
    proxy_read_timeout 65s;
}

# http级别:
# map $http_upgrade $connection_upgrade {
#     default upgrade;
#     '' close;
# }
# server内部的WebSocket location:
# location /ws {
#     proxy_pass http://127.0.0.1:8000;
#     proxy_http_version 1.1;
#     proxy_set_header Upgrade $http_upgrade;
#     proxy_set_header Connection $connection_upgrade;
#     proxy_read_timeout 65s;
# }

4. 在浏览器核对升级和关闭

在隔离页面Console连接已确认的代理WS地址,观察Network中的握手与Frames/Messages。HTTPS页面使用wss。收到open后发送hello和ping,再主动关闭;按API课程有限退避逻辑验证重连。

文件:浏览器Console

const socket = new WebSocket("ws://127.0.0.1:8080/ws");
socket.onopen = () => {
  socket.send("hello");
  socket.send("ping");
};
socket.onmessage = event => console.log(JSON.parse(event.data));
socket.onerror = () => console.log("socket error");
socket.onclose = event => console.log("closed", event.code, event.reason);
// 收到消息后主动停止:socket.close(1000, "drill complete");

5. 核对主动停止与真实清理

SSE收到done后关闭EventSource;fetch用AbortController停止读取,服务端取消后释放资源。WebSocket主动停止后等待超过最大重连间隔,确认无新连接。恢复所有临时代理配置,再跑一次完整SSE和echo。

文件:stream-verification.md

# 记录实际观测,勿填写预计时间:
# direct_first_data_seconds:
# proxy_first_data_seconds:
# browser_first_render_seconds:
# websocket_handshake_result:
# stop_followed_by_reconnect: 未测
# server_cleanup_observed: 未测
# result: not_run

验收结果

  • 直连、代理、浏览器三段各有独立到达记录。
  • WebSocket能完成握手与echo/pong,不仅是普通HTTP返回200。
  • 主动停止后没有意外重连或重复生成,服务端清理可观察。

生产约束与故障演练

本地API流式正常,部署后文本一次出现或WebSocket频繁断开。

实现决策

  • 按直连、代理、客户端逐段定位。
  • 配置变更限定相关location,测试语法后再重载隔离实例。
  • 重连不会自动重复不可逆业务消息。

隔离环境演练

只在隔离代理开启/关闭响应缓冲做对照,随后测试空闲与主动停止两类断开。

恢复验收

  • 到达时间来自真实业务片段。
  • 代理与浏览器行为分别验证。
  • 故障配置恢复,停止逻辑无残留连接。

常见错误

终端逐段显示,网页一次显示全部

原因:前端调用了response.text/json或等待完整文本再渲染。

处理:使用流式读取和按协议边界解析,分段更新UI。

WebSocket连接约固定时间断开

原因:代理空闲超时或心跳缺失可能触发,但需结合日志验证。

处理:核对代理读超时与实际消息间隔,采用有界应用心跳和停止规则。

扩展练习

如果response headers立刻到达、三条data都在1.2秒同时出现,能断言服务器没有yield吗?

提示:中间层或客户端可能缓冲,先做直连对照。

参考答案:

不能。比较直连data到达时间与代理结果,再检查生成器、代理缓冲和客户端读取;响应头时间不证明每段业务数据的传输情况。

技术文档


12 完成故障演练、恢复门槛与 runbook

把排障步骤整理为可交接流程,保存证据、恢复条件和后续修复项。

本节目标

  • 执行单变量故障矩阵并保留实际观测
  • 用明确业务门槛判断恢复
  • 编写可执行的runbook与复盘行动项

必要概念

演练要有开始与结束条件

每个故障限定实例、任务ID、持续时间和恢复方法。达到预定风险边界就停止,不能为了复现而无限增加负载。

恢复门槛应覆盖业务与数据

服务恢复包括新请求正常、积压受控、已受理任务去向清楚、无错误发布结果。重启成功或监控颜色变绿只是部分证据。

复盘区分触发因素与系统缺口

记录什么触发、为什么扩大、哪些控制有效、哪里缺少观测或恢复能力。行动项要有负责人、验收条件和优先级,不只写“加强监控”。

动手实现

1. 准备隔离演练清单

每个案例用全新测试数据,先保存无故障基线。下面矩阵是待执行计划,不代表已经发生事故或完成测试。禁止把生产凭据、真实笔记正文放入公开材料。

文件:drill-plan.md

| 案例 | 限定故障 | 主要证据 | 恢复动作 |
|---|---|---|---|
| 请求阻塞 | 本地0.5秒同步sleep | loop lag、CPU、HTTP耗时 | 恢复await实现 |
| 数据库锁 | 两会话同一演练笔记 | blocked_by、lock_timeout | 两会话ROLLBACK |
| 缓存不可用 | 只停缓存6380 | 回源量、503、池等待 | 恢复缓存 |
| 发布积压 | 只停publisher | outbox最老等待 | 恢复有界publisher |
| worker租约到期 | 一个记录过的job | token、attempts、状态 | 有界恢复扫描 |
| SSE缓冲 | 隔离代理location | 首data时间对照 | 恢复缓冲配置 |

2. 定义停止与恢复门槛

将以下阈值改为本地机器可承受值,再执行。示例阈值只用于练习,不作为线上SLO建议。超出范围停止故障注入并回到基线,不继续加压寻找更大异常。

文件:drill-boundary.json

{
  "scope": "isolated notes-service only",
  "max_test_requests": 20,
  "max_fault_duration_seconds": 30,
  "max_controlled_python_allocation_mib": 10,
  "stop_if": ["出现非演练数据写入风险", "无法确定目标实例", "恢复命令失败"],
  "recovery_gate": [
    "live、ready与笔记读取按基线返回",
    "一个新导出完成且文件符合快照",
    "旧演练任务均有结果或明确失败原因",
    "outbox和租约等待回到可解释范围"
  ],
  "result": "not_run"
}

3. 按时间线只执行一个故障

每次记录UTC时间、目标、request_id/job_id、配置前值、操作、指标变化和恢复前值。日志示例标synthetic,实际输出标observed。任何未执行项保留not_run,不能根据理论预计填写成功。

文件:drill-result.json

{
  "record_type": "template",
  "case": "db_lock_wait",
  "started_at_utc": null,
  "target": null,
  "request_id": null,
  "job_id": null,
  "observations": [],
  "restored_at_utc": null,
  "recovery_checks": [],
  "result": "not_run"
}

4. 用固定结构编写runbook

每条runbook提供症状、只读检查、分支判断、限定动作、回滚和验收。命令中的PID、容器名、数据库和端口必须先核对;不要放“重启全部”“清空队列”之类无范围操作。

文件:runbook-export.md

# Runbook:导出已受理但长期未完成
## 识别
记录job_id、受理时间、当前数据库状态。
## 只读检查
outbox未发布?队列有人消费?租约有效?attempts/deadline是否耗尽?
## 分支
未发布→查publisher;无人消费→查路由;租约过期→按恢复规则处理。
## 动作
只处理已确认的本地实例/任务,保持次数、退避与容量约束。
## 回滚
恢复本次修改前配置,停止新故障输入。
## 验收
核对新任务、旧任务与结果文件;未恢复项明确列出。

5. 输出复盘行动项与证据索引

复盘不只记录最后一个报错。为触发原因、扩大因素、恢复机制和观测缺口分别填写证据,并把修复项写成可验收工作。示例中的行动项需要实际演练支持后才采用。

文件:postmortem-actions.md

| 类型 | 发现 | 证据 | 行动项 | 验收 |
|---|---|---|---|---|
| 触发 | 待实际观测 | 时间线链接 | 待定 | 可重复复现 |
| 扩大因素 | 待实际观测 | 指标/trace | 待定 | 相同故障影响受限 |
| 恢复缺口 | 待实际观测 | 任务状态 | 待定 | 恢复时间可测 |
| 观测缺口 | 待实际观测 | 缺失字段 | 待定 | 可定位具体阶段 |

验收结果

  • 每次故障均有范围、停止条件和实际恢复记录。
  • 恢复验证覆盖新请求、旧任务与输出一致性。
  • runbook有分支与验收,复盘行动项能被具体检查。

生产约束与故障演练

将notes-service交付维护,希望另一位开发者能按文档定位并恢复常见故障。

实现决策

  • 演练在独立环境,目标和恢复动作先明确。
  • 证据与预计行为分开,未运行不标通过。
  • 复盘行动项落实到观测、容量、一致性和恢复能力。

隔离环境演练

逐项执行本课矩阵,每次恢复基线后才进入下一项,最终检查所有临时开关与事务已关闭。

恢复验收

  • 交接者可按runbook走到具体分支。
  • 已受理任务没有不明去向。
  • 所有完成声明有实际记录,未执行部分明确列出。

常见错误

演练结束但临时故障开关还开着

原因:没有记录配置前值和恢复清单。

处理:每次演练最后核对开关、代理配置、进程和事务,跑无故障回归。

复盘只写某人误操作

原因:忽略缺少权限边界、幂等、容量或观测导致的扩大。

处理:把触发因素和系统控制缺口分别写清,并给可验收修复项。

扩展练习

把“加强监控”改成一个可验收行动项。

提示:指定指标、范围、触发条件、关联信息和验证方式。

参考答案:

例如:为export_outbox最老未发布年龄增加告警,限定服务标签,持续超过本地设定阈值时提供publisher日志入口;用暂停本地publisher的演练验证触发与恢复,数值按实际业务基线设置。

技术文档