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

数据库事务系列 · 第 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;
    }
  }
}

八、最后的建议

  1. 理解原理:ACID、MVCC、锁------这些不只是面试题,是解决实际问题的工具
  2. 多做实验:像小张一样,在测试环境动手验证
  3. 写代码前想清楚:隔离级别?事务大小?死锁风险?
  4. 监控和日志:长事务、死锁、锁等待------没有监控就看不见问题

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. **监控和日志**:长事务、死锁、锁等待------没有监控就看不见问题
相关推荐
Database_Cool_2 小时前
AI 应用数据底座首选:阿里云 PolarDB 为大模型 RAG 提供一体化支撑
数据库·阿里云
玖玥拾2 小时前
C# 语言进阶(十五)C# 游戏服务端 MySQL 数据库
服务器·开发语言·网络·数据库·mysql·c#
IvorySQL3 小时前
PG 日报|社区讨论重构 pg_hba 配置文件格式
数据库·人工智能·postgresql·重构·ivorysql
逝水无殇3 小时前
C# 文件的输入与输出详解
开发语言·数据库·后端·c#
罗政5 小时前
PDF 批量合并工具:本地 AI 自动排序、识别正文日期与合同编号
数据库·pdf·php
SelectDB5 小时前
宽表元数据膨胀怎么解?Doris Segment V3 对比 Parquet、Lance
大数据·数据库·数据分析
曹牧5 小时前
Oracle:快速定位最新数据
数据库·oracle
Zhu7586 小时前
在Docker环境部署ApacheGuacamole,对接PG数据库
数据库·docker·容器
Leighteen6 小时前
Redis与MySQL双写一致性:从问题根源到工程落地
数据库·redis·mysql