news 2026/9/9 23:39:49

分布式事务实战:解冻支付场景下的TCC、幂等与最终一致性设计

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
分布式事务实战:解冻支付场景下的TCC、幂等与最终一致性设计

先讲一个我凌晨两点被电话叫醒的案子。监控群里连续刷出十几条资金流水不平的告警,客服那边也炸了锅,有用户说订单取消了但钱一直没回来,另一拨人却反馈钱退了但订单还卡在支付中不敢动。两个方向上看起来完全相反的问题,最后都指向同一个功能——解冻支付。

这个功能听起来简单:用户下单时先冻结钱包余额,订单取消或确认支付时再把冻结资金解冻,要么退回钱包,要么完成真实扣款。但一旦订单、钱包、支付网关被拆成独立服务、独立数据库,一次解冻操作就变成了一条跨越三个以上节点的调用链,任何一步超时、重试、宕机,都会让数据状态发散。最后我们靠一整套分布式事务设计才把它稳住。这篇文章把当时的业务拆解、方案选型、落地实现和踩过的坑完整复盘一遍,给你一个可以直接参考的生产级版本。

1. 业务背景与故障现场还原

1.1 资金冻结和解冻在交易链路里的角色

先理清业务模型。用户充钱之后,钱包里有一个“可用余额”。用户下单时,平台不能直接把钱扣走,因为订单还可能被取消、可能超时、可能支付失败,所以先把下单金额从“可用余额”转移到“冻结余额”。这个动作在账务上叫冻结,也叫预授权。

等订单进入终态,再对冻结资金做处理:如果支付成功,冻结余额归零,这笔钱变成平台的收入或者商家的结算款;如果订单取消、超时关闭、退款,冻结余额归零,钱退回到用户可用余额。从账务角度看,冻结不能凭空产生,它必须由可用余额转入;解冻也不能凭空消失,要么退回可用,要么变成收入,任何一笔冻结单最终都必须落在一个明确的资金去处。

我们当时的系统把订单中心、钱包中心、库存中心、支付网关全拆成了独立微服务,各自拥有独立数据库。一次“解冻并退回”操作实际要做的事包括:订单服务修改订单状态、库存服务释放库存、钱包服务将冻结余额退回可用余额。这三件事发生在三个数据库里,没有一个数据库事务能同时覆盖它们。

1.2 凌晨两点的线上故障现场

那个晚上最初的告警是资金总额对不上。我们有个日切对账任务,每天晚上要做一次总和校验:所有用户可用余额加冻结余额加平台收入,必须等于用户总充值累计。那天凌晨,对账任务跑出了一个非常大的差额,差额方向是“平台资产凭空少了”。

顺着告警查下去,发现大量订单处于“已取消”状态,但对应的冻结单还停在“已冻结”,用户的余额根本没有退回去。为什么会出现这种状态?订单中心在调用钱包中心的“解冻并退回余额”接口时,网络发生了超时。钱包中心实际上已经完成了退款并更新数据库,但订单中心在超时后判定调用失败,于是触发了重试。

重试时问题来了:当时解冻接口的幂等键做得不完整,第二次请求没有匹配到第一次已经处理的冻结单,而是又走了一遍解冻逻辑。第一次解冻已经把冻结余额清零,第二次解冻因为找不到可解冻的余额而返回失败。订单中心看到的是“最终还是失败”,但又不甘心地重试了几次,最后一次尝试时订单中心不再关心钱包结果,直接把订单置为“已取消”。于是数据库里留下大量“订单已取消、冻结未解冻、余额未退回”的脏数据。

更麻烦的是,由于调用方重试逻辑并不完全一致,另一个时间片里有少数订单被重复解冻:同一笔冻结资金被退回两次,用户余额凭空多出一笔钱。这就是典型的分布式系统三大问题同时爆发:调用成功但响应丢失、重试导致重复处理、多个服务状态各自独立演进最终发散。

