Files
cs-note/hhs/MySQL/02-SQL核心/06-DDL 建表与结构变更.md
T
2026-05-24 11:42:38 +08:00

20 KiB
Raw Blame History

tags, create time, update time
tags create time update time
MySQL
DDL
ALTER TABLE
CREATE TABLE
DATATYPE
NULL
ONLINE DDL
INDEX
CHARACTER SET
SNOWFLAKE
PRIMARY KEY
2026-05-16 00:00 2026-05-20 12:00

DDL — 建表与结构变更

概述

DDL(Data Definition Language)用于定义和修改数据库对象的结构。本章从最基础的 CREATE TABLE 语法出发,覆盖数据类型选型、主键策略、索引设计原则与常见反模式,然后深入 ALTER TABLE 操作与在线 DDL 机制,最后给出生产环境的完整 DDL 检查清单。

CREATE TABLE

CREATE TABLE users (
    id            BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    username      VARCHAR(50)   NOT NULL COMMENT '用户名',
    email         VARCHAR(255)  NOT NULL COMMENT '邮箱唯一',
    password_hash VARCHAR(64)   NOT NULL COMMENT 'bcrypt hash',
    status        TINYINT       NOT NULL DEFAULT 1 COMMENT '1=active 0=disabled',
    created_at    DATETIME      NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at    DATETIME      NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    
    UNIQUE KEY uk_email (email),
    INDEX idx_username (username),
    INDEX idx_status_created (status, created_at)
) ENGINE=InnoDB
  DEFAULT CHARSET=utf8mb4
  COLLATE=utf8mb4_0900_ai_ci
  COMMENT='用户表';

关键语法解析

子句 作用
ENGINE=InnoDB 显式指定存储引擎(8.0 默认值)
DEFAULT CHARSET 数据库级字符集覆盖
ON UPDATE CURRENT_TIMESTAMP 自动维护更新时间戳
UNIQUE KEY 唯一索引,同时约束数据唯一性
INDEX idx_name (col) 普通索引,辅助查询
COMMENT 表和字段注释,可通过 SHOW FULL COLUMNS 查看

[!TIP] 命名规范约定

  • 索引名:idx_表名_列名(普通索引)、uk_表名_列名(唯一索引)——避免超过 64 字符
  • 时间字段:统一用 DATETIME 而非 TIMESTAMP。后者范围仅限 1970~2038,遇到闰秒时会报错
  • 自增主键:务必用 UNSIGNED,正数区间翻倍(0 ~ 42.9 亿),且能略微节省空间

[!QUESTION] 思考:为什么 password_hash 要用 VARCHAR(64) 而不是固定长度? 答案取决于你使用的哈希算法。bcrypt 输出为 60 字符(如 $2b$12$...),预留 64 留有扩展余量。如果改用 SHA-256 则恰好 64 字节,此时 CHAR(64) 反而更高效——因为固定长度无需额外存储长度前缀。


数据类型选择指南

建表时数据类型直接决定磁盘占用和查询性能。以下是常见场景的经验推荐:

graph TD
    A["需要整数?"] -->|"是"| B["选择尺寸"]
    B --> C["TINYINT<br/>-128 ~ 127"]
    B --> D["SMALLINT<br/>-3.2 万 ~ 3.2 万"]
    B --> E["MEDIUMINT<br/>-838 万 ~ 838 万"]
    B --> F["INT<br/>-21 亿 ~ 21 亿"]
    B --> G["BIGINT<br/>±922 亿亿"]

    A -->|"否"| H["字符串?"]
    H --> I["变长 < 255"]
    I --> J["VARCHAR(N)"]
    H --> K["定长 (密码/签名/IP)"]
    K --> L["CHAR(N)"]
    H --> M["超长 (文章/JSON)"]
    M --> N["TEXT / LONGTEXT"]
类型家族 适用场景 避坑提示
TINYINT 状态标记、布尔值 建议加 UNSIGNED,0~255 足够
INT 计数器、外键引用 数字型 ID 优先 INT UNSIGNED,超 42 亿再用 BIGINT
VARCHAR(N) 用户名、邮箱、地址 N 按实际 +20% 余量;不要盲目设 255
DATETIME 所有时间戳 MySQL 8.0 推荐;避免 TIMESTAMP 的 2038 年瓶颈
DECIMAL(M,D) 金额 绝对禁止用 FLOAT/DOUBLE 存储财务数据

一个常见的反例

-- ❌ 错误:用 FLOAT 存价格,会出现精度丢失
price       FLOAT           DEFAULT 0.00,

