PAYATHON 2026

如何为每个 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 删除时禁止存在 PaymentBooking 不做物理删除;使用取消、关闭等状态
一个 Booking 只能创建一个 PaymentPayment 数据库中的唯一约束或幂等键
Payment 金额必须与 Booking 一致由 Booking Service 提供应付金额,Payment Service 校验并保存快照
Booking 取消后不能支付状态机、事件通知,以及扣款前的最终校验
两个服务必须同时更新成功Saga 和补偿操作

Payment 表需要保存金额和币种的快照。展示历史支付记录时,不能再根据 Booking 的当前金额动态计算,否则 Booking 修改后,支付审计记录也会随之改变。

使用 Saga 处理完整付款流程

一次付款通常涉及 Booking Service、Payment Service 和外部支付渠道,无法依靠 MySQL 的单个本地事务完成。

流程可以设计为:

  1. Booking Service 将 Booking 从 PENDING_PAYMENT 改为 PAYMENT_PROCESSING。
  2. Payment Service 创建 Payment,并使用幂等键请求支付渠道扣款。
  3. 扣款成功后发布 PaymentSucceeded。
  4. Booking Service 消费事件,将 Booking 改为 CONFIRMED。
  5. 如果扣款失败,发布 PaymentFailed,Booking Service 恢复到允许重试的状态。
  6. 如果扣款成功但 Booking 无法确认,则根据业务规则人工介入或执行退款补偿。

支付操作通常无法真正回滚。因此,这里的“事务”并不是让两个数据库一起提交或回滚,而是通过状态迁移和补偿操作完成。

需要特别注意的地方

不要让 Payment Service 直接连接 Booking 数据库。这样做会妨碍 Booking Service 自主修改表结构,也会增加独立扩容、迁移和控制数据访问权限的难度。

不要使用跨服务 JOIN。需要组合展示数据时,可以由 API Gateway、BFF 或专门的查询服务调用多个服务。查询量较大时,可以建立面向查询的本地读模型。

不能把“调用 Booking API 成功”视为永久约束。校验完成后,Booking 仍可能被取消,所以必须明确状态转换规则。例如,进入 PAYMENT_PROCESSING 后能否取消、超时后如何释放,以及成功扣款与取消同时发生时哪一个操作优先。

所有会产生副作用的接口都应支持幂等。创建支付时,可以由调用方提供 Idempotency-Key,Payment Service 再为该键设置数据库唯一约束,防止网络重试产生多笔支付。

服务间调用还应设置明确的超时时间,并区分错误类型。Booking 不存在或状态不允许付款属于业务错误,连接超时和服务不可用属于临时故障,不能用同一种方式处理。

最终原则是:数据库约束负责单个服务内部的数据完整性,服务之间的数据完整性则由 API 契约、事件、幂等、状态机和补偿流程共同维护。

备注:内容仅供参考。