ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Java网上银行转账系统实战:从数据库设计到并发控制

Java网上银行转账系统实战:从数据库设计到并发控制 简介基于Java的网上银行转账系统设计源码包是一份适用于Java Web开发学习者与初级后端工程师的完整项目参考聚焦在线转账中的用户认证、账户操作、事务处理与安全防护等核心环节。源码包共46个文件压缩包约356KB其中19个Java源文件承载认证、转账及异常处理等业务逻辑14个JSP页面负责账户信息展示与交互界面7个XML文件用于配置数据库连接及第三方接口另有3个文本说明、2个属性文件与1个JavaScript脚本辅助环境配置和前端校验。目前已有279人学习下载。借助该项目可系统梳理Java Web分层结构理解Servlet/JSP协作方式、Maven构建配置、事务ACID特性及防止SQL注入、XSS等常见安全实践同时学会将敏感参数独立到配置文件中以降低维护风险。整体代码组织清晰适合以此作为课程设计、毕业设计或企业级转账功能开发的参考蓝本。1. 网上银行转账系统Java 实现的核心链路与选型理由网上银行转账系统是 Java 后端方向里最典型的落地场景之一也是课程设计、面试项目和银行外包模块里反复出现的题目。它表面上是“从 A 账户扣钱往 B 账户加钱”的接口但真正跑起来你会碰到事务边界、并发超扣、幂等去重、对账追溯这些硬问题。市面上能下载到的“基于 Java 的网上银行转账系统设计源码”不少代码质量差距也很大直接拿回来多半跑不通或者跑通了也不敢上生产。下面直接按我平时做项目的节奏来拆数据库怎么设计、转账链路怎么写、并发怎么扛、出了故障怎么查。不管你是做课程设计、准备面试项目还是接单写转账模块照着这套思路走能少踩不少坑。2. 数据库设计与模块拆解转账系统里先想清楚的四个问题2.1 账户模型余额字段为什么不能单独存在很多初学版本的源码里账户表就一个 id 加一个 balance转账就是一个简单 UPDATE。这套做法在单用户、无并发、无对账要求的演示场景勉强能跑一旦进入真实业务场景立刻崩。原因很朴素你只知道账户“现在有多少钱”不知道钱是怎么进、怎么出的余额一旦错掉连追查线索都没有。我一般把模型拆成两层。账户表存当前余额和乐观锁版本号另一张资金流水表记录每一笔资金的来源、去向、金额、状态和时间。转账时先插流水再改余额最后更新流水状态。这样每一笔异常资金变动都能靠 biz_no 追溯到原始请求对账也只需要把流水增减汇总出来和账户余额比对几分钟就能定位差异。有同学问为什么不把余额快照也做进去真实银行系统确实是分层设计账户余额表、交易流水表、对账明细表各管各的。新手阶段不用一步到位但“余额和流水分离”这个原则必须守住否则后面做对账、排错、审计全都要返工。2.2 转账链路扣款、入账、通知的先后顺序网上银行转账的标准链路是发起转账、校验账户状态、扣减付款账户余额、增加收款账户余额、写入转账流水、发送通知。这里最关键的约束是扣款和入账必须在同一个数据库事务里完成。如果先扣成功再加钱失败资金凭空消失反过来则资金凭空多出来两种结果在金融系统里都是事故。单库多账的常见做法是直接用一步事务方案一个 Transactional 方法包住扣款、入账和流水更新代码直观、排错容易。跨行转账或 T1 结算场景才需要引入冻结模型付款方账户先冻结一部分资金收款方确认入账后再正式扣减。冻结模型能缩小资金风险窗口但状态机复杂度明显上升新手阶段不建议一上来就铺开先跑通一步事务方案再演进。我见过不少“严谨”的源码把通知逻辑也塞进转账事务里调短信网关、推消息队列一个网络超时就把整个转账回滚了。通知应该放到事务提交之后再去做用 Spring 的 TransactionalEventListener(phase AFTER_COMMIT)或者直接把通知消息丢进 MQ。转账事务只管资金变动不碰外部 IO这条边界越清晰系统越稳。2.3 幂等与并发转账系统里的两道必答题幂等是转账系统最容易被忽略的设计。用户在前端双击转账按钮或者网络超时后客户端自动重试同一个业务流水号可能被提交两次。后端不做幂等就会真的转出去两笔钱。通用做法是建一张以业务流水号 biz_no 为唯一键的流水表转账前先插入遇到唯一索引冲突说明这个请求已经处理过直接返回旧结果。并发侧最典型的坑是超扣。两个请求同时读到余额 100各自扣 80如果代码是“先查余额、判断够不够、再 UPDATE 扣款”两次判断都通过最终余额变成 -60。解决办法是把余额充足性校验写进 UPDATE 的条件里扣款影响行数为 0 就说明余额不够直接抛业务异常。这一条 SQL 比任何应用层加锁都可靠。乐观锁和悲观锁怎么选我一般看冲突概率。网上银行同账户并发扣款是常态带条件的 UPDATE 本质是乐观锁的一种变形不需要额外 SELECT FOR UPDATE吞吐更好。只有涉及资金冻结、解冻这种多步状态流转时才值得用悲观锁把行锁住确保状态不被并发打断。2.4 技术栈选型为什么组合是 Spring Boot MyBatis MySQL网上能下载到的 Java 转账系统源码绝大多数基于 Spring Boot 加 MyBatis 加 MySQL这不是巧合。Spring Boot 把事务、连接池、Web 容器自动化配置好起步成本最低MyBatis 让 SQL 完全可控转账场景需要手工调整 SQL比如把余额校验写进 UPDATE这种场景用 ORM 自动生成的 SQL 反而碍事MySQL InnoDB 引擎提供行锁和事务是目前最容易上手的关系型数据库。连接池用 HikariCPSpring Boot 默认就是它不需要额外引包。事务管理器用 DataSourceTransactionManager 就够单库转账场景开分布式事务是自找麻烦。Redis 可以做幂等前置过滤但数据库幂等表必须保留。Redis 挂了不能影响转账主流程这个原则不用商量。这里务必要注意一个配置层面的坑Spring Boot 2.x 默认的 JDBC 依赖是 HikariCP但如果你在 pom 里额外引了 commons-dbcp2数据源会被自动换成 DBCP行为完全不同线上遇到过连接池泄漏导致接口全部卡死的情况。2.5 建表与初始化贴一份可直接执行的 SQLCREATE TABLE account ( id BIGINT PRIMARY KEY AUTO_INCREMENT, account_no VARCHAR(32) NOT NULL, user_name VARCHAR(64) NOT NULL, balance DECIMAL(18,2) NOT NULL DEFAULT 0, version INT NOT NULL DEFAULT 0, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_account_no (account_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT账户表; CREATE TABLE transfer_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, biz_no VARCHAR(64) NOT NULL, from_account_no VARCHAR(32) NOT NULL, to_account_no VARCHAR(32) NOT NULL, amount DECIMAL(18,2) NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT 0-处理中 1-成功 2-失败, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_biz_no (biz_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT转账流水表;表结构里有几个参数值得展开说。DECIMAL(18,2) 对应千亿级金额保留两位小数金融场景不要用 DOUBLE 存钱。biz_no 由前端生成、后端只校验格式唯一索引是幂等去重的第一道防线。version 字段配合乐观锁使用扣款 UPDATE 时 version1用来观察并发冲突时的版本变化轨迹。初始化时插入两个测试账户一个余额 10000一个余额 5000后续测试转账和压测直接复用这套数据。3. 转账主流程实现从 Controller 到数据库的完整代码链路3.1 Service 层事务边界与核心转账代码转账 Service 是整个系统的心脏。下面给出一份最小可跑的版本去掉消息队列、缓存这些外围组件把事务、幂等、并发控制放在最显眼的位置。代码风格按 Spring Boot 2.7 的构造器注入来写。Service public class TransferService { private final AccountMapper accountMapper; private final TransferRecordMapper transferRecordMapper; public TransferService(AccountMapper accountMapper, TransferRecordMapper transferRecordMapper) { this.accountMapper accountMapper; this.transferRecordMapper transferRecordMapper; } Transactional(rollbackFor Exception.class) public TransferResult transfer(TransferRequest req) { // 1. 插入处理中流水利用 biz_no 唯一索引做幂等 try { transferRecordMapper.insert(TransferRecord.of(req, 0)); } catch (DuplicateKeyException e) { TransferRecord exist transferRecordMapper.findByBizNo(req.getBizNo()); return TransferResult.repeated(exist.getStatus()); } // 2. 扣减付款账户余额带余额条件防止超扣 int deducted accountMapper.deductBalance( req.getFromAccountNo(), req.getAmount()); if (deducted 0) { throw new InsufficientBalanceException(余额不足); } // 3. 增加收款账户余额 int credited accountMapper.increaseBalance( req.getToAccountNo(), req.getAmount()); if (credited 0) { throw new AccountNotFoundException(收款账户不存在); } // 4. 更新流水状态为成功 transferRecordMapper.updateStatus(req.getBizNo(), 1); return TransferResult.success(); } }逻辑说明整个转账方法包在 Transactional 里任何未捕获异常都会触发数据库回滚。第 1 步先插流水做幂等插入成功才继续遇到重复键说明该业务请求已经处理过直接查旧结果返回完全不碰余额。第 2 步用带余额条件的 UPDATE 扣款影响行数为 0 说明余额不够抛出异常后流水和之前的任何修改全部回滚不会留下半笔脏数据。第 3 步入账时如果收款账户不存在同样走回滚不会出现“钱扣了但入不了账”的状态。第 4 步把流水置为成功整个事务提交资金变动和流水记录保持同一生命周期。这里有一个容易被忽略的参数Transactional 的 rollbackFor 必须写成 Exception.class。Spring 默认只对 RuntimeException 回滚如果转账里抛的是受检异常比如自定义业务异常继承了 Exception不加 rollbackFor 会造成扣款成功但流水仍是处理中的状态。3.2 Mapper 与 SQL带条件的 UPDATE 是并发安全底线!-- AccountMapper.xml -- update iddeductBalance UPDATE account SET balance balance - #{amount}, version version 1 WHERE account_no #{accountNo} AND balance gt; #{amount} /update update idincreaseBalance UPDATE account SET balance balance #{amount}, version version 1 WHERE account_no #{accountNo} /update参数说明deductBalance 把余额充足性判断直接写进了 SQL 的 WHERE 子句。InnoDB 行锁保证同一时刻只有一个事务能更新这行数据第二个事务必须等第一个提交后才能执行此时余额已经减少条件不满足影响行数为 0。这样“查余额、判断、扣款”三个步骤被合并成一个原子操作不需要额外的 SELECT FOR UPDATE也没有应用层锁的性能开销。increaseBalance 不加余额条件入账不会产生负数但要确认收款账户真实存在返回 0 就说明账号不对。两个 UPDATE 都带上 version version 1这个字段不参与业务判断只记录版本变化排查并发冲突时能看出是哪一方在什么时候改过这行。这里再补一个 MyBatis 的细节XML 里 balance #{amount} 的写法需要转义写成 直接写 会报 XML 解析错误。报错信息是 Content is not allowed in prolog新手看到这个提示很难想到是 XML 转义问题。3.3 幂等控制唯一索引与状态机的配合幂等表的设计细节决定成败。插入的处理中流水必须放在事务最前面状态初值必须是“处理中”。如果插入成功但后续扣款失败事务回滚会把这条流水一并回滚下一次重试还能完整走一遍流程。如果事务最终提交成功重试请求插入时撞上 uk_biz_no 唯一索引抛 DuplicateKeyException代码捕获后返回已处理状态不会重复扣款。我见过团队把幂等键放在 Redis 里 SETNX存在就直接返回。这个方案可以做前置过滤但数据库唯一索引必须保留作为兜底因为 Redis 的 key 有过期时间过期之后重试请求会穿透到扣款逻辑。幂等表还有一个隐藏红利转账明细天然就是流水记录对账时不需要再查别的表直接按 biz_no 分组聚合就行。// 幂等失败时的返回对象 public class TransferResult { private final String bizNo; private final boolean repeated; private final int status; // 0-处理中 1-成功 2-失败 // 省略 getter / setter / 构造方法 }业务场景里有一种情况需要单独处理第一次请求提交后事务还在执行中第二次请求撞唯一索引会等待锁。MySQL 默认行锁等待时间是 50 秒如果转账逻辑本身卡了重试请求会堵在插入流水那一步。这里建议把 Transactional 的 timeout 设置成 5 秒超时快速失败避免请求堆积。3.4 Controller 层参数校验与异常兜底RestController RequestMapping(/api/transfer) public class TransferController { private final TransferService transferService; public TransferController(TransferService transferService) { this.transferService transferService; } PostMapping public ResultVoid transfer(RequestBody Valid TransferRequest req) { TransferResult result transferService.transfer(req); return result.isRepeated() ? Result.ok(重复请求本次不重复处理) : Result.ok(转账成功); } }TransferRequest 里的 bizNo 必须标 NotBlankamount 标 DecimalMin(0.01)fromAccountNo 和 toAccountNo 标 Size(min 5, max 32)。前端进入转账页面时用 UUID 生成 bizNo后续重试复用同一个值不能每次请求重新生成。金额字段建议用 String 接收再转 BigDecimal避免前端浮点数精度问题后端实体类里也用 BigDecimal别用 double。Controller 不写业务逻辑只做校验、调 Service、统一返回。响应对象里带业务流水号、转账时间、处理状态前端查单和用户对账都靠这几个字段。异常统一用 RestControllerAdvice 处理转成 Result 对象返回不要把数据库异常堆栈直接抛给前端。面试时遇到“转账系统怎么设计”这类问题能把这套 Controller 薄、Service 重、事务边界清晰的骨架讲出来已经比大多数背八股文的答案扎实。4. 转账系统避坑5 个我踩过的运行期问题与排查路径4.1 转账成功但余额没变事务是否真的提交了现象接口返回成功数据库账户余额纹丝不动前端显示转账成功但金额没变。原因最常见的三种情况。第一种是 Transactional 注解没有生效同类内部调用、方法不是 public、或者 Spring 容器没扫描到这个类代理没生成注解静默失效。第二种是异常在方法内部被 try-catch 吞掉事务感知不到错误不会回滚但余额更新语句可能根本没执行。第三种是事务方法里调了远程接口远程超时被捕获忽略后续本地操作照常提交转账流程实际已经断裂。解决先用日志或 debug 确认实际执行的 SQL 和事务日志再用 IDE 的 Spring 插件看这个 Bean 有没有被代理最后在事务方法里故意抛一个测试异常验证回滚是否生效。生产环境要查这类问题最好打开 MySQL 的 general log把转账接口的完整 SQL 序列打出来如果序列里看不到 BEGIN 和 COMMIT问题基本锁定在 Spring 代理层。4.2 高并发下余额超扣压测工具一跑就现形现象JMeter 开 50 个线程同时向同一个账户发起转账压测结束发现余额出现负数比初始余额还小。原因代码是“先 SELECT 余额、Java 里判断够不够、再 UPDATE 扣款”。判断和扣款之间没有做任何同步多个请求读取到同一个旧余额都认为余额够于是都执行了扣款。并发越高这个时间窗口越大压测工具一跑立刻现形。解决改成一条带条件的 UPDATE把 balance #{amount} 写进 SQL 的 WHERE 子句。InnoDB 行锁保证同一时刻只有一个事务能更新这一行第二个事务必须等第一个提交此时余额已经扣减条件不满足影响行数为 0代码直接抛余额不足异常。压测时还要注意 HikariCP 连接池默认 maximumPoolSize 是 1050 个线程并发时大部分在等连接容易把瓶颈误判在业务代码上。先把连接池参数调到 50 再跑压测拿到数据才有参考意义。4.3 重复提交重复转账幂等键生成位置错了现象前端双击转账按钮数据库里出现两笔相同金额、相同账户的转账流水用户被扣了两次钱。原因幂等键 bizNo 是后端自己在每次请求里生成的导致每次提交的 bizNo 都不相同唯一索引形同虚设。或者幂等判断放在扣款之后才做第一次请求扣款成功但响应超时前端重试带了新幂等键又走了一遍完整扣款。解决幂等键必须由调用方生成前端进入转账页面时生成 UUID后续重试复用同一个值后端只校验 bizNo 不为空和长度合法绝不自己生成。幂等表的插入要放在事务最前面任何后续失败都会回滚重试还能走完整流程事务提交成功后重试插入撞唯一索引直接返回已处理结果不再扣款。这条规则要在接口文档里写清楚前端后端各管一头谁都不能偷懒。4.4 两账户互相转账死锁锁顺序不一致引发循环等待现象A 转 B 和 B 转 A 两个请求同时发起数据库报 Deadlock found when trying to get lock其中一个事务被回滚用户看到转账失败。原因两个事务分别先锁了 A 账户再锁 B 账户、先锁了 B 账户再锁 A 账户形成循环等待。InnoDB 的死锁检测会选中一个代价较小的事务回滚但应用层如果不做处理用户看到的就是莫名其妙的失败。这个场景在并发互转压测时非常容易复现规律是清晰的不是玄学。解决按账户号排序后统一加锁顺序。无论转账方向是从 A 到 B 还是从 B 到 A先比较两个账户号按排序结果先更新较小账户号对应的行再更新较大的。这样所有事务的加锁顺序一致循环等待的前提就不存在了。排序逻辑放在 service 层扣款前先把 from 和 to 两个 accountNo 排序再决定两条 UPDATE 语句的执行顺序别在两个 Mapper 调用里写死先后。4.5 对账不平流水与余额没保持同一事务现象月底跑对账脚本流水汇总和账户余额对不上差几笔或者差几分钱。原因流水插入和余额更新被拆成了两个独立事务。比如先 UPDATE 余额并提交再 INSERT 流水时应用宕机数据库里余额变了但流水没写进去。这种情况在测试阶段几乎不出现因为人工操作速度慢、两个动作间隔极短但对账脚本一跑就全暴露了。解决把流水插入、余额扣减、入账和状态更新全部放进同一个 Transactional 方法顺序固定为先插流水再动余额。事务提交后流水和余额必然保持一致事务回滚后两边都不存在不会出现只有一半的情况。开发阶段就要写好对账 SQL按天分组汇总流水增减和账户余额日终值比对第二天对不上立刻查链路别拖到月底再查。金融类功能的底线就是每一分钱都有流水可溯。5. 最小验证与上线前的几个实用习惯5.1 用 JUnit 把三个关键场景钉死写一个并发扣款测试用 CountDownLatch 让 20 个线程同时扣同一个账户断言最终余额不为负且扣款次数正确。再写一个重复请求测试同一个 bizNo 调两次转账接口断言流水表只有一条记录。测试通过只能说明业务逻辑对还需要打开 MySQL 的 general log确认实际执行的 UPDATE 语句确实带了 balance 条件防止 MyBatis 动态 SQL 在某些分支下把条件漏掉。5.2 上线前必查的四个配置第一个是 HikariCP 的 maximumPoolSize根据压测结果调整别用默认值扛并发。第二个是事务超时用 Transactional(timeout 5) 给转账方法加兜底防止锁等待拖死接口。第三个是 MySQL 隔离级别保持默认的 REPEATABLE READ不要手滑改成 READ UNCOMMITTED 去“提升性能”转账场景脏读一次就够你受的。第四个是 binlog 格式设为 ROW后续接数据同步和对账平台都方便。5.3 压测顺序先死锁再吞吐最后幂等压测别上来就看 TPS。先开 20 个线程跑 5 分钟互转场景查错误日志里有没有 Deadlock确认没有死锁后再测单账户并发扣款看余额有没有超扣最后用同一 bizNo 连续压测确认不产生重复流水。这个顺序我每次做转账系统都会固定执行一遍别问为什么问就是线上死锁过。写到这儿想起自己第一次做转账模块时余额扣成负数被同事笑了很久。后来把“先查再扣”改成“条件扣款”把幂等表放回事务最前面这套架子就再没出过大问题。希望帮到你。本文还有配套的精品资源点击获取
返回列表