![]() |
这件事让我想了很久。我们当时明明跑了用例、写了断言,为什么还是漏了?后来复盘才明白:我们测的是”正常流程能不能跑通”,而不是”异常流程会不会把钱搞错”。银行系统怕的不是功能做不出来,而是钱在并发、超时、重试、回调这些混乱场景下,账对不上、钱多扣了
所以这篇文章,我想把”交易幂等性”和”数据一致性”这两块硬骨头,掰开揉碎讲给你听,把概念、测试方法、用例设计、代码实现、真实踩坑一条线讲清楚
一、什么是幂等性?为什么银行系统必须做幂等设计
先说定义。幂等(Idempotent),在数学和计算机里指的是:同一个操作,无论执行一次还是执行多次,对系统状态产生的结果是一致的。翻译成人话就是——你重复点、重复发、重复调用,系统不会因为你多点了几下就多干几件事
为什么银行系统必须做幂等?因为支付链路天生就充满了”不确定”。我画一张支付链路的图,你一看就明白断点都在哪
图片引自微信公众号,扫码关注阅读原文支付链路 6 节点 4 断点:每个断点都可能因重试/重复请求产生多笔操作
你看这张图,一笔支付从”用户/前端”走到”回调系统”,中间有 四个关键断点。每一个断点的上游,都可能出现”请求发了但响应没回来”的情况,于是上游就会重试,于是同一个业务动作可能被送达两次。四个断点分别是:
- 断点 1:订单创建 / 预扣
前端重复点击、弱网重发,可能生成两笔订单或两次预扣。这是”重复下单”的典型来源 - 断点 2:支付受理 / 扣款
支付网关超时,客户端重试,核心系统如果没拦住,就出现”一笔钱扣两次” - 断点 3:渠道回调 / 通知
第三方支付(银联、网联、微信、支付宝)的回调,网络抖动可能重复推送同一条支付结果,系统若照单全收就会重复入账 - 断点 4:发货 / 入账
下游系统(如积分、权益、账务核心)消费消息时,消息队列可能重复投递,导致重复发放
这四处里,只要有任意一处没做幂等,客户就可能在某一刻发现”我的钱怎么少了”或者”我的权益怎么没到账又来了”。银行系统对资金的要求是分毫不差,所以幂等不是”加分项”,而是”保命项”
|
|
二、幂等性测试的 3 种经典场景
讲完概念,我们落地到测试。幂等测试不是拍脑袋想用例,它有三类绕不开的经典场景。这三类覆盖了银行系统里 90% 以上的重复请求来源
图片引自微信公众号,扫码关注阅读原文三类幂等场景对比:超时重试、重复点击、MQ 重复消费
场景一:网络超时重试
这是银行系统里常见的重复来源。支付网关调用核心系统,核心系统其实已经扣款成功,但响应在返回的路上丢了(超时)。客户端(App、网银)没收到明确成功/失败,按设计发起重试。如果核心系统靠”订单号 + 状态”判断,而不是靠”幂等键”拦截,第二次请求就会再扣一次
|
测试手顺:模拟”首次请求核心已处理但客户端视它为超时,随后用相同幂等键重试”。断言:账户只扣一次,流水只记一笔 |
场景二:前端重复点击
用户在弱网环境下,点”确认支付”没反应,连点两三下。如果前端没做按钮置灰 / 防抖,后端就会收到多个携带相同业务参数的请求。这种场景在手机银行、POS 收单里极常见
|
测试手顺:用压测工具或脚本,在毫秒级间隔内并发发送 N 个相同业务请求(同一订单号、同一幂等键),断言全程只落一笔 |
场景三:消息队列重复消费
银行核心大量使用消息队列做解耦(如支付成功后发消息通知积分系统、风控系统)。消息中间件普遍保证的是”至少一次投递”(At-least-once),也就是可能重复投。消费者如果不在业务层做幂等,重复消息就会被处理两遍——比如给用户发两次优惠券
|
测试手顺:向队列发送同一条消息两次(或手动重发),观察消费者是否只产生一次业务效果。常用手段是消费端用”消息唯一 ID + 去重表”拦截 |
|
容易忽略的一点:这三类场景的验证手法其实一致——都是”用相同的业务标识重复提交,看结果是否只生效一次”。区别在于触发方式和落在系统的哪一环。测试设计时要让用例能覆盖到每一环,而不是只在接口层打一发就完事 |
三、测试用例设计:幂等性边界场景全覆盖
到了用例设计环节。很多团队幂等测试只写了”重复提交一次,期望不重复扣款”这一条,这显然不够。幂等的边界场景非常多,我整理成了一个矩阵
图片引自微信公众号,扫码关注阅读原文我把用例按两个维度展开:横向是触发方式(正向正常、超时后重试、高并发重复、幂等键冲突),纵向是业务时机(单请求、第三方回调延迟、幂等键过期后重试)。交叉出来的格子,就是我们该写的用例。下面给一张可直接拿去用的详细表
|
|
|
|
|
|
|
|---|---|---|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
这张表我特意把”幂等键冲突”和”键过期”两个容易被漏掉的边界也列进去了。讲两个容易踩的细节:
关于幂等键冲突(ID-04)
幂等键要保证”业务唯一”,但又不能太宽。如果键设计成纯”用户 ID”,那这个用户的所有交易都会被当成重复;如果设计成”订单号”,那订单号生成规则一旦有缺陷(比如依赖时间精度不够),就会撞车。测试时要专门构造”不同业务但键相同”的 case,验证系统是按业务维度区分,而不是一刀切
关于键过期(ID-06)
Redis 里的幂等键一般设 TTL(比如 5 到 15 分钟)。如果业务本身处理时间可能超过 TTL,过期后重复请求会被当成新请求再扣一次。测试要验证 TTL 设置和业务超时是否匹配,以及过期后的行为是否符合预期(是允许再处理,还是拒绝)
配套可运行代码:Python + Redis 幂等测试
下面这段脚本把”幂等键生成 + 超时模拟 + 并发测试”三件事都做齐了,初学者装好 redis-py 就能直接跑。核心是用 Redis 的 SET key value NX EX 300 原子操作,保证”同一幂等键只被首个请求抢占”,其余重复请求直接返回 DUPLICATE,不二次扣款
import redisimport threadingimport timer = redis.Redis(host='127.0.0.1', port=6379, db=0, decode_responses=True)def build_idempotency_key(user_id, order_id):# 幂等键:业务维度唯一,由 用户ID + 订单ID 组成return "idem:" + user_id + ":" + order_iddef pay_once(key, amount):# SET NX EX —— 不存在才写入,5分钟过期,原子操作ok = r.set(key, "PROCESSING", nx=True, ex=300)if not ok:return "DUPLICATE" # 已存在 = 重复请求,直接返回,不二次扣款try:time.sleep(0.05) # 模拟核心扣款逻辑r.set(key, "SUCCESS", xx=True)return "SUCCESS"except Exception:r.delete(key) # 失败清理,允许客户端重试return "FAILED"def simulate_timeout_and_retry(key, amount):# 首次请求超时(未返回结果),客户端用相同幂等键重试r.set(key, "PROCESSING", nx=True, ex=300)time.sleep(2) # 模拟网络超时窗口return pay_once(key, amount) # 第二次应识别为重复def concurrent_test(key, amount, threads=10):results = []lock = threading.Lock()def worker():res = pay_once(key, amount)with lock:results.append(res)ts = [threading.Thread(target=worker) for _ in range(threads)]for t in ts: t.start()for t in ts: t.join()return results # 应只有 1 个 SUCCESS,其余 DUPLICATEif __name__ == "__main__":k = build_idempotency_key("u1001", "o20260816")# 用例1:并发重复提交,断言只成功 1 次print(concurrent_test(k, 100.0))# 用例2:超时重试,断言不二次扣款print(simulate_timeout_and_retry("k2", 50.0))
|
|
四、数据一致性:银行核心 4 表一致性验证
讲完幂等,进入更硬核的一块:数据一致性。幂等保证”不重复处理”,一致性保证”账算得平”。银行核心里,有四张表是资金准确性的命根子,它们必须始终保持一致
图片引自微信公众号,扫码关注阅读原文四张表各自的职责
- 账务表(t_account):
记录每一笔会计分录(借、贷),是”发生了什么”的源头。 - 流水表(t_journal):
记录交易流水,一笔业务对应一笔或多笔流水,是”逐笔痕迹”。 - 余额表(t_balance):
记录每个账户当前余额,是”现在剩多少”的快照。 - 对账表(t_recon):
记录内外部对账结果,用来发现并解释差异。
它们之间的勾稽关系必须成立:流水表的总借贷发生额,必须和余额表的变动一致;账务表的借贷必须平衡;对账表要能解释每一笔未平差异。下面我给出可直接建表和校验的 SQL
建表与样例数据
-- 账务表:每笔会计分录CREATE TABLE t_account (id BIGINT PRIMARY KEY AUTO_INCREMENT,biz_no VARCHAR(64) NOT NULL COMMENT '业务流水号(幂等键)',acct_no VARCHAR(32) NOT NULL COMMENT '账户号',dr_amount DECIMAL(18,2) DEFAULT 0 COMMENT '借方金额',cr_amount DECIMAL(18,2) DEFAULT 0 COMMENT '贷方金额',create_time DATETIME NOT NULL) COMMENT='账务表';-- 流水表:逐笔交易痕迹CREATE TABLE t_journal (id BIGINT PRIMARY KEY AUTO_INCREMENT,biz_no VARCHAR(64) NOT NULL,acct_no VARCHAR(32) NOT NULL,amount DECIMAL(18,2) NOT NULL COMMENT '交易金额',direction CHAR(1) NOT NULL COMMENT 'D借方 C贷方',create_time DATETIME NOT NULL) COMMENT='流水表';-- 余额表:账户当前余额CREATE TABLE t_balance (acct_no VARCHAR(32) PRIMARY KEY,balance DECIMAL(18,2) NOT NULL DEFAULT 0,update_time DATETIME NOT NULL) COMMENT='余额表';-- 对账表:记录未平差异CREATE TABLE t_recon (id BIGINT PRIMARY KEY AUTO_INCREMENT,biz_no VARCHAR(64),diff_amount DECIMAL(18,2),status CHAR(1) COMMENT '0未平 1已平',create_time DATETIME) COMMENT='对账表';
一致性校验脚本
下面这段 SQL 是测试同学可以每天跑、每次发版跑的”体检脚本”。它检查三类不一致:借贷不平衡、余额与流水对不上、存在未解释的差异
-- 校验1:账务表借贷是否平衡(每个 biz_no 借贷应相等)SELECT biz_no,SUM(dr_amount) AS total_dr,SUM(cr_amount) AS total_crFROM t_accountGROUP BY biz_noHAVING ABS(SUM(dr_amount) - SUM(cr_amount)) > 0.005;-- 返回空结果 = 借贷平衡;有行 = 存在不平衡-- 校验2:余额表的变动是否等于流水的借贷净额SELECT j.acct_no,b.balance AS cur_balance,(SELECT SUM(CASE WHEN direction='C' THEN amount ELSE -amount END)FROM t_journal j2 WHERE j2.acct_no = j.acct_no) AS net_flowFROM t_journal jJOIN t_balance b ON b.acct_no = j.acct_noGROUP BY j.acct_no, b.balanceHAVING ABS(b.balance - (SELECT SUM(CASE WHEN direction='C' THEN amount ELSE -amount END)FROM t_journal j3 WHERE j3.acct_no = j.acct_no)) > 0.005;-- 返回空 = 余额与流水一致-- 校验3:对账表中仍存在未平差异的记录SELECT * FROM t_recon WHERE status = '0';-- 返回空 = 无未平差异;有行 = 需人工干预-- 校验4:幂等性对账——同一 biz_no 不应出现多笔"已生效"流水SELECT biz_no, COUNT(*) cntFROM t_journalGROUP BY biz_noHAVING COUNT(*) > 1;-- 返回空 = 无重复生效;有行 = 幂等可能失效,排查
ABS(差) > 0.005 的容差写法,这是银行对账的常规做法 |
五、分布式事务一致性测试:TCC 模式与 Saga 模式
银行核心现在普遍微服务化,一笔”跨行转账”可能要调用账户服务、清结算服务、通知服务。这时候单库事务不够用了,就得上分布式事务。两种主流补偿方案是 TCC 和 Saga,测试策略也截然不同
图片引自微信公众号,扫码关注阅读原文TCC 模式
TCC 把每个操作拆成三阶段:Try(预留/冻结资源,比如先冻结账户里的钱)、Confirm(真正提交,扣掉冻结的钱)、Cancel(释放,把钱解冻回去)。它的特点是”先占坑再结算”,所以能保证较强的一致性,适合短事务、资金敏感场景
|
测试策略: ①Confirm 必须在 Try 成功之后才能执行,Try 没成功就 Confirm 应被拒绝; ②Cancel 要能完整回滚 Try 的冻结; ③网络分区下,Confirm/Cancel 要能幂等重试(补偿操作本身也要幂等!) |
Saga 模式
Saga 把长事务拆成一串本地事务 T1→T2→T3,任何一个失败,就按相反顺序执行补偿 C1、C2……它是“终态一致”,适合跨多个服务的长流程(比如”转账+发短信+记积分”)
|
测试策略:重点测补偿链的正确性——T2 失败时,C1 必须被正确触发且仅触发一次;补偿本身也要幂等;还要测”补偿过程中又失败”的二次补偿。Saga 的坑在于补偿顺序和补偿失败,测试用例要覆盖”正向到哪一步断、就从哪一步反向补” |
|
两种模式怎么选来测:TCC 看”冻结→提交/释放”是否严丝合缝;Saga 看”正向链断在哪、补偿链是否闭环”。无论哪种,补偿操作本身必须幂等,否则会出现”为了回滚又多扣一次”的二次事故 |
TCC 模式测试 Python 示例
下面这段模拟 TCC 的 Try/Confirm/Cancel,并验证”Confirm 在 Try 未成功时应被拒绝”以及”Cancel 幂等可重复调用”
import threading# 用字典模拟账户与冻结状态(生产用数据库+分布式锁)class TccTransfer:def __init__(self):self.balance = {"A": 1000.0, "B": 0.0}self.frozen = {} # biz_no -> 冻结金额self.confirmed = set() # 已确认的幂等键self.lock = threading.Lock()def try_lock(self, biz_no, payer, amount):with self.lock:if biz_no in self.frozen:return "DUPLICATE_TRY" # 幂等:重复 Try 直接返回if self.balance[payer] < amount:return "INSUFFICIENT"self.balance[payer] -= amountself.frozen[biz_no] = amountreturn "TRY_OK"def confirm(self, biz_no, payer, payee, amount):with self.lock:if biz_no not in self.frozen:return "REJECT_NO_TRY" # Try 未成功,拒绝 Confirmif biz_no in self.confirmed:return "DUPLICATE_CONFIRM" # 幂等self.balance[payee] += amountself.frozen.pop(biz_no, 0)self.confirmed.add(biz_no)return "CONFIRM_OK"def cancel(self, biz_no, payer, amount):with self.lock:if biz_no not in self.frozen:return "NO_NEED" # 没冻结就无需释放self.balance[payer] += self.frozen.pop(biz_no, 0) # 幂等:多次调用结果一致return "CANCEL_OK"# ===== 测试开始 =====if __name__ == "__main__":t = TccTransfer()bn = "biz_20260816_001"# 用例1:Try 成功后再 Confirmprint(t.try_lock(bn, "A", 200.0)) # TRY_OKprint(t.confirm(bn, "A", "B", 200.0)) # CONFIRM_OKprint(t.confirm(bn, "A", "B", 200.0)) # DUPLICATE_CONFIRM(幂等)# 用例2:未 Try 直接 Confirm,应被拒绝t2 = TccTransfer()print(t2.confirm("no_try", "A", "B", 50.0)) # REJECT_NO_TRY# 用例3:Cancel 幂等,调两次结果一致t3 = TccTransfer()t3.try_lock("biz_x", "A", 100.0)print(t3.cancel("biz_x", "A", 100.0)) # CANCEL_OKprint(t3.cancel("biz_x", "A", 100.0)) # NO_NEED(已释放)print("A余额=", t3.balance["A"]) # 应为 1000.0,钱回来了
六、实战讲解:某支付系统重复下单故障的全链路复盘
回到开头那个”被扣两次钱”的故障。这一节我把它完整复盘一遍,从根因、复现、修复到验证,带你走一遍真实测试该怎么做
图片引自微信公众号,扫码关注阅读原文故障根因
根因有两个,叠在一起才出事:
- 前端无防抖:
还款按钮在请求返回前没有置灰,用户连点产生两个相同业务请求 - 核心未用幂等键拦截:
核心系统靠”订单状态”判断,但两个请求几乎同时到达,都读到”待支付”,于是都往下走扣款逻辑。幂等键本应在 Redis 用 SET NX立刻拦住第二个,但当时的实现里幂等键是在”扣款成功后”才写,等于门是事后才关的
|
根因:幂等校验的位置错了——它发生在”扣款之后”而不是”扣款之前”。任何把幂等当成”事后补丁”的设计,在并发面前都会漏 |
测试复现
我们用文章开头的 Redis 并发代码复现了它:同一个幂等键,10 个线程同时打,期望只有 1 个 SUCCESS。在旧实现里,因为键是事后写,出现了 2 到 3 个 SUCCESS——复现成功,问题坐实
修复方案
- 前端:
点击后立即置灰按钮,请求结束(成功或失败)才恢复,并加 1 秒防抖 - 核心:
把幂等键校验提前到”扣款逻辑之前”,用 SET key value NX EX 300原子操作。只有抢到锁的那个请求继续扣款,其余直接返回”已受理/重复请求”
验证用例
修复后我们补了三组验证:
- 并发验证:
同键 10 并发,断言恰好 1 个 SUCCESS,9 个 DUPLICATE,余额只减一次 - 超时重试验证:
首次处理完但客户端判超时,用同键重试,断言不二次扣款 - 对账验证:
跑第四节的”校验4(同 biz_no 不应多笔生效流水)”,断言返回空
|
复盘结论:这次故障不是”功能没做”,而是”异常没测”。我们的用例库后来把”并发重复 + 幂等键位置”作为支付类接口的必测项,每次发版自动回归。钱的事,宁可多测十遍,不能漏掉一回 |
七、银行测试工程师必知的 4 类踩坑案例
下面四类,是银行测试里容易翻车的地方,每一类我都配了对照,作为检查清单用
图片引自微信公众号,扫码关注阅读原文坑一:重复下单
如上所述,前端防抖缺失 + 幂等键校验位置错误。对策是把幂等键校验前置到业务流程开头,用 Redis 原子操作抢占
坑二:余额扣减超限
账户余额扣减如果只在应用层判断”余额够不够”,并发下会超卖——两个请求都读到”余额 100″,都扣 80,结果变负。对策是数据库层用行锁或UPDATE ... SET balance = balance - 80 WHERE balance >= 80这种带条件的原子更新,让数据库保证不超扣
-- 正确的余额扣减:条件更新,数据库保证原子性UPDATE t_balanceSET balance = balance - 80.00,update_time = NOW()WHERE acct_no = 'A'AND balance >= 80.00;-- 返回影响行数=1 才表示扣款成功;=0 表示余额不足,绝不超扣
坑三:冲正失败
交易失败后要做冲正(把钱退回)。如果冲正本身没做幂等,重试冲正时可能退两次。对策是冲正也要带自己的幂等键,且冲正失败要进对账表等待人工/自动二次处理,不能静默丢
坑四:对账不平
日终对账发现”核心账和渠道账差几毛钱”。常见原因是:漏记某笔手续费、精度处理不一致、或者某笔流水状态机跳变没记全。对策是建立”第四节那套校验脚本”做常态化体检,并且对账差异必须留痕到 t_recon,不允许无解释地抹平
|
总结:这四类坑,本质都是同一句话——“在异常、并发、重试、补偿这些混乱场景下,钱和账还能不能算平”。把幂等和一致性测到位,这四类坑至少能挡掉八成 |

