资讯动态

模拟银行转账实战:事务、锁、幂等与对账全解析

发布时间:2026/10/14 3:13:02 来源:尧图企业网站定制
简介这是一份面向Java初学者与进阶学习者的桌面端实战项目源码以“模拟银行转账”为主题用Swing构建图形界面借助集合类模拟数据库存储账户信息帮助读者理解分层架构在真实业务中的落地方式。项目按表示层、业务逻辑层、数据访问层组织涵盖转账校验、余额更新、异常处理、单元测试、设计模式与代码规范等知识点适合作为课程设计、面试复习或分层思想练手的参考案例。资源包共23个文件全部为java源码压缩后约19KB按action、ui、dao、daoimpl、entity、biz等目录划分结构清晰便于按模块阅读与二次修改。目前已有7248人学习下载读者可从中获得一套可运行的转账流程实现、异常体系设计思路以及分层解耦的代码组织范例快速对照理解业务逻辑与界面交互的协作方式。1. 模拟银行转账为什么它是检验后端功底的“试金石”很多人第一次接触“模拟银行转账”是在面试题或者培训班的作业里。题目通常很简洁A 账户扣钱B 账户加钱保证要么都成功要么都失败。看起来简单但真正动手写的时候你会发现它把并发、事务、幂等、锁、日志、对账这些后端核心问题全串起来了。我见过不少工作两三年的开发者写业务 CRUD 很熟练一到转账场景就翻车——不是超卖就是死锁要么就是重复提交导致用户被扣两次钱。这篇文章不讲虚的就围绕“模拟银行转账”这个场景把从表结构设计、事务边界、并发控制到幂等和对账的完整落地路径拆开。适合正在准备面试的后端新人也适合想梳理自己知识体系的熟手。你不需要有真实的银行系统经验只要会写基本的 SQL 和一门后端语言就能跟着把一套可运行的转账核心逻辑搭出来。我会用 Python MySQL 做示例但思路对 Java、Go 同样适用。2. 先把账本和流水设计对表结构决定后面少踩一半坑2.1 账户表与流水表的最小字段集很多人在模拟转账时只建一张account表字段就id、balance。跑单线程没问题一上并发就出鬼。正确的做法是至少两张表一张存当前余额账户表一张存每一次变动流水表。账户表用于快速读取余额和扣减流水表用于追溯和对账。-- 账户表存当前状态 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, -- 乐观锁版本号 created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINEInnoDB; -- 流水表存每一次变动只增不改 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 TINYINT NOT NULL DEFAULT 0, -- 0处理中 1成功 2失败 request_id VARCHAR(64) NOT NULL, -- 幂等键 created_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_request (request_id) ) ENGINEInnoDB;逻辑说明balance用DECIMAL而不是FLOAT因为浮点数在金额计算上会出现0.1 0.2 ! 0.3的经典问题对账时能把你逼疯。version字段用于乐观锁后面会讲怎么用。transfer_log的request_id加了唯一索引这是幂等控制的基石——同一个请求 ID 只能插入一次重复提交直接报唯一键冲突天然防重。参数说明DECIMAL(18,2)表示总共 18 位其中 2 位小数足够存到万亿级别。status用TINYINT而不是ENUM方便后续扩展状态比如增加“冲正”状态。request_id建议由客户端生成 UUID或者服务端根据业务规则拼接长度 64 足够。2.2 为什么流水表要只增不改流水表一旦写入就不应该再更新金额和方向。如果转账失败正确做法是插入一条反向流水或者把状态改为失败而不是去修改原流水的金额。这是审计的基本要求任何对余额的变动都必须有迹可循且历史记录不可篡改。我一般会在代码里把流水表的UPDATE权限都禁掉只允许INSERT和SELECT。另外账户表的balance字段不要用INT存“分”虽然能避免浮点问题但可读性差对账时还要手动除 100。用DECIMAL配合正确的舍入模式ROUND_HALF_UP更直观。如果团队有规范要求存分那就统一存分别一半存元一半存分。3. 用事务把扣款和加款绑在一起隔离级别与锁的选择3.1 一个最简事务版本及其并发缺陷先看一个能跑但并发下会出问题的版本import pymysql def transfer_bad(conn, from_id, to_id, amount): with conn.cursor() as cur: # 查余额 cur.execute(SELECT balance FROM account WHERE id%s, (from_id,)) balance cur.fetchone()[0] if balance amount: raise ValueError(余额不足) # 扣款 cur.execute(UPDATE account SET balancebalance-%s WHERE id%s, (amount, from_id)) # 加款 cur.execute(UPDATE account SET balancebalance%s WHERE id%s, (amount, to_id)) conn.commit()这段代码在单线程下没问题但两个并发请求同时查余额时可能都查到 100 元然后各自扣 80 元最终余额变成 -60 元。这就是典型的“写后写”丢失更新。要解决它要么用悲观锁SELECT ... FOR UPDATE要么用乐观锁版本号要么把隔离级别提到SERIALIZABLE但性能太差不推荐。3.2 悲观锁方案SELECT FOR UPDATE 的正确打开方式def transfer_pessimistic(conn, from_id, to_id, amount, request_id): with conn.cursor() as cur: # 开启事务 conn.begin() try: # 按固定顺序加锁避免死锁 first, second sorted([from_id, to_id]) cur.execute(SELECT id, balance FROM account WHERE id IN (%s,%s) ORDER BY id FOR UPDATE, (first, second)) rows {row[0]: row[1] for row in cur.fetchall()} if rows[from_id] amount: raise ValueError(余额不足) # 扣款 cur.execute(UPDATE account SET balancebalance-%s WHERE id%s, (amount, from_id)) # 加款 cur.execute(UPDATE account SET balancebalance%s WHERE id%s, (amount, to_id)) # 写流水 cur.execute( INSERT INTO transfer_log(from_account,to_account,amount,status,request_id) VALUES(%s,%s,%s,1,%s), (from_id, to_id, amount, request_id) ) conn.commit() except Exception: conn.rollback() raise逻辑说明SELECT ... FOR UPDATE会对选中的行加排他锁其他事务再想锁这些行就会阻塞直到当前事务提交或回滚。这样就能保证查余额和扣款之间没有其他事务插进来。关键点是ORDER BY id和sorted——如果两个事务分别先锁 A 再锁 B另一个先锁 B 再锁 A就会死锁。按固定顺序加锁能打破循环等待条件。参数说明conn.begin()显式开启事务确保后续操作在同一个事务里。FOR UPDATE必须放在事务中才生效如果 autocommit 是开的锁会立即释放。request_id传入流水表配合唯一索引做幂等。3.3 乐观锁方案版本号 重试悲观锁在并发高时容易阻塞如果业务允许重试乐观锁更轻量def transfer_optimistic(conn, from_id, to_id, amount, request_id, max_retry3): for attempt in range(max_retry): with conn.cursor() as cur: conn.begin() try: # 查版本号和余额 cur.execute(SELECT balance, version FROM account WHERE id%s, (from_id,)) balance, version cur.fetchone() if balance amount: raise ValueError(余额不足) # 带版本号更新影响行数为0说明被改过 affected cur.execute( UPDATE account SET balancebalance-%s, versionversion1 WHERE id%s AND version%s, (amount, from_id, version) ) if affected 0: conn.rollback() continue # 重试 cur.execute(UPDATE account SET balancebalance%s WHERE id%s, (amount, to_id)) cur.execute( INSERT INTO transfer_log(from_account,to_account,amount,status,request_id) VALUES(%s,%s,%s,1,%s), (from_id, to_id, amount, request_id) ) conn.commit() return True except Exception: conn.rollback() raise raise RuntimeError(重试次数耗尽)逻辑说明更新时带上version条件如果版本号被其他事务改过affected就是 0当前事务回滚并重试。重试次数一般设 3 次超过就报错让上层处理。注意加款操作没有用乐观锁因为加款不会导致余额不足但为了严谨也可以加上版本号。参数说明max_retry控制重试上限避免无限循环。version字段每次更新加 1不要用时间戳代替时间戳在并发下可能重复。4. 幂等与防重别让用户点两次按钮就扣两次钱4.1 用唯一索引 状态机实现幂等用户在网络卡顿时重复点击提交或者消息队列重投都会导致同一个转账请求被执行多次。幂等的核心是同一个request_id无论调用多少次结果都一样。前面流水表的uk_request唯一索引就是第一道防线。def transfer_idempotent(conn, from_id, to_id, amount, request_id): with conn.cursor() as cur: # 先查是否已处理 cur.execute(SELECT status FROM transfer_log WHERE request_id%s, (request_id,)) row cur.fetchone() if row: if row[0] 1: return already_success elif row[0] 0: return processing else: return failed # 插入处理中记录利用唯一索引防并发 try: cur.execute( INSERT INTO transfer_log(from_account,to_account,amount,status,request_id) VALUES(%s,%s,%s,0,%s), (from_id, to_id, amount, request_id) ) conn.commit() except pymysql.err.IntegrityError: conn.rollback() return duplicate # 后续执行真正的转账成功后更新 status1 # ...逻辑说明先插入一条status0的流水如果唯一键冲突说明已有请求在处理直接返回。这样即使两个线程同时到达也只有一个能插入成功。然后执行转账成功后把status更新为 1。如果转账失败更新为 2 或删除记录根据业务要求。参数说明request_id必须全局唯一建议用UUID或业务前缀时间戳随机数。状态机要定义清楚0 处理中、1 成功、2 失败。查询时如果发现长时间处于 0需要有补偿任务去核对。4.2 数据库唯一索引与分布式锁的取舍有人会问用 Redis 分布式锁行不行行但多引入一个组件就多一个故障点。如果数据库本身能通过唯一索引解决优先用数据库。唯一索引是强一致的Redis 锁在极端情况下主从切换可能失效。我一般只在跨库跨服务时才用分布式锁单库转账用唯一索引足够。5. 避坑与排查转账场景下最容易翻车的 5 个点5.1 死锁现象是数据库报 Deadlock found原因是加锁顺序不一致现象两个并发转账一个从 A 到 B一个从 B 到 A数据库抛出Deadlock found when trying to get lock。原因事务 1 锁了 A 再等 B事务 2 锁了 B 再等 A循环等待。解决所有涉及多行加锁的操作按主键排序后依次加锁。上面的sorted([from_id, to_id])就是干这个的。如果已经死锁数据库会自动回滚其中一个事务捕获异常后重试即可。5.2 余额扣成负数现象是并发后余额小于 0原因是检查与扣款不在同一把锁内现象明明代码里判断了balance amount就抛异常但并发后余额还是负了。原因查余额和扣款之间没有锁两个事务都查到足够余额。解决用SELECT ... FOR UPDATE把查和扣锁在一起或者用UPDATE ... WHERE balance amount让数据库在更新时再判断一次。后者更简洁UPDATE account SET balance balance - 100 WHERE id 1 AND balance 100;如果affected rows 0说明余额不足直接回滚。5.3 重复扣款现象是用户被扣两次原因是幂等键没生效或重试逻辑有误现象用户点击两次或者服务端超时重试导致同一笔转账执行两次。原因request_id没有唯一约束或者重试时生成了新的request_id。解决确保request_id由客户端生成并在整个重试链路中保持不变。服务端收到请求先查流水表已成功直接返回。如果客户端无法生成服务端可以根据用户ID时间窗口金额做哈希但不如客户端 UUID 可靠。5.4 事务未提交导致锁等待超时现象是请求卡住然后报 Lock wait timeout exceeded现象一个事务执行了SELECT ... FOR UPDATE但忘记提交或回滚后续请求全部阻塞。原因代码异常路径没有rollback或者连接池归还连接时事务未结束。解决用try...except...finally确保任何路径都提交或回滚。连接池配置autocommitFalse时尤其要注意。另外innodb_lock_wait_timeout默认 50 秒可以适当调小到 5-10 秒让失败更快暴露。5.5 对账时流水与余额不一致现象是流水总和与账户余额对不上原因是并发下更新丢失或事务隔离级别理解错误现象跑了一天发现SUM(流水)和账户余额不相等。原因可能是某些转账只更新了余额没写流水或者流水写了但事务回滚了余额没回滚。解决所有余额变动必须和流水写入在同一个事务里且流水表只增不改。每天跑一次对账任务用账户初始余额 SUM(收入) - SUM(支出) 当前余额来校验。如果对不上根据request_id追溯。6. 进阶用对账任务和压测验证你的转账系统6.1 写一个对账脚本每天跑一次对账是转账系统的后悔药。不管前面设计得多好没有对账就不敢上线。下面是一个简化的对账脚本def reconcile(conn): with conn.cursor() as cur: # 计算每个账户的流水净额 cur.execute( SELECT account_id, SUM(amount) FROM ( SELECT from_account AS account_id, -amount AS amount FROM transfer_log WHERE status1 UNION ALL SELECT to_account AS account_id, amount FROM transfer_log WHERE status1 ) t GROUP BY account_id ) flow_map {row[0]: row[1] for row in cur.fetchall()} # 读取当前余额 cur.execute(SELECT id, balance FROM account) for acc_id, balance in cur.fetchall(): expected flow_map.get(acc_id, 0) if abs(balance - expected) 0.01: print(f账户 {acc_id} 不一致: 余额{balance}, 流水净额{expected})逻辑说明把转出当作负流水转入当作正流水按账户汇总再和账户表余额对比。差异超过 1 分钱就告警。这个脚本可以每天凌晨跑也可以每小时跑一次增量。参数说明status1只统计成功的流水。如果存在处理中的流水需要单独处理。0.01是容忍的舍入误差因为DECIMAL计算理论上无误差但不同数据库可能有微小差异。6.2 用并发压测暴露问题写完代码别急着上线用ab或wrk模拟 100 个并发同时转账。观察几个指标成功率、平均耗时、数据库死锁次数、余额是否最终一致。我一般会写一个简单的压测脚本# 用 ab 模拟 100 并发总共 1000 次请求 ab -n 1000 -c 100 -p transfer.json -T application/json http://localhost:8000/transfertransfer.json里放不同的request_id和账户组合。压测后跑对账脚本如果余额和流水一致说明并发控制基本过关。如果不一致重点查死锁日志和事务隔离级别。6.3 一个我踩过的坑隔离级别用 READ COMMITTED 还是 REPEATABLE READMySQL 默认是REPEATABLE READ在这个级别下SELECT ... FOR UPDATE会锁住符合条件的行但普通SELECT读的是快照。如果你在事务里先普通SELECT查余额再UPDATE可能读到旧数据。我一般会把转账相关的事务隔离级别显式设为READ COMMITTED这样每次读都是最新已提交数据配合FOR UPDATE更符合直觉。但READ COMMITTED下会有幻读问题不过转账场景不涉及范围查询影响不大。设置方式SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;或者在连接池配置里指定。改完后一定要重新跑压测和对账确认没有引入新问题。转账系统没有银弹只有一层层的校验和兜底。我自己的习惯是任何涉及金额的代码先写对账脚本再写业务逻辑。因为你知道怎么验证才敢说怎么做对了。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