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这类库,它们都支持原生的BEGIN和COMMIT。
来看一个典型的转账例子:
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 TRANSACTION或BEGIN语句。我个人习惯用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)并不真正支持嵌套事务。你调用的SAVEPOINT和ROLLBACK 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的BEGIN套BEGIN),结果子事务回滚时把父事务也带崩了。后来查文档才发现,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,除非你确定并发量极低,否则别碰——它会把并发性能拉到地板。
好了,关于事务处理,核心就是这些。记住一句话:事务不是银弹,但不用事务是炸弹。写代码时多想想「如果这一步挂了怎么办」,你的系统就会稳很多。