← 返回题目列表

观察者模式如何处理重复通知和幂等问题?

高频 困难 第 15 / 27 题 更新于 2026/08/02
观察者模式幂等重复通知事件消费

简化版

观察者模式一旦引入异步、重试、可靠投递或多实例部署,就要假设事件可能重复。处理方式是为事件设计全局唯一 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 状态、超时重扫、重试次数和死信队列。

九、加强记忆

  1. 事件系统默认按可能重复设计。
  2. 事件要有 eventId,业务动作要有幂等键。
  3. 多监听器用 eventId + listenerName 记录处理状态。
  4. 并发幂等靠唯一索引或原子抢占。
  5. 外部调用也要传幂等键。
  6. 幂等让重试安全,不是替代重试。