简介:这是一份面向Java初学者与进阶学习者的桌面端实战项目源码,以“模拟银行转账”为主题,用Swing构建图形界面,通过集合类模拟数据库存储账户信息,帮助读者理解分层架构在真实业务中的落地方式。项目按表示层、业务逻辑层与数据访问层组织代码,涵盖转账校验、余额更新、异常处理、单元测试及工厂、单例等设计模式的运用,并涉及Git版本控制与代码规范。压缩包共23个文件,全部为java源码,整体约19KB,按action、ui、dao、biz、entity、exception等模块划分,结构清晰,便于按层阅读与二次修改。目前已有7248人学习下载,适合希望从零梳理Java分层开发、集合应用与Swing界面编程的读者参考,也可作为课程设计或练手项目的骨架。
1. 模拟银行转账:为什么它是并发编程最狠的面试题
很多人第一次接触“模拟银行转账”,是在面试里被要求手写一个转账函数。看起来简单得离谱——A 账户减 100,B 账户加 100,完事。但真正写下去就会发现,单线程下跑一万次都没问题,一上多线程就出现总额对不上、余额变负数、甚至死锁卡死。这不是玄学,这是并发编程里最经典的一类问题:共享状态 + 多步操作 + 无原子性保障。
模拟银行转账的核心价值在于,它用一个极简的业务场景,把锁、事务、隔离级别、幂等、死锁检测这些分布式系统的硬骨头全串起来了。适合谁?适合刚学完线程基础想找个练手项目的开发者,也适合工作几年但没系统梳理过并发控制的工程师。下面我从零开始,把这个模拟系统拆成能跑、能测、能排错的完整方案。
2. 先定模型再写代码:账户、流水、锁粒度怎么选
2.1 账户模型的最小字段集与余额校验规则
一个能支撑转账模拟的账户模型,至少要有四个字段:账户 ID、余额、版本号、状态标记。余额用整数分而不是浮点数,这是血泪经验——浮点数在多次加减后会出现精度漂移,导致对账永远差几分钱。版本号用于乐观锁场景,状态标记用于冻结账户或标记异常。
import threading from dataclasses import dataclass, field @dataclass class Account: account_id: str balance: int # 单位:分 version: int = 0 frozen: bool = False lock: threading.Lock = field(default_factory=threading.Lock) def can_transfer(self, amount: int) -> bool: # 转出前校验:未冻结且余额充足 return not self.frozen and self.balance >= amount这里把lock直接挂在账户对象上,是为了后面做细粒度锁。can_transfer只做只读判断,不加锁,因为真正的原子性要靠外层转账逻辑保证。余额用int存分,展示时再除以 100,这个习惯能省掉后面无数对账麻烦。
2.2 转账操作的原子性边界:从单锁到双锁的演进
最朴素的转账实现是给整个转账函数加一把全局锁,所有账户共用。这样确实不会出错,但吞吐量极低,因为任意两笔不相关的转账也会互相阻塞。常见做法是给每个账户一把锁,转账时同时锁住转出和转入账户。但这里有个致命问题:如果 A 转 B 时锁了 A 再锁 B,B 转 A 时锁了 B 再锁 A,两个线程就会互相等待,形成死锁。
def transfer_naive(from_acc: Account, to_acc: Account, amount: int): with from_acc.lock: with to_acc.lock: if not from_acc.can_transfer(amount): raise ValueError("余额不足或账户冻结") from_acc.balance -= amount to_acc.balance += amount这段代码在单方向转账时没问题,但一旦出现双向并发就会翻车。解决办法是按账户 ID 排序后再加锁,保证所有线程获取锁的顺序一致。这是模拟银行转账里最值得记住的一个技巧:锁顺序全局统一,死锁自然消失。
2.3 用账户 ID 排序法消除死锁的完整实现
def transfer_ordered(from_acc: Account, to_acc: Account, amount: int): # 按 account_id 字典序决定加锁顺序,避免循环等待 first, second = sorted([from_acc, to_acc], key=lambda a: a.account_id) with first.lock: with second.lock: if not from_acc.can_transfer(amount): raise ValueError("余额不足或账户冻结") from_acc.balance -= amount to_acc.balance += amount from_acc.version += 1 to_acc.version += 1逻辑说明:sorted保证无论传入顺序如何,加锁顺序永远一致。参数上,amount必须是正整数,实际业务里还要校验不能超过单笔限额。版本号自增是为了配合乐观锁做后续的冲突检测。这个实现能扛住双向并发,但还没解决转账失败后的回滚问题——如果扣款成功、入账时抛异常,钱就凭空消失了。所以下一步要引入事务日志。
3. 把转账做成可回滚事务:日志、快照与补偿
3.1 流水表结构设计与写入时机
要支持回滚,必须在扣款前先写一条“待确认”流水,记录转出账户、转入账户、金额、状态。只有扣款和入账都成功,才把流水标记为“完成”;任何一步失败,就根据流水做反向补偿。
import uuid import time class TransferLog: def __init__(self): self.records = {} self.lock = threading.Lock() def create_pending(self, from_id: str, to_id: str, amount: int) -> str: tx_id = str(uuid.uuid4()) with self.lock: self.records[tx_id] = { "tx_id": tx_id, "from": from_id, "to": to_id, "amount": amount, "status": "pending", "ts": time.time() } return tx_id def mark_done(self, tx_id: str): with self.lock: self.records[tx_id]["status"] = "done" def mark_failed(self, tx_id: str): with self.lock: self.records[tx_id]["status"] = "failed"流水表用字典模拟,生产环境会换成数据库表。create_pending必须在加锁前调用,且要保证流水写入成功后再动余额。写入时机很关键:先写日志再改余额,这叫预写日志,是数据库里保证崩溃恢复的基本手段。
3.2 扣款与入账分离后的补偿逻辑
def transfer_with_log(from_acc: Account, to_acc: Account, amount: int, log: TransferLog): tx_id = log.create_pending(from_acc.account_id, to_acc.account_id, amount) first, second = sorted([from_acc, to_acc], key=lambda a: a.account_id) try: with first.lock: with second.lock: if not from_acc.can_transfer(amount): raise ValueError("余额不足或账户冻结") from_acc.balance -= amount to_acc.balance += amount from_acc.version += 1 to_acc.version += 1 log.mark_done(tx_id) except Exception as e: # 补偿:如果扣款已发生但入账失败,需要反向操作 # 这里简化处理,实际要根据流水状态判断 log.mark_failed(tx_id) raise e这段代码把流水创建放在加锁之前,保证即使后续失败也有记录可查。补偿逻辑在真实系统里要更精细:需要判断扣款是否已经执行,如果执行了就要加回去。这里为了演示清晰做了简化,但核心思想不变——任何一步失败,都要有据可查、有路可退。
3.3 用版本号做乐观锁重试的适用场景
悲观锁适合写多读少、冲突激烈的场景;乐观锁适合读多写少、冲突较少的场景。模拟银行转账里,如果大部分转账集中在少数热门账户上,悲观锁更稳;如果是大量小额分散转账,乐观锁吞吐更高。
def transfer_optimistic(from_acc: Account, to_acc: Account, amount: int, max_retry: int = 3): for _ in range(max_retry): v_from = from_acc.version v_to = to_acc.version if not from_acc.can_transfer(amount): raise ValueError("余额不足或账户冻结") # 模拟 CAS 操作:版本号未变才提交 if from_acc.version == v_from and to_acc.version == v_to: from_acc.balance -= amount to_acc.balance += amount from_acc.version += 1 to_acc.version += 1 return raise RuntimeError("乐观锁重试次数耗尽")参数max_retry一般设 3 到 5 次,太多会拖长响应时间,太少在高冲突下容易失败。注意这段代码在 Python 里因为 GIL 的存在并不能真正并行,但逻辑模型可以迁移到 Java 或 Go。乐观锁的坑在于 ABA 问题——版本号如果回绕,可能误判。实际用的时候版本号要用长整型,别用短整型。
4. 压测与对账:怎么验证你的转账模拟没写错
4.1 多线程并发转账的测试脚本
写完了核心逻辑,必须用多线程压测来验证。测试目标有三个:总额守恒、无死锁、无负数余额。
import threading import random def stress_test(accounts, log, rounds=1000, threads=10): errors = [] def worker(): for _ in range(rounds): a, b = random.sample(accounts, 2) amount = random.randint(1, 100) try: transfer_with_log(a, b, amount, log) except Exception as e: errors.append(str(e)) ts = [threading.Thread(target=worker) for _ in range(threads)] for t in ts: t.start() for t in ts: t.join() total = sum(acc.balance for acc in accounts) print(f"最终总额: {total}, 错误数: {len(errors)}") return total, errorsrounds控制每个线程的转账次数,threads控制并发度。跑之前先记录初始总额,跑完之后对比。如果总额变了,说明原子性被破坏;如果程序卡住不退出,说明死锁了;如果出现负数余额,说明校验逻辑有漏洞。
4.2 对账脚本:从流水反推余额是否一致
def reconcile(accounts, log): # 从初始余额和流水反推每个账户的应有余额 expected = {acc.account_id: 0 for acc in accounts} for rec in log.records.values(): if rec["status"] == "done": expected[rec["from"]] -= rec["amount"] expected[rec["to"]] += rec["amount"] for acc in accounts: # 这里假设初始余额为 0,实际要加上初始值 if acc.balance != expected[acc.account_id]: print(f"对账失败: {acc.account_id}, 实际 {acc.balance}, 应有 {expected[acc.account_id]}") return False print("对账通过") return True对账是模拟银行转账里最容易被忽略的一环。很多人跑完压测看总额没变就以为对了,但总额守恒不代表每个账户都对。必须从流水逐笔累加,和账户余额做比对。这个脚本能抓出补偿逻辑里的细微错误。
4.3 用超时机制检测死锁的工程做法
死锁不会报错,只会让程序卡住。工程上常用超时机制来检测:加锁时设置超时时间,超时后抛异常并记录现场。
def transfer_with_timeout(from_acc, to_acc, amount, timeout=2.0): first, second = sorted([from_acc, to_acc], key=lambda a: a.account_id) acquired = first.lock.acquire(timeout=timeout) if not acquired: raise TimeoutError(f"获取锁超时: {first.account_id}") try: acquired2 = second.lock.acquire(timeout=timeout) if not acquired2: raise TimeoutError(f"获取锁超时: {second.account_id}") try: if not from_acc.can_transfer(amount): raise ValueError("余额不足或账户冻结") from_acc.balance -= amount to_acc.balance += amount finally: second.lock.release() finally: first.lock.release()超时时间一般设 1 到 3 秒,太短会误报,太长会拖慢故障发现。注意acquire失败后必须释放已经拿到的锁,否则会制造新的死锁。这个写法比with语句啰嗦,但能精确控制超时和释放顺序。
5. 避坑与排查:模拟银行转账里最容易翻车的五个点
5.1 余额用浮点数导致对账永远差几分
现象:压测跑完总额看起来对,但对账脚本总是报某个账户差 0.01。原因:浮点数二进制表示无法精确表达十进制小数,多次加减后误差累积。解决:余额一律用整数分存储,展示时再格式化。如果必须用小数,用decimal.Decimal并指定精度。
5.2 锁顺序不一致导致死锁且无日志
现象:程序跑着跑着卡死,CPU 占用为 0,没有任何报错。原因:两个线程分别持有对方需要的锁,循环等待。解决:所有转账按账户 ID 排序后加锁,保证全局锁顺序一致。排查时可以用jstack或py-spydump 线程栈,看哪些线程在等锁。
5.3 流水写入与余额修改不在同一临界区
现象:压测偶尔出现总额对不上,但流水记录看起来完整。原因:流水创建在锁外,余额修改在锁内,两者之间有时间窗口,导致流水状态和实际余额不一致。解决:流水创建和余额修改要么放在同一临界区,要么用两阶段提交加补偿。简单做法是把流水创建也放进锁内,代价是锁持有时间变长。
5.4 补偿逻辑重复执行导致余额翻倍
现象:转账失败后重试,发现转出账户被扣了两次。原因:补偿逻辑没有幂等性,同一个流水 ID 被处理多次。解决:补偿操作前先检查流水状态,只有pending状态才执行补偿,执行后立即标记为failed。用数据库的话,加唯一索引或行锁。
5.5 压测线程数过高导致锁竞争剧烈
现象:线程数从 10 加到 100,吞吐量不升反降,错误率飙升。原因:锁竞争导致大量线程上下文切换,实际有效工作时间减少。解决:找到吞吐量拐点,一般线程数设为 CPU 核数的 2 到 4 倍。模拟银行转账的压测重点不是拼线程数,而是验证正确性。
6. 从模拟到生产:把转账逻辑迁移到真实数据库的要点
模拟环境跑通之后,下一步是迁移到真实数据库。核心变化有三个:锁从内存锁变成数据库行锁,流水从字典变成表,补偿从代码逻辑变成事务回滚或定时任务。
数据库转账的典型写法是用SELECT ... FOR UPDATE锁住两行记录,然后在一个事务里完成扣款、入账、写流水。注意锁的顺序依然要一致,否则数据库层面也会死锁。隔离级别建议用READ COMMITTED,太高会影响并发,太低会出现幻读。
BEGIN; SELECT balance FROM accounts WHERE account_id = 'A' FOR UPDATE; SELECT balance FROM accounts WHERE account_id = 'B' FOR UPDATE; UPDATE accounts SET balance = balance - 100 WHERE account_id = 'A' AND balance >= 100; UPDATE accounts SET balance = balance + 100 WHERE account_id = 'B'; INSERT INTO transfer_log (tx_id, from_id, to_id, amount, status) VALUES ('xxx', 'A', 'B', 100, 'done'); COMMIT;这段 SQL 的关键在于FOR UPDATE的顺序要和代码里排序后的顺序一致,且UPDATE要带余额充足条件,防止并发扣款导致负数。如果UPDATE影响行数为 0,说明余额不足,直接回滚。
一个我常用来验证迁移是否成功的技巧:在模拟环境跑 10 万笔随机转账,记录每笔的流水;迁移到数据库后,用同样的随机种子跑一遍,对比最终余额和流水条数。如果完全一致,说明迁移没引入新问题。这个习惯帮我省过好几次后悔药——有次迁移后总额对但某个账户差了 1 分,就是靠逐笔对比抓出来的。
最后说一个我自己的教训:模拟银行转账最容易被轻视的就是对账环节。很多人觉得压测总额没变就万事大吉,结果上线后遇到边界条件才发现补偿逻辑有漏洞。我的习惯是每写一个转账版本,必配一个对账脚本,跑完压测先对账再看性能。这个习惯看起来笨,但能让你在面试和真实项目里都少翻车。希望帮到你。
本文还有配套的精品资源,点击获取