数据库事务系列 · 第 4 篇
引言:上线第一天,系统崩了
柜员小张和小李经过前三周的学习,已经掌握了事务的完整知识体系:
- 第 1 篇:ACID 四大特性,学会了 BEGIN、COMMIT、ROLLBACK
- 第 2 篇:隔离级别,亲眼看到了脏读、不可重复读、幻读
- 第 3 篇:MVCC 和锁机制,学会了记录锁、间隙锁、Next-Key Lock
现在,银行的新转账系统要上线了。小张负责写的转账接口,之前测试环境跑得好好的,隔离级别选的 REPEATABLE READ,代码也很简单------先扣张三的钱,再加给李四。
上线第一天,凌晨两点,小张被电话吵醒。
「系统卡死了,大量转账失败,用户投诉了。」
小张打开监控面板------数据库连接数飙升到上限,大量请求在等待锁释放。
他知道,理论终于要接受现实的检验了。

一、事故一:隔离级别选错,高并发下插入被间隙锁阻塞
1.1 事故现场
小张登录数据库,执行了第 3 篇学过的命令:
sql
SELECT * FROM information_schema.INNODB_TRX;
他发现大量事务都处于 LOCK WAIT 状态,而且都是同一个操作------INSERT INTO transfer_record(插入转账记录)。
1.2 问题分析
小张打开自己用 Prisma 写的代码:
typescript
// ❌ 问题代码:使用默认的 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,去掉间隙锁的负担。
typescript
// ✅ 方案一:在事务中设置隔离级别
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 篇学过的死锁排查命令:
sql
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(李四转给张三) |
|---|---|---|
| T1 | UPDATE ... WHERE id = 1(锁住张三) |
|
| T2 | UPDATE ... WHERE id = 2(锁住李四) |
|
| T3 | UPDATE ... WHERE id = 2 → 等待事务B |
|
| T4 | UPDATE ... WHERE id = 1 → 等待事务A |
死锁的四个必要条件:
| 条件 | 说明 | 本例中是否满足 |
|---|---|---|
| 互斥 | 资源只能被一个事务占用 | ✅ 行锁是互斥的 |
| 持有并等待 | 持有锁的同时等待其他锁 | ✅ 事务A 持有 id=1,等待 id=2 |
| 不可抢占 | 已持有的锁不能被强制释放 | ✅ 只能等事务主动提交 |
| 循环等待 | 事务之间形成等待环 | ✅ A 等 B,B 等 A |
关键认知:死锁的本质是加锁顺序不一致。事务A 先锁 1 再锁 2,事务B 先锁 2 再锁 1。
2.3 解决方案
方案一:统一加锁顺序(推荐)
typescript
// ✅ 先统一加锁顺序,再执行业务
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() }
});
});
核心原则:所有事务以相同顺序访问资源,循环等待不可能发生。
方案二:死锁重试机制(兜底方案)
typescript
// ✅ 在应用层实现重试机制
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 问题分析
小张找到了结息代码:
typescript
// ❌ 问题代码:所有账户在一个事务中处理
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 解决方案
方案:拆分事务,分批处理
typescript
// ✅ 分批处理:每批一个独立事务
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 的读操作默认不加锁
typescript
// ❌ 这样读不加锁,可能读到未提交的数据(脏读)
const account = await tx.account.findUnique({
where: { id: 1 }
});
解决方案:使用 $queryRaw 加锁
typescript
// ✅ 使用原生 SQL 加锁
const accounts = await tx.$queryRaw`
SELECT * FROM account WHERE id IN (${fromId}, ${toId}) FOR UPDATE
`;
4.2 坑二:Prisma 的 $transaction 不支持嵌套
typescript
// ❌ 错误:Prisma 不支持嵌套事务
await prisma.$transaction(async (tx1) => {
await tx1.account.update(...);
await prisma.$transaction(async (tx2) => { // 报错!
await tx2.account.update(...);
});
});
解决方案:扁平化事务
typescript
// ✅ 正确:所有操作在同一个事务中完成
await prisma.$transaction(async (tx) => {
await tx.account.update(...);
await tx.account.update(...);
});
4.3 坑三:Prisma 事务的隔离级别设置
Prisma 不设置隔离级别,依赖数据库默认值。MySQL 默认是 REPEATABLE READ。
如果需要 READ COMMITTED:
typescript
// ✅ 在事务中设置隔离级别
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 不支持 | 扁平化处理 |
七、完整示例:转账服务的最终实现
typescript
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;
}
}
}
八、最后的建议
- 理解原理:ACID、MVCC、锁------这些不只是面试题,是解决实际问题的工具
- 多做实验:像小张一样,在测试环境动手验证
- 写代码前想清楚:隔离级别?事务大小?死锁风险?
- 监控和日志:长事务、死锁、锁等待------没有监控就看不见问题
e() }
});
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. **监控和日志**:长事务、死锁、锁等待------没有监控就看不见问题