← Blog
·12 blog.minutes

现代应用数据库设计模式

从关系型与 NoSQL 的选择到 CI/CD 中的迁移管理,这些是每个开发者都需要知道的数据库设计模式。

数据库设计是一种稀有的技能,随着应用增长,它只会变得越来越重要。项目开始时设计良好的模式可以支持多年的功能开发而无需大规模重写。设计不良的模式会让每个新功能都痛苦、每次查询都更慢、每次部署都风险更大。你在数据层做出的决策会回响到应用的每一层,而以后更改它们的代价很高。

本文涵盖了现代应用中最重要的模式。这些不是学术练习——它们是团队在生产中用来保持数据库快速、可维护和安全变更的实用策略。我们将讨论存储引擎选择、模式设计原则、查询优化、CQRS 和事件溯源等架构模式,以及随着团队和数据增长保持数据库平稳运行的操作实践。

选择数据库:关系型 vs NoSQL

你将做出的第一个也是最重要的决策是哪一类数据库。好消息是旧的部落战争基本结束了。很少有严肃的团队是纯粹的关系型或纯粹的 NoSQL。务实的方法是为每个工作负载选择正确的工具,有时同时运行多个存储引擎。

像 PostgreSQL 和 SQLite 这样的关系型数据库在数据有清晰关系、引用完整性重要以及需要以灵活方式跨实体查询时表现出色。如果你在构建一个计费系统、库存管理工具或任何事务要么完全完成要么完全不发生的应用,你需要 ACID 保证。关系型数据库经过数十年的实战检验,为你提供这些保证。

像 MongoDB 这样的文档数据库更适合当你的数据是层级结构、你的访问模式事先已知、并且你愿意用一致性保证换取写入吞吐量或模式灵活性时。它们在内容管理系统、事件日志管道和数据结构频繁变化的应用中表现出色。

以下是选择主要数据库的实用决策框架:

  • 默认使用 PostgreSQL。它很好地处理了 95% 的用例,支持 JSON 列用于文档风格数据,有出色的索引和成熟的生态系统。从这里开始,只在你有一个特定理由时才偏离。
  • 当你的应用在设备上运行、通过 WASM 在浏览器中运行或作为单服务器工具的嵌入式数据库时,选择 SQLite。它零配置,读取速度极快,而且通过最近的扩展功能出人意料地强大。
  • 当你总是作为一个整体读取和写入深度嵌套的文档,并且你的一致性要求足够宽松可以容忍最终一致性读取时,考虑 MongoDB 或 Firestore。
  • 避免在生产中使用多数据库陷阱。运行两个数据库会使你的操作复杂性翻倍。只有在你测量到主要数据库无法处理工作负载时,才添加第二个存储引擎。

我在生产代码库中看到的最常见的遗憾是选择 NoSQL 数据库来处理关系型数据。如果你的实体相互引用并且需要连接它们,你需要一个关系型数据库。应用对象和关系表之间的阻抗不匹配是真实存在的,但它远小于本质上关系型模型与从未为此设计的文档存储之间的阻抗不匹配。

范式化、反范式化和真正的中间地带

每个开发者都在学校学习过范式化。第一范式、第二范式、第三范式——这个递进承诺一个干净、无冗余、没有更新异常的模式。生产现实更加微妙。完全范式化的模式通常会产生连接十个表才能渲染单个页面的查询——缓慢且复杂。完全反范式化的模式让写入快、读取简单,但引入了一致性问题并使每次写入更容易出错。

实用的中间地带是为完整性进行范式化,为性能进行反范式化,但要有意为之并记录你的理由。从一个捕获数据中真实关系的范式化模式开始。然后,随着你测量真实查询模式,添加反范式化字段或汇总表,在性能收益证明增加的复杂性是合理的地方。

