LOADING
3132 words
16 minutes
生产环境中的事务实践——银行系统上线记(四)

数据库事务系列 · 第 4 篇

引言:上线第一天,系统崩了

柜员小张和小李经过前三周的学习,已经掌握了事务的完整知识体系:

  • 第 1 篇:ACID 四大特性,学会了 BEGIN、COMMIT、ROLLBACK
  • 第 2 篇:隔离级别,亲眼看到了脏读、不可重复读、幻读
  • 第 3 篇:MVCC 和锁机制,学会了记录锁、间隙锁、Next-Key Lock

现在,银行的新转账系统要上线了。小张负责写的转账接口,之前测试环境跑得好好的,隔离级别选的 REPEATABLE READ,代码也很简单——先扣张三的钱,再加给李四。

上线第一天,凌晨两点,小张被电话吵醒。

「系统卡死了,大量转账失败,用户投诉了。」

小张打开监控面板——数据库连接数飙升到上限,大量请求在等待锁释放。

他知道,理论终于要接受现实的检验了。

一、事故一:隔离级别选错,高并发下插入被间隙锁阻塞

1.1 事故现场

小张登录数据库,执行了第 3 篇学过的命令:

SELECT * FROM information_schema.INNODB_TRX;

他发现大量事务都处于 LOCK WAIT 状态,而且都是同一个操作——INSERT INTO transfer_record(插入转账记录)。

1.2 问题分析

小张打开自己用 Prisma 写的代码:

// ❌ 问题代码:使用默认的 REPEATABLE READ
await prisma.$transaction(async (tx) => {
// 1. 查询转出账户
const from = await tx.account.findUnique({
where: { id: fromId }
});
// 2. 查询转入账户
const to = await tx.account.findUnique({
where: { id: toId }
});
// 3. 扣减转出账户
await tx.account.update({
where: { id: fromId },
data: { balance: from.balance - amount }
});
// 4. 增加转入账户
await tx.account.update({
where: { id: toId },
data: { balance: to.balance + amount }
});
// 5. 插入转账记录 ← 问题在这:RR 级别下插入会被间隙锁阻塞
await tx.transferRecord.create({
data: {
fromId,
toId,
amount,
createdAt: new Date()
}
});
});

为什么插入转账记录会被锁住?

他在第 3 篇学过:在 REPEATABLE READ 级别下,InnoDB 会对插入操作加间隙锁。转账记录表的主键是自增 id,所有插入都落在同一个「最大间隙」中,导致所有插入操作排队执行。

┌─────────────────────────────────────────────────────────────────────────────────┐
│ 间隙锁导致插入排队 │
├─────────────────────────────────────────────────────────────────────────────────┤
│ │
│ 事务A 插入 id=101 → 持有间隙锁 │
│ 事务B 插入 id=102 → 等待事务A 释放间隙锁 │
│ 事务C 插入 id=103 → 等待事务B 获取锁 │
│ ... │
│ 事务N 插入 id=200 → 等待前面所有事务 │
│ │
│ 结果:所有插入操作串行化,并发能力归零 │
│ │
└─────────────────────────────────────────────────────────────────────────────────┘

1.3 核心知识点:间隙锁的触发条件

间隙锁在什么情况下触发?

