领域模块的数据所有权:谁能读,谁能写

0阅读13分钟

数据耦合比代码耦合更隐蔽

我在项目里见过不少这样的场景:模块目录拆得干干净净,每个模块有自己的 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_accountsuser_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  // 冗余存储,查询时不需要联表
    })
  }
}

这里有一个重要的权衡:冗余存储了用户名称,就可能出现名称更新后订单里的旧名称不一致。这要看业务需求——如果历史订单的用户名称需要保持「下单时的快照」,冗余反而是对的;如果需要实时同步,就需要监听用户名称变更事件来更新。关键是这个决策是显式做出的,而不是靠联表查询隐式依赖。

流程图画布 · 115%
Mermaid 流程图加载中...

这个流程图展示了数据所有者和消费方之间的关系。用户模块是唯一的数据所有者,通过事件和接口对外提供信息。消费方模块各自维护自己需要的数据副本,副本的字段和更新策略由消费方根据业务需求决定。用户模块内部表结构怎么变、怎么迁移,只要接口和事件的契约不变,消费方完全不受影响。

所有权先看写入,读取可以灵活

判断数据归属的第一条规则是:看谁有权写入。能写入的模块通常就是数据 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 适合数据量大、变更频繁的场景,但引入的基础设施成本不低,需要团队有能力运维。

数据所有权的检查清单

在评审模块设计或者重构现有代码时,可以用这个清单逐项检查。

设计阶段

  1. 每个领域模块是否明确了自己的数据所有权?是否知道哪些表是自己拥有的、哪些是只读的?
  2. 模块间的读写边界是否文档化?新加入团队的成员能否通过文档快速了解数据归属?
  3. 如果一个实体被多个模块使用,是否分析了不同模块对这个实体的理解是否一致?不一致时是否考虑了拆分?
  4. 是否识别了派生数据(冗余字段、缓存、搜索索引)并标注了它们的事实来源?

实现阶段

  1. 跨模块的数据写入是否通过模块接口完成,而不是直接操作对方的表?
  2. 跨模块的查询是否避免了联表,改用冗余字段、接口调用或派生读模型?
  3. 事件驱动同步是否有对账机制来捕获丢失的事件?
  4. 事件契约是否有版本管理?新增字段是否向后兼容?
  5. 数据库层面是否有权限隔离?模块是否只能写自己的 Schema?

运维与演进阶段

  1. 数据所有者的表结构迁移计划是否考虑了消费方的影响?是否通过接口和事件契约来屏蔽变更?
  2. 派生数据和事实来源之间是否有监控?数据不一致时能否及时发现?
  3. 是否定期审查跨模块的数据访问路径?是否有新增的直接依赖没有被发现?

一些务实的建议

数据所有权不是一个非黑即白的规则。在实际项目里,完全严格的隔离成本很高,而且可能在早期没有必要。但「不隔离」和「有意识地选择适度耦合」是两回事。

如果团队在早期,模块边界还在探索,可以先做逻辑隔离(独立 Schema 或表前缀),配合代码层面的模块边界检查。等边界稳定了,再逐步加数据库层面的权限控制和事件同步。

如果系统已经有很多跨模块的联表查询和直接表访问,不要试图一步到位全部重构。先识别出变更最频繁的模块,把那些模块的数据所有权理清、隔离做好。变更越频繁的模块,数据耦合的代价越高。

如果不确定一个冗余是否合理,问自己两个问题:这个冗余字段更新频率高不高?如果不一致,业务后果是什么?更新频率低、不一致后果可控的冗余,可以容忍。更新频率高、不一致会影响核心业务的冗余,就需要实时同步机制。

数据所有权的本质是变更隔离。谁拥有数据,谁就承担这个数据变更的全部成本。其他模块通过契约访问数据,就不用关心底层表结构怎么变、存储引擎怎么换。这个契约可以是 API 接口、领域事件、或者数据库的只读视图——形式不重要,重要的是边界是否明确、是否被代码和流程保护。

参考资料

评论 0

0 / 1000