PAYATHON 2026

如何关联不同 microservices 的独立 RDBMS 数据?

支付阿杰

结论

不同微服务各自使用独立数据库时,通常不设置数据库级 foreign key,也不直接跨库 JOIN。服务之间使用稳定的业务标识关联,如 userId、courseId、paymentId。至于”用户获得了哪些课程”,应由一个职责明确的业务服务记录。

在这个场景中,“用户购买并获得课程访问权”是一个独立的业务概念,可以建模为 Enrollment 或 Entitlement:

Enrollment
- enrollmentId
- userId
- courseId
- paymentId
- status
- createdAt

因此,“用户 X 已注册课程 A、B、C”应直接由 Enrollment 数据表达,而不是临时连接 User、Course 和 Payment 三个数据库推导。

为什么不能继续使用 foreign key

数据库的 foreign key 适合约束同一个数据库管理边界内的数据。拆分为微服务后,各服务的职责是:

  • User Service 管理用户数据。
  • Course Service 管理课程数据。
  • Payment Service 管理支付交易。
  • 每个服务只能直接读写自己的数据库。
  • 其他服务的数据通过 API 或事件获取。

如果 Course Service 的数据库保存了 userId,这个字段只是对外部实体的逻辑引用,并不是指向 User Service 数据库的物理外键。

这不只是技术限制,也关系到服务自治。一个服务如果能直接查询或修改另一个服务的表,两者仍在共享数据模型,部署、扩容和数据库迁移都会相互牵制。

应该由哪个服务保存关联

通常可以增加 Enrollment Service,或者把 Enrollment 聚合放在负责选课和课程访问控制的服务中。

各服务可以这样划分职责:

服务负责的数据
User Service用户资料、账户状态
Course Service课程名称、内容、上下架状态
Payment Service支付订单、支付状态、退款记录
Enrollment Service用户与课程的注册关系、访问资格

如果系统规模较小,暂时不想增加第四个服务,也可以由 Course Service 保存 Enrollment 数据。但要明确,它保存的是用户对课程的访问关系,不包括用户资料或支付详情。

Enrollment 数据表可以这样设计:

CREATE TABLE enrollments (
    enrollment_id UUID PRIMARY KEY,
    user_id UUID NOT NULL,
    course_id UUID NOT NULL,
    payment_id UUID,
    status VARCHAR(32) NOT NULL,
    created_at TIMESTAMP NOT NULL,
    updated_at TIMESTAMP NOT NULL,
    UNIQUE (user_id, course_id)
);

CREATE INDEX idx_enrollments_user_id
    ON enrollments (user_id);

其中,user_id、course_id 和 payment_id 都是逻辑引用,不应跨服务数据库建立外键。UNIQUE (user_id, course_id) 可以防止同一用户重复注册同一课程。

购买课程的处理流程

一种常见的处理流程如下:

  1. 客户端向 Enrollment Service 提交选课请求。
  2. Enrollment Service 调用 User Service,确认用户存在且状态正常。
  3. Enrollment Service 调用 Course Service,确认课程存在、可以购买,并获取价格信息。
  4. 系统创建待支付订单,由 Payment Service 处理支付。
  5. 支付成功后,Payment Service 发布 PaymentCompleted 事件。
  6. Enrollment Service 消费该事件,创建或激活课程注册记录。
  7. 用户访问课程时,系统根据 Enrollment 状态判断其是否拥有访问权限。

事件示例:

{
  "eventId": "evt-8c31b8ea",
  "eventType": "PaymentCompleted",
  "occurredAt": "2026-09-01T10:20:30Z",
  "paymentId": "pay-9012",
  "orderId": "order-3001",
  "userId": "user-X",
  "items": [
    {
      "courseId": "course-A"
    },
    {
      "courseId": "course-B"
    },
    {
      "courseId": "course-C"
    }
  ]
}

Enrollment Service 收到事件后,把注册记录写入自己的数据库:

