简介:这是一份用于教学演示的模拟银行账户转账系统源码,面向正在学习图形界面编程和多线程开发的初学者。系统设有两个初始余额为一千元的账户,随机向对方转账,转账金额不能超过当前余额,余额为零则停止交易;界面提供交易开始和清屏按钮,点击开始后切换为结束交易,结束时将全部交易记录写入文件。压缩包内共两个文件,均为Java源码,大小仅2KB,主窗口类负责界面与按钮响应,线程类负责随机金额生成、余额校验及记录保存,适合逐行阅读和快速调试。目前已有3984人浏览学习,是理解线程同步、随机数生成和文件操作的典型项目。下载后导入开发环境即可运行,便于对照代码掌握多线程转账中的资源竞争与界面刷新,对课程设计也有参考价值。
1. 项目概述与核心痛点拆解
转账系统几乎是每个做后端开发的程序员都会遇到的经典练手项目,也是面试中被问得最多的场景之一。但很多人写出来的转账功能只停留在“能从A账户扣钱、往B账户加钱”的玩具级别,一旦涉及并发、异常恢复、重复请求这些真实场景,立刻原形毕露。
我这次做的模拟银行账户转账系统,目标不只是跑通流程,而是要把一个转账动作背后涉及的技术点全部落地:账户模型设计、事务边界控制、并发下的余额一致性、转账幂等性保障、以及操作日志审计。这个项目非常适合刚学完Spring Boot + MySQL、想找一个能写进简历的完整项目的同学,也适合已经在工作但想系统梳理支付/转账核心逻辑的后端工程师。
整个系统的核心需求一句话就能说清:用户A向用户B转账指定金额,系统保证任何情况下都不会出现钱凭空多出来、少掉或者重复扣款的情况。听起来简单,真正实现起来需要处理的细节远超预期。
2. 整体设计思路与方案选型
2.1 为什么选择单体应用而非微服务
很多人一上来就想把转账系统拆成账户服务、交易服务、通知服务三个微服务,觉得这样才“高级”。但对于模拟项目来说,这是典型的过度设计。转账操作本质上是强一致性的事务操作,微服务拆分反而会引入分布式事务这个巨大的复杂度,对新手极不友好。
我选用的方案是Spring Boot单体应用 + MySQL存储 + Redis做分布式锁和幂等控制。这个组合的合理性在于:单体架构下,一次转账事务可以直接在同一个数据库事务里完成,不需要考虑网络调用失败、消息丢失等分布式难题。系统核心逻辑清晰,后续想扩展成微服务,也能在现有代码基础上逐步拆分,而不是一上来就被复杂的技术架构淹没。
2.2 技术栈与数据库选型逻辑
数据库用MySQL InnoDB引擎,这是有明确理由的:InnoDB支持行级锁和事务,这两点是转账系统最基础的保障。账户表用InnoDB,转账操作在事务里先锁住相关行再更新余额,才能避免并发操作导致的数据错乱。
Redis在这里承担两个职责:一个是实现分布式锁,防止多实例部署时同一账户被并发操作;另一个是实现转账接口的幂等性,通过唯一请求号去重,防止网络重试导致同一笔转账执行两次。
整个项目我用了以下核心依赖:
- Spring Boot 2.7.x
- MyBatis-Plus 3.5.x
- MySQL 8.0
- Redis 6.x
- Hutool工具库(生成请求号、日期处理)
2.3 核心表结构设计
账户表和转账流水表是整个系统的地基,设计上要提前考虑扩展性。账户表除了基础的用户ID和余额,我还加了一个version字段用于乐观锁控制,以及一个status字段标识账户是否冻结。
转账流水表是审计和排查问题的关键,每个字段都有实际用途。trade_no是全局唯一业务流水号,在业务层面标识一笔转账;from_account和to_account记录转账双方;amount存转账金额;status记录流水状态;remark字段我建议一定要留,线上排查问题时这个字段能救命,可以存失败原因、重试标记等信息。
3. 转账核心逻辑与实现细节
3.1 转账流程的完整链路
一次标准转账请求,从接口接收到最终落库,我把它拆成了六个步骤。第一步生成全局唯一流水号;第二步检查转账双方账户是否存在且状态正常;第三步加锁防止并发;第四步校验转出账户余额是否充足;第五步执行扣款和加款操作;第六步更新流水状态并记录日志。
前两步没有技术难度,但容易被忽略。账户状态检查尤其重要,实际业务中经常出现账户被冻结、被注销但用户还能发起转账的情况,模拟系统虽然不是生产环境,但把这一步做严谨能帮自己养成好习惯。
加锁这一步要说明的是,代码里我同时在数据库行锁和Redis分布式锁两个层面做了防护。数据库行锁通过SELECT ... FOR UPDATE实现,这是保证数据一致性的底线。Redis锁则用来处理更上层的并发控制,比如防止同一账户的并发转账请求在业务逻辑层就产生冲突。
3.2 事务边界与余额扣减的经典写法
事务边界是整个转账系统最核心的部分。扣款和加款必须放在同一个数据库事务里,任何一步失败都要全部回滚。用Spring的@Transactional注解最方便,但要注意事务的粒度:锁的获取、幂等校验这些操作要放在事务外面,事务里面只放真正需要原子性的数据库操作。
余额扣减的SQL是另一个容易出错的环节。最安全的写法是带条件的更新:
UPDATE account SET balance = balance - #{amount}, version = version + 1 WHERE id = #{fromAccountId} AND balance >= #{amount} AND status = 1这段SQL的精髓在于把余额判断直接放进UPDATE语句里,利用数据库的锁机制保证原子性。如果返回的影响行数为0,说明余额不足或账户状态异常,直接抛出异常让事务回滚。这种方式比先SELECT余额再UPDATE要安全得多,因为在高并发下,先查后改存在时间窗口,余额可能在查询后被其他事务扣减,导致超扣。
加款操作相对简单,但也必须放在同一个事务中。我用的是:
UPDATE account SET balance = balance + #{amount}, version = version + 1 WHERE id = #{toAccountId} AND status = 1需要注意的是,虽然加款不会导致负数,但同样要检查影响行数,因为目标账户可能同时被冻结或删除。
3.3 大金额转账的限额与风控预留
虽然这是一个模拟项目,但我在设计时预留了限额控制逻辑。实际银行系统中,单笔转账限额、日累计限额、频次限制都是标配。我在代码中加了一个简单的可配置校验:
# 单笔最大转账金额,单位:分 transfer.single-limit=50000000 # 单日最大累计转账金额,单位:分 transfer.daily-limit=200000000在转账校验阶段,先查当前账户今日已转出的累计金额,加上本次转账金额,如果超过日限额就拒绝。这个逻辑用一行SQL就能实现,从转账流水表中按from_account和日期聚合求和。这个功能的成本很低,但能让项目在面试中被问到“如何设计风控”时有话可说,而不是只能说“我们没做”。
4. 并发一致性与幂等性设计
4.1 为什么说直接UPDATE余额比先查后改安全
在并发场景下,如果先查询余额再更新,大概率会出现超扣。我举个具体例子说明:假设账户余额100元,两个请求同时转账80元。如果两个线程都先查到余额100元,然后各自执行UPDATE扣减80元,最终余额变成20元而不是-60元,这就出现了严重的资损。
有人会说我用SELECT ... FOR UPDATE先锁定行再操作,这样没问题。确实,但前提是必须记住在执行UPDATE之前锁定数据行,且锁要一直持有到事务提交。实际操作中很多人写着写着就忘了加FOR UPDATE,或者把查询放在了加锁范围之外。最稳妥的做法还是用我刚写的那种带余额条件的UPDATE语句,从根本上杜绝了超扣问题。
4.2 分布式锁的作用与使用边界
数据库行锁已经保证了数据一致性,那Redis分布式锁还有必要吗?我的答案是:有必要,但作用域不同。
数据库行锁保护的是单条数据更新的正确性,但它管不了更上层业务流程的串行化需求。比如用户连续点了两次转账按钮,第一次请求还在处理中,第二次请求又开始执行。如果业务层不加以控制,两次请求可能都通过幂等校验,进入数据库层面后靠行锁排队执行,导致用户的钱被扣了两次。而实际上用户只是在网络抖动时多点了两下而已。
这时候Redis锁的价值就体现出来了。我在转账请求入口处,以lock:trade:{fromAccountId}_{toAccountId}作为锁的key,用Redis的SETNX加过期时间的方式获取锁,拿到锁才继续执行转账核心逻辑。这样同一对账户的并发转账请求会在入口处被串行化,后面的请求要么等待前面的处理完,要么快速失败返回“处理中,请勿重复操作”。
String lockKey = "lock:transfer:" + fromAccountId + "_" + toAccountId; boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, requestNo, 10, TimeUnit.SECONDS); if (!locked) { throw new BizException("转账处理中,请勿重复提交"); }4.3 幂等控制的完整实现方案
幂等性的核心目标是:同一个转账请求无论被提交多少次,最终只生效一次。我采用的方案是在转账流水表中建立唯一索引,用前端生成的请求号或业务编号做幂等键。
具体做法是,转账流水表中增加request_no字段,并在该字段上建立唯一索引。转账时先将流水记录插入数据库,状态为“处理中”。如果同一个请求号再次提交,数据库会因为唯一索引约束而插入失败,直接返回上次的处理结果。
但这里有个细节:如果直接把“插入流水”和“扣款加款”放在同一个事务里,流水插入成功后事务还没提交,数据库的锁还没释放,此时其他请求是无法看到这条未提交的流水的。所以我调整了执行顺序:先生成并插入一条状态为“初始化”的流水记录并提交事务,然后再执行转账核心逻辑,最后更新流水状态为“成功”或“失败”。这样即使核心转账逻辑执行了两次,第一次因重复请求号被数据库拒绝,也保证了幂等。
5. 实际运行效果与代码层面的易错点
5.1 一次完整转账的执行痕迹
我用Postman模拟了一次A向B转账100元的请求,从日志可以看到核心执行流程。首先是获取分布式锁成功,然后检查双方账户状态,接着执行扣款UPDATE返回影响行数1,加款UPDATE影响行数也是1,最后更新流水状态为SUCCESS,释放锁。
整个过程耗时大概50毫秒左右,其中大部分时间消耗在数据库事务提交上。如果出现余额不足,扣款UPDATE返回影响行数为0,系统会抛出余额不足异常,流水状态更新为FAILED,事务回滚后A和B的余额都不变。这个环节我测试了不下20次,确认没有任何一次出现A扣了钱但B没收到钱的情况。
5.2 自增主键与业务流水号分离
很多新手做转账表设计时容易犯一个错误:用数据库自增ID当业务流水号。这样做在模拟项目里能跑,但到了生产环境会成为巨大隐患。真实业务中,流水号需要具备全局唯一、趋势递增、可追溯的特性,通常由业务方生成。
我在代码中使用的是Hutool工具类生成的带时间戳的流水号:20250316143022123_A_10001。这种格式包含了时间和业务标识,在排查问题时一眼就能看出这笔转账大概发生在什么时候。MySQL自增ID只作为内部主键使用,不暴露给外部调用方。
5.3 金额存储的精度问题
在实际开发中,金额必须用整数类型存储,以“分”为单位。用浮点数或者BigDecimal直接存“元”都会在计算过程中出现精度丢失问题。
我在数据库里把金额字段设计为BIGINT类型,单位为分。接口接收的金额参数以元为单位,进入系统后立即乘以100转为分。转账计算全程使用long类型,不会出现任何精度问题。写代码时拿整数做金额计算的爽快感,用过的人都知道。
5.4 值得记录的三个代码层面的易错点
第一个是@Transactional的自调用失效问题。在同一个类内部通过this调用带有@Transactional注解的方法,事务是不会生效的,因为Spring的声明式事务基于代理实现。我刚开始实现时就把转账方法写在同一个Service类里,导致事务根本没包裹住扣款和加款,测试时发现A扣了钱但B没收到。解决办法是把事务方法拆分到独立的Service类中,或者使用编程式事务。
第二个是MyBatis-Plus的updateById方法默认不会更新null字段。这个特性在实际中是个坑:如果某次更新操作需要把某个字段置为null,直接传一个实体对象调用updateById,结果是该字段不会被更新为null,依然是原来的值。解决方法是使用UpdateWrapper显式指定要更新的字段。
第三个是数据库表字段名和Java字段映射问题。MyBatis-Plus开启了下划线转驼峰后,from_account能正常映射到fromAccount,但如果数据库字段或者Java字段命名不规范,就会出现字段映射不上的问题。排查这类问题最快的办法是开启MyBatis的SQL日志,看实际执行的SQL和参数值。
6. 转账系统的测试策略与验收标准
6.1 并发场景测试怎么设计
转账系统最需要验证的是并发场景下的数据一致性。我设计了三组测试用例:单账户并发转出、多账户互转、同账户同时转入转出。
第一组用例是让同一个账户A同时向B、C、D三个账户各转50元,初始余额100元。正常结果应该是其中两笔成功、一笔失败,失败原因是余额不足。如果出现三笔都成功或者余额变负数的情况,说明并发控制出了问题。
第二组用例是A和B各自同时给对方转账100元,初始各200元。正常结果是两笔都成功,最终各200元。这个场景测试的是数据库行锁对双向更新的响应,如果出现死锁,说明锁的获取顺序不一致。
第三组用例最容易踩坑:A账户同时被多个请求转入和转出,初始余额100元,并发转入50元和转出80元各10次。正常结果需要保证最终余额等于100+500-800=-200,但这个结果实际不合理,因为余额变成了负数。所以这种用例必须搭配“余额必须大于0”的业务约束,最终结果应该是转出成功的笔数不超过总可用余额允许的笔数。
6.2 我踩过的死锁与排查方法
在测试多账户互转时,程序抛出了死锁异常。排查后发现原因是我在处理A转B和B转A时,加锁的顺序不一致:A转B先锁A再锁B,B转A也先锁A再锁B的话就不会死锁,但我一不小心让B转A变成了先锁B再锁A,两个事务互相等待对方的锁,MySQL检测到死锁后主动回滚了其中一个事务。
解决办法是统一加锁顺序:在转账方法中,先比较fromAccountId和toAccountId的大小,总是先锁id较小的账户,再锁id较大的账户。这样就能避免循环等待,从根源上消除死锁。
6.3 自动化测试脚本的编写思路
用手工测试并发场景效率太低,我写了一个简单的JUnit并发测试类,利用CountDownLatch模拟20个线程同时发起转账。核心代码是用ExecutorService创建线程池,每个线程提交一个转账任务,全部启动后通过CountDownLatch控制同时释放,然后验证最终余额是否符合预期。
测试结果:20个并发转账请求,最终余额与预期完全一致,无超扣、无漏扣。自动化并发测试的意义在于,每次修改代码后都能快速回归验证。转账系统最怕的就是改了一个无关痛痒的校验逻辑,结果把核心事务搞坏了,有了自动化测试,问题能在提交代码前就被发现。
7. 日志审计与线上问题排查经验
7.1 转账日志中必须打印的关键信息
生产环境出了问题,第一件事就是查日志。好的日志设计能让你在几分钟内定位问题,差的日志设计会让你在凌晨三点对着几百行无意义输出干瞪眼。
我在转账系统的每个关键节点都打印了结构化日志,固定包含以下信息:流水号requestNo、转出账户fromAccount、转入账户toAccount、金额amount、当前步骤step、执行结果result。日志格式统一为:
[TRANSFER][2025-03-16 14:30:22.123][requestNo=20250316143022123_A_10001][step=扣款][from=10001][to=10002][amount=10000][result=SUCCESS]这种格式的日志在ELK或者简单的grep命令下都能快速筛选出单笔转账的完整生命周期。排查问题时,用流水号搜索日志就能把一笔转账的每一步操作串起来。
7.2 对账机制:定期检查账是否平
一个可靠的转账系统,不能只依赖业务代码的正确性,还需要独立于业务逻辑之外的对账机制来守住最后一道防线。我实现了一个简单的日终对账任务:每天凌晨统计每个账户的期望余额与实际余额是否一致。
对账逻辑是:取出账户表当前余额,然后从转账流水表中汇总该账户所有转出总额和转入总额,用“初始余额+累计转入-累计转出=当前余额”的公式验证。如果等式不成立,系统会生成告警任务,通知管理员手工介入。这个机制看起来笨拙,但确实是银行系统中最基础也最有效的一致性保障手段。
7.3 模拟系统里最容易忽略的“小额高频”陷阱
在测试中我模拟了一个高频小额转账的场景:A账户向B账户转账0.01元,连续转500次。问题出在事务提交的频率上,500次串行转账耗时超过了10秒,接口响应时间明显变慢。这个现象提示我:模拟系统虽然不考虑性能,但真实场景下类似操作必须做合并或异步批处理。
我把这个场景和解决方案记录在项目README中,面试时讲清楚“为什么小额高频场景需要异步批处理”,比单纯说“我用了线程池”要有说服力得多。每次都开启一个完整的事务提交,在高频场景下代价太大,合理的方式是批量转账每次处理多笔交易,或者引入消息队列做削峰填谷。
8. 从模拟到生产环境的关键一步
很多人的模拟项目做完就结束了,但如果想真正理解生产级转账系统的复杂度,还要再往前走一步:把这个模拟系统扔到分布式环境中看它会出什么问题。我在项目后期做了一次扩展,将单体应用拆成了两个服务实例,通过Nginx负载均衡对外提供服务,然后立刻发现了两个在单机环境下完全看不出的问题。
第一个是Redis分布式锁的过期时间问题。原本锁的过期时间设的是10秒,但遇到大事务或者数据库慢查询,转账处理时间超过了10秒,锁自动过期释放。此时另一个请求拿到锁进入核心逻辑,两个事务同时操作同一账户。虽然数据库行锁兜底保证了不会超扣,但幂等控制在这个场景下出现了漏洞。解决办法是使用Redisson的看门狗机制,让锁在业务执行期间自动续期。
第二个是流水号生成冲突问题。原本我用的流水号是时间戳+随机数字,单机环境下几乎没有冲突,但部署两个实例后,同一毫秒内两个实例可能生成相同的流水号。解决办法是把流水号改为雪花算法生成,保证全局唯一。
这两个问题的发现和解决,让这个模拟项目的含金量提升了一个档次。如果条件允许,建议大家做完单体版本后都试着做一次多实例部署测试,这个过程学到的东西比读十篇技术文章都多。
这个项目做到现在,我个人最大的体会是:转账系统的核心从来不是“能转账”,而是“任何情况下都不会转错账”。从数据库事务到并发控制,从幂等设计到日志审计,每一步都对应着真实生产环境中的一个具体问题。把这些细节想清楚、实现好、测试通过,你就真正理解了支付的本质。最后再分享一个小技巧:把这个项目跑起来之后,故意在代码里埋几个bug(比如去掉幂等判断、去掉余额校验),然后观察测试用例怎么失败,这种反向学习法能帮你把每个技术点的价值理解得更深刻。
本文还有配套的精品资源,点击获取