术核 AI · 后端工程课件
把 API 路由、数据库连接和环境配置分开,为后续事务与迁移建立可重复的练习环境。
本模块在新的 notes-service 目录逐课演进。数据库沿用数据库开发模块的 PostgreSQL 16、notes_lab 与 app_user。它与已有 api-lab 下载包相互独立;下面的端点需要按课件新建和整合,不能在旧下载包直接调用。没有做过数据库模块的同学先准备独立的本机 PostgreSQL 16 与空 notes_lab 数据库。
数据库地址随机器变化,表结构与业务规则随代码版本变化。配置放在环境变量或本机 .env,代码只保存字段声明和 .env.example。URL 中的密码若含 @、/、: 等字符要百分号编码;日志只报告配置项缺失,不输出完整连接串。
练习 app_user 可创建表以便完成迁移;上线通常由独立迁移账号拥有 DDL 权限,运行账号仅获得当前服务所需表的读写及 identity 序列权限。健康检查不证明账号能完成所有业务写操作,权限仍需在部署验收中覆盖。
在新的 notes-service 根目录执行。以下为 PowerShell 命令;Linux 用 python3 -m venv .venv 并激活对应环境。这里固定大版本范围,完成验收后生成锁定文件。
文件:终端 / 当前课时
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
新建 .env.example,再复制为 .env,填写你在独立练习库设置的密码。不要把此示例密码用在其他环境。DATABASE_URL 的 psycopg 方言由下一课 create_async_engine 选择异步驱动。
文件:.env.example
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
Settings() 会读取当前项目根目录下的 .env;启动命令始终在 notes-service 根目录运行。env 中同名变量优先于文件。约束使拼错的负数连接数在启动时失败。
文件:app/config.py
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()
新建 .gitignore,内容如下。在虚拟环境运行 python -c "from app.config import settings; print(settings.db_pool_size)",只应输出 5。临时把 DB_POOL_SIZE 改成 -1,导入应出现校验失败;恢复后继续。
文件:.gitignore
.env
.venv/
__pycache__/
.pytest_cache/
使用你安装的 psql 连接本机练习库;-W 交互输入密码。检查数据库和账号,禁止对共享数据库练习建表和失败注入。已有 notes 表在第 3 课核对结构。 沿用数据库模块的 Docker 本机端口映射 5433:5432;若安装原生 PostgreSQL,按实际监听端口同步修改 .env 与 psql 参数。
文件:终端 / 当前课时
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');
同一份代码在开发、测试和线上读取不同数据库配置。
只在本机把端口改为未监听端口,记录配置校验成功而连接失败的区别,恢复配置。
原因:未创建 .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 应触发校验失败。
建立一个进程级连接池,每个请求创建自己的 AsyncSession,并明确同步与异步驱动的使用位置。
本模块使用 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;要求原子的多步操作应在同一任务中顺序使用同一事务。
依赖上一课 app/config.py。create_async_engine 使用 postgresql+psycopg URL 自动选择异步连接。连接与 SQL 超时是本课程起始练习值,后续用预算与测量调整。
文件:app/db.py
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")]
依赖上一步 db.py。/live 仅证明进程能响应;/ready 尝试查询数据库。生产探针需结合平台超时设置,readiness 失败使流量停止进入该实例,不应用于不断重启所有实例。
文件:app/main.py
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"}
Linux 可执行 python -m uvicorn app.main:app --reload。Windows 的 psycopg 异步连接需要 SelectorEventLoop;新建下面的本机启动器后执行 python run_server.py。此入口只用于本机单进程练习,修改代码后手工重启。
文件:run_server.py
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")
访问 /ready 十次,再在 psql 查询下面 SQL。看到少量 idle 池连接是正常的;idle in transaction 才表示事务已开启但没有继续工作。请求数不应使连接数持续增长。
文件:终端 / 当前课时
SELECT application_name, state, count(*)
FROM pg_stat_activity
WHERE datname = 'notes_lab' AND usename = 'app_user'
GROUP BY application_name, state;
新建 scripts 目录及 init.py,再建立 scripts/probe.py,执行 python -m scripts.probe。不要把路由 db 传入 worker;每个 probe 仅持有自己的事务。Windows 在此脚本同样使用 Selector 策略。
文件:scripts/probe.py
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())
一个实例同时接收普通 API、慢客户端与后台任务。
在隔离环境连续调用 readiness 并中断客户端,查询 pg_stat_activity 确认没有遗留事务。
原因:异步 ORM 触发隐式 I/O,或 Windows 使用了 Proactor 事件循环。
处理:先确认错误堆栈:循环问题使用本课启动器;属性加载问题按第 5 课显式加载,不要混用同步驱动。
原因:同一个 Session 被多个协程共享。
处理:把 SessionFactory 传给任务或在每个任务内部创建 Session;同一原子事务保持顺序执行。
让两个并发任务分别返回当前数据库连接的 PID。
提示:SELECT pg_backend_pid() 代替 SELECT 1;不要对 PID 值做相等或不等的断言。
参考答案:
probe 中返回 await db.scalar(text("SELECT pg_backend_pid()"))。由于连接池会复用连接,PID 可能相同也可能不同;判断会话隔离应检查每个任务创建了自己的 Session,而非只看 PID。
把 notes 表映射为 ORM,对输入约束、数据库实体和 API 响应分别建模。
数据库表定义存储约束,ORM 定义 Python 对象与表的映射,Pydantic DTO 定义允许客户端提交和读取的字段。客户端不能提交 id 或 created_at。即使 Pydantic 校验通过,数据库约束仍是其他写入路径的最后防线。
add 只把对象加入会话;flush 执行 INSERT 并取得 id,但事务尚未提交。离开 db.begin() 的成功路径才 commit。先生成 DTO 可以发现类型或字段缺失问题,成功离开事务后再返回 HTTP 响应。
PATCH 只更新本次提供的字段。exclude_unset=True 保留显式传入的 null,因此必须决定 null 的业务含义。本服务的三个可修改字段均 NOT NULL,显式 null 返回 422;没有传入的字段保持原值。
依赖 app/db.py。数据库模块已有 public.notes 时先用 \d+ notes 核对:本课不改变已有表。没有表时执行下一步初始化 SQL。updated_at 由本模块所有写路径主动刷新,不声称 server_default 会在 UPDATE 自动执行。
文件:app/models.py
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())
在 notes_lab 的 psql 执行。已有表不执行 CREATE;表缺字段时回到数据库模块对齐,不使用 drop/recreate 破坏数据。第 7 课将这些 DDL 纳入版本迁移。
文件:终端 / 当前课时
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()
);
创建、响应和局部更新分开。内容限制为 100000 字符是练习的业务上限,代理请求体字节上限还需在部署模块设置;字符数不是字节数。
文件:app/schemas.py
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
新建 app/routes.py,然后在 app/main.py 的 app 创建后添加 from app.routes import router 与 app.include_router(router)。这些写路径每次请求使用一个明确事务;生产鉴权与归属隔离需要在项目接入身份系统后增加,本课程接口只绑定本机练习。
文件:app/routes.py
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)
启动服务,在 /docs 创建笔记,记录响应 id,随后读取、PATCH pinned=true 并删除。下面 Python 片段保存为 scripts/crud_probe.py 后运行 python -m scripts.crud_probe。
文件:scripts/crud_probe.py
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
API 从内存存储迁移到真实数据库,需要保持响应与持久化一致。
在本机记录创建 id,重启 API 再读取;另开 psql 核对同一行,排除误用内存列表。
原因:在服务器默认值尚未加载、Session 已关闭后才序列化 ORM。
处理:flush 后 refresh,并在事务内部 NoteOut.model_validate;不依赖响应序列化期间访问数据库。
原因:直接 model_dump() 将默认值也写入对象。
处理:使用 exclude_unset=True,并对显式 null 规定并验证行为。
禁止创建请求自定义 id,并验证超长标题不会到达数据库。
提示:extra="forbid" 与 max_length 分别解决两种输入问题。
参考答案:
向 POST /notes 发送 {"id":1,"title":"x"} 应为 422;发送 121 字符标题也应为 422。直接 SQL 写入超长标题仍由 varchar(120) 拒绝,不能只依赖 HTTP 校验。
把一项业务操作的多次写入放进同一事务,复现 flush 成功但 commit 失败的情况。
get_session 负责提供和关闭资源,不知道一次业务操作包含哪些写入。路由或服务函数用 db.begin() 包住相关操作;被调用的辅助函数只执行 SQL 或 flush,不自行 commit。HTTP 请求不是数据库事务的天然边界,流式和外部网络调用尤其需要拆开。
顺序应是校验输入 → begin → SQL → 生成 DTO → commit → 返回响应。若把 commit 放在依赖 yield 后,提交时机可能晚于响应发送,客户端收到成功却没有落库。本模块只在显式事务块内提交,与依赖清理时机无关。
数据库错误会让当前事务不可继续使用。退出 db.begin() 的异常路径自动回滚,之后才映射业务错误或开始新事务;不要在已失败事务内部捕获 IntegrityError 后继续查询。提交连接中断还可能造成结果未知,重试需幂等协议,不能简单认定一定失败。
在本机 notes_lab 执行一次。DEFERRABLE 用来演示提交时检查;第 7 课会将该表纳入基线迁移。审计保存 note_id 数值而不设外键,使笔记删除后审计仍保留。
文件:终端 / 当前课时
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
);
新建 app/services.py。辅助函数不 commit;session 的事务由调用者控制。request_key 在这里只用于审计提交冲突实验,第 10 课才实现完整幂等语义。
文件:app/services.py
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)
追加到 app/routes.py;保留原 POST /notes。该端点路径为 POST /notes/audited,无 GET 路由冲突。异常匹配使用约束名称,未知 IntegrityError 继续抛出交给错误日志,不把所有数据库异常都说成输入错误。
文件:app/routes.py
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
用同一个 request-key 连续调用两次。第二次 SQL INSERT 本身可以执行,离开事务块时才触发唯一约束。运行前后分别查询 notes 和 note_audits 的条数,第二次不应增加任一表。
文件:scripts/commit_probe.py
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
在纸上或白板沿每个箭头指出对应代码行。出现异常时从 commit 进入 rollback,再到 HTTP 409;不应有从 flush 直接跳到 HTTP 201 的路径。
文件:终端 / 当前课时
输入校验 → begin → 写 notes → 写 audit → 构造 DTO → commit → HTTP 201
└─失败→ rollback → HTTP 错误
yield 依赖:创建 Session ──────────────────────────────────→ close
创建笔记必须同时写审计,客户端只能在两项都提交后看到成功。
使用可延迟唯一约束让第二次请求在 commit 失败,核对 HTTP 与两张表的最终状态。
原因:依赖清理或后台任务中执行关键业务 commit。
处理:把提交放回业务事务块,离开事务后才 return;后台任务不能承诺当前请求已持久化。
原因:仍在失败事务内部复用会话。
处理:在 async with db.begin() 外捕获;需要局部容错时明确设计 SAVEPOINT,而非吞异常。
验证服务函数中间抛异常会回滚笔记。
提示:只在隔离练习分支,在审计插入前临时 raise RuntimeError。
参考答案:
在 create_with_audit 的 flush 后加入 raise RuntimeError("drill"),调用一次端点应返回服务错误,psql 查询找不到本次标题。移除故障注入后再次请求成功;不要保留公开故障接口。
为笔记增加评论,比较逐条懒加载、selectinload 和有界查询的 SQL 次数。
先查 20 条笔记,再为每条笔记查询评论,会形成 1+20 次数据库往返。网络延迟叠加后,即使每条 SQL 都很快,接口仍慢。异步 Session 中直接读取尚未加载的关系还可能触发 MissingGreenlet。
selectinload 将关联查询批量合并,适合规模受控的一对多集合。joinedload 用 JOIN 合并结果,集合关联会放大结果行并通常需要 unique()。分页笔记数不等于限制评论总数,不能把 selectinload 当作无限集合的分页方案。
笔记列表只返回笔记摘要或聚合计数,评论列表单独分页。本课演示 eager loading 只使用每条两条评论的固定测试数据;真实产品默认使用 /notes/{id}/comments 有界接口,删除策略明确为数据库 CASCADE。
在本机练习库执行。外键建立归属;索引支持按笔记读取并按 id 翻页。生产变更在第 7 课以后均通过 Alembic 执行。
文件:终端 / 当前课时
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);
将下列 import 合并到 app/models.py。把 comments 属性放入已有 Note 类体,Comment 类放在文件末尾。lazy="raise" 让未显式加载成为确定错误,避免悄悄执行 N+1。
文件:app/models.py
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")
追加到 app/routes.py。每次最多 100 条,以严格递增 id 翻页;无评论与笔记不存在分别返回空数组与 404。输出为明确字典,未访问 Note.comments。
文件:app/routes.py
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
先在 /docs 创建 3 条专用笔记,用 psql 为每条插入 2 条评论。把下面函数追加到 scripts/probe.py,并在其 main 内调用 await compare();只对这组 id 运行,替换 [1,2,3] 为实际值。在本机临时把 Engine echo 设为 True 观察 SQL,完成后关闭,避免记录用户内容。
文件:scripts/probe.py
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])
访问 /notes/{id}/comments?limit=1,沿 next_after_id 翻到结尾。删除练习笔记后在 psql 查询相应 comments,应该为 0 条。将 compare 中 options 移除,访问 comments 应被 lazy="raise" 拒绝,这比隐藏 N+1 更易排错。
文件:终端 / 当前课时
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;
笔记列表最初只有少量评论,运行一段时间后单条笔记积累大量评论。
只在练习库为单条笔记插入较多评论,比较 eager loading 与分页接口的返回规模;限制数据量避免占满磁盘。
原因:主表分页没有限制关联集合规模。
处理:默认分离评论分页接口,只在已知小集合使用 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。
从 PostgreSQL 连接上限反推每个进程的池容量,区分借连接、建连接、锁等待与 SQL 执行超时。
每个 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 能识别借出前的失效连接,不能修复事务执行中断,也不会安全重放写操作。
先用 psql SHOW max_connections,并列出其他客户端。以下为练习计算,不是通用推荐值;2 个实例、每个 3 worker、每 worker 5 连接占 30,滚动发布若新旧全部重叠可能占 60。
文件:终端 / 当前课时
数据库上限 100
保留给管理与故障处理 10
其他服务与后台任务 20
API 稳态 2 × 3 × (5 + 0) = 30
发布峰值按 4 × 3 × 5 = 60 估算
峰值总需求 60 + 20 + 10 = 90 < 100
第 2 课 Engine 已设置连接和 SQL 超时。运行下列 psql 只看到当前 psql 会话配置;要验证应用连接,给 scripts/probe.py 的 probe 函数临时替换为下一段,运行后恢复。
文件:scripts/probe.py
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)
添加到 app/main.py。只映射 SQLAlchemy 的池等待 TimeoutError;SQL 执行取消与数据库断连需要分别记录和处理,不能都笼统重试。请求日志应包含 request_id、延迟、异常类别,排除参数与连接串。
文件:app/main.py
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"})
新建 scripts/pool_drill.py。它创建单独练习 Engine,不改服务的配置。SELECT pg_sleep 仅在本机 notes_lab 使用;两任务各自用连接,一个等不到连接触发池超时,finally 确保释放。Windows 使用本课代码内 Selector 策略。
文件:scripts/pool_drill.py
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())
应用 probe 事务中分别执行 pg_sleep(4),或在 psql 事务锁定一条专用笔记再由 API PATCH。记录哪个超时先发生。锁练习结束在 psql 执行 ROLLBACK;不要对业务库执行这些命令。
文件:终端 / 当前课时
BEGIN;
SELECT id FROM notes WHERE id = 1 FOR UPDATE;
-- 将 1 换成本机专用练习笔记 id;此时在另一个终端 PATCH 同一笔记。
ROLLBACK;
服务扩容和滚动发布时,短时间内进程数显著增加。
独立池限制为 1,两个任务争抢连接;确认一次失败不会泄露唯一连接。
原因:每个 worker 都创建自己的池,遗漏乘数。
处理:重新预算所有实例与发布重叠;根据 CPU 与等待特征调 worker,按数据库配额分配池。
原因:pool_timeout 只限制借连接等待,不限制已借到连接上的 SQL。
处理:分别设置 statement_timeout 与 lock_timeout,并检查事务持有连接时是否等待外部 API。
如果实例翻倍但数据库配额不变,如何保持最坏连接数不变?
提示:把进程总数和每池连接上限一起计算。
参考答案:
在 worker 不变时将每 worker 的 pool_size + max_overflow 约减半,同时验证排队时间与吞吐。连接配额只是上限,不能保证响应时间;需要限流和负载测量,而非只修改参数。
把手工练习表纳入版本管理,以新增可空字段、分批回填、收紧约束完成兼容发布。
ORM 模型描述当前目标结构,迁移文件描述从旧版如何到新版。autogenerate 是候选差异,不能理解改名或业务回填语义。生产服务启动不执行 create_all;数据库迁移由单独发布步骤执行一次并记录版本。
数据库开发模块的 notes_lab 可能已包含 version、检查约束与额外索引,不能直接套用本课从零生成的迁移历史。本课固定使用新建 notes_lab_migration 数据库做迁移实验,原 notes_lab 保留不动。stamp 只登记版本,既不建表也不验证结构;本练习不对已有库执行 stamp 或重复 ADD version。接管真实已有库需要单独的结构盘点与基线计划。
Expand 先增加兼容字段或表;新旧应用共存期间双写或保留旧字段;后台按主键分批回填并核验;最后 Contract 收紧约束或删除旧字段。每步都需锁等待预算和回退窗口,数据删除不保证可通过 downgrade 恢复。
在 notes-service 根目录执行。生成 migrations/env.py 后按下一步修改,migration URL 从配置读取,不写密码进 alembic.ini。
文件:终端 / 当前课时
python -m alembic init -t async migrations
在生成的 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
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())
先把下列 note_audits Core Table 追加到 app/models.py,与现有 Note/Comment 一起进入 Base.metadata。此时 API 模型按第 3–5 课尚未声明 version,原数据库即使有该列也不影响读写。下一步只对新的空迁移实验库生成基线,不从已有 notes_lab 自动生成差异。
文件:app/models.py
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"),
)
在本机 PostgreSQL 管理账号可用的前提下执行前两条命令,-W 交互输入管理密码;若使用数据库模块的Docker容器,可在其postgres容器内使用管理psql执行同一CREATE DATABASE。已有同名库时先停止并核对来源,不删除重建。将 notes-service/.env 的 DATABASE_URL 数据库名从 notes_lab 改为 notes_lab_migration,保留原值在未提交的本机备份中;同时清除终端中覆盖 .env 的 DATABASE_URL。第 7–11 课继续使用新库,避免把教学迁移应用到已有表。
文件:终端 / 当前课时
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
仅在刚创建的 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/_expand_note_version.py
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")
先 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;验证与锁预算在预发布副本测量。
文件:终端 / 当前课时
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)
迁移实验库从零开始,重启API使新.env生效,用/docs创建几条新笔记和评论再完成后续课。第7–11课操作目标始终为notes_lab_migration;不把旧库记录默默复制进新库。完成本模块后可恢复.env原notes_lab连接,但若要在那里使用新功能,需先为该库制定独立迁移接管计划,不能直接upgrade本课历史。第12课使用另一专用notes_lab_test。
文件:终端 / 当前课时
# 在当前连接的psql中确认目标及新字段
SELECT current_database();
SELECT column_name FROM information_schema.columns
WHERE table_schema='public' AND table_name='notes' ORDER BY ordinal_position;
服务不能停机,新旧版本会在滚动发布时共同写同一张表。
在隔离验证库制造长事务阻挡 ALTER,确认 lock_timeout 使迁移快速失败,结束阻塞后可重跑。
原因:工具只看结构差异,不理解业务意图。
处理:手工改为兼容新增、迁移数据与后续删除,审核生成 SQL;有数据列的 DROP 不直接上线。
原因:把 migration 放入每个 worker 的启动钩子。
处理:迁移移到唯一发布作业,设置锁等待上限,成功后启动或推进应用发布。
旧应用没有 version 字段时,Expand 阶段的新插入会怎样?
提示:区分 Python default 与数据库 server_default。
参考答案:
本课 version 的 server_default=1 在数据库生效,旧应用省略该列仍得到 1。单写 Python default 无法覆盖旧应用或 SQL 客户端。启用版本校验前仍需完成所有写路径升级,否则旧应用不会递增版本。
使用稳定复合游标读取笔记列表,把已知约束转成明确的 API 错误。 本课继续连接第7课的 notes_lab_migration,原 notes_lab 不执行这些教学迁移。
只用 created_at 排序可能出现时间相同的多条记录,必须加唯一 id 作为第二排序键。降序下一页使用 (created_at,id) < 上页末条键。游标能减少深 OFFSET 扫描,但不是一致性快照;并发删除会导致后续页变短,新插入通常落在已经翻过的前面。
本课将时间和 id 编码为 URL 安全 Base64 JSON,并在入口限制长度与字段类型。编码不是加密或授权,客户端可伪造游标;若绑定用户、过滤器或敏感字段,需要签名与权限过滤。固定排序键 created_at 不在 API 中允许修改。
请求格式错误由 Pydantic 返回 422。业务唯一冲突可为 409,明确的检查约束可以映射 422;连接失效不是客户端输入问题。先用约束名称或 SQLSTATE 识别,再决定映射,不把数据库异常原文直接返回。
生成一个 Alembic revision 并填入函数,使用已有 op 与 sa 导入。课程练习数据量小可普通建索引;大表生产应评估 CREATE INDEX CONCURRENTLY,注意它不能在普通事务块内执行,需独立迁移与失败索引清理预案。 同步在 Note 类的 table_args 加入 Index("ix_notes_created_id", "created_at", "id"),若已有元组则追加,不能覆盖其他约束。
文件:migrations/versions/_notes_cursor_index.py
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")
新建 app/cursors.py。aware 时间保证与 timestamptz 比较一致;对 Base64、JSON 和 Pydantic 校验错误统一返回 422。游标只用于定位,不用于证明访问权限。
文件:app/cursors.py
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
追加到 app/routes.py;GET /notes 与已有 GET /notes/{note_id} 不冲突。每页多读一条决定是否有下一页;在事务内转换 DTO,列表响应不会访问未加载关系。生产接入用户后,权限过滤必须加入同一 SQL 且每页一致。
文件:app/routes.py
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
先检查存量数据,再生成迁移。下面 upgrade 中 NOT VALID 允许先建立约束而暂缓验证存量,新写入立即受限;清理练习存量空白标题后再执行 VALIDATE。同步把 CheckConstraint 加入 Note.table_args,与索引一同保留,防止后续 autogenerate 误报差异。
文件:migrations/versions/_notes_title_check.py
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")
添加到 app/main.py。现有 audited 端点会先处理自身唯一约束,其他未识别 IntegrityError 返回 500 并用不含 SQL 参数的日志记录类别与约束;真实项目接入 request_id。不要记录 str(exc),其中可能有用户输入。
文件:app/main.py
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"})
笔记增长后深分页拖慢数据库,同时出现绕过 API 的导入写入。
在本机建立时间相同的测试记录并逐页收集 id;再发送损坏游标验证不会执行数据库查询。
原因:仅以时间作为游标,缺少唯一排序键。
处理:查询排序、比较条件和游标编码同时包含 created_at 与 id。
原因:把非空、外键、检查约束和程序错误混为一类。
处理:只匹配确认过的命名约束,未知异常保持服务端错误并记录可定位信息。
分页时能否保证第一屏到最后一屏来自同一个数据库快照?
提示:每个 HTTP 请求有自己的事务。
参考答案:
本课不能保证跨请求快照。若产品需要稳定导出,可创建导出作业并在合适的一致性边界生成文件,或定义快照时间与数据版本规则;不要保持一个数据库事务等待用户慢慢翻页。
复现两个客户端覆盖彼此修改,用原子条件 UPDATE 返回 409,并识别行锁适用的短事务。
两个客户端都读到 version=1,第一个写入后版本变为 2,第二个仍提交 1 应被拒绝。检查与写入必须在同一条 SQL 的 WHERE 中完成;先 SELECT 检查再无条件 UPDATE 仍有竞争窗口。
仅新增一个安全 PATCH 不够:旧 PATCH、批量脚本和管理工具若不递增版本,仍能覆盖别人修改。第 7 课扩展字段只解决结构兼容,启用强制版本前必须完成旧写路径切换并下线不安全写入口。
SELECT FOR UPDATE 可以串行化同一行的短业务判断,例如检查状态后做多表更新。锁会持续到事务结束,不能在持锁时等待模型 API、发送邮件或用户输入。乐观控制减少等待但要求客户端处理冲突;二者可以按业务组合。
前提是第 7 课回填和 NOT NULL 迁移已成功。Note 类 version 改成 Mapped[int] = mapped_column(nullable=False, server_default=text("1"));NoteOut 新增 version: int。再在 schemas.py 增加下面的更新请求,保留 NotePatch 的字段校验。
文件:app/schemas.py
class NoteVersionPatch(NotePatch):
version: int = Field(gt=0)
删除或替换 app/routes.py 原 patch_note,避免同方法同路径重复注册。原子 UPDATE 以 id 与 version 为条件;无匹配统一 409,涵盖已删除和版本冲突,客户端重新 GET 区分是否已删除。所有 SQL 更新同步刷新 updated_at 与 version。
文件:app/routes.py
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
替换原 delete_note。这里用必填 version 查询参数便于 /docs 练习;生产协议也可统一为 If-Match + ETag。所有客户端脚本更新为提交当前 version,不能给 version 设置默认 1 绕过检查。
文件:app/routes.py
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)
在 scripts/version_probe.py 保存下面片段,运行后应得到 200 与 409。第一次写入内容应保留,不能被旧快照覆盖;创建路径可沿用 POST /notes,新字段由数据库默认值赋 1。
文件:scripts/version_probe.py
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
下面为可替换服务函数片段,加入 app/services.py;由调用者在 db.begin() 内调用。此函数只示范锁内检查与写入,不能接收旧客户端内容进行无条件覆盖。锁等待由 lock_timeout 限制。
文件:app/services.py
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)
两个管理页面同时编辑同一笔记,最后保存的人不应无提示覆盖前一次修改。
使用两个独立 HTTP 客户端同时提交同一 version,核验一个成功、一个冲突和数据库最终内容。
原因:ORM 版本检测主要作用于 flush 的单对象更新,批量 SQL 要自行加条件。
处理:本课统一显式 WHERE version 与递增;若改用 mapper versioning,仍审计所有批量路径。
原因:持锁期间执行外部请求,或多行操作锁顺序不一致。
处理:缩短事务,所有路径按相同 id 顺序加锁,外部调用通过 outbox 在提交后处理。
收到 409 后客户端可以自动读取新版本并原样重放旧编辑吗?
提示:这会绕过保护覆盖别人刚写入的内容。
参考答案:
不能无条件重放。客户端重新读取并让用户合并,或只对可证明可交换的增量操作自动重试;重试必须保留业务意图,不能只换 version。
让创建请求在网络重试后返回同一次结果,使用唯一约束处理并发抢占,并拒绝同键不同内容。
客户端超时不代表数据库回滚,服务可能已经提交,只是响应丢失。为一次逻辑创建生成稳定 Idempotency-Key 并在重试时保持不变,服务保存该键对应的状态码与响应体,就能返回第一次结果。
先查键不存在再插入存在竞争窗口。INSERT ON CONFLICT DO NOTHING 配合唯一约束决定谁获得写入权;获胜者的业务行与幂等结果在同一事务提交。后到请求等前一事务完成,随后在 READ COMMITTED 的下一条 SELECT 中读取已提交结果。
键至少绑定身份主体、HTTP 操作与请求摘要。本课尚无身份系统,只允许本机单主体练习,scope 固定表示该边界;上线必须从认证结果生成主体作用域。记录删除后相同键可能再次创建,必须公布保留期限并与客户端最大重试窗口一致。
生成 Alembic revision,把下面 SQL 放入 op.execute;downgrade 使用 op.drop_table。它是普通非延迟主键,可供 ON CONFLICT 使用。业务成功后存 status_code 与响应;失败事务不保留占位记录。后续把此 Table 加入 Base.metadata,避免 autogenerate 误判为多余表。
文件:终端 / 当前课时
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)
);
追加到 app/models.py,复用已有 Table、Column、Base、String、DateTime、func。JSONB 和 Integer 使用下方导入。SQLAlchemy Core 与 ORM 可以共用一个事务。
文件:app/models.py
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()),
)
追加到 app/services.py。它要求外层尚未完成业务写入但已经 begin;不自行提交。on_conflict_do_nothing 的返回值标识是否占位成功;同键不同请求体返回 409。创建、占位和结果保存三步都是同一事务。
文件:app/services.py
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
追加到 app/routes.py。本机单主体 scope 必须在接入鉴权后替换为服务器可信身份加操作名,不能接受客户端随意传 scope。外层事务提交后才返回保存的响应。
文件:app/routes.py
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)
新建 scripts/idempotency_probe.py。使用随机新键避免前次练习污染;两个请求并发提交相同内容,返回同一个 id。再用相同键改内容应返回 409。大量并发测试必须控制在本机练习环境。 此时服务仍使用 notes_lab_migration,所有最终数据核对都连接同一实验库。
文件:scripts/idempotency_probe.py
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())
移动客户端在创建接口超时后自动重试,不能重复创建笔记。
隔离环境并发发送相同键,丢弃首个响应后再重试,最后核对记录与响应一致。
原因:对原始 JSON 字符串直接计算摘要。
处理:先 Pydantic 规范化,再固定字段顺序和编码;版本化摘要规则,升级时维持旧键语义。
原因:保留期限短于客户端重试窗口,或没有对重复业务本身加约束。
处理:公布键保留期并记录过期行为;订单号等业务唯一性还需独立唯一约束。
如何模拟数据库已提交但客户端没收到响应?
提示:在本机先成功调用后故意丢弃响应,再以原键重发即可验证服务器语义。
参考答案:
保存 key,第一次请求完成后不使用响应,第二次用相同 key/body 发起请求。核对只新增一行且得到相同 id;若做真正断连注入,要同时覆盖 commit 前后两个时点,不能从客户端断开推断数据库一定回滚。
将笔记变更和待发事件原子落库,再由独立发布器领取事件并在提交后发送。
先提交笔记再发消息,发送失败会漏事件;先发消息再提交,事务失败会留下不存在的业务事件。Outbox 把业务记录与待发事件写在同一数据库事务内,发布器稍后重试外部发送,接受最终一致性的延迟。
发布器领取一批事件,写 lease token 与 locked_until 后立即提交,再进行网络调用。崩溃后租约到期可重新领取;发送成功但标记失败会导致重复投递。系统保证的是可重试的至少一次交付流程,接收方必须去重;不是端到端恰好一次。
SKIP LOCKED 适合队列式争抢,不能用于普通业务一致列表。按 id 领取只提供尽量有序,不保证同一笔记多个事件的严格顺序。若业务需要顺序,事件携带 aggregate_id/version,接收方按版本处理并暂存缺口。
生成 Alembic revision 并通过 op.execute 执行以下 SQL。outbox 的 payload 最小化,不复制密钥或完整敏感提示词。表定义需同步加入 Base.metadata;以下后续步骤会提供对应 Core Table 映射,避免 autogenerate 误判删除。
文件:终端 / 当前课时
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()
);
追加到 app/models.py,复用已有 Table、Column、Index、Base、BigInteger、Identity、Integer、String、DateTime、func、text 与 JSONB。只声明映射,不在应用启动时建表;迁移创建实际表。
文件:app/models.py
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()),
)
追加到 app/services.py。把 await add_created_event(db, note) 放入第 10 课 create_idempotent 的新建成功分支:在 flush/refresh 后、写入幂等结果前。重放分支不再生成事件。函数只写本地表,不做 HTTP。
文件:app/services.py
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})})
新建 app/outbox.py。批量上限 20、租约 30 秒是练习参数;本课发布器每次只领 1 条以保证 3 秒请求预算小于租约。UPDATE 确认同时匹配 token,旧发布器无法覆盖新持有者。超过重试预算的事件停止自动领取并进入人工排查清单。
文件:app/outbox.py
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})
追加到 app/main.py,仅用于本机教学模拟第二个服务。真实接收服务拥有独立数据库;去重插入与自身业务副作用必须同一事务。示例的副作用仅是记录接收,不伪称完成搜索索引等外部功能。
文件:app/main.py
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}
新建 scripts/publish_once.py,运行 python -m scripts.publish_once。它每次处理一条,HTTP 在 claim 事务提交之后执行;没有在持锁时等待网络。成功接收但 finish 前崩溃会被再次投递,这是下一步演练目标。
文件:scripts/publish_once.py
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())
仅在本机将 publish_once 中 await finish 临时注释一次,创建一条新幂等笔记后发布,确认 received_events 已有记录但 outbox 未 delivered。等待租约到期后恢复 finish 再发;接收方 duplicate=true,received_events 仍只有一行。用下面查询监控积压,不将 attempts>=8 的事件静默删除。
文件:终端 / 当前课时
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;
创建笔记后要通知索引服务,但索引服务短暂不可用。
模拟成功接收后未确认,再过期重领,核对接收记录只有一条且 outbox 最终 delivered。
原因:外部成功与本地 delivered 标记不能在一个普通数据库事务中原子提交。
处理:接受重复并按 event_id 去重,去重记录和接收方业务写入共用事务。
原因:顺序发送总耗时超过租约,其他发布器重新领取。
处理:缩小批次、限制单次请求时间;扩大并行时计算整个批次最坏耗时,必要时做可验证的续租。
如果事件用于邮件发送,接收数据库去重是否保证邮件只发送一次?
提示:数据库事务不能原子覆盖外部邮件供应商。
参考答案:
不能。邮件供应商若支持幂等键,应传 event_id;否则仍存在发送成功但记录失败的窗口。要按业务允许的重复程度设计去重、状态查询或人工补偿,不能称为恰好一次。
用专用 PostgreSQL 测试库验证提交、并发、迁移和故障恢复,避免 SQLite 掩盖生产差异。
SQLite 的类型、JSON、锁、约束和并发行为与 PostgreSQL 不同。本模块针对 PostgreSQL 16 测试,测试库独立为 notes_lab_test,使用专用凭据或限定权限。测试代码启动时检查数据库名,不从默认生产 URL 继承目标。
单请求 CRUD 可以把 Session 绑定外层连接事务,通过 savepoint 让内部 commit 后最终回滚清理。并发与提交失败测试必须使用多个真实连接和真正 commit;外层回滚会隐藏可延迟约束的提交时机,也无法验证其他连接可见性。
不只断言错误码,还要查数据库最终状态、连接是否释放、下一次正常请求是否恢复。连接中断可能造成提交结果未知,验收需用幂等键重查;不要凭一次网络错误断言写入不存在。
先用数据库管理员在本机创建 notes_lab_test 并授予练习 app_user 对该库迁移权限。仅当前测试终端设置以下变量;把 CHANGE_ME 替换为本机测试密码。运行迁移前通过 psql 核对数据库名称。不要对共享库执行测试清理。
文件:终端 / 当前课时
$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
新建 pytest.ini 内容 [pytest] 下一行 asyncio_mode = auto。再新建 tests/conftest.py。在导入 app 前覆盖 DATABASE_URL;fixture 检查测试库名并使用 NullPool,避免不同事件循环复用连接。外层事务提供回滚隔离,但不能用此 fixture 验证并发或 commit 时延迟约束。
文件:tests/conftest.py
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()
新建 tests/test_notes.py。版本字段与第 9 课保持一致,先验证成功更新再验证旧版本冲突。ASGITransport 不自动运行 lifespan;本 fixture 不依赖启动建表,迁移已在前一步完成。需要启动资源的应用应显式使用 lifespan 管理器。
文件:tests/test_notes.py
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
追加到 tests/conftest.py。该 fixture 使用不同请求各自的真实连接,不使用外层回滚;只能在专用测试库运行。每个测试用随机键隔离,并在测试 finally 清理自己产生的记录。不要并发运行会修改 app.dependency_overrides 的测试。
文件:tests/conftest.py
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()
新建 tests/test_concurrency.py。只按本测试随机 key 查到的 note_id 清理关联 outbox 和业务记录;不 TRUNCATE 全库。若测试进程被强制结束残留数据,应通过专用测试库重建或运行有审计的测试清理作业。
文件:tests/test_concurrency.py
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})
执行 python -m pytest -q。再在独立本机服务对第 4、6、11 课演练做真实提交验收,并记录错误码、数据库最终状态和恢复探针。以下矩阵是需要实际执行的验收任务,不代表课件已运行并通过;不要在未执行时把“预期”写成“结果”。
文件:终端 / 当前课时
场景 | 注入方式 | 预期状态 | 恢复验证
提交约束失败 | 重复 audited request-key | 409;notes/audits 同时回滚 | 新键创建成功
池耗尽 | 独立 pool_drill,池上限 1 | 一任务超时;无连接泄漏 | SELECT 1 成功
陈旧编辑 | 两客户端相同 version | 一个成功、一个 409 | GET 与成功者一致
同键重试 | 两真实连接同时创建 | 相同响应;一条笔记一条事件 | 再重试同结果
发布器崩溃 | 接收后跳过 finish | 事件到期重领;接收方去重 | 最终 delivered
迁移锁阻塞 | 专用库长事务占锁 | 超时失败;revision 未推进 | 释放锁后 upgrade 成功
数据库连接、迁移和并发逻辑要进入持续集成,而不依赖开发者手工点 /docs。
完整运行本课矩阵,仅使用本机专用测试库,保存预期、实际、证据和恢复结果四列记录。
原因:测试提交的是 SAVEPOINT,不是最外层数据库 COMMIT。
处理:延迟约束、并发可见性与网络提交异常使用 committed_client 或独立进程专用测试库。
原因:测试业务事务仍被外层回滚事务包裹,其他连接看不到未提交数据。
处理:跨连接集成测试使用真正提交,并按测试生成的唯一业务键清理。
如何防止测试运行时意外连接到线上?
提示:不要只依赖“我记得设置了环境变量”。
参考答案:
要求 TEST_DATABASE_URL 必填并校验数据库名,使用专用网络与最小权限测试凭据,CI 不注入生产凭据;日志输出脱敏 host/database 供核对。数据库名检查只是第一道防线,不能替代网络与权限隔离。