ConcurrentDictionary 无法保证幂等性,因其不支持查-写-处理的原子操作且进程重启后丢失数据,必须结合 Redis 或数据库持久化去重,并设置合理过期时间。

为什么 ConcurrentDictionary 加锁还不够用
消息重复投递时,仅靠 ConcurrentDictionary 记录已处理的 messageId 很容易漏判——它不保证操作原子性,比如“查不存在 → 写入 → 处理业务”这三步之间仍有并发窗口。更糟的是,进程重启后内存态的字典就清空了,幂等直接失效。
- 必须配合持久化存储(如 Redis 或数据库)做去重判断,
ConcurrentDictionary只能作本地缓存加速 - 查+写必须是原子操作:Redis 用
SET key value NX EX,SQL 用带WHERE NOT EXISTS的插入或INSERT ... ON CONFLICT DO NOTHING(PostgreSQL) - 注意过期时间设置:太短会导致误判重试消息为新消息;太长则占用存储,建议按业务重试窗口(如 15 分钟)+ 缓冲(如 5 分钟)设定
IdempotentConsumer 类要不要自己封装
自己写一个通用 IdempotentConsumer 类看似省事,但容易陷入“过度抽象”陷阱——不同消息源(Kafka、RabbitMQ、Azure Service Bus)的确认机制、重试语义、消息元数据结构差异很大,硬套同一接口反而增加出错概率。
- 优先复用框架能力:如 MassTransit 的
IdempotentConsumerFilter、NServiceBus 的MessageDrivenSubscription自带幂等支持 - 若必须自研,只封装“查重-执行-记录”骨架,把存储访问和消息提取逻辑交给调用方传入 Func,避免耦合具体中间件类型
- 别在构造函数里初始化 Redis 连接或 DB context——它们不是线程安全的,应每次从工厂获取
数据库唯一约束怎么设才不崩
用 UNIQUE(message_id) 是最常用的兜底手段,但实际踩坑最多:字段长度超限、大小写敏感、NULL 值冲突、复合键遗漏业务维度,都会让约束失效或抛出意外异常。
-
message_id字段必须定义为NOT NULL,否则多个 NULL 不触发唯一检查(SQL 标准行为) - 如果业务需按
source_system + message_id组合去重,唯一索引必须包含两者,且注意source_system长度是否足够覆盖所有系统标识 - 捕获的异常类型要精准:
SqlException.Number == 2627 || SqlException.Number == 2601(SQL Server),而不是泛泛 catchException,否则掩盖真实错误 - 插入失败后别直接 return,先查一遍该
message_id是否真已成功处理——网络超时可能导致插入失败但业务逻辑其实执行过了
Redis 分布式锁的 LockKey 设计要点
用 Redis 实现幂等,很多人直接拿 messageId 当锁 key,结果在高并发下大量锁竞争拖慢吞吐,甚至因锁过期导致重复消费。
- 锁 key 应该是
"idempotent:lock:" + Hash(messageId, 16),做分桶降低热点(比如 16 个桶,哈希后取余) - 锁的 value 必须是唯一请求标识(如
Guid.NewGuid().ToString()),释放时用 Lua 脚本比对再删,防止误删他人锁 - 锁过期时间 ≠ 消息处理超时时间,而应是「预计最长处理耗时 × 2」,避免业务慢导致锁提前释放
- 别用
StackExchange.Redis的LockTake后手动LockRelease——它不防锁漂移;改用RedLock.net或自己写带续期的租约逻辑
真正难的不是选哪种技术,而是搞清你的消息中间件会不会重发、重发时机由谁控制、下游服务是否允许部分失败回滚——这些决定了幂等边界在哪。很多问题表面是代码没写对,其实是契约没对齐。











