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
Cần đọc trước
Vấn đề
Phần tiêu đề “Vấn đề”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?
Chọn gì? (tóm tắt trước)
Phần tiêu đề “Chọn gì? (tóm tắt trước)”| 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 |
Khái niệm cốt lõi
Phần tiêu đề “Khái niệm cốt lõi”Transaction gắn với một connection
Phần tiêu đề “Transaction gắn với một connection”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=01a: 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.
Ba cách đưa client tới mọi service
Phần tiêu đề “Ba cách đưa client tới mọi service”| 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.
Cách hoạt động
Phần tiêu đề “Cách hoạt động”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).
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:
- Decorator gọi
cls.run()(store mới, sao chép nông store cha); adapter lấy client,BEGIN,setTx(client). - Ba service đọc cùng store nên cùng client, không thêm tham số nào.
PaymentServiceném23514; adapterROLLBACK,release(), store đặttxvề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.
Đăng ký module
Phần tiêu đề “Đăng ký module”@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.
Adapter cho pg: phải tự viết
Phần tiêu đề “Adapter cho pg: phải tự viết”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.
Chi tiết vận hành
Phần tiêu đề “Chi tiết vận hành”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 |
Requiredkhi đã có transaction mà vẫn truyền options (isolation level): options bị bỏ qua, thư viện chỉ log warning.- Adapter không có
wrapWithNestedTransactionthìNestedlog 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. RequiresNewlồ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ọithis.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.
Ví dụ thực tế
Phần tiêu đề “Ví dụ thực tế”Toàn bộ kịch bản trong một lần chạy:
npx --prefix examples ts-node examples/src/nestjs-transactions-cls/checkout-demo.tsOutput 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=nestedTrade-offs
Phần tiêu đề “Trade-offs”Ư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
pgthuần phải tự viết và tự bảo trì adapter.
Nhược điểm
Checklist production
Phần tiêu đề “Checklist production”- Đặt
@Transactional()ở method use case của service, không ở controller, không ở repository. - Mọi query trong service đọc
txHost.tx; cấm injectPooltrực tiếp vào service nghiệp vụ (lint hoặc review). - Bật lint
@typescript-eslint/no-floating-promisesđể bắt lời gọi thiếuawait. - Không gọi HTTP, queue hay
setTimeoutbên trong method có@Transactional; side effect đi qua transactional outbox. - Đặt
lock_timeoutvàidle_in_transaction_session_timeouttheo Lock timeout và giám sát. - Tính lại
maxcủa pool khi dùngRequiresNew: một request có thể giữ hai connection. - Adapter tự viết: test commit, rollback, lỗi khi
ROLLBACK, vàNested.
Sai lầm thường gặp
Phần tiêu đề “Sai lầm thường gặp”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 |
Tự kiểm tra
Phần tiêu đề “Tự kiểm tra”- Ở kịch bản 1b, vì sao
ROLLBACKxoá được đơn nhưng không trả lạistock? InventoryServicelà 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?- Vì sao dòng audit trong kịch bản 4 được commit dù
checkoutném lỗi? - Pool
max: 5, 5 request cùng lúc, mỗi request lồngRequiresNewtrongRequired. Chuyện gì xảy ra?
Tóm tắt
Phần tiêu đề “Tóm tắt”Tham khảo
Phần tiêu đề “Tham khảo”- Transactional plugin — nestjs-cls: danh sách adapter, propagation,
TransactionHost. - Creating a custom adapter — nestjs-cls:
wrapWithTransaction,getFallbackInstance,wrapWithNestedTransaction. - nestjs-cls trên GitHub: source decorator và
TransactionHost. - Async local storage — NestJS và Injection scopes: ALS thay request scope, chi phí của
Scope.REQUEST. - Transactions — node-postgres: vì sao transaction phải chạy trên một client.