INSERT INTO enrollments (
    enrollment_id,
    user_id,
    course_id,
    payment_id,
    status,
    created_at,
    updated_at
)
VALUES
    ('enr-001', 'user-X', 'course-A', 'pay-9012', 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
    ('enr-002', 'user-X', 'course-B', 'pay-9012', 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
    ('enr-003', 'user-X', 'course-C', 'pay-9012', 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT (user_id, course_id) DO NOTHING;

ON CONFLICT 是 PostgreSQL 语法。使用其他 RDBMS 时,需要换成对应的幂等写入方式。

如何查询用户已经注册的课程

先向 Enrollment Service 查询课程 ID:

GET /users/user-X/enrollments

返回:

{
  "userId": "user-X",
  "enrollments": [
    {
      "courseId": "course-A",
      "status": "ACTIVE"
    },
    {
      "courseId": "course-B",
      "status": "ACTIVE"
    },
    {
      "courseId": "course-C",
      "status": "ACTIVE"
    }
  ]
}

如果页面还要显示课程名称、封面和讲师等信息,可以采用下面两种方式之一。

API Composition

由 API Gateway、Backend for Frontend 或聚合服务先查询 Enrollment Service,再批量查询 Course Service:

POST /courses/batch
Content-Type: application/json

{
  "courseIds": ["course-A", "course-B", "course-C"]
}

Course Service 返回课程详情后,再由聚合层合并结果。

这种方式实现简单,适合查询量不大、服务调用链较短的系统。Course Service 应提供批量接口,避免每门课程都发起一次请求,造成 N+1 调用问题。

本地查询模型

如果”我的课程”是高频页面,可以在 Enrollment Service 或单独的 Query Service 中维护一份课程展示数据:

UserCourseView
- userId
- courseId
- courseName
- coverUrl
- enrollmentStatus

课程名称或封面发生变化时,Course Service 发布 CourseUpdated 事件,查询服务消费事件并更新本地副本。读取时只需查询一个数据库,不必同步调用多个服务。

这种设计属于 CQRS 或事件驱动的物化视图。数据会存在短暂的最终一致性,但查询性能和可用性通常更好。

数据一致性如何保证

跨服务操作无法依赖单个数据库事务。支付成功与课程注册发生在不同数据库中,需要使用 Saga 或基于事件的工作流。

以下情况需要专门处理:

  • 支付成功事件可能被重复投递。
  • Enrollment Service 处理事件时可能暂时不可用。
  • 数据写入成功后,消息确认可能失败,导致事件被再次消费。
  • 用户退款后,需要撤销或调整课程权限。
  • 课程下架不一定意味着已有用户会失去访问资格。

因此,事件消费者必须保证幂等性。可以记录已经处理过的 eventId,也可以使用明确的唯一约束:

CREATE TABLE processed_events (
    event_id VARCHAR(128) PRIMARY KEY,
    processed_at TIMESTAMP NOT NULL
);

事件发布端可以使用 Transactional Outbox Pattern,在同一个本地事务中保存支付状态和待发布事件,降低”数据库已经提交,但事件没有发出”的风险。

不要把支付记录直接当作访问权限

“付款成功”和”拥有课程访问权”并不是同一种状态。

支付可能发生退款、拒付、撤销或人工补偿,课程也可能由管理员免费分配。因此,检查课程访问权限时,应查询 Enrollment 或 Entitlement 状态:

PENDING_PAYMENT
ACTIVE
SUSPENDED
REVOKED
COMPLETED

Payment Service 记录支付事实,Enrollment Service 根据这些事实维护访问资格。不要在用户每次打开课程时临时查询 Payment Service,再推断是否允许访问。

需要注意的边界问题

  • 一个服务不应直接连接另一个服务的数据库。
  • 注册关系只能有一个权威数据源,不要让多个服务分别维护。
  • 外部 ID 必须保持稳定,不能使用用户名、邮箱或课程名称作为关联键。
  • 删除用户或课程时,不能依赖数据库级联删除,应通过事件或业务流程清理关联数据。
  • API 调用应设置超时、重试和熔断,但不能盲目重试非幂等操作。
  • 事件只携带消费者确实需要的数据,不要复制完整的用户对象或支付对象。
  • 如果业务不需要独立扩容、独立部署或团队自治,拆分多个数据库未必合算。模块化 monolith 往往更简单。

核心原则是:数据库归服务所有,关联关系由业务模型表达。跨服务引用使用稳定 ID,用户与课程之间的权威关系由 Enrollment 保存。查询时,再根据规模选择 API 聚合或事件驱动的本地查询模型。

备注:内容仅供参考。