PAYATHON 2026

Microservices 客户端确认、Saga 与 Event Sourcing

支付老李

明确结论

Booking API 不应同步等待 Payment API 和 Notification API 返回确认。创建订单后,API 应立即返回 202 Accepted,并提供 bookingId 和状态查询地址。前端可以短轮询状态接口,也可以通过 SSE、WebSocket 或移动端推送接收状态变化。

跨服务流程应交给 Saga 管理,每个步骤都要定义成功、失败、超时和补偿规则。服务暂时不可用不等于业务失败,系统需要根据重试次数、截止时间等规则判断最终结果。

Event Sourcing 应遵循“每个服务拥有自己的数据和事件存储”这一原则。多个微服务不应共同读写一个 event store。如果需要查看订单的完整轨迹,可以订阅各服务发布的 integration events,构建单独的流程视图或审计投影。

客户端如何获得处理结果

Booking API 接受请求并完成本地事务后,可以返回:

HTTP/1.1 202 Accepted
Location: /bookings/bkg-123/status
Retry-After: 2
Content-Type: application/json
{
  "bookingId": "bkg-123",
  "status": "PROCESSING",
  "statusUrl": "/bookings/bkg-123/status"
}

状态查询接口可以返回整个流程的聚合状态:

GET /bookings/bkg-123/status
{
  "bookingId": "bkg-123",
  "status": "PROCESSING",
  "steps": {
    "booking": "COMPLETED",
    "payment": "PROCESSING",
    "notification": "PENDING"
  },
  "updatedAt": "2026-09-01T10:30:00Z"
}

订单进入最终状态后,可以返回:

{
  "bookingId": "bkg-123",
  "status": "COMPLETED",
  "steps": {
    "booking": "COMPLETED",
    "payment": "COMPLETED",
    "notification": "COMPLETED"
  }
}

短轮询适合处理时间只有几秒、并发量不高的系统。查询间隔可以逐步增加,并为前端设置等待上限。例如依次在 1、2、4、8 秒后查询,避免永久保持每秒一次的请求频率。

如果需要更及时的交互,可以选择:

  • SSE:适合由服务器单向推送订单状态,实现通常比 WebSocket 简单。
  • WebSocket:适合已有长连接基础设施,或者需要双向实时通信的系统。
  • Web Push 或移动端推送:适合流程可能持续数分钟,而且用户可能已经离开当前页面的情况。

即使使用推送,也要保留状态查询接口。推送可能因断线而丢失,客户端重新打开页面后仍需主动查询当前状态。

使用 Saga 管理跨服务流程

在这个场景中,确认消息更适合被视为业务事件,而不是消息队列层面的 acknowledgement。例如:

BookingCreated
PaymentSucceeded
PaymentFailed
NotificationSent
NotificationFailed
BookingCompleted
BookingFailed

消息中应携带统一的关联信息:

{
  "eventId": "evt-456",
  "eventType": "PaymentSucceeded",
  "bookingId": "bkg-123",
  "sagaId": "saga-789",
  "correlationId": "req-abc",
  "occurredAt": "2026-09-01T10:30:05Z"
}

Saga 常见的实现方式有两种。

编排式 Saga

Saga orchestrator 保存流程状态,并决定下一步操作:

CreateBooking
    ↓
RequestPayment
    ├── PaymentSucceeded → RequestNotification → CompleteBooking
    └── PaymentFailed    → CancelBooking

这种方式适合当前场景,因为流程顺序、超时条件和失败处理都比较清晰。orchestrator 可以归属于 Booking 业务域,也可以作为独立的 workflow service,但不应长时间占用 HTTP 请求来等待处理结果。

Saga 状态可以设计为:

PENDING
PAYMENT_PROCESSING
PAYMENT_FAILED
NOTIFICATION_PROCESSING
COMPLETED
COMPENSATING
COMPENSATED
TIMED_OUT

协同式 Saga

各服务分别订阅事件,并在完成处理后发布新事件,不设置中央 orchestrator。例如,Payment API 收到 BookingCreated 后发布 PaymentSucceeded,Notification API 再订阅该事件。

这种方式适合步骤较少、依赖关系简单的流程。分支、重试和补偿一旦增多,流程逻辑就容易散落在多个服务中,问题也更难排查。

如何处理故障、超时和无限等待

系统不能假设所有服务最终都会返回确认。每个 Saga 步骤都应设置业务截止时间,并记录下一次重试时间。例如:

{
  "sagaId": "saga-789",
  "bookingId": "bkg-123",
  "currentStep": "PAYMENT",
  "status": "PAYMENT_PROCESSING",
  "attempts": 3,
  "deadline": "2026-09-01T10:35:00Z",
  "nextRetryAt": "2026-09-01T10:31:30Z"
}

超过截止时间后,可以按照业务规则采取以下处理方式:

  • 将状态标记为 TIMED_OUT;
  • 继续在后台重试,同时向用户显示“处理中时间较长”;
  • 转入人工处理队列;
  • 执行补偿操作,并将订单标记为失败。

处理时需要区分三种情况:

  • 技术故障:服务宕机、网络中断或消息暂时无法投递,通常需要重试。
  • 业务失败:余额不足、银行卡拒付或订单参数无效,应直接进入确定的失败状态。
  • 结果未知:付款请求已经发出,但响应丢失。此时不能贸然再次扣款,应使用幂等键查询结果或重试原请求。

付款接口必须支持幂等处理,例如使用 bookingId 或单独生成的 paymentRequestId:

