☰
模拟银行账户转账系统:事务、并发与幂等的生产级实践
2026/10/1 9:54:16 网站建设 项目流程

简介:模拟银行账户转账系统是一份面向Java初学者的图形界面编程与多线程实战项目,适合正在学习Swing、线程同步及文件读写的读者。项目模拟A、B两个账户各1000元初始余额,随机向对方转账且转账金额不能超过余额,余额为0则自动停止交易,并通过两个按钮控制交易开始、结束与清屏,逻辑清晰、交互直观。资源包总大小仅2KB,包含2个Java源文件,其中MyFrame.java负责窗口布局与按钮事件,MyThread.java负责转账线程的随机金额生成与交易记录写入,可帮助读者完整理解界面与业务逻辑的分离设计。压缩包内目录结构简单,直接解压即可导入Eclipse或IDEA运行调试,便于逐行学习线程控制和文件保存细节。目前已有4041人学习浏览,是巩固多线程与Swing知识、动手完成小型模拟系统的不错选择。

1. 模拟银行账户转账系统到底在练什么:不是加减法,是事务与并发

“模拟银行账户转账系统”是后端练手项目里出现频率最高的一个,但也是被误解最深的一个。很多人以为它就是两个 update 语句,一个扣钱一个加钱,跑通就完事。可等你把并发压测打开、把客户端重试加进来,会发现余额变负、重复入账、死锁这些问题一个接一个冒出来。这篇文章要把这个“模拟”做到接近生产的样子:账户模型怎么建、转账事务怎么写、并发下怎么不翻车,以及出了问题怎么排查。适合正在做课程设计或面试项目的开发者,也适合被线上转账问题折腾过的测试和运维。

2. 账户模型与表结构设计:把转账系统的基础数据模型一次搭对

转账系统第一步不是写接口,而是把表建好。表结构决定了后续事务、并发、对账能不能做下去。我见过太多项目在 account 表里只放一个 balance 字段,转账就是两行 update,结果出了问题根本查不了账。先把模型搭对,后面所有代码都是顺着模型长出来的。

2.1 为什么转账第一步是建账户模型,不是先写接口

账户模型至少要包含三层:账户、流水、转账订单。账户表存当前余额,流水表存每一笔金额变动,转账订单表存一次完整的转账行为。只改余额不记流水,等于让系统变成黑匣子——钱少了不知道去哪,钱多了不知道哪来的。流水表是审计、对账、排查的唯一依据。

另一个常见误区是:把转账实现成“扣款接口 + 入账接口”两个独立方法,先调扣款再调入账。模拟环境里这么写问题不大,但一旦其中一个调用失败,钱就凭空消失了。正确的做法是:把转账建模成一条“业务记录”,扣款和入账是这条记录的两个动作,必须在同一个事务里生效。

2.2 账户表、流水表、转账订单表:三张表的字段与 DDL

建表时我一般把金额统一成 DECIMAL(20,2),绝不用 float 或 double。浮点数在二进制里无法精确表示 0.1,累加多了就会出现 0.30000000000000004 这种问题,对账的时候会让人怀疑人生。下面这套 DDL 可以直接用在 MySQL 8.x 上:

CREATE TABLE account ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, balance DECIMAL(20,2) NOT NULL DEFAULT 0.00, frozen_balance DECIMAL(20,2) NOT NULL DEFAULT 0.00, version INT NOT NULL DEFAULT 0, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_user_id (user_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

balance 是可用余额,frozen_balance 是冻结余额。冻结余额在很多场景下都要用:转账中的中间状态、提现处理、风控锁定,都可以先把钱从 balance 挪到 frozen_balance,等对方确认入账后再扣减冻结额。version 字段是给乐观锁用的,后面并发章节会专门讲。

CREATE TABLE account_flow ( id BIGINT PRIMARY KEY AUTO_INCREMENT, account_id BIGINT NOT NULL, change_amount DECIMAL(20,2) NOT NULL, balance_after DECIMAL(20,2) NOT NULL, flow_type VARCHAR(20) NOT NULL, biz_id BIGINT NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_account_id (account_id), KEY idx_biz_id (biz_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

account_flow 是流水表,每一行代表账户余额的一次变动。change_amount 扣款记负数,入账记正数。balance_after 记这笔记账发生后的余额,这是对账时最重要的字段——只要 balance_after 和 account 表的 balance 对不上,就说明有流水丢失或重复记账。flow_type 用来区分是转账、充值、提现还是退款,biz_id 指向这次业务操作的唯一编号。

CREATE TABLE transfer_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, out_account_id BIGINT NOT NULL, in_account_id BIGINT NOT NULL, amount DECIMAL(20,2) NOT NULL, status TINYINT NOT NULL DEFAULT 0, biz_id VARCHAR(64) NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_biz_id (biz_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

transfer_order 表记录一次完整的转账请求。status 用 TINYINT,0 表示处理中、1 表示成功、2 表示失败。biz_id 是整个幂等设计的地基,UNIQUE KEY uk_biz_id 保证同一笔业务单号只能插入一次,数据库层面的唯一约束比应用层判断可靠得多。

2.3 唯一键、索引与 decimal:让数据库替你把住第一道关

很多人在设计表时把索引当成加速查询的工具,但在转账系统里,唯一键是一道数据完整性防线。biz_id 的唯一键能拦截重复请求,account 表的 user_id 唯一键能防止一个人开多个账户导致的对账混乱。哪怕应用层逻辑写漏了,数据库也能把脏数据挡在外面。

索引方面,account_flow 表一定要建 idx_account_id 和 idx_biz_id。对账时最常做的操作就是“按账户查一段时间内的所有流水”,没有这个索引,数据量一大就是全表扫描。transfer_order 表除了 biz_id 唯一键,还可以加一个 out_account_id + created_at 的联合索引,用于查询某个账户的历史转账记录。

decimal 类型的参数也要注意。DECIMAL(20,2) 表示最长 20 位数字,小数点后保留 2 位,最大支持到 10 的 18 次方,日常模拟完全够用。如果将来要做积分、加密货币这类需要更多小数位的系统,可以改成 DECIMAL(30,8),但数据库计算开销会略高。记住一条原则:金额字段一律用定点数,余额和流水的精度保持一致,否则对账时你会被 0.01 的差异卡到凌晨两点。

3. 核心转账服务:用本地事务把扣款和入账绑在一起

表建好之后,接下来写真正的转账逻辑。核心就一句话:扣款和入账必须在一个事务里,要么都成功,要么都失败。这一章用 Spring Boot + MyBatis 的常见写法,把整个过程拆开讲清楚。

3.1 先扣款还是先入账:转账事务的操作顺序

在一个事务里,先扣款还是先入账,结果都一样,但有一个细节会影响性能:锁的顺序。如果事务 A 先从账户 1 扣款再给账户 2 入账,事务 B 先从账户 2 扣款再给账户 1 入账,两个事务互相等对方释放锁,就会死锁。解决办法是:所有转账操作都按账户 id 升序加锁,或者干脆先锁转出账户再锁转入账户,并且全系统保持一致。

常见的做法是先扣转出方的钱,再给转入方加钱。这样做的直觉是:转出方余额不足时,事务可以直接抛异常回滚,不需要先动转入方。但注意,扣款前必须用 SELECT ... FOR UPDATE 锁住账户行,否则两个请求同时读到余额 100,各自扣 80,最后余额变成 -60。

3.2 转账核心方法的代码实现:Spring 事务版

下面是一段可以直接跑通的 Spring 服务方法,核心逻辑都在事务里:

@Service public class TransferService { private final AccountMapper accountMapper; private final TransferOrderMapper transferOrderMapper; private final AccountFlowMapper accountFlowMapper; public TransferService(AccountMapper accountMapper, TransferOrderMapper transferOrderMapper, AccountFlowMapper accountFlowMapper) { this.accountMapper = accountMapper; this.transferOrderMapper = transferOrderMapper; this.accountFlowMapper = accountFlowMapper; } @Transactional(rollbackFor = Exception.class) public void transfer(TransferCommand cmd) { if (cmd.getAmount().compareTo(BigDecimal.ZERO) <= 0) { throw new IllegalArgumentException("转账金额必须大于 0"); } Account out = accountMapper.selectByIdForUpdate(cmd.getOutAccountId()); if (out == null) { throw new AccountNotFoundException("转出账户不存在"); } if (out.getBalance().compareTo(cmd.getAmount()) < 0) { throw new InsufficientBalanceException("余额不足,当前余额 " + out.getBalance()); } Account in = accountMapper.selectByIdForUpdate(cmd.getInAccountId()); if (in == null) { throw new AccountNotFoundException("转入账户不存在"); } accountMapper.changeBalance(out.getId(), cmd.getAmount().negate()); accountMapper.changeBalance(in.getId(), cmd.getAmount()); transferOrderMapper.updateStatus(cmd.getBizId(), 1); accountFlowMapper.insert(buildFlow(out.getId(), cmd.getAmount().negate())); accountFlowMapper.insert(buildFlow(in.getId(), cmd.getAmount())); } }

selectByIdForUpdate 是 MyBatis 的查询方法,对应的 SQL 是SELECT * FROM account WHERE id = #{id} FOR UPDATE。FOR UPDATE 会在事务内锁定这行数据,直到事务提交或回滚才释放。两个并发请求同时转同一笔钱时,第二个请求会阻塞在锁上,等第一个请求完成后再继续,从根上避免了余额扣成负数。

changeBalance 对应的 SQL 是:

UPDATE account SET balance = balance + #{delta} WHERE id = #{id}

delta 是负数就是扣款,正数就是入账。这里直接用balance = balance + delta,而不是先 select 再 update,避免读到旧值。事务内已经用 FOR UPDATE 锁了行,这条 update 一定操作的是最新数据。

流水插入放在事务的最后面。这样做的原因是:只要前面任何一步抛异常,事务整体回滚,流水也不会落库。如果你把流水插入放在扣款前面,万一入账失败,流水还在库里,对账就会多出一笔没有实际发生的变动。

3.3 隔离级别与连接池参数:事务之外的三个配置

@Transactional 注解默认有几个参数需要关注。rollbackFor 必须设成 Exception.class,否则方法抛出 RuntimeException 以外的异常时,Spring 默认不回滚,钱就悄悄扣掉了。我在模拟系统里见过只抛 Exception 不回滚的情况——因为默认只捕获 RuntimeException。

事务超时建议设置。模拟系统里单笔转账一般几十毫秒,但在压测时如果连接池排队,事务可能长时间不释放。可以用@Transactional(timeout = 5)限制单笔事务最多 5 秒,超过直接抛异常回滚。

连接池配置也要匹配事务模型。以 HikariCP 为例,maximumPoolSize 建议设为核心线程数的 2 倍左右。太小会导致请求排队,事务等待时间变长;太大会让数据库连接数打满,反而拖垮 MySQL。下面是一组本地模拟常用的配置:

参数推荐值说明
maximumPoolSize10本地模拟 4 核 8G 足够
minimumIdle2保持最少空闲连接
connectionTimeout30003 秒拿不到连接就失败
transactionTimeout5000事务执行超时上限

隔离级别方面,MySQL 默认的 REPEATABLE_READ 在转账场景里够用,因为行锁已经保证了并发安全。如果你用的是 PostgreSQL,默认 READ_COMMITTED 也行。不要在模拟阶段去调全局隔离级别,那会让问题变复杂,先确保锁和事务边界正确,隔离级别的调优留到压测阶段再说。

4. 高并发下的转账:乐观锁、幂等与重试的落地配置

上一章用 FOR UPDATE 悲观锁控制了并发,但悲观锁有一个代价:所有请求串行排队,吞吐量上不去。模拟银行账户转账系统在压测时很容易暴露出这个问题。这一章讲两种替代方案:乐观锁扛并发,幂等键挡重试。

4.1 并发扣款为什么会翻车:丢失更新与余额为负

先看一个典型事故。账户余额 100,两个请求同时要给这个账户各扣 80。如果代码是先SELECT balance再UPDATE balance = 20,两个请求都读到 100,各自算出 20,后提交的覆盖先提交的,最终余额是 20 而不是 -60。这还算好的,更糟的情况是第二个 update 把余额改成 -60,风控直接报警。

丢失更新的根源是“读-改-写”三步不是原子的。FOR UPDATE 能解决,但会把并发请求变成串行。乐观锁的思路是:不提前锁行,而是在 update 时带一个版本号条件,只有版本号匹配才更新成功,否则说明数据已经被人改过,需要重试。

4.2 乐观锁扣款:update where version 的写法与重试

乐观锁的核心是一条条件更新的 SQL:

UPDATE account SET balance = balance - #{amount}, version = version + 1 WHERE id = #{accountId} AND version = #{oldVersion}

这条 SQL 的意义是:只有在当前版本号还是你读到的那个版本号时,扣款才生效。如果有并发请求先改了这行,version 已经 +1,你的 update 影响行数就是 0,说明这次扣款失败。此时不要直接报错,而是重新读取余额和版本号,再次尝试扣款。

public boolean deductWithOptimisticLock(Long accountId, BigDecimal amount, int retryTimes) { for (int i = 0; i < retryTimes; i++) { Account account = accountMapper.selectById(accountId); int rows = accountMapper.deductByVersion( accountId, amount, account.getVersion()); if (rows == 1) { return true; } // 有人抢先改了数据,睡 20ms 后重试 Thread.sleep(20); } return false; }

重试次数一般建议 3 次,间隔 20ms。超过重试次数仍然失败,就直接抛异常让用户稍后重试,不要无限循环。重试的核心理由是乐观锁失败属于正常业务冲突,不是系统故障。压测时你会发现,乐观锁的吞吐量比悲观锁高不少,代价是失败的请求需要额外读一次数据库,整体 CPU 占用会略高。

真正到生产环境时,很多人会把版本号换成更新时间戳。思路一样:UPDATE account SET balance = balance - #{amount} WHERE id = #{accountId} AND updated_at = #{oldUpdatedAt}。时间戳的精度要够,否则两个请求在同一毫秒内执行时会误判。我个人的习惯是保留 int 版本号,简单直观,排查问题时一眼能看出冲突次数。

4.3 幂等控制:用唯一业务流水号挡住重复转账

并发之外,还有一个隐蔽的坑:客户端重试。用户点了一下转账按钮没反应,又点了一下,或者支付网关超时后自动重发,同一笔转账可能被提交两次。如果没有幂等机制,100 块会变成扣 200 块。

幂等的核心是业务流水号 biz_id。客户端在发起转账时生成一个全局唯一编号(UUID 或雪花 ID),服务端收到请求后先把 biz_id 插入 transfer_order 表,利用唯一键拦截重复请求:

try { transferOrderMapper.insert(TransferOrder.builder() .bizId(cmd.getBizId()) .outAccountId(cmd.getOutAccountId()) .inAccountId(cmd.getInAccountId()) .amount(cmd.getAmount()) .status(0) .build()); } catch (DuplicateKeyException e) { // 同一个 biz_id 已经处理过,直接返回成功,避免重复转账 return; }

这段代码要放在事务的最前面。第一次请求插入成功,走后面的转账逻辑;第二次请求插入时主键冲突,被数据库挡下来,直接返回“已处理”。这样即使客户端重发一百次,也只会有一次真正扣款。

幂等键的生成也有讲究。如果是用户主动转账,可以用用户 id + 时间戳 + 随机数生成 biz_id;如果是回调触发的转账,直接用上游系统的交易流水号作为 biz_id,天然幂等。模拟系统里我建议用 UUID,简单且不用担心重复。

需要特别注意的一个边界:插入 transfer_order 和真正的转账在同一个事务里,一旦转账失败回滚,order 记录也会回滚,所以客户端重试时还能重新插入。如果你把幂等插入放在事务外,转账失败后 order 还在,重试会被当成已处理跳过,用户钱没转出去还以为成功了。这个顺序问题我后面在排查章节还会再提。

5. 模拟银行账户转账系统的常见问题排查:5 个必踩的坑

模拟系统做得再完善,跑起来之后依然会踩坑。这一章挑 5 个我在实际排查中最常遇到的问题,按“现象 → 原因 → 解决”写清楚,每一条都是血泪经验换来的。

5.1 转账显示成功,对方余额没变

现象:调用转账接口返回成功,transfer_order 表里状态也是成功,但转入账户的 balance 没有增加。

原因:最常见的原因是事务实际没有提交。Spring 的 @Transactional 默认只在方法正常返回时提交,如果方法内部把异常 catch 住吞掉了,数据库回滚了,但调用方不知道,还以为成功了。另一个可能是配置了事务管理器但没有生效,比如 Spring Boot 项目里少了@EnableTransactionManagement,或者方法被同类内部调用,事务代理没生效。

解决:先查 account_flow 表,看转入账户有没有对应流水。没流水说明转账根本没落库,直接查日志里有没有被吞掉的异常。然后检查事务注解的方法是不是被外围 catch 了,被吞了就改成抛出异常或在 catch 里手动TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()。同类内部调用的问题,把方法拆到另一个 Service 类里,或者自己注入代理对象,让事务注解生效。

5.2 余额变成负数

现象:账户余额出现负数,转账逻辑明明判断过“余额不足就报错”,但还是穿过去了。

原因:判断余额和扣款不是原子的。两个并发请求同时读到余额 100,都通过了“余额是否大于 80”的检查,然后各自扣款,最后变成 -60。我在前面章节说过,这个问题的根源是读取和更新之间存在时间窗口。

解决:用SELECT ... FOR UPDATE锁行,或者用乐观锁的 version 条件更新。最省事的方案是把余额检查直接写进 SQL:UPDATE account SET balance = balance - #{amount} WHERE id = #{id} AND balance >= #{amount},影响行数为 0 就说明余额不足。这样连查询都省了,判断和扣款一步完成,任何并发场景都不会扣成负数。

5.3 同一笔转账执行了两次

现象:用户一笔 100 元的转账,在 account_flow 里出现了两条扣款记录,transfer_order 表里也有两条记录,只是 biz_id 不同。

原因:客户端没有做幂等,或者服务端没有按 biz_id 去重。更隐蔽的情况是:服务端处理超时返回给客户端“失败”,客户端自动重试,但第一次请求其实已经提交成功了,只是响应在网络上丢了,结果重试让同一笔业务被处理两遍。

解决:给 transfer_order 表加 biz_id 唯一键,并在事务最前面插入订单记录。插入冲突时直接返回成功。记住我前面强调的:幂等插入一定要和转账在同一个事务里,否则转账失败后重试会被误判为“已处理”。另外,业务侧可以约定:同一用户对同一账户的转账,在 30 秒内金额相同就自动拦截,这个兜底策略在模拟系统里很好用。

5.4 并发压测时出现死锁

现象:用 JMeter 开 100 个线程同时转账,数据库日志里出现 Deadlock found 错误,一堆请求失败。

原因:两个事务各自持有一行数据的锁,又去申请对方的锁。比如事务 A 先锁账户 1 再锁账户 2,事务 B 先锁账户 2 再锁账户 1,互相等待,InnoDB 检测到死锁后强制回滚其中一个事务。

解决:统一加锁顺序。最常用的是按账户 id 排序,不管转出还是转入,都先锁 id 小的再锁 id 大的。我的习惯是在 service 入口处先对账户 id 排序,然后按排好的顺序获取锁。这样任何两个事务请求锁的顺序都一致,死锁自然消失。如果业务上必须保持“先转出后转入”的语义,也要在代码层保证全系统唯一的顺序约定,并在压测前加一段死锁检测的日志,方便定位是哪两条 SQL 互相等待。

5.5 对账不平,总账少了钱

现象:把所有账户的 balance 加总,和初始总金额对不上,少了 50 元。或者某账户的 balance 与 account_flow 最近一条的 balance_after 不一致。

原因:漏记了流水。常见场景是事务里只改了 balance,没有插入 account_flow,或者流水插入语句被误放在事务外面,在事务提交前崩溃导致流水丢失。也可能是有人手工在数据库里改了 balance,没有同步写流水。

解决:写一个对账脚本,每天扫描一次。检查逻辑分两层:第一层,遍历所有账户,把 account_flow 按 account_id 分组求和,加上初始余额,必须等于当前 balance;第二层,把账户表余额加总和所有转账订单的金额差比对。两层都通过才能说账是平的。模拟系统里我通常每跑完一批压测就手动执行一次对账 SQL,什么时候对不平了,就用那个不平的账户 id 去 flow 表里逐条核对,基本都能定位到漏掉的那笔流水。

6. 从模拟到生产:多账户转账、冲正与验证技巧

模拟系统跑通、并发问题也处理完之后,最后一步是把方案往真实业务方向延伸一点点。不需要做分布式事务,但至少要知道两个关键设计:多账户转账怎么拆,冲正怎么做。

6.1 多账户转账:把单事务变流程

一次转给多个账户,不能简单地在一个事务里循环扣款,因为中间某一步失败会导致整个事务回滚,用户体验很差。常见做法是拆成“冻结 → 批量入账 → 确认解冻”三步。先冻结转出方的钱,然后逐个给转入方入账并写流水,全部成功后再扣减冻结额。任何一步失败,就触发撤销已入账的流水。这套思路在模拟系统里可以用一张 frozen 表和定时任务实现,不需要引入消息队列。

6.2 冲正机制:给转账系统留一剂后悔药

冲正就是反向流水。人工发现一笔转错账后,不要直接改 balance,而是再插一条金额为负的转账记录,走一遍正常的转账流程把错误抵消。这样所有金额变动都在流水里留痕,对账永远平。我给自己定的一个规矩是:任何手动调整余额的操作都必须通过“冲正订单 + 新流水”完成,绝不允许直接 update balance。守住这条规矩,能省掉无数个对账深夜。

6.3 验证方法:用并发脚本和异常注入检验系统

我习惯在本地模拟环境里跑三个验证:先用 JMeter 开 50 个线程各转 100 笔,结束后跑对账 SQL,余额必须分毫不差;再手动构造“转入账户不存在”和“余额不足”两种异常,确认事务回滚且没有残留流水;最后用断电测试——事务执行到一半时 kill 掉数据库连接,看重启后 transfer_order 的状态和流水是否一致。这三关过了,模拟银行账户转账系统才算真正能看。

这套从表结构、事务、并发到排查的顺序,我每次做转账类需求都会重新走一遍。最大的教训是:永远不要在没想好幂等和流水设计之前就写 update 语句,否则后面都是还债。希望帮到你。

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

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

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

立即咨询