简介:这是一份面向Java初学者与GUI编程练习者的模拟银行账户转账系统源码,围绕A、B两个初始余额均为1000元的账户展开,实现双方随机互相转账、金额不得超过账户余额、余额为零即无法交易等核心业务规则,适合用于理解多线程协作与图形界面事件驱动的综合小项目。压缩包内共2个文件,均为java源码,整体约2KB,分别承担窗口界面搭建与转账线程逻辑,结构精简、便于直接阅读与二次修改。资源已有4107人学习下载,说明其作为课堂练习或自学案例具备一定参考价值。读者可从中获得完整的双账户随机转账实现思路、按钮状态切换(交易开始与结束交易)的交互设计、交易记录写入ab.txt的持久化处理方式,以及余额校验与线程调度的排错参考,适合作为Java课程设计或GUI入门练手素材。
1. 从一次转账失败说起:为什么需要一套完整的银行账户转账系统
转账这件事,看起来只是「A 扣钱、B 加钱」两步操作,但真正动手写过的开发者都知道,这里面的坑远比想象中多。某高校的课程设计答辩现场,A 同学演示转账功能时,第一次点击「确认转账」页面卡住,第二次点击后账户余额直接扣了两次——这就是典型的并发问题和幂等性缺失。模拟银行账户转账系统要解决的核心问题,就是在单机或局域网环境下,完整复现真实银行转账的业务闭环:账户管理、身份验证、转账事务、流水记录、并发控制。这套方案适合正在做 Java Web 课程设计、数据库课设或分布式入门项目的学生,也适合想理解事务与锁机制的初中级开发者。它不追求高并发压测指标,而是把「一笔转账从发起到落账」的每个环节都拆开讲透,让你能照着搭出一套能演示、能答辩、能经得起追问的系统。
2. 需求分析与技术选型:六个核心模块的职责边界
2.1 功能性需求拆解
一套能拿得出手的转账系统,功能需求通常要覆盖六个模块。用户模块负责注册、登录、密码修改,登录态用 Session 或 Token 维持;账户模块管理每个用户的银行卡号、余额、状态(正常/冻结);转账模块是核心,处理转出、转入、金额校验、余额扣减与增加;流水模块记录每一笔交易的完整信息,包括交易号、双方账号、金额、时间戳、状态;并发控制模块保证同一账户在并发转账时不会出现余额错乱;异常处理模块负责转账失败后的回滚和补偿。
我一般会把需求写成一张表,方便后续对照验收:
| 模块 | 核心功能 | 关键约束 |
|---|---|---|
| 用户模块 | 注册、登录、登出 | 密码加密存储,登录态超时 |
| 账户模块 | 开户、查询余额、冻结 | 账号唯一,余额非负 |
| 转账模块 | 发起转账、确认到账 | 金额>0,转出方余额充足 |
| 流水模块 | 记录、查询交易明细 | 每笔交易有唯一流水号 |
| 并发控制 | 锁账户、事务隔离 | 同一账户串行扣款 |
| 异常处理 | 回滚、重试、告警 | 失败交易不产生脏数据 |
这张表看起来简单,但每一条约束背后都对应着具体的实现决策。比如「余额非负」这条,如果只在应用层用 if 判断,并发场景下必然翻车,必须落到数据库层面用约束或行锁兜底。
2.2 技术选型与理由
技术栈的选择直接决定后续开发效率和答辩时的可解释性。对于课程设计级别的转账系统,我推荐 Spring Boot + MyBatis + MySQL + Redis 的组合。Spring Boot 负责快速搭建 Web 层和依赖注入;MyBatis 做数据持久化,SQL 可控性强,方便在答辩时展示事务和锁的细节;MySQL 用 InnoDB 引擎,天然支持行级锁和事务;Redis 用来做分布式锁或缓存账户热点数据,虽然单机场景下不是必须,但加上它能体现你对并发控制的思考。
前端不用太复杂,Thymeleaf 或 Vue 都行,重点是后端逻辑要清晰。如果你时间紧,直接用 Postman 调接口演示也完全可以,答辩老师更关心的是事务和并发怎么处理的,而不是页面好不好看。
提示:不要为了炫技引入消息队列或微服务,课程设计的核心是把转账事务讲清楚,引入过多中间件反而增加调试成本。
2.3 数据库表设计
表结构是转账系统的地基,设计不好后面全是坑。核心表就三张:用户表、账户表、交易流水表。
-- 用户表:存登录信息 CREATE TABLE t_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE COMMENT '登录名', password VARCHAR(128) NOT NULL COMMENT '加密后的密码', created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 账户表:存余额和状态,一个用户可对应多个账户 CREATE TABLE t_account ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL COMMENT '所属用户', account_no VARCHAR(32) NOT NULL UNIQUE COMMENT '银行卡号', balance DECIMAL(18,2) NOT NULL DEFAULT 0.00 COMMENT '余额,精确到分', status TINYINT NOT NULL DEFAULT 1 COMMENT '1正常 0冻结', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', INDEX idx_user_id (user_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 交易流水表:每笔转账一条记录 CREATE TABLE t_transaction ( id BIGINT PRIMARY KEY AUTO_INCREMENT, trade_no VARCHAR(64) NOT NULL UNIQUE COMMENT '交易流水号', from_account_no VARCHAR(32) NOT NULL, to_account_no VARCHAR(32) NOT NULL, amount DECIMAL(18,2) NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT '0处理中 1成功 2失败', remark VARCHAR(255) DEFAULT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_from_account (from_account_no), INDEX idx_to_account (to_account_no) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;余额字段用 DECIMAL 而不是 FLOAT,这是血泪经验——浮点数在金额计算时会出现精度丢失,0.1 + 0.2 不等于 0.3 的问题在转账场景里是致命的。version 字段是为乐观锁准备的,后面讲并发控制时会用到。交易流水号 trade_no 必须唯一,通常用「时间戳 + 随机数 + 用户 ID」生成,保证可追溯。
3. 转账核心逻辑实现:事务、锁与幂等性的三重保障
3.1 基于 Spring 声明式事务的转账服务
转账的本质是一个数据库事务:扣转出方余额、加转入方余额、写流水记录,三步要么全成功,要么全失败。Spring 的 @Transactional 注解能帮我们管理事务边界,但有几个细节必须注意。
@Service public class TransferService { @Autowired private AccountMapper accountMapper; @Autowired private TransactionMapper transactionMapper; /** * 转账核心方法 * @param fromAccountNo 转出账号 * @param toAccountNo 转入账号 * @param amount 转账金额,单位元 * @param tradeNo 外部传入的交易号,用于幂等 */ @Transactional(rollbackFor = Exception.class) public void transfer(String fromAccountNo, String toAccountNo, BigDecimal amount, String tradeNo) { // 1. 幂等校验:同一 tradeNo 只处理一次 Transaction exist = transactionMapper.selectByTradeNo(tradeNo); if (exist != null) { throw new BizException("重复交易,请勿重复提交"); } // 2. 校验金额 if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) { throw new BizException("转账金额必须大于0"); } // 3. 锁定转出账户(悲观锁,防止并发扣款) Account from = accountMapper.selectForUpdate(fromAccountNo); if (from == null || from.getStatus() != 1) { throw new BizException("转出账户不存在或已冻结"); } if (from.getBalance().compareTo(amount) < 0) { throw new BizException("余额不足"); } // 4. 锁定转入账户 Account to = accountMapper.selectForUpdate(toAccountNo); if (to == null || to.getStatus() != 1) { throw new BizException("转入账户不存在或已冻结"); } // 5. 扣减与增加余额 accountMapper.updateBalance(fromAccountNo, amount.negate()); accountMapper.updateBalance(toAccountNo, amount); // 6. 写入流水 Transaction record = new Transaction(); record.setTradeNo(tradeNo); record.setFromAccountNo(fromAccountNo); record.setToAccountNo(toAccountNo); record.setAmount(amount); record.setStatus(1); transactionMapper.insert(record); } }这段代码有几个关键点值得展开。第一,@Transactional(rollbackFor = Exception.class)里的 rollbackFor 必须显式指定,因为 Spring 默认只对 RuntimeException 回滚,如果抛出的是受检异常,事务不会回滚,这是很多人踩过的坑。第二,selectForUpdate对应 SQL 里的SELECT ... FOR UPDATE,它会锁定行记录,其他事务在锁释放前无法修改该行,从而保证同一账户的扣款是串行的。第三,幂等校验放在最前面,用 tradeNo 查流水表,如果已存在就直接拒绝,防止用户重复点击导致重复扣款。
对应的 Mapper XML 关键 SQL:
<!-- 悲观锁查询账户 --> <select id="selectForUpdate" resultType="Account"> SELECT * FROM t_account WHERE account_no = #{accountNo} FOR UPDATE </select> <!-- 更新余额,正数为加,负数为减 --> <update id="updateBalance"> UPDATE t_account SET balance = balance + #{delta} WHERE account_no = #{accountNo} </update>updateBalance用balance = balance + delta而不是先查再算再写,这样即使有并发,数据库层面的原子性也能保证不会算错。delta 为负数就是扣款,正数就是入账。
3.2 乐观锁与悲观锁的选择
上面用的是悲观锁,适合并发冲突较多的场景。如果你的系统读多写少,可以考虑乐观锁,用 version 字段实现。
-- 乐观锁更新:每次更新检查 version 是否变化 UPDATE t_account SET balance = balance - #{amount}, version = version + 1 WHERE account_no = #{accountNo} AND version = #{version} AND balance >= #{amount}执行后检查受影响行数,如果为 0 说明 version 被其他事务改过,需要重试或报错。乐观锁的好处是不加锁、性能好,坏处是冲突时需要重试,实现复杂度略高。我一般建议课程设计用悲观锁,逻辑直观,答辩时也好解释;如果老师追问高并发优化,再补充乐观锁方案作为对比。
3.3 幂等性设计的三种思路
幂等性是转账系统的后悔药。用户网络卡顿重复提交、前端按钮没置灰、接口被恶意重放,都会导致同一笔转账执行多次。常见的幂等方案有三种:一是用唯一交易号做数据库唯一索引,插入冲突就拒绝;二是用 Redis 记录 tradeNo,处理前先查缓存;三是状态机控制,交易状态从「处理中」到「成功」只允许流转一次。
我通常会把第一种和第三种结合:tradeNo 在流水表建唯一索引,同时交易状态严格按 0→1 或 0→2 流转,不允许从 1 再回到 0。这样即使有并发请求,数据库唯一约束会兜底,业务层状态机再挡一道。
4. 并发场景下的避坑与排查:五条最容易翻车的点
4.1 余额扣成负数
现象:两个线程同时转账,都查到余额 100,各自扣 80,最终余额变成 -60。原因:没有加锁,两个事务读到了相同的初始余额。解决:用SELECT ... FOR UPDATE锁定账户行,或者在 UPDATE 语句里加AND balance >= amount条件,让数据库层面保证不会扣成负数。
4.2 事务不回滚
现象:转账过程中抛出异常,但转出方余额已经扣了,转入方没收到。原因:异常类型是受检异常,Spring 默认不回滚;或者方法内部 try-catch 把异常吞了。解决:@Transactional 显式指定 rollbackFor = Exception.class,并且不要在事务方法内部捕获异常后不抛出。
4.3 死锁
现象:A 向 B 转账的同时 B 向 A 转账,两个事务互相等待对方释放锁,数据库报 Deadlock found。原因:两个事务加锁顺序相反。解决:统一按账户 ID 或账号字符串排序后依次加锁,保证所有事务的加锁顺序一致。
4.4 重复扣款
现象:用户点击两次提交,扣了两次钱。原因:接口没有幂等控制。解决:前端按钮点击后置灰,后端用 tradeNo 唯一索引 + 状态机双重校验,重复请求直接返回「交易已处理」。
4.5 流水与余额不一致
现象:流水表记录转账成功,但账户余额没变。原因:写流水和更新余额不在同一个事务里,或者事务传播行为配置错误。解决:确保扣款、入账、写流水在同一个 @Transactional 方法内,传播行为用默认的 REQUIRED。
注意:排查这类问题时,先看数据库的隔离级别和锁等待超时日志,再看应用日志里事务的提交回滚记录,最后用单元测试模拟并发复现。不要一上来就改代码,先定位清楚是锁的问题还是事务边界的问题。
5. 验收与答辩:怎么证明你的转账系统真的可靠
验收不是把功能点一遍就完事,而是要拿出证据证明系统在异常和并发场景下依然正确。我一般会准备三组测试:功能测试、并发测试、异常测试。
功能测试用 Postman 或 JUnit 跑通正常转账、余额不足、账户冻结、重复提交四个用例,每个用例记录请求参数、预期结果、实际结果。并发测试用 JMeter 或自己写多线程脚本,模拟 50 个线程同时对同一账户转账,跑完后检查最终余额是否等于初始余额减去所有成功交易金额之和。异常测试故意在转账中途抛异常,验证事务是否回滚、流水是否一致。
答辩时老师最爱问的三个问题:你的转账怎么保证不超卖?事务边界在哪里?幂等怎么做的?提前把答案准备好,最好能现场打开数据库表展示数据和流水。如果老师追问「如果数据库宕机了怎么办」,可以回答:单机场景下依赖数据库的 redo log 保证持久性,如果要做高可用,需要主从复制和故障转移,但这超出课程设计范围,可以作为扩展方向提一句。
| 验收项 | 测试方法 | 通过标准 |
|---|---|---|
| 正常转账 | 接口调用 | 双方余额正确变化,流水生成 |
| 余额不足 | 构造余额小于转账额 | 返回错误,余额不变 |
| 并发扣款 | 50 线程同时转账 | 最终余额 = 初始 - 成功总额 |
| 重复提交 | 同一 tradeNo 调两次 | 第二次返回重复交易 |
| 异常回滚 | 中途抛异常 | 双方余额和流水均无变化 |
从那以后我每次做涉及金额或状态流转的项目,都会强制走一遍「并发 + 异常 + 幂等」这三板斧,哪怕需求再简单也不跳过。希望这套结构能帮到你,把课程设计或方案交付稳稳拿下来。
本文还有配套的精品资源,点击获取