云消息与事件服务
队列/主题/事件总线概念对比、投递语义、可见性超时、死信队列、扇出过滤与顺序性保证。
知识点地图
- 知识类别:托管消息与事件服务(SQS/SNS、EventBridge、Pub/Sub、Event Hubs、RocketMQ/Pulsar 类)的概念层——选型模型、投递语义与可靠性机制。
- 解决什么问题:服务间异步解耦时,「用哪个服务」「消息会不会丢、会不会重、会不会乱序」「消费失败去哪」这些决策散落在各厂商文档里;本文把它们收敛成一套统一概念。
- 什么时候用到:设计订单、支付、通知类异步链路;评估「要不要上消息队列」;给事件驱动架构选型;排查「消息重复消费」「顺序乱了」类故障。
前置:微服务架构的异步通信模式(服务通信章节);SQS/SNS 的命令级实操见SQS 与 SNS 命令(概念-命令分层:本文管「为什么与怎么选」,470 管「命令怎么敲」)。
1. 心智模型:三种形状的「消息基础设施」
| 形状 | 交互模型 | 生命周期 | 代表产品 | 典型问题 |
|---|---|---|---|---|
| 队列 Queue | 一对一:一条消息被一个消费者取走 | 有消费者就消费,无消费者堆积等待 | SQS、RocketMQ 队列、Pub/Sub 订阅 | 任务分摊、削峰 |
| 主题 Topic(Pub/Sub) | 一对多:发布者不知道谁在听 | 订阅者在线才收(或订阅带保留) | SNS、Pub/Sub、Kafka 主题 | 广播、扇出 |
| 事件总线 Event Bus | 规则路由:事件按内容路由到多个目标 | 事件是「发生过的事实」,常配归档 | EventBridge、EventGrid | 集成、事件驱动架构 |
三个场景对照选型:
- 订单系统解耦下单与发货:下单服务发「订单已支付」,发货服务消费——用队列(每条订单恰好处理一次、天然分摊);若同时要通知库存、积分、短信三个下游,前面加主题扇出,或事件总线按事件类型路由。
- IoT 设备上报:十万设备每秒上报遥测——事件总线 + 规则按设备类型分流,冷数据走 Kinesis 类流式管道;队列在这里不合适(不是「任务」而是「事实流」)。
- 审计流水:所有写操作事件进总线并开归档(EventBridge Archive / Pub/Sub 导出 BigQuery),回放可重建任意时间点状态——「事件溯源」的托管版。
与自建消息中间件(Kafka/RabbitMQ)的关系:托管服务省运维与扩容,语义相对简单;自建换来自定义协议、超长保留、精确一次等能力。中小团队先托管、规模与语义需求到了再评估自建。
2. 投递语义:至少一次的世界
三种投递语义与托管服务的现实:
| 语义 | 含义 | 托管服务现实 |
|---|---|---|
| 至多一次 | 可能丢,绝不重 | 少见(实时指标上报容忍丢失时可用) |
| 至少一次 | 绝不丢,可能重 | SQS 标准队列、SNS、Pub/Sub 的默认 |
| 精确一次 | 不丢不重 | SQS FIFO(配合幂等消费)、Kafka 事务(有限范围) |
关键推论:至少一次投递必然要求幂等消费。 消费者必须能安全地处理同一条消息两次。三种幂等设计:
- 自然幂等:
UPDATE account SET status='shipped' WHERE id=... AND status='paid'——重放第二次条件不满足,无副作用。能写成状态机条件更新的,优先这么写。 - 消息级去重键:业务唯一键(订单号+操作)做去重表/Redis
SETNX,重放时发现已处理直接 ack。 - FIFO 队列的去重:SQS FIFO 5 分钟窗口内的重复消息自动去重(基于
MessageDeduplicationId),但窗口外与跨队列的重复仍需业务幂等——FIFO 不等于可以不写幂等。
易错点:把「精确一次」理解成基础设施承诺。SQS FIFO 的精确一次范围是「队列内的投递与去重」,消费函数执行到一半崩溃再重启,处理依然会从头再来——端到端不丢不重的唯一保证是「至少一次 + 业务幂等」。
工程场景(短信重复发送事故复盘):促销短信服务消费 SNS 消息,某次消费超时但实际已发短信,SNS 重投导致同一用户收到三条。复盘修复:发送前查 sent_log 表(手机号+活动 id 唯一约束),插入冲突即跳过——消息级去重键方案。事故成本一条短信几分钱,乘以百万用户就是一次公关事件。
3. 可见性超时与死信队列:可靠性的两根支柱
3.1 可见性超时(Visibility Timeout)
队列模型里消费者不是被推送,而是**拉取并「借走」**消息:消息进入不可见期(visibility timeout),期间其他消费者看不到它;消费者处理完主动删除。不可见期过后消息仍未删除——自动重新可见,交给(可能另一个)消费者重试。
消费 → 消息隐藏 N 秒 → 处理成功 → delete(结束)
└→ 处理失败/超时 → N 秒后重新可见 → 别的消费者再试- 取值逻辑:略大于最慢处理耗时。设 60 秒而处理只要 2 秒,真故障时要白等 58 秒才重试;设 5 秒而处理要 8 秒,第 5 秒消息重新可见,另一个消费者并发处理同一消息——重复消费。拿不准就配合心跳续期(SQS
ChangeMessageVisibility,每处理一段续一次)。 - 易错点:批处理任务把超时拉到 15 分钟来「防重」——不如老老实实写幂等,长超时只是把重复窗口拉长且故障恢复变慢。
3.2 死信队列(DLQ)
重试若干次仍失败的消息,转入死信队列隔离,让人接手而不是无限重试堵住主队列:
主队列 --(maxReceiveCount 超过阈值)--> 死信队列 --> 告警 + 人工排查/修复后重放maxReceiveCount是「被接收次数」阈值(SQS 红驱策略),不是「失败次数」——消费者借走又放回一次就算一次 receive。- DLQ 必须配告警(队列深度 > 0 即告警),否则死信变成「安静的垃圾场」,故障静默丢失。
- 重放流程:修复 bug 后用重放工具(SQS Console 的 Start DLQ redrive、自建脚本)把死信按限速倒回主队列——限速是为了不把修复后的下游再次打垮。
3.3 组合:一条支付回调链路
支付网关回调 → API 确认秒回(校验+入队即返回)→ 队列(可见性 30s,重试 3 次)
→ 消费者幂等更新订单状态 → 失败 3 次进 DLQ → 告警值班 → 修复后限速重放回调类集成的铁律:先确认后处理(收回调即入队返回 200,业务处理在队列里异步做),网关超时重发由幂等消费兜底。在回调接口里同步调下游服务,网关一次超时重发就是一次重复支付。
4. 扇出与过滤
一对多分发的三种实现与取舍:
- 主题扇出(SNS Fan-out):发布到主题,多个队列各自订阅。订阅端互不影响——一个下游挂了不影响其他。注意:SNS 往 SQS 投递仍是至少一次,每个队列的消费者各自幂等。
- 事件总线规则路由(EventBridge):事件带结构化字段(
detail-type、source),规则按内容匹配路由——source: orders, detail-type: OrderPaid的新事件自动流到新增的积分服务,发布方代码零改动。这是事件驱动架构里「加订阅者不改发布者」的关键。 - 消费者自取全量再过滤:Kafka 消费组各自拉全量主题自己过滤。代价是无效消费带宽;换来的是任意复杂的过滤逻辑与回放能力。
过滤在订阅侧做(SNS 订阅过滤策略 / EventBridge 规则)比「广播后各自丢弃」省一个数量级的下游流量;但过滤条件是配置不是代码,记得纳入版本控制(GitOps 管起来)。
工程场景(EventBridge 路由多下游):电商「订单已支付」事件按 detail-type 建三条规则——进发货队列、进积分队列、进数据湖归档。大促临时加「风控旁路」订阅,加规则十分钟上线,订单服务一行代码不改。
5. 顺序性保证
标准队列/主题不保证顺序(多副本并行投递换取吞吐)。需要顺序时的三种姿势:
- 分区有序(消息组):SQS FIFO 的
MessageGroupId、Kafka 的 partition key——同一组内严格有序,组间并行。订单系统用订单号做组 id:同一订单的状态变更有序,不同订单并行。 - 单队列串行:全部走一个 FIFO 队列单个消费者——最简单,吞吐上限也最低;只在低流量且全局有序不可妥协时用。
- 版本号/时间戳自校:无序到达的消息带版本号,消费者丢弃过期版本。与上游不可控(第三方推送)时的兜底方案。
易错点:给每个消息都设唯一 MessageGroupId 等于把 FIFO 用成标准队列(零并行度,吞吐垫底);反过来用固定 group id 又退化成完全串行。组 id 的基数决定并行度,按「需要有序的最小业务单元」选(通常是实体 id,如订单号、用户 id)。
6. push 与 pull 的取舍
| 维度 | pull(拉取,SQS/Kafka) | push(推送,SNS HTTP 订阅/Pub/Sub push) |
|---|---|---|
| 消费节奏 | 消费者自控,天然削峰 | 跟随生产速率,下游被打垮要自己限流 |
| 空闲成本 | 轮询(长轮询省费用) | 零空转,有消息即推 |
| 端点要求 | 消费者可以是 worker/函数 | 消费者必须是可达的 HTTPS 端点 |
| 失败重试 | 可见性超时天然重试 | 平台按策略重试(退避可配) |
选型直觉:常驻 worker、处理耗时不均——pull;Serverless 函数消费、端点即函数 URL——push。两边可以组合:SNS push 到 SQS 再由 worker pull,兼得「多下游扇出」与「拉取削峰」。
动手实践
任务:设计「退货申请」链路的概念方案并落到 AWS 资产清单:App 提交退货 → 异步审核(1-5 分钟出结果)→ 结果通知用户 + 更新库存。要求写出:队列或主题的选择与理由、投递语义假设、幂等键、DLQ 策略、组 id。
提示:
- 审核是任务型消费——队列;结果通知是扇出——主题或事件总线;
- 幂等键:
return_id + 状态迁移条件;组 id:return_id; - 审核服务调外部风控 API 可能超时——可见性超时与 maxReceiveCount 怎么配?
参考方案(先自己写,再展开对照)
API(POST /returns)→ 校验+落库(status=submitted)→ SQS 标准队列
visibility=60s(审核含外部风控调用),maxReceiveCount=3
消费组:审核 worker(幂等键 return_id,条件更新 status)
成功 → SNS topic "return-completed"(filter 短信/邮件/库存三个订阅)
连续失败 → DLQ returns-dlq(深度告警)→ 值班修复 → 限速重放要点自检:API 同步路径只做校验落库(秒回,审核慢不阻塞用户);重复回调被条件更新挡住(status 已非 submitted 即跳过);风控抖动由可见性超时自然重试,3 次仍败进 DLQ 而非堵死主队列。若后续要按「退货原因」加订阅方(财务对账只关心退款完成),把 SQS 换成 EventBridge 规则路由,加订阅不改 API。
常见陷阱
- 不写幂等就上队列:至少一次语义下重复消费只是时间问题;幂等键与状态机条件更新是标配。
- 可见性超时 < 处理耗时:消息提前重新可见,多消费者并发处理同一消息。
- DLQ 没告警:死信静默堆积,业务悄悄丢消息。
- FIFO 每条消息唯一 group id:并行度归零,吞吐垫底;组 id 按实体 id 选。
- 回调接口同步处理业务:网关超时重发导致重复处理;先确认后处理,业务进队列。
- 过滤逻辑写在消费者里:全量广播再丢弃,下游流量白付;订阅侧过滤 + 规则路由。
相关阅读
- SQS/SNS 命令实操(创建队列、可见性超时、DLQ、SNS 扇出):SQS 与 SNS 命令
- 异步通信在微服务中的位置:微服务架构
- 事件驱动架构模式(事件溯源/CQRS):事件驱动架构
- 消费端幂等与重试语义(Serverless 侧):Serverless 架构
参考与致谢
- Amazon SQS 官方开发者指南(AWS 网站条款):https://docs.aws.amazon.com/sqs/
- Amazon SNS 官方开发者指南(AWS 网站条款):https://docs.aws.amazon.com/sns/
- Google Cloud Pub/Sub 文档(Google 服务条款):https://cloud.google.com/pub/docs
- EventBridge 规则文档(AWS 网站条款):https://docs.aws.amazon.com/eventbridge/