ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Java银行系统:事务一致性与账户状态机实战

Java银行系统:事务一致性与账户状态机实战

简介:这是一份面向Java初学者与高校课程实践者的银行管理系统教学项目,聚焦面向对象编程核心能力训练,覆盖账户管理、存取款、转账、余额查询等典型银行业务逻辑,帮助学习者深入理解封装、继承、多态及线程安全等关键概念。资源包共7个文件,含3个编译后的class字节码(体现可运行性)、1个java源文件(含核心业务逻辑,如Account类的deposit/withdraw/transferTo方法)、1个.project与1个.classpath(Eclipse工程配置,保障开箱即用),以及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> <id>mysql</id> <url>http://repo1.maven.org/maven2/</url> <!-- 注意:这里是 HTTP --> <releases><enabled>true</enabled></releases> <snapshots><enabled>false</enabled></snapshots> </repository> </repositories>

并在执行前加一行命令绕过 HTTPS 强制(仅限本地开发):

mvn clean compile -Dmaven.wagon.http.ssl.insecure=true -Dmaven.wagon.http.ssl.allowall=true

提示:生产环境必须升级 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包含建表语句,关键点有三:

  1. account表中status VARCHAR(20) NOT NULL DEFAULT 'NORMAL',对应AccountStatus枚举,不允许 NULL,避免状态歧义;
  2. transaction_record表中amount DECIMAL(15,2) NOT NULL CHECK (amount != 0),强制金额非零,堵死“0元转账”这种无效操作;
  3. created_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,但不依赖数据库自动生成,而是在TransactionRecord构造时由 JavaLocalDateTime.now()赋值,保证时间戳与应用层日志一致。

连接配置在src/main/resources/jdbc.properties:

jdbc.driver=org.h2.Driver jdbc.url=jdbc:h2:mem:bankdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE jdbc.username=sa jdbc.password=

注意:若要切 MySQL,请改jdbc.driver=com.mysql.cj.jdbc.Driver,URL 加?serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true,并确保mysql-connector-java:8.0.33在 classpath。别忘了把 H2 的h2-schema.sql改成 MySQL 兼容语法(如AUTO_INCREMENT替换IDENTITY)。

2.4 Web 层极简设计:为什么不用 Spring MVC 而用原生 Servlet

com.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;charset=UTF-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 步要么全成功,要么全回滚:

  1. 查出账:根据账号查Account实体(含当前余额、状态、最后更新时间戳);
  2. 状态校验:检查fromAccount.getStatus() == NORMAL且toAccount.getStatus() == NORMAL;
  3. 余额校验:fromAccount.getBalance().compareTo(amount) >= 0(注意:用compareTo,非>=);
  4. 生成交易记录:构造TransactionRecord,type=TRANSFER,direction=OUTGOING(对转出方)和INCOMING(对转入方);
  5. 更新账户余额:fromAccount.setBalance(fromAccount.getBalance().subtract(amount)),toAccount.setBalance(toAccount.getBalance().add(amount));
  6. 持久化交易记录:两条TransactionRecord(一出一入)插入数据库;
  7. 更新账户快照:AccountDao.updateBalanceAndVersion(account),其中version字段用于乐观锁防并发覆盖。

关键代码在TransactionService.java:

@Transactional // 注意:这是自定义注解,非 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 切面,基于ThreadLocal<Connection>实现,比 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而非 MERGE

TransactionDao.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-complete="true",否则注解可能被忽略。

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_TIMEOUT=10000,并改用SELECT * FROM account WHERE account_no = ? FOR UPDATE WAIT 5(H2 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。冲正必须满足:

  • 只能冲正当天的交易(T+0);
  • 同一笔原交易只能被冲正一次;
  • 冲正交易本身不可再被冲正。

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,原ID+REV后缀 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中加一个action=reverse分支:

// 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" method="post"> <input type="hidden" name="action" value="reverse"/> <input type="text" name="txnId" placeholder="Original transaction ID"/> <button type="submit">Execute Reversal</button> </form>

从那以后我每次加新功能,都强制走一遍「单测 → 并发压测 → 异常注入测试」三步:用CountDownLatch模拟并发,用PowerMockito模拟 DAO 抛异常,确保事务边界不被破坏。这套银行系统最硬核的地方,不是它多炫技,而是每个if都有对应的else,每个try都配了catch,每个 SQL 都有WHERE条件——它逼你写出确定性的代码。希望帮到你。

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

返回列表