观察者模式如何处理重复通知和幂等问题?
简化版
观察者模式一旦引入异步、重试、可靠投递或多实例部署,就要假设事件可能重复。处理方式是为事件设计全局唯一 eventId,为业务动作设计幂等键,监听器在执行前检查是否已处理,执行后记录结果。对加积分、发券、扣库存等非幂等动作,不能只靠“代码不会重复调用”的假设。
详细版
重复通知来源很多:
- 发布方重试;
- 消息队列至少一次投递;
- 消费者处理成功但提交 offset 失败;
- 用户重复点击;
- 定时补偿任务重复扫描;
- 多个应用实例同时处理同一事件。
因此观察者监听器要按“可能重复”来设计。典型做法:
String idempotentKey = event.id() + ":" + listenerName;
if (processedRepository.exists(idempotentKey)) {
return;
}
doBusiness(event);
processedRepository.save(idempotentKey);
但这只是基本形态,真实项目还要考虑并发抢占、事务边界和外部接口幂等。
记忆钩子:事件系统里不要问“会不会重复”,要问“重复了是否仍然正确”。
完整版教学
面试提示:幂等题要同时覆盖事件 id、业务唯一键、状态机和重试策略,单说“去重表”是不够的。
一、为什么观察者会重复收到事件
本地同步观察者看起来不会重复,但只要工程化一点,就会出现重复来源:
publish failed -> retry
listener timeout -> retry
process crash -> replay
mq rebalance -> redelivery
compensation job -> scan again
消息系统常见语义是至少一次投递,即宁可重复,也不能轻易丢。监听器必须自己保证幂等。
二、先区分天然幂等和非幂等动作
| 动作 | 是否天然幂等 | 风险 |
|---|---|---|
| 更新用户状态为 ACTIVE | 通常幂等 | 重复执行结果一样 |
| 插入审计日志 | 不一定 | 可能重复记录 |
| 发放 10 积分 | 非幂等 | 重复加分 |
| 发送短信 | 非幂等 | 用户收到多条 |
| 创建发票 | 非幂等 | 重复开票 |
非幂等动作必须有业务唯一键或处理记录。
三、事件要有唯一标识
事件对象应该包含:
record DomainEvent(
String eventId,
String eventType,
String aggregateId,
long version,
Instant occurredAt
) {}
eventId 用来识别一次事件,aggregateId + version 可以识别某个聚合对象的某次状态变化。不要只用当前时间或随机日志字符串做弱标识。
四、监听器要有处理记录
因为同一个事件可能有多个监听器,所以处理记录通常包含监听器名称:
processed_event
event_id
listener_name
status
processed_at
唯一索引:
unique(event_id, listener_name)
这样短信监听器处理过,不影响积分监听器继续处理。
五、并发幂等要靠数据库或分布式锁兜住
单纯先查再插存在竞态:
Thread A: 查不到
Thread B: 查不到
Thread A: 执行
Thread B: 执行
更稳的是用唯一约束抢占:
boolean acquired = processedRepository.tryInsert(eventId, listenerName);
if (!acquired) {
return;
}
doBusiness(event);
processedRepository.markSuccess(eventId, listenerName);
如果业务动作和处理记录能放在同一个数据库事务里,可靠性更好。
六、外部接口也要传幂等键
如果监听器调用外部服务,例如发券或支付退款,要把幂等键传给下游:
couponClient.grantCoupon(userId, couponId, eventId + ":coupon");
否则本地即使记录了处理状态,网络超时后也无法确认下游是否执行成功。外部接口如果支持幂等键,重试会安全很多。
七、幂等不等于不重试
幂等是为了让重试安全,不是为了避免重试。推荐流程:
收到事件
-> 抢占幂等记录 PROCESSING
-> 执行业务
-> 成功标记 SUCCESS
-> 失败标记 FAILED 并进入重试/死信
如果一直 PROCESSING,要有超时恢复机制,避免进程宕机后记录卡死。
八、常见误区与追问
- 误区:本地观察者不会重复,所以不用幂等。 一旦加重试、异步、补偿、多实例,就可能重复。
- 误区:用
eventId判断一次就够了。 多个监听器要分别记录处理状态,通常使用eventId + listenerName。 - 误区:先查再插就能防重复。 并发下会竞态,要靠唯一索引、锁或原子插入。
- 误区:幂等就是失败不重试。 幂等是让重试变安全,可靠系统通常仍然需要重试。
- 追问:短信怎么做幂等? 用业务唯一键记录发送状态,或调用短信服务时传幂等键,避免重复发送。
- 追问:加积分怎么做幂等? 积分流水表用业务单号唯一索引,重复事件不能插入第二条加分流水。
- 追问:处理到一半宕机怎么办? 使用 PROCESSING 状态、超时重扫、重试次数和死信队列。
九、加强记忆
- 事件系统默认按可能重复设计。
- 事件要有 eventId,业务动作要有幂等键。
- 多监听器用
eventId + listenerName记录处理状态。 - 并发幂等靠唯一索引或原子抢占。
- 外部调用也要传幂等键。
- 幂等让重试安全,不是替代重试。