-- Start normalized
CREATE TABLE orders (
  id        UUID PRIMARY KEY,
  user_id   UUID NOT NULL REFERENCES users(id),
  status    TEXT NOT NULL DEFAULT 'pending',
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE order_items (
  id         UUID PRIMARY KEY,
  order_id   UUID NOT NULL REFERENCES orders(id),
  product_id UUID NOT NULL REFERENCES products(id),
  quantity   INT NOT NULL,
  unit_price NUMERIC(10,2) NOT NULL
);

-- Denormalize only when measured: add total to orders
ALTER TABLE orders ADD COLUMN total NUMERIC(10,2);

-- Keep it consistent with a trigger or application-level logic
CREATE OR REPLACE FUNCTION update_order_total()
RETURNS TRIGGER AS $$
BEGIN
  UPDATE orders SET total = (
    SELECT SUM(quantity * unit_price)
    FROM order_items WHERE order_id = NEW.order_id
  ) WHERE id = NEW.order_id;
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER order_total_trigger
AFTER INSERT OR UPDATE OR DELETE ON order_items
FOR EACH ROW EXECUTE FUNCTION update_order_total();

经验法则很简单:永远不要在测量到重要的查询之前进行反范式化。过早的反范式化引入了冗余数据的所有复杂性,而没有证明它解决了实际问题的证据。当你确实要反范式化时,记录决策,添加验证一致性的测试,并构建一个能够检测和修复漂移的对账任务。冗余数据总会随时间漂移。计划漂移检测不是悲观——而是工程成熟度。

真正有效的索引策略

索引是任何数据库用户可以使用的最有影响力的性能优化。一个精心放置的索引可以将数百万行的顺序扫描变成几次页面读取。但索引不是免费的。每个索引都会增加写入开销、消耗存储空间,并且如果给定查询有太多候选索引,可能会混淆查询计划器。

在生产中持续产生良好结果的策略遵循三个原则。首先,为你的外键建立索引。每个引用另一个表的列默认应该被索引。连接性能取决于连接两侧的索引查找,而忘记为外键建立索引是关系型数据库中最常见的性能错误。

其次,为你的查询模式建立索引,而不是为你的列。查看你最慢查询中的 WHERE 子句和 ORDER BY 子句,创建精确匹配这些模式的复合索引。(status, created_at) 上的复合索引对仅按 created_at 过滤的查询是无用的,但 (created_at, status) 上的复合索引如果 created_at 足够有选择性,可以同时服务两个查询。复合索引中列的顺序至关重要。

-- Instead of separate indexes
CREATE INDEX idx_orders_status ON orders(status);
CREATE INDEX idx_orders_created ON orders(created_at);

-- Create composite indexes that match real query patterns
-- Query: SELECT * FROM orders WHERE status = 'active' ORDER BY created_at DESC;
CREATE INDEX idx_orders_status_created ON orders(status, created_at DESC);

-- Query: SELECT * FROM orders WHERE user_id = $1 AND status = 'active';
CREATE INDEX idx_orders_user_status ON orders(user_id, status);

第三,测量前后。PostgreSQL 的 pg_stat_user_indexes 视图显示了哪些索引实际被使用,哪些是死重。运行一个工作负载,检查索引使用统计信息,并丢弃从未被扫描的索引。未使用的索引不是无害的——它们会减慢每次写操作并消耗本可以保存有用数据页面的缓存内存。

部分索引是一个未被充分利用的工具。如果你经常只查询行的子集——活跃订单、未处理事件、未删除用户——创建一个只覆盖这些行的部分索引。它会是完整索引的一小部分并且扫描起来快得多。

-- Partial index: only index active orders
CREATE INDEX idx_orders_active ON orders(created_at DESC)
WHERE status = 'active';

-- This index is tiny compared to a full index and serves the query perfectly

仓储模式和数据访问抽象

仓储模式在你的领域逻辑和数据访问代码之间进行中介。它定义了一个类似集合的接口用于检索和持久化聚合,隐藏了底层存储机制的细节。在实践中,这意味着你的应用代码调用类似 userRepository.findById(id) 或 orderRepository.save(order) 的方法,而不知道数据来自 PostgreSQL、缓存层还是外部 API。

这种模式的价值在你需要更改数据库或引入缓存层时变得清晰。一个查询分散在控制器、服务和工具函数中的团队,在从 MongoDB 切换到 PostgreSQL 时将面临数百个文件的重写。使用仓储模式的团队只需更改几个实现文件,接口保持不变。

然而,仓储模式与关系型数据库存在一个众所周知的紧张关系。如果你的仓储接口过于通用——findAll、findById、save、delete——它无法表达关系型数据库提供的丰富查询能力。团队最终无论如何都会向仓储中添加专门的查询方法,这逐渐使抽象变成了有漏洞的抽象。解决方案是接受关系型数据库的仓储将比键值存储的仓储有更多的方法。一个带有 findActiveByRole、searchByName 和 countByStatus 方法的用户仓储不是抽象的失败——而是对底层引擎能力的诚实反映。

隐藏查询能力的仓储不是抽象——是一种障碍。目标是隔离你的领域与存储细节,而不是将每个数据库降到最低公分母。

CQRS 和事件溯源:何时使用高级模式

命令查询职责分离(CQRS)将数据修改路径与数据读取路径分开。在最简单的形式中,CQRS 架构使用相同的数据库但不同的模型进行写入和读取。写入模型强制执行不变约束并产生事件。读取模型消费这些事件并构建为特定查询优化的反范式化视图。这种分离允许每一侧独立扩展和优化。

事件溯源更进一步。它不是存储实体的当前状态,而是存储每个改变它的事件。当前状态通过重放这些事件来推导。这给了你完整的审计跟踪、在任何时间点重建状态的能力,以及向下游消费者提供事件的自然来源。权衡是显著的操作复杂性——事件存储基础设施、投影管理、写模型和读模型之间的最终一致性,以及用事件而不是状态思考的认知开销。

以下是对这种模式的诚实评估:大多数应用不需要 CQRS 或事件溯源。它们增加的复杂性只有在你拥有更简单架构无法满足的特定需求时才合理。当你的读取和写入工作负载有根本不同的特征时——高写入吞吐量伴复杂读取投影,或每个操作的不同一致性要求——考虑 CQRS。当你需要法律或产品要求的不可变审计跟踪,或者当每个状态变更必须可重建和可分析时,考虑事件溯源。

如果你确实采用了这些模式,先从单独的 CQRS 开始,只有在审计跟踪需求明确时才添加事件溯源。在 PostgreSQL 中通过维护单独的读模型来实现 CQRS 是可控的。在上面添加事件存储是复杂性的显著跃升,应该是一个慎重、资金充足的决策。

迁移、连接池和操作健康

数据库设计的操作面是良好模式区分平稳演进的应用和需要生产事故来更改列类型的应用的地方。三个实践一致地区分了处理数据库变更良好的团队和挣扎的团队。

首先,每个模式变更必须是一个存储在版本控制中的可逆迁移。像 Flyway、Liquibase 或 Alembic 这样的工具按顺序应用迁移并跟踪哪些已经应用。迁移文件是代码——它经过审阅、测试并通过与应用代码相同的管道部署。每个迁移应该是小而专注的。在一个文件中添加列、回填数据和重命名表的迁移是有风险的。将其分解为可以独立回滚的单独步骤。

第二,连接池不是可选的。打开数据库连接是昂贵的——涉及 TCP 握手、SSL 协商和认证。连接池维护一组持久连接,线程借出和归还。池大小很重要:连接太少请求会排队,太多则数据库将所有时间花在上下文切换上。PostgreSQL 的一个好的起点是 pool_size = 2 * CPU_cores,然后基于查询延迟和连接等待时间进行监控和调整。

第三,小心地将迁移集成到你的 CI/CD 管道中。最安全的模式是在部署新应用代码之前应用迁移。这样,新代码看到它期望的模式,而旧代码仍然与新模式兼容,因为向后兼容的变更已经先应用了。这要求每个迁移与当前代码向后兼容——不删减运行中代码仍然引用的列,不在没有过渡期的情况下重命名表。

  • 扩展:在旧代码仍在运行时添加新列或新表。
  • 迁移:回填数据并将写入转移到新结构。
  • 收缩:在确认旧代码不再运行后移除旧列或旧表。

这种扩展-迁移-收缩模式用于零停机模式变更之所以有效,是因为它永远不会让数据库处于运行中代码无法处理的状态。它需要纪律——你需要让旧代码路径存活一个部署周期——但它消除了与数据库变更相关的最常见的部署失败原因。

SQL vs ORM:找到正确的平衡

编写原始 SQL 和使用对象关系映射器(ORM)之间的辩论是软件开发中最持久的争论之一。双方都有合理的观点,正确答案取决于项目的上下文。

像 Prisma、TypeORM 或 SQLAlchemy 这样的 ORM 提供了应用对象和数据库表之间的自动映射、迁移管理以及在你的应用语言中构建查询的能力。它们消除了整类样板代码,并使入门变得容易。代价是它们抽象了 SQL,这意味着当有问题时——慢查询、意外的连接、锁升级——你必须同时理解 ORM 的行为和底层 SQL 才能调试。ORM 也倾向于为复杂访问模式生成次优查询,而 N+1 查询问题是每个使用 ORM 的团队的必修课。

原始 SQL 让你完全控制在数据库服务器上执行的内容。你可以编写你需要的精确查询,针对你的模式和数据库引擎的能力进行调整。代价是你失去了自动映射,你必须自己管理迁移,并且你的代码库最终会散布着难以测试和更难重构的 SQL 字符串。

务实的中间地带是使用 ORM 处理 80% 的直接 CRUD 操作查询,对需要性能调整或复杂报告的 20% 退回到原始 SQL。大多数好的 ORM 提供了一种执行原始查询并将结果映射回类型化对象的方法。对简单操作使用 ORM 的查询构建器,对复杂操作编写原始 SQL,并使用真实数据量在真实数据库上测试两者。

PostgreSQL 是大多数现代应用的默认选择,因为它在能力、可靠性和生态系统方面比其他任何数据库都更平衡。SQLite 在嵌入式和本地优先领域占主导地位。MySQL 在遗留和 WordPress 生态系统中仍然常见。你选择的数据库不如你应用来良好使用它的模式重要——模式设计、索引、迁移管理和操作实践决定你的成功程度,远超过罐头的标志。

你选择的数据库不如你应用来良好使用它的模式重要。模式设计、索引和操作实践决定你的成功程度,远超过罐头的标志。

数据库设计不是一次性的活动。它是一个持续测量、调整和学习的实践。本文中的模式将是一个好的起点,但真正的专业知识来自观察你的数据库在真实工作负载下的行为,并以有针对性的、深思熟虑的变更做出回应。从简单开始。度量一切。并且永远不要害怕使用迁移文件。