ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

模拟银行账户转账系统源码拆解:事务、幂等、死锁与对账

模拟银行账户转账系统源码拆解:事务、幂等、死锁与对账 简介面向 Java 初学者的银行账户转账模拟系统以图形界面与多线程机制实现适合正在学习 Swing 界面编程和线程并发的读者帮助理解多线程共享账户余额时的读写与同步问题。程序设定 A、B 两个初始余额均为 1000 元的账户由线程随机向对方转账转账金额不得大于当前余额余额为零即无法继续交易界面下方设有「交易开始」「清屏」按钮点击开始后按钮切换为「结束交易」结束时将全部记录写入 ab.txt。压缩包共 2 个 java 源文件分别承载窗口界面的构建与转账线程逻辑整体仅约 2KB结构紧凑便于导入 IDE 直接运行和二次改造。已有 4171 人学习下载可作为课程实验、并发练习或课程设计的参考范例帮助读者掌握界面事件响应、线程调度与交易记录持久化的完整实现思路。1. 银行账户转账几乎是每个做后端课程设计的人都写过的题目两个账户一扣一加包进事务跑通就算完事。可并发一开到 50余额对不上、偶发死锁、超时重试后钱被扣两次问题会一起冒出来。这份模拟银行账户转账系统的源码包价值不在“能转”而在它把账户表、流水表、事务边界、幂等占位和对账 SQL 都摊开摆在明面上能顺着一条链路看清钱从 A 到 B 的每一步也能看清哪一步最容易漏。适合正在做课程设计的学生、要给业务系统补一段结算逻辑的后端以及想把“事务到底加在哪一层”弄明白的开发者。下面按拆包顺序走数据模型、调用链路、幂等与对账、踩坑现场最后是一套能自己跑的验证方法。2. 数据模型先立住账户表、流水表、单据表怎么摆拿到包先别急着跑main把建表脚本翻出来看一遍基本能判断这套代码是“能演示”还是“能改”。模拟转账系统里最常见的偷懒做法是只建一张account表扣款balance balance - x入账balance balance x跑通了日志也漂亮。问题是这套结构没有任何回溯能力账一旦对不上你只能看着两个数字发呆不知道是哪一笔、哪个时刻、哪个方向出的错。所以合格的做法一定是三张表起步——账户、单据、流水各管一件事。2.1 快照与事实分离为什么流水表不能省账户表存的是快照回答“现在有多少钱”流水表存的是事实回答“钱是怎么变成现在这样的”。两者的关系类似库存与出入库单快照可以重算事实不能重写。只留快照的系统等于把账本烧了只记住余额出了问题连后悔药都没得吃。一笔转账在流水表里应该落两条记录一条出账DEBIT一条入账CREDIT金额相等、方向相反。这样就得到一个恒等式所有流水的出账金额之和永远等于入账金额之和误差为零。这条恒等式是对账时最强的断言比任何“我核对了一遍余额”都可靠。很多课程设计只落一条“转账流水”把 from/to 塞进同一行看着省事真出问题时你没法用 SQL 直接验证平衡性只能写代码绕。单据表则是另一层一次转账请求对应一行带唯一单号它既是业务凭证也是幂等键。有了它“这笔请求到底成功没有”这个问题才有地方回答。三张表的分工可以记成一句话单据管请求流水管变化账户管现状。2.2 建表 SQL三个约束把脏数据挡在数据库门外下面是我在这个包里看到的表结构我做了一点整理注释补全了可以直接拿去跑。注意引擎必须是 InnoDBMyISAM 没有事务也没有行锁这个场景下等于残废。-- 账户表只存当前状态balance 是快照 CREATE TABLE account ( id BIGINT NOT NULL AUTO_INCREMENT, account_no VARCHAR(32) NOT NULL COMMENT 账号业务唯一键, owner_name VARCHAR(64) NOT NULL COMMENT 户名演示用不存任何真实信息, balance DECIMAL(18,2) NOT NULL DEFAULT 0.00 COMMENT 可用余额单位元, status TINYINT NOT NULL DEFAULT 1 COMMENT 1正常 0冻结, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_account_no (account_no), CONSTRAINT ck_balance_nonneg CHECK (balance 0) -- 最后一道防线 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;-- 单据表一次转账请求一行order_no 同时是幂等键 CREATE TABLE transfer_order ( id BIGINT NOT NULL AUTO_INCREMENT, order_no VARCHAR(40) NOT NULL COMMENT 幂等键客户端生成或服务端雪花号, from_account VARCHAR(32) NOT NULL, to_account VARCHAR(32) NOT NULL, amount DECIMAL(18,2) NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT 0处理中 1成功 2失败, fail_reason VARCHAR(255) DEFAULT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no), -- 幂等靠它不靠先查后插 KEY idx_from_created (from_account, created_at) -- 查某账户最近转出用 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;-- 流水表只追加不更新一笔转账两条 CREATE TABLE account_flow ( id BIGINT NOT NULL AUTO_INCREMENT, order_no VARCHAR(40) NOT NULL, account_no VARCHAR(32) NOT NULL, direction TINYINT NOT NULL COMMENT 1出账 2入账, amount DECIMAL(18,2) NOT NULL, balance_after DECIMAL(18,2) NOT NULL COMMENT 记账后余额回溯时不用重算, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_order_acc_dir (order_no, account_no, direction), -- 防同一笔重复记账 KEY idx_acc_id (account_no, id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;三个约束是这套表结构的核心。uk_order_no让并发下只有一条单据能插进去重复请求在数据库层面就被挡住uk_order_acc_dir保证同一笔单据对同一账户同一方向只能记一次账重试逻辑写错了也不会双记ck_balance_nonneg是余额的兜底任何绕过业务校验的路径都会被数据库拒绝。提示MySQL 5.7 及更早版本会解析CHECK但不执行写上去只是注释。上线前用SELECT VERSION();确认8.0.16 以上才真正生效。如果只能跑 5.7这个断言必须由应用层和定时巡检补上。索引也在为后面的查询铺路。idx_acc_id (account_no, id)是给“查某账户流水按时间倒序翻页”准备的用自增 id 代替 created_at 排序可以避免同一秒内多条流水顺序不稳。idx_from_created用来做“某账户当日转出总额”这类风控口径的统计。2.3 余额存字段还是实时汇总三种方案的成本账新手最容易纠结的一点既然有流水表为什么还要在账户表冗余一个balance直接SUM不行吗能行但要看规模。三种常见方案的实际差别如下。方案余额怎么得到读性能写性能一致性风险适用场景只存流水SUM(CASE WHEN direction2 THEN amount ELSE -amount END)差随流水行数线性劣化好纯追加低账户少、流水少的课堂演示余额字段 流水直接读account.balance好主键或唯一索引命中中需行锁保护中字段与流水可能不一致绝大多数业务系统余额字段 日终校准读字段定时任务用流水重算比对好中低有对账任务的正式系统这个包用的是第二种也是我一般会选的读多写少的场景下把计算成本压到写入时一次性付掉。代价是必须在转账链路里保证“改余额”和“写流水”在同一个事务里否则就会出现账户扣了钱、流水没落下的单边账。第三种是在第二种基础上加定时校准属于线上系统的标配课程设计里加一个每天凌晨跑的比对任务成本很低答辩时也是个加分项。有一点要提前说清余额字段是冗余冗余就意味着可能不一致。所以系统里必须存在一条能从流水重算出余额的路径哪怕它平时不跑。这条路径就是你的后悔药没有它任何“余额好像不对”的反馈你都只能靠猜。3. 转账链路拆解一次请求里的锁、事务和回滚表建好了接下来看钱的路径。一次转账从 HTTP 进来经过参数校验、幂等占位、加锁、校验、扣款、入账、记流水、改单据状态最后提交事务。这条链路上任何一步的位置放错都会在并发下暴露出来。这一章拆三段事务边界画在哪、锁按什么顺序拿、隔离级别和超时怎么配。3.1 事务边界为什么Transactional只能贴在 Service 的公有方法上事务边界的原则很简单一个事务只包住必须原子的一段数据库操作越短越好。放在 Controller 上是错的把参数解析、序列化都包进去了放在 Mapper 上也不对一次转账要动四张表跨 Mapper正确位置是 Service 的公有方法而且这个方法必须是通过 Spring 代理调用的。下面这几件事千万别塞进事务里调用外部接口、发消息、写文件日志、睡几毫秒做“防重”。事务持有数据库连接和行锁任何耗时操作都在放大锁冲突窗口。我见过把一次 HTTP 回调写在事务里的实现压测时锁等待直接打满innodb_lock_wait_timeout一个接一个超时。常见做法是把外部调用挪到事务提交之后用事务同步回调或者本地消息表来保证最终一致。另一个容易忽略的点是rollbackFor。Spring 默认只在遇到RuntimeException和Error时回滚受检异常直接提交。也就是说你throw new IOException()之后扣款已经落库了。// 错误写法受检异常不回滚扣款会留在库里 Transactional public void transfer(TransferCmd cmd) throws Exception { ... } // 正确写法显式声明回滚异常类型 Transactional(rollbackFor Exception.class) public void transfer(TransferCmd cmd) { ... }参数说明rollbackFor Exception.class覆盖所有异常如果业务上确实有“部分失败也要提交”的场景用noRollbackFor单独排除别靠吞异常来实现吞异常是后面第 5 章要重点说的坑。3.2 加锁顺序与核心转账代码并发转账出死锁八成是锁顺序不一致。A 转 B 时先锁 A 再锁 BB 转 A 时先锁 B 再锁 A两个事务各持一把等对方InnoDB 检测到环路就干掉其中一个报Deadlock found when trying to get lock。解决办法不复杂所有涉及双账户的操作统一按账号字典序升序加锁。账号是字符串用compareTo排排完再依次SELECT ... FOR UPDATE。Service public class TransferService { private final AccountMapper accountMapper; private final TransferOrderMapper orderMapper; private final AccountFlowMapper flowMapper; Transactional(rollbackFor Exception.class, isolation Isolation.READ_COMMITTED) public TransferResult transfer(TransferCmd cmd) { String orderNo cmd.getOrderNo(); // 1. 抢占幂等键并发下只有一个线程能插进 uk_order_no try { orderMapper.insertProcessing(orderNo, cmd.getFrom(), cmd.getTo(), cmd.getAmount()); } catch (DuplicateKeyException e) { // 重复请求不再转账直接返回上一次的结果 return orderMapper.queryResult(orderNo); } // 2. 统一加锁顺序账号字典序小的先锁 String first cmd.getFrom().compareTo(cmd.getTo()) 0 ? cmd.getFrom() : cmd.getTo(); String second first.equals(cmd.getFrom()) ? cmd.getTo() : cmd.getFrom(); Account a accountMapper.lockByNo(first); // SELECT ... FOR UPDATE Account b accountMapper.lockByNo(second); if (a null || b null) { throw new BizException(账户不存在); } if (a.getStatus() ! 1 || b.getStatus() ! 1) { throw new BizException(账户状态异常); } // 3. 金额比较用 compareToBigDecimal 的 equals 会比较 scale if (a.getBalance().compareTo(cmd.getAmount()) 0) { throw new BizException(余额不足); } // 4. 带条件的扣款即便上面的判断失效这里也扣不成负数 int updated accountMapper.debit(cmd.getFrom(), cmd.getAmount()); if (updated ! 1) { throw new BizException(扣款失败余额不足); } accountMapper.credit(cmd.getTo(), cmd.getAmount()); // 5. 两条流水出账一条、入账一条各带记账后余额 flowMapper.insert(orderNo, cmd.getFrom(), 1, cmd.getAmount(), accountMapper.selectBalance(cmd.getFrom())); flowMapper.insert(orderNo, cmd.getTo(), 2, cmd.getAmount(), accountMapper.selectBalance(cmd.getTo())); orderMapper.markSuccess(orderNo); return TransferResult.success(orderNo); } }对应的 Mapper 里扣款语句必须带条件这是余额不为负的最终保障!-- 扣款把判断和更新合成一条语句消除检查与执行之间的窗口 -- update iddebit UPDATE account SET balance balance - #{amount}, updated_at NOW() WHERE account_no #{accountNo} AND balance gt; #{amount} /update !-- 入账不需要条件加钱永远安全 -- update idcredit UPDATE account SET balance balance #{amount}, updated_at NOW() WHERE account_no #{accountNo} /update逻辑说明第 1 步的幂等占位放在事务内、业务操作之前让重复请求在最早的地方被挡掉第 2 步的排序是关键它把“两个事务互相等”这件事从根上消除第 4 步的updated ! 1判断不能省它是把数据库返回的影响行数当作业务信号用——影响 0 行说明条件不成立直接抛异常回滚别硬着头皮往下走。参数说明isolation Isolation.READ_COMMITTED是可选项MySQL 默认的可重复读在这个场景下也能工作因为FOR UPDATE是当前读。但读已提交能让锁的持有范围更小一些配合短事务更稳。如果你的包用的是默认隔离级别别为了这一行去改先跑通再说。注意SELECT ... FOR UPDATE必须命中索引否则会退化成锁全表。确认account_no上有唯一索引且执行计划是typeeq_ref或ref。3.3 隔离级别、锁等待和连接池的三个参数代码写对了参数配错一样出事。这个包里我调整过三个值都比较关键。第一个是innodb_lock_wait_timeout默认 50 秒。转账这种短事务等 50 秒毫无意义只会把 Tomcat 线程和连接池一起拖死。改成 3 到 5 秒超时立刻失败让上游重试比干等强。-- 会话级先验证确认无副作用后再写进配置文件 SET SESSION innodb_lock_wait_timeout 5; SHOW VARIABLES LIKE innodb_lock_wait_timeout;第二个是 Spring 事务的timeout属性单位秒。它和数据库侧的锁等待超时是两个层次的东西前者管整个事务方法的执行时长后者只管等锁。两个都要设Transactional(timeout 10)配合数据库 5 秒能保证事务不会因为某条慢 SQL 无限挂起。第三个是连接池大小。转账事务在高峰期同时占用连接数的上限就是池子的上限。池子太小会排队太大会把数据库打满。经验值是从 10 到 20 起压测时看HikariPool的activeConnections和pendingThreads后者长期大于 0 就说明该加但别超过数据库max_connections的七成。还有一个参数常被忽略spring.datasource.hikari.connection-timeout默认 30 秒。转账接口的 RT 通常是几十毫秒连接等待 30 秒没有意义调到 3 秒左右让过载快速失败比雪崩好。4. 幂等与对账重复提交、超时重试之后账还对得上吗真实场景里转账请求不会只来一次。用户手抖点两下、网关超时自动重试、上游消息重复投递都会让你的接口收到同一个业务请求的多份副本。幂等不是加分项是底线。而对账是幂等失效时的兜底两者一套组合才能说这个系统“账是对的”。4.1 幂等占位用唯一索引抢锁别用先查后插先查后插在并发下必然翻车因为查询和插入之间存在窗口两个线程可以同时查到“不存在”。正确做法是把判断交给数据库order_no上加唯一索引插入成功的那一个线程继续执行失败的直接走重复请求分支。这就是常说的“用唯一约束做分布式锁”。但这里有个 Spring 层面的坑要提前避开。如果幂等占位和业务操作在同一个事务里插入抛出的DuplicateKeyException被catch住了事务本身可能已经被标记为 rollback-only后续任何操作都会在提交时炸掉报Transaction rolled back because it has been marked as rollback-only。不同版本的代理行为有差异稳妥的做法是把占位插入放到独立事务里。// 独立事务占位失败不影响外层捕获异常后事务仍是干净的 Transactional(propagation Propagation.REQUIRES_NEW, rollbackFor Exception.class) public boolean tryOccupy(String orderNo, TransferCmd cmd) { try { orderMapper.insertProcessing(orderNo, cmd.getFrom(), cmd.getTo(), cmd.getAmount()); return true; // 抢到了继续转账 } catch (DuplicateKeyException e) { return false; // 没抢到说明是重复请求 } }逻辑说明REQUIRES_NEW会挂起外层事务、新开一个插入失败只影响这个内层事务外层不受污染。返回false时外层直接查单据状态返回结果不再执行扣款。代价是多占一个连接所以这个方法必须极短不能在里面做任何查询以外的事。参数说明propagation选REQUIRES_NEW而不是NESTED因为NESTED依赖保存点某些连接池和驱动下行为不一致。业务上还有个细节如果占位成功了但后面的转账失败了单据状态会停在 0处理中。这时不能简单删掉重来得靠后面的状态机和对账处理。4.2 状态机与失败补偿0/1/2 三个状态够不够单据表里我用了三个状态0 处理中、1 成功、2 失败。够用但有前提——必须有一套任务去清理长期停在 0 的单据。处理中的单据意味着“占位成功但结果未知”。可能的原因包括进程被 kill、数据库连接断开、事务超时。这类单据不能直接判成功也不能直接判失败正确姿势是查流水如果account_flow里已经有这笔order_no的两条记录说明钱动过了把状态补成 1如果没有说明事务回滚了补成 2。这个判断依据流水而不是依据余额因为流水是只追加的事实。-- 清理僵尸单据30 分钟前还是处理中且没有任何流水 SELECT o.order_no FROM transfer_order o LEFT JOIN account_flow f ON f.order_no o.order_no WHERE o.status 0 AND o.created_at NOW() - INTERVAL 30 MINUTE GROUP BY o.order_no HAVING COUNT(f.id) 0;查到之后统一置为失败并记录原因。注意这条 SQL 要跑在只读副本或者低峰期LEFT JOIN GROUP BY在单据量大时并不便宜加个时间范围限定比如只扫最近三天。补偿逻辑一定要做成可重复执行的。定时任务重复跑、人工手动触发、服务重启补跑都可能发生。判断依据永远是“当前状态 流水是否存在”而不是“上次跑到哪了”。4.3 对账 SQL三条查询把不平的账捞出来对账不需要写代码三条 SQL 基本能覆盖课程设计和中小系统。第一条查单边账——单据成功但流水条数不对。-- 成功的单据流水必须正好两条不等于 2 的就是单边账 SELECT o.order_no, o.amount, COUNT(f.id) AS flow_cnt FROM transfer_order o LEFT JOIN account_flow f ON f.order_no o.order_no WHERE o.status 1 GROUP BY o.order_no, o.amount HAVING COUNT(f.id) 2;第二条查金额不平——出账和入账金额必须相等差值不为零就是脏数据。-- 同一单据内出账金额 - 入账金额 必须为 0 SELECT order_no, SUM(CASE WHEN direction 1 THEN amount ELSE -amount END) AS diff FROM account_flow GROUP BY order_no HAVING diff 0;第三条查余额断裂——每条流水的balance_after应该等于上一笔的balance_after加减本笔金额账户表当前余额应等于最后一笔流水的balance_after。第三条 SQL 稍长思路是用窗口函数取上一行。-- 8.0 以上用 LAG 取上一笔余额验证流水链是否连续 WITH f AS ( SELECT account_no, id, direction, amount, balance_after, LAG(balance_after) OVER (PARTITION BY account_no ORDER BY id) AS prev_balance FROM account_flow ) SELECT * FROM f WHERE prev_balance IS NOT NULL AND balance_after prev_balance (CASE WHEN direction 2 THEN amount ELSE -amount END);三条查询都返回空集才算这批数据是干净的。前两条可以随时跑第三条在流水量大时要限账户否则窗口函数会扫全表。5. 避坑排查死锁、负余额和“事务没回滚”的五个现场这一章是我自己在跑这套代码时踩到的五个现场按出现频率排。每条都按“现象 → 原因 → 解决”写症状描述尽量贴近你会在日志里看到的那行字。5.1 现象并发压测几分钟后开始报 Deadlock found现象50 并发双向转账前 30 秒正常之后日志里零星出现Deadlock found when trying to get lock; try restarting transaction接口返回 500重试后又能成功。原因两个方向的事务加锁顺序相反。A→B 先锁 AB→A 先锁 B交叉等待形成环路。InnoDB 检测到环路会主动杀掉代价小的那个事务被杀的抛出这个异常。另一个可能来源是SELECT ... FOR UPDATE没走到account_no索引退化成锁一批记录甚至全表锁范围一大任何交叉都可能死锁。解决按账号字典序统一加锁这是根本解。同时确认执行计划EXPLAIN SELECT * FROM account WHERE account_no ? FOR UPDATE;应该显示typeconst或eq_ref。如果业务层无法保证顺序比如批量代发把批量拆成单笔或者用SELECT ... FOR UPDATE时一次性按IN排序取回让数据库按索引顺序加锁而不是按IN列表顺序。5.2 现象A 账户扣了钱B 账户没收到单据状态是成功现象对账 SQL 查出单边账transfer_order状态为 1但account_flow只有一条出账记录B 的余额没变。原因入账和出账被拆到了两个事务里。常见写法是扣款方法加Transactional入账方法也加然后 Service 里顺序调用。两个方法各自提交中间任何失败都会留下半成品。另一种是同一种病在try-catch里把入账的异常吞了只打了日志事务照常提交。解决一笔转账的所有数据库写操作必须在同一个事务边界内debit、credit、两条流水、单据状态更新全部由最外层的Transactional覆盖。捕获异常的地方只允许两种处理要么原样抛出触发回滚要么记录后抛出。任何“catch 了只打日志”的代码都是定时炸弹。5.3 现象余额出现负数但业务校验没报错现象定时对账发现某账户balance -0.01查流水最后几笔扣款都成功但当时余额已经不够了。原因先SELECT查余额判断够不够再UPDATE扣款。两个动作之间有窗口另一个事务插进来把余额花掉了你的判断基于旧值扣款照常执行。这是最经典的检查与使用分离问题TOCTOU。还有一种情况是CHECK (balance 0)在 5.7 上不生效应用层又没做条件更新直接扣穿。解决把判断写进UPDATE的WHERE里也就是AND balance #{amount}靠影响行数判断成败。这比任何锁都轻。再加数据库CHECK兜底确认版本 8.0.16以及一条每日巡检 SQL查balance 0的账户哪怕理论上不可能也要让它报警。5.4 现象Transactional明明加了抛异常后数据还是落库了现象故意在转账后抛RuntimeException期望回滚结果余额被改了单据也变成成功了。原因五种常见失效场景——方法不是publicSpring 代理拦不到同一个类里this.method()自调用走不到代理异常被catch后没重新抛抛的是受检异常而没配rollbackFor类没有被 Spring 管理自己new出来的对象。这五种现象一样、原因各异排查时按这个顺序对。解决自调用场景把方法拆到另一个 Bean受检异常统一配rollbackFor Exception.class类上加Service且确保包扫描覆盖最省事的验证方式是在事务方法里手动抛一次异常看数据库有没有变化两分钟能排掉大部分疑虑。提示调试事务是否生效把日志级别开到logging.level.org.springframework.transactionDEBUG能看到Creating new transaction/Rolling back这类关键行比读代码快。5.5 现象对账差一分钱反复对数都对不上现象总额守恒的校验差 0.01逐个账户查又都能对上只有某些涉及手续费的记录不平。原因double参与金额计算。0.1 0.2在二进制浮点里不等于0.3这个误差会在多次累加后变成一分钱。另一种原因是在中间步骤就setScale四舍五入每步都舍一次误差累积。还有一种BigDecimal.equals()比较时把scale也比了1.0和1.00判不相等导致校验逻辑误判。解决金额全程BigDecimal且只用字符串构造new BigDecimal(0.01)绝不用new BigDecimal(0.01)。比较用compareTo() 0。舍入只在最终落库那一刻做一次setScale(2, RoundingMode.HALF_UP)。手续费在什么环节扣、按什么基数算写进注释别靠人记。6. 压测验证用 200 并发和三条断言验收账平不平跑通单笔转账只能说明代码能编译验证账对不对还得靠并发。我一般会写个最土的压测脚本先制造混乱再用断言收口比看一堆日志有说服力。#!/usr/bin/env bash # 200 个并发请求每个请求单号唯一同时故意发 50 个重复单号试幂等 BASEhttp://localhost:8080/api/transfer seq 1 200 | xargs -P 50 -I{} curl -s -o /dev/null -w %{http_code}\n \ -X POST $BASE \ -H Content-Type: application/json \ -d {\orderNo\:\T20240{}\,\from\:\ACC0001\,\to\:\ACC0002\,\amount\:\1.00\} # 幂等验证同一个单号打 50 次最终只应有一条单据、两条流水 seq 1 50 | xargs -P 50 -I{} curl -s -o /dev/null \ -X POST $BASE \ -H Content-Type: application/json \ -d {orderNo:IDEMPOTENT-001,from:ACC0003,to:ACC0004,amount:2.00}脚本里-P 50是并发度-w %{http_code}把状态码打出来方便统计失败率。跑完先看接口层200 个请求里成功多少、超时多少、5xx 多少。低于 95% 成功率就先去查锁等待和连接池别急着分析数据。数据层的验收是三条断言全过才算这套实现站得住。-- 断言一总额守恒转账不创造也不销毁钱 SELECT SUM(balance) AS total_after FROM account; -- 应等于压测前记录的初始总额 -- 断言二成功单据的流水净额必须为 0 SELECT order_no, SUM(CASE WHEN direction 1 THEN amount ELSE -amount END) AS diff FROM account_flow GROUP BY order_no HAVING diff 0; -- 断言三幂等单号只被真正执行一次 SELECT COUNT(*) AS flow_cnt FROM account_flow WHERE order_no IDEMPOTENT-001; -- 应为 2断言一是最硬的那条。压测前后各跑一次两个数字必须完全相等差一分都说明有事务没回滚干净或者有写入绕过了流水。断言二验证每笔单据内部平衡断言三验证幂等占位是否真的挡住了 49 次重复请求。观测指标建议同时盯住四个用一张表记下来比凭感觉判断靠谱。指标从哪看健康区间异常时先查TPS压测工具汇总稳定不抖连接池、慢 SQLP99 响应时间压测工具汇总小于锁等待超时的一半innodb_lock_wait_timeout死锁次数SHOW ENGINE INNODB STATUS\G的 LATEST DETECTED DEADLOCK0加锁顺序、索引命中活跃连接数Hikari 的activeConnections小于池上限的 80%事务是否过长这套流程我现在每次改完转账相关代码都会完整走一遍哪怕只是把一个SELECT改成UPDATE。因为“看起来等价”的改动在并发下经常不等价而账目类的 bug 一旦上线损失的不是性能而是可信度。上面这些建表语句、Service 骨架和对账 SQL 都来自这份模拟银行账户转账系统的源码包改一改就能塞进自己的课程设计或者业务模块里跑起来先从单笔跑通再上并发脚本别跳步。希望帮到你。本文还有配套的精品资源点击获取
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表