如何关联不同 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) 可以防止同一用户重复注册同一课程。
购买课程的处理流程
一种常见的处理流程如下:
- 客户端向 Enrollment Service 提交选课请求。
- Enrollment Service 调用 User Service,确认用户存在且状态正常。
- Enrollment Service 调用 Course Service,确认课程存在、可以购买,并获取价格信息。
- 系统创建待支付订单,由 Payment Service 处理支付。
- 支付成功后,Payment Service 发布
PaymentCompleted事件。 - Enrollment Service 消费该事件,创建或激活课程注册记录。
- 用户访问课程时,系统根据 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 聚合或事件驱动的本地查询模型。
备注:内容仅供参考。