-- ✅ 正确:DECIMAL 精确表示货币,M 总位数 D 小数位
price       DECIMAL(10, 2)  DEFAULT 0.00 COMMENT '单位:元',

[!NOTE] DECIMAL(10,2) 表示最多 10 位数字,其中 2 位在小数点后,即最大值为 99999999.99。在 InnoDB 内部以二进制紧凑存储,性能接近整数类型。


主键设计选型

InnoDB 是 索引组织表(IOT)——数据和聚簇索引存于一体。主键的选择直接影响磁盘布局、二级索引大小和分库分表的可行性。

方案 长度 有序性 可预测性 适用场景
BIGINT AUTO_INCREMENT 8 字节 ✅ 天然有序 ❌ 单调递增暴露业务量 单库单表、中小型项目
BIGINT UNSIGNED + 外部生成器 8 字节 ❌ 随机 ✅ 不可预测 高并发分布式场景
CHAR(36) UUID() 36 字节 ❌ 完全随机 ✅ 不可预测 需要全局唯一 ID 的场景
CHAR(26) ULID / Base62 Snowflake 26 字节 ✅ 时间有序 ✅ 不可预测 推荐:分布式 + 有序 + 紧凑
-- ❌ 不推荐:直接用 UUID() 作主键(8.0.13 之前)
id CHAR(36) PRIMARY KEY DEFAULT (UUID())
-- 问题:① 36 字节膨胀二级索引;② 随机写入导致页分裂严重;
--      ③ B+ 树高度增加,缓冲池命中率下降

-- ✅ 推荐:Snowflake 风格 64 位整数
-- 1 bit 符号位 + 41 bit 毫秒时间戳 + 10 bit 机器标识 + 12 bit 序列号
-- 最大 ID = 2^63 - 1 ≈ 922 亿亿,可支撑到公元 2262 年
id BIGINT UNSIGNED NOT NULL PRIMARY KEY

[!QUESTION] 为什么 Snowflake 比 UUID 更受青睐? 核心在于 B+ 树的插入局部性。Snowflake ID 大致有序,InnoDB 可以追加写入最后一页,开销极小。而 UUID v4 完全随机,每次插入都可能触发页面分裂和页迁移,在高并发写入时性能差距可达 5~10 倍。

[!TIP] 自增主键的安全隐患 连续递增的 ID 会泄露业务量级信息(竞争对手可通过 API 返回的 ID 推测日活)。若需隐藏业务数据,可在应用层用 AES 加密 ID(Pseudo Random ID),或在数据库层添加 INSERT 前 RAND() 扰序。


索引设计与反模式

索引是 DDL 中最值得投入精力、也最容易被滥用的部分。一个好的索引可以让查询从全表扫描降到 O(log N)。

基本原则

-- ✅ 最左前缀原则:联合索引 (a, b, c)
INDEX idx_abc (a, b, c)

-- 以下查询都能命中索引
WHERE a = 1                         -- ✓ (a,)
WHERE a = 1 AND b = 2               -- ✓ (a,b)
WHERE a = 1 AND b = 2 AND c = 3     -- ✓ (a,b,c)
WHERE a = 1 AND c = 3               -- ✓ (a,) + filescan 查 c

-- 以下查询只能命中部分索引
WHERE b = 2 AND c = 3               -- ✗ 只用了 (a,b,c) 中的 c,b 无法利用
WHERE a > 1 AND b = 2               -- ✓ 只有 b 能用到索引,c 不能(> 后断链)

[!TIP] 最左前缀匹配口诀 「从左往右,碰到范围就停」——一旦遇到 >、<、BETWEEN、LIKE 'abc%' 这类范围条件,后面的列就不再参与索引查找。这也是为什么把等值列放在联合索引前面的原因。

覆盖索引 & 回表

-- 假设表有 PRIMARY KEY (id), INDEX idx_status (status)

-- ❌ 需要回表:查出不在索引中的列
SELECT id, username, email FROM users WHERE status = 1;
-- → 先查 idx_status 找到 id,再按 id 回聚簇索引拿 username/email

-- ✅ 覆盖索引:所有所需列都在索引中,无需回表
SELECT id FROM users WHERE status = 1;
-- → 直接从 idx_status 取 id,零回表

[!NOTE] 覆盖索引(Covering Index)是性能优化的杀手锏。当查询只需的列全部包含在某个二级索引中时,InnoDB 可以直接从索引树提取数据,无需再访问聚簇索引,大幅减少 I/O。

常见反模式

-- ❌ 函数包裹导致索引失效
SELECT * FROM users WHERE YEAR(created_at) = 2025;
-- ✅ 改写为范围查询,利用索引
SELECT * FROM users
WHERE created_at >= '2025-01-01' AND created_at < '2026-01-01';