POST /payments
Idempotency-Key: payment-bkg-123

消费者也要保存已经处理过的 eventId,防止消息重复投递造成重复扣款或重复发送通知。

保证数据库提交与事件发布的一致性

下面的执行顺序存在风险:

写入 Booking 数据库
提交事务
发布 BookingCreated

如果数据库提交成功后进程崩溃,事件可能永远无法发布。解决这个问题可以使用 Transactional Outbox,在同一个本地数据库事务中写入 booking 和 outbox 记录。

BEGIN;

INSERT INTO bookings (id, status)
VALUES ('bkg-123', 'PROCESSING');

INSERT INTO outbox_events (
    event_id,
    aggregate_id,
    event_type,
    payload
) VALUES (
    'evt-456',
    'bkg-123',
    'BookingCreated',
    '{"bookingId":"bkg-123"}'
);

COMMIT;

后台 publisher 随后将 outbox 中尚未发布的记录发送到消息代理。发送过程可能产生重复消息,因此消费者仍要保证幂等。对应的 Inbox Pattern 可以记录消费者已经处理过的事件。

消息重试达到上限后可以进入 DLQ,但 DLQ 不能成为流程的终点。系统还需要提供监控、告警和重新投递机制,并同步更新用户可见的 Saga 状态。

Notification 是否应该决定订单成功

通常,付款成功才是订单的主要业务结果。通知发送失败不应让整个 booking 失败。状态可以设计为:

{
  "status": "COMPLETED",
  "paymentStatus": "COMPLETED",
  "notificationStatus": "RETRYING"
}

Notification API 可以独立重试,直到发送成功或转入人工处理。只有业务明确规定“通知送达后订单才算完成”时,才应把通知放入 Saga 的关键路径。这取决于业务规则,并非微服务架构本身的要求。

Event Sourcing 应如何划分

Event Sourcing 不是把所有服务的消息简单保存到一个公共表中。它要求通过领域事件的重放来还原某个聚合的当前状态。例如,Booking 聚合可以保存:

BookingRequested
BookingConfirmed
BookingCancelled

Payment 聚合保存自己的事件:

PaymentRequested
PaymentAuthorized
PaymentCaptured
PaymentDeclined
PaymentRefunded

每个服务都应拥有自己的 event store,而且只有该服务可以写入。其他服务通过公开的 integration events 协作,不应直接读取或追加另一个服务的领域事件。

共享 event store 会带来以下问题:

  • 服务的数据所有权变得模糊;
  • 多个服务与同一套事件结构和存储模式耦合;
  • schema 演进需要多个团队共同协调;
  • 一个服务可能依赖另一个服务的内部领域事件;
  • 存储故障可能扩散为多个服务的共同故障。

领域事件和 integration event 不必一一对应。服务可以先处理内部领域事件,再发布结构更稳定的外部事件。例如,Payment 服务内部产生 CardAuthorizationRecorded,但对外只发布 PaymentSucceeded。

构建完整的订单处理轨迹

如果需要跨服务查看某笔 booking 的完整处理过程,可以建立独立的 Process View、Audit Service 或 Timeline Projection。它订阅各服务的 integration events,再按照 bookingId、sagaId 和 correlationId 整理时间线:

{
  "bookingId": "bkg-123",
  "timeline": [
    {
      "type": "BookingCreated",
      "service": "booking",
      "occurredAt": "2026-09-01T10:30:00Z"
    },
    {
      "type": "PaymentSucceeded",
      "service": "payment",
      "occurredAt": "2026-09-01T10:30:05Z"
    },
    {
      "type": "NotificationSent",
      "service": "notification",
      "occurredAt": "2026-09-01T10:30:07Z"
    }
  ]
}

这个投影可以使用适合查询的普通数据库,不一定要采用 Event Sourcing。它是可以重建的读取模型,不是供所有服务共享的事务数据库。

系统还应配合 distributed tracing,不过它与上述机制解决的问题不同:

  • Saga 状态表示业务流程已经进行到哪一步。
  • Audit timeline 记录已经发生的业务事件。
  • Distributed tracing 用于分析一次调用或消息传播经过的技术链路。
  • 日志记录实现细节,用于异常诊断。

推荐的整体方案

可以按以下顺序实现:

  1. Booking API 在本地事务中创建 booking 和 outbox event。
  2. API 返回 202 Accepted、bookingId 和 statusUrl。
  3. Saga orchestrator 消费 BookingCreated,并发起付款请求。
  4. Payment API 使用幂等键处理付款,然后发布 PaymentSucceeded 或 PaymentFailed。
  5. Saga 根据付款结果完成订单、执行补偿,或者进入超时及人工处理状态。
  6. Notification API 在主要业务完成后异步发送通知。除非业务有明确要求,否则通知失败不应回滚付款和订单。
  7. 前端通过状态接口、SSE 或 WebSocket 获取处理进度。
  8. 各服务分别持有自己的数据库和 event store。
  9. 独立的 timeline projection 汇总 integration events,支持跨服务审计和查询。
  10. 分别为消息重复、乱序、重试、DLQ、超时和结果未知制定处理规则。

Event Sourcing 会明显增加事件版本管理、投影重建、幂等消费和运维成本。如果需求只是查看完整轨迹,通常可以先使用普通状态表、outbox、不可变审计日志和 timeline projection。只有业务确实需要通过事件重建聚合、执行时态查询或保留完整的领域历史时,再在对应服务中实施 Event Sourcing。

备注:内容仅供参考。