如何为每个 Microservice 设计独立 datasource?
结论
每个 Microservice 都应独立拥有并管理自己的数据库。Payment Service 可以保存 bookingId,但不要在 Payment 数据库和 Booking 数据库之间建立外键。
跨服务的数据关系可以这样维护:
- 调用 Booking Service API 查询或校验预订信息。
- 通过领域事件,将 Payment Service 需要的少量预订数据同步到本地。
- 使用 Saga、状态机、幂等处理和补偿操作保证业务一致性。
- 外键、唯一约束等数据库约束只在服务自己的数据库内使用。
即使两个数据库位于同一个 MySQL 实例,技术上也允许建立跨 schema 外键,仍不建议这样做。跨库外键会让两个服务在表结构、部署、权限和故障处理上重新产生耦合。
推荐的数据所有权
两个服务的数据边界可以这样划分:
- Booking Service 管理预订、客户、入住日期和预订状态等数据。
- Payment Service 管理支付单、支付状态、支付渠道、支付金额和交易流水号等数据。
- Payment Service 中的
booking_id是业务引用,不是数据库外键。
例如,Booking Service 的数据库可以包含:
CREATE TABLE booking (
id BIGINT PRIMARY KEY,
customer_id BIGINT NOT NULL,
total_amount DECIMAL(19, 2) NOT NULL,
currency CHAR(3) NOT NULL,
status VARCHAR(32) NOT NULL,
created_at TIMESTAMP(6) NOT NULL
);
Payment Service 使用自己的数据库:
CREATE TABLE payment (
id BIGINT PRIMARY KEY,
booking_id BIGINT NOT NULL,
amount DECIMAL(19, 2) NOT NULL,
currency CHAR(3) NOT NULL,
status VARCHAR(32) NOT NULL,
provider_transaction_id VARCHAR(128),
created_at TIMESTAMP(6) NOT NULL,
updated_at TIMESTAMP(6) NOT NULL,
INDEX idx_payment_booking_id (booking_id)
);
这里没有设置:
FOREIGN KEY (booking_id) REFERENCES booking(id)
如果业务规定一个 Booking 最多只能有一个 Payment,可以增加:
ALTER TABLE payment
ADD CONSTRAINT uk_payment_booking_id UNIQUE (booking_id);
如果允许支付重试、分期支付或多次扣款,就不应添加这个唯一约束,而应单独设计支付尝试表,例如 payment_attempt。
方案一:通过 Booking Service API 查询
Payment Service 创建支付单前,先调用 Booking Service 获取预订信息,并确认:
- Booking 是否存在。
- 当前状态是否允许付款。
- 支付金额和币种是否匹配。
- Booking 是否已经取消或过期。
Booking Service 可以提供内部接口:
@RestController
@RequestMapping("/internal/bookings")
@RequiredArgsConstructor
public class BookingQueryController {
private final BookingQueryService bookingQueryService;
@GetMapping("/{bookingId}")
public BookingSummary getBooking(@PathVariable Long bookingId) {
return bookingQueryService.getSummary(bookingId);
}
}
接口只返回 Payment Service 真正需要的信息,不要直接暴露 Booking 数据库的表结构:
public record BookingSummary(
Long bookingId,
BigDecimal payableAmount,
String currency,
String status
) {
}
Payment Service 可以使用 Spring WebClient 调用该接口:
@Component
public class BookingClient {
private final WebClient webClient;
public BookingClient(
WebClient.Builder builder,
@Value("${booking-service.base-url}") String baseUrl) {
this.webClient = builder
.baseUrl(baseUrl)
.build();
}
public BookingSummary getBooking(Long bookingId) {
return webClient.get()
.uri("/internal/bookings/{bookingId}", bookingId)
.retrieve()
.bodyToMono(BookingSummary.class)
.block();
}
}
创建支付单时完成业务校验:
@Service
@RequiredArgsConstructor
public class PaymentService {
private final BookingClient bookingClient;
private final PaymentRepository paymentRepository;
@Transactional
public Payment createPayment(Long bookingId) {
BookingSummary booking = bookingClient.getBooking(bookingId);
if (!"PENDING_PAYMENT".equals(booking.status())) {
throw new IllegalStateException("Booking is not payable");
}
Payment payment = new Payment();
payment.setBookingId(booking.bookingId());
payment.setAmount(booking.payableAmount());
payment.setCurrency(booking.currency());
payment.setStatus(PaymentStatus.CREATED);
return paymentRepository.save(payment);
}
}
这个例子只展示调用关系。生产环境还要处理超时、有限次数的重试、熔断、身份认证和错误映射。支付创建请求不能无条件重试,否则可能造成重复扣款。
这种方式实现简单,也能获取较新的 Booking 状态,但 Booking Service 不可用时,Payment Service 也会受到影响。
方案二:通过事件同步所需数据
如果 Payment Service 经常读取 Booking 信息,或者付款流程不能因为单次查询失败而完全不可用,可以让 Booking Service 发布领域事件,例如:
{
"eventId": "01J8Y8M6J5B7S4Q3ZC2N1K0ABC",
"eventType": "BookingCreated",
"occurredAt": "2026-09-01T10:30:00Z",
"bookingId": 10001,
"payableAmount": 1280.00,
"currency": "CNY",
"status": "PENDING_PAYMENT"
}
Payment Service 消费事件后,将需要的字段保存到本地,例如:
CREATE TABLE booking_payment_view (
booking_id BIGINT PRIMARY KEY,
payable_amount DECIMAL(19, 2) NOT NULL,
currency CHAR(3) NOT NULL,
booking_status VARCHAR(32) NOT NULL,
event_version BIGINT NOT NULL,
updated_at TIMESTAMP(6) NOT NULL
);
这张表只是 Payment Service 的本地视图,并非 Booking Service 数据表的完整副本。Payment Service 也不能直接修改 Booking 状态。
事件驱动减少了同步调用,却会带来最终一致性问题,因为事件发布和消费之间存在延迟。真正扣款前是否还要向 Booking Service 再次确认状态,需要根据业务风险决定。涉及金额或库存锁定时,通常要采用更严格的业务协议,不能只依赖可能存在延迟的本地副本。
保证事件可靠性
如果 Booking Service 先提交数据库事务,再发送消息,进程可能在这两步之间崩溃。此时 Booking 已经创建,事件却没有发出。反过来,如果先发送消息,也可能出现事件已经发出,但事务最终回滚的情况。
常见的处理方式是 Transactional Outbox。Booking Service 在同一个本地事务中写入 Booking 和事件记录:
CREATE TABLE outbox_event (
id CHAR(26) PRIMARY KEY,
aggregate_type VARCHAR(64) NOT NULL,
aggregate_id VARCHAR(64) NOT NULL,
event_type VARCHAR(128) NOT NULL,
payload JSON NOT NULL,
created_at TIMESTAMP(6) NOT NULL,
published_at TIMESTAMP(6)
);
@Transactional
public Booking createBooking(CreateBookingCommand command) {
Booking booking = bookingRepository.save(Booking.create(command));
OutboxEvent event = OutboxEvent.bookingCreated(booking);
outboxEventRepository.save(event);
return booking;
}
之后,由后台任务或 CDC 组件将 Outbox 中的事件发送到消息系统。消息通常采用至少一次投递,因此 Payment Service 必须根据 eventId 去重,并确保重复消费不会重复创建支付单。
如何替代跨数据库约束
数据库外键主要保证被引用的记录存在,并在删除时维持引用关系。拆分为多个服务后,这些规则需要转成业务层约束:
| 原来的数据库约束 | Microservice 中的实现 |
|---|---|
| Booking 必须存在 | 调用 Booking API 校验,或检查本地事件视图 |
| Booking 删除时禁止存在 Payment | Booking 不做物理删除;使用取消、关闭等状态 |
| 一个 Booking 只能创建一个 Payment | Payment 数据库中的唯一约束或幂等键 |
| Payment 金额必须与 Booking 一致 | 由 Booking Service 提供应付金额,Payment Service 校验并保存快照 |
| Booking 取消后不能支付 | 状态机、事件通知,以及扣款前的最终校验 |
| 两个服务必须同时更新成功 | Saga 和补偿操作 |
Payment 表需要保存金额和币种的快照。展示历史支付记录时,不能再根据 Booking 的当前金额动态计算,否则 Booking 修改后,支付审计记录也会随之改变。
使用 Saga 处理完整付款流程
一次付款通常涉及 Booking Service、Payment Service 和外部支付渠道,无法依靠 MySQL 的单个本地事务完成。
流程可以设计为:
- Booking Service 将 Booking 从
PENDING_PAYMENT改为PAYMENT_PROCESSING。 - Payment Service 创建 Payment,并使用幂等键请求支付渠道扣款。
- 扣款成功后发布
PaymentSucceeded。 - Booking Service 消费事件,将 Booking 改为
CONFIRMED。 - 如果扣款失败,发布
PaymentFailed,Booking Service 恢复到允许重试的状态。 - 如果扣款成功但 Booking 无法确认,则根据业务规则人工介入或执行退款补偿。
支付操作通常无法真正回滚。因此,这里的“事务”并不是让两个数据库一起提交或回滚,而是通过状态迁移和补偿操作完成。
需要特别注意的地方
不要让 Payment Service 直接连接 Booking 数据库。这样做会妨碍 Booking Service 自主修改表结构,也会增加独立扩容、迁移和控制数据访问权限的难度。
不要使用跨服务 JOIN。需要组合展示数据时,可以由 API Gateway、BFF 或专门的查询服务调用多个服务。查询量较大时,可以建立面向查询的本地读模型。
不能把“调用 Booking API 成功”视为永久约束。校验完成后,Booking 仍可能被取消,所以必须明确状态转换规则。例如,进入 PAYMENT_PROCESSING 后能否取消、超时后如何释放,以及成功扣款与取消同时发生时哪一个操作优先。
所有会产生副作用的接口都应支持幂等。创建支付时,可以由调用方提供 Idempotency-Key,Payment Service 再为该键设置数据库唯一约束,防止网络重试产生多笔支付。
服务间调用还应设置明确的超时时间,并区分错误类型。Booking 不存在或状态不允许付款属于业务错误,连接超时和服务不可用属于临时故障,不能用同一种方式处理。
最终原则是:数据库约束负责单个服务内部的数据完整性,服务之间的数据完整性则由 API 契约、事件、幂等、状态机和补偿流程共同维护。
备注:内容仅供参考。
