尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

银行管理系统进阶:事务、并发锁与安全加密实战解析

银行管理系统进阶:事务、并发锁与安全加密实战解析 项目写到第三篇说明前面那套“能跑但有点土”的版本已经稳稳当当地立住了。这篇我打算聊点进阶的东西事务、并发、日志、安全以及那些你在面试时最容易被追问的“为什么”。如果你只是照着教程敲代码那基础版已经够用但如果你想把这个银行管理系统写进简历里或者拿着它去应付Java相关的面试那今天这些内容会是你最好的护城河。先交代一下本系列前两篇的成果我用纯JDBC Swing 搭了一个桌面端银行管理系统实现了用户登录、开户、存款、取款、转账、流水查询等基础功能数据表也建得七七八八。但说实话第一篇和第二篇的代码只能算“能运行的教学代码”距离“能写在简历上的业务系统”还有一段路要走。第三篇的核心目标只有两个让系统更健壮让系统更安全。这篇会围绕六个方面展开账户与流水表的设计优化、事务在转账场景下的落地、多线程并发取款的模拟与锁策略、操作日志与审计模块、密码加盐存储方案以及一套面试官最爱问的高频问题排查实录。每一块我都会给出可以直接复用的代码思路和踩坑记录看完之后你再回头改自己的项目心里会有底得多。1. 整体设计思路从“能跑”到“能扛事”很多初学者有个误区觉得银行管理系统就是“增删改查”把用户表、账户表、交易流水表建好几个按钮一绑项目就算完了。但你要是真去银行网点看一眼或者去面一次Java开发岗就会明白这类系统真正难的不是增删改查而是三件事——数据一致性、并发正确性、操作可追溯性。1.1 为什么第三篇才引入这些概念前两篇我们刻意忽略了事务和并发原因是初学阶段的核心目标是熟悉Java语法、JDBC操作和GUI组件。如果一上来就抛Connection的提交和回滚、synchronized锁、乐观锁这些概念很容易劝退。但到了第三篇知识点已经积累到一定程度必须把“正确性”提上日程。举个例子前两篇的转账可能是这样的先执行一条UPDATE把转出账户的余额减去1000再执行一条UPDATE把转入账户的余额加上1000。如果两条SQL之间程序突然崩溃或者第一条成功第二条失败钱就凭空消失了——这在银行系统里是不可接受的。这就是事务必须登场的原因。1.2 模块划分与职责边界我重新梳理了整个项目的分层结构这套分层在后端Java项目里几乎是通用做法dao层负责所有数据库操作返回值要么是实体对象要么是集合绝不允许把Connection或Statement泄漏到上层。service层承载业务逻辑比如转账要校验余额、要启动事务、要写流水。这一层是银行的“业务大脑”。controller层/UI层在桌面应用里就是Swing的各个面板只做两件事收集用户输入、展示结果。util层放工具类比如DBUtil、MD5Util、日志工具保持无状态静态方法为主。这套分层的核心价值在于当并发问题出现时你不需要去GUI代码里找锁的问题而是直接盯service层当SQL报错时你不需要翻业务逻辑而是去dao层排查。项目大了以后这种职责边界能替你节省大量排查时间。1.3 为什么选择纯JDBC而非MyBatis这个系列的前两篇一直用纯JDBC很多读者问为什么不直接上MyBatis或Spring。理由很实在当你还在理解ResultSet、PreparedStatement、事务提交这些底层机制时过早引入ORM框架会把问题掩盖在“配置”里。我见过很多用MyBatis的同学连Connection是什么都说不清一旦遇到“数据没提交”或者“连接没关闭”就彻底懵。而第三篇我们引入的进阶特性其实都是在JDBC原生机制之上做的封装思维训练。等你真正理解了底层原理再上手Spring MyBatis也就一两天的事这不是绕路是抄近道。2. 数据库表结构再优化为事务和并发打地基2.1 账户表的关键字段设计到了第三篇账户表不能再只是“id、name、balance”三件套了。结合事务和并发的需求我建议至少补齐以下字段字段名类型说明account_novarchar(20)业务账号唯一索引user_namevarchar(50)户主姓名balancedecimal(15,2)账户余额禁用浮点类型versionint乐观锁版本号初始为0statustinyint账户状态0正常1冻结create_timedatetime开户时间update_timedatetime最近更新时间这里有几个点值得展开。balance用decimal(15,2)而不是double是因为double在二进制下无法精确表示0.1这类小数多次加减之后会出现0.30000000000000004这种结果。银行账目分毫都不能差所以必须用精确数值类型。Java实体类对应使用BigDecimal绝不能再用Double接收金额。version字段是乐观锁的根基。后面做并发扣款时我们会用“UPDATE ... SET balance balance - ?, version version 1 WHERE account_no ? AND version ?”这种带条件更新的SQL来保证并发安全。version没有业务含义只用来判断数据在读取之后是否被其他人改过。2.2 交易流水表如何承载审计需求流水表的设计也做了升级。前两篇只记录“谁转给谁多少钱”第三篇我加了几个字段flow_no流水号、trade_type交易类型、create_time交易时间、operator操作人、remark备注。CREATE TABLE t_trade_flow ( id BIGINT PRIMARY KEY AUTO_INCREMENT, flow_no VARCHAR(32) NOT NULL UNIQUE, account_no VARCHAR(20) NOT NULL, trade_type VARCHAR(10) NOT NULL, amount DECIMAL(15,2) NOT NULL, balance_after DECIMAL(15,2) NOT NULL, operator VARCHAR(50), remark VARCHAR(255), create_time DATETIME NOT NULL );这里我最想强调的是balance_after这个字段。每次交易后把账户的最新余额冗余到流水表里虽然听起来浪费存储但对排查问题极其有用。你可以根据一条流水直接还原某个时点的账户状态不用再去推算。另外flow_no的生成我采用的是“时间戳 业务随机数”的组合方式避免数据库自增主键在并发场景下产生主键冲突。虽然这个项目的并发量远达不到高并发标准但养成“业务流水号独立生成”的习惯以后做订单号、支付流水号时会少踩很多坑。2.3 索引设计原则在第三篇里我顺手做了索引优化。现金流的表查询频率最高的是“按账号查流水”所以我在account_no上建了普通索引流水表里“按时间范围查”我在create_time上建了普通索引。需要注意索引不是越多越好。每个索引都会拖慢INSERT和UPDATE的速度因为数据库需要同步维护索引结构。对于这个银行管理系统来说把查询频率最高的2到3个查询条件索引起来就足够了没必要每个字段都建索引。3. 转账模块的事务落地实操转账是银行系统里最典型的事务场景。我的实现思路是在service层开启事务无论中途哪一步出错都通过回滚让账户余额恢复到操作前状态。3.1 事务的ACID到底是什么意思很多面试题会问“事务的ACID是什么”标准答案是原子性、一致性、隔离性、持久性。对应到转账场景原子性扣款和加款要么全部成功要么全部失败不存在“只扣不加”或“只加不扣”。一致性事务执行前后所有账户的余额总和不变。隔离性两个并发事务互不干扰A转账过程中B不能看到中间状态。持久性事务一旦提交结果就永久保存数据库重启也不会丢失。在JDBC里持久性由数据库日志机制保证隔离性靠数据库锁机制保证我们主要动手落实的是原子性。3.2 纯JDBC事务的标准写法话不多说直接上代码。这是我在service层写的一个转账方法核心逻辑去掉了业务校验的冗余部分public boolean transfer(String fromAccount, String toAccount, BigDecimal amount) { Connection conn null; try { conn DBUtil.getConnection(); // 关键步骤1关闭自动提交 conn.setAutoCommit(false); AccountDao accountDao new AccountDao(); // 关键步骤2扣款带余额校验 int rows accountDao.decreaseBalance(conn, fromAccount, amount); if (rows 0) { // 余额不足或账号不存在直接回滚 conn.rollback(); return false; } // 关键步骤3加款 rows accountDao.increaseBalance(conn, toAccount, amount); if (rows 0) { conn.rollback(); return false; } // 关键步骤4写入流水 accountDao.insertTradeFlow(conn, fromAccount, toAccount, amount); // 关键步骤5提交 conn.commit(); return true; } catch (Exception e) { // 任何异常都回滚 try { if (conn ! null) { conn.rollback(); } } catch (SQLException ex) { ex.printStackTrace(); } e.printStackTrace(); return false; } finally { // 关键步骤6恢复自动提交并关闭连接 try { if (conn ! null) { conn.setAutoCommit(true); conn.close(); } } catch (SQLException e) { e.printStackTrace(); } } }这段代码踩坑点集中在两处。第一setAutoCommit(false)必须在拿到连接后第一件事就执行如果前后还有别的SQL事务边界就会变得模糊。第二finally里一定要把autoCommit恢复为true因为连接池中的连接是复用的如果这次事务改成了手动提交但没恢复下一次从连接池拿到同一连接的线程就会莫名其妙地“怎么不自动提交了”。3.3 事务期间的性能表现写完后我顺手做了一个简单测试连续执行1000次转账操作每次转账需要执行4条SQL扣款、加款、流水、更新在开启事务的情况下总耗时大约是关闭自动提交逐条提交的40%左右。原因很简单关闭自动提交后所有SQL在同一个事务里批量提交减少了大量的网络往返和磁盘同步开销。这也从侧面证明事务不仅保证正确性还能提升批量操作的性能。不过事务也不是越大越好如果一个事务里塞了几万条SQL持有锁的时间过长反而会阻塞其他事务。这也引出了下一个大模块——并发。4. 多线程并发取款锁、版本号与CompletableFuture银行系统最常见的并发场景是什么多个柜台同时操作同一个账户。比如账户余额5000元两个柜台同时接到取款4000元的请求如果不加控制两个线程都读到5000元余额都判断“余额充足”然后都执行扣款最后的余额可能会变成-3000元这在真实银行是不可能发生的。4.1 为什么余额扣减不能用Java的if判断很多人的第一反应是给取款方法加synchronized。这在单机应用里确实可行而且是最容易理解的方案。但在实际项目中银行系统是分布式部署的多个服务实例各自持有一个锁synchronized只能锁住当前JVM内的线程跨进程就失效了。真正的解决办法分两个层面单机层面用synchronized或ReentrantLock保护共享资源数据库层面用行锁或乐观锁保证即使多个应用节点同时操作数据库也不会出错我建议第三篇的读者两个方案都实现一遍这样面试时就能答出“JVM锁只能锁单机真正的最终保障是数据库锁”这个关键点。4.2 数据库乐观锁的实现在账户表里加version字段后扣款SQL变成了这样UPDATE t_account SET balance balance - ?, version version 1 WHERE account_no ? AND version ?Java侧这样调用public int decreaseBalanceWithVersion(Connection conn, String accountNo, BigDecimal amount, int version) { String sql UPDATE t_account SET balance balance - ?, version version 1 WHERE account_no ? AND version ?; try (PreparedStatement ps conn.prepareStatement(sql)) { ps.setBigDecimal(1, amount); ps.setString(2, accountNo); ps.setInt(3, version); return ps.executeUpdate(); } catch (SQLException e) { e.printStackTrace(); return 0; } }这里的核心逻辑是执行UPDATE之前先查出version执行UPDATE时把version作为WHERE条件之一。如果执行返回的影响行数为0说明在查询和更新之间有别的线程已经改过这条记录version已经变了那么当前操作就要重试或报错。乐观锁的优点是并发读不受影响缺点是如果冲突频繁重试成本高。取款场景属于偶尔冲突用乐观锁很合适如果是热点账户秒杀那种每分钟几万次修改的场景就要优先考虑悲观锁或排队。4.3 用CompletableFuture模拟高并发取款JDK 8之后有了CompletableFuture写并发测试代码变得非常优雅。这里用它模拟20个线程同时向同一账户发起取款public static void main(String[] args) throws Exception { String accountNo 622200001; int threadCount 20; BigDecimal amount new BigDecimal(1000); // 用固定线程池模拟柜台 ExecutorService executor Executors.newFixedThreadPool(8); ListCompletableFutureBoolean futures new ArrayList(); for (int i 0; i threadCount; i) { CompletableFutureBoolean future CompletableFuture.supplyAsync(() - { BankService service new BankService(); return service.withdraw(accountNo, amount); }, executor); futures.add(future); } // 等待所有任务完成 CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join(); executor.shutdown(); System.out.println(并发取款测试完成); }我第一次跑这个测试的时候账户余额10000元20个并发取款各1000元如果不做任何并发控制最终余额不但会变成负数而且每条线程都返回“取款成功”。加上乐观锁之后只有大约10个请求能真正成功其余请求会返回“余额不足或操作冲突”。这个差异非常直观也特别适合在面试时提一嘴。4.4 关于锁的错误示范如果你要在单机场景下给Service层的取款方法加锁注意别把锁加错位置。锁加在Controller层UI层是错误的因为界面展示和业务逻辑不是一一绑定的同一个service方法可能被多个界面入口调用。正确做法是把锁加在Service层的方法上或者更精细地加在“账户级锁对象”上。另外synchronized加在方法上和加在代码块上的效果差别很大。如果方法体里有耗时的数据库操作直接锁住整个方法会让其他无关账户的取款也被阻塞性能很差。更合理的做法是使用ConcurrentHashMap维护一个accountNo到锁对象的映射然后只锁当前账户对应的锁对象。private final ConcurrentHashMapString, Object accountLocks new ConcurrentHashMap(); public boolean withdrawWithLock(String accountNo, BigDecimal amount) { Object lock accountLocks.computeIfAbsent(accountNo, k - new Object()); synchronized (lock) { // 查询余额、校验、扣款 } }这个方法在单机应用里效果很好既能保证同一账户的操作串行化又不会影响不同账户之间的并发。面试时我推荐主动展开这一段面试官会认为你对并发的理解不是停留在背八股文。5. 操作日志与审计凭什么说这个系统“可追溯”真实银行系统里每一个操作都会被记录在案谁在什么时间通过哪个渠道操作了哪个账户都要能追溯到。前两篇我们完全没做日志第三篇必须补上这也是面试官非常喜欢追问的一个点。5.1 日志的三个层次我在这里把日志方案分成了三个层次从简单到复杂你可以根据自己的时间选做第一层Java标准库的java.util.logging或System.out打印。最简单但没法控制输出格式和级别生产环境基本不这么用。第二层引入Logback或Log4j2日志框架。这是业界的标准做法支持按级别输出到不同文件也支持按天滚动。我项目中用的是SLF4J Logback配置简单性能也很稳。第三层面向切面记录操作日志。在Spring项目中用AOP注解自动记录方法的入参、出参、耗时这次桌面项目没有用Spring所以我选择了手动在Service层关键方法中埋日志点。5.2 在Service层记录业务日志我们先看最实用的第二层方案。引入Logback依赖后在classpath下放一个logback.xmlconfiguration appender nameFILE classch.qos.logback.core.rolling.RollingFileAppender filelogs/bank.log/file rollingPolicy classch.qos.logback.core.rolling.TimeBasedRollingPolicy fileNamePatternlogs/bank.%d{yyyy-MM-dd}.log/fileNamePattern maxHistory30/maxHistory /rollingPolicy encoder pattern%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n/pattern /encoder /appender root levelINFO appender-ref refFILE/ /root /configuration然后在转账方法里加上业务日志private static final Logger logger LoggerFactory.getLogger(BankService.class); public boolean transfer(String fromAccount, String toAccount, BigDecimal amount) { logger.info(开始转账, 转出账号: {}, 转入账号: {}, 金额: {}, fromAccount, toAccount, amount); // ... 事务逻辑 ... logger.info(转账成功, 流水号: {}, flowNo); }注意这里我用的占位符是{}而不是字符串拼接。因为字符串拼接在每次调用logger.info时都会执行哪怕当前日志级别是WARN不需要输出这条日志拼接操作依然白白耗费CPU。而用占位符等到真正需要输出时才拼接是一个不起眼但很实用的性能优化点。5.3 日志级别的选择经验日志级别从上到下分为TRACE、DEBUG、INFO、WARN、ERROR。很多新手喜欢到处用info结果日志文件里全是垃圾信息真正要找的问题反而被淹没了。我的习惯是系统启动、关键业务动作转账、开户、销户用INFO排查类细节SQL参数、循环过程用DEBUG可恢复的异常重试逻辑、临时失败用WARN程序没法继续运行或数据完整性被破坏用ERROR每天跑下来INFO日志能告诉我系统做了什么WARN和ERROR能告诉我哪里出了问题DEBUG日志在需要排查时随时可以打开生产环境默认关掉。这个分级习惯用在工作里领导会对你的代码交付质量高看很多。5.4 操作日志表的落库方案除了文件日志银行系统还需要“操作日志表”来记录审计所需的业务数据。我的做法是在Service层方法里同步记录一条操作日志到t_operate_log表包含操作人、操作类型、被操作的账号、操作前余额、操作后余额、时间戳。这里有个关键心得操作日志的写入必须和业务操作在同一个事务里。如果转账成功了但日志写入失败整个事务回滚确保不会出现“账转了但查不到记录”的诡异状态。这一点在面试时经常会被追问答对了很加分。6. 安全加固密码加盐与SQL注入防御银行系统的安全不用多说第三篇至少要把密码存储和SQL注入这两个基础安全工作做扎实。6.1 为什么不能明文存密码很多初学者图省事把用户表设计成user_name password密码直接字符串存进数据库。这绝对是大忌。一旦数据库泄露所有用户的密码直接暴露而这些用户很可能在其他平台也用了相同密码间接损失不可估量。正确的做法是只存密码的哈希值。哈希算法是单向的无法从结果反推出原文即使数据库泄露攻击者拿到的也只是一堆看不懂的哈希串。6.2 MD5加盐为什么比纯MD5强MD5本身已经被证明不够安全而且纯MD5存在一个致命问题相同密码生成的哈希永远相同。攻击者可以提前准备一张“常见密码到MD5的映射表”彩虹表从数据库里拿到哈希后反查就能还原出明文。解决思路是加盐给每个用户生成一个唯一的随机字符串盐值把密码 盐值拼接后再做哈希。这样一来即使用户的密码相同只要盐值不同最终存进数据库的哈希串也完全不同彩虹表直接失效。Java实现如下我这里用SHA-256替代了MD5强度更高public class PasswordUtil { public static String generateSalt() { byte[] salt new byte[16]; new SecureRandom().nextBytes(salt); // 转成16进制字符串保存 return HexUtil.toHexString(salt); } public static String hashPassword(String password, String salt) { try { MessageDigest md MessageDigest.getInstance(SHA-256); md.update(salt.getBytes(StandardCharsets.UTF_8)); byte[] hashed md.digest(password.getBytes(StandardCharsets.UTF_8)); return HexUtil.toHexString(hashed); } catch (NoSuchAlgorithmException e) { throw new RuntimeException(e); } } public static boolean verify(String password, String salt, String expectedHash) { String actualHash hashPassword(password, salt); return actualHash.equals(expectedHash); } }注意生成盐值要用SecureRandom而不是Random。SecureRandom是密码学安全的伪随机数生成器Random的可预测性太强不适合安全场景。数据库存储上我会在用户表里加password_salt和password_hash两个字段。至于为什么把盐值和哈希分两个字段存而不是拼一个字符串这是为了方便验证时单独取出盐值。你可以把它们拼接存储但解析时容易出问题不推荐。6.3 SQL注入防御为什么必须用PreparedStatement前两篇我们一直用PreparedStatement但没有系统解释过为什么。举个反例登录校验如果写成字符串拼接SQLString sql SELECT * FROM t_user WHERE user_name username AND password password ;当用户在用户名框中输入 OR 11SQL就变成了SELECT * FROM t_user WHERE user_name OR 11 AND password OR 11永远为真整条SQL就能在没有正确密码的情况下查出用户信息。这就是最经典的SQL注入。PreparedStatement之所以安全是因为它使用预编译机制把SQL结构先发给数据库编译之后传入的参数仅作为“数据”绑定不会被当成SQL语句的一部分执行。所以无论用户在输入框里填什么它都只是字符串破坏不了SQL结构。 提示不要觉得“我这个系统又没有真的用来存钱无所谓”。学习阶段养成用PreparedStatement的习惯工作之后才能自然而然地在写代码时把防注入放在第一位。这类低级漏洞如果出现在真实项目中是严重的事故。7. 面试高频确认项目写完你怎么讲清楚这几个“为什么”不少读者做完项目代码都跑通了但一到面试被问“转账为什么需要事务”“万一并发取款怎么办”就卡壳。这一节我把面试官最爱问的高频问题整理成一张速查表顺带附上排查实录里面那些报错信息都是初学者群里最常遇到的真问题。7.1 高频追问与参考回答面试官提问参考回答要点转账实现的事务是哪种隔离级别数据库默认的可重复读MySQL InnoDB但业务场景对脏读、不可重复读的容忍度决定了是否需要调整隔离级别synchronized和ReentrantLock的区别synchronized是JVM层面实现的ReentrantLock是JDK提供的API支持超时、可中断、公平锁等高级功能乐观锁在项目中是怎么实现的在表里加version字段更新时作为条件影响行数为0则重试或失败为什么密码用加盐哈希而不是加密哈希不可逆加密可逆存储密码只需要验证不需要解密JDBC编程中为什么要关闭资源连接、语句、结果集都占用数据库资源不关闭会导致连接耗尽数据库连接是怎么管理的自己写的最简单的DBUtil里用静态代码块注册驱动项目中用连接池思想管理连接这些回答不是让你死记硬背而是理解背后原理之后用自己的话讲出来。面试官最看重的不是答案标准与否而是你能不能现场推导出结论。7.2 运行期常见报错排查实录“java: 警告: 源发行版 17 需要目标发行版 17”这个报错十有八九是IDEA里的项目SDK和Java编译器版本不一致。检查三点Project Structure里的Project SDK、Modules里的Language level、Settings里的Java Compiler的target bytecode version把三者统一到同一个JDK版本即可。“java.lang.OutOfMemoryError: Insufficient memory”这个报错在小项目里很少见一旦出现通常是Swing界面缓存了过多图片资源或者加载了超大文件。解决办法是给JVM加上启动参数-Xmx512m限制最大堆内存根据自己的需求调整同时排查代码里是否有死循环或集合无限增长。还有一种更隐蔽的情况ResultSet没关闭导致游标持有的数据无法释放。这个从JDBC资源管理的角度排查最快。“java.sql.SQLException: No suitable driver found for jdbc:mysql://localhost:3306/bank”MySQL驱动包没引入或者类加载失败。检查是不是忘了把mysql-connector-java的jar包加到项目依赖里或者是新版MySQL驱动类的名字变了。老版本驱动类是com.mysql.jdbc.Driver新版本是com.mysql.cj.jdbc.Driver注意区分。“java.lang.ClassCastException: java.math.BigDecimal cannot be cast to java.lang.Double”这是读取数据库DECIMAL类型字段时用getDouble方式获取导致的问题。解决办法是统一用getBigDecimal接收然后通过bigDecimal.doubleValue()转换为需要的基本类型。本质上还是“银行金额一律用BigDecimal”这个原则没落实到位。“Could not find or load main class”通常是运行配置里主类路径写错了。在IDEA的Run Configuration里重新选择包含main方法的类即可。还有一种情况是编译输出目录被清空了重新Build一下项目就能恢复。7.3 排查问题的方法论我排查运行期报错的方法基本是三步走。第一步看栈信息的最顶部几行异常类型加异常消息基本能定位问题方向。第二步找到项目中第一次报这个错的源代码行数在本地断点调试。第三步把错误信息的关键词复制到搜索引擎里搜注意筛选和项目环境相似的答案。有一个我踩过的坑要特别提醒不要一报错就重启项目什么信息都不看。错误日志是排查的第一手资料很多人遇到报错第一反应是“重启试试”结果重启之后报错还在却浪费了现场信息。正确的做法是先截屏保存日志再尝试修复。8. 写在最后的个人心得这套银行管理系统写到现在前两篇解决的是“从无到有”的问题第三篇解决的是“从有到优”的问题。我个人做项目有个习惯每完成一个阶段就停下来问自己三个问题——如果并发翻十倍系统会崩吗如果数据库宕机数据能恢复吗如果恶意用户乱输入系统扛得住吗第三个系列把这些问号一个一个变成了句号。如果你正在跟着这个系列做项目我建议你拿到代码后不要急着往下抄先亲手把转账逻辑改成不使用事务的版本观察余额异常再实现一遍乐观锁看哪些请求失败了。这种“亲手制造故障再修复”的过程比任何文档都更能建立肌肉记忆。把这套东西吃透你再去面试官面前聊并发、事务、安全不再是背书而是在讲自己真实做过的事。
返回列表