☰
NestJS高并发下的事务与一致性控制:从悲观锁到最终一致性
2026/10/1 10:56:15 网站建设 项目流程

“消息 9002,级别 17,状态 2,第 1 行 数据库 ‘xxx’ 的事务日志已满。”这个报错我到现在都记得,是某次线上促销活动深夜出现的,数据库直接拒掉所有写请求,订单表瞬间积压了几千条。当时第一反应是“谁把日志文件设太小了”,排查完才发现,罪魁祸首是一个同事在 NestJS 服务里写了个大事务:循环插入几千条数据,事务一直不提交,日志疯狂增长,直接把磁盘撑爆。

这就是数据库事务在高并发下的真实面貌——你以为它只是个“要么全成功要么全失败”的开关,实际上它连着锁、日志、隔离级别、连接池、性能,甚至分布式一致性。这篇作为 NestJS 系列教程的第十六篇,我把事务和高并发一致性控制这件事摊开讲:从 TypeORM 的基础事务写法,到悲观锁/乐观锁落地,再到分布式场景下的最终一致性套路,全程用真实项目踩坑记录说话。适合已经会用 NestJS 写 CRUD、但对事务只有模糊概念、想系统搞定并发数据安全的开发者。

1. 高并发下的事务困境与一致性本质

1.1 ACID 到底在解决什么问题

说到事务,教科书必提 ACID:原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)、持久性(Durability)。我见过很多人在面试时背得滚瓜烂熟,但写代码时依然会问:我就做个库存扣减,有必要开事务吗?有必要。

拿一个典型的电商下单场景来说。用户点击“立即购买”,后端要做的事情包括:校验库存、扣减库存、生成订单、锁定优惠券。如果每一步都单独执行 SQL 而不做事务包裹,那么扣完库存后订单生成失败,库存就莫名其妙少了;或者优惠券锁定了但订单没建起来,用户的钱被扣了东西却没买到。事务的原子性保证这四步要么全部成功,要么全部回滚,这是业务正确性的底线。

但 ACID 不只是“出错回滚”这么简单。隔离性在高并发下才是真正的难点。没有隔离控制,两个请求同时扣同一件商品的库存,会产生丢失更新(Lost Update)——两个事务都读到库存是 10,各自扣 1,最后库存变成 9 而不是 8。这个问题的本质是:并发环境下,事务之间互相干扰,数据一致性被打破。

所以谈到“一致性控制”,核心任务其实是两件事:第一,保证单个业务操作的原子性;第二,保证并发操作之间的隔离性。前者靠事务本身,后者靠锁机制和隔离级别。很多团队在 NestJS 项目里把事务当成“写了 @Transactional 就万事大吉”,结果压测一上来就出现超卖、死锁、连接池耗尽,就是因为只理解了原子性,没理解隔离性和锁。

1.2 那个“事务日志已满”的故障复盘

回到开头提到的 9002 错误。那次事故的根源,是一个报表同步任务在 NestJS 里用 TypeORM 的 QueryRunner 开启了一个事务,然后在事务内循环执行了几千条 INSERT,每条 INSERT 之间还夹杂着远程接口调用。事务迟迟不提交,SQL Server 的事务日志只能不断累积,直到磁盘空间耗尽。

这个场景暴露了事务使用的两个大忌:

第一,事务内不允许做远程调用。事务提交前会持有连接和锁,远程调用的耗时可能是几百毫秒甚至几秒,这段时间内连接池会被占死,其他正常请求拿不到连接。而且远程调用失败还会导致事务回滚,如果调用的是第三方支付接口,回滚后支付状态和本地库不一致,更难处理。

第二,事务要短小精悍。事务从 BEGIN 到 COMMIT 的时间越短越好,这不只是日志增长问题,还关系到锁的持有时间。数据库的锁是事务级释放的,事务不结束,锁就一直在。高并发下多个事务相互等待,就形成死锁或者锁等待超时。

那次故障的修复其实不复杂:把大事务拆成小批次,每批 500 条提交一次;远程调用移出事务,改成先本地落库,再通过异步任务处理。但从那以后,我对“事务”这两个字有了敬畏心——它不是一个简单的装饰器,而是一个需要精心设计的资源管理策略。

1.3 一致性模型:强一致与最终一致的选择

