PHP异步系统必备:对账与重试机制的设计与实践
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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这里有两个字段极易被忽略我必须单独解释一下。第一个是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方法这是很多人不会注意到但非常实用的细节。对账重试不等于无脑重试扫描到任务后应该先检查一下这笔业务当前是否还允许执行。举个例子订单超时未支付被自动关闭了那这笔订单关联的推送优惠券异步任务就没有执行的意义了直接把它标记成status3 已补偿补偿结果就是无需执行才对。这个检查放在重试前可以避免大量无效重试。分批处理还有一个好处当你发现有大量任务同时失败时分批能起到平滑流量的作用不会让一波补偿任务瞬间全部打到下游。我见过有团队把重试写成死循环如果接口恢复慢短时间上万次重试请求直接打到第三方被对方拉黑。分批加退避是对下游最基本的礼貌。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); }注意insertOrIgnoreMySQL里也可以用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会把一堆消息一次性全推给消费者消费者处理不过来时消息堆积在进程内存里一旦进程崩溃这批消息全部丢失。设置成1RabbitMQ每次只给一个消费者投递一条处理完确认后才给下一条配合手动确认配合死信队列这才能算一个不会丢消息的消费端。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当死信队列有消息进入时机器人自动发一条告警。这条告警可能每周会响几次但每次响起都代表我们的异步系统正在履行它的承诺把失败暴露出来而不是藏起来。刚开始团队觉得吵后来渐渐习惯反而每个人看到告警都会下意识地去查一下是不是自己的任务处理逻辑有问题。整个团队的代码质量就这样被一套对账重试机制反向推动着提升。这就是我理解的庖丁解牛——不是把代码写得多么精妙而是把失败的路径一条条解剖清楚让每条路都有兜底。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →