上个月朋友拉我过去看一个线上问题,订单表才300万行,一个带条件的分页查询跑了快4秒。我坐到工位上拿到表结构,扫了两眼就看明白了——这不是SQL能救的事,字段类型乱选、索引全没规划、命名更是神仙打架,三个人维护过的表,字段叫法各有各的脾气。这种表一旦上线,后面每一个版本都在还债。
今天就把数据库表设计这件事掰开揉碎聊一聊,整理了一份40条规范清单。这份东西不是教科书理论,是我这些年做项目、做评审、救线上火的时候一条条沉淀下来的。适合正准备建模的开发者、要给别人做表结构评审的组长、还有那些被历史烂表折磨得想重构的同学。你不需要全盘照抄,但每一条背后都有一个真实踩坑场景,看懂“为什么”比记住“是什么”更重要。
1. 设计思路:为什么表设计值得定一套规范
1.1 一次痛彻心扉的线上事故
先讲一个真实案例。某个电商后台系统,订单表字段将近50个,命名混乱到令人窒息:既有user_id,又有uid,还有name、createtime、create_tm,甚至有个字段就叫data1,注释写着“备用”。这张表上线半年后问题集中爆发——查询接口响应极慢,报表统计经常超时,开发想改字段又怕影响线上,新来的同事根本看不懂字段含义,只能靠猜。
我接手排查时发现,这张表有一半索引是冗余的,idx_user_id和idx_uid指向同一列,而真正高频查询的order_no居然没有索引。最离谱的是,订单状态字段用的是varchar(20),存的是PENDING、PAID这种字符串,统计时还要做一堆CASE WHEN转换。后来重构这张表花了整整三周,期间还因为字段命名不一致导致数据迁移脚本写错,差点丢数据。
这次经历让我悟出一个道理:表设计阶段的偷懒,会在后面无数个版本迭代里加倍偿还。数据库表是系统的地基,地基歪了,上面盖多少层都危险。
1.2 规范到底约束了什么
很多人一听“规范”两个字就觉得是束缚,其实恰恰相反。表设计规范的本质是建立一种“团队共识”,让所有人都按照同一套语言、同一套套路来建表,最后得到的是可预期、可维护、可交接的产物。
具体来说,规范约束三个层面:
- 一致性:命名风格、字段类型、必备字段都统一,新人接手不慌,跨模块协作不用猜。
- 性能预期:建表时就考虑查询场景,索引和字段类型选对,避免上线后性能返工。
- 演进空间:预留审计字段、逻辑删除、时间戳,让表结构能适应业务变化。
拿装修来类比。水电改造时图纸画得烂、线管乱走,住进去之后插座不够用、网络线抽不动,想改就得砸墙。表设计规范就是水电图纸的标准化,画的时候多花十分钟,住的时候省十年心。
1.3 这40条规范怎么用
我整理这份清单时的定位,不是“学术论文”,而是“评审手册”。建议的使用方式是:
- 新项目建模时,打印出来一条条过,做完标记。
- 表结构评审会,直接拿这个清单当checklist,对着看。
- 新人培训时,挑几个反例讲一遍,比讲半天理论管用。
40条按模块分成了五类:命名规范、字段类型、必备字段、索引规范、设计原则。下面每个模块都会先讲核心思路,再给规范清单和实战解读,最后讲讲我踩过的坑。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 命名规范:让表结构会说话
2.1 一个好名字胜过一行注释
我见过太多表结构,字段名全是a、b、c或者temp1、temp2,看着只想叹气。数据库里没有“代码提示”这种友好机制,字段名就是最直接的文档。一个叫created_at的字段,不用看注释也知道是创建时间;一个叫ct的字段,你得翻半天文档才能确认是不是创建时间。
命名规范还有一个隐蔽但重要的好处:减少团队沟通成本。后端、前端、数据分析师都要看表结构,如果user_id和uid同时存在,写SQL的人永远搞不清该用哪个。统一命名约定,等于给整个团队建立了共同语言。
2.2 命名规范8条清单与反面案例
| 编号 | 规范 | 说明 |
|---|---|---|
| 1 | 表名采用“模块前缀_业务名” | 如user_account、order_detail,一眼看出归属模块 |
| 2 | 所有命名统一小写+下划线 | 禁止驼峰或大小写混用,MySQL在Linux下对表名大小写敏感 |
| 3 | 表名使用单数形式 | 团队内保持一致,推荐单数,避免users和user混用 |
| 4 | 字段名使用完整英文单词 | 禁止拼音缩写,如xming这种坚决不出现 |
| 5 | 关联字段用xxx_id格式 |
如user_id、order_id,明确表达外键关系 |
| 6 | 时间字段用xxx_at或xxx_time |
统一风格,如created_at、paid_at |
| 7 | 状态类型字段用status、type |
字段语义明确,配合注释说明取值范围 |
| 8 | 索引名规范化 | 普通索引idx_表名_字段名,唯一索引uk_表名_字段名 |
反面案例我印象最深的是早年一张用户表,既有username又有account还有一个login_name,三个字段其实都是登录名。原因是三个开发各写各的,谁也没跟谁对齐。最后做账号合同时,数据清洗花费了大量人力。
2.3 命名规范的核心心得
取名字这个事情,核心原则就一句话:让别人不看注释也能猜懂八九成。所以我特别强调模块前缀,因为这能解决一个大痛点——多业务线共用库的时候,光看表名就能知道归属。比如order_pay_record和user_pay_record,显然一个在订单域,一个在用户域。
另外要提醒一点:命名确定后不要反复改。数据库表名和字段名一旦被引用,改一次就是一场灾难。所以建模阶段宁可多花半小时讨论命名,也不要上线后改。我在项目里推过一个笨办法:新建表之前,把拟定的表名、字段名清单贴到群里公示一天,让大家挑毛病。这个习惯帮我避掉了很多命名坑。
3. 字段类型规范:选错类型是给自己挖坑
3.1 类型选择的核心原则
字段类型选择的第一原则是:用能满足业务的最小类型。很多人觉得反正是数据库,类型大一点无所谓,但类型直接影响存储空间、索引大小、查询性能和内存占用。一张表几个字段看不出差别,几千万行数据之后,空间和性能差距就出来了。
第二原则是:类型必须贴合字段的真实语义。金额就是DECIMAL,时间就是DATETIME,状态就是TINYINT。选错类型不只是浪费空间,还会引发精度丢失、索引失效、隐式转换等一系列问题。
3.2 字段类型规范8条详解
| 编号 | 规范 | 说明 |
|---|---|---|
| 9 | 整型按取值范围选择 | TINYINT/SMALLINT/INT/BIGINT,不要一律INT |
| 10 | 金额、费率必须DECIMAL |
禁止FLOAT/DOUBLE,避免精度丢失 |
| 11 | 字符串绝大多数用VARCHAR |
定长短值才用CHAR,长度按业务合理设置 |
| 12 | 大文本用TEXT系列类型 |
注意不建索引,查询性能要靠业务规避 |
| 13 | 时间字段用DATETIME |
默认不用TIMESTAMP,时区问题后面细说 |
| 14 | 不用ENUM和SET |
改枚举值要ALTER TABLE,扩展性差 |
| 15 | 二进制和文件不存数据库 | 存对象存储路径,数据库只保存URL |
| 16 | 布尔用TINYINT(1) |
值为0/1,不要用BIT或CHAR |
3.3 字段类型常见坑点
最典型的坑是金额字段用FLOAT。你用FLOAT存0.1,读出来可能是0.100000001490116,因为浮点数在二进制里无法精确表示。做金额累加时误差会越滚越大,财务对不上账的时候,这个锅没人想背。所以金额一律DECIMAL(10,2)起步,如果需要更大范围用DECIMAL(18,2)。
第二个坑是手机号用BIGINT。看似合理,但手机号前面可能有国家码、+86这类格式,还有一部分号码以0开头的场景(虽然国内少见),BIGINT会把前导0丢掉。统一用VARCHAR(20)最省心。
第三个坑是VARCHAR长度习惯性给255。VARCHAR(255)和VARCHAR(50)在存储空间上没有本质差别,但在排序和临时表操作时,长度越短性能越好。所以长度按业务给:用户名50足够,订单号32足够,不要无脑255。
第四个坑是时间字段。DATETIME和TIMESTAMP的区别很多人不知道:DATETIME不依赖时区,存什么读什么;TIMESTAMP会随着数据库时区设置转换,如果未来服务器时区变了,历史数据的时间可能会“漂移”。默认用DATETIME,除非你要做跨时区的自动转换。
3.4 实战:状态字段从字符串改成数字
早年间我做订单系统,状态字段用VARCHAR(20),存PENDING、PAID、SHIPPED这些字符串。表面上看可读性好,但实际用起来处处难受:统计时得CASE WHEN转换,索引体积大,改一个状态值要动数据字典。
后来全部改成TINYINT,用字典表或代码枚举定义状态码:0待支付、1已支付、2已发货、3已完成、4已取消。查询速度明显提升,代码里定义枚举反而更规范。改动上线后,我顺手把字典表也建好了,新增状态只加记录,不用改表结构。
4. 必备字段与主键设计:每张新表都要带的6件套
4.1 主键选择:自增、雪花还是UUID
主键是表设计的灵魂,没有之一。我先给结论:单机单库场景用自增BIGINT,分布式场景用雪花ID,能不用UUID就不用。
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
自增BIGINT |
性能最好、占空间小、实现简单 | 暴露数据量、迁移麻烦、分布式下不能全局唯一 | 单库单表、内部系统 |
| 雪花ID | 全局唯一、趋势递增、包含时间信息 | 需要引入发号器或算法实现 | 分布式系统、数据分片 |
| UUID字符串 | 生成简单、全局唯一 | 占空间大、无序导致聚簇索引页分裂 | 不推荐做主键 |
为什么UUID做主键在InnoDB下是灾难?因为InnoDB的聚簇索引是B+树,按主键顺序组织数据。自增ID插入时永远追加到末尾,性能稳定;UUID是随机字符串,插入时会在索引树的随机位置分裂页,产生大量随机IO和碎片。一张千万行的表用UUID做主键,写入性能可以差一个数量级。
4.2 必备字段规范6条
| 编号 | 规范 | 说明 |
|---|---|---|
| 17 | 每张表必须有主键id |
BIGINT UNSIGNED,不推荐复合主键 |
| 18 | 每张表必须有created_at |
记录创建时间,默认当前时间 |
| 19 | 每张表必须有updated_at |
记录更新时间,更新时自动维护 |
| 20 | 逻辑删除字段is_deleted |
默认0,删除置1,所有查询带条件 |
| 21 | 审计字段created_by、updated_by |
记录操作人,排障和审计必备 |
| 22 | 并发控制字段version |
乐观锁使用,更新时校验版本 |
4.3 逻辑删除的坑,必须讲透
逻辑删除是个争议话题。我见过直接物理删除把数据删没了的惨案,也见过逻辑删除导致唯一索引冲突的麻烦事。我的建议是:业务数据尽量物理删除,需要留痕的数据用归档表。但现实中很多系统确实需要逻辑删除,那就要处理好一个关键问题:唯一索引冲突。
比如用户表用手机号做唯一索引,用户删除了(逻辑删,is_deleted=1),这个手机号还占着记录。新用户注册同一个手机号,插入时直接报唯一索引冲突。解法是:把唯一索引从uk_phone改成uk_phone_is_deleted(phone, is_deleted),但这样还是不行——is_deleted多个1一样冲突。更好的做法是引入deleted_at字段,默认NULL,删除时写入当前时间;MySQL唯一索引允许多个NULL,所以删除多条不冲突。查询条件也从is_deleted = 0改成deleted_at IS NULL。
这个细节很多老手都会忽略,等线上炸了才恍然大悟。
4.4 乐观锁版本号的作用
version字段在并发更新场景下非常有用。比如一个商品库存表,两个请求同时读到库存100,各自扣减后写回,没有版本号就会超卖。加上version字段后,更新语句写成:
sql复制UPDATE product_stock
SET stock = stock - 1, version = version + 1
WHERE id = 123 AND version = 1;
这样只有第一个请求能更新成功,第二个请求因为version变了,影响行数为0,业务层再走重试或提示失败。这是最经典的乐观锁方案,比SELECT ... FOR UPDATE的性能开销小得多。
5. 索引规范:索引不是越多越好
5.1 索引设计的基本思路
索引的本质是拿空间换时间,但每个索引都是独立的数据结构,写入时要维护,不是免费的。很多开发喜欢一口气把所有查询字段都建上索引,结果一张表建了20个索引,写入慢、存储膨胀,查询时优化器反而不知道选哪个。
正确思路是:从业务SQL反推索引。把所有高频查询场景列出来,分析WHERE条件、JOIN字段、ORDER BY字段,然后按需要建索引。建索引不是在建模时一次性搞定,而是随着业务查询模式稳定后持续迭代,但建模阶段至少要把主键、唯一键、高频条件字段的索引设计好。
5.2 索引规范10条详解
| 编号 | 规范 | 说明 |
|---|---|---|
| 23 | 每张表必须有主键索引 | 主键即聚簇索引,这是性能基石 |
| 24 | 业务唯一性用唯一索引约束 | 应用层判断不够,数据库层兜底 |
| 25 | 高频查询列建普通索引 | 等值查询和范围查询的列优先 |
| 26 | 复合索引遵循最左前缀原则 | 字段顺序决定索引起效条件 |
| 27 | 索引列区分度要高 | 区分度太低,索引反而拖慢查询 |
| 28 | 禁止对索引列做函数运算或隐式转换 | 会导致索引失效 |
| 29 | 单表索引数量控制在5个以内 | 超出后写入开销和优化器负担增大 |
| 30 | 定期清理冗余索引 | SHOW INDEX排查,重复索引果断删 |
| 31 | 大字段不直接建索引 | TEXT类型需前缀索引 |
| 32 | 外键用普通索引加应用层约束 | 物理外键在并发高时锁开销大 |
5.3 复合索引最左前缀原则
这是索引设计里最核心也最容易犯错的点。复合索引(a, b, c),相当于创建了a、(a, b)、(a, b, c)三个索引。查询条件里如果只有b或b, c,走不了这个复合索引。
举例子,订单表建了idx_user_status(user_id, status):
WHERE user_id = 100 AND status = 1,走索引;WHERE user_id = 100,走索引;WHERE status = 1,不走索引。
所以复合索引的字段顺序要按“等值条件优先、范围条件靠后”来排。user_id是等值查询,放前面;status可能是范围或等值,放后面。这个顺序一旦定错,索引效率大打折扣。
5.4 实战:一次慢SQL诊断全程
有一次同事反馈一个接口很慢,SQL长这样:
sql复制SELECT * FROM user_order
WHERE user_id = '10001'
AND create_time > '2024-01-01'
ORDER BY create_time DESC
LIMIT 20;
表里user_id字段是VARCHAR,查询参数也带了引号,看似没问题。但用EXPLAIN一看:
code复制type: ref
possible_keys: idx_user_id
key: idx_user_id
rows: 50000
问题出在create_time和user_id的复合索引没建,导致先按user_id过滤出5万行,再在内存里排序取20条。优化方案是建复合索引(user_id, create_time),让排序直接走索引。改完再EXPLAIN,rows降到20,查询时间从800毫秒降到5毫秒。
这种优化不复杂,但需要时刻记住:索引不只能做WHERE过滤,还能做排序。
5.5 隐式类型转换和函数运算的坑
WHERE user_phone = 13800138000,字段是VARCHAR,参数是数字,MySQL会做隐式类型转换,把字段转成数字再比较,导致索引失效。解决办法很简单:参数加上引号。但这个坑藏得很深,因为结果集看起来没问题,只是性能悄悄变差了。
同样,WHERE DATE(create_time) = '2024-01-01'这种写法也不会走索引。正确的写法是:
sql复制WHERE create_time >= '2024-01-01 00:00:00'
AND create_time < '2024-01-02 00:00:00'
记住一条铁律:索引列保持纯净,任何函数、运算、类型转换都不能加在索引列上。
6. 表结构设计原则:范式、冗余与拆分
6.1 三范式是真的需要吗
教科书上的三范式:第一范式要求字段原子性不可再分,第二范式要求消除部分依赖,第三范式要求消除传递依赖。但实际业务里,如果完全按三范式设计,你会发现表拆得稀碎,查个订单要JOIN七八张表,性能惨不忍睹。
我的经验是:先满足业务,再谈范式,但不要反范式到失控。具体说就是:
- 核心业务表按业务域划分,不要过度拆分;
- 为了性能做冗余设计时,想清楚数据一致性怎么保证;
- 评估报表、统计、运营各种查询场景,再决定范式程度。
设计原则不是教条,是平衡的产物。
6.2 合理冗余:快照和反规范
合理冗余最常见的场景是“快照”。订单表里冗余商品名称和下单时价格,看起来违反了第三范式(商品名称在商品表里),但这是刻意的:商品改了价格和名称,历史订单需要保留下单时的快照,否则报表里历史订单金额对不上。
再比如用户表冗余用户的会员等级,虽然等级可以通过关联用户等级表查出来,但高频查询里省一次JOIN,性能提升明显。关键是要控制冗余的边界:冗余字段必须是一对一关系、低更新频率或只读型快照。如果一个冗余字段频繁变化,那你就要考虑数据一致性怎么维护,是同步更新还是异步刷新。
6.3 冷热分离和大表拆分策略
表结构设计的高级阶段是考虑数据的生命周期。一张表数据量过大后,再好的索引也扛不住。常见策略:
- 冷热分离:订单场景中,90天前的订单访问频率很低,定期迁移到历史表或归档库。热表保持小体量,查询性能自然提升。
- 垂直拆分:一张表字段太多(比如超过40个),把不常查询的大字段拆到另一张表,通过主键一对一关联。这在用户表里很常见,比如把用户的扩展资料、个性签名这些低频字段拆到
user_profile表。 - 水平分表:数据量持续增长且无法归档的场景,按用户ID或订单ID取模分表。比如
order_0、order_1、order_2,路由规则根据分表键计算。
6.4 汇总表和预聚合
统计需求是数据库性能杀手。每次实时COUNT(*)或SUM(),在千万级数据量上都可能让数据库崩溃。我推荐的做法是建汇总表或宽表。
比如运营要看每日订单量,与其每次全表扫,不如搞一张日汇总表:
sql复制CREATE TABLE daily_order_stats (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
stat_date DATE NOT NULL UNIQUE,
order_count INT NOT NULL DEFAULT 0,
order_amount DECIMAL(18,2) NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
每日定时任务或者实时增量更新这张表,报表查询直接查一行数据,性能从秒级到毫秒级。这就是典型的空间换时间。
6.5 设计原则8条清单
| 编号 | 规范 | 说明 |
|---|---|---|
| 33 | 先满足业务,再谈范式 | 过度范式化导致深层次JOIN,性能受损 |
| 34 | 适当冗余减少多表JOIN | 冗余字段保持低更新频率,保证一致性 |
| 35 | 冷热字段垂直拆分 | 低频大字段拆到扩展表 |
| 36 | 大表垂直拆分为多表 | 字段过多时按业务域拆分 |
| 37 | 大数据量水平分表 | 按业务主键取模或按时间分片 |
| 38 | 统计场景用汇总表/宽表 | 预聚合比实时计算高效得多 |
| 39 | JSON字段用于非核心弱关联配置 |
不要把所有动态属性塞进JSON |
| 40 | 表和字段必须有注释 | 没有注释的表结构是欠债的开始 |
6.6 JSON字段的使用边界
第39条单独说一下。MySQL 5.7之后支持JSON类型,很多开发很喜欢把一个对象的全部属性塞进一个JSON字段,省事。但JSON字段有几个问题:无法走索引(除非用虚拟列)、更新时需要读改写、查询时不容易做条件过滤。
JSON字段的正确使用场景是:非核心的、弱关联的、结构不固定的配置信息。比如商品表的扩展属性,不同品类属性不一样,用JSON比建几十个扩展字段合理。但不要把所有业务字段都往JSON里塞,否则后期想按某个属性统计时,你会发现SQL写得想哭。
7. 常见问题与排查技巧实录
7.1 分页查询越来越慢的优化方案
分页慢是面试高频题,也是实际项目最常见的性能问题。LIMIT 100000, 20这种写法,数据库会扫描前100020行,然后丢弃前10万行,只返回最后20行。偏移量越大越慢。
优化方案是延迟关联:
sql复制-- 优化前:慢
SELECT * FROM user_order
WHERE status = 1
ORDER BY id DESC
LIMIT 100000, 20;
-- 优化后:快
SELECT t.* FROM user_order t
INNER JOIN (
SELECT id FROM user_order
WHERE status = 1
ORDER BY id DESC
LIMIT 100000, 20
) tmp ON t.id = tmp.id;
核心原理是内层子查询只查主键,不需要回表,扫描成本大幅降低;外层再按主键关联取整行数据。如果业务改成游标分页(WHERE id > 上一页最大ID LIMIT 20),性能更好,但需要产品上配合。
7.2 字符集不一致导致的乱码和报错
字符集问题看起来小,踩坑的人非常多。建库建表一定要统一utf8mb4,不要用老旧的utf8(实际是utf8mb3,不支持emoji)。更要命的是,两张表字符集不一致,JOIN时索引会失效,甚至直接报“Illegal mix of collations”错误。
建表时显式指定字符集:
sql复制CREATE TABLE user_order (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
order_no VARCHAR(32) NOT NULL,
PRIMARY KEY (id)
) ENGINE=InnoDB
DEFAULT CHARSET=utf8mb4
COLLATE=utf8mb4_unicode_ci;
这里utf8mb4_unicode_ci和utf8mb4_general_ci的差别不大,选一个固定用就行,关键是全库统一。
7.3 逻辑删除遇到唯一索引冲突的完整解法
前面第4.3节提过这个问题,这里再说一种更优雅的实现。假设用户表需要逻辑删除,同时手机号唯一:
sql复制CREATE TABLE user (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
phone VARCHAR(20) NOT NULL,
deleted_at DATETIME DEFAULT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uk_phone_deleted (phone, deleted_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
插入时deleted_at为NULL,删除时写入当前时间。因为唯一索引允许多个NULL,所以可以:
- 同一个有效手机号只能有一条
deleted_at IS NULL的记录; - 已经删除的手机号可以反复注册(每次删除的
deleted_at时间不同,不冲突)。
查询有效用户时统一用WHERE deleted_at IS NULL,这样索引也能利用起来。
7.4 表结构评审时我必问的5个问题
最后分享一个我的工作习惯。每次评审表结构,不管是谁设计的,我都会问这5个问题:
- 这张表的主键怎么生成的?自增还是分布式ID?
- 哪些列是高频查询条件?有没有对应的索引?
- 所有字段类型是不是贴合业务语义?有没有什么东西都能塞的
varchar(255)? - 逻辑删除和唯一约束怎么配合的?会不会冲突?
- 这张表三年后数据量大概多大?分表和归档方案想好了吗?
这五个问题过一轮,表设计里90%的隐患都能暴露出来。我在实际项目中就是用这个方式帮团队避掉了很多雷,有一次还真问出了一个致命问题——一张流水表居然没有唯一索引,导致同一笔订单重复插入,最后靠加唯一索引才堵住漏洞。
数据库表设计这份功夫,属于典型的“前期多花十分钟,后期省十小时”。40条规范看起来多,真正理解每条背后的场景后,你会发现它们串起来就是一个完整的设计思路:先想清楚业务语义和命名,再定类型和字段,然后规划索引和扩展,最后考虑数据生命周期。把这套思路内化成习惯,你设计的表不仅能支撑业务,还能在未来几年里经得起迭代折腾。
