[ { "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/_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/_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/_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、输入边界、并发与幂等有真实自动化断言。", "迁移与失败路径有执行证据,未运行的项目明确标记未验证。" ] } } ]