소스 검색

Convert backend curriculum and deployment examples to MySQL 8.4

Codex 1 개월 전
부모
커밋
9a7ca88665

+ 6 - 4
README.md

@@ -58,7 +58,7 @@ npm run build
   "database/01": {
     "type": "link",
     "url": "https://你的课程平台/实际视频地址",
-    "title": "PostgreSQL 练习环境"
+    "title": "MySQL 练习环境"
   }
 }
 ```
@@ -69,10 +69,12 @@ npm run build
 
 `examples/` 和 `public/downloads/api-lab.zip` 是已运行验证的 FastAPI 基础参考项目,包含 30 项测试。其接口不自动包含后续课程新增的 ORM、队列和部署配置。
 
-后续模块在课件中按步骤创建和演进 `notes-service`,提供 SQL、Python、Compose、systemd 和 Nginx 等示例。模块 Markdown 可从网页下载。代码片段必须遵循每一步的文件路径、前置定义和替换说明,不能全部无差别追加到一个文件。外部服务实验需要学员具备对应环境;课件结构和代码语法校验不等同于真实数据库、Docker 或 Linux 故障演练已执行。
+后续模块在课件中按步骤创建和演进 `notes-service`,提供 SQL、Python、Compose、systemd 和 Nginx 等示例。模块 Markdown 可从网页下载。代码片段必须遵循每一步的文件路径、前置定义和替换说明,不能全部无差别追加到一个文件。外部服务实验需要学员具备对应环境。MySQL 改版使用隔离的 MySQL 8.4.11 容器验证了约束、事务隔离、并发锁、任务状态与 outbox 恢复、排障 SQL,以及 mysqldump 备份与空库恢复。任务验证中的消息发送使用模拟;完整 Celery 拓扑、Ubuntu/systemd 发布和真实生产故障演练未执行。课件的 Python 3.11 语法检查与实际运行验证分别统计。
 
-数据库使用独立 `notes_lab`,示例宿主端口为 PostgreSQL 5433、Redis 缓存 6380、任务 broker 6384;Redis 容量、AOF、复制实验分别使用 6381、6382、6383。容器内部使用服务名和 5432/6379,不能把宿主地址机械复制到容器里。数据库备份、停止进程、锁等待、资源限制等故障操作均限于课件指定的隔离环境。
+数据库统一使用 MySQL 8.4 LTS、InnoDB 和 utf8mb4。API 使用 SQLAlchemy 2 + asyncmy;同步数据库、缓存和任务示例使用 PyMySQL。时间统一按 UTC 写入 `DATETIME(6)`,课程区分 MySQL 默认 REPEATABLE READ 与应用显式 READ COMMITTED,并按 MySQL 处理自增主键、JSON、行锁和 DDL 隐式提交。
+
+数据库使用独立 `notes_lab`,Alembic 迁移实验使用 `notes_lab_migration`,示例宿主端口为 MySQL 3307、Redis 缓存 6380、任务 broker 6384;Redis 容量、AOF、复制实验分别使用 6381、6382、6383。容器内部使用服务名和 3306/6379,不能把宿主地址机械复制到容器里。数据库备份、停止进程、锁等待、资源限制等故障操作均限于课件指定的隔离环境。
 
 ## 发布
 
-继续使用 `.openai/hosting.json` 中已有 Sites 项目。发布会更新既有网页,访问范围保持现有设置。页面上线只提供课件和静态下载,不会在 Sites 中启动 PostgreSQL、Celery 或 Python 示例服务。
+继续使用 `.openai/hosting.json` 中已有 Sites 项目。发布会更新既有网页,访问范围保持现有设置。页面上线只提供课件和静态下载,不会在 Sites 中启动 MySQL、Celery 或 Python 示例服务。

+ 4 - 4
app/module-catalog.ts

@@ -36,7 +36,7 @@ export const modules = [
     icon: Database,
     count: 12,
     kicker: '数据与事务',
-    stack: 'PostgreSQL 16 · SQL · psycopg 3',
+    stack: 'MySQL 8.4 LTS · InnoDB · PyMySQL',
     description:
       '建立有约束的数据模型,编写查询,在并发和故障下验证事务、锁、索引与数据恢复。',
     project: 'notes_lab 数据库',
@@ -44,7 +44,7 @@ export const modules = [
       '从表结构和 SQL 开始,完成查询计划分析、并发事务实验、最小权限与可恢复的备份。',
     prerequisites: [
       '已完成 API 服务开发',
-      'PostgreSQL 16 与独立 notes_lab 练习库',
+      'MySQL 8.4 LTS 与独立 notes_lab 练习库',
     ],
     depends: ['api'],
     outcomes: [
@@ -60,7 +60,7 @@ export const modules = [
     icon: Layers3,
     count: 12,
     kicker: '持久化服务',
-    stack: 'SQLAlchemy 2 · Alembic · PostgreSQL',
+    stack: 'SQLAlchemy 2 · Alembic · MySQL',
     description:
       '把笔记接口接入数据库,明确会话与事务边界,处理并发更新、幂等请求和兼容性迁移。',
     project: 'notes-service 持久化 API',
@@ -72,7 +72,7 @@ export const modules = [
       '每个请求独立使用 Session,并在明确边界提交或回滚。',
       '验证连接池预算、超时、重复提交与并发修改。',
       '通过 Alembic 实施兼容性迁移并验证旧版本行为。',
-      '以真实 PostgreSQL 测试约束、事务和故障路径。',
+      '以真实 MySQL 测试约束、事务和故障路径。',
     ],
   },
   {

+ 1 - 1
app/task-delivery-demo.tsx

@@ -102,7 +102,7 @@ export function TaskDeliveryDemo() {
       <div className="delivery-nodes">
         {[
           { name: 'API', icon: Code2 },
-          { name: 'PostgreSQL', icon: Database },
+          { name: 'MySQL', icon: Database },
           { name: '消息队列', icon: Network },
           { name: 'Worker', icon: Server },
         ].map((n, i) => (

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 347 - 301
public/content/api-database.json


+ 637 - 608
public/content/database.json

@@ -1,1246 +1,1275 @@
 [
   {
     "id": "01",
-    "title": "准备 PostgreSQL 与 psql 练习环境",
     "group": "连接与数据模型",
-    "summary": "为 notes-service 建立独立练习库,区分数据库进程、数据库、角色与客户端。",
+    "title": "准备 MySQL 8.4 与命令行练习环境",
+    "summary": "创建独立的 notes_lab,核对客户端、账号、字符集和会话参数。",
     "objectives": [
-      "启动 PostgreSQL 16 并创建非超级用户",
-      "用 psql 明确连接地址、端口、角色和数据库",
-      "区分连接失败、认证失败和 SQL 错误"
+      "启动MySQL 8.4 LTS并用业务账号连接",
+      "区分容器端口3306与宿主端口3307",
+      "核对InnoDB、utf8mb4和当前数据库"
     ],
     "concepts": [
       {
-        "title": "服务端与客户端",
-        "body": "PostgreSQL 进程负责保存数据和执行 SQL;psql 是向它发送命令的终端工具。关闭 psql 不会停止数据库。一个 PostgreSQL 实例可以包含多个数据库,连接时必须确定要进入哪个库。"
+        "title": "服务进程、数据库与客户端",
+        "body": "mysqld持续保存和处理数据;mysql命令行客户端发送SQL。一个实例中可以创建多个数据库。MySQL中的SCHEMA与DATABASE在这里是同义词,账号则是用户名与来源host的组合。退出客户端不会停止服务。"
       },
       {
-        "title": "角色与数据库分别创建",
-        "body": "角色表示谁在连接,数据库表示在哪里读写。练习的 app_user 是 notes_lab 所有者,但不是超级用户。第 11 课再拆分迁移角色与运行时角色,避免 API 进程拥有修改表结构的权限。"
+        "title": "本机独立实验边界",
+        "body": "所有建表、写入、锁等待与恢复命令仅在notes_lab练习环境运行,不针对线上实例。需要Docker与Python 3.11+;Windows建议在WSL Bash中执行本模块Shell命令。本节只借Docker获得一致环境,后续容器模块再系统讲解。"
       },
       {
-        "title": "实验边界",
-        "body": "本模块所有命令都是学员在独立 notes_lab 中执行的练习,不是让你操作线上数据库。需要 Python 3.11+ 和已安装、启动的 Docker。此处只用容器获得一致的 PostgreSQL 16 环境;镜像、卷与容器网络在 Docker 模块系统学习。"
+        "title": "默认值也要核对",
+        "body": "本课程固定MySQL 8.4、InnoDB、utf8mb4;默认隔离级别通常是REPEATABLE-READ,后续实验显式设置。统一把DATETIME(6)中的业务时间解释为UTC,各客户端建立连接时设置+00:00。"
       }
     ],
     "steps": [
       {
-        "title": "建立项目与本机密码文件",
-        "body": "以下为 Linux / macOS Bash 命令;Windows 可在 WSL 中执行。本节使用 127.0.0.1:5433,容器内部仍是 5432。密码只用于本机练习,不要提交到 Git。终端在 notes-service 中执行。",
-        "code": "mkdir -p notes-service\ncd notes-service\numask 077\nprintf 'POSTGRES_PASSWORD=%s\\n' \"$(python3 -c 'import secrets; print(secrets.token_urlsafe(24))')\" > .postgres.env\nprintf '.postgres.env\\n.env\\nbackups/\\n.venv/\\n' >> .gitignore\ndocker run --name notes-postgres --env-file .postgres.env \\\n  -p 127.0.0.1:5433:5432 \\\n  -v notes_pgdata:/var/lib/postgresql/data -d postgres:16\ndocker exec notes-postgres pg_isready -U postgres",
+        "title": "生成本机凭据并启动数据库",
+        "body": "在准备存放工程的父目录中执行以下 Bash,命令会创建并进入 notes-service。仅首次初始化时生成凭据;继续学习时进入既有目录并复用 .mysql.env,不要重复生成密码。镜像首次初始化会创建notes_lab和app_user,app_user只在该练习数据库获得权限,不是root。密码生成在忽略文件中,终端不打印;重新使用旧数据卷时环境变量不会重置已存在账号。",
+        "code": "mkdir -p notes-service\ncd notes-service\numask 077\npython3 - <<'PY'\nimport secrets\nfrom pathlib import Path\nroot_password=secrets.token_urlsafe(32)\napp_password=secrets.token_urlsafe(32)\nPath('.mysql.env').write_text(f'MYSQL_ROOT_PASSWORD={root_password}\\nMYSQL_DATABASE=notes_lab\\nMYSQL_USER=app_user\\nMYSQL_PASSWORD={app_password}\\n')\nPY\nprintf '.mysql.env\\n.mysql-client.cnf\\n.env\\nbackups/\\n.venv/\\n' >> .gitignore\ndocker run --name notes-mysql --env-file .mysql.env \\\n  -p 127.0.0.1:3307:3306 -v notes_mysql_data:/var/lib/mysql \\\n  -d mysql:8.4 --character-set-server=utf8mb4 --collation-server=utf8mb4_0900_ai_ci --default-time-zone=+00:00\ndocker logs --tail 30 notes-mysql",
         "lang": "bash"
       },
       {
-        "title": "创建练习角色和数据库",
-        "body": "等待 pg_isready 显示 accepting connections。先执行 docker exec -it notes-postgres psql -U postgres -d postgres,然后在该 psql 中执行下列命令。\\password 会交互要求输入两遍 app_user 密码,不在 SQL 历史中直接写出密码。下面建库命令只执行一次。",
-        "code": "CREATE ROLE app_user LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE;\n\\password app_user\nCREATE DATABASE notes_lab OWNER app_user;\n\\q",
-        "lang": "sql"
+        "title": "用交互密码连接业务库",
+        "body": "等待日志出现ready for connections。用编辑器读取本机.mysql.env中的MYSQL_PASSWORD用于交互登录,不粘贴到共享日志。第一条命令在容器内连接3306;本机安装了mysql客户端则用第二条,两条选择一条。",
+        "code": "docker exec -it notes-mysql mysql --protocol=TCP -h 127.0.0.1 -P 3306 -u app_user -p notes_lab\n# 或本机客户端:\nmysql --protocol=TCP -h 127.0.0.1 -P 3307 -u app_user -p notes_lab",
+        "lang": "bash"
       },
       {
-        "title": "用业务身份重新连接",
-        "body": "执行 docker exec -it notes-postgres psql -h 127.0.0.1 -U app_user -d notes_lab -W,输入刚设置的密码。该命令在容器内访问容器的 5432。若本机已安装 psql,则使用 psql -h 127.0.0.1 -p 5433 -U app_user -d notes_lab -W。后续未特别标明的 SQL 都在此连接运行。",
-        "code": "\\conninfo\nSELECT current_database(), current_user, version();\nSHOW server_encoding;\nSHOW TimeZone;\n\\set VERBOSITY verbose\n\\timing on",
+        "title": "确认环境与身份",
+        "body": "下面是mysql客户端中的SQL。DATABASE()必须是notes_lab,CURRENT_USER()显示匹配到的授权账号;不要只凭命令行提示符判断目标。默认排序规则ai_ci不区分重音和大小写,涉及唯一名称时要把此行为纳入设计。",
+        "code": "SELECT VERSION(),DATABASE(),CURRENT_USER(),USER();\nSELECT @@default_storage_engine,@@character_set_database,@@collation_database;\nSELECT @@transaction_isolation,@@autocommit,@@sql_mode;\nSET SESSION time_zone = '+00:00';\nSELECT @@session.time_zone,UTC_TIMESTAMP(6);\nSHOW GRANTS FOR CURRENT_USER();",
         "lang": "sql"
       },
       {
-        "title": "验证持久化与 SQL 命令边界",
-        "body": "运行两条 SELECT,观察分号用于结束 SQL,反斜杠命令由 psql 处理。退出客户端后重新连接,数据库仍存在。数据库卷保留了实例数据;不要为了重新做某节练习删除整个卷。",
-        "code": "SELECT 6 * 7 AS answer;\n-- answer = 42\nSELECT current_database() = 'notes_lab' AS in_lab;\n-- in_lab = t\n\\q",
+        "title": "验证客户端和事务开关",
+        "body": "mysql交互客户端通常autocommit=1,单条DML独立提交;Python稍后显式使用autocommit=False。不要将两个客户端的默认行为混为一谈。退出重连后再次确认数据库。",
+        "code": "SELECT 6*7 AS answer;\n-- answer=42\nSELECT DATABASE()='notes_lab' AS in_lab;\n-- in_lab=1\nquit",
         "lang": "sql"
       }
     ],
     "checks": [
-      "current_database() 为 notes_lab,current_user 为 app_user。",
-      "version() 显示 PostgreSQL 16,server_encoding 为 UTF8。",
-      "退出并重新连接后 SELECT 6 * 7 仍能执行;数据库进程没有因客户端退出而停止。"
+      "服务版本为8.4,数据库为notes_lab,存储引擎为InnoDB。",
+      "客户端使用3307或容器内部3306的正确位置,业务身份不是root。",
+      "字符集utf8mb4、会话UTC和隔离级别已实际核对。"
     ],
     "pitfalls": [
       {
-        "symptom": "connection refused",
-        "cause": "容器未启动、端口尚未就绪或混用了宿主机与容器端口。",
-        "fix": "用 docker ps 与 pg_isready 检查状态;本机使用 5433,docker exec 内部使用 5432。"
+        "symptom": "Can't connect to MySQL server",
+        "cause": "初始化尚未完成,或混淆宿主与容器端口。",
+        "fix": "查看专用容器日志;宿主3307,docker exec内部3306。"
       },
       {
-        "symptom": "role app_user does not exist 或 password authentication failed",
-        "cause": "创建角色步骤未执行,或输入的是 postgres 密码。",
-        "fix": "使用管理连接检查 \\du;通过 \\password app_user 重设本机练习密码,再用业务身份重连。"
+        "symptom": "Access denied for user",
+        "cause": "用户名/host匹配或密码不对;旧卷不会因env变化重设密码。",
+        "fix": "使用当前卷的真实凭据,管理账号确认账号host;需要改密时执行明确ALTER USER,不删卷解决。"
       }
     ],
     "exercise": {
-      "task": "分别在一个正确连接和一个故意写错数据库名的连接中观察结果,记录错误发生在哪一层。",
-      "hint": "将 notes_lab 暂时写成 notes_missing;不要真的创建这个库。",
-      "answer": "正确连接能执行 SELECT;错误库名在连接阶段返回 database does not exist,SQL 尚未执行。连接地址错通常是网络层失败,密码错是认证失败,SELECT 1/0 则是连接成功后的 SQL 执行错误。"
+      "task": "故意用notes_missing连接,再与连接成功后SELECT 1/0比较错误层次。",
+      "hint": "数据库名属于连接目标,SQL表达式在建立会话之后才执行。",
+      "answer": "不存在数据库通常为1049 Unknown database;认证失败常为1045。MySQL中SELECT 1/0通常返回NULL并产生警告,可用SHOW WARNINGS查看,不能把它当作稳定抛异常的事务测试。要测试约束失败请使用后续非法INSERT。"
     },
     "refs": [
       {
-        "label": "PostgreSQL 16:psql",
-        "url": "https://www.postgresql.org/docs/16/app-psql.html"
+        "label": "MySQL 8.4:mysql客户端",
+        "url": "https://dev.mysql.com/doc/refman/8.4/en/mysql.html"
       },
       {
-        "label": "PostgreSQL 16:创建数据库",
-        "url": "https://www.postgresql.org/docs/16/sql-createdatabase.html"
+        "label": "MySQL 8.4:账号名称",
+        "url": "https://dev.mysql.com/doc/refman/8.4/en/account-names.html"
       },
       {
-        "label": "Docker:PostgreSQL 官方镜像",
-        "url": "https://hub.docker.com/_/postgres"
+        "label": "Docker:MySQL镜像",
+        "url": "https://hub.docker.com/_/mysql"
       }
     ],
     "production": {
-      "scenario": "新服务需要一个可重复创建、不会误连线上实例的开发数据库。",
+      "scenario": "新服务需要能重复建立且不会误连线上库的开发环境。",
       "decisions": [
-        "连接串明确包含数据库名与端口。",
-        "先用非超级用户练习,环境文件加入忽略规则。",
-        "生产环境选择受维护的 16.x 补丁版并锁定镜像摘要;postgres:16 是本课程环境入口。"
+        "显式核对数据库与授权账号。",
+        "本机密码文件不进入仓库。",
+        "生产记录8.4补丁版和镜像摘要,练习使用mysql:8.4入口。"
       ],
-      "failureDrill": "在独立练习环境中用错误端口尝试连接,依据 pg_isready 与实际端口定位原因。",
+      "failureDrill": "在独立实例用错库名与端口连接,分别记录错误层次。",
       "acceptance": [
-        "能从连接信息确认当前实例和身份。",
-        "数据库卷与容器用途可区分。",
-        "仓库没有密码文件。"
+        "目标可辨认。",
+        "环境与数据卷生命周期可区分。",
+        "无共享明文密码。"
       ]
     }
   },
   {
     "id": "02",
-    "title": "定义笔记表、数据类型与约束",
     "group": "连接与数据模型",
-    "summary": "把 Python 对象转成有明确边界的数据表,让非法数据在数据库入口被拒绝。",
+    "title": "定义InnoDB笔记表、UTC时间与数据约束",
+    "summary": "创建统一notes模型,验证布尔值、标题和时间的真实MySQL行为。",
     "objectives": [
-      "建立 notes 表并理解主键",
-      "选择 text、boolean 和 timestamptz",
-      "通过 NOT NULL、CHECK 与默认值维护数据规则"
+      "建立AUTO_INCREMENT主键和CHECK约束",
+      "区分DATETIME存值约定与时区转换",
+      "说明JSON、TEXT和BOOLEAN的存储边界"
     ],
     "concepts": [
       {
-        "title": "列类型约束存储形式",
-        "body": "bigint 存储整数编号,varchar(120) 限制标题字符数,text 存储正文。boolean 使用 true / false,不能用随意约定的字符串。时间使用 timestamptz 表示一个时间点,显示格式受会话时区影响。"
+        "title": "类型与约束各自负责边界",
+        "body": "id使用BIGINT AUTO_INCREMENT,标题VARCHAR(120),正文TEXT。MySQL BOOLEAN是TINYINT(1)同义词,单写BOOLEAN不会限制只能0或1,所以增加CHECK。MySQL 8.4会执行CHECK,不应沿用旧版本忽略CHECK的印象。"
       },
       {
-        "title": "默认值与非空是两条规则",
-        "body": "DEFAULT 只在插入时省略列或使用 DEFAULT 时生效;显式传 NULL 仍然是 NULL。NOT NULL 才拒绝空值,CHECK 再约束有效内容。应用校验改善错误提示,数据库约束保护所有写入入口。"
+        "title": "UTC是明确的数据约定",
+        "body": "DATETIME(6)保存带微秒的日期时间字段,本身不保存时区,也不会因SET time_zone转换已有值。连接统一+00:00,写入使用UTC_TIMESTAMP(6)或UTC会话下的CURRENT_TIMESTAMP(6),Python输出时再明确标注UTC。"
       },
       {
-        "title": "主键和生成编号",
-        "body": "identity 自动提供编号,PRIMARY KEY 保证编号唯一且非空。编号用于定位,不保证连续,也不代表严格的业务提交顺序。updated_at 的默认值只在插入时生效,更新时需要显式修改或使用统一触发器。"
+        "title": "结构化扩展字段需限定用途",
+        "body": "JSON列验证文档语法,但不能替代需要连接、唯一性和外键的关系表。TEXT默认值使用表达式DEFAULT ('');正文上限16000字符,utf8mb4最坏64000字节仍在TEXT容量内;JSON和日志也应限制大小,避免无限载荷。updated_at不自动更新,本课程每次写入显式维护。"
       }
     ],
     "steps": [
       {
-        "title": "确认连接后创建表",
-        "body": "在第 01 课的 app_user / notes_lab 连接中执行。保存为 sql/001_notes.sql,首次运行一次;后续变更使用单独迁移文件,不要反复删表重建。",
-        "code": "CREATE TABLE notes (\n  id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,\n  title varchar(120) NOT NULL CHECK (char_length(btrim(title)) > 0),\n  content text NOT NULL DEFAULT '',\n  pinned boolean NOT NULL DEFAULT false,\n  created_at timestamptz NOT NULL DEFAULT now(),\n  updated_at timestamptz NOT NULL DEFAULT now()\n);\n\\d notes",
+        "title": "首次创建统一notes表",
+        "body": "在app_user/notes_lab下执行一次,保存为sql/001_notes.sql。version从第一版就存在,后续并发修改统一递增。所有业务表明确ENGINE=InnoDB,不依赖不同环境的隐含配置。",
+        "code": "CREATE TABLE notes (\n id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,\n title VARCHAR(120) NOT NULL,\n content TEXT NOT NULL DEFAULT (''),\n pinned BOOLEAN NOT NULL DEFAULT FALSE,\n created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),\n updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),\n version INT NOT NULL DEFAULT 1,\n CONSTRAINT notes_title_nonempty CHECK (CHAR_LENGTH(TRIM(title))>0),\n CONSTRAINT notes_pinned_boolean CHECK (pinned IN (0,1)),\n CONSTRAINT notes_content_limit CHECK (CHAR_LENGTH(content)<=16000),\n CONSTRAINT notes_version_positive CHECK (version>0)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;\nSHOW CREATE TABLE notes;",
         "lang": "sql",
         "filename": "sql/001_notes.sql"
       },
       {
-        "title": "插入合法数据并看默认值",
-        "body": "本节写入的标题用 db02- 前缀,避免与后续实验混淆。RETURNING 在同一次写入中返回实际保存的行。记录生成的编号,但后续练习不依赖它必须从 1 开始。",
-        "code": "INSERT INTO notes (title) VALUES ('db02-first')\nRETURNING id, title, content, pinned, created_at, updated_at;\n-- content 为空串,pinned 为 f,两列时间均非空\nSELECT count(*) FROM notes WHERE title = 'db02-first';",
+        "title": "插入并从同一会话取得编号",
+        "body": "MySQL 8.4不提供本模块DML的RETURNING语法。插入后使用同一会话LAST_INSERT_ID(),Python使用cursor.lastrowid;绝不能查询MAX(id)猜自己的编号。",
+        "code": "INSERT INTO notes(title) VALUES('db02-first');\nSET @note_id=LAST_INSERT_ID();\nSELECT id,title,content,pinned,version,created_at,updated_at FROM notes WHERE id=@note_id;\n-- content空串,pinned=0,version=1,时间非空",
         "lang": "sql"
       },
       {
-        "title": "让数据库拒绝非法数据",
-        "body": "以下两条语句在 psql 默认自动提交状态逐条执行;预期分别失败,不要包进同一个 BEGIN 后继续运行。随后 count(*) 仍为 1。",
-        "code": "INSERT INTO notes (title) VALUES (NULL);\n-- SQLSTATE 23502: not_null_violation\nINSERT INTO notes (title) VALUES ('   ');\n-- SQLSTATE 23514: check_violation\nSELECT count(*) FROM notes WHERE title = 'db02-first';",
+        "title": "逐条提交非法值观察错误",
+        "body": "在交互客户端autocommit=1时逐条运行,预期各自失败。先确认STRICT_TRANS_TABLES包含在sql_mode,超长数据不能静默截断。随后核对只有合法样本。",
+        "code": "INSERT INTO notes(title) VALUES(NULL);\n-- 1048:列不可为NULL\nINSERT INTO notes(title) VALUES('   ');\n-- 3819:CHECK失败\nINSERT INTO notes(title,pinned) VALUES('db02-invalid-bool',2);\n-- 3819:BOOLEAN底层是整数,CHECK拦住2\nINSERT INTO notes(title) VALUES(REPEAT('a',121));\n-- 严格模式下1406:数据过长\nSELECT id,title FROM notes WHERE id=@note_id;",
         "lang": "sql"
       },
       {
-        "title": "观察同一时间点的时区显示",
-        "body": "这里改的是当前会话显示方式。UTC 与上海时间显示相差 8 小时,epoch 秒数相同。业务对外使用带时区 ISO 8601,不能在 Python 中去掉 tzinfo 后再猜测时区。",
-        "code": "SET TIME ZONE 'UTC';\nSELECT created_at, extract(epoch FROM created_at) FROM notes WHERE title='db02-first';\nSET TIME ZONE 'Asia/Shanghai';\nSELECT created_at, extract(epoch FROM created_at) FROM notes WHERE title='db02-first';\nSET TIME ZONE 'UTC';",
+        "title": "验证DATETIME不会随显示时区转换",
+        "body": "同一created_at在两个会话时区设置下保持原字段值,而NOW(6)随会话时区变化、UTC_TIMESTAMP(6)保持UTC。不要把没有tzinfo的数据库datetime直接当作用户本地时间。最后恢复+00:00。",
+        "code": "SET SESSION time_zone='+00:00';\nSELECT created_at,NOW(6),UTC_TIMESTAMP(6) FROM notes WHERE id=@note_id;\nSET SESSION time_zone='+08:00';\nSELECT created_at,NOW(6),UTC_TIMESTAMP(6) FROM notes WHERE id=@note_id;\nSET SESSION time_zone='+00:00';\nSELECT JSON_EXTRACT('{\"source\":\"api\",\"revision\":1}','$.source') AS source;\n-- \"api\":JSON字符串与SQL普通字符串的输出表示不同",
         "lang": "sql"
       }
     ],
     "checks": [
-      "\\d notes 显示主键、identity、非空与标题 CHECK。",
-      "省略 content 和 pinned 时得到默认值;显式 NULL 标题被拒绝。",
-      "切换时区后显示不同,epoch 值保持一致。"
+      "SHOW CREATE TABLE确认InnoDB、CHECK、version和utf8mb4。",
+      "非法NULL、空标题和pinned=2被拒绝,默认值与主键正确。",
+      "切换时区时DATETIME原值不变,能解释UTC存值约定。"
     ],
     "pitfalls": [
       {
-        "symptom": "UPDATE 后 updated_at 没变化",
-        "cause": "DEFAULT now() 只参与插入。",
-        "fix": "后续每次更新同时设置 updated_at=now();多人维护时把规则收敛到统一写入层或触发器。"
+        "symptom": "pinned保存成2也成功",
+        "cause": "只声明BOOLEAN却没有CHECK。",
+        "fix": "定义CHECK(pinned IN(0,1)),应用入口也按布尔校验。"
       },
       {
-        "symptom": "空标题进入数据库",
-        "cause": "只使用了 NOT NULL,空串不是 NULL。",
-        "fix": "加入 btrim 与长度 CHECK,并在应用侧做一致校验;已有不合格数据需先清理。"
+        "symptom": "序列化时间出现8小时偏差",
+        "cause": "把UTC约定的无时区DATETIME当本地时间解释。",
+        "fix": "统一连接+00:00,读出后按已知UTC约定加timezone.utc,输出带时区ISO8601。"
       }
     ],
     "exercise": {
-      "task": "验证 121 个字符的标题被拒绝,再插入带单引号的合法标题。",
-      "hint": "SQL 字面量中的单引号用两个单引号表示;Python 动态值在下一课使用参数化。",
-      "answer": "INSERT INTO notes(title) VALUES(repeat('a',121)); 会触发长度错误。INSERT INTO notes(title) VALUES('db02-O''Reilly') RETURNING title; 返回 db02-O'Reilly。SQL 中的引号转义不替代 Python 驱动的参数化接口。"
+      "task": "比较JSON扩展属性与tags关系表,说明标签唯一性应该放在哪里。",
+      "hint": "JSON适合弱结构扩展,不自动提供跨行唯一和外键规则。",
+      "answer": "标签名在tags表使用UNIQUE,笔记标签在关联表维护外键。少量无需关系查询的附加属性可用JSON;热点JSON路径需显式生成列或函数索引并验证计划。"
     },
     "refs": [
       {
-        "label": "PostgreSQL 16:约束",
-        "url": "https://www.postgresql.org/docs/16/ddl-constraints.html"
+        "label": "MySQL 8.4:CREATE TABLE与CHECK",
+        "url": "https://dev.mysql.com/doc/refman/8.4/en/create-table.html"
       },
       {
-        "label": "PostgreSQL 16:日期时间类型",
-        "url": "https://www.postgresql.org/docs/16/datatype-datetime.html"
+        "label": "MySQL 8.4:时间类型",
+        "url": "https://dev.mysql.com/doc/refman/8.4/en/date-and-time-types.html"
       },
       {
-        "label": "PostgreSQL 16:生成列编号",
-        "url": "https://www.postgresql.org/docs/16/sql-createtable.html"
+        "label": "MySQL 8.4:JSON",
+        "url": "https://dev.mysql.com/doc/refman/8.4/en/json.html"
       }
     ],
     "production": {
-      "scenario": "同一张表将被 API、导入脚本和后台任务共同写入。",
+      "scenario": "API、脚本和worker共同写入同一张表。",
       "decisions": [
-        "关键合法性规则落到数据库。",
-        "编号只当标识符,不用于推算数据量。",
-        "统一存时间点、在展示层选择时区。"
+        "完整性规则放到数据库。",
+        "UTC作为所有写入入口的共同约定。",
+        "编号不保证连续,updated_at与version由统一写入代码维护。"
       ],
-      "failureDrill": "向 notes_lab 提交空标题、NULL 和超长标题,逐条记录 SQLSTATE。",
+      "failureDrill": "向notes_lab提交非法标题和布尔整数,记录错误码与未落库结果。",
       "acceptance": [
-        "非法行没有落库。",
-        "合法写入能返回真实生成值。",
-        "能说明 updated_at 的维护位置。"
+        "约束真实生效。",
+        "时间序列化规则一致。",
+        "没有依赖自增编号连续性。"
       ]
     }
   },
   {
     "id": "03",
-    "title": "用参数化 SQL 完成增删改查",
     "group": "SQL 与关系建模",
-    "summary": "从 Python 读写笔记,处理不存在的数据,并验证用户输入不会变成 SQL。",
+    "title": "用PyMySQL参数化完成增删改查",
+    "summary": "建立显式提交、回滚和关闭的Python访问层,用lastrowid与rowcount确认结果。",
     "objectives": [
-      "使用 psycopg 3 传递参数",
-      "通过 RETURNING 判断实际写入结果",
-      "用明确列名、过滤与排序控制查询"
+      "使用DictCursor和独立参数元组",
+      "正确读取lastrowid和受影响行数",
+      "在成功与异常路径释放事务和连接"
     ],
     "concepts": [
       {
-        "title": "SQL 结构与参数分开传递",
-        "body": "execute 的 SQL 字符串描述操作结构,第二个参数提供数据。值占位符统一写 %s,不自行加引号,也不用 f-string 拼接输入。表名、列名不能作为普通值参数传入,动态排序应使用固定白名单。"
+        "title": "动态值通过驱动参数传递",
+        "body": "PyMySQL execute(sql,params)的%s表示值,不加引号、不用f-string替换用户输入。标识符不是值参数,动态列名和排序方向用固定白名单。连接默认禁用多语句,不启用MULTI_STATEMENTS。"
       },
       {
-        "title": "写入结果要检查",
-        "body": "UPDATE 或 DELETE 匹配不到数据通常不是 SQL 错误。RETURNING 返回零行能表示资源不存在;不要在未检查结果时声称修改成功。"
+        "title": "MySQL写入结果的读取方法",
+        "body": "INSERT后从当前cursor.lastrowid取得生成编号,再SELECT同一行。UPDATE要根据rowcount及业务语义判断:默认统计实际变化行,相同值可能返回0;本课每次更新递增version,因此成功匹配一定发生变化。"
       },
       {
-        "title": "读取结果必须有边界",
-        "body": "查询显式列名能稳定输出结构。列表查询需要 LIMIT 和确定排序,否则数据库不承诺返回顺序。id DESC 可作为当前课程按编号倒序的简单规则。"
+        "title": "显式管理生命周期",
+        "body": "PyMySQL使用autocommit=False。本课完整脚本try中commit、except中rollback、finally中close,cursor上下文只关闭cursor,不代替数据库提交。连接读写超时是客户端等待预算,不等同于服务端事务整体超时。"
       }
     ],
     "steps": [
       {
-        "title": "安装驱动与设置连接",
-        "body": "在 notes-service 创建并激活 Python 3.11+ 虚拟环境,执行 python -m pip install \"psycopg[binary]>=3.2,<4\"。把实际解析版本写入 requirements-lock.txt。Linux 使用下面的 PG 环境变量,PGPASSWORD 在当前 shell 中交互输入,不写进脚本。正式环境使用密钥注入并避免输出连接串。",
-        "code": "export PGHOST=127.0.0.1 PGPORT=5433 PGDATABASE=notes_lab PGUSER=app_user\nread -rsp 'Database password: ' PGPASSWORD\nprintf '\\n'\nexport PGPASSWORD\npython -m pip freeze > requirements-lock.txt",
+        "title": "安装同步驱动并设置环境",
+        "body": "Bash沿用第01课生成文件。set -a与source只用于自己生成的本机文件,不source不可信文件。MySQL 8.4默认认证使用caching_sha2_password,安装PyMySQL的rsa附加依赖用于无TLS练习连接;生产应配置可信TLS。",
+        "code": "python -m pip install 'PyMySQL[rsa]>=1.1,<2'\nset -a\nsource .mysql.env\nset +a\nunset MYSQL_ROOT_PASSWORD\nexport MYSQL_HOST=127.0.0.1 MYSQL_PORT=3307\npython -m pip freeze > requirements-lock.txt",
         "lang": "bash"
       },
       {
-        "title": "保存并运行完整 CRUD 脚本",
-        "body": "保存为 db_crud.py,再执行 python db_crud.py。connect(\"\") 读取 PG 环境变量;上下文正常退出提交,异常退出回滚。单元素参数必须带逗号。此脚本先创建自己的数据,最后删除同一行,可以重复执行。",
-        "code": "import psycopg\nfrom psycopg.rows import dict_row\n\nwith psycopg.connect(\"\", row_factory=dict_row) as conn:\n    created = conn.execute(\n        \"INSERT INTO notes(title, content) VALUES (%s, %s) RETURNING id, title\",\n        (\"db03-O'Reilly\", \"parameterized insert\"),\n    ).fetchone()\n    note_id = created[\"id\"]\n    print(\"created\", created)\n    print(\"read\", conn.execute(\n        \"SELECT id, title, pinned FROM notes WHERE id = %s\", (note_id,)\n    ).fetchone())\n    print(\"updated\", conn.execute(\n        \"UPDATE notes SET pinned = %s, updated_at = now() WHERE id = %s RETURNING id, pinned\",\n        (True, note_id),\n    ).fetchone())\n    print(\"deleted\", conn.execute(\n        \"DELETE FROM notes WHERE id = %s RETURNING id\", (note_id,)\n    ).fetchone())\n    print(\"missing\", conn.execute(\n        \"SELECT id FROM notes WHERE id = %s\", (note_id,)\n    ).fetchone())",
+        "title": "保存统一连接函数",
+        "body": "保存mysql_client.py;后续Redis模块直接复用。max_execution_time主要限制SELECT,innodb_lock_wait_timeout处理行锁,lock_wait_timeout处理元数据锁;它们不是统一的所有SQL执行超时。生产另配置请求deadline和慢查询观测。此直接驱动入口使用MYSQL_*,同步SQLAlchemy连接串才是mysql+pymysql://。",
+        "code": "import os\nimport pymysql\nfrom pymysql.cursors import DictCursor\n\ndef connect():\n    conn = pymysql.connect(\n        host=os.environ.get(\"MYSQL_HOST\", \"127.0.0.1\"),\n        port=int(os.environ.get(\"MYSQL_PORT\", \"3307\")),\n        user=os.environ.get(\"MYSQL_USER\", \"app_user\"),\n        password=os.environ[\"MYSQL_PASSWORD\"],\n        database=os.environ.get(\"MYSQL_DATABASE\", \"notes_lab\"),\n        charset=\"utf8mb4\", cursorclass=DictCursor, autocommit=False,\n        connect_timeout=3, read_timeout=5, write_timeout=5,\n    )\n    try:\n        with conn.cursor() as cur:\n            cur.execute(\"SET SESSION time_zone = '+00:00'\")\n            cur.execute(\"SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED\")\n            cur.execute(\"SET SESSION innodb_lock_wait_timeout = 2\")\n            cur.execute(\"SET SESSION lock_wait_timeout = 3\")\n            cur.execute(\"SET SESSION max_execution_time = 3000\")\n        conn.commit()\n        return conn\n    except BaseException:\n        conn.close()\n        raise\n",
+        "lang": "python",
+        "filename": "mysql_client.py"
+      },
+      {
+        "title": "执行完整CRUD并显式结束事务",
+        "body": "保存db_crud.py并运行。自建样本最后删除,可以重复执行。读取和修改都只使用自己的lastrowid;同一SQL更新version与updated_at。打印的pinned通常是0/1,业务JSON应按模型转成bool。",
+        "code": "from mysql_client import connect\n\nconn=connect()\ntry:\n    with conn.cursor() as cur:\n        cur.execute(\"INSERT INTO notes(title,content) VALUES(%s,%s)\",(\"db03-O'Reilly\",\"parameterized\"))\n        note_id=cur.lastrowid\n        cur.execute(\"SELECT id,title,pinned,version FROM notes WHERE id=%s\",(note_id,))\n        print(\"created\",cur.fetchone())\n        cur.execute(\"UPDATE notes SET pinned=%s,version=version+1,updated_at=UTC_TIMESTAMP(6) WHERE id=%s\",(True,note_id))\n        assert cur.rowcount==1\n        cur.execute(\"SELECT id,pinned,version FROM notes WHERE id=%s\",(note_id,))\n        print(\"updated\",cur.fetchone())\n        cur.execute(\"DELETE FROM notes WHERE id=%s\",(note_id,))\n        assert cur.rowcount==1\n        cur.execute(\"SELECT id FROM notes WHERE id=%s\",(note_id,))\n        assert cur.fetchone() is None\n    conn.commit()\nexcept BaseException:\n    conn.rollback()\n    raise\nfinally:\n    conn.close()",
         "lang": "python",
         "filename": "db_crud.py"
       },
       {
-        "title": "提交一段看起来像 SQL 的标题",
-        "body": "单独保存为 db_input.py 并运行。预期只插入一条普通笔记,输入里的 DROP TABLE 不会执行。finally 外层连接上下文会正常提交这个插入和删除的组合;最后 notes 表仍能查询。",
-        "code": "import psycopg\n\npayload = \"db03-x'); DROP TABLE notes; --\"\nwith psycopg.connect(\"\") as conn:\n    row = conn.execute(\n        \"INSERT INTO notes(title) VALUES (%s) RETURNING id, title\", (payload,)\n    ).fetchone()\n    assert row[1] == payload\n    print(row)\n    conn.execute(\"DELETE FROM notes WHERE id = %s\", (row[0],))\n    print(\"table exists\", conn.execute(\"SELECT count(*) FROM notes\").fetchone())",
+        "title": "验证SQL片段只成为普通文本",
+        "body": "保存db_input.py。输入中包含单引号和DROP片段,驱动将其作为值处理;读取原值后回滚专用插入,不执行任何表删除命令。注意finally没有代替rollback。",
+        "code": "from mysql_client import connect\n\npayload=\"db03-x'); DROP TABLE notes; --\"\nconn=connect()\ntry:\n    with conn.cursor() as cur:\n        cur.execute(\"INSERT INTO notes(title) VALUES(%s)\",(payload,))\n        note_id=cur.lastrowid\n        cur.execute(\"SELECT title FROM notes WHERE id=%s\",(note_id,))\n        assert cur.fetchone()[\"title\"]==payload\n    conn.rollback()  # 仅验收,不保留样本\nexcept BaseException:\n    conn.rollback()\n    raise\nfinally:\n    conn.close()",
         "lang": "python",
         "filename": "db_input.py"
       },
       {
-        "title": "练习有边界的列表查询",
-        "body": "回到 psql 执行,记录结果列与顺序。修改 LIMIT 观察数量变化。对照 Python 中的写法:LIMIT 也可用 %s 传值,但应用应限制 1 到 100,不能让客户端请求任意数量。",
-        "code": "SELECT id, title, pinned\nFROM notes\nWHERE pinned = false\nORDER BY id DESC\nLIMIT 10;\nUPDATE notes SET pinned = true WHERE id = -1 RETURNING id;\n-- 0 rows / UPDATE 0,不是 SQL 异常",
+        "title": "明确列表边界与不存在结果",
+        "body": "回到mysql客户端执行。列表使用确定排序和LIMIT,更新不存在编号得到0 affected rows;PyMySQL不能用execute返回值当作整条数据库记录。生产API再将未找到与版本冲突区分成约定响应。",
+        "code": "SELECT id,title,pinned FROM notes WHERE pinned=0 ORDER BY id DESC LIMIT 10;\nUPDATE notes SET pinned=1,version=version+1 WHERE id=-1;\nSELECT ROW_COUNT() AS changed_rows;\n-- 0",
         "lang": "sql"
       }
     ],
     "checks": [
-      "CRUD 输出依次出现同一编号,最后 missing 为 None。",
-      "含单引号和 SQL 片段的标题保持原样,notes 表仍存在。",
-      "不存在编号的 UPDATE 返回零行;列表最多 10 行且编号递减。"
+      "CRUD输出使用同一生成编号,更新后version增加。",
+      "输入SQL片段保持文本,数据库表仍可访问。",
+      "每条完整脚本都有commit或显式回滚,以及finally关闭连接。"
     ],
     "pitfalls": [
       {
-        "symptom": "syntax error 或用户输入改变了 SQL 含义",
-        "cause": "把输入放进 f-string 或字符串拼接。",
-        "fix": "恢复固定 SQL 和独立参数元组;不要手写通用转义函数。"
+        "symptom": "读取到的是1而不是记录字典",
+        "cause": "把cursor.execute返回的受影响行数当作查询数据。",
+        "fix": "SELECT后使用fetchone/fetchall,INSERT后使用lastrowid。"
       },
       {
-        "symptom": "脚本输出成功但另一个连接查不到新增行",
-        "cause": "事务尚未提交,或者最后主动删除了练习行。",
-        "fix": "确认连接上下文已经退出;若只验证插入,暂时移除删除步骤后在独立连接核查。"
+        "symptom": "UPDATE相同内容返回0,被误判为不存在",
+        "cause": "默认受影响行数反映实际修改。",
+        "fix": "使用版本递增的条件更新,或明确启用FOUND_ROWS并理解行为;需要区分不存在与未变更时单独查询。"
       }
     ],
     "exercise": {
-      "task": "写一个只更新标题的函数,返回更新后的 id/title;不存在时返回 None。",
-      "hint": "WHERE id = %s,并在同一 UPDATE 中 RETURNING id, title。",
-      "answer": "def rename_note(conn, note_id, title):\n    return conn.execute(\"UPDATE notes SET title=%s, updated_at=now() WHERE id=%s RETURNING id,title\", (title,note_id)).fetchone()\n调用方决定事务提交边界;函数不私自创建连接或提交。"
+      "task": "写一个rename_note(conn,id,title,expected_version),返回是否修改成功,函数不自行提交。",
+      "hint": "把WHERE版本比较、版本递增和时间更新放到一条UPDATE。",
+      "answer": "with conn.cursor() as cur:\n    cur.execute(\"UPDATE notes SET title=%s,version=version+1,updated_at=UTC_TIMESTAMP(6) WHERE id=%s AND version=%s\",(title,id,expected_version))\n    return cur.rowcount==1\n外层负责commit/rollback/close。更新0行可能不存在或冲突,需要时再读取区分。"
     },
     "refs": [
       {
-        "label": "Psycopg 3:参数传递",
-        "url": "https://www.psycopg.org/psycopg3/docs/basic/params.html"
+        "label": "PyMySQL:使用示例",
+        "url": "https://pymysql.readthedocs.io/en/latest/user/examples.html"
       },
       {
-        "label": "Psycopg 3:基本使用",
-        "url": "https://www.psycopg.org/psycopg3/docs/basic/usage.html"
+        "label": "PyMySQL:连接参数",
+        "url": "https://pymysql.readthedocs.io/en/latest/modules/connections.html"
       },
       {
-        "label": "PostgreSQL 16:RETURNING",
-        "url": "https://www.postgresql.org/docs/16/dml-returning.html"
+        "label": "MySQL 8.4:LAST_INSERT_ID",
+        "url": "https://dev.mysql.com/doc/refman/8.4/en/information-functions.html"
       }
     ],
     "production": {
-      "scenario": "笔记标题来自浏览器,数据库不能把任何一段用户输入当成可执行语法。",
+      "scenario": "浏览器输入将进入数据库,写入成功必须有可确认的结果。",
       "decisions": [
-        "参数化所有动态值。",
-        "列表显式限制数量和排序。",
-        "以 RETURNING 判断变更结果。"
+        "固定SQL与独立参数。",
+        "默认不开启多语句。",
+        "不在发生不确定写入结果后无条件重试。"
       ],
-      "failureDrill": "在 notes_lab 用包含单引号与 SQL 片段的标题创建笔记,核验原值与表完整性。",
+      "failureDrill": "对专用样本提交包含SQL语法的标题,再回滚并查询表完整性。",
       "acceptance": [
-        "没有 SQL 字符串插值。",
-        "空结果有明确处理。",
-        "连接与事务在脚本结束后释放。"
+        "无SQL插值。",
+        "空结果和行数语义正确。",
+        "异常路径不泄漏连接。"
       ]
     }
   },
   {
     "id": "04",
-    "title": "用关联表、JOIN 与聚合表达关系",
     "group": "SQL 与关系建模",
-    "summary": "为笔记添加标签,使用外键保证关联有效,再统计每条笔记的标签数量。",
+    "title": "使用外键、JOIN与聚合建模标签",
+    "summary": "建立InnoDB多对多关系,验证唯一关联、外键索引和删除约束。",
     "objectives": [
-      "建立多对多关系和联合主键",
-      "区别 INNER JOIN 与 LEFT JOIN",
-      "避免 JOIN 重复行导致聚合错误"
+      "设计tags和note_tags关系表",
+      "用LEFT JOIN保留零标签笔记",
+      "理解MySQL外键即时检查与索引要求"
     ],
     "concepts": [
       {
-        "title": "多对多关系单独建表",
-        "body": "一条笔记可有多个标签,一个标签可属于多条笔记。note_tags 的每一行代表一组关联,联合主键阻止重复关联。将标签塞进逗号字符串会让检索、唯一性与改名都变得困难。"
+        "title": "联合主键表达一组关系",
+        "body": "note_tags一行表示一条笔记与一个标签的关联,PRIMARY KEY(note_id,tag_id)防重复。标签名UNIQUE遵循表排序规则,默认ai_ci下Python和python可能冲突,需要时选择明确的区分大小写排序规则。"
       },
       {
-        "title": "外键检查关联完整性",
-        "body": "note_id 必须对应真实笔记,tag_id 必须对应真实标签。此例删除笔记时级联清理关联行,删除仍被使用的标签则拒绝;删除策略来自业务,而不是所有外键统一加 CASCADE。"
+        "title": "InnoDB即时检查外键",
+        "body": "父记录必须先存在;MySQL不支持将此类外键检查推迟到COMMIT。InnoDB要求外键列有合适前缀索引,缺少时可能自动创建;本课显式声明tag_id索引便于审计。不要通过foreign_key_checks=0绕过正常业务写入。"
       },
       {
-        "title": "聚合前先辨认行数",
-        "body": "LEFT JOIN 会保留没有标签的笔记,并补一个右侧为空的连接结果。COUNT(*) 会把这一行算进去,COUNT(nt.tag_id) 只统计非空标签。聚合查询返回的是分组后的结果。"
+        "title": "LEFT JOIN后的计数要选列",
+        "body": "未匹配标签的笔记仍有一条右侧NULL行。COUNT(*)会计为1,COUNT(nt.tag_id)得到0。连接更多一对多关系时注意行数放大,不直接对放大后的记录计数。"
       }
     ],
     "steps": [
       {
-        "title": "建立标签和关联表",
-        "body": "在 notes_lab 首次执行一次;保存为 sql/002_tags.sql。引用 notes 主键,不复制标题。外键列并不会自动获得适合所有反向查询的索引,后续根据查询创建。",
-        "code": "CREATE TABLE tags (\n  id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,\n  name varchar(40) NOT NULL UNIQUE CHECK (char_length(btrim(name)) > 0)\n);\nCREATE TABLE note_tags (\n  note_id bigint NOT NULL REFERENCES notes(id) ON DELETE CASCADE,\n  tag_id bigint NOT NULL REFERENCES tags(id) ON DELETE RESTRICT,\n  PRIMARY KEY (note_id, tag_id)\n);",
+        "title": "创建父表与关联表",
+        "body": "保存sql/002_tags.sql,在notes_lab首次执行一次。删除笔记级联清理关联;被引用标签使用RESTRICT。显式存储引擎与字符集,避免外键因表属性不一致失败。",
+        "code": "CREATE TABLE tags (\n id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,\n name VARCHAR(40) NOT NULL UNIQUE,\n CONSTRAINT tags_name_nonempty CHECK(CHAR_LENGTH(TRIM(name))>0)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;\nCREATE TABLE note_tags (\n note_id BIGINT NOT NULL, tag_id BIGINT NOT NULL,\n PRIMARY KEY(note_id,tag_id),\n INDEX note_tags_tag_id_idx(tag_id),\n CONSTRAINT nt_note_fk FOREIGN KEY(note_id) REFERENCES notes(id) ON DELETE CASCADE,\n CONSTRAINT nt_tag_fk FOREIGN KEY(tag_id) REFERENCES tags(id) ON DELETE RESTRICT\n) ENGINE=InnoDB;",
         "lang": "sql",
         "filename": "sql/002_tags.sql"
       },
       {
-        "title": "准备两条笔记和两个标签",
-        "body": "以下是 psql 脚本;\\gset 将 RETURNING 值写进本会话变量。创建笔记返回语句末尾不加分号,由 \\gset 执行。标签可以重复准备,笔记每次执行都会产生两条独立样本。",
-        "code": "INSERT INTO notes(title) VALUES ('db04-with-tags') RETURNING id AS tagged_id \\gset\nINSERT INTO notes(title) VALUES ('db04-no-tags') RETURNING id AS empty_id \\gset\nINSERT INTO tags(name) VALUES ('db04-python'), ('db04-api') ON CONFLICT (name) DO NOTHING;\nINSERT INTO note_tags(note_id,tag_id)\nSELECT :tagged_id, id FROM tags WHERE name IN ('db04-python','db04-api');",
+        "title": "在当前会话准备独立样本",
+        "body": "@tagged_id与@empty_id是当前mysql会话变量,换窗口需要重新设置。标签插入只首次运行;重复练习可直接复用已有两个标签,不使用INSERT IGNORE吞掉其他约束错误。",
+        "code": "INSERT INTO tags(name) VALUES('db04-python'),('db04-api');\nINSERT INTO notes(title) VALUES('db04-with-tags');\nSET @tagged_id=LAST_INSERT_ID();\nINSERT INTO notes(title) VALUES('db04-no-tags');\nSET @empty_id=LAST_INSERT_ID();\nINSERT INTO note_tags(note_id,tag_id)\nSELECT @tagged_id,id FROM tags WHERE name IN ('db04-python','db04-api');",
         "lang": "sql"
       },
       {
-        "title": "分别查看关联明细和数量",
-        "body": "第一条查询得到 2 行标签明细。第二条查询保留无标签笔记,数量分别为 2 与 0。注意按 id 筛选的是上一步新建的两行,不受此前重复运行影响。",
-        "code": "SELECT n.id,n.title,t.name\nFROM notes n JOIN note_tags nt ON nt.note_id=n.id\nJOIN tags t ON t.id=nt.tag_id\nWHERE n.id=:tagged_id ORDER BY t.name;\n\nSELECT n.id,n.title,count(nt.tag_id) AS tag_count\nFROM notes n LEFT JOIN note_tags nt ON nt.note_id=n.id\nWHERE n.id IN (:tagged_id,:empty_id)\nGROUP BY n.id,n.title ORDER BY n.id;",
+        "title": "读取明细与零标签统计",
+        "body": "第一条2行明细,第二条两个笔记的标签数分别2与0。group by显式包含所选笔记列,与默认ONLY_FULL_GROUP_BY一致。",
+        "code": "SELECT n.id,n.title,t.name FROM notes n\nJOIN note_tags nt ON nt.note_id=n.id JOIN tags t ON t.id=nt.tag_id\nWHERE n.id=@tagged_id ORDER BY t.name;\nSELECT n.id,n.title,COUNT(nt.tag_id) AS tag_count FROM notes n\nLEFT JOIN note_tags nt ON nt.note_id=n.id\nWHERE n.id IN (@tagged_id,@empty_id)\nGROUP BY n.id,n.title ORDER BY n.id;",
         "lang": "sql"
       },
       {
-        "title": "验证重复关联和删除策略",
-        "body": "先单独执行重复 INSERT,预期 23505 唯一性错误;再单独执行 DELETE tags,预期 23503 外键错误。最后删除本课带标签笔记,对应关联行自动清理。只删除本会话创建的样本。",
-        "code": "INSERT INTO note_tags(note_id,tag_id)\nSELECT :tagged_id,id FROM tags WHERE name='db04-python';\n-- 23505:重复关联\nDELETE FROM tags WHERE name='db04-python';\n-- 23503:仍被笔记引用\nDELETE FROM notes WHERE id=:tagged_id;\nSELECT count(*) FROM note_tags WHERE note_id=:tagged_id;\n-- 0",
+        "title": "验证即时约束和级联删除",
+        "body": "逐条执行预期失败语句。重复关联1062,删除被引用标签1451,不存在父标签的关联1452。最后删除本课带标签笔记,关联消失而标签本身仍保留。",
+        "code": "INSERT INTO note_tags(note_id,tag_id)\nSELECT @tagged_id,id FROM tags WHERE name='db04-python';\n-- 1062 duplicate entry\nDELETE FROM tags WHERE name='db04-python';\n-- 1451 parent row referenced\nINSERT INTO note_tags(note_id,tag_id) VALUES(@empty_id,-999);\n-- 1452 parent missing,立即失败\nDELETE FROM notes WHERE id=@tagged_id;\nSELECT COUNT(*) AS remaining FROM note_tags WHERE note_id=@tagged_id;\nSHOW INDEX FROM note_tags;",
         "lang": "sql"
       }
     ],
     "checks": [
-      "关联明细返回两个标签,聚合结果分别为 2 与 0。",
-      "重复添加同一关联失败,不存在编号的外键写入也会失败。",
-      "删除练习笔记后仅其关联行消失,标签本身仍存在。"
+      "明细2行、计数2与0。",
+      "重复、被引用删除和无父关联分别被立即拒绝。",
+      "联合主键与tag_id辅助索引可在SHOW INDEX中确认。"
     ],
     "pitfalls": [
       {
-        "symptom": "没有标签的笔记消失了",
-        "cause": "使用 INNER JOIN,或在 WHERE 中过滤右表导致 LEFT JOIN 效果被破坏。",
-        "fix": "需要保留左表时使用 LEFT JOIN;右表匹配条件按语义放到 ON,并检查空值结果。"
+        "symptom": "到COMMIT才希望补齐父行,但子行INSERT立即失败",
+        "cause": "把其他系统的延迟外键习惯套到MySQL。",
+        "fix": "先插父记录,再插子记录;需要复杂调度时重新规划写入顺序。"
       },
       {
-        "symptom": "无标签笔记显示数量 1",
-        "cause": "COUNT(*) 统计了 LEFT JOIN 生成的空关联行。",
-        "fix": "改用 COUNT(nt.tag_id),并检查连接多个一对多表时是否又放大行数。"
+        "symptom": "无标签笔记统计为1",
+        "cause": "COUNT(*)把LEFT JOIN空关联行计入。",
+        "fix": "改COUNT(nt.tag_id),检查其他连接是否进一步放大。"
       }
     ],
     "exercise": {
-      "task": "查询拥有 db04-python 标签的笔记,要求每条笔记最多出现一次。",
-      "hint": "可使用 EXISTS 子查询表达是否存在,不需要把标签列放进输出。",
-      "answer": "SELECT n.id,n.title FROM notes n WHERE EXISTS (SELECT 1 FROM note_tags nt JOIN tags t ON t.id=nt.tag_id WHERE nt.note_id=n.id AND t.name='db04-python') ORDER BY n.id; 第 04 步已删除本课关联时结果为空;重新执行第 02 步后应出现新笔记。"
+      "task": "查询带db04-python标签的笔记且每条最多一次。",
+      "hint": "EXISTS直接表达是否存在,避免把标签明细带回结果。",
+      "answer": "SELECT n.id,n.title FROM notes n WHERE EXISTS(SELECT 1 FROM note_tags nt JOIN tags t ON t.id=nt.tag_id WHERE nt.note_id=n.id AND t.name='db04-python') ORDER BY n.id; 清理关联后可为空,重新准备样本后再验收。"
     },
     "refs": [
       {
-        "label": "PostgreSQL 16:表表达式与 JOIN",
-        "url": "https://www.postgresql.org/docs/16/queries-table-expressions.html"
+        "label": "MySQL 8.4:JOIN",
+        "url": "https://dev.mysql.com/doc/refman/8.4/en/join.html"
       },
       {
-        "label": "PostgreSQL 16:聚合函数",
-        "url": "https://www.postgresql.org/docs/16/functions-aggregate.html"
+        "label": "MySQL 8.4:外键",
+        "url": "https://dev.mysql.com/doc/refman/8.4/en/create-table-foreign-keys.html"
       },
       {
-        "label": "PostgreSQL 16:外键",
-        "url": "https://www.postgresql.org/docs/16/ddl-constraints.html"
+        "label": "MySQL 8.4:聚合",
+        "url": "https://dev.mysql.com/doc/refman/8.4/en/aggregate-functions.html"
       }
     ],
     "production": {
-      "scenario": "生产列表页需要展示标签数,同时允许笔记暂时没有标签。",
+      "scenario": "笔记列表要展示标签,删除策略应与业务约定一致。",
       "decisions": [
-        "通过关联表表达多对多。",
-        "用唯一约束阻止重复关系。",
-        "明确父记录删除行为并验证空关系。"
+        "外键即时检查。",
+        "关系使用约束与明确索引。",
+        "零关系结果单独测试。"
       ],
-      "failureDrill": "在 notes_lab 重复插入关联、删除被引用标签,记录数据库拒绝原因。",
+      "failureDrill": "用本课专用样本重复建立关系、删除被引用标签,并核对约束错误。",
       "acceptance": [
-        "零标签结果正确。",
-        "重复关系无法落库。",
-        "统计没有因连接放大而重复计算。"
+        "无孤儿关联。",
+        "零标签不丢行。",
+        "删除边界清晰。"
       ]
     }
   },
   {
     "id": "05",
-    "title": "把多次写入放进同一个事务",
     "group": "事务与并发",
-    "summary": "一次保存笔记并关联标签,主动制造第二步失败,确认第一步也回滚。",
+    "title": "明确InnoDB事务、保存点与DDL提交边界",
+    "summary": "让必需写入一起提交,并验证错误语句、整个事务和结构变更的不同回滚范围。",
     "objectives": [
-      "划定需要同时成功的写入边界",
-      "识别失败事务状态并执行回滚",
-      "使用保存点隔离可选步骤"
+      "使用START TRANSACTION与显式ROLLBACK",
+      "区分语句失败和事务整体失败",
+      "验证DDL隐式提交不能用ROLLBACK撤销"
     ],
     "concepts": [
       {
-        "title": "事务覆盖一个业务动作",
-        "body": "创建笔记和保存必需标签属于同一次提交。只有两步都成功才向调用者返回成功。数据库事务不能自动回滚已经发送的邮件或外部 HTTP 请求,跨系统一致性在 API + 数据库和异步任务模块继续处理。"
+        "title": "事务覆盖需要同时成功的动作",
+        "body": "创建笔记和关联必需标签放在同一InnoDB事务。客户端收到异常后应按业务边界回滚,而不是看数据库是否仍允许SELECT就继续提交。外部HTTP、邮件或文件不随数据库事务自动回滚。"
       },
       {
-        "title": "错误后的事务需要结束",
-        "body": "BEGIN 后发生约束错误,事务进入失败状态。继续 SELECT 通常得到 25P02;应 ROLLBACK 整个事务,或者在预先建立的 SAVEPOINT 上回滚到保存点。"
+        "title": "普通约束错误通常只回滚本条语句",
+        "body": "MySQL中重复键或外键失败后,事务里的其他成功写入仍可能保留,事务也通常能继续执行。死锁1213会回滚整个事务;锁等待超时1205默认通常只回滚当前语句,受配置影响。应用统一结束失败业务事务可避免半成品。"
       },
       {
-        "title": "事务越短越容易控制",
-        "body": "事务持有连接与可能的锁。不要在 BEGIN 与 COMMIT 之间等用户输入、调用模型或上传文件。identity 消耗的编号即使回滚也可能不复用,所以不能用编号是否连续判断事务正确性。"
+        "title": "DDL不是可随意回滚的DML",
+        "body": "CREATE TABLE、ALTER TABLE等通常在执行前后隐式提交。MySQL原子DDL保护单条DDL的崩溃一致性,不表示BEGIN包起来的多条结构变更可以ROLLBACK。迁移文件要独立于业务事务。"
       }
     ],
     "steps": [
       {
-        "title": "用成功路径保存笔记和标签",
-        "body": "先完成第 04 课表结构。此脚本在 psql 中执行;标签使用已有 db04-api,若无该标签先执行 INSERT INTO tags(name) VALUES ('db04-api') ON CONFLICT DO NOTHING。事务末尾应 COMMIT。",
-        "code": "BEGIN;\nINSERT INTO notes(title) VALUES ('db05-atomic-ok') RETURNING id AS ok_id \\gset\nINSERT INTO note_tags(note_id,tag_id)\nSELECT :ok_id,id FROM tags WHERE name='db04-api';\nCOMMIT;\nSELECT n.id,n.title,t.name FROM notes n\nJOIN note_tags nt ON nt.note_id=n.id JOIN tags t ON t.id=nt.tag_id\nWHERE n.id=:ok_id;",
+        "title": "执行笔记与标签一起成功的路径",
+        "body": "在mysql客户端执行,先确认db04-api标签存在。没有DML RETURNING,紧接INSERT用同连接LAST_INSERT_ID。COMMIT后从另一个会话查询能看到笔记和关联。",
+        "code": "START TRANSACTION;\nINSERT INTO notes(title) VALUES('db05-atomic-ok');\nSET @ok_id=LAST_INSERT_ID();\nINSERT INTO note_tags(note_id,tag_id)\nSELECT @ok_id,id FROM tags WHERE name='db04-api';\nCOMMIT;\nSELECT n.id,n.title,t.name FROM notes n\nJOIN note_tags nt ON nt.note_id=n.id JOIN tags t ON t.id=nt.tag_id\nWHERE n.id=@ok_id;",
         "lang": "sql"
       },
       {
-        "title": "制造第二步失败",
-        "body": "本例用不存在的负标签编号触发外键错误。逐条执行,观察提示符从 notes_lab=* 改为失败事务状态。不要把预期错误脚本用 ON_ERROR_STOP 直接执行后遗漏回滚。",
-        "code": "BEGIN;\nINSERT INTO notes(title) VALUES ('db05-must-rollback') RETURNING id AS failed_id \\gset\nINSERT INTO note_tags(note_id,tag_id) VALUES (:failed_id,-999);\n-- 23503:外键错误\nSELECT 1;\n-- 25P02:当前事务已失败\nROLLBACK;\nSELECT count(*) FROM notes WHERE id=:failed_id;\n-- 0",
+        "title": "让第二步失败后明确回滚整个业务",
+        "body": "逐条执行。外键失败后SELECT仍能看到本事务第一步插入的笔记,这正是不能忽略异常后COMMIT的原因。ROLLBACK后在原会话和新会话查询该编号均为空。",
+        "code": "START TRANSACTION;\nINSERT INTO notes(title) VALUES('db05-must-rollback');\nSET @failed_id=LAST_INSERT_ID();\nINSERT INTO note_tags(note_id,tag_id) VALUES(@failed_id,-999);\n-- 1452:本条语句失败\nSELECT id,title FROM notes WHERE id=@failed_id;\n-- 本事务仍可看到第一步成功写入\nROLLBACK;\nSELECT COUNT(*) AS remaining FROM notes WHERE id=@failed_id;\n-- 0",
         "lang": "sql"
       },
       {
-        "title": "用保存点处理可选标签",
-        "body": "只有当业务允许“笔记成功、可选标签失败”时才采用本例。保存点前的笔记保留,保存点之后失败的关联被回滚。若标签是业务必填,则应沿用上一步整笔回滚。",
-        "code": "BEGIN;\nINSERT INTO notes(title) VALUES ('db05-optional-tag') RETURNING id AS optional_id \\gset\nSAVEPOINT optional_tag;\nINSERT INTO note_tags(note_id,tag_id) VALUES (:optional_id,-999);\nROLLBACK TO SAVEPOINT optional_tag;\nCOMMIT;\nSELECT id,title FROM notes WHERE id=:optional_id;\n-- 1 row",
+        "title": "只对明确可选步骤使用保存点",
+        "body": "本例业务允许笔记创建而标签失败。保存点之前写入保留,恢复到保存点后提交。必需标签不得借此绕过验证;AUTO_INCREMENT已消耗编号可能留下空洞。",
+        "code": "START TRANSACTION;\nINSERT INTO notes(title) VALUES('db05-optional-tag');\nSET @optional_id=LAST_INSERT_ID();\nSAVEPOINT optional_tag;\nINSERT INTO note_tags(note_id,tag_id) VALUES(@optional_id,-999);\nROLLBACK TO SAVEPOINT optional_tag;\nRELEASE SAVEPOINT optional_tag;\nCOMMIT;\nSELECT id,title FROM notes WHERE id=@optional_id;",
         "lang": "sql"
       },
       {
-        "title": "在独立会话核对提交边界",
-        "body": "打开第二个 psql,执行相同 SELECT。成功笔记和可选标签笔记可见,must-rollback 不可见。再在会话 A BEGIN 后插入一条新笔记,暂不提交;会话 B 查询该标题为零行,A COMMIT 后 B 再查才能看到。最后结束两个会话中所有未结束事务。",
-        "code": "SELECT title,count(*) FROM notes\nWHERE title LIKE 'db05-%' GROUP BY title ORDER BY title;\n-- 包含 db05-atomic-ok、db05-optional-tag\n-- 不包含 db05-must-rollback",
+        "title": "用专用表证明DDL隐式提交",
+        "body": "仅notes_lab执行,一次实验会保留一条笔记和一张空表,再显式清理。CREATE TABLE之前INSERT因隐式提交已落库,随后ROLLBACK不能撤销它们。不要将此模式用于真实业务迁移。",
+        "code": "START TRANSACTION;\nINSERT INTO notes(title) VALUES('db05-ddl-committed');\nSET @ddl_id=LAST_INSERT_ID();\nCREATE TABLE ddl_commit_probe(id INT PRIMARY KEY) ENGINE=InnoDB;\nROLLBACK;\nSELECT id,title FROM notes WHERE id=@ddl_id;\nSHOW TABLES LIKE 'ddl_commit_probe';\n-- 都存在,证明ROLLBACK没有回滚DDL前的提交\nDELETE FROM notes WHERE id=@ddl_id;\nDROP TABLE ddl_commit_probe;",
         "lang": "sql"
       }
     ],
     "checks": [
-      "第二步外键失败后,回滚事务中的笔记不存在。",
-      "失败状态执行 SELECT 返回 25P02,ROLLBACK 后恢复。",
-      "另一会话只能读取已提交数据;保存点例子符合明确的可选业务规则。"
+      "失败后主动回滚,第一步笔记没有遗留。",
+      "保存点例子只保留业务明确允许的部分。",
+      "能观察DDL隐式提交,并清理仅本课创建的标记。"
     ],
     "pitfalls": [
       {
-        "symptom": "笔记创建了但标签没保存",
-        "cause": "每条 SQL 独立提交,缺少完整事务边界。",
-        "fix": "将必需写入放入同一事务,仅在成功提交后返回业务成功。"
+        "symptom": "标签失败但笔记仍保存",
+        "cause": "约束错误通常只撤销本条SQL,应用继续提交了事务。",
+        "fix": "except中显式rollback整个业务事务,再向调用方报告失败。"
       },
       {
-        "symptom": "重试 SQL 一直提示 current transaction is aborted",
-        "cause": "在失败事务中继续执行,没有先回滚。",
-        "fix": "退出事务上下文或显式 ROLLBACK,再决定是否重试整个业务动作。"
+        "symptom": "ROLLBACK没有撤销ALTER或CREATE",
+        "cause": "DDL发生隐式提交,原子DDL不等于用户可回滚DDL事务。",
+        "fix": "迁移拆分为可检查步骤、先备份和演练,避免与业务DML混用。"
       }
     ],
     "exercise": {
-      "task": "把可选标签例子改成标签必填,说明应该删除哪些语句。",
-      "hint": "必填意味着不能在失败后只回滚局部。",
-      "answer": "移除 SAVEPOINT 和 ROLLBACK TO SAVEPOINT。标签插入异常时执行整个事务 ROLLBACK,笔记和关联都不保留;应用把错误转成合适的响应,而不是返回创建成功。"
+      "task": "PyMySQL第二次execute触发1452时,最小异常处理是什么?",
+      "hint": "不要根据SELECT还能运行就判断可以继续提交。",
+      "answer": "try内完成全部必需SQL后conn.commit;except BaseException中conn.rollback并raise;finally conn.close。若需要只保留部分成功,必须先定义业务允许的保存点范围。"
     },
     "refs": [
       {
-        "label": "PostgreSQL 16:事务入门",
-        "url": "https://www.postgresql.org/docs/16/tutorial-transactions.html"
+        "label": "MySQL 8.4:事务语句",
+        "url": "https://dev.mysql.com/doc/refman/8.4/en/commit.html"
+      },
+      {
+        "label": "MySQL 8.4:隐式提交",
+        "url": "https://dev.mysql.com/doc/refman/8.4/en/implicit-commit.html"
       },
       {
-        "label": "PostgreSQL 16:SAVEPOINT",
-        "url": "https://www.postgresql.org/docs/16/sql-savepoint.html"
+        "label": "MySQL 8.4:InnoDB错误处理",
+        "url": "https://dev.mysql.com/doc/refman/8.4/en/innodb-error-handling.html"
       },
       {
-        "label": "Psycopg 3:事务管理",
-        "url": "https://www.psycopg.org/psycopg3/docs/basic/transactions.html"
+        "label": "MySQL 8.4:保存点",
+        "url": "https://dev.mysql.com/doc/refman/8.4/en/savepoint.html"
       }
     ],
     "production": {
-      "scenario": "保存按钮触发多张表写入,任何必需步骤失败都不能产生半成品记录。",
+      "scenario": "一次用户保存涉及多表,异常处理必须符合MySQL实际回滚语义。",
       "decisions": [
-        "事务以业务动作划分。",
-        "可选步骤才允许保存点局部恢复。",
-        "外部调用在数据库事务之外安排。"
+        "DML事务显式提交回滚。",
+        "DDL独立运行并设计恢复路径。",
+        "事务中不等待外部服务。"
       ],
-      "failureDrill": "在 notes_lab 用无效外键中断保存,重新连接检查是否留下半条业务记录。",
+      "failureDrill": "在notes_lab触发外键错误和DDL隐式提交,分别记录保留与回滚的记录。",
       "acceptance": [
-        "必需写入要么全有要么全无。",
-        "失败后连接能够继续服务。",
-        "日志能定位原始约束错误而不是只有后续 25P02。"
+        "无必需写入半成品。",
+        "错误码与回滚范围可解释。",
+        "不宣称BEGIN能包住所有DDL。"
       ]
     }
   },
   {
     "id": "06",
-    "title": "用两个会话观察隔离级别与 MVCC",
     "group": "事务与并发",
-    "summary": "按固定顺序执行读写,比较 Read Committed 和 Repeatable Read 的可见结果。",
+    "title": "用双会话观察RR、RC与当前读",
+    "summary": "按固定顺序比较MySQL默认RR快照、显式RC和带锁当前读。",
     "objectives": [
-      "用两个 psql 会话复现读取差异",
-      "解释语句快照与事务快照",
-      "识别需要重试的序列化失败"
+      "复现默认RR下的一致性读",
+      "显式切换RC观察新提交",
+      "区分快照SELECT与锁定读/UPDATE"
     ],
     "concepts": [
       {
-        "title": "快照决定读者能看见什么",
-        "body": "Read Committed 下,每条查询读取该语句开始时已经提交的数据。Repeatable Read 通常固定在事务第一条实际查询建立的快照。下面的实验用同一行、同一个读取动作对照,避免只背隔离级别名字。"
+        "title": "默认RR固定一致性读快照",
+        "body": "InnoDB默认REPEATABLE READ。普通SELECT通常在事务第一次一致性读建立快照,后续普通读取使用相同快照;START TRANSACTION本身不一定已建立快照。长快照会增加undo保留与purge压力。"
       },
       {
-        "title": "普通读与写锁不是同一机制",
-        "body": "MVCC 使普通 SELECT 可以读取已提交版本,不必因为另一会话正在更新同一行而等待其提交。SELECT FOR UPDATE 是带锁读取,行为不同。长事务会保留旧快照,增加旧版本清理压力。"
+        "title": "RC每次一致性读建立新快照",
+        "body": "READ COMMITTED下不同SELECT可以读到期间新提交的值。SET TRANSACTION ISOLATION LEVEL只设置接下来事务,必须在START TRANSACTION之前且当前没有活动事务。普通读均不读取另一会话尚未提交的值。"
       },
       {
-        "title": "更强隔离伴随重试责任",
-        "body": "Repeatable Read 或 Serializable 在并发冲突下可能返回 SQLSTATE 40001。重试应从事务开始重新读取和计算,设置次数上限与退避;不能在已经失败的事务里重发最后一条 SQL。"
+        "title": "UPDATE与锁定读不能按旧快照推断",
+        "body": "SELECT FOR UPDATE与UPDATE使用锁定/当前读规则,可访问较新的已提交行。RR事务普通读仍见v1时,带锁读取可能见v2;后续写入可能覆盖他人编辑,因此版本条件仍必要。RR不自动把并发编辑变成版本冲突异常。"
       }
     ],
     "steps": [
       {
-        "title": "准备专用行并打开会话 A 与 B",
-        "body": "在任意会话执行一次第一组语句,记录返回的编号。A、B 均使用 app_user / notes_lab。每个会话执行 \\set iso_id 实际编号,示例中的 123 必须替换。每轮实验开始前确保双方已 COMMIT 或 ROLLBACK。",
-        "code": "INSERT INTO notes(title,content) VALUES ('db06-isolation','v1') RETURNING id;\n-- 假设返回 123,在 A 和 B 各自设置:\n\\set iso_id 123\nSHOW default_transaction_isolation;\n-- read committed",
+        "title": "准备同一行和两个窗口",
+        "body": "在一个mysql会话插入样本,记录编号;A/B都SET @iso_id=实际编号。不要假定样本id=1。每次切换隔离级别前双方执行ROLLBACK结束旧事务。",
+        "code": "INSERT INTO notes(title,content) VALUES('db06-isolation','v1');\nSELECT LAST_INSERT_ID() AS experiment_id;\n-- A、B均替换为刚返回的编号\nSET @iso_id=123;\nSELECT @@transaction_isolation;\n-- 默认REPEATABLE-READ",
         "lang": "sql"
       },
       {
-        "title": "执行 Read Committed 时序",
-        "body": "按注释切换窗口,不要一次性粘贴到同一会话。A 第一次读 v1;B 更新但未提交时 A 仍读 v1;B 提交后 A 再读变为 v2。A 的事务没有重开,但下一条查询已经取得新快照。",
-        "code": "-- A:先执行\nBEGIN ISOLATION LEVEL READ COMMITTED;\nSELECT content FROM notes WHERE id=:iso_id;\n-- B:执行,先不提交\nBEGIN;\nUPDATE notes SET content='v2',updated_at=now() WHERE id=:iso_id;\n-- A:此时仍为 v1\nSELECT content FROM notes WHERE id=:iso_id;\n-- B:现在提交\nCOMMIT;\n-- A:现在为 v2,然后结束\nSELECT content FROM notes WHERE id=:iso_id;\nCOMMIT;",
+        "title": "按窗口顺序观察默认RR",
+        "body": "A建立快照后,B更新但不提交时A仍v1;B提交后A普通读也仍v1;A结束事务再读才v2。代码按注释切窗口执行,不要整段粘到单一窗口。",
+        "code": "-- A\nSET TRANSACTION ISOLATION LEVEL REPEATABLE READ;\nSTART TRANSACTION;\nSELECT content FROM notes WHERE id=@iso_id;\n-- B\nSTART TRANSACTION;\nUPDATE notes SET content='v2',version=version+1,updated_at=UTC_TIMESTAMP(6) WHERE id=@iso_id;\n-- A:v1\nSELECT content FROM notes WHERE id=@iso_id;\n-- B\nCOMMIT;\n-- A:仍v1\nSELECT content FROM notes WHERE id=@iso_id;\nCOMMIT;\nSELECT content FROM notes WHERE id=@iso_id;\n-- v2",
         "lang": "sql"
       },
       {
-        "title": "执行 Repeatable Read 时序",
-        "body": "延续上一步的 v2。A 建立快照后,B 写入 v3 并提交;A 在原事务内仍读 v2。A 提交并开始新查询后读到 v3。记录四次读取结果与相应窗口。",
-        "code": "-- A\nBEGIN ISOLATION LEVEL REPEATABLE READ;\nSELECT content FROM notes WHERE id=:iso_id;\n-- B:自动提交状态\nUPDATE notes SET content='v3',updated_at=now() WHERE id=:iso_id;\n-- A:原快照仍为 v2\nSELECT content FROM notes WHERE id=:iso_id;\nCOMMIT;\nSELECT content FROM notes WHERE id=:iso_id;\n-- v3",
+        "title": "切为RC观察两次查询变化",
+        "body": "延续v2。A显式RC读取,B自动提交更新v3后,A在相同事务的下一条SELECT读到v3。最后结束A事务,后续下一事务仍按会话默认RR,除非再显式设置。",
+        "code": "-- A\nSET TRANSACTION ISOLATION LEVEL READ COMMITTED;\nSTART TRANSACTION;\nSELECT content FROM notes WHERE id=@iso_id;\n-- B,确认autocommit=1\nUPDATE notes SET content='v3',version=version+1,updated_at=UTC_TIMESTAMP(6) WHERE id=@iso_id;\n-- A:现在为v3\nSELECT content FROM notes WHERE id=@iso_id;\nCOMMIT;",
         "lang": "sql"
       },
       {
-        "title": "复现并发更新失败并恢复",
-        "body": "A 用 Repeatable Read 读取 v3,B 改成 v4 并提交,A 再更新同一行会遇到 40001。最后 A ROLLBACK 并重新读取,确认新事务看到 v4。这里只演示失败机制,不对包含外部副作用的动作进行盲目重试。",
-        "code": "-- A\nBEGIN ISOLATION LEVEL REPEATABLE READ;\nSELECT content FROM notes WHERE id=:iso_id;\n-- B\nUPDATE notes SET content='v4',updated_at=now() WHERE id=:iso_id;\n-- A\nUPDATE notes SET content='v5' WHERE id=:iso_id;\n-- ERROR: could not serialize access due to concurrent update\nROLLBACK;\nSELECT content FROM notes WHERE id=:iso_id;\n-- v4",
+        "title": "证明RR普通读与当前读可以不同",
+        "body": "A普通读v3建立快照;B提交v4;A普通读仍v3,但FOR UPDATE读到v4并取得锁。此处只观察,不覆盖写入。A立即ROLLBACK释放锁;不能把这一实验推导为无条件覆盖更新是安全的。",
+        "code": "-- A\nSET TRANSACTION ISOLATION LEVEL REPEATABLE READ;\nSTART TRANSACTION;\nSELECT content FROM notes WHERE id=@iso_id;\n-- B\nUPDATE notes SET content='v4',version=version+1,updated_at=UTC_TIMESTAMP(6) WHERE id=@iso_id;\n-- A:v3\nSELECT content FROM notes WHERE id=@iso_id;\n-- A:v4,带锁当前读\nSELECT content FROM notes WHERE id=@iso_id FOR UPDATE;\nROLLBACK;",
         "lang": "sql"
       }
     ],
     "checks": [
-      "Read Committed 在 B 提交前后读到 v1、v2。",
-      "Repeatable Read 在事务中保持 v2,事务结束后读到 v3。",
-      "并发更新实验出现 40001,ROLLBACK 后读取恢复。"
+      "RR事务中保持旧值,结束后读取新值。",
+      "RC同事务下一次SELECT能看到B新提交。",
+      "RR普通读与FOR UPDATE结果不同,所有实验事务已结束。"
     ],
     "pitfalls": [
       {
-        "symptom": "两种隔离级别看起来没有区别",
-        "cause": "A 没有显式 BEGIN,或者 B 在 A 建立快照之前已提交。",
-        "fix": "严格按窗口与注释顺序执行,记录每次 BEGIN、SELECT 和 COMMIT 的时点。"
+        "symptom": "切隔离级别报事务中不能修改",
+        "cause": "已有事务尚未结束,或在START TRANSACTION之后才SET TRANSACTION。",
+        "fix": "先ROLLBACK,再设置下一事务的级别,然后START TRANSACTION。"
       },
       {
-        "symptom": "实验结束后连接一直 idle in transaction",
-        "cause": "只观察结果没有结束事务。",
-        "fix": "两个窗口都执行 ROLLBACK;生产应用用上下文管理事务并限制事务内工作量。"
+        "symptom": "以为RR能防止旧表单覆盖新内容",
+        "cause": "普通读取快照与UPDATE的锁定读规则不同。",
+        "fix": "在UPDATE中加入expected version条件,或对需要串行的业务使用短锁定事务。"
       }
     ],
     "exercise": {
-      "task": "说明报表需要同一时点多次读取,而普通单条查询通常不用提高到 Repeatable Read 的原因。",
-      "hint": "先确定需要保证的是一条 SQL,还是多条 SQL 共享快照。",
-      "answer": "单条 SELECT 本身在一个语句快照内执行。需要多条 SELECT 共享一致快照时,可选择短的只读 Repeatable Read 事务;若能用一条 SQL 表达汇总,通常可减少事务持续时间。提高隔离级别不能代替业务约束与正确的重试实现。"
+      "task": "为什么多条报表SQL可用短RR只读事务,但编辑接口仍需版本检查?",
+      "hint": "报表关心一致快照,编辑关心提交时是否有人已修改。",
+      "answer": "RR普通读取可以共享一致快照;编辑提交必须用WHERE id=? AND version=?做原子条件更新。不要让用户填写表单的整个过程占用数据库事务。"
     },
     "refs": [
       {
-        "label": "PostgreSQL 16:事务隔离",
-        "url": "https://www.postgresql.org/docs/16/transaction-iso.html"
+        "label": "MySQL 8.4:隔离级别",
+        "url": "https://dev.mysql.com/doc/refman/8.4/en/innodb-transaction-isolation-levels.html"
       },
       {
-        "label": "PostgreSQL 16:MVCC 简介",
-        "url": "https://www.postgresql.org/docs/16/mvcc-intro.html"
+        "label": "MySQL 8.4:一致性读",
+        "url": "https://dev.mysql.com/doc/refman/8.4/en/innodb-consistent-read.html"
       },
       {
-        "label": "PostgreSQL 16:序列化失败处理",
-        "url": "https://www.postgresql.org/docs/16/mvcc-serialization-failure-handling.html"
+        "label": "MySQL 8.4:锁定读",
+        "url": "https://dev.mysql.com/doc/refman/8.4/en/innodb-locking-reads.html"
       }
     ],
     "production": {
-      "scenario": "后台导出需要一致读取,编辑接口同时持续更新笔记。",
+      "scenario": "报表导出与多人编辑同时运行,需要明确每种读操作所见的数据。",
       "decisions": [
-        "根据读取范围选择隔离级别。",
-        "限制长事务,避免把导出网络传输放在读事务内。",
-        "40001 重试覆盖完整事务。"
+        "默认RR与显式RC分别验收。",
+        "短事务降低undo保留压力。",
+        "当前读与普通快照读不混淆。"
       ],
-      "failureDrill": "在 notes_lab 双会话执行快照与并发更新实验,保留实际输出顺序。",
+      "failureDrill": "在同一笔记上执行固定双会话时序,记录每次提交和读取结果。",
       "acceptance": [
-        "每个结果可对应到一次提交。",
-        "实验后无遗留事务。",
-        "能区分数据可见性与行锁等待。"
+        "结果能对应具体时点。",
+        "无遗留长事务。",
+        "不把RR描述成自动乐观锁。"
       ]
     }
   },
   {
     "id": "07",
-    "title": "定位锁等待、复现死锁并加入版本检查",
     "group": "事务与并发",
-    "summary": "观察同一行写入的等待关系,再分别用固定加锁顺序和乐观锁解决不同冲突。",
+    "title": "观察行锁、间隙锁、死锁与版本冲突",
+    "summary": "使用独立实验行定位InnoDB等待,再用一致加锁顺序和条件版本更新约束并发。",
     "objectives": [
-      "用 pg_blocking_pids 找到锁等待源头",
-      "复现并处理死锁回滚",
-      "用 version 条件阻止覆盖其他人的编辑"
+      "通过performance_schema定位等待链",
+      "复现RR范围锁与死锁",
+      "用版本条件阻止旧编辑覆盖"
     ],
     "concepts": [
       {
-        "title": "行锁持续到事务结束",
-        "body": "UPDATE 和 SELECT FOR UPDATE 可阻止另一个事务更新同一行。等待不一定是坏事,但必须有时限与可观测性;持锁时调用外部服务会让数据库承担外部延迟。"
+        "title": "锁依赖访问路径与隔离级别",
+        "body": "InnoDB锁定索引记录。唯一主键等值查找通常只需目标记录锁;RR范围锁定读可能使用next-key/gap锁阻止范围插入。RC通常减少搜索与扫描的间隙锁,但外键和重复键检查等仍有例外,不能说RC完全没有间隙锁。"
       },
       {
-        "title": "死锁来自循环等待",
-        "body": "A 持有行一等待行二,B 持有行二等待行一,形成闭环。数据库会中止其中一个事务,不能预言一定中止谁。固定顺序获取多行锁可减少死锁,应用仍需识别 40P01 并有限重试整个事务。"
+        "title": "死锁与超时有不同回滚范围",
+        "body": "循环等待会触发死锁检测,一方收到1213,整个事务被回滚;1205锁等待超时默认通常只回滚当前语句。应用遇到两类错误都显式ROLLBACK,并在事务外有限重试完整业务动作,不重试任意一条末尾SQL。"
       },
       {
-        "title": "版本检查表达编辑冲突",
-        "body": "客户端读取 version=1,更新时要求 WHERE version=1 并同时 version=version+1。若别人已提交,更新返回零行,调用者应重新读取并解决冲突。版本检查防止旧内容覆盖新内容,不用于替代所有数据库锁。"
+        "title": "数据库锁与编辑版本分别使用",
+        "body": "数据库锁保护短事务中的并发资源,用户编辑过程不应持有连接。UPDATE同时比较version并递增,rowcount=0表示未成功匹配,必须返回冲突或不存在而不是假装保存成功。"
       }
     ],
     "steps": [
       {
-        "title": "建立两个实验行并添加版本列",
-        "body": "完成前面表结构后执行一次 ADD COLUMN;若已存在 version 则只运行后两条。把返回编号记为 first_id 和 second_id,在 A、B 两个窗口各设置相同变量。",
-        "code": "ALTER TABLE notes ADD COLUMN version integer NOT NULL DEFAULT 1 CHECK (version > 0);\nINSERT INTO notes(title) VALUES ('db07-first') RETURNING id;\nINSERT INTO notes(title) VALUES ('db07-second') RETURNING id;\n-- 在两个窗口替换为实际返回值\n\\set first_id 124\n\\set second_id 125",
+        "title": "准备两个实验编号与三个窗口",
+        "body": "业务窗口A/B连接app_user,分别记录CONNECTION_ID()。另开只读管理窗口用root连接本机notes-mysql,查看performance_schema需要额外权限,不把诊断权限授予API。记录两个新编号并在A/B各设置变量。",
+        "code": "INSERT INTO notes(title) VALUES('db07-first');\nSELECT LAST_INSERT_ID();\nINSERT INTO notes(title) VALUES('db07-second');\nSELECT LAST_INSERT_ID();\n-- A/B替换为实际编号\nSET @first_id=124,@second_id=125;\nSELECT CONNECTION_ID();\nSET SESSION innodb_lock_wait_timeout=5;",
         "lang": "sql"
       },
       {
-        "title": "观察一个有上限的锁等待",
-        "body": "先 A 加锁,再 B 执行 UPDATE。B 等待期间切回 A 查询阻塞信息;如果 5 秒不够观察,可重做一次。B 约 5 秒后收到 lock timeout,之后必须 ROLLBACK;A 也 ROLLBACK 释放锁。",
-        "code": "-- A\nBEGIN;\nSELECT id FROM notes WHERE id=:first_id FOR UPDATE;\n-- B\nBEGIN;\nSET LOCAL lock_timeout='5s';\nUPDATE notes SET content='waiting' WHERE id=:first_id;\n-- A:在 B 等待期间执行\nSELECT pid,wait_event_type,wait_event,pg_blocking_pids(pid) AS blockers\nFROM pg_stat_activity WHERE datname='notes_lab';\n-- B 超时后,以及 A 观察完后,各自执行\nROLLBACK;",
+        "title": "制造有上限的行锁等待",
+        "body": "A先START TRANSACTION并锁第一行,B开始事务更新同一行会等待。等待期间管理窗口执行查询,记录blocking与waiting线程。B约5秒收到1205后ROLLBACK,A观察后也ROLLBACK;不要直接KILL任何未核对会话。",
+        "code": "-- A\nSTART TRANSACTION;\nSELECT id FROM notes WHERE id=@first_id FOR UPDATE;\n-- B\nSTART TRANSACTION;\nUPDATE notes SET content='waiting',version=version+1 WHERE id=@first_id;\n-- 管理窗口:在等待期间执行\nSELECT REQUESTING_THREAD_ID,BLOCKING_THREAD_ID,REQUESTING_ENGINE_TRANSACTION_ID,\n       BLOCKING_ENGINE_TRANSACTION_ID FROM performance_schema.data_lock_waits;\n-- B超时后,以及A观察完后,各自执行\nROLLBACK;",
         "lang": "sql"
       },
       {
-        "title": "按顺序制造死锁",
-        "body": "先确认双方没有遗留事务。先 A 锁第一行,再 B 锁第二行,然后 A 请求第二行进入等待,最后 B 请求第一行。一个窗口返回 deadlock detected,另一个窗口可继续获得锁。无论哪个失败,两个窗口最后都执行 ROLLBACK。",
-        "code": "-- A\nBEGIN;\nSELECT id FROM notes WHERE id=:first_id FOR UPDATE;\n-- B\nBEGIN;\nSELECT id FROM notes WHERE id=:second_id FOR UPDATE;\n-- A:此处会等待\nSELECT id FROM notes WHERE id=:second_id FOR UPDATE;\n-- B:触发循环等待\nSELECT id FROM notes WHERE id=:first_id FOR UPDATE;\n-- SQLSTATE 40P01 出现在其中一个会话\n-- A 和 B 各执行 ROLLBACK;",
+        "title": "固定相反顺序复现死锁",
+        "body": "先A锁第一行、B锁第二行,再A请求第二行进入等待,最后B请求第一行。其中一方收到1213,不能指定一定哪方被牺牲。另一方拿到锁后两边都结束事务。管理窗口SHOW ENGINE INNODB STATUS可查看最近死锁,输出可能含业务SQL,留存时脱敏。",
+        "code": "-- A\nSTART TRANSACTION;\nSELECT id FROM notes WHERE id=@first_id FOR UPDATE;\n-- B\nSTART TRANSACTION;\nSELECT id FROM notes WHERE id=@second_id FOR UPDATE;\n-- A等待\nSELECT id FROM notes WHERE id=@second_id FOR UPDATE;\n-- B触发死锁,1213出现在一个窗口\nSELECT id FROM notes WHERE id=@first_id FOR UPDATE;\n-- A/B均ROLLBACK\n-- 管理窗口\nSHOW ENGINE INNODB STATUS;",
         "lang": "sql"
       },
       {
-        "title": "改为相同顺序锁定多行",
-        "body": "在 A 执行下面代码并保持事务,B 执行同样代码会等待 A。A COMMIT 后 B 能继续,然后 B COMMIT。双方都按 id 升序取得目标锁,避免本例的相反顺序。",
-        "code": "BEGIN;\nSELECT id FROM notes WHERE id IN (:first_id,:second_id) ORDER BY id FOR UPDATE;\n-- A 先 COMMIT,B 等待结束后再 COMMIT\nCOMMIT;",
+        "title": "以固定顺序加锁并保持短事务",
+        "body": "A、B都按相同主键顺序锁两行。先确认全部旧事务结束,再A执行下列START与SELECT,B执行同样两条等待;A COMMIT后B继续,B再COMMIT。本课数量只有两行,不把大范围全表加锁作为默认方案。",
+        "code": "START TRANSACTION;\nSELECT id FROM notes WHERE id IN (@first_id,@second_id) ORDER BY id FOR UPDATE;\n-- A先COMMIT,B返回后再COMMIT\nCOMMIT;",
         "lang": "sql"
       },
       {
-        "title": "用旧版本号重放编辑",
-        "body": "在同一个窗口按顺序执行即可模拟两个客户端都读到版本 1。先把专用行重置到 version=1;第一次更新得到 2,第二次带旧版本条件返回 0 行。只有实际更新成功才向客户端返回成功。",
-        "code": "UPDATE notes SET version=1,content='base' WHERE id=:first_id;\nUPDATE notes SET content='editor A',version=version+1,updated_at=now()\nWHERE id=:first_id AND version=1 RETURNING id,content,version;\n-- version=2\nUPDATE notes SET content='editor B',version=version+1,updated_at=now()\nWHERE id=:first_id AND version=1 RETURNING id,content,version;\n-- 0 rows\nSELECT content,version FROM notes WHERE id=:first_id;\n-- editor A, 2",
+        "title": "用独立范围表观察RR间隙保护",
+        "body": "仅在notes_lab建立专用lock_range_probe,DDL在事务外。A RR锁[10,20]范围后B插入15会等待;B超时回滚、A回滚。再将A换READ COMMITTED重复,B通常可插入15,因为不再需要同样的范围间隙保护;两轮结束后清理专用表。",
+        "code": "CREATE TABLE lock_range_probe(id INT PRIMARY KEY) ENGINE=InnoDB;\nINSERT INTO lock_range_probe VALUES(10),(20),(30);\n-- A\nSET TRANSACTION ISOLATION LEVEL REPEATABLE READ;\nSTART TRANSACTION;\nSELECT id FROM lock_range_probe WHERE id BETWEEN 10 AND 20 FOR UPDATE;\n-- B,单独设置较短等待后执行\nSET SESSION innodb_lock_wait_timeout=2;\nINSERT INTO lock_range_probe VALUES(15);\n-- B 1205后ROLLBACK;A也ROLLBACK。\n-- A改READ COMMITTED,重复同一SELECT;B再INSERT(15),应成功。\n-- 所有事务结束后,只清理本课专用表\nDROP TABLE lock_range_probe;",
+        "lang": "sql"
+      },
+      {
+        "title": "原子比较旧版本并拒绝第二次覆盖",
+        "body": "专用笔记先重置到version=1仅为实验,生产版本不得倒退。第一次更新成功1行,第二次仍带version=1匹配0行;SELECT确认内容仍为editor A。",
+        "code": "UPDATE notes SET version=1,content='base' WHERE id=@first_id;\nUPDATE notes SET content='editor A',version=version+1,updated_at=UTC_TIMESTAMP(6)\nWHERE id=@first_id AND version=1;\nSELECT ROW_COUNT() AS changed_rows;\n-- 1\nUPDATE notes SET content='editor B',version=version+1,updated_at=UTC_TIMESTAMP(6)\nWHERE id=@first_id AND version=1;\nSELECT ROW_COUNT() AS changed_rows;\n-- 0\nSELECT content,version FROM notes WHERE id=@first_id;",
         "lang": "sql"
       }
     ],
     "checks": [
-      "锁等待能查到非空 blockers,超时后事务可回滚。",
-      "死锁仅保证一个参与事务被中止,两个窗口最后均无活动事务。",
-      "旧版本更新返回零行,editor A 的内容没有被覆盖。"
+      "行锁等待能在管理窗口找到,1205后主动回滚。",
+      "死锁出现1213,固定顺序实验仅等待而不产生本例闭环。",
+      "范围实验与版本实验分别解释间隙保护和编辑冲突。"
     ],
     "pitfalls": [
       {
-        "symptom": "设置 statement_timeout 后还是不清楚在等什么",
-        "cause": "语句超时能中断执行,但没有分析锁等待来源。",
-        "fix": "在等待期间查看 wait_event 与 pg_blocking_pids,结合持锁会话事务时长定位。"
+        "symptom": "设置较高隔离后插入也卡住",
+        "cause": "RR范围锁可能保护间隙,更新不只锁住已存在行。",
+        "fix": "核对索引访问范围和data_locks,缩小事务范围,按业务需要选择RC。"
       },
       {
-        "symptom": "版本检查存在但更新仍覆盖内容",
-        "cause": "SELECT 后在 Python 检查版本,UPDATE 没带 version 条件。",
-        "fix": "把版本比较和递增放在同一条 UPDATE 中,依据 RETURNING 判定成功。"
+        "symptom": "发生1205后继续COMMIT留下部分写入",
+        "cause": "误以为所有数据库错误都会回滚整个事务。",
+        "fix": "统一显式rollback失败业务,再有限重试;记录错误码与完整操作关联。"
       }
     ],
     "exercise": {
-      "task": "将批量修改改为按 id 升序锁定,并说明收到 40P01 后重试范围。",
-      "hint": "不要只重试死锁报错的最后一条 SELECT。",
-      "answer": "先确定全部目标编号,在同一事务按升序 SELECT ... FOR UPDATE,再执行修改。收到 40P01 后回滚并重新读取整个业务动作的输入,有限退避重试;涉及外部副作用时先使用幂等机制。"
+      "task": "收到1213重试时,应复用旧计算结果还是重新读取整个事务?",
+      "hint": "数据库已回滚被牺牲事务,外部事实可能变化。",
+      "answer": "回滚并重新建立事务、重新读取和计算,使用次数上限与抖动退避。任何外部副作用需要幂等键,不能因数据库回滚就断言邮件或文件也消失。"
     },
     "refs": [
       {
-        "label": "PostgreSQL 16:显式锁与死锁",
-        "url": "https://www.postgresql.org/docs/16/explicit-locking.html"
+        "label": "MySQL 8.4:InnoDB锁",
+        "url": "https://dev.mysql.com/doc/refman/8.4/en/innodb-locking.html"
       },
       {
-        "label": "PostgreSQL 16:统计活动",
-        "url": "https://www.postgresql.org/docs/16/monitoring-stats.html"
+        "label": "MySQL 8.4:死锁示例",
+        "url": "https://dev.mysql.com/doc/refman/8.4/en/innodb-deadlock-example.html"
       },
       {
-        "label": "PostgreSQL 16:错误码",
-        "url": "https://www.postgresql.org/docs/16/errcodes-appendix.html"
+        "label": "MySQL 8.4:data_lock_waits",
+        "url": "https://dev.mysql.com/doc/refman/8.4/en/performance-schema-data-lock-waits-table.html"
       }
     ],
     "production": {
-      "scenario": "两名用户同时编辑笔记,后台任务又可能批量修改相同记录。",
+      "scenario": "多人编辑和批量任务竞争同一批数据。",
       "decisions": [
-        "短事务与锁超时控制等待。",
-        "多行写入约定一致的锁顺序。",
-        "普通编辑用版本检查向用户呈现冲突。"
+        "管理诊断权限与业务权限分开。",
+        "加锁顺序一致,等待有上限。",
+        "版本比较与更新在同一SQL完成。"
       ],
-      "failureDrill": "仅在 notes_lab 双会话制造行锁与死锁,记录等待链、SQLSTATE 和回滚结果。",
+      "failureDrill": "仅在专用行和lock_range_probe上制造等待与死锁,保存脱敏证据并结束所有事务。",
       "acceptance": [
-        "没有无限等待。",
-        "所有写入入口维护 version。",
-        "冲突时保留已提交内容并报告更新失败。"
+        "无无限等待。",
+        "旧编辑无法覆盖已提交版本。",
+        "能区分1213与1205的回滚边界。"
       ]
     }
   },
   {
     "id": "08",
-    "title": "围绕查询设计单列、组合与部分索引",
     "group": "查询性能",
-    "summary": "准备足够的数据,对照查询条件与排序建立索引,再核算索引带来的写入成本。",
+    "title": "设计组合索引、覆盖索引与JSON路径索引",
+    "summary": "按查询条件建立InnoDB索引,用不可见索引辅助对照,核算写入成本。",
     "objectives": [
-      "从 WHERE 与 ORDER BY 推导组合索引",
-      "用部分索引服务低占比条件",
-      "知道主键、唯一约束与外键索引的区别"
+      "从WHERE和ORDER BY推导索引顺序",
+      "理解聚簇主键与二级索引",
+      "为JSON路径创建受控的生成列索引"
     ],
     "concepts": [
       {
-        "title": "索引缩小查找范围",
-        "body": "B-tree 索引按键组织数据,使等值、范围与有序读取可以少访问一些数据页。索引占用磁盘,写入和更新时也要维护。是否使用索引由优化器估计成本决定,小表顺序扫描常常更合适。"
+        "title": "InnoDB主键决定聚簇组织",
+        "body": "主键索引叶子包含行数据,二级索引包含其索引键和主键值。选择过宽的主键会增加所有二级索引成本。查询返回列都在索引中时可能形成覆盖读取,仍需用实际计划确认。"
       },
       {
-        "title": "组合键的顺序来自查询",
-        "body": "WHERE pinned=true ORDER BY created_at DESC,id DESC LIMIT 20 可用 pinned、created_at、id 的组合索引。前面的等值条件固定范围,后面提供稳定排序。只对 pinned 这个低基数字段建索引不一定有收益。"
+        "title": "组合索引依据固定查询设计",
+        "body": "置顶列表WHERE pinned=1 ORDER BY created_at DESC,id DESC可使用(pinned,created_at DESC,id DESC)。低基数单列pinned索引未必划算。MySQL没有WHERE谓词形式的部分索引,本课用组合索引与实际选择率解决查询。"
       },
       {
-        "title": "部分索引只保存需要的行",
-        "body": "如果置顶笔记仅占少数,可只索引 WHERE pinned=true 的记录。查询条件需能匹配该谓词;不要假设参数化的任意布尔条件总能使用该索引。主键和唯一约束会创建索引,外键引用列不自动创建索引。"
+        "title": "不可见索引与JSON索引各有边界",
+        "body": "不可见索引默认不参与优化器选择,但仍维护并占用空间,适合可控验证,不是删除替代。JSON字段本身不能按任意路径自动获得B-tree索引;抽出稳定字段到生成列后建索引,需同时设计类型、长度和NULL语义。"
       }
     ],
     "steps": [
       {
-        "title": "在独立练习库生成性能样本",
-        "body": "仅 notes_lab 执行,写入 50,000 条带 db08-bench- 前缀的笔记。此脚本只运行一次,重复执行会额外产生同量数据。固定时间与稀疏 pinned 分布便于对照,但不能代表真实业务分布。",
-        "code": "INSERT INTO notes(title,content,pinned,created_at,updated_at)\nSELECT 'db08-bench-' || g, repeat('x',200), g % 100 = 0,\n       timestamptz '2026-01-01 00:00:00+00' + g * interval '1 second',\n       timestamptz '2026-01-01 00:00:00+00' + g * interval '1 second'\nFROM generate_series(1,50000) AS g;\nANALYZE notes;\nSELECT count(*),count(*) FILTER(WHERE pinned) FROM notes;",
-        "lang": "sql"
+        "title": "有限批次生成50000条性能样本",
+        "body": "保存db_seed_performance.py,只对notes_lab执行一次。每批500条提交,出错只回滚当前批,之前批次仍保留;再次运行会新增同量数据,不能假定脚本幂等。所有正文不超过16000字符。",
+        "code": "from datetime import datetime,timedelta\nfrom mysql_client import connect\n\nconn=connect()\ntry:\n    for start in range(1,50001,500):\n        rows=[]\n        for number in range(start,min(start+500,50001)):\n            utc_value=datetime(2026,1,1)+timedelta(seconds=number)\n            rows.append((f\"db08-bench-{number}\",\"x\"*200,number%100==0,utc_value,utc_value))\n        with conn.cursor() as cur:\n            cur.executemany(\"INSERT INTO notes(title,content,pinned,created_at,updated_at) VALUES(%s,%s,%s,%s,%s)\",rows)\n        conn.commit()\n    print(\"inserted\",50000)\nexcept BaseException:\n    conn.rollback()\n    raise\nfinally:\n    conn.close()",
+        "lang": "python",
+        "filename": "db_seed_performance.py"
       },
       {
-        "title": "保存无专用索引时的计划",
-        "body": "先只用 EXPLAIN 观察估计计划,下一课运行带 ANALYZE 的实际计划。记录扫描方式、Sort 是否存在及估计行数,不把某个固定 cost 数值作为正确答案。",
-        "code": "EXPLAIN\nSELECT id,title,created_at FROM notes\nWHERE pinned=true ORDER BY created_at DESC,id DESC LIMIT 20;\nSELECT indexname,indexdef FROM pg_indexes WHERE tablename='notes';",
+        "title": "保存原计划并建立组合索引",
+        "body": "MySQL DDL发生隐式提交,因此在独立维护会话运行。ALGORITHM=INPLACE,LOCK=NONE请求在线二级索引创建,若当前操作或表条件不支持就报错而非假装完全无锁。仍可能需要短暂元数据锁,设置lock_wait_timeout。",
+        "code": "ANALYZE TABLE notes;\nEXPLAIN SELECT id,title,created_at FROM notes WHERE pinned=1\nORDER BY created_at DESC,id DESC LIMIT 20;\nSET SESSION lock_wait_timeout=3;\nCREATE INDEX notes_pinned_created_id_idx ON notes(pinned,created_at DESC,id DESC)\nALGORITHM=INPLACE LOCK=NONE;\nEXPLAIN SELECT id,title,created_at FROM notes WHERE pinned=1\nORDER BY created_at DESC,id DESC LIMIT 20;\nSHOW INDEX FROM notes;",
         "lang": "sql"
       },
       {
-        "title": "建立组合索引并对比",
-        "body": "建索引后重新执行相同 EXPLAIN。数据量足够时通常能减少扫描和排序工作,但输出由版本、统计和数据决定。索引包含 id 用于同一时间点下稳定排序。",
-        "code": "CREATE INDEX notes_pinned_created_id_idx ON notes(pinned,created_at DESC,id DESC);\nANALYZE notes;\nEXPLAIN\nSELECT id,title,created_at FROM notes\nWHERE pinned=true ORDER BY created_at DESC,id DESC LIMIT 20;\nSELECT pg_size_pretty(pg_relation_size('notes_pinned_created_id_idx')) AS index_size;",
+        "title": "用不可见索引做读计划对照",
+        "body": "仅对刚创建的实验索引切换,不修改主键。观察key、rows和Extra变化,恢复VISIBLE后重新验收。不可见期间写入仍需维护该索引,不能用它证明删除索引后的写入收益。",
+        "code": "ALTER TABLE notes ALTER INDEX notes_pinned_created_id_idx INVISIBLE;\nEXPLAIN SELECT id,title,created_at FROM notes WHERE pinned=1\nORDER BY created_at DESC,id DESC LIMIT 20;\nALTER TABLE notes ALTER INDEX notes_pinned_created_id_idx VISIBLE;\nSELECT DATA_LENGTH,INDEX_LENGTH FROM information_schema.tables\nWHERE table_schema='notes_lab' AND table_name='notes';",
         "lang": "sql"
       },
       {
-        "title": "将置顶查询改为部分索引实验",
-        "body": "为能明确对照,只在 notes_lab 删除刚创建的实验索引,改建部分索引,再执行同样查询。生产建索引可评估 CREATE INDEX CONCURRENTLY 以减少阻塞,但它不能放在事务块中,失败后还需检查无效索引。",
-        "code": "DROP INDEX notes_pinned_created_id_idx;\nCREATE INDEX notes_pinned_recent_idx ON notes(created_at DESC,id DESC) WHERE pinned=true;\nEXPLAIN\nSELECT id,title,created_at FROM notes\nWHERE pinned=true ORDER BY created_at DESC,id DESC LIMIT 20;\nSELECT pg_size_pretty(pg_relation_size('notes_pinned_recent_idx')) AS index_size;\nCREATE INDEX note_tags_tag_id_idx ON note_tags(tag_id);",
+        "title": "在专用表给JSON路径建生成列索引",
+        "body": "为避免改变贯穿课程notes模型,用独立json_index_probe。source取固定文本,JSON无该字段时生成NULL。查询直接使用生成列,避免依赖优化器是否把另一个表达式识别成相同形式。小表可能仍顺序扫描,目的先验证语义。",
+        "code": "CREATE TABLE json_index_probe (\n id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,\n payload JSON NOT NULL,\n source VARCHAR(32) GENERATED ALWAYS AS (JSON_UNQUOTE(JSON_EXTRACT(payload,'$.source'))) STORED,\n INDEX source_idx(source)\n) ENGINE=InnoDB;\nINSERT INTO json_index_probe(payload) VALUES('{\"source\":\"api\",\"revision\":1}'),('{\"revision\":2}');\nSELECT id,source FROM json_index_probe ORDER BY id;\nEXPLAIN SELECT id FROM json_index_probe WHERE source='api';\n-- 本课专用表验收结束后清理\nDROP TABLE json_index_probe;",
         "lang": "sql"
       }
     ],
     "checks": [
-      "记录至少两个版本计划和各自索引大小。",
-      "能说明 pinned=true 查询与部分索引谓词的关系。",
-      "note_tags 的联合主键覆盖 note_id 开头查找,反向 tag_id 查询有单独索引。"
+      "保存建索引前后真实计划,不要求固定成本数值。",
+      "不可见索引能恢复可见,明确仍有写维护成本。",
+      "JSON路径生成列得到api与NULL,查询含义明确。"
     ],
     "pitfalls": [
       {
-        "symptom": "建了索引仍然 Seq Scan",
-        "cause": "表很小、命中行过多、统计过旧或查询不匹配索引。",
-        "fix": "先 ANALYZE 并检查实际 SQL、类型与选择率;不要通过关闭顺序扫描作为生产修复。"
+        "symptom": "照搬WHERE pinned=1的CREATE INDEX报语法错误",
+        "cause": "MySQL不支持谓词部分索引。",
+        "fix": "使用与查询匹配的组合索引,或按业务设计受控生成列并验证访问路径。"
       },
       {
-        "symptom": "索引越加越多,写入越来越慢",
-        "cause": "每个索引都增加存储与维护,部分索引可能重复覆盖同一查询。",
-        "fix": "保留使用证据与目标查询,对重复索引评估后再清理,结合写入延迟一起验收。"
+        "symptom": "在线建索引仍在等待",
+        "cause": "LOCK=NONE不等于从不申请元数据锁,长事务可能阻塞DDL。",
+        "fix": "设短lock_wait_timeout并检查元数据锁,安排维护窗口;不要关闭所有约束强行建表。"
       }
     ],
     "exercise": {
-      "task": "为 WHERE tag_id=? 查询关联的 note_id 设计索引,并判断现有主键是否足够。",
-      "hint": "联合主键是 (note_id,tag_id),注意最左列。",
-      "answer": "现有主键擅长 note_id 或 note_id+tag_id 条件,不应默认满足按 tag_id 反查的性能需求。可使用 CREATE INDEX ON note_tags(tag_id,note_id),并在实际数据上对比;若采用此组合索引,应评估是否还需要单列 tag_id 索引。"
+      "task": "为什么SELECT id只读置顶列表比同时读取大content更容易覆盖索引?",
+      "hint": "二级索引有索引列与主键,但不自动包含整个TEXT。",
+      "answer": "id、pinned、created_at可由组合二级索引提供;content通常需要回聚簇索引取行,扩大读取量。不要为覆盖随意把大正文加入索引,先限制输出字段并比较计划。"
     },
     "refs": [
       {
-        "label": "PostgreSQL 16:多列索引",
-        "url": "https://www.postgresql.org/docs/16/indexes-multicolumn.html"
+        "label": "MySQL 8.4:多列索引",
+        "url": "https://dev.mysql.com/doc/refman/8.4/en/multiple-column-indexes.html"
       },
       {
-        "label": "PostgreSQL 16:部分索引",
-        "url": "https://www.postgresql.org/docs/16/indexes-partial.html"
+        "label": "MySQL 8.4:不可见索引",
+        "url": "https://dev.mysql.com/doc/refman/8.4/en/invisible-indexes.html"
       },
       {
-        "label": "PostgreSQL 16:CREATE INDEX",
-        "url": "https://www.postgresql.org/docs/16/sql-createindex.html"
+        "label": "MySQL 8.4:生成列索引",
+        "url": "https://dev.mysql.com/doc/refman/8.4/en/generated-column-index-optimizations.html"
+      },
+      {
+        "label": "MySQL 8.4:在线DDL",
+        "url": "https://dev.mysql.com/doc/refman/8.4/en/innodb-online-ddl-operations.html"
       }
     ],
     "production": {
-      "scenario": "首页需要快速显示置顶笔记,写入仍持续发生。",
+      "scenario": "首页置顶查询变慢,需要同时控制读延迟和写入成本。",
       "decisions": [
-        "索引对应一条明确查询。",
-        "用接近真实分布的数据评估读写成本。",
-        "生产在线建索引单独规划事务与失败恢复。"
+        "索引对应明确查询。",
+        "维护DDL与业务事务分开。",
+        "JSON路径类型与长度受约束。"
       ],
-      "failureDrill": "在 notes_lab 比较无索引、组合索引与部分索引的计划与大小。",
+      "failureDrill": "在notes_lab比较可见/不可见计划和JSON路径结果,恢复索引可见状态。",
       "acceptance": [
-        "查询顺序确定。",
-        "有性能证据支持索引选择。",
-        "没有把练习库 DROP INDEX 直接当成生产变更方案。"
+        "有数据规模和计划证据。",
+        "不声称MySQL支持部分索引。",
+        "没有为每个字段盲目建索引。"
       ]
     }
   },
   {
     "id": "09",
-    "title": "读取 EXPLAIN ANALYZE 并改造深分页",
     "group": "查询性能",
-    "summary": "用执行计划验证实际工作量,再把大 OFFSET 改成稳定的游标条件。",
+    "title": "读懂EXPLAIN ANALYZE并优化深分页",
+    "summary": "对比估计JSON计划与实际TREE执行结果,用范围条件替代大OFFSET。",
     "objectives": [
-      "读懂实际行数、loops 与 buffers",
-      "比较浅分页和深分页的扫描量",
-      "使用 created_at 与 id 实现键集分页"
+      "区分EXPLAIN估计与ANALYZE真实执行",
+      "用实际rows和loops分析深分页",
+      "实现与排序一致的二字段游标"
     ],
     "concepts": [
       {
-        "title": "ANALYZE 会真的执行 SQL",
-        "body": "EXPLAIN 只生成计划,EXPLAIN ANALYZE 会执行语句并收集结果。此课仅分析 SELECT;不要直接拿它分析线上 DELETE 或 UPDATE。实际耗时受缓存与负载影响,需要同时看扫描量和缓冲访问。"
+        "title": "MySQL的计划格式要对应版本",
+        "body": "EXPLAIN FORMAT=JSON提供估计计划结构;MySQL 8.4的EXPLAIN ANALYZE使用TREE展示实际时间、行数和循环。ANALYZE会执行查询,本课只用于SELECT,不把写语句当作无副作用诊断。"
       },
       {
-        "title": "深 OFFSET 仍需跳过前面的记录",
-        "body": "LIMIT 20 OFFSET 40000 返回 20 行,但执行器通常仍需走过被跳过的记录。键集分页使用上一页最后一行的排序键,直接定位下一段范围。"
+        "title": "深OFFSET需要处理跳过的数据",
+        "body": "LIMIT 20 OFFSET 40000通常仍处理之前大量记录。连续滚动适合以最后一行的排序键定位下一页;任意跳页则需要另做产品与性能取舍,不能把取深游标的成本忽略掉。"
       },
       {
-        "title": "游标必须与排序完全一致",
-        "body": "按 created_at DESC,id DESC 排序,就用 (created_at,id)<(游标时间,游标编号)。id 处理相同时间的并列。游标不是快照;并发删除、排序字段修改仍可能影响跨页视图,导出要另行定义一致性。"
+        "title": "复合游标展开成明确范围",
+        "body": "按created_at DESC,id DESC排序,就筛选created_at<游标时间 OR(created_at=游标时间 AND id<游标id)。展开条件方便核对MySQL访问计划,不能仅假定行构造器比较一定利用完整复合索引。created_at应保持稳定。"
       }
     ],
     "steps": [
       {
-        "title": "创建列表索引并收集实际计划",
-        "body": "继续使用第 08 课的 50,000 条样本。首次建索引一次,运行 ANALYZE。记录节点名称、actual rows、loops、Buffers 和 Execution Time。不要要求每台机器出现完全相同的执行时间。",
-        "code": "CREATE INDEX notes_created_id_idx ON notes(created_at DESC,id DESC);\nANALYZE notes;\nEXPLAIN (ANALYZE,BUFFERS)\nSELECT id,title,created_at FROM notes ORDER BY created_at DESC,id DESC LIMIT 20;",
+        "title": "建立列表索引并比较两种计划",
+        "body": "继续使用第08课样本,CREATE INDEX首次执行一次。ANALYZE TABLE是更新统计,EXPLAIN ANALYZE是执行并测量查询,两者用途不同。读取节点actual time、rows、loops;不要把cost当毫秒。",
+        "code": "CREATE INDEX notes_created_id_idx ON notes(created_at DESC,id DESC);\nANALYZE TABLE notes;\nEXPLAIN FORMAT=JSON SELECT id,title,created_at FROM notes\nORDER BY created_at DESC,id DESC LIMIT 20;\nEXPLAIN ANALYZE SELECT id,title,created_at FROM notes\nORDER BY created_at DESC,id DESC LIMIT 20;",
         "lang": "sql"
       },
       {
-        "title": "比较深分页的额外工作",
-        "body": "运行相同排序的深 OFFSET。即使最后只返回 20 行,下层扫描节点通常处理约 40,020 行。若计划不同,按真实节点追踪返回与丢弃的行,不强行匹配示例计划。",
-        "code": "EXPLAIN (ANALYZE,BUFFERS)\nSELECT id,title,created_at FROM notes\nORDER BY created_at DESC,id DESC LIMIT 20 OFFSET 40000;",
+        "title": "记录深OFFSET额外扫描量",
+        "body": "相同查询仅改变OFFSET,比较底层扫描节点的actual rows。设备、缓冲池和其他事务会影响毫秒数,保留数据规模及是否已预热,不预设固定数值。",
+        "code": "EXPLAIN ANALYZE SELECT id,title,created_at FROM notes\nORDER BY created_at DESC,id DESC LIMIT 20 OFFSET 40000;",
         "lang": "sql"
       },
       {
-        "title": "从第一页取得下一页游标",
-        "body": "以下 psql 查询取第一页最后一行的时间和编号,\\gset 保存为 cursor_created、cursor_id。实际 API 应从已返回的第 20 行取值,不再单独 OFFSET 查询。使用 :'cursor_created' 让 psql 正确引用时间文本。",
-        "code": "SELECT created_at AS cursor_created,id AS cursor_id\nFROM notes ORDER BY created_at DESC,id DESC LIMIT 1 OFFSET 19 \\gset\nSELECT id,title,created_at FROM notes\nWHERE (created_at,id) < (:'cursor_created'::timestamptz,:cursor_id)\nORDER BY created_at DESC,id DESC LIMIT 20;\nEXPLAIN (ANALYZE,BUFFERS)\nSELECT id,title,created_at FROM notes\nWHERE (created_at,id) < (:'cursor_created'::timestamptz,:cursor_id)\nORDER BY created_at DESC,id DESC LIMIT 20;",
+        "title": "从第一页末尾读取游标",
+        "body": "mysql会话变量通过SELECT INTO设置。实际API直接从已返回第20行取得时间和编号,不额外做OFFSET查询。下一页查询必须同时保留同样的过滤条件和排序方向。",
+        "code": "SELECT created_at,id INTO @cursor_time,@cursor_id FROM notes\nORDER BY created_at DESC,id DESC LIMIT 1 OFFSET 19;\nSELECT id,title,created_at FROM notes\nWHERE created_at<@cursor_time OR (created_at=@cursor_time AND id<@cursor_id)\nORDER BY created_at DESC,id DESC LIMIT 20;\nEXPLAIN ANALYZE SELECT id,title,created_at FROM notes\nWHERE created_at<@cursor_time OR (created_at=@cursor_time AND id<@cursor_id)\nORDER BY created_at DESC,id DESC LIMIT 20;",
         "lang": "sql"
       },
       {
         "title": "在相近深度公平对照",
-        "body": "为了教学比较,先花一次 OFFSET 成本拿到深处游标,再测游标查询本身。实际连续翻页只传前页游标。连续运行两次,说明缓存预热影响,不用单次毫秒值证明普遍结论。",
-        "code": "SELECT created_at AS deep_created,id AS deep_id\nFROM notes ORDER BY created_at DESC,id DESC LIMIT 1 OFFSET 39999 \\gset\nEXPLAIN (ANALYZE,BUFFERS)\nSELECT id,title,created_at FROM notes\nWHERE (created_at,id) < (:'deep_created'::timestamptz,:deep_id)\nORDER BY created_at DESC,id DESC LIMIT 20;",
+        "body": "教学为了获得第40000行游标,先花一次OFFSET成本,随后只比较游标查询本身。生产连续翻页用上页已有游标。若计划仍扫描大量行,观察OR范围与索引使用情况,而不是强行宣称游标一定更快。",
+        "code": "SELECT created_at,id INTO @deep_time,@deep_id FROM notes\nORDER BY created_at DESC,id DESC LIMIT 1 OFFSET 39999;\nEXPLAIN ANALYZE SELECT id,title,created_at FROM notes\nWHERE created_at<@deep_time OR(created_at=@deep_time AND id<@deep_id)\nORDER BY created_at DESC,id DESC LIMIT 20;\n-- 第二次运行用于观察热缓冲条件,记录两次实际结果。",
         "lang": "sql"
       }
     ],
     "checks": [
-      "记录浅 OFFSET、深 OFFSET 与深游标的实际扫描量。",
-      "下一页与前页没有重复编号,排序键与过滤条件完全一致。",
-      "能区分计划估计值与实际执行值,不把 cost 当成毫秒。"
+      "区分JSON估计计划与TREE实际执行数据。",
+      "深OFFSET和深游标有可对照的扫描量记录。",
+      "时间相同的记录由id稳定排序,跨页无重复编号。"
     ],
     "pitfalls": [
       {
-        "symptom": "分页重复或漏掉相同时间的记录",
-        "cause": "只把 created_at 放进排序或游标,缺少唯一的并列键。",
-        "fix": "排序和比较同时包含 id,并让排序方向与比较符号一致。"
+        "symptom": "EXPLAIN ANALYZE FORMAT=JSON不支持",
+        "cause": "把不同版本或其他数据库的格式组合套到MySQL8.4。",
+        "fix": "本课使用EXPLAIN FORMAT=JSON与EXPLAIN ANALYZE两条独立命令。"
       },
       {
-        "symptom": "游标查询变慢且出现 Sort",
-        "cause": "缺少匹配排序的索引,或添加了新筛选条件改变访问方式。",
-        "fix": "对实际查询重新 EXPLAIN ANALYZE,检查复合索引与筛选顺序,而不是只调 LIMIT。"
+        "symptom": "同一时间的笔记跨页重复",
+        "cause": "只按时间排序或只使用一个时间游标。",
+        "fix": "排序与筛选同时包含id,并完整保留其他列表条件。"
       }
     ],
     "exercise": {
-      "task": "给置顶列表加入键集分页,并说明使用哪个已有索引。",
-      "hint": "保留 pinned=true,同时添加二元组范围条件。",
-      "answer": "SELECT id,title,created_at FROM notes WHERE pinned=true AND (created_at,id)<($1,$2) ORDER BY created_at DESC,id DESC LIMIT 20; 这是数据库参数形式示意,psycopg 中使用 %s。第 08 课的 notes_pinned_recent_idx 对该固定谓词和排序有用,仍需实际执行计划验证。"
+      "task": "给置顶列表添加游标条件,说明逻辑运算括号位置。",
+      "hint": "pinned条件必须作用于OR的两个分支。",
+      "answer": "WHERE pinned=1 AND (created_at<%s OR(created_at=%s AND id<%s)) ORDER BY created_at DESC,id DESC LIMIT 20。参数传(time,time,id)。缺少外层括号会让后一OR分支绕过pinned过滤。"
     },
     "refs": [
       {
-        "label": "PostgreSQL 16:EXPLAIN",
-        "url": "https://www.postgresql.org/docs/16/using-explain.html"
+        "label": "MySQL 8.4:EXPLAIN",
+        "url": "https://dev.mysql.com/doc/refman/8.4/en/explain.html"
       },
       {
-        "label": "PostgreSQL 16:LIMIT 与 OFFSET",
-        "url": "https://www.postgresql.org/docs/16/queries-limit.html"
+        "label": "MySQL 8.4:LIMIT优化",
+        "url": "https://dev.mysql.com/doc/refman/8.4/en/limit-optimization.html"
       },
       {
-        "label": "PostgreSQL 16:行比较",
-        "url": "https://www.postgresql.org/docs/16/functions-comparisons.html"
+        "label": "MySQL 8.4:行构造器优化",
+        "url": "https://dev.mysql.com/doc/refman/8.4/en/row-constructor-optimization.html"
       }
     ],
     "production": {
-      "scenario": "笔记数量增长后,第几百页响应变慢,而第一页正常。",
+      "scenario": "数据增长后后页延迟明显高于第一页。",
       "decisions": [
-        "以实际扫描量解释深分页成本。",
-        "连续滚动用键集分页,任意跳页另做产品和性能取舍。",
-        "游标字段选择稳定值,分页不默认承诺全程快照。"
+        "先量工作量再比较耗时。",
+        "游标字段稳定且唯一排序。",
+        "跨页请求不默认共享数据库快照。"
       ],
-      "failureDrill": "在 notes_lab 对比相同深度的 OFFSET 与游标查询,记录执行计划。",
+      "failureDrill": "在notes_lab对照浅页、深OFFSET和深游标,保存真实计划。",
       "acceptance": [
-        "深页工作量明显下降或有证据解释未下降原因。",
-        "同时间记录不重复。",
-        "测试记录注明样本规模与缓存状态。"
+        "不忽略取游标成本。",
+        "无错误格式选项。",
+        "性能结论注明样本与缓存状态。"
       ]
     }
   },
   {
     "id": "10",
-    "title": "用 psycopg 连接池管理连接与事务",
     "group": "Python 与运行保障",
-    "summary": "让并发代码借用有限连接,复现池耗尽,确保异常后连接能够归还。",
+    "title": "用有界连接池管理PyMySQL连接与事务",
+    "summary": "明确驱动不自带业务连接池,通过QueuePool复用连接并验证借还和耗尽恢复。",
     "objectives": [
-      "建立一个可显式开启和关闭的连接池",
-      "把事务包含在借还连接的作用域内",
-      "通过超时和统计观察池拥塞"
+      "建立进程级有界DBAPI连接池",
+      "明确commit/rollback与close归还语义",
+      "在连接损坏时失效而非暗中重连"
     ],
     "concepts": [
       {
-        "title": "连接池是有限资源预算",
-        "body": "每个连接对应数据库后端资源。池在 min_size 与 max_size 范围复用连接,借不到时等待而非无限新建。进程数乘每进程最大池容量,再加任务、运维与其他服务连接,必须落在数据库预算内。"
+        "title": "PyMySQL连接池需额外组件",
+        "body": "PyMySQL是同步DBAPI驱动,本课使用SQLAlchemy QueuePool作为独立池组件,不引入ORM。pool_size加max_overflow是本进程连接上限,池按需创建连接;应用启动、关闭统一管理。"
       },
       {
-        "title": "连接不等于业务并发数",
-        "body": "多个请求可以排队等待少量连接。持有连接的时间越长,可服务吞吐越低。一个连接上的查询串行执行;不同线程不要把同一连接当作各自独立事务。同步 psycopg 应运行在同步代码或受控线程中。"
+        "title": "连接与事务都不能跨并发请求共享",
+        "body": "线程借到各自连接后完成短事务,再close归还。池代理的close不是销毁底层连接,dispose关闭空闲连接;关闭时先停止工作并归还所有借出连接。多进程在子进程内各建池,不跨fork复用连接。"
       },
       {
-        "title": "借还边界包含提交与回滚",
-        "body": "pool.connection() 的上下文负责正常结束时提交、异常退出时回滚并归还连接。应用启动创建池、退出关闭池;业务函数借用连接,不在每次调用时重新创建整个池。异步 API 对应 AsyncConnectionPool,在 API + 数据库模块接入。"
+        "title": "活跃事务禁止透明重连",
+        "body": "网络断开后不要ping(reconnect=True)继续使用旧事务,数据库事务已经丢失。池借出前用ping(reconnect=False)发现坏连接并invalidate,交上层按业务语义重试。socket超时不能证明最后一个写入未提交。"
       }
     ],
     "steps": [
       {
-        "title": "安装池依赖",
-        "body": "沿用第 03 课 PG 环境变量。在虚拟环境运行 python -m pip install \"psycopg[binary,pool]>=3.2,<4\",然后更新 requirements-lock.txt。psycopg_pool 是单独分发的依赖,安装 psycopg 本体不代表已安装连接池。",
-        "code": "python -m pip install \"psycopg[binary,pool]>=3.2,<4\"\npython -m pip freeze > requirements-lock.txt",
-        "lang": "bash"
+        "title": "建立独立池组件",
+        "body": "安装SQLAlchemy2.0系列并更新锁文件。在db_pool.py保存下面代码,复用第03课mysql_client.connect及其UTC、RC和超时。没有把同步调用放进async路由;异步接入在API+数据库模块使用对应驱动与池。",
+        "code": "# Shell先执行:python -m pip install 'SQLAlchemy>=2.0,<2.1'\nfrom sqlalchemy.pool import QueuePool\nfrom mysql_client import connect\n\npool=QueuePool(creator=connect,pool_size=2,max_overflow=0,timeout=0.2,\n               recycle=300,reset_on_return=\"rollback\")\n\ndef checkout():\n    conn=pool.connect()\n    try:\n        conn.ping(reconnect=False)\n        return conn\n    except BaseException:\n        conn.invalidate()\n        conn.close()\n        raise",
+        "lang": "python",
+        "filename": "db_pool.py"
       },
       {
-        "title": "运行并发读的完整示例",
-        "body": "保存为 db_pool.py 并运行。4 个工作线程共用最大 2 条连接;每次函数调用独立借还。open=False 加 open(wait=True) 让启动阶段就确认连接可用,退出通过 finally 关闭。应用不应把密码或整条连接信息打印到日志。",
-        "code": "from concurrent.futures import ThreadPoolExecutor\nfrom psycopg_pool import ConnectionPool\n\npool = ConnectionPool(\"\", min_size=1, max_size=2, timeout=2, open=False,\n                      kwargs={\"application_name\": \"notes-service-db-lab\"})\n\ndef count_notes(_):\n    with pool.connection() as conn:\n        with conn.cursor() as cur:\n            cur.execute(\"SET LOCAL statement_timeout='2s'\")\n            cur.execute(\"SELECT count(*) FROM notes\")\n            return cur.fetchone()[0]\n\ntry:\n    pool.open(wait=True, timeout=10)\n    with ThreadPoolExecutor(max_workers=4) as workers:\n        print(list(workers.map(count_notes, range(8))))\n    print(pool.get_stats())\nfinally:\n    pool.close()",
+        "title": "4个线程共享最多2条连接",
+        "body": "保存db_pool_read.py。异常回滚失败时也必须在finally归还或失效;这里只读查询出错后把连接失效处理,避免把损坏连接交给下一请求。不要在持有连接时睡眠或执行外部HTTP。",
+        "code": "from concurrent.futures import ThreadPoolExecutor\nfrom db_pool import pool,checkout\n\ndef count_notes(_):\n    conn=checkout()\n    try:\n        with conn.cursor() as cur:\n            cur.execute(\"SELECT COUNT(*) AS n FROM notes\")\n            value=cur.fetchone()[\"n\"]\n        conn.commit()\n        return value\n    except BaseException:\n        try:\n            conn.rollback()\n        finally:\n            conn.invalidate()\n        raise\n    finally:\n        conn.close()\n\ntry:\n    with ThreadPoolExecutor(max_workers=4) as workers:\n        print(list(workers.map(count_notes,range(8))))\n    print(pool.status())\nfinally:\n    pool.dispose()",
         "lang": "python",
-        "filename": "db_pool.py"
+        "filename": "db_pool_read.py"
       },
       {
-        "title": "验证异常后事务回滚",
-        "body": "保存为 db_pool_rollback.py。故意抛出 Python 异常,异常必须离开 connection 上下文后再捕获,否则上下文可能把事务当作正常结束而提交。第二次借用连接后查询结果应为 0。",
-        "code": "from psycopg_pool import ConnectionPool\n\nwith ConnectionPool(\"\", min_size=1, max_size=1) as pool:\n    pool.wait(timeout=10)\n    note_id = None\n    try:\n        with pool.connection() as conn:\n            note_id = conn.execute(\n                \"INSERT INTO notes(title) VALUES (%s) RETURNING id\",\n                (\"db10-rollback\",),\n            ).fetchone()[0]\n            raise RuntimeError(\"simulated failure after insert\")\n    except RuntimeError:\n        pass\n    with pool.connection() as conn:\n        count = conn.execute(\"SELECT count(*) FROM notes WHERE id=%s\", (note_id,)).fetchone()[0]\n        assert count == 0\n        print(\"rolled back\", count)",
+        "title": "验证异常插入明确回滚",
+        "body": "保存db_pool_rollback.py。主动异常后rollback,再借到连接查询同一编号应0。代码不依赖连接上下文自动提交。SQLAlchemy池归还回滚是最后防线,不能替代显式业务事务控制。",
+        "code": "from db_pool import pool,checkout\n\nnote_id=None\nconn=checkout()\ntry:\n    with conn.cursor() as cur:\n        cur.execute(\"INSERT INTO notes(title) VALUES(%s)\",(\"db10-rollback\",))\n        note_id=cur.lastrowid\n    raise RuntimeError(\"simulated after insert\")\nexcept RuntimeError:\n    conn.rollback()\nfinally:\n    conn.close()\nconn=checkout()\ntry:\n    with conn.cursor() as cur:\n        cur.execute(\"SELECT COUNT(*) AS n FROM notes WHERE id=%s\",(note_id,))\n        assert cur.fetchone()[\"n\"]==0\n    conn.commit()\nfinally:\n    conn.rollback()\n    conn.close()\n    pool.dispose()",
         "lang": "python",
         "filename": "db_pool_rollback.py"
       },
       {
-        "title": "可控复现池耗尽",
-        "body": "保存为 db_pool_timeout.py,只在本机练习运行。max_size=1 时外层已经占用唯一连接,内层借用 0.2 秒后超时;捕获异常后退出外层归还连接,再借用应成功。生产代码应消除嵌套借还并缩短持有时间,不能只扩大池。",
-        "code": "from psycopg_pool import ConnectionPool, PoolTimeout\n\nwith ConnectionPool(\"\", min_size=1, max_size=1) as pool:\n    pool.wait(timeout=10)\n    with pool.connection():\n        try:\n            with pool.connection(timeout=0.2):\n                pass\n        except PoolTimeout:\n            print(\"expected pool timeout\")\n    with pool.connection() as conn:\n        print(\"recovered\", conn.execute(\"SELECT 1\").fetchone()[0])\n    print(pool.get_stats())",
+        "title": "占用全部连接后触发有界等待",
+        "body": "保存db_pool_timeout.py。两条连接都借出时第三次借用约0.2秒后抛SQLAlchemy TimeoutError;释放后重新借用成功。这个超时与数据库行锁1205不是同一故障层。仅在独立练习进程执行。",
+        "code": "from sqlalchemy.exc import TimeoutError\nfrom db_pool import pool,checkout\n\nfirst=checkout()\nsecond=checkout()\ntry:\n    try:\n        unexpected=checkout()\n    except TimeoutError:\n        print(\"expected pool timeout\")\n    else:\n        unexpected.close()\n        raise AssertionError(\"pool exceeded configured limit\")\nfinally:\n    first.rollback();first.close()\n    second.rollback();second.close()\nconn=checkout()\ntry:\n    with conn.cursor() as cur:\n        cur.execute(\"SELECT 1 AS ok\")\n        assert cur.fetchone()[\"ok\"]==1\n    conn.commit()\nfinally:\n    conn.rollback();conn.close();pool.dispose()",
         "lang": "python",
         "filename": "db_pool_timeout.py"
       }
     ],
     "checks": [
-      "并发读取成功,连接数量受 max_size=2 限制。",
-      "异常插入的笔记在下一次借用中查不到。",
-      "池耗尽产生 PoolTimeout,归还后 SELECT 1 恢复。"
+      "并发读取完成且最大连接数为2。",
+      "异常插入回滚,连接归还后可以继续使用。",
+      "耗尽产生池等待超时,释放后恢复,不盲目扩池。"
     ],
     "pitfalls": [
       {
-        "symptom": "高并发时不断出现 PoolTimeout",
-        "cause": "连接持有时间过长、嵌套借用或总并发超过容量。",
-        "fix": "检查等待数和事务时长,先缩短查询与持有范围,再按数据库预算调整池。"
+        "symptom": "关闭池后仍有数据库连接",
+        "cause": "还有借出连接或工作线程未结束。",
+        "fix": "先停止接收任务并等待有界工作结束,归还连接后dispose。"
       },
       {
-        "symptom": "async 路由中数据库调用阻塞其他请求",
-        "cause": "在事件循环中直接执行同步 psycopg 查询。",
-        "fix": "用 AsyncConnectionPool 与 await,或在受控线程执行同步代码;不要只把函数名改成 async。"
+        "symptom": "断线后自动重连并继续提交",
+        "cause": "新连接不包含旧事务状态。",
+        "fix": "禁用事务中透明重连,失效旧连接并按幂等性重试完整业务动作。"
       }
     ],
     "exercise": {
-      "task": "4 个 API 进程各 max_size=8,两个任务进程各 max_size=5,预留运维 10 条连接,至少需要规划多少连接?",
-      "hint": "进程之间不共享一个 Python 连接池。",
-      "answer": "仅这些进程峰值预算是 4×8+2×5+10=52 条,还应核查迁移、监控、其他服务和数据库保留连接。52 是这组假设下的预算,不是直接把 max_connections 设为 52 的建议。"
+      "task": "4个API进程每池8条、2个任务进程每池5条,再留10条运维,最低连接预算是多少?",
+      "hint": "每个进程有独立池,不能只算一个进程。",
+      "answer": "4×8+2×5+10=52条,还需加迁移、监控和其他服务余量。max_connections不能只机械等于52,数据库内存与请求排队策略也应验收。"
     },
     "refs": [
       {
-        "label": "Psycopg 3:连接池",
-        "url": "https://www.psycopg.org/psycopg3/docs/advanced/pool.html"
+        "label": "SQLAlchemy:独立池用法",
+        "url": "https://docs.sqlalchemy.org/en/20/core/pooling.html#using-a-pool-instance-directly"
       },
       {
-        "label": "Psycopg 3:池 API",
-        "url": "https://www.psycopg.org/psycopg3/docs/api/pool.html"
+        "label": "PyMySQL:连接参数",
+        "url": "https://pymysql.readthedocs.io/en/latest/modules/connections.html"
       },
       {
-        "label": "Psycopg 3:并发与异步",
-        "url": "https://www.psycopg.org/psycopg3/docs/advanced/async.html"
+        "label": "MySQL 8.4:连接过多",
+        "url": "https://dev.mysql.com/doc/refman/8.4/en/too-many-connections.html"
       }
     ],
     "production": {
-      "scenario": "API 与任务进程同时访问 PostgreSQL,突发请求不应耗尽全部数据库连接。",
+      "scenario": "API与worker同时访问数据库,突发流量不能无限增加连接。",
       "decisions": [
-        "按进程数计算总连接预算。",
-        "连接等待和 SQL 执行分别设时限。",
-        "业务异常离开事务边界后再转为响应。"
+        "进程内池、进程间总预算。",
+        "事务显式结束,断线连接失效。",
+        "池等待、网络等待、数据库锁等待分开监控。"
       ],
-      "failureDrill": "在 notes_lab 用单连接池制造超时和事务异常,再确认连接能归还复用。",
+      "failureDrill": "在独立进程耗尽2条连接并触发插入回滚,观察归还恢复。",
       "acceptance": [
-        "没有连接泄漏。",
-        "等待有时限与可记录指标。",
-        "连接池在应用生命周期内创建和关闭。"
+        "无泄漏。",
+        "等待有界。",
+        "坏连接不携带假事务继续执行。"
       ]
     }
   },
   {
     "id": "11",
-    "title": "拆分数据库权限并管理连接配置",
     "group": "Python 与运行保障",
-    "summary": "让运行时账号只做业务读写,限制危险语句,并检查日志和连接配置中的敏感信息。",
+    "title": "按账号host范围拆分权限与连接配置",
+    "summary": "为API创建最小读写账号,区分DDL、业务查询和运维诊断权限。",
     "objectives": [
-      "区分迁移账号与 API 运行账号",
-      "配置现有对象和未来对象的授权",
-      "验证最小权限以及超时配置"
+      "识别user@host授权匹配",
+      "授予现有与未来业务表的数据库级权限",
+      "配置TLS、凭据和每次连接的会话参数"
     ],
     "concepts": [
       {
-        "title": "权限应按工作职责分配",
-        "body": "app_user 保留为练习的对象所有者和迁移身份;新增 notes_runtime 供 API 运行。运行时需要 SELECT、INSERT、UPDATE、DELETE,不需要 CREATE ROLE、DROP TABLE 或 ALTER TABLE。数据库 CONNECT、schema USAGE、表权限是不同层。"
+        "title": "MySQL账号包含来源host",
+        "body": "app_user@%与app_user@localhost是两个账号。CURRENT_USER()显示最终匹配的授权身份。Docker练习通过网络连接,用%仅方便独立网络测试;生产使用受控网络范围、TLS与独立密码,不能把用户名相同当成同一身份。"
       },
       {
-        "title": "未来对象不会自动继承手工授权",
-        "body": "GRANT ON ALL TABLES 只覆盖当时存在的表。ALTER DEFAULT PRIVILEGES 针对特定创建者未来创建的对象生效,必须由正确身份设置。生产服务若只需要部分表,应显式授权,不能照搬练习的全部业务表授权。"
+        "title": "数据库级授权覆盖该库未来表",
+        "body": "GRANT SELECT,INSERT,UPDATE,DELETE ON notes_lab.*可覆盖现有和未来对象,不需要逐表自动权限模板。它范围比单表授权宽,生产根据服务边界选择。运行账号不授予CREATE、ALTER、DROP或全局管理权限。"
       },
       {
-        "title": "连接加密与秘密存储",
-        "body": "生产连接优先使用 TLS 且校验证书和主机名,例如 sslmode=verify-full 配合可信根证书。本机官方镜像没有自动配置可校验 TLS,不应伪称本课容器已提供 TLS。密码通过环境或秘密文件注入,SQL 参数和业务正文也可能包含敏感数据。"
+        "title": "配置责任不能靠账号默认猜测",
+        "body": "MySQL普通账号没有通用的每账号SET默认会话变量机制供本例照搬;应用connect统一设置UTC、RC、锁等待和SELECT时限。TLS要校验证书与主机名,密码轮换后新建连接验证,再逐步回收旧连接。"
       }
     ],
     "steps": [
       {
-        "title": "管理身份创建运行角色",
-        "body": "使用第 01 课 docker exec -it notes-postgres psql -U postgres -d notes_lab 进入管理连接。密码通过 \\password 交互设置。PUBLIC 是所有角色的隐式集合,撤销公开连接只限此独立练习库,不在共享线上库直接执行。",
-        "code": "CREATE ROLE notes_runtime LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE;\n\\password notes_runtime\nREVOKE CONNECT ON DATABASE notes_lab FROM PUBLIC;\nGRANT CONNECT ON DATABASE notes_lab TO app_user,notes_runtime;\nALTER ROLE notes_runtime IN DATABASE notes_lab SET statement_timeout='3s';\nALTER ROLE notes_runtime IN DATABASE notes_lab SET lock_timeout='1s';\nALTER ROLE notes_runtime IN DATABASE notes_lab SET idle_in_transaction_session_timeout='30s';",
+        "title": "管理连接创建运行身份",
+        "body": "docker exec -it notes-mysql mysql -u root -p后执行;密码在专用本机终端填写,不使用示例密码部署。mysql客户端将凭据命令写历史的风险可用该交互进程MYSQL_HISTFILE=/dev/null禁用,或使用受控管理员工具。下面账号仅一次创建。",
+        "code": "CREATE USER 'notes_runtime'@'%' IDENTIFIED BY 'REPLACE_WITH_LOCAL_RANDOM_PASSWORD';\nGRANT SELECT,INSERT,UPDATE,DELETE ON notes_lab.* TO 'notes_runtime'@'%';\nSHOW GRANTS FOR 'notes_runtime'@'%';\nSHOW GRANTS FOR 'app_user'@'%';",
         "lang": "sql"
       },
       {
-        "title": "所有者身份授权现有与未来对象",
-        "body": "重新用 app_user 连接 notes_lab 执行。当前课程所有业务表都位于 public,生产可改成独立 schema 并按表授权。identity 之外若使用显式序列默认值,需要相应 sequence 权限;本例将业务序列使用权限一并授予。",
-        "code": "REVOKE CREATE ON SCHEMA public FROM PUBLIC;\nGRANT USAGE ON SCHEMA public TO notes_runtime;\nGRANT SELECT,INSERT,UPDATE,DELETE ON ALL TABLES IN SCHEMA public TO notes_runtime;\nGRANT USAGE,SELECT ON ALL SEQUENCES IN SCHEMA public TO notes_runtime;\nALTER DEFAULT PRIVILEGES FOR ROLE app_user IN SCHEMA public\nGRANT SELECT,INSERT,UPDATE,DELETE ON TABLES TO notes_runtime;\nALTER DEFAULT PRIVILEGES FOR ROLE app_user IN SCHEMA public\nGRANT USAGE,SELECT ON SEQUENCES TO notes_runtime;",
+        "title": "以运行身份验证允许与拒绝",
+        "body": "新终端使用mysql --protocol=TCP -h127.0.0.1 -P3307 -u notes_runtime -p notes_lab连接。CRUD成功但ALTER被拒绝,专用样本插入后立即删除;不得给运行账号补DDL权限来消除这个预期错误。",
+        "code": "SELECT CURRENT_USER(),DATABASE();\nSET SESSION time_zone='+00:00';\nINSERT INTO notes(title) VALUES('db11-runtime');\nSET @runtime_id=LAST_INSERT_ID();\nDELETE FROM notes WHERE id=@runtime_id;\nALTER TABLE notes ADD COLUMN should_not_exist INT;\n-- 1142:ALTER command denied",
         "lang": "sql"
       },
       {
-        "title": "运行身份验证允许与拒绝操作",
-        "body": "通过 docker exec -it notes-postgres psql -h 127.0.0.1 -U notes_runtime -d notes_lab -W 连接。插入成功后删除本次编号;尝试 ALTER TABLE 会因不是所有者失败,不应产生新列。",
-        "code": "SELECT current_user;\nINSERT INTO notes(title) VALUES ('db11-runtime') RETURNING id AS runtime_id \\gset\nDELETE FROM notes WHERE id=:runtime_id;\nALTER TABLE notes ADD COLUMN should_not_exist text;\n-- ERROR: must be owner of table notes\nSHOW statement_timeout;\nSHOW lock_timeout;\nSELECT pg_sleep(4);\n-- 约 3 秒取消,SQLSTATE 57014",
+        "title": "验证未来表与会话超时",
+        "body": "app_user创建permission_probe后,notes_runtime可SELECT,说明库级授权覆盖未来表;验收后由app_user删除。超时是当前连接设置:MAX_EXECUTION_TIME主要用于SELECT,本例SLEEP可用于观察;客户端不同也应重做连接配置。",
+        "code": "-- app_user\nCREATE TABLE permission_probe(id INT PRIMARY KEY) ENGINE=InnoDB;\nINSERT INTO permission_probe VALUES(1);\n-- notes_runtime\nSELECT id FROM permission_probe;\nSET SESSION max_execution_time=1000;\nSELECT SLEEP(2);\nSHOW WARNINGS;\n-- SLEEP可能返回1表示被中断;以实际状态与耗时验收,勿依赖固定异常\n-- app_user验收后\nDROP TABLE permission_probe;",
         "lang": "sql"
       },
       {
-        "title": "验证未来表权限并调整应用配置",
-        "body": "以 app_user 创建临时验收表,再以 notes_runtime SELECT 应成功。验收后 app_user 删除该专用表。把应用 PGUSER 改为 notes_runtime 并注入对应密码,迁移工具继续单独使用 app_user。检查日志只记录操作名、耗时和 SQLSTATE,不打印密码或完整正文。",
-        "code": "-- app_user\nCREATE TABLE permission_probe(id integer PRIMARY KEY);\nINSERT INTO permission_probe VALUES (1);\n-- notes_runtime:应该读到 1\nSELECT id FROM permission_probe;\n-- app_user:验收结束清理本课专用表\nDROP TABLE permission_probe;",
-        "lang": "sql"
+        "title": "为生产连接开启校验而不破坏练习配置",
+        "body": "在mysql_client.connect的pymysql.connect参数中按实际证书增加这些参数,不将占位路径直接用于本机无证书环境。MYSQL_HOST应匹配证书主机名,密钥不写进仓库。应用MYSQL_USER改为notes_runtime;迁移仍使用单独app_user凭据。",
+        "code": "# pymysql.connect(...) 生产TLS参数片段:\n# ssl_ca=os.environ[\"MYSQL_SSL_CA\"],\n# ssl_verify_cert=True,\n# ssl_verify_identity=True,\n# host=os.environ[\"MYSQL_HOST\"],\n\n# 业务连接成功后可查询(不要记录秘密):\n# SHOW SESSION STATUS LIKE 'Ssl_cipher';\n# SELECT CURRENT_USER(), @@session.time_zone, @@transaction_isolation;",
+        "lang": "python"
       }
     ],
     "checks": [
-      "notes_runtime 可以执行业务 CRUD,不能修改表结构。",
-      "新连接显示 3 秒语句超时和 1 秒锁超时。",
-      "app_user 创建的未来表遵循默认权限,密码没有写进仓库和示例日志。"
+      "运行账号CRUD可用但ALTER被拒绝。",
+      "未来表权限符合数据库级授权范围。",
+      "实际连接身份、TLS和UTC/RC设置均可核对。"
     ],
     "pitfalls": [
       {
-        "symptom": "现有表能访问,新迁移建的表报 permission denied",
-        "cause": "只给现有表 GRANT,或默认权限针对了错误创建角色。",
-        "fix": "检查实际迁移角色,给该角色设置 ALTER DEFAULT PRIVILEGES,并补授权已经创建的对象。"
+        "symptom": "本机能登录、容器连接却被拒绝",
+        "cause": "账号host条目和连接来源不同。",
+        "fix": "核对CURRENT_USER、USER及已创建账号来源范围,不通过授予root远程权限绕过。"
       },
       {
-        "symptom": "sslmode=require 就被当成完成服务器身份校验",
-        "cause": "只要求加密不等于完整证书与主机名校验。",
-        "fix": "生产按基础设施证书配置 verify-full 与根证书;本机未配 TLS 的容器使用明确的开发配置。"
+        "symptom": "配置ssl=True就认为已验证服务器身份",
+        "cause": "加密与证书链、主机名验证是不同要求。",
+        "fix": "设置可信CA并启用ssl_verify_cert和ssl_verify_identity,使用证书匹配主机名。"
       }
     ],
     "exercise": {
-      "task": "给只读报表账号设计最小权限,说明为什么不应复用 app_user。",
-      "hint": "需要 CONNECT、schema USAGE 和必要表的 SELECT;不授予写入或对象所有权。",
-      "answer": "创建独立 LOGIN 角色,授予 notes_lab 的 CONNECT、public 的 USAGE、指定业务表的 SELECT。为其设置合适超时与只读事务默认值作为附加限制;最终权限仍由 GRANT 决定。app_user 是对象所有者,权限范围远超报表需求。"
+      "task": "给只读报表建立权限,并说明PROCESS应不应该顺手授予。",
+      "hint": "业务查询所需权限与全实例诊断不同。",
+      "answer": "独立报表账号授予指定库/表SELECT,必要时只授予特定视图;PROCESS和performance_schema广泛诊断权限单独给运维身份。报表连接也设置时限,不能仅靠只读权限防止昂贵查询。"
     },
     "refs": [
       {
-        "label": "PostgreSQL 16:权限",
-        "url": "https://www.postgresql.org/docs/16/ddl-priv.html"
+        "label": "MySQL 8.4:GRANT",
+        "url": "https://dev.mysql.com/doc/refman/8.4/en/grant.html"
       },
       {
-        "label": "PostgreSQL 16:默认权限",
-        "url": "https://www.postgresql.org/docs/16/sql-alterdefaultprivileges.html"
+        "label": "MySQL 8.4:账号名称",
+        "url": "https://dev.mysql.com/doc/refman/8.4/en/account-names.html"
       },
       {
-        "label": "PostgreSQL 16:SSL 客户端验证",
-        "url": "https://www.postgresql.org/docs/16/libpq-ssl.html"
+        "label": "MySQL 8.4:加密连接",
+        "url": "https://dev.mysql.com/doc/refman/8.4/en/using-encrypted-connections.html"
       },
       {
-        "label": "PostgreSQL 16:客户端默认设置",
-        "url": "https://www.postgresql.org/docs/16/runtime-config-client.html"
+        "label": "PyMySQL:TLS参数",
+        "url": "https://pymysql.readthedocs.io/en/latest/modules/connections.html"
       }
     ],
     "production": {
-      "scenario": "API 连接配置泄漏或代码出现错误时,需要限制其能影响的数据库对象。",
+      "scenario": "运行配置泄漏或应用代码出错时,需要限制影响范围。",
       "decisions": [
-        "运行角色与迁移角色使用不同秘密。",
-        "超时配置需重连生效并实测。",
-        "审计日志避免包含原始敏感正文。"
+        "迁移、运行和诊断账号分开。",
+        "host匹配与网络访问共同限制。",
+        "每次连接主动设置会话参数。"
       ],
-      "failureDrill": "在 notes_lab 用运行角色尝试 ALTER TABLE 并执行超时查询,验证拒绝和恢复。",
+      "failureDrill": "用notes_runtime尝试ALTER并验证拒绝,然后核对正常读写不受影响。",
       "acceptance": [
-        "运行角色不是所有者或超级用户。",
-        "权限随未来迁移保持可用。",
-        "生产 TLS、凭据注入和轮换方式有明确配置责任。"
+        "非root运行。",
+        "拒绝错误有明确原因。",
+        "生产TLS验证不被省略。"
       ]
     }
   },
   {
     "id": "12",
-    "title": "备份恢复与可回退的结构变更",
     "group": "Python 与运行保障",
-    "summary": "导出练习库并恢复到另一个数据库,通过数据核对和迁移演练完成生产验收。",
+    "title": "用mysqldump恢复并验收MySQL结构变更",
+    "summary": "恢复到另一个数据库验证数据与自增,再为新增列设计独立的兼容迁移。",
     "objectives": [
-      "用 pg_dump 创建逻辑备份并校验文件",
-      "在独立恢复库验证结构、数据和序列",
-      "使用扩展、回填、验证的步骤变更表结构"
+      "用single-transaction备份InnoDB并恢复到隔离库",
+      "理解mysqldump与binlog时间点恢复的范围",
+      "验证DDL隐式提交和INSTANT操作限制"
     ],
     "concepts": [
       {
-        "title": "备份文件可生成不代表能恢复",
-        "body": "逻辑备份包含数据库对象与数据,恢复需要兼容的工具、角色与目标环境。单库 pg_dump 不包含整个实例的角色定义,也不能独自提供任意时间点恢复;严格 RPO 需要基础备份与 WAL 归档等方案。"
+        "title": "逻辑备份需要匹配写入与DDL条件",
+        "body": "mysqldump --single-transaction为InnoDB建立一致性读取,不保证非事务表一致,也不适合备份同时执行ALTER/DROP等DDL。dump包含SQL,恢复前核对来源;单业务库备份不会自动包含完整账号和授权配置。"
       },
       {
-        "title": "RPO 和 RTO 是验收条件",
-        "body": "RPO 是可接受的数据丢失时间范围,RTO 是恢复服务需要的时间上限。备份频率、保留、异地副本与恢复速度必须与业务目标一致。本课实际测量恢复步骤,而不把本机几秒恢复推断为线上保证。"
+        "title": "时间点恢复需要备份加连续binlog",
+        "body": "每日dump不满足15分钟RPO。需要记录与备份一致的binlog坐标或GTID以及保留完整日志,再在隔离环境恢复到明确停止点。binlog复制和本机留存不等于异地备份;不可在原线上库盲目重放。"
       },
       {
-        "title": "结构变更考虑新旧代码并存",
-        "body": "增加可空列通常比直接删除或改名更容易兼容。先加字段,再部署兼容代码,分批回填并验证,最后收紧约束。回滚代码不意味着可以随意 DROP 已有用户数据的新列,破坏性变更需要单独恢复设计。"
+        "title": "兼容迁移不依赖DDL回滚",
+        "body": "新增可空列可先兼容旧代码,再逐步上线使用。MySQL 8.4对符合条件的加列支持ALGORITHM=INSTANT,明确要求可在不支持时失败;即使INSTANT也需要元数据锁,且DDL隐式提交,ROLLBACK不能撤销。"
       }
     ],
     "steps": [
       {
-        "title": "生成备份与源库验收数据",
-        "body": "以下 Linux Bash 在 notes-service 执行,需要 PostgreSQL 16 客户端 pg_dump、psql、pg_restore;沿用 PGHOST/PGPORT/PGDATABASE,PGUSER 使用 app_user 且注入其密码。备份目录限制权限。命令失败必须停止,不继续上传空文件。",
-        "code": "set -e\nmkdir -p backups\nchmod 700 backups\npg_dump --version\npg_dump -Fc --no-owner --no-acl -f backups/notes_lab.dump\nsha256sum backups/notes_lab.dump > backups/notes_lab.dump.sha256\npg_restore --list backups/notes_lab.dump > backups/notes_lab.contents.txt\npsql -At -v ON_ERROR_STOP=1 -c \"SELECT count(*),min(id),max(id),count(*) FILTER(WHERE pinned) FROM notes\" > backups/source-notes.txt\nsha256sum -c backups/notes_lab.dump.sha256",
-        "lang": "bash"
+        "title": "准备客户端凭据文件",
+        "body": "用编辑器在notes-service创建.mysql-client.cnf,按下面INI结构填写本机练习密码。此处示例占位值必须替换;权限设置为600,目录只供当前用户访问。不要把凭据放进mysqldump命令参数或共享日志。",
+        "code": "[client]\nhost=127.0.0.1\nport=3307\nprotocol=TCP\nuser=app_user\npassword=REPLACE_WITH_LOCAL_APP_PASSWORD\ndefault-character-set=utf8mb4",
+        "lang": "ini",
+        "filename": ".mysql-client.cnf"
       },
       {
-        "title": "创建隔离恢复目标并恢复",
-        "body": "管理连接执行 CREATE DATABASE notes_restore OWNER app_user;,只在本机首次创建该专用恢复库。不要向原 notes_lab 恢复。下面 --no-owner --no-acl 使本课恢复对象由 app_user 持有,权限在恢复后按第 11 课脚本重建;这不是自动恢复所有生产角色。",
-        "code": "# Linux Bash,PGUSER=app_user,使用相应密码\npg_restore --exit-on-error --single-transaction --no-owner --no-acl \\\n  -h 127.0.0.1 -p 5433 -U app_user -d notes_restore backups/notes_lab.dump\npsql -d notes_restore -At -v ON_ERROR_STOP=1 \\\n  -c \"SELECT count(*),min(id),max(id),count(*) FILTER(WHERE pinned) FROM notes\" > backups/restored-notes.txt\ndiff backups/source-notes.txt backups/restored-notes.txt",
+        "title": "用凭据文件生成逻辑备份",
+        "body": "以下Linux Bash需要MySQL8.4客户端。先在受保护.mysql-client.cnf保存client的host=127.0.0.1、port=3307、user=app_user和password;chmod600,不提交。暂停本次练习写入与DDL以便后续行数对照。--set-gtid-purged=OFF使本课单库恢复不改目标GTID集合;生产时间点恢复另行规划坐标。",
+        "code": "mkdir -p backups\nchmod 700 backups\nchmod 600 .mysql-client.cnf\nmysqldump --version\nmysqldump --defaults-extra-file=.mysql-client.cnf \\\n  --single-transaction --quick --no-tablespaces --set-gtid-purged=OFF \\\n  notes_lab --result-file=backups/notes_lab.sql\n# 前一命令成功退出后再继续;shell脚本应开启set -e。\nsha256sum backups/notes_lab.sql > backups/notes_lab.sql.sha256\nmysql --defaults-extra-file=.mysql-client.cnf -N -B notes_lab \\\n  -e 'SELECT COUNT(*),MIN(id),MAX(id),COALESCE(SUM(pinned=1),0) FROM notes' > backups/source-notes.txt\nsha256sum -c backups/notes_lab.sql.sha256",
         "lang": "bash"
       },
       {
-        "title": "核查恢复后的关系与新写入",
-        "body": "使用 app_user 连 notes_restore 执行。反关联查询均应为 0。插入能生成合法新编号,ROLLBACK 避免保留验收笔记;序列可能前进一位,这是正常行为。若源库备份期间持续写入,外部另查的 count 不一定对应 pg_dump 快照;本课在停止练习写入后对照。",
-        "code": "SELECT count(*) FROM note_tags nt LEFT JOIN notes n ON n.id=nt.note_id WHERE n.id IS NULL;\nSELECT count(*) FROM note_tags nt LEFT JOIN tags t ON t.id=nt.tag_id WHERE t.id IS NULL;\nBEGIN;\nINSERT INTO notes(title) VALUES ('db12-restore-probe') RETURNING id,title;\nROLLBACK;\n\\d notes\n\\d note_tags",
+        "title": "创建独立恢复库并授予恢复身份",
+        "body": "仅本机root管理连接执行。不要使用--databases生成固定USE notes_lab后再误以为命令参数能改目标;本课dump只带表SQL,显式导入notes_restore。notes_restore只能作为验收库,不能覆盖notes_lab。",
+        "code": "CREATE DATABASE notes_restore CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;\nGRANT ALL PRIVILEGES ON notes_restore.* TO 'app_user'@'%';\n-- 只针对本机隔离恢复库,恢复完成后按运行账号权限方案重新授权。",
         "lang": "sql"
       },
       {
-        "title": "在练习源库做一次兼容扩展迁移",
-        "body": "重新连接 notes_lab / app_user。创建 archived_at 可空列表示未归档;保存为 sql/003_archived.sql,使用 psql -v ON_ERROR_STOP=1 -f sql/003_archived.sql 执行一次。短 lock_timeout 保证拿不到结构锁时尽快失败,再由人检查阻塞后择机重试。它不能保证 DDL 永远无阻塞。",
-        "code": "BEGIN;\nSET LOCAL lock_timeout='1s';\nSET LOCAL statement_timeout='5s';\nALTER TABLE notes ADD COLUMN archived_at timestamptz;\nCOMMIT;",
-        "lang": "sql",
-        "filename": "sql/003_archived.sql"
+        "title": "恢复后比较行数、关系与自增",
+        "body": "Bash导入前使用SELECT DATABASE确认目标;mysql --force会跳过错误继续执行,本课不使用。下面数量一致只适用于已暂停写入的练习源库,不能拿备份后持续变化的数据简单判错。",
+        "code": "mysql --defaults-extra-file=.mysql-client.cnf notes_restore -e 'SELECT DATABASE();'\nmysql --defaults-extra-file=.mysql-client.cnf notes_restore < backups/notes_lab.sql\nmysql --defaults-extra-file=.mysql-client.cnf -N -B notes_restore \\\n  -e 'SELECT COUNT(*),MIN(id),MAX(id),COALESCE(SUM(pinned=1),0) FROM notes' > backups/restored-notes.txt\ndiff backups/source-notes.txt backups/restored-notes.txt\nmysql --defaults-extra-file=.mysql-client.cnf notes_restore \\\n  -e 'SELECT COUNT(*) FROM note_tags nt LEFT JOIN notes n ON n.id=nt.note_id WHERE n.id IS NULL; SHOW CREATE TABLE notes;'",
+        "lang": "bash"
+      },
+      {
+        "title": "验证恢复库新写入与源库兼容迁移",
+        "body": "先在notes_restore执行前一组DML,LAST_INSERT_ID生成合法编号后ROLLBACK,不保留标记。再明确USE notes_lab执行独立DDL;不要包BEGIN期待可回滚。ALGORITHM=INSTANT失败时先评估支持限制,不能静默退回可能重建整表的操作。",
+        "code": "USE notes_restore;\nSET SESSION time_zone='+00:00';\nSTART TRANSACTION;\nINSERT INTO notes(title) VALUES('db12-restore-probe');\nSELECT LAST_INSERT_ID();\nROLLBACK;\n\nUSE notes_lab;\nSELECT DATABASE();\nSET SESSION lock_wait_timeout=3;\nALTER TABLE notes ADD COLUMN archived_at DATETIME(6) NULL, ALGORITHM=INSTANT;\nSELECT id,title,pinned FROM notes ORDER BY id DESC LIMIT 3;\nSELECT id,title FROM notes WHERE archived_at IS NULL ORDER BY id DESC LIMIT 3;\n-- 旧查询和新查询都可工作;DDL不能用ROLLBACK撤销。",
+        "lang": "sql"
       },
       {
-        "title": "验证新旧访问兼容并记录恢复方案",
-        "body": "旧查询只选择明确列名,不受新列影响。新查询把 archived_at IS NULL 当作活动笔记。故障演练是在恢复库模拟错误更新再从备份另建恢复目标,不覆盖源库。本课不实际给出删除全表语句;验收恢复耗时、权限重建与业务请求都必须列入恢复记录。",
-        "code": "SELECT id,title,pinned FROM notes ORDER BY id DESC LIMIT 3;\nSELECT id,title FROM notes WHERE archived_at IS NULL ORDER BY id DESC LIMIT 3;\nSELECT column_name,is_nullable,data_type\nFROM information_schema.columns\nWHERE table_name='notes' AND column_name='archived_at';\n-- is_nullable=YES,data_type=timestamp with time zone",
+        "title": "记录binlog恢复演练所需证据",
+        "body": "本课逻辑恢复已可执行,时间点恢复部分按真实环境补录,不能冒填文件名与GTID。管理窗口只读查看变量;不要将mysqlbinlog输出直接管道写入现有业务库。生产选择持久日志归档方案并在隔离实例恢复到误操作之前,验收角色、时区、索引和业务请求。",
+        "code": "SHOW VARIABLES LIKE 'log_bin';\nSHOW VARIABLES LIKE 'binlog_format';\nSHOW VARIABLES LIKE 'gtid_mode';\nSHOW VARIABLES LIKE 'binlog_expire_logs_seconds';\nSHOW BINARY LOG STATUS;\n-- 保存:备份时间/一致快照对应日志坐标或GTID/日志保留与异地副本\n-- 演练:隔离恢复实例、停止点、实际恢复耗时、允许丢失窗口、业务验收结果",
         "lang": "sql"
       }
     ],
     "checks": [
-      "备份校验和正确,pg_restore 清单包含业务表、约束和数据。",
-      "独立恢复库行数与静止源库一致,外键无孤儿,新插入成功。",
-      "新增字段后旧查询与新查询都可运行,迁移失败有停止和重试策略。"
+      "dump退出成功、校验和正确,独立恢复库数量与静止源库一致。",
+      "外键无孤儿,恢复库新插入自增正常且能回滚DML。",
+      "新增列前后旧查询兼容,明确DDL不能由ROLLBACK撤销。"
     ],
     "pitfalls": [
       {
-        "symptom": "备份命令成功,但恢复时缺角色或权限",
-        "cause": "把单库逻辑备份当成完整实例备份,未规划角色与授权。",
-        "fix": "在恢复流程中显式重建角色和授权,或由受控配置管理维护;本课使用 no-owner/no-acl 后重新授权。"
+        "symptom": "恢复导入竟修改原库",
+        "cause": "dump包含固定CREATE DATABASE/USE,或连接目标未核对。",
+        "fix": "本课不加--databases,导入前核对SQL内容与SELECT DATABASE;生产先隔离恢复。"
       },
       {
-        "symptom": "上线加字段一直等待,API 也开始变慢",
-        "cause": "DDL 正在等待强锁,可能与长事务形成阻塞队列。",
-        "fix": "用短 lock_timeout 退出,查等待链并缩短长事务;在明确窗口重试,不无限等锁。"
+        "symptom": "每日dump被认为能恢复到任意一分钟",
+        "cause": "没有完整binlog和一致起点。",
+        "fix": "规划备份坐标/GTID、日志保留与归档,并实际演练恢复停止点。"
       },
       {
-        "symptom": "恢复行数不等于备份后源库行数",
-        "cause": "源库继续写入,两个统计不属于同一快照。",
-        "fix": "练习对比时暂停写入;生产验收使用与备份快照一致的方法或业务不变量,不把后续变化误判为备份损坏。"
+        "symptom": "INSTANT加列仍等待或不支持",
+        "cause": "元数据锁冲突或表/操作条件不满足。",
+        "fix": "短lock_wait_timeout失败后查阻塞,确认版本和限制,不盲目降级成大表复制。"
       }
     ],
     "exercise": {
-      "task": "为“每日备份、允许最多丢失 15 分钟数据”的需求判断现有方案是否合格,并写出补充项。",
-      "hint": "先将备份间隔与 RPO 对照,再考虑恢复演练。",
-      "answer": "单独每日 pg_dump 无法满足 15 分钟 RPO。需要能够持续归档的 WAL 与基础备份或受管数据库时间点恢复,并验证归档延迟、保留期、异地副本、恢复步骤和监控;按目标时间点实际演练并测量 RTO。"
+      "task": "业务最多丢15分钟数据,只有每日mysqldump是否足够?还需验收什么?",
+      "hint": "把备份间隔、binlog归档延迟、恢复速度分别对齐RPO/RTO。",
+      "answer": "不够。需要与一致性备份关联的连续binlog或受管时间点恢复,监控归档延迟与保留期,保护异地副本;实际恢复到指定停止点并测量RTO,同时重建权限和验收业务读写。"
     },
     "refs": [
       {
-        "label": "PostgreSQL 16:pg_dump",
-        "url": "https://www.postgresql.org/docs/16/app-pgdump.html"
+        "label": "MySQL 8.4:mysqldump",
+        "url": "https://dev.mysql.com/doc/refman/8.4/en/mysqldump.html"
       },
       {
-        "label": "PostgreSQL 16:pg_restore",
-        "url": "https://www.postgresql.org/docs/16/app-pgrestore.html"
+        "label": "MySQL 8.4:时间点恢复",
+        "url": "https://dev.mysql.com/doc/refman/8.4/en/point-in-time-recovery.html"
       },
       {
-        "label": "PostgreSQL 16:持续归档与时间点恢复",
-        "url": "https://www.postgresql.org/docs/16/continuous-archiving.html"
+        "label": "MySQL 8.4:在线DDL操作",
+        "url": "https://dev.mysql.com/doc/refman/8.4/en/innodb-online-ddl-operations.html"
       },
       {
-        "label": "PostgreSQL 16:ALTER TABLE",
-        "url": "https://www.postgresql.org/docs/16/sql-altertable.html"
+        "label": "MySQL 8.4:隐式提交",
+        "url": "https://dev.mysql.com/doc/refman/8.4/en/implicit-commit.html"
       }
     ],
     "production": {
-      "scenario": "发布结构变更之前,需要证明数据库可恢复,旧版本服务仍能短暂并存。",
+      "scenario": "结构发布前需要证明可恢复,且新旧应用能在过渡期并存。",
       "decisions": [
-        "恢复目标与源库严格分开。",
-        "备份验收覆盖数据、约束、权限与新写入。",
-        "新增字段先兼容,再部署和回填;迁移文件纳入版本管理。"
+        "恢复库与源库隔离。",
+        "备份验证覆盖数据、外键、自增与权限。",
+        "DDL单独执行,明确兼容与恢复方案。"
       ],
-      "failureDrill": "在独立 notes_restore 检查备份恢复,然后在 notes_lab 用短锁超时演练新增可空字段;所有操作保持目标库可辨认。",
+      "failureDrill": "先逻辑恢复到notes_restore,再在notes_lab新增可空列;时间点恢复需真实日志与明确停止点后单独执行。",
       "acceptance": [
-        "记录实际恢复耗时与备份时间。",
-        "恢复库能完成业务 CRUD。",
-        "迁移前后访问兼容,生产 RPO/RTO 不由本机测试推定。"
+        "有实际恢复证据后才标通过。",
+        "未把本机耗时推定为线上SLA。",
+        "不以删库或删卷作为常规回退。"
       ]
     }
   }

+ 79 - 74
public/content/docker.json

@@ -132,7 +132,7 @@
       {
         "title": "建立目标平台锁定依赖",
         "body": "在 notes-service 新建 requirements.in,采用下列主依赖。已有业务的额外依赖应一起加入。用目标 Linux/Python3.11 的构建环境生成 requirements-lock.txt;不要把 Windows 专有库锁直接用于 Linux。下一课给出容器内生成命令。",
-        "code": "fastapi>=0.121,<1\nuvicorn[standard]>=0.30,<1\npydantic-settings>=2,<3\nSQLAlchemy>=2.0,<2.1\npsycopg[binary]>=3,<4\nalembic>=1.13,<2\nhttpx>=0.27,<1\ncelery[redis]>=5.4,<6",
+        "code": "fastapi>=0.121,<1\nuvicorn[standard]>=0.30,<1\npydantic-settings>=2,<3\nSQLAlchemy>=2.0,<2.1\nasyncmy>=0.2,<0.3\nPyMySQL[rsa]>=1.1,<2\nalembic>=1.13,<2\nhttpx>=0.27,<1\ncelery[redis]>=5.4,<6",
         "lang": "text",
         "filename": "requirements.in"
       },
@@ -158,8 +158,8 @@
       },
       {
         "title": "通过练习网络配置启动服务",
-        "body": "本课暂不启动完整服务,先验证代码和依赖存在;第 4 课 Compose 会提供 postgres DNS 与 DATABASE_URL。直接运行镜像若缺 DATABASE_URL 应明确失败,这比默认连错库更容易排错。",
-        "code": "docker run --rm notes-service:lab-01 python -c \"import fastapi, sqlalchemy, psycopg; print(fastapi.__version__, sqlalchemy.__version__, psycopg.__version__)\"\ndocker run --rm notes-service:lab-01 python -c \"from pathlib import Path; print(Path('/app/alembic.ini').exists(), Path('/app/migrations/env.py').exists())\"",
+        "body": "本课暂不启动完整服务,先验证代码和依赖存在;第 4 课 Compose 会提供 mysql DNS 与 DATABASE_URL。直接运行镜像若缺 DATABASE_URL 应明确失败,这比默认连错库更容易排错。",
+        "code": "docker run --rm notes-service:lab-01 python -c \"import fastapi, sqlalchemy, asyncmy, pymysql; print(fastapi.__version__, sqlalchemy.__version__); print('mysql drivers import ok')\"\ndocker run --rm notes-service:lab-01 python -c \"from pathlib import Path; print(Path('/app/alembic.ini').exists(), Path('/app/migrations/env.py').exists())\"",
         "lang": "bash"
       }
     ],
@@ -251,7 +251,7 @@
       },
       {
         "title": "替换 Dockerfile 为多阶段版本",
-        "body": "用本段完整替换第 2 课 Dockerfile。PYTHON_IMAGE 的默认标签供练习,发布构建必须通过 --build-arg 传已记录的 digest;builder 与 runtime 共用同一值。psycopg[binary] 避免本课额外引入编译工具。",
+        "body": "用本段完整替换第 2 课 Dockerfile。PYTHON_IMAGE 的默认标签供练习,发布构建必须通过 --build-arg 传已记录的 digest;builder 与 runtime 共用同一值。asyncmy需要匹配目标Python和架构的wheel;若目标平台没有wheel,在builder层安装编译所需工具,重新锁定并验证,运行层保留必要运行库。PyMySQL[rsa]支持MySQL 8.4默认的caching_sha2_password认证,不启用旧认证插件。",
         "code": "ARG PYTHON_IMAGE=python:3.11-slim\nFROM ${PYTHON_IMAGE} AS builder\nENV PIP_DISABLE_PIP_VERSION_CHECK=1\nWORKDIR /build\nRUN python -m venv /opt/venv\nCOPY requirements-lock.txt ./\nRUN /opt/venv/bin/python -m pip install --no-cache-dir --require-hashes -r requirements-lock.txt\n\nFROM ${PYTHON_IMAGE} AS runtime\nENV PATH=\"/opt/venv/bin:$PATH\" PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1\nRUN groupadd --gid 10001 notes     && useradd --uid 10001 --gid notes --no-create-home --shell /usr/sbin/nologin notes\nWORKDIR /app\nCOPY --from=builder /opt/venv /opt/venv\nCOPY app ./app\nCOPY alembic.ini ./\nCOPY migrations ./migrations\nUSER 10001:10001\nEXPOSE 8000\nCMD [\"python\", \"-m\", \"uvicorn\", \"app.main:app\", \"--host\", \"0.0.0.0\", \"--port\", \"8000\", \"--timeout-graceful-shutdown\", \"25\"]",
         "lang": "dockerfile",
         "filename": "Dockerfile"
@@ -326,17 +326,17 @@
   {
     "id": "04",
     "title": "Compose 网络、服务 DNS 与端口",
-    "summary": "把 API、PostgreSQL 和 Redis 放入同一专用网络,区分容器内地址和宿主调试端口。",
+    "summary": "把 API、MySQL 和 Redis 放入同一专用网络,区分容器内地址和宿主调试端口。",
     "group": "运行与资源",
     "objectives": [
-      "通过服务名连接 PostgreSQL 与 Redis",
+      "通过服务名连接 MySQL 与 Redis",
       "绑定本机调试端口并避免冲突",
       "用 Compose 配置检查和容器内探针定位连通性"
     ],
     "concepts": [
       {
         "title": "localhost 指当前容器",
-        "body": "API 中 127.0.0.1 指向 API 自己,不能到 PostgreSQL。Compose 默认网络提供 postgres 和 redis 服务名解析;容器间连接使用 postgres:5432 与 redis:6379,宿主调试使用 127.0.0.1:5433 与 127.0.0.1:6380。"
+        "body": "API 中 127.0.0.1 指向 API 自己,不能到 MySQL。Compose 默认网络提供 mysql 和 redis 服务名解析;容器间连接使用 mysql:3306 与 redis:6379,宿主调试使用 127.0.0.1:3307 与 127.0.0.1:6380。"
       },
       {
         "title": "端口发布的方向",
@@ -344,54 +344,54 @@
       },
       {
         "title": "项目隔离",
-        "body": "Compose project name 区分同机多个练习栈的网络与卷;不要设置固定 container_name,它会阻碍扩容并造成跨项目名字冲突。示例数据库为 notes_lab,app_user 是普通应用角色,postgres 管理账号不进入 API。"
+        "body": "Compose project name 区分同机多个练习栈的网络与卷;不要设置固定 container_name,它会阻碍扩容并造成跨项目名字冲突。示例数据库为 notes_lab,app_user 是普通应用角色,mysql 管理账号不进入 API。"
       }
     ],
     "steps": [
       {
         "title": "新建 Compose 专用环境文件",
         "body": "复制下列模板为 .env.compose 并填写两个不同的本机练习密码。文件不提交,后续统一命令显式 --env-file .env.compose;它用于 Compose 插值,不覆盖先前本机 Python 使用的 .env。连接串密码有特殊字符需 URL 编码。",
-        "code": "COMPOSE_PROJECT_NAME=notes-lab\nDB_ADMIN_PASSWORD=CHANGE_ADMIN_PASSWORD\nAPP_DB_PASSWORD=CHANGE_APP_PASSWORD\nDATABASE_URL=postgresql+psycopg://app_user:CHANGE_APP_PASSWORD@postgres:5432/notes_lab\nREDIS_URL=redis://redis:6379/0\nNOTES_IMAGE=notes-service:lab-02",
+        "code": "COMPOSE_PROJECT_NAME=notes-lab\nDB_ADMIN_PASSWORD=CHANGE_ADMIN_PASSWORD\nAPP_DB_PASSWORD=CHANGE_APP_PASSWORD\nDATABASE_URL=mysql+asyncmy://app_user:CHANGE_APP_PASSWORD@mysql:3306/notes_lab?charset=utf8mb4\nREDIS_URL=redis://redis:6379/0\nNOTES_IMAGE=notes-service:lab-02",
         "lang": "dotenv",
         "filename": ".env.compose"
       },
       {
-        "title": "新建初始化脚本",
-        "body": "新建 deploy/init/01-app.sh,保存 LF 换行。postgres 官方镜像在空数据目录首次初始化时执行脚本;后续改环境变量不会自动修改角色密码。psql 变量替换用 SQL 字符串引用处理密码,禁止直接字符串拼 SQL。练习 app_user 拥有 public schema 的 DDL 权限,生产迁移权限下一阶段单独拆分。",
-        "code": "#!/bin/sh\nset -eu\npsql -v ON_ERROR_STOP=1 --username \"$POSTGRES_USER\" --dbname \"$POSTGRES_DB\"   --set=app_password=\"$APP_DB_PASSWORD\" <<'SQL'\nCREATE ROLE app_user LOGIN PASSWORD :'app_password';\nGRANT CONNECT ON DATABASE notes_lab TO app_user;\nGRANT USAGE, CREATE ON SCHEMA public TO app_user;\nSQL",
-        "lang": "bash",
-        "filename": "deploy/init/01-app.sh"
+        "title": "用官方镜像完成首次初始化",
+        "body": "MySQL官方镜像仅在空数据目录初始化MYSQL_DATABASE、MYSQL_USER和MYSQL_PASSWORD。app_user取得notes_lab库内权限,API不使用root。后续修改环境文件不会修改已存在账户的密码;已有卷应通过审核后的ALTER USER修改凭据。这里不再编写自定义建用户脚本,镜像负责转义初始化输入。",
+        "code": "MYSQL_ROOT_PASSWORD → 独立管理员密码\nMYSQL_DATABASE=notes_lab → 仅首次创建练习库\nMYSQL_USER=app_user / MYSQL_PASSWORD → 仅该库的练习账号\n持久卷 mysql_data → /var/lib/mysql\n服务名 mysql:3306 → 容器访问\n127.0.0.1:3307 → 宿主访问\nInnoDB / utf8mb4 / UTC → 全模块约定",
+        "lang": "text",
+        "filename": "初始化约定"
       },
       {
         "title": "新建 compose.yaml",
-        "body": "本段创建 API、Postgres、Redis 与独立卷;Redis 此时只是连通性练习,不承诺任务持久化。宿主数据库端口与数据库模块一致。API 初次启动可能尚未建表,第 6 课加入迁移和健康依赖。",
-        "code": "services:\n  api:\n    image: ${NOTES_IMAGE:?set NOTES_IMAGE}\n    environment:\n      DATABASE_URL: ${DATABASE_URL:?set DATABASE_URL}\n      REDIS_URL: ${REDIS_URL:?set REDIS_URL}\n    ports: [\"127.0.0.1:8000:8000\"]\n  postgres:\n    image: postgres:16\n    environment:\n      POSTGRES_DB: notes_lab\n      POSTGRES_USER: postgres\n      POSTGRES_PASSWORD: ${DB_ADMIN_PASSWORD:?set admin password}\n      APP_DB_PASSWORD: ${APP_DB_PASSWORD:?set app password}\n    ports: [\"127.0.0.1:5433:5432\"]\n    volumes:\n      - pg_data:/var/lib/postgresql/data\n      - ./deploy/init:/docker-entrypoint-initdb.d:ro\n  redis:\n    image: redis:7.4\n    ports: [\"127.0.0.1:6380:6379\"]\nvolumes:\n  pg_data:",
+        "body": "本段创建 API、MySQL、Redis 与独立卷;Redis 此时只是连通性练习,不承诺任务持久化。宿主数据库端口与数据库模块一致。API 初次启动可能尚未建表,第 6 课加入迁移和健康依赖。",
+        "code": "services:\n  api:\n    image: ${NOTES_IMAGE:?set NOTES_IMAGE}\n    environment:\n      DATABASE_URL: ${DATABASE_URL:?set DATABASE_URL}\n      REDIS_URL: ${REDIS_URL:?set REDIS_URL}\n    ports: [\"127.0.0.1:8000:8000\"]\n  mysql:\n    image: mysql:8.4\n    command: [\"--character-set-server=utf8mb4\", \"--collation-server=utf8mb4_0900_ai_ci\", \"--default-time-zone=+00:00\"]\n    environment:\n      MYSQL_ROOT_PASSWORD: ${DB_ADMIN_PASSWORD:?set admin password}\n      MYSQL_DATABASE: notes_lab\n      MYSQL_USER: app_user\n      MYSQL_PASSWORD: ${APP_DB_PASSWORD:?set app password}\n    ports: [\"127.0.0.1:3307:3306\"]\n    volumes: [\"mysql_data:/var/lib/mysql\"]\n  redis:\n    image: redis:7.4\n    ports: [\"127.0.0.1:6380:6379\"]\nvolumes:\n  mysql_data: {}",
         "lang": "yaml",
         "filename": "compose.yaml"
       },
       {
         "title": "检查配置并启动依赖",
-        "body": "如果上一模块已经占用 5433 或 6380,先确认属于哪个独立练习栈,选择复用同一栈或停止其对应服务,不能直接杀掉未知进程。config --quiet 检查语法,不把展开后的密码配置贴进聊天或日志。",
-        "code": "docker compose --env-file .env.compose config --quiet\ndocker compose --env-file .env.compose up -d postgres redis\ndocker compose --env-file .env.compose ps\ndocker compose --env-file .env.compose logs --tail 50 postgres",
+        "body": "如果上一模块已经占用 3307 或 6380,先确认属于哪个独立练习栈,选择复用同一栈或停止其对应服务,不能直接杀掉未知进程。config --quiet 检查语法,不把展开后的密码配置贴进聊天或日志。",
+        "code": "docker compose --env-file .env.compose config --quiet\ndocker compose --env-file .env.compose up -d mysql redis\ndocker compose --env-file .env.compose ps\ndocker compose --env-file .env.compose logs --tail 50 mysql",
         "lang": "bash"
       },
       {
         "title": "从 API 网络位置检测服务 DNS",
-        "body": "run --rm 临时容器共享项目网络,使用 Python socket 验证 DNS 和 TCP 端口。成功只说明网络可达,不能证明账号权限或表迁移完成。",
-        "code": "docker compose --env-file .env.compose run --rm --no-deps api python -c \"import socket; print(socket.gethostbyname('postgres')); socket.create_connection(('postgres',5432),3).close(); socket.create_connection(('redis',6379),3).close(); print('tcp ok')\"\ndocker compose --env-file .env.compose exec postgres psql -U postgres -d notes_lab -c \"SELECT rolname, rolsuper FROM pg_roles WHERE rolname='app_user';\"",
+        "body": "run --rm 临时容器共享项目网络,使用 Python socket 验证 DNS 和 TCP 端口。成功只说明网络可达,不能证明账号权限或表迁移完成。 第二条命令交互输入app_user密码,并验证认证及库权限;网络探针成功不代替这一步。",
+        "code": "docker compose --env-file .env.compose run --rm --no-deps api python -c \"import socket; print(socket.gethostbyname('mysql')); socket.create_connection(('mysql',3306),3).close(); socket.create_connection(('redis',6379),3).close(); print('tcp ok')\"\ndocker compose --env-file .env.compose exec mysql mysql --protocol=TCP -h127.0.0.1 -uapp_user -p notes_lab -e \"SELECT VERSION(),DATABASE(),CURRENT_USER(); SHOW GRANTS;\"",
         "lang": "bash"
       }
     ],
     "checks": [
-      "API 容器可解析 postgres/redis,并连接内部 5432/6379。",
-      "宿主端口仅绑定 127.0.0.1 的 8000、5433、6380。",
+      "API 容器可解析 mysql/redis,并连接内部 3306/6379。",
+      "宿主端口仅绑定 127.0.0.1 的 8000、3307、6380。",
       "app_user 的 rolsuper 为 false;管理账号密码未注入 API。"
     ],
     "pitfalls": [
       {
-        "symptom": "API 连接 127.0.0.1:5433 被拒绝",
+        "symptom": "API 连接 127.0.0.1:3307 被拒绝",
         "cause": "把宿主访问地址用于容器内部。",
-        "fix": "容器 DATABASE_URL 使用 postgres:5432;宿主 psql 才使用本机 5433。"
+        "fix": "容器DATABASE_URL使用mysql:3306;宿主mysql客户端使用127.0.0.1:3307。"
       },
       {
         "symptom": "修改 APP_DB_PASSWORD 后认证仍失败",
@@ -400,9 +400,9 @@
       }
     ],
     "exercise": {
-      "task": "为了让 API 访问数据库,是否必须发布 5433?",
+      "task": "为了让 API 访问数据库,是否必须发布 3307?",
       "hint": "Compose 服务互通走内部网络。",
-      "answer": "不需要。删去 postgres 的 ports 后,API 仍能使用 postgres:5432;宿主 psql 失去直接端口入口,可通过 docker compose exec 调试。线上通常不发布数据库宿主端口。"
+      "answer": "不需要。删去 mysql 的 ports 后,API 仍能使用 mysql:3306;宿主 mysql客户端 失去直接端口入口,可通过 docker compose exec 调试。线上通常不发布数据库宿主端口。"
     },
     "refs": [
       {
@@ -414,8 +414,8 @@
         "url": "https://docs.docker.com/engine/network/port-publishing/"
       },
       {
-        "label": "Postgres 官方镜像说明",
-        "url": "https://hub.docker.com/_/postgres"
+        "label": "MySQL 官方容器镜像",
+        "url": "https://github.com/docker-library/docs/blob/master/mysql/README.md"
       }
     ],
     "production": {
@@ -433,94 +433,98 @@
   },
   {
     "id": "05",
-    "title": "PostgreSQL 持久卷与可恢复备份",
+    "title": "MySQL 持久卷与可恢复备份",
     "summary": "验证重建数据库容器后数据仍存在,在独立恢复容器中验证逻辑备份。",
     "group": "运行与资源",
     "objectives": [
       "区分 named volume 与 bind mount 的权限责任",
-      "验证容器重建不丢 PostgreSQL 数据",
+      "验证容器重建不丢 MySQL 数据",
       "制作并恢复逻辑备份,记录恢复证据"
     ],
     "concepts": [
       {
         "title": "卷保存数据库目录",
-        "body": "PostgreSQL 16 官方镜像的本课持久路径为 /var/lib/postgresql/data。命名卷由 Docker 管理宿主位置,容器删除不会自动删除命名卷。不要把卷当作备份,宿主磁盘故障和误删仍会影响它。"
+        "body": "MySQL 8.4 官方镜像的本课持久路径为 /var/lib/mysql。命名卷由 Docker 管理宿主位置,容器删除不会自动删除命名卷。不要把卷当作备份,宿主磁盘故障和误删仍会影响它。"
       },
       {
         "title": "权限与一致性",
-        "body": "宿主 bind mount 需要匹配数据库用户 UID/GID 与权限,直接 chmod 777 不是解决方案。运行中的 PGDATA 不能随手复制成可靠备份;用 pg_dump 做逻辑备份,物理备份需 pg_basebackup/WAL 等一致性协议。"
+        "body": "命名卷保存/var/lib/mysql,但卷本身不是备份。运行中的数据目录不能随手复制:逻辑备份用一致性mysqldump;物理备份使用支持该MySQL版本的一致性备份工具,并验证恢复与binlog位置。"
       },
       {
         "title": "恢复才验证备份",
         "body": "备份成功退出只能证明生成了文件,不能证明恢复后应用能使用。恢复演练要用另一容器和另一卷,核对表、行数和业务查询;备份保留策略还需异机、加密、访问控制与实际 RPO/RTO。"
+      },
+      {
+        "title": "逻辑备份与binlog恢复分工",
+        "body": "SQL转储恢复到快照时刻。更小RPO还需要连续保存binlog、匹配备份位置或GTID,并在独立实例验证时间点恢复;单独每日mysqldump不能满足分钟级数据损失目标。"
       }
     ],
     "steps": [
       {
         "title": "确认卷与数据路径",
         "body": "只读检查 Compose 解析到的数据库挂载。取得容器 id 后可 inspect Mounts;不进入 Docker 内部卷目录手工改权限或编辑文件。",
-        "code": "docker compose --env-file .env.compose ps -q postgres\ndocker compose --env-file .env.compose exec postgres psql -U postgres -d notes_lab -c \"SHOW data_directory;\"\ndocker volume ls --filter label=com.docker.compose.project=notes-lab",
+        "code": "docker compose --env-file .env.compose ps -q mysql\ndocker compose --env-file .env.compose exec mysql mysql -uroot -p -e \"SELECT @@datadir,@@version,@@default_storage_engine;\"\ndocker volume ls --filter label=com.docker.compose.project=notes-lab",
         "lang": "bash"
       },
       {
         "title": "创建专用持久化标记并重建容器",
-        "body": "在练习库创建一张明确命名的验证表,重建仅 postgres 容器,保留 pg_data 卷。执行后查询同一行。正常生产更新仍要评估可用性与数据库版本兼容。 重建后先执行 pg_isready,返回 accepting connections 后再查询标记;尚未就绪时等待后重试,不把首次连接失败当作卷丢失。",
-        "code": "docker compose --env-file .env.compose exec postgres psql -U postgres -d notes_lab -c \"CREATE TABLE IF NOT EXISTS volume_probe (id integer PRIMARY KEY, value text NOT NULL); INSERT INTO volume_probe VALUES (1,'survives recreate') ON CONFLICT (id) DO UPDATE SET value=EXCLUDED.value;\"\ndocker compose --env-file .env.compose up -d --force-recreate postgres\ndocker compose --env-file .env.compose exec postgres pg_isready -U postgres -d notes_lab\n# 上一步返回 accepting connections 后继续;否则等待后重试。\ndocker compose --env-file .env.compose exec postgres psql -U postgres -d notes_lab -c \"SELECT * FROM volume_probe;\"",
+        "body": "只在练习库创建volume_probe(InnoDB),重建mysql容器并保留mysql_data。重建后再次用app_user执行SELECT 1,连接和认证都成功后才查询标记;初始化期间连接失败不能证明卷丢失。下面每次-p均交互输入练习密码。",
+        "code": "docker compose --env-file .env.compose exec mysql mysql -uapp_user -p notes_lab -e \"CREATE TABLE IF NOT EXISTS volume_probe (id INT PRIMARY KEY,value VARCHAR(100) NOT NULL) ENGINE=InnoDB; INSERT INTO volume_probe VALUES (1,'survives recreate') ON DUPLICATE KEY UPDATE value='survives recreate';\"\ndocker compose --env-file .env.compose up -d --force-recreate mysql\ndocker compose --env-file .env.compose exec mysql mysql --protocol=TCP -h127.0.0.1 -uapp_user -p notes_lab -e \"SELECT 1;\"\n# 连接认证成功后继续;否则等待后重试。\ndocker compose --env-file .env.compose exec mysql mysql -uapp_user -p notes_lab -e \"SELECT * FROM volume_probe;\"",
         "lang": "bash"
       },
       {
         "title": "生成备份并从容器复制",
-        "body": "先在容器内生成自定义格式备份,再 docker compose cp,避免旧版 Windows PowerShell 重定向二进制流时损坏文件。备份含业务数据,放到已忽略的 backups 目录;不上传到公开资源。",
-        "code": "mkdir backups\ndocker compose --env-file .env.compose exec postgres pg_dump -U postgres -d notes_lab -Fc --no-owner --no-acl -f /tmp/notes_lab.dump\ndocker compose --env-file .env.compose cp postgres:/tmp/notes_lab.dump ./backups/notes_lab.dump\ndocker compose --env-file .env.compose exec postgres pg_restore --list /tmp/notes_lab.dump",
+        "body": "使用同一8.4镜像中的mysqldump,直接写容器内SQL文件后复制,避免Windows重定向改变编码。--single-transaction对InnoDB取得一致性快照,备份期间禁止并发DDL;它不保证非事务表一致性。--no-tablespaces减少所需权限,--set-gtid-purged=OFF适用于本课独立逻辑恢复,不是构建复制节点的流程。包含触发器、存储过程和事件,备份账号需相应权限,本隔离练习使用root。",
+        "code": "mkdir backups\ndocker compose --env-file .env.compose exec mysql mysqldump -uroot -p --single-transaction --routines --events --triggers --no-tablespaces --set-gtid-purged=OFF --hex-blob --default-character-set=utf8mb4 --result-file=/tmp/notes_lab.sql notes_lab\ndocker compose --env-file .env.compose cp mysql:/tmp/notes_lab.sql ./backups/notes_lab.sql\n# 只有mysqldump退出0才使用该文件;文件存在、大小正常不代表恢复已验证。",
         "lang": "bash"
       },
       {
         "title": "创建独立恢复容器",
-        "body": "新建 .env.restore,只含 POSTGRES_PASSWORD=独立练习密码,加入 .gitignore。恢复容器不发布端口,使用单独 named volume;docker exec 从同容器 Unix socket 操作。等待日志 ready 后再恢复,不复用业务 pg_data。",
-        "code": "docker volume create notes-restore-data\ndocker run -d --name notes-restore --env-file .env.restore -e POSTGRES_DB=notes_restore -v notes-restore-data:/var/lib/postgresql/data postgres:16\ndocker logs --tail 30 notes-restore\ndocker cp ./backups/notes_lab.dump notes-restore:/tmp/notes_lab.dump\ndocker exec notes-restore pg_restore -U postgres -d notes_restore --no-owner --no-acl --exit-on-error /tmp/notes_lab.dump",
+        "body": "新建已忽略的.env.restore,只含MYSQL_ROOT_PASSWORD=独立练习密码。恢复容器使用新的命名卷,不发布端口;mysql -p交互输入恢复密码。等待日志报告ready并能执行SELECT 1,再导入到notes_restore。备份未使用--databases,因此不会自动切回源库名。重复练习时使用新的恢复库与卷,不叠加到上次非空库。 导入使用mysql批处理标准输入,不使用--force;出错即停止。仅在此隔离容器中从已有MYSQL_ROOT_PASSWORD环境变量临时提供认证,不把密码展开到命令参数;生产备份恢复用受控客户端选项文件。",
+        "code": "docker volume create notes-mysql-restore-data\ndocker run -d --name notes-mysql-restore --env-file .env.restore -e MYSQL_DATABASE=notes_restore -v notes-mysql-restore-data:/var/lib/mysql mysql:8.4 --default-time-zone=+00:00\ndocker logs --tail 30 notes-mysql-restore\ndocker exec -it notes-mysql-restore mysql -uroot -p -e \"SELECT 1;\"\n# 成功后再复制,用批处理标准输入恢复,检查退出码。\ndocker cp ./backups/notes_lab.sql notes-mysql-restore:/tmp/notes_lab.sql\ndocker exec notes-mysql-restore sh -c 'MYSQL_PWD=\"$MYSQL_ROOT_PASSWORD\" mysql -uroot --default-character-set=utf8mb4 notes_restore < /tmp/notes_lab.sql'",
         "lang": "bash"
       },
       {
         "title": "核对恢复并保留证据",
         "body": "本课尚未执行第06课的应用迁移,因此只核对已创建的 volume_probe,不查询尚不存在的 notes 或 alembic_version。恢复标记应为 id=1、value=survives recreate。完成第06课迁移并创建笔记后,再重复备份和恢复流程;使用新的恢复数据库或新的专用恢复卷,避免把第二份备份叠加到非空恢复库,再比较 notes 数量和 alembic_version。此时才完成业务数据恢复验收。当前先停止恢复容器,保留恢复卷用于核查。",
-        "code": "docker exec notes-restore psql -U postgres -d notes_restore -c \"SELECT * FROM volume_probe;\"\ndocker stop notes-restore\n# 第06课迁移、创建笔记并重新备份到新的隔离恢复库后,再在新恢复库查询:\n# SELECT count(*) FROM notes;\n# SELECT version_num FROM alembic_version;",
+        "code": "docker exec -it notes-mysql-restore mysql -uroot -p notes_restore -e \"SELECT * FROM volume_probe; SHOW TABLE STATUS LIKE 'volume_probe';\"\ndocker stop notes-mysql-restore\n# 第06课迁移并创建笔记后,重新备份到新的空恢复库,再核对:\n# SELECT count(*) FROM notes;\n# SELECT version_num FROM alembic_version;",
         "lang": "bash"
       }
     ],
     "checks": [
-      "重建 postgres 容器后 volume_probe 行仍存在。",
-      "pg_restore --list 可读取备份结构,恢复命令退出为 0。",
+      "重建 mysql 容器后 volume_probe 行仍存在。",
+      "mysqldump与mysql导入均退出0,恢复后的volume_probe内容和InnoDB引擎符合预期。",
       "独立恢复库包含正确的 volume_probe,原练习库未被覆盖;业务表恢复在第06课迁移后用新备份补验。"
     ],
     "pitfalls": [
       {
         "symptom": "重建容器后数据全没了",
         "cause": "未挂载持久卷、挂载路径错误,或切换了 Compose project name。",
-        "fix": "检查 Mounts 和卷标签,确认实际使用的 pg_data;不要在未确认旧卷前初始化覆盖或删除。"
+        "fix": "检查 Mounts 和卷标签,确认实际使用的 mysql_data;不要在未确认旧卷前初始化覆盖或删除。"
       },
       {
         "symptom": "恢复时报 archive 格式错误",
         "cause": "用不支持二进制透明传输的 Shell 重定向生成备份。",
-        "fix": "在容器内写 -f 文件,再 docker cp;检查备份大小和 pg_restore --list。"
+        "fix": "使用mysqldump --result-file在容器内直接生成SQL文件,再docker cp;必须验证命令退出状态并恢复到独立空库。"
       }
     ],
     "exercise": {
-      "task": "每天把 PGDATA 目录复制到同一宿主另一个文件夹能否替代备份方案?",
+      "task": "每天把 MySQLDATA 目录复制到同一宿主另一个文件夹能否替代备份方案?",
       "hint": "分别考虑运行中一致性与宿主故障。",
       "answer": "不能。普通文件复制可能得到不一致数据库,而且宿主故障会同时损坏原件与副本。使用数据库支持的备份协议,并保留异机副本、加密密钥与定期恢复证据。"
     },
     "refs": [
       {
-        "label": "Docker:Volumes",
-        "url": "https://docs.docker.com/engine/storage/volumes/"
+        "label": "MySQL 8.4:mysqldump",
+        "url": "https://dev.mysql.com/doc/refman/8.4/en/mysqldump.html"
       },
       {
-        "label": "PostgreSQL 16:pg_dump",
-        "url": "https://www.postgresql.org/docs/16/app-pgdump.html"
+        "label": "MySQL 8.4:SQL格式备份恢复",
+        "url": "https://dev.mysql.com/doc/refman/8.4/en/reloading-sql-format-dumps.html"
       },
       {
-        "label": "PostgreSQL 16:pg_restore",
-        "url": "https://www.postgresql.org/docs/16/app-pgrestore.html"
+        "label": "Docker volumes",
+        "url": "https://docs.docker.com/engine/storage/volumes/"
       }
     ],
     "production": {
@@ -549,7 +553,7 @@
     "concepts": [
       {
         "title": "running 不是 ready",
-        "body": "容器进程创建后,Postgres 可能仍在初始化,API 可能缺表。pg_isready 证明数据库接受连接,不验证 app_user 凭据或业务表。应用 /ready 执行 SQL,迁移作业另行证明表版本已到目标。"
+        "body": "容器进程已创建不代表MySQL完成初始化,也不代表业务表已迁移。数据库探针执行认证后的SELECT 1,应用/ready验证自己的数据库连接;迁移作业再核对表结构和revision。"
       },
       {
         "title": "启动顺序只覆盖启动",
@@ -563,15 +567,15 @@
     "steps": [
       {
         "title": "增加数据库和 Redis 健康检查",
-        "body": "把以下字段分别合并到 compose.yaml 的 postgres 与 redis 服务内,不新建同名重复服务。$$ 让变量传入容器 Shell 后展开,避免 Compose 提前替换。",
-        "code": "# postgres 服务内\nhealthcheck:\n  test: [\"CMD-SHELL\", \"pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB\"]\n  interval: 5s\n  timeout: 3s\n  retries: 12\n  start_period: 10s\n\n# redis 服务内\nhealthcheck:\n  test: [\"CMD\", \"redis-cli\", \"ping\"]\n  interval: 5s\n  timeout: 2s\n  retries: 5",
+        "body": "将以下字段分别合并到mysql与redis服务。MySQL探针实际执行SELECT 1,能够检测认证失败;不能用只判断进程存活的ping代替就绪检查。$$延迟到容器Shell展开变量。此处沿用本机练习环境变量,生产应使用受控客户端凭据文件。",
+        "code": "# mysql 服务内\nhealthcheck:\n  test: [\"CMD-SHELL\", 'MYSQL_PWD=\"$${MYSQL_PASSWORD}\" mysql --protocol=TCP -h127.0.0.1 -u\"$${MYSQL_USER}\" --database=\"$${MYSQL_DATABASE}\" --connect-timeout=2 -e \"SELECT 1\" >/dev/null 2>&1']\n  interval: 5s\n  timeout: 3s\n  retries: 20\n  start_period: 30s\n\n# redis 服务内\nhealthcheck:\n  test: [\"CMD\", \"redis-cli\", \"ping\"]\n  interval: 5s\n  timeout: 2s\n  retries: 5",
         "lang": "yaml",
         "filename": "compose.yaml"
       },
       {
         "title": "新增一次性迁移服务",
-        "body": "合并到 services 层,镜像与 API 完全相同。练习迁移使用 app_user 的 DDL 权限;生产用单独迁移凭据覆盖此服务的 DATABASE_URL,API 保持更小权限。",
-        "code": "migrate:\n  image: ${NOTES_IMAGE:?set NOTES_IMAGE}\n  command: [\"python\", \"-m\", \"alembic\", \"upgrade\", \"head\"]\n  environment:\n    DATABASE_URL: ${DATABASE_URL:?set DATABASE_URL}\n  depends_on:\n    postgres:\n      condition: service_healthy\n  restart: \"no\"",
+        "body": "合并到 services 层,镜像与 API 完全相同。练习迁移使用 app_user 的 DDL 权限;生产用单独迁移凭据覆盖此服务的 DATABASE_URL,API 保持更小权限。 MySQL DDL通常隐式提交,迁移失败可能已经完成部分结构变更;先核对实际结构与revision,再修复或补偿,不能直接假定整个迁移事务已回滚。",
+        "code": "migrate:\n  image: ${NOTES_IMAGE:?set NOTES_IMAGE}\n  command: [\"python\", \"-m\", \"alembic\", \"upgrade\", \"head\"]\n  environment:\n    DATABASE_URL: ${DATABASE_URL:?set DATABASE_URL}\n  depends_on:\n    mysql:\n      condition: service_healthy\n  restart: \"no\"",
         "lang": "yaml",
         "filename": "compose.yaml"
       },
@@ -585,13 +589,13 @@
       {
         "title": "首次启动并验证迁移状态",
         "body": "up -d --wait 等待服务达到健康状态,但它不是业务验收;随后创建一条笔记并读取。migrate 是正常退出的作业,其 Exited (0) 不算服务故障。",
-        "code": "docker compose --env-file .env.compose config --quiet\ndocker compose --env-file .env.compose up -d --wait\ndocker compose --env-file .env.compose ps -a\ndocker compose --env-file .env.compose logs --tail 50 migrate\ndocker compose --env-file .env.compose exec postgres psql -U postgres -d notes_lab -c \"SELECT version_num FROM alembic_version;\"",
+        "code": "docker compose --env-file .env.compose config --quiet\ndocker compose --env-file .env.compose up -d --wait\ndocker compose --env-file .env.compose ps -a\ndocker compose --env-file .env.compose logs --tail 50 migrate\ndocker compose --env-file .env.compose exec mysql mysql -uapp_user -p notes_lab -e \"SELECT version_num FROM alembic_version;\"",
         "lang": "bash"
       },
       {
         "title": "演练运行时数据库短暂停止",
-        "body": "仅在专用本机栈停止 postgres,此时 /live 应仍为 200、/ready 为 503;重启数据库后连接池 pre_ping 应逐步恢复。Docker 标记 unhealthy 本身不会自动重启容器,restart 策略主要响应进程退出。写入失败不要盲目自动重放,用幂等键确认结果。",
-        "code": "docker compose --env-file .env.compose stop postgres\ndocker compose --env-file .env.compose logs --tail 30 api\ndocker compose --env-file .env.compose start postgres\ndocker compose --env-file .env.compose ps",
+        "body": "仅在专用本机栈停止 mysql,此时 /live 应仍为 200、/ready 为 503;重启数据库后连接池 pre_ping 应逐步恢复。Docker 标记 unhealthy 本身不会自动重启容器,restart 策略主要响应进程退出。写入失败不要盲目自动重放,用幂等键确认结果。",
+        "code": "docker compose --env-file .env.compose stop mysql\ndocker compose --env-file .env.compose logs --tail 30 api\ndocker compose --env-file .env.compose start mysql\ndocker compose --env-file .env.compose ps",
         "lang": "bash"
       }
     ],
@@ -627,8 +631,8 @@
         "url": "https://docs.docker.com/reference/dockerfile/#healthcheck"
       },
       {
-        "label": "PostgreSQL 16:pg_isready",
-        "url": "https://www.postgresql.org/docs/16/app-pg-isready.html"
+        "label": "MySQL 8.4:隐式提交",
+        "url": "https://dev.mysql.com/doc/refman/8.4/en/implicit-commit.html"
       }
     ],
     "production": {
@@ -637,7 +641,7 @@
         "依赖健康、迁移完成和业务就绪分别验证。",
         "运行时重试有限,写请求按幂等协议恢复。"
       ],
-      "failureDrill": "停止练习 postgres,观察 /live 与 /ready 分离,恢复后验证原数据和新写入。",
+      "failureDrill": "停止练习 mysql,观察 /live 与 /ready 分离,恢复后验证原数据和新写入。",
       "acceptance": [
         "共享数据库故障不会触发无意义的无限重启。",
         "启动失败能够定位到依赖、迁移或应用阶段。"
@@ -697,7 +701,7 @@
       {
         "title": "记录资源与退出验收",
         "body": "创建 resource-budget.md,记录实例/worker 数、连接池乘数、CPU/内存峰值及关闭耗时。任务 worker 的并发进程数和每个进程连接池也算入数据库配额。",
-        "code": "API:1 进程/容器;数据库池 5;内存上限 512MiB;关闭预算 25s < 容器35s\nWorker:按第9课单独配置并发;连接数按实际执行模型计算\n宿主:为 Postgres、Redis、Docker 和系统留余量\n证据:实际 stats 峰值、OOM 状态、SIGTERM 关闭耗时",
+        "code": "API:1 进程/容器;数据库池 5;内存上限 512MiB;关闭预算 25s < 容器35s\nWorker:按第9课单独配置并发;连接数按实际执行模型计算\n宿主:为 MySQL、Redis、Docker 和系统留余量\n证据:实际 stats 峰值、OOM 状态、SIGTERM 关闭耗时",
         "lang": "text",
         "filename": "resource-budget.md"
       }
@@ -789,7 +793,7 @@
       },
       {
         "title": "建立本机秘密文件",
-        "body": "在已忽略的 secrets 目录用编辑器新建 database_url,填写 postgres:5432 的容器连接串。以下为 Linux / WSL 的本地 file-backed secrets 方案:父目录由当前可信宿主账户持有且权限700,单个绑定文件644,使容器UID10001可读。其他普通宿主账户无法穿过700父目录;有Docker控制权限的账户仍属于受信任管理员。不能仅写uid/gid/mode并假设Compose会重映射文件权限。",
+        "body": "在已忽略的 secrets 目录用编辑器新建 database_url,填写 mysql:3306 的容器连接串。以下为 Linux / WSL 的本地 file-backed secrets 方案:父目录由当前可信宿主账户持有且权限700,单个绑定文件644,使容器UID10001可读。其他普通宿主账户无法穿过700父目录;有Docker控制权限的账户仍属于受信任管理员。不能仅写uid/gid/mode并假设Compose会重映射文件权限。",
         "code": "mkdir -p secrets\n# 用编辑器创建 secrets/database_url,填实际练习连接串。\nchmod 700 secrets\nchmod 644 secrets/database_url\n# 此处只调整明确的单个绑定文件;不把父目录改成公开可进入。",
         "lang": "bash"
       },
@@ -901,14 +905,14 @@
       },
       {
         "title": "为任务角色建立秘密加载入口",
-        "body": "新建 deploy/run_role.py。API 的 SQLAlchemy URL 用 postgresql+psycopg://,任务模块直接 psycopg.connect 使用 postgresql://,两者分别保存。入口从三个只读秘密文件读入实际环境变量再 exec 目标进程,不输出秘密;读取 /run/secrets/task_database_url、celery_broker_url、celery_result_backend。",
+        "body": "新建deploy/run_role.py。API的SQLAlchemy异步URL使用mysql+asyncmy://;任务的同步PyMySQL适配器接受mysql+pymysql://,均指向mysql:3306/notes_lab。入口从三个只读秘密文件读取环境变量后exec目标进程,不打印值。同步连接的解析、UTC与事务设置继续复用任务模块state_store.py。",
         "code": "import os\nimport sys\nfrom pathlib import Path\n\nfor name, variable in {\n    \"task_database_url\": \"DATABASE_URL\",\n    \"celery_broker_url\": \"CELERY_BROKER_URL\",\n    \"celery_result_backend\": \"CELERY_RESULT_BACKEND\",\n}.items():\n    value = Path(\"/run/secrets\", name).read_text().strip()\n    if not value:\n        raise RuntimeError(f\"empty secret: {name}\")\n    os.environ[variable] = value\nif len(sys.argv) < 2:\n    raise RuntimeError(\"missing process command\")\nos.execvp(sys.argv[1], sys.argv[1:])",
         "lang": "python",
         "filename": "deploy/run_role.py"
       },
       {
         "title": "配置独立认证 broker 与秘密文件",
-        "body": "新增 broker 服务,不复用缓存 redis。宿主6384为任务专用,6380–6383已由Redis课程其他隔离实例使用。用强随机 TASK_BROKER_PASSWORD 在编辑器生成秘密文件:task_broker_conf 包含 requirepass、appendonly yes、appendfsync everysec、maxmemory 192mb、maxmemory-policy noeviction、dir /data;celery_broker_url 为 redis://:URL编码密码@broker:6379/0,celery_result_backend 为同地址/1;task_database_url 为 postgresql://app_user:URL编码密码@postgres:5432/notes_lab。三个URL文件由task服务读取;broker配置只读挂载,不提交到仓库。 本地file-backed secrets按第8课使用宿主受控父目录700、单秘密文件644,使UID10001可读取实际挂载;启动前用run --no-deps检查可读性且不打印内容。TASK_BROKER_PASSWORD建议本机生成高熵十六进制值,使Redis配置requirepass行无需额外引号转义。",
+        "body": "新增 broker 服务,不复用缓存 redis。宿主6384为任务专用,6380–6383已由Redis课程其他隔离实例使用。用强随机 TASK_BROKER_PASSWORD 在编辑器生成秘密文件:task_broker_conf 包含 requirepass、appendonly yes、appendfsync everysec、maxmemory 192mb、maxmemory-policy noeviction、dir /data;celery_broker_url 为 redis://:URL编码密码@broker:6379/0,celery_result_backend 为同地址/1;task_database_url 为 mysql+pymysql://app_user:URL编码密码@mysql:3306/notes_lab。三个URL文件由task服务读取;broker配置只读挂载,不提交到仓库。 本地file-backed secrets按第8课使用宿主受控父目录700、单秘密文件644,使UID10001可读取实际挂载;启动前用run --no-deps检查可读性且不打印内容。TASK_BROKER_PASSWORD建议本机生成高熵十六进制值,使Redis配置requirepass行无需额外引号转义。",
         "code": "# services 中新增;本机调试端口仅回环开放\nbroker:\n  image: redis:7.4\n  command: [\"redis-server\", \"/run/secrets/task_broker_conf\"]\n  ports: [\"127.0.0.1:6384:6379\"]\n  secrets: [task_broker_conf]\n  volumes: [\"broker_data:/data\"]\n  mem_limit: 256m\n  restart: unless-stopped\n\n# 顶层 volumes 新增 broker_data: {}\n# 顶层 secrets 新增以下条目(与已有 database_url 并列)\n# task_broker_conf: {file: ./secrets/task_broker_conf}\n# task_database_url: {file: ./secrets/task_database_url}\n# celery_broker_url: {file: ./secrets/celery_broker_url}\n# celery_result_backend: {file: ./secrets/celery_result_backend}",
         "lang": "yaml",
         "filename": "compose.yaml"
@@ -936,13 +940,13 @@
       {
         "title": "按真实业务链路验收与故障恢复",
         "body": "先在notes服务创建至少一条练习笔记,然后以下命令从真实notes表选择一条并调用最终state_store.submit_snapshot;它已包含job/outbox原子写入,不调用export_notes.delay。记录返回job_id。publisher将事件路由到exports.fast/bulk,worker写artifacts,最终运行任务第16课verify_terminal.py核对文件与固定快照。暂停worker后提交应queued,恢复后完成。beat仅在第14课周期任务完成后单实例部署。",
-        "code": "docker compose --env-file .env.compose stop worker worker-bulk\ndocker compose --env-file .env.compose run --rm --no-deps worker python /app/deploy/run_role.py python -c \"from state_store import connect,submit_snapshot; conn=connect(); row=conn.execute('SELECT id FROM notes ORDER BY id LIMIT 1').fetchone(); conn.close(); assert row, 'create a note first'; print(submit_snapshot([row['id']]))\"\ndocker compose --env-file .env.compose start worker worker-bulk\ndocker compose --env-file .env.compose exec postgres psql -U postgres -d notes_lab -c \"SELECT id,status,result_ref,attempts FROM export_jobs ORDER BY created_at DESC LIMIT 10;\"\n# 用真实返回的UUID替换JOB_ID;任务完成后验证artifact\ndocker compose --env-file .env.compose exec worker python /app/deploy/run_role.py python verify_terminal.py JOB_ID",
+        "code": "docker compose --env-file .env.compose stop worker worker-bulk\n# 以下为Bash/WSL,在容器内用真实transaction()适配器选择笔记再提交。\ndocker compose --env-file .env.compose run --rm -T --no-deps worker python /app/deploy/run_role.py python - <<'PY'\nfrom state_store import transaction, submit_snapshot\nwith transaction() as cur:\n    cur.execute(\"SELECT id FROM notes ORDER BY id LIMIT 1\")\n    row = cur.fetchone()\nassert row, \"create a note first\"\nprint(submit_snapshot([row[\"id\"]]))\nPY\ndocker compose --env-file .env.compose start worker worker-bulk\ndocker compose --env-file .env.compose exec mysql mysql -uapp_user -p notes_lab -e \"SELECT id,status,result_ref,attempts FROM export_jobs ORDER BY created_at DESC LIMIT 10;\"\n# 将JOB_ID替换为刚返回的真实UUID,完成后验收结果文件。\ndocker compose --env-file .env.compose exec worker python /app/deploy/run_role.py python verify_terminal.py JOB_ID",
         "lang": "bash"
       }
     ],
     "checks": [
       "Celery inspect 注册 notes.export,fast/bulk worker 只消费对应队列。",
-      "PostgreSQL中job/outbox到最终状态与可读取的输出文件一致,不能只凭Celery结果缓存。",
+      "MySQL中job/outbox到最终状态与可读取的输出文件一致,不能只凭Celery结果缓存。",
       "缓存redis与认证broker是不同实例;宿主任务端口6384,容器内部broker:6379。"
     ],
     "pitfalls": [
@@ -958,9 +962,9 @@
       }
     ],
     "exercise": {
-      "task": "为什么不能把postgresql+psycopg://连接串原样交给任务模块的psycopg.connect?",
-      "hint": "区分SQLAlchemy方言URL与PostgreSQL驱动DSN。",
-      "answer": "API使用SQLAlchemy URL;任务state_store直接使用psycopg.connect,需要postgresql://或libpq键值DSN。因此配置分成API database_url与task_database_url,端口和账号相同但协议前缀不同。"
+      "task": "为什么API和同步任务使用不同的MySQL连接URL?",
+      "hint": "区分SQLAlchemy方言URL与MySQL驱动DSN。",
+      "answer": "SQLAlchemy的mysql+asyncmy://选择异步驱动;任务state_store适配器解析mysql+pymysql://并调用同步PyMySQL。两者可以使用相同库和账号,但驱动入口及调用方式不同,不能把异步SQLAlchemy URL直接交给同步驱动。"
     },
     "refs": [
       {
@@ -1024,7 +1028,7 @@
       {
         "title": "构建、标记并本机验收",
         "body": "构建使用第 3 课已验证基础 digest。用户拥有并授权的镜像仓库才可执行登录与推送;课件提供命令模板,不实际向外发布。标签中使用真实版本或 commit,不重复覆盖旧发布标签。 以下命令在Bash/WSL运行,PYTHON_IMAGE必须是第03课保存的完整摘要;开启新终端时先恢复已审核的变量,不能静默回退到浮动标签。",
-        "code": "docker build --build-arg PYTHON_IMAGE=\"${PYTHON_IMAGE:?set verified base digest}\" -t notes-service:release-01 .\ndocker run --rm notes-service:release-01 python -c \"import fastapi, sqlalchemy, psycopg; print('imports ok')\"\n# 已具备目标仓库权限后,替换 REGISTRY/PROJECT 为真实仓库:\n# docker tag notes-service:release-01 REGISTRY/PROJECT/notes-service:release-01\n# docker push REGISTRY/PROJECT/notes-service:release-01\n# docker buildx imagetools inspect REGISTRY/PROJECT/notes-service:release-01",
+        "code": "docker build --build-arg PYTHON_IMAGE=\"${PYTHON_IMAGE:?set verified base digest}\" -t notes-service:release-01 .\ndocker run --rm notes-service:release-01 python -c \"import fastapi, sqlalchemy, asyncmy, pymysql; print('imports ok')\"\n# 已具备目标仓库权限后,替换 REGISTRY/PROJECT 为真实仓库:\n# docker tag notes-service:release-01 REGISTRY/PROJECT/notes-service:release-01\n# docker push REGISTRY/PROJECT/notes-service:release-01\n# docker buildx imagetools inspect REGISTRY/PROJECT/notes-service:release-01",
         "lang": "bash"
       },
       {
@@ -1035,7 +1039,7 @@
       },
       {
         "title": "验证业务再决定继续或回退",
-        "body": "检查 /ready、创建读取、旧版本冲突、幂等重试和 真实导出任务;记录实际 HTTP 与数据库结果。失败时先将 NOTES_IMAGE 恢复上一兼容版本,再 up --no-deps api worker,不回退 Redis/Postgres 镜像主版本,也不自动操作数据库迁移。",
+        "body": "检查 /ready、创建读取、旧版本冲突、幂等重试和 真实导出任务;记录实际 HTTP 与数据库结果。失败时先将 NOTES_IMAGE 恢复上一兼容版本,再 up --no-deps api worker,不回退 Redis/MySQL 镜像主版本,也不自动操作数据库迁移。",
         "code": "# 将 .env.compose 的 NOTES_IMAGE 恢复到已记录的上一兼容版本后:\ndocker compose --env-file .env.compose up -d --no-deps api worker worker-bulk publisher recovery\ndocker compose --env-file .env.compose exec api python -c \"import urllib.request; print(urllib.request.urlopen('http://127.0.0.1:8000/ready',timeout=4).status)\"",
         "lang": "bash"
       },
@@ -1086,7 +1090,8 @@
       "scenario": "一次发布必须能够定位软件版本,并在已声明兼容范围内回退。",
       "decisions": [
         "构建一次并以明确版本交付。",
-        "应用回退与数据库恢复分开决策,保持数据卷。"
+        "应用回退与数据库恢复分开决策,保持数据卷。",
+        "MySQL的原子DDL保证单条受支持DDL的原子性,不把多条Alembic操作变成可整体回滚事务;发布门槛同时核对实际schema与revision。"
       ],
       "failureDrill": "在本机发布一个故意缺配置的应用版本,确认错误后回到上一镜像并核对既有数据。",
       "acceptance": [

+ 40 - 39
public/content/linux.json

@@ -122,7 +122,7 @@
       },
       {
         "title": "公开端口最小化",
-        "body": "Nginx对外提供80/443,API只监听127.0.0.1:8000;PostgreSQL与Redis继续使用本机回环调试端口或受控私网。Docker发布端口可能经过不同于普通INPUT的转发规则,不能假定UFW会拦截所有Docker ports。"
+        "body": "Nginx对外提供80/443,API只监听127.0.0.1:8000;MySQL与Redis继续使用本机回环调试端口或受控私网。Docker发布端口可能经过不同于普通INPUT的转发规则,不能假定UFW会拦截所有Docker ports。"
       },
       {
         "title": "防止锁死",
@@ -156,7 +156,7 @@
       },
       {
         "title": "核对Docker与云侧规则",
-        "body": "若依赖运行在Docker,docker ps检查端口只能为127.0.0.1:5433、缓存6380、任务broker6384。云安全组按本练习仅开放已验证SSH和80/443;此课不代替在真实云账号创建规则。",
+        "body": "若依赖运行在Docker,docker ps检查端口只能为127.0.0.1:3307、缓存6380、任务broker6384。云安全组按本练习仅开放已验证SSH和80/443;此课不代替在真实云账号创建规则。",
         "code": "docker ps --format 'table {{.Names}}\t{{.Ports}}'\nsudo ss -lntp\n# 从另一台获准练习客户端检测VM的80/443;数据库端口不应对公网开放。",
         "lang": "bash"
       }
@@ -231,14 +231,14 @@
       },
       {
         "title": "环境文件不是Shell脚本",
-        "body": "systemd EnvironmentFile读取KEY=value,不执行$(command)或source逻辑。API使用SQLAlchemy URL,任务直接psycopg使用PostgreSQL DSN,分别存文件。秘密文件root管理,运行用户通过所属组只读,诊断时不输出全文。"
+        "body": "systemd EnvironmentFile读取KEY=value,不执行命令或source。API使用mysql+asyncmy://;任务state_store解析mysql+pymysql://后建立同步连接。秘密文件由root管理,运行组只读,诊断时不输出全文。"
       }
     ],
     "steps": [
       {
         "title": "安装Ubuntu提供的运行依赖",
-        "body": "只在独立VM安装包;升级已有生产主机应走维护流程。本模块进程主线使用Ubuntu默认python3,不安装到系统site-packages。若数据库继续使用Docker,宿主只安装postgresql-client用于检查与备份。",
-        "code": "sudo apt update\nsudo apt install -y python3 python3-venv nginx postgresql-client curl rsync\npython3 --version",
+        "body": "在独立Ubuntu VM安装应用运行依赖和交互SQL客户端;不在系统site-packages安装应用。发行版客户端版本可能与MySQL8.4不同,交互查询先检查兼容性;第09课备份明确使用mysql:8.4工具镜像,不假定Ubuntu默认安装的是8.4。",
+        "code": "sudo apt update\nsudo apt install -y python3 python3-venv nginx default-mysql-client curl rsync\npython3 --version",
         "lang": "bash"
       },
       {
@@ -249,8 +249,8 @@
       },
       {
         "title": "建立两个专用环境文件",
-        "body": "用sudoedit创建下列文件,填VM实际依赖地址与URL编码密码。若沿用Docker练习依赖:PG宿主5433,缓存6380,任务broker6384;若迁移到受控私网数据库按实际地址改,绝不能在主机进程中使用Docker服务名postgres/broker。",
-        "code": "# /etc/notes-service.env(API)\nDATABASE_URL=postgresql+psycopg://app_user:ENCODED_PASSWORD@127.0.0.1:5433/notes_lab\nDB_POOL_SIZE=5\nDB_MAX_OVERFLOW=0\nDB_POOL_TIMEOUT=2\n\n# /etc/notes-tasks.env(tasks_lab)\nDATABASE_URL=postgresql://app_user:ENCODED_PASSWORD@127.0.0.1:5433/notes_lab\nCELERY_BROKER_URL=redis://:ENCODED_TASK_BROKER_PASSWORD@127.0.0.1:6384/0\nCELERY_RESULT_BACKEND=redis://:ENCODED_TASK_BROKER_PASSWORD@127.0.0.1:6384/1",
+        "body": "用sudoedit创建下列文件,填VM实际依赖地址与URL编码密码。若沿用Docker练习依赖:MySQL宿主3307,缓存6380,任务broker6384;若迁移到受控私网数据库按实际地址改,绝不能在主机进程中使用Docker服务名mysql/broker。",
+        "code": "# /etc/notes-service.env(API)\nDATABASE_URL=mysql+asyncmy://app_user:ENCODED_PASSWORD@127.0.0.1:3307/notes_lab?charset=utf8mb4\nDB_POOL_SIZE=5\nDB_MAX_OVERFLOW=0\nDB_POOL_TIMEOUT=2\n\n# /etc/notes-tasks.env(tasks_lab)\nDATABASE_URL=mysql+pymysql://app_user:ENCODED_PASSWORD@127.0.0.1:3307/notes_lab?charset=utf8mb4\nCELERY_BROKER_URL=redis://:ENCODED_TASK_BROKER_PASSWORD@127.0.0.1:6384/0\nCELERY_RESULT_BACKEND=redis://:ENCODED_TASK_BROKER_PASSWORD@127.0.0.1:6384/1",
         "lang": "dotenv"
       },
       {
@@ -262,7 +262,7 @@
       {
         "title": "检查环境与导入",
         "body": "不source秘密文件到共享交互终端。下面只验证包和文件路径;带环境启动由下一课systemd读取。若依赖锁与Python版本不兼容,返回目标Linux/Python版本重新生成并验证锁,不去掉固定版本强行安装。",
-        "code": "sudo -u notes /srv/notes-service/current/.venv/bin/python -c \"import sys, fastapi, psycopg; print(sys.version); print(fastapi.__version__, psycopg.__version__)\"\nsudo -u notes readlink -f /srv/notes-service/current\nsudo namei -l /srv/notes-service/current/app/main.py",
+        "code": "sudo -u notes /srv/notes-service/current/.venv/bin/python -c \"import sys, fastapi, asyncmy, pymysql; print(sys.version); print(fastapi.__version__); print('mysql drivers import ok')\"\nsudo -u notes readlink -f /srv/notes-service/current\nsudo namei -l /srv/notes-service/current/app/main.py",
         "lang": "bash"
       }
     ],
@@ -273,9 +273,9 @@
     ],
     "pitfalls": [
       {
-        "symptom": "直接psycopg报invalid connection option或scheme",
-        "cause": "将SQLAlchemy的postgresql+psycopg://交给直接驱动。",
-        "fix": "任务环境使用postgresql://,API保留SQLAlchemy方言URL。"
+        "symptom": "任务连接URL解析失败或加载了错误驱动",
+        "cause": "把API的异步驱动URL原样复制给任务的同步PyMySQL适配器。",
+        "fix": "API使用mysql+asyncmy://,任务使用mysql+pymysql://;核对URL编码、3307宿主端口与实际环境来源。"
       },
       {
         "symptom": "systemd找不到包但登录终端能导入",
@@ -676,7 +676,7 @@
       },
       {
         "title": "用一次性systemd作业运行迁移",
-        "body": "仅对专用练习数据库执行已审核迁移;EnvironmentFile使用API SQLAlchemy URL,迁移账号若已分离应改为独立/etc/notes-migrate.env。systemd-run --wait会报告进程结果;失败不继续切换。先保存当前revision与备份记录。",
+        "body": "仅对专用练习数据库执行已审核迁移;EnvironmentFile使用API SQLAlchemy URL,迁移账号若已分离应改为独立/etc/notes-migrate.env。systemd-run --wait会报告进程结果;失败不继续切换。先保存当前revision与备份记录。 MySQL DDL隐式提交,失败后检查已经应用的语句和实际结构,保留兼容窗口;不得直接重复整份迁移或认为失败即零变更。",
         "code": "sudo systemd-run --unit=notes-migrate-r002 --wait --collect   --property=User=notes --property=Group=notes   --property=WorkingDirectory=/srv/notes-service/releases/r002   --property=EnvironmentFile=/etc/notes-service.env   /srv/notes-service/releases/r002/.venv/bin/python -m alembic upgrade head",
         "lang": "bash"
       },
@@ -820,7 +820,7 @@
       {
         "symptom": "磁盘满后直接删除大文件仍未释放空间",
         "cause": "文件可能仍由进程持有,或误删了重要数据。",
-        "fix": "先用lsof等确认持有者和文件类型,按服务支持方式轮转;不能删除PGDATA文件排空间。"
+        "fix": "先用lsof等确认持有者和文件类型,按服务支持方式轮转;不能删除MySQLDATA文件排空间。"
       }
     ],
     "exercise": {
@@ -861,53 +861,53 @@
     "title": "数据库备份、异机加密与恢复目标",
     "summary": "从一致性备份走到异机副本与隔离恢复,明确RPO和RTO是需要测量的目标。",
     "objectives": [
-      "创建可验证的PostgreSQL逻辑备份",
+      "创建可验证的MySQL逻辑备份",
       "对备份加密并传到获准的独立存储",
       "测量恢复时间并核对应用级数据"
     ],
     "concepts": [
       {
-        "title": "RPO与RTO",
-        "body": "RPO描述可接受的数据回退时长,RTO描述可接受的恢复耗时。每天一次逻辑备份通常无法支持分钟级RPO,需WAL归档和PITR等方案。恢复时间包含取回备份、解密、建库、恢复、索引和业务验收,不只是pg_restore命令。"
+        "title": "一致性快照与DDL窗口",
+        "body": "mysqldump --single-transaction从InnoDB一致性快照读取数据;非事务表和并发DDL需要其他协调措施,成功退出不是恢复能力的证明。"
       },
       {
-        "title": "权限与密钥分离",
-        "body": "备份包含业务数据,应使用专用备份账号与最小读取权限,传输加密、静态加密、访问审计。解密私钥不能只放在同一台被备份主机,否则主机丢失后备份也无法解密。"
+        "title": "RPO取决于快照和binlog保留",
+        "body": "每日逻辑转储无法满足分钟级RPO。需要快照对应的位置或GTID,以及完整、连续、异地保存的binlog,再在隔离实例演练指定时间恢复。"
       },
       {
-        "title": "恢复隔离",
-        "body": "恢复到新的notes_restore数据库或独立恢复主机,不能覆盖线上notes_lab来试验。行数、关键约束、迁移版本、任务状态与文件引用都要检查;数据库备份不自动包含shared/artifacts,输出文件需独立备份或可重建规则。"
+        "title": "恢复包含数据库和外部结果",
+        "body": "恢复SQL后还要核对任务result_ref对应的artifacts或对象存储。恢复环境不得连接真实broker并意外执行旧任务;账号、权限、事件调度器与外发都应单独验收。"
       }
     ],
     "steps": [
       {
         "title": "为练习备份建立目录与凭据文件",
-        "body": "在VM创建root专用备份目录。使用.pgpass格式的/etc/notes-backup.pgpass保存专用备份凭据,权限600;示例可用练习app_user作为自己表的所有者读取,生产另建备份角色并验证所需权限。密码中的:与\\要按libpq规则转义。",
-        "code": "sudo install -d -o root -g root -m 700 /var/backups/notes-service\nsudoedit /etc/notes-backup.pgpass\n# 文件格式:127.0.0.1:5433:notes_lab:BACKUP_USER:ESCAPED_PASSWORD\nsudo chown root:root /etc/notes-backup.pgpass\nsudo chmod 600 /etc/notes-backup.pgpass",
+        "body": "使用root专用目录保存备份。用sudoedit建立/etc/notes-backup.cnf,内容是MySQL客户端[client]配置,权限600;密码按MySQL选项文件规则加引号与转义,不使用URL编码。备份账号需具备SELECT、SHOW VIEW、TRIGGER等权限;包含routines/events时另核对对应权限。备份客户端使用8.4镜像,Docker命令由管理员执行。",
+        "code": "sudo install -d -o root -g root -m 700 /var/backups/notes-service\nsudoedit /etc/notes-backup.cnf\n# 文件内容(用编辑器填写真实值;不复制秘密到终端输出):\n# [client]\n# host=127.0.0.1\n# port=3307\n# protocol=TCP\n# user=BACKUP_USER\n# password=\"ACTUAL_PASSWORD\"\n# default-character-set=utf8mb4\nsudo chown root:root /etc/notes-backup.cnf\nsudo chmod 600 /etc/notes-backup.cnf",
         "lang": "bash"
       },
       {
         "title": "生成备份并检查可读目录",
-        "body": "下方命令中的BACKUP_USER替换为已验证的备份角色。使用PostgreSQL16客户端备份16服务器;Ubuntu24.04默认客户端通常为16,先检查。sudo env只指定凭据文件路径,命令行不写密码。备份文件名本次固定lab-01,存在时换新编号,不能覆盖唯一好备份。",
-        "code": "pg_dump --version\nsudo env PGPASSFILE=/etc/notes-backup.pgpass pg_dump -h 127.0.0.1 -p 5433 -U BACKUP_USER -d notes_lab -Fc --no-owner --no-acl -f /var/backups/notes-service/notes-lab-01.dump\nsudo pg_restore --list /var/backups/notes-service/notes-lab-01.dump\nsudo sha256sum /var/backups/notes-service/notes-lab-01.dump",
+        "body": "以下仅用于独立Linux VM,工具容器用host网络访问配置中的本机3307;它不启动数据库服务。--defaults-extra-file必须紧跟程序名。--single-transaction要求业务表均为InnoDB,备份期间不要执行DDL。示例不修改GTID集合;创建复制节点另按GTID一致性流程处理。固定文件名用于本次演练,已存在时换新编号;失败文件不能覆盖唯一可用备份。",
+        "code": "sudo docker run --rm mysql:8.4 mysqldump --version\nsudo docker run --rm --network host   --mount type=bind,source=/etc/notes-backup.cnf,target=/run/secrets/client.cnf,readonly   --mount type=bind,source=/var/backups/notes-service,target=/backup   mysql:8.4 mysqldump --defaults-extra-file=/run/secrets/client.cnf   --single-transaction --routines --events --triggers --no-tablespaces   --set-gtid-purged=OFF --hex-blob --default-character-set=utf8mb4   --result-file=/backup/notes-lab-01.sql notes_lab\n# 仅退出0后记录摘要;摘要不能代替实际恢复。\nsudo sha256sum /var/backups/notes-service/notes-lab-01.sql",
         "lang": "bash"
       },
       {
         "title": "使用接收方公钥加密",
         "body": "本步骤示例使用age,先按官方文档安装受信任版本。在恢复端生成并保管age私钥,备份VM仅保存接收方公钥;AGE_RECIPIENT替换真实公钥。此课不实际向外部发送数据,只有自己拥有且已获准的备份主机/存储才执行传输。",
-        "code": "# 备份VM,仅持有接收方公钥\nsudo age -r AGE_RECIPIENT -o /var/backups/notes-service/notes-lab-01.dump.age /var/backups/notes-service/notes-lab-01.dump\nsudo sha256sum /var/backups/notes-service/notes-lab-01.dump.age\n# 使用已验证主机密钥的独立备份目标;替换主机、账号、路径后再执行\n# scp notes-lab-01.dump.age backup@AUTHORIZED_BACKUP_HOST:/approved/notes-service/",
+        "code": "# 备份VM,仅持有接收方公钥\nsudo age -r AGE_RECIPIENT -o /var/backups/notes-service/notes-lab-01.sql.age /var/backups/notes-service/notes-lab-01.sql\nsudo sha256sum /var/backups/notes-service/notes-lab-01.sql.age\n# 使用已验证主机密钥的独立备份目标;替换主机、账号、路径后再执行\n# scp notes-lab-01.sql.age backup@AUTHORIZED_BACKUP_HOST:/approved/notes-service/",
         "lang": "bash"
       },
       {
         "title": "在独立恢复环境解密与恢复",
-        "body": "只在独立恢复VM或数据库执行,先createdb创建notes_restore并验证名称。解密私钥文件必须具有合适权限;不把私钥写在命令参数正文。记录开始与结束时间,恢复账号需在该恢复库有建表权限。",
-        "code": "age -d -i /secure/recovery-age-key.txt -o notes-lab-01-restored.dump notes-lab-01.dump.age\nsha256sum notes-lab-01-restored.dump\ncreatedb -h RESTORE_HOST -U RESTORE_ADMIN -W notes_restore\ndate -u\npg_restore -h RESTORE_HOST -U RESTORE_ADMIN -W -d notes_restore --no-owner --no-acl --exit-on-error notes-lab-01-restored.dump\ndate -u",
+        "body": "只在独立恢复VM和空notes_restore库操作。先解密并与原SQL摘要核对;在恢复主机准备mysql8.4客户端及权限600的/secure/restore.cnf([client]内填恢复主机/端口/管理员/密码)。未指定--databases的转储不会自动切换源库。记录导入时间和退出状态,恢复API使用独立broker并禁用真实外发。",
+        "code": "age -d -i /secure/recovery-age-key.txt -o notes-lab-01-restored.sql notes-lab-01.sql.age\nsha256sum notes-lab-01-restored.sql\nmysql --version\nmysql --defaults-extra-file=/secure/restore.cnf -e \"CREATE DATABASE notes_restore CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;\"\ndate -u\nmysql --defaults-extra-file=/secure/restore.cnf --default-character-set=utf8mb4 notes_restore < notes-lab-01-restored.sql\ndate -u",
         "lang": "bash"
       },
       {
         "title": "核对恢复内容并计算差距",
         "body": "在恢复库执行下列SQL,再将隔离API指向恢复库完成读取与约束验收。不要让恢复API连接生产队列并重新执行旧任务;禁用外发或使用独立broker,处理已完成/未完成任务的恢复语义。",
-        "code": "SELECT current_database();\nSELECT count(*) FROM notes;\nSELECT version_num FROM alembic_version;\nSELECT status, count(*) FROM export_jobs GROUP BY status;\n-- 另行核对shared/artifacts备份或对象存储中的result_ref文件是否存在。",
+        "code": "SELECT DATABASE(),@@version,@@session.time_zone;\nSELECT count(*) FROM notes;\nSELECT version_num FROM alembic_version;\nSELECT status, count(*) FROM export_jobs GROUP BY status;\n-- 另行核对shared/artifacts备份或对象存储中的result_ref文件是否存在。",
         "lang": "sql"
       }
     ],
@@ -931,31 +931,32 @@
     "exercise": {
       "task": "每天备份一次,能否承诺最多丢5分钟数据?",
       "hint": "最后成功备份到故障发生之间可能多长?",
-      "answer": "不能。要达到分钟级RPO,应设计并验证WAL归档/PITR或其他受控方案,同时监控归档延迟;逻辑备份仍可作为补充,但目标必须有实际恢复证据。"
+      "answer": "不能。要达到分钟级RPO,应设计并验证连续binlog归档与时间点恢复或其他受控方案,同时监控归档延迟;逻辑备份仍可作为补充,但目标必须有实际恢复证据。"
     },
     "refs": [
       {
-        "label": "PostgreSQL16:备份恢复",
-        "url": "https://www.postgresql.org/docs/16/backup.html"
+        "label": "age官方用法",
+        "url": "https://github.com/FiloSottile/age"
       },
       {
-        "label": "PostgreSQL16:持续归档与PITR",
-        "url": "https://www.postgresql.org/docs/16/continuous-archiving.html"
+        "label": "MySQL 8.4:备份方法",
+        "url": "https://dev.mysql.com/doc/refman/8.4/en/backup-methods.html"
       },
       {
-        "label": "PostgreSQL16:密码文件",
-        "url": "https://www.postgresql.org/docs/16/libpq-pgpass.html"
+        "label": "MySQL 8.4:binlog时间点恢复",
+        "url": "https://dev.mysql.com/doc/refman/8.4/en/point-in-time-recovery.html"
       },
       {
-        "label": "age官方用法",
-        "url": "https://github.com/FiloSottile/age"
+        "label": "MySQL 8.4:选项文件",
+        "url": "https://dev.mysql.com/doc/refman/8.4/en/option-files.html"
       }
     ],
     "production": {
       "scenario": "主机或数据库损坏后,需要在业务可接受时间内恢复数据且避免重复外部副作用。",
       "decisions": [
         "备份异机加密,密钥另行保管。",
-        "恢复库和队列隔离,业务验收计入RTO。"
+        "恢复库和队列隔离,业务验收计入RTO。",
+        "binlog保留期与备份周期、恢复速度配套;--set-gtid-purged=OFF的独立逻辑恢复不直接充当复制节点初始化流程。"
       ],
       "failureDrill": "在独立恢复环境取回、解密、恢复一份练习备份,验证行数、约束、任务状态与输出文件。",
       "acceptance": [
@@ -1005,7 +1006,7 @@
       {
         "title": "执行数据库与任务业务验收",
         "body": "API+数据库最终脚本先验证创建、PATCH version与409;任务侧使用以下一次性systemd作业从真实notes表选一条并调用最终state_store.submit_snapshot。该函数包含job/outbox原子写入,不手工再delay。输出job_id后等worker完成,再在同一工作目录和环境运行verify_terminal.py JOB_ID,检查artifact与固定payload一致。",
-        "code": "sudo systemd-run --unit=notes-submit-probe --wait --collect --pipe \\\n  --property=User=notes --property=Group=notes \\\n  --property=WorkingDirectory=/srv/notes-service/current/tasks_lab \\\n  --property=EnvironmentFile=/etc/notes-tasks.env \\\n  /srv/notes-service/current/.venv/bin/python -c \"from state_store import connect,submit_snapshot; conn=connect(); row=conn.execute('SELECT id FROM notes ORDER BY id LIMIT 1').fetchone(); conn.close(); assert row, 'create a note first'; print(submit_snapshot([row['id']]))\"\n# 记录上一步实际job_id,完成后替换JOB_ID\nsudo systemd-run --unit=notes-verify-probe --wait --collect --pipe \\\n  --property=User=notes --property=Group=notes \\\n  --property=WorkingDirectory=/srv/notes-service/current/tasks_lab \\\n  --property=EnvironmentFile=/etc/notes-tasks.env \\\n  /srv/notes-service/current/.venv/bin/python verify_terminal.py JOB_ID",
+        "code": "# Bash:选真实笔记并提交,配置由systemd读取,不source秘密文件。\nsudo systemd-run --unit=notes-submit-probe --wait --collect --pipe   --property=User=notes --property=Group=notes   --property=WorkingDirectory=/srv/notes-service/current/tasks_lab   --property=EnvironmentFile=/etc/notes-tasks.env   /srv/notes-service/current/.venv/bin/python - <<'PY'\nfrom state_store import transaction, submit_snapshot\nwith transaction() as cur:\n    cur.execute(\"SELECT id FROM notes ORDER BY id LIMIT 1\")\n    row = cur.fetchone()\nassert row, \"create a note first\"\nprint(submit_snapshot([row[\"id\"]]))\nPY\n# 替换为上一步真实JOB_ID,等待任务完成后核对结果。\nsudo systemd-run --unit=notes-verify-probe --wait --collect --pipe   --property=User=notes --property=Group=notes   --property=WorkingDirectory=/srv/notes-service/current/tasks_lab   --property=EnvironmentFile=/etc/notes-tasks.env   /srv/notes-service/current/.venv/bin/python verify_terminal.py JOB_ID",
         "lang": "bash"
       },
       {

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 12 - 12
public/content/redis.json


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 13 - 14
public/content/tasks.json


+ 44 - 44
public/content/troubleshooting.json

@@ -47,7 +47,7 @@
       {
         "title": "核对止损后仍有无遗漏任务",
         "body": "只读查询数据库任务状态与最老等待。恢复判定至少覆盖健康、笔记读取、一个新导出完成、旧任务去向;不能仅以进程重新启动作为结束标准。",
-        "code": "SELECT status,count(*) AS jobs,\n       extract(epoch FROM now()-min(created_at)) AS oldest_age_seconds\nFROM export_jobs GROUP BY status;\n\nSELECT count(*) AS unpublished FROM export_outbox WHERE sent_at IS NULL;",
+        "code": "SELECT status,count(*) AS jobs,\n       TIMESTAMPDIFF(SECOND,MIN(created_at),UTC_TIMESTAMP(6)) AS oldest_age_seconds\nFROM export_jobs GROUP BY status;\n\nSELECT count(*) AS unpublished FROM export_outbox WHERE sent_at IS NULL;",
         "lang": "sql"
       },
       {
@@ -361,22 +361,22 @@
       },
       {
         "title": "给API和数据库操作加span",
-        "body": "在应用启动配置中调用setup_trace一次,instrument现有app。数据库span只记录有限操作名和行数,不把笔记正文或SQL参数设成属性;HTTPX自动插桩也需核对URL是否包含敏感查询参数。",
-        "code": "from opentelemetry import trace\nfrom opentelemetry.instrumentation.fastapi import FastAPIInstrumentor\nfrom opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor\nfrom trace_setup import setup_trace\n\nsetup_trace(\"notes-service-api\")\nFastAPIInstrumentor.instrument_app(app, excluded_urls=\"live,ready,metrics\")\nHTTPXClientInstrumentor().instrument()\ntracer = trace.get_tracer(\"notes-service\")\n\ndef traced_read(conn, note_id):\n    with tracer.start_as_current_span(\"db.notes.read\") as span:\n        row = conn.execute(\"SELECT id,title FROM notes WHERE id=%s\", (note_id,)).fetchone()\n        span.set_attribute(\"app.rows_returned\", 1 if row else 0)\n        return row",
+        "body": "在应用启动配置中调用setup_trace一次,instrument现有app。数据库span只记录有限操作名和行数,不把笔记正文或SQL参数设成属性;HTTPX自动插桩也需核对URL是否包含敏感查询参数。 traced_read接收现有SQLAlchemy AsyncSession,由调用者管理事务并await此函数;不要把同步PyMySQL调用直接放入异步路由。",
+        "code": "from opentelemetry import trace\nfrom opentelemetry.instrumentation.fastapi import FastAPIInstrumentor\nfrom opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor\nfrom trace_setup import setup_trace\n\nsetup_trace(\"notes-service-api\")\nFastAPIInstrumentor.instrument_app(app, excluded_urls=\"live,ready,metrics\")\nHTTPXClientInstrumentor().instrument()\ntracer = trace.get_tracer(\"notes-service\")\n\nfrom sqlalchemy import text\n\nasync def traced_read(db, note_id):\n    with tracer.start_as_current_span(\"db.notes.read\") as span:\n        row = (await db.execute(\n            text(\"SELECT id,title FROM notes WHERE id=:id\"), {\"id\": note_id}\n        )).mappings().one_or_none()\n        span.set_attribute(\"app.rows_returned\", 1 if row else 0)\n        return row",
         "lang": "python",
         "filename": "app/main.py"
       },
       {
         "title": "把允许的追踪字段存入outbox",
-        "body": "给export_outbox加trace_context jsonb列,默认{}。在提交事务中调用carrier_for_outbox并把返回值作为Jsonb写入同一事件。只保存标准追踪字段,不保存任意baggage或请求头。",
-        "code": "from opentelemetry.propagate import inject\n\ndef carrier_for_outbox():\n    carrier = {}\n    inject(carrier)\n    return {key: value for key, value in carrier.items()\n            if key in {\"traceparent\", \"tracestate\"}}\n\n# SQL:\n# ALTER TABLE export_outbox ADD COLUMN IF NOT EXISTS\n# trace_context jsonb NOT NULL DEFAULT '{}';\n# INSERT outbox时将carrier_for_outbox()写入该列。",
+        "body": "通过审核后的MySQL迁移为export_outbox新增trace_context JSON列。先确认列不存在;ALTER TABLE会隐式提交,不包在业务受理事务里。提交事件时,在同一业务事务中将json.dumps(carrier_for_outbox())作为参数绑定到该列。只保存标准追踪字段,不复制任意baggage或请求头。",
+        "code": "import json\nfrom opentelemetry.propagate import inject\n\ndef carrier_for_outbox():\n    carrier = {}\n    inject(carrier)\n    return {key: value for key,value in carrier.items()\n            if key in {\"traceparent\", \"tracestate\"}}\n\n# 只在迁移中执行一次,先检查实际列结构:\n# ALTER TABLE export_outbox\n# ADD COLUMN trace_context JSON NOT NULL DEFAULT (JSON_OBJECT());\n# submit_snapshot的outbox INSERT增加trace_context占位符,参数传:\n# json.dumps(carrier_for_outbox())",
         "lang": "python",
         "filename": "trace_carrier.py"
       },
       {
         "title": "在publisher与worker恢复上下文",
-        "body": "publisher领取事件时返回trace_context,在send_task外围attach/extract,finally detach。Celery消费者使用官方插桩;prefork子进程在worker_process_init中初始化provider。producer也在其进程初始化CeleryInstrumentor,避免重复初始化。",
-        "code": "from opentelemetry import context\nfrom opentelemetry.propagate import extract\n\ndef publish_with_context(event, celery_app):\n    token = context.attach(extract(event.get(\"trace_context\") or {}))\n    try:\n        return celery_app.send_task(\n            \"notes.export\", args=[str(event[\"job_id\"])],\n            task_id=str(event[\"id\"]), queue=event[\"queue_name\"], retry=False,\n        )\n    finally:\n        context.detach(token)\n\n# worker进程初始化模块,确保被Celery导入:\nfrom celery.signals import worker_process_init\nfrom opentelemetry.instrumentation.celery import CeleryInstrumentor\nfrom trace_setup import setup_trace\n\n@worker_process_init.connect(weak=False)\ndef init_worker_trace(**kwargs):\n    setup_trace(\"notes-service-worker\")\n    CeleryInstrumentor().instrument()",
+        "body": "publisher领取事件时返回trace_context,在send_task外围attach/extract,finally detach。Celery消费者使用官方插桩;prefork子进程在worker_process_init中初始化provider。producer也在其进程初始化CeleryInstrumentor,避免重复初始化。 PyMySQL读取JSON列通常得到字符串,先json.loads再交给propagator;缺失值按空上下文处理。事件领取SELECT必须包含trace_context。",
+        "code": "import json\nfrom opentelemetry import context\nfrom opentelemetry.propagate import extract\n\ndef publish_with_context(event, celery_app):\n    raw = event.get(\"trace_context\")\n    carrier = json.loads(raw) if isinstance(raw,str) else (raw or {})\n    if not isinstance(carrier,dict):\n        raise ValueError(\"invalid trace context\")\n    carrier = {k:v for k,v in carrier.items() if k in {\"traceparent\",\"tracestate\"} and isinstance(v,str)}\n    token = context.attach(extract(carrier))\n    try:\n        return celery_app.send_task(\n            \"notes.export\", args=[str(event[\"job_id\"])],\n            task_id=str(event[\"id\"]), queue=event[\"queue_name\"], retry=False,\n        )\n    finally:\n        context.detach(token)\n\n# worker进程初始化模块,确保被Celery导入:\nfrom celery.signals import worker_process_init\nfrom opentelemetry.instrumentation.celery import CeleryInstrumentor\nfrom trace_setup import setup_trace\n\n@worker_process_init.connect(weak=False)\ndef init_worker_trace(**kwargs):\n    setup_trace(\"notes-service-worker\")\n    CeleryInstrumentor().instrument()",
         "lang": "python",
         "filename": "trace_worker.py"
       },
@@ -754,11 +754,11 @@
     "id": "08",
     "group": "进程、内存与依赖",
     "title": "定位慢 SQL、锁等待与连接池耗尽",
-    "summary": "从 pg_stat_activity 进入具体等待关系,使用短事务和隔离锁实验验证原因。",
+    "summary": "从MySQL会话、InnoDB事务和Performance Schema锁等待关系定位原因,再验证应用池与事务释放。",
     "objectives": [
-      "读取活动会话与阻塞PID",
-      "区分SQL执行慢和等待连接/锁",
-      "用参数化EXPLAIN与事务级超时限制诊断"
+      "关联连接ID、InnoDB事务与阻塞连接",
+      "区分应用池等待、SQL执行、行锁与元数据锁",
+      "用受控SELECT和会话预算完成诊断并回滚实验事务"
     ],
     "concepts": [
       {
@@ -766,44 +766,44 @@
         "body": "应用先等连接池,再发送SQL,数据库还可能等锁或I/O。只看到接口慢,不足以判断需要加索引。"
       },
       {
-        "title": "等待者与阻塞者要关联",
-        "body": "pg_stat_activity显示状态、事务年龄和wait_event,pg_blocking_pids定位阻塞来源。先看进程与关系,再决定是否需要查看脱敏SQL。"
+        "title": "连接空闲也可能持有事务",
+        "body": "SHOW PROCESSLIST中的Sleep表示当前没有执行语句,不能证明事务已结束。INFORMATION_SCHEMA.INNODB_TRX关联MySQL连接ID;performance_schema.data_lock_waits与data_locks关联InnoDB行锁,metadata_locks用于表结构等元数据锁。"
       },
       {
-        "title": "诊断SQL也可能执行实际工作",
-        "body": "EXPLAIN ANALYZE会真的执行查询,写操作还会产生副作用。先用EXPLAIN;隔离环境才能用已限制范围的ANALYZE,并设置合理statement_timeout和lock_timeout。"
+        "title": "超时作用范围不同",
+        "body": "innodb_lock_wait_timeout约束InnoDB行锁等待,不是所有锁或所有SQL的总超时。max_execution_time主要约束适用的只读SELECT。EXPLAIN ANALYZE会实际执行查询,只在受控小数据上使用,不把诊断当成零成本。"
       }
     ],
     "steps": [
       {
         "title": "读取活动会话和等待信息",
-        "body": "连接PostgreSQL16 notes_lab,app_user,宿主5433。通过安全配置的DATABASE_URL或pgpass连接,避免把密码贴入命令行。普通账户可见信息受权限限制,不把缺失字段当作没有活动。",
-        "code": "SELECT pid,usename,application_name,state,\n       now()-xact_start AS transaction_age,\n       now()-query_start AS query_age,\n       wait_event_type,wait_event,\n       pg_blocking_pids(pid) AS blocked_by\nFROM pg_stat_activity\nWHERE datname='notes_lab' AND pid<>pg_backend_pid()\nORDER BY xact_start NULLS LAST;",
+        "body": "在MySQL8.4练习实例用经授权的诊断账户执行,宿主3307。交互客户端使用-p提示输入密码或权限受控的选项文件。跨账户会话/Performance Schema可见性取决于权限,权限不足不等于没有阻塞;不要为排障把管理员权限授给API。保存连接ID、事务开始时间和等待关系,SQL正文分享前脱敏。",
+        "code": "SELECT CONNECTION_ID(),DATABASE(),@@transaction_isolation,@@session.time_zone;\nSHOW FULL PROCESSLIST;\nSELECT trx_id,trx_state,trx_started,trx_mysql_thread_id,trx_rows_locked\nFROM information_schema.innodb_trx ORDER BY trx_started;\nSELECT w.REQUESTING_ENGINE_TRANSACTION_ID AS waiting_trx,\n       w.BLOCKING_ENGINE_TRANSACTION_ID AS blocking_trx,\n       rt.PROCESSLIST_ID AS waiting_connection,\n       bt.PROCESSLIST_ID AS blocking_connection\nFROM performance_schema.data_lock_waits w\nLEFT JOIN performance_schema.threads rt ON rt.THREAD_ID=w.REQUESTING_THREAD_ID\nLEFT JOIN performance_schema.threads bt ON bt.THREAD_ID=w.BLOCKING_THREAD_ID;",
         "lang": "sql"
       },
       {
         "title": "在两会话复现有界锁等待",
-        "body": "只选本地演练笔记ID。会话A开启事务并锁定该行;会话B设置1秒lock_timeout后尝试锁同一行。观察锁等待错误,随后两个会话都ROLLBACK,释放故障条件。",
-        "code": "-- 会话A:将1替换为专门用于本次演练的笔记ID\nBEGIN;\nSELECT id FROM notes WHERE id=1 FOR UPDATE;\n-- 暂不提交,在会话B执行下面语句后回到A:ROLLBACK;\n\n-- 会话B\nBEGIN;\nSET LOCAL lock_timeout='1s';\nSELECT id FROM notes WHERE id=1 FOR UPDATE;\n-- 超时后:ROLLBACK;",
+        "body": "选本机专用笔记ID,会话A锁定一行,会话B设置1秒InnoDB锁等待预算再请求同一行。第三个诊断会话可先将B预算改为5秒观察等待关系,之后恢复1秒。默认锁等待超时通常仅回滚失败语句,不能当作事务已经释放;实验结束A/B均显式ROLLBACK。死锁可能回滚整笔事务,重试仍以完整业务事务为边界。",
+        "code": "-- 会话A:替换为专用笔记ID\nSTART TRANSACTION;\nSELECT id FROM notes WHERE id=1 FOR UPDATE;\n-- 暂不提交;完成B的观察后执行ROLLBACK。\n\n-- 会话B\nSET SESSION innodb_lock_wait_timeout=1;\nSTART TRANSACTION;\nSELECT id FROM notes WHERE id=1 FOR UPDATE;\n-- 出现1205后执行:\nROLLBACK;\n-- 最后回到A执行ROLLBACK,释放行锁。",
         "lang": "sql"
       },
       {
         "title": "先看计划,再在隔离数据上测执行",
-        "body": "下面EXPLAIN不实际执行SELECT。若需要ANALYZE,先确认只读、数据规模有限且本地可承受,再以事务级statement_timeout执行。不要在事故中直接对未知大表运行昂贵诊断。",
-        "code": "EXPLAIN (COSTS, VERBOSE)\nSELECT id,title FROM notes WHERE id=1;\n\n-- 仅隔离练习:\nBEGIN;\nSET LOCAL statement_timeout='2s';\nEXPLAIN (ANALYZE, BUFFERS)\nSELECT id,title FROM notes WHERE id=1;\nROLLBACK;",
+        "body": "先看非执行计划。需要EXPLAIN ANALYZE时,只在隔离小表执行只读SELECT并设置会话max_execution_time;该变量不是任意写SQL的硬超时。记录rows、实际迭代次数、索引及访问路径,不能仅凭使用索引就断定足够快。",
+        "code": "EXPLAIN FORMAT=TREE SELECT id,title FROM notes WHERE id=1;\n-- 仅隔离练习,客户端设置语句预算:\nSET SESSION max_execution_time=2000;\nEXPLAIN ANALYZE SELECT id,title FROM notes WHERE id=1;\nSET SESSION max_execution_time=0;",
         "lang": "sql"
       },
       {
         "title": "观察应用连接池预算",
-        "body": "若使用psycopg_pool,记录get_stats中的等待、请求和连接数,字段按安装版本核对。连接预算需乘以API worker和Celery进程数;不要只把每个池的max_size调大。等待连接时数据库可能没有相应新查询。",
-        "code": "# 使用现有psycopg_pool对象,只记录统计数字:\n# stats = pool.get_stats()\n# logger.info({\"event\": \"db_pool_stats\", \"stats\": stats})\n\n# 检查应用事务边界:\n# 获取连接 → 短SQL/事务 → 提交或回滚 → 归还连接\n# 不把网络上传、长await或CPU导出放在持有事务的区间。",
+        "body": "API复用SQLAlchemy AsyncAdaptedQueuePool,每个进程各自拥有池。以下函数只记录数字,不输出连接串;由已有监控采样逻辑调用。池等待需在实际获取连接周围单独计时,checkedout只能反映当前占用。同步任务模块按短事务打开PyMySQL连接,不虚构存在一个任务连接池。",
+        "code": "from app.db import engine\n\ndef pool_snapshot():\n    pool = engine.sync_engine.pool\n    return {\n        \"size\": pool.size(),\n        \"checked_out\": pool.checkedout(),\n        \"overflow\": pool.overflow(),\n    }\n\n# 由每个API进程的现有监控采样逻辑调用;不要另启动新进程观察一个空池。\n# 总预算 = API实例 × worker × (pool_size + max_overflow)\n#          + Celery执行进程 + publisher/recovery + 诊断/备份保留连接。",
         "lang": "python",
         "filename": "pool_observation.py"
       },
       {
         "title": "恢复后核对锁和业务请求",
-        "body": "回滚会话A后重试读取,应恢复正常;再次查询活动会话确认没有本次idle in transaction。记录谁持锁、谁等待、锁何时释放。未经确认不要终止任意数据库backend。",
-        "code": "SELECT pid,state,now()-xact_start AS transaction_age\nFROM pg_stat_activity\nWHERE datname='notes_lab' AND state='idle in transaction';",
+        "body": "A/B回滚后确认对应连接没有活跃InnoDB事务;再查询行锁等待与未授予元数据锁。Sleep本身不是错误,Sleep且有长事务才需要定位持有者。未经确认不终止他人连接;恢复门槛同时包含业务请求成功和等待消失。",
+        "code": "SELECT t.trx_mysql_thread_id AS connection_id,t.trx_state,t.trx_started,\n       p.COMMAND,p.TIME,p.STATE\nFROM information_schema.innodb_trx t\nLEFT JOIN information_schema.PROCESSLIST p ON p.ID=t.trx_mysql_thread_id;\nSELECT OBJECT_SCHEMA,OBJECT_NAME,LOCK_TYPE,LOCK_DURATION,OWNER_THREAD_ID\nFROM performance_schema.metadata_locks\nWHERE OBJECT_SCHEMA='notes_lab' AND LOCK_STATUS='PENDING';",
         "lang": "sql"
       }
     ],
@@ -814,14 +814,14 @@
     ],
     "pitfalls": [
       {
-        "symptom": "接口慢但pg_stat_activity没有对应查询",
-        "cause": "请求可能还在应用连接池排队。",
-        "fix": "检查连接归还、池等待统计和进程总连接预算。"
+        "symptom": "API慢但MySQL中没有相应执行语句",
+        "cause": "请求可能仍在应用连接池等待,尚未向数据库发送SQL。",
+        "fix": "观察同一API进程的池等待与借还连接,核对总连接预算和持有事务的上游调用。"
       },
       {
-        "symptom": "锁超时后继续在同一事务执行又报错",
-        "cause": "PostgreSQL事务已进入失败状态。",
-        "fix": "先ROLLBACK,再重新开始需要的事务;不要忽略异常后继续使用。"
+        "symptom": "锁等待超时后其他连接仍被阻塞",
+        "cause": "MySQL默认通常只回滚超时语句,原事务和先前获得的锁可能仍存在。",
+        "fix": "应用异常路径显式回滚完整业务事务,再以幂等和总预算决定是否重试;检查innodb_rollback_on_timeout实际设置。"
       }
     ],
     "exercise": {
@@ -831,20 +831,20 @@
     },
     "refs": [
       {
-        "label": "PostgreSQL活动统计",
-        "url": "https://www.postgresql.org/docs/16/monitoring-stats.html"
+        "label": "MySQL8.4:InnoDB事务监控",
+        "url": "https://dev.mysql.com/doc/refman/8.4/en/innodb-information-schema-transactions.html"
       },
       {
-        "label": "PostgreSQL锁",
-        "url": "https://www.postgresql.org/docs/16/explicit-locking.html"
+        "label": "MySQL8.4:锁等待关系",
+        "url": "https://dev.mysql.com/doc/refman/8.4/en/performance-schema-data-lock-waits-table.html"
       },
       {
-        "label": "PostgreSQL EXPLAIN",
-        "url": "https://www.postgresql.org/docs/16/using-explain.html"
+        "label": "MySQL8.4:EXPLAIN",
+        "url": "https://dev.mysql.com/doc/refman/8.4/en/explain.html"
       },
       {
-        "label": "Psycopg连接池",
-        "url": "https://www.psycopg.org/psycopg3/docs/advanced/pool.html"
+        "label": "SQLAlchemy连接池",
+        "url": "https://docs.sqlalchemy.org/en/20/core/pooling.html"
       }
     ],
     "production": {
@@ -992,14 +992,14 @@
     "steps": [
       {
         "title": "读取worker与路由状态",
-        "body": "在tasks_lab工作目录,读取正确Celery环境:独立broker宿主6384,容器内broker:6379,入口celery_app:app。inspect可能包含任务参数,本模块后期只传job_id;分享输出前核对。",
-        "code": "python -m celery -A celery_app:app status\npython -m celery -A celery_app:app inspect active\npython -m celery -A celery_app:app inspect reserved\npython -m celery -A celery_app:app inspect scheduled\npython -m celery -A celery_app:app inspect active_queues",
+        "body": "使用部署时相同的配置来源。以下 Bash 函数用于 Linux/systemd 路线,由 systemd 读取 /etc/notes-tasks.env;若采用 Docker,在 Compose 工程目录用注释中的函数替换 run_task 定义,再执行相同检查。保持此终端供本课恢复步骤复用。inspect 可能包含任务参数,分享前核对输出。",
+        "code": "# Linux/systemd 路线,在管理员的 Bash 终端定义:\nrun_task() {\n  sudo systemd-run --quiet --wait --collect --pipe \\\n    --property=User=notes --property=Group=notes \\\n    --property=WorkingDirectory=/srv/notes-service/current/tasks_lab \\\n    --property=EnvironmentFile=/etc/notes-tasks.env \\\n    /srv/notes-service/current/.venv/bin/python \"$@\"\n}\n# Docker 路线:在 Compose 工程目录,用以下定义替换上面的函数:\n# run_task() {\n#   docker compose --env-file .env.compose run --rm -T --no-deps worker \\\n#     python /app/deploy/run_role.py python \"$@\"\n# }\nrun_task -m celery -A celery_app:app status\nrun_task -m celery -A celery_app:app inspect active\nrun_task -m celery -A celery_app:app inspect reserved\nrun_task -m celery -A celery_app:app inspect scheduled\nrun_task -m celery -A celery_app:app inspect active_queues",
         "lang": "bash"
       },
       {
         "title": "把数据库阶段并排查询",
         "body": "使用SQL同时看业务状态、待发布事件和过期租约。queued很老只是排查线索,不足以直接断言消息已经丢失;按异步任务模块的派发记录与恢复门槛处理。",
-        "code": "SELECT status,count(*) AS total,\n       extract(epoch FROM now()-min(created_at)) AS oldest_seconds\nFROM export_jobs GROUP BY status;\n\nSELECT queue_name,count(*) AS pending,\n       extract(epoch FROM now()-min(created_at)) AS oldest_seconds\nFROM export_outbox WHERE sent_at IS NULL GROUP BY queue_name;\n\nSELECT id,attempts,lease_until,error_code\nFROM export_jobs WHERE status='running' AND lease_until<now()\nORDER BY lease_until LIMIT 20;",
+        "code": "SELECT status,count(*) AS total,\n       TIMESTAMPDIFF(SECOND,MIN(created_at),UTC_TIMESTAMP(6)) AS oldest_seconds\nFROM export_jobs GROUP BY status;\n\nSELECT queue_name,count(*) AS pending,\n       TIMESTAMPDIFF(SECOND,MIN(created_at),UTC_TIMESTAMP(6)) AS oldest_seconds\nFROM export_outbox WHERE sent_at IS NULL GROUP BY queue_name;\n\nSELECT id,attempts,lease_until,error_code\nFROM export_jobs WHERE status='running' AND lease_until<UTC_TIMESTAMP(6)\nORDER BY lease_until LIMIT 20;",
         "lang": "sql"
       },
       {
@@ -1010,8 +1010,8 @@
       },
       {
         "title": "恢复一个有证据的本地故障",
-        "body": "先确认下游恢复、容量可用和代码修复,再运行任务模块有限批次recovery与publisher。单步脚本不常驻;部署中的循环wrapper有独立节奏与停机管理。失败耗尽任务仅在确认修复后用replay_job逐条生成新job,保留旧失败证据。",
-        "code": "python recovery.py\npython publisher.py\n# 对已修复且明确的failed任务:\n# python replay_job.py <本次已核对的失败job_id>\n# 不运行celery purge,不通过重置attempts解除失败限制。",
+        "body": "先确认下游恢复、容量可用和代码修复,再运行任务模块有限批次recovery与publisher。单步脚本不常驻;部署中的循环wrapper有独立节奏与停机管理。失败耗尽任务仅在确认修复后用replay_job逐条生成新job,保留旧失败证据。 在第一步已定义 run_task 的同一 Bash 终端执行;新终端先重新定义所选部署路线的函数,确保配置和工作目录一致。",
+        "code": "run_task recovery.py\nrun_task publisher.py\n# 对已修复且明确的 failed 任务:\n# run_task replay_job.py <本次已核对的失败job_id>\n# 不运行 celery purge,不通过重置 attempts 解除失败限制。",
         "lang": "bash"
       },
       {

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 309 - 242
public/downloads/api-database-lessons.md


+ 1055 - 871
public/downloads/database-lessons.md

@@ -2,35 +2,35 @@
 
 术核 AI · 后端工程课件
 
-## 01 准备 PostgreSQL 与 psql 练习环境
+## 01 准备 MySQL 8.4 与命令行练习环境
 
-为 notes-service 建立独立练习库,区分数据库进程、数据库、角色与客户端。
+创建独立的 notes_lab,核对客户端、账号、字符集和会话参数。
 
 ### 本节目标
 
-- 启动 PostgreSQL 16 并创建非超级用户
-- 用 psql 明确连接地址、端口、角色和数据库
-- 区分连接失败、认证失败和 SQL 错误
+- 启动MySQL 8.4 LTS并用业务账号连接
+- 区分容器端口3306与宿主端口3307
+- 核对InnoDB、utf8mb4和当前数据库
 
 ### 必要概念
 
-#### 服务端与客户端
+#### 服务进程、数据库与客户端
 
-PostgreSQL 进程负责保存数据和执行 SQL;psql 是向它发送命令的终端工具。关闭 psql 不会停止数据库。一个 PostgreSQL 实例可以包含多个数据库,连接时必须确定要进入哪个库。
+mysqld持续保存和处理数据;mysql命令行客户端发送SQL。一个实例中可以创建多个数据库。MySQL中的SCHEMA与DATABASE在这里是同义词,账号则是用户名与来源host的组合。退出客户端不会停止服务。
 
-#### 角色与数据库分别创建
+#### 本机独立实验边界
 
-角色表示谁在连接,数据库表示在哪里读写。练习的 app_user 是 notes_lab 所有者,但不是超级用户。第 11 课再拆分迁移角色与运行时角色,避免 API 进程拥有修改表结构的权限。
+所有建表、写入、锁等待与恢复命令仅在notes_lab练习环境运行,不针对线上实例。需要Docker与Python 3.11+;Windows建议在WSL Bash中执行本模块Shell命令。本节只借Docker获得一致环境,后续容器模块再系统讲解。
 
-#### 实验边界
+#### 默认值也要核对
 
-本模块所有命令都是学员在独立 notes_lab 中执行的练习,不是让你操作线上数据库。需要 Python 3.11+ 和已安装、启动的 Docker。此处只用容器获得一致的 PostgreSQL 16 环境;镜像、卷与容器网络在 Docker 模块系统学习。
+本课程固定MySQL 8.4、InnoDB、utf8mb4;默认隔离级别通常是REPEATABLE-READ,后续实验显式设置。统一把DATETIME(6)中的业务时间解释为UTC,各客户端建立连接时设置+00:00。
 
 ### 动手实现
 
-#### 1. 建立项目与本机密码文件
+#### 1. 生成本机凭据并启动数据库
 
-以下为 Linux / macOS Bash 命令;Windows 可在 WSL 中执行。本节使用 127.0.0.1:5433,容器内部仍是 5432。密码只用于本机练习,不要提交到 Git。终端在 notes-service 中执行。
+在准备存放工程的父目录中执行以下 Bash,命令会创建并进入 notes-service。仅首次初始化时生成凭据;继续学习时进入既有目录并复用 .mysql.env,不要重复生成密码。镜像首次初始化会创建notes_lab和app_user,app_user只在该练习数据库获得权限,不是root。密码生成在忽略文件中,终端不打印;重新使用旧数据卷时环境变量不会重置已存在账号。
 
 文件:终端 / 当前课时
 
@@ -38,1830 +38,2014 @@ PostgreSQL 进程负责保存数据和执行 SQL;psql 是向它发送命令的
 mkdir -p notes-service
 cd notes-service
 umask 077
-printf 'POSTGRES_PASSWORD=%s\n' "$(python3 -c 'import secrets; print(secrets.token_urlsafe(24))')" > .postgres.env
-printf '.postgres.env\n.env\nbackups/\n.venv/\n' >> .gitignore
-docker run --name notes-postgres --env-file .postgres.env \
-  -p 127.0.0.1:5433:5432 \
-  -v notes_pgdata:/var/lib/postgresql/data -d postgres:16
-docker exec notes-postgres pg_isready -U postgres
+python3 - <<'PY'
+import secrets
+from pathlib import Path
+root_password=secrets.token_urlsafe(32)
+app_password=secrets.token_urlsafe(32)
+Path('.mysql.env').write_text(f'MYSQL_ROOT_PASSWORD={root_password}\nMYSQL_DATABASE=notes_lab\nMYSQL_USER=app_user\nMYSQL_PASSWORD={app_password}\n')
+PY
+printf '.mysql.env\n.mysql-client.cnf\n.env\nbackups/\n.venv/\n' >> .gitignore
+docker run --name notes-mysql --env-file .mysql.env \
+  -p 127.0.0.1:3307:3306 -v notes_mysql_data:/var/lib/mysql \
+  -d mysql:8.4 --character-set-server=utf8mb4 --collation-server=utf8mb4_0900_ai_ci --default-time-zone=+00:00
+docker logs --tail 30 notes-mysql
 ````
 
-#### 2. 创建练习角色和数据库
+#### 2. 用交互密码连接业务库
 
-等待 pg_isready 显示 accepting connections。先执行 docker exec -it notes-postgres psql -U postgres -d postgres,然后在该 psql 中执行下列命令。\password 会交互要求输入两遍 app_user 密码,不在 SQL 历史中直接写出密码。下面建库命令只执行一次。
+等待日志出现ready for connections。用编辑器读取本机.mysql.env中的MYSQL_PASSWORD用于交互登录,不粘贴到共享日志。第一条命令在容器内连接3306;本机安装了mysql客户端则用第二条,两条选择一条。
 
 文件:终端 / 当前课时
 
-````sql
-CREATE ROLE app_user LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE;
-\password app_user
-CREATE DATABASE notes_lab OWNER app_user;
-\q
+````bash
+docker exec -it notes-mysql mysql --protocol=TCP -h 127.0.0.1 -P 3306 -u app_user -p notes_lab
+# 或本机客户端:
+mysql --protocol=TCP -h 127.0.0.1 -P 3307 -u app_user -p notes_lab
 ````
 
-#### 3. 用业务身份重新连接
+#### 3. 确认环境与身份
 
-执行 docker exec -it notes-postgres psql -h 127.0.0.1 -U app_user -d notes_lab -W,输入刚设置的密码。该命令在容器内访问容器的 5432。若本机已安装 psql,则使用 psql -h 127.0.0.1 -p 5433 -U app_user -d notes_lab -W。后续未特别标明的 SQL 都在此连接运行。
+下面是mysql客户端中的SQL。DATABASE()必须是notes_lab,CURRENT_USER()显示匹配到的授权账号;不要只凭命令行提示符判断目标。默认排序规则ai_ci不区分重音和大小写,涉及唯一名称时要把此行为纳入设计。
 
 文件:终端 / 当前课时
 
 ````sql
-\conninfo
-SELECT current_database(), current_user, version();
-SHOW server_encoding;
-SHOW TimeZone;
-\set VERBOSITY verbose
-\timing on
+SELECT VERSION(),DATABASE(),CURRENT_USER(),USER();
+SELECT @@default_storage_engine,@@character_set_database,@@collation_database;
+SELECT @@transaction_isolation,@@autocommit,@@sql_mode;
+SET SESSION time_zone = '+00:00';
+SELECT @@session.time_zone,UTC_TIMESTAMP(6);
+SHOW GRANTS FOR CURRENT_USER();
 ````
 
-#### 4. 验证持久化与 SQL 命令边界
+#### 4. 验证客户端和事务开关
 
-运行两条 SELECT,观察分号用于结束 SQL,反斜杠命令由 psql 处理。退出客户端后重新连接,数据库仍存在。数据库卷保留了实例数据;不要为了重新做某节练习删除整个卷。
+mysql交互客户端通常autocommit=1,单条DML独立提交;Python稍后显式使用autocommit=False。不要将两个客户端的默认行为混为一谈。退出重连后再次确认数据库。
 
 文件:终端 / 当前课时
 
 ````sql
-SELECT 6 * 7 AS answer;
--- answer = 42
-SELECT current_database() = 'notes_lab' AS in_lab;
--- in_lab = t
-\q
+SELECT 6*7 AS answer;
+-- answer=42
+SELECT DATABASE()='notes_lab' AS in_lab;
+-- in_lab=1
+quit
 ````
 
 ### 验收结果
 
-- current_database() 为 notes_lab,current_user 为 app_user。
-- version() 显示 PostgreSQL 16,server_encoding 为 UTF8。
-- 退出并重新连接后 SELECT 6 * 7 仍能执行;数据库进程没有因客户端退出而停止。
+- 服务版本为8.4,数据库为notes_lab,存储引擎为InnoDB。
+- 客户端使用3307或容器内部3306的正确位置,业务身份不是root。
+- 字符集utf8mb4、会话UTC和隔离级别已实际核对。
 
 ### 生产约束与故障演练
 
-新服务需要一个可重复创建、不会误连线上实例的开发数据库。
+新服务需要能重复建立且不会误连线上库的开发环境。
 
 #### 实现决策
 
-- 连接串明确包含数据库名与端口。
-- 先用非超级用户练习,环境文件加入忽略规则。
-- 生产环境选择受维护的 16.x 补丁版并锁定镜像摘要;postgres:16 是本课程环境入口。
+- 显式核对数据库与授权账号。
+- 本机密码文件不进入仓库。
+- 生产记录8.4补丁版和镜像摘要,练习使用mysql:8.4入口。
 
 #### 隔离环境演练
 
-在独立练习环境中用错误端口尝试连接,依据 pg_isready 与实际端口定位原因。
+在独立实例用错库名与端口连接,分别记录错误层次。
 
 #### 恢复验收
 
-- 能从连接信息确认当前实例和身份。
-- 数据库卷与容器用途可区分。
-- 仓库没有密码文件。
+- 目标可辨认。
+- 环境与数据卷生命周期可区分。
+- 无共享明文密码。
 
 ### 常见错误
 
-#### connection refused
+#### Can't connect to MySQL server
 
-原因:容器未启动、端口尚未就绪或混用了宿主机与容器端口。
+原因:初始化尚未完成,或混淆宿主与容器端口。
 
-处理:用 docker ps 与 pg_isready 检查状态;本机使用 5433,docker exec 内部使用 5432。
+处理:查看专用容器日志;宿主3307,docker exec内部3306。
 
-#### role app_user does not exist 或 password authentication failed
+#### Access denied for user
 
-原因:创建角色步骤未执行,或输入的是 postgres 密码。
+原因:用户名/host匹配或密码不对;旧卷不会因env变化重设密码。
 
-处理:使用管理连接检查 \du;通过 \password app_user 重设本机练习密码,再用业务身份重连。
+处理:使用当前卷的真实凭据,管理账号确认账号host;需要改密时执行明确ALTER USER,不删卷解决。
 
 ### 扩展练习
 
-分别在一个正确连接和一个故意写错数据库名的连接中观察结果,记录错误发生在哪一层。
+故意用notes_missing连接,再与连接成功后SELECT 1/0比较错误层次。
 
-提示:将 notes_lab 暂时写成 notes_missing;不要真的创建这个库。
+提示:数据库名属于连接目标,SQL表达式在建立会话之后才执行。
 
 参考答案:
 
-正确连接能执行 SELECT;错误库名在连接阶段返回 database does not exist,SQL 尚未执行。连接地址错通常是网络层失败,密码错是认证失败,SELECT 1/0 则是连接成功后的 SQL 执行错误。
+不存在数据库通常为1049 Unknown database;认证失败常为1045。MySQL中SELECT 1/0通常返回NULL并产生警告,可用SHOW WARNINGS查看,不能把它当作稳定抛异常的事务测试。要测试约束失败请使用后续非法INSERT。
 
 ### 技术文档
 
-- [PostgreSQL 16:psql](https://www.postgresql.org/docs/16/app-psql.html)
-- [PostgreSQL 16:创建数据库](https://www.postgresql.org/docs/16/sql-createdatabase.html)
-- [Docker:PostgreSQL 官方镜像](https://hub.docker.com/_/postgres)
+- [MySQL 8.4:mysql客户端](https://dev.mysql.com/doc/refman/8.4/en/mysql.html)
+- [MySQL 8.4:账号名称](https://dev.mysql.com/doc/refman/8.4/en/account-names.html)
+- [Docker:MySQL镜像](https://hub.docker.com/_/mysql)
 
 ---
 
-## 02 定义笔记表、数据类型与约束
+## 02 定义InnoDB笔记表、UTC时间与数据约束
 
-把 Python 对象转成有明确边界的数据表,让非法数据在数据库入口被拒绝。
+创建统一notes模型,验证布尔值、标题和时间的真实MySQL行为。
 
 ### 本节目标
 
-- 建立 notes 表并理解主键
-- 选择 text、boolean 和 timestamptz
-- 通过 NOT NULL、CHECK 与默认值维护数据规则
+- 建立AUTO_INCREMENT主键和CHECK约束
+- 区分DATETIME存值约定与时区转换
+- 说明JSON、TEXT和BOOLEAN的存储边界
 
 ### 必要概念
 
-#### 列类型约束存储形式
+#### 类型与约束各自负责边界
 
-bigint 存储整数编号,varchar(120) 限制标题字符数,text 存储正文。boolean 使用 true / false,不能用随意约定的字符串。时间使用 timestamptz 表示一个时间点,显示格式受会话时区影响。
+id使用BIGINT AUTO_INCREMENT,标题VARCHAR(120),正文TEXT。MySQL BOOLEAN是TINYINT(1)同义词,单写BOOLEAN不会限制只能0或1,所以增加CHECK。MySQL 8.4会执行CHECK,不应沿用旧版本忽略CHECK的印象。
 
-#### 默认值与非空是两条规则
+#### UTC是明确的数据约定
 
-DEFAULT 只在插入时省略列或使用 DEFAULT 时生效;显式传 NULL 仍然是 NULL。NOT NULL 才拒绝空值,CHECK 再约束有效内容。应用校验改善错误提示,数据库约束保护所有写入入口。
+DATETIME(6)保存带微秒的日期时间字段,本身不保存时区,也不会因SET time_zone转换已有值。连接统一+00:00,写入使用UTC_TIMESTAMP(6)或UTC会话下的CURRENT_TIMESTAMP(6),Python输出时再明确标注UTC。
 
-#### 主键和生成编号
+#### 结构化扩展字段需限定用途
 
-identity 自动提供编号,PRIMARY KEY 保证编号唯一且非空。编号用于定位,不保证连续,也不代表严格的业务提交顺序。updated_at 的默认值只在插入时生效,更新时需要显式修改或使用统一触发器。
+JSON列验证文档语法,但不能替代需要连接、唯一性和外键的关系表。TEXT默认值使用表达式DEFAULT ('');正文上限16000字符,utf8mb4最坏64000字节仍在TEXT容量内;JSON和日志也应限制大小,避免无限载荷。updated_at不自动更新,本课程每次写入显式维护。
 
 ### 动手实现
 
-#### 1. 确认连接后创建表
+#### 1. 首次创建统一notes表
 
-在第 01 课的 app_user / notes_lab 连接中执行。保存为 sql/001_notes.sql,首次运行一次;后续变更使用单独迁移文件,不要反复删表重建。
+在app_user/notes_lab下执行一次,保存为sql/001_notes.sql。version从第一版就存在,后续并发修改统一递增。所有业务表明确ENGINE=InnoDB,不依赖不同环境的隐含配置。
 
 文件:sql/001_notes.sql
 
 ````sql
 CREATE TABLE notes (
-  id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
-  title varchar(120) NOT NULL CHECK (char_length(btrim(title)) > 0),
-  content text NOT NULL DEFAULT '',
-  pinned boolean NOT NULL DEFAULT false,
-  created_at timestamptz NOT NULL DEFAULT now(),
-  updated_at timestamptz NOT NULL DEFAULT now()
-);
-\d notes
+ id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
+ title VARCHAR(120) NOT NULL,
+ content TEXT NOT NULL DEFAULT (''),
+ pinned BOOLEAN NOT NULL DEFAULT FALSE,
+ created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
+ updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
+ version INT NOT NULL DEFAULT 1,
+ CONSTRAINT notes_title_nonempty CHECK (CHAR_LENGTH(TRIM(title))>0),
+ CONSTRAINT notes_pinned_boolean CHECK (pinned IN (0,1)),
+ CONSTRAINT notes_content_limit CHECK (CHAR_LENGTH(content)<=16000),
+ CONSTRAINT notes_version_positive CHECK (version>0)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
+SHOW CREATE TABLE notes;
 ````
 
-#### 2. 插入合法数据并看默认值
+#### 2. 插入并从同一会话取得编号
 
-本节写入的标题用 db02- 前缀,避免与后续实验混淆。RETURNING 在同一次写入中返回实际保存的行。记录生成的编号,但后续练习不依赖它必须从 1 开始。
+MySQL 8.4不提供本模块DML的RETURNING语法。插入后使用同一会话LAST_INSERT_ID(),Python使用cursor.lastrowid;绝不能查询MAX(id)猜自己的编号。
 
 文件:终端 / 当前课时
 
 ````sql
-INSERT INTO notes (title) VALUES ('db02-first')
-RETURNING id, title, content, pinned, created_at, updated_at;
--- content 为空串,pinned 为 f,两列时间均非空
-SELECT count(*) FROM notes WHERE title = 'db02-first';
+INSERT INTO notes(title) VALUES('db02-first');
+SET @note_id=LAST_INSERT_ID();
+SELECT id,title,content,pinned,version,created_at,updated_at FROM notes WHERE id=@note_id;
+-- content空串,pinned=0,version=1,时间非空
 ````
 
-#### 3. 让数据库拒绝非法数据
+#### 3. 逐条提交非法值观察错误
 
-以下两条语句在 psql 默认自动提交状态逐条执行;预期分别失败,不要包进同一个 BEGIN 后继续运行。随后 count(*) 仍为 1。
+在交互客户端autocommit=1时逐条运行,预期各自失败。先确认STRICT_TRANS_TABLES包含在sql_mode,超长数据不能静默截断。随后核对只有合法样本。
 
 文件:终端 / 当前课时
 
 ````sql
-INSERT INTO notes (title) VALUES (NULL);
--- SQLSTATE 23502: not_null_violation
-INSERT INTO notes (title) VALUES ('   ');
--- SQLSTATE 23514: check_violation
-SELECT count(*) FROM notes WHERE title = 'db02-first';
+INSERT INTO notes(title) VALUES(NULL);
+-- 1048:列不可为NULL
+INSERT INTO notes(title) VALUES('   ');
+-- 3819:CHECK失败
+INSERT INTO notes(title,pinned) VALUES('db02-invalid-bool',2);
+-- 3819:BOOLEAN底层是整数,CHECK拦住2
+INSERT INTO notes(title) VALUES(REPEAT('a',121));
+-- 严格模式下1406:数据过长
+SELECT id,title FROM notes WHERE id=@note_id;
 ````
 
-#### 4. 观察同一时间点的时区显示
+#### 4. 验证DATETIME不会随显示时区转换
 
-这里改的是当前会话显示方式。UTC 与上海时间显示相差 8 小时,epoch 秒数相同。业务对外使用带时区 ISO 8601,不能在 Python 中去掉 tzinfo 后再猜测时区。
+同一created_at在两个会话时区设置下保持原字段值,而NOW(6)随会话时区变化、UTC_TIMESTAMP(6)保持UTC。不要把没有tzinfo的数据库datetime直接当作用户本地时间。最后恢复+00:00。
 
 文件:终端 / 当前课时
 
 ````sql
-SET TIME ZONE 'UTC';
-SELECT created_at, extract(epoch FROM created_at) FROM notes WHERE title='db02-first';
-SET TIME ZONE 'Asia/Shanghai';
-SELECT created_at, extract(epoch FROM created_at) FROM notes WHERE title='db02-first';
-SET TIME ZONE 'UTC';
+SET SESSION time_zone='+00:00';
+SELECT created_at,NOW(6),UTC_TIMESTAMP(6) FROM notes WHERE id=@note_id;
+SET SESSION time_zone='+08:00';
+SELECT created_at,NOW(6),UTC_TIMESTAMP(6) FROM notes WHERE id=@note_id;
+SET SESSION time_zone='+00:00';
+SELECT JSON_EXTRACT('{"source":"api","revision":1}','$.source') AS source;
+-- "api":JSON字符串与SQL普通字符串的输出表示不同
 ````
 
 ### 验收结果
 
-- \d notes 显示主键、identity、非空与标题 CHECK。
-- 省略 content 和 pinned 时得到默认值;显式 NULL 标题被拒绝。
-- 切换时区后显示不同,epoch 值保持一致。
+- SHOW CREATE TABLE确认InnoDB、CHECK、version和utf8mb4。
+- 非法NULL、空标题和pinned=2被拒绝,默认值与主键正确。
+- 切换时区时DATETIME原值不变,能解释UTC存值约定。
 
 ### 生产约束与故障演练
 
-同一张表将被 API、导入脚本和后台任务共同写入。
+API、脚本和worker共同写入同一张表。
 
 #### 实现决策
 
-- 关键合法性规则落到数据库。
-- 编号只当标识符,不用于推算数据量。
-- 统一存时间点、在展示层选择时区。
+- 完整性规则放到数据库。
+- UTC作为所有写入入口的共同约定。
+- 编号不保证连续,updated_at与version由统一写入代码维护。
 
 #### 隔离环境演练
 
-向 notes_lab 提交空标题、NULL 和超长标题,逐条记录 SQLSTATE。
+向notes_lab提交非法标题和布尔整数,记录错误码与未落库结果。
 
 #### 恢复验收
 
-- 非法行没有落库。
-- 合法写入能返回真实生成值。
-- 能说明 updated_at 的维护位置。
+- 约束真实生效。
+- 时间序列化规则一致。
+- 没有依赖自增编号连续性。
 
 ### 常见错误
 
-#### UPDATE 后 updated_at 没变化
+#### pinned保存成2也成功
 
-原因:DEFAULT now() 只参与插入。
+原因:只声明BOOLEAN却没有CHECK。
 
-处理:后续每次更新同时设置 updated_at=now();多人维护时把规则收敛到统一写入层或触发器。
+处理:定义CHECK(pinned IN(0,1)),应用入口也按布尔校验。
 
-#### 空标题进入数据库
+#### 序列化时间出现8小时偏差
 
-原因:只使用了 NOT NULL,空串不是 NULL。
+原因:把UTC约定的无时区DATETIME当本地时间解释。
 
-处理:加入 btrim 与长度 CHECK,并在应用侧做一致校验;已有不合格数据需先清理。
+处理:统一连接+00:00,读出后按已知UTC约定加timezone.utc,输出带时区ISO8601。
 
 ### 扩展练习
 
-验证 121 个字符的标题被拒绝,再插入带单引号的合法标题。
+比较JSON扩展属性与tags关系表,说明标签唯一性应该放在哪里。
 
-提示:SQL 字面量中的单引号用两个单引号表示;Python 动态值在下一课使用参数化。
+提示:JSON适合弱结构扩展,不自动提供跨行唯一和外键规则。
 
 参考答案:
 
-INSERT INTO notes(title) VALUES(repeat('a',121)); 会触发长度错误。INSERT INTO notes(title) VALUES('db02-O''Reilly') RETURNING title; 返回 db02-O'Reilly。SQL 中的引号转义不替代 Python 驱动的参数化接口。
+标签名在tags表使用UNIQUE,笔记标签在关联表维护外键。少量无需关系查询的附加属性可用JSON;热点JSON路径需显式生成列或函数索引并验证计划。
 
 ### 技术文档
 
-- [PostgreSQL 16:约束](https://www.postgresql.org/docs/16/ddl-constraints.html)
-- [PostgreSQL 16:日期时间类型](https://www.postgresql.org/docs/16/datatype-datetime.html)
-- [PostgreSQL 16:生成列编号](https://www.postgresql.org/docs/16/sql-createtable.html)
+- [MySQL 8.4:CREATE TABLE与CHECK](https://dev.mysql.com/doc/refman/8.4/en/create-table.html)
+- [MySQL 8.4:时间类型](https://dev.mysql.com/doc/refman/8.4/en/date-and-time-types.html)
+- [MySQL 8.4:JSON](https://dev.mysql.com/doc/refman/8.4/en/json.html)
 
 ---
 
-## 03 用参数化 SQL 完成增删改查
+## 03 用PyMySQL参数化完成增删改查
 
-从 Python 读写笔记,处理不存在的数据,并验证用户输入不会变成 SQL。
+建立显式提交、回滚和关闭的Python访问层,用lastrowid与rowcount确认结果。
 
 ### 本节目标
 
-- 使用 psycopg 3 传递参数
-- 通过 RETURNING 判断实际写入结果
-- 用明确列名、过滤与排序控制查询
+- 使用DictCursor和独立参数元组
+- 正确读取lastrowid和受影响行数
+- 在成功与异常路径释放事务和连接
 
 ### 必要概念
 
-#### SQL 结构与参数分开传递
+#### 动态值通过驱动参数传递
 
-execute 的 SQL 字符串描述操作结构,第二个参数提供数据。值占位符统一写 %s,不自行加引号,也不用 f-string 拼接输入。表名、列名不能作为普通值参数传入,动态排序应使用固定白名单。
+PyMySQL execute(sql,params)的%s表示值,不加引号、不用f-string替换用户输入。标识符不是值参数,动态列名和排序方向用固定白名单。连接默认禁用多语句,不启用MULTI_STATEMENTS。
 
-#### 写入结果要检查
+#### MySQL写入结果的读取方法
 
-UPDATE 或 DELETE 匹配不到数据通常不是 SQL 错误。RETURNING 返回零行能表示资源不存在;不要在未检查结果时声称修改成功。
+INSERT后从当前cursor.lastrowid取得生成编号,再SELECT同一行。UPDATE要根据rowcount及业务语义判断:默认统计实际变化行,相同值可能返回0;本课每次更新递增version,因此成功匹配一定发生变化。
 
-#### 读取结果必须有边界
+#### 显式管理生命周期
 
-查询显式列名能稳定输出结构。列表查询需要 LIMIT 和确定排序,否则数据库不承诺返回顺序。id DESC 可作为当前课程按编号倒序的简单规则。
+PyMySQL使用autocommit=False。本课完整脚本try中commit、except中rollback、finally中close,cursor上下文只关闭cursor,不代替数据库提交。连接读写超时是客户端等待预算,不等同于服务端事务整体超时。
 
 ### 动手实现
 
-#### 1. 安装驱动与设置连接
+#### 1. 安装同步驱动并设置环境
 
-在 notes-service 创建并激活 Python 3.11+ 虚拟环境,执行 python -m pip install "psycopg[binary]>=3.2,<4"。把实际解析版本写入 requirements-lock.txt。Linux 使用下面的 PG 环境变量,PGPASSWORD 在当前 shell 中交互输入,不写进脚本。正式环境使用密钥注入并避免输出连接串。
+Bash沿用第01课生成文件。set -a与source只用于自己生成的本机文件,不source不可信文件。MySQL 8.4默认认证使用caching_sha2_password,安装PyMySQL的rsa附加依赖用于无TLS练习连接;生产应配置可信TLS。
 
 文件:终端 / 当前课时
 
 ````bash
-export PGHOST=127.0.0.1 PGPORT=5433 PGDATABASE=notes_lab PGUSER=app_user
-read -rsp 'Database password: ' PGPASSWORD
-printf '\n'
-export PGPASSWORD
+python -m pip install 'PyMySQL[rsa]>=1.1,<2'
+set -a
+source .mysql.env
+set +a
+unset MYSQL_ROOT_PASSWORD
+export MYSQL_HOST=127.0.0.1 MYSQL_PORT=3307
 python -m pip freeze > requirements-lock.txt
 ````
 
-#### 2. 保存并运行完整 CRUD 脚本
+#### 2. 保存统一连接函数
+
+保存mysql_client.py;后续Redis模块直接复用。max_execution_time主要限制SELECT,innodb_lock_wait_timeout处理行锁,lock_wait_timeout处理元数据锁;它们不是统一的所有SQL执行超时。生产另配置请求deadline和慢查询观测。此直接驱动入口使用MYSQL_*,同步SQLAlchemy连接串才是mysql+pymysql://。
+
+文件:mysql_client.py
 
-保存为 db_crud.py,再执行 python db_crud.py。connect("") 读取 PG 环境变量;上下文正常退出提交,异常退出回滚。单元素参数必须带逗号。此脚本先创建自己的数据,最后删除同一行,可以重复执行。
+````python
+import os
+import pymysql
+from pymysql.cursors import DictCursor
+
+def connect():
+    conn = pymysql.connect(
+        host=os.environ.get("MYSQL_HOST", "127.0.0.1"),
+        port=int(os.environ.get("MYSQL_PORT", "3307")),
+        user=os.environ.get("MYSQL_USER", "app_user"),
+        password=os.environ["MYSQL_PASSWORD"],
+        database=os.environ.get("MYSQL_DATABASE", "notes_lab"),
+        charset="utf8mb4", cursorclass=DictCursor, autocommit=False,
+        connect_timeout=3, read_timeout=5, write_timeout=5,
+    )
+    try:
+        with conn.cursor() as cur:
+            cur.execute("SET SESSION time_zone = '+00:00'")
+            cur.execute("SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED")
+            cur.execute("SET SESSION innodb_lock_wait_timeout = 2")
+            cur.execute("SET SESSION lock_wait_timeout = 3")
+            cur.execute("SET SESSION max_execution_time = 3000")
+        conn.commit()
+        return conn
+    except BaseException:
+        conn.close()
+        raise
+
+````
+
+#### 3. 执行完整CRUD并显式结束事务
+
+保存db_crud.py并运行。自建样本最后删除,可以重复执行。读取和修改都只使用自己的lastrowid;同一SQL更新version与updated_at。打印的pinned通常是0/1,业务JSON应按模型转成bool。
 
 文件:db_crud.py
 
 ````python
-import psycopg
-from psycopg.rows import dict_row
-
-with psycopg.connect("", row_factory=dict_row) as conn:
-    created = conn.execute(
-        "INSERT INTO notes(title, content) VALUES (%s, %s) RETURNING id, title",
-        ("db03-O'Reilly", "parameterized insert"),
-    ).fetchone()
-    note_id = created["id"]
-    print("created", created)
-    print("read", conn.execute(
-        "SELECT id, title, pinned FROM notes WHERE id = %s", (note_id,)
-    ).fetchone())
-    print("updated", conn.execute(
-        "UPDATE notes SET pinned = %s, updated_at = now() WHERE id = %s RETURNING id, pinned",
-        (True, note_id),
-    ).fetchone())
-    print("deleted", conn.execute(
-        "DELETE FROM notes WHERE id = %s RETURNING id", (note_id,)
-    ).fetchone())
-    print("missing", conn.execute(
-        "SELECT id FROM notes WHERE id = %s", (note_id,)
-    ).fetchone())
+from mysql_client import connect
+
+conn=connect()
+try:
+    with conn.cursor() as cur:
+        cur.execute("INSERT INTO notes(title,content) VALUES(%s,%s)",("db03-O'Reilly","parameterized"))
+        note_id=cur.lastrowid
+        cur.execute("SELECT id,title,pinned,version FROM notes WHERE id=%s",(note_id,))
+        print("created",cur.fetchone())
+        cur.execute("UPDATE notes SET pinned=%s,version=version+1,updated_at=UTC_TIMESTAMP(6) WHERE id=%s",(True,note_id))
+        assert cur.rowcount==1
+        cur.execute("SELECT id,pinned,version FROM notes WHERE id=%s",(note_id,))
+        print("updated",cur.fetchone())
+        cur.execute("DELETE FROM notes WHERE id=%s",(note_id,))
+        assert cur.rowcount==1
+        cur.execute("SELECT id FROM notes WHERE id=%s",(note_id,))
+        assert cur.fetchone() is None
+    conn.commit()
+except BaseException:
+    conn.rollback()
+    raise
+finally:
+    conn.close()
 ````
 
-#### 3. 提交一段看起来像 SQL 的标题
+#### 4. 验证SQL片段只成为普通文本
 
-单独保存为 db_input.py 并运行。预期只插入一条普通笔记,输入里的 DROP TABLE 不会执行。finally 外层连接上下文会正常提交这个插入和删除的组合;最后 notes 表仍能查询。
+保存db_input.py。输入中包含单引号和DROP片段,驱动将其作为值处理;读取原值后回滚专用插入,不执行任何表删除命令。注意finally没有代替rollback。
 
 文件:db_input.py
 
 ````python
-import psycopg
-
-payload = "db03-x'); DROP TABLE notes; --"
-with psycopg.connect("") as conn:
-    row = conn.execute(
-        "INSERT INTO notes(title) VALUES (%s) RETURNING id, title", (payload,)
-    ).fetchone()
-    assert row[1] == payload
-    print(row)
-    conn.execute("DELETE FROM notes WHERE id = %s", (row[0],))
-    print("table exists", conn.execute("SELECT count(*) FROM notes").fetchone())
+from mysql_client import connect
+
+payload="db03-x'); DROP TABLE notes; --"
+conn=connect()
+try:
+    with conn.cursor() as cur:
+        cur.execute("INSERT INTO notes(title) VALUES(%s)",(payload,))
+        note_id=cur.lastrowid
+        cur.execute("SELECT title FROM notes WHERE id=%s",(note_id,))
+        assert cur.fetchone()["title"]==payload
+    conn.rollback()  # 仅验收,不保留样本
+except BaseException:
+    conn.rollback()
+    raise
+finally:
+    conn.close()
 ````
 
-#### 4. 练习有边界的列表查询
+#### 5. 明确列表边界与不存在结果
 
-回到 psql 执行,记录结果列与顺序。修改 LIMIT 观察数量变化。对照 Python 中的写法:LIMIT 也可用 %s 传值,但应用应限制 1 到 100,不能让客户端请求任意数量。
+回到mysql客户端执行。列表使用确定排序和LIMIT,更新不存在编号得到0 affected rows;PyMySQL不能用execute返回值当作整条数据库记录。生产API再将未找到与版本冲突区分成约定响应。
 
 文件:终端 / 当前课时
 
 ````sql
-SELECT id, title, pinned
-FROM notes
-WHERE pinned = false
-ORDER BY id DESC
-LIMIT 10;
-UPDATE notes SET pinned = true WHERE id = -1 RETURNING id;
--- 0 rows / UPDATE 0,不是 SQL 异常
+SELECT id,title,pinned FROM notes WHERE pinned=0 ORDER BY id DESC LIMIT 10;
+UPDATE notes SET pinned=1,version=version+1 WHERE id=-1;
+SELECT ROW_COUNT() AS changed_rows;
+-- 0
 ````
 
 ### 验收结果
 
-- CRUD 输出依次出现同一编号,最后 missing 为 None。
-- 含单引号和 SQL 片段的标题保持原样,notes 表仍存在。
-- 不存在编号的 UPDATE 返回零行;列表最多 10 行且编号递减。
+- CRUD输出使用同一生成编号,更新后version增加。
+- 输入SQL片段保持文本,数据库表仍可访问。
+- 每条完整脚本都有commit或显式回滚,以及finally关闭连接。
 
 ### 生产约束与故障演练
 
-笔记标题来自浏览器,数据库不能把任何一段用户输入当成可执行语法。
+浏览器输入将进入数据库,写入成功必须有可确认的结果。
 
 #### 实现决策
 
-- 参数化所有动态值。
-- 列表显式限制数量和排序。
-- 以 RETURNING 判断变更结果。
+- 固定SQL与独立参数。
+- 默认不开启多语句。
+- 不在发生不确定写入结果后无条件重试。
 
 #### 隔离环境演练
 
-在 notes_lab 用包含单引号与 SQL 片段的标题创建笔记,核验原值与表完整性。
+对专用样本提交包含SQL语法的标题,再回滚并查询表完整性。
 
 #### 恢复验收
 
-- 没有 SQL 字符串插值。
-- 空结果有明确处理。
-- 连接与事务在脚本结束后释放。
+- 无SQL插值。
+- 空结果和行数语义正确。
+- 异常路径不泄漏连接。
 
 ### 常见错误
 
-#### syntax error 或用户输入改变了 SQL 含义
+#### 读取到的是1而不是记录字典
 
-原因:把输入放进 f-string 或字符串拼接。
+原因:把cursor.execute返回的受影响行数当作查询数据。
 
-处理:恢复固定 SQL 和独立参数元组;不要手写通用转义函数。
+处理:SELECT后使用fetchone/fetchall,INSERT后使用lastrowid。
 
-#### 脚本输出成功但另一个连接查不到新增行
+#### UPDATE相同内容返回0,被误判为不存在
 
-原因:事务尚未提交,或者最后主动删除了练习行。
+原因:默认受影响行数反映实际修改。
 
-处理:确认连接上下文已经退出;若只验证插入,暂时移除删除步骤后在独立连接核查。
+处理:使用版本递增的条件更新,或明确启用FOUND_ROWS并理解行为;需要区分不存在与未变更时单独查询。
 
 ### 扩展练习
 
-写一个只更新标题的函数,返回更新后的 id/title;不存在时返回 None。
+写一个rename_note(conn,id,title,expected_version),返回是否修改成功,函数不自行提交。
 
-提示:WHERE id = %s,并在同一 UPDATE 中 RETURNING id, title。
+提示:把WHERE版本比较、版本递增和时间更新放到一条UPDATE。
 
 参考答案:
 
-def rename_note(conn, note_id, title):
-    return conn.execute("UPDATE notes SET title=%s, updated_at=now() WHERE id=%s RETURNING id,title", (title,note_id)).fetchone()
-调用方决定事务提交边界;函数不私自创建连接或提交。
+with conn.cursor() as cur:
+    cur.execute("UPDATE notes SET title=%s,version=version+1,updated_at=UTC_TIMESTAMP(6) WHERE id=%s AND version=%s",(title,id,expected_version))
+    return cur.rowcount==1
+外层负责commit/rollback/close。更新0行可能不存在或冲突,需要时再读取区分。
 
 ### 技术文档
 
-- [Psycopg 3:参数传递](https://www.psycopg.org/psycopg3/docs/basic/params.html)
-- [Psycopg 3:基本使用](https://www.psycopg.org/psycopg3/docs/basic/usage.html)
-- [PostgreSQL 16:RETURNING](https://www.postgresql.org/docs/16/dml-returning.html)
+- [PyMySQL:使用示例](https://pymysql.readthedocs.io/en/latest/user/examples.html)
+- [PyMySQL:连接参数](https://pymysql.readthedocs.io/en/latest/modules/connections.html)
+- [MySQL 8.4:LAST_INSERT_ID](https://dev.mysql.com/doc/refman/8.4/en/information-functions.html)
 
 ---
 
-## 04 用关联表、JOIN 与聚合表达关系
+## 04 使用外键、JOIN与聚合建模标签
 
-为笔记添加标签,使用外键保证关联有效,再统计每条笔记的标签数量。
+建立InnoDB多对多关系,验证唯一关联、外键索引和删除约束。
 
 ### 本节目标
 
-- 建立多对多关系和联合主键
-- 区别 INNER JOIN 与 LEFT JOIN
-- 避免 JOIN 重复行导致聚合错误
+- 设计tags和note_tags关系表
+- 用LEFT JOIN保留零标签笔记
+- 理解MySQL外键即时检查与索引要求
 
 ### 必要概念
 
-#### 多对多关系单独建表
+#### 联合主键表达一组关系
 
-一条笔记可有多个标签,一个标签可属于多条笔记。note_tags 的每一行代表一组关联,联合主键阻止重复关联。将标签塞进逗号字符串会让检索、唯一性与改名都变得困难。
+note_tags一行表示一条笔记与一个标签的关联,PRIMARY KEY(note_id,tag_id)防重复。标签名UNIQUE遵循表排序规则,默认ai_ci下Python和python可能冲突,需要时选择明确的区分大小写排序规则。
 
-#### 外键检查关联完整性
+#### InnoDB即时检查外键
 
-note_id 必须对应真实笔记,tag_id 必须对应真实标签。此例删除笔记时级联清理关联行,删除仍被使用的标签则拒绝;删除策略来自业务,而不是所有外键统一加 CASCADE。
+父记录必须先存在;MySQL不支持将此类外键检查推迟到COMMIT。InnoDB要求外键列有合适前缀索引,缺少时可能自动创建;本课显式声明tag_id索引便于审计。不要通过foreign_key_checks=0绕过正常业务写入。
 
-#### 聚合前先辨认行数
+#### LEFT JOIN后的计数要选列
 
-LEFT JOIN 会保留没有标签的笔记,并补一个右侧为空的连接结果。COUNT(*) 会把这一行算进去,COUNT(nt.tag_id) 只统计非空标签。聚合查询返回的是分组后的结果。
+未匹配标签的笔记仍有一条右侧NULL行。COUNT(*)会计为1,COUNT(nt.tag_id)得到0。连接更多一对多关系时注意行数放大,不直接对放大后的记录计数。
 
 ### 动手实现
 
-#### 1. 建立标签和关联表
+#### 1. 创建父表与关联表
 
-在 notes_lab 首次执行一次;保存为 sql/002_tags.sql。引用 notes 主键,不复制标题。外键列并不会自动获得适合所有反向查询的索引,后续根据查询创建。
+保存sql/002_tags.sql,在notes_lab首次执行一次。删除笔记级联清理关联;被引用标签使用RESTRICT。显式存储引擎与字符集,避免外键因表属性不一致失败。
 
 文件:sql/002_tags.sql
 
 ````sql
 CREATE TABLE tags (
-  id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
-  name varchar(40) NOT NULL UNIQUE CHECK (char_length(btrim(name)) > 0)
-);
+ id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
+ name VARCHAR(40) NOT NULL UNIQUE,
+ CONSTRAINT tags_name_nonempty CHECK(CHAR_LENGTH(TRIM(name))>0)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
 CREATE TABLE note_tags (
-  note_id bigint NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
-  tag_id bigint NOT NULL REFERENCES tags(id) ON DELETE RESTRICT,
-  PRIMARY KEY (note_id, tag_id)
-);
+ note_id BIGINT NOT NULL, tag_id BIGINT NOT NULL,
+ PRIMARY KEY(note_id,tag_id),
+ INDEX note_tags_tag_id_idx(tag_id),
+ CONSTRAINT nt_note_fk FOREIGN KEY(note_id) REFERENCES notes(id) ON DELETE CASCADE,
+ CONSTRAINT nt_tag_fk FOREIGN KEY(tag_id) REFERENCES tags(id) ON DELETE RESTRICT
+) ENGINE=InnoDB;
 ````
 
-#### 2. 准备两条笔记和两个标签
+#### 2. 在当前会话准备独立样本
 
-以下是 psql 脚本;\gset 将 RETURNING 值写进本会话变量。创建笔记返回语句末尾不加分号,由 \gset 执行。标签可以重复准备,笔记每次执行都会产生两条独立样本。
+@tagged_id与@empty_id是当前mysql会话变量,换窗口需要重新设置。标签插入只首次运行;重复练习可直接复用已有两个标签,不使用INSERT IGNORE吞掉其他约束错误。
 
 文件:终端 / 当前课时
 
 ````sql
-INSERT INTO notes(title) VALUES ('db04-with-tags') RETURNING id AS tagged_id \gset
-INSERT INTO notes(title) VALUES ('db04-no-tags') RETURNING id AS empty_id \gset
-INSERT INTO tags(name) VALUES ('db04-python'), ('db04-api') ON CONFLICT (name) DO NOTHING;
+INSERT INTO tags(name) VALUES('db04-python'),('db04-api');
+INSERT INTO notes(title) VALUES('db04-with-tags');
+SET @tagged_id=LAST_INSERT_ID();
+INSERT INTO notes(title) VALUES('db04-no-tags');
+SET @empty_id=LAST_INSERT_ID();
 INSERT INTO note_tags(note_id,tag_id)
-SELECT :tagged_id, id FROM tags WHERE name IN ('db04-python','db04-api');
+SELECT @tagged_id,id FROM tags WHERE name IN ('db04-python','db04-api');
 ````
 
-#### 3. 分别查看关联明细和数量
+#### 3. 读取明细与零标签统计
 
-第一条查询得到 2 行标签明细。第二条查询保留无标签笔记,数量分别为 2 与 0。注意按 id 筛选的是上一步新建的两行,不受此前重复运行影响。
+第一条2行明细,第二条两个笔记的标签数分别2与0。group by显式包含所选笔记列,与默认ONLY_FULL_GROUP_BY一致。
 
 文件:终端 / 当前课时
 
 ````sql
-SELECT n.id,n.title,t.name
-FROM notes n JOIN note_tags nt ON nt.note_id=n.id
-JOIN tags t ON t.id=nt.tag_id
-WHERE n.id=:tagged_id ORDER BY t.name;
-
-SELECT n.id,n.title,count(nt.tag_id) AS tag_count
-FROM notes n LEFT JOIN note_tags nt ON nt.note_id=n.id
-WHERE n.id IN (:tagged_id,:empty_id)
+SELECT n.id,n.title,t.name FROM notes n
+JOIN note_tags nt ON nt.note_id=n.id JOIN tags t ON t.id=nt.tag_id
+WHERE n.id=@tagged_id ORDER BY t.name;
+SELECT n.id,n.title,COUNT(nt.tag_id) AS tag_count FROM notes n
+LEFT JOIN note_tags nt ON nt.note_id=n.id
+WHERE n.id IN (@tagged_id,@empty_id)
 GROUP BY n.id,n.title ORDER BY n.id;
 ````
 
-#### 4. 验证重复关联和删除策略
+#### 4. 验证即时约束和级联删除
 
-先单独执行重复 INSERT,预期 23505 唯一性错误;再单独执行 DELETE tags,预期 23503 外键错误。最后删除本课带标签笔记,对应关联行自动清理。只删除本会话创建的样本。
+逐条执行预期失败语句。重复关联1062,删除被引用标签1451,不存在父标签的关联1452。最后删除本课带标签笔记,关联消失而标签本身仍保留。
 
 文件:终端 / 当前课时
 
 ````sql
 INSERT INTO note_tags(note_id,tag_id)
-SELECT :tagged_id,id FROM tags WHERE name='db04-python';
--- 23505:重复关联
+SELECT @tagged_id,id FROM tags WHERE name='db04-python';
+-- 1062 duplicate entry
 DELETE FROM tags WHERE name='db04-python';
--- 23503:仍被笔记引用
-DELETE FROM notes WHERE id=:tagged_id;
-SELECT count(*) FROM note_tags WHERE note_id=:tagged_id;
--- 0
+-- 1451 parent row referenced
+INSERT INTO note_tags(note_id,tag_id) VALUES(@empty_id,-999);
+-- 1452 parent missing,立即失败
+DELETE FROM notes WHERE id=@tagged_id;
+SELECT COUNT(*) AS remaining FROM note_tags WHERE note_id=@tagged_id;
+SHOW INDEX FROM note_tags;
 ````
 
 ### 验收结果
 
-- 关联明细返回两个标签,聚合结果分别为 2 与 0。
-- 重复添加同一关联失败,不存在编号的外键写入也会失败。
-- 删除练习笔记后仅其关联行消失,标签本身仍存在。
+- 明细2行、计数2与0。
+- 重复、被引用删除和无父关联分别被立即拒绝。
+- 联合主键与tag_id辅助索引可在SHOW INDEX中确认。
 
 ### 生产约束与故障演练
 
-生产列表页需要展示标签数,同时允许笔记暂时没有标签。
+笔记列表要展示标签,删除策略应与业务约定一致。
 
 #### 实现决策
 
-- 通过关联表表达多对多。
-- 用唯一约束阻止重复关系。
-- 明确父记录删除行为并验证空关系。
+- 外键即时检查。
+- 关系使用约束与明确索引。
+- 零关系结果单独测试。
 
 #### 隔离环境演练
 
-在 notes_lab 重复插入关联、删除被引用标签,记录数据库拒绝原因。
+用本课专用样本重复建立关系、删除被引用标签,并核对约束错误。
 
 #### 恢复验收
 
-- 零标签结果正确。
-- 重复关系无法落库。
-- 统计没有因连接放大而重复计算。
+- 无孤儿关联。
+- 零标签不丢行。
+- 删除边界清晰。
 
 ### 常见错误
 
-#### 没有标签的笔记消失了
+#### 到COMMIT才希望补齐父行,但子行INSERT立即失败
 
-原因:使用 INNER JOIN,或在 WHERE 中过滤右表导致 LEFT JOIN 效果被破坏。
+原因:把其他系统的延迟外键习惯套到MySQL。
 
-处理:需要保留左表时使用 LEFT JOIN;右表匹配条件按语义放到 ON,并检查空值结果。
+处理:先插父记录,再插子记录;需要复杂调度时重新规划写入顺序。
 
-#### 无标签笔记显示数量 1
+#### 无标签笔记统计为1
 
-原因:COUNT(*) 统计了 LEFT JOIN 生成的空关联行。
+原因:COUNT(*)把LEFT JOIN空关联行计入。
 
-处理:改用 COUNT(nt.tag_id),并检查连接多个一对多表时是否又放大行数。
+处理:改COUNT(nt.tag_id),检查其他连接是否进一步放大。
 
 ### 扩展练习
 
-查询拥有 db04-python 标签的笔记,要求每条笔记最多出现一次。
+查询带db04-python标签的笔记且每条最多一次。
 
-提示:可使用 EXISTS 子查询表达是否存在,不需要把标签列放进输出。
+提示:EXISTS直接表达是否存在,避免把标签明细带回结果。
 
 参考答案:
 
-SELECT n.id,n.title FROM notes n WHERE EXISTS (SELECT 1 FROM note_tags nt JOIN tags t ON t.id=nt.tag_id WHERE nt.note_id=n.id AND t.name='db04-python') ORDER BY n.id; 第 04 步已删除本课关联时结果为空;重新执行第 02 步后应出现新笔记。
+SELECT n.id,n.title FROM notes n WHERE EXISTS(SELECT 1 FROM note_tags nt JOIN tags t ON t.id=nt.tag_id WHERE nt.note_id=n.id AND t.name='db04-python') ORDER BY n.id; 清理关联后可为空,重新准备样本后再验收。
 
 ### 技术文档
 
-- [PostgreSQL 16:表表达式与 JOIN](https://www.postgresql.org/docs/16/queries-table-expressions.html)
-- [PostgreSQL 16:聚合函数](https://www.postgresql.org/docs/16/functions-aggregate.html)
-- [PostgreSQL 16:外键](https://www.postgresql.org/docs/16/ddl-constraints.html)
+- [MySQL 8.4:JOIN](https://dev.mysql.com/doc/refman/8.4/en/join.html)
+- [MySQL 8.4:外键](https://dev.mysql.com/doc/refman/8.4/en/create-table-foreign-keys.html)
+- [MySQL 8.4:聚合](https://dev.mysql.com/doc/refman/8.4/en/aggregate-functions.html)
 
 ---
 
-## 05 把多次写入放进同一个事务
+## 05 明确InnoDB事务、保存点与DDL提交边界
 
-一次保存笔记并关联标签,主动制造第二步失败,确认第一步也回滚。
+让必需写入一起提交,并验证错误语句、整个事务和结构变更的不同回滚范围。
 
 ### 本节目标
 
-- 划定需要同时成功的写入边界
-- 识别失败事务状态并执行回滚
-- 使用保存点隔离可选步骤
+- 使用START TRANSACTION与显式ROLLBACK
+- 区分语句失败和事务整体失败
+- 验证DDL隐式提交不能用ROLLBACK撤销
 
 ### 必要概念
 
-#### 事务覆盖一个业务动作
+#### 事务覆盖需要同时成功的动作
 
-创建笔记和保存必需标签属于同一次提交。只有两步都成功才向调用者返回成功。数据库事务不能自动回滚已经发送的邮件或外部 HTTP 请求,跨系统一致性在 API + 数据库和异步任务模块继续处理。
+创建笔记和关联必需标签放在同一InnoDB事务。客户端收到异常后应按业务边界回滚,而不是看数据库是否仍允许SELECT就继续提交。外部HTTP、邮件或文件不随数据库事务自动回滚。
 
-#### 错误后的事务需要结束
+#### 普通约束错误通常只回滚本条语句
 
-BEGIN 后发生约束错误,事务进入失败状态。继续 SELECT 通常得到 25P02;应 ROLLBACK 整个事务,或者在预先建立的 SAVEPOINT 上回滚到保存点。
+MySQL中重复键或外键失败后,事务里的其他成功写入仍可能保留,事务也通常能继续执行。死锁1213会回滚整个事务;锁等待超时1205默认通常只回滚当前语句,受配置影响。应用统一结束失败业务事务可避免半成品。
 
-#### 事务越短越容易控制
+#### DDL不是可随意回滚的DML
 
-事务持有连接与可能的锁。不要在 BEGIN 与 COMMIT 之间等用户输入、调用模型或上传文件。identity 消耗的编号即使回滚也可能不复用,所以不能用编号是否连续判断事务正确性。
+CREATE TABLE、ALTER TABLE等通常在执行前后隐式提交。MySQL原子DDL保护单条DDL的崩溃一致性,不表示BEGIN包起来的多条结构变更可以ROLLBACK。迁移文件要独立于业务事务。
 
 ### 动手实现
 
-#### 1. 用成功路径保存笔记和标签
+#### 1. 执行笔记与标签一起成功的路径
 
-先完成第 04 课表结构。此脚本在 psql 中执行;标签使用已有 db04-api,若无该标签先执行 INSERT INTO tags(name) VALUES ('db04-api') ON CONFLICT DO NOTHING。事务末尾应 COMMIT。
+在mysql客户端执行,先确认db04-api标签存在。没有DML RETURNING,紧接INSERT用同连接LAST_INSERT_ID。COMMIT后从另一个会话查询能看到笔记和关联。
 
 文件:终端 / 当前课时
 
 ````sql
-BEGIN;
-INSERT INTO notes(title) VALUES ('db05-atomic-ok') RETURNING id AS ok_id \gset
+START TRANSACTION;
+INSERT INTO notes(title) VALUES('db05-atomic-ok');
+SET @ok_id=LAST_INSERT_ID();
 INSERT INTO note_tags(note_id,tag_id)
-SELECT :ok_id,id FROM tags WHERE name='db04-api';
+SELECT @ok_id,id FROM tags WHERE name='db04-api';
 COMMIT;
 SELECT n.id,n.title,t.name FROM notes n
 JOIN note_tags nt ON nt.note_id=n.id JOIN tags t ON t.id=nt.tag_id
-WHERE n.id=:ok_id;
+WHERE n.id=@ok_id;
 ````
 
-#### 2. 制造第二步失败
+#### 2. 让第二步失败后明确回滚整个业务
 
-本例用不存在的负标签编号触发外键错误。逐条执行,观察提示符从 notes_lab=* 改为失败事务状态。不要把预期错误脚本用 ON_ERROR_STOP 直接执行后遗漏回滚。
+逐条执行。外键失败后SELECT仍能看到本事务第一步插入的笔记,这正是不能忽略异常后COMMIT的原因。ROLLBACK后在原会话和新会话查询该编号均为空。
 
 文件:终端 / 当前课时
 
 ````sql
-BEGIN;
-INSERT INTO notes(title) VALUES ('db05-must-rollback') RETURNING id AS failed_id \gset
-INSERT INTO note_tags(note_id,tag_id) VALUES (:failed_id,-999);
--- 23503:外键错误
-SELECT 1;
--- 25P02:当前事务已失败
+START TRANSACTION;
+INSERT INTO notes(title) VALUES('db05-must-rollback');
+SET @failed_id=LAST_INSERT_ID();
+INSERT INTO note_tags(note_id,tag_id) VALUES(@failed_id,-999);
+-- 1452:本条语句失败
+SELECT id,title FROM notes WHERE id=@failed_id;
+-- 本事务仍可看到第一步成功写入
 ROLLBACK;
-SELECT count(*) FROM notes WHERE id=:failed_id;
+SELECT COUNT(*) AS remaining FROM notes WHERE id=@failed_id;
 -- 0
 ````
 
-#### 3. 用保存点处理可选标签
+#### 3. 只对明确可选步骤使用保存点
 
-只有当业务允许“笔记成功、可选标签失败”时才采用本例。保存点前的笔记保留,保存点之后失败的关联被回滚。若标签是业务必填,则应沿用上一步整笔回滚。
+本例业务允许笔记创建而标签失败。保存点之前写入保留,恢复到保存点后提交。必需标签不得借此绕过验证;AUTO_INCREMENT已消耗编号可能留下空洞。
 
 文件:终端 / 当前课时
 
 ````sql
-BEGIN;
-INSERT INTO notes(title) VALUES ('db05-optional-tag') RETURNING id AS optional_id \gset
+START TRANSACTION;
+INSERT INTO notes(title) VALUES('db05-optional-tag');
+SET @optional_id=LAST_INSERT_ID();
 SAVEPOINT optional_tag;
-INSERT INTO note_tags(note_id,tag_id) VALUES (:optional_id,-999);
+INSERT INTO note_tags(note_id,tag_id) VALUES(@optional_id,-999);
 ROLLBACK TO SAVEPOINT optional_tag;
+RELEASE SAVEPOINT optional_tag;
 COMMIT;
-SELECT id,title FROM notes WHERE id=:optional_id;
--- 1 row
+SELECT id,title FROM notes WHERE id=@optional_id;
 ````
 
-#### 4. 在独立会话核对提交边界
+#### 4. 用专用表证明DDL隐式提交
 
-打开第二个 psql,执行相同 SELECT。成功笔记和可选标签笔记可见,must-rollback 不可见。再在会话 A BEGIN 后插入一条新笔记,暂不提交;会话 B 查询该标题为零行,A COMMIT 后 B 再查才能看到。最后结束两个会话中所有未结束事务。
+仅notes_lab执行,一次实验会保留一条笔记和一张空表,再显式清理。CREATE TABLE之前INSERT因隐式提交已落库,随后ROLLBACK不能撤销它们。不要将此模式用于真实业务迁移。
 
 文件:终端 / 当前课时
 
 ````sql
-SELECT title,count(*) FROM notes
-WHERE title LIKE 'db05-%' GROUP BY title ORDER BY title;
--- 包含 db05-atomic-ok、db05-optional-tag
--- 不包含 db05-must-rollback
+START TRANSACTION;
+INSERT INTO notes(title) VALUES('db05-ddl-committed');
+SET @ddl_id=LAST_INSERT_ID();
+CREATE TABLE ddl_commit_probe(id INT PRIMARY KEY) ENGINE=InnoDB;
+ROLLBACK;
+SELECT id,title FROM notes WHERE id=@ddl_id;
+SHOW TABLES LIKE 'ddl_commit_probe';
+-- 都存在,证明ROLLBACK没有回滚DDL前的提交
+DELETE FROM notes WHERE id=@ddl_id;
+DROP TABLE ddl_commit_probe;
 ````
 
 ### 验收结果
 
-- 第二步外键失败后,回滚事务中的笔记不存在。
-- 失败状态执行 SELECT 返回 25P02,ROLLBACK 后恢复。
-- 另一会话只能读取已提交数据;保存点例子符合明确的可选业务规则。
+- 失败后主动回滚,第一步笔记没有遗留。
+- 保存点例子只保留业务明确允许的部分。
+- 能观察DDL隐式提交,并清理仅本课创建的标记。
 
 ### 生产约束与故障演练
 
-保存按钮触发多张表写入,任何必需步骤失败都不能产生半成品记录。
+一次用户保存涉及多表,异常处理必须符合MySQL实际回滚语义。
 
 #### 实现决策
 
-- 事务以业务动作划分。
-- 可选步骤才允许保存点局部恢复。
-- 外部调用在数据库事务之外安排。
+- DML事务显式提交回滚。
+- DDL独立运行并设计恢复路径。
+- 事务中不等待外部服务。
 
 #### 隔离环境演练
 
-在 notes_lab 用无效外键中断保存,重新连接检查是否留下半条业务记录。
+在notes_lab触发外键错误和DDL隐式提交,分别记录保留与回滚的记录。
 
 #### 恢复验收
 
-- 必需写入要么全有要么全无。
-- 失败后连接能够继续服务。
-- 日志能定位原始约束错误而不是只有后续 25P02。
+- 无必需写入半成品。
+- 错误码与回滚范围可解释。
+- 不宣称BEGIN能包住所有DDL。
 
 ### 常见错误
 
-#### 笔记创建了但标签没保存
+#### 标签失败但笔记仍保存
 
-原因:每条 SQL 独立提交,缺少完整事务边界。
+原因:约束错误通常只撤销本条SQL,应用继续提交了事务。
 
-处理:将必需写入放入同一事务,仅在成功提交后返回业务成功。
+处理:except中显式rollback整个业务事务,再向调用方报告失败。
 
-#### 重试 SQL 一直提示 current transaction is aborted
+#### ROLLBACK没有撤销ALTER或CREATE
 
-原因:在失败事务中继续执行,没有先回滚。
+原因:DDL发生隐式提交,原子DDL不等于用户可回滚DDL事务。
 
-处理:退出事务上下文或显式 ROLLBACK,再决定是否重试整个业务动作。
+处理:迁移拆分为可检查步骤、先备份和演练,避免与业务DML混用。
 
 ### 扩展练习
 
-把可选标签例子改成标签必填,说明应该删除哪些语句。
+PyMySQL第二次execute触发1452时,最小异常处理是什么?
 
-提示:必填意味着不能在失败后只回滚局部。
+提示:不要根据SELECT还能运行就判断可以继续提交。
 
 参考答案:
 
-移除 SAVEPOINT 和 ROLLBACK TO SAVEPOINT。标签插入异常时执行整个事务 ROLLBACK,笔记和关联都不保留;应用把错误转成合适的响应,而不是返回创建成功。
+try内完成全部必需SQL后conn.commit;except BaseException中conn.rollback并raise;finally conn.close。若需要只保留部分成功,必须先定义业务允许的保存点范围。
 
 ### 技术文档
 
-- [PostgreSQL 16:事务入门](https://www.postgresql.org/docs/16/tutorial-transactions.html)
-- [PostgreSQL 16:SAVEPOINT](https://www.postgresql.org/docs/16/sql-savepoint.html)
-- [Psycopg 3:事务管理](https://www.psycopg.org/psycopg3/docs/basic/transactions.html)
+- [MySQL 8.4:事务语句](https://dev.mysql.com/doc/refman/8.4/en/commit.html)
+- [MySQL 8.4:隐式提交](https://dev.mysql.com/doc/refman/8.4/en/implicit-commit.html)
+- [MySQL 8.4:InnoDB错误处理](https://dev.mysql.com/doc/refman/8.4/en/innodb-error-handling.html)
+- [MySQL 8.4:保存点](https://dev.mysql.com/doc/refman/8.4/en/savepoint.html)
 
 ---
 
-## 06 用两个会话观察隔离级别与 MVCC
+## 06 用双会话观察RR、RC与当前读
 
-按固定顺序执行读写,比较 Read Committed 和 Repeatable Read 的可见结果。
+按固定顺序比较MySQL默认RR快照、显式RC和带锁当前读。
 
 ### 本节目标
 
-- 用两个 psql 会话复现读取差异
-- 解释语句快照与事务快照
-- 识别需要重试的序列化失败
+- 复现默认RR下的一致性读
+- 显式切换RC观察新提交
+- 区分快照SELECT与锁定读/UPDATE
 
 ### 必要概念
 
-#### 快照决定读者能看见什么
+#### 默认RR固定一致性读快照
 
-Read Committed 下,每条查询读取该语句开始时已经提交的数据。Repeatable Read 通常固定在事务第一条实际查询建立的快照。下面的实验用同一行、同一个读取动作对照,避免只背隔离级别名字。
+InnoDB默认REPEATABLE READ。普通SELECT通常在事务第一次一致性读建立快照,后续普通读取使用相同快照;START TRANSACTION本身不一定已建立快照。长快照会增加undo保留与purge压力。
 
-#### 普通读与写锁不是同一机制
+#### RC每次一致性读建立新快照
 
-MVCC 使普通 SELECT 可以读取已提交版本,不必因为另一会话正在更新同一行而等待其提交。SELECT FOR UPDATE 是带锁读取,行为不同。长事务会保留旧快照,增加旧版本清理压力。
+READ COMMITTED下不同SELECT可以读到期间新提交的值。SET TRANSACTION ISOLATION LEVEL只设置接下来事务,必须在START TRANSACTION之前且当前没有活动事务。普通读均不读取另一会话尚未提交的值。
 
-#### 更强隔离伴随重试责任
+#### UPDATE与锁定读不能按旧快照推断
 
-Repeatable Read 或 Serializable 在并发冲突下可能返回 SQLSTATE 40001。重试应从事务开始重新读取和计算,设置次数上限与退避;不能在已经失败的事务里重发最后一条 SQL。
+SELECT FOR UPDATE与UPDATE使用锁定/当前读规则,可访问较新的已提交行。RR事务普通读仍见v1时,带锁读取可能见v2;后续写入可能覆盖他人编辑,因此版本条件仍必要。RR不自动把并发编辑变成版本冲突异常。
 
 ### 动手实现
 
-#### 1. 准备专用行并打开会话 A 与 B
+#### 1. 准备同一行和两个窗口
 
-在任意会话执行一次第一组语句,记录返回的编号。A、B 均使用 app_user / notes_lab。每个会话执行 \set iso_id 实际编号,示例中的 123 必须替换。每轮实验开始前确保双方已 COMMIT 或 ROLLBACK。
+在一个mysql会话插入样本,记录编号;A/B都SET @iso_id=实际编号。不要假定样本id=1。每次切换隔离级别前双方执行ROLLBACK结束旧事务。
 
 文件:终端 / 当前课时
 
 ````sql
-INSERT INTO notes(title,content) VALUES ('db06-isolation','v1') RETURNING id;
--- 假设返回 123,在 A 和 B 各自设置:
-\set iso_id 123
-SHOW default_transaction_isolation;
--- read committed
+INSERT INTO notes(title,content) VALUES('db06-isolation','v1');
+SELECT LAST_INSERT_ID() AS experiment_id;
+-- A、B均替换为刚返回的编号
+SET @iso_id=123;
+SELECT @@transaction_isolation;
+-- 默认REPEATABLE-READ
 ````
 
-#### 2. 执行 Read Committed 时序
+#### 2. 按窗口顺序观察默认RR
 
-按注释切换窗口,不要一次性粘贴到同一会话。A 第一次读 v1;B 更新但未提交时 A 仍读 v1;B 提交后 A 再读变为 v2。A 的事务没有重开,但下一条查询已经取得新快照。
+A建立快照后,B更新但不提交时A仍v1;B提交后A普通读也仍v1;A结束事务再读才v2。代码按注释切窗口执行,不要整段粘到单一窗口。
 
 文件:终端 / 当前课时
 
 ````sql
--- A:先执行
-BEGIN ISOLATION LEVEL READ COMMITTED;
-SELECT content FROM notes WHERE id=:iso_id;
--- B:执行,先不提交
-BEGIN;
-UPDATE notes SET content='v2',updated_at=now() WHERE id=:iso_id;
--- A:此时仍为 v1
-SELECT content FROM notes WHERE id=:iso_id;
--- B:现在提交
+-- A
+SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
+START TRANSACTION;
+SELECT content FROM notes WHERE id=@iso_id;
+-- B
+START TRANSACTION;
+UPDATE notes SET content='v2',version=version+1,updated_at=UTC_TIMESTAMP(6) WHERE id=@iso_id;
+-- A:v1
+SELECT content FROM notes WHERE id=@iso_id;
+-- B
 COMMIT;
--- A:现在为 v2,然后结束
-SELECT content FROM notes WHERE id=:iso_id;
+-- A:仍v1
+SELECT content FROM notes WHERE id=@iso_id;
 COMMIT;
+SELECT content FROM notes WHERE id=@iso_id;
+-- v2
 ````
 
-#### 3. 执行 Repeatable Read 时序
+#### 3. 切为RC观察两次查询变化
 
-延续上一步的 v2。A 建立快照后,B 写入 v3 并提交;A 在原事务内仍读 v2。A 提交并开始新查询后读到 v3。记录四次读取结果与相应窗口。
+延续v2。A显式RC读取,B自动提交更新v3后,A在相同事务的下一条SELECT读到v3。最后结束A事务,后续下一事务仍按会话默认RR,除非再显式设置。
 
 文件:终端 / 当前课时
 
 ````sql
 -- A
-BEGIN ISOLATION LEVEL REPEATABLE READ;
-SELECT content FROM notes WHERE id=:iso_id;
--- B:自动提交状态
-UPDATE notes SET content='v3',updated_at=now() WHERE id=:iso_id;
--- A:原快照仍为 v2
-SELECT content FROM notes WHERE id=:iso_id;
+SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
+START TRANSACTION;
+SELECT content FROM notes WHERE id=@iso_id;
+-- B,确认autocommit=1
+UPDATE notes SET content='v3',version=version+1,updated_at=UTC_TIMESTAMP(6) WHERE id=@iso_id;
+-- A:现在为v3
+SELECT content FROM notes WHERE id=@iso_id;
 COMMIT;
-SELECT content FROM notes WHERE id=:iso_id;
--- v3
 ````
 
-#### 4. 复现并发更新失败并恢复
+#### 4. 证明RR普通读与当前读可以不同
 
-A 用 Repeatable Read 读取 v3,B 改成 v4 并提交,A 再更新同一行会遇到 40001。最后 A ROLLBACK 并重新读取,确认新事务看到 v4。这里只演示失败机制,不对包含外部副作用的动作进行盲目重试。
+A普通读v3建立快照;B提交v4;A普通读仍v3,但FOR UPDATE读到v4并取得锁。此处只观察,不覆盖写入。A立即ROLLBACK释放锁;不能把这一实验推导为无条件覆盖更新是安全的。
 
 文件:终端 / 当前课时
 
 ````sql
 -- A
-BEGIN ISOLATION LEVEL REPEATABLE READ;
-SELECT content FROM notes WHERE id=:iso_id;
+SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
+START TRANSACTION;
+SELECT content FROM notes WHERE id=@iso_id;
 -- B
-UPDATE notes SET content='v4',updated_at=now() WHERE id=:iso_id;
--- A
-UPDATE notes SET content='v5' WHERE id=:iso_id;
--- ERROR: could not serialize access due to concurrent update
+UPDATE notes SET content='v4',version=version+1,updated_at=UTC_TIMESTAMP(6) WHERE id=@iso_id;
+-- A:v3
+SELECT content FROM notes WHERE id=@iso_id;
+-- A:v4,带锁当前读
+SELECT content FROM notes WHERE id=@iso_id FOR UPDATE;
 ROLLBACK;
-SELECT content FROM notes WHERE id=:iso_id;
--- v4
 ````
 
 ### 验收结果
 
-- Read Committed 在 B 提交前后读到 v1、v2。
-- Repeatable Read 在事务中保持 v2,事务结束后读到 v3。
-- 并发更新实验出现 40001,ROLLBACK 后读取恢复。
+- RR事务中保持旧值,结束后读取新值。
+- RC同事务下一次SELECT能看到B新提交。
+- RR普通读与FOR UPDATE结果不同,所有实验事务已结束。
 
 ### 生产约束与故障演练
 
-后台导出需要一致读取,编辑接口同时持续更新笔记。
+报表导出与多人编辑同时运行,需要明确每种读操作所见的数据。
 
 #### 实现决策
 
-- 根据读取范围选择隔离级别。
-- 限制长事务,避免把导出网络传输放在读事务内。
-- 40001 重试覆盖完整事务。
+- 默认RR与显式RC分别验收。
+- 短事务降低undo保留压力。
+- 当前读与普通快照读不混淆。
 
 #### 隔离环境演练
 
-在 notes_lab 双会话执行快照与并发更新实验,保留实际输出顺序。
+在同一笔记上执行固定双会话时序,记录每次提交和读取结果。
 
 #### 恢复验收
 
-- 每个结果可对应到一次提交。
-- 实验后无遗留事务。
-- 能区分数据可见性与行锁等待。
+- 结果能对应具体时点。
+- 无遗留长事务。
+- 不把RR描述成自动乐观锁。
 
 ### 常见错误
 
-#### 两种隔离级别看起来没有区别
+#### 切隔离级别报事务中不能修改
 
-原因:A 没有显式 BEGIN,或者 B 在 A 建立快照之前已提交。
+原因:已有事务尚未结束,或在START TRANSACTION之后才SET TRANSACTION。
 
-处理:严格按窗口与注释顺序执行,记录每次 BEGIN、SELECT 和 COMMIT 的时点。
+处理:先ROLLBACK,再设置下一事务的级别,然后START TRANSACTION。
 
-#### 实验结束后连接一直 idle in transaction
+#### 以为RR能防止旧表单覆盖新内容
 
-原因:只观察结果没有结束事务。
+原因:普通读取快照与UPDATE的锁定读规则不同。
 
-处理:两个窗口都执行 ROLLBACK;生产应用用上下文管理事务并限制事务内工作量。
+处理:在UPDATE中加入expected version条件,或对需要串行的业务使用短锁定事务。
 
 ### 扩展练习
 
-说明报表需要同一时点多次读取,而普通单条查询通常不用提高到 Repeatable Read 的原因。
+为什么多条报表SQL可用短RR只读事务,但编辑接口仍需版本检查?
 
-提示:先确定需要保证的是一条 SQL,还是多条 SQL 共享快照。
+提示:报表关心一致快照,编辑关心提交时是否有人已修改。
 
 参考答案:
 
-单条 SELECT 本身在一个语句快照内执行。需要多条 SELECT 共享一致快照时,可选择短的只读 Repeatable Read 事务;若能用一条 SQL 表达汇总,通常可减少事务持续时间。提高隔离级别不能代替业务约束与正确的重试实现。
+RR普通读取可以共享一致快照;编辑提交必须用WHERE id=? AND version=?做原子条件更新。不要让用户填写表单的整个过程占用数据库事务。
 
 ### 技术文档
 
-- [PostgreSQL 16:事务隔离](https://www.postgresql.org/docs/16/transaction-iso.html)
-- [PostgreSQL 16:MVCC 简介](https://www.postgresql.org/docs/16/mvcc-intro.html)
-- [PostgreSQL 16:序列化失败处理](https://www.postgresql.org/docs/16/mvcc-serialization-failure-handling.html)
+- [MySQL 8.4:隔离级别](https://dev.mysql.com/doc/refman/8.4/en/innodb-transaction-isolation-levels.html)
+- [MySQL 8.4:一致性读](https://dev.mysql.com/doc/refman/8.4/en/innodb-consistent-read.html)
+- [MySQL 8.4:锁定读](https://dev.mysql.com/doc/refman/8.4/en/innodb-locking-reads.html)
 
 ---
 
-## 07 定位锁等待、复现死锁并加入版本检查
+## 07 观察行锁、间隙锁、死锁与版本冲突
 
-观察同一行写入的等待关系,再分别用固定加锁顺序和乐观锁解决不同冲突。
+使用独立实验行定位InnoDB等待,再用一致加锁顺序和条件版本更新约束并发。
 
 ### 本节目标
 
-- 用 pg_blocking_pids 找到锁等待源头
-- 复现并处理死锁回滚
-- 用 version 条件阻止覆盖其他人的编辑
+- 通过performance_schema定位等待链
+- 复现RR范围锁与死锁
+- 用版本条件阻止旧编辑覆盖
 
 ### 必要概念
 
-#### 行锁持续到事务结束
+#### 锁依赖访问路径与隔离级别
 
-UPDATE 和 SELECT FOR UPDATE 可阻止另一个事务更新同一行。等待不一定是坏事,但必须有时限与可观测性;持锁时调用外部服务会让数据库承担外部延迟。
+InnoDB锁定索引记录。唯一主键等值查找通常只需目标记录锁;RR范围锁定读可能使用next-key/gap锁阻止范围插入。RC通常减少搜索与扫描的间隙锁,但外键和重复键检查等仍有例外,不能说RC完全没有间隙锁。
 
-#### 死锁来自循环等待
+#### 死锁与超时有不同回滚范围
 
-A 持有行一等待行二,B 持有行二等待行一,形成闭环。数据库会中止其中一个事务,不能预言一定中止谁。固定顺序获取多行锁可减少死锁,应用仍需识别 40P01 并有限重试整个事务。
+循环等待会触发死锁检测,一方收到1213,整个事务被回滚;1205锁等待超时默认通常只回滚当前语句。应用遇到两类错误都显式ROLLBACK,并在事务外有限重试完整业务动作,不重试任意一条末尾SQL。
 
-#### 版本检查表达编辑冲突
+#### 数据库锁与编辑版本分别使用
 
-客户端读取 version=1,更新时要求 WHERE version=1 并同时 version=version+1。若别人已提交,更新返回零行,调用者应重新读取并解决冲突。版本检查防止旧内容覆盖新内容,不用于替代所有数据库锁。
+数据库锁保护短事务中的并发资源,用户编辑过程不应持有连接。UPDATE同时比较version并递增,rowcount=0表示未成功匹配,必须返回冲突或不存在而不是假装保存成功。
 
 ### 动手实现
 
-#### 1. 建立两个实验行并添加版本列
+#### 1. 准备两个实验编号与三个窗口
 
-完成前面表结构后执行一次 ADD COLUMN;若已存在 version 则只运行后两条。把返回编号记为 first_id 和 second_id,在 A、B 两个窗口各设置相同变量。
+业务窗口A/B连接app_user,分别记录CONNECTION_ID()。另开只读管理窗口用root连接本机notes-mysql,查看performance_schema需要额外权限,不把诊断权限授予API。记录两个新编号并在A/B各设置变量。
 
 文件:终端 / 当前课时
 
 ````sql
-ALTER TABLE notes ADD COLUMN version integer NOT NULL DEFAULT 1 CHECK (version > 0);
-INSERT INTO notes(title) VALUES ('db07-first') RETURNING id;
-INSERT INTO notes(title) VALUES ('db07-second') RETURNING id;
--- 在两个窗口替换为实际返回值
-\set first_id 124
-\set second_id 125
+INSERT INTO notes(title) VALUES('db07-first');
+SELECT LAST_INSERT_ID();
+INSERT INTO notes(title) VALUES('db07-second');
+SELECT LAST_INSERT_ID();
+-- A/B替换为实际编号
+SET @first_id=124,@second_id=125;
+SELECT CONNECTION_ID();
+SET SESSION innodb_lock_wait_timeout=5;
 ````
 
-#### 2. 观察一个有上限的锁等待
+#### 2. 制造有上限的行锁等待
 
-先 A 加锁,再 B 执行 UPDATE。B 等待期间切回 A 查询阻塞信息;如果 5 秒不够观察,可重做一次。B 约 5 秒后收到 lock timeout,之后必须 ROLLBACK;A 也 ROLLBACK 释放锁。
+A先START TRANSACTION并锁第一行,B开始事务更新同一行会等待。等待期间管理窗口执行查询,记录blocking与waiting线程。B约5秒收到1205后ROLLBACK,A观察后也ROLLBACK;不要直接KILL任何未核对会话。
 
 文件:终端 / 当前课时
 
 ````sql
 -- A
-BEGIN;
-SELECT id FROM notes WHERE id=:first_id FOR UPDATE;
+START TRANSACTION;
+SELECT id FROM notes WHERE id=@first_id FOR UPDATE;
 -- B
-BEGIN;
-SET LOCAL lock_timeout='5s';
-UPDATE notes SET content='waiting' WHERE id=:first_id;
--- A:在 B 等待期间执行
-SELECT pid,wait_event_type,wait_event,pg_blocking_pids(pid) AS blockers
-FROM pg_stat_activity WHERE datname='notes_lab';
--- B 超时后,以及 A 观察完后,各自执行
+START TRANSACTION;
+UPDATE notes SET content='waiting',version=version+1 WHERE id=@first_id;
+-- 管理窗口:在等待期间执行
+SELECT REQUESTING_THREAD_ID,BLOCKING_THREAD_ID,REQUESTING_ENGINE_TRANSACTION_ID,
+       BLOCKING_ENGINE_TRANSACTION_ID FROM performance_schema.data_lock_waits;
+-- B超时后,以及A观察完后,各自执行
 ROLLBACK;
 ````
 
-#### 3. 按顺序制造死锁
+#### 3. 固定相反顺序复现死锁
 
-先确认双方没有遗留事务。先 A 锁第一行,再 B 锁第二行,然后 A 请求第二行进入等待,最后 B 请求第一行。一个窗口返回 deadlock detected,另一个窗口可继续获得锁。无论哪个失败,两个窗口最后都执行 ROLLBACK。
+先A锁第一行、B锁第二行,再A请求第二行进入等待,最后B请求第一行。其中一方收到1213,不能指定一定哪方被牺牲。另一方拿到锁后两边都结束事务。管理窗口SHOW ENGINE INNODB STATUS可查看最近死锁,输出可能含业务SQL,留存时脱敏。
 
 文件:终端 / 当前课时
 
 ````sql
 -- A
-BEGIN;
-SELECT id FROM notes WHERE id=:first_id FOR UPDATE;
+START TRANSACTION;
+SELECT id FROM notes WHERE id=@first_id FOR UPDATE;
 -- B
-BEGIN;
-SELECT id FROM notes WHERE id=:second_id FOR UPDATE;
--- A:此处会等待
-SELECT id FROM notes WHERE id=:second_id FOR UPDATE;
--- B:触发循环等待
-SELECT id FROM notes WHERE id=:first_id FOR UPDATE;
--- SQLSTATE 40P01 出现在其中一个会话
--- A 和 B 各执行 ROLLBACK;
+START TRANSACTION;
+SELECT id FROM notes WHERE id=@second_id FOR UPDATE;
+-- A等待
+SELECT id FROM notes WHERE id=@second_id FOR UPDATE;
+-- B触发死锁,1213出现在一个窗口
+SELECT id FROM notes WHERE id=@first_id FOR UPDATE;
+-- A/B均ROLLBACK
+-- 管理窗口
+SHOW ENGINE INNODB STATUS;
 ````
 
-#### 4. 改为相同顺序锁定多行
+#### 4. 以固定顺序加锁并保持短事务
 
-在 A 执行下面代码并保持事务,B 执行同样代码会等待 A。A COMMIT 后 B 能继续,然后 B COMMIT。双方都按 id 升序取得目标锁,避免本例的相反顺序。
+A、B都按相同主键顺序锁两行。先确认全部旧事务结束,再A执行下列START与SELECT,B执行同样两条等待;A COMMIT后B继续,B再COMMIT。本课数量只有两行,不把大范围全表加锁作为默认方案。
 
 文件:终端 / 当前课时
 
 ````sql
-BEGIN;
-SELECT id FROM notes WHERE id IN (:first_id,:second_id) ORDER BY id FOR UPDATE;
--- A 先 COMMIT,B 等待结束后再 COMMIT
+START TRANSACTION;
+SELECT id FROM notes WHERE id IN (@first_id,@second_id) ORDER BY id FOR UPDATE;
+-- A先COMMIT,B返回后再COMMIT
 COMMIT;
 ````
 
-#### 5. 用旧版本号重放编辑
+#### 5. 用独立范围表观察RR间隙保护
 
-在同一个窗口按顺序执行即可模拟两个客户端都读到版本 1。先把专用行重置到 version=1;第一次更新得到 2,第二次带旧版本条件返回 0 行。只有实际更新成功才向客户端返回成功。
+仅在notes_lab建立专用lock_range_probe,DDL在事务外。A RR锁[10,20]范围后B插入15会等待;B超时回滚、A回滚。再将A换READ COMMITTED重复,B通常可插入15,因为不再需要同样的范围间隙保护;两轮结束后清理专用表。
 
 文件:终端 / 当前课时
 
 ````sql
-UPDATE notes SET version=1,content='base' WHERE id=:first_id;
-UPDATE notes SET content='editor A',version=version+1,updated_at=now()
-WHERE id=:first_id AND version=1 RETURNING id,content,version;
--- version=2
-UPDATE notes SET content='editor B',version=version+1,updated_at=now()
-WHERE id=:first_id AND version=1 RETURNING id,content,version;
--- 0 rows
-SELECT content,version FROM notes WHERE id=:first_id;
--- editor A, 2
+CREATE TABLE lock_range_probe(id INT PRIMARY KEY) ENGINE=InnoDB;
+INSERT INTO lock_range_probe VALUES(10),(20),(30);
+-- A
+SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
+START TRANSACTION;
+SELECT id FROM lock_range_probe WHERE id BETWEEN 10 AND 20 FOR UPDATE;
+-- B,单独设置较短等待后执行
+SET SESSION innodb_lock_wait_timeout=2;
+INSERT INTO lock_range_probe VALUES(15);
+-- B 1205后ROLLBACK;A也ROLLBACK。
+-- A改READ COMMITTED,重复同一SELECT;B再INSERT(15),应成功。
+-- 所有事务结束后,只清理本课专用表
+DROP TABLE lock_range_probe;
+````
+
+#### 6. 原子比较旧版本并拒绝第二次覆盖
+
+专用笔记先重置到version=1仅为实验,生产版本不得倒退。第一次更新成功1行,第二次仍带version=1匹配0行;SELECT确认内容仍为editor A。
+
+文件:终端 / 当前课时
+
+````sql
+UPDATE notes SET version=1,content='base' WHERE id=@first_id;
+UPDATE notes SET content='editor A',version=version+1,updated_at=UTC_TIMESTAMP(6)
+WHERE id=@first_id AND version=1;
+SELECT ROW_COUNT() AS changed_rows;
+-- 1
+UPDATE notes SET content='editor B',version=version+1,updated_at=UTC_TIMESTAMP(6)
+WHERE id=@first_id AND version=1;
+SELECT ROW_COUNT() AS changed_rows;
+-- 0
+SELECT content,version FROM notes WHERE id=@first_id;
 ````
 
 ### 验收结果
 
-- 锁等待能查到非空 blockers,超时后事务可回滚。
-- 死锁仅保证一个参与事务被中止,两个窗口最后均无活动事务。
-- 旧版本更新返回零行,editor A 的内容没有被覆盖。
+- 行锁等待能在管理窗口找到,1205后主动回滚。
+- 死锁出现1213,固定顺序实验仅等待而不产生本例闭环。
+- 范围实验与版本实验分别解释间隙保护和编辑冲突。
 
 ### 生产约束与故障演练
 
-两名用户同时编辑笔记,后台任务又可能批量修改相同记录。
+多人编辑和批量任务竞争同一批数据。
 
 #### 实现决策
 
-- 短事务与锁超时控制等待。
-- 多行写入约定一致的锁顺序。
-- 普通编辑用版本检查向用户呈现冲突。
+- 管理诊断权限与业务权限分开。
+- 加锁顺序一致,等待有上限。
+- 版本比较与更新在同一SQL完成。
 
 #### 隔离环境演练
 
-仅在 notes_lab 双会话制造行锁与死锁,记录等待链、SQLSTATE 和回滚结果。
+仅在专用行和lock_range_probe上制造等待与死锁,保存脱敏证据并结束所有事务。
 
 #### 恢复验收
 
-- 没有无限等待。
-- 所有写入入口维护 version。
-- 冲突时保留已提交内容并报告更新失败。
+- 无无限等待。
+- 旧编辑无法覆盖已提交版本。
+- 能区分1213与1205的回滚边界。
 
 ### 常见错误
 
-#### 设置 statement_timeout 后还是不清楚在等什么
+#### 设置较高隔离后插入也卡住
 
-原因:语句超时能中断执行,但没有分析锁等待来源。
+原因:RR范围锁可能保护间隙,更新不只锁住已存在行。
 
-处理:在等待期间查看 wait_event 与 pg_blocking_pids,结合持锁会话事务时长定位。
+处理:核对索引访问范围和data_locks,缩小事务范围,按业务需要选择RC。
 
-#### 版本检查存在但更新仍覆盖内容
+#### 发生1205后继续COMMIT留下部分写入
 
-原因:SELECT 后在 Python 检查版本,UPDATE 没带 version 条件。
+原因:误以为所有数据库错误都会回滚整个事务。
 
-处理:把版本比较和递增放在同一条 UPDATE 中,依据 RETURNING 判定成功。
+处理:统一显式rollback失败业务,再有限重试;记录错误码与完整操作关联。
 
 ### 扩展练习
 
-将批量修改改为按 id 升序锁定,并说明收到 40P01 后重试范围。
+收到1213重试时,应复用旧计算结果还是重新读取整个事务?
 
-提示:不要只重试死锁报错的最后一条 SELECT。
+提示:数据库已回滚被牺牲事务,外部事实可能变化。
 
 参考答案:
 
-先确定全部目标编号,在同一事务按升序 SELECT ... FOR UPDATE,再执行修改。收到 40P01 后回滚并重新读取整个业务动作的输入,有限退避重试;涉及外部副作用时先使用幂等机制。
+回滚并重新建立事务、重新读取和计算,使用次数上限与抖动退避。任何外部副作用需要幂等键,不能因数据库回滚就断言邮件或文件也消失。
 
 ### 技术文档
 
-- [PostgreSQL 16:显式锁与死锁](https://www.postgresql.org/docs/16/explicit-locking.html)
-- [PostgreSQL 16:统计活动](https://www.postgresql.org/docs/16/monitoring-stats.html)
-- [PostgreSQL 16:错误码](https://www.postgresql.org/docs/16/errcodes-appendix.html)
+- [MySQL 8.4:InnoDB锁](https://dev.mysql.com/doc/refman/8.4/en/innodb-locking.html)
+- [MySQL 8.4:死锁示例](https://dev.mysql.com/doc/refman/8.4/en/innodb-deadlock-example.html)
+- [MySQL 8.4:data_lock_waits](https://dev.mysql.com/doc/refman/8.4/en/performance-schema-data-lock-waits-table.html)
 
 ---
 
-## 08 围绕查询设计单列、组合与部分索引
+## 08 设计组合索引、覆盖索引与JSON路径索引
 
-准备足够的数据,对照查询条件与排序建立索引,再核算索引带来的写入成本。
+按查询条件建立InnoDB索引,用不可见索引辅助对照,核算写入成本。
 
 ### 本节目标
 
-- 从 WHERE 与 ORDER BY 推导组合索引
-- 用部分索引服务低占比条件
-- 知道主键、唯一约束与外键索引的区别
+- 从WHERE和ORDER BY推导索引顺序
+- 理解聚簇主键与二级索引
+- 为JSON路径创建受控的生成列索引
 
 ### 必要概念
 
-#### 索引缩小查找范围
+#### InnoDB主键决定聚簇组织
 
-B-tree 索引按键组织数据,使等值、范围与有序读取可以少访问一些数据页。索引占用磁盘,写入和更新时也要维护。是否使用索引由优化器估计成本决定,小表顺序扫描常常更合适。
+主键索引叶子包含行数据,二级索引包含其索引键和主键值。选择过宽的主键会增加所有二级索引成本。查询返回列都在索引中时可能形成覆盖读取,仍需用实际计划确认。
 
-#### 组合键的顺序来自查询
+#### 组合索引依据固定查询设计
 
-WHERE pinned=true ORDER BY created_at DESC,id DESC LIMIT 20 可用 pinned、created_at、id 的组合索引。前面的等值条件固定范围,后面提供稳定排序。只对 pinned 这个低基数字段建索引不一定有收益。
+置顶列表WHERE pinned=1 ORDER BY created_at DESC,id DESC可使用(pinned,created_at DESC,id DESC)。低基数单列pinned索引未必划算。MySQL没有WHERE谓词形式的部分索引,本课用组合索引与实际选择率解决查询。
 
-#### 部分索引只保存需要的行
+#### 不可见索引与JSON索引各有边界
 
-如果置顶笔记仅占少数,可只索引 WHERE pinned=true 的记录。查询条件需能匹配该谓词;不要假设参数化的任意布尔条件总能使用该索引。主键和唯一约束会创建索引,外键引用列不自动创建索引。
+不可见索引默认不参与优化器选择,但仍维护并占用空间,适合可控验证,不是删除替代。JSON字段本身不能按任意路径自动获得B-tree索引;抽出稳定字段到生成列后建索引,需同时设计类型、长度和NULL语义。
 
 ### 动手实现
 
-#### 1. 在独立练习库生成性能样本
+#### 1. 有限批次生成50000条性能样本
 
-仅 notes_lab 执行,写入 50,000 条带 db08-bench- 前缀的笔记。此脚本只运行一次,重复执行会额外产生同量数据。固定时间与稀疏 pinned 分布便于对照,但不能代表真实业务分布。
+保存db_seed_performance.py,只对notes_lab执行一次。每批500条提交,出错只回滚当前批,之前批次仍保留;再次运行会新增同量数据,不能假定脚本幂等。所有正文不超过16000字符。
 
-文件:终端 / 当前课时
+文件:db_seed_performance.py
 
-````sql
-INSERT INTO notes(title,content,pinned,created_at,updated_at)
-SELECT 'db08-bench-' || g, repeat('x',200), g % 100 = 0,
-       timestamptz '2026-01-01 00:00:00+00' + g * interval '1 second',
-       timestamptz '2026-01-01 00:00:00+00' + g * interval '1 second'
-FROM generate_series(1,50000) AS g;
-ANALYZE notes;
-SELECT count(*),count(*) FILTER(WHERE pinned) FROM notes;
+````python
+from datetime import datetime,timedelta
+from mysql_client import connect
+
+conn=connect()
+try:
+    for start in range(1,50001,500):
+        rows=[]
+        for number in range(start,min(start+500,50001)):
+            utc_value=datetime(2026,1,1)+timedelta(seconds=number)
+            rows.append((f"db08-bench-{number}","x"*200,number%100==0,utc_value,utc_value))
+        with conn.cursor() as cur:
+            cur.executemany("INSERT INTO notes(title,content,pinned,created_at,updated_at) VALUES(%s,%s,%s,%s,%s)",rows)
+        conn.commit()
+    print("inserted",50000)
+except BaseException:
+    conn.rollback()
+    raise
+finally:
+    conn.close()
 ````
 
-#### 2. 保存无专用索引时的计划
+#### 2. 保存原计划并建立组合索引
 
-先只用 EXPLAIN 观察估计计划,下一课运行带 ANALYZE 的实际计划。记录扫描方式、Sort 是否存在及估计行数,不把某个固定 cost 数值作为正确答案。
+MySQL DDL发生隐式提交,因此在独立维护会话运行。ALGORITHM=INPLACE,LOCK=NONE请求在线二级索引创建,若当前操作或表条件不支持就报错而非假装完全无锁。仍可能需要短暂元数据锁,设置lock_wait_timeout。
 
 文件:终端 / 当前课时
 
 ````sql
-EXPLAIN
-SELECT id,title,created_at FROM notes
-WHERE pinned=true ORDER BY created_at DESC,id DESC LIMIT 20;
-SELECT indexname,indexdef FROM pg_indexes WHERE tablename='notes';
+ANALYZE TABLE notes;
+EXPLAIN SELECT id,title,created_at FROM notes WHERE pinned=1
+ORDER BY created_at DESC,id DESC LIMIT 20;
+SET SESSION lock_wait_timeout=3;
+CREATE INDEX notes_pinned_created_id_idx ON notes(pinned,created_at DESC,id DESC)
+ALGORITHM=INPLACE LOCK=NONE;
+EXPLAIN SELECT id,title,created_at FROM notes WHERE pinned=1
+ORDER BY created_at DESC,id DESC LIMIT 20;
+SHOW INDEX FROM notes;
 ````
 
-#### 3. 建立组合索引并对比
+#### 3. 用不可见索引做读计划对照
 
-建索引后重新执行相同 EXPLAIN。数据量足够时通常能减少扫描和排序工作,但输出由版本、统计和数据决定。索引包含 id 用于同一时间点下稳定排序。
+仅对刚创建的实验索引切换,不修改主键。观察key、rows和Extra变化,恢复VISIBLE后重新验收。不可见期间写入仍需维护该索引,不能用它证明删除索引后的写入收益。
 
 文件:终端 / 当前课时
 
 ````sql
-CREATE INDEX notes_pinned_created_id_idx ON notes(pinned,created_at DESC,id DESC);
-ANALYZE notes;
-EXPLAIN
-SELECT id,title,created_at FROM notes
-WHERE pinned=true ORDER BY created_at DESC,id DESC LIMIT 20;
-SELECT pg_size_pretty(pg_relation_size('notes_pinned_created_id_idx')) AS index_size;
+ALTER TABLE notes ALTER INDEX notes_pinned_created_id_idx INVISIBLE;
+EXPLAIN SELECT id,title,created_at FROM notes WHERE pinned=1
+ORDER BY created_at DESC,id DESC LIMIT 20;
+ALTER TABLE notes ALTER INDEX notes_pinned_created_id_idx VISIBLE;
+SELECT DATA_LENGTH,INDEX_LENGTH FROM information_schema.tables
+WHERE table_schema='notes_lab' AND table_name='notes';
 ````
 
-#### 4. 将置顶查询改为部分索引实验
+#### 4. 在专用表给JSON路径建生成列索引
 
-为能明确对照,只在 notes_lab 删除刚创建的实验索引,改建部分索引,再执行同样查询。生产建索引可评估 CREATE INDEX CONCURRENTLY 以减少阻塞,但它不能放在事务块中,失败后还需检查无效索引。
+为避免改变贯穿课程notes模型,用独立json_index_probe。source取固定文本,JSON无该字段时生成NULL。查询直接使用生成列,避免依赖优化器是否把另一个表达式识别成相同形式。小表可能仍顺序扫描,目的先验证语义。
 
 文件:终端 / 当前课时
 
 ````sql
-DROP INDEX notes_pinned_created_id_idx;
-CREATE INDEX notes_pinned_recent_idx ON notes(created_at DESC,id DESC) WHERE pinned=true;
-EXPLAIN
-SELECT id,title,created_at FROM notes
-WHERE pinned=true ORDER BY created_at DESC,id DESC LIMIT 20;
-SELECT pg_size_pretty(pg_relation_size('notes_pinned_recent_idx')) AS index_size;
-CREATE INDEX note_tags_tag_id_idx ON note_tags(tag_id);
+CREATE TABLE json_index_probe (
+ id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
+ payload JSON NOT NULL,
+ source VARCHAR(32) GENERATED ALWAYS AS (JSON_UNQUOTE(JSON_EXTRACT(payload,'$.source'))) STORED,
+ INDEX source_idx(source)
+) ENGINE=InnoDB;
+INSERT INTO json_index_probe(payload) VALUES('{"source":"api","revision":1}'),('{"revision":2}');
+SELECT id,source FROM json_index_probe ORDER BY id;
+EXPLAIN SELECT id FROM json_index_probe WHERE source='api';
+-- 本课专用表验收结束后清理
+DROP TABLE json_index_probe;
 ````
 
 ### 验收结果
 
-- 记录至少两个版本计划和各自索引大小。
-- 能说明 pinned=true 查询与部分索引谓词的关系。
-- note_tags 的联合主键覆盖 note_id 开头查找,反向 tag_id 查询有单独索引。
+- 保存建索引前后真实计划,不要求固定成本数值。
+- 不可见索引能恢复可见,明确仍有写维护成本。
+- JSON路径生成列得到api与NULL,查询含义明确。
 
 ### 生产约束与故障演练
 
-首页需要快速显示置顶笔记,写入仍持续发生。
+首页置顶查询变慢,需要同时控制读延迟和写入成本。
 
 #### 实现决策
 
-- 索引对应一条明确查询。
-- 用接近真实分布的数据评估读写成本。
-- 生产在线建索引单独规划事务与失败恢复。
+- 索引对应明确查询。
+- 维护DDL与业务事务分开。
+- JSON路径类型与长度受约束。
 
 #### 隔离环境演练
 
-在 notes_lab 比较无索引、组合索引与部分索引的计划与大小。
+在notes_lab比较可见/不可见计划和JSON路径结果,恢复索引可见状态。
 
 #### 恢复验收
 
-- 查询顺序确定。
-- 有性能证据支持索引选择。
-- 没有把练习库 DROP INDEX 直接当成生产变更方案。
+- 有数据规模和计划证据。
+- 不声称MySQL支持部分索引。
+- 没有为每个字段盲目建索引。
 
 ### 常见错误
 
-#### 建了索引仍然 Seq Scan
+#### 照搬WHERE pinned=1的CREATE INDEX报语法错误
 
-原因:表很小、命中行过多、统计过旧或查询不匹配索引。
+原因:MySQL不支持谓词部分索引。
 
-处理:先 ANALYZE 并检查实际 SQL、类型与选择率;不要通过关闭顺序扫描作为生产修复。
+处理:使用与查询匹配的组合索引,或按业务设计受控生成列并验证访问路径。
 
-#### 索引越加越多,写入越来越慢
+#### 在线建索引仍在等待
 
-原因:每个索引都增加存储与维护,部分索引可能重复覆盖同一查询。
+原因:LOCK=NONE不等于从不申请元数据锁,长事务可能阻塞DDL。
 
-处理:保留使用证据与目标查询,对重复索引评估后再清理,结合写入延迟一起验收。
+处理:设短lock_wait_timeout并检查元数据锁,安排维护窗口;不要关闭所有约束强行建表。
 
 ### 扩展练习
 
-为 WHERE tag_id=? 查询关联的 note_id 设计索引,并判断现有主键是否足够。
+为什么SELECT id只读置顶列表比同时读取大content更容易覆盖索引?
 
-提示:联合主键是 (note_id,tag_id),注意最左列。
+提示:二级索引有索引列与主键,但不自动包含整个TEXT。
 
 参考答案:
 
-现有主键擅长 note_id 或 note_id+tag_id 条件,不应默认满足按 tag_id 反查的性能需求。可使用 CREATE INDEX ON note_tags(tag_id,note_id),并在实际数据上对比;若采用此组合索引,应评估是否还需要单列 tag_id 索引。
+id、pinned、created_at可由组合二级索引提供;content通常需要回聚簇索引取行,扩大读取量。不要为覆盖随意把大正文加入索引,先限制输出字段并比较计划。
 
 ### 技术文档
 
-- [PostgreSQL 16:多列索引](https://www.postgresql.org/docs/16/indexes-multicolumn.html)
-- [PostgreSQL 16:部分索引](https://www.postgresql.org/docs/16/indexes-partial.html)
-- [PostgreSQL 16:CREATE INDEX](https://www.postgresql.org/docs/16/sql-createindex.html)
+- [MySQL 8.4:多列索引](https://dev.mysql.com/doc/refman/8.4/en/multiple-column-indexes.html)
+- [MySQL 8.4:不可见索引](https://dev.mysql.com/doc/refman/8.4/en/invisible-indexes.html)
+- [MySQL 8.4:生成列索引](https://dev.mysql.com/doc/refman/8.4/en/generated-column-index-optimizations.html)
+- [MySQL 8.4:在线DDL](https://dev.mysql.com/doc/refman/8.4/en/innodb-online-ddl-operations.html)
 
 ---
 
-## 09 读取 EXPLAIN ANALYZE 并改造深分页
+## 09 读懂EXPLAIN ANALYZE并优化深分页
 
-用执行计划验证实际工作量,再把大 OFFSET 改成稳定的游标条件。
+对比估计JSON计划与实际TREE执行结果,用范围条件替代大OFFSET。
 
 ### 本节目标
 
-- 读懂实际行数、loops 与 buffers
-- 比较浅分页和深分页的扫描量
-- 使用 created_at 与 id 实现键集分页
+- 区分EXPLAIN估计与ANALYZE真实执行
+- 用实际rows和loops分析深分页
+- 实现与排序一致的二字段游标
 
 ### 必要概念
 
-#### ANALYZE 会真的执行 SQL
+#### MySQL的计划格式要对应版本
 
-EXPLAIN 只生成计划,EXPLAIN ANALYZE 会执行语句并收集结果。此课仅分析 SELECT;不要直接拿它分析线上 DELETE 或 UPDATE。实际耗时受缓存与负载影响,需要同时看扫描量和缓冲访问。
+EXPLAIN FORMAT=JSON提供估计计划结构;MySQL 8.4的EXPLAIN ANALYZE使用TREE展示实际时间、行数和循环。ANALYZE会执行查询,本课只用于SELECT,不把写语句当作无副作用诊断。
 
-#### 深 OFFSET 仍需跳过前面的记录
+#### 深OFFSET需要处理跳过的数据
 
-LIMIT 20 OFFSET 40000 返回 20 行,但执行器通常仍需走过被跳过的记录。键集分页使用上一页最后一行的排序键,直接定位下一段范围。
+LIMIT 20 OFFSET 40000通常仍处理之前大量记录。连续滚动适合以最后一行的排序键定位下一页;任意跳页则需要另做产品与性能取舍,不能把取深游标的成本忽略掉。
 
-#### 游标必须与排序完全一致
+#### 复合游标展开成明确范围
 
-按 created_at DESC,id DESC 排序,就用 (created_at,id)<(游标时间,游标编号)。id 处理相同时间的并列。游标不是快照;并发删除、排序字段修改仍可能影响跨页视图,导出要另行定义一致性。
+按created_at DESC,id DESC排序,就筛选created_at<游标时间 OR(created_at=游标时间 AND id<游标id)。展开条件方便核对MySQL访问计划,不能仅假定行构造器比较一定利用完整复合索引。created_at应保持稳定。
 
 ### 动手实现
 
-#### 1. 创建列表索引并收集实际计划
+#### 1. 建立列表索引并比较两种计划
 
-继续使用第 08 课的 50,000 条样本。首次建索引一次,运行 ANALYZE。记录节点名称、actual rows、loops、Buffers 和 Execution Time。不要要求每台机器出现完全相同的执行时间。
+继续使用第08课样本,CREATE INDEX首次执行一次。ANALYZE TABLE是更新统计,EXPLAIN ANALYZE是执行并测量查询,两者用途不同。读取节点actual time、rows、loops;不要把cost当毫秒。
 
 文件:终端 / 当前课时
 
 ````sql
 CREATE INDEX notes_created_id_idx ON notes(created_at DESC,id DESC);
-ANALYZE notes;
-EXPLAIN (ANALYZE,BUFFERS)
-SELECT id,title,created_at FROM notes ORDER BY created_at DESC,id DESC LIMIT 20;
+ANALYZE TABLE notes;
+EXPLAIN FORMAT=JSON SELECT id,title,created_at FROM notes
+ORDER BY created_at DESC,id DESC LIMIT 20;
+EXPLAIN ANALYZE SELECT id,title,created_at FROM notes
+ORDER BY created_at DESC,id DESC LIMIT 20;
 ````
 
-#### 2. 比较深分页的额外工作
+#### 2. 记录深OFFSET额外扫描量
 
-运行相同排序的深 OFFSET。即使最后只返回 20 行,下层扫描节点通常处理约 40,020 行。若计划不同,按真实节点追踪返回与丢弃的行,不强行匹配示例计划。
+相同查询仅改变OFFSET,比较底层扫描节点的actual rows。设备、缓冲池和其他事务会影响毫秒数,保留数据规模及是否已预热,不预设固定数值。
 
 文件:终端 / 当前课时
 
 ````sql
-EXPLAIN (ANALYZE,BUFFERS)
-SELECT id,title,created_at FROM notes
+EXPLAIN ANALYZE SELECT id,title,created_at FROM notes
 ORDER BY created_at DESC,id DESC LIMIT 20 OFFSET 40000;
 ````
 
-#### 3. 从第一页取得下一页游标
+#### 3. 从第一页末尾读取游标
 
-以下 psql 查询取第一页最后一行的时间和编号,\gset 保存为 cursor_created、cursor_id。实际 API 应从已返回的第 20 行取值,不再单独 OFFSET 查询。使用 :'cursor_created' 让 psql 正确引用时间文本。
+mysql会话变量通过SELECT INTO设置。实际API直接从已返回第20行取得时间和编号,不额外做OFFSET查询。下一页查询必须同时保留同样的过滤条件和排序方向。
 
 文件:终端 / 当前课时
 
 ````sql
-SELECT created_at AS cursor_created,id AS cursor_id
-FROM notes ORDER BY created_at DESC,id DESC LIMIT 1 OFFSET 19 \gset
+SELECT created_at,id INTO @cursor_time,@cursor_id FROM notes
+ORDER BY created_at DESC,id DESC LIMIT 1 OFFSET 19;
 SELECT id,title,created_at FROM notes
-WHERE (created_at,id) < (:'cursor_created'::timestamptz,:cursor_id)
+WHERE created_at<@cursor_time OR (created_at=@cursor_time AND id<@cursor_id)
 ORDER BY created_at DESC,id DESC LIMIT 20;
-EXPLAIN (ANALYZE,BUFFERS)
-SELECT id,title,created_at FROM notes
-WHERE (created_at,id) < (:'cursor_created'::timestamptz,:cursor_id)
+EXPLAIN ANALYZE SELECT id,title,created_at FROM notes
+WHERE created_at<@cursor_time OR (created_at=@cursor_time AND id<@cursor_id)
 ORDER BY created_at DESC,id DESC LIMIT 20;
 ````
 
 #### 4. 在相近深度公平对照
 
-为了教学比较,先花一次 OFFSET 成本拿到深处游标,再测游标查询本身。实际连续翻页只传前页游标。连续运行两次,说明缓存预热影响,不用单次毫秒值证明普遍结论。
+教学为了获得第40000行游标,先花一次OFFSET成本,随后只比较游标查询本身。生产连续翻页用上页已有游标。若计划仍扫描大量行,观察OR范围与索引使用情况,而不是强行宣称游标一定更快。
 
 文件:终端 / 当前课时
 
 ````sql
-SELECT created_at AS deep_created,id AS deep_id
-FROM notes ORDER BY created_at DESC,id DESC LIMIT 1 OFFSET 39999 \gset
-EXPLAIN (ANALYZE,BUFFERS)
-SELECT id,title,created_at FROM notes
-WHERE (created_at,id) < (:'deep_created'::timestamptz,:deep_id)
+SELECT created_at,id INTO @deep_time,@deep_id FROM notes
+ORDER BY created_at DESC,id DESC LIMIT 1 OFFSET 39999;
+EXPLAIN ANALYZE SELECT id,title,created_at FROM notes
+WHERE created_at<@deep_time OR(created_at=@deep_time AND id<@deep_id)
 ORDER BY created_at DESC,id DESC LIMIT 20;
+-- 第二次运行用于观察热缓冲条件,记录两次实际结果。
 ````
 
 ### 验收结果
 
-- 记录浅 OFFSET、深 OFFSET 与深游标的实际扫描量。
-- 下一页与前页没有重复编号,排序键与过滤条件完全一致。
-- 能区分计划估计值与实际执行值,不把 cost 当成毫秒。
+- 区分JSON估计计划与TREE实际执行数据。
+- 深OFFSET和深游标有可对照的扫描量记录。
+- 时间相同的记录由id稳定排序,跨页无重复编号。
 
 ### 生产约束与故障演练
 
-笔记数量增长后,第几百页响应变慢,而第一页正常。
+数据增长后后页延迟明显高于第一页。
 
 #### 实现决策
 
-- 以实际扫描量解释深分页成本。
-- 连续滚动用键集分页,任意跳页另做产品和性能取舍。
-- 游标字段选择稳定值,分页不默认承诺全程快照。
+- 先量工作量再比较耗时。
+- 游标字段稳定且唯一排序。
+- 跨页请求不默认共享数据库快照。
 
 #### 隔离环境演练
 
-在 notes_lab 对比相同深度的 OFFSET 与游标查询,记录执行计划。
+在notes_lab对照浅页、深OFFSET和深游标,保存真实计划。
 
 #### 恢复验收
 
-- 深页工作量明显下降或有证据解释未下降原因。
-- 同时间记录不重复。
-- 测试记录注明样本规模与缓存状态。
+- 不忽略取游标成本。
+- 无错误格式选项。
+- 性能结论注明样本与缓存状态。
 
 ### 常见错误
 
-#### 分页重复或漏掉相同时间的记录
+#### EXPLAIN ANALYZE FORMAT=JSON不支持
 
-原因:只把 created_at 放进排序或游标,缺少唯一的并列键。
+原因:把不同版本或其他数据库的格式组合套到MySQL8.4。
 
-处理:排序和比较同时包含 id,并让排序方向与比较符号一致。
+处理:本课使用EXPLAIN FORMAT=JSON与EXPLAIN ANALYZE两条独立命令。
 
-#### 游标查询变慢且出现 Sort
+#### 同一时间的笔记跨页重复
 
-原因:缺少匹配排序的索引,或添加了新筛选条件改变访问方式。
+原因:只按时间排序或只使用一个时间游标。
 
-处理:对实际查询重新 EXPLAIN ANALYZE,检查复合索引与筛选顺序,而不是只调 LIMIT。
+处理:排序与筛选同时包含id,并完整保留其他列表条件。
 
 ### 扩展练习
 
-给置顶列表加入键集分页,并说明使用哪个已有索引。
+给置顶列表添加游标条件,说明逻辑运算括号位置。
 
-提示:保留 pinned=true,同时添加二元组范围条件。
+提示:pinned条件必须作用于OR的两个分支。
 
 参考答案:
 
-SELECT id,title,created_at FROM notes WHERE pinned=true AND (created_at,id)<($1,$2) ORDER BY created_at DESC,id DESC LIMIT 20; 这是数据库参数形式示意,psycopg 中使用 %s。第 08 课的 notes_pinned_recent_idx 对该固定谓词和排序有用,仍需实际执行计划验证。
+WHERE pinned=1 AND (created_at<%s OR(created_at=%s AND id<%s)) ORDER BY created_at DESC,id DESC LIMIT 20。参数传(time,time,id)。缺少外层括号会让后一OR分支绕过pinned过滤。
 
 ### 技术文档
 
-- [PostgreSQL 16:EXPLAIN](https://www.postgresql.org/docs/16/using-explain.html)
-- [PostgreSQL 16:LIMIT 与 OFFSET](https://www.postgresql.org/docs/16/queries-limit.html)
-- [PostgreSQL 16:行比较](https://www.postgresql.org/docs/16/functions-comparisons.html)
+- [MySQL 8.4:EXPLAIN](https://dev.mysql.com/doc/refman/8.4/en/explain.html)
+- [MySQL 8.4:LIMIT优化](https://dev.mysql.com/doc/refman/8.4/en/limit-optimization.html)
+- [MySQL 8.4:行构造器优化](https://dev.mysql.com/doc/refman/8.4/en/row-constructor-optimization.html)
 
 ---
 
-## 10 用 psycopg 连接池管理连接与事务
+## 10 用有界连接池管理PyMySQL连接与事务
 
-让并发代码借用有限连接,复现池耗尽,确保异常后连接能够归还。
+明确驱动不自带业务连接池,通过QueuePool复用连接并验证借还和耗尽恢复。
 
 ### 本节目标
 
-- 建立一个可显式开启和关闭的连接池
-- 把事务包含在借还连接的作用域内
-- 通过超时和统计观察池拥塞
+- 建立进程级有界DBAPI连接池
+- 明确commit/rollback与close归还语义
+- 在连接损坏时失效而非暗中重连
 
 ### 必要概念
 
-#### 连接池是有限资源预算
+#### PyMySQL连接池需额外组件
 
-每个连接对应数据库后端资源。池在 min_size 与 max_size 范围复用连接,借不到时等待而非无限新建。进程数乘每进程最大池容量,再加任务、运维与其他服务连接,必须落在数据库预算内。
+PyMySQL是同步DBAPI驱动,本课使用SQLAlchemy QueuePool作为独立池组件,不引入ORM。pool_size加max_overflow是本进程连接上限,池按需创建连接;应用启动、关闭统一管理。
 
-#### 连接不等于业务并发数
+#### 连接与事务都不能跨并发请求共享
 
-多个请求可以排队等待少量连接。持有连接的时间越长,可服务吞吐越低。一个连接上的查询串行执行;不同线程不要把同一连接当作各自独立事务。同步 psycopg 应运行在同步代码或受控线程中。
+线程借到各自连接后完成短事务,再close归还。池代理的close不是销毁底层连接,dispose关闭空闲连接;关闭时先停止工作并归还所有借出连接。多进程在子进程内各建池,不跨fork复用连接。
 
-#### 借还边界包含提交与回滚
+#### 活跃事务禁止透明重连
 
-pool.connection() 的上下文负责正常结束时提交、异常退出时回滚并归还连接。应用启动创建池、退出关闭池;业务函数借用连接,不在每次调用时重新创建整个池。异步 API 对应 AsyncConnectionPool,在 API + 数据库模块接入。
+网络断开后不要ping(reconnect=True)继续使用旧事务,数据库事务已经丢失。池借出前用ping(reconnect=False)发现坏连接并invalidate,交上层按业务语义重试。socket超时不能证明最后一个写入未提交。
 
 ### 动手实现
 
-#### 1. 安装池依赖
+#### 1. 建立独立池组件
 
-沿用第 03 课 PG 环境变量。在虚拟环境运行 python -m pip install "psycopg[binary,pool]>=3.2,<4",然后更新 requirements-lock.txt。psycopg_pool 是单独分发的依赖,安装 psycopg 本体不代表已安装连接池。
+安装SQLAlchemy2.0系列并更新锁文件。在db_pool.py保存下面代码,复用第03课mysql_client.connect及其UTC、RC和超时。没有把同步调用放进async路由;异步接入在API+数据库模块使用对应驱动与池。
 
-文件:终端 / 当前课时
+文件:db_pool.py
 
-````bash
-python -m pip install "psycopg[binary,pool]>=3.2,<4"
-python -m pip freeze > requirements-lock.txt
+````python
+# Shell先执行:python -m pip install 'SQLAlchemy>=2.0,<2.1'
+from sqlalchemy.pool import QueuePool
+from mysql_client import connect
+
+pool=QueuePool(creator=connect,pool_size=2,max_overflow=0,timeout=0.2,
+               recycle=300,reset_on_return="rollback")
+
+def checkout():
+    conn=pool.connect()
+    try:
+        conn.ping(reconnect=False)
+        return conn
+    except BaseException:
+        conn.invalidate()
+        conn.close()
+        raise
 ````
 
-#### 2. 运行并发读的完整示例
+#### 2. 4个线程共享最多2条连接
 
-保存为 db_pool.py 并运行。4 个工作线程共用最大 2 条连接;每次函数调用独立借还。open=False 加 open(wait=True) 让启动阶段就确认连接可用,退出通过 finally 关闭。应用不应把密码或整条连接信息打印到日志。
+保存db_pool_read.py。异常回滚失败时也必须在finally归还或失效;这里只读查询出错后把连接失效处理,避免把损坏连接交给下一请求。不要在持有连接时睡眠或执行外部HTTP。
 
-文件:db_pool.py
+文件:db_pool_read.py
 
 ````python
 from concurrent.futures import ThreadPoolExecutor
-from psycopg_pool import ConnectionPool
-
-pool = ConnectionPool("", min_size=1, max_size=2, timeout=2, open=False,
-                      kwargs={"application_name": "notes-service-db-lab"})
+from db_pool import pool,checkout
 
 def count_notes(_):
-    with pool.connection() as conn:
+    conn=checkout()
+    try:
         with conn.cursor() as cur:
-            cur.execute("SET LOCAL statement_timeout='2s'")
-            cur.execute("SELECT count(*) FROM notes")
-            return cur.fetchone()[0]
+            cur.execute("SELECT COUNT(*) AS n FROM notes")
+            value=cur.fetchone()["n"]
+        conn.commit()
+        return value
+    except BaseException:
+        try:
+            conn.rollback()
+        finally:
+            conn.invalidate()
+        raise
+    finally:
+        conn.close()
 
 try:
-    pool.open(wait=True, timeout=10)
     with ThreadPoolExecutor(max_workers=4) as workers:
-        print(list(workers.map(count_notes, range(8))))
-    print(pool.get_stats())
+        print(list(workers.map(count_notes,range(8))))
+    print(pool.status())
 finally:
-    pool.close()
+    pool.dispose()
 ````
 
-#### 3. 验证异常后事务回滚
+#### 3. 验证异常插入明确回滚
 
-保存为 db_pool_rollback.py。故意抛出 Python 异常,异常必须离开 connection 上下文后再捕获,否则上下文可能把事务当作正常结束而提交。第二次借用连接后查询结果应为 0。
+保存db_pool_rollback.py。主动异常后rollback,再借到连接查询同一编号应0。代码不依赖连接上下文自动提交。SQLAlchemy池归还回滚是最后防线,不能替代显式业务事务控制。
 
 文件:db_pool_rollback.py
 
 ````python
-from psycopg_pool import ConnectionPool
+from db_pool import pool,checkout
 
-with ConnectionPool("", min_size=1, max_size=1) as pool:
-    pool.wait(timeout=10)
-    note_id = None
-    try:
-        with pool.connection() as conn:
-            note_id = conn.execute(
-                "INSERT INTO notes(title) VALUES (%s) RETURNING id",
-                ("db10-rollback",),
-            ).fetchone()[0]
-            raise RuntimeError("simulated failure after insert")
-    except RuntimeError:
-        pass
-    with pool.connection() as conn:
-        count = conn.execute("SELECT count(*) FROM notes WHERE id=%s", (note_id,)).fetchone()[0]
-        assert count == 0
-        print("rolled back", count)
+note_id=None
+conn=checkout()
+try:
+    with conn.cursor() as cur:
+        cur.execute("INSERT INTO notes(title) VALUES(%s)",("db10-rollback",))
+        note_id=cur.lastrowid
+    raise RuntimeError("simulated after insert")
+except RuntimeError:
+    conn.rollback()
+finally:
+    conn.close()
+conn=checkout()
+try:
+    with conn.cursor() as cur:
+        cur.execute("SELECT COUNT(*) AS n FROM notes WHERE id=%s",(note_id,))
+        assert cur.fetchone()["n"]==0
+    conn.commit()
+finally:
+    conn.rollback()
+    conn.close()
+    pool.dispose()
 ````
 
-#### 4. 可控复现池耗尽
+#### 4. 占用全部连接后触发有界等待
 
-保存为 db_pool_timeout.py,只在本机练习运行。max_size=1 时外层已经占用唯一连接,内层借用 0.2 秒后超时;捕获异常后退出外层归还连接,再借用应成功。生产代码应消除嵌套借还并缩短持有时间,不能只扩大池。
+保存db_pool_timeout.py。两条连接都借出时第三次借用约0.2秒后抛SQLAlchemy TimeoutError;释放后重新借用成功。这个超时与数据库行锁1205不是同一故障层。仅在独立练习进程执行。
 
 文件:db_pool_timeout.py
 
 ````python
-from psycopg_pool import ConnectionPool, PoolTimeout
+from sqlalchemy.exc import TimeoutError
+from db_pool import pool,checkout
 
-with ConnectionPool("", min_size=1, max_size=1) as pool:
-    pool.wait(timeout=10)
-    with pool.connection():
-        try:
-            with pool.connection(timeout=0.2):
-                pass
-        except PoolTimeout:
-            print("expected pool timeout")
-    with pool.connection() as conn:
-        print("recovered", conn.execute("SELECT 1").fetchone()[0])
-    print(pool.get_stats())
+first=checkout()
+second=checkout()
+try:
+    try:
+        unexpected=checkout()
+    except TimeoutError:
+        print("expected pool timeout")
+    else:
+        unexpected.close()
+        raise AssertionError("pool exceeded configured limit")
+finally:
+    first.rollback();first.close()
+    second.rollback();second.close()
+conn=checkout()
+try:
+    with conn.cursor() as cur:
+        cur.execute("SELECT 1 AS ok")
+        assert cur.fetchone()["ok"]==1
+    conn.commit()
+finally:
+    conn.rollback();conn.close();pool.dispose()
 ````
 
 ### 验收结果
 
-- 并发读取成功,连接数量受 max_size=2 限制。
-- 异常插入的笔记在下一次借用中查不到。
-- 池耗尽产生 PoolTimeout,归还后 SELECT 1 恢复。
+- 并发读取完成且最大连接数为2。
+- 异常插入回滚,连接归还后可以继续使用。
+- 耗尽产生池等待超时,释放后恢复,不盲目扩池。
 
 ### 生产约束与故障演练
 
-API 与任务进程同时访问 PostgreSQL,突发请求不应耗尽全部数据库连接。
+API与worker同时访问数据库,突发流量不能无限增加连接。
 
 #### 实现决策
 
-- 按进程数计算总连接预算。
-- 连接等待和 SQL 执行分别设时限。
-- 业务异常离开事务边界后再转为响应。
+- 进程内池、进程间总预算。
+- 事务显式结束,断线连接失效。
+- 池等待、网络等待、数据库锁等待分开监控。
 
 #### 隔离环境演练
 
-在 notes_lab 用单连接池制造超时和事务异常,再确认连接能归还复用。
+在独立进程耗尽2条连接并触发插入回滚,观察归还恢复。
 
 #### 恢复验收
 
-- 没有连接泄漏。
-- 等待有时限与可记录指标。
-- 连接池在应用生命周期内创建和关闭。
+- 无泄漏。
+- 等待有界。
+- 坏连接不携带假事务继续执行。
 
 ### 常见错误
 
-#### 高并发时不断出现 PoolTimeout
+#### 关闭池后仍有数据库连接
 
-原因:连接持有时间过长、嵌套借用或总并发超过容量。
+原因:还有借出连接或工作线程未结束。
 
-处理:检查等待数和事务时长,先缩短查询与持有范围,再按数据库预算调整池。
+处理:先停止接收任务并等待有界工作结束,归还连接后dispose。
 
-#### async 路由中数据库调用阻塞其他请求
+#### 断线后自动重连并继续提交
 
-原因:在事件循环中直接执行同步 psycopg 查询。
+原因:新连接不包含旧事务状态。
 
-处理:用 AsyncConnectionPool 与 await,或在受控线程执行同步代码;不要只把函数名改成 async。
+处理:禁用事务中透明重连,失效旧连接并按幂等性重试完整业务动作。
 
 ### 扩展练习
 
-4 个 API 进程各 max_size=8,两个任务进程各 max_size=5,预留运维 10 条连接,至少需要规划多少连接?
+4个API进程每池8条、2个任务进程每池5条,再留10条运维,最低连接预算是多少?
 
-提示:进程之间不共享一个 Python 连接池。
+提示:每个进程有独立池,不能只算一个进程。
 
 参考答案:
 
-仅这些进程峰值预算是 4×8+2×5+10=52 条,还应核查迁移、监控、其他服务和数据库保留连接。52 是这组假设下的预算,不是直接把 max_connections 设为 52 的建议。
+4×8+2×5+10=52条,还需加迁移、监控和其他服务余量。max_connections不能只机械等于52,数据库内存与请求排队策略也应验收。
 
 ### 技术文档
 
-- [Psycopg 3:连接池](https://www.psycopg.org/psycopg3/docs/advanced/pool.html)
-- [Psycopg 3:池 API](https://www.psycopg.org/psycopg3/docs/api/pool.html)
-- [Psycopg 3:并发与异步](https://www.psycopg.org/psycopg3/docs/advanced/async.html)
+- [SQLAlchemy:独立池用法](https://docs.sqlalchemy.org/en/20/core/pooling.html#using-a-pool-instance-directly)
+- [PyMySQL:连接参数](https://pymysql.readthedocs.io/en/latest/modules/connections.html)
+- [MySQL 8.4:连接过多](https://dev.mysql.com/doc/refman/8.4/en/too-many-connections.html)
 
 ---
 
-## 11 拆分数据库权限并管理连接配置
+## 11 按账号host范围拆分权限与连接配置
 
-让运行时账号只做业务读写,限制危险语句,并检查日志和连接配置中的敏感信息。
+为API创建最小读写账号,区分DDL、业务查询和运维诊断权限。
 
 ### 本节目标
 
-- 区分迁移账号与 API 运行账号
-- 配置现有对象和未来对象的授权
-- 验证最小权限以及超时配置
+- 识别user@host授权匹配
+- 授予现有与未来业务表的数据库级权限
+- 配置TLS、凭据和每次连接的会话参数
 
 ### 必要概念
 
-#### 权限应按工作职责分配
+#### MySQL账号包含来源host
 
-app_user 保留为练习的对象所有者和迁移身份;新增 notes_runtime 供 API 运行。运行时需要 SELECT、INSERT、UPDATE、DELETE,不需要 CREATE ROLE、DROP TABLE 或 ALTER TABLE。数据库 CONNECT、schema USAGE、表权限是不同层。
+app_user@%与app_user@localhost是两个账号。CURRENT_USER()显示最终匹配的授权身份。Docker练习通过网络连接,用%仅方便独立网络测试;生产使用受控网络范围、TLS与独立密码,不能把用户名相同当成同一身份。
 
-#### 未来对象不会自动继承手工授权
+#### 数据库级授权覆盖该库未来表
 
-GRANT ON ALL TABLES 只覆盖当时存在的表。ALTER DEFAULT PRIVILEGES 针对特定创建者未来创建的对象生效,必须由正确身份设置。生产服务若只需要部分表,应显式授权,不能照搬练习的全部业务表授权。
+GRANT SELECT,INSERT,UPDATE,DELETE ON notes_lab.*可覆盖现有和未来对象,不需要逐表自动权限模板。它范围比单表授权宽,生产根据服务边界选择。运行账号不授予CREATE、ALTER、DROP或全局管理权限。
 
-#### 连接加密与秘密存储
+#### 配置责任不能靠账号默认猜测
 
-生产连接优先使用 TLS 且校验证书和主机名,例如 sslmode=verify-full 配合可信根证书。本机官方镜像没有自动配置可校验 TLS,不应伪称本课容器已提供 TLS。密码通过环境或秘密文件注入,SQL 参数和业务正文也可能包含敏感数据。
+MySQL普通账号没有通用的每账号SET默认会话变量机制供本例照搬;应用connect统一设置UTC、RC、锁等待和SELECT时限。TLS要校验证书与主机名,密码轮换后新建连接验证,再逐步回收旧连接。
 
 ### 动手实现
 
-#### 1. 管理身份创建运行角色
+#### 1. 管理连接创建运行身份
 
-使用第 01 课 docker exec -it notes-postgres psql -U postgres -d notes_lab 进入管理连接。密码通过 \password 交互设置。PUBLIC 是所有角色的隐式集合,撤销公开连接只限此独立练习库,不在共享线上库直接执行。
+docker exec -it notes-mysql mysql -u root -p后执行;密码在专用本机终端填写,不使用示例密码部署。mysql客户端将凭据命令写历史的风险可用该交互进程MYSQL_HISTFILE=/dev/null禁用,或使用受控管理员工具。下面账号仅一次创建。
 
 文件:终端 / 当前课时
 
 ````sql
-CREATE ROLE notes_runtime LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE;
-\password notes_runtime
-REVOKE CONNECT ON DATABASE notes_lab FROM PUBLIC;
-GRANT CONNECT ON DATABASE notes_lab TO app_user,notes_runtime;
-ALTER ROLE notes_runtime IN DATABASE notes_lab SET statement_timeout='3s';
-ALTER ROLE notes_runtime IN DATABASE notes_lab SET lock_timeout='1s';
-ALTER ROLE notes_runtime IN DATABASE notes_lab SET idle_in_transaction_session_timeout='30s';
+CREATE USER 'notes_runtime'@'%' IDENTIFIED BY 'REPLACE_WITH_LOCAL_RANDOM_PASSWORD';
+GRANT SELECT,INSERT,UPDATE,DELETE ON notes_lab.* TO 'notes_runtime'@'%';
+SHOW GRANTS FOR 'notes_runtime'@'%';
+SHOW GRANTS FOR 'app_user'@'%';
 ````
 
-#### 2. 所有者身份授权现有与未来对象
+#### 2. 以运行身份验证允许与拒绝
 
-重新用 app_user 连接 notes_lab 执行。当前课程所有业务表都位于 public,生产可改成独立 schema 并按表授权。identity 之外若使用显式序列默认值,需要相应 sequence 权限;本例将业务序列使用权限一并授予。
+新终端使用mysql --protocol=TCP -h127.0.0.1 -P3307 -u notes_runtime -p notes_lab连接。CRUD成功但ALTER被拒绝,专用样本插入后立即删除;不得给运行账号补DDL权限来消除这个预期错误。
 
 文件:终端 / 当前课时
 
 ````sql
-REVOKE CREATE ON SCHEMA public FROM PUBLIC;
-GRANT USAGE ON SCHEMA public TO notes_runtime;
-GRANT SELECT,INSERT,UPDATE,DELETE ON ALL TABLES IN SCHEMA public TO notes_runtime;
-GRANT USAGE,SELECT ON ALL SEQUENCES IN SCHEMA public TO notes_runtime;
-ALTER DEFAULT PRIVILEGES FOR ROLE app_user IN SCHEMA public
-GRANT SELECT,INSERT,UPDATE,DELETE ON TABLES TO notes_runtime;
-ALTER DEFAULT PRIVILEGES FOR ROLE app_user IN SCHEMA public
-GRANT USAGE,SELECT ON SEQUENCES TO notes_runtime;
+SELECT CURRENT_USER(),DATABASE();
+SET SESSION time_zone='+00:00';
+INSERT INTO notes(title) VALUES('db11-runtime');
+SET @runtime_id=LAST_INSERT_ID();
+DELETE FROM notes WHERE id=@runtime_id;
+ALTER TABLE notes ADD COLUMN should_not_exist INT;
+-- 1142:ALTER command denied
 ````
 
-#### 3. 运行身份验证允许与拒绝操作
+#### 3. 验证未来表与会话超时
 
-通过 docker exec -it notes-postgres psql -h 127.0.0.1 -U notes_runtime -d notes_lab -W 连接。插入成功后删除本次编号;尝试 ALTER TABLE 会因不是所有者失败,不应产生新列。
+app_user创建permission_probe后,notes_runtime可SELECT,说明库级授权覆盖未来表;验收后由app_user删除。超时是当前连接设置:MAX_EXECUTION_TIME主要用于SELECT,本例SLEEP可用于观察;客户端不同也应重做连接配置。
 
 文件:终端 / 当前课时
 
 ````sql
-SELECT current_user;
-INSERT INTO notes(title) VALUES ('db11-runtime') RETURNING id AS runtime_id \gset
-DELETE FROM notes WHERE id=:runtime_id;
-ALTER TABLE notes ADD COLUMN should_not_exist text;
--- ERROR: must be owner of table notes
-SHOW statement_timeout;
-SHOW lock_timeout;
-SELECT pg_sleep(4);
--- 约 3 秒取消,SQLSTATE 57014
+-- app_user
+CREATE TABLE permission_probe(id INT PRIMARY KEY) ENGINE=InnoDB;
+INSERT INTO permission_probe VALUES(1);
+-- notes_runtime
+SELECT id FROM permission_probe;
+SET SESSION max_execution_time=1000;
+SELECT SLEEP(2);
+SHOW WARNINGS;
+-- SLEEP可能返回1表示被中断;以实际状态与耗时验收,勿依赖固定异常
+-- app_user验收后
+DROP TABLE permission_probe;
 ````
 
-#### 4. 验证未来表权限并调整应用配置
+#### 4. 为生产连接开启校验而不破坏练习配置
 
-以 app_user 创建临时验收表,再以 notes_runtime SELECT 应成功。验收后 app_user 删除该专用表。把应用 PGUSER 改为 notes_runtime 并注入对应密码,迁移工具继续单独使用 app_user。检查日志只记录操作名、耗时和 SQLSTATE,不打印密码或完整正文。
+在mysql_client.connect的pymysql.connect参数中按实际证书增加这些参数,不将占位路径直接用于本机无证书环境。MYSQL_HOST应匹配证书主机名,密钥不写进仓库。应用MYSQL_USER改为notes_runtime;迁移仍使用单独app_user凭据。
 
 文件:终端 / 当前课时
 
-````sql
--- app_user
-CREATE TABLE permission_probe(id integer PRIMARY KEY);
-INSERT INTO permission_probe VALUES (1);
--- notes_runtime:应该读到 1
-SELECT id FROM permission_probe;
--- app_user:验收结束清理本课专用表
-DROP TABLE permission_probe;
+````python
+# pymysql.connect(...) 生产TLS参数片段:
+# ssl_ca=os.environ["MYSQL_SSL_CA"],
+# ssl_verify_cert=True,
+# ssl_verify_identity=True,
+# host=os.environ["MYSQL_HOST"],
+
+# 业务连接成功后可查询(不要记录秘密):
+# SHOW SESSION STATUS LIKE 'Ssl_cipher';
+# SELECT CURRENT_USER(), @@session.time_zone, @@transaction_isolation;
 ````
 
 ### 验收结果
 
-- notes_runtime 可以执行业务 CRUD,不能修改表结构。
-- 新连接显示 3 秒语句超时和 1 秒锁超时。
-- app_user 创建的未来表遵循默认权限,密码没有写进仓库和示例日志。
+- 运行账号CRUD可用但ALTER被拒绝。
+- 未来表权限符合数据库级授权范围。
+- 实际连接身份、TLS和UTC/RC设置均可核对。
 
 ### 生产约束与故障演练
 
-API 连接配置泄漏或代码出现错误时,需要限制其能影响的数据库对象。
+运行配置泄漏或应用代码出错时,需要限制影响范围。
 
 #### 实现决策
 
-- 运行角色与迁移角色使用不同秘密。
-- 超时配置需重连生效并实测。
-- 审计日志避免包含原始敏感正文。
+- 迁移、运行和诊断账号分开。
+- host匹配与网络访问共同限制。
+- 每次连接主动设置会话参数。
 
 #### 隔离环境演练
 
-在 notes_lab 用运行角色尝试 ALTER TABLE 并执行超时查询,验证拒绝和恢复。
+用notes_runtime尝试ALTER并验证拒绝,然后核对正常读写不受影响。
 
 #### 恢复验收
 
-- 运行角色不是所有者或超级用户。
-- 权限随未来迁移保持可用。
-- 生产 TLS、凭据注入和轮换方式有明确配置责任。
+- 非root运行。
+- 拒绝错误有明确原因。
+- 生产TLS验证不被省略。
 
 ### 常见错误
 
-#### 现有表能访问,新迁移建的表报 permission denied
+#### 本机能登录、容器连接却被拒绝
 
-原因:只给现有表 GRANT,或默认权限针对了错误创建角色。
+原因:账号host条目和连接来源不同。
 
-处理:检查实际迁移角色,给该角色设置 ALTER DEFAULT PRIVILEGES,并补授权已经创建的对象。
+处理:核对CURRENT_USER、USER及已创建账号来源范围,不通过授予root远程权限绕过。
 
-#### sslmode=require 就被当成完成服务器身份校验
+#### 配置ssl=True就认为已验证服务器身份
 
-原因:只要求加密不等于完整证书与主机名校验。
+原因:加密与证书链、主机名验证是不同要求。
 
-处理:生产按基础设施证书配置 verify-full 与根证书;本机未配 TLS 的容器使用明确的开发配置。
+处理:设置可信CA并启用ssl_verify_cert和ssl_verify_identity,使用证书匹配主机名。
 
 ### 扩展练习
 
-给只读报表账号设计最小权限,说明为什么不应复用 app_user。
+给只读报表建立权限,并说明PROCESS应不应该顺手授予。
 
-提示:需要 CONNECT、schema USAGE 和必要表的 SELECT;不授予写入或对象所有权。
+提示:业务查询所需权限与全实例诊断不同。
 
 参考答案:
 
-创建独立 LOGIN 角色,授予 notes_lab 的 CONNECT、public 的 USAGE、指定业务表的 SELECT。为其设置合适超时与只读事务默认值作为附加限制;最终权限仍由 GRANT 决定。app_user 是对象所有者,权限范围远超报表需求。
+独立报表账号授予指定库/表SELECT,必要时只授予特定视图;PROCESS和performance_schema广泛诊断权限单独给运维身份。报表连接也设置时限,不能仅靠只读权限防止昂贵查询。
 
 ### 技术文档
 
-- [PostgreSQL 16:权限](https://www.postgresql.org/docs/16/ddl-priv.html)
-- [PostgreSQL 16:默认权限](https://www.postgresql.org/docs/16/sql-alterdefaultprivileges.html)
-- [PostgreSQL 16:SSL 客户端验证](https://www.postgresql.org/docs/16/libpq-ssl.html)
-- [PostgreSQL 16:客户端默认设置](https://www.postgresql.org/docs/16/runtime-config-client.html)
+- [MySQL 8.4:GRANT](https://dev.mysql.com/doc/refman/8.4/en/grant.html)
+- [MySQL 8.4:账号名称](https://dev.mysql.com/doc/refman/8.4/en/account-names.html)
+- [MySQL 8.4:加密连接](https://dev.mysql.com/doc/refman/8.4/en/using-encrypted-connections.html)
+- [PyMySQL:TLS参数](https://pymysql.readthedocs.io/en/latest/modules/connections.html)
 
 ---
 
-## 12 备份恢复与可回退的结构变更
+## 12 用mysqldump恢复并验收MySQL结构变更
 
-导出练习库并恢复到另一个数据库,通过数据核对和迁移演练完成生产验收。
+恢复到另一个数据库验证数据与自增,再为新增列设计独立的兼容迁移。
 
 ### 本节目标
 
-- 用 pg_dump 创建逻辑备份并校验文件
-- 在独立恢复库验证结构、数据和序列
-- 使用扩展、回填、验证的步骤变更表结构
+- 用single-transaction备份InnoDB并恢复到隔离库
+- 理解mysqldump与binlog时间点恢复的范围
+- 验证DDL隐式提交和INSTANT操作限制
 
 ### 必要概念
 
-#### 备份文件可生成不代表能恢复
+#### 逻辑备份需要匹配写入与DDL条件
 
-逻辑备份包含数据库对象与数据,恢复需要兼容的工具、角色与目标环境。单库 pg_dump 不包含整个实例的角色定义,也不能独自提供任意时间点恢复;严格 RPO 需要基础备份与 WAL 归档等方案。
+mysqldump --single-transaction为InnoDB建立一致性读取,不保证非事务表一致,也不适合备份同时执行ALTER/DROP等DDL。dump包含SQL,恢复前核对来源;单业务库备份不会自动包含完整账号和授权配置。
 
-#### RPO 和 RTO 是验收条件
+#### 时间点恢复需要备份加连续binlog
 
-RPO 是可接受的数据丢失时间范围,RTO 是恢复服务需要的时间上限。备份频率、保留、异地副本与恢复速度必须与业务目标一致。本课实际测量恢复步骤,而不把本机几秒恢复推断为线上保证。
+每日dump不满足15分钟RPO。需要记录与备份一致的binlog坐标或GTID以及保留完整日志,再在隔离环境恢复到明确停止点。binlog复制和本机留存不等于异地备份;不可在原线上库盲目重放。
 
-#### 结构变更考虑新旧代码并存
+#### 兼容迁移不依赖DDL回滚
 
-增加可空列通常比直接删除或改名更容易兼容。先加字段,再部署兼容代码,分批回填并验证,最后收紧约束。回滚代码不意味着可以随意 DROP 已有用户数据的新列,破坏性变更需要单独恢复设计。
+新增可空列可先兼容旧代码,再逐步上线使用。MySQL 8.4对符合条件的加列支持ALGORITHM=INSTANT,明确要求可在不支持时失败;即使INSTANT也需要元数据锁,且DDL隐式提交,ROLLBACK不能撤销。
 
 ### 动手实现
 
-#### 1. 生成备份与源库验收数据
+#### 1. 准备客户端凭据文件
+
+用编辑器在notes-service创建.mysql-client.cnf,按下面INI结构填写本机练习密码。此处示例占位值必须替换;权限设置为600,目录只供当前用户访问。不要把凭据放进mysqldump命令参数或共享日志。
 
-以下 Linux Bash 在 notes-service 执行,需要 PostgreSQL 16 客户端 pg_dump、psql、pg_restore;沿用 PGHOST/PGPORT/PGDATABASE,PGUSER 使用 app_user 且注入其密码。备份目录限制权限。命令失败必须停止,不继续上传空文件。
+文件:.mysql-client.cnf
+
+````ini
+[client]
+host=127.0.0.1
+port=3307
+protocol=TCP
+user=app_user
+password=REPLACE_WITH_LOCAL_APP_PASSWORD
+default-character-set=utf8mb4
+````
+
+#### 2. 用凭据文件生成逻辑备份
+
+以下Linux Bash需要MySQL8.4客户端。先在受保护.mysql-client.cnf保存client的host=127.0.0.1、port=3307、user=app_user和password;chmod600,不提交。暂停本次练习写入与DDL以便后续行数对照。--set-gtid-purged=OFF使本课单库恢复不改目标GTID集合;生产时间点恢复另行规划坐标。
 
 文件:终端 / 当前课时
 
 ````bash
-set -e
 mkdir -p backups
 chmod 700 backups
-pg_dump --version
-pg_dump -Fc --no-owner --no-acl -f backups/notes_lab.dump
-sha256sum backups/notes_lab.dump > backups/notes_lab.dump.sha256
-pg_restore --list backups/notes_lab.dump > backups/notes_lab.contents.txt
-psql -At -v ON_ERROR_STOP=1 -c "SELECT count(*),min(id),max(id),count(*) FILTER(WHERE pinned) FROM notes" > backups/source-notes.txt
-sha256sum -c backups/notes_lab.dump.sha256
+chmod 600 .mysql-client.cnf
+mysqldump --version
+mysqldump --defaults-extra-file=.mysql-client.cnf \
+  --single-transaction --quick --no-tablespaces --set-gtid-purged=OFF \
+  notes_lab --result-file=backups/notes_lab.sql
+# 前一命令成功退出后再继续;shell脚本应开启set -e。
+sha256sum backups/notes_lab.sql > backups/notes_lab.sql.sha256
+mysql --defaults-extra-file=.mysql-client.cnf -N -B notes_lab \
+  -e 'SELECT COUNT(*),MIN(id),MAX(id),COALESCE(SUM(pinned=1),0) FROM notes' > backups/source-notes.txt
+sha256sum -c backups/notes_lab.sql.sha256
 ````
 
-#### 2. 创建隔离恢复目标并恢复
+#### 3. 创建独立恢复库并授予恢复身份
 
-管理连接执行 CREATE DATABASE notes_restore OWNER app_user;,只在本机首次创建该专用恢复库。不要向原 notes_lab 恢复。下面 --no-owner --no-acl 使本课恢复对象由 app_user 持有,权限在恢复后按第 11 课脚本重建;这不是自动恢复所有生产角色。
+仅本机root管理连接执行。不要使用--databases生成固定USE notes_lab后再误以为命令参数能改目标;本课dump只带表SQL,显式导入notes_restore。notes_restore只能作为验收库,不能覆盖notes_lab。
 
 文件:终端 / 当前课时
 
-````bash
-# Linux Bash,PGUSER=app_user,使用相应密码
-pg_restore --exit-on-error --single-transaction --no-owner --no-acl \
-  -h 127.0.0.1 -p 5433 -U app_user -d notes_restore backups/notes_lab.dump
-psql -d notes_restore -At -v ON_ERROR_STOP=1 \
-  -c "SELECT count(*),min(id),max(id),count(*) FILTER(WHERE pinned) FROM notes" > backups/restored-notes.txt
-diff backups/source-notes.txt backups/restored-notes.txt
+````sql
+CREATE DATABASE notes_restore CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
+GRANT ALL PRIVILEGES ON notes_restore.* TO 'app_user'@'%';
+-- 只针对本机隔离恢复库,恢复完成后按运行账号权限方案重新授权。
 ````
 
-#### 3. 核查恢复后的关系与新写入
+#### 4. 恢复后比较行数、关系与自增
 
-使用 app_user 连 notes_restore 执行。反关联查询均应为 0。插入能生成合法新编号,ROLLBACK 避免保留验收笔记;序列可能前进一位,这是正常行为。若源库备份期间持续写入,外部另查的 count 不一定对应 pg_dump 快照;本课在停止练习写入后对照。
+Bash导入前使用SELECT DATABASE确认目标;mysql --force会跳过错误继续执行,本课不使用。下面数量一致只适用于已暂停写入的练习源库,不能拿备份后持续变化的数据简单判错。
 
 文件:终端 / 当前课时
 
-````sql
-SELECT count(*) FROM note_tags nt LEFT JOIN notes n ON n.id=nt.note_id WHERE n.id IS NULL;
-SELECT count(*) FROM note_tags nt LEFT JOIN tags t ON t.id=nt.tag_id WHERE t.id IS NULL;
-BEGIN;
-INSERT INTO notes(title) VALUES ('db12-restore-probe') RETURNING id,title;
-ROLLBACK;
-\d notes
-\d note_tags
+````bash
+mysql --defaults-extra-file=.mysql-client.cnf notes_restore -e 'SELECT DATABASE();'
+mysql --defaults-extra-file=.mysql-client.cnf notes_restore < backups/notes_lab.sql
+mysql --defaults-extra-file=.mysql-client.cnf -N -B notes_restore \
+  -e 'SELECT COUNT(*),MIN(id),MAX(id),COALESCE(SUM(pinned=1),0) FROM notes' > backups/restored-notes.txt
+diff backups/source-notes.txt backups/restored-notes.txt
+mysql --defaults-extra-file=.mysql-client.cnf notes_restore \
+  -e 'SELECT COUNT(*) FROM note_tags nt LEFT JOIN notes n ON n.id=nt.note_id WHERE n.id IS NULL; SHOW CREATE TABLE notes;'
 ````
 
-#### 4. 在练习源库做一次兼容扩展迁移
+#### 5. 验证恢复库新写入与源库兼容迁移
 
-重新连接 notes_lab / app_user。创建 archived_at 可空列表示未归档;保存为 sql/003_archived.sql,使用 psql -v ON_ERROR_STOP=1 -f sql/003_archived.sql 执行一次。短 lock_timeout 保证拿不到结构锁时尽快失败,再由人检查阻塞后择机重试。它不能保证 DDL 永远无阻塞。
+先在notes_restore执行前一组DML,LAST_INSERT_ID生成合法编号后ROLLBACK,不保留标记。再明确USE notes_lab执行独立DDL;不要包BEGIN期待可回滚。ALGORITHM=INSTANT失败时先评估支持限制,不能静默退回可能重建整表的操作。
 
-文件:sql/003_archived.sql
+文件:终端 / 当前课时
 
 ````sql
-BEGIN;
-SET LOCAL lock_timeout='1s';
-SET LOCAL statement_timeout='5s';
-ALTER TABLE notes ADD COLUMN archived_at timestamptz;
-COMMIT;
+USE notes_restore;
+SET SESSION time_zone='+00:00';
+START TRANSACTION;
+INSERT INTO notes(title) VALUES('db12-restore-probe');
+SELECT LAST_INSERT_ID();
+ROLLBACK;
+
+USE notes_lab;
+SELECT DATABASE();
+SET SESSION lock_wait_timeout=3;
+ALTER TABLE notes ADD COLUMN archived_at DATETIME(6) NULL, ALGORITHM=INSTANT;
+SELECT id,title,pinned FROM notes ORDER BY id DESC LIMIT 3;
+SELECT id,title FROM notes WHERE archived_at IS NULL ORDER BY id DESC LIMIT 3;
+-- 旧查询和新查询都可工作;DDL不能用ROLLBACK撤销。
 ````
 
-#### 5. 验证新旧访问兼容并记录恢复方案
+#### 6. 记录binlog恢复演练所需证据
 
-旧查询只选择明确列名,不受新列影响。新查询把 archived_at IS NULL 当作活动笔记。故障演练是在恢复库模拟错误更新再从备份另建恢复目标,不覆盖源库。本课不实际给出删除全表语句;验收恢复耗时、权限重建与业务请求都必须列入恢复记录。
+本课逻辑恢复已可执行,时间点恢复部分按真实环境补录,不能冒填文件名与GTID。管理窗口只读查看变量;不要将mysqlbinlog输出直接管道写入现有业务库。生产选择持久日志归档方案并在隔离实例恢复到误操作之前,验收角色、时区、索引和业务请求。
 
 文件:终端 / 当前课时
 
 ````sql
-SELECT id,title,pinned FROM notes ORDER BY id DESC LIMIT 3;
-SELECT id,title FROM notes WHERE archived_at IS NULL ORDER BY id DESC LIMIT 3;
-SELECT column_name,is_nullable,data_type
-FROM information_schema.columns
-WHERE table_name='notes' AND column_name='archived_at';
--- is_nullable=YES,data_type=timestamp with time zone
+SHOW VARIABLES LIKE 'log_bin';
+SHOW VARIABLES LIKE 'binlog_format';
+SHOW VARIABLES LIKE 'gtid_mode';
+SHOW VARIABLES LIKE 'binlog_expire_logs_seconds';
+SHOW BINARY LOG STATUS;
+-- 保存:备份时间/一致快照对应日志坐标或GTID/日志保留与异地副本
+-- 演练:隔离恢复实例、停止点、实际恢复耗时、允许丢失窗口、业务验收结果
 ````
 
 ### 验收结果
 
-- 备份校验和正确,pg_restore 清单包含业务表、约束和数据。
-- 独立恢复库行数与静止源库一致,外键无孤儿,新插入成功。
-- 新增字段后旧查询与新查询都可运行,迁移失败有停止和重试策略。
+- dump退出成功、校验和正确,独立恢复库数量与静止源库一致。
+- 外键无孤儿,恢复库新插入自增正常且能回滚DML。
+- 新增列前后旧查询兼容,明确DDL不能由ROLLBACK撤销。
 
 ### 生产约束与故障演练
 
-发布结构变更之前,需要证明数据库可恢复,旧版本服务仍能短暂并存。
+结构发布前需要证明可恢复,且新旧应用能在过渡期并存。
 
 #### 实现决策
 
-- 恢复目标与源库严格分开。
-- 备份验收覆盖数据、约束、权限与新写入。
-- 新增字段先兼容,再部署和回填;迁移文件纳入版本管理。
+- 恢复库与源库隔离。
+- 备份验证覆盖数据、外键、自增与权限。
+- DDL单独执行,明确兼容与恢复方案。
 
 #### 隔离环境演练
 
-在独立 notes_restore 检查备份恢复,然后在 notes_lab 用短锁超时演练新增可空字段;所有操作保持目标库可辨认。
+先逻辑恢复到notes_restore,再在notes_lab新增可空列;时间点恢复需真实日志与明确停止点后单独执行。
 
 #### 恢复验收
 
-- 记录实际恢复耗时与备份时间。
-- 恢复库能完成业务 CRUD。
-- 迁移前后访问兼容,生产 RPO/RTO 不由本机测试推定。
+- 有实际恢复证据后才标通过。
+- 未把本机耗时推定为线上SLA。
+- 不以删库或删卷作为常规回退。
 
 ### 常见错误
 
-#### 备份命令成功,但恢复时缺角色或权限
+#### 恢复导入竟修改原库
 
-原因:把单库逻辑备份当成完整实例备份,未规划角色与授权。
+原因:dump包含固定CREATE DATABASE/USE,或连接目标未核对。
 
-处理:在恢复流程中显式重建角色和授权,或由受控配置管理维护;本课使用 no-owner/no-acl 后重新授权。
+处理:本课不加--databases,导入前核对SQL内容与SELECT DATABASE;生产先隔离恢复。
 
-#### 上线加字段一直等待,API 也开始变慢
+#### 每日dump被认为能恢复到任意一分钟
 
-原因:DDL 正在等待强锁,可能与长事务形成阻塞队列。
+原因:没有完整binlog和一致起点。
 
-处理:用短 lock_timeout 退出,查等待链并缩短长事务;在明确窗口重试,不无限等锁。
+处理:规划备份坐标/GTID、日志保留与归档,并实际演练恢复停止点。
 
-#### 恢复行数不等于备份后源库行数
+#### INSTANT加列仍等待或不支持
 
-原因:源库继续写入,两个统计不属于同一快照。
+原因:元数据锁冲突或表/操作条件不满足。
 
-处理:练习对比时暂停写入;生产验收使用与备份快照一致的方法或业务不变量,不把后续变化误判为备份损坏。
+处理:短lock_wait_timeout失败后查阻塞,确认版本和限制,不盲目降级成大表复制。
 
 ### 扩展练习
 
-为“每日备份、允许最多丢失 15 分钟数据”的需求判断现有方案是否合格,并写出补充项。
+业务最多丢15分钟数据,只有每日mysqldump是否足够?还需验收什么?
 
-提示:先将备份间隔与 RPO 对照,再考虑恢复演练。
+提示:把备份间隔、binlog归档延迟、恢复速度分别对齐RPO/RTO。
 
 参考答案:
 
-单独每日 pg_dump 无法满足 15 分钟 RPO。需要能够持续归档的 WAL 与基础备份或受管数据库时间点恢复,并验证归档延迟、保留期、异地副本、恢复步骤和监控;按目标时间点实际演练并测量 RTO。
+不够。需要与一致性备份关联的连续binlog或受管时间点恢复,监控归档延迟与保留期,保护异地副本;实际恢复到指定停止点并测量RTO,同时重建权限和验收业务读写。
 
 ### 技术文档
 
-- [PostgreSQL 16:pg_dump](https://www.postgresql.org/docs/16/app-pgdump.html)
-- [PostgreSQL 16:pg_restore](https://www.postgresql.org/docs/16/app-pgrestore.html)
-- [PostgreSQL 16:持续归档与时间点恢复](https://www.postgresql.org/docs/16/continuous-archiving.html)
-- [PostgreSQL 16:ALTER TABLE](https://www.postgresql.org/docs/16/sql-altertable.html)
+- [MySQL 8.4:mysqldump](https://dev.mysql.com/doc/refman/8.4/en/mysqldump.html)
+- [MySQL 8.4:时间点恢复](https://dev.mysql.com/doc/refman/8.4/en/point-in-time-recovery.html)
+- [MySQL 8.4:在线DDL操作](https://dev.mysql.com/doc/refman/8.4/en/innodb-online-ddl-operations.html)
+- [MySQL 8.4:隐式提交](https://dev.mysql.com/doc/refman/8.4/en/implicit-commit.html)

+ 120 - 105
public/downloads/docker-lessons.md

@@ -181,7 +181,8 @@ fastapi>=0.121,<1
 uvicorn[standard]>=0.30,<1
 pydantic-settings>=2,<3
 SQLAlchemy>=2.0,<2.1
-psycopg[binary]>=3,<4
+asyncmy>=0.2,<0.3
+PyMySQL[rsa]>=1.1,<2
 alembic>=1.13,<2
 httpx>=0.27,<1
 celery[redis]>=5.4,<6
@@ -241,12 +242,12 @@ docker image inspect notes-service:lab-01 --format '{{.Config.User}} {{json .Con
 
 #### 5. 通过练习网络配置启动服务
 
-本课暂不启动完整服务,先验证代码和依赖存在;第 4 课 Compose 会提供 postgres DNS 与 DATABASE_URL。直接运行镜像若缺 DATABASE_URL 应明确失败,这比默认连错库更容易排错。
+本课暂不启动完整服务,先验证代码和依赖存在;第 4 课 Compose 会提供 mysql DNS 与 DATABASE_URL。直接运行镜像若缺 DATABASE_URL 应明确失败,这比默认连错库更容易排错。
 
 文件:终端 / 当前课时
 
 ````bash
-docker run --rm notes-service:lab-01 python -c "import fastapi, sqlalchemy, psycopg; print(fastapi.__version__, sqlalchemy.__version__, psycopg.__version__)"
+docker run --rm notes-service:lab-01 python -c "import fastapi, sqlalchemy, asyncmy, pymysql; print(fastapi.__version__, sqlalchemy.__version__); print('mysql drivers import ok')"
 docker run --rm notes-service:lab-01 python -c "from pathlib import Path; print(Path('/app/alembic.ini').exists(), Path('/app/migrations/env.py').exists())"
 ````
 
@@ -358,7 +359,7 @@ case "$PYTHON_IMAGE" in python@sha256:*) export PYTHON_IMAGE ;; *) echo 'unexpec
 
 #### 3. 替换 Dockerfile 为多阶段版本
 
-用本段完整替换第 2 课 Dockerfile。PYTHON_IMAGE 的默认标签供练习,发布构建必须通过 --build-arg 传已记录的 digest;builder 与 runtime 共用同一值。psycopg[binary] 避免本课额外引入编译工具。
+用本段完整替换第 2 课 Dockerfile。PYTHON_IMAGE 的默认标签供练习,发布构建必须通过 --build-arg 传已记录的 digest;builder 与 runtime 共用同一值。asyncmy需要匹配目标Python和架构的wheel;若目标平台没有wheel,在builder层安装编译所需工具,重新锁定并验证,运行层保留必要运行库。PyMySQL[rsa]支持MySQL 8.4默认的caching_sha2_password认证,不启用旧认证插件。
 
 文件:Dockerfile
 
@@ -470,11 +471,11 @@ docker image inspect notes-service:lab-02 --format '{{.Id}}'
 
 ## 04 Compose 网络、服务 DNS 与端口
 
-把 API、PostgreSQL 和 Redis 放入同一专用网络,区分容器内地址和宿主调试端口。
+把 API、MySQL 和 Redis 放入同一专用网络,区分容器内地址和宿主调试端口。
 
 ### 本节目标
 
-- 通过服务名连接 PostgreSQL 与 Redis
+- 通过服务名连接 MySQL 与 Redis
 - 绑定本机调试端口并避免冲突
 - 用 Compose 配置检查和容器内探针定位连通性
 
@@ -482,7 +483,7 @@ docker image inspect notes-service:lab-02 --format '{{.Id}}'
 
 #### localhost 指当前容器
 
-API 中 127.0.0.1 指向 API 自己,不能到 PostgreSQL。Compose 默认网络提供 postgres 和 redis 服务名解析;容器间连接使用 postgres:5432 与 redis:6379,宿主调试使用 127.0.0.1:5433 与 127.0.0.1:6380。
+API 中 127.0.0.1 指向 API 自己,不能到 MySQL。Compose 默认网络提供 mysql 和 redis 服务名解析;容器间连接使用 mysql:3306 与 redis:6379,宿主调试使用 127.0.0.1:3307 与 127.0.0.1:6380。
 
 #### 端口发布的方向
 
@@ -490,7 +491,7 @@ ports 的左侧是宿主绑定,右侧是容器监听。容器间通信不依
 
 #### 项目隔离
 
-Compose project name 区分同机多个练习栈的网络与卷;不要设置固定 container_name,它会阻碍扩容并造成跨项目名字冲突。示例数据库为 notes_lab,app_user 是普通应用角色,postgres 管理账号不进入 API。
+Compose project name 区分同机多个练习栈的网络与卷;不要设置固定 container_name,它会阻碍扩容并造成跨项目名字冲突。示例数据库为 notes_lab,app_user 是普通应用角色,mysql 管理账号不进入 API。
 
 ### 动手实现
 
@@ -504,30 +505,30 @@ Compose project name 区分同机多个练习栈的网络与卷;不要设置
 COMPOSE_PROJECT_NAME=notes-lab
 DB_ADMIN_PASSWORD=CHANGE_ADMIN_PASSWORD
 APP_DB_PASSWORD=CHANGE_APP_PASSWORD
-DATABASE_URL=postgresql+psycopg://app_user:CHANGE_APP_PASSWORD@postgres:5432/notes_lab
+DATABASE_URL=mysql+asyncmy://app_user:CHANGE_APP_PASSWORD@mysql:3306/notes_lab?charset=utf8mb4
 REDIS_URL=redis://redis:6379/0
 NOTES_IMAGE=notes-service:lab-02
 ````
 
-#### 2. 新建初始化脚本
+#### 2. 用官方镜像完成首次初始化
 
-新建 deploy/init/01-app.sh,保存 LF 换行。postgres 官方镜像在空数据目录首次初始化时执行脚本;后续改环境变量不会自动修改角色密码。psql 变量替换用 SQL 字符串引用处理密码,禁止直接字符串拼 SQL。练习 app_user 拥有 public schema 的 DDL 权限,生产迁移权限下一阶段单独拆分。
+MySQL官方镜像仅在空数据目录初始化MYSQL_DATABASE、MYSQL_USER和MYSQL_PASSWORD。app_user取得notes_lab库内权限,API不使用root。后续修改环境文件不会修改已存在账户的密码;已有卷应通过审核后的ALTER USER修改凭据。这里不再编写自定义建用户脚本,镜像负责转义初始化输入。
 
-文件:deploy/init/01-app.sh
+文件:初始化约定
 
-````bash
-#!/bin/sh
-set -eu
-psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB"   --set=app_password="$APP_DB_PASSWORD" <<'SQL'
-CREATE ROLE app_user LOGIN PASSWORD :'app_password';
-GRANT CONNECT ON DATABASE notes_lab TO app_user;
-GRANT USAGE, CREATE ON SCHEMA public TO app_user;
-SQL
+````text
+MYSQL_ROOT_PASSWORD → 独立管理员密码
+MYSQL_DATABASE=notes_lab → 仅首次创建练习库
+MYSQL_USER=app_user / MYSQL_PASSWORD → 仅该库的练习账号
+持久卷 mysql_data → /var/lib/mysql
+服务名 mysql:3306 → 容器访问
+127.0.0.1:3307 → 宿主访问
+InnoDB / utf8mb4 / UTC → 全模块约定
 ````
 
 #### 3. 新建 compose.yaml
 
-本段创建 API、Postgres、Redis 与独立卷;Redis 此时只是连通性练习,不承诺任务持久化。宿主数据库端口与数据库模块一致。API 初次启动可能尚未建表,第 6 课加入迁移和健康依赖。
+本段创建 API、MySQL、Redis 与独立卷;Redis 此时只是连通性练习,不承诺任务持久化。宿主数据库端口与数据库模块一致。API 初次启动可能尚未建表,第 6 课加入迁移和健康依赖。
 
 文件:compose.yaml
 
@@ -539,52 +540,51 @@ services:
       DATABASE_URL: ${DATABASE_URL:?set DATABASE_URL}
       REDIS_URL: ${REDIS_URL:?set REDIS_URL}
     ports: ["127.0.0.1:8000:8000"]
-  postgres:
-    image: postgres:16
+  mysql:
+    image: mysql:8.4
+    command: ["--character-set-server=utf8mb4", "--collation-server=utf8mb4_0900_ai_ci", "--default-time-zone=+00:00"]
     environment:
-      POSTGRES_DB: notes_lab
-      POSTGRES_USER: postgres
-      POSTGRES_PASSWORD: ${DB_ADMIN_PASSWORD:?set admin password}
-      APP_DB_PASSWORD: ${APP_DB_PASSWORD:?set app password}
-    ports: ["127.0.0.1:5433:5432"]
-    volumes:
-      - pg_data:/var/lib/postgresql/data
-      - ./deploy/init:/docker-entrypoint-initdb.d:ro
+      MYSQL_ROOT_PASSWORD: ${DB_ADMIN_PASSWORD:?set admin password}
+      MYSQL_DATABASE: notes_lab
+      MYSQL_USER: app_user
+      MYSQL_PASSWORD: ${APP_DB_PASSWORD:?set app password}
+    ports: ["127.0.0.1:3307:3306"]
+    volumes: ["mysql_data:/var/lib/mysql"]
   redis:
     image: redis:7.4
     ports: ["127.0.0.1:6380:6379"]
 volumes:
-  pg_data:
+  mysql_data: {}
 ````
 
 #### 4. 检查配置并启动依赖
 
-如果上一模块已经占用 5433 或 6380,先确认属于哪个独立练习栈,选择复用同一栈或停止其对应服务,不能直接杀掉未知进程。config --quiet 检查语法,不把展开后的密码配置贴进聊天或日志。
+如果上一模块已经占用 3307 或 6380,先确认属于哪个独立练习栈,选择复用同一栈或停止其对应服务,不能直接杀掉未知进程。config --quiet 检查语法,不把展开后的密码配置贴进聊天或日志。
 
 文件:终端 / 当前课时
 
 ````bash
 docker compose --env-file .env.compose config --quiet
-docker compose --env-file .env.compose up -d postgres redis
+docker compose --env-file .env.compose up -d mysql redis
 docker compose --env-file .env.compose ps
-docker compose --env-file .env.compose logs --tail 50 postgres
+docker compose --env-file .env.compose logs --tail 50 mysql
 ````
 
 #### 5. 从 API 网络位置检测服务 DNS
 
-run --rm 临时容器共享项目网络,使用 Python socket 验证 DNS 和 TCP 端口。成功只说明网络可达,不能证明账号权限或表迁移完成。
+run --rm 临时容器共享项目网络,使用 Python socket 验证 DNS 和 TCP 端口。成功只说明网络可达,不能证明账号权限或表迁移完成。 第二条命令交互输入app_user密码,并验证认证及库权限;网络探针成功不代替这一步。
 
 文件:终端 / 当前课时
 
 ````bash
-docker compose --env-file .env.compose run --rm --no-deps api python -c "import socket; print(socket.gethostbyname('postgres')); socket.create_connection(('postgres',5432),3).close(); socket.create_connection(('redis',6379),3).close(); print('tcp ok')"
-docker compose --env-file .env.compose exec postgres psql -U postgres -d notes_lab -c "SELECT rolname, rolsuper FROM pg_roles WHERE rolname='app_user';"
+docker compose --env-file .env.compose run --rm --no-deps api python -c "import socket; print(socket.gethostbyname('mysql')); socket.create_connection(('mysql',3306),3).close(); socket.create_connection(('redis',6379),3).close(); print('tcp ok')"
+docker compose --env-file .env.compose exec mysql mysql --protocol=TCP -h127.0.0.1 -uapp_user -p notes_lab -e "SELECT VERSION(),DATABASE(),CURRENT_USER(); SHOW GRANTS;"
 ````
 
 ### 验收结果
 
-- API 容器可解析 postgres/redis,并连接内部 5432/6379。
-- 宿主端口仅绑定 127.0.0.1 的 8000、5433、6380。
+- API 容器可解析 mysql/redis,并连接内部 3306/6379。
+- 宿主端口仅绑定 127.0.0.1 的 8000、3307、6380。
 - app_user 的 rolsuper 为 false;管理账号密码未注入 API。
 
 ### 生产约束与故障演练
@@ -607,11 +607,11 @@ docker compose --env-file .env.compose exec postgres psql -U postgres -d notes_l
 
 ### 常见错误
 
-#### API 连接 127.0.0.1:5433 被拒绝
+#### API 连接 127.0.0.1:3307 被拒绝
 
 原因:把宿主访问地址用于容器内部。
 
-处理:容器 DATABASE_URL 使用 postgres:5432;宿主 psql 才使用本机 5433。
+处理:容器DATABASE_URL使用mysql:3306;宿主mysql客户端使用127.0.0.1:3307。
 
 #### 修改 APP_DB_PASSWORD 后认证仍失败
 
@@ -621,46 +621,50 @@ docker compose --env-file .env.compose exec postgres psql -U postgres -d notes_l
 
 ### 扩展练习
 
-为了让 API 访问数据库,是否必须发布 5433?
+为了让 API 访问数据库,是否必须发布 3307?
 
 提示:Compose 服务互通走内部网络。
 
 参考答案:
 
-不需要。删去 postgres 的 ports 后,API 仍能使用 postgres:5432;宿主 psql 失去直接端口入口,可通过 docker compose exec 调试。线上通常不发布数据库宿主端口。
+不需要。删去 mysql 的 ports 后,API 仍能使用 mysql:3306;宿主 mysql客户端 失去直接端口入口,可通过 docker compose exec 调试。线上通常不发布数据库宿主端口。
 
 ### 技术文档
 
 - [Docker:Compose 网络](https://docs.docker.com/compose/how-tos/networking/)
 - [Docker:端口发布](https://docs.docker.com/engine/network/port-publishing/)
-- [Postgres 官方镜像说明](https://hub.docker.com/_/postgres)
+- [MySQL 官方容器镜像](https://github.com/docker-library/docs/blob/master/mysql/README.md)
 
 ---
 
-## 05 PostgreSQL 持久卷与可恢复备份
+## 05 MySQL 持久卷与可恢复备份
 
 验证重建数据库容器后数据仍存在,在独立恢复容器中验证逻辑备份。
 
 ### 本节目标
 
 - 区分 named volume 与 bind mount 的权限责任
-- 验证容器重建不丢 PostgreSQL 数据
+- 验证容器重建不丢 MySQL 数据
 - 制作并恢复逻辑备份,记录恢复证据
 
 ### 必要概念
 
 #### 卷保存数据库目录
 
-PostgreSQL 16 官方镜像的本课持久路径为 /var/lib/postgresql/data。命名卷由 Docker 管理宿主位置,容器删除不会自动删除命名卷。不要把卷当作备份,宿主磁盘故障和误删仍会影响它。
+MySQL 8.4 官方镜像的本课持久路径为 /var/lib/mysql。命名卷由 Docker 管理宿主位置,容器删除不会自动删除命名卷。不要把卷当作备份,宿主磁盘故障和误删仍会影响它。
 
 #### 权限与一致性
 
-宿主 bind mount 需要匹配数据库用户 UID/GID 与权限,直接 chmod 777 不是解决方案。运行中的 PGDATA 不能随手复制成可靠备份;用 pg_dump 做逻辑备份,物理备份需 pg_basebackup/WAL 等一致性协议。
+命名卷保存/var/lib/mysql,但卷本身不是备份。运行中的数据目录不能随手复制:逻辑备份用一致性mysqldump;物理备份使用支持该MySQL版本的一致性备份工具,并验证恢复与binlog位置。
 
 #### 恢复才验证备份
 
 备份成功退出只能证明生成了文件,不能证明恢复后应用能使用。恢复演练要用另一容器和另一卷,核对表、行数和业务查询;备份保留策略还需异机、加密、访问控制与实际 RPO/RTO。
 
+#### 逻辑备份与binlog恢复分工
+
+SQL转储恢复到快照时刻。更小RPO还需要连续保存binlog、匹配备份位置或GTID,并在独立实例验证时间点恢复;单独每日mysqldump不能满足分钟级数据损失目标。
+
 ### 动手实现
 
 #### 1. 确认卷与数据路径
@@ -670,50 +674,52 @@ PostgreSQL 16 官方镜像的本课持久路径为 /var/lib/postgresql/data。
 文件:终端 / 当前课时
 
 ````bash
-docker compose --env-file .env.compose ps -q postgres
-docker compose --env-file .env.compose exec postgres psql -U postgres -d notes_lab -c "SHOW data_directory;"
+docker compose --env-file .env.compose ps -q mysql
+docker compose --env-file .env.compose exec mysql mysql -uroot -p -e "SELECT @@datadir,@@version,@@default_storage_engine;"
 docker volume ls --filter label=com.docker.compose.project=notes-lab
 ````
 
 #### 2. 创建专用持久化标记并重建容器
 
-在练习库创建一张明确命名的验证表,重建仅 postgres 容器,保留 pg_data 卷。执行后查询同一行。正常生产更新仍要评估可用性与数据库版本兼容。 重建后先执行 pg_isready,返回 accepting connections 后再查询标记;尚未就绪时等待后重试,不把首次连接失败当作卷丢失。
+只在练习库创建volume_probe(InnoDB),重建mysql容器并保留mysql_data。重建后再次用app_user执行SELECT 1,连接和认证都成功后才查询标记;初始化期间连接失败不能证明卷丢失。下面每次-p均交互输入练习密码。
 
 文件:终端 / 当前课时
 
 ````bash
-docker compose --env-file .env.compose exec postgres psql -U postgres -d notes_lab -c "CREATE TABLE IF NOT EXISTS volume_probe (id integer PRIMARY KEY, value text NOT NULL); INSERT INTO volume_probe VALUES (1,'survives recreate') ON CONFLICT (id) DO UPDATE SET value=EXCLUDED.value;"
-docker compose --env-file .env.compose up -d --force-recreate postgres
-docker compose --env-file .env.compose exec postgres pg_isready -U postgres -d notes_lab
-# 上一步返回 accepting connections 后继续;否则等待后重试。
-docker compose --env-file .env.compose exec postgres psql -U postgres -d notes_lab -c "SELECT * FROM volume_probe;"
+docker compose --env-file .env.compose exec mysql mysql -uapp_user -p notes_lab -e "CREATE TABLE IF NOT EXISTS volume_probe (id INT PRIMARY KEY,value VARCHAR(100) NOT NULL) ENGINE=InnoDB; INSERT INTO volume_probe VALUES (1,'survives recreate') ON DUPLICATE KEY UPDATE value='survives recreate';"
+docker compose --env-file .env.compose up -d --force-recreate mysql
+docker compose --env-file .env.compose exec mysql mysql --protocol=TCP -h127.0.0.1 -uapp_user -p notes_lab -e "SELECT 1;"
+# 连接认证成功后继续;否则等待后重试。
+docker compose --env-file .env.compose exec mysql mysql -uapp_user -p notes_lab -e "SELECT * FROM volume_probe;"
 ````
 
 #### 3. 生成备份并从容器复制
 
-先在容器内生成自定义格式备份,再 docker compose cp,避免旧版 Windows PowerShell 重定向二进制流时损坏文件。备份含业务数据,放到已忽略的 backups 目录;不上传到公开资源。
+使用同一8.4镜像中的mysqldump,直接写容器内SQL文件后复制,避免Windows重定向改变编码。--single-transaction对InnoDB取得一致性快照,备份期间禁止并发DDL;它不保证非事务表一致性。--no-tablespaces减少所需权限,--set-gtid-purged=OFF适用于本课独立逻辑恢复,不是构建复制节点的流程。包含触发器、存储过程和事件,备份账号需相应权限,本隔离练习使用root。
 
 文件:终端 / 当前课时
 
 ````bash
 mkdir backups
-docker compose --env-file .env.compose exec postgres pg_dump -U postgres -d notes_lab -Fc --no-owner --no-acl -f /tmp/notes_lab.dump
-docker compose --env-file .env.compose cp postgres:/tmp/notes_lab.dump ./backups/notes_lab.dump
-docker compose --env-file .env.compose exec postgres pg_restore --list /tmp/notes_lab.dump
+docker compose --env-file .env.compose exec mysql mysqldump -uroot -p --single-transaction --routines --events --triggers --no-tablespaces --set-gtid-purged=OFF --hex-blob --default-character-set=utf8mb4 --result-file=/tmp/notes_lab.sql notes_lab
+docker compose --env-file .env.compose cp mysql:/tmp/notes_lab.sql ./backups/notes_lab.sql
+# 只有mysqldump退出0才使用该文件;文件存在、大小正常不代表恢复已验证。
 ````
 
 #### 4. 创建独立恢复容器
 
-新建 .env.restore,只含 POSTGRES_PASSWORD=独立练习密码,加入 .gitignore。恢复容器不发布端口,使用单独 named volume;docker exec 从同容器 Unix socket 操作。等待日志 ready 后再恢复,不复用业务 pg_data。
+新建已忽略的.env.restore,只含MYSQL_ROOT_PASSWORD=独立练习密码。恢复容器使用新的命名卷,不发布端口;mysql -p交互输入恢复密码。等待日志报告ready并能执行SELECT 1,再导入到notes_restore。备份未使用--databases,因此不会自动切回源库名。重复练习时使用新的恢复库与卷,不叠加到上次非空库。 导入使用mysql批处理标准输入,不使用--force;出错即停止。仅在此隔离容器中从已有MYSQL_ROOT_PASSWORD环境变量临时提供认证,不把密码展开到命令参数;生产备份恢复用受控客户端选项文件。
 
 文件:终端 / 当前课时
 
 ````bash
-docker volume create notes-restore-data
-docker run -d --name notes-restore --env-file .env.restore -e POSTGRES_DB=notes_restore -v notes-restore-data:/var/lib/postgresql/data postgres:16
-docker logs --tail 30 notes-restore
-docker cp ./backups/notes_lab.dump notes-restore:/tmp/notes_lab.dump
-docker exec notes-restore pg_restore -U postgres -d notes_restore --no-owner --no-acl --exit-on-error /tmp/notes_lab.dump
+docker volume create notes-mysql-restore-data
+docker run -d --name notes-mysql-restore --env-file .env.restore -e MYSQL_DATABASE=notes_restore -v notes-mysql-restore-data:/var/lib/mysql mysql:8.4 --default-time-zone=+00:00
+docker logs --tail 30 notes-mysql-restore
+docker exec -it notes-mysql-restore mysql -uroot -p -e "SELECT 1;"
+# 成功后再复制,用批处理标准输入恢复,检查退出码。
+docker cp ./backups/notes_lab.sql notes-mysql-restore:/tmp/notes_lab.sql
+docker exec notes-mysql-restore sh -c 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" mysql -uroot --default-character-set=utf8mb4 notes_restore < /tmp/notes_lab.sql'
 ````
 
 #### 5. 核对恢复并保留证据
@@ -723,17 +729,17 @@ docker exec notes-restore pg_restore -U postgres -d notes_restore --no-owner --n
 文件:终端 / 当前课时
 
 ````bash
-docker exec notes-restore psql -U postgres -d notes_restore -c "SELECT * FROM volume_probe;"
-docker stop notes-restore
-# 第06课迁移、创建笔记并重新备份到新的隔离恢复库后,再在新恢复库查询:
+docker exec -it notes-mysql-restore mysql -uroot -p notes_restore -e "SELECT * FROM volume_probe; SHOW TABLE STATUS LIKE 'volume_probe';"
+docker stop notes-mysql-restore
+# 第06课迁移并创建笔记后,重新备份到新的空恢复库,再核对:
 # SELECT count(*) FROM notes;
 # SELECT version_num FROM alembic_version;
 ````
 
 ### 验收结果
 
-- 重建 postgres 容器后 volume_probe 行仍存在。
-- pg_restore --list 可读取备份结构,恢复命令退出为 0。
+- 重建 mysql 容器后 volume_probe 行仍存在。
+- mysqldump与mysql导入均退出0,恢复后的volume_probe内容和InnoDB引擎符合预期。
 - 独立恢复库包含正确的 volume_probe,原练习库未被覆盖;业务表恢复在第06课迁移后用新备份补验。
 
 ### 生产约束与故障演练
@@ -760,17 +766,17 @@ docker stop notes-restore
 
 原因:未挂载持久卷、挂载路径错误,或切换了 Compose project name。
 
-处理:检查 Mounts 和卷标签,确认实际使用的 pg_data;不要在未确认旧卷前初始化覆盖或删除。
+处理:检查 Mounts 和卷标签,确认实际使用的 mysql_data;不要在未确认旧卷前初始化覆盖或删除。
 
 #### 恢复时报 archive 格式错误
 
 原因:用不支持二进制透明传输的 Shell 重定向生成备份。
 
-处理:在容器内写 -f 文件,再 docker cp;检查备份大小和 pg_restore --list。
+处理:使用mysqldump --result-file在容器内直接生成SQL文件,再docker cp;必须验证命令退出状态并恢复到独立空库。
 
 ### 扩展练习
 
-每天把 PGDATA 目录复制到同一宿主另一个文件夹能否替代备份方案?
+每天把 MySQLDATA 目录复制到同一宿主另一个文件夹能否替代备份方案?
 
 提示:分别考虑运行中一致性与宿主故障。
 
@@ -780,9 +786,9 @@ docker stop notes-restore
 
 ### 技术文档
 
-- [Docker:Volumes](https://docs.docker.com/engine/storage/volumes/)
-- [PostgreSQL 16:pg_dump](https://www.postgresql.org/docs/16/app-pgdump.html)
-- [PostgreSQL 16:pg_restore](https://www.postgresql.org/docs/16/app-pgrestore.html)
+- [MySQL 8.4:mysqldump](https://dev.mysql.com/doc/refman/8.4/en/mysqldump.html)
+- [MySQL 8.4:SQL格式备份恢复](https://dev.mysql.com/doc/refman/8.4/en/reloading-sql-format-dumps.html)
+- [Docker volumes](https://docs.docker.com/engine/storage/volumes/)
 
 ---
 
@@ -800,7 +806,7 @@ docker stop notes-restore
 
 #### running 不是 ready
 
-容器进程创建后,Postgres 可能仍在初始化,API 可能缺表。pg_isready 证明数据库接受连接,不验证 app_user 凭据或业务表。应用 /ready 执行 SQL,迁移作业另行证明表版本已到目标。
+容器进程已创建不代表MySQL完成初始化,也不代表业务表已迁移。数据库探针执行认证后的SELECT 1,应用/ready验证自己的数据库连接;迁移作业再核对表结构和revision。
 
 #### 启动顺序只覆盖启动
 
@@ -814,18 +820,18 @@ depends_on condition 控制 Compose 启动前置条件,不能保证依赖永
 
 #### 1. 增加数据库和 Redis 健康检查
 
-把以下字段分别合并到 compose.yaml 的 postgres 与 redis 服务内,不新建同名重复服务。$$ 让变量传入容器 Shell 后展开,避免 Compose 提前替换。
+将以下字段分别合并到mysql与redis服务。MySQL探针实际执行SELECT 1,能够检测认证失败;不能用只判断进程存活的ping代替就绪检查。$$延迟到容器Shell展开变量。此处沿用本机练习环境变量,生产应使用受控客户端凭据文件。
 
 文件:compose.yaml
 
 ````yaml
-# postgres 服务内
+# mysql 服务内
 healthcheck:
-  test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
+  test: ["CMD-SHELL", 'MYSQL_PWD="$${MYSQL_PASSWORD}" mysql --protocol=TCP -h127.0.0.1 -u"$${MYSQL_USER}" --database="$${MYSQL_DATABASE}" --connect-timeout=2 -e "SELECT 1" >/dev/null 2>&1']
   interval: 5s
   timeout: 3s
-  retries: 12
-  start_period: 10s
+  retries: 20
+  start_period: 30s
 
 # redis 服务内
 healthcheck:
@@ -837,7 +843,7 @@ healthcheck:
 
 #### 2. 新增一次性迁移服务
 
-合并到 services 层,镜像与 API 完全相同。练习迁移使用 app_user 的 DDL 权限;生产用单独迁移凭据覆盖此服务的 DATABASE_URL,API 保持更小权限。
+合并到 services 层,镜像与 API 完全相同。练习迁移使用 app_user 的 DDL 权限;生产用单独迁移凭据覆盖此服务的 DATABASE_URL,API 保持更小权限。 MySQL DDL通常隐式提交,迁移失败可能已经完成部分结构变更;先核对实际结构与revision,再修复或补偿,不能直接假定整个迁移事务已回滚。
 
 文件:compose.yaml
 
@@ -848,7 +854,7 @@ migrate:
   environment:
     DATABASE_URL: ${DATABASE_URL:?set DATABASE_URL}
   depends_on:
-    postgres:
+    mysql:
       condition: service_healthy
   restart: "no"
 ````
@@ -883,19 +889,19 @@ docker compose --env-file .env.compose config --quiet
 docker compose --env-file .env.compose up -d --wait
 docker compose --env-file .env.compose ps -a
 docker compose --env-file .env.compose logs --tail 50 migrate
-docker compose --env-file .env.compose exec postgres psql -U postgres -d notes_lab -c "SELECT version_num FROM alembic_version;"
+docker compose --env-file .env.compose exec mysql mysql -uapp_user -p notes_lab -e "SELECT version_num FROM alembic_version;"
 ````
 
 #### 5. 演练运行时数据库短暂停止
 
-仅在专用本机栈停止 postgres,此时 /live 应仍为 200、/ready 为 503;重启数据库后连接池 pre_ping 应逐步恢复。Docker 标记 unhealthy 本身不会自动重启容器,restart 策略主要响应进程退出。写入失败不要盲目自动重放,用幂等键确认结果。
+仅在专用本机栈停止 mysql,此时 /live 应仍为 200、/ready 为 503;重启数据库后连接池 pre_ping 应逐步恢复。Docker 标记 unhealthy 本身不会自动重启容器,restart 策略主要响应进程退出。写入失败不要盲目自动重放,用幂等键确认结果。
 
 文件:终端 / 当前课时
 
 ````bash
-docker compose --env-file .env.compose stop postgres
+docker compose --env-file .env.compose stop mysql
 docker compose --env-file .env.compose logs --tail 30 api
-docker compose --env-file .env.compose start postgres
+docker compose --env-file .env.compose start mysql
 docker compose --env-file .env.compose ps
 ````
 
@@ -916,7 +922,7 @@ docker compose --env-file .env.compose ps
 
 #### 隔离环境演练
 
-停止练习 postgres,观察 /live 与 /ready 分离,恢复后验证原数据和新写入。
+停止练习 mysql,观察 /live 与 /ready 分离,恢复后验证原数据和新写入。
 
 #### 恢复验收
 
@@ -951,7 +957,7 @@ docker compose --env-file .env.compose ps
 
 - [Docker:启动顺序](https://docs.docker.com/compose/how-tos/startup-order/)
 - [Docker:健康检查](https://docs.docker.com/reference/dockerfile/#healthcheck)
-- [PostgreSQL 16:pg_isready](https://www.postgresql.org/docs/16/app-pg-isready.html)
+- [MySQL 8.4:隐式提交](https://dev.mysql.com/doc/refman/8.4/en/implicit-commit.html)
 
 ---
 
@@ -1047,7 +1053,7 @@ docker compose --env-file .env.compose start api
 ````text
 API:1 进程/容器;数据库池 5;内存上限 512MiB;关闭预算 25s < 容器35s
 Worker:按第9课单独配置并发;连接数按实际执行模型计算
-宿主:为 Postgres、Redis、Docker 和系统留余量
+宿主:为 MySQL、Redis、Docker 和系统留余量
 证据:实际 stats 峰值、OOM 状态、SIGTERM 关闭耗时
 ````
 
@@ -1156,7 +1162,7 @@ settings = Settings()
 
 #### 2. 建立本机秘密文件
 
-在已忽略的 secrets 目录用编辑器新建 database_url,填写 postgres:5432 的容器连接串。以下为 Linux / WSL 的本地 file-backed secrets 方案:父目录由当前可信宿主账户持有且权限700,单个绑定文件644,使容器UID10001可读。其他普通宿主账户无法穿过700父目录;有Docker控制权限的账户仍属于受信任管理员。不能仅写uid/gid/mode并假设Compose会重映射文件权限。
+在已忽略的 secrets 目录用编辑器新建 database_url,填写 mysql:3306 的容器连接串。以下为 Linux / WSL 的本地 file-backed secrets 方案:父目录由当前可信宿主账户持有且权限700,单个绑定文件644,使容器UID10001可读。其他普通宿主账户无法穿过700父目录;有Docker控制权限的账户仍属于受信任管理员。不能仅写uid/gid/mode并假设Compose会重映射文件权限。
 
 文件:终端 / 当前课时
 
@@ -1311,7 +1317,7 @@ COPY deploy/run_role.py ./deploy/run_role.py
 
 #### 2. 为任务角色建立秘密加载入口
 
-新建 deploy/run_role.py。API 的 SQLAlchemy URL 用 postgresql+psycopg://,任务模块直接 psycopg.connect 使用 postgresql://,两者分别保存。入口从三个只读秘密文件读入实际环境变量再 exec 目标进程,不输出秘密;读取 /run/secrets/task_database_url、celery_broker_url、celery_result_backend。
+新建deploy/run_role.py。API的SQLAlchemy异步URL使用mysql+asyncmy://;任务的同步PyMySQL适配器接受mysql+pymysql://,均指向mysql:3306/notes_lab。入口从三个只读秘密文件读取环境变量后exec目标进程,不打印值。同步连接的解析、UTC与事务设置继续复用任务模块state_store.py。
 
 文件:deploy/run_role.py
 
@@ -1336,7 +1342,7 @@ os.execvp(sys.argv[1], sys.argv[1:])
 
 #### 3. 配置独立认证 broker 与秘密文件
 
-新增 broker 服务,不复用缓存 redis。宿主6384为任务专用,6380–6383已由Redis课程其他隔离实例使用。用强随机 TASK_BROKER_PASSWORD 在编辑器生成秘密文件:task_broker_conf 包含 requirepass、appendonly yes、appendfsync everysec、maxmemory 192mb、maxmemory-policy noeviction、dir /data;celery_broker_url 为 redis://:URL编码密码@broker:6379/0,celery_result_backend 为同地址/1;task_database_url 为 postgresql://app_user:URL编码密码@postgres:5432/notes_lab。三个URL文件由task服务读取;broker配置只读挂载,不提交到仓库。 本地file-backed secrets按第8课使用宿主受控父目录700、单秘密文件644,使UID10001可读取实际挂载;启动前用run --no-deps检查可读性且不打印内容。TASK_BROKER_PASSWORD建议本机生成高熵十六进制值,使Redis配置requirepass行无需额外引号转义。
+新增 broker 服务,不复用缓存 redis。宿主6384为任务专用,6380–6383已由Redis课程其他隔离实例使用。用强随机 TASK_BROKER_PASSWORD 在编辑器生成秘密文件:task_broker_conf 包含 requirepass、appendonly yes、appendfsync everysec、maxmemory 192mb、maxmemory-policy noeviction、dir /data;celery_broker_url 为 redis://:URL编码密码@broker:6379/0,celery_result_backend 为同地址/1;task_database_url 为 mysql+pymysql://app_user:URL编码密码@mysql:3306/notes_lab。三个URL文件由task服务读取;broker配置只读挂载,不提交到仓库。 本地file-backed secrets按第8课使用宿主受控父目录700、单秘密文件644,使UID10001可读取实际挂载;启动前用run --no-deps检查可读性且不打印内容。TASK_BROKER_PASSWORD建议本机生成高熵十六进制值,使Redis配置requirepass行无需额外引号转义。
 
 文件:compose.yaml
 
@@ -1443,17 +1449,25 @@ docker compose --env-file .env.compose logs --tail 50 worker worker-bulk publish
 
 ````bash
 docker compose --env-file .env.compose stop worker worker-bulk
-docker compose --env-file .env.compose run --rm --no-deps worker python /app/deploy/run_role.py python -c "from state_store import connect,submit_snapshot; conn=connect(); row=conn.execute('SELECT id FROM notes ORDER BY id LIMIT 1').fetchone(); conn.close(); assert row, 'create a note first'; print(submit_snapshot([row['id']]))"
+# 以下为Bash/WSL,在容器内用真实transaction()适配器选择笔记再提交。
+docker compose --env-file .env.compose run --rm -T --no-deps worker python /app/deploy/run_role.py python - <<'PY'
+from state_store import transaction, submit_snapshot
+with transaction() as cur:
+    cur.execute("SELECT id FROM notes ORDER BY id LIMIT 1")
+    row = cur.fetchone()
+assert row, "create a note first"
+print(submit_snapshot([row["id"]]))
+PY
 docker compose --env-file .env.compose start worker worker-bulk
-docker compose --env-file .env.compose exec postgres psql -U postgres -d notes_lab -c "SELECT id,status,result_ref,attempts FROM export_jobs ORDER BY created_at DESC LIMIT 10;"
-# 用真实返回的UUID替换JOB_ID;任务完成后验证artifact
+docker compose --env-file .env.compose exec mysql mysql -uapp_user -p notes_lab -e "SELECT id,status,result_ref,attempts FROM export_jobs ORDER BY created_at DESC LIMIT 10;"
+# 将JOB_ID替换为刚返回的真实UUID,完成后验收结果文件。
 docker compose --env-file .env.compose exec worker python /app/deploy/run_role.py python verify_terminal.py JOB_ID
 ````
 
 ### 验收结果
 
 - Celery inspect 注册 notes.export,fast/bulk worker 只消费对应队列。
-- PostgreSQL中job/outbox到最终状态与可读取的输出文件一致,不能只凭Celery结果缓存。
+- MySQL中job/outbox到最终状态与可读取的输出文件一致,不能只凭Celery结果缓存。
 - 缓存redis与认证broker是不同实例;宿主任务端口6384,容器内部broker:6379。
 
 ### 生产约束与故障演练
@@ -1490,13 +1504,13 @@ API 快速返回任务已提交,worker 可以独立扩缩容和更新。
 
 ### 扩展练习
 
-为什么不能把postgresql+psycopg://连接串原样交给任务模块的psycopg.connect?
+为什么API和同步任务使用不同的MySQL连接URL?
 
-提示:区分SQLAlchemy方言URL与PostgreSQL驱动DSN。
+提示:区分SQLAlchemy方言URL与MySQL驱动DSN。
 
 参考答案:
 
-API使用SQLAlchemy URL;任务state_store直接使用psycopg.connect,需要postgresql://或libpq键值DSN。因此配置分成API database_url与task_database_url,端口和账号相同但协议前缀不同。
+SQLAlchemy的mysql+asyncmy://选择异步驱动;任务state_store适配器解析mysql+pymysql://并调用同步PyMySQL。两者可以使用相同库和账号,但驱动入口及调用方式不同,不能把异步SQLAlchemy URL直接交给同步驱动。
 
 ### 技术文档
 
@@ -1556,7 +1570,7 @@ API使用SQLAlchemy URL;任务state_store直接使用psycopg.connect,需要p
 
 ````bash
 docker build --build-arg PYTHON_IMAGE="${PYTHON_IMAGE:?set verified base digest}" -t notes-service:release-01 .
-docker run --rm notes-service:release-01 python -c "import fastapi, sqlalchemy, psycopg; print('imports ok')"
+docker run --rm notes-service:release-01 python -c "import fastapi, sqlalchemy, asyncmy, pymysql; print('imports ok')"
 # 已具备目标仓库权限后,替换 REGISTRY/PROJECT 为真实仓库:
 # docker tag notes-service:release-01 REGISTRY/PROJECT/notes-service:release-01
 # docker push REGISTRY/PROJECT/notes-service:release-01
@@ -1578,7 +1592,7 @@ docker compose --env-file .env.compose logs --tail 100 api worker worker-bulk pu
 
 #### 4. 验证业务再决定继续或回退
 
-检查 /ready、创建读取、旧版本冲突、幂等重试和 真实导出任务;记录实际 HTTP 与数据库结果。失败时先将 NOTES_IMAGE 恢复上一兼容版本,再 up --no-deps api worker,不回退 Redis/Postgres 镜像主版本,也不自动操作数据库迁移。
+检查 /ready、创建读取、旧版本冲突、幂等重试和 真实导出任务;记录实际 HTTP 与数据库结果。失败时先将 NOTES_IMAGE 恢复上一兼容版本,再 up --no-deps api worker,不回退 Redis/MySQL 镜像主版本,也不自动操作数据库迁移。
 
 文件:终端 / 当前课时
 
@@ -1618,6 +1632,7 @@ OOM演练 | 专用容器OOMKilled | 调整预算并检查宿主余量
 
 - 构建一次并以明确版本交付。
 - 应用回退与数据库恢复分开决策,保持数据卷。
+- MySQL的原子DDL保证单条受支持DDL的原子性,不把多条Alembic操作变成可整体回滚事务;发布门槛同时核对实际schema与revision。
 
 #### 隔离环境演练
 

+ 64 - 55
public/downloads/linux-lessons.md

@@ -177,7 +177,7 @@ PermitRootLogin no
 
 #### 公开端口最小化
 
-Nginx对外提供80/443,API只监听127.0.0.1:8000;PostgreSQL与Redis继续使用本机回环调试端口或受控私网。Docker发布端口可能经过不同于普通INPUT的转发规则,不能假定UFW会拦截所有Docker ports。
+Nginx对外提供80/443,API只监听127.0.0.1:8000;MySQL与Redis继续使用本机回环调试端口或受控私网。Docker发布端口可能经过不同于普通INPUT的转发规则,不能假定UFW会拦截所有Docker ports。
 
 #### 防止锁死
 
@@ -241,7 +241,7 @@ sudo systemctl status notes-firewall-rollback.timer --no-pager
 
 #### 5. 核对Docker与云侧规则
 
-若依赖运行在Docker,docker ps检查端口只能为127.0.0.1:5433、缓存6380、任务broker6384。云安全组按本练习仅开放已验证SSH和80/443;此课不代替在真实云账号创建规则。
+若依赖运行在Docker,docker ps检查端口只能为127.0.0.1:3307、缓存6380、任务broker6384。云安全组按本练习仅开放已验证SSH和80/443;此课不代替在真实云账号创建规则。
 
 文件:终端 / 当前课时
 
@@ -329,19 +329,19 @@ Ubuntu24.04通常提供Python3.12,满足课程Python3.11+要求;不要为了
 
 #### 环境文件不是Shell脚本
 
-systemd EnvironmentFile读取KEY=value,不执行$(command)或source逻辑。API使用SQLAlchemy URL,任务直接psycopg使用PostgreSQL DSN,分别存文件。秘密文件root管理,运行用户通过所属组只读,诊断时不输出全文。
+systemd EnvironmentFile读取KEY=value,不执行命令或source。API使用mysql+asyncmy://;任务state_store解析mysql+pymysql://后建立同步连接。秘密文件由root管理,运行组只读,诊断时不输出全文。
 
 ### 动手实现
 
 #### 1. 安装Ubuntu提供的运行依赖
 
-只在独立VM安装包;升级已有生产主机应走维护流程。本模块进程主线使用Ubuntu默认python3,不安装到系统site-packages。若数据库继续使用Docker,宿主只安装postgresql-client用于检查与备份。
+在独立Ubuntu VM安装应用运行依赖和交互SQL客户端;不在系统site-packages安装应用。发行版客户端版本可能与MySQL8.4不同,交互查询先检查兼容性;第09课备份明确使用mysql:8.4工具镜像,不假定Ubuntu默认安装的是8.4。
 
 文件:终端 / 当前课时
 
 ````bash
 sudo apt update
-sudo apt install -y python3 python3-venv nginx postgresql-client curl rsync
+sudo apt install -y python3 python3-venv nginx default-mysql-client curl rsync
 python3 --version
 ````
 
@@ -362,19 +362,19 @@ sudo /srv/notes-service/releases/r001/.venv/bin/python -m pip install --require-
 
 #### 3. 建立两个专用环境文件
 
-用sudoedit创建下列文件,填VM实际依赖地址与URL编码密码。若沿用Docker练习依赖:PG宿主5433,缓存6380,任务broker6384;若迁移到受控私网数据库按实际地址改,绝不能在主机进程中使用Docker服务名postgres/broker。
+用sudoedit创建下列文件,填VM实际依赖地址与URL编码密码。若沿用Docker练习依赖:MySQL宿主3307,缓存6380,任务broker6384;若迁移到受控私网数据库按实际地址改,绝不能在主机进程中使用Docker服务名mysql/broker。
 
 文件:终端 / 当前课时
 
 ````dotenv
 # /etc/notes-service.env(API)
-DATABASE_URL=postgresql+psycopg://app_user:ENCODED_PASSWORD@127.0.0.1:5433/notes_lab
+DATABASE_URL=mysql+asyncmy://app_user:ENCODED_PASSWORD@127.0.0.1:3307/notes_lab?charset=utf8mb4
 DB_POOL_SIZE=5
 DB_MAX_OVERFLOW=0
 DB_POOL_TIMEOUT=2
 
 # /etc/notes-tasks.env(tasks_lab)
-DATABASE_URL=postgresql://app_user:ENCODED_PASSWORD@127.0.0.1:5433/notes_lab
+DATABASE_URL=mysql+pymysql://app_user:ENCODED_PASSWORD@127.0.0.1:3307/notes_lab?charset=utf8mb4
 CELERY_BROKER_URL=redis://:ENCODED_TASK_BROKER_PASSWORD@127.0.0.1:6384/0
 CELERY_RESULT_BACKEND=redis://:ENCODED_TASK_BROKER_PASSWORD@127.0.0.1:6384/1
 ````
@@ -402,7 +402,7 @@ sudo -u notes test -w /srv/notes-service/shared/artifacts
 文件:终端 / 当前课时
 
 ````bash
-sudo -u notes /srv/notes-service/current/.venv/bin/python -c "import sys, fastapi, psycopg; print(sys.version); print(fastapi.__version__, psycopg.__version__)"
+sudo -u notes /srv/notes-service/current/.venv/bin/python -c "import sys, fastapi, asyncmy, pymysql; print(sys.version); print(fastapi.__version__); print('mysql drivers import ok')"
 sudo -u notes readlink -f /srv/notes-service/current
 sudo namei -l /srv/notes-service/current/app/main.py
 ````
@@ -433,11 +433,11 @@ sudo namei -l /srv/notes-service/current/app/main.py
 
 ### 常见错误
 
-#### 直接psycopg报invalid connection option或scheme
+#### 任务连接URL解析失败或加载了错误驱动
 
-原因:将SQLAlchemy的postgresql+psycopg://交给直接驱动。
+原因:把API的异步驱动URL原样复制给任务的同步PyMySQL适配器。
 
-处理:任务环境使用postgresql://,API保留SQLAlchemy方言URL。
+处理:API使用mysql+asyncmy://,任务使用mysql+pymysql://;核对URL编码、3307宿主端口与实际环境来源。
 
 #### systemd找不到包但登录终端能导入
 
@@ -1056,7 +1056,7 @@ sudo -u notes readlink -f /srv/notes-service/current
 
 #### 2. 用一次性systemd作业运行迁移
 
-仅对专用练习数据库执行已审核迁移;EnvironmentFile使用API SQLAlchemy URL,迁移账号若已分离应改为独立/etc/notes-migrate.env。systemd-run --wait会报告进程结果;失败不继续切换。先保存当前revision与备份记录。
+仅对专用练习数据库执行已审核迁移;EnvironmentFile使用API SQLAlchemy URL,迁移账号若已分离应改为独立/etc/notes-migrate.env。systemd-run --wait会报告进程结果;失败不继续切换。先保存当前revision与备份记录。 MySQL DDL隐式提交,失败后检查已经应用的语句和实际结构,保留兼容窗口;不得直接重复整份迁移或认为失败即零变更。
 
 文件:终端 / 当前课时
 
@@ -1293,7 +1293,7 @@ sudo journalctl -u notes-maintenance@publisher --since '5 minutes ago' --no-page
 
 原因:文件可能仍由进程持有,或误删了重要数据。
 
-处理:先用lsof等确认持有者和文件类型,按服务支持方式轮转;不能删除PGDATA文件排空间。
+处理:先用lsof等确认持有者和文件类型,按服务支持方式轮转;不能删除MySQLDATA文件排空间。
 
 ### 扩展练习
 
@@ -1319,51 +1319,58 @@ worker进程active就说明任务系统健康吗?
 
 ### 本节目标
 
-- 创建可验证的PostgreSQL逻辑备份
+- 创建可验证的MySQL逻辑备份
 - 对备份加密并传到获准的独立存储
 - 测量恢复时间并核对应用级数据
 
 ### 必要概念
 
-#### RPO与RTO
+#### 一致性快照与DDL窗口
 
-RPO描述可接受的数据回退时长,RTO描述可接受的恢复耗时。每天一次逻辑备份通常无法支持分钟级RPO,需WAL归档和PITR等方案。恢复时间包含取回备份、解密、建库、恢复、索引和业务验收,不只是pg_restore命令。
+mysqldump --single-transaction从InnoDB一致性快照读取数据;非事务表和并发DDL需要其他协调措施,成功退出不是恢复能力的证明。
 
-#### 权限与密钥分离
+#### RPO取决于快照和binlog保留
 
-备份包含业务数据,应使用专用备份账号与最小读取权限,传输加密、静态加密、访问审计。解密私钥不能只放在同一台被备份主机,否则主机丢失后备份也无法解密。
+每日逻辑转储无法满足分钟级RPO。需要快照对应的位置或GTID,以及完整、连续、异地保存的binlog,再在隔离实例演练指定时间恢复。
 
-#### 恢复隔离
+#### 恢复包含数据库和外部结果
 
-恢复到新的notes_restore数据库或独立恢复主机,不能覆盖线上notes_lab来试验。行数、关键约束、迁移版本、任务状态与文件引用都要检查;数据库备份不自动包含shared/artifacts,输出文件需独立备份或可重建规则。
+恢复SQL后还要核对任务result_ref对应的artifacts或对象存储。恢复环境不得连接真实broker并意外执行旧任务;账号、权限、事件调度器与外发都应单独验收。
 
 ### 动手实现
 
 #### 1. 为练习备份建立目录与凭据文件
 
-在VM创建root专用备份目录。使用.pgpass格式的/etc/notes-backup.pgpass保存专用备份凭据,权限600;示例可用练习app_user作为自己表的所有者读取,生产另建备份角色并验证所需权限。密码中的:与\要按libpq规则转义。
+使用root专用目录保存备份。用sudoedit建立/etc/notes-backup.cnf,内容是MySQL客户端[client]配置,权限600;密码按MySQL选项文件规则加引号与转义,不使用URL编码。备份账号需具备SELECT、SHOW VIEW、TRIGGER等权限;包含routines/events时另核对对应权限。备份客户端使用8.4镜像,Docker命令由管理员执行。
 
 文件:终端 / 当前课时
 
 ````bash
 sudo install -d -o root -g root -m 700 /var/backups/notes-service
-sudoedit /etc/notes-backup.pgpass
-# 文件格式:127.0.0.1:5433:notes_lab:BACKUP_USER:ESCAPED_PASSWORD
-sudo chown root:root /etc/notes-backup.pgpass
-sudo chmod 600 /etc/notes-backup.pgpass
+sudoedit /etc/notes-backup.cnf
+# 文件内容(用编辑器填写真实值;不复制秘密到终端输出):
+# [client]
+# host=127.0.0.1
+# port=3307
+# protocol=TCP
+# user=BACKUP_USER
+# password="ACTUAL_PASSWORD"
+# default-character-set=utf8mb4
+sudo chown root:root /etc/notes-backup.cnf
+sudo chmod 600 /etc/notes-backup.cnf
 ````
 
 #### 2. 生成备份并检查可读目录
 
-下方命令中的BACKUP_USER替换为已验证的备份角色。使用PostgreSQL16客户端备份16服务器;Ubuntu24.04默认客户端通常为16,先检查。sudo env只指定凭据文件路径,命令行不写密码。备份文件名本次固定lab-01,存在时换新编号,不能覆盖唯一好备份。
+以下仅用于独立Linux VM,工具容器用host网络访问配置中的本机3307;它不启动数据库服务。--defaults-extra-file必须紧跟程序名。--single-transaction要求业务表均为InnoDB,备份期间不要执行DDL。示例不修改GTID集合;创建复制节点另按GTID一致性流程处理。固定文件名用于本次演练,已存在时换新编号;失败文件不能覆盖唯一可用备份。
 
 文件:终端 / 当前课时
 
 ````bash
-pg_dump --version
-sudo env PGPASSFILE=/etc/notes-backup.pgpass pg_dump -h 127.0.0.1 -p 5433 -U BACKUP_USER -d notes_lab -Fc --no-owner --no-acl -f /var/backups/notes-service/notes-lab-01.dump
-sudo pg_restore --list /var/backups/notes-service/notes-lab-01.dump
-sudo sha256sum /var/backups/notes-service/notes-lab-01.dump
+sudo docker run --rm mysql:8.4 mysqldump --version
+sudo docker run --rm --network host   --mount type=bind,source=/etc/notes-backup.cnf,target=/run/secrets/client.cnf,readonly   --mount type=bind,source=/var/backups/notes-service,target=/backup   mysql:8.4 mysqldump --defaults-extra-file=/run/secrets/client.cnf   --single-transaction --routines --events --triggers --no-tablespaces   --set-gtid-purged=OFF --hex-blob --default-character-set=utf8mb4   --result-file=/backup/notes-lab-01.sql notes_lab
+# 仅退出0后记录摘要;摘要不能代替实际恢复。
+sudo sha256sum /var/backups/notes-service/notes-lab-01.sql
 ````
 
 #### 3. 使用接收方公钥加密
@@ -1374,24 +1381,25 @@ sudo sha256sum /var/backups/notes-service/notes-lab-01.dump
 
 ````bash
 # 备份VM,仅持有接收方公钥
-sudo age -r AGE_RECIPIENT -o /var/backups/notes-service/notes-lab-01.dump.age /var/backups/notes-service/notes-lab-01.dump
-sudo sha256sum /var/backups/notes-service/notes-lab-01.dump.age
+sudo age -r AGE_RECIPIENT -o /var/backups/notes-service/notes-lab-01.sql.age /var/backups/notes-service/notes-lab-01.sql
+sudo sha256sum /var/backups/notes-service/notes-lab-01.sql.age
 # 使用已验证主机密钥的独立备份目标;替换主机、账号、路径后再执行
-# scp notes-lab-01.dump.age backup@AUTHORIZED_BACKUP_HOST:/approved/notes-service/
+# scp notes-lab-01.sql.age backup@AUTHORIZED_BACKUP_HOST:/approved/notes-service/
 ````
 
 #### 4. 在独立恢复环境解密与恢复
 
-只在独立恢复VM或数据库执行,先createdb创建notes_restore并验证名称。解密私钥文件必须具有合适权限;不把私钥写在命令参数正文。记录开始与结束时间,恢复账号需在该恢复库有建表权限。
+只在独立恢复VM和空notes_restore库操作。先解密并与原SQL摘要核对;在恢复主机准备mysql8.4客户端及权限600的/secure/restore.cnf([client]内填恢复主机/端口/管理员/密码)。未指定--databases的转储不会自动切换源库。记录导入时间和退出状态,恢复API使用独立broker并禁用真实外发。
 
 文件:终端 / 当前课时
 
 ````bash
-age -d -i /secure/recovery-age-key.txt -o notes-lab-01-restored.dump notes-lab-01.dump.age
-sha256sum notes-lab-01-restored.dump
-createdb -h RESTORE_HOST -U RESTORE_ADMIN -W notes_restore
+age -d -i /secure/recovery-age-key.txt -o notes-lab-01-restored.sql notes-lab-01.sql.age
+sha256sum notes-lab-01-restored.sql
+mysql --version
+mysql --defaults-extra-file=/secure/restore.cnf -e "CREATE DATABASE notes_restore CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;"
 date -u
-pg_restore -h RESTORE_HOST -U RESTORE_ADMIN -W -d notes_restore --no-owner --no-acl --exit-on-error notes-lab-01-restored.dump
+mysql --defaults-extra-file=/secure/restore.cnf --default-character-set=utf8mb4 notes_restore < notes-lab-01-restored.sql
 date -u
 ````
 
@@ -1402,7 +1410,7 @@ date -u
 文件:终端 / 当前课时
 
 ````sql
-SELECT current_database();
+SELECT DATABASE(),@@version,@@session.time_zone;
 SELECT count(*) FROM notes;
 SELECT version_num FROM alembic_version;
 SELECT status, count(*) FROM export_jobs GROUP BY status;
@@ -1423,6 +1431,7 @@ SELECT status, count(*) FROM export_jobs GROUP BY status;
 
 - 备份异机加密,密钥另行保管。
 - 恢复库和队列隔离,业务验收计入RTO。
+- binlog保留期与备份周期、恢复速度配套;--set-gtid-purged=OFF的独立逻辑恢复不直接充当复制节点初始化流程。
 
 #### 隔离环境演练
 
@@ -1455,14 +1464,14 @@ SELECT status, count(*) FROM export_jobs GROUP BY status;
 
 参考答案:
 
-不能。要达到分钟级RPO,应设计并验证WAL归档/PITR或其他受控方案,同时监控归档延迟;逻辑备份仍可作为补充,但目标必须有实际恢复证据。
+不能。要达到分钟级RPO,应设计并验证连续binlog归档与时间点恢复或其他受控方案,同时监控归档延迟;逻辑备份仍可作为补充,但目标必须有实际恢复证据。
 
 ### 技术文档
 
-- [PostgreSQL16:备份恢复](https://www.postgresql.org/docs/16/backup.html)
-- [PostgreSQL16:持续归档与PITR](https://www.postgresql.org/docs/16/continuous-archiving.html)
-- [PostgreSQL16:密码文件](https://www.postgresql.org/docs/16/libpq-pgpass.html)
 - [age官方用法](https://github.com/FiloSottile/age)
+- [MySQL 8.4:备份方法](https://dev.mysql.com/doc/refman/8.4/en/backup-methods.html)
+- [MySQL 8.4:binlog时间点恢复](https://dev.mysql.com/doc/refman/8.4/en/point-in-time-recovery.html)
+- [MySQL 8.4:选项文件](https://dev.mysql.com/doc/refman/8.4/en/option-files.html)
 
 ---
 
@@ -1528,17 +1537,17 @@ API+数据库最终脚本先验证创建、PATCH version与409;任务侧使用
 文件:终端 / 当前课时
 
 ````bash
-sudo systemd-run --unit=notes-submit-probe --wait --collect --pipe \
-  --property=User=notes --property=Group=notes \
-  --property=WorkingDirectory=/srv/notes-service/current/tasks_lab \
-  --property=EnvironmentFile=/etc/notes-tasks.env \
-  /srv/notes-service/current/.venv/bin/python -c "from state_store import connect,submit_snapshot; conn=connect(); row=conn.execute('SELECT id FROM notes ORDER BY id LIMIT 1').fetchone(); conn.close(); assert row, 'create a note first'; print(submit_snapshot([row['id']]))"
-# 记录上一步实际job_id,完成后替换JOB_ID
-sudo systemd-run --unit=notes-verify-probe --wait --collect --pipe \
-  --property=User=notes --property=Group=notes \
-  --property=WorkingDirectory=/srv/notes-service/current/tasks_lab \
-  --property=EnvironmentFile=/etc/notes-tasks.env \
-  /srv/notes-service/current/.venv/bin/python verify_terminal.py JOB_ID
+# Bash:选真实笔记并提交,配置由systemd读取,不source秘密文件。
+sudo systemd-run --unit=notes-submit-probe --wait --collect --pipe   --property=User=notes --property=Group=notes   --property=WorkingDirectory=/srv/notes-service/current/tasks_lab   --property=EnvironmentFile=/etc/notes-tasks.env   /srv/notes-service/current/.venv/bin/python - <<'PY'
+from state_store import transaction, submit_snapshot
+with transaction() as cur:
+    cur.execute("SELECT id FROM notes ORDER BY id LIMIT 1")
+    row = cur.fetchone()
+assert row, "create a note first"
+print(submit_snapshot([row["id"]]))
+PY
+# 替换为上一步真实JOB_ID,等待任务完成后核对结果。
+sudo systemd-run --unit=notes-verify-probe --wait --collect --pipe   --property=User=notes --property=Group=notes   --property=WorkingDirectory=/srv/notes-service/current/tasks_lab   --property=EnvironmentFile=/etc/notes-tasks.env   /srv/notes-service/current/.venv/bin/python verify_terminal.py JOB_ID
 ````
 
 #### 4. 逐个注入隔离故障并恢复

+ 168 - 98
public/downloads/redis-lessons.md

@@ -16,7 +16,7 @@
 
 #### 缓存副本与业务真源
 
-本模块以 PostgreSQL notes_lab 为笔记真源,Redis 只保存可重新生成的副本。缓存命中减少数据库读取;缓存丢失应影响性能而不应删除笔记。计数器、锁和消息队列的正确性要求不同,不能自动套用可随时丢弃的缓存策略。
+本模块以MySQL 8.4 LTS的InnoDB notes_lab为笔记真源,Redis只保存可重新生成的副本。缓存丢失影响读取性能,不应删除真实笔记。业务连接显式RC、UTC与有界等待;数据库命令行课另行演示默认RR。计数器、锁和消息队列的正确性要求不同,不能自动套用可随时丢弃的缓存策略。
 
 #### 数据类型决定命令
 
@@ -136,13 +136,14 @@ r.delete(k)
 
 ### 生产约束与故障演练
 
-笔记详情需要缓存,但重启 Redis 不能影响 PostgreSQL 中的真实笔记。
+笔记详情需要缓存,但重启 Redis 不能影响 MySQL 中的真实笔记。
 
 #### 实现决策
 
 - 键名包含应用、环境与数据用途。
 - 固定开发端口并设置认证。
 - 缓存写入与 TTL 在同一条命令中完成。
+- MySQL连接使用MYSQL_*配置,宿主3307/容器3306;Redis端口仍为6380。
 
 #### 隔离环境演练
 
@@ -189,7 +190,7 @@ r.set("notes:lab:json-probe", json.dumps({"schema":1,"id":1}), ex=30)。再次 r
 
 ## 02 实现 Cache-Aside 与带版本的 JSON 缓存
 
-先查 Redis,未命中再读 PostgreSQL,验证第一次回源与第二次命中的完整路径。
+先查 Redis,未命中再读 MySQL,验证第一次回源与第二次命中的完整路径。
 
 ### 本节目标
 
@@ -201,11 +202,11 @@ r.set("notes:lab:json-probe", json.dumps({"schema":1,"id":1}), ex=30)。再次 r
 
 #### Cache-Aside 的读路径
 
-应用先 GET;不存在时查询数据库,再把结果 SET 到缓存。Redis 不会自动知道 PostgreSQL 更新。先把基本路径做对,后续逐课处理失效、并发回填和故障降级。
+应用先 GET;不存在时查询数据库,再把结果 SET 到缓存。Redis 不会自动知道 MySQL 更新。先把基本路径做对,后续逐课处理失效、并发回填和故障降级。
 
 #### 两种版本解决不同问题
 
-缓存外层 schema=1 表示 JSON 格式协议,键名 v1 允许将来迁移格式。note.version 来自数据库,表示业务记录的修改版本。改 JSON 字段不等于修改业务记录,两者不要混为一个值。
+缓存外层schema=1表示JSON协议,note.version是MySQL记录的业务版本。MySQL不会自动把DATETIME附带UTC信息,也不会把TINYINT(1)自动转成JSON布尔值,所以序列化边界显式转换。将来添加JSON列时还应确认驱动返回的是文本还是已解码对象,不能让多个入口各自猜测。
 
 #### 缓存值也需要验证
 
@@ -213,72 +214,111 @@ r.set("notes:lab:json-probe", json.dumps({"schema":1,"id":1}), ex=30)。再次 r
 
 ### 动手实现
 
-#### 1. 准备一条 PostgreSQL 笔记
+#### 1. 准备一条MySQL笔记
 
-先完成数据库模块第 07 课,确保 notes 含 version。沿用 PG 环境变量连接 notes_lab,REDIS_PASSWORD 使用本模块第 01 课密码。在 Python 交互或保存以下脚本运行一次,记下编号并在 Bash 设置 export NOTE_ID=实际编号。
+先完成数据库第03课mysql_client.py;其中PyMySQL[rsa]>=1.1,<2、DictCursor、autocommit=False、显式UTC/RC与连接超时已配置。设置MYSQL_HOST=127.0.0.1、MYSQL_PORT=3307、MYSQL_DATABASE=notes_lab、MYSQL_USER=app_user和本机MYSQL_PASSWORD;不输出密码。notes从初始DDL就包含version。REDIS_PASSWORD保持Redis第01课配置。运行脚本后设置export NOTE_ID=输出的实际编号。
 
 文件:seed_cache_note.py
 
 ````python
-import psycopg
-
-with psycopg.connect("") as conn:
-    row = conn.execute(
-        "INSERT INTO notes(title,content) VALUES (%s,%s) RETURNING id",
-        ("cache-lab", "database is the source"),
-    ).fetchone()
-    print("NOTE_ID=", row[0])
+from mysql_client import connect
+
+conn=connect()
+try:
+    with conn.cursor() as cur:
+        cur.execute("INSERT INTO notes(title,content) VALUES(%s,%s)",
+                    ("cache-lab","database is the source"))
+        note_id=cur.lastrowid
+    conn.commit()
+    print("NOTE_ID=",note_id)
+except BaseException:
+    conn.rollback()
+    raise
+finally:
+    conn.close()
 ````
 
-#### 2. 编写数据库读取与缓存编码
+#### 2. 显式转换MySQL结果并实现Cache-Aside
 
-保存为 note_cache.py。load_note 使用明确列名,把时间转换成字符串。此教学函数每次回源新建数据库连接,生产接入时用 API + 数据库模块的连接池,不能照搬成高并发连接策略。key 中花括号包围编号,为同一笔记相关键预留同槽标签;当前实例仍是单机 Redis。
+保存note_cache.py,完整替换旧实现。PyMySQL读取DATETIME(6)通常得到无tzinfo的datetime,本项目确认所有写入都采用UTC后才附加timezone.utc;BOOLEAN的0/1显式转bool。正文上限16000字符,与API保持一致。读取成功也commit结束RC事务,异常rollback,finally关闭。此教学回源每次新建连接,生产改用数据库第10课的有界池或API模块对应异步连接池。
 
 文件:note_cache.py
 
 ````python
 import json
-import psycopg
-from psycopg.rows import dict_row
+from datetime import datetime,timezone
+from mysql_client import connect
 from redis_client import r
 
 def key(note_id):
     return f"notes:lab:v1:note:{{{note_id}}}"
 
+def utc_iso(value):
+    if not isinstance(value,datetime):
+        raise TypeError("expected database datetime")
+    # DATETIME没有自带时区;此赋值依赖所有写入均为UTC的项目约定。
+    if value.tzinfo is None:
+        value=value.replace(tzinfo=timezone.utc)
+    return value.astimezone(timezone.utc).isoformat()
+
+def normalize_note(row):
+    if row is None:
+        return None
+    return {
+        "id":int(row["id"]),"title":row["title"],"content":row["content"],
+        "pinned":bool(row["pinned"]),
+        "created_at":utc_iso(row["created_at"]),
+        "updated_at":utc_iso(row["updated_at"]),
+        "version":int(row["version"]),
+    }
+
 def load_note(note_id):
-    with psycopg.connect("", row_factory=dict_row) as conn:
-        row = conn.execute(
-            "SELECT id,title,content,pinned,created_at,updated_at,version FROM notes WHERE id=%s",
-            (note_id,),
-        ).fetchone()
-    if row is not None:
-        row["created_at"] = row["created_at"].isoformat()
-        row["updated_at"] = row["updated_at"].isoformat()
-    return row
+    conn=connect()
+    try:
+        with conn.cursor() as cur:
+            cur.execute("SELECT id,title,content,pinned,created_at,updated_at,version FROM notes WHERE id=%s",(note_id,))
+            row=cur.fetchone()
+        conn.commit()
+        return normalize_note(row)
+    except BaseException:
+        conn.rollback()
+        raise
+    finally:
+        conn.close()
 
 def encode(note):
-    return json.dumps({"schema": 1, "note": note}, ensure_ascii=False)
-
-def decode(raw, note_id):
-    data = json.loads(raw)
-    note = data["note"]
-    if data["schema"] != 1 or not isinstance(note, dict) or note.get("id") != note_id:
-        raise ValueError("invalid cached note")
-    if not isinstance(note.get("title"), str) or not isinstance(note.get("version"), int):
+    return json.dumps({"schema":1,"note":note},ensure_ascii=False)
+
+def decode(raw,note_id):
+    data=json.loads(raw)
+    if not isinstance(data,dict) or data.get("schema")!=1:
+        raise ValueError("unsupported cache schema")
+    note=data["note"]
+    if not isinstance(note,dict) or type(note.get("id")) is not int or note["id"]!=note_id:
+        raise ValueError("invalid cached note id")
+    if not isinstance(note.get("title"),str) or not 1<=len(note["title"])<=120:
+        raise ValueError("invalid cached title")
+    if not isinstance(note.get("content"),str) or len(note["content"])>16000:
+        raise ValueError("invalid cached content")
+    if type(note.get("pinned")) is not bool or type(note.get("version")) is not int or note["version"]<1:
         raise ValueError("invalid cached fields")
+    for field in ("created_at","updated_at"):
+        parsed=datetime.fromisoformat(note[field])
+        if parsed.tzinfo is None or parsed.utcoffset().total_seconds()!=0:
+            raise ValueError("cached timestamp must be UTC")
     return note
 
 def get_note(note_id):
-    raw = r.get(key(note_id))
+    raw=r.get(key(note_id))
     if raw is not None:
         try:
-            return decode(raw, note_id), "cache"
-        except (ValueError, KeyError, TypeError):
+            return decode(raw,note_id),"cache"
+        except (ValueError,KeyError,TypeError):
             r.delete(key(note_id))
-    note = load_note(note_id)
+    note=load_note(note_id)
     if note is not None:
-        r.set(key(note_id), encode(note), ex=60)
-    return note, "database"
+        r.set(key(note_id),encode(note),ex=60)
+    return note,"database"
 ````
 
 #### 3. 对照两次读取来源
@@ -323,7 +363,7 @@ for bad in ["not-json", '{"schema":99,"note":{"id":1}}']:
 ### 验收结果
 
 - 第一次返回 database,第二次返回 cache,业务字段一致。
-- 缓存含 schema 和 note.version,时间为明确时区字符串。
+- 缓存含schema和整数note.version;pinned是真正JSON布尔值,时间为+00:00的ISO8601。
 - 坏 JSON 和未知 schema 不直接导致业务解码崩溃,回源后得到有效内容。
 
 ### 生产约束与故障演练
@@ -332,7 +372,7 @@ for bad in ["not-json", '{"schema":99,"note":{"id":1}}']:
 
 #### 实现决策
 
-- PostgreSQL 成功读取后才回填。
+- MySQL 成功读取后才回填。
 - 缓存格式版本和业务版本分离。
 - 异常与不存在使用不同结果。
 
@@ -348,11 +388,11 @@ for bad in ["not-json", '{"schema":99,"note":{"id":1}}']:
 
 ### 常见错误
 
-#### json.dumps 报 datetime is not JSON serializable
+#### 缓存返回整数pinned或没有时区的时间
 
-原因:直接把数据库 datetime 对象塞进缓存。
+原因:直接序列化驱动结果,没有把MySQL类型转成API约定的DTO。
 
-处理:在固定序列化边界转换为 ISO 8601,或使用受控模型序列化;不要随意 str 整个对象。
+处理:在normalize_note中集中bool、整数与UTC时间转换;对DATETIME补UTC依赖明确存值约定,不能猜测已有未知来源时间。
 
 #### 修改代码增加字段后旧缓存导致 KeyError
 
@@ -368,13 +408,15 @@ for bad in ["not-json", '{"schema":99,"note":{"id":1}}']:
 
 参考答案:
 
-load_note 返回 None 时 get_note 返回 (None,"database")。捕获不到数据库时不能伪造 None,否则会把故障错误缓存成不存在;应向调用方返回可区分的服务错误,后续负缓存只处理确定的无行结果。
+load_note成功查询且fetchone为None时,返回None与database来源。PyMySQL连接异常、语句超时和数据转换异常继续传播,不能伪造不存在并写负缓存。读取事务也显式commit或rollback并关闭;故障降级不意味着吞掉真源错误。
 
 ### 技术文档
 
 - [Redis:GET](https://redis.io/docs/latest/commands/get/)
 - [Redis:Python 客户端](https://redis.io/docs/latest/develop/clients/redis-py/)
 - [Python:json](https://docs.python.org/3/library/json.html)
+- [MySQL8.4:日期时间类型](https://dev.mysql.com/doc/refman/8.4/en/date-and-time-types.html)
+- [PyMySQL:DictCursor与显式提交](https://pymysql.readthedocs.io/en/latest/user/examples.html)
 
 ---
 
@@ -400,37 +442,44 @@ load_note 返回 None 时 get_note 返回 (None,"database")。捕获不到数据
 
 #### 失效失败是待补偿工作
 
-PostgreSQL 和 Redis 没有共享一个本地事务。数据库已提交、DEL 失败时不能声称数据库回滚。生产可在同一数据库事务写 Outbox,由后台可靠重试失效,结合短 TTL;要求立即读取自己写入结果时直接返回数据库 RETURNING 数据或绕过缓存。
+MySQL事务和Redis命令没有共同本地事务。数据库已提交、DEL失败不能报告回滚。生产可在同一InnoDB事务写业务行和Outbox(事件表提前由独立DDL创建),后台可靠重试删除;若要求立即读取自己提交的结果,直接返回同一事务SELECT得到的版本或显式绕过缓存。
 
 ### 动手实现
 
-#### 1. 实现带版本检查的更新与提交后删除
+#### 1. 用受影响行数确认修改,提交后再删除缓存
 
-保存为 cache_write.py。expected_version 来自读取结果。上下文退出才表示提交成功,随后才删 Redis。示例返回 invalidated 标志供练习观察;生产失效失败应写入可靠补偿流程并告警,这里没有实现 Outbox,不能当成完整可靠投递。
+保存cache_write.py。MySQL没有此处的DML RETURNING,先用expected_version条件UPDATE并检查rowcount,再在同一持锁事务SELECT真实结果。UPDATE总是递增version,成功匹配必改变行。显式conn.commit成功后关闭连接,再调用Redis DEL;commit网络错误时结果可能不确定,不能盲重试。示例invalidated=False需由生产Outbox补偿,此文件未实现可靠投递。
 
 文件:cache_write.py
 
 ````python
-import psycopg
+from mysql_client import connect
 from redis.exceptions import RedisError
 from note_cache import key
 from redis_client import r
 
-def rename_note(note_id, title, expected_version):
-    with psycopg.connect("") as conn:
-        row = conn.execute(
-            "UPDATE notes SET title=%s,version=version+1,updated_at=now() "
-            "WHERE id=%s AND version=%s RETURNING id,title,version",
-            (title, note_id, expected_version),
-        ).fetchone()
-        if row is None:
-            raise ValueError("not found or version conflict")
+def rename_note(note_id,title,expected_version):
+    conn=connect()
+    try:
+        with conn.cursor() as cur:
+            cur.execute("UPDATE notes SET title=%s,version=version+1,updated_at=UTC_TIMESTAMP(6) WHERE id=%s AND version=%s",
+                        (title,note_id,expected_version))
+            if cur.rowcount!=1:
+                raise ValueError("not found or version conflict")
+            cur.execute("SELECT id,title,version FROM notes WHERE id=%s",(note_id,))
+            row=cur.fetchone()
+        conn.commit()
+    except BaseException:
+        conn.rollback()
+        raise
+    finally:
+        conn.close()
     try:
         r.delete(key(note_id))
-        invalidated = True
+        invalidated=True
     except RedisError:
-        invalidated = False
-    return {"id": row[0], "title": row[1], "version": row[2], "invalidated": invalidated}
+        invalidated=False
+    return {"id":int(row["id"]),"title":row["title"],"version":int(row["version"]),"invalidated":invalidated}
 ````
 
 #### 2. 验证成功修改后的首次回源
@@ -489,11 +538,11 @@ assert get_note(n)[0]["version"] == fresh["version"]
 
 ````text
 事件:note.updated,字段 note_id / committed_version / event_id
-写入:业务 UPDATE 与 outbox INSERT 在同一个 PostgreSQL 事务
+写入:业务 UPDATE 与 outbox INSERT 在同一个MySQL InnoDB事务(事件表DDL在部署阶段预先完成)
 消费:按事件删除详情键及明确的派生键,成功后记录处理状态
 重复:DEL 对不存在键仍可安全重放
 失败:有限退避后进入待处理队列并告警,不丢弃失效事件
-读取:写接口返回数据库实际版本;强一致读取显式绕过缓存
+读取:写接口返回同事务SELECT得到并已commit的真实版本;强一致读取显式绕过缓存
 观测:失效失败数、最老未处理事件年龄、回源率、旧版本样本
 ````
 
@@ -512,6 +561,7 @@ assert get_note(n)[0]["version"] == fresh["version"]
 - 提交后删除详情缓存。
 - 用确定性时序测试陈旧回填。
 - 可靠失效事件与直接读写结果满足不同一致性需求。
+- 事务中完成UPDATE与结果SELECT,commit成功后再失效;迁移DDL不混入业务事务。
 
 #### 隔离环境演练
 
@@ -545,13 +595,14 @@ assert get_note(n)[0]["version"] == fresh["version"]
 
 参考答案:
 
-不能报告回滚。应保留实际提交版本,记录失效待补偿事件、event_id 和失败原因,采用 Outbox 等可靠机制重试。写响应可直接返回已提交数据,强一致读取绕过缓存;不要盲目重试创建造成重复记录。
+不能报告回滚。commit已成功时应保留实际提交版本,记录待补偿事件及失败原因,使用同事务Outbox可靠重试缓存删除。若commit本身因网络中断返回错误,结果是不确定,需按幂等标识查询真源核对,不能假定没提交。
 
 ### 技术文档
 
 - [Redis:DEL](https://redis.io/docs/latest/commands/del/)
 - [Redis:客户端缓存与失效](https://redis.io/docs/latest/develop/reference/client-side-caching/)
-- [PostgreSQL 16:事务](https://www.postgresql.org/docs/16/tutorial-transactions.html)
+- [MySQL8.4:InnoDB事务](https://dev.mysql.com/doc/refman/8.4/en/innodb-autocommit-commit-rollback.html)
+- [MySQL8.4:UPDATE](https://dev.mysql.com/doc/refman/8.4/en/update.html)
 
 ---
 
@@ -590,7 +641,7 @@ Redis GET 返回 None 表示缓存键不存在,不等于业务记录不存在
 ````python
 import json
 from redis_client import r
-from note_cache import load_note
+from note_cache import load_note,decode,encode
 
 def lookup_key(note_id):
     return f"notes:lab:v1:lookup:{{{note_id}}}"
@@ -610,7 +661,7 @@ def get_with_negative(note_id):
             note = data["note"]
             if not isinstance(note, dict) or note.get("id") != note_id:
                 raise ValueError("bad note")
-            return note, "cache"
+            return decode(encode(note),note_id), "cache"
         except (ValueError, KeyError, TypeError):
             r.delete(k)
     note = load_note(note_id)  # 异常继续向外传递,不伪造不存在
@@ -620,29 +671,37 @@ def get_with_negative(note_id):
     return note, "database"
 ````
 
-#### 2. 选择确认不存在的编号并查询两次
+#### 2. 选择确认不存在的MySQL编号并比较来源
 
-在独立练习库选择 max(id)+100000,先确认没有该记录。这里只做课堂样本选择,不给线上攻击者提供枚举工具。第一次 database、第二次 negative-cache,数据库错误没有进入这条成功路径。
+只在独立练习库选MAX(id)+100000并确认在BIGINT正数范围。MAX这里只是产生确定不存在的实验输入,不用于获取刚插入记录的主键。成功只读事务也显式commit,异常rollback,最终close。
 
 文件:negative_probe.py
 
 ````python
-import psycopg
-from negative_cache import get_with_negative, lookup_key
+from mysql_client import connect
+from negative_cache import get_with_negative,lookup_key
 from redis_client import r
 
-with psycopg.connect("") as conn:
-    missing_id = conn.execute("SELECT coalesce(max(id),0)+100000 FROM notes").fetchone()[0]
+conn=connect()
+try:
+    with conn.cursor() as cur:
+        cur.execute("SELECT COALESCE(MAX(id),0)+100000 AS missing_id FROM notes")
+        missing_id=int(cur.fetchone()["missing_id"])
+    conn.commit()
+finally:
+    conn.rollback()
+    conn.close()
+assert 1<=missing_id<=2**63-1
 r.delete(lookup_key(missing_id))
 print(get_with_negative(missing_id))
 print(get_with_negative(missing_id))
-print("ttl", r.ttl(lookup_key(missing_id)))
+print("ttl",r.ttl(lookup_key(missing_id)))
 r.delete(lookup_key(missing_id))
 ````
 
 #### 3. 让非法编号在访问存储之前失败
 
-保存并运行。bad 值都会触发 ValueError;后续 API 接入时由参数模型先执行同等校验。单个非法值的验证不要发起 Redis 或 PostgreSQL 请求。
+保存并运行。bad 值都会触发 ValueError;后续 API 接入时由参数模型先执行同等校验。单个非法值的验证不要发起 Redis 或 MySQL 请求。
 
 文件:negative_invalid.py
 
@@ -939,7 +998,7 @@ print("ownership check passed")
 
 ## 06 应对缓存雪崩:TTL 抖动、受控预热与降级
 
-分散批量过期时间,限制冷启动回源并发,在 Redis 失效时保护 PostgreSQL。
+分散批量过期时间,限制冷启动回源并发,在 Redis 失效时保护 MySQL。
 
 ### 本节目标
 
@@ -955,7 +1014,7 @@ print("ownership check passed")
 
 #### 降级也有容量上限
 
-缓存可绕过不代表数据库能接住全量请求。为回源设置并发预算、等待上限、SQL 超时和入口限流,超出容量返回明确的暂不可用结果。不能在数据库也失败时伪造空笔记。
+Redis故障不代表MySQL能接住全部流量。回源需按进程设置并发名额、池等待、驱动读写超时、InnoDB锁等待和SELECT执行预算;超量返回明确暂不可用。即使数据库默认RR,应用连接也显式RC并结束每次只读事务,避免复用连接时长期保留旧快照。
 
 #### 预热从热点开始
 
@@ -985,32 +1044,41 @@ print("buckets", sorted(Counter(ttls).items()))
 r.delete(*keys)
 ````
 
-#### 2. 小批次预热最近笔记
+#### 2. 用MySQL有限读取预热最近20条
 
-保存为 cache_warm.py。仅预热最多 20 条,顺序处理并在每条之间等待 50 毫秒;目的是控制负载,不是追求最快跑完。真正热点应来自访问统计。本例直接用已有 load_note,生产切换到连接池并增加任务取消。
+保存cache_warm.py。先在短事务读取最多20个编号,commit并close后再逐个回源填充。每次load_note都重新打开短RC读取,删除期间得到None则跳过;不是一致性全库导出。顺序预热、每条50毫秒与TTL抖动限制本机负载,生产使用池及实际热点统计。
 
 文件:cache_warm.py
 
 ````python
 import random
 import time
-import psycopg
-from note_cache import key, load_note, encode
+from mysql_client import connect
+from note_cache import key,load_note,encode
 from redis_client import r
 
-with psycopg.connect("") as conn:
-    ids = [row[0] for row in conn.execute("SELECT id FROM notes ORDER BY id DESC LIMIT 20")]
+conn=connect()
+try:
+    with conn.cursor() as cur:
+        cur.execute("SELECT id FROM notes ORDER BY id DESC LIMIT 20")
+        ids=[int(row["id"]) for row in cur.fetchall()]
+    conn.commit()
+except BaseException:
+    conn.rollback()
+    raise
+finally:
+    conn.close()
 for note_id in ids:
-    note = load_note(note_id)
+    note=load_note(note_id)
     if note is not None:
-        r.set(key(note_id), encode(note), ex=60 + random.randint(-15, 15))
+        r.set(key(note_id),encode(note),ex=60+random.randint(-15,15))
     time.sleep(0.05)
-print("warmed", len(ids))
+print("warmed",len(ids))
 ````
 
 #### 3. 实现有并发上限的回源降级
 
-保存为 cache_fallback.py。该函数直接实现读路径,不调用可能已经回源的 get_note 后再次回源,避免重复数据库查询。Redis GET/SET 失败可以绕过,PostgreSQL 异常仍交给调用者。Semaphore 只约束本进程;多进程预算需要总量规划。同步代码不能直接阻塞 async 路由。
+保存为 cache_fallback.py。该函数直接实现读路径,不调用可能已经回源的 get_note 后再次回源,避免重复数据库查询。Redis GET/SET 失败可以绕过,MySQL 异常仍交给调用者。Semaphore 只约束本进程;多进程预算需要总量规划。同步代码不能直接阻塞 async 路由。
 
 文件:cache_fallback.py
 
@@ -1072,7 +1140,7 @@ print(source, note["id"], note["version"])
 
 - TTL 分布有上下界,批量键没有全部同刻过期。
 - 预热数量和速率有限,未把全库一次性加载。
-- 专用 Redis 停止时仍读到 PostgreSQL 数据,回源超出预算时返回受控错误。
+- 专用 Redis 停止时仍读到 MySQL 数据,回源超出预算时返回受控错误。
 
 ### 生产约束与故障演练
 
@@ -1083,6 +1151,7 @@ print(source, note["id"], note["version"])
 - 到期抖动、限速预热与故障降级分别处理不同触发条件。
 - 数据库回源有硬并发预算。
 - 允许陈旧数据的范围由业务定义。
+- 回源同步驱动为PyMySQL;异步API调用改用对应asyncmy接入,不在事件循环中直接执行同步SQL。
 
 #### 隔离环境演练
 
@@ -1144,7 +1213,7 @@ transaction=False 的 pipeline 批量发送命令,不让多条业务操作自
 
 #### WATCH 是提交前的乐观检查
 
-WATCH 键后读取当前值,MULTI 与 EXEC 提交修改;被其他客户端改动时 redis-py 抛 WatchError,应用重新读取并有限重试。Redis 事务与 PostgreSQL 不同,执行阶段某条命令出错不会撤销已经成功的其他命令。
+WATCH 键后读取当前值,MULTI 与 EXEC 提交修改;被其他客户端改动时 redis-py 抛 WatchError,应用重新读取并有限重试。Redis 事务与 MySQL 不同,执行阶段某条命令出错不会撤销已经成功的其他命令。
 
 #### Lua 组合命令时先验证输入
 
@@ -1410,9 +1479,9 @@ client.close()
 文件:终端 / 当前课时
 
 ````text
-普通笔记缓存:独立 cache 实例;明确 maxmemory;可用 allkeys-lru/LFU;值可从 PostgreSQL 重建
+普通笔记缓存:独立 cache 实例;明确 maxmemory;可用 allkeys-lru/LFU;值可从 MySQL 重建
 任务 Broker:独立实例;按任务框架要求设置持久化与 noeviction;拒写必须使投递失败可见
-任务结果:独立 TTL 与保留期;持久业务状态放 PostgreSQL
+任务结果:独立 TTL 与保留期;持久业务状态放 MySQL
 容量预算:容器限制 > maxmemory,并留复制、AOF 重写、碎片等余量
 告警:used_memory/maxmemory、RSS、evicted_keys 增量、命中率、写入拒绝数
 检查:DB 0 与 DB 1 仅键空间区分,不提供内存、CPU 或故障隔离
@@ -1720,13 +1789,13 @@ Redis 主从默认异步复制,主节点确认写入时副本可能尚未收
 
 #### 恢复验收从真源开始
 
-先确认 PostgreSQL 数据完整与可用,再检查 Redis 连接、复制角色、命中和回源限流。缓存可丢时不必恢复每一个旧键,受控重建通常更直接。任务 Broker 恢复还要验收未确认任务与重复消费,不用缓存恢复结果替代任务验收。
+先确认MySQL 8.4业务数据完整、InnoDB可读写,再检查Redis角色、复制与受控回源。Redis AOF、RDB与MySQL的redo、binlog是不同日志,不能互相替代。缓存可丢时可从MySQL重建;任务Broker还需验收持久Outbox、未确认任务与幂等消费,不用缓存重启结果证明任务可靠性。
 
 ### 动手实现
 
 #### 1. 确认无持久化缓存重启后可回源
 
-使用第 02 课 NOTE_ID 预热后,记录缓存存在。执行 docker restart notes-redis,仅重启专用本机实例,然后再次读取应先 database 再 cache。配置 save "" 与 appendonly no 禁用了持久化,但 PostgreSQL 的笔记仍然存在。
+使用第 02 课 NOTE_ID 预热后,记录缓存存在。执行 docker restart notes-redis,仅重启专用本机实例,然后再次读取应先 database 再 cache。配置 save "" 与 appendonly no 禁用了持久化,但 MySQL 的笔记仍然存在。
 
 文件:cache_restart_probe.py
 
@@ -1840,7 +1909,7 @@ finally:
 
 ### 验收结果
 
-- 6380 重启清空缓存后,业务笔记能从 PostgreSQL 读取并重建。
+- 6380 重启清空缓存后,业务笔记能从 MySQL 读取并重建。
 - 6382 AOF 正常重启后样本仍可读取,TTL 正常减少,持久化状态无错误。
 - 副本连接正常时能读取复制值,主节点停止后不误认为已实现自动故障转移。
 
@@ -1863,6 +1932,7 @@ finally:
 - 真实笔记不丢失。
 - 没有宣称 everysec 零丢失。
 - 未部署自动选主时明确记录为手动恢复实验。
+- Redis重启只影响可重建副本;MySQL中的笔记、业务version与UTC时间保持可核对。
 
 ### 常见错误
 
@@ -1892,7 +1962,7 @@ finally:
 
 参考答案:
 
-确认 PostgreSQL 正常,启用回源限流;检查 Redis 实例身份、认证和配置;恢复连接后小批次预热热点并观察错误率与数据库延迟。可重建缓存不必恢复所有旧键。任务 Broker 需要额外检查积压、未确认任务和重复消费,不能沿用此简化流程。
+确认 MySQL 正常,启用回源限流;检查 Redis 实例身份、认证和配置;恢复连接后小批次预热热点并观察错误率与数据库延迟。可重建缓存不必恢复所有旧键。任务 Broker 需要额外检查积压、未确认任务和重复消费,不能沿用此简化流程。
 
 ### 技术文档
 

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 426 - 220
public/downloads/tasks-lessons.md


+ 119 - 83
public/downloads/troubleshooting-lessons.md

@@ -85,7 +85,7 @@ curl -sS --connect-timeout 1 --max-time 3 -o /dev/null -w "ready status=%{http_c
 
 ````sql
 SELECT status,count(*) AS jobs,
-       extract(epoch FROM now()-min(created_at)) AS oldest_age_seconds
+       TIMESTAMPDIFF(SECOND,MIN(created_at),UTC_TIMESTAMP(6)) AS oldest_age_seconds
 FROM export_jobs GROUP BY status;
 
 SELECT count(*) AS unpublished FROM export_outbox WHERE sent_at IS NULL;
@@ -570,7 +570,7 @@ def setup_trace(service_name: str):
 
 #### 2. 给API和数据库操作加span
 
-在应用启动配置中调用setup_trace一次,instrument现有app。数据库span只记录有限操作名和行数,不把笔记正文或SQL参数设成属性;HTTPX自动插桩也需核对URL是否包含敏感查询参数。
+在应用启动配置中调用setup_trace一次,instrument现有app。数据库span只记录有限操作名和行数,不把笔记正文或SQL参数设成属性;HTTPX自动插桩也需核对URL是否包含敏感查询参数。 traced_read接收现有SQLAlchemy AsyncSession,由调用者管理事务并await此函数;不要把同步PyMySQL调用直接放入异步路由。
 
 文件:app/main.py
 
@@ -585,46 +585,58 @@ FastAPIInstrumentor.instrument_app(app, excluded_urls="live,ready,metrics")
 HTTPXClientInstrumentor().instrument()
 tracer = trace.get_tracer("notes-service")
 
-def traced_read(conn, note_id):
+from sqlalchemy import text
+
+async def traced_read(db, note_id):
     with tracer.start_as_current_span("db.notes.read") as span:
-        row = conn.execute("SELECT id,title FROM notes WHERE id=%s", (note_id,)).fetchone()
+        row = (await db.execute(
+            text("SELECT id,title FROM notes WHERE id=:id"), {"id": note_id}
+        )).mappings().one_or_none()
         span.set_attribute("app.rows_returned", 1 if row else 0)
         return row
 ````
 
 #### 3. 把允许的追踪字段存入outbox
 
-给export_outbox加trace_context jsonb列,默认{}。在提交事务中调用carrier_for_outbox并把返回值作为Jsonb写入同一事件。只保存标准追踪字段,不保存任意baggage或请求头。
+通过审核后的MySQL迁移为export_outbox新增trace_context JSON列。先确认列不存在;ALTER TABLE会隐式提交,不包在业务受理事务里。提交事件时,在同一业务事务中将json.dumps(carrier_for_outbox())作为参数绑定到该列。只保存标准追踪字段,不复制任意baggage或请求头。
 
 文件:trace_carrier.py
 
 ````python
+import json
 from opentelemetry.propagate import inject
 
 def carrier_for_outbox():
     carrier = {}
     inject(carrier)
-    return {key: value for key, value in carrier.items()
+    return {key: value for key,value in carrier.items()
             if key in {"traceparent", "tracestate"}}
 
-# SQL:
-# ALTER TABLE export_outbox ADD COLUMN IF NOT EXISTS
-# trace_context jsonb NOT NULL DEFAULT '{}';
-# INSERT outbox时将carrier_for_outbox()写入该列。
+# 只在迁移中执行一次,先检查实际列结构:
+# ALTER TABLE export_outbox
+# ADD COLUMN trace_context JSON NOT NULL DEFAULT (JSON_OBJECT());
+# submit_snapshot的outbox INSERT增加trace_context占位符,参数传:
+# json.dumps(carrier_for_outbox())
 ````
 
 #### 4. 在publisher与worker恢复上下文
 
-publisher领取事件时返回trace_context,在send_task外围attach/extract,finally detach。Celery消费者使用官方插桩;prefork子进程在worker_process_init中初始化provider。producer也在其进程初始化CeleryInstrumentor,避免重复初始化。
+publisher领取事件时返回trace_context,在send_task外围attach/extract,finally detach。Celery消费者使用官方插桩;prefork子进程在worker_process_init中初始化provider。producer也在其进程初始化CeleryInstrumentor,避免重复初始化。 PyMySQL读取JSON列通常得到字符串,先json.loads再交给propagator;缺失值按空上下文处理。事件领取SELECT必须包含trace_context。
 
 文件:trace_worker.py
 
 ````python
+import json
 from opentelemetry import context
 from opentelemetry.propagate import extract
 
 def publish_with_context(event, celery_app):
-    token = context.attach(extract(event.get("trace_context") or {}))
+    raw = event.get("trace_context")
+    carrier = json.loads(raw) if isinstance(raw,str) else (raw or {})
+    if not isinstance(carrier,dict):
+        raise ValueError("invalid trace context")
+    carrier = {k:v for k,v in carrier.items() if k in {"traceparent","tracestate"} and isinstance(v,str)}
+    token = context.attach(extract(carrier))
     try:
         return celery_app.send_task(
             "notes.export", args=[str(event["job_id"])],
@@ -1202,13 +1214,13 @@ if __name__ == "__main__":
 
 ## 08 定位慢 SQL、锁等待与连接池耗尽
 
-从 pg_stat_activity 进入具体等待关系,使用短事务和隔离锁实验验证原因。
+从MySQL会话、InnoDB事务和Performance Schema锁等待关系定位原因,再验证应用池与事务释放。
 
 ### 本节目标
 
-- 读取活动会话与阻塞PID
-- 区分SQL执行慢和等待连接/锁
-- 用参数化EXPLAIN与事务级超时限制诊断
+- 关联连接ID、InnoDB事务与阻塞连接
+- 区分应用池等待、SQL执行、行锁与元数据锁
+- 用受控SELECT和会话预算完成诊断并回滚实验事务
 
 ### 必要概念
 
@@ -1216,96 +1228,107 @@ if __name__ == "__main__":
 
 应用先等连接池,再发送SQL,数据库还可能等锁或I/O。只看到接口慢,不足以判断需要加索引。
 
-#### 等待者与阻塞者要关联
+#### 连接空闲也可能持有事务
 
-pg_stat_activity显示状态、事务年龄和wait_event,pg_blocking_pids定位阻塞来源。先看进程与关系,再决定是否需要查看脱敏SQL。
+SHOW PROCESSLIST中的Sleep表示当前没有执行语句,不能证明事务已结束。INFORMATION_SCHEMA.INNODB_TRX关联MySQL连接ID;performance_schema.data_lock_waits与data_locks关联InnoDB行锁,metadata_locks用于表结构等元数据锁。
 
-#### 诊断SQL也可能执行实际工作
+#### 超时作用范围不同
 
-EXPLAIN ANALYZE会真的执行查询,写操作还会产生副作用。先用EXPLAIN;隔离环境才能用已限制范围的ANALYZE,并设置合理statement_timeout和lock_timeout。
+innodb_lock_wait_timeout约束InnoDB行锁等待,不是所有锁或所有SQL的总超时。max_execution_time主要约束适用的只读SELECT。EXPLAIN ANALYZE会实际执行查询,只在受控小数据上使用,不把诊断当成零成本。
 
 ### 动手实现
 
 #### 1. 读取活动会话和等待信息
 
-连接PostgreSQL16 notes_lab,app_user,宿主5433。通过安全配置的DATABASE_URL或pgpass连接,避免把密码贴入命令行。普通账户可见信息受权限限制,不把缺失字段当作没有活动。
+在MySQL8.4练习实例用经授权的诊断账户执行,宿主3307。交互客户端使用-p提示输入密码或权限受控的选项文件。跨账户会话/Performance Schema可见性取决于权限,权限不足不等于没有阻塞;不要为排障把管理员权限授给API。保存连接ID、事务开始时间和等待关系,SQL正文分享前脱敏。
 
 文件:终端 / 当前课时
 
 ````sql
-SELECT pid,usename,application_name,state,
-       now()-xact_start AS transaction_age,
-       now()-query_start AS query_age,
-       wait_event_type,wait_event,
-       pg_blocking_pids(pid) AS blocked_by
-FROM pg_stat_activity
-WHERE datname='notes_lab' AND pid<>pg_backend_pid()
-ORDER BY xact_start NULLS LAST;
+SELECT CONNECTION_ID(),DATABASE(),@@transaction_isolation,@@session.time_zone;
+SHOW FULL PROCESSLIST;
+SELECT trx_id,trx_state,trx_started,trx_mysql_thread_id,trx_rows_locked
+FROM information_schema.innodb_trx ORDER BY trx_started;
+SELECT w.REQUESTING_ENGINE_TRANSACTION_ID AS waiting_trx,
+       w.BLOCKING_ENGINE_TRANSACTION_ID AS blocking_trx,
+       rt.PROCESSLIST_ID AS waiting_connection,
+       bt.PROCESSLIST_ID AS blocking_connection
+FROM performance_schema.data_lock_waits w
+LEFT JOIN performance_schema.threads rt ON rt.THREAD_ID=w.REQUESTING_THREAD_ID
+LEFT JOIN performance_schema.threads bt ON bt.THREAD_ID=w.BLOCKING_THREAD_ID;
 ````
 
 #### 2. 在两会话复现有界锁等待
 
-只选本地演练笔记ID。会话A开启事务并锁定该行;会话B设置1秒lock_timeout后尝试锁同一行。观察锁等待错误,随后两个会话都ROLLBACK,释放故障条件。
+选本机专用笔记ID,会话A锁定一行,会话B设置1秒InnoDB锁等待预算再请求同一行。第三个诊断会话可先将B预算改为5秒观察等待关系,之后恢复1秒。默认锁等待超时通常仅回滚失败语句,不能当作事务已经释放;实验结束A/B均显式ROLLBACK。死锁可能回滚整笔事务,重试仍以完整业务事务为边界。
 
 文件:终端 / 当前课时
 
 ````sql
--- 会话A:将1替换为专门用于本次演练的笔记ID
-BEGIN;
+-- 会话A:替换为专用笔记ID
+START TRANSACTION;
 SELECT id FROM notes WHERE id=1 FOR UPDATE;
--- 暂不提交,在会话B执行下面语句后回到A:ROLLBACK;
+-- 暂不提交;完成B的观察后执行ROLLBACK。
 
 -- 会话B
-BEGIN;
-SET LOCAL lock_timeout='1s';
+SET SESSION innodb_lock_wait_timeout=1;
+START TRANSACTION;
 SELECT id FROM notes WHERE id=1 FOR UPDATE;
--- 超时后:ROLLBACK;
+-- 出现1205后执行:
+ROLLBACK;
+-- 最后回到A执行ROLLBACK,释放行锁。
 ````
 
 #### 3. 先看计划,再在隔离数据上测执行
 
-下面EXPLAIN不实际执行SELECT。若需要ANALYZE,先确认只读、数据规模有限且本地可承受,再以事务级statement_timeout执行。不要在事故中直接对未知大表运行昂贵诊断。
+先看非执行计划。需要EXPLAIN ANALYZE时,只在隔离小表执行只读SELECT并设置会话max_execution_time;该变量不是任意写SQL的硬超时。记录rows、实际迭代次数、索引及访问路径,不能仅凭使用索引就断定足够快。
 
 文件:终端 / 当前课时
 
 ````sql
-EXPLAIN (COSTS, VERBOSE)
-SELECT id,title FROM notes WHERE id=1;
-
--- 仅隔离练习:
-BEGIN;
-SET LOCAL statement_timeout='2s';
-EXPLAIN (ANALYZE, BUFFERS)
-SELECT id,title FROM notes WHERE id=1;
-ROLLBACK;
+EXPLAIN FORMAT=TREE SELECT id,title FROM notes WHERE id=1;
+-- 仅隔离练习,客户端设置语句预算:
+SET SESSION max_execution_time=2000;
+EXPLAIN ANALYZE SELECT id,title FROM notes WHERE id=1;
+SET SESSION max_execution_time=0;
 ````
 
 #### 4. 观察应用连接池预算
 
-若使用psycopg_pool,记录get_stats中的等待、请求和连接数,字段按安装版本核对。连接预算需乘以API worker和Celery进程数;不要只把每个池的max_size调大。等待连接时数据库可能没有相应新查询。
+API复用SQLAlchemy AsyncAdaptedQueuePool,每个进程各自拥有池。以下函数只记录数字,不输出连接串;由已有监控采样逻辑调用。池等待需在实际获取连接周围单独计时,checkedout只能反映当前占用。同步任务模块按短事务打开PyMySQL连接,不虚构存在一个任务连接池。
 
 文件:pool_observation.py
 
 ````python
-# 使用现有psycopg_pool对象,只记录统计数字:
-# stats = pool.get_stats()
-# logger.info({"event": "db_pool_stats", "stats": stats})
-
-# 检查应用事务边界:
-# 获取连接 → 短SQL/事务 → 提交或回滚 → 归还连接
-# 不把网络上传、长await或CPU导出放在持有事务的区间。
+from app.db import engine
+
+def pool_snapshot():
+    pool = engine.sync_engine.pool
+    return {
+        "size": pool.size(),
+        "checked_out": pool.checkedout(),
+        "overflow": pool.overflow(),
+    }
+
+# 由每个API进程的现有监控采样逻辑调用;不要另启动新进程观察一个空池。
+# 总预算 = API实例 × worker × (pool_size + max_overflow)
+#          + Celery执行进程 + publisher/recovery + 诊断/备份保留连接。
 ````
 
 #### 5. 恢复后核对锁和业务请求
 
-回滚会话A后重试读取,应恢复正常;再次查询活动会话确认没有本次idle in transaction。记录谁持锁、谁等待、锁何时释放。未经确认不要终止任意数据库backend。
+A/B回滚后确认对应连接没有活跃InnoDB事务;再查询行锁等待与未授予元数据锁。Sleep本身不是错误,Sleep且有长事务才需要定位持有者。未经确认不终止他人连接;恢复门槛同时包含业务请求成功和等待消失。
 
 文件:终端 / 当前课时
 
 ````sql
-SELECT pid,state,now()-xact_start AS transaction_age
-FROM pg_stat_activity
-WHERE datname='notes_lab' AND state='idle in transaction';
+SELECT t.trx_mysql_thread_id AS connection_id,t.trx_state,t.trx_started,
+       p.COMMAND,p.TIME,p.STATE
+FROM information_schema.innodb_trx t
+LEFT JOIN information_schema.PROCESSLIST p ON p.ID=t.trx_mysql_thread_id;
+SELECT OBJECT_SCHEMA,OBJECT_NAME,LOCK_TYPE,LOCK_DURATION,OWNER_THREAD_ID
+FROM performance_schema.metadata_locks
+WHERE OBJECT_SCHEMA='notes_lab' AND LOCK_STATUS='PENDING';
 ````
 
 ### 验收结果
@@ -1336,17 +1359,17 @@ WHERE datname='notes_lab' AND state='idle in transaction';
 
 ### 常见错误
 
-#### 接口慢但pg_stat_activity没有对应查询
+#### API慢但MySQL中没有相应执行语句
 
-原因:请求可能还在应用连接池排队。
+原因:请求可能仍在应用连接池等待,尚未向数据库发送SQL。
 
-处理:检查连接归还、池等待统计和进程总连接预算。
+处理:观察同一API进程的池等待与借还连接,核对总连接预算和持有事务的上游调用。
 
-#### 锁超时后继续在同一事务执行又报错
+#### 锁等待超时后其他连接仍被阻塞
 
-原因:PostgreSQL事务已进入失败状态。
+原因:MySQL默认通常只回滚超时语句,原事务和先前获得的锁可能仍存在。
 
-处理:先ROLLBACK,再重新开始需要的事务;不要忽略异常后继续使用。
+处理:应用异常路径显式回滚完整业务事务,再以幂等和总预算决定是否重试;检查innodb_rollback_on_timeout实际设置。
 
 ### 扩展练习
 
@@ -1360,10 +1383,10 @@ WHERE datname='notes_lab' AND state='idle in transaction';
 
 ### 技术文档
 
-- [PostgreSQL活动统计](https://www.postgresql.org/docs/16/monitoring-stats.html)
-- [PostgreSQL锁](https://www.postgresql.org/docs/16/explicit-locking.html)
-- [PostgreSQL EXPLAIN](https://www.postgresql.org/docs/16/using-explain.html)
-- [Psycopg连接池](https://www.psycopg.org/psycopg3/docs/advanced/pool.html)
+- [MySQL8.4:InnoDB事务监控](https://dev.mysql.com/doc/refman/8.4/en/innodb-information-schema-transactions.html)
+- [MySQL8.4:锁等待关系](https://dev.mysql.com/doc/refman/8.4/en/performance-schema-data-lock-waits-table.html)
+- [MySQL8.4:EXPLAIN](https://dev.mysql.com/doc/refman/8.4/en/explain.html)
+- [SQLAlchemy连接池](https://docs.sqlalchemy.org/en/20/core/pooling.html)
 
 ---
 
@@ -1560,16 +1583,29 @@ export_outbox未发布也属于用户等待;只看Celery active会漏掉publis
 
 #### 1. 读取worker与路由状态
 
-在tasks_lab工作目录,读取正确Celery环境:独立broker宿主6384,容器内broker:6379,入口celery_app:app。inspect可能包含任务参数,本模块后期只传job_id;分享输出前核对。
+使用部署时相同的配置来源。以下 Bash 函数用于 Linux/systemd 路线,由 systemd 读取 /etc/notes-tasks.env;若采用 Docker,在 Compose 工程目录用注释中的函数替换 run_task 定义,再执行相同检查。保持此终端供本课恢复步骤复用。inspect 可能包含任务参数,分享前核对输出。
 
 文件:终端 / 当前课时
 
 ````bash
-python -m celery -A celery_app:app status
-python -m celery -A celery_app:app inspect active
-python -m celery -A celery_app:app inspect reserved
-python -m celery -A celery_app:app inspect scheduled
-python -m celery -A celery_app:app inspect active_queues
+# Linux/systemd 路线,在管理员的 Bash 终端定义:
+run_task() {
+  sudo systemd-run --quiet --wait --collect --pipe \
+    --property=User=notes --property=Group=notes \
+    --property=WorkingDirectory=/srv/notes-service/current/tasks_lab \
+    --property=EnvironmentFile=/etc/notes-tasks.env \
+    /srv/notes-service/current/.venv/bin/python "$@"
+}
+# Docker 路线:在 Compose 工程目录,用以下定义替换上面的函数:
+# run_task() {
+#   docker compose --env-file .env.compose run --rm -T --no-deps worker \
+#     python /app/deploy/run_role.py python "$@"
+# }
+run_task -m celery -A celery_app:app status
+run_task -m celery -A celery_app:app inspect active
+run_task -m celery -A celery_app:app inspect reserved
+run_task -m celery -A celery_app:app inspect scheduled
+run_task -m celery -A celery_app:app inspect active_queues
 ````
 
 #### 2. 把数据库阶段并排查询
@@ -1580,15 +1616,15 @@ python -m celery -A celery_app:app inspect active_queues
 
 ````sql
 SELECT status,count(*) AS total,
-       extract(epoch FROM now()-min(created_at)) AS oldest_seconds
+       TIMESTAMPDIFF(SECOND,MIN(created_at),UTC_TIMESTAMP(6)) AS oldest_seconds
 FROM export_jobs GROUP BY status;
 
 SELECT queue_name,count(*) AS pending,
-       extract(epoch FROM now()-min(created_at)) AS oldest_seconds
+       TIMESTAMPDIFF(SECOND,MIN(created_at),UTC_TIMESTAMP(6)) AS oldest_seconds
 FROM export_outbox WHERE sent_at IS NULL GROUP BY queue_name;
 
 SELECT id,attempts,lease_until,error_code
-FROM export_jobs WHERE status='running' AND lease_until<now()
+FROM export_jobs WHERE status='running' AND lease_until<UTC_TIMESTAMP(6)
 ORDER BY lease_until LIMIT 20;
 ````
 
@@ -1609,16 +1645,16 @@ ORDER BY lease_until LIMIT 20;
 
 #### 4. 恢复一个有证据的本地故障
 
-先确认下游恢复、容量可用和代码修复,再运行任务模块有限批次recovery与publisher。单步脚本不常驻;部署中的循环wrapper有独立节奏与停机管理。失败耗尽任务仅在确认修复后用replay_job逐条生成新job,保留旧失败证据。
+先确认下游恢复、容量可用和代码修复,再运行任务模块有限批次recovery与publisher。单步脚本不常驻;部署中的循环wrapper有独立节奏与停机管理。失败耗尽任务仅在确认修复后用replay_job逐条生成新job,保留旧失败证据。 在第一步已定义 run_task 的同一 Bash 终端执行;新终端先重新定义所选部署路线的函数,确保配置和工作目录一致。
 
 文件:终端 / 当前课时
 
 ````bash
-python recovery.py
-python publisher.py
-# 对已修复且明确的failed任务:
-# python replay_job.py <本次已核对的失败job_id>
-# 不运行celery purge,不通过重置attempts解除失败限制。
+run_task recovery.py
+run_task publisher.py
+# 对已修复且明确的 failed 任务:
+# run_task replay_job.py <本次已核对的失败job_id>
+# 不运行 celery purge,不通过重置 attempts 解除失败限制。
 ````
 
 #### 5. 验证恢复不是短暂下降

이 변경점에서 너무 많은 파일들이 변경되어 몇몇 파일들은 표시되지 않았습니다.