首页 / 资讯中心 / 文章详情

基于Spring Boot的银行账户转账系统:事务、锁与幂等性实战

基于Spring Boot的银行账户转账系统:事务、锁与幂等性实战 ★ FEATURED ARTICLE
简介这是一份面向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 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 账户表存余额和状态一个用户可对应多个账户 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 交易流水表每笔转账一条记录 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;余额字段用 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 idselectForUpdate resultTypeAccount SELECT * FROM t_account WHERE account_no #{accountNo} FOR UPDATE /select !-- 更新余额正数为加负数为减 -- update idupdateBalance UPDATE t_account SET balance balance #{delta} WHERE account_no #{accountNo} /updateupdateBalance用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 调两次第二次返回重复交易异常回滚中途抛异常双方余额和流水均无变化从那以后我每次做涉及金额或状态流转的项目都会强制走一遍「并发 异常 幂等」这三板斧哪怕需求再简单也不跳过。希望这套结构能帮到你把课程设计或方案交付稳稳拿下来。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站