
简介这份资源是一套面向Java初学者与GUI编程练习者的模拟银行账户转账系统围绕A、B两个初始余额均为1000元的账户展开通过随机互相转账演示余额校验、交易终止等核心逻辑适合用于课堂实验、课程设计或线程与界面联动的入门练习。压缩包共2个文件均为java源码整体约2KB分别承担窗口界面搭建与转账线程逻辑结构精简便于直接阅读和二次修改。资源已有4194人学习下载具备一定的参考热度。读者可从中获得一个可运行的图形界面示例下方设有“交易开始”与“清屏”按钮开始后按钮切换为“结束交易”余额不足或为零时自动停止交易结束后将交易记录写入ab.txt有助于理解事件响应、按钮状态切换、随机金额生成与文件持久化的完整流程并可作为同类账户模拟题目的改造基础。1. 从一次“钱扣了但没到账”说起模拟银行账户转账系统能解决什么你有没有遇到过这种场景转账页面点了确认余额已经扣了对方账户却迟迟没动静客服一查说“中间状态卡住了”。这类问题在真实系统里叫事务一致性而在学习和二次开发阶段最省事的验证方式就是先跑一套模拟银行账户转账系统。它把开户、存款、转账、流水查询这些动作压缩成可本地运行的工程核心目标只有一个让每一笔转账要么完整成功要么完整回滚绝不出现“扣了钱没到账”的中间态。这套资源适合正在学数据库事务、想练手后端接口设计、或者需要给教学演示搭一个可复现环境的开发者。你拿到手就能看到账户表怎么建、转账事务怎么包、并发下怎么加锁而不是对着理论干瞪眼。2. 系统架构与目录结构账户、事务、接口三层怎么摆2.1 技术选型与整体分层这套模拟系统走的是轻量后端路线常见做法是 Java Spring Boot MyBatis MySQL前端可以不要直接用接口测试工具或自带的一两个页面验证。选这套组合的原因很实际转账的核心是数据库事务Spring 的Transactional能把事务边界讲清楚MyBatis 写 SQL 直观MySQL 的 InnoDB 支持行锁和回滚正好覆盖“并发转账”这个最容易翻车的点。如果你习惯 Python换成 FastAPI SQLAlchemy 也能复现同样的逻辑但本文按 Java 栈来讲因为输入里“银行账户转账”这个场景在 Java 教学项目里最常见。分层上分三层Controller 只做参数校验和返回封装Service 承载转账业务和事务注解Mapper/DAO 负责账户余额的读写。别小看这个分层很多新手把事务注解写在 Controller 上结果代理不生效转账失败也不回滚这是血泪经验里排第一的坑。2.2 目录结构长什么样一个能跑起来的工程目录大致如下不同脚手架略有差异但核心文件跑不出这个范围bank-transfer-demo/ ├── pom.xml # Maven 依赖spring-boot-starter-web、mybatis、mysql-connector ├── src/main/java/com/example/bank/ │ ├── BankApplication.java # 启动类 │ ├── controller/ │ │ └── AccountController.java # 开户、查询余额、转账接口 │ ├── service/ │ │ ├── AccountService.java # 业务接口 │ │ └── impl/AccountServiceImpl.java # 转账事务核心 │ ├── mapper/ │ │ └── AccountMapper.java # 余额增减、账户查询 │ ├── entity/ │ │ ├── Account.java # 账户实体 │ │ └── TransferRecord.java # 转账流水 │ └── common/ │ └── Result.java # 统一返回结构 ├── src/main/resources/ │ ├── application.yml # 数据源、事务、端口配置 │ ├── mapper/AccountMapper.xml # SQL 映射 │ └── schema.sql # 建表脚本 └── README.md这个结构的好处是职责一眼能看清要改转账规则只动 Service要加字段只动 entity 和 schema要换数据库只动 application.yml 和 Mapper XML。二次开发时最怕的就是业务逻辑散落在 Controller 里这套目录至少把边界划出来了。2.3 数据表设计账户表与流水表转账系统最少需要两张表账户表存余额流水表存每一笔变动。建表脚本常见写法如下-- 账户表余额用 DECIMAL别用 FLOAT CREATE TABLE account ( id BIGINT PRIMARY KEY AUTO_INCREMENT, account_no VARCHAR(32) NOT NULL UNIQUE COMMENT 账户号, owner_name VARCHAR(64) NOT NULL COMMENT 户名, balance DECIMAL(18,2) NOT NULL DEFAULT 0.00 COMMENT 余额, version INT NOT NULL DEFAULT 0 COMMENT 乐观锁版本号, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 转账流水表记录转出转入双方和金额 CREATE TABLE transfer_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, from_account VARCHAR(32) NOT NULL, to_account VARCHAR(32) NOT NULL, amount DECIMAL(18,2) NOT NULL, status VARCHAR(16) NOT NULL COMMENT SUCCESS/FAILED, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;两个参数要重点说balance用DECIMAL(18,2)而不是FLOAT因为浮点数在金额累加时会出现0.1 0.2 0.30000000000000004这种玄学误差对账时能把你逼疯version字段是为乐观锁准备的后面讲并发控制会用到。流水表的status字段让失败记录也能留痕排查问题时不用只靠日志猜。3. 核心转账链路拆解一个请求从进入到落库的完整过程3.1 转账接口的入参与校验Controller 层负责把外部请求翻译成内部调用同时挡住明显非法的参数。常见写法RestController RequestMapping(/api/account) public class AccountController { Autowired private AccountService accountService; // 转账接口from 转出账户to 转入账户amount 金额 PostMapping(/transfer) public Result transfer(RequestBody TransferRequest req) { // 基础校验账户不能为空、金额必须为正 if (req.getFromAccount() null || req.getToAccount() null) { return Result.fail(账户号不能为空); } if (req.getAmount() null || req.getAmount().compareTo(BigDecimal.ZERO) 0) { return Result.fail(转账金额必须大于0); } if (req.getFromAccount().equals(req.getToAccount())) { return Result.fail(不能给自己转账); } return accountService.transfer(req.getFromAccount(), req.getToAccount(), req.getAmount()); } }这段代码的关键在BigDecimal的比较用compareTo而不是equals因为new BigDecimal(1.0).equals(new BigDecimal(1.00))返回 false用 equals 判断金额相等会翻车。参数校验放在 Controller 是为了快速失败真正的余额是否充足、账户是否存在要放到 Service 里在事务内判断否则并发下校验完余额可能已经被别的请求改掉了。3.2 Service 层事务转账成败的分水岭转账的核心就一段扣转出方、加转入方、写流水三步必须在同一个事务里。实现如下Service public class AccountServiceImpl implements AccountService { Autowired private AccountMapper accountMapper; // Transactional 保证三步要么全成功要么全回滚 Override Transactional(rollbackFor Exception.class) public Result transfer(String from, String to, BigDecimal amount) { // 1. 扣减转出账户余额条件里带 balance amount 防止扣成负数 int deducted accountMapper.deductBalance(from, amount); if (deducted 0) { throw new BizException(余额不足或账户不存在); } // 2. 增加转入账户余额 int added accountMapper.addBalance(to, amount); if (added 0) { throw new BizException(转入账户不存在); } // 3. 写转账流水 accountMapper.insertRecord(from, to, amount, SUCCESS); return Result.ok(转账成功); } }rollbackFor Exception.class这个参数必须显式写因为 Spring 默认只对RuntimeException回滚你抛一个受检异常它是不回滚的这是第二个高频坑。deductBalance的 SQL 里带balance #{amount}条件靠数据库的原子更新来防超扣比先查余额再判断更可靠update iddeductBalance UPDATE account SET balance balance - #{amount}, version version 1 WHERE account_no #{accountNo} AND balance #{amount} /update这条 SQL 返回的影响行数为 0 就说明余额不够或账户不存在Service 里据此抛异常触发回滚。把判断交给数据库是并发场景下最省心的做法。3.3 并发转账下的锁策略两个人同时往同一个账户转钱或者同一账户同时转出两笔余额就可能算错。常见两种解法悲观锁用SELECT ... FOR UPDATE锁住账户行简单但并发差乐观锁用 version 字段更新时比对版本冲突就重试。模拟系统里我一般先用乐观锁把逻辑跑通// 乐观锁更新version 不匹配则更新失败由上层重试 Update(UPDATE account SET balance balance - #{amount}, version version 1 WHERE account_no #{accountNo} AND version #{version} AND balance #{amount}) int deductWithVersion(Param(accountNo) String accountNo, Param(amount) BigDecimal amount, Param(version) int version);调用方先查出账户和 version再带着 version 去更新影响行数为 0 就重查重试重试次数设个上限比如 3 次超过就返回“系统繁忙”。乐观锁的好处是不占数据库连接等锁适合冲突不激烈的教学场景如果演示高并发压测再切到FOR UPDATE对比效果正好能把两种锁的差别讲透。4. 本地跑起来环境、配置与验证步骤4.1 环境准备与依赖安装跑这套系统需要 JDK 8 或以上、Maven 3.6、MySQL 5.7 或 8.0。步骤不复杂但顺序别乱建库CREATE DATABASE bank_demo DEFAULT CHARSET utf8mb4;执行schema.sql建两张表再插两条测试账户余额各给 1000。改application.yml里的数据源地址、用户名、密码端口默认 8080。项目根目录执行mvn clean package再java -jar target/bank-transfer-demo.jar启动。application.yml里事务相关配置常见这样写spring: datasource: url: jdbc:mysql://localhost:3306/bank_demo?useSSLfalseserverTimezoneAsia/Shanghai username: root password: your_password driver-class-name: com.mysql.cj.jdbc.Driver transaction: default-timeout: 10 # 事务超时秒数防止长事务拖垮连接池serverTimezone不配在某些 MySQL 8 版本下会报时区错误这是环境层面的小坑配一次就记住了。4.2 用接口验证转账是否真的原子启动后用 curl 或接口工具发一笔转账curl -X POST http://localhost:8080/api/account/transfer \ -H Content-Type: application/json \ -d {fromAccount:A001,toAccount:B002,amount:200}预期返回成功然后查两个账户余额A001 应为 800B002 应为 1200流水表多一条 SUCCESS 记录。接着故意转一笔超过余额的金额比如 99999预期返回“余额不足”再查余额必须原封不动流水表也不应出现 SUCCESS 记录。这一步是验证事务回滚的关键很多人只测成功路径结果回滚没生效也发现不了。4.3 失败时看哪里转账失败先看三处应用日志里有没有异常堆栈重点看BizException的 message数据库transfer_record表有没有半截记录账户表余额是否出现只扣不加。如果余额扣了但没加八成是Transactional没生效检查方法是不是被同类内部调用内部调用不走代理事务注解形同虚设或者异常类型没被rollbackFor覆盖。这个排查顺序能省掉大量瞎猜的时间。5. 避坑与常见问题转账系统最容易翻车的五个点5.1 事务注解失效导致扣钱不回滚现象转出账户余额扣了转入账户没加流水也没写但接口返回失败。原因Transactional方法被同一个类里的另一个方法直接调用Spring 代理没介入。解决把转账方法抽到独立 Service或者通过注入自身代理调用同时确认启动类开了EnableTransactionManagementSpring Boot 自动配置一般已开。5.2 金额用 double 导致对账差几分钱现象多笔小额转账后总余额和流水合计对不上差 0.01。原因double/float二进制表示不了 0.1 这类十进制小数。解决实体、入参、数据库字段统一用BigDecimal和DECIMAL比较用compareTo运算用add/subtract并指定RoundingMode。5.3 并发下余额被扣成负数现象压测时账户余额出现 -50。原因先查余额再判断再更新两步之间被其他请求插队。解决更新 SQL 带balance #{amount}条件靠影响行数判断成败或者用SELECT ... FOR UPDATE锁行。别在 Java 代码里做“查-判-改”三步那不是原子操作。5.4 转账流水与余额不一致现象余额正确但流水少一条或流水有但余额没变。原因写流水和改余额不在同一事务或者写流水时抛了异常被吞掉。解决三步放同一Transactional方法内流水插入失败必须抛异常流水表加唯一业务号防重复插入。5.5 长事务拖垮连接池现象并发一高接口大面积超时日志报获取连接超时。原因事务里夹了远程调用、文件读写或大量循环事务持有连接时间过长。解决事务方法只包数据库操作default-timeout设短一点重试逻辑放到事务外层。6. 二次开发改造思路从模拟系统走向可演示的完整案例拿到这套代码后最值得动手改的有三个方向。第一是加转账幂等给每笔转账请求带一个业务流水号流水表对该号建唯一索引重复提交直接返回上次结果避免用户连点两次扣两笔。第二是补一个失败重试与补偿转账失败写一条 FAILED 流水定时任务扫描后人工或自动重试这能把“事务回滚”和“最终一致”的区别演示清楚。第三是加一个简单的并发压测脚本用 JMeter 或一段多线程代码同时发 100 笔转账观察乐观锁重试次数和最终余额是否守恒验证方法就是“转出总额 初始余额 转入总额 结束余额”这个等式不成立就说明锁或事务有问题。改造时有个习惯我一直保持每加一个功能先把失败路径的测试用例写出来再写成功路径。比如加幂等先测“同一流水号提交两次只扣一次”再测正常转账。这套模拟系统本身不复杂价值在于它把事务、锁、金额精度这几个后端基本功压缩在一个能跑的小工程里改坏了重跑一遍 schema 就能重来试错成本极低。从那以后我每次碰转账类需求都强制先把“扣款、入账、流水”三步的事务边界画出来再写代码希望帮到你。本文还有配套的精品资源点击获取