news 2026/10/25 21:46:28

模拟银行账户转账系统:并发扣款、事务回滚与幂等性实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
模拟银行账户转账系统:并发扣款、事务回滚与幂等性实战

简介:这是一份面向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.1+0.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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; 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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

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 READ

MySQL 默认REPEATABLE READ,PostgreSQL 默认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_retry=3): 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 balance=balance-%s, version=version+1 " "WHERE id=%s AND version=%s", (amount, from_id, version) ) if cur.rowcount == 0: conn.rollback() continue # 版本冲突,重试 cur.execute( "UPDATE account SET balance=balance+%s, version=version+1 WHERE id=%s", (amount, to_id) ) conn.commit() return True raise RuntimeError("乐观锁重试次数耗尽")

逻辑说明:max_retry=3是经验值,冲突率低于 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/transfer

transfer_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 也读到 100,A 扣 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 }, timeout=0.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 }, timeout=5).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。这个脚本我每次改完事务逻辑都会强制走一遍,比任何单元测试都直观。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/22 5:30:01

用Claude Code和Vibe Coding一天搞定macOS小工具开发

过去做一个 macOS 小工具&#xff0c;哪怕功能再简单&#xff0c;也要走完“搭建 Xcode 工程 → 写界面 → 写逻辑 → 打包签名 → 找测试用户 → 写文档 → 发布”这一整套流程。对没有 macOS 开发经验的人来说&#xff0c;光是搞清楚 SwiftUI 的MenuBarExtra怎么用、NSApplic…

作者头像 李华
网站建设 2026/10/18 6:32:00

电话语音提醒服务搭建指南:从TTS合成到自动外呼的完整实现

“想我可以打给我&#xff0c;我说的是电话”——这句话放在社交场景里是一句带情绪的表达&#xff0c;但放在技术场景里&#xff0c;完全可以变成一个真正可执行的需求&#xff1a;让程序、脚本或者后台服务在需要找人的时候&#xff0c;直接拨通手机&#xff0c;用语音把话说…

作者头像 李华
网站建设 2026/10/21 20:08:47

JavaWeb华为商城.zip实战:JSP+Servlet+MySQL部署与避坑指南

简介&#xff1a;一份基于JavaWeb技术实现的“华为商城”网上商城系统项目&#xff0c;面向JavaWeb与MySQL学习者&#xff0c;特别适合课程设计、毕业设计或综合实训时参考。资源共2个文件&#xff0c;合计58.24MB&#xff0c;包含RAR格式的完整项目源码包与SQL数据库脚本&…

作者头像 李华
网站建设 2026/10/25 18:27:58

iOS混合轮播组件实现:UICollectionView与AVPlayer生命周期管理避坑指南

简介&#xff1a;面向iOS开发者的视频与图片混合轮播实现方案资源&#xff0c;涵盖UICollectionView自定义轮播与SDCycleScrollView扩展两条技术路径。内容围绕AVFoundation框架的视频播放、AVPlayerLayer嵌入视图、cell复用、手势滑动与无缝轮播展开&#xff0c;适合对Objecti…

作者头像 李华
网站建设 2026/10/14 6:54:55

东华复试OJ刷题复盘:链表合并、括号匹配与最长上升子序列

复试上机刷题这件事&#xff0c;走到第130题的时候&#xff0c;我才真正感觉到“题感”开始成型。每天3题打卡看着简单&#xff0c;难的是持续把每一题都嚼透。今天这篇复盘&#xff0c;针对的是东华复试OJ上第130、131、132三道题&#xff1a;合并两个有序链表、括号匹配、最长…

作者头像 李华