在交互服务模块的背景下,不可避免的问题出现了:通信遵循什么规则?在 IT 产品中,“合同”代表对系统之间流动的数据及其传输方式的正式理解。这需要数据格式(JSON、Protobuf 等)、结构元素(字段、数据类型)、通信协议(REST、gRPC、消息队列)和其他规范。
合同确保开放性(每个人都知道收到和发送了什么)、可预测性(我们可以更新合同并维护版本)和可靠性(如果我们做出管理良好的更改,我们的系统就不会失败)。
在实践中,尽管大家都在谈论微服务、“契约”和 API,但我们经常看到人们采取这样的方法:“为什么不在数据库中创建共享表,而不是构建 API?”
因此,虽然使用共享表进行数据交换看似高效且可以快速获得结果,但从长远来看,它会带来各种技术和组织挑战。然而,当团队选择共享表进行数据交换时,他们在实施过程中可能会面临许多问题。
当服务通过 REST/gRPC/GraphQL 进行通信时,它们具有正式定义:OpenAPI(Swagger)、protobuf 模式或 GraphQL 模式。这些详细定义了哪些资源(端点)可用、需要哪些字段、它们的类型以及请求/响应格式。当“共享表”充当合同时,没有正式的描述:没有合同的正式描述;只有表模式(DDL)可用,而且甚至没有很好的文档记录。对表结构的任何细微修改(例如,添加或删除列、更改数据类型)都会影响读取或写入此表的其他团队。
API 版本控制是一种常规做法:我们可能有 v1、v2 等等,我们可以保持向后兼容性,然后逐步将客户端迁移到较新的版本。对于数据库表,我们只有 DDL 操作(例如ALTER TABLE
),这些操作与特定的数据库引擎紧密相关,需要谨慎处理迁移。
没有一个集中式系统可以向消费者发送有关架构更改的警报,要求他们更新查询。因此,可能会发生“私下”交易:有人可以在聊天中发帖说“明天,我们将把 X 列改为 Y 列”,但不能保证每个人都能及时做好准备。
当 API 定义明确时,谁拥有它就一目了然:作为 API 发布者的服务。当多个团队使用同一个数据库表时,对于谁来决定结构、存储哪些字段以及如何解释这些字段,就会产生混淆。因此,该表可能成为“无人拥有的”,并且每次更改都成为一项任务:“我们需要与另一个团队核实,以防他们使用旧列!”
如果许多团队都有权访问数据库,那么很难跟踪谁可以读取和写入表。未经授权的服务有可能访问数据,即使这些数据不是为他们准备的。使用 API 可以更轻松地管理此类问题:您可以控制访问权限(谁可以调用哪些方法)、使用身份验证和授权以及监视谁调用了什么。使用表则要复杂得多。
对数据的任何内部修改(重新组织索引、对表进行分区、更改数据库)都会成为全局问题。如果表充当公共接口,则所有者无法在不危及所有外部读者和作者的情况下进行内部更改。
这是最痛苦的部分:如何通知另一个团队第二天架构将会改变?
当多个团队使用共享表来选择和更新关键数据时,它很容易变成“战场”。结果是业务逻辑最终分散在不同的服务中,并且没有对数据完整性的集中控制。很难知道为什么某个字段以特定方式存储,谁可以更新它,以及如果将其留空会发生什么。
例如,假设表损坏:假设有坏数据或有人锁定了一些关键行。确定问题的根源通常需要询问每个具有数据库访问权限的团队,以确定哪个查询导致了问题。这通常并不明显:这意味着一个团队的查询可能锁定了数据库,而另一个团队的查询产生了可观察到的错误。
共享数据库是单点故障。如果它发生故障,那么许多服务都会随之发生故障。当数据库由于一项服务的查询繁重而出现性能问题时,所有服务都会遇到问题。在具有明确 API 和数据所有权的模型中,每个团队都是其服务可用性和性能的主人,因此一个组件的故障不会传播到其他组件。
一种常见的折衷方案是:“我们会为您提供一个只读副本,这样您就可以在不影响我们主数据库的情况下进行查询。”起初,这可能会解决一些负载问题,但是:
现代设计实践(例如“API 优先”或“契约优先”)始于正式的接口定义。使用 OpenAPI/Swagger、protobuf 或 GraphQL 模式。这样,人和机器都知道哪些端点可用、哪些字段是必需的以及使用哪些数据类型。
在微服务(甚至是模块化)架构中,假设每个服务完全拥有其数据。它定义结构、存储和业务逻辑,并为所有外部访问该 API 提供 API。没有人可以接触“其他人的”数据库:只有官方端点或事件。每当有变化时,这都会让生活变得更轻松,而且总是清楚谁应该负责。
GET /items
、 POST /items
等端点,客户端使用明确定义的数据模式(DTO)发出请求。
无论哪种模型,在接口上实现版本控制都是可能的,也是必要的。例如:
一个基本原则是,拥有数据的团队可以决定如何存储和管理数据,但他们不应向其他服务授予直接写入权限。其他人必须通过 API,而不是编辑外部数据。这会产生更清晰的责任分配:如果服务 A 出现故障,那么修复它是服务 A 的责任,而不是它的邻居。
乍一看,如果一切都在一个团队中,为什么要用 API 把事情复杂化呢?实际上,即使你将一个产品分成多个模块,共享表也会导致同样的问题。
例如,订单服务是订单表的所有者,而计费服务不直接访问该表 - 它调用订单服务的端点来获取订单详细信息或将订单标记为已付款。
在更高层次上,当两个或多个团队负责不同的领域时,原则保持不变。例如:
如果团队 B 直接查询属于团队 A 的“Catalog”表,则 A 的任何内部模式更改(例如,添加字段、更改结构)都可能影响团队 B。
正确的方法是使用 API:团队 A 提供GET /catalog/items
、 GET /catalog/items/{id}
等端点,团队 B 使用这些方法。如果 A 能够支持旧版本和新版本,他们可以发布 /v2,这让 B 有时间进行迁移。
有了正式合同,所有更改都是可见的:在 Swagger/OpenAPI、.proto 文件或事件文档中。任何更新都可以事先讨论、适当测试和安排,并根据需要制定向后兼容策略。
一项服务的变化对其他服务的影响较小。如果团队妥善管理新旧字段或端点,确保平稳过渡,就不必担心“破坏”其他人的服务。
API 网关、身份验证和授权(JWT、OAuth)是服务的标准配置,但使用共享表几乎不可能实现。更容易微调访问权限(谁可以调用哪些方法)、保存日志、跟踪使用情况统计数据和施加配额。这使系统更安全、更可预测。
数据库中的共享表是实现细节,而不是服务之间的协议,因此不被视为合同。许多问题(复杂的版本控制、混乱的变化、不明确的所有权、安全性和性能风险)使这种方法从长远来看无法维持。
正确的方法是契约优先,即通过正式设计定义交互并遵循每个服务仍然是其数据所有者的原则。这不仅有助于减少技术债务,而且还能提高透明度、加快产品开发速度,并实现安全更改,而无需参与数据库模式的斗争。
这既是技术问题(如何设计和集成),也是组织问题(团队如何沟通和管理变更)。如果您希望产品能够不断发展,而不必处理与数据库架构相关的无休止的紧急情况,那么您应该开始考虑合同,而不是直接数据库访问。