ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

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

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

简介:这是一份面向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 ) ENGINE=InnoDB; -- 流水表:存每一次变动,只增不改 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) ) ENGINE=InnoDB;

逻辑说明: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 balance=balance-%s WHERE id=%s", (amount, from_id)) # 加款 cur.execute("UPDATE account SET balance=balance+%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 balance=balance-%s WHERE id=%s", (amount, from_id)) # 加款 cur.execute("UPDATE account SET balance=balance+%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_retry=3): 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 balance=balance-%s, version=version+1 WHERE id=%s AND version=%s", (amount, from_id, version) ) if affected == 0: conn.rollback() continue # 重试 cur.execute("UPDATE account SET balance=balance+%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" # 后续执行真正的转账,成功后更新 status=1 # ...

逻辑说明:先插入一条status=0的流水,如果唯一键冲突说明已有请求在处理,直接返回。这样即使两个线程同时到达,也只有一个能插入成功。然后执行转账,成功后把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确保任何路径都提交或回滚。连接池配置autocommit=False时尤其要注意。另外,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 status=1 UNION ALL SELECT to_account AS account_id, amount FROM transfer_log WHERE status=1 ) 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 分钱就告警。这个脚本可以每天凌晨跑,也可以每小时跑一次增量。

参数说明:status=1只统计成功的流水。如果存在处理中的流水,需要单独处理。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/transfer

transfer.json里放不同的request_id和账户组合。压测后跑对账脚本,如果余额和流水一致,说明并发控制基本过关。如果不一致,重点查死锁日志和事务隔离级别。

6.3 一个我踩过的坑:隔离级别用 READ COMMITTED 还是 REPEATABLE READ

MySQL 默认是REPEATABLE READ,在这个级别下,SELECT ... FOR UPDATE会锁住符合条件的行,但普通SELECT读的是快照。如果你在事务里先普通SELECT查余额,再UPDATE,可能读到旧数据。我一般会把转账相关的事务隔离级别显式设为READ COMMITTED,这样每次读都是最新已提交数据,配合FOR UPDATE更符合直觉。但READ COMMITTED下会有幻读问题,不过转账场景不涉及范围查询,影响不大。

设置方式:

SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;

或者在连接池配置里指定。改完后一定要重新跑压测和对账,确认没有引入新问题。

转账系统没有银弹,只有一层层的校验和兜底。我自己的习惯是:任何涉及金额的代码,先写对账脚本,再写业务逻辑。因为你知道怎么验证,才敢说怎么做对了。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进