Bỏ qua để đến nội dung
Knowledge Hub

Transaction xuyên service trong NestJS: giữ một connection qua nhiều service bằng CLS Nâng cao

Thời gian đọc ước tính: 11 phút

OrderService.checkout() tạo đơn, gọi InventoryService.reserve() trừ 2 áo trong kho (stock 10), rồi gọi PaymentService.record() ghi thanh toán. Mỗi service là singleton, inject Pool của node-postgres và tự pool.query.

Khi PaymentService lỗi, đơn đã ghi và kho đã trừ. Thêm BEGIN ở OrderService cũng không cứu được: demo đo được stock = 8 sau ROLLBACK.

Câu hỏi: làm sao để ba service dùng chung một transaction mà không sửa mọi chữ ký hàm, và điều gì làm query lặng lẽ rơi khỏi transaction?

Tình huống Chọn Điều kiện
Một service, hai ba hàm repository Truyền client qua tham số Repository nhận PoolClient, như withTransaction
Nhiều service lồng nhau CLS + @Transactional() Mọi query đọc txHost.tx; có test bắt lỗi quên await
Provider đã request-scoped vì lý do khác Unit of work Scope.REQUEST Chấp nhận scope lan lên cây (provider scopes); không dùng cho cron
Một phần phải commit dù đơn rollback (log lỗi) Propagation.RequiresNew Đủ connection trong pool cho hai transaction cùng lúc

BEGIN mở transaction trên session (một connection) đã gửi nó. Câu lệnh đi qua connection khác không biết transaction đó tồn tại. pool.query mượn một connection rảnh bất kỳ cho từng câu (connection pool); chi tiết lỗi kinh điển này ở Transaction trong code.

Kịch bản 1b trong demo: OrderService lấy client A và BEGIN; InventoryService vẫn pool.query.

Đang tải diagram…

Output thật, rút gọn (PostgreSQL 17, pool max: 5, Payment lỗi 23514 ở cả hai):

=== 1a. Không transaction, Payment lỗi ===
order pid=37168, inventory pid=37168
=> orders=1, stock=8, payments=0
=== 1b. BEGIN ở OrderService, service khác dùng pool.query ===
order pid=37169 (trong BEGIN), inventory pid=37170
=> orders=0, stock=8, payments=0

1a: cùng pid nhưng mỗi câu tự commit; cùng connection không có nghĩa cùng transaction. 1b: đơn rollback, kho thì không, khó phát hiện hơn.

Cách Cơ chế Giá phải trả
Tham số reserve(tx, productId, qty) Mọi hàm trên đường đi thêm tham số tx, kể cả hàm chỉ chuyển tiếp
Scope.REQUEST Provider giữ client, inject vào service Mọi consumer thành request-scoped; không có request trong cron, consumer
CLS AsyncLocalStorage giữ client trong store của async chain Dependency ngầm; rò transaction khi code chạy ngoài chain

Bài AsyncLocalStorage khuyên dữ liệu nghiệp vụ đi bằng tham số. CLS cho transaction là ngoại lệ có chủ đích: ranh giới vẫn nằm ở decorator, chỉ handle đi ngầm.

nestjs-cls 7 bọc ALS thành ClsService. Plugin @nestjs-cls/transactional 4 thêm @Transactional() (bọc method trong transaction) và TransactionHost.tx (transaction đang mở trong store hiện tại).

Ba service singleton dùng chung một client qua CLS; Payment lỗi thì cả ba rollback
Ba service singleton dùng chung một client qua CLS; Payment lỗi thì cả ba rollbackBước 1/6: Decorator mở CLS context, adapter lấy một client, chạy BEGIN và lưu client vào store.thời gian (một lần checkout)một transaction, một client …pool.connect@Transactionalcls.runBEGINsetTx(client)OrderServicecheckout()InventoryServicereserve()PaymentServicerecord()Connectionclient từ pool
Bước 1/6

Decorator mở CLS context, adapter lấy một client, chạy BEGIN và lưu client vào store.

