如何优化基于 SQS queue 的事件驱动 worker node 架构?
结论
把可能耗时超过 15 分钟的付款处理从同步 HTTP 调用改为异步任务,整体方向合理,也能避开 ALB 的请求超时限制。
不过,不建议把 integrator API 的后处理逻辑和 invoice API 调用直接移入 payment API。否则,payment API 会同时负责付款处理、流程编排和开票,服务间的耦合也会随之增加。
建议采用下面的结构:
Client
│
▼
Integrator API
│ 1. 保存原始 payment details
│ 2. 创建 operation/job
│ 3. 投递 PaymentRequested
│ 4. 返回 202 Accepted + operationId
▼
Payment Request Queue
│
▼
Payment Worker
│ 调用 legacy DBMS
│
├──成功──► PaymentCompleted Queue
│
└──失败──► retry / DLQ
│
▼
Integrator Worker / Post-processing Worker
│ 执行原 integrator 的 post processing
│
▼
Invoice Request Queue
│
▼
Invoice Worker / Invoice API
这里的 “Integrator Worker” 不一定要做成新的微服务,也可以是 integrator API 的另一个 ECS worker 进程。重点是让 payment API 专注于付款领域,不要让它在不知不觉中变成整个业务流程的编排器。
1. 请求入口应立即返回任务标识
用户不需要立刻看到 invoice details,因此入口 API 没必要等待付款和开票全部完成。
建议返回:
HTTP/1.1 202 Accepted
Location: /operations/pay_01JABC...
Content-Type: application/json
{
"operationId": "pay_01JABC...",
"status": "PENDING"
}
客户端可以通过以下方式获取最终状态:
- 轮询
GET /operations/{operationId} - WebSocket 或 Server-Sent Events
- Webhook
- 邮件或站内通知
状态可以设计为:
PENDING
PAYMENT_PROCESSING
PAYMENT_COMPLETED
POST_PROCESSING
INVOICE_PENDING
COMPLETED
FAILED
状态记录最好单独保存在 operation/job 表中,不要只依赖暂时存放在 SQS 里的消息。
2. SQS 只传引用,不传大对象
大型 payment details 应存放在外部存储中,SQS 消息只携带引用。这是常见的 claim-check pattern。
消息可以保持得很小:
{
"eventType": "PaymentRequested",
"eventVersion": 1,
"operationId": "pay_01JABC",
"paymentId": "payment-12345",
"payload": {
"store": "s3",
"key": "payments/pay_01JABC/request.json",
"checksum": "sha256:..."
},
"correlationId": "req-789",
"createdAt": "2026-09-01T08:30:00Z"
}
SQS 的最大消息大小应以当前 AWS 文档和目标区域的实际限制为准。即使某个请求没有超过当前限制,也不建议把接近上限的大对象直接放进消息,因为消息越大,网络开销、重试成本和费用通常也越高。
外部存储可以按数据的使用方式选择:
- 如果原始数据本质上是 JSON 文档或二进制对象,优先考虑 S3。
- 如果处理过程中需要按字段查询、修改或建立索引,DocumentDB 更合适。
- 如果已经使用 DocumentDB,而且数据模型确实需要数据库能力,可以继续使用。
如果只是暂存大型 payload,S3 通常更简单。它的成本较低,对象大小限制宽松,还可以通过 lifecycle rule 自动清理。
3. 不要只靠 worker 主动删除暂存数据
“成功后由 payment API 删除数据”只能处理正常路径,不能作为唯一的清理机制。以下情况都可能留下残余数据:
- worker 处理成功,但在删除前崩溃;
- 消息进入 DLQ;
- legacy DBMS 长时间无响应;
- 后处理或开票永久失败;
- 消息被重复消费;
- 部署或网络故障导致状态不一致。
还应配置兜底清理策略:
- S3 使用 lifecycle expiration;
- DocumentDB 使用 TTL index;
- operation 记录保存
expiresAt; - 定期清理已经进入终态并超过保留期的数据;
- 为
FAILED状态和 DLQ 中的任务保留足够时间,以便排查和重放。
付款处理结束后,不要立即删除原始 payload。如果后处理、开票或人工重放还需要这些数据,应在整个 operation 到达终态后再清理,或者按照明确的审计周期保留。
4. Payment Worker 必须按照”至少一次投递”设计
SQS Standard queue 可能重复投递消息。即使使用 FIFO queue,也不能完全依靠队列去重来保证业务正确性。
Payment Worker 应实现业务层幂等:
INSERT INTO processed_operations (
operation_id,
step_name,
status
)
VALUES (
:operation_id,
'PROCESS_PAYMENT',
'STARTED'
)
ON CONFLICT (operation_id, step_name) DO NOTHING;
具体写法需要根据所用数据库调整,但每个处理步骤都应有唯一键,例如:
(operationId, stepName)
调用 legacy DBMS 时,最好携带稳定的业务幂等键:
idempotencyKey = operationId + ":payment"
如果 legacy DBMS 不支持幂等键,就需要在本地持久化调用状态和业务结果,再通过付款编号、交易编号等唯一约束防止重复扣款。支付场景不能只靠删除 SQS 消息来保证”只执行一次”。
5. 正确处理 visibility timeout
worker 收到消息后,在 visibility timeout 到期前,其他消费者看不到这条消息。如果 legacy DBMS 的处理时间可能达到 15 分钟,visibility timeout 必须覆盖这段时间,或者由 worker 定期续期。
伪代码如下:
message = sqs.receive_message(
QueueUrl=queue_url,
WaitTimeSeconds=20,
VisibilityTimeout=300
)
receipt_handle = message["ReceiptHandle"]
try:
while payment_is_running():
result = poll_or_process_payment()
sqs.change_message_visibility(
QueueUrl=queue_url,
ReceiptHandle=receipt_handle,
VisibilityTimeout=300
)
save_result(result)
publish_payment_completed(result)
sqs.delete_message(
QueueUrl=queue_url,
ReceiptHandle=receipt_handle
)
except RetryableError:
# 不删除消息,让 visibility timeout 到期后重新投递
raise
实际运行时还要注意:
- visibility timeout 需要留出余量;
- 必须在当前 timeout 到期前续期;
- worker 关闭时应停止接收新消息,并为正在处理的任务预留 drain 时间;
- ECS 部署和 Auto Scaling 缩容时需要考虑长任务;
- visibility timeout 不是数据库调用本身的超时,legacy DBMS 调用仍然需要明确的客户端超时和取消策略。
如果一次数据库调用会阻塞 15 分钟,而且无法查询进度,worker 只能维持长连接等待。如果 legacy 系统支持提交任务后查询状态,最好将流程拆成”提交任务”和”检查结果”两个阶段,避免长时间占用 ECS worker。
6. 发布成功事件与删除原消息之间存在一致性问题
下面三个操作不是原子的:
保存付款结果
发布 PaymentCompleted
删除 PaymentRequested
任何两个步骤之间发生崩溃,都可能造成事件重复或状态缺失。例如,系统成功发布 PaymentCompleted 后,在删除原消息前崩溃,原消息就会再次被消费。
常见的处理方式有两种:
- 所有消费者都实现幂等,并允许事件重复。
- 使用 transactional outbox pattern。
Outbox 会在同一个数据库事务中写入业务结果和待发送事件:
BEGIN;
UPDATE payment_operation
SET status = 'PAYMENT_COMPLETED',
result_location = :result_location
WHERE operation_id = :operation_id;
INSERT INTO event_outbox (
event_id,
aggregate_id,
event_type,
payload,
published
)
VALUES (
:event_id,
:operation_id,
'PaymentCompleted',
:payload,
false
);
COMMIT;
另一个 publisher 扫描 outbox,将事件投递到 SQS,然后标记为已发布。即使事件被重复投递,消费者也能通过 eventId 去重。
如果 DocumentDB 的事务边界或现有数据模型不适合实现 outbox,也可以接受”可能重复发布”,但后处理和 invoice 创建必须具备可靠的业务幂等性。
7. 将后处理拆成独立步骤
payment API 完成付款后发布结果事件:
{
"eventType": "PaymentCompleted",
"eventVersion": 1,
"eventId": "evt-01JXYZ",
"operationId": "pay_01JABC",
"paymentId": "payment-12345",
"result": {
"store": "s3",
"key": "payments/pay_01JABC/result.json"
},
"correlationId": "req-789"
}
Integrator Worker 消费这个事件,执行原有 post processing,再发布:
{
"eventType": "InvoiceRequested",
"eventVersion": 1,
"operationId": "pay_01JABC",
"paymentId": "payment-12345",
"invoiceInput": {
"store": "s3",
"key": "payments/pay_01JABC/invoice-input.json"
}
}
这种拆分有几个直接好处:
- payment API 不需要了解 invoice API;
- 后处理逻辑仍由 integrator 负责;
- invoice API 故障不会阻塞 payment worker;
- 三个阶段可以分别扩容、重试和部署;
- 将来增加通知、审计等步骤时,不必继续修改 payment API。
如果 invoice API 目前只支持同步 HTTP,可以由 Invoice Worker 消费 SQS 消息后调用它。如果 invoice API 的处理时间也可能很长,则应采用同样的异步模式改造。
8. 重试要区分临时故障和永久故障
并不是所有错误都适合无条件重试。
适合重试的情况包括:
- 网络超时;
- 临时连接失败;
- HTTP
429; - HTTP
502、503、504; - legacy DBMS 暂时不可用。
通常不适合自动重试的情况包括:
- payment details 格式错误;
- 必填字段缺失;
- 无法识别的账户;
- 业务规则拒绝;
- 永久权限错误。
建议为每个队列配置 DLQ,并设置合理的 maxReceiveCount。重试时还应使用指数退避和抖动,避免故障恢复后所有任务同时发起重试:
delay = min(base_delay * (2 ** attempt), max_delay)
delay += random.uniform(0, delay * 0.2)
SQS 对单条消息的延迟能力有限。如果需要数小时级重试、根据错误类型安排不同的重试时间,或者查看每一步的执行历史,可以考虑:
- 专门的 retry queue;
- EventBridge Scheduler;
- Step Functions Standard Workflow;
- 在 operation 表中保存
nextAttemptAt,再由调度器重新投递。
DLQ 还需要配套告警和重放流程。如果只有 DLQ 而没有监控,故障只是被转移到了另一个队列里。
9. 是否使用 FIFO queue
如果同一个 payment 的消息必须严格有序,可以考虑 FIFO queue,并设置:
MessageGroupId = paymentId
但 FIFO 不能提供端到端的 exactly-once 保证,应用层仍需实现幂等,原因包括:
- 消费者可能在完成业务操作后、删除消息前崩溃;
- 去重窗口不是永久的业务约束;
- 对外部 DBMS 或 invoice API 的调用不在 SQS 事务范围内。
如果不同 payment 之间互不依赖,Standard queue 通常能提供更灵活的吞吐量。不要让所有消息共用同一个 MessageGroupId,否则并发处理会退化为串行处理。
10. 可以使用 Step Functions,但并非必需
如果流程以后会增加许多分支,例如:
付款 → 风控 → 后处理 → 开票 → 通知 → 补偿
Step Functions Standard Workflow 通常比手写状态机更容易观察和维护,尤其适用于:
- 长时间运行的工作流;
- 分支和条件判断;
- 明确的重试、超时和补偿;
- 人工介入;
- 查看每一步的执行历史。
大型 payment details 仍不应直接放在工作流状态中传递,只需传递 operationId 或 S3 key。
如果当前流程只有付款、后处理和开票三个稳定步骤,SQS 加 operation 状态表已经足够,不必为了形式上的完整引入额外的编排服务。
11. 建议补齐的运行保障
生产环境至少还应包括:
- 使用
operationId、eventId、correlationId串联日志; - 监控 queue depth、oldest message age、失败率和处理耗时;
- 对 DLQ 非空、任务长期停留、临时数据即将过期等情况设置告警;
- 根据队列积压量扩缩 ECS worker;
- 为每个步骤设置最大尝试次数和最终失败状态;
- 使用 IAM role,限制 worker 只能访问所需的 queue、bucket 和 collection;
- 为 SQS、S3、DocumentDB 以及传输链路启用适当的加密;
- 不在消息中保存银行卡号、凭证等敏感信息;
- 为事件增加
eventVersion,便于以后兼容字段变化; - 明确定义任务取消、人工重放和部分成功时的处理方式。
推荐方案不只是”在 payment API 前面放一个 queue”,而是把整个请求建模为可追踪的异步 operation。大型数据放在外部存储中,SQS 只传递小型事件,各步骤独立消费,所有副作用都要保持幂等。同时通过 DLQ、过期清理、状态记录和监控处理异常路径。这样才能解决长时间处理带来的问题,避免只是把 HTTP 超时换成更难排查的异步故障。
备注:内容仅供参考。
