如果你问我 SQL 里最基础但最容易翻车的是哪条命令,我会选 CREATE TABLE。刚入行那会儿我也觉得它没什么可学的,不就是写个表名、列几个字段吗?后来在生产环境里把订单金额字段设成了 FLOAT,上线第二天对账就差了 0.01,我才真正意识到:CREATE TABLE 不是一段普通的建表语句,它是在给整个系统的数据地基画图纸。图纸歪了,后面所有查询、索引、报表、数据迁移都得跟着买单。
这篇文章我会结合实际案例,把 CREATE TABLE 从语法、字段类型选型、约束设计、索引规划到日常最容易踩的坑,完整地讲一遍。适合刚接触 SQL 的初学者,也适合写过不少 SQL 但没有认真梳理过建表细节的开发者和数据分析师。哪怕你用的是 MySQL、SQL Server 还是 PostgreSQL,核心思路都相通,差异点我会在对应位置标出来。
1. 建表之前先弄懂:表结构设计的核心思维
1.1 一张表到底是由什么组成的
很多人把表理解成 Excel,行列结构确实一模一样,但关系数据库要比 Excel 严格得多:每一列必须有明确的数据类型,每一行都必须满足表上定义的约束。你可以把一张表想象成带类型检查的仓库货架,每个格子能放什么东西,在上架之前就先规定好了。CREATE TABLE 做的就是“规定货架”这一步。
一张表真正的组成远不止“列名 + 类型”这么简单。它至少包含表名、列名、数据类型、是否允许 NULL、默认值、主键、唯一约束、检查约束、外键、索引,以及 MySQL 里的存储引擎和字符集、SQL Server 里的文件组等物理属性。很多新人写 CREATE TABLE 时只关心列名和类型,忽略后面这一大堆因素,结果表是建出来了,真正跑业务的时候却各种扯皮。
1.2 为什么字段设计比语法更容易毁掉一张表
CREATE TABLE 的语法学起来真的很快,你十分钟就能背下来,但字段设计这件事没有半年实战经验基本拿不准。举个例子,有人把订单状态字段设计成 VARCHAR(16),存“未支付”“已支付”“已退款”这种中文词,看着直观,等到你要做统计报表时就难受了,每次都要 CASE WHEN 转义,写出来的 SQL 又长又慢。更合理的做法是用 TINYINT 存状态码,配合一张状态字典表去解释含义。
再比如日期字段用 VARCHAR(20) 存,写入方便,但查询的时候根本无法高效排序和范围筛选,后台一查近三十天订单就会慢到怀疑人生。还有金额字段用 FLOAT 存,更是大坑,后面我会单独展开。这里我想说的核心就一句:CREATE TABLE 时你在设计的是未来所有查询和数据逻辑的底层结构,设计阶段多留一天认真讨论业务,后面能少加十天班去洗数据和调慢 SQL。
2. CREATE TABLE 标准语法:从最小可用语句拆起
2.1 一条最小建表语句的标准长相
先看一段最省事的建表语句,我用 MySQL 语法写,其他数据库的差异后面补:
CREATE TABLE IF NOT EXISTS user_info ( id INT AUTO_INCREMENT PRIMARY KEY, user_name VARCHAR(50) NOT NULL, email VARCHAR(100) NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP );这段 SQL 已经是生产可用级别了,不要以为最简就是CREATE TABLE user_info (id INT, name VARCHAR(20)),那种等你上线跑数据时一定会后悔。拆开来看:IF NOT EXISTS用来保证脚本重复执行时不报错;id INT AUTO_INCREMENT PRIMARY KEY是 MySQL 的自增主键写法,SQL Server 对应的是INT IDENTITY(1,1);NOT NULL表示字段不允许为空;DEFAULT CURRENT_TIMESTAMP让创建时间自动填当前时间。
有一点特别重要:列之间必须用英文逗号分隔,最后一个列定义后面不能有逗号,不然很多数据库会直接报语法错误。这个错误我在新人代码里见得太多了。
2.2 一个包含常见选项的订单表示例
在实际系统里,单表往往有几十个字段,这里我用一个订单表做模板,把最常见的建表选项都放进去:
CREATE TABLE IF NOT EXISTS `order` ( order_id BIGINT AUTO_INCREMENT PRIMARY KEY, user_id INT NOT NULL, order_no VARCHAR(32) NOT NULL COMMENT '业务订单号', total_amount DECIMAL(12,2) NOT NULL DEFAULT 0.00, status TINYINT NOT NULL DEFAULT 0, remark VARCHAR(255) NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_order_no (order_no), KEY idx_user_id (user_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这里面有几个设计点:订单号必须加唯一约束,防止重复;金额用DECIMAL(12,2)而不是FLOAT,后面单独解释;状态用TINYINT用数字语义,而不是存中文;updated_at加了ON UPDATE CURRENT_TIMESTAMP,每次更新记录时自动刷新时间,MySQL 支持,但 SQL Server 不支持这个写法,需要改用触发器维护,这是跨数据库经常遇到的坑。
order是保留字,这里用了反引号包裹,MySQL 支持这种写法。不过我更建议在业务命名阶段就规避保留字,比如表名干脆叫t_order或trade_order,省得后面写 SQL 到处加反引号。
2.3 大小写、保留字和命名规范,这些细节不能忽略
先说保留字。order、user、group、default、condition这些看起来很适合当表名或列名,但它们多半是 SQL 保留字或关键字,直接使用会导致 SQL 语法错误。MySQL 里可以用反引号转义,SQL Server 里用方括号[],PostgreSQL 里用双引号"",都能解决,但每个查询都得写转义符号,非常繁琐,能避免就避免。
再说命名。表名用单数还是复数,团队内部通常会有约定,我个人倾向单数:user、order_item,不要一会儿users一会儿order_items。字段统一小写 snake_case,Java 后端自动映射成驼峰也方便,不要混用多种命名风格。
最后说大小写。MySQL 在 Linux 上默认区分表名大小写,在 Windows 上不区分,这会导致同一个建表脚本在两个环境表现不一致。最稳妥的做法是全程统一小写表名,字段名也是一样。SQL Server 的默认排序规则通常不区分大小写,但你也别指望靠这个兜底,规范统一才是正解。
3. 字段类型没选对,等于给慢SQL埋雷
3.1 数值型选型:整数、定点数还是浮点数
数值类型看起来简单,实际上是最容易埋雷的地方。先看一张对照表:
| 类型 | 适合场景 | 注意点 |
|---|---|---|
| TINYINT / SMALLINT | 状态码、年龄、枚举 | 范围小,省空间 |
| INT / INT UNSIGNED | 用户ID、数量 | 常规选择 |
| BIGINT | 订单号、流水号、大表主键 | 空间翻倍,别滥用 |
| DECIMAL(p,s) | 金额、汇率、精确计算 | 整数位必须预留足够 |
| FLOAT / DOUBLE | 科学计算、坐标近似值 | 不要用于业务金额 |
最经典的翻车案例就是把金额存成 FLOAT。FLOAT 是二进制浮点数,无法精确表示所有十进制小数,0.1 + 0.2 的结果在计算机里不是 0.3,而是 0.30000000000000004。一次两次看不出问题,订单量一大,对账差几分钱是很正常的事。金额一律用 DECIMAL,比如DECIMAL(12,2)表示总位数 12 位,小数 2 位,也就是整数部分最多 10 位,绝大多数业务足够用了。
还有个坑是手机号用 INT 或 BIGINT 存。手机号看上去是数字,但它不参与任何算术运算,而且可能会有前导 0 或者 +86 这种形态,一旦转成数值,前导 0 直接丢失。正确做法是存VARCHAR(20)。类似的还有银行卡号、证件号,都当字符串处理。
3.2 字符串与字符集:VARCHAR、CHAR 和 TEXT
字符串类型在业务表里用得最多,也最容易踩坑。VARCHAR(n)是变长字符串,存多长用多长,CHAR(n)是定长,存不满会补空格,MySQL 比较时通常会忽略尾部空格,SQL Server 也有类似行为,所以能用 VARCHAR 尽量用 VARCHAR。
TEXT这种大字段能不用就不用。它在数据库里存储和检索的开销都比普通字段大,而且在排序、去重、分组时很容易驱动临时表落盘,性能差得离谱。如果你的字段确实需要存很长的文本,要么拆到单独的大字段表,通过主键关联,要么直接用对象存储保存内容,表里只存地址,查询体验完全不同。
字符集是很多乱码问题的根源。MySQL 建表时请固定用utf8mb4,它不是utf8的升级版那么简单,utf8在 MySQL 里最多存 3 字节字符,很多特殊字符和 emoji 都存不进去,写入直接报错或者变问号。utf8mb4是完整的 UTF-8 支持。SQL Server 没有这个烦恼,你建表时选nvarchar和nchar就能正确存储 Unicode,但要注意所有相关列都统一使用n前缀类型,混用照样会出现字符集转换损耗。
3.3 日期时间类型:别用字符串存时间
日期时间的选型,不同数据库差异很大。MySQL 常用DATE、DATETIME、TIMESTAMP;SQL Server 用datatime、datetime2、datetimeoffset;PostgreSQL 用date、timestamp、timestamptz。但有一条铁律是通用的:不要让日期时间变成字符串。
用字符串存日期,写入时确实很顺,想怎么存怎么存,但索引会失效,范围查询会变成字符串比较,时区处理更是灾难。比如你存2025-06-01 10:30:00,看起来像时间,三个表里三种格式,第二个表存20250601,第三个表存2025/06/01 10:30,等你要做月平均统计的时候就知道有多痛苦了。
关于DATETIME和TIMESTAMP的区别,MySQL 里TIMESTAMP支持自动更新,但是范围只到 2038 年,DATETIME范围远很多,业务表一般优先DATETIME。默认值可以直接写DEFAULT CURRENT_TIMESTAMP,SQL Server 对应写DEFAULT GETDATE(),PostgreSQL 写DEFAULT now()。任何日期字段都不建议允许 NULL,宁可给一个默认时间。
3.4 字段类型、慢SQL与注入风险的隐含关系
很多人觉得慢 SQL 优化是查询阶段的事,跟建表没关系,其实大错特错。最常见的慢 SQL 根因之一就是“隐式转换”:表里某列是VARCHAR类型,查询条件却传了一个数字,数据库只能先把每一行的字符串转成数字再比较,索引直接作废,全表扫描跑起来,数据量一上去,反应时间立刻指数级上升。建表时把类型定准了,比后面加索引管用得多。
再说 SQL 注入。注入的本质是拼接不可信内容进 SQL 语句,最常见发生在查询和插入阶段,但 DDL 同样需要警惕——永远不要用用户输入直接拼接表名、列名去执行CREATE TABLE、DROP TABLE这类操作。所以建表时明确约定字段语义、限制长度,尽可能用参数化查询,这不仅是安全要求,也是让慢 SQL 无处遁形的必要前提。
4. 约束条件:主键、去重与数据完整性防线
4.1 主键到底怎么选:自增、UUID 还是业务键
主键是表的灵魂,先看常见方案的对比:
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 自增主键 | 写入有序,索引小,性能好 | 分布式合并会冲突,泄露业务量 | 单体系统、内部表 |
| UUID/GUID | 全局唯一,跨库合并方便 | 随机写入导致索引碎片,存储大 | 分布式、多区域合并 |
| 雪花ID | 趋势递增,分布式唯一 | 需要额外实现分配器 | 中大型分布式系统 |
| 业务键 | 天然有意义,省字段 | 业务可能变化,主键不可变更 | 非常稳定的业务标识 |
我的建议是:绝大多数业务表都别把业务字段当主键。比如用户表不要用email做主键,因为邮箱可能改;订单表不要用order_no做主键,因为订单号规则可能调整。正解是用代理主键,比如自增或者雪花 ID,再把order_no、email加一个UNIQUE KEY保证业务唯一。这样两者各司其职,不会互相拖累。
主键还有个硬性要求:不允许为空,这一点在 CREATE TABLE 里一定要写明白。有些数据库允许主键建在可空列上,但行为极其诡异,宁可报错也别放任。
4.2 NOT NULL 和 DEFAULT:别怕麻烦,老老实实加上
很多新人为了省事,字段全部允许 NULL,结果后面写统计 SQL 时一脸茫然。NULL 在 SQL 里有特殊含义:它代表“未知”,而不是空字符串。NULL 参与COUNT、SUM、GROUP BY、ORDER BY计算时行为都和其他值不同,查出来的结果往往让人摸不着头脑。
所以建表时默认逻辑是:业务上一定存在的字段,全部NOT NULL,并且加上合理的DEFAULT。比如状态字段status TINYINT NOT NULL DEFAULT 0,创建时间created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP。那些真正可选的字段才允许 NULL,同时一定想清楚,你对“空字符串”和“NULL”的语义区分是什么。如果在业务里两者含义不同,就要在数据字典里写清楚,否则后面洗数据的人会崩溃。
4.3 唯一约束:让去重从源头发生
提到“SQL 语句去重”,大多数人的第一反应是SELECT DISTINCT或者GROUP BY,但那是查询阶段的事后补救。真正靠谱的做法是在建表阶段就通过唯一约束,让重复数据根本写不进去。
语法很简单,一列唯一:
CREATE TABLE user_info ( id INT AUTO_INCREMENT PRIMARY KEY, email VARCHAR(100) NOT NULL, UNIQUE KEY uk_email (email) );多列联合唯一也常见,比如同一个用户对同一商品只能有一条评价:
CREATE TABLE review ( id INT AUTO_INCREMENT PRIMARY KEY, user_id INT NOT NULL, product_id INT NOT NULL, content TEXT, UNIQUE KEY uk_user_product (user_id, product_id) );有了这个联合唯一约束,即便应用层因为并发导致重复提交,数据库也会拦下第二条,这是最可靠的一层兜底。还有一个细节:唯一索引里的多个 NULL 是不冲突的,因为 NULL 表示未知,MySQL 认为两个未知值不一定相同。如果业务上“空值也算一种重复”,那你得额外设计其他约束来补位。
4.4 外键和CHECK约束:用还是不用
外键能保证子表引用主表的数据一定存在,学术上叫引用完整性。但生产环境里,很多互联网团队默认不用外键,原因很简单:高并发插入和更新时外键会带来额外的锁检查和校验开销,而且分布式分库分表后外键根本没法跨库生效。我的建议是核心事务系统可以用,高并发读多写少的系统尽量在设计文档里定义好关系,由应用层保证数据一致性,再配合定时对账兜底。
CHECK 约束的支持情况要留意。MySQL 8.0.16 之前只解析不生效,8.0.16 之后才真正落地;SQL Server 和 PostgreSQL 一直支持得很好。比如限制状态值范围:
CREATE TABLE t_order ( status TINYINT NOT NULL DEFAULT 0, CONSTRAINT chk_order_status CHECK (status IN (0, 1, 2)) );这种约束能挡掉很多应用层漏进来的脏数据,成本远低于后面一遍遍清洗。核心问题不是“用不用”,而是“别滥用”:约束越多,写入时要做的检查越多,所以要让团队里每一个人都明白每条约束的业务含义,而不是堆一堆别人根本不知道的规则上去。
5. 复用和迁移:CREATE TABLE AS 与 LIKE 的实战差异
5.1 从查询结果直接建表,最快的临时表生产方式
新建表不一定每次都要从零手写,有一类需求是想把一张或几张表的查询结果落成一张新表,常见于报表、数据备份、临时分析。MySQL 和 PostgreSQL 的语法是CREATE TABLE new_table AS SELECT ...,简写 CTAS;SQL Server 则用SELECT ... INTO new_table。举个例子:
-- MySQL / PostgreSQL CREATE TABLE order_bak_20250601 AS SELECT * FROM t_order WHERE created_at >= '2025-06-01'; -- SQL Server SELECT * INTO order_bak_20250601 FROM t_order WHERE created_at >= '2025-06-01';这条语句日常非常顺手,尤其适合把核心库的大表抽一部分数据到分析库。但你必须清楚,CTAS 创建的新表只是数据的快照,不会复制源表的约束、默认值、主键、索引和自增属性。也就是说,这张新表除了字段类型跟着源表走,其他什么都没带走。如果后续要在这张备份表上做高频查询,还得自己补主键和索引。
5.2 带着“去重逻辑”建新表,一招解决重复数据
建表同时做数据清洗,最常见的场景是源表里存在重复记录,需要生成一张去重后的新表。用窗口函数配合 CTAS 是最好的做法。比如用户导入表里同一个邮箱有多条记录,想保留 ID 最小的那条:
CREATE TABLE user_dedup AS SELECT id, user_name, email, created_at FROM ( SELECT id, user_name, email, created_at, ROW_NUMBER() OVER (PARTITION BY email ORDER BY id) AS rn FROM user_raw ) t WHERE rn = 1;这里先用窗口函数ROW_NUMBER()按邮箱分组,给每一组分一个序号,ID 最小的排第 1,然后只保留rn = 1的行。这个模式在数据分析里很常用,比GROUP BY去重更灵活,因为你能同时保留其他字段,不会丢失明细信息。
去重只是第一步,表建好后别急着高兴,记得回到源表把唯一索引补上,否则过几天新数据又会重复进来。这种“建表不设防,靠事后 distinct”的做法,本质上是在给未来埋雷。
5.3 CREATE TABLE LIKE 与临时表:只复制结构,还是只存在一会儿
MySQL 中有一种特殊语法,用于完全复制某张表的表结构但不带数据:
CREATE TABLE new_table LIKE old_table;LIKE会复制源表的列定义、索引、自增属性,比 CTAS 保留的内容完整得多,但它不会复制数据,有些版本也不会完整复制外键约束。这个玩法常用于做影子表:在业务高峰期给一个大表创建一个结构一模一样的空表,先建好索引,再在低峰期把数据切过去,避免直接在大表上长时间加锁。
临时表又是另一个概念。MySQL 和 PostgreSQL 支持CREATE TEMPORARY TABLE,SQL Server 用#tmp前缀,它只对当前会话可见,会话结束表自动删除,特别适合存中间结果。不过记住,临时表再方便也只是临时方案,如果哪一天你在生产环境里发现需要频繁重建临时表,应该回头检查正式表的数据模型是不是设计不合理,而不是继续给临时表加一堆索引。
6. 建表之后的索引与性能设计:从源头减少慢查询
6.1 哪些字段值得建索引,哪些建了反而拖慢写入
建表之后紧接着要考虑索引。很多慢 SQL 的根子就在建表阶段:高频查询的字段没有索引,或者索引建得乱七八糟,查询优化器根本用不上。
先明确基本规则:出现在WHERE、JOIN ON、ORDER BY、GROUP BY里的字段,是优先建索引的对象。特别是那些区分度高的字段,比如order_no、email、user_id,天然适合做索引。相反,状态字段如果只有 0、1、2 三种值,区分度太低,单独建索引效果很差,查询优化器很可能还是选择全表扫描,因为逐个索引回表反而更慢。
索引不是越多越好。每加一个索引,写入数据时就要多维护一棵索引树。我见过一张业务表被开发叠了 12 个索引,正常的订单插入都慢成龟速,这是典型的无脑优化。建索引前先问自己:这个字段真的会被高频查询吗?这个查询真的需要单独建索引吗?能不能复用已有索引?
6.2 联合索引与最左前缀:顺序错了等于白建
多条件查询场景,联合索引的字段顺序非常重要。假设评论表有(user_id, created_at)联合索引,那它能高效支持WHERE user_id = ?,也支持WHERE user_id = ? AND created_at > ?,甚至支持ORDER BY user_id, created_at。但如果你查询条件只有created_at,这个联合索引就用不上了,因为索引最左前缀原则要求必须从最左边的字段开始匹配。
所以设计联合索引时,把等值条件字段放前面,范围条件放后面。比如订单查询常见user_id + status + created_at,那(user_id, status, created_at)通常比(status, user_id, created_at)更合理,因为user_id和status的等值匹配可以直接定位到一个小范围,再用created_at做范围过滤和排序。
另一个技巧是覆盖索引:如果查询只需要返回user_id、status、created_at这几个字段,而它们恰好都在同一个索引里,MySQL 就可以只扫描索引树、不回表取数据,性能直接上一个台阶。这也是为什么建表时不要轻易把大字段塞进冗余索引里去的原因。
6.3 EXPLAIN 验证:建表后第一件事是看执行计划
写任何建表语句和查询语句之后,我都建议你跑一下EXPLAIN,确认查询不是全表扫描。比如:
EXPLAIN SELECT order_no, status FROM t_order WHERE user_id = 10086;看执行计划里type字段的值,如果出现ALL,说明全表扫描;如果看到ref、range、index,说明索引用上了。另外看key列是不是你预期的索引名,有时候查询优化器会选一个和你想象完全不同的索引,这时候要思考是不是联合索引顺序没设计好。
有一点必须提醒:数据量太小时,优化器可能觉得走全表扫描比走索引更快,这并不代表建表设计错了。别拿十几条测试数据来判断性能,要拿生产环境的真实数据量或接近真实的数据量做压测,结论才靠谱。建表阶段多花半小时做这个验证,后面排查慢 SQL 的时间能少一大半。
7. 我踩过的几个建表坑:都是生产环境里真实发生过的
7.1 字符集不一致,中文写入后乱码
有一次某个业务模块上线后发现,用户填写的备注信息到了数据库里全是问号和乱码。查了一圈,发现表是默认latin1字符集,而应用连接用的 UTF-8,写入时一路转码转成了乱码。最坑的是,乱码一旦落库,直接改字符集并不能把已经乱掉的数据恢复回来。
解决办法其实很朴素:建表语句里强制写DEFAULT CHARSET=utf8mb4,连接串里也统一 UTF-8,并让 DBA 把数据库默认字符集也改成utf8mb4。如果你的表已经建了,不要只修改表定义,要先用ALTER TABLE ... CONVERT TO CHARACTER SET utf8mb4转换数据,再修改连接配置,否则数据可能二次损坏。统一字符集这件事,最好在建表第一天就做到,而不是等出现乱码再救火。
7.2 DECIMAL 精度看错,对账差一分钱
有位同事在设计结算表时用了DECIMAL(10,2),算日流水没问题,某天突然出现一条报错,提示数值超出列范围。查原因才发现,DECIMAL(10,2)的整数部分最大只有 8 位,也就是 99999999,如果某笔结算单金额超过这个数,插入直接失败。
另外,DECIMAL定点数能精确表示有限位的十进制小数,但它也不建议用来做复杂的小数运算后反复取整,因为中间计算截断和取整规则不同。正解是金额字段预留足够的位数,比如DECIMAL(18,2),同时所有金额运算统一用相同精度类型,避免隐式转换。还有,业务上如果涉及汇率这种多位小数,需要更长的比例尺,这时候只能用DECIMAL(18,8)之类的高精度方案,应用层再做金额换算。
7.3 上线脚本没做幂等,跑第二次直接报错
很多建表脚本第一次执行成功,第二次执行的时候就报“表已存在”。这在小项目里可能无所谓,但进入 CI/CD 自动化发布流程后,同一个脚本可能被重复执行,报错轻则导致发布失败,重则中断整个流水线,后面的人还一脸懵。
让脚本变安全的最简单方法就是加IF NOT EXISTS:
CREATE TABLE IF NOT EXISTS t_order (...);如果你的团队用 Flyway、Liquibase 这类迁移工具,它们靠版本号管理脚本,本身就能避免重复执行,但前提是每个版本号对应的脚本内容不能改。在生成环境加表,我建议永远配套写回滚脚本:建表脚本执行前先确认表不存在,回滚脚本就是DROP TABLE IF EXISTS。哪怕你不用迁移工具,把建表语句全部收敛到版本化目录里,也比随便在运维平台手动执行安全得多。
7.4 用保留字当表名,写查询时处处碰壁
我曾接手过一个老系统,里面有个表叫user,MySQL 下所有相关 SQL 全都要写成:
SELECT * FROM `user` WHERE ...这套代码所有地方都加了反引号,写起来烦,而且团队新人经常忘记反引号,一执行就是语法错误。后来我们花了两个星期做重命名迁移,把user改成user_account,才彻底消停了。
所以建表命名阶段就应该用业务前缀或者更明确的语义词,比如t_user、sys_user、user_info,避免order、group、condition这类保留字。列名也一样,别用order这种词,用order_no、sort_no这种具体含义明确的命名,后面写 SQL 的体验完全不一样。
7.5 外键 ON DELETE CASCADE 引发的“级联翻车”
这个坑是最吓人的。某次上线一个删除功能,管理员在后台删除一个用户,结果用户的历史订单、订单明细、评价记录全部被外键级联删了个干净。幸好当时还有备份,恢复数据花了一个晚上,但整个团队都被吓得不轻。
ON DELETE CASCADE在数据模型看来很优雅,但实际业务里“删用户”和“删订单明细”往往不应该是同一条物理操作。用户可能只是被禁用,订单需要保留用于审计,你要做的是逻辑删除而不是物理删除。我的经验是:业务表的删除尽量用状态字段软删除,即使要做物理清理,也写成显式的批量任务,逐张表可控地处理,坚决不依赖数据库级联。这不算约束的错,而是设计时要把每个删除场景都想清楚。
7.6 盲目建索引,写入慢到没法用
最后再讲一个很常见的过度设计。为了提升查询速度,业务方一口气给一张表建了一堆索引,觉得“反正数据库空间够”。结果这张表本来就是高频写入表,每天几十万次插入,每个索引都是一棵独立的 B+ 树,数据写入时要同时写入十几棵索引树,最终一个简单的插入操作被拖到几百毫秒,订单积压严重。
正确的做法是先基于真实查询模式建立必要索引,每多一个索引前都问一句“少了它会怎么样”。同时,如果确实需要做大批量数据灌入,通常先删除非必要索引、导入完成后再重建索引,这样整体耗时反而更短。建索引不是在建收藏夹,建得越多越心安,它是有成本的,写放大和空间膨胀都是代价。那些索引热词下面频繁出现的“慢 SQL 优化”,大多数时候根本不是优化器的问题,而是建表阶段索引策略就没想清楚。
这些坑我不止踩过一次,而且往往是同一种类型的错误在不同项目里再次出现。现在每次执行 CREATE TABLE 前,我至少会问自己三件事:这个字段的容量和类型在三年后还够用吗?这个约束能不能在源头挡住脏数据?这套索引真的覆盖了高频查询吗?想清楚再回车,后面省下来的全是治慢 SQL 和洗数据的宝贵时间。