Đặt vấn đề: Thách thức trong việc theo dõi lịch sử biến động dữ liệu (Audit Trail)
Trong các ứng dụng cấp doanh nghiệp như hệ thống Tài chính, Banking, ERP hay các nền tảng SaaS B2B, việc ghi lại toàn bộ lịch sử biến động dữ liệu (Audit Trail) là yêu cầu bắt buộc nhằm phục vụ công tác truy vết, tuân thủ pháp lý (compliance) và khôi phục sự cố. Khi một bản ghi bị thay đổi hoặc xóa, hệ thống cần lưu lại chính xác: Ai đã thực hiện, thời điểm nào, hành động gì (INSERT, UPDATE, DELETE), dữ liệu cũ (old values) và dữ liệu mới (new values) là gì.
Cách tiếp cận ngây thơ nhất là gọi hàm ghi log thủ công ở từng Service method mỗi khi thực hiện thao tác Mutation dữ liệu vào Database. Tuy nhiên, phương pháp này bộc lộ nhiều nhược điểm nghiêm trọng:
- Vi phạm nguyên lý DRY (Don't Repeat Yourself): Mã nguồn bị lặp lại ở vô số vị trí trong hệ thống.
- Rủi ro sót log cao: Lập trình viên rất dễ quên gọi hàm logging khi phát triển feature mới hoặc khi thực hiện refactoring.
- Mất dấu vết context người dùng: Khi dữ liệu bị thay đổi từ các tác vụ chạy ẩn như Cronjob, Queue Worker hoặc Event Listener, việc xác định context thực thi trở nên cực kỳ phức tạp.
- Chặt chẽ về coupling: Service logic bị trộn lẫn với Audit Logging logic, làm giảm tính mô-đun và khó viết Unit Test.
Để giải quyết triệt để các vấn đề trên, giải pháp tối ưu nhất là tự động hóa quá trình ghi Audit Log tại tầng Data Access Layer (DAL) bằng cách kết hợp TypeORM Entity Subscribers và AsyncLocalStorage trong NestJS. Nền tảng kiến trúc này cho phép chúng ta bắt trọn mọi thao tác biến động dữ liệu một cách hoàn toàn tự động mà không cần can thiệp vào các Business Service hiện có.
Kiến trúc Giải pháp: Kết hợp AsyncLocalStorage và TypeORM Subscribers
Để xây dựng một Audit Logging Engine tự động và hoàn chỉnh, chúng ta kết hợp hai thành phần cốt lõi trong hệ sinh thái Node.js và NestJS:
1. AsyncLocalStorage (Node.js Asynchronous Context Tracking)
Một trong những thách thức lớn nhất khi ghi log ở tầng ORM / Database Level là: Làm thế nào để Entity Subscriber (chạy ở tầng ORM) biết được thông tin của User (ID, IP Address, Request ID) đang thực hiện HTTP Request?
Truyền tham số User ID qua hàng loạt hàm từ Controller xuống Service, Repository rồi đến Subscriber là giải pháp rất tồi. Module async_hooks của Node.js cung cấp class AsyncLocalStorage, cho phép chúng ta lưu trữ một trạng thái (state) xuyên suốt vòng đời của một luồng xử lý bất đồng bộ (Asynchronous Execution Chain) tương tự như ThreadLocal trong Java hay ThreadStatic trong C#.
2. TypeORM Entity Subscribers
TypeORM cung cấp giao diện EntitySubscriberInterface, cho phép lắng nghe các sự kiện lifecycle của Entity như afterInsert, afterUpdate, và afterRemove. Khi bất kỳ Entity nào trong hệ thống có sự thay đổi, Subscriber sẽ được kích hoạt tự động.
Sơ đồ luồng dữ liệu (Data Flow) của hệ thống được tóm tắt như sau:
- HTTP Request gửi tới NestJS Gateway / Controller.
- NestJS Interceptor / Middleware trích xuất thông tin User (từ JWT) và Request Context, sau đó lưu vào
AsyncLocalStorage. - Business Service thực hiện thao tác ghi/sửa dữ liệu thông qua TypeORM Repository.
- TypeORM Subscriber tự động bắt sự kiện, đọc thông tin User từ
AsyncLocalStorage, tính toán sự chênh lệch dữ liệu (Diffing) và lưu vào bảngaudit_logs.
Triển khai Chi tiết từng Bước trong NestJS
Bước 1: Xây dựng User Context Module với AsyncLocalStorage
Đầu tiên, chúng ta tạo một Service quản lý AsyncLocalStorage để lưu trữ thông tin của người dùng trong suốt thời gian Request diễn ra.
import { Injectable } from '@nestjs/common';
import { AsyncLocalStorage } from 'async_hooks';
export interface UserContextStore {
userId?: string;
ipAddress?: string;
userAgent?: string;
requestId?: string;
}
@Injectable()
export class UserContextService {
private readonly asyncLocalStorage = new AsyncLocalStorage<UserContextStore>();
public run(store: UserContextStore, callback: () => void): void {
this.asyncLocalStorage.run(store, callback);
}
public getStore(): UserContextStore | undefined {
return this.asyncLocalStorage.getStore();
}
public getUserId(): string | undefined {
return this.asyncLocalStorage.getStore()?.userId;
}
}Tiếp theo, xây dựng một Interceptor để trích xuất dữ liệu từ HTTP Request và khởi tạo Context:
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { UserContextService } from './user-context.service';
@Injectable()
export class UserContextInterceptor implements NestInterceptor {
constructor(private readonly userContextService: UserContextService) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const request = context.switchToHttp().getRequest();
const userId = request.user?.id || 'SYSTEM';
const ipAddress = request.ip || request.connection?.remoteAddress;
const userAgent = request.headers['user-agent'];
const requestId = request.headers['x-request-id'] || String(Date.now());
const store = { userId, ipAddress, userAgent, requestId };
return new Observable((subscriber) => {
this.userContextService.run(store, () => {
next
.handle()
.subscribe({
next: (res) => subscriber.next(res),
error: (err) => subscriber.error(err),
complete: () => subscriber.complete(),
});
});
});
}
}Bước 2: Định nghĩa Schema cho AuditLog Entity
Bảng audit_logs cần lưu trữ thông tin chi tiết về đối tượng bị thay đổi, kiểu hành động, dữ liệu trước và sau khi thay đổi dưới dạng JSONB (đối với PostgreSQL) để phục vụ việc truy vấn nhanh chóng.
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
Index,
} from 'typeorm';
export enum AuditAction {
INSERT = 'INSERT',
UPDATE = 'UPDATE',
DELETE = 'DELETE',
}
@Entity('audit_logs')
export class AuditLogEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
@Index()
entityName: string;
@Column()
@Index()
entityId: string;
@Column({ type: 'enum', enum: AuditAction })
action: AuditAction;
@Column({ type: 'jsonb', nullable: true })
oldValues: Record<string, any> | null;
@Column({ type: 'jsonb', nullable: true })
newValues: Record<string, any> | null;
@Column({ nullable: true })
performedBy: string;
@Column({ nullable: true })
ipAddress: string;
@Column({ nullable: true })
requestId: string;
@CreateDateColumn()
createdAt: Date;
}Bước 3: Xây dựng Global TypeORM Audit Subscriber
Đây là thành phần quan trọng nhất, chịu trách nhiệm so sánh khác biệt (diffing) dữ liệu và lưu vết tự động. Để đạt hiệu năng cao, chúng ta chỉ lưu các trường thực sự bị biến đổi khi xảy ra thao tác UPDATE.
import {
EventSubscriber,
EntitySubscriberInterface,
InsertEvent,
UpdateEvent,
RemoveEvent,
DataSource,
} from 'typeorm';
import { Injectable } from '@nestjs/common';
import { AuditLogEntity, AuditAction } from './audit-log.entity';
import { UserContextService } from './user-context.service';
@Injectable()
@EventSubscriber()
export class AuditSubscriber implements EntitySubscriberInterface {
constructor(
private readonly dataSource: DataSource,
private readonly userContextService: UserContextService,
) {
dataSource.subscribers.push(this);
}
private isIgnoredEntity(entityName: string): boolean {
return entityName === AuditLogEntity.name;
}
public async afterInsert(event: InsertEvent<any>): Promise<void> {
if (!event.entity || this.isIgnoredEntity(event.metadata.name)) return;
await this.saveLog(event, AuditAction.INSERT, null, event.entity);
}
public async afterUpdate(event: UpdateEvent<any>): Promise<void> {
if (!event.entity || this.isIgnoredEntity(event.metadata.name)) return;
const oldValues: Record<string, any> = {};
const newValues: Record<string, any> = {};
if (event.databaseEntity && event.entity) {
for (const column of event.metadata.columns) {
const propertyName = column.propertyName;
const oldValue = event.databaseEntity[propertyName];
const newValue = event.entity[propertyName];
if (newValue !== undefined && JSON.stringify(oldValue) !== JSON.stringify(newValue)) {
oldValues[propertyName] = oldValue;
newValues[propertyName] = newValue;
}
}
}
if (Object.keys(newValues).length > 0) {
await this.saveLog(event, AuditAction.UPDATE, oldValues, newValues);
}
}
public async afterRemove(event: RemoveEvent<any>): Promise<void> {
if (this.isIgnoredEntity(event.metadata.name)) return;
const oldValues = event.databaseEntity || event.entity;
await this.saveLog(event, AuditAction.DELETE, oldValues, null);
}
private async saveLog(
event: InsertEvent<any> | UpdateEvent<any> | RemoveEvent<any>,
action: AuditAction,
oldValues: Record<string, any> | null,
newValues: Record<string, any> | null,
): Promise<void> {
const context = this.userContextService.getStore();
const entityId =
event.entity?.id ||
event.databaseEntity?.id ||
'UNKNOWN';
const auditLog = event.manager.create(AuditLogEntity, {
entityName: event.metadata.name,
entityId: String(entityId),
action,
oldValues,
newValues,
performedBy: context?.userId || 'SYSTEM',
ipAddress: context?.ipAddress || null,
requestId: context?.requestId || null,
});
// Sử dụng queryRunner độc lập hoặc manager hiện tại tùy chiến lược Transaction
await event.manager.getRepository(AuditLogEntity).save(auditLog);
}
}Xử lý các bài toán Nâng cao trong Thực tế
1. Ngăn chặn hiện tượng Vòng lặp Vô hạn (Infinite Loop)
Khi Subscriber bắt được sự kiện và thực hiện lệnh save(auditLog), nếu AuditLogEntity cũng được lắng nghe bởi chính Subscriber đó, hệ thống sẽ rơi vào vòng lặp vô hạn và tràn bộ nhớ (Stack Overflow). Hàm isIgnoredEntity ở đoạn code trên đóng vai trò là chốt chặn loại trừ chính AuditLogEntity khỏi quá trình tracking.
2. Đảm bảo tính nhất quán dữ liệu với Database Transactions
Khi thao tác Business chính nằm trong một Transaction (ví dụ: Chuyển tiền ngân hàng), việc ghi Audit Log nên nằm chung trong Transaction đó (dùng event.manager) để đảm bảo nếu thao tác chính bị Rollback thì Audit Log cũng được Rollback tương ứng. Tuy nhiên, nếu bạn muốn ghi log thất bại (audit cả các vụ đâm lỗi), bạn cần sử dụng một Transaction cách ly hoàn toàn bằng cách lấy connection mới thông qua QueryRunner độc lập.
3. Tối ưu Hiệu năng với Message Queue (BullMQ / RabbitMQ)
Trong các hệ thống có lượng ghi dữ liệu cực cao (High Writes Volume), việc ghi Audit Log đồng bộ (Synchronous) vào Database có thể làm tăng độ trễ (latency) của HTTP Response. Giải pháp nâng cao là đẩy Payload Audit Event vào Message Queue (như Redis BullMQ hoặc RabbitMQ) trong Subscriber, sau đó một Worker Service riêng biệt sẽ đọc từ Queue và chèn vào Database (hoặc Elasticsearch / ClickHouse) theo cơ chế Batching.
# Cấu hình bảng Log trong PostgreSQL tối ưu cho ghi dữ liệu lớn
CREATE TABLE audit_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
entity_name VARCHAR(100) NOT NULL,
entity_id VARCHAR(100) NOT NULL,
action VARCHAR(20) NOT NULL,
old_values JSONB,
new_values JSONB,
performed_by VARCHAR(100),
ip_address VARCHAR(45),
request_id VARCHAR(100),
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
) PARTITION BY RANGE (created_at);Tổng kết
Việc kết hợp TypeORM Entity Subscribers và AsyncLocalStorage mang lại một giải pháp Audit Logging mạnh mẽ, tự động hóa 100% và tách biệt hoàn toàn khỏi Business Logic của ứng dụng. Kiến trúc này không chỉ giúp codebase gọn gàng, tuân thủ các chuẩn mực thiết kế phần mềm sạch (Clean Architecture) mà còn đảm bảo tính toàn vẹn và khả năng truy vết dữ liệu ở mức độ cao nhất cho ứng dụng Enterprise.
Để làm chủ các kỹ thuật nâng cao trong xây dựng hệ thống Back-end quy mô lớn, thiết kế kiến trúc chuẩn hóa và tối ưu hóa hiệu năng cơ sở dữ liệu chuyên sâu, bạn có thể Tham khảo khóa học RESTful API với NestJS & TypeORM tại đây.





Bình luận 0
Chia sẻ ý kiến hoặc đặt câu hỏi cùng cộng đồng