同步调用把两个服务的可用性绑在一起;事件把它们解开。Encore 的 Pub/Sub 原语用两个类(Topic / Subscription)覆盖发布订阅全场景,本地 NSQ、云上 SNS+SQS / GCP Pub/Sub,代码零改动。
以订票场景为例:订单支付成功后要发确认短信、更新库存、通知报表系统。同步实现的问题:
事件驱动的版本:支付服务只发布 order-paid 事件;短信、库存、报表各自订阅。发布者不知道(也不关心)有谁在听——新增消费者不改发布方代码。
import { Topic } from "encore.dev/pubsub";
export interface OrderPaidEvent {
orderNo: string;
amountCents: number;
paidAt: string;
}
export const orderPaid = new Topic<OrderPaidEvent>("order-paid", {
deliveryGuarantee: "at-least-once",
});
规则:
| 语义 | 行为 | 代价与限制 |
|---|---|---|
| at-least-once(默认) | 至少投递一次,可能重复 | handler 必须幂等 |
| exactly-once | 最小化重复投递 | 云上限流:AWS 300 msg/s/主题,GCP 3000+ msg/s/区域;且不做发布端去重 |
幂等是订阅方的义务 at-least-once 下重复投递是正常现象。幂等三板斧:
- 数据库唯一约束(如 sms_log.order_no 唯一,重复插入吞掉 already_exists);
- 状态机前置检查(已是 NOTIFIED 就直接 return);
- 用 currentRequest() 的 deliveryAttempt 感知重试次数,超阈值走补偿逻辑。 即使 exactly-once 也不能豁免——它只是"最小化"重复,且发布端重试仍可能产生重复消息。
投递失败按重试策略退避重试;超过最大次数进入死信队列 (Dead Letter Queue, DLQ),不阻塞后续消息。DLQ 消息在云上对应 SQS DLQ / GCP dead-letter topic,需要运维流程兜底(告警 + 人工/脚本重放)。
字段包上 Attribute<T> 后成为消息属性(不在 body 里,可用于过滤/排序键):
orderingAttribute 保证同一键值内的消息按发布顺序投递(不同键值之间无序)。限制:AWS 300 msg/s/主题、GCP 1 MBps/排序键;本地环境不模拟有序,别在本地"验证"顺序性。
| 环境 | 实现 |
|---|---|
| 本地 encore run | NSQ(Docker 自动拉起) |
| AWS | SNS + SQS |
| GCP | Pub/Sub |
代码不变。这就是原语抽象的价值:语义在框架层锁定,实现按环境替换。
轮询类系统(监控、对账)里"每次检查都发事件"会淹没消费者。正确做法:对比上一次状态,翻转时才发布:
"写库 + 发事件"的原子性问题(写库成功、发布失败)在 Encore 里同样存在。轻量解法:先写库、后发布,订阅方幂等 + 发布方失败重试;严格场景用 outbox 表 + Cron 扫描补发(第 8 章的 CronJob 正好派上用场)。
一个业务动作触发多个后续步骤时,用一个事件 + N 个订阅,而不是在业务代码里排队调 N 个服务。第 14 章的 uptime 监控就是最小案例:uptime-transition 主题 + Slack 通知订阅。
encore test 环境下发布是真实可调用的(本地实现),常规做法:
订阅 handler 是普通函数——直接调用它测试业务逻辑,不必绕道真实投递链路。投递语义(重试、DLQ)是框架职责,不需要你的测试覆盖。
请继续阅读:第8章:定时任务与密钥。