云计算百科
云计算领域专业知识百科平台

从一条 SQL 的生命周期开始,把 MySQL 一次性想明白


这篇笔记沿着「一条 SQL 语句在 MySQL 里的完整旅程」这条主线,把架构、存储引擎、索引、事务、MVCC、锁、日志与性能优化全部串起来——每章都配可跑的实战 SQL(电商库 mall),结尾附 20 问速答。忘了没关系,跟着主线走一遍,知识点会自己「长」回脑子里。 适用版本:MySQL 8.0 · InnoDB · 10 章 · 40+ 段实战 SQL · 20 问速查

目录

  • 架构全景:一条 SQL 的生命周期
  • 存储引擎与数据类型
  • SQL 核心语法与电商建库实战
  • 查询进阶:JOIN、窗口函数与经典业务题
  • 索引:从 B+ 树到 EXPLAIN 实战
  • 事务与 MVCC
  • 锁:从 MDL 到间隙锁
  • 日志系统:redo / undo / binlog
  • 性能优化实战
  • 速查手册与复习路线

  • 一、架构全景:一条 SQL 的生命周期

    回忆 MySQL 最高效的路径,不是把知识点一个个背回来,而是顺着**「一条 SQL 语句从客户端发出去,到拿到结果」的完整旅程**走一遍——每个组件在旅程中轮番登场,忘了哪个就补哪个。这一章先建立全景地图,后面九章都是对地图某一块的放大。

    1.1 先看全景:四层结构

    把 MySQL 想象成一栋四层楼:客户端在楼外,SQL 从楼顶进、数据在楼底存。

    #mermaid-svg-7YIbNxS3lh4m5yJp{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-7YIbNxS3lh4m5yJp .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-7YIbNxS3lh4m5yJp .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-7YIbNxS3lh4m5yJp .error-icon{fill:#552222;}#mermaid-svg-7YIbNxS3lh4m5yJp .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-7YIbNxS3lh4m5yJp .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-7YIbNxS3lh4m5yJp .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-7YIbNxS3lh4m5yJp .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-7YIbNxS3lh4m5yJp .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-7YIbNxS3lh4m5yJp .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-7YIbNxS3lh4m5yJp .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-7YIbNxS3lh4m5yJp .marker{fill:#333333;stroke:#333333;}#mermaid-svg-7YIbNxS3lh4m5yJp .marker.cross{stroke:#333333;}#mermaid-svg-7YIbNxS3lh4m5yJp svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-7YIbNxS3lh4m5yJp p{margin:0;}#mermaid-svg-7YIbNxS3lh4m5yJp .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-7YIbNxS3lh4m5yJp .cluster-label text{fill:#333;}#mermaid-svg-7YIbNxS3lh4m5yJp .cluster-label span{color:#333;}#mermaid-svg-7YIbNxS3lh4m5yJp .cluster-label span p{background-color:transparent;}#mermaid-svg-7YIbNxS3lh4m5yJp .label text,#mermaid-svg-7YIbNxS3lh4m5yJp span{fill:#333;color:#333;}#mermaid-svg-7YIbNxS3lh4m5yJp .node rect,#mermaid-svg-7YIbNxS3lh4m5yJp .node circle,#mermaid-svg-7YIbNxS3lh4m5yJp .node ellipse,#mermaid-svg-7YIbNxS3lh4m5yJp .node polygon,#mermaid-svg-7YIbNxS3lh4m5yJp .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-7YIbNxS3lh4m5yJp .rough-node .label text,#mermaid-svg-7YIbNxS3lh4m5yJp .node .label text,#mermaid-svg-7YIbNxS3lh4m5yJp .image-shape .label,#mermaid-svg-7YIbNxS3lh4m5yJp .icon-shape .label{text-anchor:middle;}#mermaid-svg-7YIbNxS3lh4m5yJp .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-7YIbNxS3lh4m5yJp .rough-node .label,#mermaid-svg-7YIbNxS3lh4m5yJp .node .label,#mermaid-svg-7YIbNxS3lh4m5yJp .image-shape .label,#mermaid-svg-7YIbNxS3lh4m5yJp .icon-shape .label{text-align:center;}#mermaid-svg-7YIbNxS3lh4m5yJp .node.clickable{cursor:pointer;}#mermaid-svg-7YIbNxS3lh4m5yJp .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-7YIbNxS3lh4m5yJp .arrowheadPath{fill:#333333;}#mermaid-svg-7YIbNxS3lh4m5yJp .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-7YIbNxS3lh4m5yJp .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-7YIbNxS3lh4m5yJp .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-7YIbNxS3lh4m5yJp .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-7YIbNxS3lh4m5yJp .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-7YIbNxS3lh4m5yJp .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-7YIbNxS3lh4m5yJp .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-7YIbNxS3lh4m5yJp .cluster text{fill:#333;}#mermaid-svg-7YIbNxS3lh4m5yJp .cluster span{color:#333;}#mermaid-svg-7YIbNxS3lh4m5yJp div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-7YIbNxS3lh4m5yJp .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-7YIbNxS3lh4m5yJp rect.text{fill:none;stroke-width:0;}#mermaid-svg-7YIbNxS3lh4m5yJp .icon-shape,#mermaid-svg-7YIbNxS3lh4m5yJp .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-7YIbNxS3lh4m5yJp .icon-shape p,#mermaid-svg-7YIbNxS3lh4m5yJp .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-7YIbNxS3lh4m5yJp .icon-shape .label rect,#mermaid-svg-7YIbNxS3lh4m5yJp .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-7YIbNxS3lh4m5yJp .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-7YIbNxS3lh4m5yJp .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-7YIbNxS3lh4m5yJp :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}

    文件系统层

    存储引擎层(InnoDB)

    MySQL 服务层(Server Layer)

    SQL 语句

    仅更新语句,提交时写

    客户端Navicat · JDBC · 程序

    连接器连接管理 · 权限校验

    分析器词法 · 语法解析

    优化器选索引 · 定执行计划

    执行器调用引擎接口

    Buffer Pool内存数据页

    事务 · 锁 · MVCC

    数据文件.ibd 表空间

    redo log

    undo log

    binlog

    • 连接层:管理 TCP 连接、账号密码认证、线程复用。客户端真正连的是它。
    • 服务层:包含连接器、分析器、优化器、执行器,以及内置函数、存储过程、触发器等跨引擎能力。核心 SQL 逻辑都在这层。
    • 存储引擎层:插件式设计,真正负责数据的存与取。InnoDB 还独占管理事务、锁、MVCC 和 redo/undo 日志。
    • 文件系统层:数据文件(.ibd)和三类日志的最终归宿,落盘的地方。

    记忆锚点:服务层管「怎么执行 SQL」,引擎层管「数据怎么存取」。binlog 属于服务层,redo/undo 属于 InnoDB——这个归属关系后面讲两阶段提交时会反复用到。

    1.2 一条 SELECT 的五步旅程

    以最常见的一句话为例:SELECT * FROM users WHERE id = 1;

  • 连接器:建立(或复用)连接,校验账号密码,随后读取该账号的权限表并缓存到这个连接里。所以修改用户权限只对新连接生效,老连接要重连才感知。
  • 查询缓存(8.0 已移除):5.7 及以前的版本会先拿 SQL 原文当 key 查缓存,命中直接返回。但只要表里有一行更新,这张表相关的缓存全部失效,实际命中率极低,MySQL 8.0 把整块功能删掉了。
  • 分析器:先做词法分析(把字符串拆成关键字、表名、列名),再做语法分析生成语法树。报错 You have an error in your SQL syntax 就是这一步抛的。
  • 优化器:决定「怎么执行最快」——用主键索引还是二级索引、多表 JOIN 时谁做驱动表。它产出的方案就是 EXPLAIN 能看到的东西。
  • 执行器:先检查对这个表有没有执行权限,然后调用 InnoDB 的接口取数据。InnoDB 定位到 id=1 的行返回给执行器,执行器把结果集发给客户端。
  • 记忆锚点:分析器管「这句话对不对」,优化器管「怎么执行最快」,执行器管「真去干活」,存储引擎管「数据怎么存取」。四连问,面试和排障都靠它定位问题层。

    1.3 一条 UPDATE 多了什么(伏笔)

    换成 UPDATE users SET status = 0 WHERE id = 1;,前四步完全一样,但进入 InnoDB 后多出一整套动作:

  • 把目标行所在数据页加载进 Buffer Pool(内存);
  • 写 undo log(记录修改前的值,供回滚和 MVCC 用);
  • 在 Buffer Pool 里改数据行,该页变成脏页,由后台线程异步刷回磁盘;
  • 写 redo log(prepare 状态);
  • 执行器写 binlog,随后 redo log 置为 commit——这就是两阶段提交,第 8 章专门拆。
  • 这套「先写日志、后刷数据页」的设计叫 WAL(Write-Ahead Logging),是 MySQL 崩溃后能自动恢复的全部底气。

    1.4 连接管理实战

    连接建立有真实开销:TCP 握手、身份认证、读权限表。高并发短连接场景下这些开销会被放大,所以应用侧永远用连接池(FastAPI 侧如 SQLAlchemy 的 pool_size、pool_recycle)。两个经典问题先记下来:

    • 空闲连接超时:由 wait_timeout(默认 8 小时)控制,超时后服务端主动断开,客户端再发 SQL 就会收到 MySQL server has gone away。连接池的 pool_recycle 要设得比它小。
    • 长连接内存上涨:5.7 及以前版本中,连接内执行过的大的临时结果不会立即释放,长连接越久内存越高。对策:连接池定期回收重建连接,或执行完大任务后调用 mysql_reset_connection()。

    实战演练:三条命令看清当前连接

    SHOW PROCESSLIST; — 谁连着、正在干嘛
    SHOW STATUS LIKE 'Threads%'; — 连接数概况
    SHOW VARIABLES LIKE 'wait_timeout'; — 空闲多久会被断开

    mysql> SHOW PROCESSLIST;
    +—-+——+———————+——+———+——+————+————————–+
    | Id | User | Host | db | Command | Time | State | Info |
    +—-+——+———————+——+———+——+————+————————–+
    | 8 | root | 192.168.1.10:52331 | mall | Query | 0 | starting | SHOW PROCESSLIST |
    | 12 | app | 192.168.1.20:60012 | mall | Sleep | 120 | | NULL |
    | 15 | app | 192.168.1.20:60018 | mall | Query | 12 | sending da | SELECT * FROM orders … |
    +—-+——+———————+——+———+——+————+————————–+
    — Command=Query 且 Time 很大:大概率是慢 SQL 或被锁阻塞;确认后 KILL 15; 掐掉它


    二、存储引擎与数据类型

    这一章回答两个问题:表用什么引擎建?每个字段选什么类型?——这两件事在建表那一刻就决定了这张表未来五年的性能上限。

    2.1 InnoDB 与 MyISAM:一场已经结束的比赛

    MySQL 5.5 之前默认引擎是 MyISAM,5.5 起换成 InnoDB,8.0 连系统表也全部改用 InnoDB。面试还爱问这个对比,因为对比的每一行都是考点:

    维度InnoDBMyISAM
    事务 支持(ACID) 不支持
    锁粒度 行级锁(并发高) 表级锁(读写互斥)
    外键 支持 不支持
    崩溃恢复 redo log 自动恢复,崩溃安全 几乎没有,断电易坏表
    索引组织 聚簇索引:叶子节点存整行数据 非聚簇:索引与数据文件分离
    无 where 的 count(*) 要扫描(MVCC 下各事务可见行数不同) O(1),直接读存储的行数
    全文索引 5.6 起支持 支持
    今天的用途 99% 的 OLTP 场景默认选它 纯只读统计、归档(也基本被淘汰)

    实战演练:查看引擎

    SHOW ENGINES; — 查看支持的引擎清单
    SHOW TABLE STATUS FROM mall WHERE Name = 'orders'; — 看某张表用的引擎
    — 8.0 里查到的 Engine 列应为 InnoDB;想迁移老表:
    ALTER TABLE old_table ENGINE = InnoDB;

    2.2 数值类型:整数与实数

    类型字节有符号范围UNSIGNED典型用途
    TINYINT 1 -128 ~ 127 0 ~ 255 状态、类型、开关
    SMALLINT 2 ±3.2 万 0 ~ 6.5 万 小范围计数
    MEDIUMINT 3 ±838 万 0 ~ 1677 万 中等计数
    INT 4 ±21.4 亿 0 ~ 42.9 亿 一般数值
    BIGINT 8 ±9.2 × 10^18 更大 主键、订单号、时间戳(ms)
    • INT(11) 里的 11 只是「显示宽度」,不限制取值范围,8.0.17 起官方已废弃这个写法,直接写 INT 即可。
    • 状态字段用 TINYINT UNSIGNED 并在 COMMENT 里写清枚举含义,别用 ENUM(见 2.3)。
    • 主键统一 BIGINT UNSIGNED:INT 的 21 亿在数据量大的业务里真会用完,且未来接雪花 ID、合并表都方便。

    实数只有一条军规:金额必须用 DECIMAL。FLOAT/DOUBLE 是二进制浮点数,天生无法精确表示 0.1 这样的十进制小数:

    SELECT 0.1 + 0.2 = 0.3; — 0(不相等!浮点精度丢失)
    SELECT 0.1 + 0.2; — 0.30000000000000004

    — 正确姿势:金额 DECIMAL(10,2),总共 10 位、小数 2 位
    SELECT CAST(0.1 AS DECIMAL(10,2)) + CAST(0.2 AS DECIMAL(10,2)); — 0.3

    DECIMAL 是精确存储,代价是比 DOUBLE 占空间、计算慢。超大并发的支付核心表里,常见的替代方案是「以分为单位存 BIGINT」,在应用层换算。

    2.3 字符串:CHAR / VARCHAR / TEXT / ENUM

    • CHAR(N):定长,不足 N 位自动补空格(读出时去掉)。适合长度恒定的值:国家码 CHAR(2)、MD5 值 CHAR(32)。
    • VARCHAR(N):变长,额外花 1~2 字节记录实际长度。N 是「字符数」,utf8mb4 下一个字符最多占 4 字节,所以单列理论上限约 16383 字符(受行大小 65535 字节约束)。
    • TEXT / BLOB:超长内容溢出到独立数据页存储,查询代价高。确有需求时垂直拆到附表,别和主业务字段混在一行。
    • ENUM:改一个枚举值就要 DDL、排序按定义顺序而非字典序,团队规范通常禁用——用 TINYINT + 字典表(或 COMMENT 枚举说明)替代。

    实战演练:IP 地址存什么类型?

    — 方案 A:VARCHAR(15),可读性最好,通用业务直接用
    — 方案 B:INT UNSIGNED,省 7 字节,且能按网段范围比较(内网/日志统计场景)
    SELECT INET_ATON('192.168.1.100'); — 3232235876(点分十进制 → 整数)
    SELECT INET_NTOA(3232235876); — '192.168.1.100'(整数 → 点分十进制)

    — 方案 C:VARBINARY(16),需要支持 IPv6 时用 INET6_ATON/INET6_NTOA

    2.4 时间类型:DATETIME vs TIMESTAMP

    类型字节范围时区行为
    DATETIME 8 1000-01-01 ~ 9999-12-31 无时区语义:写入什么读出什么
    TIMESTAMP 4 1970 ~ 2038-01-19(2038 问题) 存 UTC,读写按会话时区自动转换
    DATE 3 仅日期 —
    TIME / YEAR 3 / 1 时间 / 年份 —

    推荐组合:DATETIME + DEFAULT CURRENT_TIMESTAMP(5.6.5 起 DATETIME 也支持该默认值与 ON UPDATE CURRENT_TIMESTAMP),避开 TIMESTAMP 的 2038 上限;跨国业务统一存 UTC,展示层再转时区。

    2.5 JSON 与生成列(8.0)

    8.0 的 JSON 不是简单存个字符串:它是二进制格式,能按路径查询。但 JSON 列不能直接建索引,套路是「生成列 + 索引」:

    ALTER TABLE products ADD COLUMN attrs JSON COMMENT '扩展属性';

    — 把常用的 JSON 字段物化成生成列,再建索引
    ALTER TABLE products
    ADD COLUMN attrs_brand VARCHAR(50)
    GENERATED ALWAYS AS (JSON_UNQUOTE(attrs –>> '$.brand')) STORED,
    ADD INDEX idx_attrs_brand (attrs_brand);

    SELECT * FROM products WHERE attrs_brand = 'Apple'; — 能走索引了

    2.6 建表规范清单(团队通用版)

    • 字段尽量 NOT NULL + DEFAULT:NULL 参与比较、统计、索引都有额外成本,COUNT(字段) 还会跳过 NULL 行;
    • 主键 BIGINT UNSIGNED AUTO_INCREMENT,保持业务无意义;
    • 金额 DECIMAL(10,2)(或分单位 BIGINT),禁 FLOAT/DOUBLE;
    • 字符集 utf8mb4——MySQL 的 utf8 是历史阉割版(utf8mb3),存不了 emoji;
    • 每表每字段写 COMMENT,半年后的你会感谢现在的你;
    • created_at / updated_at 交给默认值自动维护,应用层少一次赋值;
    • 单表字段控制在 50 个以内,TEXT 大字段垂直拆分;
    • 不建物理外键:外键带来额外的锁与级联开销、DDL 耦合,分库分表后完全没法用——用「逻辑外键 + 应用层保证」。

    记忆锚点:类型选型一句话——能小不用大,能定长不用变长,数值优于字符串,NULL 越少越好。索引列尤其如此:类型越小,一页装得越多,树越矮,查得越快。


    三、SQL 核心语法与电商建库实战

    从这一章开始动手:我们建一个贯穿全文的电商库 mall,后面所有的索引、事务、锁、优化案例都在这五张表上发生。语法只讲「用了会踩坑」的部分,手册上查得到的略过。

    3.1 建库建表:五张表的完整 DDL

    CREATE DATABASE mall DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
    USE mall;

    CREATE TABLE users (
    id BIGINT UNSIGNED AUTO_INCREMENT COMMENT '用户ID',
    username VARCHAR(50) NOT NULL COMMENT '用户名',
    email VARCHAR(100) NOT NULL COMMENT '邮箱',
    status TINYINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '1正常 0禁用',
    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '注册时间',
    updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    PRIMARY KEY (id),
    UNIQUE KEY uk_username (username),
    UNIQUE KEY uk_email (email)
    ) ENGINE=InnoDB COMMENT='用户表';

    CREATE TABLE categories ( — 树形分类
    id BIGINT UNSIGNED AUTO_INCREMENT COMMENT '分类ID',
    name VARCHAR(50) NOT NULL COMMENT '分类名',
    parent_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '父分类ID,0为顶级',
    sort INT NOT NULL DEFAULT 0 COMMENT '排序权重',
    PRIMARY KEY (id),
    KEY idx_parent (parent_id)
    ) ENGINE=InnoDB COMMENT='商品分类表';

    CREATE TABLE products (
    id BIGINT UNSIGNED AUTO_INCREMENT COMMENT '商品ID',
    category_id BIGINT UNSIGNED NOT NULL COMMENT '分类ID(逻辑外键)',
    name VARCHAR(100) NOT NULL COMMENT '商品名',
    price DECIMAL(10,2) NOT NULL COMMENT '售价',
    stock INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '库存',
    sales INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '累计销量',
    status TINYINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '1上架 0下架',
    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    PRIMARY KEY (id),
    KEY idx_category (category_id),
    KEY idx_status_created (status, created_at)
    ) ENGINE=InnoDB COMMENT='商品表';

    CREATE TABLE orders (
    id BIGINT UNSIGNED AUTO_INCREMENT COMMENT '订单ID',
    order_no VARCHAR(32) NOT NULL COMMENT '业务订单号',
    user_id BIGINT UNSIGNED NOT NULL COMMENT '下单用户(逻辑外键)',
    total_amount DECIMAL(12,2) NOT NULL COMMENT '订单总额',
    status TINYINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0待支付 1已支付 2已发货 3已完成 4已取消',
    pay_time DATETIME NULL COMMENT '支付时间',
    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '下单时间',
    updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    PRIMARY KEY (id),
    UNIQUE KEY uk_order_no (order_no),
    KEY idx_user_created (user_id, created_at),
    KEY idx_status_created (status, created_at)
    ) ENGINE=InnoDB COMMENT='订单表';

    CREATE TABLE order_items (
    id BIGINT UNSIGNED AUTO_INCREMENT COMMENT '明细ID',
    order_id BIGINT UNSIGNED NOT NULL COMMENT '订单ID(逻辑外键)',
    product_id BIGINT UNSIGNED NOT NULL COMMENT '商品ID(逻辑外键)',
    quantity INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '购买数量',
    price DECIMAL(10,2) NOT NULL COMMENT '成交单价(下单时快照)',
    PRIMARY KEY (id),
    KEY idx_order (order_id),
    KEY idx_product (product_id)
    ) ENGINE=InnoDB COMMENT='订单明细表';

    三个设计决策值得停下来想一想,它们是所有业务表设计的通用套路:

    • order_items.price 存「快照」:商品价格会变,历史订单必须记当时的成交价,不能 JOIN products 现价来算——账单会错;
    • orders.total_amount 存「冗余」:明明能 SUM(明细) 算出来,但订单页是高频读,冗余一列换取免聚合,读多写少场景的标准取舍;
    • 只有逻辑外键:字段上加了索引(如 idx_user_created),但不建 FOREIGN KEY 约束,理由见 2.6。

    3.2 DDL:ALTER 的坑与 8.0 的甜

    ALTER TABLE products ADD COLUMN subtitle VARCHAR(100) NOT NULL DEFAULT '' COMMENT '副标题';
    — 8.0 加列是 INSTANT 算法:只改元数据,千万行大表也是秒级

    ALTER TABLE products ADD INDEX idx_name (name); — INPLACE,可在线加
    ALTER TABLE products DROP INDEX idx_name;
    ALTER TABLE products
    MODIFY COLUMN subtitle VARCHAR(200) NOT NULL DEFAULT '' COMMENT '副标题';
    — 注意:改列类型/长度无法 INSTANT,大表会锁或长时间 INPLACE,务必低峰操作

    易错点:线上 ALTER 前先看算法。 执行前用 ALTER TABLE … , ALGORITHM=INSTANT 试探:支持就秒回;不支持会直接报错而不是默默锁表。大表变更推荐 gh-ost / pt-online-schema-change 工具。

    3.3 DML:增删改的四个细节

    — ① 批量插入:一条 SQL 插多行,比循环单插快一个数量级
    INSERT INTO users (username, email) VALUES
    ('alice', 'alice@test.com'),
    ('bob', 'bob@test.com');

    — ② 存在则更新(8.0.20+ 行别名写法,VALUES() 写法已废弃)
    INSERT INTO products (id, name, price, stock) VALUES
    (1, 'iPhone 17', 6999.00, 100) AS new
    ON DUPLICATE KEY UPDATE stock = stock + new.stock;

    — ③ UPDATE 条件写在 WHERE 里,顺手防超卖(第 7 章展开)
    UPDATE products SET stock = stock – 1 WHERE id = 1 AND stock >= 1;

    — ④ 安全带:开启后,没写 WHERE 或 WHERE 没走索引列的 UPDATE/DELETE 直接报错
    SET SQL_SAFE_UPDATES = 1;

    维度DELETETRUNCATE
    WHERE 条件 支持,逐行删 不支持,清空全表
    事务回滚 可以(记录 undo) 属于 DDL,不可回滚
    自增值 保留 重置为 1
    触发器 触发 DELETE 触发器 不触发
    速度 慢(逐行 + binlog) 快(重建表)

    3.4 DCL:给应用账号最小权限

    CREATE USER 'mall_app'@'192.168.1.%' IDENTIFIED BY '请用强密码';
    GRANT SELECT, INSERT, UPDATE, DELETE ON mall.* TO 'mall_app'@'192.168.1.%';
    SHOW GRANTS FOR 'mall_app'@'192.168.1.%'; — 检查授予结果
    REVOKE DELETE ON mall.* FROM 'mall_app'@'192.168.1.%'; — 收回误授权限

    • 应用账号只给表的增删改查,永远不给 DROP / ALTER / GRANT;
    • 报表/看板账号给只读 GRANT SELECT;
    • GRANT/REVOKE 立即生效,不需要 FLUSH PRIVILEGES(那是直接改 mysql.user 表时的老规矩);
    • 账号按「用途 + 来源网段」收紧:mall_app 只允许从应用网段连。

    3.5 常用函数速查

    类别函数一句话说明
    字符串 CONCAT / SUBSTRING / UPPER / TRIM 拼接、截取、大小写、去空格
    日期 NOW() / DATE_FORMAT / DATE_ADD / DATEDIFF 当前时间、格式化、偏移、差值
    控制流 IFNULL / CASE WHEN NULL 兜底、多条件分支
    聚合 COUNT / SUM / AVG / MIN / MAX 第 4 章配合 GROUP BY 使用
    数值 ROUND / CEIL / FLOOR / ABS 四舍五入、上下取整、绝对值
    JSON -> / ->> / JSON_EXTRACT 路径取值,->> 顺带去引号

    SELECT DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i'); — '2026-09-25 14:30'
    SELECT DATE_ADD(NOW(), INTERVAL 7 DAY); — 一周后
    SELECT DATEDIFF('2026-09-25', '2026-09-01'); — 24(只按日期算天数)
    SELECT IFNULL(NULL, '默认值'); — '默认值'
    SELECT CASE WHEN status = 1 THEN '正常' ELSE '禁用' END FROM users;


    四、查询进阶:JOIN、窗口函数与经典业务题

    业务 SQL 的复杂度 80% 来自「多表 + 分组 + 排名」。这一章把 JOIN、子查询、分组、窗口函数一口气过完,最后用四道经典业务题收尾——每一道都是面试和实战的高频客。

    4.1 JOIN:INNER 和 LEFT 就够用了

    — INNER JOIN:取交集。已支付订单 + 下单用户名
    SELECT o.order_no, u.username, o.total_amount
    FROM orders o
    INNER JOIN users u ON o.user_id = u.id
    WHERE o.status >= 1;

    — LEFT JOIN:保留左表全部。所有商品各自的销量(包括一件没卖出去的)
    SELECT p.id, p.name, IFNULL(SUM(oi.quantity), 0) AS sold
    FROM products p
    LEFT JOIN order_items oi ON oi.product_id = p.id
    GROUP BY p.id, p.name;

    RIGHT JOIN 在实际代码里几乎不出现——把两张表换个位置写 LEFT 就行。真正容易出错的是下面这个细节:

    易错点:LEFT JOIN 的过滤条件写 ON 还是 WHERE?

    对右表的过滤条件写在 ON 里:左表行全保留,不匹配的补 NULL(「每个商品及其高价订单,没卖过高价的显示 NULL」)。写在 WHERE 里:NULL 行会被过滤掉,LEFT JOIN 悄悄退化成 INNER JOIN。这是统计 SQL 出「数据变少」bug 的头号原因。

    — ON 过滤右表:保留所有商品(正确姿势)
    FROM products p LEFT JOIN order_items oi
    ON oi.product_id = p.id AND oi.price > 100

    — WHERE 过滤:卖不出高价的商品直接消失(LEFT 白写了)
    FROM products p LEFT JOIN order_items oi
    ON oi.product_id = p.id
    WHERE oi.price > 100

    4.2 子查询:IN 还是 EXISTS

    — 下过单的用户:IN 写法
    SELECT * FROM users u
    WHERE u.id IN (SELECT user_id FROM orders);

    — EXISTS 写法
    SELECT * FROM users u
    WHERE EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id);

    经典结论是**「小表驱动大表」**:子查询结果集小用 IN(外层大表被小集合过滤);外表小、内层大且内层连接列有索引时 EXISTS 更快(外表每行去内层索引探测一次)。8.0 的优化器在多数场景会自动改写两者,优先保证语义清晰,别为想象中的性能牺牲可读性。

    4.3 GROUP BY 与 HAVING:销售日报

    SELECT DATE(o.pay_time) AS day,
    COUNT(*) AS order_cnt,
    SUM(o.total_amount) AS gmv,
    ROUND(AVG(o.total_amount), 2) AS avg_amount
    FROM orders o
    WHERE o.status >= 1 — 只统计有效订单
    GROUP BY DATE(o.pay_time) — 按支付日分组
    HAVING SUM(o.total_amount) > 10000 — 只要 GMV 过万的日子
    ORDER BY day DESC;

    记忆锚点:一条 SELECT 的执行顺序——FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY → LIMIT。 WHERE 过滤「行」(不能用聚合函数),HAVING 过滤「组」(可以用聚合函数)。这也是为什么 SELECT 里起的别名在 WHERE 里用不了、在 ORDER BY 里能用。

    易错点:ONLY_FULL_GROUP_BY。 8.0 默认开启该 sql_mode:SELECT 里出现的非聚合列必须出现在 GROUP BY 里,否则报 1055 错误。别急着关它——它防的是「一行结果混入别的行的值」这种逻辑炸弹。

    4.4 窗口函数(8.0):排名神器

    窗口函数 = 「不合并行的 GROUP BY」:分组聚合会把 N 行压成 1 行,窗口函数在每行旁边附上一个计算结果。先认三兄弟:

    SELECT name, score,
    ROW_NUMBER() OVER (ORDER BY score DESC) AS rn, — 1 2 3 4(并列也硬排)
    RANK() OVER (ORDER BY score DESC) AS rk, — 1 2 2 4(并列同名,跳号)
    DENSE_RANK() OVER (ORDER BY score DESC) AS dr — 1 2 2 3(并列同名,不跳号)
    FROM students;

    配 PARTITION BY 就是「分组内排名」——直接解掉最经典的业务题:

    — 经典题 1:每个分类销量 Top3 的商品
    SELECT * FROM (
    SELECT p.id, p.name, p.category_id, p.sales,
    ROW_NUMBER() OVER (PARTITION BY p.category_id ORDER BY p.sales DESC) AS rn
    FROM products p
    ) t
    WHERE t.rn <= 3;

    — 经典题 2:每日 GMV 与累计 GMV(先按天聚合,再对聚合结果开窗)
    SELECT DATE(pay_time) AS day,
    SUM(total_amount) AS daily,
    SUM(SUM(total_amount)) OVER (ORDER BY DATE(pay_time)) AS cumulative
    FROM orders
    WHERE status >= 1
    GROUP BY DATE(pay_time);

    第二题里 SUM(SUM(…)) OVER 看着吓人:内层 SUM 先完成「按天分组」,外层窗口对分组结果做「按天累加」。窗口函数在 GROUP BY 之后执行,所以聚合函数可以整体作为窗口的输入。

    4.5 递归 CTE:查整棵分类树

    categories 是树形结构(parent_id 指向父节点),查出「某分类的所有子孙」或整棵树,8.0 一个递归 CTE 搞定:

    WITH RECURSIVE tree AS (
    — 锚点:顶级分类
    SELECT id, name, parent_id, 1 AS lvl, CAST(id AS CHAR(200)) AS path
    FROM categories WHERE parent_id = 0
    UNION ALL
    — 递归:不断找上一层节点的孩子
    SELECT c.id, c.name, c.parent_id, tree.lvl + 1, CONCAT(tree.path, '-', c.id)
    FROM categories c
    INNER JOIN tree ON c.parent_id = tree.id
    )
    SELECT * FROM tree ORDER BY path; — path 前缀天然按树形排序

    4.6 两道压轴实战题

    压轴 1:深分页为什么慢、怎么救

    — 慢:LIMIT 1000000, 20 要取 1000020 行、丢掉前 100 万行
    SELECT * FROM orders ORDER BY id LIMIT 1000000, 20;

    — 优化 A:游标/书签翻页——记住上一页最后的 id(要求排序键连续递增)
    SELECT * FROM orders WHERE id > 1000000 ORDER BY id LIMIT 20;

    — 优化 B:延迟关联——先用覆盖索引把 20 个 id 找出来,再回表取整行
    SELECT o.*
    FROM orders o
    INNER JOIN (SELECT id FROM orders ORDER BY id LIMIT 1000000, 20) t
    ON o.id = t.id;

    压轴 2:找出连续登录 ≥ 3 天的用户

    思路一眼难、代码一行透:连续的日期减去连续的序号,差值恒定——差值相同的日子必然连成一段。

    — 表:users_login(user_id, login_date)
    SELECT user_id, MIN(login_date) AS streak_start, COUNT(*) AS days
    FROM (
    SELECT user_id, login_date,
    DATE_SUB(login_date, INTERVAL ROW_NUMBER() OVER (
    PARTITION BY user_id ORDER BY login_date) DAY) AS grp
    FROM (SELECT DISTINCT user_id, login_date FROM users_login) d — 先去重
    ) t
    GROUP BY user_id, grp
    HAVING COUNT(*) >= 3;
    — grp 相同的行即一段连续登录;要 ≥N 天就改 HAVING 条件


    五、索引:从 B+ 树到 EXPLAIN 实战

    索引是 MySQL 性能问题的第一现场:九成慢 SQL 的病因和药方都在索引上。这一章从「为什么偏偏是 B+ 树」讲起,一路到 EXPLAIN 实战排障,是全文最值得反复看的一章。

    5.1 为什么是 B+ 树:一道排除题

    回忆索引结构,最快的办法是把候选方案挨个排除:

    • 哈希表:等值查询 O(1) 很香,但完全不支持范围查询和排序——WHERE id > 100 直接歇菜;
    • 二叉搜索树:数据递增插入会退化成链表,O(n);
    • 红黑树/AVL:解决了退化,但二叉意味着「百万数据 20+ 层」,每层都是一次磁盘 IO;
    • B 树:多叉矮胖,方向对了;但每个节点都存整行数据,一页 16KB 装不了几个键,树还是偏高;
    • B+ 树:非叶子节点只存「键 + 指针」,叶子节点存数据并且按序组成双向链表——矮、且范围查询顺藤摸瓜。

    #mermaid-svg-iAP2EhnRiZiIZRBv{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-iAP2EhnRiZiIZRBv .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-iAP2EhnRiZiIZRBv .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-iAP2EhnRiZiIZRBv .error-icon{fill:#552222;}#mermaid-svg-iAP2EhnRiZiIZRBv .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-iAP2EhnRiZiIZRBv .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-iAP2EhnRiZiIZRBv .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-iAP2EhnRiZiIZRBv .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-iAP2EhnRiZiIZRBv .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-iAP2EhnRiZiIZRBv .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-iAP2EhnRiZiIZRBv .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-iAP2EhnRiZiIZRBv .marker{fill:#333333;stroke:#333333;}#mermaid-svg-iAP2EhnRiZiIZRBv .marker.cross{stroke:#333333;}#mermaid-svg-iAP2EhnRiZiIZRBv svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-iAP2EhnRiZiIZRBv p{margin:0;}#mermaid-svg-iAP2EhnRiZiIZRBv .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-iAP2EhnRiZiIZRBv .cluster-label text{fill:#333;}#mermaid-svg-iAP2EhnRiZiIZRBv .cluster-label span{color:#333;}#mermaid-svg-iAP2EhnRiZiIZRBv .cluster-label span p{background-color:transparent;}#mermaid-svg-iAP2EhnRiZiIZRBv .label text,#mermaid-svg-iAP2EhnRiZiIZRBv span{fill:#333;color:#333;}#mermaid-svg-iAP2EhnRiZiIZRBv .node rect,#mermaid-svg-iAP2EhnRiZiIZRBv .node circle,#mermaid-svg-iAP2EhnRiZiIZRBv .node ellipse,#mermaid-svg-iAP2EhnRiZiIZRBv .node polygon,#mermaid-svg-iAP2EhnRiZiIZRBv .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-iAP2EhnRiZiIZRBv .rough-node .label text,#mermaid-svg-iAP2EhnRiZiIZRBv .node .label text,#mermaid-svg-iAP2EhnRiZiIZRBv .image-shape .label,#mermaid-svg-iAP2EhnRiZiIZRBv .icon-shape .label{text-anchor:middle;}#mermaid-svg-iAP2EhnRiZiIZRBv .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-iAP2EhnRiZiIZRBv .rough-node .label,#mermaid-svg-iAP2EhnRiZiIZRBv .node .label,#mermaid-svg-iAP2EhnRiZiIZRBv .image-shape .label,#mermaid-svg-iAP2EhnRiZiIZRBv .icon-shape .label{text-align:center;}#mermaid-svg-iAP2EhnRiZiIZRBv .node.clickable{cursor:pointer;}#mermaid-svg-iAP2EhnRiZiIZRBv .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-iAP2EhnRiZiIZRBv .arrowheadPath{fill:#333333;}#mermaid-svg-iAP2EhnRiZiIZRBv .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-iAP2EhnRiZiIZRBv .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-iAP2EhnRiZiIZRBv .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-iAP2EhnRiZiIZRBv .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-iAP2EhnRiZiIZRBv .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-iAP2EhnRiZiIZRBv .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-iAP2EhnRiZiIZRBv .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-iAP2EhnRiZiIZRBv .cluster text{fill:#333;}#mermaid-svg-iAP2EhnRiZiIZRBv .cluster span{color:#333;}#mermaid-svg-iAP2EhnRiZiIZRBv div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-iAP2EhnRiZiIZRBv .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-iAP2EhnRiZiIZRBv rect.text{fill:none;stroke-width:0;}#mermaid-svg-iAP2EhnRiZiIZRBv .icon-shape,#mermaid-svg-iAP2EhnRiZiIZRBv .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-iAP2EhnRiZiIZRBv .icon-shape p,#mermaid-svg-iAP2EhnRiZiIZRBv .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-iAP2EhnRiZiIZRBv .icon-shape .label rect,#mermaid-svg-iAP2EhnRiZiIZRBv .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-iAP2EhnRiZiIZRBv .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-iAP2EhnRiZiIZRBv .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-iAP2EhnRiZiIZRBv :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}

    双向链表

    双向链表

    根节点(内部页)只存键和指针:15 · 35

    叶子页①5 · 10 · 15

    叶子页②20 · 25 · 30

    叶子页③40 · 45 · 50

    著名的「2000 万行只需 3 层」推导:主键 BIGINT 8 字节 + 页指针 6 字节 = 14 字节,一页 16KB 约 1170 个路牌;假设每行数据 1KB,一页叶子存 16 行。于是 3 层树容量 = 1170 × 1170 × 16 ≈ 2190 万行。根和中间层常年驻留内存,一次主键查询通常只有 1 次磁盘 IO。

    5.2 聚簇索引与二级索引:回表的由来

    • 聚簇索引(主键索引):叶子节点存整行数据。InnoDB 表本身就是「按主键组织」的 B+ 树,所以也叫索引组织表;表没有主键时,InnoDB 会挑一个唯一非空索引顶替,再没有就生成隐藏的 row_id。
    • 二级索引(普通索引):叶子节点存「索引列的值 + 主键值」,不存行数据——不是不想存,是行数据会随页分裂移动,存地址会失效,存稳定的主键值才可靠。

    于是一次二级索引查询的完整路径是:先在二级索引树找到主键值,再拿主键值去聚簇索引树查一遍整行——第二次查找就叫回表。如果命中行数很大(比如 10 万行),就是 10 万次回表,这就是很多「明明有索引还是慢」的真相。

    记忆锚点:主键为什么推荐自增。 自增主键永远追加到 B+ 树最右侧,页写满才开新页,顺序友好;UUID 这类随机主键会让新数据随机插入,频繁页分裂、内存命中率下降、树越来越高。非要用分布式 ID,用趋势递增的雪花 ID。

    5.3 覆盖索引与索引下推:省掉回表的两板斧

    — 覆盖索引:SELECT 的列全部包含在索引里 → 不用回表,EXPLAIN 的 Extra 显示 Using index
    SELECT id, category_id FROM products WHERE category_id = 3; — idx_category(category_id)

    — 索引下推(ICP,5.6+):把 WHERE 里能用索引判断的条件"下推"给存储引擎先过滤
    — 假设索引 idx_name_age(name, age):
    SELECT * FROM users WHERE name LIKE '张%' AND age = 10;
    — 没有 ICP:引擎按 '张%' 找到 1000 行全部回表,Server 层再按 age=10 过滤剩 10 行
    — 有 ICP: 引擎在索引里就把 age != 10 的 990 行扔掉,只回表 10 行

    5.4 最左前缀原则:联合索引的灵魂

    联合索引 (a, b, c) 的排序规则是「先按 a 排,a 相同按 b 排,再相同按 c 排」——电话簿先姓后名。所以查询条件必须从最左列开始连续命中:

    WHERE 条件索引使用情况原因
    a = 1 用上 a 从最左列开始
    a = 1 AND b = 2 用上 a、b 连续命中
    a = 1 AND b = 2 AND c = 3 全用上 完美命中
    a = 1 AND b > 2 AND c = 3 只用 a、b b 是范围查询,c 断了
    b = 2 AND c = 3 用不上 缺了最左列 a
    a = 1 AND c = 3 只用 a b 缺席,c 接不上(8.0.13+ 部分场景有 Skip Scan,别依赖)
    • 设计口诀:等值条件的列放前面,范围条件的列放最后;区分度(不重复比例)高的列放前面;多租户系统把 tenant_id 放最前;
    • 已建 (a,b) 就不必单建 (a):最左前缀让它兼职了 a 的索引——「减少冗余索引」是 DDL 评审的固定动作。

    5.5 索引失效场景清单(逐条对照)

    — ① 索引列参与运算或套函数
    WHERE YEAR(created_at) = 2026 — ✗ 给列套了函数,索引报废
    WHERE created_at >= '2026-01-01'
    AND created_at < '2027-01-01' — ✓ 改写成范围条件

    — ② 隐式类型转换(字符串列用数字查)
    WHERE order_no = 20260925001 — ✗ 等价于给 order_no 套 CAST 函数
    WHERE order_no = '20260925001' — ✓ 类型对齐

    — ③ 前导模糊匹配
    WHERE name LIKE '%手机%' — ✗ 开头不确定,B+ 树没法定位
    WHERE name LIKE '苹果%' — ✓ 相当于范围查询 [苹果, 苹果+1)

    — ④ OR 连接了没有索引的列
    WHERE category_id = 3 OR subtitle = 'x' — ✗ subtitle 无索引 → 整条 SQL 全表扫描

    — ⑤ 联合索引不满足最左前缀(见 5.4)

    — ⑥ 优化器「主动放弃」:预估命中行数太多,回表成本 > 全表扫描
    — (严格说这不叫失效,是成本估算;FORCE INDEX 可强制,但先想清楚要不要)

    纠偏:老文章的两大误传。「!= 和 NOT IN 一定失效」——不对,MySQL 会按成本估算决定走 range 还是全表,很多时候照样走索引;「IS NULL 用不了索引」——也不对,NULL 值同样存在 B+ 树里。判断标准永远是 EXPLAIN,不是口诀。

    5.6 EXPLAIN:慢 SQL 的听诊器

    EXPLAIN SELECT * FROM orders WHERE user_id = 88 AND status = 1
    ORDER BY created_at DESC LIMIT 20;

    字段看什么
    id 执行序号,相同为一组从上往下执行;子查询/UNION 会产生新 id,大结果集在后
    type 访问类型,性能从好到差(见下方梯度)
    key / key_len 实际用了哪个索引、用了几列(判断联合索引用到第几列)
    rows 预估扫描行数,数量级比精确值重要
    Extra 附加动作,重点盯 Using filesort / Using temporary

    type 梯度(从好到差):const → eq_ref → ref → range → index → ALL。日常标准:至少 range,等值查询应到 ref,JOIN 被驱动表应到 eq_ref;出现 ALL(全表扫描)就要警惕。

    • const:主键/唯一键等值,最多一行;
    • eq_ref:JOIN 时被驱动表走主键或唯一索引;
    • ref:普通二级索引等值;
    • range:索引范围(BETWEEN、>、IN 等);
    • index:扫整棵索引树(常见于覆盖索引或 MIN/MAX);
    • ALL:全表扫描。

    Extra 高频值:Using index 覆盖索引(好);Using index condition 索引下推(好);Using filesort 需要额外排序(优化信号);Using temporary 建了临时表(GROUP BY / DISTINCT 常见,重点优化)。

    5.7 实战:订单列表页慢查询优化全过程

    场景:用户中心「我的订单」页,线上平均 1.8s。SQL 与优化前 EXPLAIN:

    SELECT * FROM orders
    WHERE user_id = 88 AND status = 1
    ORDER BY created_at DESC
    LIMIT 20;

    — 优化前(表上只有 idx_user_created(user_id, created_at)):
    — type: ref | key: idx_user_created | rows: 8972 | Extra: Using index condition
    — 诊断:status 不在索引里 → 8972 行全部回表再过滤,行数越多越慢

    分析:查询条件是 user_id 等值 + status 等值 + created_at 排序。按「等值列在前、范围/排序列在后」重建联合索引:

    ALTER TABLE orders ADD INDEX idx_user_status_created (user_id, status, created_at);
    ALTER TABLE orders DROP INDEX idx_user_created;

    — 优化后:
    — type: ref | key: idx_user_status_created | rows: 41 | Extra: Backward index scan
    — 三列全部命中:先按 user_id+status 精确定位,created_at 在索引里天然有序 → 免排序
    — 线上实测 1.8s → 12ms

    记忆锚点:索引优化三步法。 ① EXPLAIN 看 type/key/rows/Extra 四件套 → ② 判断「条件列有没有都进索引、排序能不能免掉」→ ③ 按「等值在前、范围在后」调整联合索引列顺序。九成慢 SQL 走不完这三步就解决了。


    六、事务与 MVCC

    转账是最经典的开场:A 扣 100、B 加 100,两步必须同生共死。事务解决的正是「多个操作打包成一个不可分割的单元」,而 MVCC 是 InnoDB 实现「读写不互相阻塞」的独门功夫。

    6.1 ACID:四个特性各靠什么实现

    特性含义InnoDB 靠什么
    原子性 Atomicity 要么全做、要么全不做 undo log(回滚日志)
    一致性 Consistency 数据从一个合法状态到另一个合法状态 它是目的,由 A、I、D 共同保障 + 应用层约束
    隔离性 Isolation 并发事务互不干扰 MVCC + 锁
    持久性 Durability 提交了就永久生效,宕机也不丢 redo log(WAL)

    (这一列就是第 7、8 章的目录。)

    6.2 并发三问题与四个隔离级别

    两个事务并发跑,会出现三种「脏」现象:脏读(读到了别人没提交的数据)、不可重复读(同一事务里两次读同一行,值变了)、幻读(同一事务里两次按同样条件查,行数变了)。隔离级别就是「允许哪种现象」的取舍:

    隔离级别脏读不可重复读幻读
    READ UNCOMMITTED 读未提交 可能 可能 可能
    READ COMMITTED 读已提交 不可能 可能 可能
    REPEATABLE-READ 可重复读(默认) 不可能 不可能 部分解决(见 6.5)
    SERIALIZABLE 串行化 不可能 不可能 不可能(读也加锁,并发最差)

    实战演练:转账事务 + 隔离级别

    SELECT @@transaction_isolation; — 查看当前隔离级别,默认 REPEATABLE-READ
    SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED; — 只改当前会话

    — 转账:两个终端(会话 A 扣款、会话 B 观察)演示
    BEGIN;
    UPDATE accounts SET balance = balance – 100 WHERE id = 1; — A:只改不提交
    UPDATE accounts SET balance = balance + 100 WHERE id = 2;
    COMMIT; — 或 ROLLBACK; 撤销一切
    — 会话 B 在 A 提交前查询:RU 级别能读到未提交数据(脏读);RC/RR 读到的仍是旧值

    — SAVEPOINT:事务里的"存档点"
    BEGIN;
    UPDATE products SET stock = stock – 1 WHERE id = 1;
    SAVEPOINT sp1;
    UPDATE products SET stock = stock – 1 WHERE id = 2; — 这步做错了
    ROLLBACK TO sp1; — 只撤销第二步,第一步保留
    COMMIT;

    6.3 MVCC 三件套:隐藏列、版本链、ReadView

    RR 级别下「别人改了但没提交,我读到的还是旧值」——不是锁等待,是 **MVCC(多版本并发控制)**在悄悄工作。它由三件套组成:

    • 隐藏列:每行数据都有两个隐形字段 DB_TRX_ID(最后修改它的事务 ID)和 DB_ROLL_PTR(回滚指针,指向 undo log 里的上一版本);
    • undo log 版本链:每次修改都把旧版本存进 undo log,一行数据的历史版本串成链表;
    • ReadView(读视图):事务发起快照读时生成的「名单」,记录此刻哪些事务活跃、哪些已提交。

    #mermaid-svg-6dnQ4a4ygnKdUD0D{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-6dnQ4a4ygnKdUD0D .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-6dnQ4a4ygnKdUD0D .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-6dnQ4a4ygnKdUD0D .error-icon{fill:#552222;}#mermaid-svg-6dnQ4a4ygnKdUD0D .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-6dnQ4a4ygnKdUD0D .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-6dnQ4a4ygnKdUD0D .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-6dnQ4a4ygnKdUD0D .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-6dnQ4a4ygnKdUD0D .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-6dnQ4a4ygnKdUD0D .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-6dnQ4a4ygnKdUD0D .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-6dnQ4a4ygnKdUD0D .marker{fill:#333333;stroke:#333333;}#mermaid-svg-6dnQ4a4ygnKdUD0D .marker.cross{stroke:#333333;}#mermaid-svg-6dnQ4a4ygnKdUD0D svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-6dnQ4a4ygnKdUD0D p{margin:0;}#mermaid-svg-6dnQ4a4ygnKdUD0D .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-6dnQ4a4ygnKdUD0D .cluster-label text{fill:#333;}#mermaid-svg-6dnQ4a4ygnKdUD0D .cluster-label span{color:#333;}#mermaid-svg-6dnQ4a4ygnKdUD0D .cluster-label span p{background-color:transparent;}#mermaid-svg-6dnQ4a4ygnKdUD0D .label text,#mermaid-svg-6dnQ4a4ygnKdUD0D span{fill:#333;color:#333;}#mermaid-svg-6dnQ4a4ygnKdUD0D .node rect,#mermaid-svg-6dnQ4a4ygnKdUD0D .node circle,#mermaid-svg-6dnQ4a4ygnKdUD0D .node ellipse,#mermaid-svg-6dnQ4a4ygnKdUD0D .node polygon,#mermaid-svg-6dnQ4a4ygnKdUD0D .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-6dnQ4a4ygnKdUD0D .rough-node .label text,#mermaid-svg-6dnQ4a4ygnKdUD0D .node .label text,#mermaid-svg-6dnQ4a4ygnKdUD0D .image-shape .label,#mermaid-svg-6dnQ4a4ygnKdUD0D .icon-shape .label{text-anchor:middle;}#mermaid-svg-6dnQ4a4ygnKdUD0D .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-6dnQ4a4ygnKdUD0D .rough-node .label,#mermaid-svg-6dnQ4a4ygnKdUD0D .node .label,#mermaid-svg-6dnQ4a4ygnKdUD0D .image-shape .label,#mermaid-svg-6dnQ4a4ygnKdUD0D .icon-shape .label{text-align:center;}#mermaid-svg-6dnQ4a4ygnKdUD0D .node.clickable{cursor:pointer;}#mermaid-svg-6dnQ4a4ygnKdUD0D .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-6dnQ4a4ygnKdUD0D .arrowheadPath{fill:#333333;}#mermaid-svg-6dnQ4a4ygnKdUD0D .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-6dnQ4a4ygnKdUD0D .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-6dnQ4a4ygnKdUD0D .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-6dnQ4a4ygnKdUD0D .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-6dnQ4a4ygnKdUD0D .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-6dnQ4a4ygnKdUD0D .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-6dnQ4a4ygnKdUD0D .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-6dnQ4a4ygnKdUD0D .cluster text{fill:#333;}#mermaid-svg-6dnQ4a4ygnKdUD0D .cluster span{color:#333;}#mermaid-svg-6dnQ4a4ygnKdUD0D div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-6dnQ4a4ygnKdUD0D .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-6dnQ4a4ygnKdUD0D rect.text{fill:none;stroke-width:0;}#mermaid-svg-6dnQ4a4ygnKdUD0D .icon-shape,#mermaid-svg-6dnQ4a4ygnKdUD0D .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-6dnQ4a4ygnKdUD0D .icon-shape p,#mermaid-svg-6dnQ4a4ygnKdUD0D .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-6dnQ4a4ygnKdUD0D .icon-shape .label rect,#mermaid-svg-6dnQ4a4ygnKdUD0D .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-6dnQ4a4ygnKdUD0D .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-6dnQ4a4ygnKdUD0D .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-6dnQ4a4ygnKdUD0D :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}

    roll_pointer

    roll_pointer

    当前版本balance = 900trx_id = 300

    undo 版本 2balance = 1000trx_id = 200

    undo 版本 1(初始)balance = 1000trx_id = 100

    可见性判断规则(简化记忆版)——拿版本的 trx_id 和 ReadView 比大小:

  • 等于「我自己」的 trx_id → 可见(自己改的当然能看);
  • 小于活跃事务下限 → 可见(我拍照前早就提交了);
  • 大于等于活跃事务上限 → 不可见(我拍照之后才开的事务);
  • 落在区间内:在活跃名单里 → 不可见(还没提交);不在名单里 → 可见(已提交)。
  • 不可见就顺着 roll_pointer 走到上一版本,重复判断,直到找到可见版本或链尾。一句口诀:「小于下限可见,超过上限不可见,中间查名单」。

    记忆锚点:RC 和 RR 的本质区别只有一句话。 RC:每次快照读都生成新 ReadView → 能看到别的事务新提交的数据 → 不可重复读。 RR:只在事务第一次查询时生成 ReadView,之后一直复用 → 整个事务看到的世界定格在开头 → 可重复读。 所有隔离级别差异,都是这一句话的推论。

    6.4 快照读与当前读

    • 快照读:普通 SELECT,走 MVCC,不加锁,读的是快照;
    • 当前读:SELECT … FOR UPDATE、SELECT … FOR SHARE(8.0 写法,旧版 LOCK IN SHARE MODE)以及所有 UPDATE / DELETE——读的是最新版本,并加锁。

    为什么 UPDATE 必须「当前读」?如果不读最新值,两个事务基于同一个旧快照各自 +1,结果会丢一次更新。改数据永远以最新版本为准。

    6.5 RR 到底有没有解决幻读

    标准答案:InnoDB 的 RR 在很大程度上解决了幻读,但没堵死:

    • 快照读:靠 MVCC,事务内多次查询结果一致,天然无幻读;
    • 当前读:靠**临键锁(Next-Key Lock)**锁住「已有记录 + 记录间的间隙」,别的事务插不进来(第 7 章展开);
    • 漏洞场景:事务先快照读(未加锁),别的事务插入一行并提交,本事务再当前读——这批「幻影行」就出现了。要彻底避免,第一次查询就用 FOR UPDATE。

    七、锁:从 MDL 到间隙锁

    锁是隔离性的另一半。MVCC 让「读」不阻塞,锁负责「写与写」排队。这一章按粒度从大到小过一遍,最后用死锁复现和「超卖」三段式收尾——都是真实线上会疼的案例。

    7.1 锁的全景:一张表看全

    粒度锁一句话职责
    全局 FTWRL 全局读锁 整库只读,逻辑备份用
    表级 LOCK TABLES 表锁 显式锁表,行锁时代基本不用
    表级 MDL 元数据锁 DDL 与 DML 互斥的隐形保镖,最容易出线上事故
    表级 意向锁 IS / IX 行锁前先在表上「挂号」,避免逐行检查冲突
    表级 AUTO-INC 锁 保证自增值分配不重号
    行级 Record Lock 记录锁 锁住索引上的单条记录
    行级 Gap Lock 间隙锁 锁住两条记录之间的空隙,专防插入
    行级 Next-Key Lock 临键锁 记录锁 + 间隙锁,RR 防幻读的主力

    7.2 全局锁:备份的正确姿势

    FLUSH TABLES WITH READ LOCK; — 全库只读:MyISAM 时代的备份方法
    UNLOCK TABLES;

    — InnoDB 时代:靠 MVCC 快照拿到一致性视图,完全不阻塞业务
    mysqldump –single-transaction -uroot -p mall > mall_$(date +%F).sql

    7.3 MDL 锁:最隐蔽的线上事故

    对表做增删改查时自动加 MDL读锁,做 ALTER 时需要 MDL写锁,读写互斥。经典翻车现场:

    会话 A:BEGIN; SELECT * FROM users WHERE id = 1; — 拿到 MDL 读锁,事务挂着没提交
    会话 B:ALTER TABLE users ADD COLUMN vip INT; — 需要 MDL 写锁 → 排队等 A
    会话 C:SELECT * FROM users WHERE id = 2; — 只要 MDL 读锁,却被 B 挡在后面
    — 结果:这张表的所有后续查询全部堆积,业务"假死"

    • 预防一:DDL 前先查长事务,有挂着的事务就别动表:SELECT * FROM information_schema.innodb_trx ORDER BY trx_started;
    • 预防二:把 MDL 等待上限调小,别用默认值(默认约一年):SET GLOBAL lock_wait_timeout = 10;——拿不到写锁就失败撤退,而不是把表堵死;
    • 预防三:低峰期做 DDL,并先在预发环境演练。

    7.4 行锁三兄弟:加在索引上的锁

    假设表里有 id 为 1、5、10、15 四行(id 有索引),RR 级别下的加锁规则:

    语句场景加什么锁锁住的范围
    唯一索引等值,命中(id = 10) 记录锁 只有 id=10 这一行
    唯一索引等值,未命中(id = 7) 间隙锁 (5, 10) 之间的空隙,禁止插入
    普通索引等值(idx 上 age = 10) 临键锁 + 间隙锁 (5, 10] 及下一个间隙,防幻读
    范围条件(id > 10) 临键锁 (10, 15]、(15, +∞) 一路锁过去
    条件列没有索引 全部记录加临键锁 效果 ≈ 锁全表(最危险)

    易错点:行锁加在索引上,索引失效等于锁全表。 UPDATE orders SET status = 4 WHERE order_no = 'X1';——order_no 没索引时,InnoDB 全表扫描,把每一行都锁住,其他事务的任何更新全部排队。「删了一个没走索引的 WHERE」和「锁全表」是同一件事,线上大批量 UPDATE 前先 EXPLAIN 一遍。

    7.5 死锁复现与排查

    — 会话 A — 会话 B
    BEGIN; BEGIN;
    UPDATE products SET stock = stock-1 WHERE id = 1;
    UPDATE products SET stock = stock-1 WHERE id = 2;
    UPDATE products SET stock = stock-1 WHERE id = 2; — A 等 B 放 id=2
    UPDATE products SET stock = stock-1 WHERE id = 1; — B 等 A 放 id=1
    — ERROR 1213 (40001): Deadlock found …(B 被选为牺牲者回滚)

    SHOW ENGINE INNODB STATUS\\G
    — 找 "LATEST DETECTED DEADLOCK" 段:两个事务各持有什么锁、在等什么锁、最后执行的 SQL

    • InnoDB 默认开启死锁检测(innodb_deadlock_detect=ON),发现回环立即回滚代价小的一方,通常毫秒级解开;
    • 拿不到锁的等待由 innodb_lock_wait_timeout(默认 50s)控制,超时报 ERROR 1205;
    • 防死锁四招:多行更新固定顺序(如按主键升序)、事务尽量短、用条件更新代替「先查后改」、热点行用队列/Redis 削峰而不是硬怼数据库。

    7.6 实战:超卖问题的三种解法

    秒杀扣库存,同一个商品 1000 个请求同时来,库存只有 100——三段演进:

    — 版本 1:先查后改(必超卖)
    — stock = SELECT stock FROM products WHERE id = 1; → 应用判断 stock >= 1
    — UPDATE products SET stock = stock – 1 WHERE id = 1;
    — 并发下两个请求同时读到 stock=1、同时通过判断 → 卖出 2 件,库存只有 1

    — 版本 2:条件更新(推荐,本质是乐观锁思想)
    UPDATE products SET stock = stock – 1 WHERE id = 1 AND stock >= 1;
    — affected_rows = 0 → 没抢到,返回"已售罄";判断进入 WHERE,靠行锁保证原子性

    — 版本 3:悲观锁 SELECT … FOR UPDATE(适合还要联动多表的复杂扣减)
    BEGIN;
    SELECT stock FROM products WHERE id = 1 FOR UPDATE; — 锁住,别人进不来
    — … 应用层业务校验、写订单、写明细 …
    UPDATE products SET stock = stock – 1 WHERE id = 1;
    COMMIT;

    — 乐观锁通用形态:version 号
    UPDATE docs SET content = '新内容', version = version + 1
    WHERE id = 1 AND version = 5; — 命中 0 行 = 别人先改了,重读重试

    记忆锚点:什么时候用哪种锁。 冲突少 → 乐观锁(version / 条件更新),无锁高性能,失败重试;冲突激烈 → 悲观锁(FOR UPDATE),一次锁到位避免反复重试打爆 CPU;秒杀级热点 → 数据库锁都不合适,上 Redis 预扣 + MQ 异步落库。


    八、日志系统:redo / undo / binlog

    第 6 章表说过:原子性靠 undo、持久性靠 redo。这一章把三本日志彻底讲透,再回答那个经典问题——为什么更新一条数据要写两次日志(两阶段提交)。

    8.1 三本日志各管什么

    维度redo logundo logbinlog
    所属层 InnoDB InnoDB Server 层(所有引擎都有)
    核心作用 崩溃恢复,保持久性 回滚 + MVCC 版本链 主从复制、数据归档恢复
    记录内容 物理日志:「某页某位置改成了什么」 逻辑日志:反向操作(INSERT 记 DELETE) 逻辑日志:SQL 语句或行变更镜像
    写入方式 固定大小,循环写 随事务产生,提交后可清理 追加写,写满换下一个文件

    8.2 redo log:WAL 的精髓

    为什么改数据不直接刷盘?数据页在磁盘上随机分散,刷页是随机 IO;而日志是顺序追加,快几个数量级。所以 InnoDB 的选择是:先在内存(Buffer Pool)改页、同时把「这一页改了什么」顺序写进 redo log,脏页由后台线程慢慢刷。这套顺序写换随机写的思路就是 WAL(Write-Ahead Logging)。

    • 循环写:redo log 是固定大小的环(如 4 个文件各 1GB),write pos 追着 checkpoint 跑;写满就必须停下来推进 checkpoint(强制刷脏页),此时业务更新会被阻塞——线上偶发的「MySQL 卡一下」,查查 redo log 大小;
    • 落盘时机由 innodb_flush_log_at_trx_commit 控制:
    值行为风险
    0 每秒刷盘一次 崩溃丢最后一秒的事务
    1(默认) 每次提交都 fsync 落盘 最安全,IO 压力最大
    2 提交写到 OS 缓存,每秒 fsync MySQL 挂了不丢,主机断电丢一秒

    8.3 undo log:回滚的底气

    每次修改前先记「反向操作」:INSERT 记一条 DELETE,UPDATE 记旧值,DELETE 记整行。两个用途:一是 ROLLBACK 时逐条执行反向操作;二是作为 MVCC 的版本链(第 6 章图)。长事务的隐藏成本就在这:版本链不能清理,undo 越积越长,回滚段膨胀——又一个「别开长事务」的理由。

    8.4 binlog:复制与归档的生命线

    格式记录内容优点风险
    statement SQL 原文 日志量小 now()、uuid()、limit 顺序都可能导致主从不一致
    row(默认) 每行变更的前后镜像 精确,主从强一致 批量更新 100 万行就是 100 万条记录,日志暴涨
    mixed 一般 statement,有风险自动切 row 折中 切换逻辑复杂,排查问题绕

    SHOW BINLOG EVENTS IN 'binlog.000123' LIMIT 10; — 人肉看一眼 binlog 内容
    SHOW MASTER STATUS; — 当前写入到哪个 binlog 文件、位置

    8.5 两阶段提交:为什么更新要写两次日志

    redo log(InnoDB)和 binlog(Server)是两个独立的日志系统。假设不用两阶段、简单地「先 redo 后 binlog」,中间崩溃:恢复后主库有这条数据(redo 已落盘),binlog 里却没有 → 从库永远少这条 → 主从不一致。反过来先 binlog 后 redo,则从库多一条。两阶段提交就是让两本日志「要么都算数,要么都不算」:

    InnoDB

    执行器

    InnoDB

    执行器

    #mermaid-svg-1aTCO8TcWnj9kMl7{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-1aTCO8TcWnj9kMl7 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-1aTCO8TcWnj9kMl7 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-1aTCO8TcWnj9kMl7 .error-icon{fill:#552222;}#mermaid-svg-1aTCO8TcWnj9kMl7 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-1aTCO8TcWnj9kMl7 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-1aTCO8TcWnj9kMl7 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-1aTCO8TcWnj9kMl7 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-1aTCO8TcWnj9kMl7 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-1aTCO8TcWnj9kMl7 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-1aTCO8TcWnj9kMl7 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-1aTCO8TcWnj9kMl7 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-1aTCO8TcWnj9kMl7 .marker.cross{stroke:#333333;}#mermaid-svg-1aTCO8TcWnj9kMl7 svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-1aTCO8TcWnj9kMl7 p{margin:0;}#mermaid-svg-1aTCO8TcWnj9kMl7 .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-1aTCO8TcWnj9kMl7 text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-1aTCO8TcWnj9kMl7 .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-1aTCO8TcWnj9kMl7 .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-1aTCO8TcWnj9kMl7 .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-1aTCO8TcWnj9kMl7 .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-1aTCO8TcWnj9kMl7 #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-1aTCO8TcWnj9kMl7 .sequenceNumber{fill:white;}#mermaid-svg-1aTCO8TcWnj9kMl7 #sequencenumber{fill:#333;}#mermaid-svg-1aTCO8TcWnj9kMl7 #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-1aTCO8TcWnj9kMl7 .messageText{fill:#333;stroke:none;}#mermaid-svg-1aTCO8TcWnj9kMl7 .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-1aTCO8TcWnj9kMl7 .labelText,#mermaid-svg-1aTCO8TcWnj9kMl7 .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-1aTCO8TcWnj9kMl7 .loopText,#mermaid-svg-1aTCO8TcWnj9kMl7 .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-1aTCO8TcWnj9kMl7 .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-1aTCO8TcWnj9kMl7 .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-1aTCO8TcWnj9kMl7 .noteText,#mermaid-svg-1aTCO8TcWnj9kMl7 .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-1aTCO8TcWnj9kMl7 .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-1aTCO8TcWnj9kMl7 .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-1aTCO8TcWnj9kMl7 .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-1aTCO8TcWnj9kMl7 .actorPopupMenu{position:absolute;}#mermaid-svg-1aTCO8TcWnj9kMl7 .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-1aTCO8TcWnj9kMl7 .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-1aTCO8TcWnj9kMl7 .actor-man circle,#mermaid-svg-1aTCO8TcWnj9kMl7 line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-1aTCO8TcWnj9kMl7 :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}

    UPDATE orders SET status = 1 WHERE id = 10

    写 undo log(记录旧值)

    Buffer Pool 中修改数据页(脏页)

    返回影响行数

    COMMIT

    ① redo log 写入,状态 = prepare

    可以提交

    ② binlog 写入并落盘

    ③ 事务提交

    redo log 状态 = commit,完成

    记忆锚点:崩溃恢复的裁决规则。 重启后扫描 redo log:状态是 commit → 直接提交;状态是 prepare → 去查 binlog:这条事务的 binlog 完整(有对应 XID)→ 提交;不完整 → 回滚。一句话:「redo 说改了,还得 binlog 拍板」。

    与两阶段提交配套的还有 binlog 的落盘参数 sync_binlog:0(交给 OS,可能丢)、1(每次提交 fsync,最安全)、N(攒 N 个事务再 fsync)。生产标准配置是「双 1」:innodb_flush_log_at_trx_commit = 1 + sync_binlog = 1——钱相关的系统别省这个 IO。


    九、性能优化实战

    前面八章的知识,最终都汇到这一章使用。优化的正确顺序永远是:先找到慢的 SQL(慢查询日志),再看它为什么慢(EXPLAIN),最后对症下药(索引/SQL 改写/参数/架构)。反过来「凭感觉加索引」是新人最常见的弯路。

    9.1 第一步:让慢 SQL 自己现身

    — 开启慢查询日志:记录所有超过 1 秒的 SQL(以及没走索引的全表扫描)
    SET GLOBAL slow_query_log = ON;
    SET GLOBAL long_query_time = 1; — 线上一般从 1s 起步,逐步收紧到 0.5 / 0.2

    — 找到日志位置
    SHOW VARIABLES LIKE 'slow_query_log_file';

    — 8.0 直接查 performance_schema,聚合出 Top 慢 SQL
    SELECT DIGEST_TEXT AS sql_pattern,
    COUNT_STAR AS exec_count,
    ROUND(AVG_TIMER_WAIT/1e12, 3) AS avg_sec,
    ROUND(SUM_TIMER_WAIT/1e12, 3) AS total_sec
    FROM performance_schema.events_statements_summary_by_digest
    ORDER BY SUM_TIMER_WAIT DESC
    LIMIT 5;
    — 按 total_sec 排序:总耗时最多的先治理;按 avg_sec 排序:单次最疼的先治理

    9.2 看整体健康度

    SHOW GLOBAL STATUS LIKE 'Threads_running'; — 当前活跃线程,飙高 = 并发压力或锁等待
    SHOW GLOBAL STATUS LIKE 'Innodb_row_lock%'; — 行锁统计
    — Innodb_row_lock_waits / Innodb_row_lock_time_avg 高 → 第 7 章的锁问题

    — Buffer Pool 命中率:低于 99% 说明内存不够或存在全表扫描
    SHOW GLOBAL STATUS LIKE 'Innodb_buffer_pool_read%';
    — 命中率 = 1 – Innodb_buffer_pool_reads / Innodb_buffer_pool_read_requests

    9.3 实战案例一:深分页拖垮后台导出

    症状:运营后台导出订单,翻到几百页后每页要 5s+,期间整库 RT 上升。

    — 原始 SQL:LIMIT 500000, 20 —— 索引能定位起点,但优化器仍要数过50万行并回表
    SELECT * FROM orders ORDER BY id LIMIT 500000, 20;

    — 处方(游标翻页,4.6 节讲过):WHERE id > 上页最后id,一步跳到位
    SELECT * FROM orders WHERE id > 500000 ORDER BY id LIMIT 20; — 5s → 8ms
    — 导出场景没有"上一页"按钮,天然适合游标;页面跳转场景用延迟关联兜底

    9.4 实战案例二:一条统计 SQL 拖垮整个服务

    症状:晚高峰订单创建全部超时,但订单表 QPS 并不高。

    — 罪魁:运营写了条统计 SQL,WHERE 里的列套了函数
    SELECT COUNT(*) FROM orders WHERE DATE(created_at) = '2026-09-24';
    — EXPLAIN: type=ALL, rows=全表 → 全表扫描

    — 更糟的是同一条连接随后执行了:
    UPDATE orders SET status = 4 WHERE DATE(created_at) < '2026-09-01';
    — 没走索引的 UPDATE = 全表逐行加锁(7.4 节)→ 所有订单更新排队 → 业务雪崩

    — 处方:改成范围条件,索引复活
    SELECT COUNT(*) FROM orders
    WHERE created_at >= '2026-09-24' AND created_at < '2026-09-25'; — 走 idx_status_created
    UPDATE orders SET status = 4
    WHERE created_at < '2026-09-01'; — 走索引,只锁旧行
    — 教训:函数包列不仅慢查询,批量 UPDATE 时还会升级成锁全表

    9.5 实战案例三:COUNT(*) 太慢怎么救

    症状:商品列表页要显示总数,SELECT COUNT(*) FROM products 要 2s(200 万行)。

    — 认清事实:InnoDB 的 COUNT(*) 必须真数(MVCC 下每个事务可见行数不同),没有捷径
    — 但可以给 COUNT 加过滤条件,让它走覆盖索引免回表:
    SELECT COUNT(*) FROM products WHERE category_id = 3; — 走 idx_category,只扫索引树

    — 页面不要求精确时的三层方案:
    — ① 显示"约 N 万":explain 的 rows 估算值直接拿来用
    EXPLAIN SELECT * FROM products; — rows 列 ≈ 行数,毫秒级
    — ② 计数表:计数频繁的场景,单独维护 counter 表或 Redis 计数
    — ③ 汇总表:离线/定时任务每 5 分钟聚合一次写进 stats 表,前端读它

    9.6 参数调优速查(8.0 默认配置基础上)

    参数默认建议为什么
    innodb_buffer_pool_size 128M 物理内存的 50%~70% 数据页缓存,命中率是一切性能的地基
    innodb_flush_log_at_trx_commit 1 1(核心)/ 2(可容忍秒级丢失) 第 8 章
    sync_binlog 1 1 与上条组成「双 1」,主从一致性基石
    max_connections 151 按连接池 × 实例数评估 不是越大越好:每个连接耗内存,过多上下文切换
    innodb_log_file_size(redo) 48M 1~4G 太小频繁触发 checkpoint 刷脏,业务卡顿
    slow_query_log + long_query_time 关 开 + 1s 起步 没有慢日志的线上库等于盲飞

    9.7 优化心法:一张地图

    记忆锚点:性能优化的优先级。 SQL 与索引(成本最低收益最大)→ 表结构(冗余/拆分/冷热分离)→ 参数(Buffer Pool、日志)→ 架构(读写分离、缓存、分库分表)。 前两层解决 90% 的问题,永远从它们开始;分库分表是最后的大招,不是第一反应。


    十、速查手册与复习路线

    最后一章是「考前冲刺」:20 个高频问题的一句话答案,适合面试前或排障时快速唤醒记忆。答案里括号内的章节号,就是需要展开回忆的入口。

    10.1 20 问速答

    问题一句话答案
    1. MySQL 8.0 为什么删了查询缓存? 表更新即失效、命中率极低,得不偿失(1.2)
    2. InnoDB 和 MyISAM 的本质区别? 事务 + 行级锁 + 聚簇索引 + 崩溃恢复(2.1)
    3. 金额字段用什么类型? DECIMAL(10,2) 或分单位 BIGINT,禁浮点(2.2)
    4. utf8 和 utf8mb4 的区别? utf8 是最多 3 字节的阉割版,存不了 emoji(2.6)
    5. 为什么主键推荐自增整型? 顺序插入不页分裂、树矮、二级索引叶子小(5.2)
    6. B+ 树和 B 树的区别? 非叶子节点不存数据(更矮)+ 叶子层有序链表(范围查询)(5.1)
    7. 什么是回表?怎么避免? 二级索引查到主键再查聚簇索引取整行;覆盖索引避免(5.2 / 5.3)
    8. 联合索引 (a,b,c),WHERE b=2 走索引吗? 不走,缺最左列 a(5.4)
    9. LIKE ‘%xx%’ 为什么不走索引? 前导不确定,B+ 树无法定位起点;‘xx%’ 相当于范围查询(5.5)
    10. EXPLAIN 里 type=ALL 说明什么? 全表扫描,优先排查索引缺失或失效(5.6)
    11. ACID 各靠什么实现? A 靠 undo、D 靠 redo、I 靠 MVCC+锁、C 是前三者的目的(6.1)
    12. RR 和 RC 的本质区别? ReadView 生成时机:RR 第一次查询生成后复用,RC 每次查询重新生成(6.3)
    13. MVCC 是怎么实现的? 隐藏列(trx_id/roll_ptr)+ undo 版本链 + ReadView 可见性判断(6.3)
    14. RR 完全解决幻读了吗? 快照读靠 MVCC、当前读靠临键锁;先快照读后当前读仍可能幻读(6.5)
    15. 行锁加在什么上面? 索引上;条件列无索引 = 锁全表(7.4)
    16. 怎么防死锁? 固定加锁顺序 + 短事务 + 条件更新 + 热点行出队(7.5)
    17. 超卖怎么防? 把库存判断写进 UPDATE 的 WHERE(条件更新/乐观锁)(7.6)
    18. 为什么要两阶段提交? 保证 redo log 与 binlog 的原子性,否则崩溃后主从不一致(8.5)
    19. 「双 1」配置是什么? innodb_flush_log_at_trx_commit=1 + sync_binlog=1,最安全落盘(8.5)
    20. 深分页慢怎么优化? 游标翻页(WHERE id > 上页末尾)或延迟关联(4.6 / 9.3)

    10.2 常用运维命令贴

    — 看状态
    SHOW PROCESSLIST; — 当前连接与正在执行的 SQL
    SHOW ENGINE INNODB STATUS\\G — 死锁/锁等待/缓冲池全景
    SELECT * FROM information_schema.innodb_trx; — 活跃事务(DDL 前必查)

    — 看表
    SHOW TABLE STATUS FROM mall; — 引擎、行数、平均行长
    SHOW INDEX FROM orders; — 索引清单
    SHOW CREATE TABLE orders\\G — 完整建表语句

    — 看执行
    EXPLAIN ANALYZE SELECT ...; — 8.0 的 ANALYZE 会真的执行并给真实耗时

    — 备份与恢复
    mysqldump –single-transaction -uroot -p mall > mall_backup.sql
    mysql –uroot –p mall < mall_backup.sql

    10.3 三轮复习路线

    • 第一轮 · 串主线(今天):只读每章的「记忆锚点」和四张图,把「SQL 生命周期 → 索引 → MVCC → 锁 → 日志」这条线连起来,目标是合上页面能复述这条线;
    • 第二轮 · 跑实战(本周):把 mall 五张表建出来,亲手跑第 3、4 章的每一段 SQL,做一遍 5.7 节的索引优化实验和 7.5 节的死锁复现——手过一遍顶看十遍;
    • 第三轮 · 刷速查(面试前夜):只看 10.1 的 20 问,答不上来的按括号里的章节号回去补那一段。

    最后一块拼图:所有 MySQL 知识最终收敛成一句话——一条 SQL 进来,服务层解析优化,引擎层用 B+ 树找数据,靠 MVCC 让读写不打架,靠锁让写写排队,靠 redo/undo/binlog 保证不丢不错。能默写这句话,你就没忘。


    MySQL 知识全景回顾 · 基于 MySQL 8.0 / InnoDB · 2026-09 整理 · 欢迎收藏复习,转载请注明出处

    赞(0)
    未经允许不得转载:网硕互联帮助中心 » 从一条 SQL 的生命周期开始,把 MySQL 一次性想明白
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!