尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Redis SETNX实现MQ消费幂等:解决重复出餐的线上实战方案

Redis SETNX实现MQ消费幂等:解决重复出餐的线上实战方案 凌晨一点我盯着线上监控面板看到同一张外卖订单在“霸王餐活动出餐记录”里出现了两次。一单加了优惠抵扣的霸王餐重复出餐意味着平台要承担双倍的成本问题排查到最后不是 MQ 本身坏了而是消费方把同一条消息处理了两遍。后来我在 Java 服务里用 Redis 的 SETNX 加过期时间做了一个不到五十行代码的幂等过滤层把重复消费的尾巴挡在了业务逻辑之外。这套方案没有引入额外的消息去重组件也没有让 DBA 加唯一索引就靠 Redis 的一条原子命令解决成本很低效果却很直接。这篇文章会把这套方案的完整链路讲清楚为什么外卖霸王餐这种场景特别容易踩重复消费的坑、SETNX 相比其他幂等方案的优势在哪里、核心代码怎么落地、上线后最容易翻车的几个细节以及随着订单量增长这套方案怎么演进。适合正在做 Java 消息队列开发的同学、准备分布式面试的朋友以及所有被“重复消息导致重复出餐/重复发券”坑过的人。1. 霸王餐订单被重复消费一次线上事故的全链路复盘1.1 事故现场同一条出餐消息进了两次业务处理那天的霸王餐活动是晚上八点开始的用户领券、下单、支付商家接单后我们这边有一个订单服务会往 MQ 里投递“出餐指令”。正常流程是消费者拿到消息后校验订单状态、扣减库存、推送商家后台最后把订单状态改成“已出餐”。监控告警是凌晨两点多触发的我们拉出出餐明细发现同一笔订单在日志里留下了两条完整的处理链路扣了两次库存商家后台也推送了两条出餐通知。这个问题的直接后果是商家手里多了一份“霸王餐”平台要多承担一份餐品成本用户那边因为重复出餐还可能产生售后纠纷。这类问题最麻烦的地方在于它不会每次都发生而是偶发性的。比如消息队列在某个消费者节点宕机后重新投递、生产者在发送时发生超时重试、消费端手动 ack 时网络抖动返回了异常这些场景都可能让同一条逻辑消息出现在消费者的入参里两次。1.2 重复消息从哪来生产者重试、Broker重投、消费者恢复要理解幂等方案为什么必须做先得摸清重复消息的三个来源。第一类是生产者侧的重试。订单服务往 MQ 发送消息时如果网络超时或 Broker 返回了一个不确定的结果生产者通常不会直接判定失败而是会重发。但由于 TCP 连接可能已断开、应答可能丢失生产者无法确定 Broker 到底有没有收到上一条所以同一条消息在 Broker 里会有两条物理记录。第二类是 Broker 自身的重投机制。主流 MQ 中间件默认采用至少一次at least once的投递语义意思是消费者必须显式返回 ackBroker 才认为这条消息处理成功。如果消费者处理耗时太长导致 ack 超时或者消费线程在 ack 前崩溃Broker 会把消息重新放入待消费队列下一次拉取时同一条消息又会出现。第三类是消费端故障恢复。消费者节点在消费过程中被 kill、重启、扩容缩容都会触发分区重平衡。重平衡过程中没处理完的消息会被其他节点重新拉走如果代码里没有判重逻辑这些消息就会被当成新消息再处理一遍。这三类来源说明一个事实重复消费不是 MQ 的 bug而是分布式系统中为了不丢消息必须付出的代价。既然为了可靠性接受了 at least once就得在业务侧解决 exactly once 的效果也就是幂等。1.3 “过滤重复消息”不等于缓存穿透判重要有业务含义很多人会把幂等和缓存去重混在一起谈其实它们是两回事。缓存去重关心的是“这个请求是不是之前见过”键通常是请求 ID、消息 ID 这类纯技术 ID幂等判重关心的是“这个业务动作是不是已经生效”键必须包含业务语义比如订单 ID、活动 ID、用户 ID 的组合。举个例子同一张订单如果因为支付回调重复触发“发放霸王餐券”这个动作你不能只看消息 ID因为支付平台回调两次的消息 ID 可能不同但业务单号是同一个。真正的幂等判重要落到订单维度这张订单是不是已经发过券了如果已经处理成功后续无论来多少条消息都应该被挡住。这个认知非常关键。我们第一版方案确实用过 MQ 自带的 messageId 去重结果发现同一个业务动作可能由不同源头触发messageId 变了业务还是重复执行了。后来才把幂等键统一改成业务维度这是后话。2. 方案选型唯一索引、分布式锁、SETNX到底怎么选2.1 为什么不首选数据库唯一索引说到幂等很多人的第一反应是给业务表加唯一索引。比如建一张幂等记录表把biz_id设为唯一键消费时先插一条记录插入冲突就说明消息重复了。这个方案的优势是强一致数据落在 MySQL 里不依赖于 Redis 的可用性即使 Redis 挂了也不影响判重。但外卖霸王餐这个场景有一个特点峰值时间段订单量很大而且多数操作都是几毫秒到几百毫秒完成。如果每条消息都先往 MySQL 插一条幂等记录意味着多一次网络往返、多一次索引维护、多一次事务开销。线上 MySQL 的 QPS 本身就有瓶颈再叠加上幂等表的写入数据库压力会明显上升。另一个痛点是唯一索引方案需要手动处理“插入冲突”的异常代码写起来并不简单try { idempotentMapper.insert(record); } catch (DuplicateKeyException e) { // 这里就是重复消息直接过滤 return; }看着没什么问题但如果插入后事务还没提交消费者进程就挂了这条幂等记录可能回滚后续重投的消息又会重新进入业务逻辑。如果强行要求幂等写入和业务处理在同一个事务里又会把 MQ 消费的吞吐拖得很低处理耗时的上限也被拉长。所以数据库唯一索引适合低频、强一致要求高的场景不适合这种高频短时窗口的过滤。2.2 为什么 Redisson 分布式锁在这个场景里“偏重”也有人会想到用分布式锁消费前加锁处理完释放用锁来保证同一时间只有一个线程在处理同一条消息。Redisson 的RLock天然支持看门狗续期和可重入语义在分布式互斥场景里确实很好用。但分布式锁解决的是“并发互斥”不是“幂等判重”。加锁只能保证同一时刻只有一个消费者执行可如果第一个消费者处理完刚释放锁第二个消费者又拿到锁这条消息还是会执行第二遍。你要想靠锁来做幂等还得在锁内再做一次状态检查等于绕了一圈又回到判重本身。另外 Redisson 分布式锁的复杂度更高涉及锁续期、锁释放异常、看门狗停止时机等细节。在霸王餐活动出餐这个场景里业务处理时间短、重复消费是低频偶发用分布式锁去解决幂等问题属于拿大炮打蚊子引入了不必要的运维和排查成本。2.3 SETNX 过期时间一条命令带上“第一次消费”的语义Redis 的 SETNX 全称是 SET if Not eXists语义非常贴合幂等场景如果 key 不存在就设置成功如果 key 已经存在就设置失败。这个“失败”恰好可以映射成“消息已经被消费过了”。但这里有个关键前提必须用带 NX 参数的单条 SET 命令而且要同时带上 EX 过期时间。Redis 从 2.6.12 版本开始支持SET key value NX EX seconds这种原子写法它把“加锁”和“设置过期时间”合并成一条命令不需要分成 SETNX EXPIRE 两步。做幂等时我们把“业务单号”作为 key第一次消费时 SETNX 成功说明这是首次处理后续重复消息再来SETNX 返回空直接丢弃整个过程只需要一次 Redis 访问延迟通常在一毫秒以内。过期时间主要起自我保护作用避免 Redis 里的幂等 key 越积越多同时也给“业务处理超时后的重投”留出窗口。2.4 选型对照三个方案的取舍没有绝对对错维度数据库唯一索引Redisson分布式锁Redis SETNX TTL一致性模型强一致强一致锁粒度内最终一致依赖Redis单次判重耗时毫秒级 DB网络开销毫秒级 锁续期逻辑亚毫秒级对DB压力大小无实现复杂度低中高很低应对高并发中中高适用场景低频强一致并发互斥高频短时判重我这里的选择逻辑是霸王餐活动需要在短时间内承接大量出餐消息重复消费的比例不高但一旦发生就必须拦住Redis 本来就是业务系统的基础组件顺手用它的 SETNX 做一个轻量过滤层既能挡住重复消息又不会给 MySQL 增加额外写入负担。如果你的业务对数据强一致要求极高或者不允许 Redis 抖动影响消费那还是优先考虑数据库方案方案没有绝对的最好只有最匹配场景的。3. SETNX核心实现一条原子命令替代两步判断3.1 必须是一条命令SETNX EXPIRE 拆分是最大的坑网上很多老教程会把 Redis 做分布式锁的代码写成先SETNX再EXPIRE两步。这种写法在幂等场景里非常危险。假设有两个线程同时处理同一条消息线程 A 执行 SETNX 成功线程 B 的 SETNX 也执行成功因为 key 已经被 A 设置过了B 应该失败才对。但如果两个线程严格并发A 刚 SETNX 完还没来得及执行 EXPIREB 的 SETNX 一样会失败看起来问题不大。真正的风险在于线程 A 执行完 SETNX 后进程崩溃了EXPIRE 指令根本没发出去这时 Redis 里的幂等 key 没有过期时间会一直占用内存并且后续所有携带相同 biz_id 的消息都会被当成重复消息过滤掉相当于这个订单永远不能重试。所以正确做法是用 Redis 的原子命令SET key value NX EX ttl把设置值和过期时间捆绑成一次网络交互。Redis 会保证这个命令要么整体成功、要么整体失败不会存在“设置了 key 但没设置过期时间”的中间状态。在 Java 的 Spring Data Redis 中可以直接这样写Boolean firstTime stringRedisTemplate.opsForValue() .setIfAbsent(key, 1, Duration.ofSeconds(ttlSeconds));这个方法最终会转化为 Redis 的 SET key value NX EX ttl 命令。注意看这里没有任何“先判断再执行”的逻辑Redis 内部对同一 key 的并发写入是串行处理的所以即使十个线程同时来也只可能有一个返回true其余全部返回false。3.2 消费者代码改动先判重再处理处理失败要回滚我把核心逻辑抽成了一个MessageIdempotentGuard组件用StringRedisTemplate做底层操作Component public class MessageIdempotentGuard { private static final String IDEMPOTENT_PREFIX msg:idempotent:; private final StringRedisTemplate stringRedisTemplate; public MessageIdempotentGuard(StringRedisTemplate stringRedisTemplate) { this.stringRedisTemplate stringRedisTemplate; } /** * 尝试获取某个业务单号的消费权 * 返回 true 表示首次消费后续逻辑可以继续执行 * 返回 false 表示重复消息直接过滤 */ public boolean tryAcquire(String bizId, long ttlSeconds) { String key IDEMPOTENT_PREFIX bizId; return Boolean.TRUE.equals( stringRedisTemplate.opsForValue() .setIfAbsent(key, 1, Duration.ofSeconds(ttlSeconds)) ); } /** * 业务处理失败时释放消费权让 MQ 重投的消息可以再次尝试 */ public void release(String bizId) { String key IDEMPOTENT_PREFIX bizId; stringRedisTemplate.delete(key); } }消费者里的用法非常简单RabbitListener(queues order.consume.queue) public void onOrderMessage(OrderMessage message) { String bizId message.getOrderId() : message.getActivityId(); if (!idempotentGuard.tryAcquire(bizId, 3600)) { log.warn(重复消息自动过滤, bizId{}, bizId); return; } try { orderConsumeService.handle(message); } catch (Exception e) { idempotentGuard.release(bizId); log.error(出餐消息处理失败, bizId{}, bizId, e); throw e; } }这段代码的逻辑很直白tryAcquire返回true才继续处理返回false就说明同一业务单号已经有人处理过直接返回。业务处理失败时调用release删除 key这样 Broker 重新投递的消息还能再次进入处理流程不会因为一次死信就永久卡住。3.3 key、value、TTL 这三个参数怎么设计先看 key 的粒度。不能用 messageId 做 key原因前面说过同一个业务动作可能由不同消息触发。我这边用的是orderId activityId因为你不能只按 orderId 判重同一个订单如果同时参加了两期霸王餐活动就会产生两个不同的业务动作混淆在一起会误伤。正确做法是找到“最小业务幂等单元”也就是这条消息到底要完成哪个操作就把这个操作涉及的业务标识组合成 key。再看 value 的取值。我这里只存了一个1因为 SETNX 本身只关心 key 存不存在value 暂时没有业务用途。但如果你需要更复杂的降级逻辑比如区分第一次处理中、处理成功、处理失败可以把 value 存成状态码然后用 GET 命令辅助判断。后面我会讲到一个用 token 做安全释放的增强版本。最后是 TTL。这个参数很容易拍脑袋我给出一个相对可复用的估算公式TTL max(业务平均处理耗时 × 20, 最大处理耗时 × 5, MQ 最大重投间隔 × 2)假设霸王餐出餐业务平均耗时 200ms极端情况 3 秒MQ 重投间隔通常几十秒到几分钟那 TTL 设 30 分钟到 1 小时是比较稳妥的。TTL 太短业务还没处理完 key 就过期了重复消息会绕过过滤TTL 太长Redis 里堆积大量无用 key白白占内存。活动类场景一般设 1 小时内就够了不用给到几天。3.4 进阶用 token 解决“释放他人锁”的隐患上面release方法有一个隐患如果第一条消息处理超过 TTLkey 自动过期了第二条消息获得了消费权并设置的新的 key。这时候第一条消息恰好异常进入 catch 块执行delete操作就会把第二条消息的幂等标记误删掉导致后续消息又能重复消费。要解决这个问题可以在设置 key 时把 value 存成一个随机 token释放时先用 Lua 脚本比对 token一致才删除。Spring Data Redis 里可以直接执行 Lua-- KEYS[1]: 幂等key -- ARGV[1]: 本次消费生成的token if redis.call(GET, KEYS[1]) ARGV[1] then return redis.call(DEL, KEYS[1]) else return 0 end对应的 Java 代码public boolean releaseIfOwner(String bizId, String token) { String key IDEMPOTENT_PREFIX bizId; ListString keys Collections.singletonList(key); ListString args Collections.singletonList(token); Long result stringRedisTemplate.execute( new DefaultRedisScript(SCRIPT, Long.class), keys, args); return result ! null result 1L; }这段代码看起来多写了不少但很值。特别是消息处理链路较长、TTL 时间比较紧张的业务token 机制能避免一次“误删幂等标记引起重复消费”的事故。如果只是内部小流量场景按第一个简单版本也够用你自己判断风险接受度。4. 真正容易翻车的细节过期时间、降级和重试补偿4.1 过期时间为什么不能拍脑袋短了失效长了吃内存我在 3.3 节给了估算公式这里再补充一个真实案例。刚开始我们在测试环境把 TTL 设成了 10 分钟压测跑得飞快结果上线第二天就有用户反馈同一个订单收到了两次出餐通知。排查发现是业务处理链路里调用了一个外部商家接口该接口在高峰期响应特别慢个别消息处理耗时超过了 10 分钟key 到期后被后续重投消息再次入队等于幂等窗口空出来了。后来我们把 TTL 调到 30 分钟同时给外部接口加了更严格的超时控制问题才消失。这件事给我的教训是TTL 必须覆盖“业务处理的最差情况”而不是平均情况。外部依赖、GC停顿、网络抖动都会拉长单条处理时间这些都要算进去。TTL 也不能设得过于“保险”比如直接设一天。幂等 key 每个占用几十字节如果一天有百万级订单Redis 里会堆积上千万个 key虽然惰性删除机制会在 key 过期后逐步清理但过期时间太长意味着内存回收很滞后。活动结束后的数据没用了应该尽早让 key 过期释放。我建议按活动周期设置最长不要超过 24 小时。4.2 Redis 短暂不可用时怎么兜底宁可抛异常也不能无脑放行有人说 SETNX 方案把砝码都押在 Redis 上了那 Redis 挂了怎么办这里必须有一个明确的选择是 fail-closed 还是 fail-open。fail-open 是 Redis 异常时直接放行消息让业务继续往下走。这种做法的好处是保证业务可用性坏处是 Redis 抖动期间重复消息会全部漏进来可能出现重复出餐。对于霸王餐这种和资金成本直接挂钩的场景风险太大我不建议。我用的是 fail-closedRedis 访问抛出异常时直接让当前消费方法抛异常消息不 ack交给 MQ 重新投递。这相当于把 Redis 不可用的压力转移给 MQ 的重试机制等 Redis 恢复后消息会被重新消费幂等语义仍然成立。代价是 Redis 故障期间消费会阻塞但 Redis 本身是高可用部署短暂故障后快速恢复这个代价是可接受的。在代码里tryAcquire方法不用专门捕获 Redis 异常因为底层的setIfAbsent在连接失败时会抛出RedisConnectionFailureException或超时异常它们会向上抛到消费者方法。消费者方法如果包了一层try-catch(Exception)注意别把 Redis 异常误当成业务异常去调用release否则可能在 Redis 抖动时把别人刚建立的幂等 key 删掉。4.3 消费失败重试和幂等记录同时存在怎么办这是一个非常容易踩的细节当业务处理失败时我们确实执行了release删除 key但release本身也可能失败。比如 Redis 刚好在删除前一刻超时或者 key 已经因为某种原因被清掉那么 MQ 重投的消息来临时tryAcquire可能返回false消息就被过滤掉了重试永远不会发生业务单子卡死在初始状态。针对这种情况我的做法是不把 Redis 当作唯一的事实来源。业务处理成功后会更新订单状态字段比如把 status 改成CONSUMED同时记录处理时间。消费者判重时先看 Redis如果 Redis 显示重复再查一次业务状态确认状态已经是成功态才能放心过滤如果业务状态还是初始态说明上次大概率是失败且 key 误删或异常过期这时就算 Redis 返回重复也要继续往下走业务逻辑。这一段“双保险”逻辑我在 4.4 节展开但你需要先建立这个意识Redis 幂等只能挡掉大部分重复消息最终的业务一致性必须靠业务表状态兜底。4.4 双保险业务状态再查一遍别把 Redis 当唯一判据以我的订单出餐为例订单表里会有status字段整个生命周期是INIT - PROCESSING - CONSUMED消费者里可以在 SETNX 通过之后先看订单状态是不是已经是CONSUMEDif (!orderService.isStatusInit(orderId)) { log.info(订单已处理过直接返回, orderId{}, orderId); return; }为什么要多这一层因为 Redis 的 key 是会过期的过期的瞬间正好有另一条重复消息进来Redis 挡不住但业务状态能挡住。换句话说Redis 是第一道快速过滤业务状态是最后一道事实校验。还有另一种常见做法是直接在数据库做 CAS 更新UPDATE order_info SET status CONSUMED WHERE order_id #{orderId} AND status INIT如果更新影响行数为 1说明当前线程拿到了唯一一次状态流转权限如果影响行数为 0说明这个订单早就处理过了直接丢弃消息。这个方案不依赖 Redis语义也很干净缺点是数据库压力更大、处理耗时更长。我一般把 CAS 更新当成兜底而不是首选Redis 挡不住的那一小撮异常流量再走数据库校验。5. 压测与线上验证在重复消息投递环境下实测过滤效果5.1 测试环境怎么搭模拟重复投递是重点写代码容易验证代码难。幂等方案最怕的是测试环境里根本造不出重复消息导致功能看起来没问题一上生产就出事。我这边用了一个很土但很有效的方法直接写一个压力测试程序往 MQ 里投递消息时每条业务消息连续投递 N 次模拟网络重试后的重复到达。测试参数是这样的Java 版本JDK 17Spring Boot 2.7Redis单机 4C8G默认配置无持久化MQRabbitMQ 单机测试数据1000 个订单每个订单投递 3 条完全相同的消息业务单号一致共 3000 条消息并发消费线程50幂等 TTL30 分钟业务处理逻辑模拟一次 50ms 的耗时操作然后更新订单状态这个设计的核心是让“重复消息”和“首次消息”混在同一个队列里同时被消费制造出真实的并发竞争而不是等第一条消息完全处理完再发第二条。5.2 压测结果SETNX 挡下了多少重复请求我这边的观察结果如下指标数值投递消息总数3000承载业务单号数1000进入业务处理的次数1000被幂等过滤的重复消息2000误过滤业务未完成却被过滤0业务成功处理数1000SETNX 判重平均耗时0.3msSETNX 判重 P99 耗时2.8msRedis 峰值内存增量约 1.2MB3000 条消息最终只有 1000 条真正执行了出餐业务逻辑和预期完全一致。判重耗时几乎可以忽略不计P99 也在几毫秒以内不会对消费吞吐造成影响。内存增量几十字节 × 3000 个 key 在这个量级可以忽略这也反映出 TTL 的重要性如果 key 不设过期跑一个月活动数据累积起来就不是这个数了。压测过程中还故意模拟了几次消费线程在业务处理中途被强杀的情况重启消费者后MQ 重新投递了未 ack 的消息SETNX 因为业务单号已经存在而返回重复但业务表状态还是 INIT说明上一次强杀导致事务回滚于是代码里双保险逻辑会跳过 Redis 的过滤重新执行业务处理最终订单状态正确落到了CONSUMED。这说明双保险不是摆设是真能兜底。5.3 线上日志里看到的实际效果上线后我把日志统计接到告警上主要看三个指标被过滤的重复消息量、过滤错误率、处理失败重试次数。第一周的结果是高峰时段每天会触发几十次重复消息过滤说明重复消费在生产环境不是理论问题而是确实存在的物理现象。被过滤的消息全部来自 MQ 重投和生产者侧超时重试过滤错误率为零。但我也发现了一个有意思的现象有少量消息被 SETNX 挡下后日志里已经看不到对应的“处理成功”记录。查下来是早期版本消费者在tryAcquire成功后、进入业务逻辑前发生了线程池拒绝消息也没来得及 ack重新投递后被幂等挡住了。这就是典型的“Redis 标记先于业务提交”的副作用。后来我把tryAcquire的成功点从业务逻辑最前面往后移了一些并把线程池拒绝场景单独暴露成异常不再让消息在“已抢到幂等权但未处理”的状态下静默丢失这个问题才算彻底解决。6. 业务量再涨一步从 SETNX 到更完备的幂等体系6.1 把幂等记录从 Redis 落到持久化表长期归档SETNX 方案适合作为短时间窗口的第一道过滤但它存在两个天然短板一是 key 会自动过期过期后的重复消息挡不住二是 Redis 内存终究有限不适合把所有历史幂等记录都塞在 Redis 里。当订单量持续增长后我的建议是把幂等分成两层第一层还是 Redis SETNX负责挡住短时间窗口内的高频重复流量TTL 可以是 5 到 30 分钟这个窗口基本能覆盖 MQ 重投和生产者重试产生的绝大多数重复消息。第二层是持久化幂等表Redis 返回“重复”之后如果业务状态还是 INIT说明 Redis 的 key 可能过期了这时去查幂等表或业务状态表做最终确认。这张表的结构很简单idempotent_id VARCHAR(64) PRIMARY KEY biz_type VARCHAR(32) status TINYINT created_at DATETIME processed_at DATETIME写入频率不高因为只有 Redis 过期窗口边缘的极少数消息会打到这一层不会产生太大的数据库压力。定期清理脚本可以只保留最近 30 天数据更早的归档到离线数仓。6.2 把幂等过滤抽成通用组件注解化省心很多如果团队里多个服务都在消费消息每个服务都自己写一遍tryAcquire很容易出现风格不一致。可以把它抽成一个通用组件用注解耕地方式标注幂等键Idempotent(prefix order:consume, keys {orderId, activityId}, ttlSeconds 3600) RabbitListener(queues order.consume.queue) public void onOrderMessage(OrderMessage message) { // 业务代码 }注解的底层逻辑本质上还是调用 SETNX只是把 key 组装、TTL 配置、失败释放封装成切面。这样做的好处是核心业务代码里看不到幂等判断幂等变成一个声明式能力后续就算要换实现方案比如把 Redis 换成数据库唯一索引也只改切面内部逻辑不动各个消费者方法。6.3 面试里被问到的延伸问题这里一起回答因为标题里带了 Java 面试的氛围我把几个面试官爱问的点也列一下第一个问题Redis SETNX 和分布式锁有什么区别SETNX 是一个“占位”操作成功代表 key 不存在它不能自动续期也不能保证持 key 方在业务执行期间一直存活。分布式锁是在 SETNX 的基础上增加了值校验、看门狗续期、释放锁校验等机制解决的是“临界区互斥”问题而不是“重复执行”问题。幂等场景在意的是“这个单子是否处理过”互斥只是附带效果。第二个问题为什么不用INCR或者SETBITINCR也能判断 key 是否存在第一次INCR返回 1以后返回大于 1 的数但它会改变 value 的值而且语义上更适合计数器SETBIT适合大量位映射判重但需要提前设计位图容量。SETNX 的语义最直接就是“首次写入”。第三个问题如果 Redis 是集群模式SETNX 还能用吗能用。幂等 key 会按 hash slot 落到某个节点Redis Cluster 会保证同一 key 的命令被路由到同一个节点执行SETNX 的原子性不受影响。但要注意 key 里不要带上容易倾斜的分片字段比如都用同一个固定前缀否则热点 key 全落在同一个节点上。可以在 key 中加入订单号这类自然散列的字段。第四个问题消息可能迟到 30 分钟TTL 设成 30 分钟够吗不够。迟到消息必须考虑消息在 MQ 里积压的时间幂等 TTL 必须覆盖“消息最大可能的延迟时间”加“业务处理最大耗时”。如果这个窗口很大不如直接用业务表状态兜底Redis 只做短窗口加速。最后说一个我长期实践下来的习惯做 MQ 消费者无论用不用幂等组件我都会在入口把消息的业务单号和关键状态打一条日志线上排查重复消费时这条日志能帮你快速判断消息是“第一次进来”还是“被重投进来的”。技术方案可以升级日志习惯一定要保持否则出了问题连现场都还原不出来。
返回列表