补偿任务队列怎么设计:支付回调失败后的重试和人工兜底

📅 2026/7/1 2:14:29
补偿任务队列怎么设计:支付回调失败后的重试和人工兜底
支付回调失败后不建议只靠人工查订单也不建议在回调接口里无限重试。更稳妥的做法是把失败回调、发货失败、状态不一致和对账差异都沉淀为补偿任务让系统按规则重试重试失败后再进入人工兜底。本文只讨论后端技术设计不讨论具体产品选型。一、补偿任务解决什么问题支付链路里常见的问题有几类支付渠道回调成功但本地更新订单失败。本地订单已支付但游戏发货接口超时。回调重复到达业务状态已经推进过。对账发现渠道有单本地没有成功状态。订单状态卡在处理中运营侧无法判断是否该补发。如果这些问题都靠临时查库处理排查成本会很高。补偿任务队列的价值就是把“需要再次处理的异常”变成可追踪、可重试、可审计的数据。二、补偿任务不要等同于消息重试消息队列的消费重试可以处理短时间故障例如网络抖动、服务短暂不可用、下游超时。但支付场景里很多异常不能只靠消息重试订单状态可能已经被其他流程更新。回调原文需要长期留存。发货失败可能需要换接口重试。对账差异可能隔天才发现。部分异常需要人工确认。所以消息重试负责“即时故障恢复”补偿任务负责“业务状态修复”。两者可以配合但不要混成一个概念。三、建议的补偿任务表补偿任务表至少要能记录任务来源、业务对象、重试次数、下次执行时间和最终处理结果。CREATETABLEcompensation_task(idBIGINTPRIMARYKEYAUTO_INCREMENT,task_noVARCHAR(64)NOTNULL,biz_typeVARCHAR(32)NOTNULL,biz_idVARCHAR(64)NOTNULL,order_noVARCHAR(64)DEFAULTNULL,sourceVARCHAR(32)NOTNULL,task_statusVARCHAR(24)NOTNULL,retry_countINTNOTNULLDEFAULT0,max_retry_countINTNOTNULLDEFAULT10,next_execute_atDATETIMENOTNULL,last_execute_atDATETIMEDEFAULTNULL,last_error_codeVARCHAR(64)DEFAULTNULL,last_error_msgVARCHAR(512)DEFAULTNULL,request_snapshotTEXT,result_snapshotTEXT,locked_byVARCHAR(64)DEFAULTNULL,locked_atDATETIMEDEFAULTNULL,created_atDATETIMENOTNULL,updated_atDATETIMENOTNULL,UNIQUEKEYuk_task_no(task_no),KEYidx_status_next_time(task_status,next_execute_at),KEYidx_biz(biz_type,biz_id),KEYidx_order_no(order_no));biz_type可以区分支付回调、游戏发货、对账补单、结算回写等任务。source可以记录任务来自回调失败、对账差异、人工创建或系统巡检。四、任务状态怎么设计补偿任务至少要有这些状态WAITING等待执行。PROCESSING执行中。SUCCESS处理成功。RETRY执行失败等待下次重试。FAILED达到重试上限。MANUAL需要人工处理。CLOSED人工确认关闭。状态流转要清楚不能失败后只改一个错误字段。推荐流程是WAITING - PROCESSING - SUCCESS WAITING - PROCESSING - RETRY - WAITING WAITING - PROCESSING - FAILED - MANUAL - CLOSED这样运营或技术排查时可以知道任务是自动修复成功还是已经进入人工兜底。五、执行器要做幂等控制补偿任务可能被重复调度也可能因为服务重启重复执行。执行器必须先做幂等判断。核心原则是同一个业务对象同一种补偿动作多次执行只能产生一次有效业务影响。以米洛SDK这类涉及支付回调、发货、对账的业务系统为例补偿任务执行前通常要先查订单当前状态再决定是否继续推进而不是直接重放历史请求。伪代码可以这样写TasktasktaskRepository.lockNextTask(workerId);if(tasknull){return;}OrderorderorderRepository.findByOrderNo(task.getOrderNo());if(ordernull){task.markManual(ORDER_NOT_FOUND);return;}if(order.isFinished()){task.markSuccess(ORDER_ALREADY_FINISHED);return;}try{CompensationResultresulthandler.execute(task,order);if(result.success()){task.markSuccess(result.message());}else{task.scheduleRetry(result.errorCode(),result.message());}}catch(Exceptione){task.scheduleRetry(SYSTEM_ERROR,e.getMessage());}重点不是把异常吞掉而是把每次执行结果写回任务表。六、重试间隔不要固定固定每 1 分钟重试一次看起来简单但容易在下游故障时持续放大压力。可以按指数退避设计第 1 次失败1 分钟后重试 第 2 次失败3 分钟后重试 第 3 次失败10 分钟后重试 第 4 次失败30 分钟后重试 第 5 次失败1 小时后重试 超过上限进入人工处理不同任务可以配置不同重试上限。支付状态确认可以多重试几次发货接口如果连续失败就应该更早进入人工排查。七、怎么避免多个 worker 抢同一任务如果有多个补偿 worker需要做任务锁。常见方式是用状态和锁字段原子更新UPDATEcompensation_taskSETtask_statusPROCESSING,locked_by?,locked_atNOW(),last_execute_atNOW(),updated_atNOW()WHEREid?ANDtask_statusIN(WAITING,RETRY)ANDnext_execute_atNOW();只有影响行数为 1 时当前 worker 才真正拿到任务。否则说明任务已被其他 worker 抢走。同时还要处理锁超时。例如某个 worker 拿到任务后宕机可以把超过 10 分钟仍处于PROCESSING的任务重新拉回WAITING并记录一次异常。八、人工兜底要留下记录自动补偿不是万能的。以下情况建议进入人工处理订单不存在。金额不一致。渠道流水和本地订单无法匹配。发货接口持续失败。退款或撤单状态不明确。达到最大重试次数。人工处理也要留记录至少包括处理人、处理时间、处理结论、关联订单、处理前状态和处理后状态。不要让人工处理变成“线下沟通后直接改库”。一旦没有处理日志后续对账或客诉复盘会失去证据。九、对账任务怎么接入补偿队列对账发现差异后不要直接改订单状态。推荐先生成差异记录再根据差异类型创建补偿任务。例如渠道有单本地无单进入人工确认。渠道成功本地未支付创建订单状态补偿任务。本地已支付未发货创建发货补偿任务。金额不一致进入人工确认。状态不一致创建状态核验任务。这样对账系统只负责发现差异补偿队列负责推进修复人工处理负责兜底确认。十、上线前检查清单上线前至少检查这些问题是否有补偿任务表。是否有唯一任务号。是否记录任务来源。是否支持重试次数和下次执行时间。是否有执行锁。是否能处理 worker 宕机后的锁超时。是否记录每次执行错误。是否有人工处理状态。是否能按订单号查询补偿记录。是否能和对账差异记录关联。补偿任务不是为了替代订单表、回调日志表或对账表而是把异常修复过程独立出来。总结支付回调失败后的补偿任务队列核心是把异常变成可追踪的任务。任务要有状态、重试次数、下次执行时间、锁机制、错误记录和人工兜底。只要幂等、重试、死信、对账和人工处理边界设计清楚支付链路出现异常时就能从“临时查库”变成“按任务排查和修复”。配图说明支付回调失败后的补偿任务、重试和对账排查链路示意图