欢迎光临
我们一直在努力

MySQL 高级 SQL 语句

《MySQL 高级 SQL 语句》

面向读者:已掌握 CREATE / INSERT / SELECT / UPDATE / DELETE 基础语法,需要把 SQL 直接用于生产运维场景(报表查询、数据迁移、批量更新、性能排查、搜索与归档)的 Linux 运维 / DBA。

文档依据:课程讲义 05.MySQL高级SQL语句.pdf(第 5 章,共 44 页,含 5.1 进阶查询、5.2 数据库函数、5.3 存储过程)。
讲义未覆盖的主题(窗口函数 / CTE / 视图 / 触发器 / 事务与锁 / 索引与执行计划 / JSON 与全文检索)依据 MySQL 8.0 / 8.4 LTS 官方标准实践补充,并在第 14 章逐条标注。


文档信息

项目内容
适用版本 MySQL 8.0+(窗口函数、CTE、EXPLAIN ANALYZE 的最低要求);推荐 8.4 LTS
存储引擎 全文默认 InnoDB(全文检索、事务、行锁均以 InnoDB 为前提)
操作系统 CentOS / RHEL / Rocky / AlmaLinux / CentOS Stream(bash 环境)
演示库 demo(第 0 章一键初始化脚本 mysql-advanced-init.sh 创建)
字符序 utf8mb4 + utf8mb4_0900_ai_ci(8.0 默认;5.7 为 utf8mb4_general_ci)
示例校验 所有 Shell 代码块已通过 bash -n 静态校验;SQL 按 8.0/8.4 语法逐条人工核验(详见第 14 章)
配套文件 mysql-advanced-init.sh(环境初始化)、validation_report.md(验证报告)

阅读约定

  • 每个知识点统一采用两节固定结构:N.1 语法说明与适用场景 + N.2 可执行 Shell 示例。
  • 所有 Shell 示例开头都带 3 行连接前置,保证任意一段都能单独复制运行。
  • 示例中的行内注释标注每一步的作用与目的,注释前带 ① ② ③ 编号的表示关键步骤。
  • ⚠ 标记表示生产环境的高频踩坑点,务必先看再跑。

第 0 章 实验环境与数据准备

0.1 语法说明与适用场景

(1)版本能力矩阵(决定"能不能用")
特性最低版本说明
子查询 / 连接查询 / 内置函数 / 存储过程 5.0+ 讲义第 5 章全部内容
视图 / 触发器 / 事务 5.0+(InnoDB 事务 5.5+ 默认引擎)
performance_schema.data_locks 8.0+ 8.0 之前查 information_schema.INNODB_LOCKS
窗口函数、CTE(含递归) 8.0+ 5.7 完全不可用,这是 8.0 最有价值的升级点
函数索引、降序索引、不可见索引、直方图 8.0+(函数索引 8.0.13+)
EXPLAIN ANALYZE 8.0.18+ 真实执行耗时,8.0.18 之前只能用 EXPLAIN 估算
Hash Join 8.0.18+ 无索引的等值连接场景大幅提速
JSON_TABLE 8.0+ JSON 数组展开为关系行
命名窗口 WINDOW w AS (…) 8.0.22+ 多个窗口函数复用同一窗口定义

判定方法(运维必记):SELECT @@version; 拿到主版本号后,上表未覆盖的特性一律先在测试库验证,再上生产。

(2)⚠ MySQL 8.4 LTS 相对旧版的破坏性变更

本课程环境为 MySQL 8.4.11,以下几个写法在 8.4 上直接报错,讲义与旧文档仍在用,必须替换:

旧写法(5.7 / 8.0 早期)8.4 正确写法后果
SHOW MASTER STATUS SHOW BINARY LOG STATUS 8.4 已彻底移除,报 ERROR 1064
SHOW SLAVE STATUS SHOW REPLICA STATUS 同上(8.4 移除)
information_schema.INNODB_LOCKS performance_schema.data_locks 8.4 表已删除,查询返回空
information_schema.INNODB_LOCK_WAITS performance_schema.data_lock_waits 同上
explicit_defaults_for_timestamp=1 删除该参数 8.4 保留会报 unknown variable,mysqld 启动失败
mysql_native_password 认证 caching_sha2_password 8.4 默认禁用该插件,老客户端连不上
SELECT … LOCK IN SHARE MODE SELECT … FOR SHARE 旧写法仍兼容,但新代码应统一用 FOR SHARE
(3)连接入口:只用 ~/.my.cnf 一种方式

⚠ 关键坑:MySQL 客户端读配置的优先级是 ~/.mylogin.cnf(mysql_config_editor 生成)高于 ~/.my.cnf。
如果之前跑过 mysql_config_editor set –login-path=client 但直接回车没输密码,.mylogin.cnf 里会存一个空密码,把 ~/.my.cnf 的正确密码顶掉,表现为每次都 using password: NO 登录失败。

对策:课程与生产统一只用 ~/.my.cnf,不并行使用 mysql_config_editor。若已出现该问题:

rm -f "$HOME/.mylogin.cnf" # ① 删除优先级更高但内容为空的登录文件
printf '[client]\\nuser=root\\npassword=你的密码\\n' > "$HOME/.my.cnf" # ② 重建干净配置
chmod 600 "$HOME/.my.cnf" # ③ 权限必须 600,否则客户端警告并忽略

(4)演示数据集设计
表名用途覆盖章节
player 玩家等级表(沿用讲义结构) 1、2、4、5
a_player / b_player 连接查询对照表(沿用讲义) 3
emp 员工薪资表 5、6、7、8
orders 订单流水表(5000 行) 6、9、10、11、12
dept 部门层级树 6(递归 CTE)
product 商品表(含 JSON 列) 13
article 文章表(中文全文检索) 13
orders_audit 订单变更审计表 10(触发器)

0.2 可执行 Shell 示例

示例 0-1:环境自检 + 建立免密连接 + 一键初始化

# ══════════════════════════════════════════════════════════════════════
# 示例 0-1 环境自检与免密连接配置(所有后续示例的前置条件)
# 场景:新拿到一台 MySQL 服务器,先确认版本能力、建立可脚本化的连接入口
# ══════════════════════════════════════════════════════════════════════

# ── 连接前置:有 ~/.my.cnf 就免密,否则退回交互输密码 ──────────────────
if [[ -r "$HOME/.my.cnf" ]]; then
MYSQL=(mysql –defaults-extra-file="$HOME/.my.cnf") # 数组形式:路径含空格也不会被拆词
else
MYSQL=(mysql -u root -p) # 交互输密码,不把明文写进 history
fi
DB='demo' # 演示库名,可用 DB=other 覆盖

# ① 确认客户端存在;没有 mysql 客户端后面全跑不了
command -v mysql >/dev/null 2>&1 || { echo "未找到 mysql 客户端,请先安装 mysql-community-client"; exit 1; }

# ② 版本能力判定:窗口函数/CTE 需要 8.0+,5.7 直接劝退
"${MYSQL[@]}" -N -B -e "SELECT CONCAT('版本=', @@version, ' 引擎=', @@default_storage_engine,
' 隔离级别=', @@transaction_isolation, ' binlog=', @@log_bin);"

# ③ 打印 8.4 专属状态(旧版执行会报错,用 || true 兜底不影响后续)
echo "— binlog 位点(8.4 用 SHOW BINARY LOG STATUS,8.0 及以前是 SHOW MASTER STATUS)—"
"${MYSQL[@]}" -e "SHOW BINARY LOG STATUS\\G" 2>/dev/null || \\
"${MYSQL[@]}" -e "SHOW MASTER STATUS\\G" 2>/dev/null || echo "(未开启 binlog,跳过)"

# ④ 建立免密配置(幂等:已存在则跳过,不覆盖用户已有配置)
if [[ ! -r "$HOME/.my.cnf" ]]; then
read -rsp "请输入 MySQL root 密码(输入不回显):" MYSQL_PWD_INPUT; echo
printf '[client]\\nuser=root\\npassword=%s\\n' "$MYSQL_PWD_INPUT" > "$HOME/.my.cnf"
chmod 600 "$HOME/.my.cnf" # 权限必须 600,否则 mysql 客户端会忽略该文件
echo "已写入 $HOME/.my.cnf(权限 600)"
fi

# ⑤ 删除可能覆盖密码的 .mylogin.cnf(空密码 bug 的根源)
[[ -f "$HOME/.mylogin.cnf" ]] && { echo "发现 .mylogin.cnf,为避免空密码覆盖,建议删除:"; ls -l "$HOME/.mylogin.cnf"; }

# ⑥ 一键初始化演示库与数据(脚本见附录 B)
# 先确认脚本位置:默认取当前目录,也可用 LAB_DIR=实际路径 显式指定
LAB_DIR="${LAB_DIR:-$(pwd)}"
if [[ ! -f "${LAB_DIR}/mysql-advanced-init.sh" ]]; then
echo "未找到 mysql-advanced-init.sh(当前查找目录:${LAB_DIR})"
echo "请先 cd 到脚本所在目录,或执行:LAB_DIR=/实际路径 bash 本脚本"
exit 1
fi
bash "${LAB_DIR}/mysql-advanced-init.sh" –all

说明:第 ⑥ 步依赖同目录的 mysql-advanced-init.sh。若尚未下载该脚本,可跳过,后续章节示例会在用到表时给出建表语句。


第 1 章 排序、分组与分页(ORDER BY / GROUP BY / LIMIT)

1.1 语法说明与适用场景

(1)核心语法

— ① 排序:多字段按书写顺序决定优先级,A 相同时才按 B 排
SELECT col1, col2 FROM tbl WHERE cond ORDER BY col1 [ASC|DESC], col2 [ASC|DESC];

— ② 分组:SELECT 列表中只能出现「分组列」或「聚合函数」的包裹列
SELECT group_col, AGG(col) FROM tbl WHERE cond GROUP BY group_col [WITH ROLLUP] HAVING agg_cond;

— ③ 分页:offset 从 0 开始;两种写法等价,推荐第二种(语义更清晰)
SELECT col FROM tbl ORDER BY col LIMIT offset, size;
SELECT col FROM tbl ORDER BY col LIMIT size OFFSET offset;

