退款是电商系统里最容易出资金事故的环节。一笔退款操作通常要同时改动订单状态、写入退款流水、更新账户余额,这几件事必须要么全部成功,要么全部失败,任何一个中间状态被读到,都可能导致重复退款或者账目对不上。MySQL作为大多数业务系统的核心存储,其事务机制、锁机制和唯一索引约束,正好可以为退款流程提供底层保障。本文从表结构设计讲起,逐步给出事务代码、防重复退款方案以及并发场景下的优化建议。

一、退款相关的表结构设计
退款流程的第一步是把数据模型建对。核心是三张表:订单表、退款单表和资金流水表。订单表记录主交易信息,退款单表记录每一次退款申请,资金流水表则记录账户余额的每一次变动,方便后续对账。
订单表至少要包含订单号、金额、已退金额、订单状态、版本号等字段。已退金额这个字段非常关键,它是判断能否继续退款、能退多少的直接依据。退款单表要有自己的退款单号、关联的订单号、退款金额、退款状态以及第三方支付渠道的退款流水号。
-- 订单表 CREATE TABLE `t_order` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `order_no` VARCHAR(32) NOT NULL COMMENT '订单号', `amount` DECIMAL(12,2) NOT NULL COMMENT '订单总金额', `refunded_amount` DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT '已退金额', `status` TINYINT NOT NULL COMMENT '1待支付 2已支付 3部分退款 4全额退款', `version` INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 退款单表 CREATE TABLE `t_refund` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `refund_no` VARCHAR(32) NOT NULL COMMENT '退款单号', `order_no` VARCHAR(32) NOT NULL COMMENT '关联订单号', `refund_amount` DECIMAL(12,2) NOT NULL COMMENT '本次退款金额', `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0处理中 1成功 2失败', `channel_refund_no` VARCHAR(64) DEFAULT NULL COMMENT '渠道退款流水号', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_refund_no` (`refund_no`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
这里有两个设计要点值得展开。第一,金额字段必须用DECIMAL而不是FLOAT或DOUBLE,浮点数存在精度丢失,一分钱的误差在资金场景都是不可接受的。第二,退款单号要建唯一索引,它不只是查询加速,更是防止重复提交退款申请的数据库层防线,后面会详细讲。
二、用事务保证退款操作的原子性
一次完整的退款至少要做三件事:更新订单表的已退金额和状态、插入退款流水、如果涉及余额账户还要更新余额。这三件事必须包在一个事务里,利用InnoDB的ACID特性保证原子性。核心SQL示例如下:
START TRANSACTION;
-- 1. 锁定订单行,FOR UPDATE会在该行上加排他锁
SELECT amount, refunded_amount, status
FROM t_order
WHERE order_no = '20240516000123'
FOR UPDATE;
-- 2. 校验是否满足退款条件(应用层判断)
-- 可退金额 = amount - refunded_amount,必须大于等于本次退款金额
-- 且订单状态必须是已支付或部分退款
-- 3. 更新订单已退金额,附带条件防止脏数据
UPDATE t_order
SET refunded_amount = refunded_amount + 100.00,
status = CASE
WHEN refunded_amount + 100.00 >= amount THEN 4
ELSE 3
END
WHERE order_no = '20240516000123';
-- 4. 写入退款单
INSERT INTO t_refund (refund_no, order_no, refund_amount, status)
VALUES ('RF20240516000001', '20240516000123', 100.00, 0);
-- 5. 记录资金流水(如涉及余额账户)
INSERT INTO t_fund_flow (biz_no, account_id, amount, type)
VALUES ('RF20240516000001', 88, 100.00, 'REFUND_IN');
COMMIT;
SELECT ... FOR UPDATE是这套方案的核心。它对订单行加了排他锁,在事务提交之前,其他事务再来锁同一行会被阻塞等待。这样即使在同一瞬间有多个退款请求打到同一笔订单上,它们也会排队串行执行,后到的请求等锁释放后重新读到最新的已退金额,从而避免超退。要注意FOR UPDATE必须在事务中才生效,且建议走唯一索引order_no查询,如果走了全表扫描,锁的范围会扩大,并发性能会急剧下降。
另外注意UPDATE语句里对status的计算用了refunded_amount + 100.00而不是直接引用SET之后的值,因为MySQL的UPDATE语句中,SET赋值是从左到右顺序生效的,同一个字段先更新再引用会拿到新值,这里显式重算更清晰。也可以把这条UPDATE改写成带WHERE条件的防御形式,例如加上AND refunded_amount + 100.00 <= amount,然后检查受影响行数,如果为0说明校验失败直接回滚,这是双保险的写法。
三、防止重复退款的三道防线
重复退款是资金损失的最直接来源,光靠前端按钮置灰或接口幂等参数是不够的,数据库层面要有兜底手段。实践中一般叠加三道防线。
第一道防线是唯一索引。为同一笔订单的退款请求生成退款单号时,如果是「整单只允许退一次」的业务,可以给t_refund表的order_no字段也加上唯一索引。这样第二次插入退款单会直接报Duplicate entry错误,事务回滚,物理上杜绝重复退款。如果支持多次部分退款,则可以用「订单号加退款序号」的组合唯一索引,或者依赖业务侧生成的幂等退款号。
第二道防线是状态机校验。退款前必须检查订单当前状态,只有处于已支付或部分退款状态的订单才允许发起退款。这个校验要放在事务内、拿到行锁之后做,而不是在事务外先查一次。事务外查询存在时间窗口,查的时候状态合法,等真正更新时可能已经被别的请求改掉了,这正是典型的TOCTOU(检查时与使用时不一致)问题。
第三道防线是乐观锁版本号。对于不希望长时间持有行锁的场景,可以在UPDATE语句中带上version条件:
UPDATE t_order
SET refunded_amount = refunded_amount + 100.00,
version = version + 1
WHERE order_no = '20240516000123'
AND version = 5
AND refunded_amount + 100.00 <= amount;
-- 受影响行数为0说明并发冲突或校验失败,需要重试或拒绝
这种写法不阻塞读操作,冲突时由应用层决定重试还是返回失败,适合退款频率不高但读订单非常频繁的系统。缺点是需要处理重试逻辑,代码复杂度略高。
四、异步退款与第三方渠道的一致性问题
真实业务中,退款往往要调用微信、支付宝等第三方渠道的退款接口,这个过程是异步的且不可控。如果把调用第三方接口放在数据库事务里,一次网络抖动就会让事务长时间挂起,行锁迟迟不释放,拖垮整个连接池。正确做法是把本地事务与外部调用拆开,采用「先落单、后回调」的模式。
第一步在事务内完成:校验、更新订单金额、插入一条状态为处理中的退款单,然后立即提交。第二步在事务外调用第三方退款接口,调用成功后把渠道流水号回写到退款单。第三步是接收第三方的异步回调,将退款单状态更新为成功或失败。如果失败,需要再开一个事务把订单的已退金额回退,并更新订单状态。整个退款单的生命周期就是一个状态机:处理中、成功、失败、超时关闭。
这里要特别处理超时问题。第三方回调可能丢失,所以需要定时任务扫描长时间处于处理中的退款单,主动向渠道查询真实退款结果,再决定推进状态还是关闭退款单。查询时可以先查退款单表拿到退款号,去渠道确认后,用条件UPDATE推进状态:
UPDATE t_refund
SET status = 1,
channel_refund_no = 'wx_rf_20240516abc'
WHERE refund_no = 'RF20240516000001'
AND status = 0;
-- 条件 status = 0 保证只有首次确认会生效,回调与定时任务并发时天然幂等
这条带状态条件的UPDATE是幂等的关键。回调可能重试多次,定时任务也可能与回调同时触发,但只有第一个执行的事务能把状态从0改为1,后续的UPDATE受影响行数为0,自然失效,不会重复处理。
五、常见坑与性能建议
最后总结几个实践中的高频问题。一是死锁:如果事务内先锁退款单再锁订单,而另一个事务顺序相反,就可能互相等待。解决办法是统一所有事务的加锁顺序,比如永远先锁订单行再操作其他表。二是长事务:事务内绝不能有RPC调用、发消息、写文件等慢操作,事务只包纯SQL。三是索引缺失导致锁升级:FOR UPDATE的查询条件如果没有命中索引,InnoDB在可重复读隔离级别下可能锁住大量记录甚至间隙,务必确认执行计划走的是唯一索引。
金额精度方面,所有计算交给SQL的DECIMAL运算,不要在应用层用浮点数算完再传回来。对账方面,建议每天定时核对订单已退金额与退款单成功金额之和,两者不等说明有流程漏洞,这套对账SQL也是排查问题的第一入口。把事务、行锁、唯一索引和状态机这四件事做扎实,一套纯MySQL驱动的退款流程就能做到资金安全与性能的平衡。