惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

T
The Blog of Author Tim Ferriss
I
InfoQ
H
Hackread – Cybersecurity News, Data Breaches, AI and More
aimingoo的专栏
aimingoo的专栏
小众软件
小众软件
有赞技术团队
有赞技术团队
J
Java Code Geeks
Apple Machine Learning Research
Apple Machine Learning Research
大猫的无限游戏
大猫的无限游戏
Engineering at Meta
Engineering at Meta
B
Blog RSS Feed
博客园_首页
Y
Y Combinator Blog
V
Visual Studio Blog
Google DeepMind News
Google DeepMind News
M
MIT News - Artificial intelligence
雷峰网
雷峰网
博客园 - 司徒正美
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
H
Help Net Security
P
Proofpoint News Feed
B
Blog
云风的 BLOG
云风的 BLOG
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报

Aimee's Blog

秒杀怎么防机器人:验证码之外的几道防线 防薅羊毛怎么做:号码风控、设备指纹与分层策略 接口限流怎么选:固定窗口、滑动窗口、令牌桶 批量打包下载怎么设计:流式 ZIP 与异步任务 图片处理怎么做:异步处理与 CDN 实时参数 大文件上传怎么设计:分片、断点续传、秒传 报表可重跑的设计:快照、水位线与幂等 UV 统计为什么有误差:精确计数与近似计数怎么选 大数据量导出怎么做:流式写入与异步任务 排行榜的两类需求,难度差了一个数量级 内容审核怎么选:先审后发,还是先发后审 实时热榜怎么设计:Redis ZSet 与热度分值 互关状态怎么保持同步:双向关系的维护与缓存失效 并发点赞的计数设计:原子操作与最终对账 删了父评论,子评论怎么处理——先想清楚用户看到的是什么 定时消息任务触发了两次 消息量大,写扩散还是读扩散——接到这个需求先问清楚规模 活动结束了,用户还在收短信 接到注销需求,先问两个问题 微服务与服务拆分:何时拆、怎么拆 异步与事件驱动架构:把协作从「打电话」改成「发消息」 高可用设计:怎么让系统尽量不宕机 可扩展性设计:怎么让系统加机器就能扛更多 缓存架构:多级缓存怎么搭 高并发三板斧:限流、熔断、降级 架构设计到底在设计什么 —— 从单体到微服务的演进 服务成本账:一个服务一个月烧多少钱 可观测性:线上出问题怎么查 API 设计:好接口长什么样 消息队列:为什么要 MQ,以及丢失、重复、顺序怎么破
同一条消息推送了三次
Aimee · 2026-08-02 · via Aimee's Blog

同一条消息推送了三次

用户反馈:刚才连续收到三条一模一样的推送通知。

日志里能查到这条消息,确实发送了三次——每次间隔大概 2-3 秒,内容完全一致。


根源:MQ 的 at-least-once 语义

消息队列默认保证"至少送达一次"(at-least-once),不保证"只送达一次"(exactly-once)。

消费者拉取消息 → 处理 → 发 ACK 给 MQ。如果在 ACK 之前:

  • 消费者挂了
  • 网络抖动 ACK 没送达
  • 消费者处理超时,MQ 认为超时重投

MQ 会把消息重新投递给其他消费者(或同一消费者),于是同一条消息被消费了多次。

推送服务如果不做幂等,就会推送多次。


推送幂等:消息 ID 去重

每条推送消息在生成时分配一个唯一的 msgId。推送服务消费时,用 Redis 记录"这个 msgId 已处理过":

public void consumePushMessage(PushMessage msg) {
    String dedupKey = "push:sent:" + msg.getMsgId();

    // setIfAbsent: 只有第一次会返回 true
    Boolean isFirst = redis.setIfAbsent(dedupKey, "1", 24, TimeUnit.HOURS);
    if (!Boolean.TRUE.equals(isFirst)) {
        log.info("Duplicate push message {}, skip", msg.getMsgId());
        return;
    }

    // 真正发推送
    pushGateway.send(msg.getDeviceToken(), msg.getTitle(), msg.getBody());
}

setIfAbsent 是原子操作,并发情况下只有一个线程能成功写入,其余线程走去重逻辑。

TTL 设为 24 小时足够覆盖 MQ 的重投窗口——正常情况下 MQ 不会延迟 24 小时才重投。


msgId 从哪来

msgId 必须在生产端就确定,不能在消费端生成——消费端每次消费生成一个新 ID,去重就失效了。

常见方案:

  • 业务事件 ID(如订单 ID + 事件类型)拼接:orderId:123456:PAID
  • 上游业务自行生成 UUID,写入 MQ 消息体
  • MQ 消息头的 messageId(Kafka 没有内置,RocketMQ 有 msgId,但 RocketMQ 的 msgId 在 broker 重启后可能重置,业务层 ID 更可靠)

为什么三次而不是两次

这不是偶然的,说明消费端的 ACK 超时时间配置得偏短,或者推送网关响应慢导致消费超时被重投。

排查步骤:

  1. 看 MQ 消费端日志:三次消费是同一个 consumer 实例还是不同实例?
  2. 看推送网关耗时:第一次消费是否因为超时而没有 ACK?
  3. 调整消费端 ACK 超时:把超时时间配置成推送网关 P99 耗时的 3-5 倍

幂等去重是根本解;优化超时配置减少不必要的重投是辅助。两件事都要做。


设备侧也可以做最后一道防线:App 在本地记录最近 N 条 msgId,收到重复的 msgId 直接静默丢弃,不展示给用户。但这是 App 的职责,后端不能依赖客户端来兜底。