
我记得很清楚自己第一次独立设计数据库表结构的时候满脑子都是“字段齐了就行”。结果表是建出来了上线一个月就开始难受用户要按时间筛选发现日期存的是字符串要做数据统计发现状态字段中文英文混着来产品要加个会员等级发现得同时改五张表。那时候我才反应过来数据库设计根本不是“建几张表”的事它决定了你未来一年是天天写简单查询还是天天为了一张破表做各种补偿逻辑。这篇就是把这些年踩过的坑、沉淀下来的原则一次性整理清楚给正在做表设计或者准备重构数据模型的同学当个参考。很多人写数据库设计原则爱从理论开讲什么范式、什么完整性约束一套套的。我不打算这么来我更愿意按着“真实业务怎么推着你去设计表”的顺序把那些好用的习惯、容易翻车的细节、以及背后“为什么必须这么干”的逻辑讲明白。这里头没有一条是“为了规范而规范”每条背后都对应着至少一个我亲手填过的坑。1. 为什么数据库设计总是被拖到“最后一步”然后疯狂返工先说个我观察到的普遍现象大多数项目的开发节奏是“先写接口再补表”甚至有人是“代码跑通了才发现没地方存数据”这时候才回头建表。这种流程带来的结果就是表结构完全服务于当前这一版代码压根没考虑数据将来要经历什么。短期看效率挺高但“能用”和“好改”是两码事。1.1 表结构本质上是“永不卸载的接口”我后来想明白一个道理表结构比接口更像接口。接口升级了你大不了换个版本老客户端强制升级就行但一张表一旦上了生产里面有真实数据你要改字段类型、要拆表、要合并表每一步都得考虑数据迁移、历史数据兼容、还有那些你根本不知道在哪跑着的定时任务。改表的成本通常比改接口高一个数量级。所以设计表的时候你就得假设这张表要活五年、十年这期间业务规则变十次但表结构尽量只加字段、不改语义更不能推倒重来。这其实就是数据库设计原则的底层逻辑不是追求“当前最正确的模型”而是追求“未来最好改的模型”。我见过太多人为了“灵活”搞出一堆设计结果改一个需求要动六张表也见过保守到把所有东西塞一张宽表里的人加个字段就得锁表。走极端都不行得在中间找那个“未来变更成本最低”的点。1.2 设计不良的三类隐性代价细数一下表设计失败的代价基本归成三类。第一类是迁移成本。业务跑了一年突然发现订单表里把“会员ID”和“用户ID”混着存了想拆开得写复杂的清洗脚本还得担心拆的过程中产生脏数据。每次做这种迁移我都是加班到凌晨改完还要盯几天报表有没有异常。第二类是查询性能劣化。典型的例子是设计的时候图省事把逗号分隔的标签直接存一个字符串字段比如tags: 水果,生鲜,折扣。查询“所有生鲜商品”时只能全表扫一遍再 LIKE数据量到百万级就等着超时吧。这就是把“该在关系里表达的数据”塞进了标量里等于主动放弃了数据库的索引能力。第三类是数据质量失控。没有约束、没有规范同一个含义的字段在不同表里叫法不一样同一个状态在这张表是数字、那张表是字符串连应用层都不知道哪个是准的。最后的结果就是报表没人信系统里全是“历史原因”造成的脏数据。数据质量一出问题整个团队对数据的信任就崩了后面的分析、决策全部变成猜。1.3 把“原则”理解为“约束的保护”而不是“流程的束缚”有些开发一听“设计原则”就头大觉得是 DBA 或者架构师拿条条框框来限制自己。实际工作几年后我的体会正好反过来这些原则是保护你的。拿“数据类型严格”来说你存日期就用日期类型应用层想传个乱七八糟的字符串进来数据库直接报错这难道不是帮你挡掉脏数据吗拿“外键约束”来说虽然大厂高并发场景经常禁用物理外键但在中小体量的系统里一个外键能防止你写出“删了用户但订单里还留着孤立的用户ID”这种逻辑错误。我现在的态度很简单设计原则是“防守策略”它不保证你能做出最华丽的设计但能保证你不出大事故。出大事故的设计基本都有一条共性——设计者太相信人的“自觉”不相信约束。可实际上业务一变代码一乱人的自觉是最靠不住的。2. 先立规矩表与字段命名里的门道和纪律命名看着是小事实际上命名规范是表结构长期可维护性的第一道防线。我接手的每个烂摊子项目第一感觉就是“看不懂”a、b、temp1、test2这种表和字段比比皆是要不就是同一个含义今天叫createtime明天叫create_time后天叫gmt_create。就别提什么外键字段完全看不出指向哪张表的事了。2.1 表名的两个大方向单数还是复数前缀加不加关于表名单数还是复数社区吵了十年也没吵完。我的立场很明确统一用单数。user而不是usersorder而不是orders。理由很简单表是一个“实体集合”的模板你读出数据的时候是一个个对象ORM 映射的时候类名也都是单数。你顺手写SELECT * FROM users也没毛病但和代码的命名习惯一对照单数更顺。关键是团队里必须只有一种选择否则有人写user有人写users那就是灾难。第二个争议点是前缀。在一些老项目里你会看到t_user、tb_order、sys_config这种写法。我的建议是除非公司有统一规范否则别加t_前缀。前缀本身不携带信息纯属噪声。但“业务模块前缀”可以加比如订单域的表统一order_开头order_main、order_item、order_pay_record这样在茫茫表海里一眼就能认出同一个域的表尤其在库里有几百张表的时候这个前缀是真的能救命。反过来没有域概念全是user_info、user_address、user_credit_log这种以“主体”为中心的表也可以不加域前缀完全看团队习惯。核心是有规律、无例外、一眼懂。2.2 字段命名每个字段都要能读出“唯一含义”字段命名的第一原则是“含义唯一”也就是一个字段名在整个库里不能有第二种解释。最常见的翻车点是时间字段create_time、created_at、gmt_create、createTime都表示“创建时间”能不能统一我现在的库里强制所有表统一用create_time、update_time、delete_flag这种小写下划线风格应用层也不需要针对每张表写不同的映射规则。再说外键字段。凡是“指向某张表”的字段命名格式统一为“目标表名单数语义 _id”。订单表里指向用户的字段就叫user_id指向商品的叫product_id千万别叫uid或者pid——缩写节省的几个字符换来的是未来每次写 JOIN 都得翻表结构确认太亏了。布尔字段加is_前缀枚举/状态字段直接用状态本身的单词如order_status不要叫status_flag或者flag。这里我强烈建议所有“状态类”字段都配上注释写明每个枚举值代表的含义最好连业务流转关系也写进表注释里因为状态字段是后来者最容易猜错的东西。2.3 主键与逻辑删除的规范要统一主键命名没什么好说的但这里有两个容易犯的错。第一个是拿业务字段当主键比如用手机号、身份证号做用户表主键。业务字段一变你的主键就得跟着变还会引发一系列关联表的外键连锁更新。正确做法是搞一个与业务无关的代理主键业务唯一性用unique key兜底。第二个是不同类型的主键混用有的表用bigint有的表用varcharJOIN 的时候性能天然吃亏类型不统一也容易埋坑。逻辑删除我用得比物理删除多但也最怕没规矩。字段统一delete_flag0代表正常、1代表删除默认0加索引。千万别一个表叫is_deleted另一个表叫deleted值含义还分成0/1和Y/N。还有逻辑删除字段要放进唯一索引里做联合比如unique(user_id, delete_flag)否则用户二次注册时“明明删了却提示已存在”。2.4 用注释把“设计意图”留下来命名之余我最后想强调一个很多人忽略的动作写注释。字段注释、表注释必须写。一张表刚建出来时设计者当然懂字段含义但半年后、换了一个人、甚至换了一整个团队后没有注释的表就是天书。我经手过一个系统有个字段叫ext注释是空的问了三个当初参与开发的人三个人给出三种解释。自那以后我养成了一个习惯每个字段都写注释状态枚举直接列全0-待支付 1-已支付 2-已取消。这个动作多花 30 秒但能救后来人 3 小时。3. 范式不是面试八股它是帮你省钱的数学工具说到范式很多人的反应是“面试背过实际不咋用”。其实不是范式没用而是没把它放在“权衡”里去用。范式化解决的核心问题是避免数据冗余和更新异常。你想想同一份数据存在十几个地方那更新的时候就得同步改十几处漏一处就是脏数据。范式化的过程本质上是在“拆开”这种风险。但拆得太狠也有代价查询要 JOIN 一堆表性能变差。所以真正的功夫是知道什么时候拆什么时候故意不拆。3.1 三大范式用大白话讲一遍第一范式字段不可再分。说人话就是一张表的每个字段存一个独立含义的数据别在一个字段里塞一组值。比如hobbies: 篮球,足球就不符合尽管很多人这么干。第二范式非主键字段必须完全依赖于主键不能只依赖主键的一部分。这主要针对联合主键的场景。比如一张选课表的主键是(student_id, course_id)那你不能把“学生姓名”放进去——它只依赖student_id不依赖整个联合主键这就会导致同一个学生选了五门课他的名字在表里存五份改一次得改五行属于典型的“部分依赖”。第三范式非主键字段不能依赖于其他非主键字段。最经典的就是把“部门名称”直接存在员工表里。部门名称依赖于“部门ID”部门ID才是员工表的字段而部门名称跟员工ID没有直接关系。这属于“传递依赖”带来的问题同步修改成本高部门改名得 UPDATE 一堆员工行。3.2 用订单场景拆一遍该拆的必须拆举个我经常拿来做培训的例子——订单表。很多人第一版设计喜欢把所有能塞的全塞进一张表order_id, user_id, user_name, user_phone, address, product_id, product_name, product_price, quantity, total_amount, create_time。这表刚建出来的时候确实好使查询只要一张表全搞定。可后来麻烦了同一个用户下了十单user_name和user_phone存了十份用户改了手机号你得先找出他所有历史订单全部同步更新而且一旦漏更新历史订单显示旧号客服查单就对不上。按范式拆就是把“用户基础信息”拆到user表“订单主体”拆到order表只留id, order_no, user_id, status, total_amount, create_time订单里的商品明细拆到order_item表id, order_id, product_id, product_name, product_price, quantity。你发现没有product_name和product_price我故意留在明细表里了——这是“快照”思想商品名称和价格未来可能变但订单里的商品信息必须是你下单那一刻的。这不叫违反第三范式这叫合理冗余是为了业务正确性。这种拆法带来的好处立竿见影用户改手机号只需要 UPDATE 用户表一行商品改名不影响任何历史订单订单表和明细表之间用 JOIN 查询。代价是查询多了一次 JOIN但在绝大多数业务中这个代价远小于同步更新和脏数据的代价。3.3 什么时候“故意不范式化”才正确但范式化不是万能药。有些场景我设计时会刻意“反范式”。第一类是读多写少的聚合数据。比如商品详情页要展示“销量”和“评价数”如果每次请求都去 COUNT 订单和评价表数据库压力你扛不住。正确做法是商品表里冗余两个字段sale_count、comment_count下单和评价的事务里顺手 1。这种冗余换来的是查询性能十倍提升带来的风险是计数偶尔不准但绝大业务都能接受。第二类是日志和流水类数据。操作日志、登录记录这种数据几乎只读不更新也不会有人拿它做复杂的多表关联。这种情况下你甚至可以不设计主键、不用外键全字段随缘冗余重点是写入吞吐和查询效率不是范式。我见过矫枉过正的人给登录日志做三个范式拆分把用户信息、设备信息、IP 归属地全部拆开结果查一次日志 JOIN 五张表日志查询接口慢得没法用这是把范式用错地方了。第三类是关系型数据库里的“文档结构”。比如订单的收货地址快照用户可能改了地址但历史订单必须还是旧的。这同样不是“冗余失控”而是“数据版本化”的合理设计。3.4 判断要不要拆的那把尺子拆不拆我一般拿三个问题衡量这个问题多久会被更新一次每次更新涉及多少行查询是会更频繁还是更少如果答案是“经常更新、牵扯多行、查询不频繁”坚决拆“不更新、纯展示、查询很热”可以合理冗余。数据库设计原则从来不是铁律而是在写放大和读放大之间做取舍。想清楚这把尺子范式的“度”自然就出来了。4. 字段类型选错代价在三年后兑现字段类型是数据库设计里最“细碎”但又最影响长期使用体验的部分。很多人建表时随手varchar(255)到处用所有数字都上int时间一律datetime等数据涨起来、查询慢下来再回头改字段类型那就是一场生产事故级别的迁移。我建议大家在第一步就把类型选对省掉后面的苦。4.1 整数类型别用 int 装所有数字也别给手机号用 int整数的选择遵循“够用且留余量”原则。TINYINT是 1 字节范围 -128~127无符号 0~255状态码、数量、星级打分类这不是正好吗SMALLINT2 字节最大 65535无符号存年龄、库存余量都可以。INT4 字节最大 21 亿多大多数业务主键和数量字段都够。BIGINT8 字节分布式场景唯一 ID、雪花 ID、超大金额的累加值都得用它。这里有个反面教材拿int存手机号。手机号在数据库里永远用varchar因为你在 Java/JS 里拿数字处理容易溢出而且手机号根本不是“数字”不需要做加减乘除它只是一个有格式的字符串。用数字类型存储还可能导致前面有 0 的号码被截断这种低级事故。另一个反面教材是拿int存订单号——订单号是业务序列号可能蕴含日期/分库分表规则应该做成varchar或bigint绝不能图省事就int。4.2 小数类型金额必须 DECIMAL别拿 DOUBLE 跟钱开玩笑如果你用DOUBLE或者FLOAT存金额终有一天会被对账折磨死。原因很简单二进制浮点数无法精确表示大部分十进制小数比如0.1在二进制里是个无限循环小数存进去已经产生了误差。当你计算0.1 0.2时结果不是0.3可能是0.30000000000000004。日常展示你看不出来但累计到几千行订单流水对不平、总账差几分钱你找都不知道去哪找。金额、价格、费率、余额全部用DECIMAL。你只需要明确DECIMAL(m, d)里m是总位数d是小数位数。比如订单金额我一般用DECIMAL(10, 2)最大能表达 99999999.99基本满足绝大多数中小业务。如果涉及汇率这种特别需要精度的场景可以DECIMAL(20, 6)展示时再四舍五入。不要嘲笑用DOUBLE存金额的做法很多老系统就是这么活过来的但他们每年的对账脚本里一定少不了一大堆“抹零”“容差”的脏逻辑。4.3 字符串类型长度还是内容选 VARCHAR 还是 TEXT我用VARCHAR而不是TEXT的坚持源于一次线上故障。有张资讯表把正文存进了TEXT查询时有非常慢的ORDER BY create_time当时排查才发现TEXT字段作为辅助列会让 MySQL 使用磁盘临时表性能直接被拖垮。后来把TEXT拆分到独立的附属表才恢复正常。所以我的经验是能用 VARCHAR 的不用 TEXT。VARCHAR 可以指定长度并建立索引而 TEXT/BLOB 上建索引限制多、效率差。短内容、有索引诉求的全用 VARCHAR。VARCHAR 的长度也不是拍脑袋255了事。长度直接关系行大小和索引长度InnoDB 索引键最长 3072 字节utf8mb4下一个字符占 4 字节意味着你varchar(200)的字段建普通索引200×4 已经 800 字节还好如果搞 500 以上索引就建不上了。所以总结就是名称类给varchar(64)足够编码/序列号给varchar(128)手机号varchar(20)邮箱varchar(64)URL 可以varchar(512)。别有事没事就varchar(2000)既浪费空间又坑索引。只有一种情况用TEXT字段内容真的可能超过 64KB 且不需要索引但一般文章正文也才几十 KB这种场景极少数。4.4 时间类型DATETIME 优先警惕时区陷阱时间的存储很多新手会用字符串varchar存2024-06-01 12:00:00这是最让我头疼的操作。字符串时间无法做范围比较、无法做日期函数运算、也没法排序查询性能和正确性全是坑。MySQL 里建议用DATETIME或TIMESTAMP。两者区别DATETIME范围广1000~9999 年和时区无关TIMESTAMP范围到 2038 年存储跟随时区转换。业务系统我更倾向于DATETIME因为逻辑简单不会发生“应用层穿 UTC数据库转来转去”的错乱。还有一个“存时间戳”的流派直接用BIGINT存毫秒数这在一些大数据系统里看得到。如果你只是普通业务不建议这样因为“可读性”归零了排查问题还得把时间戳转回人类语言。我最后强调一个时区一致性的细节无论你选哪种类型全系统要统一同一个时区规则应用、数据库、日志、消息队列全部对齐到 UTC8 或者 UTC不然就会出现“我数据查出来差了 8 个小时”的诡异现象。5. 主键设计自增、UUID、雪花选哪个不只是技术偏好主键大概是数据库设计里争吵最多的话题三个阵营各有道理。我的建议往往让人失望没有银弹得看你的部署架构和业务规模。但我们可以把每个选项的适用边界讲清楚让你自己选的时候不用再纠结。5.1 自增主键中小系统的默认答案单库单表、TPS 不高的业务自增主键几乎是最优解。它的好处是天然有序InnoDB 是聚簇索引组织新插入的数据大概率追加到页尾减少了页分裂写入性能稳定主键索引体积小普通二级索引回表效率也高。还有一个隐含好处就是当你做“最近新增记录”这类查询时直接按主键倒序查就行速度极快。自增主键的担忧主要有两个一是并发高的时候通过auto_increment取号会成为热点但这在中小系统里远不到瓶颈二是业务数据可能被遍历抓取猜测 ID 就能爬走所有数据敏感业务可以加个随机数或者业务编号兜底。但总体而言我不建议为了“未来可能分布式”而提前放弃自增主键业务的复杂度往往是自己的选择不是技术逼出来的。5.2 UUID 主键性能是最大的坑但某些场景不得不选UUID 主键最大的问题不在“占空间”而在“随机性”。BTree 的叶子节点按主键有序排列如果你的主键是随机的 16 字节字符串那每次插入都可能落在已有页的中间位置导致大量的页分裂、随机 IO、以及索引碎片化。数据量小的时候无所谓到了千万级就是一个字卡。如果因为业务跨库合并、离线导入、需要“全局唯一”用 UUID最好做两个优化一是把 36 位字符串压缩成二进制BINARY(16)去掉连字符后再存能省一半多空间二是可以考虑“类 UUID”的有序方案比如时间排序的 UUID 变体UUIDv7既保持全局唯一又减少随机性插入性能比传统 UUID 好不少。另外用 UUID 做业务单据号展示给用户看是没问题的但展示 ID 和主键分开主键继续用BIGINT展示用随机业务编号两头的好处都占。5.3 雪花算法分库分表时代的主流选择当你的系统确定要分库分表每张分表的自增 ID 会冲突UUID 随机性的问题又无解雪花算法就会登场。它的核心是一个 64 位 LONG1 位符号位固定 0 41 位时间戳 10 位机器位 12 位序列号。从设计上它既保证了全局趋势递增时间戳在高位又通过机器位和序列号保证了同一毫秒内的并发不冲突。但雪花算法不是拿来即用落地时有两个坑第一机器位配置错了或者 ID 生成器重复就会产生重复 ID所以你得有完善的启动自检和上报机制第二时钟回拨问题——如果服务器的 NTP 对时导致时间倒退ID 生成器在“下一个时间戳”小于“上一个时间戳”时如果不处理就会重复发号。常用的措施是“等待回拨完成”或者“借序列号递增”或者“搞一个备用时钟源”总之这属于基础设施值得专门花功夫打磨。5.4 我的选择路径三分钟判断法我每次设计新系统的主键都按下面的判断路径走如果明确单库单表、不需要跨系统合并数据无脑自增 BIGINT。如果未来可能要分库分表但还没分也可以用 BIGINT后面做分片时再换算法如果系统已经确定了分布式架构或者业务实体天然就是跨区域的比如门店、IoT 设备上报直接用雪花但必须把“ID 生成器”做成独立组件而不是每个业务各自实现一套。至于 UUID我基本只接受它做“非主键业务编号”做主键的场景很少除非这个表本质上是“同步合并型”的——比如多终端离线写入后再汇总这种场景用 UUID 或者基于 UUID 的变体反而省心。一句话总结主键的终极目标是“稳定、唯一、趋势递增、占用小”四选三就不错四选全是理想世界但现实中我一般是取“唯一 趋势递增 小”这三个。6. 索引设计提升查询性能的习惯不是事后的补救索引设计是否合理决定了你的数据库是“越跑越快”还是“越跑越慢”。但是我对索引的要求一贯是要设计而不是堆砌。很多人的习惯是“查询慢了就加索引”哪里慢了加哪里结果索引越来越多、写入越来越慢查询也没快多少典型的得不偿失。正确的姿势是在建表阶段就根据“查询模式”规划好索引而不是等慢查询日志把你打疼了再补救。6.1 联合索引的顺序不是想放谁就放谁联合索引是新手最容易犯浑的地方。比如电商订单列表页常见查询条件是WHERE user_id ? AND status ? ORDER BY create_time DESC。这时候联合索引应该怎么建大多数人想都不想直接搞(user_id, status, create_time)就完事。对这个查询来说这个索引确实能走因为你条件里前面的列都用上了排序字段也在索引内性能最佳。但如果你还有一个高频查询是WHERE status ? ORDER BY create_time DESC这个索引就失效了因为联合索引最左前缀原则要求查询条件必须从第一列开始你跳过了user_id直接用status索引就废了。我的方法是先收集这个表上所有高频查询按“等值条件”出现频率排优先级等值条件最常出现的列放最左边范围条件大于、小于放中间排序字段放最后面。然后针对极端重要的单独查询再考虑额外建立专有索引而不是试图用一个大索引满足所有场景。索引设计本质是“用空间换时间”但空间也不能乱换。6.2 索引失效的常见案例隐式类型转换、函数包裹、LIKE 前置通配设计索引是一回事让它生效是另一回事。我总结过最常见的索引失效场景每一个都在真实系统里碰到过。隐式类型转换字段是varchar你查询时传入的是数字123MySQL 会自动把字段转换成数字再比较导致索引失效。常见于电话号、订单号这种 varchar 字段上使劲查的场景。解决办法就是应用层参数一律带引号传字符串。函数包裹列WHERE DATE(create_time) 2024-06-01你以为是范围查询实际上对索引列做了函数运算索引就失效了。正确写法是WHERE create_time 2024-06-01 00:00:00 AND create_time 2024-06-02 00:00:00这样能走索引。模糊匹配前置通配LIKE %手机%因为通配符在开头索引无法从中间开始匹配只能全表扫。但LIKE 手机%这种前缀匹配就能走索引。方案是如果业务真的需要中间匹配可以上全文索引或者外部搜索引擎数据库硬扛不是一个正确方向。OR 连接WHERE a 1 OR b 2除非两个字段都有独立索引并且优化器能用 index merge否则很可能全表扫。把 OR 改成 UNION 或者拆成两条 SQL通常更可控。6.3 覆盖索引的妙用不深入了解数据库的人可能不理解“回表”的概念。简单说InnoDB 的二级索引叶子节点存的是主键值你查到二级索引后还要拿主键再去主键索引里把整行数据捞出来这个“再捞一次”就是回表。如果你查询的列正好都在二级索引里就无需回表这叫“覆盖索引”。典型例子一张大表查询SELECT user_id, status FROM order WHERE status 1如果你建了(status, user_id)联合索引查询需要的两个字段都在索引里直接就能返回。这在高频统计、列表分页场景很有用。说白了就是牺牲一点写性能换取“查询全部走索引”的效果这笔账绝大多数时候划算。6.4 别光想着查得快也要想想写得动最后一定要泼一盆冷水索引不是免费的每个索引在每次 INSERT/UPDATE 时都要同步更新索引多了写放大严重。一张 1000 万行的表多加一个索引插入耗时可能从几十毫秒涨到几百毫秒。所以索引设计必须“克制”一张表哪怕查询再复杂我也很少让索引数超过 5~6 个超过这个数先回过头检查是不是表设计就没拆干净或者 JOIN 条件写得太随意。索引不是万金油它只是对“查询模式”的二次建模真正决定查询复杂度的还是表结构本身。7. 高频业务模型的拆解一对多、多对多、状态机前面讲的是“原子原则”这块我想用几个高频业务模型把设计串起来你会更容易理解那些原则怎么落地。这些模型你几乎在每个系统里都会遇到掌握了它们等于拿到了 70% 业务表设计的地图。7.1 一对多关系用户-订单、订单-明细的标准拆法用户和订单是一对多这是最基础的模型。拆法也很标准用户的属性留在user表订单的主信息订单号、下单用户、总金额、状态、下单时间放order表订单里的每个商品明细放order_item表。这里有一个“千万不要做”的操作把明细 JSON 化存在订单表的一个字段里items: [{...}]。如果明细永远不会被单独统计、查询、聚合那也许能撑一阵子但只要是做电商明细一定需要被统计哪个商品卖得最好、哪个仓库出库多少JSON 存进去就全毁了。宁可拆表多 JOIN也不要 JSON 一坨。这是我经历过的最痛的教训之一全天下的优惠券分摊、对账、退款全建立在明细行的粒度上你把明细吞进单个字段就是把自己逼上绝路。7.2 多对多关系用户-角色-权限的中间表艺术用户和角色是多对多角色和权限也是多对多标准的拆分是“用户表、角色表、用户角色中间表、权限表、角色权限中间表”。中间表的核心职责就是记录“谁和谁有关系”所以最少只要两个外键字段。但实际设计时中间表常常需要“额外字段”。比如用户角色中间表里加一个create_time权限分配审计要用加一个source区分是手动分配还是系统默认。这里我建议两点一是中间表一定要有独立主键哪怕你业务上觉得联合主键就够了独立主键方便后期按单条记录操作二是中间表上要建立联合唯一索引比如unique(user_id, role_id)防止同一关系被插两遍。还有一点很多人觉得“用户-角色”中间表未来不会变化就不重视实际上权限模型是所有系统里最容易膨胀的部分——今天加个部门角色、明天加个数据权限范围字段设计时留一点冗余字段空间但不滥用比后面一次次 ALTER 要舒服得多。7.3 状态机别只存一个字段记录流转历史更安全订单有“待支付、已支付、已发货、已完成、已取消”这个状态流转是典型的有限状态机。表设计上至少要有“状态字段”用于当前状态的快速检索和判断如果你要做审计、要做超时自动关单、要排查“这个订单为什么变成取消”最好再加一张“状态流转记录表”order_id, from_status, to_status, operator_id, reason, create_time。只存最新状态时间久了你会完全失去历史有了流转记录表问题定位和数据审计都变得清晰。我见过一个反面设计把状态存在一个varchar里还允许NULL配合一个“上一状态”字段逻辑混乱到看三个月都理不清。状态机表设计的要点是当前状态用短整型或短字符串存流转历史分开记状态枚举值和业务规则在注释里写清楚。做到这三件事即便未来状态增加也就加个流转记录的事表结构基本不用动。顺带说一句状态字段默认值要设置不要允许NULL否则应用层的判断逻辑会各种绕。7.4 树形结构邻接表适合大多数场景闭包表留给强查询需求树形结构分类树、组织架构、菜单树是另一个高频模型。最常用的方式是“邻接表”表里一个parent_id指向父节点简单直观查儿子容易但查整棵子树得递归在 MySQL 8 之前只能多次查询或应用层递归。如果你的树深度很小2~3 层邻接表完全够用查询也不慢。如果树特别深、或者经常要“查某个节点下整棵子树”闭包表closure table更合适单独建一张“节点关系表”存所有“祖先-后代”对查询子树时一次 JOIN 完成。代价是插入/删除节点时要维护关系表。个人建议是80% 的业务场景用邻接表就够了闭包表属于少数高查询强度需求的正解不要一上来就闭包过度设计同样会让自己后续维护崩溃。8. 那些让我反复改表的翻车现场以及你现在就能避开的雷前面讲了很多“应该怎么做”下面用几个我真实的翻车现场来反向说明。这些案例没有一个是高深的理论问题全是细节但每个都让我在深夜改数据改到怀疑人生。8.1 把状态存成字符串还夹杂着中文我接手过一个老系统订单表的状态字段是varchar(20)存的是待付款、已付款、PAID、1这种大杂烩。数据是不同时期不同人写的查询统计只能 CASE WHEN 一个个清洗。我最终花了一整个迭代把字段统一改成tinyint枚举并且加 CHECK 约束或者应用层白名单校验才把这坨东西收拾干净。教训是什么状态字段从第一天就要用短数字或者短英文枚举而不是中文字符串中文适合做展示不适合做存储。8.2 手机号存 varchar 却允许 NULL手机号作为登录账号有人为了“有些人没绑手机号”就把字段设成NULL可空。结果呢用户表里 NULL 和空字符串混着来唯一索引对 NULL 不生效——因为 MySQL 的 UNIQUE 索引允许多个 NULL 值导致同一个手机号可以绑定到多个账号直接破坏了账号唯一性。正确做法是真正的手机号字段保持 NOT NULL 且加唯一索引没绑定的用关联表记录或者设一个默认不可用的状态来区分。不要拿 NULL 当“没有”的意思NULL 在不同场景语义模糊应用的判断逻辑会因此变得复杂。8.3 订单金额用 DOUBLE对账对到怀疑人生这个我前面已经提到过但值得再说一遍金额用 DOUBLE 存看起来“够用”实际对账时出现 0.01 的差异你根本说不清是代码 BUG 还是浮点误差。我们用 DECIMAL(10,2) 重构后一夜之间对账差异全消失了。奉劝各位涉及钱、分数、费率的字段没有任何犹豫余地直接 DECIMAL这不是性能问题这是正确性问题。8.4 一张大表塞太多字段锁范围变大、缓存命中率下降很多业务喜欢“宽表崇拜”所有属性一张表。当表的字段超过 50 个甚至上百个时问题来了一行数据体积巨大InnoDB 一个数据页能装的记录数变少缓存命中率下降更新任意字段要拿行锁锁的就是整行并发更新不同字段互相阻塞全表扫描的 IO 开销巨大。解决办法是拆成“核心表 扩展表”核心表存高频、少变、必查的字段扩展表存低频、多变、大字段。别怕 JOINJOIN 的成本通常远低于宽表带来的锁竞争和 IO 放大。8.5 没有考虑历史数据归档分区成了事后诸葛亮前几年有个系统的订单表没有任何分区数据 3 年后膨胀到几亿行日常查询被历史数据拖慢线上慢查询一堆。后来只能加班做数据归档方案把老订单迁到历史库在线库只保留近一年数据。如果当初建表时就按月分区PARTITION BY RANGE (YEAR(create_time) * 100 MONTH(create_time))或者设计好冷热分离的归档策略根本不会这么痛苦。我现在的原则是只要表的数据量会随时间增长建表时就要把“生命周期管理”纳入设计——要么提前按时间分区要么明确归档方案别等把数据库拖垮了才后悔。数据不光是“存下来”还要想清楚“怎么淘汰”这是很多新人不会去想的维度。9. 把设计原则化为日常习惯一个可执行的检查清单讲了这么多估计有些朋友有点晕。最后给一套我自己每次建表都会过的检查清单项目紧张的时候照着清单过一遍至少能挡住 90% 的常见坑。表归属哪个业务域表名是否遵循统一命名规范是否加了必要的模块前缀主键选型是否和目标架构匹配单库单表优先自增有没有业务字段误当主键每个字段类型是否准确金额/费率是否 DECIMAL状态/枚举是否短整型时间是否 DATETIME字符串长度是否克制每个字段是否都有注释状态字段是否完整列出所有枚举值是否考虑到逻辑删除和统一时间字段全库是否一套命名哪些高频查询会打到这张表联合索引顺序是否按最左前缀原则设计有没有多余的、查询用不到的索引是否有需要冗余的聚合字段冗余字段的更新路径是否明确且可控树、多对多、状态机等特殊模型是否按对应方式设计中间表的联合唯一索引有没有加数据生命周期这张表要不要分区需不需要提前规划归档写完建表 SQL 之后用 EXPLAIN 跑一遍核心查询确认索引真的生效而不是觉得“应该能生效”。这个清单是我自己整理的看起来细但每一条背后都有真实的坑。你真做到每条都有明确答案这张表基本就是“三年后还能接着用”的样子了。最后我还想多嘴说一点。做数据库设计最关键的其实不是技巧而是“对数据负责”的心态。你设计一张用户表意味着未来上千万行用户数据都活在这套结构里你愿不愿意为一个字段多花两分钟想清楚它的生命周期决定了一年之后你是在优雅地加字段还是在狼狈地倒数据。这个选择每个做开发的同仁都值得认真对待。