在做高并发系统设计时,还有一个必须想清楚的问题:你到底需要哪种一致性。强一致意味着任何时刻任何节点读到的数据都是最新的,数据库单实例的事务天然满足这一点;但在分布式环境下,强一致往往意味着极高的延迟和极低的吞吐——你要跨网络协调多个节点,每一步都要等待确认。

现实中的互联网业务,很少有一刀切的要求。库存扣减、账户余额这种数据,必须强一致,扣多了是资损,扣少了是纠纷。但用户昵称的修改、文章阅读量的增加、Feed 流的更新,这些数据最终一致就够了——用户能接受修改后过几秒才看到效果。

NestJS 项目里常见的一致性分层是这样的:单服务内、单数据库处理核心资金类操作,用数据库事务加强一致;跨服务、跨数据库的操作,引入消息队列和补偿机制,用 Outbox 模式或 Saga 模式实现最终一致。第四章我会详细展开分布式场景的实现方案,这一章先建立认知:没有一种技术能同时满足高吞吐、强一致、低延迟,关键是想清楚业务要什么。

2. NestJS + TypeORM 的事务基础实操

2.1 三种基础事务写法与适用场景

NestJS 项目通常搭配 TypeORM 使用,事务写法有三种:DataSource 的 transaction 方法、@Transactional 装饰器、QueryRunner 手动管理。先说结论,我实际开发中90% 的场景用 QueryRunner,9% 用 DataSource.transaction,1% 用装饰器。为什么后面细说,先看代码。

第一种,DataSource.transaction。这种适合简单的、没有复杂异常处理的场景:

import { Injectable } from '@nestjs/common'; import { DataSource } from 'typeorm'; @Injectable() export class OrderService { constructor(private readonly dataSource: DataSource) {} async createOrderWithTransaction(userId: number, productId: number) { return this.dataSource.transaction(async (manager) => { // 事务内所有操作使用 manager 而不是 repository const product = await manager.findOne(Product, { where: { id: productId }, lock: { mode: 'pessimistic_write' }, }); if (product.stock <= 0) { throw new Error('库存不足'); } await manager.update(Product, productId, { stock: product.stock - 1, }); const order = await manager.save(Order, { userId, productId, quantity: 1, }); return order; }); } }

第二种,@Transactional 装饰器。它来自 typeorm-transactional-cls-hooked 这个库,用起来确实简洁:

import { Transactional } from 'typeorm-transactional-cls-hooked'; @Injectable() export class OrderService { @Transactional() async createOrder(userId: number, productId: number) { // 这里直接用 repository 操作即可,事务会自动包裹 } }

这个方案的坑在于它依赖 CLS(Continuation Local Storage)机制传递事务上下文,在异步回调、RabbitMQ 消费消息、定时任务这些场景下很容易出现事务上下文丢失的问题。我曾经在 BullMQ 的 worker 里用它,结果事务根本没生效,数据写到一半就提交了,排查了半天才发现是 CLS 上下文没绑定。

第三种,QueryRunner。这是我最推荐的方式,原因是它把事务的控制权完全交给你,什么时候提交、什么时候回滚、事务隔离级别怎么设置,全都在你手里:

import { Injectable } from '@nestjs/common'; import { DataSource, QueryRunner } from 'typeorm'; @Injectable() export class OrderService { constructor(private readonly dataSource: DataSource) {} async createOrder(userId: number, productId: number) { const queryRunner = this.dataSource.createQueryRunner(); await queryRunner.connect(); await queryRunner.startTransaction(); try { // 所有数据库操作通过 queryRunner.manager const product = await queryRunner.manager.findOne(Product, { where: { id: productId }, lock: { mode: 'pessimistic_write' }, }); if (product.stock <= 0) { throw new Error('库存不足'); } await queryRunner.manager.update(Product, productId, { stock: product.stock - 1, }); await queryRunner.manager.save(Order, { userId, productId, quantity: 1, }); await queryRunner.commitTransaction(); return { success: true }; } catch (error) { await queryRunner.rollbackTransaction(); throw error; } finally { // 一定记得释放连接池连接 await queryRunner.release(); } } }

2.2 QueryRunner 手动管理的好处与“必忘事项”

为啥我几乎全用 QueryRunner?最重要的一点是,它能设置事务隔离级别。DataSource.transaction 的写法虽然简洁,但它的隔离级别默认是数据库的默认级别,MySQL 是 REPEATABLE READ,PostgreSQL 是 READ COMMITTED。有些业务场景你需要 SERIALIZABLE 或者 READ UNCOMMITTED,用 QueryRunner 可以这样:

await queryRunner.startTransaction('SERIALIZABLE');

这一点在“先查再改”的防超卖场景里很关键。我在 3.2 节会详细讲。

另一个好处是异常处理的颗粒度。QueryRunner 可以在 catch 块里定义不同的回滚策略——有些错误只需要回滚当前事务,有些错误需要释放整个连接,你可以在 finally 块统一处理。用 @Transactional 的话,一旦异常抛出,框架会帮你回滚,但你没法在回滚前记录日志、做补偿操作。

不过,QueryRunner 有一个“必忘事项”:释放连接。很多新手写完事务操作,忘了在 finally 里调用 queryRunner.release(),导致连接池连接被占满,后续请求全部超时。我见过不止一次生产事故,就是因为这段代码只写了 commit 和 rollback,漏了 release。所以我的习惯是把 QueryRunner 的创建、释放封装成一个基类方法,或者用装饰器统一处理,避免每个人自己写一遍。

重要:QueryRunner 如果中途发生异常,没有走到 release,连接会一直挂在连接池里。建议在 finally 块里调用 release,并且在 release 之前判断 queryRunner.isReleased。

finally { if (!queryRunner.isReleased) { await queryRunner.release(); } }

2.3 事务内绝对不能做的事

结合我经历过的大大小小的事故,整理一份“事务内禁忌清单”,每一条都是血泪:

不要在事务内调用外部 HTTP 接口。前面讲日志满事故时已经提过。外部接口的响应时间不确定,而事务持有数据库连接和锁,一旦外部接口超时,连接被占用几十秒,整个服务在并发上来时很容易雪崩。正确做法是本地事务先提交,然后通过异步队列去触发外部调用,把外部调用失败的结果用重试和补偿机制兜住。

不要在事务内做耗时计算或大批量循环。比如在事务里循环几千次 update,或者做复杂的字符串处理和正则匹配,都会拉长事务时间。尽量在事务外准备好数据,事务内只做必要的数据库读写。

不要在事务内查询不必要的字段。有些团队习惯在事务开始时把整行记录查出来,哪怕只需要一个 stock 字段。如果表有很多列,每次 SELECT * 都会增加锁的粒度和 IO 消耗。建议用 select 指定列,减少锁持有时间。

不要让事务跨多个 service 方法。在 NestJS 的分层架构里,事务应该在一个 Service 方法内部完成,而不是由 Controller 层跨多个 Service 调用发出“整体事务”。因为每个 Service 方法可能各自开启了独立事务,跨方法组合根本无法保证原子性。我的做法是:把需要事务的核心业务流程封装成一个 Service 方法,内部用 QueryRunner 控制。

提示:事务的本质是“快进快出”。任何拉长事务时间的操作,都会放大锁竞争和连接池压力。

3. 高并发下的一致性控制方案:从锁到版本号

3.1 先认识“丢失更新”与“超卖”的根因

高并发下最常见的数据一致性问题,就是丢失更新。场景很经典:两个请求同时读到商品库存 10,各自扣减 1,最后都写回 9,库存变成了 9 而不是 8。这个问题的根因是读-改-写不是原子操作——读和写之间隔着一个时间窗,在这个时间窗内,其他事务可以插入同样的读操作。

超卖问题本质也是如此。用户 A 和用户 B 同时看到库存还剩 1 件,同时提交订单,系统里要判断“库存 > 0”才能扣减,但两个请求都通过了判断,于是卖出了 2 件但库存只有 1 件。要解决这个问题,关键在于让“检查库存 + 扣减库存”成为原子操作。

有几种常见的错误解法,我一个个说:

第一种,直接改库存字段,不检查。比如UPDATE product SET stock = stock - 1 WHERE id = ?。这种写法在数据库层面是原子操作,但如果库存已经为 0,它会把库存改成 -1,超卖照样发生。

第二种,先 SELECT 再 UPDATE,但不用锁。这就是丢失更新问题的标准场景,在高并发下必炸。

第三种,用应用层锁,比如 Node.js 进程内的互斥锁。这只在单实例部署下有效,NestJS 服务一旦扩容到多实例,各进程之间互不相干,锁形同虚设。

要真正解决,必须借助数据库能力,核心思路只有两条:悲观锁和乐观锁。

3.2 悲观锁:SELECT FOR UPDATE 的正确姿势

悲观锁的思路是:读数据之前先加锁,让其他事务等着,直到当前事务提交或回滚。在 TypeORM 里,通过 findOne 的 lock 选项实现:

const product = await queryRunner.manager.findOne(Product, { where: { id: productId }, select: ['id', 'stock'], lock: { mode: 'pessimistic_write' }, }); if (product.stock <= 0) { throw new Error('库存不足'); } await queryRunner.manager.update(Product, productId, { stock: product.stock - 1, });

这里有一点必须强调:悲观锁必须放在事务内才有意义。如果不在事务里,SELECT FOR UPDATE 执行完,锁就释放了,后续的 UPDATE 操作根本没有锁保护,其他事务依然可以穿插进来。所以 2.1 节我特意用 QueryRunner 写示例,就是为了让锁和事务形成闭环。

MySQL 的悲观锁底层实现是 FOR UPDATE,PostgreSQL 也有同样的语法,SQL Server 对应的是 UPDLOCK 提示。TypeORM 的 pessimistic_write 会自动适配不同的数据库方言。

悲观锁适合写多读少、冲突概率高的场景。比如秒杀库存扣减,几乎每个请求都在竞争同一行数据,用乐观锁的话大量请求会在重试中失败,反而浪费数据库资源。悲观锁让请求排队执行,虽然吞吐量下降,但成功率和一致性都有保障。

用悲观锁有一个要注意的性能坑:锁等待超时。两个事务互相持有对方需要的锁,就可能死锁,数据库会自动回滚其中一个事务。在 MySQL 里,可以通过innodb_lock_wait_timeout控制锁等待超时时间,默认 50 秒——生产环境我会调到 5 秒以内,避免一个事务出问题拖死整个系统的连接池。

3.3 乐观锁:版本号机制与 TypeORM @Version

乐观锁的思路是:不加锁,但在更新时校验数据是否被修改过。最常用的方式是版本号机制:记录上有一个 version 字段,每次更新时 version +1,更新条件带上“version = 旧版本号”,如果影响行数为 0,说明数据已经被其他事务改过了,本次更新失败。

TypeORM 对版本号有内置支持,实体里加一个 @Version 装饰器字段即可:

@Entity() export class Product { @PrimaryGeneratedColumn() id: number; @Column() stock: number; @Version() version: number; }

然后更新库存的逻辑可以这样写:

const product = await queryRunner.manager.findOne(Product, { where: { id: productId }, }); // 尝试用版本号条件更新 const result = await queryRunner.manager.update( Product, { id: productId, version: product.version }, { stock: product.stock - 1, version: product.version + 1, }, ); if (result.affected === 0) { throw new Error('数据已被修改,请重试'); }

这里注意一个细节:result.affected是更新影响的行数,MySQL 下默认返回实际修改的行数,但有些配置下如果值没变,affected 可能是 0。所以更稳妥的方式是先查询版本号,再按版本号更新,影响行数判断只能作为辅助。为了更保险,建议把 UPDATE 语句里的 version 条件作为主判断。

乐观锁适合读多写少、冲突概率低的场景。比如文章更新、用户资料修改,冲突不频繁,没必要用悲观锁排队等待。但乐观锁有一个副作用:冲突发生时用户需要重试。我在订单业务里用过乐观锁,结果压测发现大量请求返回“请重试”,体验很差。所以先评估业务冲突概率,再选锁策略,这是架构师的基本素养。

3.4 两种锁策略的选型对比

把两种方案放在一起对比,方便在实际项目里决策:

维度悲观锁乐观锁
原理读前加锁,阻塞其他事务更新时校验版本号,冲突则失败
实现成本低,TypeORM 直接支持中,需要处理重试逻辑
并发吞吐低,请求排队高,不阻塞读操作
冲突处理自动等待,数据库负责业务层处理失败重试
适用场景写多读少,冲突高(秒杀、库存)读多写少,冲突低(资料修改)
常见问题死锁、锁等待超时、连接池占用重试风暴、版本号维护遗漏

还有一种组合玩法:查库存用快照读(不加锁),扣库存时用条件更新UPDATE ... SET stock = stock - 1 WHERE id = ? AND stock > 0,然后判断受影响行数决定是否重试。这其实是乐观锁的一个变种,省去了版本号字段,实现更简洁:

const result = await queryRunner.manager.update( Product, { id: productId, stock: MoreThan(0) }, { stock: () => 'stock - 1' }, ); if (result.affected === 0) { throw new Error('库存不足或并发冲突'); }

这种写法充分利用了数据库的原子更新能力,在高并发秒杀场景下实际效果不错,也是我目前库存扣减场景用得最多的方案。

4. 分布式场景下的一致性延伸方案

4.1 本地事务救不了分布式问题

NestJS 服务一旦拆分成多个微服务,或者使用多个数据库,本地事务就力不从心了。比如订单服务调用库存服务,两个服务各自维护自己的数据库,你不可能让两个数据库共享一个事务——至少常规的关系型数据库做不到。

有人会想到两阶段提交(2PC),让一个协调者统一管理所有参与者的提交和回滚。但我实际接触的团队几乎没人用它,原因很现实:2PC 的协调者本身是单点,协调者挂了所有参与者都卡住;而且 2PC 的投票阶段锁资源时间太长,高并发下根本撑不住。

所以分布式场景下,主流方案是从强一致退化为最终一致,用状态机和补偿机制保证数据最终是对的。核心工具是消息队列 + 消息重试 + 幂等消费。

4.2 事务性 Outbox:保底的消息可靠投递

“先写数据库,再发消息”这个模式,最常见的坑是:消息发出去了,数据库事务回滚了,消费者那边以为自己该处理,结果数据根本不存在。或者反过来,数据库提交了,消息发送失败,下游服务永远不知道有新订单。

事务性 Outbox 模式解决这个问题。思路是:在同一个本地事务里,不仅写业务数据,还写一张 outbox 表,记录一条“待发送消息”。事务提交后,一个后台任务或 CDC(变更数据捕获)组件读取 outbox 表,把消息发到 MQ,发成功后才把 outbox 记录标记为已发送。

用 NestJS 实现一个最简单的 Outbox 示例:

// 创建订单时,在同一个事务里写订单表和 outbox 表 async createOrderWithOutbox(userId: number, productId: number, quantity: number) { const queryRunner = this.dataSource.createQueryRunner(); await queryRunner.connect(); await queryRunner.startTransaction(); try { // 1. 业务数据写入 const order = await queryRunner.manager.save(Order, { userId, productId, quantity, status: 'CREATED', }); // 2. outbox 记录写入,保证业务数据和消息同生共死 await queryRunner.manager.save(OutboxMessage, { aggregateId: order.id, aggregateType: 'Order', payload: JSON.stringify(order), status: 'PENDING', }); await queryRunner.commitTransaction(); return order; } catch (error) { await queryRunner.rollbackTransaction(); throw error; } finally { await queryRunner.release(); } }

然后后台有个定时任务扫描 outbox 表,把 PENDING 状态的消息发到 RabbitMQ:

@Cron(CronExpression.EVERY_10_SECONDS) async publishOutboxMessages() { const messages = await this.outboxRepo.find({ where: { status: 'PENDING' }, take: 100, }); for (const message of messages) { try { await this.rabbitmqService.publish('order.created', message.payload); // 发布成功才更新状态 await this.outboxRepo.update(message.id, { status: 'SENT', sentAt: new Date(), }); } catch (error) { // 失败则保留 PENDING,下次定时任务继续尝试 this.logger.error(`Outbox 消息发送失败: ${message.id}`, error.stack); } } }

这个模式的精髓在于:outbox 记录和业务数据在同一事务里,要么都成功,要么都失败,消息不会凭空产生也不会凭空消失。发送失败可以无限重试,消费端配合幂等机制兜底,最终必然一致。

注意:outbox 定时任务的扫描间隔不要设太短,10 秒比较合理。间隔太短会频繁扫表,给数据库造成不必要压力;太长则影响消息实时性。

4.3 Redis 分布式锁:多实例下的互斥控制

NestJS 服务部署多个实例后,进程内的锁完全失效。比如一个定时任务,本来应该只有一个实例执行,但多个实例同时启动,任务就重复执行了。分布式锁就是解决这类跨进程互斥问题的手段。

我用 Redis 实现分布式锁的次数最多,原因简单:Redis 有现成的原子操作,性能高,而且大多数项目本来就有 Redis。核心思路是用 SET 命令加 NX 和 PX 参数:

import Redis from 'ioredis'; export class RedisLockService { constructor(private readonly redis: Redis) {} async acquireLock(key: string, ttlMs = 5000): Promise<boolean> { const result = await this.redis.set( `lock:${key}`, 'locked', 'PX', ttlMs, 'NX', ); return result === 'OK'; } async releaseLock(key: string): Promise<void> { // 最简版本,直接用 del await this.redis.del(`lock:${key}`); } }

但这个最简版本有个坑:释放锁的时候,可能把别人刚拿到的锁删掉。比如实例 A 拿到锁,处理业务超时导致锁自动过期,实例 B 马上拿到锁开始处理;这时 A 的异步回调执行完了,执行 del 删除锁,删掉的是 B 的锁。解决办法是给锁设置唯一标识,释放时用 Lua 脚本校验:

async acquireLock(key: string, requestId: string, ttlMs = 5000): Promise<boolean> { const result = await this.redis.set( `lock:${key}`, requestId, 'PX', ttlMs, 'NX', ); return result === 'OK'; } async releaseLock(key: string, requestId: string): Promise<void> { // 校验 requestId 一致才删除,防止误删其他实例的锁 const luaScript = ` if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end `; await this.redis.eval(luaScript, 1, `lock:${key}`, requestId); }

锁的 TTL 设置也要打磨。设置太短,业务处理时间超过 TTL,锁自动释放,其他实例可能并发进入;设置太长,如果实例宕机,锁要等很久才能被自动清理。我一般根据业务特性设 3-5 秒,同时给业务代码加看门狗续期机制——虽然 Egg、NestJS 官方库里有现成的 Redlock 实现,但自己实现的成本也不高。

4.4 幂等设计:消息消费与接口重试的护城河

最终一致性方案跑起来后,你迟早会遇到消息重复消费的问题。比如消费端处理成功但 MQ 确认失败,消息被重新投递,消费逻辑又被跑了一遍,结果扣了两次库存或者创建了两个订单。幂等设计就是解决这个问题的关键。

幂等方案总结下来有三种:唯一键约束、状态机校验、Redis 去重标记。

唯一键约束适合创建类操作。比如订单表里有一个 bizId 字段,设置为唯一索引,插入时如果 bizId 重复,数据库直接拒绝。这样消息重复消费时,第二次插入必然失败,不影响原数据。

状态机校验适合状态流转类操作。比如订单状态从 PENDING 到 PAID,只有 PENDING 状态能更新到 PAID,如果消息重复触发,此时订单已经是 PAID,更新结果为 0,业务直接跳过。

Redis 去重标记适合高频接口。每次请求先 SETNX 一个去重 key,设置成功才执行业务逻辑,设置失败说明重复请求:

const dedupKey = `dedup:order:${orderId}`; const isFirstCall = await this.redis.set(dedupKey, '1', 'EX', 60, 'NX'); if (!isFirstCall) { return { isDeleted: true }; // 重复请求,直接返回 } // ...执行业务逻辑

幂等设计没有银弹,我通常是根据业务场景组合使用:数据库唯一约束兜底 + Redis 去重码削峰。

5. 常见问题与排查技巧实录

5.1 高并发事务经典问题速查表

这些年下来,我把团队踩过的事务相关坑整理成了一张速查表,生产环境一遇到类似问题,直接对号入座:

现象可能原因排查思路直接解决
接口超时率高、连接池满事务里有远程调用或长耗时操作抓慢 SQL,查事务持续时间远程调用移出事务,拆分小事务
库存超卖检查+更新非原子检查是否加了锁或版本号用条件更新或悲观锁
大量死锁报错多个事务按不同顺序更新多张表查看死锁日志统一更新顺序,适当降低隔离级别
数据库日志满(9002/日志文件满)长事务未提交,日志无法截断查活跃事务 ID 和开始时间尽快提交/回滚,拆批提交
数据库 CPU 飚高大量锁等待 + 重试看锁等待图和活跃会话优化索引,缩短事务内 SQL 时间
数据延迟最终不一致消息队列消费失败或重复消费看 MQ 消费日志和死信队列增加重试和幂等机制

5.2 如何定位一个“卡住”的事务

高并发系统出问题时,第一件事不是看代码,而是看数据库当前有哪些事务在跑、跑了多久、持有哪些锁。以 PostgreSQL 为例,这条 SQL 可以查到所有活跃事务:

SELECT pid, state, now() - xact_start AS duration, query FROM pg_stat_activity WHERE state = 'active' ORDER BY duration DESC;

MySQL 可以用这条思路:

SELECT trx_id, trx_state, trx_started, trx_query FROM information_schema.innodb_trx ORDER BY trx_started ASC;

SQL Server 查活跃事务用 DBCC:

DBCC OPENTRAN();

查出很长时间没提交的事务后,一般就锁定到对应的 Service 方法。我遇到过一个案例:某个接口事务里调用了外部短信服务,短信服务刚好挂了,每次超时 30 秒,事务在这 30 秒内一直持有连接,压测一开连接池就被打爆。解决办法很简单:把短信调用从事务里移出去。

5.3 事务参数与隔离级别的调优经验

很多 DBA 和架构师在优化高并发事务时,都会关注这几个参数,我也分享下自己的调优经验:

连接池大小。NestJS 项目用 TypeORM 时,连接池默认是 10 个连接。高并发场景下,如果每个请求都开一个事务,10 个连接很快就满了。我一般会调大连接池,比如 50 个,但要注意数据库服务端的最大连接数限制,调太大反而会影响数据库整体性能。同时,连接池不只是数量问题,更重要的是连接复用率——确保每个请求处理完,连接能及时回到池子。

事务超时时间。在 TypeORM 里可以在 DataSource 配置中设置maxQueryExecutionTime来记录慢查询日志,帮助发现事务内的慢 SQL。事务本身的超时设置依赖数据库端,比如 MySQL 的innodb_lock_wait_timeout,建议调低到 5 秒以内,宁可快速失败,也不要无限等待。

隔离级别。默认的隔离级别是安全但偏保守的。MySQL 默认 REPEATABLE READ,会对读操作加间隙锁,在高并发插入场景下可能引发性能问题。如果业务允许,可以降到 READ COMMITTED。但不要为了性能降到 READ UNCOMMITTED,否则你读到的就是别人未提交的脏数据,业务上基本不可接受。

5.4 我的几个底层习惯

最后分享几个我个人的底线习惯,不是官方教程里会写的,但都是实际问题逼出来的:

第一,事务代码必须写注释,标注业务原因和预期耗时。比如“此处事务内包含库存校验和扣减,预计耗时小于 50ms”。这样后人接手时,能看到事务存在的意义,不会盲目地往里加耗时操作。

第二,凡是涉及库存、余额、积分的扣减类操作,一律用条件更新,不允许“先查再改”。这是我从超卖事故里学来的最深刻一条。

第三,事务的回滚处理里,至少记录一条错误日志。很多人 catch 之后直接 throw,导致事务中途异常的原因在日志里完全看不到,后续排查只能靠猜。

第四,加锁的查询必须走索引。SELECT FOR UPDATE 只有在命中索引时才是行锁,否则 MySQL 会对整张表加锁。这个问题特别隐蔽:测试环境数据量小,全表锁也没感觉;生产环境几百万行数据,一个没走索引的 FOR UPDATE 直接把整张表锁死。用 EXPLAIN 看执行计划,确认 type 不是 ALL,是开发时就要养成的习惯。

结尾:一点个人体会

说实话,“数据库事务与高并发一致性控制”这个题目,做久了会发现它本质上是一个权衡问题——你永远在一致性、性能、可用性之间找平衡。事务不是越多越好,锁也不是越严越好。核心是搞清楚每个业务场景真正需要什么:库存扣减必须强一致,那就老老实实加锁或条件更新;用户 Feed 流不需要强一致,那就别为了“看起来严谨”把整个流程包进一个大事务里,白白牺牲吞吐。

从实践来看,NestJS 项目里把事务代码写好、把锁用对、把消息消费的幂等做扎实,已经能覆盖绝大多数业务需求。分布式事务那套更重的方案(比如 Saga 编排、TCC 补偿),反而是业务规模真正到了那一步再去考虑的事。下一篇我可以继续聊一聊 NestJS 里怎么用 BullMQ 实现可靠的任务队列,以及它和事务、Outbox 模式的配合玩法——这也是我最近在重构项目时正在做的事情。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询