4、Node.js中的事务处理:ACID特性、事务的开启与提交、回滚机制、嵌套事务处理

聊到数据库操作,事务处理绝对是个绕不开的话题。我记得刚入行那会儿,有一次线上跑了个批量转账的脚本,中间某一步挂了,结果A扣了钱,B没收到。嗯,那场面,至今难忘。从那以后,我对事务就格外上心。

说白了,事务就是一组数据库操作,要么全部成功,要么全部失败。没有中间状态。你想想看,这在金融、电商、订单系统里,简直就是命根子。

4.1 ACID特性:事务的四大护法

事务为什么靠谱?因为它有四个基本特性,业内叫ACID。我习惯把这四个字拆开理解:

特性 全称 大白话解释
A Atomicity(原子性) 事务里的操作,要么全做完,要么全不做。就像你点外卖,要么整单成功,要么整单取消,不会出现只收到一半菜的情况。
C Consistency(一致性) 事务执行前后,数据必须符合所有预设规则。比如转账后,总金额不变。这个约束由业务逻辑和数据库共同保证。
I Isolation(隔离性) 多个事务并发执行时,互不干扰。就像你在ATM取钱,别人也在取,但你们各自看到的余额都是对的。
D Durability(持久性) 事务一旦提交,数据就永久保存。哪怕机器重启,数据也不会丢。

核心观点:ACID这四个特性,原子性和持久性主要靠数据库底层保证,一致性和隔离性则需要开发者写代码时多留个心眼。我在项目中见过不少「事务提交了但数据还是错的」案例,多半是一致性没处理好。

4.2 事务的开启与提交:从begin到commit

在Node.js里操作事务,最常用的方式是通过连接池拿到连接,然后手动控制。我一般用mysql2或者pg这类库,它们都支持原生的BEGINCOMMIT

来看一个典型的转账例子:

const mysql = require('mysql2/promise');

async function transfer(fromId, toId, amount) {
  const connection = await mysql.createConnection({
    host: 'localhost',
    user: 'root',
    password: '123456',
    database: 'bank'
  });

  try {
    // 开启事务
    await connection.beginTransaction();

    // 扣钱
    await connection.execute(
      'UPDATE accounts SET balance = balance - ? WHERE id = ?',
      [amount, fromId]
    );

    // 加钱
    await connection.execute(
      'UPDATE accounts SET balance = balance + ? WHERE id = ?',
      [amount, toId]
    );

    // 提交事务
    await connection.commit();
    console.log('转账成功');
  } catch (err) {
    // 出错了就回滚
    await connection.rollback();
    console.error('转账失败,已回滚', err);
  } finally {
    connection.end();
  }
}

这里有个细节:beginTransaction实际上就是发送了START TRANSACTIONBEGIN语句。我个人习惯用beginTransaction方法,因为它更语义化,不容易忘。

小技巧:如果你用的是ORM(比如Sequelize、TypeORM),它们通常提供了transaction方法,会自动帮你管理连接和回滚。但我建议你至少手写一次原生事务,这样能真正理解底层发生了什么。

4.3 回滚机制:出了问题怎么办

回滚,就是事务执行到一半发现不对劲,把已经做的操作全部撤销。我曾经在一个订单系统里遇到过这种情况:扣库存成功了,但生成订单时数据库连接断了。如果没有回滚,库存就凭空少了。

回滚的触发时机一般有两种:

  • 主动回滚:业务逻辑判断不满足条件,手动调用rollback()
  • 被动回滚:代码抛出异常,在catch块里回滚。

看一个带条件判断的例子:

async function placeOrder(userId, productId, quantity) {
  const conn = await pool.getConnection();
  try {
    await conn.beginTransaction();

    // 检查库存
    const [rows] = await conn.execute(
      'SELECT stock FROM products WHERE id = ? FOR UPDATE',
      [productId]
    );
    if (rows[0].stock < quantity) {
      // 库存不足,主动回滚
      await conn.rollback();
      throw new Error('库存不足');
    }

    // 扣库存
    await conn.execute(
      'UPDATE products SET stock = stock - ? WHERE id = ?',
      [quantity, productId]
    );

    // 创建订单
    await conn.execute(
      'INSERT INTO orders (user_id, product_id, quantity) VALUES (?, ?, ?)',
      [userId, productId, quantity]
    );

    await conn.commit();
  } catch (err) {
    await conn.rollback();
    throw err;
  } finally {
    conn.release();
  }
}

注意:回滚之后,一定要记得释放连接(conn.release()conn.end())。我见过有人回滚完忘了释放连接,结果连接池被占满,整个服务挂了。嗯,那个「有人」其实就是我。

4.4 嵌套事务处理:事务里套事务

嵌套事务,说白了就是一个事务里面再开一个子事务。但这里有个坑:大多数关系型数据库(比如MySQL的InnoDB)并不真正支持嵌套事务。你调用的SAVEPOINTROLLBACK TO SAVEPOINT,其实只是模拟了嵌套的效果。

为什么会有嵌套需求?我举个例子:你要批量导入1000条数据,每条数据都要做一系列校验和写入。如果某一条失败了,你不想回滚全部,只想回滚这一条。这时候就可以用保存点(Savepoint)。

看代码:

async function batchImport(records) {
  const conn = await pool.getConnection();
  try {
    await conn.beginTransaction();

    for (let i = 0; i < records.length; i++) {
      const record = records[i];
      // 设置保存点,名字用索引区分
      const savepoint = `sp_${i}`;
      await conn.query(`SAVEPOINT ${savepoint}`);

      try {
        // 处理单条记录
        await processRecord(conn, record);
      } catch (err) {
        // 单条失败,回滚到保存点,不影响其他记录
        await conn.query(`ROLLBACK TO SAVEPOINT ${savepoint}`);
        console.error(`第${i}条记录处理失败,已跳过`, err);
        // 继续处理下一条
      }
    }

    await conn.commit();
  } catch (err) {
    await conn.rollback();
    throw err;
  } finally {
    conn.release();
  }
}

这里要注意几个点:

  • 保存点的名字在同一个事务里必须唯一,否则会覆盖。
  • 回滚到保存点后,保存点本身不会被自动释放。你可以手动RELEASE SAVEPOINT,但一般不用管,事务提交或整体回滚时会自动清理。
  • 如果你用了ORM的嵌套事务API,底层大概率也是用保存点实现的。

避坑指南:我曾经在一个项目里用了真正的嵌套事务(比如PostgreSQL的BEGINBEGIN),结果子事务回滚时把父事务也带崩了。后来查文档才发现,PostgreSQL的嵌套事务其实是「子事务」机制,行为跟保存点类似,但更严格。所以,除非你非常清楚数据库的底层实现,否则老老实实用保存点

4.5 事务隔离级别:并发控制的关键

事务隔离级别决定了「一个事务能看到另一个事务的哪些中间状态」。MySQL InnoDB支持四种级别:

隔离级别 脏读 不可重复读 幻读
READ UNCOMMITTED 可能 可能 可能
READ COMMITTED 不会 可能 可能
REPEATABLE READ(默认) 不会 不会 可能(InnoDB通过MVCC解决)
SERIALIZABLE 不会 不会 不会

在Node.js里设置隔离级别很简单:

await conn.query('SET TRANSACTION ISOLATION LEVEL READ COMMITTED');
await conn.beginTransaction();

我的建议:大部分业务场景用READ COMMITTED就够了,性能比REPEATABLE READ好。如果你要做金融对账,可以考虑REPEATABLE READ。至于SERIALIZABLE,除非你确定并发量极低,否则别碰——它会把并发性能拉到地板。

好了,关于事务处理,核心就是这些。记住一句话:事务不是银弹,但不用事务是炸弹。写代码时多想想「如果这一步挂了怎么办」,你的系统就会稳很多。