简介这是一份面向Java初学者与GUI编程练习者的模拟银行账户转账系统源码围绕A、B两个初始余额均为1000元的账户展开实现双方随机互相转账、金额不得超过账户余额、余额为零即无法交易等核心业务规则适合用于理解多线程协作与图形界面事件驱动的综合小项目。压缩包为rar格式共2个文件均为java源码包体约2KB分别承担窗口界面搭建与转账线程逻辑结构精简便于直接阅读与二次修改。资源已有4107人学习下载说明其作为课堂练习或自学案例具备一定参考价值。读者可从中获取完整的双账户随机转账实现思路、按钮状态切换交易开始与结束交易的交互设计、交易记录写入ab.txt的持久化处理方式以及余额校验与线程调度的排错参考适合作为Java GUI与多线程入门练手素材。1. 模拟银行账户转账系统从并发扣款到事务回滚的完整落地转账这件事看着简单真动手写起来翻车点比想象中多。一个模拟银行账户转账系统核心要解决的是两个账户之间扣款和入账必须同时成功或同时失败中间不能出现钱扣了没到账、或者两边余额都对不上的情况。很多新手写出来的版本单线程跑没问题一上并发就出现余额覆盖、脏读、甚至负数余额。这份资源就是冲着这些真实痛点来的它把账户模型、事务边界、锁策略、异常回滚这几块拆开揉碎给了一套能直接跑、能改参数、能压测的代码骨架。适合正在学 Java 或 Python 后端、想搞懂事务和并发控制到底怎么落地的人也适合面试前想拿一个能讲清楚的项目练手的人。下面按“先跑通、再压测、最后排坑”的顺序拆。2. 账户模型与事务边界先把钱怎么记搞清楚2.1 账户表结构设计余额字段不是唯一解很多人第一反应是建一张account表里面放id、name、balance三个字段转账就是两条 update。这个做法在单机低并发下能用但一旦要追溯流水、对账、或者做风控就发现余额字段是个黑匣子——你只知道现在剩多少不知道钱怎么来的。常见做法是拆成两张表账户表只存静态信息流水表记录每一笔变动余额通过流水汇总或者定时快照来算。-- 账户表只存静态信息和当前余额快照 CREATE TABLE account ( id BIGINT PRIMARY KEY AUTO_INCREMENT, owner_name VARCHAR(64) NOT NULL, balance DECIMAL(18,2) NOT NULL DEFAULT 0.00, version INT NOT NULL DEFAULT 0, -- 乐观锁版本号 updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ); -- 流水表每一笔转账都留痕用于对账和回滚 CREATE TABLE transfer_log ( id BIGINT PRIMARY KEY AUTO_INCREMENT, from_account BIGINT NOT NULL, to_account BIGINT NOT NULL, amount DECIMAL(18,2) NOT NULL, status TINYINT NOT NULL DEFAULT 0, -- 0进行中 1成功 2失败 created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, INDEX idx_from (from_account), INDEX idx_to (to_account) );balance用DECIMAL而不是FLOAT这是血泪经验——浮点数做金额运算会出现0.1 0.2 0.30000000000000004这种玄学结果对账时能把你逼疯。version字段是给乐观锁用的后面并发控制会展开。transfer_log的status字段是关键转账开始前先插一条“进行中”的记录成功改 1异常改 2这样即使程序崩了也能根据日志把悬挂的交易捞出来补偿。2.2 事务边界别把整个方法都包进 Transactional事务边界划多大直接决定锁持有时间和并发吞吐。我见过有人把参数校验、日志打印、甚至远程调用全塞进一个Transactional方法里结果数据库连接池瞬间被打满。正确做法是事务只包住“扣款 入账 写流水状态”这三步数据库操作校验和后续通知放到事务外。// 事务只包住核心三步校验和通知在外面 public TransferResult transfer(Long fromId, Long toId, BigDecimal amount) { // 1. 事务外做参数校验快速失败 if (amount.compareTo(BigDecimal.ZERO) 0) { return TransferResult.fail(金额必须大于0); } if (fromId.equals(toId)) { return TransferResult.fail(不能给自己转账); } // 2. 事务内只做数据库写操作 return transactionTemplate.execute(status - { try { int rows1 accountMapper.deduct(fromId, amount); if (rows1 0) { status.setRollbackOnly(); return TransferResult.fail(扣款失败余额不足或账户不存在); } int rows2 accountMapper.credit(toId, amount); if (rows2 0) { status.setRollbackOnly(); return TransferResult.fail(入账失败账户不存在); } transferLogMapper.markSuccess(fromId, toId, amount); return TransferResult.success(); } catch (Exception e) { status.setRollbackOnly(); throw e; } }); }transactionTemplate.execute里的代码要么全成功要么全回滚status.setRollbackOnly()是手动标记回滚比抛异常更可控。注意扣款和入账的顺序先扣后入扣款失败直接返回不用执行入账。如果先入后扣扣款失败时入账已经生效虽然事务能回滚但锁的持有顺序反了高并发下更容易死锁。2.3 扣款 SQL 的写法where 条件里藏着的并发陷阱扣款语句看起来简单但 where 条件怎么写决定了并发安全性。最朴素的写法是update account set balance balance - #{amount} where id #{fromId}这条语句在数据库层面是原子的但如果你先select查余额、在 Java 里判断够不够、再update那就完蛋了——两个线程同时查到余额 100都判断够扣 80最后余额变成 -60。-- 正确写法把余额判断塞进 where 条件利用数据库行锁 UPDATE account SET balance balance - #{amount}, version version 1 WHERE id #{fromId} AND balance #{amount}; -- 关键余额不足时影响行数为0balance #{amount}这个条件让数据库在行锁层面完成“检查 扣减”返回的影响行数rows1为 0 就说明余额不够或者账户不存在直接走失败分支。这是最省事也最可靠的防超扣手段比在 Java 里加 synchronized 靠谱得多——synchronized 只能锁单机集群部署就失效了。3. 并发控制与锁策略乐观锁、悲观锁怎么选3.1 乐观锁重试version 字段的正确打开方式乐观锁的思路是读的时候记下 version写的时候带上where version #{oldVersion}如果 version 被别的线程改过影响行数为 0就重试。转账场景里扣款和入账都可以用乐观锁但重试次数要设上限不然高并发下会活锁。// 乐观锁重试扣款最多重试3次 public boolean deductWithRetry(Long accountId, BigDecimal amount, int maxRetry) { for (int i 0; i maxRetry; i) { Account acc accountMapper.selectById(accountId); if (acc.getBalance().compareTo(amount) 0) { return false; // 余额不足不重试 } int rows accountMapper.deductWithVersion( accountId, amount, acc.getVersion()); if (rows 0) { return true; // 扣款成功 } // 影响行数为0说明version被改循环重试 } return false; // 重试耗尽放弃 }maxRetry一般设 3 到 5再多就没意义了——重试次数越多说明冲突越激烈继续重试只会浪费 CPU。重试之间可以加一个随机退避比如Thread.sleep(random(10, 50))减少碰撞概率。乐观锁适合读多写少的场景转账这种写操作密集的冲突率会比较高重试成本不低。3.2 悲观锁 select for update什么时候值得用悲观锁的思路是一上来就锁住行别人进不来。MySQL 里用select ... for update实现锁的是行级排他锁。转账场景里如果并发冲突非常频繁乐观锁重试到怀疑人生那就该换悲观锁。-- 悲观锁按固定顺序锁两个账户避免死锁 SELECT * FROM account WHERE id #{fromId} FOR UPDATE; SELECT * FROM account WHERE id #{toId} FOR UPDATE;注意锁的顺序如果线程 A 锁账户 1 再锁账户 2线程 B 锁账户 2 再锁账户 1就会死锁。解决办法是统一按账户 id 从小到大加锁比如Math.min(fromId, toId)先锁Math.max后锁。悲观锁的代价是持有锁期间其他事务全部阻塞吞吐量会下降适合冲突率极高、重试成本大于等待成本的场景。我一般会先上乐观锁压测如果失败率超过 10%再考虑换悲观锁。3.3 分布式场景下的锁本地锁不够用怎么办单机环境下数据库行锁和 Java 锁能搞定大部分问题。但一旦服务部署多实例本地 synchronized 就失效了数据库行锁虽然还能用但连接池压力会很大。常见做法是引入 Redis 分布式锁按账户 id 加锁锁的粒度尽量小。// Redis 分布式锁按账户id加锁超时时间设短一点 public boolean tryLock(Long accountId, long timeoutMs) { String key lock:account: accountId; String value UUID.randomUUID().toString(); Boolean ok redisTemplate.opsForValue() .setIfAbsent(key, value, timeoutMs, TimeUnit.MILLISECONDS); return Boolean.TRUE.equals(ok); }timeoutMs设多少很讲究设太短业务没跑完锁就释放了别的线程进来照样出问题设太长一个线程挂了锁一直不释放其他请求全堵死。我一般设 3 到 5 秒配合看门狗续期。释放锁的时候要用 Lua 脚本比对 value防止误删别人的锁。分布式锁是最后手段能靠数据库行锁解决就别引入 Redis多一个组件多一堆坑。4. 异常回滚与对账补偿钱扣了没到账怎么救4.1 事务回滚的边界哪些异常会触发回滚Spring 的Transactional默认只对RuntimeException和Error回滚受检异常比如IOException不会触发回滚。这是个经典翻车点——你方法里抛了个Exception以为事务会回滚结果扣款生效了入账没执行钱凭空消失。// 显式指定回滚异常类型别依赖默认行为 Transactional(rollbackFor Exception.class) public void transfer(...) throws Exception { // ... }rollbackFor Exception.class让所有异常都触发回滚省心。但要注意如果异常被 catch 了没往外抛事务照样不回滚。所以 catch 块里要么throw出去要么手动TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()。4.2 悬挂交易补偿定时任务捞“进行中”的流水即使事务机制再完善也架不住程序崩溃、网络超时、数据库连接断开这些极端情况。transfer_log里 status 为 0 的记录就是悬挂交易需要定时任务去补偿。-- 捞出超过5分钟还是“进行中”的流水 SELECT * FROM transfer_log WHERE status 0 AND created_at DATE_SUB(NOW(), INTERVAL 5 MINUTE);补偿逻辑分两种如果扣款和入账都查得到对应记录就把 status 改成 1如果只有扣款没有入账就反向操作把扣的钱加回去status 改成 2。补偿任务要幂等同一笔流水补偿多次不能重复加钱一般用流水 id 做唯一键控制。4.3 对账机制定时核对余额和流水总和对账是最后一道防线。每天凌晨跑一次全量对账把每个账户的余额和流水表里所有成功交易的总和比对不一致就告警。-- 对账查询余额 vs 流水汇总 SELECT a.id, a.balance, COALESCE(SUM(CASE WHEN t.to_account a.id THEN t.amount ELSE 0 END), 0) - COALESCE(SUM(CASE WHEN t.from_account a.id THEN t.amount ELSE 0 END), 0) AS computed_balance FROM account a LEFT JOIN transfer_log t ON (t.from_account a.id OR t.to_account a.id) AND t.status 1 GROUP BY a.id, a.balance HAVING a.balance ! computed_balance;这条 SQL 跑出来有记录就说明账对不上得人工介入查。对账频率看业务量量大的话可以按小时增量对账只核对最近变动的账户。5. 避坑与排查转账系统最常见的五个翻车现场5.1 现象并发压测时余额变成负数原因扣款 SQL 没加balance amount条件或者先 select 判断再 update两个线程同时通过判断。解决把余额判断塞进 update 的 where 条件用影响行数判断是否成功。这是最基础的防线任何转账系统都必须有。5.2 现象事务没回滚扣款成功但入账失败原因异常被 catch 了没往外抛或者抛的是受检异常而Transactional默认不回滚。解决rollbackFor Exception.classcatch 块里手动setRollbackOnly()或者重新抛出。上线前写个单测故意在入账后抛异常验证扣款是否回滚。5.3 现象死锁两个线程互相等对方释放锁原因加锁顺序不一致线程 A 锁账户 1 等账户 2线程 B 锁账户 2 等账户 1。解决统一按账户 id 排序后加锁select for update也按这个顺序。数据库死锁日志里能看到LATEST DETECTED DEADLOCK排查时重点看两个事务的加锁顺序。5.4 现象Redis 分布式锁失效并发还是出问题原因锁超时时间设太短业务没跑完锁自动释放或者释放锁时没校验 value误删了别人的锁。解决超时时间设成业务最大耗时的 2 到 3 倍释放锁用 Lua 脚本比对 value。更稳妥的做法是加看门狗线程定期续期。5.5 现象对账发现余额和流水对不上但查不出哪笔错了原因流水表 status 更新和余额更新不在同一个事务里或者补偿任务重复执行。解决流水状态变更和余额变更必须在同一事务补偿任务用唯一键或状态机保证幂等。对账 SQL 要能定位到具体账户再根据账户 id 捞流水逐笔核对。6. 压测验证与参数调优用 JMeter 跑出真实瓶颈写完代码不压测等于没写。我一般用 JMeter 或者 wrk 模拟 100 到 500 并发持续跑 5 分钟观察几个关键指标TPS、平均响应时间、错误率、数据库连接池活跃数。压测脚本里随机生成 fromId 和 toId金额设小一点比如 1 到 10 块避免余额不足导致大量失败干扰结果。# 用 wrk 快速压测转账接口-t 线程数 -c 连接数 -d 持续时间 wrk -t4 -c200 -d300s --latency \ -s transfer_post.lua \ http://localhost:8080/api/transfertransfer_post.lua里构造 POST 请求体随机 fromId 和 toId。压测时重点看数据库的SHOW ENGINE INNODB STATUS如果TRANSACTIONS里大量锁等待说明锁粒度太粗或者事务太长。调优方向缩小事务范围、降低隔离级别到READ COMMITTED、给from_account和to_account加索引、连接池大小调到 CPU 核数的 2 到 4 倍。参数调优有个经验值maxRetry设 3Redis 锁超时设 3000ms事务超时设 5s连接池maxActive设 50。这些不是死的要根据压测结果调。我习惯每次改完参数都重新跑一轮压测对比 TPS 和错误率找到拐点。从那以后我每次上线转账相关改动都强制走一遍“单测覆盖回滚 并发压测 对账验证”三步少一步都不敢发。希望帮到你。本文还有配套的精品资源点击获取