土法炼钢兴趣小组的算法知识备份

【系统架构设计百科】数据建模:从关系范式到文档模型的真实权衡

文章导航

分类入口
architecture
标签入口
#data-modeling#normalization#document-model#graph-model#relational

目录

你刚入职一家电商公司,接手一个订单系统。代码仓库里有 12 个微服务,其中 6 个用 MySQL,3 个用 MongoDB,2 个用 Redis 做主存储,还有一个用 Neo4j 存社交关系图谱。你问前任架构师为什么选了这么多存储引擎,得到的回答是”当时觉得文档模型灵活”“图数据库查关系快”“Redis 延迟低”。

三个月后你发现:MongoDB 里的订单集合嵌套了 7 层,查询一个用户的历史订单要写 40 行聚合管道(Aggregation Pipeline);MySQL 里的商品表反范式化到一张表有 87 个字段,改一个商品分类要更新几十万行;Neo4j 里存了用户的好友关系,但实际业务只需要查两层好友推荐,用 SQL 的递归 CTE 完全够用。

这就是数据建模决策的真实代价。选错模型不会立刻报错,它会在数据量增长、业务逻辑复杂化之后以查询性能恶化、代码维护成本飙升、数据一致性问题频发的方式慢慢显现。

这篇文章要回答两个核心问题:范式化(Normalization)和反范式化(Denormalization)的决策分界线在哪里?文档模型(Document Model)真的比关系模型(Relational Model)更灵活吗?

我们从关系范式的工程意义讲起,然后分析反范式化的真实决策点,拆解文档模型的 join 困境,讨论图模型的适用边界,最后用一个电商订单系统从 0 到 1 的建模演进案例把所有知识串起来。


一、关系范式的工程意义:不只是考试题

大多数工程师对范式的记忆停留在大学数据库课程的考试题上:判断一张表是不是第三范式(3NF),画出函数依赖图,做分解。但在实际工程中,范式化的核心价值不是”形式上的正确”,而是三个非常具体的工程目标:

  1. 消除数据冗余——同一份数据只存一处,更新时不会出现不一致
  2. 减少更新异常——插入、删除、修改操作不会产生意外的副作用
  3. 保持数据独立性——业务逻辑变化时,模式的改动范围可控

下面我们逐一拆解四个常用范式的工程含义,不背定义,看真实的表结构和问题。

1.1 第一范式(1NF):原子性的真实含义

1NF 要求每个字段的值是不可再分的原子值。听起来简单,但实际中违反 1NF 的情况比你想象得多。

来看一个真实的订单表:

CREATE TABLE orders_v0 (
    order_id    BIGINT PRIMARY KEY,
    user_id     BIGINT NOT NULL,
    items       TEXT NOT NULL,  -- "iPhone 15:2:7999,AirPods:1:999"
    address     TEXT NOT NULL,  -- "北京市海淀区中关村大街1号3单元502"
    created_at  TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

items 字段把商品名、数量、价格用逗号和冒号拼在一起,address 把省、市、区、街道、门牌号合成一个字符串。这在早期原型阶段很常见——“先跑起来再说”。

问题在哪?

-- 查询买过 AirPods 的订单:需要 LIKE 模糊匹配,无法走索引
SELECT * FROM orders_v0 WHERE items LIKE '%AirPods%';

-- 统计每个商品的销量:需要在应用层解析字符串
-- SQL 层面无法直接聚合

-- 修改商品名称(比如 "AirPods" 改为 "AirPods 4"):
-- 需要全表扫描 + 字符串替换,极易出错
UPDATE orders_v0
SET items = REPLACE(items, 'AirPods', 'AirPods 4')
WHERE items LIKE '%AirPods%';
-- 如果有商品叫 "AirPods Pro",这条 UPDATE 会把它改成 "AirPods 4 Pro"

1NF 的解法是把复合字段拆成独立的行和列:

CREATE TABLE orders (
    order_id    BIGINT PRIMARY KEY,
    user_id     BIGINT NOT NULL,
    created_at  TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE order_items (
    id          BIGINT PRIMARY KEY AUTO_INCREMENT,
    order_id    BIGINT NOT NULL,
    product_id  BIGINT NOT NULL,
    quantity    INT NOT NULL,
    unit_price  DECIMAL(10,2) NOT NULL,
    FOREIGN KEY (order_id) REFERENCES orders(order_id)
);

CREATE TABLE addresses (
    id          BIGINT PRIMARY KEY AUTO_INCREMENT,
    user_id     BIGINT NOT NULL,
    province    VARCHAR(20) NOT NULL,
    city        VARCHAR(20) NOT NULL,
    district    VARCHAR(20) NOT NULL,
    street      VARCHAR(100) NOT NULL,
    detail      VARCHAR(200) NOT NULL
);

现在查”买过 AirPods 的订单”变成了一个精确的 join 查询:

SELECT o.order_id, o.created_at
FROM orders o
JOIN order_items oi ON o.order_id = oi.order_id
JOIN products p ON oi.product_id = p.product_id
WHERE p.product_name = 'AirPods';

可以在 products.product_name 上建索引,查询效率从全表扫描变成索引查找。

工程结论:1NF 不是教条——它的本质是让数据库引擎能理解你的数据结构,从而利用索引、约束、聚合函数来保障查询效率和数据完整性。如果一个字段需要被查询、过滤、聚合,那它就必须是原子的。

1.2 第二范式(2NF):消除部分依赖

2NF 在 1NF 基础上要求:非主属性必须完全依赖于候选键(Candidate Key),不能只依赖键的一部分。这在复合主键的表中最容易出问题。

考虑一张课程成绩表:

CREATE TABLE student_courses_v0 (
    student_id      BIGINT NOT NULL,
    course_id       BIGINT NOT NULL,
    student_name    VARCHAR(50) NOT NULL,
    course_name     VARCHAR(100) NOT NULL,
    department      VARCHAR(50) NOT NULL,  -- 学生所在院系
    score           DECIMAL(5,2),
    PRIMARY KEY (student_id, course_id)
);

主键是 (student_id, course_id)。问题是 student_namedepartment 只依赖于 student_idcourse_name 只依赖于 course_id。这就是部分依赖(Partial Dependency)。

工程后果:

-- 一个学生选了 10 门课,student_name 和 department 重复存了 10 遍
-- 学生转院系(department 变更),需要更新 10 行
UPDATE student_courses_v0
SET department = '计算机学院'
WHERE student_id = 2024001;
-- 如果漏更新一行,同一个学生在不同行有不同院系——数据不一致

-- 一个新课程还没有学生选,无法插入课程信息
-- 因为 student_id 是主键的一部分,不能为 NULL

2NF 的解法是按依赖关系拆表:

CREATE TABLE students (
    student_id  BIGINT PRIMARY KEY,
    name        VARCHAR(50) NOT NULL,
    department  VARCHAR(50) NOT NULL
);

CREATE TABLE courses (
    course_id   BIGINT PRIMARY KEY,
    name        VARCHAR(100) NOT NULL
);

CREATE TABLE enrollments (
    student_id  BIGINT NOT NULL,
    course_id   BIGINT NOT NULL,
    score       DECIMAL(5,2),
    PRIMARY KEY (student_id, course_id),
    FOREIGN KEY (student_id) REFERENCES students(student_id),
    FOREIGN KEY (course_id) REFERENCES courses(course_id)
);

工程结论:2NF 解决的是”一张表混了多个实体”的问题。判断标准很简单:如果你的表有复合主键,检查非键字段是不是只依赖键的一部分。如果是,就该拆。

1.3 第三范式(3NF):消除传递依赖

3NF 要求:非主属性之间不能有传递依赖。也就是说,非键字段 A 依赖于非键字段 B,B 依赖于主键——A 通过 B 间接依赖于主键,这就是传递依赖(Transitive Dependency)。

经典例子是员工表:

CREATE TABLE employees_v0 (
    emp_id          BIGINT PRIMARY KEY,
    emp_name        VARCHAR(50) NOT NULL,
    dept_id         BIGINT NOT NULL,
    dept_name       VARCHAR(50) NOT NULL,
    dept_location   VARCHAR(100) NOT NULL
);

dept_namedept_location 依赖于 dept_id,而 dept_id 依赖于 emp_id。传递依赖的工程后果:

解法同样是拆表:

CREATE TABLE departments (
    dept_id     BIGINT PRIMARY KEY,
    name        VARCHAR(50) NOT NULL,
    location    VARCHAR(100) NOT NULL
);

CREATE TABLE employees (
    emp_id      BIGINT PRIMARY KEY,
    name        VARCHAR(50) NOT NULL,
    dept_id     BIGINT NOT NULL,
    FOREIGN KEY (dept_id) REFERENCES departments(dept_id)
);

1.4 BCNF:主属性也不能有冗余

BC 范式(Boyce-Codd Normal Form)比 3NF 更严格:每个非平凡的函数依赖的决定因素必须是超键(Superkey)。通俗地说,3NF 只限制非主属性,BCNF 连主属性也管。

考虑一个教学安排场景:一门课可以有多个老师教,一个老师只教一门课,一个学生的一门课只能选一个老师。

CREATE TABLE teaching_v0 (
    student_id  BIGINT NOT NULL,
    course_id   BIGINT NOT NULL,
    teacher_id  BIGINT NOT NULL,
    PRIMARY KEY (student_id, course_id)
);
-- 函数依赖:
-- (student_id, course_id) -> teacher_id
-- teacher_id -> course_id  (一个老师只教一门课)

这张表满足 3NF(没有非主属性的传递依赖),但不满足 BCNF——因为 teacher_id -> course_id 中,teacher_id 不是超键。

工程后果:如果教师 T1 教课程 C1,有 200 个学生选了 T1 的课,那 teacher_id -> course_id 这个关系重复存了 200 遍。如果 T1 换了一门课,需要更新 200 行。

BCNF 分解:

CREATE TABLE teacher_courses (
    teacher_id  BIGINT PRIMARY KEY,
    course_id   BIGINT NOT NULL
);

CREATE TABLE student_teachers (
    student_id  BIGINT NOT NULL,
    teacher_id  BIGINT NOT NULL,
    PRIMARY KEY (student_id, teacher_id),
    FOREIGN KEY (teacher_id) REFERENCES teacher_courses(teacher_id)
);

关于 BCNF 的工程判断:实践中大多数系统做到 3NF 就够了。BCNF 分解有时会丢失函数依赖(即分解后无法用单表约束来保证原来的依赖关系),需要在应用层补偿。只有当数据冗余确实造成了一致性问题时,才值得追到 BCNF。

1.5 范式的整体工程视角

下面这张图展示了从非范式化到 BCNF 的演进路径和每一步解决的核心问题:

graph TD
    A["非范式化表<br/>(复合字段、重复组)"] -->|消除复合字段| B["1NF<br/>字段原子化"]
    B -->|消除部分依赖| C["2NF<br/>拆分复合键表"]
    C -->|消除传递依赖| D["3NF<br/>拆分间接依赖"]
    D -->|消除主属性冗余| E["BCNF<br/>所有决定因素都是超键"]

    A -.- A1["问题:无法索引、无法聚合"]
    B -.- B1["问题:复合键表中的冗余"]
    C -.- C1["问题:非键字段之间的依赖"]
    D -.- D1["问题:主属性的冗余依赖"]
    E -.- E1["可能丢失函数依赖"]

    style A fill:#ffcccc
    style B fill:#ffe0cc
    style C fill:#fff2cc
    style D fill:#ccffcc
    style E fill:#cce5ff

关键认知:范式化不是目的,它是消除冗余和异常的工具。工程师的工作不是”把所有表都做到 3NF”,而是理解每一级范式解决了什么问题,在具体场景中判断”冗余带来的维护成本是否超过了 join 带来的查询成本”。这个判断直接引出下一节的主题——反范式化。


二、反范式化的决策点:何时冗余是正确选择

范式化消除了冗余,但代价是引入了 join。对于 OLTP(在线事务处理)系统中的简单查询,join 的成本几乎可以忽略。但当数据量达到亿级、查询模式高度固定、读写比严重倾斜时,join 可能成为性能瓶颈。这时候,有策略地引入冗余——也就是反范式化——是正当的工程决策。

但关键词是有策略地。我见过太多团队把”反范式化”当作”随便设计”的借口,搞出 100 列的大宽表,每次业务需求变更都痛不欲生。

2.1 反范式化的四个合理场景

场景一:读密集型查询的性能优化

一个电商平台的商品列表页,每次请求需要展示:商品名、价格、分类名、品牌名、店铺名、主图 URL。如果完全范式化,这个查询需要 join 5 张表:

SELECT
    p.name AS product_name,
    p.price,
    c.name AS category_name,
    b.name AS brand_name,
    s.name AS shop_name,
    pi.url AS main_image_url
FROM products p
JOIN categories c ON p.category_id = c.category_id
JOIN brands b ON p.brand_id = b.brand_id
JOIN shops s ON p.shop_id = s.shop_id
JOIN product_images pi ON p.product_id = pi.product_id AND pi.is_main = 1;

商品列表页的读写比是 1000:1 以上。分类名、品牌名、店铺名这些字段几乎不会变。在这个场景下,把这些字段冗余到商品表是合理的:

CREATE TABLE products_denormalized (
    product_id      BIGINT PRIMARY KEY,
    name            VARCHAR(200) NOT NULL,
    price           DECIMAL(10,2) NOT NULL,
    category_id     BIGINT NOT NULL,
    category_name   VARCHAR(50) NOT NULL,   -- 冗余字段
    brand_id        BIGINT NOT NULL,
    brand_name      VARCHAR(50) NOT NULL,    -- 冗余字段
    shop_id         BIGINT NOT NULL,
    shop_name       VARCHAR(50) NOT NULL,    -- 冗余字段
    main_image_url  VARCHAR(500),            -- 冗余字段
    updated_at      TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);

但你必须建立同步机制——当品牌改名时,冗余字段也要更新:

@Transactional
public void updateBrandName(Long brandId, String newName) {
    // 1. 更新品牌主表
    brandRepository.updateName(brandId, newName);

    // 2. 异步更新冗余字段(品牌改名频率极低,异步即可)
    eventPublisher.publish(new BrandNameChangedEvent(brandId, newName));
}

@EventListener
public void onBrandNameChanged(BrandNameChangedEvent event) {
    // 批量更新商品表中的冗余字段
    productRepository.updateBrandName(event.getBrandId(), event.getNewName());
}

场景二:聚合查询的预计算

一个数据分析仪表盘需要展示”每日各品类的销售额”。完全范式化的查询:

SELECT
    DATE(o.created_at) AS sale_date,
    c.name AS category_name,
    SUM(oi.quantity * oi.unit_price) AS total_amount
FROM orders o
JOIN order_items oi ON o.order_id = oi.order_id
JOIN products p ON oi.product_id = p.product_id
JOIN categories c ON p.category_id = c.category_id
WHERE o.created_at >= '2025-01-01'
GROUP BY DATE(o.created_at), c.name;

当订单量达到千万级,这个查询的执行时间可能超过 30 秒。解法是用物化视图(Materialized View)或预计算表:

CREATE TABLE daily_category_sales (
    sale_date       DATE NOT NULL,
    category_id     BIGINT NOT NULL,
    category_name   VARCHAR(50) NOT NULL,
    total_amount    DECIMAL(15,2) NOT NULL DEFAULT 0,
    order_count     INT NOT NULL DEFAULT 0,
    updated_at      TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    PRIMARY KEY (sale_date, category_id)
);

通过定时任务或事件驱动增量更新:

func updateDailyCategorySales(ctx context.Context, order Order) error {
    for _, item := range order.Items {
        _, err := db.ExecContext(ctx, `
            INSERT INTO daily_category_sales (sale_date, category_id, category_name, total_amount, order_count)
            VALUES (CURDATE(), ?, ?, ?, 1)
            ON DUPLICATE KEY UPDATE
                total_amount = total_amount + VALUES(total_amount),
                order_count = order_count + 1,
                updated_at = CURRENT_TIMESTAMP
        `, item.CategoryID, item.CategoryName, item.Amount())
        if err != nil {
            return fmt.Errorf("update daily sales: %w", err)
        }
    }
    return nil
}

场景三:跨服务查询的数据冗余

在微服务架构中,服务之间不能直接访问对方的数据库。订单服务需要展示用户姓名,但用户数据在用户服务。两种选择:

  1. 每次查询都调用户服务的 API——引入网络延迟和可用性依赖
  2. 在订单服务中冗余一份用户姓名——通过事件同步保持一致

当读频率远高于用户改名频率时,选择 2 通常是更好的工程决策。

场景四:历史快照的业务需求

订单创建时的商品价格、收货地址,这些数据在订单维度上是不可变的。即使后来商品涨价了、用户改了地址,订单上记录的应该是下单时刻的值。这不是冗余——这是业务语义要求的快照(Snapshot)。

CREATE TABLE order_snapshots (
    order_id            BIGINT PRIMARY KEY,
    user_name_snapshot  VARCHAR(50) NOT NULL,    -- 下单时的用户名
    address_snapshot    JSON NOT NULL,            -- 下单时的收货地址
    items_snapshot      JSON NOT NULL,            -- 下单时的商品信息(含价格)
    created_at          TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

2.2 反范式化的决策框架

不是每个 join 都需要消除。以下是一个实用的决策清单:

判断维度 倾向范式化 倾向反范式化
读写比 读写均衡或写多读少 读远多于写(100:1 以上)
数据变更频率 冗余字段经常变更 冗余字段几乎不变
查询模式 查询模式多变、临时查询多 查询模式高度固定
数据量级 千万级以下 亿级以上
一致性要求 强一致性、不能容忍延迟 可接受秒级或分钟级的最终一致
架构边界 单体或单库 跨服务、跨库
业务语义 引用关系(如当前地址) 快照关系(如下单时地址)

核心原则:反范式化是用存储空间和一致性维护成本换取查询性能。每次做这个决策时,你必须明确回答两个问题:

  1. 冗余数据变更时的同步机制是什么?(事件驱动?定时任务?触发器?)
  2. 同步延迟的业务影响是什么?(用户改名后 5 分钟内历史订单显示旧名字,能接受吗?)

如果你回答不了这两个问题,就不要反范式化。


三、文档模型的 join 困境

2010 年代 NoSQL 浪潮中,文档数据库(Document Database)以 MongoDB 为代表,凭借”无模式(Schema-less)“和”灵活嵌套”吸引了大量开发者。但经过十多年的工程实践,行业对文档模型的认知已经从”银弹论”回归到”权衡论”。

3.1 文档模型的核心优势:数据局部性

文档模型的核心设计理念是:把一起被访问的数据放在一起存储。这叫数据局部性(Data Locality)。

一个博客文章在关系模型中可能分散在 4 张表里:

-- 关系模型:4 张表
SELECT * FROM posts WHERE id = 42;
SELECT * FROM comments WHERE post_id = 42;
SELECT * FROM tags WHERE tag_id IN (SELECT tag_id FROM post_tags WHERE post_id = 42);
SELECT * FROM authors WHERE id = (SELECT author_id FROM posts WHERE id = 42);

在文档模型中,它是一个完整的文档:

{
    "_id": "42",
    "title": "数据建模的权衡",
    "author": {
        "name": "张三",
        "email": "zhangsan@example.com"
    },
    "tags": ["architecture", "database", "modeling"],
    "comments": [
        {
            "user": "李四",
            "text": "写得好",
            "created_at": "2025-03-15T10:30:00Z"
        },
        {
            "user": "王五",
            "text": "第三节有点问题",
            "created_at": "2025-03-15T11:00:00Z"
        }
    ],
    "created_at": "2025-03-15T08:00:00Z"
}

一次 I/O 就能拿到所有相关数据,不需要 join。对于”按 ID 查询一个实体及其所有关联数据”这种访问模式,文档模型的性能天然优于关系模型。

3.2 join 困境:当关系穿透文档边界

但数据不是孤岛。一旦数据之间存在多对多(Many-to-Many)关系,文档模型就开始暴露问题。

回到电商场景。一个商品属于一个分类,一个分类下有很多商品。如果把分类信息嵌入商品文档:

{
    "_id": "product_001",
    "name": "机械键盘",
    "price": 599,
    "category": {
        "id": "cat_electronics",
        "name": "电子产品",
        "path": "数码 > 电脑外设 > 键盘"
    }
}

现在”电子产品”这个分类要改名为”消费电子”。你需要更新所有属于这个分类的商品文档。在 MongoDB 中:

// 更新所有包含该分类的商品文档
db.products.updateMany(
    { "category.id": "cat_electronics" },
    {
        $set: {
            "category.name": "消费电子",
            "category.path": "数码 > 电脑外设 > 键盘"
            // 等等,path 也要改,而且不同子分类的 path 不一样...
        }
    }
);
// 这个操作在百万级商品集合上可能需要几分钟
// 而且更新过程中,部分文档已更新、部分未更新——不一致窗口

在关系模型中,改分类名只需要更新一行:

UPDATE categories SET name = '消费电子' WHERE id = 'cat_electronics';
-- 一行更新,所有引用这个分类的查询自动拿到新名字

这就是文档模型和关系模型的根本区别:关系模型通过引用(Reference)消除冗余,文档模型通过嵌入(Embedding)追求局部性。两者不可兼得。

3.3 MongoDB 的 $lookup:不是真正的 join

MongoDB 3.2 引入了 $lookup 操作符,可以在聚合管道中做类似 SQL join 的操作:

db.orders.aggregate([
    {
        $lookup: {
            from: "users",
            localField: "user_id",
            foreignField: "_id",
            as: "user_info"
        }
    },
    {
        $lookup: {
            from: "products",
            localField: "items.product_id",
            foreignField: "_id",
            as: "product_info"
        }
    }
]);

看起来和 SQL 的 join 类似,但有几个关键差异:

  1. 性能特征不同$lookup 在内部实现上是嵌套循环(Nested Loop),没有关系数据库的 Hash Join 或 Sort-Merge Join 优化器。对于大集合的 join,性能远不如 PostgreSQL 或 MySQL。

  2. 不能跨分片 join:在分片集群(Sharded Cluster)中,$lookupfrom 集合必须在同一个数据库中,且不能是分片集合(MongoDB 5.1 之前)。这意味着你最需要 join 的时候(数据量大到需要分片),恰恰用不了 $lookup

  3. 事务语义有限:多文档事务(Multi-Document Transaction)在 MongoDB 4.0 才引入,性能开销比关系数据库的事务大得多,尤其是在分片集群中。

  4. 索引限制:嵌套数组中的字段索引(Multikey Index)有诸多限制,复合索引不能包含多个数组字段。

3.4 嵌入 vs 引用的决策模型

MongoDB 官方文档和社区博客总结出的嵌入/引用决策模型:

graph TD
    Q1{"关联数据是否总是<br/>和父文档一起被查询?"}
    Q1 -- 是 --> Q2{"关联数据的<br/>基数是多少?"}
    Q1 -- 否 --> REF["使用引用(Reference)"]

    Q2 -- "一对一 / 一对少" --> EMB["嵌入(Embed)"]
    Q2 -- "一对多(有限)" --> Q3{"子文档是否需要<br/>被独立查询?"}
    Q2 -- "一对非常多 / 多对多" --> REF

    Q3 -- 否 --> EMB
    Q3 -- 是 --> REF

    EMB -.- E1["优点:一次读取、原子更新<br/>缺点:文档膨胀、冗余"]
    REF -.- R1["优点:避免冗余、独立访问<br/>缺点:需要额外查询"]

    style EMB fill:#ccffcc
    style REF fill:#cce5ff

实际工程中的指导原则:

场景 建议 原因
用户的收货地址(一对几) 嵌入 地址数量有限,总是和用户一起读取
文章的评论(一对多) 引用 评论可能有上千条,需要分页独立查询
商品的分类标签(多对多) 引用 分类被多个商品共享,需要独立维护
订单的商品快照(一对多,不可变) 嵌入 快照数据不可变,总是和订单一起读取
用户的好友关系(多对多) 引用(或换图模型) 双向多对多关系无法用嵌入表达

3.5 真实案例:某社交电商的 MongoDB 困局

2019 年我参与过一个社交电商平台的架构审查。这个平台初期选择 MongoDB 作为主存储,理由是”文档模型灵活,适合快速迭代”。

初始数据模型大致如下:

{
    "_id": "order_20190501_001",
    "user": {
        "id": "user_123",
        "name": "张三",
        "phone": "138xxxx0001",
        "level": "VIP3",
        "referrer": {
            "id": "user_050",
            "name": "李四",
            "level": "VIP5"
        }
    },
    "items": [
        {
            "product_id": "prod_789",
            "name": "有机大米 5kg",
            "price": 89.9,
            "quantity": 2,
            "shop": {
                "id": "shop_456",
                "name": "绿色农场旗舰店",
                "commission_rate": 0.15
            }
        }
    ],
    "payment": {
        "method": "wechat",
        "amount": 179.8,
        "paid_at": "2019-05-01T10:30:00Z"
    },
    "commissions": [
        {
            "user_id": "user_050",
            "user_name": "李四",
            "amount": 26.97,
            "level": 1
        },
        {
            "user_id": "user_020",
            "user_name": "王五",
            "amount": 8.99,
            "level": 2
        }
    ]
}

这个设计把用户信息、商品信息、店铺信息、佣金信息全部嵌入订单文档。初期体验很好——查询一个订单的完整信息只需一次读取。

但随着业务发展,问题逐渐暴露:

问题一:用户等级变更的级联更新

社交电商的核心是分佣体系,用户等级直接决定佣金比例。用户升级 VIP 等级后,需要更新所有包含该用户信息的文档:

// 用户 user_123 从 VIP3 升级到 VIP4
// 需要更新:
// 1. 所有 user_123 的订单文档中的 user.level
// 2. 所有 user_123 作为推荐人的订单文档中的 user.referrer.level
// 3. 所有 user_123 在佣金列表中的记录

// 第一类更新
db.orders.updateMany(
    { "user.id": "user_123" },
    { $set: { "user.level": "VIP4" } }
);

// 第二类更新
db.orders.updateMany(
    { "user.referrer.id": "user_123" },
    { $set: { "user.referrer.level": "VIP4" } }
);

// 第三类更新——这个最麻烦,需要操作嵌套数组
db.orders.updateMany(
    { "commissions.user_id": "user_123" },
    { $set: { "commissions.$[elem].level_at_update": "VIP4" } },
    { arrayFilters: [{ "elem.user_id": "user_123" }] }
);

当订单量达到 500 万时,一次用户等级变更需要扫描和更新上万个文档,耗时超过 10 秒。

问题二:佣金统计的聚合困难

业务需要查”某段时间内每个推荐人的总佣金”。这个查询需要 $unwind 拆开嵌套数组再 $group

db.orders.aggregate([
    { $match: { "payment.paid_at": { $gte: ISODate("2019-05-01"), $lt: ISODate("2019-06-01") } } },
    { $unwind: "$commissions" },
    { $group: {
        _id: "$commissions.user_id",
        total_commission: { $sum: "$commissions.amount" },
        order_count: { $sum: 1 }
    }},
    { $sort: { total_commission: -1 } },
    { $limit: 100 }
]);

500 万订单、平均每单 3 条佣金记录——$unwind 把数据膨胀到 1500 万行再聚合,耗时超过 30 秒。同样的查询在 MySQL 中通过独立的佣金表 + 索引,不到 1 秒。

最终重构方案:团队在 2020 年初做了重构,把订单系统迁移到 PostgreSQL。核心变化是把嵌入改为引用,恢复了正常的关系建模:

CREATE TABLE orders (
    order_id    VARCHAR(30) PRIMARY KEY,
    user_id     VARCHAR(20) NOT NULL REFERENCES users(user_id),
    referrer_id VARCHAR(20) REFERENCES users(user_id),
    total_amount DECIMAL(10,2) NOT NULL,
    payment_method VARCHAR(20) NOT NULL,
    paid_at     TIMESTAMP,
    created_at  TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE order_items (
    id          SERIAL PRIMARY KEY,
    order_id    VARCHAR(30) NOT NULL REFERENCES orders(order_id),
    product_id  VARCHAR(20) NOT NULL REFERENCES products(product_id),
    shop_id     VARCHAR(20) NOT NULL REFERENCES shops(shop_id),
    quantity    INT NOT NULL,
    unit_price  DECIMAL(10,2) NOT NULL,
    -- 快照字段:下单时刻的商品名和价格
    product_name_snapshot VARCHAR(200) NOT NULL,
    unit_price_snapshot   DECIMAL(10,2) NOT NULL
);

CREATE TABLE commissions (
    id          SERIAL PRIMARY KEY,
    order_id    VARCHAR(30) NOT NULL REFERENCES orders(order_id),
    user_id     VARCHAR(20) NOT NULL REFERENCES users(user_id),
    amount      DECIMAL(10,2) NOT NULL,
    level       INT NOT NULL,
    created_at  TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX idx_commissions_user_created ON commissions(user_id, created_at);

迁移后,用户等级变更只需更新 users 表一行,佣金统计查询降到 200ms 以内。

教训:文档模型的”灵活性”在数据关系简单时是优势,但当实体之间存在复杂的多对多关系和频繁的交叉查询时,它的”灵活性”反而变成了负担。文档模型最适合的场景是:实体边界清晰、访问模式固定、关联关系以一对一或一对有限多为主。


四、图模型的适用场景分析

图数据库(Graph Database)以 Neo4j 为代表,在社交网络、知识图谱、欺诈检测等场景中被频繁提及。但在实际工程中,图数据库的适用边界远比宣传材料说的窄。

4.1 图模型的核心抽象

图模型由三个基本元素组成:

一个社交网络的图结构:

(Alice)-[:FOLLOWS]->(Bob)
(Alice)-[:FOLLOWS]->(Charlie)
(Bob)-[:FOLLOWS]->(Charlie)
(Charlie)-[:LIKES]->(Post_001)
(Alice)-[:WROTE]->(Post_001)

图模型的核心优势在于关系遍历(Traversal)的性能。在关系数据库中,遍历多层关系需要多次 join;在图数据库中,遍历是沿着边的指针直接跳转,时间复杂度与总数据量无关,只与遍历的局部子图大小有关。

4.2 图查询 vs SQL 递归查询

来看一个具体的对比。假设我们要查”Alice 的朋友的朋友中有谁关注了 Charlie”——这是一个两层遍历 + 条件过滤的查询。

Neo4j Cypher 查询

MATCH (alice:User {name: "Alice"})-[:FOLLOWS]->(:User)-[:FOLLOWS]->(fof:User)-[:FOLLOWS]->(charlie:User {name: "Charlie"})
WHERE fof <> alice AND fof <> charlie
RETURN DISTINCT fof.name AS friend_of_friend;

PostgreSQL 递归 CTE 查询

WITH RECURSIVE friends_of_friends AS (
    -- 第一层:Alice 的直接关注
    SELECT
        f.target_user_id AS user_id,
        1 AS depth
    FROM follows f
    WHERE f.source_user_id = (SELECT user_id FROM users WHERE name = 'Alice')

    UNION

    -- 第二层:朋友的关注
    SELECT
        f.target_user_id AS user_id,
        fof.depth + 1 AS depth
    FROM follows f
    JOIN friends_of_friends fof ON f.source_user_id = fof.user_id
    WHERE fof.depth < 2
)
SELECT DISTINCT u.name
FROM friends_of_friends fof
JOIN users u ON fof.user_id = u.user_id
-- 过滤:这些人中谁关注了 Charlie
WHERE EXISTS (
    SELECT 1 FROM follows f2
    WHERE f2.source_user_id = fof.user_id
    AND f2.target_user_id = (SELECT user_id FROM users WHERE name = 'Charlie')
)
AND u.name NOT IN ('Alice', 'Charlie');

SQL 版本的代码量是 Cypher 的 3-4 倍,可读性明显差。但更关键的区别在性能特征。

4.3 性能对比:关系型多跳查询的成本

在一个 100 万用户、5000 万关注关系的数据集上做性能测试:

查询类型 Neo4j PostgreSQL(有索引) MySQL(有索引)
一层关系(直接好友) 1-2ms 2-5ms 3-8ms
两层关系(好友的好友) 5-15ms 20-80ms 30-150ms
三层关系 15-50ms 200ms-2s 500ms-5s
四层关系 30-100ms 2s-30s 5s-超时
五层关系 50-200ms 经常超时 几乎总是超时
最短路径 10-50ms 需要自定义存储过程 极难实现

规律很明显:关系遍历的层数越深,图数据库的优势越大。因为关系数据库的 join 是”全局”操作——每多一层就多一次全表或索引扫描;图数据库的遍历是”局部”操作——从起始节点出发,只访问邻居节点,与数据库总大小无关。

但在只需要一到两层关系查询的场景下,关系数据库完全够用,甚至可能更好(因为事务支持更成熟、运维生态更完善)。

4.4 图数据库的适用场景

场景 推荐模型 理由
社交网络好友推荐(3 层以上) 图模型 多层遍历是核心操作
知识图谱推理 图模型 实体关系复杂、查询模式多变
金融欺诈检测(关联分析) 图模型 需要发现隐藏的关联路径
网络拓扑分析 图模型 最短路径、连通性是核心查询
简单的上下级关系(组织架构) 关系模型 层级有限,递归 CTE 够用
商品分类树 关系模型 树结构固定,关系模型有成熟方案
推荐系统(用户-商品评分) 取决于深度 浅层用协同过滤算法,深层关联走图

4.5 关系数据库中的图查询优化

如果你的场景只需要两到三层关系遍历,且不想引入图数据库的运维复杂度,可以在关系数据库中做一些优化:

方案一:邻接列表 + 索引

CREATE TABLE follows (
    source_user_id BIGINT NOT NULL,
    target_user_id BIGINT NOT NULL,
    created_at     TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (source_user_id, target_user_id),
    INDEX idx_target (target_user_id, source_user_id)
);

两层查询可以用标准 join 替代递归 CTE,性能更好:

-- Alice 的朋友的朋友
SELECT DISTINCT f2.target_user_id
FROM follows f1
JOIN follows f2 ON f1.target_user_id = f2.source_user_id
WHERE f1.source_user_id = 123  -- Alice 的 user_id
AND f2.target_user_id <> 123;

方案二:闭包表(Closure Table)

对于树形或 DAG 结构的关系,预计算所有可达路径:

CREATE TABLE category_closure (
    ancestor_id     BIGINT NOT NULL,
    descendant_id   BIGINT NOT NULL,
    depth           INT NOT NULL,
    PRIMARY KEY (ancestor_id, descendant_id),
    INDEX idx_descendant (descendant_id, ancestor_id)
);

-- 查询某分类下的所有子分类(任意层级):O(1) 复杂度
SELECT descendant_id FROM category_closure
WHERE ancestor_id = 100 AND depth > 0;

-- 查询某分类的所有上级分类:O(1) 复杂度
SELECT ancestor_id FROM category_closure
WHERE descendant_id = 200 AND depth > 0
ORDER BY depth;

代价是插入和删除节点时需要维护闭包表,但对于读多写少的分类树场景,这是值得的。

方案三:PostgreSQL 的 ltree 扩展

PostgreSQL 原生支持层级路径数据类型:

CREATE EXTENSION IF NOT EXISTS ltree;

CREATE TABLE categories (
    category_id SERIAL PRIMARY KEY,
    name        VARCHAR(100) NOT NULL,
    path        ltree NOT NULL
);

CREATE INDEX idx_path_gist ON categories USING GIST (path);

-- 插入数据
INSERT INTO categories (name, path) VALUES
    ('电子产品', 'root.electronics'),
    ('手机', 'root.electronics.phones'),
    ('苹果手机', 'root.electronics.phones.apple'),
    ('安卓手机', 'root.electronics.phones.android');

-- 查询"电子产品"下的所有子分类
SELECT * FROM categories
WHERE path <@ 'root.electronics';

-- 查询"苹果手机"的所有上级分类
SELECT * FROM categories
WHERE path @> 'root.electronics.phones.apple';

五、工程案例:电商订单系统的数据建模演进

用一个完整的案例把前面的知识串起来。我们跟踪一个虚构但高度真实的电商平台”星辰商城”从创业到规模化的数据建模演进历程。

5.1 阶段一:MVP 时期(用户 < 1 万,日订单 < 100)

团队 3 个人,后端用 Go + MySQL,目标是两周内上线最小可用产品。

初始表设计完全遵循 3NF:

-- 用户表
CREATE TABLE users (
    user_id     BIGINT AUTO_INCREMENT PRIMARY KEY,
    username    VARCHAR(50) NOT NULL UNIQUE,
    email       VARCHAR(100) NOT NULL UNIQUE,
    phone       VARCHAR(20),
    created_at  TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB;

-- 商品表
CREATE TABLE products (
    product_id  BIGINT AUTO_INCREMENT PRIMARY KEY,
    name        VARCHAR(200) NOT NULL,
    description TEXT,
    price       DECIMAL(10,2) NOT NULL,
    stock       INT NOT NULL DEFAULT 0,
    category_id BIGINT NOT NULL,
    shop_id     BIGINT NOT NULL,
    status      TINYINT NOT NULL DEFAULT 1,
    created_at  TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    FOREIGN KEY (category_id) REFERENCES categories(category_id),
    FOREIGN KEY (shop_id) REFERENCES shops(shop_id)
) ENGINE=InnoDB;

-- 分类表(邻接列表模型)
CREATE TABLE categories (
    category_id BIGINT AUTO_INCREMENT PRIMARY KEY,
    name        VARCHAR(50) NOT NULL,
    parent_id   BIGINT,
    level       TINYINT NOT NULL DEFAULT 1,
    FOREIGN KEY (parent_id) REFERENCES categories(category_id)
) ENGINE=InnoDB;

-- 订单表
CREATE TABLE orders (
    order_id    BIGINT AUTO_INCREMENT PRIMARY KEY,
    user_id     BIGINT NOT NULL,
    total_amount DECIMAL(10,2) NOT NULL,
    status      VARCHAR(20) NOT NULL DEFAULT 'pending',
    address_id  BIGINT NOT NULL,
    created_at  TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    FOREIGN KEY (user_id) REFERENCES users(user_id),
    FOREIGN KEY (address_id) REFERENCES addresses(address_id)
) ENGINE=InnoDB;

-- 订单明细表
CREATE TABLE order_items (
    id          BIGINT AUTO_INCREMENT PRIMARY KEY,
    order_id    BIGINT NOT NULL,
    product_id  BIGINT NOT NULL,
    quantity    INT NOT NULL,
    unit_price  DECIMAL(10,2) NOT NULL,
    FOREIGN KEY (order_id) REFERENCES orders(order_id),
    FOREIGN KEY (product_id) REFERENCES products(product_id)
) ENGINE=InnoDB;

-- 地址表
CREATE TABLE addresses (
    address_id  BIGINT AUTO_INCREMENT PRIMARY KEY,
    user_id     BIGINT NOT NULL,
    province    VARCHAR(20) NOT NULL,
    city        VARCHAR(20) NOT NULL,
    district    VARCHAR(20) NOT NULL,
    detail      VARCHAR(200) NOT NULL,
    phone       VARCHAR(20) NOT NULL,
    receiver    VARCHAR(50) NOT NULL,
    is_default  TINYINT NOT NULL DEFAULT 0,
    FOREIGN KEY (user_id) REFERENCES users(user_id)
) ENGINE=InnoDB;

这个阶段的设计决策:

对应的 Go 代码用标准的 join 查询获取订单详情:

type OrderDetail struct {
    OrderID    int64           `db:"order_id"`
    UserName   string          `db:"username"`
    TotalAmount decimal.Decimal `db:"total_amount"`
    Status     string          `db:"status"`
    Receiver   string          `db:"receiver"`
    Address    string          `db:"full_address"`
    Items      []OrderItemDetail
    CreatedAt  time.Time       `db:"created_at"`
}

func (r *OrderRepo) GetOrderDetail(ctx context.Context, orderID int64) (*OrderDetail, error) {
    query := `
        SELECT o.order_id, u.username, o.total_amount, o.status,
               a.receiver,
               CONCAT(a.province, a.city, a.district, a.detail) AS full_address,
               o.created_at
        FROM orders o
        JOIN users u ON o.user_id = u.user_id
        JOIN addresses a ON o.address_id = a.address_id
        WHERE o.order_id = ?
    `
    var detail OrderDetail
    if err := r.db.GetContext(ctx, &detail, query, orderID); err != nil {
        return nil, fmt.Errorf("get order detail: %w", err)
    }

    itemsQuery := `
        SELECT oi.product_id, p.name AS product_name, oi.quantity, oi.unit_price
        FROM order_items oi
        JOIN products p ON oi.product_id = p.product_id
        WHERE oi.order_id = ?
    `
    if err := r.db.SelectContext(ctx, &detail.Items, itemsQuery, orderID); err != nil {
        return nil, fmt.Errorf("get order items: %w", err)
    }

    return &detail, nil
}

5.2 阶段二:增长期(用户 10 万+,日订单 5000+)

业务增长后暴露了第一个设计问题:订单引用的地址不应该是引用,应该是快照

用户下单后修改了收货地址,旧订单的地址也跟着变了——因为 orders 表存的是 address_id(引用),指向的是用户的当前地址。

修复:给订单加地址快照字段。

ALTER TABLE orders
    ADD COLUMN receiver_snapshot VARCHAR(50) NOT NULL DEFAULT '',
    ADD COLUMN address_snapshot JSON NOT NULL DEFAULT '{}';

-- 新建订单时,把当前地址快照写入
-- 旧的 address_id 字段保留,但不再用于展示订单地址

对应的业务逻辑变更:

func (s *OrderService) CreateOrder(ctx context.Context, req CreateOrderRequest) (*Order, error) {
    // 获取当前地址
    addr, err := s.addressRepo.GetByID(ctx, req.AddressID)
    if err != nil {
        return nil, fmt.Errorf("get address: %w", err)
    }

    // 创建订单时拍快照
    order := &Order{
        UserID:           req.UserID,
        TotalAmount:      calculateTotal(req.Items),
        Status:           "pending",
        AddressID:        req.AddressID,
        ReceiverSnapshot: addr.Receiver,
        AddressSnapshot: AddressSnapshot{
            Province: addr.Province,
            City:     addr.City,
            District: addr.District,
            Detail:   addr.Detail,
            Phone:    addr.Phone,
        },
    }

    return s.orderRepo.Create(ctx, order)
}

同时,商品列表页的查询开始变慢。join 5 张表的查询在 10 万商品 + 500 分类 + 1000 品牌的数据量下,P99 延迟从 5ms 涨到 50ms。团队决定做第一次反范式化——在商品表上冗余分类名和品牌名。

ALTER TABLE products
    ADD COLUMN category_name VARCHAR(50) NOT NULL DEFAULT '',
    ADD COLUMN brand_name VARCHAR(50) NOT NULL DEFAULT '';

-- 一次性回填
UPDATE products p
JOIN categories c ON p.category_id = c.category_id
SET p.category_name = c.name;

-- 通过事件机制保持同步(分类改名时触发更新)

5.3 阶段三:规模化(用户 500 万+,日订单 10 万+)

这个阶段面临三个核心挑战:

挑战一:订单表的读写分离

日订单 10 万意味着订单表每年新增 3600 万行。历史订单查询(用户中心”我的订单”)和实时订单处理(新建、支付、发货)竞争同一张表的资源。

解法:读写分离 + 按时间分表。

-- 热数据表:最近 3 个月的订单
CREATE TABLE orders_hot (
    -- 结构同 orders 表
) ENGINE=InnoDB;

-- 冷数据表:3 个月前的订单(按年分表)
CREATE TABLE orders_archive_2025 (
    -- 结构同 orders 表
) ENGINE=InnoDB;

-- 应用层路由逻辑
func (r *OrderRepo) GetUserOrders(ctx context.Context, userID int64, page, size int) ([]Order, error) {
    // 先查热表
    orders, err := r.queryOrders(ctx, "orders_hot", userID, page, size)
    if err != nil {
        return nil, err
    }
    if len(orders) >= size {
        return orders, nil
    }

    // 热表不够,补查归档表
    remaining := size - len(orders)
    archiveOrders, err := r.queryOrders(ctx, "orders_archive_2025", userID, 1, remaining)
    if err != nil {
        return nil, err
    }

    return append(orders, archiveOrders...), nil
}

挑战二:商品搜索需要独立的搜索模型

用户搜索商品时需要全文检索、多条件筛选、按销量/价格排序。MySQL 的 LIKE 和 FULLTEXT INDEX 已经无法满足。

解法:引入 Elasticsearch 作为搜索模型,通过事件同步。

type ProductSearchDoc struct {
    ProductID    int64    `json:"product_id"`
    Name         string   `json:"name"`
    Description  string   `json:"description"`
    Price        float64  `json:"price"`
    CategoryName string   `json:"category_name"`
    CategoryPath []string `json:"category_path"`  // ["电子产品", "手机", "苹果手机"]
    BrandName    string   `json:"brand_name"`
    ShopName     string   `json:"shop_name"`
    SalesCount   int64    `json:"sales_count"`
    Rating       float64  `json:"rating"`
    Tags         []string `json:"tags"`
    CreatedAt    string   `json:"created_at"`
}

func (s *SearchSyncService) OnProductUpdated(event ProductUpdatedEvent) error {
    doc := s.buildSearchDoc(event.ProductID)
    _, err := s.esClient.Index().
        Index("products").
        Id(fmt.Sprintf("%d", event.ProductID)).
        BodyJson(doc).
        Do(context.Background())
    return err
}

这里 Elasticsearch 中的文档是一个典型的反范式化视图——它把分类路径、品牌名、店铺名全部冗余进来,专门为搜索场景优化。MySQL 仍然是数据的权威来源(Source of Truth),Elasticsearch 是派生的读优化视图。

挑战三:推荐系统需要用户行为图谱

产品团队要做”买了这个商品的人还买了什么”推荐。这需要分析用户-商品的交互关系——本质上是一个二部图(Bipartite Graph)。

团队评估了两个方案:

方案 A:在 MySQL 中用 SQL 实现协同过滤

-- 找出和用户 123 买过相同商品的其他用户,然后推荐他们买的其他商品
SELECT
    oi2.product_id,
    COUNT(DISTINCT oi2.order_id) AS purchase_count
FROM order_items oi1
JOIN orders o1 ON oi1.order_id = o1.order_id AND o1.user_id = 123
JOIN order_items oi_same ON oi1.product_id = oi_same.product_id AND oi_same.id <> oi1.id
JOIN orders o_same ON oi_same.order_id = o_same.order_id AND o_same.user_id <> 123
JOIN order_items oi2 ON o_same.order_id = oi2.order_id
WHERE oi2.product_id NOT IN (
    SELECT product_id FROM order_items
    JOIN orders ON order_items.order_id = orders.order_id
    WHERE orders.user_id = 123
)
GROUP BY oi2.product_id
ORDER BY purchase_count DESC
LIMIT 20;

这个查询在千万级订单数据上执行时间超过 60 秒,无法用于实时推荐。

方案 B:用 Neo4j 存储购买关系图,做图遍历推荐

// 导入购买关系
LOAD CSV FROM 'file:///purchases.csv' AS row
MATCH (u:User {id: row[0]})
MATCH (p:Product {id: row[1]})
CREATE (u)-[:PURCHASED {date: row[2], quantity: toInteger(row[3])}]->(p);

// 协同过滤推荐
MATCH (target:User {id: "user_123"})-[:PURCHASED]->(product)<-[:PURCHASED]-(similar_user)
MATCH (similar_user)-[:PURCHASED]->(recommended)
WHERE NOT (target)-[:PURCHASED]->(recommended)
RETURN recommended.name, COUNT(DISTINCT similar_user) AS score
ORDER BY score DESC
LIMIT 20;

Neo4j 的查询在同样的数据规模下耗时 200ms 以内。

最终团队选择了方案 B,但只在推荐系统中使用 Neo4j。订单的核心 CRUD 仍然用 MySQL,用户数据仍然用 MySQL,商品搜索用 Elasticsearch。每个存储引擎负责它最擅长的场景。

5.4 最终架构的数据流

graph LR
    APP["应用服务"] -->|CRUD| MYSQL["MySQL<br/>(权威数据源)"]
    APP -->|搜索查询| ES["Elasticsearch<br/>(搜索视图)"]
    APP -->|推荐查询| NEO4J["Neo4j<br/>(关系图谱)"]

    MYSQL -->|Binlog / 事件| CDC["CDC 同步服务"]
    CDC -->|商品变更| ES
    CDC -->|购买事件| NEO4J

    MYSQL -.- M1["订单、商品、用户<br/>事务一致性"]
    ES -.- E1["全文搜索、多条件筛选<br/>最终一致"]
    NEO4J -.- N1["协同过滤推荐<br/>关系遍历"]

每个存储引擎的职责边界清晰:

这就是所谓的多语言持久化(Polyglot Persistence)——用正确的工具做正确的事。但它的代价是运维复杂度:你需要维护三套存储系统、保证它们之间的数据一致性、处理同步延迟和故障恢复。


六、各模型的权衡对比

6.1 三大模型的全面对比

维度 关系模型(RDBMS) 文档模型(Document DB) 图模型(Graph DB)
代表产品 PostgreSQL, MySQL MongoDB, CouchDB Neo4j, JanusGraph
数据结构 二维表 + 外键关系 嵌套 JSON/BSON 文档 节点 + 边 + 属性
Schema 强 Schema(DDL 定义) 弱 Schema(应用层约定) 弱 Schema
核心优势 事务、一致性、灵活查询 数据局部性、开发迭代快 关系遍历性能
核心劣势 水平扩展难、Schema 变更重 join 能力弱、一致性保证弱 生态小、运维经验少
最佳场景 事务型业务、复杂查询 内容管理、配置存储、事件日志 社交网络、知识图谱、欺诈检测
最差场景 超大规模写入、稀疏数据 多对多关系、复杂聚合 简单 CRUD、批量分析
扩展方式 读写分离 + 分库分表 原生分片(Sharding) 图分区(Graph Partitioning)
事务支持 ACID(成熟、高效) 多文档事务(4.0+,开销大) 有限(视产品而定)
查询语言 SQL(标准化) MongoDB Query / Aggregation Cypher / Gremlin / SPARQL
运维成熟度 非常成熟 成熟 相对不成熟

6.2 选型决策流程

在做数据建模和存储选型时,建议按以下顺序思考:

第一步:分析数据之间的关系结构

第二步:分析查询模式

第三步:分析一致性和事务需求

第四步:评估运维成本

核心原则默认选择关系模型,只在关系模型明确不能满足需求时才引入其他模型。PostgreSQL + 适当的反范式化能覆盖 90% 以上的业务场景。不要因为”听说 MongoDB 灵活”或”Neo4j 查关系快”就盲目引入新的存储引擎。每多一种存储引擎,运维复杂度不是加法增长而是乘法增长。

6.3 关于”Schema-less”的迷思

很多团队选择 MongoDB 的理由是”Schema-less,不用建表迁移”。但这是一个被严重误解的概念。

数据的结构约束永远存在——不在数据库层面就在应用层面。所谓”Schema-less”只是把 Schema 的定义和验证从数据库转移到了应用代码中。在大型项目中,这往往意味着:

  1. 应用代码中散布着大量的类型检查和空值判断
  2. 不同版本的文档格式混合存储在同一个集合中
  3. Schema 的变更历史没有数据库迁移脚本可以追溯

MongoDB 自身也意识到了这个问题,在后续版本中加入了 JSON Schema Validation(文档验证):

db.createCollection("orders", {
    validator: {
        $jsonSchema: {
            bsonType: "object",
            required: ["user_id", "items", "total_amount", "status"],
            properties: {
                user_id: {
                    bsonType: "string",
                    description: "必填字段,用户 ID"
                },
                items: {
                    bsonType: "array",
                    minItems: 1,
                    items: {
                        bsonType: "object",
                        required: ["product_id", "quantity", "price"],
                        properties: {
                            product_id: { bsonType: "string" },
                            quantity: { bsonType: "int", minimum: 1 },
                            price: { bsonType: "decimal" }
                        }
                    }
                },
                total_amount: { bsonType: "decimal" },
                status: { enum: ["pending", "paid", "shipped", "completed", "cancelled"] }
            }
        }
    }
});

本质上,这就是在数据库层面重新定义 Schema——绕了一圈回到了起点。

正确的认知是:文档模型的核心优势不是”没有 Schema”,而是”数据局部性”和”嵌套结构的自然表达”。如果你选 MongoDB 的理由是”不用写 DDL”,那你可能选错了。

6.4 PostgreSQL:关系模型的最大公约数

值得单独说一下 PostgreSQL。它已经模糊了关系模型和其他模型的边界:

-- PostgreSQL JSONB:在关系表中使用文档模型
CREATE TABLE events (
    event_id    BIGSERIAL PRIMARY KEY,
    event_type  VARCHAR(50) NOT NULL,
    payload     JSONB NOT NULL,
    created_at  TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

-- 在 JSONB 字段上建索引
CREATE INDEX idx_events_payload ON events USING GIN (payload);

-- 查询 JSONB 字段
SELECT * FROM events
WHERE payload @> '{"user_id": "user_123", "action": "purchase"}'
AND created_at >= '2025-01-01';

这种方式允许你在核心业务表上保持严格的 Schema 和事务保证,同时在扩展字段、事件日志、配置数据等场景下使用灵活的 JSON 存储。不需要引入一个独立的 MongoDB 实例。


七、数据建模的工程原则总结

经过前面六节的分析,我们可以提炼出一些经得起推敲的数据建模原则:

原则一:先范式化,再有策略地反范式化

不要从一开始就设计大宽表。先按 3NF 建模,在性能瓶颈被度量工具确认后,再针对具体的查询路径做反范式化。过早的反范式化是一种技术债务——它在初期不会报错,但在业务逻辑变更时会加倍偿还。

原则二:区分”引用”和”快照”

数据之间的关系有两种语义:

引用用外键实现,快照用冗余字段实现。搞混这两种语义是数据建模中最常见的错误之一。

原则三:数据模型跟着访问模式走

Martin Kleppmann 在《设计数据密集型应用》(Designing Data-Intensive Applications)中总结得好:关系模型对写入友好(通过范式化避免冗余),文档模型对读取友好(通过嵌入避免 join)。你的数据模型应该为你最频繁的访问模式优化。

原则四:默认关系模型,必要时多语言持久化

不要被 NoSQL 营销话术裹挟。对于绝大多数业务系统,PostgreSQL 或 MySQL 是最安全的起点。只在以下情况引入额外的存储引擎:

原则五:Schema 是资产,不是负担

无论用什么存储引擎,都应该有明确的 Schema 定义和版本管理。对于关系数据库,用迁移工具(如 Flyway、Alembic)管理 DDL 变更。对于文档数据库,用 JSON Schema 或应用层的类型定义来约束文档结构。“Schema-less”不是”没有结构”的借口。


八、常见误区与反模式

8.1 误区一:过度嵌入

在 MongoDB 中把所有关联数据都嵌入父文档,导致文档体积膨胀到几 MB 甚至触及 16MB 的文档大小限制。

正确做法:只嵌入关联度高、基数有限、不需要独立访问的数据。

8.2 误区二:在文档数据库中模拟关系模型

有些团队在 MongoDB 中建了 20 个集合,每个集合都像关系表一样只有平坦的字段,然后用 $lookup 做 join。这完全丧失了文档模型的优势,同时还承受了文档数据库在 join 上的劣势。

正确做法:如果你的数据模型是高度关系化的,就直接用关系数据库。

8.3 误区三:为了图数据库而用图数据库

引入 Neo4j 来存储简单的树形结构(如组织架构、分类树),但实际查询只需要一到两层遍历。关系数据库的递归 CTE 或闭包表完全够用,引入图数据库只增加了运维负担。

正确做法:只在需要三层以上的关系遍历或图算法(最短路径、社区发现)时才考虑图数据库。

8.4 误区四:忽视数据迁移的成本

选型时只考虑当前需求,不考虑未来数据量增长后的迁移成本。从 MongoDB 迁移到 PostgreSQL、从单表迁移到分库分表,这些都是以月为单位的大工程。

正确做法:在选型时多问一个问题——“如果数据量增长 100 倍,这个模型还能撑住吗?如果撑不住,迁移方案是什么?”

8.5 误区五:混淆 OLTP 和 OLAP 的建模策略

OLTP(在线事务处理)和 OLAP(在线分析处理)的建模策略截然不同。OLTP 应该范式化以保证写入效率和数据一致性;OLAP 应该反范式化(星型模型、雪花模型)以保证查询效率。

把 OLAP 的大宽表设计搬到 OLTP 系统中,或者在 OLAP 系统中过度范式化,都是常见的反模式。

-- OLAP 星型模型示例(适合数据仓库,不适合 OLTP)
CREATE TABLE fact_sales (
    sale_id         BIGINT PRIMARY KEY,
    date_key        INT NOT NULL,       -- 维度外键
    product_key     INT NOT NULL,       -- 维度外键
    customer_key    INT NOT NULL,       -- 维度外键
    store_key       INT NOT NULL,       -- 维度外键
    quantity        INT NOT NULL,
    unit_price      DECIMAL(10,2) NOT NULL,
    total_amount    DECIMAL(12,2) NOT NULL,
    discount_amount DECIMAL(10,2) NOT NULL DEFAULT 0
);

-- 日期维度表(预计算的、反范式化的)
CREATE TABLE dim_date (
    date_key        INT PRIMARY KEY,
    full_date       DATE NOT NULL,
    year            INT NOT NULL,
    quarter         INT NOT NULL,
    month           INT NOT NULL,
    week            INT NOT NULL,
    day_of_week     VARCHAR(10) NOT NULL,
    is_weekend      BOOLEAN NOT NULL,
    is_holiday      BOOLEAN NOT NULL,
    fiscal_year     INT NOT NULL,
    fiscal_quarter  INT NOT NULL
);

上一篇:性能测试

下一篇:多模数据库选型


参考资料

  1. Martin Kleppmann,《Designing Data-Intensive Applications》, O’Reilly, 2017 —— 第二章”Data Models and Query Languages”和第三章”Storage and Retrieval”是数据建模的必读材料
  2. C.J. Date,《An Introduction to Database Systems》, 8th Edition —— 范式理论的权威教材
  3. MongoDB 官方文档, “Data Modeling Introduction” —— 嵌入 vs 引用的决策框架
  4. Neo4j 官方文档, “Graph Data Modeling Guidelines” —— 图模型的建模原则
  5. Rick Houlihan, “Advanced Design Patterns for Amazon DynamoDB”, AWS re:Invent 2018 —— NoSQL 数据建模的工业实践
  6. PostgreSQL 官方文档, “JSON Types” 和 “ltree” —— PostgreSQL 的多模能力
  7. Pat Helland, “Immutability Changes Everything”, ACM Queue, 2015 —— 不可变数据和快照的理论基础
  8. Edgar F. Codd, “A Relational Model of Data for Large Shared Data Banks”, CACM, 1970 —— 关系模型的奠基论文

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。

2026-04-13 · architecture

【系统架构设计百科】架构质量属性:不只是"高可用高性能"

需求评审时写下的'高可用、高性能、高并发',到了架构设计阶段几乎无法落地——因为它们不是可执行的需求。本文从 SEI/CMU 的质量属性理论出发,用 stimulus-response 场景模型把模糊需求变成可量化、可验证的架构约束,并拆解属性之间的冲突与联动关系。

2026-04-13 · architecture

【系统架构设计百科】告警策略:如何避免"狼来了"

大多数团队的告警系统都在制造噪声而不是传递信号。阈值告警看似直观,实则产生大量误报和漏报,值班工程师在凌晨三点被叫醒,却发现只是一次无害的毛刺。本文从告警疲劳的工业数据出发,拆解基于 SLO 的多窗口燃烧率告警算法,深入 Alertmanager 的路由、抑制与分组机制,结合 PagerDuty 的告警疲劳研究和真实工程案例,给出一套可落地的告警策略设计方法。

2026-04-13 · architecture

【系统架构设计百科】复杂性管理:架构的核心战场

系统复杂性是架构腐化的根源——本文从 Brooks 的本质复杂性与偶然复杂性划分出发,结合认知负荷理论与 Parnas 的信息隐藏原则,系统阐述复杂性的来源、度量与控制手段,并给出可操作的架构策略


By .