ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Java网上银行转账系统源码实战:事务、并发与对账设计

Java网上银行转账系统源码实战:事务、并发与对账设计 简介这是一套面向Java Web初学者与课程设计者的网上银行转账系统源码围绕账户认证、资金转入转出、事务管理与安全防护等核心业务展开适合用于毕业设计、课程实训或Java后端入门练手。压缩包共46个文件、约356KB其中19个Java源文件承载后端业务逻辑14个JSP页面负责账户信息展示与转账表单交互7个XML配置文件管理数据库连接与系统参数另有properties属性文件、txt说明文档和1个JavaScript脚本用于前端校验与动态效果。项目采用Maven构建目录结构清晰便于按模块阅读与二次开发。已有279人学习下载读者可借此理解JSPJava在金融场景中的分层实现方式掌握事务ACID、SQL注入与XSS防护等安全要点并参考README快速完成环境配置与运行调试。1. 从一份 Java 网上银行转账系统源码说起它到底能解决什么问题很多人第一次接触「基于Java的网上银行转账系统设计源码」是在课程设计或者毕业设计的节点上。手里拿到一份源码跑起来能看到登录页、账户余额、转账表单但真要把这套东西讲清楚、改明白、甚至拿去面试时当项目讲就会发现里面全是黑匣子钱从 A 账户扣掉、加到 B 账户中间到底怎么保证不出错并发转账会不会把余额扣成负数事务回滚到底回滚了什么这份源码的价值不在于它界面多漂亮而在于它把「转账」这个最典型的金融业务场景用 Java 技术栈完整地实现了一遍涉及数据库事务、并发控制、账户状态机、流水记录这几块硬骨头。它适合三类人一是正在做课程设计、需要一份能跑通、能讲清楚逻辑的 Java 项目二是刚入行的 Java 工程师想找一个比 CRUD 管理系统更有含金量的练手项目三是准备面试的人因为「转账系统怎么保证不超卖、不丢钱」几乎是 Java 后端面试的高频追问点。下面我不谈虚的直接按「这套系统怎么搭起来、转账核心逻辑怎么写、并发和事务的坑在哪、怎么验证它真的可靠」这条线把一份可复现的方案讲透。2. 网上银行转账系统的技术选型与库表设计为什么这么定2.1 技术栈怎么选才不给自己挖坑一份能落地的 Java 转账系统技术栈不需要花哨但每一层都要选得稳。我一般会这么定Spring Boot 做基础框架省掉大量 XML 配置持久层用 MyBatis-Plus因为它对单表 CRUD 和分页的支持足够省事同时又能随时写原生 SQL 处理转账这种复杂逻辑数据库选 MySQL 8InnoDB 引擎是必须的因为只有它支持行级锁和事务前端如果只是演示Thymeleaf 或者简单的 Vue 都行但核心不在前端。这里要特别说一句热搜里经常出现「mybatisplus根据java实体类生成创建表的sql语句」这个能力在转账系统里其实很有用。你定义好 Account、TransferRecord 这些实体类用 MyBatis-Plus 的代码生成或者手写 DDL能快速把表结构对齐减少实体和表字段不一致导致的低级错误。但注意生成归生成转账相关的字段类型和约束必须手动确认不能全信自动生成。选型理由说到底就一条转账系统的核心矛盾是「数据一致性」所以任何选型都要服务于「事务可控、锁可控、日志可查」。用 JPA 不是不行但在需要精细控制 SQL 和锁的场景下MyBatis 系更透明出问题你能看到具体执行了哪条语句。2.2 账户表和流水表的字段设计库表设计是这套系统的地基设计错了后面全是坑。核心就两张表账户表account和转账流水表transfer_record。账户表至少要有主键 id、账户号 account_no唯一索引、户名、余额 balance用 DECIMAL 而不是 DOUBLE、账户状态 status、版本号 version做乐观锁用、创建和更新时间。余额字段用 DECIMAL(18,2) 是铁律浮点数在金额计算上会丢精度这是血泪经验。流水表要记录每一笔转账的完整信息主键、转出账户、转入账户、金额、转账前双方余额、转账后双方余额、状态处理中/成功/失败、失败原因、创建时间。为什么要记转账前后余额因为一旦出现纠纷或者对账你能凭流水还原当时的状态这是金融系统的基本要求也是很多人做课程设计时最容易漏掉的一环。CREATE TABLE account ( id BIGINT PRIMARY KEY AUTO_INCREMENT, account_no VARCHAR(32) NOT NULL COMMENT 账户号, owner_name VARCHAR(64) NOT NULL 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 乐观锁版本号, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_account_no (account_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE transfer_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, from_account_no VARCHAR(32) NOT NULL, to_account_no VARCHAR(32) NOT NULL, amount DECIMAL(18,2) NOT NULL, from_balance_before DECIMAL(18,2) NOT NULL, from_balance_after DECIMAL(18,2) NOT NULL, to_balance_before DECIMAL(18,2) NOT NULL, to_balance_after DECIMAL(18,2) NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT 0处理中 1成功 2失败, fail_reason VARCHAR(255) DEFAULT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_from (from_account_no), KEY idx_to (to_account_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;上面这段 DDL 里account 表的 version 字段是给乐观锁用的status 字段是给账户状态机用的这两个字段在后面的并发控制里会反复出现。transfer_record 表把转账前后的余额都存下来是为了对账和排查别嫌字段多真出问题时这些字段能救命。索引方面account_no 唯一索引保证账户不重复流水表的 from 和 to 索引保证按账户查流水时不至于全表扫描。2.3 账户状态机与转账前置校验转账不是拿到两个账户就直接扣加前置校验决定了系统能不能挡住脏数据。一个账户至少有三种状态正常、冻结、销户。转账前必须校验转出账户状态正常、转入账户状态正常、转出账户余额充足、转账金额大于零、不能给自己转账业务上通常禁止。这些校验要放在 Service 层而不是只靠前端因为前端校验永远可以被绕过。我一般会把校验逻辑抽成一个独立的 validate 方法返回明确的错误码和错误信息而不是简单抛异常。这样前端能根据错误码给出友好提示后端日志也能快速定位。比如余额不足返回 BALANCE_NOT_ENOUGH账户冻结返回 ACCOUNT_FROZEN。这套错误码体系在后面的流水记录里也会用到失败原因直接落库排查时一目了然。3. 转账核心逻辑的 Java 实现从扣款到入账的完整链路3.1 用事务把扣款和入账绑成一个原子操作转账的本质是「A 扣钱、B 加钱」这两步必须同时成功或同时失败这就是事务要解决的问题。在 Spring Boot 里最直接的做法是在 Service 方法上加 Transactional然后按顺序执行查转出账户、查转入账户、校验、扣转出、加转入、写流水。任何一步抛异常整个事务回滚。但这里有个新手最容易翻车的地方Transactional 默认只对 RuntimeException 回滚如果你抛的是受检异常Checked Exception事务不会回滚。所以要么统一抛 RuntimeException要么显式写 Transactional(rollbackFor Exception.class)。我一般直接写 rollbackFor Exception.class省得后面踩坑。Service public class TransferService { Autowired private AccountMapper accountMapper; Autowired private TransferRecordMapper recordMapper; Transactional(rollbackFor Exception.class) public TransferResult transfer(String fromNo, String toNo, BigDecimal amount) { // 1. 前置校验 if (amount null || amount.compareTo(BigDecimal.ZERO) 0) { throw new BizException(AMOUNT_INVALID, 转账金额必须大于零); } if (fromNo.equals(toNo)) { throw new BizException(SAME_ACCOUNT, 不能给自己转账); } // 2. 查询账户这里用行锁见下一节 Account from accountMapper.selectForUpdate(fromNo); Account to accountMapper.selectForUpdate(toNo); if (from null || to null) { throw new BizException(ACCOUNT_NOT_FOUND, 账户不存在); } if (from.getStatus() ! 1 || to.getStatus() ! 1) { throw new BizException(ACCOUNT_FROZEN, 账户状态异常); } if (from.getBalance().compareTo(amount) 0) { throw new BizException(BALANCE_NOT_ENOUGH, 余额不足); } // 3. 记录转账前余额 BigDecimal fromBefore from.getBalance(); BigDecimal toBefore to.getBalance(); // 4. 扣款与入账 from.setBalance(fromBefore.subtract(amount)); to.setBalance(toBefore.add(amount)); accountMapper.updateById(from); accountMapper.updateById(to); // 5. 写流水 TransferRecord record new TransferRecord(); record.setFromAccountNo(fromNo); record.setToAccountNo(toNo); record.setAmount(amount); record.setFromBalanceBefore(fromBefore); record.setFromBalanceAfter(from.getBalance()); record.setToBalanceBefore(toBefore); record.setToBalanceAfter(to.getBalance()); record.setStatus(1); recordMapper.insert(record); return TransferResult.success(record.getId()); } }这段代码的逻辑顺序很关键先校验、再查账户、再扣加、最后写流水。每一步失败都会抛异常触发回滚保证不会出现「扣了钱没加钱」或者「加了钱没写流水」的情况。参数方面amount 用 BigDecimal 保证精度compareTo 而不是 equals 来比较金额因为 BigDecimal 的 equals 会比较精度1.0 和 1.00 不相等这是个经典坑。3.2 并发转账下怎么防止余额被扣成负数上面那段代码在单线程下没问题但一旦两个请求同时给同一个账户转账就会出问题。假设账户余额 100两个请求各转 80两个线程同时查到余额 100都认为够然后各自扣 80最后余额变成 -60。这就是典型的并发超扣。解决办法有两种悲观锁和乐观锁。悲观锁就是在查询账户时加行锁SELECT ... FOR UPDATE这样第二个请求会阻塞到第一个事务提交拿到的是最新余额。上面代码里的 selectForUpdate 就是干这个的。悲观锁的优点是简单可靠缺点是并发量大时会有锁等待影响吞吐。乐观锁则是用 version 字段更新时带上版本号条件UPDATE account SET balance ?, version version 1 WHERE id ? AND version ?如果影响行数为 0说明被别人改过重试或报错。乐观锁适合冲突少的场景冲突多时重试成本高。!-- AccountMapper.xml 中的悲观锁查询 -- select idselectForUpdate resultTypecom.example.entity.Account SELECT * FROM account WHERE account_no #{accountNo} FOR UPDATE /select// 乐观锁更新示例 Update(UPDATE account SET balance #{balance}, version version 1 WHERE id #{id} AND version #{version}) int updateWithVersion(Account account);我一般会这么选如果是课程设计或者并发量不大的内部系统直接用悲观锁代码简单不容易错如果是要讲给面试官听、体现技术深度就把两种都实现说明各自适用场景。注意悲观锁的 FOR UPDATE 必须在事务内才生效如果你在事务外调用锁会立刻释放等于没加。3.3 转账流水的状态流转与对账思路流水表里的 status 字段不是摆设它记录了一笔转账的生命周期。正常流程是插入流水时 status0处理中转账成功后更新为 1成功如果中途失败则更新为 2失败并记录 fail_reason。为什么要先插处理中再更新因为如果转账过程中系统崩溃你能通过 status0 的流水发现「有笔转账没走完」这就是对账的入口。对账的基本思路是定时扫描 status0 且创建时间超过一定阈值的流水人工或自动核查账户余额是否与流水一致。如果发现不一致就以流水记录的前后余额为准进行修复。这套机制在真实银行系统里是核心在课程设计里加上这一笔项目的完整度会明显高一个档次。提示流水表不要用物理删除只做状态标记。金融数据一旦删除对账就失去了依据这是行业铁律。4. 转账系统的避坑与排查那些让我后悔药都来不及吃的瞬间4.1 事务不生效导致扣了钱没加钱现象转账后转出账户余额减少了转入账户没变流水也没写。原因Transactional 注解失效。常见失效场景有三种方法不是 public、同类内部方法直接调用没走代理、异常被 catch 了没抛出去。解决确保注解方法 public内部调用通过注入自身或者拆到另一个 Servicecatch 后要么重新抛出要么手动回滚 TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()。4.2 余额用 double 导致精度丢失现象转账 0.1 加 0.2 后余额显示 0.30000000000000004。原因double 和 float 是二进制浮点数无法精确表示十进制小数。解决金额字段一律用 DECIMALJava 里用 BigDecimal并且用字符串构造 BigDecimal不要用 double 构造。这个坑我在早期项目里踩过对账时差了 1 分钱查了一下午。4.3 悲观锁加错位置导致死锁现象两个账户互相转账时系统卡死日志显示死锁。原因两个事务分别锁了 A 和 B然后互相等待对方释放。解决统一加锁顺序比如按账户号排序后再加锁保证所有事务以相同顺序获取锁。或者直接用乐观锁避开这个问题。死锁一旦发生MySQL 会自动回滚其中一个事务但用户体验很差。4.4 流水表字段类型和实体类不一致现象插入流水时报错提示字段长度不够或者类型不匹配。原因用 MyBatis-Plus 自动生成实体时DECIMAL 可能被映射成 Double或者 VARCHAR 长度和实际不符。解决生成后手动核对实体类和 DDL金额字段确认是 BigDecimal字符串字段确认长度够用。热搜里「mybatisplus根据java实体类生成创建表的sql语句」这个功能反向用也要小心生成完必须人工 review。4.5 转账接口没有幂等导致重复扣款现象用户网络卡顿点了两次提交扣了两次钱。原因接口没有幂等控制。解决引入请求流水号 requestNo每次转账请求带唯一流水号Service 层先查这个流水号是否已处理已处理直接返回上次结果。这个字段在真实系统里是标配课程设计里加上能体现你对业务的理解。5. 怎么验证这套转账系统真的可靠压测、对账与代码走查写完代码不代表就完事了转账系统最怕的是「看起来能跑一压就崩」。我一般会用三步来验证第一步是单元测试覆盖正常转账、余额不足、账户冻结、自己转自己这几个分支用 JUnit 加 Transactional 保证测试数据不污染库。第二步是并发压测用 JMeter 或者自己写个多线程测试类模拟 100 个线程同时给同一账户转账跑完后核对账户余额是否等于初始余额减去成功转账总额如果对不上说明并发控制有问题。Test public void testConcurrentTransfer() throws Exception { int threads 100; ExecutorService pool Executors.newFixedThreadPool(threads); CountDownLatch latch new CountDownLatch(threads); AtomicInteger successCount new AtomicInteger(0); for (int i 0; i threads; i) { pool.submit(() - { try { // 每个线程转 1 元从 A 到 B transferService.transfer(A001, B001, new BigDecimal(1.00)); successCount.incrementAndGet(); } catch (Exception e) { // 余额不足等异常忽略 } finally { latch.countDown(); } }); } latch.await(); // 核对余额A 初始 100成功 n 笔A 应为 100 - n Account a accountMapper.selectByNo(A001); assertEquals(100 - successCount.get(), a.getBalance().intValue()); }这段测试代码的关键在于用 CountDownLatch 让所有线程同时开始最大化并发冲突用 AtomicInteger 统计成功笔数最后核对余额是否等于初始值减去成功笔数。如果并发控制没做好这个断言会失败你就能立刻发现问题。参数上线程数可以调到 200 甚至 500看系统在压力下的表现。第三步是代码走查重点看三处事务边界是否覆盖了扣款和入账、锁的粒度和顺序是否合理、异常处理是否会导致事务不回滚。这三处是转账系统最容易出问题的地方也是面试官最爱追问的点。走查时我习惯把关键方法画成时序图脑子里画就行确认每一步的失败路径都有对应的回滚或补偿。最后说个我自己的习惯每次改完转账相关代码我都会手动跑一遍「转账成功、转账失败、并发转账」三个场景确认流水表和账户表的数据对得上。这个习惯帮我挡掉过好几次上线前的低级错误。转账系统没有捷径靠的就是对每一分钱的敬畏。希望帮到你。本文还有配套的精品资源点击获取
返回列表