简介:基于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 + JDBC | Spring Boot + MyBatis |
|---|---|---|
| 事务控制 | 手动管理 Connection,commit/rollback 全靠自觉 | @Transactional 声明式事务,默认回滚规则清晰 |
| 数据库连接 | 自己写连接池,容易漏关连接 | 内置 HikariCP,参数开箱即用 |
| 前端页面 | JSP 内嵌 Java 代码,改样式头疼 | 静态页面 + REST 接口,前后端分离 |
| 面试含金量 | 偏教学玩具,问两句就到底 | 贴近真实业务开发,能展开讲 |
| 项目体积 | 依赖少,老机器能跑 | 依赖多但 Maven 一键拉齐 |
如果你的机器内存只有 4G,Spring 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 方法上,不能放到 controller;mapper 层只负责 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 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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 请求读到余额 100,B 请求也读到余额 100,A 扣了 50 写回 50,B 扣了 50 写回 50,实际应该是 0,账户里却多了 50。这就是典型的“丢失更新”。
解决办法有两种。悲观锁是查询时直接锁行,SELECT ... FOR UPDATE,别的请求只能等它提交完再读。乐观锁是更新时带上版本号,UPDATE ... SET balance = balance - ?, version = version + 1 WHERE version = ?,影响行数为 0 就说明被别人改过了,需要重试或报错。
转账首选悲观锁,因为转账的频率和一致性要求决定了冲突率不会太低,悲观锁实现简单、不用写重试逻辑。对应 SQL 这样写:
<select id="selectByAccountNoForUpdate" resultType="Account"> SELECT id, account_no, balance, status, version FROM t_account WHERE account_no = #{accountNo} FOR UPDATE </select>FOR 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 或外部类调用进来的,不能让类内部自己调自己。检查方法是不是public,Spring 默认只对 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 后端的人。
本文还有配套的精品资源,点击获取