模拟银行转账系统:从事务机制到并发控制与数据一致性
发布时间:2026/9/7 3:59:54来源:尧图网络
简介模拟银行账户转账系统是一份面向Java初学者的课程设计资源解决账户间随机转账、余额校验与交易记录持久化问题。压缩包仅2KB共2个Java文件分别负责图形界面构建与转账线程模拟体现了Swing界面编程、多线程并发、随机数生成及文件写入等核心知识点已有3984人学习。该系统以A、B两个初始金额1000元的账户为模型提供‘交易开始/结束交易’与‘清屏’按钮运行时可随机互转且转账金额受限账户余额归零后自动停止交易结束时将交易记录保存至ab.txt。代码结构简洁清晰适合理解线程安全与账户操作逻辑也可作为Java课程设计或实训报告的参考模板。通过阅读这份源码读者能够掌握如何将界面事件、业务逻辑与数据持久化整合在一个小型项目中并在此基础上扩展更复杂的银行系统功能。 我决定把模拟银行账户转账系统这个项目完整做一遍不是为了应付课程作业而是因为转账几乎是后端开发里最典型、最能串起知识点的业务场景。一个表面上看只有扣钱、加钱两步的操作背后牵涉到数据一致性、并发控制、账目可追溯、异常恢复等一系列问题。这篇文章会把从零搭建这个模拟系统的全过程拆开来讲包括表结构怎么设计、转账代码怎么写、并发场景怎么保证不串账以及我在压测时踩到的一个真实 Bug 的排查过程。适合正在学习事务和锁机制的开发者也适合准备系统设计类面试的同学参考。1. 为什么需要一个模拟银行转账系统1.1 模拟系统与真实系统的边界很多人会觉得模拟银行转账系统只是个练手 demo没什么实际价值。但真实情况是一个真实的转账系统底层要面对资金安全、监管合规、高并发处理、分布式容灾等问题普通人很难直接接触。模拟系统的价值在于它完整保留了转账这个业务领域的核心逻辑同时把真实环境的成本和风险完全剥离。这里说的模拟不是做一个假的页面骗自己而是用真实的后端技术栈实现一个功能完整的转账服务账户管理、余额扣减、流水记录、失败回滚一样都不少。它和真实系统的唯一区别是账户里存的不是真钱所以你敢于测试各种极端情况——比如同一个小号短时间内被狂转一千笔小额转账或者故意在转账过程中杀进程看数据会不会乱掉。1.2 项目推进路线三步走我做完这个项目后建议你也按照下面这个顺序推进每一步都有它的意义第一步跑通最简单的转账接口。能正常转账、余额不足时报错先把主流程打通。第二步加事务、加并发控制。处理高并发下两个请求同时扣一个账户的问题这一步是核心考点。第三步完善流水、对账和异常恢复机制。让系统具备可审计的能力这是工程化落地和面试深挖的重点。我的个人经验是很多人止步于第一步因为觉得能转账就完事了。但真正有价值的东西恰恰在第二、三步面试里聊系统设计时面试官追问的也都是这些细节。2. 数据模型与账户体系设计先想清楚钱怎么记账转账系统的第一件事不是写接口而是设计数据模型。做模拟系统时如果表结构设计得草率后面并发一上来你会发现所有诡异问题都出在表结构上。2.1 账户表为什么余额要用整数存储我最终使用的账户表结构是 MySQL 方言你可以直接参考CREATE TABLE account ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id VARCHAR(64) NOT NULL, balance BIGINT NOT NULL DEFAULT 0 COMMENT 可用余额单位分, status TINYINT NOT NULL DEFAULT 1 COMMENT 1-正常 2-冻结 3-注销, version INT NOT NULL DEFAULT 0 COMMENT 乐观锁版本号, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_user_id (user_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这里有两个关键决策值得展开说一下。第一余额用 BIGINT 类型存分不要用 DOUBLE 或 FLOAT 存元。这是因为计算机里的浮点数在二进制下无法精确表示 0.1 这样的十进制小数你做 0.1 元加 0.2 元这种运算时结果可能是 0.30000000000000004。银行系统对金额误差是零容忍的所以业界的通用做法是把最小单位定为分用整数存储。第二余额和流水要分开永远不要只在一个 balance 字段上改来改去。如果你只改余额有一天账目对不上了你完全无从追溯是哪笔操作导致的。每笔转账都必须有对应的流水记录账目才说得清。2.2 流水表只追加不修改接下来是交易流水表CREATE TABLE account_transaction ( id BIGINT PRIMARY KEY AUTO_INCREMENT, trans_no VARCHAR(64) NOT NULL COMMENT 外部唯一单号, from_account_id BIGINT NOT NULL, to_account_id BIGINT NOT NULL, amount BIGINT NOT NULL COMMENT 转账金额单位分, status TINYINT NOT NULL DEFAULT 0 COMMENT 0-处理中 1-成功 2-失败 3-已回滚, error_msg VARCHAR(255) DEFAULT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, ended_at DATETIME DEFAULT NULL, UNIQUE KEY uk_trans_no (trans_no), KEY idx_from (from_account_id), KEY idx_to (to_account_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;流水表的核心设计原则是只追加不修改。转账过程中先插入一条 status0 的记录表示这笔钱正在处理中整个转账操作结束后再把状态更新为成功或失败。这样做的价值在于即使转账进行到一半系统崩溃重启后你也能根据流水表里处理中的记录做恢复处理要么回滚要么补偿。2.3 外部单号防重复转账的第一道防线trans_no 这个字段非常关键。它的约定是每次转账请求调用方生成一个全局唯一的单号通常用雪花 ID 或 UUID同一个 trans_no 只能成功入账一次。这天然提供了一层防重能力如果客户端网络异常导致重试服务端通过唯一索引就能拦住重复转账。我当时做系统时一开始没把 trans_no 设为必填结果测试时不小心重复提交了一次表单给测试账户多转了 100 元模拟币最后还得手动改数据库修正。加上唯一索引之后这种情况直接从代码层面被拦截了。所以我的建议是不管你用不用分布式 ID 生成器单号字段一定要有唯一约束一定要建。3. 核心转账流程的实现从余额扣减到事务提交数据模型定好之后就可以写核心的转账逻辑了。下面我用 Java Spring 的代码来演示但核心逻辑换成 Go、Python 或其他语言都是同理的。3.1 转账代码的骨架与三个隐藏缺陷最直觉的写法是这样的Transactional(rollbackFor Exception.class) public void transfer(String transNo, Long fromId, Long toId, Long amount) { if (transactionMapper.existsByTransNo(transNo)) { throw new DuplicateTransactionException(重复的转账单号); } if (fromId.equals(toId)) { throw new InvalidTransferException(不能给自己转账); } Account from accountMapper.selectById(fromId); if (from null || from.getStatus() ! 1) { throw new AccountNotAvailableException(转出账户不可用); } Account to accountMapper.selectById(toId); if (to null || to.getStatus() ! 1) { throw new AccountNotAvailableException(转入账户不可用); } accountMapper.decreaseBalance(fromId, amount); accountMapper.increaseBalance(toId, amount); transactionMapper.insert(transNo, fromId, toId, amount, 1); }这段代码看起来没问题但它有一个隐藏缺陷我在调试时被坑过decreaseBalance的 SQL 如果只写成UPDATE account SET balance balance - ? WHERE id ?在余额不足时它也会执行成功把余额扣成负数。正确做法是在 UPDATE 语句后面加一个余额条件把判断余额是否充足和扣减余额合并成一个原子操作UPDATE account SET balance balance - #{amount} WHERE id #{accountId} AND balance #{amount}然后检查受影响行数如果为 0说明余额不足直接抛异常让事务回滚。这种方式比先 SELECT 余额判断够不够再 UPDATE更安全因为在高并发下两个请求可能同时读到同一个余额都判断够然后都执行 UPDATE数据就错了。另一个隐藏缺陷是事务回滚范围。Transactional默认只对 Runtime 异常回滚如果你在代码里 catch 了异常然后正常返回事务是不会回滚的。要么让异常向上抛出要么在 catch 块里手动调用TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()。3.2 事务边界别把外部调用裹进事务很多初学者会把整个转账方法塞进一个事务这本身没有错因为转账就是强一致场景必须保证原子性。但要注意事务边界内的操作应该只包含数据库写操作和必要的查询不要包含外部调用。比如转账成功后给用户发短信通知、推消息到 MQ、调第三方风控接口这些都不应该放在事务里。原因很简单外部调用的耗时不可控如果对方响应慢事务就会一直持有数据库连接高并发下一小会儿就能把数据库连接池打满整个服务就瘫了。我做的模拟系统里加了一个转账成功后打印通知的操作一开始放在事务内压测时发现事务耗时明显变长。把它挪到事务外之后数据库连接占用率立刻降了下来。正确的姿势是事务内只做扣款、入账、写流水事务提交成功后再发通知通知失败也不影响转账本身。3.3 状态机设计成功、失败与回滚转账状态建议设计成这样一个流程请求到达生成 trans_no插入一条 status0 的流水记录事务内完成余额扣减和增加更新流水 status1任何一个环节异常事务回滚更新流水 status2并记录 error_msg流水状态的更新和转账操作必须放在同一个事务里这样才能保证要么都成功要么都失败。我建议状态多预留一个已回滚status3因为后续做分布式事务或补偿逻辑时这个状态能帮你区分这笔交易最终是失败了还是被补偿了。4. 并发转账两个线程同时操作一个账户会怎样这是整个项目里最值得深究的部分也是面试的高频考点。4.1 并发丢更新的复现方式假设账户 A 余额是 100 元同时来了两笔转账一笔转出 80一笔转出 60。两笔都合法吗显然不是因为 80 加 60 大于 100最终只能有一笔成功。但如果你是先 SELECT balance判断够不够再 UPDATE那么两个线程可能同时读到 balance100 元都判断够然后都执行 UPDATE——数据就被覆盖了最后一笔写入的余额成了负值或者错误值。这就是典型的并发丢更新问题。在模拟系统里你只要用多线程并发调用转账接口这个 Bug 几乎必现。我建议你亲手复现一次比看十篇博客都管用。4.2 方案一悲观锁SELECT FOR UPDATEMySQL InnoDB 引擎下SELECT ... FOR UPDATE会给命中行加排他锁直到当前事务提交或回滚才释放。在事务里先锁住转出账户的行再执行余额判断可以保证同一时刻只有一个线程在操作这个账户Transactional(rollbackFor Exception.class) public void transfer(...) { // 其他校验省略 Account from accountMapper.selectByIdForUpdate(fromId); if (from.getBalance() amount) { throw new InsufficientBalanceException(余额不足); } accountMapper.decreaseBalance(fromId, amount); accountMapper.increaseBalance(toId, amount); }这个方法简单直接模拟系统完全够用。代价是行锁会降低并发度——但转账场景本来就是强一致优先牺牲一些并发换取正确性值。4.3 方案二乐观锁version 对比更新如果不想长期占用数据库锁可以用乐观锁账户表里的 version 字段每次更新时加一UPDATE 语句带上 version 条件UPDATE account SET balance balance - #{amount}, version version 1 WHERE id #{accountId} AND version #{oldVersion}受影响行数为 0说明版本已变操作需要重试。这个方案适合并发冲突不严重的场景实现更轻量。但对于转账这种强一致场景我个人的偏好是悲观锁代码更好理解也不会在重试逻辑里引入额外复杂度。4.4 四层防护从余额条件到唯一索引即便并发控制写得再好也建议把所有防护层级都加上形成纵深防御。我把这个系统的四层防护总结成一张表防护层级手段解决的问题第一层UPDATE 条件中带 balance amount余额不足时原子拒绝扣款第二层SELECT FOR UPDATE 行锁同一账户并发转账互斥第三层version 乐观锁无行锁场景下的数据一致性第四层trans_no 唯一索引防止同一笔交易重复提交前文说的 trans_no 唯一索引在这里是最后一道防线。即便并发控制有漏洞唯一索引也会把同一笔转账的重复请求拦住。但要注意并发插入时唯一索引会抛 DuplicateKeyException你需要捕获这个异常并把它转换成重复转账的业务提示而不是让用户看到 500 错误。5. 一次对账不平问题的完整排查过程这部分我想分享一个真实踩坑过程也是这个项目让我印象最深的一个 Bug。5.1 现象500 笔并发转账后总余额少了我写了一个压测脚本模拟 500 笔并发转账结束后跑了一个 SQL 统计所有账户余额总和发现比初始值少了 120 元模拟币。对账不平资金凭空消失。当时我第一反应是并发更新覆盖但检查代码后发现我已经用了 SELECT FOR UPDATE理论上不该丢更新。于是我在本地用多线程重新压测同样的转账逻辑结果真的复现了余额变少的问题。5.2 排查链路从流水定位到隔离级别第二步我打印出每一笔流水的状态发现有一批流水 status 已经是成功但对应账户的余额实际没有变化。这个现象说明事务提交了但更新没生效逻辑上很矛盾。第三步我查了数据库连接池的配置发现我把隔离级别设成了 READ_UNCOMMITTED。这个级别允许一个事务读到另一个事务尚未提交的脏数据余额判断在这样的脏数据基础上自然会产生错误扣减。第四步把隔离级别改成 READ_COMMITTED或用默认的 REPEATABLE_READ重新跑同一批压测脚本对账恢复正常。5.3 为什么隔离级别如此关键这次事故让我真正理解了数据库事务隔离级别的意义。转账场景下最低只能接受 READ_COMMITTED因为它保证读到的都是已提交的数据。实际生产系统里通常用 REPEATABLE_READ 甚至 SERIALIZABLE但每种隔离级别都有适用场景不能无脑上 SERIALIZABLE并发性能会很难看。模拟系统里出现的这个问题放到真实金融系统里就是资金事故级别。这也是为什么我建议每个开发者都要亲手遇见一次——只有被真实问题坑过你才会记住事务隔离级别的威力而不是仅仅停留在背概念。6. 测试用例怎么证明转账系统是对的代码写完了怎么证明它是对的这是工程化落地里非常重要的一环。我设计了下面几类测试你可以直接参考。6.1 功能测试用例清单编号场景预期结果1账户 A 转给 B 100 元余额充足转账成功A 减 100B 加 1002A 余额 50转给 B 100抛出余额不足异常A 和 B 余额不变3A 转给自己明确报错流水不生成4同一 trans_no 重复提交第二次请求被拦截不重复扣款5A 同时向 B 和 C 各转 60A 余额 100只有一笔成功另一笔余额不足这些用例覆盖了主流程和主要异常分支能保证基本功能不出大问题。6.2 并发验证脚本与断言在并发层面我用 CountDownLatch 启动 20 个线程同时向同一个账户转账然后用断言检查最终余额是否与预期一致int threadCount 20; CountDownLatch latch new CountDownLatch(threadCount); for (int i 0; i threadCount; i) { new Thread(() - { try { transferService.transfer(UUID.randomUUID().toString(), A, B, 1L); } finally { latch.countDown(); } }).start(); } latch.await(); // 断言A 减少了 20B 增加了 20如果你用的是 Go可以换成 goroutine 配 WaitGroup如果是 Python可以用 threading.Barrier。核心是让尽可能多的线程同时发起请求才容易暴露并发问题。6.3 对账 SQL让账目自己说话最后一个验证手段是对账脚本。简单但实用-- 所有账户余额总和 SELECT SUM(balance) FROM account WHERE status 1; -- 所有成功流水的金额总和注意转出为负、转入为正 SELECT COALESCE(SUM(CASE WHEN from_account_id IS NOT NULL THEN -amount END), 0) COALESCE(SUM(CASE WHEN to_account_id IS NOT NULL THEN amount END), 0) FROM account_transaction WHERE status 1;第一个查询是当前账户的静态余额总和第二个查询是所有成功流水造成的动态影响总和。这两个结果必须相等。如果不相等说明系统里存在不一致数据需要回溯流水查找原因。我做完这个模拟银行账户转账系统之后的体会是转账业务真正的复杂度不在代码量而在于数据在多个环节之间的流转规则。从表结构设计到事务边界从并发控制到流水对账每一步都有值得深挖的细节。最后再给正在复现这个项目的你一个建议——不要只盯着功能跑通一定要主动制造故障场景比如在转账过程中杀进程、模拟数据库超时、跑高并发压测。只有在异常情况下系统依然能保证账目一致你才算真正理解了做这个项目的意义。本文还有配套的精品资源点击获取
网站建设高端定制企业官网