ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

NestJS高并发下的事务与一致性控制:从悲观锁到最终一致性

NestJS高并发下的事务与一致性控制:从悲观锁到最终一致性 “消息 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% 的场景用 QueryRunner9% 用 DataSource.transaction1% 用装饰器。为什么后面细说先看代码。第一种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 操作即可事务会自动包裹 } }这个方案的坑在于它依赖 CLSContinuation 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 READPostgreSQL 是 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 UPDATEPostgreSQL 也有同样的语法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 状态的消息发到 RabbitMQCron(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): Promiseboolean { const result await this.redis.set( lock:${key}, locked, PX, ttlMs, NX, ); return result OK; } async releaseLock(key: string): Promisevoid { // 最简版本直接用 del await this.redis.del(lock:${key}); } }但这个最简版本有个坑释放锁的时候可能把别人刚拿到的锁删掉。比如实例 A 拿到锁处理业务超时导致锁自动过期实例 B 马上拿到锁开始处理这时 A 的异步回调执行完了执行 del 删除锁删掉的是 B 的锁。解决办法是给锁设置唯一标识释放时用 Lua 脚本校验async acquireLock(key: string, requestId: string, ttlMs 5000): Promiseboolean { const result await this.redis.set( lock:${key}, requestId, PX, ttlMs, NX, ); return result OK; } async releaseLock(key: string, requestId: string): Promisevoid { // 校验 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 查活跃事务用 DBCCDBCC 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 模式的配合玩法——这也是我最近在重构项目时正在做的事情。
返回列表