1. 为什么建表之前先想清楚这四件事直接说结论MySQL里建表这件事90%的问题都不是写在CREATE TABLE那一刻而是动手之前没想清楚。你让我写这个标题我第一反应不是教你背语法而是先拉着你坐下来聊聊——你建这张表到底是给谁用的数据会长什么样以后要怎么查。我早年带过不少刚入行的同事很多人习惯性上来就写CREATE TABLE user (id int, name varchar(20));然后就被业务方追着加字段从phone加到address再从address拆成province、city、detail最后表结构比需求文档还长。这种表不是建出来的是补丁摞出来的。所以在打开命令行之前我建议你先逼自己回答四个问题这张表的核心业务对象是什么它跟订单、商品、用户之间是什么关系数据是从哪里来的接口写入、日志采集还是人工录入写入频率多高线上最高频的查询是什么按什么字段过滤、排序、关联数据的生命周期有多长会不会有历史归档、删除、更新这四个问题直接决定你的字段类型、索引设计、约束策略和存储引擎取舍。别嫌麻烦这一步省了后面全是债。2. 创建表的完整语法拆解与字段类型选择2.1 CREATE TABLE 语法逐段解读先看一份最常用的建表语句我把注释写在里面CREATE TABLE orders ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 主键ID, order_no VARCHAR(64) NOT NULL COMMENT 业务订单号, user_id BIGINT UNSIGNED NOT NULL COMMENT 下单用户ID, total_amount DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT 订单总金额, status TINYINT NOT NULL DEFAULT 0 COMMENT 订单状态0待支付 1已支付 2已取消, 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_user_id (user_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COLLATEutf8mb4_general_ci COMMENT订单主表;这段语法里每个部分都可以单独拆出来聊我挑几个最容易出问题的点讲。反引号要养成习惯。table_name、column_name外面套反引号能避免字段名和MySQL保留字冲突。比如order、group、desc这些词你不加反引号SQL解析直接报错。虽然可以设计字段时故意避开但谁也保不准未来接手的同事会不会写一个rank之类的不规范命名。加反引号不费事是防御性编程的基本素养。AUTO_INCREMENT只能用在整数主键上。很多人以为随便找个字段都能自增其实MySQL约束得很死自增列必须被定义为某个索引的组成部分通常是主键。而且一张表只能有一个AUTO_INCREMENT列。早期版本MyISAM支持自增列作为联合索引的一部分InnoDB下我建议就老老实实放主键上省得惹麻烦。CURRENT_TIMESTAMP作为默认值是两个版本的分水岭。在MySQL 5.6.5之前一个表里只能有一个TIMESTAMP列使用CURRENT_TIMESTAMP作为默认值或更新值5.6.5之后这个限制放开DATETIME也支持默认值了。这也是我推荐created_at、updated_at都写在建表语句里的原因——比你在应用层赋值靠谱尤其遇到多个服务同时写库的情况毕竟谁也不敢保证所有服务的服务器时间绝对一致。2.2 字段类型选错表就是定时炸弹字段类型是建表时最容易被低估的一环。我见过用VARCHAR(255)存手机号的用DECIMAL存金额结果精度不够的还有用TEXT存用户昵称的。每一条都是实打实的线上事故。先给一张我自己常用的类型选型对照表按经验整理的不保证覆盖所有场景但解决80%日常需求绰绰有余数据类别推荐类型踩坑点主键IDBIGINT UNSIGNED用INT未来数据量过20亿会溢出用VARCHAR当主键会拖垮写入性能业务编号VARCHAR(32)~VARCHAR(64)长度设计宁短勿长但也要留冗余过短会面临重构手机号VARCHAR(20)别用BIGINT你存不了86、区号这些格式而且隐式转换会废掉索引金额DECIMAL(12,2)严禁使用FLOAT/DOUBLE二进制浮点数的精度误差在金融场景不可接受状态码TINYINT别用VARCHAR存pending这类字符串空间浪费至少10倍查询效率也差描述文本VARCHAR(500)不确定长短时用TEXT但注意TEXT不能有默认值时间DATETIMETIMESTAMP上限是2038年别给子孙留坑DATETIME范围大得多我反复强调一条原则在满足业务需求的前提下选最省空间的类型。磁盘空间是小事但InnoDB的数据页大小固定16KB字段越宽一页能容纳的行数越少扫描代价和缓存命中率都会受影响。你以为多几个字节无所谓到了千万级数据量差别就是秒级和毫秒级的距离。还有个很细节的坑VARCHAR(M)里的M指的是字符数不是字节数。在utf8mb4字符集下一个汉字占4个字节VARCHAR(20)能存20个汉字但最多占用80字节。你根据字节数去估长度就完蛋了。3. 约束机制全解析从主键到外键3.1 五大约束的作用与使用边界MySQL的约束本质上是数据库帮你做数据完整性校验减少烂数据进库的可能性。但我得先说个大实话约束不是越多越好用错了地方它就是性能杀手和业务绊脚石。**主键约束PRIMARY KEY**是表的根。它保证每行记录唯一同时InnoDB下主键就是聚簇索引直接决定了数据在磁盘上的物理排列顺序。这里有个巨坑如果你建表时不显式指定主键InnoDB会找第一个非空唯一索引作为聚簇索引找不到就生成一个6字节的隐藏主键。隐藏主键不在你的控制范围内会导致备份恢复、主从复制、数据对账都很被动。所以建表第一件事先定主键。主键选型上自增整数主键和业务自然主键一直有争论。我个人的选型倾向是绝大多数业务表用BIGINT UNSIGNED AUTO_INCREMENT做主键插入顺序可控页分裂最少二级索引占用空间小。分布式场景或者需要数据合并的场景用雪花ID之类的全局ID但不要直接当主键建议配上BIGINT类型。真正稳定且全局唯一的自然键比如身份证号在某个系统里可以考虑做主键但一旦有变更可能性立刻放弃。**唯一约束UNIQUE KEY**是业务层面的兜底。比如订单表里的order_no理论上由业务生成保证不重复但并发情况下谁也不敢打保票所以数据库加个唯一索引是双保险。注意唯一索引允许有多个NULL值这跟唯一字面上的直觉相悖MySQL里NULL不等于NULL所以uk列上多个NULL能共存。如果你需要NULL也唯一就需要额外的处理方案。**非空约束NOT NULL**是约束里最不起眼但坑最多的。我给新手的建议是建表时尽量默认NOT NULL实在允许为空就显式写DEFAULT NULL别偷懒不写。为什么因为查询时一旦出现NULL比较逻辑全乱套WHERE name 查不到name IS NULL的记录WHERE name ! 张三也排除掉NULL行聚合函数COUNT(name)直接跳过NULL。一堆NULL在表里业务代码里全是判空if写起来想摔键盘。**默认值约束DEFAULT和检查约束CHECK**放在一起说。MySQL 8.0.16之前CHECK约束是解析了但完全忽略的很多人不知道这点建了CHECK然后以为高枕无忧结果烂数据照样入库。8.0.16之后CHECK真正生效了但我还是很少在实际项目里用它主要原因是逻辑校验放在应用层更灵活数据库层做太强的约束改业务规则时要走一轮DDL运维成本高。比如说订单金额必须大于0用CHECK当然没错但如果哪天上线了优惠券倒贴活动金额允许为负你还得记得去改CHECK少一步就事故。**外键约束FOREIGN KEY**是争议最大的一个。我个人的看法很简单高并发互联网业务系统里能不用外键就别用。不是外键没用而是它会带来两个很现实的问题InnoDB在外键约束的列上会自动加索引插入和删除时要检查关联表的完整性在写多读少的场景下这个开销会被放大外键把数据之间的关联逻辑固化在数据库层表结构变更、批量导入、分库分表时极其痛苦。那外键就完全不用了吗也不是。内部管理系统、ERP这类低并发、强一致性的场景外键能帮你挡住大量的程序bug。比如删除一个客户时外键能阻止你删掉还有订单记录的客户。这类系统用外键收益远大于成本。技术选型没有绝对的对错只有场景适不适合。3.2 约束的命名规范与运维可观测性约束命名看起来不影响功能影响的是你半夜排查问题的效率。想象一个场景凌晨两点线上报警日志抛出Duplicate entry xxx for key uniq_abc你看到错误信息里的索引名得能第一时间反应过来是哪个字段。我自己的命名习惯主键PRIMARY默认就行。唯一索引uk_{表名}_{字段名}比如uk_orders_order_no。普通索引idx_{表名}_{字段名}比如idx_orders_user_id。联合索引idx_{表名}_{字段1}_{字段2}按索引定义的字段顺序排列。外键fk_{表名}_{关联表}_{关联字段}。一个容易忽略的问题约束名在表内必须唯一但不同库之间可以重复。做数据迁移、字符集转换时如果你自己写脚本千万注意不要用DROP PRIMARY KEY之后忘了加回来这种低级错误一旦发生整张表的性能立刻退化因为InnoDB会重建聚簇索引。另外要提醒一句给表加约束时MySQL 8.0之后支持ALTER TABLE ... ADD CONSTRAINT ...的方式比ADD PRIMARY KEY的写法可读性更强。但要注意添加约束时如果表里已有数据MySQL会先扫描全表做校验大表上这个操作会锁表。所以生产环境加唯一约束一定选流量低谷期或者用pt-osc这类工具做在线变更。4. 实战从零搭建一套用户订单库4.1 业务分析先行这块是我理解的实战部分——不是拿一条建表语句演示完事而是真正从业务出发走一遍完整的建模流程。假设我们要搭建一个电商系统最核心的三张表用户表、商品表、订单表。需求背景我定得尽量典型一个中型电商平台用户量预计百万级订单量千万级日活峰值几万。没有分库分表的必要但表结构要为未来留口子。先梳理实体关系和核心字段用户表用户基本信息重点是手机号登录所以phone要建唯一索引。商品表SKU级别的商品包含价格、库存、上下架状态。价格用DECIMAL库存用INT。订单表这单是谁下的、买了什么、多少钱、什么状态。带一个订单明细表存订单里的每件商品快照。这里有个建模上的关键点订单表里为什么冗余了total_amount和status而不去关联查询因为订单创建之后商品可能改价、用户可能改名如果把关联数据实时连表查历史订单会被当下的业务变动污染。所以订单表里必须冗余一份下单时的商品快照和订单总价。这是电商领域建模的基本功叫快照思想。4.2 建表语句完整演示第一步用户表CREATE TABLE user ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 用户ID, phone VARCHAR(20) NOT NULL COMMENT 手机号, nickname VARCHAR(50) NOT NULL DEFAULT COMMENT 昵称, avatar_url VARCHAR(255) NOT NULL DEFAULT COMMENT 头像地址, gender TINYINT NOT NULL DEFAULT 0 COMMENT 性别0未知 1男 2女, 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_user_phone (phone), KEY idx_user_created_at (created_at) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COLLATEutf8mb4_general_ci COMMENT用户表;第二步商品表CREATE TABLE product ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 商品ID, product_name VARCHAR(200) NOT NULL COMMENT 商品名称, price DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT 销售价, cost_price DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT 成本价, stock INT UNSIGNED NOT NULL DEFAULT 0 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), KEY idx_product_status (status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COLLATEutf8mb4_general_ci COMMENT商品表;第三步订单主表和明细表CREATE TABLE orders ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 订单ID, order_no VARCHAR(64) NOT NULL COMMENT 订单号, user_id BIGINT UNSIGNED NOT NULL COMMENT 用户ID, total_amount DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT 订单总金额, status TINYINT NOT NULL DEFAULT 0 COMMENT 状态0待支付 1已支付 2已发货 3已完成 4已取消, address_snapshot VARCHAR(500) NOT NULL DEFAULT COMMENT 收货地址快照, remark VARCHAR(255) NOT NULL DEFAULT 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_orders_order_no (order_no), KEY idx_orders_user_id (user_id), KEY idx_orders_status_created (status, created_at) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COLLATEutf8mb4_general_ci COMMENT订单主表; CREATE TABLE order_item ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 明细ID, order_id BIGINT UNSIGNED NOT NULL COMMENT 订单ID, product_id BIGINT UNSIGNED NOT NULL COMMENT 商品ID, product_name VARCHAR(200) NOT NULL COMMENT 商品名称快照, product_price DECIMAL(12,2) NOT NULL COMMENT 下单时单价快照, quantity INT UNSIGNED NOT NULL DEFAULT 1 COMMENT 购买数量, subtotal DECIMAL(12,2) NOT NULL COMMENT 小计金额, PRIMARY KEY (id), KEY idx_order_item_order_id (order_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COLLATEutf8mb4_general_ci COMMENT订单明细表;4.3 设计思路逐条说明上面这段建表代码每一处设计都有原因我拆开讲。order_no上建唯一索引这件事我强调过但还想再展开一次。订单号是业务维度上的唯一标识用户可能查订单号、售后要按订单号对账、客服要用订单号检索所以唯一索引必须建。但它不适合当主键因为订单号是字符串用字符串做聚簇索引会让数据页的排列顺序失去物理连续性插入时随机写磁盘页分裂频繁。所以用自增id做主键order_no用唯一索引辅助这是最稳妥的组合。idx_orders_user_id是为了支撑查某个用户的所有订单这类高频查询。刚开始数据量小的时候可能感觉不到等订单量到百万级没有这个索引每次查用户订单就是全表扫描慢到怀疑人生。idx_orders_status_created是联合索引字段顺序按status在前、created_at在后排列。这里有个关键点联合索引遵循最左前缀原则。查询条件是WHERE status 1 ORDER BY created_at这个索引能直接命中如果查询条件只有created_at索引就废了。所以设计联合索引时经常作为等值条件的字段放前面范围条件的字段放后面。订单明细表我特意把order_id放在最前面不设主键自增而是用逻辑主键id再对order_id建普通索引。你可能会有疑问为什么不直接用order_id和product_id做联合主键因为如果同一个订单里同一商品买了多件又拆成多条记录的话联合主键就顶不住了除非强行约束业务但那样会把简单问题复杂化。自增主键最省心order_id建索引查询时走覆盖索引性能足够。还有一个容易被忽略的点address_snapshot字段存的是整个收货地址的JSON文本。为什么不用三张关联表拆出省市区因为收货地址属于低频查询字段不需要按省市区做聚合统计拆开反而增加表数量和JOIN成本。快照冗余一举两得既保证订单历史不被地址变更污染又省了一次关联查询。4.4 补充几条通用约束经验我把自己在建表时经常要对照的几条经验放在一起算是一个小自检清单字段注释必写。别觉得字段名叫status就谁都懂半年后你自己看都得猜半天。COMMENT写清楚每个枚举值的含义。默认值能不给NULL就不给NULL。比如nickname给空字符串默认值查询和展示都少很多判断。金额字段一律DECIMAL。DBA圈流传一句话用FLOAT存金额的迟早要上线事故复盘。DECIMAL虽然计算慢一点但精确。时间字段别存时间戳整数。created_at用DATETIME可读性强索引效率也不差。要是为了省几个字节用INT存Unix时间戳DBA审核那一关你就过不去。字符集统一utf8mb4。MySQL 8.0默认就是utf8mb4但老库升级时经常漏。别用utf8它连emoji都存不了。5. 常见问题与排查技巧实录5.1 建表后才发现字段类型不对怎么改这个问题几乎每个人都撞上过。表建完了业务跑了两周你发现某个字段长度不够或者类型选得离谱。这时候别慌先分清楚情况。如果表里数据量还小比如几千行直接ALTER TABLE改秒级完成ALTER TABLE user MODIFY COLUMN nickname VARCHAR(100) NOT NULL DEFAULT COMMENT 昵称;如果表已经几百万行了这个操作在MySQL 5.6之前会锁表5.6之后InnoDB支持在线DDL大多数情况下不会阻塞DML但还是会占用大量IO资源线上操作要谨慎。我先说个经验任何大表DDL变更先看数据量再选低峰期最后准备回滚方案。数据量大的话别用一条ALTER硬刚可以用gh-ost这类工具做在线无锁变更。对于类型变更还有个更隐蔽的问题VARCHAR(50)改成VARCHAR(100)是安全的但VARCHAR改INT就可能导致数据丢失MySQL在转换时会自动截断非数字内容而且不会给你提示。所以做这类变更之前先备份再写一段检查SQL看看有没有脏数据SELECT * FROM user WHERE nickname REGEXP [^0-9] LIMIT 10;5.2 自增主键用完或者跳号了怎么办自增ID耗尽是个经典惊悚场景。INT UNSIGNED最大能存42亿多看似很多但如果是频繁写入的日志表、流水表几年就撞墙。我排查过一个线上系统主键用INT跑了三年半达到21亿DBA天天盯着剩余空间。处理方式有两种把INT改BIGINT这是治本方案。MySQL 8.0支持ALTER TABLE ... MODIFY在线变更但对大表还是有代价。如果表已经无法承受改主键的开销也可以考虑用BIGINT前缀加业务ID的方式但那属于分库分表范畴了复杂度更高。自增ID跳号倒是常见情况比如事务回滚后自增计数不会回退删除最大ID后也不会复用。这是InnoDB的机制别指望自增ID是连续的它只保证唯一。只要业务不依赖ID必须连续你就不用管。5.3 插入数据报错Duplicate entry怎么排查错误信息是Duplicate entry 13800138000 for key user.uk_user_phone。看到这个报错说明你往唯一索引列插入了重复值。排查思路我按顺序列一下先查是不是真的重复了SQL检查字段的重复情况。排除应用层并发重试导致的重复提交这类情况可能需要业务上做幂等处理。检查是否有历史脏数据老系统没建唯一索引后来补索引时发现数据早就重复了。这时要跟业务方确认保留哪条再清理。值得一提的场景是很多业务系统都有注册后手机号唯一的需求。但用户注销后手机号是保留还是释放如果用uk_user_phone唯一索引释放后的手机号可以重新注册但要注意逻辑删除的用户记录是否也占着唯一索引。这时候要么物理删除要么在唯一索引里加一个is_deleted的联合字段比如uk_user_phone_deleted (phone, is_deleted)把删除标记一起纳入唯一约束。5.4 一个外键引发的删除性能事故我处理过一次线上事故印象很深。某个后台管理系统订单表关联用户表用了外键这个设计在低并发时确实没问题。但后来订单量暴增运维同学要做历史数据清理一条DELETE FROM orders WHERE created_at 2023-01-01执行到一半就卡死了。原因就是外键的级联检查每删除一行订单InnoDB都要去用户表检查是否存在关联记录百万级的删除操作全部走逐行校验耗时直接翻了几十倍。最后停掉清理任务把外键删掉用应用层先校验用户存在性再删除速度立刻恢复正常。这个案例不是劝你永远不用外键而是提醒你上外键之前先评估删除和更新频率。读多写多又需要强一致的系统可以保留外键海量数据清理、高频写入、频繁变更的表结构外键是包袱不是保障。5.5 一张常见问题速查表我把平时工作里最高频的建表相关问题整理成一个速查表方便你直接查阅问题现象可能原因处理思路插入数据报Data too long字段长度设计过短确认业务真实长度ALTER MODIFY加长查询很慢但索引存在字段隐式转换比如WHERE phone 138...数字查VARCHAR应用层统一类型或建索引时与查询类型匹配插入中文变成???表和连接字符集不一致统一utf8mb4检查连接串charset参数删表后磁盘空间没释放InnoDB表空间不自动收缩用OPTIMIZE TABLE重建表空间但大表慎用修改表结构卡死表数据量大DDL持锁流量低峰执行或用在线变更工具唯一约束字段有多个NULLNULL互不相等这是MySQL的规则业务上另行处理比如加联合索引6. 写在最后的一点个人体会在建表这件事上踩过这么多坑之后我的感受是MySQL给我们的自由度很大恰恰是这种自由要求我们自己约束自己。语法文档背得再熟不如开工之前多问一句这张表三年后会变成什么样。约束不是限制是为了让数据在失控之前被拦住。我个人还有一个习惯建完表之后一定会执行SHOW CREATE TABLE把实际生效的建表语句导出来看一遍。这一步能发现很多你意想不到的问题——比如你以为设了utf8mb4结果表实际是utf8你以为字段是NOT NULL结果DDL被工具改写成了允许NULL。数据库是很诚实的你别看自己写了什么要看它最后执行的是什么。这篇文章里所有的建表语句、排查思路、命名规范都是可以照着抄的。但更希望你能从这些具体操作里提炼出自己的判断标准。表结构设计这件事没有银弹只有一个个场景下的权衡。多写几张表多踩几次坑你就慢慢有了手感。如果读完这篇你能在下次建表时多想一步字段类型够不够省、约束该不该加、索引会不会被用到我觉得这篇指南就没白写。
阅读完成 · 觉得有帮助?