Ver código fonte

Complete production backend curriculum and apply Shuh branding

Codex 1 mês atrás
pai
commit
d4c11cb933

+ 49 - 29
README.md

@@ -1,58 +1,78 @@
-# Agent 开发 · 后端基础补修课
+# 术核 AI · 后端工程课件
 
-单页课程教材。8 个模块保留独立入口,目前只完成 API 服务开发的 16 节课件。其他模块不包含预先编写的教学内容。
+面向掌握 Python 基础、需要补齐后端工程经验的开发者。课程按 API、数据库、持久化服务、任务、缓存、容器、部署和排障组织。
 
-## 本地查看
+| 模块          | 标识            | 课时 |
+| ------------- | --------------- | ---- |
+| API 服务开发  | api             | 16   |
+| 数据库开发    | database        | 12   |
+| API + 数据库  | api-database    | 12   |
+| 异步任务开发  | tasks           | 16   |
+| Redis 缓存    | redis           | 10   |
+| Docker 容器化 | docker          | 10   |
+| Linux 部署    | linux           | 10   |
+| 线上问题排查  | troubleshooting | 12   |
 
-项目依赖 Node.js 22.13 或更新版本。第一次运行:
+每节包含目标、概念、实战步骤、验收、排错、练习答案和官方文档。后七个模块另有生产场景、实现决策、故障演练和恢复验收。
+
+## 本地运行
+
+需要 Node.js 22.13 或更新版本。
 
 ```bash
 npm install
 npm run dev
 ```
 
-打开终端显示的地址(通常为 http://localhost:3000)。顶部「授课视图」会收起目录并放大正文和代码,适合录屏;再次点击退出。课时链接使用 `?lesson=01` 到 `?lesson=16`,可以直接定位,浏览器前进和后退可用。交互图解是浏览器内的状态模拟,真实后端需单独运行配套项目。
+访问终端显示的地址。原有 `?lesson=01` 链接继续指向 API 课时。其他模块使用 `?module=database&lesson=01`,模块概览使用 `?module=database`。浏览器前进、后退和移动端目录均可用。
 
-## 修改课件
+## 内容维护
 
-- `app/lessons.json`:16 节结构化课件。每节包括目标、概念、实战步骤、验收、排错、练习和技术文档链接。
-- `app/course.tsx`:模块目录、课件展示、视频位置、授课视图。
-- `app/demos.tsx`:HTTP 请求处理、协议消息序列、异步 I/O 时间线三个教学演示。
-- `app/globals.css`:版式、移动端与录屏样式。
-- `app/videos.json`:课时编号到视频的映射,默认留空。视频尚未录制。
-- `examples/`:可运行的 FastAPI 参考项目源代码。完整项目覆盖课程的主线端点,课堂代码包含需自行加入的演进步骤和扩展实验。
-- `public/downloads/api-lab.zip`:学员下载包。解压后的入口为 `api-lab/main.py`,按包内 README 启动。
+- `app/lessons.json`:API 模块数据。
+- `public/content/{模块标识}.json`:后续七个模块。按需载入,切换模块时显示载入或重试状态。
+- `app/module-catalog.ts`:名称、前置关系、工程边界、完成标准和课时数量。
+- `app/course.tsx`:通用课件页面,不需要为每个新模块复制页面代码。
+- `app/lesson-types.ts`:课件结构。每个模块内部课时从 `01` 连续编号;不保存教师视图或录制时长字段。
+- `app/demos.tsx`、`app/task-delivery-demo.tsx`:浏览器内概念演示。模型演示不发送实际后端请求。
+- `app/globals.css`:正文、导航和响应式样式。
+- `public/brand/`:从 shuh.ai 下载的原始 Logo、favicon 和来源记录。
+- `scripts/build-course-content.mjs`:验证课件结构、课时数、生产练习字段及文档链接格式,生成各模块 Markdown 下载。
 
-修改课时标题、时长或正文后,目录会从课程数据自动生成。阶段分组目前对应 01–04、05–08、09–10、11–16;如增删课时,需要同时调整 course.tsx 内的 phases。
+增加课时后同步修改目录数量;分组从每节 `group` 字段自动生成。修改内容后运行:
 
-## 接入课程视频
+```bash
+npm run content
+python scripts/check-python-snippets.py
+node --experimental-strip-types scripts/test-course-navigation.mjs
+npm exec tsc -- --noEmit
+npm run build
+```
 
-录制完成后,编辑 `app/videos.json`。键为两位课时编号;`type: "file"` 使用网页内播放器(浏览器支持的 MP4/WebM 文件),`type: "link"` 打开已有视频平台地址。
+## 接入视频
+
+在 `app/videos.json` 中使用“模块标识/课时编号”作为键。未配置视频时,不显示播放器或空占位。旧 API 编号键仍兼容。
 
 ```json
 {
-  "01": { "type": "file", "url": "/videos/01.mp4" },
-  "02": {
+  "api/01": { "type": "file", "url": "/videos/api-01.mp4" },
+  "database/01": {
     "type": "link",
     "url": "https://你的课程平台/实际视频地址",
-    "title": "读懂一次 HTTP 请求"
+    "title": "PostgreSQL 练习环境"
   }
 }
 ```
 
-本地视频放在 `public/videos/`;大视频建议使用可直接访问的 HTTPS 媒体地址。示例里的平台地址须换成真实地址。没有配置的课时显示「视频待录制」,不会生成假的播放入口。本页不上传或转码视频,也不绕过视频平台的访问限制。
+`file` 用于浏览器支持的 MP4/WebM 文件,可用 `captions` 指定 WebVTT 中文字幕地址;`link` 在视频平台打开。视频文件放在 `public/videos/`,或使用可访问的 HTTPS 媒体地址。这里不提供上传、转码或视频平台授权功能。
 
-## 示例项目和验证
+## 实战代码与验证边界
 
-进入 `examples`,按该目录的 README 创建 Python 虚拟环境、安装依赖并运行 FastAPI。课件网页与 Python API 是独立进程;网页上线不代表 Python 示例接口同时上线。
+`examples/` 和 `public/downloads/api-lab.zip` 是已运行验证的 FastAPI 基础参考项目,包含 30 项测试。其接口不自动包含后续课程新增的 ORM、队列和部署配置。
 
-```bash
-npm exec tsc -- --noEmit
-npm run build
-```
+后续模块在课件中按步骤创建和演进 `notes-service`,提供 SQL、Python、Compose、systemd 和 Nginx 等示例。模块 Markdown 可从网页下载。代码片段必须遵循每一步的文件路径、前置定义和替换说明,不能全部无差别追加到一个文件。外部服务实验需要学员具备对应环境;课件结构和代码语法校验不等同于真实数据库、Docker 或 Linux 故障演练已执行。
 
-示例项目含 30 项 pytest 测试,版本和真实网络验证结果见 `examples/VERIFICATION.md`。课件使用 Python 3.11+ / Pydantic 2,下载包锁定已测试版本。课程源码片段用于逐节演进,不应不加区分地全部追加到参考项目中。
+数据库使用独立 `notes_lab`,示例宿主端口为 PostgreSQL 5433、Redis 缓存 6380、任务 broker 6384;Redis 容量、AOF、复制实验分别使用 6381、6382、6383。容器内部使用服务名和 5432/6379,不能把宿主地址机械复制到容器里。数据库备份、停止进程、锁等待、资源限制等故障操作均限于课件指定的隔离环境。
 
-更新 examples 后,重新生成 `public/downloads/api-lab.zip`,只收录示例源码、依赖、说明和客户端,不包含虚拟环境、缓存或本地密钥。ZIP 内保留 `api-lab/` 顶层目录。
+## 发布
 
-网站的私有发布信息保存在 `.openai/hosting.json`。学生访问权限需要在正式发放教材前按实际授课范围设置。
+继续使用 `.openai/hosting.json` 中已有 Sites 项目。发布会更新既有网页,访问范围保持现有设置。页面上线只提供课件和静态下载,不会在 Sites 中启动 PostgreSQL、Celery 或 Python 示例服务。

+ 14 - 0
app/course-navigation.ts

@@ -0,0 +1,14 @@
+export type CourseRoute = { module: string; lesson?: string };
+export function courseHref(module: string, lesson?: string) {
+  const query = new URLSearchParams();
+  if (module !== 'api' || !lesson) query.set('module', module);
+  if (lesson) query.set('lesson', lesson);
+  return `?${query.toString()}`;
+}
+export function parseCourseQuery(search: string): CourseRoute {
+  const query = new URLSearchParams(search);
+  return {
+    module: query.get('module') || 'api',
+    lesson: query.get('lesson') || undefined,
+  };
+}

Diferenças do arquivo suprimidas por serem muito extensas
+ 436 - 362
app/course.tsx


Diferenças do arquivo suprimidas por serem muito extensas
+ 155 - 178
app/globals.css


+ 3 - 3
app/layout.tsx

@@ -4,10 +4,10 @@ import './globals.css';
 const sans = Geist({ variable: '--font-geist-sans', subsets: ['latin'] });
 const mono = Geist_Mono({ variable: '--font-geist-mono', subsets: ['latin'] });
 export const metadata: Metadata = {
-  title: 'API 服务开发 · Agent 开发补修课',
+  title: '术核 AI · 后端工程课件',
   description:
-    '面向具有 Python 基础的开发者,用 FastAPI 完成 REST 接口、异步调用、SSE 流式响应与 WebSocket 通信。配套实战步骤、代码与练习。',
-  icons: { icon: '/favicon.svg' },
+    '从 API 服务、数据库、异步任务和 Redis 到 Docker、Linux 部署与线上排障的生产系统实战课件。',
+  icons: { icon: '/brand/shuh-favicon.ico' },
 };
 export default function RootLayout({
   children,

+ 12 - 2
app/lesson-types.ts

@@ -3,7 +3,6 @@ export type Lesson = {
   group: string;
   title: string;
   summary: string;
-  minutes: number;
   objectives: string[];
   concepts: { title: string; body: string }[];
   steps: {
@@ -18,5 +17,16 @@ export type Lesson = {
   exercise: { task: string; hint: string; answer: string };
   refs: { label: string; url: string }[];
   demo?: 'http' | 'async' | 'protocol';
+  production?: {
+    scenario: string;
+    decisions: string[];
+    failureDrill: string;
+    acceptance: string[];
+  };
+};
+export type Video = {
+  type: 'file' | 'link';
+  url: string;
+  title?: string;
+  captions?: string;
 };
-export type Video = { type: 'file' | 'link'; url: string; title?: string };

+ 4 - 20
app/lessons.json

@@ -4,7 +4,6 @@
     "group": "起步与 HTTP",
     "title": "启动第一个 FastAPI 服务",
     "summary": "建立 api-lab 项目,让 /health 返回 JSON,并通过交互文档发出第一次请求。",
-    "minutes": 20,
     "objectives": [
       "为项目创建独立的 Python 环境",
       "解释 main:app、端口与 --reload 的作用",
@@ -33,7 +32,7 @@
       },
       {
         "title": "安装依赖并记录环境",
-        "body": "创建 requirements.txt,写入下面三行。然后在激活后的终端运行 python -m pip install -r requirements.txt。本模块使用 Pydantic v2 的 model_dump() 写法。安装成功后运行 python -m pip freeze > requirements-lock.txt,保留本次录课实际使用的版本;另一个环境可从这个锁定文件安装。 如果直接运行下载包,请使用包内已经锁定版本的 requirements.txt。",
+        "body": "创建 requirements.txt,写入下面三行。然后在激活后的终端运行 python -m pip install -r requirements.txt。本模块使用 Pydantic v2 的 model_dump() 写法。安装成功后运行 python -m pip freeze > requirements-lock.txt,保留当前环境实际使用的版本;另一个环境可从这个锁定文件安装。 如果直接运行下载包,请使用包内已经锁定版本的 requirements.txt。",
         "filename": "requirements.txt",
         "lang": "bash",
         "code": "fastapi>=0.115,<1\nuvicorn[standard]>=0.30,<1\npydantic>=2,<3"
@@ -105,7 +104,6 @@
     "group": "起步与 HTTP",
     "title": "读懂一次 HTTP 请求与响应",
     "summary": "拆开方法、路径、查询参数、请求头和请求体,亲手发送 GET 与 POST。",
-    "minutes": 18,
     "demo": "http",
     "objectives": [
       "在请求中指出方法、URL、请求头和请求体的位置",
@@ -201,7 +199,6 @@
     "group": "起步与 HTTP",
     "title": "从路径、查询参数和 JSON 中接收数据",
     "summary": "为笔记接口声明参数类型,让 FastAPI 完成解析、默认值填充和基础校验。",
-    "minutes": 22,
     "objectives": [
       "将 note_id 放在路径中,将筛选条件放在查询参数中",
       "用 Pydantic 模型接收 JSON 请求体",
@@ -304,7 +301,6 @@
     "group": "起步与 HTTP",
     "title": "用 Pydantic 定义输入和输出契约",
     "summary": "限制标题长度、拒绝拼错的字段,并通过 response_model 控制客户端能看到的数据。",
-    "minutes": 22,
     "objectives": [
       "用 Field 声明范围,用 extra='forbid' 发现字段拼写错误",
       "分开定义创建输入与接口输出模型",
@@ -406,7 +402,6 @@
     "group": "REST 接口",
     "title": "设计笔记资源与 HTTP 行为",
     "summary": "确定集合、单条资源、全量替换和删除的接口约定,再用请求验证方法与状态码。",
-    "minutes": 18,
     "objectives": [
       "用资源路径表达笔记集合和单条笔记",
       "区分 POST、PUT、PATCH、DELETE 的语义",
@@ -433,7 +428,7 @@
     "steps": [
       {
         "title": "写下接口清单",
-        "body": "以下是第 06 课要实现的目标契约。当前服务还处于参数练习阶段,不应把回显结果视为已保存。录课时先在纸上或请求文件中列出方法、路径、输入和成功状态,后续逐项实现。",
+        "body": "以下是第 06 课要实现的目标契约。当前服务还处于参数练习阶段,不应把回显结果视为已保存。先在纸上或请求文件中列出方法、路径、输入和成功状态,后续逐项实现。",
         "filename": "notes-contract.http",
         "lang": "http",
         "code": "GET    /notes              # 查询集合,200,响应为数组\nPOST   /notes              # 创建,201,响应包含新 id\nGET    /notes/{note_id}    # 查询单条,200 或 404\nPUT    /notes/{note_id}    # 完整替换,200 或 404\nDELETE /notes/{note_id}    # 删除,204 或 404"
@@ -506,7 +501,6 @@
     "group": "REST 接口",
     "title": "完成内存版笔记 CRUD",
     "summary": "把接口契约变成可运行程序,按创建、查询、替换、删除的顺序完成一次完整操作。",
-    "minutes": 25,
     "objectives": [
       "用字典保存笔记并生成不重复的进程内编号",
       "实现带校验与响应模型的笔记 REST 接口",
@@ -572,7 +566,7 @@
       {
         "symptom": "保存代码后刚创建的笔记找不到了",
         "cause": "--reload 重启了服务进程,内存字典被重新初始化。",
-        "fix": "完成代码编辑后重新准备数据;录制一轮 CRUD 时不要中途保存服务端文件。"
+        "fix": "完成代码编辑后重新准备数据;验证一轮 CRUD 时不要中途保存服务端文件。"
       },
       {
         "symptom": "筛选 pinned=false 返回全部数据",
@@ -610,7 +604,6 @@
     "group": "REST 接口",
     "title": "返回可定位、格式一致的错误",
     "summary": "用 HTTPException 表达已知失败,统一 404、405 与 422 的响应结构,并保留字段位置。",
-    "minutes": 22,
     "objectives": [
       "在业务失败时抛出 HTTPException",
       "为 HTTPException 和 RequestValidationError 注册处理器",
@@ -707,7 +700,6 @@
     "group": "REST 接口",
     "title": "复用依赖与拆分路由文件",
     "summary": "用 X-API-Key 练习依赖执行顺序,把 /private 放入独立 APIRouter,并通过环境变量配置课堂密钥。",
-    "minutes": 25,
     "objectives": [
       "用 Depends 在路由执行前完成可复用校验",
       "从请求头读取 X-API-Key,从环境变量读取预期值",
@@ -818,7 +810,6 @@
     "group": "异步与调用",
     "title": "使用 async / await 处理 I/O 等待",
     "summary": "用两个并发请求复现阻塞,掌握 async / await 在接口中的实际作用。",
-    "minutes": 22,
     "objectives": [
       "写出不阻塞事件循环的 /slow 接口",
       "测量串行与并发请求的耗时差异",
@@ -910,7 +901,6 @@
     "group": "异步与调用",
     "title": "使用 HTTPX 调用服务与调试 CORS",
     "summary": "复用 HTTPX 客户端,处理超时与上游错误,并定位浏览器跨域问题。",
-    "minutes": 26,
     "objectives": [
       "在应用生命周期中创建和关闭 AsyncClient",
       "区分超时、连接失败和非成功状态码",
@@ -1021,7 +1011,6 @@
     "group": "流式与实时通信",
     "title": "完整响应、流式响应、SSE 与 WebSocket",
     "summary": "从通信方向与消息格式选择接口形态,用同一份文本比较接收过程。",
-    "minutes": 18,
     "objectives": [
       "分清 REST 风格、HTTP 响应方式和 WebSocket 协议",
       "根据发送方向与数据频率选择实现",
@@ -1051,7 +1040,7 @@
       },
       {
         "title": "记录完整响应的等待",
-        "body": "在 /docs 对 POST /generate 输入下面 JSON。结果约在所有步骤完成后出现。区分响应头到达与可用业务内容到达;录屏关注用户何时看到第一段内容。",
+        "body": "在 /docs 对 POST /generate 输入下面 JSON。结果约在所有步骤完成后出现。区分响应头到达与可用业务内容到达;观察何时收到第一段内容。",
         "code": "{\"prompt\":\"hello\"}",
         "lang": "json"
       },
@@ -1107,7 +1096,6 @@
     "group": "流式与实时通信",
     "title": "用 StreamingResponse 输出 SSE 事件",
     "summary": "实现有限长度文本流,定义 token / done 事件,并处理客户端断开。",
-    "minutes": 25,
     "objectives": [
       "用异步生成器逐段发送 text/event-stream",
       "使用 JSON 编码 data 字段并保留空行边界",
@@ -1209,7 +1197,6 @@
     "group": "流式与实时通信",
     "title": "在浏览器中读取流,并在中途停止",
     "summary": "分别使用 EventSource 与 fetch,处理 UTF-8 分块、事件边界和取消请求。",
-    "minutes": 30,
     "objectives": [
       "用 EventSource 监听命名事件并主动关闭",
       "用 ReadableStream 解析可能被拆开的 SSE 数据",
@@ -1314,7 +1301,6 @@
     "group": "流式与实时通信",
     "title": "建立第一条 WebSocket 双向连接",
     "summary": "实现 /ws 回显,观察建立、发送、接收和关闭的完整过程。",
-    "minutes": 23,
     "objectives": [
       "使用 accept、receive_text 和 send_json 完成回显",
       "在浏览器连接打开后发送消息",
@@ -1407,7 +1393,6 @@
     "group": "流式与实时通信",
     "title": "连接断开后:重连、心跳与取消",
     "summary": "给 WebSocket 加上有限重试和定时器清理,验证停止后不会自行恢复。",
-    "minutes": 30,
     "objectives": [
       "在异常关闭后用指数退避重新连接",
       "通过应用层 ping / pong 检测连接是否仍可用",
@@ -1508,7 +1493,6 @@
     "group": "联调与验收",
     "title": "把能运行的接口变成可重复验收的服务",
     "summary": "用 pytest 检查 CRUD、错误、认证、SSE 和 WebSocket,再完成一次真实浏览器联调。",
-    "minutes": 32,
     "objectives": [
       "使用 TestClient 在测试中运行 FastAPI 应用",
       "为正常路径和 401 / 404 / 422 写出明确断言",

+ 197 - 0
app/module-catalog.ts

@@ -0,0 +1,197 @@
+import {
+  Code2,
+  Database,
+  Layers3,
+  ListTodo,
+  MemoryStick,
+  Container,
+  Server,
+  Activity,
+} from 'lucide-react';
+export const modules = [
+  {
+    slug: 'api',
+    name: 'API 服务开发',
+    icon: Code2,
+    count: 16,
+    kicker: '接口与通信',
+    stack: 'FastAPI · HTTP · SSE · WebSocket',
+    description:
+      '从 Python 函数到可调用的服务,完成参数校验、REST 接口、异步调用、流式响应与双向通信。',
+    project: '笔记与消息服务',
+    scope:
+      '用内存版服务理解接口行为。生产中的数据持久化、连接预算和异步任务由后续模块逐步补齐。',
+    prerequisites: ['Python 函数、列表与字典', '能创建虚拟环境、执行终端命令'],
+    depends: [],
+    outcomes: [
+      '从空目录启动服务,用 /docs 和 curl 验证接口。',
+      '完成 CRUD,正确处理状态码、输入校验与错误。',
+      '消费 SSE 并释放连接,处理 WebSocket 断开。',
+      '用自动化测试复现接口成功与失败路径。',
+    ],
+  },
+  {
+    slug: 'database',
+    name: '数据库开发',
+    icon: Database,
+    count: 12,
+    kicker: '数据与事务',
+    stack: 'PostgreSQL 16 · SQL · psycopg 3',
+    description:
+      '建立有约束的数据模型,编写查询,在并发和故障下验证事务、锁、索引与数据恢复。',
+    project: 'notes_lab 数据库',
+    scope:
+      '从表结构和 SQL 开始,完成查询计划分析、并发事务实验、最小权限与可恢复的备份。',
+    prerequisites: [
+      '已完成 API 服务开发',
+      'PostgreSQL 16 与独立 notes_lab 练习库',
+    ],
+    depends: ['api'],
+    outcomes: [
+      '用数据库约束保护数据,而非仅依赖应用校验。',
+      '使用两个会话复现隔离、锁等待和死锁。',
+      '根据 EXPLAIN 与实际数据选择索引和查询方式。',
+      '完成受限账户访问、备份与隔离恢复验收。',
+    ],
+  },
+  {
+    slug: 'api-database',
+    name: 'API + 数据库',
+    icon: Layers3,
+    count: 12,
+    kicker: '持久化服务',
+    stack: 'SQLAlchemy 2 · Alembic · PostgreSQL',
+    description:
+      '把笔记接口接入数据库,明确会话与事务边界,处理并发更新、幂等请求和兼容性迁移。',
+    project: 'notes-service 持久化 API',
+    scope:
+      '以请求生命周期组织连接、事务和错误;用版本字段、唯一约束与 outbox 处理跨请求及跨进程一致性。',
+    prerequisites: ['REST 接口与 Pydantic 模型', 'SQL、事务和数据库连接'],
+    depends: ['api', 'database'],
+    outcomes: [
+      '每个请求独立使用 Session,并在明确边界提交或回滚。',
+      '验证连接池预算、超时、重复提交与并发修改。',
+      '通过 Alembic 实施兼容性迁移并验证旧版本行为。',
+      '以真实 PostgreSQL 测试约束、事务和故障路径。',
+    ],
+  },
+  {
+    slug: 'tasks',
+    name: '异步任务开发',
+    icon: ListTodo,
+    count: 16,
+    kicker: '并发与任务执行',
+    stack: 'asyncio · threading · multiprocessing · Celery',
+    description:
+      '根据 I/O、CPU、隔离与持久性要求选择执行方式,把长任务变成可限流、可重试、可恢复的任务系统。',
+    project: '笔记导出与长文处理任务',
+    scope:
+      '从事件循环、线程和进程开始,再引入任务队列、数据库状态、outbox、工作租约和故障恢复。',
+    prerequisites: [
+      'API 与数据库事务',
+      'Linux / WSL2 运行 Celery worker;Redis 基本连接',
+    ],
+    depends: ['api-database'],
+    outcomes: [
+      '为 I/O 与 CPU 工作选择有界的协程、线程池或进程池。',
+      '区分进程内后台任务与可持久化任务队列。',
+      '处理重复投递、超时、取消、重试与 worker 退出。',
+      '验证状态机、幂等、背压、调度与任务积压告警。',
+    ],
+  },
+  {
+    slug: 'redis',
+    name: 'Redis 缓存',
+    icon: MemoryStick,
+    count: 10,
+    kicker: '缓存与协调',
+    stack: 'Redis 7 · redis-py · Cache Aside',
+    description:
+      '为读请求引入缓存,明确数据真源、失效窗口和容量边界,复现穿透、击穿、雪崩与 Redis 故障。',
+    project: 'notes-service 查询缓存',
+    scope:
+      '缓存只承载可重建数据;围绕更新失效、热点保护、并发控制、内存策略与故障降级设计实现。',
+    prerequisites: ['持久化 API 与事务提交边界', 'Redis 独立练习实例'],
+    depends: ['api-database'],
+    outcomes: [
+      '实现包含 TTL 与键版本的缓存读取和更新失效。',
+      '在并发下验证缓存击穿、过期抖动与热点保护。',
+      '设置内存预算、连接超时与可用的失败降级。',
+      '明确锁、计数器、会话与队列各自的可靠性边界。',
+    ],
+  },
+  {
+    slug: 'docker',
+    name: 'Docker 容器化',
+    icon: Container,
+    count: 10,
+    kicker: '构建与运行',
+    stack: 'Docker Engine · Compose · OCI Images',
+    description:
+      '构建可复现镜像,组合 API、数据库、缓存与 worker,管理数据、网络、资源、健康检查与升级。',
+    project: 'notes-service 容器运行环境',
+    scope:
+      '区分镜像、容器和持久卷;用非 root 运行、固定依赖、资源上限、迁移作业与可回滚发布管理服务。',
+    prerequisites: [
+      'API、数据库、Redis 与任务进程的职责',
+      'Docker Engine / Docker Desktop 与 Compose',
+    ],
+    depends: ['tasks', 'redis'],
+    outcomes: [
+      '构建分层合理、非 root 运行的应用镜像。',
+      '使用服务名、健康检查和持久卷组织多容器系统。',
+      '验证优雅退出、资源限制、密钥注入与日志输出。',
+      '在保留数据库数据的条件下完成升级与回滚。',
+    ],
+  },
+  {
+    slug: 'linux',
+    name: 'Linux 部署',
+    icon: Server,
+    count: 10,
+    kicker: '交付与运维',
+    stack: 'Linux · systemd · Nginx · TLS',
+    description:
+      '把本地可运行服务部署到 Linux,建立权限边界、进程管理、反向代理、发布回滚与备份恢复流程。',
+    project: 'notes-service Linux 部署',
+    scope:
+      '以独立练习主机为环境,验证服务启动、网络入口、流式代理、凭证权限、发布检查与恢复手册。',
+    prerequisites: ['容器与服务网络', '独立 Linux 练习机,使用普通账户与 sudo'],
+    depends: ['docker'],
+    outcomes: [
+      '建立最小权限运行账户、目录权限与网络入口。',
+      '管理 systemd 服务并验证重启和停止行为。',
+      '为 HTTP、SSE 和 WebSocket 配置代理与 TLS。',
+      '执行有健康门槛的发布、回滚及隔离恢复演练。',
+    ],
+  },
+  {
+    slug: 'troubleshooting',
+    name: '线上问题排查',
+    icon: Activity,
+    count: 12,
+    kicker: '观测与故障恢复',
+    stack: 'Logs · Metrics · Traces · Runbooks',
+    description:
+      '从请求症状建立证据链,定位 CPU、内存、连接、数据库、缓存与队列问题,验证止损和修复效果。',
+    project: 'notes-service 故障演练环境',
+    scope:
+      '先定义影响范围,再观察指标、日志和追踪;在隔离环境复现故障,记录恢复操作与复发条件。',
+    prerequisites: [
+      'API、数据库、任务与缓存实现',
+      'Linux 部署与独立故障演练环境',
+    ],
+    depends: ['linux'],
+    outcomes: [
+      '用请求 ID、日志与指标缩小问题范围。',
+      '区分事件循环阻塞、CPU 饱和、内存增长和连接耗尽。',
+      '定位慢 SQL、锁等待、缓存失效与任务堆积。',
+      '验证恢复门槛、回滚、容量改进与复盘行动。',
+    ],
+  },
+] as const;
+export type ModuleSlug = (typeof modules)[number]['slug'];
+export type ModuleInfo = (typeof modules)[number];
+export function getModule(slug: string) {
+  return modules.find((m) => m.slug === slug);
+}

+ 165 - 0
app/task-delivery-demo.tsx

@@ -0,0 +1,165 @@
+'use client';
+import { useState } from 'react';
+import {
+  ArrowRight,
+  Check,
+  RotateCcw,
+  SkipForward,
+  Unplug,
+  Database,
+  Network,
+  Server,
+  Code2,
+} from 'lucide-react';
+import { Button } from '@/components/ui/button';
+const states = {
+  ready: {
+    title: '等待请求',
+    detail: '尚未创建任务。客户端准备提交一次笔记导出请求。',
+    node: -1,
+    deliveries: 0,
+    writes: 0,
+  },
+  persisted: {
+    title: '事务提交',
+    detail: 'API 在同一数据库事务写入任务与 outbox 记录。任务 ID 为 job-42。',
+    node: 1,
+    deliveries: 0,
+    writes: 0,
+  },
+  queued: {
+    title: '消息已发布',
+    detail: '发布器把 job-42 发送到队列。消息中的 ID 引用数据库任务记录。',
+    node: 2,
+    deliveries: 1,
+    writes: 0,
+  },
+  running: {
+    title: 'Worker 执行',
+    detail:
+      'Worker 取得执行资格,使用 job-42 作为本次操作的去重键。消息尚未 ACK。',
+    node: 3,
+    deliveries: 1,
+    writes: 0,
+  },
+  committed: {
+    title: '业务事务已提交,尚未 ACK',
+    detail: '结果和去重记录已原子提交。此时崩溃,队列仍可能再次投递消息。',
+    node: 1,
+    deliveries: 1,
+    writes: 1,
+  },
+  crashed: {
+    title: 'Worker 在 ACK 前退出',
+    detail:
+      '已提交的数据库结果仍存在。未确认的消息在队列恢复机制触发后重新投递。',
+    node: 2,
+    deliveries: 1,
+    writes: 1,
+  },
+  redelivered: {
+    title: '再次收到同一个任务',
+    detail:
+      '新的 Worker 检查 job-42 的已完成记录,返回已有结果,不再重复写入。',
+    node: 3,
+    deliveries: 2,
+    writes: 1,
+  },
+  done: {
+    title: '确认消息,处理完成',
+    detail: '消息被 ACK。正常执行时投递一次;经过故障恢复时可能投递两次。',
+    node: 3,
+    deliveries: 1,
+    writes: 1,
+  },
+  recovered: {
+    title: '重复投递已处理',
+    detail: '重复消息被 ACK。队列投递了两次,数据库副作用只提交一次。',
+    node: 3,
+    deliveries: 2,
+    writes: 1,
+  },
+};
+type State = keyof typeof states;
+const next: Partial<Record<State, State>> = {
+  ready: 'persisted',
+  persisted: 'queued',
+  queued: 'running',
+  running: 'committed',
+  committed: 'done',
+  crashed: 'redelivered',
+  redelivered: 'recovered',
+};
+export function TaskDeliveryDemo() {
+  const [state, setState] = useState<State>('ready');
+  const s = states[state];
+  return (
+    <div className="demo delivery-demo">
+      <div className="demo-top">
+        <span className="demo-label">提交成功后,消息为什么还会重复?</span>
+        <span className="simulation">状态模型演示 · 不连接真实服务</span>
+      </div>
+      <div className="delivery-nodes">
+        {[
+          { name: 'API', icon: Code2 },
+          { name: 'PostgreSQL', icon: Database },
+          { name: '消息队列', icon: Network },
+          { name: 'Worker', icon: Server },
+        ].map((n, i) => (
+          <div className={s.node === i ? 'current' : ''} key={n.name}>
+            <n.icon size={22} />
+            <span>{n.name}</span>
+            {i < 3 && <ArrowRight size={13} />}
+          </div>
+        ))}
+      </div>
+      <div className="demo-result" aria-live="polite">
+        <div>
+          <h3>{s.title}</h3>
+          <p>{s.detail}</p>
+        </div>
+      </div>
+      <div className="delivery-counters">
+        <span>
+          任务 ID <strong>job-42</strong>
+        </span>
+        <span>
+          消息投递 <strong>{s.deliveries}</strong>
+        </span>
+        <span>
+          业务写入 <strong>{s.writes}</strong>
+        </span>
+      </div>
+      <div className="demo-controls">
+        <Button
+          variant="outline"
+          disabled={!next[state]}
+          onClick={() => setState(next[state]!)}
+        >
+          <SkipForward />
+          {state === 'ready' ? '提交任务' : '推进一步'}
+        </Button>
+        <Button
+          variant="ghost"
+          disabled={state !== 'committed'}
+          onClick={() => setState('crashed')}
+        >
+          <Unplug />
+          提交后崩溃
+        </Button>
+        <Button
+          variant="ghost"
+          onClick={() => setState('ready')}
+          aria-label="重置任务投递演示"
+        >
+          <RotateCcw />
+        </Button>
+        {!next[state] && <Check size={16} />}
+      </div>
+      <p className="demo-footnote">
+        本例假设业务结果与去重记录处于同一个数据库事务,并有唯一约束。发送邮件、调用支付等外部副作用需要单独设计幂等协议。队列恢复时间取决于
+        broker、ACK 和 visibility timeout 配置。
+      </p>
+    </div>
+  );
+}

+ 2 - 1
package.json

@@ -10,7 +10,8 @@
     "build": "vinext build",
     "start": "wrangler dev --config dist/server/wrangler.json",
     "lint": "oxlint",
-    "format": "oxfmt"
+    "format": "oxfmt",
+    "content": "node scripts/build-course-content.mjs"
   },
   "dependencies": {
     "react": "19.2.6",

+ 11 - 0
public/brand/SOURCE.md

@@ -0,0 +1,11 @@
+# 品牌资产来源
+
+经课程维护者要求,从术核官网直接下载并保留原始图像。
+
+- Logo:https://shuh.ai/assets/logo-white-B9Rl8IW3.png
+- Favicon:https://shuh.ai/assets/favicon-BqqbBh4J.ico
+- 配色参考:https://shuh.ai/assets/index-g0ba3F6d.css
+- 官网:https://shuh.ai/
+- 获取日期:2026-09-10
+
+官网主色为 #2f6bff,深藏蓝为 #071126,正文为 #111827,辅助文字为 #667085,浅色区域为 #f8fafc。本课件保留浅色阅读区,使用官网的深色导航与蓝色交互强调。

BIN
public/brand/shuh-favicon.ico


BIN
public/brand/shuh-logo-white.png


+ 1334 - 0
public/content/api-database.json

@@ -0,0 +1,1334 @@
+[
+  {
+    "id": "01",
+    "group": "连接与事务",
+    "title": "建立 notes-service 的配置与目录",
+    "summary": "把 API 路由、数据库连接和环境配置分开,为后续事务与迁移建立可重复的练习环境。",
+    "objectives": [
+      "建立可独立启动的 notes-service 项目",
+      "从环境读取数据库地址并在启动时校验",
+      "区分练习账号、迁移权限和线上运行权限"
+    ],
+    "concepts": [
+      {
+        "title": "项目边界",
+        "body": "本模块在新的 notes-service 目录逐课演进。数据库沿用数据库开发模块的 PostgreSQL 16、notes_lab 与 app_user。它与已有 api-lab 下载包相互独立;下面的端点需要按课件新建和整合,不能在旧下载包直接调用。没有做过数据库模块的同学先准备独立的本机 PostgreSQL 16 与空 notes_lab 数据库。"
+      },
+      {
+        "title": "配置与代码",
+        "body": "数据库地址随机器变化,表结构与业务规则随代码版本变化。配置放在环境变量或本机 .env,代码只保存字段声明和 .env.example。URL 中的密码若含 @、/、: 等字符要百分号编码;日志只报告配置项缺失,不输出完整连接串。"
+      },
+      {
+        "title": "生产账号分工",
+        "body": "练习 app_user 可创建表以便完成迁移;上线通常由独立迁移账号拥有 DDL 权限,运行账号仅获得当前服务所需表的读写及 identity 序列权限。健康检查不证明账号能完成所有业务写操作,权限仍需在部署验收中覆盖。"
+      }
+    ],
+    "steps": [
+      {
+        "title": "新建项目并安装依赖",
+        "body": "在新的 notes-service 根目录执行。以下为 PowerShell 命令;Linux 用 python3 -m venv .venv 并激活对应环境。这里固定大版本范围,完成验收后生成锁定文件。",
+        "code": "mkdir notes-service\ncd notes-service\npy -3 -m venv .venv\n.\\.venv\\Scripts\\python.exe -m pip install \"fastapi>=0.121,<1\" \"uvicorn[standard]>=0.30,<1\" \"pydantic-settings>=2,<3\" \"SQLAlchemy>=2.0,<2.1\" \"psycopg[binary]>=3,<4\" \"alembic>=1.13,<2\" \"httpx>=0.27,<1\" \"pytest>=8,<10\" \"pytest-asyncio>=1,<2\"\nmkdir app\nNew-Item app/__init__.py -ItemType File\n.\\.venv\\Scripts\\python.exe -m pip freeze > requirements-lock.txt",
+        "lang": "powershell"
+      },
+      {
+        "title": "新建环境模板",
+        "body": "新建 .env.example,再复制为 .env,填写你在独立练习库设置的密码。不要把此示例密码用在其他环境。DATABASE_URL 的 psycopg 方言由下一课 create_async_engine 选择异步驱动。",
+        "code": "DATABASE_URL=postgresql+psycopg://app_user:CHANGE_ME@127.0.0.1:5433/notes_lab\nDB_POOL_SIZE=5\nDB_MAX_OVERFLOW=0\nDB_POOL_TIMEOUT=2",
+        "lang": "dotenv",
+        "filename": ".env.example"
+      },
+      {
+        "title": "新建配置模块",
+        "body": "Settings() 会读取当前项目根目录下的 .env;启动命令始终在 notes-service 根目录运行。env 中同名变量优先于文件。约束使拼错的负数连接数在启动时失败。",
+        "code": "from pydantic import Field\nfrom pydantic_settings import BaseSettings, SettingsConfigDict\n\nclass Settings(BaseSettings):\n    model_config = SettingsConfigDict(env_file=\".env\", extra=\"ignore\")\n    database_url: str = Field(min_length=1)\n    db_pool_size: int = Field(default=5, ge=1, le=50)\n    db_max_overflow: int = Field(default=0, ge=0, le=50)\n    db_pool_timeout: float = Field(default=2, gt=0, le=30)\n\nsettings = Settings()",
+        "lang": "python",
+        "filename": "app/config.py"
+      },
+      {
+        "title": "隔离本机配置并验证导入",
+        "body": "新建 .gitignore,内容如下。在虚拟环境运行 python -c \"from app.config import settings; print(settings.db_pool_size)\",只应输出 5。临时把 DB_POOL_SIZE 改成 -1,导入应出现校验失败;恢复后继续。",
+        "code": ".env\n.venv/\n__pycache__/\n.pytest_cache/",
+        "lang": "text",
+        "filename": ".gitignore"
+      },
+      {
+        "title": "验证目标库与现有表",
+        "body": "使用你安装的 psql 连接本机练习库;-W 交互输入密码。检查数据库和账号,禁止对共享数据库练习建表和失败注入。已有 notes 表在第 3 课核对结构。 沿用数据库模块的 Docker 本机端口映射 5433:5432;若安装原生 PostgreSQL,按实际监听端口同步修改 .env 与 psql 参数。",
+        "code": "psql -h 127.0.0.1 -p 5433 -U app_user -d notes_lab -W\n-- 以下在 psql 中执行:\nSELECT current_database(), current_user, version();\nSELECT to_regclass('public.notes');",
+        "lang": "sql"
+      }
+    ],
+    "checks": [
+      "缺少 DATABASE_URL 时启动配置校验失败;日志未打印密码。",
+      "requirements-lock.txt 记录了实际安装版本,app/__init__.py 已存在。",
+      "psql 显示数据库为 notes_lab、账号为 app_user,且是专用练习环境。"
+    ],
+    "pitfalls": [
+      {
+        "symptom": "导入配置提示 Field required",
+        "cause": "未创建 .env,或从错误工作目录运行。",
+        "fix": "在 notes-service 根目录复制 .env.example 为 .env 并填写数据库连接;不要在 Python 中硬编码密码绕过。"
+      },
+      {
+        "symptom": "数据库地址正确却认证失败",
+        "cause": "URL 特殊字符未编码,或账号密码与本机实例不一致。",
+        "fix": "先用 psql -W 验证凭据,再修正 URL 的密码编码;不要把连接串贴进排错截图。"
+      }
+    ],
+    "exercise": {
+      "task": "新增 app_env 配置,只接受 local、test、production。",
+      "hint": "用 typing.Literal 声明允许值。",
+      "answer": "在 config.py 导入 Literal,然后在 Settings 中加入 app_env: Literal[\"local\", \"test\", \"production\"] = \"local\"。在 .env 设置 APP_ENV=staging 应触发校验失败。"
+    },
+    "refs": [
+      {
+        "label": "Pydantic Settings:环境配置",
+        "url": "https://docs.pydantic.dev/latest/concepts/pydantic_settings/"
+      },
+      {
+        "label": "SQLAlchemy:数据库 URL",
+        "url": "https://docs.sqlalchemy.org/en/20/core/engines.html#database-urls"
+      }
+    ],
+    "production": {
+      "scenario": "同一份代码在开发、测试和线上读取不同数据库配置。",
+      "decisions": [
+        "配置缺失立即失败,不回退连接开发数据库。",
+        "运行账号与迁移账号分开管理;练习账号权限不直接复制到线上。"
+      ],
+      "failureDrill": "只在本机把端口改为未监听端口,记录配置校验成功而连接失败的区别,恢复配置。",
+      "acceptance": [
+        "配置不含默认真实密码。",
+        "能用脱敏信息判断地址、认证、权限三个故障层。"
+      ]
+    }
+  },
+  {
+    "id": "02",
+    "group": "连接与事务",
+    "title": "异步 Session 的生命周期",
+    "summary": "建立一个进程级连接池,每个请求创建自己的 AsyncSession,并明确同步与异步驱动的使用位置。",
+    "objectives": [
+      "解释 Engine、连接池、连接与 Session 的关系",
+      "通过依赖管理请求 Session 并释放资源",
+      "避免在 asyncio 并发任务或线程之间共享 Session"
+    ],
+    "concepts": [
+      {
+        "title": "选择一条完整的执行路径",
+        "body": "本模块使用 async def 路由、SQLAlchemy AsyncSession 与 psycopg 3 异步连接。同步方案也可用于阻塞型后端:def 路由搭配同步 Session,让框架在线程池执行;不能在 async def 中直接执行同步数据库 I/O。异步提升等待期间的并发利用率,不使数据库查询本身更快。"
+      },
+      {
+        "title": "对象的生命周期",
+        "body": "Engine 与 sessionmaker 在每个服务进程内复用;Session 属于一次请求或一个任务。Session 通常到执行 SQL 时才借连接。事务结束后连接归还池,Session 关闭清理剩余状态;进程退出还需 dispose Engine。"
+      },
+      {
+        "title": "并发安全",
+        "body": "Session 跟踪待写对象、事务与连接,不能传给 asyncio.gather 的多个并发分支,也不能传进工作线程。独立事务的任务各自通过 SessionFactory() 获取 Session;要求原子的多步操作应在同一任务中顺序使用同一事务。"
+      }
+    ],
+    "steps": [
+      {
+        "title": "新建数据库模块",
+        "body": "依赖上一课 app/config.py。create_async_engine 使用 postgresql+psycopg URL 自动选择异步连接。连接与 SQL 超时是本课程起始练习值,后续用预算与测量调整。",
+        "code": "from typing import Annotated\nfrom fastapi import Depends\nfrom sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine\nfrom app.config import settings\n\nengine = create_async_engine(\n    settings.database_url,\n    pool_size=settings.db_pool_size,\n    max_overflow=settings.db_max_overflow,\n    pool_timeout=settings.db_pool_timeout,\n    pool_pre_ping=True,\n    connect_args={\"connect_timeout\": 3, \"options\": \"-c statement_timeout=3000 -c lock_timeout=1000\"},\n)\nSessionFactory = async_sessionmaker(engine, expire_on_commit=False)\n\nasync def get_session():\n    async with SessionFactory() as session:\n        yield session\n\nDb = Annotated[AsyncSession, Depends(get_session, scope=\"function\")]",
+        "lang": "python",
+        "filename": "app/db.py"
+      },
+      {
+        "title": "新建应用入口",
+        "body": "依赖上一步 db.py。/live 仅证明进程能响应;/ready 尝试查询数据库。生产探针需结合平台超时设置,readiness 失败使流量停止进入该实例,不应用于不断重启所有实例。",
+        "code": "from contextlib import asynccontextmanager\nfrom fastapi import FastAPI, HTTPException\nfrom sqlalchemy import text\nfrom sqlalchemy.exc import SQLAlchemyError\nfrom app.db import Db, engine\n\n@asynccontextmanager\nasync def lifespan(app: FastAPI):\n    yield\n    await engine.dispose()\n\napp = FastAPI(title=\"notes-service\", lifespan=lifespan)\n\n@app.get(\"/live\")\nasync def live():\n    return {\"status\": \"ok\"}\n\n@app.get(\"/ready\")\nasync def ready(db: Db):\n    try:\n        async with db.begin():\n            await db.execute(text(\"SELECT 1\"))\n    except SQLAlchemyError as exc:\n        raise HTTPException(503, \"database unavailable\") from exc\n    return {\"status\": \"ready\"}",
+        "lang": "python",
+        "filename": "app/main.py"
+      },
+      {
+        "title": "启动并检查资源释放",
+        "body": "Linux 可执行 python -m uvicorn app.main:app --reload。Windows 的 psycopg 异步连接需要 SelectorEventLoop;新建下面的本机启动器后执行 python run_server.py。此入口只用于本机单进程练习,修改代码后手工重启。",
+        "code": "import asyncio\nimport sys\nimport uvicorn\n\nif __name__ == \"__main__\":\n    if sys.platform == \"win32\":\n        asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())\n    uvicorn.run(\"app.main:app\", host=\"127.0.0.1\", port=8000, loop=\"asyncio\")",
+        "lang": "python",
+        "filename": "run_server.py"
+      },
+      {
+        "title": "观察借还连接",
+        "body": "访问 /ready 十次,再在 psql 查询下面 SQL。看到少量 idle 池连接是正常的;idle in transaction 才表示事务已开启但没有继续工作。请求数不应使连接数持续增长。",
+        "code": "SELECT application_name, state, count(*)\nFROM pg_stat_activity\nWHERE datname = 'notes_lab' AND usename = 'app_user'\nGROUP BY application_name, state;",
+        "lang": "sql"
+      },
+      {
+        "title": "单独任务使用自己的会话",
+        "body": "新建 scripts 目录及 __init__.py,再建立 scripts/probe.py,执行 python -m scripts.probe。不要把路由 db 传入 worker;每个 probe 仅持有自己的事务。Windows 在此脚本同样使用 Selector 策略。",
+        "code": "import asyncio\nimport sys\nfrom sqlalchemy import text\nfrom app.db import SessionFactory, engine\n\nasync def probe():\n    async with SessionFactory.begin() as db:\n        return await db.scalar(text(\"SELECT 1\"))\n\nasync def main():\n    try:\n        print(await asyncio.gather(probe(), probe()))\n    finally:\n        await engine.dispose()\n\nif __name__ == \"__main__\":\n    if sys.platform == \"win32\":\n        asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())\n    asyncio.run(main())",
+        "lang": "python",
+        "filename": "scripts/probe.py"
+      }
+    ],
+    "checks": [
+      "/live 和 /ready 都返回 200;停止本机数据库后 /live 仍成功而 /ready 为 503。",
+      "连续请求结束后没有属于 API 的 idle in transaction 连接。",
+      "两个 probe 返回 [1, 1],各自创建会话,退出时 Engine 已 dispose。"
+    ],
+    "pitfalls": [
+      {
+        "symptom": "出现 MissingGreenlet 或异步驱动不兼容",
+        "cause": "异步 ORM 触发隐式 I/O,或 Windows 使用了 Proactor 事件循环。",
+        "fix": "先确认错误堆栈:循环问题使用本课启动器;属性加载问题按第 5 课显式加载,不要混用同步驱动。"
+      },
+      {
+        "symptom": "并发时提示 transaction already begun / operation in progress",
+        "cause": "同一个 Session 被多个协程共享。",
+        "fix": "把 SessionFactory 传给任务或在每个任务内部创建 Session;同一原子事务保持顺序执行。"
+      }
+    ],
+    "exercise": {
+      "task": "让两个并发任务分别返回当前数据库连接的 PID。",
+      "hint": "SELECT pg_backend_pid() 代替 SELECT 1;不要对 PID 值做相等或不等的断言。",
+      "answer": "probe 中返回 await db.scalar(text(\"SELECT pg_backend_pid()\"))。由于连接池会复用连接,PID 可能相同也可能不同;判断会话隔离应检查每个任务创建了自己的 Session,而非只看 PID。"
+    },
+    "refs": [
+      {
+        "label": "SQLAlchemy:异步 ORM",
+        "url": "https://docs.sqlalchemy.org/en/20/orm/extensions/asyncio.html"
+      },
+      {
+        "label": "Psycopg:异步与 Windows",
+        "url": "https://www.psycopg.org/psycopg3/docs/advanced/async.html"
+      },
+      {
+        "label": "FastAPI:依赖作用域",
+        "url": "https://fastapi.tiangolo.com/advanced/advanced-dependencies/"
+      }
+    ],
+    "production": {
+      "scenario": "一个实例同时接收普通 API、慢客户端与后台任务。",
+      "decisions": [
+        "请求依赖只负责会话关闭,业务函数在返回前完成事务。",
+        "慢流式响应先查询有限数据并转 DTO,再关闭事务;不把连接绑定整个流。"
+      ],
+      "failureDrill": "在隔离环境连续调用 readiness 并中断客户端,查询 pg_stat_activity 确认没有遗留事务。",
+      "acceptance": [
+        "Session 不跨并发任务共享。",
+        "数据库不可用时 readiness 为 503,响应不包含数据库密码或原始异常。"
+      ]
+    }
+  },
+  {
+    "id": "03",
+    "group": "连接与事务",
+    "title": "ORM CRUD 与输入输出模型",
+    "summary": "把 notes 表映射为 ORM,对输入约束、数据库实体和 API 响应分别建模。",
+    "objectives": [
+      "对齐 PostgreSQL 字段类型与 Python 映射",
+      "实现创建、读取、修改和删除笔记",
+      "将 ORM 对象在事务内转换为稳定的 DTO"
+    ],
+    "concepts": [
+      {
+        "title": "三种模型回答不同问题",
+        "body": "数据库表定义存储约束,ORM 定义 Python 对象与表的映射,Pydantic DTO 定义允许客户端提交和读取的字段。客户端不能提交 id 或 created_at。即使 Pydantic 校验通过,数据库约束仍是其他写入路径的最后防线。"
+      },
+      {
+        "title": "flush 与 commit",
+        "body": "add 只把对象加入会话;flush 执行 INSERT 并取得 id,但事务尚未提交。离开 db.begin() 的成功路径才 commit。先生成 DTO 可以发现类型或字段缺失问题,成功离开事务后再返回 HTTP 响应。"
+      },
+      {
+        "title": "PATCH 的省略与空值",
+        "body": "PATCH 只更新本次提供的字段。exclude_unset=True 保留显式传入的 null,因此必须决定 null 的业务含义。本服务的三个可修改字段均 NOT NULL,显式 null 返回 422;没有传入的字段保持原值。"
+      }
+    ],
+    "steps": [
+      {
+        "title": "新建基础 ORM",
+        "body": "依赖 app/db.py。数据库模块已有 public.notes 时先用 \\d+ notes 核对:本课不改变已有表。没有表时执行下一步初始化 SQL。updated_at 由本模块所有写路径主动刷新,不声称 server_default 会在 UPDATE 自动执行。",
+        "code": "from datetime import datetime\nfrom sqlalchemy import BigInteger, Boolean, DateTime, Identity, String, Text, func, text\nfrom sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column\n\nclass Base(DeclarativeBase):\n    pass\n\nclass Note(Base):\n    __tablename__ = \"notes\"\n    id: Mapped[int] = mapped_column(BigInteger, Identity(), primary_key=True)\n    title: Mapped[str] = mapped_column(String(120), nullable=False)\n    content: Mapped[str] = mapped_column(Text, nullable=False, server_default=text(\"''\"))\n    pinned: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text(\"false\"))\n    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())\n    updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now())",
+        "lang": "python",
+        "filename": "app/models.py"
+      },
+      {
+        "title": "仅对没有 notes 表的练习库初始化",
+        "body": "在 notes_lab 的 psql 执行。已有表不执行 CREATE;表缺字段时回到数据库模块对齐,不使用 drop/recreate 破坏数据。第 7 课将这些 DDL 纳入版本迁移。",
+        "code": "CREATE TABLE notes (\n    id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,\n    title varchar(120) NOT NULL,\n    content text NOT NULL DEFAULT '',\n    pinned boolean NOT NULL DEFAULT false,\n    created_at timestamptz NOT NULL DEFAULT now(),\n    updated_at timestamptz NOT NULL DEFAULT now()\n);",
+        "lang": "sql"
+      },
+      {
+        "title": "新建输入输出模型",
+        "body": "创建、响应和局部更新分开。内容限制为 100000 字符是练习的业务上限,代理请求体字节上限还需在部署模块设置;字符数不是字节数。",
+        "code": "from datetime import datetime\nfrom pydantic import BaseModel, ConfigDict, Field, model_validator\n\nclass NoteCreate(BaseModel):\n    model_config = ConfigDict(extra=\"forbid\", str_strip_whitespace=True)\n    title: str = Field(min_length=1, max_length=120)\n    content: str = Field(default=\"\", max_length=100000)\n    pinned: bool = False\n\nclass NotePatch(BaseModel):\n    model_config = ConfigDict(extra=\"forbid\")\n    title: str | None = Field(default=None, min_length=1, max_length=120)\n    content: str | None = Field(default=None, max_length=100000)\n    pinned: bool | None = None\n\n    @model_validator(mode=\"after\")\n    def reject_null(self):\n        if any(getattr(self, name) is None for name in self.model_fields_set):\n            raise ValueError(\"provided fields must not be null\")\n        if self.title is not None:\n            self.title = self.title.strip()\n            if not self.title:\n                raise ValueError(\"title must not be blank\")\n        return self\n\nclass NoteOut(BaseModel):\n    model_config = ConfigDict(from_attributes=True)\n    id: int\n    title: str\n    content: str\n    pinned: bool\n    created_at: datetime\n    updated_at: datetime",
+        "lang": "python",
+        "filename": "app/schemas.py"
+      },
+      {
+        "title": "新建 CRUD 路由",
+        "body": "新建 app/routes.py,然后在 app/main.py 的 app 创建后添加 from app.routes import router 与 app.include_router(router)。这些写路径每次请求使用一个明确事务;生产鉴权与归属隔离需要在项目接入身份系统后增加,本课程接口只绑定本机练习。",
+        "code": "from datetime import datetime, timezone\nfrom fastapi import APIRouter, HTTPException, Response\nfrom app.db import Db\nfrom app.models import Note\nfrom app.schemas import NoteCreate, NoteOut, NotePatch\n\nrouter = APIRouter(prefix=\"/notes\", tags=[\"notes\"])\n\n@router.post(\"\", response_model=NoteOut, status_code=201)\nasync def create_note(body: NoteCreate, db: Db):\n    async with db.begin():\n        note = Note(**body.model_dump())\n        db.add(note)\n        await db.flush()\n        await db.refresh(note)\n        result = NoteOut.model_validate(note)\n    return result\n\n@router.get(\"/{note_id}\", response_model=NoteOut)\nasync def get_note(note_id: int, db: Db):\n    async with db.begin():\n        note = await db.get(Note, note_id)\n        if note is None:\n            raise HTTPException(404, \"note not found\")\n        result = NoteOut.model_validate(note)\n    return result\n\n@router.patch(\"/{note_id}\", response_model=NoteOut)\nasync def patch_note(note_id: int, body: NotePatch, db: Db):\n    async with db.begin():\n        note = await db.get(Note, note_id)\n        if note is None:\n            raise HTTPException(404, \"note not found\")\n        for name, value in body.model_dump(exclude_unset=True).items():\n            setattr(note, name, value)\n        note.updated_at = datetime.now(timezone.utc)\n        await db.flush()\n        result = NoteOut.model_validate(note)\n    return result\n\n@router.delete(\"/{note_id}\", status_code=204)\nasync def delete_note(note_id: int, db: Db):\n    async with db.begin():\n        note = await db.get(Note, note_id)\n        if note is None:\n            raise HTTPException(404, \"note not found\")\n        await db.delete(note)\n    return Response(status_code=204)",
+        "lang": "python",
+        "filename": "app/routes.py"
+      },
+      {
+        "title": "通过 HTTP 与 SQL 双向验收",
+        "body": "启动服务,在 /docs 创建笔记,记录响应 id,随后读取、PATCH pinned=true 并删除。下面 Python 片段保存为 scripts/crud_probe.py 后运行 python -m scripts.crud_probe。",
+        "code": "import httpx\n\nwith httpx.Client(base_url=\"http://127.0.0.1:8000\", timeout=5) as client:\n    created = client.post(\"/notes\", json={\"title\": \"持久化练习\"})\n    assert created.status_code == 201, created.text\n    note_id = created.json()[\"id\"]\n    assert client.get(f\"/notes/{note_id}\").status_code == 200\n    patched = client.patch(f\"/notes/{note_id}\", json={\"pinned\": True})\n    assert patched.json()[\"pinned\"] is True\n    assert client.patch(f\"/notes/{note_id}\", json={\"title\": None}).status_code == 422\n    assert client.delete(f\"/notes/{note_id}\").status_code == 204\n    assert client.get(f\"/notes/{note_id}\").status_code == 404",
+        "lang": "python",
+        "filename": "scripts/crud_probe.py"
+      }
+    ],
+    "checks": [
+      "POST 返回 201、数据库生成的 id 和带时区时间,重启 API 后仍能读取未删除的记录。",
+      "PATCH 省略 content 不改变原文;显式 null 与空白标题返回 422。",
+      "DELETE 成功返回空响应体 204,后续 GET 返回 404。"
+    ],
+    "pitfalls": [
+      {
+        "symptom": "返回对象时报字段缺失或隐式查询错误",
+        "cause": "在服务器默认值尚未加载、Session 已关闭后才序列化 ORM。",
+        "fix": "flush 后 refresh,并在事务内部 NoteOut.model_validate;不依赖响应序列化期间访问数据库。"
+      },
+      {
+        "symptom": "PATCH 把未传字段改成 None",
+        "cause": "直接 model_dump() 将默认值也写入对象。",
+        "fix": "使用 exclude_unset=True,并对显式 null 规定并验证行为。"
+      }
+    ],
+    "exercise": {
+      "task": "禁止创建请求自定义 id,并验证超长标题不会到达数据库。",
+      "hint": "extra=\"forbid\" 与 max_length 分别解决两种输入问题。",
+      "answer": "向 POST /notes 发送 {\"id\":1,\"title\":\"x\"} 应为 422;发送 121 字符标题也应为 422。直接 SQL 写入超长标题仍由 varchar(120) 拒绝,不能只依赖 HTTP 校验。"
+    },
+    "refs": [
+      {
+        "label": "SQLAlchemy:声明式映射",
+        "url": "https://docs.sqlalchemy.org/en/20/orm/declarative_tables.html"
+      },
+      {
+        "label": "Pydantic:模型与属性读取",
+        "url": "https://docs.pydantic.dev/latest/concepts/models/"
+      },
+      {
+        "label": "FastAPI:局部更新",
+        "url": "https://fastapi.tiangolo.com/tutorial/body-updates/"
+      }
+    ],
+    "production": {
+      "scenario": "API 从内存存储迁移到真实数据库,需要保持响应与持久化一致。",
+      "decisions": [
+        "输入模型不暴露数据库生成字段。",
+        "业务 DTO 在事务内完成校验,提交成功后才返回 2xx。"
+      ],
+      "failureDrill": "在本机记录创建 id,重启 API 再读取;另开 psql 核对同一行,排除误用内存列表。",
+      "acceptance": [
+        "数据跨进程重启保留。",
+        "请求、响应、数据库约束各有明确职责。"
+      ]
+    }
+  },
+  {
+    "id": "04",
+    "group": "连接与事务",
+    "title": "事务边界与失败提交",
+    "summary": "把一项业务操作的多次写入放进同一事务,复现 flush 成功但 commit 失败的情况。",
+    "objectives": [
+      "识别业务事务的开始、提交和回滚位置",
+      "避免在 yield 依赖结束时隐藏 commit",
+      "让数据库提交失败发生在 HTTP 成功响应之前"
+    ],
+    "concepts": [
+      {
+        "title": "事务由业务入口负责",
+        "body": "get_session 负责提供和关闭资源,不知道一次业务操作包含哪些写入。路由或服务函数用 db.begin() 包住相关操作;被调用的辅助函数只执行 SQL 或 flush,不自行 commit。HTTP 请求不是数据库事务的天然边界,流式和外部网络调用尤其需要拆开。"
+      },
+      {
+        "title": "成功返回的顺序",
+        "body": "顺序应是校验输入 → begin → SQL → 生成 DTO → commit → 返回响应。若把 commit 放在依赖 yield 后,提交时机可能晚于响应发送,客户端收到成功却没有落库。本模块只在显式事务块内提交,与依赖清理时机无关。"
+      },
+      {
+        "title": "失败之后的 Session",
+        "body": "数据库错误会让当前事务不可继续使用。退出 db.begin() 的异常路径自动回滚,之后才映射业务错误或开始新事务;不要在已失败事务内部捕获 IntegrityError 后继续查询。提交连接中断还可能造成结果未知,重试需幂等协议,不能简单认定一定失败。"
+      }
+    ],
+    "steps": [
+      {
+        "title": "为审计记录准备练习表",
+        "body": "在本机 notes_lab 执行一次。DEFERRABLE 用来演示提交时检查;第 7 课会将该表纳入基线迁移。审计保存 note_id 数值而不设外键,使笔记删除后审计仍保留。",
+        "code": "CREATE TABLE note_audits (\n    id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,\n    note_id bigint NOT NULL,\n    action text NOT NULL,\n    request_key text NOT NULL,\n    created_at timestamptz NOT NULL DEFAULT now(),\n    CONSTRAINT uq_note_audits_request UNIQUE (request_key) DEFERRABLE INITIALLY DEFERRED\n);",
+        "lang": "sql"
+      },
+      {
+        "title": "把创建与审计组合为事务",
+        "body": "新建 app/services.py。辅助函数不 commit;session 的事务由调用者控制。request_key 在这里只用于审计提交冲突实验,第 10 课才实现完整幂等语义。",
+        "code": "from sqlalchemy import text\nfrom sqlalchemy.ext.asyncio import AsyncSession\nfrom app.models import Note\nfrom app.schemas import NoteCreate, NoteOut\n\nasync def create_with_audit(db: AsyncSession, body: NoteCreate, request_key: str):\n    note = Note(**body.model_dump())\n    db.add(note)\n    await db.flush()\n    await db.refresh(note)\n    await db.execute(text(\"\"\"\n        INSERT INTO note_audits (note_id, action, request_key)\n        VALUES (:note_id, 'created', :request_key)\n    \"\"\"), {\"note_id\": note.id, \"request_key\": request_key})\n    return NoteOut.model_validate(note)",
+        "lang": "python",
+        "filename": "app/services.py"
+      },
+      {
+        "title": "新增演示端点,在事务外捕获约束错误",
+        "body": "追加到 app/routes.py;保留原 POST /notes。该端点路径为 POST /notes/audited,无 GET 路由冲突。异常匹配使用约束名称,未知 IntegrityError 继续抛出交给错误日志,不把所有数据库异常都说成输入错误。",
+        "code": "from typing import Annotated\nfrom fastapi import Header\nfrom sqlalchemy.exc import IntegrityError\nfrom app.services import create_with_audit\n\n@router.post(\"/audited\", response_model=NoteOut, status_code=201)\nasync def create_audited(body: NoteCreate, db: Db, request_key: Annotated[str, Header(min_length=1, max_length=100)]):\n    try:\n        async with db.begin():\n            result = await create_with_audit(db, body, request_key)\n    except IntegrityError as exc:\n        constraint = getattr(getattr(exc.orig, \"diag\", None), \"constraint_name\", None)\n        if constraint == \"uq_note_audits_request\":\n            raise HTTPException(409, \"audit request key already exists\") from exc\n        raise\n    return result",
+        "lang": "python",
+        "filename": "app/routes.py"
+      },
+      {
+        "title": "复现延迟约束在 commit 才报错",
+        "body": "用同一个 request-key 连续调用两次。第二次 SQL INSERT 本身可以执行,离开事务块时才触发唯一约束。运行前后分别查询 notes 和 note_audits 的条数,第二次不应增加任一表。",
+        "code": "import httpx\n\nwith httpx.Client(base_url=\"http://127.0.0.1:8000\", timeout=5) as client:\n    headers = {\"request-key\": \"commit-lab-001\"}\n    first = client.post(\"/notes/audited\", json={\"title\": \"first\"}, headers=headers)\n    second = client.post(\"/notes/audited\", json={\"title\": \"must rollback\"}, headers=headers)\n    print(first.status_code, second.status_code)\n    assert first.status_code == 201\n    assert second.status_code == 409",
+        "lang": "python",
+        "filename": "scripts/commit_probe.py"
+      },
+      {
+        "title": "画出并核对实际控制流",
+        "body": "在纸上或白板沿每个箭头指出对应代码行。出现异常时从 commit 进入 rollback,再到 HTTP 409;不应有从 flush 直接跳到 HTTP 201 的路径。",
+        "code": "输入校验 → begin → 写 notes → 写 audit → 构造 DTO → commit → HTTP 201\n                                                  └─失败→ rollback → HTTP 错误\nyield 依赖:创建 Session ──────────────────────────────────→ close",
+        "lang": "text"
+      }
+    ],
+    "checks": [
+      "第二次重复 request-key 返回 409,不能返回 201 后才打印提交异常。",
+      "重复请求失败后两张表都没有新增第二条业务数据。",
+      "普通 POST /notes 仍能成功,说明失败事务已回滚释放。"
+    ],
+    "pitfalls": [
+      {
+        "symptom": "客户端得到成功,日志随后才报告 commit 失败",
+        "cause": "依赖清理或后台任务中执行关键业务 commit。",
+        "fix": "把提交放回业务事务块,离开事务后才 return;后台任务不能承诺当前请求已持久化。"
+      },
+      {
+        "symptom": "捕获异常后执行 SELECT 又报 InFailedSqlTransaction",
+        "cause": "仍在失败事务内部复用会话。",
+        "fix": "在 async with db.begin() 外捕获;需要局部容错时明确设计 SAVEPOINT,而非吞异常。"
+      }
+    ],
+    "exercise": {
+      "task": "验证服务函数中间抛异常会回滚笔记。",
+      "hint": "只在隔离练习分支,在审计插入前临时 raise RuntimeError。",
+      "answer": "在 create_with_audit 的 flush 后加入 raise RuntimeError(\"drill\"),调用一次端点应返回服务错误,psql 查询找不到本次标题。移除故障注入后再次请求成功;不要保留公开故障接口。"
+    },
+    "refs": [
+      {
+        "label": "SQLAlchemy:事务管理",
+        "url": "https://docs.sqlalchemy.org/en/20/orm/session_transaction.html"
+      },
+      {
+        "label": "PostgreSQL 16:SET CONSTRAINTS",
+        "url": "https://www.postgresql.org/docs/16/sql-set-constraints.html"
+      },
+      {
+        "label": "FastAPI:yield 依赖",
+        "url": "https://fastapi.tiangolo.com/tutorial/dependencies/dependencies-with-yield/"
+      }
+    ],
+    "production": {
+      "scenario": "创建笔记必须同时写审计,客户端只能在两项都提交后看到成功。",
+      "decisions": [
+        "业务辅助函数无隐式 commit。",
+        "已知约束映射成可识别错误;未知失败保留内部堆栈但响应脱敏。"
+      ],
+      "failureDrill": "使用可延迟唯一约束让第二次请求在 commit 失败,核对 HTTP 与两张表的最终状态。",
+      "acceptance": [
+        "commit 失败没有 2xx。",
+        "回滚范围覆盖全部相关写入。"
+      ]
+    }
+  },
+  {
+    "id": "05",
+    "group": "查询与演进",
+    "title": "关系加载与 N+1 查询",
+    "summary": "为笔记增加评论,比较逐条懒加载、selectinload 和有界查询的 SQL 次数。",
+    "objectives": [
+      "建立一对多关系并约束删除行为",
+      "用显式加载避免异步隐式 I/O",
+      "限制主列表和关联集合的读取规模"
+    ],
+    "concepts": [
+      {
+        "title": "N+1 如何发生",
+        "body": "先查 20 条笔记,再为每条笔记查询评论,会形成 1+20 次数据库往返。网络延迟叠加后,即使每条 SQL 都很快,接口仍慢。异步 Session 中直接读取尚未加载的关系还可能触发 MissingGreenlet。"
+      },
+      {
+        "title": "显式加载的选择",
+        "body": "selectinload 将关联查询批量合并,适合规模受控的一对多集合。joinedload 用 JOIN 合并结果,集合关联会放大结果行并通常需要 unique()。分页笔记数不等于限制评论总数,不能把 selectinload 当作无限集合的分页方案。"
+      },
+      {
+        "title": "生产接口的边界",
+        "body": "笔记列表只返回笔记摘要或聚合计数,评论列表单独分页。本课演示 eager loading 只使用每条两条评论的固定测试数据;真实产品默认使用 /notes/{id}/comments 有界接口,删除策略明确为数据库 CASCADE。"
+      }
+    ],
+    "steps": [
+      {
+        "title": "新增评论表",
+        "body": "在本机练习库执行。外键建立归属;索引支持按笔记读取并按 id 翻页。生产变更在第 7 课以后均通过 Alembic 执行。",
+        "code": "CREATE TABLE comments (\n    id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,\n    note_id bigint NOT NULL REFERENCES notes(id) ON DELETE CASCADE,\n    body varchar(2000) NOT NULL,\n    created_at timestamptz NOT NULL DEFAULT now()\n);\nCREATE INDEX ix_comments_note_id_id ON comments (note_id, id);",
+        "lang": "sql"
+      },
+      {
+        "title": "扩展 ORM 映射",
+        "body": "将下列 import 合并到 app/models.py。把 comments 属性放入已有 Note 类体,Comment 类放在文件末尾。lazy=\"raise\" 让未显式加载成为确定错误,避免悄悄执行 N+1。",
+        "code": "from sqlalchemy import ForeignKey, Index\nfrom sqlalchemy.orm import relationship\n\n# 插入已有 Note 类内部:\n# comments: Mapped[list[\"Comment\"]] = relationship(\n#     back_populates=\"note\", lazy=\"raise\", passive_deletes=\"all\"\n# )\n\nclass Comment(Base):\n    __tablename__ = \"comments\"\n    __table_args__ = (Index(\"ix_comments_note_id_id\", \"note_id\", \"id\"),)\n    id: Mapped[int] = mapped_column(BigInteger, Identity(), primary_key=True)\n    note_id: Mapped[int] = mapped_column(ForeignKey(\"notes.id\", ondelete=\"CASCADE\"), nullable=False)\n    body: Mapped[str] = mapped_column(String(2000), nullable=False)\n    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())\n    note: Mapped[\"Note\"] = relationship(back_populates=\"comments\", lazy=\"raise\")",
+        "lang": "python",
+        "filename": "app/models.py"
+      },
+      {
+        "title": "新增生产形式的分页评论接口",
+        "body": "追加到 app/routes.py。每次最多 100 条,以严格递增 id 翻页;无评论与笔记不存在分别返回空数组与 404。输出为明确字典,未访问 Note.comments。",
+        "code": "from typing import Annotated\nfrom fastapi import Query\nfrom sqlalchemy import select\nfrom app.models import Comment\n\n@router.get(\"/{note_id}/comments\")\nasync def list_comments(note_id: int, db: Db,\n    after_id: Annotated[int, Query(ge=0)] = 0,\n    limit: Annotated[int, Query(ge=1, le=100)] = 20):\n    async with db.begin():\n        if await db.get(Note, note_id) is None:\n            raise HTTPException(404, \"note not found\")\n        rows = (await db.scalars(select(Comment).where(\n            Comment.note_id == note_id, Comment.id > after_id\n        ).order_by(Comment.id).limit(limit + 1))).all()\n        visible = rows[:limit]\n        result = {\"items\": [{\"id\": x.id, \"body\": x.body} for x in visible],\n                  \"next_after_id\": visible[-1].id if len(rows) > limit else None}\n    return result",
+        "lang": "python",
+        "filename": "app/routes.py"
+      },
+      {
+        "title": "在固定小数据集比较加载方式",
+        "body": "先在 /docs 创建 3 条专用笔记,用 psql 为每条插入 2 条评论。把下面函数追加到 scripts/probe.py,并在其 main 内调用 await compare();只对这组 id 运行,替换 [1,2,3] 为实际值。在本机临时把 Engine echo 设为 True 观察 SQL,完成后关闭,避免记录用户内容。",
+        "code": "from sqlalchemy import select\nfrom sqlalchemy.orm import selectinload\nfrom app.models import Note\n\nasync def compare():\n    async with SessionFactory.begin() as db:\n        notes = (await db.scalars(select(Note).where(Note.id.in_([1, 2, 3]))\n            .options(selectinload(Note.comments)))).all()\n        print([(note.id, len(note.comments)) for note in notes])",
+        "lang": "python",
+        "filename": "scripts/probe.py"
+      },
+      {
+        "title": "验收集合边界与删除",
+        "body": "访问 /notes/{id}/comments?limit=1,沿 next_after_id 翻到结尾。删除练习笔记后在 psql 查询相应 comments,应该为 0 条。将 compare 中 options 移除,访问 comments 应被 lazy=\"raise\" 拒绝,这比隐藏 N+1 更易排错。",
+        "code": "SELECT note_id, count(*) FROM comments GROUP BY note_id;\nEXPLAIN (ANALYZE, BUFFERS)\nSELECT id, body FROM comments\nWHERE note_id = 1 AND id > 0 ORDER BY id LIMIT 21;",
+        "lang": "sql"
+      }
+    ],
+    "checks": [
+      "小数据 selectinload 示例用笔记查询与评论查询完成读取,没有每条笔记再发一次 SQL。",
+      "评论接口 limit=101 返回 422,翻页不重复已见 id。",
+      "删除笔记后对应评论被数据库删除,无孤立外键记录。"
+    ],
+    "pitfalls": [
+      {
+        "symptom": "分页笔记 20 条仍返回几十万条评论",
+        "cause": "主表分页没有限制关联集合规模。",
+        "fix": "默认分离评论分页接口,只在已知小集合使用 selectinload;聚合计数使用 GROUP BY 子查询。"
+      },
+      {
+        "symptom": "删除带评论的笔记出现外键非空错误",
+        "cause": "ORM 尝试把外键置空,与数据库 CASCADE 策略不一致。",
+        "fix": "使用本课 passive_deletes=\"all\" 让数据库处理删除;同一会话内已加载对象可能过期,删除后不再返回其评论对象。"
+      }
+    ],
+    "exercise": {
+      "task": "给评论接口增加排序反向读取。",
+      "hint": "反向页需同时改变比较符与 ORDER BY。",
+      "answer": "使用 before_id 游标,WHERE id < before_id ORDER BY id DESC LIMIT limit+1。首次无游标时不加比较条件,下一页传上一页末尾 id;不要只改排序而保留 id > after_id。"
+    },
+    "refs": [
+      {
+        "label": "SQLAlchemy:关系加载策略",
+        "url": "https://docs.sqlalchemy.org/en/20/orm/queryguide/relationships.html"
+      },
+      {
+        "label": "SQLAlchemy:数据库级删除级联",
+        "url": "https://docs.sqlalchemy.org/en/20/orm/cascades.html#using-foreign-key-on-delete-cascade-with-orm-relationships"
+      }
+    ],
+    "production": {
+      "scenario": "笔记列表最初只有少量评论,运行一段时间后单条笔记积累大量评论。",
+      "decisions": [
+        "主列表不嵌入无界集合。",
+        "通过 SQL 次数与返回行数共同判断优化效果。"
+      ],
+      "failureDrill": "只在练习库为单条笔记插入较多评论,比较 eager loading 与分页接口的返回规模;限制数据量避免占满磁盘。",
+      "acceptance": [
+        "关联读取有明确上限。",
+        "响应序列化不触发数据库查询。"
+      ]
+    }
+  },
+  {
+    "id": "06",
+    "group": "查询与演进",
+    "title": "连接池预算、超时与多 Worker",
+    "summary": "从 PostgreSQL 连接上限反推每个进程的池容量,区分借连接、建连接、锁等待与 SQL 执行超时。",
+    "objectives": [
+      "计算多实例多 Worker 下的最坏连接数",
+      "为数据库等待设置分层预算",
+      "在隔离环境复现连接池耗尽并观察恢复"
+    ],
+    "concepts": [
+      {
+        "title": "连接数按进程相乘",
+        "body": "每个 Uvicorn worker 都拥有自己的 Engine 与连接池。应用最坏连接数约为实例数 × worker 数 × (pool_size + max_overflow),还要加任务消费者、迁移、运维与其他应用。连接池预算小于数据库可用配额,不能仅把 max_connections 全部分给 API。"
+      },
+      {
+        "title": "四种等待",
+        "body": "pool_timeout 是等待池中连接的时间,connect_timeout 是建立新连接的时间,lock_timeout 是 SQL 等锁的上限,statement_timeout 是单条 SQL 执行总上限。它们有重叠,不能简单相加等同请求耗时;请求预算还包括网络、序列化与多条 SQL。"
+      },
+      {
+        "title": "拥塞时及时拒绝",
+        "body": "扩大池会增加数据库并发和内存压力。先缩短事务、排查慢 SQL、控制入口与任务并发,再测量池容量。pool_pre_ping 能识别借出前的失效连接,不能修复事务执行中断,也不会安全重放写操作。"
+      }
+    ],
+    "steps": [
+      {
+        "title": "计算部署预算",
+        "body": "先用 psql SHOW max_connections,并列出其他客户端。以下为练习计算,不是通用推荐值;2 个实例、每个 3 worker、每 worker 5 连接占 30,滚动发布若新旧全部重叠可能占 60。",
+        "code": "数据库上限 100\n保留给管理与故障处理 10\n其他服务与后台任务 20\nAPI 稳态 2 × 3 × (5 + 0) = 30\n发布峰值按 4 × 3 × 5 = 60 估算\n峰值总需求 60 + 20 + 10 = 90 < 100",
+        "lang": "text"
+      },
+      {
+        "title": "检查当前生效超时",
+        "body": "第 2 课 Engine 已设置连接和 SQL 超时。运行下列 psql 只看到当前 psql 会话配置;要验证应用连接,给 scripts/probe.py 的 probe 函数临时替换为下一段,运行后恢复。",
+        "code": "async def probe():\n    async with SessionFactory.begin() as db:\n        rows = (await db.execute(text(\"\"\"\n            SELECT current_setting('statement_timeout') AS statement_timeout,\n                   current_setting('lock_timeout') AS lock_timeout\n        \"\"\"))).mappings().one()\n        return dict(rows)",
+        "lang": "python",
+        "filename": "scripts/probe.py"
+      },
+      {
+        "title": "为池耗尽提供明确响应",
+        "body": "添加到 app/main.py。只映射 SQLAlchemy 的池等待 TimeoutError;SQL 执行取消与数据库断连需要分别记录和处理,不能都笼统重试。请求日志应包含 request_id、延迟、异常类别,排除参数与连接串。",
+        "code": "from fastapi import Request\nfrom fastapi.responses import JSONResponse\nfrom sqlalchemy.exc import TimeoutError as PoolTimeout\n\n@app.exception_handler(PoolTimeout)\nasync def pool_timeout_handler(request: Request, exc: PoolTimeout):\n    return JSONResponse(status_code=503,\n        content={\"detail\": \"database capacity exhausted\"},\n        headers={\"Retry-After\": \"1\"})",
+        "lang": "python",
+        "filename": "app/main.py"
+      },
+      {
+        "title": "本机复现池竞争",
+        "body": "新建 scripts/pool_drill.py。它创建单独练习 Engine,不改服务的配置。SELECT pg_sleep 仅在本机 notes_lab 使用;两任务各自用连接,一个等不到连接触发池超时,finally 确保释放。Windows 使用本课代码内 Selector 策略。",
+        "code": "import asyncio\nimport sys\nfrom sqlalchemy import text\nfrom sqlalchemy.exc import TimeoutError\nfrom sqlalchemy.ext.asyncio import create_async_engine\nfrom app.config import settings\n\nasync def main():\n    engine = create_async_engine(settings.database_url, pool_size=1,\n        max_overflow=0, pool_timeout=0.1)\n    async def hold():\n        async with engine.begin() as conn:\n            await conn.execute(text(\"SELECT pg_sleep(0.5)\"))\n    try:\n        results = await asyncio.gather(hold(), hold(), return_exceptions=True)\n        assert sum(isinstance(x, TimeoutError) for x in results) == 1, results\n        async with engine.begin() as conn:\n            assert await conn.scalar(text(\"SELECT 1\")) == 1\n        print(\"one rejected; pool recovered\")\n    finally:\n        await engine.dispose()\n\nif __name__ == \"__main__\":\n    if sys.platform == \"win32\":\n        asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())\n    asyncio.run(main())",
+        "lang": "python",
+        "filename": "scripts/pool_drill.py"
+      },
+      {
+        "title": "验证慢 SQL 与锁等待分开",
+        "body": "应用 probe 事务中分别执行 pg_sleep(4),或在 psql 事务锁定一条专用笔记再由 API PATCH。记录哪个超时先发生。锁练习结束在 psql 执行 ROLLBACK;不要对业务库执行这些命令。",
+        "code": "BEGIN;\nSELECT id FROM notes WHERE id = 1 FOR UPDATE;\n-- 将 1 换成本机专用练习笔记 id;此时在另一个终端 PATCH 同一笔记。\nROLLBACK;",
+        "lang": "sql"
+      }
+    ],
+    "checks": [
+      "预算表包含 worker、实例、发布重叠和后台任务。",
+      "池演练输出 one rejected; pool recovered;失败后新查询仍成功。",
+      "能区分池等待超时、statement_timeout 与 lock_timeout,并记录实际延迟。"
+    ],
+    "pitfalls": [
+      {
+        "symptom": "把 worker 从 2 加到 8 后数据库连接爆满",
+        "cause": "每个 worker 都创建自己的池,遗漏乘数。",
+        "fix": "重新预算所有实例与发布重叠;根据 CPU 与等待特征调 worker,按数据库配额分配池。"
+      },
+      {
+        "symptom": "设置 pool_timeout 后慢查询仍占用很久",
+        "cause": "pool_timeout 只限制借连接等待,不限制已借到连接上的 SQL。",
+        "fix": "分别设置 statement_timeout 与 lock_timeout,并检查事务持有连接时是否等待外部 API。"
+      }
+    ],
+    "exercise": {
+      "task": "如果实例翻倍但数据库配额不变,如何保持最坏连接数不变?",
+      "hint": "把进程总数和每池连接上限一起计算。",
+      "answer": "在 worker 不变时将每 worker 的 pool_size + max_overflow 约减半,同时验证排队时间与吞吐。连接配额只是上限,不能保证响应时间;需要限流和负载测量,而非只修改参数。"
+    },
+    "refs": [
+      {
+        "label": "SQLAlchemy:连接池",
+        "url": "https://docs.sqlalchemy.org/en/20/core/pooling.html"
+      },
+      {
+        "label": "PostgreSQL 16:客户端连接默认设置",
+        "url": "https://www.postgresql.org/docs/16/runtime-config-client.html"
+      },
+      {
+        "label": "Uvicorn:部署与 Worker",
+        "url": "https://uvicorn.dev/deployment/"
+      }
+    ],
+    "production": {
+      "scenario": "服务扩容和滚动发布时,短时间内进程数显著增加。",
+      "decisions": [
+        "max_overflow=0 起步使容量上限可预测。",
+        "容量不足返回可恢复错误,重试使用退避与幂等;不在应用层无限排队。"
+      ],
+      "failureDrill": "独立池限制为 1,两个任务争抢连接;确认一次失败不会泄露唯一连接。",
+      "acceptance": [
+        "失败后池恢复。",
+        "最坏连接需求含部署峰值且留管理余量。"
+      ]
+    }
+  },
+  {
+    "id": "07",
+    "group": "查询与演进",
+    "title": "Alembic 迁移与 Expand–Contract",
+    "summary": "把手工练习表纳入版本管理,以新增可空字段、分批回填、收紧约束完成兼容发布。",
+    "objectives": [
+      "为 async Engine 建立 Alembic 环境",
+      "区分 upgrade、autogenerate 与 stamp",
+      "设计新旧应用可同时运行的数据库演进"
+    ],
+    "concepts": [
+      {
+        "title": "迁移版本是发布状态",
+        "body": "ORM 模型描述当前目标结构,迁移文件描述从旧版如何到新版。autogenerate 是候选差异,不能理解改名或业务回填语义。生产服务启动不执行 create_all;数据库迁移由单独发布步骤执行一次并记录版本。"
+      },
+      {
+        "title": "接管已有数据库",
+        "body": "数据库开发模块的 notes_lab 可能已包含 version、检查约束与额外索引,不能直接套用本课从零生成的迁移历史。本课固定使用新建 notes_lab_migration 数据库做迁移实验,原 notes_lab 保留不动。stamp 只登记版本,既不建表也不验证结构;本练习不对已有库执行 stamp 或重复 ADD version。接管真实已有库需要单独的结构盘点与基线计划。"
+      },
+      {
+        "title": "兼容发布",
+        "body": "Expand 先增加兼容字段或表;新旧应用共存期间双写或保留旧字段;后台按主键分批回填并核验;最后 Contract 收紧约束或删除旧字段。每步都需锁等待预算和回退窗口,数据删除不保证可通过 downgrade 恢复。"
+      }
+    ],
+    "steps": [
+      {
+        "title": "初始化异步迁移环境",
+        "body": "在 notes-service 根目录执行。生成 migrations/env.py 后按下一步修改,migration URL 从配置读取,不写密码进 alembic.ini。",
+        "code": "python -m alembic init -t async migrations",
+        "lang": "bash"
+      },
+      {
+        "title": "配置 metadata 与连接地址",
+        "body": "在生成的 migrations/env.py 中导入下面内容,替换原 target_metadata = None;使用 set_main_option 时将 % 转为 %%,因为 Alembic 配置有插值规则。保留 async 模板的 run_sync、NullPool 与上下文,不自行改成 asyncio.run 包套 asyncio.run。 Windows 在 env.py 导入 asyncio 后、执行 run_migrations_online 之前,添加 import sys,并在 sys.platform == \"win32\" 时设置 asyncio.WindowsSelectorEventLoopPolicy(),与应用入口保持一致。",
+        "code": "from app.config import settings\nfrom app.models import Base\n\ntarget_metadata = Base.metadata\nconfig.set_main_option(\"sqlalchemy.url\", settings.database_url.replace(\"%\", \"%%\"))\n\nimport sys\nif sys.platform == \"win32\":\n    asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())",
+        "lang": "python",
+        "filename": "migrations/env.py"
+      },
+      {
+        "title": "生成并审核基线",
+        "body": "先把下列 note_audits Core Table 追加到 app/models.py,与现有 Note/Comment 一起进入 Base.metadata。此时 API 模型按第 3–5 课尚未声明 version,原数据库即使有该列也不影响读写。下一步只对新的空迁移实验库生成基线,不从已有 notes_lab 自动生成差异。",
+        "code": "from sqlalchemy import Column, MetaData, Table, UniqueConstraint\n\nnote_audits = Table(\"note_audits\", Base.metadata,\n    Column(\"id\", BigInteger, Identity(), primary_key=True),\n    Column(\"note_id\", BigInteger, nullable=False),\n    Column(\"action\", Text, nullable=False),\n    Column(\"request_key\", Text, nullable=False),\n    Column(\"created_at\", DateTime(timezone=True), nullable=False, server_default=func.now()),\n    UniqueConstraint(\"request_key\", name=\"uq_note_audits_request\",\n        deferrable=True, initially=\"DEFERRED\"),\n)",
+        "lang": "python",
+        "filename": "app/models.py"
+      },
+      {
+        "title": "创建空迁移实验库并明确切换",
+        "body": "在本机 PostgreSQL 管理账号可用的前提下执行前两条命令,-W 交互输入管理密码;若使用数据库模块的Docker容器,可在其postgres容器内使用管理psql执行同一CREATE DATABASE。已有同名库时先停止并核对来源,不删除重建。将 notes-service/.env 的 DATABASE_URL 数据库名从 notes_lab 改为 notes_lab_migration,保留原值在未提交的本机备份中;同时清除终端中覆盖 .env 的 DATABASE_URL。第 7–11 课继续使用新库,避免把教学迁移应用到已有表。",
+        "code": "psql -h 127.0.0.1 -p 5433 -U postgres -d postgres -W -c \"CREATE DATABASE notes_lab_migration OWNER app_user;\"\npsql -h 127.0.0.1 -p 5433 -U app_user -d notes_lab_migration -W -c \"SELECT current_database(), current_user;\"\n# PowerShell:清除可能优先于.env的旧环境变量\nRemove-Item Env:DATABASE_URL -ErrorAction SilentlyContinue\n# 用编辑器将.env连接串末尾改为/notes_lab_migration,再运行:\npython -m alembic revision --autogenerate -m \"baseline notes comments audits\"\npython -m alembic upgrade head\npython -m alembic check\npython -m alembic current",
+        "lang": "powershell"
+      },
+      {
+        "title": "新增 nullable version 字段",
+        "body": "仅在刚创建的 notes_lab_migration 完成基线后执行:alembic revision -m \"expand note version\",把下面函数写入生成文件并保留 revision/down_revision/import op/import sqlalchemy as sa。同步在 Note 类加入 version: Mapped[int | None] = mapped_column(nullable=True, server_default=text(\"1\"))。原 notes_lab 已有 version 时不执行本迁移;本练习的新增与回填只发生在独立新库。",
+        "code": "def upgrade():\n    op.execute(\"SET LOCAL lock_timeout = '1s'\")\n    op.add_column(\"notes\", sa.Column(\"version\", sa.Integer(), nullable=True, server_default=sa.text(\"1\")))\n\ndef downgrade():\n    op.drop_column(\"notes\", \"version\")",
+        "lang": "python",
+        "filename": "migrations/versions/<revision>_expand_note_version.py"
+      },
+      {
+        "title": "分批回填后收紧非空约束",
+        "body": "先 upgrade,再在 psql 重复下面有界 UPDATE,每次独立提交,直到 UPDATE 0。随后生成新 revision 收紧 nullable=False,并把 ORM version 注解改为 Mapped[int]。大表可先 ADD CHECK (version IS NOT NULL) NOT VALID,再 VALIDATE CONSTRAINT,最后 SET NOT NULL 并删除过渡 CHECK;验证与锁预算在预发布副本测量。",
+        "code": "WITH batch AS (\n    SELECT id FROM notes WHERE version IS NULL ORDER BY id LIMIT 1000\n)\nUPDATE notes AS n SET version = 1 FROM batch WHERE n.id = batch.id;\n\nSELECT count(*) FROM notes WHERE version IS NULL;\n-- 收紧约束的下一份 Alembic revision 中:\n-- op.alter_column(\"notes\", \"version\", existing_type=sa.Integer(), nullable=False)",
+        "lang": "sql"
+      },
+      {
+        "title": "初始化练习数据并保留返回路径",
+        "body": "迁移实验库从零开始,重启API使新.env生效,用/docs创建几条新笔记和评论再完成后续课。第7–11课操作目标始终为notes_lab_migration;不把旧库记录默默复制进新库。完成本模块后可恢复.env原notes_lab连接,但若要在那里使用新功能,需先为该库制定独立迁移接管计划,不能直接upgrade本课历史。第12课使用另一专用notes_lab_test。",
+        "code": "# 在当前连接的psql中确认目标及新字段\nSELECT current_database();\nSELECT column_name FROM information_schema.columns\nWHERE table_schema='public' AND table_name='notes' ORDER BY ordinal_position;",
+        "lang": "sql"
+      }
+    ],
+    "checks": [
+      "notes_lab_migration 从空库 upgrade 得到三张基础表与 version;原 notes_lab 没有执行本课DDL。",
+      "配置确实指向 notes_lab_migration,终端环境变量不会覆盖 .env,API重启后写入新库。",
+      "回填仅操作新实验库,收紧前 version IS NULL 为0,alembic current 为预期版本。"
+    ],
+    "pitfalls": [
+      {
+        "symptom": "autogenerate 把重命名识别成删列加列",
+        "cause": "工具只看结构差异,不理解业务意图。",
+        "fix": "手工改为兼容新增、迁移数据与后续删除,审核生成 SQL;有数据列的 DROP 不直接上线。"
+      },
+      {
+        "symptom": "线上多实例启动时同时运行迁移",
+        "cause": "把 migration 放入每个 worker 的启动钩子。",
+        "fix": "迁移移到唯一发布作业,设置锁等待上限,成功后启动或推进应用发布。"
+      }
+    ],
+    "exercise": {
+      "task": "旧应用没有 version 字段时,Expand 阶段的新插入会怎样?",
+      "hint": "区分 Python default 与数据库 server_default。",
+      "answer": "本课 version 的 server_default=1 在数据库生效,旧应用省略该列仍得到 1。单写 Python default 无法覆盖旧应用或 SQL 客户端。启用版本校验前仍需完成所有写路径升级,否则旧应用不会递增版本。"
+    },
+    "refs": [
+      {
+        "label": "Alembic:异步迁移",
+        "url": "https://alembic.sqlalchemy.org/en/latest/cookbook.html#using-asyncio-with-alembic"
+      },
+      {
+        "label": "Alembic:自动生成限制",
+        "url": "https://alembic.sqlalchemy.org/en/latest/autogenerate.html"
+      },
+      {
+        "label": "PostgreSQL 16:ALTER TABLE",
+        "url": "https://www.postgresql.org/docs/16/sql-altertable.html"
+      }
+    ],
+    "production": {
+      "scenario": "服务不能停机,新旧版本会在滚动发布时共同写同一张表。",
+      "decisions": [
+        "先兼容扩展,观测回填与应用切换,再收紧结构。",
+        "迁移单独运行,自动生成文件必须人工读懂每一条 DDL。"
+      ],
+      "failureDrill": "在隔离验证库制造长事务阻挡 ALTER,确认 lock_timeout 使迁移快速失败,结束阻塞后可重跑。",
+      "acceptance": [
+        "已有库不会被直接stamp或重复ADD version。",
+        "能够从空库复现迁移历史,清楚原库接管是独立工作。"
+      ]
+    }
+  },
+  {
+    "id": "08",
+    "group": "查询与演进",
+    "title": "Keyset 分页与数据库错误映射",
+    "summary": "使用稳定复合游标读取笔记列表,把已知约束转成明确的 API 错误。 本课继续连接第7课的 notes_lab_migration,原 notes_lab 不执行这些教学迁移。",
+    "objectives": [
+      "实现 created_at 与 id 复合 keyset 游标",
+      "限制分页大小并校验游标结构",
+      "按约束名称映射数据库错误而不泄露 SQL"
+    ],
+    "concepts": [
+      {
+        "title": "稳定排序",
+        "body": "只用 created_at 排序可能出现时间相同的多条记录,必须加唯一 id 作为第二排序键。降序下一页使用 (created_at,id) < 上页末条键。游标能减少深 OFFSET 扫描,但不是一致性快照;并发删除会导致后续页变短,新插入通常落在已经翻过的前面。"
+      },
+      {
+        "title": "游标是协议",
+        "body": "本课将时间和 id 编码为 URL 安全 Base64 JSON,并在入口限制长度与字段类型。编码不是加密或授权,客户端可伪造游标;若绑定用户、过滤器或敏感字段,需要签名与权限过滤。固定排序键 created_at 不在 API 中允许修改。"
+      },
+      {
+        "title": "约束错误分层",
+        "body": "请求格式错误由 Pydantic 返回 422。业务唯一冲突可为 409,明确的检查约束可以映射 422;连接失效不是客户端输入问题。先用约束名称或 SQLSTATE 识别,再决定映射,不把数据库异常原文直接返回。"
+      }
+    ],
+    "steps": [
+      {
+        "title": "创建复合索引迁移",
+        "body": "生成一个 Alembic revision 并填入函数,使用已有 op 与 sa 导入。课程练习数据量小可普通建索引;大表生产应评估 CREATE INDEX CONCURRENTLY,注意它不能在普通事务块内执行,需独立迁移与失败索引清理预案。 同步在 Note 类的 __table_args__ 加入 Index(\"ix_notes_created_id\", \"created_at\", \"id\"),若已有元组则追加,不能覆盖其他约束。",
+        "code": "def upgrade():\n    op.create_index(\"ix_notes_created_id\", \"notes\", [\"created_at\", \"id\"])\n\ndef downgrade():\n    op.drop_index(\"ix_notes_created_id\", table_name=\"notes\")",
+        "lang": "python",
+        "filename": "migrations/versions/<revision>_notes_cursor_index.py"
+      },
+      {
+        "title": "新增游标编码模块",
+        "body": "新建 app/cursors.py。aware 时间保证与 timestamptz 比较一致;对 Base64、JSON 和 Pydantic 校验错误统一返回 422。游标只用于定位,不用于证明访问权限。",
+        "code": "import base64\nimport binascii\nimport json\nfrom fastapi import HTTPException\nfrom pydantic import AwareDatetime, BaseModel, ConfigDict, Field, ValidationError\n\nclass Cursor(BaseModel):\n    model_config = ConfigDict(extra=\"forbid\")\n    created_at: AwareDatetime\n    id: int = Field(gt=0)\n\ndef encode_cursor(created_at, note_id):\n    raw = Cursor(created_at=created_at, id=note_id).model_dump_json().encode()\n    return base64.urlsafe_b64encode(raw).decode().rstrip(\"=\")\n\ndef decode_cursor(value):\n    try:\n        raw = base64.b64decode(value + \"=\" * (-len(value) % 4), altchars=b\"-_\", validate=True)\n        return Cursor.model_validate_json(raw)\n    except (ValueError, ValidationError, binascii.Error) as exc:\n        raise HTTPException(422, \"invalid cursor\") from exc",
+        "lang": "python",
+        "filename": "app/cursors.py"
+      },
+      {
+        "title": "新增分页笔记接口",
+        "body": "追加到 app/routes.py;GET /notes 与已有 GET /notes/{note_id} 不冲突。每页多读一条决定是否有下一页;在事务内转换 DTO,列表响应不会访问未加载关系。生产接入用户后,权限过滤必须加入同一 SQL 且每页一致。",
+        "code": "from sqlalchemy import tuple_\nfrom app.cursors import decode_cursor, encode_cursor\n\n@router.get(\"\")\nasync def list_notes(db: Db,\n    limit: Annotated[int, Query(ge=1, le=100)] = 20,\n    cursor: Annotated[str | None, Query(max_length=512)] = None):\n    stmt = select(Note).order_by(Note.created_at.desc(), Note.id.desc()).limit(limit + 1)\n    if cursor:\n        key = decode_cursor(cursor)\n        stmt = stmt.where(tuple_(Note.created_at, Note.id) < tuple_(key.created_at, key.id))\n    async with db.begin():\n        rows = (await db.scalars(stmt)).all()\n        visible = rows[:limit]\n        result = {\"items\": [NoteOut.model_validate(x) for x in visible],\n                  \"next_cursor\": encode_cursor(visible[-1].created_at, visible[-1].id)\n                      if len(rows) > limit else None}\n    return result",
+        "lang": "python",
+        "filename": "app/routes.py"
+      },
+      {
+        "title": "新增非空白标题的数据库约束",
+        "body": "先检查存量数据,再生成迁移。下面 upgrade 中 NOT VALID 允许先建立约束而暂缓验证存量,新写入立即受限;清理练习存量空白标题后再执行 VALIDATE。同步把 CheckConstraint 加入 Note.__table_args__,与索引一同保留,防止后续 autogenerate 误报差异。",
+        "code": "def upgrade():\n    op.execute(\"ALTER TABLE notes ADD CONSTRAINT ck_notes_title_nonblank CHECK (length(btrim(title)) > 0) NOT VALID\")\n    op.execute(\"ALTER TABLE notes VALIDATE CONSTRAINT ck_notes_title_nonblank\")\n\ndef downgrade():\n    op.drop_constraint(\"ck_notes_title_nonblank\", \"notes\", type_=\"check\")",
+        "lang": "python",
+        "filename": "migrations/versions/<revision>_notes_title_check.py"
+      },
+      {
+        "title": "统一映射明确的约束",
+        "body": "添加到 app/main.py。现有 audited 端点会先处理自身唯一约束,其他未识别 IntegrityError 返回 500 并用不含 SQL 参数的日志记录类别与约束;真实项目接入 request_id。不要记录 str(exc),其中可能有用户输入。",
+        "code": "import logging\nfrom sqlalchemy.exc import IntegrityError\n\nlogger = logging.getLogger(\"notes-service\")\n\n@app.exception_handler(IntegrityError)\nasync def integrity_handler(request: Request, exc: IntegrityError):\n    diag = getattr(exc.orig, \"diag\", None)\n    name = getattr(diag, \"constraint_name\", None)\n    if name == \"ck_notes_title_nonblank\":\n        return JSONResponse(status_code=422, content={\"detail\": \"title must not be blank\"})\n    logger.error(\"database constraint failure\", extra={\"constraint\": name, \"path\": request.url.path})\n    return JSONResponse(status_code=500, content={\"detail\": \"database write failed\"})",
+        "lang": "python",
+        "filename": "app/main.py"
+      }
+    ],
+    "checks": [
+      "相同 created_at 的多条笔记分页仍无重复、无静态数据遗漏。",
+      "limit=0、limit=101、损坏游标都为 422,最后一页 next_cursor=null。",
+      "直接 SQL 写空白标题由 ck_notes_title_nonblank 拒绝,API 错误响应不含 SQL 和原始参数。"
+    ],
+    "pitfalls": [
+      {
+        "symptom": "翻页时相同时间的记录重复或遗漏",
+        "cause": "仅以时间作为游标,缺少唯一排序键。",
+        "fix": "查询排序、比较条件和游标编码同时包含 created_at 与 id。"
+      },
+      {
+        "symptom": "所有 IntegrityError 都返回 409",
+        "cause": "把非空、外键、检查约束和程序错误混为一类。",
+        "fix": "只匹配确认过的命名约束,未知异常保持服务端错误并记录可定位信息。"
+      }
+    ],
+    "exercise": {
+      "task": "分页时能否保证第一屏到最后一屏来自同一个数据库快照?",
+      "hint": "每个 HTTP 请求有自己的事务。",
+      "answer": "本课不能保证跨请求快照。若产品需要稳定导出,可创建导出作业并在合适的一致性边界生成文件,或定义快照时间与数据版本规则;不要保持一个数据库事务等待用户慢慢翻页。"
+    },
+    "refs": [
+      {
+        "label": "PostgreSQL 16:行比较",
+        "url": "https://www.postgresql.org/docs/16/functions-comparisons.html#ROW-WISE-COMPARISON"
+      },
+      {
+        "label": "PostgreSQL 16:错误码",
+        "url": "https://www.postgresql.org/docs/16/errcodes-appendix.html"
+      },
+      {
+        "label": "SQLAlchemy:tuple_ 表达式",
+        "url": "https://docs.sqlalchemy.org/en/20/core/sqlelement.html#sqlalchemy.sql.expression.tuple_"
+      }
+    ],
+    "production": {
+      "scenario": "笔记增长后深分页拖慢数据库,同时出现绕过 API 的导入写入。",
+      "decisions": [
+        "固定排序、限制页大小,数据库层仍执行关键不变量。",
+        "游标不承担授权职责,过滤条件改变时丢弃旧游标。"
+      ],
+      "failureDrill": "在本机建立时间相同的测试记录并逐页收集 id;再发送损坏游标验证不会执行数据库查询。",
+      "acceptance": [
+        "静态数据集完整且不重复。",
+        "未知数据库错误不会被包装成错误的成功或业务冲突。"
+      ]
+    }
+  },
+  {
+    "id": "09",
+    "group": "一致性与验收",
+    "title": "并发更新:版本条件与行锁",
+    "summary": "复现两个客户端覆盖彼此修改,用原子条件 UPDATE 返回 409,并识别行锁适用的短事务。",
+    "objectives": [
+      "复现读后写导致的丢失更新",
+      "以 version 条件原子更新并对过期请求返回 409",
+      "区分乐观并发控制和短事务行锁"
+    ],
+    "concepts": [
+      {
+        "title": "版本表示读到的状态",
+        "body": "两个客户端都读到 version=1,第一个写入后版本变为 2,第二个仍提交 1 应被拒绝。检查与写入必须在同一条 SQL 的 WHERE 中完成;先 SELECT 检查再无条件 UPDATE 仍有竞争窗口。"
+      },
+      {
+        "title": "所有写路径必须遵守协议",
+        "body": "仅新增一个安全 PATCH 不够:旧 PATCH、批量脚本和管理工具若不递增版本,仍能覆盖别人修改。第 7 课扩展字段只解决结构兼容,启用强制版本前必须完成旧写路径切换并下线不安全写入口。"
+      },
+      {
+        "title": "行锁的用途",
+        "body": "SELECT FOR UPDATE 可以串行化同一行的短业务判断,例如检查状态后做多表更新。锁会持续到事务结束,不能在持锁时等待模型 API、发送邮件或用户输入。乐观控制减少等待但要求客户端处理冲突;二者可以按业务组合。"
+      }
+    ],
+    "steps": [
+      {
+        "title": "收紧 version 并扩展 DTO",
+        "body": "前提是第 7 课回填和 NOT NULL 迁移已成功。Note 类 version 改成 Mapped[int] = mapped_column(nullable=False, server_default=text(\"1\"));NoteOut 新增 version: int。再在 schemas.py 增加下面的更新请求,保留 NotePatch 的字段校验。",
+        "code": "class NoteVersionPatch(NotePatch):\n    version: int = Field(gt=0)",
+        "lang": "python",
+        "filename": "app/schemas.py"
+      },
+      {
+        "title": "替换原 PATCH 端点",
+        "body": "删除或替换 app/routes.py 原 patch_note,避免同方法同路径重复注册。原子 UPDATE 以 id 与 version 为条件;无匹配统一 409,涵盖已删除和版本冲突,客户端重新 GET 区分是否已删除。所有 SQL 更新同步刷新 updated_at 与 version。",
+        "code": "from sqlalchemy import update, func\nfrom app.schemas import NoteVersionPatch\n\n@router.patch(\"/{note_id}\", response_model=NoteOut)\nasync def patch_note(note_id: int, body: NoteVersionPatch, db: Db):\n    changes = body.model_dump(exclude_unset=True, exclude={\"version\"})\n    if not changes:\n        raise HTTPException(422, \"provide at least one field to update\")\n    async with db.begin():\n        stmt = update(Note).where(Note.id == note_id, Note.version == body.version).values(\n            **changes, version=Note.version + 1, updated_at=func.now()).returning(Note)\n        note = (await db.scalars(stmt)).one_or_none()\n        if note is None:\n            raise HTTPException(409, \"note changed or was deleted; reload before editing\")\n        result = NoteOut.model_validate(note)\n    return result",
+        "lang": "python",
+        "filename": "app/routes.py"
+      },
+      {
+        "title": "让删除也携带版本",
+        "body": "替换原 delete_note。这里用必填 version 查询参数便于 /docs 练习;生产协议也可统一为 If-Match + ETag。所有客户端脚本更新为提交当前 version,不能给 version 设置默认 1 绕过检查。",
+        "code": "from sqlalchemy import delete\n\n@router.delete(\"/{note_id}\", status_code=204)\nasync def delete_note(note_id: int, db: Db, version: Annotated[int, Query(gt=0)]):\n    async with db.begin():\n        deleted = await db.scalar(delete(Note).where(\n            Note.id == note_id, Note.version == version).returning(Note.id))\n        if deleted is None:\n            raise HTTPException(409, \"note changed or was deleted\")\n    return Response(status_code=204)",
+        "lang": "python",
+        "filename": "app/routes.py"
+      },
+      {
+        "title": "用两个客户端快照复现冲突",
+        "body": "在 scripts/version_probe.py 保存下面片段,运行后应得到 200 与 409。第一次写入内容应保留,不能被旧快照覆盖;创建路径可沿用 POST /notes,新字段由数据库默认值赋 1。",
+        "code": "import httpx\n\nwith httpx.Client(base_url=\"http://127.0.0.1:8000\", timeout=5) as client:\n    note = client.post(\"/notes\", json={\"title\": \"concurrency\"}).json()\n    path = f\"/notes/{note['id']}\"\n    a = client.get(path).json()\n    b = client.get(path).json()\n    first = client.patch(path, json={\"title\": \"client A\", \"version\": a[\"version\"]})\n    second = client.patch(path, json={\"title\": \"client B\", \"version\": b[\"version\"]})\n    assert first.status_code == 200, first.text\n    assert second.status_code == 409, second.text\n    current = client.get(path).json()\n    assert current[\"title\"] == \"client A\"\n    assert current[\"version\"] == note[\"version\"] + 1",
+        "lang": "python",
+        "filename": "scripts/version_probe.py"
+      },
+      {
+        "title": "比较短事务行锁",
+        "body": "下面为可替换服务函数片段,加入 app/services.py;由调用者在 db.begin() 内调用。此函数只示范锁内检查与写入,不能接收旧客户端内容进行无条件覆盖。锁等待由 lock_timeout 限制。",
+        "code": "from sqlalchemy import select, func\nfrom fastapi import HTTPException\n\nasync def pin_once(db: AsyncSession, note_id: int):\n    note = await db.scalar(select(Note).where(Note.id == note_id).with_for_update())\n    if note is None:\n        raise HTTPException(404, \"note not found\")\n    if not note.pinned:\n        note.pinned = True\n        note.version += 1\n        note.updated_at = func.now()\n        await db.flush()\n        await db.refresh(note)\n    return NoteOut.model_validate(note)",
+        "lang": "python",
+        "filename": "app/services.py"
+      }
+    ],
+    "checks": [
+      "两个相同版本更新只有一个成功,另一个为 409。",
+      "成功更新 version 恰好增加 1,内容与 updated_at 对应本次提交。",
+      "原 PATCH 与 DELETE 已替换,旧客户端缺 version 会失败而非静默覆盖。"
+    ],
+    "pitfalls": [
+      {
+        "symptom": "使用 ORM version_id_col 仍有批量更新绕过版本",
+        "cause": "ORM 版本检测主要作用于 flush 的单对象更新,批量 SQL 要自行加条件。",
+        "fix": "本课统一显式 WHERE version 与递增;若改用 mapper versioning,仍审计所有批量路径。"
+      },
+      {
+        "symptom": "锁等待导致接口成批超时",
+        "cause": "持锁期间执行外部请求,或多行操作锁顺序不一致。",
+        "fix": "缩短事务,所有路径按相同 id 顺序加锁,外部调用通过 outbox 在提交后处理。"
+      }
+    ],
+    "exercise": {
+      "task": "收到 409 后客户端可以自动读取新版本并原样重放旧编辑吗?",
+      "hint": "这会绕过保护覆盖别人刚写入的内容。",
+      "answer": "不能无条件重放。客户端重新读取并让用户合并,或只对可证明可交换的增量操作自动重试;重试必须保留业务意图,不能只换 version。"
+    },
+    "refs": [
+      {
+        "label": "PostgreSQL 16:显式锁",
+        "url": "https://www.postgresql.org/docs/16/explicit-locking.html"
+      },
+      {
+        "label": "SQLAlchemy:版本计数限制",
+        "url": "https://docs.sqlalchemy.org/en/20/orm/versioning.html"
+      },
+      {
+        "label": "SQLAlchemy:UPDATE RETURNING",
+        "url": "https://docs.sqlalchemy.org/en/20/tutorial/data_update.html"
+      }
+    ],
+    "production": {
+      "scenario": "两个管理页面同时编辑同一笔记,最后保存的人不应无提示覆盖前一次修改。",
+      "decisions": [
+        "客户端提交读到的版本,数据库原子验证。",
+        "脚本与管理入口遵守同一版本规则。"
+      ],
+      "failureDrill": "使用两个独立 HTTP 客户端同时提交同一 version,核验一个成功、一个冲突和数据库最终内容。",
+      "acceptance": [
+        "不出现丢失更新。",
+        "409 对客户端有明确恢复流程。"
+      ]
+    }
+  },
+  {
+    "id": "10",
+    "group": "一致性与验收",
+    "title": "幂等键与唯一约束",
+    "summary": "让创建请求在网络重试后返回同一次结果,使用唯一约束处理并发抢占,并拒绝同键不同内容。",
+    "objectives": [
+      "区分请求重试与重复创建业务语义",
+      "将幂等记录与业务写入放在同一事务",
+      "处理同时到达的同键请求和响应丢失"
+    ],
+    "concepts": [
+      {
+        "title": "结果未知需要可重查",
+        "body": "客户端超时不代表数据库回滚,服务可能已经提交,只是响应丢失。为一次逻辑创建生成稳定 Idempotency-Key 并在重试时保持不变,服务保存该键对应的状态码与响应体,就能返回第一次结果。"
+      },
+      {
+        "title": "唯一约束解决竞态",
+        "body": "先查键不存在再插入存在竞争窗口。INSERT ON CONFLICT DO NOTHING 配合唯一约束决定谁获得写入权;获胜者的业务行与幂等结果在同一事务提交。后到请求等前一事务完成,随后在 READ COMMITTED 的下一条 SELECT 中读取已提交结果。"
+      },
+      {
+        "title": "键的作用域与保留期限",
+        "body": "键至少绑定身份主体、HTTP 操作与请求摘要。本课尚无身份系统,只允许本机单主体练习,scope 固定表示该边界;上线必须从认证结果生成主体作用域。记录删除后相同键可能再次创建,必须公布保留期限并与客户端最大重试窗口一致。"
+      }
+    ],
+    "steps": [
+      {
+        "title": "添加幂等结果表迁移",
+        "body": "生成 Alembic revision,把下面 SQL 放入 op.execute;downgrade 使用 op.drop_table。它是普通非延迟主键,可供 ON CONFLICT 使用。业务成功后存 status_code 与响应;失败事务不保留占位记录。后续把此 Table 加入 Base.metadata,避免 autogenerate 误判为多余表。",
+        "code": "CREATE TABLE idempotency_records (\n    scope varchar(120) NOT NULL,\n    key varchar(100) NOT NULL,\n    request_hash varchar(64) NOT NULL,\n    status_code integer,\n    response_body jsonb,\n    created_at timestamptz NOT NULL DEFAULT now(),\n    PRIMARY KEY (scope, key)\n);",
+        "lang": "sql"
+      },
+      {
+        "title": "把幂等表登记为 SQLAlchemy Core Table",
+        "body": "追加到 app/models.py,复用已有 Table、Column、Base、String、DateTime、func。JSONB 和 Integer 使用下方导入。SQLAlchemy Core 与 ORM 可以共用一个事务。",
+        "code": "from sqlalchemy import Integer\nfrom sqlalchemy.dialects.postgresql import JSONB\n\nidempotency_records = Table(\"idempotency_records\", Base.metadata,\n    Column(\"scope\", String(120), primary_key=True),\n    Column(\"key\", String(100), primary_key=True),\n    Column(\"request_hash\", String(64), nullable=False),\n    Column(\"status_code\", Integer),\n    Column(\"response_body\", JSONB),\n    Column(\"created_at\", DateTime(timezone=True), nullable=False, server_default=func.now()),\n)",
+        "lang": "python",
+        "filename": "app/models.py"
+      },
+      {
+        "title": "新增幂等服务函数",
+        "body": "追加到 app/services.py。它要求外层尚未完成业务写入但已经 begin;不自行提交。on_conflict_do_nothing 的返回值标识是否占位成功;同键不同请求体返回 409。创建、占位和结果保存三步都是同一事务。",
+        "code": "import hashlib\nimport json\nfrom sqlalchemy import select, update\nfrom sqlalchemy.dialects.postgresql import insert\nfrom app.models import idempotency_records\n\nasync def create_idempotent(db: AsyncSession, body: NoteCreate, key: str, scope: str):\n    payload = json.dumps(body.model_dump(mode=\"json\"), sort_keys=True, separators=(\",\", \":\"), ensure_ascii=False)\n    digest = hashlib.sha256(payload.encode()).hexdigest()\n    claimed = await db.scalar(insert(idempotency_records).values(\n        scope=scope, key=key, request_hash=digest\n    ).on_conflict_do_nothing(index_elements=[\"scope\", \"key\"]).returning(idempotency_records.c.key))\n    if claimed is None:\n        stored = (await db.execute(select(idempotency_records).where(\n            idempotency_records.c.scope == scope, idempotency_records.c.key == key))).mappings().one()\n        if stored[\"request_hash\"] != digest:\n            raise HTTPException(409, \"idempotency key reused with different payload\")\n        if stored[\"status_code\"] is None:\n            raise HTTPException(503, \"idempotency result not ready\")\n        return stored[\"status_code\"], stored[\"response_body\"]\n    note = Note(**body.model_dump())\n    db.add(note)\n    await db.flush()\n    await db.refresh(note)\n    result = NoteOut.model_validate(note).model_dump(mode=\"json\")\n    await db.execute(update(idempotency_records).where(\n        idempotency_records.c.scope == scope, idempotency_records.c.key == key\n    ).values(status_code=201, response_body=result))\n    return 201, result",
+        "lang": "python",
+        "filename": "app/services.py"
+      },
+      {
+        "title": "新增 HTTP 入口",
+        "body": "追加到 app/routes.py。本机单主体 scope 必须在接入鉴权后替换为服务器可信身份加操作名,不能接受客户端随意传 scope。外层事务提交后才返回保存的响应。",
+        "code": "from fastapi.responses import JSONResponse\nfrom app.services import create_idempotent\n\n@router.post(\"/idempotent\", status_code=201)\nasync def create_idempotent_note(body: NoteCreate, db: Db,\n    idempotency_key: Annotated[str, Header(min_length=1, max_length=100)]):\n    async with db.begin():\n        status, payload = await create_idempotent(db, body, idempotency_key,\n            scope=\"local-student:POST:/notes/idempotent:v1\")\n    return JSONResponse(status_code=status, content=payload)",
+        "lang": "python",
+        "filename": "app/routes.py"
+      },
+      {
+        "title": "并发重试并核对结果",
+        "body": "新建 scripts/idempotency_probe.py。使用随机新键避免前次练习污染;两个请求并发提交相同内容,返回同一个 id。再用相同键改内容应返回 409。大量并发测试必须控制在本机练习环境。 此时服务仍使用 notes_lab_migration,所有最终数据核对都连接同一实验库。",
+        "code": "import asyncio\nfrom uuid import uuid4\nimport httpx\n\nasync def main():\n    headers = {\"Idempotency-Key\": str(uuid4())}\n    async with httpx.AsyncClient(base_url=\"http://127.0.0.1:8000\", timeout=5) as client:\n        async def send(title):\n            return await client.post(\"/notes/idempotent\", json={\"title\": title}, headers=headers)\n        a, b = await asyncio.gather(send(\"retry\"), send(\"retry\"))\n        assert a.status_code == b.status_code == 201, (a.text, b.text)\n        assert a.json() == b.json()\n        assert (await send(\"different\")).status_code == 409\n\nasyncio.run(main())",
+        "lang": "python",
+        "filename": "scripts/idempotency_probe.py"
+      }
+    ],
+    "checks": [
+      "两个相同键相同内容请求返回相同状态码和响应体,notes 只新增一条。",
+      "同键不同内容返回 409,不改变最初结果。",
+      "业务事务失败后不会留下 status_code=null 的已提交占位记录。"
+    ],
+    "pitfalls": [
+      {
+        "symptom": "请求哈希把 JSON 字段顺序当成内容不同",
+        "cause": "对原始 JSON 字符串直接计算摘要。",
+        "fix": "先 Pydantic 规范化,再固定字段顺序和编码;版本化摘要规则,升级时维持旧键语义。"
+      },
+      {
+        "symptom": "清理幂等表后客户端晚到重试又创建一条",
+        "cause": "保留期限短于客户端重试窗口,或没有对重复业务本身加约束。",
+        "fix": "公布键保留期并记录过期行为;订单号等业务唯一性还需独立唯一约束。"
+      }
+    ],
+    "exercise": {
+      "task": "如何模拟数据库已提交但客户端没收到响应?",
+      "hint": "在本机先成功调用后故意丢弃响应,再以原键重发即可验证服务器语义。",
+      "answer": "保存 key,第一次请求完成后不使用响应,第二次用相同 key/body 发起请求。核对只新增一行且得到相同 id;若做真正断连注入,要同时覆盖 commit 前后两个时点,不能从客户端断开推断数据库一定回滚。"
+    },
+    "refs": [
+      {
+        "label": "PostgreSQL 16:INSERT ON CONFLICT",
+        "url": "https://www.postgresql.org/docs/16/sql-insert.html"
+      },
+      {
+        "label": "SQLAlchemy:PostgreSQL UPSERT",
+        "url": "https://docs.sqlalchemy.org/en/20/dialects/postgresql.html#insert-on-conflict-upsert"
+      },
+      {
+        "label": "PostgreSQL 16:READ COMMITTED",
+        "url": "https://www.postgresql.org/docs/16/transaction-iso.html#XACT-READ-COMMITTED"
+      }
+    ],
+    "production": {
+      "scenario": "移动客户端在创建接口超时后自动重试,不能重复创建笔记。",
+      "decisions": [
+        "同一逻辑操作重用键,键绑定主体与操作。",
+        "幂等结果与业务数据在同一数据库事务提交。"
+      ],
+      "failureDrill": "隔离环境并发发送相同键,丢弃首个响应后再重试,最后核对记录与响应一致。",
+      "acceptance": [
+        "每个键最多创建一次本服务记录。",
+        "文档明确保留期限、主体作用域和不同 payload 冲突行为。"
+      ]
+    }
+  },
+  {
+    "id": "11",
+    "group": "一致性与验收",
+    "title": "事务 Outbox 与跨服务一致性",
+    "summary": "将笔记变更和待发事件原子落库,再由独立发布器领取事件并在提交后发送。",
+    "objectives": [
+      "解释数据库提交和消息发送之间的双写缺口",
+      "实现带租约的 outbox 领取和确认",
+      "让接收方以 event_id 去重并承受重复投递"
+    ],
+    "concepts": [
+      {
+        "title": "本地事务不覆盖外部服务",
+        "body": "先提交笔记再发消息,发送失败会漏事件;先发消息再提交,事务失败会留下不存在的业务事件。Outbox 把业务记录与待发事件写在同一数据库事务内,发布器稍后重试外部发送,接受最终一致性的延迟。"
+      },
+      {
+        "title": "至少一次与租约",
+        "body": "发布器领取一批事件,写 lease token 与 locked_until 后立即提交,再进行网络调用。崩溃后租约到期可重新领取;发送成功但标记失败会导致重复投递。系统保证的是可重试的至少一次交付流程,接收方必须去重;不是端到端恰好一次。"
+      },
+      {
+        "title": "领取、重试与顺序",
+        "body": "SKIP LOCKED 适合队列式争抢,不能用于普通业务一致列表。按 id 领取只提供尽量有序,不保证同一笔记多个事件的严格顺序。若业务需要顺序,事件携带 aggregate_id/version,接收方按版本处理并暂存缺口。"
+      }
+    ],
+    "steps": [
+      {
+        "title": "迁移 outbox 与本机接收表",
+        "body": "生成 Alembic revision 并通过 op.execute 执行以下 SQL。outbox 的 payload 最小化,不复制密钥或完整敏感提示词。表定义需同步加入 Base.metadata;以下后续步骤会提供对应 Core Table 映射,避免 autogenerate 误判删除。",
+        "code": "CREATE TABLE outbox_events (\n    id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,\n    event_id uuid NOT NULL UNIQUE,\n    aggregate_id bigint NOT NULL,\n    event_type varchar(80) NOT NULL,\n    payload jsonb NOT NULL,\n    created_at timestamptz NOT NULL DEFAULT now(),\n    available_at timestamptz NOT NULL DEFAULT now(),\n    locked_until timestamptz,\n    lock_token uuid,\n    attempts integer NOT NULL DEFAULT 0,\n    delivered_at timestamptz,\n    last_error varchar(120)\n);\nCREATE INDEX ix_outbox_pending ON outbox_events (available_at, id) WHERE delivered_at IS NULL;\nCREATE TABLE received_events (\n    event_id uuid PRIMARY KEY,\n    received_at timestamptz NOT NULL DEFAULT now()\n);",
+        "lang": "sql"
+      },
+      {
+        "title": "把 Outbox 结构同步到 metadata",
+        "body": "追加到 app/models.py,复用已有 Table、Column、Index、Base、BigInteger、Identity、Integer、String、DateTime、func、text 与 JSONB。只声明映射,不在应用启动时建表;迁移创建实际表。",
+        "code": "from sqlalchemy.dialects.postgresql import UUID\n\noutbox_events = Table(\"outbox_events\", Base.metadata,\n    Column(\"id\", BigInteger, Identity(), primary_key=True),\n    Column(\"event_id\", UUID(as_uuid=True), nullable=False, unique=True),\n    Column(\"aggregate_id\", BigInteger, nullable=False),\n    Column(\"event_type\", String(80), nullable=False),\n    Column(\"payload\", JSONB, nullable=False),\n    Column(\"created_at\", DateTime(timezone=True), nullable=False, server_default=func.now()),\n    Column(\"available_at\", DateTime(timezone=True), nullable=False, server_default=func.now()),\n    Column(\"locked_until\", DateTime(timezone=True)),\n    Column(\"lock_token\", UUID(as_uuid=True)),\n    Column(\"attempts\", Integer, nullable=False, server_default=text(\"0\")),\n    Column(\"delivered_at\", DateTime(timezone=True)),\n    Column(\"last_error\", String(120)),\n)\nIndex(\"ix_outbox_pending\", outbox_events.c.available_at, outbox_events.c.id,\n      postgresql_where=outbox_events.c.delivered_at.is_(None))\nreceived_events = Table(\"received_events\", Base.metadata,\n    Column(\"event_id\", UUID(as_uuid=True), primary_key=True),\n    Column(\"received_at\", DateTime(timezone=True), nullable=False, server_default=func.now()),\n)",
+        "lang": "python",
+        "filename": "app/models.py"
+      },
+      {
+        "title": "在创建事务内追加事件",
+        "body": "追加到 app/services.py。把 await add_created_event(db, note) 放入第 10 课 create_idempotent 的新建成功分支:在 flush/refresh 后、写入幂等结果前。重放分支不再生成事件。函数只写本地表,不做 HTTP。",
+        "code": "from uuid import uuid4\n\nasync def add_created_event(db: AsyncSession, note: Note):\n    await db.execute(text(\"\"\"\n        INSERT INTO outbox_events (event_id, aggregate_id, event_type, payload)\n        VALUES (:event_id, :note_id, 'note.created', CAST(:payload AS jsonb))\n    \"\"\"), {\"event_id\": uuid4(), \"note_id\": note.id,\n             \"payload\": json.dumps({\"note_id\": note.id, \"version\": note.version})})",
+        "lang": "python",
+        "filename": "app/services.py"
+      },
+      {
+        "title": "新建领取与确认函数",
+        "body": "新建 app/outbox.py。批量上限 20、租约 30 秒是练习参数;本课发布器每次只领 1 条以保证 3 秒请求预算小于租约。UPDATE 确认同时匹配 token,旧发布器无法覆盖新持有者。超过重试预算的事件停止自动领取并进入人工排查清单。",
+        "code": "from uuid import uuid4\nfrom sqlalchemy import text\nfrom app.db import SessionFactory\n\nasync def claim(limit=1):\n    if not 1 <= limit <= 20:\n        raise ValueError(\"limit must be between 1 and 20\")\n    token = uuid4()\n    async with SessionFactory.begin() as db:\n        rows = (await db.execute(text(\"\"\"\n            WITH picked AS (\n                SELECT id FROM outbox_events\n                WHERE delivered_at IS NULL AND available_at <= now()\n                  AND attempts < 8\n                  AND (locked_until IS NULL OR locked_until < now())\n                ORDER BY id FOR UPDATE SKIP LOCKED LIMIT :limit\n            )\n            UPDATE outbox_events e SET lock_token=:token,\n                locked_until=now()+interval '30 seconds', attempts=attempts+1\n            FROM picked WHERE e.id=picked.id\n            RETURNING e.id, e.event_id, e.event_type, e.payload, e.attempts\n        \"\"\"), {\"token\": token, \"limit\": limit})).mappings().all()\n    return token, [dict(x) for x in rows]\n\nasync def finish(event_id, token, success):\n    async with SessionFactory.begin() as db:\n        await db.execute(text(\"\"\"\n            UPDATE outbox_events\n            SET delivered_at=CASE WHEN :success THEN now() ELSE NULL END,\n                available_at=CASE WHEN :success THEN available_at ELSE\n                    now()+make_interval(secs => least(300, power(2, attempts)::int)) END,\n                locked_until=NULL, lock_token=NULL,\n                last_error=CASE WHEN :success THEN NULL ELSE 'delivery_failed' END\n            WHERE id=:id AND lock_token=:token AND delivered_at IS NULL\n        \"\"\"), {\"id\": event_id, \"token\": token, \"success\": success})",
+        "lang": "python",
+        "filename": "app/outbox.py"
+      },
+      {
+        "title": "添加本机去重接收端点",
+        "body": "追加到 app/main.py,仅用于本机教学模拟第二个服务。真实接收服务拥有独立数据库;去重插入与自身业务副作用必须同一事务。示例的副作用仅是记录接收,不伪称完成搜索索引等外部功能。",
+        "code": "from uuid import UUID\nfrom pydantic import BaseModel, Field\n\nclass IncomingEvent(BaseModel):\n    event_id: UUID\n    event_type: str = Field(max_length=80)\n    payload: dict\n\n@app.post(\"/internal/events\")\nasync def receive_event(body: IncomingEvent, db: Db):\n    async with db.begin():\n        new_id = await db.scalar(text(\"\"\"\n            INSERT INTO received_events (event_id) VALUES (:event_id)\n            ON CONFLICT (event_id) DO NOTHING RETURNING event_id\n        \"\"\"), {\"event_id\": body.event_id})\n        # 真实接收方在这里执行自己的数据库副作用,和去重记录一起提交。\n    return {\"accepted\": True, \"duplicate\": new_id is None}",
+        "lang": "python",
+        "filename": "app/main.py"
+      },
+      {
+        "title": "创建单次发布脚本",
+        "body": "新建 scripts/publish_once.py,运行 python -m scripts.publish_once。它每次处理一条,HTTP 在 claim 事务提交之后执行;没有在持锁时等待网络。成功接收但 finish 前崩溃会被再次投递,这是下一步演练目标。",
+        "code": "import asyncio\nimport sys\nimport httpx\nfrom app.db import engine\nfrom app.outbox import claim, finish\n\nasync def main():\n    try:\n        token, events = await claim(limit=1)\n        async with httpx.AsyncClient(timeout=3) as client:\n            for event in events:\n                success = False\n                try:\n                    response = await client.post(\"http://127.0.0.1:8000/internal/events\", json={\n                        \"event_id\": str(event[\"event_id\"]), \"event_type\": event[\"event_type\"],\n                        \"payload\": event[\"payload\"]})\n                    response.raise_for_status()\n                    success = True\n                except httpx.HTTPError:\n                    pass\n                await finish(event[\"id\"], token, success)\n    finally:\n        await engine.dispose()\n\nif __name__ == \"__main__\":\n    if sys.platform == \"win32\":\n        asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())\n    asyncio.run(main())",
+        "lang": "python",
+        "filename": "scripts/publish_once.py"
+      },
+      {
+        "title": "演练发送成功、确认丢失",
+        "body": "仅在本机将 publish_once 中 await finish 临时注释一次,创建一条新幂等笔记后发布,确认 received_events 已有记录但 outbox 未 delivered。等待租约到期后恢复 finish 再发;接收方 duplicate=true,received_events 仍只有一行。用下面查询监控积压,不将 attempts>=8 的事件静默删除。",
+        "code": "SELECT count(*) AS pending,\n       min(created_at) AS oldest_pending,\n       count(*) FILTER (WHERE attempts >= 8) AS needs_attention\nFROM outbox_events WHERE delivered_at IS NULL;",
+        "lang": "sql"
+      }
+    ],
+    "checks": [
+      "幂等创建只产生一条 notes 和一条对应 outbox event,重试不会再发新事件。",
+      "claim 结束后才调用 HTTP,数据库没有等待外部服务的长事务。",
+      "重复投递被接收方去重,失败事件保留次数、下一次可尝试时间与排查状态。"
+    ],
+    "pitfalls": [
+      {
+        "symptom": "消息发送成功后进程崩溃,恢复后又发送一次",
+        "cause": "外部成功与本地 delivered 标记不能在一个普通数据库事务中原子提交。",
+        "fix": "接受重复并按 event_id 去重,去重记录和接收方业务写入共用事务。"
+      },
+      {
+        "symptom": "批量领取 100 条后大量租约过期",
+        "cause": "顺序发送总耗时超过租约,其他发布器重新领取。",
+        "fix": "缩小批次、限制单次请求时间;扩大并行时计算整个批次最坏耗时,必要时做可验证的续租。"
+      }
+    ],
+    "exercise": {
+      "task": "如果事件用于邮件发送,接收数据库去重是否保证邮件只发送一次?",
+      "hint": "数据库事务不能原子覆盖外部邮件供应商。",
+      "answer": "不能。邮件供应商若支持幂等键,应传 event_id;否则仍存在发送成功但记录失败的窗口。要按业务允许的重复程度设计去重、状态查询或人工补偿,不能称为恰好一次。"
+    },
+    "refs": [
+      {
+        "label": "PostgreSQL 16:SKIP LOCKED",
+        "url": "https://www.postgresql.org/docs/16/sql-select.html"
+      },
+      {
+        "label": "AWS:事务 Outbox 模式",
+        "url": "https://docs.aws.amazon.com/prescriptive-guidance/latest/cloud-design-patterns/transactional-outbox.html"
+      },
+      {
+        "label": "PostgreSQL 16:唯一约束",
+        "url": "https://www.postgresql.org/docs/16/ddl-constraints.html#DDL-CONSTRAINTS-UNIQUE-CONSTRAINTS"
+      }
+    ],
+    "production": {
+      "scenario": "创建笔记后要通知索引服务,但索引服务短暂不可用。",
+      "decisions": [
+        "业务事务内只记录事件;外部传输在短租约领取后执行。",
+        "监控积压年龄与耗尽重试,接收方按事件 id 去重。"
+      ],
+      "failureDrill": "模拟成功接收后未确认,再过期重领,核对接收记录只有一条且 outbox 最终 delivered。",
+      "acceptance": [
+        "不丢失已提交业务的待发事件。",
+        "承认并验证至少一次投递与最终一致性边界。"
+      ]
+    }
+  },
+  {
+    "id": "12",
+    "group": "一致性与验收",
+    "title": "真实 PostgreSQL 隔离测试与故障验收",
+    "summary": "用专用 PostgreSQL 测试库验证提交、并发、迁移和故障恢复,避免 SQLite 掩盖生产差异。",
+    "objectives": [
+      "区分回滚隔离测试与真实提交并发测试",
+      "使用依赖覆盖连接真实 PostgreSQL",
+      "建立错误注入与恢复验收清单"
+    ],
+    "concepts": [
+      {
+        "title": "测试数据库必须同类",
+        "body": "SQLite 的类型、JSON、锁、约束和并发行为与 PostgreSQL 不同。本模块针对 PostgreSQL 16 测试,测试库独立为 notes_lab_test,使用专用凭据或限定权限。测试代码启动时检查数据库名,不从默认生产 URL 继承目标。"
+      },
+      {
+        "title": "两类测试不能混用",
+        "body": "单请求 CRUD 可以把 Session 绑定外层连接事务,通过 savepoint 让内部 commit 后最终回滚清理。并发与提交失败测试必须使用多个真实连接和真正 commit;外层回滚会隐藏可延迟约束的提交时机,也无法验证其他连接可见性。"
+      },
+      {
+        "title": "故障测试的验收对象",
+        "body": "不只断言错误码,还要查数据库最终状态、连接是否释放、下一次正常请求是否恢复。连接中断可能造成提交结果未知,验收需用幂等键重查;不要凭一次网络错误断言写入不存在。"
+      }
+    ],
+    "steps": [
+      {
+        "title": "准备独立测试库与迁移",
+        "body": "先用数据库管理员在本机创建 notes_lab_test 并授予练习 app_user 对该库迁移权限。仅当前测试终端设置以下变量;把 CHANGE_ME 替换为本机测试密码。运行迁移前通过 psql 核对数据库名称。不要对共享库执行测试清理。",
+        "code": "$env:TEST_DATABASE_URL=\"postgresql+psycopg://app_user:CHANGE_ME@127.0.0.1:5433/notes_lab_test\"\n$env:DATABASE_URL=$env:TEST_DATABASE_URL\npython -m alembic upgrade head\npython -m alembic check",
+        "lang": "powershell"
+      },
+      {
+        "title": "新建 pytest 配置与隔离 fixture",
+        "body": "新建 pytest.ini 内容 [pytest] 下一行 asyncio_mode = auto。再新建 tests/conftest.py。在导入 app 前覆盖 DATABASE_URL;fixture 检查测试库名并使用 NullPool,避免不同事件循环复用连接。外层事务提供回滚隔离,但不能用此 fixture 验证并发或 commit 时延迟约束。",
+        "code": "import os\nimport sys\nimport asyncio\nimport pytest_asyncio\nfrom httpx import ASGITransport, AsyncClient\nfrom sqlalchemy.engine import make_url\nfrom sqlalchemy.ext.asyncio import AsyncSession, create_async_engine\nfrom sqlalchemy.pool import NullPool\n\nif sys.platform == \"win32\":\n    asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())\n\ntest_url = os.environ[\"TEST_DATABASE_URL\"]\nif make_url(test_url).database != \"notes_lab_test\":\n    raise RuntimeError(\"tests require notes_lab_test\")\nos.environ[\"DATABASE_URL\"] = test_url\n\nfrom app.main import app\nfrom app.db import get_session\n\n@pytest_asyncio.fixture\nasync def client():\n    test_engine = create_async_engine(test_url, poolclass=NullPool)\n    try:\n        async with test_engine.connect() as conn:\n            outer = await conn.begin()\n            async def override():\n                async with AsyncSession(bind=conn, expire_on_commit=False,\n                    join_transaction_mode=\"create_savepoint\") as db:\n                    yield db\n            app.dependency_overrides[get_session] = override\n            try:\n                async with AsyncClient(transport=ASGITransport(app=app), base_url=\"http://test\") as c:\n                    yield c\n            finally:\n                app.dependency_overrides.clear()\n                await outer.rollback()\n    finally:\n        await test_engine.dispose()",
+        "lang": "python",
+        "filename": "tests/conftest.py"
+      },
+      {
+        "title": "测试最终版本 CRUD 协议",
+        "body": "新建 tests/test_notes.py。版本字段与第 9 课保持一致,先验证成功更新再验证旧版本冲突。ASGITransport 不自动运行 lifespan;本 fixture 不依赖启动建表,迁移已在前一步完成。需要启动资源的应用应显式使用 lifespan 管理器。",
+        "code": "async def test_create_patch_conflict_delete(client):\n    response = await client.post(\"/notes\", json={\"title\": \"isolated\"})\n    assert response.status_code == 201, response.text\n    original = response.json()\n    path = f\"/notes/{original['id']}\"\n    saved = await client.patch(path, json={\"title\": \"updated\", \"version\": original[\"version\"]})\n    assert saved.status_code == 200, saved.text\n    updated = saved.json()\n    assert updated[\"version\"] == original[\"version\"] + 1\n    conflict = await client.patch(path, json={\"title\": \"stale\", \"version\": original[\"version\"]})\n    assert conflict.status_code == 409\n    assert (await client.get(path)).json()[\"title\"] == \"updated\"\n    deleted = await client.delete(path, params={\"version\": updated[\"version\"]})\n    assert deleted.status_code == 204 and deleted.content == b\"\"\n    assert (await client.get(path)).status_code == 404\n\nasync def test_input_boundaries(client):\n    assert (await client.post(\"/notes\", json={\"title\": \"x\" * 121})).status_code == 422\n    assert (await client.get(\"/notes\", params={\"limit\": 101})).status_code == 422\n    assert (await client.get(\"/notes\", params={\"cursor\": \"broken!\"})).status_code == 422",
+        "lang": "python",
+        "filename": "tests/test_notes.py"
+      },
+      {
+        "title": "增加真正提交的并发 fixture",
+        "body": "追加到 tests/conftest.py。该 fixture 使用不同请求各自的真实连接,不使用外层回滚;只能在专用测试库运行。每个测试用随机键隔离,并在测试 finally 清理自己产生的记录。不要并发运行会修改 app.dependency_overrides 的测试。",
+        "code": "from sqlalchemy.ext.asyncio import async_sessionmaker\n\n@pytest_asyncio.fixture\nasync def committed_client():\n    test_engine = create_async_engine(test_url, poolclass=NullPool)\n    factory = async_sessionmaker(test_engine, expire_on_commit=False)\n    async def override():\n        async with factory() as db:\n            yield db\n    app.dependency_overrides[get_session] = override\n    try:\n        async with AsyncClient(transport=ASGITransport(app=app), base_url=\"http://test\") as c:\n            yield c, factory\n    finally:\n        app.dependency_overrides.clear()\n        await test_engine.dispose()",
+        "lang": "python",
+        "filename": "tests/conftest.py"
+      },
+      {
+        "title": "编写并发幂等测试并精确清理",
+        "body": "新建 tests/test_concurrency.py。只按本测试随机 key 查到的 note_id 清理关联 outbox 和业务记录;不 TRUNCATE 全库。若测试进程被强制结束残留数据,应通过专用测试库重建或运行有审计的测试清理作业。",
+        "code": "import asyncio\nfrom uuid import uuid4\nfrom sqlalchemy import text\n\nasync def test_idempotency_concurrent(committed_client):\n    client, factory = committed_client\n    key = str(uuid4())\n    scope = \"local-student:POST:/notes/idempotent:v1\"\n    try:\n        async def send():\n            return await client.post(\"/notes/idempotent\", json={\"title\": \"parallel\"},\n                headers={\"Idempotency-Key\": key})\n        a, b = await asyncio.gather(send(), send())\n        assert a.status_code == b.status_code == 201, (a.text, b.text)\n        assert a.json() == b.json()\n        async with factory.begin() as db:\n            count = await db.scalar(text(\"SELECT count(*) FROM outbox_events WHERE aggregate_id=:id\"), {\"id\": a.json()[\"id\"]})\n            assert count == 1\n    finally:\n        async with factory.begin() as db:\n            payload = await db.scalar(text(\"SELECT response_body FROM idempotency_records WHERE scope=:scope AND key=:key\"), {\"scope\": scope, \"key\": key})\n            if payload:\n                note_id = payload[\"id\"]\n                await db.execute(text(\"DELETE FROM outbox_events WHERE aggregate_id=:id\"), {\"id\": note_id})\n                await db.execute(text(\"DELETE FROM notes WHERE id=:id\"), {\"id\": note_id})\n            await db.execute(text(\"DELETE FROM idempotency_records WHERE scope=:scope AND key=:key\"), {\"scope\": scope, \"key\": key})",
+        "lang": "python",
+        "filename": "tests/test_concurrency.py"
+      },
+      {
+        "title": "运行测试与故障矩阵",
+        "body": "执行 python -m pytest -q。再在独立本机服务对第 4、6、11 课演练做真实提交验收,并记录错误码、数据库最终状态和恢复探针。以下矩阵是需要实际执行的验收任务,不代表课件已运行并通过;不要在未执行时把“预期”写成“结果”。",
+        "code": "场景 | 注入方式 | 预期状态 | 恢复验证\n提交约束失败 | 重复 audited request-key | 409;notes/audits 同时回滚 | 新键创建成功\n池耗尽 | 独立 pool_drill,池上限 1 | 一任务超时;无连接泄漏 | SELECT 1 成功\n陈旧编辑 | 两客户端相同 version | 一个成功、一个 409 | GET 与成功者一致\n同键重试 | 两真实连接同时创建 | 相同响应;一条笔记一条事件 | 再重试同结果\n发布器崩溃 | 接收后跳过 finish | 事件到期重领;接收方去重 | 最终 delivered\n迁移锁阻塞 | 专用库长事务占锁 | 超时失败;revision 未推进 | 释放锁后 upgrade 成功",
+        "lang": "text"
+      }
+    ],
+    "checks": [
+      "pytest 输出真实执行结果;测试库名称防护拒绝 notes_lab 或其他库。",
+      "回滚 fixture 不留下 CRUD 数据,并发 fixture 使用真实提交且只清理自身记录。",
+      "失败演练检查了数据库最终状态和恢复能力,没有用 SQLite 替代 PostgreSQL 特性。"
+    ],
+    "pitfalls": [
+      {
+        "symptom": "回滚隔离测试通过,但线上 commit 延迟约束失败",
+        "cause": "测试提交的是 SAVEPOINT,不是最外层数据库 COMMIT。",
+        "fix": "延迟约束、并发可见性与网络提交异常使用 committed_client 或独立进程专用测试库。"
+      },
+      {
+        "symptom": "测试发出请求后后台发布器读不到数据",
+        "cause": "测试业务事务仍被外层回滚事务包裹,其他连接看不到未提交数据。",
+        "fix": "跨连接集成测试使用真正提交,并按测试生成的唯一业务键清理。"
+      }
+    ],
+    "exercise": {
+      "task": "如何防止测试运行时意外连接到线上?",
+      "hint": "不要只依赖“我记得设置了环境变量”。",
+      "answer": "要求 TEST_DATABASE_URL 必填并校验数据库名,使用专用网络与最小权限测试凭据,CI 不注入生产凭据;日志输出脱敏 host/database 供核对。数据库名检查只是第一道防线,不能替代网络与权限隔离。"
+    },
+    "refs": [
+      {
+        "label": "SQLAlchemy:外部事务与测试",
+        "url": "https://docs.sqlalchemy.org/en/20/orm/session_transaction.html#joining-a-session-into-an-external-transaction-such-as-for-test-suites"
+      },
+      {
+        "label": "FastAPI:异步测试",
+        "url": "https://fastapi.tiangolo.com/advanced/async-tests/"
+      },
+      {
+        "label": "HTTPX:ASGI Transport",
+        "url": "https://www.python-httpx.org/advanced/transports/#asgi-transport"
+      },
+      {
+        "label": "pytest-asyncio:配置",
+        "url": "https://pytest-asyncio.readthedocs.io/en/stable/reference/configuration.html"
+      }
+    ],
+    "production": {
+      "scenario": "数据库连接、迁移和并发逻辑要进入持续集成,而不依赖开发者手工点 /docs。",
+      "decisions": [
+        "同类数据库验证实际语义,测试环境显式隔离。",
+        "单请求回滚测试与真实提交集成测试分开,验收包括故障恢复。"
+      ],
+      "failureDrill": "完整运行本课矩阵,仅使用本机专用测试库,保存预期、实际、证据和恢复结果四列记录。",
+      "acceptance": [
+        "CRUD、输入边界、并发与幂等有真实自动化断言。",
+        "迁移与失败路径有执行证据,未运行的项目明确标记未验证。"
+      ]
+    }
+  }
+]

+ 1247 - 0
public/content/database.json

@@ -0,0 +1,1247 @@
+[
+  {
+    "id": "01",
+    "title": "准备 PostgreSQL 与 psql 练习环境",
+    "group": "连接与数据模型",
+    "summary": "为 notes-service 建立独立练习库,区分数据库进程、数据库、角色与客户端。",
+    "objectives": [
+      "启动 PostgreSQL 16 并创建非超级用户",
+      "用 psql 明确连接地址、端口、角色和数据库",
+      "区分连接失败、认证失败和 SQL 错误"
+    ],
+    "concepts": [
+      {
+        "title": "服务端与客户端",
+        "body": "PostgreSQL 进程负责保存数据和执行 SQL;psql 是向它发送命令的终端工具。关闭 psql 不会停止数据库。一个 PostgreSQL 实例可以包含多个数据库,连接时必须确定要进入哪个库。"
+      },
+      {
+        "title": "角色与数据库分别创建",
+        "body": "角色表示谁在连接,数据库表示在哪里读写。练习的 app_user 是 notes_lab 所有者,但不是超级用户。第 11 课再拆分迁移角色与运行时角色,避免 API 进程拥有修改表结构的权限。"
+      },
+      {
+        "title": "实验边界",
+        "body": "本模块所有命令都是学员在独立 notes_lab 中执行的练习,不是让你操作线上数据库。需要 Python 3.11+ 和已安装、启动的 Docker。此处只用容器获得一致的 PostgreSQL 16 环境;镜像、卷与容器网络在 Docker 模块系统学习。"
+      }
+    ],
+    "steps": [
+      {
+        "title": "建立项目与本机密码文件",
+        "body": "以下为 Linux / macOS Bash 命令;Windows 可在 WSL 中执行。本节使用 127.0.0.1:5433,容器内部仍是 5432。密码只用于本机练习,不要提交到 Git。终端在 notes-service 中执行。",
+        "code": "mkdir -p notes-service\ncd notes-service\numask 077\nprintf 'POSTGRES_PASSWORD=%s\\n' \"$(python3 -c 'import secrets; print(secrets.token_urlsafe(24))')\" > .postgres.env\nprintf '.postgres.env\\n.env\\nbackups/\\n.venv/\\n' >> .gitignore\ndocker run --name notes-postgres --env-file .postgres.env \\\n  -p 127.0.0.1:5433:5432 \\\n  -v notes_pgdata:/var/lib/postgresql/data -d postgres:16\ndocker exec notes-postgres pg_isready -U postgres",
+        "lang": "bash"
+      },
+      {
+        "title": "创建练习角色和数据库",
+        "body": "等待 pg_isready 显示 accepting connections。先执行 docker exec -it notes-postgres psql -U postgres -d postgres,然后在该 psql 中执行下列命令。\\password 会交互要求输入两遍 app_user 密码,不在 SQL 历史中直接写出密码。下面建库命令只执行一次。",
+        "code": "CREATE ROLE app_user LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE;\n\\password app_user\nCREATE DATABASE notes_lab OWNER app_user;\n\\q",
+        "lang": "sql"
+      },
+      {
+        "title": "用业务身份重新连接",
+        "body": "执行 docker exec -it notes-postgres psql -h 127.0.0.1 -U app_user -d notes_lab -W,输入刚设置的密码。该命令在容器内访问容器的 5432。若本机已安装 psql,则使用 psql -h 127.0.0.1 -p 5433 -U app_user -d notes_lab -W。后续未特别标明的 SQL 都在此连接运行。",
+        "code": "\\conninfo\nSELECT current_database(), current_user, version();\nSHOW server_encoding;\nSHOW TimeZone;\n\\set VERBOSITY verbose\n\\timing on",
+        "lang": "sql"
+      },
+      {
+        "title": "验证持久化与 SQL 命令边界",
+        "body": "运行两条 SELECT,观察分号用于结束 SQL,反斜杠命令由 psql 处理。退出客户端后重新连接,数据库仍存在。数据库卷保留了实例数据;不要为了重新做某节练习删除整个卷。",
+        "code": "SELECT 6 * 7 AS answer;\n-- answer = 42\nSELECT current_database() = 'notes_lab' AS in_lab;\n-- in_lab = t\n\\q",
+        "lang": "sql"
+      }
+    ],
+    "checks": [
+      "current_database() 为 notes_lab,current_user 为 app_user。",
+      "version() 显示 PostgreSQL 16,server_encoding 为 UTF8。",
+      "退出并重新连接后 SELECT 6 * 7 仍能执行;数据库进程没有因客户端退出而停止。"
+    ],
+    "pitfalls": [
+      {
+        "symptom": "connection refused",
+        "cause": "容器未启动、端口尚未就绪或混用了宿主机与容器端口。",
+        "fix": "用 docker ps 与 pg_isready 检查状态;本机使用 5433,docker exec 内部使用 5432。"
+      },
+      {
+        "symptom": "role app_user does not exist 或 password authentication failed",
+        "cause": "创建角色步骤未执行,或输入的是 postgres 密码。",
+        "fix": "使用管理连接检查 \\du;通过 \\password app_user 重设本机练习密码,再用业务身份重连。"
+      }
+    ],
+    "exercise": {
+      "task": "分别在一个正确连接和一个故意写错数据库名的连接中观察结果,记录错误发生在哪一层。",
+      "hint": "将 notes_lab 暂时写成 notes_missing;不要真的创建这个库。",
+      "answer": "正确连接能执行 SELECT;错误库名在连接阶段返回 database does not exist,SQL 尚未执行。连接地址错通常是网络层失败,密码错是认证失败,SELECT 1/0 则是连接成功后的 SQL 执行错误。"
+    },
+    "refs": [
+      {
+        "label": "PostgreSQL 16:psql",
+        "url": "https://www.postgresql.org/docs/16/app-psql.html"
+      },
+      {
+        "label": "PostgreSQL 16:创建数据库",
+        "url": "https://www.postgresql.org/docs/16/sql-createdatabase.html"
+      },
+      {
+        "label": "Docker:PostgreSQL 官方镜像",
+        "url": "https://hub.docker.com/_/postgres"
+      }
+    ],
+    "production": {
+      "scenario": "新服务需要一个可重复创建、不会误连线上实例的开发数据库。",
+      "decisions": [
+        "连接串明确包含数据库名与端口。",
+        "先用非超级用户练习,环境文件加入忽略规则。",
+        "生产环境选择受维护的 16.x 补丁版并锁定镜像摘要;postgres:16 是本课程环境入口。"
+      ],
+      "failureDrill": "在独立练习环境中用错误端口尝试连接,依据 pg_isready 与实际端口定位原因。",
+      "acceptance": [
+        "能从连接信息确认当前实例和身份。",
+        "数据库卷与容器用途可区分。",
+        "仓库没有密码文件。"
+      ]
+    }
+  },
+  {
+    "id": "02",
+    "title": "定义笔记表、数据类型与约束",
+    "group": "连接与数据模型",
+    "summary": "把 Python 对象转成有明确边界的数据表,让非法数据在数据库入口被拒绝。",
+    "objectives": [
+      "建立 notes 表并理解主键",
+      "选择 text、boolean 和 timestamptz",
+      "通过 NOT NULL、CHECK 与默认值维护数据规则"
+    ],
+    "concepts": [
+      {
+        "title": "列类型约束存储形式",
+        "body": "bigint 存储整数编号,varchar(120) 限制标题字符数,text 存储正文。boolean 使用 true / false,不能用随意约定的字符串。时间使用 timestamptz 表示一个时间点,显示格式受会话时区影响。"
+      },
+      {
+        "title": "默认值与非空是两条规则",
+        "body": "DEFAULT 只在插入时省略列或使用 DEFAULT 时生效;显式传 NULL 仍然是 NULL。NOT NULL 才拒绝空值,CHECK 再约束有效内容。应用校验改善错误提示,数据库约束保护所有写入入口。"
+      },
+      {
+        "title": "主键和生成编号",
+        "body": "identity 自动提供编号,PRIMARY KEY 保证编号唯一且非空。编号用于定位,不保证连续,也不代表严格的业务提交顺序。updated_at 的默认值只在插入时生效,更新时需要显式修改或使用统一触发器。"
+      }
+    ],
+    "steps": [
+      {
+        "title": "确认连接后创建表",
+        "body": "在第 01 课的 app_user / notes_lab 连接中执行。保存为 sql/001_notes.sql,首次运行一次;后续变更使用单独迁移文件,不要反复删表重建。",
+        "code": "CREATE TABLE notes (\n  id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,\n  title varchar(120) NOT NULL CHECK (char_length(btrim(title)) > 0),\n  content text NOT NULL DEFAULT '',\n  pinned boolean NOT NULL DEFAULT false,\n  created_at timestamptz NOT NULL DEFAULT now(),\n  updated_at timestamptz NOT NULL DEFAULT now()\n);\n\\d notes",
+        "lang": "sql",
+        "filename": "sql/001_notes.sql"
+      },
+      {
+        "title": "插入合法数据并看默认值",
+        "body": "本节写入的标题用 db02- 前缀,避免与后续实验混淆。RETURNING 在同一次写入中返回实际保存的行。记录生成的编号,但后续练习不依赖它必须从 1 开始。",
+        "code": "INSERT INTO notes (title) VALUES ('db02-first')\nRETURNING id, title, content, pinned, created_at, updated_at;\n-- content 为空串,pinned 为 f,两列时间均非空\nSELECT count(*) FROM notes WHERE title = 'db02-first';",
+        "lang": "sql"
+      },
+      {
+        "title": "让数据库拒绝非法数据",
+        "body": "以下两条语句在 psql 默认自动提交状态逐条执行;预期分别失败,不要包进同一个 BEGIN 后继续运行。随后 count(*) 仍为 1。",
+        "code": "INSERT INTO notes (title) VALUES (NULL);\n-- SQLSTATE 23502: not_null_violation\nINSERT INTO notes (title) VALUES ('   ');\n-- SQLSTATE 23514: check_violation\nSELECT count(*) FROM notes WHERE title = 'db02-first';",
+        "lang": "sql"
+      },
+      {
+        "title": "观察同一时间点的时区显示",
+        "body": "这里改的是当前会话显示方式。UTC 与上海时间显示相差 8 小时,epoch 秒数相同。业务对外使用带时区 ISO 8601,不能在 Python 中去掉 tzinfo 后再猜测时区。",
+        "code": "SET TIME ZONE 'UTC';\nSELECT created_at, extract(epoch FROM created_at) FROM notes WHERE title='db02-first';\nSET TIME ZONE 'Asia/Shanghai';\nSELECT created_at, extract(epoch FROM created_at) FROM notes WHERE title='db02-first';\nSET TIME ZONE 'UTC';",
+        "lang": "sql"
+      }
+    ],
+    "checks": [
+      "\\d notes 显示主键、identity、非空与标题 CHECK。",
+      "省略 content 和 pinned 时得到默认值;显式 NULL 标题被拒绝。",
+      "切换时区后显示不同,epoch 值保持一致。"
+    ],
+    "pitfalls": [
+      {
+        "symptom": "UPDATE 后 updated_at 没变化",
+        "cause": "DEFAULT now() 只参与插入。",
+        "fix": "后续每次更新同时设置 updated_at=now();多人维护时把规则收敛到统一写入层或触发器。"
+      },
+      {
+        "symptom": "空标题进入数据库",
+        "cause": "只使用了 NOT NULL,空串不是 NULL。",
+        "fix": "加入 btrim 与长度 CHECK,并在应用侧做一致校验;已有不合格数据需先清理。"
+      }
+    ],
+    "exercise": {
+      "task": "验证 121 个字符的标题被拒绝,再插入带单引号的合法标题。",
+      "hint": "SQL 字面量中的单引号用两个单引号表示;Python 动态值在下一课使用参数化。",
+      "answer": "INSERT INTO notes(title) VALUES(repeat('a',121)); 会触发长度错误。INSERT INTO notes(title) VALUES('db02-O''Reilly') RETURNING title; 返回 db02-O'Reilly。SQL 中的引号转义不替代 Python 驱动的参数化接口。"
+    },
+    "refs": [
+      {
+        "label": "PostgreSQL 16:约束",
+        "url": "https://www.postgresql.org/docs/16/ddl-constraints.html"
+      },
+      {
+        "label": "PostgreSQL 16:日期时间类型",
+        "url": "https://www.postgresql.org/docs/16/datatype-datetime.html"
+      },
+      {
+        "label": "PostgreSQL 16:生成列编号",
+        "url": "https://www.postgresql.org/docs/16/sql-createtable.html"
+      }
+    ],
+    "production": {
+      "scenario": "同一张表将被 API、导入脚本和后台任务共同写入。",
+      "decisions": [
+        "关键合法性规则落到数据库。",
+        "编号只当标识符,不用于推算数据量。",
+        "统一存时间点、在展示层选择时区。"
+      ],
+      "failureDrill": "向 notes_lab 提交空标题、NULL 和超长标题,逐条记录 SQLSTATE。",
+      "acceptance": [
+        "非法行没有落库。",
+        "合法写入能返回真实生成值。",
+        "能说明 updated_at 的维护位置。"
+      ]
+    }
+  },
+  {
+    "id": "03",
+    "title": "用参数化 SQL 完成增删改查",
+    "group": "SQL 与关系建模",
+    "summary": "从 Python 读写笔记,处理不存在的数据,并验证用户输入不会变成 SQL。",
+    "objectives": [
+      "使用 psycopg 3 传递参数",
+      "通过 RETURNING 判断实际写入结果",
+      "用明确列名、过滤与排序控制查询"
+    ],
+    "concepts": [
+      {
+        "title": "SQL 结构与参数分开传递",
+        "body": "execute 的 SQL 字符串描述操作结构,第二个参数提供数据。值占位符统一写 %s,不自行加引号,也不用 f-string 拼接输入。表名、列名不能作为普通值参数传入,动态排序应使用固定白名单。"
+      },
+      {
+        "title": "写入结果要检查",
+        "body": "UPDATE 或 DELETE 匹配不到数据通常不是 SQL 错误。RETURNING 返回零行能表示资源不存在;不要在未检查结果时声称修改成功。"
+      },
+      {
+        "title": "读取结果必须有边界",
+        "body": "查询显式列名能稳定输出结构。列表查询需要 LIMIT 和确定排序,否则数据库不承诺返回顺序。id DESC 可作为当前课程按编号倒序的简单规则。"
+      }
+    ],
+    "steps": [
+      {
+        "title": "安装驱动与设置连接",
+        "body": "在 notes-service 创建并激活 Python 3.11+ 虚拟环境,执行 python -m pip install \"psycopg[binary]>=3.2,<4\"。把实际解析版本写入 requirements-lock.txt。Linux 使用下面的 PG 环境变量,PGPASSWORD 在当前 shell 中交互输入,不写进脚本。正式环境使用密钥注入并避免输出连接串。",
+        "code": "export PGHOST=127.0.0.1 PGPORT=5433 PGDATABASE=notes_lab PGUSER=app_user\nread -rsp 'Database password: ' PGPASSWORD\nprintf '\\n'\nexport PGPASSWORD\npython -m pip freeze > requirements-lock.txt",
+        "lang": "bash"
+      },
+      {
+        "title": "保存并运行完整 CRUD 脚本",
+        "body": "保存为 db_crud.py,再执行 python db_crud.py。connect(\"\") 读取 PG 环境变量;上下文正常退出提交,异常退出回滚。单元素参数必须带逗号。此脚本先创建自己的数据,最后删除同一行,可以重复执行。",
+        "code": "import psycopg\nfrom psycopg.rows import dict_row\n\nwith psycopg.connect(\"\", row_factory=dict_row) as conn:\n    created = conn.execute(\n        \"INSERT INTO notes(title, content) VALUES (%s, %s) RETURNING id, title\",\n        (\"db03-O'Reilly\", \"parameterized insert\"),\n    ).fetchone()\n    note_id = created[\"id\"]\n    print(\"created\", created)\n    print(\"read\", conn.execute(\n        \"SELECT id, title, pinned FROM notes WHERE id = %s\", (note_id,)\n    ).fetchone())\n    print(\"updated\", conn.execute(\n        \"UPDATE notes SET pinned = %s, updated_at = now() WHERE id = %s RETURNING id, pinned\",\n        (True, note_id),\n    ).fetchone())\n    print(\"deleted\", conn.execute(\n        \"DELETE FROM notes WHERE id = %s RETURNING id\", (note_id,)\n    ).fetchone())\n    print(\"missing\", conn.execute(\n        \"SELECT id FROM notes WHERE id = %s\", (note_id,)\n    ).fetchone())",
+        "lang": "python",
+        "filename": "db_crud.py"
+      },
+      {
+        "title": "提交一段看起来像 SQL 的标题",
+        "body": "单独保存为 db_input.py 并运行。预期只插入一条普通笔记,输入里的 DROP TABLE 不会执行。finally 外层连接上下文会正常提交这个插入和删除的组合;最后 notes 表仍能查询。",
+        "code": "import psycopg\n\npayload = \"db03-x'); DROP TABLE notes; --\"\nwith psycopg.connect(\"\") as conn:\n    row = conn.execute(\n        \"INSERT INTO notes(title) VALUES (%s) RETURNING id, title\", (payload,)\n    ).fetchone()\n    assert row[1] == payload\n    print(row)\n    conn.execute(\"DELETE FROM notes WHERE id = %s\", (row[0],))\n    print(\"table exists\", conn.execute(\"SELECT count(*) FROM notes\").fetchone())",
+        "lang": "python",
+        "filename": "db_input.py"
+      },
+      {
+        "title": "练习有边界的列表查询",
+        "body": "回到 psql 执行,记录结果列与顺序。修改 LIMIT 观察数量变化。对照 Python 中的写法:LIMIT 也可用 %s 传值,但应用应限制 1 到 100,不能让客户端请求任意数量。",
+        "code": "SELECT id, title, pinned\nFROM notes\nWHERE pinned = false\nORDER BY id DESC\nLIMIT 10;\nUPDATE notes SET pinned = true WHERE id = -1 RETURNING id;\n-- 0 rows / UPDATE 0,不是 SQL 异常",
+        "lang": "sql"
+      }
+    ],
+    "checks": [
+      "CRUD 输出依次出现同一编号,最后 missing 为 None。",
+      "含单引号和 SQL 片段的标题保持原样,notes 表仍存在。",
+      "不存在编号的 UPDATE 返回零行;列表最多 10 行且编号递减。"
+    ],
+    "pitfalls": [
+      {
+        "symptom": "syntax error 或用户输入改变了 SQL 含义",
+        "cause": "把输入放进 f-string 或字符串拼接。",
+        "fix": "恢复固定 SQL 和独立参数元组;不要手写通用转义函数。"
+      },
+      {
+        "symptom": "脚本输出成功但另一个连接查不到新增行",
+        "cause": "事务尚未提交,或者最后主动删除了练习行。",
+        "fix": "确认连接上下文已经退出;若只验证插入,暂时移除删除步骤后在独立连接核查。"
+      }
+    ],
+    "exercise": {
+      "task": "写一个只更新标题的函数,返回更新后的 id/title;不存在时返回 None。",
+      "hint": "WHERE id = %s,并在同一 UPDATE 中 RETURNING id, title。",
+      "answer": "def rename_note(conn, note_id, title):\n    return conn.execute(\"UPDATE notes SET title=%s, updated_at=now() WHERE id=%s RETURNING id,title\", (title,note_id)).fetchone()\n调用方决定事务提交边界;函数不私自创建连接或提交。"
+    },
+    "refs": [
+      {
+        "label": "Psycopg 3:参数传递",
+        "url": "https://www.psycopg.org/psycopg3/docs/basic/params.html"
+      },
+      {
+        "label": "Psycopg 3:基本使用",
+        "url": "https://www.psycopg.org/psycopg3/docs/basic/usage.html"
+      },
+      {
+        "label": "PostgreSQL 16:RETURNING",
+        "url": "https://www.postgresql.org/docs/16/dml-returning.html"
+      }
+    ],
+    "production": {
+      "scenario": "笔记标题来自浏览器,数据库不能把任何一段用户输入当成可执行语法。",
+      "decisions": [
+        "参数化所有动态值。",
+        "列表显式限制数量和排序。",
+        "以 RETURNING 判断变更结果。"
+      ],
+      "failureDrill": "在 notes_lab 用包含单引号与 SQL 片段的标题创建笔记,核验原值与表完整性。",
+      "acceptance": [
+        "没有 SQL 字符串插值。",
+        "空结果有明确处理。",
+        "连接与事务在脚本结束后释放。"
+      ]
+    }
+  },
+  {
+    "id": "04",
+    "title": "用关联表、JOIN 与聚合表达关系",
+    "group": "SQL 与关系建模",
+    "summary": "为笔记添加标签,使用外键保证关联有效,再统计每条笔记的标签数量。",
+    "objectives": [
+      "建立多对多关系和联合主键",
+      "区别 INNER JOIN 与 LEFT JOIN",
+      "避免 JOIN 重复行导致聚合错误"
+    ],
+    "concepts": [
+      {
+        "title": "多对多关系单独建表",
+        "body": "一条笔记可有多个标签,一个标签可属于多条笔记。note_tags 的每一行代表一组关联,联合主键阻止重复关联。将标签塞进逗号字符串会让检索、唯一性与改名都变得困难。"
+      },
+      {
+        "title": "外键检查关联完整性",
+        "body": "note_id 必须对应真实笔记,tag_id 必须对应真实标签。此例删除笔记时级联清理关联行,删除仍被使用的标签则拒绝;删除策略来自业务,而不是所有外键统一加 CASCADE。"
+      },
+      {
+        "title": "聚合前先辨认行数",
+        "body": "LEFT JOIN 会保留没有标签的笔记,并补一个右侧为空的连接结果。COUNT(*) 会把这一行算进去,COUNT(nt.tag_id) 只统计非空标签。聚合查询返回的是分组后的结果。"
+      }
+    ],
+    "steps": [
+      {
+        "title": "建立标签和关联表",
+        "body": "在 notes_lab 首次执行一次;保存为 sql/002_tags.sql。引用 notes 主键,不复制标题。外键列并不会自动获得适合所有反向查询的索引,后续根据查询创建。",
+        "code": "CREATE TABLE tags (\n  id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,\n  name varchar(40) NOT NULL UNIQUE CHECK (char_length(btrim(name)) > 0)\n);\nCREATE TABLE note_tags (\n  note_id bigint NOT NULL REFERENCES notes(id) ON DELETE CASCADE,\n  tag_id bigint NOT NULL REFERENCES tags(id) ON DELETE RESTRICT,\n  PRIMARY KEY (note_id, tag_id)\n);",
+        "lang": "sql",
+        "filename": "sql/002_tags.sql"
+      },
+      {
+        "title": "准备两条笔记和两个标签",
+        "body": "以下是 psql 脚本;\\gset 将 RETURNING 值写进本会话变量。创建笔记返回语句末尾不加分号,由 \\gset 执行。标签可以重复准备,笔记每次执行都会产生两条独立样本。",
+        "code": "INSERT INTO notes(title) VALUES ('db04-with-tags') RETURNING id AS tagged_id \\gset\nINSERT INTO notes(title) VALUES ('db04-no-tags') RETURNING id AS empty_id \\gset\nINSERT INTO tags(name) VALUES ('db04-python'), ('db04-api') ON CONFLICT (name) DO NOTHING;\nINSERT INTO note_tags(note_id,tag_id)\nSELECT :tagged_id, id FROM tags WHERE name IN ('db04-python','db04-api');",
+        "lang": "sql"
+      },
+      {
+        "title": "分别查看关联明细和数量",
+        "body": "第一条查询得到 2 行标签明细。第二条查询保留无标签笔记,数量分别为 2 与 0。注意按 id 筛选的是上一步新建的两行,不受此前重复运行影响。",
+        "code": "SELECT n.id,n.title,t.name\nFROM notes n JOIN note_tags nt ON nt.note_id=n.id\nJOIN tags t ON t.id=nt.tag_id\nWHERE n.id=:tagged_id ORDER BY t.name;\n\nSELECT n.id,n.title,count(nt.tag_id) AS tag_count\nFROM notes n LEFT JOIN note_tags nt ON nt.note_id=n.id\nWHERE n.id IN (:tagged_id,:empty_id)\nGROUP BY n.id,n.title ORDER BY n.id;",
+        "lang": "sql"
+      },
+      {
+        "title": "验证重复关联和删除策略",
+        "body": "先单独执行重复 INSERT,预期 23505 唯一性错误;再单独执行 DELETE tags,预期 23503 外键错误。最后删除本课带标签笔记,对应关联行自动清理。只删除本会话创建的样本。",
+        "code": "INSERT INTO note_tags(note_id,tag_id)\nSELECT :tagged_id,id FROM tags WHERE name='db04-python';\n-- 23505:重复关联\nDELETE FROM tags WHERE name='db04-python';\n-- 23503:仍被笔记引用\nDELETE FROM notes WHERE id=:tagged_id;\nSELECT count(*) FROM note_tags WHERE note_id=:tagged_id;\n-- 0",
+        "lang": "sql"
+      }
+    ],
+    "checks": [
+      "关联明细返回两个标签,聚合结果分别为 2 与 0。",
+      "重复添加同一关联失败,不存在编号的外键写入也会失败。",
+      "删除练习笔记后仅其关联行消失,标签本身仍存在。"
+    ],
+    "pitfalls": [
+      {
+        "symptom": "没有标签的笔记消失了",
+        "cause": "使用 INNER JOIN,或在 WHERE 中过滤右表导致 LEFT JOIN 效果被破坏。",
+        "fix": "需要保留左表时使用 LEFT JOIN;右表匹配条件按语义放到 ON,并检查空值结果。"
+      },
+      {
+        "symptom": "无标签笔记显示数量 1",
+        "cause": "COUNT(*) 统计了 LEFT JOIN 生成的空关联行。",
+        "fix": "改用 COUNT(nt.tag_id),并检查连接多个一对多表时是否又放大行数。"
+      }
+    ],
+    "exercise": {
+      "task": "查询拥有 db04-python 标签的笔记,要求每条笔记最多出现一次。",
+      "hint": "可使用 EXISTS 子查询表达是否存在,不需要把标签列放进输出。",
+      "answer": "SELECT n.id,n.title FROM notes n WHERE EXISTS (SELECT 1 FROM note_tags nt JOIN tags t ON t.id=nt.tag_id WHERE nt.note_id=n.id AND t.name='db04-python') ORDER BY n.id; 第 04 步已删除本课关联时结果为空;重新执行第 02 步后应出现新笔记。"
+    },
+    "refs": [
+      {
+        "label": "PostgreSQL 16:表表达式与 JOIN",
+        "url": "https://www.postgresql.org/docs/16/queries-table-expressions.html"
+      },
+      {
+        "label": "PostgreSQL 16:聚合函数",
+        "url": "https://www.postgresql.org/docs/16/functions-aggregate.html"
+      },
+      {
+        "label": "PostgreSQL 16:外键",
+        "url": "https://www.postgresql.org/docs/16/ddl-constraints.html"
+      }
+    ],
+    "production": {
+      "scenario": "生产列表页需要展示标签数,同时允许笔记暂时没有标签。",
+      "decisions": [
+        "通过关联表表达多对多。",
+        "用唯一约束阻止重复关系。",
+        "明确父记录删除行为并验证空关系。"
+      ],
+      "failureDrill": "在 notes_lab 重复插入关联、删除被引用标签,记录数据库拒绝原因。",
+      "acceptance": [
+        "零标签结果正确。",
+        "重复关系无法落库。",
+        "统计没有因连接放大而重复计算。"
+      ]
+    }
+  },
+  {
+    "id": "05",
+    "title": "把多次写入放进同一个事务",
+    "group": "事务与并发",
+    "summary": "一次保存笔记并关联标签,主动制造第二步失败,确认第一步也回滚。",
+    "objectives": [
+      "划定需要同时成功的写入边界",
+      "识别失败事务状态并执行回滚",
+      "使用保存点隔离可选步骤"
+    ],
+    "concepts": [
+      {
+        "title": "事务覆盖一个业务动作",
+        "body": "创建笔记和保存必需标签属于同一次提交。只有两步都成功才向调用者返回成功。数据库事务不能自动回滚已经发送的邮件或外部 HTTP 请求,跨系统一致性在 API + 数据库和异步任务模块继续处理。"
+      },
+      {
+        "title": "错误后的事务需要结束",
+        "body": "BEGIN 后发生约束错误,事务进入失败状态。继续 SELECT 通常得到 25P02;应 ROLLBACK 整个事务,或者在预先建立的 SAVEPOINT 上回滚到保存点。"
+      },
+      {
+        "title": "事务越短越容易控制",
+        "body": "事务持有连接与可能的锁。不要在 BEGIN 与 COMMIT 之间等用户输入、调用模型或上传文件。identity 消耗的编号即使回滚也可能不复用,所以不能用编号是否连续判断事务正确性。"
+      }
+    ],
+    "steps": [
+      {
+        "title": "用成功路径保存笔记和标签",
+        "body": "先完成第 04 课表结构。此脚本在 psql 中执行;标签使用已有 db04-api,若无该标签先执行 INSERT INTO tags(name) VALUES ('db04-api') ON CONFLICT DO NOTHING。事务末尾应 COMMIT。",
+        "code": "BEGIN;\nINSERT INTO notes(title) VALUES ('db05-atomic-ok') RETURNING id AS ok_id \\gset\nINSERT INTO note_tags(note_id,tag_id)\nSELECT :ok_id,id FROM tags WHERE name='db04-api';\nCOMMIT;\nSELECT n.id,n.title,t.name FROM notes n\nJOIN note_tags nt ON nt.note_id=n.id JOIN tags t ON t.id=nt.tag_id\nWHERE n.id=:ok_id;",
+        "lang": "sql"
+      },
+      {
+        "title": "制造第二步失败",
+        "body": "本例用不存在的负标签编号触发外键错误。逐条执行,观察提示符从 notes_lab=* 改为失败事务状态。不要把预期错误脚本用 ON_ERROR_STOP 直接执行后遗漏回滚。",
+        "code": "BEGIN;\nINSERT INTO notes(title) VALUES ('db05-must-rollback') RETURNING id AS failed_id \\gset\nINSERT INTO note_tags(note_id,tag_id) VALUES (:failed_id,-999);\n-- 23503:外键错误\nSELECT 1;\n-- 25P02:当前事务已失败\nROLLBACK;\nSELECT count(*) FROM notes WHERE id=:failed_id;\n-- 0",
+        "lang": "sql"
+      },
+      {
+        "title": "用保存点处理可选标签",
+        "body": "只有当业务允许“笔记成功、可选标签失败”时才采用本例。保存点前的笔记保留,保存点之后失败的关联被回滚。若标签是业务必填,则应沿用上一步整笔回滚。",
+        "code": "BEGIN;\nINSERT INTO notes(title) VALUES ('db05-optional-tag') RETURNING id AS optional_id \\gset\nSAVEPOINT optional_tag;\nINSERT INTO note_tags(note_id,tag_id) VALUES (:optional_id,-999);\nROLLBACK TO SAVEPOINT optional_tag;\nCOMMIT;\nSELECT id,title FROM notes WHERE id=:optional_id;\n-- 1 row",
+        "lang": "sql"
+      },
+      {
+        "title": "在独立会话核对提交边界",
+        "body": "打开第二个 psql,执行相同 SELECT。成功笔记和可选标签笔记可见,must-rollback 不可见。再在会话 A BEGIN 后插入一条新笔记,暂不提交;会话 B 查询该标题为零行,A COMMIT 后 B 再查才能看到。最后结束两个会话中所有未结束事务。",
+        "code": "SELECT title,count(*) FROM notes\nWHERE title LIKE 'db05-%' GROUP BY title ORDER BY title;\n-- 包含 db05-atomic-ok、db05-optional-tag\n-- 不包含 db05-must-rollback",
+        "lang": "sql"
+      }
+    ],
+    "checks": [
+      "第二步外键失败后,回滚事务中的笔记不存在。",
+      "失败状态执行 SELECT 返回 25P02,ROLLBACK 后恢复。",
+      "另一会话只能读取已提交数据;保存点例子符合明确的可选业务规则。"
+    ],
+    "pitfalls": [
+      {
+        "symptom": "笔记创建了但标签没保存",
+        "cause": "每条 SQL 独立提交,缺少完整事务边界。",
+        "fix": "将必需写入放入同一事务,仅在成功提交后返回业务成功。"
+      },
+      {
+        "symptom": "重试 SQL 一直提示 current transaction is aborted",
+        "cause": "在失败事务中继续执行,没有先回滚。",
+        "fix": "退出事务上下文或显式 ROLLBACK,再决定是否重试整个业务动作。"
+      }
+    ],
+    "exercise": {
+      "task": "把可选标签例子改成标签必填,说明应该删除哪些语句。",
+      "hint": "必填意味着不能在失败后只回滚局部。",
+      "answer": "移除 SAVEPOINT 和 ROLLBACK TO SAVEPOINT。标签插入异常时执行整个事务 ROLLBACK,笔记和关联都不保留;应用把错误转成合适的响应,而不是返回创建成功。"
+    },
+    "refs": [
+      {
+        "label": "PostgreSQL 16:事务入门",
+        "url": "https://www.postgresql.org/docs/16/tutorial-transactions.html"
+      },
+      {
+        "label": "PostgreSQL 16:SAVEPOINT",
+        "url": "https://www.postgresql.org/docs/16/sql-savepoint.html"
+      },
+      {
+        "label": "Psycopg 3:事务管理",
+        "url": "https://www.psycopg.org/psycopg3/docs/basic/transactions.html"
+      }
+    ],
+    "production": {
+      "scenario": "保存按钮触发多张表写入,任何必需步骤失败都不能产生半成品记录。",
+      "decisions": [
+        "事务以业务动作划分。",
+        "可选步骤才允许保存点局部恢复。",
+        "外部调用在数据库事务之外安排。"
+      ],
+      "failureDrill": "在 notes_lab 用无效外键中断保存,重新连接检查是否留下半条业务记录。",
+      "acceptance": [
+        "必需写入要么全有要么全无。",
+        "失败后连接能够继续服务。",
+        "日志能定位原始约束错误而不是只有后续 25P02。"
+      ]
+    }
+  },
+  {
+    "id": "06",
+    "title": "用两个会话观察隔离级别与 MVCC",
+    "group": "事务与并发",
+    "summary": "按固定顺序执行读写,比较 Read Committed 和 Repeatable Read 的可见结果。",
+    "objectives": [
+      "用两个 psql 会话复现读取差异",
+      "解释语句快照与事务快照",
+      "识别需要重试的序列化失败"
+    ],
+    "concepts": [
+      {
+        "title": "快照决定读者能看见什么",
+        "body": "Read Committed 下,每条查询读取该语句开始时已经提交的数据。Repeatable Read 通常固定在事务第一条实际查询建立的快照。下面的实验用同一行、同一个读取动作对照,避免只背隔离级别名字。"
+      },
+      {
+        "title": "普通读与写锁不是同一机制",
+        "body": "MVCC 使普通 SELECT 可以读取已提交版本,不必因为另一会话正在更新同一行而等待其提交。SELECT FOR UPDATE 是带锁读取,行为不同。长事务会保留旧快照,增加旧版本清理压力。"
+      },
+      {
+        "title": "更强隔离伴随重试责任",
+        "body": "Repeatable Read 或 Serializable 在并发冲突下可能返回 SQLSTATE 40001。重试应从事务开始重新读取和计算,设置次数上限与退避;不能在已经失败的事务里重发最后一条 SQL。"
+      }
+    ],
+    "steps": [
+      {
+        "title": "准备专用行并打开会话 A 与 B",
+        "body": "在任意会话执行一次第一组语句,记录返回的编号。A、B 均使用 app_user / notes_lab。每个会话执行 \\set iso_id 实际编号,示例中的 123 必须替换。每轮实验开始前确保双方已 COMMIT 或 ROLLBACK。",
+        "code": "INSERT INTO notes(title,content) VALUES ('db06-isolation','v1') RETURNING id;\n-- 假设返回 123,在 A 和 B 各自设置:\n\\set iso_id 123\nSHOW default_transaction_isolation;\n-- read committed",
+        "lang": "sql"
+      },
+      {
+        "title": "执行 Read Committed 时序",
+        "body": "按注释切换窗口,不要一次性粘贴到同一会话。A 第一次读 v1;B 更新但未提交时 A 仍读 v1;B 提交后 A 再读变为 v2。A 的事务没有重开,但下一条查询已经取得新快照。",
+        "code": "-- A:先执行\nBEGIN ISOLATION LEVEL READ COMMITTED;\nSELECT content FROM notes WHERE id=:iso_id;\n-- B:执行,先不提交\nBEGIN;\nUPDATE notes SET content='v2',updated_at=now() WHERE id=:iso_id;\n-- A:此时仍为 v1\nSELECT content FROM notes WHERE id=:iso_id;\n-- B:现在提交\nCOMMIT;\n-- A:现在为 v2,然后结束\nSELECT content FROM notes WHERE id=:iso_id;\nCOMMIT;",
+        "lang": "sql"
+      },
+      {
+        "title": "执行 Repeatable Read 时序",
+        "body": "延续上一步的 v2。A 建立快照后,B 写入 v3 并提交;A 在原事务内仍读 v2。A 提交并开始新查询后读到 v3。记录四次读取结果与相应窗口。",
+        "code": "-- A\nBEGIN ISOLATION LEVEL REPEATABLE READ;\nSELECT content FROM notes WHERE id=:iso_id;\n-- B:自动提交状态\nUPDATE notes SET content='v3',updated_at=now() WHERE id=:iso_id;\n-- A:原快照仍为 v2\nSELECT content FROM notes WHERE id=:iso_id;\nCOMMIT;\nSELECT content FROM notes WHERE id=:iso_id;\n-- v3",
+        "lang": "sql"
+      },
+      {
+        "title": "复现并发更新失败并恢复",
+        "body": "A 用 Repeatable Read 读取 v3,B 改成 v4 并提交,A 再更新同一行会遇到 40001。最后 A ROLLBACK 并重新读取,确认新事务看到 v4。这里只演示失败机制,不对包含外部副作用的动作进行盲目重试。",
+        "code": "-- A\nBEGIN ISOLATION LEVEL REPEATABLE READ;\nSELECT content FROM notes WHERE id=:iso_id;\n-- B\nUPDATE notes SET content='v4',updated_at=now() WHERE id=:iso_id;\n-- A\nUPDATE notes SET content='v5' WHERE id=:iso_id;\n-- ERROR: could not serialize access due to concurrent update\nROLLBACK;\nSELECT content FROM notes WHERE id=:iso_id;\n-- v4",
+        "lang": "sql"
+      }
+    ],
+    "checks": [
+      "Read Committed 在 B 提交前后读到 v1、v2。",
+      "Repeatable Read 在事务中保持 v2,事务结束后读到 v3。",
+      "并发更新实验出现 40001,ROLLBACK 后读取恢复。"
+    ],
+    "pitfalls": [
+      {
+        "symptom": "两种隔离级别看起来没有区别",
+        "cause": "A 没有显式 BEGIN,或者 B 在 A 建立快照之前已提交。",
+        "fix": "严格按窗口与注释顺序执行,记录每次 BEGIN、SELECT 和 COMMIT 的时点。"
+      },
+      {
+        "symptom": "实验结束后连接一直 idle in transaction",
+        "cause": "只观察结果没有结束事务。",
+        "fix": "两个窗口都执行 ROLLBACK;生产应用用上下文管理事务并限制事务内工作量。"
+      }
+    ],
+    "exercise": {
+      "task": "说明报表需要同一时点多次读取,而普通单条查询通常不用提高到 Repeatable Read 的原因。",
+      "hint": "先确定需要保证的是一条 SQL,还是多条 SQL 共享快照。",
+      "answer": "单条 SELECT 本身在一个语句快照内执行。需要多条 SELECT 共享一致快照时,可选择短的只读 Repeatable Read 事务;若能用一条 SQL 表达汇总,通常可减少事务持续时间。提高隔离级别不能代替业务约束与正确的重试实现。"
+    },
+    "refs": [
+      {
+        "label": "PostgreSQL 16:事务隔离",
+        "url": "https://www.postgresql.org/docs/16/transaction-iso.html"
+      },
+      {
+        "label": "PostgreSQL 16:MVCC 简介",
+        "url": "https://www.postgresql.org/docs/16/mvcc-intro.html"
+      },
+      {
+        "label": "PostgreSQL 16:序列化失败处理",
+        "url": "https://www.postgresql.org/docs/16/mvcc-serialization-failure-handling.html"
+      }
+    ],
+    "production": {
+      "scenario": "后台导出需要一致读取,编辑接口同时持续更新笔记。",
+      "decisions": [
+        "根据读取范围选择隔离级别。",
+        "限制长事务,避免把导出网络传输放在读事务内。",
+        "40001 重试覆盖完整事务。"
+      ],
+      "failureDrill": "在 notes_lab 双会话执行快照与并发更新实验,保留实际输出顺序。",
+      "acceptance": [
+        "每个结果可对应到一次提交。",
+        "实验后无遗留事务。",
+        "能区分数据可见性与行锁等待。"
+      ]
+    }
+  },
+  {
+    "id": "07",
+    "title": "定位锁等待、复现死锁并加入版本检查",
+    "group": "事务与并发",
+    "summary": "观察同一行写入的等待关系,再分别用固定加锁顺序和乐观锁解决不同冲突。",
+    "objectives": [
+      "用 pg_blocking_pids 找到锁等待源头",
+      "复现并处理死锁回滚",
+      "用 version 条件阻止覆盖其他人的编辑"
+    ],
+    "concepts": [
+      {
+        "title": "行锁持续到事务结束",
+        "body": "UPDATE 和 SELECT FOR UPDATE 可阻止另一个事务更新同一行。等待不一定是坏事,但必须有时限与可观测性;持锁时调用外部服务会让数据库承担外部延迟。"
+      },
+      {
+        "title": "死锁来自循环等待",
+        "body": "A 持有行一等待行二,B 持有行二等待行一,形成闭环。数据库会中止其中一个事务,不能预言一定中止谁。固定顺序获取多行锁可减少死锁,应用仍需识别 40P01 并有限重试整个事务。"
+      },
+      {
+        "title": "版本检查表达编辑冲突",
+        "body": "客户端读取 version=1,更新时要求 WHERE version=1 并同时 version=version+1。若别人已提交,更新返回零行,调用者应重新读取并解决冲突。版本检查防止旧内容覆盖新内容,不用于替代所有数据库锁。"
+      }
+    ],
+    "steps": [
+      {
+        "title": "建立两个实验行并添加版本列",
+        "body": "完成前面表结构后执行一次 ADD COLUMN;若已存在 version 则只运行后两条。把返回编号记为 first_id 和 second_id,在 A、B 两个窗口各设置相同变量。",
+        "code": "ALTER TABLE notes ADD COLUMN version integer NOT NULL DEFAULT 1 CHECK (version > 0);\nINSERT INTO notes(title) VALUES ('db07-first') RETURNING id;\nINSERT INTO notes(title) VALUES ('db07-second') RETURNING id;\n-- 在两个窗口替换为实际返回值\n\\set first_id 124\n\\set second_id 125",
+        "lang": "sql"
+      },
+      {
+        "title": "观察一个有上限的锁等待",
+        "body": "先 A 加锁,再 B 执行 UPDATE。B 等待期间切回 A 查询阻塞信息;如果 5 秒不够观察,可重做一次。B 约 5 秒后收到 lock timeout,之后必须 ROLLBACK;A 也 ROLLBACK 释放锁。",
+        "code": "-- A\nBEGIN;\nSELECT id FROM notes WHERE id=:first_id FOR UPDATE;\n-- B\nBEGIN;\nSET LOCAL lock_timeout='5s';\nUPDATE notes SET content='waiting' WHERE id=:first_id;\n-- A:在 B 等待期间执行\nSELECT pid,wait_event_type,wait_event,pg_blocking_pids(pid) AS blockers\nFROM pg_stat_activity WHERE datname='notes_lab';\n-- B 超时后,以及 A 观察完后,各自执行\nROLLBACK;",
+        "lang": "sql"
+      },
+      {
+        "title": "按顺序制造死锁",
+        "body": "先确认双方没有遗留事务。先 A 锁第一行,再 B 锁第二行,然后 A 请求第二行进入等待,最后 B 请求第一行。一个窗口返回 deadlock detected,另一个窗口可继续获得锁。无论哪个失败,两个窗口最后都执行 ROLLBACK。",
+        "code": "-- A\nBEGIN;\nSELECT id FROM notes WHERE id=:first_id FOR UPDATE;\n-- B\nBEGIN;\nSELECT id FROM notes WHERE id=:second_id FOR UPDATE;\n-- A:此处会等待\nSELECT id FROM notes WHERE id=:second_id FOR UPDATE;\n-- B:触发循环等待\nSELECT id FROM notes WHERE id=:first_id FOR UPDATE;\n-- SQLSTATE 40P01 出现在其中一个会话\n-- A 和 B 各执行 ROLLBACK;",
+        "lang": "sql"
+      },
+      {
+        "title": "改为相同顺序锁定多行",
+        "body": "在 A 执行下面代码并保持事务,B 执行同样代码会等待 A。A COMMIT 后 B 能继续,然后 B COMMIT。双方都按 id 升序取得目标锁,避免本例的相反顺序。",
+        "code": "BEGIN;\nSELECT id FROM notes WHERE id IN (:first_id,:second_id) ORDER BY id FOR UPDATE;\n-- A 先 COMMIT,B 等待结束后再 COMMIT\nCOMMIT;",
+        "lang": "sql"
+      },
+      {
+        "title": "用旧版本号重放编辑",
+        "body": "在同一个窗口按顺序执行即可模拟两个客户端都读到版本 1。先把专用行重置到 version=1;第一次更新得到 2,第二次带旧版本条件返回 0 行。只有实际更新成功才向客户端返回成功。",
+        "code": "UPDATE notes SET version=1,content='base' WHERE id=:first_id;\nUPDATE notes SET content='editor A',version=version+1,updated_at=now()\nWHERE id=:first_id AND version=1 RETURNING id,content,version;\n-- version=2\nUPDATE notes SET content='editor B',version=version+1,updated_at=now()\nWHERE id=:first_id AND version=1 RETURNING id,content,version;\n-- 0 rows\nSELECT content,version FROM notes WHERE id=:first_id;\n-- editor A, 2",
+        "lang": "sql"
+      }
+    ],
+    "checks": [
+      "锁等待能查到非空 blockers,超时后事务可回滚。",
+      "死锁仅保证一个参与事务被中止,两个窗口最后均无活动事务。",
+      "旧版本更新返回零行,editor A 的内容没有被覆盖。"
+    ],
+    "pitfalls": [
+      {
+        "symptom": "设置 statement_timeout 后还是不清楚在等什么",
+        "cause": "语句超时能中断执行,但没有分析锁等待来源。",
+        "fix": "在等待期间查看 wait_event 与 pg_blocking_pids,结合持锁会话事务时长定位。"
+      },
+      {
+        "symptom": "版本检查存在但更新仍覆盖内容",
+        "cause": "SELECT 后在 Python 检查版本,UPDATE 没带 version 条件。",
+        "fix": "把版本比较和递增放在同一条 UPDATE 中,依据 RETURNING 判定成功。"
+      }
+    ],
+    "exercise": {
+      "task": "将批量修改改为按 id 升序锁定,并说明收到 40P01 后重试范围。",
+      "hint": "不要只重试死锁报错的最后一条 SELECT。",
+      "answer": "先确定全部目标编号,在同一事务按升序 SELECT ... FOR UPDATE,再执行修改。收到 40P01 后回滚并重新读取整个业务动作的输入,有限退避重试;涉及外部副作用时先使用幂等机制。"
+    },
+    "refs": [
+      {
+        "label": "PostgreSQL 16:显式锁与死锁",
+        "url": "https://www.postgresql.org/docs/16/explicit-locking.html"
+      },
+      {
+        "label": "PostgreSQL 16:统计活动",
+        "url": "https://www.postgresql.org/docs/16/monitoring-stats.html"
+      },
+      {
+        "label": "PostgreSQL 16:错误码",
+        "url": "https://www.postgresql.org/docs/16/errcodes-appendix.html"
+      }
+    ],
+    "production": {
+      "scenario": "两名用户同时编辑笔记,后台任务又可能批量修改相同记录。",
+      "decisions": [
+        "短事务与锁超时控制等待。",
+        "多行写入约定一致的锁顺序。",
+        "普通编辑用版本检查向用户呈现冲突。"
+      ],
+      "failureDrill": "仅在 notes_lab 双会话制造行锁与死锁,记录等待链、SQLSTATE 和回滚结果。",
+      "acceptance": [
+        "没有无限等待。",
+        "所有写入入口维护 version。",
+        "冲突时保留已提交内容并报告更新失败。"
+      ]
+    }
+  },
+  {
+    "id": "08",
+    "title": "围绕查询设计单列、组合与部分索引",
+    "group": "查询性能",
+    "summary": "准备足够的数据,对照查询条件与排序建立索引,再核算索引带来的写入成本。",
+    "objectives": [
+      "从 WHERE 与 ORDER BY 推导组合索引",
+      "用部分索引服务低占比条件",
+      "知道主键、唯一约束与外键索引的区别"
+    ],
+    "concepts": [
+      {
+        "title": "索引缩小查找范围",
+        "body": "B-tree 索引按键组织数据,使等值、范围与有序读取可以少访问一些数据页。索引占用磁盘,写入和更新时也要维护。是否使用索引由优化器估计成本决定,小表顺序扫描常常更合适。"
+      },
+      {
+        "title": "组合键的顺序来自查询",
+        "body": "WHERE pinned=true ORDER BY created_at DESC,id DESC LIMIT 20 可用 pinned、created_at、id 的组合索引。前面的等值条件固定范围,后面提供稳定排序。只对 pinned 这个低基数字段建索引不一定有收益。"
+      },
+      {
+        "title": "部分索引只保存需要的行",
+        "body": "如果置顶笔记仅占少数,可只索引 WHERE pinned=true 的记录。查询条件需能匹配该谓词;不要假设参数化的任意布尔条件总能使用该索引。主键和唯一约束会创建索引,外键引用列不自动创建索引。"
+      }
+    ],
+    "steps": [
+      {
+        "title": "在独立练习库生成性能样本",
+        "body": "仅 notes_lab 执行,写入 50,000 条带 db08-bench- 前缀的笔记。此脚本只运行一次,重复执行会额外产生同量数据。固定时间与稀疏 pinned 分布便于对照,但不能代表真实业务分布。",
+        "code": "INSERT INTO notes(title,content,pinned,created_at,updated_at)\nSELECT 'db08-bench-' || g, repeat('x',200), g % 100 = 0,\n       timestamptz '2026-01-01 00:00:00+00' + g * interval '1 second',\n       timestamptz '2026-01-01 00:00:00+00' + g * interval '1 second'\nFROM generate_series(1,50000) AS g;\nANALYZE notes;\nSELECT count(*),count(*) FILTER(WHERE pinned) FROM notes;",
+        "lang": "sql"
+      },
+      {
+        "title": "保存无专用索引时的计划",
+        "body": "先只用 EXPLAIN 观察估计计划,下一课运行带 ANALYZE 的实际计划。记录扫描方式、Sort 是否存在及估计行数,不把某个固定 cost 数值作为正确答案。",
+        "code": "EXPLAIN\nSELECT id,title,created_at FROM notes\nWHERE pinned=true ORDER BY created_at DESC,id DESC LIMIT 20;\nSELECT indexname,indexdef FROM pg_indexes WHERE tablename='notes';",
+        "lang": "sql"
+      },
+      {
+        "title": "建立组合索引并对比",
+        "body": "建索引后重新执行相同 EXPLAIN。数据量足够时通常能减少扫描和排序工作,但输出由版本、统计和数据决定。索引包含 id 用于同一时间点下稳定排序。",
+        "code": "CREATE INDEX notes_pinned_created_id_idx ON notes(pinned,created_at DESC,id DESC);\nANALYZE notes;\nEXPLAIN\nSELECT id,title,created_at FROM notes\nWHERE pinned=true ORDER BY created_at DESC,id DESC LIMIT 20;\nSELECT pg_size_pretty(pg_relation_size('notes_pinned_created_id_idx')) AS index_size;",
+        "lang": "sql"
+      },
+      {
+        "title": "将置顶查询改为部分索引实验",
+        "body": "为能明确对照,只在 notes_lab 删除刚创建的实验索引,改建部分索引,再执行同样查询。生产建索引可评估 CREATE INDEX CONCURRENTLY 以减少阻塞,但它不能放在事务块中,失败后还需检查无效索引。",
+        "code": "DROP INDEX notes_pinned_created_id_idx;\nCREATE INDEX notes_pinned_recent_idx ON notes(created_at DESC,id DESC) WHERE pinned=true;\nEXPLAIN\nSELECT id,title,created_at FROM notes\nWHERE pinned=true ORDER BY created_at DESC,id DESC LIMIT 20;\nSELECT pg_size_pretty(pg_relation_size('notes_pinned_recent_idx')) AS index_size;\nCREATE INDEX note_tags_tag_id_idx ON note_tags(tag_id);",
+        "lang": "sql"
+      }
+    ],
+    "checks": [
+      "记录至少两个版本计划和各自索引大小。",
+      "能说明 pinned=true 查询与部分索引谓词的关系。",
+      "note_tags 的联合主键覆盖 note_id 开头查找,反向 tag_id 查询有单独索引。"
+    ],
+    "pitfalls": [
+      {
+        "symptom": "建了索引仍然 Seq Scan",
+        "cause": "表很小、命中行过多、统计过旧或查询不匹配索引。",
+        "fix": "先 ANALYZE 并检查实际 SQL、类型与选择率;不要通过关闭顺序扫描作为生产修复。"
+      },
+      {
+        "symptom": "索引越加越多,写入越来越慢",
+        "cause": "每个索引都增加存储与维护,部分索引可能重复覆盖同一查询。",
+        "fix": "保留使用证据与目标查询,对重复索引评估后再清理,结合写入延迟一起验收。"
+      }
+    ],
+    "exercise": {
+      "task": "为 WHERE tag_id=? 查询关联的 note_id 设计索引,并判断现有主键是否足够。",
+      "hint": "联合主键是 (note_id,tag_id),注意最左列。",
+      "answer": "现有主键擅长 note_id 或 note_id+tag_id 条件,不应默认满足按 tag_id 反查的性能需求。可使用 CREATE INDEX ON note_tags(tag_id,note_id),并在实际数据上对比;若采用此组合索引,应评估是否还需要单列 tag_id 索引。"
+    },
+    "refs": [
+      {
+        "label": "PostgreSQL 16:多列索引",
+        "url": "https://www.postgresql.org/docs/16/indexes-multicolumn.html"
+      },
+      {
+        "label": "PostgreSQL 16:部分索引",
+        "url": "https://www.postgresql.org/docs/16/indexes-partial.html"
+      },
+      {
+        "label": "PostgreSQL 16:CREATE INDEX",
+        "url": "https://www.postgresql.org/docs/16/sql-createindex.html"
+      }
+    ],
+    "production": {
+      "scenario": "首页需要快速显示置顶笔记,写入仍持续发生。",
+      "decisions": [
+        "索引对应一条明确查询。",
+        "用接近真实分布的数据评估读写成本。",
+        "生产在线建索引单独规划事务与失败恢复。"
+      ],
+      "failureDrill": "在 notes_lab 比较无索引、组合索引与部分索引的计划与大小。",
+      "acceptance": [
+        "查询顺序确定。",
+        "有性能证据支持索引选择。",
+        "没有把练习库 DROP INDEX 直接当成生产变更方案。"
+      ]
+    }
+  },
+  {
+    "id": "09",
+    "title": "读取 EXPLAIN ANALYZE 并改造深分页",
+    "group": "查询性能",
+    "summary": "用执行计划验证实际工作量,再把大 OFFSET 改成稳定的游标条件。",
+    "objectives": [
+      "读懂实际行数、loops 与 buffers",
+      "比较浅分页和深分页的扫描量",
+      "使用 created_at 与 id 实现键集分页"
+    ],
+    "concepts": [
+      {
+        "title": "ANALYZE 会真的执行 SQL",
+        "body": "EXPLAIN 只生成计划,EXPLAIN ANALYZE 会执行语句并收集结果。此课仅分析 SELECT;不要直接拿它分析线上 DELETE 或 UPDATE。实际耗时受缓存与负载影响,需要同时看扫描量和缓冲访问。"
+      },
+      {
+        "title": "深 OFFSET 仍需跳过前面的记录",
+        "body": "LIMIT 20 OFFSET 40000 返回 20 行,但执行器通常仍需走过被跳过的记录。键集分页使用上一页最后一行的排序键,直接定位下一段范围。"
+      },
+      {
+        "title": "游标必须与排序完全一致",
+        "body": "按 created_at DESC,id DESC 排序,就用 (created_at,id)<(游标时间,游标编号)。id 处理相同时间的并列。游标不是快照;并发删除、排序字段修改仍可能影响跨页视图,导出要另行定义一致性。"
+      }
+    ],
+    "steps": [
+      {
+        "title": "创建列表索引并收集实际计划",
+        "body": "继续使用第 08 课的 50,000 条样本。首次建索引一次,运行 ANALYZE。记录节点名称、actual rows、loops、Buffers 和 Execution Time。不要要求每台机器出现完全相同的执行时间。",
+        "code": "CREATE INDEX notes_created_id_idx ON notes(created_at DESC,id DESC);\nANALYZE notes;\nEXPLAIN (ANALYZE,BUFFERS)\nSELECT id,title,created_at FROM notes ORDER BY created_at DESC,id DESC LIMIT 20;",
+        "lang": "sql"
+      },
+      {
+        "title": "比较深分页的额外工作",
+        "body": "运行相同排序的深 OFFSET。即使最后只返回 20 行,下层扫描节点通常处理约 40,020 行。若计划不同,按真实节点追踪返回与丢弃的行,不强行匹配示例计划。",
+        "code": "EXPLAIN (ANALYZE,BUFFERS)\nSELECT id,title,created_at FROM notes\nORDER BY created_at DESC,id DESC LIMIT 20 OFFSET 40000;",
+        "lang": "sql"
+      },
+      {
+        "title": "从第一页取得下一页游标",
+        "body": "以下 psql 查询取第一页最后一行的时间和编号,\\gset 保存为 cursor_created、cursor_id。实际 API 应从已返回的第 20 行取值,不再单独 OFFSET 查询。使用 :'cursor_created' 让 psql 正确引用时间文本。",
+        "code": "SELECT created_at AS cursor_created,id AS cursor_id\nFROM notes ORDER BY created_at DESC,id DESC LIMIT 1 OFFSET 19 \\gset\nSELECT id,title,created_at FROM notes\nWHERE (created_at,id) < (:'cursor_created'::timestamptz,:cursor_id)\nORDER BY created_at DESC,id DESC LIMIT 20;\nEXPLAIN (ANALYZE,BUFFERS)\nSELECT id,title,created_at FROM notes\nWHERE (created_at,id) < (:'cursor_created'::timestamptz,:cursor_id)\nORDER BY created_at DESC,id DESC LIMIT 20;",
+        "lang": "sql"
+      },
+      {
+        "title": "在相近深度公平对照",
+        "body": "为了教学比较,先花一次 OFFSET 成本拿到深处游标,再测游标查询本身。实际连续翻页只传前页游标。连续运行两次,说明缓存预热影响,不用单次毫秒值证明普遍结论。",
+        "code": "SELECT created_at AS deep_created,id AS deep_id\nFROM notes ORDER BY created_at DESC,id DESC LIMIT 1 OFFSET 39999 \\gset\nEXPLAIN (ANALYZE,BUFFERS)\nSELECT id,title,created_at FROM notes\nWHERE (created_at,id) < (:'deep_created'::timestamptz,:deep_id)\nORDER BY created_at DESC,id DESC LIMIT 20;",
+        "lang": "sql"
+      }
+    ],
+    "checks": [
+      "记录浅 OFFSET、深 OFFSET 与深游标的实际扫描量。",
+      "下一页与前页没有重复编号,排序键与过滤条件完全一致。",
+      "能区分计划估计值与实际执行值,不把 cost 当成毫秒。"
+    ],
+    "pitfalls": [
+      {
+        "symptom": "分页重复或漏掉相同时间的记录",
+        "cause": "只把 created_at 放进排序或游标,缺少唯一的并列键。",
+        "fix": "排序和比较同时包含 id,并让排序方向与比较符号一致。"
+      },
+      {
+        "symptom": "游标查询变慢且出现 Sort",
+        "cause": "缺少匹配排序的索引,或添加了新筛选条件改变访问方式。",
+        "fix": "对实际查询重新 EXPLAIN ANALYZE,检查复合索引与筛选顺序,而不是只调 LIMIT。"
+      }
+    ],
+    "exercise": {
+      "task": "给置顶列表加入键集分页,并说明使用哪个已有索引。",
+      "hint": "保留 pinned=true,同时添加二元组范围条件。",
+      "answer": "SELECT id,title,created_at FROM notes WHERE pinned=true AND (created_at,id)<($1,$2) ORDER BY created_at DESC,id DESC LIMIT 20; 这是数据库参数形式示意,psycopg 中使用 %s。第 08 课的 notes_pinned_recent_idx 对该固定谓词和排序有用,仍需实际执行计划验证。"
+    },
+    "refs": [
+      {
+        "label": "PostgreSQL 16:EXPLAIN",
+        "url": "https://www.postgresql.org/docs/16/using-explain.html"
+      },
+      {
+        "label": "PostgreSQL 16:LIMIT 与 OFFSET",
+        "url": "https://www.postgresql.org/docs/16/queries-limit.html"
+      },
+      {
+        "label": "PostgreSQL 16:行比较",
+        "url": "https://www.postgresql.org/docs/16/functions-comparisons.html"
+      }
+    ],
+    "production": {
+      "scenario": "笔记数量增长后,第几百页响应变慢,而第一页正常。",
+      "decisions": [
+        "以实际扫描量解释深分页成本。",
+        "连续滚动用键集分页,任意跳页另做产品和性能取舍。",
+        "游标字段选择稳定值,分页不默认承诺全程快照。"
+      ],
+      "failureDrill": "在 notes_lab 对比相同深度的 OFFSET 与游标查询,记录执行计划。",
+      "acceptance": [
+        "深页工作量明显下降或有证据解释未下降原因。",
+        "同时间记录不重复。",
+        "测试记录注明样本规模与缓存状态。"
+      ]
+    }
+  },
+  {
+    "id": "10",
+    "title": "用 psycopg 连接池管理连接与事务",
+    "group": "Python 与运行保障",
+    "summary": "让并发代码借用有限连接,复现池耗尽,确保异常后连接能够归还。",
+    "objectives": [
+      "建立一个可显式开启和关闭的连接池",
+      "把事务包含在借还连接的作用域内",
+      "通过超时和统计观察池拥塞"
+    ],
+    "concepts": [
+      {
+        "title": "连接池是有限资源预算",
+        "body": "每个连接对应数据库后端资源。池在 min_size 与 max_size 范围复用连接,借不到时等待而非无限新建。进程数乘每进程最大池容量,再加任务、运维与其他服务连接,必须落在数据库预算内。"
+      },
+      {
+        "title": "连接不等于业务并发数",
+        "body": "多个请求可以排队等待少量连接。持有连接的时间越长,可服务吞吐越低。一个连接上的查询串行执行;不同线程不要把同一连接当作各自独立事务。同步 psycopg 应运行在同步代码或受控线程中。"
+      },
+      {
+        "title": "借还边界包含提交与回滚",
+        "body": "pool.connection() 的上下文负责正常结束时提交、异常退出时回滚并归还连接。应用启动创建池、退出关闭池;业务函数借用连接,不在每次调用时重新创建整个池。异步 API 对应 AsyncConnectionPool,在 API + 数据库模块接入。"
+      }
+    ],
+    "steps": [
+      {
+        "title": "安装池依赖",
+        "body": "沿用第 03 课 PG 环境变量。在虚拟环境运行 python -m pip install \"psycopg[binary,pool]>=3.2,<4\",然后更新 requirements-lock.txt。psycopg_pool 是单独分发的依赖,安装 psycopg 本体不代表已安装连接池。",
+        "code": "python -m pip install \"psycopg[binary,pool]>=3.2,<4\"\npython -m pip freeze > requirements-lock.txt",
+        "lang": "bash"
+      },
+      {
+        "title": "运行并发读的完整示例",
+        "body": "保存为 db_pool.py 并运行。4 个工作线程共用最大 2 条连接;每次函数调用独立借还。open=False 加 open(wait=True) 让启动阶段就确认连接可用,退出通过 finally 关闭。应用不应把密码或整条连接信息打印到日志。",
+        "code": "from concurrent.futures import ThreadPoolExecutor\nfrom psycopg_pool import ConnectionPool\n\npool = ConnectionPool(\"\", min_size=1, max_size=2, timeout=2, open=False,\n                      kwargs={\"application_name\": \"notes-service-db-lab\"})\n\ndef count_notes(_):\n    with pool.connection() as conn:\n        with conn.cursor() as cur:\n            cur.execute(\"SET LOCAL statement_timeout='2s'\")\n            cur.execute(\"SELECT count(*) FROM notes\")\n            return cur.fetchone()[0]\n\ntry:\n    pool.open(wait=True, timeout=10)\n    with ThreadPoolExecutor(max_workers=4) as workers:\n        print(list(workers.map(count_notes, range(8))))\n    print(pool.get_stats())\nfinally:\n    pool.close()",
+        "lang": "python",
+        "filename": "db_pool.py"
+      },
+      {
+        "title": "验证异常后事务回滚",
+        "body": "保存为 db_pool_rollback.py。故意抛出 Python 异常,异常必须离开 connection 上下文后再捕获,否则上下文可能把事务当作正常结束而提交。第二次借用连接后查询结果应为 0。",
+        "code": "from psycopg_pool import ConnectionPool\n\nwith ConnectionPool(\"\", min_size=1, max_size=1) as pool:\n    pool.wait(timeout=10)\n    note_id = None\n    try:\n        with pool.connection() as conn:\n            note_id = conn.execute(\n                \"INSERT INTO notes(title) VALUES (%s) RETURNING id\",\n                (\"db10-rollback\",),\n            ).fetchone()[0]\n            raise RuntimeError(\"simulated failure after insert\")\n    except RuntimeError:\n        pass\n    with pool.connection() as conn:\n        count = conn.execute(\"SELECT count(*) FROM notes WHERE id=%s\", (note_id,)).fetchone()[0]\n        assert count == 0\n        print(\"rolled back\", count)",
+        "lang": "python",
+        "filename": "db_pool_rollback.py"
+      },
+      {
+        "title": "可控复现池耗尽",
+        "body": "保存为 db_pool_timeout.py,只在本机练习运行。max_size=1 时外层已经占用唯一连接,内层借用 0.2 秒后超时;捕获异常后退出外层归还连接,再借用应成功。生产代码应消除嵌套借还并缩短持有时间,不能只扩大池。",
+        "code": "from psycopg_pool import ConnectionPool, PoolTimeout\n\nwith ConnectionPool(\"\", min_size=1, max_size=1) as pool:\n    pool.wait(timeout=10)\n    with pool.connection():\n        try:\n            with pool.connection(timeout=0.2):\n                pass\n        except PoolTimeout:\n            print(\"expected pool timeout\")\n    with pool.connection() as conn:\n        print(\"recovered\", conn.execute(\"SELECT 1\").fetchone()[0])\n    print(pool.get_stats())",
+        "lang": "python",
+        "filename": "db_pool_timeout.py"
+      }
+    ],
+    "checks": [
+      "并发读取成功,连接数量受 max_size=2 限制。",
+      "异常插入的笔记在下一次借用中查不到。",
+      "池耗尽产生 PoolTimeout,归还后 SELECT 1 恢复。"
+    ],
+    "pitfalls": [
+      {
+        "symptom": "高并发时不断出现 PoolTimeout",
+        "cause": "连接持有时间过长、嵌套借用或总并发超过容量。",
+        "fix": "检查等待数和事务时长,先缩短查询与持有范围,再按数据库预算调整池。"
+      },
+      {
+        "symptom": "async 路由中数据库调用阻塞其他请求",
+        "cause": "在事件循环中直接执行同步 psycopg 查询。",
+        "fix": "用 AsyncConnectionPool 与 await,或在受控线程执行同步代码;不要只把函数名改成 async。"
+      }
+    ],
+    "exercise": {
+      "task": "4 个 API 进程各 max_size=8,两个任务进程各 max_size=5,预留运维 10 条连接,至少需要规划多少连接?",
+      "hint": "进程之间不共享一个 Python 连接池。",
+      "answer": "仅这些进程峰值预算是 4×8+2×5+10=52 条,还应核查迁移、监控、其他服务和数据库保留连接。52 是这组假设下的预算,不是直接把 max_connections 设为 52 的建议。"
+    },
+    "refs": [
+      {
+        "label": "Psycopg 3:连接池",
+        "url": "https://www.psycopg.org/psycopg3/docs/advanced/pool.html"
+      },
+      {
+        "label": "Psycopg 3:池 API",
+        "url": "https://www.psycopg.org/psycopg3/docs/api/pool.html"
+      },
+      {
+        "label": "Psycopg 3:并发与异步",
+        "url": "https://www.psycopg.org/psycopg3/docs/advanced/async.html"
+      }
+    ],
+    "production": {
+      "scenario": "API 与任务进程同时访问 PostgreSQL,突发请求不应耗尽全部数据库连接。",
+      "decisions": [
+        "按进程数计算总连接预算。",
+        "连接等待和 SQL 执行分别设时限。",
+        "业务异常离开事务边界后再转为响应。"
+      ],
+      "failureDrill": "在 notes_lab 用单连接池制造超时和事务异常,再确认连接能归还复用。",
+      "acceptance": [
+        "没有连接泄漏。",
+        "等待有时限与可记录指标。",
+        "连接池在应用生命周期内创建和关闭。"
+      ]
+    }
+  },
+  {
+    "id": "11",
+    "title": "拆分数据库权限并管理连接配置",
+    "group": "Python 与运行保障",
+    "summary": "让运行时账号只做业务读写,限制危险语句,并检查日志和连接配置中的敏感信息。",
+    "objectives": [
+      "区分迁移账号与 API 运行账号",
+      "配置现有对象和未来对象的授权",
+      "验证最小权限以及超时配置"
+    ],
+    "concepts": [
+      {
+        "title": "权限应按工作职责分配",
+        "body": "app_user 保留为练习的对象所有者和迁移身份;新增 notes_runtime 供 API 运行。运行时需要 SELECT、INSERT、UPDATE、DELETE,不需要 CREATE ROLE、DROP TABLE 或 ALTER TABLE。数据库 CONNECT、schema USAGE、表权限是不同层。"
+      },
+      {
+        "title": "未来对象不会自动继承手工授权",
+        "body": "GRANT ON ALL TABLES 只覆盖当时存在的表。ALTER DEFAULT PRIVILEGES 针对特定创建者未来创建的对象生效,必须由正确身份设置。生产服务若只需要部分表,应显式授权,不能照搬练习的全部业务表授权。"
+      },
+      {
+        "title": "连接加密与秘密存储",
+        "body": "生产连接优先使用 TLS 且校验证书和主机名,例如 sslmode=verify-full 配合可信根证书。本机官方镜像没有自动配置可校验 TLS,不应伪称本课容器已提供 TLS。密码通过环境或秘密文件注入,SQL 参数和业务正文也可能包含敏感数据。"
+      }
+    ],
+    "steps": [
+      {
+        "title": "管理身份创建运行角色",
+        "body": "使用第 01 课 docker exec -it notes-postgres psql -U postgres -d notes_lab 进入管理连接。密码通过 \\password 交互设置。PUBLIC 是所有角色的隐式集合,撤销公开连接只限此独立练习库,不在共享线上库直接执行。",
+        "code": "CREATE ROLE notes_runtime LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE;\n\\password notes_runtime\nREVOKE CONNECT ON DATABASE notes_lab FROM PUBLIC;\nGRANT CONNECT ON DATABASE notes_lab TO app_user,notes_runtime;\nALTER ROLE notes_runtime IN DATABASE notes_lab SET statement_timeout='3s';\nALTER ROLE notes_runtime IN DATABASE notes_lab SET lock_timeout='1s';\nALTER ROLE notes_runtime IN DATABASE notes_lab SET idle_in_transaction_session_timeout='30s';",
+        "lang": "sql"
+      },
+      {
+        "title": "所有者身份授权现有与未来对象",
+        "body": "重新用 app_user 连接 notes_lab 执行。当前课程所有业务表都位于 public,生产可改成独立 schema 并按表授权。identity 之外若使用显式序列默认值,需要相应 sequence 权限;本例将业务序列使用权限一并授予。",
+        "code": "REVOKE CREATE ON SCHEMA public FROM PUBLIC;\nGRANT USAGE ON SCHEMA public TO notes_runtime;\nGRANT SELECT,INSERT,UPDATE,DELETE ON ALL TABLES IN SCHEMA public TO notes_runtime;\nGRANT USAGE,SELECT ON ALL SEQUENCES IN SCHEMA public TO notes_runtime;\nALTER DEFAULT PRIVILEGES FOR ROLE app_user IN SCHEMA public\nGRANT SELECT,INSERT,UPDATE,DELETE ON TABLES TO notes_runtime;\nALTER DEFAULT PRIVILEGES FOR ROLE app_user IN SCHEMA public\nGRANT USAGE,SELECT ON SEQUENCES TO notes_runtime;",
+        "lang": "sql"
+      },
+      {
+        "title": "运行身份验证允许与拒绝操作",
+        "body": "通过 docker exec -it notes-postgres psql -h 127.0.0.1 -U notes_runtime -d notes_lab -W 连接。插入成功后删除本次编号;尝试 ALTER TABLE 会因不是所有者失败,不应产生新列。",
+        "code": "SELECT current_user;\nINSERT INTO notes(title) VALUES ('db11-runtime') RETURNING id AS runtime_id \\gset\nDELETE FROM notes WHERE id=:runtime_id;\nALTER TABLE notes ADD COLUMN should_not_exist text;\n-- ERROR: must be owner of table notes\nSHOW statement_timeout;\nSHOW lock_timeout;\nSELECT pg_sleep(4);\n-- 约 3 秒取消,SQLSTATE 57014",
+        "lang": "sql"
+      },
+      {
+        "title": "验证未来表权限并调整应用配置",
+        "body": "以 app_user 创建临时验收表,再以 notes_runtime SELECT 应成功。验收后 app_user 删除该专用表。把应用 PGUSER 改为 notes_runtime 并注入对应密码,迁移工具继续单独使用 app_user。检查日志只记录操作名、耗时和 SQLSTATE,不打印密码或完整正文。",
+        "code": "-- app_user\nCREATE TABLE permission_probe(id integer PRIMARY KEY);\nINSERT INTO permission_probe VALUES (1);\n-- notes_runtime:应该读到 1\nSELECT id FROM permission_probe;\n-- app_user:验收结束清理本课专用表\nDROP TABLE permission_probe;",
+        "lang": "sql"
+      }
+    ],
+    "checks": [
+      "notes_runtime 可以执行业务 CRUD,不能修改表结构。",
+      "新连接显示 3 秒语句超时和 1 秒锁超时。",
+      "app_user 创建的未来表遵循默认权限,密码没有写进仓库和示例日志。"
+    ],
+    "pitfalls": [
+      {
+        "symptom": "现有表能访问,新迁移建的表报 permission denied",
+        "cause": "只给现有表 GRANT,或默认权限针对了错误创建角色。",
+        "fix": "检查实际迁移角色,给该角色设置 ALTER DEFAULT PRIVILEGES,并补授权已经创建的对象。"
+      },
+      {
+        "symptom": "sslmode=require 就被当成完成服务器身份校验",
+        "cause": "只要求加密不等于完整证书与主机名校验。",
+        "fix": "生产按基础设施证书配置 verify-full 与根证书;本机未配 TLS 的容器使用明确的开发配置。"
+      }
+    ],
+    "exercise": {
+      "task": "给只读报表账号设计最小权限,说明为什么不应复用 app_user。",
+      "hint": "需要 CONNECT、schema USAGE 和必要表的 SELECT;不授予写入或对象所有权。",
+      "answer": "创建独立 LOGIN 角色,授予 notes_lab 的 CONNECT、public 的 USAGE、指定业务表的 SELECT。为其设置合适超时与只读事务默认值作为附加限制;最终权限仍由 GRANT 决定。app_user 是对象所有者,权限范围远超报表需求。"
+    },
+    "refs": [
+      {
+        "label": "PostgreSQL 16:权限",
+        "url": "https://www.postgresql.org/docs/16/ddl-priv.html"
+      },
+      {
+        "label": "PostgreSQL 16:默认权限",
+        "url": "https://www.postgresql.org/docs/16/sql-alterdefaultprivileges.html"
+      },
+      {
+        "label": "PostgreSQL 16:SSL 客户端验证",
+        "url": "https://www.postgresql.org/docs/16/libpq-ssl.html"
+      },
+      {
+        "label": "PostgreSQL 16:客户端默认设置",
+        "url": "https://www.postgresql.org/docs/16/runtime-config-client.html"
+      }
+    ],
+    "production": {
+      "scenario": "API 连接配置泄漏或代码出现错误时,需要限制其能影响的数据库对象。",
+      "decisions": [
+        "运行角色与迁移角色使用不同秘密。",
+        "超时配置需重连生效并实测。",
+        "审计日志避免包含原始敏感正文。"
+      ],
+      "failureDrill": "在 notes_lab 用运行角色尝试 ALTER TABLE 并执行超时查询,验证拒绝和恢复。",
+      "acceptance": [
+        "运行角色不是所有者或超级用户。",
+        "权限随未来迁移保持可用。",
+        "生产 TLS、凭据注入和轮换方式有明确配置责任。"
+      ]
+    }
+  },
+  {
+    "id": "12",
+    "title": "备份恢复与可回退的结构变更",
+    "group": "Python 与运行保障",
+    "summary": "导出练习库并恢复到另一个数据库,通过数据核对和迁移演练完成生产验收。",
+    "objectives": [
+      "用 pg_dump 创建逻辑备份并校验文件",
+      "在独立恢复库验证结构、数据和序列",
+      "使用扩展、回填、验证的步骤变更表结构"
+    ],
+    "concepts": [
+      {
+        "title": "备份文件可生成不代表能恢复",
+        "body": "逻辑备份包含数据库对象与数据,恢复需要兼容的工具、角色与目标环境。单库 pg_dump 不包含整个实例的角色定义,也不能独自提供任意时间点恢复;严格 RPO 需要基础备份与 WAL 归档等方案。"
+      },
+      {
+        "title": "RPO 和 RTO 是验收条件",
+        "body": "RPO 是可接受的数据丢失时间范围,RTO 是恢复服务需要的时间上限。备份频率、保留、异地副本与恢复速度必须与业务目标一致。本课实际测量恢复步骤,而不把本机几秒恢复推断为线上保证。"
+      },
+      {
+        "title": "结构变更考虑新旧代码并存",
+        "body": "增加可空列通常比直接删除或改名更容易兼容。先加字段,再部署兼容代码,分批回填并验证,最后收紧约束。回滚代码不意味着可以随意 DROP 已有用户数据的新列,破坏性变更需要单独恢复设计。"
+      }
+    ],
+    "steps": [
+      {
+        "title": "生成备份与源库验收数据",
+        "body": "以下 Linux Bash 在 notes-service 执行,需要 PostgreSQL 16 客户端 pg_dump、psql、pg_restore;沿用 PGHOST/PGPORT/PGDATABASE,PGUSER 使用 app_user 且注入其密码。备份目录限制权限。命令失败必须停止,不继续上传空文件。",
+        "code": "set -e\nmkdir -p backups\nchmod 700 backups\npg_dump --version\npg_dump -Fc --no-owner --no-acl -f backups/notes_lab.dump\nsha256sum backups/notes_lab.dump > backups/notes_lab.dump.sha256\npg_restore --list backups/notes_lab.dump > backups/notes_lab.contents.txt\npsql -At -v ON_ERROR_STOP=1 -c \"SELECT count(*),min(id),max(id),count(*) FILTER(WHERE pinned) FROM notes\" > backups/source-notes.txt\nsha256sum -c backups/notes_lab.dump.sha256",
+        "lang": "bash"
+      },
+      {
+        "title": "创建隔离恢复目标并恢复",
+        "body": "管理连接执行 CREATE DATABASE notes_restore OWNER app_user;,只在本机首次创建该专用恢复库。不要向原 notes_lab 恢复。下面 --no-owner --no-acl 使本课恢复对象由 app_user 持有,权限在恢复后按第 11 课脚本重建;这不是自动恢复所有生产角色。",
+        "code": "# Linux Bash,PGUSER=app_user,使用相应密码\npg_restore --exit-on-error --single-transaction --no-owner --no-acl \\\n  -h 127.0.0.1 -p 5433 -U app_user -d notes_restore backups/notes_lab.dump\npsql -d notes_restore -At -v ON_ERROR_STOP=1 \\\n  -c \"SELECT count(*),min(id),max(id),count(*) FILTER(WHERE pinned) FROM notes\" > backups/restored-notes.txt\ndiff backups/source-notes.txt backups/restored-notes.txt",
+        "lang": "bash"
+      },
+      {
+        "title": "核查恢复后的关系与新写入",
+        "body": "使用 app_user 连 notes_restore 执行。反关联查询均应为 0。插入能生成合法新编号,ROLLBACK 避免保留验收笔记;序列可能前进一位,这是正常行为。若源库备份期间持续写入,外部另查的 count 不一定对应 pg_dump 快照;本课在停止练习写入后对照。",
+        "code": "SELECT count(*) FROM note_tags nt LEFT JOIN notes n ON n.id=nt.note_id WHERE n.id IS NULL;\nSELECT count(*) FROM note_tags nt LEFT JOIN tags t ON t.id=nt.tag_id WHERE t.id IS NULL;\nBEGIN;\nINSERT INTO notes(title) VALUES ('db12-restore-probe') RETURNING id,title;\nROLLBACK;\n\\d notes\n\\d note_tags",
+        "lang": "sql"
+      },
+      {
+        "title": "在练习源库做一次兼容扩展迁移",
+        "body": "重新连接 notes_lab / app_user。创建 archived_at 可空列表示未归档;保存为 sql/003_archived.sql,使用 psql -v ON_ERROR_STOP=1 -f sql/003_archived.sql 执行一次。短 lock_timeout 保证拿不到结构锁时尽快失败,再由人检查阻塞后择机重试。它不能保证 DDL 永远无阻塞。",
+        "code": "BEGIN;\nSET LOCAL lock_timeout='1s';\nSET LOCAL statement_timeout='5s';\nALTER TABLE notes ADD COLUMN archived_at timestamptz;\nCOMMIT;",
+        "lang": "sql",
+        "filename": "sql/003_archived.sql"
+      },
+      {
+        "title": "验证新旧访问兼容并记录恢复方案",
+        "body": "旧查询只选择明确列名,不受新列影响。新查询把 archived_at IS NULL 当作活动笔记。故障演练是在恢复库模拟错误更新再从备份另建恢复目标,不覆盖源库。本课不实际给出删除全表语句;验收恢复耗时、权限重建与业务请求都必须列入恢复记录。",
+        "code": "SELECT id,title,pinned FROM notes ORDER BY id DESC LIMIT 3;\nSELECT id,title FROM notes WHERE archived_at IS NULL ORDER BY id DESC LIMIT 3;\nSELECT column_name,is_nullable,data_type\nFROM information_schema.columns\nWHERE table_name='notes' AND column_name='archived_at';\n-- is_nullable=YES,data_type=timestamp with time zone",
+        "lang": "sql"
+      }
+    ],
+    "checks": [
+      "备份校验和正确,pg_restore 清单包含业务表、约束和数据。",
+      "独立恢复库行数与静止源库一致,外键无孤儿,新插入成功。",
+      "新增字段后旧查询与新查询都可运行,迁移失败有停止和重试策略。"
+    ],
+    "pitfalls": [
+      {
+        "symptom": "备份命令成功,但恢复时缺角色或权限",
+        "cause": "把单库逻辑备份当成完整实例备份,未规划角色与授权。",
+        "fix": "在恢复流程中显式重建角色和授权,或由受控配置管理维护;本课使用 no-owner/no-acl 后重新授权。"
+      },
+      {
+        "symptom": "上线加字段一直等待,API 也开始变慢",
+        "cause": "DDL 正在等待强锁,可能与长事务形成阻塞队列。",
+        "fix": "用短 lock_timeout 退出,查等待链并缩短长事务;在明确窗口重试,不无限等锁。"
+      },
+      {
+        "symptom": "恢复行数不等于备份后源库行数",
+        "cause": "源库继续写入,两个统计不属于同一快照。",
+        "fix": "练习对比时暂停写入;生产验收使用与备份快照一致的方法或业务不变量,不把后续变化误判为备份损坏。"
+      }
+    ],
+    "exercise": {
+      "task": "为“每日备份、允许最多丢失 15 分钟数据”的需求判断现有方案是否合格,并写出补充项。",
+      "hint": "先将备份间隔与 RPO 对照,再考虑恢复演练。",
+      "answer": "单独每日 pg_dump 无法满足 15 分钟 RPO。需要能够持续归档的 WAL 与基础备份或受管数据库时间点恢复,并验证归档延迟、保留期、异地副本、恢复步骤和监控;按目标时间点实际演练并测量 RTO。"
+    },
+    "refs": [
+      {
+        "label": "PostgreSQL 16:pg_dump",
+        "url": "https://www.postgresql.org/docs/16/app-pgdump.html"
+      },
+      {
+        "label": "PostgreSQL 16:pg_restore",
+        "url": "https://www.postgresql.org/docs/16/app-pgrestore.html"
+      },
+      {
+        "label": "PostgreSQL 16:持续归档与时间点恢复",
+        "url": "https://www.postgresql.org/docs/16/continuous-archiving.html"
+      },
+      {
+        "label": "PostgreSQL 16:ALTER TABLE",
+        "url": "https://www.postgresql.org/docs/16/sql-altertable.html"
+      }
+    ],
+    "production": {
+      "scenario": "发布结构变更之前,需要证明数据库可恢复,旧版本服务仍能短暂并存。",
+      "decisions": [
+        "恢复目标与源库严格分开。",
+        "备份验收覆盖数据、约束、权限与新写入。",
+        "新增字段先兼容,再部署和回填;迁移文件纳入版本管理。"
+      ],
+      "failureDrill": "在独立 notes_restore 检查备份恢复,然后在 notes_lab 用短锁超时演练新增可空字段;所有操作保持目标库可辨认。",
+      "acceptance": [
+        "记录实际恢复耗时与备份时间。",
+        "恢复库能完成业务 CRUD。",
+        "迁移前后访问兼容,生产 RPO/RTO 不由本机测试推定。"
+      ]
+    }
+  }
+]

+ 1098 - 0
public/content/docker.json

@@ -0,0 +1,1098 @@
+[
+  {
+    "id": "01",
+    "title": "镜像、容器与进程生命周期",
+    "summary": "从一个临时 Python 容器开始,观察进程退出、停止、重建与数据保留的区别。",
+    "group": "镜像与构建",
+    "objectives": [
+      "区分镜像模板与运行中的容器",
+      "找到容器进程、日志和退出状态",
+      "判断哪些数据应写入卷而非容器可写层"
+    ],
+    "concepts": [
+      {
+        "title": "容器运行的是进程",
+        "body": "镜像是文件系统和启动配置;容器是基于镜像创建的进程及其隔离环境。主进程退出,容器便停止,Docker 不会因为容器存在就持续执行应用。容器不是需要手工登录安装软件的长期虚拟机。"
+      },
+      {
+        "title": "停止与删除不同",
+        "body": "stop 发送停止信号并等待,start 重用原容器,rm 删除已停止容器的可写层。重新 run 创建另一份容器。镜像和显式命名卷有独立生命周期,重建 API 容器不应删除数据库卷。"
+      },
+      {
+        "title": "本模块练习边界",
+        "body": "使用 Docker Desktop 的 Linux 容器模式或独立 Linux 练习机,先完成 API+数据库模块的 notes-service。所有命令针对本地专用练习项目;不连接线上 Docker context,不挂载 Docker socket 到应用。未完成前置代码时,端点和 worker 不会自动存在。"
+      }
+    ],
+    "steps": [
+      {
+        "title": "核对 Docker 目标",
+        "body": "执行下列只读命令,确认 context 指向本机练习环境,Linux 容器模式,Compose V2 可用。若无 Docker,应先按官方安装文档完成当前系统安装,不执行来源不明的一键脚本。",
+        "code": "docker context show\ndocker info --format '{{.OSType}}'\ndocker compose version\ndocker version",
+        "lang": "bash"
+      },
+      {
+        "title": "运行一次性 Python 进程",
+        "body": "该容器运行完 print 就退出,--rm 清理它自己的可写层。它没有数据库、持久卷或宿主目录挂载,不影响 notes 数据。",
+        "code": "docker run --rm python:3.11-slim python -c \"import sys; print(sys.version)\"",
+        "lang": "bash"
+      },
+      {
+        "title": "观察持续运行的主进程",
+        "body": "创建专用生命周期容器,查看它的日志和进程。这里只写容器内临时文件,接下来可以安全删除该练习容器。Windows PowerShell 可原样使用双引号内的 Python 代码。",
+        "code": "docker run -d --name notes-lifecycle python:3.11-slim python -u -c \"import time; from pathlib import Path; Path('/tmp/probe.txt').write_text('container layer'); print('ready'); time.sleep(300)\"\ndocker logs notes-lifecycle\ndocker top notes-lifecycle\ndocker inspect notes-lifecycle --format '{{.State.Status}}'",
+        "lang": "bash"
+      },
+      {
+        "title": "停止、重启再查看文件",
+        "body": "stop 后仍保留原容器文件层,start 重新运行启动命令。观察完再次 stop,并只删除名为 notes-lifecycle 的容器;不要用批量 prune 清理所有项目。",
+        "code": "docker stop --time 5 notes-lifecycle\ndocker start notes-lifecycle\ndocker exec notes-lifecycle cat /tmp/probe.txt\ndocker stop --time 5 notes-lifecycle\ndocker rm notes-lifecycle",
+        "lang": "bash"
+      },
+      {
+        "title": "比较新容器的文件层",
+        "body": "使用相同镜像创建新容器,它没有前一容器的 probe 文件。预期 False 不代表镜像损坏,而是临时写入没有进入镜像或持久卷。",
+        "code": "docker run --rm python:3.11-slim python -c \"from pathlib import Path; print(Path('/tmp/probe.txt').exists())\"",
+        "lang": "bash"
+      }
+    ],
+    "checks": [
+      "docker info 的 OSType 为 linux,context 明确指向练习环境。",
+      "能够观察主进程退出导致容器停止,日志可独立读取。",
+      "新容器中没有上一容器临时文件,能说明卷的必要性。"
+    ],
+    "pitfalls": [
+      {
+        "symptom": "docker run 很快结束,以为容器启动失败",
+        "cause": "启动命令只是一个会完成的脚本。",
+        "fix": "查看 docker logs 与退出码,服务需要持续监听进程,而非用 tail 掩盖真实服务退出。"
+      },
+      {
+        "symptom": "进入容器 pip install 后重建又缺包",
+        "cause": "安装发生在容器临时文件层。",
+        "fix": "将依赖写进 Dockerfile 与锁定文件,通过重建镜像保留。"
+      }
+    ],
+    "exercise": {
+      "task": "镜像没有变化,为何两个容器的文件可能不同?",
+      "hint": "启动配置相同不代表运行时写入相同。",
+      "answer": "容器各自拥有可写层和进程状态;环境变量、挂载卷和启动命令也可能不同。把需要复现的软件放进镜像,把需要保留的业务数据放进明确的持久存储。"
+    },
+    "refs": [
+      {
+        "label": "Docker:什么是容器",
+        "url": "https://docs.docker.com/get-started/docker-concepts/the-basics/what-is-a-container/"
+      },
+      {
+        "label": "Docker:镜像",
+        "url": "https://docs.docker.com/get-started/docker-concepts/the-basics/what-is-an-image/"
+      },
+      {
+        "label": "Docker:安装",
+        "url": "https://docs.docker.com/engine/install/"
+      }
+    ],
+    "production": {
+      "scenario": "开发者需要交付能在另一台机器启动的服务,而非一份手工配置记录。",
+      "decisions": [
+        "以主进程状态判断服务是否运行。",
+        "只清理明确命名的练习容器,持久数据单独管理。"
+      ],
+      "failureDrill": "停止并删除无挂载的生命周期容器,再从同一镜像创建,观察临时文件消失。",
+      "acceptance": [
+        "能解释进程、可写层、镜像与卷各自的生命周期。",
+        "所有操作目标都可通过 context 和容器名定位。"
+      ]
+    }
+  },
+  {
+    "id": "02",
+    "title": "为 FastAPI 编写非 Root Dockerfile",
+    "summary": "把 notes-service 源码与固定依赖构建进镜像,以非 root 用户启动 Uvicorn。",
+    "group": "镜像与构建",
+    "objectives": [
+      "编写最小可运行 Dockerfile 和 .dockerignore",
+      "用锁定依赖避免不同机器安装漂移",
+      "以 exec 形式运行非 root 应用进程"
+    ],
+    "concepts": [
+      {
+        "title": "构建上下文决定哪些文件可被复制",
+        "body": "docker build 的最后一个路径是构建上下文,COPY 只能读取其内容。把 .env、备份、虚拟环境排除可减少构建大小并避免秘密进入镜像;在后续 RUN 删除秘密无法抹去早期镜像层。"
+      },
+      {
+        "title": "运行用户与监听地址",
+        "body": "容器内应用使用固定非 root UID,监听 0.0.0.0:8000 才能接收容器网卡流量。宿主发布到 127.0.0.1:8000 限制外部访问。非 root 不等于完整隔离,但能减少意外写入和权限范围。"
+      },
+      {
+        "title": "进程命令",
+        "body": "JSON 数组形式 CMD 直接运行应用进程,停止信号可以到达 Uvicorn;不要用 shell 后台启动加无限 sleep。生产镜像不启用 --reload,日志写 stdout/stderr,数据库迁移独立运行。"
+      }
+    ],
+    "steps": [
+      {
+        "title": "建立目标平台锁定依赖",
+        "body": "在 notes-service 新建 requirements.in,采用下列主依赖。已有业务的额外依赖应一起加入。用目标 Linux/Python3.11 的构建环境生成 requirements-lock.txt;不要把 Windows 专有库锁直接用于 Linux。下一课给出容器内生成命令。",
+        "code": "fastapi>=0.121,<1\nuvicorn[standard]>=0.30,<1\npydantic-settings>=2,<3\nSQLAlchemy>=2.0,<2.1\npsycopg[binary]>=3,<4\nalembic>=1.13,<2\nhttpx>=0.27,<1\ncelery[redis]>=5.4,<6",
+        "lang": "text",
+        "filename": "requirements.in"
+      },
+      {
+        "title": "新建忽略文件",
+        "body": "Docker 构建不需要本机 .env、虚拟环境、数据库转储和 Git 历史。保留 .env.example 可供文档参考,但本课 Dockerfile 只显式 COPY 应用必需文件。",
+        "code": ".git\n.venv\n.env\n.env.*\nsecrets/\nbackups/\n*.dump\n*.sql.gz\n__pycache__/\n.pytest_cache/\ntests/",
+        "lang": "text",
+        "filename": ".dockerignore"
+      },
+      {
+        "title": "新建 Dockerfile",
+        "body": "依赖完整 app 目录、alembic.ini、migrations 与生成的 requirements-lock.txt。Python slim 基础版本下一课固定为实际 digest;此处标签用于首次学习构建。COPY 不复制本机配置。",
+        "code": "FROM python:3.11-slim\nENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 PIP_DISABLE_PIP_VERSION_CHECK=1\nWORKDIR /app\nCOPY requirements-lock.txt ./\nRUN python -m pip install --no-cache-dir --require-hashes -r requirements-lock.txt     && groupadd --gid 10001 notes     && useradd --uid 10001 --gid notes --no-create-home --shell /usr/sbin/nologin notes\nCOPY app ./app\nCOPY alembic.ini ./\nCOPY migrations ./migrations\nUSER 10001:10001\nEXPOSE 8000\nCMD [\"python\", \"-m\", \"uvicorn\", \"app.main:app\", \"--host\", \"0.0.0.0\", \"--port\", \"8000\", \"--timeout-graceful-shutdown\", \"25\"]",
+        "lang": "dockerfile",
+        "filename": "Dockerfile"
+      },
+      {
+        "title": "构建并核对镜像用户",
+        "body": "完成下一课锁文件生成后在项目根目录构建。此检查不需要数据库配置,因为 python -c 直接执行,没有导入 app。用户 id 应为 10001。",
+        "code": "docker build -t notes-service:lab-01 .\ndocker run --rm notes-service:lab-01 python -c \"import os; print(os.getuid())\"\ndocker image inspect notes-service:lab-01 --format '{{.Config.User}} {{json .Config.Cmd}}'",
+        "lang": "bash"
+      },
+      {
+        "title": "通过练习网络配置启动服务",
+        "body": "本课暂不启动完整服务,先验证代码和依赖存在;第 4 课 Compose 会提供 postgres DNS 与 DATABASE_URL。直接运行镜像若缺 DATABASE_URL 应明确失败,这比默认连错库更容易排错。",
+        "code": "docker run --rm notes-service:lab-01 python -c \"import fastapi, sqlalchemy, psycopg; print(fastapi.__version__, sqlalchemy.__version__, psycopg.__version__)\"\ndocker run --rm notes-service:lab-01 python -c \"from pathlib import Path; print(Path('/app/alembic.ini').exists(), Path('/app/migrations/env.py').exists())\"",
+        "lang": "bash"
+      }
+    ],
+    "checks": [
+      "镜像运行用户为 10001,不需要 --privileged。",
+      "镜像包含应用与迁移文件,不含 .env 和备份。",
+      "CMD 直接启动 Uvicorn,未启用 reload;依赖版本能在容器内打印。"
+    ],
+    "pitfalls": [
+      {
+        "symptom": "容器日志显示已启动,宿主访问不到",
+        "cause": "Uvicorn 只监听容器回环地址 127.0.0.1。",
+        "fix": "容器内监听 0.0.0.0,宿主端口发布是否公开由 ports 绑定地址控制。"
+      },
+      {
+        "symptom": "pip --require-hashes 拒绝安装",
+        "cause": "锁文件不是带完整依赖与哈希的锁文件。",
+        "fix": "按下一课用 pip-tools 生成,不能去掉哈希选项假装已经固定依赖。"
+      }
+    ],
+    "exercise": {
+      "task": "为什么不直接 COPY . /app?",
+      "hint": "检查构建上下文中的本机配置与数据。",
+      "answer": "显式 COPY 应用、迁移和锁文件能缩小误带入范围;.dockerignore 再提供过滤。COPY . 并非语法错误,但需要更严格控制上下文,新手项目容易把秘密、缓存和备份带进去。"
+    },
+    "refs": [
+      {
+        "label": "Docker:Dockerfile 参考",
+        "url": "https://docs.docker.com/reference/dockerfile/"
+      },
+      {
+        "label": "Docker:构建上下文与忽略文件",
+        "url": "https://docs.docker.com/build/concepts/context/"
+      },
+      {
+        "label": "pip:安全可重复安装",
+        "url": "https://pip.pypa.io/en/stable/topics/secure-installs/"
+      }
+    ],
+    "production": {
+      "scenario": "应用镜像交给部署机器运行,需要固定依赖与明确的低权限进程。",
+      "decisions": [
+        "运行时依赖装在构建阶段,秘密仅运行时注入。",
+        "只复制需要的目录,非 root 启动。"
+      ],
+      "failureDrill": "在构建上下文放一个无敏感内容的 .env 练习标记,构建后确认 /app/.env 不存在。",
+      "acceptance": [
+        "镜像内没有本机配置标记。",
+        "容器用户和启动命令可审计。"
+      ]
+    }
+  },
+  {
+    "id": "03",
+    "title": "分层缓存、多阶段构建与可重复输入",
+    "summary": "将依赖构建和运行文件分开,固定基础镜像与 Python 包,让构建结果可以追溯。",
+    "group": "镜像与构建",
+    "objectives": [
+      "用目标平台生成带哈希依赖锁",
+      "理解 COPY 顺序对构建缓存的影响",
+      "以多阶段构建移除构建工具并记录镜像摘要"
+    ],
+    "concepts": [
+      {
+        "title": "缓存按层失效",
+        "body": "先 COPY 依赖锁再安装,最后 COPY 源码,业务代码修改通常不会让依赖安装层失效。缓存提升速度,不代表输入自动更新;基础镜像和锁文件更新仍需显式执行与测试。"
+      },
+      {
+        "title": "标签与摘要",
+        "body": "python:3.11-slim 标签可能随安全更新移动,digest 指向具体镜像内容。使用真实查询到的 digest 可固定基础镜像;固定也意味着必须有主动更新流程,不能永久停留在旧漏洞版本。"
+      },
+      {
+        "title": "多阶段的边界",
+        "body": "builder 创建虚拟环境,runtime 只复制环境与应用代码。二者使用相同基础镜像和架构,避免复制不兼容的本地扩展。哈希锁、基础 digest 和目标平台稳定输入;如果要求字节级相同镜像,还需控制构建时间戳与工具链,不能仅靠 Dockerfile 保证。"
+      }
+    ],
+    "steps": [
+      {
+        "title": "在一次性 Linux 容器生成锁文件",
+        "body": "以下为 Bash/WSL 在 notes-service 根目录执行;PowerShell 使用 ${PWD} 对应绝对路径替换 $(pwd)。pip-tools 安装只发生在临时构建工具容器。生成后审阅锁定变更并纳入代码管理。",
+        "code": "docker run --rm --mount type=bind,source=\"$(pwd)\",target=/src -w /src python:3.11-slim sh -c 'python -m pip install \"pip-tools>=7,<8\" && python -m piptools compile --generate-hashes --output-file requirements-lock.txt requirements.in'",
+        "lang": "bash"
+      },
+      {
+        "title": "查询实际基础摘要",
+        "body": "执行 pull 后查看 RepoDigests,将输出中的完整 python@sha256:... 记录到本机构建变量 PYTHON_IMAGE。不要复制课件中的占位摘要。多架构构建需明确目标平台并记录选择。",
+        "code": "docker pull python:3.11-slim\ndocker image inspect python:3.11-slim --format '{{json .RepoDigests}}'\n# Bash / WSL:选择并保存刚拉取镜像的完整仓库摘要。\nPYTHON_IMAGE=$(docker image inspect python:3.11-slim --format '{{index .RepoDigests 0}}')\ncase \"$PYTHON_IMAGE\" in python@sha256:*) export PYTHON_IMAGE ;; *) echo 'unexpected image digest'; exit 1 ;; esac",
+        "lang": "bash"
+      },
+      {
+        "title": "替换 Dockerfile 为多阶段版本",
+        "body": "用本段完整替换第 2 课 Dockerfile。PYTHON_IMAGE 的默认标签供练习,发布构建必须通过 --build-arg 传已记录的 digest;builder 与 runtime 共用同一值。psycopg[binary] 避免本课额外引入编译工具。",
+        "code": "ARG PYTHON_IMAGE=python:3.11-slim\nFROM ${PYTHON_IMAGE} AS builder\nENV PIP_DISABLE_PIP_VERSION_CHECK=1\nWORKDIR /build\nRUN python -m venv /opt/venv\nCOPY requirements-lock.txt ./\nRUN /opt/venv/bin/python -m pip install --no-cache-dir --require-hashes -r requirements-lock.txt\n\nFROM ${PYTHON_IMAGE} AS runtime\nENV PATH=\"/opt/venv/bin:$PATH\" PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1\nRUN groupadd --gid 10001 notes     && useradd --uid 10001 --gid notes --no-create-home --shell /usr/sbin/nologin notes\nWORKDIR /app\nCOPY --from=builder /opt/venv /opt/venv\nCOPY app ./app\nCOPY alembic.ini ./\nCOPY migrations ./migrations\nUSER 10001:10001\nEXPOSE 8000\nCMD [\"python\", \"-m\", \"uvicorn\", \"app.main:app\", \"--host\", \"0.0.0.0\", \"--port\", \"8000\", \"--timeout-graceful-shutdown\", \"25\"]",
+        "lang": "dockerfile",
+        "filename": "Dockerfile"
+      },
+      {
+        "title": "构建并观察缓存",
+        "body": "在同一 Bash / WSL 终端使用上一阶段取得并验证的 PYTHON_IMAGE;新开终端时重新从已拉取镜像读取摘要。变量为空会在构建前停止。第一次完成后仅修改 app 中一个无副作用注释,再构建,依赖层应显示 CACHED。",
+        "code": "docker build --progress=plain --build-arg PYTHON_IMAGE=\"${PYTHON_IMAGE:?set verified base digest}\" -t notes-service:lab-02 .\ndocker history notes-service:lab-02\ndocker image inspect notes-service:lab-02 --format '{{.Id}}'",
+        "lang": "bash"
+      },
+      {
+        "title": "记录构建证据与更新路径",
+        "body": "创建 build-record.md,记录下面字段。依赖升级时在分支重新生成锁、构建新镜像并运行数据库测试,再发布;不能在运行容器内 pip install -U 修补并忘记回写镜像。",
+        "code": "源码提交:实际 git commit SHA\n目标平台:例如 linux/amd64\n基础镜像:实际 python@sha256:...\n依赖锁:requirements-lock.txt 的版本/摘要\n应用镜像:本机 image ID;推送后补仓库 digest\n验证结果:执行命令、实际输出、未验证项",
+        "lang": "text",
+        "filename": "build-record.md"
+      }
+    ],
+    "checks": [
+      "requirements-lock.txt 包含递归依赖的固定版本与哈希,容器中 --require-hashes 安装通过。",
+      "只改应用源码时依赖层命中缓存。",
+      "构建记录能关联源码、基础镜像、依赖锁与最终镜像。"
+    ],
+    "pitfalls": [
+      {
+        "symptom": "复制本机 .venv 后容器启动找不到动态库",
+        "cause": "宿主系统或架构与容器不一致。",
+        "fix": "在目标 Linux 基础镜像内创建环境,builder/runtime 保持相同平台与 ABI。"
+      },
+      {
+        "symptom": "固定基础摘要后长期没有安全更新",
+        "cause": "把固定输入误当作无需维护。",
+        "fix": "定期更新摘要和依赖锁,在测试通过后产生新发布版本,保留变更记录。"
+      }
+    ],
+    "exercise": {
+      "task": "--no-cache 是否保证获取最新基础镜像?",
+      "hint": "构建缓存与拉取基础镜像是两件事。",
+      "answer": "不保证。--pull 会检查标签的新内容;但如果固定 digest,应先审阅并选择新 digest,再更新构建参数。盲目拉最新也不等于通过了应用兼容测试。"
+    },
+    "refs": [
+      {
+        "label": "Docker:多阶段构建",
+        "url": "https://docs.docker.com/build/building/multi-stage/"
+      },
+      {
+        "label": "Docker:构建缓存",
+        "url": "https://docs.docker.com/build/cache/"
+      },
+      {
+        "label": "Docker:固定基础镜像",
+        "url": "https://docs.docker.com/build/building/best-practices/#pin-base-image-versions"
+      },
+      {
+        "label": "pip-tools:依赖锁",
+        "url": "https://pip-tools.readthedocs.io/en/stable/"
+      }
+    ],
+    "production": {
+      "scenario": "同一版本需要在预发布和线上运行相同的软件输入。",
+      "decisions": [
+        "构建一次,按同一镜像摘要提升到下一个环境。",
+        "构建工具留在 builder,运行层只包含运行所需文件。"
+      ],
+      "failureDrill": "修改源码与依赖锁分别构建,记录哪几层重建;还原后核对镜像关联记录。",
+      "acceptance": [
+        "能解释缓存结果。",
+        "依赖升级产生新的可追溯发布物。"
+      ]
+    }
+  },
+  {
+    "id": "04",
+    "title": "Compose 网络、服务 DNS 与端口",
+    "summary": "把 API、PostgreSQL 和 Redis 放入同一专用网络,区分容器内地址和宿主调试端口。",
+    "group": "运行与资源",
+    "objectives": [
+      "通过服务名连接 PostgreSQL 与 Redis",
+      "绑定本机调试端口并避免冲突",
+      "用 Compose 配置检查和容器内探针定位连通性"
+    ],
+    "concepts": [
+      {
+        "title": "localhost 指当前容器",
+        "body": "API 中 127.0.0.1 指向 API 自己,不能到 PostgreSQL。Compose 默认网络提供 postgres 和 redis 服务名解析;容器间连接使用 postgres:5432 与 redis:6379,宿主调试使用 127.0.0.1:5433 与 127.0.0.1:6380。"
+      },
+      {
+        "title": "端口发布的方向",
+        "body": "ports 的左侧是宿主绑定,右侧是容器监听。容器间通信不依赖宿主端口映射;数据库不需要被公网发布。EXPOSE 是镜像元信息,也不会自动开放宿主端口。"
+      },
+      {
+        "title": "项目隔离",
+        "body": "Compose project name 区分同机多个练习栈的网络与卷;不要设置固定 container_name,它会阻碍扩容并造成跨项目名字冲突。示例数据库为 notes_lab,app_user 是普通应用角色,postgres 管理账号不进入 API。"
+      }
+    ],
+    "steps": [
+      {
+        "title": "新建 Compose 专用环境文件",
+        "body": "复制下列模板为 .env.compose 并填写两个不同的本机练习密码。文件不提交,后续统一命令显式 --env-file .env.compose;它用于 Compose 插值,不覆盖先前本机 Python 使用的 .env。连接串密码有特殊字符需 URL 编码。",
+        "code": "COMPOSE_PROJECT_NAME=notes-lab\nDB_ADMIN_PASSWORD=CHANGE_ADMIN_PASSWORD\nAPP_DB_PASSWORD=CHANGE_APP_PASSWORD\nDATABASE_URL=postgresql+psycopg://app_user:CHANGE_APP_PASSWORD@postgres:5432/notes_lab\nREDIS_URL=redis://redis:6379/0\nNOTES_IMAGE=notes-service:lab-02",
+        "lang": "dotenv",
+        "filename": ".env.compose"
+      },
+      {
+        "title": "新建初始化脚本",
+        "body": "新建 deploy/init/01-app.sh,保存 LF 换行。postgres 官方镜像在空数据目录首次初始化时执行脚本;后续改环境变量不会自动修改角色密码。psql 变量替换用 SQL 字符串引用处理密码,禁止直接字符串拼 SQL。练习 app_user 拥有 public schema 的 DDL 权限,生产迁移权限下一阶段单独拆分。",
+        "code": "#!/bin/sh\nset -eu\npsql -v ON_ERROR_STOP=1 --username \"$POSTGRES_USER\" --dbname \"$POSTGRES_DB\"   --set=app_password=\"$APP_DB_PASSWORD\" <<'SQL'\nCREATE ROLE app_user LOGIN PASSWORD :'app_password';\nGRANT CONNECT ON DATABASE notes_lab TO app_user;\nGRANT USAGE, CREATE ON SCHEMA public TO app_user;\nSQL",
+        "lang": "bash",
+        "filename": "deploy/init/01-app.sh"
+      },
+      {
+        "title": "新建 compose.yaml",
+        "body": "本段创建 API、Postgres、Redis 与独立卷;Redis 此时只是连通性练习,不承诺任务持久化。宿主数据库端口与数据库模块一致。API 初次启动可能尚未建表,第 6 课加入迁移和健康依赖。",
+        "code": "services:\n  api:\n    image: ${NOTES_IMAGE:?set NOTES_IMAGE}\n    environment:\n      DATABASE_URL: ${DATABASE_URL:?set DATABASE_URL}\n      REDIS_URL: ${REDIS_URL:?set REDIS_URL}\n    ports: [\"127.0.0.1:8000:8000\"]\n  postgres:\n    image: postgres:16\n    environment:\n      POSTGRES_DB: notes_lab\n      POSTGRES_USER: postgres\n      POSTGRES_PASSWORD: ${DB_ADMIN_PASSWORD:?set admin password}\n      APP_DB_PASSWORD: ${APP_DB_PASSWORD:?set app password}\n    ports: [\"127.0.0.1:5433:5432\"]\n    volumes:\n      - pg_data:/var/lib/postgresql/data\n      - ./deploy/init:/docker-entrypoint-initdb.d:ro\n  redis:\n    image: redis:7.4\n    ports: [\"127.0.0.1:6380:6379\"]\nvolumes:\n  pg_data:",
+        "lang": "yaml",
+        "filename": "compose.yaml"
+      },
+      {
+        "title": "检查配置并启动依赖",
+        "body": "如果上一模块已经占用 5433 或 6380,先确认属于哪个独立练习栈,选择复用同一栈或停止其对应服务,不能直接杀掉未知进程。config --quiet 检查语法,不把展开后的密码配置贴进聊天或日志。",
+        "code": "docker compose --env-file .env.compose config --quiet\ndocker compose --env-file .env.compose up -d postgres redis\ndocker compose --env-file .env.compose ps\ndocker compose --env-file .env.compose logs --tail 50 postgres",
+        "lang": "bash"
+      },
+      {
+        "title": "从 API 网络位置检测服务 DNS",
+        "body": "run --rm 临时容器共享项目网络,使用 Python socket 验证 DNS 和 TCP 端口。成功只说明网络可达,不能证明账号权限或表迁移完成。",
+        "code": "docker compose --env-file .env.compose run --rm --no-deps api python -c \"import socket; print(socket.gethostbyname('postgres')); socket.create_connection(('postgres',5432),3).close(); socket.create_connection(('redis',6379),3).close(); print('tcp ok')\"\ndocker compose --env-file .env.compose exec postgres psql -U postgres -d notes_lab -c \"SELECT rolname, rolsuper FROM pg_roles WHERE rolname='app_user';\"",
+        "lang": "bash"
+      }
+    ],
+    "checks": [
+      "API 容器可解析 postgres/redis,并连接内部 5432/6379。",
+      "宿主端口仅绑定 127.0.0.1 的 8000、5433、6380。",
+      "app_user 的 rolsuper 为 false;管理账号密码未注入 API。"
+    ],
+    "pitfalls": [
+      {
+        "symptom": "API 连接 127.0.0.1:5433 被拒绝",
+        "cause": "把宿主访问地址用于容器内部。",
+        "fix": "容器 DATABASE_URL 使用 postgres:5432;宿主 psql 才使用本机 5433。"
+      },
+      {
+        "symptom": "修改 APP_DB_PASSWORD 后认证仍失败",
+        "cause": "初始化脚本只在空数据目录执行,已有角色密码未改变。",
+        "fix": "通过受控 SQL 轮换角色密码并同步应用秘密;不能删除卷来修复认证。"
+      }
+    ],
+    "exercise": {
+      "task": "为了让 API 访问数据库,是否必须发布 5433?",
+      "hint": "Compose 服务互通走内部网络。",
+      "answer": "不需要。删去 postgres 的 ports 后,API 仍能使用 postgres:5432;宿主 psql 失去直接端口入口,可通过 docker compose exec 调试。线上通常不发布数据库宿主端口。"
+    },
+    "refs": [
+      {
+        "label": "Docker:Compose 网络",
+        "url": "https://docs.docker.com/compose/how-tos/networking/"
+      },
+      {
+        "label": "Docker:端口发布",
+        "url": "https://docs.docker.com/engine/network/port-publishing/"
+      },
+      {
+        "label": "Postgres 官方镜像说明",
+        "url": "https://hub.docker.com/_/postgres"
+      }
+    ],
+    "production": {
+      "scenario": "同机运行多个后端依赖,地址与端口必须在容器内外保持清楚。",
+      "decisions": [
+        "应用用 DNS 服务名,不固定容器 IP。",
+        "数据库调试端口仅回环绑定,发布环境删去不必要端口。"
+      ],
+      "failureDrill": "只在练习 .env.compose 把主机名改为不存在名称,观察 DNS 错误后恢复,区分认证失败与网络失败。",
+      "acceptance": [
+        "能够按 DNS、TCP、认证、SQL 四层定位。",
+        "不同 Compose 项目资源有明确命名。"
+      ]
+    }
+  },
+  {
+    "id": "05",
+    "title": "PostgreSQL 持久卷与可恢复备份",
+    "summary": "验证重建数据库容器后数据仍存在,在独立恢复容器中验证逻辑备份。",
+    "group": "运行与资源",
+    "objectives": [
+      "区分 named volume 与 bind mount 的权限责任",
+      "验证容器重建不丢 PostgreSQL 数据",
+      "制作并恢复逻辑备份,记录恢复证据"
+    ],
+    "concepts": [
+      {
+        "title": "卷保存数据库目录",
+        "body": "PostgreSQL 16 官方镜像的本课持久路径为 /var/lib/postgresql/data。命名卷由 Docker 管理宿主位置,容器删除不会自动删除命名卷。不要把卷当作备份,宿主磁盘故障和误删仍会影响它。"
+      },
+      {
+        "title": "权限与一致性",
+        "body": "宿主 bind mount 需要匹配数据库用户 UID/GID 与权限,直接 chmod 777 不是解决方案。运行中的 PGDATA 不能随手复制成可靠备份;用 pg_dump 做逻辑备份,物理备份需 pg_basebackup/WAL 等一致性协议。"
+      },
+      {
+        "title": "恢复才验证备份",
+        "body": "备份成功退出只能证明生成了文件,不能证明恢复后应用能使用。恢复演练要用另一容器和另一卷,核对表、行数和业务查询;备份保留策略还需异机、加密、访问控制与实际 RPO/RTO。"
+      }
+    ],
+    "steps": [
+      {
+        "title": "确认卷与数据路径",
+        "body": "只读检查 Compose 解析到的数据库挂载。取得容器 id 后可 inspect Mounts;不进入 Docker 内部卷目录手工改权限或编辑文件。",
+        "code": "docker compose --env-file .env.compose ps -q postgres\ndocker compose --env-file .env.compose exec postgres psql -U postgres -d notes_lab -c \"SHOW data_directory;\"\ndocker volume ls --filter label=com.docker.compose.project=notes-lab",
+        "lang": "bash"
+      },
+      {
+        "title": "创建专用持久化标记并重建容器",
+        "body": "在练习库创建一张明确命名的验证表,重建仅 postgres 容器,保留 pg_data 卷。执行后查询同一行。正常生产更新仍要评估可用性与数据库版本兼容。 重建后先执行 pg_isready,返回 accepting connections 后再查询标记;尚未就绪时等待后重试,不把首次连接失败当作卷丢失。",
+        "code": "docker compose --env-file .env.compose exec postgres psql -U postgres -d notes_lab -c \"CREATE TABLE IF NOT EXISTS volume_probe (id integer PRIMARY KEY, value text NOT NULL); INSERT INTO volume_probe VALUES (1,'survives recreate') ON CONFLICT (id) DO UPDATE SET value=EXCLUDED.value;\"\ndocker compose --env-file .env.compose up -d --force-recreate postgres\ndocker compose --env-file .env.compose exec postgres pg_isready -U postgres -d notes_lab\n# 上一步返回 accepting connections 后继续;否则等待后重试。\ndocker compose --env-file .env.compose exec postgres psql -U postgres -d notes_lab -c \"SELECT * FROM volume_probe;\"",
+        "lang": "bash"
+      },
+      {
+        "title": "生成备份并从容器复制",
+        "body": "先在容器内生成自定义格式备份,再 docker compose cp,避免旧版 Windows PowerShell 重定向二进制流时损坏文件。备份含业务数据,放到已忽略的 backups 目录;不上传到公开资源。",
+        "code": "mkdir backups\ndocker compose --env-file .env.compose exec postgres pg_dump -U postgres -d notes_lab -Fc --no-owner --no-acl -f /tmp/notes_lab.dump\ndocker compose --env-file .env.compose cp postgres:/tmp/notes_lab.dump ./backups/notes_lab.dump\ndocker compose --env-file .env.compose exec postgres pg_restore --list /tmp/notes_lab.dump",
+        "lang": "bash"
+      },
+      {
+        "title": "创建独立恢复容器",
+        "body": "新建 .env.restore,只含 POSTGRES_PASSWORD=独立练习密码,加入 .gitignore。恢复容器不发布端口,使用单独 named volume;docker exec 从同容器 Unix socket 操作。等待日志 ready 后再恢复,不复用业务 pg_data。",
+        "code": "docker volume create notes-restore-data\ndocker run -d --name notes-restore --env-file .env.restore -e POSTGRES_DB=notes_restore -v notes-restore-data:/var/lib/postgresql/data postgres:16\ndocker logs --tail 30 notes-restore\ndocker cp ./backups/notes_lab.dump notes-restore:/tmp/notes_lab.dump\ndocker exec notes-restore pg_restore -U postgres -d notes_restore --no-owner --no-acl --exit-on-error /tmp/notes_lab.dump",
+        "lang": "bash"
+      },
+      {
+        "title": "核对恢复并保留证据",
+        "body": "本课尚未执行第06课的应用迁移,因此只核对已创建的 volume_probe,不查询尚不存在的 notes 或 alembic_version。恢复标记应为 id=1、value=survives recreate。完成第06课迁移并创建笔记后,再重复备份和恢复流程;使用新的恢复数据库或新的专用恢复卷,避免把第二份备份叠加到非空恢复库,再比较 notes 数量和 alembic_version。此时才完成业务数据恢复验收。当前先停止恢复容器,保留恢复卷用于核查。",
+        "code": "docker exec notes-restore psql -U postgres -d notes_restore -c \"SELECT * FROM volume_probe;\"\ndocker stop notes-restore\n# 第06课迁移、创建笔记并重新备份到新的隔离恢复库后,再在新恢复库查询:\n# SELECT count(*) FROM notes;\n# SELECT version_num FROM alembic_version;",
+        "lang": "bash"
+      }
+    ],
+    "checks": [
+      "重建 postgres 容器后 volume_probe 行仍存在。",
+      "pg_restore --list 可读取备份结构,恢复命令退出为 0。",
+      "独立恢复库包含正确的 volume_probe,原练习库未被覆盖;业务表恢复在第06课迁移后用新备份补验。"
+    ],
+    "pitfalls": [
+      {
+        "symptom": "重建容器后数据全没了",
+        "cause": "未挂载持久卷、挂载路径错误,或切换了 Compose project name。",
+        "fix": "检查 Mounts 和卷标签,确认实际使用的 pg_data;不要在未确认旧卷前初始化覆盖或删除。"
+      },
+      {
+        "symptom": "恢复时报 archive 格式错误",
+        "cause": "用不支持二进制透明传输的 Shell 重定向生成备份。",
+        "fix": "在容器内写 -f 文件,再 docker cp;检查备份大小和 pg_restore --list。"
+      }
+    ],
+    "exercise": {
+      "task": "每天把 PGDATA 目录复制到同一宿主另一个文件夹能否替代备份方案?",
+      "hint": "分别考虑运行中一致性与宿主故障。",
+      "answer": "不能。普通文件复制可能得到不一致数据库,而且宿主故障会同时损坏原件与副本。使用数据库支持的备份协议,并保留异机副本、加密密钥与定期恢复证据。"
+    },
+    "refs": [
+      {
+        "label": "Docker:Volumes",
+        "url": "https://docs.docker.com/engine/storage/volumes/"
+      },
+      {
+        "label": "PostgreSQL 16:pg_dump",
+        "url": "https://www.postgresql.org/docs/16/app-pgdump.html"
+      },
+      {
+        "label": "PostgreSQL 16:pg_restore",
+        "url": "https://www.postgresql.org/docs/16/app-pgrestore.html"
+      }
+    ],
+    "production": {
+      "scenario": "镜像升级必须保留数据库,宿主损坏后也应有可恢复副本。",
+      "decisions": [
+        "容器和数据库卷独立管理。",
+        "备份在独立环境恢复后才计入可用备份。"
+      ],
+      "failureDrill": "重建练习数据库容器,再把备份恢复到另一卷并核对 volume_probe。完成第06课迁移后,另取新备份恢复到新的空恢复库,补验业务行数和迁移版本。",
+      "acceptance": [
+        "重建不丢数据。",
+        "有一次完整备份恢复的实际记录。"
+      ]
+    }
+  },
+  {
+    "id": "06",
+    "title": "健康检查、启动重试与一次性迁移",
+    "summary": "让 API 等待数据库可用与迁移成功,同时处理运行期间的依赖故障。",
+    "group": "运行与资源",
+    "objectives": [
+      "区分容器运行、健康、应用就绪与迁移完成",
+      "用 Compose 条件依赖组织首次启动",
+      "为瞬时断连设计有界重试而不重放未知写事务"
+    ],
+    "concepts": [
+      {
+        "title": "running 不是 ready",
+        "body": "容器进程创建后,Postgres 可能仍在初始化,API 可能缺表。pg_isready 证明数据库接受连接,不验证 app_user 凭据或业务表。应用 /ready 执行 SQL,迁移作业另行证明表版本已到目标。"
+      },
+      {
+        "title": "启动顺序只覆盖启动",
+        "body": "depends_on condition 控制 Compose 启动前置条件,不能保证依赖永远在线。运行时数据库重启仍会引发请求失败,连接池需检测失效连接,调用方需按幂等规则重试。"
+      },
+      {
+        "title": "迁移是独立作业",
+        "body": "一个 migrate 容器执行 alembic upgrade head,退出码 0 才允许 API 启动。不让多个 API worker 各自执行迁移。迁移失败保留日志与旧应用,不能循环忽略失败继续接流量。"
+      }
+    ],
+    "steps": [
+      {
+        "title": "增加数据库和 Redis 健康检查",
+        "body": "把以下字段分别合并到 compose.yaml 的 postgres 与 redis 服务内,不新建同名重复服务。$$ 让变量传入容器 Shell 后展开,避免 Compose 提前替换。",
+        "code": "# postgres 服务内\nhealthcheck:\n  test: [\"CMD-SHELL\", \"pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB\"]\n  interval: 5s\n  timeout: 3s\n  retries: 12\n  start_period: 10s\n\n# redis 服务内\nhealthcheck:\n  test: [\"CMD\", \"redis-cli\", \"ping\"]\n  interval: 5s\n  timeout: 2s\n  retries: 5",
+        "lang": "yaml",
+        "filename": "compose.yaml"
+      },
+      {
+        "title": "新增一次性迁移服务",
+        "body": "合并到 services 层,镜像与 API 完全相同。练习迁移使用 app_user 的 DDL 权限;生产用单独迁移凭据覆盖此服务的 DATABASE_URL,API 保持更小权限。",
+        "code": "migrate:\n  image: ${NOTES_IMAGE:?set NOTES_IMAGE}\n  command: [\"python\", \"-m\", \"alembic\", \"upgrade\", \"head\"]\n  environment:\n    DATABASE_URL: ${DATABASE_URL:?set DATABASE_URL}\n  depends_on:\n    postgres:\n      condition: service_healthy\n  restart: \"no\"",
+        "lang": "yaml",
+        "filename": "compose.yaml"
+      },
+      {
+        "title": "为 API 添加就绪探针与条件依赖",
+        "body": "合并到已有 api 服务。urllib 已在 Python 标准库,无需额外安装 curl。/ready 是前置模块实现的数据库探针;用户自定义应用须先实现同等端点。start_period 不屏蔽真实请求,只影响健康状态统计。",
+        "code": "depends_on:\n  migrate:\n    condition: service_completed_successfully\nhealthcheck:\n  test: [\"CMD\", \"python\", \"-c\", \"import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/ready',timeout=4).read()\"]\n  interval: 10s\n  timeout: 5s\n  retries: 3\n  start_period: 15s\nrestart: unless-stopped",
+        "lang": "yaml",
+        "filename": "compose.yaml"
+      },
+      {
+        "title": "首次启动并验证迁移状态",
+        "body": "up -d --wait 等待服务达到健康状态,但它不是业务验收;随后创建一条笔记并读取。migrate 是正常退出的作业,其 Exited (0) 不算服务故障。",
+        "code": "docker compose --env-file .env.compose config --quiet\ndocker compose --env-file .env.compose up -d --wait\ndocker compose --env-file .env.compose ps -a\ndocker compose --env-file .env.compose logs --tail 50 migrate\ndocker compose --env-file .env.compose exec postgres psql -U postgres -d notes_lab -c \"SELECT version_num FROM alembic_version;\"",
+        "lang": "bash"
+      },
+      {
+        "title": "演练运行时数据库短暂停止",
+        "body": "仅在专用本机栈停止 postgres,此时 /live 应仍为 200、/ready 为 503;重启数据库后连接池 pre_ping 应逐步恢复。Docker 标记 unhealthy 本身不会自动重启容器,restart 策略主要响应进程退出。写入失败不要盲目自动重放,用幂等键确认结果。",
+        "code": "docker compose --env-file .env.compose stop postgres\ndocker compose --env-file .env.compose logs --tail 30 api\ndocker compose --env-file .env.compose start postgres\ndocker compose --env-file .env.compose ps",
+        "lang": "bash"
+      }
+    ],
+    "checks": [
+      "首次启动迁移先成功退出,API 随后 ready。",
+      "迁移失败时 API 不按成功依赖启动,日志保留真实异常。",
+      "数据库重启期间 readiness 失败,恢复后可创建读取数据,无隐式无限重试。"
+    ],
+    "pitfalls": [
+      {
+        "symptom": "depends_on 后仍出现连接拒绝",
+        "cause": "只写了服务名依赖,没等待健康;或故障发生在启动之后。",
+        "fix": "首次启动使用 health 条件,运行期间通过连接恢复、超时和调用方幂等重试处理。"
+      },
+      {
+        "symptom": "容器 unhealthy 却没有自动重启",
+        "cause": "把健康状态与 restart 策略混为一谈。",
+        "fix": "明确由部署平台或外部监控处理健康流量与重启;不要让所有实例因共享数据库故障一起重启。"
+      }
+    ],
+    "exercise": {
+      "task": "能否把 alembic upgrade head 放进 API CMD 前面,每个副本都执行?",
+      "hint": "扩容时有多少个进程会同时触发 DDL?",
+      "answer": "不应。使用单独迁移步骤并保证唯一执行;多个进程同时迁移会竞争锁与版本状态。迁移成功是发布门槛,不是每个请求实例的初始化副作用。"
+    },
+    "refs": [
+      {
+        "label": "Docker:启动顺序",
+        "url": "https://docs.docker.com/compose/how-tos/startup-order/"
+      },
+      {
+        "label": "Docker:健康检查",
+        "url": "https://docs.docker.com/reference/dockerfile/#healthcheck"
+      },
+      {
+        "label": "PostgreSQL 16:pg_isready",
+        "url": "https://www.postgresql.org/docs/16/app-pg-isready.html"
+      }
+    ],
+    "production": {
+      "scenario": "整套服务在新机器首次启动,以及运行期间数据库维护重启。",
+      "decisions": [
+        "依赖健康、迁移完成和业务就绪分别验证。",
+        "运行时重试有限,写请求按幂等协议恢复。"
+      ],
+      "failureDrill": "停止练习 postgres,观察 /live 与 /ready 分离,恢复后验证原数据和新写入。",
+      "acceptance": [
+        "共享数据库故障不会触发无意义的无限重启。",
+        "启动失败能够定位到依赖、迁移或应用阶段。"
+      ]
+    }
+  },
+  {
+    "id": "07",
+    "title": "CPU、内存、进程数与优雅退出",
+    "summary": "给容器设置资源边界,观察 OOM 与 SIGTERM,确保在途请求和任务有明确退出行为。",
+    "group": "运行与资源",
+    "objectives": [
+      "设置并验证容器 CPU、内存与 PID 上限",
+      "识别 OOMKilled 和普通应用退出",
+      "协调停止宽限期、HTTP 排空与任务重投"
+    ],
+    "concepts": [
+      {
+        "title": "限制是边界而非性能保证",
+        "body": "cpus 限制可用 CPU 时间,mem_limit 限制内存,pids_limit 限制线程与进程数量。线程也可能计入 PID 额度。限制过低会导致正常峰值失败,过高会让单服务挤压数据库与宿主;起始值需要用实际负载校准。"
+      },
+      {
+        "title": "SIGTERM 与 SIGKILL",
+        "body": "docker stop 通常先发 SIGTERM,等待 stop_grace_period 后再 SIGKILL。SIGTERM 允许应用停止接新请求并完成在途工作;SIGKILL 无法执行 finally。Uvicorn 的优雅关闭预算必须小于容器宽限期。"
+      },
+      {
+        "title": "退出与任务一致性",
+        "body": "HTTP 排空和队列任务退出是两套行为。Celery worker 收到 TERM 可进行 warm shutdown,但容器最终可能强杀长任务。任务必须幂等,确认、可见性超时和恢复策略与异步任务模块一致,不能把 SIGTERM 当作任务绝不丢失的保证。"
+      }
+    ],
+    "steps": [
+      {
+        "title": "为 API 配置资源上限",
+        "body": "将这些字段合并到 api 服务。示例 1 CPU/512MB/128 PID 是练习起点,不是生产容量结论;Dockerfile Uvicorn graceful timeout 已为 25 秒,容器给予 35 秒。",
+        "code": "cpus: 1.0\nmem_limit: 512m\npids_limit: 128\ninit: true\nstop_grace_period: 35s\nlogging:\n  driver: local\n  options:\n    max-size: \"10m\"\n    max-file: \"3\"",
+        "lang": "yaml",
+        "filename": "compose.yaml"
+      },
+      {
+        "title": "重建并核对运行限制",
+        "body": "应用配置变化需 up 重建容器,不是只 restart。stats 观察实际内存和 CPU;inspect 以 API 容器 id 读取 HostConfig,不把完整环境配置输出到公共日志。",
+        "code": "docker compose --env-file .env.compose up -d api\ndocker compose --env-file .env.compose stats --no-stream api\ndocker compose --env-file .env.compose ps -q api\n# 将下面 id 替换为刚取得的 API 容器 id\ndocker inspect API_CONTAINER_ID --format '{{.HostConfig.Memory}} {{.HostConfig.NanoCpus}} {{.HostConfig.PidsLimit}}'",
+        "lang": "bash"
+      },
+      {
+        "title": "在一次性专用容器触发 OOM",
+        "body": "此演练不使用 API 容器或数据库卷;仅分配大于 64MB 上限的内存。不要加 --rm,便于查看退出状态,检查后只删除 notes-oom-drill。",
+        "code": "docker run --name notes-oom-drill --memory=64m --memory-swap=64m --pids-limit=32 python:3.11-slim python -c \"x=bytearray(256*1024*1024); print(len(x))\"\ndocker inspect notes-oom-drill --format '{{.State.ExitCode}} {{.State.OOMKilled}}'\ndocker rm notes-oom-drill",
+        "lang": "bash"
+      },
+      {
+        "title": "验证 API 接收停止信号",
+        "body": "在另一个终端持续请求 /ready,再 stop api,观察日志中应用开始关闭与 shutdown complete。正常短请求应完成,关闭后客户端连接失败属于流量需要移走的阶段。单容器 Compose 没有自动负载均衡排空能力。",
+        "code": "docker compose --env-file .env.compose stop api\ndocker compose --env-file .env.compose logs --tail 50 api\ndocker compose --env-file .env.compose start api",
+        "lang": "bash"
+      },
+      {
+        "title": "记录资源与退出验收",
+        "body": "创建 resource-budget.md,记录实例/worker 数、连接池乘数、CPU/内存峰值及关闭耗时。任务 worker 的并发进程数和每个进程连接池也算入数据库配额。",
+        "code": "API:1 进程/容器;数据库池 5;内存上限 512MiB;关闭预算 25s < 容器35s\nWorker:按第9课单独配置并发;连接数按实际执行模型计算\n宿主:为 Postgres、Redis、Docker 和系统留余量\n证据:实际 stats 峰值、OOM 状态、SIGTERM 关闭耗时",
+        "lang": "text",
+        "filename": "resource-budget.md"
+      }
+    ],
+    "checks": [
+      "运行容器实际 HostConfig 包含预期内存、CPU、PID 限制。",
+      "一次性 OOM 容器能够通过 OOMKilled 区分资源杀死与普通异常。",
+      "API 在宽限期内完成关闭,没有依赖 shell 后台进程转发信号。"
+    ],
+    "pitfalls": [
+      {
+        "symptom": "退出码 137 就断言一定 OOM",
+        "cause": "137 也可能由其他 SIGKILL 来源导致。",
+        "fix": "同时检查 OOMKilled、宿主内核日志、容器事件与部署停止超时。"
+      },
+      {
+        "symptom": "容器停止后任务重复执行",
+        "cause": "任务在完成业务后、确认前被强杀,队列再次投递。",
+        "fix": "按任务幂等与业务唯一约束处理重投,调整任务时限和退出预算;不要为了避免重复提前确认并忽略丢失风险。"
+      }
+    ],
+    "exercise": {
+      "task": "每容器 4 个 API worker 是否一定比 1 个更快?",
+      "hint": "同时计算 CPU、内存与数据库连接。",
+      "answer": "不一定。4 个进程增加内存和连接池总数,受 1 CPU 限制时可能只增加调度开销。用实际等待与计算比例评估;容器水平扩容和 worker 数必须一起预算。"
+    },
+    "refs": [
+      {
+        "label": "Docker:资源限制",
+        "url": "https://docs.docker.com/engine/containers/resource_constraints/"
+      },
+      {
+        "label": "Docker:Compose 服务资源字段",
+        "url": "https://docs.docker.com/reference/compose-file/services/"
+      },
+      {
+        "label": "Docker:stop",
+        "url": "https://docs.docker.com/reference/cli/docker/container/stop/"
+      },
+      {
+        "label": "Uvicorn:设置",
+        "url": "https://uvicorn.dev/settings/"
+      }
+    ],
+    "production": {
+      "scenario": "单个大请求或长任务不能耗尽宿主全部资源,部署停止也不能无限等待。",
+      "decisions": [
+        "资源值先设上限再压测校准。",
+        "容器停止预算大于应用优雅退出预算。"
+      ],
+      "failureDrill": "使用无挂载专用容器触发 OOM,再对练习 API 执行 SIGTERM,分别记录原因与恢复。",
+      "acceptance": [
+        "OOM 不影响数据库卷。",
+        "关闭和任务恢复行为有明确记录。"
+      ]
+    }
+  },
+  {
+    "id": "08",
+    "title": "配置、Secrets 与容器运行权限",
+    "summary": "将连接秘密从 Compose 环境输出中移走,收紧应用可写目录与 Linux capabilities,并建立依赖扫描流程。",
+    "group": "安全与交付",
+    "objectives": [
+      "通过文件向应用注入秘密并验证权限",
+      "限制运行容器的写入与提权能力",
+      "读取扫描结果并形成可验证的修复步骤"
+    ],
+    "concepts": [
+      {
+        "title": "环境变量与秘密文件",
+        "body": "环境变量方便但常出现在 inspect、诊断和进程信息中。Compose secrets 将文件挂载到 /run/secrets,应用必须主动读取;环境变量 NAME_FILE 不是通用自动功能,只有支持它的程序会处理。"
+      },
+      {
+        "title": "本地 Compose 的边界",
+        "body": "本地 secrets 通常来自宿主文件挂载,不等同 Swarm 的加密分发。宿主源文件和备份仍需权限控制,拥有 Docker daemon 管理权限的人可访问容器与挂载。应用不用 Docker socket,不把日常用户随意加入 docker 组。"
+      },
+      {
+        "title": "扫描与修复",
+        "body": "镜像扫描报告依赖和系统包漏洞,是定位输入,不是应用已安全的证明。确认运行路径与受影响版本,更新基础 digest/锁文件、重建并回归。不要在运行容器里删除库来“清零”扫描数字。"
+      }
+    ],
+    "steps": [
+      {
+        "title": "让配置支持文件秘密",
+        "body": "替换 app/config.py 中 Settings 的 database_url 声明并扩展 model_config,下面是该部分的完整写法;保留此前连接池字段。secret 目录中的 database_url 文件提供值,环境变量同名值优先,因此从 api 服务移除旧 DATABASE_URL environment 项。",
+        "code": "from pydantic import Field\nfrom pydantic_settings import BaseSettings, SettingsConfigDict\n\nclass Settings(BaseSettings):\n    model_config = SettingsConfigDict(env_file=\".env\", extra=\"ignore\", secrets_dir=\"/run/secrets\")\n    database_url: str = Field(min_length=1)\n    db_pool_size: int = Field(default=5, ge=1, le=50)\n    db_max_overflow: int = Field(default=0, ge=0, le=50)\n    db_pool_timeout: float = Field(default=2, gt=0, le=30)\n\nsettings = Settings()",
+        "lang": "python",
+        "filename": "app/config.py"
+      },
+      {
+        "title": "建立本机秘密文件",
+        "body": "在已忽略的 secrets 目录用编辑器新建 database_url,填写 postgres:5432 的容器连接串。以下为 Linux / WSL 的本地 file-backed secrets 方案:父目录由当前可信宿主账户持有且权限700,单个绑定文件644,使容器UID10001可读。其他普通宿主账户无法穿过700父目录;有Docker控制权限的账户仍属于受信任管理员。不能仅写uid/gid/mode并假设Compose会重映射文件权限。",
+        "code": "mkdir -p secrets\n# 用编辑器创建 secrets/database_url,填实际练习连接串。\nchmod 700 secrets\nchmod 644 secrets/database_url\n# 此处只调整明确的单个绑定文件;不把父目录改成公开可进入。",
+        "lang": "bash"
+      },
+      {
+        "title": "挂载秘密并检查实际权限",
+        "body": "在 compose.yaml 顶层新增 secrets;在 api/migrate 的服务中挂载 secrets: [database_url] 并移除 DATABASE_URL 环境值。file-backed secrets实际采用绑定文件权限,不能依赖Compose的uid/gid/mode映射。后续任务URL和broker配置也放在同一受控目录,逐一授予容器读取,并在启动正式进程前用临时容器验证。",
+        "code": "# compose.yaml 顶层\nsecrets:\n  database_url:\n    file: ./secrets/database_url\n\n# api 与 migrate 各自服务内添加\n# secrets:\n#   - database_url",
+        "lang": "yaml",
+        "filename": "compose.yaml"
+      },
+      {
+        "title": "收紧 API 运行权限",
+        "body": "合并到 api 服务并重建镜像以包含新的配置模块。根文件系统只读,临时目录使用有界 tmpfs;app 不写本地持久文件。Celery 需要的调度状态在第 9 课单独处理。",
+        "code": "read_only: true\ntmpfs:\n  - /tmp:size=64m,mode=1777\ncap_drop: [ALL]\nsecurity_opt:\n  - no-new-privileges:true",
+        "lang": "yaml",
+        "filename": "compose.yaml"
+      },
+      {
+        "title": "验证只读、秘密和依赖扫描",
+        "body": "先重建镜像,随后用 command 覆盖应用启动命令,只读取秘密文件并输出布尔值。该检查使用镜像默认非root UID10001;成功后才启动API,避免先因Settings读取失败退出再尝试exec。只读写入检查应报Read-only file system,是本步骤预期结果。Docker Scout若已安装授权可执行cves,否则记录未执行原因,不声称扫描通过。",
+        "code": "docker build --build-arg PYTHON_IMAGE=\"${PYTHON_IMAGE:?set verified base digest}\" -t notes-service:lab-02 .\ndocker compose --env-file .env.compose run --rm --no-deps api python -c \"from pathlib import Path; assert Path('/run/secrets/database_url').read_text().strip(); print('secret readable')\"\n# 仅当前置检查成功后继续;失败时先修正文件所有者/权限和挂载。\ndocker compose --env-file .env.compose up -d api\ndocker compose --env-file .env.compose exec api python -c \"from pathlib import Path; Path('/app/should_fail').write_text('x')\"\ndocker scout cves notes-service:lab-02",
+        "lang": "bash"
+      }
+    ],
+    "checks": [
+      "API 配置可读取挂载秘密,检查时未打印连接串。",
+      "/app 写入失败,/tmp 可以写入且有容量上限。",
+      "扫描结果或未执行原因有记录,修复通过基础镜像/锁文件重建完成。"
+    ],
+    "pitfalls": [
+      {
+        "symptom": "加了 DATABASE_URL_FILE 但应用仍缺配置",
+        "cause": "应用没有实现读取该变量指向文件的逻辑。",
+        "fix": "采用本课 Pydantic secrets_dir 文件名 database_url,或显式实现并测试 _FILE 读取。"
+      },
+      {
+        "symptom": "只读 rootfs 后应用启动失败",
+        "cause": "程序尝试写字节码、缓存、PID 或调度文件。",
+        "fix": "禁用字节码并为必要临时写入配置 tmpfs;持久调度状态使用单独卷,不把整个根文件系统改回可写。"
+      }
+    ],
+    "exercise": {
+      "task": "把 .env 复制进镜像后下一层删除,是否已清除秘密?",
+      "hint": "镜像保留每层的文件变更。",
+      "answer": "没有。旧层仍可能恢复内容。轮换已泄露秘密,重新构建不含该层的新镜像,检查镜像仓库与构建日志中的副本;正常构建使用运行时秘密或 BuildKit secret mount。"
+    },
+    "refs": [
+      {
+        "label": "Docker:Compose Secrets",
+        "url": "https://docs.docker.com/compose/how-tos/use-secrets/"
+      },
+      {
+        "label": "Docker:构建秘密",
+        "url": "https://docs.docker.com/build/building/secrets/"
+      },
+      {
+        "label": "Pydantic Settings:Secrets",
+        "url": "https://docs.pydantic.dev/latest/concepts/pydantic_settings/#secrets"
+      },
+      {
+        "label": "Docker Scout:CVE 分析",
+        "url": "https://docs.docker.com/scout/quickstart/"
+      }
+    ],
+    "production": {
+      "scenario": "诊断日志和镜像交付不应携带数据库密码,应用应只有必需的文件写入权限。",
+      "decisions": [
+        "秘密只在运行时挂载,宿主源文件访问受控。",
+        "非 root、只读根文件系统和去除 capabilities 一起验证。"
+      ],
+      "failureDrill": "在本机尝试向 /app 写入,预期拒绝;仍能读取秘密并执行 /ready。",
+      "acceptance": [
+        "权限收紧不破坏正常 API。",
+        "秘密没有进入镜像层与共享日志。"
+      ]
+    }
+  },
+  {
+    "id": "09",
+    "title": "API、Worker 与队列的 Compose 整合",
+    "summary": "使用同一镜像运行不同进程角色,把任务执行、调度和 HTTP 服务分开部署。",
+    "group": "安全与交付",
+    "objectives": [
+      "部署异步任务模块真实 tasks_lab 工程与 exports.fast/bulk 队列",
+      "为 API、worker、publisher、recovery 分别配置进程与资源",
+      "隔离缓存 Redis 与带认证的任务 broker,并验证持久业务状态"
+    ],
+    "concepts": [
+      {
+        "title": "同镜像不同职责",
+        "body": "API 接收请求并提交任务,worker 消费任务,beat 周期调度;它们可以来自同一代码镜像,但生命周期、并发与资源不同。不把 worker 后台进程塞进 API 容器,也不在每个 API 副本内启动周期调度器。"
+      },
+      {
+        "title": "任务边界",
+        "body": "复用异步任务模块第 11–16 课最终 tasks_lab 代码:celery_app.py 暴露 app,export_tasks.py 注册 notes.export,参数为 job_id。export_jobs/export_outbox 是业务真源,publisher.py 和 recovery.py 目前是单步脚本。不要部署第 8 课的小快照过渡任务,也不要假定旧 api-lab 下载包已有这些文件。"
+      },
+      {
+        "title": "Redis 隔离",
+        "body": "本地 Redis /0 与 /1 可以区分 key 空间,不能隔离内存、CPU、故障与驱逐策略。生产缓存与消息代理通常使用独立实例及策略;队列持久化和 ack 机制也不能单独构成端到端任务不丢失保证。"
+      }
+    ],
+    "steps": [
+      {
+        "title": "复制完整最终任务工程并统一依赖",
+        "body": "把异步任务模块完成后的 tasks_lab 目录放入 notes-service 根目录,保留 celery_app.py、export_tasks.py、state_store.py、publisher.py、recovery.py 及其全部实际依赖模块。把其 requirements-tasks-lock.txt 所需包合并进 requirements.in,重新生成目标平台哈希锁。Dockerfile 在 USER 前加入以下 COPY;不要用本课创建一个同名的假业务 worker 替代原任务。",
+        "code": "COPY tasks_lab ./tasks_lab\nCOPY deploy/run_role.py ./deploy/run_role.py",
+        "lang": "dockerfile",
+        "filename": "Dockerfile"
+      },
+      {
+        "title": "为任务角色建立秘密加载入口",
+        "body": "新建 deploy/run_role.py。API 的 SQLAlchemy URL 用 postgresql+psycopg://,任务模块直接 psycopg.connect 使用 postgresql://,两者分别保存。入口从三个只读秘密文件读入实际环境变量再 exec 目标进程,不输出秘密;读取 /run/secrets/task_database_url、celery_broker_url、celery_result_backend。",
+        "code": "import os\nimport sys\nfrom pathlib import Path\n\nfor name, variable in {\n    \"task_database_url\": \"DATABASE_URL\",\n    \"celery_broker_url\": \"CELERY_BROKER_URL\",\n    \"celery_result_backend\": \"CELERY_RESULT_BACKEND\",\n}.items():\n    value = Path(\"/run/secrets\", name).read_text().strip()\n    if not value:\n        raise RuntimeError(f\"empty secret: {name}\")\n    os.environ[variable] = value\nif len(sys.argv) < 2:\n    raise RuntimeError(\"missing process command\")\nos.execvp(sys.argv[1], sys.argv[1:])",
+        "lang": "python",
+        "filename": "deploy/run_role.py"
+      },
+      {
+        "title": "配置独立认证 broker 与秘密文件",
+        "body": "新增 broker 服务,不复用缓存 redis。宿主6384为任务专用,6380–6383已由Redis课程其他隔离实例使用。用强随机 TASK_BROKER_PASSWORD 在编辑器生成秘密文件:task_broker_conf 包含 requirepass、appendonly yes、appendfsync everysec、maxmemory 192mb、maxmemory-policy noeviction、dir /data;celery_broker_url 为 redis://:URL编码密码@broker:6379/0,celery_result_backend 为同地址/1;task_database_url 为 postgresql://app_user:URL编码密码@postgres:5432/notes_lab。三个URL文件由task服务读取;broker配置只读挂载,不提交到仓库。 本地file-backed secrets按第8课使用宿主受控父目录700、单秘密文件644,使UID10001可读取实际挂载;启动前用run --no-deps检查可读性且不打印内容。TASK_BROKER_PASSWORD建议本机生成高熵十六进制值,使Redis配置requirepass行无需额外引号转义。",
+        "code": "# services 中新增;本机调试端口仅回环开放\nbroker:\n  image: redis:7.4\n  command: [\"redis-server\", \"/run/secrets/task_broker_conf\"]\n  ports: [\"127.0.0.1:6384:6379\"]\n  secrets: [task_broker_conf]\n  volumes: [\"broker_data:/data\"]\n  mem_limit: 256m\n  restart: unless-stopped\n\n# 顶层 volumes 新增 broker_data: {}\n# 顶层 secrets 新增以下条目(与已有 database_url 并列)\n# task_broker_conf: {file: ./secrets/task_broker_conf}\n# task_database_url: {file: ./secrets/task_database_url}\n# celery_broker_url: {file: ./secrets/celery_broker_url}\n# celery_result_backend: {file: ./secrets/celery_result_backend}",
+        "lang": "yaml",
+        "filename": "compose.yaml"
+      },
+      {
+        "title": "添加真实 fast Worker 与 bulk Worker",
+        "body": "新增 worker 服务,worker-bulk 使用相同字段,仅 command 的队列改 exports.bulk、节点名改 bulk@%h。任务模块使用相对 Path(\"artifacts\"),工作目录下挂载 /app/tasks_lab/artifacts 命名卷并初始化给UID10001;同机共享输出供恢复/下载使用,跨宿主生产改对象存储。API仅提供笔记接口时不伪称已实现任务提交HTTP端点,先用任务模块 submit/publisher 流程验收。",
+        "code": "worker:\n  image: ${NOTES_IMAGE:?set NOTES_IMAGE}\n  working_dir: /app/tasks_lab\n  command: [\"python\", \"/app/deploy/run_role.py\", \"python\", \"-m\", \"celery\", \"-A\", \"celery_app:app\", \"worker\", \"-l\", \"INFO\", \"-Q\", \"exports.fast\", \"--concurrency=1\", \"-n\", \"fast@%h\"]\n  secrets: [task_database_url, celery_broker_url, celery_result_backend]\n  volumes: [\"artifacts_data:/app/tasks_lab/artifacts\"]\n  depends_on:\n    broker: {condition: service_started}\n    migrate: {condition: service_completed_successfully}\n  cpus: 1.0\n  mem_limit: 512m\n  pids_limit: 128\n  init: true\n  stop_grace_period: 75s\n  restart: unless-stopped\n  read_only: true\n  tmpfs: [\"/tmp:size=64m,mode=1777\"]\n  cap_drop: [ALL]\n  security_opt: [\"no-new-privileges:true\"]\n\n# 顶层 volumes 新增 artifacts_data: {}",
+        "lang": "yaml",
+        "filename": "compose.yaml"
+      },
+      {
+        "title": "包装有界 publisher 与 recovery 循环",
+        "body": "新建 tasks_lab/run_loops.py。publisher 每次 publish_one() 最多发送一条,recovery 每次 recover_batch()最多50条,循环设置固定间隔并响应TERM;函数自身数据库/网络超时仍需保持任务模块的约束,不能靠事件替代I/O超时。出现异常记录类别并退避。分别创建publisher/recovery服务,沿用worker环境、秘密和工作目录,command 改成 python /app/deploy/run_role.py python run_loops.py publisher 或 recovery;它们不启动 Celery worker。",
+        "code": "import logging\nimport signal\nimport sys\nfrom threading import Event\nfrom publisher import publish_one\nfrom recovery import recover_batch\n\nstop = Event()\nsignal.signal(signal.SIGTERM, lambda *_: stop.set())\nsignal.signal(signal.SIGINT, lambda *_: stop.set())\nfunctions = {\"publisher\": (publish_one, 0.5), \"recovery\": (recover_batch, 5)}\noperation, interval = functions[sys.argv[1]]\nwhile not stop.is_set():\n    try:\n        operation()\n    except Exception as exc:\n        logging.error(\"task maintenance failed: %s\", type(exc).__name__)\n        if stop.wait(5):\n            break\n    stop.wait(interval)",
+        "lang": "python",
+        "filename": "tasks_lab/run_loops.py"
+      },
+      {
+        "title": "初始化任务表、输出权限并构建",
+        "body": "将任务课程的 export_jobs/export_outbox/死信/容量/周期等最终DDL纳入本项目Alembic迁移,先在空练习库验证;不要认为notes迁移自动创建这些任务表。挂载卷首次创建为root时,使用仅针对artifacts_data挂载点的一次性初始化作业 chown 10001:10001 /app/tasks_lab/artifacts,并在worker启动前完成;不可递归修改宿主未知目录。构建新标签,更新 .env.compose NOTES_IMAGE,再启动并核对注册任务和队列。",
+        "code": "docker build -t notes-service:lab-03 .\n# 编辑 .env.compose:NOTES_IMAGE=notes-service:lab-03\ndocker compose --env-file .env.compose run --rm --no-deps worker python -c \"from pathlib import Path; names=['task_database_url','celery_broker_url','celery_result_backend']; assert all(Path('/run/secrets',n).read_text().strip() for n in names); print('secrets readable')\"\ndocker compose --env-file .env.compose run --rm --user 0 --cap-add CHOWN --entrypoint sh worker -c 'chown 10001:10001 /app/tasks_lab/artifacts'\ndocker compose --env-file .env.compose run --rm migrate\ndocker compose --env-file .env.compose up -d worker worker-bulk publisher recovery\ndocker compose --env-file .env.compose exec worker python /app/deploy/run_role.py python -m celery -A celery_app:app inspect registered\ndocker compose --env-file .env.compose logs --tail 50 worker worker-bulk publisher recovery",
+        "lang": "bash"
+      },
+      {
+        "title": "按真实业务链路验收与故障恢复",
+        "body": "先在notes服务创建至少一条练习笔记,然后以下命令从真实notes表选择一条并调用最终state_store.submit_snapshot;它已包含job/outbox原子写入,不调用export_notes.delay。记录返回job_id。publisher将事件路由到exports.fast/bulk,worker写artifacts,最终运行任务第16课verify_terminal.py核对文件与固定快照。暂停worker后提交应queued,恢复后完成。beat仅在第14课周期任务完成后单实例部署。",
+        "code": "docker compose --env-file .env.compose stop worker worker-bulk\ndocker compose --env-file .env.compose run --rm --no-deps worker python /app/deploy/run_role.py python -c \"from state_store import connect,submit_snapshot; conn=connect(); row=conn.execute('SELECT id FROM notes ORDER BY id LIMIT 1').fetchone(); conn.close(); assert row, 'create a note first'; print(submit_snapshot([row['id']]))\"\ndocker compose --env-file .env.compose start worker worker-bulk\ndocker compose --env-file .env.compose exec postgres psql -U postgres -d notes_lab -c \"SELECT id,status,result_ref,attempts FROM export_jobs ORDER BY created_at DESC LIMIT 10;\"\n# 用真实返回的UUID替换JOB_ID;任务完成后验证artifact\ndocker compose --env-file .env.compose exec worker python /app/deploy/run_role.py python verify_terminal.py JOB_ID",
+        "lang": "bash"
+      }
+    ],
+    "checks": [
+      "Celery inspect 注册 notes.export,fast/bulk worker 只消费对应队列。",
+      "PostgreSQL中job/outbox到最终状态与可读取的输出文件一致,不能只凭Celery结果缓存。",
+      "缓存redis与认证broker是不同实例;宿主任务端口6384,容器内部broker:6379。"
+    ],
+    "pitfalls": [
+      {
+        "symptom": "扩容 API 后周期任务重复发送",
+        "cause": "每个 API 容器都启动了 beat 或本地定时器。",
+        "fix": "beat 单独部署单实例或使用有领导选举的调度方案,任务仍需幂等。"
+      },
+      {
+        "symptom": "worker 配了更多并发,数据库连接数翻倍",
+        "cause": "每个执行进程建立自己的池,遗漏 fork 与连接生命周期。",
+        "fix": "按进程模型重新预算,在子进程创建/重置连接资源;不继承已建立连接。"
+      }
+    ],
+    "exercise": {
+      "task": "为什么不能把postgresql+psycopg://连接串原样交给任务模块的psycopg.connect?",
+      "hint": "区分SQLAlchemy方言URL与PostgreSQL驱动DSN。",
+      "answer": "API使用SQLAlchemy URL;任务state_store直接使用psycopg.connect,需要postgresql://或libpq键值DSN。因此配置分成API database_url与task_database_url,端口和账号相同但协议前缀不同。"
+    },
+    "refs": [
+      {
+        "label": "Celery:Worker 管理",
+        "url": "https://docs.celeryq.dev/en/stable/userguide/workers.html"
+      },
+      {
+        "label": "Celery:Redis 注意事项",
+        "url": "https://docs.celeryq.dev/en/stable/getting-started/backends-and-brokers/redis.html"
+      },
+      {
+        "label": "Redis:持久化",
+        "url": "https://redis.io/docs/latest/operate/oss_and_stack/management/persistence/"
+      }
+    ],
+    "production": {
+      "scenario": "API 快速返回任务已提交,worker 可以独立扩缩容和更新。",
+      "decisions": [
+        "任务执行资源与 API 分配分开。",
+        "队列状态重要时建立持久业务记录与可恢复投递流程。"
+      ],
+      "failureDrill": "在独立练习栈暂停broker后提交持久job,核对job/outbox保留;恢复broker和publisher后worker完成且同一业务结果不被重复执行覆盖。",
+      "acceptance": [
+        "真实tasks_lab入口、队列、环境变量和文件路径一致。",
+        "publisher/recovery有独立受控循环,业务状态与输出经数据库核对。"
+      ]
+    }
+  },
+  {
+    "id": "10",
+    "title": "镜像版本、发布回滚与故障验收",
+    "summary": "用不可混淆的版本标签与镜像摘要发布,保留数据库兼容窗口并验证回退行为。",
+    "group": "安全与交付",
+    "objectives": [
+      "记录并部署明确的镜像版本或摘要",
+      "执行迁移在前、流量切换在后的发布流程",
+      "区分应用回滚、数据库回滚与数据恢复"
+    ],
+    "concepts": [
+      {
+        "title": "发布的是已验证镜像",
+        "body": "本机 image ID 与仓库 digest 是不同概念;推送后以仓库名@sha256 选择不可变发布物。标签可便于阅读但可能被覆盖,不能只保存 latest。预发布和生产使用同一已验证摘要,避免在每台机器重建出不同依赖。"
+      },
+      {
+        "title": "Compose 的可用性边界",
+        "body": "单机单副本 Compose 更新通常会有短暂中断,不承诺零停机。需要平滑切换时部署两个版本后由代理切换,或者使用具备滚动与就绪能力的平台;数据库迁移仍须兼容新旧版本。"
+      },
+      {
+        "title": "回滚不等于删数据",
+        "body": "旧镜像能否回退取决于当前数据库结构是否兼容。新增列通常可保留供旧版忽略,删除/重命名/数据语义改变可能阻止回退。不能为了退应用版本就清空数据库卷或机械执行 downgrade。"
+      }
+    ],
+    "steps": [
+      {
+        "title": "记录当前可回退版本",
+        "body": "发布前保存当前 NOTES_IMAGE、迁移版本、配置版本和必要备份。只记录秘密文件的版本标识或来源,不把内容复制进 release-record.md。",
+        "code": "版本:notes-service:release-2026-09-10-01 或实际仓库 digest\n源码提交:实际 SHA\n当前迁移 revision:实际 alembic current\n目标 revision:待发布版本所需 revision\n兼容范围:旧应用可读取/写入的新结构范围\n备份与恢复验证:文件标识、时间、恢复证据\n回退触发:例如连续5分钟错误率超目标;须先定义观测窗口",
+        "lang": "text",
+        "filename": "release-record.md"
+      },
+      {
+        "title": "构建、标记并本机验收",
+        "body": "构建使用第 3 课已验证基础 digest。用户拥有并授权的镜像仓库才可执行登录与推送;课件提供命令模板,不实际向外发布。标签中使用真实版本或 commit,不重复覆盖旧发布标签。 以下命令在Bash/WSL运行,PYTHON_IMAGE必须是第03课保存的完整摘要;开启新终端时先恢复已审核的变量,不能静默回退到浮动标签。",
+        "code": "docker build --build-arg PYTHON_IMAGE=\"${PYTHON_IMAGE:?set verified base digest}\" -t notes-service:release-01 .\ndocker run --rm notes-service:release-01 python -c \"import fastapi, sqlalchemy, psycopg; print('imports ok')\"\n# 已具备目标仓库权限后,替换 REGISTRY/PROJECT 为真实仓库:\n# docker tag notes-service:release-01 REGISTRY/PROJECT/notes-service:release-01\n# docker push REGISTRY/PROJECT/notes-service:release-01\n# docker buildx imagetools inspect REGISTRY/PROJECT/notes-service:release-01",
+        "lang": "bash"
+      },
+      {
+        "title": "先运行唯一迁移作业",
+        "body": "更新 .env.compose 的 NOTES_IMAGE 为目标版本,先在预发布演练下面命令。发布时 run --rm migrate 执行当前目标镜像迁移;API 仍可维持旧版,前提是迁移经过 expand-contract 兼容审核。迁移失败即停止后续发布。",
+        "code": "docker compose --env-file .env.compose run --rm migrate\ndocker compose --env-file .env.compose up -d --no-deps api worker worker-bulk publisher recovery\ndocker compose --env-file .env.compose ps\ndocker compose --env-file .env.compose logs --tail 100 api worker worker-bulk publisher recovery",
+        "lang": "bash"
+      },
+      {
+        "title": "验证业务再决定继续或回退",
+        "body": "检查 /ready、创建读取、旧版本冲突、幂等重试和 真实导出任务;记录实际 HTTP 与数据库结果。失败时先将 NOTES_IMAGE 恢复上一兼容版本,再 up --no-deps api worker,不回退 Redis/Postgres 镜像主版本,也不自动操作数据库迁移。",
+        "code": "# 将 .env.compose 的 NOTES_IMAGE 恢复到已记录的上一兼容版本后:\ndocker compose --env-file .env.compose up -d --no-deps api worker worker-bulk publisher recovery\ndocker compose --env-file .env.compose exec api python -c \"import urllib.request; print(urllib.request.urlopen('http://127.0.0.1:8000/ready',timeout=4).status)\"",
+        "lang": "bash"
+      },
+      {
+        "title": "完成隔离故障矩阵",
+        "body": "在本机专用栈逐项执行,保存预期与实测。数据库数据使用迁移和备份处理,不以删卷作为回滚。超出本机实测范围的远程仓库、TLS、负载均衡明确标记未验证。",
+        "code": "场景 | 观测 | 恢复动作\n错误数据库密码 | readiness失败,认证日志 | 修正秘密并重建API;不删卷\n错误应用镜像 | API启动失败,旧版可用性记录 | 切回兼容镜像\nworker停止 | 队列积压,API仍可提交 | 启动worker,核对任务去重\nOOM演练 | 专用容器OOMKilled | 调整预算并检查宿主余量\n迁移失败 | migrate退出非0 | 停止发布,修复迁移或锁阻塞\n数据库容器重建 | 卷保留,原笔记可读 | 等待ready并执行业务探针",
+        "lang": "text"
+      }
+    ],
+    "checks": [
+      "发布记录包含真实镜像标识、迁移版本和回退边界。",
+      "迁移失败不会继续切换 API 与 worker。",
+      "回退应用镜像后业务探针通过,数据库卷和已提交数据保留。"
+    ],
+    "pitfalls": [
+      {
+        "symptom": "latest 回退后仍运行同一坏版本",
+        "cause": "标签被覆盖,无法指明旧镜像内容。",
+        "fix": "保存不可变版本标签与仓库摘要,部署时以明确摘要选择。"
+      },
+      {
+        "symptom": "应用回退成功但读数据失败",
+        "cause": "新迁移删除或改变了旧应用依赖的字段。",
+        "fix": "发布前验证兼容窗口;不可兼容时执行修复版本或已验证恢复方案,不机械 downgrade。"
+      }
+    ],
+    "exercise": {
+      "task": "怎样证明“回滚方案可用”,而非只写一句“退回旧版本”?",
+      "hint": "在同一新数据库结构上运行旧镜像并发请求。",
+      "answer": "在隔离预发布库先执行新迁移,再部署旧镜像执行读写与幂等探针,记录结果和不可兼容点。若旧版写入破坏新语义,回退窗口应关闭,改用前向修复。"
+    },
+    "refs": [
+      {
+        "label": "Docker:镜像标签",
+        "url": "https://docs.docker.com/reference/cli/docker/image/tag/"
+      },
+      {
+        "label": "Docker:镜像摘要",
+        "url": "https://docs.docker.com/dhi/core-concepts/digests/"
+      },
+      {
+        "label": "Docker:Compose up",
+        "url": "https://docs.docker.com/reference/cli/docker/compose/up/"
+      }
+    ],
+    "production": {
+      "scenario": "一次发布必须能够定位软件版本,并在已声明兼容范围内回退。",
+      "decisions": [
+        "构建一次并以明确版本交付。",
+        "应用回退与数据库恢复分开决策,保持数据卷。"
+      ],
+      "failureDrill": "在本机发布一个故意缺配置的应用版本,确认错误后回到上一镜像并核对既有数据。",
+      "acceptance": [
+        "有实际回退耗时和业务恢复结果。",
+        "未验证的仓库、远程部署和高可用能力明确标记。"
+      ]
+    }
+  }
+]

+ 1073 - 0
public/content/linux.json

@@ -0,0 +1,1073 @@
+[
+  {
+    "id": "01",
+    "group": "主机与进程",
+    "title": "SSH、账户与服务目录",
+    "summary": "在独立 Ubuntu 24.04 练习虚拟机上建立管理入口与低权限运行账户。",
+    "objectives": [
+      "核对 SSH 目标与主机密钥指纹",
+      "区分登录管理账号与不可登录服务账号",
+      "建立发布目录和可写业务目录的权限边界"
+    ],
+    "concepts": [
+      {
+        "title": "练习环境必须明确",
+        "body": "本模块只在自己控制、可从控制台恢复的独立 Ubuntu 24.04 VM 执行。示例主机 notes-vm、IP 与域名均需替换成真实练习目标;不在已有生产主机套用整段账户、防火墙和服务配置。先保存VM快照并确认带外控制台可用。"
+      },
+      {
+        "title": "两个账号的职责",
+        "body": "deploy 用 SSH 登录并执行经过核对的 sudo 操作,notes 只运行应用,shell 为 nologin,不保存 SSH 私钥。应用被利用后不应具备替换代码、修改 systemd 单元或读取其他服务秘密的权限。"
+      },
+      {
+        "title": "目录所有权",
+        "body": "/srv/notes-service/releases 保存不可由运行用户修改的版本;current 指向当前版本;shared 保存需要跨版本保留的导出文件。代码、配置和业务输出分开授权,不能对整个 /srv 递归 chmod 777。"
+      }
+    ],
+    "steps": [
+      {
+        "title": "确认系统与目标",
+        "body": "通过虚拟机控制台或已验证的管理 SSH 会话执行。记录 hostname、Ubuntu 版本和 SSH 主机公钥指纹,再从本机连接时对照。不要用 StrictHostKeyChecking=no 隐藏主机变化。",
+        "code": "hostnamectl\ncat /etc/os-release\nsudo ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub\nwhoami",
+        "lang": "bash"
+      },
+      {
+        "title": "在本机生成独立登录密钥",
+        "body": "在自己的电脑执行,并使用口令保护私钥。路径为本次练习新文件,若已存在不要覆盖。将 .pub 公钥交给VM管理员安装,私钥不复制到服务器。",
+        "code": "ssh-keygen -t ed25519 -f ~/.ssh/notes_lab_ed25519 -C notes-lab\n# 核对指纹后连接;VM_IP替换成实际练习地址\n# 完成下一步deploy账户与authorized_keys配置后,才执行:\n# ssh -i ~/.ssh/notes_lab_ed25519 deploy@VM_IP",
+        "lang": "bash"
+      },
+      {
+        "title": "在虚拟机建立管理和运行账户",
+        "body": "使用现有管理账号执行,adduser deploy 会交互设置管理账号。用编辑器将刚生成的公钥内容写入 /home/deploy/.ssh/authorized_keys,再设置文件权限;不要粘贴私钥。notes 无交互登录权限。",
+        "code": "sudo adduser deploy\nsudo usermod -aG sudo deploy\nsudo install -d -o deploy -g deploy -m 700 /home/deploy/.ssh\nsudoedit /home/deploy/.ssh/authorized_keys\nsudo chown deploy:deploy /home/deploy/.ssh/authorized_keys\nsudo chmod 600 /home/deploy/.ssh/authorized_keys\nsudo useradd --system --user-group --home-dir /srv/notes-service --shell /usr/sbin/nologin notes",
+        "lang": "bash"
+      },
+      {
+        "title": "建立明确目录边界",
+        "body": "releases 只有管理员可写,notes 组可读和进入;shared/artifacts 给运行账号写。部署文件后仍需核对权限,不能因为目录存在就把所有文件设可执行。",
+        "code": "sudo install -d -o root -g notes -m 750 /srv/notes-service\nsudo install -d -o root -g notes -m 750 /srv/notes-service/releases\nsudo install -d -o root -g notes -m 750 /srv/notes-service/shared\nsudo install -d -o notes -g notes -m 750 /srv/notes-service/shared/artifacts\nsudo -u notes test -w /srv/notes-service/shared/artifacts\nsudo -u notes test ! -w /srv/notes-service/releases",
+        "lang": "bash"
+      },
+      {
+        "title": "验证第二个会话再收紧 SSH",
+        "body": "保留原管理会话和控制台,从本机新开 deploy 密钥会话并运行 sudo -v。成功后才考虑在 /etc/ssh/sshd_config.d/60-notes-lab.conf 写入下面配置。先 sshd -t,再 reload;已有配置和云初始化可能覆盖行为,sshd -T 检查实际值。 第二个会话使用 ssh -i ~/.ssh/notes_lab_ed25519 deploy@VM_IP;账户和公钥在前一步已配置好。",
+        "code": "# /etc/ssh/sshd_config.d/60-notes-lab.conf\nPubkeyAuthentication yes\nPasswordAuthentication no\nPermitRootLogin no\n\n# 保存后在原管理会话执行\n# sudo sshd -t\n# sudo systemctl reload ssh\n# sudo sshd -T | rg 'passwordauthentication|permitrootlogin'",
+        "lang": "text",
+        "filename": "/etc/ssh/sshd_config.d/60-notes-lab.conf"
+      }
+    ],
+    "checks": [
+      "从新终端能用独立密钥登录 deploy 并执行 sudo -v。",
+      "notes 不能交互登录,不能修改 releases,但可写 shared/exports。",
+      "SSH 配置先校验再 reload,保留原会话和控制台恢复路径。"
+    ],
+    "pitfalls": [
+      {
+        "symptom": "修改 SSH 后新连接失败",
+        "cause": "公钥权限错误、配置顺序覆盖或改动未经新会话验证。",
+        "fix": "使用保留会话或控制台恢复该练习配置,检查 auth 日志与 sshd -T,再用新终端验证。"
+      },
+      {
+        "symptom": "服务账号能改 Dockerfile 或 systemd 单元",
+        "cause": "代码和系统配置错误归运行用户所有。",
+        "fix": "将发布物与单元归 root 管理,只允许 notes 写特定输出目录。"
+      }
+    ],
+    "exercise": {
+      "task": "为什么不能让 Web 服务直接使用 deploy 账号运行?",
+      "hint": "deploy 具备管理能力与登录凭据。",
+      "answer": "应用漏洞会扩大到发布权限或 sudo 能力。独立 notes 账号将运行权限限制在服务需要的配置、网络和输出目录,deploy 只用于受控部署。"
+    },
+    "refs": [
+      {
+        "label": "Ubuntu:OpenSSH",
+        "url": "https://ubuntu.com/server/docs/how-to/security/openssh-server/"
+      },
+      {
+        "label": "OpenSSH:ssh-keygen",
+        "url": "https://man.openbsd.org/ssh-keygen"
+      },
+      {
+        "label": "Ubuntu:用户管理",
+        "url": "https://documentation.ubuntu.com/server/how-to/security/user-management/"
+      }
+    ],
+    "production": {
+      "scenario": "服务长期运行在远程主机,必须保留可靠管理入口并限制应用写权限。",
+      "decisions": [
+        "管理账号与服务账号分离。",
+        "SSH变更先验证新会话,再关闭旧入口。"
+      ],
+      "failureDrill": "在隔离VM故意给一份测试authorized_keys设置错误权限,观察新会话失败,用保留会话修复;不对真实唯一入口试错。",
+      "acceptance": [
+        "密钥指纹与权限有记录。",
+        "账户错误可通过预留入口恢复。"
+      ]
+    }
+  },
+  {
+    "id": "02",
+    "group": "主机与进程",
+    "title": "监听地址、DNS 与防火墙",
+    "summary": "从进程监听到云网络逐层检查连通性,在开启防火墙前验证 SSH 保留与回退。",
+    "objectives": [
+      "区分服务监听、主机防火墙与云安全组",
+      "使用DNS和TCP检查定位访问失败",
+      "安全地测试UFW规则并保留SSH会话"
+    ],
+    "concepts": [
+      {
+        "title": "网络路径有多层",
+        "body": "客户端先解析DNS,连接公网或私网IP,经云安全组/路由到主机防火墙,再到监听端口。服务没监听时放开防火墙没有作用;DNS解析正确也不能证明端口可达。"
+      },
+      {
+        "title": "公开端口最小化",
+        "body": "Nginx对外提供80/443,API只监听127.0.0.1:8000;PostgreSQL与Redis继续使用本机回环调试端口或受控私网。Docker发布端口可能经过不同于普通INPUT的转发规则,不能假定UFW会拦截所有Docker ports。"
+      },
+      {
+        "title": "防止锁死",
+        "body": "SSH端口可能不是22,来源IP也可能经过VPN或跳板机。启用UFW前读取实际SSH监听、添加保留规则、准备限时自动回退,并保留当前会话和控制台。关闭旧会话不是验证新规则的方法。"
+      }
+    ],
+    "steps": [
+      {
+        "title": "核对当前监听与路由",
+        "body": "在VM执行只读命令;没有rg则用grep。识别真实SSH端口与监听地址,后续规则使用实际值,不盲目套22。",
+        "code": "sudo ss -lntp\nip -brief address\nip route\nsudo ufw status verbose",
+        "lang": "bash"
+      },
+      {
+        "title": "检查域名解析与本机端点",
+        "body": "尚无域名时用VM IP或本机Host头完成后续HTTP课程。notes.example.com为文档占位,不能注册证书。DNS A与AAAA都应指向能提供服务的地址,错误AAAA可能导致部分用户访问失败。",
+        "code": "getent ahosts notes.example.com\n# API启动后再执行:\ncurl --connect-timeout 2 --max-time 5 -i http://127.0.0.1:8000/ready",
+        "lang": "bash"
+      },
+      {
+        "title": "先添加SSH和HTTP规则",
+        "body": "仅在已确认SSH为22且有控制台的独立VM执行。若使用非22端口,修改下列规则。已有UFW规则不要 reset;先阅读并保留其他必要访问。自动回退任务3分钟后关闭UFW,只用于这次隔离练习。",
+        "code": "sudo ufw allow 22/tcp comment 'keep verified SSH'\nsudo ufw allow 80/tcp comment 'HTTP and ACME'\nsudo ufw allow 443/tcp comment 'HTTPS'\nsudo systemd-run --unit=notes-firewall-rollback --on-active=3m /usr/sbin/ufw disable\nsudo ufw default deny incoming\nsudo ufw default allow outgoing\nsudo ufw enable",
+        "lang": "bash"
+      },
+      {
+        "title": "用新会话验证后取消回退",
+        "body": "保持旧会话,在本机另开SSH并执行一个只读命令。成功后检查UFW状态,取消尚未执行的回退timer;若失败,不继续收紧规则,使用保留会话或等待自动回退。",
+        "code": "# 新SSH会话成功后执行\nsudo ufw status numbered\nsudo systemctl stop notes-firewall-rollback.timer\nsudo systemctl status notes-firewall-rollback.timer --no-pager",
+        "lang": "bash"
+      },
+      {
+        "title": "核对Docker与云侧规则",
+        "body": "若依赖运行在Docker,docker ps检查端口只能为127.0.0.1:5433、缓存6380、任务broker6384。云安全组按本练习仅开放已验证SSH和80/443;此课不代替在真实云账号创建规则。",
+        "code": "docker ps --format 'table {{.Names}}\t{{.Ports}}'\nsudo ss -lntp\n# 从另一台获准练习客户端检测VM的80/443;数据库端口不应对公网开放。",
+        "lang": "bash"
+      }
+    ],
+    "checks": [
+      "新SSH会话在UFW启用后成功,回退timer已确认取消。",
+      "API/数据库/Redis监听地址与外部公开端口列表一致。",
+      "DNS、TCP、HTTP失败可以分层描述,未对数据库开放公网入口。"
+    ],
+    "pitfalls": [
+      {
+        "symptom": "本机curl成功,外部浏览器超时",
+        "cause": "云安全组、主机防火墙或监听绑定地址阻断。",
+        "fix": "按路径逐层检查,不同时关闭全部安全规则掩盖问题。"
+      },
+      {
+        "symptom": "UFW拒绝端口但Docker服务仍能被外部访问",
+        "cause": "Docker发布端口涉及NAT/转发规则。",
+        "fix": "优先取消公开发布或绑定回环,按Docker官方防火墙集成文档核对实际路径。"
+      }
+    ],
+    "exercise": {
+      "task": "域名同时有A和AAAA记录,只检查IPv4是否足够?",
+      "hint": "客户端可能优先选择IPv6。",
+      "answer": "不够。需要验证两种地址都可到达正确服务;未配置IPv6服务时移除错误AAAA,不能让部分用户随机遇到超时。"
+    },
+    "refs": [
+      {
+        "label": "Ubuntu:防火墙",
+        "url": "https://ubuntu.com/server/docs/security-firewall/"
+      },
+      {
+        "label": "Docker:防火墙与UFW",
+        "url": "https://docs.docker.com/engine/network/packet-filtering-firewalls/"
+      },
+      {
+        "label": "systemd-run:临时任务(Ubuntu 24.04)",
+        "url": "https://manpages.ubuntu.com/manpages/noble/man1/systemd-run.1.html"
+      }
+    ],
+    "production": {
+      "scenario": "只公开HTTPS入口,数据库与队列保留在内网或本机。",
+      "decisions": [
+        "防火墙变更保留已验证SSH路径与自动回退。",
+        "主机和云网络规则分别核对,Docker端口单独检查。"
+      ],
+      "failureDrill": "在隔离VM启用规则并从新会话验证;若配置错误,使用定时回退或控制台恢复。",
+      "acceptance": [
+        "没有失去管理入口。",
+        "公开端口清单符合服务架构。"
+      ]
+    }
+  },
+  {
+    "id": "03",
+    "group": "主机与进程",
+    "title": "Python 环境、配置与最小权限",
+    "summary": "把验证过的源码安装到独立发布目录,使用系统支持的Python与专用环境文件启动。",
+    "objectives": [
+      "在发布目录创建独立venv并安装锁定依赖",
+      "区分API与任务驱动的数据库连接串",
+      "让运行账号只读代码和配置,单独写输出"
+    ],
+    "concepts": [
+      {
+        "title": "Ubuntu默认Python版本",
+        "body": "Ubuntu24.04通常提供Python3.12,满足课程Python3.11+要求;不要为了匹配旧命令替换系统/usr/bin/python3。若必须精确3.11,使用经过组织批准的运行时或Docker3.11镜像,并分别生成目标版本依赖锁。"
+      },
+      {
+        "title": "部署输入完整",
+        "body": "发布物要包含app、迁移、锁文件和最终tasks_lab,而不是旧api-lab下载包。任务模块的DDL需纳入迁移并在空库验证,任务入口为tasks_lab/celery_app.py中的app,队列为exports.fast/bulk。"
+      },
+      {
+        "title": "环境文件不是Shell脚本",
+        "body": "systemd EnvironmentFile读取KEY=value,不执行$(command)或source逻辑。API使用SQLAlchemy URL,任务直接psycopg使用PostgreSQL DSN,分别存文件。秘密文件root管理,运行用户通过所属组只读,诊断时不输出全文。"
+      }
+    ],
+    "steps": [
+      {
+        "title": "安装Ubuntu提供的运行依赖",
+        "body": "只在独立VM安装包;升级已有生产主机应走维护流程。本模块进程主线使用Ubuntu默认python3,不安装到系统site-packages。若数据库继续使用Docker,宿主只安装postgresql-client用于检查与备份。",
+        "code": "sudo apt update\nsudo apt install -y python3 python3-venv nginx postgresql-client curl rsync\npython3 --version",
+        "lang": "bash"
+      },
+      {
+        "title": "部署已验证发布物",
+        "body": "将本地完成的notes-service源码包上传到deploy自己的staging目录,先校验文件清单与摘要。不要以root解压不明tar。下列命令假定~/staging/notes-service已是经检查的目录;r001是本次练习明确版本,不能覆盖已有目录。",
+        "code": "sudo install -d -o root -g notes -m 750 /srv/notes-service/releases/r001\nsudo rsync -a --exclude=.env --exclude='.env.*' --exclude=.venv --exclude=secrets --exclude=backups ~/staging/notes-service/ /srv/notes-service/releases/r001/\nsudo chown -R root:notes /srv/notes-service/releases/r001\nsudo chmod -R u=rwX,g=rX,o= /srv/notes-service/releases/r001\nsudo python3 -m venv /srv/notes-service/releases/r001/.venv\nsudo /srv/notes-service/releases/r001/.venv/bin/python -m pip install --require-hashes -r /srv/notes-service/releases/r001/requirements-lock.txt",
+        "lang": "bash"
+      },
+      {
+        "title": "建立两个专用环境文件",
+        "body": "用sudoedit创建下列文件,填VM实际依赖地址与URL编码密码。若沿用Docker练习依赖:PG宿主5433,缓存6380,任务broker6384;若迁移到受控私网数据库按实际地址改,绝不能在主机进程中使用Docker服务名postgres/broker。",
+        "code": "# /etc/notes-service.env(API)\nDATABASE_URL=postgresql+psycopg://app_user:ENCODED_PASSWORD@127.0.0.1:5433/notes_lab\nDB_POOL_SIZE=5\nDB_MAX_OVERFLOW=0\nDB_POOL_TIMEOUT=2\n\n# /etc/notes-tasks.env(tasks_lab)\nDATABASE_URL=postgresql://app_user:ENCODED_PASSWORD@127.0.0.1:5433/notes_lab\nCELERY_BROKER_URL=redis://:ENCODED_TASK_BROKER_PASSWORD@127.0.0.1:6384/0\nCELERY_RESULT_BACKEND=redis://:ENCODED_TASK_BROKER_PASSWORD@127.0.0.1:6384/1",
+        "lang": "dotenv"
+      },
+      {
+        "title": "限制配置和输出权限",
+        "body": "先确认文件是刚编辑的两个目标,再设置所有权。任务工程默认相对artifacts目录改为指向shared/artifacts的软链;已有目录若包含结果先核对并迁移,不能直接rm -rf。新发布物应不包含旧导出文件。",
+        "code": "sudo chown root:notes /etc/notes-service.env /etc/notes-tasks.env\nsudo chmod 640 /etc/notes-service.env /etc/notes-tasks.env\nsudo ln -s /srv/notes-service/shared/artifacts /srv/notes-service/releases/r001/tasks_lab/artifacts\nsudo ln -s releases/r001 /srv/notes-service/current\nsudo -u notes test -r /etc/notes-service.env\nsudo -u notes test ! -w /srv/notes-service/releases/r001/app/main.py\nsudo -u notes test -w /srv/notes-service/shared/artifacts",
+        "lang": "bash"
+      },
+      {
+        "title": "检查环境与导入",
+        "body": "不source秘密文件到共享交互终端。下面只验证包和文件路径;带环境启动由下一课systemd读取。若依赖锁与Python版本不兼容,返回目标Linux/Python版本重新生成并验证锁,不去掉固定版本强行安装。",
+        "code": "sudo -u notes /srv/notes-service/current/.venv/bin/python -c \"import sys, fastapi, psycopg; print(sys.version); print(fastapi.__version__, psycopg.__version__)\"\nsudo -u notes readlink -f /srv/notes-service/current\nsudo namei -l /srv/notes-service/current/app/main.py",
+        "lang": "bash"
+      }
+    ],
+    "checks": [
+      "API与任务环境文件使用不同DSN前缀,宿主端口与实际依赖一致。",
+      "notes可读配置但不能写发布代码,导出路径指向shared。",
+      "venv来自当前VM支持的Python,锁定依赖安装和导入成功。"
+    ],
+    "pitfalls": [
+      {
+        "symptom": "直接psycopg报invalid connection option或scheme",
+        "cause": "将SQLAlchemy的postgresql+psycopg://交给直接驱动。",
+        "fix": "任务环境使用postgresql://,API保留SQLAlchemy方言URL。"
+      },
+      {
+        "symptom": "systemd找不到包但登录终端能导入",
+        "cause": "服务未使用venv解释器,或发布路径不一致。",
+        "fix": "ExecStart指定绝对venv Python路径,不依赖交互shell激活。"
+      }
+    ],
+    "exercise": {
+      "task": "为什么不能在服务器执行sudo pip install -U覆盖系统Python依赖?",
+      "hint": "Ubuntu工具也可能依赖系统Python。",
+      "answer": "这样会污染系统包管理和其他应用。每个发布目录使用独立venv,升级形成新发布物,系统Python由发行版包管理维护。"
+    },
+    "refs": [
+      {
+        "label": "Python:venv",
+        "url": "https://docs.python.org/3/library/venv.html"
+      },
+      {
+        "label": "Ubuntu:Python开发环境",
+        "url": "https://documentation.ubuntu.com/ubuntu-for-developers/howto/python-setup/"
+      },
+      {
+        "label": "systemd:EnvironmentFile(Ubuntu 24.04)",
+        "url": "https://manpages.ubuntu.com/manpages/noble/man5/systemd.exec.5.html"
+      }
+    ],
+    "production": {
+      "scenario": "服务器重启后进程需要用确定的解释器、代码和秘密恢复。",
+      "decisions": [
+        "发布目录不可由服务修改。",
+        "API与任务配置分开,秘密不进入源码包。"
+      ],
+      "failureDrill": "用notes账号尝试写代码文件应失败,写shared/exports应成功;误配DSN在隔离环境修复后再启动。",
+      "acceptance": [
+        "部署输入和权限边界可复现。",
+        "无全局pip安装和明文秘密日志。"
+      ]
+    }
+  },
+  {
+    "id": "04",
+    "group": "主机与进程",
+    "title": "systemd 管理 API、Worker 与维护进程",
+    "summary": "把服务交给systemd启动、重启和终止,明确任务循环与单实例调度器。",
+    "objectives": [
+      "编写可验证的systemd单元",
+      "分别管理API、fast/bulk worker、publisher/recovery",
+      "设置重启节流、资源限制和SIGTERM退出预算"
+    ],
+    "concepts": [
+      {
+        "title": "进程监督",
+        "body": "systemd知道主进程PID、退出码和重启次数,可以开机启动并收集日志。Type=simple服务应前台运行,不nohup、不后台&。Wants network-online不是数据库一定可用,应用仍需有界连接超时。"
+      },
+      {
+        "title": "真实任务入口",
+        "body": "worker工作目录为current/tasks_lab,命令python -m celery -A celery_app:app;fast/bulk分别订阅exports.fast与exports.bulk。publisher.py/recovery.py本身只执行一次,需要Docker模块提供的run_loops.py或同等有界循环,不能将一次性脚本伪称常驻服务。"
+      },
+      {
+        "title": "重启与资源",
+        "body": "Restart=on-failure适合异常退出重启,StartLimit限制崩溃循环。MemoryMax、CPUQuota、TasksMax是cgroup边界,不是性能承诺。Celery warm shutdown可能等待当前任务,TimeoutStopSec应覆盖已定义的任务最大时长,仍需恢复租约处理强杀。"
+      }
+    ],
+    "steps": [
+      {
+        "title": "新建API服务单元",
+        "body": "保存到/etc/systemd/system/notes-api.service。使用Uvicorn内置进程主线,未采用已弃用的uvicorn.workers。API只监听回环,代理头仅信任本机Nginx。数据库迁移不放进此单元ExecStartPre。",
+        "code": "[Unit]\nDescription=notes-service API\nWants=network-online.target\nAfter=network-online.target\nStartLimitIntervalSec=60\nStartLimitBurst=5\n\n[Service]\nType=simple\nUser=notes\nGroup=notes\nWorkingDirectory=/srv/notes-service/current\nEnvironmentFile=/etc/notes-service.env\nEnvironment=PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1\nExecStart=/srv/notes-service/current/.venv/bin/python -m uvicorn app.main:app --host 127.0.0.1 --port 8000 --proxy-headers --forwarded-allow-ips=127.0.0.1 --timeout-graceful-shutdown 25\nRestart=on-failure\nRestartSec=3\nTimeoutStopSec=35\nKillSignal=SIGTERM\nMemoryMax=512M\nCPUQuota=100%\nTasksMax=128\nNoNewPrivileges=true\nPrivateTmp=true\nProtectSystem=strict\nProtectHome=true\nUMask=0027\n\n[Install]\nWantedBy=multi-user.target",
+        "lang": "ini",
+        "filename": "/etc/systemd/system/notes-api.service"
+      },
+      {
+        "title": "新建fast/bulk Worker模板",
+        "body": "保存notes-worker@.service,实例参数fast和bulk映射两个既定队列。共享输出需要ReadWritePaths;先验证共享目录存在。实际任务上限若超过60秒,应先调整任务协议与退出预算,不照抄75秒。",
+        "code": "[Unit]\nDescription=notes-service %i export worker\nWants=network-online.target\nAfter=network-online.target\nStartLimitIntervalSec=60\nStartLimitBurst=5\n\n[Service]\nType=simple\nUser=notes\nGroup=notes\nWorkingDirectory=/srv/notes-service/current/tasks_lab\nEnvironmentFile=/etc/notes-tasks.env\nEnvironment=PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1\nExecStart=/srv/notes-service/current/.venv/bin/python -m celery -A celery_app:app worker -l INFO -Q exports.%i --concurrency=1 -n %i@%%h\nRestart=on-failure\nRestartSec=5\nTimeoutStopSec=75\nKillSignal=SIGTERM\nKillMode=mixed\nMemoryMax=768M\nCPUQuota=100%\nTasksMax=128\nNoNewPrivileges=true\nPrivateTmp=true\nProtectSystem=strict\nProtectHome=true\nReadWritePaths=/srv/notes-service/shared/artifacts\nUMask=0027\n\n[Install]\nWantedBy=multi-user.target",
+        "lang": "ini",
+        "filename": "/etc/systemd/system/notes-worker@.service"
+      },
+      {
+        "title": "为publisher/recovery创建独立模板",
+        "body": "需要Docker第9课已给出的tasks_lab/run_loops.py,或复制那份受控循环到发布物。模板传publisher/recovery选择单步函数;不把维护任务与worker塞入同进程。EnvironmentFile仍为tasks专用,日志仅记录异常类别与关联id。",
+        "code": "[Unit]\nDescription=notes-service %i loop\nWants=network-online.target\nAfter=network-online.target\nStartLimitIntervalSec=60\nStartLimitBurst=5\n\n[Service]\nType=simple\nUser=notes\nGroup=notes\nWorkingDirectory=/srv/notes-service/current/tasks_lab\nEnvironmentFile=/etc/notes-tasks.env\nEnvironment=PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1\nExecStart=/srv/notes-service/current/.venv/bin/python run_loops.py %i\nRestart=on-failure\nRestartSec=5\nTimeoutStopSec=20\nMemoryMax=256M\nTasksMax=64\nNoNewPrivileges=true\nProtectSystem=strict\nProtectHome=true\nPrivateTmp=true\nUMask=0027\n\n[Install]\nWantedBy=multi-user.target",
+        "lang": "ini",
+        "filename": "/etc/systemd/system/notes-maintenance@.service"
+      },
+      {
+        "title": "可选新增单实例beat",
+        "body": "只有完成任务模块周期任务后才启用。沿用Worker单元但Description改为notes-service beat、ExecStart替换如下、移除原ReadWritePaths并加StateDirectory=notes-service。保存独立notes-beat.service,只运行一个实例;本地调度文件不是分布式领导选举。",
+        "code": "ExecStart=/srv/notes-service/current/.venv/bin/python -m celery -A celery_app:app beat -l INFO --schedule=/var/lib/notes-service/celerybeat-schedule\nStateDirectory=notes-service\nReadWritePaths=/var/lib/notes-service",
+        "lang": "ini",
+        "filename": "/etc/systemd/system/notes-beat.service"
+      },
+      {
+        "title": "验证配置并按顺序启动",
+        "body": "先完成数据库模块和任务模块最终DDL迁移,数据库及认证broker必须可达。systemd-analyze verify可发现字段/路径问题,不能代替真实数据库验收;readiness与真实任务完成后再enable开机启动。",
+        "code": "sudo systemd-analyze verify /etc/systemd/system/notes-api.service /etc/systemd/system/notes-worker@.service /etc/systemd/system/notes-maintenance@.service\nsudo systemctl daemon-reload\nsudo systemctl start notes-api notes-worker@fast notes-worker@bulk notes-maintenance@publisher notes-maintenance@recovery\nsudo systemctl status notes-api notes-worker@fast --no-pager\ncurl --fail --max-time 5 http://127.0.0.1:8000/ready\nsudo systemctl enable notes-api notes-worker@fast notes-worker@bulk notes-maintenance@publisher notes-maintenance@recovery",
+        "lang": "bash"
+      }
+    ],
+    "checks": [
+      "API和各任务进程有独立unit、PID与日志,退出原因可查询。",
+      "Celery节点名展开正确,fast/bulk只消费对应队列。",
+      "SIGTERM后进程在预算内退出,超过预算的业务任务按租约恢复协议处理。"
+    ],
+    "pitfalls": [
+      {
+        "symptom": "systemd启动立即报找不到celery_app",
+        "cause": "WorkingDirectory未指向tasks_lab或源文件未部署。",
+        "fix": "核对最终任务工程路径及绝对venv,不能靠登录终端PYTHONPATH临时修复。"
+      },
+      {
+        "symptom": "节点名出现错误或systemd拒绝ExecStart",
+        "cause": "systemd把%h当成自身specifier展开。",
+        "fix": "传给Celery的%h需在unit中写%%h;%i保留用于systemd实例名。"
+      }
+    ],
+    "exercise": {
+      "task": "数据库短暂故障时要不要让API进程退出,以便systemd不断重启?",
+      "hint": "所有实例依赖同一数据库时会发生什么?",
+      "answer": "通常不应把readiness失败直接变成所有进程退出。API保持可观测并返回503,修复依赖后恢复;只有进程自身无法继续工作才退出。重启策略设置节流,避免掩盖真实故障。"
+    },
+    "refs": [
+      {
+        "label": "systemd:service(Ubuntu 24.04)",
+        "url": "https://manpages.ubuntu.com/manpages/noble/man5/systemd.service.5.html"
+      },
+      {
+        "label": "systemd:资源控制(Ubuntu 24.04)",
+        "url": "https://manpages.ubuntu.com/manpages/noble/man5/systemd.resource-control.5.html"
+      },
+      {
+        "label": "Celery:Worker关闭",
+        "url": "https://docs.celeryq.dev/en/stable/userguide/workers.html"
+      },
+      {
+        "label": "Uvicorn:部署",
+        "url": "https://uvicorn.dev/deployment/"
+      }
+    ],
+    "production": {
+      "scenario": "主机重启与应用崩溃后,各角色需要按各自预算恢复运行。",
+      "decisions": [
+        "监督前台主进程,迁移独立执行。",
+        "任务维护循环和调度器与worker分开,beat单实例。"
+      ],
+      "failureDrill": "在独立VM正常stop/start API与一组worker,核对日志、任务状态和恢复时间。",
+      "acceptance": [
+        "开机启动可验证。",
+        "重启不会无界循环或静默丢失业务状态。"
+      ]
+    }
+  },
+  {
+    "id": "05",
+    "group": "代理与发布",
+    "title": "Nginx HTTP 入口与 TLS",
+    "summary": "先把回环API接到Nginx,再在拥有域名与DNS控制权时配置受信任HTTPS。",
+    "objectives": [
+      "建立可验证的HTTP反向代理配置",
+      "设置请求体与上游超时边界",
+      "在明确域名与证书前置条件下配置TLS续期"
+    ],
+    "concepts": [
+      {
+        "title": "代理负责公网连接",
+        "body": "Nginx终止客户端连接,将HTTP转发到127.0.0.1:8000。API不直接公开端口,转发Host与真实协议;只有可信代理可以影响应用识别的客户端地址与HTTPS状态。"
+      },
+      {
+        "title": "错误发生的层级",
+        "body": "502通常表示上游连接或协议失败,504通常表示等待上游超时,413来自请求体限制。应结合Nginx error log与API request_id判断,不能看到所有5xx都重启数据库。"
+      },
+      {
+        "title": "证书需要真实控制权",
+        "body": "notes.example.com是占位名,不能申请证书。只有持有实际域名、DNS正确指向练习主机且验证路径可达时,才使用Certbot完成ACME。没有这些条件时先用HTTP本机探针学习代理,不关闭客户端TLS校验伪造完成。"
+      }
+    ],
+    "steps": [
+      {
+        "title": "新建站点配置",
+        "body": "保存/etc/nginx/sites-available/notes-service。在独立VM确认没有同名站点或80端口冲突;server_name先用文档占位配合本机Host验证,正式申请证书前替换为自己持有的域名。",
+        "code": "server {\n    listen 80;\n    server_name notes.example.com;\n    client_max_body_size 2m;\n    access_log /var/log/nginx/notes-access.log;\n    error_log /var/log/nginx/notes-error.log warn;\n\n    location / {\n        proxy_pass http://127.0.0.1:8000;\n        proxy_http_version 1.1;\n        proxy_set_header Host $host;\n        proxy_set_header X-Real-IP $remote_addr;\n        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n        proxy_set_header X-Forwarded-Proto $scheme;\n        proxy_connect_timeout 3s;\n        proxy_read_timeout 30s;\n        proxy_send_timeout 30s;\n    }\n}",
+        "lang": "nginx",
+        "filename": "/etc/nginx/sites-available/notes-service"
+      },
+      {
+        "title": "校验再加载站点",
+        "body": "只新建本次站点软链,不删除现有默认或其他用户站点。nginx -t失败时不reload,修复配置后重试。用Host头选中本次server块,验证/ready与/notes。",
+        "code": "sudo ln -s /etc/nginx/sites-available/notes-service /etc/nginx/sites-enabled/notes-service\nsudo nginx -t\nsudo systemctl reload nginx\ncurl --fail --max-time 5 -H 'Host: notes.example.com' http://127.0.0.1/ready\nsudo tail -n 30 /var/log/nginx/notes-error.log",
+        "lang": "bash"
+      },
+      {
+        "title": "具备真实域名后申请证书",
+        "body": "此步骤需要实际域名控制权和公网验证条件,本课不替用户注册域名或申请证书。按Certbot官网Ubuntu/Nginx当前安装说明准备工具,替换YOUR_DOMAIN后执行;先验证DNS与HTTP访问。签发会联系外部CA并修改站点配置,应在自己的练习域名操作。",
+        "code": "# 前提已完成:真实域名DNS/80端口验证、Certbot安装、Nginx配置校验\nsudo certbot --nginx -d YOUR_DOMAIN\nsudo nginx -t\nsudo certbot certificates",
+        "lang": "bash"
+      },
+      {
+        "title": "验证TLS与续期",
+        "body": "使用真实域名而不是curl -k检查证书主机名、链与有效期。检查系统中的续期timer,执行dry-run验证续期流程;测试必须保留ACME挑战访问或对应DNS验证能力。",
+        "code": "curl --fail --max-time 10 https://YOUR_DOMAIN/ready\nsudo certbot renew --dry-run\nsystemctl list-timers --all | grep -E 'certbot|snap.certbot'",
+        "lang": "bash"
+      },
+      {
+        "title": "演练上游失败并确认日志",
+        "body": "仅在隔离VM停API,Nginx应返回网关错误,同时error log报告上游连接失败;启动API后恢复。不要把异常SQL、密码或Authorization记录进Nginx自定义日志。",
+        "code": "sudo systemctl stop notes-api\ncurl --max-time 5 -i -H 'Host: notes.example.com' http://127.0.0.1/ready\nsudo tail -n 20 /var/log/nginx/notes-error.log\nsudo systemctl start notes-api",
+        "lang": "bash"
+      }
+    ],
+    "checks": [
+      "HTTP代理能到达回环API,nginx -t通过。",
+      "真实域名条件具备时,HTTPS无需跳过校验且续期dry-run成功;否则明确未配置TLS。",
+      "停API产生可定位的502类上游错误,恢复后/ready成功。"
+    ],
+    "pitfalls": [
+      {
+        "symptom": "Nginx默认欢迎页而非API",
+        "cause": "请求Host没有匹配server_name,或站点链接未加载。",
+        "fix": "用nginx -T在可信终端定位server块,检查Host和软链;不要随意删除所有默认配置。"
+      },
+      {
+        "symptom": "证书首次申请成功,数月后过期",
+        "cause": "续期任务未运行或验证路径被防火墙/代理改动阻断。",
+        "fix": "检查timer、续期日志与dry-run,证书剩余天数加入监控。"
+      }
+    ],
+    "exercise": {
+      "task": "把client_max_body_size调到1GB能否解决所有上传需求?",
+      "hint": "应用、代理、磁盘和超时仍有各自限制。",
+      "answer": "不能。应按业务定义大小、流式处理和对象存储上传方案,避免单请求在API内存中聚集大文件;代理限制必须与应用校验和资源预算一致。"
+    },
+    "refs": [
+      {
+        "label": "Nginx:反向代理",
+        "url": "https://nginx.org/en/docs/http/ngx_http_proxy_module.html"
+      },
+      {
+        "label": "Nginx:HTTPS配置",
+        "url": "https://nginx.org/en/docs/http/configuring_https_servers.html"
+      },
+      {
+        "label": "Certbot:Nginx安装与续期",
+        "url": "https://certbot.eff.org/instructions?ws=nginx&os=snap"
+      }
+    ],
+    "production": {
+      "scenario": "用户通过HTTPS访问API,证书和代理错误必须持续可观测。",
+      "decisions": [
+        "应用只信任本机代理,公网由Nginx统一入口。",
+        "证书申请与续期都验证,缺域名条件时明确未完成项。",
+        "本例笔记接口用于隔离练习,尚无完整用户身份与数据归属校验。TLS只加密传输;投入真实业务前必须补齐身份、操作权限、数据归属和审计,并验证未授权请求无法读写。"
+      ],
+      "failureDrill": "停止练习API观察Nginx错误,再恢复;对自己域名执行续期dry-run并记录结果。",
+      "acceptance": [
+        "代理错误能定位到上游层。",
+        "TLS状态不靠忽略证书错误通过。"
+      ]
+    }
+  },
+  {
+    "id": "06",
+    "group": "代理与发布",
+    "title": "SSE、WebSocket 与代理超时",
+    "summary": "为长连接选择独立代理设置,验证SSE逐条到达和WebSocket协议升级。",
+    "objectives": [
+      "关闭SSE响应缓冲并区分请求缓冲",
+      "正确传递WebSocket Upgrade与Connection",
+      "协调心跳、代理空闲超时和就绪排空"
+    ],
+    "concepts": [
+      {
+        "title": "SSE是持续HTTP响应",
+        "body": "服务端不断发送text/event-stream,若代理缓冲,用户会很晚才看到一整块内容。proxy_buffering off影响响应,proxy_request_buffering影响请求上传,二者不能互相替代。心跳间隔应小于经过的各层空闲超时。"
+      },
+      {
+        "title": "WebSocket需要显式升级",
+        "body": "Upgrade和Connection是逐跳头,反向代理需显式传递。上游返回101后转为双向隧道。HTTP的普通30秒响应预算不适用于长连接,但无限超时会隐藏失联客户端,仍需ping/pong和断线重连。"
+      },
+      {
+        "title": "就绪与已建立连接",
+        "body": "从负载均衡摘除实例通常只阻止新连接,已有SSE/WS不会自动搬到另一进程。发布需要排空窗口、合理的连接寿命和客户端重连/续传。不要在长流期间持续占用数据库事务。"
+      }
+    ],
+    "steps": [
+      {
+        "title": "明确本课端点前置",
+        "body": "API+数据库模块本身没有SSE/WS。先将API服务开发模块完成的流式与WebSocket示例路由整合到notes-service,确认实际路径;下文使用/stream与/ws作为本次教学适配路径,若原代码不同需同时改代理和客户端,不能声称下载包自动有端点。",
+        "code": "# 先绕过Nginx验证实际应用端点;服务必须已经整合这些路由\ncurl -N --max-time 10 http://127.0.0.1:8000/stream\n# WebSocket用API模块已有客户端连接 ws://127.0.0.1:8000/ws",
+        "lang": "bash"
+      },
+      {
+        "title": "在http上下文新增Upgrade映射",
+        "body": "Ubuntu默认nginx.conf的http块会include /etc/nginx/conf.d/*.conf。新建此文件仅定义map,不包额外http块;nginx -t确认上下文正确。",
+        "code": "map $http_upgrade $connection_upgrade {\n    default upgrade;\n    '' close;\n}",
+        "lang": "nginx",
+        "filename": "/etc/nginx/conf.d/notes-upgrade.conf"
+      },
+      {
+        "title": "给SSE路径增加独立location",
+        "body": "将此location添加到本次notes-service的server块,和已有location /并列。代理重写规则保持路径原样;示例读取空闲超时75s需配合应用每15–25秒心跳,实际周期以API模块实现为准。",
+        "code": "location /stream {\n    proxy_pass http://127.0.0.1:8000;\n    proxy_http_version 1.1;\n    proxy_set_header Host $host;\n    proxy_set_header X-Forwarded-Proto $scheme;\n    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n    proxy_buffering off;\n    proxy_cache off;\n    proxy_read_timeout 75s;\n    proxy_send_timeout 75s;\n    gzip off;\n}",
+        "lang": "nginx",
+        "filename": "/etc/nginx/sites-available/notes-service"
+      },
+      {
+        "title": "给WebSocket路径增加升级配置",
+        "body": "同样添加到server块。TLS站点客户端使用wss://。proxy_read_timeout衡量两次读取之间的间隔,不是整条连接绝对存活时间;应用层需要心跳和会话过期规则。",
+        "code": "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_set_header Host $host;\n    proxy_set_header X-Forwarded-Proto $scheme;\n    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n    proxy_read_timeout 75s;\n    proxy_send_timeout 75s;\n}",
+        "lang": "nginx",
+        "filename": "/etc/nginx/sites-available/notes-service"
+      },
+      {
+        "title": "验证逐条到达与断线恢复",
+        "body": "先nginx -t再reload;curl -N避免客户端输出缓冲混淆。使用API课已有WS客户端确认101和双向消息,然后在隔离VM重启API,记录客户端如何重连。SSE若没有event id/续传实现,应明确重连不能恢复已漏事件。",
+        "code": "sudo nginx -t\nsudo systemctl reload nginx\ncurl -N --max-time 20 -H 'Host: notes.example.com' http://127.0.0.1/stream\n# 另一个终端观察本次站点日志与客户端重连\nsudo journalctl -u notes-api -f",
+        "lang": "bash"
+      }
+    ],
+    "checks": [
+      "SSE通过Nginx仍逐条到达,未被缓冲成结束时一次输出。",
+      "WebSocket成功返回101并双向传输,TLS环境使用wss。",
+      "重启时长连接会断开且客户端有明确恢复行为,未宣称无损切换。"
+    ],
+    "pitfalls": [
+      {
+        "symptom": "服务端日志逐条输出,浏览器最后才看到全部内容",
+        "cause": "Nginx或其他代理/客户端缓冲。",
+        "fix": "逐层绕过代理对比,设置proxy_buffering off并用curl -N;检查上游应用是否正确发送SSE边界。"
+      },
+      {
+        "symptom": "WS连接约60秒断开",
+        "cause": "代理空闲超时与应用心跳不匹配。",
+        "fix": "测量实际心跳周期,协调Nginx和前置负载均衡超时,不能只无限延长一层。"
+      }
+    ],
+    "exercise": {
+      "task": "readiness变为503后,已有WebSocket是否自动迁移到健康实例?",
+      "hint": "TCP连接状态绑定原进程。",
+      "answer": "不会。需要客户端断线重连到健康实例;若要续接会话,状态和事件序号必须保存在可共享存储中,而非只在单进程内存。"
+    },
+    "refs": [
+      {
+        "label": "Nginx:WebSocket代理",
+        "url": "https://nginx.org/en/docs/http/websocket.html"
+      },
+      {
+        "label": "Nginx:响应缓冲",
+        "url": "https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_buffering"
+      },
+      {
+        "label": "Nginx:读取超时",
+        "url": "https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_read_timeout"
+      }
+    ],
+    "production": {
+      "scenario": "Agent流式结果和交互会话经过反向代理后仍应及时、可恢复地传输。",
+      "decisions": [
+        "SSE与WS按各自协议配置,不套普通短请求超时。",
+        "客户端恢复协议与代理排空共同设计。"
+      ],
+      "failureDrill": "在隔离VM通过代理建立流和WS,再重启API,记录最后收到事件、断线时间与重连结果。",
+      "acceptance": [
+        "逐条传输有实际观测。",
+        "没有把连接重连等同业务状态自动恢复。"
+      ]
+    }
+  },
+  {
+    "id": "07",
+    "group": "代理与发布",
+    "title": "发布目录、原子切换与兼容回退",
+    "summary": "用新版本目录准备完整环境,先迁移后切换current,并明确单实例中断和蓝绿切换边界。",
+    "objectives": [
+      "在独立版本目录准备和验证发布物",
+      "通过原子软链替换选择新版本",
+      "判断数据库兼容性和在途任务是否允许回退"
+    ],
+    "concepts": [
+      {
+        "title": "目录切换不替换运行进程",
+        "body": "current软链原子替换只影响之后打开的路径,已经运行的Python仍持有旧代码、工作目录和内存。必须明确重启API与各任务进程,不能以软链已更新推断服务已更新。"
+      },
+      {
+        "title": "迁移先兼容",
+        "body": "新迁移在切换前运行,旧进程仍可能访问数据库,所以结构必须向后兼容。任务名和消息参数也属于跨版本协议:旧消息可能在新worker上执行,新消息可能被旧worker拿到,升级时必须保留兼容或分版本队列。"
+      },
+      {
+        "title": "可用性选择",
+        "body": "单API实例systemctl restart有短暂中断。本课先完成可回退的单实例发布;要平滑切换可在8001启动候选实例、验收后改Nginx upstream并reload,再排空旧实例。长连接仍需要重连协议,不能称为绝对零中断。"
+      }
+    ],
+    "steps": [
+      {
+        "title": "准备r002完整目录",
+        "body": "按第3课创建新目录r002、复制已验证源码、建立venv并安装目标锁,创建artifacts软链。不要覆盖r001,也不要在current目录边写代码边处理请求。下面只检查新目录满足条件。 deploy不加入运行组,路径检查通过sudo -u notes执行,保持发布权限与运行身份分开。",
+        "code": "sudo -u notes test -f /srv/notes-service/releases/r002/app/main.py\nsudo -u notes test -f /srv/notes-service/releases/r002/tasks_lab/celery_app.py\nsudo -u notes test -x /srv/notes-service/releases/r002/.venv/bin/python\nsudo -u notes readlink -f /srv/notes-service/current",
+        "lang": "bash"
+      },
+      {
+        "title": "用一次性systemd作业运行迁移",
+        "body": "仅对专用练习数据库执行已审核迁移;EnvironmentFile使用API SQLAlchemy URL,迁移账号若已分离应改为独立/etc/notes-migrate.env。systemd-run --wait会报告进程结果;失败不继续切换。先保存当前revision与备份记录。",
+        "code": "sudo systemd-run --unit=notes-migrate-r002 --wait --collect   --property=User=notes --property=Group=notes   --property=WorkingDirectory=/srv/notes-service/releases/r002   --property=EnvironmentFile=/etc/notes-service.env   /srv/notes-service/releases/r002/.venv/bin/python -m alembic upgrade head",
+        "lang": "bash"
+      },
+      {
+        "title": "原子替换current并重启",
+        "body": "在/srv/notes-service同一文件系统内先创建临时软链,再mv -T替换current。先检查current确实是软链且指向已记录的r001,禁止覆盖普通目录。root拥有该目录,运行用户不能劫持切换目标。",
+        "code": "sudo -u notes test -L /srv/notes-service/current\nsudo -u notes readlink -f /srv/notes-service/current\nsudo ln -s releases/r002 /srv/notes-service/current.next\nsudo mv -Tf /srv/notes-service/current.next /srv/notes-service/current\nsudo systemctl restart notes-api notes-worker@fast notes-worker@bulk notes-maintenance@publisher notes-maintenance@recovery\ncurl --fail --max-time 5 http://127.0.0.1:8000/ready",
+        "lang": "bash"
+      },
+      {
+        "title": "验证新版本并演练应用回退",
+        "body": "使用真实创建读取、版本冲突和持久导出链路验收。若触发预先定义的错误率/延迟阈值且数据库仍兼容r001,则用同样原子软链方式切回r001并重启全部角色。不自动downgrade数据库,不删除新数据。",
+        "code": "# 仅在兼容性已验证且current为软链时回退\nsudo ln -s releases/r001 /srv/notes-service/current.rollback\nsudo mv -Tf /srv/notes-service/current.rollback /srv/notes-service/current\nsudo systemctl restart notes-api notes-worker@fast notes-worker@bulk notes-maintenance@publisher notes-maintenance@recovery\ncurl --fail --max-time 5 http://127.0.0.1:8000/ready",
+        "lang": "bash"
+      },
+      {
+        "title": "升级到两端口切换时先验证候选",
+        "body": "复制API unit为notes-api-candidate,WorkingDirectory与venv绝对固定到r002,端口改8001。先直接访问8001/ready与业务探针,再把Nginx proxy_pass由8000改8001,nginx -t后reload。观察旧连接排空再停止旧服务;这一步是可选演练,不用current软链同时控制两个版本。",
+        "code": "curl --fail --max-time 5 http://127.0.0.1:8001/ready\nsudo nginx -t\nsudo systemctl reload nginx\n# 确认流量已切换并等待已定义排空窗口后,停止旧notes-api。",
+        "lang": "bash"
+      }
+    ],
+    "checks": [
+      "r001与r002是独立完整发布物,current切换前后路径可核对。",
+      "迁移失败停止发布,应用回退只在数据库兼容范围内执行。",
+      "实际进程已重启或代理已切换,版本验证不只检查软链。"
+    ],
+    "pitfalls": [
+      {
+        "symptom": "current已变但请求仍返回旧行为",
+        "cause": "旧Python进程没有退出,仍持有旧代码。",
+        "fix": "检查systemd MainPID与启动时间,执行受控重启或候选端口切换。"
+      },
+      {
+        "symptom": "旧worker无法读取新消息参数",
+        "cause": "任务协议变更未考虑队列中的存量消息。",
+        "fix": "保持稳定任务名与兼容参数,必要时用新版本队列并先排空旧队列。"
+      }
+    ],
+    "exercise": {
+      "task": "新版本删除了一列,发现问题后还能直接切r001吗?",
+      "hint": "旧代码是否仍读取或写入该列?",
+      "answer": "通常不能保证。应采用expand-contract保留回退窗口;已执行不兼容删除时优先前向修复,或走经验证的数据恢复方案并评估数据损失,不把软链切回当完整回滚。"
+    },
+    "refs": [
+      {
+        "label": "Alembic:迁移命令",
+        "url": "https://alembic.sqlalchemy.org/en/latest/tutorial.html"
+      },
+      {
+        "label": "Nginx:控制与reload",
+        "url": "https://nginx.org/en/docs/control.html"
+      },
+      {
+        "label": "systemd-run:一次性作业(Ubuntu 24.04)",
+        "url": "https://manpages.ubuntu.com/manpages/noble/man1/systemd-run.1.html"
+      }
+    ],
+    "production": {
+      "scenario": "发布不仅换代码,还要协调数据库、队列协议和已有连接。",
+      "decisions": [
+        "每版独立目录与依赖,迁移先兼容。",
+        "记录可回退版本和明确触发条件。"
+      ],
+      "failureDrill": "在隔离VM让r002故意缺一个必要配置,观察失败后按兼容路径切回r001并验证数据。",
+      "acceptance": [
+        "恢复耗时与业务验证有记录。",
+        "不兼容数据库变更明确阻止机械回退。"
+      ]
+    }
+  },
+  {
+    "id": "08",
+    "group": "运维与验收",
+    "title": "journald、日志轮转与可执行告警",
+    "summary": "建立从请求到任务的关联日志,控制磁盘占用,并定义能触发具体行动的监控条件。",
+    "objectives": [
+      "用journalctl按服务和时间定位故障",
+      "限制journald与Nginx日志占用",
+      "区分存活、业务就绪与队列健康指标"
+    ],
+    "concepts": [
+      {
+        "title": "日志回答一次事件",
+        "body": "API记录request_id、方法、路由、状态码与耗时,任务记录job_id、阶段、attempt与异常类别。数据库密码、Authorization、笔记正文和完整提示词不进普通日志。关联ID连接各进程信息,不靠长堆栈猜同一次请求。"
+      },
+      {
+        "title": "指标回答是否持续异常",
+        "body": "错误率、延迟分位数、数据库池等待、最老outbox年龄和队列等待时间要看时间窗口。只看worker存活会漏掉队列不消费、publisher停滞和结果文件失效。告警需阈值、持续时间、负责人和runbook。"
+      },
+      {
+        "title": "日志本身会占资源",
+        "body": "无限日志可填满磁盘导致数据库写入失败。journald设容量和保留期,Nginx使用发行版logrotate配置并核对新文件路径是否匹配;不要直接截断数据库或正在写入的日志文件。"
+      }
+    ],
+    "steps": [
+      {
+        "title": "按服务与时间查日志",
+        "body": "这些命令只读。先定位首次异常时间和部署版本,再扩大范围;生产收集日志时脱敏并限制导出范围,不直接输出EnvironmentFile。",
+        "code": "sudo journalctl -u notes-api --since '15 minutes ago' --no-pager\nsudo journalctl -u notes-worker@fast -u notes-maintenance@publisher --since today --no-pager\nsudo systemctl show notes-api -p MainPID -p NRestarts -p ExecMainStatus\nsudo journalctl --disk-usage",
+        "lang": "bash"
+      },
+      {
+        "title": "配置journald容量",
+        "body": "在独立VM新建drop-in。值为小型练习机起点,实际保留期由故障调查需求和磁盘预算决定;修改只限制未来保留行为,不能代替异机日志归档。",
+        "code": "[Journal]\nStorage=persistent\nSystemMaxUse=500M\nSystemKeepFree=1G\nMaxRetentionSec=14day",
+        "lang": "ini",
+        "filename": "/etc/systemd/journald.conf.d/notes-limits.conf"
+      },
+      {
+        "title": "加载并检查Nginx轮转",
+        "body": "先使用sudo install -d创建journald.conf.d目录,再保存配置并restart journald。Nginx发行版通常已有/var/log/nginx/*.log规则,确认覆盖notes-access/error;避免重复建立两个规则处理同一文件。logrotate -d仅调试,不执行轮转。",
+        "code": "sudo systemctl restart systemd-journald\nsudo journalctl --disk-usage\nsudo cat /etc/logrotate.d/nginx\nsudo logrotate -d /etc/logrotate.conf\ndf -h /var /srv",
+        "lang": "bash"
+      },
+      {
+        "title": "定义服务指标与告警动作",
+        "body": "把下列初始规则保存monitoring-runbook.md并按实际SLO调整。探针应在独立监控主机执行,避免本机整体宕机时连监控一起消失;课件没有配置真实通知渠道,不能声称告警已接通。",
+        "code": "API就绪:/ready 连续3次失败(间隔10s)→ 检查依赖/发布,暂停继续放量\nHTTP错误率:5分钟窗口5xx超过业务阈值 → 按request_id查首次异常\n队列:最老queued/outbox等待超过任务SLO → 检查publisher、broker与worker\n磁盘:剩余空间低于容量预算,且持续下降 → 排查日志/备份,禁止先删数据库文件\n证书:剩余天数低于续期排障窗口 → 查看Certbot定时任务与验证路径\n告警必须包含:环境、版本、首次时间、当前值、负责人、runbook链接",
+        "lang": "text",
+        "filename": "monitoring-runbook.md"
+      },
+      {
+        "title": "用一次故障验证告警流程",
+        "body": "隔离VM暂停publisher,提交一个持久导出任务,等待超过练习阈值,手工执行监控SQL观察最老未发事件。恢复publisher后应完成投递,记录从发现到恢复用时;真实通知渠道尚未接通则明确标记为手工演练。",
+        "code": "sudo systemctl stop notes-maintenance@publisher\n# 按任务模块提交一个新job,并查询export_outbox最老未发送时间\nsudo systemctl start notes-maintenance@publisher\nsudo journalctl -u notes-maintenance@publisher --since '5 minutes ago' --no-pager",
+        "lang": "bash"
+      }
+    ],
+    "checks": [
+      "能够按request_id/job_id定位跨进程阶段,不打印业务正文与凭据。",
+      "journald容量可查询,Nginx日志匹配一个有效轮转规则。",
+      "至少一个故障从指标触发到恢复完成演练,未接通通知的部分明确说明。"
+    ],
+    "pitfalls": [
+      {
+        "symptom": "接口变慢但没有报错日志",
+        "cause": "日志只覆盖异常,未记录耗时和排队。",
+        "fix": "加入请求/任务阶段耗时指标,区分数据库等待、执行与队列延迟。"
+      },
+      {
+        "symptom": "磁盘满后直接删除大文件仍未释放空间",
+        "cause": "文件可能仍由进程持有,或误删了重要数据。",
+        "fix": "先用lsof等确认持有者和文件类型,按服务支持方式轮转;不能删除PGDATA文件排空间。"
+      }
+    ],
+    "exercise": {
+      "task": "worker进程active就说明任务系统健康吗?",
+      "hint": "任务经过哪些独立组件?",
+      "answer": "不说明。要同时检查outbox最老年龄、broker连通、队列等待、任务成功率和输出可读性;publisher停掉时worker仍可以一直active。"
+    },
+    "refs": [
+      {
+        "label": "systemd:journalctl(Ubuntu 24.04)",
+        "url": "https://manpages.ubuntu.com/manpages/noble/man1/journalctl.1.html"
+      },
+      {
+        "label": "systemd:journald配置(Ubuntu 24.04)",
+        "url": "https://manpages.ubuntu.com/manpages/noble/man5/journald.conf.5.html"
+      },
+      {
+        "label": "Nginx:日志模块",
+        "url": "https://nginx.org/en/docs/http/ngx_http_log_module.html"
+      }
+    ],
+    "production": {
+      "scenario": "故障必须在磁盘耗尽或用户集中反馈前被发现,并能关联到具体发布与任务。",
+      "decisions": [
+        "告警对应可执行排查步骤。",
+        "日志容量和敏感字段有明确限制。"
+      ],
+      "failureDrill": "暂停练习publisher形成积压,检查最老待发年龄,恢复后核对任务完成与指标下降。",
+      "acceptance": [
+        "告警条件对应真实业务影响。",
+        "发现、定位、恢复耗时都有记录。"
+      ]
+    }
+  },
+  {
+    "id": "09",
+    "group": "运维与验收",
+    "title": "数据库备份、异机加密与恢复目标",
+    "summary": "从一致性备份走到异机副本与隔离恢复,明确RPO和RTO是需要测量的目标。",
+    "objectives": [
+      "创建可验证的PostgreSQL逻辑备份",
+      "对备份加密并传到获准的独立存储",
+      "测量恢复时间并核对应用级数据"
+    ],
+    "concepts": [
+      {
+        "title": "RPO与RTO",
+        "body": "RPO描述可接受的数据回退时长,RTO描述可接受的恢复耗时。每天一次逻辑备份通常无法支持分钟级RPO,需WAL归档和PITR等方案。恢复时间包含取回备份、解密、建库、恢复、索引和业务验收,不只是pg_restore命令。"
+      },
+      {
+        "title": "权限与密钥分离",
+        "body": "备份包含业务数据,应使用专用备份账号与最小读取权限,传输加密、静态加密、访问审计。解密私钥不能只放在同一台被备份主机,否则主机丢失后备份也无法解密。"
+      },
+      {
+        "title": "恢复隔离",
+        "body": "恢复到新的notes_restore数据库或独立恢复主机,不能覆盖线上notes_lab来试验。行数、关键约束、迁移版本、任务状态与文件引用都要检查;数据库备份不自动包含shared/artifacts,输出文件需独立备份或可重建规则。"
+      }
+    ],
+    "steps": [
+      {
+        "title": "为练习备份建立目录与凭据文件",
+        "body": "在VM创建root专用备份目录。使用.pgpass格式的/etc/notes-backup.pgpass保存专用备份凭据,权限600;示例可用练习app_user作为自己表的所有者读取,生产另建备份角色并验证所需权限。密码中的:与\\要按libpq规则转义。",
+        "code": "sudo install -d -o root -g root -m 700 /var/backups/notes-service\nsudoedit /etc/notes-backup.pgpass\n# 文件格式:127.0.0.1:5433:notes_lab:BACKUP_USER:ESCAPED_PASSWORD\nsudo chown root:root /etc/notes-backup.pgpass\nsudo chmod 600 /etc/notes-backup.pgpass",
+        "lang": "bash"
+      },
+      {
+        "title": "生成备份并检查可读目录",
+        "body": "下方命令中的BACKUP_USER替换为已验证的备份角色。使用PostgreSQL16客户端备份16服务器;Ubuntu24.04默认客户端通常为16,先检查。sudo env只指定凭据文件路径,命令行不写密码。备份文件名本次固定lab-01,存在时换新编号,不能覆盖唯一好备份。",
+        "code": "pg_dump --version\nsudo env PGPASSFILE=/etc/notes-backup.pgpass pg_dump -h 127.0.0.1 -p 5433 -U BACKUP_USER -d notes_lab -Fc --no-owner --no-acl -f /var/backups/notes-service/notes-lab-01.dump\nsudo pg_restore --list /var/backups/notes-service/notes-lab-01.dump\nsudo sha256sum /var/backups/notes-service/notes-lab-01.dump",
+        "lang": "bash"
+      },
+      {
+        "title": "使用接收方公钥加密",
+        "body": "本步骤示例使用age,先按官方文档安装受信任版本。在恢复端生成并保管age私钥,备份VM仅保存接收方公钥;AGE_RECIPIENT替换真实公钥。此课不实际向外部发送数据,只有自己拥有且已获准的备份主机/存储才执行传输。",
+        "code": "# 备份VM,仅持有接收方公钥\nsudo age -r AGE_RECIPIENT -o /var/backups/notes-service/notes-lab-01.dump.age /var/backups/notes-service/notes-lab-01.dump\nsudo sha256sum /var/backups/notes-service/notes-lab-01.dump.age\n# 使用已验证主机密钥的独立备份目标;替换主机、账号、路径后再执行\n# scp notes-lab-01.dump.age backup@AUTHORIZED_BACKUP_HOST:/approved/notes-service/",
+        "lang": "bash"
+      },
+      {
+        "title": "在独立恢复环境解密与恢复",
+        "body": "只在独立恢复VM或数据库执行,先createdb创建notes_restore并验证名称。解密私钥文件必须具有合适权限;不把私钥写在命令参数正文。记录开始与结束时间,恢复账号需在该恢复库有建表权限。",
+        "code": "age -d -i /secure/recovery-age-key.txt -o notes-lab-01-restored.dump notes-lab-01.dump.age\nsha256sum notes-lab-01-restored.dump\ncreatedb -h RESTORE_HOST -U RESTORE_ADMIN -W notes_restore\ndate -u\npg_restore -h RESTORE_HOST -U RESTORE_ADMIN -W -d notes_restore --no-owner --no-acl --exit-on-error notes-lab-01-restored.dump\ndate -u",
+        "lang": "bash"
+      },
+      {
+        "title": "核对恢复内容并计算差距",
+        "body": "在恢复库执行下列SQL,再将隔离API指向恢复库完成读取与约束验收。不要让恢复API连接生产队列并重新执行旧任务;禁用外发或使用独立broker,处理已完成/未完成任务的恢复语义。",
+        "code": "SELECT current_database();\nSELECT count(*) FROM notes;\nSELECT version_num FROM alembic_version;\nSELECT status, count(*) FROM export_jobs GROUP BY status;\n-- 另行核对shared/artifacts备份或对象存储中的result_ref文件是否存在。",
+        "lang": "sql"
+      }
+    ],
+    "checks": [
+      "备份文件目录可读,解密后摘要与原始dump一致。",
+      "恢复发生在独立notes_restore,原数据库未覆盖。",
+      "记录实际恢复耗时、备份时间点及任务输出核验,能判断是否满足目标RPO/RTO。"
+    ],
+    "pitfalls": [
+      {
+        "symptom": "备份在但恢复时没有解密私钥",
+        "cause": "密钥只存在已损坏的原主机。",
+        "fix": "把恢复密钥放在独立受控保管位置并演练访问,备份机只需公钥。"
+      },
+      {
+        "symptom": "恢复数据库后任务重复发送邮件或调用外部接口",
+        "cause": "恢复环境连上生产broker或仍启用外发调度。",
+        "fix": "恢复环境使用独立依赖并禁用外发,先核对任务状态和恢复策略,再受控恢复业务。"
+      }
+    ],
+    "exercise": {
+      "task": "每天备份一次,能否承诺最多丢5分钟数据?",
+      "hint": "最后成功备份到故障发生之间可能多长?",
+      "answer": "不能。要达到分钟级RPO,应设计并验证WAL归档/PITR或其他受控方案,同时监控归档延迟;逻辑备份仍可作为补充,但目标必须有实际恢复证据。"
+    },
+    "refs": [
+      {
+        "label": "PostgreSQL16:备份恢复",
+        "url": "https://www.postgresql.org/docs/16/backup.html"
+      },
+      {
+        "label": "PostgreSQL16:持续归档与PITR",
+        "url": "https://www.postgresql.org/docs/16/continuous-archiving.html"
+      },
+      {
+        "label": "PostgreSQL16:密码文件",
+        "url": "https://www.postgresql.org/docs/16/libpq-pgpass.html"
+      },
+      {
+        "label": "age官方用法",
+        "url": "https://github.com/FiloSottile/age"
+      }
+    ],
+    "production": {
+      "scenario": "主机或数据库损坏后,需要在业务可接受时间内恢复数据且避免重复外部副作用。",
+      "decisions": [
+        "备份异机加密,密钥另行保管。",
+        "恢复库和队列隔离,业务验收计入RTO。"
+      ],
+      "failureDrill": "在独立恢复环境取回、解密、恢复一份练习备份,验证行数、约束、任务状态与输出文件。",
+      "acceptance": [
+        "备份可恢复而非仅存在。",
+        "恢复不连接生产外发渠道。"
+      ]
+    }
+  },
+  {
+    "id": "10",
+    "group": "运维与验收",
+    "title": "完整发布、回退与故障 Runbook",
+    "summary": "把主机、代理、应用、数据库和任务链路组成可执行验收清单,记录恢复边界。",
+    "objectives": [
+      "按层完成新版本发布验收",
+      "依据故障症状选择检查与恢复步骤",
+      "用隔离故障演练证明回退和备份方案"
+    ],
+    "concepts": [
+      {
+        "title": "验收从用户路径出发",
+        "body": "/live只证明进程回应,/ready验证部分依赖,真正的验收要创建、读取、条件更新以及提交持久任务并读取结果。每项记录环境、版本、时间、预期与实际,不能把课件命令当作已执行证据。"
+      },
+      {
+        "title": "恢复先找边界",
+        "body": "API代码错误可回退兼容版本;数据库锁等待需找阻塞事务;磁盘不足要处理明确的数据来源;队列积压先查publisher与worker。没有证据时重启所有服务可能放大故障并丢失现场。"
+      },
+      {
+        "title": "进程与Docker部署差异",
+        "body": "systemd模式检查unit、绝对venv和主机端口;Docker模式检查镜像digest、Compose服务DNS、容器资源和卷。二者共享迁移、备份、幂等、代理与告警要求,不能因容器化省略数据库验收。"
+      }
+    ],
+    "steps": [
+      {
+        "title": "建立发布检查单",
+        "body": "新建/srv/notes-service/shared/release-runbook.md供管理员维护,运行用户无需写此文件。填入真实版本、依赖地址和负责人,不在文档写秘密。",
+        "code": "发布前:目标VM/域名核对;当前版本与revision记录;备份恢复证据;兼容窗口\n发布准备:新目录/镜像验证;锁定依赖;配置权限;迁移SQL审核\n执行:唯一迁移成功 → 启动候选/切换版本 → 业务探针 → 观察窗口\n验收:API CRUD/version409;幂等重试;job/outbox/worker/结果文件一致\n回退:触发条件、旧版本、数据库兼容限制、任务协议限制、恢复负责人\n证据:命令结果、日志时间、指标曲线、未验证项目",
+        "lang": "text",
+        "filename": "release-runbook.md"
+      },
+      {
+        "title": "执行不带秘密的基础探针",
+        "body": "在VM检查服务和磁盘,客户端检查真正入口。HTTPS仅在已配置自己域名时执行;未配置证书则通过本机HTTP Host探针,不使用-k冒充TLS成功。",
+        "code": "sudo systemctl is-active notes-api notes-worker@fast notes-worker@bulk notes-maintenance@publisher notes-maintenance@recovery nginx\ncurl --fail --max-time 5 http://127.0.0.1:8000/ready\ncurl --fail --max-time 10 https://YOUR_DOMAIN/ready\ndf -h /srv /var\nsudo journalctl -u notes-api --since '5 minutes ago' --no-pager",
+        "lang": "bash"
+      },
+      {
+        "title": "执行数据库与任务业务验收",
+        "body": "API+数据库最终脚本先验证创建、PATCH version与409;任务侧使用以下一次性systemd作业从真实notes表选一条并调用最终state_store.submit_snapshot。该函数包含job/outbox原子写入,不手工再delay。输出job_id后等worker完成,再在同一工作目录和环境运行verify_terminal.py JOB_ID,检查artifact与固定payload一致。",
+        "code": "sudo systemd-run --unit=notes-submit-probe --wait --collect --pipe \\\n  --property=User=notes --property=Group=notes \\\n  --property=WorkingDirectory=/srv/notes-service/current/tasks_lab \\\n  --property=EnvironmentFile=/etc/notes-tasks.env \\\n  /srv/notes-service/current/.venv/bin/python -c \"from state_store import connect,submit_snapshot; conn=connect(); row=conn.execute('SELECT id FROM notes ORDER BY id LIMIT 1').fetchone(); conn.close(); assert row, 'create a note first'; print(submit_snapshot([row['id']]))\"\n# 记录上一步实际job_id,完成后替换JOB_ID\nsudo systemd-run --unit=notes-verify-probe --wait --collect --pipe \\\n  --property=User=notes --property=Group=notes \\\n  --property=WorkingDirectory=/srv/notes-service/current/tasks_lab \\\n  --property=EnvironmentFile=/etc/notes-tasks.env \\\n  /srv/notes-service/current/.venv/bin/python verify_terminal.py JOB_ID",
+        "lang": "bash"
+      },
+      {
+        "title": "逐个注入隔离故障并恢复",
+        "body": "一次只注入一个故障,先记录预期,完成恢复后再下一个。不在生产或唯一远程管理入口执行;SSH/UFW练习必须保留控制台和回退timer。",
+        "code": "故障 | 首先检查 | 预期恢复\nAPI停止 | systemctl status / Nginx上游日志 | 启动已验证版本,/ready恢复\n错误DB凭据 | API异常类别 / 脱敏配置来源 | 修正配置并重启对应进程\npublisher停止 | 最老outbox年龄 / unit状态 | 启动publisher,任务完成\nworker超时退出 | job租约 / attempts / recovery日志 | 过期恢复,幂等处理\n新版本不兼容 | revision与旧版兼容表 | 停止机械回退,前向修复或恢复计划\n备份恢复 | 独立恢复库 / 解密与结果引用 | 满足已测RPO/RTO",
+        "lang": "text"
+      },
+      {
+        "title": "对比Docker部署同一验收",
+        "body": "若选择Docker路线,API仍由Nginx代理回环发布端口;用下列只读命令替代unit检查。不得同时让systemd API与Compose API争抢8000,先明确使用哪条运行主线。最终报告列出已实机验证和未验证项目。",
+        "code": "docker compose --env-file .env.compose ps -a\ndocker compose --env-file .env.compose logs --tail 100 api worker worker-bulk publisher recovery\ndocker compose --env-file .env.compose images\ndocker compose --env-file .env.compose stats --no-stream",
+        "lang": "bash"
+      }
+    ],
+    "checks": [
+      "有一次完整发布与兼容回退的实际执行记录,而不只是计划。",
+      "API与任务验收核对数据库最终状态和输出文件,失败后恢复验证完成。",
+      "未进行的远程部署、证书、异机备份或告警通知明确标记未验证。"
+    ],
+    "pitfalls": [
+      {
+        "symptom": "所有组件进程都active但用户任务一直queued",
+        "cause": "存活检查未覆盖业务链路与积压年龄。",
+        "fix": "核对job/outbox、publisher投递、队列路由和worker注册任务,最后验证result_ref。"
+      },
+      {
+        "symptom": "为了快速恢复反复重启所有组件",
+        "cause": "未区分故障层,重启破坏现场并触发重复任务。",
+        "fix": "一次验证一个假设,先记录错误、版本和状态,再执行最小恢复动作。"
+      }
+    ],
+    "exercise": {
+      "task": "向同事交付部署方案时,至少需要哪些可审查证据?",
+      "hint": "让对方不依赖你的终端历史也能复现。",
+      "answer": "提供版本/镜像标识、依赖锁、配置项名称与权限、迁移状态、探针结果、任务最终状态、日志时间、回退边界和恢复耗时。秘密只给受控存储位置,不复制值。未执行的步骤标为待验证。"
+    },
+    "refs": [
+      {
+        "label": "Ubuntu Server文档",
+        "url": "https://documentation.ubuntu.com/server/"
+      },
+      {
+        "label": "systemd:systemctl(Ubuntu 24.04)",
+        "url": "https://manpages.ubuntu.com/manpages/noble/man1/systemctl.1.html"
+      },
+      {
+        "label": "Docker:生产Compose",
+        "url": "https://docs.docker.com/compose/how-tos/production/"
+      }
+    ],
+    "production": {
+      "scenario": "学员需要独立发布一个可排错、可回退、可恢复的后端服务。",
+      "decisions": [
+        "验收覆盖用户路径与持久状态。",
+        "故障按层定位,恢复动作最小且有证据。"
+      ],
+      "failureDrill": "在专用VM完成API停止、publisher停止和兼容版本回退三项,记录发现、恢复时间与数据结果;剩余矩阵逐项标记。",
+      "acceptance": [
+        "有可交付runbook和执行证据。",
+        "没有把未实机验证的命令称为已部署成功。"
+      ]
+    }
+  }
+]

+ 1070 - 0
public/content/redis.json

@@ -0,0 +1,1070 @@
+[
+  {
+    "id": "01",
+    "title": "连接 Redis、选择类型并设置 TTL",
+    "group": "连接与缓存模型",
+    "summary": "在隔离的 Redis 7 练习实例中操作字符串、哈希和集合,建立可辨认的键名与过期规则。",
+    "objectives": [
+      "通过环境变量配置带密码的 Redis 连接",
+      "区分字符串、哈希与集合的操作",
+      "解释 TTL 的 -1、-2 和覆盖写入行为"
+    ],
+    "concepts": [
+      {
+        "title": "缓存副本与业务真源",
+        "body": "本模块以 PostgreSQL notes_lab 为笔记真源,Redis 只保存可重新生成的副本。缓存命中减少数据库读取;缓存丢失应影响性能而不应删除笔记。计数器、锁和消息队列的正确性要求不同,不能自动套用可随时丢弃的缓存策略。"
+      },
+      {
+        "title": "数据类型决定命令",
+        "body": "字符串可保存序列化 JSON;Hash 保存多个字段;Set 表示无重复成员。不同类型有各自命令,GET 一个 Hash 会返回 WRONGTYPE。这里不使用额外 RedisJSON 模块,JSON 文本由 Python 编解码。"
+      },
+      {
+        "title": "TTL 是数据生命期",
+        "body": "TTL 正数表示剩余秒数,-1 表示存在但没有过期时间,-2 表示键不存在。SET 默认覆盖值并移除原 TTL,除非显式设置新期限或保留期限。缓存写入通常把值和 EX/PX 放在同一条 SET 中,避免写值成功却未设置过期。"
+      }
+    ],
+    "steps": [
+      {
+        "title": "启动只绑定本机端口的练习实例",
+        "body": "需要已安装并运行 Docker、Python 3.11+。下面是 Linux / macOS Bash,在 notes-service 执行,只创建本机专用 notes-redis。config 文件含练习密码,目录只供当前用户访问;挂载单个配置文件供容器读取。生产应按凭据管理和 TLS 方案部署,不能直接使用这份本机配置。",
+        "code": "mkdir -p redis-lab\nchmod 700 redis-lab\nexport REDIS_PASSWORD=\"$(python3 -c 'import secrets; print(secrets.token_urlsafe(32))')\"\npython3 - <<'PY'\nimport os\nfrom pathlib import Path\npassword = os.environ['REDIS_PASSWORD']\np = Path('redis-lab/redis.conf')\np.write_text('bind 0.0.0.0\\nprotected-mode yes\\nport 6379\\n'\n             f'requirepass {password}\\n'\n             'save \"\"\\nappendonly no\\nmaxmemory 128mb\\nmaxmemory-policy allkeys-lru\\n')\np.chmod(0o644)\nPY\nprintf 'redis-lab/\\n' >> .gitignore\ndocker run --name notes-redis -d -p 127.0.0.1:6380:6379 \\\n  -v \"$PWD/redis-lab/redis.conf:/usr/local/etc/redis/redis.conf:ro\" \\\n  redis:7 redis-server /usr/local/etc/redis/redis.conf\nexport REDIS_HOST=127.0.0.1 REDIS_PORT=6380\npython -m pip install 'redis>=5.3,<6'\npython -m pip freeze > requirements-lock.txt",
+        "lang": "bash"
+      },
+      {
+        "title": "建立后续复用的客户端",
+        "body": "保存为 redis_client.py。REDIS_PASSWORD 必须存在,忘记设置时应尽早失败。客户端启动不会立即验证连通,main 中 PING 才实际访问。这里使用 redis-py 5.3 系列稳定 API,Redis 7 不支持的较新命令不在示例中使用。",
+        "code": "import os\nimport redis\n\nr = redis.Redis(\n    host=os.environ.get(\"REDIS_HOST\", \"127.0.0.1\"),\n    port=int(os.environ.get(\"REDIS_PORT\", \"6380\")),\n    password=os.environ[\"REDIS_PASSWORD\"],\n    db=0, decode_responses=True,\n    socket_connect_timeout=0.2, socket_timeout=0.2,\n    health_check_interval=30, max_connections=16,\n)\n\nif __name__ == \"__main__\":\n    print(\"ping\", r.ping())\n    print(\"version\", r.info(\"server\")[\"redis_version\"])\n    r.close()",
+        "lang": "python",
+        "filename": "redis_client.py"
+      },
+      {
+        "title": "操作三种数据类型",
+        "body": "保存为 redis_types.py,执行 python redis_types.py。所有键使用 notes:lab: 前缀,仅删除本课明确列出的三个键。反复练习不用 FLUSHALL;不同业务键不能共用同一个名字。",
+        "code": "from redis_client import r\n\nkeys = [\"notes:lab:types:title\", \"notes:lab:types:hash\", \"notes:lab:types:tags\"]\nr.delete(*keys)\nr.set(keys[0], \"first note\", ex=60)\nr.hset(keys[1], mapping={\"title\": \"first note\", \"pinned\": \"false\"})\nr.expire(keys[1], 60)\nr.sadd(keys[2], \"python\", \"api\", \"python\")\nr.expire(keys[2], 60)\nprint(r.get(keys[0]))\nprint(r.hgetall(keys[1]))\nprint(sorted(r.smembers(keys[2])))\nprint([r.type(k) for k in keys])\nr.delete(*keys)",
+        "lang": "python",
+        "filename": "redis_types.py"
+      },
+      {
+        "title": "验证 TTL 与覆盖写入",
+        "body": "保存为 redis_ttl.py 并运行。等待只发生在专用 1 秒练习键,不用长时间阻塞服务。SET 后 TTL 为 -1 的一幕用于认识风险;最终删除该练习键。",
+        "code": "import time\nfrom redis_client import r\n\nk = \"notes:lab:ttl-probe\"\nr.set(k, \"v1\", ex=1)\nprint(\"expires\", r.ttl(k))\ntime.sleep(1.1)\nprint(\"missing\", r.ttl(k))\nr.set(k, \"v2\", ex=60)\nr.set(k, \"v3\")\nprint(\"no expiration\", r.ttl(k))\nr.set(k, \"v4\", ex=60)\nprint(\"restored expiration\", r.ttl(k))\nr.delete(k)",
+        "lang": "python",
+        "filename": "redis_ttl.py"
+      }
+    ],
+    "checks": [
+      "PING 返回 True,服务版本属于 Redis 7。",
+      "字符串、Hash、Set 的值和类型符合预期,Set 中 python 只出现一次。",
+      "能复现 TTL=-2 与 TTL=-1,所有长期缓存写入都明确设置期限。"
+    ],
+    "pitfalls": [
+      {
+        "symptom": "NOAUTH 或 AuthenticationError",
+        "cause": "客户端未提供 REDIS_PASSWORD,或重开终端后环境变量丢失。",
+        "fix": "从本机受保护配置恢复正确密码到当前环境,确认客户端指向 6380;不把密码贴入日志。"
+      },
+      {
+        "symptom": "WRONGTYPE Operation against a key holding the wrong kind of value",
+        "cause": "相同键名先被写成一种类型,后被另一类命令操作。",
+        "fix": "用 TYPE 检查,修改键名命名空间;仅清理确认属于当前实验的键。"
+      }
+    ],
+    "exercise": {
+      "task": "写入一个 30 秒 JSON 文本缓存,并证明覆盖写入时必须重新处理 TTL。",
+      "hint": "JSON 先 json.dumps,再 SET ex=30;不要分开 SET 与 EXPIRE。",
+      "answer": "r.set(\"notes:lab:json-probe\", json.dumps({\"schema\":1,\"id\":1}), ex=30)。再次 r.set 同键时仍带 ex=30,或明确使用 keepttl=True;读后用 json.loads。SET 未指定过期且未 KEEPTTL 会移除已有 TTL。"
+    },
+    "refs": [
+      {
+        "label": "Redis:数据类型",
+        "url": "https://redis.io/docs/latest/develop/data-types/"
+      },
+      {
+        "label": "Redis:SET",
+        "url": "https://redis.io/docs/latest/commands/set/"
+      },
+      {
+        "label": "Redis:TTL",
+        "url": "https://redis.io/docs/latest/commands/ttl/"
+      },
+      {
+        "label": "redis-py 5.3:连接参数",
+        "url": "https://redis.readthedocs.io/en/v5.3.0/connections.html"
+      }
+    ],
+    "production": {
+      "scenario": "笔记详情需要缓存,但重启 Redis 不能影响 PostgreSQL 中的真实笔记。",
+      "decisions": [
+        "键名包含应用、环境与数据用途。",
+        "固定开发端口并设置认证。",
+        "缓存写入与 TTL 在同一条命令中完成。"
+      ],
+      "failureDrill": "在专用键上移除 TTL 并观察 -1,再修复为带 EX 的 SET。",
+      "acceptance": [
+        "客户端没有明文硬编码密码。",
+        "练习不使用 FLUSHALL。",
+        "能从键名和 TYPE 识别数据用途。"
+      ]
+    }
+  },
+  {
+    "id": "02",
+    "title": "实现 Cache-Aside 与带版本的 JSON 缓存",
+    "group": "读取与一致性",
+    "summary": "先查 Redis,未命中再读 PostgreSQL,验证第一次回源与第二次命中的完整路径。",
+    "objectives": [
+      "实现显式命中、未命中和回填",
+      "把数据库类型转成稳定 JSON 结构",
+      "用 schema 区分缓存格式版本与业务数据版本"
+    ],
+    "concepts": [
+      {
+        "title": "Cache-Aside 的读路径",
+        "body": "应用先 GET;不存在时查询数据库,再把结果 SET 到缓存。Redis 不会自动知道 PostgreSQL 更新。先把基本路径做对,后续逐课处理失效、并发回填和故障降级。"
+      },
+      {
+        "title": "两种版本解决不同问题",
+        "body": "缓存外层 schema=1 表示 JSON 格式协议,键名 v1 允许将来迁移格式。note.version 来自数据库,表示业务记录的修改版本。改 JSON 字段不等于修改业务记录,两者不要混为一个值。"
+      },
+      {
+        "title": "缓存值也需要验证",
+        "body": "反序列化 JSON 后还要检查结构和编号,坏缓存应被当作未命中处理。数据库时间转换为带时区 ISO 8601,不用 pickle 反序列化来自缓存的任意对象。本课缓存只包含列表出的笔记字段。"
+      }
+    ],
+    "steps": [
+      {
+        "title": "准备一条 PostgreSQL 笔记",
+        "body": "先完成数据库模块第 07 课,确保 notes 含 version。沿用 PG 环境变量连接 notes_lab,REDIS_PASSWORD 使用本模块第 01 课密码。在 Python 交互或保存以下脚本运行一次,记下编号并在 Bash 设置 export NOTE_ID=实际编号。",
+        "code": "import psycopg\n\nwith psycopg.connect(\"\") as conn:\n    row = conn.execute(\n        \"INSERT INTO notes(title,content) VALUES (%s,%s) RETURNING id\",\n        (\"cache-lab\", \"database is the source\"),\n    ).fetchone()\n    print(\"NOTE_ID=\", row[0])",
+        "lang": "python",
+        "filename": "seed_cache_note.py"
+      },
+      {
+        "title": "编写数据库读取与缓存编码",
+        "body": "保存为 note_cache.py。load_note 使用明确列名,把时间转换成字符串。此教学函数每次回源新建数据库连接,生产接入时用 API + 数据库模块的连接池,不能照搬成高并发连接策略。key 中花括号包围编号,为同一笔记相关键预留同槽标签;当前实例仍是单机 Redis。",
+        "code": "import json\nimport psycopg\nfrom psycopg.rows import dict_row\nfrom redis_client import r\n\ndef key(note_id):\n    return f\"notes:lab:v1:note:{{{note_id}}}\"\n\ndef load_note(note_id):\n    with psycopg.connect(\"\", row_factory=dict_row) as conn:\n        row = conn.execute(\n            \"SELECT id,title,content,pinned,created_at,updated_at,version FROM notes WHERE id=%s\",\n            (note_id,),\n        ).fetchone()\n    if row is not None:\n        row[\"created_at\"] = row[\"created_at\"].isoformat()\n        row[\"updated_at\"] = row[\"updated_at\"].isoformat()\n    return row\n\ndef encode(note):\n    return json.dumps({\"schema\": 1, \"note\": note}, ensure_ascii=False)\n\ndef decode(raw, note_id):\n    data = json.loads(raw)\n    note = data[\"note\"]\n    if data[\"schema\"] != 1 or not isinstance(note, dict) or note.get(\"id\") != note_id:\n        raise ValueError(\"invalid cached note\")\n    if not isinstance(note.get(\"title\"), str) or not isinstance(note.get(\"version\"), int):\n        raise ValueError(\"invalid cached fields\")\n    return note\n\ndef get_note(note_id):\n    raw = r.get(key(note_id))\n    if raw is not None:\n        try:\n            return decode(raw, note_id), \"cache\"\n        except (ValueError, KeyError, TypeError):\n            r.delete(key(note_id))\n    note = load_note(note_id)\n    if note is not None:\n        r.set(key(note_id), encode(note), ex=60)\n    return note, \"database\"",
+        "lang": "python",
+        "filename": "note_cache.py"
+      },
+      {
+        "title": "对照两次读取来源",
+        "body": "保存为 cache_read.py。只清除当前练习笔记的一个缓存键。第一次来源 database,第二次 cache,内容应相等,TTL 在 1 到 60 之间。没有记录时返回 None,本课尚未缓存不存在的结果。",
+        "code": "import os\nfrom redis_client import r\nfrom note_cache import key, get_note\n\nnote_id = int(os.environ[\"NOTE_ID\"])\nr.delete(key(note_id))\nfirst, source1 = get_note(note_id)\nsecond, source2 = get_note(note_id)\nassert first == second and first is not None\nprint(source1, source2, r.ttl(key(note_id)))\nprint(first)",
+        "lang": "python",
+        "filename": "cache_read.py"
+      },
+      {
+        "title": "注入坏格式并验证自动回源",
+        "body": "先写入不可解析文本,再写入不受支持的 schema。每次调用都应返回 database 和合法笔记,然后缓存被合法 JSON 替换。Redis 网络错误尚未在此函数内处理,第 06 和 09 课补齐有界降级。",
+        "code": "import os\nfrom redis_client import r\nfrom note_cache import key, get_note\n\nnote_id = int(os.environ[\"NOTE_ID\"])\nfor bad in [\"not-json\", '{\"schema\":99,\"note\":{\"id\":1}}']:\n    r.set(key(note_id), bad, ex=60)\n    note, source = get_note(note_id)\n    print(source, note[\"id\"])\n    assert source == \"database\"",
+        "lang": "python",
+        "filename": "cache_bad_format.py"
+      }
+    ],
+    "checks": [
+      "第一次返回 database,第二次返回 cache,业务字段一致。",
+      "缓存含 schema 和 note.version,时间为明确时区字符串。",
+      "坏 JSON 和未知 schema 不直接导致业务解码崩溃,回源后得到有效内容。"
+    ],
+    "pitfalls": [
+      {
+        "symptom": "json.dumps 报 datetime is not JSON serializable",
+        "cause": "直接把数据库 datetime 对象塞进缓存。",
+        "fix": "在固定序列化边界转换为 ISO 8601,或使用受控模型序列化;不要随意 str 整个对象。"
+      },
+      {
+        "symptom": "修改代码增加字段后旧缓存导致 KeyError",
+        "cause": "缓存格式没有版本和校验,部署后仍读取旧结构。",
+        "fix": "提升缓存 schema 或键名前缀,允许旧键自然过期;读取方明确接受的结构。"
+      }
+    ],
+    "exercise": {
+      "task": "让记录不存在时也能返回明确来源,并说明为什么不能把数据库异常当成不存在。",
+      "hint": "None 表示成功查询且无行;连接异常代表没有完成查询。",
+      "answer": "load_note 返回 None 时 get_note 返回 (None,\"database\")。捕获不到数据库时不能伪造 None,否则会把故障错误缓存成不存在;应向调用方返回可区分的服务错误,后续负缓存只处理确定的无行结果。"
+    },
+    "refs": [
+      {
+        "label": "Redis:GET",
+        "url": "https://redis.io/docs/latest/commands/get/"
+      },
+      {
+        "label": "Redis:Python 客户端",
+        "url": "https://redis.io/docs/latest/develop/clients/redis-py/"
+      },
+      {
+        "label": "Python:json",
+        "url": "https://docs.python.org/3/library/json.html"
+      }
+    ],
+    "production": {
+      "scenario": "同一笔记被反复读取,希望降低数据库访问同时保留格式升级能力。",
+      "decisions": [
+        "PostgreSQL 成功读取后才回填。",
+        "缓存格式版本和业务版本分离。",
+        "异常与不存在使用不同结果。"
+      ],
+      "failureDrill": "给单个练习键写入坏 JSON 和未知 schema,检查回源和重新编码。",
+      "acceptance": [
+        "命中来源可观测。",
+        "无任意对象反序列化。",
+        "缓存值不包含未设计对外展示的敏感字段。"
+      ]
+    }
+  },
+  {
+    "id": "03",
+    "title": "提交后失效缓存,并复现旧值回填竞态",
+    "group": "读取与一致性",
+    "summary": "把失效放到事务提交之后,按固定时序制造旧快照覆盖,明确最终一致性的边界。",
+    "objectives": [
+      "正确安排数据库提交与缓存删除的顺序",
+      "复现先读旧值后回填的竞态",
+      "设计失效失败后的重试与一致性验收"
+    ],
+    "concepts": [
+      {
+        "title": "先提交数据库,再失效缓存",
+        "body": "数据库提交成功后删除详情缓存,下一次读取从真源重建。如果在提交前删除,另一个读请求可能读到尚未更新的数据库旧值并重新缓存。提交后删除减少这一类窗口,但不消除所有并发竞态。"
+      },
+      {
+        "title": "旧读取可以晚于新写入回填",
+        "body": "读者 A 在写者提交前读到 v1,写者 B 提交 v2 并删除缓存,A 随后把 v1 回填。即使删除发生在提交后,缓存仍可能短暂陈旧。TTL 只限制一次缓存副本的存活时间,不应被表述为完整一致性证明。"
+      },
+      {
+        "title": "失效失败是待补偿工作",
+        "body": "PostgreSQL 和 Redis 没有共享一个本地事务。数据库已提交、DEL 失败时不能声称数据库回滚。生产可在同一数据库事务写 Outbox,由后台可靠重试失效,结合短 TTL;要求立即读取自己写入结果时直接返回数据库 RETURNING 数据或绕过缓存。"
+      }
+    ],
+    "steps": [
+      {
+        "title": "实现带版本检查的更新与提交后删除",
+        "body": "保存为 cache_write.py。expected_version 来自读取结果。上下文退出才表示提交成功,随后才删 Redis。示例返回 invalidated 标志供练习观察;生产失效失败应写入可靠补偿流程并告警,这里没有实现 Outbox,不能当成完整可靠投递。",
+        "code": "import psycopg\nfrom redis.exceptions import RedisError\nfrom note_cache import key\nfrom redis_client import r\n\ndef rename_note(note_id, title, expected_version):\n    with psycopg.connect(\"\") as conn:\n        row = conn.execute(\n            \"UPDATE notes SET title=%s,version=version+1,updated_at=now() \"\n            \"WHERE id=%s AND version=%s RETURNING id,title,version\",\n            (title, note_id, expected_version),\n        ).fetchone()\n        if row is None:\n            raise ValueError(\"not found or version conflict\")\n    try:\n        r.delete(key(note_id))\n        invalidated = True\n    except RedisError:\n        invalidated = False\n    return {\"id\": row[0], \"title\": row[1], \"version\": row[2], \"invalidated\": invalidated}",
+        "lang": "python",
+        "filename": "cache_write.py"
+      },
+      {
+        "title": "验证成功修改后的首次回源",
+        "body": "保存为 cache_invalidate.py 并运行。先预热旧值,再以当前版本更新。更新后键不存在,下一次 get_note 返回数据库的新标题与新版本。重复运行时从数据库读取当前版本,不假定总是 1。",
+        "code": "import os\nfrom note_cache import get_note, key, load_note\nfrom cache_write import rename_note\nfrom redis_client import r\n\nn = int(os.environ[\"NOTE_ID\"])\nbefore = load_note(n)\nr.delete(key(n))\nget_note(n)\nprint(rename_note(n, \"cache03-new-title\", before[\"version\"]))\nassert r.get(key(n)) is None\nafter, source = get_note(n)\nprint(source, after[\"title\"], after[\"version\"])\nassert source == \"database\" and after[\"version\"] == before[\"version\"] + 1",
+        "lang": "python",
+        "filename": "cache_invalidate.py"
+      },
+      {
+        "title": "按确定时序重现旧值晚回填",
+        "body": "本脚本将两个并发请求的步骤按顺序手工调度,结果不依赖线程调度或 sleep。只操作 NOTE_ID 专用笔记。A 先读取旧值并暂停,B 完成提交和失效,A 最后回填旧值;读取命中旧版本,数据库版本更高。",
+        "code": "import os\nfrom note_cache import load_note, get_note, encode, key\nfrom cache_write import rename_note\nfrom redis_client import r\n\nn = int(os.environ[\"NOTE_ID\"])\nr.delete(key(n))\nsnapshot_a = load_note(n)  # A: 已回源,尚未回填\nwritten_b = rename_note(n, \"cache03-race-new\", snapshot_a[\"version\"])  # B\nr.set(key(n), encode(snapshot_a), ex=5)  # A: 旧值晚到\ncached, source = get_note(n)\nfresh = load_note(n)\nprint(\"cached\", source, cached[\"version\"], \"database\", fresh[\"version\"])\nassert cached[\"version\"] < fresh[\"version\"]\nr.delete(key(n))  # 清理本次故障注入\nassert get_note(n)[0][\"version\"] == fresh[\"version\"]",
+        "lang": "python",
+        "filename": "cache_stale_refill.py"
+      },
+      {
+        "title": "制定可检查的失效策略",
+        "body": "为 notes-service 的详情、列表、标签聚合分别列出受一次编辑影响的键。详情可精确删除;列表缓存需要版本化命名空间或短 TTL,不能用 KEYS 模糊删除整个实例。将下列记录保存为开发验收清单,后续 Outbox 模块实现重试。",
+        "code": "事件:note.updated,字段 note_id / committed_version / event_id\n写入:业务 UPDATE 与 outbox INSERT 在同一个 PostgreSQL 事务\n消费:按事件删除详情键及明确的派生键,成功后记录处理状态\n重复:DEL 对不存在键仍可安全重放\n失败:有限退避后进入待处理队列并告警,不丢弃失效事件\n读取:写接口返回数据库实际版本;强一致读取显式绕过缓存\n观测:失效失败数、最老未处理事件年龄、回源率、旧版本样本",
+        "lang": "text"
+      }
+    ],
+    "checks": [
+      "DEL 只出现在数据库事务成功退出后。",
+      "固定时序实验能得到缓存版本小于数据库版本,清理后恢复。",
+      "设计文档区分提交成功与失效成功,并给失效失败安排可靠补偿。"
+    ],
+    "pitfalls": [
+      {
+        "symptom": "在异常里删缓存,用户仍看到旧值",
+        "cause": "失效顺序没有跟随真实提交结果,或遗漏派生列表键。",
+        "fix": "围绕事务提交定义事件,列出所有派生缓存;不要以请求函数是否执行完代替提交状态。"
+      },
+      {
+        "symptom": "采用延迟双删后宣称缓存强一致",
+        "cause": "延迟长短无法覆盖任意暂停、网络延迟和进程故障。",
+        "fix": "把延迟删除视为减少窗口的措施;依据业务选择可接受陈旧、可靠事件或直接读真源。"
+      }
+    ],
+    "exercise": {
+      "task": "数据库更新已提交但 Redis 断开时,接口能否返回“数据库操作已回滚”?应记录什么?",
+      "hint": "数据库提交和 Redis 失效是两个独立结果。",
+      "answer": "不能报告回滚。应保留实际提交版本,记录失效待补偿事件、event_id 和失败原因,采用 Outbox 等可靠机制重试。写响应可直接返回已提交数据,强一致读取绕过缓存;不要盲目重试创建造成重复记录。"
+    },
+    "refs": [
+      {
+        "label": "Redis:DEL",
+        "url": "https://redis.io/docs/latest/commands/del/"
+      },
+      {
+        "label": "Redis:客户端缓存与失效",
+        "url": "https://redis.io/docs/latest/develop/reference/client-side-caching/"
+      },
+      {
+        "label": "PostgreSQL 16:事务",
+        "url": "https://www.postgresql.org/docs/16/tutorial-transactions.html"
+      }
+    ],
+    "production": {
+      "scenario": "编辑提交成功后用户立即刷新,后台又可能存在较慢的并发读取。",
+      "decisions": [
+        "提交后删除详情缓存。",
+        "用确定性时序测试陈旧回填。",
+        "可靠失效事件与直接读写结果满足不同一致性需求。"
+      ],
+      "failureDrill": "仅对 NOTE_ID 练习笔记模拟旧读取在新提交之后回填,比较两个版本并清理故障值。",
+      "acceptance": [
+        "没有宣称 Cache-Aside 天然强一致。",
+        "失效故障不会伪装成数据库回滚。",
+        "派生缓存清单和重试责任明确。"
+      ]
+    }
+  },
+  {
+    "id": "04",
+    "title": "阻止缓存穿透:输入校验与短期负缓存",
+    "group": "热点与流量保护",
+    "summary": "对确定不存在的编号缓存短期空结果,并避免把故障或权限拒绝当作全局不存在。",
+    "objectives": [
+      "区分格式非法、不存在和无权限",
+      "为不存在结果设计明确编码和短 TTL",
+      "限制攻击者制造的负缓存键数量"
+    ],
+    "concepts": [
+      {
+        "title": "穿透是大量请求持续落到真源",
+        "body": "查询从不存在的编号时,普通 Cache-Aside 每次都得到空结果而不回填,会重复访问数据库。输入范围校验先拦掉无意义请求,负缓存减少同一个合法但不存在编号的重复查询。"
+      },
+      {
+        "title": "空结果必须有明确标记",
+        "body": "Redis GET 返回 None 表示缓存键不存在,不等于业务记录不存在。负缓存使用 found=false;正缓存使用 found=true 和 note,读取时分别处理。数据库连接失败绝不能被编码为 found=false。"
+      },
+      {
+        "title": "权限不能被共享缓存省略",
+        "body": "记录缓存只是数据副本,不代替鉴权。多租户应用应先完成主体与租户校验,并把需要隔离的租户放进键名。单用户课程暂不引入租户字段;不能把某个用户的 403 缓存成所有用户都不存在。"
+      }
+    ],
+    "steps": [
+      {
+        "title": "实现正负结果独立编码",
+        "body": "保存为 negative_cache.py。单独使用 v1:lookup 命名空间,不与第 02 课 JSON 结构混用。正结果 60 秒,确定无行 5 秒。类型校验拒绝 bool 作为整数编号;上限 2^63-1 对应数据库 bigint 的正数范围。",
+        "code": "import json\nfrom redis_client import r\nfrom note_cache import load_note\n\ndef lookup_key(note_id):\n    return f\"notes:lab:v1:lookup:{{{note_id}}}\"\n\ndef get_with_negative(note_id):\n    if type(note_id) is not int or not 1 <= note_id <= 2**63 - 1:\n        raise ValueError(\"invalid note id\")\n    k = lookup_key(note_id)\n    raw = r.get(k)\n    if raw is not None:\n        try:\n            data = json.loads(raw)\n            if data[\"schema\"] != 1 or type(data[\"found\"]) is not bool:\n                raise ValueError(\"bad cache\")\n            if data[\"found\"] is False:\n                return None, \"negative-cache\"\n            note = data[\"note\"]\n            if not isinstance(note, dict) or note.get(\"id\") != note_id:\n                raise ValueError(\"bad note\")\n            return note, \"cache\"\n        except (ValueError, KeyError, TypeError):\n            r.delete(k)\n    note = load_note(note_id)  # 异常继续向外传递,不伪造不存在\n    found = note is not None\n    payload = {\"schema\": 1, \"found\": found, \"note\": note}\n    r.set(k, json.dumps(payload), ex=60 if found else 5)\n    return note, \"database\"",
+        "lang": "python",
+        "filename": "negative_cache.py"
+      },
+      {
+        "title": "选择确认不存在的编号并查询两次",
+        "body": "在独立练习库选择 max(id)+100000,先确认没有该记录。这里只做课堂样本选择,不给线上攻击者提供枚举工具。第一次 database、第二次 negative-cache,数据库错误没有进入这条成功路径。",
+        "code": "import psycopg\nfrom negative_cache import get_with_negative, lookup_key\nfrom redis_client import r\n\nwith psycopg.connect(\"\") as conn:\n    missing_id = conn.execute(\"SELECT coalesce(max(id),0)+100000 FROM notes\").fetchone()[0]\nr.delete(lookup_key(missing_id))\nprint(get_with_negative(missing_id))\nprint(get_with_negative(missing_id))\nprint(\"ttl\", r.ttl(lookup_key(missing_id)))\nr.delete(lookup_key(missing_id))",
+        "lang": "python",
+        "filename": "negative_probe.py"
+      },
+      {
+        "title": "让非法编号在访问存储之前失败",
+        "body": "保存并运行。bad 值都会触发 ValueError;后续 API 接入时由参数模型先执行同等校验。单个非法值的验证不要发起 Redis 或 PostgreSQL 请求。",
+        "code": "from negative_cache import get_with_negative\n\nfor bad in [0, -1, True, \"12\", 2**63]:\n    try:\n        get_with_negative(bad)\n    except ValueError as exc:\n        print(repr(bad), str(exc))\n    else:\n        raise AssertionError(\"invalid id reached storage\")",
+        "lang": "python",
+        "filename": "negative_invalid.py"
+      },
+      {
+        "title": "把创建与更新失效覆盖到负缓存",
+        "body": "将 lookup_key 对应键加入提交后失效清单。新建时数据库返回 id 后再删除同一编号的负缓存;更新与删除时同时失效详情和 lookup 两种命名空间。下面函数只演示失效动作,调用必须放在业务事务提交后。",
+        "code": "from redis_client import r\nfrom note_cache import key\nfrom negative_cache import lookup_key\n\ndef invalidate_note_caches(note_id):\n    return r.delete(key(note_id), lookup_key(note_id))\n\n# 调用位置:数据库提交成功后,或可靠 Outbox 消费函数中。\n# 不在这里捕获错误后无声丢弃。",
+        "lang": "python",
+        "filename": "cache_invalidate_keys.py"
+      }
+    ],
+    "checks": [
+      "无行结果只回源一次,短期第二次返回 negative-cache。",
+      "非法编号在存储访问之前被拒绝。",
+      "新建、修改和删除的失效清单包含负缓存键,数据库错误不写入负缓存。"
+    ],
+    "pitfalls": [
+      {
+        "symptom": "新建笔记后短时间仍显示不存在",
+        "cause": "创建前留下负缓存,创建后没有失效。",
+        "fix": "用数据库返回的真实 id 删除对应负缓存,并设置较短负 TTL。"
+      },
+      {
+        "symptom": "缓存里出现海量随机不存在编号",
+        "cause": "攻击者每次换编号,短 TTL 也无法阻止瞬时键膨胀。",
+        "fix": "在入口鉴权和限流,限制参数与访问范围;观察负缓存创建率和内存,不依赖负缓存单独抗攻击。"
+      }
+    ],
+    "exercise": {
+      "task": "如果增加布隆过滤器,返回“可能存在”和“确定不在过滤器”各意味着什么?",
+      "hint": "过滤器有误判,且构建及更新是否完整决定能否用来拒绝查询。",
+      "answer": "可能存在仍需查缓存或数据库。确定不在过滤器只有在过滤器完整且新增写入同步维护时才可拒绝;过滤器延迟会误拒绝新记录,因此需要回源或版本化切换策略。它不能代替权限检查与数据库真源。"
+    },
+    "refs": [
+      {
+        "label": "Redis:过期键",
+        "url": "https://redis.io/docs/latest/commands/expire/"
+      },
+      {
+        "label": "Redis:布隆过滤器",
+        "url": "https://redis.io/docs/latest/develop/data-types/probabilistic/bloom-filter/"
+      },
+      {
+        "label": "Redis:SCAN",
+        "url": "https://redis.io/docs/latest/commands/scan/"
+      }
+    ],
+    "production": {
+      "scenario": "重复访问不存在笔记的请求增加,数据库空查询消耗开始明显。",
+      "decisions": [
+        "格式校验先于缓存读取。",
+        "只缓存确定的无行结果,负 TTL 短于正常缓存。",
+        "负缓存结合入口限流与容量监控。"
+      ],
+      "failureDrill": "对确定不存在的练习编号连续读取,再提交非法编号,比较来源和存储请求。",
+      "acceptance": [
+        "不存在与故障分开。",
+        "没有以负缓存绕过鉴权。",
+        "新增路径失效负缓存。"
+      ]
+    }
+  },
+  {
+    "id": "05",
+    "title": "抑制热点击穿:带令牌的单飞回填",
+    "group": "热点与流量保护",
+    "summary": "让同一热点键过期时只有少量请求回源,使用原子令牌检查防止旧持有者误删新锁。",
+    "objectives": [
+      "用 SET NX PX 合并同一键的回源",
+      "通过 Lua 原子校验令牌和释放",
+      "给竞争者等待与锁过期设计明确结果"
+    ],
+    "concepts": [
+      {
+        "title": "单飞用于压低重复工作",
+        "body": "同一个热点缓存失效时,许多请求同时回源会压垮数据库。短期填充锁让一个请求重建,其他请求短暂等待缓存或收到忙响应。它服务于缓存效率,不承诺跨故障条件下业务副作用严格只执行一次。"
+      },
+      {
+        "title": "释放时检查所有者令牌",
+        "body": "每次竞争生成随机 token,并用 SET lock token NX PX 同时设置所有权和租期。释放必须在一段 Lua 中比较 token 再删除;先 GET 后 DEL 存在两条命令之间被新持有者接手的窗口。Redis 7 示例使用 Lua,不使用较新版本的条件删除命令。"
+      },
+      {
+        "title": "过期意味着可能出现另一位持有者",
+        "body": "进程暂停或回源过慢会让锁先过期。旧持有者恢复后不能删除或发布覆盖新持有者结果。下面脚本把校验、回填与释放放在一次 Lua 中;数据库并发更新仍有上一课的陈旧回填问题,单飞并不自动解决缓存一致性。"
+      }
+    ],
+    "steps": [
+      {
+        "title": "保存安全释放脚本",
+        "body": "保存为 cache_singleflight.py,后续步骤继续向此文件追加。键名使用同一个 {note_id} 标签,便于将来多键脚本要求同槽;当前未部署 Redis Cluster。脚本只有固定数量短操作,不在 Redis 内做长循环或外部访问。",
+        "code": "import secrets\nimport time\nfrom redis_client import r\nfrom note_cache import key, load_note, encode, decode\n\nclass CacheBusy(RuntimeError):\n    pass\n\nrelease = r.register_script(\"\"\"\nif redis.call('GET', KEYS[1]) == ARGV[1] then\n  return redis.call('DEL', KEYS[1])\nend\nreturn 0\n\"\"\")\n\npublish = r.register_script(\"\"\"\nif redis.call('GET', KEYS[1]) ~= ARGV[1] then return 0 end\nredis.call('SET', KEYS[2], ARGV[2], 'PX', ARGV[3])\nredis.call('DEL', KEYS[1])\nreturn 1\n\"\"\")",
+        "lang": "python",
+        "filename": "cache_singleflight.py"
+      },
+      {
+        "title": "实现有限等待与二次读取",
+        "body": "追加到 cache_singleflight.py。获得锁后再读一次缓存,避免等待途中别人已经回填。竞争失败最多等 0.3 秒,每 20 毫秒看一次结果;耗尽后 CacheBusy,应映射为受控 503 或按策略返回允许的旧值。不能让所有竞争者超时后同时无限回源。",
+        "code": "def singleflight_note(note_id, loader=load_note):\n    cache_key, lock_key = key(note_id), key(note_id) + \":fill\"\n    raw = r.get(cache_key)\n    if raw is not None:\n        return decode(raw, note_id)\n    token = secrets.token_urlsafe(24)\n    if r.set(lock_key, token, nx=True, px=2000):\n        try:\n            raw = r.get(cache_key)\n            if raw is not None:\n                return decode(raw, note_id)\n            note = loader(note_id)\n            if note is None:\n                return None\n            stored = publish(keys=[lock_key, cache_key], args=[token, encode(note), 60000])\n            if not stored:\n                raise CacheBusy(\"fill lease expired\")\n            return note\n        finally:\n            release(keys=[lock_key], args=[token])\n    deadline = time.monotonic() + 0.3\n    while time.monotonic() < deadline:\n        time.sleep(0.02)\n        raw = r.get(cache_key)\n        if raw is not None:\n            return decode(raw, note_id)\n    raise CacheBusy(\"cache fill still running\")",
+        "lang": "python",
+        "filename": "cache_singleflight.py"
+      },
+      {
+        "title": "并发验证回源次数",
+        "body": "保存为 cache_singleflight_probe.py。8 个线程同时读取同一笔记,模拟回源增加 80 毫秒。正常本机负载下 calls 应为 1;若发生 Redis 超时或调度超过等待期限,应把失败视为实际故障记录,不删除断言掩盖问题。专用记录必须存在,运行前清理详情和填充锁。",
+        "code": "import os\nimport threading\nimport time\nfrom concurrent.futures import ThreadPoolExecutor\nfrom note_cache import key, load_note\nfrom redis_client import r\nfrom cache_singleflight import singleflight_note\n\nn = int(os.environ[\"NOTE_ID\"])\nr.delete(key(n), key(n) + \":fill\")\nguard = threading.Lock()\ncalls = 0\n\ndef counted_loader(note_id):\n    global calls\n    with guard:\n        calls += 1\n    time.sleep(0.08)\n    return load_note(note_id)\n\nwith ThreadPoolExecutor(max_workers=8) as workers:\n    rows = list(workers.map(lambda _: singleflight_note(n, counted_loader), range(8)))\nassert all(row is not None and row[\"id\"] == n for row in rows)\nassert calls == 1\nprint(\"database loads\", calls)",
+        "lang": "python",
+        "filename": "cache_singleflight_probe.py"
+      },
+      {
+        "title": "验证旧令牌不能释放新锁",
+        "body": "保存为 cache_lock_token.py。只对本课 token-probe 键模拟替换所有者,原令牌释放返回 0,键仍为 new-owner。这个实验直接设置新 token 以固定复现所有权变化;真实场景通常由原锁过期后竞争产生。",
+        "code": "from redis_client import r\nfrom cache_singleflight import release\n\nk = \"notes:lab:token-probe\"\nr.set(k, \"old-owner\", px=2000)\nr.set(k, \"new-owner\", px=2000)\nassert release(keys=[k], args=[\"old-owner\"]) == 0\nassert r.get(k) == \"new-owner\"\nassert release(keys=[k], args=[\"new-owner\"]) == 1\nprint(\"ownership check passed\")",
+        "lang": "python",
+        "filename": "cache_lock_token.py"
+      }
+    ],
+    "checks": [
+      "正常热点并发实验只回源一次,其余读取缓存结果。",
+      "旧 token 不能删除新 token 的锁。",
+      "等待、租期、Redis 超时各有上限,锁过期后不发布旧持有者结果。"
+    ],
+    "pitfalls": [
+      {
+        "symptom": "finally 中 DEL 删除了别人的锁",
+        "cause": "原锁过期后另一请求获得锁,旧请求无条件删除。",
+        "fix": "使用 Lua 比较 token 并原子删除,不能把 GET 与 DEL 分开发。"
+      },
+      {
+        "symptom": "把缓存锁用于扣款并宣称绝不重复",
+        "cause": "租期过期、进程暂停或异步复制故障会破坏单持有者假设。",
+        "fix": "业务正确性使用数据库约束、幂等键或具备 fencing 校验的资源;缓存锁只减少重复回源。"
+      }
+    ],
+    "exercise": {
+      "task": "把 loader 延迟改为 2.2 秒,预测原持有者和竞争者结果,并说明生产应如何处理。",
+      "hint": "填充租期 2 秒,竞争者等待 0.3 秒;二者是不同预算。",
+      "answer": "竞争者先收到 CacheBusy;原持有者回源完成后 publish 因 token 已过期返回 0,也抛出 CacheBusy。应该限制回源耗时、选择合适租期并监控失败;若需要续租必须验证所有者且限定总时间,不能无限延长。"
+    },
+    "refs": [
+      {
+        "label": "Redis:分布式锁与单实例令牌",
+        "url": "https://redis.io/docs/latest/develop/clients/patterns/distributed-locks/"
+      },
+      {
+        "label": "Redis:SET",
+        "url": "https://redis.io/docs/latest/commands/set/"
+      },
+      {
+        "label": "Redis:EVAL",
+        "url": "https://redis.io/docs/latest/commands/eval/"
+      }
+    ],
+    "production": {
+      "scenario": "热门笔记缓存到期后突然出现大量相同详情请求。",
+      "decisions": [
+        "先用进程内合并或单实例填充锁降低重复回源。",
+        "令牌检查与发布、释放原子执行。",
+        "竞争失败有界等待,不把所有失败者直接推向数据库。"
+      ],
+      "failureDrill": "在本机令牌键替换所有者,再延长 loader 到超过租期,检查旧持有者行为。",
+      "acceptance": [
+        "旧持有者不能删新锁。",
+        "数据库回源次数有度量。",
+        "不把缓存锁当作生产业务唯一性保证。"
+      ]
+    }
+  },
+  {
+    "id": "06",
+    "title": "应对缓存雪崩:TTL 抖动、受控预热与降级",
+    "group": "热点与流量保护",
+    "summary": "分散批量过期时间,限制冷启动回源并发,在 Redis 失效时保护 PostgreSQL。",
+    "objectives": [
+      "给 TTL 加有界随机偏移",
+      "用限速小批次预热热点数据",
+      "让缓存降级服从数据库并发预算"
+    ],
+    "concepts": [
+      {
+        "title": "雪崩影响一批键或整个实例",
+        "body": "同一时刻大量键到期,或 Redis 整体不可用,会把原本被缓存吸收的流量推向数据库。TTL 抖动分散到期时刻,不能解决实例故障;需要回源限流和服务端负载保护。"
+      },
+      {
+        "title": "降级也有容量上限",
+        "body": "缓存可绕过不代表数据库能接住全量请求。为回源设置并发预算、等待上限、SQL 超时和入口限流,超出容量返回明确的暂不可用结果。不能在数据库也失败时伪造空笔记。"
+      },
+      {
+        "title": "预热从热点开始",
+        "body": "预热最常访问的有限集合,并控制批次大小、并发和速率。全库一起加载会抢占生产数据库资源和缓存容量。旧值兜底只适用于允许陈旧的字段,权限、删除状态和敏感信息不能随意继续返回。"
+      }
+    ],
+    "steps": [
+      {
+        "title": "观察有界 TTL 抖动分布",
+        "body": "保存为 cache_jitter.py。生成 100 个专用小键,TTL 均在 45 到 75 秒,真实设置后可能因执行消耗略减 1 秒。只清理本脚本创建的键,不扫描并删除其他缓存。",
+        "code": "import random\nfrom collections import Counter\nfrom redis_client import r\n\nkeys = [f\"notes:lab:jitter:{i}\" for i in range(100)]\nttls = [60 + random.randint(-15, 15) for _ in keys]\nwith r.pipeline(transaction=False) as pipe:\n    for k, ttl in zip(keys, ttls):\n        pipe.set(k, \"sample\", ex=ttl)\n    pipe.execute()\nprint(\"min/max\", min(ttls), max(ttls))\nprint(\"buckets\", sorted(Counter(ttls).items()))\nr.delete(*keys)",
+        "lang": "python",
+        "filename": "cache_jitter.py"
+      },
+      {
+        "title": "小批次预热最近笔记",
+        "body": "保存为 cache_warm.py。仅预热最多 20 条,顺序处理并在每条之间等待 50 毫秒;目的是控制负载,不是追求最快跑完。真正热点应来自访问统计。本例直接用已有 load_note,生产切换到连接池并增加任务取消。",
+        "code": "import random\nimport time\nimport psycopg\nfrom note_cache import key, load_note, encode\nfrom redis_client import r\n\nwith psycopg.connect(\"\") as conn:\n    ids = [row[0] for row in conn.execute(\"SELECT id FROM notes ORDER BY id DESC LIMIT 20\")]\nfor note_id in ids:\n    note = load_note(note_id)\n    if note is not None:\n        r.set(key(note_id), encode(note), ex=60 + random.randint(-15, 15))\n    time.sleep(0.05)\nprint(\"warmed\", len(ids))",
+        "lang": "python",
+        "filename": "cache_warm.py"
+      },
+      {
+        "title": "实现有并发上限的回源降级",
+        "body": "保存为 cache_fallback.py。该函数直接实现读路径,不调用可能已经回源的 get_note 后再次回源,避免重复数据库查询。Redis GET/SET 失败可以绕过,PostgreSQL 异常仍交给调用者。Semaphore 只约束本进程;多进程预算需要总量规划。同步代码不能直接阻塞 async 路由。",
+        "code": "import random\nimport threading\nfrom redis.exceptions import RedisError\nfrom redis_client import r\nfrom note_cache import key, load_note, encode, decode\n\ndb_slots = threading.BoundedSemaphore(4)\n\nclass SourceBusy(RuntimeError):\n    pass\n\ndef get_available(note_id, loader=load_note):\n    try:\n        raw = r.get(key(note_id))\n        if raw is not None:\n            try:\n                return decode(raw, note_id), \"cache\"\n            except (ValueError, KeyError, TypeError):\n                pass\n    except RedisError:\n        pass  # 生产记录 redis_read_error 计数,不记录密码\n    if not db_slots.acquire(timeout=0.05):\n        raise SourceBusy(\"database fallback capacity exhausted\")\n    try:\n        note = loader(note_id)\n    finally:\n        db_slots.release()\n    if note is not None:\n        try:\n            r.set(key(note_id), encode(note), ex=60 + random.randint(-15, 15))\n        except RedisError:\n            pass  # 生产记录 redis_fill_error 计数\n    return note, \"database\"",
+        "lang": "python",
+        "filename": "cache_fallback.py"
+      },
+      {
+        "title": "停掉本机缓存并验证仍可读取真源",
+        "body": "先在终端执行 docker stop notes-redis,仅影响本课专用实例;运行下面脚本,预期来源 database。完成后 docker start notes-redis 并再运行两次,后续可恢复 cache。不要在服务正在高负载时无保护地执行全局故障演练。",
+        "code": "import os\nfrom cache_fallback import get_available\n\nnote, source = get_available(int(os.environ[\"NOTE_ID\"]))\nassert note is not None\nprint(source, note[\"id\"], note[\"version\"])\n\n# Shell 完成后恢复:docker start notes-redis\n# 本课 Redis 未开启持久化,重启后的缓存需要重新构建。",
+        "lang": "python",
+        "filename": "cache_outage_probe.py"
+      }
+    ],
+    "checks": [
+      "TTL 分布有上下界,批量键没有全部同刻过期。",
+      "预热数量和速率有限,未把全库一次性加载。",
+      "专用 Redis 停止时仍读到 PostgreSQL 数据,回源超出预算时返回受控错误。"
+    ],
+    "pitfalls": [
+      {
+        "symptom": "缓存断开后数据库连接数瞬间暴涨",
+        "cause": "降级没有并发限制,重试与每次新建连接叠加。",
+        "fix": "按进程规划回源预算,使用池和短等待,超过容量快速拒绝;避免多层重试。"
+      },
+      {
+        "symptom": "抖动后出现零或负 TTL",
+        "cause": "随机偏移超过基础期限。",
+        "fix": "明确最小 TTL 并测试范围,例如 max(1,base+jitter),业务过期上限也要符合要求。"
+      }
+    ],
+    "exercise": {
+      "task": "在 Redis 停止时用 12 个线程和每次 0.2 秒的 loader 调用 get_available,预测 SourceBusy 的原因。",
+      "hint": "本进程只有 4 个回源名额,等待最多 0.05 秒。",
+      "answer": "前 4 个请求可占用回源名额,其余请求可能在名额释放前超时并得到 SourceBusy。实际数量由调度决定,验收应看峰值回源并发不超过 4、等待有界、恢复后正常,而非硬编码某个固定失败数。"
+    },
+    "refs": [
+      {
+        "label": "Redis:客户端生产使用",
+        "url": "https://redis.io/docs/latest/develop/clients/redis-py/produsage/"
+      },
+      {
+        "label": "Redis:EXPIRE",
+        "url": "https://redis.io/docs/latest/commands/expire/"
+      },
+      {
+        "label": "Redis:内存淘汰",
+        "url": "https://redis.io/docs/latest/develop/reference/eviction/"
+      }
+    ],
+    "production": {
+      "scenario": "发布或 Redis 重启后大量缓存同时为空,数据库必须保持可用。",
+      "decisions": [
+        "到期抖动、限速预热与故障降级分别处理不同触发条件。",
+        "数据库回源有硬并发预算。",
+        "允许陈旧数据的范围由业务定义。"
+      ],
+      "failureDrill": "仅停止 notes-redis,使用有限并发读取并测量回源峰值,随后恢复实例。",
+      "acceptance": [
+        "不会无限回源或无限等待。",
+        "数据库错误仍可见。",
+        "故障恢复后命中率逐步恢复而非立即全量预热。"
+      ]
+    }
+  },
+  {
+    "id": "07",
+    "title": "使用 Pipeline、WATCH 与 Lua 保证正确的原子操作",
+    "group": "原子操作与容量",
+    "summary": "比较批量发送、乐观事务和 Lua,在 Redis 7 中实现带过期窗口的原子计数限流。",
+    "objectives": [
+      "区别流水线减少往返与事务原子执行",
+      "处理 WATCH 发现的并发修改",
+      "用短 Lua 脚本组合 INCR 和到期设置"
+    ],
+    "concepts": [
+      {
+        "title": "Pipeline 首先减少网络往返",
+        "body": "transaction=False 的 pipeline 批量发送命令,不让多条业务操作自动变成一个不可分割动作。redis-py pipeline 默认启用事务,教学中明确写参数以免混淆。大批次会占内存和延长等待,应限制规模。"
+      },
+      {
+        "title": "WATCH 是提交前的乐观检查",
+        "body": "WATCH 键后读取当前值,MULTI 与 EXEC 提交修改;被其他客户端改动时 redis-py 抛 WatchError,应用重新读取并有限重试。Redis 事务与 PostgreSQL 不同,执行阶段某条命令出错不会撤销已经成功的其他命令。"
+      },
+      {
+        "title": "Lua 组合命令时先验证输入",
+        "body": "短 Lua 脚本在执行期间不被其他命令穿插。原子执行不代表发生运行时错误会回滚此前的写入。参数与数据类型应在变更前校验,脚本必须短,不能用长循环阻塞所有客户端。"
+      }
+    ],
+    "steps": [
+      {
+        "title": "批量发送并理解返回位置",
+        "body": "保存为 redis_pipeline.py。transaction=False 只减少往返,返回列表按命令顺序排列。专用键全部带 TTL,并在验收后显式删除。若一次批量中途网络断开,不能仅凭客户端异常判断所有命令都没执行。",
+        "code": "from redis_client import r\n\nkeys = [f\"notes:lab:pipeline:{i}\" for i in range(3)]\nwith r.pipeline(transaction=False) as pipe:\n    for i, k in enumerate(keys):\n        pipe.set(k, str(i), ex=60)\n    for k in keys:\n        pipe.get(k)\n    result = pipe.execute()\nprint(result)\nassert result == [True, True, True, \"0\", \"1\", \"2\"]\nr.delete(*keys)",
+        "lang": "python",
+        "filename": "redis_pipeline.py"
+      },
+      {
+        "title": "制造 WATCH 冲突并重读",
+        "body": "保存为 redis_watch.py。第一次 watch 后,使用客户端的另一次命令修改该键,确保 EXEC 抛 WatchError;第二次重新 watch 和读取,应从值 10 增加到 11。最多尝试 3 次,示例没有无限循环。",
+        "code": "from redis import WatchError\nfrom redis_client import r\n\nk = \"notes:lab:watch-counter\"\nr.set(k, 0, ex=60)\nfor attempt in range(3):\n    with r.pipeline() as pipe:\n        try:\n            pipe.watch(k)\n            current = int(pipe.get(k) or 0)\n            if attempt == 0:\n                r.set(k, 10, ex=60)  # 另一次命令制造冲突\n            pipe.multi()\n            pipe.set(k, current + 1, ex=60)\n            pipe.execute()\n            break\n        except WatchError:\n            print(\"conflict, retry from read\")\nelse:\n    raise RuntimeError(\"retry budget exhausted\")\nassert r.get(k) == \"11\"\nr.delete(k)",
+        "lang": "python",
+        "filename": "redis_watch.py"
+      },
+      {
+        "title": "实现原子计数与固定期限",
+        "body": "保存为 redis_limiter.py。脚本验证限制与窗口后 INCR,首次计数同时 PEXPIRE,避免 INCR 成功但 EXPIRE 未发出的永久计数器。窗口从首次请求开始,不是精确滑动窗口。网络超时重试可能再次计数,业务必须允许保守限流或使用独立请求幂等方案。",
+        "code": "from redis_client import r\n\nlimit_script = r.register_script(\"\"\"\nlocal limit = tonumber(ARGV[1])\nlocal window = tonumber(ARGV[2])\nif not limit or limit < 1 or limit % 1 ~= 0 then\n  return redis.error_reply('invalid limit')\nend\nif not window or window < 1 or window % 1 ~= 0 then\n  return redis.error_reply('invalid window')\nend\nlocal count = redis.call('INCR', KEYS[1])\nif count == 1 then redis.call('PEXPIRE', KEYS[1], window) end\nlocal allowed = 0\nif count <= limit then allowed = 1 end\nreturn {allowed, count, redis.call('PTTL', KEYS[1])}\n\"\"\")\n\ndef allow_request(subject):\n    # subject 必须是鉴权后得到的稳定主体 ID,不能是任意超长输入。\n    k = f\"notes:lab:limit:{{{subject}}}\"\n    allowed, count, ttl_ms = limit_script(keys=[k], args=[3, 1000])\n    return bool(allowed), count, ttl_ms",
+        "lang": "python",
+        "filename": "redis_limiter.py"
+      },
+      {
+        "title": "验证窗口内拒绝和到期恢复",
+        "body": "保存为 redis_limiter_probe.py。前 3 次允许,第 4 次拒绝且返回正的剩余毫秒;约 1.1 秒后新窗口允许。生产映射 429,并根据 TTL 向上取整计算 Retry-After;权限鉴定接口与普通缓存故障时的放行策略必须分别设计。",
+        "code": "import time\nfrom redis_client import r\nfrom redis_limiter import allow_request\n\nk = \"notes:lab:limit:{probe-user}\"\nr.delete(k)\nresults = [allow_request(\"probe-user\") for _ in range(4)]\nprint(results)\nassert [x[0] for x in results] == [True, True, True, False]\nassert results[-1][2] > 0\ntime.sleep(1.1)\nassert allow_request(\"probe-user\")[0] is True\nr.delete(k)",
+        "lang": "python",
+        "filename": "redis_limiter_probe.py"
+      }
+    ],
+    "checks": [
+      "Pipeline 返回顺序与命令顺序一致,批量大小有限。",
+      "WATCH 实验先失败再从新值重试,不覆盖外部修改。",
+      "限流前三次允许、第四次拒绝、窗口到期恢复,计数键存在时带 TTL。"
+    ],
+    "pitfalls": [
+      {
+        "symptom": "误把 transaction=False 的 Pipeline 当成原子业务操作",
+        "cause": "减少网络往返与命令不可交错是不同保证。",
+        "fix": "需要检查并更新用 WATCH,需要短组合操作用 Lua;明确事务选项。"
+      },
+      {
+        "symptom": "INCR 后进程崩溃留下永不过期限流键",
+        "cause": "EXPIRE 是另一条命令,没来得及发出。",
+        "fix": "把首次计数和到期设置放进短脚本;对专用命名空间监控 TTL=-1 异常。"
+      }
+    ],
+    "exercise": {
+      "task": "窗口边界附近为什么可能在很短时间内通过接近两倍限制的请求?",
+      "hint": "上一窗口末尾和下一窗口开头分别拥有各自额度。",
+      "answer": "固定窗口允许边界突发:旧窗口末尾用完额度,过期后新窗口立即重新计数。需要更平滑限制时可选择滑动窗口或令牌桶,评估精度、状态量和脚本成本;不能把本例当作精确的任意 1 秒最多 3 次。"
+    },
+    "refs": [
+      {
+        "label": "Redis:Pipeline 与事务",
+        "url": "https://redis.io/docs/latest/develop/clients/redis-py/transpipe/"
+      },
+      {
+        "label": "Redis:事务语义",
+        "url": "https://redis.io/docs/latest/develop/using-commands/transactions/"
+      },
+      {
+        "label": "Redis:Lua 脚本",
+        "url": "https://redis.io/docs/latest/develop/programmability/eval-intro/"
+      },
+      {
+        "label": "Redis:INCR",
+        "url": "https://redis.io/docs/latest/commands/incr/"
+      }
+    ],
+    "production": {
+      "scenario": "API 需要在多进程之间共享请求计数,并避免计数和过期配置分离。",
+      "decisions": [
+        "流水线、事务和 Lua 各用于明确的操作边界。",
+        "脚本短且输入受限。",
+        "限流故障策略不沿用普通数据缓存的无条件放行。"
+      ],
+      "failureDrill": "在专用键制造 WATCH 冲突与窗口超额请求,验证有限重试和原子期限。",
+      "acceptance": [
+        "没有永久计数键。",
+        "事务错误不被错误描述为自动回滚。",
+        "超时重试与边界突发的业务含义明确。"
+      ]
+    }
+  },
+  {
+    "id": "08",
+    "title": "控制缓存容量与淘汰,隔离任务 Broker",
+    "group": "原子操作与容量",
+    "summary": "在专用容量实例制造淘汰,观察命中率和内存,再区分可丢缓存与不可随意丢弃的队列数据。",
+    "objectives": [
+      "读取内存、淘汰和命中指标",
+      "根据数据用途选择 allkeys-lru 或 noeviction 等策略",
+      "解释为什么逻辑数据库不能替代实例隔离"
+    ],
+    "concepts": [
+      {
+        "title": "maxmemory 不是进程总内存上限",
+        "body": "Redis 的 maxmemory 控制参与淘汰计算的内存范围,进程 RSS、复制缓冲、持久化重写等还需要额外预算。容器内存上限应留余量,不能把 maxmemory 与容器限制设成完全相同。"
+      },
+      {
+        "title": "淘汰与到期不同",
+        "body": "到期按 TTL 删除,淘汰在内存压力下依据策略移除键。allkeys-lru 可从所有键中近似选择较久未使用的键;noeviction 在不足时拒绝某些需要新增内存的写命令。没有任何策略保证缓存永不丢失。"
+      },
+      {
+        "title": "缓存实例不承载任务可靠性",
+        "body": "任务 Broker、结果存储、锁与缓存可能要求不同持久化、淘汰和资源策略。逻辑 DB 0 与 DB 1 仍共享同一进程内存、淘汰配置和故障域。队列数据应按任务系统要求使用独立实例,noeviction 也只是拒写,不会自动让队列可靠。"
+      }
+    ],
+    "steps": [
+      {
+        "title": "启动单独容量实验实例",
+        "body": "以下 Linux Bash 只创建 notes-redis-capacity,宿主机 6381,与业务练习缓存 6380 分开。读取现有本机配置并只修改 maxmemory。不要对共享 Redis 执行 CONFIG SET 作为临时实验。镜像实例仍使用已有 REDIS_PASSWORD。",
+        "code": "python3 - <<'PY'\nfrom pathlib import Path\nsource = Path('redis-lab/redis.conf').read_text()\nPath('redis-lab/capacity.conf').write_text(source.replace('maxmemory 128mb', 'maxmemory 8mb'))\nPY\ndocker run --name notes-redis-capacity -d -p 127.0.0.1:6381:6379 \\\n  -v \"$PWD/redis-lab/capacity.conf:/usr/local/etc/redis/redis.conf:ro\" \\\n  redis:7 redis-server /usr/local/etc/redis/redis.conf",
+        "lang": "bash"
+      },
+      {
+        "title": "有限写入制造容量压力",
+        "body": "保存为 redis_capacity.py。最多写 2,000 个约 8KB 字符串,带 10 分钟 TTL;请求数和内存实验都有上限。运行前核对端口 6381。记录写入前后 evicted_keys 差值,不能用实例启动以来累计值直接推算本次淘汰。",
+        "code": "import os\nimport redis\n\nclient = redis.Redis(host=\"127.0.0.1\", port=6381, password=os.environ[\"REDIS_PASSWORD\"],\n                     decode_responses=True, socket_timeout=1, socket_connect_timeout=1)\nbefore = client.info(\"stats\")\nfor i in range(2000):\n    client.set(f\"notes:lab:capacity:{i}\", \"x\" * 8192, ex=600)\nafter = client.info(\"stats\")\nmemory = client.info(\"memory\")\nprint(\"evicted delta\", after[\"evicted_keys\"] - before[\"evicted_keys\"])\nprint(\"used / rss / max\", memory[\"used_memory\"], memory[\"used_memory_rss\"], memory[\"maxmemory\"])\nprint(\"remaining keys\", client.dbsize())\nprint(\"policy\", client.config_get(\"maxmemory-policy\"))\nclient.close()",
+        "lang": "python",
+        "filename": "redis_capacity.py"
+      },
+      {
+        "title": "区分命中率与淘汰率",
+        "body": "在容量实例读取 2,000 个明确键,统计本次命中和未命中。下面本地计数是这批请求的真实比例;INFO 的 keyspace_hits/misses 是实例全局累计值,做监控时用区间增量。不要用 KEYS * 在大实例全量扫描。",
+        "code": "import os\nimport redis\n\nclient = redis.Redis(host=\"127.0.0.1\", port=6381, password=os.environ[\"REDIS_PASSWORD\"],\n                     decode_responses=True, socket_timeout=1)\nhits = sum(client.get(f\"notes:lab:capacity:{i}\") is not None for i in range(2000))\nprint(\"hits\", hits, \"misses\", 2000-hits, \"ratio\", hits/2000)\nstats = client.info(\"stats\")\nprint({k: stats[k] for k in [\"keyspace_hits\", \"keyspace_misses\", \"evicted_keys\", \"expired_keys\"]})\nclient.close()",
+        "lang": "python",
+        "filename": "redis_capacity_hits.py"
+      },
+      {
+        "title": "形成缓存与 Broker 的独立配置",
+        "body": "把下列配置记录加入部署设计。容量实验完成后执行 docker stop notes-redis-capacity,保留环境便于复查,不触碰 6380。若要演练 noeviction,应复制配置另建独立实例,以收到内存不足写入错误为预期,并验证任务生产端如何处理拒写。",
+        "code": "普通笔记缓存:独立 cache 实例;明确 maxmemory;可用 allkeys-lru/LFU;值可从 PostgreSQL 重建\n任务 Broker:独立实例;按任务框架要求设置持久化与 noeviction;拒写必须使投递失败可见\n任务结果:独立 TTL 与保留期;持久业务状态放 PostgreSQL\n容量预算:容器限制 > maxmemory,并留复制、AOF 重写、碎片等余量\n告警:used_memory/maxmemory、RSS、evicted_keys 增量、命中率、写入拒绝数\n检查:DB 0 与 DB 1 仅键空间区分,不提供内存、CPU 或故障隔离",
+        "lang": "text"
+      }
+    ],
+    "checks": [
+      "专用容量实例 evicted_keys 增长,键数量小于写入总数。",
+      "命中率从本次读取统计,INFO 累计指标用增量解释。",
+      "设计明确缓存与 Broker 分实例、不同持久化和拒写处理。"
+    ],
+    "pitfalls": [
+      {
+        "symptom": "Redis 尚未到 maxmemory,容器已经 OOM",
+        "cause": "忽略 RSS、缓冲和持久化工作期间额外内存。",
+        "fix": "对照容器指标和 used_memory_rss,留容量余量并压测重写与复制场景。"
+      },
+      {
+        "symptom": "任务突然丢失,但 Redis 没报错",
+        "cause": "队列与普通缓存共用允许淘汰所有键的实例。",
+        "fix": "按 Broker 要求迁移到独立实例并禁止随意淘汰,同时完善任务确认、重投和持久业务状态。"
+      }
+    ],
+    "exercise": {
+      "task": "为什么把 Celery Broker 放 DB 1、缓存放 DB 0 仍可能互相影响?",
+      "hint": "想清楚 maxmemory、CPU、进程退出和持久化属于哪一层。",
+      "answer": "逻辑库共享同一 Redis 进程和服务器级配置,缓存写入可触发整体内存压力,CPU 长命令和实例故障也影响队列。需要独立实例与资源预算;不同 DB 编号只能减少键名碰撞。"
+    },
+    "refs": [
+      {
+        "label": "Redis:键淘汰",
+        "url": "https://redis.io/docs/latest/develop/reference/eviction/"
+      },
+      {
+        "label": "Redis:INFO",
+        "url": "https://redis.io/docs/latest/commands/info/"
+      },
+      {
+        "label": "Redis:内存优化",
+        "url": "https://redis.io/docs/latest/operate/oss_and_stack/management/optimization/memory-optimization/"
+      }
+    ],
+    "production": {
+      "scenario": "缓存规模增长且后台任务开始共用 Redis,需要避免缓存压力导致任务异常。",
+      "decisions": [
+        "实验容量实例独立于其他课程环境。",
+        "按进程 RSS 与服务负载规划余量。",
+        "可丢数据与可靠任务隔离。"
+      ],
+      "failureDrill": "在 6381 专用实例有限写入大值,观察淘汰与命中变化,完成后停止该实例。",
+      "acceptance": [
+        "未改动 6380 缓存策略。",
+        "队列拒写和任务恢复有处理路径。",
+        "告警使用区间指标与业务影响而非单一内存数值。"
+      ]
+    }
+  },
+  {
+    "id": "09",
+    "title": "配置连接池、超时与熔断并记录缓存指标",
+    "group": "连接与故障恢复",
+    "summary": "让 Redis 故障快速暴露,短期跳过持续失败的访问,并在恢复后用探测重新启用缓存。",
+    "objectives": [
+      "分别设置连接、读写和池等待超时",
+      "实现进程内有限状态熔断示例",
+      "记录命中、回源、错误与等待指标"
+    ],
+    "concepts": [
+      {
+        "title": "超时需要放进请求总预算",
+        "body": "连接超时、socket 超时、池等待和重试时间会叠加。API 总预算只有一秒时,不能给每层都设置一秒再重试三遍。写命令超时可能已经执行,重试 INCR、队列入队等操作需要额外语义设计。"
+      },
+      {
+        "title": "熔断短期跳过已知失败依赖",
+        "body": "连续 Redis 连接错误达到阈值后短暂打开熔断;冷却后只允许一个探测请求,其余请求继续走受控回源。恢复探测成功才关闭。下面是线程安全的进程内教学实现,不是跨所有进程同步的全局开关。"
+      },
+      {
+        "title": "命中率不是唯一目标",
+        "body": "高命中率也可能返回陈旧数据,低命中率也可能是新内容正常访问。至少同时观察缓存调用延迟、连接错误、回源并发、数据库延迟、失效失败与版本新鲜度。计数标签不能使用每个 note_id,否则监控基数失控。"
+      }
+    ],
+    "steps": [
+      {
+        "title": "显式配置池与关闭顺序",
+        "body": "保存为 redis_pool_client.py。BlockingConnectionPool 让借不到连接时最多等 50 毫秒;connect/socket 各 100 毫秒,Retry(...,0) 禁止本层自动重试,避免示例将预算偷偷翻倍。同步客户端供同步代码使用,async 路由选择 redis.asyncio 并 await。",
+        "code": "import os\nimport redis\nfrom redis.backoff import NoBackoff\nfrom redis.retry import Retry\n\npool = redis.BlockingConnectionPool(\n    host=os.environ.get(\"REDIS_HOST\", \"127.0.0.1\"),\n    port=int(os.environ.get(\"REDIS_PORT\", \"6380\")),\n    password=os.environ[\"REDIS_PASSWORD\"],\n    decode_responses=True, max_connections=8, timeout=0.05,\n    socket_connect_timeout=0.1, socket_timeout=0.1,\n    health_check_interval=30, retry=Retry(NoBackoff(), 0),\n)\nclient = redis.Redis(connection_pool=pool)\n\nif __name__ == \"__main__\":\n    try:\n        print(client.ping())\n    finally:\n        client.close()\n        pool.disconnect()",
+        "lang": "python",
+        "filename": "redis_pool_client.py"
+      },
+      {
+        "title": "实现线程安全的短期熔断器",
+        "body": "保存为 redis_breaker.py。只包裹 Redis 操作;业务 SQL 和 JSON 解析错误不能自动判作 Redis 断网。AUTH 等配置错误应报警修配置,不用无限探测掩盖。调用方对返回的 bypass 标志进入第 06 课的有界回源流程。",
+        "code": "import threading\nimport time\nfrom redis.exceptions import AuthenticationError, ConnectionError, TimeoutError\n\nclass RedisBreaker:\n    def __init__(self, threshold=3, cooldown=2):\n        self.threshold, self.cooldown = threshold, cooldown\n        self.failures = 0\n        self.open_until = 0.0\n        self.probing = False\n        self.generation = 0\n        self.lock = threading.Lock()\n\n    def call(self, operation):\n        now = time.monotonic()\n        with self.lock:\n            if self.open_until > now or self.probing:\n                return None, \"bypass\"\n            if self.open_until:\n                self.probing = True\n                self.generation += 1\n            generation = self.generation\n        try:\n            value = operation()\n        except AuthenticationError:\n            with self.lock:\n                if generation == self.generation:\n                    self.probing = False\n            raise  # 配置错误直接上报,不伪装成普通缓存未命中\n        except (ConnectionError, TimeoutError):\n            with self.lock:\n                if generation == self.generation:\n                    self.failures += 1\n                    if self.probing or self.failures >= self.threshold:\n                        self.open_until = time.monotonic() + self.cooldown\n                        self.generation += 1\n                    self.probing = False\n            return None, \"redis-error\"\n        except BaseException:\n            with self.lock:\n                if generation == self.generation:\n                    self.probing = False\n            raise\n        with self.lock:\n            if generation == self.generation:\n                self.failures, self.open_until, self.probing = 0, 0.0, False\n        return value, \"redis\"",
+        "lang": "python",
+        "filename": "redis_breaker.py"
+      },
+      {
+        "title": "不用停服务验证状态转移",
+        "body": "保存为 redis_breaker_probe.py。注入固定抛错函数,前 3 次真实失败,第 4 次直接 bypass;冷却后成功探测,下一次正常执行。此脚本先验证顺序行为;实现中的 generation 用于丢弃旧一代在途请求对新状态的覆盖,并发测试还需验证半开阶段只启动一个探测。",
+        "code": "import time\nfrom redis.exceptions import ConnectionError\nfrom redis_breaker import RedisBreaker\n\nbreaker = RedisBreaker(threshold=3, cooldown=0.1)\ncalls = 0\ndef broken():\n    global calls\n    calls += 1\n    raise ConnectionError(\"simulated unavailable\")\n\nfor _ in range(4):\n    print(breaker.call(broken))\nassert calls == 3\ntime.sleep(0.11)\nassert breaker.call(lambda: \"PONG\") == (\"PONG\", \"redis\")\nassert breaker.call(lambda: \"cached\") == (\"cached\", \"redis\")\nprint(\"recovered\")",
+        "lang": "python",
+        "filename": "redis_breaker_probe.py"
+      },
+      {
+        "title": "记录实际调用状态与延迟",
+        "body": "保存为 cache_metrics_probe.py。故障实验只停止 notes-redis 后运行,再重启后运行。下面状态为教学输出,生产导出聚合 counter/histogram,不记录密码、完整值或每条笔记作为标签。Redis miss 是 value=None 且 status=redis,不能与 bypass 合并成命中。",
+        "code": "import os\nimport time\nfrom note_cache import key\nfrom redis_pool_client import client, pool\nfrom redis_breaker import RedisBreaker\n\nbreaker = RedisBreaker()\ntry:\n    for _ in range(5):\n        started = time.perf_counter()\n        value, status = breaker.call(lambda: client.get(key(int(os.environ[\"NOTE_ID\"]))))\n        hit = status == \"redis\" and value is not None\n        print({\"status\":status,\"hit\":hit,\"duration_ms\":round((time.perf_counter()-started)*1000,2)})\nfinally:\n    client.close()\n    pool.disconnect()",
+        "lang": "python",
+        "filename": "cache_metrics_probe.py"
+      }
+    ],
+    "checks": [
+      "池等待、连接、socket 超时与重试次数均显式设置。",
+      "失败阈值、bypass、冷却探测与恢复能通过注入实验验证。",
+      "指标区分命中、未命中、Redis 错误和主动绕过,不记录高基数或敏感数据。"
+    ],
+    "pitfalls": [
+      {
+        "symptom": "Redis 已故障,但每个请求仍等很久",
+        "cause": "多层超时和自动重试叠加,没有总预算。",
+        "fix": "画出一次请求的所有等待预算,限制重试所在层;以实测故障延迟验收。"
+      },
+      {
+        "symptom": "一个并发成功请求把刚打开的熔断立即关闭",
+        "cause": "多个已在途调用同时回写熔断状态,若删除 generation 检查就会出现旧请求覆盖新状态。",
+        "fix": "保留 generation 校验:打开熔断或进入半开时推进代次,旧代次结果不改变新状态;补充并发探测测试或使用已有成熟组件。"
+      }
+    ],
+    "exercise": {
+      "task": "为什么缓存 GET 超时可以考虑回源,而任务入队超时不能直接断言“任务没入队”?",
+      "hint": "响应丢失不等于服务器没执行命令。",
+      "answer": "GET 是读取,失败后可按预算回源;入队命令可能已执行但响应丢失,直接再次入队可能重复。使用任务 ID、幂等消费、投递记录与状态核对,重试策略按操作语义设计。"
+    },
+    "refs": [
+      {
+        "label": "Redis:生产客户端使用",
+        "url": "https://redis.io/docs/latest/develop/clients/redis-py/produsage/"
+      },
+      {
+        "label": "Redis:Python 错误处理",
+        "url": "https://redis.io/docs/latest/develop/clients/redis-py/error-handling/"
+      },
+      {
+        "label": "redis-py 5.3:连接池与重试参数",
+        "url": "https://redis.readthedocs.io/en/v5.3.0/connections.html"
+      }
+    ],
+    "production": {
+      "scenario": "Redis 网络故障时,API 需要在可接受延迟内切换到受控回源,并在恢复后重新命中。",
+      "decisions": [
+        "超时预算以完整请求为单位计算。",
+        "进程内熔断只负责快速跳过 Redis,回源仍受容量限制。",
+        "通过状态代次阻止在途旧响应覆盖新状态,并对半开探测做并发验收。"
+      ],
+      "failureDrill": "注入连续连接错误验证熔断转移,再用专用实例停止恢复观察实际延迟和来源。",
+      "acceptance": [
+        "故障延迟有上限。",
+        "没有把所有 Redis 错误当成普通 miss。",
+        "连接池在进程退出时关闭。"
+      ]
+    }
+  },
+  {
+    "id": "10",
+    "title": "演练重启、复制与持久化恢复",
+    "group": "连接与故障恢复",
+    "summary": "对比可重建缓存与开启 AOF 的实验实例,检查副本状态,并建立故障后的业务验收顺序。",
+    "objectives": [
+      "验证无持久化缓存重启后的回源",
+      "在隔离实例验证 AOF 重启恢复",
+      "理解异步复制不等于零数据丢失或自动高可用"
+    ],
+    "concepts": [
+      {
+        "title": "RDB 与 AOF 保存不同形式",
+        "body": "RDB 保存某个时点的快照;AOF 记录写操作并按 fsync 策略落盘。appendfsync everysec 在故障下仍有近期数据丢失窗口。正常重启看到值还在,只证明该次正常路径,不能据此宣称断电零丢失。"
+      },
+      {
+        "title": "复制不是自动故障转移",
+        "body": "Redis 主从默认异步复制,主节点确认写入时副本可能尚未收到。WAIT 可等待副本确认复制,仍不把系统变成强一致存储。生产自动故障转移由 Sentinel、Cluster 或受管平台等承担,部署拓扑与客户端发现都需独立验证。"
+      },
+      {
+        "title": "恢复验收从真源开始",
+        "body": "先确认 PostgreSQL 数据完整与可用,再检查 Redis 连接、复制角色、命中和回源限流。缓存可丢时不必恢复每一个旧键,受控重建通常更直接。任务 Broker 恢复还要验收未确认任务与重复消费,不用缓存恢复结果替代任务验收。"
+      }
+    ],
+    "steps": [
+      {
+        "title": "确认无持久化缓存重启后可回源",
+        "body": "使用第 02 课 NOTE_ID 预热后,记录缓存存在。执行 docker restart notes-redis,仅重启专用本机实例,然后再次读取应先 database 再 cache。配置 save \"\" 与 appendonly no 禁用了持久化,但 PostgreSQL 的笔记仍然存在。",
+        "code": "import os\nfrom note_cache import get_note, key\nfrom redis_client import r\n\nn = int(os.environ[\"NOTE_ID\"])\nprint(get_note(n)[1])\nprint(\"cache exists\", bool(r.exists(key(n))))\n# 终端执行 docker restart notes-redis 后,再运行:\n# print(get_note(n)[1])  -> database\n# print(get_note(n)[1])  -> cache",
+        "lang": "python",
+        "filename": "cache_restart_probe.py"
+      },
+      {
+        "title": "创建独立 AOF 实例",
+        "body": "以下 Linux Bash 创建 notes-redis-aof,端口 6382,命名卷 notes_redis_aof 保存 /data。由已有配置生成 AOF 配置,主业务缓存 6380 保持原样。正式镜像应记录具体 7.x 版本与摘要,本课程用 redis:7 表示兼容主版本。",
+        "code": "python3 - <<'PY'\nfrom pathlib import Path\nsource = Path('redis-lab/redis.conf').read_text()\nsource = source.replace('appendonly no', 'appendonly yes\\nappendfsync everysec')\nPath('redis-lab/aof.conf').write_text(source)\nPY\ndocker run --name notes-redis-aof -d -p 127.0.0.1:6382:6379 \\\n  -v \"$PWD/redis-lab/aof.conf:/usr/local/etc/redis/redis.conf:ro\" \\\n  -v notes_redis_aof:/data \\\n  redis:7 redis-server /usr/local/etc/redis/redis.conf",
+        "lang": "bash"
+      },
+      {
+        "title": "验证 AOF 状态与正常重启恢复",
+        "body": "保存为 redis_aof_probe.py。第一次运行写入样本并输出 aof_enabled、aof_last_write_status;终端 docker restart notes-redis-aof 后仅执行读取部分,不再次写入掩盖丢失。样本 TTL 10 分钟,重启后剩余时间减少;超过期限消失符合语义。",
+        "code": "import os\nimport redis\n\nclient = redis.Redis(host=\"127.0.0.1\",port=6382,password=os.environ[\"REDIS_PASSWORD\"],\n                     decode_responses=True,socket_timeout=1)\nk = \"notes:lab:aof-probe\"\nclient.set(k,\"persist-me\",ex=600)  # 只在首次运行执行,重启验证时移除此行\ninfo = client.info(\"persistence\")\nprint({name: info[name] for name in [\"aof_enabled\",\"aof_last_write_status\",\"aof_rewrite_in_progress\"]})\nprint(\"value\",client.get(k),\"ttl\",client.ttl(k))\nclient.close()",
+        "lang": "python",
+        "filename": "redis_aof_probe.py"
+      },
+      {
+        "title": "建立只读副本并检查复制",
+        "body": "为 6382 的 AOF 主实例创建本机 Docker 网络,添加无持久化副本 notes-redis-replica,宿主机 6383。配置中的 masterauth 使用同一练习密码;生产应采用专用 ACL 凭据和加密通道。副本首次同步可能需要等待,INFO replication 中 master_link_status=up 才进入验收。",
+        "code": "docker network create notes-redis-lab\ndocker network connect notes-redis-lab notes-redis-aof\npython3 - <<'PY'\nimport os\nfrom pathlib import Path\nsource = Path('redis-lab/redis.conf').read_text()\nsource += 'replicaof notes-redis-aof 6379\\n'\nsource += f\"masterauth {os.environ['REDIS_PASSWORD']}\\nreplica-read-only yes\\n\"\nPath('redis-lab/replica.conf').write_text(source)\nPY\ndocker run --name notes-redis-replica --network notes-redis-lab -d \\\n  -p 127.0.0.1:6383:6379 \\\n  -v \"$PWD/redis-lab/replica.conf:/usr/local/etc/redis/redis.conf:ro\" \\\n  redis:7 redis-server /usr/local/etc/redis/redis.conf",
+        "lang": "bash"
+      },
+      {
+        "title": "核对复制、主节点故障和恢复边界",
+        "body": "保存为 redis_replica_probe.py。WAIT 需要在同一连接跟随写入,pipeline(transaction=False) 保持同一连接发送两个命令。返回副本确认数至少 1 后读取副本,正常条件下得到新值。随后只停止 notes-redis-aof,副本不会自动升主;检查链路转为 down 后恢复主实例。演练结束停止 aof 与 replica 两个专用实例。",
+        "code": "import os\nimport redis\n\noptions = dict(host=\"127.0.0.1\",password=os.environ[\"REDIS_PASSWORD\"],decode_responses=True,\n               socket_timeout=2,socket_connect_timeout=1)\nprimary = redis.Redis(port=6382,**options)\nreplica = redis.Redis(port=6383,**options)\ntry:\n    print(\"replica\",replica.info(\"replication\")[\"master_link_status\"])\n    with primary.pipeline(transaction=False) as pipe:\n        pipe.set(\"notes:lab:replica-probe\",\"replicated\",ex=600)\n        pipe.wait(1,1000)\n        written,acknowledged = pipe.execute()\n    print(\"acknowledged\",acknowledged)\n    assert acknowledged >= 1\n    print(\"replica value\",replica.get(\"notes:lab:replica-probe\"))\nfinally:\n    primary.close()\n    replica.close()\n\n# Linux 故障操作仅针对专用实例:docker stop notes-redis-aof\n# 检查副本 INFO replication;不会自动升主。\n# 恢复:docker start notes-redis-aof\n# 演练结束:docker stop notes-redis-aof notes-redis-replica",
+        "lang": "python",
+        "filename": "redis_replica_probe.py"
+      }
+    ],
+    "checks": [
+      "6380 重启清空缓存后,业务笔记能从 PostgreSQL 读取并重建。",
+      "6382 AOF 正常重启后样本仍可读取,TTL 正常减少,持久化状态无错误。",
+      "副本连接正常时能读取复制值,主节点停止后不误认为已实现自动故障转移。"
+    ],
+    "pitfalls": [
+      {
+        "symptom": "启动了副本就宣称零丢失高可用",
+        "cause": "忽略异步复制窗口、自动选主与客户端切换。",
+        "fix": "分别验证复制延迟、故障转移组件和客户端发现;关键业务状态保存在适合其可靠性要求的真源。"
+      },
+      {
+        "symptom": "重启恢复测试总成功,实际上每次都重新 SET",
+        "cause": "验收脚本在读取之前又写入样本,掩盖持久化失败。",
+        "fix": "将写入阶段与重启后只读验收分开,记录初始值、时间、TTL 和持久化状态。"
+      },
+      {
+        "symptom": "副本长期显示 master_link_status=down",
+        "cause": "Docker 网络、主实例名称或 masterauth 不匹配。",
+        "fix": "检查主实例是否加入 notes-redis-lab、名称是否一致,以及双方认证配置;不要通过关掉认证规避问题。"
+      }
+    ],
+    "exercise": {
+      "task": "为缓存实例故障写一份最短恢复顺序,并说明是否必须先恢复所有旧缓存键。",
+      "hint": "先保证真源和受控回源,再逐步恢复缓存。",
+      "answer": "确认 PostgreSQL 正常,启用回源限流;检查 Redis 实例身份、认证和配置;恢复连接后小批次预热热点并观察错误率与数据库延迟。可重建缓存不必恢复所有旧键。任务 Broker 需要额外检查积压、未确认任务和重复消费,不能沿用此简化流程。"
+    },
+    "refs": [
+      {
+        "label": "Redis:持久化",
+        "url": "https://redis.io/docs/latest/operate/oss_and_stack/management/persistence/"
+      },
+      {
+        "label": "Redis:复制",
+        "url": "https://redis.io/docs/latest/operate/oss_and_stack/management/replication/"
+      },
+      {
+        "label": "Redis:WAIT",
+        "url": "https://redis.io/docs/latest/commands/wait/"
+      },
+      {
+        "label": "Redis:Sentinel",
+        "url": "https://redis.io/docs/latest/operate/oss_and_stack/management/sentinel/"
+      }
+    ],
+    "production": {
+      "scenario": "缓存节点需要维护重启,团队必须知道数据会丢在哪里、服务如何继续以及恢复验收是什么。",
+      "decisions": [
+        "按数据用途决定是否持久化。",
+        "正常重启、异常断电与主从切换分开测试。",
+        "复制不是数据库真源和任务幂等性的替代。"
+      ],
+      "failureDrill": "仅重启或停止 6380/6382/6383 这些本机专用实例,分别记录回源、AOF 和复制状态,再恢复或停止实验环境。",
+      "acceptance": [
+        "真实笔记不丢失。",
+        "没有宣称 everysec 零丢失。",
+        "未部署自动选主时明确记录为手动恢复实验。"
+      ]
+    }
+  }
+]

Diferenças do arquivo suprimidas por serem muito extensas
+ 1572 - 0
public/content/tasks.json


+ 1303 - 0
public/content/troubleshooting.json

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

+ 2435 - 0
public/downloads/api-database-lessons.md

@@ -0,0 +1,2435 @@
+# API + 数据库
+
+术核 AI · 后端工程课件
+
+## 01 建立 notes-service 的配置与目录
+
+把 API 路由、数据库连接和环境配置分开,为后续事务与迁移建立可重复的练习环境。
+
+### 本节目标
+
+- 建立可独立启动的 notes-service 项目
+- 从环境读取数据库地址并在启动时校验
+- 区分练习账号、迁移权限和线上运行权限
+
+### 必要概念
+
+#### 项目边界
+
+本模块在新的 notes-service 目录逐课演进。数据库沿用数据库开发模块的 PostgreSQL 16、notes_lab 与 app_user。它与已有 api-lab 下载包相互独立;下面的端点需要按课件新建和整合,不能在旧下载包直接调用。没有做过数据库模块的同学先准备独立的本机 PostgreSQL 16 与空 notes_lab 数据库。
+
+#### 配置与代码
+
+数据库地址随机器变化,表结构与业务规则随代码版本变化。配置放在环境变量或本机 .env,代码只保存字段声明和 .env.example。URL 中的密码若含 @、/、: 等字符要百分号编码;日志只报告配置项缺失,不输出完整连接串。
+
+#### 生产账号分工
+
+练习 app_user 可创建表以便完成迁移;上线通常由独立迁移账号拥有 DDL 权限,运行账号仅获得当前服务所需表的读写及 identity 序列权限。健康检查不证明账号能完成所有业务写操作,权限仍需在部署验收中覆盖。
+
+### 动手实现
+
+#### 1. 新建项目并安装依赖
+
+在新的 notes-service 根目录执行。以下为 PowerShell 命令;Linux 用 python3 -m venv .venv 并激活对应环境。这里固定大版本范围,完成验收后生成锁定文件。
+
+文件:终端 / 当前课时
+
+````powershell
+mkdir notes-service
+cd notes-service
+py -3 -m venv .venv
+.\.venv\Scripts\python.exe -m pip install "fastapi>=0.121,<1" "uvicorn[standard]>=0.30,<1" "pydantic-settings>=2,<3" "SQLAlchemy>=2.0,<2.1" "psycopg[binary]>=3,<4" "alembic>=1.13,<2" "httpx>=0.27,<1" "pytest>=8,<10" "pytest-asyncio>=1,<2"
+mkdir app
+New-Item app/__init__.py -ItemType File
+.\.venv\Scripts\python.exe -m pip freeze > requirements-lock.txt
+````
+
+#### 2. 新建环境模板
+
+新建 .env.example,再复制为 .env,填写你在独立练习库设置的密码。不要把此示例密码用在其他环境。DATABASE_URL 的 psycopg 方言由下一课 create_async_engine 选择异步驱动。
+
+文件:.env.example
+
+````dotenv
+DATABASE_URL=postgresql+psycopg://app_user:CHANGE_ME@127.0.0.1:5433/notes_lab
+DB_POOL_SIZE=5
+DB_MAX_OVERFLOW=0
+DB_POOL_TIMEOUT=2
+````
+
+#### 3. 新建配置模块
+
+Settings() 会读取当前项目根目录下的 .env;启动命令始终在 notes-service 根目录运行。env 中同名变量优先于文件。约束使拼错的负数连接数在启动时失败。
+
+文件:app/config.py
+
+````python
+from pydantic import Field
+from pydantic_settings import BaseSettings, SettingsConfigDict
+
+class Settings(BaseSettings):
+    model_config = SettingsConfigDict(env_file=".env", extra="ignore")
+    database_url: str = Field(min_length=1)
+    db_pool_size: int = Field(default=5, ge=1, le=50)
+    db_max_overflow: int = Field(default=0, ge=0, le=50)
+    db_pool_timeout: float = Field(default=2, gt=0, le=30)
+
+settings = Settings()
+````
+
+#### 4. 隔离本机配置并验证导入
+
+新建 .gitignore,内容如下。在虚拟环境运行 python -c "from app.config import settings; print(settings.db_pool_size)",只应输出 5。临时把 DB_POOL_SIZE 改成 -1,导入应出现校验失败;恢复后继续。
+
+文件:.gitignore
+
+````text
+.env
+.venv/
+__pycache__/
+.pytest_cache/
+````
+
+#### 5. 验证目标库与现有表
+
+使用你安装的 psql 连接本机练习库;-W 交互输入密码。检查数据库和账号,禁止对共享数据库练习建表和失败注入。已有 notes 表在第 3 课核对结构。 沿用数据库模块的 Docker 本机端口映射 5433:5432;若安装原生 PostgreSQL,按实际监听端口同步修改 .env 与 psql 参数。
+
+文件:终端 / 当前课时
+
+````sql
+psql -h 127.0.0.1 -p 5433 -U app_user -d notes_lab -W
+-- 以下在 psql 中执行:
+SELECT current_database(), current_user, version();
+SELECT to_regclass('public.notes');
+````
+
+### 验收结果
+
+- 缺少 DATABASE_URL 时启动配置校验失败;日志未打印密码。
+- requirements-lock.txt 记录了实际安装版本,app/__init__.py 已存在。
+- psql 显示数据库为 notes_lab、账号为 app_user,且是专用练习环境。
+
+### 生产约束与故障演练
+
+同一份代码在开发、测试和线上读取不同数据库配置。
+
+#### 实现决策
+
+- 配置缺失立即失败,不回退连接开发数据库。
+- 运行账号与迁移账号分开管理;练习账号权限不直接复制到线上。
+
+#### 隔离环境演练
+
+只在本机把端口改为未监听端口,记录配置校验成功而连接失败的区别,恢复配置。
+
+#### 恢复验收
+
+- 配置不含默认真实密码。
+- 能用脱敏信息判断地址、认证、权限三个故障层。
+
+### 常见错误
+
+#### 导入配置提示 Field required
+
+原因:未创建 .env,或从错误工作目录运行。
+
+处理:在 notes-service 根目录复制 .env.example 为 .env 并填写数据库连接;不要在 Python 中硬编码密码绕过。
+
+#### 数据库地址正确却认证失败
+
+原因:URL 特殊字符未编码,或账号密码与本机实例不一致。
+
+处理:先用 psql -W 验证凭据,再修正 URL 的密码编码;不要把连接串贴进排错截图。
+
+### 扩展练习
+
+新增 app_env 配置,只接受 local、test、production。
+
+提示:用 typing.Literal 声明允许值。
+
+参考答案:
+
+在 config.py 导入 Literal,然后在 Settings 中加入 app_env: Literal["local", "test", "production"] = "local"。在 .env 设置 APP_ENV=staging 应触发校验失败。
+
+### 技术文档
+
+- [Pydantic Settings:环境配置](https://docs.pydantic.dev/latest/concepts/pydantic_settings/)
+- [SQLAlchemy:数据库 URL](https://docs.sqlalchemy.org/en/20/core/engines.html#database-urls)
+
+---
+
+## 02 异步 Session 的生命周期
+
+建立一个进程级连接池,每个请求创建自己的 AsyncSession,并明确同步与异步驱动的使用位置。
+
+### 本节目标
+
+- 解释 Engine、连接池、连接与 Session 的关系
+- 通过依赖管理请求 Session 并释放资源
+- 避免在 asyncio 并发任务或线程之间共享 Session
+
+### 必要概念
+
+#### 选择一条完整的执行路径
+
+本模块使用 async def 路由、SQLAlchemy AsyncSession 与 psycopg 3 异步连接。同步方案也可用于阻塞型后端:def 路由搭配同步 Session,让框架在线程池执行;不能在 async def 中直接执行同步数据库 I/O。异步提升等待期间的并发利用率,不使数据库查询本身更快。
+
+#### 对象的生命周期
+
+Engine 与 sessionmaker 在每个服务进程内复用;Session 属于一次请求或一个任务。Session 通常到执行 SQL 时才借连接。事务结束后连接归还池,Session 关闭清理剩余状态;进程退出还需 dispose Engine。
+
+#### 并发安全
+
+Session 跟踪待写对象、事务与连接,不能传给 asyncio.gather 的多个并发分支,也不能传进工作线程。独立事务的任务各自通过 SessionFactory() 获取 Session;要求原子的多步操作应在同一任务中顺序使用同一事务。
+
+### 动手实现
+
+#### 1. 新建数据库模块
+
+依赖上一课 app/config.py。create_async_engine 使用 postgresql+psycopg URL 自动选择异步连接。连接与 SQL 超时是本课程起始练习值,后续用预算与测量调整。
+
+文件:app/db.py
+
+````python
+from typing import Annotated
+from fastapi import Depends
+from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
+from app.config import settings
+
+engine = create_async_engine(
+    settings.database_url,
+    pool_size=settings.db_pool_size,
+    max_overflow=settings.db_max_overflow,
+    pool_timeout=settings.db_pool_timeout,
+    pool_pre_ping=True,
+    connect_args={"connect_timeout": 3, "options": "-c statement_timeout=3000 -c lock_timeout=1000"},
+)
+SessionFactory = async_sessionmaker(engine, expire_on_commit=False)
+
+async def get_session():
+    async with SessionFactory() as session:
+        yield session
+
+Db = Annotated[AsyncSession, Depends(get_session, scope="function")]
+````
+
+#### 2. 新建应用入口
+
+依赖上一步 db.py。/live 仅证明进程能响应;/ready 尝试查询数据库。生产探针需结合平台超时设置,readiness 失败使流量停止进入该实例,不应用于不断重启所有实例。
+
+文件:app/main.py
+
+````python
+from contextlib import asynccontextmanager
+from fastapi import FastAPI, HTTPException
+from sqlalchemy import text
+from sqlalchemy.exc import SQLAlchemyError
+from app.db import Db, engine
+
+@asynccontextmanager
+async def lifespan(app: FastAPI):
+    yield
+    await engine.dispose()
+
+app = FastAPI(title="notes-service", lifespan=lifespan)
+
+@app.get("/live")
+async def live():
+    return {"status": "ok"}
+
+@app.get("/ready")
+async def ready(db: Db):
+    try:
+        async with db.begin():
+            await db.execute(text("SELECT 1"))
+    except SQLAlchemyError as exc:
+        raise HTTPException(503, "database unavailable") from exc
+    return {"status": "ready"}
+````
+
+#### 3. 启动并检查资源释放
+
+Linux 可执行 python -m uvicorn app.main:app --reload。Windows 的 psycopg 异步连接需要 SelectorEventLoop;新建下面的本机启动器后执行 python run_server.py。此入口只用于本机单进程练习,修改代码后手工重启。
+
+文件:run_server.py
+
+````python
+import asyncio
+import sys
+import uvicorn
+
+if __name__ == "__main__":
+    if sys.platform == "win32":
+        asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
+    uvicorn.run("app.main:app", host="127.0.0.1", port=8000, loop="asyncio")
+````
+
+#### 4. 观察借还连接
+
+访问 /ready 十次,再在 psql 查询下面 SQL。看到少量 idle 池连接是正常的;idle in transaction 才表示事务已开启但没有继续工作。请求数不应使连接数持续增长。
+
+文件:终端 / 当前课时
+
+````sql
+SELECT application_name, state, count(*)
+FROM pg_stat_activity
+WHERE datname = 'notes_lab' AND usename = 'app_user'
+GROUP BY application_name, state;
+````
+
+#### 5. 单独任务使用自己的会话
+
+新建 scripts 目录及 __init__.py,再建立 scripts/probe.py,执行 python -m scripts.probe。不要把路由 db 传入 worker;每个 probe 仅持有自己的事务。Windows 在此脚本同样使用 Selector 策略。
+
+文件:scripts/probe.py
+
+````python
+import asyncio
+import sys
+from sqlalchemy import text
+from app.db import SessionFactory, engine
+
+async def probe():
+    async with SessionFactory.begin() as db:
+        return await db.scalar(text("SELECT 1"))
+
+async def main():
+    try:
+        print(await asyncio.gather(probe(), probe()))
+    finally:
+        await engine.dispose()
+
+if __name__ == "__main__":
+    if sys.platform == "win32":
+        asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
+    asyncio.run(main())
+````
+
+### 验收结果
+
+- /live 和 /ready 都返回 200;停止本机数据库后 /live 仍成功而 /ready 为 503。
+- 连续请求结束后没有属于 API 的 idle in transaction 连接。
+- 两个 probe 返回 [1, 1],各自创建会话,退出时 Engine 已 dispose。
+
+### 生产约束与故障演练
+
+一个实例同时接收普通 API、慢客户端与后台任务。
+
+#### 实现决策
+
+- 请求依赖只负责会话关闭,业务函数在返回前完成事务。
+- 慢流式响应先查询有限数据并转 DTO,再关闭事务;不把连接绑定整个流。
+
+#### 隔离环境演练
+
+在隔离环境连续调用 readiness 并中断客户端,查询 pg_stat_activity 确认没有遗留事务。
+
+#### 恢复验收
+
+- Session 不跨并发任务共享。
+- 数据库不可用时 readiness 为 503,响应不包含数据库密码或原始异常。
+
+### 常见错误
+
+#### 出现 MissingGreenlet 或异步驱动不兼容
+
+原因:异步 ORM 触发隐式 I/O,或 Windows 使用了 Proactor 事件循环。
+
+处理:先确认错误堆栈:循环问题使用本课启动器;属性加载问题按第 5 课显式加载,不要混用同步驱动。
+
+#### 并发时提示 transaction already begun / operation in progress
+
+原因:同一个 Session 被多个协程共享。
+
+处理:把 SessionFactory 传给任务或在每个任务内部创建 Session;同一原子事务保持顺序执行。
+
+### 扩展练习
+
+让两个并发任务分别返回当前数据库连接的 PID。
+
+提示:SELECT pg_backend_pid() 代替 SELECT 1;不要对 PID 值做相等或不等的断言。
+
+参考答案:
+
+probe 中返回 await db.scalar(text("SELECT pg_backend_pid()"))。由于连接池会复用连接,PID 可能相同也可能不同;判断会话隔离应检查每个任务创建了自己的 Session,而非只看 PID。
+
+### 技术文档
+
+- [SQLAlchemy:异步 ORM](https://docs.sqlalchemy.org/en/20/orm/extensions/asyncio.html)
+- [Psycopg:异步与 Windows](https://www.psycopg.org/psycopg3/docs/advanced/async.html)
+- [FastAPI:依赖作用域](https://fastapi.tiangolo.com/advanced/advanced-dependencies/)
+
+---
+
+## 03 ORM CRUD 与输入输出模型
+
+把 notes 表映射为 ORM,对输入约束、数据库实体和 API 响应分别建模。
+
+### 本节目标
+
+- 对齐 PostgreSQL 字段类型与 Python 映射
+- 实现创建、读取、修改和删除笔记
+- 将 ORM 对象在事务内转换为稳定的 DTO
+
+### 必要概念
+
+#### 三种模型回答不同问题
+
+数据库表定义存储约束,ORM 定义 Python 对象与表的映射,Pydantic DTO 定义允许客户端提交和读取的字段。客户端不能提交 id 或 created_at。即使 Pydantic 校验通过,数据库约束仍是其他写入路径的最后防线。
+
+#### flush 与 commit
+
+add 只把对象加入会话;flush 执行 INSERT 并取得 id,但事务尚未提交。离开 db.begin() 的成功路径才 commit。先生成 DTO 可以发现类型或字段缺失问题,成功离开事务后再返回 HTTP 响应。
+
+#### PATCH 的省略与空值
+
+PATCH 只更新本次提供的字段。exclude_unset=True 保留显式传入的 null,因此必须决定 null 的业务含义。本服务的三个可修改字段均 NOT NULL,显式 null 返回 422;没有传入的字段保持原值。
+
+### 动手实现
+
+#### 1. 新建基础 ORM
+
+依赖 app/db.py。数据库模块已有 public.notes 时先用 \d+ notes 核对:本课不改变已有表。没有表时执行下一步初始化 SQL。updated_at 由本模块所有写路径主动刷新,不声称 server_default 会在 UPDATE 自动执行。
+
+文件:app/models.py
+
+````python
+from datetime import datetime
+from sqlalchemy import BigInteger, Boolean, DateTime, Identity, String, Text, func, text
+from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
+
+class Base(DeclarativeBase):
+    pass
+
+class Note(Base):
+    __tablename__ = "notes"
+    id: Mapped[int] = mapped_column(BigInteger, Identity(), primary_key=True)
+    title: Mapped[str] = mapped_column(String(120), nullable=False)
+    content: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''"))
+    pinned: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("false"))
+    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
+    updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now())
+````
+
+#### 2. 仅对没有 notes 表的练习库初始化
+
+在 notes_lab 的 psql 执行。已有表不执行 CREATE;表缺字段时回到数据库模块对齐,不使用 drop/recreate 破坏数据。第 7 课将这些 DDL 纳入版本迁移。
+
+文件:终端 / 当前课时
+
+````sql
+CREATE TABLE notes (
+    id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
+    title varchar(120) NOT NULL,
+    content text NOT NULL DEFAULT '',
+    pinned boolean NOT NULL DEFAULT false,
+    created_at timestamptz NOT NULL DEFAULT now(),
+    updated_at timestamptz NOT NULL DEFAULT now()
+);
+````
+
+#### 3. 新建输入输出模型
+
+创建、响应和局部更新分开。内容限制为 100000 字符是练习的业务上限,代理请求体字节上限还需在部署模块设置;字符数不是字节数。
+
+文件:app/schemas.py
+
+````python
+from datetime import datetime
+from pydantic import BaseModel, ConfigDict, Field, model_validator
+
+class NoteCreate(BaseModel):
+    model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
+    title: str = Field(min_length=1, max_length=120)
+    content: str = Field(default="", max_length=100000)
+    pinned: bool = False
+
+class NotePatch(BaseModel):
+    model_config = ConfigDict(extra="forbid")
+    title: str | None = Field(default=None, min_length=1, max_length=120)
+    content: str | None = Field(default=None, max_length=100000)
+    pinned: bool | None = None
+
+    @model_validator(mode="after")
+    def reject_null(self):
+        if any(getattr(self, name) is None for name in self.model_fields_set):
+            raise ValueError("provided fields must not be null")
+        if self.title is not None:
+            self.title = self.title.strip()
+            if not self.title:
+                raise ValueError("title must not be blank")
+        return self
+
+class NoteOut(BaseModel):
+    model_config = ConfigDict(from_attributes=True)
+    id: int
+    title: str
+    content: str
+    pinned: bool
+    created_at: datetime
+    updated_at: datetime
+````
+
+#### 4. 新建 CRUD 路由
+
+新建 app/routes.py,然后在 app/main.py 的 app 创建后添加 from app.routes import router 与 app.include_router(router)。这些写路径每次请求使用一个明确事务;生产鉴权与归属隔离需要在项目接入身份系统后增加,本课程接口只绑定本机练习。
+
+文件:app/routes.py
+
+````python
+from datetime import datetime, timezone
+from fastapi import APIRouter, HTTPException, Response
+from app.db import Db
+from app.models import Note
+from app.schemas import NoteCreate, NoteOut, NotePatch
+
+router = APIRouter(prefix="/notes", tags=["notes"])
+
+@router.post("", response_model=NoteOut, status_code=201)
+async def create_note(body: NoteCreate, db: Db):
+    async with db.begin():
+        note = Note(**body.model_dump())
+        db.add(note)
+        await db.flush()
+        await db.refresh(note)
+        result = NoteOut.model_validate(note)
+    return result
+
+@router.get("/{note_id}", response_model=NoteOut)
+async def get_note(note_id: int, db: Db):
+    async with db.begin():
+        note = await db.get(Note, note_id)
+        if note is None:
+            raise HTTPException(404, "note not found")
+        result = NoteOut.model_validate(note)
+    return result
+
+@router.patch("/{note_id}", response_model=NoteOut)
+async def patch_note(note_id: int, body: NotePatch, db: Db):
+    async with db.begin():
+        note = await db.get(Note, note_id)
+        if note is None:
+            raise HTTPException(404, "note not found")
+        for name, value in body.model_dump(exclude_unset=True).items():
+            setattr(note, name, value)
+        note.updated_at = datetime.now(timezone.utc)
+        await db.flush()
+        result = NoteOut.model_validate(note)
+    return result
+
+@router.delete("/{note_id}", status_code=204)
+async def delete_note(note_id: int, db: Db):
+    async with db.begin():
+        note = await db.get(Note, note_id)
+        if note is None:
+            raise HTTPException(404, "note not found")
+        await db.delete(note)
+    return Response(status_code=204)
+````
+
+#### 5. 通过 HTTP 与 SQL 双向验收
+
+启动服务,在 /docs 创建笔记,记录响应 id,随后读取、PATCH pinned=true 并删除。下面 Python 片段保存为 scripts/crud_probe.py 后运行 python -m scripts.crud_probe。
+
+文件:scripts/crud_probe.py
+
+````python
+import httpx
+
+with httpx.Client(base_url="http://127.0.0.1:8000", timeout=5) as client:
+    created = client.post("/notes", json={"title": "持久化练习"})
+    assert created.status_code == 201, created.text
+    note_id = created.json()["id"]
+    assert client.get(f"/notes/{note_id}").status_code == 200
+    patched = client.patch(f"/notes/{note_id}", json={"pinned": True})
+    assert patched.json()["pinned"] is True
+    assert client.patch(f"/notes/{note_id}", json={"title": None}).status_code == 422
+    assert client.delete(f"/notes/{note_id}").status_code == 204
+    assert client.get(f"/notes/{note_id}").status_code == 404
+````
+
+### 验收结果
+
+- POST 返回 201、数据库生成的 id 和带时区时间,重启 API 后仍能读取未删除的记录。
+- PATCH 省略 content 不改变原文;显式 null 与空白标题返回 422。
+- DELETE 成功返回空响应体 204,后续 GET 返回 404。
+
+### 生产约束与故障演练
+
+API 从内存存储迁移到真实数据库,需要保持响应与持久化一致。
+
+#### 实现决策
+
+- 输入模型不暴露数据库生成字段。
+- 业务 DTO 在事务内完成校验,提交成功后才返回 2xx。
+
+#### 隔离环境演练
+
+在本机记录创建 id,重启 API 再读取;另开 psql 核对同一行,排除误用内存列表。
+
+#### 恢复验收
+
+- 数据跨进程重启保留。
+- 请求、响应、数据库约束各有明确职责。
+
+### 常见错误
+
+#### 返回对象时报字段缺失或隐式查询错误
+
+原因:在服务器默认值尚未加载、Session 已关闭后才序列化 ORM。
+
+处理:flush 后 refresh,并在事务内部 NoteOut.model_validate;不依赖响应序列化期间访问数据库。
+
+#### PATCH 把未传字段改成 None
+
+原因:直接 model_dump() 将默认值也写入对象。
+
+处理:使用 exclude_unset=True,并对显式 null 规定并验证行为。
+
+### 扩展练习
+
+禁止创建请求自定义 id,并验证超长标题不会到达数据库。
+
+提示:extra="forbid" 与 max_length 分别解决两种输入问题。
+
+参考答案:
+
+向 POST /notes 发送 {"id":1,"title":"x"} 应为 422;发送 121 字符标题也应为 422。直接 SQL 写入超长标题仍由 varchar(120) 拒绝,不能只依赖 HTTP 校验。
+
+### 技术文档
+
+- [SQLAlchemy:声明式映射](https://docs.sqlalchemy.org/en/20/orm/declarative_tables.html)
+- [Pydantic:模型与属性读取](https://docs.pydantic.dev/latest/concepts/models/)
+- [FastAPI:局部更新](https://fastapi.tiangolo.com/tutorial/body-updates/)
+
+---
+
+## 04 事务边界与失败提交
+
+把一项业务操作的多次写入放进同一事务,复现 flush 成功但 commit 失败的情况。
+
+### 本节目标
+
+- 识别业务事务的开始、提交和回滚位置
+- 避免在 yield 依赖结束时隐藏 commit
+- 让数据库提交失败发生在 HTTP 成功响应之前
+
+### 必要概念
+
+#### 事务由业务入口负责
+
+get_session 负责提供和关闭资源,不知道一次业务操作包含哪些写入。路由或服务函数用 db.begin() 包住相关操作;被调用的辅助函数只执行 SQL 或 flush,不自行 commit。HTTP 请求不是数据库事务的天然边界,流式和外部网络调用尤其需要拆开。
+
+#### 成功返回的顺序
+
+顺序应是校验输入 → begin → SQL → 生成 DTO → commit → 返回响应。若把 commit 放在依赖 yield 后,提交时机可能晚于响应发送,客户端收到成功却没有落库。本模块只在显式事务块内提交,与依赖清理时机无关。
+
+#### 失败之后的 Session
+
+数据库错误会让当前事务不可继续使用。退出 db.begin() 的异常路径自动回滚,之后才映射业务错误或开始新事务;不要在已失败事务内部捕获 IntegrityError 后继续查询。提交连接中断还可能造成结果未知,重试需幂等协议,不能简单认定一定失败。
+
+### 动手实现
+
+#### 1. 为审计记录准备练习表
+
+在本机 notes_lab 执行一次。DEFERRABLE 用来演示提交时检查;第 7 课会将该表纳入基线迁移。审计保存 note_id 数值而不设外键,使笔记删除后审计仍保留。
+
+文件:终端 / 当前课时
+
+````sql
+CREATE TABLE note_audits (
+    id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
+    note_id bigint NOT NULL,
+    action text NOT NULL,
+    request_key text NOT NULL,
+    created_at timestamptz NOT NULL DEFAULT now(),
+    CONSTRAINT uq_note_audits_request UNIQUE (request_key) DEFERRABLE INITIALLY DEFERRED
+);
+````
+
+#### 2. 把创建与审计组合为事务
+
+新建 app/services.py。辅助函数不 commit;session 的事务由调用者控制。request_key 在这里只用于审计提交冲突实验,第 10 课才实现完整幂等语义。
+
+文件:app/services.py
+
+````python
+from sqlalchemy import text
+from sqlalchemy.ext.asyncio import AsyncSession
+from app.models import Note
+from app.schemas import NoteCreate, NoteOut
+
+async def create_with_audit(db: AsyncSession, body: NoteCreate, request_key: str):
+    note = Note(**body.model_dump())
+    db.add(note)
+    await db.flush()
+    await db.refresh(note)
+    await db.execute(text("""
+        INSERT INTO note_audits (note_id, action, request_key)
+        VALUES (:note_id, 'created', :request_key)
+    """), {"note_id": note.id, "request_key": request_key})
+    return NoteOut.model_validate(note)
+````
+
+#### 3. 新增演示端点,在事务外捕获约束错误
+
+追加到 app/routes.py;保留原 POST /notes。该端点路径为 POST /notes/audited,无 GET 路由冲突。异常匹配使用约束名称,未知 IntegrityError 继续抛出交给错误日志,不把所有数据库异常都说成输入错误。
+
+文件:app/routes.py
+
+````python
+from typing import Annotated
+from fastapi import Header
+from sqlalchemy.exc import IntegrityError
+from app.services import create_with_audit
+
+@router.post("/audited", response_model=NoteOut, status_code=201)
+async def create_audited(body: NoteCreate, db: Db, request_key: Annotated[str, Header(min_length=1, max_length=100)]):
+    try:
+        async with db.begin():
+            result = await create_with_audit(db, body, request_key)
+    except IntegrityError as exc:
+        constraint = getattr(getattr(exc.orig, "diag", None), "constraint_name", None)
+        if constraint == "uq_note_audits_request":
+            raise HTTPException(409, "audit request key already exists") from exc
+        raise
+    return result
+````
+
+#### 4. 复现延迟约束在 commit 才报错
+
+用同一个 request-key 连续调用两次。第二次 SQL INSERT 本身可以执行,离开事务块时才触发唯一约束。运行前后分别查询 notes 和 note_audits 的条数,第二次不应增加任一表。
+
+文件:scripts/commit_probe.py
+
+````python
+import httpx
+
+with httpx.Client(base_url="http://127.0.0.1:8000", timeout=5) as client:
+    headers = {"request-key": "commit-lab-001"}
+    first = client.post("/notes/audited", json={"title": "first"}, headers=headers)
+    second = client.post("/notes/audited", json={"title": "must rollback"}, headers=headers)
+    print(first.status_code, second.status_code)
+    assert first.status_code == 201
+    assert second.status_code == 409
+````
+
+#### 5. 画出并核对实际控制流
+
+在纸上或白板沿每个箭头指出对应代码行。出现异常时从 commit 进入 rollback,再到 HTTP 409;不应有从 flush 直接跳到 HTTP 201 的路径。
+
+文件:终端 / 当前课时
+
+````text
+输入校验 → begin → 写 notes → 写 audit → 构造 DTO → commit → HTTP 201
+                                                  └─失败→ rollback → HTTP 错误
+yield 依赖:创建 Session ──────────────────────────────────→ close
+````
+
+### 验收结果
+
+- 第二次重复 request-key 返回 409,不能返回 201 后才打印提交异常。
+- 重复请求失败后两张表都没有新增第二条业务数据。
+- 普通 POST /notes 仍能成功,说明失败事务已回滚释放。
+
+### 生产约束与故障演练
+
+创建笔记必须同时写审计,客户端只能在两项都提交后看到成功。
+
+#### 实现决策
+
+- 业务辅助函数无隐式 commit。
+- 已知约束映射成可识别错误;未知失败保留内部堆栈但响应脱敏。
+
+#### 隔离环境演练
+
+使用可延迟唯一约束让第二次请求在 commit 失败,核对 HTTP 与两张表的最终状态。
+
+#### 恢复验收
+
+- commit 失败没有 2xx。
+- 回滚范围覆盖全部相关写入。
+
+### 常见错误
+
+#### 客户端得到成功,日志随后才报告 commit 失败
+
+原因:依赖清理或后台任务中执行关键业务 commit。
+
+处理:把提交放回业务事务块,离开事务后才 return;后台任务不能承诺当前请求已持久化。
+
+#### 捕获异常后执行 SELECT 又报 InFailedSqlTransaction
+
+原因:仍在失败事务内部复用会话。
+
+处理:在 async with db.begin() 外捕获;需要局部容错时明确设计 SAVEPOINT,而非吞异常。
+
+### 扩展练习
+
+验证服务函数中间抛异常会回滚笔记。
+
+提示:只在隔离练习分支,在审计插入前临时 raise RuntimeError。
+
+参考答案:
+
+在 create_with_audit 的 flush 后加入 raise RuntimeError("drill"),调用一次端点应返回服务错误,psql 查询找不到本次标题。移除故障注入后再次请求成功;不要保留公开故障接口。
+
+### 技术文档
+
+- [SQLAlchemy:事务管理](https://docs.sqlalchemy.org/en/20/orm/session_transaction.html)
+- [PostgreSQL 16:SET CONSTRAINTS](https://www.postgresql.org/docs/16/sql-set-constraints.html)
+- [FastAPI:yield 依赖](https://fastapi.tiangolo.com/tutorial/dependencies/dependencies-with-yield/)
+
+---
+
+## 05 关系加载与 N+1 查询
+
+为笔记增加评论,比较逐条懒加载、selectinload 和有界查询的 SQL 次数。
+
+### 本节目标
+
+- 建立一对多关系并约束删除行为
+- 用显式加载避免异步隐式 I/O
+- 限制主列表和关联集合的读取规模
+
+### 必要概念
+
+#### N+1 如何发生
+
+先查 20 条笔记,再为每条笔记查询评论,会形成 1+20 次数据库往返。网络延迟叠加后,即使每条 SQL 都很快,接口仍慢。异步 Session 中直接读取尚未加载的关系还可能触发 MissingGreenlet。
+
+#### 显式加载的选择
+
+selectinload 将关联查询批量合并,适合规模受控的一对多集合。joinedload 用 JOIN 合并结果,集合关联会放大结果行并通常需要 unique()。分页笔记数不等于限制评论总数,不能把 selectinload 当作无限集合的分页方案。
+
+#### 生产接口的边界
+
+笔记列表只返回笔记摘要或聚合计数,评论列表单独分页。本课演示 eager loading 只使用每条两条评论的固定测试数据;真实产品默认使用 /notes/{id}/comments 有界接口,删除策略明确为数据库 CASCADE。
+
+### 动手实现
+
+#### 1. 新增评论表
+
+在本机练习库执行。外键建立归属;索引支持按笔记读取并按 id 翻页。生产变更在第 7 课以后均通过 Alembic 执行。
+
+文件:终端 / 当前课时
+
+````sql
+CREATE TABLE comments (
+    id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
+    note_id bigint NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
+    body varchar(2000) NOT NULL,
+    created_at timestamptz NOT NULL DEFAULT now()
+);
+CREATE INDEX ix_comments_note_id_id ON comments (note_id, id);
+````
+
+#### 2. 扩展 ORM 映射
+
+将下列 import 合并到 app/models.py。把 comments 属性放入已有 Note 类体,Comment 类放在文件末尾。lazy="raise" 让未显式加载成为确定错误,避免悄悄执行 N+1。
+
+文件:app/models.py
+
+````python
+from sqlalchemy import ForeignKey, Index
+from sqlalchemy.orm import relationship
+
+# 插入已有 Note 类内部:
+# comments: Mapped[list["Comment"]] = relationship(
+#     back_populates="note", lazy="raise", passive_deletes="all"
+# )
+
+class Comment(Base):
+    __tablename__ = "comments"
+    __table_args__ = (Index("ix_comments_note_id_id", "note_id", "id"),)
+    id: Mapped[int] = mapped_column(BigInteger, Identity(), primary_key=True)
+    note_id: Mapped[int] = mapped_column(ForeignKey("notes.id", ondelete="CASCADE"), nullable=False)
+    body: Mapped[str] = mapped_column(String(2000), nullable=False)
+    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
+    note: Mapped["Note"] = relationship(back_populates="comments", lazy="raise")
+````
+
+#### 3. 新增生产形式的分页评论接口
+
+追加到 app/routes.py。每次最多 100 条,以严格递增 id 翻页;无评论与笔记不存在分别返回空数组与 404。输出为明确字典,未访问 Note.comments。
+
+文件:app/routes.py
+
+````python
+from typing import Annotated
+from fastapi import Query
+from sqlalchemy import select
+from app.models import Comment
+
+@router.get("/{note_id}/comments")
+async def list_comments(note_id: int, db: Db,
+    after_id: Annotated[int, Query(ge=0)] = 0,
+    limit: Annotated[int, Query(ge=1, le=100)] = 20):
+    async with db.begin():
+        if await db.get(Note, note_id) is None:
+            raise HTTPException(404, "note not found")
+        rows = (await db.scalars(select(Comment).where(
+            Comment.note_id == note_id, Comment.id > after_id
+        ).order_by(Comment.id).limit(limit + 1))).all()
+        visible = rows[:limit]
+        result = {"items": [{"id": x.id, "body": x.body} for x in visible],
+                  "next_after_id": visible[-1].id if len(rows) > limit else None}
+    return result
+````
+
+#### 4. 在固定小数据集比较加载方式
+
+先在 /docs 创建 3 条专用笔记,用 psql 为每条插入 2 条评论。把下面函数追加到 scripts/probe.py,并在其 main 内调用 await compare();只对这组 id 运行,替换 [1,2,3] 为实际值。在本机临时把 Engine echo 设为 True 观察 SQL,完成后关闭,避免记录用户内容。
+
+文件:scripts/probe.py
+
+````python
+from sqlalchemy import select
+from sqlalchemy.orm import selectinload
+from app.models import Note
+
+async def compare():
+    async with SessionFactory.begin() as db:
+        notes = (await db.scalars(select(Note).where(Note.id.in_([1, 2, 3]))
+            .options(selectinload(Note.comments)))).all()
+        print([(note.id, len(note.comments)) for note in notes])
+````
+
+#### 5. 验收集合边界与删除
+
+访问 /notes/{id}/comments?limit=1,沿 next_after_id 翻到结尾。删除练习笔记后在 psql 查询相应 comments,应该为 0 条。将 compare 中 options 移除,访问 comments 应被 lazy="raise" 拒绝,这比隐藏 N+1 更易排错。
+
+文件:终端 / 当前课时
+
+````sql
+SELECT note_id, count(*) FROM comments GROUP BY note_id;
+EXPLAIN (ANALYZE, BUFFERS)
+SELECT id, body FROM comments
+WHERE note_id = 1 AND id > 0 ORDER BY id LIMIT 21;
+````
+
+### 验收结果
+
+- 小数据 selectinload 示例用笔记查询与评论查询完成读取,没有每条笔记再发一次 SQL。
+- 评论接口 limit=101 返回 422,翻页不重复已见 id。
+- 删除笔记后对应评论被数据库删除,无孤立外键记录。
+
+### 生产约束与故障演练
+
+笔记列表最初只有少量评论,运行一段时间后单条笔记积累大量评论。
+
+#### 实现决策
+
+- 主列表不嵌入无界集合。
+- 通过 SQL 次数与返回行数共同判断优化效果。
+
+#### 隔离环境演练
+
+只在练习库为单条笔记插入较多评论,比较 eager loading 与分页接口的返回规模;限制数据量避免占满磁盘。
+
+#### 恢复验收
+
+- 关联读取有明确上限。
+- 响应序列化不触发数据库查询。
+
+### 常见错误
+
+#### 分页笔记 20 条仍返回几十万条评论
+
+原因:主表分页没有限制关联集合规模。
+
+处理:默认分离评论分页接口,只在已知小集合使用 selectinload;聚合计数使用 GROUP BY 子查询。
+
+#### 删除带评论的笔记出现外键非空错误
+
+原因:ORM 尝试把外键置空,与数据库 CASCADE 策略不一致。
+
+处理:使用本课 passive_deletes="all" 让数据库处理删除;同一会话内已加载对象可能过期,删除后不再返回其评论对象。
+
+### 扩展练习
+
+给评论接口增加排序反向读取。
+
+提示:反向页需同时改变比较符与 ORDER BY。
+
+参考答案:
+
+使用 before_id 游标,WHERE id < before_id ORDER BY id DESC LIMIT limit+1。首次无游标时不加比较条件,下一页传上一页末尾 id;不要只改排序而保留 id > after_id。
+
+### 技术文档
+
+- [SQLAlchemy:关系加载策略](https://docs.sqlalchemy.org/en/20/orm/queryguide/relationships.html)
+- [SQLAlchemy:数据库级删除级联](https://docs.sqlalchemy.org/en/20/orm/cascades.html#using-foreign-key-on-delete-cascade-with-orm-relationships)
+
+---
+
+## 06 连接池预算、超时与多 Worker
+
+从 PostgreSQL 连接上限反推每个进程的池容量,区分借连接、建连接、锁等待与 SQL 执行超时。
+
+### 本节目标
+
+- 计算多实例多 Worker 下的最坏连接数
+- 为数据库等待设置分层预算
+- 在隔离环境复现连接池耗尽并观察恢复
+
+### 必要概念
+
+#### 连接数按进程相乘
+
+每个 Uvicorn worker 都拥有自己的 Engine 与连接池。应用最坏连接数约为实例数 × worker 数 × (pool_size + max_overflow),还要加任务消费者、迁移、运维与其他应用。连接池预算小于数据库可用配额,不能仅把 max_connections 全部分给 API。
+
+#### 四种等待
+
+pool_timeout 是等待池中连接的时间,connect_timeout 是建立新连接的时间,lock_timeout 是 SQL 等锁的上限,statement_timeout 是单条 SQL 执行总上限。它们有重叠,不能简单相加等同请求耗时;请求预算还包括网络、序列化与多条 SQL。
+
+#### 拥塞时及时拒绝
+
+扩大池会增加数据库并发和内存压力。先缩短事务、排查慢 SQL、控制入口与任务并发,再测量池容量。pool_pre_ping 能识别借出前的失效连接,不能修复事务执行中断,也不会安全重放写操作。
+
+### 动手实现
+
+#### 1. 计算部署预算
+
+先用 psql SHOW max_connections,并列出其他客户端。以下为练习计算,不是通用推荐值;2 个实例、每个 3 worker、每 worker 5 连接占 30,滚动发布若新旧全部重叠可能占 60。
+
+文件:终端 / 当前课时
+
+````text
+数据库上限 100
+保留给管理与故障处理 10
+其他服务与后台任务 20
+API 稳态 2 × 3 × (5 + 0) = 30
+发布峰值按 4 × 3 × 5 = 60 估算
+峰值总需求 60 + 20 + 10 = 90 < 100
+````
+
+#### 2. 检查当前生效超时
+
+第 2 课 Engine 已设置连接和 SQL 超时。运行下列 psql 只看到当前 psql 会话配置;要验证应用连接,给 scripts/probe.py 的 probe 函数临时替换为下一段,运行后恢复。
+
+文件:scripts/probe.py
+
+````python
+async def probe():
+    async with SessionFactory.begin() as db:
+        rows = (await db.execute(text("""
+            SELECT current_setting('statement_timeout') AS statement_timeout,
+                   current_setting('lock_timeout') AS lock_timeout
+        """))).mappings().one()
+        return dict(rows)
+````
+
+#### 3. 为池耗尽提供明确响应
+
+添加到 app/main.py。只映射 SQLAlchemy 的池等待 TimeoutError;SQL 执行取消与数据库断连需要分别记录和处理,不能都笼统重试。请求日志应包含 request_id、延迟、异常类别,排除参数与连接串。
+
+文件:app/main.py
+
+````python
+from fastapi import Request
+from fastapi.responses import JSONResponse
+from sqlalchemy.exc import TimeoutError as PoolTimeout
+
+@app.exception_handler(PoolTimeout)
+async def pool_timeout_handler(request: Request, exc: PoolTimeout):
+    return JSONResponse(status_code=503,
+        content={"detail": "database capacity exhausted"},
+        headers={"Retry-After": "1"})
+````
+
+#### 4. 本机复现池竞争
+
+新建 scripts/pool_drill.py。它创建单独练习 Engine,不改服务的配置。SELECT pg_sleep 仅在本机 notes_lab 使用;两任务各自用连接,一个等不到连接触发池超时,finally 确保释放。Windows 使用本课代码内 Selector 策略。
+
+文件:scripts/pool_drill.py
+
+````python
+import asyncio
+import sys
+from sqlalchemy import text
+from sqlalchemy.exc import TimeoutError
+from sqlalchemy.ext.asyncio import create_async_engine
+from app.config import settings
+
+async def main():
+    engine = create_async_engine(settings.database_url, pool_size=1,
+        max_overflow=0, pool_timeout=0.1)
+    async def hold():
+        async with engine.begin() as conn:
+            await conn.execute(text("SELECT pg_sleep(0.5)"))
+    try:
+        results = await asyncio.gather(hold(), hold(), return_exceptions=True)
+        assert sum(isinstance(x, TimeoutError) for x in results) == 1, results
+        async with engine.begin() as conn:
+            assert await conn.scalar(text("SELECT 1")) == 1
+        print("one rejected; pool recovered")
+    finally:
+        await engine.dispose()
+
+if __name__ == "__main__":
+    if sys.platform == "win32":
+        asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
+    asyncio.run(main())
+````
+
+#### 5. 验证慢 SQL 与锁等待分开
+
+应用 probe 事务中分别执行 pg_sleep(4),或在 psql 事务锁定一条专用笔记再由 API PATCH。记录哪个超时先发生。锁练习结束在 psql 执行 ROLLBACK;不要对业务库执行这些命令。
+
+文件:终端 / 当前课时
+
+````sql
+BEGIN;
+SELECT id FROM notes WHERE id = 1 FOR UPDATE;
+-- 将 1 换成本机专用练习笔记 id;此时在另一个终端 PATCH 同一笔记。
+ROLLBACK;
+````
+
+### 验收结果
+
+- 预算表包含 worker、实例、发布重叠和后台任务。
+- 池演练输出 one rejected; pool recovered;失败后新查询仍成功。
+- 能区分池等待超时、statement_timeout 与 lock_timeout,并记录实际延迟。
+
+### 生产约束与故障演练
+
+服务扩容和滚动发布时,短时间内进程数显著增加。
+
+#### 实现决策
+
+- max_overflow=0 起步使容量上限可预测。
+- 容量不足返回可恢复错误,重试使用退避与幂等;不在应用层无限排队。
+
+#### 隔离环境演练
+
+独立池限制为 1,两个任务争抢连接;确认一次失败不会泄露唯一连接。
+
+#### 恢复验收
+
+- 失败后池恢复。
+- 最坏连接需求含部署峰值且留管理余量。
+
+### 常见错误
+
+#### 把 worker 从 2 加到 8 后数据库连接爆满
+
+原因:每个 worker 都创建自己的池,遗漏乘数。
+
+处理:重新预算所有实例与发布重叠;根据 CPU 与等待特征调 worker,按数据库配额分配池。
+
+#### 设置 pool_timeout 后慢查询仍占用很久
+
+原因:pool_timeout 只限制借连接等待,不限制已借到连接上的 SQL。
+
+处理:分别设置 statement_timeout 与 lock_timeout,并检查事务持有连接时是否等待外部 API。
+
+### 扩展练习
+
+如果实例翻倍但数据库配额不变,如何保持最坏连接数不变?
+
+提示:把进程总数和每池连接上限一起计算。
+
+参考答案:
+
+在 worker 不变时将每 worker 的 pool_size + max_overflow 约减半,同时验证排队时间与吞吐。连接配额只是上限,不能保证响应时间;需要限流和负载测量,而非只修改参数。
+
+### 技术文档
+
+- [SQLAlchemy:连接池](https://docs.sqlalchemy.org/en/20/core/pooling.html)
+- [PostgreSQL 16:客户端连接默认设置](https://www.postgresql.org/docs/16/runtime-config-client.html)
+- [Uvicorn:部署与 Worker](https://uvicorn.dev/deployment/)
+
+---
+
+## 07 Alembic 迁移与 Expand–Contract
+
+把手工练习表纳入版本管理,以新增可空字段、分批回填、收紧约束完成兼容发布。
+
+### 本节目标
+
+- 为 async Engine 建立 Alembic 环境
+- 区分 upgrade、autogenerate 与 stamp
+- 设计新旧应用可同时运行的数据库演进
+
+### 必要概念
+
+#### 迁移版本是发布状态
+
+ORM 模型描述当前目标结构,迁移文件描述从旧版如何到新版。autogenerate 是候选差异,不能理解改名或业务回填语义。生产服务启动不执行 create_all;数据库迁移由单独发布步骤执行一次并记录版本。
+
+#### 接管已有数据库
+
+数据库开发模块的 notes_lab 可能已包含 version、检查约束与额外索引,不能直接套用本课从零生成的迁移历史。本课固定使用新建 notes_lab_migration 数据库做迁移实验,原 notes_lab 保留不动。stamp 只登记版本,既不建表也不验证结构;本练习不对已有库执行 stamp 或重复 ADD version。接管真实已有库需要单独的结构盘点与基线计划。
+
+#### 兼容发布
+
+Expand 先增加兼容字段或表;新旧应用共存期间双写或保留旧字段;后台按主键分批回填并核验;最后 Contract 收紧约束或删除旧字段。每步都需锁等待预算和回退窗口,数据删除不保证可通过 downgrade 恢复。
+
+### 动手实现
+
+#### 1. 初始化异步迁移环境
+
+在 notes-service 根目录执行。生成 migrations/env.py 后按下一步修改,migration URL 从配置读取,不写密码进 alembic.ini。
+
+文件:终端 / 当前课时
+
+````bash
+python -m alembic init -t async migrations
+````
+
+#### 2. 配置 metadata 与连接地址
+
+在生成的 migrations/env.py 中导入下面内容,替换原 target_metadata = None;使用 set_main_option 时将 % 转为 %%,因为 Alembic 配置有插值规则。保留 async 模板的 run_sync、NullPool 与上下文,不自行改成 asyncio.run 包套 asyncio.run。 Windows 在 env.py 导入 asyncio 后、执行 run_migrations_online 之前,添加 import sys,并在 sys.platform == "win32" 时设置 asyncio.WindowsSelectorEventLoopPolicy(),与应用入口保持一致。
+
+文件:migrations/env.py
+
+````python
+from app.config import settings
+from app.models import Base
+
+target_metadata = Base.metadata
+config.set_main_option("sqlalchemy.url", settings.database_url.replace("%", "%%"))
+
+import sys
+if sys.platform == "win32":
+    asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
+````
+
+#### 3. 生成并审核基线
+
+先把下列 note_audits Core Table 追加到 app/models.py,与现有 Note/Comment 一起进入 Base.metadata。此时 API 模型按第 3–5 课尚未声明 version,原数据库即使有该列也不影响读写。下一步只对新的空迁移实验库生成基线,不从已有 notes_lab 自动生成差异。
+
+文件:app/models.py
+
+````python
+from sqlalchemy import Column, MetaData, Table, UniqueConstraint
+
+note_audits = Table("note_audits", Base.metadata,
+    Column("id", BigInteger, Identity(), primary_key=True),
+    Column("note_id", BigInteger, nullable=False),
+    Column("action", Text, nullable=False),
+    Column("request_key", Text, nullable=False),
+    Column("created_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
+    UniqueConstraint("request_key", name="uq_note_audits_request",
+        deferrable=True, initially="DEFERRED"),
+)
+````
+
+#### 4. 创建空迁移实验库并明确切换
+
+在本机 PostgreSQL 管理账号可用的前提下执行前两条命令,-W 交互输入管理密码;若使用数据库模块的Docker容器,可在其postgres容器内使用管理psql执行同一CREATE DATABASE。已有同名库时先停止并核对来源,不删除重建。将 notes-service/.env 的 DATABASE_URL 数据库名从 notes_lab 改为 notes_lab_migration,保留原值在未提交的本机备份中;同时清除终端中覆盖 .env 的 DATABASE_URL。第 7–11 课继续使用新库,避免把教学迁移应用到已有表。
+
+文件:终端 / 当前课时
+
+````powershell
+psql -h 127.0.0.1 -p 5433 -U postgres -d postgres -W -c "CREATE DATABASE notes_lab_migration OWNER app_user;"
+psql -h 127.0.0.1 -p 5433 -U app_user -d notes_lab_migration -W -c "SELECT current_database(), current_user;"
+# PowerShell:清除可能优先于.env的旧环境变量
+Remove-Item Env:DATABASE_URL -ErrorAction SilentlyContinue
+# 用编辑器将.env连接串末尾改为/notes_lab_migration,再运行:
+python -m alembic revision --autogenerate -m "baseline notes comments audits"
+python -m alembic upgrade head
+python -m alembic check
+python -m alembic current
+````
+
+#### 5. 新增 nullable version 字段
+
+仅在刚创建的 notes_lab_migration 完成基线后执行:alembic revision -m "expand note version",把下面函数写入生成文件并保留 revision/down_revision/import op/import sqlalchemy as sa。同步在 Note 类加入 version: Mapped[int | None] = mapped_column(nullable=True, server_default=text("1"))。原 notes_lab 已有 version 时不执行本迁移;本练习的新增与回填只发生在独立新库。
+
+文件:migrations/versions/<revision>_expand_note_version.py
+
+````python
+def upgrade():
+    op.execute("SET LOCAL lock_timeout = '1s'")
+    op.add_column("notes", sa.Column("version", sa.Integer(), nullable=True, server_default=sa.text("1")))
+
+def downgrade():
+    op.drop_column("notes", "version")
+````
+
+#### 6. 分批回填后收紧非空约束
+
+先 upgrade,再在 psql 重复下面有界 UPDATE,每次独立提交,直到 UPDATE 0。随后生成新 revision 收紧 nullable=False,并把 ORM version 注解改为 Mapped[int]。大表可先 ADD CHECK (version IS NOT NULL) NOT VALID,再 VALIDATE CONSTRAINT,最后 SET NOT NULL 并删除过渡 CHECK;验证与锁预算在预发布副本测量。
+
+文件:终端 / 当前课时
+
+````sql
+WITH batch AS (
+    SELECT id FROM notes WHERE version IS NULL ORDER BY id LIMIT 1000
+)
+UPDATE notes AS n SET version = 1 FROM batch WHERE n.id = batch.id;
+
+SELECT count(*) FROM notes WHERE version IS NULL;
+-- 收紧约束的下一份 Alembic revision 中:
+-- op.alter_column("notes", "version", existing_type=sa.Integer(), nullable=False)
+````
+
+#### 7. 初始化练习数据并保留返回路径
+
+迁移实验库从零开始,重启API使新.env生效,用/docs创建几条新笔记和评论再完成后续课。第7–11课操作目标始终为notes_lab_migration;不把旧库记录默默复制进新库。完成本模块后可恢复.env原notes_lab连接,但若要在那里使用新功能,需先为该库制定独立迁移接管计划,不能直接upgrade本课历史。第12课使用另一专用notes_lab_test。
+
+文件:终端 / 当前课时
+
+````sql
+# 在当前连接的psql中确认目标及新字段
+SELECT current_database();
+SELECT column_name FROM information_schema.columns
+WHERE table_schema='public' AND table_name='notes' ORDER BY ordinal_position;
+````
+
+### 验收结果
+
+- notes_lab_migration 从空库 upgrade 得到三张基础表与 version;原 notes_lab 没有执行本课DDL。
+- 配置确实指向 notes_lab_migration,终端环境变量不会覆盖 .env,API重启后写入新库。
+- 回填仅操作新实验库,收紧前 version IS NULL 为0,alembic current 为预期版本。
+
+### 生产约束与故障演练
+
+服务不能停机,新旧版本会在滚动发布时共同写同一张表。
+
+#### 实现决策
+
+- 先兼容扩展,观测回填与应用切换,再收紧结构。
+- 迁移单独运行,自动生成文件必须人工读懂每一条 DDL。
+
+#### 隔离环境演练
+
+在隔离验证库制造长事务阻挡 ALTER,确认 lock_timeout 使迁移快速失败,结束阻塞后可重跑。
+
+#### 恢复验收
+
+- 已有库不会被直接stamp或重复ADD version。
+- 能够从空库复现迁移历史,清楚原库接管是独立工作。
+
+### 常见错误
+
+#### autogenerate 把重命名识别成删列加列
+
+原因:工具只看结构差异,不理解业务意图。
+
+处理:手工改为兼容新增、迁移数据与后续删除,审核生成 SQL;有数据列的 DROP 不直接上线。
+
+#### 线上多实例启动时同时运行迁移
+
+原因:把 migration 放入每个 worker 的启动钩子。
+
+处理:迁移移到唯一发布作业,设置锁等待上限,成功后启动或推进应用发布。
+
+### 扩展练习
+
+旧应用没有 version 字段时,Expand 阶段的新插入会怎样?
+
+提示:区分 Python default 与数据库 server_default。
+
+参考答案:
+
+本课 version 的 server_default=1 在数据库生效,旧应用省略该列仍得到 1。单写 Python default 无法覆盖旧应用或 SQL 客户端。启用版本校验前仍需完成所有写路径升级,否则旧应用不会递增版本。
+
+### 技术文档
+
+- [Alembic:异步迁移](https://alembic.sqlalchemy.org/en/latest/cookbook.html#using-asyncio-with-alembic)
+- [Alembic:自动生成限制](https://alembic.sqlalchemy.org/en/latest/autogenerate.html)
+- [PostgreSQL 16:ALTER TABLE](https://www.postgresql.org/docs/16/sql-altertable.html)
+
+---
+
+## 08 Keyset 分页与数据库错误映射
+
+使用稳定复合游标读取笔记列表,把已知约束转成明确的 API 错误。 本课继续连接第7课的 notes_lab_migration,原 notes_lab 不执行这些教学迁移。
+
+### 本节目标
+
+- 实现 created_at 与 id 复合 keyset 游标
+- 限制分页大小并校验游标结构
+- 按约束名称映射数据库错误而不泄露 SQL
+
+### 必要概念
+
+#### 稳定排序
+
+只用 created_at 排序可能出现时间相同的多条记录,必须加唯一 id 作为第二排序键。降序下一页使用 (created_at,id) < 上页末条键。游标能减少深 OFFSET 扫描,但不是一致性快照;并发删除会导致后续页变短,新插入通常落在已经翻过的前面。
+
+#### 游标是协议
+
+本课将时间和 id 编码为 URL 安全 Base64 JSON,并在入口限制长度与字段类型。编码不是加密或授权,客户端可伪造游标;若绑定用户、过滤器或敏感字段,需要签名与权限过滤。固定排序键 created_at 不在 API 中允许修改。
+
+#### 约束错误分层
+
+请求格式错误由 Pydantic 返回 422。业务唯一冲突可为 409,明确的检查约束可以映射 422;连接失效不是客户端输入问题。先用约束名称或 SQLSTATE 识别,再决定映射,不把数据库异常原文直接返回。
+
+### 动手实现
+
+#### 1. 创建复合索引迁移
+
+生成一个 Alembic revision 并填入函数,使用已有 op 与 sa 导入。课程练习数据量小可普通建索引;大表生产应评估 CREATE INDEX CONCURRENTLY,注意它不能在普通事务块内执行,需独立迁移与失败索引清理预案。 同步在 Note 类的 __table_args__ 加入 Index("ix_notes_created_id", "created_at", "id"),若已有元组则追加,不能覆盖其他约束。
+
+文件:migrations/versions/<revision>_notes_cursor_index.py
+
+````python
+def upgrade():
+    op.create_index("ix_notes_created_id", "notes", ["created_at", "id"])
+
+def downgrade():
+    op.drop_index("ix_notes_created_id", table_name="notes")
+````
+
+#### 2. 新增游标编码模块
+
+新建 app/cursors.py。aware 时间保证与 timestamptz 比较一致;对 Base64、JSON 和 Pydantic 校验错误统一返回 422。游标只用于定位,不用于证明访问权限。
+
+文件:app/cursors.py
+
+````python
+import base64
+import binascii
+import json
+from fastapi import HTTPException
+from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, ValidationError
+
+class Cursor(BaseModel):
+    model_config = ConfigDict(extra="forbid")
+    created_at: AwareDatetime
+    id: int = Field(gt=0)
+
+def encode_cursor(created_at, note_id):
+    raw = Cursor(created_at=created_at, id=note_id).model_dump_json().encode()
+    return base64.urlsafe_b64encode(raw).decode().rstrip("=")
+
+def decode_cursor(value):
+    try:
+        raw = base64.b64decode(value + "=" * (-len(value) % 4), altchars=b"-_", validate=True)
+        return Cursor.model_validate_json(raw)
+    except (ValueError, ValidationError, binascii.Error) as exc:
+        raise HTTPException(422, "invalid cursor") from exc
+````
+
+#### 3. 新增分页笔记接口
+
+追加到 app/routes.py;GET /notes 与已有 GET /notes/{note_id} 不冲突。每页多读一条决定是否有下一页;在事务内转换 DTO,列表响应不会访问未加载关系。生产接入用户后,权限过滤必须加入同一 SQL 且每页一致。
+
+文件:app/routes.py
+
+````python
+from sqlalchemy import tuple_
+from app.cursors import decode_cursor, encode_cursor
+
+@router.get("")
+async def list_notes(db: Db,
+    limit: Annotated[int, Query(ge=1, le=100)] = 20,
+    cursor: Annotated[str | None, Query(max_length=512)] = None):
+    stmt = select(Note).order_by(Note.created_at.desc(), Note.id.desc()).limit(limit + 1)
+    if cursor:
+        key = decode_cursor(cursor)
+        stmt = stmt.where(tuple_(Note.created_at, Note.id) < tuple_(key.created_at, key.id))
+    async with db.begin():
+        rows = (await db.scalars(stmt)).all()
+        visible = rows[:limit]
+        result = {"items": [NoteOut.model_validate(x) for x in visible],
+                  "next_cursor": encode_cursor(visible[-1].created_at, visible[-1].id)
+                      if len(rows) > limit else None}
+    return result
+````
+
+#### 4. 新增非空白标题的数据库约束
+
+先检查存量数据,再生成迁移。下面 upgrade 中 NOT VALID 允许先建立约束而暂缓验证存量,新写入立即受限;清理练习存量空白标题后再执行 VALIDATE。同步把 CheckConstraint 加入 Note.__table_args__,与索引一同保留,防止后续 autogenerate 误报差异。
+
+文件:migrations/versions/<revision>_notes_title_check.py
+
+````python
+def upgrade():
+    op.execute("ALTER TABLE notes ADD CONSTRAINT ck_notes_title_nonblank CHECK (length(btrim(title)) > 0) NOT VALID")
+    op.execute("ALTER TABLE notes VALIDATE CONSTRAINT ck_notes_title_nonblank")
+
+def downgrade():
+    op.drop_constraint("ck_notes_title_nonblank", "notes", type_="check")
+````
+
+#### 5. 统一映射明确的约束
+
+添加到 app/main.py。现有 audited 端点会先处理自身唯一约束,其他未识别 IntegrityError 返回 500 并用不含 SQL 参数的日志记录类别与约束;真实项目接入 request_id。不要记录 str(exc),其中可能有用户输入。
+
+文件:app/main.py
+
+````python
+import logging
+from sqlalchemy.exc import IntegrityError
+
+logger = logging.getLogger("notes-service")
+
+@app.exception_handler(IntegrityError)
+async def integrity_handler(request: Request, exc: IntegrityError):
+    diag = getattr(exc.orig, "diag", None)
+    name = getattr(diag, "constraint_name", None)
+    if name == "ck_notes_title_nonblank":
+        return JSONResponse(status_code=422, content={"detail": "title must not be blank"})
+    logger.error("database constraint failure", extra={"constraint": name, "path": request.url.path})
+    return JSONResponse(status_code=500, content={"detail": "database write failed"})
+````
+
+### 验收结果
+
+- 相同 created_at 的多条笔记分页仍无重复、无静态数据遗漏。
+- limit=0、limit=101、损坏游标都为 422,最后一页 next_cursor=null。
+- 直接 SQL 写空白标题由 ck_notes_title_nonblank 拒绝,API 错误响应不含 SQL 和原始参数。
+
+### 生产约束与故障演练
+
+笔记增长后深分页拖慢数据库,同时出现绕过 API 的导入写入。
+
+#### 实现决策
+
+- 固定排序、限制页大小,数据库层仍执行关键不变量。
+- 游标不承担授权职责,过滤条件改变时丢弃旧游标。
+
+#### 隔离环境演练
+
+在本机建立时间相同的测试记录并逐页收集 id;再发送损坏游标验证不会执行数据库查询。
+
+#### 恢复验收
+
+- 静态数据集完整且不重复。
+- 未知数据库错误不会被包装成错误的成功或业务冲突。
+
+### 常见错误
+
+#### 翻页时相同时间的记录重复或遗漏
+
+原因:仅以时间作为游标,缺少唯一排序键。
+
+处理:查询排序、比较条件和游标编码同时包含 created_at 与 id。
+
+#### 所有 IntegrityError 都返回 409
+
+原因:把非空、外键、检查约束和程序错误混为一类。
+
+处理:只匹配确认过的命名约束,未知异常保持服务端错误并记录可定位信息。
+
+### 扩展练习
+
+分页时能否保证第一屏到最后一屏来自同一个数据库快照?
+
+提示:每个 HTTP 请求有自己的事务。
+
+参考答案:
+
+本课不能保证跨请求快照。若产品需要稳定导出,可创建导出作业并在合适的一致性边界生成文件,或定义快照时间与数据版本规则;不要保持一个数据库事务等待用户慢慢翻页。
+
+### 技术文档
+
+- [PostgreSQL 16:行比较](https://www.postgresql.org/docs/16/functions-comparisons.html#ROW-WISE-COMPARISON)
+- [PostgreSQL 16:错误码](https://www.postgresql.org/docs/16/errcodes-appendix.html)
+- [SQLAlchemy:tuple_ 表达式](https://docs.sqlalchemy.org/en/20/core/sqlelement.html#sqlalchemy.sql.expression.tuple_)
+
+---
+
+## 09 并发更新:版本条件与行锁
+
+复现两个客户端覆盖彼此修改,用原子条件 UPDATE 返回 409,并识别行锁适用的短事务。
+
+### 本节目标
+
+- 复现读后写导致的丢失更新
+- 以 version 条件原子更新并对过期请求返回 409
+- 区分乐观并发控制和短事务行锁
+
+### 必要概念
+
+#### 版本表示读到的状态
+
+两个客户端都读到 version=1,第一个写入后版本变为 2,第二个仍提交 1 应被拒绝。检查与写入必须在同一条 SQL 的 WHERE 中完成;先 SELECT 检查再无条件 UPDATE 仍有竞争窗口。
+
+#### 所有写路径必须遵守协议
+
+仅新增一个安全 PATCH 不够:旧 PATCH、批量脚本和管理工具若不递增版本,仍能覆盖别人修改。第 7 课扩展字段只解决结构兼容,启用强制版本前必须完成旧写路径切换并下线不安全写入口。
+
+#### 行锁的用途
+
+SELECT FOR UPDATE 可以串行化同一行的短业务判断,例如检查状态后做多表更新。锁会持续到事务结束,不能在持锁时等待模型 API、发送邮件或用户输入。乐观控制减少等待但要求客户端处理冲突;二者可以按业务组合。
+
+### 动手实现
+
+#### 1. 收紧 version 并扩展 DTO
+
+前提是第 7 课回填和 NOT NULL 迁移已成功。Note 类 version 改成 Mapped[int] = mapped_column(nullable=False, server_default=text("1"));NoteOut 新增 version: int。再在 schemas.py 增加下面的更新请求,保留 NotePatch 的字段校验。
+
+文件:app/schemas.py
+
+````python
+class NoteVersionPatch(NotePatch):
+    version: int = Field(gt=0)
+````
+
+#### 2. 替换原 PATCH 端点
+
+删除或替换 app/routes.py 原 patch_note,避免同方法同路径重复注册。原子 UPDATE 以 id 与 version 为条件;无匹配统一 409,涵盖已删除和版本冲突,客户端重新 GET 区分是否已删除。所有 SQL 更新同步刷新 updated_at 与 version。
+
+文件:app/routes.py
+
+````python
+from sqlalchemy import update, func
+from app.schemas import NoteVersionPatch
+
+@router.patch("/{note_id}", response_model=NoteOut)
+async def patch_note(note_id: int, body: NoteVersionPatch, db: Db):
+    changes = body.model_dump(exclude_unset=True, exclude={"version"})
+    if not changes:
+        raise HTTPException(422, "provide at least one field to update")
+    async with db.begin():
+        stmt = update(Note).where(Note.id == note_id, Note.version == body.version).values(
+            **changes, version=Note.version + 1, updated_at=func.now()).returning(Note)
+        note = (await db.scalars(stmt)).one_or_none()
+        if note is None:
+            raise HTTPException(409, "note changed or was deleted; reload before editing")
+        result = NoteOut.model_validate(note)
+    return result
+````
+
+#### 3. 让删除也携带版本
+
+替换原 delete_note。这里用必填 version 查询参数便于 /docs 练习;生产协议也可统一为 If-Match + ETag。所有客户端脚本更新为提交当前 version,不能给 version 设置默认 1 绕过检查。
+
+文件:app/routes.py
+
+````python
+from sqlalchemy import delete
+
+@router.delete("/{note_id}", status_code=204)
+async def delete_note(note_id: int, db: Db, version: Annotated[int, Query(gt=0)]):
+    async with db.begin():
+        deleted = await db.scalar(delete(Note).where(
+            Note.id == note_id, Note.version == version).returning(Note.id))
+        if deleted is None:
+            raise HTTPException(409, "note changed or was deleted")
+    return Response(status_code=204)
+````
+
+#### 4. 用两个客户端快照复现冲突
+
+在 scripts/version_probe.py 保存下面片段,运行后应得到 200 与 409。第一次写入内容应保留,不能被旧快照覆盖;创建路径可沿用 POST /notes,新字段由数据库默认值赋 1。
+
+文件:scripts/version_probe.py
+
+````python
+import httpx
+
+with httpx.Client(base_url="http://127.0.0.1:8000", timeout=5) as client:
+    note = client.post("/notes", json={"title": "concurrency"}).json()
+    path = f"/notes/{note['id']}"
+    a = client.get(path).json()
+    b = client.get(path).json()
+    first = client.patch(path, json={"title": "client A", "version": a["version"]})
+    second = client.patch(path, json={"title": "client B", "version": b["version"]})
+    assert first.status_code == 200, first.text
+    assert second.status_code == 409, second.text
+    current = client.get(path).json()
+    assert current["title"] == "client A"
+    assert current["version"] == note["version"] + 1
+````
+
+#### 5. 比较短事务行锁
+
+下面为可替换服务函数片段,加入 app/services.py;由调用者在 db.begin() 内调用。此函数只示范锁内检查与写入,不能接收旧客户端内容进行无条件覆盖。锁等待由 lock_timeout 限制。
+
+文件:app/services.py
+
+````python
+from sqlalchemy import select, func
+from fastapi import HTTPException
+
+async def pin_once(db: AsyncSession, note_id: int):
+    note = await db.scalar(select(Note).where(Note.id == note_id).with_for_update())
+    if note is None:
+        raise HTTPException(404, "note not found")
+    if not note.pinned:
+        note.pinned = True
+        note.version += 1
+        note.updated_at = func.now()
+        await db.flush()
+        await db.refresh(note)
+    return NoteOut.model_validate(note)
+````
+
+### 验收结果
+
+- 两个相同版本更新只有一个成功,另一个为 409。
+- 成功更新 version 恰好增加 1,内容与 updated_at 对应本次提交。
+- 原 PATCH 与 DELETE 已替换,旧客户端缺 version 会失败而非静默覆盖。
+
+### 生产约束与故障演练
+
+两个管理页面同时编辑同一笔记,最后保存的人不应无提示覆盖前一次修改。
+
+#### 实现决策
+
+- 客户端提交读到的版本,数据库原子验证。
+- 脚本与管理入口遵守同一版本规则。
+
+#### 隔离环境演练
+
+使用两个独立 HTTP 客户端同时提交同一 version,核验一个成功、一个冲突和数据库最终内容。
+
+#### 恢复验收
+
+- 不出现丢失更新。
+- 409 对客户端有明确恢复流程。
+
+### 常见错误
+
+#### 使用 ORM version_id_col 仍有批量更新绕过版本
+
+原因:ORM 版本检测主要作用于 flush 的单对象更新,批量 SQL 要自行加条件。
+
+处理:本课统一显式 WHERE version 与递增;若改用 mapper versioning,仍审计所有批量路径。
+
+#### 锁等待导致接口成批超时
+
+原因:持锁期间执行外部请求,或多行操作锁顺序不一致。
+
+处理:缩短事务,所有路径按相同 id 顺序加锁,外部调用通过 outbox 在提交后处理。
+
+### 扩展练习
+
+收到 409 后客户端可以自动读取新版本并原样重放旧编辑吗?
+
+提示:这会绕过保护覆盖别人刚写入的内容。
+
+参考答案:
+
+不能无条件重放。客户端重新读取并让用户合并,或只对可证明可交换的增量操作自动重试;重试必须保留业务意图,不能只换 version。
+
+### 技术文档
+
+- [PostgreSQL 16:显式锁](https://www.postgresql.org/docs/16/explicit-locking.html)
+- [SQLAlchemy:版本计数限制](https://docs.sqlalchemy.org/en/20/orm/versioning.html)
+- [SQLAlchemy:UPDATE RETURNING](https://docs.sqlalchemy.org/en/20/tutorial/data_update.html)
+
+---
+
+## 10 幂等键与唯一约束
+
+让创建请求在网络重试后返回同一次结果,使用唯一约束处理并发抢占,并拒绝同键不同内容。
+
+### 本节目标
+
+- 区分请求重试与重复创建业务语义
+- 将幂等记录与业务写入放在同一事务
+- 处理同时到达的同键请求和响应丢失
+
+### 必要概念
+
+#### 结果未知需要可重查
+
+客户端超时不代表数据库回滚,服务可能已经提交,只是响应丢失。为一次逻辑创建生成稳定 Idempotency-Key 并在重试时保持不变,服务保存该键对应的状态码与响应体,就能返回第一次结果。
+
+#### 唯一约束解决竞态
+
+先查键不存在再插入存在竞争窗口。INSERT ON CONFLICT DO NOTHING 配合唯一约束决定谁获得写入权;获胜者的业务行与幂等结果在同一事务提交。后到请求等前一事务完成,随后在 READ COMMITTED 的下一条 SELECT 中读取已提交结果。
+
+#### 键的作用域与保留期限
+
+键至少绑定身份主体、HTTP 操作与请求摘要。本课尚无身份系统,只允许本机单主体练习,scope 固定表示该边界;上线必须从认证结果生成主体作用域。记录删除后相同键可能再次创建,必须公布保留期限并与客户端最大重试窗口一致。
+
+### 动手实现
+
+#### 1. 添加幂等结果表迁移
+
+生成 Alembic revision,把下面 SQL 放入 op.execute;downgrade 使用 op.drop_table。它是普通非延迟主键,可供 ON CONFLICT 使用。业务成功后存 status_code 与响应;失败事务不保留占位记录。后续把此 Table 加入 Base.metadata,避免 autogenerate 误判为多余表。
+
+文件:终端 / 当前课时
+
+````sql
+CREATE TABLE idempotency_records (
+    scope varchar(120) NOT NULL,
+    key varchar(100) NOT NULL,
+    request_hash varchar(64) NOT NULL,
+    status_code integer,
+    response_body jsonb,
+    created_at timestamptz NOT NULL DEFAULT now(),
+    PRIMARY KEY (scope, key)
+);
+````
+
+#### 2. 把幂等表登记为 SQLAlchemy Core Table
+
+追加到 app/models.py,复用已有 Table、Column、Base、String、DateTime、func。JSONB 和 Integer 使用下方导入。SQLAlchemy Core 与 ORM 可以共用一个事务。
+
+文件:app/models.py
+
+````python
+from sqlalchemy import Integer
+from sqlalchemy.dialects.postgresql import JSONB
+
+idempotency_records = Table("idempotency_records", Base.metadata,
+    Column("scope", String(120), primary_key=True),
+    Column("key", String(100), primary_key=True),
+    Column("request_hash", String(64), nullable=False),
+    Column("status_code", Integer),
+    Column("response_body", JSONB),
+    Column("created_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
+)
+````
+
+#### 3. 新增幂等服务函数
+
+追加到 app/services.py。它要求外层尚未完成业务写入但已经 begin;不自行提交。on_conflict_do_nothing 的返回值标识是否占位成功;同键不同请求体返回 409。创建、占位和结果保存三步都是同一事务。
+
+文件:app/services.py
+
+````python
+import hashlib
+import json
+from sqlalchemy import select, update
+from sqlalchemy.dialects.postgresql import insert
+from app.models import idempotency_records
+
+async def create_idempotent(db: AsyncSession, body: NoteCreate, key: str, scope: str):
+    payload = json.dumps(body.model_dump(mode="json"), sort_keys=True, separators=(",", ":"), ensure_ascii=False)
+    digest = hashlib.sha256(payload.encode()).hexdigest()
+    claimed = await db.scalar(insert(idempotency_records).values(
+        scope=scope, key=key, request_hash=digest
+    ).on_conflict_do_nothing(index_elements=["scope", "key"]).returning(idempotency_records.c.key))
+    if claimed is None:
+        stored = (await db.execute(select(idempotency_records).where(
+            idempotency_records.c.scope == scope, idempotency_records.c.key == key))).mappings().one()
+        if stored["request_hash"] != digest:
+            raise HTTPException(409, "idempotency key reused with different payload")
+        if stored["status_code"] is None:
+            raise HTTPException(503, "idempotency result not ready")
+        return stored["status_code"], stored["response_body"]
+    note = Note(**body.model_dump())
+    db.add(note)
+    await db.flush()
+    await db.refresh(note)
+    result = NoteOut.model_validate(note).model_dump(mode="json")
+    await db.execute(update(idempotency_records).where(
+        idempotency_records.c.scope == scope, idempotency_records.c.key == key
+    ).values(status_code=201, response_body=result))
+    return 201, result
+````
+
+#### 4. 新增 HTTP 入口
+
+追加到 app/routes.py。本机单主体 scope 必须在接入鉴权后替换为服务器可信身份加操作名,不能接受客户端随意传 scope。外层事务提交后才返回保存的响应。
+
+文件:app/routes.py
+
+````python
+from fastapi.responses import JSONResponse
+from app.services import create_idempotent
+
+@router.post("/idempotent", status_code=201)
+async def create_idempotent_note(body: NoteCreate, db: Db,
+    idempotency_key: Annotated[str, Header(min_length=1, max_length=100)]):
+    async with db.begin():
+        status, payload = await create_idempotent(db, body, idempotency_key,
+            scope="local-student:POST:/notes/idempotent:v1")
+    return JSONResponse(status_code=status, content=payload)
+````
+
+#### 5. 并发重试并核对结果
+
+新建 scripts/idempotency_probe.py。使用随机新键避免前次练习污染;两个请求并发提交相同内容,返回同一个 id。再用相同键改内容应返回 409。大量并发测试必须控制在本机练习环境。 此时服务仍使用 notes_lab_migration,所有最终数据核对都连接同一实验库。
+
+文件:scripts/idempotency_probe.py
+
+````python
+import asyncio
+from uuid import uuid4
+import httpx
+
+async def main():
+    headers = {"Idempotency-Key": str(uuid4())}
+    async with httpx.AsyncClient(base_url="http://127.0.0.1:8000", timeout=5) as client:
+        async def send(title):
+            return await client.post("/notes/idempotent", json={"title": title}, headers=headers)
+        a, b = await asyncio.gather(send("retry"), send("retry"))
+        assert a.status_code == b.status_code == 201, (a.text, b.text)
+        assert a.json() == b.json()
+        assert (await send("different")).status_code == 409
+
+asyncio.run(main())
+````
+
+### 验收结果
+
+- 两个相同键相同内容请求返回相同状态码和响应体,notes 只新增一条。
+- 同键不同内容返回 409,不改变最初结果。
+- 业务事务失败后不会留下 status_code=null 的已提交占位记录。
+
+### 生产约束与故障演练
+
+移动客户端在创建接口超时后自动重试,不能重复创建笔记。
+
+#### 实现决策
+
+- 同一逻辑操作重用键,键绑定主体与操作。
+- 幂等结果与业务数据在同一数据库事务提交。
+
+#### 隔离环境演练
+
+隔离环境并发发送相同键,丢弃首个响应后再重试,最后核对记录与响应一致。
+
+#### 恢复验收
+
+- 每个键最多创建一次本服务记录。
+- 文档明确保留期限、主体作用域和不同 payload 冲突行为。
+
+### 常见错误
+
+#### 请求哈希把 JSON 字段顺序当成内容不同
+
+原因:对原始 JSON 字符串直接计算摘要。
+
+处理:先 Pydantic 规范化,再固定字段顺序和编码;版本化摘要规则,升级时维持旧键语义。
+
+#### 清理幂等表后客户端晚到重试又创建一条
+
+原因:保留期限短于客户端重试窗口,或没有对重复业务本身加约束。
+
+处理:公布键保留期并记录过期行为;订单号等业务唯一性还需独立唯一约束。
+
+### 扩展练习
+
+如何模拟数据库已提交但客户端没收到响应?
+
+提示:在本机先成功调用后故意丢弃响应,再以原键重发即可验证服务器语义。
+
+参考答案:
+
+保存 key,第一次请求完成后不使用响应,第二次用相同 key/body 发起请求。核对只新增一行且得到相同 id;若做真正断连注入,要同时覆盖 commit 前后两个时点,不能从客户端断开推断数据库一定回滚。
+
+### 技术文档
+
+- [PostgreSQL 16:INSERT ON CONFLICT](https://www.postgresql.org/docs/16/sql-insert.html)
+- [SQLAlchemy:PostgreSQL UPSERT](https://docs.sqlalchemy.org/en/20/dialects/postgresql.html#insert-on-conflict-upsert)
+- [PostgreSQL 16:READ COMMITTED](https://www.postgresql.org/docs/16/transaction-iso.html#XACT-READ-COMMITTED)
+
+---
+
+## 11 事务 Outbox 与跨服务一致性
+
+将笔记变更和待发事件原子落库,再由独立发布器领取事件并在提交后发送。
+
+### 本节目标
+
+- 解释数据库提交和消息发送之间的双写缺口
+- 实现带租约的 outbox 领取和确认
+- 让接收方以 event_id 去重并承受重复投递
+
+### 必要概念
+
+#### 本地事务不覆盖外部服务
+
+先提交笔记再发消息,发送失败会漏事件;先发消息再提交,事务失败会留下不存在的业务事件。Outbox 把业务记录与待发事件写在同一数据库事务内,发布器稍后重试外部发送,接受最终一致性的延迟。
+
+#### 至少一次与租约
+
+发布器领取一批事件,写 lease token 与 locked_until 后立即提交,再进行网络调用。崩溃后租约到期可重新领取;发送成功但标记失败会导致重复投递。系统保证的是可重试的至少一次交付流程,接收方必须去重;不是端到端恰好一次。
+
+#### 领取、重试与顺序
+
+SKIP LOCKED 适合队列式争抢,不能用于普通业务一致列表。按 id 领取只提供尽量有序,不保证同一笔记多个事件的严格顺序。若业务需要顺序,事件携带 aggregate_id/version,接收方按版本处理并暂存缺口。
+
+### 动手实现
+
+#### 1. 迁移 outbox 与本机接收表
+
+生成 Alembic revision 并通过 op.execute 执行以下 SQL。outbox 的 payload 最小化,不复制密钥或完整敏感提示词。表定义需同步加入 Base.metadata;以下后续步骤会提供对应 Core Table 映射,避免 autogenerate 误判删除。
+
+文件:终端 / 当前课时
+
+````sql
+CREATE TABLE outbox_events (
+    id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
+    event_id uuid NOT NULL UNIQUE,
+    aggregate_id bigint NOT NULL,
+    event_type varchar(80) NOT NULL,
+    payload jsonb NOT NULL,
+    created_at timestamptz NOT NULL DEFAULT now(),
+    available_at timestamptz NOT NULL DEFAULT now(),
+    locked_until timestamptz,
+    lock_token uuid,
+    attempts integer NOT NULL DEFAULT 0,
+    delivered_at timestamptz,
+    last_error varchar(120)
+);
+CREATE INDEX ix_outbox_pending ON outbox_events (available_at, id) WHERE delivered_at IS NULL;
+CREATE TABLE received_events (
+    event_id uuid PRIMARY KEY,
+    received_at timestamptz NOT NULL DEFAULT now()
+);
+````
+
+#### 2. 把 Outbox 结构同步到 metadata
+
+追加到 app/models.py,复用已有 Table、Column、Index、Base、BigInteger、Identity、Integer、String、DateTime、func、text 与 JSONB。只声明映射,不在应用启动时建表;迁移创建实际表。
+
+文件:app/models.py
+
+````python
+from sqlalchemy.dialects.postgresql import UUID
+
+outbox_events = Table("outbox_events", Base.metadata,
+    Column("id", BigInteger, Identity(), primary_key=True),
+    Column("event_id", UUID(as_uuid=True), nullable=False, unique=True),
+    Column("aggregate_id", BigInteger, nullable=False),
+    Column("event_type", String(80), nullable=False),
+    Column("payload", JSONB, nullable=False),
+    Column("created_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
+    Column("available_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
+    Column("locked_until", DateTime(timezone=True)),
+    Column("lock_token", UUID(as_uuid=True)),
+    Column("attempts", Integer, nullable=False, server_default=text("0")),
+    Column("delivered_at", DateTime(timezone=True)),
+    Column("last_error", String(120)),
+)
+Index("ix_outbox_pending", outbox_events.c.available_at, outbox_events.c.id,
+      postgresql_where=outbox_events.c.delivered_at.is_(None))
+received_events = Table("received_events", Base.metadata,
+    Column("event_id", UUID(as_uuid=True), primary_key=True),
+    Column("received_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
+)
+````
+
+#### 3. 在创建事务内追加事件
+
+追加到 app/services.py。把 await add_created_event(db, note) 放入第 10 课 create_idempotent 的新建成功分支:在 flush/refresh 后、写入幂等结果前。重放分支不再生成事件。函数只写本地表,不做 HTTP。
+
+文件:app/services.py
+
+````python
+from uuid import uuid4
+
+async def add_created_event(db: AsyncSession, note: Note):
+    await db.execute(text("""
+        INSERT INTO outbox_events (event_id, aggregate_id, event_type, payload)
+        VALUES (:event_id, :note_id, 'note.created', CAST(:payload AS jsonb))
+    """), {"event_id": uuid4(), "note_id": note.id,
+             "payload": json.dumps({"note_id": note.id, "version": note.version})})
+````
+
+#### 4. 新建领取与确认函数
+
+新建 app/outbox.py。批量上限 20、租约 30 秒是练习参数;本课发布器每次只领 1 条以保证 3 秒请求预算小于租约。UPDATE 确认同时匹配 token,旧发布器无法覆盖新持有者。超过重试预算的事件停止自动领取并进入人工排查清单。
+
+文件:app/outbox.py
+
+````python
+from uuid import uuid4
+from sqlalchemy import text
+from app.db import SessionFactory
+
+async def claim(limit=1):
+    if not 1 <= limit <= 20:
+        raise ValueError("limit must be between 1 and 20")
+    token = uuid4()
+    async with SessionFactory.begin() as db:
+        rows = (await db.execute(text("""
+            WITH picked AS (
+                SELECT id FROM outbox_events
+                WHERE delivered_at IS NULL AND available_at <= now()
+                  AND attempts < 8
+                  AND (locked_until IS NULL OR locked_until < now())
+                ORDER BY id FOR UPDATE SKIP LOCKED LIMIT :limit
+            )
+            UPDATE outbox_events e SET lock_token=:token,
+                locked_until=now()+interval '30 seconds', attempts=attempts+1
+            FROM picked WHERE e.id=picked.id
+            RETURNING e.id, e.event_id, e.event_type, e.payload, e.attempts
+        """), {"token": token, "limit": limit})).mappings().all()
+    return token, [dict(x) for x in rows]
+
+async def finish(event_id, token, success):
+    async with SessionFactory.begin() as db:
+        await db.execute(text("""
+            UPDATE outbox_events
+            SET delivered_at=CASE WHEN :success THEN now() ELSE NULL END,
+                available_at=CASE WHEN :success THEN available_at ELSE
+                    now()+make_interval(secs => least(300, power(2, attempts)::int)) END,
+                locked_until=NULL, lock_token=NULL,
+                last_error=CASE WHEN :success THEN NULL ELSE 'delivery_failed' END
+            WHERE id=:id AND lock_token=:token AND delivered_at IS NULL
+        """), {"id": event_id, "token": token, "success": success})
+````
+
+#### 5. 添加本机去重接收端点
+
+追加到 app/main.py,仅用于本机教学模拟第二个服务。真实接收服务拥有独立数据库;去重插入与自身业务副作用必须同一事务。示例的副作用仅是记录接收,不伪称完成搜索索引等外部功能。
+
+文件:app/main.py
+
+````python
+from uuid import UUID
+from pydantic import BaseModel, Field
+
+class IncomingEvent(BaseModel):
+    event_id: UUID
+    event_type: str = Field(max_length=80)
+    payload: dict
+
+@app.post("/internal/events")
+async def receive_event(body: IncomingEvent, db: Db):
+    async with db.begin():
+        new_id = await db.scalar(text("""
+            INSERT INTO received_events (event_id) VALUES (:event_id)
+            ON CONFLICT (event_id) DO NOTHING RETURNING event_id
+        """), {"event_id": body.event_id})
+        # 真实接收方在这里执行自己的数据库副作用,和去重记录一起提交。
+    return {"accepted": True, "duplicate": new_id is None}
+````
+
+#### 6. 创建单次发布脚本
+
+新建 scripts/publish_once.py,运行 python -m scripts.publish_once。它每次处理一条,HTTP 在 claim 事务提交之后执行;没有在持锁时等待网络。成功接收但 finish 前崩溃会被再次投递,这是下一步演练目标。
+
+文件:scripts/publish_once.py
+
+````python
+import asyncio
+import sys
+import httpx
+from app.db import engine
+from app.outbox import claim, finish
+
+async def main():
+    try:
+        token, events = await claim(limit=1)
+        async with httpx.AsyncClient(timeout=3) as client:
+            for event in events:
+                success = False
+                try:
+                    response = await client.post("http://127.0.0.1:8000/internal/events", json={
+                        "event_id": str(event["event_id"]), "event_type": event["event_type"],
+                        "payload": event["payload"]})
+                    response.raise_for_status()
+                    success = True
+                except httpx.HTTPError:
+                    pass
+                await finish(event["id"], token, success)
+    finally:
+        await engine.dispose()
+
+if __name__ == "__main__":
+    if sys.platform == "win32":
+        asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
+    asyncio.run(main())
+````
+
+#### 7. 演练发送成功、确认丢失
+
+仅在本机将 publish_once 中 await finish 临时注释一次,创建一条新幂等笔记后发布,确认 received_events 已有记录但 outbox 未 delivered。等待租约到期后恢复 finish 再发;接收方 duplicate=true,received_events 仍只有一行。用下面查询监控积压,不将 attempts>=8 的事件静默删除。
+
+文件:终端 / 当前课时
+
+````sql
+SELECT count(*) AS pending,
+       min(created_at) AS oldest_pending,
+       count(*) FILTER (WHERE attempts >= 8) AS needs_attention
+FROM outbox_events WHERE delivered_at IS NULL;
+````
+
+### 验收结果
+
+- 幂等创建只产生一条 notes 和一条对应 outbox event,重试不会再发新事件。
+- claim 结束后才调用 HTTP,数据库没有等待外部服务的长事务。
+- 重复投递被接收方去重,失败事件保留次数、下一次可尝试时间与排查状态。
+
+### 生产约束与故障演练
+
+创建笔记后要通知索引服务,但索引服务短暂不可用。
+
+#### 实现决策
+
+- 业务事务内只记录事件;外部传输在短租约领取后执行。
+- 监控积压年龄与耗尽重试,接收方按事件 id 去重。
+
+#### 隔离环境演练
+
+模拟成功接收后未确认,再过期重领,核对接收记录只有一条且 outbox 最终 delivered。
+
+#### 恢复验收
+
+- 不丢失已提交业务的待发事件。
+- 承认并验证至少一次投递与最终一致性边界。
+
+### 常见错误
+
+#### 消息发送成功后进程崩溃,恢复后又发送一次
+
+原因:外部成功与本地 delivered 标记不能在一个普通数据库事务中原子提交。
+
+处理:接受重复并按 event_id 去重,去重记录和接收方业务写入共用事务。
+
+#### 批量领取 100 条后大量租约过期
+
+原因:顺序发送总耗时超过租约,其他发布器重新领取。
+
+处理:缩小批次、限制单次请求时间;扩大并行时计算整个批次最坏耗时,必要时做可验证的续租。
+
+### 扩展练习
+
+如果事件用于邮件发送,接收数据库去重是否保证邮件只发送一次?
+
+提示:数据库事务不能原子覆盖外部邮件供应商。
+
+参考答案:
+
+不能。邮件供应商若支持幂等键,应传 event_id;否则仍存在发送成功但记录失败的窗口。要按业务允许的重复程度设计去重、状态查询或人工补偿,不能称为恰好一次。
+
+### 技术文档
+
+- [PostgreSQL 16:SKIP LOCKED](https://www.postgresql.org/docs/16/sql-select.html)
+- [AWS:事务 Outbox 模式](https://docs.aws.amazon.com/prescriptive-guidance/latest/cloud-design-patterns/transactional-outbox.html)
+- [PostgreSQL 16:唯一约束](https://www.postgresql.org/docs/16/ddl-constraints.html#DDL-CONSTRAINTS-UNIQUE-CONSTRAINTS)
+
+---
+
+## 12 真实 PostgreSQL 隔离测试与故障验收
+
+用专用 PostgreSQL 测试库验证提交、并发、迁移和故障恢复,避免 SQLite 掩盖生产差异。
+
+### 本节目标
+
+- 区分回滚隔离测试与真实提交并发测试
+- 使用依赖覆盖连接真实 PostgreSQL
+- 建立错误注入与恢复验收清单
+
+### 必要概念
+
+#### 测试数据库必须同类
+
+SQLite 的类型、JSON、锁、约束和并发行为与 PostgreSQL 不同。本模块针对 PostgreSQL 16 测试,测试库独立为 notes_lab_test,使用专用凭据或限定权限。测试代码启动时检查数据库名,不从默认生产 URL 继承目标。
+
+#### 两类测试不能混用
+
+单请求 CRUD 可以把 Session 绑定外层连接事务,通过 savepoint 让内部 commit 后最终回滚清理。并发与提交失败测试必须使用多个真实连接和真正 commit;外层回滚会隐藏可延迟约束的提交时机,也无法验证其他连接可见性。
+
+#### 故障测试的验收对象
+
+不只断言错误码,还要查数据库最终状态、连接是否释放、下一次正常请求是否恢复。连接中断可能造成提交结果未知,验收需用幂等键重查;不要凭一次网络错误断言写入不存在。
+
+### 动手实现
+
+#### 1. 准备独立测试库与迁移
+
+先用数据库管理员在本机创建 notes_lab_test 并授予练习 app_user 对该库迁移权限。仅当前测试终端设置以下变量;把 CHANGE_ME 替换为本机测试密码。运行迁移前通过 psql 核对数据库名称。不要对共享库执行测试清理。
+
+文件:终端 / 当前课时
+
+````powershell
+$env:TEST_DATABASE_URL="postgresql+psycopg://app_user:CHANGE_ME@127.0.0.1:5433/notes_lab_test"
+$env:DATABASE_URL=$env:TEST_DATABASE_URL
+python -m alembic upgrade head
+python -m alembic check
+````
+
+#### 2. 新建 pytest 配置与隔离 fixture
+
+新建 pytest.ini 内容 [pytest] 下一行 asyncio_mode = auto。再新建 tests/conftest.py。在导入 app 前覆盖 DATABASE_URL;fixture 检查测试库名并使用 NullPool,避免不同事件循环复用连接。外层事务提供回滚隔离,但不能用此 fixture 验证并发或 commit 时延迟约束。
+
+文件:tests/conftest.py
+
+````python
+import os
+import sys
+import asyncio
+import pytest_asyncio
+from httpx import ASGITransport, AsyncClient
+from sqlalchemy.engine import make_url
+from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
+from sqlalchemy.pool import NullPool
+
+if sys.platform == "win32":
+    asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
+
+test_url = os.environ["TEST_DATABASE_URL"]
+if make_url(test_url).database != "notes_lab_test":
+    raise RuntimeError("tests require notes_lab_test")
+os.environ["DATABASE_URL"] = test_url
+
+from app.main import app
+from app.db import get_session
+
+@pytest_asyncio.fixture
+async def client():
+    test_engine = create_async_engine(test_url, poolclass=NullPool)
+    try:
+        async with test_engine.connect() as conn:
+            outer = await conn.begin()
+            async def override():
+                async with AsyncSession(bind=conn, expire_on_commit=False,
+                    join_transaction_mode="create_savepoint") as db:
+                    yield db
+            app.dependency_overrides[get_session] = override
+            try:
+                async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
+                    yield c
+            finally:
+                app.dependency_overrides.clear()
+                await outer.rollback()
+    finally:
+        await test_engine.dispose()
+````
+
+#### 3. 测试最终版本 CRUD 协议
+
+新建 tests/test_notes.py。版本字段与第 9 课保持一致,先验证成功更新再验证旧版本冲突。ASGITransport 不自动运行 lifespan;本 fixture 不依赖启动建表,迁移已在前一步完成。需要启动资源的应用应显式使用 lifespan 管理器。
+
+文件:tests/test_notes.py
+
+````python
+async def test_create_patch_conflict_delete(client):
+    response = await client.post("/notes", json={"title": "isolated"})
+    assert response.status_code == 201, response.text
+    original = response.json()
+    path = f"/notes/{original['id']}"
+    saved = await client.patch(path, json={"title": "updated", "version": original["version"]})
+    assert saved.status_code == 200, saved.text
+    updated = saved.json()
+    assert updated["version"] == original["version"] + 1
+    conflict = await client.patch(path, json={"title": "stale", "version": original["version"]})
+    assert conflict.status_code == 409
+    assert (await client.get(path)).json()["title"] == "updated"
+    deleted = await client.delete(path, params={"version": updated["version"]})
+    assert deleted.status_code == 204 and deleted.content == b""
+    assert (await client.get(path)).status_code == 404
+
+async def test_input_boundaries(client):
+    assert (await client.post("/notes", json={"title": "x" * 121})).status_code == 422
+    assert (await client.get("/notes", params={"limit": 101})).status_code == 422
+    assert (await client.get("/notes", params={"cursor": "broken!"})).status_code == 422
+````
+
+#### 4. 增加真正提交的并发 fixture
+
+追加到 tests/conftest.py。该 fixture 使用不同请求各自的真实连接,不使用外层回滚;只能在专用测试库运行。每个测试用随机键隔离,并在测试 finally 清理自己产生的记录。不要并发运行会修改 app.dependency_overrides 的测试。
+
+文件:tests/conftest.py
+
+````python
+from sqlalchemy.ext.asyncio import async_sessionmaker
+
+@pytest_asyncio.fixture
+async def committed_client():
+    test_engine = create_async_engine(test_url, poolclass=NullPool)
+    factory = async_sessionmaker(test_engine, expire_on_commit=False)
+    async def override():
+        async with factory() as db:
+            yield db
+    app.dependency_overrides[get_session] = override
+    try:
+        async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
+            yield c, factory
+    finally:
+        app.dependency_overrides.clear()
+        await test_engine.dispose()
+````
+
+#### 5. 编写并发幂等测试并精确清理
+
+新建 tests/test_concurrency.py。只按本测试随机 key 查到的 note_id 清理关联 outbox 和业务记录;不 TRUNCATE 全库。若测试进程被强制结束残留数据,应通过专用测试库重建或运行有审计的测试清理作业。
+
+文件:tests/test_concurrency.py
+
+````python
+import asyncio
+from uuid import uuid4
+from sqlalchemy import text
+
+async def test_idempotency_concurrent(committed_client):
+    client, factory = committed_client
+    key = str(uuid4())
+    scope = "local-student:POST:/notes/idempotent:v1"
+    try:
+        async def send():
+            return await client.post("/notes/idempotent", json={"title": "parallel"},
+                headers={"Idempotency-Key": key})
+        a, b = await asyncio.gather(send(), send())
+        assert a.status_code == b.status_code == 201, (a.text, b.text)
+        assert a.json() == b.json()
+        async with factory.begin() as db:
+            count = await db.scalar(text("SELECT count(*) FROM outbox_events WHERE aggregate_id=:id"), {"id": a.json()["id"]})
+            assert count == 1
+    finally:
+        async with factory.begin() as db:
+            payload = await db.scalar(text("SELECT response_body FROM idempotency_records WHERE scope=:scope AND key=:key"), {"scope": scope, "key": key})
+            if payload:
+                note_id = payload["id"]
+                await db.execute(text("DELETE FROM outbox_events WHERE aggregate_id=:id"), {"id": note_id})
+                await db.execute(text("DELETE FROM notes WHERE id=:id"), {"id": note_id})
+            await db.execute(text("DELETE FROM idempotency_records WHERE scope=:scope AND key=:key"), {"scope": scope, "key": key})
+````
+
+#### 6. 运行测试与故障矩阵
+
+执行 python -m pytest -q。再在独立本机服务对第 4、6、11 课演练做真实提交验收,并记录错误码、数据库最终状态和恢复探针。以下矩阵是需要实际执行的验收任务,不代表课件已运行并通过;不要在未执行时把“预期”写成“结果”。
+
+文件:终端 / 当前课时
+
+````text
+场景 | 注入方式 | 预期状态 | 恢复验证
+提交约束失败 | 重复 audited request-key | 409;notes/audits 同时回滚 | 新键创建成功
+池耗尽 | 独立 pool_drill,池上限 1 | 一任务超时;无连接泄漏 | SELECT 1 成功
+陈旧编辑 | 两客户端相同 version | 一个成功、一个 409 | GET 与成功者一致
+同键重试 | 两真实连接同时创建 | 相同响应;一条笔记一条事件 | 再重试同结果
+发布器崩溃 | 接收后跳过 finish | 事件到期重领;接收方去重 | 最终 delivered
+迁移锁阻塞 | 专用库长事务占锁 | 超时失败;revision 未推进 | 释放锁后 upgrade 成功
+````
+
+### 验收结果
+
+- pytest 输出真实执行结果;测试库名称防护拒绝 notes_lab 或其他库。
+- 回滚 fixture 不留下 CRUD 数据,并发 fixture 使用真实提交且只清理自身记录。
+- 失败演练检查了数据库最终状态和恢复能力,没有用 SQLite 替代 PostgreSQL 特性。
+
+### 生产约束与故障演练
+
+数据库连接、迁移和并发逻辑要进入持续集成,而不依赖开发者手工点 /docs。
+
+#### 实现决策
+
+- 同类数据库验证实际语义,测试环境显式隔离。
+- 单请求回滚测试与真实提交集成测试分开,验收包括故障恢复。
+
+#### 隔离环境演练
+
+完整运行本课矩阵,仅使用本机专用测试库,保存预期、实际、证据和恢复结果四列记录。
+
+#### 恢复验收
+
+- CRUD、输入边界、并发与幂等有真实自动化断言。
+- 迁移与失败路径有执行证据,未运行的项目明确标记未验证。
+
+### 常见错误
+
+#### 回滚隔离测试通过,但线上 commit 延迟约束失败
+
+原因:测试提交的是 SAVEPOINT,不是最外层数据库 COMMIT。
+
+处理:延迟约束、并发可见性与网络提交异常使用 committed_client 或独立进程专用测试库。
+
+#### 测试发出请求后后台发布器读不到数据
+
+原因:测试业务事务仍被外层回滚事务包裹,其他连接看不到未提交数据。
+
+处理:跨连接集成测试使用真正提交,并按测试生成的唯一业务键清理。
+
+### 扩展练习
+
+如何防止测试运行时意外连接到线上?
+
+提示:不要只依赖“我记得设置了环境变量”。
+
+参考答案:
+
+要求 TEST_DATABASE_URL 必填并校验数据库名,使用专用网络与最小权限测试凭据,CI 不注入生产凭据;日志输出脱敏 host/database 供核对。数据库名检查只是第一道防线,不能替代网络与权限隔离。
+
+### 技术文档
+
+- [SQLAlchemy:外部事务与测试](https://docs.sqlalchemy.org/en/20/orm/session_transaction.html#joining-a-session-into-an-external-transaction-such-as-for-test-suites)
+- [FastAPI:异步测试](https://fastapi.tiangolo.com/advanced/async-tests/)
+- [HTTPX:ASGI Transport](https://www.python-httpx.org/advanced/transports/#asgi-transport)
+- [pytest-asyncio:配置](https://pytest-asyncio.readthedocs.io/en/stable/reference/configuration.html)

+ 2561 - 0
public/downloads/api-lessons.md

@@ -0,0 +1,2561 @@
+# API 服务开发
+
+术核 AI · 后端工程课件
+
+## 01 启动第一个 FastAPI 服务
+
+建立 api-lab 项目,让 /health 返回 JSON,并通过交互文档发出第一次请求。
+
+### 本节目标
+
+- 为项目创建独立的 Python 环境
+- 解释 main:app、端口与 --reload 的作用
+- 分别在浏览器和 /docs 验证同一个接口
+
+### 必要概念
+
+#### 服务进程与路由函数
+
+普通脚本运行结束后会退出。API 服务由 Uvicorn 持续监听本机端口,收到 HTTP 请求后调用 FastAPI 中匹配的方法与路径。@app.get('/health') 注册的是 GET /health;函数的返回值会被序列化为 JSON 响应。
+
+#### 虚拟环境决定从哪里导入依赖
+
+同一台电脑可以有多个 Python。虚拟环境把当前项目的解释器入口和第三方包放在 .venv 中。用 python -m pip 安装,能使 pip 与当前 python 指向同一环境;编辑器也应选择 .venv 中的解释器。
+
+#### 开发服务器的几个参数
+
+main:app 表示从当前目录的 main.py 导入 app 对象。127.0.0.1 是本机地址,8000 是默认端口。--reload 会在代码变化后重启开发进程,重启也会清空之后课程中的内存数据。
+
+### 动手实现
+
+#### 1. 建立目录和虚拟环境
+
+准备 Python 3.11 或更新版本。以下两组命令按系统选择一组。激活只影响当前终端;以后重新打开终端时,要先切换到 api-lab 再激活。
+
+文件:终端 / 当前课时
+
+````bash
+# Windows PowerShell
+mkdir api-lab
+cd api-lab
+py -3 -m venv .venv
+.\.venv\Scripts\Activate.ps1
+
+# macOS / Linux
+mkdir api-lab
+cd api-lab
+python3 -m venv .venv
+source .venv/bin/activate
+
+# 激活后检查解释器路径,结果应包含 api-lab/.venv
+python -c "import sys; print(sys.executable)"
+````
+
+#### 2. 安装依赖并记录环境
+
+创建 requirements.txt,写入下面三行。然后在激活后的终端运行 python -m pip install -r requirements.txt。本模块使用 Pydantic v2 的 model_dump() 写法。安装成功后运行 python -m pip freeze > requirements-lock.txt,保留当前环境实际使用的版本;另一个环境可从这个锁定文件安装。 如果直接运行下载包,请使用包内已经锁定版本的 requirements.txt。
+
+文件:requirements.txt
+
+````bash
+fastapi>=0.115,<1
+uvicorn[standard]>=0.30,<1
+pydantic>=2,<3
+````
+
+#### 3. 编写健康检查接口
+
+在 api-lab 根目录创建 main.py。FastAPI 实例只创建一次。路由装饰器紧接在函数定义上方,返回值使用 Python 字典,JSON 编码由框架完成。
+
+文件:main.py
+
+````python
+from fastapi import FastAPI
+
+app = FastAPI(title="API Lab", version="0.1.0")
+
+@app.get("/health", tags=["system"])
+def health():
+    return {"status": "ok"}
+````
+
+#### 4. 启动服务并观察终端
+
+在 main.py 所在目录运行以下命令。终端保持运行状态是正常现象;看到 Application startup complete 后再访问浏览器。服务运行期间用另一个终端执行请求,停止服务使用 Ctrl+C。
+
+文件:终端 / 当前课时
+
+````bash
+python -m uvicorn main:app --reload
+# 默认访问地址:http://127.0.0.1:8000
+# 8000 被占用时,可加 --port 8001
+````
+
+#### 5. 通过浏览器和交互文档验收
+
+先打开 http://127.0.0.1:8000/health,确认显示 {"status":"ok"}。再打开 /docs,展开 GET /health → Try it out → Execute,查看状态码、响应头和响应体。最后把 main.py 中的 ok 改为 ready 并保存,观察终端重载日志和新响应,完成后改回 ok。
+
+文件:终端 / 当前课时
+
+````http
+GET /health HTTP/1.1
+Host: 127.0.0.1:8000
+
+HTTP/1.1 200 OK
+Content-Type: application/json
+
+{"status":"ok"}
+````
+
+### 验收结果
+
+- 终端的 sys.executable 路径包含 api-lab/.venv。
+- GET /health 返回 200 和 {"status":"ok"};访问未定义的 / 返回 404,这不表示服务未启动。
+- /docs 中出现 GET /health,点击 Execute 后能看到真实响应。
+
+### 常见错误
+
+#### ModuleNotFoundError: No module named 'fastapi'
+
+原因:安装依赖和启动服务使用了不同解释器。
+
+处理:激活 .venv,检查 sys.executable,再执行 python -m pip install -r requirements.txt。
+
+#### Could not import module 'main'
+
+原因:当前目录没有 main.py,或者文件中存在语法错误。
+
+处理:切换到 api-lab 根目录,确认文件名为 main.py,阅读终端中最早出现的 Python 错误。
+
+#### PowerShell 阻止 Activate.ps1 运行
+
+原因:当前执行策略限制脚本运行。
+
+处理:无需修改全局策略,可直接使用 .\.venv\Scripts\python.exe -m pip install -r requirements.txt,以及 .\.venv\Scripts\python.exe -m uvicorn main:app --reload。
+
+### 扩展练习
+
+添加 GET /version,返回 {"service":"api-lab","version":"0.1.0"},并在 /docs 中执行一次。
+
+提示:保留已有 app,在另一个函数上方注册新的 GET 路由。
+
+参考答案:
+
+在 main.py 末尾添加:
+@app.get('/version')
+def version():
+    return {'service': 'api-lab', 'version': '0.1.0'}
+
+保存后访问 /version,应返回 200;刷新 /docs 后应同时看到 /health 与 /version。
+
+### 技术文档
+
+- [Python:venv](https://docs.python.org/3/library/venv.html)
+- [FastAPI:First Steps](https://fastapi.tiangolo.com/tutorial/first-steps/)
+- [FastAPI:手动运行服务器](https://fastapi.tiangolo.com/deployment/manually/)
+
+---
+
+## 02 读懂一次 HTTP 请求与响应
+
+拆开方法、路径、查询参数、请求头和请求体,亲手发送 GET 与 POST。
+
+### 本节目标
+
+- 在请求中指出方法、URL、请求头和请求体的位置
+- 区分 Content-Type、Accept 与 HTTP 状态码
+- 用 /docs 和命令行复现同一条请求
+
+### 必要概念
+
+#### 请求由几个独立部分组成
+
+以 POST /notes?source=lesson 为例:POST 是方法,/notes 是路径,source=lesson 是查询参数。请求头携带内容类型等元信息,请求体携带笔记数据。下面的 HTTP/1.1 文本用于辨认字段;浏览器也可能使用其他 HTTP 版本,业务含义一致。
+
+#### Content-Type 描述实际内容
+
+请求头 Content-Type: application/json 表示请求体按 JSON 编码;Accept: application/json 表示客户端期望的响应格式。服务器仍按接口定义产生响应,并非只改 Accept 就会自动得到另一种格式。JSON 键和字符串使用双引号,布尔值使用 true / false。
+
+#### 响应状态与业务字段分开读取
+
+响应先给出 HTTP 状态码,再给出响应头和响应体。200 表示本次 HTTP 操作成功,422 表示请求数据未通过当前接口校验。把 {"code":404} 放在一个 200 响应里,不会把 HTTP 状态变成 404。
+
+### 动手实现
+
+#### 1. 观察最小 GET 请求
+
+保持第 01 课服务运行。在 /docs 执行 /health,同时查看本页的请求拆解演示。把方法、路径、Host 和响应体分别定位;GET /health 不需要请求体。
+
+文件:终端 / 当前课时
+
+````http
+GET /health HTTP/1.1
+Host: 127.0.0.1:8000
+Accept: application/json
+
+HTTP/1.1 200 OK
+Content-Type: application/json
+
+{"status":"ok"}
+````
+
+#### 2. 添加临时的 POST 回显接口
+
+把以下代码追加到 main.py。这个临时接口只读取请求体并返回,不保存笔记,也不宣称创建成功。第 03 课会用带类型的模型替换它。这里先借助 Request 观察 HTTP 数据,异步语法在后续课程集中练习。
+
+文件:main.py
+
+````python
+from fastapi import Request
+
+@app.post("/notes")
+async def inspect_note(request: Request):
+    data = await request.json()
+    return {"received": data}
+````
+
+#### 3. 准备 JSON 文件并发送 POST
+
+在 api-lab 中创建 note.json,使用 UTF-8 保存。macOS / Linux 执行 curl -i -X POST http://127.0.0.1:8000/notes -H "Content-Type: application/json" --data-binary @note.json;Windows PowerShell 使用 curl.exe,并把 @note.json 写成引号包围的 "@note.json"。-i 同时显示响应头。
+
+文件:note.json
+
+````json
+{
+  "title": "first note",
+  "content": "learn HTTP",
+  "pinned": false
+}
+````
+
+#### 4. 对照原始请求与服务器结果
+
+命令行应返回 200 和 received 对象。改动 note.json 中的 title 后再次发送,确认响应随之变化。在浏览器开发者工具 Network 中展开 /docs 发出的请求,也能查看 Method、Request Headers、Payload 和 Response。
+
+文件:终端 / 当前课时
+
+````http
+POST /notes HTTP/1.1
+Host: 127.0.0.1:8000
+Content-Type: application/json
+
+{"title":"first note","content":"learn HTTP","pinned":false}
+
+HTTP/1.1 200 OK
+Content-Type: application/json
+
+{"received":{"title":"first note","content":"learn HTTP","pinned":false}}
+````
+
+### 验收结果
+
+- 能说明查询参数属于 URL,请求体位于请求头之后,两者不是同一块数据。
+- 命令行 POST 返回 200,并在 received 中保留 note.json 的三个字段。
+- 浏览器直接打开 /notes 会发出 GET;当前只注册了 POST,因此应返回 405 Method Not Allowed。
+
+### 常见错误
+
+#### PowerShell 提示无法识别 -H 参数
+
+原因:使用了旧版 PowerShell 的 curl 别名。
+
+处理:明确执行 curl.exe;或者在 /docs 中操作。
+
+#### 请求体解析失败
+
+原因:把 Python 字典写法当成 JSON,例如使用单引号或 False。
+
+处理:使用 note.json 文件,字符串和字段名用双引号,布尔值写 true / false。
+
+#### 浏览器地址栏打不开 POST 接口
+
+原因:地址栏导航通常发送 GET,没有发送预期的 POST 和 JSON 请求体。
+
+处理:使用 curl、/docs 或能够配置 HTTP 方法的客户端。
+
+### 扩展练习
+
+把请求体的 title 改成 second note,再发送一次。记录响应的 HTTP 状态码、Content-Type 和 received.title;最后说明 Accept 和 Content-Type 的区别。
+
+提示:使用 -i 查看响应头,不要只看最后一行 JSON。
+
+参考答案:
+
+状态码为 200;响应 Content-Type 为 application/json;received.title 为 second note。请求 Content-Type 描述这次发送的数据格式,Accept 描述客户端希望接收的格式。
+
+### 技术文档
+
+- [RFC 9110:HTTP 消息与内容语义](https://www.rfc-editor.org/rfc/rfc9110.html#section-6)
+- [RFC 8259:JSON 语法](https://www.rfc-editor.org/rfc/rfc8259)
+- [FastAPI:直接使用 Request](https://fastapi.tiangolo.com/advanced/using-request-directly/)
+
+---
+
+## 03 从路径、查询参数和 JSON 中接收数据
+
+为笔记接口声明参数类型,让 FastAPI 完成解析、默认值填充和基础校验。
+
+### 本节目标
+
+- 将 note_id 放在路径中,将筛选条件放在查询参数中
+- 用 Pydantic 模型接收 JSON 请求体
+- 通过 422 响应定位错误参数
+
+### 必要概念
+
+#### 路径标识对象,查询参数控制读取方式
+
+/notes/12 中的 12 表示要操作的笔记;/notes?limit=10&pinned=true 中的参数表示读取条件。参数名出现在路由路径中时,FastAPI 将其识别为路径参数。未出现在路径中的简单类型参数通常来自查询字符串。
+
+#### 类型声明参与运行时解析
+
+HTTP 路径和查询字符串首先都是文本。note_id: int 要求它能解析为整数;limit: int = 10 提供默认值。无法转换的值会在进入路由函数前产生 422 响应,因此函数里不用重复写 int() 和大量类型判断。
+
+#### 请求模型决定必填与默认值
+
+继承 BaseModel 的 NoteCreate 声明 JSON 对象的结构。title 没有默认值,所以必须发送;content 默认空字符串,pinned 默认 false。str | None 表示允许 null,不自动表示可以省略;若要允许省略,还需写默认值,例如 = None。
+
+### 动手实现
+
+#### 1. 声明创建笔记的输入模型
+
+在 main.py 的路由定义之前添加模型。删除第 02 课的 inspect_note 路由函数及其装饰器,随后用第 04 步的新 POST /notes 替换,避免同一方法和路径出现多个处理函数。
+
+文件:main.py
+
+````python
+from pydantic import BaseModel
+
+class NoteCreate(BaseModel):
+    title: str
+    content: str = ""
+    pinned: bool = False
+````
+
+#### 2. 添加路径参数
+
+添加 GET /notes/{note_id}。本节先回显解析后的编号,暂不查询存储。依次请求 /notes/12 与 /notes/abc,比较状态码;整数解析失败时,错误位置会包含 path 和 note_id。
+
+文件:main.py
+
+````python
+@app.get("/notes/{note_id}")
+def inspect_note_id(note_id: int):
+    return {"note_id": note_id}
+````
+
+#### 3. 添加查询参数和范围
+
+添加 GET /notes。Annotated 把 Python 类型和 FastAPI 的参数规则写在一起。limit 默认为 10,只允许 1 到 100;pinned 未传时为 None,传 false 时是明确的 False,后续筛选中要区分这两种情况。
+
+文件:main.py
+
+````python
+from typing import Annotated
+from fastapi import Query
+
+@app.get("/notes")
+def inspect_filters(
+    limit: Annotated[int, Query(ge=1, le=100)] = 10,
+    pinned: bool | None = None,
+):
+    return {"limit": limit, "pinned": pinned}
+````
+
+#### 4. 用类型模型替换原始请求体
+
+定义新的 POST /notes。在 /docs 中仅发送 {"title":"first note"},观察缺省字段被补齐。本节仍是参数练习,不写入存储。model_dump() 把 Pydantic 模型转换为 Python 字典。
+
+文件:main.py
+
+````python
+@app.post("/notes")
+def inspect_note_body(note: NoteCreate):
+    return note.model_dump()
+````
+
+#### 5. 建立有效值和无效值对照
+
+在 /docs 连续完成以下四次调用,记录状态码与响应中的字段位置。缺少 title 的 POST 应在路由运行前失败。下一课会继续收紧 title 的长度与多余字段规则。
+
+文件:终端 / 当前课时
+
+````http
+GET /notes/12
+# 200: {"note_id":12}
+
+GET /notes?limit=5&pinned=false
+# 200: {"limit":5,"pinned":false}
+
+GET /notes?limit=0
+# 422,错误位置包含 ["query","limit"]
+
+POST /notes
+Content-Type: application/json
+
+{}
+# 422,错误位置包含 ["body","title"]
+````
+
+### 验收结果
+
+- GET /notes/12 返回数字 12,/notes/abc 返回 422。
+- GET /notes 返回 {"limit":10,"pinned":null};?pinned=false 返回 false。
+- POST /notes 只传 title 时补齐 content 和 pinned;缺少 title 返回 422。
+
+### 常见错误
+
+#### 把 note_id 放进查询字符串,得到列表而不是单条笔记
+
+原因:GET /notes?note_id=12 匹配的是 GET /notes。本课列表路由没有声明 note_id,FastAPI 默认忽略这个额外查询参数,因此返回 200 和列表;它不会匹配 /notes/{note_id}。
+
+处理:读取单条笔记时改为 GET /notes/12。先检查方法和路径,再检查查询参数;只有确实没有匹配路由或资源不存在时才会得到相应的 404。
+
+#### 写了 title: str | None 后,省略 title 仍然报错
+
+原因:允许 null 和允许省略是两个条件。
+
+处理:如果业务允许省略,显式写默认值 = None;本例 title 应保持必填字符串。
+
+#### 修改 POST /notes 后响应还像上一课
+
+原因:旧路由没有移除,重复注册了相同方法和路径。
+
+处理:搜索 @app.post('/notes'),只保留当前一个定义,保存并确认服务完成重载。
+
+### 扩展练习
+
+为 GET /notes 添加 offset 查询参数,默认 0,只允许非负整数,并把解析结果加进响应。验证 offset=3 与 offset=-1。
+
+提示:参考 limit 的 Annotated[int, Query(...)],使用 ge=0。
+
+参考答案:
+
+在函数参数中加入 offset: Annotated[int, Query(ge=0)] = 0,并在返回字典中加入 'offset': offset。?offset=3 返回 200 且 offset 为 3;?offset=-1 返回 422,loc 包含 query、offset。
+
+### 技术文档
+
+- [FastAPI:路径参数](https://fastapi.tiangolo.com/tutorial/path-params/)
+- [FastAPI:查询参数](https://fastapi.tiangolo.com/tutorial/query-params/)
+- [FastAPI:请求体](https://fastapi.tiangolo.com/tutorial/body/)
+
+---
+
+## 04 用 Pydantic 定义输入和输出契约
+
+限制标题长度、拒绝拼错的字段,并通过 response_model 控制客户端能看到的数据。
+
+### 本节目标
+
+- 用 Field 声明范围,用 extra='forbid' 发现字段拼写错误
+- 分开定义创建输入与接口输出模型
+- 区分请求校验失败与响应校验失败
+
+### 必要概念
+
+#### 模型把接口规则放到一起
+
+类型描述数据形状,Field 描述长度或数值约束。本例 title 的长度为 1 到 120。extra='forbid' 会拒绝模型中未声明的字段,能及时发现 pinnd 这样的拼写错误;Pydantic 默认会忽略多余字段,因此需要显式配置。
+
+#### 输入模型与输出模型承担不同职责
+
+创建时客户端只提交 title、content、pinned;id 由服务端产生。NoteOut 在输入字段之外增加 id。response_model 会校验并筛选普通返回数据,未声明的内部字段不会进入响应;这也会出现在 /docs 的响应结构中。
+
+#### 校验成功不代表全部业务规则成立
+
+长度为 1 的字符串可能只是一个空格,类型与长度规则不会自动决定标题是否有意义。输入校验失败通常返回 422;如果路由返回的数据不符合输出模型,例如漏掉 id,则属于服务端代码错误,默认返回 500,并在终端记录具体原因。
+
+### 动手实现
+
+#### 1. 收紧 NoteCreate 的输入规则
+
+用下面定义替换原来的 NoteCreate,放在路由之前。保留其余路由。随后在 /docs 中分别发送空标题和拼错的 pinnd 字段,确认都得到 422。
+
+文件:main.py
+
+````python
+from pydantic import BaseModel, ConfigDict, Field
+
+class NoteCreate(BaseModel):
+    model_config = ConfigDict(extra="forbid")
+
+    title: str = Field(min_length=1, max_length=120)
+    content: str = ""
+    pinned: bool = False
+````
+
+#### 2. 增加响应模型
+
+在 NoteCreate 后添加 NoteOut。输出字段单独声明,避免把输入的 extra='forbid' 规则带入本节的输出过滤实验。id 在输出里必填;客户端创建时不能自行指定 id。
+
+文件:main.py
+
+````python
+class NoteOut(BaseModel):
+    id: int
+    title: str
+    content: str
+    pinned: bool
+````
+
+#### 3. 用 response_model 约束 POST 响应
+
+替换 POST /notes 的装饰器和函数。本节的 id=1 仍是演示值,下一阶段才生成真实编号。返回字典故意增加 internal_source,查看 HTTP 响应时该字段应被过滤。
+
+文件:main.py
+
+````python
+@app.post("/notes", response_model=NoteOut)
+def inspect_note_body(note: NoteCreate):
+    return {
+        "id": 1,
+        **note.model_dump(),
+        "internal_source": "validation-lesson",
+    }
+````
+
+#### 4. 分别制造输入与输出错误
+
+先发送下面的空标题请求,确认 422 的错误位置包含 body、title。再临时删除返回字典中的 id,发送正常请求,观察 500 和终端中的响应校验异常。恢复 id 后再继续;不要把服务端输出错误改成客户端的 422。
+
+文件:终端 / 当前课时
+
+````json
+{
+  "title": "",
+  "content": "title must not be empty",
+  "pinned": false
+}
+````
+
+#### 5. 检查文档中的契约
+
+刷新 /docs。展开 POST /notes 的 Schema,检查 title 的 minLength、maxLength,以及 NoteOut 的 id。发送 {"title":"schema check"} 后应返回下面四个字段。保存一份响应作为后续 CRUD 的验收样本。
+
+文件:终端 / 当前课时
+
+````json
+{
+  "id": 1,
+  "title": "schema check",
+  "content": "",
+  "pinned": false
+}
+````
+
+### 验收结果
+
+- 空 title 或额外 pinnd 字段均返回 422。
+- 正常创建演示返回 id、title、content、pinned 四个字段,不包含 internal_source。
+- 故意漏掉输出 id 时出现 500;恢复后为 200,当前尚未设置创建成功的 201。
+
+### 常见错误
+
+#### 空格标题通过了 min_length=1
+
+原因:空格也是字符串字符,长度校验不执行去空格。
+
+处理:根据业务要求加入 strip_whitespace 或字段校验器,去空格后再检查是否为空。
+
+#### response_model 没有过滤某个自定义响应
+
+原因:直接返回 JSONResponse 等 Response 对象时,框架不会按普通返回值继续执行响应模型转换。
+
+处理:本节返回字典或模型对象,让 FastAPI 使用 response_model 处理。
+
+#### 把模型直接拼到字典时提示类型不匹配
+
+原因:Pydantic 模型对象不等于普通字典。
+
+处理:需要字典时调用 note.model_dump();不要使用旧教材中的 .dict() 作为本模块标准写法。
+
+### 扩展练习
+
+拒绝只有空白的标题,并将 '  first note  ' 保存为 'first note'。保持 120 字符上限。
+
+提示:为 title 添加 mode='before' 的 field_validator,先去除字符串两端空白,再交给 Field 长度校验。
+
+参考答案:
+
+从 pydantic 导入 field_validator,在 NoteCreate 内添加:
+@field_validator('title', mode='before')
+@classmethod
+def strip_title(cls, value):
+    return value.strip() if isinstance(value, str) else value
+
+只含空格会变为空字符串并返回 422;'  first note  ' 经解析后为 'first note'。
+
+### 技术文档
+
+- [Pydantic:Field 与字段约束](https://docs.pydantic.dev/latest/concepts/fields/)
+- [Pydantic:验证器](https://docs.pydantic.dev/latest/concepts/validators/)
+- [FastAPI:响应模型](https://fastapi.tiangolo.com/tutorial/response-model/)
+
+---
+
+## 05 设计笔记资源与 HTTP 行为
+
+确定集合、单条资源、全量替换和删除的接口约定,再用请求验证方法与状态码。
+
+### 本节目标
+
+- 用资源路径表达笔记集合和单条笔记
+- 区分 POST、PUT、PATCH、DELETE 的语义
+- 解释 200、201、204、404、405、422 与幂等性
+
+### 必要概念
+
+#### 先确定资源,再确定操作
+
+本项目使用 /notes 表示笔记集合,/notes/{note_id} 表示一条笔记。读取用 GET,创建用 POST,完整替换用 PUT,删除用 DELETE。方法和路径共同确定接口,因此 GET /notes 与 POST /notes 可以共用路径。
+
+#### PUT 的完整替换需要明确输入规则
+
+本课程的 PUT 要求 title、content、pinned 全部出现,替换指定笔记可编辑字段,id 保持不变。PATCH 通常表达局部更新,本模块暂不实现。若把创建模型直接用于 PUT,省略 content 会触发默认空字符串,容易被误认为保留旧值。
+
+#### 幂等描述重复请求后的目标状态
+
+重复相同 PUT,目标笔记应保持相同内容;重复 DELETE,目标笔记都处于不存在状态,第一次可以返回 204,第二次可以返回 404。幂等不要求每次响应完全相同。POST 创建通常每次产生新资源,不能假设自动重复发送不会多建一条。
+
+#### 状态码也是接口契约
+
+读取与更新成功使用 200,创建成功使用 201,删除成功且无响应体使用 204。找不到资源使用 404,路径存在但方法不支持使用 405,输入校验失败使用 422。4xx、5xx 响应不能只靠 JSON 业务字段表达,HTTP 状态也必须正确。
+
+### 动手实现
+
+#### 1. 写下接口清单
+
+以下是第 06 课要实现的目标契约。当前服务还处于参数练习阶段,不应把回显结果视为已保存。先在纸上或请求文件中列出方法、路径、输入和成功状态,后续逐项实现。
+
+文件:notes-contract.http
+
+````http
+GET    /notes              # 查询集合,200,响应为数组
+POST   /notes              # 创建,201,响应包含新 id
+GET    /notes/{note_id}    # 查询单条,200 或 404
+PUT    /notes/{note_id}    # 完整替换,200 或 404
+DELETE /notes/{note_id}    # 删除,204 或 404
+````
+
+#### 2. 用已有服务区分 404 与 405
+
+在 /docs 执行 GET /health,应返回 200。然后命令行发送 POST /health,应返回 405,并检查 Allow 响应头;再 GET /missing-path,应返回 404。Windows 将下面 curl 改为 curl.exe。这两类错误都与 JSON 字段校验无关。
+
+文件:终端 / 当前课时
+
+````bash
+curl -i http://127.0.0.1:8000/health
+curl -i -X POST http://127.0.0.1:8000/health
+curl -i http://127.0.0.1:8000/missing-path
+````
+
+#### 3. 为完整替换定义独立输入
+
+在 main.py 的模型区添加 NoteReplace。三个字段均无默认值,确保调用方明确提交完整内容。在 Python 终端或单独脚本中使用下面示例验证;此时只定义模型,尚未注册 PUT 路由。
+
+文件:main.py
+
+````python
+class NoteReplace(BaseModel):
+    model_config = ConfigDict(extra="forbid")
+
+    title: str = Field(min_length=1, max_length=120)
+    content: str
+    pinned: bool
+
+# 在独立 Python 终端中测试:
+# from main import NoteReplace
+# NoteReplace(title="updated", content="", pinned=False)  # 通过
+# NoteReplace(title="updated")  # ValidationError:缺少两项
+````
+
+#### 4. 准备替换与删除的验收请求
+
+把完整请求体保存为 replace.json。下一课先创建一条笔记,记录真实 id,再执行两次同样 PUT 和两次 DELETE。不要写死假定 id 永远为 1;服务重载会影响当前内存。204 响应后不要继续解析 JSON。
+
+文件:replace.json
+
+````json
+{
+  "title": "updated note",
+  "content": "replace all editable fields",
+  "pinned": true
+}
+````
+
+### 验收结果
+
+- GET /health 是 200;POST /health 是 405;不存在的路径是 404。
+- NoteReplace 缺少 content 或 pinned 时验证失败,空 content 和 false 本身都合法。
+- 能解释 DELETE 第二次返回 404 仍然满足幂等,以及 204 为什么不能带 {"ok":true}。
+
+### 常见错误
+
+#### PUT 请求只传 title,意外把 content 清空
+
+原因:复用了带默认值的创建模型,未明确全量替换规则。
+
+处理:使用所有可编辑字段均必填的 NoteReplace;如果需要局部更新,应另行设计 PATCH。
+
+#### 客户端对成功 DELETE 调用 response.json() 后报错
+
+原因:204 响应没有响应体。
+
+处理:先判断状态码;遇到 204 直接按成功处理,不执行 JSON 解码。
+
+#### 网络超时后重复 POST,出现两条笔记
+
+原因:客户端没收到响应,并不等于服务器没创建资源。
+
+处理:本地实验先查询列表核对;生产创建接口需根据业务设计幂等键等重复提交控制,不能直接假定 POST 可安全重试。
+
+### 扩展练习
+
+评审三个接口:GET /delete-note?id=1、PUT /notes/1 只接收 pinned、DELETE /notes/1 返回 200 和 {"deleted":true}。给出本课程约定下的修改方案,并说明第三种设计是否违反 HTTP。
+
+提示:分清 HTTP 协议允许的行为与课程选择的统一接口约定。
+
+参考答案:
+
+第一种改为 DELETE /notes/1,GET 不用于用户请求的数据删除。第二种若只做局部修改应定义 PATCH,或者要求 PUT 传齐 title/content/pinned。第三种返回 200 和结果体在 HTTP 中可以成立;本课程统一选择 204 空体,因此需同时调整状态码和响应内容。
+
+### 技术文档
+
+- [RFC 9110:请求方法](https://www.rfc-editor.org/rfc/rfc9110.html#section-9)
+- [RFC 9110:204 No Content](https://www.rfc-editor.org/rfc/rfc9110.html#section-15.3.5)
+- [FastAPI:更新请求体](https://fastapi.tiangolo.com/tutorial/body-updates/)
+
+---
+
+## 06 完成内存版笔记 CRUD
+
+把接口契约变成可运行程序,按创建、查询、替换、删除的顺序完成一次完整操作。
+
+### 本节目标
+
+- 用字典保存笔记并生成不重复的进程内编号
+- 实现带校验与响应模型的笔记 REST 接口
+- 按真实创建结果串联后续请求并验证失败路径
+
+### 必要概念
+
+#### 请求之间需要共享状态
+
+局部变量在函数调用结束后不用于保存下一次请求的数据。这里用模块级 notes 字典,键为整数 id,值为笔记字段。它只存在于当前 Python 进程内;重启、重载都会清空,多个 worker 也不会共享这份字典。
+
+#### 编号与列表位置不是同一个概念
+
+使用单独递增的 next_id 分配编号,删除笔记后不回退。不能用 len(notes)+1 生成 id,否则删掉中间一条后可能复用仍存在的编号。客户端应从创建响应读取 id,再构造单条资源路径。
+
+#### 演示存储的运行条件
+
+本节路由使用 async def,内部没有 await,字典读写在一次事件循环执行段中完成。以单个 Uvicorn worker 运行本地实验;不要在更新共享状态的中间加入等待操作。异步函数与等待的细节将在后面的并发课程中单独演示。
+
+### 动手实现
+
+#### 1. 重建 main.py 的公共部分
+
+先备份前几课的实验文件,再将 main.py 替换为本步骤代码,并按顺序追加第 2、3、4 步。不要同时保留临时回显路由。全部代码完成后再启动;本节不含 /version 等附加练习接口。
+
+文件:main.py
+
+````python
+from typing import Annotated
+
+from fastapi import FastAPI, HTTPException, Query, Response
+from pydantic import BaseModel, ConfigDict, Field
+
+app = FastAPI(title="API Lab")
+
+class NoteCreate(BaseModel):
+    model_config = ConfigDict(extra="forbid")
+    title: str = Field(min_length=1, max_length=120)
+    content: str = ""
+    pinned: bool = False
+
+class NoteReplace(BaseModel):
+    model_config = ConfigDict(extra="forbid")
+    title: str = Field(min_length=1, max_length=120)
+    content: str
+    pinned: bool
+
+class NoteOut(BaseModel):
+    id: int
+    title: str
+    content: str
+    pinned: bool
+
+notes: dict[int, dict] = {}
+next_id = 1
+
+@app.get("/health", tags=["system"])
+async def health():
+    return {"status": "ok"}
+````
+
+#### 2. 实现列表与创建
+
+追加两个集合路由。pinned is not None 保证 false 也会触发筛选。创建成功后返回 201,并通过 Location 响应头给出新资源路径。next_id 在当前进程递增,保存时写入所有输入字段。
+
+文件:main.py
+
+````python
+@app.get("/notes", response_model=list[NoteOut], tags=["notes"])
+async def list_notes(
+    limit: Annotated[int, Query(ge=1, le=100)] = 10,
+    pinned: bool | None = None,
+):
+    rows = list(notes.values())
+    if pinned is not None:
+        rows = [row for row in rows if row["pinned"] == pinned]
+    return rows[:limit]
+
+@app.post("/notes", response_model=NoteOut, status_code=201, tags=["notes"])
+async def create_note(note: NoteCreate, response: Response):
+    global next_id
+    note_id = next_id
+    next_id += 1
+    row = {"id": note_id, **note.model_dump()}
+    notes[note_id] = row
+    response.headers["Location"] = f"/notes/{note_id}"
+    return row
+````
+
+#### 3. 实现单条读取与全量替换
+
+先定义统一查找函数。不存在时 raise HTTPException 会立即中断本次请求,下一课再统一其错误格式。PUT 创建全新的字段字典并保留路径 id,不把旧 content 或 pinned 合并进去。
+
+文件:main.py
+
+````python
+def require_note(note_id: int) -> dict:
+    if note_id not in notes:
+        raise HTTPException(status_code=404, detail="笔记不存在")
+    return notes[note_id]
+
+@app.get("/notes/{note_id}", response_model=NoteOut, tags=["notes"])
+async def get_note(note_id: int):
+    return require_note(note_id)
+
+@app.put("/notes/{note_id}", response_model=NoteOut, tags=["notes"])
+async def replace_note(note_id: int, note: NoteReplace):
+    require_note(note_id)
+    row = {"id": note_id, **note.model_dump()}
+    notes[note_id] = row
+    return row
+````
+
+#### 4. 实现无响应体的删除
+
+删除前先检查是否存在;返回 Response(status_code=204) 明确表示空响应。全部代码写好后运行 python -m uvicorn main:app --reload,打开 /docs,确认一个健康检查加五个笔记操作,共六个路由方法。
+
+文件:main.py
+
+````python
+@app.delete("/notes/{note_id}", status_code=204, tags=["notes"])
+async def delete_note(note_id: int):
+    require_note(note_id)
+    del notes[note_id]
+    return Response(status_code=204)
+````
+
+#### 5. 按顺序完成一轮 CRUD
+
+使用 /docs:先 GET /notes 确認 [];POST 下列对象并记下实际 id;GET /notes/{id};PUT 提交 title='updated'、content=''、pinned=true;再 GET 确认内容已替换;DELETE 后再次 GET,确认 404。操作过程中不要修改代码触发重载。最后重新创建一条,检查编号没有因删除而回退。
+
+文件:终端 / 当前课时
+
+````json
+{
+  "title": "first CRUD",
+  "content": "create, read, replace, delete",
+  "pinned": false
+}
+````
+
+### 验收结果
+
+- 空列表返回 200 和 [];创建返回 201,响应含整数 id,Location 指向 /notes/{id}。
+- PUT 传齐字段时返回 200,id 保持不变;仅传 title 时返回 422。
+- 删除返回 204 且响应体为空;再 GET 或 DELETE 相同 id 均返回 404。
+- 创建两条并删除其中一条后,再创建不会覆盖另一条;服务重启后列表重新为空。
+
+### 常见错误
+
+#### 保存代码后刚创建的笔记找不到了
+
+原因:--reload 重启了服务进程,内存字典被重新初始化。
+
+处理:完成代码编辑后重新准备数据;验证一轮 CRUD 时不要中途保存服务端文件。
+
+#### 筛选 pinned=false 返回全部数据
+
+原因:使用 if pinned 判断是否筛选,False 被当成未提供条件。
+
+处理:使用 if pinned is not None,再比较字段的布尔值。
+
+#### 删除一条后,新笔记覆盖了旧笔记
+
+原因:用 len(notes)+1 计算编号导致重复。
+
+处理:独立维护递增 next_id,不因删除回退;本地练习保持单进程运行。
+
+### 扩展练习
+
+在列表接口加入 offset>=0,先按 pinned 筛选,再返回 rows[offset:offset+limit]。创建三条笔记,第二条和第三条设为 pinned=true,验证 pinned=true&offset=1&limit=1 只返回第三条。
+
+提示:分页应该作用在筛选后的结果上;把切片放在筛选后,limit 和 offset 均添加参数约束。
+
+参考答案:
+
+添加 offset: Annotated[int, Query(ge=0)] = 0;保持 pinned 筛选顺序;把 return rows[:limit] 改为 return rows[offset:offset + limit]。按题目创建顺序,返回数组长度为 1,元素为第三条笔记;offset=-1 返回 422。
+
+### 技术文档
+
+- [FastAPI:响应状态码](https://fastapi.tiangolo.com/tutorial/response-status-code/)
+- [FastAPI:直接返回 Response](https://fastapi.tiangolo.com/advanced/response-directly/)
+- [FastAPI:处理错误](https://fastapi.tiangolo.com/tutorial/handling-errors/)
+
+---
+
+## 07 返回可定位、格式一致的错误
+
+用 HTTPException 表达已知失败,统一 404、405 与 422 的响应结构,并保留字段位置。
+
+### 本节目标
+
+- 在业务失败时抛出 HTTPException
+- 为 HTTPException 和 RequestValidationError 注册处理器
+- 保持 HTTP 状态码,同时返回稳定的错误结构
+
+### 必要概念
+
+#### 已知失败应在对应位置结束请求
+
+查不到笔记是可以预期的业务结果,用 raise HTTPException(status_code=404, detail='笔记不存在') 结束当前处理。return HTTPException(...) 只是在返回一个对象,不能表达相同控制流程。未知编程错误需要修复,不能一律包装成 200。
+
+#### 统一外层结构,保留不同错误的细节
+
+本项目约定 {error:{code,message,details}}。HTTP 错误使用 code='http_error';请求校验使用 code='validation_error'。HTTP 状态码仍保留 404、405、422 等差异。details 中只返回定位所需的字段路径、错误类型和说明,不回显整个请求体。
+
+#### 校验失败可能发生在路由执行之前
+
+参数解析失败会抛出 RequestValidationError,不会进入路由里的 try/except。因此在应用级处理器统一处理。注册 Starlette 的 HTTPException 基类还能覆盖路由不存在和方法不支持等框架错误;处理时保留 Allow 等原有响应头。
+
+### 动手实现
+
+#### 1. 确认已有业务错误入口
+
+保留第 06 课代码,找到 require_note。确认它使用 raise 而非 return。请求一个不存在的 id,此时默认响应通常为 {"detail":"笔记不存在"},接下来统一其外层结构。
+
+文件:main.py
+
+````python
+def require_note(note_id: int) -> dict:
+    if note_id not in notes:
+        raise HTTPException(status_code=404, detail="笔记不存在")
+    return notes[note_id]
+````
+
+#### 2. 统一 HTTP 错误处理
+
+在 main.py 中添加导入与处理器。这里演示业务 detail 为字符串;若后续传入字典,应单独约定字段映射,不把任意内部对象直接返回。保留 exc.headers,确保例如 405 的 Allow 头仍然存在。
+
+文件:main.py
+
+````python
+from fastapi import Request
+from fastapi.responses import JSONResponse
+from starlette.exceptions import HTTPException as StarletteHTTPException
+
+@app.exception_handler(StarletteHTTPException)
+async def handle_http_error(request: Request, exc: StarletteHTTPException):
+    return JSONResponse(
+        status_code=exc.status_code,
+        headers=exc.headers,
+        content={
+            "error": {
+                "code": "http_error",
+                "message": str(exc.detail),
+                "details": [],
+            }
+        },
+    )
+````
+
+#### 3. 统一 422 并保留字段位置
+
+添加请求校验处理器。不要直接把 exc.errors() 中的所有对象交给 JSON 序列化;自定义校验器的 ctx 可能包含异常对象。这里只提取 JSON 可序列化的 loc、type、msg,也避免在响应中回显原始输入。
+
+文件:main.py
+
+````python
+from fastapi.exceptions import RequestValidationError
+
+@app.exception_handler(RequestValidationError)
+async def handle_validation_error(request: Request, exc: RequestValidationError):
+    details = [
+        {
+            "loc": list(error["loc"]),
+            "type": error["type"],
+            "msg": error["msg"],
+        }
+        for error in exc.errors()
+    ]
+    return JSONResponse(
+        status_code=422,
+        content={
+            "error": {
+                "code": "validation_error",
+                "message": "请求参数不符合接口要求",
+                "details": details,
+            }
+        },
+    )
+````
+
+#### 4. 用三类失败验证处理器
+
+先 GET /notes/999999,确认 404 且 error.code='http_error'。再 GET /notes/abc 和 POST /notes 的空对象,确认 422;前者定位 path.note_id,后者定位 body.title。最后 POST /health,检查 405、http_error 以及 Allow 头。具体英文 msg 可能随依赖版本变化,客户端不要解析这段文本作业务判断。
+
+文件:终端 / 当前课时
+
+````json
+{
+  "error": {
+    "code": "validation_error",
+    "message": "请求参数不符合接口要求",
+    "details": [
+      {
+        "loc": ["body", "title"],
+        "type": "missing",
+        "msg": "Field required"
+      }
+    ]
+  }
+}
+````
+
+### 验收结果
+
+- 不存在的笔记返回 404、http_error 和“笔记不存在”。
+- 非法 note_id 与缺失 title 都返回 422、validation_error,details.loc 能指出不同位置。
+- POST /health 仍返回 405,Allow 响应头被保留。
+- 成功的笔记响应不被包在 error 字段中;204 删除仍然没有响应体。
+
+### 常见错误
+
+#### 错误响应有 message,但 HTTP 状态一直是 200
+
+原因:只返回了错误字典,没有设置 JSONResponse 的 status_code。
+
+处理:在异常处理器中显式设置对应状态,HTTP 错误使用 exc.status_code。
+
+#### 注册 HTTPException 处理器后,未知路径仍是默认格式
+
+原因:只处理 FastAPI 的异常子类,没有覆盖框架抛出的 Starlette 基类异常。
+
+处理:导入并为 StarletteHTTPException 注册处理器,路由仍可抛出 FastAPI 的 HTTPException。
+
+#### 自定义标题校验失败后出现 JSON 序列化异常
+
+原因:把 exc.errors() 的 ctx 异常对象直接放进 JSONResponse。
+
+处理:只提取 loc、type、msg 等必要字段,不直接返回任意异常对象或原始请求内容。
+
+### 扩展练习
+
+对 GET /notes?limit=0、GET /notes/abc、POST /notes 的 {} 逐一记录 status、error.code 和第一条 details.loc,写出客户端应如何定位表单或请求参数错误。
+
+提示:三次都是 422,但错误位置的来源不同:query、path、body。
+
+参考答案:
+
+三次 status 都是 422,code 都是 validation_error。loc 分别为 ['query','limit']、['path','note_id']、['body','title']。客户端先按 status/code 判断错误类别,再用 loc 映射对应输入位置;msg 用于显示,不作为稳定的判断条件。
+
+### 技术文档
+
+- [FastAPI:覆盖异常处理器](https://fastapi.tiangolo.com/tutorial/handling-errors/)
+- [Starlette:Exceptions](https://github.com/Kludex/starlette/blob/main/docs/exceptions.md)
+- [Pydantic:错误处理](https://docs.pydantic.dev/latest/errors/errors/)
+
+---
+
+## 08 复用依赖与拆分路由文件
+
+用 X-API-Key 练习依赖执行顺序,把 /private 放入独立 APIRouter,并通过环境变量配置课堂密钥。
+
+### 本节目标
+
+- 用 Depends 在路由执行前完成可复用校验
+- 从请求头读取 X-API-Key,从环境变量读取预期值
+- 用 APIRouter 拆分文件并注册到同一个应用
+
+### 必要概念
+
+#### Depends 声明请求处理的前置步骤
+
+FastAPI 收到请求后先解析依赖需要的参数,执行依赖函数,再进入路由。依赖抛出 HTTPException 时,路由不会执行。同一套校验可以挂在多个接口或整个 APIRouter 上,避免每个路由重复读取请求头。
+
+#### Header 与查询参数的来源不同
+
+x_api_key: str 这种普通参数默认会作为查询参数。使用 Header(alias='X-API-Key') 才从请求头读取。这里给缺失值默认 None,再由依赖统一返回课堂约定的 401;缺少请求头不会先变成默认必填参数的 422。401 响应携带 WWW-Authenticate: APIKey,说明本实验使用的凭据类型。
+
+#### 配置值与身份系统分开理解
+
+DEMO_API_KEY 从启动终端的环境变量读取,网页教程中的 classroom-key 仅用于本地实验。这个共享字符串只演示请求校验,不能区分用户,也没有密钥轮换或权限管理。本节只保护 /private,公开的笔记接口用于本地练习。
+
+#### APIRouter 负责组织,不会自动启动服务
+
+独立文件创建 router 并注册其路由,主入口再 app.include_router(router)。依赖模块不导入 main,避免循环导入。多个文件最终仍组成同一个 FastAPI 应用,共享第 07 课注册的异常处理器。
+
+### 动手实现
+
+#### 1. 建立依赖函数文件
+
+在 api-lab 根目录新增 dependencies.py,不修改前面的笔记接口。用 secrets.compare_digest 比较字符串。未配置环境变量返回 503,缺失或错误请求头返回 401,这两种失败帮助区分服务配置和调用参数。
+
+文件:dependencies.py
+
+````python
+import os
+import secrets
+from typing import Annotated
+
+from fastapi import Header, HTTPException
+
+def require_demo_key(
+    x_api_key: Annotated[str | None, Header(alias="X-API-Key")] = None,
+) -> None:
+    expected = os.getenv("DEMO_API_KEY")
+    if not expected:
+        raise HTTPException(status_code=503, detail="服务未配置 DEMO_API_KEY")
+    if x_api_key is None or not secrets.compare_digest(
+        x_api_key.encode("utf-8"), expected.encode("utf-8")
+    ):
+        raise HTTPException(
+            status_code=401,
+            detail="X-API-Key 无效",
+            headers={"WWW-Authenticate": "APIKey"},
+        )
+````
+
+#### 2. 创建独立的私有路由
+
+新增 routers 目录,其中创建空的 __init__.py 和下面的 private.py。把依赖挂在 APIRouter 上,当前文件中的接口都会执行相同校验。装饰器使用 @router.get,路径保持 /private。
+
+文件:routers/private.py
+
+````python
+from fastapi import APIRouter, Depends
+
+from dependencies import require_demo_key
+
+router = APIRouter(
+    tags=["private"],
+    dependencies=[Depends(require_demo_key)],
+)
+
+@router.get("/private")
+def read_private():
+    return {"message": "API key accepted"}
+````
+
+#### 3. 在主入口注册 router
+
+在 main.py 导入路由模块,并在 app 创建之后调用 include_router。保留原有 /health、笔记路由和两个异常处理器。刷新 /docs,确认出现 private 分组及 X-API-Key 参数;如果没有出现,先检查是否真正注册。
+
+文件:main.py
+
+````python
+from routers import private
+
+# 放在 app = FastAPI(...) 之后
+app.include_router(private.router)
+
+# 目录结构
+# api-lab/
+#   main.py
+#   dependencies.py
+#   routers/
+#     __init__.py
+#     private.py
+````
+
+#### 4. 设置当前终端环境并重新启动
+
+先 Ctrl+C 停止旧服务,在同一个终端按系统选择一组命令。新启动的进程继承该终端环境;在另一窗口设置变量不会修改正在运行的服务。classroom-key 是公开教学校验值。
+
+文件:终端 / 当前课时
+
+````bash
+# Windows PowerShell
+$env:DEMO_API_KEY = "classroom-key"
+python -m uvicorn main:app --reload
+
+# macOS / Linux
+export DEMO_API_KEY="classroom-key"
+python -m uvicorn main:app --reload
+````
+
+#### 5. 验证缺失、错误与正确密钥
+
+在 /docs 连续执行 GET /private:不填写头部、填写 wrong-key、填写 classroom-key。前两次返回 401,第三次返回 200。第 07 课错误处理器会把失败转换为统一 error 对象。再访问 /notes,确认该本地练习接口未要求密钥。
+
+文件:终端 / 当前课时
+
+````bash
+# macOS / Linux;Windows PowerShell 使用 curl.exe
+curl -i http://127.0.0.1:8000/private
+curl -i http://127.0.0.1:8000/private -H "X-API-Key: wrong-key"
+curl -i http://127.0.0.1:8000/private -H "X-API-Key: classroom-key"
+````
+
+### 验收结果
+
+- /docs 显示 /private 和 X-API-Key 请求头输入项。
+- 配置环境变量后,缺失或错误密钥返回 401 和 http_error;正确密钥返回 200 和 {"message":"API key accepted"}。
+- 未设置 DEMO_API_KEY 的新服务进程返回 503,而不会因为请求头与配置都为空而通过。
+- 拆分文件后 /health、原有笔记 CRUD 与错误结构仍然可用。
+
+### 常见错误
+
+#### 已在客户端写 X-API-Key,却显示密钥无效
+
+原因:服务读取的头部来源声明不正确,或者客户端把值放到了查询字符串。
+
+处理:依赖明确使用 Header(alias='X-API-Key');在 Network 或 curl 输出中核对 Request Headers。
+
+#### 环境变量已设置,接口仍返回 503
+
+原因:变量设置在另一个终端,或者旧服务进程没有重启。
+
+处理:在设置变量的同一个终端停止并重启 Uvicorn。
+
+#### 新增 private.py 后 /private 返回 404
+
+原因:只创建了 router,没有在主 app 上 include_router,或启动时目录不正确。
+
+处理:确认 main.py 注册 private.router,并从 api-lab 根目录启动 main:app。
+
+### 扩展练习
+
+在 routers/private.py 中新增 GET /private/status,返回 {"enabled":true}。复用 router 上的依赖,分别验证无头部与正确头部。再说明为什么这仍不能判断是哪位学生发出的请求。
+
+提示:只添加 @router.get('/private/status') 的函数,不必复制校验代码。
+
+参考答案:
+
+添加:
+@router.get('/private/status')
+def private_status():
+    return {'enabled': True}
+
+无 X-API-Key 返回 401;classroom-key 返回 200 和 {'enabled':true}。两个接口自动复用 router 依赖。所有人使用同一公开密钥,服务只能判断是否匹配,无法识别具体用户。
+
+### 技术文档
+
+- [FastAPI:依赖注入](https://fastapi.tiangolo.com/tutorial/dependencies/)
+- [FastAPI:Header 参数](https://fastapi.tiangolo.com/tutorial/header-params/)
+- [FastAPI:多文件应用](https://fastapi.tiangolo.com/tutorial/bigger-applications/)
+- [Python:compare_digest](https://docs.python.org/3/library/secrets.html#secrets.compare_digest)
+
+---
+
+## 09 使用 async / await 处理 I/O 等待
+
+用两个并发请求复现阻塞,掌握 async / await 在接口中的实际作用。
+
+### 本节目标
+
+- 写出不阻塞事件循环的 /slow 接口
+- 测量串行与并发请求的耗时差异
+- 判断同步 I/O 应改用异步客户端,还是移交线程
+
+### 必要概念
+
+#### async def 定义协程函数
+
+调用后得到可等待对象;await 等待结果,并在操作尚未完成时让出执行机会。仅给函数添加 async 不会把内部的阻塞代码变为异步。
+
+#### 等待与计算分开判断
+
+网络响应、磁盘访问属于 I/O;大量 Python 计算属于 CPU 工作。异步可以交错等待,本课不通过 async 加速计算,也不在接口里引入后台任务队列。
+
+#### FastAPI 的执行位置
+
+FastAPI 调用普通 def 路由时使用线程池;async def 路由在事件循环中执行。你在 async def 内直接调用普通辅助函数时,FastAPI 不会自动把辅助函数移到线程。
+
+### 动手实现
+
+#### 1. 增加一个可测量的等待接口
+
+开始前,在已激活的虚拟环境中运行 python -m pip install "httpx>=0.27,<1",第 09 节的客户端脚本会导入它。下载包的 requirements.txt 已包含此依赖。
+
+在 api-lab 的应用中加入路由。delay 限制在 0–5 秒,避免误输很大数值;运行单个服务进程进行本课实验。
+
+文件:main.py
+
+````python
+import asyncio
+from fastapi import Query
+
+@app.get("/slow")
+async def slow(delay: float = Query(default=1, ge=0, le=5)):
+    await asyncio.sleep(delay)
+    return {"delay": delay}
+````
+
+#### 2. 同时发出两次请求
+
+新建脚本,保持服务已启动后执行 python concurrent_check.py。先建立一个客户端,再让两次请求同时等待;打印状态码和总耗时。页面演示是浏览器模拟,真实耗时以此脚本为准。
+
+文件:concurrent_check.py
+
+````python
+import asyncio
+from time import perf_counter
+import httpx
+
+async def main():
+    async with httpx.AsyncClient(base_url="http://127.0.0.1:8000") as client:
+        started = perf_counter()
+        results = await asyncio.gather(
+            client.get("/slow", params={"delay": 1}),
+            client.get("/slow", params={"delay": 1}),
+        )
+        print([r.status_code for r in results])
+        print(f"elapsed={perf_counter() - started:.2f}s")
+
+asyncio.run(main())
+````
+
+#### 3. 复现阻塞,再恢复
+
+暂时把 await asyncio.sleep(delay) 换成 time.sleep(delay),并 import time。保存后等服务完成重载,再运行相同脚本。单进程时两次等待通常接近 2 秒;恢复 async sleep 后通常接近 1 秒。另开终端访问 /health,观察阻塞时它也要等待。结果会受机器和调度开销影响,不以毫秒误差判断对错。
+
+文件:终端 / 当前课时
+
+````bash
+curl.exe "http://127.0.0.1:8000/health"
+````
+
+#### 4. 为无法替换的同步 I/O 选择线程
+
+优先使用库提供的异步方法;若必须在 async def 路由中调用同步 I/O,用 asyncio.to_thread。此处 slow_sync 用 sleep 模拟同步 I/O,实际替换为该库的函数;线程不是本课 CPU 计算加速方案。
+
+文件:终端 / 当前课时
+
+````python
+import time
+
+def slow_sync(delay: float):
+    time.sleep(delay)
+    return {"delay": delay}
+
+# 放在 async def 路由内:
+# result = await asyncio.to_thread(slow_sync, delay)
+# return result
+````
+
+### 验收结果
+
+- 异步版本两个 delay=1 请求均返回 200,总耗时通常接近 1 秒。
+- 阻塞版本在单进程下耗时明显增加,恢复后 /health 不再等待 slow 的 sleep 结束。
+- /slow?delay=-1 与 /slow?delay=6 都返回 422。
+
+### 常见错误
+
+#### 写了 async def,其他请求仍卡住
+
+原因:路由内使用 time.sleep、同步 HTTP 客户端或长时间计算,占住事件循环。
+
+处理:定位耗时调用;I/O 改用 await 支持的 API,必要时把同步 I/O 放到线程。
+
+#### 两次 await 请求仍耗时约 2 秒
+
+原因:先 await 第一次,再创建第二次,代码仍是串行等待。
+
+处理:用 asyncio.gather 同时调度两个请求,再比较结果。
+
+#### 出现 coroutine was never awaited
+
+原因:调用异步函数但没有 await,也没有交给任务调度。
+
+处理:在 async 函数内 await 结果;脚本最外层用 asyncio.run(main())。
+
+### 扩展练习
+
+把脚本改成同时请求 3 个 delay=1 的 /slow,再增加一次 /health,记录总耗时及各状态码。
+
+提示:把第三个 /slow 和 /health 一并放进 gather;不要依次 await。
+
+参考答案:
+
+四个请求应全部返回 200。使用非阻塞等待时,总耗时通常仍接近最长的一次等待,即约 1 秒;若接近 3 秒,先检查服务是否恢复了 asyncio.sleep。
+
+### 技术文档
+
+- [FastAPI:并发与 async / await](https://fastapi.tiangolo.com/async/)
+- [Python:协程、gather 与 to_thread](https://docs.python.org/3/library/asyncio-task.html)
+
+---
+
+## 10 使用 HTTPX 调用服务与调试 CORS
+
+复用 HTTPX 客户端,处理超时与上游错误,并定位浏览器跨域问题。
+
+### 本节目标
+
+- 在应用生命周期中创建和关闭 AsyncClient
+- 区分超时、连接失败和非成功状态码
+- 说明为什么 curl 正常而浏览器请求失败
+
+### 必要概念
+
+#### 一次请求有两个方向
+
+浏览器到 FastAPI 是入站请求;FastAPI 到另一个服务是出站请求。调用失败时,先确定错误发生在哪一段。
+
+#### 客户端与连接池
+
+AsyncClient 可以复用连接。应用启动时创建,退出时关闭;不要在每次高频请求内重新创建客户端。connect、read、write、pool 是不同阶段的超时,read 通常是等待下一块数据的上限,不是请求总时限。
+
+#### CORS 由浏览器检查
+
+Origin 由协议、主机和端口组成。http://localhost:5500 与 http://127.0.0.1:5500 是两个来源;curl 和服务端 HTTPX 不执行浏览器的 CORS 检查。CORS 也不替代 API 认证。
+
+### 动手实现
+
+#### 1. 把客户端交给 lifespan 管理
+
+安装 httpx。把现有 FastAPI() 初始化改为使用 lifespan;若应用已有 lifespan,把下面的客户端管理合并进去,不要再创建第二个 app。上游使用本机已完成的 /slow,让超时实验不依赖公网。
+
+文件:main.py
+
+````python
+from contextlib import asynccontextmanager
+import httpx
+from fastapi import FastAPI
+
+@asynccontextmanager
+async def lifespan(app: FastAPI):
+    timeout = httpx.Timeout(3.0, connect=1.0)
+    async with httpx.AsyncClient(
+        base_url="http://127.0.0.1:8000", timeout=timeout
+    ) as client:
+        app.state.http = client
+        yield
+
+app = FastAPI(lifespan=lifespan)
+````
+
+#### 2. 把出站失败转换为可判断的响应
+
+增加 /upstream-demo。默认 delay=1 会成功,delay=4 超过 read 超时。这里调用本机接口只是本地实验;正式业务中使用服务方的固定地址,避免让用户任意指定出站 URL。raise_for_status 会把非 2xx 响应转为 HTTPStatusError。
+
+文件:main.py
+
+````python
+from fastapi import HTTPException, Query, Request
+
+@app.get("/upstream-demo")
+async def upstream_demo(
+    request: Request,
+    delay: float = Query(default=1, ge=0, le=5),
+):
+    try:
+        response = await request.app.state.http.get(
+            "/slow", params={"delay": delay}
+        )
+        response.raise_for_status()
+        return {"upstream": response.json()}
+    except httpx.TimeoutException as exc:
+        raise HTTPException(504, "上游响应超时") from exc
+    except httpx.HTTPStatusError as exc:
+        raise HTTPException(502, "上游返回非成功状态") from exc
+    except httpx.RequestError as exc:
+        raise HTTPException(502, "无法连接上游服务") from exc
+````
+
+#### 3. 在两个页面来源之间复现 CORS
+
+API 保持运行在 8000 端口。把下面中间件加入现有 app;已有 CORSMiddleware 时修改原配置,不要重复添加。本实验只允许 http://127.0.0.1:5500。如果使用下载的参考项目,先暂时从 allow_origins 中移除 http://localhost:5500,才能复现后面来源不匹配的情况。
+
+文件:main.py
+
+````python
+from fastapi.middleware.cors import CORSMiddleware
+
+app.add_middleware(
+    CORSMiddleware,
+    allow_origins=["http://127.0.0.1:5500"],
+    allow_credentials=False,
+    allow_methods=["GET", "POST", "PUT", "DELETE"],
+    allow_headers=["Content-Type", "X-API-Key"],
+)
+````
+
+#### 4. 建立前端页面并从 5500 访问 API
+
+在 api-lab 中新建 cors-demo.html,粘贴下面内容。另开一个终端,在 api-lab 目录执行 python -m http.server 5500,保留 API 的 8000 终端。打开 http://127.0.0.1:5500/cors-demo.html 并点击按钮,在 Network 中观察 OPTIONS 预检和 POST;JSON 的 Content-Type 会触发本次跨域预检。再通过 http://localhost:5500/cors-demo.html 打开同一文件,对照来源不匹配时的浏览器错误。此时简单的 GET /health 不一定产生 OPTIONS。
+
+文件:cors-demo.html
+
+````html
+<!doctype html>
+<html lang="zh-CN">
+<meta charset="utf-8">
+<title>CORS 实验</title>
+<button id="send">向 API 创建笔记</button>
+<pre id="result"></pre>
+<script>
+document.querySelector("#send").onclick = async () => {
+  const output = document.querySelector("#result");
+  try {
+    const response = await fetch("http://127.0.0.1:8000/notes", {
+      method: "POST",
+      headers: {"Content-Type": "application/json"},
+      body: JSON.stringify({title: "CORS 实验"}),
+    });
+    output.textContent = response.status + "\n" + await response.text();
+  } catch (error) {
+    output.textContent = "浏览器未能读取响应;检查 Network 和 Console:" + error.message;
+  }
+};
+</script>
+</html>
+````
+
+#### 5. 读预检请求和响应头
+
+在浏览器 Network 面板查看 Origin、OPTIONS 和 Access-Control-Allow-Origin。curl.exe 可手动查看预检响应,但它不会替浏览器决定是否允许页面读取响应。把来源改为 localhost:5500 后复现不匹配,再恢复准确来源。 macOS / Linux 把 curl.exe 改为 curl。实验完成后,如需同时使用两个前端域名,再把 localhost:5500 加回白名单。
+
+文件:终端 / 当前课时
+
+````bash
+curl.exe -i -X OPTIONS "http://127.0.0.1:8000/notes" -H "Origin: http://127.0.0.1:5500" -H "Access-Control-Request-Method: POST" -H "Access-Control-Request-Headers: content-type"
+curl.exe -i "http://127.0.0.1:8000/upstream-demo?delay=4"
+````
+
+### 验收结果
+
+- /upstream-demo?delay=1 返回 200,包含上游的 delay 字段。
+- /upstream-demo?delay=4 返回 504;把上游地址临时改为未监听端口可观察 502,随后恢复。
+- 允许来源的预检响应包含 Access-Control-Allow-Origin: http://127.0.0.1:5500,浏览器可以读取响应。
+
+### 常见错误
+
+#### 上游返回 404,接口却继续执行正常处理
+
+原因:HTTP 请求收到响应不等于业务成功,代码没有检查状态。
+
+处理:调用 raise_for_status 并单独处理 HTTPStatusError。
+
+#### curl 成功,网页报 CORS 错误
+
+原因:浏览器页面来源与 allow_origins 不匹配,或预检方法、请求头未被允许。
+
+处理:逐项核对 Origin、方法和自定义请求头,再看浏览器 Network 中的 OPTIONS 响应。
+
+#### 用 timeout=3 就以为整个请求最多 3 秒
+
+原因:把阶段性超时误认为总请求截止时间,流式响应每次及时发送数据时可能持续更久。
+
+处理:先明确要限制连接、块间等待还是总耗时;本课实验只验证读超时。
+
+### 扩展练习
+
+让 /upstream-demo 在等待超过 0.2 秒时返回 504,同时保持直接访问 /slow?delay=1 能正常返回。
+
+提示:只覆盖 client.get 的 timeout 参数,保留异步 /slow。
+
+参考答案:
+
+给该次请求设置 timeout=httpx.Timeout(0.2, connect=1.0)。请求 /upstream-demo?delay=1 会在等待上游数据时触发超时;直接请求 /slow?delay=1 仍约 1 秒后返回 200。
+
+### 技术文档
+
+- [HTTPX:异步客户端](https://www.python-httpx.org/async/)
+- [HTTPX:超时阶段](https://www.python-httpx.org/advanced/timeouts/)
+- [HTTPX:状态码与异常](https://www.python-httpx.org/quickstart/)
+- [FastAPI:lifespan](https://fastapi.tiangolo.com/advanced/events/)
+- [FastAPI:CORS](https://fastapi.tiangolo.com/tutorial/cors/)
+
+---
+
+## 11 完整响应、流式响应、SSE 与 WebSocket
+
+从通信方向与消息格式选择接口形态,用同一份文本比较接收过程。
+
+### 本节目标
+
+- 分清 REST 风格、HTTP 响应方式和 WebSocket 协议
+- 根据发送方向与数据频率选择实现
+- 观察首段到达时间与全部完成时间
+
+### 必要概念
+
+#### REST 不规定必须一次返回全部数据
+
+REST 主要讨论资源及 HTTP 操作方式。普通 JSON 响应先得到完整结果再返回;HTTP 流式响应逐段发送同一个响应体。流式发送不自动定义每段的业务含义。
+
+#### SSE 为文本流定义事件边界
+
+SSE 在 HTTP 响应体中使用 text/event-stream,以空行分隔事件。连接内数据由服务端推向客户端;浏览器可用 EventSource 解析。SSE 是 HTTP 流式响应的一种约定。
+
+#### WebSocket 保持双向通信
+
+连接建立后,双方可以独立发送消息,不必每条消息新建 HTTP 请求。业务仍需定义消息类型、结束、错误与连接关闭后的行为。
+
+### 动手实现
+
+#### 1. 先跑一次完整结果接口
+
+用延时代表逐步产生内容,POST /generate 等待全部内容完成后返回一个 JSON 对象。PromptIn 限制长度,本课使用固定文本拼接,便于观察协议而不引入模型服务。
+
+文件:main.py
+
+````python
+import asyncio
+from pydantic import BaseModel, Field
+
+class PromptIn(BaseModel):
+    prompt: str = Field(min_length=1, max_length=200)
+
+@app.post("/generate")
+async def generate(payload: PromptIn):
+    parts = []
+    for text in ["收到:", payload.prompt, "。处理完成。"]:
+        await asyncio.sleep(0.4)
+        parts.append(text)
+    return {"text": "".join(parts)}
+````
+
+#### 2. 记录完整响应的等待
+
+在 /docs 对 POST /generate 输入下面 JSON。结果约在所有步骤完成后出现。区分响应头到达与可用业务内容到达;观察何时收到第一段内容。
+
+文件:终端 / 当前课时
+
+````json
+{"prompt":"hello"}
+````
+
+#### 3. 对照三条连接过程
+
+在本页协议演示中依次选择完整 HTTP、SSE、WebSocket:完整响应在完成后显示文本;SSE 逐条显示 token 事件;WebSocket 可在连接保持期间发送新消息。此交互是浏览器模拟;第 12–15 课会用 api-lab 进行真实收发验证。
+
+#### 4. 把场景写成协议选择题
+
+保存笔记通常使用 HTTP 的 POST /notes;一段处理结果要边产生边显示,可使用 HTTP 流式响应,文本事件采用 SSE;前后端要频繁互发消息,可使用 WebSocket。若只需偶尔查询状态,也可以定期 HTTP 查询,不需要先维护长连接。
+
+### 验收结果
+
+- 能说清 SSE 使用 HTTP,且不是 WebSocket 的别名。
+- /generate 返回单个 {text:...} 对象;不会在每个步骤结束时返回一个新 HTTP 响应。
+- 能为保存笔记、逐步显示文本、双向实时消息分别选择方案并说明原因。
+
+### 常见错误
+
+#### 为了逐字显示文字,直接把所有接口都改成 WebSocket
+
+原因:把流式接收和双向消息通信混为一谈。
+
+处理:先确定客户端是否需要在同一条连接上持续主动发送;单向文本结果通常先考虑 HTTP 流式响应。
+
+#### 把服务器 yield 的次数当成客户端 read 的次数
+
+原因:网络、服务器和浏览器可能合并或拆开数据块。
+
+处理:依赖协议的事件分隔符解析消息,不依赖网络块边界。
+
+### 扩展练习
+
+用户提交一次短文本后只接收生成结果,偶尔点击“停止”。是否必须使用 WebSocket?
+
+提示:思考取消 HTTP 请求是否已经满足停止读取的需要。
+
+参考答案:
+
+不必须。可以提交 HTTP 请求并读取流式响应,通过取消请求停止读取;服务端同时处理断开并释放资源。如果还需要在同一会话持续双向发送独立消息,再评估 WebSocket。
+
+### 技术文档
+
+- [FastAPI:StreamingResponse](https://fastapi.tiangolo.com/advanced/custom-response/)
+- [MDN:SSE 事件流](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events)
+- [FastAPI:WebSocket](https://fastapi.tiangolo.com/advanced/websockets/)
+
+---
+
+## 12 用 StreamingResponse 输出 SSE 事件
+
+实现有限长度文本流,定义 token / done 事件,并处理客户端断开。
+
+### 本节目标
+
+- 用异步生成器逐段发送 text/event-stream
+- 使用 JSON 编码 data 字段并保留空行边界
+- 区分正常完成、连接断开和流内错误
+
+### 必要概念
+
+#### yield 交出一段响应体
+
+异步生成器可在产生下一段数据前 await。StreamingResponse 消费生成器并发送响应体;生成器必须有明确的正常结束条件。
+
+#### 消息格式属于双方约定
+
+本课每个 token 事件的 data 是 {text:...},最后发送 done 事件且 data 为 {}。JSON 编码处理引号和换行,空行负责分隔事件。不能直接把任意用户输入拼接成 SSE 行。
+
+#### 响应开始后不能改写 HTTP 状态
+
+参数验证应在开始流式响应前完成。发送响应头后若处理中断,应通过约定的流内错误事件或连接结束通知客户端;不能再把已经开始的 200 改成 500。
+
+### 动手实现
+
+#### 1. 定义一处统一的事件编码
+
+把事件名限制为代码中定义的固定值;用 json.dumps 编码数据。返回字符串末尾是两个换行字符,即一个空行。ensure_ascii=False 保留中文可读性。
+
+文件:main.py
+
+````python
+import json
+
+def encode_sse(event: str, data: dict) -> str:
+    payload = json.dumps(data, ensure_ascii=False)
+    return f"event: {event}\ndata: {payload}\n\n"
+````
+
+#### 2. 产生有限长度 token 流
+
+每隔约 0.4 秒发送一个 token,完成后发 done。使用 finally 观察连接关闭后的清理路径;真实上游迭代器、文件等资源应在这里或上下文管理器内关闭。不要吞掉取消异常。
+
+文件:main.py
+
+````python
+import asyncio
+from fastapi import Request
+
+async def token_events(request: Request, prompt: str):
+    try:
+        for text in ["收到:", prompt, "。处理完成。"]:
+            await asyncio.sleep(0.4)
+            if await request.is_disconnected():
+                return
+            yield encode_sse("token", {"text": text})
+        yield encode_sse("done", {})
+    finally:
+        print("stream closed")
+````
+
+#### 3. 在 /stream 返回 StreamingResponse
+
+GET 查询参数适合 EventSource 演示;不要在 URL 中传密钥或敏感正文。prompt 先经过长度验证,再进入生成器。已有相同路由时替换定义,避免重复注册。
+
+文件:main.py
+
+````python
+from fastapi import Query
+from fastapi.responses import StreamingResponse
+
+@app.get("/stream")
+async def stream(
+    request: Request,
+    prompt: str = Query(default="hello", min_length=1, max_length=200),
+):
+    return StreamingResponse(
+        token_events(request, prompt),
+        media_type="text/event-stream",
+        headers={"Cache-Control": "no-cache"},
+    )
+````
+
+#### 4. 从终端逐段观察,再主动取消
+
+使用 curl.exe 的 -N 禁用它自己的输出缓冲。应先后看到三条 token 与一条 done,每条后有空行。第二次请求在生成中按 Ctrl+C,服务端最终应打印 stream closed;断开通常通过取消路径或断开检查被观察到。
+
+文件:终端 / 当前课时
+
+````bash
+curl.exe -N -i "http://127.0.0.1:8000/stream?prompt=hello"
+````
+
+#### 5. 核对线上实际字节格式
+
+以下是事件体格式展示,其中空行是协议的一部分。data 内的 JSON 字符串若有换行,应由 JSON 编码为转义序列,不能破坏事件边界。
+
+文件:终端 / 当前课时
+
+````http
+event: token
+data: {"text": "hello"}
+
+event: done
+data: {}
+
+
+````
+
+### 验收结果
+
+- 响应 Content-Type 以 text/event-stream 开头;终端能在流结束前看到 token。
+- 正常结束只有一个 done,三个 token 的 text 拼接后与 /generate 的 text 相同。
+- prompt 为空返回 422;请求中途取消后清理日志出现,服务仍能处理 /health。
+
+### 常见错误
+
+#### 客户端一直等不到事件回调
+
+原因:发送了 data 行却漏了事件末尾的空行。
+
+处理:每条事件统一通过 encode_sse 编码,并确认末尾为 \n\n。
+
+#### 输入含换行后事件解析失败
+
+原因:手工拼接 JSON 或把原始输入直接放进 data 行。
+
+处理:先 json.dumps,再写入 SSE data 字段。
+
+#### 断开后仍持续执行,或者没有清理资源
+
+原因:无限循环未处理断开、没有 await 让取消生效,或遗漏 finally 清理。
+
+处理:在迭代中检查断开,使用可等待的 I/O,并在 finally 中释放资源;保持本课生成器有限长度。
+
+### 扩展练习
+
+在三个 token 后、done 前增加一个 stats 事件,返回 token_count:3;保持已有客户端仍能完成。
+
+提示:stats 是独立事件类型,不需要把它伪装成 token。
+
+参考答案:
+
+在 done 前增加 yield encode_sse("stats", {"token_count": 3})。原客户端只监听 token 和 done 时会忽略 stats;新增监听器后可以显示统计值。
+
+### 技术文档
+
+- [FastAPI:自定义与流式响应](https://fastapi.tiangolo.com/advanced/custom-response/)
+- [MDN:SSE 消息格式](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events)
+- [FastAPI:Request 类与断开检查](https://fastapi.tiangolo.com/reference/request/)
+
+---
+
+## 13 在浏览器中读取流,并在中途停止
+
+分别使用 EventSource 与 fetch,处理 UTF-8 分块、事件边界和取消请求。
+
+### 本节目标
+
+- 用 EventSource 监听命名事件并主动关闭
+- 用 ReadableStream 解析可能被拆开的 SSE 数据
+- 通过 AbortController 停止请求并区分取消与错误
+
+### 必要概念
+
+#### EventSource 简单,但请求形态有限
+
+原生 EventSource 发起 GET 请求,自动解析 SSE 并具备重连行为;构造参数不能任意设置请求体和自定义请求头。收到本课有限流的 done 后应主动 close,防止结束后重新请求。
+
+#### fetch 能发送 POST,再读取响应流
+
+POST 请求不妨碍响应逐段返回。需要 JSON 请求体或自定义头时可用 fetch,但必须自己解析响应体。调用 response.json() 或 response.text() 会等到响应体读完。
+
+#### 网络块不等于事件
+
+一个中文字符的 UTF-8 字节、一个 JSON 或空行都可能跨越两次 read;一次 read 也可能包含多条事件。先连续解码字节,再缓存文本并按事件边界提取。
+
+### 动手实现
+
+#### 1. 先用 EventSource 接收命名事件
+
+打开本地 http://127.0.0.1:8000/docs,在开发者工具 Console 运行。URL 与页面同源,先排除 CORS 干扰。控制台应逐条输出文本,done 后主动关闭;本课把网络失败也关闭,避免自动重连重复执行。
+
+文件:浏览器 Console
+
+````javascript
+{
+  const source = new EventSource("/stream?prompt=hello");
+  source.addEventListener("token", (event) => {
+    console.log(JSON.parse(event.data).text);
+  });
+  source.addEventListener("done", () => {
+    console.log("完成");
+    source.close();
+  });
+  source.onerror = () => {
+    console.error("流连接失败,已停止");
+    source.close();
+  };
+  window.stopSse = () => source.close();
+}
+````
+
+#### 2. 把事件解析与网络读取分开
+
+先在 Console 定义解析函数。它为本课 LF / CRLF 事件格式处理跨块与多条 data 行,且要求正常收到 done;不实现完整 EventSource 的 id、retry 与自动重连功能。TextDecoder 的 stream:true 会暂存未完整的 UTF-8 字节,最后再刷新。
+
+文件:浏览器 Console
+
+````javascript
+async function consumeSse(response, onEvent) {
+  if (!response.ok) throw new Error(`HTTP ${response.status}`);
+  if (!response.headers.get("content-type")?.includes("text/event-stream")) {
+    throw new Error("响应不是 SSE");
+  }
+  if (!response.body) throw new Error("没有响应体");
+  const reader = response.body.getReader();
+  const decoder = new TextDecoder("utf-8");
+  let buffer = "";
+  let completed = false;
+  try {
+    while (!completed) {
+      const { value, done } = await reader.read();
+      buffer += done ? decoder.decode() : decoder.decode(value, { stream: true });
+      let boundary;
+      while ((boundary = /\r?\n\r?\n/.exec(buffer)) !== null) {
+        const block = buffer.slice(0, boundary.index);
+        buffer = buffer.slice(boundary.index + boundary[0].length);
+        const lines = block.split(/\r?\n/);
+        const name = lines.find(line => line.startsWith("event:"))
+          ?.slice(6).trim() || "message";
+        const data = lines.filter(line => line.startsWith("data:"))
+          .map(line => line.slice(5).replace(/^ /, "")).join("\n");
+        if (data) onEvent(name, JSON.parse(data));
+        if (name === "done") { completed = true; break; }
+      }
+      if (done && !completed) throw new Error("连接结束,但没有收到 done");
+    }
+    await reader.cancel();
+  } catch (error) {
+    await reader.cancel().catch(() => {});
+    throw error;
+  } finally {
+    reader.releaseLock();
+  }
+}
+````
+
+#### 3. 用 fetch 读取同一个 GET 接口
+
+在同一 Console 定义 startStream,然后执行 startStream()。紧接着执行 stopStream() 可中途取消;AbortError 代表用户主动取消,不应显示为服务故障。新的请求使用新的 AbortController。
+
+文件:浏览器 Console
+
+````javascript
+async function startStream() {
+  const controller = new AbortController();
+  window.stopStream = () => controller.abort();
+  try {
+    const response = await fetch("/stream?prompt=hello", {
+      signal: controller.signal,
+    });
+    await consumeSse(response, (name, data) => {
+      if (name === "token") console.log(data.text);
+      if (name === "done") console.log("完成");
+    });
+  } catch (error) {
+    if (error.name === "AbortError") console.log("已取消");
+    else console.error(error);
+  }
+}
+startStream();
+````
+
+#### 4. 扩展:需要 POST 请求体时
+
+先在 main.py 额外加入以下 POST /stream 路由,复用第 11 课 PromptIn 与第 12 课 token_events。然后把上一步 fetch 的 URL 改为 /stream,并在配置对象中加入 method:"POST"、headers:{"Content-Type":"application/json"}、body:JSON.stringify({prompt:"hello"});保留 signal。POST /generate 仍返回完整 JSON,不可拿它套用 SSE 解析函数。
+
+文件:main.py
+
+````python
+@app.post("/stream")
+async def stream_post(request: Request, payload: PromptIn):
+    return StreamingResponse(
+        token_events(request, payload.prompt),
+        media_type="text/event-stream",
+        headers={"Cache-Control": "no-cache"},
+    )
+````
+
+#### 5. 核对流为什么没有逐段显示
+
+先用 curl.exe -N 直连本地 API。若本地逐段到达但页面一次出现,检查是否调用了 response.text/json,或渲染是否缓存了结果。若只有经过中间服务才一次出现,再检查中间服务的响应缓冲;本课只定位差异,具体代理配置留到部署模块。
+
+### 验收结果
+
+- EventSource 收到三次 token 和一次 done 后关闭,不会周期性重新发起 /stream。
+- fetch 版本能正常显示中文;主动 stopStream 后输出“已取消”。
+- 扩展 POST /stream 返回 SSE;POST /generate 仍返回 application/json。
+
+### 常见错误
+
+#### 偶发 JSON.parse 错误,或中文出现替换字符
+
+原因:直接对每次 read 的内容解码和解析,忽略了字符、事件可能跨块。
+
+处理:保持同一 TextDecoder 并设置 stream:true,再缓存到完整空行边界后解析。
+
+#### 一次成功结束后又出现同样文本
+
+原因:有限 SSE 结束后 EventSource 自动重连,客户端没有处理 done。
+
+处理:监听 done 调用 close;真实恢复能力需要单独定义,不默认重复执行。
+
+#### 点击停止后仍把状态改成失败
+
+原因:把所有 fetch 异常都当成网络故障。
+
+处理:单独识别 AbortError,并同步把页面状态改为已取消。
+
+### 扩展练习
+
+把一条包含“你好”的 SSE 消息拆成逐个字节的 ReadableStream,验证解析函数仍得到完整中文和 done。
+
+提示:用 TextEncoder 生成字节,再通过 Response 包装本地 ReadableStream;这只是解析器实验,不是 API 网络测试。
+
+参考答案:
+
+构造文本 'event: token\ndata: {"text":"你好"}\n\nevent: done\ndata: {}\n\n';对 TextEncoder().encode(text) 的每个字节调用 controller.enqueue(new Uint8Array([byte])),最后 close。用 new Response(stream,{headers:{"Content-Type":"text/event-stream"}}) 传入 consumeSse,回调应只收到完整 token 和 done。
+
+### 技术文档
+
+- [MDN:EventSource](https://developer.mozilla.org/en-US/docs/Web/API/EventSource)
+- [MDN:读取 ReadableStream](https://developer.mozilla.org/en-US/docs/Web/API/Streams_API/Using_readable_streams)
+- [MDN:TextDecoder.decode](https://developer.mozilla.org/en-US/docs/Web/API/TextDecoder/decode)
+- [MDN:AbortController](https://developer.mozilla.org/en-US/docs/Web/API/AbortController)
+
+---
+
+## 14 建立第一条 WebSocket 双向连接
+
+实现 /ws 回显,观察建立、发送、接收和关闭的完整过程。
+
+### 本节目标
+
+- 使用 accept、receive_text 和 send_json 完成回显
+- 在浏览器连接打开后发送消息
+- 捕获断开并释放连接相关资源
+
+### 必要概念
+
+#### 连接先建立,消息再发送
+
+服务端 accept 后进入收发阶段;浏览器 WebSocket 从 CONNECTING 转为 OPEN 后才能发送。send 只表示数据进入发送流程,不证明对方已经处理。
+
+#### 一条连接,多条消息
+
+本课客户端发送文本,服务端回复 JSON。普通文本对应 type:echo;文本 ping 对应 type:pong。双方必须约定数据格式,不能把 HTTP 路由的返回值当作 WebSocket 消息发送。
+
+#### 断开是正常生命周期
+
+用户关页、主动关闭和网络中断都可能结束连接。receive_text 在断开时会抛出 WebSocketDisconnect;在 finally 中清理该连接持有的资源。
+
+### 动手实现
+
+#### 1. 在 main.py 插入 /ws 路由
+
+接受连接后循环读取消息。回复必须调用 send_json,而不是 return 一个字典。这个本地练习只接收文本消息,不实现用户身份或消息保存。
+
+文件:main.py
+
+````python
+from fastapi import WebSocket, WebSocketDisconnect
+
+@app.websocket("/ws")
+async def websocket_echo(websocket: WebSocket):
+    await websocket.accept()
+    try:
+        while True:
+            text = await websocket.receive_text()
+            if text == "ping":
+                await websocket.send_json({"type": "pong"})
+            else:
+                await websocket.send_json({"type": "echo", "text": text})
+    except WebSocketDisconnect:
+        pass
+    finally:
+        print("websocket closed")
+````
+
+#### 2. 从浏览器建立连接
+
+打开本地 http://127.0.0.1:8000/docs,在 Console 运行。代码把连接保留到 window.echoSocket,方便随后发送和关闭。open、message、close 的日志对应三个不同阶段。
+
+文件:浏览器 Console
+
+````javascript
+{
+  const socket = new WebSocket("ws://127.0.0.1:8000/ws");
+  window.echoSocket = socket;
+  socket.onopen = () => console.log("连接已打开");
+  socket.onmessage = event => console.log("收到", JSON.parse(event.data));
+  socket.onerror = () => console.error("连接发生错误");
+  socket.onclose = event => console.log("已关闭", event.code, event.reason);
+}
+````
+
+#### 3. 在 OPEN 状态发送两种消息
+
+等“连接已打开”出现后,执行下列代码。在 Network 面板选择 WS 请求并查看消息列表,区分客户端发出的原始文本与服务端返回的 JSON。WebSocket 请求不使用 /docs 的 HTTP Execute 按钮测试。
+
+文件:浏览器 Console
+
+````javascript
+if (echoSocket.readyState === WebSocket.OPEN) {
+  echoSocket.send("hello");
+  echoSocket.send("ping");
+}
+````
+
+#### 4. 主动关闭并确认生命周期结束
+
+调用 close 后,浏览器输出关闭事件,服务端输出清理日志。关闭后不能复用旧 socket 继续发送;需要新建 WebSocket。重新连接并再发送一次 hello,确认服务仍可用。
+
+文件:浏览器 Console
+
+````javascript
+echoSocket.close(1000, "lesson finished");
+````
+
+### 验收结果
+
+- 浏览器先收到 open,发送 hello 后收到 {type:'echo',text:'hello'}。
+- 发送 ping 后收到 {type:'pong'};同一连接可重复收发。
+- 主动关闭后收到 close,服务端清理日志出现,重新连接仍正常。
+
+### 常见错误
+
+#### 调用 send 报连接尚未建立
+
+原因:new WebSocket 返回时握手仍在进行。
+
+处理:在 open 回调中发送,或先检查 readyState === WebSocket.OPEN。
+
+#### 浏览器无任何回复,路由却 return 了字典
+
+原因:WebSocket 消息不会像 HTTP 响应一样自动序列化返回。
+
+处理:在连接内 await websocket.send_json(...)。
+
+#### 把 WebSocket 地址粘到普通地址栏后得到 404
+
+原因:使用 HTTP GET 测试了 WebSocket 端点,没有进行 WebSocket 握手。
+
+处理:通过浏览器 WebSocket API 或支持 WS 的客户端测试,使用 ws:// 地址。
+
+### 扩展练习
+
+增加一个文本命令 upper:hello,让服务端回复 {type:'upper',text:'HELLO'},其他消息保持回显。
+
+提示:把 startswith('upper:') 分支放在默认 echo 分支之前。
+
+参考答案:
+
+在 echo 的 else 之前加入 elif text.startswith("upper:"): await websocket.send_json({"type":"upper","text":text.removeprefix("upper:").upper()})。浏览器收到的 JSON type 应为 upper。
+
+### 技术文档
+
+- [FastAPI:WebSocket 收发与断开](https://fastapi.tiangolo.com/advanced/websockets/)
+- [MDN:编写 WebSocket 客户端](https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API/Writing_WebSocket_client_applications)
+
+---
+
+## 15 连接断开后:重连、心跳与取消
+
+给 WebSocket 加上有限重试和定时器清理,验证停止后不会自行恢复。
+
+### 本节目标
+
+- 在异常关闭后用指数退避重新连接
+- 通过应用层 ping / pong 检测连接是否仍可用
+- 清理重连和心跳定时器,不自动重发未确认消息
+
+### 必要概念
+
+#### 重连创建一条新连接
+
+新连接不会自动恢复旧连接内的执行状态,也不保证断开期间消息送达。本课不缓存、不重发业务消息;需要可靠恢复时,必须另行设计消息编号、确认和服务端去重。
+
+#### 退避限制重试频率
+
+连续失败时逐渐延长等待,并加少量随机抖动,避免同时快速请求。设置最大次数;用户主动停止后取消待执行重连。
+
+#### 心跳与页面状态
+
+这里的 ping 是普通文本消息,pong 是业务 JSON,属于应用层心跳。浏览器 WebSocket API 不提供主动发送协议级 Ping 帧的方法。计时器会受后台页面节流影响,超时阈值需要按场景调整。
+
+### 动手实现
+
+#### 1. 先定义连接状态与定时器清理
+
+在本地 /docs 的 Console 按步骤运行。先关闭上一课 echoSocket,避免同时观察两条连接。下面状态只属于本次练习;刷新页面可重新开始。
+
+文件:浏览器 Console
+
+````javascript
+let wsSocket = null;
+let retryTimer = null;
+let heartbeatTimer = null;
+let pongTimer = null;
+let wsStopped = true;
+let retryCount = 0;
+
+function clearHeartbeat() {
+  clearInterval(heartbeatTimer);
+  clearTimeout(pongTimer);
+  heartbeatTimer = null;
+  pongTimer = null;
+}
+````
+
+#### 2. 加入应用层心跳
+
+每 5 秒发送一次 ping,3 秒内没有 pong 就关闭连接,让同一条关闭处理路径决定是否重连。收到 pong 时取消对应超时。这里只允许一个等待中的心跳。
+
+文件:浏览器 Console
+
+````javascript
+function startHeartbeat(socket) {
+  clearHeartbeat();
+  heartbeatTimer = setInterval(() => {
+    if (socket.readyState !== WebSocket.OPEN) return;
+    socket.send("ping");
+    pongTimer = setTimeout(() => {
+      socket.close(4000, "heartbeat timeout");
+    }, 3000);
+  }, 5000);
+}
+````
+
+#### 3. 把重连集中在 close 事件中
+
+首次失败约 0.5 秒后重连,随后约 1、2、4、8 秒,另加少量随机抖动;最多连续重试 5 次。error 只记录,避免 error 和 close 各重试一次。收到服务端有效消息后清零连续失败计数。
+
+文件:浏览器 Console
+
+````javascript
+function connectWs() {
+  if (wsStopped) return;
+  const socket = new WebSocket("ws://127.0.0.1:8000/ws");
+  wsSocket = socket;
+  socket.onopen = () => {
+    if (wsStopped || wsSocket !== socket) { socket.close(); return; }
+    console.log("已连接");
+    startHeartbeat(socket);
+  };
+  socket.onmessage = event => {
+    if (wsSocket !== socket) return;
+    const message = JSON.parse(event.data);
+    retryCount = 0;
+    if (message.type === "pong") {
+      clearTimeout(pongTimer);
+      pongTimer = null;
+    } else {
+      console.log("收到", message);
+    }
+  };
+  socket.onerror = () => console.log("连接错误,等待 close");
+  socket.onclose = () => {
+    if (wsSocket !== socket) return;
+    clearHeartbeat();
+    if (wsStopped) return;
+    if (retryCount >= 5) {
+      wsStopped = true;
+      console.log("连续重试已达上限,请手动开始");
+      return;
+    }
+    const delay = Math.min(8000, 500 * 2 ** retryCount) + Math.random() * 200;
+    retryCount += 1;
+    console.log(`第 ${retryCount} 次重试,等待 ${Math.round(delay)}ms`);
+    retryTimer = setTimeout(connectWs, delay);
+  };
+}
+````
+
+#### 4. 实现开始、发送与停止
+
+开始函数防止重复创建连接;发送函数在未连接时明确拒绝;停止函数先记录主动停止,再清理所有计时器和连接。stopWs 同时可供页面关闭时调用。
+
+文件:浏览器 Console
+
+````javascript
+function startWs() {
+  if (!wsStopped) return;
+  wsStopped = false;
+  retryCount = 0;
+  connectWs();
+}
+function sendWs(text) {
+  if (wsSocket?.readyState !== WebSocket.OPEN) {
+    console.log("尚未连接,本条消息未发送");
+    return;
+  }
+  wsSocket.send(text);
+}
+function stopWs() {
+  wsStopped = true;
+  clearTimeout(retryTimer);
+  clearHeartbeat();
+  wsSocket?.close(1000, "user stopped");
+  wsSocket = null;
+  console.log("已停止");
+}
+window.addEventListener("pagehide", stopWs);
+startWs();
+````
+
+#### 5. 执行三组失败实验
+
+正常连接后用 sendWs('hello') 验证回显。接着停止本地服务,观察逐渐延长的重试间隔,重启服务后确认新连接可收发。最后在下一次重试前调用 stopWs(),等待超过 9 秒,确认没有新建 WS 请求。断线期间尝试发送的消息不能在重连后自动出现。
+
+### 验收结果
+
+- 服务停止时重连间隔逐步增加,连续失败达到 5 次重试后停止。
+- 服务恢复后能建立新连接并再次收到 echo,心跳 ping 对应 pong。
+- stopWs 后超过 9 秒没有新连接;未连接时调用 sendWs 不会排队重发。
+
+### 常见错误
+
+#### 每次断线都会产生两条新连接
+
+原因:在 error 和 close 中分别启动重试,或开始按钮允许重复创建。
+
+处理:只在 close 调度重试;开始前检查当前是否已经运行。
+
+#### 点停止后几秒钟又自动连上
+
+原因:只关闭 socket,没有取消已经安排的 setTimeout。
+
+处理:先设置主动停止标记,再清理重试、心跳和 pong 超时定时器。
+
+#### 重连后重复执行了一条操作
+
+原因:客户端自动重发消息,但无法确定服务端在断开前是否已经执行。
+
+处理:本课不自动重发;需要重试业务操作时另行加入确认、操作编号与幂等处理。
+
+### 扩展练习
+
+把连续重试上限改为 3 次,并验证用户手动 startWs() 后可以开始新一轮连接。
+
+提示:更改重试边界,保留达到上限时 wsStopped=true,以及开始时 retryCount=0。
+
+参考答案:
+
+把 if (retryCount >= 5) 改为 >= 3。连续失败后控制台打印到达上限;随后调用 startWs() 重置次数并重新连接,若服务已恢复即可继续回显。
+
+### 技术文档
+
+- [MDN:WebSocket 客户端生命周期](https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API/Writing_WebSocket_client_applications)
+- [MDN:WebSocket.close](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/close)
+- [RFC 6455:异常关闭后的恢复建议](https://www.rfc-editor.org/rfc/rfc6455#section-7.2.3)
+
+---
+
+## 16 把能运行的接口变成可重复验收的服务
+
+用 pytest 检查 CRUD、错误、认证、SSE 和 WebSocket,再完成一次真实浏览器联调。
+
+### 本节目标
+
+- 使用 TestClient 在测试中运行 FastAPI 应用
+- 为正常路径和 401 / 404 / 422 写出明确断言
+- 区分接口逻辑验收与真实网络流式验证
+
+### 必要概念
+
+#### 断言检查可观察结果
+
+测试应检查状态码、响应字段与操作后结果,而不是只确认请求没报异常。每个测试创建自己的数据,通过返回 id 继续操作,避免假设某条笔记固定为 id=1。
+
+#### TestClient 不等同于浏览器
+
+TestClient 基于 HTTPX,可在进程内调用应用,不必先启动 Uvicorn。用 with 管理客户端才会执行 lifespan;它不验证浏览器 CORS,也不能证明真实网络按预期逐段到达。
+
+#### 错误与正常结束分别验收
+
+422 验证输入约束;401 验证 /private 的 API Key;404 验证资源不存在。SSE 既检查事件数据,也检查 done;WebSocket 检查来回消息并关闭会话。
+
+### 动手实现
+
+#### 1. 建立测试客户端
+
+在 api-lab 新建 tests/test_acceptance.py;以下第 1–4 步代码依次放入同一文件。先运行 python -m pip install pytest httpx。monkeypatch 只在测试期间设置演示 Key,测试结束自动恢复;导入入口为根目录 main.py。
+
+文件:tests/test_acceptance.py
+
+````python
+import json
+from uuid import uuid4
+import pytest
+from fastapi.testclient import TestClient
+from main import app
+
+@pytest.fixture
+def client(monkeypatch):
+    monkeypatch.setenv("DEMO_API_KEY", "classroom-key")
+    with TestClient(app) as test_client:
+        yield test_client
+
+def test_health(client):
+    response = client.get("/health")
+    assert response.status_code == 200
+````
+
+#### 2. 验证创建、查询、更新与删除
+
+从 POST 返回值提取 id,再验证默认值、列表、PUT 完整替换和 DELETE 后 404。PUT 必须包含 title、content、pinned 三个可写字段。DELETE 204 响应体应为空,不能再对它调用 json()。
+
+文件:tests/test_acceptance.py
+
+````python
+def test_note_lifecycle(client):
+    title = f"acceptance-{uuid4()}"
+    created = client.post("/notes", json={"title": title})
+    assert created.status_code == 201
+    note = created.json()
+    note_id = note["id"]
+    assert note["content"] == ""
+    assert note["pinned"] is False
+    assert client.get(f"/notes/{note_id}").json() == note
+
+    listed = client.get("/notes", params={"q": title, "limit": 100})
+    assert listed.status_code == 200
+    assert any(item["id"] == note_id for item in listed.json())
+
+    replacement = {"title": "更新后", "content": "测试正文", "pinned": True}
+    updated = client.put(f"/notes/{note_id}", json=replacement)
+    assert updated.status_code == 200
+    assert updated.json() == {"id": note_id, **replacement}
+    assert client.get(f"/notes/{note_id}").json() == updated.json()
+
+    deleted = client.delete(f"/notes/{note_id}")
+    assert deleted.status_code == 204
+    assert deleted.content == b""
+    missing = client.get(f"/notes/{note_id}")
+    assert missing.status_code == 404
+    assert missing.json()["error"]["code"] == "http_error"
+````
+
+#### 3. 验证输入错误与受保护接口
+
+空标题应被输入校验拒绝。只有 /private 在本地样例中需要 X-API-Key;不要把笔记的公开练习路由与认证演示混在一起。错误体沿用前面课程的统一结构 error.code / message / details。
+
+文件:tests/test_acceptance.py
+
+````python
+def test_validation(client):
+    invalid = client.post("/notes", json={"title": ""})
+    assert invalid.status_code == 422
+    assert invalid.json()["error"]["code"] == "validation_error"
+    assert client.get("/slow", params={"delay": -1}).status_code == 422
+
+
+def test_api_key(client):
+    assert client.get("/private").status_code == 401
+    wrong = client.get("/private", headers={"X-API-Key": "wrong-key"})
+    assert wrong.status_code == 401
+    assert wrong.json()["error"]["code"] == "http_error"
+    valid = client.get("/private", headers={"X-API-Key": "classroom-key"})
+    assert valid.status_code == 200
+````
+
+#### 4. 验证 SSE 内容和 WebSocket 来回消息
+
+TestClient 读取有限 SSE 的完整响应后检查协议内容;该测试不用于断言首段到达时间。WebSocket 使用上下文管理器,离开时关闭连接。 如果完成了第 12 课的 stats 扩展,测试允许保留该事件,只对 token/done 的主线顺序和内容断言;done 仍必须是最后一个事件。
+
+文件:tests/test_acceptance.py
+
+````python
+def test_stream_matches_complete_result(client):
+    complete = client.post("/generate", json={"prompt": "hello"})
+    assert complete.status_code == 200
+    response = client.get("/stream", params={"prompt": "hello"})
+    assert response.status_code == 200
+    assert response.headers["content-type"].startswith("text/event-stream")
+    events = []
+    for block in response.text.replace("\r\n", "\n").strip().split("\n\n"):
+        fields = {}
+        for line in block.split("\n"):
+            name, value = line.split(":", 1)
+            fields[name] = value.lstrip(" ")
+        events.append((fields["event"], json.loads(fields["data"])))
+    assert [name for name, _ in events if name != "stats"] == ["token", "token", "token", "done"]
+    text = "".join(data["text"] for name, data in events if name == "token")
+    assert text == complete.json()["text"]
+    assert events[-1] == ("done", {})
+
+
+def test_websocket(client):
+    with client.websocket_connect("/ws") as socket:
+        socket.send_text("hello")
+        assert socket.receive_json() == {"type": "echo", "text": "hello"}
+        socket.send_text("ping")
+        assert socket.receive_json() == {"type": "pong"}
+````
+
+#### 5. 运行自动验收,再测真实网络
+
+在 api-lab 执行 python -m pytest -q tests/test_acceptance.py。通过后另开终端运行 python -m uvicorn main:app --reload,再运行下面脚本。SSE 的 data 行应分批出现,时间通常约为 0.4、0.8、1.2 秒,而非都等到最后。最后在浏览器完成 CORS、主动停止流、WebSocket 断开重连三个场景。
+
+文件:verify_stream_network.py
+
+````python
+from time import perf_counter
+import httpx
+
+with httpx.Client(base_url="http://127.0.0.1:8000", timeout=5.0) as client:
+    started = perf_counter()
+    with client.stream("GET", "/stream", params={"prompt": "hello"}) as response:
+        response.raise_for_status()
+        for line in response.iter_lines():
+            if line.startswith("data:"):
+                print(f"{perf_counter() - started:.2f}s {line}", flush=True)
+````
+
+### 验收结果
+
+- 自动测试覆盖健康检查、完整 CRUD、422 校验、401 认证、删除后 404、SSE 与 WebSocket,运行结果全部通过。
+- 真实 HTTPX / curl 客户端在响应结束前接收到 token,浏览器停止读取后服务端执行清理。
+- 允许来源的浏览器能够调用 API;未允许来源不能读取响应;WebSocket 主动停止后不再重连。
+
+### 常见错误
+
+#### 单个测试能过,整套测试偶尔失败
+
+原因:多个测试假定相同 id 或依赖前一个测试创建的数据。
+
+处理:每个测试自行创建记录,用返回 id 操作,并在生命周期测试中删除。
+
+#### 测试里 app.state.http 不存在
+
+原因:没有通过 with TestClient(app) 启动 lifespan,或代码未正确注册 lifespan。
+
+处理:在 fixture 的上下文中创建客户端,并核对 FastAPI(lifespan=lifespan)。
+
+#### SSE 测试通过,网页仍一次显示全部内容
+
+原因:进程内测试只检查完整内容,没有经过浏览器、真实网络和中间服务。
+
+处理:用真实 Uvicorn 配合 httpx.stream / curl -N 观察时间,再检查浏览器读取与渲染。
+
+### 扩展练习
+
+增加一个测试:创建笔记后,PUT 只发送 title 应返回 422,随后 GET 仍返回原来的完整内容。
+
+提示:输入不合法时不能发生部分更新;先保存创建响应,再比较读取结果。
+
+参考答案:
+
+创建笔记并保存 note;使用 client.put(f'/notes/{note["id"]}', json={"title":"修改"}),断言 422;再次 GET 并断言等于 note,最后 DELETE 清理。这个测试同时检查完整替换约定和校验失败不写入的行为。
+
+### 技术文档
+
+- [FastAPI:TestClient 与 pytest](https://fastapi.tiangolo.com/tutorial/testing/)
+- [FastAPI:测试 lifespan](https://fastapi.tiangolo.com/advanced/testing-events/)
+- [FastAPI:测试 WebSocket](https://fastapi.tiangolo.com/advanced/testing-websockets/)
+- [pytest:fixtures](https://docs.pytest.org/en/stable/how-to/fixtures.html)
+- [pytest:临时环境变量](https://docs.pytest.org/en/stable/how-to/monkeypatch.html)
+- [HTTPX:流式响应](https://www.python-httpx.org/quickstart/)

+ 1867 - 0
public/downloads/database-lessons.md

@@ -0,0 +1,1867 @@
+# 数据库开发
+
+术核 AI · 后端工程课件
+
+## 01 准备 PostgreSQL 与 psql 练习环境
+
+为 notes-service 建立独立练习库,区分数据库进程、数据库、角色与客户端。
+
+### 本节目标
+
+- 启动 PostgreSQL 16 并创建非超级用户
+- 用 psql 明确连接地址、端口、角色和数据库
+- 区分连接失败、认证失败和 SQL 错误
+
+### 必要概念
+
+#### 服务端与客户端
+
+PostgreSQL 进程负责保存数据和执行 SQL;psql 是向它发送命令的终端工具。关闭 psql 不会停止数据库。一个 PostgreSQL 实例可以包含多个数据库,连接时必须确定要进入哪个库。
+
+#### 角色与数据库分别创建
+
+角色表示谁在连接,数据库表示在哪里读写。练习的 app_user 是 notes_lab 所有者,但不是超级用户。第 11 课再拆分迁移角色与运行时角色,避免 API 进程拥有修改表结构的权限。
+
+#### 实验边界
+
+本模块所有命令都是学员在独立 notes_lab 中执行的练习,不是让你操作线上数据库。需要 Python 3.11+ 和已安装、启动的 Docker。此处只用容器获得一致的 PostgreSQL 16 环境;镜像、卷与容器网络在 Docker 模块系统学习。
+
+### 动手实现
+
+#### 1. 建立项目与本机密码文件
+
+以下为 Linux / macOS Bash 命令;Windows 可在 WSL 中执行。本节使用 127.0.0.1:5433,容器内部仍是 5432。密码只用于本机练习,不要提交到 Git。终端在 notes-service 中执行。
+
+文件:终端 / 当前课时
+
+````bash
+mkdir -p notes-service
+cd notes-service
+umask 077
+printf 'POSTGRES_PASSWORD=%s\n' "$(python3 -c 'import secrets; print(secrets.token_urlsafe(24))')" > .postgres.env
+printf '.postgres.env\n.env\nbackups/\n.venv/\n' >> .gitignore
+docker run --name notes-postgres --env-file .postgres.env \
+  -p 127.0.0.1:5433:5432 \
+  -v notes_pgdata:/var/lib/postgresql/data -d postgres:16
+docker exec notes-postgres pg_isready -U postgres
+````
+
+#### 2. 创建练习角色和数据库
+
+等待 pg_isready 显示 accepting connections。先执行 docker exec -it notes-postgres psql -U postgres -d postgres,然后在该 psql 中执行下列命令。\password 会交互要求输入两遍 app_user 密码,不在 SQL 历史中直接写出密码。下面建库命令只执行一次。
+
+文件:终端 / 当前课时
+
+````sql
+CREATE ROLE app_user LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE;
+\password app_user
+CREATE DATABASE notes_lab OWNER app_user;
+\q
+````
+
+#### 3. 用业务身份重新连接
+
+执行 docker exec -it notes-postgres psql -h 127.0.0.1 -U app_user -d notes_lab -W,输入刚设置的密码。该命令在容器内访问容器的 5432。若本机已安装 psql,则使用 psql -h 127.0.0.1 -p 5433 -U app_user -d notes_lab -W。后续未特别标明的 SQL 都在此连接运行。
+
+文件:终端 / 当前课时
+
+````sql
+\conninfo
+SELECT current_database(), current_user, version();
+SHOW server_encoding;
+SHOW TimeZone;
+\set VERBOSITY verbose
+\timing on
+````
+
+#### 4. 验证持久化与 SQL 命令边界
+
+运行两条 SELECT,观察分号用于结束 SQL,反斜杠命令由 psql 处理。退出客户端后重新连接,数据库仍存在。数据库卷保留了实例数据;不要为了重新做某节练习删除整个卷。
+
+文件:终端 / 当前课时
+
+````sql
+SELECT 6 * 7 AS answer;
+-- answer = 42
+SELECT current_database() = 'notes_lab' AS in_lab;
+-- in_lab = t
+\q
+````
+
+### 验收结果
+
+- current_database() 为 notes_lab,current_user 为 app_user。
+- version() 显示 PostgreSQL 16,server_encoding 为 UTF8。
+- 退出并重新连接后 SELECT 6 * 7 仍能执行;数据库进程没有因客户端退出而停止。
+
+### 生产约束与故障演练
+
+新服务需要一个可重复创建、不会误连线上实例的开发数据库。
+
+#### 实现决策
+
+- 连接串明确包含数据库名与端口。
+- 先用非超级用户练习,环境文件加入忽略规则。
+- 生产环境选择受维护的 16.x 补丁版并锁定镜像摘要;postgres:16 是本课程环境入口。
+
+#### 隔离环境演练
+
+在独立练习环境中用错误端口尝试连接,依据 pg_isready 与实际端口定位原因。
+
+#### 恢复验收
+
+- 能从连接信息确认当前实例和身份。
+- 数据库卷与容器用途可区分。
+- 仓库没有密码文件。
+
+### 常见错误
+
+#### connection refused
+
+原因:容器未启动、端口尚未就绪或混用了宿主机与容器端口。
+
+处理:用 docker ps 与 pg_isready 检查状态;本机使用 5433,docker exec 内部使用 5432。
+
+#### role app_user does not exist 或 password authentication failed
+
+原因:创建角色步骤未执行,或输入的是 postgres 密码。
+
+处理:使用管理连接检查 \du;通过 \password app_user 重设本机练习密码,再用业务身份重连。
+
+### 扩展练习
+
+分别在一个正确连接和一个故意写错数据库名的连接中观察结果,记录错误发生在哪一层。
+
+提示:将 notes_lab 暂时写成 notes_missing;不要真的创建这个库。
+
+参考答案:
+
+正确连接能执行 SELECT;错误库名在连接阶段返回 database does not exist,SQL 尚未执行。连接地址错通常是网络层失败,密码错是认证失败,SELECT 1/0 则是连接成功后的 SQL 执行错误。
+
+### 技术文档
+
+- [PostgreSQL 16:psql](https://www.postgresql.org/docs/16/app-psql.html)
+- [PostgreSQL 16:创建数据库](https://www.postgresql.org/docs/16/sql-createdatabase.html)
+- [Docker:PostgreSQL 官方镜像](https://hub.docker.com/_/postgres)
+
+---
+
+## 02 定义笔记表、数据类型与约束
+
+把 Python 对象转成有明确边界的数据表,让非法数据在数据库入口被拒绝。
+
+### 本节目标
+
+- 建立 notes 表并理解主键
+- 选择 text、boolean 和 timestamptz
+- 通过 NOT NULL、CHECK 与默认值维护数据规则
+
+### 必要概念
+
+#### 列类型约束存储形式
+
+bigint 存储整数编号,varchar(120) 限制标题字符数,text 存储正文。boolean 使用 true / false,不能用随意约定的字符串。时间使用 timestamptz 表示一个时间点,显示格式受会话时区影响。
+
+#### 默认值与非空是两条规则
+
+DEFAULT 只在插入时省略列或使用 DEFAULT 时生效;显式传 NULL 仍然是 NULL。NOT NULL 才拒绝空值,CHECK 再约束有效内容。应用校验改善错误提示,数据库约束保护所有写入入口。
+
+#### 主键和生成编号
+
+identity 自动提供编号,PRIMARY KEY 保证编号唯一且非空。编号用于定位,不保证连续,也不代表严格的业务提交顺序。updated_at 的默认值只在插入时生效,更新时需要显式修改或使用统一触发器。
+
+### 动手实现
+
+#### 1. 确认连接后创建表
+
+在第 01 课的 app_user / notes_lab 连接中执行。保存为 sql/001_notes.sql,首次运行一次;后续变更使用单独迁移文件,不要反复删表重建。
+
+文件:sql/001_notes.sql
+
+````sql
+CREATE TABLE notes (
+  id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+  title varchar(120) NOT NULL CHECK (char_length(btrim(title)) > 0),
+  content text NOT NULL DEFAULT '',
+  pinned boolean NOT NULL DEFAULT false,
+  created_at timestamptz NOT NULL DEFAULT now(),
+  updated_at timestamptz NOT NULL DEFAULT now()
+);
+\d notes
+````
+
+#### 2. 插入合法数据并看默认值
+
+本节写入的标题用 db02- 前缀,避免与后续实验混淆。RETURNING 在同一次写入中返回实际保存的行。记录生成的编号,但后续练习不依赖它必须从 1 开始。
+
+文件:终端 / 当前课时
+
+````sql
+INSERT INTO notes (title) VALUES ('db02-first')
+RETURNING id, title, content, pinned, created_at, updated_at;
+-- content 为空串,pinned 为 f,两列时间均非空
+SELECT count(*) FROM notes WHERE title = 'db02-first';
+````
+
+#### 3. 让数据库拒绝非法数据
+
+以下两条语句在 psql 默认自动提交状态逐条执行;预期分别失败,不要包进同一个 BEGIN 后继续运行。随后 count(*) 仍为 1。
+
+文件:终端 / 当前课时
+
+````sql
+INSERT INTO notes (title) VALUES (NULL);
+-- SQLSTATE 23502: not_null_violation
+INSERT INTO notes (title) VALUES ('   ');
+-- SQLSTATE 23514: check_violation
+SELECT count(*) FROM notes WHERE title = 'db02-first';
+````
+
+#### 4. 观察同一时间点的时区显示
+
+这里改的是当前会话显示方式。UTC 与上海时间显示相差 8 小时,epoch 秒数相同。业务对外使用带时区 ISO 8601,不能在 Python 中去掉 tzinfo 后再猜测时区。
+
+文件:终端 / 当前课时
+
+````sql
+SET TIME ZONE 'UTC';
+SELECT created_at, extract(epoch FROM created_at) FROM notes WHERE title='db02-first';
+SET TIME ZONE 'Asia/Shanghai';
+SELECT created_at, extract(epoch FROM created_at) FROM notes WHERE title='db02-first';
+SET TIME ZONE 'UTC';
+````
+
+### 验收结果
+
+- \d notes 显示主键、identity、非空与标题 CHECK。
+- 省略 content 和 pinned 时得到默认值;显式 NULL 标题被拒绝。
+- 切换时区后显示不同,epoch 值保持一致。
+
+### 生产约束与故障演练
+
+同一张表将被 API、导入脚本和后台任务共同写入。
+
+#### 实现决策
+
+- 关键合法性规则落到数据库。
+- 编号只当标识符,不用于推算数据量。
+- 统一存时间点、在展示层选择时区。
+
+#### 隔离环境演练
+
+向 notes_lab 提交空标题、NULL 和超长标题,逐条记录 SQLSTATE。
+
+#### 恢复验收
+
+- 非法行没有落库。
+- 合法写入能返回真实生成值。
+- 能说明 updated_at 的维护位置。
+
+### 常见错误
+
+#### UPDATE 后 updated_at 没变化
+
+原因:DEFAULT now() 只参与插入。
+
+处理:后续每次更新同时设置 updated_at=now();多人维护时把规则收敛到统一写入层或触发器。
+
+#### 空标题进入数据库
+
+原因:只使用了 NOT NULL,空串不是 NULL。
+
+处理:加入 btrim 与长度 CHECK,并在应用侧做一致校验;已有不合格数据需先清理。
+
+### 扩展练习
+
+验证 121 个字符的标题被拒绝,再插入带单引号的合法标题。
+
+提示:SQL 字面量中的单引号用两个单引号表示;Python 动态值在下一课使用参数化。
+
+参考答案:
+
+INSERT INTO notes(title) VALUES(repeat('a',121)); 会触发长度错误。INSERT INTO notes(title) VALUES('db02-O''Reilly') RETURNING title; 返回 db02-O'Reilly。SQL 中的引号转义不替代 Python 驱动的参数化接口。
+
+### 技术文档
+
+- [PostgreSQL 16:约束](https://www.postgresql.org/docs/16/ddl-constraints.html)
+- [PostgreSQL 16:日期时间类型](https://www.postgresql.org/docs/16/datatype-datetime.html)
+- [PostgreSQL 16:生成列编号](https://www.postgresql.org/docs/16/sql-createtable.html)
+
+---
+
+## 03 用参数化 SQL 完成增删改查
+
+从 Python 读写笔记,处理不存在的数据,并验证用户输入不会变成 SQL。
+
+### 本节目标
+
+- 使用 psycopg 3 传递参数
+- 通过 RETURNING 判断实际写入结果
+- 用明确列名、过滤与排序控制查询
+
+### 必要概念
+
+#### SQL 结构与参数分开传递
+
+execute 的 SQL 字符串描述操作结构,第二个参数提供数据。值占位符统一写 %s,不自行加引号,也不用 f-string 拼接输入。表名、列名不能作为普通值参数传入,动态排序应使用固定白名单。
+
+#### 写入结果要检查
+
+UPDATE 或 DELETE 匹配不到数据通常不是 SQL 错误。RETURNING 返回零行能表示资源不存在;不要在未检查结果时声称修改成功。
+
+#### 读取结果必须有边界
+
+查询显式列名能稳定输出结构。列表查询需要 LIMIT 和确定排序,否则数据库不承诺返回顺序。id DESC 可作为当前课程按编号倒序的简单规则。
+
+### 动手实现
+
+#### 1. 安装驱动与设置连接
+
+在 notes-service 创建并激活 Python 3.11+ 虚拟环境,执行 python -m pip install "psycopg[binary]>=3.2,<4"。把实际解析版本写入 requirements-lock.txt。Linux 使用下面的 PG 环境变量,PGPASSWORD 在当前 shell 中交互输入,不写进脚本。正式环境使用密钥注入并避免输出连接串。
+
+文件:终端 / 当前课时
+
+````bash
+export PGHOST=127.0.0.1 PGPORT=5433 PGDATABASE=notes_lab PGUSER=app_user
+read -rsp 'Database password: ' PGPASSWORD
+printf '\n'
+export PGPASSWORD
+python -m pip freeze > requirements-lock.txt
+````
+
+#### 2. 保存并运行完整 CRUD 脚本
+
+保存为 db_crud.py,再执行 python db_crud.py。connect("") 读取 PG 环境变量;上下文正常退出提交,异常退出回滚。单元素参数必须带逗号。此脚本先创建自己的数据,最后删除同一行,可以重复执行。
+
+文件:db_crud.py
+
+````python
+import psycopg
+from psycopg.rows import dict_row
+
+with psycopg.connect("", row_factory=dict_row) as conn:
+    created = conn.execute(
+        "INSERT INTO notes(title, content) VALUES (%s, %s) RETURNING id, title",
+        ("db03-O'Reilly", "parameterized insert"),
+    ).fetchone()
+    note_id = created["id"]
+    print("created", created)
+    print("read", conn.execute(
+        "SELECT id, title, pinned FROM notes WHERE id = %s", (note_id,)
+    ).fetchone())
+    print("updated", conn.execute(
+        "UPDATE notes SET pinned = %s, updated_at = now() WHERE id = %s RETURNING id, pinned",
+        (True, note_id),
+    ).fetchone())
+    print("deleted", conn.execute(
+        "DELETE FROM notes WHERE id = %s RETURNING id", (note_id,)
+    ).fetchone())
+    print("missing", conn.execute(
+        "SELECT id FROM notes WHERE id = %s", (note_id,)
+    ).fetchone())
+````
+
+#### 3. 提交一段看起来像 SQL 的标题
+
+单独保存为 db_input.py 并运行。预期只插入一条普通笔记,输入里的 DROP TABLE 不会执行。finally 外层连接上下文会正常提交这个插入和删除的组合;最后 notes 表仍能查询。
+
+文件:db_input.py
+
+````python
+import psycopg
+
+payload = "db03-x'); DROP TABLE notes; --"
+with psycopg.connect("") as conn:
+    row = conn.execute(
+        "INSERT INTO notes(title) VALUES (%s) RETURNING id, title", (payload,)
+    ).fetchone()
+    assert row[1] == payload
+    print(row)
+    conn.execute("DELETE FROM notes WHERE id = %s", (row[0],))
+    print("table exists", conn.execute("SELECT count(*) FROM notes").fetchone())
+````
+
+#### 4. 练习有边界的列表查询
+
+回到 psql 执行,记录结果列与顺序。修改 LIMIT 观察数量变化。对照 Python 中的写法:LIMIT 也可用 %s 传值,但应用应限制 1 到 100,不能让客户端请求任意数量。
+
+文件:终端 / 当前课时
+
+````sql
+SELECT id, title, pinned
+FROM notes
+WHERE pinned = false
+ORDER BY id DESC
+LIMIT 10;
+UPDATE notes SET pinned = true WHERE id = -1 RETURNING id;
+-- 0 rows / UPDATE 0,不是 SQL 异常
+````
+
+### 验收结果
+
+- CRUD 输出依次出现同一编号,最后 missing 为 None。
+- 含单引号和 SQL 片段的标题保持原样,notes 表仍存在。
+- 不存在编号的 UPDATE 返回零行;列表最多 10 行且编号递减。
+
+### 生产约束与故障演练
+
+笔记标题来自浏览器,数据库不能把任何一段用户输入当成可执行语法。
+
+#### 实现决策
+
+- 参数化所有动态值。
+- 列表显式限制数量和排序。
+- 以 RETURNING 判断变更结果。
+
+#### 隔离环境演练
+
+在 notes_lab 用包含单引号与 SQL 片段的标题创建笔记,核验原值与表完整性。
+
+#### 恢复验收
+
+- 没有 SQL 字符串插值。
+- 空结果有明确处理。
+- 连接与事务在脚本结束后释放。
+
+### 常见错误
+
+#### syntax error 或用户输入改变了 SQL 含义
+
+原因:把输入放进 f-string 或字符串拼接。
+
+处理:恢复固定 SQL 和独立参数元组;不要手写通用转义函数。
+
+#### 脚本输出成功但另一个连接查不到新增行
+
+原因:事务尚未提交,或者最后主动删除了练习行。
+
+处理:确认连接上下文已经退出;若只验证插入,暂时移除删除步骤后在独立连接核查。
+
+### 扩展练习
+
+写一个只更新标题的函数,返回更新后的 id/title;不存在时返回 None。
+
+提示:WHERE id = %s,并在同一 UPDATE 中 RETURNING id, title。
+
+参考答案:
+
+def rename_note(conn, note_id, title):
+    return conn.execute("UPDATE notes SET title=%s, updated_at=now() WHERE id=%s RETURNING id,title", (title,note_id)).fetchone()
+调用方决定事务提交边界;函数不私自创建连接或提交。
+
+### 技术文档
+
+- [Psycopg 3:参数传递](https://www.psycopg.org/psycopg3/docs/basic/params.html)
+- [Psycopg 3:基本使用](https://www.psycopg.org/psycopg3/docs/basic/usage.html)
+- [PostgreSQL 16:RETURNING](https://www.postgresql.org/docs/16/dml-returning.html)
+
+---
+
+## 04 用关联表、JOIN 与聚合表达关系
+
+为笔记添加标签,使用外键保证关联有效,再统计每条笔记的标签数量。
+
+### 本节目标
+
+- 建立多对多关系和联合主键
+- 区别 INNER JOIN 与 LEFT JOIN
+- 避免 JOIN 重复行导致聚合错误
+
+### 必要概念
+
+#### 多对多关系单独建表
+
+一条笔记可有多个标签,一个标签可属于多条笔记。note_tags 的每一行代表一组关联,联合主键阻止重复关联。将标签塞进逗号字符串会让检索、唯一性与改名都变得困难。
+
+#### 外键检查关联完整性
+
+note_id 必须对应真实笔记,tag_id 必须对应真实标签。此例删除笔记时级联清理关联行,删除仍被使用的标签则拒绝;删除策略来自业务,而不是所有外键统一加 CASCADE。
+
+#### 聚合前先辨认行数
+
+LEFT JOIN 会保留没有标签的笔记,并补一个右侧为空的连接结果。COUNT(*) 会把这一行算进去,COUNT(nt.tag_id) 只统计非空标签。聚合查询返回的是分组后的结果。
+
+### 动手实现
+
+#### 1. 建立标签和关联表
+
+在 notes_lab 首次执行一次;保存为 sql/002_tags.sql。引用 notes 主键,不复制标题。外键列并不会自动获得适合所有反向查询的索引,后续根据查询创建。
+
+文件:sql/002_tags.sql
+
+````sql
+CREATE TABLE tags (
+  id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+  name varchar(40) NOT NULL UNIQUE CHECK (char_length(btrim(name)) > 0)
+);
+CREATE TABLE note_tags (
+  note_id bigint NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
+  tag_id bigint NOT NULL REFERENCES tags(id) ON DELETE RESTRICT,
+  PRIMARY KEY (note_id, tag_id)
+);
+````
+
+#### 2. 准备两条笔记和两个标签
+
+以下是 psql 脚本;\gset 将 RETURNING 值写进本会话变量。创建笔记返回语句末尾不加分号,由 \gset 执行。标签可以重复准备,笔记每次执行都会产生两条独立样本。
+
+文件:终端 / 当前课时
+
+````sql
+INSERT INTO notes(title) VALUES ('db04-with-tags') RETURNING id AS tagged_id \gset
+INSERT INTO notes(title) VALUES ('db04-no-tags') RETURNING id AS empty_id \gset
+INSERT INTO tags(name) VALUES ('db04-python'), ('db04-api') ON CONFLICT (name) DO NOTHING;
+INSERT INTO note_tags(note_id,tag_id)
+SELECT :tagged_id, id FROM tags WHERE name IN ('db04-python','db04-api');
+````
+
+#### 3. 分别查看关联明细和数量
+
+第一条查询得到 2 行标签明细。第二条查询保留无标签笔记,数量分别为 2 与 0。注意按 id 筛选的是上一步新建的两行,不受此前重复运行影响。
+
+文件:终端 / 当前课时
+
+````sql
+SELECT n.id,n.title,t.name
+FROM notes n JOIN note_tags nt ON nt.note_id=n.id
+JOIN tags t ON t.id=nt.tag_id
+WHERE n.id=:tagged_id ORDER BY t.name;
+
+SELECT n.id,n.title,count(nt.tag_id) AS tag_count
+FROM notes n LEFT JOIN note_tags nt ON nt.note_id=n.id
+WHERE n.id IN (:tagged_id,:empty_id)
+GROUP BY n.id,n.title ORDER BY n.id;
+````
+
+#### 4. 验证重复关联和删除策略
+
+先单独执行重复 INSERT,预期 23505 唯一性错误;再单独执行 DELETE tags,预期 23503 外键错误。最后删除本课带标签笔记,对应关联行自动清理。只删除本会话创建的样本。
+
+文件:终端 / 当前课时
+
+````sql
+INSERT INTO note_tags(note_id,tag_id)
+SELECT :tagged_id,id FROM tags WHERE name='db04-python';
+-- 23505:重复关联
+DELETE FROM tags WHERE name='db04-python';
+-- 23503:仍被笔记引用
+DELETE FROM notes WHERE id=:tagged_id;
+SELECT count(*) FROM note_tags WHERE note_id=:tagged_id;
+-- 0
+````
+
+### 验收结果
+
+- 关联明细返回两个标签,聚合结果分别为 2 与 0。
+- 重复添加同一关联失败,不存在编号的外键写入也会失败。
+- 删除练习笔记后仅其关联行消失,标签本身仍存在。
+
+### 生产约束与故障演练
+
+生产列表页需要展示标签数,同时允许笔记暂时没有标签。
+
+#### 实现决策
+
+- 通过关联表表达多对多。
+- 用唯一约束阻止重复关系。
+- 明确父记录删除行为并验证空关系。
+
+#### 隔离环境演练
+
+在 notes_lab 重复插入关联、删除被引用标签,记录数据库拒绝原因。
+
+#### 恢复验收
+
+- 零标签结果正确。
+- 重复关系无法落库。
+- 统计没有因连接放大而重复计算。
+
+### 常见错误
+
+#### 没有标签的笔记消失了
+
+原因:使用 INNER JOIN,或在 WHERE 中过滤右表导致 LEFT JOIN 效果被破坏。
+
+处理:需要保留左表时使用 LEFT JOIN;右表匹配条件按语义放到 ON,并检查空值结果。
+
+#### 无标签笔记显示数量 1
+
+原因:COUNT(*) 统计了 LEFT JOIN 生成的空关联行。
+
+处理:改用 COUNT(nt.tag_id),并检查连接多个一对多表时是否又放大行数。
+
+### 扩展练习
+
+查询拥有 db04-python 标签的笔记,要求每条笔记最多出现一次。
+
+提示:可使用 EXISTS 子查询表达是否存在,不需要把标签列放进输出。
+
+参考答案:
+
+SELECT n.id,n.title FROM notes n WHERE EXISTS (SELECT 1 FROM note_tags nt JOIN tags t ON t.id=nt.tag_id WHERE nt.note_id=n.id AND t.name='db04-python') ORDER BY n.id; 第 04 步已删除本课关联时结果为空;重新执行第 02 步后应出现新笔记。
+
+### 技术文档
+
+- [PostgreSQL 16:表表达式与 JOIN](https://www.postgresql.org/docs/16/queries-table-expressions.html)
+- [PostgreSQL 16:聚合函数](https://www.postgresql.org/docs/16/functions-aggregate.html)
+- [PostgreSQL 16:外键](https://www.postgresql.org/docs/16/ddl-constraints.html)
+
+---
+
+## 05 把多次写入放进同一个事务
+
+一次保存笔记并关联标签,主动制造第二步失败,确认第一步也回滚。
+
+### 本节目标
+
+- 划定需要同时成功的写入边界
+- 识别失败事务状态并执行回滚
+- 使用保存点隔离可选步骤
+
+### 必要概念
+
+#### 事务覆盖一个业务动作
+
+创建笔记和保存必需标签属于同一次提交。只有两步都成功才向调用者返回成功。数据库事务不能自动回滚已经发送的邮件或外部 HTTP 请求,跨系统一致性在 API + 数据库和异步任务模块继续处理。
+
+#### 错误后的事务需要结束
+
+BEGIN 后发生约束错误,事务进入失败状态。继续 SELECT 通常得到 25P02;应 ROLLBACK 整个事务,或者在预先建立的 SAVEPOINT 上回滚到保存点。
+
+#### 事务越短越容易控制
+
+事务持有连接与可能的锁。不要在 BEGIN 与 COMMIT 之间等用户输入、调用模型或上传文件。identity 消耗的编号即使回滚也可能不复用,所以不能用编号是否连续判断事务正确性。
+
+### 动手实现
+
+#### 1. 用成功路径保存笔记和标签
+
+先完成第 04 课表结构。此脚本在 psql 中执行;标签使用已有 db04-api,若无该标签先执行 INSERT INTO tags(name) VALUES ('db04-api') ON CONFLICT DO NOTHING。事务末尾应 COMMIT。
+
+文件:终端 / 当前课时
+
+````sql
+BEGIN;
+INSERT INTO notes(title) VALUES ('db05-atomic-ok') RETURNING id AS ok_id \gset
+INSERT INTO note_tags(note_id,tag_id)
+SELECT :ok_id,id FROM tags WHERE name='db04-api';
+COMMIT;
+SELECT n.id,n.title,t.name FROM notes n
+JOIN note_tags nt ON nt.note_id=n.id JOIN tags t ON t.id=nt.tag_id
+WHERE n.id=:ok_id;
+````
+
+#### 2. 制造第二步失败
+
+本例用不存在的负标签编号触发外键错误。逐条执行,观察提示符从 notes_lab=* 改为失败事务状态。不要把预期错误脚本用 ON_ERROR_STOP 直接执行后遗漏回滚。
+
+文件:终端 / 当前课时
+
+````sql
+BEGIN;
+INSERT INTO notes(title) VALUES ('db05-must-rollback') RETURNING id AS failed_id \gset
+INSERT INTO note_tags(note_id,tag_id) VALUES (:failed_id,-999);
+-- 23503:外键错误
+SELECT 1;
+-- 25P02:当前事务已失败
+ROLLBACK;
+SELECT count(*) FROM notes WHERE id=:failed_id;
+-- 0
+````
+
+#### 3. 用保存点处理可选标签
+
+只有当业务允许“笔记成功、可选标签失败”时才采用本例。保存点前的笔记保留,保存点之后失败的关联被回滚。若标签是业务必填,则应沿用上一步整笔回滚。
+
+文件:终端 / 当前课时
+
+````sql
+BEGIN;
+INSERT INTO notes(title) VALUES ('db05-optional-tag') RETURNING id AS optional_id \gset
+SAVEPOINT optional_tag;
+INSERT INTO note_tags(note_id,tag_id) VALUES (:optional_id,-999);
+ROLLBACK TO SAVEPOINT optional_tag;
+COMMIT;
+SELECT id,title FROM notes WHERE id=:optional_id;
+-- 1 row
+````
+
+#### 4. 在独立会话核对提交边界
+
+打开第二个 psql,执行相同 SELECT。成功笔记和可选标签笔记可见,must-rollback 不可见。再在会话 A BEGIN 后插入一条新笔记,暂不提交;会话 B 查询该标题为零行,A COMMIT 后 B 再查才能看到。最后结束两个会话中所有未结束事务。
+
+文件:终端 / 当前课时
+
+````sql
+SELECT title,count(*) FROM notes
+WHERE title LIKE 'db05-%' GROUP BY title ORDER BY title;
+-- 包含 db05-atomic-ok、db05-optional-tag
+-- 不包含 db05-must-rollback
+````
+
+### 验收结果
+
+- 第二步外键失败后,回滚事务中的笔记不存在。
+- 失败状态执行 SELECT 返回 25P02,ROLLBACK 后恢复。
+- 另一会话只能读取已提交数据;保存点例子符合明确的可选业务规则。
+
+### 生产约束与故障演练
+
+保存按钮触发多张表写入,任何必需步骤失败都不能产生半成品记录。
+
+#### 实现决策
+
+- 事务以业务动作划分。
+- 可选步骤才允许保存点局部恢复。
+- 外部调用在数据库事务之外安排。
+
+#### 隔离环境演练
+
+在 notes_lab 用无效外键中断保存,重新连接检查是否留下半条业务记录。
+
+#### 恢复验收
+
+- 必需写入要么全有要么全无。
+- 失败后连接能够继续服务。
+- 日志能定位原始约束错误而不是只有后续 25P02。
+
+### 常见错误
+
+#### 笔记创建了但标签没保存
+
+原因:每条 SQL 独立提交,缺少完整事务边界。
+
+处理:将必需写入放入同一事务,仅在成功提交后返回业务成功。
+
+#### 重试 SQL 一直提示 current transaction is aborted
+
+原因:在失败事务中继续执行,没有先回滚。
+
+处理:退出事务上下文或显式 ROLLBACK,再决定是否重试整个业务动作。
+
+### 扩展练习
+
+把可选标签例子改成标签必填,说明应该删除哪些语句。
+
+提示:必填意味着不能在失败后只回滚局部。
+
+参考答案:
+
+移除 SAVEPOINT 和 ROLLBACK TO SAVEPOINT。标签插入异常时执行整个事务 ROLLBACK,笔记和关联都不保留;应用把错误转成合适的响应,而不是返回创建成功。
+
+### 技术文档
+
+- [PostgreSQL 16:事务入门](https://www.postgresql.org/docs/16/tutorial-transactions.html)
+- [PostgreSQL 16:SAVEPOINT](https://www.postgresql.org/docs/16/sql-savepoint.html)
+- [Psycopg 3:事务管理](https://www.psycopg.org/psycopg3/docs/basic/transactions.html)
+
+---
+
+## 06 用两个会话观察隔离级别与 MVCC
+
+按固定顺序执行读写,比较 Read Committed 和 Repeatable Read 的可见结果。
+
+### 本节目标
+
+- 用两个 psql 会话复现读取差异
+- 解释语句快照与事务快照
+- 识别需要重试的序列化失败
+
+### 必要概念
+
+#### 快照决定读者能看见什么
+
+Read Committed 下,每条查询读取该语句开始时已经提交的数据。Repeatable Read 通常固定在事务第一条实际查询建立的快照。下面的实验用同一行、同一个读取动作对照,避免只背隔离级别名字。
+
+#### 普通读与写锁不是同一机制
+
+MVCC 使普通 SELECT 可以读取已提交版本,不必因为另一会话正在更新同一行而等待其提交。SELECT FOR UPDATE 是带锁读取,行为不同。长事务会保留旧快照,增加旧版本清理压力。
+
+#### 更强隔离伴随重试责任
+
+Repeatable Read 或 Serializable 在并发冲突下可能返回 SQLSTATE 40001。重试应从事务开始重新读取和计算,设置次数上限与退避;不能在已经失败的事务里重发最后一条 SQL。
+
+### 动手实现
+
+#### 1. 准备专用行并打开会话 A 与 B
+
+在任意会话执行一次第一组语句,记录返回的编号。A、B 均使用 app_user / notes_lab。每个会话执行 \set iso_id 实际编号,示例中的 123 必须替换。每轮实验开始前确保双方已 COMMIT 或 ROLLBACK。
+
+文件:终端 / 当前课时
+
+````sql
+INSERT INTO notes(title,content) VALUES ('db06-isolation','v1') RETURNING id;
+-- 假设返回 123,在 A 和 B 各自设置:
+\set iso_id 123
+SHOW default_transaction_isolation;
+-- read committed
+````
+
+#### 2. 执行 Read Committed 时序
+
+按注释切换窗口,不要一次性粘贴到同一会话。A 第一次读 v1;B 更新但未提交时 A 仍读 v1;B 提交后 A 再读变为 v2。A 的事务没有重开,但下一条查询已经取得新快照。
+
+文件:终端 / 当前课时
+
+````sql
+-- A:先执行
+BEGIN ISOLATION LEVEL READ COMMITTED;
+SELECT content FROM notes WHERE id=:iso_id;
+-- B:执行,先不提交
+BEGIN;
+UPDATE notes SET content='v2',updated_at=now() WHERE id=:iso_id;
+-- A:此时仍为 v1
+SELECT content FROM notes WHERE id=:iso_id;
+-- B:现在提交
+COMMIT;
+-- A:现在为 v2,然后结束
+SELECT content FROM notes WHERE id=:iso_id;
+COMMIT;
+````
+
+#### 3. 执行 Repeatable Read 时序
+
+延续上一步的 v2。A 建立快照后,B 写入 v3 并提交;A 在原事务内仍读 v2。A 提交并开始新查询后读到 v3。记录四次读取结果与相应窗口。
+
+文件:终端 / 当前课时
+
+````sql
+-- A
+BEGIN ISOLATION LEVEL REPEATABLE READ;
+SELECT content FROM notes WHERE id=:iso_id;
+-- B:自动提交状态
+UPDATE notes SET content='v3',updated_at=now() WHERE id=:iso_id;
+-- A:原快照仍为 v2
+SELECT content FROM notes WHERE id=:iso_id;
+COMMIT;
+SELECT content FROM notes WHERE id=:iso_id;
+-- v3
+````
+
+#### 4. 复现并发更新失败并恢复
+
+A 用 Repeatable Read 读取 v3,B 改成 v4 并提交,A 再更新同一行会遇到 40001。最后 A ROLLBACK 并重新读取,确认新事务看到 v4。这里只演示失败机制,不对包含外部副作用的动作进行盲目重试。
+
+文件:终端 / 当前课时
+
+````sql
+-- A
+BEGIN ISOLATION LEVEL REPEATABLE READ;
+SELECT content FROM notes WHERE id=:iso_id;
+-- B
+UPDATE notes SET content='v4',updated_at=now() WHERE id=:iso_id;
+-- A
+UPDATE notes SET content='v5' WHERE id=:iso_id;
+-- ERROR: could not serialize access due to concurrent update
+ROLLBACK;
+SELECT content FROM notes WHERE id=:iso_id;
+-- v4
+````
+
+### 验收结果
+
+- Read Committed 在 B 提交前后读到 v1、v2。
+- Repeatable Read 在事务中保持 v2,事务结束后读到 v3。
+- 并发更新实验出现 40001,ROLLBACK 后读取恢复。
+
+### 生产约束与故障演练
+
+后台导出需要一致读取,编辑接口同时持续更新笔记。
+
+#### 实现决策
+
+- 根据读取范围选择隔离级别。
+- 限制长事务,避免把导出网络传输放在读事务内。
+- 40001 重试覆盖完整事务。
+
+#### 隔离环境演练
+
+在 notes_lab 双会话执行快照与并发更新实验,保留实际输出顺序。
+
+#### 恢复验收
+
+- 每个结果可对应到一次提交。
+- 实验后无遗留事务。
+- 能区分数据可见性与行锁等待。
+
+### 常见错误
+
+#### 两种隔离级别看起来没有区别
+
+原因:A 没有显式 BEGIN,或者 B 在 A 建立快照之前已提交。
+
+处理:严格按窗口与注释顺序执行,记录每次 BEGIN、SELECT 和 COMMIT 的时点。
+
+#### 实验结束后连接一直 idle in transaction
+
+原因:只观察结果没有结束事务。
+
+处理:两个窗口都执行 ROLLBACK;生产应用用上下文管理事务并限制事务内工作量。
+
+### 扩展练习
+
+说明报表需要同一时点多次读取,而普通单条查询通常不用提高到 Repeatable Read 的原因。
+
+提示:先确定需要保证的是一条 SQL,还是多条 SQL 共享快照。
+
+参考答案:
+
+单条 SELECT 本身在一个语句快照内执行。需要多条 SELECT 共享一致快照时,可选择短的只读 Repeatable Read 事务;若能用一条 SQL 表达汇总,通常可减少事务持续时间。提高隔离级别不能代替业务约束与正确的重试实现。
+
+### 技术文档
+
+- [PostgreSQL 16:事务隔离](https://www.postgresql.org/docs/16/transaction-iso.html)
+- [PostgreSQL 16:MVCC 简介](https://www.postgresql.org/docs/16/mvcc-intro.html)
+- [PostgreSQL 16:序列化失败处理](https://www.postgresql.org/docs/16/mvcc-serialization-failure-handling.html)
+
+---
+
+## 07 定位锁等待、复现死锁并加入版本检查
+
+观察同一行写入的等待关系,再分别用固定加锁顺序和乐观锁解决不同冲突。
+
+### 本节目标
+
+- 用 pg_blocking_pids 找到锁等待源头
+- 复现并处理死锁回滚
+- 用 version 条件阻止覆盖其他人的编辑
+
+### 必要概念
+
+#### 行锁持续到事务结束
+
+UPDATE 和 SELECT FOR UPDATE 可阻止另一个事务更新同一行。等待不一定是坏事,但必须有时限与可观测性;持锁时调用外部服务会让数据库承担外部延迟。
+
+#### 死锁来自循环等待
+
+A 持有行一等待行二,B 持有行二等待行一,形成闭环。数据库会中止其中一个事务,不能预言一定中止谁。固定顺序获取多行锁可减少死锁,应用仍需识别 40P01 并有限重试整个事务。
+
+#### 版本检查表达编辑冲突
+
+客户端读取 version=1,更新时要求 WHERE version=1 并同时 version=version+1。若别人已提交,更新返回零行,调用者应重新读取并解决冲突。版本检查防止旧内容覆盖新内容,不用于替代所有数据库锁。
+
+### 动手实现
+
+#### 1. 建立两个实验行并添加版本列
+
+完成前面表结构后执行一次 ADD COLUMN;若已存在 version 则只运行后两条。把返回编号记为 first_id 和 second_id,在 A、B 两个窗口各设置相同变量。
+
+文件:终端 / 当前课时
+
+````sql
+ALTER TABLE notes ADD COLUMN version integer NOT NULL DEFAULT 1 CHECK (version > 0);
+INSERT INTO notes(title) VALUES ('db07-first') RETURNING id;
+INSERT INTO notes(title) VALUES ('db07-second') RETURNING id;
+-- 在两个窗口替换为实际返回值
+\set first_id 124
+\set second_id 125
+````
+
+#### 2. 观察一个有上限的锁等待
+
+先 A 加锁,再 B 执行 UPDATE。B 等待期间切回 A 查询阻塞信息;如果 5 秒不够观察,可重做一次。B 约 5 秒后收到 lock timeout,之后必须 ROLLBACK;A 也 ROLLBACK 释放锁。
+
+文件:终端 / 当前课时
+
+````sql
+-- A
+BEGIN;
+SELECT id FROM notes WHERE id=:first_id FOR UPDATE;
+-- B
+BEGIN;
+SET LOCAL lock_timeout='5s';
+UPDATE notes SET content='waiting' WHERE id=:first_id;
+-- A:在 B 等待期间执行
+SELECT pid,wait_event_type,wait_event,pg_blocking_pids(pid) AS blockers
+FROM pg_stat_activity WHERE datname='notes_lab';
+-- B 超时后,以及 A 观察完后,各自执行
+ROLLBACK;
+````
+
+#### 3. 按顺序制造死锁
+
+先确认双方没有遗留事务。先 A 锁第一行,再 B 锁第二行,然后 A 请求第二行进入等待,最后 B 请求第一行。一个窗口返回 deadlock detected,另一个窗口可继续获得锁。无论哪个失败,两个窗口最后都执行 ROLLBACK。
+
+文件:终端 / 当前课时
+
+````sql
+-- A
+BEGIN;
+SELECT id FROM notes WHERE id=:first_id FOR UPDATE;
+-- B
+BEGIN;
+SELECT id FROM notes WHERE id=:second_id FOR UPDATE;
+-- A:此处会等待
+SELECT id FROM notes WHERE id=:second_id FOR UPDATE;
+-- B:触发循环等待
+SELECT id FROM notes WHERE id=:first_id FOR UPDATE;
+-- SQLSTATE 40P01 出现在其中一个会话
+-- A 和 B 各执行 ROLLBACK;
+````
+
+#### 4. 改为相同顺序锁定多行
+
+在 A 执行下面代码并保持事务,B 执行同样代码会等待 A。A COMMIT 后 B 能继续,然后 B COMMIT。双方都按 id 升序取得目标锁,避免本例的相反顺序。
+
+文件:终端 / 当前课时
+
+````sql
+BEGIN;
+SELECT id FROM notes WHERE id IN (:first_id,:second_id) ORDER BY id FOR UPDATE;
+-- A 先 COMMIT,B 等待结束后再 COMMIT
+COMMIT;
+````
+
+#### 5. 用旧版本号重放编辑
+
+在同一个窗口按顺序执行即可模拟两个客户端都读到版本 1。先把专用行重置到 version=1;第一次更新得到 2,第二次带旧版本条件返回 0 行。只有实际更新成功才向客户端返回成功。
+
+文件:终端 / 当前课时
+
+````sql
+UPDATE notes SET version=1,content='base' WHERE id=:first_id;
+UPDATE notes SET content='editor A',version=version+1,updated_at=now()
+WHERE id=:first_id AND version=1 RETURNING id,content,version;
+-- version=2
+UPDATE notes SET content='editor B',version=version+1,updated_at=now()
+WHERE id=:first_id AND version=1 RETURNING id,content,version;
+-- 0 rows
+SELECT content,version FROM notes WHERE id=:first_id;
+-- editor A, 2
+````
+
+### 验收结果
+
+- 锁等待能查到非空 blockers,超时后事务可回滚。
+- 死锁仅保证一个参与事务被中止,两个窗口最后均无活动事务。
+- 旧版本更新返回零行,editor A 的内容没有被覆盖。
+
+### 生产约束与故障演练
+
+两名用户同时编辑笔记,后台任务又可能批量修改相同记录。
+
+#### 实现决策
+
+- 短事务与锁超时控制等待。
+- 多行写入约定一致的锁顺序。
+- 普通编辑用版本检查向用户呈现冲突。
+
+#### 隔离环境演练
+
+仅在 notes_lab 双会话制造行锁与死锁,记录等待链、SQLSTATE 和回滚结果。
+
+#### 恢复验收
+
+- 没有无限等待。
+- 所有写入入口维护 version。
+- 冲突时保留已提交内容并报告更新失败。
+
+### 常见错误
+
+#### 设置 statement_timeout 后还是不清楚在等什么
+
+原因:语句超时能中断执行,但没有分析锁等待来源。
+
+处理:在等待期间查看 wait_event 与 pg_blocking_pids,结合持锁会话事务时长定位。
+
+#### 版本检查存在但更新仍覆盖内容
+
+原因:SELECT 后在 Python 检查版本,UPDATE 没带 version 条件。
+
+处理:把版本比较和递增放在同一条 UPDATE 中,依据 RETURNING 判定成功。
+
+### 扩展练习
+
+将批量修改改为按 id 升序锁定,并说明收到 40P01 后重试范围。
+
+提示:不要只重试死锁报错的最后一条 SELECT。
+
+参考答案:
+
+先确定全部目标编号,在同一事务按升序 SELECT ... FOR UPDATE,再执行修改。收到 40P01 后回滚并重新读取整个业务动作的输入,有限退避重试;涉及外部副作用时先使用幂等机制。
+
+### 技术文档
+
+- [PostgreSQL 16:显式锁与死锁](https://www.postgresql.org/docs/16/explicit-locking.html)
+- [PostgreSQL 16:统计活动](https://www.postgresql.org/docs/16/monitoring-stats.html)
+- [PostgreSQL 16:错误码](https://www.postgresql.org/docs/16/errcodes-appendix.html)
+
+---
+
+## 08 围绕查询设计单列、组合与部分索引
+
+准备足够的数据,对照查询条件与排序建立索引,再核算索引带来的写入成本。
+
+### 本节目标
+
+- 从 WHERE 与 ORDER BY 推导组合索引
+- 用部分索引服务低占比条件
+- 知道主键、唯一约束与外键索引的区别
+
+### 必要概念
+
+#### 索引缩小查找范围
+
+B-tree 索引按键组织数据,使等值、范围与有序读取可以少访问一些数据页。索引占用磁盘,写入和更新时也要维护。是否使用索引由优化器估计成本决定,小表顺序扫描常常更合适。
+
+#### 组合键的顺序来自查询
+
+WHERE pinned=true ORDER BY created_at DESC,id DESC LIMIT 20 可用 pinned、created_at、id 的组合索引。前面的等值条件固定范围,后面提供稳定排序。只对 pinned 这个低基数字段建索引不一定有收益。
+
+#### 部分索引只保存需要的行
+
+如果置顶笔记仅占少数,可只索引 WHERE pinned=true 的记录。查询条件需能匹配该谓词;不要假设参数化的任意布尔条件总能使用该索引。主键和唯一约束会创建索引,外键引用列不自动创建索引。
+
+### 动手实现
+
+#### 1. 在独立练习库生成性能样本
+
+仅 notes_lab 执行,写入 50,000 条带 db08-bench- 前缀的笔记。此脚本只运行一次,重复执行会额外产生同量数据。固定时间与稀疏 pinned 分布便于对照,但不能代表真实业务分布。
+
+文件:终端 / 当前课时
+
+````sql
+INSERT INTO notes(title,content,pinned,created_at,updated_at)
+SELECT 'db08-bench-' || g, repeat('x',200), g % 100 = 0,
+       timestamptz '2026-01-01 00:00:00+00' + g * interval '1 second',
+       timestamptz '2026-01-01 00:00:00+00' + g * interval '1 second'
+FROM generate_series(1,50000) AS g;
+ANALYZE notes;
+SELECT count(*),count(*) FILTER(WHERE pinned) FROM notes;
+````
+
+#### 2. 保存无专用索引时的计划
+
+先只用 EXPLAIN 观察估计计划,下一课运行带 ANALYZE 的实际计划。记录扫描方式、Sort 是否存在及估计行数,不把某个固定 cost 数值作为正确答案。
+
+文件:终端 / 当前课时
+
+````sql
+EXPLAIN
+SELECT id,title,created_at FROM notes
+WHERE pinned=true ORDER BY created_at DESC,id DESC LIMIT 20;
+SELECT indexname,indexdef FROM pg_indexes WHERE tablename='notes';
+````
+
+#### 3. 建立组合索引并对比
+
+建索引后重新执行相同 EXPLAIN。数据量足够时通常能减少扫描和排序工作,但输出由版本、统计和数据决定。索引包含 id 用于同一时间点下稳定排序。
+
+文件:终端 / 当前课时
+
+````sql
+CREATE INDEX notes_pinned_created_id_idx ON notes(pinned,created_at DESC,id DESC);
+ANALYZE notes;
+EXPLAIN
+SELECT id,title,created_at FROM notes
+WHERE pinned=true ORDER BY created_at DESC,id DESC LIMIT 20;
+SELECT pg_size_pretty(pg_relation_size('notes_pinned_created_id_idx')) AS index_size;
+````
+
+#### 4. 将置顶查询改为部分索引实验
+
+为能明确对照,只在 notes_lab 删除刚创建的实验索引,改建部分索引,再执行同样查询。生产建索引可评估 CREATE INDEX CONCURRENTLY 以减少阻塞,但它不能放在事务块中,失败后还需检查无效索引。
+
+文件:终端 / 当前课时
+
+````sql
+DROP INDEX notes_pinned_created_id_idx;
+CREATE INDEX notes_pinned_recent_idx ON notes(created_at DESC,id DESC) WHERE pinned=true;
+EXPLAIN
+SELECT id,title,created_at FROM notes
+WHERE pinned=true ORDER BY created_at DESC,id DESC LIMIT 20;
+SELECT pg_size_pretty(pg_relation_size('notes_pinned_recent_idx')) AS index_size;
+CREATE INDEX note_tags_tag_id_idx ON note_tags(tag_id);
+````
+
+### 验收结果
+
+- 记录至少两个版本计划和各自索引大小。
+- 能说明 pinned=true 查询与部分索引谓词的关系。
+- note_tags 的联合主键覆盖 note_id 开头查找,反向 tag_id 查询有单独索引。
+
+### 生产约束与故障演练
+
+首页需要快速显示置顶笔记,写入仍持续发生。
+
+#### 实现决策
+
+- 索引对应一条明确查询。
+- 用接近真实分布的数据评估读写成本。
+- 生产在线建索引单独规划事务与失败恢复。
+
+#### 隔离环境演练
+
+在 notes_lab 比较无索引、组合索引与部分索引的计划与大小。
+
+#### 恢复验收
+
+- 查询顺序确定。
+- 有性能证据支持索引选择。
+- 没有把练习库 DROP INDEX 直接当成生产变更方案。
+
+### 常见错误
+
+#### 建了索引仍然 Seq Scan
+
+原因:表很小、命中行过多、统计过旧或查询不匹配索引。
+
+处理:先 ANALYZE 并检查实际 SQL、类型与选择率;不要通过关闭顺序扫描作为生产修复。
+
+#### 索引越加越多,写入越来越慢
+
+原因:每个索引都增加存储与维护,部分索引可能重复覆盖同一查询。
+
+处理:保留使用证据与目标查询,对重复索引评估后再清理,结合写入延迟一起验收。
+
+### 扩展练习
+
+为 WHERE tag_id=? 查询关联的 note_id 设计索引,并判断现有主键是否足够。
+
+提示:联合主键是 (note_id,tag_id),注意最左列。
+
+参考答案:
+
+现有主键擅长 note_id 或 note_id+tag_id 条件,不应默认满足按 tag_id 反查的性能需求。可使用 CREATE INDEX ON note_tags(tag_id,note_id),并在实际数据上对比;若采用此组合索引,应评估是否还需要单列 tag_id 索引。
+
+### 技术文档
+
+- [PostgreSQL 16:多列索引](https://www.postgresql.org/docs/16/indexes-multicolumn.html)
+- [PostgreSQL 16:部分索引](https://www.postgresql.org/docs/16/indexes-partial.html)
+- [PostgreSQL 16:CREATE INDEX](https://www.postgresql.org/docs/16/sql-createindex.html)
+
+---
+
+## 09 读取 EXPLAIN ANALYZE 并改造深分页
+
+用执行计划验证实际工作量,再把大 OFFSET 改成稳定的游标条件。
+
+### 本节目标
+
+- 读懂实际行数、loops 与 buffers
+- 比较浅分页和深分页的扫描量
+- 使用 created_at 与 id 实现键集分页
+
+### 必要概念
+
+#### ANALYZE 会真的执行 SQL
+
+EXPLAIN 只生成计划,EXPLAIN ANALYZE 会执行语句并收集结果。此课仅分析 SELECT;不要直接拿它分析线上 DELETE 或 UPDATE。实际耗时受缓存与负载影响,需要同时看扫描量和缓冲访问。
+
+#### 深 OFFSET 仍需跳过前面的记录
+
+LIMIT 20 OFFSET 40000 返回 20 行,但执行器通常仍需走过被跳过的记录。键集分页使用上一页最后一行的排序键,直接定位下一段范围。
+
+#### 游标必须与排序完全一致
+
+按 created_at DESC,id DESC 排序,就用 (created_at,id)<(游标时间,游标编号)。id 处理相同时间的并列。游标不是快照;并发删除、排序字段修改仍可能影响跨页视图,导出要另行定义一致性。
+
+### 动手实现
+
+#### 1. 创建列表索引并收集实际计划
+
+继续使用第 08 课的 50,000 条样本。首次建索引一次,运行 ANALYZE。记录节点名称、actual rows、loops、Buffers 和 Execution Time。不要要求每台机器出现完全相同的执行时间。
+
+文件:终端 / 当前课时
+
+````sql
+CREATE INDEX notes_created_id_idx ON notes(created_at DESC,id DESC);
+ANALYZE notes;
+EXPLAIN (ANALYZE,BUFFERS)
+SELECT id,title,created_at FROM notes ORDER BY created_at DESC,id DESC LIMIT 20;
+````
+
+#### 2. 比较深分页的额外工作
+
+运行相同排序的深 OFFSET。即使最后只返回 20 行,下层扫描节点通常处理约 40,020 行。若计划不同,按真实节点追踪返回与丢弃的行,不强行匹配示例计划。
+
+文件:终端 / 当前课时
+
+````sql
+EXPLAIN (ANALYZE,BUFFERS)
+SELECT id,title,created_at FROM notes
+ORDER BY created_at DESC,id DESC LIMIT 20 OFFSET 40000;
+````
+
+#### 3. 从第一页取得下一页游标
+
+以下 psql 查询取第一页最后一行的时间和编号,\gset 保存为 cursor_created、cursor_id。实际 API 应从已返回的第 20 行取值,不再单独 OFFSET 查询。使用 :'cursor_created' 让 psql 正确引用时间文本。
+
+文件:终端 / 当前课时
+
+````sql
+SELECT created_at AS cursor_created,id AS cursor_id
+FROM notes ORDER BY created_at DESC,id DESC LIMIT 1 OFFSET 19 \gset
+SELECT id,title,created_at FROM notes
+WHERE (created_at,id) < (:'cursor_created'::timestamptz,:cursor_id)
+ORDER BY created_at DESC,id DESC LIMIT 20;
+EXPLAIN (ANALYZE,BUFFERS)
+SELECT id,title,created_at FROM notes
+WHERE (created_at,id) < (:'cursor_created'::timestamptz,:cursor_id)
+ORDER BY created_at DESC,id DESC LIMIT 20;
+````
+
+#### 4. 在相近深度公平对照
+
+为了教学比较,先花一次 OFFSET 成本拿到深处游标,再测游标查询本身。实际连续翻页只传前页游标。连续运行两次,说明缓存预热影响,不用单次毫秒值证明普遍结论。
+
+文件:终端 / 当前课时
+
+````sql
+SELECT created_at AS deep_created,id AS deep_id
+FROM notes ORDER BY created_at DESC,id DESC LIMIT 1 OFFSET 39999 \gset
+EXPLAIN (ANALYZE,BUFFERS)
+SELECT id,title,created_at FROM notes
+WHERE (created_at,id) < (:'deep_created'::timestamptz,:deep_id)
+ORDER BY created_at DESC,id DESC LIMIT 20;
+````
+
+### 验收结果
+
+- 记录浅 OFFSET、深 OFFSET 与深游标的实际扫描量。
+- 下一页与前页没有重复编号,排序键与过滤条件完全一致。
+- 能区分计划估计值与实际执行值,不把 cost 当成毫秒。
+
+### 生产约束与故障演练
+
+笔记数量增长后,第几百页响应变慢,而第一页正常。
+
+#### 实现决策
+
+- 以实际扫描量解释深分页成本。
+- 连续滚动用键集分页,任意跳页另做产品和性能取舍。
+- 游标字段选择稳定值,分页不默认承诺全程快照。
+
+#### 隔离环境演练
+
+在 notes_lab 对比相同深度的 OFFSET 与游标查询,记录执行计划。
+
+#### 恢复验收
+
+- 深页工作量明显下降或有证据解释未下降原因。
+- 同时间记录不重复。
+- 测试记录注明样本规模与缓存状态。
+
+### 常见错误
+
+#### 分页重复或漏掉相同时间的记录
+
+原因:只把 created_at 放进排序或游标,缺少唯一的并列键。
+
+处理:排序和比较同时包含 id,并让排序方向与比较符号一致。
+
+#### 游标查询变慢且出现 Sort
+
+原因:缺少匹配排序的索引,或添加了新筛选条件改变访问方式。
+
+处理:对实际查询重新 EXPLAIN ANALYZE,检查复合索引与筛选顺序,而不是只调 LIMIT。
+
+### 扩展练习
+
+给置顶列表加入键集分页,并说明使用哪个已有索引。
+
+提示:保留 pinned=true,同时添加二元组范围条件。
+
+参考答案:
+
+SELECT id,title,created_at FROM notes WHERE pinned=true AND (created_at,id)<($1,$2) ORDER BY created_at DESC,id DESC LIMIT 20; 这是数据库参数形式示意,psycopg 中使用 %s。第 08 课的 notes_pinned_recent_idx 对该固定谓词和排序有用,仍需实际执行计划验证。
+
+### 技术文档
+
+- [PostgreSQL 16:EXPLAIN](https://www.postgresql.org/docs/16/using-explain.html)
+- [PostgreSQL 16:LIMIT 与 OFFSET](https://www.postgresql.org/docs/16/queries-limit.html)
+- [PostgreSQL 16:行比较](https://www.postgresql.org/docs/16/functions-comparisons.html)
+
+---
+
+## 10 用 psycopg 连接池管理连接与事务
+
+让并发代码借用有限连接,复现池耗尽,确保异常后连接能够归还。
+
+### 本节目标
+
+- 建立一个可显式开启和关闭的连接池
+- 把事务包含在借还连接的作用域内
+- 通过超时和统计观察池拥塞
+
+### 必要概念
+
+#### 连接池是有限资源预算
+
+每个连接对应数据库后端资源。池在 min_size 与 max_size 范围复用连接,借不到时等待而非无限新建。进程数乘每进程最大池容量,再加任务、运维与其他服务连接,必须落在数据库预算内。
+
+#### 连接不等于业务并发数
+
+多个请求可以排队等待少量连接。持有连接的时间越长,可服务吞吐越低。一个连接上的查询串行执行;不同线程不要把同一连接当作各自独立事务。同步 psycopg 应运行在同步代码或受控线程中。
+
+#### 借还边界包含提交与回滚
+
+pool.connection() 的上下文负责正常结束时提交、异常退出时回滚并归还连接。应用启动创建池、退出关闭池;业务函数借用连接,不在每次调用时重新创建整个池。异步 API 对应 AsyncConnectionPool,在 API + 数据库模块接入。
+
+### 动手实现
+
+#### 1. 安装池依赖
+
+沿用第 03 课 PG 环境变量。在虚拟环境运行 python -m pip install "psycopg[binary,pool]>=3.2,<4",然后更新 requirements-lock.txt。psycopg_pool 是单独分发的依赖,安装 psycopg 本体不代表已安装连接池。
+
+文件:终端 / 当前课时
+
+````bash
+python -m pip install "psycopg[binary,pool]>=3.2,<4"
+python -m pip freeze > requirements-lock.txt
+````
+
+#### 2. 运行并发读的完整示例
+
+保存为 db_pool.py 并运行。4 个工作线程共用最大 2 条连接;每次函数调用独立借还。open=False 加 open(wait=True) 让启动阶段就确认连接可用,退出通过 finally 关闭。应用不应把密码或整条连接信息打印到日志。
+
+文件:db_pool.py
+
+````python
+from concurrent.futures import ThreadPoolExecutor
+from psycopg_pool import ConnectionPool
+
+pool = ConnectionPool("", min_size=1, max_size=2, timeout=2, open=False,
+                      kwargs={"application_name": "notes-service-db-lab"})
+
+def count_notes(_):
+    with pool.connection() as conn:
+        with conn.cursor() as cur:
+            cur.execute("SET LOCAL statement_timeout='2s'")
+            cur.execute("SELECT count(*) FROM notes")
+            return cur.fetchone()[0]
+
+try:
+    pool.open(wait=True, timeout=10)
+    with ThreadPoolExecutor(max_workers=4) as workers:
+        print(list(workers.map(count_notes, range(8))))
+    print(pool.get_stats())
+finally:
+    pool.close()
+````
+
+#### 3. 验证异常后事务回滚
+
+保存为 db_pool_rollback.py。故意抛出 Python 异常,异常必须离开 connection 上下文后再捕获,否则上下文可能把事务当作正常结束而提交。第二次借用连接后查询结果应为 0。
+
+文件:db_pool_rollback.py
+
+````python
+from psycopg_pool import ConnectionPool
+
+with ConnectionPool("", min_size=1, max_size=1) as pool:
+    pool.wait(timeout=10)
+    note_id = None
+    try:
+        with pool.connection() as conn:
+            note_id = conn.execute(
+                "INSERT INTO notes(title) VALUES (%s) RETURNING id",
+                ("db10-rollback",),
+            ).fetchone()[0]
+            raise RuntimeError("simulated failure after insert")
+    except RuntimeError:
+        pass
+    with pool.connection() as conn:
+        count = conn.execute("SELECT count(*) FROM notes WHERE id=%s", (note_id,)).fetchone()[0]
+        assert count == 0
+        print("rolled back", count)
+````
+
+#### 4. 可控复现池耗尽
+
+保存为 db_pool_timeout.py,只在本机练习运行。max_size=1 时外层已经占用唯一连接,内层借用 0.2 秒后超时;捕获异常后退出外层归还连接,再借用应成功。生产代码应消除嵌套借还并缩短持有时间,不能只扩大池。
+
+文件:db_pool_timeout.py
+
+````python
+from psycopg_pool import ConnectionPool, PoolTimeout
+
+with ConnectionPool("", min_size=1, max_size=1) as pool:
+    pool.wait(timeout=10)
+    with pool.connection():
+        try:
+            with pool.connection(timeout=0.2):
+                pass
+        except PoolTimeout:
+            print("expected pool timeout")
+    with pool.connection() as conn:
+        print("recovered", conn.execute("SELECT 1").fetchone()[0])
+    print(pool.get_stats())
+````
+
+### 验收结果
+
+- 并发读取成功,连接数量受 max_size=2 限制。
+- 异常插入的笔记在下一次借用中查不到。
+- 池耗尽产生 PoolTimeout,归还后 SELECT 1 恢复。
+
+### 生产约束与故障演练
+
+API 与任务进程同时访问 PostgreSQL,突发请求不应耗尽全部数据库连接。
+
+#### 实现决策
+
+- 按进程数计算总连接预算。
+- 连接等待和 SQL 执行分别设时限。
+- 业务异常离开事务边界后再转为响应。
+
+#### 隔离环境演练
+
+在 notes_lab 用单连接池制造超时和事务异常,再确认连接能归还复用。
+
+#### 恢复验收
+
+- 没有连接泄漏。
+- 等待有时限与可记录指标。
+- 连接池在应用生命周期内创建和关闭。
+
+### 常见错误
+
+#### 高并发时不断出现 PoolTimeout
+
+原因:连接持有时间过长、嵌套借用或总并发超过容量。
+
+处理:检查等待数和事务时长,先缩短查询与持有范围,再按数据库预算调整池。
+
+#### async 路由中数据库调用阻塞其他请求
+
+原因:在事件循环中直接执行同步 psycopg 查询。
+
+处理:用 AsyncConnectionPool 与 await,或在受控线程执行同步代码;不要只把函数名改成 async。
+
+### 扩展练习
+
+4 个 API 进程各 max_size=8,两个任务进程各 max_size=5,预留运维 10 条连接,至少需要规划多少连接?
+
+提示:进程之间不共享一个 Python 连接池。
+
+参考答案:
+
+仅这些进程峰值预算是 4×8+2×5+10=52 条,还应核查迁移、监控、其他服务和数据库保留连接。52 是这组假设下的预算,不是直接把 max_connections 设为 52 的建议。
+
+### 技术文档
+
+- [Psycopg 3:连接池](https://www.psycopg.org/psycopg3/docs/advanced/pool.html)
+- [Psycopg 3:池 API](https://www.psycopg.org/psycopg3/docs/api/pool.html)
+- [Psycopg 3:并发与异步](https://www.psycopg.org/psycopg3/docs/advanced/async.html)
+
+---
+
+## 11 拆分数据库权限并管理连接配置
+
+让运行时账号只做业务读写,限制危险语句,并检查日志和连接配置中的敏感信息。
+
+### 本节目标
+
+- 区分迁移账号与 API 运行账号
+- 配置现有对象和未来对象的授权
+- 验证最小权限以及超时配置
+
+### 必要概念
+
+#### 权限应按工作职责分配
+
+app_user 保留为练习的对象所有者和迁移身份;新增 notes_runtime 供 API 运行。运行时需要 SELECT、INSERT、UPDATE、DELETE,不需要 CREATE ROLE、DROP TABLE 或 ALTER TABLE。数据库 CONNECT、schema USAGE、表权限是不同层。
+
+#### 未来对象不会自动继承手工授权
+
+GRANT ON ALL TABLES 只覆盖当时存在的表。ALTER DEFAULT PRIVILEGES 针对特定创建者未来创建的对象生效,必须由正确身份设置。生产服务若只需要部分表,应显式授权,不能照搬练习的全部业务表授权。
+
+#### 连接加密与秘密存储
+
+生产连接优先使用 TLS 且校验证书和主机名,例如 sslmode=verify-full 配合可信根证书。本机官方镜像没有自动配置可校验 TLS,不应伪称本课容器已提供 TLS。密码通过环境或秘密文件注入,SQL 参数和业务正文也可能包含敏感数据。
+
+### 动手实现
+
+#### 1. 管理身份创建运行角色
+
+使用第 01 课 docker exec -it notes-postgres psql -U postgres -d notes_lab 进入管理连接。密码通过 \password 交互设置。PUBLIC 是所有角色的隐式集合,撤销公开连接只限此独立练习库,不在共享线上库直接执行。
+
+文件:终端 / 当前课时
+
+````sql
+CREATE ROLE notes_runtime LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE;
+\password notes_runtime
+REVOKE CONNECT ON DATABASE notes_lab FROM PUBLIC;
+GRANT CONNECT ON DATABASE notes_lab TO app_user,notes_runtime;
+ALTER ROLE notes_runtime IN DATABASE notes_lab SET statement_timeout='3s';
+ALTER ROLE notes_runtime IN DATABASE notes_lab SET lock_timeout='1s';
+ALTER ROLE notes_runtime IN DATABASE notes_lab SET idle_in_transaction_session_timeout='30s';
+````
+
+#### 2. 所有者身份授权现有与未来对象
+
+重新用 app_user 连接 notes_lab 执行。当前课程所有业务表都位于 public,生产可改成独立 schema 并按表授权。identity 之外若使用显式序列默认值,需要相应 sequence 权限;本例将业务序列使用权限一并授予。
+
+文件:终端 / 当前课时
+
+````sql
+REVOKE CREATE ON SCHEMA public FROM PUBLIC;
+GRANT USAGE ON SCHEMA public TO notes_runtime;
+GRANT SELECT,INSERT,UPDATE,DELETE ON ALL TABLES IN SCHEMA public TO notes_runtime;
+GRANT USAGE,SELECT ON ALL SEQUENCES IN SCHEMA public TO notes_runtime;
+ALTER DEFAULT PRIVILEGES FOR ROLE app_user IN SCHEMA public
+GRANT SELECT,INSERT,UPDATE,DELETE ON TABLES TO notes_runtime;
+ALTER DEFAULT PRIVILEGES FOR ROLE app_user IN SCHEMA public
+GRANT USAGE,SELECT ON SEQUENCES TO notes_runtime;
+````
+
+#### 3. 运行身份验证允许与拒绝操作
+
+通过 docker exec -it notes-postgres psql -h 127.0.0.1 -U notes_runtime -d notes_lab -W 连接。插入成功后删除本次编号;尝试 ALTER TABLE 会因不是所有者失败,不应产生新列。
+
+文件:终端 / 当前课时
+
+````sql
+SELECT current_user;
+INSERT INTO notes(title) VALUES ('db11-runtime') RETURNING id AS runtime_id \gset
+DELETE FROM notes WHERE id=:runtime_id;
+ALTER TABLE notes ADD COLUMN should_not_exist text;
+-- ERROR: must be owner of table notes
+SHOW statement_timeout;
+SHOW lock_timeout;
+SELECT pg_sleep(4);
+-- 约 3 秒取消,SQLSTATE 57014
+````
+
+#### 4. 验证未来表权限并调整应用配置
+
+以 app_user 创建临时验收表,再以 notes_runtime SELECT 应成功。验收后 app_user 删除该专用表。把应用 PGUSER 改为 notes_runtime 并注入对应密码,迁移工具继续单独使用 app_user。检查日志只记录操作名、耗时和 SQLSTATE,不打印密码或完整正文。
+
+文件:终端 / 当前课时
+
+````sql
+-- app_user
+CREATE TABLE permission_probe(id integer PRIMARY KEY);
+INSERT INTO permission_probe VALUES (1);
+-- notes_runtime:应该读到 1
+SELECT id FROM permission_probe;
+-- app_user:验收结束清理本课专用表
+DROP TABLE permission_probe;
+````
+
+### 验收结果
+
+- notes_runtime 可以执行业务 CRUD,不能修改表结构。
+- 新连接显示 3 秒语句超时和 1 秒锁超时。
+- app_user 创建的未来表遵循默认权限,密码没有写进仓库和示例日志。
+
+### 生产约束与故障演练
+
+API 连接配置泄漏或代码出现错误时,需要限制其能影响的数据库对象。
+
+#### 实现决策
+
+- 运行角色与迁移角色使用不同秘密。
+- 超时配置需重连生效并实测。
+- 审计日志避免包含原始敏感正文。
+
+#### 隔离环境演练
+
+在 notes_lab 用运行角色尝试 ALTER TABLE 并执行超时查询,验证拒绝和恢复。
+
+#### 恢复验收
+
+- 运行角色不是所有者或超级用户。
+- 权限随未来迁移保持可用。
+- 生产 TLS、凭据注入和轮换方式有明确配置责任。
+
+### 常见错误
+
+#### 现有表能访问,新迁移建的表报 permission denied
+
+原因:只给现有表 GRANT,或默认权限针对了错误创建角色。
+
+处理:检查实际迁移角色,给该角色设置 ALTER DEFAULT PRIVILEGES,并补授权已经创建的对象。
+
+#### sslmode=require 就被当成完成服务器身份校验
+
+原因:只要求加密不等于完整证书与主机名校验。
+
+处理:生产按基础设施证书配置 verify-full 与根证书;本机未配 TLS 的容器使用明确的开发配置。
+
+### 扩展练习
+
+给只读报表账号设计最小权限,说明为什么不应复用 app_user。
+
+提示:需要 CONNECT、schema USAGE 和必要表的 SELECT;不授予写入或对象所有权。
+
+参考答案:
+
+创建独立 LOGIN 角色,授予 notes_lab 的 CONNECT、public 的 USAGE、指定业务表的 SELECT。为其设置合适超时与只读事务默认值作为附加限制;最终权限仍由 GRANT 决定。app_user 是对象所有者,权限范围远超报表需求。
+
+### 技术文档
+
+- [PostgreSQL 16:权限](https://www.postgresql.org/docs/16/ddl-priv.html)
+- [PostgreSQL 16:默认权限](https://www.postgresql.org/docs/16/sql-alterdefaultprivileges.html)
+- [PostgreSQL 16:SSL 客户端验证](https://www.postgresql.org/docs/16/libpq-ssl.html)
+- [PostgreSQL 16:客户端默认设置](https://www.postgresql.org/docs/16/runtime-config-client.html)
+
+---
+
+## 12 备份恢复与可回退的结构变更
+
+导出练习库并恢复到另一个数据库,通过数据核对和迁移演练完成生产验收。
+
+### 本节目标
+
+- 用 pg_dump 创建逻辑备份并校验文件
+- 在独立恢复库验证结构、数据和序列
+- 使用扩展、回填、验证的步骤变更表结构
+
+### 必要概念
+
+#### 备份文件可生成不代表能恢复
+
+逻辑备份包含数据库对象与数据,恢复需要兼容的工具、角色与目标环境。单库 pg_dump 不包含整个实例的角色定义,也不能独自提供任意时间点恢复;严格 RPO 需要基础备份与 WAL 归档等方案。
+
+#### RPO 和 RTO 是验收条件
+
+RPO 是可接受的数据丢失时间范围,RTO 是恢复服务需要的时间上限。备份频率、保留、异地副本与恢复速度必须与业务目标一致。本课实际测量恢复步骤,而不把本机几秒恢复推断为线上保证。
+
+#### 结构变更考虑新旧代码并存
+
+增加可空列通常比直接删除或改名更容易兼容。先加字段,再部署兼容代码,分批回填并验证,最后收紧约束。回滚代码不意味着可以随意 DROP 已有用户数据的新列,破坏性变更需要单独恢复设计。
+
+### 动手实现
+
+#### 1. 生成备份与源库验收数据
+
+以下 Linux Bash 在 notes-service 执行,需要 PostgreSQL 16 客户端 pg_dump、psql、pg_restore;沿用 PGHOST/PGPORT/PGDATABASE,PGUSER 使用 app_user 且注入其密码。备份目录限制权限。命令失败必须停止,不继续上传空文件。
+
+文件:终端 / 当前课时
+
+````bash
+set -e
+mkdir -p backups
+chmod 700 backups
+pg_dump --version
+pg_dump -Fc --no-owner --no-acl -f backups/notes_lab.dump
+sha256sum backups/notes_lab.dump > backups/notes_lab.dump.sha256
+pg_restore --list backups/notes_lab.dump > backups/notes_lab.contents.txt
+psql -At -v ON_ERROR_STOP=1 -c "SELECT count(*),min(id),max(id),count(*) FILTER(WHERE pinned) FROM notes" > backups/source-notes.txt
+sha256sum -c backups/notes_lab.dump.sha256
+````
+
+#### 2. 创建隔离恢复目标并恢复
+
+管理连接执行 CREATE DATABASE notes_restore OWNER app_user;,只在本机首次创建该专用恢复库。不要向原 notes_lab 恢复。下面 --no-owner --no-acl 使本课恢复对象由 app_user 持有,权限在恢复后按第 11 课脚本重建;这不是自动恢复所有生产角色。
+
+文件:终端 / 当前课时
+
+````bash
+# Linux Bash,PGUSER=app_user,使用相应密码
+pg_restore --exit-on-error --single-transaction --no-owner --no-acl \
+  -h 127.0.0.1 -p 5433 -U app_user -d notes_restore backups/notes_lab.dump
+psql -d notes_restore -At -v ON_ERROR_STOP=1 \
+  -c "SELECT count(*),min(id),max(id),count(*) FILTER(WHERE pinned) FROM notes" > backups/restored-notes.txt
+diff backups/source-notes.txt backups/restored-notes.txt
+````
+
+#### 3. 核查恢复后的关系与新写入
+
+使用 app_user 连 notes_restore 执行。反关联查询均应为 0。插入能生成合法新编号,ROLLBACK 避免保留验收笔记;序列可能前进一位,这是正常行为。若源库备份期间持续写入,外部另查的 count 不一定对应 pg_dump 快照;本课在停止练习写入后对照。
+
+文件:终端 / 当前课时
+
+````sql
+SELECT count(*) FROM note_tags nt LEFT JOIN notes n ON n.id=nt.note_id WHERE n.id IS NULL;
+SELECT count(*) FROM note_tags nt LEFT JOIN tags t ON t.id=nt.tag_id WHERE t.id IS NULL;
+BEGIN;
+INSERT INTO notes(title) VALUES ('db12-restore-probe') RETURNING id,title;
+ROLLBACK;
+\d notes
+\d note_tags
+````
+
+#### 4. 在练习源库做一次兼容扩展迁移
+
+重新连接 notes_lab / app_user。创建 archived_at 可空列表示未归档;保存为 sql/003_archived.sql,使用 psql -v ON_ERROR_STOP=1 -f sql/003_archived.sql 执行一次。短 lock_timeout 保证拿不到结构锁时尽快失败,再由人检查阻塞后择机重试。它不能保证 DDL 永远无阻塞。
+
+文件:sql/003_archived.sql
+
+````sql
+BEGIN;
+SET LOCAL lock_timeout='1s';
+SET LOCAL statement_timeout='5s';
+ALTER TABLE notes ADD COLUMN archived_at timestamptz;
+COMMIT;
+````
+
+#### 5. 验证新旧访问兼容并记录恢复方案
+
+旧查询只选择明确列名,不受新列影响。新查询把 archived_at IS NULL 当作活动笔记。故障演练是在恢复库模拟错误更新再从备份另建恢复目标,不覆盖源库。本课不实际给出删除全表语句;验收恢复耗时、权限重建与业务请求都必须列入恢复记录。
+
+文件:终端 / 当前课时
+
+````sql
+SELECT id,title,pinned FROM notes ORDER BY id DESC LIMIT 3;
+SELECT id,title FROM notes WHERE archived_at IS NULL ORDER BY id DESC LIMIT 3;
+SELECT column_name,is_nullable,data_type
+FROM information_schema.columns
+WHERE table_name='notes' AND column_name='archived_at';
+-- is_nullable=YES,data_type=timestamp with time zone
+````
+
+### 验收结果
+
+- 备份校验和正确,pg_restore 清单包含业务表、约束和数据。
+- 独立恢复库行数与静止源库一致,外键无孤儿,新插入成功。
+- 新增字段后旧查询与新查询都可运行,迁移失败有停止和重试策略。
+
+### 生产约束与故障演练
+
+发布结构变更之前,需要证明数据库可恢复,旧版本服务仍能短暂并存。
+
+#### 实现决策
+
+- 恢复目标与源库严格分开。
+- 备份验收覆盖数据、约束、权限与新写入。
+- 新增字段先兼容,再部署和回填;迁移文件纳入版本管理。
+
+#### 隔离环境演练
+
+在独立 notes_restore 检查备份恢复,然后在 notes_lab 用短锁超时演练新增可空字段;所有操作保持目标库可辨认。
+
+#### 恢复验收
+
+- 记录实际恢复耗时与备份时间。
+- 恢复库能完成业务 CRUD。
+- 迁移前后访问兼容,生产 RPO/RTO 不由本机测试推定。
+
+### 常见错误
+
+#### 备份命令成功,但恢复时缺角色或权限
+
+原因:把单库逻辑备份当成完整实例备份,未规划角色与授权。
+
+处理:在恢复流程中显式重建角色和授权,或由受控配置管理维护;本课使用 no-owner/no-acl 后重新授权。
+
+#### 上线加字段一直等待,API 也开始变慢
+
+原因:DDL 正在等待强锁,可能与长事务形成阻塞队列。
+
+处理:用短 lock_timeout 退出,查等待链并缩短长事务;在明确窗口重试,不无限等锁。
+
+#### 恢复行数不等于备份后源库行数
+
+原因:源库继续写入,两个统计不属于同一快照。
+
+处理:练习对比时暂停写入;生产验收使用与备份快照一致的方法或业务不变量,不把后续变化误判为备份损坏。
+
+### 扩展练习
+
+为“每日备份、允许最多丢失 15 分钟数据”的需求判断现有方案是否合格,并写出补充项。
+
+提示:先将备份间隔与 RPO 对照,再考虑恢复演练。
+
+参考答案:
+
+单独每日 pg_dump 无法满足 15 分钟 RPO。需要能够持续归档的 WAL 与基础备份或受管数据库时间点恢复,并验证归档延迟、保留期、异地副本、恢复步骤和监控;按目标时间点实际演练并测量 RTO。
+
+### 技术文档
+
+- [PostgreSQL 16:pg_dump](https://www.postgresql.org/docs/16/app-pgdump.html)
+- [PostgreSQL 16:pg_restore](https://www.postgresql.org/docs/16/app-pgrestore.html)
+- [PostgreSQL 16:持续归档与时间点恢复](https://www.postgresql.org/docs/16/continuous-archiving.html)
+- [PostgreSQL 16:ALTER TABLE](https://www.postgresql.org/docs/16/sql-altertable.html)

+ 1659 - 0
public/downloads/docker-lessons.md

@@ -0,0 +1,1659 @@
+# Docker 容器化
+
+术核 AI · 后端工程课件
+
+## 01 镜像、容器与进程生命周期
+
+从一个临时 Python 容器开始,观察进程退出、停止、重建与数据保留的区别。
+
+### 本节目标
+
+- 区分镜像模板与运行中的容器
+- 找到容器进程、日志和退出状态
+- 判断哪些数据应写入卷而非容器可写层
+
+### 必要概念
+
+#### 容器运行的是进程
+
+镜像是文件系统和启动配置;容器是基于镜像创建的进程及其隔离环境。主进程退出,容器便停止,Docker 不会因为容器存在就持续执行应用。容器不是需要手工登录安装软件的长期虚拟机。
+
+#### 停止与删除不同
+
+stop 发送停止信号并等待,start 重用原容器,rm 删除已停止容器的可写层。重新 run 创建另一份容器。镜像和显式命名卷有独立生命周期,重建 API 容器不应删除数据库卷。
+
+#### 本模块练习边界
+
+使用 Docker Desktop 的 Linux 容器模式或独立 Linux 练习机,先完成 API+数据库模块的 notes-service。所有命令针对本地专用练习项目;不连接线上 Docker context,不挂载 Docker socket 到应用。未完成前置代码时,端点和 worker 不会自动存在。
+
+### 动手实现
+
+#### 1. 核对 Docker 目标
+
+执行下列只读命令,确认 context 指向本机练习环境,Linux 容器模式,Compose V2 可用。若无 Docker,应先按官方安装文档完成当前系统安装,不执行来源不明的一键脚本。
+
+文件:终端 / 当前课时
+
+````bash
+docker context show
+docker info --format '{{.OSType}}'
+docker compose version
+docker version
+````
+
+#### 2. 运行一次性 Python 进程
+
+该容器运行完 print 就退出,--rm 清理它自己的可写层。它没有数据库、持久卷或宿主目录挂载,不影响 notes 数据。
+
+文件:终端 / 当前课时
+
+````bash
+docker run --rm python:3.11-slim python -c "import sys; print(sys.version)"
+````
+
+#### 3. 观察持续运行的主进程
+
+创建专用生命周期容器,查看它的日志和进程。这里只写容器内临时文件,接下来可以安全删除该练习容器。Windows PowerShell 可原样使用双引号内的 Python 代码。
+
+文件:终端 / 当前课时
+
+````bash
+docker run -d --name notes-lifecycle python:3.11-slim python -u -c "import time; from pathlib import Path; Path('/tmp/probe.txt').write_text('container layer'); print('ready'); time.sleep(300)"
+docker logs notes-lifecycle
+docker top notes-lifecycle
+docker inspect notes-lifecycle --format '{{.State.Status}}'
+````
+
+#### 4. 停止、重启再查看文件
+
+stop 后仍保留原容器文件层,start 重新运行启动命令。观察完再次 stop,并只删除名为 notes-lifecycle 的容器;不要用批量 prune 清理所有项目。
+
+文件:终端 / 当前课时
+
+````bash
+docker stop --time 5 notes-lifecycle
+docker start notes-lifecycle
+docker exec notes-lifecycle cat /tmp/probe.txt
+docker stop --time 5 notes-lifecycle
+docker rm notes-lifecycle
+````
+
+#### 5. 比较新容器的文件层
+
+使用相同镜像创建新容器,它没有前一容器的 probe 文件。预期 False 不代表镜像损坏,而是临时写入没有进入镜像或持久卷。
+
+文件:终端 / 当前课时
+
+````bash
+docker run --rm python:3.11-slim python -c "from pathlib import Path; print(Path('/tmp/probe.txt').exists())"
+````
+
+### 验收结果
+
+- docker info 的 OSType 为 linux,context 明确指向练习环境。
+- 能够观察主进程退出导致容器停止,日志可独立读取。
+- 新容器中没有上一容器临时文件,能说明卷的必要性。
+
+### 生产约束与故障演练
+
+开发者需要交付能在另一台机器启动的服务,而非一份手工配置记录。
+
+#### 实现决策
+
+- 以主进程状态判断服务是否运行。
+- 只清理明确命名的练习容器,持久数据单独管理。
+
+#### 隔离环境演练
+
+停止并删除无挂载的生命周期容器,再从同一镜像创建,观察临时文件消失。
+
+#### 恢复验收
+
+- 能解释进程、可写层、镜像与卷各自的生命周期。
+- 所有操作目标都可通过 context 和容器名定位。
+
+### 常见错误
+
+#### docker run 很快结束,以为容器启动失败
+
+原因:启动命令只是一个会完成的脚本。
+
+处理:查看 docker logs 与退出码,服务需要持续监听进程,而非用 tail 掩盖真实服务退出。
+
+#### 进入容器 pip install 后重建又缺包
+
+原因:安装发生在容器临时文件层。
+
+处理:将依赖写进 Dockerfile 与锁定文件,通过重建镜像保留。
+
+### 扩展练习
+
+镜像没有变化,为何两个容器的文件可能不同?
+
+提示:启动配置相同不代表运行时写入相同。
+
+参考答案:
+
+容器各自拥有可写层和进程状态;环境变量、挂载卷和启动命令也可能不同。把需要复现的软件放进镜像,把需要保留的业务数据放进明确的持久存储。
+
+### 技术文档
+
+- [Docker:什么是容器](https://docs.docker.com/get-started/docker-concepts/the-basics/what-is-a-container/)
+- [Docker:镜像](https://docs.docker.com/get-started/docker-concepts/the-basics/what-is-an-image/)
+- [Docker:安装](https://docs.docker.com/engine/install/)
+
+---
+
+## 02 为 FastAPI 编写非 Root Dockerfile
+
+把 notes-service 源码与固定依赖构建进镜像,以非 root 用户启动 Uvicorn。
+
+### 本节目标
+
+- 编写最小可运行 Dockerfile 和 .dockerignore
+- 用锁定依赖避免不同机器安装漂移
+- 以 exec 形式运行非 root 应用进程
+
+### 必要概念
+
+#### 构建上下文决定哪些文件可被复制
+
+docker build 的最后一个路径是构建上下文,COPY 只能读取其内容。把 .env、备份、虚拟环境排除可减少构建大小并避免秘密进入镜像;在后续 RUN 删除秘密无法抹去早期镜像层。
+
+#### 运行用户与监听地址
+
+容器内应用使用固定非 root UID,监听 0.0.0.0:8000 才能接收容器网卡流量。宿主发布到 127.0.0.1:8000 限制外部访问。非 root 不等于完整隔离,但能减少意外写入和权限范围。
+
+#### 进程命令
+
+JSON 数组形式 CMD 直接运行应用进程,停止信号可以到达 Uvicorn;不要用 shell 后台启动加无限 sleep。生产镜像不启用 --reload,日志写 stdout/stderr,数据库迁移独立运行。
+
+### 动手实现
+
+#### 1. 建立目标平台锁定依赖
+
+在 notes-service 新建 requirements.in,采用下列主依赖。已有业务的额外依赖应一起加入。用目标 Linux/Python3.11 的构建环境生成 requirements-lock.txt;不要把 Windows 专有库锁直接用于 Linux。下一课给出容器内生成命令。
+
+文件:requirements.in
+
+````text
+fastapi>=0.121,<1
+uvicorn[standard]>=0.30,<1
+pydantic-settings>=2,<3
+SQLAlchemy>=2.0,<2.1
+psycopg[binary]>=3,<4
+alembic>=1.13,<2
+httpx>=0.27,<1
+celery[redis]>=5.4,<6
+````
+
+#### 2. 新建忽略文件
+
+Docker 构建不需要本机 .env、虚拟环境、数据库转储和 Git 历史。保留 .env.example 可供文档参考,但本课 Dockerfile 只显式 COPY 应用必需文件。
+
+文件:.dockerignore
+
+````text
+.git
+.venv
+.env
+.env.*
+secrets/
+backups/
+*.dump
+*.sql.gz
+__pycache__/
+.pytest_cache/
+tests/
+````
+
+#### 3. 新建 Dockerfile
+
+依赖完整 app 目录、alembic.ini、migrations 与生成的 requirements-lock.txt。Python slim 基础版本下一课固定为实际 digest;此处标签用于首次学习构建。COPY 不复制本机配置。
+
+文件:Dockerfile
+
+````dockerfile
+FROM python:3.11-slim
+ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 PIP_DISABLE_PIP_VERSION_CHECK=1
+WORKDIR /app
+COPY requirements-lock.txt ./
+RUN python -m pip install --no-cache-dir --require-hashes -r requirements-lock.txt     && groupadd --gid 10001 notes     && useradd --uid 10001 --gid notes --no-create-home --shell /usr/sbin/nologin notes
+COPY app ./app
+COPY alembic.ini ./
+COPY migrations ./migrations
+USER 10001:10001
+EXPOSE 8000
+CMD ["python", "-m", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--timeout-graceful-shutdown", "25"]
+````
+
+#### 4. 构建并核对镜像用户
+
+完成下一课锁文件生成后在项目根目录构建。此检查不需要数据库配置,因为 python -c 直接执行,没有导入 app。用户 id 应为 10001。
+
+文件:终端 / 当前课时
+
+````bash
+docker build -t notes-service:lab-01 .
+docker run --rm notes-service:lab-01 python -c "import os; print(os.getuid())"
+docker image inspect notes-service:lab-01 --format '{{.Config.User}} {{json .Config.Cmd}}'
+````
+
+#### 5. 通过练习网络配置启动服务
+
+本课暂不启动完整服务,先验证代码和依赖存在;第 4 课 Compose 会提供 postgres DNS 与 DATABASE_URL。直接运行镜像若缺 DATABASE_URL 应明确失败,这比默认连错库更容易排错。
+
+文件:终端 / 当前课时
+
+````bash
+docker run --rm notes-service:lab-01 python -c "import fastapi, sqlalchemy, psycopg; print(fastapi.__version__, sqlalchemy.__version__, psycopg.__version__)"
+docker run --rm notes-service:lab-01 python -c "from pathlib import Path; print(Path('/app/alembic.ini').exists(), Path('/app/migrations/env.py').exists())"
+````
+
+### 验收结果
+
+- 镜像运行用户为 10001,不需要 --privileged。
+- 镜像包含应用与迁移文件,不含 .env 和备份。
+- CMD 直接启动 Uvicorn,未启用 reload;依赖版本能在容器内打印。
+
+### 生产约束与故障演练
+
+应用镜像交给部署机器运行,需要固定依赖与明确的低权限进程。
+
+#### 实现决策
+
+- 运行时依赖装在构建阶段,秘密仅运行时注入。
+- 只复制需要的目录,非 root 启动。
+
+#### 隔离环境演练
+
+在构建上下文放一个无敏感内容的 .env 练习标记,构建后确认 /app/.env 不存在。
+
+#### 恢复验收
+
+- 镜像内没有本机配置标记。
+- 容器用户和启动命令可审计。
+
+### 常见错误
+
+#### 容器日志显示已启动,宿主访问不到
+
+原因:Uvicorn 只监听容器回环地址 127.0.0.1。
+
+处理:容器内监听 0.0.0.0,宿主端口发布是否公开由 ports 绑定地址控制。
+
+#### pip --require-hashes 拒绝安装
+
+原因:锁文件不是带完整依赖与哈希的锁文件。
+
+处理:按下一课用 pip-tools 生成,不能去掉哈希选项假装已经固定依赖。
+
+### 扩展练习
+
+为什么不直接 COPY . /app?
+
+提示:检查构建上下文中的本机配置与数据。
+
+参考答案:
+
+显式 COPY 应用、迁移和锁文件能缩小误带入范围;.dockerignore 再提供过滤。COPY . 并非语法错误,但需要更严格控制上下文,新手项目容易把秘密、缓存和备份带进去。
+
+### 技术文档
+
+- [Docker:Dockerfile 参考](https://docs.docker.com/reference/dockerfile/)
+- [Docker:构建上下文与忽略文件](https://docs.docker.com/build/concepts/context/)
+- [pip:安全可重复安装](https://pip.pypa.io/en/stable/topics/secure-installs/)
+
+---
+
+## 03 分层缓存、多阶段构建与可重复输入
+
+将依赖构建和运行文件分开,固定基础镜像与 Python 包,让构建结果可以追溯。
+
+### 本节目标
+
+- 用目标平台生成带哈希依赖锁
+- 理解 COPY 顺序对构建缓存的影响
+- 以多阶段构建移除构建工具并记录镜像摘要
+
+### 必要概念
+
+#### 缓存按层失效
+
+先 COPY 依赖锁再安装,最后 COPY 源码,业务代码修改通常不会让依赖安装层失效。缓存提升速度,不代表输入自动更新;基础镜像和锁文件更新仍需显式执行与测试。
+
+#### 标签与摘要
+
+python:3.11-slim 标签可能随安全更新移动,digest 指向具体镜像内容。使用真实查询到的 digest 可固定基础镜像;固定也意味着必须有主动更新流程,不能永久停留在旧漏洞版本。
+
+#### 多阶段的边界
+
+builder 创建虚拟环境,runtime 只复制环境与应用代码。二者使用相同基础镜像和架构,避免复制不兼容的本地扩展。哈希锁、基础 digest 和目标平台稳定输入;如果要求字节级相同镜像,还需控制构建时间戳与工具链,不能仅靠 Dockerfile 保证。
+
+### 动手实现
+
+#### 1. 在一次性 Linux 容器生成锁文件
+
+以下为 Bash/WSL 在 notes-service 根目录执行;PowerShell 使用 ${PWD} 对应绝对路径替换 $(pwd)。pip-tools 安装只发生在临时构建工具容器。生成后审阅锁定变更并纳入代码管理。
+
+文件:终端 / 当前课时
+
+````bash
+docker run --rm --mount type=bind,source="$(pwd)",target=/src -w /src python:3.11-slim sh -c 'python -m pip install "pip-tools>=7,<8" && python -m piptools compile --generate-hashes --output-file requirements-lock.txt requirements.in'
+````
+
+#### 2. 查询实际基础摘要
+
+执行 pull 后查看 RepoDigests,将输出中的完整 python@sha256:... 记录到本机构建变量 PYTHON_IMAGE。不要复制课件中的占位摘要。多架构构建需明确目标平台并记录选择。
+
+文件:终端 / 当前课时
+
+````bash
+docker pull python:3.11-slim
+docker image inspect python:3.11-slim --format '{{json .RepoDigests}}'
+# Bash / WSL:选择并保存刚拉取镜像的完整仓库摘要。
+PYTHON_IMAGE=$(docker image inspect python:3.11-slim --format '{{index .RepoDigests 0}}')
+case "$PYTHON_IMAGE" in python@sha256:*) export PYTHON_IMAGE ;; *) echo 'unexpected image digest'; exit 1 ;; esac
+````
+
+#### 3. 替换 Dockerfile 为多阶段版本
+
+用本段完整替换第 2 课 Dockerfile。PYTHON_IMAGE 的默认标签供练习,发布构建必须通过 --build-arg 传已记录的 digest;builder 与 runtime 共用同一值。psycopg[binary] 避免本课额外引入编译工具。
+
+文件:Dockerfile
+
+````dockerfile
+ARG PYTHON_IMAGE=python:3.11-slim
+FROM ${PYTHON_IMAGE} AS builder
+ENV PIP_DISABLE_PIP_VERSION_CHECK=1
+WORKDIR /build
+RUN python -m venv /opt/venv
+COPY requirements-lock.txt ./
+RUN /opt/venv/bin/python -m pip install --no-cache-dir --require-hashes -r requirements-lock.txt
+
+FROM ${PYTHON_IMAGE} AS runtime
+ENV PATH="/opt/venv/bin:$PATH" PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
+RUN groupadd --gid 10001 notes     && useradd --uid 10001 --gid notes --no-create-home --shell /usr/sbin/nologin notes
+WORKDIR /app
+COPY --from=builder /opt/venv /opt/venv
+COPY app ./app
+COPY alembic.ini ./
+COPY migrations ./migrations
+USER 10001:10001
+EXPOSE 8000
+CMD ["python", "-m", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--timeout-graceful-shutdown", "25"]
+````
+
+#### 4. 构建并观察缓存
+
+在同一 Bash / WSL 终端使用上一阶段取得并验证的 PYTHON_IMAGE;新开终端时重新从已拉取镜像读取摘要。变量为空会在构建前停止。第一次完成后仅修改 app 中一个无副作用注释,再构建,依赖层应显示 CACHED。
+
+文件:终端 / 当前课时
+
+````bash
+docker build --progress=plain --build-arg PYTHON_IMAGE="${PYTHON_IMAGE:?set verified base digest}" -t notes-service:lab-02 .
+docker history notes-service:lab-02
+docker image inspect notes-service:lab-02 --format '{{.Id}}'
+````
+
+#### 5. 记录构建证据与更新路径
+
+创建 build-record.md,记录下面字段。依赖升级时在分支重新生成锁、构建新镜像并运行数据库测试,再发布;不能在运行容器内 pip install -U 修补并忘记回写镜像。
+
+文件:build-record.md
+
+````text
+源码提交:实际 git commit SHA
+目标平台:例如 linux/amd64
+基础镜像:实际 python@sha256:...
+依赖锁:requirements-lock.txt 的版本/摘要
+应用镜像:本机 image ID;推送后补仓库 digest
+验证结果:执行命令、实际输出、未验证项
+````
+
+### 验收结果
+
+- requirements-lock.txt 包含递归依赖的固定版本与哈希,容器中 --require-hashes 安装通过。
+- 只改应用源码时依赖层命中缓存。
+- 构建记录能关联源码、基础镜像、依赖锁与最终镜像。
+
+### 生产约束与故障演练
+
+同一版本需要在预发布和线上运行相同的软件输入。
+
+#### 实现决策
+
+- 构建一次,按同一镜像摘要提升到下一个环境。
+- 构建工具留在 builder,运行层只包含运行所需文件。
+
+#### 隔离环境演练
+
+修改源码与依赖锁分别构建,记录哪几层重建;还原后核对镜像关联记录。
+
+#### 恢复验收
+
+- 能解释缓存结果。
+- 依赖升级产生新的可追溯发布物。
+
+### 常见错误
+
+#### 复制本机 .venv 后容器启动找不到动态库
+
+原因:宿主系统或架构与容器不一致。
+
+处理:在目标 Linux 基础镜像内创建环境,builder/runtime 保持相同平台与 ABI。
+
+#### 固定基础摘要后长期没有安全更新
+
+原因:把固定输入误当作无需维护。
+
+处理:定期更新摘要和依赖锁,在测试通过后产生新发布版本,保留变更记录。
+
+### 扩展练习
+
+--no-cache 是否保证获取最新基础镜像?
+
+提示:构建缓存与拉取基础镜像是两件事。
+
+参考答案:
+
+不保证。--pull 会检查标签的新内容;但如果固定 digest,应先审阅并选择新 digest,再更新构建参数。盲目拉最新也不等于通过了应用兼容测试。
+
+### 技术文档
+
+- [Docker:多阶段构建](https://docs.docker.com/build/building/multi-stage/)
+- [Docker:构建缓存](https://docs.docker.com/build/cache/)
+- [Docker:固定基础镜像](https://docs.docker.com/build/building/best-practices/#pin-base-image-versions)
+- [pip-tools:依赖锁](https://pip-tools.readthedocs.io/en/stable/)
+
+---
+
+## 04 Compose 网络、服务 DNS 与端口
+
+把 API、PostgreSQL 和 Redis 放入同一专用网络,区分容器内地址和宿主调试端口。
+
+### 本节目标
+
+- 通过服务名连接 PostgreSQL 与 Redis
+- 绑定本机调试端口并避免冲突
+- 用 Compose 配置检查和容器内探针定位连通性
+
+### 必要概念
+
+#### localhost 指当前容器
+
+API 中 127.0.0.1 指向 API 自己,不能到 PostgreSQL。Compose 默认网络提供 postgres 和 redis 服务名解析;容器间连接使用 postgres:5432 与 redis:6379,宿主调试使用 127.0.0.1:5433 与 127.0.0.1:6380。
+
+#### 端口发布的方向
+
+ports 的左侧是宿主绑定,右侧是容器监听。容器间通信不依赖宿主端口映射;数据库不需要被公网发布。EXPOSE 是镜像元信息,也不会自动开放宿主端口。
+
+#### 项目隔离
+
+Compose project name 区分同机多个练习栈的网络与卷;不要设置固定 container_name,它会阻碍扩容并造成跨项目名字冲突。示例数据库为 notes_lab,app_user 是普通应用角色,postgres 管理账号不进入 API。
+
+### 动手实现
+
+#### 1. 新建 Compose 专用环境文件
+
+复制下列模板为 .env.compose 并填写两个不同的本机练习密码。文件不提交,后续统一命令显式 --env-file .env.compose;它用于 Compose 插值,不覆盖先前本机 Python 使用的 .env。连接串密码有特殊字符需 URL 编码。
+
+文件:.env.compose
+
+````dotenv
+COMPOSE_PROJECT_NAME=notes-lab
+DB_ADMIN_PASSWORD=CHANGE_ADMIN_PASSWORD
+APP_DB_PASSWORD=CHANGE_APP_PASSWORD
+DATABASE_URL=postgresql+psycopg://app_user:CHANGE_APP_PASSWORD@postgres:5432/notes_lab
+REDIS_URL=redis://redis:6379/0
+NOTES_IMAGE=notes-service:lab-02
+````
+
+#### 2. 新建初始化脚本
+
+新建 deploy/init/01-app.sh,保存 LF 换行。postgres 官方镜像在空数据目录首次初始化时执行脚本;后续改环境变量不会自动修改角色密码。psql 变量替换用 SQL 字符串引用处理密码,禁止直接字符串拼 SQL。练习 app_user 拥有 public schema 的 DDL 权限,生产迁移权限下一阶段单独拆分。
+
+文件:deploy/init/01-app.sh
+
+````bash
+#!/bin/sh
+set -eu
+psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB"   --set=app_password="$APP_DB_PASSWORD" <<'SQL'
+CREATE ROLE app_user LOGIN PASSWORD :'app_password';
+GRANT CONNECT ON DATABASE notes_lab TO app_user;
+GRANT USAGE, CREATE ON SCHEMA public TO app_user;
+SQL
+````
+
+#### 3. 新建 compose.yaml
+
+本段创建 API、Postgres、Redis 与独立卷;Redis 此时只是连通性练习,不承诺任务持久化。宿主数据库端口与数据库模块一致。API 初次启动可能尚未建表,第 6 课加入迁移和健康依赖。
+
+文件:compose.yaml
+
+````yaml
+services:
+  api:
+    image: ${NOTES_IMAGE:?set NOTES_IMAGE}
+    environment:
+      DATABASE_URL: ${DATABASE_URL:?set DATABASE_URL}
+      REDIS_URL: ${REDIS_URL:?set REDIS_URL}
+    ports: ["127.0.0.1:8000:8000"]
+  postgres:
+    image: postgres:16
+    environment:
+      POSTGRES_DB: notes_lab
+      POSTGRES_USER: postgres
+      POSTGRES_PASSWORD: ${DB_ADMIN_PASSWORD:?set admin password}
+      APP_DB_PASSWORD: ${APP_DB_PASSWORD:?set app password}
+    ports: ["127.0.0.1:5433:5432"]
+    volumes:
+      - pg_data:/var/lib/postgresql/data
+      - ./deploy/init:/docker-entrypoint-initdb.d:ro
+  redis:
+    image: redis:7.4
+    ports: ["127.0.0.1:6380:6379"]
+volumes:
+  pg_data:
+````
+
+#### 4. 检查配置并启动依赖
+
+如果上一模块已经占用 5433 或 6380,先确认属于哪个独立练习栈,选择复用同一栈或停止其对应服务,不能直接杀掉未知进程。config --quiet 检查语法,不把展开后的密码配置贴进聊天或日志。
+
+文件:终端 / 当前课时
+
+````bash
+docker compose --env-file .env.compose config --quiet
+docker compose --env-file .env.compose up -d postgres redis
+docker compose --env-file .env.compose ps
+docker compose --env-file .env.compose logs --tail 50 postgres
+````
+
+#### 5. 从 API 网络位置检测服务 DNS
+
+run --rm 临时容器共享项目网络,使用 Python socket 验证 DNS 和 TCP 端口。成功只说明网络可达,不能证明账号权限或表迁移完成。
+
+文件:终端 / 当前课时
+
+````bash
+docker compose --env-file .env.compose run --rm --no-deps api python -c "import socket; print(socket.gethostbyname('postgres')); socket.create_connection(('postgres',5432),3).close(); socket.create_connection(('redis',6379),3).close(); print('tcp ok')"
+docker compose --env-file .env.compose exec postgres psql -U postgres -d notes_lab -c "SELECT rolname, rolsuper FROM pg_roles WHERE rolname='app_user';"
+````
+
+### 验收结果
+
+- API 容器可解析 postgres/redis,并连接内部 5432/6379。
+- 宿主端口仅绑定 127.0.0.1 的 8000、5433、6380。
+- app_user 的 rolsuper 为 false;管理账号密码未注入 API。
+
+### 生产约束与故障演练
+
+同机运行多个后端依赖,地址与端口必须在容器内外保持清楚。
+
+#### 实现决策
+
+- 应用用 DNS 服务名,不固定容器 IP。
+- 数据库调试端口仅回环绑定,发布环境删去不必要端口。
+
+#### 隔离环境演练
+
+只在练习 .env.compose 把主机名改为不存在名称,观察 DNS 错误后恢复,区分认证失败与网络失败。
+
+#### 恢复验收
+
+- 能够按 DNS、TCP、认证、SQL 四层定位。
+- 不同 Compose 项目资源有明确命名。
+
+### 常见错误
+
+#### API 连接 127.0.0.1:5433 被拒绝
+
+原因:把宿主访问地址用于容器内部。
+
+处理:容器 DATABASE_URL 使用 postgres:5432;宿主 psql 才使用本机 5433。
+
+#### 修改 APP_DB_PASSWORD 后认证仍失败
+
+原因:初始化脚本只在空数据目录执行,已有角色密码未改变。
+
+处理:通过受控 SQL 轮换角色密码并同步应用秘密;不能删除卷来修复认证。
+
+### 扩展练习
+
+为了让 API 访问数据库,是否必须发布 5433?
+
+提示:Compose 服务互通走内部网络。
+
+参考答案:
+
+不需要。删去 postgres 的 ports 后,API 仍能使用 postgres:5432;宿主 psql 失去直接端口入口,可通过 docker compose exec 调试。线上通常不发布数据库宿主端口。
+
+### 技术文档
+
+- [Docker:Compose 网络](https://docs.docker.com/compose/how-tos/networking/)
+- [Docker:端口发布](https://docs.docker.com/engine/network/port-publishing/)
+- [Postgres 官方镜像说明](https://hub.docker.com/_/postgres)
+
+---
+
+## 05 PostgreSQL 持久卷与可恢复备份
+
+验证重建数据库容器后数据仍存在,在独立恢复容器中验证逻辑备份。
+
+### 本节目标
+
+- 区分 named volume 与 bind mount 的权限责任
+- 验证容器重建不丢 PostgreSQL 数据
+- 制作并恢复逻辑备份,记录恢复证据
+
+### 必要概念
+
+#### 卷保存数据库目录
+
+PostgreSQL 16 官方镜像的本课持久路径为 /var/lib/postgresql/data。命名卷由 Docker 管理宿主位置,容器删除不会自动删除命名卷。不要把卷当作备份,宿主磁盘故障和误删仍会影响它。
+
+#### 权限与一致性
+
+宿主 bind mount 需要匹配数据库用户 UID/GID 与权限,直接 chmod 777 不是解决方案。运行中的 PGDATA 不能随手复制成可靠备份;用 pg_dump 做逻辑备份,物理备份需 pg_basebackup/WAL 等一致性协议。
+
+#### 恢复才验证备份
+
+备份成功退出只能证明生成了文件,不能证明恢复后应用能使用。恢复演练要用另一容器和另一卷,核对表、行数和业务查询;备份保留策略还需异机、加密、访问控制与实际 RPO/RTO。
+
+### 动手实现
+
+#### 1. 确认卷与数据路径
+
+只读检查 Compose 解析到的数据库挂载。取得容器 id 后可 inspect Mounts;不进入 Docker 内部卷目录手工改权限或编辑文件。
+
+文件:终端 / 当前课时
+
+````bash
+docker compose --env-file .env.compose ps -q postgres
+docker compose --env-file .env.compose exec postgres psql -U postgres -d notes_lab -c "SHOW data_directory;"
+docker volume ls --filter label=com.docker.compose.project=notes-lab
+````
+
+#### 2. 创建专用持久化标记并重建容器
+
+在练习库创建一张明确命名的验证表,重建仅 postgres 容器,保留 pg_data 卷。执行后查询同一行。正常生产更新仍要评估可用性与数据库版本兼容。 重建后先执行 pg_isready,返回 accepting connections 后再查询标记;尚未就绪时等待后重试,不把首次连接失败当作卷丢失。
+
+文件:终端 / 当前课时
+
+````bash
+docker compose --env-file .env.compose exec postgres psql -U postgres -d notes_lab -c "CREATE TABLE IF NOT EXISTS volume_probe (id integer PRIMARY KEY, value text NOT NULL); INSERT INTO volume_probe VALUES (1,'survives recreate') ON CONFLICT (id) DO UPDATE SET value=EXCLUDED.value;"
+docker compose --env-file .env.compose up -d --force-recreate postgres
+docker compose --env-file .env.compose exec postgres pg_isready -U postgres -d notes_lab
+# 上一步返回 accepting connections 后继续;否则等待后重试。
+docker compose --env-file .env.compose exec postgres psql -U postgres -d notes_lab -c "SELECT * FROM volume_probe;"
+````
+
+#### 3. 生成备份并从容器复制
+
+先在容器内生成自定义格式备份,再 docker compose cp,避免旧版 Windows PowerShell 重定向二进制流时损坏文件。备份含业务数据,放到已忽略的 backups 目录;不上传到公开资源。
+
+文件:终端 / 当前课时
+
+````bash
+mkdir backups
+docker compose --env-file .env.compose exec postgres pg_dump -U postgres -d notes_lab -Fc --no-owner --no-acl -f /tmp/notes_lab.dump
+docker compose --env-file .env.compose cp postgres:/tmp/notes_lab.dump ./backups/notes_lab.dump
+docker compose --env-file .env.compose exec postgres pg_restore --list /tmp/notes_lab.dump
+````
+
+#### 4. 创建独立恢复容器
+
+新建 .env.restore,只含 POSTGRES_PASSWORD=独立练习密码,加入 .gitignore。恢复容器不发布端口,使用单独 named volume;docker exec 从同容器 Unix socket 操作。等待日志 ready 后再恢复,不复用业务 pg_data。
+
+文件:终端 / 当前课时
+
+````bash
+docker volume create notes-restore-data
+docker run -d --name notes-restore --env-file .env.restore -e POSTGRES_DB=notes_restore -v notes-restore-data:/var/lib/postgresql/data postgres:16
+docker logs --tail 30 notes-restore
+docker cp ./backups/notes_lab.dump notes-restore:/tmp/notes_lab.dump
+docker exec notes-restore pg_restore -U postgres -d notes_restore --no-owner --no-acl --exit-on-error /tmp/notes_lab.dump
+````
+
+#### 5. 核对恢复并保留证据
+
+本课尚未执行第06课的应用迁移,因此只核对已创建的 volume_probe,不查询尚不存在的 notes 或 alembic_version。恢复标记应为 id=1、value=survives recreate。完成第06课迁移并创建笔记后,再重复备份和恢复流程;使用新的恢复数据库或新的专用恢复卷,避免把第二份备份叠加到非空恢复库,再比较 notes 数量和 alembic_version。此时才完成业务数据恢复验收。当前先停止恢复容器,保留恢复卷用于核查。
+
+文件:终端 / 当前课时
+
+````bash
+docker exec notes-restore psql -U postgres -d notes_restore -c "SELECT * FROM volume_probe;"
+docker stop notes-restore
+# 第06课迁移、创建笔记并重新备份到新的隔离恢复库后,再在新恢复库查询:
+# SELECT count(*) FROM notes;
+# SELECT version_num FROM alembic_version;
+````
+
+### 验收结果
+
+- 重建 postgres 容器后 volume_probe 行仍存在。
+- pg_restore --list 可读取备份结构,恢复命令退出为 0。
+- 独立恢复库包含正确的 volume_probe,原练习库未被覆盖;业务表恢复在第06课迁移后用新备份补验。
+
+### 生产约束与故障演练
+
+镜像升级必须保留数据库,宿主损坏后也应有可恢复副本。
+
+#### 实现决策
+
+- 容器和数据库卷独立管理。
+- 备份在独立环境恢复后才计入可用备份。
+
+#### 隔离环境演练
+
+重建练习数据库容器,再把备份恢复到另一卷并核对 volume_probe。完成第06课迁移后,另取新备份恢复到新的空恢复库,补验业务行数和迁移版本。
+
+#### 恢复验收
+
+- 重建不丢数据。
+- 有一次完整备份恢复的实际记录。
+
+### 常见错误
+
+#### 重建容器后数据全没了
+
+原因:未挂载持久卷、挂载路径错误,或切换了 Compose project name。
+
+处理:检查 Mounts 和卷标签,确认实际使用的 pg_data;不要在未确认旧卷前初始化覆盖或删除。
+
+#### 恢复时报 archive 格式错误
+
+原因:用不支持二进制透明传输的 Shell 重定向生成备份。
+
+处理:在容器内写 -f 文件,再 docker cp;检查备份大小和 pg_restore --list。
+
+### 扩展练习
+
+每天把 PGDATA 目录复制到同一宿主另一个文件夹能否替代备份方案?
+
+提示:分别考虑运行中一致性与宿主故障。
+
+参考答案:
+
+不能。普通文件复制可能得到不一致数据库,而且宿主故障会同时损坏原件与副本。使用数据库支持的备份协议,并保留异机副本、加密密钥与定期恢复证据。
+
+### 技术文档
+
+- [Docker:Volumes](https://docs.docker.com/engine/storage/volumes/)
+- [PostgreSQL 16:pg_dump](https://www.postgresql.org/docs/16/app-pgdump.html)
+- [PostgreSQL 16:pg_restore](https://www.postgresql.org/docs/16/app-pgrestore.html)
+
+---
+
+## 06 健康检查、启动重试与一次性迁移
+
+让 API 等待数据库可用与迁移成功,同时处理运行期间的依赖故障。
+
+### 本节目标
+
+- 区分容器运行、健康、应用就绪与迁移完成
+- 用 Compose 条件依赖组织首次启动
+- 为瞬时断连设计有界重试而不重放未知写事务
+
+### 必要概念
+
+#### running 不是 ready
+
+容器进程创建后,Postgres 可能仍在初始化,API 可能缺表。pg_isready 证明数据库接受连接,不验证 app_user 凭据或业务表。应用 /ready 执行 SQL,迁移作业另行证明表版本已到目标。
+
+#### 启动顺序只覆盖启动
+
+depends_on condition 控制 Compose 启动前置条件,不能保证依赖永远在线。运行时数据库重启仍会引发请求失败,连接池需检测失效连接,调用方需按幂等规则重试。
+
+#### 迁移是独立作业
+
+一个 migrate 容器执行 alembic upgrade head,退出码 0 才允许 API 启动。不让多个 API worker 各自执行迁移。迁移失败保留日志与旧应用,不能循环忽略失败继续接流量。
+
+### 动手实现
+
+#### 1. 增加数据库和 Redis 健康检查
+
+把以下字段分别合并到 compose.yaml 的 postgres 与 redis 服务内,不新建同名重复服务。$$ 让变量传入容器 Shell 后展开,避免 Compose 提前替换。
+
+文件:compose.yaml
+
+````yaml
+# postgres 服务内
+healthcheck:
+  test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
+  interval: 5s
+  timeout: 3s
+  retries: 12
+  start_period: 10s
+
+# redis 服务内
+healthcheck:
+  test: ["CMD", "redis-cli", "ping"]
+  interval: 5s
+  timeout: 2s
+  retries: 5
+````
+
+#### 2. 新增一次性迁移服务
+
+合并到 services 层,镜像与 API 完全相同。练习迁移使用 app_user 的 DDL 权限;生产用单独迁移凭据覆盖此服务的 DATABASE_URL,API 保持更小权限。
+
+文件:compose.yaml
+
+````yaml
+migrate:
+  image: ${NOTES_IMAGE:?set NOTES_IMAGE}
+  command: ["python", "-m", "alembic", "upgrade", "head"]
+  environment:
+    DATABASE_URL: ${DATABASE_URL:?set DATABASE_URL}
+  depends_on:
+    postgres:
+      condition: service_healthy
+  restart: "no"
+````
+
+#### 3. 为 API 添加就绪探针与条件依赖
+
+合并到已有 api 服务。urllib 已在 Python 标准库,无需额外安装 curl。/ready 是前置模块实现的数据库探针;用户自定义应用须先实现同等端点。start_period 不屏蔽真实请求,只影响健康状态统计。
+
+文件:compose.yaml
+
+````yaml
+depends_on:
+  migrate:
+    condition: service_completed_successfully
+healthcheck:
+  test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/ready',timeout=4).read()"]
+  interval: 10s
+  timeout: 5s
+  retries: 3
+  start_period: 15s
+restart: unless-stopped
+````
+
+#### 4. 首次启动并验证迁移状态
+
+up -d --wait 等待服务达到健康状态,但它不是业务验收;随后创建一条笔记并读取。migrate 是正常退出的作业,其 Exited (0) 不算服务故障。
+
+文件:终端 / 当前课时
+
+````bash
+docker compose --env-file .env.compose config --quiet
+docker compose --env-file .env.compose up -d --wait
+docker compose --env-file .env.compose ps -a
+docker compose --env-file .env.compose logs --tail 50 migrate
+docker compose --env-file .env.compose exec postgres psql -U postgres -d notes_lab -c "SELECT version_num FROM alembic_version;"
+````
+
+#### 5. 演练运行时数据库短暂停止
+
+仅在专用本机栈停止 postgres,此时 /live 应仍为 200、/ready 为 503;重启数据库后连接池 pre_ping 应逐步恢复。Docker 标记 unhealthy 本身不会自动重启容器,restart 策略主要响应进程退出。写入失败不要盲目自动重放,用幂等键确认结果。
+
+文件:终端 / 当前课时
+
+````bash
+docker compose --env-file .env.compose stop postgres
+docker compose --env-file .env.compose logs --tail 30 api
+docker compose --env-file .env.compose start postgres
+docker compose --env-file .env.compose ps
+````
+
+### 验收结果
+
+- 首次启动迁移先成功退出,API 随后 ready。
+- 迁移失败时 API 不按成功依赖启动,日志保留真实异常。
+- 数据库重启期间 readiness 失败,恢复后可创建读取数据,无隐式无限重试。
+
+### 生产约束与故障演练
+
+整套服务在新机器首次启动,以及运行期间数据库维护重启。
+
+#### 实现决策
+
+- 依赖健康、迁移完成和业务就绪分别验证。
+- 运行时重试有限,写请求按幂等协议恢复。
+
+#### 隔离环境演练
+
+停止练习 postgres,观察 /live 与 /ready 分离,恢复后验证原数据和新写入。
+
+#### 恢复验收
+
+- 共享数据库故障不会触发无意义的无限重启。
+- 启动失败能够定位到依赖、迁移或应用阶段。
+
+### 常见错误
+
+#### depends_on 后仍出现连接拒绝
+
+原因:只写了服务名依赖,没等待健康;或故障发生在启动之后。
+
+处理:首次启动使用 health 条件,运行期间通过连接恢复、超时和调用方幂等重试处理。
+
+#### 容器 unhealthy 却没有自动重启
+
+原因:把健康状态与 restart 策略混为一谈。
+
+处理:明确由部署平台或外部监控处理健康流量与重启;不要让所有实例因共享数据库故障一起重启。
+
+### 扩展练习
+
+能否把 alembic upgrade head 放进 API CMD 前面,每个副本都执行?
+
+提示:扩容时有多少个进程会同时触发 DDL?
+
+参考答案:
+
+不应。使用单独迁移步骤并保证唯一执行;多个进程同时迁移会竞争锁与版本状态。迁移成功是发布门槛,不是每个请求实例的初始化副作用。
+
+### 技术文档
+
+- [Docker:启动顺序](https://docs.docker.com/compose/how-tos/startup-order/)
+- [Docker:健康检查](https://docs.docker.com/reference/dockerfile/#healthcheck)
+- [PostgreSQL 16:pg_isready](https://www.postgresql.org/docs/16/app-pg-isready.html)
+
+---
+
+## 07 CPU、内存、进程数与优雅退出
+
+给容器设置资源边界,观察 OOM 与 SIGTERM,确保在途请求和任务有明确退出行为。
+
+### 本节目标
+
+- 设置并验证容器 CPU、内存与 PID 上限
+- 识别 OOMKilled 和普通应用退出
+- 协调停止宽限期、HTTP 排空与任务重投
+
+### 必要概念
+
+#### 限制是边界而非性能保证
+
+cpus 限制可用 CPU 时间,mem_limit 限制内存,pids_limit 限制线程与进程数量。线程也可能计入 PID 额度。限制过低会导致正常峰值失败,过高会让单服务挤压数据库与宿主;起始值需要用实际负载校准。
+
+#### SIGTERM 与 SIGKILL
+
+docker stop 通常先发 SIGTERM,等待 stop_grace_period 后再 SIGKILL。SIGTERM 允许应用停止接新请求并完成在途工作;SIGKILL 无法执行 finally。Uvicorn 的优雅关闭预算必须小于容器宽限期。
+
+#### 退出与任务一致性
+
+HTTP 排空和队列任务退出是两套行为。Celery worker 收到 TERM 可进行 warm shutdown,但容器最终可能强杀长任务。任务必须幂等,确认、可见性超时和恢复策略与异步任务模块一致,不能把 SIGTERM 当作任务绝不丢失的保证。
+
+### 动手实现
+
+#### 1. 为 API 配置资源上限
+
+将这些字段合并到 api 服务。示例 1 CPU/512MB/128 PID 是练习起点,不是生产容量结论;Dockerfile Uvicorn graceful timeout 已为 25 秒,容器给予 35 秒。
+
+文件:compose.yaml
+
+````yaml
+cpus: 1.0
+mem_limit: 512m
+pids_limit: 128
+init: true
+stop_grace_period: 35s
+logging:
+  driver: local
+  options:
+    max-size: "10m"
+    max-file: "3"
+````
+
+#### 2. 重建并核对运行限制
+
+应用配置变化需 up 重建容器,不是只 restart。stats 观察实际内存和 CPU;inspect 以 API 容器 id 读取 HostConfig,不把完整环境配置输出到公共日志。
+
+文件:终端 / 当前课时
+
+````bash
+docker compose --env-file .env.compose up -d api
+docker compose --env-file .env.compose stats --no-stream api
+docker compose --env-file .env.compose ps -q api
+# 将下面 id 替换为刚取得的 API 容器 id
+docker inspect API_CONTAINER_ID --format '{{.HostConfig.Memory}} {{.HostConfig.NanoCpus}} {{.HostConfig.PidsLimit}}'
+````
+
+#### 3. 在一次性专用容器触发 OOM
+
+此演练不使用 API 容器或数据库卷;仅分配大于 64MB 上限的内存。不要加 --rm,便于查看退出状态,检查后只删除 notes-oom-drill。
+
+文件:终端 / 当前课时
+
+````bash
+docker run --name notes-oom-drill --memory=64m --memory-swap=64m --pids-limit=32 python:3.11-slim python -c "x=bytearray(256*1024*1024); print(len(x))"
+docker inspect notes-oom-drill --format '{{.State.ExitCode}} {{.State.OOMKilled}}'
+docker rm notes-oom-drill
+````
+
+#### 4. 验证 API 接收停止信号
+
+在另一个终端持续请求 /ready,再 stop api,观察日志中应用开始关闭与 shutdown complete。正常短请求应完成,关闭后客户端连接失败属于流量需要移走的阶段。单容器 Compose 没有自动负载均衡排空能力。
+
+文件:终端 / 当前课时
+
+````bash
+docker compose --env-file .env.compose stop api
+docker compose --env-file .env.compose logs --tail 50 api
+docker compose --env-file .env.compose start api
+````
+
+#### 5. 记录资源与退出验收
+
+创建 resource-budget.md,记录实例/worker 数、连接池乘数、CPU/内存峰值及关闭耗时。任务 worker 的并发进程数和每个进程连接池也算入数据库配额。
+
+文件:resource-budget.md
+
+````text
+API:1 进程/容器;数据库池 5;内存上限 512MiB;关闭预算 25s < 容器35s
+Worker:按第9课单独配置并发;连接数按实际执行模型计算
+宿主:为 Postgres、Redis、Docker 和系统留余量
+证据:实际 stats 峰值、OOM 状态、SIGTERM 关闭耗时
+````
+
+### 验收结果
+
+- 运行容器实际 HostConfig 包含预期内存、CPU、PID 限制。
+- 一次性 OOM 容器能够通过 OOMKilled 区分资源杀死与普通异常。
+- API 在宽限期内完成关闭,没有依赖 shell 后台进程转发信号。
+
+### 生产约束与故障演练
+
+单个大请求或长任务不能耗尽宿主全部资源,部署停止也不能无限等待。
+
+#### 实现决策
+
+- 资源值先设上限再压测校准。
+- 容器停止预算大于应用优雅退出预算。
+
+#### 隔离环境演练
+
+使用无挂载专用容器触发 OOM,再对练习 API 执行 SIGTERM,分别记录原因与恢复。
+
+#### 恢复验收
+
+- OOM 不影响数据库卷。
+- 关闭和任务恢复行为有明确记录。
+
+### 常见错误
+
+#### 退出码 137 就断言一定 OOM
+
+原因:137 也可能由其他 SIGKILL 来源导致。
+
+处理:同时检查 OOMKilled、宿主内核日志、容器事件与部署停止超时。
+
+#### 容器停止后任务重复执行
+
+原因:任务在完成业务后、确认前被强杀,队列再次投递。
+
+处理:按任务幂等与业务唯一约束处理重投,调整任务时限和退出预算;不要为了避免重复提前确认并忽略丢失风险。
+
+### 扩展练习
+
+每容器 4 个 API worker 是否一定比 1 个更快?
+
+提示:同时计算 CPU、内存与数据库连接。
+
+参考答案:
+
+不一定。4 个进程增加内存和连接池总数,受 1 CPU 限制时可能只增加调度开销。用实际等待与计算比例评估;容器水平扩容和 worker 数必须一起预算。
+
+### 技术文档
+
+- [Docker:资源限制](https://docs.docker.com/engine/containers/resource_constraints/)
+- [Docker:Compose 服务资源字段](https://docs.docker.com/reference/compose-file/services/)
+- [Docker:stop](https://docs.docker.com/reference/cli/docker/container/stop/)
+- [Uvicorn:设置](https://uvicorn.dev/settings/)
+
+---
+
+## 08 配置、Secrets 与容器运行权限
+
+将连接秘密从 Compose 环境输出中移走,收紧应用可写目录与 Linux capabilities,并建立依赖扫描流程。
+
+### 本节目标
+
+- 通过文件向应用注入秘密并验证权限
+- 限制运行容器的写入与提权能力
+- 读取扫描结果并形成可验证的修复步骤
+
+### 必要概念
+
+#### 环境变量与秘密文件
+
+环境变量方便但常出现在 inspect、诊断和进程信息中。Compose secrets 将文件挂载到 /run/secrets,应用必须主动读取;环境变量 NAME_FILE 不是通用自动功能,只有支持它的程序会处理。
+
+#### 本地 Compose 的边界
+
+本地 secrets 通常来自宿主文件挂载,不等同 Swarm 的加密分发。宿主源文件和备份仍需权限控制,拥有 Docker daemon 管理权限的人可访问容器与挂载。应用不用 Docker socket,不把日常用户随意加入 docker 组。
+
+#### 扫描与修复
+
+镜像扫描报告依赖和系统包漏洞,是定位输入,不是应用已安全的证明。确认运行路径与受影响版本,更新基础 digest/锁文件、重建并回归。不要在运行容器里删除库来“清零”扫描数字。
+
+### 动手实现
+
+#### 1. 让配置支持文件秘密
+
+替换 app/config.py 中 Settings 的 database_url 声明并扩展 model_config,下面是该部分的完整写法;保留此前连接池字段。secret 目录中的 database_url 文件提供值,环境变量同名值优先,因此从 api 服务移除旧 DATABASE_URL environment 项。
+
+文件:app/config.py
+
+````python
+from pydantic import Field
+from pydantic_settings import BaseSettings, SettingsConfigDict
+
+class Settings(BaseSettings):
+    model_config = SettingsConfigDict(env_file=".env", extra="ignore", secrets_dir="/run/secrets")
+    database_url: str = Field(min_length=1)
+    db_pool_size: int = Field(default=5, ge=1, le=50)
+    db_max_overflow: int = Field(default=0, ge=0, le=50)
+    db_pool_timeout: float = Field(default=2, gt=0, le=30)
+
+settings = Settings()
+````
+
+#### 2. 建立本机秘密文件
+
+在已忽略的 secrets 目录用编辑器新建 database_url,填写 postgres:5432 的容器连接串。以下为 Linux / WSL 的本地 file-backed secrets 方案:父目录由当前可信宿主账户持有且权限700,单个绑定文件644,使容器UID10001可读。其他普通宿主账户无法穿过700父目录;有Docker控制权限的账户仍属于受信任管理员。不能仅写uid/gid/mode并假设Compose会重映射文件权限。
+
+文件:终端 / 当前课时
+
+````bash
+mkdir -p secrets
+# 用编辑器创建 secrets/database_url,填实际练习连接串。
+chmod 700 secrets
+chmod 644 secrets/database_url
+# 此处只调整明确的单个绑定文件;不把父目录改成公开可进入。
+````
+
+#### 3. 挂载秘密并检查实际权限
+
+在 compose.yaml 顶层新增 secrets;在 api/migrate 的服务中挂载 secrets: [database_url] 并移除 DATABASE_URL 环境值。file-backed secrets实际采用绑定文件权限,不能依赖Compose的uid/gid/mode映射。后续任务URL和broker配置也放在同一受控目录,逐一授予容器读取,并在启动正式进程前用临时容器验证。
+
+文件:compose.yaml
+
+````yaml
+# compose.yaml 顶层
+secrets:
+  database_url:
+    file: ./secrets/database_url
+
+# api 与 migrate 各自服务内添加
+# secrets:
+#   - database_url
+````
+
+#### 4. 收紧 API 运行权限
+
+合并到 api 服务并重建镜像以包含新的配置模块。根文件系统只读,临时目录使用有界 tmpfs;app 不写本地持久文件。Celery 需要的调度状态在第 9 课单独处理。
+
+文件:compose.yaml
+
+````yaml
+read_only: true
+tmpfs:
+  - /tmp:size=64m,mode=1777
+cap_drop: [ALL]
+security_opt:
+  - no-new-privileges:true
+````
+
+#### 5. 验证只读、秘密和依赖扫描
+
+先重建镜像,随后用 command 覆盖应用启动命令,只读取秘密文件并输出布尔值。该检查使用镜像默认非root UID10001;成功后才启动API,避免先因Settings读取失败退出再尝试exec。只读写入检查应报Read-only file system,是本步骤预期结果。Docker Scout若已安装授权可执行cves,否则记录未执行原因,不声称扫描通过。
+
+文件:终端 / 当前课时
+
+````bash
+docker build --build-arg PYTHON_IMAGE="${PYTHON_IMAGE:?set verified base digest}" -t notes-service:lab-02 .
+docker compose --env-file .env.compose run --rm --no-deps api python -c "from pathlib import Path; assert Path('/run/secrets/database_url').read_text().strip(); print('secret readable')"
+# 仅当前置检查成功后继续;失败时先修正文件所有者/权限和挂载。
+docker compose --env-file .env.compose up -d api
+docker compose --env-file .env.compose exec api python -c "from pathlib import Path; Path('/app/should_fail').write_text('x')"
+docker scout cves notes-service:lab-02
+````
+
+### 验收结果
+
+- API 配置可读取挂载秘密,检查时未打印连接串。
+- /app 写入失败,/tmp 可以写入且有容量上限。
+- 扫描结果或未执行原因有记录,修复通过基础镜像/锁文件重建完成。
+
+### 生产约束与故障演练
+
+诊断日志和镜像交付不应携带数据库密码,应用应只有必需的文件写入权限。
+
+#### 实现决策
+
+- 秘密只在运行时挂载,宿主源文件访问受控。
+- 非 root、只读根文件系统和去除 capabilities 一起验证。
+
+#### 隔离环境演练
+
+在本机尝试向 /app 写入,预期拒绝;仍能读取秘密并执行 /ready。
+
+#### 恢复验收
+
+- 权限收紧不破坏正常 API。
+- 秘密没有进入镜像层与共享日志。
+
+### 常见错误
+
+#### 加了 DATABASE_URL_FILE 但应用仍缺配置
+
+原因:应用没有实现读取该变量指向文件的逻辑。
+
+处理:采用本课 Pydantic secrets_dir 文件名 database_url,或显式实现并测试 _FILE 读取。
+
+#### 只读 rootfs 后应用启动失败
+
+原因:程序尝试写字节码、缓存、PID 或调度文件。
+
+处理:禁用字节码并为必要临时写入配置 tmpfs;持久调度状态使用单独卷,不把整个根文件系统改回可写。
+
+### 扩展练习
+
+把 .env 复制进镜像后下一层删除,是否已清除秘密?
+
+提示:镜像保留每层的文件变更。
+
+参考答案:
+
+没有。旧层仍可能恢复内容。轮换已泄露秘密,重新构建不含该层的新镜像,检查镜像仓库与构建日志中的副本;正常构建使用运行时秘密或 BuildKit secret mount。
+
+### 技术文档
+
+- [Docker:Compose Secrets](https://docs.docker.com/compose/how-tos/use-secrets/)
+- [Docker:构建秘密](https://docs.docker.com/build/building/secrets/)
+- [Pydantic Settings:Secrets](https://docs.pydantic.dev/latest/concepts/pydantic_settings/#secrets)
+- [Docker Scout:CVE 分析](https://docs.docker.com/scout/quickstart/)
+
+---
+
+## 09 API、Worker 与队列的 Compose 整合
+
+使用同一镜像运行不同进程角色,把任务执行、调度和 HTTP 服务分开部署。
+
+### 本节目标
+
+- 部署异步任务模块真实 tasks_lab 工程与 exports.fast/bulk 队列
+- 为 API、worker、publisher、recovery 分别配置进程与资源
+- 隔离缓存 Redis 与带认证的任务 broker,并验证持久业务状态
+
+### 必要概念
+
+#### 同镜像不同职责
+
+API 接收请求并提交任务,worker 消费任务,beat 周期调度;它们可以来自同一代码镜像,但生命周期、并发与资源不同。不把 worker 后台进程塞进 API 容器,也不在每个 API 副本内启动周期调度器。
+
+#### 任务边界
+
+复用异步任务模块第 11–16 课最终 tasks_lab 代码:celery_app.py 暴露 app,export_tasks.py 注册 notes.export,参数为 job_id。export_jobs/export_outbox 是业务真源,publisher.py 和 recovery.py 目前是单步脚本。不要部署第 8 课的小快照过渡任务,也不要假定旧 api-lab 下载包已有这些文件。
+
+#### Redis 隔离
+
+本地 Redis /0 与 /1 可以区分 key 空间,不能隔离内存、CPU、故障与驱逐策略。生产缓存与消息代理通常使用独立实例及策略;队列持久化和 ack 机制也不能单独构成端到端任务不丢失保证。
+
+### 动手实现
+
+#### 1. 复制完整最终任务工程并统一依赖
+
+把异步任务模块完成后的 tasks_lab 目录放入 notes-service 根目录,保留 celery_app.py、export_tasks.py、state_store.py、publisher.py、recovery.py 及其全部实际依赖模块。把其 requirements-tasks-lock.txt 所需包合并进 requirements.in,重新生成目标平台哈希锁。Dockerfile 在 USER 前加入以下 COPY;不要用本课创建一个同名的假业务 worker 替代原任务。
+
+文件:Dockerfile
+
+````dockerfile
+COPY tasks_lab ./tasks_lab
+COPY deploy/run_role.py ./deploy/run_role.py
+````
+
+#### 2. 为任务角色建立秘密加载入口
+
+新建 deploy/run_role.py。API 的 SQLAlchemy URL 用 postgresql+psycopg://,任务模块直接 psycopg.connect 使用 postgresql://,两者分别保存。入口从三个只读秘密文件读入实际环境变量再 exec 目标进程,不输出秘密;读取 /run/secrets/task_database_url、celery_broker_url、celery_result_backend。
+
+文件:deploy/run_role.py
+
+````python
+import os
+import sys
+from pathlib import Path
+
+for name, variable in {
+    "task_database_url": "DATABASE_URL",
+    "celery_broker_url": "CELERY_BROKER_URL",
+    "celery_result_backend": "CELERY_RESULT_BACKEND",
+}.items():
+    value = Path("/run/secrets", name).read_text().strip()
+    if not value:
+        raise RuntimeError(f"empty secret: {name}")
+    os.environ[variable] = value
+if len(sys.argv) < 2:
+    raise RuntimeError("missing process command")
+os.execvp(sys.argv[1], sys.argv[1:])
+````
+
+#### 3. 配置独立认证 broker 与秘密文件
+
+新增 broker 服务,不复用缓存 redis。宿主6384为任务专用,6380–6383已由Redis课程其他隔离实例使用。用强随机 TASK_BROKER_PASSWORD 在编辑器生成秘密文件:task_broker_conf 包含 requirepass、appendonly yes、appendfsync everysec、maxmemory 192mb、maxmemory-policy noeviction、dir /data;celery_broker_url 为 redis://:URL编码密码@broker:6379/0,celery_result_backend 为同地址/1;task_database_url 为 postgresql://app_user:URL编码密码@postgres:5432/notes_lab。三个URL文件由task服务读取;broker配置只读挂载,不提交到仓库。 本地file-backed secrets按第8课使用宿主受控父目录700、单秘密文件644,使UID10001可读取实际挂载;启动前用run --no-deps检查可读性且不打印内容。TASK_BROKER_PASSWORD建议本机生成高熵十六进制值,使Redis配置requirepass行无需额外引号转义。
+
+文件:compose.yaml
+
+````yaml
+# services 中新增;本机调试端口仅回环开放
+broker:
+  image: redis:7.4
+  command: ["redis-server", "/run/secrets/task_broker_conf"]
+  ports: ["127.0.0.1:6384:6379"]
+  secrets: [task_broker_conf]
+  volumes: ["broker_data:/data"]
+  mem_limit: 256m
+  restart: unless-stopped
+
+# 顶层 volumes 新增 broker_data: {}
+# 顶层 secrets 新增以下条目(与已有 database_url 并列)
+# task_broker_conf: {file: ./secrets/task_broker_conf}
+# task_database_url: {file: ./secrets/task_database_url}
+# celery_broker_url: {file: ./secrets/celery_broker_url}
+# celery_result_backend: {file: ./secrets/celery_result_backend}
+````
+
+#### 4. 添加真实 fast Worker 与 bulk Worker
+
+新增 worker 服务,worker-bulk 使用相同字段,仅 command 的队列改 exports.bulk、节点名改 bulk@%h。任务模块使用相对 Path("artifacts"),工作目录下挂载 /app/tasks_lab/artifacts 命名卷并初始化给UID10001;同机共享输出供恢复/下载使用,跨宿主生产改对象存储。API仅提供笔记接口时不伪称已实现任务提交HTTP端点,先用任务模块 submit/publisher 流程验收。
+
+文件:compose.yaml
+
+````yaml
+worker:
+  image: ${NOTES_IMAGE:?set NOTES_IMAGE}
+  working_dir: /app/tasks_lab
+  command: ["python", "/app/deploy/run_role.py", "python", "-m", "celery", "-A", "celery_app:app", "worker", "-l", "INFO", "-Q", "exports.fast", "--concurrency=1", "-n", "fast@%h"]
+  secrets: [task_database_url, celery_broker_url, celery_result_backend]
+  volumes: ["artifacts_data:/app/tasks_lab/artifacts"]
+  depends_on:
+    broker: {condition: service_started}
+    migrate: {condition: service_completed_successfully}
+  cpus: 1.0
+  mem_limit: 512m
+  pids_limit: 128
+  init: true
+  stop_grace_period: 75s
+  restart: unless-stopped
+  read_only: true
+  tmpfs: ["/tmp:size=64m,mode=1777"]
+  cap_drop: [ALL]
+  security_opt: ["no-new-privileges:true"]
+
+# 顶层 volumes 新增 artifacts_data: {}
+````
+
+#### 5. 包装有界 publisher 与 recovery 循环
+
+新建 tasks_lab/run_loops.py。publisher 每次 publish_one() 最多发送一条,recovery 每次 recover_batch()最多50条,循环设置固定间隔并响应TERM;函数自身数据库/网络超时仍需保持任务模块的约束,不能靠事件替代I/O超时。出现异常记录类别并退避。分别创建publisher/recovery服务,沿用worker环境、秘密和工作目录,command 改成 python /app/deploy/run_role.py python run_loops.py publisher 或 recovery;它们不启动 Celery worker。
+
+文件:tasks_lab/run_loops.py
+
+````python
+import logging
+import signal
+import sys
+from threading import Event
+from publisher import publish_one
+from recovery import recover_batch
+
+stop = Event()
+signal.signal(signal.SIGTERM, lambda *_: stop.set())
+signal.signal(signal.SIGINT, lambda *_: stop.set())
+functions = {"publisher": (publish_one, 0.5), "recovery": (recover_batch, 5)}
+operation, interval = functions[sys.argv[1]]
+while not stop.is_set():
+    try:
+        operation()
+    except Exception as exc:
+        logging.error("task maintenance failed: %s", type(exc).__name__)
+        if stop.wait(5):
+            break
+    stop.wait(interval)
+````
+
+#### 6. 初始化任务表、输出权限并构建
+
+将任务课程的 export_jobs/export_outbox/死信/容量/周期等最终DDL纳入本项目Alembic迁移,先在空练习库验证;不要认为notes迁移自动创建这些任务表。挂载卷首次创建为root时,使用仅针对artifacts_data挂载点的一次性初始化作业 chown 10001:10001 /app/tasks_lab/artifacts,并在worker启动前完成;不可递归修改宿主未知目录。构建新标签,更新 .env.compose NOTES_IMAGE,再启动并核对注册任务和队列。
+
+文件:终端 / 当前课时
+
+````bash
+docker build -t notes-service:lab-03 .
+# 编辑 .env.compose:NOTES_IMAGE=notes-service:lab-03
+docker compose --env-file .env.compose run --rm --no-deps worker python -c "from pathlib import Path; names=['task_database_url','celery_broker_url','celery_result_backend']; assert all(Path('/run/secrets',n).read_text().strip() for n in names); print('secrets readable')"
+docker compose --env-file .env.compose run --rm --user 0 --cap-add CHOWN --entrypoint sh worker -c 'chown 10001:10001 /app/tasks_lab/artifacts'
+docker compose --env-file .env.compose run --rm migrate
+docker compose --env-file .env.compose up -d worker worker-bulk publisher recovery
+docker compose --env-file .env.compose exec worker python /app/deploy/run_role.py python -m celery -A celery_app:app inspect registered
+docker compose --env-file .env.compose logs --tail 50 worker worker-bulk publisher recovery
+````
+
+#### 7. 按真实业务链路验收与故障恢复
+
+先在notes服务创建至少一条练习笔记,然后以下命令从真实notes表选择一条并调用最终state_store.submit_snapshot;它已包含job/outbox原子写入,不调用export_notes.delay。记录返回job_id。publisher将事件路由到exports.fast/bulk,worker写artifacts,最终运行任务第16课verify_terminal.py核对文件与固定快照。暂停worker后提交应queued,恢复后完成。beat仅在第14课周期任务完成后单实例部署。
+
+文件:终端 / 当前课时
+
+````bash
+docker compose --env-file .env.compose stop worker worker-bulk
+docker compose --env-file .env.compose run --rm --no-deps worker python /app/deploy/run_role.py python -c "from state_store import connect,submit_snapshot; conn=connect(); row=conn.execute('SELECT id FROM notes ORDER BY id LIMIT 1').fetchone(); conn.close(); assert row, 'create a note first'; print(submit_snapshot([row['id']]))"
+docker compose --env-file .env.compose start worker worker-bulk
+docker compose --env-file .env.compose exec postgres psql -U postgres -d notes_lab -c "SELECT id,status,result_ref,attempts FROM export_jobs ORDER BY created_at DESC LIMIT 10;"
+# 用真实返回的UUID替换JOB_ID;任务完成后验证artifact
+docker compose --env-file .env.compose exec worker python /app/deploy/run_role.py python verify_terminal.py JOB_ID
+````
+
+### 验收结果
+
+- Celery inspect 注册 notes.export,fast/bulk worker 只消费对应队列。
+- PostgreSQL中job/outbox到最终状态与可读取的输出文件一致,不能只凭Celery结果缓存。
+- 缓存redis与认证broker是不同实例;宿主任务端口6384,容器内部broker:6379。
+
+### 生产约束与故障演练
+
+API 快速返回任务已提交,worker 可以独立扩缩容和更新。
+
+#### 实现决策
+
+- 任务执行资源与 API 分配分开。
+- 队列状态重要时建立持久业务记录与可恢复投递流程。
+
+#### 隔离环境演练
+
+在独立练习栈暂停broker后提交持久job,核对job/outbox保留;恢复broker和publisher后worker完成且同一业务结果不被重复执行覆盖。
+
+#### 恢复验收
+
+- 真实tasks_lab入口、队列、环境变量和文件路径一致。
+- publisher/recovery有独立受控循环,业务状态与输出经数据库核对。
+
+### 常见错误
+
+#### 扩容 API 后周期任务重复发送
+
+原因:每个 API 容器都启动了 beat 或本地定时器。
+
+处理:beat 单独部署单实例或使用有领导选举的调度方案,任务仍需幂等。
+
+#### worker 配了更多并发,数据库连接数翻倍
+
+原因:每个执行进程建立自己的池,遗漏 fork 与连接生命周期。
+
+处理:按进程模型重新预算,在子进程创建/重置连接资源;不继承已建立连接。
+
+### 扩展练习
+
+为什么不能把postgresql+psycopg://连接串原样交给任务模块的psycopg.connect?
+
+提示:区分SQLAlchemy方言URL与PostgreSQL驱动DSN。
+
+参考答案:
+
+API使用SQLAlchemy URL;任务state_store直接使用psycopg.connect,需要postgresql://或libpq键值DSN。因此配置分成API database_url与task_database_url,端口和账号相同但协议前缀不同。
+
+### 技术文档
+
+- [Celery:Worker 管理](https://docs.celeryq.dev/en/stable/userguide/workers.html)
+- [Celery:Redis 注意事项](https://docs.celeryq.dev/en/stable/getting-started/backends-and-brokers/redis.html)
+- [Redis:持久化](https://redis.io/docs/latest/operate/oss_and_stack/management/persistence/)
+
+---
+
+## 10 镜像版本、发布回滚与故障验收
+
+用不可混淆的版本标签与镜像摘要发布,保留数据库兼容窗口并验证回退行为。
+
+### 本节目标
+
+- 记录并部署明确的镜像版本或摘要
+- 执行迁移在前、流量切换在后的发布流程
+- 区分应用回滚、数据库回滚与数据恢复
+
+### 必要概念
+
+#### 发布的是已验证镜像
+
+本机 image ID 与仓库 digest 是不同概念;推送后以仓库名@sha256 选择不可变发布物。标签可便于阅读但可能被覆盖,不能只保存 latest。预发布和生产使用同一已验证摘要,避免在每台机器重建出不同依赖。
+
+#### Compose 的可用性边界
+
+单机单副本 Compose 更新通常会有短暂中断,不承诺零停机。需要平滑切换时部署两个版本后由代理切换,或者使用具备滚动与就绪能力的平台;数据库迁移仍须兼容新旧版本。
+
+#### 回滚不等于删数据
+
+旧镜像能否回退取决于当前数据库结构是否兼容。新增列通常可保留供旧版忽略,删除/重命名/数据语义改变可能阻止回退。不能为了退应用版本就清空数据库卷或机械执行 downgrade。
+
+### 动手实现
+
+#### 1. 记录当前可回退版本
+
+发布前保存当前 NOTES_IMAGE、迁移版本、配置版本和必要备份。只记录秘密文件的版本标识或来源,不把内容复制进 release-record.md。
+
+文件:release-record.md
+
+````text
+版本:notes-service:release-2026-09-10-01 或实际仓库 digest
+源码提交:实际 SHA
+当前迁移 revision:实际 alembic current
+目标 revision:待发布版本所需 revision
+兼容范围:旧应用可读取/写入的新结构范围
+备份与恢复验证:文件标识、时间、恢复证据
+回退触发:例如连续5分钟错误率超目标;须先定义观测窗口
+````
+
+#### 2. 构建、标记并本机验收
+
+构建使用第 3 课已验证基础 digest。用户拥有并授权的镜像仓库才可执行登录与推送;课件提供命令模板,不实际向外发布。标签中使用真实版本或 commit,不重复覆盖旧发布标签。 以下命令在Bash/WSL运行,PYTHON_IMAGE必须是第03课保存的完整摘要;开启新终端时先恢复已审核的变量,不能静默回退到浮动标签。
+
+文件:终端 / 当前课时
+
+````bash
+docker build --build-arg PYTHON_IMAGE="${PYTHON_IMAGE:?set verified base digest}" -t notes-service:release-01 .
+docker run --rm notes-service:release-01 python -c "import fastapi, sqlalchemy, psycopg; print('imports ok')"
+# 已具备目标仓库权限后,替换 REGISTRY/PROJECT 为真实仓库:
+# docker tag notes-service:release-01 REGISTRY/PROJECT/notes-service:release-01
+# docker push REGISTRY/PROJECT/notes-service:release-01
+# docker buildx imagetools inspect REGISTRY/PROJECT/notes-service:release-01
+````
+
+#### 3. 先运行唯一迁移作业
+
+更新 .env.compose 的 NOTES_IMAGE 为目标版本,先在预发布演练下面命令。发布时 run --rm migrate 执行当前目标镜像迁移;API 仍可维持旧版,前提是迁移经过 expand-contract 兼容审核。迁移失败即停止后续发布。
+
+文件:终端 / 当前课时
+
+````bash
+docker compose --env-file .env.compose run --rm migrate
+docker compose --env-file .env.compose up -d --no-deps api worker worker-bulk publisher recovery
+docker compose --env-file .env.compose ps
+docker compose --env-file .env.compose logs --tail 100 api worker worker-bulk publisher recovery
+````
+
+#### 4. 验证业务再决定继续或回退
+
+检查 /ready、创建读取、旧版本冲突、幂等重试和 真实导出任务;记录实际 HTTP 与数据库结果。失败时先将 NOTES_IMAGE 恢复上一兼容版本,再 up --no-deps api worker,不回退 Redis/Postgres 镜像主版本,也不自动操作数据库迁移。
+
+文件:终端 / 当前课时
+
+````bash
+# 将 .env.compose 的 NOTES_IMAGE 恢复到已记录的上一兼容版本后:
+docker compose --env-file .env.compose up -d --no-deps api worker worker-bulk publisher recovery
+docker compose --env-file .env.compose exec api python -c "import urllib.request; print(urllib.request.urlopen('http://127.0.0.1:8000/ready',timeout=4).status)"
+````
+
+#### 5. 完成隔离故障矩阵
+
+在本机专用栈逐项执行,保存预期与实测。数据库数据使用迁移和备份处理,不以删卷作为回滚。超出本机实测范围的远程仓库、TLS、负载均衡明确标记未验证。
+
+文件:终端 / 当前课时
+
+````text
+场景 | 观测 | 恢复动作
+错误数据库密码 | readiness失败,认证日志 | 修正秘密并重建API;不删卷
+错误应用镜像 | API启动失败,旧版可用性记录 | 切回兼容镜像
+worker停止 | 队列积压,API仍可提交 | 启动worker,核对任务去重
+OOM演练 | 专用容器OOMKilled | 调整预算并检查宿主余量
+迁移失败 | migrate退出非0 | 停止发布,修复迁移或锁阻塞
+数据库容器重建 | 卷保留,原笔记可读 | 等待ready并执行业务探针
+````
+
+### 验收结果
+
+- 发布记录包含真实镜像标识、迁移版本和回退边界。
+- 迁移失败不会继续切换 API 与 worker。
+- 回退应用镜像后业务探针通过,数据库卷和已提交数据保留。
+
+### 生产约束与故障演练
+
+一次发布必须能够定位软件版本,并在已声明兼容范围内回退。
+
+#### 实现决策
+
+- 构建一次并以明确版本交付。
+- 应用回退与数据库恢复分开决策,保持数据卷。
+
+#### 隔离环境演练
+
+在本机发布一个故意缺配置的应用版本,确认错误后回到上一镜像并核对既有数据。
+
+#### 恢复验收
+
+- 有实际回退耗时和业务恢复结果。
+- 未验证的仓库、远程部署和高可用能力明确标记。
+
+### 常见错误
+
+#### latest 回退后仍运行同一坏版本
+
+原因:标签被覆盖,无法指明旧镜像内容。
+
+处理:保存不可变版本标签与仓库摘要,部署时以明确摘要选择。
+
+#### 应用回退成功但读数据失败
+
+原因:新迁移删除或改变了旧应用依赖的字段。
+
+处理:发布前验证兼容窗口;不可兼容时执行修复版本或已验证恢复方案,不机械 downgrade。
+
+### 扩展练习
+
+怎样证明“回滚方案可用”,而非只写一句“退回旧版本”?
+
+提示:在同一新数据库结构上运行旧镜像并发请求。
+
+参考答案:
+
+在隔离预发布库先执行新迁移,再部署旧镜像执行读写与幂等探针,记录结果和不可兼容点。若旧版写入破坏新语义,回退窗口应关闭,改用前向修复。
+
+### 技术文档
+
+- [Docker:镜像标签](https://docs.docker.com/reference/cli/docker/image/tag/)
+- [Docker:镜像摘要](https://docs.docker.com/dhi/core-concepts/digests/)
+- [Docker:Compose up](https://docs.docker.com/reference/cli/docker/compose/up/)

+ 1625 - 0
public/downloads/linux-lessons.md

@@ -0,0 +1,1625 @@
+# Linux 部署
+
+术核 AI · 后端工程课件
+
+## 01 SSH、账户与服务目录
+
+在独立 Ubuntu 24.04 练习虚拟机上建立管理入口与低权限运行账户。
+
+### 本节目标
+
+- 核对 SSH 目标与主机密钥指纹
+- 区分登录管理账号与不可登录服务账号
+- 建立发布目录和可写业务目录的权限边界
+
+### 必要概念
+
+#### 练习环境必须明确
+
+本模块只在自己控制、可从控制台恢复的独立 Ubuntu 24.04 VM 执行。示例主机 notes-vm、IP 与域名均需替换成真实练习目标;不在已有生产主机套用整段账户、防火墙和服务配置。先保存VM快照并确认带外控制台可用。
+
+#### 两个账号的职责
+
+deploy 用 SSH 登录并执行经过核对的 sudo 操作,notes 只运行应用,shell 为 nologin,不保存 SSH 私钥。应用被利用后不应具备替换代码、修改 systemd 单元或读取其他服务秘密的权限。
+
+#### 目录所有权
+
+/srv/notes-service/releases 保存不可由运行用户修改的版本;current 指向当前版本;shared 保存需要跨版本保留的导出文件。代码、配置和业务输出分开授权,不能对整个 /srv 递归 chmod 777。
+
+### 动手实现
+
+#### 1. 确认系统与目标
+
+通过虚拟机控制台或已验证的管理 SSH 会话执行。记录 hostname、Ubuntu 版本和 SSH 主机公钥指纹,再从本机连接时对照。不要用 StrictHostKeyChecking=no 隐藏主机变化。
+
+文件:终端 / 当前课时
+
+````bash
+hostnamectl
+cat /etc/os-release
+sudo ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub
+whoami
+````
+
+#### 2. 在本机生成独立登录密钥
+
+在自己的电脑执行,并使用口令保护私钥。路径为本次练习新文件,若已存在不要覆盖。将 .pub 公钥交给VM管理员安装,私钥不复制到服务器。
+
+文件:终端 / 当前课时
+
+````bash
+ssh-keygen -t ed25519 -f ~/.ssh/notes_lab_ed25519 -C notes-lab
+# 核对指纹后连接;VM_IP替换成实际练习地址
+# 完成下一步deploy账户与authorized_keys配置后,才执行:
+# ssh -i ~/.ssh/notes_lab_ed25519 deploy@VM_IP
+````
+
+#### 3. 在虚拟机建立管理和运行账户
+
+使用现有管理账号执行,adduser deploy 会交互设置管理账号。用编辑器将刚生成的公钥内容写入 /home/deploy/.ssh/authorized_keys,再设置文件权限;不要粘贴私钥。notes 无交互登录权限。
+
+文件:终端 / 当前课时
+
+````bash
+sudo adduser deploy
+sudo usermod -aG sudo deploy
+sudo install -d -o deploy -g deploy -m 700 /home/deploy/.ssh
+sudoedit /home/deploy/.ssh/authorized_keys
+sudo chown deploy:deploy /home/deploy/.ssh/authorized_keys
+sudo chmod 600 /home/deploy/.ssh/authorized_keys
+sudo useradd --system --user-group --home-dir /srv/notes-service --shell /usr/sbin/nologin notes
+````
+
+#### 4. 建立明确目录边界
+
+releases 只有管理员可写,notes 组可读和进入;shared/artifacts 给运行账号写。部署文件后仍需核对权限,不能因为目录存在就把所有文件设可执行。
+
+文件:终端 / 当前课时
+
+````bash
+sudo install -d -o root -g notes -m 750 /srv/notes-service
+sudo install -d -o root -g notes -m 750 /srv/notes-service/releases
+sudo install -d -o root -g notes -m 750 /srv/notes-service/shared
+sudo install -d -o notes -g notes -m 750 /srv/notes-service/shared/artifacts
+sudo -u notes test -w /srv/notes-service/shared/artifacts
+sudo -u notes test ! -w /srv/notes-service/releases
+````
+
+#### 5. 验证第二个会话再收紧 SSH
+
+保留原管理会话和控制台,从本机新开 deploy 密钥会话并运行 sudo -v。成功后才考虑在 /etc/ssh/sshd_config.d/60-notes-lab.conf 写入下面配置。先 sshd -t,再 reload;已有配置和云初始化可能覆盖行为,sshd -T 检查实际值。 第二个会话使用 ssh -i ~/.ssh/notes_lab_ed25519 deploy@VM_IP;账户和公钥在前一步已配置好。
+
+文件:/etc/ssh/sshd_config.d/60-notes-lab.conf
+
+````text
+# /etc/ssh/sshd_config.d/60-notes-lab.conf
+PubkeyAuthentication yes
+PasswordAuthentication no
+PermitRootLogin no
+
+# 保存后在原管理会话执行
+# sudo sshd -t
+# sudo systemctl reload ssh
+# sudo sshd -T | rg 'passwordauthentication|permitrootlogin'
+````
+
+### 验收结果
+
+- 从新终端能用独立密钥登录 deploy 并执行 sudo -v。
+- notes 不能交互登录,不能修改 releases,但可写 shared/exports。
+- SSH 配置先校验再 reload,保留原会话和控制台恢复路径。
+
+### 生产约束与故障演练
+
+服务长期运行在远程主机,必须保留可靠管理入口并限制应用写权限。
+
+#### 实现决策
+
+- 管理账号与服务账号分离。
+- SSH变更先验证新会话,再关闭旧入口。
+
+#### 隔离环境演练
+
+在隔离VM故意给一份测试authorized_keys设置错误权限,观察新会话失败,用保留会话修复;不对真实唯一入口试错。
+
+#### 恢复验收
+
+- 密钥指纹与权限有记录。
+- 账户错误可通过预留入口恢复。
+
+### 常见错误
+
+#### 修改 SSH 后新连接失败
+
+原因:公钥权限错误、配置顺序覆盖或改动未经新会话验证。
+
+处理:使用保留会话或控制台恢复该练习配置,检查 auth 日志与 sshd -T,再用新终端验证。
+
+#### 服务账号能改 Dockerfile 或 systemd 单元
+
+原因:代码和系统配置错误归运行用户所有。
+
+处理:将发布物与单元归 root 管理,只允许 notes 写特定输出目录。
+
+### 扩展练习
+
+为什么不能让 Web 服务直接使用 deploy 账号运行?
+
+提示:deploy 具备管理能力与登录凭据。
+
+参考答案:
+
+应用漏洞会扩大到发布权限或 sudo 能力。独立 notes 账号将运行权限限制在服务需要的配置、网络和输出目录,deploy 只用于受控部署。
+
+### 技术文档
+
+- [Ubuntu:OpenSSH](https://ubuntu.com/server/docs/how-to/security/openssh-server/)
+- [OpenSSH:ssh-keygen](https://man.openbsd.org/ssh-keygen)
+- [Ubuntu:用户管理](https://documentation.ubuntu.com/server/how-to/security/user-management/)
+
+---
+
+## 02 监听地址、DNS 与防火墙
+
+从进程监听到云网络逐层检查连通性,在开启防火墙前验证 SSH 保留与回退。
+
+### 本节目标
+
+- 区分服务监听、主机防火墙与云安全组
+- 使用DNS和TCP检查定位访问失败
+- 安全地测试UFW规则并保留SSH会话
+
+### 必要概念
+
+#### 网络路径有多层
+
+客户端先解析DNS,连接公网或私网IP,经云安全组/路由到主机防火墙,再到监听端口。服务没监听时放开防火墙没有作用;DNS解析正确也不能证明端口可达。
+
+#### 公开端口最小化
+
+Nginx对外提供80/443,API只监听127.0.0.1:8000;PostgreSQL与Redis继续使用本机回环调试端口或受控私网。Docker发布端口可能经过不同于普通INPUT的转发规则,不能假定UFW会拦截所有Docker ports。
+
+#### 防止锁死
+
+SSH端口可能不是22,来源IP也可能经过VPN或跳板机。启用UFW前读取实际SSH监听、添加保留规则、准备限时自动回退,并保留当前会话和控制台。关闭旧会话不是验证新规则的方法。
+
+### 动手实现
+
+#### 1. 核对当前监听与路由
+
+在VM执行只读命令;没有rg则用grep。识别真实SSH端口与监听地址,后续规则使用实际值,不盲目套22。
+
+文件:终端 / 当前课时
+
+````bash
+sudo ss -lntp
+ip -brief address
+ip route
+sudo ufw status verbose
+````
+
+#### 2. 检查域名解析与本机端点
+
+尚无域名时用VM IP或本机Host头完成后续HTTP课程。notes.example.com为文档占位,不能注册证书。DNS A与AAAA都应指向能提供服务的地址,错误AAAA可能导致部分用户访问失败。
+
+文件:终端 / 当前课时
+
+````bash
+getent ahosts notes.example.com
+# API启动后再执行:
+curl --connect-timeout 2 --max-time 5 -i http://127.0.0.1:8000/ready
+````
+
+#### 3. 先添加SSH和HTTP规则
+
+仅在已确认SSH为22且有控制台的独立VM执行。若使用非22端口,修改下列规则。已有UFW规则不要 reset;先阅读并保留其他必要访问。自动回退任务3分钟后关闭UFW,只用于这次隔离练习。
+
+文件:终端 / 当前课时
+
+````bash
+sudo ufw allow 22/tcp comment 'keep verified SSH'
+sudo ufw allow 80/tcp comment 'HTTP and ACME'
+sudo ufw allow 443/tcp comment 'HTTPS'
+sudo systemd-run --unit=notes-firewall-rollback --on-active=3m /usr/sbin/ufw disable
+sudo ufw default deny incoming
+sudo ufw default allow outgoing
+sudo ufw enable
+````
+
+#### 4. 用新会话验证后取消回退
+
+保持旧会话,在本机另开SSH并执行一个只读命令。成功后检查UFW状态,取消尚未执行的回退timer;若失败,不继续收紧规则,使用保留会话或等待自动回退。
+
+文件:终端 / 当前课时
+
+````bash
+# 新SSH会话成功后执行
+sudo ufw status numbered
+sudo systemctl stop notes-firewall-rollback.timer
+sudo systemctl status notes-firewall-rollback.timer --no-pager
+````
+
+#### 5. 核对Docker与云侧规则
+
+若依赖运行在Docker,docker ps检查端口只能为127.0.0.1:5433、缓存6380、任务broker6384。云安全组按本练习仅开放已验证SSH和80/443;此课不代替在真实云账号创建规则。
+
+文件:终端 / 当前课时
+
+````bash
+docker ps --format 'table {{.Names}}	{{.Ports}}'
+sudo ss -lntp
+# 从另一台获准练习客户端检测VM的80/443;数据库端口不应对公网开放。
+````
+
+### 验收结果
+
+- 新SSH会话在UFW启用后成功,回退timer已确认取消。
+- API/数据库/Redis监听地址与外部公开端口列表一致。
+- DNS、TCP、HTTP失败可以分层描述,未对数据库开放公网入口。
+
+### 生产约束与故障演练
+
+只公开HTTPS入口,数据库与队列保留在内网或本机。
+
+#### 实现决策
+
+- 防火墙变更保留已验证SSH路径与自动回退。
+- 主机和云网络规则分别核对,Docker端口单独检查。
+
+#### 隔离环境演练
+
+在隔离VM启用规则并从新会话验证;若配置错误,使用定时回退或控制台恢复。
+
+#### 恢复验收
+
+- 没有失去管理入口。
+- 公开端口清单符合服务架构。
+
+### 常见错误
+
+#### 本机curl成功,外部浏览器超时
+
+原因:云安全组、主机防火墙或监听绑定地址阻断。
+
+处理:按路径逐层检查,不同时关闭全部安全规则掩盖问题。
+
+#### UFW拒绝端口但Docker服务仍能被外部访问
+
+原因:Docker发布端口涉及NAT/转发规则。
+
+处理:优先取消公开发布或绑定回环,按Docker官方防火墙集成文档核对实际路径。
+
+### 扩展练习
+
+域名同时有A和AAAA记录,只检查IPv4是否足够?
+
+提示:客户端可能优先选择IPv6。
+
+参考答案:
+
+不够。需要验证两种地址都可到达正确服务;未配置IPv6服务时移除错误AAAA,不能让部分用户随机遇到超时。
+
+### 技术文档
+
+- [Ubuntu:防火墙](https://ubuntu.com/server/docs/security-firewall/)
+- [Docker:防火墙与UFW](https://docs.docker.com/engine/network/packet-filtering-firewalls/)
+- [systemd-run:临时任务(Ubuntu 24.04)](https://manpages.ubuntu.com/manpages/noble/man1/systemd-run.1.html)
+
+---
+
+## 03 Python 环境、配置与最小权限
+
+把验证过的源码安装到独立发布目录,使用系统支持的Python与专用环境文件启动。
+
+### 本节目标
+
+- 在发布目录创建独立venv并安装锁定依赖
+- 区分API与任务驱动的数据库连接串
+- 让运行账号只读代码和配置,单独写输出
+
+### 必要概念
+
+#### Ubuntu默认Python版本
+
+Ubuntu24.04通常提供Python3.12,满足课程Python3.11+要求;不要为了匹配旧命令替换系统/usr/bin/python3。若必须精确3.11,使用经过组织批准的运行时或Docker3.11镜像,并分别生成目标版本依赖锁。
+
+#### 部署输入完整
+
+发布物要包含app、迁移、锁文件和最终tasks_lab,而不是旧api-lab下载包。任务模块的DDL需纳入迁移并在空库验证,任务入口为tasks_lab/celery_app.py中的app,队列为exports.fast/bulk。
+
+#### 环境文件不是Shell脚本
+
+systemd EnvironmentFile读取KEY=value,不执行$(command)或source逻辑。API使用SQLAlchemy URL,任务直接psycopg使用PostgreSQL DSN,分别存文件。秘密文件root管理,运行用户通过所属组只读,诊断时不输出全文。
+
+### 动手实现
+
+#### 1. 安装Ubuntu提供的运行依赖
+
+只在独立VM安装包;升级已有生产主机应走维护流程。本模块进程主线使用Ubuntu默认python3,不安装到系统site-packages。若数据库继续使用Docker,宿主只安装postgresql-client用于检查与备份。
+
+文件:终端 / 当前课时
+
+````bash
+sudo apt update
+sudo apt install -y python3 python3-venv nginx postgresql-client curl rsync
+python3 --version
+````
+
+#### 2. 部署已验证发布物
+
+将本地完成的notes-service源码包上传到deploy自己的staging目录,先校验文件清单与摘要。不要以root解压不明tar。下列命令假定~/staging/notes-service已是经检查的目录;r001是本次练习明确版本,不能覆盖已有目录。
+
+文件:终端 / 当前课时
+
+````bash
+sudo install -d -o root -g notes -m 750 /srv/notes-service/releases/r001
+sudo rsync -a --exclude=.env --exclude='.env.*' --exclude=.venv --exclude=secrets --exclude=backups ~/staging/notes-service/ /srv/notes-service/releases/r001/
+sudo chown -R root:notes /srv/notes-service/releases/r001
+sudo chmod -R u=rwX,g=rX,o= /srv/notes-service/releases/r001
+sudo python3 -m venv /srv/notes-service/releases/r001/.venv
+sudo /srv/notes-service/releases/r001/.venv/bin/python -m pip install --require-hashes -r /srv/notes-service/releases/r001/requirements-lock.txt
+````
+
+#### 3. 建立两个专用环境文件
+
+用sudoedit创建下列文件,填VM实际依赖地址与URL编码密码。若沿用Docker练习依赖:PG宿主5433,缓存6380,任务broker6384;若迁移到受控私网数据库按实际地址改,绝不能在主机进程中使用Docker服务名postgres/broker。
+
+文件:终端 / 当前课时
+
+````dotenv
+# /etc/notes-service.env(API)
+DATABASE_URL=postgresql+psycopg://app_user:ENCODED_PASSWORD@127.0.0.1:5433/notes_lab
+DB_POOL_SIZE=5
+DB_MAX_OVERFLOW=0
+DB_POOL_TIMEOUT=2
+
+# /etc/notes-tasks.env(tasks_lab)
+DATABASE_URL=postgresql://app_user:ENCODED_PASSWORD@127.0.0.1:5433/notes_lab
+CELERY_BROKER_URL=redis://:ENCODED_TASK_BROKER_PASSWORD@127.0.0.1:6384/0
+CELERY_RESULT_BACKEND=redis://:ENCODED_TASK_BROKER_PASSWORD@127.0.0.1:6384/1
+````
+
+#### 4. 限制配置和输出权限
+
+先确认文件是刚编辑的两个目标,再设置所有权。任务工程默认相对artifacts目录改为指向shared/artifacts的软链;已有目录若包含结果先核对并迁移,不能直接rm -rf。新发布物应不包含旧导出文件。
+
+文件:终端 / 当前课时
+
+````bash
+sudo chown root:notes /etc/notes-service.env /etc/notes-tasks.env
+sudo chmod 640 /etc/notes-service.env /etc/notes-tasks.env
+sudo ln -s /srv/notes-service/shared/artifacts /srv/notes-service/releases/r001/tasks_lab/artifacts
+sudo ln -s releases/r001 /srv/notes-service/current
+sudo -u notes test -r /etc/notes-service.env
+sudo -u notes test ! -w /srv/notes-service/releases/r001/app/main.py
+sudo -u notes test -w /srv/notes-service/shared/artifacts
+````
+
+#### 5. 检查环境与导入
+
+不source秘密文件到共享交互终端。下面只验证包和文件路径;带环境启动由下一课systemd读取。若依赖锁与Python版本不兼容,返回目标Linux/Python版本重新生成并验证锁,不去掉固定版本强行安装。
+
+文件:终端 / 当前课时
+
+````bash
+sudo -u notes /srv/notes-service/current/.venv/bin/python -c "import sys, fastapi, psycopg; print(sys.version); print(fastapi.__version__, psycopg.__version__)"
+sudo -u notes readlink -f /srv/notes-service/current
+sudo namei -l /srv/notes-service/current/app/main.py
+````
+
+### 验收结果
+
+- API与任务环境文件使用不同DSN前缀,宿主端口与实际依赖一致。
+- notes可读配置但不能写发布代码,导出路径指向shared。
+- venv来自当前VM支持的Python,锁定依赖安装和导入成功。
+
+### 生产约束与故障演练
+
+服务器重启后进程需要用确定的解释器、代码和秘密恢复。
+
+#### 实现决策
+
+- 发布目录不可由服务修改。
+- API与任务配置分开,秘密不进入源码包。
+
+#### 隔离环境演练
+
+用notes账号尝试写代码文件应失败,写shared/exports应成功;误配DSN在隔离环境修复后再启动。
+
+#### 恢复验收
+
+- 部署输入和权限边界可复现。
+- 无全局pip安装和明文秘密日志。
+
+### 常见错误
+
+#### 直接psycopg报invalid connection option或scheme
+
+原因:将SQLAlchemy的postgresql+psycopg://交给直接驱动。
+
+处理:任务环境使用postgresql://,API保留SQLAlchemy方言URL。
+
+#### systemd找不到包但登录终端能导入
+
+原因:服务未使用venv解释器,或发布路径不一致。
+
+处理:ExecStart指定绝对venv Python路径,不依赖交互shell激活。
+
+### 扩展练习
+
+为什么不能在服务器执行sudo pip install -U覆盖系统Python依赖?
+
+提示:Ubuntu工具也可能依赖系统Python。
+
+参考答案:
+
+这样会污染系统包管理和其他应用。每个发布目录使用独立venv,升级形成新发布物,系统Python由发行版包管理维护。
+
+### 技术文档
+
+- [Python:venv](https://docs.python.org/3/library/venv.html)
+- [Ubuntu:Python开发环境](https://documentation.ubuntu.com/ubuntu-for-developers/howto/python-setup/)
+- [systemd:EnvironmentFile(Ubuntu 24.04)](https://manpages.ubuntu.com/manpages/noble/man5/systemd.exec.5.html)
+
+---
+
+## 04 systemd 管理 API、Worker 与维护进程
+
+把服务交给systemd启动、重启和终止,明确任务循环与单实例调度器。
+
+### 本节目标
+
+- 编写可验证的systemd单元
+- 分别管理API、fast/bulk worker、publisher/recovery
+- 设置重启节流、资源限制和SIGTERM退出预算
+
+### 必要概念
+
+#### 进程监督
+
+systemd知道主进程PID、退出码和重启次数,可以开机启动并收集日志。Type=simple服务应前台运行,不nohup、不后台&。Wants network-online不是数据库一定可用,应用仍需有界连接超时。
+
+#### 真实任务入口
+
+worker工作目录为current/tasks_lab,命令python -m celery -A celery_app:app;fast/bulk分别订阅exports.fast与exports.bulk。publisher.py/recovery.py本身只执行一次,需要Docker模块提供的run_loops.py或同等有界循环,不能将一次性脚本伪称常驻服务。
+
+#### 重启与资源
+
+Restart=on-failure适合异常退出重启,StartLimit限制崩溃循环。MemoryMax、CPUQuota、TasksMax是cgroup边界,不是性能承诺。Celery warm shutdown可能等待当前任务,TimeoutStopSec应覆盖已定义的任务最大时长,仍需恢复租约处理强杀。
+
+### 动手实现
+
+#### 1. 新建API服务单元
+
+保存到/etc/systemd/system/notes-api.service。使用Uvicorn内置进程主线,未采用已弃用的uvicorn.workers。API只监听回环,代理头仅信任本机Nginx。数据库迁移不放进此单元ExecStartPre。
+
+文件:/etc/systemd/system/notes-api.service
+
+````ini
+[Unit]
+Description=notes-service API
+Wants=network-online.target
+After=network-online.target
+StartLimitIntervalSec=60
+StartLimitBurst=5
+
+[Service]
+Type=simple
+User=notes
+Group=notes
+WorkingDirectory=/srv/notes-service/current
+EnvironmentFile=/etc/notes-service.env
+Environment=PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
+ExecStart=/srv/notes-service/current/.venv/bin/python -m uvicorn app.main:app --host 127.0.0.1 --port 8000 --proxy-headers --forwarded-allow-ips=127.0.0.1 --timeout-graceful-shutdown 25
+Restart=on-failure
+RestartSec=3
+TimeoutStopSec=35
+KillSignal=SIGTERM
+MemoryMax=512M
+CPUQuota=100%
+TasksMax=128
+NoNewPrivileges=true
+PrivateTmp=true
+ProtectSystem=strict
+ProtectHome=true
+UMask=0027
+
+[Install]
+WantedBy=multi-user.target
+````
+
+#### 2. 新建fast/bulk Worker模板
+
+保存notes-worker@.service,实例参数fast和bulk映射两个既定队列。共享输出需要ReadWritePaths;先验证共享目录存在。实际任务上限若超过60秒,应先调整任务协议与退出预算,不照抄75秒。
+
+文件:/etc/systemd/system/notes-worker@.service
+
+````ini
+[Unit]
+Description=notes-service %i export worker
+Wants=network-online.target
+After=network-online.target
+StartLimitIntervalSec=60
+StartLimitBurst=5
+
+[Service]
+Type=simple
+User=notes
+Group=notes
+WorkingDirectory=/srv/notes-service/current/tasks_lab
+EnvironmentFile=/etc/notes-tasks.env
+Environment=PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
+ExecStart=/srv/notes-service/current/.venv/bin/python -m celery -A celery_app:app worker -l INFO -Q exports.%i --concurrency=1 -n %i@%%h
+Restart=on-failure
+RestartSec=5
+TimeoutStopSec=75
+KillSignal=SIGTERM
+KillMode=mixed
+MemoryMax=768M
+CPUQuota=100%
+TasksMax=128
+NoNewPrivileges=true
+PrivateTmp=true
+ProtectSystem=strict
+ProtectHome=true
+ReadWritePaths=/srv/notes-service/shared/artifacts
+UMask=0027
+
+[Install]
+WantedBy=multi-user.target
+````
+
+#### 3. 为publisher/recovery创建独立模板
+
+需要Docker第9课已给出的tasks_lab/run_loops.py,或复制那份受控循环到发布物。模板传publisher/recovery选择单步函数;不把维护任务与worker塞入同进程。EnvironmentFile仍为tasks专用,日志仅记录异常类别与关联id。
+
+文件:/etc/systemd/system/notes-maintenance@.service
+
+````ini
+[Unit]
+Description=notes-service %i loop
+Wants=network-online.target
+After=network-online.target
+StartLimitIntervalSec=60
+StartLimitBurst=5
+
+[Service]
+Type=simple
+User=notes
+Group=notes
+WorkingDirectory=/srv/notes-service/current/tasks_lab
+EnvironmentFile=/etc/notes-tasks.env
+Environment=PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
+ExecStart=/srv/notes-service/current/.venv/bin/python run_loops.py %i
+Restart=on-failure
+RestartSec=5
+TimeoutStopSec=20
+MemoryMax=256M
+TasksMax=64
+NoNewPrivileges=true
+ProtectSystem=strict
+ProtectHome=true
+PrivateTmp=true
+UMask=0027
+
+[Install]
+WantedBy=multi-user.target
+````
+
+#### 4. 可选新增单实例beat
+
+只有完成任务模块周期任务后才启用。沿用Worker单元但Description改为notes-service beat、ExecStart替换如下、移除原ReadWritePaths并加StateDirectory=notes-service。保存独立notes-beat.service,只运行一个实例;本地调度文件不是分布式领导选举。
+
+文件:/etc/systemd/system/notes-beat.service
+
+````ini
+ExecStart=/srv/notes-service/current/.venv/bin/python -m celery -A celery_app:app beat -l INFO --schedule=/var/lib/notes-service/celerybeat-schedule
+StateDirectory=notes-service
+ReadWritePaths=/var/lib/notes-service
+````
+
+#### 5. 验证配置并按顺序启动
+
+先完成数据库模块和任务模块最终DDL迁移,数据库及认证broker必须可达。systemd-analyze verify可发现字段/路径问题,不能代替真实数据库验收;readiness与真实任务完成后再enable开机启动。
+
+文件:终端 / 当前课时
+
+````bash
+sudo systemd-analyze verify /etc/systemd/system/notes-api.service /etc/systemd/system/notes-worker@.service /etc/systemd/system/notes-maintenance@.service
+sudo systemctl daemon-reload
+sudo systemctl start notes-api notes-worker@fast notes-worker@bulk notes-maintenance@publisher notes-maintenance@recovery
+sudo systemctl status notes-api notes-worker@fast --no-pager
+curl --fail --max-time 5 http://127.0.0.1:8000/ready
+sudo systemctl enable notes-api notes-worker@fast notes-worker@bulk notes-maintenance@publisher notes-maintenance@recovery
+````
+
+### 验收结果
+
+- API和各任务进程有独立unit、PID与日志,退出原因可查询。
+- Celery节点名展开正确,fast/bulk只消费对应队列。
+- SIGTERM后进程在预算内退出,超过预算的业务任务按租约恢复协议处理。
+
+### 生产约束与故障演练
+
+主机重启与应用崩溃后,各角色需要按各自预算恢复运行。
+
+#### 实现决策
+
+- 监督前台主进程,迁移独立执行。
+- 任务维护循环和调度器与worker分开,beat单实例。
+
+#### 隔离环境演练
+
+在独立VM正常stop/start API与一组worker,核对日志、任务状态和恢复时间。
+
+#### 恢复验收
+
+- 开机启动可验证。
+- 重启不会无界循环或静默丢失业务状态。
+
+### 常见错误
+
+#### systemd启动立即报找不到celery_app
+
+原因:WorkingDirectory未指向tasks_lab或源文件未部署。
+
+处理:核对最终任务工程路径及绝对venv,不能靠登录终端PYTHONPATH临时修复。
+
+#### 节点名出现错误或systemd拒绝ExecStart
+
+原因:systemd把%h当成自身specifier展开。
+
+处理:传给Celery的%h需在unit中写%%h;%i保留用于systemd实例名。
+
+### 扩展练习
+
+数据库短暂故障时要不要让API进程退出,以便systemd不断重启?
+
+提示:所有实例依赖同一数据库时会发生什么?
+
+参考答案:
+
+通常不应把readiness失败直接变成所有进程退出。API保持可观测并返回503,修复依赖后恢复;只有进程自身无法继续工作才退出。重启策略设置节流,避免掩盖真实故障。
+
+### 技术文档
+
+- [systemd:service(Ubuntu 24.04)](https://manpages.ubuntu.com/manpages/noble/man5/systemd.service.5.html)
+- [systemd:资源控制(Ubuntu 24.04)](https://manpages.ubuntu.com/manpages/noble/man5/systemd.resource-control.5.html)
+- [Celery:Worker关闭](https://docs.celeryq.dev/en/stable/userguide/workers.html)
+- [Uvicorn:部署](https://uvicorn.dev/deployment/)
+
+---
+
+## 05 Nginx HTTP 入口与 TLS
+
+先把回环API接到Nginx,再在拥有域名与DNS控制权时配置受信任HTTPS。
+
+### 本节目标
+
+- 建立可验证的HTTP反向代理配置
+- 设置请求体与上游超时边界
+- 在明确域名与证书前置条件下配置TLS续期
+
+### 必要概念
+
+#### 代理负责公网连接
+
+Nginx终止客户端连接,将HTTP转发到127.0.0.1:8000。API不直接公开端口,转发Host与真实协议;只有可信代理可以影响应用识别的客户端地址与HTTPS状态。
+
+#### 错误发生的层级
+
+502通常表示上游连接或协议失败,504通常表示等待上游超时,413来自请求体限制。应结合Nginx error log与API request_id判断,不能看到所有5xx都重启数据库。
+
+#### 证书需要真实控制权
+
+notes.example.com是占位名,不能申请证书。只有持有实际域名、DNS正确指向练习主机且验证路径可达时,才使用Certbot完成ACME。没有这些条件时先用HTTP本机探针学习代理,不关闭客户端TLS校验伪造完成。
+
+### 动手实现
+
+#### 1. 新建站点配置
+
+保存/etc/nginx/sites-available/notes-service。在独立VM确认没有同名站点或80端口冲突;server_name先用文档占位配合本机Host验证,正式申请证书前替换为自己持有的域名。
+
+文件:/etc/nginx/sites-available/notes-service
+
+````nginx
+server {
+    listen 80;
+    server_name notes.example.com;
+    client_max_body_size 2m;
+    access_log /var/log/nginx/notes-access.log;
+    error_log /var/log/nginx/notes-error.log warn;
+
+    location / {
+        proxy_pass http://127.0.0.1:8000;
+        proxy_http_version 1.1;
+        proxy_set_header Host $host;
+        proxy_set_header X-Real-IP $remote_addr;
+        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+        proxy_set_header X-Forwarded-Proto $scheme;
+        proxy_connect_timeout 3s;
+        proxy_read_timeout 30s;
+        proxy_send_timeout 30s;
+    }
+}
+````
+
+#### 2. 校验再加载站点
+
+只新建本次站点软链,不删除现有默认或其他用户站点。nginx -t失败时不reload,修复配置后重试。用Host头选中本次server块,验证/ready与/notes。
+
+文件:终端 / 当前课时
+
+````bash
+sudo ln -s /etc/nginx/sites-available/notes-service /etc/nginx/sites-enabled/notes-service
+sudo nginx -t
+sudo systemctl reload nginx
+curl --fail --max-time 5 -H 'Host: notes.example.com' http://127.0.0.1/ready
+sudo tail -n 30 /var/log/nginx/notes-error.log
+````
+
+#### 3. 具备真实域名后申请证书
+
+此步骤需要实际域名控制权和公网验证条件,本课不替用户注册域名或申请证书。按Certbot官网Ubuntu/Nginx当前安装说明准备工具,替换YOUR_DOMAIN后执行;先验证DNS与HTTP访问。签发会联系外部CA并修改站点配置,应在自己的练习域名操作。
+
+文件:终端 / 当前课时
+
+````bash
+# 前提已完成:真实域名DNS/80端口验证、Certbot安装、Nginx配置校验
+sudo certbot --nginx -d YOUR_DOMAIN
+sudo nginx -t
+sudo certbot certificates
+````
+
+#### 4. 验证TLS与续期
+
+使用真实域名而不是curl -k检查证书主机名、链与有效期。检查系统中的续期timer,执行dry-run验证续期流程;测试必须保留ACME挑战访问或对应DNS验证能力。
+
+文件:终端 / 当前课时
+
+````bash
+curl --fail --max-time 10 https://YOUR_DOMAIN/ready
+sudo certbot renew --dry-run
+systemctl list-timers --all | grep -E 'certbot|snap.certbot'
+````
+
+#### 5. 演练上游失败并确认日志
+
+仅在隔离VM停API,Nginx应返回网关错误,同时error log报告上游连接失败;启动API后恢复。不要把异常SQL、密码或Authorization记录进Nginx自定义日志。
+
+文件:终端 / 当前课时
+
+````bash
+sudo systemctl stop notes-api
+curl --max-time 5 -i -H 'Host: notes.example.com' http://127.0.0.1/ready
+sudo tail -n 20 /var/log/nginx/notes-error.log
+sudo systemctl start notes-api
+````
+
+### 验收结果
+
+- HTTP代理能到达回环API,nginx -t通过。
+- 真实域名条件具备时,HTTPS无需跳过校验且续期dry-run成功;否则明确未配置TLS。
+- 停API产生可定位的502类上游错误,恢复后/ready成功。
+
+### 生产约束与故障演练
+
+用户通过HTTPS访问API,证书和代理错误必须持续可观测。
+
+#### 实现决策
+
+- 应用只信任本机代理,公网由Nginx统一入口。
+- 证书申请与续期都验证,缺域名条件时明确未完成项。
+- 本例笔记接口用于隔离练习,尚无完整用户身份与数据归属校验。TLS只加密传输;投入真实业务前必须补齐身份、操作权限、数据归属和审计,并验证未授权请求无法读写。
+
+#### 隔离环境演练
+
+停止练习API观察Nginx错误,再恢复;对自己域名执行续期dry-run并记录结果。
+
+#### 恢复验收
+
+- 代理错误能定位到上游层。
+- TLS状态不靠忽略证书错误通过。
+
+### 常见错误
+
+#### Nginx默认欢迎页而非API
+
+原因:请求Host没有匹配server_name,或站点链接未加载。
+
+处理:用nginx -T在可信终端定位server块,检查Host和软链;不要随意删除所有默认配置。
+
+#### 证书首次申请成功,数月后过期
+
+原因:续期任务未运行或验证路径被防火墙/代理改动阻断。
+
+处理:检查timer、续期日志与dry-run,证书剩余天数加入监控。
+
+### 扩展练习
+
+把client_max_body_size调到1GB能否解决所有上传需求?
+
+提示:应用、代理、磁盘和超时仍有各自限制。
+
+参考答案:
+
+不能。应按业务定义大小、流式处理和对象存储上传方案,避免单请求在API内存中聚集大文件;代理限制必须与应用校验和资源预算一致。
+
+### 技术文档
+
+- [Nginx:反向代理](https://nginx.org/en/docs/http/ngx_http_proxy_module.html)
+- [Nginx:HTTPS配置](https://nginx.org/en/docs/http/configuring_https_servers.html)
+- [Certbot:Nginx安装与续期](https://certbot.eff.org/instructions?ws=nginx&os=snap)
+
+---
+
+## 06 SSE、WebSocket 与代理超时
+
+为长连接选择独立代理设置,验证SSE逐条到达和WebSocket协议升级。
+
+### 本节目标
+
+- 关闭SSE响应缓冲并区分请求缓冲
+- 正确传递WebSocket Upgrade与Connection
+- 协调心跳、代理空闲超时和就绪排空
+
+### 必要概念
+
+#### SSE是持续HTTP响应
+
+服务端不断发送text/event-stream,若代理缓冲,用户会很晚才看到一整块内容。proxy_buffering off影响响应,proxy_request_buffering影响请求上传,二者不能互相替代。心跳间隔应小于经过的各层空闲超时。
+
+#### WebSocket需要显式升级
+
+Upgrade和Connection是逐跳头,反向代理需显式传递。上游返回101后转为双向隧道。HTTP的普通30秒响应预算不适用于长连接,但无限超时会隐藏失联客户端,仍需ping/pong和断线重连。
+
+#### 就绪与已建立连接
+
+从负载均衡摘除实例通常只阻止新连接,已有SSE/WS不会自动搬到另一进程。发布需要排空窗口、合理的连接寿命和客户端重连/续传。不要在长流期间持续占用数据库事务。
+
+### 动手实现
+
+#### 1. 明确本课端点前置
+
+API+数据库模块本身没有SSE/WS。先将API服务开发模块完成的流式与WebSocket示例路由整合到notes-service,确认实际路径;下文使用/stream与/ws作为本次教学适配路径,若原代码不同需同时改代理和客户端,不能声称下载包自动有端点。
+
+文件:终端 / 当前课时
+
+````bash
+# 先绕过Nginx验证实际应用端点;服务必须已经整合这些路由
+curl -N --max-time 10 http://127.0.0.1:8000/stream
+# WebSocket用API模块已有客户端连接 ws://127.0.0.1:8000/ws
+````
+
+#### 2. 在http上下文新增Upgrade映射
+
+Ubuntu默认nginx.conf的http块会include /etc/nginx/conf.d/*.conf。新建此文件仅定义map,不包额外http块;nginx -t确认上下文正确。
+
+文件:/etc/nginx/conf.d/notes-upgrade.conf
+
+````nginx
+map $http_upgrade $connection_upgrade {
+    default upgrade;
+    '' close;
+}
+````
+
+#### 3. 给SSE路径增加独立location
+
+将此location添加到本次notes-service的server块,和已有location /并列。代理重写规则保持路径原样;示例读取空闲超时75s需配合应用每15–25秒心跳,实际周期以API模块实现为准。
+
+文件:/etc/nginx/sites-available/notes-service
+
+````nginx
+location /stream {
+    proxy_pass http://127.0.0.1:8000;
+    proxy_http_version 1.1;
+    proxy_set_header Host $host;
+    proxy_set_header X-Forwarded-Proto $scheme;
+    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+    proxy_buffering off;
+    proxy_cache off;
+    proxy_read_timeout 75s;
+    proxy_send_timeout 75s;
+    gzip off;
+}
+````
+
+#### 4. 给WebSocket路径增加升级配置
+
+同样添加到server块。TLS站点客户端使用wss://。proxy_read_timeout衡量两次读取之间的间隔,不是整条连接绝对存活时间;应用层需要心跳和会话过期规则。
+
+文件:/etc/nginx/sites-available/notes-service
+
+````nginx
+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_set_header Host $host;
+    proxy_set_header X-Forwarded-Proto $scheme;
+    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+    proxy_read_timeout 75s;
+    proxy_send_timeout 75s;
+}
+````
+
+#### 5. 验证逐条到达与断线恢复
+
+先nginx -t再reload;curl -N避免客户端输出缓冲混淆。使用API课已有WS客户端确认101和双向消息,然后在隔离VM重启API,记录客户端如何重连。SSE若没有event id/续传实现,应明确重连不能恢复已漏事件。
+
+文件:终端 / 当前课时
+
+````bash
+sudo nginx -t
+sudo systemctl reload nginx
+curl -N --max-time 20 -H 'Host: notes.example.com' http://127.0.0.1/stream
+# 另一个终端观察本次站点日志与客户端重连
+sudo journalctl -u notes-api -f
+````
+
+### 验收结果
+
+- SSE通过Nginx仍逐条到达,未被缓冲成结束时一次输出。
+- WebSocket成功返回101并双向传输,TLS环境使用wss。
+- 重启时长连接会断开且客户端有明确恢复行为,未宣称无损切换。
+
+### 生产约束与故障演练
+
+Agent流式结果和交互会话经过反向代理后仍应及时、可恢复地传输。
+
+#### 实现决策
+
+- SSE与WS按各自协议配置,不套普通短请求超时。
+- 客户端恢复协议与代理排空共同设计。
+
+#### 隔离环境演练
+
+在隔离VM通过代理建立流和WS,再重启API,记录最后收到事件、断线时间与重连结果。
+
+#### 恢复验收
+
+- 逐条传输有实际观测。
+- 没有把连接重连等同业务状态自动恢复。
+
+### 常见错误
+
+#### 服务端日志逐条输出,浏览器最后才看到全部内容
+
+原因:Nginx或其他代理/客户端缓冲。
+
+处理:逐层绕过代理对比,设置proxy_buffering off并用curl -N;检查上游应用是否正确发送SSE边界。
+
+#### WS连接约60秒断开
+
+原因:代理空闲超时与应用心跳不匹配。
+
+处理:测量实际心跳周期,协调Nginx和前置负载均衡超时,不能只无限延长一层。
+
+### 扩展练习
+
+readiness变为503后,已有WebSocket是否自动迁移到健康实例?
+
+提示:TCP连接状态绑定原进程。
+
+参考答案:
+
+不会。需要客户端断线重连到健康实例;若要续接会话,状态和事件序号必须保存在可共享存储中,而非只在单进程内存。
+
+### 技术文档
+
+- [Nginx:WebSocket代理](https://nginx.org/en/docs/http/websocket.html)
+- [Nginx:响应缓冲](https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_buffering)
+- [Nginx:读取超时](https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_read_timeout)
+
+---
+
+## 07 发布目录、原子切换与兼容回退
+
+用新版本目录准备完整环境,先迁移后切换current,并明确单实例中断和蓝绿切换边界。
+
+### 本节目标
+
+- 在独立版本目录准备和验证发布物
+- 通过原子软链替换选择新版本
+- 判断数据库兼容性和在途任务是否允许回退
+
+### 必要概念
+
+#### 目录切换不替换运行进程
+
+current软链原子替换只影响之后打开的路径,已经运行的Python仍持有旧代码、工作目录和内存。必须明确重启API与各任务进程,不能以软链已更新推断服务已更新。
+
+#### 迁移先兼容
+
+新迁移在切换前运行,旧进程仍可能访问数据库,所以结构必须向后兼容。任务名和消息参数也属于跨版本协议:旧消息可能在新worker上执行,新消息可能被旧worker拿到,升级时必须保留兼容或分版本队列。
+
+#### 可用性选择
+
+单API实例systemctl restart有短暂中断。本课先完成可回退的单实例发布;要平滑切换可在8001启动候选实例、验收后改Nginx upstream并reload,再排空旧实例。长连接仍需要重连协议,不能称为绝对零中断。
+
+### 动手实现
+
+#### 1. 准备r002完整目录
+
+按第3课创建新目录r002、复制已验证源码、建立venv并安装目标锁,创建artifacts软链。不要覆盖r001,也不要在current目录边写代码边处理请求。下面只检查新目录满足条件。 deploy不加入运行组,路径检查通过sudo -u notes执行,保持发布权限与运行身份分开。
+
+文件:终端 / 当前课时
+
+````bash
+sudo -u notes test -f /srv/notes-service/releases/r002/app/main.py
+sudo -u notes test -f /srv/notes-service/releases/r002/tasks_lab/celery_app.py
+sudo -u notes test -x /srv/notes-service/releases/r002/.venv/bin/python
+sudo -u notes readlink -f /srv/notes-service/current
+````
+
+#### 2. 用一次性systemd作业运行迁移
+
+仅对专用练习数据库执行已审核迁移;EnvironmentFile使用API SQLAlchemy URL,迁移账号若已分离应改为独立/etc/notes-migrate.env。systemd-run --wait会报告进程结果;失败不继续切换。先保存当前revision与备份记录。
+
+文件:终端 / 当前课时
+
+````bash
+sudo systemd-run --unit=notes-migrate-r002 --wait --collect   --property=User=notes --property=Group=notes   --property=WorkingDirectory=/srv/notes-service/releases/r002   --property=EnvironmentFile=/etc/notes-service.env   /srv/notes-service/releases/r002/.venv/bin/python -m alembic upgrade head
+````
+
+#### 3. 原子替换current并重启
+
+在/srv/notes-service同一文件系统内先创建临时软链,再mv -T替换current。先检查current确实是软链且指向已记录的r001,禁止覆盖普通目录。root拥有该目录,运行用户不能劫持切换目标。
+
+文件:终端 / 当前课时
+
+````bash
+sudo -u notes test -L /srv/notes-service/current
+sudo -u notes readlink -f /srv/notes-service/current
+sudo ln -s releases/r002 /srv/notes-service/current.next
+sudo mv -Tf /srv/notes-service/current.next /srv/notes-service/current
+sudo systemctl restart notes-api notes-worker@fast notes-worker@bulk notes-maintenance@publisher notes-maintenance@recovery
+curl --fail --max-time 5 http://127.0.0.1:8000/ready
+````
+
+#### 4. 验证新版本并演练应用回退
+
+使用真实创建读取、版本冲突和持久导出链路验收。若触发预先定义的错误率/延迟阈值且数据库仍兼容r001,则用同样原子软链方式切回r001并重启全部角色。不自动downgrade数据库,不删除新数据。
+
+文件:终端 / 当前课时
+
+````bash
+# 仅在兼容性已验证且current为软链时回退
+sudo ln -s releases/r001 /srv/notes-service/current.rollback
+sudo mv -Tf /srv/notes-service/current.rollback /srv/notes-service/current
+sudo systemctl restart notes-api notes-worker@fast notes-worker@bulk notes-maintenance@publisher notes-maintenance@recovery
+curl --fail --max-time 5 http://127.0.0.1:8000/ready
+````
+
+#### 5. 升级到两端口切换时先验证候选
+
+复制API unit为notes-api-candidate,WorkingDirectory与venv绝对固定到r002,端口改8001。先直接访问8001/ready与业务探针,再把Nginx proxy_pass由8000改8001,nginx -t后reload。观察旧连接排空再停止旧服务;这一步是可选演练,不用current软链同时控制两个版本。
+
+文件:终端 / 当前课时
+
+````bash
+curl --fail --max-time 5 http://127.0.0.1:8001/ready
+sudo nginx -t
+sudo systemctl reload nginx
+# 确认流量已切换并等待已定义排空窗口后,停止旧notes-api。
+````
+
+### 验收结果
+
+- r001与r002是独立完整发布物,current切换前后路径可核对。
+- 迁移失败停止发布,应用回退只在数据库兼容范围内执行。
+- 实际进程已重启或代理已切换,版本验证不只检查软链。
+
+### 生产约束与故障演练
+
+发布不仅换代码,还要协调数据库、队列协议和已有连接。
+
+#### 实现决策
+
+- 每版独立目录与依赖,迁移先兼容。
+- 记录可回退版本和明确触发条件。
+
+#### 隔离环境演练
+
+在隔离VM让r002故意缺一个必要配置,观察失败后按兼容路径切回r001并验证数据。
+
+#### 恢复验收
+
+- 恢复耗时与业务验证有记录。
+- 不兼容数据库变更明确阻止机械回退。
+
+### 常见错误
+
+#### current已变但请求仍返回旧行为
+
+原因:旧Python进程没有退出,仍持有旧代码。
+
+处理:检查systemd MainPID与启动时间,执行受控重启或候选端口切换。
+
+#### 旧worker无法读取新消息参数
+
+原因:任务协议变更未考虑队列中的存量消息。
+
+处理:保持稳定任务名与兼容参数,必要时用新版本队列并先排空旧队列。
+
+### 扩展练习
+
+新版本删除了一列,发现问题后还能直接切r001吗?
+
+提示:旧代码是否仍读取或写入该列?
+
+参考答案:
+
+通常不能保证。应采用expand-contract保留回退窗口;已执行不兼容删除时优先前向修复,或走经验证的数据恢复方案并评估数据损失,不把软链切回当完整回滚。
+
+### 技术文档
+
+- [Alembic:迁移命令](https://alembic.sqlalchemy.org/en/latest/tutorial.html)
+- [Nginx:控制与reload](https://nginx.org/en/docs/control.html)
+- [systemd-run:一次性作业(Ubuntu 24.04)](https://manpages.ubuntu.com/manpages/noble/man1/systemd-run.1.html)
+
+---
+
+## 08 journald、日志轮转与可执行告警
+
+建立从请求到任务的关联日志,控制磁盘占用,并定义能触发具体行动的监控条件。
+
+### 本节目标
+
+- 用journalctl按服务和时间定位故障
+- 限制journald与Nginx日志占用
+- 区分存活、业务就绪与队列健康指标
+
+### 必要概念
+
+#### 日志回答一次事件
+
+API记录request_id、方法、路由、状态码与耗时,任务记录job_id、阶段、attempt与异常类别。数据库密码、Authorization、笔记正文和完整提示词不进普通日志。关联ID连接各进程信息,不靠长堆栈猜同一次请求。
+
+#### 指标回答是否持续异常
+
+错误率、延迟分位数、数据库池等待、最老outbox年龄和队列等待时间要看时间窗口。只看worker存活会漏掉队列不消费、publisher停滞和结果文件失效。告警需阈值、持续时间、负责人和runbook。
+
+#### 日志本身会占资源
+
+无限日志可填满磁盘导致数据库写入失败。journald设容量和保留期,Nginx使用发行版logrotate配置并核对新文件路径是否匹配;不要直接截断数据库或正在写入的日志文件。
+
+### 动手实现
+
+#### 1. 按服务与时间查日志
+
+这些命令只读。先定位首次异常时间和部署版本,再扩大范围;生产收集日志时脱敏并限制导出范围,不直接输出EnvironmentFile。
+
+文件:终端 / 当前课时
+
+````bash
+sudo journalctl -u notes-api --since '15 minutes ago' --no-pager
+sudo journalctl -u notes-worker@fast -u notes-maintenance@publisher --since today --no-pager
+sudo systemctl show notes-api -p MainPID -p NRestarts -p ExecMainStatus
+sudo journalctl --disk-usage
+````
+
+#### 2. 配置journald容量
+
+在独立VM新建drop-in。值为小型练习机起点,实际保留期由故障调查需求和磁盘预算决定;修改只限制未来保留行为,不能代替异机日志归档。
+
+文件:/etc/systemd/journald.conf.d/notes-limits.conf
+
+````ini
+[Journal]
+Storage=persistent
+SystemMaxUse=500M
+SystemKeepFree=1G
+MaxRetentionSec=14day
+````
+
+#### 3. 加载并检查Nginx轮转
+
+先使用sudo install -d创建journald.conf.d目录,再保存配置并restart journald。Nginx发行版通常已有/var/log/nginx/*.log规则,确认覆盖notes-access/error;避免重复建立两个规则处理同一文件。logrotate -d仅调试,不执行轮转。
+
+文件:终端 / 当前课时
+
+````bash
+sudo systemctl restart systemd-journald
+sudo journalctl --disk-usage
+sudo cat /etc/logrotate.d/nginx
+sudo logrotate -d /etc/logrotate.conf
+df -h /var /srv
+````
+
+#### 4. 定义服务指标与告警动作
+
+把下列初始规则保存monitoring-runbook.md并按实际SLO调整。探针应在独立监控主机执行,避免本机整体宕机时连监控一起消失;课件没有配置真实通知渠道,不能声称告警已接通。
+
+文件:monitoring-runbook.md
+
+````text
+API就绪:/ready 连续3次失败(间隔10s)→ 检查依赖/发布,暂停继续放量
+HTTP错误率:5分钟窗口5xx超过业务阈值 → 按request_id查首次异常
+队列:最老queued/outbox等待超过任务SLO → 检查publisher、broker与worker
+磁盘:剩余空间低于容量预算,且持续下降 → 排查日志/备份,禁止先删数据库文件
+证书:剩余天数低于续期排障窗口 → 查看Certbot定时任务与验证路径
+告警必须包含:环境、版本、首次时间、当前值、负责人、runbook链接
+````
+
+#### 5. 用一次故障验证告警流程
+
+隔离VM暂停publisher,提交一个持久导出任务,等待超过练习阈值,手工执行监控SQL观察最老未发事件。恢复publisher后应完成投递,记录从发现到恢复用时;真实通知渠道尚未接通则明确标记为手工演练。
+
+文件:终端 / 当前课时
+
+````bash
+sudo systemctl stop notes-maintenance@publisher
+# 按任务模块提交一个新job,并查询export_outbox最老未发送时间
+sudo systemctl start notes-maintenance@publisher
+sudo journalctl -u notes-maintenance@publisher --since '5 minutes ago' --no-pager
+````
+
+### 验收结果
+
+- 能够按request_id/job_id定位跨进程阶段,不打印业务正文与凭据。
+- journald容量可查询,Nginx日志匹配一个有效轮转规则。
+- 至少一个故障从指标触发到恢复完成演练,未接通通知的部分明确说明。
+
+### 生产约束与故障演练
+
+故障必须在磁盘耗尽或用户集中反馈前被发现,并能关联到具体发布与任务。
+
+#### 实现决策
+
+- 告警对应可执行排查步骤。
+- 日志容量和敏感字段有明确限制。
+
+#### 隔离环境演练
+
+暂停练习publisher形成积压,检查最老待发年龄,恢复后核对任务完成与指标下降。
+
+#### 恢复验收
+
+- 告警条件对应真实业务影响。
+- 发现、定位、恢复耗时都有记录。
+
+### 常见错误
+
+#### 接口变慢但没有报错日志
+
+原因:日志只覆盖异常,未记录耗时和排队。
+
+处理:加入请求/任务阶段耗时指标,区分数据库等待、执行与队列延迟。
+
+#### 磁盘满后直接删除大文件仍未释放空间
+
+原因:文件可能仍由进程持有,或误删了重要数据。
+
+处理:先用lsof等确认持有者和文件类型,按服务支持方式轮转;不能删除PGDATA文件排空间。
+
+### 扩展练习
+
+worker进程active就说明任务系统健康吗?
+
+提示:任务经过哪些独立组件?
+
+参考答案:
+
+不说明。要同时检查outbox最老年龄、broker连通、队列等待、任务成功率和输出可读性;publisher停掉时worker仍可以一直active。
+
+### 技术文档
+
+- [systemd:journalctl(Ubuntu 24.04)](https://manpages.ubuntu.com/manpages/noble/man1/journalctl.1.html)
+- [systemd:journald配置(Ubuntu 24.04)](https://manpages.ubuntu.com/manpages/noble/man5/journald.conf.5.html)
+- [Nginx:日志模块](https://nginx.org/en/docs/http/ngx_http_log_module.html)
+
+---
+
+## 09 数据库备份、异机加密与恢复目标
+
+从一致性备份走到异机副本与隔离恢复,明确RPO和RTO是需要测量的目标。
+
+### 本节目标
+
+- 创建可验证的PostgreSQL逻辑备份
+- 对备份加密并传到获准的独立存储
+- 测量恢复时间并核对应用级数据
+
+### 必要概念
+
+#### RPO与RTO
+
+RPO描述可接受的数据回退时长,RTO描述可接受的恢复耗时。每天一次逻辑备份通常无法支持分钟级RPO,需WAL归档和PITR等方案。恢复时间包含取回备份、解密、建库、恢复、索引和业务验收,不只是pg_restore命令。
+
+#### 权限与密钥分离
+
+备份包含业务数据,应使用专用备份账号与最小读取权限,传输加密、静态加密、访问审计。解密私钥不能只放在同一台被备份主机,否则主机丢失后备份也无法解密。
+
+#### 恢复隔离
+
+恢复到新的notes_restore数据库或独立恢复主机,不能覆盖线上notes_lab来试验。行数、关键约束、迁移版本、任务状态与文件引用都要检查;数据库备份不自动包含shared/artifacts,输出文件需独立备份或可重建规则。
+
+### 动手实现
+
+#### 1. 为练习备份建立目录与凭据文件
+
+在VM创建root专用备份目录。使用.pgpass格式的/etc/notes-backup.pgpass保存专用备份凭据,权限600;示例可用练习app_user作为自己表的所有者读取,生产另建备份角色并验证所需权限。密码中的:与\要按libpq规则转义。
+
+文件:终端 / 当前课时
+
+````bash
+sudo install -d -o root -g root -m 700 /var/backups/notes-service
+sudoedit /etc/notes-backup.pgpass
+# 文件格式:127.0.0.1:5433:notes_lab:BACKUP_USER:ESCAPED_PASSWORD
+sudo chown root:root /etc/notes-backup.pgpass
+sudo chmod 600 /etc/notes-backup.pgpass
+````
+
+#### 2. 生成备份并检查可读目录
+
+下方命令中的BACKUP_USER替换为已验证的备份角色。使用PostgreSQL16客户端备份16服务器;Ubuntu24.04默认客户端通常为16,先检查。sudo env只指定凭据文件路径,命令行不写密码。备份文件名本次固定lab-01,存在时换新编号,不能覆盖唯一好备份。
+
+文件:终端 / 当前课时
+
+````bash
+pg_dump --version
+sudo env PGPASSFILE=/etc/notes-backup.pgpass pg_dump -h 127.0.0.1 -p 5433 -U BACKUP_USER -d notes_lab -Fc --no-owner --no-acl -f /var/backups/notes-service/notes-lab-01.dump
+sudo pg_restore --list /var/backups/notes-service/notes-lab-01.dump
+sudo sha256sum /var/backups/notes-service/notes-lab-01.dump
+````
+
+#### 3. 使用接收方公钥加密
+
+本步骤示例使用age,先按官方文档安装受信任版本。在恢复端生成并保管age私钥,备份VM仅保存接收方公钥;AGE_RECIPIENT替换真实公钥。此课不实际向外部发送数据,只有自己拥有且已获准的备份主机/存储才执行传输。
+
+文件:终端 / 当前课时
+
+````bash
+# 备份VM,仅持有接收方公钥
+sudo age -r AGE_RECIPIENT -o /var/backups/notes-service/notes-lab-01.dump.age /var/backups/notes-service/notes-lab-01.dump
+sudo sha256sum /var/backups/notes-service/notes-lab-01.dump.age
+# 使用已验证主机密钥的独立备份目标;替换主机、账号、路径后再执行
+# scp notes-lab-01.dump.age backup@AUTHORIZED_BACKUP_HOST:/approved/notes-service/
+````
+
+#### 4. 在独立恢复环境解密与恢复
+
+只在独立恢复VM或数据库执行,先createdb创建notes_restore并验证名称。解密私钥文件必须具有合适权限;不把私钥写在命令参数正文。记录开始与结束时间,恢复账号需在该恢复库有建表权限。
+
+文件:终端 / 当前课时
+
+````bash
+age -d -i /secure/recovery-age-key.txt -o notes-lab-01-restored.dump notes-lab-01.dump.age
+sha256sum notes-lab-01-restored.dump
+createdb -h RESTORE_HOST -U RESTORE_ADMIN -W notes_restore
+date -u
+pg_restore -h RESTORE_HOST -U RESTORE_ADMIN -W -d notes_restore --no-owner --no-acl --exit-on-error notes-lab-01-restored.dump
+date -u
+````
+
+#### 5. 核对恢复内容并计算差距
+
+在恢复库执行下列SQL,再将隔离API指向恢复库完成读取与约束验收。不要让恢复API连接生产队列并重新执行旧任务;禁用外发或使用独立broker,处理已完成/未完成任务的恢复语义。
+
+文件:终端 / 当前课时
+
+````sql
+SELECT current_database();
+SELECT count(*) FROM notes;
+SELECT version_num FROM alembic_version;
+SELECT status, count(*) FROM export_jobs GROUP BY status;
+-- 另行核对shared/artifacts备份或对象存储中的result_ref文件是否存在。
+````
+
+### 验收结果
+
+- 备份文件目录可读,解密后摘要与原始dump一致。
+- 恢复发生在独立notes_restore,原数据库未覆盖。
+- 记录实际恢复耗时、备份时间点及任务输出核验,能判断是否满足目标RPO/RTO。
+
+### 生产约束与故障演练
+
+主机或数据库损坏后,需要在业务可接受时间内恢复数据且避免重复外部副作用。
+
+#### 实现决策
+
+- 备份异机加密,密钥另行保管。
+- 恢复库和队列隔离,业务验收计入RTO。
+
+#### 隔离环境演练
+
+在独立恢复环境取回、解密、恢复一份练习备份,验证行数、约束、任务状态与输出文件。
+
+#### 恢复验收
+
+- 备份可恢复而非仅存在。
+- 恢复不连接生产外发渠道。
+
+### 常见错误
+
+#### 备份在但恢复时没有解密私钥
+
+原因:密钥只存在已损坏的原主机。
+
+处理:把恢复密钥放在独立受控保管位置并演练访问,备份机只需公钥。
+
+#### 恢复数据库后任务重复发送邮件或调用外部接口
+
+原因:恢复环境连上生产broker或仍启用外发调度。
+
+处理:恢复环境使用独立依赖并禁用外发,先核对任务状态和恢复策略,再受控恢复业务。
+
+### 扩展练习
+
+每天备份一次,能否承诺最多丢5分钟数据?
+
+提示:最后成功备份到故障发生之间可能多长?
+
+参考答案:
+
+不能。要达到分钟级RPO,应设计并验证WAL归档/PITR或其他受控方案,同时监控归档延迟;逻辑备份仍可作为补充,但目标必须有实际恢复证据。
+
+### 技术文档
+
+- [PostgreSQL16:备份恢复](https://www.postgresql.org/docs/16/backup.html)
+- [PostgreSQL16:持续归档与PITR](https://www.postgresql.org/docs/16/continuous-archiving.html)
+- [PostgreSQL16:密码文件](https://www.postgresql.org/docs/16/libpq-pgpass.html)
+- [age官方用法](https://github.com/FiloSottile/age)
+
+---
+
+## 10 完整发布、回退与故障 Runbook
+
+把主机、代理、应用、数据库和任务链路组成可执行验收清单,记录恢复边界。
+
+### 本节目标
+
+- 按层完成新版本发布验收
+- 依据故障症状选择检查与恢复步骤
+- 用隔离故障演练证明回退和备份方案
+
+### 必要概念
+
+#### 验收从用户路径出发
+
+/live只证明进程回应,/ready验证部分依赖,真正的验收要创建、读取、条件更新以及提交持久任务并读取结果。每项记录环境、版本、时间、预期与实际,不能把课件命令当作已执行证据。
+
+#### 恢复先找边界
+
+API代码错误可回退兼容版本;数据库锁等待需找阻塞事务;磁盘不足要处理明确的数据来源;队列积压先查publisher与worker。没有证据时重启所有服务可能放大故障并丢失现场。
+
+#### 进程与Docker部署差异
+
+systemd模式检查unit、绝对venv和主机端口;Docker模式检查镜像digest、Compose服务DNS、容器资源和卷。二者共享迁移、备份、幂等、代理与告警要求,不能因容器化省略数据库验收。
+
+### 动手实现
+
+#### 1. 建立发布检查单
+
+新建/srv/notes-service/shared/release-runbook.md供管理员维护,运行用户无需写此文件。填入真实版本、依赖地址和负责人,不在文档写秘密。
+
+文件:release-runbook.md
+
+````text
+发布前:目标VM/域名核对;当前版本与revision记录;备份恢复证据;兼容窗口
+发布准备:新目录/镜像验证;锁定依赖;配置权限;迁移SQL审核
+执行:唯一迁移成功 → 启动候选/切换版本 → 业务探针 → 观察窗口
+验收:API CRUD/version409;幂等重试;job/outbox/worker/结果文件一致
+回退:触发条件、旧版本、数据库兼容限制、任务协议限制、恢复负责人
+证据:命令结果、日志时间、指标曲线、未验证项目
+````
+
+#### 2. 执行不带秘密的基础探针
+
+在VM检查服务和磁盘,客户端检查真正入口。HTTPS仅在已配置自己域名时执行;未配置证书则通过本机HTTP Host探针,不使用-k冒充TLS成功。
+
+文件:终端 / 当前课时
+
+````bash
+sudo systemctl is-active notes-api notes-worker@fast notes-worker@bulk notes-maintenance@publisher notes-maintenance@recovery nginx
+curl --fail --max-time 5 http://127.0.0.1:8000/ready
+curl --fail --max-time 10 https://YOUR_DOMAIN/ready
+df -h /srv /var
+sudo journalctl -u notes-api --since '5 minutes ago' --no-pager
+````
+
+#### 3. 执行数据库与任务业务验收
+
+API+数据库最终脚本先验证创建、PATCH version与409;任务侧使用以下一次性systemd作业从真实notes表选一条并调用最终state_store.submit_snapshot。该函数包含job/outbox原子写入,不手工再delay。输出job_id后等worker完成,再在同一工作目录和环境运行verify_terminal.py JOB_ID,检查artifact与固定payload一致。
+
+文件:终端 / 当前课时
+
+````bash
+sudo systemd-run --unit=notes-submit-probe --wait --collect --pipe \
+  --property=User=notes --property=Group=notes \
+  --property=WorkingDirectory=/srv/notes-service/current/tasks_lab \
+  --property=EnvironmentFile=/etc/notes-tasks.env \
+  /srv/notes-service/current/.venv/bin/python -c "from state_store import connect,submit_snapshot; conn=connect(); row=conn.execute('SELECT id FROM notes ORDER BY id LIMIT 1').fetchone(); conn.close(); assert row, 'create a note first'; print(submit_snapshot([row['id']]))"
+# 记录上一步实际job_id,完成后替换JOB_ID
+sudo systemd-run --unit=notes-verify-probe --wait --collect --pipe \
+  --property=User=notes --property=Group=notes \
+  --property=WorkingDirectory=/srv/notes-service/current/tasks_lab \
+  --property=EnvironmentFile=/etc/notes-tasks.env \
+  /srv/notes-service/current/.venv/bin/python verify_terminal.py JOB_ID
+````
+
+#### 4. 逐个注入隔离故障并恢复
+
+一次只注入一个故障,先记录预期,完成恢复后再下一个。不在生产或唯一远程管理入口执行;SSH/UFW练习必须保留控制台和回退timer。
+
+文件:终端 / 当前课时
+
+````text
+故障 | 首先检查 | 预期恢复
+API停止 | systemctl status / Nginx上游日志 | 启动已验证版本,/ready恢复
+错误DB凭据 | API异常类别 / 脱敏配置来源 | 修正配置并重启对应进程
+publisher停止 | 最老outbox年龄 / unit状态 | 启动publisher,任务完成
+worker超时退出 | job租约 / attempts / recovery日志 | 过期恢复,幂等处理
+新版本不兼容 | revision与旧版兼容表 | 停止机械回退,前向修复或恢复计划
+备份恢复 | 独立恢复库 / 解密与结果引用 | 满足已测RPO/RTO
+````
+
+#### 5. 对比Docker部署同一验收
+
+若选择Docker路线,API仍由Nginx代理回环发布端口;用下列只读命令替代unit检查。不得同时让systemd API与Compose API争抢8000,先明确使用哪条运行主线。最终报告列出已实机验证和未验证项目。
+
+文件:终端 / 当前课时
+
+````bash
+docker compose --env-file .env.compose ps -a
+docker compose --env-file .env.compose logs --tail 100 api worker worker-bulk publisher recovery
+docker compose --env-file .env.compose images
+docker compose --env-file .env.compose stats --no-stream
+````
+
+### 验收结果
+
+- 有一次完整发布与兼容回退的实际执行记录,而不只是计划。
+- API与任务验收核对数据库最终状态和输出文件,失败后恢复验证完成。
+- 未进行的远程部署、证书、异机备份或告警通知明确标记未验证。
+
+### 生产约束与故障演练
+
+学员需要独立发布一个可排错、可回退、可恢复的后端服务。
+
+#### 实现决策
+
+- 验收覆盖用户路径与持久状态。
+- 故障按层定位,恢复动作最小且有证据。
+
+#### 隔离环境演练
+
+在专用VM完成API停止、publisher停止和兼容版本回退三项,记录发现、恢复时间与数据结果;剩余矩阵逐项标记。
+
+#### 恢复验收
+
+- 有可交付runbook和执行证据。
+- 没有把未实机验证的命令称为已部署成功。
+
+### 常见错误
+
+#### 所有组件进程都active但用户任务一直queued
+
+原因:存活检查未覆盖业务链路与积压年龄。
+
+处理:核对job/outbox、publisher投递、队列路由和worker注册任务,最后验证result_ref。
+
+#### 为了快速恢复反复重启所有组件
+
+原因:未区分故障层,重启破坏现场并触发重复任务。
+
+处理:一次验证一个假设,先记录错误、版本和状态,再执行最小恢复动作。
+
+### 扩展练习
+
+向同事交付部署方案时,至少需要哪些可审查证据?
+
+提示:让对方不依赖你的终端历史也能复现。
+
+参考答案:
+
+提供版本/镜像标识、依赖锁、配置项名称与权限、迁移状态、探针结果、任务最终状态、日志时间、回退边界和恢复耗时。秘密只给受控存储位置,不复制值。未执行的步骤标为待验证。
+
+### 技术文档
+
+- [Ubuntu Server文档](https://documentation.ubuntu.com/server/)
+- [systemd:systemctl(Ubuntu 24.04)](https://manpages.ubuntu.com/manpages/noble/man1/systemctl.1.html)
+- [Docker:生产Compose](https://docs.docker.com/compose/how-tos/production/)

+ 1902 - 0
public/downloads/redis-lessons.md

@@ -0,0 +1,1902 @@
+# Redis 缓存
+
+术核 AI · 后端工程课件
+
+## 01 连接 Redis、选择类型并设置 TTL
+
+在隔离的 Redis 7 练习实例中操作字符串、哈希和集合,建立可辨认的键名与过期规则。
+
+### 本节目标
+
+- 通过环境变量配置带密码的 Redis 连接
+- 区分字符串、哈希与集合的操作
+- 解释 TTL 的 -1、-2 和覆盖写入行为
+
+### 必要概念
+
+#### 缓存副本与业务真源
+
+本模块以 PostgreSQL notes_lab 为笔记真源,Redis 只保存可重新生成的副本。缓存命中减少数据库读取;缓存丢失应影响性能而不应删除笔记。计数器、锁和消息队列的正确性要求不同,不能自动套用可随时丢弃的缓存策略。
+
+#### 数据类型决定命令
+
+字符串可保存序列化 JSON;Hash 保存多个字段;Set 表示无重复成员。不同类型有各自命令,GET 一个 Hash 会返回 WRONGTYPE。这里不使用额外 RedisJSON 模块,JSON 文本由 Python 编解码。
+
+#### TTL 是数据生命期
+
+TTL 正数表示剩余秒数,-1 表示存在但没有过期时间,-2 表示键不存在。SET 默认覆盖值并移除原 TTL,除非显式设置新期限或保留期限。缓存写入通常把值和 EX/PX 放在同一条 SET 中,避免写值成功却未设置过期。
+
+### 动手实现
+
+#### 1. 启动只绑定本机端口的练习实例
+
+需要已安装并运行 Docker、Python 3.11+。下面是 Linux / macOS Bash,在 notes-service 执行,只创建本机专用 notes-redis。config 文件含练习密码,目录只供当前用户访问;挂载单个配置文件供容器读取。生产应按凭据管理和 TLS 方案部署,不能直接使用这份本机配置。
+
+文件:终端 / 当前课时
+
+````bash
+mkdir -p redis-lab
+chmod 700 redis-lab
+export REDIS_PASSWORD="$(python3 -c 'import secrets; print(secrets.token_urlsafe(32))')"
+python3 - <<'PY'
+import os
+from pathlib import Path
+password = os.environ['REDIS_PASSWORD']
+p = Path('redis-lab/redis.conf')
+p.write_text('bind 0.0.0.0\nprotected-mode yes\nport 6379\n'
+             f'requirepass {password}\n'
+             'save ""\nappendonly no\nmaxmemory 128mb\nmaxmemory-policy allkeys-lru\n')
+p.chmod(0o644)
+PY
+printf 'redis-lab/\n' >> .gitignore
+docker run --name notes-redis -d -p 127.0.0.1:6380:6379 \
+  -v "$PWD/redis-lab/redis.conf:/usr/local/etc/redis/redis.conf:ro" \
+  redis:7 redis-server /usr/local/etc/redis/redis.conf
+export REDIS_HOST=127.0.0.1 REDIS_PORT=6380
+python -m pip install 'redis>=5.3,<6'
+python -m pip freeze > requirements-lock.txt
+````
+
+#### 2. 建立后续复用的客户端
+
+保存为 redis_client.py。REDIS_PASSWORD 必须存在,忘记设置时应尽早失败。客户端启动不会立即验证连通,main 中 PING 才实际访问。这里使用 redis-py 5.3 系列稳定 API,Redis 7 不支持的较新命令不在示例中使用。
+
+文件:redis_client.py
+
+````python
+import os
+import redis
+
+r = redis.Redis(
+    host=os.environ.get("REDIS_HOST", "127.0.0.1"),
+    port=int(os.environ.get("REDIS_PORT", "6380")),
+    password=os.environ["REDIS_PASSWORD"],
+    db=0, decode_responses=True,
+    socket_connect_timeout=0.2, socket_timeout=0.2,
+    health_check_interval=30, max_connections=16,
+)
+
+if __name__ == "__main__":
+    print("ping", r.ping())
+    print("version", r.info("server")["redis_version"])
+    r.close()
+````
+
+#### 3. 操作三种数据类型
+
+保存为 redis_types.py,执行 python redis_types.py。所有键使用 notes:lab: 前缀,仅删除本课明确列出的三个键。反复练习不用 FLUSHALL;不同业务键不能共用同一个名字。
+
+文件:redis_types.py
+
+````python
+from redis_client import r
+
+keys = ["notes:lab:types:title", "notes:lab:types:hash", "notes:lab:types:tags"]
+r.delete(*keys)
+r.set(keys[0], "first note", ex=60)
+r.hset(keys[1], mapping={"title": "first note", "pinned": "false"})
+r.expire(keys[1], 60)
+r.sadd(keys[2], "python", "api", "python")
+r.expire(keys[2], 60)
+print(r.get(keys[0]))
+print(r.hgetall(keys[1]))
+print(sorted(r.smembers(keys[2])))
+print([r.type(k) for k in keys])
+r.delete(*keys)
+````
+
+#### 4. 验证 TTL 与覆盖写入
+
+保存为 redis_ttl.py 并运行。等待只发生在专用 1 秒练习键,不用长时间阻塞服务。SET 后 TTL 为 -1 的一幕用于认识风险;最终删除该练习键。
+
+文件:redis_ttl.py
+
+````python
+import time
+from redis_client import r
+
+k = "notes:lab:ttl-probe"
+r.set(k, "v1", ex=1)
+print("expires", r.ttl(k))
+time.sleep(1.1)
+print("missing", r.ttl(k))
+r.set(k, "v2", ex=60)
+r.set(k, "v3")
+print("no expiration", r.ttl(k))
+r.set(k, "v4", ex=60)
+print("restored expiration", r.ttl(k))
+r.delete(k)
+````
+
+### 验收结果
+
+- PING 返回 True,服务版本属于 Redis 7。
+- 字符串、Hash、Set 的值和类型符合预期,Set 中 python 只出现一次。
+- 能复现 TTL=-2 与 TTL=-1,所有长期缓存写入都明确设置期限。
+
+### 生产约束与故障演练
+
+笔记详情需要缓存,但重启 Redis 不能影响 PostgreSQL 中的真实笔记。
+
+#### 实现决策
+
+- 键名包含应用、环境与数据用途。
+- 固定开发端口并设置认证。
+- 缓存写入与 TTL 在同一条命令中完成。
+
+#### 隔离环境演练
+
+在专用键上移除 TTL 并观察 -1,再修复为带 EX 的 SET。
+
+#### 恢复验收
+
+- 客户端没有明文硬编码密码。
+- 练习不使用 FLUSHALL。
+- 能从键名和 TYPE 识别数据用途。
+
+### 常见错误
+
+#### NOAUTH 或 AuthenticationError
+
+原因:客户端未提供 REDIS_PASSWORD,或重开终端后环境变量丢失。
+
+处理:从本机受保护配置恢复正确密码到当前环境,确认客户端指向 6380;不把密码贴入日志。
+
+#### WRONGTYPE Operation against a key holding the wrong kind of value
+
+原因:相同键名先被写成一种类型,后被另一类命令操作。
+
+处理:用 TYPE 检查,修改键名命名空间;仅清理确认属于当前实验的键。
+
+### 扩展练习
+
+写入一个 30 秒 JSON 文本缓存,并证明覆盖写入时必须重新处理 TTL。
+
+提示:JSON 先 json.dumps,再 SET ex=30;不要分开 SET 与 EXPIRE。
+
+参考答案:
+
+r.set("notes:lab:json-probe", json.dumps({"schema":1,"id":1}), ex=30)。再次 r.set 同键时仍带 ex=30,或明确使用 keepttl=True;读后用 json.loads。SET 未指定过期且未 KEEPTTL 会移除已有 TTL。
+
+### 技术文档
+
+- [Redis:数据类型](https://redis.io/docs/latest/develop/data-types/)
+- [Redis:SET](https://redis.io/docs/latest/commands/set/)
+- [Redis:TTL](https://redis.io/docs/latest/commands/ttl/)
+- [redis-py 5.3:连接参数](https://redis.readthedocs.io/en/v5.3.0/connections.html)
+
+---
+
+## 02 实现 Cache-Aside 与带版本的 JSON 缓存
+
+先查 Redis,未命中再读 PostgreSQL,验证第一次回源与第二次命中的完整路径。
+
+### 本节目标
+
+- 实现显式命中、未命中和回填
+- 把数据库类型转成稳定 JSON 结构
+- 用 schema 区分缓存格式版本与业务数据版本
+
+### 必要概念
+
+#### Cache-Aside 的读路径
+
+应用先 GET;不存在时查询数据库,再把结果 SET 到缓存。Redis 不会自动知道 PostgreSQL 更新。先把基本路径做对,后续逐课处理失效、并发回填和故障降级。
+
+#### 两种版本解决不同问题
+
+缓存外层 schema=1 表示 JSON 格式协议,键名 v1 允许将来迁移格式。note.version 来自数据库,表示业务记录的修改版本。改 JSON 字段不等于修改业务记录,两者不要混为一个值。
+
+#### 缓存值也需要验证
+
+反序列化 JSON 后还要检查结构和编号,坏缓存应被当作未命中处理。数据库时间转换为带时区 ISO 8601,不用 pickle 反序列化来自缓存的任意对象。本课缓存只包含列表出的笔记字段。
+
+### 动手实现
+
+#### 1. 准备一条 PostgreSQL 笔记
+
+先完成数据库模块第 07 课,确保 notes 含 version。沿用 PG 环境变量连接 notes_lab,REDIS_PASSWORD 使用本模块第 01 课密码。在 Python 交互或保存以下脚本运行一次,记下编号并在 Bash 设置 export NOTE_ID=实际编号。
+
+文件:seed_cache_note.py
+
+````python
+import psycopg
+
+with psycopg.connect("") as conn:
+    row = conn.execute(
+        "INSERT INTO notes(title,content) VALUES (%s,%s) RETURNING id",
+        ("cache-lab", "database is the source"),
+    ).fetchone()
+    print("NOTE_ID=", row[0])
+````
+
+#### 2. 编写数据库读取与缓存编码
+
+保存为 note_cache.py。load_note 使用明确列名,把时间转换成字符串。此教学函数每次回源新建数据库连接,生产接入时用 API + 数据库模块的连接池,不能照搬成高并发连接策略。key 中花括号包围编号,为同一笔记相关键预留同槽标签;当前实例仍是单机 Redis。
+
+文件:note_cache.py
+
+````python
+import json
+import psycopg
+from psycopg.rows import dict_row
+from redis_client import r
+
+def key(note_id):
+    return f"notes:lab:v1:note:{{{note_id}}}"
+
+def load_note(note_id):
+    with psycopg.connect("", row_factory=dict_row) as conn:
+        row = conn.execute(
+            "SELECT id,title,content,pinned,created_at,updated_at,version FROM notes WHERE id=%s",
+            (note_id,),
+        ).fetchone()
+    if row is not None:
+        row["created_at"] = row["created_at"].isoformat()
+        row["updated_at"] = row["updated_at"].isoformat()
+    return row
+
+def encode(note):
+    return json.dumps({"schema": 1, "note": note}, ensure_ascii=False)
+
+def decode(raw, note_id):
+    data = json.loads(raw)
+    note = data["note"]
+    if data["schema"] != 1 or not isinstance(note, dict) or note.get("id") != note_id:
+        raise ValueError("invalid cached note")
+    if not isinstance(note.get("title"), str) or not isinstance(note.get("version"), int):
+        raise ValueError("invalid cached fields")
+    return note
+
+def get_note(note_id):
+    raw = r.get(key(note_id))
+    if raw is not None:
+        try:
+            return decode(raw, note_id), "cache"
+        except (ValueError, KeyError, TypeError):
+            r.delete(key(note_id))
+    note = load_note(note_id)
+    if note is not None:
+        r.set(key(note_id), encode(note), ex=60)
+    return note, "database"
+````
+
+#### 3. 对照两次读取来源
+
+保存为 cache_read.py。只清除当前练习笔记的一个缓存键。第一次来源 database,第二次 cache,内容应相等,TTL 在 1 到 60 之间。没有记录时返回 None,本课尚未缓存不存在的结果。
+
+文件:cache_read.py
+
+````python
+import os
+from redis_client import r
+from note_cache import key, get_note
+
+note_id = int(os.environ["NOTE_ID"])
+r.delete(key(note_id))
+first, source1 = get_note(note_id)
+second, source2 = get_note(note_id)
+assert first == second and first is not None
+print(source1, source2, r.ttl(key(note_id)))
+print(first)
+````
+
+#### 4. 注入坏格式并验证自动回源
+
+先写入不可解析文本,再写入不受支持的 schema。每次调用都应返回 database 和合法笔记,然后缓存被合法 JSON 替换。Redis 网络错误尚未在此函数内处理,第 06 和 09 课补齐有界降级。
+
+文件:cache_bad_format.py
+
+````python
+import os
+from redis_client import r
+from note_cache import key, get_note
+
+note_id = int(os.environ["NOTE_ID"])
+for bad in ["not-json", '{"schema":99,"note":{"id":1}}']:
+    r.set(key(note_id), bad, ex=60)
+    note, source = get_note(note_id)
+    print(source, note["id"])
+    assert source == "database"
+````
+
+### 验收结果
+
+- 第一次返回 database,第二次返回 cache,业务字段一致。
+- 缓存含 schema 和 note.version,时间为明确时区字符串。
+- 坏 JSON 和未知 schema 不直接导致业务解码崩溃,回源后得到有效内容。
+
+### 生产约束与故障演练
+
+同一笔记被反复读取,希望降低数据库访问同时保留格式升级能力。
+
+#### 实现决策
+
+- PostgreSQL 成功读取后才回填。
+- 缓存格式版本和业务版本分离。
+- 异常与不存在使用不同结果。
+
+#### 隔离环境演练
+
+给单个练习键写入坏 JSON 和未知 schema,检查回源和重新编码。
+
+#### 恢复验收
+
+- 命中来源可观测。
+- 无任意对象反序列化。
+- 缓存值不包含未设计对外展示的敏感字段。
+
+### 常见错误
+
+#### json.dumps 报 datetime is not JSON serializable
+
+原因:直接把数据库 datetime 对象塞进缓存。
+
+处理:在固定序列化边界转换为 ISO 8601,或使用受控模型序列化;不要随意 str 整个对象。
+
+#### 修改代码增加字段后旧缓存导致 KeyError
+
+原因:缓存格式没有版本和校验,部署后仍读取旧结构。
+
+处理:提升缓存 schema 或键名前缀,允许旧键自然过期;读取方明确接受的结构。
+
+### 扩展练习
+
+让记录不存在时也能返回明确来源,并说明为什么不能把数据库异常当成不存在。
+
+提示:None 表示成功查询且无行;连接异常代表没有完成查询。
+
+参考答案:
+
+load_note 返回 None 时 get_note 返回 (None,"database")。捕获不到数据库时不能伪造 None,否则会把故障错误缓存成不存在;应向调用方返回可区分的服务错误,后续负缓存只处理确定的无行结果。
+
+### 技术文档
+
+- [Redis:GET](https://redis.io/docs/latest/commands/get/)
+- [Redis:Python 客户端](https://redis.io/docs/latest/develop/clients/redis-py/)
+- [Python:json](https://docs.python.org/3/library/json.html)
+
+---
+
+## 03 提交后失效缓存,并复现旧值回填竞态
+
+把失效放到事务提交之后,按固定时序制造旧快照覆盖,明确最终一致性的边界。
+
+### 本节目标
+
+- 正确安排数据库提交与缓存删除的顺序
+- 复现先读旧值后回填的竞态
+- 设计失效失败后的重试与一致性验收
+
+### 必要概念
+
+#### 先提交数据库,再失效缓存
+
+数据库提交成功后删除详情缓存,下一次读取从真源重建。如果在提交前删除,另一个读请求可能读到尚未更新的数据库旧值并重新缓存。提交后删除减少这一类窗口,但不消除所有并发竞态。
+
+#### 旧读取可以晚于新写入回填
+
+读者 A 在写者提交前读到 v1,写者 B 提交 v2 并删除缓存,A 随后把 v1 回填。即使删除发生在提交后,缓存仍可能短暂陈旧。TTL 只限制一次缓存副本的存活时间,不应被表述为完整一致性证明。
+
+#### 失效失败是待补偿工作
+
+PostgreSQL 和 Redis 没有共享一个本地事务。数据库已提交、DEL 失败时不能声称数据库回滚。生产可在同一数据库事务写 Outbox,由后台可靠重试失效,结合短 TTL;要求立即读取自己写入结果时直接返回数据库 RETURNING 数据或绕过缓存。
+
+### 动手实现
+
+#### 1. 实现带版本检查的更新与提交后删除
+
+保存为 cache_write.py。expected_version 来自读取结果。上下文退出才表示提交成功,随后才删 Redis。示例返回 invalidated 标志供练习观察;生产失效失败应写入可靠补偿流程并告警,这里没有实现 Outbox,不能当成完整可靠投递。
+
+文件:cache_write.py
+
+````python
+import psycopg
+from redis.exceptions import RedisError
+from note_cache import key
+from redis_client import r
+
+def rename_note(note_id, title, expected_version):
+    with psycopg.connect("") as conn:
+        row = conn.execute(
+            "UPDATE notes SET title=%s,version=version+1,updated_at=now() "
+            "WHERE id=%s AND version=%s RETURNING id,title,version",
+            (title, note_id, expected_version),
+        ).fetchone()
+        if row is None:
+            raise ValueError("not found or version conflict")
+    try:
+        r.delete(key(note_id))
+        invalidated = True
+    except RedisError:
+        invalidated = False
+    return {"id": row[0], "title": row[1], "version": row[2], "invalidated": invalidated}
+````
+
+#### 2. 验证成功修改后的首次回源
+
+保存为 cache_invalidate.py 并运行。先预热旧值,再以当前版本更新。更新后键不存在,下一次 get_note 返回数据库的新标题与新版本。重复运行时从数据库读取当前版本,不假定总是 1。
+
+文件:cache_invalidate.py
+
+````python
+import os
+from note_cache import get_note, key, load_note
+from cache_write import rename_note
+from redis_client import r
+
+n = int(os.environ["NOTE_ID"])
+before = load_note(n)
+r.delete(key(n))
+get_note(n)
+print(rename_note(n, "cache03-new-title", before["version"]))
+assert r.get(key(n)) is None
+after, source = get_note(n)
+print(source, after["title"], after["version"])
+assert source == "database" and after["version"] == before["version"] + 1
+````
+
+#### 3. 按确定时序重现旧值晚回填
+
+本脚本将两个并发请求的步骤按顺序手工调度,结果不依赖线程调度或 sleep。只操作 NOTE_ID 专用笔记。A 先读取旧值并暂停,B 完成提交和失效,A 最后回填旧值;读取命中旧版本,数据库版本更高。
+
+文件:cache_stale_refill.py
+
+````python
+import os
+from note_cache import load_note, get_note, encode, key
+from cache_write import rename_note
+from redis_client import r
+
+n = int(os.environ["NOTE_ID"])
+r.delete(key(n))
+snapshot_a = load_note(n)  # A: 已回源,尚未回填
+written_b = rename_note(n, "cache03-race-new", snapshot_a["version"])  # B
+r.set(key(n), encode(snapshot_a), ex=5)  # A: 旧值晚到
+cached, source = get_note(n)
+fresh = load_note(n)
+print("cached", source, cached["version"], "database", fresh["version"])
+assert cached["version"] < fresh["version"]
+r.delete(key(n))  # 清理本次故障注入
+assert get_note(n)[0]["version"] == fresh["version"]
+````
+
+#### 4. 制定可检查的失效策略
+
+为 notes-service 的详情、列表、标签聚合分别列出受一次编辑影响的键。详情可精确删除;列表缓存需要版本化命名空间或短 TTL,不能用 KEYS 模糊删除整个实例。将下列记录保存为开发验收清单,后续 Outbox 模块实现重试。
+
+文件:终端 / 当前课时
+
+````text
+事件:note.updated,字段 note_id / committed_version / event_id
+写入:业务 UPDATE 与 outbox INSERT 在同一个 PostgreSQL 事务
+消费:按事件删除详情键及明确的派生键,成功后记录处理状态
+重复:DEL 对不存在键仍可安全重放
+失败:有限退避后进入待处理队列并告警,不丢弃失效事件
+读取:写接口返回数据库实际版本;强一致读取显式绕过缓存
+观测:失效失败数、最老未处理事件年龄、回源率、旧版本样本
+````
+
+### 验收结果
+
+- DEL 只出现在数据库事务成功退出后。
+- 固定时序实验能得到缓存版本小于数据库版本,清理后恢复。
+- 设计文档区分提交成功与失效成功,并给失效失败安排可靠补偿。
+
+### 生产约束与故障演练
+
+编辑提交成功后用户立即刷新,后台又可能存在较慢的并发读取。
+
+#### 实现决策
+
+- 提交后删除详情缓存。
+- 用确定性时序测试陈旧回填。
+- 可靠失效事件与直接读写结果满足不同一致性需求。
+
+#### 隔离环境演练
+
+仅对 NOTE_ID 练习笔记模拟旧读取在新提交之后回填,比较两个版本并清理故障值。
+
+#### 恢复验收
+
+- 没有宣称 Cache-Aside 天然强一致。
+- 失效故障不会伪装成数据库回滚。
+- 派生缓存清单和重试责任明确。
+
+### 常见错误
+
+#### 在异常里删缓存,用户仍看到旧值
+
+原因:失效顺序没有跟随真实提交结果,或遗漏派生列表键。
+
+处理:围绕事务提交定义事件,列出所有派生缓存;不要以请求函数是否执行完代替提交状态。
+
+#### 采用延迟双删后宣称缓存强一致
+
+原因:延迟长短无法覆盖任意暂停、网络延迟和进程故障。
+
+处理:把延迟删除视为减少窗口的措施;依据业务选择可接受陈旧、可靠事件或直接读真源。
+
+### 扩展练习
+
+数据库更新已提交但 Redis 断开时,接口能否返回“数据库操作已回滚”?应记录什么?
+
+提示:数据库提交和 Redis 失效是两个独立结果。
+
+参考答案:
+
+不能报告回滚。应保留实际提交版本,记录失效待补偿事件、event_id 和失败原因,采用 Outbox 等可靠机制重试。写响应可直接返回已提交数据,强一致读取绕过缓存;不要盲目重试创建造成重复记录。
+
+### 技术文档
+
+- [Redis:DEL](https://redis.io/docs/latest/commands/del/)
+- [Redis:客户端缓存与失效](https://redis.io/docs/latest/develop/reference/client-side-caching/)
+- [PostgreSQL 16:事务](https://www.postgresql.org/docs/16/tutorial-transactions.html)
+
+---
+
+## 04 阻止缓存穿透:输入校验与短期负缓存
+
+对确定不存在的编号缓存短期空结果,并避免把故障或权限拒绝当作全局不存在。
+
+### 本节目标
+
+- 区分格式非法、不存在和无权限
+- 为不存在结果设计明确编码和短 TTL
+- 限制攻击者制造的负缓存键数量
+
+### 必要概念
+
+#### 穿透是大量请求持续落到真源
+
+查询从不存在的编号时,普通 Cache-Aside 每次都得到空结果而不回填,会重复访问数据库。输入范围校验先拦掉无意义请求,负缓存减少同一个合法但不存在编号的重复查询。
+
+#### 空结果必须有明确标记
+
+Redis GET 返回 None 表示缓存键不存在,不等于业务记录不存在。负缓存使用 found=false;正缓存使用 found=true 和 note,读取时分别处理。数据库连接失败绝不能被编码为 found=false。
+
+#### 权限不能被共享缓存省略
+
+记录缓存只是数据副本,不代替鉴权。多租户应用应先完成主体与租户校验,并把需要隔离的租户放进键名。单用户课程暂不引入租户字段;不能把某个用户的 403 缓存成所有用户都不存在。
+
+### 动手实现
+
+#### 1. 实现正负结果独立编码
+
+保存为 negative_cache.py。单独使用 v1:lookup 命名空间,不与第 02 课 JSON 结构混用。正结果 60 秒,确定无行 5 秒。类型校验拒绝 bool 作为整数编号;上限 2^63-1 对应数据库 bigint 的正数范围。
+
+文件:negative_cache.py
+
+````python
+import json
+from redis_client import r
+from note_cache import load_note
+
+def lookup_key(note_id):
+    return f"notes:lab:v1:lookup:{{{note_id}}}"
+
+def get_with_negative(note_id):
+    if type(note_id) is not int or not 1 <= note_id <= 2**63 - 1:
+        raise ValueError("invalid note id")
+    k = lookup_key(note_id)
+    raw = r.get(k)
+    if raw is not None:
+        try:
+            data = json.loads(raw)
+            if data["schema"] != 1 or type(data["found"]) is not bool:
+                raise ValueError("bad cache")
+            if data["found"] is False:
+                return None, "negative-cache"
+            note = data["note"]
+            if not isinstance(note, dict) or note.get("id") != note_id:
+                raise ValueError("bad note")
+            return note, "cache"
+        except (ValueError, KeyError, TypeError):
+            r.delete(k)
+    note = load_note(note_id)  # 异常继续向外传递,不伪造不存在
+    found = note is not None
+    payload = {"schema": 1, "found": found, "note": note}
+    r.set(k, json.dumps(payload), ex=60 if found else 5)
+    return note, "database"
+````
+
+#### 2. 选择确认不存在的编号并查询两次
+
+在独立练习库选择 max(id)+100000,先确认没有该记录。这里只做课堂样本选择,不给线上攻击者提供枚举工具。第一次 database、第二次 negative-cache,数据库错误没有进入这条成功路径。
+
+文件:negative_probe.py
+
+````python
+import psycopg
+from negative_cache import get_with_negative, lookup_key
+from redis_client import r
+
+with psycopg.connect("") as conn:
+    missing_id = conn.execute("SELECT coalesce(max(id),0)+100000 FROM notes").fetchone()[0]
+r.delete(lookup_key(missing_id))
+print(get_with_negative(missing_id))
+print(get_with_negative(missing_id))
+print("ttl", r.ttl(lookup_key(missing_id)))
+r.delete(lookup_key(missing_id))
+````
+
+#### 3. 让非法编号在访问存储之前失败
+
+保存并运行。bad 值都会触发 ValueError;后续 API 接入时由参数模型先执行同等校验。单个非法值的验证不要发起 Redis 或 PostgreSQL 请求。
+
+文件:negative_invalid.py
+
+````python
+from negative_cache import get_with_negative
+
+for bad in [0, -1, True, "12", 2**63]:
+    try:
+        get_with_negative(bad)
+    except ValueError as exc:
+        print(repr(bad), str(exc))
+    else:
+        raise AssertionError("invalid id reached storage")
+````
+
+#### 4. 把创建与更新失效覆盖到负缓存
+
+将 lookup_key 对应键加入提交后失效清单。新建时数据库返回 id 后再删除同一编号的负缓存;更新与删除时同时失效详情和 lookup 两种命名空间。下面函数只演示失效动作,调用必须放在业务事务提交后。
+
+文件:cache_invalidate_keys.py
+
+````python
+from redis_client import r
+from note_cache import key
+from negative_cache import lookup_key
+
+def invalidate_note_caches(note_id):
+    return r.delete(key(note_id), lookup_key(note_id))
+
+# 调用位置:数据库提交成功后,或可靠 Outbox 消费函数中。
+# 不在这里捕获错误后无声丢弃。
+````
+
+### 验收结果
+
+- 无行结果只回源一次,短期第二次返回 negative-cache。
+- 非法编号在存储访问之前被拒绝。
+- 新建、修改和删除的失效清单包含负缓存键,数据库错误不写入负缓存。
+
+### 生产约束与故障演练
+
+重复访问不存在笔记的请求增加,数据库空查询消耗开始明显。
+
+#### 实现决策
+
+- 格式校验先于缓存读取。
+- 只缓存确定的无行结果,负 TTL 短于正常缓存。
+- 负缓存结合入口限流与容量监控。
+
+#### 隔离环境演练
+
+对确定不存在的练习编号连续读取,再提交非法编号,比较来源和存储请求。
+
+#### 恢复验收
+
+- 不存在与故障分开。
+- 没有以负缓存绕过鉴权。
+- 新增路径失效负缓存。
+
+### 常见错误
+
+#### 新建笔记后短时间仍显示不存在
+
+原因:创建前留下负缓存,创建后没有失效。
+
+处理:用数据库返回的真实 id 删除对应负缓存,并设置较短负 TTL。
+
+#### 缓存里出现海量随机不存在编号
+
+原因:攻击者每次换编号,短 TTL 也无法阻止瞬时键膨胀。
+
+处理:在入口鉴权和限流,限制参数与访问范围;观察负缓存创建率和内存,不依赖负缓存单独抗攻击。
+
+### 扩展练习
+
+如果增加布隆过滤器,返回“可能存在”和“确定不在过滤器”各意味着什么?
+
+提示:过滤器有误判,且构建及更新是否完整决定能否用来拒绝查询。
+
+参考答案:
+
+可能存在仍需查缓存或数据库。确定不在过滤器只有在过滤器完整且新增写入同步维护时才可拒绝;过滤器延迟会误拒绝新记录,因此需要回源或版本化切换策略。它不能代替权限检查与数据库真源。
+
+### 技术文档
+
+- [Redis:过期键](https://redis.io/docs/latest/commands/expire/)
+- [Redis:布隆过滤器](https://redis.io/docs/latest/develop/data-types/probabilistic/bloom-filter/)
+- [Redis:SCAN](https://redis.io/docs/latest/commands/scan/)
+
+---
+
+## 05 抑制热点击穿:带令牌的单飞回填
+
+让同一热点键过期时只有少量请求回源,使用原子令牌检查防止旧持有者误删新锁。
+
+### 本节目标
+
+- 用 SET NX PX 合并同一键的回源
+- 通过 Lua 原子校验令牌和释放
+- 给竞争者等待与锁过期设计明确结果
+
+### 必要概念
+
+#### 单飞用于压低重复工作
+
+同一个热点缓存失效时,许多请求同时回源会压垮数据库。短期填充锁让一个请求重建,其他请求短暂等待缓存或收到忙响应。它服务于缓存效率,不承诺跨故障条件下业务副作用严格只执行一次。
+
+#### 释放时检查所有者令牌
+
+每次竞争生成随机 token,并用 SET lock token NX PX 同时设置所有权和租期。释放必须在一段 Lua 中比较 token 再删除;先 GET 后 DEL 存在两条命令之间被新持有者接手的窗口。Redis 7 示例使用 Lua,不使用较新版本的条件删除命令。
+
+#### 过期意味着可能出现另一位持有者
+
+进程暂停或回源过慢会让锁先过期。旧持有者恢复后不能删除或发布覆盖新持有者结果。下面脚本把校验、回填与释放放在一次 Lua 中;数据库并发更新仍有上一课的陈旧回填问题,单飞并不自动解决缓存一致性。
+
+### 动手实现
+
+#### 1. 保存安全释放脚本
+
+保存为 cache_singleflight.py,后续步骤继续向此文件追加。键名使用同一个 {note_id} 标签,便于将来多键脚本要求同槽;当前未部署 Redis Cluster。脚本只有固定数量短操作,不在 Redis 内做长循环或外部访问。
+
+文件:cache_singleflight.py
+
+````python
+import secrets
+import time
+from redis_client import r
+from note_cache import key, load_note, encode, decode
+
+class CacheBusy(RuntimeError):
+    pass
+
+release = r.register_script("""
+if redis.call('GET', KEYS[1]) == ARGV[1] then
+  return redis.call('DEL', KEYS[1])
+end
+return 0
+""")
+
+publish = r.register_script("""
+if redis.call('GET', KEYS[1]) ~= ARGV[1] then return 0 end
+redis.call('SET', KEYS[2], ARGV[2], 'PX', ARGV[3])
+redis.call('DEL', KEYS[1])
+return 1
+""")
+````
+
+#### 2. 实现有限等待与二次读取
+
+追加到 cache_singleflight.py。获得锁后再读一次缓存,避免等待途中别人已经回填。竞争失败最多等 0.3 秒,每 20 毫秒看一次结果;耗尽后 CacheBusy,应映射为受控 503 或按策略返回允许的旧值。不能让所有竞争者超时后同时无限回源。
+
+文件:cache_singleflight.py
+
+````python
+def singleflight_note(note_id, loader=load_note):
+    cache_key, lock_key = key(note_id), key(note_id) + ":fill"
+    raw = r.get(cache_key)
+    if raw is not None:
+        return decode(raw, note_id)
+    token = secrets.token_urlsafe(24)
+    if r.set(lock_key, token, nx=True, px=2000):
+        try:
+            raw = r.get(cache_key)
+            if raw is not None:
+                return decode(raw, note_id)
+            note = loader(note_id)
+            if note is None:
+                return None
+            stored = publish(keys=[lock_key, cache_key], args=[token, encode(note), 60000])
+            if not stored:
+                raise CacheBusy("fill lease expired")
+            return note
+        finally:
+            release(keys=[lock_key], args=[token])
+    deadline = time.monotonic() + 0.3
+    while time.monotonic() < deadline:
+        time.sleep(0.02)
+        raw = r.get(cache_key)
+        if raw is not None:
+            return decode(raw, note_id)
+    raise CacheBusy("cache fill still running")
+````
+
+#### 3. 并发验证回源次数
+
+保存为 cache_singleflight_probe.py。8 个线程同时读取同一笔记,模拟回源增加 80 毫秒。正常本机负载下 calls 应为 1;若发生 Redis 超时或调度超过等待期限,应把失败视为实际故障记录,不删除断言掩盖问题。专用记录必须存在,运行前清理详情和填充锁。
+
+文件:cache_singleflight_probe.py
+
+````python
+import os
+import threading
+import time
+from concurrent.futures import ThreadPoolExecutor
+from note_cache import key, load_note
+from redis_client import r
+from cache_singleflight import singleflight_note
+
+n = int(os.environ["NOTE_ID"])
+r.delete(key(n), key(n) + ":fill")
+guard = threading.Lock()
+calls = 0
+
+def counted_loader(note_id):
+    global calls
+    with guard:
+        calls += 1
+    time.sleep(0.08)
+    return load_note(note_id)
+
+with ThreadPoolExecutor(max_workers=8) as workers:
+    rows = list(workers.map(lambda _: singleflight_note(n, counted_loader), range(8)))
+assert all(row is not None and row["id"] == n for row in rows)
+assert calls == 1
+print("database loads", calls)
+````
+
+#### 4. 验证旧令牌不能释放新锁
+
+保存为 cache_lock_token.py。只对本课 token-probe 键模拟替换所有者,原令牌释放返回 0,键仍为 new-owner。这个实验直接设置新 token 以固定复现所有权变化;真实场景通常由原锁过期后竞争产生。
+
+文件:cache_lock_token.py
+
+````python
+from redis_client import r
+from cache_singleflight import release
+
+k = "notes:lab:token-probe"
+r.set(k, "old-owner", px=2000)
+r.set(k, "new-owner", px=2000)
+assert release(keys=[k], args=["old-owner"]) == 0
+assert r.get(k) == "new-owner"
+assert release(keys=[k], args=["new-owner"]) == 1
+print("ownership check passed")
+````
+
+### 验收结果
+
+- 正常热点并发实验只回源一次,其余读取缓存结果。
+- 旧 token 不能删除新 token 的锁。
+- 等待、租期、Redis 超时各有上限,锁过期后不发布旧持有者结果。
+
+### 生产约束与故障演练
+
+热门笔记缓存到期后突然出现大量相同详情请求。
+
+#### 实现决策
+
+- 先用进程内合并或单实例填充锁降低重复回源。
+- 令牌检查与发布、释放原子执行。
+- 竞争失败有界等待,不把所有失败者直接推向数据库。
+
+#### 隔离环境演练
+
+在本机令牌键替换所有者,再延长 loader 到超过租期,检查旧持有者行为。
+
+#### 恢复验收
+
+- 旧持有者不能删新锁。
+- 数据库回源次数有度量。
+- 不把缓存锁当作生产业务唯一性保证。
+
+### 常见错误
+
+#### finally 中 DEL 删除了别人的锁
+
+原因:原锁过期后另一请求获得锁,旧请求无条件删除。
+
+处理:使用 Lua 比较 token 并原子删除,不能把 GET 与 DEL 分开发。
+
+#### 把缓存锁用于扣款并宣称绝不重复
+
+原因:租期过期、进程暂停或异步复制故障会破坏单持有者假设。
+
+处理:业务正确性使用数据库约束、幂等键或具备 fencing 校验的资源;缓存锁只减少重复回源。
+
+### 扩展练习
+
+把 loader 延迟改为 2.2 秒,预测原持有者和竞争者结果,并说明生产应如何处理。
+
+提示:填充租期 2 秒,竞争者等待 0.3 秒;二者是不同预算。
+
+参考答案:
+
+竞争者先收到 CacheBusy;原持有者回源完成后 publish 因 token 已过期返回 0,也抛出 CacheBusy。应该限制回源耗时、选择合适租期并监控失败;若需要续租必须验证所有者且限定总时间,不能无限延长。
+
+### 技术文档
+
+- [Redis:分布式锁与单实例令牌](https://redis.io/docs/latest/develop/clients/patterns/distributed-locks/)
+- [Redis:SET](https://redis.io/docs/latest/commands/set/)
+- [Redis:EVAL](https://redis.io/docs/latest/commands/eval/)
+
+---
+
+## 06 应对缓存雪崩:TTL 抖动、受控预热与降级
+
+分散批量过期时间,限制冷启动回源并发,在 Redis 失效时保护 PostgreSQL。
+
+### 本节目标
+
+- 给 TTL 加有界随机偏移
+- 用限速小批次预热热点数据
+- 让缓存降级服从数据库并发预算
+
+### 必要概念
+
+#### 雪崩影响一批键或整个实例
+
+同一时刻大量键到期,或 Redis 整体不可用,会把原本被缓存吸收的流量推向数据库。TTL 抖动分散到期时刻,不能解决实例故障;需要回源限流和服务端负载保护。
+
+#### 降级也有容量上限
+
+缓存可绕过不代表数据库能接住全量请求。为回源设置并发预算、等待上限、SQL 超时和入口限流,超出容量返回明确的暂不可用结果。不能在数据库也失败时伪造空笔记。
+
+#### 预热从热点开始
+
+预热最常访问的有限集合,并控制批次大小、并发和速率。全库一起加载会抢占生产数据库资源和缓存容量。旧值兜底只适用于允许陈旧的字段,权限、删除状态和敏感信息不能随意继续返回。
+
+### 动手实现
+
+#### 1. 观察有界 TTL 抖动分布
+
+保存为 cache_jitter.py。生成 100 个专用小键,TTL 均在 45 到 75 秒,真实设置后可能因执行消耗略减 1 秒。只清理本脚本创建的键,不扫描并删除其他缓存。
+
+文件:cache_jitter.py
+
+````python
+import random
+from collections import Counter
+from redis_client import r
+
+keys = [f"notes:lab:jitter:{i}" for i in range(100)]
+ttls = [60 + random.randint(-15, 15) for _ in keys]
+with r.pipeline(transaction=False) as pipe:
+    for k, ttl in zip(keys, ttls):
+        pipe.set(k, "sample", ex=ttl)
+    pipe.execute()
+print("min/max", min(ttls), max(ttls))
+print("buckets", sorted(Counter(ttls).items()))
+r.delete(*keys)
+````
+
+#### 2. 小批次预热最近笔记
+
+保存为 cache_warm.py。仅预热最多 20 条,顺序处理并在每条之间等待 50 毫秒;目的是控制负载,不是追求最快跑完。真正热点应来自访问统计。本例直接用已有 load_note,生产切换到连接池并增加任务取消。
+
+文件:cache_warm.py
+
+````python
+import random
+import time
+import psycopg
+from note_cache import key, load_note, encode
+from redis_client import r
+
+with psycopg.connect("") as conn:
+    ids = [row[0] for row in conn.execute("SELECT id FROM notes ORDER BY id DESC LIMIT 20")]
+for note_id in ids:
+    note = load_note(note_id)
+    if note is not None:
+        r.set(key(note_id), encode(note), ex=60 + random.randint(-15, 15))
+    time.sleep(0.05)
+print("warmed", len(ids))
+````
+
+#### 3. 实现有并发上限的回源降级
+
+保存为 cache_fallback.py。该函数直接实现读路径,不调用可能已经回源的 get_note 后再次回源,避免重复数据库查询。Redis GET/SET 失败可以绕过,PostgreSQL 异常仍交给调用者。Semaphore 只约束本进程;多进程预算需要总量规划。同步代码不能直接阻塞 async 路由。
+
+文件:cache_fallback.py
+
+````python
+import random
+import threading
+from redis.exceptions import RedisError
+from redis_client import r
+from note_cache import key, load_note, encode, decode
+
+db_slots = threading.BoundedSemaphore(4)
+
+class SourceBusy(RuntimeError):
+    pass
+
+def get_available(note_id, loader=load_note):
+    try:
+        raw = r.get(key(note_id))
+        if raw is not None:
+            try:
+                return decode(raw, note_id), "cache"
+            except (ValueError, KeyError, TypeError):
+                pass
+    except RedisError:
+        pass  # 生产记录 redis_read_error 计数,不记录密码
+    if not db_slots.acquire(timeout=0.05):
+        raise SourceBusy("database fallback capacity exhausted")
+    try:
+        note = loader(note_id)
+    finally:
+        db_slots.release()
+    if note is not None:
+        try:
+            r.set(key(note_id), encode(note), ex=60 + random.randint(-15, 15))
+        except RedisError:
+            pass  # 生产记录 redis_fill_error 计数
+    return note, "database"
+````
+
+#### 4. 停掉本机缓存并验证仍可读取真源
+
+先在终端执行 docker stop notes-redis,仅影响本课专用实例;运行下面脚本,预期来源 database。完成后 docker start notes-redis 并再运行两次,后续可恢复 cache。不要在服务正在高负载时无保护地执行全局故障演练。
+
+文件:cache_outage_probe.py
+
+````python
+import os
+from cache_fallback import get_available
+
+note, source = get_available(int(os.environ["NOTE_ID"]))
+assert note is not None
+print(source, note["id"], note["version"])
+
+# Shell 完成后恢复:docker start notes-redis
+# 本课 Redis 未开启持久化,重启后的缓存需要重新构建。
+````
+
+### 验收结果
+
+- TTL 分布有上下界,批量键没有全部同刻过期。
+- 预热数量和速率有限,未把全库一次性加载。
+- 专用 Redis 停止时仍读到 PostgreSQL 数据,回源超出预算时返回受控错误。
+
+### 生产约束与故障演练
+
+发布或 Redis 重启后大量缓存同时为空,数据库必须保持可用。
+
+#### 实现决策
+
+- 到期抖动、限速预热与故障降级分别处理不同触发条件。
+- 数据库回源有硬并发预算。
+- 允许陈旧数据的范围由业务定义。
+
+#### 隔离环境演练
+
+仅停止 notes-redis,使用有限并发读取并测量回源峰值,随后恢复实例。
+
+#### 恢复验收
+
+- 不会无限回源或无限等待。
+- 数据库错误仍可见。
+- 故障恢复后命中率逐步恢复而非立即全量预热。
+
+### 常见错误
+
+#### 缓存断开后数据库连接数瞬间暴涨
+
+原因:降级没有并发限制,重试与每次新建连接叠加。
+
+处理:按进程规划回源预算,使用池和短等待,超过容量快速拒绝;避免多层重试。
+
+#### 抖动后出现零或负 TTL
+
+原因:随机偏移超过基础期限。
+
+处理:明确最小 TTL 并测试范围,例如 max(1,base+jitter),业务过期上限也要符合要求。
+
+### 扩展练习
+
+在 Redis 停止时用 12 个线程和每次 0.2 秒的 loader 调用 get_available,预测 SourceBusy 的原因。
+
+提示:本进程只有 4 个回源名额,等待最多 0.05 秒。
+
+参考答案:
+
+前 4 个请求可占用回源名额,其余请求可能在名额释放前超时并得到 SourceBusy。实际数量由调度决定,验收应看峰值回源并发不超过 4、等待有界、恢复后正常,而非硬编码某个固定失败数。
+
+### 技术文档
+
+- [Redis:客户端生产使用](https://redis.io/docs/latest/develop/clients/redis-py/produsage/)
+- [Redis:EXPIRE](https://redis.io/docs/latest/commands/expire/)
+- [Redis:内存淘汰](https://redis.io/docs/latest/develop/reference/eviction/)
+
+---
+
+## 07 使用 Pipeline、WATCH 与 Lua 保证正确的原子操作
+
+比较批量发送、乐观事务和 Lua,在 Redis 7 中实现带过期窗口的原子计数限流。
+
+### 本节目标
+
+- 区别流水线减少往返与事务原子执行
+- 处理 WATCH 发现的并发修改
+- 用短 Lua 脚本组合 INCR 和到期设置
+
+### 必要概念
+
+#### Pipeline 首先减少网络往返
+
+transaction=False 的 pipeline 批量发送命令,不让多条业务操作自动变成一个不可分割动作。redis-py pipeline 默认启用事务,教学中明确写参数以免混淆。大批次会占内存和延长等待,应限制规模。
+
+#### WATCH 是提交前的乐观检查
+
+WATCH 键后读取当前值,MULTI 与 EXEC 提交修改;被其他客户端改动时 redis-py 抛 WatchError,应用重新读取并有限重试。Redis 事务与 PostgreSQL 不同,执行阶段某条命令出错不会撤销已经成功的其他命令。
+
+#### Lua 组合命令时先验证输入
+
+短 Lua 脚本在执行期间不被其他命令穿插。原子执行不代表发生运行时错误会回滚此前的写入。参数与数据类型应在变更前校验,脚本必须短,不能用长循环阻塞所有客户端。
+
+### 动手实现
+
+#### 1. 批量发送并理解返回位置
+
+保存为 redis_pipeline.py。transaction=False 只减少往返,返回列表按命令顺序排列。专用键全部带 TTL,并在验收后显式删除。若一次批量中途网络断开,不能仅凭客户端异常判断所有命令都没执行。
+
+文件:redis_pipeline.py
+
+````python
+from redis_client import r
+
+keys = [f"notes:lab:pipeline:{i}" for i in range(3)]
+with r.pipeline(transaction=False) as pipe:
+    for i, k in enumerate(keys):
+        pipe.set(k, str(i), ex=60)
+    for k in keys:
+        pipe.get(k)
+    result = pipe.execute()
+print(result)
+assert result == [True, True, True, "0", "1", "2"]
+r.delete(*keys)
+````
+
+#### 2. 制造 WATCH 冲突并重读
+
+保存为 redis_watch.py。第一次 watch 后,使用客户端的另一次命令修改该键,确保 EXEC 抛 WatchError;第二次重新 watch 和读取,应从值 10 增加到 11。最多尝试 3 次,示例没有无限循环。
+
+文件:redis_watch.py
+
+````python
+from redis import WatchError
+from redis_client import r
+
+k = "notes:lab:watch-counter"
+r.set(k, 0, ex=60)
+for attempt in range(3):
+    with r.pipeline() as pipe:
+        try:
+            pipe.watch(k)
+            current = int(pipe.get(k) or 0)
+            if attempt == 0:
+                r.set(k, 10, ex=60)  # 另一次命令制造冲突
+            pipe.multi()
+            pipe.set(k, current + 1, ex=60)
+            pipe.execute()
+            break
+        except WatchError:
+            print("conflict, retry from read")
+else:
+    raise RuntimeError("retry budget exhausted")
+assert r.get(k) == "11"
+r.delete(k)
+````
+
+#### 3. 实现原子计数与固定期限
+
+保存为 redis_limiter.py。脚本验证限制与窗口后 INCR,首次计数同时 PEXPIRE,避免 INCR 成功但 EXPIRE 未发出的永久计数器。窗口从首次请求开始,不是精确滑动窗口。网络超时重试可能再次计数,业务必须允许保守限流或使用独立请求幂等方案。
+
+文件:redis_limiter.py
+
+````python
+from redis_client import r
+
+limit_script = r.register_script("""
+local limit = tonumber(ARGV[1])
+local window = tonumber(ARGV[2])
+if not limit or limit < 1 or limit % 1 ~= 0 then
+  return redis.error_reply('invalid limit')
+end
+if not window or window < 1 or window % 1 ~= 0 then
+  return redis.error_reply('invalid window')
+end
+local count = redis.call('INCR', KEYS[1])
+if count == 1 then redis.call('PEXPIRE', KEYS[1], window) end
+local allowed = 0
+if count <= limit then allowed = 1 end
+return {allowed, count, redis.call('PTTL', KEYS[1])}
+""")
+
+def allow_request(subject):
+    # subject 必须是鉴权后得到的稳定主体 ID,不能是任意超长输入。
+    k = f"notes:lab:limit:{{{subject}}}"
+    allowed, count, ttl_ms = limit_script(keys=[k], args=[3, 1000])
+    return bool(allowed), count, ttl_ms
+````
+
+#### 4. 验证窗口内拒绝和到期恢复
+
+保存为 redis_limiter_probe.py。前 3 次允许,第 4 次拒绝且返回正的剩余毫秒;约 1.1 秒后新窗口允许。生产映射 429,并根据 TTL 向上取整计算 Retry-After;权限鉴定接口与普通缓存故障时的放行策略必须分别设计。
+
+文件:redis_limiter_probe.py
+
+````python
+import time
+from redis_client import r
+from redis_limiter import allow_request
+
+k = "notes:lab:limit:{probe-user}"
+r.delete(k)
+results = [allow_request("probe-user") for _ in range(4)]
+print(results)
+assert [x[0] for x in results] == [True, True, True, False]
+assert results[-1][2] > 0
+time.sleep(1.1)
+assert allow_request("probe-user")[0] is True
+r.delete(k)
+````
+
+### 验收结果
+
+- Pipeline 返回顺序与命令顺序一致,批量大小有限。
+- WATCH 实验先失败再从新值重试,不覆盖外部修改。
+- 限流前三次允许、第四次拒绝、窗口到期恢复,计数键存在时带 TTL。
+
+### 生产约束与故障演练
+
+API 需要在多进程之间共享请求计数,并避免计数和过期配置分离。
+
+#### 实现决策
+
+- 流水线、事务和 Lua 各用于明确的操作边界。
+- 脚本短且输入受限。
+- 限流故障策略不沿用普通数据缓存的无条件放行。
+
+#### 隔离环境演练
+
+在专用键制造 WATCH 冲突与窗口超额请求,验证有限重试和原子期限。
+
+#### 恢复验收
+
+- 没有永久计数键。
+- 事务错误不被错误描述为自动回滚。
+- 超时重试与边界突发的业务含义明确。
+
+### 常见错误
+
+#### 误把 transaction=False 的 Pipeline 当成原子业务操作
+
+原因:减少网络往返与命令不可交错是不同保证。
+
+处理:需要检查并更新用 WATCH,需要短组合操作用 Lua;明确事务选项。
+
+#### INCR 后进程崩溃留下永不过期限流键
+
+原因:EXPIRE 是另一条命令,没来得及发出。
+
+处理:把首次计数和到期设置放进短脚本;对专用命名空间监控 TTL=-1 异常。
+
+### 扩展练习
+
+窗口边界附近为什么可能在很短时间内通过接近两倍限制的请求?
+
+提示:上一窗口末尾和下一窗口开头分别拥有各自额度。
+
+参考答案:
+
+固定窗口允许边界突发:旧窗口末尾用完额度,过期后新窗口立即重新计数。需要更平滑限制时可选择滑动窗口或令牌桶,评估精度、状态量和脚本成本;不能把本例当作精确的任意 1 秒最多 3 次。
+
+### 技术文档
+
+- [Redis:Pipeline 与事务](https://redis.io/docs/latest/develop/clients/redis-py/transpipe/)
+- [Redis:事务语义](https://redis.io/docs/latest/develop/using-commands/transactions/)
+- [Redis:Lua 脚本](https://redis.io/docs/latest/develop/programmability/eval-intro/)
+- [Redis:INCR](https://redis.io/docs/latest/commands/incr/)
+
+---
+
+## 08 控制缓存容量与淘汰,隔离任务 Broker
+
+在专用容量实例制造淘汰,观察命中率和内存,再区分可丢缓存与不可随意丢弃的队列数据。
+
+### 本节目标
+
+- 读取内存、淘汰和命中指标
+- 根据数据用途选择 allkeys-lru 或 noeviction 等策略
+- 解释为什么逻辑数据库不能替代实例隔离
+
+### 必要概念
+
+#### maxmemory 不是进程总内存上限
+
+Redis 的 maxmemory 控制参与淘汰计算的内存范围,进程 RSS、复制缓冲、持久化重写等还需要额外预算。容器内存上限应留余量,不能把 maxmemory 与容器限制设成完全相同。
+
+#### 淘汰与到期不同
+
+到期按 TTL 删除,淘汰在内存压力下依据策略移除键。allkeys-lru 可从所有键中近似选择较久未使用的键;noeviction 在不足时拒绝某些需要新增内存的写命令。没有任何策略保证缓存永不丢失。
+
+#### 缓存实例不承载任务可靠性
+
+任务 Broker、结果存储、锁与缓存可能要求不同持久化、淘汰和资源策略。逻辑 DB 0 与 DB 1 仍共享同一进程内存、淘汰配置和故障域。队列数据应按任务系统要求使用独立实例,noeviction 也只是拒写,不会自动让队列可靠。
+
+### 动手实现
+
+#### 1. 启动单独容量实验实例
+
+以下 Linux Bash 只创建 notes-redis-capacity,宿主机 6381,与业务练习缓存 6380 分开。读取现有本机配置并只修改 maxmemory。不要对共享 Redis 执行 CONFIG SET 作为临时实验。镜像实例仍使用已有 REDIS_PASSWORD。
+
+文件:终端 / 当前课时
+
+````bash
+python3 - <<'PY'
+from pathlib import Path
+source = Path('redis-lab/redis.conf').read_text()
+Path('redis-lab/capacity.conf').write_text(source.replace('maxmemory 128mb', 'maxmemory 8mb'))
+PY
+docker run --name notes-redis-capacity -d -p 127.0.0.1:6381:6379 \
+  -v "$PWD/redis-lab/capacity.conf:/usr/local/etc/redis/redis.conf:ro" \
+  redis:7 redis-server /usr/local/etc/redis/redis.conf
+````
+
+#### 2. 有限写入制造容量压力
+
+保存为 redis_capacity.py。最多写 2,000 个约 8KB 字符串,带 10 分钟 TTL;请求数和内存实验都有上限。运行前核对端口 6381。记录写入前后 evicted_keys 差值,不能用实例启动以来累计值直接推算本次淘汰。
+
+文件:redis_capacity.py
+
+````python
+import os
+import redis
+
+client = redis.Redis(host="127.0.0.1", port=6381, password=os.environ["REDIS_PASSWORD"],
+                     decode_responses=True, socket_timeout=1, socket_connect_timeout=1)
+before = client.info("stats")
+for i in range(2000):
+    client.set(f"notes:lab:capacity:{i}", "x" * 8192, ex=600)
+after = client.info("stats")
+memory = client.info("memory")
+print("evicted delta", after["evicted_keys"] - before["evicted_keys"])
+print("used / rss / max", memory["used_memory"], memory["used_memory_rss"], memory["maxmemory"])
+print("remaining keys", client.dbsize())
+print("policy", client.config_get("maxmemory-policy"))
+client.close()
+````
+
+#### 3. 区分命中率与淘汰率
+
+在容量实例读取 2,000 个明确键,统计本次命中和未命中。下面本地计数是这批请求的真实比例;INFO 的 keyspace_hits/misses 是实例全局累计值,做监控时用区间增量。不要用 KEYS * 在大实例全量扫描。
+
+文件:redis_capacity_hits.py
+
+````python
+import os
+import redis
+
+client = redis.Redis(host="127.0.0.1", port=6381, password=os.environ["REDIS_PASSWORD"],
+                     decode_responses=True, socket_timeout=1)
+hits = sum(client.get(f"notes:lab:capacity:{i}") is not None for i in range(2000))
+print("hits", hits, "misses", 2000-hits, "ratio", hits/2000)
+stats = client.info("stats")
+print({k: stats[k] for k in ["keyspace_hits", "keyspace_misses", "evicted_keys", "expired_keys"]})
+client.close()
+````
+
+#### 4. 形成缓存与 Broker 的独立配置
+
+把下列配置记录加入部署设计。容量实验完成后执行 docker stop notes-redis-capacity,保留环境便于复查,不触碰 6380。若要演练 noeviction,应复制配置另建独立实例,以收到内存不足写入错误为预期,并验证任务生产端如何处理拒写。
+
+文件:终端 / 当前课时
+
+````text
+普通笔记缓存:独立 cache 实例;明确 maxmemory;可用 allkeys-lru/LFU;值可从 PostgreSQL 重建
+任务 Broker:独立实例;按任务框架要求设置持久化与 noeviction;拒写必须使投递失败可见
+任务结果:独立 TTL 与保留期;持久业务状态放 PostgreSQL
+容量预算:容器限制 > maxmemory,并留复制、AOF 重写、碎片等余量
+告警:used_memory/maxmemory、RSS、evicted_keys 增量、命中率、写入拒绝数
+检查:DB 0 与 DB 1 仅键空间区分,不提供内存、CPU 或故障隔离
+````
+
+### 验收结果
+
+- 专用容量实例 evicted_keys 增长,键数量小于写入总数。
+- 命中率从本次读取统计,INFO 累计指标用增量解释。
+- 设计明确缓存与 Broker 分实例、不同持久化和拒写处理。
+
+### 生产约束与故障演练
+
+缓存规模增长且后台任务开始共用 Redis,需要避免缓存压力导致任务异常。
+
+#### 实现决策
+
+- 实验容量实例独立于其他课程环境。
+- 按进程 RSS 与服务负载规划余量。
+- 可丢数据与可靠任务隔离。
+
+#### 隔离环境演练
+
+在 6381 专用实例有限写入大值,观察淘汰与命中变化,完成后停止该实例。
+
+#### 恢复验收
+
+- 未改动 6380 缓存策略。
+- 队列拒写和任务恢复有处理路径。
+- 告警使用区间指标与业务影响而非单一内存数值。
+
+### 常见错误
+
+#### Redis 尚未到 maxmemory,容器已经 OOM
+
+原因:忽略 RSS、缓冲和持久化工作期间额外内存。
+
+处理:对照容器指标和 used_memory_rss,留容量余量并压测重写与复制场景。
+
+#### 任务突然丢失,但 Redis 没报错
+
+原因:队列与普通缓存共用允许淘汰所有键的实例。
+
+处理:按 Broker 要求迁移到独立实例并禁止随意淘汰,同时完善任务确认、重投和持久业务状态。
+
+### 扩展练习
+
+为什么把 Celery Broker 放 DB 1、缓存放 DB 0 仍可能互相影响?
+
+提示:想清楚 maxmemory、CPU、进程退出和持久化属于哪一层。
+
+参考答案:
+
+逻辑库共享同一 Redis 进程和服务器级配置,缓存写入可触发整体内存压力,CPU 长命令和实例故障也影响队列。需要独立实例与资源预算;不同 DB 编号只能减少键名碰撞。
+
+### 技术文档
+
+- [Redis:键淘汰](https://redis.io/docs/latest/develop/reference/eviction/)
+- [Redis:INFO](https://redis.io/docs/latest/commands/info/)
+- [Redis:内存优化](https://redis.io/docs/latest/operate/oss_and_stack/management/optimization/memory-optimization/)
+
+---
+
+## 09 配置连接池、超时与熔断并记录缓存指标
+
+让 Redis 故障快速暴露,短期跳过持续失败的访问,并在恢复后用探测重新启用缓存。
+
+### 本节目标
+
+- 分别设置连接、读写和池等待超时
+- 实现进程内有限状态熔断示例
+- 记录命中、回源、错误与等待指标
+
+### 必要概念
+
+#### 超时需要放进请求总预算
+
+连接超时、socket 超时、池等待和重试时间会叠加。API 总预算只有一秒时,不能给每层都设置一秒再重试三遍。写命令超时可能已经执行,重试 INCR、队列入队等操作需要额外语义设计。
+
+#### 熔断短期跳过已知失败依赖
+
+连续 Redis 连接错误达到阈值后短暂打开熔断;冷却后只允许一个探测请求,其余请求继续走受控回源。恢复探测成功才关闭。下面是线程安全的进程内教学实现,不是跨所有进程同步的全局开关。
+
+#### 命中率不是唯一目标
+
+高命中率也可能返回陈旧数据,低命中率也可能是新内容正常访问。至少同时观察缓存调用延迟、连接错误、回源并发、数据库延迟、失效失败与版本新鲜度。计数标签不能使用每个 note_id,否则监控基数失控。
+
+### 动手实现
+
+#### 1. 显式配置池与关闭顺序
+
+保存为 redis_pool_client.py。BlockingConnectionPool 让借不到连接时最多等 50 毫秒;connect/socket 各 100 毫秒,Retry(...,0) 禁止本层自动重试,避免示例将预算偷偷翻倍。同步客户端供同步代码使用,async 路由选择 redis.asyncio 并 await。
+
+文件:redis_pool_client.py
+
+````python
+import os
+import redis
+from redis.backoff import NoBackoff
+from redis.retry import Retry
+
+pool = redis.BlockingConnectionPool(
+    host=os.environ.get("REDIS_HOST", "127.0.0.1"),
+    port=int(os.environ.get("REDIS_PORT", "6380")),
+    password=os.environ["REDIS_PASSWORD"],
+    decode_responses=True, max_connections=8, timeout=0.05,
+    socket_connect_timeout=0.1, socket_timeout=0.1,
+    health_check_interval=30, retry=Retry(NoBackoff(), 0),
+)
+client = redis.Redis(connection_pool=pool)
+
+if __name__ == "__main__":
+    try:
+        print(client.ping())
+    finally:
+        client.close()
+        pool.disconnect()
+````
+
+#### 2. 实现线程安全的短期熔断器
+
+保存为 redis_breaker.py。只包裹 Redis 操作;业务 SQL 和 JSON 解析错误不能自动判作 Redis 断网。AUTH 等配置错误应报警修配置,不用无限探测掩盖。调用方对返回的 bypass 标志进入第 06 课的有界回源流程。
+
+文件:redis_breaker.py
+
+````python
+import threading
+import time
+from redis.exceptions import AuthenticationError, ConnectionError, TimeoutError
+
+class RedisBreaker:
+    def __init__(self, threshold=3, cooldown=2):
+        self.threshold, self.cooldown = threshold, cooldown
+        self.failures = 0
+        self.open_until = 0.0
+        self.probing = False
+        self.generation = 0
+        self.lock = threading.Lock()
+
+    def call(self, operation):
+        now = time.monotonic()
+        with self.lock:
+            if self.open_until > now or self.probing:
+                return None, "bypass"
+            if self.open_until:
+                self.probing = True
+                self.generation += 1
+            generation = self.generation
+        try:
+            value = operation()
+        except AuthenticationError:
+            with self.lock:
+                if generation == self.generation:
+                    self.probing = False
+            raise  # 配置错误直接上报,不伪装成普通缓存未命中
+        except (ConnectionError, TimeoutError):
+            with self.lock:
+                if generation == self.generation:
+                    self.failures += 1
+                    if self.probing or self.failures >= self.threshold:
+                        self.open_until = time.monotonic() + self.cooldown
+                        self.generation += 1
+                    self.probing = False
+            return None, "redis-error"
+        except BaseException:
+            with self.lock:
+                if generation == self.generation:
+                    self.probing = False
+            raise
+        with self.lock:
+            if generation == self.generation:
+                self.failures, self.open_until, self.probing = 0, 0.0, False
+        return value, "redis"
+````
+
+#### 3. 不用停服务验证状态转移
+
+保存为 redis_breaker_probe.py。注入固定抛错函数,前 3 次真实失败,第 4 次直接 bypass;冷却后成功探测,下一次正常执行。此脚本先验证顺序行为;实现中的 generation 用于丢弃旧一代在途请求对新状态的覆盖,并发测试还需验证半开阶段只启动一个探测。
+
+文件:redis_breaker_probe.py
+
+````python
+import time
+from redis.exceptions import ConnectionError
+from redis_breaker import RedisBreaker
+
+breaker = RedisBreaker(threshold=3, cooldown=0.1)
+calls = 0
+def broken():
+    global calls
+    calls += 1
+    raise ConnectionError("simulated unavailable")
+
+for _ in range(4):
+    print(breaker.call(broken))
+assert calls == 3
+time.sleep(0.11)
+assert breaker.call(lambda: "PONG") == ("PONG", "redis")
+assert breaker.call(lambda: "cached") == ("cached", "redis")
+print("recovered")
+````
+
+#### 4. 记录实际调用状态与延迟
+
+保存为 cache_metrics_probe.py。故障实验只停止 notes-redis 后运行,再重启后运行。下面状态为教学输出,生产导出聚合 counter/histogram,不记录密码、完整值或每条笔记作为标签。Redis miss 是 value=None 且 status=redis,不能与 bypass 合并成命中。
+
+文件:cache_metrics_probe.py
+
+````python
+import os
+import time
+from note_cache import key
+from redis_pool_client import client, pool
+from redis_breaker import RedisBreaker
+
+breaker = RedisBreaker()
+try:
+    for _ in range(5):
+        started = time.perf_counter()
+        value, status = breaker.call(lambda: client.get(key(int(os.environ["NOTE_ID"]))))
+        hit = status == "redis" and value is not None
+        print({"status":status,"hit":hit,"duration_ms":round((time.perf_counter()-started)*1000,2)})
+finally:
+    client.close()
+    pool.disconnect()
+````
+
+### 验收结果
+
+- 池等待、连接、socket 超时与重试次数均显式设置。
+- 失败阈值、bypass、冷却探测与恢复能通过注入实验验证。
+- 指标区分命中、未命中、Redis 错误和主动绕过,不记录高基数或敏感数据。
+
+### 生产约束与故障演练
+
+Redis 网络故障时,API 需要在可接受延迟内切换到受控回源,并在恢复后重新命中。
+
+#### 实现决策
+
+- 超时预算以完整请求为单位计算。
+- 进程内熔断只负责快速跳过 Redis,回源仍受容量限制。
+- 通过状态代次阻止在途旧响应覆盖新状态,并对半开探测做并发验收。
+
+#### 隔离环境演练
+
+注入连续连接错误验证熔断转移,再用专用实例停止恢复观察实际延迟和来源。
+
+#### 恢复验收
+
+- 故障延迟有上限。
+- 没有把所有 Redis 错误当成普通 miss。
+- 连接池在进程退出时关闭。
+
+### 常见错误
+
+#### Redis 已故障,但每个请求仍等很久
+
+原因:多层超时和自动重试叠加,没有总预算。
+
+处理:画出一次请求的所有等待预算,限制重试所在层;以实测故障延迟验收。
+
+#### 一个并发成功请求把刚打开的熔断立即关闭
+
+原因:多个已在途调用同时回写熔断状态,若删除 generation 检查就会出现旧请求覆盖新状态。
+
+处理:保留 generation 校验:打开熔断或进入半开时推进代次,旧代次结果不改变新状态;补充并发探测测试或使用已有成熟组件。
+
+### 扩展练习
+
+为什么缓存 GET 超时可以考虑回源,而任务入队超时不能直接断言“任务没入队”?
+
+提示:响应丢失不等于服务器没执行命令。
+
+参考答案:
+
+GET 是读取,失败后可按预算回源;入队命令可能已执行但响应丢失,直接再次入队可能重复。使用任务 ID、幂等消费、投递记录与状态核对,重试策略按操作语义设计。
+
+### 技术文档
+
+- [Redis:生产客户端使用](https://redis.io/docs/latest/develop/clients/redis-py/produsage/)
+- [Redis:Python 错误处理](https://redis.io/docs/latest/develop/clients/redis-py/error-handling/)
+- [redis-py 5.3:连接池与重试参数](https://redis.readthedocs.io/en/v5.3.0/connections.html)
+
+---
+
+## 10 演练重启、复制与持久化恢复
+
+对比可重建缓存与开启 AOF 的实验实例,检查副本状态,并建立故障后的业务验收顺序。
+
+### 本节目标
+
+- 验证无持久化缓存重启后的回源
+- 在隔离实例验证 AOF 重启恢复
+- 理解异步复制不等于零数据丢失或自动高可用
+
+### 必要概念
+
+#### RDB 与 AOF 保存不同形式
+
+RDB 保存某个时点的快照;AOF 记录写操作并按 fsync 策略落盘。appendfsync everysec 在故障下仍有近期数据丢失窗口。正常重启看到值还在,只证明该次正常路径,不能据此宣称断电零丢失。
+
+#### 复制不是自动故障转移
+
+Redis 主从默认异步复制,主节点确认写入时副本可能尚未收到。WAIT 可等待副本确认复制,仍不把系统变成强一致存储。生产自动故障转移由 Sentinel、Cluster 或受管平台等承担,部署拓扑与客户端发现都需独立验证。
+
+#### 恢复验收从真源开始
+
+先确认 PostgreSQL 数据完整与可用,再检查 Redis 连接、复制角色、命中和回源限流。缓存可丢时不必恢复每一个旧键,受控重建通常更直接。任务 Broker 恢复还要验收未确认任务与重复消费,不用缓存恢复结果替代任务验收。
+
+### 动手实现
+
+#### 1. 确认无持久化缓存重启后可回源
+
+使用第 02 课 NOTE_ID 预热后,记录缓存存在。执行 docker restart notes-redis,仅重启专用本机实例,然后再次读取应先 database 再 cache。配置 save "" 与 appendonly no 禁用了持久化,但 PostgreSQL 的笔记仍然存在。
+
+文件:cache_restart_probe.py
+
+````python
+import os
+from note_cache import get_note, key
+from redis_client import r
+
+n = int(os.environ["NOTE_ID"])
+print(get_note(n)[1])
+print("cache exists", bool(r.exists(key(n))))
+# 终端执行 docker restart notes-redis 后,再运行:
+# print(get_note(n)[1])  -> database
+# print(get_note(n)[1])  -> cache
+````
+
+#### 2. 创建独立 AOF 实例
+
+以下 Linux Bash 创建 notes-redis-aof,端口 6382,命名卷 notes_redis_aof 保存 /data。由已有配置生成 AOF 配置,主业务缓存 6380 保持原样。正式镜像应记录具体 7.x 版本与摘要,本课程用 redis:7 表示兼容主版本。
+
+文件:终端 / 当前课时
+
+````bash
+python3 - <<'PY'
+from pathlib import Path
+source = Path('redis-lab/redis.conf').read_text()
+source = source.replace('appendonly no', 'appendonly yes\nappendfsync everysec')
+Path('redis-lab/aof.conf').write_text(source)
+PY
+docker run --name notes-redis-aof -d -p 127.0.0.1:6382:6379 \
+  -v "$PWD/redis-lab/aof.conf:/usr/local/etc/redis/redis.conf:ro" \
+  -v notes_redis_aof:/data \
+  redis:7 redis-server /usr/local/etc/redis/redis.conf
+````
+
+#### 3. 验证 AOF 状态与正常重启恢复
+
+保存为 redis_aof_probe.py。第一次运行写入样本并输出 aof_enabled、aof_last_write_status;终端 docker restart notes-redis-aof 后仅执行读取部分,不再次写入掩盖丢失。样本 TTL 10 分钟,重启后剩余时间减少;超过期限消失符合语义。
+
+文件:redis_aof_probe.py
+
+````python
+import os
+import redis
+
+client = redis.Redis(host="127.0.0.1",port=6382,password=os.environ["REDIS_PASSWORD"],
+                     decode_responses=True,socket_timeout=1)
+k = "notes:lab:aof-probe"
+client.set(k,"persist-me",ex=600)  # 只在首次运行执行,重启验证时移除此行
+info = client.info("persistence")
+print({name: info[name] for name in ["aof_enabled","aof_last_write_status","aof_rewrite_in_progress"]})
+print("value",client.get(k),"ttl",client.ttl(k))
+client.close()
+````
+
+#### 4. 建立只读副本并检查复制
+
+为 6382 的 AOF 主实例创建本机 Docker 网络,添加无持久化副本 notes-redis-replica,宿主机 6383。配置中的 masterauth 使用同一练习密码;生产应采用专用 ACL 凭据和加密通道。副本首次同步可能需要等待,INFO replication 中 master_link_status=up 才进入验收。
+
+文件:终端 / 当前课时
+
+````bash
+docker network create notes-redis-lab
+docker network connect notes-redis-lab notes-redis-aof
+python3 - <<'PY'
+import os
+from pathlib import Path
+source = Path('redis-lab/redis.conf').read_text()
+source += 'replicaof notes-redis-aof 6379\n'
+source += f"masterauth {os.environ['REDIS_PASSWORD']}\nreplica-read-only yes\n"
+Path('redis-lab/replica.conf').write_text(source)
+PY
+docker run --name notes-redis-replica --network notes-redis-lab -d \
+  -p 127.0.0.1:6383:6379 \
+  -v "$PWD/redis-lab/replica.conf:/usr/local/etc/redis/redis.conf:ro" \
+  redis:7 redis-server /usr/local/etc/redis/redis.conf
+````
+
+#### 5. 核对复制、主节点故障和恢复边界
+
+保存为 redis_replica_probe.py。WAIT 需要在同一连接跟随写入,pipeline(transaction=False) 保持同一连接发送两个命令。返回副本确认数至少 1 后读取副本,正常条件下得到新值。随后只停止 notes-redis-aof,副本不会自动升主;检查链路转为 down 后恢复主实例。演练结束停止 aof 与 replica 两个专用实例。
+
+文件:redis_replica_probe.py
+
+````python
+import os
+import redis
+
+options = dict(host="127.0.0.1",password=os.environ["REDIS_PASSWORD"],decode_responses=True,
+               socket_timeout=2,socket_connect_timeout=1)
+primary = redis.Redis(port=6382,**options)
+replica = redis.Redis(port=6383,**options)
+try:
+    print("replica",replica.info("replication")["master_link_status"])
+    with primary.pipeline(transaction=False) as pipe:
+        pipe.set("notes:lab:replica-probe","replicated",ex=600)
+        pipe.wait(1,1000)
+        written,acknowledged = pipe.execute()
+    print("acknowledged",acknowledged)
+    assert acknowledged >= 1
+    print("replica value",replica.get("notes:lab:replica-probe"))
+finally:
+    primary.close()
+    replica.close()
+
+# Linux 故障操作仅针对专用实例:docker stop notes-redis-aof
+# 检查副本 INFO replication;不会自动升主。
+# 恢复:docker start notes-redis-aof
+# 演练结束:docker stop notes-redis-aof notes-redis-replica
+````
+
+### 验收结果
+
+- 6380 重启清空缓存后,业务笔记能从 PostgreSQL 读取并重建。
+- 6382 AOF 正常重启后样本仍可读取,TTL 正常减少,持久化状态无错误。
+- 副本连接正常时能读取复制值,主节点停止后不误认为已实现自动故障转移。
+
+### 生产约束与故障演练
+
+缓存节点需要维护重启,团队必须知道数据会丢在哪里、服务如何继续以及恢复验收是什么。
+
+#### 实现决策
+
+- 按数据用途决定是否持久化。
+- 正常重启、异常断电与主从切换分开测试。
+- 复制不是数据库真源和任务幂等性的替代。
+
+#### 隔离环境演练
+
+仅重启或停止 6380/6382/6383 这些本机专用实例,分别记录回源、AOF 和复制状态,再恢复或停止实验环境。
+
+#### 恢复验收
+
+- 真实笔记不丢失。
+- 没有宣称 everysec 零丢失。
+- 未部署自动选主时明确记录为手动恢复实验。
+
+### 常见错误
+
+#### 启动了副本就宣称零丢失高可用
+
+原因:忽略异步复制窗口、自动选主与客户端切换。
+
+处理:分别验证复制延迟、故障转移组件和客户端发现;关键业务状态保存在适合其可靠性要求的真源。
+
+#### 重启恢复测试总成功,实际上每次都重新 SET
+
+原因:验收脚本在读取之前又写入样本,掩盖持久化失败。
+
+处理:将写入阶段与重启后只读验收分开,记录初始值、时间、TTL 和持久化状态。
+
+#### 副本长期显示 master_link_status=down
+
+原因:Docker 网络、主实例名称或 masterauth 不匹配。
+
+处理:检查主实例是否加入 notes-redis-lab、名称是否一致,以及双方认证配置;不要通过关掉认证规避问题。
+
+### 扩展练习
+
+为缓存实例故障写一份最短恢复顺序,并说明是否必须先恢复所有旧缓存键。
+
+提示:先保证真源和受控回源,再逐步恢复缓存。
+
+参考答案:
+
+确认 PostgreSQL 正常,启用回源限流;检查 Redis 实例身份、认证和配置;恢复连接后小批次预热热点并观察错误率与数据库延迟。可重建缓存不必恢复所有旧键。任务 Broker 需要额外检查积压、未确认任务和重复消费,不能沿用此简化流程。
+
+### 技术文档
+
+- [Redis:持久化](https://redis.io/docs/latest/operate/oss_and_stack/management/persistence/)
+- [Redis:复制](https://redis.io/docs/latest/operate/oss_and_stack/management/replication/)
+- [Redis:WAIT](https://redis.io/docs/latest/commands/wait/)
+- [Redis:Sentinel](https://redis.io/docs/latest/operate/oss_and_stack/management/sentinel/)

+ 3194 - 0
public/downloads/tasks-lessons.md

@@ -0,0 +1,3194 @@
+# 异步任务开发
+
+术核 AI · 后端工程课件
+
+## 01 提交导出任务,返回 202 与可查询状态
+
+为 notes-service 建立导出任务资源,先固定状态转换与查询协议,再接入执行器。
+
+### 本节目标
+
+- 区分已受理与已完成
+- 实现 POST /exports 和 GET /exports/{job_id}
+- 拒绝不合法的状态转换
+
+### 必要概念
+
+#### 202 的含义
+
+202 只说明服务受理了请求,不能表示文件已经产生。提交响应应提供 job_id、当前状态和查询地址;最终结果通过查询接口获取。
+
+#### 状态转换是业务规则
+
+本模块使用 queued、running、succeeded、failed、cancelled。running 才能进入成功或失败;终态不再被旧执行者改写。取消请求与已取消会在后续拆开处理。
+
+#### 先使用受控的内存实验
+
+本课只建立协议,不启动后台执行。记录存在当前进程内,重启会丢失。第 11 课才把状态迁入 PostgreSQL;不能把本课 202 作为可靠交付承诺。
+
+### 动手实现
+
+#### 1. 准备隔离练习目录
+
+在 notes-service 下创建 tasks_lab 并进入,使用 Python 3.11+ 虚拟环境。第 01–07 课每个文件独立运行。Windows 可用 py -3 -m venv .venv 和 .\.venv\Scripts\Activate.ps1;下面是 Linux/WSL2 命令。没有启动任何外部生产服务。
+
+文件:终端 / 当前课时
+
+````bash
+mkdir tasks_lab
+cd tasks_lab
+python3 -m venv .venv
+source .venv/bin/activate
+python -m pip install "fastapi>=0.115,<1" "uvicorn[standard]>=0.30,<1" "httpx>=0.27,<1"
+python --version
+````
+
+#### 2. 定义状态机
+
+创建 api_states.py。transition 只用于同进程实验,先检查再修改;错误时不能留下半更新记录。保留原始 note_ids,后续任务执行只接收业务标识或可序列化快照。
+
+文件:api_states.py
+
+````python
+from uuid import uuid4
+from fastapi import FastAPI, HTTPException, Response
+from pydantic import BaseModel, Field
+
+app = FastAPI()
+jobs: dict[str, dict] = {}
+allowed = {
+    "queued": {"running", "cancelled"},
+    "running": {"succeeded", "failed", "cancelled"},
+    "succeeded": set(), "failed": set(), "cancelled": set(),
+}
+
+class ExportIn(BaseModel):
+    note_ids: list[int] = Field(min_length=1, max_length=100)
+
+def transition(job_id: str, target: str):
+    row = jobs[job_id]
+    if target not in allowed[row["status"]]:
+        raise ValueError(f'{row["status"]} -> {target} is not allowed')
+    row["status"] = target
+````
+
+#### 3. 增加提交与查询
+
+把这段追加到同一文件。返回 202 和 Location,查询不存在的编号返回 404。当前所有任务保持 queued,不能通过等待假定它会完成。运行 python -m uvicorn api_states:app --reload 后在 /docs 操作。
+
+文件:api_states.py
+
+````python
+@app.post("/exports", status_code=202)
+async def submit_export(body: ExportIn, response: Response):
+    job_id = str(uuid4())
+    jobs[job_id] = {
+        "id": job_id, "status": "queued",
+        "note_ids": body.note_ids, "result": None, "error": None,
+    }
+    response.headers["Location"] = f"/exports/{job_id}"
+    return jobs[job_id]
+
+@app.get("/exports/{job_id}")
+async def get_export(job_id: str):
+    if job_id not in jobs:
+        raise HTTPException(404, "导出任务不存在")
+    return jobs[job_id]
+````
+
+#### 4. 验证成功与非法转换
+
+单独创建 verify_states.py,执行 python verify_states.py。TestClient 与 transition 在同一进程共享字典,这不是修改另一个已启动服务的状态。记录 202、Location、queued→running→succeeded,以及终态回退失败。
+
+文件:verify_states.py
+
+````python
+from fastapi.testclient import TestClient
+from api_states import app, jobs, transition
+
+with TestClient(app) as client:
+    response = client.post("/exports", json={"note_ids": [1, 2]})
+    assert response.status_code == 202
+    job_id = response.json()["id"]
+    assert response.headers["location"] == f"/exports/{job_id}"
+    transition(job_id, "running")
+    jobs[job_id]["result"] = {"rows": 2}
+    transition(job_id, "succeeded")
+    assert client.get(f"/exports/{job_id}").json()["status"] == "succeeded"
+    try:
+        transition(job_id, "running")
+    except ValueError:
+        print("terminal transition rejected")
+````
+
+### 验收结果
+
+- POST 返回 202,Location 能查询到同一 job_id。
+- 新提交任务为 queued,未知 job_id 返回 404。
+- succeeded→running 被拒绝,原状态仍为 succeeded。
+
+### 生产约束与故障演练
+
+用户导出 100 条笔记,希望关闭页面后仍能查询结果。
+
+#### 实现决策
+
+- job_id 是独立资源标识,不能用请求连接标识代替。
+- 正式受理需要持久化输入与任务记录,本课内存接口不能承担该承诺。
+- 结果保存引用和校验信息,不把大文件塞进查询响应。
+
+#### 隔离环境演练
+
+只在本地提交一条 queued 任务后重启服务,记录查询变成404,说明进程内存的丢失边界。
+
+#### 恢复验收
+
+- 接口能区分受理、运行与终态。
+- 非法终态回退不能覆盖结果。
+- 文档明确本课没有自动执行和持久化。
+
+### 常见错误
+
+#### 客户端把 202 当成下载成功
+
+原因:受理与执行完成混在一个判断中。
+
+处理:仅在查询得到 succeeded 且存在结果引用后开放下载。
+
+#### 重启后已受理任务全部消失
+
+原因:状态只存在内存,尚未连接可靠执行系统。
+
+处理:将本课限定为协议实验,后续使用持久状态和 outbox。
+
+### 扩展练习
+
+增加 GET 查询中的 terminal 布尔字段,验证 queued 为 false、succeeded 为 true。
+
+提示:根据状态计算字段,不另存一份可能不同步的布尔值。
+
+参考答案:
+
+返回记录时增加 terminal=row['status'] in {'succeeded','failed','cancelled'};不要让客户端传入 terminal。
+
+### 技术文档
+
+- [HTTP 202](https://www.rfc-editor.org/rfc/rfc9110.html#section-15.3.3)
+- [FastAPI 状态码](https://fastapi.tiangolo.com/tutorial/response-status-code/)
+
+---
+
+## 02 用 TaskGroup、队列与超时约束 asyncio
+
+批量读取笔记时同时限制执行中的 I/O 和等待处理的数据,取消时回收全部子任务。
+
+### 本节目标
+
+- 使用 Python 3.11 的 TaskGroup 管理子任务
+- 用有限队列和 Semaphore 限制并发与排队
+- 让总超时触发取消并验证 finally 执行
+
+### 必要概念
+
+#### 并发数与任务数量是两种限制
+
+Semaphore 控制同时进入临界区的协程数量,却不会阻止一次创建十万个 Task。有限 Queue 加固定数量消费者,同时限制等待项和任务对象数量。
+
+#### TaskGroup 管理一组相关任务
+
+退出 TaskGroup 时等待子任务;子任务出现非取消异常时会取消其余任务,并以 ExceptionGroup 传播。它不会把任务保存到磁盘,也不负责服务重启后恢复。
+
+#### 超时通过取消实现
+
+asyncio.timeout() 超时后在上下文外捕获 TimeoutError。协程在 await 等位置响应取消,finally 适合清理;捕获 CancelledError 后应重新抛出。
+
+### 动手实现
+
+#### 1. 定义可等待的读取函数
+
+新建 bounded_async.py,使用 sleep 代替笔记 API 的 I/O。把输出保存在字典,以 note_id 建立对应关系;实际外部调用还必须设置连接和读取超时。
+
+文件:bounded_async.py
+
+````python
+import asyncio
+
+async def read_note(note_id: int):
+    await asyncio.sleep(0.1)
+    return {"id": note_id, "title": f"note-{note_id}"}
+````
+
+#### 2. 建立固定消费者与有限缓冲
+
+追加函数。四个消费者最多有两个同时读取;队列最多保存四项。None 是本实验的结束哨兵,真实输入不使用 None。task_done 必须放在 finally,失败也要结清已取出的项。
+
+文件:bounded_async.py
+
+````python
+async def collect_notes(note_ids):
+    queue = asyncio.Queue(maxsize=4)
+    slots = asyncio.Semaphore(2)
+    results = {}
+
+    async def consume():
+        try:
+            while True:
+                note_id = await queue.get()
+                try:
+                    if note_id is None:
+                        return
+                    async with slots:
+                        results[note_id] = await read_note(note_id)
+                finally:
+                    queue.task_done()
+        finally:
+            print("consumer closed")
+
+    async with asyncio.TaskGroup() as group:
+        for _ in range(4):
+            group.create_task(consume())
+        for note_id in note_ids:
+            await queue.put(note_id)
+        for _ in range(4):
+            await queue.put(None)
+        await queue.join()
+    return results
+````
+
+#### 3. 给整个批次设置截止时间
+
+追加入口,执行 python bounded_async.py。正常预算 3 秒足够处理 20 条;把预算改成 0.15 秒复现取消。TimeoutError 在 timeout 上下文外处理,不能返回一个看似完整的部分结果。
+
+文件:bounded_async.py
+
+````python
+async def main():
+    try:
+        async with asyncio.timeout(3):
+            rows = await collect_notes(range(20))
+        print("completed", len(rows))
+    except TimeoutError:
+        print("batch timed out")
+
+if __name__ == "__main__":
+    asyncio.run(main())
+````
+
+#### 4. 复现单条错误与协作取消
+
+临时在 read_note 中加入下面两行,再次执行。TaskGroup 失败应停止整批,其余消费者均输出清理日志。随后恢复函数;如果产品允许部分成功,应另行定义逐条错误结果,不能默默吞掉异常。
+
+文件:bounded_async.py
+
+````python
+# 临时插入 read_note 的 await 之前:
+# if note_id == 3:
+#     raise ValueError("invalid note for drill")
+
+# 如果明确需要观察组合异常,可在入口外围写:
+# try:
+#     asyncio.run(main())
+# except* ValueError as group:
+#     print("batch failed", len(group.exceptions))
+````
+
+### 验收结果
+
+- 正常分支返回20条对应编号数据,只有固定四个消费者。
+- 0.15秒总预算触发 batch timed out,四个消费者执行清理。
+- 单条 ValueError 导致整批失败,未返回伪造的完整结果。
+
+### 生产约束与故障演练
+
+一次导出需要读取大量笔记,不能耗尽连接池和内存。
+
+#### 实现决策
+
+- 队列上限、消费者数和I/O并发数分别配置。
+- 整批失败与部分成功必须由接口契约决定。
+- 总截止时间与每次I/O超时同时存在,超时不代表外部请求未生效。
+
+#### 隔离环境演练
+
+在本地第3条输入抛出异常,再用0.15秒预算运行,记录两种情况下消费者关闭与结果行为。
+
+#### 恢复验收
+
+- 并发峰值≤2、排队容量≤4。
+- 异常和超时都没有遗留未等待任务。
+- 不会把部分数据标记为完整导出。
+
+### 常见错误
+
+#### 用了Semaphore仍消耗大量内存
+
+原因:先为全部笔记创建任务,再限制内部I/O。
+
+处理:改为固定消费者和有界Queue,生产端await put自然等待。
+
+#### 超时后批次仍持续运行
+
+原因:子协程吞掉CancelledError或长期没有await。
+
+处理:清理后重新抛出取消,阻塞I/O移到下一课的合适执行器。
+
+### 扩展练习
+
+给 read_note 加运行中计数与峰值,验证最大同时读取数不超过2。
+
+提示:计数增加、减少包围真实I/O,减少放finally。
+
+参考答案:
+
+在Semaphore内部增加计数并更新峰值;try/finally中减少。正常与超时路径峰值均≤2,结束后运行中计数为0。
+
+### 技术文档
+
+- [Python 3.11 TaskGroup 与 timeout](https://docs.python.org/3.11/library/asyncio-task.html)
+- [asyncio.Queue](https://docs.python.org/3.11/library/asyncio-queue.html)
+
+---
+
+## 03 区分 BackgroundTasks 与 create_task 的生命周期
+
+在响应后执行一个短任务,观察进程退出、异常回收和重启丢失。
+
+### 本节目标
+
+- 使用 FastAPI BackgroundTasks 执行响应后工作
+- 保存 create_task 的强引用并获取异常
+- 在 lifespan 中停止接受任务并等待有界清理
+
+### 必要概念
+
+#### BackgroundTasks 仍属于当前应用进程
+
+任务在响应发出后运行,不是持久队列。执行异常无法把已发送的202改成500;重要结果必须通过任务状态和日志记录。
+
+#### create_task 需要显式管理
+
+保存强引用,读取完成异常,退出时决定等待或取消。只调用create_task不会自动得到重试、限流或跨进程恢复。
+
+#### 进程生命周期不是业务完成保证
+
+正常关闭可以给任务清理时间;强制终止、崩溃和机器断电不会执行finally。会影响用户资产的任务应进入后续持久队列方案。
+
+### 动手实现
+
+#### 1. 运行响应后的小任务
+
+新建 api_background.py,复用第01课的app和状态。这里新增独立路径,避免覆盖原来只受理的/exports。用 python -m uvicorn api_background:app 启动;此实验不要开reload以免编辑时无意重启。
+
+文件:api_background.py
+
+````python
+import asyncio
+from uuid import uuid4
+from fastapi import BackgroundTasks, Response
+from api_states import app, jobs, transition, ExportIn
+
+async def local_export(job_id: str):
+    transition(job_id, "running")
+    try:
+        await asyncio.sleep(1)
+        jobs[job_id]["result"] = {"rows": len(jobs[job_id]["note_ids"])}
+        transition(job_id, "succeeded")
+    except asyncio.CancelledError:
+        transition(job_id, "cancelled")
+        raise
+    except Exception as exc:
+        jobs[job_id]["error"] = type(exc).__name__
+        transition(job_id, "failed")
+
+@app.post("/exports/background", status_code=202)
+async def submit_local(body: ExportIn, background: BackgroundTasks, response: Response):
+    job_id = str(uuid4())
+    jobs[job_id] = {"id": job_id, "status": "queued",
+                    "note_ids": body.note_ids, "result": None, "error": None}
+    background.add_task(local_export, job_id)
+    response.headers["Location"] = f"/exports/{job_id}"
+    return jobs[job_id]
+````
+
+#### 2. 追踪自行创建的Task
+
+新建 task_registry.py。done回调既移除强引用,也获取异常;不要在回调里再次抛出未处理异常。这个组件尚未暴露提交路由,下一步只在独立生命周期实验里使用。
+
+文件:task_registry.py
+
+````python
+import asyncio
+import logging
+
+running: set[asyncio.Task] = set()
+
+def observe(task):
+    running.discard(task)
+    if task.cancelled():
+        return
+    error = task.exception()
+    if error is not None:
+        logging.error("local task failed: %r", error)
+
+def start(coro):
+    task = asyncio.create_task(coro)
+    running.add(task)
+    task.add_done_callback(observe)
+    return task
+````
+
+#### 3. 关闭时等待,再协作取消
+
+新建 registry_demo.py独立运行。先给3秒自然完成窗口,剩余任务发取消并等待清理。此例子仅含遵守取消的sleep;不能对任意阻塞库宣称3秒后必定退出。
+
+文件:registry_demo.py
+
+````python
+import asyncio
+from task_registry import start, running
+
+async def work():
+    try:
+        await asyncio.sleep(30)
+    finally:
+        print("cleanup")
+
+async def main():
+    start(work())
+    await asyncio.sleep(0.1)
+    pending = set(running)
+    if pending:
+        _, pending = await asyncio.wait(pending, timeout=3)
+    for task in pending:
+        task.cancel()
+    await asyncio.gather(*pending, return_exceptions=True)
+    print("remaining", len(running))
+
+if __name__ == "__main__":
+    asyncio.run(main())
+````
+
+#### 4. 记录正常停止和崩溃的差异
+
+提交 /exports/background 并轮询第01课查询路径,观察running到succeeded。再将sleep临时改为30秒,仅在本地终止该实验进程并重启;原job_id查询404。比较正常关闭可能等待与强制退出丢失,随后恢复1秒。
+
+文件:终端 / 当前课时
+
+````http
+POST /exports/background
+Content-Type: application/json
+
+{"note_ids":[1,2]}
+
+# 保存202响应中的id,随后查询:
+GET /exports/{job_id}
+````
+
+### 验收结果
+
+- 短任务在响应后运行,查询最终为succeeded。
+- registry_demo输出cleanup,且未保留运行中的Task。
+- 重启使内存任务消失,实验记录不把它解释为可自动恢复。
+
+### 生产约束与故障演练
+
+接口响应后写一条可丢失的诊断信息,与必须完成的用户导出同时存在。
+
+#### 实现决策
+
+- 短且允许丢失的工作可留在应用进程。
+- 用户导出不能只依赖BackgroundTasks或create_task。
+- 退出先关闭入口,再给任务有界清理时间,最终保证由持久系统承担。
+
+#### 隔离环境演练
+
+本地延长任务后终止应用,重启查询旧id,验证不存在自动恢复机制。
+
+#### 恢复验收
+
+- Task引用和异常都被回收。
+- 失败状态与HTTP受理状态分开呈现。
+- 没有将应用进程任务描述为持久队列。
+
+### 常见错误
+
+#### 返回202后异常只出现在终端
+
+原因:响应已发送,后台工作不再控制这次HTTP状态。
+
+处理:将异常转为任务failed并记录关联job_id。
+
+#### Task exception was never retrieved
+
+原因:create_task任务失败但无人await或读取异常。
+
+处理:保存引用并获取完成异常,或对相关工作使用TaskGroup。
+
+### 扩展练习
+
+让work在0.1秒后抛出ValueError,确认回调记录异常且running恢复为空。
+
+提示:不要用空except隐藏错误。
+
+参考答案:
+
+将sleep后加raise ValueError('drill');observe记录错误,回调移除任务引用,程序结束时无未获取异常提示。
+
+### 技术文档
+
+- [FastAPI BackgroundTasks](https://fastapi.tiangolo.com/tutorial/background-tasks/)
+- [Python create_task与取消](https://docs.python.org/3.11/library/asyncio-task.html)
+
+---
+
+## 04 用 Lock 与 Queue 管理线程共享状态
+
+两个线程处理笔记导出片段,保证每条任务结清并限制等待队列。
+
+### 本节目标
+
+- 用Lock保护复合读改写
+- 用Queue传递线程间任务
+- 用哨兵和join完成可预测退出
+
+### 必要概念
+
+#### GIL 不提供业务事务
+
+字典读改写涉及多个步骤,不能用GIL代替业务锁。锁只保护同一进程中的共享状态,不能协调多个Celery进程或多台机器。
+
+#### Queue 同步与业务状态分开
+
+queue.Queue提供线程安全的put/get,有界容量用于限制待处理项。取出后必须对应task_done,join等待的是计数清零,不是线程自然退出。
+
+#### 锁内只做短操作
+
+耗时读取、网络等待、文件写入放到锁外。不要在持锁期间等待另一个需要相同锁的任务,避免死锁和无意义串行化。
+
+### 动手实现
+
+#### 1. 定义共享计数和有限队列
+
+创建 threaded_queue.py。每个输入只是一条笔记编号,结果计数属于这个脚本;不要把该锁当成PostgreSQL记录锁。
+
+文件:threaded_queue.py
+
+````python
+from queue import Queue
+from threading import Lock, Thread
+import time
+
+pending = Queue(maxsize=4)
+mutex = Lock()
+counts = {"done": 0, "failed": 0}
+errors = []
+````
+
+#### 2. 实现保证结清的消费者
+
+追加consumer。异常时记录失败,finally中task_done;即使收到结束哨兵也要结清那一次get。模拟耗时发生在锁外,锁中只修改计数。
+
+文件:threaded_queue.py
+
+````python
+def consume():
+    while True:
+        note_id = pending.get()
+        try:
+            if note_id is None:
+                return
+            time.sleep(0.05)
+            if note_id == 7:
+                raise ValueError("bad note for drill")
+            with mutex:
+                counts["done"] += 1
+        except Exception as exc:
+            with mutex:
+                counts["failed"] += 1
+                errors.append((note_id, type(exc).__name__))
+        finally:
+            pending.task_done()
+````
+
+#### 3. 启动、入队与退出
+
+追加并执行脚本。非daemon线程让退出路径可见。先发送全部编号,再发送两个哨兵;队列满时put等待消费者释放容量。
+
+文件:threaded_queue.py
+
+````python
+if __name__ == "__main__":
+    threads = [Thread(target=consume) for _ in range(2)]
+    for thread in threads:
+        thread.start()
+    for note_id in range(20):
+        pending.put(note_id)
+    for _ in threads:
+        pending.put(None)
+    pending.join()
+    for thread in threads:
+        thread.join()
+    print(counts, errors)
+````
+
+#### 4. 验证失败项不会卡住队列
+
+预期done=19、failed=1、错误编号7。再把失败条件改成两个编号,确认总数仍20。不要用empty()判断所有任务完成:队列已空时消费者仍可能在处理。
+
+文件:threaded_queue.py
+
+````python
+# 验收断言可放在所有thread.join之后:
+assert counts["done"] + counts["failed"] == 20
+assert all(not thread.is_alive() for thread in threads)
+assert len(errors) == counts["failed"]
+````
+
+### 验收结果
+
+- 完成数与失败数合计20,错误记录对应失败编号。
+- pending.join能够返回,全部线程退出。
+- 模拟耗时发生在mutex保护范围之外。
+
+### 生产约束与故障演练
+
+同步笔记读取库只能在线程中调用,希望稳定处理小批量导出。
+
+#### 实现决策
+
+- 共享状态采用短临界区;每个线程自行管理不支持共享的外部连接。
+- 队列明确容量,过载时上游等待或拒绝。
+- 线程失败必须成为记录中的失败项,不能静默丢失。
+
+#### 隔离环境演练
+
+让第7条输入抛异常并把Queue容量缩小到1,验证错误收集和任务结清。
+
+#### 恢复验收
+
+- 所有入队项都有成功、失败或明确取消结论。
+- join不依赖empty/qsize的瞬间值。
+- 线程锁没有被当成跨进程幂等方案。
+
+### 常见错误
+
+#### queue.join永远不返回
+
+原因:异常或哨兵分支漏了task_done。
+
+处理:让每次成功get都在finally中task_done一次。
+
+#### 两个线程耗时与一个相同
+
+原因:持有锁时执行了sleep或阻塞I/O。
+
+处理:只把共享计数更新放在锁里,I/O放在锁外。
+
+### 扩展练习
+
+把队列容量改成1,并让消费者每条等待0.2秒,观察生产者入队会等待。
+
+提示:在put前后记录perf_counter,不能只记录消费者耗时。
+
+参考答案:
+
+生产者前几次put后会因队列满等待;消费者释放位置后继续。最终仍完成20项且线程正常退出。
+
+### 技术文档
+
+- [Python threading.Lock](https://docs.python.org/3.11/library/threading.html)
+- [Python Queue](https://docs.python.org/3.11/library/queue.html)
+
+---
+
+## 05 在线程池中调用阻塞 I/O
+
+限制提交数量,为等待设置超时,并验证取消 Future 不会强制终止已经运行的线程。
+
+### 本节目标
+
+- 使用ThreadPoolExecutor执行同步读取
+- 限制正在执行与已提交的工作数量
+- 区分等待超时、取消成功与线程实际退出
+
+### 必要概念
+
+#### 线程池只限制同时执行数
+
+max_workers不会限制任意多次submit累积的待执行Future。调用方仍需限批提交、有界队列或接入限流。
+
+#### Future超时不等于函数停止
+
+future.result(timeout=...)只限制调用方等待时间。cancel只能取消尚未运行的工作,无法安全强杀运行中的线程。
+
+#### 取消需要协作与I/O超时
+
+给同步库设置超时,长循环定期检查Event。with退出线程池默认等待工作完成,不能靠离开with逃避仍在运行的阻塞函数。
+
+### 动手实现
+
+#### 1. 建立同步I/O模拟函数
+
+创建 pool_io.py。函数每小段等待后检查停止事件;本例0.1秒是可控制的模拟I/O,真实库须使用其自身timeout参数。
+
+文件:pool_io.py
+
+````python
+import time
+from threading import Event
+from concurrent.futures import ThreadPoolExecutor, TimeoutError
+
+def read_sync(note_id, stop):
+    for _ in range(20):
+        if stop.is_set():
+            return {"id": note_id, "cancelled": True}
+        time.sleep(0.1)
+    return {"id": note_id, "cancelled": False}
+````
+
+#### 2. 观察运行中Future不能被取消
+
+追加并执行。只有一个任务提交到一个线程,确保实验没有无限排队。0.2秒时通常已经开始执行,cancel返回False;设置stop后等待实际退出。
+
+文件:pool_io.py
+
+````python
+if __name__ == "__main__":
+    stop = Event()
+    with ThreadPoolExecutor(max_workers=1) as pool:
+        future = pool.submit(read_sync, 1, stop)
+        try:
+            print(future.result(timeout=0.2))
+        except TimeoutError:
+            print("wait timed out; cancel:", future.cancel())
+            stop.set()
+            print("worker result:", future.result(timeout=1))
+    print("pool closed")
+````
+
+#### 3. 按批限制待执行Future
+
+另建 batch_io.py,导入同一同步函数。每次只提交四项,完成后才提交下一批;这会牺牲少量流水线利用率,但不会无限占用内存。不要把本例的循环直接放进async路由阻塞事件循环。
+
+文件:batch_io.py
+
+````python
+from concurrent.futures import ThreadPoolExecutor, as_completed
+from threading import Event
+from pool_io import read_sync
+
+def export_batches(note_ids):
+    stop = Event()
+    results = []
+    with ThreadPoolExecutor(max_workers=2) as pool:
+        for start in range(0, len(note_ids), 4):
+            batch = note_ids[start:start + 4]
+            futures = [pool.submit(read_sync, item, stop) for item in batch]
+            for future in as_completed(futures):
+                results.append(future.result())
+    return results
+
+if __name__ == "__main__":
+    print(len(export_batches(list(range(8)))))
+````
+
+#### 4. 在异步调用侧保留同样的退出边界
+
+在独立脚本中用asyncio.to_thread调用read_sync,并在超时后设置Event。to_thread使用的底层线程不会因为await被取消就自动消失;业务确认停止必须来自工作函数结束或持久任务状态。
+
+文件:async_thread.py
+
+````python
+import asyncio
+from threading import Event
+from pool_io import read_sync
+
+async def demo():
+    stop = Event()
+    try:
+        async with asyncio.timeout(0.2):
+            return await asyncio.to_thread(read_sync, 1, stop)
+    except TimeoutError:
+        stop.set()
+        print("stop requested, thread exits cooperatively")
+
+if __name__ == "__main__":
+    asyncio.run(demo())
+````
+
+### 验收结果
+
+- 等待超时被捕获,运行中的cancel返回False。
+- 协作停止后工作结果为cancelled=True,线程池最终关闭。
+- 批处理每批最多4个Future,同时执行最多2个。
+
+### 生产约束与故障演练
+
+导出调用只有同步接口的存储SDK,接口请求可能先超时。
+
+#### 实现决策
+
+- 同步SDK必须有连接和读写超时。
+- 线程数、待提交数量与外部服务额度一起限制。
+- 超时之后的外部副作用依靠幂等与核对,不假定取消已经回滚。
+
+#### 隔离环境演练
+
+本地让read_sync运行2秒,0.2秒停止等待,记录cancel返回值和线程实际结束时间。
+
+#### 恢复验收
+
+- 没有声称Future.cancel能强杀线程。
+- 线程池退出后没有遗留工作线程。
+- 超时状态不被误当成外部操作未执行。
+
+### 常见错误
+
+#### 接口已返回超时,外部写入却仍发生
+
+原因:调用方取消了等待,没有停止底层线程。
+
+处理:外部操作需要幂等键和明确状态,给库设置超时并使用协作取消。
+
+#### 离开with仍长时间卡住
+
+原因:线程池正在等待没有超时的阻塞调用完成。
+
+处理:先修正I/O本身的时间上限,不把shutdown当强制终止接口。
+
+### 扩展练习
+
+增加一个尚未运行的Future,验证排队任务cancel可能为True,而正在运行的任务为False。
+
+提示:max_workers=1,先提交一个长任务,再立即提交第二个。
+
+参考答案:
+
+第二个尚未运行时cancel返回True,不能再对它当作成功结果调用;首个仍需stop.set并等待实际退出。
+
+### 技术文档
+
+- [ThreadPoolExecutor与Future](https://docs.python.org/3.11/library/concurrent.futures.html)
+- [asyncio.to_thread](https://docs.python.org/3.11/library/asyncio-task.html)
+
+---
+
+## 06 用进程池处理 CPU 密集导出
+
+把纯 Python 文本统计移入独立进程,明确序列化、spawn 与进程入口要求。
+
+### 本节目标
+
+- 将CPU函数定义在可导入模块顶层
+- 使用spawn和主入口保护启动进程池
+- 比较串行与进程处理的真实开销
+
+### 必要概念
+
+#### CPU工作与I/O等待不同
+
+在通常带GIL的CPython构建中,多个线程不会让纯PythonCPU循环稳定获得多核并行;某些C扩展会释放GIL,需实际测量。进程池提供独立解释器,代价是启动与数据复制。
+
+#### 参数需要跨进程序列化
+
+传可序列化的文本、编号和结构,不传Request、锁、数据库连接或打开的文件。每个进程需要自己的连接生命周期。
+
+#### 显式spawn避免依赖平台默认值
+
+Python不同版本与平台的默认启动方式不同。本模块明确使用spawn,目标函数放模块顶层,并用if __name__ == '__main__'保护启动代码。
+
+### 动手实现
+
+#### 1. 写一个可导入的CPU函数
+
+新建 cpu_work.py。使用纯Python循环产生可比较的校验结果,不借助网络或数据库。规模可以调小,不能为了展示加速耗尽机器资源。
+
+文件:cpu_work.py
+
+````python
+def score_text(text: str, rounds: int) -> int:
+    total = 0
+    for _ in range(rounds):
+        for character in text:
+            total = (total * 33 + ord(character)) % 1000000007
+    return total
+````
+
+#### 2. 比较串行与进程池
+
+创建 process_export.py。在终端运行文件,避免直接在交互式Console定义worker函数。先用两个进程、小输入测量;小任务可能更慢,这是有效结果。
+
+文件:process_export.py
+
+````python
+import multiprocessing as mp
+from concurrent.futures import ProcessPoolExecutor
+from time import perf_counter
+from cpu_work import score_text
+
+def main():
+    texts = ["note export " * 100 for _ in range(4)]
+    started = perf_counter()
+    serial = [score_text(text, 2000) for text in texts]
+    print("serial", perf_counter() - started)
+    started = perf_counter()
+    with ProcessPoolExecutor(max_workers=2, mp_context=mp.get_context("spawn")) as pool:
+        parallel = list(pool.map(score_text, texts, [2000] * len(texts)))
+    print("process", perf_counter() - started)
+    assert serial == parallel
+
+if __name__ == "__main__":
+    main()
+````
+
+#### 3. 复现不可序列化与启动错误
+
+把目标函数临时改为main内部的局部函数,观察序列化失败,然后恢复模块顶层定义。不要删除入口保护后在繁忙机器试验递归启动;用代码审查确认创建池只发生在受保护入口。
+
+文件:process_notes_review.py
+
+````python
+# 错误结构,只用于辨认,勿替换为实际入口:
+# def main():
+#     def local_score(text):
+#         return len(text)
+#     pool.submit(local_score, "note")
+
+# 正确结构:
+from cpu_work import score_text
+assert callable(score_text)
+````
+
+#### 4. 给进程失败定义批次结论
+
+在独立调用侧捕获BrokenProcessPool并把整批视为失败。Future等待超时也不会自动结束已运行CPU函数;大工作应分块,控制单块长度,避免在API每次请求里创建一整池进程。
+
+文件:verify_process.py
+
+````python
+from concurrent.futures.process import BrokenProcessPool
+from process_export import main
+
+if __name__ == "__main__":
+    try:
+        main()
+    except BrokenProcessPool:
+        print("batch failed: worker process lost")
+        raise
+````
+
+### 验收结果
+
+- serial与parallel结果一致。
+- 脚本使用显式spawn、顶层目标函数和主入口保护。
+- 记录实际耗时,不把进程池预设为所有输入都更快。
+
+### 生产约束与故障演练
+
+笔记导出需要大量纯Python清洗或统计,阻塞API事件循环。
+
+#### 实现决策
+
+- 先测量CPU占比,再选择进程数。
+- 传入有限数据块,不共享数据库连接。
+- 池在受控生命周期中管理,失败批次进入可恢复任务流程。
+
+#### 隔离环境演练
+
+仅在隔离脚本把目标函数换为局部定义,确认序列化失败可见且不会产出成功结果。
+
+#### 恢复验收
+
+- 结果可与串行版本交叉验证。
+- CPU工作没有被描述成async能够自动加速。
+- 跨进程输入和启动方式清晰可复现。
+
+### 常见错误
+
+#### Can't pickle local object
+
+原因:任务函数是闭包、lambda或局部定义。
+
+处理:把函数移入可导入模块顶层,仅传可序列化参数。
+
+#### 每个API请求创建进程池导致CPU和内存暴涨
+
+原因:并发请求各自放大worker数量。
+
+处理:使用受控的应用级池或后续独立Celery进程,限制整体并发。
+
+### 扩展练习
+
+把rounds分别设为10和2000,比较小任务与大任务的进程启动占比。
+
+提示:两次保持输入、进程数与计时范围一致。
+
+参考答案:
+
+两个版本均先验证结果相同,再记录耗时。小任务可能明显更慢;只有在任务足够大时,多核收益才可能覆盖序列化与启动成本。
+
+### 技术文档
+
+- [ProcessPoolExecutor](https://docs.python.org/3.11/library/concurrent.futures.html)
+- [multiprocessing与spawn](https://docs.python.org/3.11/library/multiprocessing.html)
+
+---
+
+## 07 选择协程、线程、进程或独立任务队列
+
+用同一导出需求比较执行位置,并确认何时需要 RQ、Dramatiq 或 Celery。
+
+### 本节目标
+
+- 根据等待、计算和持久交付选择机制
+- 区分执行器与消息队列
+- 为本模块写出Celery选型及不解决的问题
+
+### 必要概念
+
+#### 执行器回答工作在哪里运行
+
+asyncio适合可等待I/O,线程承接阻塞I/O,进程承接需要隔离或多核的工作。它们本身不提供机器重启后的任务恢复。
+
+#### 任务队列增加独立消费者
+
+RQ围绕Redis/Valkey队列和Python函数,Dramatiq围绕actor与中间件,Celery提供任务路由、重试、调度等能力。不同产品都需要明确版本、broker与故障交付语义。
+
+#### 本模块选择Celery并保留业务状态
+
+主线采用Celery5.x、Redis7作为练习broker与结果缓存,PostgreSQL16保存任务真源。选择工具不能消除重复执行、外部副作用和数据库一致性问题。
+
+### 动手实现
+
+#### 1. 把四类工作写成可检验的需求
+
+建立decision.md,逐项填写现有notes-service的工作量,而不是只写“异步”。本例行数是练习限制,不是普遍容量建议。
+
+文件:decision.md
+
+````text
+| 工作 | 主要约束 | 起点 |
+|---|---|---|
+| 同一请求并发读20条笔记 | 可等待I/O、请求内完成 | asyncio + 有界队列 |
+| 同步SDK上传一个导出文件 | 阻塞I/O、有超时 | 受限线程池 |
+| 大批纯Python文本统计 | CPU、数据可分块 | 受限进程池 |
+| 关闭API后仍需完成导出 | 持久状态、独立消费者 | 任务队列 + 状态表 |
+````
+
+#### 2. 固定消息边界
+
+先用json.dumps验证任务参数。工作函数接收job_id和小型快照;生产阶段只传job_id,从状态表读取不可变输入。不要把密码、请求对象或数据库连接放入消息。
+
+文件:message_contract.py
+
+````python
+import json
+from uuid import uuid4
+
+message = {
+    "job_id": str(uuid4()),
+    "rows": [{"id": 1, "title": "note", "content": "", "pinned": False}],
+}
+encoded = json.dumps(message, ensure_ascii=False).encode("utf-8")
+assert len(encoded) < 64 * 1024
+print(len(encoded))
+````
+
+#### 3. 比较三种队列的实际运行边界
+
+阅读下方官方资料,记录所选版本的broker、worker启动方式、超时、重试和结果保留方式。只安装下一课Celery,不同时引入三套消费者。Celery5.x不把原生Windows作为支持平台;本模块worker统一在Linux或WSL2运行。
+
+文件:decision.md
+
+````text
+# 记录到decision.md:
+# broker: Redis 7,仅本机隔离练习
+# worker: Celery 5.x / Linux 或 WSL2
+# durable status: PostgreSQL 16 export_jobs
+# delivery: 允许重复,业务处理必须幂等
+# result backend: 临时查询缓存,不能替代export_jobs
+````
+
+#### 4. 把迁移条件写进验收
+
+运行前几课的小脚本并记录:进程退出是否丢任务、CPU是否饱和、待处理数量是否有界。若要求跨重启恢复,不以继续增加线程数作为解决方案;从下一课分离producer与worker。
+
+文件:终端 / 当前课时
+
+````bash
+python bounded_async.py
+python pool_io.py
+python process_export.py
+python message_contract.py
+````
+
+### 验收结果
+
+- 四类工作都有明确执行位置和适用边界。
+- 消息能够JSON编码且没有连接对象或凭据。
+- Celery运行平台与状态真源已写入decision.md。
+
+### 生产约束与故障演练
+
+从单机小导出演进到API与导出worker独立运行。
+
+#### 实现决策
+
+- 工具选择同时考虑运行平台、维护成本和失败恢复。
+- 统一JSON消息和业务job_id,降低执行器替换成本。
+- 队列不是状态数据库,外部输出还需幂等处理。
+
+#### 隔离环境演练
+
+对比结束本地线程脚本与停止独立worker后的任务存留,用观察结果修订选型表。
+
+#### 恢复验收
+
+- 没有用异步语法替代持久交付设计。
+- 平台约束明确,worker不按原生Windows支持说明。
+- 迁移的需求来自可测量约束。
+
+### 常见错误
+
+#### 用了Celery就宣称任务只执行一次
+
+原因:忽略ack、重投和外部副作用窗口。
+
+处理:按允许重复设计,后续用幂等输入与状态CAS约束结果。
+
+#### 把Result TTL当任务生命周期
+
+原因:缓存过期被误读为任务不存在。
+
+处理:查询任务真源使用PostgreSQL,结果缓存只用于有限期辅助诊断。
+
+### 扩展练习
+
+给“同步SDK耗时3秒且必须跨API重启完成”选择方案。
+
+提示:执行方式和交付方式可组合,并非只能选一个。
+
+参考答案:
+
+独立队列承诺持久受理与恢复;worker内部按SDK性质使用同步调用或受限线程。仍须设置SDK超时、幂等键和持久状态。
+
+### 技术文档
+
+- [RQ官方文档](https://python-rq.org/docs/)
+- [Dramatiq官方指南](https://dramatiq.io/guide.html)
+- [Celery First Steps](https://docs.celeryq.dev/en/stable/getting-started/first-steps-with-celery.html)
+- [Celery平台FAQ](https://docs.celeryq.dev/en/stable/faq.html)
+
+---
+
+## 08 启动 Celery broker、worker 与结果缓存
+
+创建带认证的独立 Redis broker,把小型笔记快照发给 worker,再查询导出结果。
+
+### 本节目标
+
+- 配置独立broker与result backend
+- 启动Linux/WSL2 Celery worker并检查注册任务
+- 从producer提交JSON任务并获取结果
+
+### 必要概念
+
+#### 三个角色分别运行
+
+producer提交消息,broker暂存与转交消息,worker执行函数。result backend保存短期执行结果;它的可用性与保留时间不等于业务记录的完整性。
+
+#### 函数导入路径属于部署契约
+
+worker必须能导入celery_app和export_tasks。装饰器指定稳定任务名,producer与worker使用相同代码和兼容参数。
+
+#### 先验证最小链路
+
+本课发送一条笔记快照并生成本地 JSON 文件,暂不读取 PostgreSQL。第 11 课改为从数据库读取任务快照。命令都在同一 Linux/WSL2 环境运行;宿主连接 broker 6384,Docker Compose 内连接 broker:6379,不能混用两种网络地址。
+
+### 动手实现
+
+#### 1. 准备 Linux/WSL2 环境与独立 Redis
+
+在 notes-service/tasks_lab 的 Python 3.11+ 虚拟环境执行。Celery worker 使用 Linux 或 WSL2,不以原生 Windows 为支持环境。先通过 docker version 确认当前环境能访问 Docker Engine;Windows 使用 Docker Desktop 的 WSL 集成。本课另建 Redis 7 broker,宿主端口 6384、容器端口 6379,与缓存练习的 6380 分开。端口或 notes-task-broker 容器名已被其他练习占用时,先核对所属用途,不删除未知容器。
+
+文件:终端 / 当前课时
+
+````bash
+python -m pip install "celery[redis]>=5.5,<6" "psycopg[binary]>=3.2,<4" "python-dotenv>=1,<2"
+docker version
+docker ps --format 'table {{.Names}}\t{{.Ports}}'
+python -m pip freeze > requirements-tasks-lock.txt
+````
+
+#### 2. 创建仅用于本地练习的认证配置
+
+保存 prepare_broker.py,下一步运行。它生成 TASK_BROKER_PASSWORD、带认证的 Celery URL 和 Redis 配置,不打印凭据,也不会覆盖现有文件。broker-local 目录只允许当前宿主用户进入;配置文件本身可由容器内 Redis 用户读取。请在 WSL 的 Linux 文件系统中运行,避免 Windows 挂载盘权限语义差异。把 .env.tasks 与 broker-local/ 加入当前项目 .gitignore;不要提交、分享配置或把 URL 复制进日志。两个 Redis DB 只分离键空间,仍共享容量与可用性。
+
+文件:prepare_broker.py
+
+````python
+from pathlib import Path
+import secrets
+from urllib.parse import quote
+
+folder = Path("broker-local")
+env_file = Path(".env.tasks")
+if env_file.exists() or folder.exists():
+    raise SystemExit("local broker configuration exists; reuse it")
+folder.mkdir(mode=0o700)
+password = secrets.token_urlsafe(24)
+encoded = quote(password, safe="")
+env_file.write_text(
+    f"TASK_BROKER_PASSWORD={password}\n"
+    f"CELERY_BROKER_URL=redis://:{encoded}@127.0.0.1:6384/0\n"
+    f"CELERY_RESULT_BACKEND=redis://:{encoded}@127.0.0.1:6384/1\n",
+    encoding="utf-8",
+)
+env_file.chmod(0o600)
+conf = folder / "redis.conf"
+conf.write_text(
+    "bind 0.0.0.0\nprotected-mode yes\nport 6379\n"
+    f"requirepass {password}\n"
+    "dir /data\nappendonly yes\nappendfsync everysec\n"
+    "maxmemory 256mb\nmaxmemory-policy noeviction\n",
+    encoding="utf-8",
+)
+conf.chmod(0o644)
+ignore = Path(".gitignore")
+existing = ignore.read_text(encoding="utf-8") if ignore.exists() else ""
+with ignore.open("a", encoding="utf-8") as stream:
+    for rule in (".env.tasks", "broker-local/"):
+        if rule not in existing.splitlines():
+            stream.write("\n" + rule + "\n")
+print("local broker files created; credentials were not printed")
+````
+
+#### 3. 启动 broker 并验证认证连接
+
+先运行 python prepare_broker.py,再执行以下命令。数据使用独立命名卷;Redis 配置只读挂载,本课不执行 CONFIG REWRITE。宿主映射限制为 127.0.0.1:6384。启动后用 Python 客户端读取 .env.tasks 并 PING,只输出 PONG。若连接仍指向旧端口,打开未设置旧 CELERY_* 变量的新终端;load_dotenv 默认保留已注入环境变量。停止后保留容器可用 docker start notes-task-broker 恢复,不重复 docker run。
+
+文件:终端 / 当前课时
+
+````bash
+python prepare_broker.py
+docker run -d --name notes-task-broker \
+  -p 127.0.0.1:6384:6379 \
+  --mount type=bind,src="$PWD/broker-local/redis.conf",dst=/usr/local/etc/redis/redis.conf,readonly \
+  --mount type=volume,src=notes-task-broker-data,dst=/data \
+  redis:7 redis-server /usr/local/etc/redis/redis.conf
+python - <<'PY'
+import os
+from dotenv import load_dotenv
+from redis import Redis
+load_dotenv(".env.tasks")
+client = Redis.from_url(
+    os.environ["CELERY_BROKER_URL"],
+    socket_connect_timeout=2, socket_timeout=2,
+)
+try:
+    print("PONG" if client.ping() else "unexpected reply")
+finally:
+    client.close()
+PY
+````
+
+#### 4. 创建Celery应用
+
+新建 celery_app.py,每次从 tasks_lab 目录运行时先加载本地 .env.tasks。部署环境已注入的变量优先,不会被文件覆盖。结果保留 1 小时;worker 只接受 JSON,消息内不传凭据。此处尚未启用 late ack,下一课演示交付窗口。
+
+文件:celery_app.py
+
+````python
+import os
+from celery import Celery
+from dotenv import load_dotenv
+
+load_dotenv(".env.tasks")
+
+app = Celery(
+    "notes_service",
+    broker=os.environ["CELERY_BROKER_URL"],
+    backend=os.environ["CELERY_RESULT_BACKEND"],
+    include=["export_tasks"],
+)
+app.conf.update(
+    task_serializer="json", result_serializer="json",
+    accept_content=["json"], result_expires=3600,
+    task_default_queue="exports", task_track_started=True,
+    broker_connection_timeout=3,
+)
+````
+
+#### 5. 实现快照导出并启动worker
+
+新建export_tasks.py。校验job_id,文件只写入当前练习目录的artifacts。另一个相同环境终端启动 python -m celery -A celery_app:app worker -l INFO -Q exports --concurrency=2。确认启动列表出现notes.export。
+
+文件:export_tasks.py
+
+````python
+import json
+from pathlib import Path
+from uuid import UUID
+from celery_app import app
+
+@app.task(name="notes.export")
+def export_notes(job_id: str, rows: list[dict]):
+    safe_id = str(UUID(job_id))
+    folder = Path("artifacts")
+    folder.mkdir(exist_ok=True)
+    target = folder / f"{safe_id}.json"
+    target.write_text(
+        json.dumps(rows, ensure_ascii=False, sort_keys=True),
+        encoding="utf-8",
+    )
+    return {"job_id": safe_id, "path": str(target), "rows": len(rows)}
+````
+
+#### 6. 从独立producer提交并查询
+
+创建produce_export.py,在有相同环境变量的另一终端运行。get仅用于本地命令行验收,不放进FastAPI请求或Celery任务中同步等待。SUCCESS表示此任务执行结果已写缓存,文件仍位于worker本地路径。
+
+文件:produce_export.py
+
+````python
+from uuid import uuid4
+from export_tasks import export_notes
+
+job_id = str(uuid4())
+rows = [{"id": 1, "title": "first", "content": "note body", "pinned": False}]
+result = export_notes.apply_async(args=[job_id, rows], task_id=job_id)
+print("submitted", result.id)
+print(result.get(timeout=15))
+print("state", result.state)
+````
+
+### 验收结果
+
+- 认证客户端 PING 返回 PONG,连接目标为独立 broker 6384;worker 注册列表包含 notes.export。
+- producer获得job_id,worker成功后返回rows=1和相对文件路径。
+- artifacts内JSON与提交快照一致,API线程没有等待result.get。
+
+### 生产约束与故障演练
+
+API提交导出后立即响应,独立worker完成文件生成。
+
+#### 实现决策
+
+- API不调用AsyncResult.get阻塞请求。
+- 生产文件保存到所有消费者可访问的持久存储;本地路径只用于单机实验。
+- broker持久性、结果TTL和任务状态分别配置并验收。
+- 任务 broker 与缓存实例分开;本地 AOF everysec 与 noeviction 仍不能消除丢失窗口,后续用持久状态和 outbox 恢复。
+
+#### 隔离环境演练
+
+仅停止本机练习worker,提交一条后恢复,观察受理、等待超时与最终完成的区别。
+
+#### 恢复验收
+
+- producer和worker确实通过独立进程通信。
+- 文件内容对应输入,任务ID能追踪。
+- 没有把Redis结果缓存描述为状态真源。
+
+### 常见错误
+
+#### Received unregistered task
+
+原因:worker未导入模块或producer任务名不一致。
+
+处理:检查include、当前目录与notes.export注册名,更新代码后重启worker。
+
+#### 查询随机ID显示PENDING
+
+原因:结果backend对未知或已过期ID可能没有记录。
+
+处理:PENDING不等于确定在排队;第11课使用状态表区分未知与已受理。
+
+#### 连接拒绝、NOAUTH 或一直连旧端口
+
+原因:宿主与容器网络地址混用、没有加载认证 URL,或旧环境变量覆盖 .env.tasks。
+
+处理:宿主使用 127.0.0.1:6384;同一 Compose 网络内使用 broker:6379。确认当前目录、已注入环境和凭据来源,不把完整 URL 打印到日志。
+
+### 扩展练习
+
+先停止worker再提交一条,记录producer拿到ID但不能立刻完成,恢复worker后再查询。
+
+提示:使用同一broker与队列,不把get的等待超时当作任务执行失败。
+
+参考答案:
+
+暂停期间消息可留在broker;producer等待可能超时,但恢复worker后任务仍可能成功。随后用相同ID核对结果与文件。
+
+### 技术文档
+
+- [Celery入门](https://docs.celeryq.dev/en/stable/getting-started/first-steps-with-celery.html)
+- [Celery Redis broker](https://docs.celeryq.dev/en/stable/getting-started/backends-and-brokers/redis.html)
+- [Celery配置](https://docs.celeryq.dev/en/stable/userguide/configuration.html)
+- [Redis Docker 官方镜像与配置挂载](https://hub.docker.com/_/redis)
+
+---
+
+## 09 理解 ack、重投与幂等输出
+
+故意重复投递同一导出任务,验证稳定输入与输出标识如何限制重复副作用。
+
+### 本节目标
+
+- 找出执行完成与消息确认之间的重复窗口
+- 配置late ack并说明worker丢失行为
+- 让重复任务产生相同逻辑结果
+
+### 必要概念
+
+#### 确认时机会改变丢失与重复窗口
+
+early ack可能在执行前确认;late ack在工作完成后确认,更便于恢复未确认消息,但完成副作用后、确认前崩溃仍可能重复。不要把任一配置称为exactly once。
+
+#### at-least-once有前提
+
+可用且按要求配置的broker、未确认消息恢复和消费者重试共同支持至少一次交付目标。Celery即使late ack,部分worker子进程丢失场景默认仍确认;reject_on_worker_lost改变该行为,也可能制造毒消息循环。
+
+#### 幂等约束逻辑副作用
+
+同一job_id使用不可变快照和稳定输出名称,重复执行产生相同内容。本地原子替换只避免读到半文件,不解决多个版本竞争、断电持久性或跨机器文件一致性。
+
+### 动手实现
+
+#### 1. 配置确认与可见性时间
+
+在celery_app.py追加配置并重启练习worker。此处最长任务应小于30秒,设置60秒仅便于本地恢复观察;真实配置需覆盖执行上界且考虑恢复等待。Redis的三处visibility_timeout统一。
+
+文件:celery_app.py
+
+````python
+app.conf.update(
+    task_acks_late=True,
+    task_reject_on_worker_lost=True,
+    worker_prefetch_multiplier=1,
+    broker_transport_options={"visibility_timeout": 60, "socket_timeout": 3, "socket_connect_timeout": 3},
+    result_backend_transport_options={"visibility_timeout": 60},
+    visibility_timeout=60,
+)
+````
+
+#### 2. 用临时文件完成原子替换
+
+用下面整体替换第08课export_tasks.py。每次执行使用自己的临时文件,最终名字只由job_id决定;输入仍是已固定的小快照。异常时清理本次临时文件,不删除其他任务的输出。
+
+文件:export_tasks.py
+
+````python
+import hashlib
+import json
+import os
+from pathlib import Path
+from uuid import UUID, uuid4
+from celery_app import app
+
+@app.task(name="notes.export")
+def export_notes(job_id: str, rows: list[dict]):
+    safe_id = str(UUID(job_id))
+    payload = json.dumps(rows, ensure_ascii=False, sort_keys=True).encode("utf-8")
+    folder = Path("artifacts")
+    folder.mkdir(exist_ok=True)
+    target = folder / f"{safe_id}.json"
+    temporary = folder / f"{safe_id}.{uuid4()}.tmp"
+    try:
+        temporary.write_bytes(payload)
+        os.replace(temporary, target)
+    finally:
+        temporary.unlink(missing_ok=True)
+    return {"job_id": safe_id, "path": str(target),
+            "sha256": hashlib.sha256(payload).hexdigest()}
+````
+
+#### 3. 重复投递同一业务任务
+
+在独立producer提交两次相同job_id和快照,但使用不同Celery消息ID,避免把共享结果缓存覆盖当作去重。应比较逻辑输出路径与摘要;Celery task_id相同本身也不是业务去重保证。
+
+文件:duplicate_export.py
+
+````python
+from uuid import uuid4
+from export_tasks import export_notes
+
+job_id = str(uuid4())
+rows = [{"id": 1, "title": "fixed snapshot", "content": "", "pinned": False}]
+first = export_notes.apply_async(args=[job_id, rows], task_id=str(uuid4()))
+second = export_notes.apply_async(args=[job_id, rows], task_id=str(uuid4()))
+a, b = first.get(timeout=15), second.get(timeout=15)
+assert a["path"] == b["path"]
+assert a["sha256"] == b["sha256"]
+print("same logical output", a)
+````
+
+#### 4. 观察重投窗口并记录限制
+
+在本地任务里临时于os.replace后增加5秒sleep,记录执行worker子进程PID,只终止该隔离进程以模拟完成后未确认。重投时再次进入任务;观察日志、最终文件和摘要,恢复后删除故障代码。不要给永久崩溃消息无限恢复机会,后续用持久attempts和死信限制。
+
+文件:终端 / 当前课时
+
+````bash
+python -m celery -A celery_app:app inspect active
+python -m celery -A celery_app:app inspect reserved
+# 只对已确认属于本次隔离任务的worker子进程做故障注入。
+# 使用日志与inspect中的任务ID核对,不按模糊进程名批量终止。
+````
+
+### 验收结果
+
+- 两个不同Celery消息ID携带同一job_id,结果路径和摘要一致。
+- 正常读取最终文件得到完整JSON,没有半文件。
+- 能够解释late ack的重复窗口和worker丢失时的配置差异。
+
+### 生产约束与故障演练
+
+导出文件已生成,worker在发送确认前崩溃。
+
+#### 实现决策
+
+- 按可能重复交付设计,每个副作用明确业务键。
+- late ack和worker丢失重投策略需与毒消息隔离一起启用。
+- 本地原子重命名只解决部分写入观察,数据库中的版本权属在第11课建立。
+
+#### 隔离环境演练
+
+重复消息确定性验收后,再在隔离worker完成输出到ack之间终止执行进程,观察重复进入而非假定只执行一次。
+
+#### 恢复验收
+
+- 重复执行不会产生两个不同逻辑导出资源。
+- 输入快照不可变约束被明确记录。
+- 失败恢复说明没有宣称绝对不丢失或exactly once。
+
+### 常见错误
+
+#### 固定task_id后仍看到函数执行两次
+
+原因:消息ID或结果缓存键不提供业务级只执行一次保证。
+
+处理:用不可变输入、稳定业务键和后续数据库CAS保护最终结果。
+
+#### 任务持续崩溃并不断重投
+
+原因:reject_on_worker_lost重排同一毒消息,没有持久尝试预算。
+
+处理:限制持久attempts,把不可恢复输入转失败或死信,修复后受控重放。
+
+### 扩展练习
+
+给第二次投递同一job_id却不同title,观察原子替换无法保证业务版本正确。
+
+提示:本实验故意违反输入不可变约束,只使用新建隔离job_id。
+
+参考答案:
+
+最终文件取决于最后完成者,不能靠os.replace解决。第11课由状态表固定快照,消息仅传job_id,并用租约token防旧执行者覆盖结果引用。
+
+### 技术文档
+
+- [Celery任务与ack](https://docs.celeryq.dev/en/stable/userguide/tasks.html)
+- [Redis可见性时间](https://docs.celeryq.dev/en/stable/getting-started/backends-and-brokers/redis.html)
+- [Python os.replace](https://docs.python.org/3.11/library/os.html#os.replace)
+
+---
+
+## 10 为重试设置退避、抖动与总预算
+
+只重试暂时性错误,在每次执行和下一次等待前检查截止时间。
+
+### 本节目标
+
+- 区分可重试故障与永久输入错误
+- 为Celery retry设置次数与指数退避
+- 将业务总截止时间跨消息保存
+
+### 必要概念
+
+#### 重试只针对有机会恢复的失败
+
+连接暂不可用、受控限流等可以重试;输入校验失败、无权访问通常直接失败。重试范围应围绕幂等的最小操作,不应重复整个含不可逆副作用的业务流程。
+
+#### 次数与总时间都要限制
+
+max_retries只限制retry次数,不限制首次排队、每次执行和等待的总耗时。deadline_at随业务任务持久保存;跨worker用明确时间基准,本地耗时测量使用monotonic。
+
+#### 退避与抖动减少同步冲击
+
+等待上限随尝试次数增长,再在区间内随机选择等待时间。长远调度不宜大量使用Redis broker的ETA/countdown,避免消息占用worker内存及可见性时间导致重投。
+
+### 动手实现
+
+#### 1. 定义可控的暂时故障
+
+新建retry_tasks.py,并把该模块加入celery_app.py的include列表。任务只模拟读取,不写文件,便于观察重试而不重复副作用。fail_until只用于本地演练,不对外开放。
+
+文件:retry_tasks.py
+
+````python
+import random
+import time
+from celery_app import app
+
+class TemporaryReadError(Exception):
+    pass
+
+def fetch_snapshot(attempt: int, fail_until: int):
+    if attempt < fail_until:
+        raise TemporaryReadError("temporary source failure")
+    return [{"id": 1, "title": "snapshot"}]
+````
+
+#### 2. 加入跨重试截止时间
+
+追加任务。deadline_epoch由producer确定并原样传递。进入执行前和计划下次重试前都检查;每次真实I/O还须设置不超过remaining的超时。手动retry会抛出Retry用于通知Celery,使用raise保持控制流。
+
+文件:retry_tasks.py
+
+````python
+@app.task(bind=True, name="notes.retry_demo", max_retries=3)
+def retry_export(self, job_id: str, deadline_epoch: float, fail_until: int = 2):
+    remaining = deadline_epoch - time.time()
+    if remaining <= 0:
+        raise RuntimeError("export deadline exceeded")
+    try:
+        rows = fetch_snapshot(self.request.retries, fail_until)
+        return {"job_id": job_id, "rows": len(rows)}
+    except TemporaryReadError as exc:
+        if self.request.retries >= self.max_retries:
+            raise
+        cap = min(8.0, 2.0 ** self.request.retries)
+        delay = random.uniform(0, cap)
+        if time.time() + delay + 0.1 >= deadline_epoch:
+            raise RuntimeError("insufficient retry budget") from exc
+        raise self.retry(exc=exc, countdown=delay)
+````
+
+#### 3. 分别验证恢复与预算耗尽
+
+重启worker加载模块;在独立producer运行两次。第一次前两次失败后成功;第二次预算已经过期,应在调用外部读取前失败。get只用于本地命令行,异常时记录类型。
+
+文件:verify_retry.py
+
+````python
+import time
+from uuid import uuid4
+from retry_tasks import retry_export
+
+ok = retry_export.delay(str(uuid4()), time.time() + 30, 2)
+print(ok.get(timeout=35))
+expired = retry_export.delay(str(uuid4()), time.time() - 1, 0)
+try:
+    expired.get(timeout=10)
+except Exception as exc:
+    print(type(exc).__name__, str(exc))
+````
+
+#### 4. 验证不能恢复的永久错误
+
+临时把fetch_snapshot改成raise ValueError,确认不会进入TemporaryReadError重试分支。再将fail_until设置10,观察最多初次加3次重试。记录开始时间、重试次数、等待区间和终态;恢复实验函数。
+
+文件:retry_tasks.py
+
+````python
+# 永久错误实验:
+# raise ValueError("invalid export request")
+
+# 次数预算实验:
+# result = retry_export.delay(str(uuid4()), time.time() + 60, 10)
+# 最多4次执行:首次 + 3次重试。
+````
+
+### 验收结果
+
+- fail_until=2的任务经历重试后完成。
+- 已过deadline的任务不执行读取,直接失败。
+- 永久ValueError不重试,最多执行次数为首次加max_retries。
+
+### 生产约束与故障演练
+
+导出依赖短暂不可用的笔记存储,用户只接受10分钟内完成。
+
+#### 实现决策
+
+- 持久任务记录保存deadline和尝试数,不只依赖消息内的request.retries。
+- 每个外部操作配置阶段超时,预计等待超过预算就失败。
+- 任务副作用保持幂等,避免重试重复发布或重复扣费。
+
+#### 隔离环境演练
+
+对本地函数分别注入可恢复错误、永久错误、过期deadline,记录三种重试轨迹。
+
+#### 恢复验收
+
+- 重试有次数上限和总时间上限。
+- 不可恢复错误不会无限排队。
+- 已超业务时限的任务不会继续消耗下游。
+
+### 常见错误
+
+#### 每次重试都重置30秒deadline
+
+原因:总预算实际永远不会耗尽。
+
+处理:deadline随原始任务保存,重试传递原值,不在worker重新计算。
+
+#### 对所有Exception自动重试
+
+原因:语法、校验或权限错误变成无用流量。
+
+处理:明确可重试异常集合,永久故障直接进入失败处理。
+
+### 扩展练习
+
+把预算设为0.05秒,说明为什么次数预算尚余也可能直接失败。
+
+提示:执行机会必须同时满足次数和时间条件。
+
+参考答案:
+
+remaining已不足下一次等待加最小操作时间,因此应结束;剩余retry次数不是必须消耗的额度。
+
+### 技术文档
+
+- [Celery retry与backoff](https://docs.celeryq.dev/en/stable/userguide/tasks.html)
+- [Celery Redis ETA约束](https://docs.celeryq.dev/en/stable/getting-started/backends-and-brokers/redis.html)
+- [Python时间基准](https://docs.python.org/3.11/library/time.html)
+
+---
+
+## 11 用 PostgreSQL 保存任务状态与租约
+
+固定导出快照,用持久领取时间与租约控制执行,阻止过期执行者发布结果。
+
+### 本节目标
+
+- 建立export_jobs持久状态表
+- 使用CAS、租约与attempts约束领取
+- 将耗时文件处理放在数据库事务之外
+
+### 必要概念
+
+#### 状态表是用户查询真源
+
+PostgreSQL保存输入快照、状态、尝试数、截止时间和结果引用。Redis结果过期不改变业务任务是否存在。此后Celery消息只传job_id,不再传第08课rows参数。
+
+#### 领取与完成都要条件更新
+
+领取只允许queued且next_attempt_at已到的任务,一次UPDATE原子生成lease_token并增加attempts。完成同时检查running、token、租约、deadline和取消标志。过期running不能直接被消息抢走,必须经过第15课恢复扫描重新排队并设置退避。
+
+#### 短事务包围状态转换
+
+读取快照和领取状态分别在短事务中完成,文件生成放在事务外。不要持有数据库行锁等待长await、网络上传或CPU计算。租约是有时限的权属,不是自动杀死旧worker。
+
+### 动手实现
+
+#### 1. 建立隔离状态表
+
+在已有本机PostgreSQL16的notes_lab库执行SQL,账户app_user,端口5433。DATABASE_URL由终端环境设置,密码使用本地练习凭据。notes表需已有id/title/content/pinned;本课不覆盖原笔记表。 next_attempt_at决定最早可领取时间,last_dispatched_at记录最新一次被publisher确认的派发时间;这不等于worker已经领取或消息已经永久持久化。
+
+文件:schema_jobs.sql
+
+````sql
+CREATE TABLE IF NOT EXISTS export_jobs (
+  id uuid PRIMARY KEY,
+  status text NOT NULL DEFAULT 'queued'
+    CHECK (status IN ('queued','running','succeeded','failed','cancelled')),
+  payload jsonb NOT NULL,
+  attempts integer NOT NULL DEFAULT 0,
+  lease_token uuid,
+  lease_until timestamptz,
+  next_attempt_at timestamptz NOT NULL DEFAULT now(),
+  last_dispatched_at timestamptz,
+  cancel_requested boolean NOT NULL DEFAULT false,
+  deadline_at timestamptz NOT NULL DEFAULT now() + interval '10 minutes',
+  result_ref text,
+  error_code text,
+  created_at timestamptz NOT NULL DEFAULT now(),
+  updated_at timestamptz NOT NULL DEFAULT now()
+);
+CREATE INDEX IF NOT EXISTS export_jobs_status_created
+ON export_jobs(status, created_at);
+
+-- 兼容已按旧版课件建立的本机练习表:
+ALTER TABLE export_jobs ADD COLUMN IF NOT EXISTS next_attempt_at timestamptz NOT NULL DEFAULT now();
+ALTER TABLE export_jobs ADD COLUMN IF NOT EXISTS last_dispatched_at timestamptz;
+````
+
+#### 2. 在短事务中固定导出快照
+
+创建state_store.py。设置DATABASE_URL指向postgresql://app_user:本地密码@127.0.0.1:5433/notes_lab;WSL2必须能访问该地址。限制100条,读取后保存JSON快照,后续修改笔记不会改变已受理的导出输入。 connect设置连接超时3秒、单条SQL超时3秒、锁等待1秒,用于本机小数据实验;连接建立、语句与事务总时间不是同一预算。生产根据服务SLO和批次大小调整,维护进程每批之间检查停机信号,不能依赖无限等待的数据库调用。
+
+文件:state_store.py
+
+````python
+import os
+from uuid import uuid4
+import psycopg
+from psycopg.rows import dict_row
+from psycopg.types.json import Jsonb
+
+def connect():
+    return psycopg.connect(
+        os.environ["DATABASE_URL"],row_factory=dict_row,connect_timeout=3,
+        options="-c statement_timeout=3000 -c lock_timeout=1000",
+    )
+
+def submit_snapshot(note_ids: list[int]) -> str:
+    ids = sorted(set(note_ids))
+    if not ids or len(ids) > 100:
+        raise ValueError("select 1..100 notes")
+    job_id = str(uuid4())
+    with connect() as conn:
+        rows = conn.execute(
+            "SELECT id,title,content,pinned FROM notes WHERE id=ANY(%s) ORDER BY id",
+            (ids,),
+        ).fetchall()
+        if len(rows) != len(ids):
+            raise ValueError("one or more notes do not exist")
+        conn.execute(
+            "INSERT INTO export_jobs(id,payload) VALUES (%s,%s)",
+            (job_id, Jsonb(rows)),
+        )
+    return job_id
+````
+
+#### 3. 添加领取与完成的CAS
+
+追加两个函数。领取事务提交后才开始工作,返回的token用于完成校验。30秒是本地小文件租约;真实大导出需分块并续租,续租失败立即停止后续发布。 running租约过期后claim仍返回None;第15课扫描器负责queued转换,避免broker重复消息绕过持久退避时间。成功领取和完成清除当前error_code,历史错误由阶段日志保留。
+
+文件:state_store.py
+
+````python
+def claim(job_id: str):
+    token = str(uuid4())
+    with connect() as conn:
+        return conn.execute("""
+            UPDATE export_jobs SET status='running', attempts=attempts+1,
+              lease_token=%s, lease_until=now()+interval '30 seconds',
+              error_code=NULL, updated_at=now()
+            WHERE id=%s AND cancel_requested=false AND attempts<3
+              AND deadline_at>now()
+              AND next_attempt_at<=now() AND status='queued'
+            RETURNING id,payload,lease_token,attempts
+        """, (token, job_id)).fetchone()
+
+def finish(job_id: str, token: str, result_ref: str) -> bool:
+    with connect() as conn:
+        row = conn.execute("""
+            UPDATE export_jobs SET status='succeeded', result_ref=%s, error_code=NULL, updated_at=now()
+            WHERE id=%s AND status='running' AND lease_token=%s
+              AND lease_until>now() AND deadline_at>now() AND cancel_requested=false
+            RETURNING id
+        """, (result_ref, job_id, token)).fetchone()
+    return row is not None
+````
+
+#### 4. 替换worker为只接收job_id
+
+整体替换export_tasks.py并重启worker,停止使用第08–09课携带rows的producer。每次领取写独立token文件,最终有效文件由数据库result_ref决定;旧worker最多留下未引用文件,不覆盖新结果。异常由Celery记录,业务状态暂留租约,恢复扫描在第15课补齐。不要把第10课retry直接套在未定义状态转换上。
+
+文件:export_tasks.py
+
+````python
+import json
+from pathlib import Path
+from celery_app import app
+from state_store import claim, finish
+
+@app.task(name="notes.export")
+def export_notes(job_id: str):
+    job = claim(job_id)
+    if job is None:
+        return {"job_id": job_id, "claimed": False}
+    token = str(job["lease_token"])
+    folder = Path("artifacts")
+    folder.mkdir(exist_ok=True)
+    target = folder / f"{job['id']}.{token}.json"
+    target.write_text(json.dumps(job["payload"], ensure_ascii=False), encoding="utf-8")
+    published = finish(job_id, token, str(target))
+    return {"job_id": job_id, "published": published}
+````
+
+#### 5. 从数据库查询并测试旧token失效
+
+使用真实已存在的笔记ID执行submit_snapshot,再export_notes.delay(job_id)。查询只读状态表。当前提交后发送仍有双写窗口,下一课用outbox解决。接下来的专用租约实验不发送Celery消息,通过明确的一条记录模拟恢复转换。
+
+文件:submit_db_export.py
+
+````python
+from state_store import submit_snapshot, connect
+from export_tasks import export_notes
+
+job_id = submit_snapshot([1])
+export_notes.delay(job_id)
+with connect() as conn:
+    print(conn.execute(
+        "SELECT id,status,attempts,result_ref FROM export_jobs WHERE id=%s",
+        (job_id,),
+    ).fetchone())
+
+# HTTP查询接口应读取同一记录;无记录返回404。
+# 本地故障注入只对刚创建的job_id修改租约,勿扫描修改其他任务。
+````
+
+#### 6. 只对专用任务验证过期与旧令牌
+
+使用已有笔记编号作为输入;下面通过最小编号选取样本,不假定id=1存在。只修改刚创建的job_id。过期running无法自动领取,实验显式模拟第15课的恢复转换;attempts不清零,随后旧token仍不能完成,新token生成真实文件后才发布。
+
+文件:verify_lease.py
+
+````python
+import json
+from pathlib import Path
+from state_store import submit_snapshot, connect, claim, finish
+
+with connect() as conn:
+    note_id = conn.execute("SELECT min(id) AS id FROM notes").fetchone()["id"]
+assert note_id is not None, "create a note first"
+job_id = submit_snapshot([note_id])
+old = claim(job_id)
+assert old is not None and claim(job_id) is None
+with connect() as conn:
+    conn.execute("UPDATE export_jobs SET lease_until=now()-interval '1 second' WHERE id=%s", (job_id,))
+assert claim(job_id) is None
+assert finish(job_id, str(old["lease_token"]), "unused") is False
+with connect() as conn:
+    conn.execute("""
+      UPDATE export_jobs SET status='queued',lease_token=NULL,lease_until=NULL,
+        next_attempt_at=now(),updated_at=now()
+      WHERE id=%s AND status='running' AND lease_until<now()
+    """, (job_id,))
+new = claim(job_id)
+assert new is not None and new["attempts"] == 2
+assert finish(job_id, str(old["lease_token"]), "unused") is False
+folder = Path("artifacts")
+folder.mkdir(exist_ok=True)
+target = folder / f"{job_id}.{new['lease_token']}.json"
+target.write_text(json.dumps(new["payload"],ensure_ascii=False),encoding="utf-8")
+assert finish(job_id,str(new["lease_token"]),str(target)) is True
+print("lease ownership verified",job_id)
+````
+
+### 验收结果
+
+- 任务记录重启后仍存在,payload是提交时固定的笔记快照。
+- 同一租约期内重复领取只有一方取得token。
+- 旧token无法完成任务,工作期间不持有数据库长事务。
+- next_attempt_at未到时所有重复消息都无法领取;过期running由恢复扫描转换后才能再次领取。
+
+### 生产约束与故障演练
+
+多个worker可能收到同一个导出job,旧worker在暂停后重新运行。
+
+#### 实现决策
+
+- 业务job与Celery消息分开,任务状态保存在PostgreSQL。
+- 输入快照固定;多租约输出隔离,CAS只发布当前token结果。
+- 授权过滤应复用notes-service当前用户权限,本地单账户练习不能直接部署为多用户接口。
+- finish同时检查业务deadline;超时后不能仅凭未过租约发布成功。
+- 领取资格来自数据库状态与next_attempt_at,不由broker是否再次交付决定。
+
+#### 隔离环境演练
+
+对专用任务令租约过期,先确认claim和旧token完成均失败,再模拟恢复为queued并重新领取,比较两次token与result_ref。
+
+#### 恢复验收
+
+- 旧执行者不能发布结果。
+- 数据库事务不跨耗时文件处理。
+- attempts与deadline来自持久记录,不因消息重投重置。
+
+### 常见错误
+
+#### 旧worker覆盖新worker结果
+
+原因:完成时只按job_id更新,没有检查token或输出路径复用。
+
+处理:文件名带token,结果发布使用状态、token和租约条件CAS。
+
+#### Redis显示SUCCESS但数据库仍queued或running
+
+原因:消息可能没有领取权,或者完成CAS失败;执行结果不等于业务终态。
+
+处理:用户查询只认export_jobs,记录claimed/published用于诊断。
+
+### 扩展练习
+
+实现续租函数:仅当token匹配、status=running、当前租约未过期且未取消时延长30秒。
+
+提示:过期租约不能自己复活,需要重新领取获取新token。
+
+参考答案:
+
+UPDATE export_jobs SET lease_until=now()+interval '30 seconds' WHERE id=%s AND lease_token=%s AND status='running' AND lease_until>now() AND deadline_at>now() AND cancel_requested=false RETURNING id;无返回记录即失去权属。
+
+### 技术文档
+
+- [PostgreSQL并发锁](https://www.postgresql.org/docs/16/explicit-locking.html)
+- [PostgreSQL UPDATE](https://www.postgresql.org/docs/16/sql-update.html)
+- [Psycopg事务](https://www.psycopg.org/psycopg3/docs/basic/transactions.html)
+
+---
+
+## 12 用事务 outbox 连接状态提交与消息发布
+
+把导出记录与待发布事件写入同一数据库事务,并处理发布成功但确认写回失败的重复。
+
+### 本节目标
+
+- 复现数据库与broker双写窗口
+- 同事务写入export_jobs和outbox事件
+- 用短领取租约与CAS确认发布结果
+
+### 必要概念
+
+#### 两个系统之间存在双写窗口
+
+先写数据库后发送消息,可能留下永不入队的queued任务;先发送后提交数据库,worker可能读不到记录。outbox把业务记录和发送意图放进同一数据库事务。
+
+#### outbox不提供exactly once发布
+
+发布成功后进程可能在写sent_at之前崩溃,事件会被再次发布。consumer继续依赖第11课的领取和结果CAS,不能因为用了outbox就移除幂等逻辑。
+
+#### 发布期间不持数据库事务
+
+先短事务领取outbox事件,提交后访问broker,最后短事务按claim_token标记sent。租约过期允许其他publisher接手;SKIP LOCKED用于队列式领取,不用于普通业务一致性查询。
+
+### 动手实现
+
+#### 1. 建立outbox表
+
+在同一notes_lab数据库执行。外键保证job存在;同一个job可以有后续重放事件,因此不对job_id单独加唯一约束。事件ID与业务job_id分开记录。
+
+文件:schema_outbox.sql
+
+````sql
+CREATE TABLE IF NOT EXISTS export_outbox (
+  id uuid PRIMARY KEY,
+  job_id uuid NOT NULL REFERENCES export_jobs(id),
+  created_at timestamptz NOT NULL DEFAULT now(),
+  available_at timestamptz NOT NULL DEFAULT now(),
+  sent_at timestamptz,
+  claim_token uuid,
+  claim_until timestamptz,
+  attempts integer NOT NULL DEFAULT 0
+);
+CREATE INDEX IF NOT EXISTS export_outbox_pending
+ON export_outbox(available_at, created_at) WHERE sent_at IS NULL;
+````
+
+#### 2. 让提交记录与事件一起提交
+
+在state_store.py的submit_snapshot里,把下面INSERT放到原有with connect()事务中、export_jobs INSERT之后。删除producer中直接export_notes.delay的调用。HTTP202只能在数据库事务提交后返回,暂时broker不可用仍可保留待发布事件。
+
+文件:submit_outbox_export.py
+
+````python
+# 放在submit_snapshot的同一个with connect() as conn内:
+# conn.execute(
+#     "INSERT INTO export_outbox(id,job_id) VALUES (%s,%s)",
+#     (str(uuid4()), job_id),
+# )
+
+# 提交侧现在只调用:
+from state_store import submit_snapshot
+job_id = submit_snapshot([1])
+print("accepted", job_id)
+````
+
+#### 3. 领取一条待发布事件
+
+创建publisher.py。CTE锁定一行并更新领取token,连接上下文退出后才发消息。该函数一次处理一条,便于观察;循环调用时需停机信号、空队列等待与有限批量。
+
+文件:publisher.py
+
+````python
+from uuid import uuid4
+from state_store import connect
+from celery_app import app
+
+def claim_event():
+    token = str(uuid4())
+    with connect() as conn:
+        return conn.execute("""
+          WITH candidate AS (
+            SELECT id FROM export_outbox
+            WHERE sent_at IS NULL AND available_at<=now()
+              AND (claim_until IS NULL OR claim_until<now())
+            ORDER BY created_at FOR UPDATE SKIP LOCKED LIMIT 1
+          )
+          UPDATE export_outbox o SET claim_token=%s,
+            claim_until=now()+interval '30 seconds', attempts=attempts+1
+          FROM candidate c WHERE o.id=c.id
+          RETURNING o.id,o.job_id,o.claim_token
+        """, (token,)).fetchone()
+````
+
+#### 4. 发送后按token确认
+
+追加并执行python publisher.py。消息只带job_id。broker调用不包在数据库事务内;失败时不标sent,等待领取租约到期后可再次尝试。生产还应限制发布重试总量并报警,不能无日志地永久积压。
+
+文件:publisher.py
+
+````python
+def publish_one():
+    event = claim_event()
+    if event is None:
+        return False
+    app.send_task(
+        "notes.export", args=[str(event["job_id"])],
+        task_id=str(event["id"]), queue="exports",
+        retry=False,
+    )
+    with connect() as conn:
+        conn.execute("""
+          UPDATE export_outbox SET sent_at=now(), claim_until=NULL
+          WHERE id=%s AND claim_token=%s AND sent_at IS NULL
+        """, (event["id"], event["claim_token"]))
+    return True
+
+if __name__ == "__main__":
+    print("published", publish_one())
+````
+
+#### 5. 注入发送后未标记的故障
+
+只在本地publisher临时把发送之后的数据库更新跳过一次。等待该事件租约到期再执行publisher,它会再次发送;检查export_jobs只有一个有效结果引用。恢复更新代码;另试停止broker后提交,确认任务和outbox同时存在。
+
+文件:终端 / 当前课时
+
+````sql
+SELECT j.id, j.status, j.result_ref, o.id AS event_id, o.attempts, o.sent_at
+FROM export_jobs j JOIN export_outbox o ON o.job_id=j.id
+ORDER BY o.created_at DESC LIMIT 10;
+````
+
+### 验收结果
+
+- 提交事务提交后,job与outbox事件同时存在。
+- broker不可用时sent_at保持NULL,事件可被后续处理。
+- 重复发布不会让旧worker越过租约CAS覆盖终态结果。
+
+### 生产约束与故障演练
+
+数据库正常、Redis暂不可用,API不能受理后永久漏发任务。
+
+#### 实现决策
+
+- 数据库提交的发送意图与业务记录同生共灭。
+- publisher网络调用有超时和重试策略,长期未发布事件报警。
+- 重复消息是常规故障路径,consumer继续使用持久幂等与租约。
+
+#### 隔离环境演练
+
+分别模拟事务回滚、broker不可用、发送成功但sent_at未写回,检查三种窗口的恢复结果。
+
+#### 恢复验收
+
+- 没有只存在job而无发送意图的已受理任务。
+- 没有跨broker等待的数据库长事务。
+- 重复发布可恢复且最终业务结果唯一可定位。
+
+### 常见错误
+
+#### outbox行已标sent但broker根本没收到
+
+原因:先标记再发送,或把异常忽略。
+
+处理:发送成功后再条件更新sent_at,失败保留待处理事件。
+
+#### 发送很慢导致数据库锁一直占用
+
+原因:把broker发布放在FOR UPDATE事务内部。
+
+处理:分成领取、网络发送、确认三个阶段,用领取token防旧publisher误确认。
+
+### 扩展练习
+
+在导出记录INSERT后、outbox INSERT前抛异常,验证两个表都没有这次提交的记录。
+
+提示:使用同一个连接事务,让异常退出with触发回滚。
+
+参考答案:
+
+保存本次job_id进行查询;两表计数都应为0。若只回滚一张表,说明事务边界没有统一。
+
+### 技术文档
+
+- [PostgreSQL事务](https://www.postgresql.org/docs/16/tutorial-transactions.html)
+- [PostgreSQL SKIP LOCKED](https://www.postgresql.org/docs/16/sql-select.html)
+- [Celery消息发布](https://docs.celeryq.dev/en/stable/userguide/calling.html)
+
+---
+
+## 13 限制受理量并拆分长短任务队列
+
+在数据库事务中控制待处理容量,用独立worker减少大导出对小任务的影响。
+
+### 本节目标
+
+- 区分排队容量、执行并发和外部调用速率
+- 用事务内受理检查避免并发超卖容量
+- 按队列隔离大导出并观察等待时间
+
+### 必要概念
+
+#### 增加worker不等于无限扩容
+
+系统吞吐还受数据库连接、存储I/O和CPU限制。到达速度长期高于完成速度时,队列持续增长,必须在入口拒绝、等待或降级。
+
+#### 限并发与限速分别处理
+
+concurrency控制同时执行,rate limit控制时间窗口速率;Celery任务rate_limit通常按worker实例生效,不等于全局额度。全局限制应在共享状态或独立入口实现。
+
+#### 队列拆分需要对应消费者容量
+
+给fast和bulk命名不会自动隔离资源;分别启动受限worker,并配置CPU、连接等预算。prefetch降低预取囤积,但不能代替入队容量限制。
+
+### 动手实现
+
+#### 1. 在受理事务内检查容量
+
+在state_store.py添加admit_export,并在submit_snapshot的with connect()内、INSERT之前调用。所有提交路径必须使用同一检查,包括之后定时任务和重放。该本地全局锁只持有短事务;多租户系统可设计分租户额度。
+
+文件:state_store.py
+
+````python
+class CapacityExceeded(Exception):
+    pass
+
+def admit_export(conn, limit: int = 100):
+    conn.execute("SELECT pg_advisory_xact_lock(831701)")
+    row = conn.execute("""
+        SELECT count(*) AS total FROM export_jobs
+        WHERE status IN ('queued','running')
+    """).fetchone()
+    if row["total"] >= limit:
+        raise CapacityExceeded("export capacity reached")
+
+# submit_snapshot内部:
+# with connect() as conn:
+#     admit_export(conn)
+#     ...读取快照、INSERT job、INSERT outbox...
+````
+
+#### 2. 把事件路由写进outbox
+
+执行SQL并在提交outbox时根据快照条数选择队列。本例≤30条走fast,其余走bulk。修改publisher的RETURNING包含queue_name,send_task的queue改为event['queue_name'];不要仅修改任务装饰器却保留显式旧queue。
+
+文件:schema_capacity.sql
+
+````sql
+ALTER TABLE export_outbox
+ADD COLUMN IF NOT EXISTS queue_name text NOT NULL DEFAULT 'exports.bulk'
+CHECK (queue_name IN ('exports.fast','exports.bulk'));
+
+-- 在提交事务中使用参数化INSERT:
+-- INSERT INTO export_outbox(id,job_id,queue_name) VALUES (%s,%s,%s);
+-- Python: queue_name = "exports.fast" if len(rows) <= 30 else "exports.bulk"
+````
+
+#### 3. 分别启动两个受限worker
+
+先完成第12课旧exports队列中的实验任务,再停旧worker。两个命令分别放在终端运行,各一个执行进程;worker_prefetch_multiplier=1继续保留。正常发布的事件必须进入其中一条被消费的队列。
+
+文件:终端 / 当前课时
+
+````bash
+# Linux / WSL2,终端A
+python -m celery -A celery_app:app worker -l INFO -Q exports.fast --concurrency=1 -n fast@%h
+
+# Linux / WSL2,终端B
+python -m celery -A celery_app:app worker -l INFO -Q exports.bulk --concurrency=1 -n bulk@%h
+````
+
+#### 4. 用数据库观察积压与最老等待
+
+暂停本地消费者后少量提交,逐渐达到本课100条容量;CapacityExceeded应由API转换为503并附Retry-After,而不是继续受理。先统计之前练习遗留的queued/running,不能假定数据库为空。恢复worker和publisher后观察最老等待下降。
+
+文件:终端 / 当前课时
+
+````sql
+SELECT status, count(*) AS jobs,
+       extract(epoch FROM now()-min(created_at)) AS oldest_age_seconds
+FROM export_jobs
+WHERE status IN ('queued','running')
+GROUP BY status;
+
+SELECT queue_name, count(*) AS unpublished,
+       extract(epoch FROM now()-min(created_at)) AS oldest_unpublished_seconds
+FROM export_outbox WHERE sent_at IS NULL GROUP BY queue_name;
+````
+
+#### 5. 给HTTP受理失败返回明确反馈
+
+将CapacityExceeded映射放在现有提交接口,不创建第二个状态真源。不同用户配额用429或业务约定,整个服务容量不足本课使用503。数据库事务回滚后不得返回202和虚假job_id。
+
+文件:admission_adapter.py
+
+````python
+from fastapi import HTTPException
+from state_store import CapacityExceeded, submit_snapshot
+
+def accept_export(note_ids: list[int]):
+    try:
+        return {"job_id": submit_snapshot(note_ids)}
+    except CapacityExceeded as exc:
+        raise HTTPException(
+            503, "导出容量暂满,请稍后重试",
+            headers={"Retry-After": "5"},
+        ) from exc
+````
+
+### 验收结果
+
+- 并发提交不会让受理记录超过配置容量。
+- fast与bulk事件分别被对应worker消费。
+- 过载时返回503而不是202,恢复后最老等待逐步下降。
+
+### 生产约束与故障演练
+
+大批量导出占满worker,小型用户导出等待过久。
+
+#### 实现决策
+
+- 入口容量、worker并发、数据库连接和下游速率分别设限。
+- 队列隔离有对应的消费者资源,不能只换名称。
+- 积压监控同时看数量与最老等待,outbox未发布也计入受理容量。
+
+#### 隔离环境演练
+
+仅暂停本地bulk消费者,提交大小两类任务,观察fast仍处理;随后压到小容量上限确认拒绝。
+
+#### 恢复验收
+
+- 受理总量有可验证上限。
+- 关键队列保留处理能力。
+- 503请求没有留下已受理但无状态的记录。
+
+### 常见错误
+
+#### 队列分了级,小任务仍一直等待
+
+原因:所有队列仍由同一批已被长任务占满的worker处理。
+
+处理:为关键小任务保留独立消费者和资源预算。
+
+#### 检查count后仍突破容量
+
+原因:多个producer并发读取相同count,检查与INSERT不在同一串行化范围。
+
+处理:同一事务获取约定锁,检查后写job与outbox,所有入口遵守。
+
+### 扩展练习
+
+将本地受理上限改为5,用两个producer同时提交,核对queued/running总数没有超过5。
+
+提示:先处理或隔离此前活动任务,再做小容量实验。
+
+参考答案:
+
+超过容量的事务抛CapacityExceeded并回滚;只有实际写入状态表和outbox的请求能返回202。
+
+### 技术文档
+
+- [Celery预取与容量](https://docs.celeryq.dev/en/stable/userguide/optimizing.html)
+- [Celery任务路由](https://docs.celeryq.dev/en/stable/userguide/routing.html)
+- [PostgreSQL advisory lock](https://www.postgresql.org/docs/16/explicit-locking.html)
+
+---
+
+## 14 用持久时间槽合并周期执行,并验证跨分钟重投边界
+
+Celery beat负责唤醒,数据库唯一键合并同一执行分钟;跨分钟重投和停机补跑明确作为不同问题验收。
+
+### 本节目标
+
+- 配置Celery beat周期与UTC时间
+- 阻止重复调度信号创建重复导出
+- 区分调度去重与执行租约
+- 复现同一信号跨分钟执行生成不同时间槽的边界
+
+### 必要概念
+
+#### beat负责发送,worker负责执行
+
+本课按worker实际开始处理时的UTC分钟生成slot_key,数据库唯一约束保证同一槽只创建一个job。这是按执行分钟合并,不是按原始beat信号去重:同一消息如果跨分钟重投,会落到不同槽并可能新建任务。
+
+#### 时间槽固定一个业务周期
+
+本地例子用worker处理信号时的UTC分钟作为slot_key,同一分钟最多创建一份快照。它不补齐停机错过的分钟;需要严格补跑时,应由持久调度表保存due_at与补跑规则。
+
+#### 不重叠执行还需要租约
+
+同一时间槽不重复创建,并不阻止不同时间槽的大导出重叠。复用任务领取租约,并按业务决定跳过、合并或限制同一导出类型的并发。
+
+#### 需要计划时间语义时使用持久调度游标
+
+若业务要求每个计划分钟最多一次且停机后补跑,应在数据库保存schedule_id、next_due_at和唯一(schedule_id,scheduled_at)槽。调度事务锁定配置、按有限批次推进游标并写job/outbox;worker消费持久scheduled_at,不能再用执行时now猜测原计划。本课保留较小的执行分钟模型,下面用实际两次调用证明边界。
+
+### 动手实现
+
+#### 1. 创建时间槽唯一约束
+
+执行SQL。外键延迟到事务提交时检查,允许先争取slot_key,再插入对应job。冲突意味着该时间槽已有任务,不能再创建第二条。
+
+文件:schema_schedule.sql
+
+````sql
+CREATE TABLE IF NOT EXISTS export_schedule_slots (
+  slot_key text PRIMARY KEY,
+  job_id uuid NOT NULL UNIQUE REFERENCES export_jobs(id)
+    DEFERRABLE INITIALLY DEFERRED,
+  created_at timestamptz NOT NULL DEFAULT now()
+);
+````
+
+#### 2. 在同一事务中创建周期任务
+
+新建schedule_store.py。复用connect、admit_export;生产参数应包含固定租户和授权范围,本地只取给定笔记。没有长任务或broker调用进入这个事务。
+
+文件:schedule_store.py
+
+````python
+from uuid import uuid4
+from psycopg.types.json import Jsonb
+from state_store import connect, admit_export
+
+def schedule_once(slot_key: str, note_ids: list[int]):
+    ids = sorted(set(note_ids))
+    if not ids or len(ids) > 100:
+        raise ValueError("select 1..100 notes")
+    job_id = str(uuid4())
+    with connect() as conn:
+        won = conn.execute("""
+          INSERT INTO export_schedule_slots(slot_key,job_id) VALUES (%s,%s)
+          ON CONFLICT(slot_key) DO NOTHING RETURNING slot_key
+        """, (slot_key, job_id)).fetchone()
+        if won is None:
+            return None
+        admit_export(conn)
+        rows = conn.execute(
+            "SELECT id,title,content,pinned FROM notes WHERE id=ANY(%s) ORDER BY id",
+            (ids,),
+        ).fetchall()
+        if len(rows) != len(ids):
+            raise ValueError("one or more scheduled notes do not exist")
+        conn.execute("INSERT INTO export_jobs(id,payload) VALUES (%s,%s)",
+                     (job_id, Jsonb(rows)))
+        conn.execute("""
+          INSERT INTO export_outbox(id,job_id,queue_name) VALUES (%s,%s,%s)
+        """, (str(uuid4()), job_id, "exports.bulk"))
+    return job_id
+````
+
+#### 3. 注册周期信号并配置beat
+
+新建schedule_tasks.py;将模块加入Celery include并重启worker。把下方app.conf部分放入celery_app.py。此信号使用fast队列,实际导出由outbox进入bulk;继续运行publisher才能把事件变成消息。 execution_slot接受显式时间,便于下一个实验固定跨分钟时序;正式tick仍使用开始执行时的now,因此不能宣称保留原beat计划时间。
+
+文件:schedule_tasks.py
+
+````python
+from datetime import datetime, timezone
+from celery_app import app
+from state_store import connect
+from schedule_store import schedule_once
+
+def execution_slot(when: datetime) -> str:
+    if when.tzinfo is None:
+        raise ValueError("timezone-aware timestamp required")
+    return when.astimezone(timezone.utc).strftime("notes-export:%Y%m%dT%H%M")
+
+@app.task(name="notes.schedule_tick")
+def schedule_tick():
+    slot = execution_slot(datetime.now(timezone.utc))
+    with connect() as conn:
+        rows = conn.execute("SELECT id FROM notes ORDER BY id LIMIT 100").fetchall()
+    ids = [row["id"] for row in rows]
+    return schedule_once(slot, ids) if ids else None
+
+# 以下配置写入celery_app.py:
+# app.conf.timezone = "UTC"
+# app.conf.beat_schedule = {
+#   "export-each-minute": {
+#     "task": "notes.schedule_tick", "schedule": 60.0,
+#     "options": {"queue": "exports.fast"},
+#   }
+# }
+````
+
+#### 4. 启动一个beat并验证重复信号
+
+Linux/WSL2单独终端执行命令,启动文件只给本次beat使用。不要为了高可用复制同一个本地schedule文件。用schedule_once对同一个测试slot连续调用两次,第一次返回job_id,第二次None。
+
+文件:终端 / 当前课时
+
+````bash
+python -m celery -A celery_app:app beat -l INFO --schedule=./beat-lab.db
+# 另一个终端在Python里测试同槽去重:
+# from schedule_store import schedule_once
+# print(schedule_once("drill:unique-slot-01", [1]))
+# print(schedule_once("drill:unique-slot-01", [1]))
+````
+
+#### 5. 证明同槽去重与跨分钟重投的区别
+
+本实验直接调用同一slot生成逻辑,并注入两个明确的UTC时间,不必等待真实分钟切换。每次生成新的drill前缀避免与真实调度或此前实验混用。相同delivery_label表示模拟同一信号;两次不同分钟仍会创建两个job,结果是当前模型的边界,不应标成原消息exactly-once。
+
+文件:verify_schedule_slots.py
+
+````python
+from datetime import datetime, timezone
+from uuid import uuid4
+from state_store import connect
+from schedule_store import schedule_once
+from schedule_tasks import execution_slot
+
+with connect() as conn:
+    note_id = conn.execute("SELECT min(id) AS id FROM notes").fetchone()["id"]
+assert note_id is not None
+prefix = f"drill:{uuid4()}:"
+delivery_label = "same-redelivered-signal"
+t1 = datetime(2026,1,1,10,0,59,tzinfo=timezone.utc)
+t2 = datetime(2026,1,1,10,1,1,tzinfo=timezone.utc)
+first = schedule_once(prefix+execution_slot(t1),[note_id])
+same_slot = schedule_once(prefix+execution_slot(t1),[note_id])
+later_slot = schedule_once(prefix+execution_slot(t2),[note_id])
+assert first is not None and same_slot is None
+assert later_slot is not None and later_slot != first
+print(delivery_label,{"first":first,"same_slot":same_slot,"later_slot":later_slot})
+# 此处会创建两条真实本地job与outbox事件;按正常publisher/worker流程处理。
+````
+
+#### 6. 验证停机与时区行为
+
+停止本地beat两分钟再启动,查询时间槽表。本例不会自动补发停机期间每一分钟的导出;明确记录这一行为。若需要补跑,先设计持久due_at游标和补跑容量,再修改任务。
+
+文件:终端 / 当前课时
+
+````sql
+SELECT slot_key,job_id,created_at
+FROM export_schedule_slots ORDER BY created_at DESC LIMIT 10;
+````
+
+### 验收结果
+
+- 同一slot_key最多对应一个job_id与初始outbox事件。
+- beat只发送信号,文件由worker和publisher链路完成。
+- 时区固定UTC,停机遗漏的分钟不会被伪装成已执行。
+- 同一模拟信号跨分钟执行会生成两个槽,明确记录这与同槽去重的区别。
+
+### 生产约束与故障演练
+
+每分钟为笔记生成一份小快照,调度进程偶尔重启。
+
+#### 实现决策
+
+- 数据库唯一键合并同一执行分钟的任务;本课不承诺原始beat信号跨分钟重投只创建一次。
+- 定义时区、时间槽和停机补跑语义,不能依靠默认本地时间。
+- 慢任务的重叠策略和补跑容量单独控制。
+- 需要固定计划时间和补跑时,升级为持久next_due_at游标与唯一scheduled_at槽,受理额度不足时不推进游标。
+
+#### 隔离环境演练
+
+依次验证同槽重复调用、同一模拟信号跨分钟调用、beat停机两分钟三种情形;当前模型分别得到合并、新槽、无自动补跑。
+
+#### 恢复验收
+
+- 重复调度不能创建两份同槽业务导出。
+- 没有把beat当任务执行器或持久业务状态真源。
+- 停机后的补跑规则可明确说明。
+- 不把执行分钟合并误写成原始信号exactly-once;跨分钟行为有实验证据。
+
+### 常见错误
+
+#### 启动两个beat后定时任务数量翻倍
+
+原因:多个调度器同时发送同一计划。
+
+处理:常态运行单beat,数据库slot唯一约束兜住重复业务创建。
+
+#### 以为周期调度天然不重叠
+
+原因:上一分钟任务未完成,下一分钟仍会生成新任务。
+
+处理:按业务增加导出类型并发限制或跳过策略,保留任务执行租约。
+
+### 扩展练习
+
+用两个producer并发调用同一全新slot_key,确认只有一个返回job_id。
+
+提示:唯一约束需要在数据库事务中争取,不能先SELECT判断再INSERT。
+
+参考答案:
+
+一方成功创建job和outbox,另一方ON CONFLICT返回None。最终一个slot、一条业务任务;数据库事务保证外键成立。
+
+### 技术文档
+
+- [Celery周期任务](https://docs.celeryq.dev/en/stable/userguide/periodic-tasks.html)
+- [PostgreSQL约束](https://www.postgresql.org/docs/16/ddl-constraints.html)
+- [Python时区时间](https://docs.python.org/3.11/library/datetime.html)
+
+---
+
+## 15 实现优雅退出、取消与失败隔离恢复
+
+按持久退避恢复过期租约,重新派发久未领取的queued任务,并保留取消、失败与人工重放记录。
+
+### 本节目标
+
+- 区分请求取消与实际停止
+- 恢复过期租约并限制重复尝试
+- 隔离耗尽任务并保留重放审计
+- 用next_attempt_at阻止重复消息绕过退避
+- 恢复已确认派发但仍未被领取的queued任务
+
+### 必要概念
+
+#### 取消需要执行者配合
+
+取消标志表示用户请求停止,不能证明线程、子进程或外部写入已经停止。执行者在分块边界检查权属与取消,发布结果前再次CAS;不要把terminate当普通业务取消。
+
+#### 正常退出先停止取新任务
+
+Celery worker收到TERM进行warm shutdown,等待正在运行工作;强制终止不能保证finally执行。退出宽限期要覆盖有界单次任务,恢复依赖状态租约和未确认消息机制。
+
+#### 死信是可核对的业务隔离
+
+Redis broker没有自动替本模块建立业务死信流程。本课用export_dead_letters保存尝试耗尽或过期任务,原任务保持failed;修复后创建新job重放,保留原始失败证据。
+
+#### 派发成功与任务领取是不同事实
+
+publisher收到发送成功后,消息仍可能在broker故障中丢失;worker也可能在claim之前因数据库连接错误退出。两者都可能留下queued和已sent事件。恢复扫描还要查久未领取、没有未发送事件的queued任务,原子新建outbox。重复派发允许存在,数据库claim决定谁能执行。
+
+### 动手实现
+
+#### 1. 增加取消标记与隔离表
+
+请求取消只修改活跃状态记录,终态不回退。取消路由仍应复用notes-service身份与任务归属校验。运行中的文件操作可能继续片刻,但第11课finish必须检查取消和deadline。
+
+文件:schema_recovery.sql
+
+````sql
+CREATE TABLE IF NOT EXISTS export_dead_letters (
+  job_id uuid PRIMARY KEY REFERENCES export_jobs(id),
+  reason text NOT NULL,
+  quarantined_at timestamptz NOT NULL DEFAULT now()
+);
+ALTER TABLE export_jobs ADD COLUMN IF NOT EXISTS replay_of uuid REFERENCES export_jobs(id);
+
+-- 用参数化语句处理一个已授权的job_id:
+-- UPDATE export_jobs SET cancel_requested=true, updated_at=now()
+-- WHERE id=%s AND status IN ('queued','running') RETURNING id,status;
+````
+
+#### 2. 在处理边界检查权属
+
+把can_continue追加到state_store.py,在生成每个数据块前调用,返回False就停止后续工作。小文件实验可在write_text前检查;完成CAS仍不可省略。有效租约也必须处于业务deadline以内。
+
+文件:state_store.py
+
+````python
+def can_continue(job_id: str, token: str) -> bool:
+    with connect() as conn:
+        row = conn.execute("""
+          SELECT id FROM export_jobs
+          WHERE id=%s AND lease_token=%s AND status='running'
+            AND lease_until>now() AND deadline_at>now()
+            AND cancel_requested=false
+        """, (job_id, token)).fetchone()
+    return row is not None
+
+# export_tasks.py生成文件前:
+# if not can_continue(job_id, token):
+#     return {"job_id": job_id, "published": False}
+````
+
+#### 3. 在publisher确认时记录派发时间
+
+替换publisher.py中的publish_one,保留原claim_event,并确认第13课已让RETURNING包含queue_name。事件确认与last_dispatched_at在同一短事务中写入,网络发送仍在事务外。UPDATE未匹配token时不写job时间,旧publisher不能延迟新一轮恢复判断。last_dispatched_at只表示发送响应已确认,不保证消息永不丢失。
+
+文件:publisher.py
+
+````python
+def publish_one():
+    event = claim_event()
+    if event is None:
+        return False
+    app.send_task(
+        "notes.export",args=[str(event["job_id"])],
+        task_id=str(event["id"]),queue=event["queue_name"],retry=False,
+    )
+    with connect() as conn:
+        confirmed = conn.execute("""
+          UPDATE export_outbox SET sent_at=now(),claim_until=NULL
+          WHERE id=%s AND claim_token=%s AND sent_at IS NULL
+          RETURNING job_id
+        """,(event["id"],event["claim_token"])).fetchone()
+        if confirmed is not None:
+            conn.execute("UPDATE export_jobs SET last_dispatched_at=now() WHERE id=%s",
+                         (confirmed["job_id"],))
+    return confirmed is not None
+````
+
+#### 4. 扫描租约与未领取任务,原子生成恢复事件
+
+新建recovery.py。每次最多50条;锁内只做数据库操作。running租约过期后设next_attempt_at=10秒后,outbox同时使用该时间;所有claim都检查此字段。queued超过最近一次确认派发60秒仍未领取、且不存在待发送事件时重新派发,解决消息丢失或claim前失败。旧queued缺last_dispatched_at时兼容读取已有sent_at。60秒是本机观察阈值,生产应结合队列正常等待SLO设置,过短会制造重复消息。 无消费能力时最多按阈值重新派发直到业务deadline,派发不增加执行attempts;attempts只在claim成功时增加。仍有未发送outbox时由publisher租约重试,扫描器不重复堆新事件。课件一次性命令只处理一批;部署时应以监督进程或调度器每5秒调用扫描器并持续运行publisher,空批次等待、异常告警、关闭时停止取新工作。
+
+文件:recovery.py
+
+````python
+from uuid import uuid4
+from state_store import connect
+
+def recover_batch():
+    with connect() as conn:
+        rows = conn.execute("""
+          SELECT j.*,j.deadline_at<=now() AS expired
+          FROM export_jobs j
+          WHERE (j.status='running' AND j.lease_until<now())
+             OR (j.status='queued' AND
+                 (j.cancel_requested OR j.deadline_at<=now() OR j.attempts>=3))
+             OR (j.status='queued' AND j.next_attempt_at<=now()
+                 AND j.cancel_requested=false AND j.deadline_at>now() AND j.attempts<3
+                 AND NOT EXISTS (
+                   SELECT 1 FROM export_outbox pending
+                   WHERE pending.job_id=j.id AND pending.sent_at IS NULL)
+                 AND GREATEST(j.updated_at,j.next_attempt_at,
+                     COALESCE(j.last_dispatched_at,
+                       (SELECT max(o.sent_at) FROM export_outbox o WHERE o.job_id=j.id),
+                       j.created_at)) < now()-interval '60 seconds')
+          ORDER BY j.updated_at FOR UPDATE OF j SKIP LOCKED LIMIT 50
+        """).fetchall()
+        for row in rows:
+            if row["cancel_requested"]:
+                status,reason,delay = "cancelled","user_cancelled",0
+            elif row["expired"] or row["attempts"] >= 3:
+                status = "failed"
+                reason = "deadline_exceeded" if row["expired"] else "attempts_exhausted"
+                delay = 0
+            elif row["status"] == "running":
+                status,reason,delay = "queued","lease_expired",10
+            else:
+                status,reason,delay = "queued","dispatch_overdue",0
+            updated = conn.execute("""
+              UPDATE export_jobs SET status=%s,error_code=%s,
+                lease_token=NULL,lease_until=NULL,
+                next_attempt_at=now()+(%s * interval '1 second'),updated_at=now()
+              WHERE id=%s RETURNING next_attempt_at
+            """,(status,reason,delay,row["id"])).fetchone()
+            if status == "failed":
+                conn.execute("""
+                  INSERT INTO export_dead_letters(job_id,reason) VALUES (%s,%s)
+                  ON CONFLICT(job_id) DO NOTHING
+                """,(row["id"],reason))
+            if status == "queued":
+                conn.execute("""
+                  INSERT INTO export_outbox(id,job_id,queue_name,available_at)
+                  VALUES (%s,%s,'exports.bulk',%s)
+                """,(str(uuid4()),row["id"],updated["next_attempt_at"]))
+    return len(rows)
+
+if __name__ == "__main__":
+    print("recovered",recover_batch())
+````
+
+#### 5. 验证重复消息不能跳过持久退避
+
+先暂停本机publisher与consumer,防止实验记录被正常流程抢先处理。只针对新建实验job设置next_attempt_at为未来10秒;两次直接claim模拟已有重复消息。两次均为None,attempts保持0。到期后claim成功且attempts变1。演练需要10秒等待,只在本地执行;任务后续通过正常worker输出或恢复扫描到终态。
+
+文件:verify_retry_gate.py
+
+````python
+import time
+from state_store import connect,submit_snapshot,claim
+
+with connect() as conn:
+    note_id=conn.execute("SELECT min(id) AS id FROM notes").fetchone()["id"]
+assert note_id is not None
+job_id=submit_snapshot([note_id])
+with connect() as conn:
+    conn.execute("UPDATE export_jobs SET next_attempt_at=now()+interval '10 seconds' WHERE id=%s",(job_id,))
+assert claim(job_id) is None
+assert claim(job_id) is None
+with connect() as conn:
+    assert conn.execute("SELECT attempts FROM export_jobs WHERE id=%s",(job_id,)).fetchone()["attempts"] == 0
+time.sleep(10.1)
+job=claim(job_id)
+assert job is not None and job["attempts"] == 1
+print("persistent retry gate verified",job_id)
+# 此处人为持有新租约;不伪造成功结果,等待租约到期后由恢复流程重新排队。
+````
+
+#### 6. 模拟派发后消息丢失,不删除broker数据
+
+暂停本机publisher和consumer后执行本实验,避免正常进程抢先处理。使用新建job的outbox行模拟发送已确认,但不真的发送Celery消息;只修改该job时间。扫描后必须出现新的pending outbox,第二次扫描不能再给它新增事件。恢复publisher和consumer后应正常执行。这覆盖排队消息丢失,也覆盖worker在claim前数据库连接失败的相同持久状态。
+
+文件:verify_queued_recovery.py
+
+````python
+from state_store import connect,submit_snapshot
+from recovery import recover_batch
+
+with connect() as conn:
+    note_id=conn.execute("SELECT min(id) AS id FROM notes").fetchone()["id"]
+assert note_id is not None
+job_id=submit_snapshot([note_id])
+with connect() as conn:
+    conn.execute("""
+      UPDATE export_outbox SET sent_at=now()-interval '61 seconds',claim_until=NULL
+      WHERE job_id=%s
+    """,(job_id,))
+    conn.execute("""
+      UPDATE export_jobs SET last_dispatched_at=now()-interval '61 seconds',
+        updated_at=now()-interval '61 seconds',next_attempt_at=now()-interval '61 seconds'
+      WHERE id=%s
+    """,(job_id,))
+recover_batch()
+with connect() as conn:
+    pending=conn.execute("SELECT count(*) AS n FROM export_outbox WHERE job_id=%s AND sent_at IS NULL",(job_id,)).fetchone()["n"]
+assert pending == 1
+recover_batch()
+with connect() as conn:
+    assert conn.execute("SELECT count(*) AS n FROM export_outbox WHERE job_id=%s AND sent_at IS NULL",(job_id,)).fetchone()["n"] == 1
+    assert conn.execute("SELECT attempts FROM export_jobs WHERE id=%s",(job_id,)).fetchone()["attempts"] == 0
+print("queued delivery recovery verified",job_id)
+# 恢复正常publisher和consumer,再核对该job终态与输出。
+````
+
+#### 7. 先正常停机,再验证租约恢复
+
+对本次隔离worker先停止取新任务或直接发送TERM,观察warm shutdown等待当前有界任务。只在故障演练时终止已核对的执行进程;30秒租约过期后运行recovery.py,再让publisher发布到期事件。不要使用模糊进程名批量终止。
+
+文件:终端 / 当前课时
+
+````bash
+python -m celery -A celery_app:app inspect active
+python -m celery -A celery_app:app inspect reserved
+# 正常关闭:对已核对的本次worker PID发送TERM,或首次Ctrl+C。
+python recovery.py
+python publisher.py
+````
+
+#### 8. 修复后创建新任务重放
+
+新建replay_job.py,在命令行传一个明确的failed任务ID。事务中保留payload、记录replay_of、建立新outbox;原dead letter不删除。自动批量重放前必须有故障已修复的证据,本模块只逐条本地重放。
+
+文件:replay_job.py
+
+````python
+import sys
+from uuid import UUID, uuid4
+from state_store import connect, admit_export
+
+def replay(old_id: str):
+    old_id = str(UUID(old_id))
+    new_id = str(uuid4())
+    with connect() as conn:
+        admit_export(conn)
+        row = conn.execute("""
+          INSERT INTO export_jobs(id,payload,replay_of)
+          SELECT %s,payload,id FROM export_jobs WHERE id=%s AND status='failed'
+          RETURNING id
+        """, (new_id, old_id)).fetchone()
+        if row is None:
+            raise ValueError("failed source job not found")
+        conn.execute("""
+          INSERT INTO export_outbox(id,job_id,queue_name)
+          VALUES (%s,%s,'exports.bulk')
+        """, (str(uuid4()), new_id))
+    return new_id
+
+if __name__ == "__main__":
+    print(replay(sys.argv[1]))
+````
+
+### 验收结果
+
+- 取消任务不能被finish发布为成功,最终由执行者或扫描器转cancelled。
+- 过期租约恢复产生新事件,attempts不归零,最多3次领取。
+- 耗尽任务进入failed和dead letter,重放使用新job_id并记录replay_of。
+- 已sent但未领取的queued任务能产生新的恢复事件;已有pending事件时不会每轮扫描继续堆积。
+- next_attempt_at到期前重复消息无法增加attempts,派发次数与实际执行次数分开。
+
+### 生产约束与故障演练
+
+发布新版本需要停worker,用户同时请求取消导出,部分任务已多次崩溃。
+
+#### 实现决策
+
+- 取消、正常退出、强制终止分别定义结果和恢复方式。
+- 扫描器只处理过期或明确终止条件,使用短事务与有限批次。
+- 故障修复后以新任务重放,保留旧失败状态和审计关联。
+- queued久未领取和running租约到期分别恢复;派发确认不作为完成证据。
+- 持久next_attempt_at约束所有消息,包括已有重复投递;broker倒计时不是唯一退避控制。
+- publisher与恢复扫描必须由持续监督运行的进程定期执行,单次脚本不是生产调度器。
+
+#### 隔离环境演练
+
+依次验证过期租约、未来next_attempt_at收到重复消息、已sent却queued、已有pending事件抑制重复恢复、3次耗尽与单条重放;每次仅操作记录的本地job_id。
+
+#### 恢复验收
+
+- 有效租约不会被扫描器抢占。
+- 尝试耗尽不会形成无限重投循环。
+- 重放有来源记录且不会抹掉原始失败证据。
+- 派发已确认但消息未领取的任务可恢复,不只等deadline后失败。
+- 退避时间未到时重复消息不消耗执行attempts。
+- 暂时无消费者时重新派发有阈值和deadline限制,不无限快速重投。
+
+### 常见错误
+
+#### revoke后立即在页面显示已取消
+
+原因:revoke或发送取消信号不等于所有执行者与外部操作已停止。
+
+处理:先显示取消申请,待持久状态确认后显示cancelled。
+
+#### 恢复脚本把所有running直接改queued
+
+原因:仍有有效租约的执行者会与新worker竞争。
+
+处理:只恢复过期租约,旧执行者发布必须受token、时间和状态CAS约束。
+
+#### outbox已sent但任务一直queued
+
+原因:消息可能丢失,或worker在claim之前因数据库错误退出;只恢复running无法覆盖。
+
+处理:按持久派发时间扫描久未领取且无pending事件的queued任务,原子重建发送意图;仍用claim防重复执行。
+
+### 扩展练习
+
+连续让同一隔离任务领取后租约过期,验证第3次后进入failed,不产生第4次领取。
+
+提示:只操作本次新建任务,并通过正常claim增加attempts。
+
+参考答案:
+
+前两次过期产生恢复事件;第三次过期被扫描器隔离,attempts保持3,后续claim返回None;原错误和重放链可查询。
+
+### 技术文档
+
+- [Celery worker关闭与revoke](https://docs.celeryq.dev/en/stable/userguide/workers.html)
+- [PostgreSQL行锁](https://www.postgresql.org/docs/16/explicit-locking.html)
+- [Celery任务确认](https://docs.celeryq.dev/en/stable/userguide/tasks.html)
+
+---
+
+## 16 完成故障注入与可观测性验收
+
+用同一组任务验证受理、发布、领取、执行、取消、恢复与结果查询的完整链路。
+
+### 本节目标
+
+- 为每个阶段记录同一业务job_id
+- 用可重复的故障矩阵验证状态与输出
+- 建立队列等待、运行耗时和恢复指标
+
+### 必要概念
+
+#### 指标要对应用户等待过程
+
+排队数量之外,还要测受理到开始、运行时长、总完成时间、最老待发布年龄、失败率和重试次数。只看worker存活无法知道任务是否积压。
+
+#### 追踪业务ID与交付ID
+
+日志同时记录job_id、outbox_id或Celery task_id、lease_token和attempt,才能区分同一个任务的重复消息与重新执行。日志不打印完整笔记正文、数据库密码或访问密钥。
+
+#### 验收包括故障后状态一致性
+
+任务成功需要有效结果引用和可解析文件;取消或失败不能被旧worker覆盖。单元测试能检查状态转换,真实broker、worker和数据库故障窗口仍需隔离集成演练。
+
+### 动手实现
+
+#### 1. 给worker添加结构化阶段日志
+
+在worker领取后和完成后记录下面格式,使用真实变量。错误日志保留异常类型与关联ID,正文快照不进入日志。要记录Celery消息ID可将任务改为bind=True,并使用self.request.id;同时保持对外任务名notes.export。
+
+文件:task_logging.py
+
+````python
+import json
+import logging
+
+logger = logging.getLogger("notes.exports")
+
+def log_phase(job_id, phase, message_id=None, lease_token=None, attempt=None, **fields):
+    logger.info(json.dumps({
+        "event": "export_phase", "job_id": str(job_id),
+        "phase": phase, "message_id": message_id,
+        "lease_token": str(lease_token) if lease_token else None,
+        "attempt": attempt, **fields,
+    }, ensure_ascii=False))
+
+# log_phase(job_id, "claimed", self.request.id, token, job["attempts"])
+# log_phase(job_id, "published", self.request.id, token, job["attempts"],
+#           success=published)
+````
+
+#### 2. 查询积压、租约和失败
+
+保存SQL为monitor_exports.sql,在本地演练前后各执行一次。age和数量一起看;注意取消等待清理、outbox未发布和running租约过期是不同阶段。当前updated_at不能直接当精确执行耗时,需另记started_at/finished_at或使用结构化阶段时间戳。
+
+文件:monitor_exports.sql
+
+````sql
+SELECT status,count(*) AS total
+FROM export_jobs GROUP BY status;
+
+SELECT count(*) AS unpublished,
+       extract(epoch FROM now()-min(created_at)) AS oldest_unpublished_seconds
+FROM export_outbox WHERE sent_at IS NULL;
+
+SELECT count(*) AS expired_leases
+FROM export_jobs WHERE status='running' AND lease_until<now();
+
+SELECT reason,count(*) AS failed
+FROM export_dead_letters GROUP BY reason;
+
+SELECT count(*) AS queued_without_pending,
+       extract(epoch FROM now()-min(COALESCE(j.last_dispatched_at,j.created_at))) AS oldest_dispatch_age
+FROM export_jobs j
+WHERE j.status='queued' AND NOT EXISTS (
+  SELECT 1 FROM export_outbox o WHERE o.job_id=j.id AND o.sent_at IS NULL
+);
+
+SELECT id,status,attempts,next_attempt_at,last_dispatched_at
+FROM export_jobs WHERE status IN ('queued','running') ORDER BY created_at LIMIT 20;
+````
+
+#### 3. 以任务ID检查终态和文件
+
+新建verify_terminal.py,运行时传本次job_id。成功时只读取artifacts目录内的受控结果,核对文件内容与固定payload。查询不是SUCCESS时明确显示实际状态,不用RedisPENDING代替数据库结果。
+
+文件:verify_terminal.py
+
+````python
+import json
+import sys
+from pathlib import Path
+from uuid import UUID
+from state_store import connect
+
+job_id = str(UUID(sys.argv[1]))
+with connect() as conn:
+    job = conn.execute("SELECT * FROM export_jobs WHERE id=%s", (job_id,)).fetchone()
+assert job is not None, "job missing"
+print("state", job["status"], "attempts", job["attempts"])
+if job["status"] == "succeeded":
+    root = Path("artifacts").resolve()
+    target = Path(job["result_ref"]).resolve()
+    assert target.is_relative_to(root), "unexpected artifact location"
+    assert json.loads(target.read_text(encoding="utf-8")) == job["payload"]
+    assert not job["cancel_requested"]
+    print("artifact verified")
+elif job["status"] in {"failed", "cancelled"}:
+    assert job["result_ref"] is None
+    print("terminal without published artifact")
+````
+
+#### 4. 按矩阵逐个注入故障
+
+每一行使用新建job_id,先记基线,再暂停或修改本次隔离组件,最后恢复并运行publisher/recovery。不要同时注入多种故障,否则无法确定恢复由哪个机制保证。下面是待执行清单,不代表已经跑过。
+
+文件:failure-matrix.md
+
+````text
+| 故障点 | 操作 | 应核对的结果 |
+|---|---|---|
+| DB事务提交前 | 本次提交主动抛异常 | job与outbox同时回滚 |
+| broker不可用 | 暂停本地Redis后提交 | job与outbox保留,sent_at为空 |
+| 发布后未标sent | 跳过一次确认写回 | 可重复发布,业务结果仍受CAS保护 |
+| worker持租约退出 | 终止本次隔离执行进程 | 到期恢复,attempts增加 |
+| 旧token晚到 | 新领取后用旧token完成 | finish=False,不改result_ref |
+| 取消 | 设置本次cancel_requested | 最终cancelled,不发布成功结果 |
+| 次数耗尽 | 3次领取均失败/过期 | failed及dead letter,无第4次领取 |
+| 结果缓存过期 | 缩短本地result_expires | PostgreSQL状态仍可查询 |
+| 队列满 | 临时小容量并发提交 | 503,无虚假202 |
+| 同槽周期重复 | 相同slot并发调度 | 只创建一个周期job |
+| 周期跨分钟重投 | 同一模拟信号用两个执行分钟 | 当前模型创建两个槽,不宣称原信号exactly-once |
+| 已派发但未领取 | 专用job模拟sent且queued、时间超过阈值 | 扫描生成新outbox,恢复后完成 |
+| claim之前失败 | worker进入claim前抛连接错误 | queued路径能重新派发 |
+| 退避期间重复消息 | next_attempt_at未来时直接重复claim | 均返回None,attempts不增加 |
+| pending事件仍在 | 同一个queued重复运行扫描器 | 不为已有pending事件继续新增恢复事件 |
+````
+
+#### 5. 保存观测证据并核对恢复预算
+
+为每次演练填写实际值:操作时间、job_id、消息ID、租约、状态转换、输出摘要、恢复耗时。只有实际运行后才能标pass。设置本地验收阈值,例如过期租约路径在60秒内重新进入处理、queued丢消息路径在90秒内重新进入处理;若超出,检查可见性时间、outbox租约、扫描周期与队列等待之和。 queued恢复包含60秒未领取阈值,不能拿租约恢复阈值直接套用;两条路径都要加入扫描周期、10秒退避(仅过期运行任务)和实际队列等待。
+
+文件:verification-record.json
+
+````json
+{
+  "case": "publish_after_send_before_mark",
+  "job_id": "填写本次实际ID",
+  "observed_states": [],
+  "duplicate_deliveries": null,
+  "recovery_seconds": null,
+  "artifact_sha256": null,
+  "result": "not_run",
+  "notes": "实际演练后填写;不要用预计行为替代观测"
+}
+````
+
+### 验收结果
+
+- 每个故障案例有实际job_id、时间、状态和恢复证据才标通过。
+- 成功文件与payload一致,失败或取消不会被旧token发布成功。
+- 结果缓存过期不导致业务记录404,容量与调度去重也经过验证。
+- 已sent未领取、退避重复消息与跨分钟周期重投都有独立案例,不被普通worker存活检查替代。
+- 任务执行attempts不会因单纯重新派发增加;等待有持久时间和deadline边界。
+
+### 生产约束与故障演练
+
+准备把导出服务交给他人维护,需要证明故障可恢复且结果可追踪。
+
+#### 实现决策
+
+- 先设受理、开始、完成和恢复的可量化目标,再配置阈值。
+- 日志以业务ID关联,不把敏感笔记内容当诊断材料。
+- 把未执行的演练标为not_run,只有实际记录支持通过结论。
+
+#### 隔离环境演练
+
+逐项执行本课矩阵,恢复所有本地组件与临时配置后,再跑一次无故障导出作为回归。
+
+#### 恢复验收
+
+- 每项恢复结论都有观测证据。
+- 已受理任务能定位到待发布、运行或明确终态。
+- 不会把语法校验或eager函数测试描述为生产可靠性证明。
+- 恢复覆盖queued和running两类滞留状态,时间槽去重范围与实际实现一致。
+
+### 常见错误
+
+#### 只有worker心跳正常就判定服务正常
+
+原因:worker可能活着但队列积压、outbox不发布或任务卡住。
+
+处理:同时观察最老等待、过期租约、失败和用户完成时间。
+
+#### 测试使用task_always_eager却声称broker恢复已验证
+
+原因:eager在当前进程执行,绕过真实交付链路。
+
+处理:eager只做函数级测试,消息重投与worker故障使用隔离集成环境。
+
+### 扩展练习
+
+选择“发布成功但sent_at未写回”案例,写出日志应包含的三个ID,并说明如何判断重复消息没有改变最终结果。
+
+提示:区分业务job_id、outbox消息ID和租约token。
+
+参考答案:
+
+同一job_id关联重复投递日志,消息ID可能相同或不同;新领取有新token,只有有效token完成CAS。最终唯一result_ref指向与payload一致的文件,旧token完成返回False。
+
+### 技术文档
+
+- [Celery监控](https://docs.celeryq.dev/en/stable/userguide/monitoring.html)
+- [Celery测试边界](https://docs.celeryq.dev/en/stable/userguide/testing.html)
+- [PostgreSQL统计](https://www.postgresql.org/docs/16/monitoring-stats.html)

+ 2064 - 0
public/downloads/troubleshooting-lessons.md

@@ -0,0 +1,2064 @@
+# 线上问题排查
+
+术核 AI · 后端工程课件
+
+## 01 先确认影响范围,再选择止损动作
+
+建立本地事故演练记录,区分接口故障、导出积压和实例不可用。
+
+### 本节目标
+
+- 用用户可观察行为定义影响范围
+- 记录统一时间线和已验证事实
+- 为止损动作写出预期、回滚与恢复门槛
+
+### 必要概念
+
+#### 错误数量不能单独决定影响
+
+一个导出接口失败和全部笔记读取失败有不同影响。记录受影响操作、用户范围、开始时间、成功率和延迟,再确定处理优先级。
+
+#### 先区分事实与假设
+
+“新增请求都超时”是观测,“数据库挂了”是待验证假设。每次只改变一个因素,避免多个操作互相覆盖证据。
+
+#### 恢复服务与定位根因分开记录
+
+止损可以是暂停大导出受理、回退本次配置或缩小流量;它不自动证明根因。恢复后继续核对已受理任务和数据一致性。
+
+### 动手实现
+
+#### 1. 建立独立演练目录与时间基线
+
+本模块在部署模块建立的 notes-service 隔离副本上演练;Linux 单元为 notes-api.service,基础端点为 /live(进程存活)与 /ready(依赖就绪)。先在副本新建 diagnostics_lab。Linux/WSL2 命令只针对隔离目标;容器部署从 Compose 目录使用 docker compose ps 定位 service,不猜容器名。第 11 课的 SSE/WS 是 API 模块扩展路由,须先确认已加入副本。
+
+文件:终端 / 当前课时
+
+````bash
+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
+
+````text
+| 操作 | 当前观测 | 受影响范围 | 最后一次正常时间 |
+|---|---|---|---|
+| GET /live | 待测 | 待测 | 待测 |
+| GET /ready | 待测 | 待测 | 待测 |
+| GET /notes | 待测 | 待测 | 待测 |
+| submit_snapshot 提交持久导出 | 待测 | 待测 | 待测 |
+| export_jobs 状态与结果文件 | 待测 | 待测 | 待测 |
+| SSE /stream 与 WS /ws | 待测 | 待测 | 待测 |
+````
+
+#### 3. 为每次动作写四个字段
+
+先用只读查询验证假设,再决定是否在隔离副本中操作。比如只暂停本地导出受理,预期减小新增积压;回滚方式是恢复之前配置。当前代码若没有受理开关,先增加并测试该开关,不能把未实现的操作写成已执行。
+
+文件:action-record.json
+
+````json
+{
+  "timestamp_utc": "执行时填写",
+  "observation": "填写实际现象",
+  "hypothesis": "填写待验证原因",
+  "action": "planned",
+  "expected_change": "填写应变化的指标",
+  "rollback": "填写恢复原配置的方法",
+  "result": "not_run"
+}
+````
+
+#### 4. 核对止损后仍有无遗漏任务
+
+只读查询数据库任务状态与最老等待。恢复判定至少覆盖健康、笔记读取、一个新导出完成、旧任务去向;不能仅以进程重新启动作为结束标准。
+
+文件:终端 / 当前课时
+
+````sql
+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
+
+````text
+# incident.md 时间线模板
+# UTC时间 | 目标实例/任务ID | 实际观测 | 动作 | 结果 | 下一验证
+# 未执行动作统一标planned,不用预计结果填入实际结果列。
+````
+
+### 验收结果
+
+- 影响范围按具体用户操作列出,而非只写服务异常。
+- 每次变更有目标、预期、回滚和实际结果。
+- 恢复门槛包含业务请求与既有任务去向,未执行项保持not_run。
+
+### 生产约束与故障演练
+
+用户反馈导出一直等待,但笔记列表仍正常。
+
+#### 实现决策
+
+- 先降低用户影响,再定位根因,两者分别留证据。
+- 一次只调整一个因素,限定实例和任务ID。
+- 业务恢复要求验证旧任务和新请求,不能只看进程状态。
+
+#### 隔离环境演练
+
+只暂停本地练习worker,提交一条导出,按模板判断影响,再恢复worker核对任务完成。
+
+#### 恢复验收
+
+- 时间线区分事实、假设与操作。
+- 没有盲目批量重启或终止进程。
+- 恢复结论有业务验证支持。
+
+### 常见错误
+
+#### 看到500立即重启所有组件
+
+原因:未判断影响位置,重启可能扩大故障并丢掉现场。
+
+处理:先保存进程、接口和依赖观测,仅对已验证目标采取有界动作。
+
+#### 健康检查成功就宣布恢复
+
+原因:健康检查可能只确认进程存活,没有覆盖数据库和队列。
+
+处理:增加真实读取与导出完成验收,区分liveness和业务可用性。
+
+### 扩展练习
+
+“/live正常、/notes正常、导出202正常但10分钟未完成”应怎样描述影响?
+
+提示:把用户完成时间与请求受理状态分开。
+
+参考答案:
+
+描述为导出执行链路延迟或积压,读取功能仍可用;检查outbox、队列、worker、租约,不能仅凭202判定业务正常,也不能直接推断数据库整体不可用。
+
+### 技术文档
+
+- [Google SRE事故管理](https://sre.google/sre-book/managing-incidents/)
+- [Google SRE有效排障](https://sre.google/sre-book/effective-troubleshooting/)
+
+---
+
+## 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
+
+````python
+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
+
+````python
+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
+
+````json
+{"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默认访问日志需要另行检查查询字符串记录策略。
+
+文件:终端 / 当前课时
+
+````bash
+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
+
+````python
+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,并可在日志关联;非法输入不会进入日志字段。
+
+### 技术文档
+
+- [Python logging](https://docs.python.org/3.11/library/logging.html)
+- [ASGI HTTP事件](https://asgi.readthedocs.io/en/latest/specs/www.html)
+- [OWASP日志安全](https://cheatsheetseries.owasp.org/cheatsheets/Logging_Cheat_Sheet.html)
+
+---
+
+## 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
+
+````python
+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
+
+````python
+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
+
+````python
+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
+
+````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作为标签。
+
+文件:终端 / 当前课时
+
+````bash
+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]))得到时长比例;若要快速且成功,应另记录成功请求的时长桶或专用好事件计数,不能直接把独立成功率与时长比例相乘。
+
+### 技术文档
+
+- [Prometheus标签命名](https://prometheus.io/docs/practices/naming/)
+- [Prometheus Histogram](https://prometheus.io/docs/practices/histograms/)
+- [Prometheus Python客户端](https://prometheus.github.io/client_python/)
+
+---
+
+## 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
+
+````python
+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
+
+````python
+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
+
+````python
+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
+
+````python
+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
+
+````text
+# 待填写的演练字段
+# 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上下文拒绝已受理业务。
+
+### 技术文档
+
+- [OpenTelemetry Python](https://opentelemetry.io/docs/languages/python/instrumentation/)
+- [FastAPI插桩](https://opentelemetry-python-contrib.readthedocs.io/en/latest/instrumentation/fastapi/fastapi.html)
+- [Celery插桩](https://opentelemetry-python-contrib.readthedocs.io/en/latest/instrumentation/celery/celery.html)
+
+---
+
+## 05 区分 CPU 忙、事件循环阻塞与线程池等待
+
+用进程指标、事件循环延迟和有界故障对照,定位Python服务变慢的位置。
+
+### 本节目标
+
+- 记录进程CPU与线程数量
+- 观察事件循环调度延迟
+- 区分阻塞I/O、纯Python计算与执行器排队
+
+### 必要概念
+
+#### CPU低不代表应用没有阻塞
+
+async路由中time.sleep或同步网络等待会阻塞事件循环,但CPU可能很低。CPU持续高则需进一步区分Python计算、序列化与底层库工作。
+
+#### 事件循环延迟显示调度是否及时
+
+周期协程实际恢复时间晚于预期,说明事件循环无法及时调度。它是现象指标,仍要结合堆栈、CPU与依赖耗时定位原因。
+
+#### 线程池排队有独立等待成本
+
+阻塞函数移到线程后,线程数不足仍可能排队;无限增加线程会扩大连接和内存消耗。任务数量、工作时间与下游额度共同决定容量。
+
+### 动手实现
+
+#### 1. 采集一个明确进程的基线
+
+先按部署名称读取MainPID,再确认PID非0、对应本次notes-service。pidstat来自sysstat,若未安装可先用ps。命令只读,不输出环境变量或完整命令行。
+
+文件:终端 / 当前课时
+
+````bash
+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
+
+````python
+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
+
+````python
+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
+
+````text
+# 在同样的低并发负载下各记录:
+# 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/数据库调用、同步文件操作;结合当前堆栈和依赖耗时验证,再改为异步调用或受限线程。
+
+### 技术文档
+
+- [Python asyncio调试](https://docs.python.org/3.11/library/asyncio-dev.html)
+- [Python faulthandler](https://docs.python.org/3.11/library/faulthandler.html)
+- [Python执行器](https://docs.python.org/3.11/library/concurrent.futures.html)
+
+---
+
+## 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 口径不同,记录时保留来源。
+
+文件:终端 / 当前课时
+
+````bash
+# 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
+
+````python
+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下结论。
+
+文件:终端 / 当前课时
+
+````bash
+# 在已确认使用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
+
+````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当修复。
+
+### 技术文档
+
+- [Python tracemalloc](https://docs.python.org/3.11/library/tracemalloc.html)
+- [Linux cgroup v2](https://www.kernel.org/doc/html/latest/admin-guide/cgroup-v2.html)
+- [Docker内存限制](https://docs.docker.com/engine/containers/resource_constraints/)
+
+---
+
+## 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。
+
+文件:终端 / 当前课时
+
+````bash
+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
+
+````python
+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
+
+````text
+# 演练记录字段:
+# phase: pool / connect / read
+# configuration_changed:
+# exception_type_observed:
+# elapsed_seconds:
+# restored_to_baseline: false
+# 上例是记录模板,不是已观察到的结果。
+````
+
+#### 4. 只读取必要网络证据
+
+若必须定位TLS,用无凭据的测试请求核对证书主机名与链路;不要以verify=False作为修复。抓包只在隔离且无敏感流量的环境按最小范围使用,本模块默认以阶段耗时和连接统计定位,不导出完整HTTP载荷。
+
+文件:network-notes.md
+
+````text
+# 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秒未响应。
+
+### 技术文档
+
+- [HTTPX阶段超时](https://www.python-httpx.org/advanced/timeouts/)
+- [HTTPX连接限制](https://www.python-httpx.org/advanced/resource-limits/)
+- [curl时间变量](https://curl.se/docs/manpage.html)
+
+---
+
+## 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连接,避免把密码贴入命令行。普通账户可见信息受权限限制,不把缺失字段当作没有活动。
+
+文件:终端 / 当前课时
+
+````sql
+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,释放故障条件。
+
+文件:终端 / 当前课时
+
+````sql
+-- 会话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执行。不要在事故中直接对未知大表运行昂贵诊断。
+
+文件:终端 / 当前课时
+
+````sql
+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
+
+````python
+# 使用现有psycopg_pool对象,只记录统计数字:
+# stats = pool.get_stats()
+# logger.info({"event": "db_pool_stats", "stats": stats})
+
+# 检查应用事务边界:
+# 获取连接 → 短SQL/事务 → 提交或回滚 → 归还连接
+# 不把网络上传、长await或CPU导出放在持有事务的区间。
+````
+
+#### 5. 恢复后核对锁和业务请求
+
+回滚会话A后重试读取,应恢复正常;再次查询活动会话确认没有本次idle in transaction。记录谁持锁、谁等待、锁何时释放。未经确认不要终止任意数据库backend。
+
+文件:终端 / 当前课时
+
+````sql
+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前先核对数据库容量。
+
+### 技术文档
+
+- [PostgreSQL活动统计](https://www.postgresql.org/docs/16/monitoring-stats.html)
+- [PostgreSQL锁](https://www.postgresql.org/docs/16/explicit-locking.html)
+- [PostgreSQL EXPLAIN](https://www.postgresql.org/docs/16/using-explain.html)
+- [Psycopg连接池](https://www.psycopg.org/psycopg3/docs/advanced/pool.html)
+
+---
+
+## 09 排查 Redis 热点、淘汰与缓存降级
+
+先确认实例用途,再观察命中率、内存和延迟,保护数据库避免缓存故障扩散。
+
+### 本节目标
+
+- 区分缓存实例与任务broker
+- 读取命中、淘汰、连接与内存指标
+- 给缓存失效设计受限回源路径
+
+### 必要概念
+
+#### 缓存与broker不能用同一恢复策略
+
+缓存可按业务降级回源,broker承载待交付消息,不能通过清空键空间恢复。模块统一缓存宿主6380、独立任务broker6384;先核对用途再操作。
+
+#### 累计指标需要按窗口取增量
+
+keyspace_hits和keyspace_misses是累计计数,计算窗口命中率要使用增量。evicted_keys增加表示内存策略淘汰,expired_keys增加表示TTL到期,两者原因不同。
+
+#### 回源必须有容量上限
+
+缓存不可用后所有请求直接打数据库可能造成级联故障。设置缓存短超时、回源并发限制和必要的过载拒绝;多进程总量不能只按单进程Semaphore理解。
+
+### 动手实现
+
+#### 1. 安全连接指定缓存实例
+
+在Linux/WSL2终端通过read隐藏输入本地缓存密码,不把密码写进-a参数。以下只读命令指向6380;broker6384需要其独立密码,不能复用缓存故障动作。
+
+文件:终端 / 当前课时
+
+````bash
+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扫描实际业务内容。
+
+文件:终端 / 当前课时
+
+````bash
+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
+
+````python
+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
+
+````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,不能把低样本波动误判为长期趋势。
+
+### 技术文档
+
+- [Redis INFO](https://redis.io/docs/latest/commands/info/)
+- [Redis淘汰策略](https://redis.io/docs/latest/develop/reference/eviction/)
+- [Redis延迟排查](https://redis.io/docs/latest/operate/oss_and_stack/management/optimization/latency/)
+
+---
+
+## 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;分享输出前核对。
+
+文件:终端 / 当前课时
+
+````bash
+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很老只是排查线索,不足以直接断言消息已经丢失;按异步任务模块的派发记录与恢复门槛处理。
+
+文件:终端 / 当前课时
+
+````sql
+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的最终缓存值。
+
+文件:终端 / 当前课时
+
+````sql
+-- 将参数绑定为本次演练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,保留旧失败证据。
+
+文件:终端 / 当前课时
+
+````bash
+python recovery.py
+python publisher.py
+# 对已修复且明确的failed任务:
+# python replay_job.py <本次已核对的失败job_id>
+# 不运行celery purge,不通过重置attempts解除失败限制。
+````
+
+#### 5. 验证恢复不是短暂下降
+
+恢复后持续观察最老等待、完成速率、重试次数和下游错误,覆盖至少一个任务截止时间与恢复扫描周期。增加worker后若数据库池等待上升,应停止扩大并发并重新核算预算。
+
+文件:queue-recovery.md
+
+````text
+# 恢复验收记录:
+# 影响队列:
+# 下游修复证据:
+# 受控恢复任务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算力不足。
+
+### 技术文档
+
+- [Celery监控](https://docs.celeryq.dev/en/stable/userguide/monitoring.html)
+- [Celery优化](https://docs.celeryq.dev/en/stable/userguide/optimizing.html)
+- [Celery Redis交付](https://docs.celeryq.dev/en/stable/getting-started/backends-and-brokers/redis.html)
+
+---
+
+## 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
+
+````python
+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或渲染缓存。
+
+文件:终端 / 当前课时
+
+````bash
+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
+
+````nginx
+# 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
+
+````javascript
+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
+
+````text
+# 记录实际观测,勿填写预计时间:
+# 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到达时间与代理结果,再检查生成器、代理缓冲和客户端读取;响应头时间不证明每段业务数据的传输情况。
+
+### 技术文档
+
+- [Nginx代理缓冲](https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_buffering)
+- [Nginx WebSocket](https://nginx.org/en/docs/http/websocket.html)
+- [HTTPX流读取](https://www.python-httpx.org/quickstart/)
+- [MDN EventSource](https://developer.mozilla.org/en-US/docs/Web/API/EventSource)
+
+---
+
+## 12 完成故障演练、恢复门槛与 runbook
+
+把排障步骤整理为可交接流程,保存证据、恢复条件和后续修复项。
+
+### 本节目标
+
+- 执行单变量故障矩阵并保留实际观测
+- 用明确业务门槛判断恢复
+- 编写可执行的runbook与复盘行动项
+
+### 必要概念
+
+#### 演练要有开始与结束条件
+
+每个故障限定实例、任务ID、持续时间和恢复方法。达到预定风险边界就停止,不能为了复现而无限增加负载。
+
+#### 恢复门槛应覆盖业务与数据
+
+服务恢复包括新请求正常、积压受控、已受理任务去向清楚、无错误发布结果。重启成功或监控颜色变绿只是部分证据。
+
+#### 复盘区分触发因素与系统缺口
+
+记录什么触发、为什么扩大、哪些控制有效、哪里缺少观测或恢复能力。行动项要有负责人、验收条件和优先级,不只写“加强监控”。
+
+### 动手实现
+
+#### 1. 准备隔离演练清单
+
+每个案例用全新测试数据,先保存无故障基线。下面矩阵是待执行计划,不代表已经发生事故或完成测试。禁止把生产凭据、真实笔记正文放入公开材料。
+
+文件:drill-plan.md
+
+````text
+| 案例 | 限定故障 | 主要证据 | 恢复动作 |
+|---|---|---|---|
+| 请求阻塞 | 本地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
+
+````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
+
+````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
+
+````text
+# Runbook:导出已受理但长期未完成
+## 识别
+记录job_id、受理时间、当前数据库状态。
+## 只读检查
+outbox未发布?队列有人消费?租约有效?attempts/deadline是否耗尽?
+## 分支
+未发布→查publisher;无人消费→查路由;租约过期→按恢复规则处理。
+## 动作
+只处理已确认的本地实例/任务,保持次数、退避与容量约束。
+## 回滚
+恢复本次修改前配置,停止新故障输入。
+## 验收
+核对新任务、旧任务与结果文件;未恢复项明确列出。
+````
+
+#### 5. 输出复盘行动项与证据索引
+
+复盘不只记录最后一个报错。为触发原因、扩大因素、恢复机制和观测缺口分别填写证据,并把修复项写成可验收工作。示例中的行动项需要实际演练支持后才采用。
+
+文件:postmortem-actions.md
+
+````text
+| 类型 | 发现 | 证据 | 行动项 | 验收 |
+|---|---|---|---|---|
+| 触发 | 待实际观测 | 时间线链接 | 待定 | 可重复复现 |
+| 扩大因素 | 待实际观测 | 指标/trace | 待定 | 相同故障影响受限 |
+| 恢复缺口 | 待实际观测 | 任务状态 | 待定 | 恢复时间可测 |
+| 观测缺口 | 待实际观测 | 缺失字段 | 待定 | 可定位具体阶段 |
+````
+
+### 验收结果
+
+- 每次故障均有范围、停止条件和实际恢复记录。
+- 恢复验证覆盖新请求、旧任务与输出一致性。
+- runbook有分支与验收,复盘行动项能被具体检查。
+
+### 生产约束与故障演练
+
+将notes-service交付维护,希望另一位开发者能按文档定位并恢复常见故障。
+
+#### 实现决策
+
+- 演练在独立环境,目标和恢复动作先明确。
+- 证据与预计行为分开,未运行不标通过。
+- 复盘行动项落实到观测、容量、一致性和恢复能力。
+
+#### 隔离环境演练
+
+逐项执行本课矩阵,每次恢复基线后才进入下一项,最终检查所有临时开关与事务已关闭。
+
+#### 恢复验收
+
+- 交接者可按runbook走到具体分支。
+- 已受理任务没有不明去向。
+- 所有完成声明有实际记录,未执行部分明确列出。
+
+### 常见错误
+
+#### 演练结束但临时故障开关还开着
+
+原因:没有记录配置前值和恢复清单。
+
+处理:每次演练最后核对开关、代理配置、进程和事务,跑无故障回归。
+
+#### 复盘只写某人误操作
+
+原因:忽略缺少权限边界、幂等、容量或观测导致的扩大。
+
+处理:把触发因素和系统控制缺口分别写清,并给可验收修复项。
+
+### 扩展练习
+
+把“加强监控”改成一个可验收行动项。
+
+提示:指定指标、范围、触发条件、关联信息和验证方式。
+
+参考答案:
+
+例如:为export_outbox最老未发布年龄增加告警,限定服务标签,持续超过本地设定阈值时提供publisher日志入口;用暂停本地publisher的演练验证触发与恢复,数值按实际业务基线设置。
+
+### 技术文档
+
+- [Google SRE复盘](https://sre.google/sre-book/postmortem-culture/)
+- [Google SRE事故管理](https://sre.google/sre-book/managing-incidents/)
+- [Google SRE有效排障](https://sre.google/sre-book/effective-troubleshooting/)

+ 155 - 0
scripts/build-course-content.mjs

@@ -0,0 +1,155 @@
+import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
+import { resolve } from 'node:path';
+const root = process.cwd();
+const catalog = [
+  ['api', 'API 服务开发', 16],
+  ['database', '数据库开发', 12],
+  ['api-database', 'API + 数据库', 12],
+  ['tasks', '异步任务开发', 16],
+  ['redis', 'Redis 缓存', 10],
+  ['docker', 'Docker 容器化', 10],
+  ['linux', 'Linux 部署', 10],
+  ['troubleshooting', '线上问题排查', 12],
+];
+const errors = [];
+let total = 0;
+const report = [];
+mkdirSync(resolve(root, 'public/downloads'), { recursive: true });
+const text = (value) => typeof value === 'string' && value.trim().length > 0;
+const list = (value, min = 1) => Array.isArray(value) && value.length >= min;
+function verify(l, slug, index) {
+  const prefix = `${slug}/${l?.id || index}`;
+  if (l?.id !== String(index + 1).padStart(2, '0'))
+    errors.push(`${prefix}: invalid lesson ID`);
+  for (const key of ['group', 'title', 'summary'])
+    if (!text(l?.[key])) errors.push(`${prefix}: missing ${key}`);
+  if ('minutes' in l)
+    errors.push(`${prefix}: instructor duration metadata is not allowed`);
+  for (const key of [
+    'objectives',
+    'concepts',
+    'steps',
+    'checks',
+    'pitfalls',
+    'refs',
+  ])
+    if (!list(l?.[key])) errors.push(`${prefix}: missing ${key}`);
+  for (const c of l.concepts || [])
+    if (!text(c.title) || !text(c.body))
+      errors.push(`${prefix}: incomplete concept`);
+  for (const s of l.steps || [])
+    if (!text(s.title) || !text(s.body))
+      errors.push(`${prefix}: incomplete step`);
+  for (const p of l.pitfalls || [])
+    if (!text(p.symptom) || !text(p.cause) || !text(p.fix))
+      errors.push(`${prefix}: incomplete troubleshooting`);
+  for (const key of ['task', 'hint', 'answer'])
+    if (!text(l.exercise?.[key]))
+      errors.push(`${prefix}: missing exercise ${key}`);
+  for (const ref of l.refs || []) {
+    try {
+      if (!text(ref.label) || new URL(ref.url).protocol !== 'https:')
+        throw new Error();
+    } catch {
+      errors.push(`${prefix}: invalid reference ${ref.url}`);
+    }
+  }
+  if (slug !== 'api') {
+    if (
+      !list(l.objectives, 3) ||
+      !list(l.steps, 4) ||
+      !list(l.concepts, 3) ||
+      !list(l.checks, 3) ||
+      !list(l.pitfalls, 2) ||
+      !list(l.refs, 2)
+    )
+      errors.push(`${prefix}: insufficient practical lesson detail`);
+    if (
+      !text(l.production?.scenario) ||
+      !list(l.production?.decisions) ||
+      !text(l.production?.failureDrill) ||
+      !list(l.production?.acceptance)
+    )
+      errors.push(`${prefix}: incomplete production exercise`);
+  }
+}
+const bullets = (items) => items.map((s) => `- ${s}`).join('\n');
+function markdown(title, lessons) {
+  return (
+    `# ${title}\n\n术核 AI · 后端工程课件\n\n` +
+    lessons
+      .map((l) => {
+        let out = `## ${l.id} ${l.title}\n\n${l.summary}\n\n### 本节目标\n\n${bullets(l.objectives)}\n\n### 必要概念\n\n`;
+        out += l.concepts
+          .map((c) => `#### ${c.title}\n\n${c.body}\n`)
+          .join('\n');
+        out +=
+          '\n### 动手实现\n\n' +
+          l.steps
+            .map(
+              (s, i) =>
+                `#### ${i + 1}. ${s.title}\n\n${s.body}\n${s.code ? `\n文件:${s.filename || '终端 / 当前课时'}\n\n\`\`\`\`${s.lang || 'text'}\n${s.code}\n\`\`\`\`\n` : ''}`,
+            )
+            .join('\n');
+        out += `\n### 验收结果\n\n${bullets(l.checks)}\n`;
+        if (l.production) {
+          const p = l.production;
+          out += `\n### 生产约束与故障演练\n\n${p.scenario}\n\n#### 实现决策\n\n${bullets(p.decisions)}\n\n#### 隔离环境演练\n\n${p.failureDrill}\n\n#### 恢复验收\n\n${bullets(p.acceptance)}\n`;
+        }
+        out +=
+          '\n### 常见错误\n\n' +
+          l.pitfalls
+            .map(
+              (p) =>
+                `#### ${p.symptom}\n\n原因:${p.cause}\n\n处理:${p.fix}\n`,
+            )
+            .join('\n');
+        out += `\n### 扩展练习\n\n${l.exercise.task}\n\n提示:${l.exercise.hint}\n\n参考答案:\n\n${l.exercise.answer}\n\n### 技术文档\n\n${l.refs.map((r) => `- [${r.label}](${r.url})`).join('\n')}\n`;
+        return out;
+      })
+      .join('\n---\n\n')
+  );
+}
+for (const [slug, title, count] of catalog) {
+  const path = resolve(
+    root,
+    slug === 'api' ? 'app/lessons.json' : `public/content/${slug}.json`,
+  );
+  let lessons;
+  try {
+    lessons = JSON.parse(readFileSync(path, 'utf8').replace(/^\uFEFF/, ''));
+  } catch (error) {
+    errors.push(`${slug}: ${error.message}`);
+    continue;
+  }
+  if (!Array.isArray(lessons) || lessons.length !== count) {
+    errors.push(`${slug}: expected ${count} lessons`);
+    continue;
+  }
+  lessons.forEach((l, i) => verify(l, slug, i));
+  total += lessons.length;
+  writeFileSync(
+    resolve(root, `public/downloads/${slug}-lessons.md`),
+    markdown(title, lessons),
+  );
+  report.push({
+    slug,
+    lessons: lessons.length,
+    steps: lessons.reduce((n, l) => n + l.steps.length, 0),
+    codeBlocks: lessons.reduce(
+      (n, l) => n + l.steps.filter((s) => s.code).length,
+      0,
+    ),
+  });
+}
+if (errors.length) {
+  console.error(errors.join('\n'));
+  process.exit(1);
+}
+console.log(
+  JSON.stringify(
+    { modules: report.length, lessons: total, details: report },
+    null,
+    2,
+  ),
+);

+ 28 - 0
scripts/check-python-snippets.py

@@ -0,0 +1,28 @@
+"""Check Python 3.11 syntax in lesson snippets without executing services."""
+
+import ast
+import json
+from pathlib import Path
+
+
+root = Path(__file__).resolve().parents[1]
+files = [root / "app/lessons.json", *sorted((root / "public/content").glob("*.json"))]
+checked = 0
+errors = []
+for path in files:
+    lessons = json.loads(path.read_text(encoding="utf-8-sig"))
+    for lesson in lessons:
+        for index, step in enumerate(lesson["steps"], 1):
+            if step.get("lang") not in {"python", "py"} or not step.get("code"):
+                continue
+            label = f"{path.stem}/{lesson['id']}/step-{index}"
+            try:
+                ast.parse(step["code"], filename=label, feature_version=(3, 11))
+                checked += 1
+            except SyntaxError as exc:
+                errors.append(f"{label}: line {exc.lineno}: {exc.msg}")
+
+if errors:
+    raise SystemExit("\n".join(errors))
+print(f"Python 3.11 AST: {checked} snippets passed across {len(files)} modules.")
+print("Syntax only; dependencies, SQL, external services and failure drills are not executed.")

+ 40 - 0
scripts/test-course-navigation.mjs

@@ -0,0 +1,40 @@
+import assert from 'node:assert/strict';
+import { courseHref, parseCourseQuery } from '../app/course-navigation.ts';
+assert.equal(courseHref('api', '01'), '?lesson=01');
+assert.deepEqual(parseCourseQuery('?lesson=01'), {
+  module: 'api',
+  lesson: '01',
+});
+const slugs = [
+  'api',
+  'database',
+  'api-database',
+  'tasks',
+  'redis',
+  'docker',
+  'linux',
+  'troubleshooting',
+];
+const urls = new Set();
+for (const slug of slugs) {
+  const url = courseHref(slug, '01');
+  urls.add(url);
+  assert.deepEqual(parseCourseQuery(url), { module: slug, lesson: '01' });
+  assert.deepEqual(parseCourseQuery(courseHref(slug)), {
+    module: slug,
+    lesson: undefined,
+  });
+}
+assert.equal(urls.size, 8, 'same lesson IDs must not collide across modules');
+assert.deepEqual(parseCourseQuery(''), { module: 'api', lesson: undefined });
+assert.deepEqual(parseCourseQuery('?module=database&lesson=99'), {
+  module: 'database',
+  lesson: '99',
+});
+assert.equal(
+  courseHref('tasks', '01&module=api'),
+  '?module=tasks&lesson=01%26module%3Dapi',
+);
+console.log(
+  'Course navigation: legacy links, 8 module round-trips, overview routes and query encoding passed.',
+);

Alguns arquivos não foram mostrados porque muitos arquivos mudaram nesse diff