这是《把 DDIA 读厚》系列的第四篇文章。今天,咱们不聊那些高大上的分布式共识,而是回到一切开始之前,聊一个每个后端工程师都必须面对的、最朴素也最重要的问题:你的数据,到底应该怎么存?

引子:建表,还是塞个 JSON?

老哥们,拿到一个新需求,是不是脑子里第一反应就是“这数据存哪个库,表怎么建”?紧接着,灵魂拷问就来了:

  • 是一板一眼地遵循三范式,把数据拆分到好几张关联的表里,然后靠 JOIN 过活?
  • 还是图个痛快,直接在表里弄个 TEXTJSON 字段,把整个对象序列化之后“一把梭”塞进去?

这个问题,表面上是“规范”与“便捷”之争,实际上,背后是两种数据模型哲学的激烈碰撞。这个选择,将在你写下第一行代码之前,就深远地影响你整个系统的架构、性能和未来的可维护性。

回顾与衔接:那些年,我们维护过的"屎山"

在上一篇的结尾,我们留下了一个关于“屎山”系统的思考题。很多时候,一个系统之所以变得难以维护,正是源于其早期做出的、看似无伤大雅的数据模型选择。一个不恰当的模型,会像一根歪掉的顶梁柱,让后续所有的添砖加瓦都变得异常痛苦。

本篇锚点:一切始于数据模型

我们今天讨论的“锚点”,是 DDIA 在第二章开篇的核心论断:

数据模型可能是软件开发中最重要的部分,它不仅影响软件的编写方式,更影响我们对问题的思考方式。

你选择用关系模型还是文档模型,这个决定,定义了你的数据世界观。接下来,我们就通过一个每个 Go 工程师都感同身受的例子,来看看这两种世界观的巨大差异。

发散深潜:一个 Go struct 的"坎坷下凡路"

1. 天堂:我们的"完美"Go struct

在我们的代码世界里,业务对象是纯洁无瑕、高度内聚的。比如,我们要为一个求职网站设计一个“用户简历”结构体,在 Go 里它长这样,非常自然:

Go

type UserProfile struct {
    ID          int64
    Name        string
    Summary     string
    Positions   []Position  // 工作经历
    Educations  []Education // 教育背景
}

type Position struct {
    JobTitle     string
    Organization string
    StartDate    time.Time
    EndDate      time.Time
}

type Education struct {
    SchoolName string
    Degree     string
    StartDate  time.Time
    EndDate    time.Time
}

在内存里,它就是一个清晰的、自包含的树状结构。我们操作它,就是一个整体。

2. 凡间第一站(关系模型):惨遭"大卸八块"

现在,这个完美的 UserProfile struct 要“下凡”持久化到我们最熟悉的 MySQL 里。于是,一场“悲剧”发生了:

  • ID, Name, Summary 这些简单字段,被存入了 users 表。
  • Positions 这个切片,里面的每一个 Position 元素,都被拆出来,存入了 positions 表。为了知道这些工作经历属于谁,我们还得加个 user_id 外键。
  • Educations 切片也一样,被存入了 education 表,同样需要 user_id

看,为了存储一个对象,我们却要同时操作三张表。当要读取时,又需要一个三表 JOIN 的复杂查询,才能在内存里把这个对象辛辛苦苦地“组装”回来。

这种应用代码里的“单一整体”和数据库里的“多张碎表”之间的转换和映射的别扭感觉,就是 DDIA 所说的“对象-关系阻抗不匹配(Object-Relational Impedance Mismatch)”。

3. 凡间第二站(文档模型):“救赎"与新的"困境”

此时,文档数据库(如 MongoDB)像“救世主”一样出现了。它可以完美地解决上面的问题。整个 UserProfile struct 可以被序列化成一个 JSON,作为一个单一文档存进去。

JSON

{
  "id": 123,
  "name": "张三",
  "summary": "资深后端工程师...",
  "positions": [
    { "job_title": "高级工程师", "organization": "A公司", ... },
    { "job_title": "架构师", "organization": "B公司", ... }
  ],
  "educations": [ ... ]
}

一次写入,一次读取,干脆利落,几乎没有“阻抗”。爽!

但是,爽是有代价的。 当你的数据关系不再是简历这种简单的树状,而是出现了多对多的网状关系时,文档模型的“阻抗不匹配”就来了。

比如,简历里的“公司”应该是一个独立的实体,很多人可能都在同一个“A 公司”工作过。这时,你怎么办?

  • 方案 A(嵌入):你在每份简历里都冗余地存一份“A 公司”的详细信息。如果 A 公司改名了,你就得去更新所有曾在 A 公司工作过的成千上万份“简历”文档。这简直是场灾难。
  • 方案 B(引用):你在“简历”文档里只存一个 company_id。当需要显示公司名时,你的 Go 代码就得先查出简历,再根据 company_id 去发起第二次查询获取公司信息。这等于把 JOIN 的工作从数据库硬生生搬到了你的应用代码里。