-- ❌ 隐式类型转换
CREATE TABLE products (sku VARCHAR(20) NOT NULL);
SELECT * FROM products WHERE sku = 12345;  -- 字符串被转成数字
-- ✅ 保持类型一致
SELECT * FROM products WHERE sku = '12345';

-- ❌ LIKE 通配符在前
SELECT * FROM users WHERE username LIKE '%admin%';
-- ✅ 改写为全文搜索或调整匹配策略
SELECT * FROM users WHERE username LIKE 'admin%';  -- 可以用索引

[!WARNING] 隐式类型转换是最隐蔽的性能杀手 当列定义为 VARCHAR 但传入的是数值(反之亦然),MySQL 会在每一行上做隐式转换,导致索引直接失效。执行 EXPLAIN 看到 type=ALL 且无 Using where 优化时,第一反应就是检查类型是否匹配。


字符集与排序规则

字符集决定了数据如何编码存储,排序规则决定了字符串比较的顺序。选错会导致乱码、排序错误甚至查询走不了索引。

排序规则 特点 适用场景
utf8mb4_general_ci 速度快但不精确,已废弃 旧系统兼容
utf8mb4_bin 精确的二进制比较,区分大小写 密码、Token 等敏感字段
utf8mb4_0900_ai_ci 8.0 默认,Unicode 9.0 准确排序 通用推荐
utf8mb4_zh_pinyin_ci 按拼音排序中文 中文名排序场景

[!TIP] utf8 不是真正的 UTF-8! MySQL 中的 utf8 只是一个缩写,最大只支持 3 字节的字符(缺少 Emoji 等 4 字节 Unicode)。始终使用 utf8mb4,哪怕你确定没有 Emoji。

[!QUESTION] 排序规则影响索引吗? 是的。如果表 A 用 utf8mb4_0900_ai_ci 而表 B 用 utf8mb4_bin,两个表做 JOIN 时 MySQL 会在运行时做临时转换,可能导致无法使用索引。关联表的字符集和排序规则应保持一致。


MySQL 8.0 新增能力

不可见索引(Invisible Indexes)

上线前验证索引有效性的利器,无需真正删除索引即可测试其影响:

-- 将索引设为不可见(优化器不再使用它)
ALTER TABLE orders ALTER INDEX idx_status INVISIBLE;

-- 验证性能影响后,确认无用再删除
DROP INDEX idx_status ON orders;

-- 恢复可见
ALTER TABLE orders ALTER INDEX idx_status VISIBLE;

[!TIP] 渐进式下线索引流程

  1. 设置 INVISIBLE → 观察慢查询和错误率 1~7 天
  2. 确认无负面影响 → DROP INDEX
  3. 此方法比直接 DROP 更安全,相当于一次「灰度发布」

表达式索引(Functional Indexes)

8.0.13+ 支持对函数结果建立索引,无需冗余列:

-- 对 JSON 字段内的嵌套属性建立索引
ALTER TABLE events ADD INDEX json_idx ((JSON_UNQUOTE(JSON_EXTRACT(data, '$.type'))));

-- 对大小写不敏感的搜索建索引
ALTER TABLE users ADD INDEX lower_email_idx ((LOWER(email)));

窗口函数与 CTE

DDL 本身不涉及这些,但它们改变了我们设计表结构的方式——有了分析函数后,某些聚合维度表可以简化:

-- 传统做法:额外建一张月粒度汇总表
CREATE TABLE sales_monthly (
    month DATE NOT NULL,
    total DECIMAL(12,2),
    PRIMARY KEY (month)
);

-- 8.0 有了窗口函数后,很多场景可以直接在原表上计算
SELECT sales_date, amount,
       SUM(amount) OVER (ORDER BY sales_date ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) AS moving_avg_7d
FROM daily_sales;

ALTER TABLE — 常见操作

增删改列

CHANGE 与 MODIFY 的区别在于:CHANGE 必须同时写出列名(可改名),而 MODIFY 只改属性不更名。这是初学最容易混淆的地方。

-- 添加列
ALTER TABLE users ADD COLUMN phone VARCHAR(20) AFTER email;
ALTER TABLE users ADD COLUMN last_login_ip VARCHAR(45); -- IPv4/IPv6 通用

-- 修改列类型(不改名)
ALTER TABLE users MODIFY COLUMN status TINYINT UNSIGNED DEFAULT 0;

-- 重命名列(改名 + 可选改类型)
ALTER TABLE users CHANGE COLUMN phone phone_number VARCHAR(20);

-- 删除列
ALTER TABLE users DROP COLUMN last_login_ip;

