478 lines
20 KiB
Markdown
478 lines
20 KiB
Markdown
|
|
---
|
|||
|
|
tags: [MySQL, DDL, ALTER TABLE, CREATE TABLE, DATATYPE, NULL, ONLINE DDL, INDEX, CHARACTER SET, SNOWFLAKE, PRIMARY KEY]
|
|||
|
|
create time: 2026-05-16 00:00
|
|||
|
|
update time: 2026-05-20 12:00
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
# DDL — 建表与结构变更
|
|||
|
|
|
|||
|
|
## 概述
|
|||
|
|
|
|||
|
|
DDL(Data Definition Language)用于定义和修改数据库对象的结构。本章从最基础的 `CREATE TABLE` 语法出发,覆盖数据类型选型、主键策略、索引设计原则与常见反模式,然后深入 `ALTER TABLE` 操作与在线 DDL 机制,最后给出生产环境的完整 DDL 检查清单。
|
|||
|
|
|
|||
|
|
## CREATE TABLE
|
|||
|
|
|
|||
|
|
```sql
|
|||
|
|
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)` 反而更高效——因为固定长度无需额外存储长度前缀。
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 数据类型选择指南
|
|||
|
|
|
|||
|
|
建表时数据类型直接决定磁盘占用和查询性能。以下是常见场景的经验推荐:
|
|||
|
|
|
|||
|
|
```mermaid
|
|||
|
|
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` 存储财务数据 |
|
|||
|
|
|
|||
|
|
#### 一个常见的反例
|
|||
|
|
|
|||
|
|
```sql
|
|||
|
|
-- ❌ 错误:用 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 字节 | ✅ 时间有序 | ✅ 不可预测 | **推荐**:分布式 + 有序 + 紧凑 |
|
|||
|
|
|
|||
|
|
```sql
|
|||
|
|
-- ❌ 不推荐:直接用 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](https://github.com/yookoala/pagination-adapter#prandomid)),或在数据库层添加 `INSERT` 前 `RAND()` 扰序。
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 索引设计与反模式
|
|||
|
|
|
|||
|
|
索引是 DDL 中最值得投入精力、也最容易被滥用的部分。一个好的索引可以让查询从全表扫描降到 O(log N)。
|
|||
|
|
|
|||
|
|
### 基本原则
|
|||
|
|
|
|||
|
|
```sql
|
|||
|
|
-- ✅ 最左前缀原则:联合索引 (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%'` 这类范围条件,后面的列就不再参与索引查找。这也是为什么把等值列放在联合索引前面的原因。
|
|||
|
|
|
|||
|
|
### 覆盖索引 & 回表
|
|||
|
|
|
|||
|
|
```sql
|
|||
|
|
-- 假设表有 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。
|
|||
|
|
|
|||
|
|
### 常见反模式
|
|||
|
|
|
|||
|
|
```sql
|
|||
|
|
-- ❌ 函数包裹导致索引失效
|
|||
|
|
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)
|
|||
|
|
|
|||
|
|
上线前验证索引有效性的利器,无需真正删除索引即可测试其影响:
|
|||
|
|
|
|||
|
|
```sql
|
|||
|
|
-- 将索引设为不可见(优化器不再使用它)
|
|||
|
|
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+ 支持对函数结果建立索引,无需冗余列:
|
|||
|
|
|
|||
|
|
```sql
|
|||
|
|
-- 对 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 本身不涉及这些,但它们改变了我们设计表结构的方式——有了分析函数后,某些聚合维度表可以简化:
|
|||
|
|
|
|||
|
|
```sql
|
|||
|
|
-- 传统做法:额外建一张月粒度汇总表
|
|||
|
|
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 只改属性不更名。这是初学最容易混淆的地方。
|
|||
|
|
|
|||
|
|
```sql
|
|||
|
|
-- 添加列
|
|||
|
|
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,允许在结构变更期间持续处理读写请求。
|
|||
|
|
|
|||
|
|
```sql
|
|||
|
|
-- 三种算法对比
|
|||
|
|
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` 看预估耗时。
|
|||
|
|
|
|||
|
|
```mermaid
|
|||
|
|
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` 时:
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
# ❌ 危险:直接在线上执行
|
|||
|
|
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% 的线上事故。
|
|||
|
|
|
|||
|
|
```text
|
|||
|
|
□ 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**。
|
|||
|
|
|
|||
|
|
```mermaid
|
|||
|
|
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 命令帮助你查看当前数据库的「结构全貌」,在排查索引失效、表空间膨胀时尤其有用。
|
|||
|
|
|
|||
|
|
```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] 快速定位慢查询相关的结构问题
|
|||
|
|
> ```sql
|
|||
|
|
> -- 检查表是否存在大量碎片的索引(数据删除后未回收的空间)
|
|||
|
|
> SELECT TABLE_NAME, INDEX_NAME, CARDINALITY
|
|||
|
|
> FROM INFORMATION_SCHEMA.STATISTICS
|
|||
|
|
> WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'users'
|
|||
|
|
> ORDER BY CARDINALITY ASC;
|
|||
|
|
>
|
|||
|
|
> -- 低基数(CARDINALITY 接近 0)的索引通常效果不佳,考虑移除
|
|||
|
|
> ```
|
|||
|
|
|
|||
|
|
## 关联笔记
|
|||
|
|
|
|||
|
|
- [[hhs/GORM/02-模型定义]] — GORM AutoMigrate 等价于 DDL 的自动化版本
|
|||
|
|
- [[hhs/GORM/17-迁移工具]] — Schema 迁移管理最佳实践
|