(2)参数与规则要点
语法项规则生产注意事项
ORDER BY A, B DESC A 默认升序,B 降序 每个字段的排序方向独立,只在字段后写 DESC 才能对该字段生效
ASC 默认,可省略 显式写出来可读性更好,团队协作建议保留
GROUP BY + ONLY_FULL_GROUP_BY 8.0 默认开启 SELECT 非分组裸列会报 ERROR 1055。要么加进 GROUP BY,要么用 ANY_VALUE(col) 包一层
HAVING 对聚合后的结果过滤 WHERE 在分组前过滤行(能用索引,优先用),HAVING 在分组后过滤组(无法用索引)
WITH ROLLUP 追加一行小计/合计 与 ORDER BY 同时使用时,MySQL 先 ROLLUP 再排序,可能把合计行排到中间,建议用 GROUPING(col) 判断(8.0+)
LIMIT offset, size offset 从 0 开始 深分页(offset 上万)会扫描并丢弃前 N 行,必须优化
(3)适用场景对比
业务需求推荐写法优势注意事项
排行榜 / TopN ORDER BY score DESC LIMIT 10 配合 (rank_col) 索引可完全避免排序 排序字段无索引会触发 Using filesort
分组统计报表 GROUP BY dept + COUNT/SUM 一次扫描出结果 分组基数大时内存临时表可能落盘
带小计的财务报表 GROUP BY … WITH ROLLUP 一条 SQL 出明细+合计,免多次查询 需处理合计行的 NULL 分组键
Web 端分页 LIMIT size OFFSET offset 简单直观 仅适合浅分页(offset < 1 万)
深分页 / 数据导出 游标分页:WHERE id > last_id ORDER BY id LIMIT size 恒定耗时,不随页码劣化 要求排序键唯一且连续可比;不支持跳页
深分页且必须跳页 延迟关联:先 LIMIT 出主键,再回表 回表行数从 offset+size 降到 size 见示例 1-2 ⑤
(4)⚠ 高频踩坑
  • LIMIT 不保证结果稳定:没有 ORDER BY 时,行序由执行计划决定,同一条 SQL 两次执行可能返回不同行。分页必须带唯一排序键。
  • 深分页性能悬崖:LIMIT 100000, 20 实际要扫描 100020 行再丢弃前 100000 行。
  • GROUP BY 的隐式排序已取消:MySQL 5.7 及以前 GROUP BY 会顺带按分组列排序,8.0 不再隐式排序。老代码依赖这个特性的,升级后结果顺序会变,必须显式补 ORDER BY。
  • 1.2 可执行 Shell 示例

    示例 1-1:等级排行榜、分组统计与 Web 分页

    # ══════════════════════════════════════════════════════════════════════
    # 示例 1-1 报表查询:TopN / 分组统计 / 分页
    # 场景:运营要看「等级≥45 的高价值玩家分布」,并按页导出给客服
    # ══════════════════════════════════════════════════════════════════════

    # ── 连接前置 ──────────────────────────────────────────────────────────
    if [[ -r "$HOME/.my.cnf" ]]; then
    MYSQL=(mysql –defaults-extra-file="$HOME/.my.cnf")
    else
    MYSQL=(mysql -u root -p)
    fi
    DB='demo'
    q() { "${MYSQL[@]}" -D "$DB" -t -e "$1"; } # -t:带边框的可读输出,给人看
    qs() { "${MYSQL[@]}" -D "$DB" -N -B -e "$1"; } # -N -B:无表头纯数据,给脚本解析

    # ① TopN 排行榜:等级降序、同级按 id 降序(多字段排序,优先级按书写顺序)
    q "SELECT id, name, level FROM player WHERE level >= 45 ORDER BY level DESC, id DESC LIMIT 10;"

    # ② 分组统计:每个等级有多少人,并按人数降序(ORDER BY 可直接引用聚合结果)
    q "SELECT level, COUNT(*) AS cnt FROM player WHERE level >= 45 GROUP BY level ORDER BY cnt DESC;"

    # ③ 分组 + 小计:WITH ROLLUP 追加合计行;IFNULL 把合计行的 NULL 分组键美化
    q "SELECT IFNULL(level,'合计') AS lvl, COUNT(*) AS cnt
    FROM player GROUP BY level WITH ROLLUP ORDER BY lvl IS NULL, lvl DESC;"

    # ④ Web 分页:第 3 页,每页 10 条(offset = (page-1)*size = 20)
    PAGE=3; SIZE=10; OFFSET=$(( (PAGE – 1) * SIZE )) # 算术展开:offset 从 0 开始
    q "SELECT id, name, level FROM player ORDER BY id LIMIT ${SIZE} OFFSET ${OFFSET};"

    # ⑤ 分页导出为 CSV(运维交付常用):把纯数据重定向到文件
    OUT="/tmp/player_page${PAGE}.csv"
    qs "SELECT id, name, level FROM player ORDER BY id LIMIT ${SIZE} OFFSET ${OFFSET};" | tr '\\t' ',' > "$OUT"
    echo "已导出:$OUT($(wc -l < "$OUT") 行)"

    示例 1-2:深分页性能排查与两种优化方案对比

    # ══════════════════════════════════════════════════════════════════════
    # 示例 1-2 性能排查:深分页劣化定位 + 延迟关联 / 游标分页优化
    # 场景:运营反馈「订单列表翻到第 500 页就卡死」,用 EXPLAIN 定位并给出两种优化
    # ══════════════════════════════════════════════════════════════════════

    # ── 连接前置 ──────────────────────────────────────────────────────────
    if [[ -r "$HOME/.my.cnf" ]]; then
    MYSQL=(mysql –defaults-extra-file="$HOME/.my.cnf")
    else
    MYSQL=(mysql -u root -p)
    fi
    DB='demo'
    q() { "${MYSQL[@]}" -D "$DB" -t -e "$1"; }

    # ① 复现问题:offset=4000 的深分页,看 EXPLAIN 的 rows 列(预估扫描行数)
    q "EXPLAIN SELECT order_id, user_id, amount FROM orders ORDER BY order_id LIMIT 4000, 20\\G"

    # ② 方案 A —— 延迟关联(Deferred Join):先用覆盖索引只查主键,再回表取完整行
    # 原理:子查询只走 idx 索引不回表,外层用主键 IN/ JOIN 精确取 20 行,回表次数从 4020 降到 20
    q "EXPLAIN SELECT o.order_id, o.user_id, o.amount
    FROM orders o
    INNER JOIN (SELECT order_id FROM orders ORDER BY order_id LIMIT 4000, 20) t
    ON o.order_id = t.order_id\\G"

    # ③ 方案 B —— 游标分页(推荐用于「下一页」式场景,不支持跳页)
    # 原理:把 offset 换成 WHERE 条件,直接定位到索引位置,扫描行数恒定为 size
    LAST_ID=4000 # 上一页最后一条的 order_id,由前端传回
    q "EXPLAIN SELECT order_id, user_id, amount FROM orders
    WHERE order_id > ${LAST_ID} ORDER BY order_id LIMIT 20\\G"

    # ④ 用 EXPLAIN ANALYZE 看真实耗时(8.0.18+;5.7/8.0.17 及以下不支持,会报语法错)
    q "EXPLAIN ANALYZE SELECT order_id, user_id, amount FROM orders ORDER BY order_id LIMIT 4000, 20\\G"

    # ⑤ 定量对比:三种写法的预估扫描行数,一眼看出差距
    "${MYSQL[@]}" -D "$DB" -N -B -e "
    SELECT '原始深分页' AS 方案, rows_scanned FROM (SELECT 4020 AS rows_scanned) x
    UNION ALL SELECT '延迟关联', 4020
    UNION ALL SELECT '游标分页', 20;"
    >/dev/null 2>&1
    echo "结论:优先让研发改成游标分页;必须跳页时用延迟关联兜底。"


    第 2 章 子查询与复杂查询

    2.1 语法说明与适用场景

    (1)核心语法

    — ① 标量子查询:返回单行单列,可用于 SELECT 列表 / WHERE 比较
    SELECT name FROM player WHERE level = (SELECT MAX(level) FROM player);

    — ② 列子查询:返回单列多行,配合 IN / NOT IN / ANY / ALL
    SELECT col FROM tbl WHERE col [NOT] IN (SELECT col FROM tbl2 WHERE ...);
    SELECT col FROM tbl WHERE col > ANY (SELECT col FROM tbl2); — 大于子集中的任意一个(> 最小值)
    SELECT col FROM tbl WHERE col > ALL (SELECT col FROM tbl2); — 大于子集中的所有值(> 最大值)

    — ③ EXISTS:判断子查询是否有返回行,只关心「有没有」不关心「是什么」
    SELECT col FROM tbl t WHERE [NOT] EXISTS (SELECT 1 FROM tbl2 WHERE tbl2.id = t.id);

    — ④ 派生表(FROM 子查询):MySQL 强制要求派生表必须有别名,否则 ERROR 1248
    SELECT * FROM (SELECT col FROM tbl) AS derived WHERE ...;

    (2)IN vs EXISTS vs JOIN 选型对比
    写法执行特点最佳场景注意事项
    IN (子查询) 8.0 优化器会自动改写为 semi-join(半连接) 子查询结果集小时 结果集大会物化成临时表,内存吃紧
    EXISTS 对外表逐行探测,命中即短路 外表小、内表大且有索引时 相关子查询无法先执行,依赖 tbl2.id 上的索引
    JOIN 8.0.18+ 无索引等值连接会走 Hash Join 需要取内表列,或 IN 被优化器误判时 一对多会放大行数,须配合 DISTINCT 或先聚合

    8.0 的自动改写:MySQL 8.0 对 IN (SELECT …) 支持 semijoin / materialization / exists 三种策略并由优化器基于代价选择,因此大多数情况下 IN 和 EXISTS 性能已趋同。真正需要人工干预的是 NOT IN 与 NOT EXISTS。

    (3)DML 中的子查询与 ERROR 1093

    — ❌ 错误:MySQL 不允许在同一条语句里,既 UPDATE 目标表又从该表 SELECT
    UPDATE tmp SET level = level – 7 WHERE id IN (SELECT id FROM tmp WHERE level >= 47);
    — ERROR 1093 (HY000): You can't specify target table 'tmp' for update in FROM clause

    — ✅ 正确:把子查询再包一层派生表,MySQL 会先物化成临时表,绕开限制
    UPDATE tmp SET level = level – 7
    WHERE id IN (SELECT a.id FROM (SELECT id FROM tmp WHERE level >= 47) a);

    (4)⚠ 高频踩坑
  • NOT IN 遇 NULL 返回空集(最危险的坑):
    WHERE id NOT IN (1, 2, NULL) 等价于 id <> 1 AND id <> 2 AND id <> NULL,而 id <> NULL 结果是 UNKNOWN,整个条件永不为真 → 返回 0 行,数据被静默吞掉。
    对策:① 子查询里加 WHERE col IS NOT NULL;② 改用 NOT EXISTS(NOT EXISTS 不受 NULL 影响,语义天然正确)。
  • 派生表必须带别名:SELECT * FROM (SELECT 1) 报 ERROR 1248: Every derived table must have its own alias。
  • 相关子查询的性能:子查询里引用了外层列(WHERE t2.id = t1.id),会导致外层每行触发一次子查询,必须保证 t2.id 有索引。
  • (5)适用场景
    场景推荐写法
    找出"没有任何订单的用户" NOT EXISTS(NULL 安全,且能用上索引)
    找出"等级高于平均值的人" 标量子查询 (SELECT AVG(level) …)
    批量 UPDATE / DELETE 前先圈定 ID 派生表包一层,绕开 ERROR 1093
    行转列报表 派生表 + GROUP BY

    2.2 可执行 Shell 示例

    示例 2-1:NOT IN 的 NULL 陷阱复现与修复

    # ══════════════════════════════════════════════════════════════════════
    # 示例 2-1 数据正确性排查:NOT IN 遇 NULL 静默返回空集
    # 场景:运维写了「清理从未下过单的用户」脚本,执行后 0 行受影响,疑似漏数
    # ══════════════════════════════════════════════════════════════════════

    # ── 连接前置 ──────────────────────────────────────────────────────────
    if [[ -r "$HOME/.my.cnf" ]]; then
    MYSQL=(mysql –defaults-extra-file="$HOME/.my.cnf")
    else
    MYSQL=(mysql -u root -p)
    fi
    DB='demo'
    q() { "${MYSQL[@]}" -D "$DB" -t -e "$1"; }

    # ① 构造现场:orders 表里插入一条 user_id 为 NULL 的脏数据(真实业务很常见:未登录下单)
    "${MYSQL[@]}" -D "$DB" -e "INSERT INTO orders (user_id, amount, status) VALUES (NULL, 9.90, 0);"

    # ② 复现问题:NOT IN 版本返回 0 行 —— 因为子查询结果里有 NULL,条件整体为 UNKNOWN
    q "SELECT COUNT(*) AS not_in_结果行数 FROM player
    WHERE id NOT IN (SELECT user_id FROM orders);"

    # ③ 定位原因:子查询确实返回了 NULL
    q "SELECT COUNT(*) AS 总行数, SUM(user_id IS NULL) AS NULL行数 FROM orders;"

    # ④ 修复 A:子查询里显式过滤 NULL(最直接的改法)
    q "SELECT COUNT(*) AS 修复后行数 FROM player
    WHERE id NOT IN (SELECT user_id FROM orders WHERE user_id IS NOT NULL);"

    # ⑤ 修复 B(推荐):改用 NOT EXISTS —— 天然 NULL 安全,且命中 orders.user_id 索引
    q "SELECT COUNT(*) AS not_exists_结果行数 FROM player p
    WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.user_id = p.id);"

    # ⑥ 用 EXPLAIN 对比:NOT EXISTS 走的是 index/subquery,NOT IN 可能物化成临时表
    q "EXPLAIN SELECT COUNT(*) FROM player p
    WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.user_id = p.id)\\G"

    # ⑦ 清理现场:删掉刚插入的脏数据
    "${MYSQL[@]}" -D "$DB" -e "DELETE FROM orders WHERE user_id IS NULL AND amount = 9.90;"

    示例 2-2:批量更新绕开 ERROR 1093 + 子查询改写为 JOIN

    # ══════════════════════════════════════════════════════════════════════
    # 示例 2-2 批量更新:ERROR 1093 绕过 + IN 改 JOIN 的性能对比
    # 场景:把「累计消费低于 100 元」的订单统一置为已取消,数据量 5000 行
    # ══════════════════════════════════════════════════════════════════════

    # ── 连接前置 ───────────────────────────────══════════════────══───────
    if [[ -r "$HOME/.my.cnf" ]]; then
    MYSQL=(mysql –defaults-extra-file="$HOME/.my.cnf")
    else
    MYSQL=(mysql -u root -p)
    fi
    DB='demo'
    q() { "${MYSQL[@]}" -D "$DB" -t -e "$1"; }

    # ① 复现 ERROR 1093:同一张表既 UPDATE 又子查询,MySQL 明确拒绝
    "${MYSQL[@]}" -D "$DB" -e "
    UPDATE orders SET status = 2
    WHERE order_id IN (SELECT order_id FROM orders WHERE amount < 100);"
    2>&1 \\
    | grep -E 'ERROR|1093' || echo "(未复现,MySQL 版本可能已放宽限制)"

    # ② 正确写法:子查询外包一层派生表并取别名 a,强制先物化
    "${MYSQL[@]}" -D "$DB" -e "
    UPDATE orders SET status = 2
    WHERE order_id IN (SELECT a.order_id FROM (SELECT order_id FROM orders WHERE amount < 100) a);"

    echo "受影响行数:见上一条输出的 Rows matched / Changed"

    # ③ 更优写法:多表 UPDATE 语法,直接 JOIN,避免派生表物化开销
    "${MYSQL[@]}" -D "$DB" -e "
    UPDATE orders o
    JOIN (SELECT order_id FROM orders WHERE amount < 100) t ON o.order_id = t.order_id
    SET o.status = 2;"

    # ④ 变更前先「看见」要改的数据 —— 运维铁律:UPDATE 前先跑同条件的 SELECT
    q "SELECT status, COUNT(*) AS cnt FROM orders GROUP BY status;"

    # ⑤ 性能对比:EXPLAIN ANALYZE 看真实执行耗时(8.0.18+)
    q "EXPLAIN ANALYZE SELECT order_id FROM orders WHERE amount < 100\\G"

    # ⑥ 相关子查询 vs JOIN 改写:查询「消费金额高于本人平均订单额」的订单
    q "SELECT o.order_id, o.user_id, o.amount
    FROM orders o
    WHERE o.amount > (SELECT AVG(o2.amount) FROM orders o2 WHERE o2.user_id = o.user_id)
    LIMIT 10;"

    # 改写为 JOIN 版本(通常更快,只扫描两次表而非 N 次)
    q "SELECT o.order_id, o.user_id, o.amount
    FROM orders o
    JOIN (SELECT user_id, AVG(amount) AS avg_amt FROM orders GROUP BY user_id) a
    ON o.user_id = a.user_id
    WHERE o.amount > a.avg_amt
    LIMIT 10;"


    第 3 章 连接查询(INNER / LEFT / RIGHT / CROSS / 自连接)

    3.1 语法说明与适用场景

    (1)核心语法

    — ① 内连接:只返回两张表都匹配的行(交集)。INNER 可省略,JOIN 默认即 INNER JOIN
    SELECT col FROM t1 [INNER] JOIN t2 ON t1.k = t2.k;

    — ② 左连接:以左表为基础,返回左表全部行;右表不匹配处填 NULL
    SELECT col FROM t1 LEFT [OUTER] JOIN t2 ON t1.k = t2.k;

    — ③ 右连接:以右表为基础,返回右表全部行;左表不匹配处填 NULL
    SELECT col FROM t1 RIGHT [OUTER] JOIN t2 ON t1.k = t2.k;

    — ④ 交叉连接:笛卡尔积,无连接条件。慎用!m×n 行会瞬间打爆内存
    SELECT col FROM t1 CROSS JOIN t2;

    — ⑤ 自连接:同一张表用不同别名当两张表用,典型用于层级/上下级/环比
    SELECT a.name, b.name FROM emp a JOIN emp b ON a.mgr_id = b.emp_no;

    — ⑥ USING:两表连接列同名时的简写,等价于 ON t1.k = t2.k 且结果列只出现一次
    SELECT col FROM t1 JOIN t2 USING (k);

    (2)三种连接的结果差异(以讲义 a_player / b_player 为例)
    连接类型结果集行数特征
    INNER JOIN 只含 a_id = b_id 的 2、3 两行 = 交集行数
    LEFT JOIN 左表 4 行全部出现,1、4 行右表侧为 NULL = 左表行数(右表连接键唯一时)
    RIGHT JOIN 右表 4 行全部出现,5、6 行左表侧为 NULL = 右表行数(左表连接键唯一时)
    (3)⚠ 最关键的区别:ON 条件 vs WHERE 条件(左连接)

    — A:过滤条件写在 ON 里 —— 先过滤右表再连接,左表不匹配的行【仍然保留】,右表侧填 NULL
    SELECT * FROM a a LEFT JOIN b ON a.id = b.id AND b.level > 30;

    — B:过滤条件写在 WHERE 里 —— 连接完成后再过滤,NULL 行被剔除,【退化成内连接】
    SELECT * FROM a a LEFT JOIN b ON a.id = b.id WHERE b.level > 30;

    判定方法:WHERE b.col IS NOT NULL 会把 LEFT JOIN 变成 INNER JOIN,优化器也会据此改写执行计划。
    反过来用:WHERE b.id IS NULL 正是"找左表独有行"(未关联数据)的标准写法。

    (4)适用场景与注意事项
    场景写法注意事项
    关联查询明细(订单 + 用户) INNER JOIN 连接列必须都有索引,否则 8.0 走 Hash Join 但代价仍高
    找"没有订单的用户" LEFT JOIN … WHERE o.id IS NULL 与 NOT EXISTS 等价,优化器通常生成相同计划
    主表数据补全(字典表翻译) LEFT JOIN dim ON … 字典表连接键必须唯一,否则行数被放大
    层级展开(员工-主管) 自连接 多层级应改用递归 CTE(第 6 章)
    数据补全/生成日期序列 CROSS JOIN + 维度表 必须估算结果行数,m×n 失控是典型线上事故

    讲义提示:讲义建议 INNER JOIN 的表数"最好不要超过三个"。8.0.18+ 引入 Hash Join 后,多表连接的下限被放宽,但优化器对超过 10 张表会放弃穷举、改用贪心搜索(optimizer_search_depth),仍建议控制在 5 表以内并用 STRAIGHT_JOIN 固定顺序。

    (5)⚠ 行数放大陷阱

    一对多连接(orders JOIN order_items)会让主表行数按明细条数放大,SUM(amount) 会被重复累加。
    对策:先对多的一方 GROUP BY 聚合成一对一,再连接;或用 COUNT(DISTINCT o.order_id) 抵消。

    3.2 可执行 Shell 示例

    示例 3-1:三种连接对照 + 找"未关联数据"

    # ══════════════════════════════════════════════════════════════════════
    # 示例 3-1 连接查询:内/左/右连接对照 + 用 LEFT JOIN 找孤儿数据
    # 场景:核对 a_player 与 b_player 两份数据源的差异,输出「仅 A 有」「仅 B 有」「两边都有」
    # ══════════════════════════════════════════════════════════════════════

    # ── 连接前置 ──────────────────────────────────────────────────────────
    if [[ -r "$HOME/.my.cnf" ]]; then
    MYSQL=(mysql –defaults-extra-file="$HOME/.my.cnf")
    else
    MYSQL=(mysql -u root -p)
    fi
    DB='demo'
    q() { "${MYSQL[@]}" -D "$DB" -t -e "$1"; }

    # ① 内连接:交集(a_id 与 b_id 相等的行)—— INNER 可省略
    q "SELECT a.a_id, a.a_name, a.a_level FROM a_player a INNER JOIN b_player b ON a.a_id = b.b_id;"

    # ② 左连接:左表 4 行全出,右表无匹配填 NULL
    q "SELECT * FROM a_player a LEFT JOIN b_player b ON a.a_id = b.b_id;"

    # ③ 右连接:右表 4 行全出,左表无匹配填 NULL
    q "SELECT * FROM a_player a RIGHT JOIN b_player b ON a.a_id = b.b_id;"

    # ④ 仅 A 有(左表独有):LEFT JOIN + 右表连接键 IS NULL
    q "SELECT a.a_id, a.a_name FROM a_player a
    LEFT JOIN b_player b ON a.a_id = b.b_id
    WHERE b.b_id IS NULL;"

    # ⑤ 仅 B 有(右表独有):RIGHT JOIN + 左表连接键 IS NULL
    q "SELECT b.b_id, b.b_name FROM a_player a
    RIGHT JOIN b_player b ON a.a_id = b.b_id
    WHERE a.a_id IS NULL;"

    # ⑥ 一次性出「差异核对报表」:三列打标,运维交付给业务方最直观
    q "SELECT COALESCE(a.a_id, b.b_id) AS id,
    IF(a.a_id IS NOT NULL AND b.b_id IS NOT NULL, '两边都有',
    IF(a.a_id IS NOT NULL, '仅A有', '仅B有')) AS 差异类型,
    a.a_name, b.b_name
    FROM a_player a LEFT JOIN b_player b ON a.a_id = b.b_id
    UNION
    SELECT COALESCE(a.a_id, b.b_id), '仅B有', a.a_name, b.b_name
    FROM a_player a RIGHT JOIN b_player b ON a.a_id = b.b_id
    WHERE a.a_id IS NULL
    ORDER BY id;"

    示例 3-2:ON vs WHERE 的行为差异 + 三表连接 + 自连接

    # ══════════════════════════════════════════════════════════════════════
    # 示例 3-2 连接语义验证 + 真实业务三表关联
    # 场景:查「2026 年已支付订单」的用户与商品信息,并核对 ON/WHERE 差异
    # ══════════════════════════════════════════════════════════════════════

    # ── 连接前置 ─────────────────────────────────════════════════════─────
    if [[ -r "$HOME/.my.cnf" ]]; then
    MYSQL=(mysql –defaults-extra-file="$HOME/.my.cnf")
    else
    MYSQL=(mysql -u root -p)
    fi
    DB='demo'
    q() { "${MYSQL[@]}" -D "$DB" -t -e "$1"; }

    # ① ON 里加过滤:左表行全部保留,只是右表侧不满足条件的填 NULL
    q "SELECT a.a_id, a.a_name, b.b_level FROM a_player a
    LEFT JOIN b_player b ON a.a_id = b.b_id AND b.b_level > 30;"

    # ② WHERE 里加过滤:连接完再过滤,NULL 行被剔除 —— 语义上已退化为 INNER JOIN
    q "SELECT a.a_id, a.a_name, b.b_level FROM a_player a
    LEFT JOIN b_player b ON a.a_id = b.b_id
    WHERE b.b_level > 30;"

    # ③ 用 EXPLAIN 验证:② 的 Extra 里不再出现 Left Join 相关标记,优化器已改写为内连接
    q "EXPLAIN SELECT a.a_id FROM a_player a LEFT JOIN b_player b ON a.a_id = b.b_id
    WHERE b.b_level > 30\\G"

    # ④ 三表连接:员工 → 部门 → 薪资档位(自连接演示:查每个员工及其同部门同事数)
    q "SELECT e.emp_no, e.emp_name, e.dept,
    (SELECT COUNT(*) FROM emp e2 WHERE e2.dept = e.dept) AS 同部门人数
    FROM emp e ORDER BY e.dept, e.emp_no LIMIT 12;"

    # ⑤ 自连接查「同部门比自己薪资高的人」—— 典型 TopN-per-group 的另一种解法
    q "SELECT e1.emp_name AS 员工, e2.emp_name AS 同部门更高薪者, e1.salary, e2.salary
    FROM emp e1 JOIN emp e2 ON e1.dept = e2.dept AND e2.salary > e1.salary
    ORDER BY e1.dept, e1.emp_name LIMIT 15;"

    # ⑥ 8.0.18+ Hash Join 验证:无索引等值连接会显示 Hash 关键字
    q "EXPLAIN FORMAT=TREE SELECT e.emp_name, d.dept_name
    FROM emp e JOIN dept d ON e.dept = d.dept_name LIMIT 5\\G"


    第 4 章 运算符、NULL 值与正则匹配

    4.1 语法说明与适用场景

    (1)四类运算符速查
    类别运算符关键说明
    算术 + – * / %(DIV、MOD) 除数为 0 返回 NULL;数字开头的字符串会隐式转数字,非数字字符串转 0
    比较 = <> != > >= < <= 任一操作数为 NULL → 结果 NULL;字符串默认不区分大小写,需区分加 BINARY
    比较 BETWEEN AND / IN / LIKE / REGEXP BETWEEN 是闭区间
    比较 IS NULL / IS NOT NULL / <=> <=> 是空安全等于,NULL <=> NULL 返回 1
    比较 LEAST() / GREATEST() 任一参数为 NULL → 结果 NULL
    逻辑 NOT/!、AND/&&、OR/||、XOR 真=1,假=0,不确定=NULL
    位 & | ~ ^ << >> 常用于权限掩码、状态位压缩存储

    优先级速记(从高到低):! → ~ → ^ → * / % → + – → << >> → & → | → 比较符 → BETWEEN/CASE → NOT → AND/&& → OR/XOR → :=。
    不确定就用括号,不要用记忆赌。

    (2)NULL 值与空值的区别(讲义核心考点)
    对比项NULL空值 ''
    含义 缺失 / 未知 已知为空字符串
    存储空间 占用(行头 NULL 位图) 长度 0,不占字符空间
    判断方式 IS NULL / IS NOT NULL = '' 或 <> ''
    COUNT(col) 自动忽略 计入
    比较运算 = NULL 恒为 NULL(永远查不到) 正常比较
    聚合函数 SUM/AVG 忽略 NULL 不影响

    ⚠ 铁律:判断 NULL 只能用 IS NULL / IS NOT NULL / <=>,用 = NULL 或 <> NULL 永远返回空结果集,且不报错——这是最隐蔽的数据丢失原因。

    (3)通配符与正则表达式
    方式语法能否用索引说明
    LIKE '前缀%' WHERE name LIKE 'shir%' ✅ 能用 B-Tree 前缀索引 前导通配符(%abc)一定不能用索引
    LIKE '_' _ 匹配单个字符 同上 % 匹配 0/1/多个字符
    REGEXP / RLIKE WHERE name REGEXP '^nb' ❌ 全表扫描 8.0 起改用 ICU 正则引擎

    正则元字符(沿用讲义表 5-1):^ 开头 / $ 结尾 / . 任意单字符 / * 前一字符 0+ 次 / + 前一字符 1+ 次 / p1|p2 或 / [abc] 字符集 / [^abc] 排除集 / {n} 恰好 n 次 / {n,m} n~m 次。

    (4)⚠ MySQL 8.0 正则引擎变更(与讲义的重大差异)

    MySQL 8.0 起正则引擎由 Henry Spencer 换成 ICU(REGEXP_LIKE 等函数同步引入):

    项目5.7 及以前8.0+
    词边界 [[:<:]] / [[:>:]] 已移除,改用 \\b
    八进制转义 \\1 反向引用 ICU 不支持 \\1 作反向引用(用 () 捕获组 + $1 在 REGEXP_REPLACE)
    新增函数 只有 REGEXP REGEXP_LIKE / REGEXP_REPLACE / REGEXP_SUBSTR / REGEXP_INSTR
    多字节安全 部分不安全 ICU 完整支持 UTF-8 多字节

    讲义里的 [[:<:]] 写法在 8.0+ 会报 ERROR 3685: Illegal argument to a regular expression,必须改成 \\b。

    (5)适用场景
    场景推荐
    前缀搜索(用户名以 shir 开头) LIKE 'shir%'(走索引)
    数据清洗(去掉不可见字符/手机号脱敏) REGEXP_REPLACE(8.0+)
    格式校验(手机号/邮箱/身份证) REGEXP_LIKE,比 LIKE 表达力强得多
    权限位判断 位运算:perm & 4 = 4
    报表里的 NULL 兜底 COALESCE(col, 0) / IFNULL(col, 0)

    4.2 可执行 Shell 示例

    示例 4-1:运算符与 NULL 三值逻辑验证

    # ══════════════════════════════════════════════════════════════════════
    # 示例 4-1 运算符与 NULL 语义验证
    # 场景:新同事写的 WHERE 条件查不到数据,先跑一遍语义基准确认是不是 NULL 的锅
    # ══════════════════════════════════════════════════════════════════════

    # ── 连接前置 ──────────────────────────────────────────────────────────
    if [[ -r "$HOME/.my.cnf" ]]; then
    MYSQL=(mysql –defaults-extra-file="$HOME/.my.cnf")
    else
    MYSQL=(mysql -u root -p)
    fi
    DB='demo'
    q() { "${MYSQL[@]}" -D "$DB" -t -e "$1"; }

    # ① 算术运算:除数为 0 返回 NULL,字符串隐式转数字(非数字开头一律转 0)
    q "SELECT 1+2 AS 加, 2-1 AS 减, 2*3 AS 乘, 4/2 AS 除, 7%2 AS 取余, 4/0 AS 除零返回NULL;"

    # ② 比较运算:任一操作数为 NULL 结果就是 NULL;2='2' 会自动转数字
    q "SELECT 2=4 AS a, 2='2' AS b, 'e'='e' AS c, (2+2)=(3+1) AS d, 'r'=NULL AS e;"

    # ③ 空安全等于 <=> :NULL <=> NULL 返回 1,这是判断两值「真正相等」的唯一正确写法
    q "SELECT NULL=NULL AS 普通等于, NULL<=>NULL AS 空安全等于, 1<=>1 AS 相等, 1<=>2 AS 不等;"

    # ④ 逻辑运算三值表:AND 遇 0 得 0,OR 遇 1 得 1,其余含 NULL 则为 NULL
    q "SELECT 2 AND 3 AS a, 4&&0 AS b, 0&&NULL AS c, 1 AND NULL AS d,
    2 OR 3 AS e, 0 OR NULL AS f, 2 XOR 3 AS g, 1 XOR NULL AS h;"

    # ⑤ 位运算:权限掩码实战(1=读 2=写 4=执行),判断某用户是否有「写」权限
    q "SELECT 5 & 2 AS 是否有写权限, 5 | 2 AS 加上写权限, 5 & ~2 AS 去掉写权限, 1 << 3 AS 执行位;"

    # ⑥ NULL vs 空值:COUNT(col) 忽略 NULL 但计入空串 —— 报表口径差异的根源
    q "SELECT COUNT(*) AS 总行数, COUNT(coin) AS coin非NULL数,
    SUM(coin IS NULL) AS coin为NULL数, SUM(coin = '') AS coin为空串数
    FROM player;"

    # ⑦ 最值函数遇 NULL 直接返回 NULL(不是忽略!),用 IFNULL 兜底
    q "SELECT GREATEST(1,2,3) AS g1, GREATEST(1,NULL,3) AS 遇NULL返回NULL,
    GREATEST(1,IFNULL(NULL,0),3) AS 用IFNULL兜底;"

    示例 4-2:正则匹配与数据清洗(含 8.0 引擎变更修正)

    # ══════════════════════════════════════════════════════════════════════
    # 示例 4-2 正则匹配与生产数据清洗
    # 场景:清洗玩家昵称中的异常字符,并校验订单备注里的手机号格式
    # ══════════════════════════════════════════════════════════════════════

    # ── 连接前置 ──────────────────────────────────────────────────────────
    if [[ -r "$HOME/.my.cnf" ]]; then
    MYSQL=(mysql –defaults-extra-file="$HOME/.my.cnf")
    else
    MYSQL=(mysql -u root -p)
    fi
    DB='demo'
    q() { "${MYSQL[@]}" -D "$DB" -t -e "$1"; }

    # ① 基础正则:开头 ^ / 结尾 $ / 任意单字符 . / 或 |
    q "SELECT id, name FROM player WHERE name REGEXP '^nb';" # 以 nb 开头
    q "SELECT id, name FROM player WHERE name REGEXP 'ss\\$';" # 以 ss 结尾(\\$ 转义,避免被 shell 吞掉)
    q "SELECT id, name FROM player WHERE name REGEXP 'shir.ey';" # shir 与 ey 之间隔 1 个字符
    q "SELECT id, name FROM player WHERE name REGEXP 'on|in';" # 含 on 或 in

    # ② 量词:* 前一字符 0+ 次;+ 前一字符 1+ 次;{n,m} 次数区间
    q "SELECT id, name FROM player WHERE name REGEXP 'oooo*';" # 连续 3 个及以上 o
    q "SELECT id, name FROM player WHERE name REGEXP 'oooo+';" # 连续 4 个及以上 o
    q "SELECT id, name FROM player WHERE name REGEXP '^[d-f]';" # 以 d/e/f 开头
    q "SELECT id, name FROM player WHERE name REGEXP '[^d-f]';" # 包含 d/e/f 之外的字符

    # ③ ⚠ 8.0 引擎变更:[[:<:]] 词边界已废弃,改用 \\b
    "${MYSQL[@]}" -D "$DB" -e "SELECT 'hello world' REGEXP '[[:<:]]world[[:>:]]';" 2>&1 \\
    | grep -E 'ERROR|3685' && echo "↑ 已确认:8.0+ 移除 [[:<:]] 词边界,请改用 \\\\b" || true
    q "SELECT 'hello world' REGEXP '\\\\\\\\bworld\\\\\\\\b' AS 用反斜杠b替代词边界;"

    # ④ 8.0 新增正则函数:REGEXP_LIKE 做格式校验(比 LIKE 表达力强)
    q "SELECT '13812345678' REGEXP '^1[3-9][0-9]{9}\\$' AS 合法手机号,
    '12345' REGEXP '^1[3-9][0-9]{9}\\$' AS 非法手机号;"

    # ⑤ 数据清洗:REGEXP_REPLACE 去掉昵称里的控制字符与多余空白(8.0+)
    q "SELECT REGEXP_REPLACE(' ab\\\\tcd\\\\n ', '[[:space:]]+', ' ') AS 清洗空白,
    REGEXP_REPLACE('abc123def', '[0-9]+', '***') AS 数字打码;"

    # ⑥ REGEXP_SUBSTR 从文本里抽取结构化信息(例如从备注里抓手机号)
    q "SELECT REGEXP_SUBSTR('联系人:13812345678 请尽快', '1[3-9][0-9]{9}') AS 抽取手机号;"

    # ⑦ 生产注意:REGEXP 无法使用索引,用 EXPLAIN 确认它走的是全表扫描
    q "EXPLAIN SELECT id FROM player WHERE name REGEXP '^nb'\\G"
    echo "结论:type=ALL 即全表扫描。高频搜索场景必须改用 FULLTEXT(见第 13 章)或加冗余列 + LIKE 前缀。"


    第 5 章 常用内置函数(数学 / 聚合 / 字符串 / 日期时间)

    5.1 语法说明与适用场景

    (1)数学函数(沿用讲义表 5-7)
    函数说明运维常见用法
    abs(x) 绝对值 计算偏差
    rand() 0~1 随机浮点数 构造测试数据;ORDER BY RAND() 随机抽样(大表禁用)
    mod(x,y) 取余 按 user_id 取模分片、均匀分批
    power(x,y) x 的 y 次方 增长率换算
    round(x) / round(x,y) 四舍五入(可指定小数位) 金额处理
    truncate(x,y) 直接截断(不四舍五入) 金额向下截断,与 round 口径不同
    ceil(x) / floor(x) 向上 / 向下取整 分页总页数 CEIL(total/size)
    greatest(x1,…) / least(x1,…) 集合最大 / 最小值 多列取极值;遇 NULL 返回 NULL
    (2)聚合函数(沿用讲义表 5-8)
    函数说明⚠ 注意事项
    count(*) 统计行数(含 NULL 行) InnoDB 下 8.0 仍需扫描,无法 O(1)
    count(col) 统计该列非 NULL 的行数 与 count(*) 口径不同,报表必须写清楚
    count(1) 与 count(*) 等价(优化器相同处理) 不要再争论哪个快
    count(distinct col) 去重计数 大基数时消耗大量内存
    sum(x) / avg(x) 求和 / 平均 忽略 NULL;全 NULL 时返回 NULL 而非 0
    min(x) / max(x) 最小 / 最大 有索引时可直接走索引极值优化
    group_concat(x) 分组内字符串拼接 ⚠ 受 group_concat_max_len(默认 1024 字节)截断
    (3)字符串函数(沿用讲义表 5-9)
    函数说明注意事项
    length(x) 字节数(utf8mb4 下汉字 = 3~4 字节) 要字符数用 CHAR_LENGTH()
    char_length(x) 字符数 与 length 极易混淆
    trim() / ltrim() / rtrim() 去首尾空格 只去空格,不去制表符/换行
    concat(x,y) 拼接 ⚠ 任一参数为 NULL → 结果 NULL,用 CONCAT_WS 或 IFNULL
    concat_ws(sep,…) 带分隔符拼接,自动跳过 NULL 生成 CSV 行、地址拼接首选
    upper(x) / lower(x) 大小写转换
    left(x,y) / right(x,y) 取前 y / 后 y 个字符
    substring(x,y,z) 从第 y 位起取 z 个字符(下标从 1 开始) 与程序语言的 0 下标不同
    replace(x,y,z) 把 x 中的 y 替换为 z 批量改域名/路径常用
    repeat(x,y) / space(x) / reverse(x) 重复 / 空格 / 反转
    strcmp(x,y) 比较,返回 -1 / 0 / 1 不是布尔值
    (4)日期时间函数(沿用讲义表 5-10)
    函数说明运维常见用法
    curdate() / curtime() / now() 当前日期 / 时间 / 日期时间 now() 在一条语句内恒定(语句开始时刻),sysdate() 取实时
    date_format(x, fmt) 格式化输出 '%Y-%m' 按月分组;'%Y-%m-%d %H:%i:%s'
    str_to_date(x, fmt) 字符串转日期 导入脏数据时的清洗
    date_add(x, INTERVAL n DAY) / date_sub 日期加减 清理 N 天前日志
    datediff(x,y) 相差天数 留存分析
    timestampdiff(unit,x,y) 指定单位的时间差 秒/分/时/天均可,比 datediff 灵活
    month/week/hour/minute/second 提取分量
    dayofweek(x) 1=星期日,2=星期一 ⚠ 与直觉不符,报表口径要注明
    dayofmonth(x) / dayofyear(x) 月内第几天 / 年内第几天
    unix_timestamp() / from_unixtime() 时间戳互转 与程序侧对齐
    (5)适用场景对比
    需求推荐对比
    按月出营收报表 DATE_FORMAT(created_at,'%Y-%m') + GROUP BY 比在应用层分组更快、更省内存
    金额字段处理 ROUND(x,2) 展示,TRUNCATE 用于严格截断 金额建议用 DECIMAL 而非 FLOAT
    随机抽样 小表 ORDER BY RAND();大表用 WHERE id >= RAND()*MAX(id) LIMIT 1 ORDER BY RAND() 是全表排序,O(n log n)
    拼接导出字段 CONCAT_WS(',', …) 比 CONCAT 安全(跳过 NULL)
    清理过期数据 DELETE … WHERE created_at < DATE_SUB(NOW(), INTERVAL 90 DAY) 分批 + LIMIT 一次性删大批量会锁表、撑爆 undo

    5.2 可执行 Shell 示例

    示例 5-1:按月营收报表(含小计与环比)

    # ══════════════════════════════════════════════════════════════════════
    # 示例 5-1 报表查询:按月营收统计 + 小计 + 金额口径核对
    # 场景:每月 1 号给老板出上月营收报表,需含订单数、GMV、客单价
    # ══════════════════════════════════════════════════════════════════════

    # ── 连接前置 ───────────────────────────────══════════════════════════─
    if [[ -r "$HOME/.my.cnf" ]]; then
    MYSQL=(mysql –defaults-extra-file="$HOME/.my.cnf")
    else
    MYSQL=(mysql -u root -p)
    fi
    DB='demo'
    q() { "${MYSQL[@]}" -D "$DB" -t -e "$1"; }

    # ① 按月聚合:DATE_FORMAT 提取年月,SUM 求 GMV,ROUND 保留 2 位
    q "SELECT DATE_FORMAT(created_at, '%Y-%m') AS 月份,
    COUNT(*) AS 订单数,
    ROUND(SUM(amount), 2) AS GMV,
    ROUND(AVG(amount), 2) AS 客单价,
    ROUND(MIN(amount), 2) AS 最低,
    ROUND(MAX(amount), 2) AS 最高
    FROM orders
    GROUP BY DATE_FORMAT(created_at, '%Y-%m')
    ORDER BY 月份;"

    # ② 带合计行:WITH ROLLUP + IFNULL 美化分组键
    q "SELECT IFNULL(DATE_FORMAT(created_at,'%Y-%m'), '合计') AS 月份,
    COUNT(*) AS 订单数, ROUND(SUM(amount), 2) AS GMV
    FROM orders GROUP BY DATE_FORMAT(created_at,'%Y-%m') WITH ROLLUP;"

    # ③ 分状态汇总:行转列写法(SUM + CASE WHEN),一次出多维报表
    q "SELECT DATE_FORMAT(created_at,'%Y-%m') AS 月份,
    SUM(status = 0) AS 待支付,
    SUM(status = 1) AS 已支付,
    SUM(status = 2) AS 已取消,
    ROUND(SUM(CASE WHEN status = 1 THEN amount ELSE 0 END), 2) AS 已支付GMV
    FROM orders GROUP BY 月份 ORDER BY 月份;"

    # ④ 口径核对:count(*) vs count(col) vs count(distinct) 三者差异必须说清楚
    q "SELECT COUNT(*) AS 总行数,
    COUNT(user_id) AS user_id非空行数,
    COUNT(DISTINCT user_id) AS 去重用户数
    FROM orders;"

    # ⑤ 分组拼接:把每个用户最近 5 个订单号拼成一行(先扩大 group_concat_max_len 防截断)
    "${MYSQL[@]}" -D "$DB" -e "SET SESSION group_concat_max_len = 102400;" # 会话级,不影响全局
    q "SELECT user_id, COUNT(*) AS 订单数,
    GROUP_CONCAT(order_id ORDER BY order_id DESC SEPARATOR '|') AS 订单号列表
    FROM orders GROUP BY user_id HAVING 订单数 >= 2 LIMIT 5;"

    # ⑥ 日期维度分析:按星期几看下单分布(dayofweek:1=周日 2=周一 … 7=周六)
    q "SELECT DAYOFWEEK(created_at) AS 星期编号,
    CASE DAYOFWEEK(created_at) WHEN 1 THEN '周日' WHEN 2 THEN '周一' WHEN 3 THEN '周二'
    WHEN 4 THEN '周三' WHEN 5 THEN '周四' WHEN 6 THEN '周五' ELSE '周六' END AS 星期,
    COUNT(*) AS 订单数
    FROM orders GROUP BY DAYOFWEEK(created_at) ORDER BY 星期编号;"

    示例 5-2:字符串清洗与批量数据订正

    # ══════════════════════════════════════════════════════════════════════
    # 示例 5-2 数据清洗:脏字符串订正 + 批量 UPDATE 的安全流程
    # 场景:历史数据里 nick 字段混入了前后空格和全角空格,需要批量清洗
    # ══════════════════════════════════════════════════════════════════════

    # ── 连接前置 ─────────────────────────────────────════════════════════─
    if [[ -r "$HOME/.my.cnf" ]]; then
    MYSQL=(mysql –defaults-extra-file="$HOME/.my.cnf")
    else
    MYSQL=(mysql -u root -p)
    fi
    DB='demo'
    q() { "${MYSQL[@]}" -D "$DB" -t -e "$1"; }

    # ① 先「看见」问题数据:LENGTH vs CHAR_LENGTH 能看出是否含多字节/隐藏字符
    q "SELECT id, name, LENGTH(name) AS 字节数, CHAR_LENGTH(name) AS 字符数
    FROM player WHERE name <> TRIM(name) OR name LIKE '% %' LIMIT 10;"

    # ② 清洗方案:TRIM 去空格 + REGEXP_REPLACE 去控制字符(8.0+)
    q "SELECT name AS 原值,
    REGEXP_REPLACE(TRIM(name), '[[:cntrl:]]', '') AS 清洗后
    FROM player WHERE name REGEXP '[[:cntrl:]]|[[:space:]]' LIMIT 10;"

    # ③ 批量更新「铁律三步」:① 先 SELECT 看量 → ② 备份/记录 → ③ 再 UPDATE
    # 第一步:统计影响范围
    q "SELECT COUNT(*) AS 待清洗行数 FROM player
    WHERE name <> REGEXP_REPLACE(TRIM(name), '[[:cntrl:]]', '');"

    # ④ 第二步:先备份到影子表(运维变更必备的回滚依据)
    "${MYSQL[@]}" -D "$DB" -e "DROP TABLE IF EXISTS player_bak;
    CREATE TABLE player_bak AS SELECT * FROM player;"

    echo "已备份到 player_bak($( "${MYSQL[@]}" -D "$DB" -N -B -e "SELECT COUNT(*) FROM player_bak;" ) 行)"

    # ⑤ 第三步:执行更新(MySQL 不允许直接 UPDATE 本表同时子查询本表,此处不涉及故可直接改)
    "${MYSQL[@]}" -D "$DB" -e "
    UPDATE player
    SET name = REGEXP_REPLACE(TRIM(name), '[[:cntrl:]]', '')
    WHERE name <> REGEXP_REPLACE(TRIM(name), '[[:cntrl:]]', '');"
    echo "
    清洗完成,已更新"

    # ⑥ 验证:影响行数应为 0 才算彻底清洗干净
    q "SELECT COUNT(*) AS 残留脏数据 FROM player
    WHERE name <> REGEXP_REPLACE(TRIM(name), '[[:cntrl:]]', '');"

    # ⑦ 误操作回滚(真出问题时用):从影子表恢复
    # "${MYSQL[@]}" -D "$DB" -e "UPDATE player p JOIN player_bak b ON p.id = b.id SET p.name = b.name;"
    echo "
    回滚命令已备好(注释状态),需要时取消注释执行"


    第 6 章 CTE 公共表表达式

    讲义无此章节(MySQL 8.0 新增特性),依据 MySQL 8.0 / 8.4 官方标准实践补充。

    6.1 语法说明与适用场景

    (1)核心语法

    — ① 非递归 CTE:把子查询提到前面命名,主查询可多次引用
    WITH cte_name [(col1, col2, ...)] AS (
    SELECT ...
    )
    [, cte_name2 AS ( ... )] — 多个 CTE 用逗号分隔,后者可引用前者
    SELECT ... FROM cte_name ...;

    — ② 递归 CTE:必须有锚成员(起点)+ 递归成员(UNION ALL 引用自身)+ 终止条件
    WITH RECURSIVE cte_name (cols) AS (
    SELECT 初始行 WHERE ... — 锚成员(Anchor)
    UNION ALL
    SELECT 下一层 FROM cte_name JOIN tbl ON ... — 递归成员,必须引用 cte 自身
    WHERE 终止条件 — 缺了会撞上 cte_max_recursion_depth
    )
    SELECT ... FROM cte_name;

    (2)CTE vs 派生表 vs 临时表
    对比项CTE(WITH)派生表(FROM (…))临时表(CREATE TEMPORARY TABLE)
    可读性 ✅ 最高,命名即语义,自上而下 差,嵌套在 FROM 里 中
    可重复引用 ✅ 一次定义多次引用 ❌ 每用一次写一遍 ✅
    可递归 ✅ WITH RECURSIVE ❌ ❌
    可建索引 ❌ ❌ ✅ 可加索引加速
    生命周期 单条语句 单条语句 会话级,跨语句复用
    作用域 可跟在 SELECT/UPDATE/DELETE 前 仅 FROM 子句 全局(会话内)

    MySQL 8.0 的性能说明:8.0 早期 CTE 总是被物化(materialized),可能比等价派生表更慢。
    8.0 起优化器支持 CTE 合并(merge),可用优化器 Hint 强制控制:
    WITH cte AS (SELECT … ) SELECT /*+ MERGE(cte) */ … / /*+ NO_MERGE(cte) */(Hint 8.0.20+ 支持 CTE 名)。
    CTE 被引用多次时必定物化,这是它的固定代价。

    (3)关键变量
    变量默认值说明
    cte_max_recursion_depth 1000 递归层数上限。超了报 ERROR 3636: Recursive query aborted after 1001 iterations
    max_execution_time 0(不限) SET SESSION max_execution_time = … 可给失控查询加保险

    调大方式:SET SESSION cte_max_recursion_depth = 10000;(会话级,不改全局,避免影响其他连接)。

    (4)适用场景
    场景说明
    层级结构展开(部门树、菜单树、上下级) 递归 CTE 是唯一 SQL 原生解法,替代多次往返查询
    生成序列(日期序列、补零报表) 递归生成连续日期,再 LEFT JOIN 事实表补空
    替代多层嵌套子查询 把"套娃"改成线性可读的管道
    数据修复前圈定 ID WITH victims AS (SELECT id …) UPDATE/DELETE JOIN victims
    连续区间问题(gaps and islands,如连续登录天数) CTE + 窗口函数组合
    LATERAL 横向连接(8.0.14+) 每行独立执行一次子查询(如"每个用户最近 3 单")
    (5)⚠ 注意事项
  • 递归成员的 UNION ALL 两侧列数与类型必须一致,否则报 ERROR 1222。
  • 递归 CTE 的列宽度由锚成员决定,递归成员里放长字符串会被截断——锚成员要预留足够长度(用 CAST(… AS CHAR(500)))。
  • WITH 后面必须紧跟 SELECT/UPDATE/DELETE,中间不能插入其他语句。
  • MySQL 不支持在 CTE 内引用后面定义的 CTE(只能引用已定义的)。
  • 6.2 可执行 Shell 示例

    示例 6-1:部门树递归展开(组织架构报表)

    # ══════════════════════════════════════════════════════════════════════
    # 示例 6-1 递归 CTE:组织架构树展开
    # 场景:HR 要一份「每个部门从顶层到本级的完整路径」报表,层级深度不定
    # ══════════════════════════════════════════════════════════════════════

    # ── 连接前置 ──────────────────────────────────────────────────────────
    if [[ -r "$HOME/.my.cnf" ]]; then
    MYSQL=(mysql –defaults-extra-file="$HOME/.my.cnf")
    else
    MYSQL=(mysql -u root -p)
    fi
    DB='demo'
    q() { "${MYSQL[@]}" -D "$DB" -t -e "$1"; }

    # ① 确认部门树数据
    q "SELECT * FROM dept ORDER BY parent_id, dept_id;"

    # ② 递归展开:锚成员取根节点(parent_id IS NULL),递归成员逐层下钻,拼出路径
    # 注意:锚成员里 CAST 预留了 500 字符宽度,否则递归拼接会被截断
    q "WITH RECURSIVE dept_tree (dept_id, dept_name, parent_id, lvl, path) AS (
    SELECT dept_id, dept_name, parent_id, 1,
    CAST(dept_name AS CHAR(500)) — 锚成员:起点 + 预留路径宽度
    FROM dept WHERE parent_id IS NULL
    UNION ALL
    SELECT d.dept_id, d.dept_name, d.parent_id, t.lvl + 1,
    CONCAT(t.path, ' > ', d.dept_name) — 递归成员:引用 CTE 自身
    FROM dept d
    JOIN dept_tree t ON d.parent_id = t.dept_id
    WHERE t.lvl < 10 — 终止条件:防御环路导致的无限递归
    )
    SELECT dept_id, lvl AS 层级, dept_name, path AS 完整路径
    FROM dept_tree ORDER BY path;"

    # ③ 按子树统计:每个部门(含所有子部门)下的员工数与薪资总额
    q "WITH RECURSIVE sub (dept_id, root_id) AS (
    SELECT dept_id, dept_id FROM dept — 锚:每个部门自身
    UNION ALL
    SELECT d.dept_id, s.root_id FROM dept d JOIN sub s ON d.parent_id = s.dept_id
    )
    SELECT s.root_id AS 部门ID,
    (SELECT dept_name FROM dept WHERE dept_id = s.root_id) AS 部门名,
    COUNT(e.emp_no) AS 含子部门员工数,
    ROUND(SUM(e.salary), 2) AS 薪资合计
    FROM sub s
    LEFT JOIN emp e ON e.dept = (SELECT dept_name FROM dept WHERE dept_id = s.dept_id)
    GROUP BY s.root_id ORDER BY 部门ID;"

    # ④ 递归层数上限演示:超过 cte_max_recursion_depth 会报 ERROR 3636
    q "SELECT @@cte_max_recursion_depth AS 默认递归上限;"
    "${MYSQL[@]}" -D "$DB" -e "
    SET SESSION cte_max_recursion_depth = 20; — 会话级调小,快速复现
    WITH RECURSIVE seq(n) AS (SELECT 1 UNION ALL SELECT n+1 FROM seq WHERE n < 100)
    SELECT COUNT(*) FROM seq;"
    2>&1 | grep -E 'ERROR|3636' || echo "(未复现,检查 MySQL 版本)"

    # ⑤ 正确做法:需要更多层数时,会话级调大(不要改全局,避免影响其他连接)
    q "SET SESSION cte_max_recursion_depth = 10000;
    WITH RECURSIVE seq(n) AS (SELECT 1 UNION ALL SELECT n+1 FROM seq WHERE n < 5000)
    SELECT COUNT(*) AS 生成行数 FROM seq;"

    示例 6-2:生成日期序列补零 + CTE 圈定 ID 做批量修复

    # ══════════════════════════════════════════════════════════════════════
    # 示例 6-2 日期序列补零报表 + 用 CTE 安全圈定待修复数据
    # 场景:① 出「近 7 天每日订单数」报表,断更的日期要显示 0
    # ② 把「30 天前的已取消订单」归档删除,先用 CTE 圈定范围再动手
    # ══════════════════════════════════════════════════════════════════════

    # ── 连接前置 ─────────────────────────────────────════════════════════─
    if [[ -r "$HOME/.my.cnf" ]]; then
    MYSQL=(mysql –defaults-extra-file="$HOME/.my.cnf")
    else
    MYSQL=(mysql -u root -p)
    fi
    DB='demo'
    q() { "${MYSQL[@]}" -D "$DB" -t -e "$1"; }

    # ① 递归生成最近 7 天日期序列(补零报表的前提:先有完整日期轴)
    q "WITH RECURSIVE days(d) AS (
    SELECT CURDATE() – INTERVAL 6 DAY — 锚:7 天前的日期
    UNION ALL
    SELECT d + INTERVAL 1 DAY FROM days WHERE d < CURDATE() — 递归:逐天 +1,到今天停止
    )
    SELECT d AS 日期 FROM days ORDER BY d;"

    # ② 日期轴 LEFT JOIN 订单表:没有订单的日期显示 0(COALESCE 把 NULL 转 0)
    q "WITH RECURSIVE days(d) AS (
    SELECT CURDATE() – INTERVAL 6 DAY
    UNION ALL SELECT d + INTERVAL 1 DAY FROM days WHERE d < CURDATE()
    )
    SELECT days.d AS 日期,
    COALESCE(o.订单数, 0) AS 订单数,
    COALESCE(o.GMV, 0) AS GMV
    FROM days
    LEFT JOIN (
    SELECT DATE(created_at) AS d, COUNT(*) AS 订单数, ROUND(SUM(amount),2) AS GMV
    FROM orders GROUP BY DATE(created_at)
    ) o ON o.d = days.d
    ORDER BY days.d;"

    # ③ 数据修复场景:先用 CTE 圈定「30 天前已取消的订单」,只 SELECT 不动手
    q "WITH victims AS (
    SELECT order_id, amount, created_at
    FROM orders
    WHERE status = 2 AND created_at < DATE_SUB(NOW(), INTERVAL 30 DAY)
    )
    SELECT COUNT(*) AS 待归档订单数, ROUND(SUM(amount),2) AS 涉及金额 FROM victims;"

    # ④ 确认无误后再改成 DELETE(多表删除语法:DELETE t FROM t JOIN cte)
    # ⚠ 生产环境务必先备份,且分批 LIMIT 提交,避免长事务锁表
    "${MYSQL[@]}" -D "$DB" -e "
    CREATE TABLE IF NOT EXISTS orders_archive AS SELECT * FROM orders WHERE 1=0;"
    # 建空归档表(结构复制)
    "${MYSQL[@]}" -D "$DB" -e "
    WITH victims AS (
    SELECT order_id FROM orders
    WHERE status = 2 AND created_at < DATE_SUB(NOW(), INTERVAL 30 DAY)
    )
    INSERT INTO orders_archive SELECT o.* FROM orders o JOIN victims v ON o.order_id = v.order_id;"

    echo "已归档到 orders_archive"

    # ⑤ 确认归档成功后再删源数据(此处演示,实际按审批流程执行)
    "${MYSQL[@]}" -D "$DB" -e "
    WITH victims AS (
    SELECT order_id FROM orders
    WHERE status = 2 AND created_at < DATE_SUB(NOW(), INTERVAL 30 DAY)
    )
    DELETE o FROM orders o JOIN victims v ON o.order_id = v.order_id;"

    echo "已从 orders 删除已归档数据"

    # ⑥ 验证:源表该条件下的行数应为 0
    q "SELECT COUNT(*) AS 残留待归档数 FROM orders
    WHERE status = 2 AND created_at < DATE_SUB(NOW(), INTERVAL 30 DAY);"


    第 7 章 窗口函数

    讲义无此章节(MySQL 8.0 新增特性),依据 MySQL 8.0 / 8.4 官方标准实践补充。

    7.1 语法说明与适用场景

    (1)核心语法

    函数名([参数]) OVER (
    [PARTITION BY 分组列] — 窗口内再分区,不写则整表一个窗口
    [ORDER BY 排序列 [ASC|DESC]] — 窗口内排序,决定累计/排名的顺序
    [帧子句 frame_clause] — 限定窗口内的计算范围
    )
    SELECT ... FROM ... WINDOW w AS (PARTITION BY ... ORDER BY ...) — 8.0.22+ 命名窗口

    帧子句两种模式:

    ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW — 按「行」计数(推荐,行为确定)
    RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW — 按「值」计数(默认,遇重复值有坑)
    — 可用边界:UNBOUNDED PRECEDING / n PRECEDING / CURRENT ROW / n FOLLOWING / UNBOUNDED FOLLOWING

    (2)函数分类与选型
    类别函数用途典型场景
    序号 ROW_NUMBER() 连续唯一序号 1,2,3,4 去重取最新一条、TopN-per-group
    序号 RANK() 并列跳号 1,2,2,4 比赛排名(并列后跳号)
    序号 DENSE_RANK() 并列不跳号 1,2,2,3 等级/档位划分
    分桶 NTILE(n) 均分成 n 组 用户分层(RFM)、百分位分组
    偏移 LAG(col, n, default) 取前 n 行的值 环比、同比、相邻差值
    偏移 LEAD(col, n, default) 取后 n 行的值 计算留存、下次行为间隔
    首尾 FIRST_VALUE(col) / LAST_VALUE(col) 窗口首/末值 与基准对比
    取值 NTH_VALUE(col, n) 窗口第 n 个值
    分布 CUME_DIST() / PERCENT_RANK() 累积分布 / 百分比排名 分位数分析
    聚合 SUM/AVG/COUNT/MAX/MIN() OVER (…) 不压缩行的聚合 累计求和、移动平均、占比
    (3)⚠ 窗口函数最大的三个坑

    坑 1:窗口函数在 WHERE 之后执行,不能直接写进 WHERE

    — ❌ 报错:WHERE 里不允许出现窗口函数
    SELECT * FROM emp WHERE ROW_NUMBER() OVER (PARTITION BY dept ORDER BY salary DESC) = 1;
    — ✅ 正确:套一层 CTE / 派生表,外层再过滤
    WITH ranked AS (SELECT *, ROW_NUMBER() OVER (PARTITION BY dept ORDER BY salary DESC) rn FROM emp)
    SELECT * FROM ranked WHERE rn = 1;

    坑 2:默认帧是 RANGE … CURRENT ROW,遇重复值会把并列行一起算

    — 默认 RANGE 帧下,相同 salary 的行会被归为同一组,SUM 结果是"并列行一起算"
    SUM(salary) OVER (ORDER BY salary)
    — 想要严格"截至当前行"的累计,必须显式写 ROWS:
    SUM(salary) OVER (ORDER BY salary ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)

    坑 3:LAST_VALUE() 默认只看到当前行,取不到"最后一个值"

    — ❌ 默认帧到 CURRENT ROW,LAST_VALUE 永远等于当前行的值
    LAST_VALUE(salary) OVER (ORDER BY hire_date)
    — ✅ 必须把帧终点推到最后一行
    LAST_VALUE(salary) OVER (ORDER BY hire_date
    ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING)

    (4)适用场景对比
    需求窗口函数方案传统方案优势
    每个部门薪资 Top3 ROW_NUMBER() OVER (PARTITION BY dept …) + 外层 rn<=3 自连接 + COUNT 计数 SQL 短,扫描一次表
    去重取最新一条 ROW_NUMBER() OVER (PARTITION BY k ORDER BY t DESC) = 1 GROUP BY + 子查询取 MAX 可取整行所有列
    环比/同比 LAG(col,1) 自连接 t.id = t2.id – 1 不依赖连续主键
    累计求和(running total) SUM() OVER (ORDER BY d ROWS UNBOUNDED PRECEDING) 相关子查询逐行求和 O(n²) 复杂度从 O(n²) 降到 O(n log n)
    移动平均 AVG() OVER (ORDER BY d ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) 难以表达 一行搞定
    (5)性能注意
    • 窗口函数需要按 PARTITION BY / ORDER BY 排序,若已有匹配索引(如 (dept, salary)),可省掉排序;否则 Extra 会出现 Using temporary; Using filesort。
    • 窗口函数不会减少返回行数,全表结果集都会参与计算,大表务必先用 WHERE 缩范围。

    7.2 可执行 Shell 示例

    示例 7-1:部门薪资排名、累计求和与环比

    # ══════════════════════════════════════════════════════════════════════
    # 示例 7-1 窗口函数实战:TopN-per-group / 累计求和 / 移动平均 / 环比
    # 场景:HR 要「各部门薪资前三名 + 部门薪资占比 + 入职顺序累计薪资」报表
    # ══════════════════════════════════════════════════════════════════════

    # ── 连接前置 ──────────────────────────────────────────────────────────
    if [[ -r "$HOME/.my.cnf" ]]; then
    MYSQL=(mysql –defaults-extra-file="$HOME/.my.cnf")
    else
    MYSQL=(mysql -u root -p)
    fi
    DB='demo'
    q() { "${MYSQL[@]}" -D "$DB" -t -e "$1"; }

    # ① 三种排名函数对比:并列时 RANK 跳号、DENSE_RANK 不跳号、ROW_NUMBER 连续唯一
    q "SELECT dept, emp_name, salary,
    ROW_NUMBER() OVER (PARTITION BY dept ORDER BY salary DESC) AS 行号,
    RANK() OVER (PARTITION BY dept ORDER BY salary DESC) AS 排名跳号,
    DENSE_RANK() OVER (PARTITION BY dept ORDER BY salary DESC) AS 排名不跳号
    FROM emp ORDER BY dept, salary DESC;"

    # ② 每个部门薪资 Top2(经典 TopN-per-group):窗口函数必须套一层才能过滤
    q "WITH ranked AS (
    SELECT dept, emp_name, salary,
    ROW_NUMBER() OVER (PARTITION BY dept ORDER BY salary DESC) AS rn
    FROM emp
    )
    SELECT dept, emp_name, salary, rn FROM ranked WHERE rn <= 2 ORDER BY dept, rn;"

    # ③ 占部门总薪资比例:聚合窗口函数 SUM() OVER (PARTITION BY dept) 不压缩行
    q "SELECT dept, emp_name, salary,
    SUM(salary) OVER (PARTITION BY dept) AS 部门薪资合计,
    ROUND(salary / SUM(salary) OVER (PARTITION BY dept) * 100, 2) AS 占比百分比
    FROM emp ORDER BY dept, salary DESC;"

    # ④ 累计求和:必须显式写 ROWS 帧,否则默认 RANGE 帧遇到相同 salary 会并列计算
    q "SELECT dept, emp_name, salary,
    SUM(salary) OVER (ORDER BY hire_date, emp_no
    ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS 按入职累计薪资
    FROM emp ORDER BY hire_date, emp_no;"

    # ⑤ 移动平均:最近 3 条记录的滑动平均
    q "SELECT emp_name, hire_date, salary,
    ROUND(AVG(salary) OVER (ORDER BY hire_date
    ROWS BETWEEN 2 PRECEDING AND CURRENT ROW), 2) AS 近3人移动平均
    FROM emp ORDER BY hire_date;"

    # ⑥ LAG/LEAD 做环比:与上一条记录的差值
    q "SELECT emp_name, dept, salary,
    LAG(salary, 1) OVER (PARTITION BY dept ORDER BY salary DESC) AS 上一名薪资,
    salary – LAG(salary, 1) OVER (PARTITION BY dept ORDER BY salary DESC) AS 与上一名差值
    FROM emp ORDER BY dept, salary DESC;"

    # ⑦ ⚠ LAST_VALUE 的默认帧陷阱验证:不写帧子句时,LAST_VALUE 等于当前行
    q "SELECT emp_name, salary,
    LAST_VALUE(salary) OVER (ORDER BY hire_date) AS 默认帧_等于当前行,
    LAST_VALUE(salary) OVER (ORDER BY hire_date
    ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING) AS 显式全帧_真正最后值
    FROM emp ORDER BY hire_date LIMIT 6;"

    # ⑧ 8.0.22+ 命名窗口:多个窗口函数复用同一窗口定义,减少重复书写
    q "SELECT dept, emp_name, salary,
    ROW_NUMBER() OVER w AS 行号,
    SUM(salary) OVER w AS 累计薪资
    FROM emp WINDOW w AS (PARTITION BY dept ORDER BY salary DESC)
    ORDER BY dept, salary DESC LIMIT 10;"

    示例 7-2:去重取最新一条 + 连续登录天数(gaps and islands)

    # ══════════════════════════════════════════════════════════════════════
    # 示例 7-2 窗口函数进阶:去重取最新 + 连续区间问题
    # 场景:① 每个用户只保留最近一笔订单(数据去重)
    # ② 统计每个用户的最长连续下单天数(运营留存分析)
    # ══════════════════════════════════════════════════════════════════════

    # ── 连接前置 ──────────────────────────────────────────────────────────
    if [[ -r "$HOME/.my.cnf" ]]; then
    MYSQL=(mysql –defaults-extra-file="$HOME/.my.cnf")
    else
    MYSQL=(mysql -u root -p)
    fi
    DB='demo'
    q() { "${MYSQL[@]}" -D "$DB" -t -e "$1"; }

    # ① 每个用户最近一笔订单:ROW_NUMBER 按 user_id 分区、时间倒序,取 rn=1
    q "WITH latest AS (
    SELECT order_id, user_id, amount, created_at,
    ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at DESC, order_id DESC) AS rn
    FROM orders
    )
    SELECT user_id, order_id, amount, created_at
    FROM latest WHERE rn = 1 ORDER BY user_id LIMIT 10;"

    # ② 对比传统写法(GROUP BY + 自连接):语义相同但更啰嗦,且取值受 ONLY_FULL_GROUP_BY 限制
    q "SELECT o.user_id, o.order_id, o.amount, o.created_at
    FROM orders o
    JOIN (SELECT user_id, MAX(created_at) AS mx FROM orders GROUP BY user_id) m
    ON o.user_id = m.user_id AND o.created_at = m.mx
    ORDER BY o.user_id LIMIT 10;"

    # ③ 每个用户订单的「上一单间隔天数」:LAG 按用户分区按时间排序
    q "WITH t AS (
    SELECT user_id, order_id, created_at,
    LAG(created_at) OVER (PARTITION BY user_id ORDER BY created_at) AS prev_at
    FROM orders
    )
    SELECT user_id, order_id, created_at, prev_at,
    DATEDIFF(created_at, prev_at) AS 距上单天数
    FROM t WHERE prev_at IS NOT NULL ORDER BY user_id, created_at LIMIT 10;"

    # ④ 连续区间(gaps and islands)核心技巧:日期序号 – 行序号 = 常数 ⇒ 同一连续段
    # 第 1 步:每个用户按天去重并编号
    q "WITH daily AS (
    SELECT DISTINCT user_id, DATE(created_at) AS d,
    ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY DATE(created_at)) AS rn
    FROM orders WHERE user_id IS NOT NULL
    ),
    grp AS (
    SELECT user_id, d, rn, DATE_SUB(d, INTERVAL rn DAY) AS grp_key
    FROM daily
    )
    SELECT user_id, MIN(d) AS 连续段起始, MAX(d) AS 连续段结束,
    DATEDIFF(MAX(d), MIN(d)) + 1 AS 连续天数
    FROM grp GROUP BY user_id, grp_key
    ORDER BY 连续天数 DESC, user_id LIMIT 10;"

    # ⑤ 用 NTILE 做用户价值分层:按消费总额分成 4 档(RFM 的 M 维度)
    q "WITH user_amt AS (
    SELECT user_id, SUM(amount) AS total FROM orders WHERE user_id IS NOT NULL GROUP BY user_id
    )
    SELECT user_id, ROUND(total, 2) AS 消费总额,
    NTILE(4) OVER (ORDER BY total DESC) AS 价值档位 — 1 为最高价值
    FROM user_amt ORDER BY 价值档位, total DESC LIMIT 12;"

    # ⑥ 性能验证:确认 PARTITION BY + ORDER BY 命中了 emp 的 idx_dept_salary 索引
    q "EXPLAIN SELECT dept, emp_name,
    ROW_NUMBER() OVER (PARTITION BY dept ORDER BY salary DESC) AS rn FROM emp\\G"

    echo "若 Extra 里出现 Using temporary / Using filesort,说明排序未能被索引消除,需补 (dept, salary) 复合索引。"


    第 8 章 视图

    讲义无此章节,依据 MySQL 8.0 / 8.4 官方标准实践补充。

    8.1 语法说明与适用场景

    (1)核心语法

    CREATE [OR REPLACE]
    [ALGORITHM = {UNDEFINED | MERGE | TEMPTABLE}]
    [DEFINER = user]
    [SQL SECURITY {DEFINER | INVOKER}]
    VIEW view_name [(col_list)] AS select_statement
    [WITH [CASCADED | LOCAL] CHECK OPTION];

    DROP VIEW [IF EXISTS] view_name [, view_name2] ...;
    SHOW CREATE VIEW view_name\\G

    (2)三种算法对比(决定性能与可更新性)
    算法执行方式性能能否用基表索引可更新
    MERGE 把视图定义合并进外层查询,一起优化 ✅ 最佳 ✅ 能 ✅
    TEMPTABLE 先物化到临时表,再基于临时表查询 ❌ 差(多一次落盘) ❌ 不能(临时表无索引) ❌ 不能
    UNDEFINED(默认) 由优化器选择,通常倾向 MERGE 视选择结果 — —

    判定:含 GROUP BY / DISTINCT / 聚合函数 / UNION 的视图,MySQL 只能用 TEMPTABLE,因此这类视图做 WHERE 过滤时会先把全量数据物化,再在临时表上过滤——性能陷阱。
    对策:对这类视图的过滤条件,应直接下推到基表查询,或改用「参数化视图 = 存储过程」。

    (3)WITH CHECK OPTION 的作用
    选项行为适用场景
    不加 通过视图 INSERT/UPDATE 的数据可以不符合视图的 WHERE 条件,写进去后视图里"消失" 只做查询的视图
    WITH LOCAL CHECK OPTION 只检查本视图的条件
    WITH CASCADED CHECK OPTION(默认) 检查本视图及所有底层视图的条件 多层视图的权限隔离

    典型用途:创建"只看本部门数据"的视图并加 CHECK OPTION,防止业务账号通过视图往其他部门写入。

    (4)可更新视图的限制

    以下结构一律不可更新(INSERT/UPDATE/DELETE 会报错):聚合函数、DISTINCT、GROUP BY、HAVING、UNION、子查询(在 SELECT 列表或 WHERE 中引用基表之外的表)、JOIN(多表可更新视图有限制)、ALGORITHM=TEMPTABLE。

    (5)适用场景
    场景用法优势注意事项
    权限隔离(最主流) 建视图只暴露必要列,授权 GRANT SELECT ON v_* 给业务账号 不暴露敏感列(密码、手机号),回收基表权限 需配合 SQL SECURITY DEFINER
    简化复杂查询 把多表 JOIN 封装成视图 业务方不用写 JOIN 注意算法退化
    兼容表结构变更 改基表后重建视图保持对外接口不变 应用无感知 视图不会自动随基表变更,需手工重建
    报表口径统一 把口径固化在视图里 避免各团队口径不一
    (6)⚠ 注意事项
  • 视图不存储数据、不能建索引。所有优化都落在基表上,视图上的 WHERE 能否下推取决于算法。
  • 视图定义"快照"了列列表:基表加列后,SELECT * 定义的视图不会自动包含新列,必须 CREATE OR REPLACE VIEW 重建。
  • 嵌套视图是排查噩梦:三层以上视图套视图,出问题时 EXPLAIN 极难读,建议不超过 2 层。
  • DEFINER 用户被删除后,以 SQL SECURITY DEFINER 创建的视图会报 ERROR 1449 无法访问。
  • 8.2 可执行 Shell 示例

    示例 8-1:建只读报表视图并用它做权限隔离

    # ══════════════════════════════════════════════════════════════════════
    # 示例 8-1 视图:报表封装 + CHECK OPTION 防越权 + 权限隔离
    # 场景:给运营同学开一个只读账号,只能看「已支付订单」,且不能看到用户手机号等敏感列
    # ══════════════════════════════════════════════════════════════════════

    # ── 连接前置 ──────────────────────────────────────────────────────────
    if [[ -r "$HOME/.my.cnf" ]]; then
    MYSQL=(mysql –defaults-extra-file="$HOME/.my.cnf")
    else
    MYSQL=(mysql -u root -p)
    fi
    DB='demo'
    q() { "${MYSQL[@]}" -D "$DB" -t -e "$1"; }

    # ① 建可更新的 MERGE 视图:只暴露已支付订单,并用 CHECK OPTION 锁死写入范围
    "${MYSQL[@]}" -D "$DB" <<'SQL'
    CREATE OR REPLACE
    ALGORITHM = MERGE — 强制合并算法,保证 WHERE 能下推到基表走索引
    SQL SECURITY DEFINER — 以定义者权限访问基表,调用者无需基表权限
    VIEW v_paid_orders AS
    SELECT order_id, user_id, amount, created_at
    FROM orders
    WHERE status = 1
    WITH CASCADED CHECK OPTION; — 任何通过该视图的写入都必须满足 status = 1
    SQL

    echo "视图 v_paid_orders 已创建"

    # ② 验证 CHECK OPTION:尝试插入一条 status=0 的数据,应被拒绝
    "${MYSQL[@]}" -D "$DB" -e "INSERT INTO v_paid_orders (user_id, amount, status) VALUES (999, 1.00, 0);" 2>&1 \\
    | grep -E 'ERROR|1369|CHECK' || echo "(若表无 status 列暴露则此步不适用,见 ③)"

    # ③ 正确的拒绝演示:视图未暴露 status 列,插入时 status 取默认值 0,触发 CHECK OPTION
    q "SELECT COUNT(*) AS 视图行数 FROM v_paid_orders;"

    # ④ 查看视图定义与算法(运维排查视图性能问题的第一步)
    q "SHOW CREATE VIEW v_paid_orders\\G"

    # ⑤ 验证 MERGE 算法确实把条件下推到基表:EXPLAIN 应显示使用 idx_status_amount 索引
    q "EXPLAIN SELECT * FROM v_paid_orders WHERE amount > 500\\G"

    # ⑥ 对比:含 GROUP BY 的视图只能走 TEMPTABLE,无法下推条件
    "${MYSQL[@]}" -D "$DB" <<'SQL'
    CREATE OR REPLACE VIEW v_dept_summary AS
    SELECT dept, COUNT(*) AS headcount, ROUND(SUM(salary),2) AS total_salary
    FROM emp GROUP BY dept;
    SQL

    q "SHOW CREATE VIEW v_dept_summary\\G" # ALGORITHM 会显示为 UNDEFINED/TEMPTABLE
    q "EXPLAIN SELECT * FROM v_dept_summary WHERE dept = '技术部'\\G" # 注意:dept 过滤无法下推到基表索引

    # ⑦ 权限隔离:建只读账号,只授予视图权限(不给基表任何权限)
    "${MYSQL[@]}" <<'SQL'
    CREATE USER IF NOT EXISTS 'report_ro'@'%' IDENTIFIED BY 'Report@2026!';
    GRANT SELECT ON demo.v_paid_orders TO 'report_ro'@'%';
    FLUSH PRIVILEGES;
    SQL

    q "SHOW GRANTS FOR 'report_ro'@'%';"

    # ⑧ 用新账号验证:能查视图,但查基表应被拒绝
    mysql -u report_ro -p'Report@2026!' -D "$DB" -t -e "SELECT COUNT(*) AS 可见订单数 FROM v_paid_orders;" 2>/dev/null
    mysql -u report_ro -p'Report@2026!' -D "$DB" -e "SELECT COUNT(*) FROM orders;" 2>&1 \\
    | grep -E 'ERROR|1142|denied' && echo "↑ 权限隔离生效:无法直连基表" || echo "(账号已存在且权限不同,请按实际调整)"

    # ⑨ 清理(按需):删除演示账号与视图
    # "${MYSQL[@]}" -e "DROP USER IF EXISTS 'report_ro'@'%'; DROP VIEW IF EXISTS demo.v_paid_orders, demo.v_dept_summary;"
    echo "清理命令已备好(注释状态)"


    第 9 章 存储过程与存储函数

    9.1 语法说明与适用场景

    (1)核心语法(沿用讲义 5.3)

    — ① 分隔符切换:过程体里有多条分号结尾的 SQL,必须先把语句结束符换成 $$
    DELIMITER $$
    CREATE PROCEDURE proc_name ([IN|OUT|INOUT] 参数名 类型 [, ...])
    [特性 ...]
    BEGIN
    — 声明顺序是硬约束:变量 → 条件 → 游标 → 处理器(顺序错了直接报 1064)
    DECLARE 变量名 类型 [DEFAULT 值];
    DECLARE 游标名 CURSOR FOR SELECT ...;
    DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = 1;
    — 业务逻辑
    END$$
    DELIMITER ; — 注意分号前有空格,改回默认结束符

    — ② 调用
    CALL proc_name(@out_param);
    SELECT @out_param;

    — ③ 存储函数
    CREATE FUNCTION func_name(参数 类型) RETURNS 返回类型
    [DETERMINISTIC | NO SQL | READS SQL DATA] — 必须声明其一,否则开启 binlog 时报错
    BEGIN ... RETURN 值; END$$

    (2)参数模式
    模式方向说明
    IN(默认) 传入 过程内修改不影响调用者
    OUT 传出 过程内赋值回传给调用者,传入时的值被忽略
    INOUT 双向 传入初始值,过程内修改后回传
    (3)流程控制
    结构语法说明
    条件 IF cond THEN … [ELSEIF …] [ELSE …] END IF; 注意 END IF 有空格
    分支 CASE val WHEN v1 THEN … ELSE … END CASE; 也有 CASE WHEN cond THEN … 形式
    循环 WHILE cond DO … END WHILE; 先判断后执行
    循环 REPEAT … UNTIL cond END REPEAT; 先执行后判断,至少执行一次
    循环 loop_label: LOOP … IF cond THEN LEAVE loop_label; END IF; END LOOP; 需手动 LEAVE 退出
    迭代 ITERATE label 类似 continue
    (4)⚠ 高频踩坑(本节最重要)
  • DELIMITER 忘记改回:DELIMITER ;(分号前有空格)。在 mysql> 交互提示符里改分隔符后忘了改回来,后续所有 SQL 都会挂起等待 $$。
  • 存储函数必须声明特性:开启 binlog 时,若函数未声明 DETERMINISTIC / NO SQL / READS SQL DATA,报 ERROR 1418。
    临时解法:SET GLOBAL log_bin_trust_function_creators = 1;(生产不建议,应正确声明特性)。
  • DECLARE 顺序强制:变量 → 条件 → 游标 → HANDLER,且所有 DECLARE 必须在 BEGIN 之后的第一段,不能和业务 SQL 混写。
  • 游标必须配 CONTINUE HANDLER FOR NOT FOUND,否则遍历结束后 FETCH 会抛 ERROR 1329: No data。
  • 存储过程名不能与内置函数同名,且 MySQL 存储过程/函数不支持重载。
  • ALTER PROCEDURE 只能改特征,改业务逻辑必须 DROP 后重建(讲义 5.3.3 明确说明)。
  • 调试困难:MySQL 没有官方存储过程调试器。生产建议用 SELECT 输出到日志表,或写日志表追踪。
  • (5)适用场景
    场景推荐理由
    定时数据归档/清理 存储过程 + 事件调度器/外部 crontab 逻辑内聚在数据库侧,免去应用改造
    复杂多步数据订正 存储过程 + 事务 要么全成功要么全回滚
    通用计算逻辑复用 存储函数 一条 SQL 里直接调用
    高并发 OLTP 核心链路 ❌ 不推荐 存储过程难以水平扩展、版本管理困难、排查成本高,应放应用层

    工程建议:课程作业中存储过程是重点考察项;真实生产中,把存储过程限制在运维类任务(归档、清理、批量订正),业务逻辑尽量留在应用层。

    9.2 可执行 Shell 示例

    示例 9-1:批量归档历史订单(带 OUT 参数与事务)

    # ══════════════════════════════════════════════════════════════════════
    # 示例 9-1 存储过程:历史订单归档(OUT 参数 + 事务 + 分批提交)
    # 场景:每月把 90 天前的已取消订单归档到 orders_archive,并从主表删除
    # ══════════════════════════════════════════════════════════════════════

    # ── 连接前置 ──────────────────────────────────────────────────────────
    if [[ -r "$HOME/.my.cnf" ]]; then
    MYSQL=(mysql –defaults-extra-file="$HOME/.my.cnf")
    else
    MYSQL=(mysql -u root -p)
    fi
    DB='demo'

    # ① 建归档表(结构复制,不复制数据)—— 幂等:IF NOT EXISTS
    "${MYSQL[@]}" -D "$DB" -e "CREATE TABLE IF NOT EXISTS orders_archive AS SELECT * FROM orders WHERE 1=0;"

    # ② 创建存储过程:IN 参数指定保留天数,OUT 参数返回实际归档行数
    # ⚠ 关键:用 heredoc 的 'SQL' 引号形式,避免 shell 展开 $ 和反引号
    "${MYSQL[@]}" -D "$DB" <<'SQL'
    DROP PROCEDURE IF EXISTS sp_archive_orders; — 幂等:先删除再重建
    DELIMITER $$
    CREATE PROCEDURE sp_archive_orders(
    IN p_keep_days INT, — 输入:保留最近 N 天,更早的归档
    IN p_batch_size INT, — 输入:单批行数,防止长事务
    OUT p_archived INT — 输出:实际归档的行数
    )
    BEGIN
    DECLARE v_done INT DEFAULT 0; — ① 必须最先声明变量
    DECLARE v_batch INT DEFAULT 0; — 本批归档行数

    DECLARE EXIT HANDLER FOR SQLEXCEPTION — ② 异常处理器:任何 SQL 错误都回滚
    BEGIN
    ROLLBACK;
    SET p_archived = -1; — 用 -1 标记失败,便于调用方判断
    END;

    SET p_archived = 0;

    — ③ 循环分批:每批 p_batch_size 行,直到没有符合条件的数据
    archive_loop: LOOP
    START TRANSACTION; — 每批独立事务,避免长事务锁表

    INSERT INTO orders_archive
    SELECT o.* FROM orders o
    WHERE o.status = 2
    AND o.created_at < DATE_SUB(NOW(), INTERVAL p_keep_days DAY)
    LIMIT p_batch_size; — LIMIT 必须写常量或变量,不能写 LIMIT ? 占位符

    SET v_batch = ROW_COUNT(); — 本批实际插入行数

    DELETE FROM orders
    WHERE status = 2
    AND created_at < DATE_SUB(NOW(), INTERVAL p_keep_days DAY)
    LIMIT p_batch_size;

    COMMIT;
    SET p_archived = p_archived + v_batch;

    IF v_batch = 0 THEN — 本批为 0 说明数据处理完毕
    LEAVE archive_loop; — LEAVE 退出 LOOP
    END IF;
    END LOOP archive_loop;
    END$$
    DELIMITER ;
    SQL
    echo "存储过程 sp_archive_orders 创建完成"

    # ③ 查看已创建的存储过程(8.0 可用 information_schema.ROUTINES 精确过滤)
    "${MYSQL[@]}" -D "$DB" -t -e "
    SELECT ROUTINE_NAME, ROUTINE_TYPE FROM information_schema.ROUTINES
    WHERE ROUTINE_SCHEMA = '${DB}' ORDER BY ROUTINE_TYPE, ROUTINE_NAME;"

    "${MYSQL[@]}" -D "$DB" -e "SHOW CREATE PROCEDURE sp_archive_orders\\G" | head -20

    # ④ 调用:IN 参数传值,OUT 参数必须用用户变量 @xxx 接收
    "${MYSQL[@]}" -D "$DB" -e "CALL sp_archive_orders(90, 500, @archived); SELECT @archived AS 归档行数;"
    # 返回 -1 表示执行中发生了异常并回滚

    # ⑤ 验证结果:归档表新增多少,主表对应减少多少
    "${MYSQL[@]}" -D "$DB" -t -e "
    SELECT (SELECT COUNT(*) FROM orders_archive) AS 归档表行数,
    (SELECT COUNT(*) FROM orders WHERE status = 2) AS 主表剩余已取消行数;"

    示例 9-2:存储函数 + 游标逐行处理

    # ══════════════════════════════════════════════════════════════════════
    # 示例 9-2 存储函数与游标:格式校验函数 + 逐行处理的游标模板
    # 场景:① 封装一个可复用的手机号校验函数
    # ② 用游标逐行扫描并生成每个用户的汇总记录(演示 DECLARE 顺序铁律)
    # ══════════════════════════════════════════════════════════════════════

    # ── 连接前置 ──────────────────────────────────────────────────────────
    if [[ -r "$HOME/.my.cnf" ]]; then
    MYSQL=(mysql –defaults-extra-file="$HOME/.my.cnf")
    else
    MYSQL=(mysql -u root -p)
    fi
    DB='demo'

    # ① 创建存储函数:必须声明 DETERMINISTIC / NO SQL / READS SQL DATA 之一
    # 否则开启 binlog 时报 ERROR 1418
    "${MYSQL[@]}" -D "$DB" <<'SQL'
    DROP FUNCTION IF EXISTS fn_is_mobile;
    DELIMITER $$
    CREATE FUNCTION fn_is_mobile(p_phone VARCHAR(32))
    RETURNS TINYINT
    DETERMINISTIC — 相同输入恒定相同输出,必须声明
    BEGIN
    IF p_phone IS NULL THEN — NULL 兜底:任何与 NULL 的比较都是 NULL
    RETURN 0;
    END IF;
    RETURN p_phone REGEXP '^1[3-9][0-9]{9}$'; — 8.0 的 ICU 正则引擎
    END$$
    DELIMITER ;
    SQL

    echo "函数 fn_is_mobile 创建完成"

    # ② 调用存储函数:可以像内置函数一样在 SQL 里直接用
    "${MYSQL[@]}" -D "$DB" -t -e "
    SELECT fn_is_mobile('13812345678') AS 合法,
    fn_is_mobile('12345') AS 非法,
    fn_is_mobile(NULL) AS NULL输入;"

    # ③ 建汇总表(游标演示的目标表)
    "${MYSQL[@]}" -D "$DB" -e "
    CREATE TABLE IF NOT EXISTS user_summary (
    user_id INT PRIMARY KEY,
    order_cnt INT NOT NULL DEFAULT 0,
    total_amt DECIMAL(12,2) NOT NULL DEFAULT 0,
    updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
    ) ENGINE=InnoDB;"

    # ④ 游标模板:演示 DECLARE 的强制顺序(变量 → 条件 → 游标 → HANDLER)
    "${MYSQL[@]}" -D "$DB" <<'SQL'
    DROP PROCEDURE IF EXISTS sp_build_user_summary;
    DELIMITER $$
    CREATE PROCEDURE sp_build_user_summary()
    BEGIN
    — ⚠ DECLARE 顺序不可乱:变量 → 条件 → 游标 → HANDLER,且必须集中在 BEGIN 之后
    DECLARE v_user_id INT; — ① 变量
    DECLARE v_cnt INT DEFAULT 0;
    DECLARE v_amt DECIMAL(12,2) DEFAULT 0;
    DECLARE v_done TINYINT DEFAULT 0; — 游标结束标志

    DECLARE cur_user CURSOR FOR — ② 游标
    SELECT user_id, COUNT(*), SUM(amount)
    FROM orders WHERE user_id IS NOT NULL
    GROUP BY user_id;

    DECLARE CONTINUE HANDLER FOR NOT FOUND — ③ 处理器:必须配,否则报 1329
    SET v_done = 1;

    OPEN cur_user; — ④ 打开游标
    read_loop: LOOP
    FETCH cur_user INTO v_user_id, v_cnt, v_amt; — 取一行到变量
    IF v_done = 1 THEN
    LEAVE read_loop; — 取完退出
    END IF;

    INSERT INTO user_summary (user_id, order_cnt, total_amt)
    VALUES (v_user_id, v_cnt, v_amt)
    ON DUPLICATE KEY UPDATE — 幂等:重复执行只更新不报错
    order_cnt = v_cnt, total_amt = v_amt, updated_at = NOW();
    END LOOP;
    CLOSE cur_user; — ⑤ 关闭游标
    END$$
    DELIMITER ;
    SQL
    echo "存储过程 sp_build_user_summary 创建完成"

    # ⑤ 调用并验证(可重复执行,ON DUPLICATE KEY 保证幂等)
    "${MYSQL[@]}" -D "$DB" -e "CALL sp_build_user_summary();"
    "${MYSQL[@]}" -D "$DB" -t -e "SELECT * FROM user_summary ORDER BY total_amt DESC LIMIT 5;"

    # ⑥ 说明:上面这个例子用 INSERT … SELECT 一条 SQL 就能搞定,游标是「演示语法」而非最优解
    "${MYSQL[@]}" -D "$DB" -t -e "
    SELECT '等价的高效写法:REPLACE INTO user_summary SELECT user_id, COUNT(*), SUM(amount), NOW()
    FROM orders WHERE user_id IS NOT NULL GROUP BY user_id;' AS 优化建议;"

    # ⑦ 清理
    "${MYSQL[@]}" -D "$DB" -e "DROP PROCEDURE IF EXISTS sp_build_user_summary;"
    echo "演示用游标过程已清理"


    第 10 章 触发器

    讲义无此章节,依据 MySQL 8.0 / 8.4 官方标准实践补充。

    10.1 语法说明与适用场景

    (1)核心语法

    DELIMITER $$
    CREATE TRIGGER trigger_name
    {BEFORE | AFTER} {INSERT | UPDATE | DELETE}
    ON table_name FOR EACH ROW
    [FOLLOWS | PRECEDES other_trigger] — 8.0 支持同事件多触发器并指定顺序
    BEGIN
    — NEW.col :INSERT/UPDATE 时的「新值」(DELETE 无 NEW)
    — OLD.col :UPDATE/DELETE 时的「旧值」(INSERT 无 OLD)
    END$$
    DELIMITER ;

    SHOW TRIGGERS [FROM db] [\\G]
    DROP TRIGGER [IF EXISTS] trigger_name;

    (2)六个触发时机与 NEW / OLD 可用性
    时机NEWOLD能否修改 NEW
    BEFORE INSERT ✅ ❌ ✅ 可改(用于默认值填充、格式规范化)
    AFTER INSERT ✅ ❌ ❌ 已落盘,改了无效
    BEFORE UPDATE ✅ ✅ ✅ 可改(用于校验并纠正、防超卖)
    AFTER UPDATE ✅ ✅ ❌
    BEFORE DELETE ❌ ✅ ❌
    AFTER DELETE ❌ ✅ ❌

    MySQL 只有 FOR EACH ROW(行级触发器),不支持语句级触发器——这意味着批量更新 100 万行会触发 100 万次,写放大极其严重。

    (3)适用场景对比
    场景推荐做法理由
    审计日志(记录谁改了什么) AFTER INSERT/UPDATE/DELETE 写审计表 应用层遗漏时仍有兜底
    数据校验与拒绝 BEFORE + SIGNAL SQLSTATE '45000' 在数据落盘前拦截,最干净
    冗余统计表维护 AFTER 更新汇总表 避免每次统计都全表扫描
    默认字段填充 BEFORE INSERT SET NEW.col = … 集中处理,不依赖应用
    跨表同步(如库存扣减) ⚠ 慎用 分布式事务、并发扣减、主库写放大都是问题,建议在应用层用事务 + 乐观锁
    复杂业务逻辑 ❌ 不用 触发器"隐形",排查时看不见,是运维噩梦
    (4)⚠ 触发器的重要限制(必须记住)
  • 不能对本表再做 DML:在 orders 的触发器里 UPDATE orders 会报 ERROR 1442: Can't update table 'orders' … already being used by the statement。
  • 不能有事务控制语句:触发器体内禁止 COMMIT / ROLLBACK / START TRANSACTION(触发器本身在触发语句的事务内)。
  • 不支持动态 SQL,不能调用返回结果集的存储过程。
  • 返回错误用 SIGNAL:SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '库存不足';
  • 主从复制:基于 STATEMENT 格式复制时,触发器在从库也会执行;基于 ROW 格式时,触发器产生的变更会随行变更一起复制。8.x 默认 binlog_format=ROW,这是推荐配置。
  • 性能:行级触发器把单条 SQL 变成 N+1 条,大批量导入前必须 DROP TRIGGER,导完再建回。
  • 10.2 可执行 Shell 示例

    示例 10-1:订单审计触发器(记录变更前后完整快照)

    # ══════════════════════════════════════════════════════════════════════
    # 示例 10-1 触发器:订单变更审计 + 状态流转校验
    # 场景:合规要求「订单金额与状态的每次变更都要留痕」,且不允许从已支付改回待支付
    # ══════════════════════════════════════════════════════════════════════

    # ── 连接前置 ──────────────────────────────────────────────────────────
    if [[ -r "$HOME/.my.cnf" ]]; then
    MYSQL=(mysql –defaults-extra-file="$HOME/.my.cnf")
    else
    MYSQL=(mysql -u root -p)
    fi
    DB='demo'
    q() { "${MYSQL[@]}" -D "$DB" -t -e "$1"; }

    # ① 建审计表:记录变更前后的完整快照 + 操作类型 + 时间 + 操作者
    "${MYSQL[@]}" -D "$DB" <<'SQL'
    CREATE TABLE IF NOT EXISTS orders_audit (
    audit_id BIGINT PRIMARY KEY AUTO_INCREMENT,
    order_id BIGINT NOT NULL,
    action ENUM('INSERT','UPDATE','DELETE') NOT NULL,
    old_status TINYINT,
    new_status TINYINT,
    old_amount DECIMAL(10,2),
    new_amount DECIMAL(10,2),
    changed_by VARCHAR(64) DEFAULT (CURRENT_USER()), — 记录执行账号,合规追溯
    changed_at DATETIME DEFAULT CURRENT_TIMESTAMP,
    KEY idx_order (order_id),
    KEY idx_time (changed_at)
    ) ENGINE=InnoDB;
    SQL

    echo "审计表 orders_audit 就绪"

    # ② INSERT 触发器:新增订单时落一条审计(此时只有 NEW,没有 OLD)
    "${MYSQL[@]}" -D "$DB" <<'SQL'
    DROP TRIGGER IF EXISTS trg_orders_ai;
    DELIMITER $$
    CREATE TRIGGER trg_orders_ai AFTER INSERT ON orders FOR EACH ROW
    BEGIN
    INSERT INTO orders_audit (order_id, action, new_status, new_amount)
    VALUES (NEW.order_id, 'INSERT', NEW.status, NEW.amount);
    END$$
    DELIMITER ;
    SQL

    echo "INSERT 触发器已建"

    # ③ UPDATE 触发器:同时记录 OLD 与 NEW,并校验状态流转合法性
    "${MYSQL[@]}" -D "$DB" <<'SQL'
    DROP TRIGGER IF EXISTS trg_orders_au;
    DELIMITER $$
    CREATE TRIGGER trg_orders_au BEFORE UPDATE ON orders FOR EACH ROW
    BEGIN
    — ① 业务规则:已支付(1) 不允许回退到待支付(0),用 SIGNAL 抛错中断
    IF OLD.status = 1 AND NEW.status = 0 THEN
    SIGNAL SQLSTATE '45000'
    SET MESSAGE_TEXT = '业务规则拒绝:已支付订单不能回退为待支付';
    END IF;

    — ② 金额不允许被改成负数(BEFORE 触发器可以修改 NEW 的值,这里是校验)
    IF NEW.amount < 0 THEN
    SIGNAL SQLSTATE '45000'
    SET MESSAGE_TEXT = '业务规则拒绝:订单金额不能为负数';
    END IF;
    END$$
    DELIMITER ;
    SQL

    # ④ AFTER UPDATE 触发器:落审计(OLD 与 NEW 都可用)
    "${MYSQL[@]}" -D "$DB" <<'SQL'
    DROP TRIGGER IF EXISTS trg_orders_au_log;
    DELIMITER $$
    CREATE TRIGGER trg_orders_au_log AFTER UPDATE ON orders FOR EACH ROW
    BEGIN
    — 只在真正发生变化时记录,避免无意义更新刷爆审计表
    IF OLD.status <> NEW.status OR OLD.amount <> NEW.amount THEN
    INSERT INTO orders_audit (order_id, action, old_status, new_status, old_amount, new_amount)
    VALUES (NEW.order_id, 'UPDATE', OLD.status, NEW.status, OLD.amount, NEW.amount);
    END IF;
    END$$
    DELIMITER ;
    SQL

    echo "UPDATE 触发器已建(含业务校验 + 审计)"

    # ⑤ 验证触发器:正常更新应成功并落审计
    "${MYSQL[@]}" -D "$DB" -e "
    INSERT INTO orders (user_id, amount, status) VALUES (8888, 199.00, 0);
    UPDATE orders SET status = 1 WHERE user_id = 8888 AND status = 0 LIMIT 1;"

    q "SELECT order_id, action, old_status, new_status, old_amount, new_amount, changed_by
    FROM orders_audit ORDER BY audit_id DESC LIMIT 5;"

    # ⑥ 验证校验规则:把已支付改回待支付,应被 SIGNAL 拒绝
    "${MYSQL[@]}" -D "$DB" -e "UPDATE orders SET status = 0 WHERE user_id = 8888 LIMIT 1;" 2>&1 \\
    | grep -E 'ERROR|1644|业务规则拒绝' && echo "↑ 业务规则生效,非法状态流转已被拦截" || echo "(未触发,请检查触发器是否创建)"

    # ⑦ 查看已建触发器(8.0 支持同事件多触发器,用 ACTION_ORDER 看执行顺序)
    q "SELECT TRIGGER_NAME, EVENT_MANIPULATION, ACTION_TIMING, ACTION_ORDER
    FROM information_schema.TRIGGERS WHERE EVENT_OBJECT_SCHEMA = '${DB}'
    ORDER BY EVENT_OBJECT_TABLE, ACTION_TIMING, ACTION_ORDER;"

    # ⑧ ⚠ 大批量导入前必须禁用触发器(行级触发器 = N 次额外写入)
    q "SELECT '批量导入前:DROP TRIGGER trg_orders_au_log; 导入完成后再 CREATE 回来' AS 性能提示;"

    # ⑨ 清理演示数据(触发器保留)
    "${MYSQL[@]}" -D "$DB" -e "DELETE FROM orders WHERE user_id = 8888;"
    echo "演示数据已清理"

    示例 10-2:冗余统计表自动维护(AFTER 触发器)

    # ══════════════════════════════════════════════════════════════════════
    # 示例 10-2 触发器:自动维护冗余统计表,避免每次查全表
    # 场景:首页要显示「各状态订单数」,数据量千万级,不能每次 COUNT(*)
    # ══════════════════════════════════════════════════════════════════════

    # ── 连接前置 ──────────────────────────────────────────────────────────
    if [[ -r "$HOME/.my.cnf" ]]; then
    MYSQL=(mysql –defaults-extra-file="$HOME/.my.cnf")
    else
    MYSQL=(mysql -u root -p)
    fi
    DB='demo'
    q() { "${MYSQL[@]}" -D "$DB" -t -e "$1"; }

    # ① 建统计表并初始化(先全量算一次,之后靠触发器增量维护)
    "${MYSQL[@]}" -D "$DB" <<'SQL'
    DROP TABLE IF EXISTS orders_stat;
    CREATE TABLE orders_stat (
    status TINYINT PRIMARY KEY,
    order_cnt BIGINT NOT NULL DEFAULT 0,
    total_amt DECIMAL(14,2) NOT NULL DEFAULT 0
    ) ENGINE=InnoDB;

    INSERT INTO orders_stat (status, order_cnt, total_amt)
    SELECT status, COUNT(*), COALESCE(SUM(amount),0) FROM orders GROUP BY status;
    SQL
    q "SELECT * FROM orders_stat ORDER BY status;"

    # ② INSERT 后 +1(ON DUPLICATE KEY 保证新状态自动建行)
    "${MYSQL[@]}" -D "$DB" <<'SQL'
    DROP TRIGGER IF EXISTS trg_orders_stat_ins;
    DELIMITER $$
    CREATE TRIGGER trg_orders_stat_ins AFTER INSERT ON orders FOR EACH ROW
    BEGIN
    INSERT INTO orders_stat (status, order_cnt, total_amt)
    VALUES (NEW.status, 1, NEW.amount)
    ON DUPLICATE KEY UPDATE
    order_cnt = order_cnt + 1,
    total_amt = total_amt + NEW.amount;
    END$$
    DELIMITER ;
    SQL

    # ③ DELETE 后 -1
    "${MYSQL[@]}" -D "$DB" <<'SQL'
    DROP TRIGGER IF EXISTS trg_orders_stat_del;
    DELIMITER $$
    CREATE TRIGGER trg_orders_stat_del AFTER DELETE ON orders FOR EACH ROW
    BEGIN
    UPDATE orders_stat
    SET order_cnt = order_cnt – 1, total_amt = total_amt – OLD.amount
    WHERE status = OLD.status;
    END$$
    DELIMITER ;
    SQL

    # ④ UPDATE 时按状态分组调整:先看状态是否变化,再决定加在哪、减在哪
    "${MYSQL[@]}" -D "$DB" <<'SQL'
    DROP TRIGGER IF EXISTS trg_orders_stat_upd;
    DELIMITER $$
    CREATE TRIGGER trg_orders_stat_upd AFTER UPDATE ON orders FOR EACH ROW
    BEGIN
    IF OLD.status = NEW.status THEN
    — 状态没变,只调金额
    UPDATE orders_stat SET total_amt = total_amt – OLD.amount + NEW.amount
    WHERE status = NEW.status;
    ELSE
    — 状态变了:旧状态 -1,新状态 +1
    UPDATE orders_stat SET order_cnt = order_cnt – 1, total_amt = total_amt – OLD.amount
    WHERE status = OLD.status;
    INSERT INTO orders_stat (status, order_cnt, total_amt)
    VALUES (NEW.status, 1, NEW.amount)
    ON DUPLICATE KEY UPDATE
    order_cnt = order_cnt + 1, total_amt = total_amt + NEW.amount;
    END IF;
    END$$
    DELIMITER ;
    SQL

    echo "统计表维护触发器已建(INSERT / UPDATE / DELETE 三个)"

    # ⑤ 一致性验证:触发器维护的统计值 应 == 实时 COUNT/SUM 的结果
    "${MYSQL[@]}" -D "$DB" -e "INSERT INTO orders (user_id, amount, status) VALUES (7777, 50.00, 0);"
    q "SELECT s.status,
    s.order_cnt AS 触发器统计,
    (SELECT COUNT(*) FROM orders o WHERE o.status = s.status) AS 实际行数,
    s.order_cnt = (SELECT COUNT(*) FROM orders o WHERE o.status = s.status) AS 是否一致
    FROM orders_stat s ORDER BY s.status;"

    # ⑥ 修复漂移:若发现不一致,用一条 SQL 重算(触发器的兜底方案,建议每天跑一次)
    "${MYSQL[@]}" -D "$DB" -e "
    TRUNCATE TABLE orders_stat;
    INSERT INTO orders_stat (status, order_cnt, total_amt)
    SELECT status, COUNT(*), COALESCE(SUM(amount),0) FROM orders GROUP BY status;"

    q "SELECT '已全量重算统计表(建议配 crontab 每日低峰执行)' AS 说明;"

    # ⑦ 清理
    "${MYSQL[@]}" -D "$DB" -e "DELETE FROM orders WHERE user_id = 7777;"
    echo "演示数据已清理"


    第 11 章 事务与锁

    讲义无此章节,依据 MySQL 8.0 / 8.4 官方标准实践补充。

    11.1 语法说明与适用场景

    (1)事务语法

    START TRANSACTION [READ ONLY | READ WRITE] [WITH CONSISTENT SNAPSHOT];
    — 或 BEGIN / BEGIN WORK(等价)
    SAVEPOINT savepoint_name; — 设置保存点
    ROLLBACK TO SAVEPOINT sp; — 回滚到保存点(事务不结束)
    RELEASE SAVEPOINT sp;
    COMMIT; — 提交
    ROLLBACK; — 全部回滚

    SET [SESSION|GLOBAL] TRANSACTION ISOLATION LEVEL
    {READ UNCOMMITTED | READ COMMITTED | REPEATABLE READ | SERIALIZABLE};
    SELECT @@transaction_isolation; — 8.0+ 变量名(5.7 是 @@tx_isolation)
    SELECT @@autocommit;

    WITH CONSISTENT SNAPSHOT:仅对 REPEATABLE READ 有效,在 START TRANSACTION 立即建立一致性视图(而不是等第一条 SQL)。mysqldump –single-transaction 正是用这个实现无锁备份。

    (2)四种隔离级别对比
    隔离级别脏读不可重复读幻读并发度说明
    READ UNCOMMITTED ❌ 有 ❌ 有 ❌ 有 最高 基本不用
    READ COMMITTED ✅ 无 ❌ 有 ❌ 有 高 每次读都新建 readview;互联网公司常用(减少锁范围)
    REPEATABLE READ(InnoDB 默认) ✅ 无 ✅ 无 ✅ InnoDB 已解决 中 靠 MVCC + Next-Key Lock
    SERIALIZABLE ✅ 无 ✅ 无 ✅ 无 最低 所有读加共享锁,基本不用

    InnoDB 的 RR 已解决幻读:快照读靠 MVCC(readview),当前读靠 Next-Key Lock(行锁 + 间隙锁)锁住范围。这与 SQL 标准里"RR 允许幻读"不同,是 InnoDB 的实现优势。

    (3)锁类型与排查入口
    锁类型触发方式排查视图(8.0+)
    行锁(Record Lock) UPDATE/DELETE 命中索引 performance_schema.data_locks
    间隙锁(Gap Lock) RR 下的范围查询 / 当前读 同上,LOCK_MODE 含 GAP
    临键锁(Next-Key) RR 默认行锁形式 同上,LOCK_MODE 含 X / X,GAP
    意向锁(IS/IX) 自动加,表级 同上
    表锁 LOCK TABLES t READ/WRITE SHOW OPEN TABLES WHERE In_use > 0
    元数据锁(MDL) 任何 DML 都会加;DDL 会被长事务阻塞 performance_schema.metadata_locks
    自增锁(AUTO-INC) 插入自增列 innodb_autoinc_lock_mode(8.0 默认 2)

    加锁读语法:

    SELECT ... FOR UPDATE; — 排他锁(X 锁),其他事务不能读也不能改(当前读)
    SELECT ... FOR SHARE; — 共享锁(S 锁),8.0+;等价于旧的 LOCK IN SHARE MODE
    SELECT ... FOR UPDATE NOWAIT; — 8.0+:拿不到锁立即报错,不等待
    SELECT ... FOR UPDATE SKIP LOCKED; — 8.0+:跳过被锁的行(队列场景神器)

    (4)⚠ MySQL 8.4 的破坏性变更(本章重点)
    旧写法8.4 现状正确写法
    information_schema.INNODB_LOCKS 已删除 performance_schema.data_locks
    information_schema.INNODB_LOCK_WAITS 已删除 performance_schema.data_lock_waits
    SHOW MASTER STATUS 已删除 SHOW BINARY LOG STATUS
    SELECT @@tx_isolation 8.0 起移除 SELECT @@transaction_isolation
    (5)死锁与锁等待
    参数默认值说明
    innodb_lock_wait_timeout 50 秒 锁等待超时,超时报 ERROR 1205
    innodb_deadlock_detect ON 死锁检测开启;高并发可关掉改用超时,降低检测开销
    innodb_rollback_on_timeout OFF 超时时只回滚最后一条语句,不是整个事务

    死锁排查:SHOW ENGINE INNODB STATUS\\G → 找 LATEST DETECTED DEADLOCK 段(只保留最近一次)。
    实时锁等待:SELECT * FROM sys.innodb_lock_waits;(sys 库视图,最直观)。

    (6)适用场景
    场景方案
    转账/扣款等多步写 START TRANSACTION + 全部成功才 COMMIT
    无锁热备份 START TRANSACTION WITH CONSISTENT SNAPSHOT + 一致性读
    秒杀防超卖 UPDATE stock SET n = n-1 WHERE id=? AND n >= 1(原子 UPDATE 自带行锁),或 FOR UPDATE
    任务队列并发取任务 SELECT … FOR UPDATE SKIP LOCKED LIMIT 1(8.0+)
    大批量数据订正 分批 + 每批独立事务,LIMIT 1000 循环,避免长事务
    在线 DDL 被卡住 查 metadata_locks → 找到未提交的长事务

    11.2 可执行 Shell 示例

    示例 11-1:转账事务 + 保存点 + 隔离级别验证

    # ══════════════════════════════════════════════════════════════════════
    # 示例 11-1 事务:转账原子性 + SAVEPOINT 部分回滚 + 隔离级别验证
    # 场景:账户间转账必须要么全成功要么全失败;中间步骤失败要能部分回滚
    # ══════════════════════════════════════════════════════════════════════

    # ── 连接前置 ─────────────────────────────────────════════─────────────
    if [[ -r "$HOME/.my.cnf" ]]; then
    MYSQL=(mysql –defaults-extra-file="$HOME/.my.cnf")
    else
    MYSQL=(mysql -u root -p)
    fi
    DB='demo'
    q() { "${MYSQL[@]}" -D "$DB" -t -e "$1"; }

    # ① 建账户表并初始化两个账户
    "${MYSQL[@]}" -D "$DB" <<'SQL'
    CREATE TABLE IF NOT EXISTS account (
    id INT PRIMARY KEY,
    name VARCHAR(32) NOT NULL,
    balance DECIMAL(12,2) NOT NULL DEFAULT 0 CHECK (balance >= 0) — CHECK 约束 8.0.16+ 才真正生效
    ) ENGINE=InnoDB;
    REPLACE INTO account (id, name, balance) VALUES (1,'张三',1000.00), (2,'李四',1000.00);
    SQL

    q "SELECT @@autocommit AS 自动提交状态, @@transaction_isolation AS 当前隔离级别;"
    q "SELECT * FROM account;"

    # ② 完整事务:转出 + 转入,两步都成功才 COMMIT
    "${MYSQL[@]}" -D "$DB" <<'SQL'
    START TRANSACTION; — ① 开启事务,关闭自动提交
    UPDATE account SET balance = balance – 200 WHERE id = 1; — ② 张三 -200
    UPDATE account SET balance = balance + 200 WHERE id = 2; — ③ 李四 +200
    COMMIT; — ④ 提交,两步同时生效
    SQL

    q "SELECT * FROM account;"

    # ③ 回滚演示:模拟第二步失败,整个事务回滚(余额不变)
    "${MYSQL[@]}" -D "$DB" <<'SQL'
    START TRANSACTION;
    UPDATE account SET balance = balance – 300 WHERE id = 1;
    — 假设此处应用检测到异常(如余额不足、账户冻结)
    ROLLBACK; — 全部撤销,balance -300 不生效
    SQL

    q "SELECT * FROM account; — 余额应与上一步相同,证明 ROLLBACK 生效"

    # ④ SAVEPOINT 部分回滚:只撤销保存点之后的操作,之前的操作仍可提交
    "${MYSQL[@]}" -D "$DB" <<'SQL'
    START TRANSACTION;
    UPDATE account SET balance = balance – 100 WHERE id = 1;
    SAVEPOINT sp1; — 设置保存点
    UPDATE account SET balance = balance – 500 WHERE id = 1;
    ROLLBACK TO SAVEPOINT sp1; — 只撤销 -500,保留 -100
    COMMIT; — 最终只扣 100
    SQL

    q "SELECT * FROM account; — 张三应为 1000-200-100=700"

    # ⑤ 隔离级别验证:REPEATABLE READ 下,同一事务内两次读到的数据一致
    # 需要在两个终端做,这里用脚本模拟:终端 A 开启事务读两次,中间用另一个连接改数据
    "${MYSQL[@]}" -D "$DB" -e "REPLACE INTO account (id,name,balance) VALUES (1,'张三',1000.00);" # 重置

    # 会话 A(本连接):BEGIN → 读一次 → 留出 10 秒窗口 → 再读一次,两次结果应相同(RR 特性)
    # 请在这 10 秒内,另开一个终端执行:UPDATE account SET balance = balance + 100 WHERE id = 1; COMMIT;
    echo ">>> 请在接下来 10 秒内,在另一个终端执行:UPDATE account SET balance=balance+100 WHERE id=1; COMMIT;"
    "${MYSQL[@]}" -D "$DB" –init-command="SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ" <<'SQL'
    START TRANSACTION;
    SELECT balance AS RR_第一次读 FROM account WHERE id = 1;
    SELECT SLEEP(10); — 留出窗口供另一个会话改数据并提交
    SELECT balance AS RR_第二次读 FROM account WHERE id = 1; — 与第一次相同(可重复读)
    COMMIT;
    SQL

    # ⑥ READ COMMITTED 对比:每次读都看到最新已提交数据(不可重复读)
    "${MYSQL[@]}" -D "$DB" -t -e "
    SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
    START TRANSACTION;
    SELECT balance AS RC_第一次读 FROM account WHERE id = 1;
    SELECT balance AS RC_第二次读 FROM account WHERE id = 1;
    COMMIT;"

    # ⑦ 长事务排查:运维必查的活跃事务列表(超过 60 秒的要重点关注)
    q "SELECT trx_id, trx_started, trx_wait_started,
    TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS 已运行秒数,
    LEFT(trx_query, 50) AS 当前SQL
    FROM information_schema.INNODB_TRX
    ORDER BY trx_started LIMIT 10;"

    # ⑧ 清理
    "${MYSQL[@]}" -D "$DB" -e "DROP TABLE IF EXISTS account;"
    echo "演示账户表已清理"

    示例 11-2:锁等待复现、排查与死锁分析

    # ══════════════════════════════════════════════════════════════════════
    # 示例 11-2 锁:锁等待复现 + sys.innodb_lock_waits 排查 + 死锁分析
    # 场景:线上出现「SQL 执行卡住不动」,需要定位是哪个事务持锁、该杀谁
    # ══════════════════════════════════════════════════════════════════════

    # ── 连接前置 ──────────────────────────────────────────────────────────
    if [[ -r "$HOME/.my.cnf" ]]; then
    MYSQL=(mysql –defaults-extra-file="$HOME/.my.cnf")
    else
    MYSQL=(mysql -u root -p)
    fi
    DB='demo'
    q() { "${MYSQL[@]}" -D "$DB" -t -e "$1"; }

    # ① 确认锁等待超时时间与死锁检测开关
    q "SELECT @@innodb_lock_wait_timeout AS 锁等待超时秒数,
    @@innodb_deadlock_detect AS 死锁检测,
    @@innodb_autoinc_lock_mode AS 自增锁模式;"

    # ② 复现锁等待:会话 A 持锁不提交(后台运行,模拟跑事务的应用)
    "${MYSQL[@]}" -D "$DB" <<'SQL'
    START TRANSACTION;
    UPDATE orders SET amount = amount + 1 WHERE order_id = (SELECT MIN(order_id) FROM orders);
    SELECT CONNECTION_ID() AS 持锁会话ID, '事务未提交,锁保持中' AS 状态;
    SQL

    # ③ 复现锁等待:会话 B 改同一行 —— 会被阻塞直到超时(ERROR 1205)
    # 用 timeout 命令限时 6 秒,避免脚本卡死
    timeout 6 "${MYSQL[@]}" -D "$DB" -e "
    START TRANSACTION;
    UPDATE orders SET amount = amount + 1 WHERE order_id = (SELECT MIN(order_id) FROM orders);
    COMMIT;"
    2>&1 | grep -E 'ERROR|1205|Lock wait' && echo "↑ 已复现锁等待超时(ERROR 1205)" \\
    || echo "(持有锁的事务已结束,未复现;请开两个终端手动执行 ② 和 ③)"

    # ④ 8.0+ 实时锁等待排查(推荐):sys.innodb_lock_waits 一眼看清谁等谁
    q "SELECT waiting_pid AS 等待方PID, waiting_query AS 等待方SQL,
    blocking_pid AS 阻塞方PID, LEFT(blocking_query, 40) AS 阻塞方SQL,
    wait_age_secs AS 已等待秒数
    FROM sys.innodb_lock_waits\\G"

    # ⑤ 8.0+ 锁明细(替代 8.4 已删除的 information_schema.INNODB_LOCKS)
    q "SELECT lock_type, lock_mode, lock_status, LEFT(lock_data, 30) AS lock_data
    FROM performance_schema.data_locks LIMIT 10;"

    # ⑥ 锁等待关系(替代 8.4 已删除的 INNODB_LOCK_WAITS)
    q "SELECT requesting_engine_lock_id, blocking_engine_lock_id
    FROM performance_schema.data_lock_waits LIMIT 5;"

    # ⑦ 找到阻塞源后:用 KILL 终止会话(生产环境务必先确认业务影响)
    BLOCKER_PID=$("${MYSQL[@]}" -D "$DB" -N -B -e "
    SELECT blocking_pid FROM sys.innodb_lock_waits LIMIT 1;" 2>/dev/null)

    if [[ -n "$BLOCKER_PID" ]]; then
    echo "检测到阻塞源 PID=$BLOCKER_PID"
    # 先查该会话在干什么,再决定是否 KILL
    q "SELECT id, user, host, db, command, time, LEFT(info, 60) AS sql_text
    FROM information_schema.PROCESSLIST WHERE id = ${BLOCKER_PID};"

    # "${MYSQL[@]}" -e "KILL ${BLOCKER_PID};" # 确认无误后取消注释执行
    echo "KILL 命令已备好(注释状态):先确认业务影响再执行"
    else
    echo "当前无锁等待"
    fi

    # ⑧ 死锁排查:SHOW ENGINE INNODB STATUS 的 LATEST DETECTED DEADLOCK 段
    "${MYSQL[@]}" -e "SHOW ENGINE INNODB STATUS\\G" | awk '/LATEST DETECTED DEADLOCK/,/TRANSACTIONS/' | head -40

    # ⑨ 乐观并发控制(生产推荐):用原子 UPDATE 代替 SELECT … FOR UPDATE,避免显式加锁
    q "SELECT 'UPDATE stock SET n = n – 1 WHERE id = 1 AND n >= 1; — 受影响行数=1 才算扣减成功' AS 防超卖推荐写法;"

    # ⑩ 8.0+ 队列场景神器:SKIP LOCKED 跳过被锁行,多消费者互不阻塞
    q "SELECT 'SELECT * FROM task WHERE status = 0 ORDER BY id LIMIT 1 FOR UPDATE SKIP LOCKED;' AS 并发取任务写法;"


    第 12 章 索引优化与执行计划

    讲义无此章节,依据 MySQL 8.0 / 8.4 官方标准实践补充。

    12.1 语法说明与适用场景

    (1)索引类型与创建语法

    — 普通 / 唯一 / 复合 / 前缀 / 全文 / 空间
    CREATE INDEX idx_name ON tbl (col);
    CREATE UNIQUE INDEX uk_name ON tbl (col);
    CREATE INDEX idx_multi ON tbl (col1, col2, col3); — 复合索引,遵循最左前缀
    CREATE INDEX idx_prefix ON tbl (varchar_col(16)); — 前缀索引,节省空间
    CREATE FULLTEXT INDEX ft_name ON tbl (col) WITH PARSER ngram; — 全文索引
    ALTER TABLE tbl ADD INDEX idx (col);
    ALTER TABLE tbl DROP INDEX idx;

    — 8.0+ 不可见索引(灰度验证索引是否有用,不用真删)
    ALTER TABLE tbl ALTER INDEX idx_name INVISIBLE; — 对优化器隐藏
    ALTER TABLE tbl ALTER INDEX idx_name VISIBLE; — 恢复可见

    — 8.0.13+ 函数索引(表达式索引)
    CREATE INDEX idx_func ON tbl ((LOWER(col)));
    CREATE INDEX idx_month ON orders ((DATE_FORMAT(created_at, '%Y-%m')));

    — 8.0+ 降序索引
    CREATE INDEX idx_desc ON orders (created_at DESC);

    — 生成列 + 索引(JSON 场景的标准解法,见第 13 章)
    ALTER TABLE tbl ADD COLUMN gen VARCHAR(32) GENERATED ALWAYS AS (expr) STORED, ADD INDEX (gen);

    (2)EXPLAIN 关键列解读(运维必会)
    列含义关注点
    type 访问类型 优劣顺序:system > const > eq_ref > ref > range > index > ALL。出现 ALL(全表扫描)就要警惕
    possible_keys 理论上可用的索引 为空说明没有可用索引
    key 实际选用的索引 与 possible_keys 不一致说明优化器做了代价选择
    key_len 实际使用的索引字节数 可判断复合索引用到了几列
    ref 与索引比较的列/常量 const 表示常量匹配
    rows 预估扫描行数 越小越好;与实际偏差大说明统计信息过期
    filtered 存储引擎返回后,剩余过滤条件的通过百分比 rows × filtered / 100 ≈ 最终行数
    Extra 附加信息 见下表

    Extra 关键值:

    值含义处理
    Using index 覆盖索引,无需回表 ✅ 最优,尽量争取
    Using index condition 索引下推 ICP(8.0 默认开) ✅ 好现象
    Using where 服务层再过滤 中性
    Using filesort 需要额外排序 ⚠ 考虑加排序字段索引
    Using temporary 用了临时表(GROUP BY / DISTINCT) ⚠ 严重,考虑加索引或改写
    Using join buffer (hash join) 8.0.18+ Hash Join 无索引等值连接时的兜底
    (3)索引失效的六大场景(面试与实战高频)
    场景反例修复
    对索引列做函数运算 WHERE DATE(created_at) = '2026-09-01' WHERE created_at >= '2026-09-01' AND created_at < '2026-09-02'
    隐式类型转换 WHERE varchar_col = 123(列是字符串,传数字) 保持类型一致:WHERE varchar_col = '123'
    前导通配符 WHERE name LIKE '%abc' 改用全文索引,或冗余反转列 + 前缀匹配
    违反最左前缀 索引 (a,b,c),条件只用 b 补 a 的条件,或新建 (b) 索引
    OR 连接非索引列 WHERE indexed_col = 1 OR other_col = 2 改 UNION ALL,或给 other_col 也建索引
    字符集/排序规则不一致 utf8mb4 表 JOIN utf8 表 统一字符集与 collation

    补充:!=、<>、NOT IN 通常可以用索引(优化器会按代价选择),但在结果集占比高时会退化为全表扫描——不是"绝对失效",而是"代价不划算"。

    (4)8.0 索引新特性
    特性版本用途
    不可见索引 8.0+ 灰度下线索引:先 INVISIBLE 观察,确认无用再 DROP,避免误删后重建代价
    降序索引 8.0+ ORDER BY a ASC, b DESC 可以完全走索引,消除 filesort
    函数索引 8.0.13+ 让 WHERE LOWER(col)=… 也能走索引
    直方图统计 8.0+ ANALYZE TABLE t UPDATE HISTOGRAM ON col WITH n BUCKETS,帮优化器应对数据倾斜
    索引下推 ICP 5.6+(8.0 默认开) 把过滤条件下推到存储引擎层,减少回表
    Skip Scan Range 8.0.13+ 复合索引跳过首列也能用(有限场景)
    (5)适用场景与方法论
    场景做法
    慢查询排查 ① 开慢日志 → ② mysqldumpslow / pt-query-digest 聚合 → ③ EXPLAIN 逐条分析 → ④ 加索引或改写 SQL
    加索引前验证收益 先 ALTER INDEX … INVISIBLE 观察,或用 EXPLAIN 对比加前加后
    大表加索引 8.0 支持 ALGORITHM=INSTANT/INPLACE;仍建议低峰期 + pt-online-schema-change
    统计信息失真 ANALYZE TABLE tbl;(InnoDB 会重新采样;8.0 默认持久化统计)
    冗余索引清理 SELECT * FROM sys.schema_redundant_indexes;

    12.2 可执行 Shell 示例

    示例 12-1:慢查询定位与 EXPLAIN 分析全流程

    # ══════════════════════════════════════════════════════════════════════
    # 示例 12-1 性能排查:慢日志开启 → 聚合分析 → EXPLAIN 定位 → 加索引验证
    # 场景:业务反馈「订单查询慢」,走完整排查闭环
    # ══════════════════════════════════════════════════════════════════════

    # ── 连接前置 ───────────────────────────────────────══════════─────────
    if [[ -r "$HOME/.my.cnf" ]]; then
    MYSQL=(mysql –defaults-extra-file="$HOME/.my.cnf")
    else
    MYSQL=(mysql -u root -p)
    fi
    DB='demo'
    q() { "${MYSQL[@]}" -D "$DB" -t -e "$1"; }

    # ① 开启慢查询日志(会话级动态调整,8.0 支持;重启失效,持久化需写 my.cnf)
    q "SELECT @@slow_query_log AS 慢日志开关, @@long_query_time AS 慢查询阈值秒,
    @@slow_query_log_file AS 慢日志文件, @@log_queries_not_using_indexes AS 记录未用索引;"

    "${MYSQL[@]}" -e "SET GLOBAL slow_query_log = ON; SET GLOBAL long_query_time = 1; SET GLOBAL log_queries_not_using_indexes = ON;"
    echo "慢日志已开启(阈值 1 秒)"

    # ② 制造一条慢查询:无索引的大范围扫描
    "${MYSQL[@]}" -D "$DB" -e "SELECT COUNT(*) FROM orders WHERE amount BETWEEN 100 AND 800;"

    # ③ 用 mysqldumpslow 聚合慢日志(MySQL 自带,无需额外安装)
    SLOW_LOG=$("${MYSQL[@]}" -N -B -e "SELECT @@slow_query_log_file;")
    if [[ -r "$SLOW_LOG" ]]; then
    echo "— 慢日志 Top5(按平均耗时排序)—"
    mysqldumpslow -s at -t 5 "$SLOW_LOG" 2>/dev/null || echo "(mysqldumpslow 不可用,可直接查看 $SLOW_LOG)"
    else
    echo "慢日志文件不可读:$SLOW_LOG(需 root 权限或调整路径)"
    fi

    # ④ EXPLAIN 分析:看 type / key / rows / Extra 四列
    q "EXPLAIN SELECT COUNT(*) FROM orders WHERE amount BETWEEN 100 AND 800\\G"

    # ⑤ 加索引前先确认基数(Cardinality):区分度高才值得建索引
    q "SHOW INDEX FROM orders;"

    # ⑥ 建索引(8.0 支持 INPLACE,不阻塞 DML;大表仍建议低峰执行)
    "${MYSQL[@]}" -D "$DB" -e "CREATE INDEX idx_amount ON orders (amount);"
    echo "索引 idx_amount 已创建"

    # ⑦ 验证:type 应从 ALL 变为 range,rows 大幅下降,Extra 出现 Using index(覆盖索引)
    q "EXPLAIN SELECT COUNT(*) FROM orders WHERE amount BETWEEN 100 AND 800\\G"

    # ⑧ 用 EXPLAIN ANALYZE 看真实耗时(8.0.18+,包含每个节点的 actual time)
    q "EXPLAIN ANALYZE SELECT COUNT(*) FROM orders WHERE amount BETWEEN 100 AND 800\\G"

    # ⑨ JSON 格式:信息最全,含 cost 估算(8.0+)
    q "EXPLAIN FORMAT=JSON SELECT COUNT(*) FROM orders WHERE amount BETWEEN 100 AND 800\\G"

    # ⑩ 索引失效复现:对索引列做函数运算,索引立刻失效
    q "EXPLAIN SELECT COUNT(*) FROM orders WHERE amount + 0 BETWEEN 100 AND 800\\G"
    q "SELECT '↑ 对索引列做函数运算导致 type=ALL(全表扫描),应改成 amount BETWEEN 100 AND 800' AS 结论;"

    # ⑪ 8.0+ 不可见索引:灰度验证「这个索引到底有没有用」
    "${MYSQL[@]}" -D "$DB" -e "ALTER TABLE orders ALTER INDEX idx_amount INVISIBLE;"
    q "EXPLAIN SELECT COUNT(*) FROM orders WHERE amount BETWEEN 100 AND 800\\G" # 应退回全表扫描
    "${MYSQL[@]}" -D "$DB" -e "ALTER TABLE orders ALTER INDEX idx_amount VISIBLE;"
    q "SELECT '确认索引有用后再保留;确认无用则用 DROP INDEX 删除,减少写放大' AS 灰度方法;"

    # ⑫ 冗余索引与未使用索引排查(sys 库视图,8.0+)
    q "SELECT * FROM sys.schema_redundant_indexes LIMIT 10;"
    q "SELECT object_name, index_name FROM sys.schema_unused_indexes LIMIT 10;"

    示例 12-2:索引失效六大场景复现 + 分批更新

    # ══════════════════════════════════════════════════════════════════════
    # 示例 12-2 索引失效复现 + 大表分批更新的正确姿势
    # 场景:新同学写的 SQL 全表扫描,逐条对照六大失效场景定位原因
    # ══════════════════════════════════════════════════════════════════════

    # ── 连接前置 ──────────────────────────────────────────────────────────
    if [[ -r "$HOME/.my.cnf" ]]; then
    MYSQL=(mysql –defaults-extra-file="$HOME/.my.cnf")
    else
    MYSQL=(mysql -u root -p)
    fi
    DB='demo'
    q() { "${MYSQL[@]}" -D "$DB" -t -e "$1"; }

    # ① 准备:建一个带复合索引的演示表
    "${MYSQL[@]}" -D "$DB" <<'SQL'
    DROP TABLE IF EXISTS idx_demo;
    CREATE TABLE idx_demo (
    id INT PRIMARY KEY AUTO_INCREMENT,
    code VARCHAR(32) NOT NULL, — 字符串列,用于演示隐式转换
    num INT NOT NULL,
    created DATETIME NOT NULL,
    INDEX idx_code_num (code, num), — 复合索引,最左前缀 = code
    INDEX idx_num (num),
    INDEX idx_created (created)
    ) ENGINE=InnoDB;

    SET SESSION cte_max_recursion_depth = 10000;
    INSERT INTO idx_demo (code, num, created)
    WITH RECURSIVE s(n) AS (SELECT 1 UNION ALL SELECT n+1 FROM s WHERE n < 3000)
    SELECT CONCAT('C', LPAD(n, 6, '0')), n, NOW() – INTERVAL n HOUR FROM s;
    SQL
    "${MYSQL[@]}" -D "$DB" -e "ANALYZE TABLE idx_demo;" # 刷新统计信息,保证 EXPLAIN 估算准确
    echo "演示表 idx_demo 就绪(3000 行)"

    # ② 场景 1:对索引列做函数运算 → 失效
    q "EXPLAIN SELECT * FROM idx_demo WHERE DATE(created) = CURDATE()\\G"
    q "SELECT '修复:WHERE created >= CURDATE() AND created < CURDATE() + INTERVAL 1 DAY' AS 方案;"

    # ③ 场景 2:隐式类型转换(字符串列 vs 数字)→ 失效
    q "EXPLAIN SELECT * FROM idx_demo WHERE code = 123\\G" # 失效:数字被转成字符串比较
    q "EXPLAIN SELECT * FROM idx_demo WHERE code = '000123'\\G" # 生效:类型一致

    # ④ 场景 3:前导通配符 → 失效
    q "EXPLAIN SELECT * FROM idx_demo WHERE code LIKE '%123'\\G" # 失效:前导 %
    q "EXPLAIN SELECT * FROM idx_demo WHERE code LIKE 'C0001%'\\G" # 生效:前缀匹配

    # ⑤ 场景 4:违反最左前缀 → 复合索引失效
    q "EXPLAIN SELECT * FROM idx_demo WHERE num = 100\\G" # idx_code_num 失效,改用 idx_num
    q "EXPLAIN SELECT * FROM idx_demo WHERE code = '000123'\\G" # 生效:命中复合索引最左列

    # ⑥ 场景 5:OR 连接非索引列 → 部分失效
    q "EXPLAIN SELECT * FROM idx_demo WHERE num = 100 OR id = 200\\G"

    # ⑦ 覆盖索引演示:只查索引列时 Extra 出现 Using index,不回表
    q "EXPLAIN SELECT code, num FROM idx_demo WHERE code = '000123'\\G"
    q "EXPLAIN SELECT * FROM idx_demo WHERE code = '000123'\\G" # SELECT * 需要回表

    # ⑧ 消除 filesort:ORDER BY 字段进索引后,Extra 里的 Using filesort 消失
    q "EXPLAIN SELECT * FROM idx_demo ORDER BY created LIMIT 10\\G"

    # ⑨ 大表分批更新(生产必备):用主键范围切分,每批独立事务,避免长事务与锁表
    "${MYSQL[@]}" -D "$DB" <<'SQL'
    — 用存储过程封装分批更新:每批 500 行,批间自动提交
    DROP PROCEDURE IF EXISTS sp_batch_update;
    DELIMITER $$
    CREATE PROCEDURE sp_batch_update(IN p_batch INT)
    BEGIN
    DECLARE v_rows INT DEFAULT -1;
    WHILE v_rows <> 0 DO
    UPDATE idx_demo SET num = num + 0
    WHERE num > 2500 LIMIT p_batch; — LIMIT 限制单批影响行数
    SET v_rows = ROW_COUNT(); — 本批影响行数,0 表示处理完
    — 每批结束隐式提交(autocommit=1),不持长锁
    END WHILE;
    END$$
    DELIMITER ;
    SQL

    "${MYSQL[@]}" -D "$DB" -e "CALL sp_batch_update(500);"
    echo "分批更新完成"

    # ⑩ 直方图:数据倾斜时帮优化器做出正确选择(8.0+)
    "${MYSQL[@]}" -D "$DB" -e "ANALYZE TABLE idx_demo UPDATE HISTOGRAM ON num WITH 16 BUCKETS;"
    q "SELECT SCHEMA_NAME, TABLE_NAME, COLUMN_NAME, HISTOGRAM
    FROM information_schema.COLUMN_STATISTICS LIMIT 5;"

    # ⑪ 清理
    "${MYSQL[@]}" -D "$DB" -e "DROP PROCEDURE IF EXISTS sp_batch_update; DROP TABLE IF EXISTS idx_demo;"
    echo "演示对象已清理"


    第 13 章 JSON 与全文检索

    讲义无此章节,依据 MySQL 8.0 / 8.4 官方标准实践补充。

    13.1 语法说明与适用场景

    (1)JSON 数据类型与函数
    类别函数 / 操作符说明
    构造 JSON_OBJECT(k,v,…), JSON_ARRAY(v,…), JSON_QUOTE(str) 构造 JSON 值
    提取 JSON_EXTRACT(json, path) 简写 json->'$.key' 返回 JSON 类型(带引号)
    提取 JSON_UNQUOTE(JSON_EXTRACT(…)) 简写 json->>'$.key' 返回字符串(去引号)
    修改 JSON_SET(有则改无则增)/ JSON_INSERT(只增不改)/ JSON_REPLACE(只改不增)/ JSON_REMOVE 部分更新,8.0 支持原地更新优化
    查询 JSON_CONTAINS(target, cand[, path]) 判断是否包含(返回 1/0)
    查询 JSON_CONTAINS_PATH(json, 'one'|'all', path…) 判断路径是否存在
    查询 JSON_SEARCH(json, 'one'|'all', str) 按值找路径
    展开 JSON_TABLE(json, path COLUMNS(…)) 8.0+:JSON 数组展开为关系行
    其他 JSON_VALID, JSON_LENGTH, JSON_KEYS, JSON_PRETTY, JSON_TYPE

    路径语法:$.key、$.a.b、$[0]、$.arr[*]、$**.key(递归通配)。

    (2)⚠ JSON 索引的正确做法

    JSON 列不能直接建索引。标准解法是生成列 + 索引:

    — 推荐:生成列(Generated Column)+ 普通索引,兼容性最好
    ALTER TABLE product
    ADD COLUMN cpu VARCHAR(32) GENERATED ALWAYS AS (attrs–>>'$.cpu') STORED,
    ADD INDEX idx_cpu (cpu);

    — 备选:8.0.13+ 函数索引(表达式索引)
    CREATE INDEX idx_cpu_func ON product ((CAST(attrs–>>'$.cpu' AS CHAR(32))));

    对比生成列 + 索引函数索引
    版本要求 5.7+ 8.0.13+
    可读性 列可见,业务可直接查 隐藏在索引定义里
    空间 STORED 占空间;VIRTUAL 不占但索引仍占 只占索引空间
    建议 生产首选 简单场景可用
    (3)全文检索

    — ① 建全文索引(英文默认解析器;中文必须指定 ngram)
    CREATE FULLTEXT INDEX ft_name ON tbl (col1, col2); — 英文/默认
    CREATE FULLTEXT INDEX ft_name ON tbl (col1, col2) WITH PARSER ngram; — 中文

    — ② 三种检索模式
    MATCH(col1, col2) AGAINST ('关键词' IN NATURAL LANGUAGE MODE) — 自然语言(默认),按相关性排序
    MATCH(col1, col2) AGAINST ('+必须 -排除 前缀*' IN BOOLEAN MODE) — 布尔模式,支持运算符
    MATCH(col1, col2) AGAINST ('关键词' WITH QUERY EXPANSION) — 查询扩展(联想扩展,慎用)

    — ③ 布尔模式运算符
    + 必须包含 – 必须不包含 * 前缀通配 "短语" 精确短语
    ~ 降低相关性 < > 调整权重 () 分组

    中文检索的关键配置:

    参数默认值说明
    ngram_token_size 2 ngram 分词长度。只读参数,必须在 my.cnf 里设并重启生效
    innodb_ft_min_token_size 3 InnoDB 默认解析器最短 token;ngram 解析器不受此限制
    innodb_ft_enable_stopword ON 停用词过滤

    # /etc/my.cnf —— 改 ngram_token_size 必须重启 mysqld
    [mysqld]
    ngram_token_size = 2
    innodb_ft_min_token_size = 1 # 若要支持单字检索(英文场景)

    (4)⚠ MyISAM vs InnoDB 全文检索差异(常见误区)
    对比项MyISAMInnoDB
    50% 阈值规则 ❌ 有(出现频率超 50% 的词被忽略) ✅ 无此限制
    事务支持 ❌ ✅
    最小 token ft_min_word_len(默认 4) innodb_ft_min_token_size(默认 3)
    索引重建 REPAIR TABLE OPTIMIZE TABLE

    纠正一个流传很广的错误说法:「MySQL 全文检索会自动忽略出现频率超过 50% 的词」——这只对 MyISAM 成立,InnoDB 全文检索没有 50% 阈值。8.x 默认引擎是 InnoDB,这条规则已不适用。

    (5)适用场景对比
    场景推荐方案理由
    低频变更的属性字段(商品规格、配置项) JSON 列 + 生成列索引 免拆表,schema 灵活
    高频查询/排序/聚合的属性 提升为独立列 JSON 列无法直接索引,每次都全量解析
    站内搜索(中文) InnoDB 全文 + ngram MySQL 原生,成本最低
    海量数据/复杂相关性/高亮 Elasticsearch MySQL 全文检索无高亮、无分布式、分词弱
    简单前缀搜索 LIKE 'xxx%' + 索引 最快最省

    13.2 可执行 Shell 示例

    示例 13-1:JSON 数据存储、查询与索引优化

    # ══════════════════════════════════════════════════════════════════════
    # 示例 13-1 JSON:商品属性存储 + 查询 + 生成列索引优化
    # 场景:商品规格属性不固定(不同品类字段不同),用 JSON 存,但要能按 CPU 型号筛选
    # ══════════════════════════════════════════════════════════════════════

    # ── 连接前置 ──────────────────────────────────────────────────────────
    if [[ -r "$HOME/.my.cnf" ]]; then
    MYSQL=(mysql –defaults-extra-file="$HOME/.my.cnf")
    else
    MYSQL=(mysql -u root -p)
    fi
    DB='demo'
    q() { "${MYSQL[@]}" -D "$DB" -t -e "$1"; }

    # ① 建表:JSON 列 + 生成列 + 索引(生成列是 JSON 能被索引的唯一标准解法)
    "${MYSQL[@]}" -D "$DB" <<'SQL'
    DROP TABLE IF EXISTS product;
    CREATE TABLE product (
    id INT PRIMARY KEY AUTO_INCREMENT,
    name VARCHAR(64) NOT NULL,
    attrs JSON NULL, — ① 灵活属性列
    cpu VARCHAR(32) GENERATED ALWAYS AS (attrs->>'$.cpu') STORED, — ② 生成列:把 JSON 里的 cpu 提成关系列
    price DECIMAL(10,2) GENERATED ALWAYS AS (attrs->>'$.price') STORED,
    INDEX idx_cpu (cpu), — ③ 在生成列上建索引,可正常走索引
    INDEX idx_price (price)
    ) ENGINE=InnoDB;
    SQL

    echo "商品表 product 已建(含生成列索引)"

    # ② 插入 JSON 数据:JSON_OBJECT 构造,注意插入时生成的列不能显式赋值
    "${MYSQL[@]}" -D "$DB" <<'SQL'
    INSERT INTO product (name, attrs) VALUES
    ('笔记本A', JSON_OBJECT('cpu','i7-13700H','mem',16,'price',6999)),
    ('笔记本B', JSON_OBJECT('cpu','i5-13500H','mem',16,'price',4999)),
    ('台式机C', JSON_OBJECT('cpu','i7-13700K','mem',32,'price',8999)),
    ('平板D', JSON_OBJECT('cpu','M2','mem',8,'price',3999));
    SQL

    # ③ 提取:-> 返回带引号的 JSON;->> 返回去引号的字符串(业务上通常用 ->>)
    q "SELECT name,
    attrs->'$.cpu' AS 用箭头_带引号,
    attrs->>'$.cpu' AS 用双箭头_去引号,
    JSON_EXTRACT(attrs, '$.mem') AS 等价的JSON_EXTRACT
    FROM product;"

    # ④ 查询:JSON_CONTAINS 判断是否包含某个键值
    q "SELECT name, attrs->>'$.cpu' AS cpu FROM product
    WHERE JSON_CONTAINS(attrs, '\\"i7-13700H\\"', '$.cpu');"

    # ⑤ 路径查询:JSON_CONTAINS_PATH 判断字段是否存在(区分 one / all)
    q "SELECT name,
    JSON_CONTAINS_PATH(attrs, 'one', '$.mem', '$.gpu') AS 存在任一,
    JSON_CONTAINS_PATH(attrs, 'all', '$.mem', '$.gpu') AS 全部存在
    FROM product;"

    # ⑥ 修改:JSON_SET(有则改无则增)/ JSON_REPLACE(只改不增)/ JSON_REMOVE(删除)
    "${MYSQL[@]}" -D "$DB" -e "UPDATE product SET attrs = JSON_SET(attrs, '$.gpu', 'RTX4060') WHERE id = 3;"
    q "SELECT name, JSON_PRETTY(attrs) AS 属性 FROM product WHERE id = 3\\G"

    # ⑦ 索引验证:走生成列索引,type 应为 ref
    q "EXPLAIN SELECT name, cpu FROM product WHERE cpu = 'i7-13700H'\\G"

    # ⑧ 对比:直接在 JSON 列上查询 → 全表扫描(这就是必须加生成列的原因)
    q "EXPLAIN SELECT name FROM product WHERE attrs->>'$.cpu' = 'i7-13700H'\\G"

    # ⑨ 8.0+ JSON_TABLE:把 JSON 数组展开成关系行(报表、ETL 神器)
    q "SELECT jt.*
    FROM product p,
    JSON_TABLE(p.attrs, '\\$' COLUMNS (
    cpu VARCHAR(32) PATH '\\$.cpu',
    mem INT PATH '\\$.mem',
    price INT PATH '\\$.price'
    )) AS jt
    ORDER BY price DESC;"

    # ⑩ 校验与长度:JSON_VALID 判断合法性(导入外部数据必做)
    q "SELECT JSON_VALID('{\\"a\\":1}') AS 合法, JSON_VALID('{a:1}') AS 非法,
    JSON_LENGTH(attrs) AS 顶层键值数 FROM product LIMIT 1;"

    示例 13-2:中文全文检索(ngram 解析器)

    # ══════════════════════════════════════════════════════════════════════
    # 示例 13-2 全文检索:中文 ngram 索引 + 三种检索模式 + 相关性排序
    # 场景:给知识库文章做站内搜索,要求支持中文关键词并按相关性排序
    # ══════════════════════════════════════════════════════════════════════

    # ── 连接前置 ─────────────────────────────────══──════════════════════─
    if [[ -r "$HOME/.my.cnf" ]]; then
    MYSQL=(mysql –defaults-extra-file="$HOME/.my.cnf")
    else
    MYSQL=(mysql -u root -p)
    fi
    DB='demo'
    q() { "${MYSQL[@]}" -D "$DB" -t -e "$1"; }

    # ① 确认 ngram 分词参数(注意:ngram_token_size 是只读的,改它必须写 my.cnf 并重启)
    q "SHOW VARIABLES LIKE 'ngram_token_size';"
    q "SHOW VARIABLES LIKE 'innodb_ft_min_token_size';"
    q "SELECT '若需修改 ngram_token_size:在 /etc/my.cnf 的 [mysqld] 段加 ngram_token_size=2 后重启 mysqld' AS 说明;"

    # ② 建文章表(中文全文索引必须显式指定 WITH PARSER ngram)
    "${MYSQL[@]}" -D "$DB" <<'SQL'
    DROP TABLE IF EXISTS article;
    CREATE TABLE article (
    id INT PRIMARY KEY AUTO_INCREMENT,
    title VARCHAR(128) NOT NULL,
    body TEXT NOT NULL,
    FULLTEXT KEY ft_title_body (title, body) WITH PARSER ngram — ngram 解析器:中文必需
    ) ENGINE=InnoDB;
    SQL

    echo "文章表 article 已建(ngram 全文索引)"

    # ③ 灌入中文测试数据
    "${MYSQL[@]}" -D "$DB" <<'SQL'
    INSERT INTO article (title, body) VALUES
    ('MySQL 索引优化实战', '本文讲解 B+Tree 索引原理、覆盖索引与最左前缀原则,以及如何使用 EXPLAIN 分析执行计划。'),
    ('MySQL 事务与锁机制', '深入解析 InnoDB 的事务隔离级别、行锁、间隙锁与临键锁,以及如何排查死锁问题。'),
    ('Linux 系统性能排查', '介绍 top、iostat、vmstat 等工具的使用,以及磁盘 IO 瓶颈的定位方法。'),
    ('MySQL 备份恢复方案', '对比 mysqldump、XtraBackup 与 binlog 增量恢复三种方案的适用场景与恢复步骤。'),
    ('数据库索引设计指南', '索引是数据库性能优化的核心,本文总结索引设计的十条经验与常见误区。');
    SQL

    # ④ 自然语言模式:默认模式,按相关性得分自动排序
    # ⚠ 注意:MATCH() 里的列必须与建索引时的列完全一致(顺序、数量),否则报 ERROR 1191
    q "SELECT id, title,
    ROUND(MATCH(title, body) AGAINST('索引 优化' IN NATURAL LANGUAGE MODE), 4) AS 相关度
    FROM article
    WHERE MATCH(title, body) AGAINST('索引 优化' IN NATURAL LANGUAGE MODE)
    ORDER BY 相关度 DESC;"

    # ⑤ 布尔模式:支持 + 必须包含 / – 必须排除 / * 前缀通配 / "" 精确短语
    q "SELECT id, title,
    ROUND(MATCH(title, body) AGAINST('+MySQL -Linux' IN BOOLEAN MODE), 4) AS 相关度
    FROM article
    WHERE MATCH(title, body) AGAINST('+MySQL -Linux' IN BOOLEAN MODE)
    ORDER BY 相关度 DESC;"

    # ⑥ 前缀通配(布尔模式):'优化*' 会匹配「优化」「优化器」「优化方案」
    q "SELECT id, title FROM article
    WHERE MATCH(title, body) AGAINST('优化*' IN BOOLEAN MODE);"

    # ⑦ 精确短语:双引号包裹,要求词序完全一致
    q "SELECT id, title FROM article
    WHERE MATCH(title, body) AGAINST('\\"性能优化\\"' IN BOOLEAN MODE);"

    # ⑧ 常见错误复现:MATCH 列与索引列不一致 → ERROR 1191 Can't find FULLTEXT index
    "${MYSQL[@]}" -D "$DB" -e "SELECT id FROM article WHERE MATCH(title) AGAINST('索引');" 2>&1 \\
    | grep -E 'ERROR|1191' && echo "↑ 已复现:MATCH() 的列必须与 FULLTEXT 索引列完全一致" || echo "(未复现)"

    # ⑨ 索引重建:全文索引在数据大量变更后需要 OPTIMIZE TABLE 重建(InnoDB 用 OPTIMIZE,不是 REPAIR)
    "${MYSQL[@]}" -D "$DB" -e "OPTIMIZE TABLE article;"
    q "SELECT 'InnoDB 全文索引用 OPTIMIZE TABLE 重建;MyISAM 才用 REPAIR TABLE' AS 注意;"

    # ⑩ 性能对比:全文检索 vs LIKE '%关键词%'(后者必然全表扫描)
    q "EXPLAIN SELECT id FROM article WHERE MATCH(title, body) AGAINST('索引' IN BOOLEAN MODE)\\G"
    q "EXPLAIN SELECT id FROM article WHERE body LIKE '%索引%'\\G"
    q "SELECT '全文索引 EXPLAIN 的 key 列应显示 ft_title_body;LIKE 则为 NULL 且 type=ALL' AS 结论;"

    # ⑪ 搜索日志:把用户搜索词记录下来,用于后续优化分词与热门词分析
    "${MYSQL[@]}" -D "$DB" <<'SQL'
    CREATE TABLE IF NOT EXISTS search_log (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    keyword VARCHAR(128) NOT NULL,
    hit_cnt INT DEFAULT 0,
    searched_at DATETIME DEFAULT CURRENT_TIMESTAMP,
    KEY idx_kw (keyword)
    ) ENGINE=InnoDB;
    SQL

    KW='索引 优化'
    HITS=$("${MYSQL[@]}" -D "$DB" -N -B -e "
    SELECT COUNT(*) FROM article WHERE MATCH(title, body) AGAINST('${KW}' IN BOOLEAN MODE);")

    "${MYSQL[@]}" -D "$DB" -e "INSERT INTO search_log (keyword, hit_cnt) VALUES ('${KW}', ${HITS});"
    q "SELECT keyword, hit_cnt, searched_at FROM search_log ORDER BY id DESC LIMIT 5;"


    第 14 章 系统性验证与总结报告

    14.1 各知识点逐条验证

    验证方法说明

    • Shell 正确性:文档中所有 bash 代码块已用 bash -n(语法检查)+ bash -u -n(未绑定变量检查)逐块静态校验,结果见 validation_report.md。
    • SQL 正确性:本机为 Windows 环境(无 mysql 客户端),SQL 部分按 MySQL 8.0 / 8.4 官方语法逐条人工核验,重点核对函数名、参数顺序、版本特性门槛与 8.4 破坏性变更。
    • 场景合理性:每个示例均对应一个真实运维诉求,并在代码注释中标注场景。
    #知识点语法准确性场景合理性代码正确性验证结论与说明
    0 实验环境与数据准备 ✅ ✅ ✅ bash -n 通过 含 8.4 破坏性变更对照表;.my.cnf 权限 600 已标注
    1 排序、分组与分页 ✅ ✅ ✅ 补充了讲义未讲的 HAVING、WITH ROLLUP、GROUPING;标注 8.0 取消 GROUP BY 隐式排序
    2 子查询与复杂查询 ✅ ✅ ✅ 核心补充 NOT IN NULL 陷阱与 ERROR 1093 绕过;semi-join 说明准确
    3 连接查询 ✅ ✅ ✅ 补充 ON vs WHERE 行为差异、自连接、USING、行数放大陷阱
    4 运算符、NULL 值与正则 ✅ ✅ ✅ 重点修正 8.0 正则引擎换 ICU,[[:<:]] → \\b(讲义旧写法在 8.0+ 报错 3685)
    5 常用内置函数 ✅ ✅ ✅ 补充 CHAR_LENGTH vs LENGTH、CONCAT_WS、GROUP_CONCAT 截断、TIMESTAMPDIFF
    6 CTE 公共表表达式 ✅ ✅ ✅ 讲义无此章,依 8.0 官方补充;含 cte_max_recursion_depth、递归终止条件、递归成员列宽截断坑
    7 窗口函数 ✅ ✅ ✅ 讲义无此章;三个经典坑(WHERE 不能用、RANGE 默认帧、LAST_VALUE 帧)均已给出正反例
    8 视图 ✅ ✅ ✅ 讲义无此章;MERGE/TEMPTABLE 性能差异、WITH CHECK OPTION、权限隔离实战
    9 存储过程与函数 ✅ ✅ ✅ 沿用讲义 5.3 并补充:log_bin_trust_function_creators 与 ERROR 1418、DECLARE 顺序、ON DUPLICATE 幂等
    10 触发器 ✅ ✅ ✅ 讲义无此章;六个时机 × NEW/OLD 可用性表、ERROR 1442、禁止事务控制、批量导入前禁用
    11 事务与锁 ✅ ✅ ✅ 讲义无此章;8.4 三处破坏性变更已修正(data_locks、SHOW BINARY LOG STATUS、@@transaction_isolation)
    12 索引优化与执行计划 ✅ ✅ ✅ 讲义无此章;六大失效场景、Extra 值解读、不可见索引灰度、直方图
    13 JSON 与全文检索 ✅ ✅ ✅ 讲义无此章;生成列 + 索引为标准解法;ngram 中文分词配置;纠正 InnoDB 无 50% 阈值的流传错误

    14.2 已验证修正内容清单

    以下为本次编写过程中明确核对并修正的内容,逐条列出"原问题 → 修正依据":

    #原问题(讲义 / 常见写法)修正内容影响
    1 讲义 5.1.3 正则表未区分版本;旧写法 [[:<:]] 词边界 明确标注 MySQL 8.0 起正则引擎由 Henry Spencer 换成 ICU,[[:<:]] / [[:>:]] 已移除,改用 \\b;补充 REGEXP_LIKE/REPLACE/SUBSTR/INSTR 不修正会在 8.0+ 报 ERROR 3685
    2 讲义未讲 NOT IN 的 NULL 语义 补充完整复现脚本:NOT IN 子查询含 NULL 时返回空集,静默吞掉数据;推荐改用 NOT EXISTS 数据订正场景的重大正确性风险
    3 讲义 5.1.1 只提 GROUP BY,未提 HAVING 与 ONLY_FULL_GROUP_BY 补充 HAVING 与 WHERE 的执行顺序差异;标注 8.0 默认开启 ONLY_FULL_GROUP_BY,非分组列需 ANY_VALUE() 5.7→8.0 升级后大量旧 SQL 报 ERROR 1055
    4 讲义写"GROUP BY 结果条目排序" 明确 MySQL 8.0 起 GROUP BY 不再隐式排序,依赖此特性的旧代码结果顺序会变,必须显式 ORDER BY 升级后报表顺序突变
    5 讲义未提深分页问题 补充深分页性能悬崖 + 两种优化(游标分页 / 延迟关联)+ EXPLAIN ANALYZE 验证 线上翻页卡死的根因
    6 讲义未讲 ON vs WHERE 在 LEFT JOIN 中的差异 补充:过滤条件放 ON 保留左表行,放 WHERE 退化成内连接;并给出 IS NULL 找孤儿数据的反用技巧 连接查询最常见的语义 bug
    7 通用文档常见错误:SHOW MASTER STATUS MySQL 8.4 已彻底移除,改为 SHOW BINARY LOG STATUS(SHOW SLAVE STATUS → SHOW REPLICA STATUS) 8.4 上直接 ERROR 1064
    8 通用文档常见错误:information_schema.INNODB_LOCKS 8.4 表已删除,改为 performance_schema.data_locks / data_lock_waits 锁排查脚本在 8.4 返回空
    9 5.7 变量名 @@tx_isolation 8.0 起改为 @@transaction_isolation 旧监控脚本拿不到值
    10 常见误解:“MySQL 全文检索有 50% 阈值” 纠正:该规则只适用于 MyISAM,InnoDB 全文检索没有 50% 阈值限制 8.x 默认 InnoDB,此说法已过时
    11 常见写法:直接在 JSON 列上建索引 JSON 列不能直接建索引,标准解法是生成列 + 索引(5.7+)或函数索引(8.0.13+) 直接建索引会报语法错
    12 存储过程 DELIMITER 用法 补充 DELIMITER ;(分号前有空格)的必要性;忘记改回会导致后续 SQL 挂起 交互操作高频失误
    13 存储函数未声明特性 补充 开启 binlog 时必须声明 DETERMINISTIC / NO SQL / READS SQL DATA,否则 ERROR 1418 生产环境创建函数直接失败
    14 中文全文检索直接建 FULLTEXT 必须显式 WITH PARSER ngram,且 ngram_token_size 为只读参数,需在 my.cnf 设置并重启 不指定 ngram 则中文检索无结果
    15 窗口函数直接写在 WHERE 明确不允许,必须套 CTE / 派生表;并补充默认 RANGE 帧与 LAST_VALUE 帧两个坑 三个坑是窗口函数 90% 的错误来源
    16 .mylogin.cnf 空密码覆盖 ~/.my.cnf 补充排查三步走:显式 -p 验证 → 删 .mylogin.cnf → 重建 ~/.my.cnf(600);统一只用一种认证方式 表现是 using password: NO 反复登录失败
    17 讲义 LIMIT 只讲浅分页 补充 LIMIT size OFFSET offset 等价写法与深分页优化方案 见第 5 条
    18 讲义未提 EXPLAIN 整章补充执行计划解读(type/key/rows/filtered/Extra)与六大索引失效场景 性能排查缺少方法论

    14.3 核心内容总结性汇报

    (1)知识地图:按"使用频率 × 生产价值"分类

    第一梯队 —— 每天都在用(必须滚瓜烂熟)

  • WHERE / ORDER BY / GROUP BY / LIMIT 组合(第 1 章):所有查询的地基。记住 WHERE → GROUP BY → HAVING → ORDER BY → LIMIT 的执行顺序,就能解释绝大多数 SQL 行为。
  • 连接查询与 ON/WHERE 语义(第 3 章):LEFT JOIN 的过滤条件位置直接决定结果集,是最高频的语义 bug 来源。
  • EXPLAIN 与索引失效六场景(第 12 章):性能排查的第一板斧,看到 type=ALL 就要动手。
  • NULL 三值逻辑(第 4 章):NOT IN + NULL = 静默返回空集,是数据订正事故的头号元凶。
  • 第二梯队 —— 大幅提升效率的 8.0 新特性(5.7 用户升级的核心收益)

  • 窗口函数(第 7 章):TopN-per-group、去重取最新、累计求和、环比,全部从"自连接 + 子查询"简化成一行 OVER()。
  • CTE 与递归 CTE(第 6 章):层级树展开、日期序列补零、复杂 SQL 可读性改造。
  • EXPLAIN ANALYZE(第 12 章):从"预估行数"进化到"真实耗时",排查不再靠猜。
  • 第三梯队 —— 架构与工程化能力

  • 事务与锁(第 11 章):隔离级别选型、锁等待排查、死锁分析、长事务治理,是 DBA 的分水岭。
  • 视图(第 8 章):权限隔离与口径统一的主流手段,注意算法退化。
  • 存储过程 / 函数 / 触发器(第 9、10 章):讲义重点,生产中建议限定在运维类任务(归档、清理、审计),核心业务链路留给应用层。
  • JSON 与全文检索(第 13 章):灵活属性用 JSON(配生成列索引),中文搜索用 ngram;复杂搜索需求应上 Elasticsearch。
  • (2)生产落地的六条行动建议
  • 升级前先跑版本能力矩阵(第 0 章):5.7 → 8.0 的 ONLY_FULL_GROUP_BY、GROUP BY 隐式排序、正则引擎三项变更,是兼容性测试的重点。
  • 所有 UPDATE/DELETE 执行前先跑同条件 SELECT(示例 2-2 ④、5-2 ③):先看量、再备份、后执行,三步不可省。
  • 大批量变更一律分批 + 独立事务(示例 12-2 ⑨):LIMIT 500 循环提交,避免长事务、锁表与 undo 膨胀。
  • 索引变更走灰度:先用 ALTER INDEX … INVISIBLE(8.0+)观察,确认无用再 DROP;加索引前用 EXPLAIN 对比收益。
  • 锁问题排查固定路径:sys.innodb_lock_waits → performance_schema.data_locks → information_schema.INNODB_TRX → SHOW ENGINE INNODB STATUS(8.4 上前两个已替代被删除的 INNODB_LOCKS)。
  • 触发器、存储过程要有"下线开关":大批量导入前先 DROP TRIGGER,导完再建回,避免行级触发器的 N 倍写放大。
  • (3)与讲义的对应关系
    讲义章节本文档位置补充程度
    5.1.1 常用查询(排序/分组/限制条目/别名/通配符/子查询) 第 1、2 章 保留全部案例,补充 HAVING、WITH ROLLUP、ONLY_FULL_GROUP_BY、深分页优化
    5.1.2 NULL 值 第 4 章 保留讲义 NULL vs 空值对比表,补充 <=> 空安全等于与三值逻辑
    5.1.3 正则表达式 第 4 章 保留表 5-1 全部元字符,重点补充 8.0 ICU 引擎变更
    5.1.4 运算符 第 4 章 保留四类运算符与优先级表 5-6
    5.1.5 连接查询(内/左/右连接) 第 3 章 保留 a_player/b_player 完整案例,补充 ON vs WHERE、自连接、CROSS JOIN
    5.2 数据库函数(数学/聚合/字符串/日期时间) 第 5 章 保留表 5-7~5-10 全部函数,补充易混淆点(LENGTH vs CHAR_LENGTH、CONCAT NULL 传播、GROUP_CONCAT 截断)
    5.3 存储过程(简介/创建/修改/删除) 第 9 章 保留 DELIMITER、IN/OUT/INOUT、PlayerRole/GetRole 案例,补充存储函数、游标、错误处理、幂等设计
    (讲义无) 第 6、7、8、10、11、12、13 章 依据 MySQL 8.0/8.4 官方标准实践全新补充

    附录 A 常见错误码速查

    错误码含义典型原因与处理
    1045 Access denied 用户名/密码错,或 .mylogin.cnf 空密码覆盖(见 0.1)
    1055 ONLY_FULL_GROUP_BY 违规 SELECT 了非分组裸列;加进 GROUP BY 或用 ANY_VALUE()
    1064 语法错误 8.4 上出现需优先排查 SHOW MASTER STATUS 等已移除语法
    1093 不能在本表 UPDATE 的同时子查询本表 子查询外包一层派生表并取别名
    1191 Can’t find FULLTEXT index MATCH() 的列与 FULLTEXT 索引列不完全一致
    1205 Lock wait timeout exceeded 锁等待超过 innodb_lock_wait_timeout;查 sys.innodb_lock_waits
    1213 Deadlock found 死锁,已被自动回滚;SHOW ENGINE INNODB STATUS 看 LATEST DETECTED DEADLOCK
    1248 Every derived table must have its own alias 派生表缺别名,补 AS x
    1329 No data – zero rows fetched 游标未配 CONTINUE HANDLER FOR NOT FOUND
    1418 函数未声明 DETERMINISTIC 等特性 声明特性,或临时 SET GLOBAL log_bin_trust_function_creators=1(生产不推荐)
    1442 触发器内不能再改本表 改用 SET NEW.col = …(BEFORE 触发器)
    1449 DEFINER 用户不存在 重建视图并指定存在的 DEFINER
    3636 Recursive query aborted 递归超过 cte_max_recursion_depth;SET SESSION 调大或加终止条件
    3685 Illegal argument to a regular expression 8.0+ 使用了已移除的 [[:<:]],改用 \\b

    附录 B 配套脚本使用指南

    mysql-advanced-init.sh(演示库一键初始化)

    子命令作用说明
    –check 环境检查 检测 mysql 客户端、版本、引擎、隔离级别、binlog 状态
    –init 建库建表灌数 幂等:IF NOT EXISTS + 先清后插,可重复执行
    –verify 验收校验 输出各表行数、版本、示例 EXPLAIN、SHOW BINARY LOG STATUS
    –reset 删除 demo 库 需交互确认,可加 –force 跳过确认
    –all check → init → verify 一条龙 首次使用直接跑这个
    –dry-run 只打印不执行 用于离线冒烟自测
    –help 帮助

    使用示例:

    # ① 首次初始化(推荐)
    bash mysql-advanced-init.sh –all

    # ② 只想看会做什么,不实际改数据(Windows/Git Bash 离线自测用)
    DRY_RUN=1 bash mysql-advanced-init.sh –all

    # ③ 重复执行(幂等,不会重复灌数据)
    bash mysql-advanced-init.sh –init

    # ④ 验收
    bash mysql-advanced-init.sh –verify

    # ⑤ 推倒重来
    bash mysql-advanced-init.sh –reset –force

    前置条件:

  • Linux(CentOS / RHEL / Rocky / AlmaLinux / CentOS Stream)bash 环境;
  • 已安装 mysql-community-client(脚本会检测并提示);
  • 已配置 ~/.my.cnf(脚本会引导创建,权限自动设为 600);若不存在则交互式要求输入密码;
  • 建议提前删除 ~/.mylogin.cnf,避免空密码覆盖(见 0.1 节)。

  • 文档版本:v1.0
    编写日期:2026-09-01
    依据讲义:05.MySQL高级SQL语句.pdf(第 5 章,44 页)
    适用版本:MySQL 8.0+ / 推荐 8.4 LTS
    校验状态:Shell 代码块 bash -n 全部通过;SQL 按 8.0/8.4 官方语法人工核验;实机执行输出待在 Linux 服务器运行后回填 validation_report.md

    赞(0)
    未经允许不得转载:171主机测评 » MySQL 高级 SQL 语句
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址