Java银行管理系统开发实战:分层架构、连接池与事务并发控制

发布时间:2026/10/19 2:16:49
Java银行管理系统开发实战:分层架构、连接池与事务并发控制 简介Java银行管理系统是一份基于Java的综合性课程实验项目资源面向Java初学者及正在学习面向对象编程、软件工程的学生用于理解账户管理、存取款、转账、利息计算等银行核心业务的实现方式。压缩包共7个文件包含源码java文件、编译后的class文件以及Eclipse工程配置.project、.classpath、.prefs整体仅8KB适合作为小型教学案例快速导入IDE运行学习。目前已有763人学习下载。项目虽小但覆盖了Account类的设计、存款取款、转账、余额查询等典型模块可帮助读者掌握类结构、方法封装、异常处理及基础数据持久化思路若进一步扩展还可涉及并发控制、SQL注入防范等安全性议题。整体上是一份麻雀虽小五脏俱全的Java入门实践参考既能巩固课堂理论也能为后续开发更完整的业务系统打下基础。1. Java银行管理系统从学生项目到可落地的分层架构做Java银行管理系统的人多半绕不开一个尴尬代码跑通了演示完了交差了但心里清楚这系统拿不出手。我在某次接手一个模拟项目X的银行系统时就遇到过这种状态——登录、开户、存取款都能跑一进并发测试就翻车余额对不上连接池直接打满。后来花了两周重构整个工程结构才把常见学生项目的“单类打天下”改成真正能维护的分层架构。这篇笔记就是用那次重构的视角来拆解从工程骨架、数据库设计到鉴权、事务和排错按落地顺序走一遍。你可能是刚入门的初学者也可能是准备做课程设计的在校生这套思路能帮你把“能演示的项目”升级成“经得起问的项目”。2. 工程骨架与数据库设计先说清楚表和连接池怎么定2.1 三层架构职责划分dao、service、web各自管什么常见的翻车写法是把JDBC代码、业务判断、Servlet请求处理全塞进一个类看起来热闹后续每个功能都要改这一个类。我在某高校项目里见过一个核心类超过三千行的版本加个字段要滚动半天。做这个银行管理系统时我用的标准三层web层只做参数接收、封装请求对象、调用service、响应结果service层负责业务规则比如余额是否充足、账户是否冻结、转账是否在同一个事务dao层只做SQL的增删改查不写if else逻辑这样分层带来的直接好处是session校验、数据库操作、业务判断可以分别测试后面换数据库或者改页面时不用动业务代码。Controller里出现的SQL语句本身就是个设计味道很重的信号。2.2 建表SQL与字段陷阱账户表、客户表、流水表怎么设计先把三张核心表和字段写下来这是整个系统里最需要先想清楚的部分后续的查询性能、对账逻辑都在这一步决定。-- 客户表 CREATE TABLE customer ( id BIGINT PRIMARY KEY AUTO_INCREMENT, id_card VARCHAR(18) NOT NULL UNIQUE COMMENT 身份证号逻辑唯一, name VARCHAR(32) NOT NULL, phone VARCHAR(11) DEFAULT NULL, address VARCHAR(128) DEFAULT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 账户表 CREATE TABLE account ( id BIGINT PRIMARY KEY AUTO_INCREMENT, account_no VARCHAR(19) NOT NULL UNIQUE COMMENT 卡号长度要预留, customer_id BIGINT NOT NULL, balance DECIMAL(14,2) NOT NULL DEFAULT 0.00, status TINYINT NOT NULL DEFAULT 1 COMMENT 1-正常 0-冻结, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, CONSTRAINT fk_account_customer FOREIGN KEY (customer_id) REFERENCES customer(id), KEY idx_customer_id (customer_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 交易流水表 CREATE TABLE transaction_log ( id BIGINT PRIMARY KEY AUTO_INCREMENT, trans_no VARCHAR(32) NOT NULL UNIQUE COMMENT 流水号格式见下, account_id BIGINT NOT NULL, trans_type TINYINT NOT NULL COMMENT 1-存款 2-取款 3-转账, amount DECIMAL(14,2) NOT NULL, balance_after DECIMAL(14,2) NOT NULL COMMENT 交易后余额对账用, target_account_id BIGINT DEFAULT NULL COMMENT 转账目标账户, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_account_created (account_id, created_at) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;字段设计上有几个地方要特别注意都是踩过坑才改出来的余额字段必须用DECIMAL不能用double或float。二进制浮点数存金额会出现0.10.2不等于0.3的经典问题银行系统一旦出现分位对不上账等于直接报废。account_no直接用19位字符串而不是自增id。因为卡号可能以特定数字开头比如银行卡常见的62开头自增id无法满足这种业务前缀。同时它作为业务维度唯一标识和数据库主键解耦。transaction_log里必须保存balance_after这个字段是交易后余额的快照。后续排查问题时如果账户余额和流水对不上直接按时间顺序重放流水就能定位是哪一笔出了问题。2.3 JDBC连接池与配置参数Druid连接池参数怎么设很多初学者用DriverManager.getConnection()每次请求都新建连接几百个并发就能把数据库连接打满。我用的是阿里的Druid连接池配置如下# druid.properties driverClassNamecom.mysql.cj.jdbc.Driver urljdbc:mysql://localhost:3306/bank_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai usernamebank_user passwordbank_pass_123 initialSize5 maxActive30 minIdle5 maxWait60000 validationQuerySELECT 1 testWhileIdletrue timeBetweenEvictionRunsMillis60000关键参数说明initialSize5启动时预建5个连接避免第一个请求来临时建连让首次访问慢半拍maxActive30最大活跃连接数超过这个数会排队等待。设太小容易打满设太大数据库本身扛不住30对中小项目够用maxWait60000拿不到连接时的最大等待时间超过就抛异常避免线程无限挂死validationQuerySELECT 1每次从池里拿连接前先验证可用性防止拿到已经被数据库断开的死连接连接池的获取和归还必须配套。我用一个工具类封装DAO层拿完连接后必须关闭连接但关闭的是归还连接池而不是真正断开这是连接池的实现机制。有的同学在finally块里忘记调用close方法跑一个晚上连接池就爆了。数据源的初始化位置我一般放在ServletContextListener里做应用启动时初始化一次比每次请求时new DataSource()要合理得多。2.4 配置文件加载失败调试classpath与路径问题第一次跑这个配置时最容易遇到的就是Properties.load()读不到文件。常见原因是配置文件放在了src/main/java目录下和Java源码混在一起编译后文件在target/classes里能找到但在IDE里看到的是源码目录。正确做法是放在src/main/resources目录下然后用如下方式加载InputStream in DruidUtil.class.getClassLoader().getResourceAsStream(druid.properties);这里有个细节getResourceAsStream返回null时Properties.load会直接抛NullPointerException而且报错信息里根本不说是文件找不到。排查方法是先判断in是否为null打印当前classpath路径看一眼。我遇到过在某IDE里运行正常、打成war包后就报错的情况多半是打包时漏了配置文件。3. 登录鉴权与角色权限Session怎么管、密码怎么存3.1 MD5加盐与迭代哈希密码不能明文存很多初学者在数据库里直接存明文密码登录时拿输入值和数据库比对。这在学习项目里能用一旦被拖库所有账号全暴露。我在重构时用加盐MD5SHA-256在密码存储和哈希速度上的表现差异较小MD5加盐配合多次迭代足够应付课程设计和中低安全需求的场景。public class PasswordUtil { private static final String SALT bank_sys_static_salt; public static String hashPassword(String rawPassword) { String saltPassword rawPassword SALT; // 迭代3次降低暴力破解速度 String hashed saltPassword; for (int i 0; i 3; i) { hashed md5(hashed); } return hashed; } private static String md5(String input) { // 标准MessageDigest实现返回32位十六进制字符串 } }这里说明一下设计取舍。静态盐SALT写死在代码里好处是用户表里不需要单独存盐字段查询简单坏处是如果源码泄漏所有用户的哈希值都可以被预先计算。真实生产环境会给每个用户生成随机盐存在库里。对于课程设计和中小系统静态盐加多次迭代能在易用性和安全性之间取平衡。3.2 Session管理与登录拦截器会话过期和权限失效登录成功后把用户信息放Session再写一个拦截器对所有受保护页面做过滤。我用的是Servlet Filter不需要额外引入框架也能达到目标public class LoginFilter implements Filter { public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) { HttpServletRequest req (HttpServletRequest) request; HttpServletResponse resp (HttpServletResponse) response; HttpSession session req.getSession(false); if (session null || session.getAttribute(currentUser) null) { resp.sendRedirect(req.getContextPath() /login.jsp); return; } chain.doFilter(request, response); } }注意点有三个一是req.getSession(false)和req.getSession()的区别。带false表示拿不到就不创建避免恶意请求大量触发session创建。不带参数的写法是如果没有session就新建一个配合拦截器逻辑判断时会产生问题。二是过滤器的映射范围。在web.xml里把需要登录才能访问的路径全部包进去比如/account/、/transaction/。千万别用/*全包否则login.jsp本身也会被拦截形成死循环。三是Session超时web.xml里配置session-config session-timeout30/session-timeout /session-config30分钟没操作自动失效。调试时这个超时会经常打断流程可以先把时间调长一点功能跑通后再改回来。3.3 角色权限柜员与管理员的操作边界银行系统里角色通常分两种柜员处理日常业务管理员查流水、冻结账户。实现方式是登录后在Session里存role字段在业务方法里用权限校验工具类判断public class PermissionUtil { public static void checkAdmin(HttpSession session) throws PermissionDeniedException { Integer role (Integer) session.getAttribute(currentRole); if (role null || role ! Roles.ADMIN) { throw new PermissionDeniedException(需要管理员权限); } } }以前权限判断最容易出问题的点在页面按钮显隐上。前端隐藏一个按钮不等于后端安全直接构造请求URL就能绕过页面调接口。后来我把权限校验下沉到Service层每个关键操作先校验再执行页面上的隐藏只是体验优化不是安全控制。这是从学生思维到工程思维的一个关键转变。4. 存款取款与转账的业务闭环事务边界和并发扣款4.1 存款业务事务边界和余额校验存取款的业务逻辑看起来简单实际上有四个环节都要做对事务开启、余额校验、更新账户、写流水。缺一个系统都会在对账时暴露问题。public void deposit(Long accountId, BigDecimal amount) { String sql UPDATE account SET balance balance ? WHERE id ? AND status 1; // 执行更新返回受影响行数 // 如果行数为0说明账户不存在或已冻结 }这里有个细节把balance从库里查出来然后在Java里加好再写回存在并发覆盖问题。两条线程同时读到100都加上50后提交的会把先提交的覆盖。所以我用UPDATE的原子操作直接在SQL语句层面完成加减数据库的行锁会保证同一时间只有一笔操作能成功修改这个账户。存款流程结束后必须写流水。流水表里存balance_after方便后续对账。写流水和更新余额这两个动作必须在同一个事务里否则会出现余额变了但流水没记录的脏数据。4.2 转账跨账户事务和并发控制转账比存款复杂在涉及两个账户事务跨度更大。经典转账代码长这样Transactional(rollbackFor Exception.class) public void transfer(Long fromAccountId, Long toAccountId, BigDecimal amount) { // 1. 扣减转出账户 int fromResult accountDao.deductBalance(fromAccountId, amount); if (fromResult 0) { throw new BusinessException(余额不足或账户异常); } // 2. 增加转入账户 accountDao.increaseBalance(toAccountId, amount); // 3. 记录转出流水 accountDao.insertTransLog(fromAccountId, 3, amount.negate(), fromAccountId); // 4. 记录转入流水 accountDao.insertTransLog(toAccountId, 3, amount, fromAccountId); }最隐蔽的问题是事务什么时候真正提交。在Spring管理下方法执行完才提交如果步骤2或3抛异常步骤1的扣减会回滚不会出现扣了钱对方没收到的情况。但如果你用的是原生JDBC没有事务控制步骤1执行完提交了步骤2失败资金凭空消失。另外accountDao.deductBalance的SQL里必须带balance ?条件UPDATE account SET balance balance - ? WHERE id ? AND balance ? AND status 1这样做的目的是让余额判断和扣减操作合在一个原子SQL里避免“查出来判断够了再更新”两步之间的并发窗口。数据库层面行锁同时只能一个事务更新这条记录后面的更新发现余额不够会直接返回0行代码里据此抛出异常。4.3 流水号生成时间戳随机数的实现方案流水号需要业务可读、尽量唯一方便排查问题。我用的方案是String transNo T System.currentTimeMillis() String.format(%04d, new Random().nextInt(10000));这个方案在单机低并发下够用但注意Random生成的4位数在高并发下可能碰撞。后来我改成用数据库自增id做前缀拼接// 插入流水记录拿到自增id Long id transLogDao.insertAndReturnId(...); String transNo T id System.currentTimeMillis();这样流水号一定唯一因为数据库自增id不会重复。这个方案的好处还在于如果业务上要做幂等控制可以直接用这个流水号作为去重键。4.4 查询优化交易流水分页与索引使用交易流水表数据量增长很快如果不在account_id和created_at上建索引按用户查历史流水会全表扫描。前面建表SQL里已经加了idx_account_created组合索引查询时用SELECT * FROM transaction_log WHERE account_id ? ORDER BY created_at DESC LIMIT ?, ?这里有一件容易忽视的事分页页数过大会导致深度分页性能下降。LIMIT 100000, 20这样的写法数据库要先扫描前面十万条记录再丢弃。当数据量真的大起来时我一般会改成基于上次查询的最后一笔流水id做游标分页也就是WHERE id ? ORDER BY id DESC LIMIT 20但课程设计阶段用LIMIT offset的方式够用。5. 常见问题与避坑连接泄漏、浮点精度和并发覆盖5.1 连接池连接被耗尽getConnection后忘了关闭现象系统运行一段时间后所有请求都卡在拿连接上日志报Connection is not available, request timed out。原因DA0层代码里使用了Connection con JDBCUtil.getConnection()但finally块里没有调用con.close()或者写成了con.close()后System.exit(0)。连接没有归还连接池池里的连接被耗尽。最典型的场景是异常路径上没有close比如SQLException抛出前忘记释放。解决用try-with-resources语法连接相关的语句全包在里面不需要手动finally。Java 7之后Connection实现了AutoCloseable接口这样写既美观又不会漏try (Connection con JDBCUtil.getConnection(); PreparedStatement ps con.prepareStatement(sql)) { ps.setLong(1, id); ResultSet rs ps.executeQuery(); } catch (SQLException e) { log.error(query failed, e); }注意ResultSet不能放try里因为它依赖StatementStatement关了它就不能用了。只在try内部使用不手动关闭。5.2 余额对不上账用了浮点类型或事务缺commit现象某个账户存取款几次后余额和流水表重算出来的金额不一样差的金额是几分钱。原因在Java代码里用了double类型传金额数据库字段也建成了double。浮点数在二进制里无法精确表示0.1、0.2这样的十进制小数导致计算结果出现0.30000000000000004。另一个原因是手动事务里执行了connection.commit()只在成功路径上异常路径没有回滚。解决Java侧所有金额相关变量用BigDecimal数据库字段在首次建表时就固定为DECIMAL(14,2)。BigDecimal构造时用字符串构造器new BigDecimal(19.99)不要用new BigDecimal(19.99)后者依然有精度问题。事务上把commit和rollback统一放finally块里做判断try { // 业务操作 con.commit(); } catch (SQLException e) { con.rollback(); } finally { con.close(); }5.3 时间精度丢失created_at默认值忽略毫秒现象同一秒内多笔交易流水表里created_at完全一样导致查不出先后顺序对账时无法分辨哪笔在前。原因数据库字段默认值是CURRENT_TIMESTAMP但它默认精度到秒毫秒被舍掉。在高频交易场景下同一秒内会有多笔记录。解决建表时把时间字段定义为DATETIME(3)配合DEFAULT CURRENT_TIMESTAMP(3)。Java侧用LocalDateTime.now()传入时注意类型匹配。如果已经建好了表用ALTER TABLE transaction_log MODIFY created_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3)来改。5.4 并发扣款覆盖余额判断和扣减分离导致超扣现象两个线程同时读取余额100各自判断可以扣80同时提交后余额变成20而不是预期的正确值甚至变成负数。原因查询余额和更新余额是两个独立步骤没有放在同一个数据库事务里也没有使用行锁保护。两个请求并发执行时同时读到旧值然后分别写回先写回的被后写回覆盖。解决用UPDATE ... WHERE balance ?原子操作替代两步逻辑。数据库行级锁会让第二个UPDATE等待第一个事务提交后执行此时余额已不满足条件返回0行业务代码抛异常。这是并发扣款场景的标准做法。5.5 Session失效但页面还显示登录态现象用户明明已经退出登录按浏览器后退按钮还能看到之前页面数据或者session超时后直接操作某个功能按钮报NullPointerException。原因前端页面缓存了动态数据没有在请求时重新校验session。浏览器后退时可能从本地cache加载页面不发起请求。后端从session取用户信息时没有判断null。解决在web.xml里给受保护页面配置禁止浏览器缓存的响应头filter filter-nameNoCacheFilter/filter-name /filter !-- 对应过滤器里设置 -- response.setHeader(Cache-Control, no-cache, no-store, must-revalidate);后端取Session属性时统一用工具类如果取出来是null直接跳转到登录页而不是让NullPointerException冒泡到用户界面。调试session问题时先在浏览器开发者工具的Network面板里看响应头有没有清晰的缓存控制标识。6. 收尾验证用单元测试和压测脚本给项目兜底6.1 Service层单元测试用Mock数据验证事务回滚功能代码写完只代表逻辑存在不代表逻辑正确。我给转账写了一个JUnit单元测试。没有测试框架的可以用一个main方法调Service逻辑测试建议用JUnit加H2内存库做集成验证。public class TransferServiceTest { private TransferService transferService new TransferService(); Test public void testTransfer_InsufficientBalance() { Long fromId 1L; Long toId 2L; BigDecimal amount new BigDecimal(9999999.00); // 断言抛出余额不足异常 assertThrows(BusinessException.class, () - { transferService.transfer(fromId, toId, amount); }); // 断言转出账户余额没变 BigDecimal fromBalance accountDao.getBalance(fromId); assertEquals(new BigDecimal(1000.00), fromBalance); } }这个测试验证的是最核心的边界转账失败时转出账户余额不能有动静。如果断言通过事务回滚是生效的。我每次改business逻辑都先跑一遍这些测试再手动点页面效率高很多。6.2 手工压测验证并发安全关键的并发场景模拟可以用一个简单的脚本程序来验证前面提到的并发扣款问题。用Java的线程池模拟多用户同时取款public class ConcurrentWithdrawTest { public static void main(String[] args) throws Exception { int threadCount 10; ExecutorService pool Executors.newFixedThreadPool(threadCount); CountDownLatch start new CountDownLatch(1); AccountDao accountDao new AccountDao(); for (int i 0; i threadCount; i) { pool.submit(() - { try { start.await(); accountDao.withdraw(1L, new BigDecimal(50.00)); } catch (Exception e) { System.out.println(失败: e.getMessage()); } }); } start.countDown(); pool.shutdown(); } }如果账户初始余额10010个线程各取50正确结果应该是最多2个成功其余全部失败。跑完后查询数据库余额确认是0而不是负数说明原子更新SQL生效了。我通常会把并发数调到线程池峰值再调大一倍看数据库连接池是否会撑爆。跑一次这个测试比自己改半天代码防并发更直观。6.3 收尾的验证清单最后我有一套固定流程先把数据库重置成初始数据启动服务走一遍开户、存款、取款、转账、查询流水全流程然后把并发线程调大再跑一遍确认余额不出负数、流水不丢不重最后把连接池的maxActive调到2模拟资源紧张环境看系统是否会抛出可读的异常而不是长时间挂死。这套流程走完我才有底气把项目交付出去。之前的项目在一次演示时当场翻车——转账后余额凭空少了50块钱现场改代码的狼狈样子现在还记得。从那以后每次做银行类项目我都强制走一遍并发测试和事务回滚验证别指望手动点多几个按钮就能覆盖边界条件。希望这次拆解对你有帮助把该绕的坑提前绕过去。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询