
简介模拟银行账户转账系统是一份面向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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;transfer_order 表记录一次完整的转账请求。status 用 TINYINT0 表示处理中、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 的写法与重试乐观锁的核心是一条条件更新的 SQLUPDATE 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 条件更新。最省事的方案是把余额检查直接写进 SQLUPDATE 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 语句否则后面都是还债。希望帮到你。本文还有配套的精品资源点击获取