Java网上银行转账系统实战:并发扣款与数据一致性全解析
2026/9/23 16:05:10 网站建设 项目流程

简介:一份基于Java与JavaScript的网上银行转账系统设计源码,面向Java Web学习者、毕业设计选题及金融类课程项目开发者,可帮助理解在线转账场景下的用户认证、账户信息处理、资金转入转出、事务管理以及基础安全防护,提升对完整前后端交互流程的认知。资源包共46个文件,其中19个Java源文件承载核心业务逻辑,14个JSP页面负责动态界面展示、转账表单与记录查询,7个XML配置文件服务于Maven构建、数据库连接及系统参数定义,另有properties关键配置、JavaScript交互脚本和文本说明,整体约356KB,文件划分明确,便于按模块阅读和二次开发。已有279人学习下载。借助该资源可掌握从JSP前端到Java后端再到配置管理的完整组织方式,了解pom.xml依赖管理、资源配置外置、SSL加密、SQL注入与XSS攻击防范等安全设计要点,适合作为课程设计项目改造或小型金融业务系统开发的入门参考。

1. 网上银行转账系统:让新手一次吃透 Java Web 全链路

做 Java 后端的人,迟早会遇到一个坎:怎么把一个业务从零到一落成能跑的 Web 服务。网上银行转账系统就是这类项目里最经典的一个——它麻雀虽小,五脏俱全:有用户体系、有账户模型、有事务、有并发扣款、有流水记录,还要能处理"钱从 A 到 B"这种绝不能错的数据一致性。很多同学拿它当毕业设计,也有转行者拿它当练手项目,但真正把它做明白的人不多,因为坑全在细节里:同样是转账,为什么 100 个请求并发打进来,余额会变成负数?为什么明明加了事务注解,钱还是扣重了?

这篇文章就围绕"基于 Java 的网上银行转账系统"来讲,从技术选型到数据库设计,从转账核心流程到并发和幂等问题,到最后给你一套可以直接跑通的方案。适合正在做课程设计的学生、准备面试的 Java 初学者,以及想找个完整项目练手但不想碰那种动不动就微服务的重型框架的人。我会按照自己做这类系统的思路,把每一步的取舍和坑都讲清楚,你照着敲完,就拥有一个能演示、能答辩、能写进简历的项目。

2. 技术选型与项目搭建:为什么是 Spring Boot + MyBatis,怎么把骨架搭起来

2.1 技术选型:转账系统用这一套就够了,别自己给自己加戏

很多人的第一个误区是一上来就上 Spring Cloud + 微服务 + 分布式事务。网上银行转账系统这个业务,单机单库完全能扛住,真正到了需要分布式事务的规模,那也得先有单机版本打底。常见的做法是 Spring Boot + MyBatis + MySQL,这套组合是 Java 后端岗位里出现频率最高的技术栈,面试问起来也最容易被认可。

选 Spring Boot 是因为它把配置简化了太多,不需要写一堆 XML 配置文件,内嵌 Tomcat 开箱即用。选 MyBatis 而不是 Spring Data JPA,是因为转账业务里有大量需要你手写 SQL 控制的场景——比如用UPDATE ... WHERE balance >= #{amount}这种带条件的原子更新,MyBatis 表达起来最直观。JPA 也能做,但它在@Version乐观锁之外能给你的控制力弱一些,不太适合讲清楚并发扣款这个核心点。

数据库选 MySQL,用 InnoDB 引擎。注意别图省事用 MyISAM,它不支持行锁和事务,转账并发场景下会出很离谱的问题,后文避坑章会讲。

2.2 初始化项目:从 pom.xml 到第一个启动类

我用 Spring Initializr 或者直接在 IDEA 里新建一个 Spring Boot 项目,Java 版本用 8 或 11 都行,不强求 17——学校机房和公司老项目的 JDK 版本未必那么新。依赖只需要spring-boot-starter-webmybatis-spring-boot-startermysql-connector-javalombok这四个核心就够了。

<dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>2.3.1</version> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> </dependencies>

这段依赖里值得说明的是 MyBatis starter 的版本。Spring Boot 2.x 配 MyBatis starter 2.3.x 是稳定组合,如果你用 Spring Boot 3.x 就得换 mybatis-spring-boot-starter 3.x 版本,否则启动会报包冲突。MySQL 连接器在新版本里换了 artifactId,老的是mysql-connector-java,新的是mysql-connector-j,两个都能用,别混。

