重复消费是消息队列“不丢消息”前提下的必然行为,需通过手动提交 offset、lua 脚本实现 redis 幂等、数据库唯一约束兜底及生产者透传稳定幂等键四层防护。

重复消费不是 Kafka 或 RabbitMQ 的 bug,是它们在“不丢消息”前提下的必然行为。C# 里想靠 ConcurrentDictionary 或 lock 拦住重复消息,上线后必翻车。
为什么手动 commit offset 是第一道硬门槛
默认开启 EnableAutoOffsetStore=true 或 EnableAutoCommit=true,等于把控制权交给框架——它会在拉到消息后立刻提交 offset,不管业务逻辑有没有跑完。一旦处理中途崩溃,那条消息就永远丢失了;更常见的是处理完了但没来得及 commit,Kafka 下次又推一遍。
- 必须显式设
EnableAutoOffsetStore=false和EnableAutoCommit=false -
consumer.StoreOffsets()和consumer.Commit()要放在业务逻辑完全成功之后,不能进finally块无条件执行 - 若用
BackgroundService,记得每次处理都using var scope = _serviceProvider.CreateScope(),避免 DbContext 或 RedisClient 被复用污染状态
Redis 幂等键必须用 Lua 脚本原子执行
只调 SETNX 再加 EXPIRE 是典型竞态:A 查无记录 → B 同时查无记录 → A 写入 → B 覆盖 → 两人同时进业务逻辑。生产环境必须用 Lua 一次性完成「查、写、设过期」。
- 推荐脚本中
KEYS[1]是"idempotent:" + messageId,ARGV[1]设为86400(24 小时),覆盖最长重试窗口 - 返回值要区分:1=首次进入可执行,0=已有终态结果(成功/失败),2=正在处理中(建议返回
409 Conflict) - 别用
TimeSpan.FromDays(7)当 TTL——Redis 内存压不住,且业务上不可能等 7 天才判定失败
数据库唯一约束不是备选,是底线
Redis 可能挂,Lua 脚本可能写错,网络可能分区,但只要 INSERT 带 IdempotencyKey 字段且建了 UNIQUE INDEX,数据库就会拦下所有漏网的重复写入。
- 建表只要两字段:
IdempotencyKey NVARCHAR(64) PRIMARY KEY+CreatedAt DATETIME2 - 插入不用先
SELECT查,直接INSERT INTO IdempotentLog (IdempotencyKey, CreatedAt) VALUES (@key, GETDATE()) - 捕获
SqlException.Number == 2627或2601就说明已存在,直接跳过后续逻辑
真正容易被忽略的点是:幂等键的生成位置和稳定性。别让前端拼时间戳+随机数传过来,也别在消费者里用 Guid.NewGuid() 重算——必须由生产者生成并随消息体透传,否则上下游对不上,整个链路就断了。










