模拟银行账户转账系统:并发事务、幂等与数据一致性实践 简介这是一份面向Java初学者的模拟银行账户转账系统源码演示如何通过图形界面实现双账户随机互转、余额校验和交易记录落盘。资源以RAR压缩包形式提供共包含两个Java源文件整体大小仅2KB结构十分精简便于快速定位关键逻辑。目前已有四千余人浏览学习适合正在练习Swing界面编程、线程并发或文件读写的小型课程设计与期末作业。源码中一个类负责构建窗口、按钮状态切换与清屏响应另一个承载转账随机调度和余额限制判断两者协作清晰展示了界面与业务逻辑分离的编写方式同时“转账金额不得大于账户余额”“余额为0无法交易”等规则覆盖了基础的状态校验与数据一致性处理能够帮助读者理解线程安全与临界资源保护。交易过程最终写入ab.txt文件可完整观察Java IO持久化的实际运用。对于想掌握Java GUI与多线程联动、或需要银行类模拟题参考模板的读者这份代码都是性价比很高的学习素材。1. 模拟银行账户转账系统别以为它简单并发问题一点不少模拟银行账户转账系统是所有后端开发者迟早要亲手做一遍的项目但它远没有“扣一个账户、加另一个账户”听起来那么轻松。单笔转账很好跑难的是并发场景下余额不会多、不会少、不会变成负数而且每一笔账都能被流水证明。我见过太多人本地一跑就通放到两百个线程一压就翻车。这篇文章就按我实际做这类项目的思路从表结构、事务、幂等到压测和排错把账户转账这个方向拆给你看。适合想拿它练手的初中级开发也适合正在准备面试讲项目的同学。2. 账户模型与流水表先把账记对再谈转账2.1 账户表与流水表为什么不能用余额加减直接覆盖模拟银行账户转账系统的第一版绝大多数人是从一个简单方案起步的前端填转出账号、转入账号和金额后端读一次数据库把余额减掉再把另一个账户加上去。这种做法在功能演示里够用但它掩盖了转账系统真正要解决的问题余额是一个事实但“为什么余额变成了这个数”比余额本身更重要。你在本地单线程跑单笔转账永远看不到问题只要用并发工具开两百个线程同时对同一账户发起小额转账余额立刻就会变成比预期少一截或者多一截的随机数而且你没法向任何人解释差额是从哪一笔开始的。直接在代码里用balance - amount再写回是一个典型的读改写竞争条件。两个事务同时读到 1000 元事务 A 准备减 100事务 B 也准备减 100它们各自把 900 写回最后余额是 900实际应该只剩 800。每个事务都觉得自己成功了实际上有一笔扣款被覆盖。解决手段不复杂要么在读余额那一刻把行锁拿死要么在更新语句里带上余额条件让写操作自己判断。但这只是手段第一步是把数据模型分开让余额与流水各司其职。为什么流水表不能被一个简单的“转账记录表”替代因为流水表的语义是“事实”。一旦一笔转账完成这条记录的金额、账号、方向就必须保持不变哪怕转错了也只能再记一笔红字冲销。这样账务系统才能永远由“初始余额 所有流水变动”推导出当前余额。如果你把流水状态改成失败或者删掉某条记录余额推导链就断了。这个设计不是银行业的过度谨慎而是可审计性的唯一实现方式。放在课程设计里它也能让提问老师立刻看出你懂账。所以账户模型里有两个核心对象账户Account和流水Transaction。账户表保存实时余额快照用于快速查询和限额拦截流水表保存每笔转账的完整事件用于对账、审计和排查。余额快照允许与流水推导值暂时不一致但在事务模型里最终强一致。2.2 用 SQL 定义初始表结构账户表、流水表和初始化数据创建账户表时我会把物理主键和业务主键分开。物理主键 id 自增业务主键 account_no 唯一业务代码只和 account_no 打交道不暴露 id。余额必须用 DECIMAL 而不是 FLOAT这是没商量的银行账差 0.01你会愿意在日志和流水里找一下午。DECIMAL(15,2) 足够支撑模拟系统。再加 currency 字段虽然永远写 CNY但有了它后面做汇率不用改表。CREATE TABLE account ( id BIGINT AUTO_INCREMENT PRIMARY KEY, account_no VARCHAR(20) NOT NULL UNIQUE COMMENT 业务账号, user_id BIGINT NOT NULL COMMENT 用户ID模拟环境可省略, balance DECIMAL(15,2) NOT NULL DEFAULT 0 COMMENT 当前余额单位元, currency VARCHAR(3) NOT NULL DEFAULT CNY COMMENT 币种, status TINYINT NOT NULL DEFAULT 1 COMMENT 1-正常 0-冻结, version BIGINT NOT NULL DEFAULT 0 COMMENT 乐观锁版本号, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), CONSTRAINT chk_balance_non_negative CHECK (balance 0) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT账户表;这里有两个容易忽略的细节。version 字段给乐观锁保底如果决定不用悲观锁更新时必须带WHERE account_no ? AND version ?同时让version version 1。status 字段用于冻结账户转账前判断没有它后面做“止付”就得改表。CHECK 约束在 MySQL 8.0.16 之后才被 InnoDB 强制校验如果你的模拟镜像还在 5.7这个约束形同虚设需要在应用层补一条校验。这个坑后面避坑章节专门展开现在先记住用来学习的 MySQL 一定要用 8.0。转账流水表我建议按“事件”而不是按“会计借贷分录”来设计更直白CREATE TABLE account_transaction ( id BIGINT AUTO_INCREMENT PRIMARY KEY, tx_no VARCHAR(32) NOT NULL UNIQUE COMMENT 转账流水号业务幂等键, from_account_no VARCHAR(20) NOT NULL, to_account_no VARCHAR(20) NOT NULL, amount DECIMAL(15,2) NOT NULL COMMENT 交易金额正数, status TINYINT NOT NULL DEFAULT 0 COMMENT 0-处理中 1-成功 2-失败, remark VARCHAR(255) DEFAULT NULL, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), KEY idx_from_account_no (from_account_no), KEY idx_created_at (created_at) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT转账流水表;tx_no 全表唯一它的使命是幂等键。你无法控制调用方会不会因为超时重试、消息队列重新投递把同一笔请求发两次但只要 tx_no 撞上唯一索引数据库会拒绝第二次插入。遇到DuplicateKeyException时页面可以直接提示“转账已成功请勿重复提交”而不是再扣一次钱。这是转账系统的第一道防线。初始化数据时放两个一万元账户INSERT INTO account(account_no, user_id, balance, version) VALUES (1001, 1, 10000.00, 1), (1002, 2, 10000.00, 1);两张表建完后应该能回答一个最核心的对账问题任意时刻A 账户当前余额等于初始余额加上所有转入它的成功流水减去所有从它转出的成功流水。如果你能写一条这样的只读 SQL后面所有压测结果都有参照物。如果对不上两条路查流水是不是被改过查有没有一笔转账绕过了流水直接改余额。这两种都是事故但在模拟系统里是宝贵的排查经验。2.3 余额放在哪里冗余字段与推导值的取舍有些人会说既然流水能推导余额账户表保留 balance 就是冗余。对它就是冗余但这是有意的冗余。没有 balance每次查询余额都要临时 sum 整张流水表数据量一上来就极慢。账户表的余额是“快照”它和流水之间靠同一笔数据库事务保持一致。可能出现的争议是如果余额和流水不一致以哪个为准金融系统以流水为准余额只是缓存。把这条原则写进代码注释可以避免后续维护者“手改余额”的错误。3. 事务、锁与一致性让转账不会丢钱或重复扣款3.1 从 ACID 到行锁为什么转账必须用数据库事务在模拟银行账户转账系统里数据库事务是唯一能把“扣款、入账、记流水”打包成一个原子的手段。原子性就是三句话三条 SQL 要么全部提交要么全部回滚。如果扣款已提交而入账失败A 账户少了钱B 账户没多钱真实系统里这叫单边账需要人工冲正。所以转账服务里不允许出现“先 update 一条再 update 另一条”而无事务保护的写法。隔离性在转账场景下的含义更具体两个并发转账事务不能同时修改同一行账户余额。InnoDB 默认 REPEATABLE READ通过 MVCC 控制读一致性但不会阻止写冲突。要真正锁住一行写入常见做法是SELECT ... FOR UPDATE把目标行加排他锁直到事务结束。第二个转账在锁释放前会一直等待。这带来了锁顺序问题两个账户都要锁时必须始终按固定顺序锁比如先锁 account_no 较小的那个账户否则两个事务互相等对方手里的锁就会死锁。死锁出现时数据库会主动牺牲一个事务回滚页面报错排查半天才发现是顺序问题。模拟项目虽然并发不大但压测线程多了之后死锁并不罕见。另一个方向是乐观锁更新语句带上version ?条件。好处是并发度高坏处是转账可能在最后一步失败适合“失败率可接受”的场景。但在银行转账里失败必须明确、可解释我一般优先用悲观锁。InnoDB 的行锁只锁一行模拟环境的并发完全够用。你可以在测试库里用两三个账户做千次并发观察锁等待和死锁次数这会比单纯调接口更早暴露问题。3.2 Transactional 的边界事务为什么没生效回滚为什么不触发Spring 项目大多数会给 service 方法加Transactional加在哪儿很重要。Service public class TransferService { private final AccountMapper accountMapper; private final TransactionMapper transactionMapper; public TransferService(AccountMapper accountMapper, TransactionMapper transactionMapper) { this.accountMapper accountMapper; this.transactionMapper transactionMapper; } Transactional(rollbackFor Exception.class, timeout 5) public TransferResult transfer(String fromAcctNo, String toAcctNo, BigDecimal amount) { if (amount.compareTo(BigDecimal.ZERO) 0) { throw new IllegalArgumentException(金额必须大于0); } Account from accountMapper.lockByAccountNo(fromAcctNo); Account to accountMapper.lockByAccountNo(toAcctNo); if (from.getBalance().compareTo(amount) 0) { throw new InsufficientBalanceException(余额不足); } String txNo TransactionNoGenerator.next(); accountMapper.changeBalance(fromAcctNo, amount.negate()); accountMapper.changeBalance(toAcctNo, amount); transactionMapper.insert(new TransactionRecord(txNo, fromAcctNo, toAcctNo, amount)); return new TransferResult(txNo); } }这段代码有三个记住了就不会翻车的教训。第一rollbackFor Exception.class。Spring 默认只回滚 RuntimeException检查异常的抛出不会回滚如果你把 SQLException 捕获后包成普通 Exception 继续抛事务不会回滚钱可能只扣了一边。所以显式写 rollbackFor或者只抛 RuntimeException。第二timeout 5 是事务内 SQL 总时长上限压测时连接池满了这个参数能快速拒绝卡死的事务而不是让所有请求排队等到超时。第三事务方法必须是 public 且通过外部调用进入同一个类内部 this.transfer() 调用不会触发事务增强。这个“内部调用失效”的问题是 Spring 新手最容易踩的。锁的粒度在哪里lockByAccountNo 对应 SQLSELECT ... FOR UPDATE在事务里执行后锁住这条账户记录直到提交。更新余额我推荐写成原子扣减不要在 Java 里读出余额再写回UPDATE account SET balance balance - #{amount} WHERE account_no #{fromAcctNo} AND balance #{amount}balance #{amount}让数据库否决余额不足。前面已经用 FOR UPDATE 锁了行这里再做一次条件更新双保险。入账侧balance balance #{amount}不需要条件但仍要锁行。到这里一条转账的核心链路完整了加事务、锁定两行、条件扣款、增加余额、插入流水。死锁不是没法干预。MySQL 提供 innodb_lock_wait_timeout默认 50 秒。模拟项目里我会把它调成 5 秒这样一旦两个事务互相等锁数据库会在 5 秒内抛异常压测脚本能立刻看到错误而不是让整个测试卡死五十分钟。这个参数是数据库会话级不是 Spring 配置项需要单独设置SET GLOBAL innodb_lock_wait_timeout 5。很多人把它和 connection-timeout 混在一起其实一个是获取连接超时一个是锁等待超时。3.3 事务内不要做的三件事远程调用、发消息、sleep我看到很多模拟项目为了体现“异步”在事务里调用消息队列或 HTTP 通知。这是把事务边界越拉越长行锁和数据库连接一直不还最终拖垮整个连接池。事务里也别做 Thread.sleep 去模拟慢转账哪怕只睡 50ms压测一百个并发时连接池就爆了。道理很简单事务持有资源的时间越短并发能力和可预测性越高。常见的正确做法是先提交事务再发通知如果一定要发消息用本地消息表加定时投递把“业务结果落库”和“通知外部”解耦。模拟银行账户转账系统其实不需要异步同步返回结果就足够真实。把这个习惯带到真实项目里你会少很多凌晨被叫醒的记录。4. 跑通第一笔转账可运行代码与必调参数4.1 转账主流程从接口到数据库事务的完整路径这里给一个可运行的最小 Spring Boot 转账服务。Controller 只做参数接收Service 是事务边界Mapper 只做 SQL。先看 ControllerRestController RequestMapping(/transfer) public class TransferController { private final TransferService transferService; public TransferController(TransferService transferService) { this.transferService transferService; } PostMapping public ResponseEntityTransferResult transfer(RequestBody TransferRequest request) { TransferResult result transferService.transfer( request.getFromAcctNo(), request.getToAcctNo(), request.getAmount()); return ResponseEntity.ok(result); } }Controller 里不写业务逻辑不写 try-catch 吞掉异常。事务里抛出的 InsufficientBalanceException 由全局异常处理器捕获并返回“余额不足”。如果你在 Controller 层 try-catch 住异常事务可能已经回滚但用户看到的是 200接口状态和实际状态不一致这是常见的接口设计失误。Service 实现与上一章代码一致不重复贴。关键在 Mapper。用 MyBatis 注解版Mapper public interface AccountMapper { Select(SELECT * FROM account WHERE account_no #{accountNo} FOR UPDATE) Account lockByAccountNo(String accountNo); Update(UPDATE account SET balance balance #{delta}, version version 1 WHERE account_no #{accountNo}) int changeBalance(Param(accountNo) String accountNo, Param(delta) BigDecimal delta); }changeBalance 的 delta 在转出时是负数在转入时是正数。SQL 里version version 1会改变行数据触发 InnoDB 行锁并保持版本号递增。转出账户的 changeBalance 必须写成带条件的版本Update(UPDATE account SET balance balance - #{amount}, version version 1 WHERE account_no #{accountNo} AND balance #{amount}) int decreaseBalance(Param(accountNo) String accountNo, Param(amount) BigDecimal amount);返回影响行数如果为 0说明余额不足或账号不存在直接抛异常。转入则只需balance balance #{amount}。到这里主流程已经完整锁行、校验、扣款、入账、插流水。注意流水插入必须放在同一个事务里依靠 Transactional 保证。4.2 幂等键与流水号这不是业务逻辑是数据正确性的前提转账接口被客户端重试是最常见的情况。用户点了一次“转账”网络卡住又点了一次Postman 联调时发了两次同样请求。如果没有幂等键第一笔成功、第二笔也成功这就是重复入账。最朴素的实现是调用方在请求里带一个业务唯一编号 requestId后端把它和流水号 tx_no 绑定利用流水表唯一索引挡住重复。如果请求方没传 requestId接口直接拒绝并提示“缺少请求ID”而不是自动生成因为自动生成一个全新 ID 无法识别调用方的重试。这个约定要写进接口文档。生成 tx_no 时我不太用 UUID。UUID 32 位作为索引会拉大 B 树体积插入时随机位置写页分裂也多。单体模拟项目用“日期 原子自增”就够public class TransactionNoGenerator { private static final AtomicLong SEQ new AtomicLong(0); public static String next() { return LocalDateTime.now().format(DateTimeFormatter.ofPattern(yyyyMMddHHmmss)) String.format(%06d, SEQ.incrementAndGet() % 1000000); } }这个生成器两个进程同时启动时可能产生相同序号所以不能把唯一性押在它身上流水表的唯一索引才是底线。在未来拆服务时可以换成雪花算法核心是一样的不靠数据库自增也能全局唯一。在模拟项目里用基础方案但要理解它的边界。如果你想让流水表更完整可以直接加一个request_id字段并建唯一索引这样请求方看到的“业务号”和内部流水号就分开了后续对账也不会把两个概念混在一起。4.3 连接池与事务参数搞崩事务的未必是并发可能是配置跑通之后你想用几十个线程压一下结果服务超时。这时十有八九是数据库连接池不够。Spring Boot 默认 HikariCP默认 maximum-pool-size 是 10。压测线程 200队尾请求只能等前面事务提交释放连接等久了超时报错。这不代表转账逻辑有问题而是连接池参数没跟上。spring: datasource: url: jdbc:mysql://localhost:3306/bank?useSSLfalseserverTimezoneAsia/Shanghai username: bank password: bank123 hikari: minimum-idle: 5 maximum-pool-size: 20 connection-timeout: 30000 idle-timeout: 600000 max-lifetime: 1800000maximum-pool-size 不是越大越好。每个连接都对应数据库线程和内存20 个连接对模拟项目足够。连接数超过数据库最大连接数默认 151会让数据库本身成为瓶颈。connection-timeout 是获取连接的超时Transactional(timeout) 是事务内 SQL 执行总时长上限两者分工不同。压测完你会发现数据库里多了一排 Sleep 连接那是连接池的正常水位别慌。5. 避坑清单并发扣钱、余额负数与重复入账5.1 丢失更新为什么压测后余额总和变少了现象并发 50 线程各转 100 元压测前两个账户余额分别是 5000 和 5000压测后总额变成了 4950而不是 5000。检查每个线程自己的日志都成功流水条数也对就是余额对不上。原因你使用了“先查余额计算新余额再把新余额写回”的三步更新。事务虽然隔离了读写但两个并发事务都能读到同样一行旧值随后各自写回新值后提交的覆盖先提交的。数据库本身没有丢失更新丢失的是业务上“两次扣款都被承认”的预期。多实例部署时在 Java 代码里加 synchronized 也没用因为每个进程各有一把锁跨进程的并发必须交给数据库。解决把三步更新改成一步原子更新并在读余额时用 FOR UPDATE 锁行。我的习惯是双重保险先锁行避免多事务同时进入转账流程再用条件更新兜底。UPDATE account SET balance balance - #{amount} WHERE account_no #{from} AND balance #{amount}让数据库直接读当前值运算代码不用维护余额旧值。注意压测时如果没有真正并发比如所有请求都从同一连接串行执行问题可能测不出来。检查压测日志里的时间戳确保有重叠。5.2 余额负数CHECK 约束能拦住吗拦住的前提是什么现象并发转账后出现余额负数数据库开始报违反 CHECK 约束的错误业务批量任务中断。代码里明明判断了if (balance amount)负数还是出现。原因MySQL 5.7 及其之前的 InnoDB 并不强制 CHECKMySQL 会解析语法但不会执行校验。如果你的模拟项目跑在 Docker 的 mysql:5.7 镜像上balance 0 的约束形同虚设。就算用了 MySQL 8.0代码里的“先查后判”也不是并发安全的两个事务同时查到余额 50都认为可以扣 50实际总扣 100余额变成负数。解决升级到 MySQL 8.0.16 以上并确认建表语句里 CHECK 被真实执行。同时SQL 更新必须带条件用影响行数判断余额是否够用。不要把不扣成负数的希望寄托在 Java 判断上数据库条件更新和上下文校验两个都要有。建表后跑SHOW CREATE TABLE account检查CONSTRAINT chk_balance_non_negative在不在如果只有注释说明约束被忽略了。另一种常见做法是把金额改成以“分”为单位的 BIGINT 存储这能绕开 DECIMAL 的精度问题但表结构、接口转换都要跟着改不要混着用。5.3 重复入账重试一次余额就多一次现象一个转账请求因为响应超时客户端自动重试。查看流水表时出现了两条几乎一样的记录转入账户余额多了 100。如果再有消息队列重新投递重复规模会更大。原因没有业务幂等键或幂等键没落库成为唯一约束。后端每次请求都当成新交易处理自然每请求扣一次、加一次、插一次流水。这不是并发才有的问题网络重试就能触发隐蔽性更高。解决流水表对 tx_no 加 UNIQUE入口处理时先按 tx_no 查流水。如果已存在且成功直接返回上次结果如果第二次插入碰到唯一索引冲突捕获 DuplicateKeyException 同样按成功处理。这里有个细节如果第一笔还在“处理中”而第二笔来了不能直接返回成功要返回“处理中请稍后查询”。模拟系统通常是同步事务处理中状态存在时间极短但状态机要留有这个分支。为了主动发现重复入账还可以跑一条对账 SQLSELECT (SELECT COALESCE(SUM(amount), 0) FROM account_transaction WHERE to_account_no 1001 AND status 1) - (SELECT COALESCE(SUM(amount), 0) FROM account_transaction WHERE from_account_no 1001 AND status 1) AS expected_income;这个结果应该等于账户表里 1001 当前余额减去初始余额。如果不相等优先查是不是有同 tx_no 的两条流水。6. 验证与进阶用压测脚本和故障注入检验你的转账系统6.1 用 Docker 拉起最小验证环境验证前面代码一行 Docker 即可前提是 3306 没有被占用docker run --name bank-mysql \ -e MYSQL_ROOT_PASSWORDroot -e MYSQL_DATABASEbank \ -p 3306:3306 -d mysql:8.0用 mysql:8.0 是故意避坑5.7 的 CHECK 约束不生效会把第 5 章的问题带出来。容器起来后把第 2 章的建表和插入语句执行一遍再连接你的 Spring Boot 应用。6.2 并发转账压测脚本与预期结果用下面的 bash 循环模拟 100 笔并发小额转账for i in $(seq 1 100); do curl -s -X POST http://localhost:8080/transfer \ -H Content-Type: application/json \ -d {fromAcctNo:1001,toAcctNo:1002,amount:1} done wait背景符号让请求并发发起wait 等待全部结束。然后查询两个账户的余额预期是 1001 变成 99001002 变成 10100总额还是 20000。如果出现偏差回到第 5 章逐项排查。故障注入也很简单在 service 里加一句throw new RuntimeException(test rollback)跑一次转账观察两个账户余额都不变流水表也没有新记录这就验证了事务原子性。6.3 从模拟到生产还需要补的四个模块模拟系统跑通后如果你要把这个方向延伸到简历或真实项目至少还需要补四样东西定时对账任务、限额与风控、审计日志、监控告警。它们不一定都写进代码但面试问“你怎么保证不出错”时你能说出从哪个日志、哪张表、哪个任务去发现错误这才是有价值的进阶。我做这类项目到最后悔的习惯是太早开始写业务而没先把流水表定义清楚。后来每次出问题都靠补流水才定位。希望你先复现问题、再写代码把表结构和事务边界放在功能前面希望帮到你。本文还有配套的精品资源点击获取