做需求转数据模型这件事,我干了快十年,发现大多数项目最后出问题,不是代码写得烂,而是需求到数据模型的这一步垮了。需求评审时大家拍着胸脯说“逻辑很清晰”,真到了建表阶段,才发现业务规则互相打架、字段语义含糊、关系说不清楚,一张订单表里塞了十几个可空字段,谁看了都头疼。
这篇东西想聊的就是“需求”和“数据模型”之间那段最容易被忽略的路:怎么从一段口语化的业务描述里,提炼出实体、属性、关系和规则,再落成一张能撑住业务十年演进的数据模型。适合产品经理、后端开发、数据仓库工程师,还有那些正在被“需求评审很顺利、上线后天天改表”折磨的团队。这里不会给你一堆纸上谈兵的原则,我会用一个实际的在线课程订单系统案例,把从需求文档到数据库建表的全过程走一遍,该给的步骤、该避的坑、该抄的模板都有。
1. 需求到数据模型:完整拆解思路与设计前提
1.1 需求文档里到底要挖出什么:实体、属性、关系、规则
很多人拿到需求文档,第一反应是看功能列表、看页面原型,然后就开始琢磨字段了。这种做法特别容易漏东西。数据模型的真正输入不是“页面上显示什么”,而是“业务里到底有哪些对象、它们怎么互相作用、每一步的约束是什么”。
我在实际工作中会把需求拆成四层去读:实体、属性、关系、规则。实体是业务世界里客观存在的对象,比如用户、课程、订单、支付流水、退款单;属性是这些对象自身的特征,比如用户有手机号、课程有定价、订单有支付状态;关系是实体之间的关联方式,一个用户能有多个订单,一个订单包含多门课程;规则是最容易被漏掉的层,比如“课程下架后不可购买”“退款金额不能超过实付金额”“同一用户可以多次购买同一门课程但不能同时存在两个待支付订单”。
这四层里面,前三层决定了你建几张表、表里有什么字段,规则层决定了你加哪些约束、状态机怎么流转、哪些组合要建唯一索引。如果你在需求阶段只整理出了实体和属性,没整理规则,那数据模型上线之后基本上逃不过补丁叠补丁的命运。
1.2 概念模型、逻辑模型、物理模型各管哪一段
数据模型设计通常分三层,这个分层不是教科书上的花架子,它解决问题的角度完全不同。概念模型是业务视角,回答“业务里有哪些东西”;逻辑模型是设计视角,回答“这些东西之间怎么关联、字段怎么定义”;物理模型是实现视角,回答“在具体数据库里怎么建表、建索引、定类型”。
我见过不少开发直接跳到最后一步,打开 Navicat 就 CREATE TABLE,边写边想字段。这相当于盖房子不画施工图直接砌墙。你确实能砌出来一栋房子,但窗户可能对不齐、承重墙可能打在了奇怪的位置。正确做法是先画概念模型,把实体和关系理清楚,再转逻辑模型,确定每个字段的语义和约束,最后才落地物理模型考虑性能。
举个日常的例子:概念模型里“订单”和“课程”是多对多关系,因为一个订单可以包含多门课程,一门课程也能出现在多个订单里。到了逻辑模型,你要把这个多对多拆成“订单主表 + 订单明细表”两张表;到了物理模型,你还要考虑订单明细表是不是需要分区、要不要按订单号建索引。每一层都有自己独特的决策点,混在一起做,一定会顾此失彼。
1.3 把业务规则翻译成数据约束:这几条最容易漏
业务规则翻译成数据约束这件事,我称之为“需求文档里最值钱的部分”,但也是最容易被跳过的地方。你可以把数据模型理解成业务规则的执行者:规则写在代码里,靠自觉遵守,换个开发就失效;规则写在数据库约束里,谁写数据都绕不过去。
以在线课程订单系统为例,常见的业务规则有这么几条,每条都应该落到数据模型上的某个具体位置:
| 业务规则 | 数据模型落地方式 |
|---|---|
| 用户注册手机号必须唯一 | users.phone 建唯一索引 |
| 订单号全局唯一且业务可读 | 订单号字段建唯一索引,或直接用业务订单号做主键 |
| 一个订单至少包含一门课程 | 订单明细表加 CHECK 约束或者在应用层保证,至少订单表不能出现明细为空的孤儿订单 |
| 支付金额必须大于 0 | orders.pay_amount 加 CHECK 约束 |
| 同一用户同一课程不能有两个待支付订单 | 在订单表加唯一索引(user_id, course_id, status)但要注意部分唯一,MySQL 8.0.19 之前需要靠生成列变通 |
| 退款金额不能超过实付金额 | 应用层校验 + 退款表记录原始订单实付金额做对照 |
这些规则里,有的是建个唯一索引就完事,有的需要应用层配合。但无论如何,你必须在建模阶段就把规则写进模型设计文档里,而不是等到写业务代码时再想起来。我见过最惨的一个项目,退款规则没在数据模型层面考虑,结果同一笔订单被用户提交了三次退款申请,财务那边差点做出三笔退款。这种问题代码写得再严谨也难防,因为入口太多,只有数据层兜底才靠谱。
2. 核心实操:实体识别、关系建模与字段设计
2.1 实体识别:名词法、事件回溯法、边界划分
实体识别看起来简单,真正做起来最大的坑是“把不该当实体的东西当实体,或者把该当实体的东西漏掉”。我有三个方法配合着用。
第一个是名词法。把需求文档里所有名词圈出来,用户、课程、订单、优惠券、支付记录、退款单、学习进度……这些名词先列出来,再去掉那些明显是属性修饰的名词,比如“课程价格”里的“价格”应该挂到课程下,而不是单独建一张表。这里的原则是:独立名词且有自己的生命周期,才配当实体,否则就做属性。
第二个是事件回溯法。顺着业务主线走一遍:用户浏览课程,下单,支付,系统开通课程,用户学习,申请退款,系统审核退款。每一个“动作”背后都有产生的结果。下单会产生订单,支付会产生支付流水,开通课程会产生订单与课程的关联记录,退款会产生退款单。这些动作产生的结果就是实体。这个方法特别适合需求文档写得含糊的场景,你只要追问“这个动作发生之后,系统里多了一条什么记录”,实体就浮出来了。
第三个是边界划分。这是我后面专门学的,也推荐给你:实体不是越多越好,不同业务域之间共用的实体要提前分清楚。比如用户和课程是两个业务域,订单属于交易域,交易域只需要引用用户ID和课程ID,不需要把用户表的全部字段拷进来。如果把用户表二十多个字段全部宽表化到订单表里,短期查询方便了,长期数据一致性就是个无底洞。先确定每个实体属于哪个业务域,再决定它跟其他域实体之间是“引用”还是“复制”,这一步思考到位了,模型的整体质量就有保障。
2.2 属性设计的关键决策:字段粒度与多值拆分的取舍
实体的字段设计,天天都在做,但不一定每次都做对。核心有两个决策点:字段粒度怎么定,多值属性拆不拆。
字段粒度,说的是一个字段该存多细的数据。用订单表举例,“收货地址”这个描述可以拆成省、市、区、详细地址四个字段,也可以只存一个 address 字符串。拆得细,后续做区域统计、物流分单就方便;不拆,写的时候省事但查询时只能用 LIKE 模糊匹配。我的经验是:如果需求里明确提到了“按省统计”“按城市筛选”,那就拆;如果只是收件人查看,存完整字符串也行。但还有一种更稳妥的做法:兼顾两者,详细地址存一个字段,省市区单独存三个字段,这部分冗余完全值得。
多值属性,说的是某个属性在一个实体上可能有多条记录。比如课程的“适用人群”,可能是“在职人员、在校学生”两个值。很多人图省事,直接在课程表里加一个 apply_people 字段,存一个“在职人员,在校学生”的字符串。这在短期看着方便,但一旦你后期需要按人群筛选课程,就会发现字符串拆分的噩梦开始了。正确的做法是单独建一张课程人群关系表,或者至少用 JSON 字段存储,配合数据库的 JSON 查询能力。这里我强烈建议:凡是需要过滤、统计、关联的属性,都别用逗号拼接,否则你迟早要写 migration 拆表。
2.3 关系建模:一对多、多对多到底该建几张表
关系建模是整个数据模型设计里最需要“经验感”的部分。一对多最简单,用户和订单就是典型的一对多,只需在“多”的那一侧加外键。一对一的场景要停下来想一想,是不是真的有必要拆表,比如用户表和用户扩展信息表,如果只是个别字段,直接放同一张表加可空字段就行,拆表反而增加 JOIN 成本。
麻烦的是多对多。拿在线课程订单来说,订单和课程就是多对多,一个订单包含多门课程,一门课程可以被多个订单包含。这种关系在逻辑模型里必须拆出一张中间表,也就是订单明细表。很多人纠结中间表要不要带自己的主键,我的建议是:如果中间表上还有自己的业务行为,比如购买价格、退款状态,那就一定要带独立主键,之后你会用到它的。这个道理,等你需要按订单明细维度去对账、退款、开发票时就会理解。
还有一种容易犯的经验性错误,就是把业务上的一对多误建成多对多。比如课程和讲师,一门课程可以有多个讲师,一个讲师可以教多门课程,看起来是多对多,但如果业务规则限定“每门课程最多三位讲师且讲师有主讲和助教之分”,那你需要的不是纯多对多,而是带角色属性的关联表。设计关系时,一定要回到业务规则问清楚:这个关系本身有没有属性?如果有,恭喜你,你必须做一张带属性的关联表。
2.4 通用字段和软删除:要不要 extends BaseEntity——我的答案
现在谈数据模型绕不开通用字段的问题:created_at、update_at、deleted、version 这些要不要每个表都放。我的答案是:每个表都放,而且建议在业务表里统一做。
created_at 和 updated_at 是必备的,这是排查数据问题时的第一现场证据。很多时候用户报“我昨天晚上下的单不见了”,你查日志查半天,最后发现 update_at 变了才定位到是某个定时任务改了数据。没有时间字段,这种问题基本没法查。
deleted 字段就是软删除,这也是我要重点说的。很多团队一开始坚持物理删除,理由是“数据不会错,删了就删了”。但现实是,一个订单删除后,用户投诉说“我以前买过这个课程,怎么订单记录没了”,你根本没法解释。我这些年做过的绝大多数业务系统,最终都回到了软删除这条路。唯一需要提醒的是:软删除字段一定要参与所有业务查询的过滤条件,否则等于没有;另外,凡是有软删除的表,唯一索引必须把 deleted 也一起放进去,否则就会出现“同一手机号删了之后再注册,唯一索引直接冲突”的低级事故。
version 字段主要是给乐观锁用的。如果你的业务里有“多个终端同时修改同一条数据”的场景,比如用户提交订单、后台修改订单状态,那 version 几乎是必备的。如果只是简单的单入口写操作,version 可以不加,避免所有更新语句都要多带一个 WHERE version = 的条件,增加了复杂度但收益有限。
3. 从逻辑模型到物理模型:建表落地全流程
3.1 主键选型:自增、雪花ID还是业务主键
主键选型看着是个小问题,实际上会影响后面若干年的开发体验和数据架构。我的经验是分层级判断。
第一种是业务主键。比如订单号本身全局唯一,业务上还要拿出来对账、查询、退款,那直接用订单号做主键完全没问题。好处是少了自增主键和业务唯一键的重复校验,坏处是业务主键往往比较长,作为其他表的外键时占用空间更大。
第二种是数据库自增主键,这是最普遍的选择。短、快、天然有序,适合绝大多数业务表。但你如果计划做分库分表,或者要防止竞争对手通过订单 ID 反推你的业务单量,自增就不合适了。
第三种是雪花 ID / UUID。分库分表、分布式部署场景下通常选它。但要注意,UUID 字符串做主键性能堪忧,建议用雪花 ID 这类整数型分布式 ID。我实际看到很多公司在业务初期就全表雪花 ID,说是“避免以后拆库麻烦”,但代价是每个表都多了一个 B+ 树索引,写入性能打了折扣。我的建议是:单体应用阶段老老实实用自增,真到需要分库分表的那一天,加一个 business_id 雪花 ID 字段做分布式场景的标识,主键还是自增,不要急着推翻重来。
3.2 命名规范与字段类型:这些规则能让你少改十次表
数据模型的命名规范,越早统一越好。我见过的团队,有一半因为命名不统一吃过苦头。表名用复数还是单数、字段名用驼峰还是下划线、状态字段是叫 status 还是 state、时间字段是叫 created_at 还是 gmt_created,这些看起来都是小事,但统一起来之后,团队成员查表时不用反复翻数据字典,效率提升非常明显。
字段类型的选择更需要提前约定。金额一律用 decimal,这个我反复强调:float/double 存金额的坑,是那种“平时没事,对账那天突然多出一分钱”的慢性病,做电商的人应该都知道。时间字段用 datetime 还是 timestamp,看你的时区要求,但我建议所有跨时区业务统一存 UTC 或带时区的 timestamp,否则运营在后台看到的时间和实际差八个小时,排查问题定位到凌晨三点,谁都不会好受。布尔字段,MySQL 里我用 tinyint(1),不要用 bit,因为 ORM 映射和导出数据时 tinyint(1) 更省心。字符串长度,不要无脑 varchar(255)。长度影响索引效率,宁可字段定义短一点并预留合理余量,也不要每个字段都 255 然后建联合索引时发现索引超长。
下面这张表是我常用的字段类型参考表,适合绝大多数业务系统:
| 字段含义 | 推荐类型 | 备注 |
|---|---|---|
| 自增主键 | bigint unsigned | 预留未来数据量增长空间 |
| 业务ID(订单号等) | varchar(32) 或 bigint | 按业务字符/纯数字决定 |
| 金额 | decimal(10,2) | 精度必须明确,不要用 float |
| 手机号 | varchar(20) | 存字符串,不要用 bigint,因为需要处理前缀 |
| 状态 | tinyint | 数字比字符串省空间,但注释必须写清楚 |
| 时间 | datetime / timestamp | 区分是否需求时区范围统一 |
| 描述/备注 | varchar(500) | 宽松一点,但别给 text,除非真的很长 |
| JSON 扩展 | json | MySQL 5.7+ 才有,版本太低别硬上 |
3.3 索引设计:先保证查询走得通,再谈跑得快
索引的设计准则,我总结成一句话:先列出这个模型要支撑的高频查询路径,再为每条查询路径建索引,不要为了“感觉这个字段会查”就乱建。
以课程订单模型为例,核心查询路径有几条:用户查自己的订单列表,按订单状态筛选;后台按订单号查订单详情;按课程维度统计销量;按课程和用户维度查询是否已购买。每条路径对应索引大概是这样:
- users.id 主键索引,users.phone 唯一索引
- orders.user_id 普通索引,orders.order_no 唯一索引
- orders.status 普通索引(如果过滤条件里频繁出现)
- order_items.order_id 普通索引,order_items.course_id 普通索引
- 唯一索引组合:orders (user_id, course_id, status) 视业务需要
索引设计最忌讳两件事:一是有索引但查询没用上,比如你建了 user_id 的索引,但查询条件写成了 where status = 1 and user_id = 99,而索引是只有 user_id 一个字段,那确实还能用,但如果 where 条件第一个字段是 status,而索引第一列是 user_id,那这个索引就失效了。所以联合索引的字段顺序要严格按照查询条件的顺序来设计。二是过多索引拖慢写入,一般的经验是单表索引数量控制在 5 个左右,最多不要超过 8 个,尤其是高并发写入的表,每多一个索引就多一份写放大。
3.4 冗余字段与反范式:该冗余的地方别犹豫
数据建模的书上都讲范式,但实际业务里完全按第三范式建模,会把你自己的查询性能拖垮。我的看法是:范式要懂,但不是每张表都必须做到第三范式,合理的冗余是工程需要。
最常见的冗余场景,是明细表里冗余主表的关键字段。订单明细表里除了 order_id,我还会冗余一份 order_no。这样按订单号查明细时,可以直接在明细表上用 order_no 过滤,不用先查订单表再映射。还有展示型字段:课程名称、课程封面图,在订单明细表里冗余一份,哪怕课程之后下架改名,订单历史也不会受影响。这就是典型的用空间换稳定。
但冗余要有克制,什么字段能冗余、什么字段不能,有一条原则:只冗余“几乎不变或允许历史快照”的字段,不冗余“经常变化且必须实时一致”的字段。课程价格就不能随便冗余到订单明细里,但课程名称可以,因为价格要参与对账,必须订单一刻的实时值;名称是展示用,历史快照完全没问题。把握好这条原则,反范式就安全了。
4. 一次真实需求的数据模型建模过程复盘
4.1 原始需求与业务规则清单
这里我拿一个实际帮朋友团队做过的“在线课程订单”需求来完整走一遍。原始需求文字不多,基本是这么几句话:用户注册后可以浏览课程列表,选择课程加入购物车,然后下单购买。一个订单可以包含多门课程,支付成功后课程自动开通到用户的课程表里。课程可以下架,下架后新用户不能购买,已购买用户不受影响。用户可以申请退款,退款需要管理员审核,退款金额原路退回。
这些文字看着简单,但建模之前必须把它们整理成业务规则清单。我建议用清单式的写法,每一条都带着编号,方便后续评审和校验:
- 用户通过手机号注册,手机号唯一。
- 课程有上架/下架状态,下架课程新用户不可购买。
- 订单可以包含一种或多种课程。
- 订单有状态:待支付、已支付、已取消、已退款、部分退款。
- 同一用户同一课程,不能同时存在两个待支付订单。
- 支付成功后才开通课程,开通记录需要记录开通时间。
- 退款金额不能超过实际支付金额。
- 课程名称、价格需要冗余到订单明细中,作为快照。
这份清单才是数据模型的真正起点,而不是那几句原始描述。你能发现,每条清单后续都会对应到某个字段约束或者某张表设计上。当你发现某一条业务规则无处安放的时候,就是模型缺了东西的信号。
4.2 概念模型与实体拓扑
基于上面的规则清单,我梳理出来的核心实体有:用户(user)、课程(course)、订单(order)、订单明细(order_item)、支付记录(payment)、开通记录(course_access)、退款单(refund)、购物车(cart)。
实体之间的关系描述起来并不复杂:用户和订单是一对多,订单和订单明细是一对多,课程和订单明细是一对多,支付记录挂在订单下,退款单挂在订单和订单明细下,开通记录挂在用户和课程下。购物车是用户维度的临时数据,不需要跟订单发生级联。这个阶段不建议直接开 MySQL 画表,可以用 draw.io 或者 dbdiagram 这种工具先画 ER 图,重点是确认实体之间关系没有遗漏。
这里有个细节值得多说一句:开通记录(course_access)这张表,很多人会把它省掉,理由是“订单支付成功了就代表有权限了”。但你一旦要承接“课程下架之后已购用户还能继续学”“学习进度追踪”“C 端展示我的课程列表”这些需求,就会发现自己每一天都在 join 订单表然后做状态过滤,性能差不说,逻辑也乱。开通记录表本质上是一个“结果表”,它的存在就是让查询路径变得简单直接。这就是概念模型阶段多思考的价值,启动成本极低,后患却能大量规避。
4.3 字段设计、数据字典与 DDL 示例
概念模型确认后,就可以进入字段设计和物理建表环节。下面我给出核心表的字段设计和示例 DDL(以 MySQL 8.x 为例,重点是结构,实际生产还要根据引擎调整参数)。
用户表(users):
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint unsigned | 主键 |
| phone | varchar(20) | 手机号,唯一 |
| nickname | varchar(50) | 昵称 |
| avatar_url | varchar(255) | 头像 |
| status | tinyint | 状态:1正常 2禁用 |
| created_at | datetime | 创建时间 |
| updated_at | datetime | 更新时间 |
| deleted | tinyint | 软删除:0未删除 1已删除 |
课程表(courses):
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint unsigned | 主键 |
| title | varchar(100) | 课程标题 |
| cover_url | varchar(255) | 封面图 |
| price | decimal(10,2) | 课程售价 |
| status | tinyint | 状态:1上架 2下架 |
| created_at | datetime | 创建时间 |
| updated_at | datetime | 更新时间 |
| deleted | tinyint | 软删除 |
订单表(orders):
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint unsigned | 主键 |
| order_no | varchar(32) | 业务订单号,唯一 |
| user_id | bigint unsigned | 用户ID |
| total_amount | decimal(10,2) | 订单总金额(快照) |
| pay_amount | decimal(10,2) | 实际支付金额 |
| status | tinyint | 1待支付 2已支付 3已取消 4已退款 5部分退款 |
| paid_at | datetime | 支付时间 |
| created_at | datetime | 创建时间 |
| updated_at | datetime | 更新时间 |
| deleted | tinyint | 软删除 |
订单明细表(order_items):
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint unsigned | 主键 |
| order_id | bigint unsigned | 订单ID |
| order_no | varchar(32) | 冗余业务订单号,方便直查 |
| course_id | bigint unsigned | 课程ID |
| course_title | varchar(100) | 冗余课程名称快照 |
| course_price | decimal(10,2) | 冗余课程单价快照 |
| refund_status | tinyint | 0未退款 1已退款 |
支付记录表(payments):
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint unsigned | 主键 |
| payment_no | varchar(32) | 支付流水号,唯一 |
| order_id | bigint unsigned | 订单ID |
| user_id | bigint unsigned | 用户ID(冗余,方便对账) |
| amount | decimal(10,2) | 支付金额 |
| channel | varchar(20) | 支付渠道 |
| status | tinyint | 支付状态 |
| paid_at | datetime | 支付完成时间 |
开通记录表(course_access):
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint unsigned | 主键 |
| user_id | bigint unsigned | 用户ID |
| course_id | bigint unsigned | 课程ID |
| order_item_id | bigint unsigned | 来源订单明细ID |
| granted_at | datetime | 开通时间 |
| status | tinyint | 开通状态 |
核心表 DDL 示例如下:
CREATE TABLE `orders` ( `id` bigint unsigned NOT NULL AUTO_INCREMENT, `order_no` varchar(32) NOT NULL COMMENT '业务订单号', `user_id` bigint unsigned NOT NULL COMMENT '用户ID', `total_amount` decimal(10,2) NOT NULL COMMENT '订单总金额', `pay_amount` decimal(10,2) NOT NULL DEFAULT '0.00' COMMENT '实际支付金额', `status` tinyint NOT NULL DEFAULT '1' COMMENT '1待支付 2已支付 3已取消 4已退款 5部分退款', `paid_at` datetime DEFAULT NULL COMMENT '支付时间', `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, `deleted` tinyint NOT NULL DEFAULT '0' COMMENT '0未删除 1已删除', PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`), KEY `idx_user_id` (`user_id`), KEY `idx_status` (`status`), KEY `idx_created_at` (`created_at`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单表';这个 DDL 里有几个细节可以解释一下:status 用 tinyint 而不是 varchar,注释写清楚每个值代表的含义,这样后面查数据的人不用翻代码就知道状态值;order_no 建唯一索引,这是业务上必须保证的;user_id 建普通索引,因为这是最高频的查询路径;deleted 字段放进唯一索引里不是必须的,但如果你加了唯一索引且业务上有软删除复用数据的场景,一定把 deleted 一起放进去(生成列方案)。时间字段默认值用 CURRENT_TIMESTAMP,能让 insert 少带两个参数。
4.4 数据模型评审清单:上线前问自己 5 个问题
建完表之后,我强烈建议停下来做一轮模型评审,不要立刻开始写代码。评审不是走形式,而是真的拿着业务规则清单,逐条对着表结构和字段过一遍。我常用的评审清单是这样的:
第一,每个业务规则能不能在模型上找到对应的落点?比如“同一用户同一课程不能同时存在两个待支付订单”,这个规则如果只在代码里做了校验,数据库层面没兜底,那么并发请求来了还是可能插进去两条。要问自己:这个规则必须落到数据库约束吗?如果要,落在哪个索引或哪个约束上?
第二,每张表的每个字段,都能说清楚它的含义、取值来源和更新时机吗?我说的是每一个字段。当你说不清楚某个字段是干嘛的,它要么是过度设计,要么就是设计时没想明白业务,这比少了字段更麻烦。
第三,查询路径都覆盖了吗?把需求文档里列出的所有查询场景拿出来:用户订单列表、后台订单详情、课程销量统计、用户已购课程列表,逐个去分析它们会怎么走索引。如果发现某个高频查询要全表扫描才能跑,那模型设计还没合格。
第四,唯一性规则有没有全部列出?哪些字段组合在业务上必须唯一?手机号、订单号是最明显的,但像“退款单里的 order_item_id 是否唯一”这种边角唯一性,经常被漏掉。
第五,删除策略是什么?物理删除还是逻辑删除?如果逻辑删除,所有查询条件里都带上了 deleted 过滤吗?唯一索引和软删除字段的组合处理了吗?这个问题在评审中经常被忽略,但一旦上线,踩中就是数据事故。
5. 常见问题与排查技巧实录
5.1 需求方说“就是加个字段”时,真的只是加个字段吗
做需求转数据模型,最常见的坑就是需求方那句“项目都上线了,我就想加个字段,很简单”。我见过太多人听到这句话就直接 ALTER TABLE ADD COLUMN,然后过两周开始痛不欲生。这话背后的真实需求往往是“我要在列表页展示这个字段”“我要按这个字段筛选统计”“我要把这个字段暴露到某个报表里”,任何一个都不是单纯加一个字段就能解决的。
我的对策是,接到“加字段”的需求,先问四个问题:字段从哪来?是用户填写、系统生成还是从别的表带过来?字段更新时机是什么时候?创建时写入还是后续变更?字段要不要参与筛选和统计?要不要显示在历史记录里?如果一个字段要参与统计,那它就不能随便存一个没规则的字符串;如果它要在历史订单里回看,那当前值发生变化后历史数据怎么处理,是要快照还是实时联查?
问完这四个问题,很多“加个字段”的需求实际变成了“要加一张关联表”或者“要建一个冗余快照字段”。这时候模型的改动量完全不一样,但对应的价值也不一样。不过有一点也要认识到:如果需求方真的只需要一个查询字段,而且用一次就不用了,那就别过度设计,加个字段然后做好注释就行。数据模型要有原则,但也要知道灵活性在哪里。
5.2 异常数据排查:模型没问题但就是不对的 3 个实际 case
聊几个实际经历过的 case,都是模型层面埋下的隐患,当时排查花了不少时间。
第一个 case 是重复支付。订单表里 status 已经是支付成功,用户端也跳转了成功页,但支付回调因为网络抖动重复触发,结果同一条订单生成了两条支付流水。表面看是回调幂等性问题,深层原因是 payments 表对 order_id 没有唯一约束。修复方案是加了一个唯一索引,同时处理掉已有的重复流水。这类问题的教训是:像订单、支付这类关键流水表,凡是能用唯一索引约束业务幂等性的,一定要加,别把希望全寄托在代码。
第二个 case 是唯一索引加软删除的冲突。我们的用户表 phone 有唯一索引,用户注销走的是软删除。后来发现同一个手机号注册新账号时,会直接报唯一索引冲突。改法是用 generated column 生成一个 phone_deleted 字段,当 deleted 为 0 时存 phone,当 deleted 为 1 时存 phone+id,再对这个生成列建唯一索引。这算是 MySQL 里软删除唯一索引的经典解法。
第三个 case 是金额精度问题。订单金额用的是 decimal,这个没问题,问题出在订单明细汇总:代码里很多人习惯用 float 接收两个 decimal 字段再加总,结果 19.9 + 19.9 在 float 世界里变成了 39.800000000000004。报表系统里看着还好,一旦做金额对账就炸。排查半天最后才发现是中间层类型转换的问题。这个 case 的教训就是,全链路都要保证 decimal,代码里、接口传输、前端展示,任何一个环节转 float 或 double,精度就没了。
5.3 常见问题速查表:从需求文档到建表落地的避坑清单
最后整理一个速查表,是我这些年做数据模型设计时反复碰到的典型问题,按发生环节归类,方便你直接对照排查。
| 阶段 | 症状 | 根因 | 建议 |
|---|---|---|---|
| 需求分析 | 需求文档看完了不知道建哪些表 | 只读功能列表没读业务规则 | 用事件回溯法走一遍业务全链路 |
| 实体识别 | 把“课程价格”单独建了表 | 名词法滥用 | 独立生命周期才算实体,属性挂在实体下 |
| 关系建模 | 订单和课程直接加 course_ids 字段 | 多对多没拆中间表 | 拆订单明细表,关系有属性时必须独立建表 |
| 字段设计 | 金额字段用 float,对账多一分钱 | 字段类型选错 | 金额一律 decimal(10,2) |
| 主键设计 | 全表 UUID,写入性能差 | 过早分布式设计 | 单体时期自增主键,分布式前加 business_id |
| 唯一索引 | 软删除后唯一索引冲突 | 唯一索引没和 deleted 联动 | 用生成列方案解决 |
| 软删除 | 误删数据找不回 | 一开始就物理删除 | 业务表统一增加 deleted 字段 |
| 冗余字段 | 课程改名后历史订单名字跟着变 | 该做快照没做 | 明细表冗余课程名称,不冗余价格 |
| 索引设计 | 查询慢,但 explain 没走索引 | 联合索引字段顺序与查询条件不符 | 索引字段排序紧跟高频查询条件 |
| 版本控制 | 多端同时改一条数据,后写覆盖先写 | 缺少乐观锁 | 增加 version 字段 |
这十几条没有一条是特别高深的理论,但每一条背后都是我在真实项目里踩过的坑。数据模型这个东西,不懂的人觉得就是建几张表,懂的人知道它其实承载了一个系统对未来所有业务规则的理解。需求变更是常态,但数据模型一旦成型,改动成本是指数级上升的。所以前期多花几个小时把规则理清楚、把关系画明白,后面省下来的返工时间是几十倍。
我个人的体会是,数据模型设计最关键的能力不是 SQL 写得多好,而是“把模糊的业务描述翻译成精确的数据约束”这种能力。这种翻译能力没有捷径,就是多看需求、多建表、多被线上问题打脸,慢慢才会有感觉。希望这份从需求到数据模型的全流程拆解,能帮你少走一段我当年走过的弯路。