hyperf 3.1 中实现异步队列消息幂等性需显式注入业务唯一键(如 order_id:event_type 哈希),通过 redis setnx+ex 原子脚本校验、sorted set 时间窗口去重或数据库唯一索引兜底,严禁依赖 delivery_tag,且须规避协程上下文污染。

在 Hyperf 3.1 中实现异步队列消息幂等性,是为了防止同一业务消息被重复消费导致数据错乱,比如用户充值两次、订单重复创建、积分重复发放。这要求每条消息在消费者端必须能被准确识别是否已处理过,且识别过程本身不能成为性能瓶颈或单点故障。
设计前提:明确消息唯一标识来源
Hyperf 默认不生成全局唯一 message_id,需由生产者主动注入。若依赖 AMQP 协议自动生成的 delivery_tag 或 RabbitMQ 的 publish_confirm 序号,将无法跨连接、跨重试场景保证一致性。
必须在消息 payload 中显式携带业务维度的【唯一业务键】,例如 order_id:123456 + event_type:pay_success,二者拼接后做 md5 或 xxh3_64 哈希作为幂等键(idempotent_key)。
跳过此步直接用 $message->getDeliveryTag() 做去重,会在消费者重启、队列重建、镜像同步等场景下完全失效。
方案一:基于 Redis String 的原子写入校验
方法一适用于中小并发、强一致性要求高的场景,如支付结果通知、核心账户变更。
第一步:在消费者中提取幂等键
从 $message->getBody() 解析出 idempotent_key,例如 $key = 'idemp:' . md5($order_id . ':' . $event_type);
第二步:执行 SETNX + EXPIRE 原子操作
使用 Hyperf\Redis\Redis::setex($key, 3600, '1') 不可行——它不是原子的;必须改用 eval 脚本:eval "if redis.call('exists', KEYS[1]) == 0 then redis.call('setex', KEYS[1], ARGV[1], ARGV[2]) return 1 else return 0 end" 1 idemp:xxx 3600 1
第三步:判断返回值决定是否继续执行
返回 1 → 首次写入成功,进入业务逻辑;返回 0 → 已存在,直接 ack 并 return;【严禁在此处 throw 异常或 nack,否则会触发重复投递】
方案二:基于 Redis Sorted Set 的时间窗口去重
适合高吞吐、允许极短时间窗口内少量重复的场景,如日志上报、行为埋点、非关键通知。
使用 zadd 写入当前时间戳作为 score,幂等键为 member:
$redis->zAdd('idemp_window:pay_event', time(), $idempotent_key);
随后调用 zremrangebyscore 清理 5 分钟前的数据:
$redis->zRemRangeByScore('idemp_window:pay_event', 0, time() - 300);
再用 zscore 判断 member 是否已存在 —— 存在即跳过。该方式避免了 key 过期竞争,但需确保 Redis 时钟与应用服务器基本一致。
方案三:数据库唯一索引兜底(推荐组合使用)
仅靠缓存无法 100% 防重,网络分区、Redis 故障、TTL 精度误差都可能漏掉重复。必须在最终落库环节加一层防护。
在业务表中添加唯一索引:ALTER TABLE `order_events` ADD UNIQUE INDEX `uk_order_id_event_type` (`order_id`, `event_type`, `source_id`);
消费逻辑中先尝试 INSERT IGNORE,成功则继续后续流程;失败则查表确认是否已存在同事件记录,存在则直接 ack,不存在则说明是其他异常(如字段超长),按需告警并人工介入。
这一步不是可选优化,而是【强制落地的最终防线】,尤其当业务涉及资金、库存等敏感操作时。
规避协程上下文污染的关键操作
Hyperf 3.1 默认开启协程,若在消费者中使用静态变量或全局数组缓存已处理 key,会导致多协程间数据串流 —— A 协程 set 的 key 可能被 B 协程误判为已存在,或反之漏判。
必须禁用所有非线程安全的本地缓存手段。可选替代方案:用 Hyperf\Context\Context::set() 绑定到当前协程上下文,但仅限单次消费生命周期内临时标记,不可跨消息复用。
示例:
Context::set('idemp_local_cache', [$key => true]);
下次消费时 Context::get('idemp_local_cache') 是空的 —— 它天然隔离,无需手动清理。











