☰
模拟银行账户转账系统:事务一致性与并发控制实战
2026/11/6 5:36:51 网站建设 项目流程

简介:这份资源是一套面向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 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 转账流水表:记录转出转入双方和金额 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 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

两个参数要重点说: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 id="deductBalance"> 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。步骤不复杂,但顺序别乱:

  1. 建库:CREATE DATABASE bank_demo DEFAULT CHARSET utf8mb4;
  2. 执行schema.sql建两张表,再插两条测试账户,余额各给 1000。
  3. 改application.yml里的数据源地址、用户名、密码,端口默认 8080。
  4. 项目根目录执行mvn clean package,再java -jar target/bank-transfer-demo.jar启动。

application.yml里事务相关配置常见这样写:

spring: datasource: url: jdbc:mysql://localhost:3306/bank_demo?useSSL=false&serverTimezone=Asia/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 应为 800,B002 应为 1200,流水表多一条 SUCCESS 记录。接着故意转一笔超过余额的金额,比如 99999,预期返回“余额不足”,再查余额必须原封不动,流水表也不应出现 SUCCESS 记录。这一步是验证事务回滚的关键,很多人只测成功路径,结果回滚没生效也发现不了。

4.3 失败时看哪里

转账失败先看三处:应用日志里有没有异常堆栈,重点看BizException的 message;数据库transfer_record表有没有半截记录;账户表余额是否出现只扣不加。如果余额扣了但没加,八成是@Transactional没生效,检查方法是不是被同类内部调用(内部调用不走代理,事务注解形同虚设),或者异常类型没被rollbackFor覆盖。这个排查顺序能省掉大量瞎猜的时间。

5. 避坑与常见问题:转账系统最容易翻车的五个点

5.1 事务注解失效导致扣钱不回滚

现象:转出账户余额扣了,转入账户没加,流水也没写,但接口返回失败。原因:@Transactional方法被同一个类里的另一个方法直接调用,Spring 代理没介入。解决:把转账方法抽到独立 Service,或者通过注入自身代理调用;同时确认启动类开了@EnableTransactionManagement(Spring 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 就能重来,试错成本极低。从那以后我每次碰转账类需求,都强制先把“扣款、入账、流水”三步的事务边界画出来再写代码,希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询