ARTICLE DETAIL

资讯详情

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

批量代发为什么不能全量回滚?状态机与分片事务构建局部回滚机制

批量代发为什么不能全量回滚?状态机与分片事务构建局部回滚机制 50万笔工资代发说多不多说少也不少了。这类批量任务的难点从来不是怎么写循环而是跑到一半挂了怎么办挂了之后怎么收拾。我自己在支付系统里泡了很多年处理过的代发事故大大小小几十起最怕听到的一句话就是运营同事半夜打电话来问能不能整体回滚。我每次的答复都很一致——不能至少不能做全量回滚。这让我想起在VSCode里回滚代码。你辛辛苦苦改了十几个文件突然发现其中一个文件改错了你会怎么做正常人只会撤销那个文件里的某几行不会点放弃所有更改把整个工作区还原。批量代发业务本质上也是这个逻辑每一笔代发就是一次改动50万笔就是50万个独立的资金变更。全量回滚等于把整个工作区打回原形代价完全不成比例。这篇文章就是围绕这个问题展开的为什么全量回滚是下策以及如何设计一套只回滚该回滚的、不动已成功的的局部回滚机制。1. 一次深夜事故复盘代发任务跑到一半到底会发生什么先还原一下真实场景。你负责的一个代发系统收到一批工资代发文件总共100万笔其中有效数据50万笔金额合计大概1.2个亿。系统按规则把50万笔拆成50个批次每批1万笔顺序提交给核心账务系统处理。前37批跑得很顺利第38批开始渠道返回大面积超时日志里全是连接重置、响应超时。这时候状态是前37个批次也就是37万笔已经实实在在入账了客户的钱已经到了银行流水已经生成了。第38批有3000笔处于提交成功但未知的悬空状态7000笔处于提交失败状态。后面12个批次还没开始处理。运营的第一反应必然是回滚吧把所有的都撤了。听起来很简单但你要清楚一件事已经入账的37万笔每一笔都是真实发生的资金流转。客户可能已经在ATM上取了钱可能已经用这笔工资还了信用卡商家可能已经收到他们扫码支付的钱。这时候你冲正/抹账客户的账户余额突然变成负数或者出现钱被划走但没有任何通知的情况后果是投诉、客诉、甚至法律纠纷。在支付系统里我们要区分三种流资金流钱从代发企业账户划出转入员工账户这是受核心账务系统控制的真实流水。状态流代发明细记录在业务数据库中的状态从待处理到处理中、成功、失败。会计流企业内部户、清算户、过渡户上的记账记录反映资金在各账户间的转移。回滚的本质不是删掉一条数据而是把这三条流都做一次反方向操作。资金流要发起冲正状态流要把状态置回会计流要生成红字分录。如果你直接执行一个清除已入账数据的SQL资金流还在客户的银行卡上会计流还挂在总账里这三条流就崩了。很多代发事故的二次事故就是这么来的。所以第一步我需要你改变一个认知在批量代发场景里不存在一键还原这个操作。只存在两种处理方式——对已成功的记录做冲正有痕迹、可追溯、需要客户端配合对未成功的记录做状态回收原地置为失败或挂账。后者成本极低前者成本极高。我们要做的是让前者尽量少发生让后者尽量精确地发生。2. 为什么全量回滚是下下策锁、账、体验的三重灾难2.1 从账务角度看抹账是把已上桌的菜端回去我用一个上菜的比喻。50万笔代发客户已经收到钱了就好比50桌客人的菜已经端上桌并且吃了几口。这时候后厨发现其中一桌的菜做错了正常做法是给那一桌换一份、道歉、补偿而不是冲进大厅把50桌的菜全部端走。全量回滚就是端走全部菜的方案。具体到账务上已经入账的记录在客户的银行卡流水里是真实存在的也已经在当天或次日出清算文件时上报了。全量回滚意味着你要生成50万笔冲正流水、50万笔红字账这些冲正记录本身也要上报清算。两边一多一少总账对不平对账部门会花好几天去核平。这是成本一。成本二是客户体验。工资代发的客户不是开发者他们不关心你系统出了什么故障。他们只看到工资到账了过了两天又被扣走了余额变负。哪怕系统在公告里写了因系统故障已入账工资将撤回你也无法保证客户没有动过那笔钱。一旦客户花掉了账户超支、透支利息、客户投诉全都变成新的运营问题。2.2 从技术角度一个巨型事务包住50万笔会崩有人可能会想那我不用冲正方案我在代码层面做事务控制把所有50万笔放在一个数据库事务里全部成功才提交任何一个失败就整体回滚。听起来很安全对吧实际上在真实环境里根本扛不住。假设你用的是MySQL或者Oracle这类传统关系型数据库50万笔代发在一个事务里意味着什么事务期间这些行上的锁全都不释放。别的业务比如单笔转账、查询余额如果撞上同一批客户账户直接阻塞。undo / rollback segment要保存50万行修改前的镜像内存和磁盘开销巨大事务提交或回滚的时间会呈指数级上升。一旦网络闪断或数据库连接被kill回滚50万行需要跑很久。我在一线见过一次极端案例400万笔批量代发放一个事务里回滚跑了40多分钟期间整个库的CPU被打满周边业务全部降级。这个结论不是理论推演是实测出来的。批量任务必须按更小的、可控的原子单元来提交而不是把整个批量当一个原子。否则你为了可靠性牺牲了可用性最后连回滚本身都不可控。2.3 语义上全量是伪命题部分成功怎么定义回滚还有一个经常被忽视的问题什么叫全量回滚如果50万笔全部成功你回滚什么没有任何失败的指令全量的概念就不存在。如果50万笔里有38万成功、12万失败回滚的目标其实是成功的38万还是失败的12万所以说真正的决策单元从来不是整个任务层面而是每一笔明细层面。这就引出本文的核心设计思路把回滚的粒度做细细到单笔、单批次。让失败只影响它所在的局部已成功的部分继续保留未处理的部分暂停处理悬空的部分做状态补偿。3. 保证不全量回滚的三大基石分片事务、状态机、幂等控制要支撑局部回滚这个目标你在系统设计阶段就要埋好三根桩子分片、状态机、幂等。一个都不能少。3.1 分片事务把大批量拆成小批次每批独立提交分片是整个方案的地基。50万笔不要一口气提交而是拆成若干个1万笔的批次具体大小可以根据核心系统吞吐量来定我常用5000到10000笔一个批次。每个批次对应一个batch_id批次内每一笔明细对应一个work_item_id。批次与批次之间是隔离的每个批次是一个独立的事务单元有自己的提交、回滚边界。伪代码如下// 伪代码分批提交核心账务 public void submitBatch(ListPayOrder payOrders, int batchSize) { int total payOrders.size(); int batchNo 0; for (int from 0; from total; from batchSize) { ListPayOrder subList payOrders.subList(from, Math.min(from batchSize, total)); batchNo; String batchId generateBatchId(batchNo); // 每个批次独立开启事务 submitOneBatch(batchId, subList); } } Transactional(rollbackFor Exception.class) public void submitOneBatch(String batchId, ListPayOrder subList) { for (PayOrder order : subList) { // 按批次行号生成幂等键提交核心系统 String requestNo buildRequestNo(batchId, order.getLineNo()); corePayService.transfer(requestNo, order); // 本地状态更新为PROCESSING payOrderDao.updateState(order.getId(), PROCESSING); } }这样做的直接好处是第38批超时了最多影响第38批内的1万笔前37批的37万笔已经在各自的批次里提交成功了数据库层面绝对不会因为它们失败而回滚。资金和状态都是一个批次一个批次向前推进的不会出现牵一发而动全身。3.2 状态机每笔代发都要有清晰可迁移的状态如果说分片是骨架状态机就是神经系统。每一笔代发记录都必须有一个明确的当前状态并且只允许按预定义的状态迁移方向流转。我习惯定义这几类状态状态含义可迁移方向PENDING待处理已入库未提交PROCESSING / FAILEDPROCESSING已提交核心结果未知SUCCESS / FAILED / SUSPENDEDSUCCESS入账成功REFUNDING / REFUND_FAILEDFAILED明确失败未入账PENDING重试/ CLOSEDSUSPENDED悬空挂账等待人工或自动冲正REFUNDING / CLOSEDREFUNDING冲正处理中REFUNDED / REFUND_FAILEDREFUNDED已冲正原资金退回CLOSED状态迁移的关键在于FAILED、SUSPENDED 这种状态只允许在本次操作未成功入账的前提下产生。一旦状态变成SUCCESS对应的资金已经进入客户账户这时候任何自动回滚都不允许直接把它打回PENDING或FAILED只能走REFUNDING——因为这是资金操作不是状态操作。这个设计在我们处理第38批悬空记录时非常有价值。悬空记录的状态是PROCESSING核心系统那边没有明确返回成功还是失败。我们不会猜测而是把它置为SUSPENDED挂到异常账上。等核心系统出对账文件确认某笔确实成功了就把它从SUSPENDED迁回SUCCESS确认失败了就迁到FAILED并释放资源。这个思路在后台开发里叫对账驱动状态收敛比在接口超时那一刻盲猜要可靠得多。3.3 幂等控制同一条代发指令不能执行两次代发系统的另一个经典坑是重放。第38批提交时网络超时了但核心系统实际上已经处理了一部分请求。你如果简单地重发一遍那部分请求就会重复入账——客户收到两笔工资等到对账时发现是企业户少了钱又是一场灾难。解决方法是给每一笔代发生成全局唯一的请求幂等键。这个幂等键通常由batch_id line_no 业务类型拼接而成在上传到核心系统时作为业务流水号。核心系统收到相同幂等键的重复请求时直接返回第一次处理的结果而不是再执行一次。你可以这样设计-- 幂等键唯一约束防止重放 ALTER TABLE pay_work_item ADD UNIQUE KEY uk_biz_request (batch_id, line_no, pay_type);在业务代码里发账户动账指令时也要遵循同一套规则先查重再执行同一请求号不重复扣款、不重复入账。很多事故不是第一次跑出来的是重试跑出来的。一次重试就是一次潜在的资金重复防重放比防故障优先级更高。3.4 失败锚点要知道死在哪一批、哪些单最后你需要一个快速定位失败边界的能力。回滚的边界怎么确定不是靠直觉是靠记录。在代发任务启动时先生成一张pay_batch表记录每个批次的序号、总笔数、总金额、当前状态。每一笔明细写入pay_work_item表带上batch_id。这样当任务失败时你可以用一条SQL快速统计出每个批次的成功、失败、悬空笔数SELECT batch_id, COUNT(*) AS total_cnt, SUM(CASE WHEN state SUCCESS THEN 1 ELSE 0 END) AS success_cnt, SUM(CASE WHEN state FAILED THEN 1 ELSE 0 END) AS failed_cnt, SUM(CASE WHEN state PROCESSING THEN 1 ELSE 0 END) AS processing_cnt FROM pay_work_item GROUP BY batch_id ORDER BY batch_id;有了这个统计你就能快速回答三个问题哪些批次已完全成功不允许动哪些批次有悬空记录需要挂账哪些批次还没开始可以暂停这也上一章提到的那句话——回滚决策的单位是批次和单笔不是整个任务。4. 实战复盘第38批失败时我是怎么做到只处理局部、不打全量的接下来用一个完整案例串一遍排查和处理链路。这个案例我处理过类似的情况细节做了脱敏和调整还原度足够参考。4.1 故障现象某月度代发任务共50万笔拆成50个批次每批1万笔。运行到第38批时核心渠道开始超时第38批有3000笔返回未知超时未见结果7000笔返回明确失败如账户状态异常、户名不符。第39~50批自动暂停提交。前37批全部成功。此时运营给我的诉求是能不能把所有人的工资撤回来我的回答是只能处理没成功的以及悬空的已经成功的绝对不能动。4.2 第一步灰度定界先看清哪些能碰执行上文那条带分组的SQL可以得到一张批次状态表。用来判断批次归属批次范围状态处理策略第1~37批全部SUCCESS不动保留第38批部分7000笔 FAILED直接置为CLOSED向企业客户反馈失败明细第38批部分3000笔 PROCESSING未知置为SUSPENDED等对账文件后收敛第39~50批PENDING未处理暂停等第38批问题解决后决定是否继续这里的关键原则是明确失败的直接关闭未知结果的全部挂账已成功的绝不操作。再明确一次系统里能安全回滚的只有未成功的部分它们的回滚其实就是状态回收。已成功的部分哪怕你很想撤销也要走正式的冲正流程而不是批量delete。4.3 第二步对悬空记录发起状态收敛等待核心对账第38批3000笔PROCESSING不能直接判定为失败因为核心系统可能已经入账了。这个场景我见过太多人犯错超时就想当然地重发或者取消。正确的做法是把它们一键置为SUSPENDED挂起然后等核心系统的日终对账文件。-- 把第38批所有PROCESSING记录置为SUSPENDED挂账 UPDATE pay_work_item SET state SUSPENDED, suspend_reason CHANNEL_TIMEOUT, suspend_time NOW() WHERE batch_id BATCH_038 AND state PROCESSING;拿到核心对账文件后脚本自动比对每一笔明细的终态对账文件反馈成功的把状态从SUSPENDED改回SUCCESS同时登记到成功明细里对账文件反馈失败的置为FAILED进入失败明细清单。整个过程不需要人工猜不需要全量回滚。4.4 第三步真正需要操作资金的场景只冲正目标单有人会问如果悬空记录最终确认是成功的但企业主已经不想要这笔代发了怎么办这时候才需要真正的资金冲正。但请注意冲正的目标是那3000笔确认成功的悬空单而不是全量50万笔。冲正要走核心系统的正式交易接口传入原始交易流水号和冲正原因核心系统反过来生成一笔退款流水。你本地要做的是把原明细状态从SUCCESS置为REFUNDING等冲正返回成功后置为REFUNDED。代码大致长这样public void refundOneWorkItem(PayWorkItem item) { // 1. 检查该明细是否允许冲正 if (!SUCCESS.equals(item.getState()) !SUSPENDED.equals(item.getState())) { throw new IllegalStateException(当前状态不允许冲正: item.getState()); } // 2. 置为冲正中防止并发重复发起 int rows payWorkItemDao.casState(item.getId(), REFUNDING, SUCCESS, SUSPENDED); if (rows 0) { return; // 已经被其他线程处理 } // 3. 调用核心冲正接口幂等键复用原交易号 corePayService.reverse(item.getOriginalTxnNo(), buildRefundReason(item)); // 4. 异步或同步更新状态 payWorkItemDao.updateState(item.getId(), REFUNDED); }这里有一个细节容易被忽略冲正前要加一次CAS乐观锁用state旧值作为条件更新防止多个运维脚本、重试任务同时处理同一笔单。没有这层保护很可能出现重复冲正——客户被扣了两次钱反过来把一次故障升级成二次事故。4.5 第四步结果验证不只查数据库处理完之后数据库里的状态看起来对了还不够。真正的验证标准有三个批次表汇总第1~37批成功笔数不变第38批从未知收敛为具体的成功/失败计数第39~50批保持不变。总额核对所有SUCCESS记录的总金额 所有FAILED记录的总金额 原代发文件总金额。拿这个等式去核对一分钱都不能差。核心流水核对从核心系统导出一份当日动账流水筛出本任务相关的所有流水单号逐一与本地SUCCESS/REFUNDED明细匹配。只在本地状态修改、核心没有对应流水的就是问题单。这三个检查都过了才算真正闭环。5. 支撑局部回滚落地的工程细节与避坑经验5.1 表结构设计从第一天就把回滚当第一公民很多代发系统一开始只设计了job表存汇总没有明细表一到故障排查就抓瞎。一个能支撑局部回滚的结构至少包括CREATE TABLE pay_batch ( id BIGINT PRIMARY KEY AUTO_INCREMENT, job_id BIGINT NOT NULL COMMENT 代发任务ID, batch_no INT NOT NULL COMMENT 批次号, total_cnt INT NOT NULL COMMENT 批次总笔数, success_cnt INT DEFAULT 0, fail_cnt INT DEFAULT 0, suspend_cnt INT DEFAULT 0, amount_total DECIMAL(18,2) NOT NULL COMMENT 批次总金额, state VARCHAR(20) NOT NULL DEFAULT PENDING COMMENT 批次状态, create_time DATETIME NOT NULL, finish_time DATETIME DEFAULT NULL ) COMMENT 代发批次表; CREATE TABLE pay_work_item ( id BIGINT PRIMARY KEY AUTO_INCREMENT, batch_id VARCHAR(32) NOT NULL, seq_no INT NOT NULL COMMENT 文件内行号, account_no VARCHAR(64) NOT NULL COMMENT 收款账号, account_name VARCHAR(128) NOT NULL, amount DECIMAL(18,2) NOT NULL, state VARCHAR(20) NOT NULL DEFAULT PENDING COMMENT 单笔状态, original_txn_no VARCHAR(64) DEFAULT NULL COMMENT 核心交易流水号, request_no VARCHAR(64) NOT NULL COMMENT 幂等请求号, fail_reason VARCHAR(255) DEFAULT NULL, suspend_reason VARCHAR(255) DEFAULT NULL, create_time DATETIME NOT NULL, update_time DATETIME NOT NULL, UNIQUE KEY uk_batch_seq (batch_id, seq_no), UNIQUE KEY uk_request_no (request_no) ) COMMENT 代发明细表;注意两个唯一键(batch_id, seq_no)保证同一批内行号不重复request_no保证代发请求全局幂等。state字段上加普通索引因为批处理统计必然会按state过滤扫描。没有这两个唯一键局部回滚里的精确打击就无从谈起。5.2 别在数据库大事务里发消息和调用外部系统处理悬空状态或回滚时最常见的二次事故是把外部调用包在本地事务里。比如在Transactional里先更新状态再调用核心冲正接口——如果冲正接口响应慢数据库事务一直不提交连接池很快被耗尽如果事务提交前网络断了核心其实已处理冲正本地又回滚了两边状态就不一致。我的习惯是本地事务只做状态记录和命令落库真正的外部操作全部放到事务提交之后通过事务消息或本地消息表异步执行。具体做法是在Transactional内把pay_work_item状态更新为REFUNDING同时插入一条refund_command消息记录。事务提交后由一个独立的消息消费者读取refund_command去调用核心系统冲正接口。核心系统返回成功后再回调更新明细状态为REFUNDED。这样做的好处是事务边界和外部依赖彻底解耦。即使消费者挂了命令还在表里重启后可以继续消费不会丢。也不会有DB事务没提交但外部已经动了账的错乱。5.3 幂等唯一键别建错我踩过的坑有一年我们做代发重构把唯一键建成了(batch_id, seq_no)当时觉得同一个批次里行号唯一就够了。后来发现一个问题批量任务重跑时batch_id会重新生成但文件里的seq_no、account_no组合没变。重跑后同一笔代发被当成两条新单重复入账。后来才把幂等键调整为request_no并且将request_no设计为文件编号 批次号 行号 业务类型的哈希串。这个键的生成规则一旦定下来要保证在任何重试场景下同一笔明细生成的值都一样。它是同一笔代发的唯一身份证。这里也顺带说一下重试还容易踩的坑是批量更新导致锁升级。比如我上面写的UPDATE pay_work_item SET stateSUSPENDED WHERE batch_idBATCH_038 AND statePROCESSING如果batch_id没有索引扫描范围大行锁会升级成表锁把整张明细表锁住。处理大批量的UPDATE一定要确认where条件能用到索引最好先SELECT id再按主键小批量更新每次几百条避免锁冲突。5.4 人工干预的余地让挂账缓冲决策时间有一种设计观值得强调能挂账的先别急着冲正。故障发生后的那个晚上运营决策层未必能立刻判断这50万笔要不要继续发、失败部分要不要补发这时候系统里所有悬空单都被你冲正完了反而没有回旋余地。挂账的好处是你可以等核心对账文件、等客户反馈、等企业客户指令在一两个小时内做出更合理的决策。挂账的单子不占成功额度也不占失败额度它就是一个等待收敛的中间态。我在实践里甚至会让SUSPENDED状态默认保留24小时超时未收敛才触发告警。这个缓冲期听起来简单却在真实事故中帮了大忙。6. 更进一步从局部回滚到动态调账补偿的设计观讲完了具体操作我想再跳出来说一点更偏设计层面的东西。局部回滚的思路本质上是把回滚从单一动作拆成一系列细粒度动作的组合状态回收、挂账、冲正、重发、暂停。真正优秀的代发系统不应该是一个失败就整个回滚的开关而应该是一个能自我调节的补偿平台。你可以把整个代发任务想象成一场货物配送50辆货车各自送货有3辆车在路上遇到雨雾你先让这3辆靠边停挂账让已经送达的37辆继续完成签收不动对明确送不了的退货置FAILED对不确定是否送达的等收货方回执对账。整个调度系统始终在做局部决策而不是召回所有车辆。这种设计观带来的另一个收益是回滚能力变成了可对外开放的功能。例如企业客户在网银端发起一笔代发后发现上传的明细金额算错了在未处理成功之前允许撤销部分批次产品上叫代发撤销技术上就是这里讲的局部回滚。这是很好的增值能力比失败后全量回滚这个兜底方案值钱得多。最后关于成本一个简单的估算能帮你说服业务方。设失败率是1%50万笔里有5000笔出问题。如果设计支持局部回滚你要处理的异常单是5000笔流程可控。如果不支持你要面对的是50万笔的全量冲正、50万笔对账、未知数量的账户超支和客诉。这个量级不是一个数量级的问题是几个数量级的问题。系统设计的取舍在故障发生那一刻就会加倍返还给你。我在实际项目里的深刻体会是回滚不是事故处理手段而是系统架构的一部分。你设计状态机、设计幂等键、设计分片提交看起来是在做常规开发其实是在为不确定的那一天铺设一条既能止血又不误伤的路。今天这些细节都是那条路上的标牌。
返回列表