先说一个我经历过不止一次的现场:项目上线三个月,平时风平浪静,某天下午客服突然反馈后台打开要十几秒,技术群里开始接龙报接口超时。查了一圈,最后定位到的不是代码问题,而是数据库设计原则从一开始就没守住——该拆的表没拆,该建的索引没建,字段类型随手写的,主键甚至用了业务编号。那天我一边看慢查询日志一边想:如果当初把表结构当业务协议来设计,而不是当临时容器,可能根本不会有这一出。
这篇文章我想系统聊一聊这些年做数据库设计时沉淀下来的原则和踩过的坑。我知道很多人对数据库设计的第一反应是“范式”“主键”“索引”这些零散知识点,但实际项目里真正要命的,往往是这些知识点之间怎么取舍、怎么配合。无论你是刚接触后端的新人,还是已经被线上事故折磨过的老手,这篇文章都值得花十分钟慢慢看,因为我说到的每一条,基本都是在真实项目和事故里检验过的。
1. 先想清楚一件事:表结构是在定义业务协议,不是随手存数据
1.1 建表前先回答业务问题
很多新人建表,上来就照着实体画字段:用户表就是 id、name、phone,订单表就是 id、user_id、amount。不能说错,但这是“给数据找容器”的思路。实际工作中,表结构的本质是一份业务协议,它定义了这个业务里有哪些实体、实体之间什么关系、谁有权限写入、谁会被高频读取、数据生命周期多长。
所以我拿到一个建表需求,会先让自己回答几个问题:
- 这个表服务于哪个业务流程?比如订单表服务于下单链路、支付回调、对账、售后,四个不同场景对字段和索引的要求完全不同。
- 谁会往这张表写数据?是业务系统、定时任务还是数据同步工具?写入频率和并发量大概多少?
- 谁会读这张表?读取条件是什么?是单条查询还是范围统计?
- 数据量级和增长速度是多少?决定了要不要考虑分表、归档。
- 字段是业务产生的,还是系统派生的?比如订单金额来自外部计算结果,下单时间来自应用服务器,而 id 永远是系统分配的代理主键。
这些问题的答案,直接决定表结构怎么设计。比如一个用户表,如果只知道“存用户信息”,你可能就会把头像、昵称、收货地址全堆在一个表里。但要深入业务场景就会发现,收货地址是独立实体,用户可以维护多个地址,如果塞进用户表,等“默认地址”“发货地址历史”这些需求来了,你就只能改造表,而改造线上表的代价通常远超你的预期。
1.2 字段来源决定约束规则
字段的业务属性决定了它的约束规则。来源稳定的字段,可以放心建唯一约束;来源不可控的字段,建约束反而会成为线上频繁报错的地雷。
举一个真实的例子。某项目需要做“用户手机号登录”,产品提的需求是“一个手机号只能注册一个账号”,于是开发者直接在 users 表的 phone 字段上建了唯一索引。上线当天没问题,第二周就炸了——有人注销账号后,拿同一个手机号去注册新账号,直接被唯一索引挡住。因为注销逻辑做的是物理删除,删除之后唯一索引应该放行,但如果是软删除,这条记录还留在表里,唯一索引就变成了永久锁死的状态。这就是典型的“没有想清楚字段来源和约束策略”导致的线上问题。
所以我建议建表阶段就把字段的约束规则写在设计文档里,哪怕只是简单标注“phone 唯一,但需支持删后重注册”,后面开发、测试、运维对接时都能少踩很多坑。
2. 范式与反范式:教科书和线上环境到底信谁
2.1 三大范式到底在说什么
先花一分钟把范式说清楚。第一范式要求字段原子性,也就是一个字段不再拆分成多个值;第二范式要求消除部分依赖,典型场景就是复合主键下,某个字段只依赖于主键的一部分;第三范式要求消除传递依赖,简单说就是非主键字段之间不能有依赖关系,比如“部门名称”依赖“部门编号”,而“部门编号”依赖“员工编号”,这就产生了传递依赖。
教科书会告诉你,遵从范式可以避免数据冗余和更新异常。这句话本身没问题,它讨论的是数据逻辑一致性问题。但实际业务系统的瓶颈往往不是磁盘空间,而是查询路径变长之后带来的性能代价。范式化是把数据拆得更细,查询时需要关联的表就更多,而多表 join 恰恰是分布式场景里最容易炸的点,也是单机数据库优化的重灾区。
2.2 生产环境为什么经常“反着来”
我见过最典型的一个项目,订单表严格按照第三范式设计,商品名称、商品价格、商品快照都存在另一张商品表里。当时设计得很开心,上线后运营需要“查看历史订单里当时买了什么”,结果每次查询都要把订单表、订单明细表、商品表三张表 join 起来,商品还被商家改过名、下过架,历史订单显示的内容全变了。
后来我们做了一个违反范式的决定:在订单明细表里冗余了“商品名称快照”“下单时单价”等字段。表面上看数据冗余了,但它彻底解决了“历史订单展示内容随商品信息漂移”的问题。这个冗余的本质是:商品名称、价格在一张订单里被写死后就不再变,它们是“历史事实”;商品表当前的信息是“可变现状”。两者语义完全不同,混在一张表里才会出问题。
所以范式与反范式之间的关键不是“哪个对”,而是你要判断哪些数据是业务里不可变的事实,哪些是随时间漂移的状态。不可变事实值得冗余和快照,可变状态才需要规范化维护。我通常的做法是:先按第三范式把模型理清楚,再针对高频查询路径、历史快照场景做有意识的冗余设计,而且每一次冗余都写清楚原因,避免变成毫无章法的大宽表。
这里要注意“大宽表”和“有目的的冗余”是两回事。大宽表是不管三七二十一,把所有字段堆一起,A 字段和 B 字段可能在语义上互相覆盖,更新时还可能不一致。有目的的冗余是经过建模后再做局部裁剪,每个冗余字段都有明确的业务解释。
3. 主键设计的隐形陷阱:自增、UUID、雪花ID的取舍
3.1 自增主键:简单可靠,但别让业务字段掺和进来
自增主键是单机数据库最经典的选择。它最大的好处是写入时索引页基本是顺序追加,B+树的叶子节点向右扩展,避免了大量随机写导致的页分裂和碎片化。而且它对开发完全透明,DBA 也好排查问题。
但它有两个明显的坑。第一是数据量跨库扩展时,多个库的自增值会冲突,需要引入全局发号器或改雪花方案。第二是自增主键会暴露业务规模,比如你的订单 id 是 1000001、1000002,竞争对手大致能估出你的订单量。对很多内部系统来说这无所谓,但对面向公开市场且数据敏感的行业,这一点需要考虑。
我强调一个更常见的原则:永远不要拿业务字段当主键。手机号、身份证号、订单编号、车牌号,这些字段表面上看起来唯一,但它们是业务数据,受业务规则影响。手机号会换,订单号可能被业务方要求重置,身份证号涉及隐私合规。一旦这类字段当了主键,后续所有关联表、缓存 key、分库分表路由都会跟着遭殃,改一次就是一次大手术。正确做法是单列代理主键 + 唯一约束兜底业务唯一性。
3.2 UUID 与雪花ID:分布式环境下的纠结
UUID 看起来很美好,客户端就能生成,不需要发号器。我见过一个开发团队为了省事,全表主键都用 UUID 字符串,结果上线一个月后,某张核心表的查询开始明显变慢。原因是 UUID 完全无序,主键索引的 B+树不断发生随机插入,导致页分裂频繁,索引碎片严重,缓冲池命中率还低。
如果实在要用 UUID,请选 UUIDv4 之外的单调递增或可排序版本,但数据库侧仍然面临字符串比较比整型慢的问题。所以我的建议是:分布式环境下优先用雪花 ID 或类雪花方案,64 位整型,趋势递增,既解决了分布式生成,又不会像 UUID 那样破坏索引性能。当然雪花方案也有自己的问题,比如依赖机器时钟,时钟回拨会导致 ID 重复,所以需要一个配套的发号服务来处理回拨,这属于额外的运维成本。
单事务、单库存、内部系统,直接自增就很好;多机房、大并发、开放平台这类场景,值得多花力气搭分布式 ID 服务。还有一点容易被忽略:主键一旦上线,基本就不能动了。引用它的外键、缓存键、消息队列里的消息体、历史备份数据,全绑在上面。所以主键选择一定要放在设计阶段最前面来定,而不是等表建好了再改。
4. 索引不是越多越好:从一次慢查询看索引设计逻辑
4.1 一次 900ms 的订单查询是怎么发生的
某个交易项目的订单列表页,运营和用户都要频繁按“用户+时间范围+状态”查询,一开始记录数是几十万,查询也就几十毫秒。随着业务增长,订单量破千万后,这条查询直接到 900ms 以上,接口超时频繁。
排查慢查询日志发现,SQL 的 where 条件是 user_id + status + created_at,而线上只有两个单列索引,分别是 status 和 created_at。数据库最终选择了 status 的单列索引,因为 status 的区分度不高,大量订单都是“已完成”状态,扫描出来的记录几十万条,再逐条回表判断 user_id,性能自然就崩了。
这个案例很有代表性。索引设计的第一步不是“给每个 where 条件建索引”,而是收集真实查询模式,提取高频过滤条件组合,然后针对组合建联合索引。
4.2 联合索引的列顺序与最左前缀
联合索引要遵守最左前缀原则。比如你建了 (user_id, status, created_at) 联合索引,那么 user_id = 某个值、user_id + status、三个字段一起的查询都能命中;但如果直接查 status = 'paid',这个索引就废了。
列顺序的核心规律是:区分度高的列放前面,如果区分度都高,再看哪个字段被稳定地作为等值查询条件。因为区分度高的列放在前面,可以更快地把数据收敛到少数索引页。举例来说,user_id 区分度通常远高于 status,所以 (user_id, status, created_at) 就是合理顺序。反过来的 (status, user_id, created_at) 在绝大多数查询里都不好用。
设计索引时还要考虑“覆盖索引”的收益。如果这条查询只需要读取 select 里的几个字段,而且这几个字段都在索引里,就可以避免回表。比如上面的查询 select user_id, status, created_at, amount,如果 amount 也在索引里,连回表都省了,速度会提升得非常明显。实际项目中,覆盖索引是性价比最高的优化手段之一。
4.3 让索引失效的隐形杀手
有几个人人都容易踩的隐形杀手:
- 在索引列上做函数运算或表达式运算,例如 where date(created_at) = '2024-01-01',这会让索引直接失效,正确写法是 where created_at >= ? and created_at < ?。
- 隐式类型转换,比如 string 类型的 phone 字段,查询时传了整型,数据库自动做类型转换,索引失效。
- 前导模糊查询,like '%关键词%' 无法走索引,只有后置模糊 like '关键词%' 能走。
- 大多数数据库里某列有大量 NULL 时,索引的选择率也可能受影响,需要做特殊处理。
关于索引维护,我还要多提一句:索引不是免费的。每个索引都会拖慢写入速度,占用额外存储,增加优化器的选择成本。建索引要从查询反推,而不是从字段出发。我个人的习惯是先跑一周慢查询日志,把高频慢查询归纳成十几个查询模式,再统一设计索引集合,而不是写一条 SQL 就加一个索引。
5. 字段命名与数据类型:三年后你会感谢当初的选择
5.1 命名规范:可读性是对未来同事的善意
表名字段名看起来是小事,实际上影响深远。统一的命名规范应该做到“扫一眼就知道这是什么”。我的建议是表名单数风格、字段名小写加下划线,例如 order、product、user_address,避免出现 Order、UserAddress 这种大小写不一致和保留字撞名的问题。曾经有个项目把表名定为 order,结果每次查询都要给 order 加反引号,因为它是数据库保留字,这种坑完全没有必要踩。
字段命名上,user_name、user_phone、order_status 一定比 uname、uphone、ostatus 更清晰。命名规范不是教条,而是降低沟通成本。一个新同事接手的表如果命名清晰,他不需要问你也能猜出字段含义。反之一堆缩写连你都忘了什么意思,维护成本就会成倍增加。
5.2 数据类型:先算一算再选
数据类型的选择,核心是“够用且有余量”:
- 整型:状态、数量、计数,用 int 或 bigint 按实际量级选,但作自增主键或预计会大扩展的字段,直接用 bigint 更稳妥。曾经有个项目主键用 int,业务增长到十亿级别后接近上限,迁移数据类型的过程非常痛苦。
- 金额:必须用 decimal,不要用 float/double。浮点数在二进制里无法精确表示十进制小数,累计对账时会出现几分钱的差异,这种问题线上查起来极其崩溃。decimal(10,2) 表金额,decimal(18,4) 表汇率和单价,按业务需求定精度。
- 时间:选 datetime 还是 timestamp 要看时区策略。timestamp 存储范围到 2038 年,而且会自动受时区影响;datetime 范围更大但不带时区信息。多人协作、跨国业务场景需要明确统一时间规范。
- 字符串:varchar 长度不要随手 255。过长的 varchar 可能导致行溢出、索引性能下降。应该根据业务上限计算合理长度,比如手机号 varchar(20) 足够,昵称 varchar(64) 通常够用。
字符集我建议统一用 utf8mb4,它能存 emoji 和生僻字,兼容性更好。排序规则选择也值得注意,不正确的排序规则会导致字符串比较行为不同,影响范围容易被忽视。
5.3 空值语义:null 不是万能的
null 和三目运算符一样,设计不好就是代码里的定时炸弹。某字段是“未填”还是“空字符串”,在语义上完全不同。最直观的问题是唯一约束:在大多数数据库里,多个 null 不会被认为是冲突值。若字段默认值为 null,业务需要一个空的手机号占位,唯一约束就形同虚设。另一个问题是查询,where phone = '' 查不到 null;where phone is null 也查不到空字符串。业务代码里就不得不对两种状态分别处理,导致判断逻辑乱七八糟。
我的建议是:凡是业务有明确“未设置”状态的字段,统一用默认值而非 null,并在设计文档里说明字段的取值语义。布尔字段用 0/1,数字字段用 0,字符串字段用空字符串,时间字段视业务决定,不要混着来。
6. 这些通用字段不能省:审计、软删除与版本号
6.1 审计字段是事故定位的生命线
我这里说的审计字段指 created_at、updated_at、created_by、updated_by 这类元信息字段。几乎每张业务表都值得加,成本极低,收益关键时候救命。曾经有个项目出现了“订单金额被修改没人认账”的事故,排查半天,发现表里根本没有 updated_by,而 updated_at 还被某种批量任务频繁覆盖,完全失去了参考价值,最后只能翻应用日志,费了很大劲才定位到定时任务里的 bug。
要避免这种问题,一是创建时写入、更新时刷新 created_at/updated_at,不要把一张表的更新时间改成“每次查询都计时”;二是 updated_by 应当记录真实的操作主体,是人还是哪个服务;三是对金额、状态等关键字段做变更历史记录,最少也要有流水表或 JSON 快照。
6.2 软删除的取舍和隐藏炸弹
软删除是那种“用得好很顺,用不好天天出事”的设计。需要审慎考虑。适合软删除的场景是数据有长期留存需求:用户、订单、账单、审核记录。不适合软删除的是纯日志、中间过程、短期缓存类数据,物理删除更简单干净。
软删除最大的坑就是前面提过的唯一索引冲突。删除一条 phone 唯一记录后,同一 phone 再注册一条新记录,如果老记录只是标记 deleted_at,唯一索引会拦下新插入。常见解决方案有三种:
- 唯一约束列包含 deleted_at,但 deleted_at 为 null 时多个 null 不冲突,删除后填时间戳,这样同一唯一值在未删除时可以唯一,删除过之后可以重新插入。
- 单独加一个 is_active 标记,和唯一字段一起做联合唯一索引,但活跃数据和非活跃数据生命周期不同,查询时要注意过滤。
- 软删除和唯一需求冲突时,直接放弃唯一的数据库约束,把唯一校验放到应用层。这个方案需要额外代码兜底,但确实绕开了所有数据库层面的限制。
我得提醒一句:软删除字段本身要建索引,因为大量查询都会带上 deleted_at is null 过滤条件,否则全表扫描逃不掉。
6.3 乐观锁版本号:并发更新的兜底网
正确加版本号字段(通常叫 version),可以在 UPDATE 语句中带上 where version = N,更新后 version 加一。如果影响行数为 0,说明数据已经被其他事务修改过,应用层可以选择重试或提示用户刷新。这个机制在账户余额、库存扣减这类写冲突敏感的场景里很关键。
拿库存来说,最简单但错误的做法是先查库存,判断够不够,再减库存。两个请求同时查到库存为 1,各减一次,最终库存变成负数。加了 version 之后,第一个请求更新成功,第二个请求的 where 条件不成立,影响行数为 0,业务就能安全兜底。这个方案比悲观锁 select for update 成本低,并发吞吐高,特别适合读多写少、冲突率低的系统。
7. 事务边界与并发控制:同样的代码为什么线上出问题
7.1 隔离级别与锁:先搞懂这些再做事务
事务的 ACID 四个特性大家都知道,但实际出问题的地方往往在隔离级别。现在常用的关系型数据库默认隔离级别要么是读已提交,要么是可重复读。在不同隔离级别下,脏读、不可重复读、幻读的出现情况不一样。
脏读是指读到其他事务未提交的数据,读已提交级别以上就不会出现。不可重复读是指同一事务里两次读取同一行,结果不一样,因为其他事务提交了更新。幻读是指同一事务里两次范围查询,结果集变多或变少,因为其他事务插入或删除了范围内的数据。
这些概念抽象,落到业务里就是:一个账户余额在两处扣减,一个订单在两处被更新状态,如果没有正确控制隔离级别和加锁,线上就会出现“明明查了余额,扣的时候余额却变了”的诡异问题。
7.2 事务边界:能短就不要长
很多新手写交易接口,习惯从接收请求开始就把整个业务包进事务,包括查库存、查用户、调外部服务、扣余额、记流水。这就是典型的“大事务”。大事务一旦中途调外部接口超时,数据库连接会被长期占用,锁也被长时间持有,后面的请求全部堵在锁等待上,线上表现就是接口越来越慢,最后连接池耗尽。
我踩过一个大坑:某个交易系统把“调用短信通知服务”也放在数据库事务里面,短信服务偶发超时三秒,数据库事务就挂起三秒,锁被晾着,高峰期直接拖垮整个下单链路。后来把短信这类外部 IO 放到事务提交之后,事务只保留读写数据库的核心步骤,问题立刻解决了。
事务边界的设计原则:事务里只保留必需的数据库写操作,外部调用、耗时计算、读快照这些都放外面。如果能接受最终一致的方案,宁可多做一步异步补偿,也不要为了强一致性把所有操作都塞进一个大事务。
7.3 锁顺序:死锁的根源与规避
死锁的常见原因是多个事务以不同顺序加锁。比如事务 A 先锁订单再锁用户,事务 B 先锁用户再锁订单,两个事务在并发时互相等待,就死锁了。数据库会自动回滚其中一个事务,但应用层如果不做重试,用户就会看到偶发的失败提示。
我的习惯是在代码里统一加锁顺序,比如凡是同时操作订单和用户,永远先锁订单再锁用户。或者在事务里一次性把需要的资源全部锁好,避免边处理边加锁。这个规则看起来简单,真正落地需要团队统一,尤其在多个服务、多个代码仓库之间,最容易出现顺序不一致的问题。
8. 一张设计评审清单与两个典型事故复盘
8.1 我最常用的一张表结构评审清单
每次设计或评审表结构,我都会按下面这份清单过一遍,基本能把 80% 的低级隐患挡在发布之前:
- 主键选择是否合理?是否用了业务字段当主键?有没有考虑分布式扩展?
- 唯一约束语义是否正确?能容忍软删除和重复注册吗?
- 每类高频查询是否都有匹配的联合索引?有没有明显的隐式转换、函数失效风险?
- 字段类型是否满足够用且有余量?金额是否用了 decimal?时间字段是否统一时区策略?
- 命名是否自解释?有没有保留字和缩写混乱?
- 冗余字段是否有业务理由?是否写入了设计文档?
- 软删除策略是否统一?和唯一约束是否冲突?
- 审计字段、版本号要不要加?
- 事务边界是否能保持短小?是否存在外部调用和长事务耦合?
- 数据增长预估是否需要分区、归档或分表?
8.2 项目复盘一:余额表设计失误的连锁反应
某账户模块,一开始想要实时余额和账户流水,就把余额字段直接存进了流水表。流水表每天百万级写入,查询余额时需要先按账户聚合再取结果,订单高峰期聚合查询消耗巨大,账户余额刷新有延迟。后来还发现金额字段用了浮点类型,对账时出现小数点错乱。
重构后的方案是拆成“账户表(冗余当前余额)”和“流水表(记录每笔变更)”。账户表保存最新余额,流水表每笔写入都记录变更前余额、变更后余额。余额更新和流水插入放在同一事务里,从始至终保证数据一致。查询余额只查账户表,统计流水单独走流水表。重构后接口耗时降了一个数量级。
这个项目让我真正理解了一个道理:账目和流水是不同抽象层次的数据,不能混在一起。
8.3 项目复盘二:多对多中间表缺少唯一约束
另一项目做权限模块,用户和角色之间是多对多关系。中间表只有自增 id、user_id、role_id。权限系统经常需要重新同步用户角色,同步任务写得不够幂等,连续跑两次,中间表里用户和角色的关联记录就重复了。重复数据一旦出现,权限判断接口里又要去重,又影响效率,整套逻辑变得格外脆弱。
修正方案也很常规:中间表建联合唯一索引 (user_id, role_id),写入时用 insert ... on conflict do nothing 或先查后插,保证幂等。给关联查询条件建好联合索引,然后把自增 id 保留作为代理主键。这个问题不是技术难度问题,而是设计阶段根本没有把“幂等写入”和“唯一性”当成中间表的必备能力来考虑。
8.4 我现在看表结构的顺序
在踩了大量坑以后,我养成了一套看表结构的习惯。先看主键和字段类型,再看命名,然后看约束,接着从慢查询日志反推索引设计,最后对着事务代码评估边界长短。每一轮评审都是带着“数据会怎么变、业务会怎么扩展、查询会怎么走”这三个问题去看的。
数据库设计的难,不完全在于懂多少原理,更在于能不能把原则落到真实业务场景里去做取舍。该冗余的地方不冗余、该索引的地方不索引、该加约束的地方不加约束,这些问题比“不会写 SQL”危险得多。这张清单和这些复盘,是我个人在若干项目里反复印证过的东西,分享出来,希望你能少踩几个我当初踩过的坑。