然后写配置文件,核心就三块:数据源、MyBatis 映射、日志。

server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/bank_db?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai username: root password: yourpassword driver-class-name: com.mysql.cj.jdbc.Driver mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.bank.entity configuration: map-underscore-to-camel-case: true logging: level: com.example.bank.mapper: debug

map-underscore-to-camel-case这个配置建议打开,这样数据库里的account_no能自动映射到实体类的accountNo字段,少写很多resultMaplogging.level里指定mapper包为 debug,是为了之后排查 SQL 问题——MyBatis 会在日志里打印完整 SQL 和参数,调试转账流程时特别管用。

2.3 目录结构与第一个能跑通的接口

项目包结构我建议这样分,简单清晰,答辩也好看:

com.example.bank ├── BankApplication.java ├── controller │ └── TransferController.java ├── service │ ├── TransferService.java │ └── impl │ └── TransferServiceImpl.java ├── mapper │ ├── AccountMapper.java │ └── TransferRecordMapper.java ├── entity │ ├── Account.java │ └── TransferRecord.java ├── common │ └── Result.java └── enums └── TransferStatus.java

controller 只做参数接收和结果返回,service 放业务逻辑,mapper 只负责数据库操作,这是从课程设计到企业项目通用的职责划分方式。写一个健康检查接口验证 Saber 有没有跑起来:

@RestController @RequestMapping("/api/bank") public class TransferController { @GetMapping("/health") public Result<String> health() { return Result.success("bank service is running"); } }

这是最简单的一个接口,返回统一封装的结果对象,框架通不通就看它。跑起来之后访问http://localhost:8080/api/bank/health,能看到 JSON 返回就说明工程没问题,接下去开始画表。

3. 数据库设计与转账核心流程:表结构怎么建,转账那句 SQL 为什么必须这么写

3.1 核心表结构:账户表和流水表,缺一不可

转账系统往最简了说也要三张表:用户表(可选)、账户表、转账流水表。用户表看情况,如果没做登录注册,可以先不建,直接把账户表当主体。账户表里的字段要能支撑转账业务,不能只放一个余额就完事。