1.3 拆解链路:一致性缺口到底在哪里

把整个调用链画出来就清楚了。用户点击取消订单,请求进入订单服务,订单服务分别调用库存服务和钱包服务。正常流程是:

  • 库存服务释放库存,扣减冻结库存;
  • 钱包服务把冻结余额退回可用余额;
  • 订单服务把订单状态改成已取消。

看起来是顺序调用,实际上每个服务都有自己的数据库事务。库存服务和钱包服务都成功了,但订单服务的写库操作如果失败,那订单状态和两侧资金/库存状态就不一致;反过来,订单服务成功但钱包服务失败,也是一样。网络上超时语义是模糊的,超时到底是处理成功还是处理失败,调用方根本不知道。如果不知道结果就盲目重试,就可能重复处理;如果不重试,又可能漏处理。

这个案例里最核心的问题不是某一个服务的代码 bug,而是缺少一个跨服务的“业务事务”机制:让所有参与方最终收敛到同一个终态,要么都成功,要么都不产生资损。业界管这个叫分布式事务,更准确地说,在异步、跨服务、跨库场景下,我们追求的是最终一致性,而不是传统数据库那个意义上的强一致性。

2. 分布式事务方案选型:为什么不能图省事

2.1 为什么本地事务解决不了跨服务问题

如果订单、库存、钱包都在同一个数据库里,这个问题非常简单,一条数据库事务就能搞定:开启事务,更新订单状态,释放库存,退回余额,然后提交。任何一步失败,整体回滚,数据永远一致。

但服务拆了之后,数据库也拆了。每个服务连的是自己的库,各自的事务边界只能覆盖本服务的数据。没有一个数据库实例能同时锁住订单表、库存表和钱包账户表,所以传统本地事务在跨服务场景天然失效。

用一句生活化的话来解释:三个人分别记三本账,一个人记“订单已取消”,一个人记“库存已释放”,一个人记“钱已退回”。你不可能让三本账在一次操作里同时写完,因为这三个人不坐在同一个办公室。能做的只能是设计一套对账和补偿机制,让三本账最终能对上。

2.2 主流方案横向对比

当时团队把业界主流的分布式事务方案全部摆出来过了一遍,包括2PC/XA、TCC、Saga、可靠消息最终一致,以及当时很火的 Seata AT 模式。这里直接给一张对比表,方便你按场景做取舍。

方案一致性强度实现侵入性性能与锁适用场景资金场景适配度
2PC/XA强一致侵入数据库,应用改动小资源锁定时间长,高并发下性能差标准数据库分布式事务,系统规模小低,热点资金行容易成瓶颈
TCC业务层可控的最终一致高,需实现Try/Confirm/Cancel锁粒度小,性能较好跨服务、资金/库存等有明确资源语义的场景高,适合资金冻结/解冻类操作
Saga最终一致高,需拆长事务和补偿逻辑异步事件驱动,吞吐高长流程、多步骤事务中,需要额外设计回滚逻辑
可靠消息最终一致最终一致中,需本地消息表或事务消息异步,性能高订单状态通知、异步解冻触发中,作为兜底非常合适
Seata AT模式全局一致低,对业务无侵入依赖全局锁,热点行锁等待明显中低并发、对强一致要求高的业务低,账户余额属于高频热点行,风险高

我们重点评估过 Seata AT 模式。它的优点是接入成本低,业务基本不用改代码,框架会自动拦截 SQL 并通过全局锁保证一致性。但资金账户场景有一个致命问题:同一个用户的余额就是一条热点数据行,所有冻结、解冻、扣款、充值操作都要在这一行上做并发更新。AT 模式全局事务提交前会一直持有全局锁,如果链路里某个节点处理慢,锁持有时间会被拉长,热点行后面排队的请求全部阻塞,数据库连接池很快就会被耗尽。

2.3 资金场景的取舍:TCC为主、可靠消息兜底、对账兜底