-- 删除索引
ALTER TABLE users DROP INDEX idx_username;

[!WARNING] ALTER TABLE DROP COLUMN 不可逆 列一旦删除,其中的数据和统计信息全部消失。生产环境执行 DDL 前务必确认已有备份。现代运维流程中应通过迁移工具(见关联笔记)做版本化管理,而非手动执行 ALTER。

在线 DDL(ALGORITHM / LOCK)

MySQL 5.6+ 支持 Online DDL,允许在结构变更期间持续处理读写请求。

-- 三种算法对比
ALTER TABLE users ADD COLUMN bio TEXT, ALGORITHM=INPLACE;
ALTER TABLE users ADD COLUMN bio TEXT, ALGORITHM=COPY;
ALTER TABLE users ADD COLUMN bio TEXT;  -- 8.0 默认 INPLACE

-- 三种锁策略对比
ALTER TABLE ... LOCK=NONE;     -- 不阻塞任何读/写(最安全)
ALTER TABLE ... LOCK=SHARED;   -- 允许并发读,阻塞写
ALTER TABLE ... LOCK=EXCLUSIVE; -- 阻塞所有其他操作(最快)

[!NOTE] INPLACE vs COPY 的本质区别

  • INPLACE:直接在原表上重建索引或新增索引,不需要全表拷贝数据。支持的场景包括:增加/删除索引、改变列默认值、新增列等。
  • COPY:创建临时表 → 逐行拷贝数据 → 原子替换。适用于改变列定义导致格式不兼容的场景,比如 VARCHAR 改 INT。

经验法则:8.0 默认的 INPLACE 对大多数操作都够用。若不确定,先跑 pt-online-schema-change --dry-run 看预估耗时。

flowchart LR
    Start["ALTER TABLE 开始"] --> Alg{"ALGORITHM"}

    Alg -->|INPLACE| Inplace["原地重建索引"]
    Alg -->|COPY| Copy["创建临时表拷贝数据"]
    Alg -->|DEFAULT(8.0)| Inplace

    Inplace --> Lock{"LOCK"}
    Copy --> Lock

    Lock|NONE| Online["在线<br/>读写不阻塞"]
    Lock|SHARED| ReadConc["并发读<br/>写入等待"]
    Lock|EXCLUSIVE| BlockAll["排他锁<br/>速度最快"]

    style Online fill:#00D866,color:#fff
    style ReadConc fill:#FF9F43,color:#000
    style BlockAll fill:#EE5A24,color:#fff

大表 DDL 实战技巧

对百万行以上的表执行 ALTER TABLE 时:

# ❌ 危险:直接在线上执行
ALTER TABLE big_table ADD INDEX idx_col (col);

# ✅ 方案一:pt-online-schema-change(Percona Toolkit)
pt-online-schema-change \
  --user=root --password=xxx \
  --alter "ADD INDEX idx_col (col)" \
  D=mydb,t=big_table \
  --execute

# ✅ 方案二:gh-ost(GitHub 开源)
gh-ost \
  --user=root --password=xxx \
  --host=127.0.0.1 --port=3306 \
  --database=mydb \
  --table=big_table \
  --alter "ADD INDEX idx_col (col)" \
  --allow-on-master \
  --execute

[!TIP] pt-osc 原理三步走

  1. 创建新表:与原表结构一致 + 目标变更
  2. 触发器同步:创建 INSERT/UPDATE/DELETE 触发器,将原表的变更实时同步到新表
  3. 原子替换:加排他锁极短时间,swap 两张表名后删除触发器和旧表

[!QUESTION] gh-ost 和 pt-osc 怎么选?

  • pt-osc 依赖触发器,对高并发写密集型表有较大开销
  • gh-ost 基于 binlog 解析,无需触发器,对线上影响更小
  • 结论:新项目优先 gh-ost;老项目已有 pt-toolkit 积累也可以用 pt-osc

生产环境 DDL Checklist

在执行任何 ALTER TABLE 之前,逐项核对这份清单。养成习惯可以避免 90% 的线上事故。

□ 1. 确认操作有对应的应用代码上线计划
   └─ DDL 与应用逻辑必须同步上线,否则可能出现字段不存在但代码已读写该字段的竞态

□ 2. 预估耗时(先在预发/从库验证)
   └─ SELECT COUNT(*) FROM big_table; → 根据行数估算 COPY 耗时
   └─ 或使用 --dry-run 模式(pt-osc / gh-ost 均支持)

□ 3. 选择 ALGORITHM=INPLACE + LOCK=NONE
   └─ 只有在无法在线完成时才考虑 LOCK=EXCLUSIVE + 低峰期执行

