第 6 章 系统设计:业务模型如何变成数据模型
副题:实体、关系、字段、范式——把"用户、内容、评论、商品、订单、支付"变成六张表
第 4 章我们把一句话需求翻译成了需求清单,第 5 章我们选定了技术栈(单体、React + Node.js、PostgreSQL)。现在,业务线走到第三站:数据怎么存。需求清单里的六个业务对象——用户、内容、评论、商品、订单、支付——是业务概念,数据库存不了概念,它只认识表、字段、行。本章跟着案例完成业务模型到数据模型的翻译——这是"从业务到系统"流水线的第二张图纸。
本章要建立的认知
- 建表不是"把对象名变成表名",是把业务模型翻译成数据模型:实体变表、属性变字段、关系变外键/关联表——每层翻译都有对应的建模决策;
- 建模决策的落点是字段:主键怎么定、金额用什么类型、改价了怎么办、属性多变怎么办——字段细节里藏着业务规则(第 4 章的业务规则在这里变成表约束);
- 建模有明确的代价与边界:过早细化会锁死演进(第 27 章迭代回收);MVP 阶段的建模粒度是"够用且留有余地"。
6.1 开始建表
需求清单(第 4 章的产出)和选型结果(第 5 章的产出)都在手上了。你打开 psql,准备建表。psql 是这个阶段最诚实的工具——它不接受任何"差不多",每个字段、每个类型都要当场落定。
清单里有六个对象:用户、内容、评论、商品、订单、支付。六个名字,看起来六张表,名字都不用改:user、content、comment、product、order、payment(order 是 SQL 保留字,建表时要加引号——6.5 的 DDL 里看得到)。你的第一版建表脚本长这样(示意,节选):
CREATE TABLE user (
id BIGINT PRIMARY KEY,
username TEXT,
email TEXT,
password TEXT
);
CREATE TABLE product (
id BIGINT PRIMARY KEY,
seller_id BIGINT,
title TEXT,
price REAL,
stock INT
);
CREATE TABLE "order" (
id BIGINT PRIMARY KEY,
buyer_id BIGINT,
product_id BIGINT,
amount REAL,
status TEXT
);
三天后,三个问题找上门来。
问题一:商品改价,历史订单跟着变。你上架了一台二手相机,标价 2000 元。有人下单付款后,你把价格改成 1800 元——然后你发现,那笔已经完成的订单金额变成了 1800。买家收到的确认信息、你的对账单、后台的成交统计,全部跟着改了。订单是历史事实,事实不能跟着当前价格走。
**问题二:价格算错了 1 分钱。**一笔订单金额 19.9 元,显示出来成了 19.899999。REAL(浮点数)存金额的经典事故——第 4 章的需求清单里写着"金额以'分'为单位存储",你建表时图省事用了浮点。老周这次没骂你,但第 17 章的对账系统会记住这笔账。
问题三:谁都能登录。user 表的 password 字段存的是明文。注册接口写完那天你顺手用测试账号登了一下,发现"密码"这列在数据库里一眼可见——第 4 章需求清单写的"密码以哈希存储"没进表结构。需求清单里每一行验收标准,都要在表结构里找到它的落点,这是建表时最容易漏的。
三个问题有一个共同点:它们都不是代码 bug,是表结构的问题——字段类型选错了、字段里少存了信息、表里缺了约束。建表看起来是六个 CREATE TABLE,实际上是一次完整的建模决策过程:每个字段的取舍,都是业务规则和数据需求的翻译。
三个问题还有一个共同的教训:需求清单里的每一行验收标准,都要在表结构里找到它的落点。"密码以哈希存储"→ password_hash 字段;"金额以分存储"→ BIGINT 字段;"order_no 唯一"→ 唯一约束。建表的时候对照需求清单逐行过一遍:这行验收标准,表结构接住了吗?接不住的那一行,就是将来返工的地方——第 4 章说验收标准是未来的测试用例,这里再加一句:验收标准也是未来的表结构检查清单。
这里有一个规律值得先记住:表结构错误的代价,比代码错误的代价高一个量级。代码错了,改代码、重新部署,几十分钟;表结构错了,改表——线上数据已经存进去了,要写迁移脚本、要处理迁移期间的新写入,动辄半天一天。所以建表这件事,值得在敲下 CREATE TABLE 之前多想一会儿。
为什么 1-3 年开发者特别容易随手建表?三个来源:ORM 的自动建表(框架帮你建,你从不看表结构)、教程的建表演示(为了跑通功能,字段随便给)、以及"反正以后能改"的侥幸。前两个是习惯问题,第三个是成本误判——以后能改,但改的代价是迁移数据。本章的每一个建模决策,都是冲着"减少以后改表的次数"去的。
回到开头的三个问题,看看它们的修法,感受一下"改表贵":问题一(改价影响历史订单)要加快照字段——新订单用新结构,存量订单要写一次性脚本回填商品信息;问题二(浮点金额)要把整个表的金额字段从 REAL 改成 BIGINT——全表扫描 + 逐行换算,数据量大时这就是一次灰度迁移;问题三(明文密码)要把 password 列改名 password_hash 并重算存量——所有老用户要强制改密或批量哈希。三个问题,三个迁移,每个都比你当初"随手建表"多花一整天。这些修法的共同点:能早做的决策,别让未来的迁移来做。
本章就来讲这次翻译怎么做。先看第一层:实体和关系怎么变表。
正式建表前还有一个流程问题:先画 ER 图,再写 DDL。ER 图是"结构蓝图"(哪些表、什么关系),DDL 是"施工图"(字段、约束、类型)。跳过 ER 图直接写 DDL,等于跳过设计直接施工——六个 CREATE TABLE 写下来,关系藏在字段里(哪个 id 指向哪张表只有你自己知道)。先画 ER 图还有一个好处:它是给同事看的(评审、协作),DDL 是给数据库看的。第 20 章的 Code Review 会看到,ER 图是表结构评审的第一份材料。
6.2 实体与关系:第一层翻译
业务模型里的"对象",在数据模型里叫实体(entity)。实体变表的规则很直接:一个实体一张表,实体的属性变成字段。用户 变 user 表,username、email 变字段——这一层几乎没有歧义。
实体变表还有一个前置问题:**什么算一个实体?**判断标准有两条:有没有独立的生命周期(用户会注册、封禁、注销;登录态不会——所以用户是实体,登录态不是),有没有独立的业务规则(订单有状态机,所以订单是实体;"购物车"在阶段 1 没有业务规则,所以它连表都不配拥有——第 4 章砍需求时已经把它砍了)。拿这两条去量需求清单里的每个名词:量不出来的,就不是实体,是实体的属性或字段。
实体边界还有一个典型的 1:1 例子:**用户的扩展信息(头像、简介、个人主页)要不要拆一张 profile 表?**不拆——头像简介就三个字段,塞进 user 表,一次查询拿全;拆——用户主页将来可能长成独立模块(个人作品集、信用记录),到时候再拆也不迟(6.6 的"够用且留有余地")。判断标准还是那两条:简介没有独立生命周期(它跟着用户走)、没有独立业务规则——所以它是 user 的属性,不是实体。
真正的翻译难点在第二层:关系。业务对象之间是有关系的,关系必须也变进表结构。关系的种类只有三种,每种都有对应的表达方式:
一对一(1:1):一个订单对应一次支付。用外键表达:payment.order_id 指向 order.id,并加唯一约束(一个订单只能有一条支付记录)。
一对多(1:N):一个用户发布多条内容,一条内容有多条评论。用"多"的那一端存外键:content.author_id 指向 user.id,comment.content_id 指向 content.id。
案例里还有一个值得展开的关系:评论的目标。评论可以评论内容,也可以评论评论(楼中楼)——如果给评论表建两个外键(content_id、comment_id),大多数行会有一个为空;更常见的做法是多态关联:target_type(目标是 content 还是 comment)+ target_id(目标的主键)。多态的代价是数据库不知道 target_id 指向哪张表(外键约束失效,得靠应用层保证),收益是一张表吃下两种目标。这个权衡案例选了多态——楼中楼是社区的常见需求,为它拆两张表不值得。
多对多(N:N):商品和标签——一个商品有多个标签,一个标签下有多个商品。N:N 不能直接用一个外键表达,需要一张关联表(join table):product_tag(product_id, tag_id),两列都是外键,合起来是主键。
外键还有一个隐藏的决策:**删掉"一"的那一端时,"多"的那一端怎么办?**用户注销了,他的内容、评论、订单怎么办?三种策略:CASCADE(级联删除,内容跟着用户一起删——数据干净,但用户注销连内容一起消失,社区内容就没了);RESTRICT(禁止删除——有内容的用户删不掉,注销变成"标记删除");SET NULL(外键置空——内容变成"匿名用户发布",但外键要可空)。案例的选择:用户不做物理删除(status 字段标记"封禁/注销",6.5 的 user 表里那个 status),所以删除策略暂时不用纠结——但建表时想一遍"删除语义",能避免将来"用户注销把交易记录删了"的事故。删除语义是建模里最容易被跳过、出事最严重的决策之一。
把案例的六对象关系画成 ER 图(图 6-1),就是六张表的蓝图:
用户 ──1:N──> 内容 ──1:N──> 评论
用户 ──1:N──> 商品
用户 ──1:N──> 订单 ──1:1──> 支付
三种关系在案例里都有落点,值得合起来看一次:1:1——订单与支付(一单一付,带唯一约束的外键);1:N——用户与内容/评论/商品/订单(四条,外键存"多"端);N:N——商品与标签(关联表 product_tag,第 4 章"标签筛选顶住"的落点)。六对象 + 五条关系 + 一张关联表,这就是阶段 1 数据模型的全部——建模的复杂度,一开始就这么大。
六张表,五条关系:四条 1:N 用外键,一条 1:1 用带唯一约束的外键。ER 图的价值就在这里:它把"关系"从业务语言翻译成了建表指令——画完 ER 图,建表脚本的结构就已经确定了,剩下的只是字段细节。
ER 图的读法有一个要点:基数从"一"读向"多"。"用户 1:N 内容"读作"一个用户发布多条内容",箭头从用户指向内容。画反了没关系,但建表时记住:箭头的"多"端存外键。
关系翻译有两个容易忽略的补充。业务关系 ≠ 外键约束:外键约束(FOREIGN KEY)是数据库帮你检查"引用的行必须存在"——它能防脏数据,但也有成本(每次插入都要查被引用的表)。MVP 阶段的选择是:关系在模型层表达(字段里存对方 id),约束按需加——支付、订单这种不能错的加外键约束;评论这种高频写入、错一条也无伤大雅的,可以只存 id 不建约束。这个取舍在第 14 章会展开(索引和约束的成本)。N:N 的关联表也是表:关联表有自己的字段(比如 created_at 记录打标签的时间),它不是一个"临时工具",是正经的业务数据——案例里就有现成的 N:N:商品和标签。第 4 章砍需求时把搜索砍了,但"先不做搜索,用标签筛选顶住"——标签筛选就是 N:N 查询:一个商品多个标签,一个标签下多个商品,product_tag(product_id, tag_id) 关联表。第 4 章的一个砍需求决定,在这里变成了数据模型里的一张关联表——需求清单的每一行,都在往表结构里长。
6.3 字段:第二层翻译
实体和关系定了,六张表的骨架就有了。现在进入最容易被低估的一层:字段设计。建表的返工,十次有八次发生在字段上——类型选错、精度丢失、少存了信息。这一层的翻译对象是第 4 章需求清单里的业务规则。
主键:自增还是业务单号?每张表都要一个主键,选什么有讲究。user、content 这类表用自增整数(BIGSERIAL)就行——简单、快。但 order 表不一样:订单号要暴露给用户(订单列表、客服查询、对账),还要在支付回调里被渠道引用(第 17 章)——所以订单表的主键是自增 id,同时有一个业务单号 order_no(唯一约束),用户和渠道看到的是 order_no,数据库内部用自增 id。一个表两个"编号",各司其职:内部编号管结构,业务单号管业务。这是第 17 章幂等(order_no 唯一键)的地基——第 4 章验收标准里"order_no 唯一"在这里落地。
除了主键、金额、快照、JSONB、状态这几个"大决策",字段设计还有一些小规则,共同点是"少踩坑":TEXT 够用就不上 VARCHAR(n)——VARCHAR 的长度限制防不住业务问题,反而会在长度到顶时给用户报错(PostgreSQL 里 TEXT 和 VARCHAR 性能相同);时间戳统一带时区(TIMESTAMPTZ)——"晚上 8 点下单"在不同时区不是同一个时刻,存 UTC、展示时转本地,是最省心的约定;布尔字段要命名成"是/否"问题——is_deleted 比 deleted 清楚,has_paid 比 paid 清楚;每个表都有 created_at——它不是装饰,是将来排查"这条数据哪来的"的唯一线索(第 25 章可观测性会用到它)。
字段命名再花两句:统一 snake_case(PostgreSQL 惯例,避免大小写混用的引号地狱)、外键命名用"表名单数_id"(content 表里指向 user 的字段叫 author_id 而不是 uid——名字要说出它指向谁)、时间戳成对命名(created_at / updated_at,updated_at 由谁维护、在哪些场景更新,是第 20 章代码质量讨论的常客)。命名不影响正确性,但影响十年里所有读这张表的人的理解速度。
created_at 还有一个维护问题:**谁给它赋值?**两个选择:数据库默认值(DEFAULT now(),插入时自动填,任何写入路径都不会漏)或应用层赋值(代码里 new Date(),灵活但每个写入点都要记得)。案例全部用数据库默认值——少一个"忘记赋值"的 bug 来源。updated_at 反过来要应用层管:它只在"业务上真的更新了"时变,数据库不知道什么是业务更新。
**金额:永远用整数"分"。**第 4 章需求清单写过,第 17 章对账会再教你一次(浮点数丢 1 分钱),这里直接落地:金额字段用 BIGINT,单位是"分"。19.9 元存 1990。展示层转回"元"只在最后一步。这条规则的代价(所有金额计算都要想着"分")远比收益小(永远不丢精度)。
快照:订单要存"下单那一刻"的事实。问题一的答案:订单表里不只存 product_id,还要存一份下单时的商品信息快照——product_title、product_price(以分计)。商品改价、改名、甚至下架,都不影响已经发生的订单:订单是历史事实,事实不能被现在的商品状态篡改。快照的代价是冗余(商品信息存了两份),收益是订单的不可变性。这是建模里最典型的"冗余换正确"——后面第 17 章的订单状态机也是同一个思路:状态一旦写入,就不能被后来的事件改写。
快照的边界也值得划清:不是把所有商品字段都复制进订单,只复制展示需要的那几个(标题、价格、一张主图)——冗余的目的是"订单能独立展示",不是"订单能独立开店"。多冗余一个字段,就多一处"两份数据不一致"的维护点,快照的粒度是"够展示,不多存"。
**属性多变:JSONB。**第 5 章选 PostgreSQL 时,评价维度里有一条"数据模型灵活性"——内容标签、商品规格这类属性会变,不想频繁改表。这里兑现:content.tag 和 product.specs 用 JSONB(PostgreSQL 的 JSON 类型,可索引可查询)。标签从"社区"加一个"二手",不需要 ALTER TABLE,改 JSON 里的一个值就行。代价是半结构化:针对 JSONB 字段的复杂查询和校验,比结构化字段费劲(第 4 章对应表里写过这一行)。
状态字段:先留好,机制后到。order.status 存什么?第 17 章会讲,订单状态不是随便改的字符串,它背后是状态机(PENDING → PAID → SHIPPED → COMPLETED / FAILED / CANCELED)。第 6 章建表时只需要做两件事:字段用 TEXT 存状态名(或者更严格用 CHECK 约束限定合法值),以及知道状态机是加在这个字段上的规则——表结构负责存状态,状态机负责管状态怎么变。两件事分开,第 17 章才接得上。
状态字段有三种实现,先说结论(第 17 章会看到它们的选择):TEXT 自由存(最灵活,但错别字也是一状态)、CHECK 约束限定(数据库拦住非法值,改状态要改约束——迁移成本)、数据库枚举类型(PostgreSQL 的 ENUM,居中)。案例先选 TEXT + 应用层校验——阶段 1 的状态只有两三个,不值得为它上约束,等第 17 章状态机来的时候一起定。
6.4 范式与权衡:建模的决策模型
字段定完,还有一层更宏观的权衡:**这张表该不该拆、该不该冗余?**业界有一套成熟的判断框架——范式(normalization),它回答"数据怎么组织最不容易出错"。
范式不是学院派的装饰,它诞生于一个具体的年代问题:1970 年代之前,数据存在文件系统里,同一份数据被复制到多个文件,改一处漏一处是常态——"订单文件"里存着买家的地址,买家搬家后,历史订单的地址全部过时(这个问题我们在 6.3 的快照里见过一次)。E.F. Codd 提出关系模型时,范式是"让数据不重复"的形式化方案。理解了它的出身,就理解了它的边界:范式是防"改一处漏一处"的工具,不是建模的目的。
范式是一组递进的规则,对 1-3 年开发者来说,记住前三级就够用:
- 第一范式(1NF):字段不可再分。一个"标签"字段里存"社区,二手,摄影"三个值,违反 1NF——应该拆成关联表或数组。
- 第二范式(2NF):非主键字段必须完全依赖主键。订单表里存
buyer_name,它依赖的是buyer_id而不是订单主键——如果买家改名,所有历史订单的buyer_name都要改(又是"历史事实被篡改"问题)。 - 第三范式(3NF):非主键字段不能互相依赖。商品表里
price和price_with_tax(含税价)——price_with_tax可以由price算出来,存两份就违反 3NF。
第三范式之上还有第四、第五范式(处理更细粒度的多值依赖),但对 1-3 年的业务建模来说,用到 3NF 已经足够——再往上的范式,收益递减到理论意义,代价(查询复杂度)却真实存在。记住"3NF 够用"本身就是一种建模决策。
范式的价值是消除重复:重复的数据 = 改一处漏一处的风险。但它有代价:范式越高,查询要 JOIN 的表越多,读起来越慢。
高范式的代价在查询时显现:第三范式的订单表要显示买家名,得 JOIN 用户表;第二范式要显示商品名,得 JOIN 商品表——每次 JOIN 都是数据库的一次额外查找。单次 JOIN 毫秒级,但列表页每条数据都 JOIN,页面上几十条数据就是几十次查找。案例里最典型的场景:内容列表页要显示每条内容的作者名——content 表只存 author_id,列表查询 JOIN user 表拿 username。MVP 阶段这个 JOIN 无感;等列表页每页 50 条、每条都 JOIN、QPS 上来之后,它就会成为第 16 章性能章的主角之一。这就是"冗余换性能"的现实动机:规范化和反规范化是同一枚硬币的两面,一面是写的正确性,一面是读的速度——建模时选哪面,取决于业务读多还是写多(第 5 章的决策模型:评价维度是读写比例)。
所以建模的真相是:范式是基准,不是目标。实际建模是在"规范化(少冗余、改一处即可)"和"反规范化(多冗余、查得快)"之间权衡——而权衡的依据,就是第 5 章那套九步决策模型。案例里有两个现成的例子:
- 订单快照(6.3):故意违反 2NF/3NF(订单表里冗余了商品标题和价格),换来"历史订单不可篡改"。业务目标"订单是历史事实",约束"商品会改价",选择冗余——这是一个"反规范化"的决策,评价维度是正确性优先于存储/一致性成本。
- **买家名要不要存订单表?**如果买家会改名,而订单要显示"下单时买家叫小明"——存快照;如果订单只要显示当前买家名(改名跟着改),就只存
buyer_id,需要时 JOIN。两种都对,取决于业务要什么——建模决策没有标准答案,只有"约束→选择→代价"。
这两条合起来,是本章最重要的一个建模认知:
把上一段的"买家名要不要存订单表"用九步完整走一遍,作为本章的决策示范。业务目标:订单详情页显示买家名。业务约束:用户可能改名(改名的需求来自社区治理:用户要求改名,或违规用户名被强制修改)。技术问题:订单表要不要冗余 buyer_name?候选方案:A 只存 buyer_id,展示时 JOIN 用户表;B 冗余 buyer_name 快照。评价维度:改名的正确性(买家改名后,历史订单显示什么)、查询成本(列表页 JOIN 次数)、存储与维护成本。方案选择:只存 buyer_id——因为"订单显示的是当前买家名"就是业务需求,改名跟着变不是 bug;JOIN 的成本在 MVP 量级可忽略(第 16 章性能章才需要担心)。收益:无冗余,无一致性维护点。代价:每次展示要 JOIN。演进条件:如果将来业务要求"订单显示下单时的买家名"(改名不回溯),改存快照——那时改表成本已经算清(6.6 的账)。走完这一步你会发现:建模决策的九步,和选型决策的九步一模一样——只是"候选"从技术换成了表结构方案。
**冗余本身不是错,无意识的冗余才是错。**快照是有意识的冗余(为了不可变性),重复的
buyer_name是无意识的冗余(为了省一次 JOIN)——两者的区别,在于建模时有没有走完决策。
反规范化除了快照,还有两种常见形态,先认识一下:计数冗余——内容表存一个 comment_count,评论新增时 +1,避免每次都 COUNT 评论表(代价:计数可能和实际不一致——"冗余换读速"的典型账单,什么时候值得付,第 16 章的度量思维会给出答案);汇总表——按天汇总的订单金额表,报表查询直接读汇总(代价:汇总要定时算,是异步任务,第 17 章讲过异步)。两种都是"冗余换读速",和快照是同一个决策模型。
反规范化的时机也有规律:读多写少、读的速度是体验的关键时,才值得反——内容列表(读多)值得冗余作者名,交易流水(写多、要准确)不值得为省一次 JOIN 冗余一堆字段。判断标准回到第 5 章:评价维度里"读速度"的权重什么时候压过"写正确性",什么时候就该反规范化——大多数项目在阶段 1 不需要,第 16 章性能危机时才真正开始。
6.5 案例落地:六张表
把前三节的决策全部落下来,案例阶段 1 的六张表长这样(关键字段示意,省略次要字段):
CREATE TABLE "user" (
id BIGSERIAL PRIMARY KEY,
username TEXT UNIQUE NOT NULL,
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL, -- 需求清单:密码哈希存储
avatar TEXT, -- 头像 URL(6.2:塞进 user 表不拆表)
bio TEXT,
status TEXT DEFAULT 'normal' -- 正常/封禁/注销
);
CREATE TABLE content (
id BIGSERIAL PRIMARY KEY,
author_id BIGINT NOT NULL REFERENCES "user"(id),
title TEXT NOT NULL,
body TEXT NOT NULL,
tag JSONB, -- 属性多变:标签用 JSONB(第 5 章选型兑现)
status TEXT DEFAULT 'published'
);
CREATE TABLE product (
id BIGSERIAL PRIMARY KEY,
seller_id BIGINT NOT NULL REFERENCES "user"(id),
title TEXT NOT NULL,
price BIGINT NOT NULL CHECK (price > 0), -- 金额以"分"存整数;业务规则:价格为正
stock INT NOT NULL CHECK (stock >= 0), -- 业务规则:库存不能为负
specs JSONB, -- 商品规格多变:JSONB
status TEXT DEFAULT 'on_sale'
);
CREATE TABLE "order" (
id BIGSERIAL PRIMARY KEY,
order_no TEXT UNIQUE NOT NULL, -- 业务单号:幂等地基(第 17 章)
buyer_id BIGINT NOT NULL REFERENCES "user"(id),
product_id BIGINT NOT NULL,
product_title TEXT NOT NULL, -- 快照:下单时的商品信息
product_price BIGINT NOT NULL, -- 快照:以"分"计
amount BIGINT NOT NULL CHECK (amount > 0),
status TEXT DEFAULT 'PENDING' -- 状态字段先留好,状态机第 17 章接
);
CREATE TABLE payment (
id BIGSERIAL PRIMARY KEY,
order_id BIGINT NOT NULL UNIQUE REFERENCES "order"(id), -- 1:1:一单一付
pay_no TEXT UNIQUE, -- 渠道单号:对账锚点(第 17 章)
amount BIGINT NOT NULL,
channel TEXT,
status TEXT DEFAULT 'pending', -- 支付状态(第 17 章:与订单状态两套)
callback_at TIMESTAMP
);
注意几件事。第一,user 表没有 password 而是 password_hash——第 4 章需求清单的"密码以哈希存储"变成了字段名。第二,order 表没有存 buyer_name——买家名 JOIN 用户表即可(6.4 的决策:订单不需要显示"下单时的买家名")。第三,order 和 payment 表在阶段 1 就建好了,但第 4 章的 MVP 砍掉了交易功能——表先建好,功能后启用,这就是第 4 章说的"砍功能,不砍验证路径"在数据层的体现:交易对象先建模,阶段 3 启用。
六张表和需求清单的六对象一一对应(user↔用户、content↔内容、comment↔评论、product↔商品、order↔订单、payment↔支付)——六对象是业务侧的名称,六张表是数据侧的名称,同一件事的两张脸。
这六张表也是第 5 章选型的一次验收:选 PostgreSQL 时评价维度里的"数据模型灵活性"(JSONB)和"事务能力"(CHECK、约束、外键)在这里逐条兑现——如果你当初选了 JSON 支持弱的数据库,content.tag 和 product.specs 就得拆关联表,6.2 的 N:N 会多两张;选型决策的收益和代价,最终都在表结构上见分晓。
username/email 的唯一约束要单独说:它不只是防重复注册,还有一个隐藏作用——唯一约束会自动建索引(第 14 章会展开),登录时按 username 查用户会用到它。第 4 章需求清单里"登录态 7 天有效"的验收标准,在表结构层的落点是:username 唯一 + password_hash + 一个会话机制(第 13 章认证章再建)。建表时多问一句"这行验收标准在表里的落点是什么",很多索引和约束都是这么"顺便"想清楚的。
索引也一样:content 按时间倒序查列表、user 按 username/email 登录,这些查询将来会慢,但索引第 14 章再建——先让功能跑起来,等查询真的慢了再优化(第 14 章会讲为什么索引不能乱建、什么时候该建)。外键字段将来也要单独建索引——JOIN 按外键查(WHERE author_id = ?),没有索引就是全表扫;外键越多,将来要补的索引越多,这也是"外键约束按需加"的另一个理由。
六张表建完,对照需求清单(第 4 章)逐行检查一遍落点:"密码以哈希存储"→ password_hash;"金额以分存储"→ 所有金额字段 BIGINT;"order_no 唯一"→ 唯一约束;"库存不能为负"→ CHECK (stock >= 0);"内容与订单零丢失"→ 数据备份(第 23 章部署时配);"登录态 7 天有效"→ 会话机制(第 13 章认证时建)。每一行验收标准都有落点的表结构,才是合格的表结构——这一遍检查,比任何建模规范都管用。
用 ORM(对象关系映射)框架的读者可能会问:这些表不是框架自动建的吗?自动建表确实省事,但它有两个盲区:约束——ORM 的模型定义往往只有字段,没有 CHECK、没有唯一约束(唯一性靠应用层查一遍再插,这是第 17 章幂等的地基,绝不能只靠应用层);迁移——自动建表只适合从零开始,表结构一变(加字段、加约束),就要迁移脚本,而迁移脚本恰恰是 ORM 最需要人把关的部分。所以结论是:ORM 可以自动建表,但表结构的设计(约束、索引、迁移)始终是人的活——本章讲的每一个决策,都不会因为用了 ORM 而消失。
第六张表 comment 和 content 同构(author_id、target_id、body),这里不展开。
不过 comment 的多态目标值得看一眼 DDL:
CREATE TABLE comment (
id BIGSERIAL PRIMARY KEY,
author_id BIGINT NOT NULL REFERENCES "user"(id),
target_type TEXT NOT NULL, -- 'content' 或 'comment'(多态,6.2 的决策)
target_id BIGINT NOT NULL,
body TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT now()
);
多态的两个字段合起来指向目标——数据库不知道 target_id 是内容还是评论(外键约束失效),所以 target 的合法性靠应用层校验。这是 6.2 那个权衡的代价,在 DDL 里看得一清二楚。
6.6 建模的代价与边界
六张表建完了,必须说建模的反面。
**过早细化会锁死演进。**建模最怕的不是"建得不好",是"建得太好"——字段全、约束全、范式拉满、每个可能的需求都预留了位置。这样的表结构看起来专业,实际是给未来上锁:每一处"预留"都是一个假设,假设错了,改表比当初不预留更痛苦(预留字段没人用、约束拦着新需求)。第 4 章讲过"需求分析的正确停止点是能开始试错",建模同理:正确停止点是"够用且留有余地"——够当前功能用,余地给已知的近邻需求(交易对象先建模),不给幻想中的需求(优惠券字段?等第 27 章需求真的来了再建)。
建模的粒度是个决策,不是个习惯。要不要冗余、要不要 JSONB、要不要 CHECK 约束——每个都是九步模型里的小决策:业务目标(订单要不可变?)、约束(商品会不会改价?)、候选(快照 vs JOIN)、评价(正确性 vs 存储成本)、选择、代价(冗余、改表成本)。建模粒度决策的独特之处在于它的代价来得特别晚——表结构错了,不是当天出错,是半年后某个功能"怎么这么难做"时才暴露,而那时数据已经存进去了,改表要迁移数据。这也是为什么第 5 章的决策模型在建模里特别值钱:它是唯一能把"半年后的代价"提前到建表当天想清楚的方法。
边界:数据模型不是业务的全部设计。表结构定了,还有两个问题没回答:业务动作怎么变成接口(第 7 章)、这些表怎么被高效地查询(第 14 章)。数据模型是系统的地基,但地基之上还有楼——第 6 章的图纸只画到地面。
最后把一条链补完整:第 4 章的验收标准("重复点击只产生一个订单")→ 本章的表结构落点(order_no 唯一约束)→ 第 18 章的测试用例(验证唯一约束真的挡住重复)——需求分析、系统设计、质量保障,三章合起来才是"业务正确"的完整链路。单看任何一章都是方法,连起来才是工程。
把本章放回第 4 章的翻译链:需求分析把"业务语言"翻译成"需求清单",本章把"需求清单"翻译成"表结构"——同一根翻译链的下一层。第 5 章的九步模型是这根链上每一段的通用工具(选型用它、建模用它、后面每一章的关键决策都用它)。这也是为什么第 4、5、6 章必须按这个顺序读:先知道要什么,再决定怎么做,再决定怎么存。
最后认识两类建模的经典错误形态,遇到过一次就长记性。把状态存成多个布尔列——订单表建 is_paid、is_shipped、is_completed 三列,三个布尔互相矛盾的状态(已发货但未支付)在数据层完全合法;状态应该是一个字段,一个状态机管着(第 17 章)。把列表存成逗号分隔字符串——违反 1NF,"查所有带'二手'标签的内容"要 LIKE '%二手%',索引失效;列表要么拆关联表,要么用数组/JSONB。这两个错误的共同点:用简单的类型逃避了建模决策,而逃避的代价,都在将来的某个查询或某个状态检查里等着。
到这里可以总结建模的三笔收益,和 6.1 那笔"改表贵"的代价放在一起看:边界清晰——哪些数据归哪张表、哪张表归哪个域,写代码的人不用猜(第 12 章分层、第 26 章拆服务都靠它);规则有落点——业务规则变成字段类型和约束,数据库替你把第一道关(密码哈希、库存非负、金额整数);演进有依据——订单/支付表先建模、状态字段先留好,阶段 3 的交易功能是"启用"而不是"返工"。三笔收益对一笔代价:建模决策花的是建表前的几分钟,省的是将来每一次迁移的半天。还有一笔账要记在团队账上:建模决策要写下来(哪张表为什么这么设计),否则三个月后没人记得当初为什么冗余——第 5 章的决策记录习惯,在这里同样适用。
还有一笔账值得提前算清:改表的真实成本。加一个字段(ALTER TABLE ADD COLUMN)在 PostgreSQL 里是元数据操作,快;但给已有数据的大表加 CHECK 约束、改字段类型、或者把逗号分隔字符串拆成关联表——每一件都是要动存量数据的迁移,写脚本、验证数据、灰度执行,半天起步。这笔账不是劝你别改表,是提醒你:改表贵,所以建表时的每一个决策都值得多想几分钟——这正是本章所有内容的意义。
数据迁移还有两层更远的关联。往远了看,第 26 章架构演进时,"拆服务"的第一步往往就是"拆表"——订单表要从单体库里迁到支付服务的库里,那是一次以天计的工程;现在建表时每一个"边界划得清不清楚"(哪些字段属于订单域、哪些属于商品域),都在为那一天的迁移难度投票,建模时的边界感是架构演进的准备金。往近了看,第 27 章每次迭代的新需求,都值得把表结构拿出来重审一遍——"这个新功能,现有表接得住吗?还是要改表?"。有 6.1 的账(改表贵)在,重审的结论通常是:能不改就不改,必须改就趁早改(数据量小的时候改,比数据量大之后改便宜得多)。建模决策不是建表那天做完就结束,它是每一次迭代的常态动作。
6.7 本章对应表
| 业务诉求 | 技术选择 | 为什么 | 代价/取舍 |
|---|---|---|---|
| 业务概念存不进数据库 | ER 建模(实体→表/属性→字段/关系→外键/关联表) | 概念翻译成结构,关系翻译成引用 | 实体边界要判断,分错要改表 |
| 订单不能跟着商品改价 | 金额快照(下单时复制商品信息) | 订单是历史事实,不能被当前状态篡改 | 冗余存储;改价不影响旧单是有意为之 |
| 金额不能丢精度 | 整数"分"存储(BIGINT) | 浮点会丢 1 分钱(第 17 章对账事故) | 所有金额计算都要想着"分" |
| 属性多变、不想频繁改表 | JSONB 字段(第 5 章选型兑现) | 免去 ALTER TABLE | 复杂查询和校验弱于结构化字段 |
| 密码不能明文存 | password_hash + 不存明文 | 需求清单"密码以哈希存储"的落点 | 登录校验要先算哈希 |
| 查询将来会慢(预告) | 索引(第 14 章再建) | 先让功能跑起来,慢了再优化 | 索引不是免费:写慢、占空间(14 章展开) |
| 建模粒度(设计到什么程度) | MVP 粒度:够用且留有余地 | 过早细化锁死演进 | 后续需求可能改表(第 27 章迭代) |
每一行都在本章正文里有完整的论证:ER 建模在 6.2,快照在 6.3,整数分在 6.3,JSONB 在 6.3,password_hash 在 6.1/6.5,索引预告在 6.5,MVP 粒度在 6.6。和前面章节的对应表一样,先看"业务诉求"列——每一行都是从建表事故或需求清单里长出来的。
把这张表放回全书地图(第 3 章的 Web 系统总架构):数据模型是数据层的第一张图纸,第 14 章会放大它的另一半(索引、事务、缓存怎么让这张图纸跑得快);同时它是业务逻辑层的地基——第 7 章的接口、第 12 章的服务分层、第 17 章的交易逻辑,全部建在这六张表上。第 6 章画的不是六张孤立的表,是整个系统下半身的地基图。
本章小结
建模速查(后续章节回指本章时翻回这里):
- 先 ER 后 DDL:实体→表、关系→外键/关联表,基数从"一"读向"多";
- 字段落点:需求清单每行验收标准都要在表里找到落点(password_hash / 整数分 / 唯一约束 / CHECK);
- 三大字段决策:主键(内部自增 + 业务单号)、金额(整数"分")、快照(历史不可变,粒度够展示不多存);
- 范式是基准不是目标:规范化防重复,反规范化换正确/速度,用九步决策权衡;
- 冗余本身不是错,无意识的冗余才是错;
- 建模粒度:够用且留有余地;过早细化锁死演进(第 27 章回收)。
(前三条是建表前的功夫,后三条是建表时的分寸——六条记住,建模的返工少一半。)
- 建表是业务模型到数据模型的翻译,三层:实体→表、关系→外键/关联表、业务规则→字段类型与约束——翻译的落点永远是字段;
- 建模决策的典型是"冗余换正确":订单快照(历史不可变)、整数分(精度)、JSONB(灵活性)——冗余本身不是错,无意识的冗余才是错;
- 范式是基准不是目标:规范化防重复,反规范化换性能/正确性,用第 5 章的决策模型权衡;
- 建模的边界:过早细化锁死演进,正确粒度是"够用且留有余地";表结构错了代价来得特别晚,这正是决策模型在建模里最值钱的原因;
- 订单/支付表先建模不启用(第 4 章的承诺兑现);状态字段先留好,状态机第 17 章接。
下一章
数据模型定了:六张表,五条关系,字段里埋着业务规则。但表只是"数据怎么存"的答案,还差最后一个问题:业务动作怎么变成接口?用户"发布内容"、卖家"上架商品"、买家"下单付款"——这些动作怎么变成前后端之间、系统之间的请求?
下一章,API 设计:业务动作如何变成接口——"从业务到系统"流水线的最后一张图纸。读完第 7 章你会发现:这六张表上的每个业务动作(发布、上架、下单),都会变成接口,而接口背后查的就是这六张表——数据模型是接口的舞台,接口是数据模型的演员。