SQL DDL详解:从建表设计到线上避坑,数据库基本功必修 📅 发布时间:2026/9/16 3:06:40 👁 浏览次数: 说实话做了这么多年数据库相关工作我越来越觉得 SQL 数据定义DDL才是真正考验基本功的部分。很多人写 SQL 的时候注意力都放在 DML 上——SELECT、JOIN、窗口函数玩得飞起但一聊到“你负责的业务表当初是怎么设计的为什么主键用 BIGINT、为什么那列允许为 NULL、索引到底该建在哪几个字段上”往往就支支吾吾说不出个所以然。DDL 看起来简单无非就是 CREATE、ALTER、DROP 那一组命令可真到了线上环境表结构一旦定下来后面几年所有的接口、报表、数据治理全都要在这套结构上跑。结构错了后面再怎么优化 SQL 都是治标不治本。所以我一直觉得DDL 才是拉开普通开发和老手差距的分水岭。这篇内容我不想写成官方文档式的命令罗列而是从一个真实做项目的角度把数据定义语言完整拆开来讲DDL 和 DML 到底有什么区别建表之前必须想清楚哪些事一张订单系统的表是怎么一步步设计出来的线上改表会遇到哪些坑。无论你是刚入行的新人还是工作几年想系统梳理一遍基础的同学这篇都值得认真过一遍。1. DDL 到底是什么先把这个概念掰开揉碎1.1 DDL 和 DML 的区别一个管结构一个管数据先搞清楚最基础的概念。SQL 语言按功能分几大类其中 DDLData Definition Language数据定义语言负责操作数据库对象的结构DMLData Manipulation Language数据操纵语言负责操作对象里的数据。很多新手会把这两者混在一起但它们的本质完全不同。我用一个抽屉来类比。DML 是往抽屉里放东西、取东西、清点东西抽屉本身是什么样它不管DDL 是决定抽屉做几个、隔板怎么分、要不要换个大柜子、或者整个扔掉。放到数据库里DDL 的典型动词包括 CREATE、ALTER、DROP、TRUNCATE、RENAME、COMMENTDML 的典型动词则是 SELECT、INSERT、UPDATE、DELETE。往订单表里插入一条订单是 DML给订单表增加一个“支付渠道”字段是 DDL。操作类型典型命令作用对象例子DDLCREATE、ALTER、DROP、TRUNCATE、RENAME表、库、索引、视图等对象的结构给 orders 表添加一列DMLSELECT、INSERT、UPDATE、DELETE表里的数据行往 orders 表插入一条订单DCLGRANT、REVOKE用户权限给某个账号授予只读权限TCLCOMMIT、ROLLBACK、SAVEPOINT事务回滚一组 DML 操作还有一个特别容易踩的坑DDL 语句在很多数据库里具备“隐式提交”的特性。以 MySQL 为例你在一个事务里执行了 INSERT还没提交这时如果执行一条 ALTER TABLEMySQL 会隐式地先提交当前事务再做结构变更后面的 ROLLBACK 根本救不回来。所以 DDL 不能简单放在事务里“试一下不行就回滚”一旦执行影响就是持久的。1.2 不同数据库的 DDL 语法差异很大别搬错千万别以为“会了 MySQL 的 DDL 就等于会了所有数据库”真实项目中做跨库迁移、多数据库支持时DDL 语法差异是最先爆雷的地方。我见过不少团队把 MySQL 的建表脚本改几个关键字就扔到 SQL Server 上跑结果各种报错最后不得不花大量时间重写。拿最基础的自增主键来说三种主流数据库的写法都不一样MySQLid BIGINT AUTO_INCREMENT PRIMARY KEYSQL Serverid BIGINT IDENTITY(1,1) PRIMARY KEYPostgreSQLid BIGINT GENERATED AS IDENTITY PRIMARY KEY老版本也可以用SERIAL修改列类型的差异就更大了。MySQL 用ALTER TABLE ... MODIFY COLUMNSQL Server 用ALTER TABLE ... ALTER COLUMNPostgreSQL 用ALTER TABLE ... ALTER COLUMN ... TYPEOracle 则是MODIFY子句配合ALTER TABLE。字符串类型也是重灾区SQL Server 里处理 Unicode 要用NVARCHARMySQL 则习惯统一用VARCHAR配合utf8mb4字符集。跨库迁移时第一件事就是先跑一遍原有 DDL 脚本所有不兼容的地方基本都会在这一步暴露出来这时候发现问题成本是最低的。1.3 为什么要重视 DDL结构错了后面全错DDL 之所以重要因为它是最难“返工”的环节。代码写错了改一行重新发布就行数据写错了UPDATE 一下也能救回来但表结构错了比如字段类型选小了、唯一约束没加、关联字段字符集不一致后期要付出的代价是几何级增长的。结构一旦定下来所有业务代码、数据同步任务、BI 报表、埋点统计都会在这套结构上运行。等到表里积累了千万级数据再想改字段类型可能要锁表几十分钟甚至几小时业务直接停摆。我的经验是任何一张业务表在建表时多花十分钟思考后期就能少加十个班。尤其是字段类型这种“小事”选错了真的会让人痛不欲生。举个最典型的例子订单金额字段如果设计时图省事用了FLOAT等数据量上来做聚合运算时会发现金额对不上账因为浮点数在二进制中无法精确表示 0.1。到那时候再想改成DECIMAL(10,2)要跨过多少历史数据、业务代码和报表口径想想都头大。这就是为什么我一直强调DDL 不光是“会写命令”就行了你得知道每个决定背后的取舍。2. 建表之前必须想清楚的三件事2.1 数据类型不是随便选的选错类型的代价建表时最基础也最关键的一步就是给每个字段选择合适的数据类型。很多新手习惯“能用 VARCHAR 就用 VARCHAR长度统一 255”这种做法短期内看不出问题但数据量增长后存储膨胀、索引效率下降、查询变慢会一起找上门。整数类型的选择核心是看取值范围和存储空间。TINYINT占用 1 字节SMALLINT占 2 字节INT占 4 字节BIGINT占 8 字节。业务表主键和关联外键我建议直接上BIGINT UNSIGNED尤其是订单、用户这类可能上亿行的表。用 INT 的话一旦超过 21 亿的上限无符号是 42 亿迁移和扩容的痛苦远超你省下的那点存储。状态码、类型标识这类字段用TINYINT就够但别用BIT很多 ORM 框架对 BIT 的映射处理得并不好容易出幺蛾子。小数类型是重灾区。记住一条铁律金额、价格、费率等所有和钱有关的字段必须用DECIMAL绝对不要用FLOAT或DOUBLE。DECIMAL(10,2)表示总共 10 位有效数字、2 位小数可以安全存储 8 位整数加 2 位小数的金额。FLOAT是浮点数存储时存在精度损失0.1 加 0.2 在浮点数运算里都不等于 0.3这在财务系统里是不可接受的。字符串类型需要注意的点更多。CHAR是定长字符串适合手机号、身份证号这类长度固定的字段因为定长存储没有碎片。VARCHAR(n)是变长字符串n 是字符数而不是字节数在utf8mb4字符集下一个汉字或 emoji 可能占 3 到 4 个字节所以一个VARCHAR(255)的字段实际可能占用接近 1000 字节这在建联合索引时很容易触发索引长度上限。TEXT类型能不用尽量不用它不能有默认值索引只能加前缀索引而且查询时可能产生临时表排序性能很差。如果实在要存大文本考虑拆到独立的扩展表。日期时间类型也有讲究。MySQL 里DATETIME范围能到 9999 年TIMESTAMP只能到 2038 年这就是传说中的“2038 年问题”。还有些团队习惯用BIGINT存毫秒时间戳这种方案在分布式系统里很常见因为不同数据库的时间函数行为不一致统一用数字存储便于跨库迁移。我的默认建议是新表业务时间用DATETIME配合默认值CURRENT_TIMESTAMP足够满足绝大多数场景。2.2 约束到底要不要加别信“先上线再补”约束是数据库保证数据完整性的第一道防线但“先不加约束等上线稳定了再说”这种论调我在很多团队里都听过。结果就是数据在入口处没有被拦截各种脏数据、空值、重复数据全进了库后期再想补约束得先处理历史数据工作量翻好几倍。主键约束没什么好说的每张表必须有主键最好使用单列自增或有序 ID别用业务字段做主键。业务字段做主键的坑在于业务规则是会变的比如用身份证号做客户表主键一旦业务扩展到海外用户身份证号不再唯一就尴尬了。NOT NULL约束能加就加。空值看起来没什么但在数据库层面NULL 会让索引统计变复杂WHERE name IS NULL这种查询无法走常规索引而且业务代码里到处要写判空逻辑。我的习惯是字段确实没有值的话设置一个明确的默认值例如数值类型默认 0字符串默认空字符串时间默认当前时间这样查询和统计都清爽很多。UNIQUE约束是业务唯一性的兜底。订单号、手机号这类有唯一性要求的字段必须加唯一索引或唯一约束这是防止数据重复的最后一道闸门。不过有一个前提加唯一索引之前一定要清洗历史数据否则执行 CREATE UNIQUE INDEX 时会直接报Duplicate entry这一点我在后面踩坑部分会详细讲。外键约束是争议最大的。互联网高并发场景下很多团队刻意不用外键因为外键会在每次写入时增加外键检查的开销而且做了分库分表之后外键本来就形同虚设数据一致性交给应用层去保证。但在金融、ERP 这类强一致要求系统中数据库外键反而是安全底线。我的建议是如果团队有信心在代码层保证关联数据一致外键可以不建但关联字段的索引必须建如果没这个信心宁可用外键也别裸奔。2.3 字符集与排序规则乱码和大小写之谜的根源字符集和排序规则是 DDL 里最容易被人忽视、但坑最多的部分。字符集决定了文本在数据库中如何编码存储排序规则决定了字符串比较和排序时的行为。两者如果不统一轻则乱码重则索引失效。MySQL 最大的坑是utf8和utf8mb4的区别。很多人以为utf8就是支持所有 Unicode但 MySQL 的utf8实际上只是utf8mb3只支持基本多语言平面遇到 emoji 和生僻字就会变成问号或乱码。正确的做法是创建数据库时统一指定utf8mb4CREATE DATABASE shop DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;排序规则里_ai表示 accent insensitive音调不敏感_ci表示 case insensitive大小写不敏感。所以在utf8mb4_general_ci或utf8mb4_0900_ai_ci下WHERE name abc能匹配到ABC。如果业务要求严格区分大小写就得用utf8mb4_bin或者在查询时加BINARY。很多人遇到“明明数据一样却查不出来”“查询结果大小写混在一起”的问题根源往往就是排序规则没搞明白。SQL Server 里也有类似的坑。VARCHAR依赖数据库代码页存储容易在不同环境间出现乱码NVARCHAR是 Unicode 存储对中文更友好。排序规则如Chinese_PRC_CI_AS同样不区分大小写。更隐蔽的问题是两张字符集或排序规则不一致的表做关联查询时索引会失效慢 SQL 排查半天都找不到原因。所以建库建表时字符集和排序规则必须从上到下统一别让不同表各用各的。3. DDL 核心操作全解从零建出一套订单系统3.1 CREATE从零建出一套订单系统的表结构理论说了一大堆下面我用一个贯穿始终的实际案例把 DDL 的核心操作串起来。假设我们要建一个最简电商系统的三张核心表客户表、订单表、订单明细表。先看建库语句-- 创建数据库 CREATE DATABASE shop DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;然后是客户表CREATE TABLE customers ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 客户ID, customer_no VARCHAR(32) NOT NULL COMMENT 客户编号业务唯一键, name VARCHAR(50) NOT NULL COMMENT 客户姓名, phone VARCHAR(20) DEFAULT NULL COMMENT 手机号, email VARCHAR(100) DEFAULT NULL COMMENT 邮箱, status TINYINT 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 COMMENT 更新时间, PRIMARY KEY (id), UNIQUE KEY uk_customer_no (customer_no), KEY idx_phone (phone) ) ENGINEInnoDB COMMENT客户表;接下来是订单表CREATE TABLE orders ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 订单ID, order_no VARCHAR(40) NOT NULL COMMENT 订单号, customer_id BIGINT UNSIGNED NOT NULL COMMENT 客户ID, total_amount DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT 订单总金额, pay_type TINYINT NOT NULL DEFAULT 0 COMMENT 支付方式0-未支付1-微信2-支付宝, status TINYINT NOT NULL DEFAULT 0 COMMENT 订单状态0-待支付1-已支付2-已发货3-已完成4-已取消, paid_at DATETIME DEFAULT NULL COMMENT 支付时间, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 更新时间, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no), KEY idx_customer_id (customer_id), KEY idx_status (status), CONSTRAINT fk_orders_customer FOREIGN KEY (customer_id) REFERENCES customers(id) ) ENGINEInnoDB COMMENT订单表;最后是订单明细表CREATE TABLE order_items ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 明细ID, order_id BIGINT UNSIGNED NOT NULL COMMENT 订单ID, product_name VARCHAR(100) NOT NULL COMMENT 商品名称, price DECIMAL(10,2) NOT NULL COMMENT 成交单价, quantity INT NOT NULL DEFAULT 1 COMMENT 数量, subtotal DECIMAL(10,2) NOT NULL COMMENT 小计金额, PRIMARY KEY (id), KEY idx_order_id (order_id), CONSTRAINT fk_items_order FOREIGN KEY (order_id) REFERENCES orders(id) ) ENGINEInnoDB COMMENT订单明细表;这三张表的每个设计点都是有讲究的。主键统一用BIGINT UNSIGNED AUTO_INCREMENT是因为自增主键在 InnoDB 的 B 树索引里写入时是顺序追加的不会频繁触发页分裂。customer_no和order_no是业务编号它们才是业务层面真正唯一的字段所以单独建了唯一索引和主键区分开。金额字段全部用DECIMAL(10,2)因为钱不能有精度损失。状态、类型字段用TINYINT配合注释说明每个值的含义而不是用VARCHAR存中文描述后者既浪费空间又容易写错。时间字段统一DATETIMEupdated_at用ON UPDATE CURRENT_TIMESTAMP让数据库自动维护更新时间省去应用层手动赋值。外键我在这里建上了因为订单系统的核心链路对数据一致性要求极高。但在高并发互联网场景很多人会去掉外键这就是前面说的取舍。另外每个字段都必须写 COMMENT这一点太重要了。三个月之后你看着一段没有注释的表结构根本想不起来那个status字段里的 1、2、3 到底是什么含义。3.2 ALTER TABLE改表结构是日常最频繁的 DDL项目上线后存量表的结构调整是躲不掉的。业务加了一个促销活动订单表要加“优惠金额”字段支付方式扩容要调整字段注释和类型。这就要用到 ALTER TABLE。-- 添加字段 ALTER TABLE orders ADD COLUMN discount_amount DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT 优惠金额 AFTER total_amount; -- 修改字段类型 ALTER TABLE orders MODIFY COLUMN discount_amount DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT 优惠金额; -- 修改字段名称MySQL 8.0 推荐写法 ALTER TABLE orders RENAME COLUMN discount_amount TO coupon_amount; -- 删除字段 ALTER TABLE orders DROP COLUMN coupon_amount; -- 添加索引 ALTER TABLE orders ADD INDEX idx_paid_at (paid_at); -- 添加唯一约束 ALTER TABLE orders ADD UNIQUE KEY uk_order_no (order_no); -- 删除索引 ALTER TABLE orders DROP INDEX idx_paid_at; -- 修改表名 ALTER TABLE orders RENAME TO sale_orders;SQL Server 的语法完全不同写法上要特别小心-- SQL Server 修改列类型 ALTER TABLE dbo.orders ALTER COLUMN total_amount DECIMAL(12,2) NOT NULL; -- SQL Server 添加列带默认值约束 ALTER TABLE dbo.orders ADD discount_amount DECIMAL(12,2) NOT NULL CONSTRAINT DF_orders_discount DEFAULT 0.00;在 SQL Server 里给已存在大量数据的表添加 NOT NULL 列如果不指定默认值数据库会填充默认值并重写整张表大表场景下非常危险。而 MySQL 8.0.12 之后ADD COLUMN支持INSTANT算法可以秒级完成这就是数据库选型带来的巨大差异。大表在线改表是另一个重点。MySQL 5.7 及之前大部分ALTER TABLE操作需要拷贝整张表期间加锁写操作全部阻塞。MySQL 8.0 的INSTANT算法目前能覆盖加列、删除列等少数操作但像修改字段类型、加索引这类操作依然要谨慎。对于真正的生产大表我的建议是使用pt-online-schema-change或gh-ost这类工具在后台建影子表、同步数据、锁定瞬间切换表名尽量做到业务无感。这个我在后面实战排查部分会展开。3.3 DROP 与 TRUNCATE删库操作别手滑DROP 和 TRUNCATE 都是破坏性操作但它们有本质区别。DROP 是删除整个数据库对象比如表本身TRUNCATE 是清空表里的所有数据但保留表结构。很多新手分不清楚 DELETE、TRUNCATE、DROP 三者的差别在错误场景用了错误命令后果不堪设想。操作删除内容是否保留表结构可加WHERE是否重置自增MySQL事务内能否回滚DELETE行数据保留可以否可以TRUNCATE全部行数据保留不可以是隐式提交不可回滚DROP表本身不保留不可以-隐式提交不可回滚TRUNCATE TABLE 会重置自增 ID这在测试环境非常方便但在生产环境是巨大的隐患。更麻烦的是MySQL 里 TRUNCATE 会触发隐式提交即使放在事务里也无法回滚千万不要有“先执行一下反正事务内能回滚”的侥幸心理。生产环境的删表规范我强烈建议执行“软删除”策略不是直接 DROP而是先把表重命名为带删除标记的名字观察几天确认没有业务报错再物理删除。-- 第一步重命名业务不可见但数据还在 RENAME TABLE orders TO orders_drop_20250115; -- 第二步观察两三天确认无误后再物理删除 DROP TABLE orders_drop_20250115;注意如果这张表和其他表有外键关联重命名操作可能会失败MySQL 的RENAME TABLE会同时更新相关外键定义有些情况下需要先删除外键再重命名。这种操作不在低峰期做我是不放心的。3.4 索引与视图用 DDL 把“读性能”和“可读性”物化出来索引是 DDL 里对性能影响最直接的部分。CREATE INDEX 可以在建表之后随时添加-- 创建联合索引 CREATE INDEX idx_customer_created ON orders(customer_id, created_at); -- 创建唯一索引 CREATE UNIQUE INDEX uk_order_no ON orders(order_no);联合索引要理解最左前缀原则。索引(customer_id, created_at)建立后查询条件里如果包含customer_id那么customer_id created_at可以走索引但如果你只按created_at查询是没法利用这个联合索引的。所以建联合索引时字段顺序要按照查询频率来排最常用、区分度最高的字段放最左边。索引不能盲目加因为每次 INSERT、UPDATE 时每多一个索引就多一次索引维护的开销。我见过有人给一张表加了十几个索引结果写入性能惨不忍睹。索引的选择标准是“为真实查询服务”不是“看着顺眼就加”。CREATE VIEW 则是另一类 DDL它创建一个虚拟表不占用实际存储但能把复杂的关联查询固化成逻辑视图CREATE VIEW v_order_summary AS SELECT o.id, o.order_no, c.name AS customer_name, o.total_amount, o.status, o.created_at FROM orders o LEFT JOIN customers c ON o.customer_id c.id;视图的价值主要有三个一是隐藏敏感字段比如给报表账号只开放视图而非底层表避免泄露客户手机号二是统一口径把复杂的 join 和多层子查询固化成一个视图业务方查询时不需要理解底层表关系三是简化权限管理只给用户授予视图权限不授予原始表权限。但要注意MySQL 里的普通视图并不是“提前算好结果存起来”它本质上就是一条命名的 SQL每次查询时都会执行底层语句不会自动提速。真正的“物化视图”像 Oracle、PostgreSQL 里的具体化视图在 MySQL 原生并不支持别指望建了视图查询就变快。4. 实战复盘从设计原则到线上避坑4.1 三范式与反范式理论要不要听数据库设计三范式大学课本里都讲过第一范式要求字段不可再分第二范式要求非主键字段完全依赖主键第三范式要求非主键字段之间不能有传递依赖。理论听起来抽象放到实际场景里并不难理解。举一个违反第三范式的反例订单明细表里存了product_name如果还顺便存了product_category、product_desc这些字段其实都依赖商品而不是订单那就会出现更新异常——商家改了商品分类历史订单里的分类字段还是旧的而且你根本不知道要改哪几行。这时就应该拆出商品表订单明细表只存product_id。但现实中很多团队会故意反规范化。电商订单明细表里存product_name就是一个典型的反范式场景商品可能被商家改名或删除但历史订单需要永远保留下单那一刻的商品名称快照。这种冗余是有明确业务理由的程序在写入时负责把快照填好后续商品表怎么变都不影响历史订单。所以我说范式和反范式不是对错问题是场景问题。核心原则是冗余字段必须是“只读快照”由程序在写入时维护业务上不能把它当作可更新的数据源。只要想清楚这一点该冗余就冗余该拆表就拆表别被教条框死。4.2 给 DDL 加上“版本管理”意识迁移脚本和项目规范很多人建表都是打开数据库客户端手敲一句 CREATE TABLE 或者用图形化工具点几下表就建好了。这在个人项目没问题但在团队协作里就是灾难——谁改了表结构、为什么改、何时改的完全没有记录测试环境和生产环境的结构很容易漂移出现“本地跑得好好的测试环境一跑就报错”的经典问题。解决办法是把所有 DDL 变更纳入版本管理用迁移脚本的形式管理。Java 生态用的比较多的是 Flyway 或 Liquibase文件命名通常是这样src/main/resources/db/migration/V1__create_tables.sql src/main/resources/db/migration/V2__add_discount_amount.sql src/main/resources/db/migration/V3__add_index_orders_paid_at.sql启动应用时Flyway 会自动对比数据库里已执行的迁移记录把新增的脚本按顺序执行一遍。好处是显而易见的环境可复现测试、预发、生产不会出现“凭记忆建表”的情况团队协作有迹可循代码评审可以审 DDL 变更线上出问题还能对照变更记录定位。我个人的建表规范仅供参考表名、字段名全小写下划线命名不使用驼峰每个字段必须写 COMMENT每张表必须有主键、created_at、updated_at变更脚本不允许去修改已经提交的历史文件而是要新增一个迁移脚本。这套规范看起来简单但真的能规避掉很多后续的坑尤其是字段注释别以为当时记得住三个月后回来改需求的时候你大概率已经忘了这列是干嘛的。4.3 线上表结构变更的常见问题排查线上执行 DDL 最容易出问题的场景就是大表。我见过一次真实的故障某平台在业务高峰期给核心订单表加字段直接用旧版本 MySQL 的 ALTER TABLE ADD COLUMN触发了全表 COPY磁盘 IO 打满订单写入接口大面积超时最终只能回滚操作影响持续了半个多小时。大表结构变更的正确姿势是使用在线变更工具。gh-ost是 GitHub 开源的方案原理是在后台创建一张影子表先把原表的历史数据导入影子表然后持续监听原表的 binlog把增量变更实时同步到影子表最后在极短的时间内通过 RENAME 完成切换。整个过程大部分时间不锁表业务几乎无感。pt-online-schema-change是 Percona Toolkit 里的老牌工具原理类似但需要借助触发器实现增量同步。如果你用的是 SQL Server可以在 ALTER TABLE 时加WITH (ONLINE ON)但不是所有操作都支持在线执行改字段类型这种还是得小心。这里把我遇到过的线上 DDL 问题整理成一个速查表方便排查现象可能原因解决方案大表 ALTER 卡死连接数暴涨默认 COPY 算法锁表时间长低峰期执行使用 gh-ost/pt-oscMySQL 8 用 INSTANT 算法加唯一索引报 Duplicate entry历史数据存在重复值先清洗或合并重复数据再加唯一索引添加外键失败已有数据违反外键约束先修复关联数据再添加外键修改字段类型导致数据截断新类型范围小于旧数据先查询 MAX 值和数据分布评估风险join 查询不走索引关联字段字符集或排序规则不一致统一字符集和排序规则另一个容易出问题的地方是长事务阻塞 DDL。如果线上有一条长时间未提交的事务MySQL 里对同一张表的 DDL 会被卡在Waiting for table metadata lock看起来像卡死。排查方法是查information_schema.innodb_trx和performance_schema里的锁等待找到阻塞源之后决定 kill 掉长事务还是等它结束。4.4 五个我实际踩过的坑最后分享五个我亲历过的 DDL 相关事故每个都是真金白银换来的教训。第一个坑主键用了 UUID。早年为了一张需要跨系统同步的表主键用了VARCHAR(36)存 UUID 字符串看起来“分布式友好”但数据量到千万级之后随机写入导致 InnoDB 的 B 树频繁页分裂插入性能严重下降索引空间膨胀得厉害。后来改成 BIGINT 自增迁移过程极其痛苦。现在我的原则是主键一律自增 ID跨系统关联用业务唯一键去解耦。第二个坑不加 NOT NULL。某张业务表的“创建来源”字段设计成可空结果业务代码里到处要判断空值埋点数据经常漏维度报表统计时一堆来源为空的数据分析根本没法做。后来花了两周时间清洗历史数据、改代码、再造数才补上这个约束。教训就是能用 NOT NULL 的字段加个默认值绝对不要懒。第三个坑删除字段之前没查代码。有一次我判断“推广渠道”字段已经没用了直接在核心表上ALTER TABLE DROP COLUMN把字段删了。结果第二天凌晨的报表定时任务报错排查半天发现老代码里还在引用这一列。从那以后我的规矩是删字段之前必须全局搜索代码和 SQL 脚本而且要分两步走——先重命名停用观察一周确认无报错再物理删除。第四个坑加唯一索引前不洗数据。给订单表加order_no唯一索引执行后直接报Duplicate entry一开始还以为是数据库抽风查出来才发现是早期接口 Bug 生成了几条重复订单号。所以记住加唯一索引前一定要先跑这条 SQLSELECT order_no, COUNT(*) FROM orders GROUP BY order_no HAVING COUNT(*) 1;把重复数据清理干净了再加索引否则 DDL 根本执行不过去。第五个坑字符集不一致导致 join 索引失效。某次做表拆分新表用了utf8mb4_bin老表是utf8mb4_general_ci两表关联查询的扫描行数异常高查了好几天才发现是排序规则不一致导致索引失效。统一字符集排序规则之后性能立刻就恢复了。这种问题用 EXPLAIN 看执行计划时会发现 key 为 NULL 或 rows 异常大但很多时候大家不会第一时间想到字符集这算是一个非常隐蔽的性能杀手。走到这里DDL 的常用内容基本都过了一遍。我现在的习惯是任何一张业务表在动手建之前先花点时间把业务实体、字段类型、约束、索引、未来可能的增长量在脑子里过一遍落成草稿再转成 SQL。这十几分钟的思考真的能省下后面不知道多少个加班的夜晚。你在实际项目中如果也遇到过类似的坑或者有自己的一套建表规矩欢迎多交流毕竟表结构设计这种事真的是踩过坑才记得住。