1. 为什么说对账和重试是异步操作的"安全带"
1.1 异步的本质是把失败推迟了,而不是把失败消灭了
很多PHP项目走到一定规模之后,一定会碰到一道坎:异步化。用户注册后发通知邮件、订单支付后推送履约消息、报表生成后回调前端轮询接口,这些场景如果全部同步做,一次请求的耗时会被拖到几秒甚至十几秒,数据库连接、PHP-FPM进程、nginx连接全部被占着,稍微有点并发就直接雪崩。于是大家开始引入队列,把耗时的"后置动作"丢给worker去处理。这个方向是对的,但这里藏着一个很多团队要踩过一轮坑才会真正想明白的问题:异步操作是把可能失败的代码从"同步请求的上下文"里搬到了"另一个进程的上下文",失败并没有消失,只是换了个时间、换了个地方发生。
同步时代,一个操作失败了,用户能立刻看到报错,你也有非常明确的失败现场,日志里一抓一个准。但异步时代不是这样。一条消息进了Redis队列,worker拉出来处理,处理到一半进程被杀、内存溢出、第三方接口超时返回500,这条消息去哪了?如果worker没有做ack确认,消息可能被重复投递;如果做了确认但业务代码已经处理了一部分才报错,那这条消息的状态到底是成功还是失败?更麻烦的是,如果业务逻辑里压根没有写"处理失败后怎么办",消息被消费完就直接从队列里消失了,那这次操作就这样"静默丢失"了。
我在实际项目里见过最典型的场景是:订单支付成功后,系统异步调用仓储WMS接口下发发货单,结果是WMS接口在晚上十点做版本升级,连续半小时返回500。队列里的消息被worker拿出去消费,WMS报错,worker日志打了一条ERROR,然后呢?然后消息就被nack并requeue,或者干脆直接丢弃了。等WMS恢复,这半小时的订单全部要人工去数据库里查出来补下发,那一刻你会非常深刻地理解一句话:没有对账机制的异步系统,本质上是靠运气在干活。
1.2 三个最容易"静默吞消息"的环节
我梳理了自己维护过的几套PHP异步系统,发现消息丢失几乎都发生在下面三个环节,你可以对照自己的系统检查一遍。
第一个环节是投递阶段。业务进程往队列里写消息,这一步看起来简单,但风险在于"先写业务数据还是先写队列"。很多代码是先在数据库里改了订单状态,再调用MQ客户端去发消息,结果MQ客户端因为网络闪断抛了一个异常,订单状态已经改成"已支付",但队列里根本没有那条消息。后续所有依赖这条消息的动作全部没执行。反过来如果先发消息再改数据库,又可能出现消息已经发出去了、但事务回滚了,下游拿到消息处理时发现订单根本不存在。这是异步系统经典的"双写一致性"问题,普遍解法是先写业务库、再发消息,然后靠对账来兜底。
第二个环节是消费阶段。worker取到消息后开始执行业务逻辑,最常见的错误是用了自动ack。RabbitMQ默认的autoAck是消息一投递给消费者就确认,不管你的业务代码有没有处理成功。假如处理函数的异常没有被捕获,消息已经被确认丢掉了,这条数据就消失了。用Redis列表做队列的也有同样的问题,lpop把消息取出来了,后面业务挂了,消息也就没了。所以消费端必须用"业务处理成功后才确认"的模式,而且还要想清楚:确认之前进程崩了怎么办,消息会不会被重复投递。
第三个环节是下游交互阶段。PHP异步任务最常见的形态是调第三方API,比如发短信、发邮件、调WMS、调财务系统。第三方接口超时、限流、返回业务错误,这些都会导致处理失败。如果你连"失败后重试"都没写,那问题直接爆发;如果你写了重试,但重试次数用完了还是没有补偿方案,消息一样会丢。而且第三方接口还有一个更隐蔽的问题:请求超时并不代表服务端没有处理成功。你发了一个请求,1秒没响应就判定失败,但对方可能已经执行完了,只是响应回包慢。这时你重试一次,对方就可能执行了两次。要防这个,必须做幂等控制。
这三个环节只要有一个没堵住,异步操作就不算可靠。到这一步你会明白,标题这句话一点不夸张:只要你在系统里引入了异步,对账和重试就不是"锦上添花",而是"欠的债早晚要还"。
2. 搭一套轻量对账机制:从对账单到差异处理
2.1 对账单怎么设计才够用
对账这个词,早年大家更多是在支付系统里听到:支付渠道每天会给商户一份交易流水,你拿自己的订单流水去比对,差异部分逐笔排查。这套思想完全可以移植到任何异步系统里。思路也很简单:每个异步操作都往一张表里落一条记录,worker处理成功后再更新这条记录的状态。定时任务专门扫描那些"该处理完而没处理完"的记录,把差异找出来。
这张表我习惯叫它async_bill,也就是异步对账单。字段设计不用太复杂,但要够用,下面这个结构是我在多个项目里验证过比较实用的,你可以根据业务调整:
CREATE TABLE `async_bill` ( `id` bigint unsigned NOT NULL AUTO_INCREMENT, `biz_type` varchar(64) NOT NULL COMMENT '业务类型,如 order.refund、sms.send', `biz_id` varchar(64) NOT NULL COMMENT '业务唯一ID,如订单号', `status` tinyint NOT NULL DEFAULT '0' COMMENT '0待处理 1成功 2失败 3已补偿', `payload` json DEFAULT NULL COMMENT '任务入参快照', `retry_count` int NOT NULL DEFAULT '0' COMMENT '已重试次数', `next_retry_time` datetime DEFAULT NULL COMMENT '下次重试时间', `finished_at` datetime DEFAULT NULL COMMENT '完成时间', `created_at` datetime DEFAULT CURRENT_TIMESTAMP, `updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_biz` (`biz_type`, `biz_id`), KEY `idx_status_next` (`status`, `next_retry_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这里有两个字段极易被忽略,我必须单独解释一下。
第一个是biz_id。对账的最小单位不应该是消息ID,而应该是业务ID。消息ID是消息队列里的概念,一条消息丢了就是丢了,无法再定位业务数据。但业务ID不同,比如订单号,就算队列里的消息被消费完删掉了,你仍然可以在业务库里找到这笔订单,重新生成一条消息再投递。这也要求你的biz_id必须设计成具有业务含义且全链路唯一的ID,最好是订单号、用户ID加业务类型组合这类业务主键,而不是自增ID。
第二个是payload。这个字段用来保存任务入参快照。为什么要存快照?因为如果任务是"给订单XXX发短信",而订单状态在等待的这段时间里发生了变化(比如用户退款了、订单被取消了),重试时直接用当前订单数据可能得出完全错误的结果。有了入参快照,重试时可以基于"任务发起那一刻的数据"去处理,保持业务一致性。用JSON存储就好,灵活性高,MySQL 5.7以上对JSON类型的索引和查询支持也够用。
2.2 定时对账任务怎么设计才不会把系统打垮
表设计好了,接下来就是对账任务的实现。很多人一听对账,第一反应是"我写个scheduler每分钟跑一次,把所有待处理的记录捞出来执行一遍不就行了"。这个做法可以用,但直接全表扫描一定出事。原因有两点:一是async_bill表会越来越大,全表扫描越来越慢;二是你每分钟把所有失败任务全捞出来重试,一个下游接口正在故障的时候,重试其实就是"火上浇油",反而会把自己系统的数据库IO打满。
对账任务必须设计成"增量扫描+分批处理+退避控制"。先看一个最基础的调度逻辑:
// app/Console/Commands/DingDuiTask.php public function handle(): void { $now = Carbon::now(); // 1. 扫描超时未完成的任务 $expiredTasks = AsyncBill::query() ->whereIn('status', [AsyncBill::STATUS_PENDING, AsyncBill::STATUS_RETRYING]) ->where('next_retry_time', '<=', $now) ->limit(500) ->get(); foreach ($expiredTasks as $task) { // 2. 检查任务是否真的"该重试" if ($this->shouldSkip($task)) { continue; } // 3. 重新投递 $this->redispatch($task); } }关键点有两个。第一是limit(500),每次最多捞500条,避免一次加载上万条任务把内存打爆;第二是next_retry_time这个字段,把"什么时候可以重试"的判断从内存挪到了数据库索引,任务失败时不是立即重试,而是计算一个延迟时间写入这个字段,只有时间到了才会被扫描到。
我还加了一个shouldSkip方法,这是很多人不会注意到但非常实用的细节。对账重试不等于无脑重试,扫描到任务后应该先检查一下"这笔业务当前是否还允许执行"。举个例子:订单超时未支付被自动关闭了,那这笔订单关联的"推送优惠券"异步任务就没有执行的意义了,直接把它标记成status=3 已补偿(补偿结果就是"无需执行")才对。这个检查放在重试前,可以避免大量无效重试。
分批处理还有一个好处:当你发现有大量任务同时失败时,分批能起到"平滑流量"的作用,不会让一波补偿任务瞬间全部打到下游。我见过有团队把重试写成死循环,如果接口恢复慢,短时间上万次重试请求直接打到第三方,被对方拉黑。分批加退避,是对下游最基本的礼貌。
2.3 差异数据怎么处理才不会引发二次问题
对账跑完,一定会有差异数据,也就是状态还是"待处理"或"失败"、但时间已经超时的任务。处理差异数据的原则是:能自动补偿的自动补偿,不能自动补偿的一定要落人工工单,并且自动补偿必须有兜底上限。
我在项目里把差异数据分成三类处理:
| 差异类型 | 判定条件 | 处理方式 |
|---|---|---|
| 可重试 | 失败原因属于临时性错误(接口超时、网络抖动),重试次数未到上限 | 按退避策略重新投递 |
| 需确认 | 失败原因属于业务性错误(参数错误、状态不匹配),重试意义不大 | 置为失败状态,通知值班人员人工核查 |
| 已过期 | 任务发起时间超过业务有效期,继续执行没有意义 | 标记为已补偿,记录原因,归档 |
第三类很容易被忽略。比如"注册后48小时未登录发优惠券"这个任务,用户在第49小时完成了首次登录,此时这个任务已经过了有效期,再执行反而会多发一张优惠券。所以对账脚本一定要带业务有效期的判断,过期任务直接关掉,不能重试。
另外,自动补偿的每一次动作都要留痕。我会在async_bill旁边放一张async_retry_log表,记录每次重试的时间、结果、返回的错误信息。这张表的作用在提交故障报告的时候会非常大——你可以精确地告诉别人"这笔任务在第几次重试时成功、第几次失败、失败原因是什么"。没有留痕的对账,排查问题的成本会高出一个量级。
3. 重试策略的边界设计:次数、间隔、退避与幂等
3.1 重试间隔为什么不能是固定值
讲完对账,接着讲重试。重试机制看似简单:失败了再来一次,但"怎么再来、来几次、间隔多久"这三个问题,直接决定了你的异步系统是"可靠"还是"雪崩加速器"。
先说间隔。新手最容易写出来的重试是失败后等5秒,再失败再等5秒,反复5次就放弃。这种做法的问题在于:如果下游接口真的故障了,5秒一次的频率只会不断给故障系统施加压力,让它更不容易恢复。
正确的做法是指数退避,也就是每次重试的间隔按指数增长。比如第1次失败后等2秒,第2次等4秒,第3次等8秒,再加上一个随机抖动,避免多个任务在同一时刻一起重试造成惊群效应:
public function nextRetryTime(int $retryCount): Carbon { // 基础间隔 2 秒,指数增长,最大不超过 5 分钟 $baseSeconds = 2; $interval = min($baseSeconds * pow(2, $retryCount), 300); // 随机抖动:在 0.8 ~ 1.2 倍之间浮动 $interval *= mt_rand(80, 120) / 100; return Carbon::now()->addSeconds((int)$interval); }这个pow(2, $retryCount)就是指数退避的精髓:给失败的系统留出恢复时间,同时避免自爆。上限5分钟是我在项目里常用的一个阈值,超过这个间隔基本就可以判定为"长时间故障",需要人工介入了。
再说次数。重试次数不是越多越好,而是要看业务容忍度。短信通知类的任务,重试3到5次就够了;订单同步到财务系统这种核心数据,可以重试10次甚至更多。但不管重试多少次,都要有一个"终极兜底",就是下一节要说的死信处理。
3.2 幂等设计是重试的前提
有个很反直觉的问题:重试本身会引入新的错误,最常见的错误就是重复执行。我在工作中反复强调一句话:先有幂等,再有重试。没有幂等保障的重试,其实是拿"数据错乱的风险"去换"任务完成的概率",完全不值得。
拿PHP操作MySQL举例。一个"给用户增加100积分"的任务,因为网络抖动了,worker执行时数据库连接断开了。你的代码可能会这么写:
DB::table('users')->where('id', $userId)->increment('points', 100);这条SQL执行后,连接断了,SQL到底有没有成功?不知道。于是你重试,又执行了一次increment,结果用户多了200积分。这种错误比重试失败还可怕——任务是"成功"的,但结果是错的。
要解决这个问题,必须引入幂等机制。最常用的做法是在业务表里加一个"处理流水表"或者"幂等键唯一索引"。每次处理任务时先插入一条带业务唯一ID的流水记录,插入成功说明这笔任务还没处理过,可以继续;插入失败说明处理过,直接视为成功跳过:
public function handlePointsTask(string $taskId, int $userId, int $points): void { // 幂等控制:taskId 是任务唯一ID,有唯一索引 $inserted = DB::table('points_flow') ->insertOrIgnore([ 'task_id' => $taskId, 'user_id' => $userId, 'points' => $points, 'created_at' => date('Y-m-d H:i:s'), ]); // 插入失败,说明这个任务已经处理过了,不再重复处理 if (!$inserted) { return; } DB::table('users')->where('id', $userId)->increment('points', $points); }注意insertOrIgnore(MySQL里也可以用INSERT IGNORE或者ON DUPLICATE KEY UPDATE)配合task_id的唯一索引,这是PHP里做幂等最简单的方案之一。只要你保证"取消息处理"和"写业务结果"在同一个事务里或者有幂等兜底,重试多少次都不会出事。
还有一类幂等比较隐蔽,是跨语言的ID生成一致性。比如PHP生成的业务唯一ID传到Java侧做MD5,如果两边字符串编码不一致(PHP的md5()默认处理的是字节串,Java的MessageDigest处理的是UTF-8字节),同一个字符串可能生成完全不同的摘要,导致下游幂等键值不一致。我之前排查过一类"偶发重复"的问题,根因就是PHP侧把中文字符串直接传给了Java侧做MD5,两边编码不同。稳妥的做法是统一在PHP侧生成好摘要再传递,不要依赖下游再算一遍。
3.3 真正该做的"重试终结者":死信与人工补偿
重试次数用完了怎么办?很多系统的答案是什么都不办,任务被丢弃,留下一句日志。这就是我前面说的"静默丢失"。一个可靠的重试系统,必须给失败的异步任务一个明确的去处——死信。
如果你用的是RabbitMQ,最直接的方式是给队列配置死信交换机:
$arguments = new AMQPTable([ 'x-dead-letter-exchange' => 'dlx.exchange', 'x-dead-letter-routing-key' => 'dlx.routing', 'x-message-ttl' => 30000, ]); $queue->setArguments($arguments);这样消息重试N次仍然失败后,会自动被投递到死信队列。死信队列的消费者做什么?我建议不要自动重试,而是把死信消息落库,再改成"人工处理"状态,并通过钉钉/企业微信/webhook通知到值班人。注意,人工处理是最后一道防线,但一定不能省。
还有一个很多做RabbitMQ的人会困惑的问题:怎么取当前消息的重试次数?RabbitMQ本身没有直接的"重试次数"字段,但消息每次被拒绝并requeue时,broker会往消息头部加一个x-death数组,里面记录着被拒绝的次数和原因。你可以这样读它:
public function getRetryCount(AMQPMessage $message): int { $death = $message->get('x-death'); if (empty($death)) { return 0; } return count($death); }但要提醒一句,x-death只有在消息经历过路由失败、nack并requeue这类操作后才会记录,如果你用的是TTL+死信队列做延迟重试,每次消息从死信队列重新投递都会被认为是"新消息",重试次数的判断逻辑可能会失效。这也是我不建议单纯依赖MQ自身的重试机制做可靠投递的原因——任务状态和重试次数,始终应该以你的对账单async_bill表为准,而不是以MQ头信息为准。MQ是执行通道,你的数据库才是最终真相。
4. 三类最常见异步场景的落地模板
4.1 队列消费型异步:从Redis到RabbitMQ的消费可靠性
先看最常见的场景:通过消息队列做异步消费。PHP生态里两种主流方案,一种是Redis的LPUSH/BRPOP列表,一种是RabbitMQ。
Redis列表作为队列,优点是轻量、部署简单,小型项目跑起来非常舒服。但它的可靠性需要你自己写代码来保证。我见过最粗犷的写法是:
$task = Redis::lpop('task_queue'); $this->handle($task);这条命令执行完,任务已经从列表里删除了。如果handle方法抛异常,任务是找不回来的。要解决这个问题,至少要改成两步:先brpoplpush把任务从主线列表挪到一个"处理中列表",处理成功后再lrem删除处理中列表里的任务;如果进程中途挂了,启动时扫描处理中列表,把里面的任务重新放回主线列表:
// 消费端:先从队列取出,放入 processing 队列 $task = Redis::brpoplpush('task_queue', 'task_processing', 0); try { $this->handle($task); // 处理成功,从 processing 队列移除 Redis::lrem('task_processing', 0, $task); } catch (\Throwable $e) { // 处理失败,重新放回队列,或者按重试策略处理 Redis::lrem('task_processing', 0, $task); $this->retry($task); }这套逻辑正是Redis官方推荐的"可靠队列"模式,本质就是"先备份再处理,成功才删除",无论进程怎么崩,消息都不会凭空消失。
如果你用的是RabbitMQ,消费端一定要注意确认模式。PHP的php-amqplib默认是自动确认,消息一投递给你就ACK了,处理失败消息就丢了。改成手动确认:
$channel->basic_qos(null, 1, null); // 一次只取一条,处理完再取下一条 $channel->basic_consume('task_queue', '', false, false, false, false, function (AMQPMessage $message) { try { $this->handle($message->body); $message->ack(); // 处理成功才确认 } catch (\Throwable $e) { // 重试次数没到,nack 并重新入队 $message->nack(true); } });basic_qos(null, 1, null)这个prefetch设置非常关键。如果不设置,RabbitMQ会把一堆消息一次性全推给消费者,消费者处理不过来时消息堆积在进程内存里,一旦进程崩溃,这批消息全部丢失。设置成1,RabbitMQ每次只给一个消费者投递一条,处理完确认后才给下一条,配合手动确认,配合死信队列,这才能算一个"不会丢消息"的消费端。
4.2 第三方回调型异步:状态机写清楚,回调才不会"鬼打墙"
第二类常见异步场景是"发请求给第三方,然后等第三方回调通知结果"。典型的就是接入支付:你先向支付网关发起支付请求,用户付款后,支付网关异步回调你的接口,通知你交易结果。
这类场景最容易出问题的不是技术,而是状态机的流转没有设计清楚。我见过很多团队的订单状态就是一组常量,回调来了就直接改状态,结果重复回调、乱序回调一来,订单状态被打得乱七八糟。比如用户付款成功后网关回调通知,你的代码把订单改成"已支付";但这时候用户发起了退款,订单状态变成"退款中";退款还没完成,网关又因为重试机制重复推送了一次"支付成功"回调,你的代码一看"哦,支付成功",直接又把订单改回"已支付"。这就是典型的"状态机没有防护"。
正确的做法是给状态流转画一条明确的边界,回调处理时先判断当前状态允许执行哪些操作。PHP里可以用枚举类来做:
enum OrderStatus: string { case Pending = 'pending'; case Paid = 'paid'; case Refunding = 'refunding'; case Refunded = 'refunded'; case Closed = 'closed'; public function canTransitionTo(self $target): bool { return match ($this) { self::Pending => in_array($target, [self::Paid, self::Closed], true), self::Paid => in_array($target, [self::Refunding, self::Refunded], true), self::Refunding => in_array($target, [self::Refunded], true), default => false, }; } }然后在回调处理方法里,先做状态迁移校验:
$current = Order::find($orderId); if (!$current->status->canTransitionTo(OrderStatus::Paid)) { // 记录日志,返回成功,避免第三方无休止重推 return $this->success(); } $current->status = OrderStatus::Paid; $current->save();这样无论回调推多少次、顺序怎么乱,状态流转都在可控范围内。另外,第三方回调还有一个很现实的问题:回调接口即使处理失败了,也要尽量返回HTTP 200给第三方。很多支付网关的规则是"回调收到非200响应就认为失败,然后反复重推,推好几次还失败就停止推送"。你返回500,第三方就重试,重试也是同样失败,最后它不推了,你这边的订单就永远卡在"待支付"状态。正确的策略是:回调接口只负责接收事件、落库、分发,真正的业务处理交给队列异步做,即使业务处理失败,接口也返回200表示"我收到了"。处理失败的部分由你的对账任务去补偿。
4.3 内部定时任务型异步:Scheduler的并发锁与优雅重启
第三类异步很多人没意识到它也需要对账:内部定时任务。PHP做定时任务,最普遍的方式是crontab定时执行一个Artisan命令(Laravel)或者一个CLI脚本。这类任务的问题是:如果任务执行时长超过了crontab的执行间隔,下一个任务实例就会被启动,两个任务同时跑,数据就被重复处理。
解决并发重叠有两个思路。第一个是用"运行锁",在任务开始时往Redis里写一个带过期时间的锁,结束时释放锁,如果获取不到锁说明上一个实例还在跑,直接退出:
public function handle(): void { $lock = Redis::set('scheduler:orderSync', '1', 'EX', 3600, 'NX'); if (!$lock) { $this->info('上一次任务还未执行完毕,本次退出'); return; } try { // 业务逻辑 } finally { Redis::del('scheduler:orderSync'); } }第二个是把任务设计成"可断点续跑"。因为PHP的CLI脚本执行时间是有上限的(除非你手动set_time_limit(0),但不建议),一个同步大批量数据跑几十分钟的任务,很容易在中间被系统杀掉。如果它从头开始跑,前面处理过的数据就会重复处理;如果从断点继续跑,就必须有记录进度的地方。我建议把"分批处理游标"写到对账单或者专用的进度表里,每处理完一批就更新游标,任务重启时先读游标,从上次位置继续。这本质也是一个"对账"思想:用记录状态来对抗执行过程的不确定性。
定时任务还有一个非常容易被忽略的坑:异常不捕获。一个crontab任务,某一次执行因为一个数据异常抛了未捕获异常,整个任务终止,这一轮所有该处理的数据全部没处理,而且没有任何对账机制提醒你有事情漏掉了。所以定时任务的主逻辑一定要包在try/catch里,并且把异常转化成任务记录,写到对账单表里,而不是让外层框架把异常吞掉或者直接打到stderr里不管。
5. 一次线上消息丢失的完整排查复盘
5.1 事故现场:用户收不到短信,任务"凭空消失"
纸上谈兵讲再多,不如直接复盘一次我在真实项目中排查过的消息丢失事故。那次事故的背景是:系统通过RabbitMQ异步发送短信通知,高峰期每天大概5万条消息。某天业务方反馈:"一部分用户收不到短信,占比大概2%。"查了消息队列的监控,队列没有积压,worker也没有报错,但业务库里的短信发送记录就是缺了一部分。
一开始所有人的第一反应都是"短信服务商那边漏发了",和对方花了两天时间来回比对,结果对方甩过来一份他们平台的发送日志——他们根本没收到这些短信的请求。问题回到了我们自己这边:短信请求没有发出去。
5.2 从日志到根源:问题出在"看起来没问题"的那条消息
我接手排查之后,先做了两件事。第一,把高峰期丢失时间段内的RabbitMQ消息日志拉出来,看看那些"丢失的短信"对应的消息是什么时候被投递的、被谁消费的、消费后做了什么。第二,查业务表里对应的订单状态和发送状态,确认这些业务在发送短信之前的前置操作是否正常完成。
排查结果很意外:这些短信消息在RabbitMQ里确实存在过,也确实被worker消费并确认了。消息没有在队列里堆积,消费也成功了,那为什么短信服务商没收到请求?
问题出在worker处理逻辑的内部。仔细看过代码后发现,业务逻辑是这样的:worker拿到消息后,先查订单,再查用户手机号,然后拼装短信内容,最后调用短信服务商的HTTP接口发请求。由于代码写得比较"省事",调用HTTP接口用的是同步curl,但超时时间设置成了5秒,而短信服务商接口在高峰期响应经常达到6到7秒。超过5秒后,curl抛了超时异常,但异常被一个巨大的catch (\Exception $e)吞掉了,catch里只写了一行日志log('短信发送异常'),没有重试、没有重新入队、没有标记失败状态,消息就这么被消费完毕,ACK也发了。
更讽刺的是,业务数据库里有一条状态是"处理成功"的记录——因为进入catch块之后,代码没有往外抛异常,业务流水被当作"流程正常结束"写了进去。这就是最典型的"假成功":消息被消费了、业务看起来走完了、但实际上真正想做的事情一件都没做成。
5.3 修复与加固:从消费逻辑到对账体系的一次重构
定位到根因后,修复分成三步:止血、填坑、建立长效机制。
止血动作很简单,把worker里的curl超时时间从5秒改为10秒,同时把吞掉异常的catch块改掉——允许重试的异常一定要往外抛,让消息被nack重新入队。两个小时内在线修复完成,当天的短信补发靠临时脚本处理。
填坑动作是补上对账机制。我在短信业务表旁边新增了async_bill表,每次投递短信任务时先在表里落一条"待处理"记录,worker处理成功后更新成"已成功"。对账任务每5分钟跑一次,把这些短信任务的记录和短信服务商的实际发送回执做比对,发现"我方记录成功、服务商没有回执"的情况,自动重新投递一次。这个机制上线后,短信丢失的问题彻底消失了,后面还顺带发现了另外两个被隐藏了很久的异步缺陷:有一个定时任务每个月会漏跑一次,还有一个回调处理逻辑会对同一笔退款重复入账。这些全是靠对账比对暴露出来的。
长效机制是整个团队定了一条铁律,对应这篇博文的标题:任何异步操作,都必须同时具备对账机制和重试机制,缺一不可。新增一个异步场景时,代码评审里多了一条硬性检查项:"这个异步操作如果失败,多久能发现?怎么发现?失败后怎么补偿?"这三个问题答不上来,代码不允许合并。
6. 一个稳固的异步系统,最后拼的是补偿兜底能力
回到最开头那句话,异步操作的本质决定了它一定会有失败的可能。我做了这么多年PHP,越来越确认一个判断:衡量一个异步系统可靠不可靠,看的不是它正常的时候跑得多流畅,而是它失败的时候能不能被快速发现、能不能被安全补齐。对账和重试,就是这套"失败响应能力"的两根支柱。
搭建这套体系的时候,有几点经验想分享给正在做的朋友。
第一,对账表最好在异步系统设计的第一天就建好,不要等出了问题再补。补的代价是巨大的:已有的线上数据状态不完整,你不知道哪些消息是真正成功、哪些是假成功,只能靠人工去核对,这比你设计阶段多花半天时间建表要昂贵得多。
第二,重试策略一定要配幂等设计,而且幂等键要选对。我见过有人用"用户ID+时间戳"做幂等键,结果同一秒内两个不同任务对同一个用户操作时被误判为重复,反而把正常请求拦掉了。幂等键要用业务上真正唯一的ID,比如订单号、任务ID、流水号。实在没有唯一ID,生成一个UUID也行,但必须保证每次重试时携带的UUID是同一个。
第三,对账任务本身要监控起来。很讽刺的一件事是,我见过有团队对账脚本本身挂了,等了一个月才发现,期间所有异步问题全部"裸奔"。对账任务在处理差异数据之前,先给自己写个心跳,或者干脆把"对账任务没异常退出"也作为一个异步任务去监控。做异步系统的第一个原则就是:所有靠自动化解决的问题,自动化本身也必须被自动化监控。
第四,善用死信。无论你用的是RabbitMQ、Kafka还是Redis队列,一定要为"重试到死"的消息设计一个明确去向,可以是死信队列,可以是对账单里的失败状态,但绝对不能是"什么都不做"。我在前面提到的短信事故里,最致命的并不是curl超时,而是超时之后没有任何失败出口。失败不可怕,可怕的是失败之后系统假装一切正常。
最后再分享一个我个人的习惯。我会在项目的运维告警群里接一个webhook,当死信队列有消息进入时,机器人自动发一条告警。这条告警可能每周会响几次,但每次响起,都代表我们的异步系统正在履行它的承诺:把失败暴露出来,而不是藏起来。刚开始团队觉得吵,后来渐渐习惯,反而每个人看到告警都会下意识地去查一下是不是自己的任务处理逻辑有问题。整个团队的代码质量,就这样被一套对账重试机制反向推动着提升。这就是我理解的"庖丁解牛"——不是把代码写得多么精妙,而是把失败的路径一条条解剖清楚,让每条路都有兜底。