--- tags: [MySQL, DDL, ALTER TABLE, CREATE TABLE, DATATYPE, NULL, ONLINE DDL] create time: 2026-05-16 00:00 --- # DDL — 建表与结构变更 ## 概述 DDL(Data Definition Language)用于定义和修改数据库对象的结构。本章聚焦最常用的 `CREATE TABLE`、`ALTER TABLE`,以及 MySQL 8.0 引入的在线 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{"需要 UNSIGNED?"} B -->|"否"| C["INT — 覆盖 -21 万 ~ 21 万"] B -->|"是"| D["BIGINT UNSIGNED — 最大 1844 亿"] A -->|"否"| E["字符串?"] E -->|"变长 < 255"| F["VARCHAR(N)"] E -->|"定长 (密码/签名)"| G["CHAR(N)"] E -->|"超长 (文章/JSON)"| H["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 内部以二进制紧凑存储,性能接近整数类型。 ## 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 A["ALTER TABLE 开始"] --> B{"ALGORITHM"} B -->|"INPLACE"| C["直接修改数据字典
原地更新索引"] B -->|"COPY"| D["创建临时表拷贝数据后替换"] C --> E{"LOCK"} D --> E E -->|"NONE"| F["在线执行
读写不阻塞"] E -->|"SHARED"| G["并发读
写入等待"] E -->|"EXCLUSIVE"| H["阻塞全部操作
速度最快"] style F fill:#00D866,color:#fff style G fill:#FF9F43,color:#000 style H 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 ### NULL vs NOT NULL — 选型指南 这是 DDL 中最常见的争论之一。核心原则:**能用 NOT NULL 就不用 NULL**。 ```mermaid graph TD A["是否允许未知状态" -->|"否"| B["NOT NULL + DEFAULT"] A -->|"是"| C{"业务语义?"} C -->|"逻辑删除/软删"| D["TINYINT DEFAULT 0
0=正常 1=已删除"] C -->|" truly optional "| E["允许 NULL
但加注释说明含义"] ``` | 维度 | 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 迁移管理最佳实践