简介:这是一份面向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.class,catch 块里手动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设 3,Redis 锁超时设 3000ms,事务超时设 5s,连接池maxActive设 50。这些不是死的,要根据压测结果调。我习惯每次改完参数都重新跑一轮压测,对比 TPS 和错误率,找到拐点。从那以后我每次上线转账相关改动,都强制走一遍“单测覆盖回滚 + 并发压测 + 对账验证”三步,少一步都不敢发。希望帮到你。
本文还有配套的精品资源,点击获取