api-database.json 175 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660
  1. [
  2. {
  3. "id": "01",
  4. "group": "连接与事务",
  5. "title": "建立 shop-service 的配置与目录",
  6. "summary": "之前的商品接口能收发请求、数据库也能存商品。现在把两者接起来,先确认项目从哪里启动、连接哪一个本机库。",
  7. "objectives": [
  8. "在已有 shop-service 中建立 app 目录并复用正确的虚拟环境",
  9. "验证正常配置输出 5,负连接数在导入配置时失败",
  10. "用 MySQL 客户端核对实例、shop_lab、app_user 与字符集"
  11. ],
  12. "concepts": [
  13. {
  14. "title": "项目边界",
  15. "body": "完成第 1 步“新建项目并安装依赖”后,再回顾这条规则:本模块在独立 shop-service 目录逐课实现。数据库统一 MySQL 8.4 LTS、InnoDB、utf8mb4,基础库 shop_lab、账号 app_user。已有数据库模块的 products 表可以沿用;第7课为迁移实验新建 shop_lab_migration,避免与已有 version 和约束冲突。课件代码需要按步骤新建、替换和整合,旧 api-lab 下载包不自动包含这些数据库端点。"
  16. },
  17. {
  18. "title": "配置与代码",
  19. "body": "完成第 2 步“新建环境模板”后,再回顾这条规则:环境配置决定连接到哪台MySQL,源码决定表结构和业务规则。.env只保留在本机,仓库保存.env.example。URL中的密码特殊字符要百分号编码,日志不输出完整连接串。容器内地址使用mysql:3306,宿主练习使用127.0.0.1:3307。"
  20. },
  21. {
  22. "title": "账号和时间约定",
  23. "body": "完成第 5 步“核对MySQL实例和账号”后,再回顾这条规则:API使用普通app_user,不使用root。教学迁移允许它修改专用实验库结构,生产迁移与运行账号分开。DATETIME(6)本身不保存时区,本课程所有连接约定UTC、数据库保存UTC无时区值,API输出明确+00:00;不能把本地时间直接写入后再标成UTC。"
  24. }
  25. ],
  26. "steps": [
  27. {
  28. "title": "新建项目并安装依赖",
  29. "body": "在数据库模块已经使用的 Linux/WSL2 Bash 中进入现有 shop-service,先执行 pwd 核对路径。保留 mysql_client.py、sql 与已有 .venv,不再创建同名嵌套项目。下面仅在 .venv 不存在时创建,然后激活并安装 API 增量依赖;已有环境必须是当前 Linux/WSL2 创建的环境,不能复用 Windows 的 Scripts/python.exe 目录。",
  30. "code": "# 使用数据库模块同一个 Linux/WSL2 Bash 和 shop-service 目录。\npwd\nif [ ! -d .venv ]; then\n python3 -m venv .venv\nfi\nsource .venv/bin/activate\npython -c \"import sys; print(sys.executable)\"\npython -m pip install \"fastapi>=0.121,<1\" \"uvicorn[standard]>=0.30,<1\" \"pydantic-settings>=2,<3\" \"SQLAlchemy>=2.0,<2.1\" \"asyncmy>=0.2,<0.3\" \"alembic>=1.13,<2\" \"httpx>=0.27,<1\" \"pytest>=8,<10\" \"pytest-asyncio>=1,<2\"\nmkdir -p app scripts\ntouch app/__init__.py scripts/__init__.py\npython -m pip freeze > requirements-lock.txt",
  31. "lang": "bash",
  32. "expected": "pwd 显示已有 shop-service,解释器路径为该目录的 .venv/bin/python;app/__init__.py、scripts/__init__.py、requirements-lock.txt 存在,依赖安装成功。",
  33. "explanation": "同一个项目逐课加文件,避免进入同名嵌套目录后找不到配置。asyncmy 供后续异步 API 使用,已有同步数据库脚本仍用自己的 PyMySQL 路径。 本模块新增app与scripts。"
  34. },
  35. {
  36. "title": "新建环境模板",
  37. "body": "复制为.env并填写本机专用练习密码。mysql+asyncmy是SQLAlchemy异步方言,charset保证连接使用utf8mb4。不要把本机3307用于同Compose网络的服务间连接。若数据库模块已存在.env,不覆盖整个文件:在原文件合并新增DATABASE_URL及DB_POOL_*项,保留同步客户端需要的已有配置。",
  38. "code": "DATABASE_URL=mysql+asyncmy://app_user:CHANGE_ME@127.0.0.1:3307/shop_lab?charset=utf8mb4\nDB_POOL_SIZE=5\nDB_MAX_OVERFLOW=0\nDB_POOL_TIMEOUT=2",
  39. "lang": "dotenv",
  40. "filename": ".env.example",
  41. "expected": ".env.example 有 DATABASE_URL 和三项 DB_POOL 配置,本机 .env 合并后使用真实练习凭据,保留原同步客户端所需变量。",
  42. "explanation": "连接串把使用的驱动、账号、主机端口和库名放在一起;环境文件让同一份代码能连接不同实验环境。密码特殊字符要编码,不能把整个连接串打印出来排错。"
  43. },
  44. {
  45. "title": "新建配置模块",
  46. "body": "将下面代码保存为 app/config.py。Settings() 会读取当前项目根目录下的 .env;启动命令始终在 shop-service 根目录运行。env 中同名变量优先于文件。约束使拼错的负数连接数在启动时失败。",
  47. "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()",
  48. "lang": "python",
  49. "filename": "app/config.py",
  50. "expected": "本步先保存定义,不会自动产生运行输出;调用后的观察在后续验证步骤完成。app/config.py 已定义 Settings 与 settings;正确配置能导入,缺 DATABASE_URL 或负连接数会抛校验错误。",
  51. "explanation": "Settings 在导入时读取配置,Field 的 ge/gt 等限制把明显错误提前挡住。extra=ignore 允许本项目 .env 同时保留其他模块需要的变量。"
  52. },
  53. {
  54. "title": "隔离本机配置并验证导入",
  55. "body": "把以下条目追加到已有 .gitignore,保留数据库模块的 .mysql.env、.mysql-client.cnf 和 backups/ 等忽略规则;没有文件时才创建。在虚拟环境运行 python -c \"from app.config import settings; print(settings.db_pool_size)\",只应输出 5。临时把 DB_POOL_SIZE 改成 -1,导入应出现校验失败;恢复后继续。",
  56. "code": ".env\n.venv/\n__pycache__/\n.pytest_cache/",
  57. "lang": "text",
  58. "filename": ".gitignore",
  59. "expected": "正确配置的导入命令只打印 5;临时 -1 触发校验失败,恢复为 5 后再次成功,.gitignore 保留原有规则。",
  60. "explanation": "配置验证不需要连接数据库,因此能先区分“值写错了”和“网络连不上”。忽略本机环境文件避免把练习密码作为课件代码提交。"
  61. },
  62. {
  63. "title": "核对MySQL实例和账号",
  64. "body": "沿用数据库模块的 shop-mysql 容器。在另一个 Bash 执行 docker exec -it shop-mysql mysql --protocol=TCP -h 127.0.0.1 -P 3306 -u app_user -p shop_lab,按提示输入已创建的本机账号密码,不写进命令行。出现 mysql> 后再逐条执行下面 SQL。此客户端在容器内连接 3306,不需要安装宿主 mysql 命令;应用 .env 仍连接宿主 127.0.0.1:3307。若实例、库或账号尚未建立,先完成数据库模块第 01 课。",
  65. "code": "SELECT VERSION(), DATABASE(), CURRENT_USER(), @@session.transaction_isolation;\nSHOW VARIABLES LIKE 'character_set_connection';\nSHOW TABLE STATUS LIKE 'products';\nSHOW GRANTS;",
  66. "lang": "sql",
  67. "expected": "查询显示 MySQL 8.4、当前库 shop_lab、实际 app_user 账号,连接字符集 utf8mb4;已有 products 表为 InnoDB。",
  68. "explanation": "CURRENT_USER 说明数据库实际按哪个账号与来源授权。InnoDB 支持后续整笔提交/回滚,字符集决定文字如何传输;UTC 会话约定让时间存储与 API 输出保持同一含义。"
  69. }
  70. ],
  71. "checks": [
  72. "缺少DATABASE_URL时配置校验失败,日志未打印密码。",
  73. "实际实例为MySQL8.4,当前库shop_lab;账号权限仅覆盖约定练习范围。",
  74. "依赖明确为SQLAlchemy2+asyncmy,连接字符集utf8mb4;已存在products表使用InnoDB。"
  75. ],
  76. "pitfalls": [
  77. {
  78. "symptom": "连接地址正确却认证失败",
  79. "cause": "账号匹配包含用户名和来源host,或URL密码特殊字符未编码。",
  80. "fix": "先用mysql客户端交互认证并确认CURRENT_USER,再修正账号host范围或URL编码;不改用root长期运行API。"
  81. },
  82. {
  83. "symptom": "ModuleNotFoundError: asyncmy",
  84. "cause": "安装与启动使用不同解释器,或依赖锁没有异步驱动。",
  85. "fix": "检查sys.executable,在同一个venv中安装并启动。"
  86. }
  87. ],
  88. "exercise": {
  89. "task": "新增 app_env 配置,只接受 local、test、production。",
  90. "hint": "用 typing.Literal 声明允许值。",
  91. "answer": "在 config.py 导入 Literal,然后在 Settings 中加入 app_env: Literal[\"local\", \"test\", \"production\"] = \"local\"。在 .env 设置 APP_ENV=staging 应触发校验失败。"
  92. },
  93. "refs": [
  94. {
  95. "label": "SQLAlchemy:asyncmy方言",
  96. "url": "https://docs.sqlalchemy.org/en/20/dialects/mysql.html#asyncmy"
  97. },
  98. {
  99. "label": "MySQL8.4:账号名称",
  100. "url": "https://dev.mysql.com/doc/refman/8.4/en/account-names.html"
  101. },
  102. {
  103. "label": "Pydantic Settings",
  104. "url": "https://docs.pydantic.dev/latest/concepts/pydantic_settings/"
  105. }
  106. ],
  107. "production": {
  108. "scenario": "同一份API代码在开发、测试和线上读取不同MySQL配置。",
  109. "decisions": [
  110. "缺少配置立即失败,不回退到其他数据库。",
  111. "账号权限、字符集和UTC时间约定一起验收。"
  112. ],
  113. "failureDrill": "仅在本机把端口改为未监听值,区分配置校验与网络连接失败,恢复后再验收。",
  114. "acceptance": [
  115. "配置不含真实默认密码。",
  116. "能区分地址、认证和权限故障。"
  117. ]
  118. },
  119. "intro": {
  120. "scenario": "之前的商品接口能收发请求、数据库也能存商品。现在把两者接起来,先确认项目从哪里启动、连接哪一个本机库。",
  121. "startingPoint": [
  122. "已完成 FastAPI 基础课与数据库连接练习,能用 app_user 登录本机 shop_lab;在终端执行 pwd,进入已有 shop-service 根目录,保留原 mysql_client.py 与 sql 文件。",
  123. "沿用数据库模块的 Linux/WSL2 Bash 与同一个 shop-service 目录;每个新终端先 cd 到该目录,再 source .venv/bin/activate,用 python -c \"import sys; print(sys.executable)\" 确认路径为 shop-service/.venv/bin/python。不要切换到 PowerShell 复用它,也不要复制 Windows 的 .venv。第 01 课允许不存在时先创建;之后 SQL 只粘贴到 mysql 客户端。",
  124. "先回到 FastAPI 基础模块仍在运行的 api-lab Uvicorn 终端按 Ctrl+C,等它返回提示符,给本模块释放 8000 端口。已停止时无需重复操作,不按模糊进程名批量结束服务。",
  125. "无需另外安装宿主 MySQL 客户端:在另一个 Bash 运行 docker exec -it shop-mysql mysql --protocol=TCP -h 127.0.0.1 -P 3306 -u app_user -p shop_lab,输入本机已有密码;出现 mysql> 后再执行课内 SQL。"
  126. ],
  127. "outcome": "项目拥有可验证的环境配置,正常读取连接数量,错误配置会在启动前报错,并确认实际数据库和账号。"
  128. }
  129. },
  130. {
  131. "id": "02",
  132. "group": "连接与事务",
  133. "title": "异步 Session 的生命周期",
  134. "summary": "商品接口要反复访问 MySQL,每次请求还可能同时到来。先做两个可访问的小地址,观察请求结束后连接能否继续复用。",
  135. "objectives": [
  136. "启动 /live 与 /ready,并区分进程能响应和数据库能查询",
  137. "观察重复短请求结束后没有遗留活动事务",
  138. "运行两个独立 Session 的并发查询,核对隔离级别与 UTC 参数"
  139. ],
  140. "concepts": [
  141. {
  142. "title": "完整的异步路径",
  143. "body": "完成第 1 步“新建数据库模块”后,再回顾这条规则:本模块使用async def路由、AsyncSession和asyncmy异步连接。同步后端可采用def路由与mysql+pymysql的同步Session,由线程池处理阻塞I/O;不能直接在事件循环执行同步数据库调用。asyncmy主线不需要额外切换Windows事件循环策略。"
  144. },
  145. {
  146. "title": "Engine复用,Session隔离",
  147. "body": "完成第 5 步“每个并发任务创建自己的Session”后,再回顾这条规则:每个服务进程复用Engine连接池;每次请求或独立任务创建自己的Session。Session在执行SQL时借连接,事务结束归还,关闭时清理状态。它是有状态的事务对象,不能被asyncio.gather的并发分支或线程共享。"
  148. },
  149. {
  150. "title": "显式设置隔离与会话参数",
  151. "body": "完成第 5 步“每个并发任务创建自己的Session”后,再回顾这条规则:MySQL默认通常为REPEATABLE READ,本课程API明确选择READ COMMITTED,避免读写流程意外依赖默认快照。连接建立时统一UTC和等待预算;SESSION变量会随池连接复用,业务代码不得随意修改后归还。max_execution_time仅限制适用的只读SELECT,不是所有SQL的通用超时。"
  152. }
  153. ],
  154. "steps": [
  155. {
  156. "title": "新建数据库模块",
  157. "body": "将下面代码保存为 app/db.py。依赖app/config.py。pool_recycle只在借出连接时检查年龄,不中断正在使用的连接,也不能重放失败事务。connect_args显式指定charset=utf8mb4,避免环境URL遗漏charset时退回不同连接字符集;本课程不允许业务覆盖这一字符集约定。",
  158. "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\nMYSQL_CONNECT_ARGS = {\n \"charset\": \"utf8mb4\",\n \"connect_timeout\": 3,\n \"init_command\": \"SET time_zone='+00:00', innodb_lock_wait_timeout=2, lock_wait_timeout=3, max_execution_time=3000\",\n}\n\ndef make_engine(url: str, **pool_options):\n return create_async_engine(url, isolation_level=\"READ COMMITTED\",\n connect_args=MYSQL_CONNECT_ARGS, pool_pre_ping=True,\n pool_recycle=1800, **pool_options)\n\nengine = make_engine(settings.database_url,\n pool_size=settings.db_pool_size, max_overflow=settings.db_max_overflow,\n pool_timeout=settings.db_pool_timeout)\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\")]",
  159. "lang": "python",
  160. "filename": "app/db.py",
  161. "expected": "app/db.py 可导入并创建 engine/SessionFactory;本步尚未实际查询数据库,错误地址可能要到 /ready 才暴露。",
  162. "explanation": "Engine 管理可复用的连接池,Session 是一次工作自己的数据库状态容器。get_session 为每次请求创建它,yield 交给路由使用,退出时关闭;不同请求不能共用同一个 Session。 make_engine供应用与测试复用,确保隔离级别和时间参数一致。参数是练习起点,后续按预算测量。"
  163. },
  164. {
  165. "title": "新建应用入口",
  166. "body": "将下面代码保存为 app/main.py。依赖上一步 db.py。/live 仅证明进程能响应;/ready 尝试查询数据库。生产探针需结合平台超时设置,readiness 失败使流量停止进入该实例,不应用于不断重启所有实例。",
  167. "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=\"shop-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\"}",
  168. "lang": "python",
  169. "filename": "app/main.py",
  170. "expected": "本步先保存定义,不会自动产生运行输出;调用后的观察在后续验证步骤完成。app/main.py 定义 /live、/ready 和退出清理逻辑,下一步启动后才能访问;/ready 的 SELECT 1 不读写商品数据。",
  171. "explanation": "/live 只回答进程是否活着,/ready 借连接做一次最小查询。db.begin 包住一个短事务,异常离开会清理;lifespan 在应用退出时 dispose 池资源。"
  172. },
  173. {
  174. "title": "使用同一入口启动",
  175. "body": "先回到旧 api-lab Uvicorn 终端按 Ctrl+C,等它停止并释放 8000;已停止时跳过。然后在 Linux/WSL2 的 shop-service 根目录激活 .venv,执行下面命令并保持该终端运行,用另一个终端发送请求。本地编辑可用 reload;生产按部署模块配置,不使用 reload。",
  176. "code": "python -m uvicorn app.main:app --host 127.0.0.1 --port 8000 --reload",
  177. "lang": "bash",
  178. "expected": "终端显示服务监听 127.0.0.1:8000;访问 /live 得到 {\"status\":\"ok\"},/ready 得到 {\"status\":\"ready\"}。",
  179. "explanation": "app.main:app 告诉 Uvicorn 导入哪个对象,reload 用于本地编辑后重启。两条返回分别证明服务入口与真实数据库读取都走通。"
  180. },
  181. {
  182. "title": "观察连接与活动事务",
  183. "body": "访问/ready十次后在mysql客户端查询。Sleep连接可能是池中的空闲连接;是否存在未结束事务要结合innodb_trx。诊断表可能需要运维权限,生产不为API账号扩大全局权限,只由诊断账号执行。",
  184. "code": "SHOW FULL PROCESSLIST;\nSELECT trx_mysql_thread_id, trx_started, trx_state, trx_query\nFROM information_schema.innodb_trx;",
  185. "lang": "sql",
  186. "expected": "访问 /ready 十次后可见少量 Sleep 连接,但不应遗留这些请求的长期活动事务;无诊断权限则明确记录该项未执行。",
  187. "explanation": "Sleep 可能只是池保留的空闲连接,不能仅凭它判定泄漏。innodb_trx 观察尚未结束的事务,能区分复用连接和把事务忘在后台。"
  188. },
  189. {
  190. "title": "每个并发任务创建自己的Session",
  191. "body": "新建scripts/__init__.py和scripts/probe.py,运行python -m scripts.probe。每个任务返回数据库连接ID、隔离级别和时区。连接ID可能因池复用而重复,不据此判断Session是否被共享。",
  192. "code": "import asyncio\nfrom sqlalchemy import text\nfrom app.db import SessionFactory, engine\n\nasync def probe():\n async with SessionFactory.begin() as db:\n row = (await db.execute(text(\"\"\"\n SELECT CONNECTION_ID() AS connection_id,\n @@session.transaction_isolation AS isolation_level,\n @@session.time_zone AS time_zone\n \"\"\"))).mappings().one()\n return dict(row)\n\nasync def main():\n try:\n print(await asyncio.gather(probe(), probe()))\n finally:\n await engine.dispose()\n\nif __name__ == \"__main__\":\n asyncio.run(main())",
  193. "lang": "python",
  194. "filename": "scripts/probe.py",
  195. "expected": "python -m scripts.probe 输出两组 connection_id、READ-COMMITTED、+00:00,脚本随后退出;连接编号允许重复。",
  196. "explanation": "每次 probe 都从 SessionFactory 开始自己的事务,因此可并发执行。底层物理连接可以重复借用,Session 隔离与连接复用并不矛盾。"
  197. }
  198. ],
  199. "checks": [
  200. "/live与/ready正常返回200;停止本机MySQL后/live仍响应而/ready为503。",
  201. "probe确认READ-COMMITTED与+00:00,连续短请求不会遗留未结束事务。",
  202. "每个并发任务自行创建Session,进程退出dispose Engine。"
  203. ],
  204. "pitfalls": [
  205. {
  206. "symptom": "出现operation in progress或事务状态混乱",
  207. "cause": "多个协程共享同一个AsyncSession。",
  208. "fix": "独立事务每个任务创建Session;同一原子事务内顺序执行,不用gather共享会话。"
  209. },
  210. {
  211. "symptom": "偶发MySQL server has gone away",
  212. "cause": "空闲连接被服务端/网络断开,或事务执行期间连接中断。",
  213. "fix": "用pre_ping与合理recycle处理借出前失效;正在执行的失败事务退出回滚,写入结果未知时用幂等键查询。"
  214. }
  215. ],
  216. "exercise": {
  217. "task": "为什么不能在请求中临时SET SESSION time_zone,然后不恢复就把连接还给池?",
  218. "hint": "下一次请求可能借到同一物理连接。",
  219. "answer": "Session对象虽然不同,池里的物理连接仍保留SESSION变量。统一连接初始化并禁止业务任意改变;确需切换时在受控连接中恢复或丢弃该连接,测试下一次借出状态。"
  220. },
  221. "refs": [
  222. {
  223. "label": "SQLAlchemy:异步Session",
  224. "url": "https://docs.sqlalchemy.org/en/20/orm/extensions/asyncio.html"
  225. },
  226. {
  227. "label": "SQLAlchemy:MySQL隔离级别",
  228. "url": "https://docs.sqlalchemy.org/en/20/dialects/mysql.html#transaction-isolation-level"
  229. },
  230. {
  231. "label": "asyncmy官方实现",
  232. "url": "https://github.com/long2ice/asyncmy"
  233. },
  234. {
  235. "label": "MySQL8.4:隔离级别",
  236. "url": "https://dev.mysql.com/doc/refman/8.4/en/innodb-transaction-isolation-levels.html"
  237. }
  238. ],
  239. "production": {
  240. "scenario": "同一API进程同时处理请求和独立后台操作。",
  241. "decisions": [
  242. "Engine按进程复用,Session按任务隔离。",
  243. "应用明确使用RC和UTC,慢流先转换DTO再释放数据库资源。"
  244. ],
  245. "failureDrill": "在本机连续调用readiness并中断客户端,核对innodb_trx没有遗留事务;重连后会话参数仍一致。",
  246. "acceptance": [
  247. "会话不跨并发任务共享。",
  248. "数据库故障返回脱敏503,恢复后可重新查询。"
  249. ]
  250. },
  251. "intro": {
  252. "scenario": "商品接口要反复访问 MySQL,每次请求还可能同时到来。先做两个可访问的小地址,观察请求结束后连接能否继续复用。",
  253. "startingPoint": [
  254. "已完成本模块第 01 课,app/config.py 导入输出 5,MySQL 客户端可连 shop_lab;在 shop-service 根目录激活同一虚拟环境,预留 API 终端与 MySQL 观测终端。",
  255. "沿用数据库模块的 Linux/WSL2 Bash 与同一个 shop-service 目录;每个新终端先 cd 到该目录,再 source .venv/bin/activate,用 python -c \"import sys; print(sys.executable)\" 确认路径为 shop-service/.venv/bin/python。不要切换到 PowerShell 复用它,也不要复制 Windows 的 .venv。第 01 课允许不存在时先创建;之后 SQL 只粘贴到 mysql 客户端。",
  256. "先回到 FastAPI 基础模块仍在运行的 api-lab Uvicorn 终端按 Ctrl+C,等它返回提示符,给本模块释放 8000 端口。已停止时无需重复操作,不按模糊进程名批量结束服务。",
  257. "无需另外安装宿主 MySQL 客户端:在另一个 Bash 运行 docker exec -it shop-mysql mysql --protocol=TCP -h 127.0.0.1 -P 3306 -u app_user -p shop_lab,输入本机已有密码;出现 mysql> 后再执行课内 SQL。"
  258. ],
  259. "outcome": "/live 和 /ready 分别证明进程能响应、数据库可查询;重复与并发读取结束后资源释放且会话参数一致。"
  260. }
  261. },
  262. {
  263. "id": "03",
  264. "group": "连接与事务",
  265. "title": "ORM CRUD 与输入输出模型",
  266. "summary": "让网页真的创建、修改和删除一件商品。先对齐商品表,再把请求中的名称和价格保存下来,最后从接口与数据库两边核对。",
  267. "objectives": [
  268. "将商品表字段、ORM 映射和输入输出模型逐项对齐",
  269. "通过 HTTP 完成商品创建、读取、价格与上架状态修改及删除",
  270. "验证非法价格返回 422,删除后查询返回 404"
  271. ],
  272. "concepts": [
  273. {
  274. "title": "先定字段契约,再写 ORM",
  275. "body": "完成第 1 步“新建基础ORM映射”后,再回顾这条规则:沿用数据库模块的 products 表:id 是稳定主键;name 用 VARCHAR(120),允许不同商品同名;description 用 TEXT NOT NULL,API 默认传空字符串;is_active 用 BOOLEAN NOT NULL DEFAULT FALSE,明确未上架和已上架;price_cents 用 INT NOT NULL DEFAULT 0,以整数分表达价格并用 CHECK 拒绝负值。created_at/updated_at 用 DATETIME(6) 保存 UTC,version 在后续并发与迁移课增加。字段名称、可空性、默认值和边界必须在 SQL、ORM、Pydantic 三处一致。"
  276. },
  277. {
  278. "title": "表、实体与DTO",
  279. "body": "完成第 3 步“新建输入输出模型”后,再回顾这条规则:表定义存储规则,ORM映射Python对象,Pydantic定义请求和响应。输入不包含id和created_at,数据库仍维护约束。本课先映射基础字段;已有products表多出的version可以暂时保留,第7课在空库演练版本列迁移。"
  280. },
  281. {
  282. "title": "MySQL生成值读取",
  283. "body": "完成第 4 步“新建CRUD路由并挂载”后,再回顾这条规则:add只登记对象,flush才执行INSERT并通过驱动的生成ID获得主键。MySQL8.4不提供本课可用的通用INSERT/UPDATE RETURNING路径,服务器默认时间通过refresh显式读取。构造DTO后离开begin完成commit,成功后才返回HTTP响应。"
  284. },
  285. {
  286. "title": "文本与UTC边界",
  287. "body": "完成第 3 步“新建输入输出模型”后,再回顾这条规则:MySQL TEXT最大容量按字节计算,本课输入限制16000字符,utf8mb4最坏64000字节,为65535字节上限留余量;需求更大时应迁移字段类型。DATETIME(6)返回无时区datetime,数据库值按UTC约定,响应统一带+00:00。PATCH省略字段与显式null分别处理。"
  288. }
  289. ],
  290. "steps": [
  291. {
  292. "title": "新建基础ORM映射",
  293. "body": "将下面映射保存为 app/models.py。依赖app/db.py。已有数据库模块的products先SHOW CREATE TABLE核对,不修改现有数据。created_at/updated_at都是DATETIME(6),服务在所有更新路径主动刷新updated_at;server_default不会自动随UPDATE执行。同时核对 price_cents 的 INT、非空、默认 0 和 ck_products_price_nonnegative。已有库缺字段时先按数据库模块的迁移方法补齐,不通过重建表丢弃练习数据。is_active 只接受 0/1,description 最多 16000 字符;两条命名 CHECK 与数据库模块保持一致,同时进入 ORM metadata 和首次建表 DDL。",
  294. "code": "from datetime import datetime\nfrom sqlalchemy import BigInteger, Boolean, CheckConstraint, Integer, String, Text, text, func\nfrom sqlalchemy.dialects.mysql import DATETIME\nfrom sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column\n\nclass Base(DeclarativeBase):\n pass\n\nclass Product(Base):\n __tablename__ = \"products\"\n __table_args__ = (\n CheckConstraint(\"price_cents >= 0\", name=\"ck_products_price_nonnegative\"),\n CheckConstraint(\"is_active IN (0, 1)\", name=\"products_is_active_boolean\"),\n CheckConstraint(\"CHAR_LENGTH(description) <= 16000\", name=\"products_description_limit\"),\n {\"mysql_engine\": \"InnoDB\", \"mysql_charset\": \"utf8mb4\"},\n )\n id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)\n name: Mapped[str] = mapped_column(String(120), nullable=False)\n description: Mapped[str] = mapped_column(Text, nullable=False)\n is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text(\"0\"))\n price_cents: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text(\"0\"))\n created_at: Mapped[datetime] = mapped_column(DATETIME(fsp=6), nullable=False, server_default=text(\"CURRENT_TIMESTAMP(6)\"))\n updated_at: Mapped[datetime] = mapped_column(DATETIME(fsp=6), nullable=False, server_default=text(\"CURRENT_TIMESTAMP(6)\"))",
  295. "lang": "python",
  296. "filename": "app/models.py",
  297. "expected": "本步先保存定义,不会自动产生运行输出;调用后的观察在后续验证步骤完成。app/models.py 的 Product 字段与已有 products 对齐,包含价格、时间和三条 CHECK;此步只是 Python 映射,不会自动改表。",
  298. "explanation": "ORM 是把表的一行对应为 Python 对象的方式。mapped_column 指定该字段在数据库如何存,主键定位具体商品;约束仍由真实 MySQL 执行,模型声明不能替代核对表结构。"
  299. },
  300. {
  301. "title": "仅对不存在products表的练习库初始化",
  302. "body": "在shop_lab的mysql客户端执行。已有表不执行CREATE,更不能删表重建。数据库和连接都使用utf8mb4;所有业务表使用InnoDB。这里不在TEXT列声明跨版本容易误用的默认值,API显式传入description默认空字符串。",
  303. "code": "SET time_zone = '+00:00';\nCREATE TABLE products (\n id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,\n name VARCHAR(120) NOT NULL,\n description TEXT NOT NULL,\n is_active BOOLEAN NOT NULL DEFAULT FALSE,\n price_cents INT NOT NULL DEFAULT 0,\n created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),\n updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),\n CONSTRAINT ck_products_price_nonnegative CHECK (price_cents >= 0),\n CONSTRAINT products_is_active_boolean CHECK (is_active IN (0, 1)),\n CONSTRAINT products_description_limit CHECK (CHAR_LENGTH(description) <= 16000)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;\nSHOW CREATE TABLE products;",
  304. "lang": "sql",
  305. "expected": "无表练习库创建后 SHOW CREATE TABLE 可见 BIGINT 主键、name 长度 120、整数分价格与非负 CHECK;已有表只核对,不重建。",
  306. "explanation": "字段类型表达存什么,非空和默认值表达缺值时怎么办,CHECK 表达什么值不能存。金额使用整数分让 1990 明确代表 19.90 元,避免浮点舍入误差。"
  307. },
  308. {
  309. "title": "新建输入输出模型",
  310. "body": "将下面请求与响应模型保存为 app/schemas.py。创建、更新、响应分别声明。ProductCreate与ProductPatch均限制描述16000字符;若绕过API写入,仍应在严格SQL模式下由数据库拒绝超容量内容,不能接受静默截断。UTC转换仅基于已经建立的存储契约。price_cents 在创建、局部修改和输出模型中使用相同整数分边界;PATCH 可以省略价格以保留原值,显式 null、负数、小数、字符串或布尔值均拒绝。",
  311. "code": "from datetime import datetime, timezone\nfrom pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator\n\nclass ProductCreate(BaseModel):\n model_config = ConfigDict(extra=\"forbid\")\n name: str = Field(min_length=1, max_length=120)\n description: str = Field(default=\"\", max_length=16000)\n is_active: bool = False\n price_cents: int = Field(default=0, ge=0, le=2147483647, strict=True)\n\n @field_validator(\"name\")\n @classmethod\n def clean_name(cls, value):\n value = value.strip()\n if not value:\n raise ValueError(\"name must not be blank\")\n return value\n\nclass ProductPatch(BaseModel):\n model_config = ConfigDict(extra=\"forbid\")\n name: str | None = Field(default=None, min_length=1, max_length=120)\n description: str | None = Field(default=None, max_length=16000)\n is_active: bool | None = None\n price_cents: int | None = Field(default=None, ge=0, le=2147483647, strict=True)\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.name is not None:\n self.name = self.name.strip()\n if not self.name:\n raise ValueError(\"name must not be blank\")\n return self\n\nclass ProductOut(BaseModel):\n model_config = ConfigDict(from_attributes=True)\n id: int\n name: str\n description: str\n is_active: bool\n price_cents: int = Field(ge=0, le=2147483647, strict=True)\n created_at: datetime\n updated_at: datetime\n\n @field_validator(\"created_at\", \"updated_at\")\n @classmethod\n def expose_utc(cls, value):\n # 仅因本服务数据库契约已约定所有DATETIME值为UTC,才允许补UTC标记。\n return value.replace(tzinfo=timezone.utc) if value.tzinfo is None else value.astimezone(timezone.utc)",
  312. "lang": "python",
  313. "filename": "app/schemas.py",
  314. "expected": "本步先保存定义,不会自动产生运行输出;调用后的观察在后续验证步骤完成。app/schemas.py 有创建、局部更新、输出三种模型;输入不接受自填 id,价格 -1、1.5、字符串或 True 均不符合约束。",
  315. "explanation": "请求模型规定用户能交什么,响应模型规定服务返回什么,数据库对象负责实际存储。PATCH 省略字段表示保持原值,显式 null 表示传了空值;本课拒绝后者。"
  316. },
  317. {
  318. "title": "新建CRUD路由并挂载",
  319. "body": "新建app/routes.py,将from app.routes import router与app.include_router(router)加到app/main.py的app创建之后。每个写端点只使用一个显式事务。此练习只绑定本机,真实产品需接入身份认证与数据归属过滤后才开放。",
  320. "code": "from datetime import datetime, timezone\nfrom fastapi import APIRouter, HTTPException, Response\nfrom app.db import Db\nfrom app.models import Product\nfrom app.schemas import ProductCreate, ProductOut, ProductPatch\n\nrouter = APIRouter(prefix=\"/products\", tags=[\"products\"])\n\n@router.post(\"\", response_model=ProductOut, status_code=201)\nasync def create_product(body: ProductCreate, db: Db):\n async with db.begin():\n product = Product(**body.model_dump())\n db.add(product)\n await db.flush()\n await db.refresh(product)\n result = ProductOut.model_validate(product)\n return result\n\n@router.get(\"/{product_id}\", response_model=ProductOut)\nasync def get_product(product_id: int, db: Db):\n async with db.begin():\n product = await db.get(Product, product_id)\n if product is None:\n raise HTTPException(404, \"product not found\")\n result = ProductOut.model_validate(product)\n return result\n\n@router.patch(\"/{product_id}\", response_model=ProductOut)\nasync def patch_product(product_id: int, body: ProductPatch, db: Db):\n async with db.begin():\n product = await db.get(Product, product_id)\n if product is None:\n raise HTTPException(404, \"product not found\")\n for name, value in body.model_dump(exclude_unset=True).items():\n setattr(product, name, value)\n product.updated_at = datetime.now(timezone.utc).replace(tzinfo=None)\n await db.flush()\n await db.refresh(product)\n result = ProductOut.model_validate(product)\n return result\n\n@router.delete(\"/{product_id}\", status_code=204)\nasync def delete_product(product_id: int, db: Db):\n async with db.begin():\n product = await db.get(Product, product_id)\n if product is None:\n raise HTTPException(404, \"product not found\")\n await db.delete(product)\n return Response(status_code=204)",
  321. "lang": "python",
  322. "filename": "app/routes.py",
  323. "expected": "挂载后 /docs 出现 products 的 POST/GET/PATCH/DELETE;创建时响应含数据库生成 id 和 UTC 时间,成功返回在事务结束之后。",
  324. "explanation": "add 先登记对象,flush 才执行 INSERT,refresh 再读回数据库生成的时间。先构造明确的输出对象,再结束事务返回,避免响应序列化时才偷偷查数据库。"
  325. },
  326. {
  327. "title": "通过 HTTP 与 SQL 双向验收",
  328. "body": "启动服务,在 /docs 创建商品,记录响应 id,随后读取、PATCH is_active=true 并删除。下面 Python 片段保存为 scripts/crud_probe.py 后运行 python -m scripts.crud_probe。",
  329. "code": "import httpx\n\nwith httpx.Client(base_url=\"http://127.0.0.1:8000\", timeout=5) as client:\n created = client.post(\"/products\", json={\"name\": \"陶瓷杯\", \"price_cents\": 1990})\n assert created.status_code == 201, created.text\n product_id = created.json()[\"id\"]\n assert client.get(f\"/products/{product_id}\").status_code == 200\n patched = client.patch(f\"/products/{product_id}\", json={\"is_active\": True, \"price_cents\": 2490})\n assert patched.json()[\"is_active\"] is True\n assert patched.json()[\"price_cents\"] == 2490\n assert client.patch(f\"/products/{product_id}\", json={\"price_cents\": -1}).status_code == 422\n assert client.patch(f\"/products/{product_id}\", json={\"name\": None}).status_code == 422\n assert client.delete(f\"/products/{product_id}\").status_code == 204\n assert client.get(f\"/products/{product_id}\").status_code == 404",
  330. "lang": "python",
  331. "filename": "scripts/crud_probe.py",
  332. "expected": "python -m scripts.crud_probe 无断言错误;依次验证 201、200、有效修改、422、204、404,最终本次创建的测试商品已删除。",
  333. "explanation": "同一 id 串起创建到删除,能证明操作的是同一条真实记录。422 是请求不合要求,404 是记录不存在;二者不能混作数据库连接失败。"
  334. }
  335. ],
  336. "checks": [
  337. "POST返回201和自动生成id,created_at/updated_at输出+00:00,重启API后仍能读取。",
  338. "PATCH省略description保持原值,显式null及空白名称返回422;超出16000字符描述被拒绝。",
  339. "DELETE成功返回空204,后续GET404;所有存储表确认为InnoDB。",
  340. "只传 name 可创建 0 分未上架教学样品;1990 分在 HTTP 与 SQL 中一致;非法价格被 API 拒绝,直接 SQL 负价格被命名 CHECK 拒绝。",
  341. "SHOW CREATE TABLE 与 ORM metadata 同时包含 products_is_active_boolean 和 products_description_limit,迁移基线不会遗漏布尔值和描述长度约束。"
  342. ],
  343. "pitfalls": [
  344. {
  345. "symptom": "新增响应缺少时间或触发隐式I/O",
  346. "cause": "依赖默认值自动返回,或Session关闭后才访问未加载属性。",
  347. "fix": "INSERT后flush+refresh,在事务内生成DTO,提交成功后返回。"
  348. },
  349. {
  350. "symptom": "中文描述写入被截断或Data too long",
  351. "cause": "把TEXT的字节上限误当字符上限,或未使用严格SQL模式。",
  352. "fix": "请求限制字符/UTF8字节数并核对sql_mode;需要更长商品描述时通过迁移选择更大类型,不吞截断警告。"
  353. }
  354. ],
  355. "exercise": {
  356. "task": "验证一段16000个emoji的描述和16001个emoji的描述。",
  357. "hint": "utf8mb4下一个这样的字符通常占4字节。",
  358. "answer": "16000字符请求应通过应用长度校验并在TEXT容量内;16001字符即使可能仍放得下,仍应按本服务明确业务上限返回422。数据库容量与API业务上限不是同一规则。"
  359. },
  360. "refs": [
  361. {
  362. "label": "SQLAlchemy:MySQL生成值与时间类型",
  363. "url": "https://docs.sqlalchemy.org/en/20/dialects/mysql.html"
  364. },
  365. {
  366. "label": "MySQL8.4:DATETIME",
  367. "url": "https://dev.mysql.com/doc/refman/8.4/en/datetime.html"
  368. },
  369. {
  370. "label": "MySQL8.4:BLOB和TEXT",
  371. "url": "https://dev.mysql.com/doc/refman/8.4/en/blob.html"
  372. },
  373. {
  374. "label": "Pydantic:字段校验",
  375. "url": "https://docs.pydantic.dev/latest/concepts/validators/"
  376. }
  377. ],
  378. "production": {
  379. "scenario": "API从内存存储迁移到真实MySQL,需要保持响应与持久化一致。",
  380. "decisions": [
  381. "生成值显式refresh,DTO在提交前完成校验。",
  382. "UTC和文本容量是跨写入路径共同遵守的契约。"
  383. ],
  384. "failureDrill": "创建记录后重启API,通过HTTP与mysql客户端核对相同id、内容及时间含义。",
  385. "acceptance": [
  386. "数据跨进程重启保留。",
  387. "时间没有被误解释为本地时区。"
  388. ]
  389. },
  390. "intro": {
  391. "scenario": "让网页真的创建、修改和删除一件商品。先对齐商品表,再把请求中的名称和价格保存下来,最后从接口与数据库两边核对。",
  392. "startingPoint": [
  393. "已完成本模块第 01–02 课,/ready 返回 200;在 shop-service 保留 app/db.py 和 app/main.py,MySQL 客户端确认 DATABASE()=shop_lab,并先用 SHOW CREATE TABLE products 核对已有表。",
  394. "沿用数据库模块的 Linux/WSL2 Bash 与同一个 shop-service 目录;每个新终端先 cd 到该目录,再 source .venv/bin/activate,用 python -c \"import sys; print(sys.executable)\" 确认路径为 shop-service/.venv/bin/python。不要切换到 PowerShell 复用它,也不要复制 Windows 的 .venv。第 01 课允许不存在时先创建;之后 SQL 只粘贴到 mysql 客户端。",
  395. "无需另外安装宿主 MySQL 客户端:在另一个 Bash 运行 docker exec -it shop-mysql mysql --protocol=TCP -h 127.0.0.1 -P 3306 -u app_user -p shop_lab,输入本机已有密码;出现 mysql> 后再执行课内 SQL。"
  396. ],
  397. "outcome": "商品创建返回真实 id、修改能改变上架状态和整数价格、删除后 404;非法价格及显式空值被拒绝。"
  398. }
  399. },
  400. {
  401. "id": "04",
  402. "group": "连接与事务",
  403. "title": "事务边界、约束失败与提交结果未知",
  404. "summary": "创建商品时还要留一条操作记录。如果第二条记录失败,第一条商品是否已经留下?用同一个请求键故意碰撞来验证。",
  405. "objectives": [
  406. "把商品与审计写入放入同一笔业务事务",
  407. "用重复审计键复现 409,并核对失败请求未留下商品",
  408. "在专用连接中演练提交前断连,按随机标记查询最终状态"
  409. ],
  410. "concepts": [
  411. {
  412. "title": "事务由业务入口控制",
  413. "body": "完成第 2 步“新建组合写入服务”后,再回顾这条规则:Session依赖只负责创建和关闭,不在yield结束后隐藏commit。路由或服务入口用begin包住同一业务的相关写入,辅助函数只执行SQL/flush。流式输出、模型调用和消息发送不应占着数据库事务等待。"
  414. },
  415. {
  416. "title": "MySQL约束立即检查",
  417. "body": "完成第 4 步“复现第二条语句失败与整个业务回滚”后,再回顾这条规则:本课唯一键在INSERT时检查,不把检查延迟到commit。第二张表的唯一冲突发生时,第一张表的INSERT可能已执行但尚未提交;让异常离开db.begin即可回滚全部写入。锁等待超时可能只回滚失败语句,应用仍主动回滚整个业务事务;死锁通常由InnoDB回滚整个受害事务。"
  418. },
  419. {
  420. "title": "提交阶段的两种不确定性",
  421. "body": "完成第 6 步“核对状态与控制流”后,再回顾这条规则:flush成功不等于commit成功。提交前连接被确认终止,未提交事务会回滚;若COMMIT已发出但响应丢失,客户端仅凭网络错误不能判断服务器是否提交。没有2xx不等于没有写入,重试需要第10课的幂等协议。DDL另有隐式提交规则,不能混进业务事务假定一并回滚。"
  422. }
  423. ],
  424. "steps": [
  425. {
  426. "title": "创建立即唯一检查的审计表",
  427. "body": "仅在独立shop_lab练习环境执行一次。审计保留product_id数值但不设置外键,允许商品删除后保留历史。request_key使用ascii_bin,使键大小写按字节区分;第7课纳入迁移metadata。",
  428. "code": "CREATE TABLE product_audits (\n id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,\n product_id BIGINT NOT NULL,\n action VARCHAR(40) NOT NULL,\n request_key VARCHAR(100) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,\n created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),\n CONSTRAINT uq_product_audits_request UNIQUE (request_key)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;",
  429. "lang": "sql",
  430. "expected": "product_audits 表可见命名唯一约束 uq_product_audits_request,request_key 使用 ascii_bin,商品删除不会级联删除审计。",
  431. "explanation": "这张表记录一次操作发生过,唯一请求键让我们能稳定制造第二条 INSERT 失败。审计保留历史,因此本例只保存商品编号数值,没有删除级联外键。"
  432. },
  433. {
  434. "title": "新建组合写入服务",
  435. "body": "新建app/services.py。create_with_audit不commit,只有调用者知道业务事务边界。请求键在本课只用于故障实验,第10课才实现完整幂等结果重放。",
  436. "code": "from sqlalchemy import text\nfrom sqlalchemy.ext.asyncio import AsyncSession\nfrom app.models import Product\nfrom app.schemas import ProductCreate, ProductOut\n\nasync def create_with_audit(db: AsyncSession, body: ProductCreate, request_key: str):\n product = Product(**body.model_dump())\n db.add(product)\n await db.flush()\n await db.refresh(product)\n await db.execute(text(\"\"\"\n INSERT INTO product_audits (product_id, action, request_key)\n VALUES (:product_id, 'created', :request_key)\n \"\"\"), {\"product_id\": product.id, \"request_key\": request_key})\n return ProductOut.model_validate(product)",
  437. "lang": "python",
  438. "filename": "app/services.py",
  439. "expected": "本步先保存定义,不会自动产生运行输出;调用后的观察在后续验证步骤完成。app/services.py 定义 create_with_audit,依次登记商品、flush/refresh、写审计;函数内部没有 commit。",
  440. "explanation": "组合操作只有外层业务入口知道何时算全部完成。内部提前提交商品,后续审计失败就无法把已提交商品收回,所以两次写入共用调用者的事务。 audit语句失败会向上传播,外层begin回滚products写入。"
  441. },
  442. {
  443. "title": "新增演示端点,在事务外映射已知冲突",
  444. "body": "追加app/routes.py。MySQL驱动提供数值errno,没有通用constraint_name对象;本处在特定审计语句路径匹配1062与已知键名,不向响应泄露原始数据库消息。完整未知异常保留服务端错误。",
  445. "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=ProductOut, status_code=201)\nasync def create_audited(body: ProductCreate, db: Db,\n request_key: Annotated[str, Header(min_length=1, max_length=100, pattern=r\"^[A-Za-z0-9._:-]+$\")]):\n try:\n async with db.begin():\n result = await create_with_audit(db, body, request_key)\n except IntegrityError as exc:\n args = getattr(exc.orig, \"args\", ())\n if args and args[0] == 1062 and \"uq_product_audits_request\" in str(args[1:]):\n raise HTTPException(409, \"audit request key already exists\") from exc\n raise\n return result",
  446. "lang": "python",
  447. "filename": "app/routes.py",
  448. "expected": "/docs 新增 POST /products/audited,需要 request-key;已知审计唯一冲突在事务退出后返回 409,未知异常继续作为服务错误。",
  449. "explanation": "数据库当场拒绝重复审计键,异常向外离开 begin 后整笔回滚。errno 和已知键名帮助把这种预期冲突译成 HTTP 反馈,不能把所有数据库错误都说成重复请求。"
  450. },
  451. {
  452. "title": "复现第二条语句失败与整个业务回滚",
  453. "body": "使用随机新键避免重复练习污染。第二次products INSERT可执行,audit INSERT立即冲突,响应409。用mysql查询第二个名称不存在;auto_increment值可能出现空洞,回滚不保证复用已分配id。 将本步代码按标注文件位置保存或合并,完成上述前置后在当前项目终端执行 python -m scripts.transaction_probe,再与下方预期对照。",
  454. "code": "from uuid import uuid4\nimport httpx\n\nwith httpx.Client(base_url=\"http://127.0.0.1:8000\", timeout=5) as client:\n key = str(uuid4())\n headers = {\"request-key\": key}\n first = client.post(\"/products/audited\", json={\"name\": \"first-\" + key}, headers=headers)\n second = client.post(\"/products/audited\", json={\"name\": \"rollback-\" + key}, headers=headers)\n assert first.status_code == 201, first.text\n assert second.status_code == 409, second.text\n print(\"verify no name:\", \"rollback-\" + key)",
  455. "lang": "python",
  456. "filename": "scripts/transaction_probe.py",
  457. "expected": "脚本第一次返回 201、第二次 409;按输出的 rollback-随机键查 products 没有行,审计仍只有首次记录。",
  458. "explanation": "第二次商品 INSERT 曾执行,却因后续审计失败一起撤销,这就是本例事务的原子性。分配过的自增编号可以留空洞,编号不连续不等于回滚失败。"
  459. },
  460. {
  461. "title": "只在本机复现commit前连接被终止",
  462. "body": "新建scripts/commit_window.py;它不会创建HTTP接口。由拥有相应权限的本机诊断账号在另一mysql会话执行KILL CONNECTION 实际ID,确认只针对本次暂停脚本;随后回车触发提交尝试,应该报数据库错误。没有操作权限就标记未执行,不扩大应用账号权限。 将本步代码按标注文件位置保存或合并,完成上述前置后在当前项目终端执行 python -m scripts.commit_window,再与下方预期对照。",
  463. "code": "import asyncio\nfrom uuid import uuid4\nfrom sqlalchemy import text\nfrom sqlalchemy.exc import DBAPIError\nfrom app.db import SessionFactory, engine\nfrom app.models import Product\n\nasync def main():\n marker = \"commit-window-\" + str(uuid4())\n try:\n async with SessionFactory() as db:\n try:\n async with db.begin():\n db.add(Product(name=marker, description=\"\", is_active=False, price_cents=0))\n await db.flush()\n print(\"only kill this lab connection:\", await db.scalar(text(\"SELECT CONNECTION_ID()\")))\n print(\"marker:\", marker)\n await asyncio.to_thread(input, \"After the isolated KILL has completed, press Enter: \")\n print(\"commit acknowledged\")\n except DBAPIError:\n print(\"commit not acknowledged; verify marker using a new connection\")\n finally:\n await engine.dispose()\n\nif __name__ == \"__main__\":\n asyncio.run(main())",
  464. "lang": "python",
  465. "filename": "scripts/commit_window.py",
  466. "expected": "确认终止脚本打印的本机连接后回车,输出 commit not acknowledged;未执行故障注入时应保留真实结果而不写已验证。",
  467. "explanation": "flush 已发送写入不等于 commit 已确认。脚本把“客户端没得到成功确认”单独记下,再由另一条新连接查标记,避免只凭异常猜数据结果。 flush后打印本次数据库CONNECTION_ID并暂停。"
  468. },
  469. {
  470. "title": "核对状态与控制流",
  471. "body": "在另一连接按脚本marker查询;本练习在commit发送前已确认KILL,因此应找不到未提交行。不要将此结果推广到COMMIT已经发出后网络断开的情形。",
  472. "code": "输入校验 → begin → INSERT products → INSERT audit → 构造DTO → commit → HTTP 201\n └─唯一冲突→ 异常离开begin → 全事务rollback → HTTP 409\ncommit响应未收到 → 查询持久状态/用原幂等键重试 → 判断最终结果\nSession依赖只负责close,不在yield之后执行关键commit",
  473. "lang": "text",
  474. "expected": "本实验在 COMMIT 发出前确认 KILL,另一连接查 marker 应无行;正常路径仍在成功离开 begin 后返回 201。",
  475. "explanation": "提交前已断连与提交已发出但回应丢失不同,后者可能已经成功。结果未知时应查持久状态或用原幂等键重试,依赖退出后的 close 不能代替业务 commit。 HTTP路径依然要求成功离开事务块才return。"
  476. }
  477. ],
  478. "checks": [
  479. "重复审计键在INSERT时失败,第二次products与audit均未新增。",
  480. "普通新键请求仍能成功,失败事务没有遗留锁和连接。",
  481. "提交前KILL演练使用精确本机连接ID;未获得commit确认时不报告业务成功。"
  482. ],
  483. "pitfalls": [
  484. {
  485. "symptom": "异常被捕获后仍提交第一张表的数据",
  486. "cause": "MySQL某些错误只撤销当前语句,代码吞异常继续提交。",
  487. "fix": "让异常离开业务begin,在外层映射HTTP错误;全事务重试需新事务和幂等依据。"
  488. },
  489. {
  490. "symptom": "客户端超时就立刻创建一个新请求键",
  491. "cause": "原COMMIT可能已完成,重试变成新的业务操作。",
  492. "fix": "保持原幂等键重查或重试,区分未确认与确定回滚。"
  493. }
  494. ],
  495. "exercise": {
  496. "task": "观察重复审计键导致事务回滚后,自增id为什么可能跳号?",
  497. "hint": "自动编号分配与业务事务回滚不是同一承诺。",
  498. "answer": "InnoDB已分配的AUTO_INCREMENT值不保证在事务回滚后回收。id用于唯一身份,不作为连续业务流水号;需要连续编号必须另行设计且考虑并发和审计。"
  499. },
  500. "refs": [
  501. {
  502. "label": "MySQL8.4:InnoDB错误处理",
  503. "url": "https://dev.mysql.com/doc/refman/8.4/en/innodb-error-handling.html"
  504. },
  505. {
  506. "label": "MySQL8.4:唯一约束",
  507. "url": "https://dev.mysql.com/doc/refman/8.4/en/constraint-primary-key.html"
  508. },
  509. {
  510. "label": "MySQL8.4:KILL",
  511. "url": "https://dev.mysql.com/doc/refman/8.4/en/kill.html"
  512. },
  513. {
  514. "label": "SQLAlchemy:事务管理",
  515. "url": "https://docs.sqlalchemy.org/en/20/orm/session_transaction.html"
  516. }
  517. ],
  518. "production": {
  519. "scenario": "创建商品和审计必须一起持久化,客户端不能在提交前看到成功。",
  520. "decisions": [
  521. "异常路径主动退出并回滚整个业务事务。",
  522. "提交确认丢失使用幂等重查,不能猜测服务器结果。"
  523. ],
  524. "failureDrill": "在独立库复现审计唯一冲突并核对两表;有诊断权限时再执行精确连接KILL演练。",
  525. "acceptance": [
  526. "失败语句不留下部分提交的业务。",
  527. "未运行的断连演练明确标记未验证。"
  528. ]
  529. },
  530. "intro": {
  531. "scenario": "创建商品时还要留一条操作记录。如果第二条记录失败,第一条商品是否已经留下?用同一个请求键故意碰撞来验证。",
  532. "startingPoint": [
  533. "已完成本模块第 03 课,CRUD 探针通过;API 运行于本机 8000,MySQL 连接 shop_lab。连接中断实验另需有诊断权限的本机账号,只针对脚本打印的连接 ID。",
  534. "沿用数据库模块的 Linux/WSL2 Bash 与同一个 shop-service 目录;每个新终端先 cd 到该目录,再 source .venv/bin/activate,用 python -c \"import sys; print(sys.executable)\" 确认路径为 shop-service/.venv/bin/python。不要切换到 PowerShell 复用它,也不要复制 Windows 的 .venv。第 01 课允许不存在时先创建;之后 SQL 只粘贴到 mysql 客户端。",
  535. "无需另外安装宿主 MySQL 客户端:在另一个 Bash 运行 docker exec -it shop-mysql mysql --protocol=TCP -h 127.0.0.1 -P 3306 -u app_user -p shop_lab,输入本机已有密码;出现 mysql> 后再执行课内 SQL。"
  536. ],
  537. "outcome": "商品与审计共同成功或回滚;重复实验键返回 409,提交前断连能按专用标记核对最终数据。"
  538. }
  539. },
  540. {
  541. "id": "05",
  542. "group": "查询与演进",
  543. "title": "商品评价关系与 N+1 查询",
  544. "summary": "商品详情要展示评价。先把评价与商品对应起来,再观察读取三件商品时,怎样避免每访问一个属性就多查一次数据库。",
  545. "objectives": [
  546. "建立评价外键和按商品编号翻页所需的复合索引",
  547. "区分商品不存在的 404 与商品暂无评价的空列表",
  548. "观察批量加载评价的 SQL 次数,并核对分页与关联删除"
  549. ],
  550. "concepts": [
  551. {
  552. "title": "关系来自访问方式",
  553. "body": "完成第 1 步“新增评价表和查询索引”后,再回顾这条规则:一件商品可以有多条评价,每条评价只属于一件商品,所以 comments 保存 product_id 外键;不要把评价数组塞进 products 的单个文本字段。这里用 ON DELETE CASCADE 便于清理练习商品,真实产品需按保留规则选择删除行为。分类关系已在数据库模块用 categories/product_categories 讲解;本课只引入评价关系,避免同时展开额外业务。"
  554. },
  555. {
  556. "title": "N+1 如何发生",
  557. "body": "完成第 4 步“在固定小数据集比较加载方式”后,再回顾这条规则:先查 20 条商品,再为每条商品查询评价,会形成 1+20 次数据库往返。网络延迟叠加后,即使每条 SQL 都很快,接口仍慢。异步 Session 中直接读取尚未加载的关系还可能触发 MissingGreenlet。"
  558. },
  559. {
  560. "title": "显式加载的选择",
  561. "body": "完成第 4 步“在固定小数据集比较加载方式”后,再回顾这条规则:selectinload 将关联查询批量合并,适合规模受控的一对多集合。joinedload 用 JOIN 合并结果,集合关联会放大结果行并通常需要 unique()。分页商品数不等于限制评价总数,不能把 selectinload 当作无限集合的分页方案。"
  562. },
  563. {
  564. "title": "生产接口的边界",
  565. "body": "完成第 3 步“新增生产形式的分页评价接口”后,再回顾这条规则:商品列表只返回商品摘要或聚合计数,评价列表单独分页。本课演示 eager loading 只使用每条两条评价的固定测试数据;真实产品默认使用 /products/{id}/comments 有界接口,删除策略明确为数据库 CASCADE。"
  566. }
  567. ],
  568. "steps": [
  569. {
  570. "title": "新增评价表和查询索引",
  571. "body": "仅在本机shop_lab执行一次,之后由第7课迁移接管。product_id与products.id都使用有符号BIGINT以满足外键类型一致,外键约束明确命名。",
  572. "code": "CREATE TABLE comments (\n id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,\n product_id BIGINT NOT NULL,\n body VARCHAR(2000) NOT NULL,\n created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),\n CONSTRAINT fk_comments_product FOREIGN KEY (product_id) REFERENCES products(id) ON DELETE CASCADE,\n INDEX ix_comments_product_id_id (product_id, id)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;",
  573. "lang": "sql",
  574. "expected": "comments 表的 product_id 外键引用 products.id,复合索引为 (product_id,id),删除策略为 CASCADE。",
  575. "explanation": "每条评价属于一件商品,外键防止评价指向不存在的商品。常用查询是“这一件商品的后续评价”,索引也按先商品再编号排列。"
  576. },
  577. {
  578. "title": "扩展ORM映射",
  579. "body": "把import合并到app/models.py;将注释标出的comments属性去掉注释并放入已有Product类内部,Comment类加在文件末尾,不重复声明Product。lazy=\"raise\"阻止隐式加载;passive_deletes=\"all\"将删除交给已明确的数据库CASCADE。",
  580. "code": "from sqlalchemy import ForeignKey, Index\nfrom sqlalchemy.orm import relationship\n\n# 插入已有Product类内部:\n# comments: Mapped[list[\"Comment\"]] = relationship(\n# back_populates=\"product\", lazy=\"raise\", passive_deletes=\"all\")\n\nclass Comment(Base):\n __tablename__ = \"comments\"\n __table_args__ = (Index(\"ix_comments_product_id_id\", \"product_id\", \"id\"),\n {\"mysql_engine\": \"InnoDB\", \"mysql_charset\": \"utf8mb4\"})\n id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)\n product_id: Mapped[int] = mapped_column(BigInteger,\n ForeignKey(\"products.id\", name=\"fk_comments_product\", ondelete=\"CASCADE\"), nullable=False)\n body: Mapped[str] = mapped_column(String(2000), nullable=False)\n created_at: Mapped[datetime] = mapped_column(DATETIME(fsp=6), nullable=False, server_default=text(\"CURRENT_TIMESTAMP(6)\"))\n product: Mapped[\"Product\"] = relationship(back_populates=\"comments\", lazy=\"raise\")",
  581. "lang": "python",
  582. "filename": "app/models.py",
  583. "expected": "本步先保存定义,不会自动产生运行输出;调用后的观察在后续验证步骤完成。Product 中有 comments 属性,Comment 在文件末尾定义且关系互相对应;没有重复声明 Product 类。",
  584. "explanation": "relationship 表达 Python 对象之间怎么关联。lazy='raise' 在未经明确加载就访问关系时直接报错,让隐式额外查询更容易被发现。"
  585. },
  586. {
  587. "title": "新增生产形式的分页评价接口",
  588. "body": "追加到 app/routes.py。每次最多 100 条,以严格递增 id 翻页;无评价与商品不存在分别返回空数组与 404。",
  589. "code": "from typing import Annotated\nfrom fastapi import Query\nfrom sqlalchemy import select\nfrom app.models import Comment\n\n@router.get(\"/{product_id}/comments\")\nasync def list_comments(product_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(Product, product_id) is None:\n raise HTTPException(404, \"product not found\")\n rows = (await db.scalars(select(Comment).where(\n Comment.product_id == product_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",
  590. "lang": "python",
  591. "filename": "app/routes.py",
  592. "expected": "已存在但无评价的商品返回 items=[],不存在商品为 404;有多条评价时 limit=1 返回一条及 next_after_id。",
  593. "explanation": "接口先判断商品存在,再按评价 id 向后取,最多额外多读一条判断下一页。直接构造字典不会顺带访问 Product.comments,也不需要把所有评价装入内存。 输出为明确字典,未访问 Product.comments。"
  594. },
  595. {
  596. "title": "在固定小数据集比较加载方式",
  597. "body": "先在 /docs 创建 3 件专用商品,用 mysql客户端 为每条插入 2 条评价。把下面函数追加到 scripts/probe.py,并在其 main 内调用 await compare();只对这组 id 运行,替换 [1,2,3] 为实际值。在本机临时把 Engine echo 设为 True 观察 SQL,完成后关闭,避免记录用户内容。",
  598. "code": "from sqlalchemy import select\nfrom sqlalchemy.orm import selectinload\nfrom app.models import Product\n\nasync def compare():\n async with SessionFactory.begin() as db:\n products = (await db.scalars(select(Product).where(Product.id.in_([1, 2, 3]))\n .options(selectinload(Product.comments)))).all()\n print([(product.id, len(product.comments)) for product in products])",
  599. "lang": "python",
  600. "filename": "scripts/probe.py",
  601. "expected": "固定三件商品各两条评价时,打印三组 (商品 id,2);SQL 日志可见取商品和批量取评价两类查询。",
  602. "explanation": "若循环访问每件商品时各发一条评价查询,就会形成 1+N 次查询。selectinload 把这些商品 id 合并为一次关系读取,先明确加载后访问属性。"
  603. },
  604. {
  605. "title": "验收集合边界与执行计划",
  606. "body": "访问评价分页limit=1并沿next_after_id读取。EXPLAIN ANALYZE会实际执行查询,仅对本课有界SELECT使用;小数据使用全表扫描未必是问题,应结合实际行数和耗时判断。",
  607. "code": "SELECT product_id, COUNT(*) FROM comments GROUP BY product_id;\nEXPLAIN ANALYZE\nSELECT id, body FROM comments\nWHERE product_id = 1 AND id > 0 ORDER BY id LIMIT 21;",
  608. "lang": "sql",
  609. "expected": "沿 next_after_id 无重复读完专用评价;删除专用商品后其评价消失,执行计划展示本课限定查询的实际读取情况。",
  610. "explanation": "分页上限保护接口返回规模,数据库 CASCADE 承担本例的关联删除。小表选择扫描可能合理,判断计划要看实际行数和耗时,而不是只看有没有用索引。 删除专用商品后关联评价应消失。"
  611. }
  612. ],
  613. "checks": [
  614. "小数据 selectinload 示例用商品查询与评价查询完成读取,没有每条商品再发一次 SQL。",
  615. "评价接口 limit=101 返回 422,翻页不重复已见 id。",
  616. "删除商品后对应评价被数据库删除,无孤立外键记录。"
  617. ],
  618. "pitfalls": [
  619. {
  620. "symptom": "分页商品 20 条仍返回几十万条评价",
  621. "cause": "主表分页没有限制关联集合规模。",
  622. "fix": "默认分离评价分页接口,只在已知小集合使用 selectinload;聚合计数使用 GROUP BY 子查询。"
  623. },
  624. {
  625. "symptom": "删除带评价的商品出现外键非空错误",
  626. "cause": "ORM 尝试把外键置空,与数据库 CASCADE 策略不一致。",
  627. "fix": "使用本课 passive_deletes=\"all\" 让数据库处理删除;同一会话内已加载对象可能过期,删除后不再返回其评价对象。"
  628. }
  629. ],
  630. "exercise": {
  631. "task": "给评价接口增加排序反向读取。",
  632. "hint": "反向页需同时改变比较符与 ORDER BY。",
  633. "answer": "使用 before_id 游标,WHERE id < before_id ORDER BY id DESC LIMIT limit+1。首次无游标时不加比较条件,下一页传上一页末尾 id;不要只改排序而保留 id > after_id。"
  634. },
  635. "refs": [
  636. {
  637. "label": "SQLAlchemy:关系加载",
  638. "url": "https://docs.sqlalchemy.org/en/20/orm/queryguide/relationships.html"
  639. },
  640. {
  641. "label": "MySQL8.4:外键约束",
  642. "url": "https://dev.mysql.com/doc/refman/8.4/en/create-table-foreign-keys.html"
  643. },
  644. {
  645. "label": "MySQL8.4:EXPLAIN ANALYZE",
  646. "url": "https://dev.mysql.com/doc/refman/8.4/en/explain.html"
  647. }
  648. ],
  649. "production": {
  650. "scenario": "商品列表最初只有少量评价,运行一段时间后单件商品积累大量评价。",
  651. "decisions": [
  652. "主列表不嵌入无界集合。",
  653. "通过 SQL 次数与返回行数共同判断优化效果。"
  654. ],
  655. "failureDrill": "只在练习库为单件商品插入较多评价,比较 eager loading 与分页接口的返回规模;限制数据量避免占满磁盘。",
  656. "acceptance": [
  657. "关联读取有明确上限。",
  658. "响应序列化不触发数据库查询。"
  659. ]
  660. },
  661. "intro": {
  662. "scenario": "商品详情要展示评价。先把评价与商品对应起来,再观察读取三件商品时,怎样避免每访问一个属性就多查一次数据库。",
  663. "startingPoint": [
  664. "已完成本模块第 03–04 课,app/models.py 和 app/routes.py 可用;继续使用 shop_lab,本课用 /docs 新建三件专用商品并记录实际 id,不假定是 1、2、3。",
  665. "沿用数据库模块的 Linux/WSL2 Bash 与同一个 shop-service 目录;每个新终端先 cd 到该目录,再 source .venv/bin/activate,用 python -c \"import sys; print(sys.executable)\" 确认路径为 shop-service/.venv/bin/python。不要切换到 PowerShell 复用它,也不要复制 Windows 的 .venv。第 01 课允许不存在时先创建;之后 SQL 只粘贴到 mysql 客户端。",
  666. "无需另外安装宿主 MySQL 客户端:在另一个 Bash 运行 docker exec -it shop-mysql mysql --protocol=TCP -h 127.0.0.1 -P 3306 -u app_user -p shop_lab,输入本机已有密码;出现 mysql> 后再执行课内 SQL。"
  667. ],
  668. "outcome": "评价按商品和编号分页,无商品返回 404、无评价返回空列表;批量读取关系的 SQL 次数可观察,删除关联符合表约定。"
  669. }
  670. },
  671. {
  672. "id": "06",
  673. "group": "查询与演进",
  674. "title": "连接池预算、超时与多 Worker",
  675. "summary": "请求变多后,数据库连接可能先用完。先算清每个进程会占多少,再用一个连接的小池看超时及恢复,区分“没借到连接”和“等行锁”。",
  676. "objectives": [
  677. "计算多实例、多 worker 和发布峰值的数据库连接需求",
  678. "用单连接实验池复现一次等待超时,并验证连接可再次使用",
  679. "在两个会话间复现指定商品的行锁等待,释放后验证请求恢复"
  680. ],
  681. "concepts": [
  682. {
  683. "title": "连接数按进程相乘",
  684. "body": "完成第 1 步“计算部署预算”后,再回顾这条规则:最坏应用连接数约为实例×worker×(pool_size+max_overflow),还需加任务消费者、迁移与诊断连接。发布新旧重叠也会加倍。MySQL max_connections是所有连接的共享上限,管理保留通路不应被业务预算占满。"
  685. },
  686. {
  687. "title": "不同等待分别限制",
  688. "body": "完成第 2 步“读取应用连接实际参数”后,再回顾这条规则:pool_timeout限制借池连接等待,connect_timeout限制建连接,innodb_lock_wait_timeout限制InnoDB行锁等待,lock_wait_timeout主要约束元数据锁等待,max_execution_time只针对适用的只读SELECT。它们不能合成所有SQL通用截止时间;DML还需要有界操作、请求预算和失败后的事务回滚。"
  689. },
  690. {
  691. "title": "错误后的事务决策",
  692. "body": "完成第 5 步“通过两会话观察行锁等待”后,再回顾这条规则:1205是锁等待超时,1213是死锁。默认锁超时可能只回滚当前语句,因此本服务让异常离开begin并回滚整个业务。重试从新事务开始、有次数与总时限且业务必须可安全重试。pre_ping/recycle只处理借连接时的失效,不恢复正在执行的事务。"
  693. }
  694. ],
  695. "steps": [
  696. {
  697. "title": "计算部署预算",
  698. "body": "先由诊断账号执行SHOW VARIABLES LIKE \"max_connections\",列出其他客户端。以下为练习预算,不是通用推荐;管理访问还需独立运维权限与恢复通路。",
  699. "code": "数据库上限 100\n保留给管理与故障处理 10\n其他服务与后台任务 20\nAPI 稳态 2 × 3 × (5 + 0) = 30\n发布峰值按 4 × 3 × 5 = 60 估算\n峰值总需求 60 + 20 + 10 = 90 < 100",
  700. "lang": "text",
  701. "expected": "预算表清楚列出稳态 30、发布峰值 60,连同其他服务和预留共 90,小于本例上限 100。",
  702. "explanation": "每个进程都有自己的池,所以实例数、worker 数和池大小要相乘。滚动发布旧新进程并存时可能比稳态占更多连接,不能只看单个池的配置。"
  703. },
  704. {
  705. "title": "读取应用连接实际参数",
  706. "body": "第2课make_engine统一初始化。把scripts/probe.py的probe函数临时替换为下列函数并运行,确认连接池中借出的连接参数正确;mysql客户端自己的SESSION值不代表API连接。",
  707. "code": "async def probe():\n async with SessionFactory.begin() as db:\n row = (await db.execute(text(\"\"\"\n SELECT @@session.transaction_isolation AS isolation_level,\n @@session.time_zone AS time_zone,\n @@session.innodb_lock_wait_timeout AS row_lock_seconds,\n @@session.lock_wait_timeout AS metadata_lock_seconds,\n @@session.max_execution_time AS select_milliseconds\n \"\"\"))).mappings().one()\n return dict(row)",
  708. "lang": "python",
  709. "filename": "scripts/probe.py",
  710. "expected": "probe 输出 READ-COMMITTED、+00:00、行锁 2 秒、元数据锁 3 秒、SELECT 3000 毫秒。",
  711. "explanation": "这些值来自应用真正借出的连接,另开 mysql 客户端的设置不会自动改变 API 的连接。不同超时控制不同等待阶段,不是一项参数覆盖所有 SQL。"
  712. },
  713. {
  714. "title": "为池耗尽提供明确响应",
  715. "body": "添加到 app/main.py。只映射 SQLAlchemy 的池等待 TimeoutError;SQL 执行取消与数据库断连需要分别记录和处理,不能都笼统重试。请求日志应包含 request_id、延迟、异常类别,排除参数与连接串。",
  716. "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\"})",
  717. "lang": "python",
  718. "filename": "app/main.py",
  719. "expected": "app/main.py 注册 PoolTimeout 处理器,对池等待耗尽返回 503 和 Retry-After: 1,响应不包含连接串或查询参数。",
  720. "explanation": "池超时意味着在预算内没有借到可用连接,查询可能尚未开始。把它译成容量不足,让调用者知道稍后再试;数据库断连和执行错误要另行分类。"
  721. },
  722. {
  723. "title": "在独立单连接池复现竞争",
  724. "body": "新建scripts/pool_drill.py,运行python -m scripts.pool_drill。仅连接本机练习库;单次SLEEP0.5秒低于SELECT预算,争抢同一池的第二个任务应在0.1秒池等待超时,之后连接可再次使用。",
  725. "code": "import asyncio\nfrom sqlalchemy import text\nfrom sqlalchemy.exc import TimeoutError\nfrom app.config import settings\nfrom app.db import make_engine\n\nasync def main():\n engine = make_engine(settings.database_url, pool_size=1, max_overflow=0, pool_timeout=0.1)\n async def hold():\n async with engine.begin() as conn:\n await conn.execute(text(\"SELECT 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 asyncio.run(main())",
  726. "lang": "python",
  727. "filename": "scripts/pool_drill.py",
  728. "expected": "python -m scripts.pool_drill 打印 one rejected; pool recovered;两个竞争者中恰有一个超时,后续 SELECT 1 成功。",
  729. "explanation": "单连接被第一项短暂占用,第二项先耗尽等待预算。第一项退出归还连接后又可使用,证明拒绝一项请求不必破坏整个池。"
  730. },
  731. {
  732. "title": "通过两会话观察行锁等待",
  733. "body": "在mysql会话A锁定一条本次练习商品。API在另一连接PATCH同一行,约2秒触发行锁等待超时;不要让HTTP重试形成风暴。观察后立刻在A执行ROLLBACK,确认后续请求恢复。",
  734. "code": "START TRANSACTION;\nSELECT id FROM products WHERE id = 1 FOR UPDATE;\n-- 将1替换成本次专用商品id;另一终端PATCH这条商品。\n-- 完成观测后在当前会话执行:\nROLLBACK;",
  735. "lang": "sql",
  736. "expected": "会话 A 持锁时 API 更新约两秒后行锁等待失败;在 A 执行 ROLLBACK 后新更新可成功。",
  737. "explanation": "此时连接已经借到,只是在等另一个事务释放同一行。超时后的业务应完整回滚,避免半笔工作带着锁继续占资源。"
  738. },
  739. {
  740. "title": "用诊断账号找阻塞关系",
  741. "body": "在锁仍被持有时,由本机运维账号查询。data_lock_waits关联申请锁和阻塞锁,metadata_locks用于DDL元数据锁;不把Performance Schema诊断权限广泛授给API账号。恢复后确认等待关系消失。",
  742. "code": "SELECT requesting_thread_id, blocking_thread_id,\n requesting_engine_transaction_id, blocking_engine_transaction_id\nFROM performance_schema.data_lock_waits;\nSELECT object_schema, object_name, lock_type, lock_status, owner_thread_id\nFROM performance_schema.metadata_locks\nWHERE object_schema IN ('shop_lab', 'shop_lab_migration');",
  743. "lang": "sql",
  744. "expected": "锁等待发生时诊断表显示 requesting 与 blocking 的对应线程;解除 A 的事务后本次等待关系消失。",
  745. "explanation": "把等待者和阻塞者关联起来,才能定位是谁没有结束事务。元数据锁用于表结构等操作,与商品行锁不同;诊断权限仍由专用账号持有。"
  746. }
  747. ],
  748. "checks": [
  749. "连接预算包含发布峰值、任务进程和诊断余量。",
  750. "pool_drill只有一个任务超时,随后SELECT1成功。",
  751. "能用实际SESSION值和诊断表区分池等待、行锁等待、元数据锁和SELECT执行预算。"
  752. ],
  753. "pitfalls": [
  754. {
  755. "symptom": "设置max_execution_time后UPDATE仍没有通用执行截止",
  756. "cause": "该变量仅适用于特定只读SELECT。",
  757. "fix": "对DML控制事务长度和批量,设置行锁/元数据锁预算;客户端超时不证明写入已回滚。"
  758. },
  759. {
  760. "symptom": "捕获1205后继续提交导致半成品业务",
  761. "cause": "锁超时默认可能只回滚当前语句。",
  762. "fix": "本服务直接让异常退出begin并回滚整项业务,恢复后用新事务重试。"
  763. }
  764. ],
  765. "exercise": {
  766. "task": "把一个事务重试3次时,只设置单条SQL超时是否足够?",
  767. "hint": "业务可能执行多条SQL并经历池等待和退避。",
  768. "answer": "不够。需要总请求/任务截止时间、每次操作上限和最多重试次数。只对可安全重放的完整事务重试,外部副作用通过outbox或幂等协议处理。"
  769. },
  770. "refs": [
  771. {
  772. "label": "SQLAlchemy:连接池",
  773. "url": "https://docs.sqlalchemy.org/en/20/core/pooling.html"
  774. },
  775. {
  776. "label": "MySQL8.4:InnoDB参数",
  777. "url": "https://dev.mysql.com/doc/refman/8.4/en/innodb-parameters.html#sysvar_innodb_lock_wait_timeout"
  778. },
  779. {
  780. "label": "MySQL8.4:SELECT时间限制",
  781. "url": "https://dev.mysql.com/doc/refman/8.4/en/server-system-variables.html#sysvar_max_execution_time"
  782. },
  783. {
  784. "label": "MySQL8.4:data_lock_waits",
  785. "url": "https://dev.mysql.com/doc/refman/8.4/en/performance-schema-data-lock-waits-table.html"
  786. }
  787. ],
  788. "production": {
  789. "scenario": "服务扩容或慢事务阻塞时,数据库等待应有边界且可以定位。",
  790. "decisions": [
  791. "按进程和发布峰值分配连接。",
  792. "异常退出后回滚整项业务,不在失败事务里继续写。"
  793. ],
  794. "failureDrill": "独立池竞争和两连接锁等待分别演练,恢复后核对新查询与写入均成功。",
  795. "acceptance": [
  796. "故障不会泄漏唯一池连接。",
  797. "诊断能定位具体阻塞关系,不盲目增加池容量。"
  798. ]
  799. },
  800. "intro": {
  801. "scenario": "请求变多后,数据库连接可能先用完。先算清每个进程会占多少,再用一个连接的小池看超时及恢复,区分“没借到连接”和“等行锁”。",
  802. "startingPoint": [
  803. "已完成本模块第 02–05 课;在 shop-service 保留 scripts/probe.py、make_engine 和一条专用商品。准备 API、MySQL 持锁及诊断三个终端,先确认 /ready 正常。",
  804. "沿用数据库模块的 Linux/WSL2 Bash 与同一个 shop-service 目录;每个新终端先 cd 到该目录,再 source .venv/bin/activate,用 python -c \"import sys; print(sys.executable)\" 确认路径为 shop-service/.venv/bin/python。不要切换到 PowerShell 复用它,也不要复制 Windows 的 .venv。第 01 课允许不存在时先创建;之后 SQL 只粘贴到 mysql 客户端。",
  805. "无需另外安装宿主 MySQL 客户端:在另一个 Bash 运行 docker exec -it shop-mysql mysql --protocol=TCP -h 127.0.0.1 -P 3306 -u app_user -p shop_lab,输入本机已有密码;出现 mysql> 后再执行课内 SQL。"
  806. ],
  807. "outcome": "能核对连接参数、稳定复现池等待超时并恢复,也能观察指定行的锁等待及解除。"
  808. }
  809. },
  810. {
  811. "id": "07",
  812. "group": "查询与演进",
  813. "title": "Alembic迁移、DDL边界与兼容发布",
  814. "summary": "下一版商品接口要多一个版本号。手工在不同环境改表容易漏步骤;先在新的空实验库记录建表过程,再分两次把版本列加完整。",
  815. "objectives": [
  816. "在独立空库生成、审核并执行商品与评价表的迁移基线",
  817. "分阶段添加、回填和收紧 version,核对模型与真实结构一致",
  818. "通过 ddl_probe 观察 ALTER 不随 ROLLBACK 撤销的结果"
  819. ],
  820. "concepts": [
  821. {
  822. "title": "迁移记录不等于数据库快照",
  823. "body": "完成第 8 步“复现DDL不随ROLLBACK撤销”后,再回顾这条规则:模型描述目标结构,revision描述演进步骤,autogenerate只产生待审核差异。MySQL很多DDL会隐式提交;单条原子DDL保证故障时该语句的一致性,不代表整份多语句revision可由ROLLBACK撤销。不能把版本号尚未推进当作表结构完全未变。"
  824. },
  825. {
  826. "title": "独立空库实验",
  827. "body": "完成第 4 步“新建实验库并授权准确账号”后,再回顾这条规则:基础shop_lab可能已由数据库模块新增version、检查约束和索引。本课固定新建shop_lab_migration,API切换到新库完成第7–11课,原库保留不动。不对已有库执行stamp或重复ADD version;接管真实现有库需单独盘点SHOW CREATE TABLE和迁移计划。"
  828. },
  829. {
  830. "title": "Expand–Contract",
  831. "body": "完成第 7 步“有界回填再生成contract迁移”后,再回顾这条规则:先新增兼容字段,旧新应用共存;按主键有界回填并验收;最后收紧约束或删除旧字段。MySQL8.4部分ALTER支持INSTANT/INPLACE,但支持范围取决于具体操作、表结构和算法,不能承诺所有DDL无锁。明确ALGORITHM/LOCK并在预发布验证,失败时停止而不是默默退回长时间拷表。"
  832. }
  833. ],
  834. "steps": [
  835. {
  836. "title": "初始化异步Alembic环境",
  837. "body": "在shop-service根目录执行;异步模板可用asyncmy Engine执行迁移,不需要平台专用事件循环策略。应用启动不运行create_all或自动迁移,发布时单独执行。",
  838. "code": "python -m alembic init -t async migrations",
  839. "lang": "bash",
  840. "expected": "生成 migrations/、versions/ 和 alembic.ini,已有应用仍未自动执行建表;重复学习不再次初始化同一目录。",
  841. "explanation": "Alembic 用按顺序编号的脚本记录表结构变化。应用进程负责接请求,迁移命令负责改变结构,分开执行能明确部署时发生了什么。"
  842. },
  843. {
  844. "title": "设置metadata与连接参数",
  845. "body": "在migrations/env.py导入下面内容替换target_metadata=None。保留模板run_sync和NullPool。另在模板async_engine_from_config调用中加入connect_args=MYSQL_CONNECT_ARGS,使UTC与锁等待设置一致;不得把数据库密码写进alembic.ini。",
  846. "code": "from app.config import settings\nfrom app.models import Base\nfrom app.db import MYSQL_CONNECT_ARGS\n\ntarget_metadata = Base.metadata\nconfig.set_main_option(\"sqlalchemy.url\", settings.database_url.replace(\"%\", \"%%\"))\n\n# 在模板run_async_migrations中替换原connectable创建语句:\n# connectable = async_engine_from_config(\n# config.get_section(config.config_ini_section, {}),\n# prefix=\"sqlalchemy.\", poolclass=pool.NullPool,\n# connect_args=MYSQL_CONNECT_ARGS,\n# )",
  847. "lang": "python",
  848. "filename": "migrations/env.py",
  849. "expected": "migrations/env.py 的 target_metadata 指向 Base.metadata,连接地址读取配置,模板保留 run_sync/NullPool 及本课连接参数。",
  850. "explanation": "metadata 是 Python 里集中登记的表结构。迁移工具拿它与数据库对照生成候选变化,候选仍需要人工检查,不能把密码写入公共配置文件。"
  851. },
  852. {
  853. "title": "把审计表补入metadata",
  854. "body": "追加app/models.py。",
  855. "code": "from sqlalchemy import Column, Table, UniqueConstraint\nfrom sqlalchemy.dialects.mysql import VARCHAR\n\nproduct_audits = Table(\"product_audits\", Base.metadata,\n Column(\"id\", BigInteger, primary_key=True, autoincrement=True),\n Column(\"product_id\", BigInteger, nullable=False),\n Column(\"action\", String(40), nullable=False),\n Column(\"request_key\", VARCHAR(100, charset=\"ascii\", collation=\"ascii_bin\"), nullable=False),\n Column(\"created_at\", DATETIME(fsp=6), nullable=False, server_default=text(\"CURRENT_TIMESTAMP(6)\")),\n UniqueConstraint(\"request_key\", name=\"uq_product_audits_request\"),\n mysql_engine=\"InnoDB\", mysql_charset=\"utf8mb4\",\n)",
  856. "lang": "python",
  857. "filename": "app/models.py",
  858. "expected": "本步先保存定义,不会自动产生运行输出;调用后的观察在后续验证步骤完成。Base.metadata 新增 product_audits,字段、命名唯一键、字符集与已有审计 DDL 一致;Product 暂未加入 version。",
  859. "explanation": "自动比较只能看见已经登记的表,漏登审计表就可能生成错误差异。先把起点登记完整,才能有可信的迁移基线。 此时Product模型尚未包含version。所有metadata表与基线一致,包括字符集、引擎、命名唯一键;MySQL迁移自动比对仍需审核类型、默认值与索引差异。"
  860. },
  861. {
  862. "title": "新建实验库并授权准确账号",
  863. "body": "本机管理员客户端入口为 docker exec -it shop-mysql mysql --protocol=TCP -h 127.0.0.1 -P 3306 -u root -p;仅在本课本机专用实例输入管理员密码并执行下面建库授权 SQL。用本机管理员mysql交互连接执行。先SELECT CURRENT_USER核对app_user实际匹配host;示例账号为数据库模块已创建的app_user@%,若你的账号host不同则使用那个已存在账号,GRANT不创建新用户。这里只授予新实验库DDL/DML,禁止使用ON *.*。已有同名库时停止核对来源,不删库重建。",
  864. "code": "CREATE DATABASE shop_lab_migration CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;\nGRANT SELECT, INSERT, UPDATE, DELETE, CREATE, ALTER, INDEX, DROP, REFERENCES\nON shop_lab_migration.* TO 'app_user'@'%';\nSHOW GRANTS FOR 'app_user'@'%';",
  865. "lang": "sql",
  866. "expected": "shop_lab_migration 创建成功,SHOW GRANTS 显示应用账号仅对这个新实验库有约定权限;原 shop_lab 未被删除或覆盖。",
  867. "explanation": "空库让我们从第一份基线练习完整升级链,避免与之前已有的 version 和索引冲突。GRANT 针对准确库和已存在账号,不扩大到所有数据库。"
  868. },
  869. {
  870. "title": "切换配置并从空库生成基线",
  871. "body": "将.env连接串数据库名改为shop_lab_migration,保留原shop_lab值在本机未提交备份中。在当前 Bash 用 unset 清除可能覆盖 .env 的 DATABASE_URL后运行下列命令。先审核生成文件只创建products/comments/product_audits,再upgrade;成功后重启API并创建新练习数据,第7–11课一直使用新库。商品表基线必须包含 price_cents 和 ck_products_price_nonnegative;对照 ORM 与 SHOW CREATE TABLE 检查默认值和 CHECK,不仅检查表名。同时保留 products_is_active_boolean 与 products_description_limit,防止切到迁移库后丢失已约定的数据边界。",
  872. "code": "unset DATABASE_URL\n# .env: mysql+asyncmy://app_user:ENCODED_PASSWORD@127.0.0.1:3307/shop_lab_migration?charset=utf8mb4\npython -m alembic revision --autogenerate -m \"baseline products comments audits\"\npython -m alembic upgrade head\npython -m alembic check\npython -m alembic current",
  873. "lang": "bash",
  874. "expected": "审核后 upgrade 完成,alembic check 无新差异,current 指向基线;API 重启后新建商品出现在 shop_lab_migration。",
  875. "explanation": "基线描述从空库建出的已知结构。切换配置后要同时核对 API 和 SQL 所在库,避免拿旧库数据判断新迁移是否成功。"
  876. },
  877. {
  878. "title": "分两版扩展并收紧version",
  879. "body": "先生成expand revision,填入下列upgrade函数并保留生成的revision/down_revision/import。同步在Product类添加version: Mapped[int | None] = mapped_column(Integer, nullable=True, server_default=text(\"1\"))并导入Integer。原shop_lab已存在version也不影响本新库实验。此DDL执行成功后即可能独立持久化,不能靠外层事务撤销。",
  880. "code": "def upgrade():\n op.execute(\"SET SESSION lock_wait_timeout = 3\")\n op.add_column(\"products\", sa.Column(\"version\", sa.Integer(), nullable=True, server_default=sa.text(\"1\")))\n\ndef downgrade():\n op.drop_column(\"products\", \"version\")",
  881. "lang": "python",
  882. "filename": "migrations/versions/<revision>_expand_version.py",
  883. "expected": "新 expand revision 包含添加可空 version 的动作,应用升级后 SHOW CREATE TABLE 可见该列和默认 1。",
  884. "explanation": "先允许新列为空,是给旧数据与旧程序一个兼容过渡阶段。revision 的编号和上一个编号把执行顺序连起来,不能把示例函数单独当完整迁移文件。"
  885. },
  886. {
  887. "title": "有界回填再生成contract迁移",
  888. "body": "先upgrade扩展版本,再在新库mysql客户端重复下列UPDATE,每批单独COMMIT,直到影响0行。随后新建revision用op.alter_column给version加NOT NULL,明确existing_type/default并保留默认1;ORM改为Mapped[int]。",
  889. "code": "UPDATE products SET version = 1 WHERE version IS NULL ORDER BY id LIMIT 1000;\nCOMMIT;\nSELECT COUNT(*) AS remaining FROM products WHERE version IS NULL;\n-- 下一份revision中的upgrade:\n-- op.alter_column(\"products\", \"version\", existing_type=sa.Integer(),\n-- existing_server_default=sa.text(\"1\"), existing_nullable=True, nullable=False)",
  890. "lang": "sql",
  891. "expected": "分批 UPDATE 后 remaining=0;收紧迁移执行后 version 为 NOT NULL DEFAULT 1,ORM 也改为非空整数。",
  892. "explanation": "先消除旧空值再要求非空,叫扩展后收紧。每批提交限制单次工作规模,结构收紧仍要单独评估表锁与执行时间。 大表需在预发布验证ALTER算法、元数据锁等待和耗时。"
  893. },
  894. {
  895. "title": "复现DDL不随ROLLBACK撤销",
  896. "body": "仅在shop_lab_migration执行独立ddl_probe表实验,不放进业务事务或真实迁移。ROLLBACK后新增列仍在;完成后可只删除这张明确命名、无业务数据的实验表。若真实revision第二条DDL失败,先SHOW CREATE TABLE/current检查部分应用,再选择修复脚本,禁止盲目重跑或stamp。",
  897. "code": "CREATE TABLE ddl_probe (id BIGINT PRIMARY KEY) ENGINE=InnoDB;\nSTART TRANSACTION;\nALTER TABLE ddl_probe ADD COLUMN marker INT NULL;\nROLLBACK;\nSHOW CREATE TABLE ddl_probe;\nDROP TABLE ddl_probe;",
  898. "lang": "sql",
  899. "expected": "ROLLBACK 后 SHOW CREATE TABLE ddl_probe 仍包含 marker 列;只删除这张本节实验表。",
  900. "explanation": "MySQL 的 DDL 会隐式提交,回滚业务事务不能撤销已改的结构。因此迁移失败要查真实结构和版本记录,不能只因版本号没推进就断言什么也没改。"
  901. }
  902. ],
  903. "checks": [
  904. "shop_lab_migration从空库建立完整基线,原shop_lab未执行本课DDL或stamp。",
  905. "回填有界,NOT NULL前空值数量为0,ORM与实际结构相符。",
  906. "DDL演练确认ALTER不随ROLLBACK撤销;迁移失败检查真实结构和revision而非只看退出码。"
  907. ],
  908. "pitfalls": [
  909. {
  910. "symptom": "migration失败后重跑出现Duplicate column",
  911. "cause": "前一条DDL已隐式提交,版本表尚未推进。",
  912. "fix": "先核对SHOW CREATE TABLE和生成SQL,制定准确恢复/修复步骤,不能删业务列来让脚本碰巧重跑。"
  913. },
  914. {
  915. "symptom": "自动生成把改名当成删列加列",
  916. "cause": "工具不理解数据迁移意图。",
  917. "fix": "改写为兼容新增、回填验证、应用切换和后续删除,明确回退窗口。"
  918. }
  919. ],
  920. "exercise": {
  921. "task": "MySQL的atomic DDL是否表示一份含三条ALTER的revision会一起提交或一起回滚?",
  922. "hint": "区分单条DDL崩溃一致性与多语句事务。",
  923. "answer": "不表示。每条DDL可能独立隐式提交;第三条失败时前两条可能已生效。需要检查实际结构并按修复计划处理,不能只执行ROLLBACK或根据alembic_version猜测结构。"
  924. },
  925. "refs": [
  926. {
  927. "label": "MySQL8.4:隐式提交语句",
  928. "url": "https://dev.mysql.com/doc/refman/8.4/en/implicit-commit.html"
  929. },
  930. {
  931. "label": "MySQL8.4:原子DDL",
  932. "url": "https://dev.mysql.com/doc/refman/8.4/en/atomic-ddl.html"
  933. },
  934. {
  935. "label": "MySQL8.4:在线DDL",
  936. "url": "https://dev.mysql.com/doc/refman/8.4/en/innodb-online-ddl.html"
  937. },
  938. {
  939. "label": "Alembic:异步迁移",
  940. "url": "https://alembic.sqlalchemy.org/en/latest/cookbook.html#using-asyncio-with-alembic"
  941. }
  942. ],
  943. "production": {
  944. "scenario": "新旧版本短暂共存,数据库结构更新必须可验证且有失败修复方案。",
  945. "decisions": [
  946. "DDL单独执行,不混入业务DML事务。",
  947. "先兼容扩展与有界回填,再收紧约束,已有库接管另行规划。"
  948. ],
  949. "failureDrill": "在独立实验库执行ddl_probe的ALTER后ROLLBACK,确认结构已改变;再在预发布演练元数据锁超时。",
  950. "acceptance": [
  951. "不把失败迁移误当全部回滚。",
  952. "原库未被重复添加version,实验库目标清楚。"
  953. ]
  954. },
  955. "intro": {
  956. "scenario": "下一版商品接口要多一个版本号。手工在不同环境改表容易漏步骤;先在新的空实验库记录建表过程,再分两次把版本列加完整。",
  957. "startingPoint": [
  958. "已完成本模块第 01–06 课,现有 shop_lab 保留原练习数据;在 shop-service 根目录确认 app/models.py 含商品、评价映射,本课另建 shop_lab_migration,第 07–11 课持续使用它。",
  959. "沿用数据库模块的 Linux/WSL2 Bash 与同一个 shop-service 目录;每个新终端先 cd 到该目录,再 source .venv/bin/activate,用 python -c \"import sys; print(sys.executable)\" 确认路径为 shop-service/.venv/bin/python。不要切换到 PowerShell 复用它,也不要复制 Windows 的 .venv。第 01 课允许不存在时先创建;之后 SQL 只粘贴到 mysql 客户端。",
  960. "第 07 课新库创建后,SQL 终端用 docker exec -it shop-mysql mysql --protocol=TCP -h 127.0.0.1 -P 3306 -u app_user -p shop_lab_migration;这是容器内 3306,Python 宿主连接仍使用 3307。"
  961. ],
  962. "outcome": "新库可按迁移记录建出相同结构,version 完成添加、回填和非空收紧;亲眼看到 DDL 不随普通回滚撤销。"
  963. }
  964. },
  965. {
  966. "id": "08",
  967. "group": "查询与演进",
  968. "title": "Keyset 分页与数据库错误映射",
  969. "summary": "商品越来越多,列表需要一页页读取。让客户端带上上一页最后一件商品的位置继续取,同时把非法游标与数据库错误区分开。",
  970. "objectives": [
  971. "让商品按创建时间与 id 排序,并用上一页游标继续读取",
  972. "验证每页数量上限、最后一页标记和非法游标的 422",
  973. "为非空白名称建立数据库约束,将已知约束错误与未知故障分开响应"
  974. ],
  975. "concepts": [
  976. {
  977. "title": "稳定复合排序",
  978. "body": "完成第 3 步“新增有界商品分页接口”后,再回顾这条规则:按created_at DESC,id DESC排序,下一页读取时间更早或同一时间id更小的行。时间精度为微秒,id解决相同时间的排序歧义。MySQL行构造器比较有语义支持,但本课用OR展开便于观察复合索引计划;是否走理想范围仍需EXPLAIN ANALYZE。"
  979. },
  980. {
  981. "title": "游标与UTC",
  982. "body": "完成第 2 步“新建UTC游标模块”后,再回顾这条规则:游标编码带时区UTC时间及id,入口限制长度、验证字段。构建SQL前将时间转换为UTC无时区datetime,匹配DATETIME(6)存储契约。编码不是权限或加密,分页请求各自事务不是跨请求一致快照。"
  983. },
  984. {
  985. "title": "数据库错误是结构化决策输入",
  986. "body": "完成第 5 步“映射已知检查错误,未知错误保留服务端分类”后,再回顾这条规则:MySQL errno1062代表重复键,3819代表CHECK失败,1452代表外键缺失,不能全部映射409。只有确定的约束与业务路径映射客户端错误;未知错误返回脱敏500并记录错误类别,不回显SQL参数和数据库消息。"
  987. }
  988. ],
  989. "steps": [
  990. {
  991. "title": "添加复合索引并同步metadata",
  992. "body": "新建Alembic revision,填入函数;在Product.__table_args__增加Index(\"ix_products_created_id\",\"created_at\",\"id\"),元组最后保留mysql_engine/mysql_charset字典。添加 Index 时保留第 3 课已有的三条 CheckConstraint。",
  993. "code": "def upgrade():\n op.create_index(\"ix_products_created_id\", \"products\", [\"created_at\", \"id\"])\n\ndef downgrade():\n op.drop_index(\"ix_products_created_id\", table_name=\"products\")",
  994. "lang": "python",
  995. "filename": "migrations/versions/<revision>_products_cursor_index.py",
  996. "expected": "新迁移执行后 SHOW INDEX 可见 ix_products_created_id,metadata 保留三条原 CHECK 与该索引;alembic check 无遗漏差异。",
  997. "explanation": "仅按时间排序时,同一时刻创建的商品可能分不清先后。再加唯一 id 作为第二顺序,把每件商品的位置确定下来,复合索引配合这条访问路径。 MySQL可反向扫描普通索引满足两个字段同向降序;实际效果用EXPLAIN验证。"
  998. },
  999. {
  1000. "title": "新建UTC游标模块",
  1001. "body": "新建app/cursors.py。服务生成的时间若无时区,依据本模块UTC存储契约补标记;客户端传入带其他偏移的有效时间先转UTC再用于SQL,不把offset直接丢弃。",
  1002. "code": "import base64\nimport binascii\nfrom datetime import timezone\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, product_id):\n if created_at.tzinfo is None:\n created_at = created_at.replace(tzinfo=timezone.utc)\n raw = Cursor(created_at=created_at.astimezone(timezone.utc), id=product_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",
  1003. "lang": "python",
  1004. "filename": "app/cursors.py",
  1005. "expected": "本步先保存定义,不会自动产生运行输出;调用后的观察在后续验证步骤完成。app/cursors.py 定义 encode/decode;编码包含 UTC 时间和正整数 id,非法 Base64 或字段值会成为 422 invalid cursor。",
  1006. "explanation": "游标是上一页最后位置的一张小便条。Base64 方便把结构放进 URL,不是加密;解码后仍要检查字段类型和时区,才能安全转成查询条件。"
  1007. },
  1008. {
  1009. "title": "新增有界商品分页接口",
  1010. "body": "追加app/routes.py。输出DTO在事务内构造,关系属性不被隐式读取。",
  1011. "code": "from sqlalchemy import and_, or_, select\nfrom app.cursors import decode_cursor, encode_cursor\n\n@router.get(\"\")\nasync def list_products(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(Product).order_by(Product.created_at.desc(), Product.id.desc()).limit(limit + 1)\n if cursor:\n key = decode_cursor(cursor)\n at = key.created_at.astimezone(timezone.utc).replace(tzinfo=None)\n stmt = stmt.where(or_(Product.created_at < at,\n and_(Product.created_at == at, Product.id < key.id)))\n async with db.begin():\n rows = (await db.scalars(stmt)).all()\n visible = rows[:limit]\n result = {\"items\": [ProductOut.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",
  1012. "lang": "python",
  1013. "filename": "app/routes.py",
  1014. "expected": "GET /products?limit=1 返回至多一件与 next_cursor,继续带该值翻页;最后一页 next_cursor=null,坏游标返回 422。",
  1015. "explanation": "条件先比较 created_at,再在时间相等时比较 id,只取位置更后的记录,这叫 Keyset 分页。多读一条只用来判断是否还有下一页,不把它提前返回。 每页多读一条判断是否还有下一页;从游标转为UTC后去掉tzinfo传给MySQL DATETIME。"
  1016. },
  1017. {
  1018. "title": "新增数据库非空白名称约束",
  1019. "body": "先用SELECT查找并在练习库处理已有空白名称,再生成迁移。MySQL8.4支持CHECK,但本课直接添加已执行约束;失败时原表未新增该约束,检查实际结构后再修数据重跑。将名称约束追加到现有元组中,保留价格、上架状态、描述长度三条 CHECK 和复合索引,不覆盖整个 __table_args__。",
  1020. "code": "def upgrade():\n op.create_check_constraint(\"ck_products_name_nonblank\", \"products\", \"CHAR_LENGTH(TRIM(name)) > 0\")\n\ndef downgrade():\n op.drop_constraint(\"ck_products_name_nonblank\", \"products\", type_=\"check\")",
  1021. "lang": "python",
  1022. "filename": "migrations/versions/<revision>_products_name_check.py",
  1023. "expected": "先核对并处理练习库空白名称后,迁移成功新增 ck_products_name_nonblank;若旧数据不满足,迁移失败且需核对真实结构。",
  1024. "explanation": "输入模型保护正常 API 请求,CHECK 保护直接 SQL 等其他写入入口。把约束加到数据库之前先处理已有坏数据,才能让规则真正建立。 同步Product.__table_args__的CheckConstraint与已有索引及引擎字典。"
  1025. },
  1026. {
  1027. "title": "映射已知检查错误,未知错误保留服务端分类",
  1028. "body": "添加app/main.py。asyncmy把CHECK错误3819归入OperationalError,而重复键通常为IntegrityError;因此在共同DBAPIError边界读取errno,再结合已知约束名映射422。未知数据库错误保留脱敏500,只记录数值errno和请求路径,不记录str(exc)。",
  1029. "code": "import logging\nfrom fastapi import Request\nfrom fastapi.responses import JSONResponse\nfrom sqlalchemy.exc import DBAPIError\n\nlogger = logging.getLogger(\"shop-service\")\n\n@app.exception_handler(DBAPIError)\nasync def database_handler(request: Request, exc: DBAPIError):\n args = getattr(exc.orig, \"args\", ())\n errno = args[0] if args else None\n if errno == 3819 and \"ck_products_name_nonblank\" in str(args[1:]):\n return JSONResponse(status_code=422, content={\"detail\": \"name must not be blank\"})\n if errno == 3819 and \"ck_products_price_nonnegative\" in str(args[1:]):\n return JSONResponse(status_code=422, content={\"detail\": \"price_cents must be nonnegative\"})\n logger.error(\"database constraint failure\", extra={\"mysql_errno\": errno, \"path\": request.url.path})\n return JSONResponse(status_code=500, content={\"detail\": \"database write failed\"})",
  1030. "lang": "python",
  1031. "filename": "app/main.py",
  1032. "expected": "已知 3819 名称/价格约束错误映射为脱敏 422,未知 DBAPIError 返回脱敏 500;正常列表与商品创建仍可使用。",
  1033. "explanation": "驱动异常类型不完全等于业务含义,需结合 MySQL 数值错误码和已知约束判断。未知故障保留服务端错误分类,避免把配置或程序故障误说成用户参数问题。 审计端点仍在自身事务外处理已知重复键。同时映射基线已有的价格非负 CHECK;API 自身已拒绝非法价格,数据库约束负责守住绕过 API 的写入路径。"
  1034. }
  1035. ],
  1036. "checks": [
  1037. "相同created_at的静态记录分页无重复和遗漏,最后页next_cursor=null。",
  1038. "非法长度/格式游标和limit=101返回422,合法时区偏移转换后定位相同UTC时刻。",
  1039. "直接SQL空白名称触发已命名CHECK,API错误不包含原始SQL及参数。"
  1040. ],
  1041. "pitfalls": [
  1042. {
  1043. "symptom": "游标跨时区后翻页错位",
  1044. "cause": "直接丢掉客户端时间偏移而未先转UTC。",
  1045. "fix": "先astimezone(timezone.utc),再转换MySQL无时区DATETIME参数。"
  1046. },
  1047. {
  1048. "symptom": "只捕获IntegrityError导致CHECK错误漏映射",
  1049. "cause": "asyncmy把3819 CHECK失败归为OperationalError,不能仅依赖Python异常子类判断业务错误。",
  1050. "fix": "在DBAPIError边界结合数值errno与确认过的约束名分类;未知错误保持500并记录脱敏信息。"
  1051. }
  1052. ],
  1053. "exercise": {
  1054. "task": "分页时能否保证第一屏到最后一屏来自同一个数据库快照?",
  1055. "hint": "每个 HTTP 请求有自己的事务。",
  1056. "answer": "本课不能保证跨请求快照。若产品需要稳定导出,可创建导出作业并在合适的一致性边界生成文件,或定义快照时间与数据版本规则;不要保持一个数据库事务等待用户慢慢翻页。"
  1057. },
  1058. "refs": [
  1059. {
  1060. "label": "MySQL8.4:范围优化",
  1061. "url": "https://dev.mysql.com/doc/refman/8.4/en/range-optimization.html"
  1062. },
  1063. {
  1064. "label": "MySQL8.4:CHECK约束",
  1065. "url": "https://dev.mysql.com/doc/refman/8.4/en/create-table-check-constraints.html"
  1066. },
  1067. {
  1068. "label": "MySQL服务端错误参考",
  1069. "url": "https://dev.mysql.com/doc/mysql-errors/8.4/en/server-error-reference.html"
  1070. }
  1071. ],
  1072. "production": {
  1073. "scenario": "商品数据增长后深分页变慢,另有导入路径绕过API写入。",
  1074. "decisions": [
  1075. "游标排序稳定且页大小有界。",
  1076. "存储约束与应用错误映射都经过实际验证。"
  1077. ],
  1078. "failureDrill": "在实验库构造相同微秒时间记录并逐页收集id,再发送非法游标和空白名称。",
  1079. "acceptance": [
  1080. "分页结果完整且稳定。",
  1081. "错误分类准确,不泄露数据库消息。"
  1082. ]
  1083. },
  1084. "intro": {
  1085. "scenario": "商品越来越多,列表需要一页页读取。让客户端带上上一页最后一件商品的位置继续取,同时把非法游标与数据库错误区分开。",
  1086. "startingPoint": [
  1087. "已完成本模块第 07 课,API 与迁移都指向 shop_lab_migration;在 shop-service 确认 alembic current 为最新、/ready 正常,并用 /docs 创建少量新商品供翻页。",
  1088. "沿用数据库模块的 Linux/WSL2 Bash 与同一个 shop-service 目录;每个新终端先 cd 到该目录,再 source .venv/bin/activate,用 python -c \"import sys; print(sys.executable)\" 确认路径为 shop-service/.venv/bin/python。不要切换到 PowerShell 复用它,也不要复制 Windows 的 .venv。第 01 课允许不存在时先创建;之后 SQL 只粘贴到 mysql 客户端。",
  1089. "第 07 课新库创建后,SQL 终端用 docker exec -it shop-mysql mysql --protocol=TCP -h 127.0.0.1 -P 3306 -u app_user -p shop_lab_migration;这是容器内 3306,Python 宿主连接仍使用 3307。"
  1090. ],
  1091. "outcome": "列表按创建时间和 id 稳定翻页;坏游标返回 422,数据库也拒绝空白名称并给出明确分类反馈。"
  1092. }
  1093. },
  1094. {
  1095. "id": "09",
  1096. "group": "一致性与验收",
  1097. "title": "并发更新:版本条件与行锁",
  1098. "summary": "两个人都打开同一件商品,后保存的人可能覆盖先保存的内容。让每次编辑带上自己读到的版本,再观察旧页面怎样收到冲突。",
  1099. "objectives": [
  1100. "为修改和删除请求加入读到的 version 条件",
  1101. "模拟两个页面编辑同一商品,验证一次成功、一次 409 且内容未被覆盖",
  1102. "用短事务行锁实现一次性上架,避免重复调用反复增加版本"
  1103. ],
  1104. "concepts": [
  1105. {
  1106. "title": "版本表示读到的状态",
  1107. "body": "完成第 4 步“用两个客户端快照复现冲突”后,再回顾这条规则:两个客户端都读到 version=1,第一个写入后版本变为 2,第二个仍提交 1 应被拒绝。检查与写入必须在同一条 SQL 的 WHERE 中完成;先 SELECT 检查再无条件 UPDATE 仍有竞争窗口。"
  1108. },
  1109. {
  1110. "title": "所有写路径必须遵守协议",
  1111. "body": "完成第 3 步“让删除同样携带版本”后,再回顾这条规则:仅新增一个安全 PATCH 不够:旧 PATCH、批量脚本和管理工具若不递增版本,仍能覆盖别人修改。第 7 课扩展字段只解决结构兼容,启用强制版本前必须完成旧写路径切换并下线不安全写入口。"
  1112. },
  1113. {
  1114. "title": "行锁的用途",
  1115. "body": "完成第 5 步“比较短事务行锁”后,再回顾这条规则:SELECT FOR UPDATE 可以串行化同一行的短业务判断,例如检查状态后做多表更新。锁会持续到事务结束,不能在持锁时等待模型 API、发送邮件或用户输入。乐观控制减少等待但要求客户端处理冲突;二者可以按业务组合。"
  1116. }
  1117. ],
  1118. "steps": [
  1119. {
  1120. "title": "完成version迁移并扩展DTO",
  1121. "body": "先完成第 07 课 version 的回填与 NOT NULL 迁移。在 app/models.py 已有 Product 类内部合并 version: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text(\"1\"));在 app/schemas.py 已有 ProductOut 类内部新增 version: int,保留其余响应字段和 UTC 校验。再在同一 app/schemas.py 文件末尾追加下方 ProductVersionPatch,保留旧 ProductPatch 的价格、空值等输入校验,不重复声明已有类。保存后重启 API,在 /docs 读取一件商品,确认响应 JSON 含整数 version,再继续改写 PATCH。",
  1122. "code": "class ProductVersionPatch(ProductPatch):\n version: int = Field(gt=0)",
  1123. "lang": "python",
  1124. "filename": "app/schemas.py",
  1125. "expected": "重启后 GET /products/{实际编号} 的 JSON 含 version;ProductVersionPatch 在代码中继承原校验并要求正版本号,尚未替换旧 PATCH 路由。若响应缺 version,先检查是否确实修改了 ProductOut 再继续。",
  1126. "explanation": "版本号表示用户读到的是第几版商品。把它随修改一并送回,服务才能判断用户编辑期间是否已有其他写入。 Product类声明version: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text(\"1\"));ProductOut增加version: int。"
  1127. },
  1128. {
  1129. "title": "替换PATCH为原子条件UPDATE",
  1130. "body": "替换原patch_product,避免同方法同路径重复注册。MySQL不使用UPDATE RETURNING:先根据影响行数判断version条件是否匹配,成功后在同一事务SELECT/refresh读取结果。version必定递增,即使名称原值相同也发生真实变更;事务仍持有更新锁,其他写者不能在本次读取前修改同一行。",
  1131. "code": "from sqlalchemy import update, func\nfrom app.schemas import ProductVersionPatch\n\n@router.patch(\"/{product_id}\", response_model=ProductOut)\nasync def patch_product(product_id: int, body: ProductVersionPatch, 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 result = await db.execute(update(Product).where(\n Product.id == product_id, Product.version == body.version).values(\n **changes, version=Product.version + 1, updated_at=func.utc_timestamp(6)\n ).execution_options(synchronize_session=False))\n if result.rowcount != 1:\n raise HTTPException(409, \"product changed or was deleted; reload before editing\")\n product = await db.get(Product, product_id)\n await db.refresh(product)\n payload = ProductOut.model_validate(product)\n return payload",
  1132. "lang": "python",
  1133. "filename": "app/routes.py",
  1134. "expected": "旧 PATCH 路由被替换且只注册一次;当前版本修改返回 200 和加一的新版本,旧版本不匹配返回 409。",
  1135. "explanation": "把 id 和 version 一起写进 UPDATE 条件,让数据库在同一动作中检查并修改。rowcount=1 证明匹配成功,不先查后改,避免两步之间又被别人抢先更新。"
  1136. },
  1137. {
  1138. "title": "让删除同样携带版本",
  1139. "body": "替换原delete_product。DELETE通过rowcount判断条件是否匹配,不读取自动返回行;已经删除和版本过期统一409,由客户端重新GET判断。",
  1140. "code": "from sqlalchemy import delete\n\n@router.delete(\"/{product_id}\", status_code=204)\nasync def delete_product(product_id: int, db: Db, version: Annotated[int, Query(gt=0)]):\n async with db.begin():\n result = await db.execute(delete(Product).where(\n Product.id == product_id, Product.version == version\n ).execution_options(synchronize_session=False))\n if result.rowcount != 1:\n raise HTTPException(409, \"product changed or was deleted\")\n return Response(status_code=204)",
  1141. "lang": "python",
  1142. "filename": "app/routes.py",
  1143. "expected": "带当前版本删除返回空体 204;旧版本或已删记录返回 409,不带 version 会被参数校验拒绝。",
  1144. "explanation": "删除也是写操作,不能绕过版本协议,否则旧页面仍可能删掉别人刚改好的商品。成功与冲突都用受影响行数判断。 更新之前的CRUD验收脚本,删除时传当前版本。"
  1145. },
  1146. {
  1147. "title": "用两个客户端快照复现冲突",
  1148. "body": "在 scripts/version_probe.py 保存下面片段,运行后应得到 200 与 409。第一次写入内容应保留,不能被旧快照覆盖;创建路径可沿用 POST /products,新字段由数据库默认值赋 1。 将本步代码按标注文件位置保存或合并,完成上述前置后在当前项目终端执行 python -m scripts.version_probe,再与下方预期对照。",
  1149. "code": "import httpx\n\nwith httpx.Client(base_url=\"http://127.0.0.1:8000\", timeout=5) as client:\n product = client.post(\"/products\", json={\"name\": \"concurrency\"}).json()\n path = f\"/products/{product['id']}\"\n a = client.get(path).json()\n b = client.get(path).json()\n first = client.patch(path, json={\"name\": \"client A\", \"version\": a[\"version\"]})\n second = client.patch(path, json={\"name\": \"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[\"name\"] == \"client A\"\n assert current[\"version\"] == product[\"version\"] + 1",
  1150. "lang": "python",
  1151. "filename": "scripts/version_probe.py",
  1152. "expected": "python -m scripts.version_probe 无断言错误,第一次保存 200、第二次 409,最终名称 client A 且版本仅加一。",
  1153. "explanation": "两个独立 GET 模拟两个页面拿到同一快照;第二次失败说明旧内容没有静默覆盖新内容。客户端收到冲突后应重新读取再决定如何修改。"
  1154. },
  1155. {
  1156. "title": "比较短事务行锁",
  1157. "body": "追加app/services.py,由调用者在db.begin内执行。锁保护读取状态后决定是否上架的短逻辑,不能在锁内调用模型或其他网络服务。",
  1158. "code": "from sqlalchemy import select, func\nfrom fastapi import HTTPException\n\nasync def activate_once(db: AsyncSession, product_id: int):\n product = await db.scalar(select(Product).where(Product.id == product_id).with_for_update())\n if product is None:\n raise HTTPException(404, \"product not found\")\n if not product.is_active:\n product.is_active = True\n product.version += 1\n product.updated_at = func.utc_timestamp(6)\n await db.flush()\n await db.refresh(product)\n return ProductOut.model_validate(product)",
  1159. "lang": "python",
  1160. "filename": "app/services.py",
  1161. "expected": "本步先保存定义,不会自动产生运行输出;调用后的观察在后续验证步骤完成。activate_once 在已开启的短事务里对未上架商品设 True 并加版本;再次调用已上架商品时版本不再增加。",
  1162. "explanation": "FOR UPDATE 先锁住这一行,让读取后作决定的短逻辑有稳定依据。它适合服务端一小段判断,不能在锁里等用户操作或远程网络服务。 数据库UTC_TIMESTAMP(6)更新后refresh再生成DTO。"
  1163. }
  1164. ],
  1165. "checks": [
  1166. "两个相同版本更新只有一个成功,另一个为 409。",
  1167. "成功更新 version 恰好增加 1,内容与 updated_at 对应本次提交。",
  1168. "原 PATCH 与 DELETE 已替换,旧客户端缺 version 会失败而非静默覆盖。"
  1169. ],
  1170. "pitfalls": [
  1171. {
  1172. "symptom": "使用 ORM version_id_col 仍有批量更新绕过版本",
  1173. "cause": "ORM 版本检测主要作用于 flush 的单对象更新,批量 SQL 要自行加条件。",
  1174. "fix": "本课统一显式 WHERE version 与递增;若改用 mapper versioning,仍审计所有批量路径。"
  1175. },
  1176. {
  1177. "symptom": "锁等待导致接口成批超时",
  1178. "cause": "持锁期间执行外部请求,或多行操作锁顺序不一致。",
  1179. "fix": "缩短事务,所有路径按相同 id 顺序加锁,外部调用通过 outbox 在提交后处理。"
  1180. }
  1181. ],
  1182. "exercise": {
  1183. "task": "收到 409 后客户端可以自动读取新版本并原样重放旧编辑吗?",
  1184. "hint": "这会绕过保护覆盖别人刚写入的内容。",
  1185. "answer": "不能无条件重放。客户端重新读取并让用户合并,或只对可证明可交换的增量操作自动重试;重试必须保留业务意图,不能只换 version。"
  1186. },
  1187. "refs": [
  1188. {
  1189. "label": "MySQL8.4:锁定读取",
  1190. "url": "https://dev.mysql.com/doc/refman/8.4/en/innodb-locking-reads.html"
  1191. },
  1192. {
  1193. "label": "SQLAlchemy:MySQL rowcount",
  1194. "url": "https://docs.sqlalchemy.org/en/20/dialects/mysql.html#rowcount-support"
  1195. },
  1196. {
  1197. "label": "SQLAlchemy:版本计数限制",
  1198. "url": "https://docs.sqlalchemy.org/en/20/orm/versioning.html"
  1199. }
  1200. ],
  1201. "production": {
  1202. "scenario": "两个管理页面同时编辑同一商品,最后保存的人不应无提示覆盖前一次修改。",
  1203. "decisions": [
  1204. "客户端提交读到的版本,数据库原子验证。",
  1205. "脚本与管理入口遵守同一版本规则。"
  1206. ],
  1207. "failureDrill": "使用两个独立 HTTP 客户端同时提交同一 version,核验一个成功、一个冲突和数据库最终内容。",
  1208. "acceptance": [
  1209. "不出现丢失更新。",
  1210. "409 对客户端有明确恢复流程。"
  1211. ]
  1212. },
  1213. "intro": {
  1214. "scenario": "两个人都打开同一件商品,后保存的人可能覆盖先保存的内容。让每次编辑带上自己读到的版本,再观察旧页面怎样收到冲突。",
  1215. "startingPoint": [
  1216. "已完成本模块第 07–08 课,shop_lab_migration 的 version 已非空且回填完成;在 shop-service 保留现有路由与输入校验,先正常读取一件商品。",
  1217. "沿用数据库模块的 Linux/WSL2 Bash 与同一个 shop-service 目录;每个新终端先 cd 到该目录,再 source .venv/bin/activate,用 python -c \"import sys; print(sys.executable)\" 确认路径为 shop-service/.venv/bin/python。不要切换到 PowerShell 复用它,也不要复制 Windows 的 .venv。第 01 课允许不存在时先创建;之后 SQL 只粘贴到 mysql 客户端。",
  1218. "第 07 课新库创建后,SQL 终端用 docker exec -it shop-mysql mysql --protocol=TCP -h 127.0.0.1 -P 3306 -u app_user -p shop_lab_migration;这是容器内 3306,Python 宿主连接仍使用 3307。"
  1219. ],
  1220. "outcome": "先保存者成功且版本加一,拿旧版本保存者得到 409;删除也检查版本,短事务行锁可用于一次性上架。"
  1221. }
  1222. },
  1223. {
  1224. "id": "10",
  1225. "group": "一致性与验收",
  1226. "title": "幂等键与唯一约束",
  1227. "summary": "创建商品的响应丢了,客户端重试是否会多建一件?给同一次创建保留一个请求键,把首次结果存下来供重复请求取回。",
  1228. "objectives": [
  1229. "用复合唯一键保存一次商品创建的请求指纹和响应",
  1230. "验证同键同内容的并发请求只得到一个商品与相同响应",
  1231. "验证同键换内容返回 409,响应丢失后原键可取回原结果"
  1232. ],
  1233. "concepts": [
  1234. {
  1235. "title": "超时不代表未提交",
  1236. "body": "完成第 6 步“验证大小写、并发和结果丢失”后,再回顾这条规则:客户端没有拿到响应时,数据库可能已经提交。一次逻辑创建保持同一个Idempotency-Key;服务保存响应状态和body以便重放。第4课提交前断连实验不能覆盖提交后响应丢失,幂等协议用于覆盖这段不确定窗口。"
  1237. },
  1238. {
  1239. "title": "唯一键、事务与隔离级别",
  1240. "body": "完成第 5 步“并发重试并核对结果”后,再回顾这条规则:MySQL默认通常RR;本课程API从第2课明确RC。INSERT...ON DUPLICATE KEY UPDATE用唯一主键串行化同scope/key争用,然后SELECT FOR UPDATE读取最新记录。新插入带随机claim_token,旧记录的token保持不变,用token判断谁新建,不依赖受驱动FOUND_ROWS行为影响的upsert rowcount。"
  1241. },
  1242. {
  1243. "title": "作用域、比较与保留期",
  1244. "body": "完成第 4 步“新增HTTP端点”后,再回顾这条规则:幂等键使用受限ASCII和ascii_bin,区分大小写,避免默认不区分大小写的排序规则合并不同键。scope绑定可信身份与操作,本课仅本机单主体。清理后旧键可能再次创建,应公布保留期并覆盖客户端重试窗口;业务唯一约束仍须独立设计。"
  1245. }
  1246. ],
  1247. "steps": [
  1248. {
  1249. "title": "增加幂等表迁移",
  1250. "body": "先把下一步Table映射加入models,再生成并审核revision;DDL目标如下。scope/idem_key是唯一复合主键,只有这项唯一冲突触发本课upsert;不要增加其他唯一约束后仍假定所有冲突都表示同一键。",
  1251. "code": "CREATE TABLE idempotency_records (\n scope VARCHAR(120) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,\n idem_key VARCHAR(100) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,\n request_hash CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,\n claim_token CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,\n status_code INT NULL,\n response_body JSON NULL,\n created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),\n PRIMARY KEY (scope, idem_key)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;",
  1252. "lang": "sql",
  1253. "expected": "本步先读目标 DDL,标出 (scope,idem_key) 复合主键和可空响应列;完成下一步模型后才用迁移建表,不在此重复手工 CREATE。",
  1254. "explanation": "一条记录同时标识“哪个使用范围的哪次请求”和它的最终响应。复合主键让同一范围内相同键只能留一行,为并发竞争提供数据库裁决。 MySQL JSON是类型化数据列。"
  1255. },
  1256. {
  1257. "title": "登记完整Core Table",
  1258. "body": "追加app/models.py,复用现有Base。与上一步DDL一一对应;通过Alembic在shop_lab_migration创建,不能既手工CREATE又重复upgrade相同建表迁移。",
  1259. "code": "from sqlalchemy import Column, Table, Integer, text\nfrom sqlalchemy.dialects.mysql import VARCHAR, CHAR, JSON, DATETIME\n\nidempotency_records = Table(\"idempotency_records\", Base.metadata,\n Column(\"scope\", VARCHAR(120, charset=\"ascii\", collation=\"ascii_bin\"), primary_key=True),\n Column(\"idem_key\", VARCHAR(100, charset=\"ascii\", collation=\"ascii_bin\"), primary_key=True),\n Column(\"request_hash\", CHAR(64, charset=\"ascii\", collation=\"ascii_bin\"), nullable=False),\n Column(\"claim_token\", CHAR(36, charset=\"ascii\", collation=\"ascii_bin\"), nullable=False),\n Column(\"status_code\", Integer),\n Column(\"response_body\", JSON),\n Column(\"created_at\", DATETIME(fsp=6), nullable=False, server_default=text(\"CURRENT_TIMESTAMP(6)\")),\n mysql_engine=\"InnoDB\", mysql_charset=\"utf8mb4\",\n)",
  1260. "lang": "python",
  1261. "filename": "app/models.py",
  1262. "expected": "app/models.py 登记完整 idempotency_records;生成、审核并 upgrade 后真实表字段一致,alembic check 无新增差异。",
  1263. "explanation": "Core Table 直接描述表,不必每张辅助表都声明 ORM 类。类型化 JSON 保存响应结构,迁移工具也因此知道如何重建这张表。"
  1264. },
  1265. {
  1266. "title": "新增MySQL幂等服务",
  1267. "body": "追加app/services.py。外层必须已开启事务,函数不commit。重复键upsert等待竞争事务结束,FOR UPDATE为当前读取;新token只有获胜事务持有。业务创建、幂等占位、最终响应同一事务提交,失败回滚不会留下已提交空壳。禁止INSERT IGNORE掩盖其他数据错误,也不使用REPLACE删除旧结果。",
  1268. "code": "import hashlib\nimport json\nfrom uuid import uuid4\nfrom fastapi import HTTPException\nfrom sqlalchemy import select, update\nfrom sqlalchemy.dialects.mysql import insert\nfrom app.models import idempotency_records\n\nasync def create_idempotent(db: AsyncSession, body: ProductCreate, key: str, scope: str):\n payload = json.dumps(body.model_dump(mode=\"json\"), sort_keys=True,\n separators=(\",\", \":\"), ensure_ascii=False)\n digest = hashlib.sha256(payload.encode()).hexdigest()\n token = str(uuid4())\n table = idempotency_records\n stmt = insert(table).values(scope=scope, idem_key=key,\n request_hash=digest, claim_token=token)\n # 冲突时保持原记录不变,不覆盖原hash、token或响应。\n await db.execute(stmt.on_duplicate_key_update(idem_key=table.c.idem_key))\n stored = (await db.execute(select(table).where(\n table.c.scope == scope, table.c.idem_key == key\n ).with_for_update())).mappings().one()\n if stored[\"claim_token\"] != token:\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 unavailable\")\n return stored[\"status_code\"], stored[\"response_body\"]\n product = Product(**body.model_dump())\n db.add(product)\n await db.flush()\n await db.refresh(product)\n result = ProductOut.model_validate(product).model_dump(mode=\"json\")\n await db.execute(update(table).where(table.c.scope == scope,\n table.c.idem_key == key, table.c.claim_token == token\n ).values(status_code=201, response_body=result))\n return 201, result",
  1269. "lang": "python",
  1270. "filename": "app/services.py",
  1271. "expected": "本步先保存定义,不会自动产生运行输出;调用后的观察在后续验证步骤完成。create_idempotent 已追加且不自行 commit;首次创建写商品与响应,重复键读取已有结果,不覆盖原 hash/token。",
  1272. "explanation": "哈希是规范化请求内容的指纹,用来发现“同一键却换了内容”。随机 token 区分首次占位者和后来重试者,两者仍由同一外层事务决定成功或回滚。"
  1273. },
  1274. {
  1275. "title": "新增HTTP端点",
  1276. "body": "追加app/routes.py,ASCII键长度1–100。scope由服务端确定;接入身份系统后使用可信用户/租户id与操作版本,不信任客户端自行指定scope。先离开begin完成提交再构造JSONResponse。",
  1277. "code": "from fastapi.responses import JSONResponse\nfrom app.services import create_idempotent\n\n@router.post(\"/idempotent\", status_code=201)\nasync def create_idempotent_product(body: ProductCreate, db: Db,\n idempotency_key: Annotated[str, Header(min_length=1, max_length=100, pattern=r\"^[A-Za-z0-9._:-]+$\")]):\n async with db.begin():\n status, payload = await create_idempotent(db, body, idempotency_key,\n scope=\"local-student:POST:/products/idempotent:v1\")\n return JSONResponse(status_code=status, content=payload)",
  1278. "lang": "python",
  1279. "filename": "app/routes.py",
  1280. "expected": "/docs 新增 /products/idempotent,要求 Idempotency-Key;成功提交后返回 201 与已保存响应,客户端不能指定 scope。",
  1281. "explanation": "scope 把用户或操作范围隔开,本课固定为本机学习者。返回放在事务之后,才能避免告诉客户端成功时数据库尚未保存结果。"
  1282. },
  1283. {
  1284. "title": "并发重试并核对结果",
  1285. "body": "新建 scripts/idempotency_probe.py。使用随机新键避免前次练习污染;两个请求并发提交相同内容,返回同一个 id。大量并发测试必须控制在本机练习环境。此时服务仍使用 shop_lab_migration,所有最终数据核对都连接同一实验库。 将本步代码按标注文件位置保存或合并,完成上述前置后在当前项目终端执行 python -m scripts.idempotency_probe,再与下方预期对照。",
  1286. "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(name):\n return await client.post(\"/products/idempotent\", json={\"name\": name}, 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())",
  1287. "lang": "python",
  1288. "filename": "scripts/idempotency_probe.py",
  1289. "expected": "python -m scripts.idempotency_probe 无断言错误:两个并发请求都是 201、响应完全相同,同键 different 为 409。",
  1290. "explanation": "重复请求不是不再执行任何代码,而是让多次尝试指向同一业务结果。数据库唯一键和事务协同决定哪一次真正创建,后来者取回已有响应。 再用相同键改内容应返回 409。"
  1291. },
  1292. {
  1293. "title": "验证大小写、并发和结果丢失",
  1294. "body": "补充在本机HTTP客户端执行三个场景:同键同body并发只新增一行;同键不同body409;KeyA与keya视为不同键。先成功调用但丢弃响应,再以原key/body重试,确认返回原id。若出现1205/1213,退出整个事务并以同一键有界重试,不更换键逃避冲突。",
  1295. "code": "SELECT scope, idem_key, claim_token, status_code\nFROM idempotency_records ORDER BY created_at DESC LIMIT 10;\nSELECT COUNT(*) AS incomplete_committed\nFROM idempotency_records WHERE status_code IS NULL;",
  1296. "lang": "sql",
  1297. "expected": "SQL 核对未完成的已提交记录数为 0;KeyA/keya 各有独立记录,丢弃响应后原键重试仍返回原 id。",
  1298. "explanation": "超时只说明调用者没拿到确认,不等于数据库没提交。保持原键才能找回原结果;更换键会被当成另一项创建请求。"
  1299. }
  1300. ],
  1301. "checks": [
  1302. "同scope/key/body并发响应相同且只新增一条products。",
  1303. "同键不同body409;键大小写按ascii_bin明确区分。",
  1304. "事务失败不留下status_code为空的已提交占位;应用连接确认为READ-COMMITTED。"
  1305. ],
  1306. "pitfalls": [
  1307. {
  1308. "symptom": "用upsert rowcount判断是否首次请求导致错误分支",
  1309. "cause": "MySQL驱动的匹配行/变更行选项影响返回值,无操作更新也有不同计数。",
  1310. "fix": "保持旧claim_token,使用新随机token与锁定读取结果比较,而非猜rowcount。"
  1311. },
  1312. {
  1313. "symptom": "幂等键大小写意外合并",
  1314. "cause": "字符串主键继承了不区分大小写的默认collation。",
  1315. "fix": "键限定ASCII并显式ascii_bin,写入、查找和清理采用同一契约。"
  1316. }
  1317. ],
  1318. "exercise": {
  1319. "task": "能否用REPLACE INTO实现幂等结果缓存覆盖?",
  1320. "hint": "REPLACE可能删除冲突旧行再插入。",
  1321. "answer": "不应。它可能改变主键、触发级联或覆盖首次结果,破坏幂等重放。使用唯一键争用但不改旧hash/token/response,首个成功结果保持不变。"
  1322. },
  1323. "refs": [
  1324. {
  1325. "label": "MySQL8.4:ON DUPLICATE KEY UPDATE",
  1326. "url": "https://dev.mysql.com/doc/refman/8.4/en/insert-on-duplicate.html"
  1327. },
  1328. {
  1329. "label": "SQLAlchemy:MySQL upsert",
  1330. "url": "https://docs.sqlalchemy.org/en/20/dialects/mysql.html#insert-on-duplicate-key-update-upsert"
  1331. },
  1332. {
  1333. "label": "MySQL8.4:READ COMMITTED",
  1334. "url": "https://dev.mysql.com/doc/refman/8.4/en/innodb-transaction-isolation-levels.html"
  1335. }
  1336. ],
  1337. "production": {
  1338. "scenario": "客户端在写入超时或响应丢失后重试,不能创建重复业务记录。",
  1339. "decisions": [
  1340. "唯一键与结果写入同事务,竞争判定不依赖upsert行数。",
  1341. "键绑定身份和操作,保留期覆盖重试窗口。"
  1342. ],
  1343. "failureDrill": "两个真实连接同时提交同键,再丢弃响应重试,核对一条业务行和同一响应。",
  1344. "acceptance": [
  1345. "首次成功结果不被后续请求覆盖。",
  1346. "死锁/超时重试仍保持原幂等键。"
  1347. ]
  1348. },
  1349. "intro": {
  1350. "scenario": "创建商品的响应丢了,客户端重试是否会多建一件?给同一次创建保留一个请求键,把首次结果存下来供重复请求取回。",
  1351. "startingPoint": [
  1352. "已完成本模块第 07–09 课,API 和 SQL 都使用 shop_lab_migration;在 shop-service 保留 app/services.py、routes.py,确认普通创建响应含 version。",
  1353. "沿用数据库模块的 Linux/WSL2 Bash 与同一个 shop-service 目录;每个新终端先 cd 到该目录,再 source .venv/bin/activate,用 python -c \"import sys; print(sys.executable)\" 确认路径为 shop-service/.venv/bin/python。不要切换到 PowerShell 复用它,也不要复制 Windows 的 .venv。第 01 课允许不存在时先创建;之后 SQL 只粘贴到 mysql 客户端。",
  1354. "第 07 课新库创建后,SQL 终端用 docker exec -it shop-mysql mysql --protocol=TCP -h 127.0.0.1 -P 3306 -u app_user -p shop_lab_migration;这是容器内 3306,Python 宿主连接仍使用 3307。"
  1355. ],
  1356. "outcome": "同键同内容的并发请求得到相同商品与响应;同键改内容返回 409,丢弃响应后仍能用原键找回结果。"
  1357. }
  1358. },
  1359. {
  1360. "id": "11",
  1361. "group": "一致性与验收",
  1362. "title": "事务 Outbox 与跨服务一致性",
  1363. "summary": "商品创建后要通知另一个服务,但对方暂时不可用。先把待发送通知与商品一起存下来,再独立发送,并观察重复通知如何处理。",
  1364. "objectives": [
  1365. "在首次幂等创建事务中同时记录一件商品和一条待发事件",
  1366. "在短事务领取后发送 HTTP,再按令牌确认发送结果",
  1367. "复现接收成功但确认丢失,验证重投去重与积压最终消除"
  1368. ],
  1369. "concepts": [
  1370. {
  1371. "title": "事务不覆盖外部系统",
  1372. "body": "完成第 1 步“先读两张事件表的设计”后,再回顾这条规则:先提交业务再发送会有漏发窗口,先发送再提交会有虚假事件。业务行与outbox在同一InnoDB事务写入,发布器稍后送达,接受明确的最终一致性延迟。"
  1373. },
  1374. {
  1375. "title": "至少一次与租约",
  1376. "body": "完成第 8 步“演练确认丢失与积压观测”后,再回顾这条规则:短事务领取、提交后发HTTP、带token确认。发送成功但确认丢失会重复,接收方以event_id和自身事务去重。租约到期后旧执行者仍可能发送,token只能保护本地状态写回,不能阻止网络重复。"
  1377. },
  1378. {
  1379. "title": "顺序与容量",
  1380. "body": "完成第 5 步“使用锁定SELECT再UPDATE领取”后,再回顾这条规则:SKIP LOCKED使多个发布器跳过被锁行,并不保证严格事件顺序。需要按商品版本处理顺序时在事件携带aggregate_id/version并设计接收端缺口处理。每次领取、并发、重试与积压都有上限,不以无限扫描和无限重试作为可靠性。"
  1381. }
  1382. ],
  1383. "steps": [
  1384. {
  1385. "title": "先读两张事件表的设计",
  1386. "body": "本步只阅读下方目标 SQL,不在客户端执行。先指出 outbox_events 如何保存待发通知、received_events 如何按 event_id 识别重复接收;第 2 步把它们登记到 app/models.py,第 3 步才生成、审核并执行实际迁移,避免手工 CREATE 后又重复建表。两表使用 InnoDB,UUID 用 ASCII CHAR(36),payload 用 JSON;待发查询使用普通复合索引,不使用 MySQL 不支持的条件索引。",
  1387. "code": "CREATE TABLE outbox_events (\n id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,\n event_id CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL UNIQUE,\n aggregate_id BIGINT NOT NULL,\n event_type VARCHAR(80) NOT NULL,\n payload JSON NOT NULL,\n created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),\n available_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),\n locked_until DATETIME(6) NULL,\n lock_token CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NULL,\n attempts INT NOT NULL DEFAULT 0,\n delivered_at DATETIME(6) NULL,\n last_error VARCHAR(120) NULL,\n INDEX ix_outbox_pending (delivered_at, available_at, id)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;\nCREATE TABLE received_events (\n event_id CHAR(36) CHARACTER SET ascii COLLATE ascii_bin PRIMARY KEY,\n receipt_token CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,\n received_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;",
  1388. "lang": "sql",
  1389. "expected": "能在目标结构中找到待发事件、尝试次数与已接收事件的唯一标识;数据库此时尚未改变,第 3 步才应用迁移。",
  1390. "explanation": "数据库事务不能连同外部 HTTP 一起提交,所以先保存“还需要通知什么”。接收表为重复交付保留证据,发送者和接收者各有自己的状态。"
  1391. },
  1392. {
  1393. "title": "把两张表同步到metadata",
  1394. "body": "将下面两张表和索引追加到 app/models.py,保留已存在的映射与 import。此步只登记 Python metadata,下一步才生成并执行迁移;不要同时手工 CREATE 这两张表。",
  1395. "code": "from sqlalchemy import Column, Table, Index, Integer, BigInteger, String, text\nfrom sqlalchemy.dialects.mysql import CHAR, JSON, DATETIME\n\noutbox_events = Table(\"outbox_events\", Base.metadata,\n Column(\"id\", BigInteger, primary_key=True, autoincrement=True),\n Column(\"event_id\", CHAR(36, charset=\"ascii\", collation=\"ascii_bin\"), nullable=False, unique=True),\n Column(\"aggregate_id\", BigInteger, nullable=False),\n Column(\"event_type\", String(80), nullable=False),\n Column(\"payload\", JSON, nullable=False),\n Column(\"created_at\", DATETIME(fsp=6), nullable=False, server_default=text(\"CURRENT_TIMESTAMP(6)\")),\n Column(\"available_at\", DATETIME(fsp=6), nullable=False, server_default=text(\"CURRENT_TIMESTAMP(6)\")),\n Column(\"locked_until\", DATETIME(fsp=6)),\n Column(\"lock_token\", CHAR(36, charset=\"ascii\", collation=\"ascii_bin\")),\n Column(\"attempts\", Integer, nullable=False, server_default=text(\"0\")),\n Column(\"delivered_at\", DATETIME(fsp=6)),\n Column(\"last_error\", String(120)),\n mysql_engine=\"InnoDB\", mysql_charset=\"utf8mb4\",\n)\nIndex(\"ix_outbox_pending\", outbox_events.c.delivered_at,\n outbox_events.c.available_at, outbox_events.c.id)\n\nreceived_events = Table(\"received_events\", Base.metadata,\n Column(\"event_id\", CHAR(36, charset=\"ascii\", collation=\"ascii_bin\"), primary_key=True),\n Column(\"receipt_token\", CHAR(36, charset=\"ascii\", collation=\"ascii_bin\"), nullable=False),\n Column(\"received_at\", DATETIME(fsp=6), nullable=False, server_default=text(\"CURRENT_TIMESTAMP(6)\")),\n mysql_engine=\"InnoDB\", mysql_charset=\"utf8mb4\",\n)",
  1396. "lang": "python",
  1397. "filename": "app/models.py",
  1398. "expected": "app/models.py 已登记 outbox_events、received_events 与 ix_outbox_pending;此时不会因为保存文件而自动建表,先不要运行写事件逻辑。",
  1399. "explanation": "metadata 与实际表一致,才使测试库和新环境能重建整条链路。待发索引按是否送达、可尝试时间和 id 排列,支持有界扫描。"
  1400. },
  1401. {
  1402. "title": "生成并执行事件表迁移",
  1403. "body": "在 shop-service 根目录激活项目虚拟环境,确认 .env 的 DATABASE_URL 指向 shop_lab_migration。先只运行下面第一条命令,打开新生成的 migrations/versions 文件,核对 upgrade 只创建 outbox_events、received_events 及预期索引,没有删除或重建旧表;审核后再依次执行余下命令。最后在同一 MySQL 实验库执行 SHOW CREATE TABLE outbox_events 与 SHOW CREATE TABLE received_events,确认真实结构。已有同名表时先核对迁移记录,不能重复手工建表或盲目 stamp。",
  1404. "code": "python -m alembic revision --autogenerate -m \"add product outbox and receipts\"\n# 先打开并审核生成的 revision,确认符合上方要求后再继续:\npython -m alembic upgrade head\npython -m alembic current\npython -m alembic check",
  1405. "lang": "bash",
  1406. "expected": "current 指向刚生成的 revision,check 不报告待生成差异;MySQL 中能查到两张真实表和待发索引,之后才具备写入事件的条件。",
  1407. "explanation": "metadata 是希望数据库具备的结构,revision 把希望转成一份可审核的改变脚本,upgrade 才真正执行。current 核对迁移进度,SHOW CREATE TABLE 核对真实结果;两者都确认后,后续代码才不会碰到表不存在。"
  1408. },
  1409. {
  1410. "title": "在幂等创建事务内写事件",
  1411. "body": "追加app/services.py,并在第10课create_idempotent首次创建分支flush/refresh之后、保存响应之前调用await add_created_event(db,product)。重放旧结果的分支不插事件。Core JSON列让SQLAlchemy负责JSON编码,不拼SQL字符串。 完成保存后重启 API,在 /docs 调用 POST /products/idempotent,使用本次未用过的 ASCII Idempotency-Key 与一件教学商品名称;保存响应 id,再查询 outbox_events 中 aggregate_id 等于该 id 的事件,确认新增一条。用同一键和内容再提交一次,核对仍是一条。",
  1412. "code": "from uuid import uuid4\nfrom sqlalchemy import insert as core_insert\nfrom app.models import outbox_events\n\nasync def add_created_event(db: AsyncSession, product: Product):\n await db.execute(core_insert(outbox_events).values(\n event_id=str(uuid4()), aggregate_id=product.id, event_type=\"product.created\",\n payload={\"product_id\": product.id, \"version\": product.version}))",
  1413. "lang": "python",
  1414. "filename": "app/services.py",
  1415. "expected": "首次幂等创建后有一件商品及一条 product.created 事件;用原键重放不会再新增事件。",
  1416. "explanation": "只有真正创建商品的分支才有新事实需要通知,把事件写入同一事务能做到商品和通知清单一起有、一起无。"
  1417. },
  1418. {
  1419. "title": "使用锁定SELECT再UPDATE领取",
  1420. "body": "新建app/outbox.py。MySQL没有本课所需UPDATE RETURNING,因此在同一短事务先锁定有限行,再更新token和租约,提交后返回内存事件。SKIP LOCKED适合队列争抢,不适合普通一致性业务列表。",
  1421. "code": "from uuid import uuid4\nfrom sqlalchemy import select, update, and_, or_, func, text\nfrom app.db import SessionFactory\nfrom app.models import outbox_events\n\nasync def claim(limit=1):\n if not 1 <= limit <= 20:\n raise ValueError(\"limit must be between 1 and 20\")\n token = str(uuid4())\n table = outbox_events\n async with SessionFactory.begin() as db:\n rows = (await db.execute(select(table).where(\n table.c.delivered_at.is_(None), table.c.available_at <= func.utc_timestamp(6),\n table.c.attempts < 8,\n or_(table.c.locked_until.is_(None), table.c.locked_until < func.utc_timestamp(6))\n ).order_by(table.c.id).limit(limit).with_for_update(skip_locked=True))).mappings().all()\n events = [dict(row) for row in rows]\n if events:\n await db.execute(update(table).where(table.c.id.in_([row[\"id\"] for row in events])).values(\n lock_token=token, locked_until=text(\"UTC_TIMESTAMP(6) + INTERVAL 30 SECOND\"),\n attempts=table.c.attempts + 1))\n for row in events:\n row[\"attempts\"] += 1\n return token, events\n\nasync def finish(event_id, token, success):\n async with SessionFactory.begin() as db:\n result = await db.execute(text(\"\"\"\n UPDATE outbox_events\n SET delivered_at=CASE WHEN :success THEN UTC_TIMESTAMP(6) ELSE NULL END,\n available_at=CASE WHEN :success THEN available_at ELSE\n TIMESTAMPADD(SECOND, LEAST(300, CAST(POW(2, attempts) AS SIGNED)), UTC_TIMESTAMP(6)) 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})\n return result.rowcount == 1",
  1422. "lang": "python",
  1423. "filename": "app/outbox.py",
  1424. "expected": "本步先保存定义,不会自动产生运行输出;调用后的观察在后续验证步骤完成。claim 一次默认至多一条,提交后返回事件与 token;失败 finish 记录次数、下次尝试时间,八次后不再自动领取。",
  1425. "explanation": "租约为发布器保留短期处理权,SKIP LOCKED 让其他发布器跳过忙行。外部发送发生在函数返回后,数据库不必持锁等网络;失败记录保留供继续恢复或排查。 失败重试有退避和8次上限,超限事件留待排查。"
  1426. },
  1427. {
  1428. "title": "添加本机去重接收端点",
  1429. "body": "追加app/main.py,仅模拟第二服务。receipt_token判定本次是否新接收,不依赖MySQL upsert rowcount。真实接收方需独立数据库,去重与其本地副作用同一事务;本课副作用仅记录接收,不声称已更新外部搜索或发送邮件。 保存后重启 API,确认 /docs 出现 /internal/events;此步只准备接收入口,首次实际接收在第 7 步发布脚本运行时观察,第 8 步验证重复接收。",
  1430. "code": "from uuid import UUID, uuid4\nfrom pydantic import BaseModel, Field\nfrom sqlalchemy import select\nfrom sqlalchemy.dialects.mysql import insert as mysql_insert\nfrom app.models import received_events\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 event_id, token = str(body.event_id), str(uuid4())\n table = received_events\n async with db.begin():\n stmt = mysql_insert(table).values(event_id=event_id, receipt_token=token)\n await db.execute(stmt.on_duplicate_key_update(event_id=table.c.event_id))\n stored = await db.scalar(select(table.c.receipt_token).where(\n table.c.event_id == event_id).with_for_update())\n duplicate = stored != token\n # 真实接收方只有duplicate=False时,才在本事务执行自己的数据库副作用。\n return {\"accepted\": True, \"duplicate\": duplicate}",
  1431. "lang": "python",
  1432. "filename": "app/main.py",
  1433. "expected": "重启后 /docs 能找到 /internal/events,接收逻辑已经注册;第 7 步首次发送会得到 accepted=True、duplicate=False,第 8 步同 event_id 重发后 duplicate=True 且接收表仍只有一行。",
  1434. "explanation": "接收令牌判断这次是不是新插入,而不是依赖 upsert 的行数猜测。真实副作用也必须和这条去重记录同事务,本课只演示接收记录。"
  1435. },
  1436. {
  1437. "title": "新建单次发布脚本",
  1438. "body": "新建scripts/publish_once.py。本次只领1条,3秒HTTP预算明显短于30秒租约;claim已经提交后才发HTTP。令牌不匹配时finish返回False,旧发布器不能覆盖新持有者。 将本步代码按标注文件位置保存或合并,完成上述前置后在当前项目终端执行 python -m scripts.publish_once,再与下方预期对照。 这里应使用第 4 步创建且 delivered_at 仍为空的事件;若已有其他待发事件,按保存的商品 id 和 event_id 核对本次记录,不只看脚本是否打印 True。",
  1439. "code": "import asyncio\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\": 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 acknowledged = await finish(event[\"id\"], token, success)\n print(\"local acknowledgement updated:\", acknowledged)\n finally:\n await engine.dispose()\n\nif __name__ == \"__main__\":\n asyncio.run(main())",
  1440. "lang": "python",
  1441. "filename": "scripts/publish_once.py",
  1442. "expected": "先创建一条事件,再运行 python -m scripts.publish_once,打印 local acknowledgement updated: True;事件 delivered_at 写入,接收表有对应 event_id。",
  1443. "explanation": "先领取、再 HTTP、最后确认是三个阶段。网络预算比租约短,确认仍检查 token,旧发布器不能把新持有者的状态覆盖掉。 发送成功但finish前崩溃会再次投递,这是设计边界。"
  1444. },
  1445. {
  1446. "title": "演练确认丢失与积压观测",
  1447. "body": "先在 /docs 用一个新的幂等键再次提交商品,确认生成新的未送达事件并记录 event_id。仅在本机临时跳过脚本中这次发送后的 finish,运行 python -m scripts.publish_once;按 event_id 确认接收表已写入而 outbox 仍待发。等该事件 locked_until 到期后恢复 finish 并再次发布,received_events 对这个 event_id 仍只有一行,outbox 最终 delivered_at 非空。再查下方总体积压数据;超限事件保留排查,不静默删除。",
  1448. "code": "SELECT COUNT(*) AS pending, MIN(created_at) AS oldest_pending,\n SUM(CASE WHEN attempts >= 8 THEN 1 ELSE 0 END) AS needs_attention\nFROM outbox_events WHERE delivered_at IS NULL;",
  1449. "lang": "sql",
  1450. "expected": "跳过 finish 后接收表已有记录而事件仍待发;租约过期恢复并重跑后接收仍一行,delivered_at 最终非空。",
  1451. "explanation": "对方收到了但本地未确认会产生重复交付,这是至少一次传递的典型现象。积压数量与最老时间说明还有多少事没送达、等了多久,不能只盯本次 HTTP 是否成功。"
  1452. }
  1453. ],
  1454. "checks": [
  1455. "幂等创建只产生一条 products 和一条对应 outbox event,重试不会再发新事件。",
  1456. "claim 结束后才调用 HTTP,数据库没有等待外部服务的长事务。",
  1457. "重复投递被接收方去重,失败事件保留次数、下一次可尝试时间与排查状态。"
  1458. ],
  1459. "pitfalls": [
  1460. {
  1461. "symptom": "消息发送成功后进程崩溃,恢复后又发送一次",
  1462. "cause": "外部成功与本地 delivered 标记不能在一个普通数据库事务中原子提交。",
  1463. "fix": "接受重复并按 event_id 去重,去重记录和接收方业务写入共用事务。"
  1464. },
  1465. {
  1466. "symptom": "批量领取 100 条后大量租约过期",
  1467. "cause": "顺序发送总耗时超过租约,其他发布器重新领取。",
  1468. "fix": "缩小批次、限制单次请求时间;扩大并行时计算整个批次最坏耗时,必要时做可验证的续租。"
  1469. }
  1470. ],
  1471. "exercise": {
  1472. "task": "如果事件用于邮件发送,接收数据库去重是否保证邮件只发送一次?",
  1473. "hint": "数据库事务不能原子覆盖外部邮件供应商。",
  1474. "answer": "不能。邮件供应商若支持幂等键,应传 event_id;否则仍存在发送成功但记录失败的窗口。要按业务允许的重复程度设计去重、状态查询或人工补偿,不能称为恰好一次。"
  1475. },
  1476. "refs": [
  1477. {
  1478. "label": "MySQL8.4:SKIP LOCKED",
  1479. "url": "https://dev.mysql.com/doc/refman/8.4/en/innodb-locking-reads.html"
  1480. },
  1481. {
  1482. "label": "MySQL8.4:JSON",
  1483. "url": "https://dev.mysql.com/doc/refman/8.4/en/json.html"
  1484. },
  1485. {
  1486. "label": "AWS:事务Outbox模式",
  1487. "url": "https://docs.aws.amazon.com/prescriptive-guidance/latest/cloud-design-patterns/transactional-outbox.html"
  1488. }
  1489. ],
  1490. "production": {
  1491. "scenario": "创建商品后要通知索引服务,但索引服务短暂不可用。",
  1492. "decisions": [
  1493. "业务事务内只记录事件;外部传输在短租约领取后执行。",
  1494. "监控积压年龄与耗尽重试,接收方按事件 id 去重。"
  1495. ],
  1496. "failureDrill": "模拟成功接收后未确认,再过期重领,核对接收记录只有一条且 outbox 最终 delivered。",
  1497. "acceptance": [
  1498. "不丢失已提交业务的待发事件。",
  1499. "承认并验证至少一次投递与最终一致性边界。"
  1500. ]
  1501. },
  1502. "intro": {
  1503. "scenario": "商品创建后要通知另一个服务,但对方暂时不可用。先把待发送通知与商品一起存下来,再独立发送,并观察重复通知如何处理。",
  1504. "startingPoint": [
  1505. "已完成本模块第 10 课,幂等创建探针通过,继续使用 shop_lab_migration;shop-service 中保留完整迁移链,API 在 8000 运行,本节接收端只模拟本机第二服务。",
  1506. "沿用数据库模块的 Linux/WSL2 Bash 与同一个 shop-service 目录;每个新终端先 cd 到该目录,再 source .venv/bin/activate,用 python -c \"import sys; print(sys.executable)\" 确认路径为 shop-service/.venv/bin/python。不要切换到 PowerShell 复用它,也不要复制 Windows 的 .venv。第 01 课允许不存在时先创建;之后 SQL 只粘贴到 mysql 客户端。",
  1507. "第 07 课新库创建后,SQL 终端用 docker exec -it shop-mysql mysql --protocol=TCP -h 127.0.0.1 -P 3306 -u app_user -p shop_lab_migration;这是容器内 3306,Python 宿主连接仍使用 3307。"
  1508. ],
  1509. "outcome": "同次幂等创建只留一件商品和一条事件;发送失败可重试,重复通知按 event_id 去重,积压可查询。"
  1510. }
  1511. },
  1512. {
  1513. "id": "12",
  1514. "group": "一致性与验收",
  1515. "title": "真实MySQL隔离测试与故障验收",
  1516. "summary": "商品接口、并发保护和通知链路已经接好。现在把一次手工点击变成可重复验收,并给需要真实提交的实验独立空间。",
  1517. "objectives": [
  1518. "在独立 shop_lab_test 中先迁移建表,再运行顺序 CRUD 测试",
  1519. "用独立连接真实提交验证并发幂等,并只清理本次随机键的数据",
  1520. "逐项记录故障后的 HTTP、数据库、资源释放及恢复证据"
  1521. ],
  1522. "concepts": [
  1523. {
  1524. "title": "数据库版本、引擎与配置都重要",
  1525. "body": "完成第 1 步“创建独立测试库并授权”后,再回顾这条规则:SQLite无法验证MySQL锁、字符排序、JSON和DDL行为。专用shop_lab_test使用MySQL8.4、InnoDB、utf8mb4,与应用同样RC/UTC连接设置。测试凭据不连接生产;数据库名防护只是第一道检查,还需网络和权限隔离。"
  1526. },
  1527. {
  1528. "title": "Savepoint只覆盖适合的DML测试",
  1529. "body": "完成第 3 步“新建DML回滚隔离fixture”后,再回顾这条规则:单请求CRUD可将Session绑定外层事务,用savepoint容纳内部commit,最后rollback清理。不能在该fixture运行DDL,MySQL隐式提交会破坏外层事务;也不能用它证明跨连接可见性、真实COMMIT或死锁回滚行为。并发与断连采用独立连接真实提交。"
  1530. },
  1531. {
  1532. "title": "故障验收看最终状态",
  1533. "body": "完成第 7 步“执行故障矩阵并标记实际结果”后,再回顾这条规则:每项检查HTTP、数据库、连接释放和下一次恢复请求。提交响应丢失可能结果未知,用原幂等键重试确认。迁移失败需检查SHOW CREATE TABLE的部分应用状态;只看alembic_version未推进不足以判断数据库没变化。"
  1534. }
  1535. ],
  1536. "steps": [
  1537. {
  1538. "title": "创建独立测试库并授权",
  1539. "body": "在 Bash 用 docker exec -it shop-mysql mysql --protocol=TCP -h 127.0.0.1 -P 3306 -u root -p 打开本机专用实例管理员客户端,输入已有本机密码。用本机管理员mysql连接执行以下SQL。app_user@%应替换为实际已有账号host;仅授予这一个实验库需要的权限。shop_lab与shop_lab_migration均不作为自动清理目标。",
  1540. "code": "CREATE DATABASE shop_lab_test CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;\nGRANT SELECT, INSERT, UPDATE, DELETE, CREATE, ALTER, INDEX, DROP, REFERENCES\nON shop_lab_test.* TO 'app_user'@'%';",
  1541. "lang": "sql",
  1542. "expected": "shop_lab_test 创建成功,授权只覆盖该库;现有 shop_lab 与 shop_lab_migration 不成为测试清理目标。",
  1543. "explanation": "测试会写入和清理数据,必须有独立边界。使用相同 MySQL 版本、引擎和字符集,才能验证真实的锁、约束与 DDL 行为。"
  1544. },
  1545. {
  1546. "title": "设置测试URL并先运行迁移",
  1547. "body": "新开独立 Bash 测试终端,进入 shop-service 并 source .venv/bin/activate。先用 Docker mysql 客户端核对 shop_lab_test 已创建,下面 URL 中的 ENCODED_PASSWORD 替换为已百分号编码的本机练习密码,不打印连接串。环境变量只作用于这个测试终端;先在测试 fixture 之外执行 Alembic 建表,再运行测试。",
  1548. "code": "export TEST_DATABASE_URL='mysql+asyncmy://app_user:ENCODED_PASSWORD@127.0.0.1:3307/shop_lab_test?charset=utf8mb4'\nexport DATABASE_URL=\"$TEST_DATABASE_URL\"\npython -m alembic upgrade head\npython -m alembic check",
  1549. "lang": "bash",
  1550. "expected": "测试终端的 TEST_DATABASE_URL 与 DATABASE_URL 指向 shop_lab_test;upgrade head 完成,alembic check 无缺表差异。",
  1551. "explanation": "先迁移再跑数据测试,把结构准备和可回滚的数据操作分开。MySQL DDL 会隐式提交,不能指望测试最后一次 rollback 撤销建表。"
  1552. },
  1553. {
  1554. "title": "新建DML回滚隔离fixture",
  1555. "body": "创建pytest.ini写入[pytest]与asyncio_mode = auto。导入app前设置DATABASE_URL,使用NullPool避免测试事件循环间复用物理连接,make_engine保证RC/UTC。此fixture只用于顺序DML测试,不能让多个并发请求共享同一个conn。",
  1556. "code": "import os\nimport pytest_asyncio\nfrom httpx import ASGITransport, AsyncClient\nfrom sqlalchemy.engine import make_url\nfrom sqlalchemy.ext.asyncio import AsyncSession\nfrom sqlalchemy.pool import NullPool\n\ntest_url = os.environ[\"TEST_DATABASE_URL\"]\nurl = make_url(test_url)\nif url.drivername != \"mysql+asyncmy\" or url.database != \"shop_lab_test\":\n raise RuntimeError(\"tests require mysql+asyncmy and shop_lab_test\")\nos.environ[\"DATABASE_URL\"] = test_url\n\nfrom app.main import app\nfrom app.db import get_session, make_engine\n\n@pytest_asyncio.fixture\nasync def client():\n test_engine = make_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()",
  1557. "lang": "python",
  1558. "filename": "tests/conftest.py",
  1559. "expected": "本步先保存定义,不会自动产生运行输出;调用后的观察在后续验证步骤完成。pytest.ini 与 tests/conftest.py 存在;测试 URL 驱动或库名不符时导入立即报错,正确配置能创建 client fixture。",
  1560. "explanation": "fixture 是测试前后自动准备与收尾的代码。外层事务包住测试,内部用保存点承接接口提交,最后回滚清掉本次数据;这个单连接方法只用于顺序数据测试。 再建立tests/conftest.py。"
  1561. },
  1562. {
  1563. "title": "验证最终CRUD协议",
  1564. "body": "创建tests/test_products.py。ASGITransport不自动启动lifespan;测试先迁移且不依赖应用启动建表,若另有启动资源需显式管理其生命周期。",
  1565. "code": "async def test_create_patch_conflict_delete(client):\n response = await client.post(\"/products\", json={\"name\": \"isolated\"})\n assert response.status_code == 201, response.text\n original = response.json()\n assert original[\"price_cents\"] == 0\n assert original[\"created_at\"].endswith((\"Z\", \"+00:00\"))\n path = f\"/products/{original['id']}\"\n saved = await client.patch(path, json={\"name\": \"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={\"name\": \"stale\", \"version\": original[\"version\"]})\n assert conflict.status_code == 409\n assert (await client.get(path)).json()[\"name\"] == \"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(\"/products\", json={\"name\": \"x\" * 121})).status_code == 422\n assert (await client.get(\"/products\", params={\"limit\": 101})).status_code == 422\n assert (await client.get(\"/products\", params={\"cursor\": \"broken!\"})).status_code == 422\n for price in (-1, 1.5, \"1990\", True, 2147483648):\n assert (await client.post(\"/products\", json={\"name\": \"非法价格\", \"price_cents\": price})).status_code == 422\n",
  1566. "lang": "python",
  1567. "filename": "tests/test_products.py",
  1568. "expected": "执行这组测试时创建 201、版本冲突 409、删除 204、非法输入 422 的断言通过;UTC 时间输出也被核对。",
  1569. "explanation": "测试从调用者能看到的响应检查协议,还查询修改后的真实内容。ASGITransport 在进程内发 HTTP 请求,不自动等于已启动应用所有生命周期资源。 响应时间应带UTC偏移,更新版本加1,旧版本409。"
  1570. },
  1571. {
  1572. "title": "增加真实提交fixture",
  1573. "body": "追加tests/conftest.py。每个请求各用自己的Session和连接,可验证并发和提交可见性。测试应使用随机业务键并仅清理自身记录;不要并行执行会共享app.dependency_overrides的不同fixture。",
  1574. "code": "from sqlalchemy.ext.asyncio import async_sessionmaker\n\n@pytest_asyncio.fixture\nasync def committed_client():\n test_engine = make_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()",
  1575. "lang": "python",
  1576. "filename": "tests/conftest.py",
  1577. "expected": "本步先保存定义,不会自动产生运行输出;调用后的观察在后续验证步骤完成。committed_client 返回客户端和独立会话工厂,每个请求创建自己的 Session;并发不会共享前一个 fixture 的连接。",
  1578. "explanation": "要验证两条连接能看到同一笔真实提交,不能把它们塞进一个最终回滚的连接。真实提交测试因此需要按本次随机业务键精确清理。"
  1579. },
  1580. {
  1581. "title": "编写MySQL真实并发幂等测试",
  1582. "body": "创建tests/test_concurrency.py。通过Core JSON类型获取dict,不假定原始text查询返回的JSON字符串自动解码。只按随机幂等键关联的id清理业务行和outbox,不TRUNCATE整个库。测试环境不要同时运行真实发布器。",
  1583. "code": "import asyncio\nfrom uuid import uuid4\nfrom sqlalchemy import select, delete, func\nfrom app.models import idempotency_records, outbox_events, Product\n\nasync def test_idempotency_concurrent(committed_client):\n client, factory = committed_client\n key = str(uuid4())\n scope = \"local-student:POST:/products/idempotent:v1\"\n try:\n async def send():\n return await client.post(\"/products/idempotent\", json={\"name\": \"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(select(func.count()).select_from(outbox_events).where(\n outbox_events.c.aggregate_id == a.json()[\"id\"]))\n assert count == 1\n finally:\n async with factory.begin() as db:\n payload = await db.scalar(select(idempotency_records.c.response_body).where(\n idempotency_records.c.scope == scope, idempotency_records.c.idem_key == key))\n if payload:\n product_id = payload[\"id\"]\n await db.execute(delete(outbox_events).where(outbox_events.c.aggregate_id == product_id))\n await db.execute(delete(Product).where(Product.id == product_id))\n await db.execute(delete(idempotency_records).where(\n idempotency_records.c.scope == scope, idempotency_records.c.idem_key == key))",
  1584. "lang": "python",
  1585. "filename": "tests/test_concurrency.py",
  1586. "expected": "并发幂等测试两个响应同为 201 且相等,相关 outbox 数量为 1;finally 仅清理当前随机键关联的记录。",
  1587. "explanation": "这个断言同时看 HTTP 结果与数据库副作用数量,才能证明重复调用没有多建事件。类型化 JSON 帮助得到结构化响应再按实际商品 id 清理。"
  1588. },
  1589. {
  1590. "title": "执行故障矩阵并标记实际结果",
  1591. "body": "执行python -m pytest -q,然后在独立环境完成下表。脚本尚未运行的项目要写未验证,不能把预期当实际。连接KILL和DDL实验需专用库及准确目标。",
  1592. "code": "场景 | 注入方式 | 数据与HTTP验收 | 恢复\n立即唯一冲突 | 重复audited键 | 409;第二笔products/audit均回滚 | 新键成功\n池耗尽 | 独立pool_drill | 一任务超时、无连接泄漏 | SELECT1成功\n行锁超时1205 | 会话A持锁、API更新 | 全业务主动rollback | 释放锁后成功\n并发version | 两请求同版本 | 一成功、一409 | 内容属于成功者\n同键并发 | 两真实连接同幂等键 | 同响应、一件商品一事件 | 原键重试相同\n提交前断连 | 精确KILL脚本连接 | 未确认commit;新连接核对marker | 原键/状态重查\nOutbox确认丢失 | 接收后跳过finish | 重投、接收去重 | 最终delivered\nDDL失败 | 多语句迁移部分失败 | 检查真实结构与revision | 经审核修复后推进",
  1593. "lang": "text",
  1594. "expected": "python -m pytest -q 输出真实通过/失败;故障矩阵每行另填实际 HTTP、数据状态与恢复结果,没有执行的项标未验证。",
  1595. "explanation": "自动测试通过与故障演练完成是不同证据。最终验收要看提交是否落地、事务是否释放及下一次请求能否恢复,不能把期待的行为当成已经发生。"
  1596. }
  1597. ],
  1598. "checks": [
  1599. "测试URL同时校验driver与shop_lab_test,真实实例为MySQL8.4/InnoDB。",
  1600. "顺序CRUD使用savepoint隔离,真实并发使用独立连接提交并精确清理。",
  1601. "DDL不在回滚fixture内执行;错误路径包含最终数据和恢复探针。"
  1602. ],
  1603. "pitfalls": [
  1604. {
  1605. "symptom": "测试结束rollback仍留下DDL结构",
  1606. "cause": "MySQL DDL隐式提交,外层测试事务无法撤销。",
  1607. "fix": "建表迁移在测试fixture外执行;迁移演练用独立库/表并核对部分应用状态。"
  1608. },
  1609. {
  1610. "symptom": "单连接fixture并发请求报事务错误",
  1611. "cause": "所有请求被绑定同一个连接,不能同时执行。",
  1612. "fix": "并发使用committed_client独立连接,按随机业务键清理数据。"
  1613. }
  1614. ],
  1615. "exercise": {
  1616. "task": "测试数据库名是shop_lab_test,是否就足以防止误操作线上?",
  1617. "hint": "任何主机都可能有同名数据库。",
  1618. "answer": "不够。还要使用专用测试凭据、隔离网络和明确的主机目标;CI不注入生产秘密。数据库名和驱动校验是辅助防线,不能替代权限与环境隔离。"
  1619. },
  1620. "refs": [
  1621. {
  1622. "label": "SQLAlchemy:外部事务测试",
  1623. "url": "https://docs.sqlalchemy.org/en/20/orm/session_transaction.html#joining-a-session-into-an-external-transaction-such-as-for-test-suites"
  1624. },
  1625. {
  1626. "label": "MySQL8.4:隐式提交",
  1627. "url": "https://dev.mysql.com/doc/refman/8.4/en/implicit-commit.html"
  1628. },
  1629. {
  1630. "label": "FastAPI:异步测试",
  1631. "url": "https://fastapi.tiangolo.com/advanced/async-tests/"
  1632. },
  1633. {
  1634. "label": "HTTPX:ASGI Transport",
  1635. "url": "https://www.python-httpx.org/advanced/transports/#asgi-transport"
  1636. }
  1637. ],
  1638. "production": {
  1639. "scenario": "数据库连接、并发、迁移与故障恢复要进入可重复验收,而不是只点一次/docs。",
  1640. "decisions": [
  1641. "真实同类数据库验证语义,测试配置与应用保持RC/UTC一致。",
  1642. "顺序DML、真实提交、DDL三类测试有不同隔离方式。"
  1643. ],
  1644. "failureDrill": "在专用MySQL环境完成矩阵并保存预期、实际、证据、恢复四列;没有执行的项目明确标记。",
  1645. "acceptance": [
  1646. "CRUD与并发有真实断言。",
  1647. "不把DDL当可由外层事务自动清理。"
  1648. ]
  1649. },
  1650. "intro": {
  1651. "scenario": "商品接口、并发保护和通知链路已经接好。现在把一次手工点击变成可重复验收,并给需要真实提交的实验独立空间。",
  1652. "startingPoint": [
  1653. "已完成本模块第 01–11 课,shop_lab_migration 正常链路已验证;在 shop-service 新开测试终端,使用本机专用 shop_lab_test 凭据,先核对主机、DATABASE() 与权限,测试时不运行真实发布器。",
  1654. "沿用数据库模块的 Linux/WSL2 Bash 与同一个 shop-service 目录;每个新终端先 cd 到该目录,再 source .venv/bin/activate,用 python -c \"import sys; print(sys.executable)\" 确认路径为 shop-service/.venv/bin/python。不要切换到 PowerShell 复用它,也不要复制 Windows 的 .venv。第 01 课允许不存在时先创建;之后 SQL 只粘贴到 mysql 客户端。",
  1655. "本课测试库创建后,SQL 终端用 docker exec -it shop-mysql mysql --protocol=TCP -h 127.0.0.1 -P 3306 -u app_user -p shop_lab_test 核对;不要在业务库执行测试清理。"
  1656. ],
  1657. "outcome": "顺序 CRUD 测试能自行清理,真实并发测试用独立连接核对一件商品一事件,故障矩阵记录实际状态与恢复证据。"
  1658. }
  1659. }
  1660. ]