条件是否触发间隙锁
REPEATABLE READ + 范围查询(如 BETWEEN✅ 触发
REPEATABLE READ + 等值查询(如 id = 5⚠️ 仅当记录不存在时触发
REPEATABLE READ + 插入操作✅ 触发(锁定最大间隙)
READ COMMITTED + 任何查询❌ 不触发

关键认知:间隙锁在 RR 级别下无法完全关闭。只要用 RR,插入操作就可能被阻塞。

1.4 解决方案

方案:把隔离级别改为 READ COMMITTED,去掉间隙锁的负担。

// ✅ 方案一:在事务中设置隔离级别
await prisma.$transaction(async (tx) => {
// 先设置隔离级别
await tx.$executeRaw`SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED`;
// 然后执行业务逻辑
const from = await tx.account.findUnique({ where: { id: fromId } });
const to = await tx.account.findUnique({ where: { id: toId } });
// ... 后续业务
});

或者在 Prisma 连接字符串中指定:

.env
DATABASE_URL="mysql://user:pass@localhost:3306/bank?isolation_level=READ_COMMITTED"

Prisma 本身不设置隔离级别,需要在数据库连接中指定。

为什么 READ COMMITTED 对转账业务是安全的?

需求是否必须说明
避免脏读✅ 必须RC 级别能避免脏读
可重复读❌ 不需要每个账户在事务中只读一次
避免幻读❌ 不需要转账记录插入不依赖之前查询结果

结论:不是所有业务都需要 RR。根据业务特征选择隔离级别,而不是无脑用默认值。

二、事故二:死锁——两个转账事务互相等待

2.1 事故现场

改完隔离级别后,系统并发能力恢复了,但日志里出现了新的错误:

Deadlock found when trying to get lock; try restarting transaction

2.2 问题分析

小张执行了第 3 篇学过的死锁排查命令:

SHOW ENGINE INNODB STATUS\G

LATEST DETECTED DEADLOCK 部分,他看到了这样的信息:

事务 1: UPDATE account SET balance = balance - 100 WHERE id = 1
事务 2: UPDATE account SET balance = balance - 100 WHERE id = 2
事务 1: UPDATE account SET balance = balance + 100 WHERE id = 2 -- 等待
事务 2: UPDATE account SET balance = balance + 100 WHERE id = 1 -- 等待

死锁还原:

时间事务A(张三转给李四)事务B(李四转给张三)
T1UPDATE ... WHERE id = 1(锁住张三)
T2UPDATE ... WHERE id = 2(锁住李四)
T3UPDATE ... WHERE id = 2等待事务B
T4UPDATE ... WHERE id = 1等待事务A

死锁的四个必要条件:

条件说明本例中是否满足
互斥资源只能被一个事务占用✅ 行锁是互斥的
持有并等待持有锁的同时等待其他锁✅ 事务A 持有 id=1,等待 id=2
不可抢占已持有的锁不能被强制释放✅ 只能等事务主动提交
循环等待事务之间形成等待环✅ A 等 B,B 等 A

关键认知:死锁的本质是加锁顺序不一致。事务A 先锁 1 再锁 2,事务B 先锁 2 再锁 1。

2.3 解决方案

方案一:统一加锁顺序(推荐)

// ✅ 先统一加锁顺序,再执行业务
await prisma.$transaction(async (tx) => {
// 设置隔离级别
await tx.$executeRaw`SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED`;
// 关键:统一按 ID 升序加锁
const firstId = Math.min(fromId, toId);
const secondId = Math.max(fromId, toId);
// 使用 FOR UPDATE 按顺序锁定账户
const lockedAccounts = await tx.$queryRaw`
SELECT * FROM account WHERE id IN (${firstId}, ${secondId}) ORDER BY id FOR UPDATE
`;
const first = lockedAccounts.find(a => a.id === firstId);
const second = lockedAccounts.find(a => a.id === secondId);
// 然后根据实际 from/to 执行转账
if (fromId === firstId) {
await tx.account.update({
where: { id: firstId },
data: { balance: first.balance - amount }
});
await tx.account.update({
where: { id: secondId },
data: { balance: second.balance + amount }
});
} else {
await tx.account.update({
where: { id: secondId },
data: { balance: second.balance - amount }
});
await tx.account.update({
where: { id: firstId },
data: { balance: first.balance + amount }
});
}
// 插入转账记录
await tx.transferRecord.create({
data: { fromId, toId, amount, createdAt: new Date() }
});
});

核心原则:所有事务以相同顺序访问资源,循环等待不可能发生。

方案二:死锁重试机制(兜底方案)

// ✅ 在应用层实现重试机制
async function transferWithRetry(fromId: number, toId: number, amount: number) {
const MAX_RETRIES = 3;
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
try {
await prisma.$transaction(async (tx) => {
// 转账业务逻辑
});
return; // 成功,退出
} catch (error) {
// 检查是否是死锁错误
if (error.message?.includes('Deadlock') && attempt < MAX_RETRIES) {
console.log(`死锁发生,第 ${attempt} 次重试...`);
await sleep(100 * attempt); // 指数退避
continue;
}
throw error;
}
}
}
function sleep(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms));
}

在第 3 篇学过:死锁是并发事务中常见且正常的现象。数据库会自动检测死锁并回滚其中一个事务。应用层要做的是捕获异常并重试,而不是直接报错。

三、事故三:大事务——月末结息拖垮从库

3.1 事故现场

改完死锁后,系统稳定了一周。但月末批量结息时,从库延迟飙到了 20 分钟,主库 CPU 飙升。

3.2 问题分析

小张找到了结息代码:

// ❌ 问题代码:所有账户在一个事务中处理
await prisma.$transaction(async (tx) => {
const allAccounts = await tx.account.findMany(); // 10 万条
for (const account of allAccounts) {
const interest = account.balance * rate;
await tx.account.update({
where: { id: account.id },
data: {
balance: account.balance + interest
}
});
}
});

这个事务的问题:

问题后果
10 万次更新在一个事务里持有锁 20 分钟
Redo Log 积累了 10 万条提交时刷新磁盘耗时巨大
Undo Log 积累了 10 万条回滚需要遍历全部
主库执行 20 分钟从库也要回放 20 分钟 → 主从延迟

他在第 1 篇学过:原子性是「全做或全不做」。但 10 万次操作做成一个原子操作,一旦失败回滚成本极高。

3.3 解决方案

方案:拆分事务,分批处理

// ✅ 分批处理:每批一个独立事务
async function monthlyInterest() {
const BATCH_SIZE = 1000;
let offset = 0;
let totalProcessed = 0;
while (true) {
// 每批只查 1000 条
const batch = await prisma.account.findMany({
skip: offset,
take: BATCH_SIZE
});
if (batch.length === 0) break;
// 每批一个独立事务
await processBatch(batch);
totalProcessed += batch.length;
offset += BATCH_SIZE;
console.log(`已处理 ${totalProcessed} 条记录`);
}
}
async function processBatch(batch: Account[]) {
await prisma.$transaction(async (tx) => {
for (const account of batch) {
const interest = account.balance * 0.003; // 月利率
await tx.account.update({
where: { id: account.id },
data: { balance: account.balance + interest }
});
}
});
}

