简介:这是面向Java初学者的多线程图形界面小项目,模拟A、B两个账户各自初始余额为一千元并随机向对方转账,转账金额不得超出余额,余额为零则自动停止交易。项目界面设有“交易开始”“清屏”按钮,按下后控制交易启停,结束交易时将全部交易记录写入ab.txt,完整覆盖了Swing界面搭建、线程运行与同步、文件输出等典型知识点。压缩包共有两个文件,均为Java源文件,体积仅2KB,代码精简、结构清晰,适合学习时逐行分析或作为课程设计参考。目前已有四千零四十一人学习,适合刚掌握Java基础、希望结合界面与多线程做综合练手的开发者。通过该资源可了解如何组织多线程协作、校验账户余额并持久化交易明细,对理解并发环境下的资源竞争也有直观帮助。
1. 模拟银行账户转账系统:先分清“会写”和“敢上线”
写银行转账系统,大多数人的第一版代码是这样的:先把发起方余额查出来,判断够不够,扣掉,再把接收方余额加上。单机单线程一跑,一切正常;一旦并发、断网、重启,余额就对不上了。模拟银行账户转账系统的核心难点不在 CRUD,而在事务边界、行锁顺序和异常补偿。它适合正在做课程设计、毕设或实训项目的同学,也适合想搞明白转账为什么不能只写三行 update 的从业者。看完你会发现,真正值钱的是把账目做成“可核对”的那部分。
2. 建表和初始化数据:账户、流水两张表怎么设计才不容易错
2.1 为什么需要两张表而不是一张表
开户、查余额、转账,最直觉的设计是只建一张 accounts 表,转完直接改余额。这个做法在演示系统里能跑,但有几个问题:第一,转出方扣款成功、转入方入账失败的中间态没有任何记录,数据库回滚后连“发生了什么”都查不到;第二,对账无从谈起,总账对不对只能靠肉眼;第三,审计和演示答辩时,你拿不出一个完整的资金链路。
所以我在设计模拟银行账户转账系统时,坚持用两张表:accounts 存账户当前余额,account_transactions 存每一笔资金变动。转账操作在同一个事务里同时写两张表,account_transactions 不光是日志,它还是对账的原始依据。两张表的字段设计有个容易被忽略的原则:流水表里不要依赖另一个账户的“当前余额”,要记录变化量,而不是记录变化后的值。这个原则在日终核对时会救你一命。
提示:流水表只记变动量(amount 正负),不记变动后的余额。这样日终核对时可以用“初始余额 + 流水变动累加”反推,任何一步错了都能定位。
2.2 建表语句与字段含义
下面是我在这个项目里常用的一套建表语句,数据库用 MySQL 5.7 以上,存储引擎必须 InnoDB,字符集 utf8mb4:
CREATE TABLE accounts ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '账户ID', account_no VARCHAR(32) NOT NULL COMMENT '账号', user_name VARCHAR(64) NOT NULL COMMENT '户名', balance DECIMAL(18,2) NOT NULL DEFAULT 0.00 COMMENT '当前余额', frozen_amount DECIMAL(18,2) NOT NULL DEFAULT 0.00 COMMENT '冻结金额', status TINYINT NOT NULL DEFAULT 1 COMMENT '账户状态:1正常 2冻结 3销户', version INT NOT NULL DEFAULT 0 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_account_no (account_no) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='账户表'; CREATE TABLE account_transactions ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, transaction_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 '转账金额,货币单位元', direction TINYINT NOT NULL COMMENT '资金方向:1转出 -1转入', trans_type VARCHAR(20) NOT NULL DEFAULT 'TRANSFER' COMMENT '交易类型', status TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1成功 2失败 3未知待对账', remark VARCHAR(255) DEFAULT NULL COMMENT '备注', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_transaction_no (transaction_no), KEY idx_from_account (from_account_no), KEY idx_create_time (create_time) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='交易流水表';字段里有几个需要特别说明的地方。account_no 用 VARCHAR(32) 并建唯一索引,主键 id 只是物理主键,业务上禁止拿 id 去关联转出转入关系。transaction_no 是每次转账唯一生成的业务流水号,唯一索引是幂等控制的第一道门槛。frozen_amount 在简单演示里可以不用,但建议保留,后面做冻结/解冻演示时不用改表。amount 用 DECIMAL(18,2),不要用 DOUBLE 或 FLOAT,二进制浮点在做金额比较时会有精度误差,这种坑在面试追问里很常见。
注意:MySQL 在 utf8mb4 下 VARCHAR(64) 能存 64 个字符而不是 64 字节,流水号用字母+数字组合完全够用,不要图省事缩减长度。
2.3 初始化数据时要注意的测试数据组合
初始化数据不要只造两个余额充足的账户,要把边界情况铺开:
INSERT INTO accounts (account_no, user_name, balance, status) VALUES ('622200001001', '张三', 10000.00, 1), ('622200001002', '李四', 0.00, 1), ('622200001003', '王五', 500.00, 2), ('622200001004', '赵六', 88.88, 1);这里四个账户分别覆盖了正常足额、零余额、冻结、小数余额四种情况。特别是零余额账户,用来验证“转给余额为 0 的账户”时入账是否正常;冻结账户用来验证状态校验是否在事务里生效。我见过不少人初始化数据只放两三条都正常的记录,后面验证冻结逻辑时又要临时改数据,效率很低。
初始化之后顺手跑一个总账校验:所有账户余额之和应该等于初始总金额。这一步虽然简单,但在后面每次压测完再跑一次,就能立刻发现有没有多扣钱。总账校验可以直接用SELECT SUM(balance) FROM accounts,把它写成一个独立脚本,比每次手动心算靠谱得多。
3. 转账核心代码:事务、行锁与余额校验的先后顺序
3.1 为什么查询余额也要放在事务里
转账的基本逻辑看起来只有三步:查余额、扣转出方、加转入方。但如果你先查余额判断,再执行两条 update,中间这一步和那一步之间,余额可能已经被别人改掉了。模拟银行账户转账系统虽然只是教学项目,但并发才是它真正的考点。
常见做法是用SELECT ... FOR UPDATE把转出账户行锁住,锁住之后在同一事务里完成余额判断和扣款。这样做的原理是:InnoDB 在可重复读隔离级别下,FOR UPDATE会对命中的索引记录加排他锁,其他事务的更新、加锁读都会阻塞,直到当前事务提交或回滚。注意这里必须带索引,如果 where 条件用的是没有索引的列,InnoDB 会退化到锁全表,演示时倒是不会错,但压测性能和锁范围就不对了。
另一个容易忽略的点:转入账户要不要加锁?如果转出和转入是同一个账户,或者两个账户出现交叉转账,只锁转出不锁转入,在极端并发下可能出现同一账户既被转出又被动用导致的超扣。所以我在代码里会把转出账户和转入账户都锁上,并且按 account_no 排个序再锁,避免死锁。
3.2 Service 层核心代码与参数说明
下面是 Service 层的关键代码,框架是 Spring Boot + MyBatis-Plus,逻辑稍微合并了一下,方便你在课程设计里看懂:
@Service @Slf4j public class TransferService { @Resource private AccountMapper accountMapper; @Resource private TransactionMapper transactionMapper; @Transactional(rollbackFor = Exception.class) public void transfer(String transactionNo, String fromAccount, String toAccount, BigDecimal amount) { // 1. 基础校验:金额必须大于 0,且不超过两位小数 if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) { throw new BizException("转账金额必须大于0"); } if (fromAccount.equals(toAccount)) { throw new BizException("转出账户和转入账户不能相同"); } // 2. 幂等检查:同一个流水号只允许成功一次 if (transactionMapper.existsByTransactionNo(transactionNo)) { throw new BizException("重复的转账流水号"); } // 3. 按账号排序后加锁,避免交叉转账死锁 TransferLock lock = TransferLock.sort(fromAccount, toAccount); AccountRow from = accountMapper.selectForUpdateByAccountNo(lock.getFirst()); AccountRow to = accountMapper.selectForUpdateByAccountNo(lock.getSecond()); // 4. 转出账户状态与余额校验 if (from.getStatus() != 1) { throw new BizException("转出账户状态异常"); } if (from.getBalance().compareTo(amount) < 0) { throw new BizException("余额不足"); } if (to.getStatus() != 1) { throw new BizException("转入账户状态异常"); } // 5. 扣款和入账:直接基于当前余额做算术更新 accountMapper.updateBalanceByAccountNo(fromAccount, amount.negate()); accountMapper.updateBalanceByAccountNo(toAccount, amount); // 6. 写两条资金流水,方向相反 transactionMapper.insert(Transaction.build(transactionNo, fromAccount, toAccount, amount.negate(), -1)); transactionMapper.insert(Transaction.build(transactionNo, fromAccount, toAccount, amount, 1)); } }代码里几个参数要重点说明一下。accountMapper.selectForUpdateByAccountNo对应的 SQL 是SELECT * FROM accounts WHERE account_no = #{accountNo} FOR UPDATE,注意这里不能加LIMIT 1,加了 LIMIT 之后 InnoDB 在部分情况下会先拿锁再过滤,返回的行和预期不一致,这是血泪经验。TransferLock.sort是把两个账号按字典序排好,保证多笔交叉转账对同一组账户加锁的顺序一致,这是防死锁的关键。第 5 步用update ... set balance = balance + #{delta}这种相对更新,而不是把查出来的余额减掉金额再写回,原因很简单:相对更新在锁内执行是安全的,而且能避免脏字段覆盖。
提示:
@Transactional(rollbackFor = Exception.class)必须写,默认只回滚 RuntimeException,遇到检查异常时会提交,转账场景这是致命的。
3.3 Mapper 层 SQL 与事务失效排查
Mapper 对应 SQL 需要注意的细节:
UPDATE accounts SET balance = balance + #{delta} WHERE account_no = #{accountNo} AND status = 1;这里把状态判断拼进了 update 条件里,即使前面校验漏了,这条语句也不会把冻结账户的余额改掉。update 返回的 int 是受影响行数,如果等于 0,说明账户状态异常或不存在,Service 里应该主动抛异常回滚。
事务失效是我在看别人项目时最常见的问题。自调用导致@Transactional不生效(同类里方法 A 调用方法 B,B 上的注解不会生效),异常被 catch 住后没有重新抛出,这些都会让扣款和入账变成两个独立事务。排查方法很简单:在转账方法里故意制造一个异常,看数据库事务日志或断点检查连接是否一直在同一个事务里。另外,Spring Boot 连接 MySQL 时,可以在 JDBC URL 上加useAffectedRows=true,让 update 返回受影响行数而不是 matched 行数,否则有些 MySQL 驱动版本下明明值没变也会返回 1,干扰你判断。
4. 并发与异常排查:最容易翻车的五个场景
4.1 现象、原因、解决的三段式定位思路
模拟银行账户转账系统的压测,通常不是死在 SQL 上,而是死在并发控制上。我把实训中反复出现的五类问题按“现象 → 原因 → 解决”梳理一遍,这套思路也是线上排查的标准姿势。
第一个坑:线程压测后发现两个账户余额合计减少了。现象是扣款成功但入账没生效,总余额对不上。原因往往是转账 Service 里先执行了扣款 update,然后转入方 update 抛异常,但异常被上层 try-catch 吞掉,事务被标记 rollback-only,最终两个 update 都回滚了,扣款单独成功的那笔其实是另一个并发线程的结果。解决:统一不要吞异常,转账方法抛出的任何异常都直接往外抛,由最外层统一处理并返回错误码。
第二个坑:同一对账户高频互转时出现 deadlock。现象是压测到一定并发数,日志里出现Deadlock found when trying to get lock; try restarting transaction。原因是线程 A 先锁 1001 再锁 1002,线程 B 先锁 1002 再锁 1001,两个事务互相持有对方要的锁。解决:就是 3.2 里说的排序加锁,保证锁顺序全局一致;同时把事务里锁外的 IO 操作全部移出事务,锁内只做内存计算和 SQL。
4.2 数据库隔离级别与锁的是非
第三个坑:查询余额的读操作走了非锁定读,导致判断基于旧数据。现象是一个线程刚扣完款,另一个线程查到的还是旧余额,判断通过后把新余额又覆盖了。原因是在默认隔离级别 REPEATABLE READ 下,普通 SELECT 是快照读,不带锁。解决:所有涉及余额判断的读都必须加FOR UPDATE,这也是上面 Service 里把查询和 update 放在同一个事务里的原因。
第四个坑:update 条件里带了余额比对balance = #{oldBalance}但不生效。现象是并发时乐观锁生效,但一致性不如悲观锁稳定。原因:模拟系统用 MySQL 的场景,悲观锁行锁已经能解决并发问题,再叠一层乐观锁会让事务重试逻辑复杂。解决:课程设计场景选择一种锁策略即可,我用FOR UPDATE行锁;如果想展示乐观锁,建议单独做一个版本对比分支,不要把两套逻辑混在转账主流程里。
4.3 数据验证方法与压测参数
第五个坑:压测结果看起来全对,但手动核对发现漏掉了小数。现象是转 0.1 元一百次,最后总余额差了几分钱。原因是 SQL 用了 FLOAT/DOUBLE 或 Java 用了 double 做累计。解决:建表用 DECIMAL,Java 用 BigDecimal,金额参与运算不允许包装类型自动拆箱,一切运算走 compareTo 和 add/subtract。
压测参数我一般是这样配置的:线程数 50,循环 20 次,单笔金额随机 0.01 到 1000.00,压测时间 60 秒。压测前先记录SELECT SUM(balance) FROM accounts,压测后再跑一次,两条记录必须一致。如果多了或少了,优先去 account_transactions 表按 transaction_no 分组,看看有没有同一个流水号出现多次。这个验证步骤看起来土,但实际上比任何测试报告都直接有效。
5. 幂等和对账:把“重复转”和“漏记”挡在门外
5.1 幂等控制:从源头挡住重复提交
模拟银行账户转账系统里,前端按钮双击、HTTP 重试、消息队列重复消费都会导致同一笔转账被提交两次。如果没有幂等控制,账户会收到两倍的钱。我的做法是用业务流水号加唯一索引,转账请求进入时先插入一条状态为“处理中”的流水,事务提交后把状态更新为成功。如果第二次插入相同 transaction_no,唯一索引直接报 DuplicateKeyException,请求被挡下,不会进入扣款逻辑。
需要注意一点:幂等检查和转账扣款要在同一个事务里。如果你先查询流水表判断是否重复,查到没有然后去转账,两次操作之间可能已经被插入了一条相同流水号的数据,然后在 insert 时被唯一索引拦住。这个场景下结果还是对的,但两个事务之间代码会更复杂。我一般直接把唯一索引当成最终防线,代码里开头也留了 existsByTransactionNo 作为快速返回,两个都做,既不冲突也不影响性能。
public String generateTransactionNo(String fromAccount, String toAccount, BigDecimal amount) { String datePart = LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE); String randomPart = UUID.randomUUID().toString().replace("-", "").substring(0, 12); return "TX" + datePart + randomPart; }这里生成流水号的规则是“日期 + 12 位随机串”,用 UUID 前面截断的原因是为了可读性,同时长度控制在 28 个字符以内。虽然 UUID 前 12 位有碰撞可能,但加上日期前缀和唯一索引之后,碰撞概率对模拟系统来说完全可以接受。如果你想更严谨,可以用雪花算法生成 ID,但雪花算法依赖机器 ID 配置,在单机演示里反而增加了部署成本。
5.2 对账 SQL:随时回答“钱去哪了”
转账系统做完,答辩时最出彩的就是一个对账 SQL。它的逻辑是用流水表反推:每个账户今天的期末余额,应该等于昨天期末余额加上今天所有入账金额减去所有转出金额。模拟系统没有“昨天余额”这个概念,所以简化成“所有账户当前余额之和 = 期初初始余额之和”。SQL 这样写:
SELECT (SELECT IFNULL(SUM(balance), 0) FROM accounts) AS current_total, (SELECT IFNULL(SUM(CASE WHEN direction = 1 THEN amount ELSE 0 END), 0) FROM account_transactions WHERE trans_type = 'TRANSFER' AND status = 1 ) AS total_income, (SELECT IFNULL(SUM(CASE WHEN direction = -1 THEN amount ELSE 0 END), 0) FROM account_transactions WHERE trans_type = 'TRANSFER' AND status = 1 ) AS total_outcome;其实这个 SQL 的结果不需要拿纸笔算,你只需要确认一点:current_total是否等于total_income + total_outcome + 期初总余额。如果不相等,再按账户分组看:
SELECT account_no, SUM(CASE WHEN direction = 1 THEN amount ELSE 0 END) AS income, SUM(CASE WHEN direction = -1 THEN amount ELSE 0 END) AS outcome FROM account_transactions WHERE status = 1 GROUP BY account_no HAVING income + outcome > 0.01 OR income + outcome < -0.01;这个分账户的 SQL 会找出每个账户的流水净额,如果某个账户的净额明显不对称,说明这个账户的入账或出账流水漏掉了。HAVING里的 0.01 是为了容忍浮点尾差,实际用 DECIMAL 不会产生尾差,但保留这个条件可以避免初始化数据里本身就不平的情况干扰定位。
提示:对账脚本不要等答辩前才跑。每次压测后、每次手工测试后都跑一遍,养成习惯,余额对不上的问题往往几分钟内就能定位。
5.3 异常流水状态与补偿策略
还有一种情况:转账主流程把事务提交了,但发送通知或写日志时失败。模拟系统里的 account_transactions 状态还是“1 成功”,但业务上认为这笔转账“通知失败”。这种不用回滚,回滚反而会把正确的资金变更撤销。我的做法是加一个补偿任务,扫描状态为 3(未知待对账)的流水,重新核对 accounts 余额,如果余额与流水一致,就补一发通知并把状态改成已知;如果不一致,说明有人手工改动过数据,标记为异常等待人工处理。
这个补偿策略在课程设计里属于加分项,哪怕只是扫一张表、更新两个状态值,也能体现“异常处理”意识。需要注意的是,补偿任务要幂等,任务扫描到未知流水时不能直接改状态,要先查一次 accounts 余额再做决定。补偿任务的执行间隔也别太短,模拟系统里 5 分钟跑一次足够,频繁扫描唯一索引反而会给数据库增加没必要的压力。
6. 日终核对与余额快照:一个小脚本守住账实相符
6.1 余额快照表的建表与写入
模拟银行账户转账系统跑到最后,余额对账要有一个强有力的工具:每日余额快照。原理很简单,每天日终时把每个账户的余额拍一张快照,存进account_daily_snapshot表。第二天日终时,用今天的快照减去昨天的快照,就能算出今天该账户的资金净变化,再和当天的流水净额比对,完全一致说明全天没有错账。
建表语句:
CREATE TABLE account_daily_snapshot ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, account_no VARCHAR(32) NOT NULL, balance DECIMAL(18,2) NOT NULL, snapshot_date DATE NOT NULL, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_account_date (account_no, snapshot_date) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='每日余额快照表';写入快照的方式是在日终时跑一个定时任务,把 accounts 表所有余额按日期插入这张快照表。唯一索引uk_account_date保证同一个账户在同一天只有一条快照,重复执行任务时用INSERT ... ON DUPLICATE KEY UPDATE覆盖,不会产生垃圾数据。
6.2 两行 SQL 完成日终校验
快照表有了以后,日终校验就是两行 SQL 的事。第一天先把当前所有账户余额作为基准快照,第二天正常跑完业务后,执行下面这个对比:
SELECT s1.account_no, s1.balance AS balance_today, s2.balance AS balance_yesterday, (s1.balance - s2.balance) AS balance_diff, IFNULL(t.day_income, 0) - IFNULL(t.day_outcome, 0) AS flow_diff FROM account_daily_snapshot s1 JOIN account_daily_snapshot s2 ON s2.account_no = s1.account_no AND s2.snapshot_date = DATE_SUB(s1.snapshot_date, INTERVAL 1 DAY) LEFT JOIN ( SELECT account_no, SUM(CASE WHEN direction = 1 THEN amount ELSE 0 END) AS day_income, SUM(CASE WHEN direction = -1 THEN amount ELSE 0 END) AS day_outcome FROM account_transactions WHERE create_time >= #{todayStart} AND create_time < #{todayEnd} GROUP BY account_no ) t ON t.account_no = s1.account_no WHERE s1.snapshot_date = #{today} AND ABS((IFNULL(t.day_income, 0) - IFNULL(t.day_outcome, 0)) - (s1.balance - s2.balance)) > 0.01;这段 SQL 的可读性比前面几段差,但逻辑很清楚:拿今天和昨天的快照算余额差,再拿当天的流水净额算交易差,两个差不一致的行就是有问题的账户。查出来的结果如果为空,说明全天账实相符;如果非空,通常问题都出在某个账户有一条状态为失败的流水被误算,或者有人绕过 Service 直接手工改了 accounts 表。
这份资源里带了完整的初始化 SQL 和可运行工程,下载后先把第 5 章的对账 SQL 跑通,再逐步替换成你自己的业务逻辑。日终核对这个习惯对我的启发很大,从那以后我每次写完一个含资金或库存的系统,都会先把“昨日快照、今日快照、当日流水净额”这三件事做成脚本,压测后随手跑一遍。模拟银行账户转账系统是教学项目,不需要真的做日终,但把这个机制放进去,你的成果就不只是“能转账”,而是“能自证没转错”。希望这个思路在你做其他系统时也能帮到你。
本文还有配套的精品资源,点击获取