恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
模拟银行转账系统实战:并发扣款、事务回滚与锁机制落地
首页
资讯中心
/
模拟银行转账系统实战:并发扣款、事务回滚与锁机制落地
模拟银行转账系统实战:并发扣款、事务回滚与锁机制落地
发布时间:2026/10/16 6:42:38
简介这是一份面向Java初学者与GUI编程练习者的模拟银行账户转账系统源码围绕A、B两个初始余额均为1000元的账户展开随机向对方转账且金额不得超过当前余额余额为零时自动停止交易帮助理解多线程协作与并发控制的基本思路。压缩包为rar格式共2个文件均为java源码整体约2KB分别承担图形界面搭建与转账线程逻辑结构精简、便于直接阅读与二次修改。资源已有4107人学习下载说明其作为课堂实验或自学练手案例具有一定参考价值。界面下方设有“交易开始”与“清屏”按钮点击开始后按钮切换为“结束交易”结束时会自动将完整交易记录保存至ab.txt读者可借此掌握Swing事件响应、按钮状态切换、线程启停以及文件写入等实用技能适合作为Java课程设计或GUI入门练习的参考实现。1. 模拟银行账户转账系统从并发扣款到事务回滚的完整落地转账这件事看着简单真写起来全是坑。我见过太多项目在单线程测试时一切正常一上并发就出现余额扣了但对方没收到、或者两边都加了钱的情况。这份「模拟银行账户转账系统」的资源就是冲着这些真实问题去的——它用一套可运行的代码把账户管理、并发控制、事务回滚、流水记录这几个核心环节串了起来。适合谁正在学 Java 或 Python 后端、想搞懂事务和锁到底怎么落地的人以及需要给团队搭一个转账 Demo 做技术验证的开发者。它不是玩具里面的并发场景和异常处理直接对应生产环境里最让人头疼的那类 bug。2. 技术选型与目录结构为什么用这套组合而不是别的2.1 技术栈的取舍逻辑这个项目没有堆砌花哨的框架核心依赖就三样一门后端语言Java 或 Python看具体实现版本、一个关系型数据库MySQL 或 H2 内存库、以及数据库自带的事务机制。为什么不用 Redis 分布式锁因为项目的定位是「模拟」重点在于让开发者看清事务边界和锁的粒度而不是引入额外的中间件复杂度。常见做法是用 Spring Boot 或 Flask 起一个轻量服务数据库层用 JDBC 或 SQLAlchemy 直接操作把转账的每一步都暴露在代码里而不是藏在框架的注解后面。我一般会建议读者先看数据库表结构再看服务层代码。表结构决定了并发控制的起点——账户表里余额字段的类型、版本号字段有没有、流水表的主键设计这些细节直接影响到后面锁怎么加、事务怎么开。2.2 目录结构拆解项目目录大致长这样以 Java 版为例Python 版结构类似transfer-demo/ ├── src/main/java/com/example/transfer/ │ ├── controller/ # 接口层暴露转账、查询余额入口 │ │ └── AccountController.java │ ├── service/ # 核心业务逻辑 │ │ ├── TransferService.java │ │ └── AccountService.java │ ├── repository/ # 数据访问层 │ │ ├── AccountRepository.java │ │ └── TransferLogRepository.java │ ├── entity/ # 数据库实体映射 │ │ ├── Account.java │ │ └── TransferLog.java │ └── exception/ # 自定义异常 │ └── InsufficientBalanceException.java ├── src/main/resources/ │ ├── application.yml # 数据库连接、事务配置 │ └── schema.sql # 建表语句 └── pom.xml # Maven 依赖这个结构的意图很明确把「转账」这个动作拆成可独立测试的层。Controller 只负责参数校验和响应封装Service 层才是事务和锁真正生效的地方Repository 层保持纯粹的数据操作。很多初学者容易把事务注解加在 Controller 上结果事务边界模糊回滚不生效——这个项目在结构上就避免了这种误用。2.3 数据库表设计的关键字段账户表至少要有这几个字段账户 ID、户名、余额、版本号乐观锁用、创建时间。流水表则记录每一笔转账的发起方、接收方、金额、状态、时间戳。余额字段用 DECIMAL 而不是 FLOAT这是血泪经验——浮点数在多次加减后会出现精度丢失转账场景下哪怕差一分钱都是事故。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, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE transfer_log ( id BIGINT PRIMARY KEY AUTO_INCREMENT, from_account_id BIGINT NOT NULL, to_account_id BIGINT NOT NULL, amount DECIMAL(18,2) NOT NULL, status VARCHAR(16) NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP );version字段是乐观锁的标记位每次更新余额时版本号加一提交时比对版本号是否变化。status字段用来标记转账是成功、失败还是处理中方便排查问题时追溯。这两个字段在后续的并发章节里会反复用到。3. 核心模块拆解一个转账请求从进入到返回的完整链路3.1 转账服务的入口与参数校验先看 Controller 层怎么接请求。这里的关键不是写得多花哨而是把非法参数挡在事务开启之前。RestController RequestMapping(/api/transfer) public class AccountController { Autowired private TransferService transferService; PostMapping(/execute) public ResponseEntityString transfer(RequestBody TransferRequest request) { // 基础校验金额必须为正数账户不能相同 if (request.getAmount() null || request.getAmount().compareTo(BigDecimal.ZERO) 0) { return ResponseEntity.badRequest().body(转账金额必须大于零); } if (request.getFromAccountId().equals(request.getToAccountId())) { return ResponseEntity.badRequest().body(转出和转入账户不能相同); } try { transferService.executeTransfer( request.getFromAccountId(), request.getToAccountId(), request.getAmount() ); return ResponseEntity.ok(转账成功); } catch (InsufficientBalanceException e) { return ResponseEntity.status(400).body(余额不足); } catch (Exception e) { return ResponseEntity.status(500).body(系统异常请稍后重试); } } }这段代码里参数校验放在事务方法调用之前避免无效请求占用数据库连接。TransferRequest是一个简单的 DTO包含三个字段转出账户 ID、转入账户 ID、金额。金额用BigDecimal接收前端传字符串或数字都能正确解析不会出现0.1 0.2 0.30000000000000004这种玄学问题。3.2 事务边界与锁的粒度控制Service 层是整个系统的核心。转账必须满足原子性要么两边都成功要么都不动。这里用Transactional注解声明事务同时配合数据库的行锁或乐观锁来控制并发。Service public class TransferService { Autowired private AccountRepository accountRepository; Autowired private TransferLogRepository transferLogRepository; Transactional(rollbackFor Exception.class) public void executeTransfer(Long fromId, Long toId, BigDecimal amount) { // 按固定顺序加锁避免死锁 Long firstId fromId toId ? fromId : toId; Long secondId fromId toId ? toId : fromId; Account first accountRepository.findByIdForUpdate(firstId); Account second accountRepository.findByIdForUpdate(secondId); Account from fromId.equals(firstId) ? first : second; Account to toId.equals(firstId) ? first : second; // 检查余额 if (from.getBalance().compareTo(amount) 0) { throw new InsufficientBalanceException(账户余额不足); } // 扣款与入账 from.setBalance(from.getBalance().subtract(amount)); to.setBalance(to.getBalance().add(amount)); accountRepository.updateBalance(from); accountRepository.updateBalance(to); // 记录流水 TransferLog log new TransferLog(fromId, toId, amount, SUCCESS); transferLogRepository.save(log); } }findByIdForUpdate对应 SQL 里的SELECT ... FOR UPDATE会对选中的行加排他锁。两个账户按 ID 大小排序后再加锁是为了防止 A 转 B 和 B 转 A 同时发生时互相等待造成死锁。这个细节很多教程不讲但实际压测时一碰就翻车。Transactional(rollbackFor Exception.class)里的rollbackFor必须显式指定因为默认只回滚运行时异常受检异常不会触发回滚。如果余额不足抛的是受检异常而没配这个参数扣款操作可能已经提交那就真成了「钱扣了但没转出去」。3.3 流水记录与幂等性保障流水表不只是记账它还是排查问题的黑匣子。每笔转账无论成功失败都应该留痕。更进一步的流水表可以配合唯一索引实现幂等——同一个请求 ID 重复提交时数据库层面直接拒绝。public void saveTransferLog(Long fromId, Long toId, BigDecimal amount, String status) { TransferLog log new TransferLog(); log.setFromAccountId(fromId); log.setToAccountId(toId); log.setAmount(amount); log.setStatus(status); log.setRequestId(RequestContext.getRequestId()); // 从上下文获取唯一请求ID transferLogRepository.save(log); }如果流水表在request_id上建了唯一索引那么重复的请求 ID 插入时会抛DuplicateKeyException事务回滚不会重复扣款。这是实现幂等最省事的做法之一比在应用层查一遍再插入要可靠得多。4. 并发场景下的避坑与排查那些压测时才暴露的问题4.1 余额扣成负数锁没加对现象压测时账户余额出现负数明明代码里检查了余额充足才扣款。原因检查余额和扣款之间没有锁保护两个线程同时读到余额 100都判断足够扣 80然后各自扣款最终余额变成 -60。解决把查询和更新放在同一个事务里并且查询时用FOR UPDATE加行锁或者用乐观锁在更新时比对版本号。4.2 死锁加锁顺序不一致现象两个转账请求互相等待数据库报死锁异常。原因A 转 B 时先锁 A 再锁 BB 转 A 时先锁 B 再锁 A两个事务各持有一把锁又等对方的锁。解决像 3.2 节那样按账户 ID 排序后再加锁保证所有事务的加锁顺序一致。这个习惯一旦养成能省掉大量排查死锁的时间。4.3 事务不生效注解加错了地方现象转账失败后余额没有回滚。原因Transactional加在了 Controller 的 public 方法上但 Controller 调用的是 Service 的另一个方法事务根本没在 Service 方法上开启。或者 Service 方法被同类内部调用代理不生效。解决事务注解统一加在 Service 层的 public 方法上避免自调用。如果确实需要自调用用AopContext.currentProxy()获取代理对象再调。4.4 连接池耗尽长事务拖垮服务现象并发量一上来接口大面积超时日志里全是获取连接超时。原因转账事务里包含了远程调用或耗时操作导致数据库连接被长时间占用。解决事务方法里只做数据库操作任何 HTTP 调用、消息发送都放到事务提交之后。这个项目的 Service 层刻意没有引入外部调用就是为了让读者看清事务的边界。4.5 流水与余额不一致回滚没覆盖到现象余额回滚了但流水表里多了一条失败记录。原因流水记录在事务内保存但异常被 catch 后没有重新抛出事务没有触发回滚。解决要么在 catch 里手动标记回滚要么让异常直接抛到事务代理层。我一般会统一在 Service 方法里不 catch 异常让全局异常处理器去兜底这样事务行为最可控。5. 二次开发与验证怎么把这个 Demo 改成你自己的5.1 从单机事务到分布式场景的改造思路这个项目目前是单数据库事务如果你要改成跨库或跨服务的转账就得引入分布式事务方案。常见做法是 TCCTry-Confirm-Cancel或本地消息表。改造时先把TransferService拆成两个独立服务各自操作自己的数据库然后引入一个协调者来管理两阶段的提交与回滚。验证方法很简单在扣款成功后、入账前手动抛异常看两边数据是否最终一致。5.2 压测与验证的实操步骤想验证并发控制是否真的生效可以用 JMeter 或 wrk 对转账接口发压。准备两个账户初始余额各 1000并发 100 个请求各转 1 元跑完后检查两边余额之和是否仍为 2000。如果少了或多了说明锁或事务有问题。这个验证方法比看代码直观得多建议每次改完并发逻辑都跑一遍。# 用 wrk 做简单压测示例 wrk -t4 -c100 -d30s -s transfer.lua http://localhost:8080/api/transfer/executetransfer.lua脚本里随机生成转出转入账户和金额模拟真实并发。跑完后直接查数据库余额汇总比看日志快。5.3 一个容易忽略的改造点余额精度与舍入如果业务涉及利息或手续费余额计算会引入除法BigDecimal的舍入模式必须显式指定。我一般用RoundingMode.HALF_UP并且所有金额运算统一保留两位小数。这个习惯是从一次线上事故里学来的——当时手续费计算用了默认的舍入模式导致部分账户出现 0.01 元的偏差对账时查了一整天才定位到。从那以后我每次写金额相关的代码都会强制走一遍setScale(2, RoundingMode.HALF_UP)不管当前业务看起来多简单。希望这个模拟转账系统能帮你把事务和并发这两块硬骨头啃下来少走一些我当年走过的弯路。本文还有配套的精品资源点击获取