ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

数据库设计原则实战:从范式到分库分表,手把手设计博客系统

数据库设计原则实战:从范式到分库分表,手把手设计博客系统 开头写了一段实打实的经历——一次被性能问题逼着重构的经历从里面提炼出数据库设计原则为什么重要的逻辑。博文的章节我尽量往工程实践角度靠不讲空泛理论而是把范式、字段、索引、并发这些设计原则落到博客系统这个完整例子里最后也聊了聊国产库和非关系型数据库带来的新变化。原文里那些热搜词比如博客系统、达梦、向量数据库、分库分表我都自然融进对应章节了。数据库设计原则这一个话题网上讲的人很多但大部分要么是教科书式的理论堆砌要么是零散的经验碎片。写这篇东西我只有一个出发点把我这些年实际踩过的坑和验证过的做法整理出来。如果你是个刚入行没多久的开发者或者正在做课程设计、在准备数据库面试这篇文章应该能帮你少走不少弯路。先交代一下背景。我大概七年前接手过一个社区项目那会儿社区刚上线用户量不大数据库表结构是前任随手设计的。comments 表里有个字段存的是回复内容的 HTML整张表连个索引都没建全表扫描扛了大半年。后来用户量涨起来一条简单的评论列表查询要跑两三秒后台任务一跑整个库的 CPU 直接拉满。那次经历让我彻底明白了一件事数据库设计阶段的每一个决定都是后面几个月甚至几年里你要还的债。所以这篇博文我不打算只讲理论我会从一个完整的博客系统案例出发结合自己在真实项目里的实践把数据库设计原则拆开揉碎了讲清楚。包括三范式怎么用、反范式什么时候该用、主键到底选自增还是 UUID、字段类型怎么定、索引为什么不能乱建以及数据量大之后分库分表和并发控制要怎么考虑。最后也会聊聊在向量数据库、时序数据库这些新东西层出不穷的今天那些经典的设计原则还有没有用。1. 先搞清楚业务再谈表结构很多人做数据库设计第一步就打开 Navicat 或命令行开始建表。这个习惯其实害了很多人。我从第一次被线上故障教育过之后就养成了一个习惯设计表结构之前先把业务需求想透。1.1 需求分析阶段最容易跳过的三个问题你要建一套博客系统的数据库第一反应可能是先建一张 articles 表加个 title、content、author_id完事了。但如果只做到这个程度后面大概率要反复改表。真正负责任的做法是先回答三个问题。第一个问题这个系统的核心业务流程是什么对于博客系统来说是用户注册登录、写文章、查看文章列表、看详情、发表评论、点赞。每一个流程背后都对应着一组数据的产生和消费。写文章会产生文章数据评论会产生评论数据点赞会产生点赞记录。你的表结构必须能完整覆盖这些流程而不是东拼西凑想起来一张建一张。第二个问题每个业务对象之间是什么关系用户和文章是一对多文章和标签是多对多文章和评论是一对多用户和点赞的文章是多对多。把这个关系理清楚了表之间的外键、中间表、索引方向基本就有数了。第三个问题未来可能怎么变化这个问题最难但恰恰是设计原则里最值钱的部分。比如文章刚上线时只有单一类型但你知道未来可能有私密文章、需要定时发布、需要支持投稿审核那么在设计表的时候就应该预留 type、status、published_at 这类字段而不是等需求来了再加。加字段本身不难难的是存量数据要迁移线上服务要停机或灰度这些都是成本。1.2 把业务画成图设计就完成了一半我在做任何数据库设计之前都会在纸上画出实体关系图。不需要用什么复杂的工具甚至一张白纸一支笔就行。把实体画成方框把关系画成连线标注上一对多还是多对多。画图的过程本质上是在逼你想清楚业务的边界。比如文章点赞这个需求你可能会想点赞记录算一个独立实体吗如果只是数一下文章被赞了多少次那在 articles 表里加一个 like_count 字段就够了。但如果要判断当前用户是否点赞过就必须有一张点赞明细表记录 user_id 和 article_id 的对应关系。这两种设计的取舍直接决定了后面功能的复杂度和查询的代价。只在文章表里加个计数字段做起来最简单但你想展示当前用户是否点过赞就麻烦了。建点赞明细表功能实现很自然但文章列表要显示点赞数时就得多一次聚合查询数据量大了还得考虑计数缓存。所以你看数据库设计从来没有绝对的对错本质是权衡。但权衡的前提是你把业务吃透了。没有这一步后面所有的设计原则都只是纸上谈兵。2. 范式与反范式教科书没讲透的权衡逻辑谈到数据库设计原则三范式是绕不开的。市面上的教材都喜欢给你列定义第一范式要求字段原子性第二范式要求非主键字段完全依赖于主键第三范式要求消除传递依赖。这些定义背下来容易但关键是要明白它们到底在解决什么问题。2.1 范式真正想解决的两件事数据冗余和更新异常我比较喜欢用一个记账本的例子来讲范式。假设你有一张账单表字段包括订单编号、商品名称、商品单价、购买数量、客户姓名、客户电话。你会发现商品单价其实由商品决定客户电话其实由客户决定。如果同一个客户买了十次东西他的电话就被存了十遍。有一天他换号了你要改十处——漏改一处数据就不一致了。这就是更新异常。三范式设计会把这张表拆成商品表、客户表、订单表和订单明细表。商品单价只存一份客户电话只存一份以后要修改只改一处全局一致。范式化最大的价值就在这里它保证了数据的单一事实来源消灭了冗余带来的不一致风险。2.2 但实际工程里完全不冗余的库几乎不存在可是你要是真的严格按三范式设计一套线上系统的库很快就会发现问题查询太慢了。为什么因为范式化拆分之后你要查一个订单列表必须先关联客户表、商品表、甚至还要聚合订单明细表里的金额。每个关联都是一次磁盘 IO关联越多查询越慢。我印象很深的是一个电商报表需求。领导要的是每个客户累计消费金额排行按三范式来设计这需要关联客户表、订单表、订单明细表三张表而且随着数据量增长这个统计查询会越来越慢。后来我们的做法是增加一张客户消费汇总表定期从订单明细表聚合数据写进去如果不额外建 transaction_item 汇总表直接跑订单明细的 group by几百万行数据的时候每次报表刷新都能把数据库打满半天。这个故意冗余的做法就是反范式。它违反了第三范式的消除传递依赖原则但换来的是查询性能的大幅提升。实际工程里下面这几种反范式设计非常常见在文章表冗余一个作者昵称字段避免每次查询文章都要关联用户表在商品表冗余一个已售数量字段避免每次查询都要去订单明细表做 count在订单表冗余一个订单总金额字段避免每次查询都要聚合明细表这些字段确实冗余但如果你能通过代码逻辑保证它们的正确性比如在用户改昵称时同步更新文章表里的作者昵称在订单创建时更新商品已售数量那么冗余带来的性能收益就远大于数据不一致的风险。2.3 判断该用范式还是反范式的标准我自己在实际工作中总结出了一个判断标准看这个字段是描述自身还是描述关系。订单明细表里的商品单价虽然逻辑上可以关联商品表查询但它是订单发生时刻的真实交易价格它描述的是这笔交易的属性所以冗余下来完全合理。文章表里的作者昵称虽然在逻辑上属于用户表但它是文章展示时的核心信息而且变更频率极低冗余下来也很划算。相反如果某个字段需要频繁更新并且一旦不一致会造成严重后果那就不该冗余老老实实规范化。把这个标准记清楚你在设计表结构时的很多纠结就迎刃而解了。3. 一套博客系统的库表设计全过程理论部分聊得差不多了我现在用博客系统这个例子带着你完整走一遍库表设计的过程。这正好也是很多人做数据库课程设计时最常见的题目。3.1 从需求文档到实体关系图假设现在要给一个博客系统做数据库设计需求是用户能注册登录能发布文章和管理自己的文章文章可以打标签用户可以查看文章列表和详情可以评论可以点赞。管理员可以审核文章。我建议你先把实体列出来。粗看有 5 个核心实体用户文章标签评论点赞再理关系用户和文章是一对多文章和标签是多对多用户和评论是一对多文章和评论是一对多用户和文章通过点赞形成多对多关系。注意多对多关系在关系型数据库里不能直接表达必须拆成中间表。所以标签和文章之间需要一张文章标签关联表用户和点赞文章之间需要一张点赞表。3.2 表结构和字段设计细节实体关系理清楚之后就进入具体的建表环节。这里我直接给出核心表的字段设计顺便解释每个字段为什么这么定。users 用户表idbigint unsigned主键自增。用户量不大时 int 够用但既然要写新系统直接上 bigint 更稳妥usernamevarchar(50)唯一索引。登录名长度限制 50 是因为绝大多数场景下用户名不会超过这个长度同时索引也更好维护password_hashvarchar(255)存密码哈希不是明文。注意哈希值长度不定给足 255 防止切割nicknamevarchar(50)展示昵称statustinyint用户状态0 禁用 1 正常。设计成数字枚举方便扩展比如 2 表示待激活created_atdatetime创建时间updated_atdatetime更新时间articles 文章表idbigint unsigned 主键自增author_idbigint unsigned外键指向 users.id同时建普通索引。查询某用户的所有文章会高频用到titlevarchar(200)文章标题contentlongtext正文内容。文章正文不是等长数据用 text 类型合适summaryvarchar(500)摘要。列表页要展示摘要单独存一个字段避免每次都截取 content 前 N 个字这是个典型的可接受冗余字段statustinyint文章状态0 草稿 1 已发布 2 已审核 3 已下架like_countint点赞数默认 0。冗余字段避免每次展示都要去点赞表 countcomment_countint评论数默认 0。同样是为了列表页性能published_atdatetime nullable发布时间。草稿为空发布后写入定时发布也靠它created_atdatetimeupdated_atdatetimetags 标签表idbigint unsigned 主键namevarchar(50)唯一索引标签不能重复created_atdatetimearticle_tags 文章标签关联表article_idbigint unsignedtag_idbigint unsigned联合主键 (article_id, tag_id)这样同一篇文章不能重复打同一个标签同时也天然给 article_id 建了索引comments 评论表idbigint unsigned 主键article_idbigint unsigned外键建普通索引user_idbigint unsigned外键建普通索引contentvarchar(1000)评论内容created_atdatetimelikes 点赞表idbigint unsigned 主键article_idbigint unsigned外键user_idbigint unsigned外键created_atdatetime联合唯一索引 (article_id, user_id)保证一个用户对一篇文章只能点赞一次。这个唯一约束非常关键它是在数据层做的防重比应用层判断再插入要可靠得多3.3 字段类型选择的一些实战经验设计字段类型时常见的坑我一个个给你排第一整数类型要按需选。user_id 用 int 还是 bigint取决于你对用户量的预期和未来的扩展计划。我的建议是主键和外键统一用 bigint unsignedint 在 40 多亿的边界上真的会撞到。别问我怎么知道的我就是见过线上 int 主键自增到溢出的例子。第二varchar 长度别乱给。很多新手喜欢把所有字符串都定义成 varchar(255)实际上一张表里几百个字段全是 varchar(255)索引膨胀得很厉害。varchar 的长度会影响索引的大小和查询效率所以宁可稍微紧一点也别无脑给 255。用户名 50、标题 200、摘要 500这些都是有依据的不是拍脑袋。第三datetime 比 timestamp 更适合做通用时间字段。timestamp 范围到 2038 年而且有时区转换的坑datetime 没有这些问题。如果你要做的业务有明确的时区需求可以在应用层统一处理数据库层用 datetime 存绝对时间即可。第四text 和 blob 类型的字段要注意。blob 是二进制内容搜索引擎不会自动包含text 类型字段不能有默认值而且参与 group by 或 distinct 时会有额外限制。所以像文章正文这种字段设计的时候就要想到它不应该出现在 where、order by、group by 条件里查询它的时候应该尽量延迟加载。3.4 索引设计的先后顺序很多人设计表的时候根本不考虑索引等到线上查询速度变慢了才回头加。我的经验是索引一定要在设计阶段就想清楚因为索引和查询模式是强绑定的表都建完了再想索引很多查询已经改不动了。对于博客系统这套表索引设计可以按这个顺序来主键索引每个表都有这个是自动的唯一索引用于保证业务上的唯一性。比如 users.username、tags.name、likes 表的 (article_id, user_id) 联合唯一索引外键关联字段索引比如 articles.author_id、comments.article_id、comments.user_id高频查询字段索引比如按 published_at 查询已发布文章列表时可以建一个包含 status 和 published_at 的组合索引索引顺序是 (status, published_at)组合索引的顺序要遵循最左前缀原则。比如你要查某个用户的所有已发布文章那组合索引顺序就应该是 (author_id, status, published_at)这样既能按用户筛选又能按状态筛选还能在这个基础上排序。如果顺序反了索引就用不上查询就会走全表扫描。4. 命名、主键、时间字段最容易埋雷的三个细节如果说范式、表结构、索引属于数据库设计的骨架那命名规约、主键策略、字段统一约定这些东西就是血肉。它们看起来不影响功能实现但直接影响一个项目的长期可维护性。4.1 命名规约为什么值得较真在一张表上面最直观的设计原则就是命名。我见过太多项目表名一会儿单数一会儿复数字段一会儿下划线一会儿驼峰同一个字段在不同表里叫的名字都不一样。这种项目维护起来谁碰谁崩溃。分享一下我习惯的命名规约表名用复数比如 users、articles、comments因为一张表存的是多个实体字段名统一小写下划线风格比如 created_at、like_count避免驼峰的歧义也方便在 SQL 里直接书写主键统一叫 id外键用关联表名_关联主键的格式比如 author_id、article_id、user_id状态类字段统一叫 status用 tinyint 枚举同时在代码里用常量或枚举类定义每个值的含义不要裸写 0、1、2布尔类字段统一加 is_ 或 has_ 前缀比如 is_deleted、is_published时间字段统一用 created_at 表示创建时间、updated_at 表示更新时间不要今天写 create_time明天写 gmt_created这些约定本身并不复杂难的是从一个项目的第一个表开始就坚持下去。只要有一个人的表名用了单数后面的人就会迷茫。所以从一开始建表的规范就应该是团队共识的一部分。4.2 主键选择自增、UUID 还是雪花主键怎么选是数据库设计里争议最大的问题之一。三种方案我都用过给你说说我的体会。自增主键最简单性能也最好因为 InnoDB 的聚簇索引按主键顺序排列插入时顺序写不需要频繁移动数据页。它的缺点有两个一是分布式场景下不同库的表主键会冲突二是主键会暴露业务数据量比如你的订单号就是自增的竞争对手通过订单号差值就能估算出你的订单量。UUID 主键解决了分布式唯一和防水表的问题但又有新问题。UUID 是随机字符串作为聚簇索引时插入顺序完全随机会导致频繁的页分裂性能下降明显。所以如果一定要用 UUID建议用 UUID 的二进制版本或者经过排序处理的版本。雪花算法是我个人在分布式场景下用得最多的方案。它是 64 位整数包含时间戳、机器标识和序列号既保证了全局唯一又保证了时间上大致有序对索引性能相对友好。缺点是生成逻辑需要在代码里实现或者依赖单独的 ID 服务。如果你做的只是个传统单体项目自增主键完全够用没必要为了高级感去引入 UUID 或雪花。选型的原则永远是够用就好不要过度设计。4.3 逻辑删除和时间字段的统一约定另一个容易埋雷的细节是逻辑删除。我的建议是几乎所有的业务表都保留 is_deleted 字段值用 0 和 1 表示未删除和已删除。为什么要逻辑删除而不是物理删除因为用户的误操作需要恢复审计追踪需要数据留痕还有外键约束之下物理删除一条记录可能引发连锁问题。但逻辑删除也有它的代价。所有的查询语句都要带上 is_deleted 0 条件漏掉一个就会出事故。为了尽量让这个约束自动化很多团队会在 ORM 层配置全局的过滤规则比如 MyBatis-Plus 的逻辑删除插件这样写代码时就不用手动加条件了。时间字段的统一约定也很重要。除了 created_at 和 updated_at我建议在需要上架/下架语义的表里增加对应的生效时间字段比如刚才说的 published_at。另外提醒一点updated_at 不应该在每次应用层更新时手动赋值而是应该让数据库在 UPDATE 时自动更新MySQL 里可以设置 on update CURRENT_TIMESTAMP 或者由 ORM 框架自动填充这样这个字段才真正可靠。5. 数据量上来之后分库分表、并发与锁该怎么在设计里提前考虑前面讲的主要是单表设计但数据量到一定规模之后数据库设计要考虑的东西就不一样了。很多人在系统初期没想这些等到被迫重构时才发现代价巨大。5.1 分库分表不是越早做越好我先说结论不要过早分库分表。分库分表带来的是极其复杂的运维和开发成本包括跨库查询、分布式事务、全局主键、聚合统计等一堆麻烦事。你为一个几百万行的表做分表纯属给自己找不痛快。但你要在架构上留下扩展空间。怎么留两条原则。第一主键不要用简单的自增因为一旦分表自增主键的全局唯一性就没法保证了至少在数据库路由层要考虑这个因素。第二业务查询要尽量带上路由键比如用户维度数据都带上 user_id这样未来按 user_id 水平分表时绝大多数查询仍然能路由到单张表。真正需要分表的时机是单表数据量超过千万级别、或者单表容量影响写入和查询性能的时候。这个阈值不是绝对的跟你的存储引擎、硬件条件、查询模式都有关系。我见过有人三百万行的表就很慢了也见过几千万行的表依然跑得飞快区别就在索引和查询模式合不合理。5.2 并发锁与死锁要在设计阶段规避数据库并发控制这个问题设计阶段就得想。经典的例子是文章点赞功能。正常的点赞流程是先查点赞表有没有记录没有就插入然后 update articles 表把 like_count 加一。这个流程在高并发下会有问题两个请求同时发现没有点赞记录同时插入其中一个会因为唯一索引冲突而失败这是够好的场景。麻烦的是如果表没有唯一索引就会插入两条点赞记录like_count 加了两次数据就错了。所以点赞表那个 (article_id, user_id) 唯一索引本质上就是并发控制的第一道防线。再配合事务先插入点赞记录再更新计数要么都成功要么都失败就不会出现数据不一致。死锁的规避也是一样。经常出现的死锁场景是多个事务以不同顺序更新多张表。比如事务 A 先更新文章表再更新用户表事务 B 先更新用户表再更新文章表两个事务互相持有对方等待的锁就死锁了。解决的办法是让所有事务都按同一顺序更新表比如统一先更新文章表再更新用户表死锁自然就消失了。5.3 冷热数据分离的思路数据量大了之后还有一个常用的设计思路是冷热分离。大多数业务里用户关注的都是最近一段时间的数据。拿博客系统举例读者打开的文章大多数是最近发布的三年前的老文章访问量很低。这时候可以把一年前的文章归档到历史库主库只保留近期数据这样主库的表体积会大幅缩小查询性能自然提升。冷热分离的实现方式有几种最简单的是定时任务把旧数据搬到另外一张表或者另一个数据库还有一个思路是直接用 MySQL 分区表按时间分区查询时只扫对应分区。分区表对应用层是透明的看起来很美好但也有一些限制比如分区键必须是主键的一部分跨分区操作性能不一定好。我个人的倾向是业务逻辑明确、数据量可预期的情况下优先用归档表的方式逻辑更直观维护也简单。6. 数据库设计的新常态国产库、向量库与文档库的冲击最后聊聊这几年数据库领域的变化。技术圈现在很热闹国产数据库、向量数据库、时序数据库轮番登上热搜传统的数据库设计原则还能不能打我觉得值得认真想一想。6.1 国产数据库迁移中的设计适配达梦、人大金仓这些国产数据库在核心功能上和 MySQL、Oracle 比较接近大多数 SQL 都能兼容但迁移过程中还是有不少设计层面的适配问题。最典型的是自增主键的差异。MySQL 用 auto_increment达梦用 identity 或序列Oracle 早期甚至没有自增要靠序列加触发器。如果你原来用的是 MySQL迁移到达梦时自增主键这部分基本可以平滑迁顺序设置 identity 即可。但从 Oracle 迁到达梦时应用层大量的字符串函数、日期函数、分页 SQL 写法都需要调整。分页就是最明显的差异点Oracle 的 rownum 或者 MySQL 的 limit 在达梦里可能要用别的语法。另一个常见的坑是大小写敏感和字符集问题。MySQL 在 Linux 下表名大小写敏感字段名不敏感而达梦在默认配置下对大小写有自己的处理逻辑。迁移之前最好先在测试库完整跑一遍 SQL 兼容性检查确认所有 SQL 语法在目标库上都能正确执行。6.2 非关系型数据库的反设计如果说国产数据库是关系型内部的变种那向量数据库、时序数据库就是对传统设计原则的更大挑战。但也别慌仔细看会发现它们的设计原则并没有消失只是换了一种形态。向量数据库是这两年伴随 AI 应用火起来的核心能力是高效地存储和检索向量数据。它的表和字段设计思路完全围绕向量运算展开你不需要去设计外键、关联查询、事务最关心的往往是索引类型。比如你给商品图片生成 embedding 向量存进向量库之后要按相似度检索最相近的商品就会用到 HNSW 或者 IVF 这些专门的向量索引。这和关系型数据库里为 where 条件建索引是同一个逻辑都是让查询变快只是索引的结构和原理完全不同。时序数据库则是为监控指标、物联网传感器数据这类时间序列数据设计的。这些数据的特点是写入多、更新少、几乎只按时间范围查询。所以时序数据库在设计上就不太在乎三范式反而鼓励你把同一时间点的多条指标写在一行里用列式存储压缩用时间分区管理冷热数据。你去看它的数据模型会发现降采样数据保留策略这些概念其实和我们刚才讲的冷热分离思想一脉相承。6.3 设计原则的不变内核先理清数据关系和访问模式面对这么多数据库产品我觉得最值得记住的一点是任何数据库设计原则本质上都是在回答两个问题。你的数据实体之间是什么关系你的应用会以什么模式访问这些数据关系型数据库用表和关联模型来解决第一个问题向量数据库用高维空间的距离计算来解决第二个问题时序数据库用时间维度的聚合和压缩来解决第二个问题。技术选型可以五花八门但思考的起点永远是一模一样的业务需求。所以在决定用 MySQL 还是 MongoDB、用关系型还是向量库之前先把你的数据关系和访问模式画出来你就不会在技术选型上犯方向性错误。数据库设计这件事理论是骨架实践是血肉。我见过太多人把三范式背得滚瓜烂熟建表时却连字段注释都不写也见过有人张口闭口分库分表结果连个最基础的联合索引最左前缀原则都没搞明白。这篇文章里提到的很多细节比如字段类型的选择、索引的先后顺序、逻辑删除的约定、并发场景下的唯一约束单拎出来都不算高深但组合在一起决定了一个数据库设计是能跑还是经得起跑。我把博客系统那个例子从头到尾过了一遍就是为了让你看到每一次设计决策都不是拍脑袋拍出来的而是从需求、从查询模式、从未来扩展的可能性里反推出来的。这套思考方式比记住任何一个具体的建表语句都重要。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进