database.json 86 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276
  1. [
  2. {
  3. "id": "01",
  4. "group": "连接与数据模型",
  5. "title": "准备 MySQL 8.4 与命令行练习环境",
  6. "summary": "创建独立的 notes_lab,核对客户端、账号、字符集和会话参数。",
  7. "objectives": [
  8. "启动MySQL 8.4 LTS并用业务账号连接",
  9. "区分容器端口3306与宿主端口3307",
  10. "核对InnoDB、utf8mb4和当前数据库"
  11. ],
  12. "concepts": [
  13. {
  14. "title": "服务进程、数据库与客户端",
  15. "body": "mysqld持续保存和处理数据;mysql命令行客户端发送SQL。一个实例中可以创建多个数据库。MySQL中的SCHEMA与DATABASE在这里是同义词,账号则是用户名与来源host的组合。退出客户端不会停止服务。"
  16. },
  17. {
  18. "title": "本机独立实验边界",
  19. "body": "所有建表、写入、锁等待与恢复命令仅在notes_lab练习环境运行,不针对线上实例。需要Docker与Python 3.11+;Windows建议在WSL Bash中执行本模块Shell命令。本节只借Docker获得一致环境,后续容器模块再系统讲解。"
  20. },
  21. {
  22. "title": "默认值也要核对",
  23. "body": "本课程固定MySQL 8.4、InnoDB、utf8mb4;默认隔离级别通常是REPEATABLE-READ,后续实验显式设置。统一把DATETIME(6)中的业务时间解释为UTC,各客户端建立连接时设置+00:00。"
  24. }
  25. ],
  26. "steps": [
  27. {
  28. "title": "生成本机凭据并启动数据库",
  29. "body": "在准备存放工程的父目录中执行以下 Bash,命令会创建并进入 notes-service。仅首次初始化时生成凭据;继续学习时进入既有目录并复用 .mysql.env,不要重复生成密码。镜像首次初始化会创建notes_lab和app_user,app_user只在该练习数据库获得权限,不是root。密码生成在忽略文件中,终端不打印;重新使用旧数据卷时环境变量不会重置已存在账号。",
  30. "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",
  31. "lang": "bash"
  32. },
  33. {
  34. "title": "用交互密码连接业务库",
  35. "body": "等待日志出现ready for connections。用编辑器读取本机.mysql.env中的MYSQL_PASSWORD用于交互登录,不粘贴到共享日志。第一条命令在容器内连接3306;本机安装了mysql客户端则用第二条,两条选择一条。",
  36. "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",
  37. "lang": "bash"
  38. },
  39. {
  40. "title": "确认环境与身份",
  41. "body": "下面是mysql客户端中的SQL。DATABASE()必须是notes_lab,CURRENT_USER()显示匹配到的授权账号;不要只凭命令行提示符判断目标。默认排序规则ai_ci不区分重音和大小写,涉及唯一名称时要把此行为纳入设计。",
  42. "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();",
  43. "lang": "sql"
  44. },
  45. {
  46. "title": "验证客户端和事务开关",
  47. "body": "mysql交互客户端通常autocommit=1,单条DML独立提交;Python稍后显式使用autocommit=False。不要将两个客户端的默认行为混为一谈。退出重连后再次确认数据库。",
  48. "code": "SELECT 6*7 AS answer;\n-- answer=42\nSELECT DATABASE()='notes_lab' AS in_lab;\n-- in_lab=1\nquit",
  49. "lang": "sql"
  50. }
  51. ],
  52. "checks": [
  53. "服务版本为8.4,数据库为notes_lab,存储引擎为InnoDB。",
  54. "客户端使用3307或容器内部3306的正确位置,业务身份不是root。",
  55. "字符集utf8mb4、会话UTC和隔离级别已实际核对。"
  56. ],
  57. "pitfalls": [
  58. {
  59. "symptom": "Can't connect to MySQL server",
  60. "cause": "初始化尚未完成,或混淆宿主与容器端口。",
  61. "fix": "查看专用容器日志;宿主3307,docker exec内部3306。"
  62. },
  63. {
  64. "symptom": "Access denied for user",
  65. "cause": "用户名/host匹配或密码不对;旧卷不会因env变化重设密码。",
  66. "fix": "使用当前卷的真实凭据,管理账号确认账号host;需要改密时执行明确ALTER USER,不删卷解决。"
  67. }
  68. ],
  69. "exercise": {
  70. "task": "故意用notes_missing连接,再与连接成功后SELECT 1/0比较错误层次。",
  71. "hint": "数据库名属于连接目标,SQL表达式在建立会话之后才执行。",
  72. "answer": "不存在数据库通常为1049 Unknown database;认证失败常为1045。MySQL中SELECT 1/0通常返回NULL并产生警告,可用SHOW WARNINGS查看,不能把它当作稳定抛异常的事务测试。要测试约束失败请使用后续非法INSERT。"
  73. },
  74. "refs": [
  75. {
  76. "label": "MySQL 8.4:mysql客户端",
  77. "url": "https://dev.mysql.com/doc/refman/8.4/en/mysql.html"
  78. },
  79. {
  80. "label": "MySQL 8.4:账号名称",
  81. "url": "https://dev.mysql.com/doc/refman/8.4/en/account-names.html"
  82. },
  83. {
  84. "label": "Docker:MySQL镜像",
  85. "url": "https://hub.docker.com/_/mysql"
  86. }
  87. ],
  88. "production": {
  89. "scenario": "新服务需要能重复建立且不会误连线上库的开发环境。",
  90. "decisions": [
  91. "显式核对数据库与授权账号。",
  92. "本机密码文件不进入仓库。",
  93. "生产记录8.4补丁版和镜像摘要,练习使用mysql:8.4入口。"
  94. ],
  95. "failureDrill": "在独立实例用错库名与端口连接,分别记录错误层次。",
  96. "acceptance": [
  97. "目标可辨认。",
  98. "环境与数据卷生命周期可区分。",
  99. "无共享明文密码。"
  100. ]
  101. }
  102. },
  103. {
  104. "id": "02",
  105. "group": "连接与数据模型",
  106. "title": "定义InnoDB笔记表、UTC时间与数据约束",
  107. "summary": "创建统一notes模型,验证布尔值、标题和时间的真实MySQL行为。",
  108. "objectives": [
  109. "建立AUTO_INCREMENT主键和CHECK约束",
  110. "区分DATETIME存值约定与时区转换",
  111. "说明JSON、TEXT和BOOLEAN的存储边界"
  112. ],
  113. "concepts": [
  114. {
  115. "title": "类型与约束各自负责边界",
  116. "body": "id使用BIGINT AUTO_INCREMENT,标题VARCHAR(120),正文TEXT。MySQL BOOLEAN是TINYINT(1)同义词,单写BOOLEAN不会限制只能0或1,所以增加CHECK。MySQL 8.4会执行CHECK,不应沿用旧版本忽略CHECK的印象。"
  117. },
  118. {
  119. "title": "UTC是明确的数据约定",
  120. "body": "DATETIME(6)保存带微秒的日期时间字段,本身不保存时区,也不会因SET time_zone转换已有值。连接统一+00:00,写入使用UTC_TIMESTAMP(6)或UTC会话下的CURRENT_TIMESTAMP(6),Python输出时再明确标注UTC。"
  121. },
  122. {
  123. "title": "结构化扩展字段需限定用途",
  124. "body": "JSON列验证文档语法,但不能替代需要连接、唯一性和外键的关系表。TEXT默认值使用表达式DEFAULT ('');正文上限16000字符,utf8mb4最坏64000字节仍在TEXT容量内;JSON和日志也应限制大小,避免无限载荷。updated_at不自动更新,本课程每次写入显式维护。"
  125. }
  126. ],
  127. "steps": [
  128. {
  129. "title": "首次创建统一notes表",
  130. "body": "在app_user/notes_lab下执行一次,保存为sql/001_notes.sql。version从第一版就存在,后续并发修改统一递增。所有业务表明确ENGINE=InnoDB,不依赖不同环境的隐含配置。",
  131. "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;",
  132. "lang": "sql",
  133. "filename": "sql/001_notes.sql"
  134. },
  135. {
  136. "title": "插入并从同一会话取得编号",
  137. "body": "MySQL 8.4不提供本模块DML的RETURNING语法。插入后使用同一会话LAST_INSERT_ID(),Python使用cursor.lastrowid;绝不能查询MAX(id)猜自己的编号。",
  138. "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,时间非空",
  139. "lang": "sql"
  140. },
  141. {
  142. "title": "逐条提交非法值观察错误",
  143. "body": "在交互客户端autocommit=1时逐条运行,预期各自失败。先确认STRICT_TRANS_TABLES包含在sql_mode,超长数据不能静默截断。随后核对只有合法样本。",
  144. "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;",
  145. "lang": "sql"
  146. },
  147. {
  148. "title": "验证DATETIME不会随显示时区转换",
  149. "body": "同一created_at在两个会话时区设置下保持原字段值,而NOW(6)随会话时区变化、UTC_TIMESTAMP(6)保持UTC。不要把没有tzinfo的数据库datetime直接当作用户本地时间。最后恢复+00:00。",
  150. "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普通字符串的输出表示不同",
  151. "lang": "sql"
  152. }
  153. ],
  154. "checks": [
  155. "SHOW CREATE TABLE确认InnoDB、CHECK、version和utf8mb4。",
  156. "非法NULL、空标题和pinned=2被拒绝,默认值与主键正确。",
  157. "切换时区时DATETIME原值不变,能解释UTC存值约定。"
  158. ],
  159. "pitfalls": [
  160. {
  161. "symptom": "pinned保存成2也成功",
  162. "cause": "只声明BOOLEAN却没有CHECK。",
  163. "fix": "定义CHECK(pinned IN(0,1)),应用入口也按布尔校验。"
  164. },
  165. {
  166. "symptom": "序列化时间出现8小时偏差",
  167. "cause": "把UTC约定的无时区DATETIME当本地时间解释。",
  168. "fix": "统一连接+00:00,读出后按已知UTC约定加timezone.utc,输出带时区ISO8601。"
  169. }
  170. ],
  171. "exercise": {
  172. "task": "比较JSON扩展属性与tags关系表,说明标签唯一性应该放在哪里。",
  173. "hint": "JSON适合弱结构扩展,不自动提供跨行唯一和外键规则。",
  174. "answer": "标签名在tags表使用UNIQUE,笔记标签在关联表维护外键。少量无需关系查询的附加属性可用JSON;热点JSON路径需显式生成列或函数索引并验证计划。"
  175. },
  176. "refs": [
  177. {
  178. "label": "MySQL 8.4:CREATE TABLE与CHECK",
  179. "url": "https://dev.mysql.com/doc/refman/8.4/en/create-table.html"
  180. },
  181. {
  182. "label": "MySQL 8.4:时间类型",
  183. "url": "https://dev.mysql.com/doc/refman/8.4/en/date-and-time-types.html"
  184. },
  185. {
  186. "label": "MySQL 8.4:JSON",
  187. "url": "https://dev.mysql.com/doc/refman/8.4/en/json.html"
  188. }
  189. ],
  190. "production": {
  191. "scenario": "API、脚本和worker共同写入同一张表。",
  192. "decisions": [
  193. "完整性规则放到数据库。",
  194. "UTC作为所有写入入口的共同约定。",
  195. "编号不保证连续,updated_at与version由统一写入代码维护。"
  196. ],
  197. "failureDrill": "向notes_lab提交非法标题和布尔整数,记录错误码与未落库结果。",
  198. "acceptance": [
  199. "约束真实生效。",
  200. "时间序列化规则一致。",
  201. "没有依赖自增编号连续性。"
  202. ]
  203. }
  204. },
  205. {
  206. "id": "03",
  207. "group": "SQL 与关系建模",
  208. "title": "用PyMySQL参数化完成增删改查",
  209. "summary": "建立显式提交、回滚和关闭的Python访问层,用lastrowid与rowcount确认结果。",
  210. "objectives": [
  211. "使用DictCursor和独立参数元组",
  212. "正确读取lastrowid和受影响行数",
  213. "在成功与异常路径释放事务和连接"
  214. ],
  215. "concepts": [
  216. {
  217. "title": "动态值通过驱动参数传递",
  218. "body": "PyMySQL execute(sql,params)的%s表示值,不加引号、不用f-string替换用户输入。标识符不是值参数,动态列名和排序方向用固定白名单。连接默认禁用多语句,不启用MULTI_STATEMENTS。"
  219. },
  220. {
  221. "title": "MySQL写入结果的读取方法",
  222. "body": "INSERT后从当前cursor.lastrowid取得生成编号,再SELECT同一行。UPDATE要根据rowcount及业务语义判断:默认统计实际变化行,相同值可能返回0;本课每次更新递增version,因此成功匹配一定发生变化。"
  223. },
  224. {
  225. "title": "显式管理生命周期",
  226. "body": "PyMySQL使用autocommit=False。本课完整脚本try中commit、except中rollback、finally中close,cursor上下文只关闭cursor,不代替数据库提交。连接读写超时是客户端等待预算,不等同于服务端事务整体超时。"
  227. }
  228. ],
  229. "steps": [
  230. {
  231. "title": "安装同步驱动并设置环境",
  232. "body": "Bash沿用第01课生成文件。set -a与source只用于自己生成的本机文件,不source不可信文件。MySQL 8.4默认认证使用caching_sha2_password,安装PyMySQL的rsa附加依赖用于无TLS练习连接;生产应配置可信TLS。",
  233. "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",
  234. "lang": "bash"
  235. },
  236. {
  237. "title": "保存统一连接函数",
  238. "body": "保存mysql_client.py;后续Redis模块直接复用。max_execution_time主要限制SELECT,innodb_lock_wait_timeout处理行锁,lock_wait_timeout处理元数据锁;它们不是统一的所有SQL执行超时。生产另配置请求deadline和慢查询观测。此直接驱动入口使用MYSQL_*,同步SQLAlchemy连接串才是mysql+pymysql://。",
  239. "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",
  240. "lang": "python",
  241. "filename": "mysql_client.py"
  242. },
  243. {
  244. "title": "执行完整CRUD并显式结束事务",
  245. "body": "保存db_crud.py并运行。自建样本最后删除,可以重复执行。读取和修改都只使用自己的lastrowid;同一SQL更新version与updated_at。打印的pinned通常是0/1,业务JSON应按模型转成bool。",
  246. "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()",
  247. "lang": "python",
  248. "filename": "db_crud.py"
  249. },
  250. {
  251. "title": "验证SQL片段只成为普通文本",
  252. "body": "保存db_input.py。输入中包含单引号和DROP片段,驱动将其作为值处理;读取原值后回滚专用插入,不执行任何表删除命令。注意finally没有代替rollback。",
  253. "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()",
  254. "lang": "python",
  255. "filename": "db_input.py"
  256. },
  257. {
  258. "title": "明确列表边界与不存在结果",
  259. "body": "回到mysql客户端执行。列表使用确定排序和LIMIT,更新不存在编号得到0 affected rows;PyMySQL不能用execute返回值当作整条数据库记录。生产API再将未找到与版本冲突区分成约定响应。",
  260. "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",
  261. "lang": "sql"
  262. }
  263. ],
  264. "checks": [
  265. "CRUD输出使用同一生成编号,更新后version增加。",
  266. "输入SQL片段保持文本,数据库表仍可访问。",
  267. "每条完整脚本都有commit或显式回滚,以及finally关闭连接。"
  268. ],
  269. "pitfalls": [
  270. {
  271. "symptom": "读取到的是1而不是记录字典",
  272. "cause": "把cursor.execute返回的受影响行数当作查询数据。",
  273. "fix": "SELECT后使用fetchone/fetchall,INSERT后使用lastrowid。"
  274. },
  275. {
  276. "symptom": "UPDATE相同内容返回0,被误判为不存在",
  277. "cause": "默认受影响行数反映实际修改。",
  278. "fix": "使用版本递增的条件更新,或明确启用FOUND_ROWS并理解行为;需要区分不存在与未变更时单独查询。"
  279. }
  280. ],
  281. "exercise": {
  282. "task": "写一个rename_note(conn,id,title,expected_version),返回是否修改成功,函数不自行提交。",
  283. "hint": "把WHERE版本比较、版本递增和时间更新放到一条UPDATE。",
  284. "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行可能不存在或冲突,需要时再读取区分。"
  285. },
  286. "refs": [
  287. {
  288. "label": "PyMySQL:使用示例",
  289. "url": "https://pymysql.readthedocs.io/en/latest/user/examples.html"
  290. },
  291. {
  292. "label": "PyMySQL:连接参数",
  293. "url": "https://pymysql.readthedocs.io/en/latest/modules/connections.html"
  294. },
  295. {
  296. "label": "MySQL 8.4:LAST_INSERT_ID",
  297. "url": "https://dev.mysql.com/doc/refman/8.4/en/information-functions.html"
  298. }
  299. ],
  300. "production": {
  301. "scenario": "浏览器输入将进入数据库,写入成功必须有可确认的结果。",
  302. "decisions": [
  303. "固定SQL与独立参数。",
  304. "默认不开启多语句。",
  305. "不在发生不确定写入结果后无条件重试。"
  306. ],
  307. "failureDrill": "对专用样本提交包含SQL语法的标题,再回滚并查询表完整性。",
  308. "acceptance": [
  309. "无SQL插值。",
  310. "空结果和行数语义正确。",
  311. "异常路径不泄漏连接。"
  312. ]
  313. }
  314. },
  315. {
  316. "id": "04",
  317. "group": "SQL 与关系建模",
  318. "title": "使用外键、JOIN与聚合建模标签",
  319. "summary": "建立InnoDB多对多关系,验证唯一关联、外键索引和删除约束。",
  320. "objectives": [
  321. "设计tags和note_tags关系表",
  322. "用LEFT JOIN保留零标签笔记",
  323. "理解MySQL外键即时检查与索引要求"
  324. ],
  325. "concepts": [
  326. {
  327. "title": "联合主键表达一组关系",
  328. "body": "note_tags一行表示一条笔记与一个标签的关联,PRIMARY KEY(note_id,tag_id)防重复。标签名UNIQUE遵循表排序规则,默认ai_ci下Python和python可能冲突,需要时选择明确的区分大小写排序规则。"
  329. },
  330. {
  331. "title": "InnoDB即时检查外键",
  332. "body": "父记录必须先存在;MySQL不支持将此类外键检查推迟到COMMIT。InnoDB要求外键列有合适前缀索引,缺少时可能自动创建;本课显式声明tag_id索引便于审计。不要通过foreign_key_checks=0绕过正常业务写入。"
  333. },
  334. {
  335. "title": "LEFT JOIN后的计数要选列",
  336. "body": "未匹配标签的笔记仍有一条右侧NULL行。COUNT(*)会计为1,COUNT(nt.tag_id)得到0。连接更多一对多关系时注意行数放大,不直接对放大后的记录计数。"
  337. }
  338. ],
  339. "steps": [
  340. {
  341. "title": "创建父表与关联表",
  342. "body": "保存sql/002_tags.sql,在notes_lab首次执行一次。删除笔记级联清理关联;被引用标签使用RESTRICT。显式存储引擎与字符集,避免外键因表属性不一致失败。",
  343. "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;",
  344. "lang": "sql",
  345. "filename": "sql/002_tags.sql"
  346. },
  347. {
  348. "title": "在当前会话准备独立样本",
  349. "body": "@tagged_id与@empty_id是当前mysql会话变量,换窗口需要重新设置。标签插入只首次运行;重复练习可直接复用已有两个标签,不使用INSERT IGNORE吞掉其他约束错误。",
  350. "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');",
  351. "lang": "sql"
  352. },
  353. {
  354. "title": "读取明细与零标签统计",
  355. "body": "第一条2行明细,第二条两个笔记的标签数分别2与0。group by显式包含所选笔记列,与默认ONLY_FULL_GROUP_BY一致。",
  356. "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;",
  357. "lang": "sql"
  358. },
  359. {
  360. "title": "验证即时约束和级联删除",
  361. "body": "逐条执行预期失败语句。重复关联1062,删除被引用标签1451,不存在父标签的关联1452。最后删除本课带标签笔记,关联消失而标签本身仍保留。",
  362. "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;",
  363. "lang": "sql"
  364. }
  365. ],
  366. "checks": [
  367. "明细2行、计数2与0。",
  368. "重复、被引用删除和无父关联分别被立即拒绝。",
  369. "联合主键与tag_id辅助索引可在SHOW INDEX中确认。"
  370. ],
  371. "pitfalls": [
  372. {
  373. "symptom": "到COMMIT才希望补齐父行,但子行INSERT立即失败",
  374. "cause": "把其他系统的延迟外键习惯套到MySQL。",
  375. "fix": "先插父记录,再插子记录;需要复杂调度时重新规划写入顺序。"
  376. },
  377. {
  378. "symptom": "无标签笔记统计为1",
  379. "cause": "COUNT(*)把LEFT JOIN空关联行计入。",
  380. "fix": "改COUNT(nt.tag_id),检查其他连接是否进一步放大。"
  381. }
  382. ],
  383. "exercise": {
  384. "task": "查询带db04-python标签的笔记且每条最多一次。",
  385. "hint": "EXISTS直接表达是否存在,避免把标签明细带回结果。",
  386. "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; 清理关联后可为空,重新准备样本后再验收。"
  387. },
  388. "refs": [
  389. {
  390. "label": "MySQL 8.4:JOIN",
  391. "url": "https://dev.mysql.com/doc/refman/8.4/en/join.html"
  392. },
  393. {
  394. "label": "MySQL 8.4:外键",
  395. "url": "https://dev.mysql.com/doc/refman/8.4/en/create-table-foreign-keys.html"
  396. },
  397. {
  398. "label": "MySQL 8.4:聚合",
  399. "url": "https://dev.mysql.com/doc/refman/8.4/en/aggregate-functions.html"
  400. }
  401. ],
  402. "production": {
  403. "scenario": "笔记列表要展示标签,删除策略应与业务约定一致。",
  404. "decisions": [
  405. "外键即时检查。",
  406. "关系使用约束与明确索引。",
  407. "零关系结果单独测试。"
  408. ],
  409. "failureDrill": "用本课专用样本重复建立关系、删除被引用标签,并核对约束错误。",
  410. "acceptance": [
  411. "无孤儿关联。",
  412. "零标签不丢行。",
  413. "删除边界清晰。"
  414. ]
  415. }
  416. },
  417. {
  418. "id": "05",
  419. "group": "事务与并发",
  420. "title": "明确InnoDB事务、保存点与DDL提交边界",
  421. "summary": "让必需写入一起提交,并验证错误语句、整个事务和结构变更的不同回滚范围。",
  422. "objectives": [
  423. "使用START TRANSACTION与显式ROLLBACK",
  424. "区分语句失败和事务整体失败",
  425. "验证DDL隐式提交不能用ROLLBACK撤销"
  426. ],
  427. "concepts": [
  428. {
  429. "title": "事务覆盖需要同时成功的动作",
  430. "body": "创建笔记和关联必需标签放在同一InnoDB事务。客户端收到异常后应按业务边界回滚,而不是看数据库是否仍允许SELECT就继续提交。外部HTTP、邮件或文件不随数据库事务自动回滚。"
  431. },
  432. {
  433. "title": "普通约束错误通常只回滚本条语句",
  434. "body": "MySQL中重复键或外键失败后,事务里的其他成功写入仍可能保留,事务也通常能继续执行。死锁1213会回滚整个事务;锁等待超时1205默认通常只回滚当前语句,受配置影响。应用统一结束失败业务事务可避免半成品。"
  435. },
  436. {
  437. "title": "DDL不是可随意回滚的DML",
  438. "body": "CREATE TABLE、ALTER TABLE等通常在执行前后隐式提交。MySQL原子DDL保护单条DDL的崩溃一致性,不表示BEGIN包起来的多条结构变更可以ROLLBACK。迁移文件要独立于业务事务。"
  439. }
  440. ],
  441. "steps": [
  442. {
  443. "title": "执行笔记与标签一起成功的路径",
  444. "body": "在mysql客户端执行,先确认db04-api标签存在。没有DML RETURNING,紧接INSERT用同连接LAST_INSERT_ID。COMMIT后从另一个会话查询能看到笔记和关联。",
  445. "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;",
  446. "lang": "sql"
  447. },
  448. {
  449. "title": "让第二步失败后明确回滚整个业务",
  450. "body": "逐条执行。外键失败后SELECT仍能看到本事务第一步插入的笔记,这正是不能忽略异常后COMMIT的原因。ROLLBACK后在原会话和新会话查询该编号均为空。",
  451. "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",
  452. "lang": "sql"
  453. },
  454. {
  455. "title": "只对明确可选步骤使用保存点",
  456. "body": "本例业务允许笔记创建而标签失败。保存点之前写入保留,恢复到保存点后提交。必需标签不得借此绕过验证;AUTO_INCREMENT已消耗编号可能留下空洞。",
  457. "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;",
  458. "lang": "sql"
  459. },
  460. {
  461. "title": "用专用表证明DDL隐式提交",
  462. "body": "仅notes_lab执行,一次实验会保留一条笔记和一张空表,再显式清理。CREATE TABLE之前INSERT因隐式提交已落库,随后ROLLBACK不能撤销它们。不要将此模式用于真实业务迁移。",
  463. "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;",
  464. "lang": "sql"
  465. }
  466. ],
  467. "checks": [
  468. "失败后主动回滚,第一步笔记没有遗留。",
  469. "保存点例子只保留业务明确允许的部分。",
  470. "能观察DDL隐式提交,并清理仅本课创建的标记。"
  471. ],
  472. "pitfalls": [
  473. {
  474. "symptom": "标签失败但笔记仍保存",
  475. "cause": "约束错误通常只撤销本条SQL,应用继续提交了事务。",
  476. "fix": "except中显式rollback整个业务事务,再向调用方报告失败。"
  477. },
  478. {
  479. "symptom": "ROLLBACK没有撤销ALTER或CREATE",
  480. "cause": "DDL发生隐式提交,原子DDL不等于用户可回滚DDL事务。",
  481. "fix": "迁移拆分为可检查步骤、先备份和演练,避免与业务DML混用。"
  482. }
  483. ],
  484. "exercise": {
  485. "task": "PyMySQL第二次execute触发1452时,最小异常处理是什么?",
  486. "hint": "不要根据SELECT还能运行就判断可以继续提交。",
  487. "answer": "try内完成全部必需SQL后conn.commit;except BaseException中conn.rollback并raise;finally conn.close。若需要只保留部分成功,必须先定义业务允许的保存点范围。"
  488. },
  489. "refs": [
  490. {
  491. "label": "MySQL 8.4:事务语句",
  492. "url": "https://dev.mysql.com/doc/refman/8.4/en/commit.html"
  493. },
  494. {
  495. "label": "MySQL 8.4:隐式提交",
  496. "url": "https://dev.mysql.com/doc/refman/8.4/en/implicit-commit.html"
  497. },
  498. {
  499. "label": "MySQL 8.4:InnoDB错误处理",
  500. "url": "https://dev.mysql.com/doc/refman/8.4/en/innodb-error-handling.html"
  501. },
  502. {
  503. "label": "MySQL 8.4:保存点",
  504. "url": "https://dev.mysql.com/doc/refman/8.4/en/savepoint.html"
  505. }
  506. ],
  507. "production": {
  508. "scenario": "一次用户保存涉及多表,异常处理必须符合MySQL实际回滚语义。",
  509. "decisions": [
  510. "DML事务显式提交回滚。",
  511. "DDL独立运行并设计恢复路径。",
  512. "事务中不等待外部服务。"
  513. ],
  514. "failureDrill": "在notes_lab触发外键错误和DDL隐式提交,分别记录保留与回滚的记录。",
  515. "acceptance": [
  516. "无必需写入半成品。",
  517. "错误码与回滚范围可解释。",
  518. "不宣称BEGIN能包住所有DDL。"
  519. ]
  520. }
  521. },
  522. {
  523. "id": "06",
  524. "group": "事务与并发",
  525. "title": "用双会话观察RR、RC与当前读",
  526. "summary": "按固定顺序比较MySQL默认RR快照、显式RC和带锁当前读。",
  527. "objectives": [
  528. "复现默认RR下的一致性读",
  529. "显式切换RC观察新提交",
  530. "区分快照SELECT与锁定读/UPDATE"
  531. ],
  532. "concepts": [
  533. {
  534. "title": "默认RR固定一致性读快照",
  535. "body": "InnoDB默认REPEATABLE READ。普通SELECT通常在事务第一次一致性读建立快照,后续普通读取使用相同快照;START TRANSACTION本身不一定已建立快照。长快照会增加undo保留与purge压力。"
  536. },
  537. {
  538. "title": "RC每次一致性读建立新快照",
  539. "body": "READ COMMITTED下不同SELECT可以读到期间新提交的值。SET TRANSACTION ISOLATION LEVEL只设置接下来事务,必须在START TRANSACTION之前且当前没有活动事务。普通读均不读取另一会话尚未提交的值。"
  540. },
  541. {
  542. "title": "UPDATE与锁定读不能按旧快照推断",
  543. "body": "SELECT FOR UPDATE与UPDATE使用锁定/当前读规则,可访问较新的已提交行。RR事务普通读仍见v1时,带锁读取可能见v2;后续写入可能覆盖他人编辑,因此版本条件仍必要。RR不自动把并发编辑变成版本冲突异常。"
  544. }
  545. ],
  546. "steps": [
  547. {
  548. "title": "准备同一行和两个窗口",
  549. "body": "在一个mysql会话插入样本,记录编号;A/B都SET @iso_id=实际编号。不要假定样本id=1。每次切换隔离级别前双方执行ROLLBACK结束旧事务。",
  550. "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",
  551. "lang": "sql"
  552. },
  553. {
  554. "title": "按窗口顺序观察默认RR",
  555. "body": "A建立快照后,B更新但不提交时A仍v1;B提交后A普通读也仍v1;A结束事务再读才v2。代码按注释切窗口执行,不要整段粘到单一窗口。",
  556. "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",
  557. "lang": "sql"
  558. },
  559. {
  560. "title": "切为RC观察两次查询变化",
  561. "body": "延续v2。A显式RC读取,B自动提交更新v3后,A在相同事务的下一条SELECT读到v3。最后结束A事务,后续下一事务仍按会话默认RR,除非再显式设置。",
  562. "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;",
  563. "lang": "sql"
  564. },
  565. {
  566. "title": "证明RR普通读与当前读可以不同",
  567. "body": "A普通读v3建立快照;B提交v4;A普通读仍v3,但FOR UPDATE读到v4并取得锁。此处只观察,不覆盖写入。A立即ROLLBACK释放锁;不能把这一实验推导为无条件覆盖更新是安全的。",
  568. "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;",
  569. "lang": "sql"
  570. }
  571. ],
  572. "checks": [
  573. "RR事务中保持旧值,结束后读取新值。",
  574. "RC同事务下一次SELECT能看到B新提交。",
  575. "RR普通读与FOR UPDATE结果不同,所有实验事务已结束。"
  576. ],
  577. "pitfalls": [
  578. {
  579. "symptom": "切隔离级别报事务中不能修改",
  580. "cause": "已有事务尚未结束,或在START TRANSACTION之后才SET TRANSACTION。",
  581. "fix": "先ROLLBACK,再设置下一事务的级别,然后START TRANSACTION。"
  582. },
  583. {
  584. "symptom": "以为RR能防止旧表单覆盖新内容",
  585. "cause": "普通读取快照与UPDATE的锁定读规则不同。",
  586. "fix": "在UPDATE中加入expected version条件,或对需要串行的业务使用短锁定事务。"
  587. }
  588. ],
  589. "exercise": {
  590. "task": "为什么多条报表SQL可用短RR只读事务,但编辑接口仍需版本检查?",
  591. "hint": "报表关心一致快照,编辑关心提交时是否有人已修改。",
  592. "answer": "RR普通读取可以共享一致快照;编辑提交必须用WHERE id=? AND version=?做原子条件更新。不要让用户填写表单的整个过程占用数据库事务。"
  593. },
  594. "refs": [
  595. {
  596. "label": "MySQL 8.4:隔离级别",
  597. "url": "https://dev.mysql.com/doc/refman/8.4/en/innodb-transaction-isolation-levels.html"
  598. },
  599. {
  600. "label": "MySQL 8.4:一致性读",
  601. "url": "https://dev.mysql.com/doc/refman/8.4/en/innodb-consistent-read.html"
  602. },
  603. {
  604. "label": "MySQL 8.4:锁定读",
  605. "url": "https://dev.mysql.com/doc/refman/8.4/en/innodb-locking-reads.html"
  606. }
  607. ],
  608. "production": {
  609. "scenario": "报表导出与多人编辑同时运行,需要明确每种读操作所见的数据。",
  610. "decisions": [
  611. "默认RR与显式RC分别验收。",
  612. "短事务降低undo保留压力。",
  613. "当前读与普通快照读不混淆。"
  614. ],
  615. "failureDrill": "在同一笔记上执行固定双会话时序,记录每次提交和读取结果。",
  616. "acceptance": [
  617. "结果能对应具体时点。",
  618. "无遗留长事务。",
  619. "不把RR描述成自动乐观锁。"
  620. ]
  621. }
  622. },
  623. {
  624. "id": "07",
  625. "group": "事务与并发",
  626. "title": "观察行锁、间隙锁、死锁与版本冲突",
  627. "summary": "使用独立实验行定位InnoDB等待,再用一致加锁顺序和条件版本更新约束并发。",
  628. "objectives": [
  629. "通过performance_schema定位等待链",
  630. "复现RR范围锁与死锁",
  631. "用版本条件阻止旧编辑覆盖"
  632. ],
  633. "concepts": [
  634. {
  635. "title": "锁依赖访问路径与隔离级别",
  636. "body": "InnoDB锁定索引记录。唯一主键等值查找通常只需目标记录锁;RR范围锁定读可能使用next-key/gap锁阻止范围插入。RC通常减少搜索与扫描的间隙锁,但外键和重复键检查等仍有例外,不能说RC完全没有间隙锁。"
  637. },
  638. {
  639. "title": "死锁与超时有不同回滚范围",
  640. "body": "循环等待会触发死锁检测,一方收到1213,整个事务被回滚;1205锁等待超时默认通常只回滚当前语句。应用遇到两类错误都显式ROLLBACK,并在事务外有限重试完整业务动作,不重试任意一条末尾SQL。"
  641. },
  642. {
  643. "title": "数据库锁与编辑版本分别使用",
  644. "body": "数据库锁保护短事务中的并发资源,用户编辑过程不应持有连接。UPDATE同时比较version并递增,rowcount=0表示未成功匹配,必须返回冲突或不存在而不是假装保存成功。"
  645. }
  646. ],
  647. "steps": [
  648. {
  649. "title": "准备两个实验编号与三个窗口",
  650. "body": "业务窗口A/B连接app_user,分别记录CONNECTION_ID()。另开只读管理窗口用root连接本机notes-mysql,查看performance_schema需要额外权限,不把诊断权限授予API。记录两个新编号并在A/B各设置变量。",
  651. "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;",
  652. "lang": "sql"
  653. },
  654. {
  655. "title": "制造有上限的行锁等待",
  656. "body": "A先START TRANSACTION并锁第一行,B开始事务更新同一行会等待。等待期间管理窗口执行查询,记录blocking与waiting线程。B约5秒收到1205后ROLLBACK,A观察后也ROLLBACK;不要直接KILL任何未核对会话。",
  657. "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;",
  658. "lang": "sql"
  659. },
  660. {
  661. "title": "固定相反顺序复现死锁",
  662. "body": "先A锁第一行、B锁第二行,再A请求第二行进入等待,最后B请求第一行。其中一方收到1213,不能指定一定哪方被牺牲。另一方拿到锁后两边都结束事务。管理窗口SHOW ENGINE INNODB STATUS可查看最近死锁,输出可能含业务SQL,留存时脱敏。",
  663. "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;",
  664. "lang": "sql"
  665. },
  666. {
  667. "title": "以固定顺序加锁并保持短事务",
  668. "body": "A、B都按相同主键顺序锁两行。先确认全部旧事务结束,再A执行下列START与SELECT,B执行同样两条等待;A COMMIT后B继续,B再COMMIT。本课数量只有两行,不把大范围全表加锁作为默认方案。",
  669. "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;",
  670. "lang": "sql"
  671. },
  672. {
  673. "title": "用独立范围表观察RR间隙保护",
  674. "body": "仅在notes_lab建立专用lock_range_probe,DDL在事务外。A RR锁[10,20]范围后B插入15会等待;B超时回滚、A回滚。再将A换READ COMMITTED重复,B通常可插入15,因为不再需要同样的范围间隙保护;两轮结束后清理专用表。",
  675. "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;",
  676. "lang": "sql"
  677. },
  678. {
  679. "title": "原子比较旧版本并拒绝第二次覆盖",
  680. "body": "专用笔记先重置到version=1仅为实验,生产版本不得倒退。第一次更新成功1行,第二次仍带version=1匹配0行;SELECT确认内容仍为editor A。",
  681. "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;",
  682. "lang": "sql"
  683. }
  684. ],
  685. "checks": [
  686. "行锁等待能在管理窗口找到,1205后主动回滚。",
  687. "死锁出现1213,固定顺序实验仅等待而不产生本例闭环。",
  688. "范围实验与版本实验分别解释间隙保护和编辑冲突。"
  689. ],
  690. "pitfalls": [
  691. {
  692. "symptom": "设置较高隔离后插入也卡住",
  693. "cause": "RR范围锁可能保护间隙,更新不只锁住已存在行。",
  694. "fix": "核对索引访问范围和data_locks,缩小事务范围,按业务需要选择RC。"
  695. },
  696. {
  697. "symptom": "发生1205后继续COMMIT留下部分写入",
  698. "cause": "误以为所有数据库错误都会回滚整个事务。",
  699. "fix": "统一显式rollback失败业务,再有限重试;记录错误码与完整操作关联。"
  700. }
  701. ],
  702. "exercise": {
  703. "task": "收到1213重试时,应复用旧计算结果还是重新读取整个事务?",
  704. "hint": "数据库已回滚被牺牲事务,外部事实可能变化。",
  705. "answer": "回滚并重新建立事务、重新读取和计算,使用次数上限与抖动退避。任何外部副作用需要幂等键,不能因数据库回滚就断言邮件或文件也消失。"
  706. },
  707. "refs": [
  708. {
  709. "label": "MySQL 8.4:InnoDB锁",
  710. "url": "https://dev.mysql.com/doc/refman/8.4/en/innodb-locking.html"
  711. },
  712. {
  713. "label": "MySQL 8.4:死锁示例",
  714. "url": "https://dev.mysql.com/doc/refman/8.4/en/innodb-deadlock-example.html"
  715. },
  716. {
  717. "label": "MySQL 8.4:data_lock_waits",
  718. "url": "https://dev.mysql.com/doc/refman/8.4/en/performance-schema-data-lock-waits-table.html"
  719. }
  720. ],
  721. "production": {
  722. "scenario": "多人编辑和批量任务竞争同一批数据。",
  723. "decisions": [
  724. "管理诊断权限与业务权限分开。",
  725. "加锁顺序一致,等待有上限。",
  726. "版本比较与更新在同一SQL完成。"
  727. ],
  728. "failureDrill": "仅在专用行和lock_range_probe上制造等待与死锁,保存脱敏证据并结束所有事务。",
  729. "acceptance": [
  730. "无无限等待。",
  731. "旧编辑无法覆盖已提交版本。",
  732. "能区分1213与1205的回滚边界。"
  733. ]
  734. }
  735. },
  736. {
  737. "id": "08",
  738. "group": "查询性能",
  739. "title": "设计组合索引、覆盖索引与JSON路径索引",
  740. "summary": "按查询条件建立InnoDB索引,用不可见索引辅助对照,核算写入成本。",
  741. "objectives": [
  742. "从WHERE和ORDER BY推导索引顺序",
  743. "理解聚簇主键与二级索引",
  744. "为JSON路径创建受控的生成列索引"
  745. ],
  746. "concepts": [
  747. {
  748. "title": "InnoDB主键决定聚簇组织",
  749. "body": "主键索引叶子包含行数据,二级索引包含其索引键和主键值。选择过宽的主键会增加所有二级索引成本。查询返回列都在索引中时可能形成覆盖读取,仍需用实际计划确认。"
  750. },
  751. {
  752. "title": "组合索引依据固定查询设计",
  753. "body": "置顶列表WHERE pinned=1 ORDER BY created_at DESC,id DESC可使用(pinned,created_at DESC,id DESC)。低基数单列pinned索引未必划算。MySQL没有WHERE谓词形式的部分索引,本课用组合索引与实际选择率解决查询。"
  754. },
  755. {
  756. "title": "不可见索引与JSON索引各有边界",
  757. "body": "不可见索引默认不参与优化器选择,但仍维护并占用空间,适合可控验证,不是删除替代。JSON字段本身不能按任意路径自动获得B-tree索引;抽出稳定字段到生成列后建索引,需同时设计类型、长度和NULL语义。"
  758. }
  759. ],
  760. "steps": [
  761. {
  762. "title": "有限批次生成50000条性能样本",
  763. "body": "保存db_seed_performance.py,只对notes_lab执行一次。每批500条提交,出错只回滚当前批,之前批次仍保留;再次运行会新增同量数据,不能假定脚本幂等。所有正文不超过16000字符。",
  764. "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()",
  765. "lang": "python",
  766. "filename": "db_seed_performance.py"
  767. },
  768. {
  769. "title": "保存原计划并建立组合索引",
  770. "body": "MySQL DDL发生隐式提交,因此在独立维护会话运行。ALGORITHM=INPLACE,LOCK=NONE请求在线二级索引创建,若当前操作或表条件不支持就报错而非假装完全无锁。仍可能需要短暂元数据锁,设置lock_wait_timeout。",
  771. "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;",
  772. "lang": "sql"
  773. },
  774. {
  775. "title": "用不可见索引做读计划对照",
  776. "body": "仅对刚创建的实验索引切换,不修改主键。观察key、rows和Extra变化,恢复VISIBLE后重新验收。不可见期间写入仍需维护该索引,不能用它证明删除索引后的写入收益。",
  777. "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';",
  778. "lang": "sql"
  779. },
  780. {
  781. "title": "在专用表给JSON路径建生成列索引",
  782. "body": "为避免改变贯穿课程notes模型,用独立json_index_probe。source取固定文本,JSON无该字段时生成NULL。查询直接使用生成列,避免依赖优化器是否把另一个表达式识别成相同形式。小表可能仍顺序扫描,目的先验证语义。",
  783. "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;",
  784. "lang": "sql"
  785. }
  786. ],
  787. "checks": [
  788. "保存建索引前后真实计划,不要求固定成本数值。",
  789. "不可见索引能恢复可见,明确仍有写维护成本。",
  790. "JSON路径生成列得到api与NULL,查询含义明确。"
  791. ],
  792. "pitfalls": [
  793. {
  794. "symptom": "照搬WHERE pinned=1的CREATE INDEX报语法错误",
  795. "cause": "MySQL不支持谓词部分索引。",
  796. "fix": "使用与查询匹配的组合索引,或按业务设计受控生成列并验证访问路径。"
  797. },
  798. {
  799. "symptom": "在线建索引仍在等待",
  800. "cause": "LOCK=NONE不等于从不申请元数据锁,长事务可能阻塞DDL。",
  801. "fix": "设短lock_wait_timeout并检查元数据锁,安排维护窗口;不要关闭所有约束强行建表。"
  802. }
  803. ],
  804. "exercise": {
  805. "task": "为什么SELECT id只读置顶列表比同时读取大content更容易覆盖索引?",
  806. "hint": "二级索引有索引列与主键,但不自动包含整个TEXT。",
  807. "answer": "id、pinned、created_at可由组合二级索引提供;content通常需要回聚簇索引取行,扩大读取量。不要为覆盖随意把大正文加入索引,先限制输出字段并比较计划。"
  808. },
  809. "refs": [
  810. {
  811. "label": "MySQL 8.4:多列索引",
  812. "url": "https://dev.mysql.com/doc/refman/8.4/en/multiple-column-indexes.html"
  813. },
  814. {
  815. "label": "MySQL 8.4:不可见索引",
  816. "url": "https://dev.mysql.com/doc/refman/8.4/en/invisible-indexes.html"
  817. },
  818. {
  819. "label": "MySQL 8.4:生成列索引",
  820. "url": "https://dev.mysql.com/doc/refman/8.4/en/generated-column-index-optimizations.html"
  821. },
  822. {
  823. "label": "MySQL 8.4:在线DDL",
  824. "url": "https://dev.mysql.com/doc/refman/8.4/en/innodb-online-ddl-operations.html"
  825. }
  826. ],
  827. "production": {
  828. "scenario": "首页置顶查询变慢,需要同时控制读延迟和写入成本。",
  829. "decisions": [
  830. "索引对应明确查询。",
  831. "维护DDL与业务事务分开。",
  832. "JSON路径类型与长度受约束。"
  833. ],
  834. "failureDrill": "在notes_lab比较可见/不可见计划和JSON路径结果,恢复索引可见状态。",
  835. "acceptance": [
  836. "有数据规模和计划证据。",
  837. "不声称MySQL支持部分索引。",
  838. "没有为每个字段盲目建索引。"
  839. ]
  840. }
  841. },
  842. {
  843. "id": "09",
  844. "group": "查询性能",
  845. "title": "读懂EXPLAIN ANALYZE并优化深分页",
  846. "summary": "对比估计JSON计划与实际TREE执行结果,用范围条件替代大OFFSET。",
  847. "objectives": [
  848. "区分EXPLAIN估计与ANALYZE真实执行",
  849. "用实际rows和loops分析深分页",
  850. "实现与排序一致的二字段游标"
  851. ],
  852. "concepts": [
  853. {
  854. "title": "MySQL的计划格式要对应版本",
  855. "body": "EXPLAIN FORMAT=JSON提供估计计划结构;MySQL 8.4的EXPLAIN ANALYZE使用TREE展示实际时间、行数和循环。ANALYZE会执行查询,本课只用于SELECT,不把写语句当作无副作用诊断。"
  856. },
  857. {
  858. "title": "深OFFSET需要处理跳过的数据",
  859. "body": "LIMIT 20 OFFSET 40000通常仍处理之前大量记录。连续滚动适合以最后一行的排序键定位下一页;任意跳页则需要另做产品与性能取舍,不能把取深游标的成本忽略掉。"
  860. },
  861. {
  862. "title": "复合游标展开成明确范围",
  863. "body": "按created_at DESC,id DESC排序,就筛选created_at<游标时间 OR(created_at=游标时间 AND id<游标id)。展开条件方便核对MySQL访问计划,不能仅假定行构造器比较一定利用完整复合索引。created_at应保持稳定。"
  864. }
  865. ],
  866. "steps": [
  867. {
  868. "title": "建立列表索引并比较两种计划",
  869. "body": "继续使用第08课样本,CREATE INDEX首次执行一次。ANALYZE TABLE是更新统计,EXPLAIN ANALYZE是执行并测量查询,两者用途不同。读取节点actual time、rows、loops;不要把cost当毫秒。",
  870. "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;",
  871. "lang": "sql"
  872. },
  873. {
  874. "title": "记录深OFFSET额外扫描量",
  875. "body": "相同查询仅改变OFFSET,比较底层扫描节点的actual rows。设备、缓冲池和其他事务会影响毫秒数,保留数据规模及是否已预热,不预设固定数值。",
  876. "code": "EXPLAIN ANALYZE SELECT id,title,created_at FROM notes\nORDER BY created_at DESC,id DESC LIMIT 20 OFFSET 40000;",
  877. "lang": "sql"
  878. },
  879. {
  880. "title": "从第一页末尾读取游标",
  881. "body": "mysql会话变量通过SELECT INTO设置。实际API直接从已返回第20行取得时间和编号,不额外做OFFSET查询。下一页查询必须同时保留同样的过滤条件和排序方向。",
  882. "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;",
  883. "lang": "sql"
  884. },
  885. {
  886. "title": "在相近深度公平对照",
  887. "body": "教学为了获得第40000行游标,先花一次OFFSET成本,随后只比较游标查询本身。生产连续翻页用上页已有游标。若计划仍扫描大量行,观察OR范围与索引使用情况,而不是强行宣称游标一定更快。",
  888. "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-- 第二次运行用于观察热缓冲条件,记录两次实际结果。",
  889. "lang": "sql"
  890. }
  891. ],
  892. "checks": [
  893. "区分JSON估计计划与TREE实际执行数据。",
  894. "深OFFSET和深游标有可对照的扫描量记录。",
  895. "时间相同的记录由id稳定排序,跨页无重复编号。"
  896. ],
  897. "pitfalls": [
  898. {
  899. "symptom": "EXPLAIN ANALYZE FORMAT=JSON不支持",
  900. "cause": "把不同版本或其他数据库的格式组合套到MySQL8.4。",
  901. "fix": "本课使用EXPLAIN FORMAT=JSON与EXPLAIN ANALYZE两条独立命令。"
  902. },
  903. {
  904. "symptom": "同一时间的笔记跨页重复",
  905. "cause": "只按时间排序或只使用一个时间游标。",
  906. "fix": "排序与筛选同时包含id,并完整保留其他列表条件。"
  907. }
  908. ],
  909. "exercise": {
  910. "task": "给置顶列表添加游标条件,说明逻辑运算括号位置。",
  911. "hint": "pinned条件必须作用于OR的两个分支。",
  912. "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过滤。"
  913. },
  914. "refs": [
  915. {
  916. "label": "MySQL 8.4:EXPLAIN",
  917. "url": "https://dev.mysql.com/doc/refman/8.4/en/explain.html"
  918. },
  919. {
  920. "label": "MySQL 8.4:LIMIT优化",
  921. "url": "https://dev.mysql.com/doc/refman/8.4/en/limit-optimization.html"
  922. },
  923. {
  924. "label": "MySQL 8.4:行构造器优化",
  925. "url": "https://dev.mysql.com/doc/refman/8.4/en/row-constructor-optimization.html"
  926. }
  927. ],
  928. "production": {
  929. "scenario": "数据增长后后页延迟明显高于第一页。",
  930. "decisions": [
  931. "先量工作量再比较耗时。",
  932. "游标字段稳定且唯一排序。",
  933. "跨页请求不默认共享数据库快照。"
  934. ],
  935. "failureDrill": "在notes_lab对照浅页、深OFFSET和深游标,保存真实计划。",
  936. "acceptance": [
  937. "不忽略取游标成本。",
  938. "无错误格式选项。",
  939. "性能结论注明样本与缓存状态。"
  940. ]
  941. }
  942. },
  943. {
  944. "id": "10",
  945. "group": "Python 与运行保障",
  946. "title": "用有界连接池管理PyMySQL连接与事务",
  947. "summary": "明确驱动不自带业务连接池,通过QueuePool复用连接并验证借还和耗尽恢复。",
  948. "objectives": [
  949. "建立进程级有界DBAPI连接池",
  950. "明确commit/rollback与close归还语义",
  951. "在连接损坏时失效而非暗中重连"
  952. ],
  953. "concepts": [
  954. {
  955. "title": "PyMySQL连接池需额外组件",
  956. "body": "PyMySQL是同步DBAPI驱动,本课使用SQLAlchemy QueuePool作为独立池组件,不引入ORM。pool_size加max_overflow是本进程连接上限,池按需创建连接;应用启动、关闭统一管理。"
  957. },
  958. {
  959. "title": "连接与事务都不能跨并发请求共享",
  960. "body": "线程借到各自连接后完成短事务,再close归还。池代理的close不是销毁底层连接,dispose关闭空闲连接;关闭时先停止工作并归还所有借出连接。多进程在子进程内各建池,不跨fork复用连接。"
  961. },
  962. {
  963. "title": "活跃事务禁止透明重连",
  964. "body": "网络断开后不要ping(reconnect=True)继续使用旧事务,数据库事务已经丢失。池借出前用ping(reconnect=False)发现坏连接并invalidate,交上层按业务语义重试。socket超时不能证明最后一个写入未提交。"
  965. }
  966. ],
  967. "steps": [
  968. {
  969. "title": "建立独立池组件",
  970. "body": "安装SQLAlchemy2.0系列并更新锁文件。在db_pool.py保存下面代码,复用第03课mysql_client.connect及其UTC、RC和超时。没有把同步调用放进async路由;异步接入在API+数据库模块使用对应驱动与池。",
  971. "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",
  972. "lang": "python",
  973. "filename": "db_pool.py"
  974. },
  975. {
  976. "title": "4个线程共享最多2条连接",
  977. "body": "保存db_pool_read.py。异常回滚失败时也必须在finally归还或失效;这里只读查询出错后把连接失效处理,避免把损坏连接交给下一请求。不要在持有连接时睡眠或执行外部HTTP。",
  978. "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()",
  979. "lang": "python",
  980. "filename": "db_pool_read.py"
  981. },
  982. {
  983. "title": "验证异常插入明确回滚",
  984. "body": "保存db_pool_rollback.py。主动异常后rollback,再借到连接查询同一编号应0。代码不依赖连接上下文自动提交。SQLAlchemy池归还回滚是最后防线,不能替代显式业务事务控制。",
  985. "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()",
  986. "lang": "python",
  987. "filename": "db_pool_rollback.py"
  988. },
  989. {
  990. "title": "占用全部连接后触发有界等待",
  991. "body": "保存db_pool_timeout.py。两条连接都借出时第三次借用约0.2秒后抛SQLAlchemy TimeoutError;释放后重新借用成功。这个超时与数据库行锁1205不是同一故障层。仅在独立练习进程执行。",
  992. "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()",
  993. "lang": "python",
  994. "filename": "db_pool_timeout.py"
  995. }
  996. ],
  997. "checks": [
  998. "并发读取完成且最大连接数为2。",
  999. "异常插入回滚,连接归还后可以继续使用。",
  1000. "耗尽产生池等待超时,释放后恢复,不盲目扩池。"
  1001. ],
  1002. "pitfalls": [
  1003. {
  1004. "symptom": "关闭池后仍有数据库连接",
  1005. "cause": "还有借出连接或工作线程未结束。",
  1006. "fix": "先停止接收任务并等待有界工作结束,归还连接后dispose。"
  1007. },
  1008. {
  1009. "symptom": "断线后自动重连并继续提交",
  1010. "cause": "新连接不包含旧事务状态。",
  1011. "fix": "禁用事务中透明重连,失效旧连接并按幂等性重试完整业务动作。"
  1012. }
  1013. ],
  1014. "exercise": {
  1015. "task": "4个API进程每池8条、2个任务进程每池5条,再留10条运维,最低连接预算是多少?",
  1016. "hint": "每个进程有独立池,不能只算一个进程。",
  1017. "answer": "4×8+2×5+10=52条,还需加迁移、监控和其他服务余量。max_connections不能只机械等于52,数据库内存与请求排队策略也应验收。"
  1018. },
  1019. "refs": [
  1020. {
  1021. "label": "SQLAlchemy:独立池用法",
  1022. "url": "https://docs.sqlalchemy.org/en/20/core/pooling.html#using-a-pool-instance-directly"
  1023. },
  1024. {
  1025. "label": "PyMySQL:连接参数",
  1026. "url": "https://pymysql.readthedocs.io/en/latest/modules/connections.html"
  1027. },
  1028. {
  1029. "label": "MySQL 8.4:连接过多",
  1030. "url": "https://dev.mysql.com/doc/refman/8.4/en/too-many-connections.html"
  1031. }
  1032. ],
  1033. "production": {
  1034. "scenario": "API与worker同时访问数据库,突发流量不能无限增加连接。",
  1035. "decisions": [
  1036. "进程内池、进程间总预算。",
  1037. "事务显式结束,断线连接失效。",
  1038. "池等待、网络等待、数据库锁等待分开监控。"
  1039. ],
  1040. "failureDrill": "在独立进程耗尽2条连接并触发插入回滚,观察归还恢复。",
  1041. "acceptance": [
  1042. "无泄漏。",
  1043. "等待有界。",
  1044. "坏连接不携带假事务继续执行。"
  1045. ]
  1046. }
  1047. },
  1048. {
  1049. "id": "11",
  1050. "group": "Python 与运行保障",
  1051. "title": "按账号host范围拆分权限与连接配置",
  1052. "summary": "为API创建最小读写账号,区分DDL、业务查询和运维诊断权限。",
  1053. "objectives": [
  1054. "识别user@host授权匹配",
  1055. "授予现有与未来业务表的数据库级权限",
  1056. "配置TLS、凭据和每次连接的会话参数"
  1057. ],
  1058. "concepts": [
  1059. {
  1060. "title": "MySQL账号包含来源host",
  1061. "body": "app_user@%与app_user@localhost是两个账号。CURRENT_USER()显示最终匹配的授权身份。Docker练习通过网络连接,用%仅方便独立网络测试;生产使用受控网络范围、TLS与独立密码,不能把用户名相同当成同一身份。"
  1062. },
  1063. {
  1064. "title": "数据库级授权覆盖该库未来表",
  1065. "body": "GRANT SELECT,INSERT,UPDATE,DELETE ON notes_lab.*可覆盖现有和未来对象,不需要逐表自动权限模板。它范围比单表授权宽,生产根据服务边界选择。运行账号不授予CREATE、ALTER、DROP或全局管理权限。"
  1066. },
  1067. {
  1068. "title": "配置责任不能靠账号默认猜测",
  1069. "body": "MySQL普通账号没有通用的每账号SET默认会话变量机制供本例照搬;应用connect统一设置UTC、RC、锁等待和SELECT时限。TLS要校验证书与主机名,密码轮换后新建连接验证,再逐步回收旧连接。"
  1070. }
  1071. ],
  1072. "steps": [
  1073. {
  1074. "title": "管理连接创建运行身份",
  1075. "body": "docker exec -it notes-mysql mysql -u root -p后执行;密码在专用本机终端填写,不使用示例密码部署。mysql客户端将凭据命令写历史的风险可用该交互进程MYSQL_HISTFILE=/dev/null禁用,或使用受控管理员工具。下面账号仅一次创建。",
  1076. "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'@'%';",
  1077. "lang": "sql"
  1078. },
  1079. {
  1080. "title": "以运行身份验证允许与拒绝",
  1081. "body": "新终端使用mysql --protocol=TCP -h127.0.0.1 -P3307 -u notes_runtime -p notes_lab连接。CRUD成功但ALTER被拒绝,专用样本插入后立即删除;不得给运行账号补DDL权限来消除这个预期错误。",
  1082. "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",
  1083. "lang": "sql"
  1084. },
  1085. {
  1086. "title": "验证未来表与会话超时",
  1087. "body": "app_user创建permission_probe后,notes_runtime可SELECT,说明库级授权覆盖未来表;验收后由app_user删除。超时是当前连接设置:MAX_EXECUTION_TIME主要用于SELECT,本例SLEEP可用于观察;客户端不同也应重做连接配置。",
  1088. "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;",
  1089. "lang": "sql"
  1090. },
  1091. {
  1092. "title": "为生产连接开启校验而不破坏练习配置",
  1093. "body": "在mysql_client.connect的pymysql.connect参数中按实际证书增加这些参数,不将占位路径直接用于本机无证书环境。MYSQL_HOST应匹配证书主机名,密钥不写进仓库。应用MYSQL_USER改为notes_runtime;迁移仍使用单独app_user凭据。",
  1094. "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;",
  1095. "lang": "python"
  1096. }
  1097. ],
  1098. "checks": [
  1099. "运行账号CRUD可用但ALTER被拒绝。",
  1100. "未来表权限符合数据库级授权范围。",
  1101. "实际连接身份、TLS和UTC/RC设置均可核对。"
  1102. ],
  1103. "pitfalls": [
  1104. {
  1105. "symptom": "本机能登录、容器连接却被拒绝",
  1106. "cause": "账号host条目和连接来源不同。",
  1107. "fix": "核对CURRENT_USER、USER及已创建账号来源范围,不通过授予root远程权限绕过。"
  1108. },
  1109. {
  1110. "symptom": "配置ssl=True就认为已验证服务器身份",
  1111. "cause": "加密与证书链、主机名验证是不同要求。",
  1112. "fix": "设置可信CA并启用ssl_verify_cert和ssl_verify_identity,使用证书匹配主机名。"
  1113. }
  1114. ],
  1115. "exercise": {
  1116. "task": "给只读报表建立权限,并说明PROCESS应不应该顺手授予。",
  1117. "hint": "业务查询所需权限与全实例诊断不同。",
  1118. "answer": "独立报表账号授予指定库/表SELECT,必要时只授予特定视图;PROCESS和performance_schema广泛诊断权限单独给运维身份。报表连接也设置时限,不能仅靠只读权限防止昂贵查询。"
  1119. },
  1120. "refs": [
  1121. {
  1122. "label": "MySQL 8.4:GRANT",
  1123. "url": "https://dev.mysql.com/doc/refman/8.4/en/grant.html"
  1124. },
  1125. {
  1126. "label": "MySQL 8.4:账号名称",
  1127. "url": "https://dev.mysql.com/doc/refman/8.4/en/account-names.html"
  1128. },
  1129. {
  1130. "label": "MySQL 8.4:加密连接",
  1131. "url": "https://dev.mysql.com/doc/refman/8.4/en/using-encrypted-connections.html"
  1132. },
  1133. {
  1134. "label": "PyMySQL:TLS参数",
  1135. "url": "https://pymysql.readthedocs.io/en/latest/modules/connections.html"
  1136. }
  1137. ],
  1138. "production": {
  1139. "scenario": "运行配置泄漏或应用代码出错时,需要限制影响范围。",
  1140. "decisions": [
  1141. "迁移、运行和诊断账号分开。",
  1142. "host匹配与网络访问共同限制。",
  1143. "每次连接主动设置会话参数。"
  1144. ],
  1145. "failureDrill": "用notes_runtime尝试ALTER并验证拒绝,然后核对正常读写不受影响。",
  1146. "acceptance": [
  1147. "非root运行。",
  1148. "拒绝错误有明确原因。",
  1149. "生产TLS验证不被省略。"
  1150. ]
  1151. }
  1152. },
  1153. {
  1154. "id": "12",
  1155. "group": "Python 与运行保障",
  1156. "title": "用mysqldump恢复并验收MySQL结构变更",
  1157. "summary": "恢复到另一个数据库验证数据与自增,再为新增列设计独立的兼容迁移。",
  1158. "objectives": [
  1159. "用single-transaction备份InnoDB并恢复到隔离库",
  1160. "理解mysqldump与binlog时间点恢复的范围",
  1161. "验证DDL隐式提交和INSTANT操作限制"
  1162. ],
  1163. "concepts": [
  1164. {
  1165. "title": "逻辑备份需要匹配写入与DDL条件",
  1166. "body": "mysqldump --single-transaction为InnoDB建立一致性读取,不保证非事务表一致,也不适合备份同时执行ALTER/DROP等DDL。dump包含SQL,恢复前核对来源;单业务库备份不会自动包含完整账号和授权配置。"
  1167. },
  1168. {
  1169. "title": "时间点恢复需要备份加连续binlog",
  1170. "body": "每日dump不满足15分钟RPO。需要记录与备份一致的binlog坐标或GTID以及保留完整日志,再在隔离环境恢复到明确停止点。binlog复制和本机留存不等于异地备份;不可在原线上库盲目重放。"
  1171. },
  1172. {
  1173. "title": "兼容迁移不依赖DDL回滚",
  1174. "body": "新增可空列可先兼容旧代码,再逐步上线使用。MySQL 8.4对符合条件的加列支持ALGORITHM=INSTANT,明确要求可在不支持时失败;即使INSTANT也需要元数据锁,且DDL隐式提交,ROLLBACK不能撤销。"
  1175. }
  1176. ],
  1177. "steps": [
  1178. {
  1179. "title": "准备客户端凭据文件",
  1180. "body": "用编辑器在notes-service创建.mysql-client.cnf,按下面INI结构填写本机练习密码。此处示例占位值必须替换;权限设置为600,目录只供当前用户访问。不要把凭据放进mysqldump命令参数或共享日志。",
  1181. "code": "[client]\nhost=127.0.0.1\nport=3307\nprotocol=TCP\nuser=app_user\npassword=REPLACE_WITH_LOCAL_APP_PASSWORD\ndefault-character-set=utf8mb4",
  1182. "lang": "ini",
  1183. "filename": ".mysql-client.cnf"
  1184. },
  1185. {
  1186. "title": "用凭据文件生成逻辑备份",
  1187. "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集合;生产时间点恢复另行规划坐标。",
  1188. "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",
  1189. "lang": "bash"
  1190. },
  1191. {
  1192. "title": "创建独立恢复库并授予恢复身份",
  1193. "body": "仅本机root管理连接执行。不要使用--databases生成固定USE notes_lab后再误以为命令参数能改目标;本课dump只带表SQL,显式导入notes_restore。notes_restore只能作为验收库,不能覆盖notes_lab。",
  1194. "code": "CREATE DATABASE notes_restore CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;\nGRANT ALL PRIVILEGES ON notes_restore.* TO 'app_user'@'%';\n-- 只针对本机隔离恢复库,恢复完成后按运行账号权限方案重新授权。",
  1195. "lang": "sql"
  1196. },
  1197. {
  1198. "title": "恢复后比较行数、关系与自增",
  1199. "body": "Bash导入前使用SELECT DATABASE确认目标;mysql --force会跳过错误继续执行,本课不使用。下面数量一致只适用于已暂停写入的练习源库,不能拿备份后持续变化的数据简单判错。",
  1200. "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;'",
  1201. "lang": "bash"
  1202. },
  1203. {
  1204. "title": "验证恢复库新写入与源库兼容迁移",
  1205. "body": "先在notes_restore执行前一组DML,LAST_INSERT_ID生成合法编号后ROLLBACK,不保留标记。再明确USE notes_lab执行独立DDL;不要包BEGIN期待可回滚。ALGORITHM=INSTANT失败时先评估支持限制,不能静默退回可能重建整表的操作。",
  1206. "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撤销。",
  1207. "lang": "sql"
  1208. },
  1209. {
  1210. "title": "记录binlog恢复演练所需证据",
  1211. "body": "本课逻辑恢复已可执行,时间点恢复部分按真实环境补录,不能冒填文件名与GTID。管理窗口只读查看变量;不要将mysqlbinlog输出直接管道写入现有业务库。生产选择持久日志归档方案并在隔离实例恢复到误操作之前,验收角色、时区、索引和业务请求。",
  1212. "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-- 演练:隔离恢复实例、停止点、实际恢复耗时、允许丢失窗口、业务验收结果",
  1213. "lang": "sql"
  1214. }
  1215. ],
  1216. "checks": [
  1217. "dump退出成功、校验和正确,独立恢复库数量与静止源库一致。",
  1218. "外键无孤儿,恢复库新插入自增正常且能回滚DML。",
  1219. "新增列前后旧查询兼容,明确DDL不能由ROLLBACK撤销。"
  1220. ],
  1221. "pitfalls": [
  1222. {
  1223. "symptom": "恢复导入竟修改原库",
  1224. "cause": "dump包含固定CREATE DATABASE/USE,或连接目标未核对。",
  1225. "fix": "本课不加--databases,导入前核对SQL内容与SELECT DATABASE;生产先隔离恢复。"
  1226. },
  1227. {
  1228. "symptom": "每日dump被认为能恢复到任意一分钟",
  1229. "cause": "没有完整binlog和一致起点。",
  1230. "fix": "规划备份坐标/GTID、日志保留与归档,并实际演练恢复停止点。"
  1231. },
  1232. {
  1233. "symptom": "INSTANT加列仍等待或不支持",
  1234. "cause": "元数据锁冲突或表/操作条件不满足。",
  1235. "fix": "短lock_wait_timeout失败后查阻塞,确认版本和限制,不盲目降级成大表复制。"
  1236. }
  1237. ],
  1238. "exercise": {
  1239. "task": "业务最多丢15分钟数据,只有每日mysqldump是否足够?还需验收什么?",
  1240. "hint": "把备份间隔、binlog归档延迟、恢复速度分别对齐RPO/RTO。",
  1241. "answer": "不够。需要与一致性备份关联的连续binlog或受管时间点恢复,监控归档延迟与保留期,保护异地副本;实际恢复到指定停止点并测量RTO,同时重建权限和验收业务读写。"
  1242. },
  1243. "refs": [
  1244. {
  1245. "label": "MySQL 8.4:mysqldump",
  1246. "url": "https://dev.mysql.com/doc/refman/8.4/en/mysqldump.html"
  1247. },
  1248. {
  1249. "label": "MySQL 8.4:时间点恢复",
  1250. "url": "https://dev.mysql.com/doc/refman/8.4/en/point-in-time-recovery.html"
  1251. },
  1252. {
  1253. "label": "MySQL 8.4:在线DDL操作",
  1254. "url": "https://dev.mysql.com/doc/refman/8.4/en/innodb-online-ddl-operations.html"
  1255. },
  1256. {
  1257. "label": "MySQL 8.4:隐式提交",
  1258. "url": "https://dev.mysql.com/doc/refman/8.4/en/implicit-commit.html"
  1259. }
  1260. ],
  1261. "production": {
  1262. "scenario": "结构发布前需要证明可恢复,且新旧应用能在过渡期并存。",
  1263. "decisions": [
  1264. "恢复库与源库隔离。",
  1265. "备份验证覆盖数据、外键、自增与权限。",
  1266. "DDL单独执行,明确兼容与恢复方案。"
  1267. ],
  1268. "failureDrill": "先逻辑恢复到notes_restore,再在notes_lab新增可空列;时间点恢复需真实日志与明确停止点后单独执行。",
  1269. "acceptance": [
  1270. "有实际恢复证据后才标通过。",
  1271. "未把本机耗时推定为线上SLA。",
  1272. "不以删库或删卷作为常规回退。"
  1273. ]
  1274. }
  1275. }
  1276. ]