□ 4. 检查磁盘空间 ≥ 当前表大小 × 2
   └─ INPLACE 通常只需少量额外空间;COPY 需要整份表的临时空间

□ 5. 确认主从复制延迟可控
   └─ DDL 在主库完成前从库会一直阻塞,DDL 完成后可能瞬间追赶大量事件
   └─ 建议在监控中关注 Seconds_Behind_Master

□ 6. 评估二级索引数量上限
   └─ 每张表建议不超过 5~7 个索引(含主键)
   └─ 每个索引都会拖慢 INSERT/UPDATE/DELETE
   └─ 使用不可见索引先下线无用索引,再删除

□ 7. 准备回滚方案
   └─ 记录 ALTER 前的 CREATE TABLE 语句(SHOW CREATE TABLE)
   └─ 如果新结构有问题,可以用原语句重建表

□ 8. 更新文档和迁移脚本
   └─ 关联笔记中的 GORM model、migration 文件需要同步修改

[!WARNING] 最危险的操作顺序

  1. 先发代码改逻辑读旧列名 → 此时新列还不存在,查询报 Unknown column
  2. 再执行 ALTER TABLE ADD COLUMN → 解决第一步的问题,但已有脏数据
  3. 最后清理旧列 → 第三次发布才能安全 DROP

最佳做法:先用 ADD COLUMN + 双写(新旧并存),然后发代码切换到新字段,最后再 DROP OLD_COLUMN——三步走,每次只做一个动作。

NULL vs NOT NULL — 选型指南

这是 DDL 中最常见的争论之一。核心原则:能用 NOT NULL 就不用 NULL。

graph TD
    Q1["是否允许未知状态?"] -->|否| NN["NOT NULL + DEFAULT"]
    Q1 -->|是| Biz{"业务语义?"}

    Biz -->|逻辑删除/软删| SoftDel["TINYINT DEFAULT 0<br/>0=正常 1=已删除"]
    Biz -->| truly optional | AllowNull["允许 NULL<br/>但加注释说明含义"]

    style NN fill:#00D866,color:#fff
    style SoftDel fill:#FF9F43,color:#000
    style AllowNull fill:#EE5A24,color:#fff
维度 NOT NULL + DEFAULT 允许 NULL
索引效率 InnoDB 二级索引不存储纯 NULL 值,用默认值占位可减少索引碎片 包含 NULL 标记的索引需要额外 1 bit
查询安全 WHERE col = ? 不会遗漏任何行 WHERE col = 'value' 排除了 NULL 行(需用 IS NULL)
聚合函数 SUM/COUNT 直接可用 COUNT(col) 忽略 NULL 行,容易误判总行数
可读性 status = 0 一目了然 status IS NULL 语义模糊——到底是"还没填"还是"被清除了"?

[!WARNING] NULL 的三个常见误区

  1. "NULL 占空间更小":InnoDB 中 NULL 也需要在行格式中标记,固定为每 8 个字节 1 bit 的 null bitmap。比存一个空字符串或零值并不节省什么。
  2. "NULL = NULL":在 SQL 三值逻辑中,NULL = NULL 结果为 UNKNOWN,永远不等于 TRUE。判断必须用 IS NULL / IS NOT NULL。
  3. "外键可以为 NULL":技术上可以,但会导致孤儿记录难以追踪。建议用显式的 deleted_at 时间戳代替外键 NULL 做软删除。

常用 DDL 查询

这些 SQL 命令帮助你查看当前数据库的「结构全貌」,在排查索引失效、表空间膨胀时尤其有用。

-- 查看表完整创建语句(含所有索引、注释、引擎配置)
SHOW CREATE TABLE users\G

-- 查看所有索引(含索引类型、列顺序、唯一性)
SHOW INDEX FROM users\G

-- 查看表统计信息(Rows 为估算值,非精确计数)
SHOW TABLE STATUS LIKE 'users'\G
-- 重点关注 Rows(估算行数)、Data_length、Index_length

-- 查看分区情况
SELECT PARTITION_NAME, PARTITION_EXPRESSION, PARTITION_DESCRIPTION
FROM INFORMATION_SCHEMA.PARTITIONS WHERE TABLE_NAME = 'logs';

[!TIP] 快速定位慢查询相关的结构问题

-- 检查表是否存在大量碎片的索引(数据删除后未回收的空间)
SELECT TABLE_NAME, INDEX_NAME, CARDINALITY
FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'users'
ORDER BY CARDINALITY ASC;

-- 低基数(CARDINALITY 接近 0)的索引通常效果不佳,考虑移除

关联笔记