看,文档模型并没有消灭“阻抗不匹配”,它只是在这种场景下,将“阻抗”从数据库层转移到了你的应用层。

4. 历史的回响:今天的我们,昨天的他们

DDIA 提出了一个惊人的观点:今天的文档数据库,像极了上世纪 70 年代的层次模型数据库 IMS。IMS 当时也是王者,数据结构和今天的 JSON 如出一辙,同样擅长处理一对多关系,也同样在多对多关系上栽了跟头。

最终,关系模型凭借其灵活的 JOIN 和声明式的 SQL,击败了 IMS 和网络模型,统治了世界三十年。

这段历史给我们的启发是:技术是个圈。我们今天在文档模型上遇到的多对多关系的纠结,半个世纪前的工程师们早已经历过。 理解这一点,能让我们在做技术选型时,多一分清醒,少一分盲从。

看到这里,有经验的工程师可能会有个疑问:今天我们津津乐道的文档模型,把数据按树状结构嵌套存储,这听起来和上世纪 70 年代就被关系模型“淘汰”掉的层次模型数据库(如 IMS)何其相似。难道说,技术发展了半个世纪,只是在原地打转吗?

这当然不是技术的倒退。用“螺旋上升”来形容这个过程,要精确得多。

我们确实是在一个相似的“问题地形”上作战——即如何高效处理**“一对多”的、自包含的树状数据**——但我们今天的武器装备,早已鸟枪换炮。当年的 IMS 运行在内存和算力极其宝贵的巨型机上,而今天的文档数据库,则享受着海量内存、高速网络和原生分布式架构的红利。它们的查询语言、灵活性和容错能力,更是 IMS 望尘莫及的。

然而,尽管技术天翻地覆,那个根本性的架构权衡却从未改变。

这个永恒的权衡就是:当你选择一个为特定场景高度优化的“专家”时,你必然会牺牲它在其他场景下的“通用性”。

文档模型,就是一位处理“树状数据”的顶级专家。它能用最自然、最高效的方式来存取一份简历、一张订单或者一篇博客及其评论。这是它的“专长领域”。

而它为此付出的“代价”,就是在处理高度互联、复杂交织的**“网状数据”(多对多关系)**时,会变得笨拙。这时,反而是看似“传统”的关系模型和它的 JOIN 操作,来得更直接、更优雅。

所以,理解这段历史,不是为了厚古薄今,或者给技术选型下一个简单的结论。它的真正价值在于,赋予我们一种架构上的“模式识别”能力。

它能帮助我们超越“哪个技术更时髦”的表面争论,在接到一个新需求时,能立刻在脑中判断出其核心数据的“形状”,并清醒地自问:“我眼前的这个‘问题地形’,究竟是更像一棵树,还是一张网?”

只有回答了这个问题,我们才能真正做出明智的、经得起时间考验的技术抉择。

收束:我们能学到什么?

给 Go 开发者的代码级清单

  1. 优雅地处理 NULL:当你的 Go 代码与关系型数据库交互时,请善用 database/sql 包中的 sql.NullString, sql.NullInt64 等类型。这能让你清晰地处理数据库中 NULL 和空值(''0)的区别。
  2. 防御性解析 JSON:当你的 Go 代码处理来自文档数据库的 JSON 时,要时刻假设任何字段都可能缺失。在 struct 中使用指针类型 *string,或者利用 json 标签的 omitempty 选项,能帮你更好地处理数据的不确定性。
  3. 选型心法:如果你的核心业务对象是自包含的、很少需要与其他对象做复杂关联的(比如一篇文章和它的评论),文档模型可能非常适合。如果你的对象之间引用关系复杂(比如一个电商订单关联了用户、多个商品、优惠券、仓库等),关系模型通常是更稳妥、长期来看更易维护的选择。

给准架构师的架构级教训

  1. 洞察数据的“关系重心”:作为架构师,首要任务是洞察业务领域的核心数据结构。数据的关系“重心”是层次化的(一对多),还是网络化的(多对多)?这个判断是所有数据存储决策的基石。
  2. 权衡“灵活性”与“约束”:DDIA 提出了“写时模式”与“读时模式”的对比。这本质上是在权衡“前期灵活性”和“长期维护成本”。架构师需要决定,管理数据多样性的“痛苦”应该由谁(数据库还是应用层)、在哪个阶段来承担。
  3. 预判数据的“成长性”:数据之间的连接只会越来越多。今天的简单文档,明天可能就要关联五个新实体。架构师需要选择一个不仅能解决当前问题,更能优雅地演进以支持未来更复杂连接的模型。避免让团队在一年后陷入“模拟 JOIN”的地狱。