PHP消息队列幂等消费实战:Redis与数据库唯一约束双保险方案
发布时间:2026/10/3 3:15:40 作者:尧图编辑部 阅读量:1,286

做PHP后端这几年要说哪类问题最让人头疼消息重复消费绝对排得上号。你辛苦写了半天的消费逻辑在测试环境跑得风调雨顺一上生产就开始给你反复执行同一条消息——扣款扣两次、库存减两次、短信发两条问题一出就是线上事故。我自己就经历过一次凌晨两点被叫起来修重复入账的经历从那以后幂等消费这四个字直接刻进了我的开发习惯里。这篇文章就把我这套基于PHP的幂等消费方案完整拆开讲清楚包括为什么需要、怎么设计、怎么落地、有哪些坑适合正在用消息队列处理业务、或者被重复请求折磨过的小伙伴参考。1. 幂等消费到底解决的是什么问题1.1 先搞清楚消息为什么会重复很多人一开始想不明白消息队列又不是快递怎么会送两次实际上重复消费是分布式系统的常态不是偶然。绝大部分消息队列比如RabbitMQ、Kafka为了保证消息不丢都采用至少一次at least once的投递语义。意思是消息可能会重复但不能丢。这个语义背后是消息队列的精力和网络现实妥协后的结果——生产者发送消息后没收到确认它会重发消费者处理完消息后还没来得及提交ack连接就断了消息队列就会重新投递消费者进程在处理消息过程中崩溃、重启未提交的消息也会被再次投递。我举一个最常见的场景你用的是RabbitMQ手动ack模式消费逻辑执行完毕业务数据也入账了结果在调用basic_ack的一瞬间网络闪断消息没有确认成功。Broker以为消费者没处理完过了一会儿就把同一条消息重新投递给你了。这时候你的业务代码已经跑过一遍再跑一遍就出问题了。这个现象在PHP长连接消费者里尤其常见因为PHP脚本挂了之后重启很快Broker还没来得及等超时就又塞了一条消息过来。1.2 不幂等的后果是实打实的钱和信任重复消费不是理论问题是直接的经济损失。我这里说几个真实案例。案例一用户在小程序里下单后端订单服务通过消息队列通知积分服务加积分。由于消费端没有幂等保护网络抖动导致同一条订单完成消息被投递了三次用户的账号里多了三倍积分。这种问题对用户来说像是天上掉馅饼但对公司来说就是实打实的负债。如果场景换成余额扣减、库存扣减那就直接变成资损事故了。案例二营销短信场景。用户在活动页触发了一条注册成功送券消息消费端没做幂等消息重试三次用户手机上就收到三条一模一样的短信。用户的第一反应不是这平台真大方而是这平台系统有毛病信任感直接崩塌。案例三对账文件生成。凌晨跑批定时任务从消息队列里消费一批交易记录去生成对账文件如果消息被重复消费同一个交易就会出现两次对账结果直接不平财务早上来上班就得灭火。这些案例有一个共同点系统本身没有感知到这条消息我之前处理过。幂等消费的核心就是让系统在重复收到同一条业务消息时能识别出来并且忽略掉或者返回之前的结果保证业务数据只被处理一次。1.3 幂等和去重、并发控制的区别聊幂等之前有三组容易混淆的概念先理清楚幂等、去重、并发控制。去重指的是在数据写入环节通过唯一键等机制丢弃重复数据幂等是一个更上层的能力强调同一个请求执行多次最终结果一致并发控制则是防止多个请求同时操作同一份数据导致不一致。这三者经常一起出现。比如你的消费逻辑是插入一条支付流水那既要做幂等重复消息不重插又要做并发控制同一笔订单不能有两个支付请求同时插入成功还要做去重按订单号渠道流水号唯一约束。方案设计时这三者要统筹考虑不能只解决一个。下文要讲的方案里Redis锁解决的主要是并发和重复问题数据库唯一键解决的是最终一致性的兜底问题两者配合才是完整的幂等方案。2. 幂等方案怎么选从简单到复杂的四套打法2.1 方案一业务层天然幂等适合状态机型业务最轻量的做法是让业务本身具备幂等性。什么叫业务天然幂等就是无论这个操作被调用多少次产生的结果都一样。最典型的是状态机业务。以订单系统为例订单状态流转是待支付 → 已支付 → 已发货 → 已完成。如果消费逻辑是先判断当前订单状态只允许待支付状态下执行扣款和状态变更为已支付那么即使同一笔支付成功的消息重复投递十次也只有第一次能成功执行状态流转后面的都被状态判断拦截了。MySQL的行级锁配合状态条件更新UPDATE orders SET statuspaid WHERE id? AND statuspending天然就能实现幂等。这种方案的优点是代码零额外依赖纯粹利用业务规则的约束。缺点是适用范围窄只适合状态流单一、有状态可判的场景。如果业务没有状态比如给用户增加积分、追加一条操作日志业务天然就不幂等得靠下面几个方案。2.2 方案二数据库唯一约束保底最硬核的兜底利用数据库唯一索引是防重复消费最后一道防线。做法是在业务表上建立唯一键这个唯一键由业务幂等ID比如订单号业务类型构成。消费逻辑执行时先尝试插入一条带有幂等ID的记录如果插入成功说明是首次消费执行后续业务逻辑如果插入失败因为唯一键冲突MySQL报1062错误说明这条消息已经处理过直接跳过。我一直认为任何核心交易链路都必须有这个兜底。Redis可能丢数据分布式锁可能超时但数据库的唯一索引一旦建立除非有人把约束删了否则它就是物理级别的幂等保证。这个方案特别适合与业务数据同库写入的场景它不需要额外的中间件维护成本低。需要注意唯一键冲突本身会占用一次写事务、产生一次死锁检测的开销高并发场景下大量重复消息同时涌来唯一键冲突会拖慢数据库性能。所以方案二一般是保底不是首选。优秀的设计是先用Redis拦住99%的重复消息只有极少数漏网之鱼落到数据库唯一键上。2.3 方案三Redis幂等标记性能最佳的拦截层Redis做幂等标记是业界最常用的方案适合对性能要求高、请求量大的场景。核心思路用一个唯一消息ID作为Redis key消费前用SET key value NX EX timeout或者SETNX抢占这个标记。抢到了说明这是第一次消费没抢到说明消息重复了直接丢弃。这个做法相当于给消息发了一张已处理的凭证。每次消费的第一件事不是去查数据库、不是执行业务逻辑而是先在内存级的Redis里做一次存在性判断。因为Redis的单线程模型保证SETNX是原子操作多个并发请求同时来只有一个能成功这正是我们要的。方案三最适合作为系统的第一道拦截层。但要注意它只能防处理过的消息重复到达防不了第一条还没处理完第二条重复消息就到了的情况——这种情况需要引入锁的语义或者把幂等标记的过期时间设置大于消息重试间隔。具体技术细节在第四章详细说。2.4 方案四消息表本地事务分布式环境最稳的保证当Redis不可靠比如Redis挂了、网络分区、消费进程崩溃时需要最后兜底。方案一不行、方案二有冲突性能损耗、方案三防不住极端情况这时候可以用本地消息表。做法是这样的消费者在本地数据库里建一张message_processed表字段包含消息唯一ID、业务类型、处理状态、处理时间。消费流程是开启数据库事务查询message_processed表里是否存在这条消息ID不存在则插入消息记录同时执行真正的业务逻辑比如更新订单、变更账户余额事务提交。因为消息记录的插入和业务数据更新在同一个事务里要么一起成功要么一起回滚不存在业务更新了但消息没标记的中间状态。即使消费者在处理完业务后、事务提交前崩溃了消息队列重新投递时本地消息表里查不到记录就会再处理一遍但这个再处理是安全的因为事务回滚了业务也没真正生效。这四种方案不是互斥的而是可以组合使用的。我自己的最佳实践是Redis拦截方案三 数据库唯一约束兜底方案二。Redis负责挡住绝大多数重复消息数据库唯一约束负责挡住极端情况下Redis失效后的漏网之鱼性能与可靠性兼得。3. PHP落地实操Redis去重 数据库唯一键双保险3.1 整体流程设计我用一个用户签到送积分的业务来演示。用户每天签到一次系统给他加10积分。签到事件从API服务发到消息队列积分服务消费这条消息。这个业务看起来简单但用户同一天重复签到就是个天然的消息重复场景。暴力测试一下一天内用户连续点击签到按钮十次API层即使有防抖网络重试也可能把同一天的签到消息发多次。我的消费流程设计是这样消费者从队列里拿到消息解析出消息唯一ID这里用user_id 业务日期拼一个签到幂等ID先用Redis的SET NX EX尝试写入幂等标记抢到标记的继续往下走没抢到的直接ack不重复处理抢到标记后写签到记录表同时依靠签到记录表的user_id sign_date唯一索引兜底签到记录插入成功后执行加积分逻辑最后释放Redis标记或者等它自然过期。流程图在脑子里过一遍就是Redis拦截 → 业务处理 → 数据库兜底。每一层各司其职相互配合。3.2 关键代码实现先看消息实体。假设队列里投递过来的消息是JSON格式{ msg_id: 9f8e7d6c-5b4a-3c2d-1e0f-abcdef123456, biz_type: user_sign, user_id: 10086, sign_date: 2025-01-15 }msg_id是全局唯一消息ID由生产者生成。biz_type区分业务类型user_id和sign_date是业务数据。我一般会封装一个幂等消费的辅助类把它做成一个简单的中间件。先看PHP代码实现?php class IdempotentConsumer { private Redis $redis; public function __construct(Redis $redis) { $this-redis $redis; } /** * 尝试获取幂等标记 * 返回 false 表示消息重复不用处理 */ public function tryAcquire(string $idempotentKey, int $ttl 3600): bool { // Redis SET NX EX 原子操作 // NXkey不存在时才设置成功 // EX设置过期时间单位秒 $result $this-redis-rawCommand( SET, $idempotentKey, 1, NX, EX, $ttl ); return $result true || $result OK; } /** * 处理消息的统一入口 * $handler 是一个闭包里面是真正的业务逻辑 */ public function consume(string $idempotentKey, callable $handler, int $ttl 3600): void { if (!$this-tryAcquire($idempotentKey, $ttl)) { // 说明这条消息已经处理过或者正在处理中 return; } try { $handler(); } catch (Throwable $e) { // 业务处理失败释放幂等标记允许消息重试 $this-release($idempotentKey); throw $e; } } /** * 释放幂等标记 * 只有在业务处理失败时才需要释放 */ public function release(string $idempotentKey): void { $this-redis-del($idempotentKey); } }注意rawCommand是PHP Redis扩展的一种用法它可以把SET key value NX EX ttl当作一条原生命令发给Redis确保原子性。如果你用的是Predis这类纯PHP客户端写法略有差异但命令本身是一样的。使用这个辅助类的消费逻辑?php // 假设这是消息队列框架的消费者入口 function handleSignMessage(array $message): void { $idempotentKey sprintf( idempotent:sign:%s:%s, $message[user_id], $message[sign_date] ); $consumer new IdempotentConsumer($redis); $consumer-consume($idempotentKey, function () use ($message) { // 1. 插入签到记录利用数据库唯一索引兜底 $inserted insertSignRecord( $message[user_id], $message[sign_date] ); // 2. 插入成功才加积分 if ($inserted) { addPoints($message[user_id], 10); } }, 86400); // 幂等标记保留一天保证当天重复签到不会二次处理 }这里有一个细节插入签到记录用INSERT IGNORE还是先查后插我的习惯是用INSERT ... ON DUPLICATE KEY UPDATE或者INSERT IGNORE让它通过数据库唯一键直接把重复数据拦掉返回受影响行数为0就说明是重复记录不需要抛异常。这样数据库层面天然幂等代码也不用区分异常导致失败和重复导致冲突。再看数据库表结构CREATE TABLE user_sign_record ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, user_id INT UNSIGNED NOT NULL COMMENT 用户ID, sign_date DATE NOT NULL COMMENT 签到日期, points INT NOT NULL DEFAULT 10 COMMENT 获得积分, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_user_date (user_id, sign_date) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户签到记录表;uk_user_date这个唯一索引就是数据库层的幂等保证。同一用户同一天只能有一条签到记录不管消息被投递多少次第二次插入必然冲突。配合INSERT IGNORE冲突不会报错数据库帮我们静默忽略了重复数据。加积分这个操作如果也想做幂等最简单的做法是把积分变更记录也做成一张流水表唯一键是user_id sign_date type同样用唯一索引兜底。核心逻辑就是所有会产生累计效果的业务操作都必须走插入流水而不是直接改总数。有了流水表就算消费重复了插不进去第二条流水。3.3 与主流队列框架的配合实际项目里大家用的队列框架五花八门。这里说说ThinkPHP Queue、RabbitMQ和Kafka下怎么套用这套逻辑。ThinkPHP Queue这是国内PHP项目最常用的队列组件之一。在消费者类的fire方法里把上面handleSignMessage的逻辑整体包起来即可。需要注意的是ThinkPHP Queue默认是自动ack的也就是说pop出来后框架会立即删除消息不存在处理失败重新投递的可能。如果你要手动控制重试需要实现shouldHandle方法或者在消费失败时抛出异常确保消息回到队列。RabbitMQ重点在于ack机制。消费者处理完消息后手动调用basic_ack处理失败时调用basic_nack并决定是否重新入队。这种情况下幂等标记要特别设计如果业务处理失败了一定要主动释放Redis的幂等标记不然消息重新入队后再次投递会因幂等标记还在而直接被丢弃业务永远无法成功。我的做法是在catch块里调用release方法把标记删掉让重试的消息重新走一遍完整流程。KafkaKafka的enable.idempotence是针对生产者防止消息重复发送的和消费者幂等不是一回事。Kafka消费者靠的是offset提交机制来防止重复消费但offset提交失败也会导致重复消费。所以Kafka这边同样要做消费幂等。注意Kafka默认的消费逻辑是拉取一批消息处理完后统一提交offset批量处理时幂等标记要按单条消息维度设置不能用一个批量key代替。4. 实战中的几个关键设计决策4.1 幂等Key的设计不能拍脑袋幂等Key是整个方案的灵魂。Key设计得不好要么挡不住重复要么误杀正常业务。先说Key的组成。最标准的格式是业务类型 业务唯一标识。比如订单支付消息idempotent:pay:order:{order_id}签到消息idempotent:sign:user:{user_id}:date:{sign_date}。这里的核心是业务唯一标识必须能唯一确定一条业务记录而且不会因为参数顺序、空格、大小写变化而变化。我之前踩过的一个坑直接用消息队列自动生成的msg_id做幂等Key。消息重投时有些生产者在重发时会重新生成一个msg_id这就导致同一笔业务消息被赋予了不同的幂等Key幂等失去了意义。所以幂等Key应该基于业务字段来生成而不是依赖传输层的消息ID。生产者那边应该把这个msg_id固定下来重试时复用同一个ID。如果你控制不了生产者那就老老实实用业务字段拼Key。4.2 Redis过期时间的选取策略Redis幂等标记的过期时间TTL设置大有讲究。设短了消息还没处理完标记就被清理了后面重试的消息会再次进来设长了Redis key堆积浪费内存也可能误伤很久之后的重试请求。我的经验是三个值取最大业务并发处理峰值耗时 消息队列最大重试间隔 时钟偏移冗余。签到场景的处理逻辑很简单几十毫秒就能跑完消息重试间隔大多是几秒到几分钟那TTL设置为1小时就够了。订单处理可能涉及调用外部接口、异步对账耗时可能到秒级甚至分钟级消息重试间隔可能到几分钟那TTL建议设置2小时或更长。有一个反向场景需要特别注意延迟任务。有时候业务逻辑本身要等很久才完成比如等待第三方支付回调消费流程会在中途把消息重新入队延迟处理。此时幂等标记的TTL必须覆盖整个延迟周期否则标记一过期回调通知到达时又变成第一次消费逻辑又跑一遍数据可能就重复了。这个坑我在4.3节细说。4.3 处理失败与重复消息怎么区分这是最容易踩坑的地方。我遇到过不少同事把处理失败和消息重复混在一起处理结果业务数据莫名其妙就丢了。严格来说它们应该分开处理消息重复消息内容一样、幂等Key一样系统之前已经处理成功过。此时应该直接ack跳过不处理。处理失败消息内容一样、幂等Key一样但系统之前处理失败了数据库里没有成功记录。此时应该允许它重试不能因为幂等标记存在就直接丢弃。区分方法很简单幂等标记写入成功 ≠ 业务处理成功。理想的设计是幂等标记只在业务成功后写入。但现实是业务处理往往需要一系列操作你没法在最开始就知道最后能不能成功。我的做法是分两个阶段先用SET NX EX抢一个处理中标记处理成功后在同一个事务或紧随其后更新标记值为success处理失败时主动删除标记让下一条重试消息重新竞争。坏消息是Redis没有原生的比较值再删除命令用GETDEL会有原子性问题。好在Redis官方有Lua脚本可以原子地实现如果值等于success才删除。我在生产环境用的是这个方案。真实项目中我用Lua脚本做删除指定值的key-- 如果当前值等于 success 才删除 if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end但更多时候我干脆不主动删标记而是把TTL设置得足够长让处理中标记自然过期。处理成功的消息幂等标记一直留着处理失败的消息等标记过期后重试消息就能再次进来。这样省掉了删除的复杂度代价是Redis内存多占一会儿。对于日活百万以下的项目这个代价完全可以接受。4.4 消费处理的拆箱与装箱PHP的消费逻辑里还有一个小细节消息体解析。队列里传输的通常是JSON字符串PHP端要json_decode成数组再处理。这里有一个我用得比较多的小技巧json_decode默认把JSON对象解析成stdClass对象很多人习惯转成数组第二个参数传true再访问这本身没问题。但如果消息里包含嵌套对象数组转来转去容易丢失类型。更好的方式是用一个DTO类来承接消息在构造函数里统一做数据校验和类型转换。比如class SignMessage { public function __construct( public readonly int $userId, public readonly string $signDate, public readonly string $msgId ) { } public static function fromJson(string $json): self { $data json_decode($json, true, 512, JSON_THROW_ON_ERROR); // 这里可以做参数校验缺字段就抛异常 return new self( (int)$data[user_id], (string)$data[sign_date], (string)$data[msg_id] ); } }这样做的好处是消息结构变化时改动只集中在一个类里消费代码里拿到的是强类型对象不用到处写isset判空。对于长期维护的项目这种先装箱、再消费的模式能让代码清爽不少。5. 常见问题与排查技巧实录5.1 Redis宕机后幂等失效怎么办有同学问我幂等依赖RedisRedis挂了是不是就废了这话只对了一半。Redis宕机第一道拦截确实失效了但我们的方案还有数据库唯一索引兜底。签到场景下数据库唯一索引照样拦住重复数据。所以在设计时一定要记住Redis是拦截层不是保底层。真正的保底永远在数据库。如果你的业务确实连数据库都拦不住比如只有Redis才能承载的高频写操作计数器类业务那就需要对这个Redis做高可用。生产环境建议至少用Redis Sentinel或者Redis Cluster主从切换后确保数据不丢。如果对一致性要求极高可以考虑引入RedLock或者在业务上设计允许小概率重复、事后补偿的策略。5.2 唯一键冲突导致业务异常INSERT IGNORE虽然能静默处理重复但如果你的业务对插入结果有区分需求比如插入成功才加积分直接用INSERT IGNORE拿不到是否真的插入了的信息得用ROW_COUNT()或者改成ON DUPLICATE KEY UPDATE配合自增字段的技巧。MySQL的INSERT ... ON DUPLICATE KEY UPDATE在冲突时会触发更新操作ROW_COUNT()返回2更新1行或0无变化。要区分首次插入和重复冲突可以用这样的小技巧INSERT INTO user_sign_record (user_id, sign_date, points) VALUES (10086, 2025-01-15, 10) ON DUPLICATE KEY UPDATE id id; -- 影响行数为 1首次插入 -- 影响行数为 0重复冲突没有实际更新然后通过PDO::rowCount()判断是否产生了实际影响。用id id这种无操作更新既不会改动数据又能触发MySQL的匹配但未改变逻辑返回0或2方便我们判断。5.3 长任务与锁续期问题我在4.2节提到过TTL必须大于处理耗时。但如果你没法预估处理耗时上限比如消息处理中要等第三方接口响应、要做人工介入TTL固定值就不可靠了。这时需要看门狗机制也就是给幂等标记续期。实现不复杂在业务处理过程中如果发现标记快过期了就重新设置TTL。用Redis Lua脚本或者直接EXPIRE命令都行。我在项目里是这样处理的起一个定时器或者利用PHP的pcntl_alarm/Swoole Timer每30秒检测一次如果业务还在处理中就给幂等标记续期到另一个30秒。这样长任务永远不会因为TTL过期而被重复消费。如果用纯PHP CLI做长任务记得处理完业务后把定时器关掉不然进程退出时会有隐患。5.4 PHP常驻进程的内存泄漏排查PHP做消息消费者有两种形态一种是用框架自带的短生命周期进程如ThinkPHP的php think queue:work处理完一批就退出一种是Swoole Workerman这类常驻内存进程。后者要注意内存泄漏。我在一个Swoole消费服务里遇到过一个怪问题批量消费时内存图形一路往上爬跑几天后OOM被系统杀掉然后消息重新入队再消费再OOM形成了一个死循环。排查过程走了不少弯路。后来用memory_get_usage(true)打印内存快照发现是json_decode出来的对象被业务代码长时间持有引用导致垃圾回收器一直没有把它回收。解决方法是在批量处理完一批消息后主动unset()大变量再调用gc_collect_cycles()。同时给消费者进程设置内存上限超过就平滑重启。这里给一个参考PHP里的gc_collect_cycles()并不是每次都要调它会阻塞进程。更推荐的做法是让Swoole的max_request或者max_coroutine机制自动回收或者定期重启进程。内存问题靠手动到处unset是治标不治本核心还是在代码里避免持有不必要的引用。5.5 消息乱序到达的场景有时候消息队列不保证有序Kafka的单个分区默认有序但业务可能跨分区RabbitMQ的多个消费者并发消费天然乱序。这会给幂等带来一个额外问题后一条消息先到先处理了前一条消息后到却被幂等拦截了。举个具体例子订单取消消息statuscancelled先到处理成功幂等标记写入几分钟后订单创建消息statuscreated才到因为幂等Key相同比如都是order:12345被当成重复消息丢弃了。结果就是订单数据只有取消状态没有创建记录对账直接不平。解决办法有两个层面。第一层如果消息依赖业务先后关系尽量让同一个业务ID的消息进入同一个队列分区/队列保障顺序。第二层如果顺序实在无法保证幂等Key就不能只包含业务ID还要包含一个版本号或时间戳。比如订单消息的幂等Key做成order:12345:v2这样不同版本的消息可以各自处理最终以版本号大的为准。相应的消息体里要带上版本号消费逻辑要按版本号做合并策略。我第一次做幂等方案时完全没考虑到乱序上线后第二天就出了数据异常。从那之后我设计任何消费逻辑都会先问自己一个问题“如果是两条顺序颠倒的消息数据还能不能正确收敛”答案如果是否就必须在幂等Key上做文章。6. 踩坑后的几点补充经验最后再分享几条实战中沉淀下来的经验每条都是用线上事故换来的。第一幂等方案不能只靠一层。Redis的SET NX很高效但Redis可能丢数据、可能主从切换丢key数据库唯一索引很可靠但高并发下冲突处理要花心思。任何生产级系统都应该至少有两层幂等保障一个高性能拦截层 一个强一致兜底层。第二幂等标记绝对不能只依赖消息队列自带的msg_id。我已经强调过如果生产者重试时重新生成了msg_id幂等就失效了。必须在消息体里带上业务幂等IDorder_id、user_iddate等由业务字段组合的ID并且由生产端保证同一笔业务重试时幂等ID不变。第三测试要模拟真实的重试场景。不要只测正常消费一次要专门写脚本把同一条消息连续投递十次、百次、千次观察数据是否始终收敛到同一结果。有条件的话把消费者进程在业务处理过程中强杀kill -9再重新消费看消息是否安全重试。第四线上排查时如果发现消息没有重复消费但数据还是不对先别急着怀疑幂等代码。看看是不是多个消费者实例同时消费了不同分区的同一条消息副本Kafka Consumer Group rebalance时会重复消费或者是消费逻辑本身依赖了外部状态比如时间、余额快照发生变化。幂等只能保证同一条消息重复执行结果一致如果外部环境变了结果天然会变。做PHP这几年我越来越觉得幂等不是一个可以事后补的功能而是从设计第一天就要规划的架构能力。前期多花点时间梳理业务场景、设计幂等Key、确定兜底方案比后来半夜爬起来处理重复数据要划算得多。希望这篇文章能让你在设计自己的PHP消费方案时少走一些弯路。如果你有其他幂等场景的实战经验也欢迎一起交流。