CREATE TABLE `account` ( `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', `user_id` VARCHAR(32) NOT NULL COMMENT '用户编号', `account_no` VARCHAR(32) NOT NULL COMMENT '账号', `balance` DECIMAL(18,2) NOT NULL DEFAULT '0.00' COMMENT '账户余额', `version` INT NOT NULL DEFAULT '0' COMMENT '乐观锁版本号', `status` TINYINT NOT NULL DEFAULT '1' COMMENT '状态:1正常 0冻结', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_account_no` (`account_no`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='账户表';

这里最关键的是balance用了DECIMAL(18,2)而不是FLOATDOUBLE。金融场景下的金额计算不允许有浮点误差,0.1 + 0.2在浮点数里会变成0.30000000000000004,这个误差在转账场景是事故级别的问题。version字段是给乐观锁用的,后面并发章节细说。

转账流水表长这样:

CREATE TABLE `transfer_record` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `transfer_no` VARCHAR(64) NOT NULL COMMENT '转账单号', `from_account_no` VARCHAR(32) NOT NULL COMMENT '转出账号', `to_account_no` VARCHAR(32) NOT NULL COMMENT '转入账号', `amount` DECIMAL(18,2) NOT NULL COMMENT '转账金额', `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, PRIMARY KEY (`id`), UNIQUE KEY `uk_transfer_no` (`transfer_no`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='转账流水表';

transfer_no是转账单号,业务幂等键,必须有唯一索引。这个字段决定了你的系统能不能防住用户狂点提交按钮导致重复扣款的问题。两张表都加上create_timeupdate_time,排查问题时你就知道这条数据是什么时候变的,这条习惯建议从第一个项目就开始养成。

3.2 转账核心流程:扣款、入账、流水,顺序和细节都讲透

转账流程看起来就三步——A 扣钱、B 加钱、记流水。但实现细节不同,系统的正确性和性能天差地别。我告诉你一个坏方案和一个好方案,你就明白为什么转账那几句 SQL 不能乱写。

坏方案是业务代码里先查余额,Java 里判断够不够,再执行 UPDATE 扣钱。这个方案在单线程下没问题,但一来并发请求就穿帮:两个请求同时读到余额 1000,都判断"够",都执行为 1000 减 100,余额变成 900,实际应该是 800。原因就是"检查"和"扣款"不是原子操作。

好方案是把检查和更新合并成一条 SQL,让数据库的行锁来保证原子性:

public interface AccountMapper { // 原子扣款:余额足够才更新成功,返回影响行数 int deductBalance(@Param("accountNo") String accountNo, @Param("amount") BigDecimal amount); // 原子入账:给收款方加钱 int addBalance(@Param("accountNo") String accountNo, @Param("amount") BigDecimal amount); // 查账户:用于后续的业务校验和展示 Account selectByAccountNo(@Param("accountNo") String accountNo); }

对应的 Mapper XML 是关键所在,第一条 SQL 是这个系统的灵魂:

<update id="deductBalance"> UPDATE account SET balance = balance - #{amount}, version = version + 1 WHERE account_no = #{accountNo} AND balance >= #{amount} AND status = 1 </update>

这条 SQL 做了三件事:把余额减少指定金额,通过balance >= #{amount}条件保证余额不足时不执行更新,通过status = 1保证冻结账户不能扣款。你不需要先查余额再做判断,数据库行锁会让这条 UPDATE 在并发时排队执行,后到的请求要么余额已被扣减导致条件不满足,要么正常执行。

入账 SQL 和扣款类似,只有一个方向的区别:

<update id="addBalance"> UPDATE account SET balance = balance + #{amount}, version = version + 1 WHERE account_no = #{accountNo} AND status = 1 </update>

入账不判断余额,因为加钱永远合法,只需要保证账户是正常状态。到这里你会发现扣款那条 SQL 的影响行数返回值如果为 0,就说明余额不足或账户异常,业务层就该抛异常回滚了。

3.3 Service 层事务编排:@Transactional 把三步包成一个原子操作

Mapper 写好后,Service 层编排业务逻辑。转账操作的完整代码:

@Service public class TransferServiceImpl implements TransferService { @Autowired private AccountMapper accountMapper; @Autowired private TransferRecordMapper transferRecordMapper; @Override @Transactional(rollbackFor = Exception.class) public void transfer(String fromAccountNo, String toAccountNo, BigDecimal amount) { // 1. 校验参数:金额必须大于0 if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) { throw new IllegalArgumentException("转账金额必须大于0"); } // 2. 校验不是给自己转账 if (fromAccountNo.equals(toAccountNo)) { throw new IllegalArgumentException("不能给自己转账"); } // 3. 生成转账单号,幂等键 String transferNo = generateTransferNo(); // 4. 插入转账流水,状态为处理中 TransferRecord record = new TransferRecord(); record.setTransferNo(transferNo); record.setFromAccountNo(fromAccountNo); record.setToAccountNo(toAccountNo); record.setAmount(amount); record.setStatus(0); transferRecordMapper.insert(record); // 5. 扣款,返回0说明余额不足或账户异常 int deductRows = accountMapper.deductBalance(fromAccountNo, amount); if (deductRows == 0) { throw new RuntimeException("余额不足或账户状态异常"); } // 6. 入账 int addRows = accountMapper.addBalance(toAccountNo, amount); if (addRows == 0) { throw new RuntimeException("收款账户状态异常"); } // 7. 更新流水状态为成功 transferRecordMapper.updateStatus(transferNo, 1, null); } }

这段代码的顺序是有讲究的:先插流水再扣款最后入账。先记流水的好处是,如果系统在步骤 5 或 6 之间崩溃了,数据库事务回滚会把步骤 4 的流水也一并回滚,不会出现"流水记录说处理中,但余额没变"的孤儿数据。整个过程由@Transactional包住,任何一个步骤抛出 RuntimeException,前面所有数据库操作全部回滚。

还有一个细节是generateTransferNo()生成单号的规则。常见做法是用时间戳 + 随机数或者 UUID,但这些方案在高并发下可能重复。我一般用yyyyMMddHHmmss + 用户ID后四位 + 随机四位数字,反正是唯一的,如果在意唯一性可以用数据库的雪花算法,但对课程设计项目,这个方案完全够用。

3.4 控制层:接收请求、参数校验、统一异常处理

Controller 层职责很薄,但有一个容易漏的地方:参数校验要在 Controller 做一层,Service 里也要做一层。Controller 层校验是为了快速返回错误提示,Service 层校验是为了保证业务逻辑的完整性,两层不能互替。

@RestController @RequestMapping("/api/bank") public class TransferController { @Autowired private TransferService transferService; @PostMapping("/transfer") public Result<String> transfer(@RequestBody TransferRequest request) { // 简单的参数校验 if (request.getAmount() == null || request.getFromAccountNo() == null || request.getToAccountNo() == null) { return Result.error("参数不能为空"); } transferService.transfer( request.getFromAccountNo(), request.getToAccountNo(), new BigDecimal(request.getAmount()) ); return Result.success("转账成功"); } }

注意这里统一用Result<T>做返回体,它包含codemessagedata三个字段。这个习惯在企业开发里是标准做法,你的前端对接起来简单,以后接微服务做统一响应也顺手。同时建议加一个@RestControllerAdvice全局异常处理,把 RuntimeException 转成业务错误响应,这样转账失败的时候用户看到的是"余额不足"而不是一串堆栈信息。

4. 并发扣款与保持数据一致:为什么转账毫秒级完成,余额却错了

4.1 并发场景下三种方案对比:数据库锁和乐观锁怎么选

很多人把转账系统跑通以后觉得很完整了,直到用 JMeter 或 Postman 并发压测才发现余额对不上。转账这个看似简单的操作,并发时至少面临三个问题:扣款超扣、重复转账、流水缺少。这一章的方案不只是为了让系统正确,更是面试时展示你理解了并发控制的有力证据。

处理并发扣款有几种常见方案。第一种是SELECT ... FOR UPDATE,把要操作的账户行锁住再处理,缺点是持锁时间长,并发性能差。第二种是乐观锁,用 version 字段控制,只在校验失败时重试。第三种就是我上面推荐的原子 UPDATE,把检查和更新放在一条 SQL 里,不额外加锁,性能最好。转账系统更适合第三种方案,因为它天然支持高并发场景。

4.2 乐观锁:version 字段在转账场景的正确用法

如果你确实需要在某些场景下用乐观锁——比如余额查询之后到扣款之前还有其他业务操作——那 version 字段是标配。比如用户在前端页面看到了余额,犹豫了一会儿才点转账,这期间余额被别人转了,你希望在提交时告诉他"余额已变化请刷新"。

<update id="deductBalanceByVersion"> UPDATE account SET balance = balance - #{amount}, version = version + 1 WHERE account_no = #{accountNo} AND version = #{version} AND balance >= #{amount} AND status = 1 </update>

这条 SQL 多了version = #{version}条件。业务在查询账户时拿到 version,做校验后执行更新,数据库只会更新 version 匹配的那行。影响行数为 0 就说明别人先改了数据。

public void transferWithVersion(String fromAccountNo, String toAccountNo, BigDecimal amount, int version) { // 查询账户拿到了version Account account = accountMapper.selectByAccountNo(fromAccountNo); // 执行业务校验... int rows = accountMapper.deductBalanceByVersion(fromAccountNo, amount, account.getVersion()); if (rows == 0) { throw new ConcurrentModificationException("数据已被其他请求修改,请重试"); } // 后续入账操作... }

乐观锁的代价是并发冲突多的时候用户体验差——大量请求拿到 0 影响行数然后被迫重试。在我们这个转账场景里,其实原子 UPDATE 已经包含了版本控制的思想,所以日常转账直接用 4.2 的方案就够了。乐观锁更适合用在那些必须先读数据做复杂业务校验、再写回的场景,比如购物车里改数量加优惠券计算那种。

4.3 幂等设计:用户手滑点了十次转账,系统怎么保证只扣一次钱

并发扣款解决的是"一次转账操作内部的数据竞争",幂等解决的是"同一个转账请求被提交多次"。前端用户不会故意点十次提交,但网络超时自动重试、前端按钮没做 loading、消息队列重复投递,这些情况都非常容易导致重复扣款。

我在转账流水表用transfer_no的唯一索引做幂等。操作流程是:请求进来先根据转账单号查流水,如果存在就不处理直接返回成功;不存在才继续转账。

@Override @Transactional(rollbackFor = Exception.class) public void transferWithIdempotent(String transferNo, String fromAccountNo, String toAccountNo, BigDecimal amount) { // 幂等校验:转账单号已存在说明已经处理过 TransferRecord exist = transferRecordMapper.selectByTransferNo(transferNo); if (exist != null) { return; // 直接返回,不重复转账 } // 插入流水,利用唯一索引做最后的防重保障 TransferRecord record = new TransferRecord(); record.setTransferNo(transferNo); record.setFromAccountNo(fromAccountNo); record.setToAccountNo(toAccountNo); record.setAmount(amount); record.setStatus(0); try { transferRecordMapper.insert(record); } catch (DuplicateKeyException e) { return; // 并发插入冲突,说明另一个请求正在处理 } // 执行扣款和入账... }

这段代码的精髓是select-then-insert之间的并发窗口,靠唯一索引来兜底:两个请求同时查流水发现都不存在,然后同时 insert,数据库唯一索引会放行一个,另一个抛 DuplicateKeyException,捕获后直接返回。转账单号怎么生成?前端提交时可以生成,后端也可以生成,但要注意不能每次调用都生成新的单号——否则幂等就失效了。正确做法是:前端在用户点击提交时生成一次,后续重试都带同一个单号。

4.4 验证并发正确性:用 JMeter 或者代码模拟 100 个并发请求

写完代码以后你要验证它对不对。最简单的验证方式是写一个测试接口,循环 100 次线程池并发调用转账接口。Java 里可以用CountDownLatch控制并发起点:

@Test public void testConcurrentTransfer() throws InterruptedException { int threadCount = 100; CountDownLatch ready = new CountDownLatch(threadCount); CountDownLatch start = new CountDownLatch(1); CountDownLatch done = new CountDownLatch(threadCount); for (int i = 0; i < threadCount; i++) { new Thread(() -> { ready.countDown(); try { start.await(); transferService.transfer("A001", "B001", new BigDecimal("100")); } catch (Exception e) { // 记录异常 } finally { done.countDown(); } }).start(); } ready.await(); start.countDown(); done.await(); // 校验A账户减少、B账户增加的总额是否等于 100 * 成功次数 }

关键在于最后的结果校验:A 账户扣减总额等于 B 账户入账总额,而且等于成功执行的线程数乘以 100。如果并发执行的线程有 30 个因为余额不足失败,那 A 账户应该只减少 70 × 100 = 7000,B 账户增加 7000。只要这两边相等,事务和并发控制就是正确的。

5. 实战避坑:转账系统最常见的 5 个踩坑现场与排查路径

5.1 浮点型存余额,账面金额越算越不对

现象:转账累计多了以后,余额出现 0.001 这种小数位,或者两个账户加起来对不上。

原因:数据库字段用了 FLOAT 或 DOUBLE,Java 实体用了 double 或 float 类型。二进制浮点数无法精确保存十进制小数,多次累加后误差积少成多。

解决:数据库统一用 DECIMAL(18,2),Java 统一用 BigDecimal,禁止用 double 做金额计算。BigDecimal 的构造用new BigDecimal("100.00"),别用new BigDecimal(100.00),后者还是会有精度问题。

5.2 @Transactional 失效:转账失败但钱扣了

现象:程序抛异常,前端显示转账失败,但数据库里的钱已经在收款方账上了。

原因:最常见的三个原因——异常被 try-catch 吞掉了、方法被同类内部调用、数据库引擎不支持事务。我见过最典型的是 Service 里try { transfer(); } catch (Exception e) { log.error(e); },异常被吞掉之后 Spring 根本不知道业务失败,事务自然照常提交。

解决:让异常抛出去,@TransactionalrollbackFor = Exception.class;检查调用方式,不要在同一个类里用this.transfer()调用事务方法;确认表引擎是 InnoDB。快速排查用后端日志里搜 "Rolling back" 关键词,MyBatis 的 SQL 日志会清楚打印回滚链路。

5.3 并发压测时余额变负数

现象:账户余额 100 元,100 个线程同时转 50 元,最终余额变成负值或者凭空多出钱。

原因:Java 代码里先select余额再update,两个线程同时读到余额 100 都认为够扣。问题不在于余额被扣成负数,而在于"检查余额"和"扣款"不是原子操作。

解决:把所有余额判断下沉到 SQL 里,用WHERE balance >= #{amount}作为 UPDATE 条件,影响行数为 0 就抛异常回滚。这也是我为什么在前文强调那条 SQL 是整个系统最核心的部分——它同时解决了超扣和并发问题。

5.4 数据库字段用 varchar 存金额,字符串拼接拼接出事故

现象:金额明明是 100 元,转账后变成 99.999999 或 100.0001 之类的数字。

原因:字段类型用了 VARCHAR 或 CHAR。MySQL 对字符串做数字运算时隐式转换出问题,尤其在排序和比较的时候按字典序处理,100 > 99 会判断为 false。

解决:金额字段只能选 DECIMAL 或 NUMERIC,这是金融场景的硬性约定。排查时用SHOW CREATE TABLE account看字段类型,顺手把 Java 实体的类型也核对一遍。

5.5 建表用了 MyISAM 引擎,事务和行锁全是摆设

现象@Transactional没生效,并发扣款直接脏读、覆盖更新。

原因:MyISAM 引擎不支持事务和外键,也不支持行级锁,用的是表级锁。转账并发操作时表现是"不报错但数据错",特别容易让人误判是代码问题而忽略引擎问题。

解决:建表时显式指定ENGINE=InnoDB,别依赖 MySQL 的默认配置。已有的表用ALTER TABLE account ENGINE=InnoDB;转换。排查方法:查SHOW TABLE STATUS LIKE 'account'看 Engine 字段值。这条坑每届毕业生都有人踩,原因就是 MySQL 某些老版本或者某些一键安装环境默认引擎不是 InnoDB。

6. 从能用走向可靠:转账系统的三个进阶验证技巧

系统能跑了、能并发了,不等于它可靠了。最后补充三个我平时验证这类系统的技巧:数据一致性检查脚本、转账失败后的状态恢复、数据库超时配置。这三个东西决定你的系统是出现在简历上的"个人项目"还是进入面试官追问列表的"亮点"。

数据一致性最好的验证方式不是看接口返回值,而是直接核对数据库。写个简单的 SQL:查每个账户的余额,再和它的所有转账记录做对账。

-- 检查账户A的历史转出总额 + 当前余额 是否等于 初始余额 SELECT a.account_no, a.balance AS current_balance, IFNULL(SUM(CASE WHEN r.status = 1 AND r.from_account_no = a.account_no THEN r.amount ELSE 0 END), 0) AS total_out FROM account a LEFT JOIN transfer_record r ON 1=1 GROUP BY a.account_no, a.balance;

这个 SQL 的精确写法要根据你的初始化数据调整,但思路是一致的:账户当前余额加上历史转出金额,减去历史转入金额,应该等于初始余额。如果不等,就说明有转账记录和余额变更不一致的事务缝隙。每次功能改动后跑一遍这个查询,能比单元测试更快发现隐蔽问题。

第二个技巧是处理"处理中"状态的流水。事务回滚会把这个状态一并回滚,但如果是系统宕机,流水和余额有可能停留在不一致状态。我习惯加一个定时任务:把创建时间超过 5 分钟且 status = 0 的流水捞出来,重新核对两端账户余额,如果余额变动了但流水标记为失败,就把它纠正为成功;如果余额没变动,标记为失败并写明原因。这种对账机制不用做太复杂,每天跑一次,能发现绝大多数极端情况的数据问题。

第三点,注意一下 MySQL 连接超时和事务超时。转账操作通常毫秒级完成,但如果数据库连接池满了,@Transactional方法可能一直等不到连接,最终抛异常。线上环境我习惯在 application.yml 里加上:

spring: datasource: hikari: connection-timeout: 30000 maximum-pool-size: 20 minimum-idle: 5

这个配置是让连接池在 30 秒内拿不到连接就快速失败,避免前端请求无限挂起。事务超时可以在@Transactional(timeout = 5)里设置——转账操作超过 5 秒就回滚。Java 后端开发这么多年,转账这类的钱相关系统给我最大的教训就是:永远不要相信客户端传来的数据,永远要在数据库层面做好最后一道防线,永远要给自己留一条对账的后悔药路径。希望这篇文章里踩过的坑,能帮你少踩一次。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询