ARTICLE DETAIL

资讯详情

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

Java网上银行转账系统:事务、锁与幂等设计实战

Java网上银行转账系统:事务、锁与幂等设计实战 简介基于Java与JavaScript整合开发的网上银行转账系统源码面向Java Web学习者、课程设计与毕业设计人群可用于理解在线转账、账户认证、交易记录及异常处理等核心业务实现。压缩包共46个文件体积356KB包含19个Java源文件、14个JSP页面文件、7个XML配置文件、2个属性文件、1个JavaScript脚本及3个文本文件。其中Java类负责业务逻辑与安全校验JSP完成用户界面和请求转发XML集中管理数据库及第三方接口参数properties保存连接配置JavaScript则用于表单验证与页面动态交互。系统附带Maven构建配置与使用说明整体目录结构清晰便于快速导入开发环境进行二次调试。已有279人学习下载适合需要参考银行转账业务流程、事务一致性处理以及安全防护设计的开发者可作为毕业设计或项目实训的完整技术方案。1. 先用三个问题看清“基于Java的网上银行转账系统”到底要干什么银行转账系统在 Java 课程设计和面试题里出现得极其频繁几乎每个 Java 学习者都下载过一份“基于Java的网上银行转账系统设计源码”但大多数人只把它当 demo 跑通就完事页面能跳转、账能转就认为自己会了。一问到“并发转同一账户会不会扣成负数”“重复提交会不会被扣两次”“事务注解为什么没生效”当场卡壳。这篇笔记不打算复述某个现成源码的下载地址或者目录结构而是把网上银行转账系统里真正决定“能不能用”的部分——事务边界、锁、幂等、金额精度——从选型到落地讲一遍。照着这套逻辑你再去看任何一份银行转账的 Java 源码都能一眼看出它埋了哪些雷哪些地方是照着抄都会翻车的。2. 给项目塑形转账系统的边界、选型和目录结构2.1 先划边界一个能交差的转账系统要覆盖哪些功能很多人拿到“网上银行转账系统”标题就开写第一版往往做成了“账户表加一个转账按钮”。这不算错但离“能答辩、能写进简历”还有距离。一个功能边界完整、又能在一学期内做完的转账系统通常由三块组成。第一块是账户管理。用户能开户、销户、查询余额、查看自己名下的账户列表。这块解决的其实是“转出方和转入方是谁”的问题也是后面所有转账操作的数据基础。第二块是转账交易这是整个系统的核心链路包括转账表单、转出账户校验、转入账户校验、金额合法性校验、执行扣款、执行入账、生成交易流水以及转账失败时的回滚。第三块是交易流水查询用户能按时间段、按方向转出/转入查看历史记录这笔记录必须在事务里和转账动作一起落库。不建议再往里加更多功能。常见的毕业设计会把定期存款、贷款、信用卡还款都塞进去结果每个模块都浅尝辄止核心转账链路反而没写扎实。面试官问一句“你转账时两个账户怎么保证数据一致性”就答不上来。把三块做深比做十块做浅有价值得多。2.2 技术选型为什么 Spring Boot MyBatis 是这个项目的标准答案网上银行转账系统的技术栈选择网上有两种主流路线一种是 JSP Servlet JDBC 的老式三层结构另一种是 Spring Boot MyBatis 的现代结构。如果你只是应付学校验收前者够用如果想把这份源码当求职项目讲我建议老老实实用后者。Spring Boot 把事务管理、依赖注入、内嵌服务器都封装好了你不需要自己写 Connection 管理、不用手工 try-catch 回滚这些恰恰是转账系统最容错的地方。MyBatis 则让你能用 XML 或注解精确控制 SQL转账场景里“查余额带行锁”“更新账户余额”这类 SQL 必须能一眼看明白有没有锁、有没有 where 条件漏掉MyBatis 的 XML 比 Hibernate 的自动 SQL 更直观。对比项JSP Servlet JDBCSpring Boot MyBatis事务控制手动管理 Connectioncommit/rollback 全靠自觉Transactional 声明式事务默认回滚规则清晰数据库连接自己写连接池容易漏关连接内置 HikariCP参数开箱即用前端页面JSP 内嵌 Java 代码改样式头疼静态页面 REST 接口前后端分离面试含金量偏教学玩具问两句就到底贴近真实业务开发能展开讲项目体积依赖少老机器能跑依赖多但 Maven 一键拉齐如果你的机器内存只有 4GSpring Boot 也能跑把 JVM 参数调成-Xms256m -Xmx512m就够开发用了。这个选型几乎不会被人质疑因为主流 Java 后端岗位用的就是这套。2.3 项目结构controller-service-mapper 三层怎么分工才不出事拿到了选型下一步是建包结构。很多网上银行转账系统源码的包名混乱业务代码和工具代码混在一起事务边界无从谈起。我习惯按下面的分包方式com.bank.transfer ├── controller │ ├── AccountController.java │ └── TransferController.java ├── service │ ├── AccountService.java │ └── TransferService.java ├── mapper │ ├── AccountMapper.java │ ├── TransferMapper.java │ └── TransferFlowMapper.java ├── entity │ ├── Account.java │ └── TransferFlow.java ├── common │ ├── Result.java │ └── BizException.java └── config └── MyBatisConfig.javacontroller 层只做参数接收和结果包装不写业务逻辑service 层是事务的边界转账这类多步操作的事务注解必须放在 service 方法上不能放到 controllermapper 层只负责 SQL 执行不处理业务判断。这个分层最大的意义在于出了问题时你知道去哪一层找。转账金额对不上先查 service 的扣款更新逻辑查询卡住先看 mapper XML 里的 SQL 是不是少了条件。如果所有逻辑都堆在 controller 里事务一失效查错会变成猜谜。3. 把“钱”这个字段建模表结构设计与账务处理3.1 表怎么拆账户表、交易流水表、交易明细表各管什么网上银行转账系统的数据库设计直接决定后面事务能不能写清楚。很多课程设计源码只有一张账户表和一张流水表这是不够的。账户表记录的是“余额状态”流水表记录的是“一笔转账发生了什么”两者是不同维度的数据必须拆开。我常用的拆法是三张表加一张用户表。转账时先生成一条交易明细记录状态为“处理中”然后执行扣款和入账最后把明细状态改成“成功”。这套写法的好处是如果扣款成功但入账失败你能从明细表里定位到是第几步出了问题而不是对着两张表瞎猜。账户表里余额是当前值流水表里存的是每一笔变动。转出时先扣余额再插流水转入时先插流水再加余额这两步必须在一个事务里完成否则系统崩溃后账就对不上了。流水表本身不做余额计算只做记录需要余额时直接查账户表。3.2 表结构 DDL字段类型怎么设才能不埋雷下面是我会落到项目里的核心表结构。字段类型这里有一条重要经验金额一律用DECIMAL不用FLOAT或DOUBLE。浮点数在 Java 和 MySQL 里都有精度问题0.1 加 0.2 算出 0.30000000000000004银行系统不允许这种事发生。CREATE TABLE t_account ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 主键, account_no VARCHAR(32) NOT NULL UNIQUE COMMENT 银行卡号业务唯一键, user_id BIGINT NOT NULL COMMENT 所属用户, balance DECIMAL(18,2) NOT NULL DEFAULT 0.00 COMMENT 账户余额单位元, status TINYINT NOT NULL DEFAULT 1 COMMENT 1正常 2冻结 3销户, version INT NOT NULL DEFAULT 0 COMMENT 乐观锁版本号, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT银行账户表; CREATE TABLE t_transfer_flow ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 主键, flow_no VARCHAR(64) NOT NULL UNIQUE COMMENT 交易流水号, from_account_no VARCHAR(32) NOT NULL COMMENT 转出账号, to_account_no VARCHAR(32) NOT NULL COMMENT 转入账号, amount DECIMAL(18,2) NOT NULL COMMENT 转账金额, fee DECIMAL(18,2) NOT NULL DEFAULT 0.00 COMMENT 手续费本项目先置0, status TINYINT NOT NULL DEFAULT 0 COMMENT 0处理中 1成功 2失败, fail_reason VARCHAR(255) DEFAULT NULL COMMENT 失败原因, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_from_account (from_account_no), KEY idx_create_time (create_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT转账流水表;注意几个关键点。flow_no加了唯一索引这是幂等控制的第一道闸后面讲幂等时会展开。from_account_no建了普通索引因为按转出账号查历史流水是最常见的查询。version字段现在看着没用等做并发控制时它就是乐观锁的凭据先建好后面不返工。余额字段默认值给0.00不给 NULL。所有金额相关字段都统一DECIMAL(18,2)Java 侧就用BigDecimal对应数据库和代码两侧都不碰double。这个约定一旦定下来全项目都遵守就不会出现金额类型混用导致的诡异 bug。3.3 Java 实体与数据库字段的映射类型对应关系一次定死实体类里最容易翻车的是把数据库的 DECIMAL 映射成 Double尤其用 MyBatis 时字段映射不会主动报错数据量小的时候看着正常一旦涉及除法或乘法精度问题就炸了。我的对应关系是这样定的。数据库的DECIMAL(18,2)对应 Java 的java.math.BigDecimal。数据库的TINYINT对应 Java 的Integer状态值用常量或枚举管理比如流水表 status 字段0 处理中、1 成功、2 失败。数据库的DATETIME对应 Java 的LocalDateTime不用java.util.Date因为我需要拿到时间做按天的流水统计。在 MyBatis 的 XML 里有一个容易忽略的点resultType如果写的是实体类全路径MyBatis 会自动把下划线字段名转成驼峰属性名。但前提是配置文件里开起了驼峰映射。mybatis: configuration: map-underscore-to-camel-case: true如果这个开关没开from_account_no就映射不到fromAccountNo属性上查询结果全是 null。这是我见过最频繁的配置踩坑点没有之一。建议建完实体类后先写一个最简单的查询接口测试映射确认结果不为空再往下写业务。3.4 唯一流水号用 UUID 还是雪花 ID别用时间戳拼随机数转账流水号就是flow_no字段它的要求是全局唯一、在十万级数据量下不重复。网上很多源码用“时间戳 随机数”生成这种做法在单机低并发下勉强能跑一旦并发量上来同一毫秒内两个请求拿到相同时间戳加相同随机数的概率并不低而流水号有唯一索引直接插入报错。我的做法是如果只是课程设计用UUID.randomUUID().toString().replace(-, )就够了够唯一但 32 位字符串当索引会牺牲一点性能。如果想把项目做扎实一点用雪花算法Java 里可以自己实现一个简单的版本核心思路是时间戳段 机器编号段 自增序列段保证同一毫秒内不重复。雪花算法不是标准库里现成的东西所以要么引入工具类要么自己写一个几十行的生成器。对这个项目来说自己写一个更合适面试被问到能讲清楚原理。核心代码大约是这样public class SnowflakeIdGenerator { private final long machineId; private long lastTimestamp -1L; private long sequence 0L; public SnowflakeIdGenerator(long machineId) { this.machineId machineId; } public synchronized String nextId() { long timestamp System.currentTimeMillis(); if (timestamp lastTimestamp) { // 时钟回拨直接等一毫秒再试课程设计够用 timestamp lastTimestamp; } if (timestamp lastTimestamp) { sequence (sequence 1) 4095; if (sequence 0) { while (timestamp lastTimestamp) { timestamp System.currentTimeMillis(); } } } else { sequence 0L; } lastTimestamp timestamp; return timestamp - machineId - sequence; } }这段代码故意简化了。4095是 12 位序列号能表示的最大值也就是同一毫秒最多生成 4096 个 ID课程设计完全够用。machineId在单机部署时填 1 即可多机部署时每台机器填不同值。返回的字符串用-拼接可读性好也能从流水号反推出生成时间这在排查问题时非常有用。4. 核心转账流程事务边界、行锁和幂等一次到位4.1 一个转账方法的事务设计读、判、扣、增、记五步走网上银行转账系统的价值说到底就是“转账不出错”四个字。实现这个目标靠的不是页面写得多好看而是 service 层一个方法把五步操作打包进一个事务读转出账户、校验余额、扣减转出账户、增加转入账户、插入流水记录。任何一步异常整体回滚。下面是一个可复现的转账方法注解和锁都放在正确的位置上Service public class TransferService { Autowired private AccountMapper accountMapper; Autowired private TransferFlowMapper flowMapper; Transactional(rollbackFor Exception.class) public void transfer(TransferRequest req) { // 1. 幂等校验同一流水号是否已处理过 TransferFlow exists flowMapper.findByFlowNo(req.getFlowNo()); if (exists ! null) { throw new BizException(重复转账请求); } // 2. 查询转出账户带上行锁 Account from accountMapper.selectByAccountNoForUpdate(req.getFromAccountNo()); if (from null || from.getStatus() ! 1) { throw new BizException(转出账户不可用); } // 3. 余额校验必须是 BigDecimal 比大小 if (from.getBalance().compareTo(req.getAmount()) 0) { throw new BizException(余额不足); } // 4. 扣转出、加转入 accountMapper.decreaseBalance(from.getAccountNo(), req.getAmount()); accountMapper.increaseBalance(req.getToAccountNo(), req.getAmount()); // 5. 插入流水状态置为成功 TransferFlow flow new TransferFlow(); flow.setFlowNo(req.getFlowNo()); flow.setFromAccountNo(req.getFromAccountNo()); flow.setToAccountNo(req.getToAccountNo()); flow.setAmount(req.getAmount()); flow.setStatus(1); flowMapper.insert(flow); } }这个方法的每一个步骤都有讲究。Transactional(rollbackFor Exception.class)是说任何异常都回滚注意默认情况下运行时异常才回滚受检异常不会显式指定Exception最稳妥。selectByAccountNoForUpdate是行锁的入口先锁住转出账户别人就动不了这条记录。compareTo而不是subtract后判断正负是为了不用double比较精度。扣款和入账我拆成了两个 update 语句没有先把余额查出来算完再写回去。这样做有个好处减少锁持有时间两个 update 都是原子操作数据库自己保证同一行更新不丢失。4.2 悲观锁与乐观锁的选择行锁为什么比更新后校验更稳转账场景必须面对并发。两个请求同时给同一个账户转钱如果不加锁A 请求读到余额 100B 请求也读到余额 100A 扣了 50 写回 50B 扣了 50 写回 50实际应该是 0账户里却多了 50。这就是典型的“丢失更新”。解决办法有两种。悲观锁是查询时直接锁行SELECT ... FOR UPDATE别的请求只能等它提交完再读。乐观锁是更新时带上版本号UPDATE ... SET balance balance - ?, version version 1 WHERE version ?影响行数为 0 就说明被别人改过了需要重试或报错。转账首选悲观锁因为转账的频率和一致性要求决定了冲突率不会太低悲观锁实现简单、不用写重试逻辑。对应 SQL 这样写select idselectByAccountNoForUpdate resultTypeAccount SELECT id, account_no, balance, status, version FROM t_account WHERE account_no #{accountNo} FOR UPDATE /selectFOR UPDATE放在事务里才生效。如果你的 service 方法忘了加Transactional这个锁会在查询结束就释放等于没加这是最容易踩的空子。另外锁一定要加在转出账户上如果只加转入账户两个并发请求同时操作同一转出账户时依然会出问题。4.3 幂等设计重复提交一次能把账打穿银行转账最大的隐患不是并发写错余额而是同一个请求被提交了两次。用户在页面点了转账按钮没反应又点了一次或者前端重试机制把同一个请求发了多遍。如果不做幂等账会被重复扣。幂等的核心是“同一业务号只处理一次”。前端生成一个全局唯一的requestId或者用前面雪花算法生成的流水号在请求到达 service 时先查流水表。我写的 transfer 方法第一步就是findByFlowNo查到了就直接抛业务异常拒绝没查到才往下走。更严谨的做法是在流水表的flow_no字段上建唯一索引这是数据库层面的兜底。即使 Java 代码里的判断因为并发同时通过了两个请求都走到 insert 时唯一索引也会让其中一个插入失败事务回滚保证只有一笔成功。这是我反复强调flow_no必须有UNIQUE约束的原因。提示如果用了 Redis可以把“处理中的流水号”放进 Redis 做前置判断但项目数据量不大时数据库唯一索引已经足够不必引入额外组件。5. 让答辩不翻车转账系统的 5 个常见坑与排查方法5.1 坑一转账后刷新页面余额没变像是没转成功现象调用转账接口返回成功但用户查余额还是原来那个数过一会儿又对了或者一直不对。原因事务没有提交。最常见的是Transactional加在了 controller 方法上而 controller 的调用链不经过 Spring 的代理对象事务注解直接失效。还有一种情况是 service 方法内自己调另一个方法比如在同一个类里this.transfer()调用另一个事务方法事务同样不会生效。解决把事务注解放在 service 实现类的transfer方法上确保这个方法是从 controller 或外部类调用进来的不能让类内部自己调自己。检查方法是不是publicSpring 默认只对 public 方法做事务代理。如果项目中用了 AspectJ 或者代理模式需要额外配置课程设计里尽量用最简单的 Spring Boot 默认配置。5.2 坑二并发扣款后余额变负数钱被多扣了现象用两个线程同时对同一个账户转出 100账户余额只有 150结果第一次转成功第二次也显示成功余额变成负 50。原因查询余额时没有加锁。两个请求都读到 150都判断余额足够然后都执行扣减。由于扣减用的是balance - 100第一次写完变 50第二次写完变负 50。判断和扣减不是原子操作。解决把查询语句改成SELECT ... FOR UPDATE让第二个请求在第一步查询时就阻塞等第一个请求事务提交后它读到的余额就是 50余额校验直接不通过。如果不想用悲观锁用乐观锁的version字段更新但需要处理重试逻辑实现成本更高。转账场景我坚持用悲观锁。5.3 坑三金额算错账户里出现一长串小数现象转账金额是 100.5转入账户余额却多了 100.50000000000001。或者两个 BigDecimal 相加结果和预期不一致。原因Java 代码或者数据库字段用了double或float。double是浮点数本身有精度误差在银行记账这种对精确有硬要求的场景里不允许使用。数据库字段如果定义成FLOAT也会遇到同样的问题。解决Java 侧所有金额字段声明为BigDecimal数据库侧所有金额字段声明为DECIMAL(18,2)。BigDecimal 构造时优先用new BigDecimal(100.5)不要用new BigDecimal(100.5)后者会把 double 的二进制近似值带进来。金额计算要用add、subtract、multiply和compareTo禁止用 - * /。5.4 坑四转账成功但流水表里没有记录对账全乱现象用户能查到两边余额都变了但t_transfer_flow里查不到这条转账记录后端日志也没有异常。原因insert 流水表的 SQL 抛了异常但事务没有回滚。如果TransferFlowMapper.insert执行时报了主键冲突或者flowNo字段长度超了异常被某个 try-catch 吞掉事务就会推进到提交阶段。还有一种情况是 insert 操作被放到了事务之外比如放在 controller 层调用。解决全链路的所有数据库操作都放在同一个 service 事务方法里。检查UNIQUE约束是否和业务逻辑冲突比如用 UUID 时长度是否超过字段长度。给TransferFlowMapper.insert加一个try-catch捕获到异常后不要吞掉直接抛出触发回滚同时记录日志。5.5 坑五两台机器同时跑流水号重复了现象系统部署到两台服务器上后转账偶尔报主键或者唯一索引冲突报错信息指向flow_no字段。原因流水号生成逻辑用的是“时间戳 随机数”或者“UUID 前几位”单机不重复多机就重复了。同一毫秒内两台机器各自生成的时间戳一样随机数碰巧也相同就撞了。解决多机部署时雪花算法的machineId每台机器配不同的值保证每个节点生成的 ID 不冲突。如果嫌麻烦直接用完整 UUID 去掉横杠冲突概率在业务量级下可以忽略。重点是不要在面试时解释不清为什么不用时间戳拼接把雪花算法的时序逻辑讲清楚这是加分项。6. 进阶用并发压测验证你的转账系统是不是真的能用跑通接口只是第一步真正让这份网上银行转账系统源码从“能演示”变成“能站住”要做的是一次简单的并发测试。JUnit 里写一个多线程测试类模拟 10 个线程同时对同一个账户发起转账最后断言账户余额变化量等于所有转出金额之和。如果测试通过说明锁和事务是对的如果不通过回到第 5 章排查。我常用的验证方式是给同一个转出账户创建 100 个线程每个线程向不同转入账户转 1 元初始余额 100 元。测试结束后断言转出账户余额为 0。这个用例能同时验证三件事行锁是否生效、事务是否完整回滚、余额计算是否有精度问题。三道关卡一次通过答辩时你就有底气说“并发场景下我做过验证”。另一个值得做的验证是幂等。同一个流水号连续发两次转账请求第二次必须被拒绝且账户余额不变。这个用例能检验你的幂等判断是否真的拦住了重复请求也会暴露唯一索引有没有建对。我对这个项目的建议是不要把自己定位成“把别人的源码跑起来的人”而是“把转账事务边界讲清楚、把并发问题解决掉的人”。后者在面试里的价值远大于前者。一次失败的并发测试比十次成功的页面点击更能让你记住锁到底加在哪。希望这种验证习惯能帮到你也帮到每一个正在啃 Java 后端的人。本文还有配套的精品资源点击获取
返回列表