领域模块的数据所有权:谁能读,谁能写
数据耦合比代码耦合更隐蔽
我在项目里见过不少这样的场景:模块目录拆得干干净净,每个模块有自己的 Service、Repository,看起来边界分明。但打开数据库一看,三个模块同时写同一张表,改一个字段要通知四五个团队。代码层面的耦合有 lint 规则和 CI 检查兜底,数据层面的耦合只有在改字段、做迁移或者查性能瓶颈时才暴露出来。
这个问题的根源不是模块拆得不够彻底,而是没有想清楚数据的「所有权」。谁的模块拥有这张表、谁有权写入、谁只能读、读到的是不是最新的事实——这些问题如果在设计阶段没有回答,后面每一次需求变更都会踩坑。
Chris Richardson 在 Microservices.io 上把「共享数据库反模式」列为微服务架构的头号隐患:两个服务不应共享一个数据存储,每个服务应该管理自己的私有数据,其他服务不能直接访问。这条原则同样适用于单体内部的模块化设计——如果你打算让模块未来有可能独立部署,数据所有权的边界今天就得画好。
理论:从限界上下文到数据归属
Eric Evans 在《领域驱动设计》里提出的「限界上下文」(Bounded Context)是理解数据所有权的基础。一个限界上下文内部有统一的领域模型,同一个术语在不同上下文中含义不同。比如「用户」在认证上下文里是账号和密码,在订单上下文里是收货人和地址,在支付上下文里是付款方式和账单记录。这些不是同一个实体在不同表里的冗余,而是同一个人在不同业务视角下的不同抽象。
微软 Azure 架构中心对数据所有权的定义很直接:每个微服务管理自己的私有数据存储,其他服务不能直接访问。这条规则的出发点不是权限控制,而是变更隔离——如果底层表结构变了,只有拥有它的模块需要改代码,其他模块通过契约(API 或事件)获取信息,不受影响。
在单体应用中,完全独立的数据库不一定现实。Mehmet Ozkaya 在讨论模块化单体的数据隔离时总结了四种策略,从轻到重依次是:
| 隔离策略 | 隔离程度 | 实现成本 | 适用场景 |
|---|---|---|---|
| 共享表(Separate Table) | 低 | 低 | 早期探索,模块边界还在验证 |
| 独立 Schema(Separate Schema) | 中 | 中 | 模块化单体的推荐起步方案 |
| 独立数据库(Separate Database) | 高 | 高 | 准备拆微服务,需要物理隔离 |
| 异构存储(Different Persistence) | 最高 | 最高 | 不同模块的读写模式差异极大 |
这四种策略不是非此即彼。我在实际项目里见到的合理做法是:起步用独立 Schema 做逻辑隔离,等模块成熟、确认要独立部署后,再迁移到独立数据库。一步到位做物理隔离的成本太高,而且容易在边界还没想清楚时就锁死了错误的结构。
Martin Fowler 对限界上下文的描述有一个关键限定:它不是一个技术边界,而是一个语义边界。同一个「订单」概念,在售前上下文里是购物车里的待结算商品,在履约上下文里是仓库的拣货任务。如果两个模块对同一个实体有不同理解,它们应该分属不同的限界上下文,各自拥有自己的数据。
案例一:订单表和库存表的写入战争
场景
一个电商系统的订单模块和库存模块都部署在同一个单体应用里。最初的设计是:下单时订单模块往 orders 表插入记录,库存模块往 inventory 表扣减库存。后来业务加了一个「下单即锁定库存」的需求,有人在订单 Service 的下单方法里直接写了库存扣减的 SQL。
翻车
半年后,库存团队要把库存模块拆成独立服务,发现 orders 表的写入逻辑分散在三个地方:订单 Service 的正常下单、库存 Service 的补偿回滚、还有后台管理脚本。三个写入点有不同的业务规则,改库存字段时每个地方都要同步修改。
修复
把写入权收回各自的模块。订单模块只写 orders 表,库存模块只写 inventory 表。下单时扣减库存通过模块间的接口调用,不直接操作对方的表。
// ❌ 坏做法:订单模块直接写库存表
class OrderService {
async createOrder(dto: CreateOrderDto) {
const order = await this.orderRepo.save(dto)
// 直接操作库存模块的数据,绕过了库存模块的业务规则
await this.db.query(
'UPDATE inventory SET stock = stock - ? WHERE sku = ?',
[dto.quantity, dto.sku]
)
return order
}
}
// ✅ 好做法:通过模块接口调用,写入权归库存模块
class OrderService {
constructor(
private orderRepo: OrderRepository,
private inventoryClient: InventoryModuleClient // 模块间的显式依赖
) {}
async createOrder(dto: CreateOrderDto) {
// 先通过接口让库存模块执行扣减
const locked = await this.inventoryClient.lockStock({
sku: dto.sku,
quantity: dto.quantity
})
if (!locked.success) {
throw new InsufficientStockError(dto.sku)
}
// 订单模块只写自己的表
return this.orderRepo.save({ ...dto, lockedAt: new Date() })
}
}这个修复的核心不是禁止跨模块访问,而是把写入路径收拢到数据所有者手里。订单模块通过库存模块暴露的接口发起请求,库存模块自己决定怎么扣减、怎么校验、怎么记录变更。如果未来库存要加「安全库存」校验、换存储引擎、或者拆成独立服务,只需要改库存模块内部的代码。
案例二:派生读模型的数据不一致
场景
商品模块是商品数据的所有者,存储了完整的商品信息(名称、价格、描述、图片等)。搜索模块需要一个商品列表页,它从商品模块同步数据到自己的 Elasticsearch 索引。同步方式是商品模块在创建和更新商品时发布领域事件,搜索模块订阅事件并更新索引。
翻车
运营人员在后台改了商品名称。商品模块更新了数据库,但发布事件时因为序列化异常,ProductUpdated 事件没有发出去。搜索模块的索引里没有收到更新,用户在搜索结果里看到的还是旧名称。三天后客服收到投诉,技术团队排查了半天才发现是事件发送失败。
修复
派生数据要有同步策略,并且明确它不是事实来源(Source of Truth)。除了事件驱动更新,还需要定期对账机制来捕获丢失的事件。
// ❌ 坏做法:只靠事件同步,没有对账
class SearchSyncHandler {
@OnEvent('ProductUpdated')
async handleUpdate(event: ProductUpdatedEvent) {
await this.elastic.update({
index: 'products',
id: event.productId,
doc: { name: event.name, price: event.price }
})
}
// 事件丢失 = 数据不一致,没有任何补偿机制
}
// ✅ 好做法:事件同步 + 定期对账
class SearchSyncHandler {
@OnEvent('ProductUpdated')
async handleUpdate(event: ProductUpdatedEvent) {
await this.elastic.update({
index: 'products',
id: event.productId,
doc: { name: event.name, price: event.price }
})
}
// 每天凌晨对账:以商品模块的数据为准,修正搜索索引
@Cron('0 3 * * *')
async reconcile() {
const lastSynced = await this.getLastSyncCheckpoint()
const products = await this.productClient.getChangedSince(lastSynced)
for (const product of products) {
await this.elastic.update({
index: 'products',
id: product.id,
doc: { name: product.name, price: product.price },
// 用版本号做乐观锁,避免旧数据覆盖新数据
version: product.version
})
}
await this.saveSyncCheckpoint(products[products.length - 1]?.updatedAt)
}
}这个修复增加了两层保障:事件驱动保证实时性,定期对账兜底捕获遗漏。关键设计点是版本号——如果商品模块在 products 表上维护一个 version 字段(每次更新自增),搜索模块就可以用乐观锁来防止旧事件覆盖新数据。事件乱序、重复投递这类问题,在分布式系统里一定会发生,版本号是成本最低的防御手段。
数据所有权的归属决定了谁是事实来源。商品模块拥有商品数据,它的数据库就是 Source of Truth。搜索模块的 Elasticsearch 索引是派生数据,是商品数据的只读副本。派生数据和事实来源之间的同步不可能做到强一致,但可以做到「最终一致 + 可检测 + 可修复」。
案例三:迁移时跨模块查询的连锁反应
场景
用户模块有一张 users 表,存了用户的基本信息。订单模块在做订单列表页时,需要一个联表查询把用户名拉出来:
SELECT o.id, o.amount, u.name
FROM orders o
JOIN users u ON o.user_id = u.id
WHERE o.status = 'paid'这个查询跑了两年没问题。后来用户模块要做数据迁移,把 users 表拆成 user_accounts 和 user_profiles 两张表(账号信息和个人资料分离)。name 字段搬到了 user_profiles 表。
翻车
用户模块改了表结构,订单模块的查询直接报错。排查后发现除了订单模块,还有报表模块、通知模块、数据导出脚本都在联表查 users.name。一个模块的内部迁移,影响了四个消费方。
修复
订单模块不应该直接查用户模块的表。正确的做法是订单模块维护自己需要的用户信息副本(通过事件同步),或者通过用户模块提供的查询接口获取。
// ❌ 坏做法:跨模块联表查询
class OrderRepository {
async getOrderList() {
return this.db.query(`
SELECT o.id, o.amount, u.name AS user_name
FROM orders o
JOIN users u ON o.user_id = u.id
WHERE o.status = 'paid'
`)
// 用户表结构一变,这个查询就挂
}
}
// ✅ 好做法:订单模块维护自己的用户名称冗余字段
class OrderRepository {
async getOrderList() {
return this.db.query(`
SELECT id, amount, user_name
FROM orders
WHERE status = 'paid'
`)
// 只查自己模块的表,不受外部迁移影响
}
}
// 订单创建时,从用户模块获取名称并冗余存储
class OrderService {
async createOrder(dto: CreateOrderDto) {
const user = await this.userClient.getBasicInfo(dto.userId)
return this.orderRepo.save({
...dto,
userName: user.name // 冗余存储,查询时不需要联表
})
}
}这里有一个重要的权衡:冗余存储了用户名称,就可能出现名称更新后订单里的旧名称不一致。这要看业务需求——如果历史订单的用户名称需要保持「下单时的快照」,冗余反而是对的;如果需要实时同步,就需要监听用户名称变更事件来更新。关键是这个决策是显式做出的,而不是靠联表查询隐式依赖。
这个流程图展示了数据所有者和消费方之间的关系。用户模块是唯一的数据所有者,通过事件和接口对外提供信息。消费方模块各自维护自己需要的数据副本,副本的字段和更新策略由消费方根据业务需求决定。用户模块内部表结构怎么变、怎么迁移,只要接口和事件的契约不变,消费方完全不受影响。
所有权先看写入,读取可以灵活
判断数据归属的第一条规则是:看谁有权写入。能写入的模块通常就是数据 owner。其他模块应该通过接口、事件或只读视图获取信息,而不是直接操作 owner 的表。
这个规则在实际执行时有几个常见的疑问。
如果两个模块都要写同一个实体怎么办? 说明这个实体可能跨了两个限界上下文,需要拆分。比如「用户地址」在用户上下文里是账号绑定的默认地址,在物流上下文里是每次配送的实际地址。它们不是同一个概念,应该分开存储。
冗余数据算不算重复写入? 不算。冗余字段是消费方自己表里的一部分,由消费方自己写入和维护。它的数据来源可能是 owner 的事件或接口,但写入动作发生在消费方自己的表里,写入权归消费方。
读取性能要求很高怎么办? 可以建派生读模型。微软 Azure 架构中心建议使用事件驱动架构,让消费方订阅数据所有者的领域事件,构建自己的物化视图(Materialized View)。这个视图可以是 Elasticsearch 索引、Redis 缓存、甚至一个独立的只读数据库。关键是明确标注它是派生数据,不是事实来源。
| 访问方式 | 一致性 | 性能 | 耦合度 | 适用场景 |
|---|---|---|---|---|
| 直接联表查询 | 强一致 | 高(无额外开销) | 极高 | 不推荐,除非是同一模块内部 |
| 模块间接口调用 | 强一致 | 中(网络开销) | 低 | 需要实时数据的跨模块查询 |
| 事件驱动同步 | 最终一致 | 高(本地读取) | 低 | 列表页、搜索、报表 |
| 冗余字段存储 | 最终一致 | 最高(无查询) | 最低 | 只需要少量固定字段 |
这四种方式不是互斥的。一个系统里可以混合使用:核心业务流程用接口调用保证一致性,列表展示用事件同步提高性能,高频读取的少量字段用冗余存储降低延迟。
代码里的所有权边界
数据所有权的规则需要在代码层面强制执行,否则随着团队扩大和时间推移,边界一定会被侵蚀。下面是几个有效的技术手段。
数据库层面的权限隔离
如果用了独立 Schema 策略,可以在数据库层面控制访问权限:
-- 给订单模块的角色只授予 order_schema 的读写权限
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA order_schema TO order_service_role;
-- 给订单模块的角色只授予 user_schema 的只读权限(如果确实需要直接读)
GRANT SELECT ON ALL TABLES IN SCHEMA user_schema TO order_service_role;
-- 不给任何其他 schema 的权限
-- 这样即使订单模块的代码里写了 INSERT INTO user_schema.users ...,数据库也会拒绝代码层面的模块边界检查
如果用 NestJS 或类似框架,可以用模块封装来限制跨模块访问:
// ❌ 坏做法:直接导出 Repository,任何模块都能注入
@Module({
imports: [TypeOrmModule.forFeature([User])],
exports: [TypeOrmModule], // 导出 TypeOrmModule = 暴露 Repository
})
export class UserModule {}
// ✅ 好做法:只暴露服务接口,不暴露 Repository
@Module({
imports: [TypeOrmModule.forFeature([User])],
providers: [UserService, UserRepository],
exports: [UserService], // 只导出业务服务
})
export class UserModule {}
// UserRepository 是私有的,其他模块不能直接注入
// 必须通过 UserService 的方法访问用户数据事件契约的版本管理
模块间通过事件通信时,事件的 Schema 就是双方的契约。契约需要版本管理,否则 owner 改了事件结构,消费方代码就会解析失败。
// ✅ 事件版本管理:向后兼容的字段变更
// v1 事件
interface ProductCreatedEventV1 {
productId: string
name: string
price: number
}
// v2 事件:新增字段,但不删除旧字段
interface ProductCreatedEventV2 {
productId: string
name: string
price: number
category: string // 新增字段,消费方不认识就忽略
originalPrice?: number // 可选字段,不强制要求
}
// 消费方用防御性解析,不假设事件一定包含所有字段
class SearchSyncHandler {
@OnEvent('ProductCreated.*') // 订阅所有版本
async handle(event: unknown) {
const parsed = this.safeParse(event)
await this.elastic.index({
index: 'products',
id: parsed.productId,
document: {
name: parsed.name,
price: parsed.price,
category: parsed.category ?? 'uncategorized' // 兼容 v1 没有 category 的情况
}
})
}
}| 契约管理方式 | 变更成本 | 消费方负担 | 适用阶段 |
|---|---|---|---|
| 无版本管理 | 低(但风险高) | 低(但随时可能挂) | 原型阶段 |
| 字段只增不删 | 中 | 低(忽略未知字段) | 模块稳定期 |
| 显式版本号 + 多版本共存 | 高 | 中(解析逻辑复杂) | 对外 API / 多团队 |
| Schema Registry(Protobuf/Avro) | 中 | 低(自动生成代码) | 大规模系统 |
派生数据的同步策略
一旦承认了数据冗余的必要性(为了性能、为了隔离),就要认真处理同步问题。下面是几种常见的同步策略和它们的特点。
同步写入(双写):owner 在写自己的数据时,同时写消费方的表。优点是一致性好,缺点是耦合度高——owner 要知道所有消费方是谁。这违反了数据所有权的原则,不推荐。
事件驱动异步同步:owner 发布领域事件,消费方订阅并更新自己的数据。优点是解耦,缺点是最终一致、有延迟。大多数场景下这是合理的选择。
定时全量/增量对账:消费方定期从 owner 的接口拉取数据,全量覆盖或增量更新。优点是可以兜底捕获丢失的事件,缺点是延迟大、对 owner 有查询压力。通常作为事件驱动的补充。
Change Data Capture (CDC):通过监听数据库的 binlog 或 WAL 来捕获变更,自动同步到消费方。优点是对业务代码无侵入,缺点是引入额外的基础设施。适合数据量大、对延迟敏感的同步场景。
| 同步策略 | 一致性 | 延迟 | 耦合度 | 基础设施要求 |
|---|---|---|---|---|
| 同步双写 | 强一致 | 无 | 高 | 无 |
| 事件驱动 | 最终一致 | 秒级 | 低 | 消息队列 |
| 定时对账 | 最终一致 | 分钟~小时 | 低 | 定时任务 |
| CDC | 最终一致 | 秒级 | 最低 | CDC 中间件 |
在实践中,我通常推荐「事件驱动 + 定时对账」的组合。事件驱动保证实时性,定时对账兜底捕获遗漏。CDC 适合数据量大、变更频繁的场景,但引入的基础设施成本不低,需要团队有能力运维。
数据所有权的检查清单
在评审模块设计或者重构现有代码时,可以用这个清单逐项检查。
设计阶段
- 每个领域模块是否明确了自己的数据所有权?是否知道哪些表是自己拥有的、哪些是只读的?
- 模块间的读写边界是否文档化?新加入团队的成员能否通过文档快速了解数据归属?
- 如果一个实体被多个模块使用,是否分析了不同模块对这个实体的理解是否一致?不一致时是否考虑了拆分?
- 是否识别了派生数据(冗余字段、缓存、搜索索引)并标注了它们的事实来源?
实现阶段
- 跨模块的数据写入是否通过模块接口完成,而不是直接操作对方的表?
- 跨模块的查询是否避免了联表,改用冗余字段、接口调用或派生读模型?
- 事件驱动同步是否有对账机制来捕获丢失的事件?
- 事件契约是否有版本管理?新增字段是否向后兼容?
- 数据库层面是否有权限隔离?模块是否只能写自己的 Schema?
运维与演进阶段
- 数据所有者的表结构迁移计划是否考虑了消费方的影响?是否通过接口和事件契约来屏蔽变更?
- 派生数据和事实来源之间是否有监控?数据不一致时能否及时发现?
- 是否定期审查跨模块的数据访问路径?是否有新增的直接依赖没有被发现?
一些务实的建议
数据所有权不是一个非黑即白的规则。在实际项目里,完全严格的隔离成本很高,而且可能在早期没有必要。但「不隔离」和「有意识地选择适度耦合」是两回事。
如果团队在早期,模块边界还在探索,可以先做逻辑隔离(独立 Schema 或表前缀),配合代码层面的模块边界检查。等边界稳定了,再逐步加数据库层面的权限控制和事件同步。
如果系统已经有很多跨模块的联表查询和直接表访问,不要试图一步到位全部重构。先识别出变更最频繁的模块,把那些模块的数据所有权理清、隔离做好。变更越频繁的模块,数据耦合的代价越高。
如果不确定一个冗余是否合理,问自己两个问题:这个冗余字段更新频率高不高?如果不一致,业务后果是什么?更新频率低、不一致后果可控的冗余,可以容忍。更新频率高、不一致会影响核心业务的冗余,就需要实时同步机制。
数据所有权的本质是变更隔离。谁拥有数据,谁就承担这个数据变更的全部成本。其他模块通过契约访问数据,就不用关心底层表结构怎么变、存储引擎怎么换。这个契约可以是 API 接口、领域事件、或者数据库的只读视图——形式不重要,重要的是边界是否明确、是否被代码和流程保护。
参考资料
- Pattern: Database per Service - Microservices.io — Chris Richardson 对数据库独享模式的系统阐述,包含反模式分析和解决方案。
- Data Considerations for Microservices - Azure Architecture Center — 微软 Azure 架构中心对数据所有权、一致性策略和事件驱动同步的官方指导。
- Data Management in Modular Monoliths: 4 Data Isolation Strategies — Mehmet Ozkaya 对模块化单体四种数据隔离策略的对比分析。
- Bounded Context - Martin Fowler — Martin Fowler 对限界上下文的精确定义,语义边界优于技术边界。
- 领域驱动设计在互联网业务开发中的实践 - 美团技术团队 — 美团技术团队对 DDD 在大型系统中落地的实践经验,包括模块划分和数据边界。
- Domain-Driven Design (DDD) Demystified - ByteByteGo — ByteByteGo 对 DDD 核心概念的简明图解,适合快速回顾限界上下文和聚合根。
- Getting modules right with Domain-Driven Design - Michael Plöd — Michael Plöd 在 Spring I/O 2022 的演讲,用实际案例说明如何用 DDD 划分模块边界。