最终定下来的方案不是单一技术,而是一个组合:

TCC 作为主干,解决“解冻并退回”和“解冻并扣款”这两个核心操作的原子性问题。TCC 的业务语义正好和资金冻结/解冻完全契合:Try 阶段预留资源(冻结余额),Confirm 阶段真正提交(扣款/退回),Cancel 阶段回滚(退回可用余额),每个阶段都有明确的业务动作,不会产生盲目的数据库锁。

可靠消息作为兜底。TCC 的 Confirm 或 Cancel 执行成功之后,需要通过消息通知订单中心和其他下游服务。如果消息丢了,或者下游服务临时不可用,可靠消息机制保证这条通知能持续重试直到成功。

最终对账体系作为最后一道防线。不管前面设计得多严密,生产环境总会有意想不到的边界情况,所以必须有定时对账任务,把订单状态、冻结单状态、账户流水三份数据放在一起比对,发现不一致就自动补偿或者人工介入。

这个组合的核心逻辑是:用 TCC 保证核心资金操作的原子性,用可靠消息保证状态传递不丢失,用对账保证所有异常最终暴露并被修复。分布式事务永远不可能做到 100% 无异常,但可以通过这层组合把隐患收敛到可控范围内。

3. 解冻支付的TCC落地实现

3.1 核心表结构与冻结状态机

TCC 能不能落地,关键看两张表:一张是冻结单表,用来记录每笔冻结业务的完整生命周期;另一张是账户余额流水表,用来记录每一笔余额变化的明细,同时承担幂等去重的作用。

先看冻结单表:

CREATE TABLE `t_fund_freeze` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `freeze_no` varchar(64) NOT NULL COMMENT '冻结单号,全局唯一', `user_id` bigint(20) NOT NULL COMMENT '用户ID', `order_no` varchar(64) NOT NULL COMMENT '关联订单号', `biz_type` varchar(32) NOT NULL COMMENT '业务类型:下单冻结、预授权等', `amount` decimal(12,2) NOT NULL COMMENT '冻结金额,单位元', `status` tinyint(4) NOT NULL DEFAULT '0' COMMENT '状态:0创建中,1已冻结,2已退回,3已扣款,4已取消', `version` int(11) NOT NULL DEFAULT '0' COMMENT '乐观锁版本号', `try_count` int(11) NOT NULL DEFAULT '0' COMMENT '尝试冻结次数', `confirm_count` int(11) NOT NULL DEFAULT '0' COMMENT '确认次数', `cancel_count` int(11) NOT NULL DEFAULT '0' COMMENT '取消次数', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_freeze_no` (`freeze_no`), KEY `idx_user_id` (`user_id`), KEY `idx_order_no` (`order_no`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='资金冻结单表';

余额流水表是幂等防重的第一道关口:

CREATE TABLE `t_account_change` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `user_id` bigint(20) NOT NULL, `change_type` tinyint(4) NOT NULL COMMENT '变动类型:1冻结,2解冻退回,3扣款,4充值', `amount` decimal(12,2) NOT NULL, `freeze_no` varchar(64) NOT NULL, `order_no` varchar(64) NOT NULL, `unique_key` varchar(128) NOT NULL COMMENT '业务幂等键,唯一索引', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_unique_key` (`unique_key`), KEY `idx_user_id` (`user_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='账户余额变动流水表';

冻结单的状态机必须严格定义,不能允许任意跳转。我们的状态迁移规则只有三条:冻结单创建后必须先进入“已冻结”;已冻结状态只能走向两个终态,要么“已退回”,要么“已扣款”;任何终态都不可再变。这个规则用数据库条件更新来实现,比在代码里做 if 判断可靠得多。

3.2 Try/Confirm/Cancel三阶段的关键实现细节

TCC 三个阶段的代码逻辑,用 Java 伪代码表示大概是这样的:

// Try阶段:预冻结资金 @Transactional public boolean tryFreeze(FreezeRequest req) { // 第一步:幂等校验,防止上游重复发起 AccountChange existed = accountChangeMapper.selectByUniqueKey(req.getUniqueKey()); if (existed != null) { return true; // 已经处理过,直接返回成功 } // 第二步:行锁锁定用户余额账户,防止并发操作同一账户 Account account = accountMapper.selectByUserIdForUpdate(req.getUserId()); // 第三步:校验可用余额是否充足 if (account.getAvailableBalance().compareTo(req.getAmount()) < 0) { throw new BusinessException("可用余额不足"); } // 第四步:扣减可用余额,增加冻结余额 accountMapper.updateAvailableAndFrozenBalance(account.getUserId(), req.getAmount().negate(), req.getAmount()); // 第五步:写账务流水,记录幂等键 accountChangeMapper.insert(buildChangeRecord(req, 1)); // 第六步:更新冻结单状态为“已冻结” freezeMapper.updateStatus(req.getFreezeNo(), 0, 1); return true; }

Try 阶段的重点是“只冻结,不扣减”。资金从可用余额转进冻结余额,但归属权还没有发生变化。如果用户取消订单,后续可以走 Cancel 把这笔钱退回可用余额;如果确认支付,后续走 Confirm 把冻结余额变成平台收入。

// Confirm阶段:确认扣款 @Transactional public boolean confirmDeduct(ConfirmRequest req) { // 条件更新:必须从“已冻结”状态才能进入“已扣款”,并且带上版本号做乐观锁 int rows = freezeMapper.updateStatusByVersion(req.getFreezeNo(), 1, 3, req.getVersion()); if (rows == 0) { // 说明状态已经变化,可能是已经扣款,也可能是已经退回 // 这种情况下不抛异常,而是查一次当前状态直接返回幂等成功 return true; } // 扣减冻结余额,增加平台收入/商家结算款 accountMapper.updateFrozenAndIncome(account.getUserId(), req.getAmount().negate(), req.getAmount()); // 写账务流水 accountChangeMapper.insert(buildChangeRecord(req, 3)); // 发送“支付成功”消息给订单中心 messageSender.sendPaySuccessNotify(req.getOrderNo()); return true; }
// Cancel阶段:退回余额 @Transactional public boolean cancelFreeze(CancelRequest req) { // 条件更新:必须从“已冻结”状态才能进入“已退回” int rows = freezeMapper.updateStatusByVersion(req.getFreezeNo(), 1, 2, req.getVersion()); if (rows == 0) { return true; // 终态幂等返回 } // 扣减冻结余额,增加可用余额 accountMapper.updateFrozenAndAvailable(account.getUserId(), req.getAmount().negate(), req.getAmount()); // 写账务流水 accountChangeMapper.insert(buildChangeRecord(req, 2)); // 发送“退款成功”消息 messageSender.sendRefundSuccessNotify(req.getOrderNo()); return true; }

Confirm 和 Cancel 在数据库层面天然互斥。两者都从“已冻结”状态出发,谁先执行成功,status 就被更新成对应的终态,后执行的条件的 update 匹配不到记录,影响行数为零,直接按幂等成功返回。这个设计避免了一个经典问题:同一笔冻结单同时收到确认和取消请求时,可能会出现两边都执行成功、资金被重复处理的严重事故。

注意:TCC 的 Confirm 和 Cancel 方法不能因为“更新零行”就抛异常抛给上游。很多重复请求其实是网络重试导致的,上游收到异常会继续重试,最后反而把简单问题复杂化。正确做法是:条件更新零行时,查一次当前状态,如果是终态就直接返回成功。

3.3 幂等与防重:资金操作的第一安全线

资金类操作里,重复执行比漏执行更可怕。漏执行可以通过对账补单发现,重复执行往往直接造成资损,而且很难追回。所以幂等设计要作为第一优先级来对待。

我们在实践里总结出三条硬性规则:

第一,每个业务操作必须有唯一的业务幂等键。对解冻支付来说,幂等键不能只取冻结单号,因为一个订单可能拆成多笔冻结,一个冻结单在某些业务场景下也可能被多个订单引用。更稳妥的做法是取“用户ID + 订单号 + 操作类型 + 冻结单号”组合,保证同一业务语义下只处理一次。

第二,账户流水表的唯一索引是幂等防重的最后防线。代码里的判断逻辑再严谨,都有并发穿透的可能。但如果 t_account_change 表的 unique_key 上建了唯一索引,并发插入时数据库会自动拒绝后插入的那条,事务随即回滚,把重复操作挡在数据层之外。

第三,状态变更必须使用条件更新,不能先查后更。先查询再判断再更新这个流程在并发下天然有竞态窗口。正确做法是直接执行UPDATE t_fund_freeze SET status = 3, version = version + 1 WHERE freeze_no = ? AND status = 1 AND version = ?,用影响行数来判断是否真的完成了状态迁移。

3.4 可靠消息兜底:本地消息表加消费重试

TCC 的 Confirm 和 Cancel 执行成功后,需要把结果通知给订单中心和库存服务。如果这一步用普通 RPC 同步调用,调用失败怎么办?消息发送失败怎么办?这个链路里仍然有缝隙。

我们的做法是引入本地消息表,把“写业务数据”和“写待发送消息”放在同一个本地事务里。也就是说,钱包服务在更新冻结单状态、写余额流水的同时,往本地 t_msg_record 表插入一条消息记录。因为两张表在同一个数据库里,这个操作要么全成功,要么全失败,消息永远不会丢。

CREATE TABLE `t_msg_record` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `msg_id` varchar(64) NOT NULL COMMENT '消息唯一ID', `biz_type` varchar(32) NOT NULL COMMENT '业务类型', `biz_no` varchar(64) NOT NULL COMMENT '业务单号', `content` text NOT NULL COMMENT '消息内容(JSON)', `status` tinyint(4) NOT NULL DEFAULT '0' COMMENT '0待发送,1已发送,2发送成功,3消费成功', `retry_count` int(11) NOT NULL DEFAULT '0' COMMENT '重试次数', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_msg_id` (`msg_id`), KEY `idx_status` (`status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='本地消息表';

消息表写入后,由独立的定时任务扫描 status 为“待发送”或“已发送但未确认”的记录,把消息投递给目标服务或 MQ。目标服务消费后必须回调确认接口,把 t_msg_record 的 status 更新为“消费成功”。如果消费失败,定时任务会按指数退避策略重试,重试超过上限后进入人工告警队列。

这套方案虽然没有直接解决“订单中心和钱包中心两个数据库的原子提交”问题,但它保证了“业务处理成功后的事件不会丢”。配合前面 TCC 的状态机,最终一致性就能在可预期的时间内收敛。

4. 最终一致性的对账与监控体系

4.1 日切对账:账实相符的底线

不管分布式事务设计得多么完善,生产系统都必须有对账兜底。我们的对账分两层:一层是总额对账,一层是明细对账。

总额对账每天凌晨跑一次,核心逻辑是资产守恒:所有用户的可用余额总和 + 冻结余额总和 + 平台收入总和,必须等于用户的历史充值总和。这是最朴素也是最有效的一条公式。只要账不平,说明系统里一定存在重复扣款、漏退款或者余额错误,必须马上人工介入。

明细对账则要更精细一些,核心比对三类不一致:

  • 订单已取消,但冻结单状态仍为“已冻结”;
  • 冻结单状态为“已扣款”,但订单状态仍停留在“待支付”或“支付中”;
  • 冻结单状态为“已退回”,但订单状态仍停留在“取消中”。

举个例子,用下面这条 SQL 就能找出第一批问题数据:

SELECT f.freeze_no, f.user_id, f.order_no, f.amount, f.status FROM t_fund_freeze f INNER JOIN t_order o ON f.order_no = o.order_no WHERE f.status = 1 AND o.order_status IN ('CANCELLED', 'CLOSED') AND f.update_time < DATE_SUB(NOW(), INTERVAL 30 MINUTE);

这类对账 SQL 看起来很土,但非常有效。我们经历过的所有分布式一致性事故,最后都是通过对账发现的。

4.2 准实时监控与告警:缩短发现时间

日切对账有一个天然劣势:发现问题时可能已经滞后 24 小时,用户投诉早就进来了。所以还需要一个准实时监控层,把不一致状态的存在时间压缩到分钟级。

我们的做法是在 TCC 确认/取消接口之外,增加了一条专门的状态一致性巡检链路。每 5 分钟扫描一次冻结单表,重点检查两类数据:一类是长时间停留在“已冻结”状态、但关联订单已经进入终态的单子;一类是消息重试队列里重试次数超过 5 次的记录。扫描结果超过阈值就触发告警,推送到值班群。

另外一个很关键的监控指标是幂等冲突发生次数。如果某个接口的 unique_key 冲突量突然上升,说明上游的重试逻辑或者调用方式发生了变化,这往往是事故的前兆。我们曾经就是因为某个上游服务升级后忘记传业务单号,导致大量请求落到同一个兜底 key 上,被幂等冲突告警提前发现,避免了一场资损事故。

4.3 自动补偿与幽灵冻结单

长时间未处理的冻结单,我们内部叫“幽灵冻结单”。用户下单冻结了资金,但订单卡在中间状态,资金既没有被扣走,也没有退回去,像幽灵一样悬在冻结余额里。

最开始我们的处理方案是定时任务扫描超时冻结单,自动发起取消。但很快踩了一个坑:支付网关的回调可能会晚于定时任务执行。如果用户实际上已经支付成功,但回调还没到,定时任务就把资金退回了,结果就是平台自己垫了一笔款项。

修复方案是给自动补偿任务加上“终态前置校验”逻辑:自动取消一个冻结单之前,必须先去订单服务确认订单状态,再去支付网关查询真实支付状态。只有两边都确认该订单没有支付成功,才能发起 Cancel。这确实增加了一次跨服务查询的耗时,但在资金场景里,宁可慢一点,也不能错一点。

5. 上线以来踩过的坑与解决实录

5.1 重复解冻:幂等键覆盖不全的教训

上线初期,我们出过一次重复解冻事故。当时线上有一个组合订单场景:用户在一个订单里购买多个商品,系统按商品维度拆分成多个子单,每个子单生成一张冻结单,但所有子单又共享同一个父订单号。

那时的解冻接口用的是“冻结单号 + 操作类型”作为幂等键。单个子单取消时没有问题,但父订单整体取消时,上游服务会把所有子单的解冻请求并发发过来,并且把父订单号作为回调参数。由于这些子单的冻结单号不同,幂等键没拦住,某些逻辑分支里同一个子单的冻结资金被处理了两次,用户余额凭空多了一笔。

这次事故给我们的教训是:幂等键的设计不能只看接口入参,必须回到业务聚合维度去思考。后来我们把幂等键调整成“用户ID + 父订单号 + 子订单号 + 操作类型”,同时把账户流水表的 unique_key 同步升级,这才彻底堵住漏洞。

5.2 消息乱序导致状态回退

另一个坑发生在可靠消息链路。确认支付和取消退订两个业务事件可能同时进入消息队列。如果队列没有做顺序保障,两个事件的消费顺序无法保证,当“取消退回”先执行完成、“确认扣款”后到达时,后者因为冻结单已经进入“已退回”终态而返回“幂等成功”,但实际语义上确认扣款是失败的,订单中心会认为扣款已经完成。

这个问题的本质是同一笔业务的状态机竞争。我们通过三层手段来规避:

第一,状态机本身必须严格校验,从“已退回”状态永远不允许跳转到“已扣款”,从数据库层面锁死状态迁移路径; 第二,消息队列按用户 ID 做哈希路由,同一用户的所有资金消息发送到同一个分区,保证消费端按顺序处理; 第三,冻结单表加上版本号,状态更新时带上 version 条件,旧版本消息直接丢弃,从逻辑上避免旧事件的晚到覆盖新状态。

5.3 订单、库存与资金两侧一致性的边界

取消订单这个动作,订单中心需要同时协调库存服务和钱包服务。早期版本是订单中心按顺序调用:先调库存服务,再调钱包服务,最后更新订单状态。问题很明显:库存释放成功了,钱包解冻失败,订单中心如果继续标记取消,就出现“库存没了但钱没退”的不一致;如果停下不处理,又可能出现“库存释放了但订单还挂在待处理”的中间状态。

后来我们把订单中心改成状态机驱动模式。订单取消请求进来后,先把订单置为“取消中”中间态,然后同时向库存服务和钱包服务发送 TCC 请求。两个服务的处理结果都成功,订单才进入“已取消”终态;如果有一个服务失败,订单保持“取消中”状态,由可靠消息和定时任务持续推动重试,直到两侧都收敛到终态或者人工介入。

这里还有一个容易忽略的点:库存释放和资金解冻没有先后依赖,可以并行触发,但必须保证两侧最终都成功。对账系统同样要同时核对库存单和冻结单,不能只盯着资金这一侧。

5.4 性能优化:批量解冻与热点账户行锁

上线一段时间后,我们遇到一个新的性能瓶颈:同一用户短时间内大量取消订单时,钱包账户的余额行会被反复锁定,数据库出现明显的行锁等待。

第一个优化是批量解冻。原来一次取消只处理一张冻结单,每个请求都要对用户余额行做一次SELECT ... FOR UPDATE。现在改为批量接口:一次事务里加载该用户的多张冻结单,在一条 SQL 里完成余额扣减和冻结余额回退,显著减少了事务次数和锁等待时间。

第二个优化是把余额字段拆细。原来用户账户表里同时存可用余额和冻结余额,任何一次冻结、解冻、扣款都会更新同一行数据。我们把账户余额与账户流水拆成更细的结构,冻结余额的变动尽量通过追加流水来实现,减少对账户余额行的直接更新。

第三个优化是对账任务的执行方式。大表扫描对账任务高峰期会抢占数据库 IO,我们改成 ID 分片加多线程分页处理,同时把扫描范围按时间窗口和业务状态过滤,避免全表扫。

最后分享一点个人体会。分布式事务这种话题,网上讨论方案选型、框架对比的资料很多,但真正到了生产环境,你会发现最靠得住的永远是三层组合:规范的 TCC 业务实现、可靠的消息兜底、以及一套把不一致数据自动找出来的对账平台。方案可以争论,框架可以换,但“出了问题能发现、能定位、能止损”这条底线,什么时候都不能丢。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/9 23:39:39

STM32 IAP实战:YMODEM协议Bootloader设计与跳转卡死排查

简介&#xff1a;面向STM32嵌入式开发者的IAP Bootloader实践资料&#xff0c;基于YModem协议实现串口在线升级方案&#xff0c;完整覆盖bootloader启动、数据接收、CRC校验、Flash写入及跳转APP的关键流程。压缩包共475个文件&#xff0c;以C语言源码、头文件、编译生成的.o/.…

作者头像 李华
网站建设 2026/9/9 23:34:24

grblHAL入门:从AVR到STM32/RP2040的移植实战与源码解析

简介&#xff1a;grbl是CNC领域广泛使用的开源运动控制固件&#xff0c;但原生版本主要运行于8位AVR平台&#xff0c;性能与外设扩展受限。grblHAL是其1.1f版本的HALified移植分支&#xff0c;专门面向ESP32、STM32、MSP432、LPC17xx、SAMD21、TM4C等32位处理器&#xff0c;解决…

作者头像 李华