Trạng thái sau từng bước:

  1. Decorator gọi cls.run() (store mới, sao chép nông store cha); adapter lấy client, BEGIN, setTx(client).
  2. Ba service đọc cùng store nên cùng client, không thêm tham số nào.
  3. PaymentService ném 23514; adapter ROLLBACK, release(), store đặt tx về undefined.

Ngoài transaction, txHost.tx trả fallback instance của adapter (ở đây là Pool): service chạy được cả trong lẫn ngoài transaction.

@Module({
imports: [
DbModule, // export provider PG_POOL
ClsModule.forRoot({
global: true,
plugins: [
new ClsPluginTransactional({
imports: [DbModule],
adapter: new PgTransactionalAdapter(PG_POOL),
}),
],
}),
],
})
export class AppModule {}
@Injectable()
class InventoryService {
constructor(private readonly txHost: TransactionHost<PgTransactionalAdapter>) {}
reserve(productId: number, qty: number) {
return this.txHost.tx.query(
'UPDATE products SET stock = stock - $2 WHERE id = $1', [productId, qty]);
}
}
// OrderService: @Transactional() async checkout(amount: number) { ... }

Kiểu trong constructor phải viết thẳng TransactionHost<...>, không qua type alias: metadata lúc chạy thành Object và Nest báo UnknownDependenciesException.

Danh sách adapter chính thức có Prisma, Knex, Kysely, pg-promise, TypeORM, MongoDB, Mongoose, Drizzle; không có gói cho node-postgres thuần (@nestjs-cls/transactional-adapter-pg không tồn tại trên npm). Adapter demo dưới đây theo hướng dẫn “Creating a custom adapter”, chỉ dùng cho demo:

export class PgTransactionalAdapter
implements TransactionalAdapter<Pool, Queryable, PgTxOptions> {
constructor(readonly connectionToken: unknown) {}
optionsFactory = (pool: Pool) => ({
wrapWithTransaction: async (options: PgTxOptions, fn: () => Promise<unknown>,
setTx: (tx?: Queryable) => void) => {
const client = await pool.connect();
try {
await client.query('BEGIN');
setTx(client);
const result = await fn();
await client.query('COMMIT');
return result;
} catch (err) {
await client.query('ROLLBACK').catch(() => {});
throw err;
} finally {
client.release();
}
},
getFallbackInstance: (): Queryable => pool,
});
}

Còn thiếu cho production: savepoint cho Nested (wrapWithNestedTransaction), timeout, xử lý client hỏng khi ROLLBACK lỗi. Đã dùng Kysely hoặc Drizzle trên pg thì dùng adapter chính thức của chúng.

Enum Propagation (4.0.1), dùng như @Transactional(Propagation.RequiresNew):

Giá trị Có transaction đang mở Không có
Required (mặc định) Dùng lại Mở mới
RequiresNew Mở transaction độc lập (connection khác) Mở mới
Nested Savepoint nếu adapter hỗ trợ Mở mới
Mandatory Dùng lại Ném TransactionNotActiveError
Supports / NotSupported / Never Dùng lại / tạm chạy ngoài / ném lỗi Chạy không transaction
  • Required khi đã có transaction mà vẫn truyền options (isolation level): options bị bỏ qua, thư viện chỉ log warning.
  • Adapter không có wrapWithNestedTransaction thì Nested log warning và mở transaction mới trên connection khác (đọc source 4.0.1, demo kịch bản 7 xác nhận), không phải savepoint.
  • RequiresNew lồng trong transaction giữ hai connection cùng lúc; pool nhỏ có thể cạn khi nhiều request cùng làm vậy.
  • @Transactional() thay method trên prototype bằng một Proxy. Gọi this.checkout() từ method khác vẫn đi qua wrapper, khác bẫy self-invocation của proxy Spring (kịch bản 3).
  • Ngoài HTTP vẫn chạy: decorator tự mở cls.run(), không cần middleware cho riêng transaction.

Toàn bộ kịch bản trong một lần chạy:

Terminal window
npx --prefix examples ts-node examples/src/nestjs-transactions-cls/checkout-demo.ts
Output kịch bản 2–7, rút gọn (pid đổi mỗi lần chạy)
=== 2. @Transactional + CLS, Payment lỗi ===
order pid=37169, inventory pid=37169, tx active=true
checkout lỗi: 23514 new row for relation "payments" violates check constraint "payments_amount_check"
=> orders=0, stock=10, payments=0, audit=-
=== 3. Gọi qua this.checkout() từ method không có decorator ===
trước this.checkout(): tx active=false
order pid=37169, inventory pid=37169, tx active=true
=== 4. Quên await trong transaction ===
audit "quen-await": isTransactionActive=false
=> orders=0, stock=10, payments=0, audit=quen-await
=== 5. setTimeout trong transaction ===
setTimeout sau 50ms: tx active=false
=== 7. Propagation.Nested với adapter không hỗ trợ ===
WARN [TransactionHost] Nested Propagation option is ignored because an adapter does not support nested transactions (for method bound writeNested).
=> orders=0, stock=10, payments=0, audit=nested

Ưu điểm

  • Service giữ singleton, chữ ký hàm không đổi khi thêm tầng.

  • Ranh giới transaction nằm ở một chỗ: decorator trên use case.

  • Propagation có tên cho nhu cầu như log độc lập.

  • < slot="cons">
  • Phụ thuộc ngầm: đọc reserve() không biết nó đang trong transaction nào.

  • Code chạy ngoài async chain (quên await, timer, listener) âm thầm rơi về fallback và autocommit.

  • Với pg thuần phải tự viết và tự bảo trì adapter.

Nhược điểm

  1. Đặt @Transactional() ở method use case của service, không ở controller, không ở repository.
  2. Mọi query trong service đọc txHost.tx; cấm inject Pool trực tiếp vào service nghiệp vụ (lint hoặc review).
  3. Bật lint @typescript-eslint/no-floating-promises để bắt lời gọi thiếu await.
  4. Không gọi HTTP, queue hay setTimeout bên trong method có @Transactional; side effect đi qua transactional outbox.
  5. Đặt lock_timeout và idle_in_transaction_session_timeout theo Lock timeout và giám sát.
  6. Tính lại max của pool khi dùng RequiresNew: một request có thể giữ hai connection.
  7. Adapter tự viết: test commit, rollback, lỗi khi ROLLBACK, và Nested.

Bẫy hay gặp nhất là một lời gọi không await bên trong transaction:

Quên await: câu ghi chạy sau khi transaction đã kết thúc

Hay gặp

@Transactional()
async checkout(amount: number) {
this.audit.write('checkout'); // không await
await this.payment.record(orderId, amount);
}

Nên viết

@Transactional()
async checkout(amount: number) {
await this.audit.write('checkout');
await this.payment.record(orderId, amount);
}

Vì sao

write() có một await trước khi query. Lúc nó chạy tiếp, transaction đã rollback và store đặt tx về undefined, nên txHost.tx trả Pool: dòng audit được commit riêng dù checkout thất bại (kịch bản 4).

Timer và listener có cùng hệ quả theo đường khác:

Cách hiểu sai Hệ quả Cần làm
this.method() bỏ qua @Transactional như Spring Thêm wrapper thừa hoặc tách class không cần thiết Thư viện thay method trên prototype; vẫn chạy (kịch bản 3)
Nested luôn là savepoint Adapter không hỗ trợ thì mở transaction độc lập, commit riêng Cài wrapWithNestedTransaction hoặc không dùng Nested
Transaction dài chỉ tốn thời gian Giữ connection và row lock, request khác chờ, cạn pool Transaction chỉ chứa SQL; timeout ở database
  1. Ở kịch bản 1b, vì sao ROLLBACK xoá được đơn nhưng không trả lại stock?
  2. InventoryService là singleton không có field nào đổi theo request. Nó lấy đúng client của checkout đang chạy bằng cách nào?
  3. Vì sao dòng audit trong kịch bản 4 được commit dù checkout ném lỗi?
  4. Pool max: 5, 5 request cùng lúc, mỗi request lồng RequiresNew trong Required. Chuyện gì xảy ra?