| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581 |
- [
- {
- "id": "01",
- "group": "连接与数据模型",
- "title": "准备 MySQL 8.4 与命令行练习环境",
- "summary": "启动独立的商品练习数据库,用专用账号连入 shop_lab,并在客户端看到计算结果 42 和正确的数据库身份。",
- "objectives": [
- "建立本机练习目录并启动独立 MySQL 服务",
- "用密码连接并确认自己位于 shop_lab",
- "读出版本与会话设置,退出后能再次连接"
- ],
- "concepts": [
- {
- "title": "服务与客户端分别负责保存和交互",
- "body": "第 4 步 quit 后仍可重新连接,说明退出 mysql 客户端没有停止 mysqld。一个实例可有多个数据库,本课所有表都限定在 shop_lab;MySQL 这里的 SCHEMA 与 DATABASE 同义。"
- },
- {
- "title": "端口取决于连接所在位置",
- "body": "第 2 步 docker exec 内部连接 3306,本机客户端连接 3307;两者由第 1 步端口转发联系起来。排错先看当前命令在容器里还是本机。"
- },
- {
- "title": "默认设置要用结果确认",
- "body": "第 3 步实际读出了引擎、字符集、身份、时区和提交开关。当前会话设置不自动应用于以后所有连接,后面的 Python 连接函数会统一再设置。"
- }
- ],
- "steps": [
- {
- "title": "生成本机凭据并启动数据库",
- "body": "在 Bash 中进入存放练习的父目录,然后按下方代码首次创建 shop-service 和凭据、启动 shop-mysql。若已经做过,进入原目录,保留 .mysql.env;已停止的容器用 docker start shop-mysql 启动,不重复执行生成密码块。最后读容器日志,密码不要打印或贴到共享日志。",
- "code": "mkdir -p shop-service\ncd shop-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=shop_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 shop-mysql --env-file .mysql.env \\\n -p 127.0.0.1:3307:3306 -v shop_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 shop-mysql",
- "lang": "bash",
- "expected": "目录出现 .mysql.env 和 .gitignore;docker run 返回容器编号,日志最终出现 ready for connections。首次下载或初始化需等待;同名容器错误应先核对是否已创建。",
- "explanation": "mysql:8.4 提供持续保存数据的服务;-p 将本机 3307 转发到容器内 3306,127.0.0.1 限制本机访问。命名数据卷保存数据,容器重启不清空;首次初始化的环境文件创建 shop_lab 和仅用于该库的 app_user,旧卷不会因重新写密码文件自动改账号。"
- },
- {
- "title": "用交互密码连接业务库",
- "body": "看到 ready for connections 后,在编辑器查看本机 .mysql.env 的 MYSQL_PASSWORD。执行下方第一条命令,在 Enter password 提示时输入该值;若已安装本机 mysql 客户端,也可改选第二条,只需一种连接方式。",
- "code": "docker exec -it shop-mysql mysql --protocol=TCP -h 127.0.0.1 -P 3306 -u app_user -p shop_lab\n# 或本机客户端:\nmysql --protocol=TCP -h 127.0.0.1 -P 3307 -u app_user -p shop_lab",
- "lang": "bash",
- "expected": "出现 mysql> 提示符,密码输入不回显。若 Access denied,核对是 MYSQL_PASSWORD 而非 MYSQL_ROOT_PASSWORD;连接失败则先看服务日志与所用端口。",
- "explanation": "mysql 是发命令的客户端,mysqld 是一直运行的数据库服务。docker exec 内部访问 3306,本机客户端访问转发端口 3307;两种入口最终都连到同一实例,不代表有两个数据库。"
- },
- {
- "title": "确认环境与身份",
- "body": "下面内容改在 mysql> 内逐条执行,SQL 以分号结束,不粘进 Bash。先看 DATABASE() 与 CURRENT_USER();若不是 shop_lab 与 app_user,退出并纠正连接再继续。把版本、字符集、时间设置与授权结果记下来。",
- "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",
- "expected": "版本为 8.4.x,DATABASE() 为 shop_lab,授权身份为 app_user;引擎为 InnoDB,字符集为 utf8mb4,会话时区设置后为 +00:00。默认隔离通常为 REPEATABLE-READ,autocommit 通常为 1。",
- "explanation": "SELECT 可查询值,@@ 读取设置,SET SESSION 只修改当前连接。InnoDB 是后面事务实验使用的存储引擎;utf8mb4 支持本课中文,ai_ci 比较不区分大小写和重音。UTC 统一时间解释,其他隔离与提交细节会通过后续修改实验展开。"
- },
- {
- "title": "验证客户端和事务开关",
- "body": "仍在 mysql> 执行下方 SELECT,确认两项结果后执行 quit。回到 Bash,再用第 2 步同样命令连接,执行 SELECT DATABASE(); 确认位置。",
- "code": "SELECT 6*7 AS answer;\n-- answer=42\nSELECT DATABASE()='shop_lab' AS in_lab;\n-- in_lab=1\nquit",
- "lang": "sql",
- "expected": "answer=42,in_lab=1;quit 后回到 Bash,重新连接仍进入 shop_lab。退出客户端没有让容器停止。",
- "explanation": "计算结果证明命令到达服务并拿到返回值。刚核对的 autocommit=1 表示交互客户端通常每条数据修改独立提交;第 03 课 Python 会显式关闭自动提交,不能把两个客户端的默认行为混为一谈。"
- }
- ],
- "checks": [
- "服务版本为8.4,数据库为shop_lab,存储引擎为InnoDB。",
- "客户端使用3307或容器内部3306的正确位置,业务身份不是root。",
- "字符集utf8mb4、会话UTC和隔离级别已实际核对。"
- ],
- "pitfalls": [
- {
- "symptom": "Can't connect to MySQL server",
- "cause": "初始化尚未完成,或混淆宿主与容器端口。",
- "fix": "查看专用容器日志;宿主3307,docker exec内部3306。"
- },
- {
- "symptom": "Access denied for user",
- "cause": "用户名/host匹配或密码不对;旧卷不会因env变化重设密码。",
- "fix": "使用当前卷的真实凭据,管理账号确认账号host;需要改密时执行明确ALTER USER,不删卷解决。"
- }
- ],
- "exercise": {
- "task": "故意用shop_missing连接,再与连接成功后SELECT 1/0比较错误层次。",
- "hint": "数据库名属于连接目标,SQL表达式在建立会话之后才执行。",
- "answer": "不存在数据库通常为1049 Unknown database;认证失败常为1045。MySQL中SELECT 1/0通常返回NULL并产生警告,可用SHOW WARNINGS查看,不能把它当作稳定抛异常的事务测试。要测试约束失败请使用后续非法INSERT。"
- },
- "refs": [
- {
- "label": "MySQL 8.4:mysql客户端",
- "url": "https://dev.mysql.com/doc/refman/8.4/en/mysql.html"
- },
- {
- "label": "MySQL 8.4:账号名称",
- "url": "https://dev.mysql.com/doc/refman/8.4/en/account-names.html"
- },
- {
- "label": "Docker:MySQL镜像",
- "url": "https://hub.docker.com/_/mysql"
- }
- ],
- "production": {
- "scenario": "新服务需要能重复建立且不会误连线上库的开发环境。",
- "decisions": [
- "显式核对数据库与授权账号。",
- "本机密码文件不进入仓库。",
- "生产记录8.4补丁版和镜像摘要,练习使用mysql:8.4入口。"
- ],
- "failureDrill": "在独立实例用错库名与端口连接,分别记录错误层次。",
- "acceptance": [
- "目标可辨认。",
- "环境与数据卷生命周期可区分。",
- "无共享明文密码。"
- ]
- },
- "intro": {
- "scenario": "商品目录放在 Python 内存时,一重启商品就消失。我们先为商品准备一个独立的 MySQL 练习服务,试着连接进去算出 42,确认确实能和它通信,再在下一课设计商品表。",
- "startingPoint": [
- "本模块从新的 shop-service 目录开始,不把命令放进 API 模块的 api-lab。若终端仍显示旧 (.venv),先执行 deactivate;也可以打开全新终端。",
- "已安装并启动 Docker,准备 Python 3.11+。本模块 Shell 代码是 Bash:Windows 请打开 WSL/Linux 终端并启用 Docker 的 WSL 集成,不直接粘到 PowerShell;macOS/Linux 使用终端。",
- "先在所选 Bash 中运行 docker info 与 python3 --version:前者能连接 Docker 服务,后者显示 3.11 或更新版本。若尚不满足,先完成对应安装;本节借 Docker 提供统一实验环境,容器细节在后续模块学习。",
- "所有操作仅针对本机新建的 shop_lab。已有 shop-mysql 容器或 shop-service/.mysql.env 时先复用已存在的练习环境,不重建密码、不删除数据卷来排错。"
- ],
- "outcome": "启动独立的商品练习数据库,用专用账号连入 shop_lab,并在客户端看到计算结果 42 和正确的数据库身份。"
- }
- },
- {
- "id": "02",
- "group": "连接与数据模型",
- "title": "从商品需求设计表结构、字段与数据约束",
- "summary": "从商品操作写出字段设计,建好 products 表,用合法样品和失败输入证明默认值、价格、名称与时间规则生效。",
- "objectives": [
- "先列商品操作,再为每个字段说明用途与默认值",
- "把主键、类型、非空与合法值约束写进建表语句",
- "通过插入、修改与失败样例核对设计,而不只看建表是否报错"
- ],
- "concepts": [
- {
- "title": "结构从操作推导,主键与名称分工",
- "body": "第 1、2 步从详情和列表推导 id、时间与排序,商品名称允许重名且修改,所以不加 UNIQUE。AUTO_INCREMENT 可因失败或回滚留空洞,编号不等于商品数量。"
- },
- {
- "title": "价格与默认值有具体含义",
- "body": "第 4 步只写名称得到免费草稿;1990 分精确表示 19.90 元。0 不是未知价格,有符号 INT 上限为 2147483647 分,API 也应校验整数和范围。"
- },
- {
- "title": "用失败数据验证多层约束",
- "body": "第 5 步分别验证类型长度、NOT NULL 与 CHECK;BOOLEAN 需要额外限制 0/1,空白名称需要 TRIM 后非空。能建表不代表所有规则都已被验证。"
- },
- {
- "title": "时间与版本由写入代码共同维护",
- "body": "第 6 步显式更新 updated_at 和 version,第 7 步证明 DATETIME 不自动换时区。创建时间保持稳定,连接统一 +00:00 才能一致解释存储值。"
- },
- {
- "title": "关系字段不能随意塞进 JSON",
- "body": "本课只用 JSON_EXTRACT 看一次弱结构值,没有在 products 里放分类数组。第 04 课将用独立关系表满足分类去重、外键与双向查询。"
- }
- ],
- "steps": [
- {
- "title": "把商品操作整理成查询清单",
- "body": "先阅读下方操作清单,在笔记中为“新建、详情、编辑、上架列表”各举一个保温杯例子。该代码块是设计表格,不在 mysql> 执行;分类关系到第 04 课再建。",
- "code": "操作 输入或过滤 返回 / 排序\n新建草稿 名称、描述、整数价格分 编号、默认下架状态、UTC时间\n读取商品详情 id等值 名称、描述、价格、上架状态\n编辑商品 id与旧version 修改字段、递增version、更新时间\n上架商品列表 is_active=1 按created_at DESC,id DESC取20条\n按分类查看商品 category_id(第04课建模) 商品列表,不重复返回商品\n\n主键候选:id;列表索引候选:(is_active,created_at DESC,id DESC)。",
- "lang": "text",
- "expected": "每项操作都能明确输入、查找条件和返回字段;列表写出了时间与编号两项排序,而不是只写“返回所有商品”。",
- "explanation": "表结构服务于具体操作:详情需要稳定编号,列表需要可比较的排序字段,编辑需要知道改的是哪条。先记录这些需求,才有依据选字段和索引;本轮只是索引候选,第 08 课再测量。"
- },
- {
- "title": "写出字段设计表并说明舍取",
- "body": "把下方字段表抄到设计笔记,与上一清单逐项连线:每个字段对应哪项输入、读取或规则。检查没有加入订单、支付或库存字段,也没有要求商品名唯一。",
- "code": "字段 类型 非空 默认值 用途 / 约束\nid BIGINT 是 AUTO_INCREMENT 主键,稳定识别,允许编号空洞\nname VARCHAR(120) 是 无 名称去空白后非空,不要求唯一\ndescription TEXT 是 空串 最多16000字符,无描述用空串\nis_active BOOLEAN 是 FALSE 仅0/1,FALSE表示下架或草稿\nprice_cents INT 是 0 人民币分,>=0;0为免费样品\ncreated_at DATETIME(6) 是 CURRENT_TIMESTAMP(6) UTC创建时间,后续不改\nupdated_at DATETIME(6) 是 CURRENT_TIMESTAMP(6) UTC修改时间,每次更新显式写入\nversion INT 是 1 >0,用于后续编辑冲突实验",
- "lang": "text",
- "expected": "一行代表一个商品;id 识别记录,name 描述记录;price_cents 为整数分、默认 0,is_active 默认为 FALSE。每个字段都写有类型、是否可缺省和用途。",
- "explanation": "主键是稳定识别一行的键,名称会改也可重名,所以不能代替 id。INT 存整数分,1990 精确表示 19.90 元;0 明确表示免费样品,不表示未知价格。version 暂记修改次数起点,后课用它比较并发编辑。"
- },
- {
- "title": "把设计落实为统一products表",
- "body": "创建 shop-service/sql/001_products.sql,保存下方 SQL。在 app_user 连接中确认 SELECT DATABASE(); 为 shop_lab 后,首次执行建表与 SHOW CREATE TABLE;不要与其他未提交修改混在一起。",
- "code": "CREATE TABLE products (\n id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,\n name VARCHAR(120) NOT NULL,\n description TEXT NOT NULL DEFAULT (''),\n is_active BOOLEAN NOT NULL DEFAULT FALSE,\n price_cents INT NOT NULL DEFAULT 0,\n created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),\n updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),\n version INT NOT NULL DEFAULT 1,\n CONSTRAINT products_name_nonempty CHECK (CHAR_LENGTH(TRIM(name))>0),\n CONSTRAINT products_is_active_boolean CHECK (is_active IN (0,1)),\n CONSTRAINT ck_products_price_nonnegative CHECK (price_cents>=0),\n CONSTRAINT products_description_limit CHECK (CHAR_LENGTH(description)<=16000),\n CONSTRAINT products_version_positive CHECK (version>0)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;\nSHOW CREATE TABLE products;",
- "lang": "sql",
- "filename": "sql/001_products.sql",
- "expected": "SHOW CREATE TABLE 列出八个字段、主键、默认值与五个 CHECK;引擎为 InnoDB。如果表已存在,先对照结构,不靠删表重建跳过检查。",
- "explanation": "CREATE TABLE 将设计落实成可执行规则。NOT NULL 不允许缺值,DEFAULT 在省略字段时补初值,CHECK 拒绝不满足条件的值。BOOLEAN 底层仍是整数,额外 CHECK 才能拦住 2;描述用 TEXT 并把业务长度限制为 16000 字符。"
- },
- {
- "title": "用默认值与明确价格分别建立样品",
- "body": "在同一个 mysql 会话逐条执行下方两组插入和查询,保留 @free_id 与 @product_id。每次 INSERT 后立即取得本会话 LAST_INSERT_ID,不用 MAX(id) 猜编号。",
- "code": "INSERT INTO products(name) VALUES('db02-免费试用样品');\nSET @free_id=LAST_INSERT_ID();\nSELECT id,name,description,is_active,price_cents,version,created_at,updated_at\nFROM products WHERE id=@free_id;\n-- description空串,is_active=0,price_cents=0,version=1,UTC时间非空\nINSERT INTO products(name,description,price_cents)\nVALUES('db02-保温杯','350毫升,教学样品',1990);\nSET @product_id=LAST_INSERT_ID();\nSELECT id,name,price_cents FROM products WHERE id=@product_id;\n-- price_cents=1990,界面展示时解释为人民币19.90元",
- "lang": "sql",
- "expected": "免费样品的描述为空串、上架状态为 0、价格为 0、version 为 1,时间非空;第二件保温杯价格为 1990。两件各有数据库生成的实际 id。",
- "explanation": "默认值在省略列时填入,所以只写 name 也得到完整初始记录。AUTO_INCREMENT 生成编号,LAST_INSERT_ID 读取本连接刚生成的值,避免读到其他人的插入;价格展示时才把分解释成元。"
- },
- {
- "title": "逐条提交边界值,验证设计确实生效",
- "body": "先执行 SELECT @@sql_mode,@@autocommit;,确认包含 STRICT_TRANS_TABLES 且自动提交为 1。然后在 mysql> 逐条执行下方非法 INSERT,每次先读错误再执行下一条;这些是预期失败实验,不使用 --force 批量吞掉错误。",
- "code": "INSERT INTO products(name) VALUES(NULL);\n-- 1048:name不可为NULL\nINSERT INTO products(name) VALUES(' ');\n-- 3819:空白名称违反CHECK\nINSERT INTO products(name,is_active) VALUES('db02-invalid-state',2);\n-- 3819:BOOLEAN底层是整数,CHECK拦住2\nINSERT INTO products(name,price_cents) VALUES('db02-negative-price',-1);\n-- 3819:负价违反CHECK\nINSERT INTO products(name,price_cents) VALUES('db02-null-price',NULL);\n-- 1048:CHECK不能替代NOT NULL\nINSERT INTO products(name) VALUES(REPEAT('a',121));\n-- 严格模式下1406:名称过长\nINSERT INTO products(name,description) VALUES('db02-long-description',REPEAT('a',16001));\n-- 3819:描述超过业务长度上限\nSELECT id,name,price_cents FROM products WHERE id IN (@free_id,@product_id);",
- "lang": "sql",
- "expected": "NULL 名称/价格得到非空错误,空白名称、状态 2、负价格和过长描述被 CHECK 拒绝,121 字符名称在严格模式下报长度错误。最后两件合法样品仍可查询。",
- "explanation": "类型、非空、范围各挡不同错误:CHECK 遇到 NULL 可能得到 UNKNOWN 而通过,所以不能替代 NOT NULL。utf8mb4 下 16000 字符最坏占 64000 字节,仍在 TEXT 容量内;数据库守住所有写入入口,应用还应提供友好提示。"
- },
- {
- "title": "验证修改时间和版本的维护约定",
- "body": "在原会话执行下方修改前查询、UPDATE 和修改后查询,比较保温杯的状态、价格、两个时间和 version。不要改 created_at。",
- "code": "SELECT created_at,updated_at,version FROM products WHERE id=@product_id;\nUPDATE products SET is_active=TRUE,price_cents=2090,\n updated_at=UTC_TIMESTAMP(6),version=version+1 WHERE id=@product_id;\nSELECT id,name,is_active,price_cents,created_at,updated_at,version\nFROM products WHERE id=@product_id;\n-- is_active=1,price_cents=2090,version=2,created_at未被修改",
- "lang": "sql",
- "expected": "is_active 从 0 变为 1,价格变为 2090,version 从 1 变为 2;created_at 保持不变,updated_at 是这次修改写入的 UTC 时间。",
- "explanation": "DEFAULT 只提供插入初值,不会自动处理每次编辑。本例 UPDATE 同时维护更新时间和递增版本,创建时间保留初次事实;遗漏这些 SET 项就不会自动得到同样结果。"
- },
- {
- "title": "验证DATETIME不会随显示时区转换",
- "body": "在同一会话按下方顺序切换 +00:00 与 +08:00,比较 created_at、NOW 和 UTC_TIMESTAMP,结束时恢复 +00:00。最后 JSON_EXTRACT 只做一次读取展示,不给商品表添加 JSON 列。",
- "code": "SET SESSION time_zone='+00:00';\nSELECT created_at,NOW(6),UTC_TIMESTAMP(6) FROM products WHERE id=@product_id;\nSET SESSION time_zone='+08:00';\nSELECT created_at,NOW(6),UTC_TIMESTAMP(6) FROM products WHERE id=@product_id;\nSET SESSION time_zone='+00:00';\nSELECT JSON_EXTRACT('{\"source\":\"api\",\"revision\":1}','$.source') AS source;\n-- \"api\":JSON字符串与SQL普通字符串的输出表示不同",
- "lang": "sql",
- "expected": "created_at 字段值不随会话时区改变;NOW 相差约 8 小时,UTC_TIMESTAMP 保持 UTC 含义。JSON_EXTRACT 展示 \"api\",恢复后的时区为 +00:00。",
- "explanation": "DATETIME(6) 保存到微秒的日期时间,却不附带时区和自动换算。连接统一 UTC 是我们共同遵守的解释约定。JSON 适合少量弱结构属性,但需要去重、外键和反向查询的分类应使用关系表,下一课之后继续实现。"
- }
- ],
- "checks": [
- "能从商品详情、编辑和上架列表解释每个字段及索引候选的用途。",
- "SHOW CREATE TABLE确认主键、InnoDB、utf8mb4、非空、默认值与命名CHECK;name没有UNIQUE。",
- "默认草稿价格为0,1990按分保存;负价、NULL价格、空白名称、非法布尔及超长描述被拒绝。",
- "修改同时更新updated_at和version、保留created_at;切换会话时区不改变DATETIME原值。"
- ],
- "pitfalls": [
- {
- "symptom": "is_active保存成2也成功",
- "cause": "只声明BOOLEAN却没有CHECK。",
- "fix": "定义CHECK(is_active IN(0,1)),应用入口也按布尔校验。"
- },
- {
- "symptom": "序列化时间出现8小时偏差",
- "cause": "把UTC约定的无时区DATETIME当本地时间解释。",
- "fix": "统一连接+00:00,读出后按已知UTC约定加timezone.utc,输出带时区ISO8601。"
- },
- {
- "symptom": "以为CHECK(price_cents>=0)也会禁止NULL",
- "cause": "CHECK表达式为UNKNOWN时可通过,NULL不是负数。",
- "fix": "同时声明price_cents INT NOT NULL DEFAULT 0与CHECK(price_cents>=0),并用负数和NULL分别验收。"
- }
- ],
- "exercise": {
- "task": "设计一个名称为帆布袋、售价29.90元、尚无描述且未上架的商品:写出字段值,解释为什么名称不加UNIQUE,再列出3种应拒绝的输入。",
- "hint": "先区分缺值、默认值与非法值;金额输入先明确单位,不用float乘100作为通用转换。",
- "answer": "INSERT INTO products(name,description,price_cents) VALUES('帆布袋','',2990); is_active省略后为FALSE,时间与version使用默认值。不同商品可同名,稳定身份由id确定;空白名称、price_cents=-1或NULL、is_active=2应被拒绝。API应接收整数分或用Decimal解析十进制文本并验证小数位,不能靠浮点四舍五入掩盖不合法输入。"
- },
- "refs": [
- {
- "label": "MySQL 8.4:CREATE TABLE与CHECK",
- "url": "https://dev.mysql.com/doc/refman/8.4/en/create-table.html"
- },
- {
- "label": "MySQL 8.4:时间类型",
- "url": "https://dev.mysql.com/doc/refman/8.4/en/date-and-time-types.html"
- },
- {
- "label": "MySQL 8.4:JSON",
- "url": "https://dev.mysql.com/doc/refman/8.4/en/json.html"
- },
- {
- "label": "MySQL 8.4:CHECK与NULL语义",
- "url": "https://dev.mysql.com/doc/refman/8.4/en/create-table-check-constraints.html"
- },
- {
- "label": "MySQL 8.4:精确数值类型",
- "url": "https://dev.mysql.com/doc/refman/8.4/en/fixed-point-types.html"
- }
- ],
- "production": {
- "scenario": "API、脚本和worker共同读写商品,字段单位、默认值与完整性必须一致。",
- "decisions": [
- "先交付操作清单与字段设计表,再评审DDL;新查询增加时重新评估索引。",
- "价格统一为人民币整数分,0只表示免费样品;名称可重复,id稳定。",
- "数据库守住非空与合法值,应用负责友好提示;UTC、updated_at和version由统一写入代码维护。"
- ],
- "failureDrill": "在shop_lab逐条提交负价格、NULL价格、空白名称和布尔整数2,核对错误码与未落库结果。",
- "acceptance": [
- "每个字段能追溯到读写需求和规则。",
- "合法值、默认值与非法值均有实际验收。",
- "所有写入入口遵守金额单位、时间与版本合同。"
- ]
- },
- "intro": {
- "scenario": "现在要真正保存一个保温杯:名称可以重名,价格不能为负,新建时先不上架。先从这些小规则列出表格,再把表格变成数据库能执行的限制。",
- "startingPoint": [
- "已完成数据库第 01 课;用 app_user 连入 mysql> 后执行 SELECT DATABASE(),CURRENT_USER();,确认 shop_lab 与业务练习身份。",
- "products 表尚未建立;先执行 SHOW TABLES LIKE 'products';。若已做过本课,核对现有结构后复用,不重复 CREATE 或删除旧表。",
- "继续 shop-service,创建 sql 子目录保存 SQL 文件;本模块直接建立带 version 的实验表,与 API+数据库模块的迁移起点分别管理。"
- ],
- "outcome": "从商品操作写出字段设计,建好 products 表,用合法样品和失败输入证明默认值、价格、名称与时间规则生效。"
- }
- },
- {
- "id": "03",
- "group": "SQL 与关系建模",
- "title": "用PyMySQL参数化完成增删改查",
- "summary": "用 Python 完成商品创建、改价、查询和删除,确认单引号与 SQL 片段只作为名称保存,并在失败后回滚和关闭连接。",
- "objectives": [
- "在正确的项目环境安装驱动并连接 shop_lab",
- "使用参数把商品字段交给 SQL",
- "用查询结果与受影响行数验证修改,并明确结束每次事务"
- ],
- "concepts": [
- {
- "title": "参数化让输入保持为数据",
- "body": "第 4 步单引号名称正常保存,第 5 步 DROP 片段只是原样字符串。SQL 和值分开交给 execute,不用 f-string 或手加引号拼接用户内容。"
- },
- {
- "title": "编号、记录、数量分别读取",
- "body": "第 4 步用 lastrowid 取 id、fetchone 取记录、rowcount 判断修改;第 6 步 0 行不能解释为找到一个内容为 0 的商品。"
- },
- {
- "title": "事务和连接都要明确收尾",
- "body": "脚本成功 commit,异常 rollback,finally close;cursor 的 with 只关闭游标。驱动连接 autocommit=False,与第 01 课交互客户端默认不同。"
- }
- ],
- "steps": [
- {
- "title": "建立 shop-service 专用 Python 环境",
- "lang": "bash",
- "code": "# 在 WSL/Linux/macOS 的 Bash、shop-service 目录执行\npython3 -m venv .venv\nsource .venv/bin/activate\npython -c \"import sys; print(sys.executable)\"",
- "body": "在 shop-service 目录执行下方 Bash;若本目录已有 .venv,复用它,仅执行激活与路径检查。不要使用仍激活的 api-lab 环境。",
- "expected": "打印的 Python 路径包含 shop-service/.venv;若仍出现 api-lab 或系统路径,先重新进入正确目录并激活。",
- "explanation": "一个项目一份 .venv,使数据库脚本的驱动版本与 API 练习分开。后面 python -m pip 使用这个已确认的 Python 安装包,mysql_client.py 也由它运行。"
- },
- {
- "title": "安装同步驱动并设置环境",
- "body": "在刚通过路径检查的终端执行下方安装和环境设置。只 source 自己生成的 .mysql.env,不读取陌生脚本;保留 MYSQL_ROOT_PASSWORD 的 unset。安装完成后再写锁文件。",
- "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",
- "expected": "PyMySQL 与所需附加包安装完成,requirements-lock.txt 出现实际版本;当前终端具备 MYSQL_HOST=127.0.0.1 与 MYSQL_PORT=3307。不要打印密码检查配置。",
- "explanation": "PyMySQL 是 Python 与 MySQL 对话的同步驱动,rsa 附加依赖支持本机无 TLS 实验里的认证。set -a 使文件变量进入随后启动程序的环境;移除 root 密码防止业务脚本不必要地继承管理凭据。"
- },
- {
- "title": "保存统一连接函数",
- "body": "在 shop-service/mysql_client.py 保存下面完整代码。仍在当前 Bash 执行 python -c \"from mysql_client import connect; c=connect(); print(c.open); c.close()\",只验证连接状态,不输出凭据。",
- "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\", \"shop_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",
- "expected": "打印 True(或等价真值)后退出;若缺 MYSQL_PASSWORD,回到上一步在同一终端载入配置;若连接拒绝,核对容器与 3307。",
- "explanation": "connect 把地址、身份和会话设置集中起来,DictCursor 使读取结果可按列名访问,autocommit=False 要求脚本明确提交。连接建立、网络读写、SELECT、行锁和元数据锁各有自己的等待预算,并不是同一个“所有 SQL 超时”。"
- },
- {
- "title": "执行完整CRUD并显式结束事务",
- "body": "在 shop-service/db_crud.py 保存下方代码,运行 python db_crud.py。随后可再运行一次,确认脚本只处理它自己刚创建的样品。",
- "code": "from mysql_client import connect\n\nconn=connect()\ntry:\n with conn.cursor() as cur:\n cur.execute(\"INSERT INTO products(name,description,price_cents) VALUES(%s,%s,%s)\",(\"db03-O'Reilly 技术书\",\"parameterized\",1990))\n product_id=cur.lastrowid\n cur.execute(\"SELECT id,name,is_active,price_cents,version FROM products WHERE id=%s\",(product_id,))\n print(\"created\",cur.fetchone())\n cur.execute(\"UPDATE products SET is_active=%s,price_cents=%s,version=version+1,updated_at=UTC_TIMESTAMP(6) WHERE id=%s\",(True,2090,product_id))\n assert cur.rowcount==1\n cur.execute(\"SELECT id,is_active,price_cents,version FROM products WHERE id=%s\",(product_id,))\n print(\"updated\",cur.fetchone())\n cur.execute(\"DELETE FROM products WHERE id=%s\",(product_id,))\n assert cur.rowcount==1\n cur.execute(\"SELECT id FROM products WHERE id=%s\",(product_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",
- "expected": "created 中名称完整保留 O'Reilly,价格为 1990、version 为 1;updated 中价格为 2090、version 为 2;脚本最后删除样品,断言全部通过并正常退出。",
- "explanation": "execute 的 SQL 与值元组分开传入,驱动处理单引号等内容。lastrowid 获得本次插入编号,rowcount 验证修改数量;本课每次更新递增 version,因此匹配成功一定发生变化。commit 提交,异常时 rollback,最后 close 释放连接。"
- },
- {
- "title": "验证SQL片段只成为普通文本",
- "body": "在 shop-service/db_input.py 保存下方代码,运行 python db_input.py。不要把 payload 字符串复制成 SQL 单独执行,也不要启用多语句连接选项;它只应作为 INSERT 的参数。",
- "code": "from mysql_client import connect\n\npayload=\"db03-x'); DROP TABLE products; --\"\nconn=connect()\ntry:\n with conn.cursor() as cur:\n cur.execute(\"INSERT INTO products(name) VALUES(%s)\",(payload,))\n product_id=cur.lastrowid\n cur.execute(\"SELECT name FROM products WHERE id=%s\",(product_id,))\n assert cur.fetchone()[\"name\"]==payload\n conn.rollback() # 仅验收,不保留样本\nexcept BaseException:\n conn.rollback()\n raise\nfinally:\n conn.close()",
- "lang": "python",
- "filename": "db_input.py",
- "expected": "脚本无断言失败地退出,读回的 name 与包含 DROP 文字的 payload 完全相同;products 表仍在,试验插入因 rollback 不保留。",
- "explanation": "%s 代表值的位置,不是用 Python 字符串插值拼 SQL。整段输入被当成一个商品名,不能跳出为第二条语句;动态表名、列名等标识符则要用固定白名单,不能当值参数替换。"
- },
- {
- "title": "明确列表边界与不存在结果",
- "body": "重新打开 app_user 的 mysql 客户端并确认 shop_lab,在 mysql> 执行下面查询和不存在编号的 UPDATE,紧接着查看 ROW_COUNT。",
- "code": "SELECT id,name,is_active FROM products WHERE is_active=0 ORDER BY id DESC LIMIT 10;\nUPDATE products SET is_active=1,version=version+1,updated_at=UTC_TIMESTAMP(6) WHERE id=-1;\nSELECT ROW_COUNT() AS changed_rows;\n-- 0",
- "lang": "sql",
- "expected": "列表最多 10 条并按 id 倒序;修改 -1 编号后 changed_rows=0。空列表也可能合法,不能把没有记录当作连接失败。",
- "explanation": "SELECT 的记录要从 fetch 方法读取,execute/rowcount 是执行数量信息。0 行可能表示不存在或条件没匹配;应用需要进一步区分,而不是向用户宣称商品已保存。"
- }
- ],
- "checks": [
- "CRUD输出使用同一生成编号,更新后version增加。",
- "输入SQL片段保持文本,数据库表仍可访问。",
- "每条完整脚本都有commit或显式回滚,以及finally关闭连接。"
- ],
- "pitfalls": [
- {
- "symptom": "读取到的是1而不是记录字典",
- "cause": "把cursor.execute返回的受影响行数当作查询数据。",
- "fix": "SELECT后使用fetchone/fetchall,INSERT后使用lastrowid。"
- },
- {
- "symptom": "UPDATE相同内容返回0,被误判为不存在",
- "cause": "默认受影响行数反映实际修改。",
- "fix": "使用版本递增的条件更新,或明确启用FOUND_ROWS并理解行为;需要区分不存在与未变更时单独查询。"
- }
- ],
- "exercise": {
- "task": "写一个rename_product(conn,id,name,expected_version),返回是否修改成功,函数不自行提交。",
- "hint": "把WHERE版本比较、版本递增和时间更新放到一条UPDATE。",
- "answer": "with conn.cursor() as cur:\n cur.execute(\"UPDATE products SET name=%s,version=version+1,updated_at=UTC_TIMESTAMP(6) WHERE id=%s AND version=%s\",(name,id,expected_version))\n return cur.rowcount==1\n外层负责commit/rollback/close。更新0行可能不存在或冲突,需要时再读取区分。"
- },
- "refs": [
- {
- "label": "PyMySQL:使用示例",
- "url": "https://pymysql.readthedocs.io/en/latest/user/examples.html"
- },
- {
- "label": "PyMySQL:连接参数",
- "url": "https://pymysql.readthedocs.io/en/latest/modules/connections.html"
- },
- {
- "label": "MySQL 8.4:LAST_INSERT_ID",
- "url": "https://dev.mysql.com/doc/refman/8.4/en/information-functions.html"
- }
- ],
- "production": {
- "scenario": "浏览器输入将进入数据库,写入成功必须有可确认的结果。",
- "decisions": [
- "固定SQL与独立参数。",
- "默认不开启多语句。",
- "不在发生不确定写入结果后无条件重试。"
- ],
- "failureDrill": "对专用样本提交包含SQL语法的商品名称,再回滚并查询表完整性。",
- "acceptance": [
- "无SQL插值。",
- "空结果和行数语义正确。",
- "异常路径不泄漏连接。"
- ]
- },
- "intro": {
- "scenario": "商品表已经能在命令行读写。接下来让 Python 创建一件带单引号名称的技术书、改价并删除,观察“程序里的值”怎样安全地交给数据库。",
- "startingPoint": [
- "已完成数据库第 02 课,products 表与合法样品都存在;第 01 课的 shop-mysql 正在运行。",
- "在 Bash 中进入 shop-service,确认 .mysql.env 是自己在第 01 课生成的文件。Windows 使用 WSL/Linux Bash,不在 PowerShell 执行 source/export。",
- "若正在使用 api-lab/.venv,先 deactivate 或开新终端。本课先建立 shop-service/.venv,后续驱动和脚本只安装、运行在这个项目环境中。"
- ],
- "outcome": "用 Python 完成商品创建、改价、查询和删除,确认单引号与 SQL 片段只作为名称保存,并在失败后回滚和关闭连接。"
- }
- },
- {
- "id": "04",
- "group": "SQL 与关系建模",
- "title": "设计商品分类关系表、主外键与删除策略",
- "summary": "建出商品、分类与归类关系,查到一个商品的两个分类和未分类商品的 0,并验证两种删除策略。",
- "objectives": [
- "从一个商品属于多个分类推导关系表",
- "用主键、唯一键和外键约束重复与无效归类",
- "用双向查询及删除实验核对设计"
- ],
- "concepts": [
- {
- "title": "每张表只存一种事实",
- "body": "第 1、3 步明确商品、分类、归类各自一行的含义;分类名没有复制到商品里,也没有拼成逗号字符串。关系查询由 JOIN 组合。"
- },
- {
- "title": "唯一性按具体规则分别设计",
- "body": "第 5 步重复归类被联合主键拦住,大小写分类名冲突被 UNIQUE 拦住;商品名称仍允许重名。关联行不需要额外无用途的 id。"
- },
- {
- "title": "关系完整性与查询路径一起核对",
- "body": "第 2 步主外键类型一致,主键前缀支持按商品找分类,反向索引支持按分类找商品;第 4、5 步分别验证可查和非法引用被拒。"
- },
- {
- "title": "零关系与删除规则要用反例验证",
- "body": "第 4 步 LEFT JOIN 配合 COUNT(关联列) 得到 0;第 6 步下架保留关系、删商品清理关系、删被引用分类被拒。只验证有分类的正常记录不足以证明设计完整。"
- }
- ],
- "steps": [
- {
- "title": "把关系需求画成实体和字段清单",
- "body": "在笔记画出“收纳盒→办公、桌面”和“免费样品→暂无分类”。再对照下方实体清单,为每张表写出一行的含义;这段是设计说明,不在 mysql> 执行。",
- "code": "products 一行一个商品;id是主键\ncategories 一行一个分类;id是主键;name VARCHAR(40)非空且唯一\nproduct_categories 一行一组归类;product_id、category_id均为BIGINT非空\n\nproducts 1 ---- 0..多 product_categories 多..0 ---- 1 categories\n联合主键:(product_id,category_id),同一归类只出现一次\n按商品找分类:使用联合主键的product_id前缀\n按分类找商品:增加(category_id,product_id)索引\n删除商品:级联删除关联;删除被引用分类:拒绝;下架:保留关系",
- "lang": "text",
- "expected": "products 一行是商品,categories 一行是分类,product_categories 一行是一组归类;一个商品可以有 0 条或多条关联,分类也可被多个商品使用。",
- "explanation": "两边都可以有多个对应对象,所以这里是多对多关系,中间表保存关联事实。若业务仅允许一个分类,在 products 放 category_id 就可表达一对多;表的数量由规则决定,不为技术而增加业务。"
- },
- {
- "title": "创建分类父表与关联表",
- "body": "保存下方 SQL 为 sql/002_categories.sql。在 mysql> 确认 shop_lab 后首次执行:先建 categories,再建 product_categories;最后读 SHOW CREATE TABLE 与 SHOW INDEX。",
- "code": "CREATE TABLE categories (\n id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,\n name VARCHAR(40) NOT NULL,\n CONSTRAINT categories_name_unique UNIQUE(name),\n CONSTRAINT categories_name_nonempty CHECK(CHAR_LENGTH(TRIM(name))>0)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;\nCREATE TABLE product_categories (\n product_id BIGINT NOT NULL,\n category_id BIGINT NOT NULL,\n PRIMARY KEY(product_id,category_id),\n INDEX product_categories_category_id_idx(category_id,product_id),\n CONSTRAINT pc_product_fk FOREIGN KEY(product_id) REFERENCES products(id)\n ON DELETE CASCADE ON UPDATE RESTRICT,\n CONSTRAINT pc_category_fk FOREIGN KEY(category_id) REFERENCES categories(id)\n ON DELETE RESTRICT ON UPDATE RESTRICT\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;\nSHOW CREATE TABLE categories;\nSHOW CREATE TABLE product_categories;\nSHOW INDEX FROM product_categories;",
- "lang": "sql",
- "filename": "sql/002_categories.sql",
- "expected": "两张表建立;关联表主键为 (product_id,category_id),有反向索引 (category_id,product_id),两列分别引用父表 id;商品删除 CASCADE,分类删除 RESTRICT。",
- "explanation": "联合主键禁止同一对编号重复,UNIQUE 约束分类名称。外键要求引用的父记录存在,类型都与父表有符号 BIGINT 一致。一个索引以商品编号开头,另一个以分类编号开头,分别服务两个查询方向,不必额外重复给 product_id 建单列索引。"
- },
- {
- "title": "准备一个双分类商品和一个未分类商品",
- "body": "在同一 mysql 会话执行下方样本 SQL,并记录 @categorized_id 与 @empty_id。两个分类仅首次插入;重复练习时复用已有 db04-office 与 db04-desk,不用 INSERT IGNORE 遮住其他错误。",
- "code": "INSERT INTO categories(name) VALUES('db04-office'),('db04-desk');\nINSERT INTO products(name,description,price_cents) VALUES('db04-桌面收纳盒','教学样品',1990);\nSET @categorized_id=LAST_INSERT_ID();\nINSERT INTO products(name,price_cents) VALUES('db04-未分类样品',0);\nSET @empty_id=LAST_INSERT_ID();\nINSERT INTO product_categories(product_id,category_id)\nSELECT @categorized_id,id FROM categories WHERE name IN ('db04-office','db04-desk');",
- "lang": "sql",
- "expected": "出现一个 1990 分收纳盒和一个 0 分未分类样品;收纳盒建立两条关联,未分类样品没有关联行。会话变量保留它们各自真实编号。",
- "explanation": "父商品和父分类先存在,才能写关联;同一商品的两条关系分别保存两组编号,不复制分类名。分类改名只需改 categories 一行,查询时再组合显示,避免多份名称不一致。"
- },
- {
- "title": "用明细、零分类计数与反向查询验收模型",
- "body": "依次执行下方三条 SELECT,分别查看收纳盒分类明细、两个样品的分类数量和办公分类包含的商品。把商品名和分类名列对齐比较。",
- "code": "SELECT p.id,p.name AS product_name,c.name AS category_name FROM products p\nJOIN product_categories pc ON pc.product_id=p.id JOIN categories c ON c.id=pc.category_id\nWHERE p.id=@categorized_id ORDER BY c.name;\nSELECT p.id,p.name,COUNT(pc.category_id) AS category_count FROM products p\nLEFT JOIN product_categories pc ON pc.product_id=p.id\nWHERE p.id IN (@categorized_id,@empty_id)\nGROUP BY p.id,p.name ORDER BY p.id;\nSELECT p.id,p.name FROM categories c\nJOIN product_categories pc ON pc.category_id=c.id JOIN products p ON p.id=pc.product_id\nWHERE c.name='db04-office' ORDER BY p.id;",
- "lang": "sql",
- "expected": "第一条返回办公与桌面两行,第二条分类数分别为 2 与 0,第三条按办公分类找到收纳盒且不重复。",
- "explanation": "JOIN 沿编号把表连接,LEFT JOIN 还保留没匹配到分类的商品。对关联列 COUNT(pc.category_id) 才能得到 0;COUNT(*) 会把保留的空关联行也算作 1。GROUP BY 按商品汇总,别名避免两种 name 混淆。"
- },
- {
- "title": "逐条验证唯一性和不存在的父记录",
- "body": "在 mysql> 逐条执行下方四个预期失败的 INSERT,每次观察错误再继续,最后查询合法关联数量。不要关闭 foreign_key_checks 来绕过错误。",
- "code": "INSERT INTO product_categories(product_id,category_id)\nSELECT @categorized_id,id FROM categories WHERE name='db04-office';\n-- 1062:重复的商品/分类组合\nINSERT INTO categories(name) VALUES('DB04-OFFICE');\n-- 1062:ai_ci排序规则下分类名冲突\nINSERT INTO product_categories(product_id,category_id) VALUES(@empty_id,-999);\n-- 1452:分类不存在\nINSERT INTO product_categories(product_id,category_id)\nSELECT -999,id FROM categories WHERE name='db04-office';\n-- 1452:商品不存在\nSELECT COUNT(*) AS valid_relations FROM product_categories WHERE product_id=@categorized_id;\n-- 2",
- "lang": "sql",
- "expected": "重复归类和仅大小写不同的分类名为 1062;不存在商品或分类为 1452;合法关联仍为 2。",
- "explanation": "联合主键约束一对编号,分类名称 UNIQUE 约束另一种规则;ai_ci 使 DB04-OFFICE 与 db04-office 相同。外键即时拒绝不存在父记录的子行,不等到 COMMIT 才检查。"
- },
- {
- "title": "分别验证下架、分类限制删除与商品级联删除",
- "body": "只针对本课收纳盒逐条执行下方上架、下架、删除与查询。分类删除报预期错误后继续观察;最后删除的是收纳盒样品,不是分类表或整个练习库。",
- "code": "UPDATE products SET is_active=TRUE,version=version+1,updated_at=UTC_TIMESTAMP(6)\nWHERE id=@categorized_id;\nUPDATE products SET is_active=FALSE,version=version+1,updated_at=UTC_TIMESTAMP(6)\nWHERE id=@categorized_id;\nSELECT COUNT(*) AS after_unpublish FROM product_categories WHERE product_id=@categorized_id;\n-- 2:下架保留分类关系\nDELETE FROM categories WHERE name='db04-office';\n-- 1451:仍被商品引用,RESTRICT拒绝\nDELETE FROM products WHERE id=@categorized_id;\nSELECT COUNT(*) AS after_delete FROM product_categories WHERE product_id=@categorized_id;\n-- 0:商品删除仅级联清理关联行\nSELECT COUNT(*) AS categories_kept FROM categories WHERE name IN ('db04-office','db04-desk');\n-- 2:分类本身保留",
- "lang": "sql",
- "expected": "下架后关系仍为 2;删除被引用分类报 1451;删除收纳盒后其关系变为 0,而两个分类仍为 2,可继续用于下一课。",
- "explanation": "下架只是修改商品字段,删除商品则触发 CASCADE 清理关联;删除仍被用到的分类由 RESTRICT 拒绝。三个动作有不同含义,外键只维护关系一致性,不能替代应用的删除权限判断。"
- }
- ],
- "checks": [
- "能用一句话说明每张表一行的含义,并解释为何本例采用多对多。",
- "SHOW CREATE TABLE确认同类型主外键、联合主键、分类名唯一约束及两种删除策略。",
- "明细2行,分类计数分别2与0,按分类查询无重复;双向索引与查询需求对应。",
- "重复分类/关联及不存在的父记录被拒绝,下架保留关系,删除商品只清理关联,分类保留。"
- ],
- "pitfalls": [
- {
- "symptom": "到COMMIT才希望补齐父行,但子行INSERT立即失败",
- "cause": "把其他系统的延迟外键习惯套到InnoDB。",
- "fix": "先写商品和分类父记录,再写关联;失败业务在应用边界整体回滚。"
- },
- {
- "symptom": "没有分类的商品统计为1或在列表中消失",
- "cause": "COUNT(*)计入LEFT JOIN空关联行,或WHERE中的分类条件把LEFT JOIN变成了实际内连接。",
- "fix": "使用COUNT(pc.category_id);明确是查所有商品还是查某分类商品,分别写SQL。"
- },
- {
- "symptom": "给关联表加了自增id,却仍出现重复归类",
- "cause": "只约束了关联行编号,没有约束product_id与category_id的组合。",
- "fix": "本例直接用联合主键;若另有理由增加id,也仍需UNIQUE(product_id,category_id)。"
- }
- ],
- "exercise": {
- "task": "现在要把办公分类改名,随后删除这个分类但保留全部商品。说明每个动作需要修改哪张表,以及为什么不能把分类名或分类数组直接存到products。",
- "hint": "改名处理分类事实;删除处理被引用关系。先定义保留商品的目标,再按外键顺序操作。",
- "answer": "改名只UPDATE categories.name,关联仍使用稳定category_id;删除被引用分类先明确移除该分类在product_categories中的关联,再DELETE categories,两步放在同一事务,商品行保留。第05课演练事务。把名称复制进products会产生多处改名,把数组存入一列则无法直接用本例外键和联合主键保证每个归类合法且不重复。若真实需求只允许一个分类,可用products.category_id表达一对多。"
- },
- "refs": [
- {
- "label": "MySQL 8.4:JOIN",
- "url": "https://dev.mysql.com/doc/refman/8.4/en/join.html"
- },
- {
- "label": "MySQL 8.4:外键",
- "url": "https://dev.mysql.com/doc/refman/8.4/en/create-table-foreign-keys.html"
- },
- {
- "label": "MySQL 8.4:聚合",
- "url": "https://dev.mysql.com/doc/refman/8.4/en/aggregate-functions.html"
- }
- ],
- "production": {
- "scenario": "商品列表展示分类,后台允许下架或删除样品,需要关系可查、无重复且删除影响明确。",
- "decisions": [
- "先确认基数和可选关系,再选择三表设计;分类保持扁平。",
- "分类名唯一与商品名可重复分别记录;外键类型与双向查询索引一致。",
- "下架保留关系,删商品级联清关联,删被引用分类先拒绝并由应用决定如何处理。"
- ],
- "failureDrill": "对专用样品重复归类、引用不存在的父记录、下架再删除,核对报错和三张表的行数。",
- "acceptance": [
- "无孤儿、无重复归类,未分类商品不丢行。",
- "能解释级联只影响关联行,不误删分类或其他商品。",
- "每条索引与实际查询或外键要求对应。"
- ]
- },
- "intro": {
- "scenario": "桌面收纳盒既属于“办公”又属于“桌面”,另一个免费样品暂时没有分类。现在据此设计三张表,并亲手验证重复归类、删分类和删商品分别会发生什么。",
- "startingPoint": [
- "已完成数据库第 02 课 products 表设计;第 03 课已练过连接与增删改查。",
- "用 app_user 连接 shop_lab,执行 SHOW TABLES; 确认 products 在,categories 和 product_categories 尚未创建;若重做,先检查并复用已有结构。",
- "本课 SQL 在 mysql> 执行,结构文件保存到 shop-service/sql/002_categories.sql;仅处理 db04 前缀的课堂样品。"
- ],
- "outcome": "建出商品、分类与归类关系,查到一个商品的两个分类和未分类商品的 0,并验证两种删除策略。"
- }
- },
- {
- "id": "05",
- "group": "事务与并发",
- "title": "明确InnoDB事务、保存点与DDL提交边界",
- "summary": "观察两次写入一起提交、失败后整体撤销、可选步骤部分撤销,以及建表语句为什么不受普通回滚保护。",
- "objectives": [
- "把商品与必需分类的两次写入一起提交",
- "制造第二次写入失败并检查第一条是否仍存在",
- "分别验证保存点与建表操作的回滚边界"
- ],
- "concepts": [
- {
- "title": "事务范围由同时成功的需求决定",
- "body": "第 1、2 步商品和必需分类在一起提交或回滚;第 3 步只有业务明确允许可选分类才使用保存点。外部 HTTP、邮件与文件不随数据库事务自动撤销。"
- },
- {
- "title": "一条语句失败不等于整个事务已撤销",
- "body": "第 2 步外键报错后还能看到商品,这是显式 ROLLBACK 的依据。后面锁实验会看到死锁和超时的范围还可能不同,应用需主动结束失败业务事务。"
- },
- {
- "title": "结构修改独立安排",
- "body": "第 4 步建表前的 INSERT 经隐式提交保留下来,ROLLBACK 不能恢复。迁移文件应独立于商品业务事务,不能依赖 BEGIN 包裹 DDL 来提供撤销能力。"
- }
- ],
- "steps": [
- {
- "title": "执行商品与商品分类一起成功的路径",
- "body": "在第一个 mysql 窗口执行下方完整成功路径,记下 @ok_id 的实际值。COMMIT 后在第二窗口用这个编号运行同样的关联查询;变量不跨窗口,需重新 SET 或直接填写编号。",
- "code": "START TRANSACTION;\nINSERT INTO products(name) VALUES('db05-atomic-ok');\nSET @ok_id=LAST_INSERT_ID();\nINSERT INTO product_categories(product_id,category_id)\nSELECT @ok_id,id FROM categories WHERE name='db04-desk';\nCOMMIT;\nSELECT p.id,p.name,c.name FROM products p\nJOIN product_categories pc ON pc.product_id=p.id JOIN categories c ON c.id=pc.category_id\nWHERE p.id=@ok_id;",
- "lang": "sql",
- "expected": "原窗口与第二窗口都能查到 db05-atomic-ok 及 db04-desk 关联;两项事实同时出现在已提交结果中。",
- "explanation": "START TRANSACTION 开始一组需要一起结束的修改,COMMIT 确认保存。LAST_INSERT_ID 仍要在插入它的同一连接读取,避免把商品与分类关联错编号。"
- },
- {
- "title": "让第二步失败后明确回滚整个业务",
- "body": "逐条运行下方失败路径,在外键错误后先执行 SELECT 看当前事务,再执行 ROLLBACK。最后在当前与第二窗口分别查失败商品编号。",
- "code": "START TRANSACTION;\nINSERT INTO products(name) VALUES('db05-must-rollback');\nSET @failed_id=LAST_INSERT_ID();\nINSERT INTO product_categories(product_id,category_id) VALUES(@failed_id,-999);\n-- 1452:本条语句失败\nSELECT id,name FROM products WHERE id=@failed_id;\n-- 本事务仍可看到第一步成功写入\nROLLBACK;\nSELECT COUNT(*) AS remaining FROM products WHERE id=@failed_id;\n-- 0",
- "lang": "sql",
- "expected": "错误 1452 后当前事务仍看到刚插入的商品;ROLLBACK 后 remaining=0,另一窗口也查不到该商品。",
- "explanation": "外键失败通常只撤销失败的那条 INSERT,不会自动抹掉整个事务之前的成功步骤。若业务要求商品与归类一起成功,就必须在异常后明确回滚整个业务,否则误 COMMIT 会留下半成品。"
- },
- {
- "title": "只对明确可选步骤使用保存点",
- "body": "现在把规则改成“允许暂未分类的商品”。逐条执行下方保存点示例:分类失败后回到 optional_category 保存点,再提交并查询;不要将这个可选规则套回上一种必需分类的情况。",
- "code": "START TRANSACTION;\nINSERT INTO products(name) VALUES('db05-optional-category');\nSET @optional_id=LAST_INSERT_ID();\nSAVEPOINT optional_category;\nINSERT INTO product_categories(product_id,category_id) VALUES(@optional_id,-999);\nROLLBACK TO SAVEPOINT optional_category;\nRELEASE SAVEPOINT optional_category;\nCOMMIT;\nSELECT id,name FROM products WHERE id=@optional_id;",
- "lang": "sql",
- "expected": "归类 INSERT 失败,但最终 db05-optional-category 商品存在且没有该非法关联;保存点之前的商品写入得以保留。",
- "explanation": "SAVEPOINT 在事务里标一个可返回位置,ROLLBACK TO 只撤销其后的修改,不结束整个事务;随后 COMMIT 保留之前允许保留的商品。自动编号已经消耗,失败或回滚留下空洞是正常的。"
- },
- {
- "title": "用专用表证明DDL隐式提交",
- "body": "确认当前数据库为 shop_lab、没有旧事务后,在专用 ddl_commit_probe 表上逐条执行下方实验。先看 ROLLBACK 后两个查询,再按末尾命令明确清理该样品和专用表;仅删除这些本课对象。",
- "code": "START TRANSACTION;\nINSERT INTO products(name) VALUES('db05-ddl-committed');\nSET @ddl_id=LAST_INSERT_ID();\nCREATE TABLE ddl_commit_probe(id INT PRIMARY KEY) ENGINE=InnoDB;\nROLLBACK;\nSELECT id,name FROM products WHERE id=@ddl_id;\nSHOW TABLES LIKE 'ddl_commit_probe';\n-- 都存在,证明ROLLBACK没有回滚DDL前的提交\nDELETE FROM products WHERE id=@ddl_id;\nDROP TABLE ddl_commit_probe;",
- "lang": "sql",
- "expected": "ROLLBACK 后 db05-ddl-committed 商品与 ddl_commit_probe 表都仍存在;末尾 DELETE 和 DROP 才真正清理它们。",
- "explanation": "CREATE TABLE 属于修改结构的 DDL,通常在执行前后隐式提交;因此之前 INSERT 已保存,后面的 ROLLBACK 无法撤销。MySQL 原子 DDL 保证单条结构操作的一致性,不等于多条建表修改能像商品数据那样一起回滚。"
- }
- ],
- "checks": [
- "失败后主动回滚,第一步商品没有遗留。",
- "保存点例子只保留业务明确允许的部分。",
- "能观察DDL隐式提交,并清理仅本课创建的标记。"
- ],
- "pitfalls": [
- {
- "symptom": "商品分类失败但商品仍保存",
- "cause": "约束错误通常只撤销本条SQL,应用继续提交了事务。",
- "fix": "except中显式rollback整个业务事务,再向调用方报告失败。"
- },
- {
- "symptom": "ROLLBACK没有撤销ALTER或CREATE",
- "cause": "DDL发生隐式提交,原子DDL不等于用户可回滚DDL事务。",
- "fix": "迁移拆分为可检查步骤、先备份和演练,避免与业务DML混用。"
- }
- ],
- "exercise": {
- "task": "PyMySQL第二次execute触发1452时,最小异常处理是什么?",
- "hint": "不要根据SELECT还能运行就判断可以继续提交。",
- "answer": "try内完成全部必需SQL后conn.commit;except BaseException中conn.rollback并raise;finally conn.close。若需要只保留部分成功,必须先定义业务允许的保存点范围。"
- },
- "refs": [
- {
- "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": "MySQL 8.4:InnoDB错误处理",
- "url": "https://dev.mysql.com/doc/refman/8.4/en/innodb-error-handling.html"
- },
- {
- "label": "MySQL 8.4:保存点",
- "url": "https://dev.mysql.com/doc/refman/8.4/en/savepoint.html"
- }
- ],
- "production": {
- "scenario": "一次用户保存涉及多表,异常处理必须符合MySQL实际回滚语义。",
- "decisions": [
- "DML事务显式提交回滚。",
- "DDL独立运行并设计恢复路径。",
- "事务中不等待外部服务。"
- ],
- "failureDrill": "在shop_lab触发外键错误和DDL隐式提交,分别记录保留与回滚的记录。",
- "acceptance": [
- "无必需写入半成品。",
- "错误码与回滚范围可解释。",
- "不宣称BEGIN能包住所有DDL。"
- ]
- },
- "intro": {
- "scenario": "这一次创建商品时必须同时归入“桌面”分类。如果商品写进去了、归类却失败,页面不应留下半件结果。我们先让两步一起成功,再故意失败,看回滚范围与保存点。",
- "startingPoint": [
- "已完成数据库第 04 课,products、categories、product_categories 存在,db04-desk 分类被保留下来。",
- "用 app_user 连入 shop_lab,执行 SELECT id FROM categories WHERE name='db04-desk';,必须恰好找到一个分类;找不到先补齐上一课。",
- "准备两个 app_user mysql 窗口用来对照提交后的可见性。所有实验只在 shop_lab,开始前两边执行 ROLLBACK 结束遗留事务。"
- ],
- "outcome": "观察两次写入一起提交、失败后整体撤销、可选步骤部分撤销,以及建表语句为什么不受普通回滚保护。"
- }
- },
- {
- "id": "06",
- "group": "事务与并发",
- "title": "用双会话观察RR、RC与当前读",
- "summary": "在两个窗口观察同一商品从 v1 到 v4 的可见时机,区分普通查询的旧视图与带锁查询的新值。",
- "objectives": [
- "在 A、B 中用实际编号定位同一件商品",
- "按指定顺序比较两种读取规则",
- "观察普通读与 FOR UPDATE 不同,并及时结束事务"
- ],
- "concepts": [
- {
- "title": "RR 的快照由观察结果确认",
- "body": "第 2 步 B 提交后 A 普通读仍 v1,直到 A 结束事务才看到 v2。普通一致性读固定快照,长时间不结束会增加旧版本保留压力。"
- },
- {
- "title": "RC 的每次查询可更新视图",
- "body": "第 3 步同事务内由 v2 变为 v3,原因是每条一致性读重新建立视图。RR 和 RC 的普通读都不会读取另一事务尚未提交的数据。"
- },
- {
- "title": "当前读不要套用快照结论",
- "body": "第 4 步同事务普通读 v3、FOR UPDATE 读 v4。锁定读和 UPDATE 有自己的当前读规则,编辑冲突仍需明确检查版本条件。"
- }
- ],
- "steps": [
- {
- "title": "准备同一行和两个窗口",
- "body": "只在 A 执行插入与 LAST_INSERT_ID 查询,记录实际编号。把示例 SET @iso_id=123 中的 123 改为该编号,然后分别在 A、B 执行 SET 与 SELECT @@transaction_isolation;不要把示例编号原样使用。",
- "code": "INSERT INTO products(name,description) 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",
- "expected": "两窗口 @iso_id 相同,查询该商品 description 为 v1,默认隔离显示 REPEATABLE-READ。样品只插入一次。",
- "explanation": "@iso_id 只存在于当前连接,另一窗口不会继承。实验必须让两次连接指向同一行,才能把后面的差异归因于读取规则,而不是误查了两个商品。"
- },
- {
- "title": "按窗口顺序观察默认RR",
- "body": "严格按下方 A/B 注释切窗口执行,不整段粘入一个窗口。每做一次 A 的 SELECT 都记下值;先观察 B 未提交,再观察 B 已提交,最后结束 A 事务后再查。",
- "code": "-- A\nSET TRANSACTION ISOLATION LEVEL REPEATABLE READ;\nSTART TRANSACTION;\nSELECT description FROM products WHERE id=@iso_id;\n-- B\nSTART TRANSACTION;\nUPDATE products SET description='v2',version=version+1,updated_at=UTC_TIMESTAMP(6) WHERE id=@iso_id;\n-- A:v1\nSELECT description FROM products WHERE id=@iso_id;\n-- B\nCOMMIT;\n-- A:仍v1\nSELECT description FROM products WHERE id=@iso_id;\nCOMMIT;\nSELECT description FROM products WHERE id=@iso_id;\n-- v2",
- "lang": "sql",
- "expected": "A 首次 v1;B 改为 v2 未提交时 A 仍 v1;B 提交后 A 普通读仍 v1;A COMMIT 后新查询才看到 v2。",
- "explanation": "这组现象叫可重复读 RR:事务第一次普通一致性查询建立一个快照,后续普通查询沿用它。START TRANSACTION 本身未必已建立快照,B 未提交的内容也不会被这种普通查询读取。"
- },
- {
- "title": "切为RC观察两次查询变化",
- "body": "确保 A、B 上一步事务都结束。A 按下方先设置本次为 READ COMMITTED 再开始事务;B 确认 autocommit=1 后改为 v3;返回 A 再查并 COMMIT。",
- "code": "-- A\nSET TRANSACTION ISOLATION LEVEL READ COMMITTED;\nSTART TRANSACTION;\nSELECT description FROM products WHERE id=@iso_id;\n-- B,确认autocommit=1\nUPDATE products SET description='v3',version=version+1,updated_at=UTC_TIMESTAMP(6) WHERE id=@iso_id;\n-- A:现在为v3\nSELECT description FROM products WHERE id=@iso_id;\nCOMMIT;",
- "lang": "sql",
- "expected": "A 在同一个事务中先看到 v2,B 提交后下一次普通查询看到 v3;没有看到未提交值。",
- "explanation": "这次是读已提交 RC:每条普通一致性查询建立新的视图,所以能看到两次查询之间已提交的修改。SET TRANSACTION 只影响接下来的事务,必须在无活动事务时设置,不是永久改变两窗口默认值。"
- },
- {
- "title": "证明RR普通读与当前读可以不同",
- "body": "事务结束后按下方顺序再做 RR:A 普通查一次,B 提交 v4,A 先普通查再 FOR UPDATE 查。只观察差异,不覆盖写入;最后立即 ROLLBACK。",
- "code": "-- A\nSET TRANSACTION ISOLATION LEVEL REPEATABLE READ;\nSTART TRANSACTION;\nSELECT description FROM products WHERE id=@iso_id;\n-- B\nUPDATE products SET description='v4',version=version+1,updated_at=UTC_TIMESTAMP(6) WHERE id=@iso_id;\n-- A:v3\nSELECT description FROM products WHERE id=@iso_id;\n-- A:v4,带锁当前读\nSELECT description FROM products WHERE id=@iso_id FOR UPDATE;\nROLLBACK;",
- "lang": "sql",
- "expected": "A 普通读仍为 v3,而 FOR UPDATE 返回 v4;ROLLBACK 后锁已释放。若某步等待,先核对另一窗口是否残留事务。",
- "explanation": "FOR UPDATE 是带锁的当前读,会取得较新的可用行并锁住它,不能按普通读的旧快照推断其结果。RR 也不会自动阻止用户基于旧页面覆盖他人修改;下一课用版本条件处理这种冲突。"
- }
- ],
- "checks": [
- "RR事务中保持旧值,结束后读取新值。",
- "RC同事务下一次SELECT能看到B新提交。",
- "RR普通读与FOR UPDATE结果不同,所有实验事务已结束。"
- ],
- "pitfalls": [
- {
- "symptom": "切隔离级别报事务中不能修改",
- "cause": "已有事务尚未结束,或在START TRANSACTION之后才SET TRANSACTION。",
- "fix": "先ROLLBACK,再设置下一事务的级别,然后START TRANSACTION。"
- },
- {
- "symptom": "以为RR能防止旧表单覆盖新内容",
- "cause": "普通读取快照与UPDATE的锁定读规则不同。",
- "fix": "在UPDATE中加入expected version条件,或对需要串行的业务使用短锁定事务。"
- }
- ],
- "exercise": {
- "task": "为什么多条报表SQL可用短RR只读事务,但编辑接口仍需版本检查?",
- "hint": "报表关心一致快照,编辑关心提交时是否有人已修改。",
- "answer": "RR普通读取可以共享一致快照;编辑提交必须用WHERE id=? AND version=?做原子条件更新。不要让用户填写表单的整个过程占用数据库事务。"
- },
- "refs": [
- {
- "label": "MySQL 8.4:隔离级别",
- "url": "https://dev.mysql.com/doc/refman/8.4/en/innodb-transaction-isolation-levels.html"
- },
- {
- "label": "MySQL 8.4:一致性读",
- "url": "https://dev.mysql.com/doc/refman/8.4/en/innodb-consistent-read.html"
- },
- {
- "label": "MySQL 8.4:锁定读",
- "url": "https://dev.mysql.com/doc/refman/8.4/en/innodb-locking-reads.html"
- }
- ],
- "production": {
- "scenario": "报表导出与多人编辑同时运行,需要明确每种读操作所见的数据。",
- "decisions": [
- "默认RR与显式RC分别验收。",
- "短事务降低undo保留压力。",
- "当前读与普通快照读不混淆。"
- ],
- "failureDrill": "在同一商品上执行固定双会话时序,记录每次提交和读取结果。",
- "acceptance": [
- "结果能对应具体时点。",
- "无遗留长事务。",
- "不把RR描述成自动乐观锁。"
- ]
- },
- "intro": {
- "scenario": "两位同学同时打开同一商品:一位改了描述并提交,另一位继续查询时什么时候能看到新描述?用两个窗口按时间顺序操作,比先背隔离级别名称更直观。",
- "startingPoint": [
- "已完成数据库第 05 课,知道 START TRANSACTION、COMMIT 与 ROLLBACK 的实际效果。",
- "打开两个 app_user mysql 窗口并标记 A、B,确认两边 DATABASE() 都为 shop_lab;两边先 ROLLBACK,并核对 @@autocommit=1。",
- "保持两窗口会话默认 REPEATABLE-READ;本课只操作新建的 db06-isolation 样品,不改旧商品。"
- ],
- "outcome": "在两个窗口观察同一商品从 v1 到 v4 的可见时机,区分普通查询的旧视图与带锁查询的新值。"
- }
- },
- {
- "id": "07",
- "group": "事务与并发",
- "title": "观察行锁、间隙锁、死锁与版本冲突",
- "summary": "亲眼看到行锁等待与死锁,比较两种隔离级别下的范围插入,再用 version 条件阻止旧内容覆盖新修改。",
- "objectives": [
- "从等待窗口与诊断结果确认谁被谁阻塞",
- "按相反顺序和相同顺序比较两个商品的加锁结果",
- "让第一次版本更新成功、第二次旧版本更新明确影响 0 行"
- ],
- "concepts": [
- {
- "title": "行锁由实际访问路径决定",
- "body": "第 2 步同主键修改等待,第 5 步 RR 范围还阻止了尚不存在的 15 插入。InnoDB 锁索引记录,唯一主键等值与范围访问的锁范围不同。"
- },
- {
- "title": "死锁与超时不能当作相同异常",
- "body": "第 2 步 1205 默认通常只撤销当前语句,第 3 步 1213 回滚被选中的完整事务。两类失败都应明确结束业务事务,并按语义有限重试。"
- },
- {
- "title": "加锁顺序与版本检查解决不同问题",
- "body": "第 4 步相同顺序减少循环等待,第 6 步版本条件防止旧页面覆盖新编辑。数据库短事务锁不应贯穿用户思考和编辑的全过程。"
- }
- ],
- "steps": [
- {
- "title": "准备两个实验编号与三个窗口",
- "body": "只在 A 创建下方两个样品,逐个记录 LAST_INSERT_ID。将示例中的 124、125 换成实际值,再分别在 A、B 设置 @first_id、@second_id 和 5 秒等待。两边记录 CONNECTION_ID()。管理窗口用 Bash 命令 docker exec -it shop-mysql mysql -u root -p 打开,使用 .mysql.env 中本机 root 密码交互登录。",
- "code": "INSERT INTO products(name) VALUES('db07-first');\nSELECT LAST_INSERT_ID();\nINSERT INTO products(name) 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",
- "expected": "A、B 指向同一对实际商品编号,但 CONNECTION_ID 不同;管理窗口可读取 performance_schema。若业务账号读取诊断表被拒绝,不在业务窗口补管理权限。",
- "explanation": "每个连接可持有自己的事务和锁,连接编号帮助确认实验对象。等待限制让操作不会无限悬挂;performance_schema 提供诊断视图,它的线程编号与连接编号不是同一个字段,不能混着用。"
- },
- {
- "title": "制造有上限的行锁等待",
- "body": "按注释先在 A 开事务并锁第一行,再在 B 开事务更新同一行。B 等待时立即在管理窗口执行诊断 SELECT;超时后 B 执行 ROLLBACK,A 观察完也执行 ROLLBACK。",
- "code": "-- A\nSTART TRANSACTION;\nSELECT id FROM products WHERE id=@first_id FOR UPDATE;\n-- B\nSTART TRANSACTION;\nUPDATE products SET description='waiting',version=version+1,updated_at=UTC_TIMESTAMP(6) 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",
- "expected": "B 的 UPDATE 暂不返回,等待视图出现请求方与阻塞方;约 5 秒 B 收到 1205。两边回滚后再次普通查询可返回,没有遗留本课锁。",
- "explanation": "FOR UPDATE 把目标索引记录锁给 A,B 要修改同一行就等待释放。1205 是锁等待超时,默认通常只撤销当前语句,所以应用仍应明确结束整个失败事务;不要看到超时就认为其他成功修改也已自动撤销。"
- },
- {
- "title": "固定相反顺序复现死锁",
- "body": "确保旧事务结束。为给切窗口留时间,先在 A、B 分别执行 SET SESSION innodb_lock_wait_timeout=30;,再预备好下方四条锁语句并按 A第一行→B第二行→A第二行→B第一行顺序尽快执行。任一窗口报死锁后两边都 ROLLBACK,管理窗口再读最近死锁。",
- "code": "-- A\nSTART TRANSACTION;\nSELECT id FROM products WHERE id=@first_id FOR UPDATE;\n-- B\nSTART TRANSACTION;\nSELECT id FROM products WHERE id=@second_id FOR UPDATE;\n-- A等待\nSELECT id FROM products WHERE id=@second_id FOR UPDATE;\n-- B触发死锁,1213出现在一个窗口\nSELECT id FROM products WHERE id=@first_id FOR UPDATE;\n-- A/B均ROLLBACK\n-- 管理窗口\nSHOW ENGINE INNODB STATUS;",
- "lang": "sql",
- "expected": "第三条在等待,第四条形成互相等待后某一方收到 1213,另一方得以继续;不能预先断定一定牺牲 A 或 B。诊断里可找到对应锁等待链。",
- "explanation": "双方各握一把锁又等对方的锁,形成循环,数据库检测后回滚一个完整事务打破循环,这叫死锁。它与单向等待超时的回滚范围不同;留存诊断时只保留练习 SQL,避免夹带其他业务内容。"
- },
- {
- "title": "以固定顺序加锁并保持短事务",
- "body": "两边 ROLLBACK 后,都按下方相同主键顺序取两行:A 先执行 START 与 SELECT,B 再执行同样两条并等待。A COMMIT 后等 B 返回,再在 B COMMIT。只处理这两个本课编号。",
- "code": "START TRANSACTION;\nSELECT id FROM products WHERE id IN (@first_id,@second_id) ORDER BY id FOR UPDATE;\n-- A先COMMIT,B返回后再COMMIT\nCOMMIT;",
- "lang": "sql",
- "expected": "B 可能等待,但 A 提交后 B 可以获得两行;这组顺序不再形成上一组互相等待。最后两边都无活动事务。",
- "explanation": "统一加锁顺序减少了本例循环等待的条件,短事务减少占锁时间。这不是让整个商品表都锁住的理由;真实死锁仍要在事务外有限重试完整业务动作,而非只补执行最后一条 SQL。"
- },
- {
- "title": "用独立范围表观察RR间隙保护",
- "body": "事务外建立下方专用范围表。按 A RR 锁定 10–20、B 设置 2 秒等待并插入 15 的顺序执行;超时后双方 ROLLBACK。第二轮 A 执行 SET TRANSACTION ISOLATION LEVEL READ COMMITTED;、START TRANSACTION; 以及同样的范围 SELECT;B 再执行 INSERT INTO lock_range_probe VALUES(15);。观察后双方 ROLLBACK,确认结束后仅 DROP 本课专用表。",
- "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",
- "expected": "RR 下即使 15 原本不存在,B 插入仍等待并超时;RC 下同样插入通常可以成功。两轮现象记录完成且专用表已清理。",
- "explanation": "RR 的范围锁定读除了锁现有记录,还可能保护记录之间的间隙,防止范围中插入新行。RC 通常减少这种范围间隙保护,但外键与重复键检查等仍有例外,不能概括成 RC 完全没有间隙锁。"
- },
- {
- "title": "原子比较旧版本并拒绝第二次覆盖",
- "body": "仅对本课第一件商品执行下方版本实验,且确认处于 autocommit=1、无旧事务。第一次重置 version=1 是准备样品,之后分别执行两次带 version=1 的更新,紧接每次看 ROW_COUNT。",
- "code": "UPDATE products SET version=1,description='base',updated_at=UTC_TIMESTAMP(6) WHERE id=@first_id;\nUPDATE products SET description='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 products SET description='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 description,version FROM products WHERE id=@first_id;",
- "lang": "sql",
- "expected": "第一次 changed_rows=1;第二次为 0;最终 description=editor A、version=2,editor B 没有覆盖它。",
- "explanation": "UPDATE 同时比较旧版本并递增,形成一个不可拆开的判断与写入动作。用户编辑页面时不必长时间持锁;提交时旧版本不再匹配就拒绝覆盖。0 行需要解释为冲突或不存在,真实商品版本不能像实验准备那样倒退。"
- }
- ],
- "checks": [
- "行锁等待能在管理窗口找到,1205后主动回滚。",
- "死锁出现1213,固定顺序实验仅等待而不产生本例闭环。",
- "范围实验与版本实验分别解释间隙保护和编辑冲突。"
- ],
- "pitfalls": [
- {
- "symptom": "设置较高隔离后插入也卡住",
- "cause": "RR范围锁可能保护间隙,更新不只锁住已存在行。",
- "fix": "核对索引访问范围和data_locks,缩小事务范围,按业务需要选择RC。"
- },
- {
- "symptom": "发生1205后继续COMMIT留下部分写入",
- "cause": "误以为所有数据库错误都会回滚整个事务。",
- "fix": "统一显式rollback失败业务,再有限重试;记录错误码与完整操作关联。"
- }
- ],
- "exercise": {
- "task": "收到1213重试时,应复用旧计算结果还是重新读取整个事务?",
- "hint": "数据库已回滚被牺牲事务,外部事实可能变化。",
- "answer": "回滚并重新建立事务、重新读取和计算,使用次数上限与抖动退避。任何外部副作用需要幂等键,不能因数据库回滚就断言邮件或文件也消失。"
- },
- "refs": [
- {
- "label": "MySQL 8.4:InnoDB锁",
- "url": "https://dev.mysql.com/doc/refman/8.4/en/innodb-locking.html"
- },
- {
- "label": "MySQL 8.4:死锁示例",
- "url": "https://dev.mysql.com/doc/refman/8.4/en/innodb-deadlock-example.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": "多人编辑和批量任务竞争同一批数据。",
- "decisions": [
- "管理诊断权限与业务权限分开。",
- "加锁顺序一致,等待有上限。",
- "版本比较与更新在同一SQL完成。"
- ],
- "failureDrill": "仅在专用行和lock_range_probe上制造等待与死锁,保存脱敏证据并结束所有事务。",
- "acceptance": [
- "无无限等待。",
- "旧编辑无法覆盖已提交版本。",
- "能区分1213与1205的回滚边界。"
- ]
- },
- "intro": {
- "scenario": "两位同学同时修改陶瓷杯时,一个保存动作可能等待另一个。我们先看见等待,再复现互相等待,最后用商品版本号拒绝第二次覆盖,比较三种问题的处理方式。",
- "startingPoint": [
- "已完成数据库第 06 课,能在 A、B 两个 app_user mysql 窗口切换执行、结束事务,并理解 FOR UPDATE 会取得锁。",
- "A、B 都确认 DATABASE()=shop_lab、@@autocommit=1,先执行 ROLLBACK。另开本机 shop-mysql 的 root 管理窗口,仅查看等待诊断,不把管理权限授予商品程序。",
- "提前准备各窗口要执行的语句,只针对本课新样品与 lock_range_probe 表。任何等待实验都必须有超时,并在观察后结束两边事务。"
- ],
- "outcome": "亲眼看到行锁等待与死锁,比较两种隔离级别下的范围插入,再用 version 条件阻止旧内容覆盖新修改。"
- }
- },
- {
- "id": "08",
- "group": "查询性能",
- "title": "设计组合索引、覆盖索引与JSON路径索引",
- "summary": "用 50000 条样品比较上架列表建索引前后的计划,切换索引可见性验证其影响,并从 JSON 中抽出可查询字段。",
- "objectives": [
- "生成有上限且分批提交的性能样品",
- "围绕固定筛选与排序建立并验证组合索引",
- "用独立小表观察 JSON 路径、生成列与索引的对应关系"
- ],
- "concepts": [
- {
- "title": "索引从固定查询推导",
- "body": "第 2 步围绕 WHERE is_active=1 ORDER BY created_at DESC,id DESC 设计组合索引。详情 id 已有主键;没有固定按价格查询需求就不顺手加价格索引,布尔列单独建索引也未必划算。"
- },
- {
- "title": "读收益与写成本同时计算",
- "body": "第 3 步不可见索引仍占空间且维护写入。InnoDB 主键叶子包含行,二级索引带主键值;主键过宽会增加所有二级索引成本,覆盖查询则可能减少回表。"
- },
- {
- "title": "要保存对照,不能只凭索引名字判断",
- "body": "第 1 步记录样本数量和 1% 上架分布,第 2、3 步比较 key、rows、Extra。生产分布和查询不一样时要重新测量,不保证照抄同一索引一定更快。"
- },
- {
- "title": "JSON 路径先形成稳定字段",
- "body": "第 4 步从 JSON 抽出 source,再给生成列建索引,缺字段时为 NULL。JSON 不是每条路径自动有 B-tree 索引,表达式、类型、长度与缺值含义需要一起设计。"
- }
- ],
- "steps": [
- {
- "title": "有限批次生成50000条性能样本",
- "body": "在 shop-service/db_seed_performance.py 保存下方脚本,只在确认尚未生成本课样品时运行 python db_seed_performance.py。完成后到 mysql> 查询 SELECT COUNT(*),SUM(is_active=1) FROM products WHERE name LIKE 'db08-bench-%';。中途失败先核对已有批次,不直接从头重跑。",
- "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,1990+(number%10)*100,utc_value,utc_value))\n with conn.cursor() as cur:\n cur.executemany(\"INSERT INTO products(name,description,is_active,price_cents,created_at,updated_at) VALUES(%s,%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",
- "expected": "脚本打印 inserted 50000;该前缀共 50000 行,其中 500 行上架。若中途失败,可能已保留部分批次,不能误报全部回滚。",
- "explanation": "每 500 行提交降低单次事务规模;前批已提交,当前批失败只回滚当前批。样品刻意仅 1% 上架,放大过滤差异,不代表真实商城分布;价格始终是整数分,描述长度仍遵守前面表约束。"
- },
- {
- "title": "保存原计划并建立组合索引",
- "body": "在独立 app_user 维护会话确认 shop_lab 且无事务,执行 ANALYZE TABLE 与第一次 EXPLAIN,保存输出;然后首次建索引,再运行相同 EXPLAIN 并读 SHOW INDEX。若已有同名索引,直接核对,勿重复创建。",
- "code": "ANALYZE TABLE products;\nEXPLAIN SELECT id,name,created_at FROM products WHERE is_active=1\nORDER BY created_at DESC,id DESC LIMIT 20;\nSET SESSION lock_wait_timeout=3;\nCREATE INDEX products_is_active_created_id_idx ON products(is_active,created_at DESC,id DESC)\nALGORITHM=INPLACE LOCK=NONE;\nEXPLAIN SELECT id,name,created_at FROM products WHERE is_active=1\nORDER BY created_at DESC,id DESC LIMIT 20;\nSHOW INDEX FROM products;",
- "lang": "sql",
- "expected": "两份计划均有 key、rows、Extra 等字段,SHOW INDEX 中出现 (is_active,created_at DESC,id DESC)。此样本下通常更容易按筛选与排序使用新索引;若未选它,保留实际结果再查分布与统计。",
- "explanation": "组合索引把等值条件放前,再按所需顺序存排序键,减少无关查找。ANALYZE 更新统计供优化器估计,EXPLAIN 描述访问方式。INPLACE/LOCK=NONE 是本次创建的要求,不支持会报错;仍可能短暂等待元数据锁,DDL 也会隐式提交。"
- },
- {
- "title": "用不可见索引做读计划对照",
- "body": "只把刚创建的实验索引改成 INVISIBLE,运行同样 EXPLAIN 保存对照,再恢复 VISIBLE。恢复后额外重跑上一条 EXPLAIN 确认,最后查询数据与索引空间。",
- "code": "ALTER TABLE products ALTER INDEX products_is_active_created_id_idx INVISIBLE;\nEXPLAIN SELECT id,name,created_at FROM products WHERE is_active=1\nORDER BY created_at DESC,id DESC LIMIT 20;\nALTER TABLE products ALTER INDEX products_is_active_created_id_idx VISIBLE;\nSELECT DATA_LENGTH,INDEX_LENGTH FROM information_schema.tables\nWHERE table_schema='shop_lab' AND table_name='products';",
- "lang": "sql",
- "expected": "不可见时默认计划不再使用该索引;恢复后它重新成为候选。INDEX_LENGTH 仍可能包含不可见索引占用的空间,没有因隐藏就归零。",
- "explanation": "不可见只是默认不交给优化器选择,写入仍需维护它,磁盘也仍占用。它适合试验读取是否依赖该索引,不能用来证明真实删索引后的写入收益;二级索引存索引键与主键,新增每条索引都有成本。"
- },
- {
- "title": "在专用表给JSON路径建生成列索引",
- "body": "仅在 shop_lab 创建下方 json_index_probe 并插入两个样品。先 SELECT 看 source,再 EXPLAIN 查询 source='api',读完后只删除这个专用表,不给贯穿课程 products 随意加 JSON 字段。",
- "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",
- "expected": "两行 source 分别为 api 与 NULL;source_idx 建立在生成列上。只有两行时优化器仍可能选择扫描,不能据此认定建索引语法失效。",
- "explanation": "JSON_EXTRACT 取路径值,JSON_UNQUOTE 去掉 JSON 字符串外层表示,生成列把结果按 VARCHAR(32) 保存,索引才有稳定类型可用。返回列若完全在索引中可覆盖读取;本步先验证路径与 NULL 语义,性能仍须看数据量和实际计划。"
- }
- ],
- "checks": [
- "保存建索引前后真实计划,不要求固定成本数值。",
- "不可见索引能恢复可见,明确仍有写维护成本。",
- "JSON路径生成列得到api与NULL,查询含义明确。",
- "能解释索引服务于哪条商品查询,以及上架/下架和新增商品带来的维护成本。"
- ],
- "pitfalls": [
- {
- "symptom": "照搬WHERE is_active=1的CREATE INDEX报语法错误",
- "cause": "MySQL不支持谓词部分索引。",
- "fix": "使用与查询匹配的组合索引,或按业务设计受控生成列并验证访问路径。"
- },
- {
- "symptom": "在线建索引仍在等待",
- "cause": "LOCK=NONE不等于从不申请元数据锁,长事务可能阻塞DDL。",
- "fix": "设短lock_wait_timeout并检查元数据锁,安排维护窗口;不要关闭所有约束强行建表。"
- }
- ],
- "exercise": {
- "task": "上架列表只返回id、created_at,和同时返回name、description、price_cents相比,哪一种更容易覆盖现有索引?为什么不把所有展示字段都塞进索引?",
- "hint": "二级索引有索引列与主键,但不自动包含整个TEXT。",
- "answer": "id、is_active、created_at可由现有组合二级索引提供;name、description、price_cents通常需要回聚簇索引取行。读详情可以按主键取整行,列表先限制返回字段并比较计划。不要为覆盖盲目加入大TEXT或所有字段,索引变宽会增加空间与写入维护,必须由实际频率和耗时证明收益。"
- },
- "refs": [
- {
- "label": "MySQL 8.4:多列索引",
- "url": "https://dev.mysql.com/doc/refman/8.4/en/multiple-column-indexes.html"
- },
- {
- "label": "MySQL 8.4:不可见索引",
- "url": "https://dev.mysql.com/doc/refman/8.4/en/invisible-indexes.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": "上架商品列表变慢,但商品编辑和上架操作仍需保持合理写入成本。",
- "decisions": [
- "索引对应明确查询。",
- "维护DDL与业务事务分开。",
- "JSON路径类型与长度受约束。"
- ],
- "failureDrill": "在shop_lab比较可见/不可见计划和JSON路径结果,恢复索引可见状态。",
- "acceptance": [
- "有数据规模和计划证据。",
- "不声称MySQL支持部分索引。",
- "没有为每个字段盲目建索引。"
- ]
- },
- "intro": {
- "scenario": "商品越来越多,但上架列表只要最近 20 件。我们先造一批有明确分布的样品,看看数据库原本怎么找,再按查询条件加索引,比较访问计划的变化。",
- "startingPoint": [
- "已完成数据库第 07 课并结束全部锁实验;products 表、mysql_client.py 和第 03 课 shop-service/.venv 可用。",
- "在 shop-service 的 Bash 中 source .venv/bin/activate;若新开终端,按第 03 课重新载入自己的 .mysql.env、移除 root 密码并设置本机 3307。",
- "用 app_user 连入 shop_lab,确认 SHOW INDEX FROM products 尚无 products_is_active_created_id_idx。先查 SELECT COUNT(*) FROM products WHERE name LIKE 'db08-bench-%';,本次首次造数应为 0;已有样品则复用,不重复增加 50000 行。"
- ],
- "outcome": "用 50000 条样品比较上架列表建索引前后的计划,切换索引可见性验证其影响,并从 JSON 中抽出可查询字段。"
- }
- },
- {
- "id": "09",
- "group": "查询性能",
- "title": "读懂EXPLAIN ANALYZE并优化深分页",
- "summary": "读出实际计划的时间与扫描行数,比较深 OFFSET 与已有游标继续读下一页,并记录公平对照的结果。",
- "objectives": [
- "区分估计计划与实际执行测量",
- "找出跳过 40000 行增加了多少工作",
- "用上一页最后一行的时间和编号构造下一页条件"
- ],
- "concepts": [
- {
- "title": "计划的估计与测量各有位置",
- "body": "第 1 步 EXPLAIN FORMAT=JSON 给估计,MySQL 8.4 的 EXPLAIN ANALYZE 以 TREE 给实际时间、行数和循环。后者真的执行查询,本课只测 SELECT。"
- },
- {
- "title": "深分页成本在被跳过的记录中",
- "body": "第 2 步顶层 20 行与底层大量扫描不同。第 4 步把获取深游标的成本单列,避免把一次昂贵定位藏起来再宣称查询变快。"
- },
- {
- "title": "游标与排序必须成对设计",
- "body": "第 3 步 created_at 较小或同时间 id 较小,恰好对应两个降序字段。保持创建时间稳定,同一筛选条件跨页不变,才有一致的连续读取边界。"
- }
- ],
- "steps": [
- {
- "title": "建立列表索引并比较两种计划",
- "body": "在无事务的维护会话中首次创建下方列表索引;已有时跳过 CREATE。执行 ANALYZE TABLE,再分别运行两种 EXPLAIN,把输出保存到笔记;定位 JSON 中的估计与 TREE 中的 actual time、rows、loops。",
- "code": "CREATE INDEX products_created_id_idx ON products(created_at DESC,id DESC);\nANALYZE TABLE products;\nEXPLAIN FORMAT=JSON SELECT id,name,created_at FROM products\nORDER BY created_at DESC,id DESC LIMIT 20;\nEXPLAIN ANALYZE SELECT id,name,created_at FROM products\nORDER BY created_at DESC,id DESC LIMIT 20;",
- "lang": "sql",
- "expected": "得到一份 JSON 估计计划和一份实际执行的 TREE 计划,查询最多返回 20 行;不会把 cost 数字当作真实毫秒。",
- "explanation": "EXPLAIN 说明数据库打算怎样执行,EXPLAIN ANALYZE 真正运行 SELECT 后报告测量;ANALYZE TABLE 则是更新统计,三者用途不同。这里无 is_active 条件,所以单独评估以 created_at 开头的索引。"
- },
- {
- "title": "记录深OFFSET额外扫描量",
- "body": "运行下方 OFFSET 40000 的实际计划,与第一步相同查询的 OFFSET 0 比较。重点记录底层扫描节点的 actual rows、loops 和是否经过排序,同时记样本总量与本次是否已运行过。",
- "code": "EXPLAIN ANALYZE SELECT id,name,created_at FROM products\nORDER BY created_at DESC,id DESC LIMIT 20 OFFSET 40000;",
- "lang": "sql",
- "expected": "即使顶层只给 20 行,底层通常需要处理约 40020 行或其他明显大于 20 的数量;以你的实际节点输出为准,时间受设备与缓存影响。",
- "explanation": "OFFSET 要跳过结果前面的记录,跳过不等于数据库完全没处理过它们。只看最终返回 20 行容易漏掉这些成本,所以要向下读扫描节点,不能只比较一次毫秒结果。"
- },
- {
- "title": "从第一页末尾读取游标",
- "body": "在同一 mysql 会话执行下方 SELECT INTO 取得第一页末尾的时间和编号,再执行下一页查询与实际计划。用第一步排序核对相邻页;继续保留相同过滤条件与排序方向。",
- "code": "SELECT created_at,id INTO @cursor_time,@cursor_id FROM products\nORDER BY created_at DESC,id DESC LIMIT 1 OFFSET 19;\nSELECT id,name,created_at FROM products\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,name,created_at FROM products\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",
- "expected": "@cursor_time 和 @cursor_id 对应第 20 行;下一页返回它之后的最多 20 件,不把末尾那一件重复返回。计划展示范围查询的实际工作量。",
- "explanation": "这两个值合在一起叫游标:先找更早时间,时间相同时找更小 id,匹配倒序的下一段。真实 API 从已经返回的末行取值即可;本课 SELECT INTO 是为了在命令行设置变量。"
- },
- {
- "title": "在相近深度公平对照",
- "body": "执行下方代码先取得第 40000 行的游标,再测下一段;这次获取游标本身的 OFFSET 成本要单独记。把游标查询本身与第 2 步相同深度的查询对照,两者各再测一次,并分别记录首次与热缓存结果。",
- "code": "SELECT created_at,id INTO @deep_time,@deep_id FROM products\nORDER BY created_at DESC,id DESC LIMIT 1 OFFSET 39999;\nEXPLAIN ANALYZE SELECT id,name,created_at FROM products\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",
- "expected": "获得同一深度附近的对照:已有游标时通常减少大量跳过工作,但若实际仍扫描很多行,要保留真实计划并检查 OR 范围与索引使用,不能只填“更快”。",
- "explanation": "连续翻页已经拿到上一页末行,才省掉重新定位深游标的成本;用户任意跳页是另一个产品需求。复合范围是否充分利用索引要靠计划验证,不能仅凭“用了游标”推断性能。"
- }
- ],
- "checks": [
- "区分JSON估计计划与TREE实际执行数据。",
- "深OFFSET和深游标有可对照的扫描量记录。",
- "时间相同的记录由id稳定排序,跨页无重复编号。"
- ],
- "pitfalls": [
- {
- "symptom": "EXPLAIN ANALYZE FORMAT=JSON不支持",
- "cause": "把不同版本或其他数据库的格式组合套到MySQL8.4。",
- "fix": "本课使用EXPLAIN FORMAT=JSON与EXPLAIN ANALYZE两条独立命令。"
- },
- {
- "symptom": "同一时间的商品跨页重复",
- "cause": "只按时间排序或只使用一个时间游标。",
- "fix": "排序与筛选同时包含id,并完整保留其他列表条件。"
- }
- ],
- "exercise": {
- "task": "给上架列表添加游标条件,说明逻辑运算括号位置。",
- "hint": "is_active条件必须作用于OR的两个分支。",
- "answer": "WHERE is_active=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分支绕过is_active过滤。"
- },
- "refs": [
- {
- "label": "MySQL 8.4:EXPLAIN",
- "url": "https://dev.mysql.com/doc/refman/8.4/en/explain.html"
- },
- {
- "label": "MySQL 8.4:LIMIT优化",
- "url": "https://dev.mysql.com/doc/refman/8.4/en/limit-optimization.html"
- },
- {
- "label": "MySQL 8.4:行构造器优化",
- "url": "https://dev.mysql.com/doc/refman/8.4/en/row-constructor-optimization.html"
- }
- ],
- "production": {
- "scenario": "数据增长后后页延迟明显高于第一页。",
- "decisions": [
- "先量工作量再比较耗时。",
- "游标字段稳定且唯一排序。",
- "跨页请求不默认共享数据库快照。"
- ],
- "failureDrill": "在shop_lab对照浅页、深OFFSET和深游标,保存真实计划。",
- "acceptance": [
- "不忽略取游标成本。",
- "无错误格式选项。",
- "性能结论注明样本与缓存状态。"
- ]
- },
- "intro": {
- "scenario": "商品列表翻到第 2001 页时,即使只显示 20 件,也可能先跳过 40000 件。我们用上一课样品读实际扫描结果,再比较“从上一页最后一件接着读”的方式。",
- "startingPoint": [
- "已完成数据库第 08 课,shop_lab 中保留 50000 条 db08-bench 样品;所有练习锁事务已结束。",
- "使用 app_user mysql 客户端;先 SELECT COUNT(*) FROM products; 确认超过 40020 行,SHOW INDEX FROM products 检查是否已建 products_created_id_idx。",
- "本课先看不带上架筛选的完整列表,排序固定 created_at DESC,id DESC;不改样品的创建时间。"
- ],
- "outcome": "读出实际计划的时间与扫描行数,比较深 OFFSET 与已有游标继续读下一页,并记录公平对照的结果。"
- }
- },
- {
- "id": "10",
- "group": "Python 与运行保障",
- "title": "用有界连接池管理PyMySQL连接与事务",
- "summary": "让多个线程复用最多两条数据库连接,验证异常写入被回滚,并看到池满时等待有上限、归还后又能查询。",
- "objectives": [
- "建立最多两条连接的借还组件",
- "让并发读取各用各的连接并及时归还",
- "分别触发业务异常和连接池耗尽,检查后续连接仍可用"
- ],
- "concepts": [
- {
- "title": "池控制连接数量与复用",
- "body": "第 2 步多个任务共用容量为 2 的池,第 4 步第三次借用触发上限。pool_size+max_overflow 属于每个进程,多个进程要分别计算总量。"
- },
- {
- "title": "借用期覆盖自己的短事务",
- "body": "第 2、3 步各请求独立借用、提交或回滚、归还;close 池代理并不等于销毁底层连接。停止工作、归还借出连接后再 dispose,不跨并发请求共享事务。"
- },
- {
- "title": "连接失败不能靠透明重连续事务",
- "body": "第 1 步使用 ping(reconnect=False),发现坏连接就失效处理。网络中断后旧事务状态不能凭新连接恢复,socket 超时也不能证明最后一次写入没有提交;是否重试需要业务语义。"
- }
- ],
- "steps": [
- {
- "title": "建立独立池组件",
- "body": "在已激活终端执行 python -m pip install \"SQLAlchemy>=2.0,<2.1\",再 python -m pip freeze > requirements-lock.txt。保存下方 Python 为 shop-service/db_pool.py;首行注释中的安装命令已执行,不作为 Python 语句粘入运行。",
- "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",
- "expected": "文件能被导入;pool_size=2、max_overflow=0、timeout=0.2 清楚可见。导入时不要求立即建立两条连接,池会按需创建。",
- "explanation": "QueuePool 负责借还数据库连接,这里没有引入 ORM。两项容量相加是本进程上限;checkout 借出后 ping(reconnect=False) 核对连接,坏连接 invalidate,避免自动重连后假装旧事务仍存在。"
- },
- {
- "title": "4个线程共享最多2条连接",
- "body": "保存下方代码为 shop-service/db_pool_read.py,运行 python db_pool_read.py。保持数据库本机负载较轻,不同时执行前课长锁等待;读完列表再看 pool.status。",
- "code": "from concurrent.futures import ThreadPoolExecutor\nfrom db_pool import pool,checkout\n\ndef count_products(_):\n conn=checkout()\n try:\n with conn.cursor() as cur:\n cur.execute(\"SELECT COUNT(*) AS n FROM products\")\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_products,range(8))))\n print(pool.status())\nfinally:\n pool.dispose()",
- "lang": "python",
- "filename": "db_pool_read.py",
- "expected": "打印八个相同商品总数;池状态显示配置规模为 2、没有仍借出的连接。若出现 0.2 秒等待超时,先确认没有同时占用或慢查询,再按实际情况记录。",
- "explanation": "四个线程不是共用同一个连接,而是各自借到一个再执行短查询、commit、close。这里 close 归还池代理,底层连接可复用;dispose 在所有任务结束、连接归还后关闭空闲连接。"
- },
- {
- "title": "验证异常插入明确回滚",
- "body": "保存下方代码为 shop-service/db_pool_rollback.py,执行 python db_pool_rollback.py。它会插入专用样品后主动制造异常,再用重新借来的连接按编号查询。",
- "code": "from db_pool import pool,checkout\n\nproduct_id=None\nconn=checkout()\ntry:\n with conn.cursor() as cur:\n cur.execute(\"INSERT INTO products(name) VALUES(%s)\",(\"db10-rollback\",))\n product_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 products WHERE id=%s\",(product_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",
- "expected": "脚本没有断言失败地退出;重新查询同一编号的计数为 0,说明异常插入没有遗留商品。",
- "explanation": "业务代码明确 rollback,随后 close 归还连接,下一次借用不会继承这份未完成写入。池的归还回滚是最后防线,不应取代对“这件业务成功还是失败”的显式事务判断。"
- },
- {
- "title": "占用全部连接后触发有界等待",
- "body": "保存下方代码为 shop-service/db_pool_timeout.py,独立执行 python db_pool_timeout.py。观察占满两条连接后的第三次借用,再看 finally 释放后执行的 SELECT 1。",
- "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",
- "expected": "约 0.2 秒后打印 expected pool timeout;释放两条连接后重新借用并查询得到 1,脚本正常结束,没有超过配置连接上限。",
- "explanation": "这是应用侧等待空闲连接的 SQLAlchemy TimeoutError,不是 MySQL 行锁的 1205。容量限制防止无限开连接;并发请求各有事务,不能通过把同一连接跨线程共享来绕过限制。"
- }
- ],
- "checks": [
- "并发读取完成且最大连接数为2。",
- "异常插入回滚,连接归还后可以继续使用。",
- "耗尽产生池等待超时,释放后恢复,不盲目扩池。"
- ],
- "pitfalls": [
- {
- "symptom": "关闭池后仍有数据库连接",
- "cause": "还有借出连接或工作线程未结束。",
- "fix": "先停止接收任务并等待有界工作结束,归还连接后dispose。"
- },
- {
- "symptom": "断线后自动重连并继续提交",
- "cause": "新连接不包含旧事务状态。",
- "fix": "禁用事务中透明重连,失效旧连接并按幂等性重试完整业务动作。"
- }
- ],
- "exercise": {
- "task": "4个API进程每池8条、2个任务进程每池5条,再留10条运维,最低连接预算是多少?",
- "hint": "每个进程有独立池,不能只算一个进程。",
- "answer": "4×8+2×5+10=52条,还需加迁移、监控和其他服务余量。max_connections不能只机械等于52,数据库内存与请求排队策略也应验收。"
- },
- "refs": [
- {
- "label": "SQLAlchemy:独立池用法",
- "url": "https://docs.sqlalchemy.org/en/20/core/pooling.html#using-a-pool-instance-directly"
- },
- {
- "label": "PyMySQL:连接参数",
- "url": "https://pymysql.readthedocs.io/en/latest/modules/connections.html"
- },
- {
- "label": "MySQL 8.4:连接过多",
- "url": "https://dev.mysql.com/doc/refman/8.4/en/too-many-connections.html"
- }
- ],
- "production": {
- "scenario": "API与worker同时访问数据库,突发流量不能无限增加连接。",
- "decisions": [
- "进程内池、进程间总预算。",
- "事务显式结束,断线连接失效。",
- "池等待、网络等待、数据库锁等待分开监控。"
- ],
- "failureDrill": "在独立进程耗尽2条连接并触发插入回滚,观察归还恢复。",
- "acceptance": [
- "无泄漏。",
- "等待有界。",
- "坏连接不携带假事务继续执行。"
- ]
- },
- "intro": {
- "scenario": "多个商品查询同时到来时,每次都新建数据库连接会有成本,而让所有请求共用一条连接又会混用事务。我们用一个最多两条连接的小池子,观察借用、归还和借不到时的行为。",
- "startingPoint": [
- "已完成数据库第 03 课 mysql_client.py 与第 05 课事务实验;第 09 课列表样品仍可查询。",
- "在 shop-service 的 Bash 激活 .venv,按第 03 课在当前终端载入本机 MYSQL_* 配置;python -c \"from mysql_client import connect; c=connect(); print(c.open); c.close()\" 应返回真值。",
- "本课新增 db_pool.py 与三个独立脚本,逐个单独执行,不把同步脚本放进 API 的 async 路由;每个脚本会结束自己创建的池。"
- ],
- "outcome": "让多个线程复用最多两条数据库连接,验证异常写入被回滚,并看到池满时等待有上限、归还后又能查询。"
- }
- },
- {
- "id": "11",
- "group": "Python 与运行保障",
- "title": "按账号host范围拆分权限与连接配置",
- "summary": "验证日常账号可以增删查改商品却不能 ALTER 表,确认未来表的授权范围,并列出加密连接需要核对的配置。",
- "objectives": [
- "为日常商品读写单独创建账号",
- "用允许和拒绝两组操作证明权限范围",
- "从真实连接读取身份与会话设置,并理解证书配置的使用前提"
- ],
- "concepts": [
- {
- "title": "登录身份包括来源范围",
- "body": "第 1、2 步用 CURRENT_USER 确认实际匹配账号;相同用户名搭配不同 host 可以是不同身份。本机 % 只是实验便利,真实部署应选择受控网络范围与独立密码。"
- },
- {
- "title": "授权范围用成功和失败同时证明",
- "body": "第 2 步能写商品却不能 ALTER,第 3 步又能读新表,分别证明动作限制和 shop_lab.* 的对象范围。拒绝结构修改是验收目标,不是需要补权限的故障。"
- },
- {
- "title": "连接配置由每次建立连接承担",
- "body": "第 3 步的时限只影响当前会话,第 4 步列出 TLS、身份与 UTC/RC 核验。权限、加密和会话默认值分别检查,不能凭账号名推断全部正确。"
- }
- ],
- "steps": [
- {
- "title": "管理连接创建运行身份",
- "body": "在本机 Bash 运行 docker exec -e MYSQL_HISTFILE=/dev/null -it shop-mysql mysql -u root -p,交互输入本机 root 密码。将下方密码占位替换为已生成的随机值后执行一次 CREATE USER 和 GRANT,再查看两份授权;已有该账号时先查看,不重复创建。",
- "code": "CREATE USER 'shop_runtime'@'%' IDENTIFIED BY 'REPLACE_WITH_LOCAL_RANDOM_PASSWORD';\nGRANT SELECT,INSERT,UPDATE,DELETE ON shop_lab.* TO 'shop_runtime'@'%';\nSHOW GRANTS FOR 'shop_runtime'@'%';\nSHOW GRANTS FOR 'app_user'@'%';",
- "lang": "sql",
- "expected": "shop_runtime 的授权列出 shop_lab.* 上 SELECT、INSERT、UPDATE、DELETE,不含 CREATE、ALTER、DROP;app_user 仍可用于本机结构练习。",
- "explanation": "MySQL 账号由用户名与来源 host 共同确定,'shop_runtime'@'%' 是完整身份;本课 % 方便容器网络连接。GRANT 的数据库范围决定这个账号能对哪些表做哪些操作,与密码正确并不是同一件事。"
- },
- {
- "title": "以运行身份验证允许与拒绝",
- "body": "另开 Bash 用 docker exec -it shop-mysql mysql --protocol=TCP -h127.0.0.1 -P3306 -u shop_runtime -p shop_lab 连接;有本机客户端也可用 3307。确认身份后执行下方样品插入、删除和预期被拒的 ALTER,不为消除这次错误补权限。",
- "code": "SELECT CURRENT_USER(),DATABASE();\nSET SESSION time_zone='+00:00';\nINSERT INTO products(name) VALUES('db11-runtime');\nSET @runtime_id=LAST_INSERT_ID();\nDELETE FROM products WHERE id=@runtime_id;\nALTER TABLE products ADD COLUMN should_not_exist INT;\n-- 1142:ALTER command denied",
- "lang": "sql",
- "expected": "CURRENT_USER 为 shop_runtime,数据库为 shop_lab;样品可以插入并删除,ALTER 报 1142,products 中没有 should_not_exist 列。",
- "explanation": "同一次正确登录里有些操作成功、有些被拒,证明授权按动作区分。运行商品程序只需要日常读写;改结构的迁移任务使用单独维护身份,避免把常驻应用变成管理入口。"
- },
- {
- "title": "验证未来表与会话超时",
- "body": "按注释切窗口:app_user 创建 permission_probe 并插入一行,shop_runtime 查询它并设置 SELECT 时限,再执行 SLEEP(2) 与 SHOW WARNINGS。验收后由 app_user 删除本课专用表。",
- "code": "-- app_user\nCREATE TABLE permission_probe(id INT PRIMARY KEY) ENGINE=InnoDB;\nINSERT INTO permission_probe VALUES(1);\n-- shop_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",
- "expected": "shop_runtime 能读刚创建的表中 id=1,说明库级授权覆盖未来表;SLEEP 通常约 1 秒被中断,可能返回 1 或给出实际警告,按你的状态与耗时记录。",
- "explanation": "shop_lab.* 的授权比单张表广,新表也在范围里。SET SESSION max_execution_time 改的是当前连接的 SELECT 预算,不是这个账号永久拥有的所有 SQL 时限;新建连接需要重新配置。"
- },
- {
- "title": "为生产连接开启校验而不破坏练习配置",
- "body": "本机无 TLS 证书时,把下方注释代码作为生产连接检查清单阅读,继续保留本机可运行配置,不执行占位路径。若有自己环境的可信 CA 和匹配主机名,在 mysql_client.connect 的 pymysql.connect 参数中加入相应设置,改用 shop_runtime,并在新连接查询下方状态;迁移继续单独使用维护账号。",
- "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",
- "expected": "本地学习笔记明确 CA 路径、主机名匹配、证书验证、实际授权身份和会话设置各由哪里配置;具备证书的扩展实验应看到非空 Ssl_cipher 与正确 CURRENT_USER,否则不宣称加密验证通过。",
- "explanation": "ssl_verify_cert 验证证书可信,ssl_verify_identity 核对连接主机名,不能只写“使用 TLS”就忽略身份。密码或证书更换后需用新连接验证,再逐步回收旧连接;账号权限不会自动替连接设置 UTC、RC 或超时。"
- }
- ],
- "checks": [
- "运行账号CRUD可用但ALTER被拒绝。",
- "未来表权限符合数据库级授权范围。",
- "实际连接身份、TLS和UTC/RC设置均可核对。"
- ],
- "pitfalls": [
- {
- "symptom": "本机能登录、容器连接却被拒绝",
- "cause": "账号host条目和连接来源不同。",
- "fix": "核对CURRENT_USER、USER及已创建账号来源范围,不通过授予root远程权限绕过。"
- },
- {
- "symptom": "配置ssl=True就认为已验证服务器身份",
- "cause": "加密与证书链、主机名验证是不同要求。",
- "fix": "设置可信CA并启用ssl_verify_cert和ssl_verify_identity,使用证书匹配主机名。"
- }
- ],
- "exercise": {
- "task": "给只读报表建立权限,并说明PROCESS应不应该顺手授予。",
- "hint": "业务查询所需权限与全实例诊断不同。",
- "answer": "独立报表账号授予指定库/表SELECT,必要时只授予特定视图;PROCESS和performance_schema广泛诊断权限单独给运维身份。报表连接也设置时限,不能仅靠只读权限防止昂贵查询。"
- },
- "refs": [
- {
- "label": "MySQL 8.4:GRANT",
- "url": "https://dev.mysql.com/doc/refman/8.4/en/grant.html"
- },
- {
- "label": "MySQL 8.4:账号名称",
- "url": "https://dev.mysql.com/doc/refman/8.4/en/account-names.html"
- },
- {
- "label": "MySQL 8.4:加密连接",
- "url": "https://dev.mysql.com/doc/refman/8.4/en/using-encrypted-connections.html"
- },
- {
- "label": "PyMySQL:TLS参数",
- "url": "https://pymysql.readthedocs.io/en/latest/modules/connections.html"
- }
- ],
- "production": {
- "scenario": "运行配置泄漏或应用代码出错时,需要限制影响范围。",
- "decisions": [
- "迁移、运行和诊断账号分开。",
- "host匹配与网络访问共同限制。",
- "每次连接主动设置会话参数。"
- ],
- "failureDrill": "用shop_runtime尝试ALTER并验证拒绝,然后核对正常读写不受影响。",
- "acceptance": [
- "非root运行。",
- "拒绝错误有明确原因。",
- "生产TLS验证不被省略。"
- ]
- },
- "intro": {
- "scenario": "商品程序需要读写商品,却没有理由随意改表结构。我们另外创建一个只做日常操作的账号,先看它能写商品,再让一次 ALTER 被明确拒绝。",
- "startingPoint": [
- "已完成数据库第 10 课;shop_lab 表结构仍可用,知道 Python 连接函数在哪里设置地址与会话参数。",
- "准备本机 root 管理窗口、app_user 结构维护窗口和稍后新增的 shop_runtime 窗口;每个窗口先用 CURRENT_USER() 和 DATABASE() 核对身份与目标。",
- "本课使用独立本机实验账号,不修改现有线上账号。提前在密码管理器生成一个本机随机密码,用于替换 CREATE USER 中的占位文本。"
- ],
- "outcome": "验证日常账号可以增删查改商品却不能 ALTER 表,确认未来表的授权范围,并列出加密连接需要核对的配置。"
- }
- },
- {
- "id": "12",
- "group": "Python 与运行保障",
- "title": "用mysqldump恢复并验收MySQL结构变更",
- "summary": "生成商品库备份并恢复到 shop_restore,核对数据与关系无误,再给源库增加可空字段并验证旧查询仍能运行。",
- "objectives": [
- "生成可校验的逻辑备份并记录源库数据摘要",
- "恢复到独立数据库并检查数据、约束与新写入",
- "执行兼容加列,区分已验证的逻辑恢复与尚需准备的时间点恢复"
- ],
- "concepts": [
- {
- "title": "备份文件存在不是恢复成功的证据",
- "body": "第 2 步校验文件完整,第 4 步恢复到独立库并比较数据、关系与约束,第 5 步再验证新写入。single-transaction 适用 InnoDB,也需要避免同期结构变更。"
- },
- {
- "title": "恢复目标与账号要单独准备",
- "body": "第 3、4 步明确使用 shop_restore,先 SELECT DATABASE 再导入。逻辑单库备份不包含完整账号体系,不能假定恢复文件就自动恢复全部授权。"
- },
- {
- "title": "兼容迁移不依赖回滚 DDL",
- "body": "第 5 步新增可空列后旧查询仍可运行,证明兼容行为;INSTANT 仍有元数据锁与隐式提交边界,不支持时需评估,而非盲目换算法继续。"
- },
- {
- "title": "时间点恢复需要连续证据",
- "body": "第 6 步只读日志状态并记录缺口;备份一致坐标或 GTID、连续 binlog、明确停止点与隔离恢复验收缺一不可。本课实际完成的是独立库逻辑恢复,不把准备清单冒充已通过的时间点恢复。"
- }
- ],
- "steps": [
- {
- "title": "准备客户端凭据文件",
- "body": "在编辑器创建 shop-service/.mysql-client.cnf,保存下方 INI;把密码占位替换为第 01 课 .mysql.env 的 MYSQL_PASSWORD,不是 root 密码。该文件已在 .gitignore 内,下一步会限制文件权限。内容是配置文件,不在 Bash 执行。",
- "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",
- "expected": "文件包含 client 段及本机 127.0.0.1、3307、app_user、真实练习密码和 utf8mb4;没有把占位文本当成实际密码。",
- "explanation": "MySQL 客户端可以从受保护的配置文件读凭据,避免将密码写进每条命令参数。它不指定固定恢复目标,后面每次命令明确给 shop_lab 或 shop_restore,便于在执行前核对。"
- },
- {
- "title": "用凭据文件生成逻辑备份",
- "body": "在 shop-service 的 Bash 中逐条执行下方命令。确认 mysqldump --version 为 8.4;dump 命令成功退出后才继续计算摘要和源数据统计。若写成 Shell 脚本,先 set -e。备份期间保持练习写入与 DDL 暂停。",
- "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 shop_lab --result-file=backups/shop_lab.sql\n# 前一命令成功退出后再继续;shell脚本应开启set -e。\nsha256sum backups/shop_lab.sql > backups/shop_lab.sql.sha256\nmysql --defaults-extra-file=.mysql-client.cnf -N -B shop_lab \\\n -e 'SELECT COUNT(*),MIN(id),MAX(id),COALESCE(SUM(is_active=1),0),COALESCE(SUM(price_cents),0) FROM products' > backups/source-products.txt\nsha256sum -c backups/shop_lab.sql.sha256",
- "lang": "bash",
- "expected": "backups 中出现 shop_lab.sql、校验文件和 source-products.txt;sha256sum -c 输出 OK。dump 失败时应停在错误处,不把空文件或半份文件当作有效备份。",
- "explanation": "mysqldump 生成可重放的 SQL,single-transaction 为 InnoDB 提供一致读取,quick 逐批读取而不一次装满内存。它不保证非事务表或同时发生 DDL 的一致性;校验和检查文件是否变化,源摘要记录商品数、编号范围、上架数与价格分总和。"
- },
- {
- "title": "创建独立恢复库并授予恢复身份",
- "body": "只在本机 root 管理连接执行下方创建 shop_restore 与授权。若已存在这个库,先核对是否有旧验收数据,不继续向非空目标重复导入;本课不删除旧库或覆盖 shop_lab。",
- "code": "CREATE DATABASE shop_restore CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;\nGRANT ALL PRIVILEGES ON shop_restore.* TO 'app_user'@'%';\n-- 只针对本机隔离恢复库,恢复完成后按运行账号权限方案重新授权。",
- "lang": "sql",
- "expected": "SHOW DATABASES LIKE 'shop_restore'; 能找到独立目标;app_user 获得仅在这个本机恢复库内重建对象的权限。",
- "explanation": "本课 dump 未使用 --databases,因此没有把 USE shop_lab 固定进导出目标,后面可以显式导入 shop_restore。单库逻辑备份不会自动带上完整账号与授权,目标权限需要单独准备。"
- },
- {
- "title": "恢复后比较行数、关系与自增",
- "body": "回到 Bash,先执行第一条 SELECT DATABASE() 并确认输出 shop_restore,才运行重定向导入;不加 --force。继续生成恢复摘要、diff 与两个方向的孤儿检查,最后查看两张表的建表语句。",
- "code": "mysql --defaults-extra-file=.mysql-client.cnf shop_restore -e 'SELECT DATABASE();'\nmysql --defaults-extra-file=.mysql-client.cnf shop_restore < backups/shop_lab.sql\nmysql --defaults-extra-file=.mysql-client.cnf -N -B shop_restore \\\n -e 'SELECT COUNT(*),MIN(id),MAX(id),COALESCE(SUM(is_active=1),0),COALESCE(SUM(price_cents),0) FROM products' > backups/restored-products.txt\ndiff backups/source-products.txt backups/restored-products.txt\nmysql --defaults-extra-file=.mysql-client.cnf shop_restore \\\n -e 'SELECT COUNT(*) FROM product_categories pc LEFT JOIN products p ON p.id=pc.product_id WHERE p.id IS NULL; SELECT COUNT(*) FROM product_categories pc LEFT JOIN categories c ON c.id=pc.category_id WHERE c.id IS NULL; SHOW CREATE TABLE products; SHOW CREATE TABLE product_categories;'",
- "lang": "bash",
- "expected": "导入没有错误,diff 没有输出,两个孤儿计数都是 0;SHOW CREATE TABLE 中主键、CHECK、商品级联与分类限制删除规则都在。若任何一项不符,先保留结果排查,不直接宣布恢复成功。",
- "explanation": "只数总行数可能漏掉价格错误或断裂关系,所以同时比较多个数据摘要与外键结构。源库必须在这段对照期间暂停写入,否则新变动会让简单 diff 失去同一时间点的意义;--force 会跳过导入错误,不能用于本次验收。"
- },
- {
- "title": "验证恢复库新写入与源库兼容迁移",
- "body": "进入 mysql> 后先 USE shop_restore,按下方写入样品并 ROLLBACK,确认自动编号还能生成。随后明确 USE shop_lab 并 SELECT DATABASE(),只首次执行 archived_at 加列,再分别运行旧查询与新查询;不要包 BEGIN 期待回滚 DDL。",
- "code": "USE shop_restore;\nSET SESSION time_zone='+00:00';\nSTART TRANSACTION;\nINSERT INTO products(name) VALUES('db12-restore-probe');\nSELECT LAST_INSERT_ID();\nROLLBACK;\n\nUSE shop_lab;\nSELECT DATABASE();\nSET SESSION lock_wait_timeout=3;\nALTER TABLE products ADD COLUMN archived_at DATETIME(6) NULL, ALGORITHM=INSTANT;\nSELECT id,name,is_active FROM products ORDER BY id DESC LIMIT 3;\nSELECT id,name FROM products WHERE archived_at IS NULL ORDER BY id DESC LIMIT 3;\n-- 旧查询和新查询都可工作;DDL不能用ROLLBACK撤销。",
- "lang": "sql",
- "expected": "恢复库能生成新编号且试写因回滚不保留;源库新增可为 NULL 的 archived_at,原有查询和按 archived_at IS NULL 的新查询都返回商品。INSTANT 不支持时应报错停下,不静默改成重建表。",
- "explanation": "恢复不只要能读旧数据,还要验证后续写入。新增可空列给旧代码留出兼容空间;即使 ALGORITHM=INSTANT 也可能需要元数据锁,DDL 隐式提交,ROLLBACK 不能撤销这次加列。"
- },
- {
- "title": "记录binlog恢复演练所需证据",
- "body": "在本机管理窗口只读执行下方日志变量查询,将实际值记进恢复笔记。另列出本次备份时间、逻辑恢复用时、已通过的验收与尚缺的连续日志/一致坐标;没有的证据写“未验证”,不编造文件名或 GTID,也不执行日志重放。",
- "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",
- "expected": "笔记明确区分:逻辑恢复已经按前几步验收;要恢复到误操作前某时刻,还缺或已具备哪些日志、坐标和隔离实例。查询到日志开启并不自动等于具备完整时间点恢复能力。",
- "explanation": "binlog 记录备份之后的变化,时间点恢复要把一致备份与连续日志衔接到明确停止点。RPO 是可接受的数据丢失时间窗口;每天一份 dump 本身不能保证只丢 15 分钟。连续保留、异地副本和隔离演练都需要实际证据。"
- }
- ],
- "checks": [
- "dump退出成功、校验和正确,独立恢复库行数、上架数量及价格分总和与静止源库一致。",
- "外键无孤儿,恢复库新插入自增正常且能回滚DML。",
- "新增列前后旧查询兼容,明确DDL不能由ROLLBACK撤销。"
- ],
- "pitfalls": [
- {
- "symptom": "恢复导入竟修改原库",
- "cause": "dump包含固定CREATE DATABASE/USE,或连接目标未核对。",
- "fix": "本课不加--databases,导入前核对SQL内容与SELECT DATABASE;生产先隔离恢复。"
- },
- {
- "symptom": "每日dump被认为能恢复到任意一分钟",
- "cause": "没有完整binlog和一致起点。",
- "fix": "规划备份坐标/GTID、日志保留与归档,并实际演练恢复停止点。"
- },
- {
- "symptom": "INSTANT加列仍等待或不支持",
- "cause": "元数据锁冲突或表/操作条件不满足。",
- "fix": "短lock_wait_timeout失败后查阻塞,确认版本和限制,不盲目降级成大表复制。"
- }
- ],
- "exercise": {
- "task": "业务最多丢15分钟数据,只有每日mysqldump是否足够?还需验收什么?",
- "hint": "把备份间隔、binlog归档延迟、恢复速度分别对齐RPO/RTO。",
- "answer": "不够。需要与一致性备份关联的连续binlog或受管时间点恢复,监控归档延迟与保留期,保护异地副本;实际恢复到指定停止点并测量RTO,同时重建权限和验收业务读写。"
- },
- "refs": [
- {
- "label": "MySQL 8.4:mysqldump",
- "url": "https://dev.mysql.com/doc/refman/8.4/en/mysqldump.html"
- },
- {
- "label": "MySQL 8.4:时间点恢复",
- "url": "https://dev.mysql.com/doc/refman/8.4/en/point-in-time-recovery.html"
- },
- {
- "label": "MySQL 8.4:在线DDL操作",
- "url": "https://dev.mysql.com/doc/refman/8.4/en/innodb-online-ddl-operations.html"
- },
- {
- "label": "MySQL 8.4:隐式提交",
- "url": "https://dev.mysql.com/doc/refman/8.4/en/implicit-commit.html"
- }
- ],
- "production": {
- "scenario": "结构发布前需要证明可恢复,且新旧应用能在过渡期并存。",
- "decisions": [
- "恢复库与源库隔离。",
- "备份验证覆盖数据、外键、自增与权限。",
- "DDL单独执行,明确兼容与恢复方案。"
- ],
- "failureDrill": "先逻辑恢复到shop_restore,再在shop_lab新增可空列;时间点恢复需真实日志与明确停止点后单独执行。",
- "acceptance": [
- "有实际恢复证据后才标通过。",
- "未把本机耗时推定为线上SLA。",
- "不以删库或删卷作为常规回退。"
- ]
- },
- "intro": {
- "scenario": "商品表结构与数据都在了,修改结构之前怎样证明备份真的能恢复?我们把商品目录恢复到另一个库,再比较数量、价格和分类关系,最后做一次兼容旧查询的加列。",
- "startingPoint": [
- "已完成数据库第 11 课,保留 shop_lab 的 products、categories、product_categories;使用 app_user 做本机备份和恢复,root 只创建独立目标库并授权。",
- "本课 Bash 在 shop-service 执行,Windows 使用 WSL/Linux Bash;需要能运行 mysql --version 与 mysqldump --version 的 MySQL 8.4 客户端,不能把仅有 docker exec 的入口当作已安装本机客户端。尚未安装时先按文末官方客户端文档安装并完成这两项检查。",
- "暂停本次练习的商品写入和结构修改,直到源库与恢复库对照完成;停止其它练习脚本并结束旧事务。shop_restore 必须是独立验收库,已有同名库时先确认用途,不覆盖已有业务数据。",
- "本课将创建受保护的 backups 目录。时间点恢复的最后一步只整理证据,不在现有业务库直接重放日志。"
- ],
- "outcome": "生成商品库备份并恢复到 shop_restore,核对数据与关系无误,再给源库增加可空字段并验证旧查询仍能运行。"
- }
- }
- ]
|