ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

PHP异步操作必知:对账与重试机制,守住数据一致性

PHP异步操作必知:对账与重试机制,守住数据一致性 1. 先把账算清楚异步操作到底会怎么丢做 PHP 的兄弟应该都有过这种经历用户提交了一个请求代码里file_get_contents或者 Guzzle 发了一个远程调用返回超时你心里一紧然后catch到异常记了日志就只能跟用户说“稍后重试”。可真到了“稍后”请求可能根本就没发出去也可能发出去了对方没收到也可能对方收到了也处理完了只是回包丢了。什么都可能发生你什么都做不了。前面这段就是我要聊的“异步操作没有对账/重试”的典型状态。先亮明观点任何异步操作只要你没有补偿机制它就是一个定时炸弹。不知道哪天会炸炸的时候一定是在凌晨三点而且用户比你先发现。1.1 从一次支付回调事故说起去年我接手过一个支付相关的 PHP 项目业务逻辑本身不算复杂用户在App下单生成支付单调第三方支付接口然后等异步回调。因为这个项目是几个同事接力写的最早的版本是“回调里面写业务”——也就是说用户支付成功后第三方网关回调用一个 URL 通知我们我们在回调里直接把订单状态改成已支付然后给用户加余额。听起来没什么问题是吧问题出在一次网关侧升级。对方发布新版本之后回调接口偶尔会超时重发而我们回调脚本里有很长一段业务逻辑查订单、校验金额、改状态、加积分、发优惠券、给运营发通知……这段逻辑一执行就是两三秒PHP-FPM 默认max_execution_time是 30 秒前面几次还扛得住等接口负载一高执行时间超过网关等待时间网关那边就判定失败开始重试。我们这边呢由于没有做幂等同一个回调进来两次积分加了两遍优惠券发了两份。最后怎么发现的不是我们的监控是运营对账的时候发现券的发放数量比订单数量多了一倍。这是典型的数据不一致而它本质上就是异步操作缺少对账和重试机制造成的。1.2 消息生命周期的三个丢点做异步操作不管你是调接口也好、丢队列也罢消息从产生到最终处理成功至少要经过三个环节投递、存储、消费。每一个环节都可能丢消息而且丢的原因千奇百怪。先说投递。调用方发起请求网络超时、连接被拒、DNS解析失败这些是网络层面的问题还有一种是业务层面的比如你往 RabbitMQ 里发布消息但交换机名字写错了消息直接进了黑洞。最麻烦的是“超时”这种情况因为超时不代表对方没收到你没法判断是发出去没送达还是送达了回包丢了。这时候如果你直接重发就会造成重复如果你不重发就会漏单。两种选择都是错的唯一正确的做法是先把状态落库再异步确认。再说存储。消息队列本身也可能丢消息。比如 RabbitMQ 默认不是持久化的你发了个消息到内存队列Broker 一重启消息就没了。Redis 做队列也类似LPUSH进去的消息没来得及BRPOPRedis 宕机了如果没开 AOF 并且刷盘策略不是everysec消息也可能直接蒸发。第三是消费端。PHP 常驻进程消费队列代码里执行了业务逻辑但没来得及确认ACK进程崩溃了消息重新入队这里可能导致重复消费反过来代码里业务逻辑执行到一半确认了 ACK但后半段逻辑比如发短信、调外部接口没执行完消息就丢了——这是最典型的“半截消费”。所以你看异步操作丢消息是常态不丢才是意外。没有对账机制你根本没法发现这些丢没有重试机制你发现了也补不回来。1.3 对账不等于重试两者缺一不可很多人觉得“我们加了重试了”但这其实是两码事。重试解决的是“单次请求失败后怎么把它再送一次”对账解决的是“仓储记录和实际业务结果之间出现偏差后怎么发现偏差并校正”。说白了重试是“点对点”的补救对账是“面”上的巡逻。举个生活化的例子你让快递员送一份合同路上快递员跟你说“车坏了明天再送”这是重试但万一快递员把合同弄丢了他以为送到了你也以为他送到了那这事儿重试一万次也没用必须得有一个机制定期去核实“合同到底到没到对方手上”——这就是对账。所以在真实的系统里对账和重试通常是一套组合拳日常靠重试保底定时靠对账兜底对账发现漏了之后再触发重试。只做重试不做对账会出现大量的“以为成功”的脏数据只做对账不做重试对完账你也不知道该怎么处理这些脏数据。只有两者配合异步操作才能真正称得上“可靠”。2. 设计对账系统状态机是地基聊完了为什么接下来聊怎么落地。一套对账系统听起来高大上但落到 PHP 项目里核心其实就是一张表 一个状态机 一个定时任务。没错就这么朴素。2.1 一张任务表长这样我推荐的做法是任何需要异步处理的操作在业务数据落库的同时往一张统一的异步任务表里插一条记录。这张表就是整个对账系统的依据。字段设计如下字段名类型说明idbigint主键自增biz_typevarchar(32)业务类型如payment、sms、refund等biz_idvarchar(64)业务ID关联具体业务单据event_idvarchar(64)全局唯一的事件ID用于幂等payloadjson需要异步处理的完整请求参数statustinyint状态0待处理、1处理中、2成功、3失败、4已补偿retry_countint已重试次数next_retry_timedatetime下次重试时间last_errorvarchar(512)最后一次错误信息created_atdatetime创建时间updated_atdatetime更新时间这里有个关键点payload一定要存完整。因为重试的时候你不能保证原来的调用方还在也不能保证原始请求参数还能原样取到。把上下文快照存进任务表重试逻辑才能做到自包含。我见过不少项目在重试时去查订单表结果订单状态已经被后续流程改了重试时用的参数已经不是当时的参数了导致数据错乱。event_id也很重要它就是一个全局唯一ID由业务方生成消费端用这个ID做幂等。这样就算同一笔异步操作被投递了800次最终也只会生效一次。2.2 状态流转别整太复杂任务表里的状态机简单至上。我就用这么几个状态pending0刚插入等待处理。此时可能还没投递也可能投递了但没确认。processing1正在处理中。消费端开始执行但没给最终结果。success2处理成功终态。failed3处理失败已达最大重试次数等待人工介入。compensated4人工或补偿程序介入处理后确认已修复。流转规则初始状态是pending投递前先把状态置为processing防止重复投递。消费端处理成功后回写success。消费端处理失败retry_count加1next_retry_time更新为下次重试时间状态回到pending也可以保持processing看你喜好。重试次数超过阈值状态置为failed进入人工对账单。人工处理完状态置为compensated。有些文章喜欢加cancelled、timeout、pending_retry之类的状态我不太建议。状态越多流转判断越复杂还得写各种状态机守卫而且对 PHP 项目来说收益不大。状态少一点对账SQL写起来也简单排查问题的时候一眼就能看懂。2.3 幂等别让重试变成灾难没有幂等设计重试机制就是“越描越黑”。举个例子你给用户发优惠券发送成功后消费端崩了没来得及回写任务状态。定时对账发现这笔任务还是processing于是重新投递。这时候如果消费端不判断“这个用户有没有领过券”用户就会收到两张一模一样的券。幂等的实现方式有三种从简单到复杂排列第一种利用数据库唯一约束。比如你要插入一条“用户优惠券”记录那就给(user_id, coupon_template_id)加唯一索引。重复插入时数据库会报错你在代码里捕获这个错误判断为“已处理过”直接返回成功。第二种用 Redis 做幂等标记。消费开始时先SETNX event_id 1如果返回成功说明第一次处理继续如果返回失败说明已经处理过直接 ACK。注意要给这个 key 设置过期时间比如24小时过期后如果消息被重放依然有概率重复所以最好和数据库唯一约束配合使用。第三种查业务表判断。比如支付回调先查订单状态是不是已经是“已支付”如果是就直接返回成功不再处理。三种方式没有绝对优劣最常见的是“Redis 标记 数据库唯一约束”双保险。Redis 挡掉大部分重复请求数据库兜底保证最终一致性。同时要注意幂等判断必须和业务操作放在同一个事务里否则判断完、还没插入业务数据进程就崩了消息重放还是会重复。2.4 对账扫描怎么跑避免全表扫定时任务定期扫描任务表把状态和next_retry_time符合条件的记录捞出来重新投递。这个逻辑比较简单但有一个性能问题任务表会越来越大全表扫描很快就不行了。我的做法是给状态和重试时间建联合索引SQL大致这样SELECT * FROM async_task WHERE status IN (0, 1) AND next_retry_time NOW() ORDER BY next_retry_time ASC LIMIT 500;这里有个小细节千万不要一次捞太多。定时任务的单次执行时间和 MySQL 的连接时长都有限处理完一批就歇一歇。500条不够就多跑几轮每轮之间usleep个100毫秒给数据库和服务端一个喘息的机会。扫描出任务后怎么做补偿两个选择如果任务表里的payload已经包含了完整参数直接调用一个内部处理器即可。如果任务对应的是一个外部调用需要重新投递到队列由队列消费者处理。我更推荐前一种。因为对账触发的是“已知失败”的故障恢复直接走内部处理器不需要再经过队列的投递确认环节链路更短成功率更高。另外扫描频率也要控制。实时性要求高的业务比如支付成功后的发货建议每分钟扫一次对账容忍半小时、一小时的业务5分钟或者10分钟扫一次也没问题。扫描间隔越短系统压力越大但数据一致性越好需要做权衡。3. 重试机制的工程落地对账系统负责发现漏网之鱼重试机制负责把鱼捞回来。这一节重点说重试本身怎么设计。3.1 重试分三类场景各不同我把重试分成三种前置重试调用方在发出请求之前自己先做几次防护性重试。比如网络抖动导致的连接超时立刻重试一次大概率能成功。这种重试适合放在 HTTP 客户端层Guzzle 可以直接配置retries参数。但注意前置重试只适合“非幂等也无所谓”的读操作写操作慎用。队列重试消息进入队列后消费失败自动重试。这是最标准的异步重试方式RabbitMQ、Kafka、Redis 队列都有相应的机制。失败的消息会回到队列尾部重新被消费。后置重试定时任务或对账系统扫描发现失败任务后主动触发重试。这种重试是兜底式的不依赖队列的重试机制逻辑上是“重新执行一个待办的异步操作”。三种重试的粒度不同作用也不同。前置重试解决临时抖动队列重试解决消费失败后置重试解决前两者都没覆盖的“漏网”情况。一套完整体系应该三者并存而不是只靠一种。3.2 退避算法不要一上来就疯狂重试重试要讲策略。实践中最忌讳的是“失败后立即重试”。你想想如果对方接口已经挂了你就算重试100次也只是给对方制造更大的压力还把自己机器的线程池打满。这就是著名的“重试风暴”。常用的退避策略有三种固定间隔每次重试间隔相同比如10秒。实现最简单但在服务恢复了之后响应不够快。指数退避间隔按指数增长比如第一次1秒第二次2秒第三次4秒第四次8秒……一直翻倍。实现也比较简单能有效避免重试风暴。指数退避 抖动在指数退避的基础上加随机偏移比如min(60000, 1000 * 2^retry_count) rand(0, 500)毫秒。抖动的价值在于多个任务同时失败时不会在同一个时间点扎堆重试而是分散开让系统慢慢恢复。我推荐第三种也不复杂公式就一行next_retry_time time() min(3600 * pow(2, retry_count), 86400) rand(0, 10)注意设置重试上限。我给自己的默认配置是最多重试7次间隔依次为 1s、4s、16s、64s、5min、30min、2h。超过7次直接置为failed交给人工处理。不是说7次一定合理但大部分业务场景下如果7次都失败了问题大概率不是临时性的再重试也是浪费时间。具体次数可以根据业务的重要程度调整但一定不要设成“无限重试”否则任务表里会积累出一堆僵尸数据谁也处理不完。3.3 RabbitMQ 里的重试与死信配置PHP 项目里用 RabbitMQ 做队列很常见。RabbitMQ 本身没有内置“消费失败自动延迟重试”的功能常见的做法是用“TTL 死信队列”组合实现延迟重试。大致流程是这样的消费者A消费业务队列如果处理失败不 ACK而是把消息投递到一个“延迟队列”。延迟队列设置了消息TTL比如10秒消息到期后成为死信被路由回业务队列消费者A再次收到这条消息实现了“延迟重试”。关键配置是业务队列绑定死信交换机// 声明业务队列设置死信交换机和路由键 $channel-queue_declare( business_queue, false, true, // durable false, false, false, [ x-dead-letter-exchange retry_exchange, x-dead-letter-routing-key business_queue, ] ); // 声明延迟队列设置TTL为10秒 $channel-queue_declare(retry_queue, false, true, false, false, false, [ x-message-ttl 10000, ]); // 将延迟队列绑定到死信交换机死信消息会转发到 business_queue $channel-queue_bind(retry_queue, retry_exchange, business_queue);这段配置的含义消息进入business_queue消费者处理失败后nack消息消息不会立刻回到队列头部而是被投递到retry_exchange根据路由键进入retry_queue。10秒钟后这条消息TTL到期成为死信死信被发送到retry_exchange再路由回business_queue消费者重新收到它。注意这里有个坑。RabbitMQ 的死信转发不会修改消息原有内容但会把x-death等死信信息加到 headers 里。消费者如果想记录“这是第几次重试”可以从 headers 里拿x-death数组的长度。很多人忽略了这个字段导致重试次数全靠自己数这在小规模场景下够用但在复杂场景下还是建议把重试次数写在消息体里由消费者自己更新。如果重试超过一定次数怎么办标准做法是“最大重试次数检查”“死信队列”。上面配置里的retry_exchange实际上承担了两层职责既负责延迟重试也负责最终丢弃。你可以在消费者里判断重试次数超过N次后手动把消息投递到“人工处理队列”同时 ACK 掉当前消息不再让它进入延迟重试循环。3.4 Redis 队列 PHP 定时器轻量级重试不是所有项目都有 RabbitMQ很多 PHP 项目就是 Redis 里一个 List 当队列用。这种场景下做重试我的方案是“Redis List delayed queue 定时扫描”。主流程// 生产者消息入队 $redis-lpush(async_queue, json_encode([ event_id $eventId, biz_type refund, payload $payload, ]));消费者用brpop阻塞取消息。处理失败时不把消息丢弃而是计算下次重试时间存入一个 ZSet// 消费者处理失败 $retryCount $data[retry_count] ?? 0; $nextRetryTime time() min(3600 * pow(2, $retryCount), 86400); $redis-zadd(async_delay_queue, $nextRetryTime, json_encode($data));然后有一个定时脚本每次执行时从 ZSet 里取所有score time()的元素重新推入主队列// 扫到期的消息 $expired $redis-zrangebyscore(async_delay_queue, 0, time()); foreach ($expired as $msg) { $redis-zrem(async_delay_queue, $msg); $redis-lpush(async_queue, $msg); }这套方案实现简单不依赖额外的中间件唯一的问题是把“消息延迟重试”这件事的可靠性完全交给了 Redis 本身。Redis 如果挂了ZSet 里的消息自然也没了。所以如果你的项目对数据的可靠性要求很高建议还是上 RabbitMQ 或者 Kafka 这类专业的消息中间件。但对于用 Redis 已经扛了很久的小项目这套方案足够。3.5 手动补偿入口最后一道保险不管自动重试机制做得多完善最终一定会有一些任务“卡死”在 failed 状态。这时候需要提供一个手动补偿的入口最好做成一个独立的管理页面列出所有failed状态的任务管理员可以查看错误详情、手动触发重试、或者修改任务状态。这个入口我用 TP6 和 Laravel 都做过。核心就是几行代码查任务、看日志、点按钮改状态。注意一点手动补偿入口必须加操作日志。谁在什么时间对哪笔任务做了什么操作都要记录下来不然后面出问题连追责的线索都没有。我见过一些团队把手动补偿入口做成了“直接改数据库”这非常危险。不是不让改而是改库的同时一定要更新updated_at和操作记录否则对账系统会因为你手动改库产生的脏数据再次误判。既然有任务表的状态机就老老实实通过状态号来操作不要让绕过程序直接对 SQL 下手。4. 实操踩过的坑与排查清单重试和对账机制上线一段时间后你会遇到各种意想不到的问题。这一节我按“症状—原因—解决方案”写几个典型案例大部分场景是通用的。4.1 重复消费加积分加了两遍这是最常见的问题。症状是运营后台里某个用户的操作记录出现了两条。原因前面已经说过消费端处理完业务逻辑后还没来得及 ACK进程崩溃了消息被 Redis 或者 RabbitMQ 重新投递。排查思路先看任务表里这条任务的retry_count是不是大于0。再确认消费端是否有幂等判断。如果没有立刻补上。重点检查消费端 ACK 与业务操作之间的顺序。强烈建议先执行业务操作并提交事务再 ACK。如果提交事务成功但 ACK 失败消息会重投重投后靠幂等判断来拦截如果先 ACK 再执行业务消息不会重投但业务可能根本没执行成功这种问题隐蔽得多。另外如果你用的是 Laravel 的 queue 组件默认配置下消息执行成功会自动 ACK这个顺序是框架控制好的问题不大。但如果你在队列任务里用了dispatch嵌套队列任务并且子任务执行失败抛异常父任务其实已经 ACK 了子任务的重试次数和状态就要自己维护别指望框架帮你搞定。4.2 死信堆积延迟队列变成“僵尸谷”RabbitMQ 的延迟重试方案有一个问题如果消息消费失败后进入延迟队列但业务队列的消费者本来就处于“连不上”或者“阻塞”状态延迟队列里的消息会不停积累。时间一长内存占用飙升RabbitMQ 的节点直接进入高水位告警。排查思路先看延迟队列的messages_ready和messages_unacknowledged指标。如果ready数量一直涨说明消费者消费不过来。看消费者进程是否假死。PHP 常驻进程用pcntl信号处理不太好排查最简单的方式是在消费逻辑开头写一条日志看日志有没有持续输出。如果是消费者连不上 RabbitMQ检查连接配置里是否开启了心跳以及长时间空闲后连接是否被对端断开。PHP 的 AMQP 扩展有heartbeat参数建议设置为 30 秒左右。另外延迟队列的 TTL 不要设得太长。我见过有人把 TTL 设为半小时甚至一小时消息积累在内存里一旦 RabbitMQ 重启这些延迟消息全部丢失。RabbitMQ 的延迟队列默认是把消息放在内存里的除非你给队列设置了x-message-ttl加持久化属性但即便如此性能也会受限。4.3 对账扫描没生效索引没建对定时任务跑了但是一条数据都没扫出来这种情况我遇到不止一次。排查下来十有八九是任务表的next_retry_time字段类型或者查询条件写错了导致索引失效。常见的坑next_retry_time字段用了datetime但代码里把时间字符串格式传成了Y-m-d H:i少了秒查询结果错位。查询条件里用了NOW()但数据库服务器时区和 PHP 服务器时区不一致导致扫出来的时间点永远对不上。status IN (0,1)和next_retry_time NOW()的组合索引没建数据库执行全表扫描表一大就超时。我的建议是字段类型统一用datetimePHP 这边时间统一用date(Y-m-d H:i:s)生成数据库连接配置里设置time_zone为与 PHP 一致的时区。组合索引就建(status, next_retry_time)一个就够不用多。4.4 监控指标推荐盯这两个有了对账和重试机制你还需要有感知——不能等运营对账发现问题才想起看任务表。我在项目里会做两个监控指标失败任务率单位时间内新增的failed状态任务数 / 新增总任务数。这个比值如果超过 1%说明系统有系统性故障需要立刻看告警。任务处理延迟任务从created_at到updated_at的平均耗时。如果超过业务可接受阈值说明消费者的处理能力不足或者队列积压。这两个指标用 Grafana 或者简单的定时脚本记到日志里都行。不用搞太重的监控体系关键是“有”和“及时”。另外告警一定要带上业务上下文。不要只发一条“任务处理失败”要带上biz_id、event_id和错误摘要这样收到告警的人不用去翻日志就能初步判断问题在哪。4.5 上线前先做个故障演练最后说一条经验对账和重试机制上线前先做个故障演练。别嫌麻烦演练五分钟线上省一天。演练方式很简单构造一笔需要异步处理的业务单子把payload里的目标地址改成一个不存在的内网IP模拟投递失败。跑一遍业务确认任务状态变成processing后超时、重试、再失败最终进入failed。把目标地址改回来在后台点“手动补偿”确认任务能重新执行并成功。再构造一笔重复投递确认幂等判断生效业务数据不会增加。我每次给新项目画异步架构图的时候都会把“对账任务”和“手工补偿”画成两个独立方块和业务队列并列。有人觉得这两个方块多余实际上它们是整个异步体系的减震器。没有它们队列一抖数据就乱有了它们队列炸了也能慢慢捋回来。回到开头那句话异步操作三分靠投递七分靠兜底。PHP 这门语言本身并不自带这些保证工程师的自觉和工程规范才是最终的保障。每次有人问我异步队列为什么又出现数据不一致我的第一反应永远是同一句话先翻翻你的任务表看看那些卡在 processing 状态的老数据它们已经告诉你真相了。
返回列表