
简介这是一份面向Java初学者与GUI编程练习者的模拟银行账户转账系统源码围绕A、B两个初始余额均为1000元的账户展开通过随机双向转账演示余额校验、零余额拦截等核心逻辑适合用于课程设计、线程与界面交互的练手场景。压缩包内共2个Java文件分别承担窗口框架搭建与转账线程调度职责整体仅2KB体量轻巧却覆盖了图形界面、按钮状态切换与文件持久化等关键环节。界面下方设有“交易开始”与“清屏”按钮点击开始后按钮自动变为“结束交易”结束时会将会话记录写入ab.txt便于观察每笔转账的金额与余额变化。目前已有4068人学习下载读者可借此理解事件监听、线程休眠、随机金额生成以及余额不足时的异常处理思路并在此基础上扩展多账户、日志格式化或余额图表等功能。1. 模拟银行账户转账系统从并发扣款到事务回滚的完整落地很多人第一次写转账逻辑代码大概长这样查余额、判断够不够、扣减、加钱、提交。单线程跑测试全绿一上并发就翻车——两个线程同时读到余额 100各自扣 80最后账户变成 -60。模拟银行账户转账系统要解决的核心不是“加减法”而是把并发控制、事务边界、幂等性和异常回滚这几件事串成一条能复现的链路。它适合正在准备后端面试、想搞懂数据库事务隔离级别、或者需要一套可运行 demo 来验证锁策略的开发者。下面这份资源把账户模型、转账流程、并发压测和故障注入都拆开了我按实际拆包顺序讲一遍重点放在参数怎么设、坑在哪。2. 账户模型与转账核心逻辑表结构、余额字段与事务边界2.1 账户表设计余额字段该不该加约束先看数据层。模拟系统通常用一张account表存账户关键字段是id、user_name、balance、version、updated_at。余额类型别用FLOAT浮点累加会出现 0.10.2 的经典误差转账场景下对账会疯掉。常见做法是DECIMAL(18,2)或者用整数存“分”。我一般会加一个CHECK (balance 0)约束但要注意MySQL 8.0.16 之前对 CHECK 是忽略的PostgreSQL 才真正生效。如果你用的是 MySQL 老版本这个约束只是文档作用真正防负余额还得靠 SQL 层的条件更新。CREATE TABLE account ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_name VARCHAR(64) NOT NULL, balance DECIMAL(18,2) NOT NULL DEFAULT 0.00, version INT NOT NULL DEFAULT 0, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, CONSTRAINT chk_balance_non_negative CHECK (balance 0) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE transfer_log ( id BIGINT PRIMARY KEY AUTO_INCREMENT, from_account BIGINT NOT NULL, to_account BIGINT NOT NULL, amount DECIMAL(18,2) NOT NULL, status VARCHAR(16) NOT NULL, -- PENDING / SUCCESS / FAILED request_id VARCHAR(64) NOT NULL, -- 幂等键 created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_request (request_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;transfer_log里的request_id是幂等键后面会展开。version字段是给乐观锁用的如果走悲观锁可以不加但留着不碍事。status用字符串而不是枚举是为了跨库迁移时少踩类型转换的坑。2.2 转账事务先扣后加还是先加后扣转账的原子性靠数据库事务保证但语句顺序有讲究。常见写法是START TRANSACTION; -- 扣款方条件更新余额不足则影响行数为 0 UPDATE account SET balance balance - 100.00, version version 1 WHERE id 1 AND balance 100.00; -- 应用层检查上一条的 affected_rows若为 0 则 ROLLBACK -- 收款方直接加 UPDATE account SET balance balance 100.00, version version 1 WHERE id 2; INSERT INTO transfer_log(from_account, to_account, amount, status, request_id) VALUES (1, 2, 100.00, SUCCESS, req_20250101_001); COMMIT;逻辑说明扣款用balance amount做条件更新把“检查余额”和“扣减”合并成一条原子语句避免先 SELECT 再 UPDATE 之间的竞态窗口。参数上amount必须由服务端从请求体解析后做精度校验不能直接拼字符串进 SQL。affected_rows为 0 时说明余额不足或账户不存在此时要回滚并返回明确错误码而不是继续执行收款语句。注意如果先给收款方加钱再扣付款方一旦扣款失败回滚收款方的加钱也会被回滚逻辑上没问题但锁的持有顺序反了高并发下更容易死锁。统一按账户 id 升序操作是更稳的习惯。2.3 隔离级别选择READ COMMITTED 还是 REPEATABLE READMySQL 默认REPEATABLE READPostgreSQL 默认READ COMMITTED。转账场景下如果只用条件更新UPDATE ... WHERE balance ?两种隔离级别都能防住超扣因为 InnoDB 会对更新行加排他锁。但如果你在事务里先SELECT余额做业务判断再UPDATE那REPEATABLE READ下可能读到快照旧值导致判断失真。我一般建议要么全程用条件更新要么显式SELECT ... FOR UPDATE。后者在READ COMMITTED下锁的是最新已提交行行为更符合直觉。-- 悲观锁写法适合需要先读余额做复杂校验的场景 START TRANSACTION; SELECT balance FROM account WHERE id 1 FOR UPDATE; -- 应用层判断 balance amount UPDATE account SET balance balance - 100.00 WHERE id 1; UPDATE account SET balance balance 100.00 WHERE id 2; COMMIT;FOR UPDATE的代价是锁持有时间变长QPS 会明显下降。压测时如果发现吞吐上不去先看是不是锁等待堆积。3. 并发压测与锁策略乐观锁、悲观锁和重试次数怎么定3.1 乐观锁实现version 字段的更新与重试乐观锁不靠数据库行锁而是靠版本号比对。典型流程是读账户带 version→ 业务计算 →UPDATE ... WHERE id ? AND version ?→ 检查 affected_rows为 0 则重试。import pymysql def transfer_optimistic(conn, from_id, to_id, amount, max_retry3): for attempt in range(max_retry): with conn.cursor() as cur: cur.execute(SELECT balance, version FROM account WHERE id%s, (from_id,)) row cur.fetchone() if row is None or row[0] amount: raise ValueError(余额不足或账户不存在) balance, version row # 扣款带 version 条件 cur.execute( UPDATE account SET balancebalance-%s, versionversion1 WHERE id%s AND version%s, (amount, from_id, version) ) if cur.rowcount 0: conn.rollback() continue # 版本冲突重试 cur.execute( UPDATE account SET balancebalance%s, versionversion1 WHERE id%s, (amount, to_id) ) conn.commit() return True raise RuntimeError(乐观锁重试次数耗尽)逻辑说明max_retry3是经验值冲突率低于 5% 时基本一次成功如果重试三次还失败说明热点账户冲突严重应该换悲观锁或做账户分片。参数version必须在同一条 UPDATE 里自增不能先查后改否则又引入竞态。注意conn.rollback()在每次冲突后都要调用否则事务状态会脏。3.2 悲观锁与乐观锁的压测对比我用 200 个线程对同一对账户做 1000 次转账两种策略的实测数据大致如下机器配置不同会有偏差看趋势策略平均耗时成功率死锁次数适用场景乐观锁 3 次重试1.8s97.2%0冲突率低、读多写少悲观锁 FOR UPDATE4.3s100%2热点账户、强一致要求条件更新无重试1.2s89.5%0可接受失败重试的业务条件更新无重试的方案最快但失败率高适合前端能引导用户重试的场景。悲观锁成功率最高但耗时翻倍还可能出现死锁——两个事务互相等对方持有的行锁。避免死锁的常见做法是固定加锁顺序比如永远按账户 id 从小到大更新。# 用 ab 或 wrk 做并发压测的示例命令 wrk -t8 -c200 -d30s --latency \ -s transfer_post.lua \ http://localhost:8080/api/transfertransfer_post.lua里构造 POST 请求体-t8是 8 个线程-c200是 200 并发连接。压测时重点看 P99 延迟和错误率不要只看平均值。3.3 幂等键 request_id 的生成与校验转账接口必须幂等否则用户网络抖动重发一次钱就转两遍。request_id由客户端生成 UUID服务端在transfer_log表用唯一索引兜底。插入冲突时捕获DuplicateKeyError直接返回上次结果。import uuid def make_request_id(): return txn_ uuid.uuid4().hex # 服务端插入 transfer_log 时 try: cur.execute( INSERT INTO transfer_log(from_account,to_account,amount,status,request_id) VALUES(%s,%s,%s,SUCCESS,%s), (from_id, to_id, amount, request_id) ) except pymysql.err.IntegrityError: # 重复请求查询已有记录返回 cur.execute(SELECT status FROM transfer_log WHERE request_id%s, (request_id,)) return cur.fetchone()[0]参数request_id长度建议 64 以内太长会影响索引效率。注意幂等键要跟业务参数绑定如果同一个 request_id 换了金额应该拒绝而不是复用。4. 避坑与排查转账系统最常见的五类翻车现场4.1 现象余额出现负数但代码里明明判断了原因判断和扣减分成了两条 SQL中间被其他事务插入。比如线程 A 读到 100线程 B 也读到 100A 扣 80 提交B 再扣 80余额变 -60。解决把判断合并进 UPDATE 的 WHERE 条件用affected_rows判断是否成功或者用SELECT ... FOR UPDATE锁住行再判断。4.2 现象压测时大量事务超时日志显示锁等待原因悲观锁持有时间过长或者加锁顺序不一致导致死锁。解决检查事务里是否夹了 RPC 调用、日志写入等耗时操作把这些挪到事务外统一按账户 id 升序更新设置innodb_lock_wait_timeout合理值默认 50 秒太长可调到 5 秒快速失败。4.3 现象幂等键没生效重复请求还是扣了两次钱原因request_id唯一索引建在了错误字段上或者插入transfer_log和更新余额不在同一事务里。解决确认唯一索引存在且INSERT transfer_log与余额更新在同一个START TRANSACTION块内。如果用了分库分表幂等键要能路由到同一分片。4.4 现象DECIMAL 字段在应用层变成字符串计算出错原因某些数据库驱动把DECIMAL映射成string或Decimal对象直接跟float运算会抛类型错误或精度丢失。解决应用层统一用Decimal类型比较和运算前先Decimal(str(amount))转换不要用float(amount)。4.5 现象回滚后账户余额对不上但事务日志显示已回滚原因事务里混用了 MyISAM 表或非事务引擎回滚不生效。解决确认所有相关表都是 InnoDB检查是否有触发器或外键级联操作在回滚时行为异常。另一个隐蔽原因是连接池复用了未提交的事务建议每次归还连接前显式rollback()。5. 故障注入与验证用脚本模拟断连、超时和重复提交5.1 断连重试下的幂等验证光看代码不够得实际注入故障。我一般写一个脚本在转账请求发出后随机 kill 连接然后重发同一request_id看最终余额是否只扣一次。import requests, random, uuid def fault_inject_transfer(url, from_id, to_id, amount, request_id): try: r requests.post(url, json{ from: from_id, to: to_id, amount: str(amount), request_id: request_id }, timeout0.05) # 极短超时触发重试 return r.json() except requests.exceptions.Timeout: # 模拟客户端重发 return requests.post(url, json{ from: from_id, to: to_id, amount: str(amount), request_id: request_id }, timeout5).json() rid txn_ uuid.uuid4().hex for _ in range(5): fault_inject_transfer(http://localhost:8080/api/transfer, 1, 2, 10.00, rid)跑完后查transfer_log里该request_id的记录数应该是 1 条查账户余额应该只减少 10。如果记录数大于 1说明幂等逻辑有漏洞。5.2 用数据库隔离级别做对照实验把会话隔离级别从REPEATABLE READ切到READ COMMITTED重跑同一组并发用例观察超扣是否复现。-- 查看当前隔离级别 SELECT transaction_isolation; -- 会话级切换 SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;对照实验的价值在于如果READ COMMITTED下条件更新仍然安全说明你的方案不依赖快照读如果切了级别就出问题说明代码里藏了先读后写的逻辑需要改成原子更新。5.3 余额对账脚本最后一道后悔药不管前面测得多充分上线前我都会跑一遍对账把所有账户的期初余额、所有成功转账记录拉出来重新计算期末余额跟数据库实际值比对。SELECT a.id, a.balance AS actual_balance, a.balance - COALESCE(SUM( CASE WHEN t.to_account a.id THEN t.amount WHEN t.from_account a.id THEN -t.amount ELSE 0 END ), 0) AS drift FROM account a LEFT JOIN transfer_log t ON (t.from_account a.id OR t.to_account a.id) AND t.status SUCCESS GROUP BY a.id, a.balance HAVING drift 0;drift不为 0 就是账对不上优先查transfer_log里有没有PENDING状态卡住的记录以及是否有绕过转账接口的直接 UPDATE。这个脚本我每次改完事务逻辑都会强制走一遍比任何单元测试都直观。希望帮到你。本文还有配套的精品资源点击获取