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 用于分析一次调用或消息传播经过的技术链路。
- 日志记录实现细节,用于异常诊断。
推荐的整体方案
可以按以下顺序实现:
- Booking API 在本地事务中创建 booking 和 outbox event。
- API 返回
202 Accepted、bookingId和statusUrl。 - Saga orchestrator 消费
BookingCreated,并发起付款请求。 - Payment API 使用幂等键处理付款,然后发布
PaymentSucceeded或PaymentFailed。 - Saga 根据付款结果完成订单、执行补偿,或者进入超时及人工处理状态。
- Notification API 在主要业务完成后异步发送通知。除非业务有明确要求,否则通知失败不应回滚付款和订单。
- 前端通过状态接口、SSE 或 WebSocket 获取处理进度。
- 各服务分别持有自己的数据库和 event store。
- 独立的 timeline projection 汇总 integration events,支持跨服务审计和查询。
- 分别为消息重复、乱序、重试、DLQ、超时和结果未知制定处理规则。
Event Sourcing 会明显增加事件版本管理、投影重建、幂等消费和运维成本。如果需求只是查看完整轨迹,通常可以先使用普通状态表、outbox、不可变审计日志和 timeline projection。只有业务确实需要通过事件重建聚合、执行时态查询或保留完整的领域历史时,再在对应服务中实施 Event Sourcing。
备注:内容仅供参考。
