ARTICLE DETAIL

资讯详情

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

基于Spring Boot的转账系统:事务、幂等与并发控制实践

基于Spring Boot的转账系统:事务、幂等与并发控制实践 简介这是一份用于教学演示的模拟银行账户转账系统源码面向正在学习图形界面编程和多线程开发的初学者。系统设有两个初始余额为一千元的账户随机向对方转账转账金额不能超过当前余额余额为零则停止交易界面提供交易开始和清屏按钮点击开始后切换为结束交易结束时将全部交易记录写入文件。压缩包内共两个文件均为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.xMyBatis-Plus 3.5.xMySQL 8.0Redis 6.xHutool工具库生成请求号、日期处理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-limit50000000 # 单日最大累计转账金额单位分 transfer.daily-limit200000000在转账校验阶段先查当前账户今日已转出的累计金额加上本次转账金额如果超过日限额就拒绝。这个逻辑用一行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次。正常结果需要保证最终余额等于100500-800-200但这个结果实际不合理因为余额变成了负数。所以这种用例必须搭配“余额必须大于0”的业务约束最终结果应该是转出成功的笔数不超过总可用余额允许的笔数。6.2 我踩过的死锁与排查方法在测试多账户互转时程序抛出了死锁异常。排查后发现原因是我在处理A转B和B转A时加锁的顺序不一致A转B先锁A再锁BB转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][requestNo20250316143022123_A_10001][step扣款][from10001][to10002][amount10000][resultSUCCESS]这种格式的日志在ELK或者简单的grep命令下都能快速筛选出单笔转账的完整生命周期。排查问题时用流水号搜索日志就能把一笔转账的每一步操作串起来。7.2 对账机制定期检查账是否平一个可靠的转账系统不能只依赖业务代码的正确性还需要独立于业务逻辑之外的对账机制来守住最后一道防线。我实现了一个简单的日终对账任务每天凌晨统计每个账户的期望余额与实际余额是否一致。对账逻辑是取出账户表当前余额然后从转账流水表中汇总该账户所有转出总额和转入总额用“初始余额累计转入-累计转出当前余额”的公式验证。如果等式不成立系统会生成告警任务通知管理员手工介入。这个机制看起来笨拙但确实是银行系统中最基础也最有效的一致性保障手段。7.3 模拟系统里最容易忽略的“小额高频”陷阱在测试中我模拟了一个高频小额转账的场景A账户向B账户转账0.01元连续转500次。问题出在事务提交的频率上500次串行转账耗时超过了10秒接口响应时间明显变慢。这个现象提示我模拟系统虽然不考虑性能但真实场景下类似操作必须做合并或异步批处理。我把这个场景和解决方案记录在项目README中面试时讲清楚“为什么小额高频场景需要异步批处理”比单纯说“我用了线程池”要有说服力得多。每次都开启一个完整的事务提交在高频场景下代价太大合理的方式是批量转账每次处理多笔交易或者引入消息队列做削峰填谷。8. 从模拟到生产环境的关键一步很多人的模拟项目做完就结束了但如果想真正理解生产级转账系统的复杂度还要再往前走一步把这个模拟系统扔到分布式环境中看它会出什么问题。我在项目后期做了一次扩展将单体应用拆成了两个服务实例通过Nginx负载均衡对外提供服务然后立刻发现了两个在单机环境下完全看不出的问题。第一个是Redis分布式锁的过期时间问题。原本锁的过期时间设的是10秒但遇到大事务或者数据库慢查询转账处理时间超过了10秒锁自动过期释放。此时另一个请求拿到锁进入核心逻辑两个事务同时操作同一账户。虽然数据库行锁兜底保证了不会超扣但幂等控制在这个场景下出现了漏洞。解决办法是使用Redisson的看门狗机制让锁在业务执行期间自动续期。第二个是流水号生成冲突问题。原本我用的流水号是时间戳随机数字单机环境下几乎没有冲突但部署两个实例后同一毫秒内两个实例可能生成相同的流水号。解决办法是把流水号改为雪花算法生成保证全局唯一。这两个问题的发现和解决让这个模拟项目的含金量提升了一个档次。如果条件允许建议大家做完单体版本后都试着做一次多实例部署测试这个过程学到的东西比读十篇技术文章都多。这个项目做到现在我个人最大的体会是转账系统的核心从来不是“能转账”而是“任何情况下都不会转错账”。从数据库事务到并发控制从幂等设计到日志审计每一步都对应着真实生产环境中的一个具体问题。把这些细节想清楚、实现好、测试通过你就真正理解了支付的本质。最后再分享一个小技巧把这个项目跑起来之后故意在代码里埋几个bug比如去掉幂等判断、去掉余额校验然后观察测试用例怎么失败这种反向学习法能帮你把每个技术点的价值理解得更深刻。本文还有配套的精品资源点击获取
返回列表