“消息 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 模式的配合玩法——这也是我最近在重构项目时正在做的事情。