效果对比:

指标大事务(拆分前)分批事务(拆分后)
单次事务大小10 万条1000 条
锁持有时间20 分钟约 1 秒
回滚代价全部回滚只回滚 1000 条
主从延迟20 分钟约 1 秒

四、Prisma 事务的三大坑

同事用 Prisma 写查询接口,遇到了几个事务相关的坑。小张用前三篇学到的知识帮他逐一解决了。

4.1 坑一:Prisma 的读操作默认不加锁

// ❌ 这样读不加锁,可能读到未提交的数据(脏读)
const account = await tx.account.findUnique({
where: { id: 1 }
});

解决方案:使用 $queryRaw 加锁

// ✅ 使用原生 SQL 加锁
const accounts = await tx.$queryRaw`
SELECT * FROM account WHERE id IN (${fromId}, ${toId}) FOR UPDATE
`;

4.2 坑二:Prisma 的 $transaction 不支持嵌套

// ❌ 错误:Prisma 不支持嵌套事务
await prisma.$transaction(async (tx1) => {
await tx1.account.update(...);
await prisma.$transaction(async (tx2) => { // 报错!
await tx2.account.update(...);
});
});

解决方案:扁平化事务

// ✅ 正确:所有操作在同一个事务中完成
await prisma.$transaction(async (tx) => {
await tx.account.update(...);
await tx.account.update(...);
});

4.3 坑三:Prisma 事务的隔离级别设置

Prisma 不设置隔离级别,依赖数据库默认值。MySQL 默认是 REPEATABLE READ

如果需要 READ COMMITTED

// ✅ 在事务中设置隔离级别
await prisma.$transaction(async (tx) => {
await tx.$executeRaw`SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED`;
// 业务逻辑
});

五、生产环境检查清单

小张把所有经验整理成了一份清单:

序号检查项对应代码
1隔离级别是 RC 还是 RR?为什么?$executeRaw 设置
2所有事务是否按相同顺序访问资源?ORDER BY id FOR UPDATE
3单个事务处理的数据量是否可控?分批处理
4是否配置了死锁重试机制?try-catch + 循环
5读操作是否需要 FOR UPDATE 加锁?$queryRaw + FOR UPDATE
6是否避免嵌套事务?扁平化 $transaction

六、全文总结

事故知识点Node.js/Prisma 解决方案
插入被阻塞间隙锁触发条件设置 READ COMMITTED
死锁统一加锁顺序ORDER BY id FOR UPDATE
大事务拆分批量分批 + 独立事务
读不加锁FOR UPDATE$queryRaw + FOR UPDATE
嵌套事务Prisma 不支持扁平化处理

七、完整示例:转账服务的最终实现

import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function transfer(fromId: number, toId: number, amount: number) {
const MAX_RETRIES = 3;
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
try {
return await prisma.$transaction(async (tx) => {
// 1. 设置隔离级别
await tx.$executeRaw`SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED`;
// 2. 统一加锁顺序(死锁预防)
const firstId = Math.min(fromId, toId);
const secondId = Math.max(fromId, toId);
const locked = await tx.$queryRaw<Account[]>`
SELECT * FROM account WHERE id IN (${firstId}, ${secondId}) ORDER BY id FOR UPDATE
`;
const first = locked.find(a => a.id === firstId)!;
const second = locked.find(a => a.id === secondId)!;
// 3. 执行转账
if (fromId === firstId) {
await tx.account.update({
where: { id: firstId },
data: { balance: first.balance - amount }
});
await tx.account.update({
where: { id: secondId },
data: { balance: second.balance + amount }
});
} else {
await tx.account.update({
where: { id: secondId },
data: { balance: second.balance - amount }
});
await tx.account.update({
where: { id: firstId },
data: { balance: first.balance + amount }
});
}
// 4. 记录转账
await tx.transferRecord.create({
data: { fromId, toId, amount, createdAt: new Date() }
});
return { success: true };
});
} catch (error) {
if (error.message?.includes('Deadlock') && attempt < MAX_RETRIES) {
console.log(`死锁发生,第 ${attempt} 次重试...`);
await new Promise(resolve => setTimeout(resolve, attempt * 100));
continue;
}
throw error;
}
}
}

八、最后的建议

  1. 理解原理:ACID、MVCC、锁——这些不只是面试题,是解决实际问题的工具
  2. 多做实验:像小张一样,在测试环境动手验证
  3. 写代码前想清楚:隔离级别?事务大小?死锁风险?
  4. 监控和日志:长事务、死锁、锁等待——没有监控就看不见问题
生产环境中的事务实践——银行系统上线记(四)
/posts/2026-6-27/4-生产环境中的事务实践银行系统上线记/
Author
Atopos
Published at
2026-06-27
License
CC BY-NC-SA 4.0

Some information may be outdated