同步调用多了,为什么堵车
下单要调库存、调优惠、调积分、调通知,服务之间同步调用像排队过收费站:任何一个下游慢了,整个请求就跟着慢;任何一个下游挂了,上游就直接失败。调用链越深,耦合越紧。
事件驱动的思路是换个协作方式:我不直接喊你干活,而是把"发生了什么"放进队列,谁关心谁自己来取。这样上游不等下游、下游不拖累上游。
同步调用问的是「你帮我办了吗」,事件驱动说的是「这件事已经发生了,需要的来拿」。
- 解耦生产者不知道谁在消费。
- 削峰突发流量先进队列,消费者按自己的节奏处理。
- 重放消息还在,就能重新处理一遍。
消息队列的基本模型
消息队列的核心是三个角色:生产者把消息发到队列,队列暂存并路由,消费者从队列取消息处理。主题之下还有分区、消费者组这些概念,用来控制并行度和顺序。
| 角色 | 职责 | 典型问题 |
|---|---|---|
| 生产者 | 发布事件 | 发失败了怎么补偿 |
| 队列/主题 | 暂存与路由 | 积压了怎么处理 |
| 消费者 | 取消息处理 | 处理失败要不要重试 |
先把这个三角想清楚,再去挑具体组件:要不要顺序、要不要广播、允许丢多少消息,答案都不一样。
可靠投递:至少一次与幂等
完全不多不少地把消息投递一次,在分布式里几乎做不到。实际工程普遍接受「至少一次」投递,代价是消费者可能收到重复消息,所以消费端必须幂等:同一事件处理两遍,结果和一遍一样。
# 幂等处理的通用做法:先查后写,带上处理标识
def handle_order_created(msg):
key = "processed:" + msg["order_id"]
if redis.setnx(key, "1", ttl=3600):
# 首次处理:真正执行业务逻辑
update_inventory(msg)
send_notification(msg)
# 重复消息直接跳过,业务结果不变
用处理标识去重,是消息场景里最常用的幂等手段,比单纯靠数据库唯一键更可控。
事件与命令:别混着用
「请把库存扣掉」是命令,带有明确意图,通常只有一个消费者;「订单已创建」是事件,表达已发生的事实,可以有多个消费者各取所需。把两者混用,消息模型很快就会乱。
| 维度 | 命令 | 事件 |
|---|---|---|
| 语义 | 要求执行 | 告知事实 |
| 时态 | 将来要做 | 已经发生 |
| 消费者 | 通常一个 | 可以多个 |
设计时先问一句:这条消息是在「下指令」还是「播报事实」?答案决定它该怎么命名、怎么路由。
# 发布事件的可靠姿势:本地事件表 + 异步发送
1. 业务事务内:写入业务数据,同时插入一条「待发送」事件记录
2. 事务提交成功后再真正发送
3. 发送成功 → 把事件标记为「已发送」
4. 发送失败 → 保留记录,由定时任务重试兜底
# 先落库再发消息,避免出现「消息发了、业务没生效」的尴尬
顺序、乱序与死信
消息一异步,顺序就不那么可靠了:同一订单的「创建」和「取消」如果乱序到达,状态就会错乱。要顺序就牺牲并行度——按业务键路由到同一分区,串行消费。
能不用全局顺序就别用全局顺序:大部分业务只需要「同一实体的消息有序」。
处理失败的消息要进死信队列单独观察,不能原地无限重试把消费者拖死。死信里的消息往往暴露的是程序 bug,而不是偶发抖动。
常见坑与取舍
| 坑 | 后果 | 应对 |
|---|---|---|
| 业务逻辑与发消息不同步 | 消息发出但业务没落库 | 事务消息或先落库再发 |
| 消费者无限重试 | 消息积压、链路放大 | 有限重试加死信 |
| 消息体放太多字段 | 升级即不兼容 | 只放必要字段,保持向后兼容 |
| 把命令当事件用 | 多个消费者抢活干 | 按语义区分命令与事件 |
这些坑不是组件能解决的,是设计时就要避开的。
什么时候该用事件驱动
事件驱动不是银弹:调用方需要同步拿结果、流程要求强一致、业务足够简单,继续用同步调用反而更清楚。
- 适合:跨服务解耦、削峰填谷、事件广播、数据同步与重放。
- 慎用:强一致交易、需要即时返回结果的交互、小型团队维护能力不足的场景。
先画清楚谁产生什么事件、谁消费什么事件,再动手引队列。模型对了,队列才越用越顺手;模型错了,队列只是把耦合搬了个位置。