Ghi log hoạt động người dùng (activity log): capture, hàng đợi bất đồng bộ, lưu trữ — so sánh Laravel & NestJS

Đề bài phỏng vấn: hệ thống cần ghi lại lịch sử hoạt động của người dùng (ai làm gì, lúc nào, trên dữ liệu nào) để phục vụ audit, hỗ trợ khách hàng khi có tranh chấp, và truy vết khi có sự cố an ninh. Em thiết kế cơ chế ghi activity log này thế nào? Viết log ngay trong request, cùng transaction với hành động nghiệp vụ, có ổn không? Và khi dữ liệu log lớn dần theo thời gian thì lưu ở đâu?

Trả lời ngắn:

  1. Activity log không phải error log, không phải metric. Error log kể chuyện hệ thống bị lỗi; metric đếm số đo theo thời gian để cảnh báo; activity log là bằng chứng nghiệp vụ — "user 42 đã đổi giá sản phẩm 7 từ 100.000 sang 120.000 lúc 10:03". Ba loại trả lời ba câu khác nhau, gộp chung là nguồn gây nhầm lẫn khi audit.
  2. Đừng ghi log đồng bộ, cùng transaction với hành động nghiệp vụ. Ghi thêm 1 dòng vào activity_logs ngay trong request làm transaction dài hơn, cộng thêm một write vào DB (write amplification), và khiến hành động nghiệp vụ thất bại theo nếu bảng log có vấn đề (hết disk, deadlock, index phình) — hai failure domain lẽ ra độc lập bị khoá chung vào nhau.
  3. Tách thành 3 tầng: capture → hand-off bất đồng bộ → storage. Laravel: Middleware/Model Observer/Event bắt hành động, ShouldQueue listener (hoặc package spatie/laravel-activitylog) đẩy việc ghi ra khỏi request. NestJS: NestInterceptor/Guard bắt hành động, EventEmitter2 (@nestjs/event-emitter) hoặc BullMQ (@nestjs/bullmq) làm hàng đợi.
  4. Lưu vào bảng riêng, có partition theo thời gian, index theo người dùng/đối tượng, và che PII trước khi ghi. Khi lưu lượng rất lớn (ghi hàng trăm event/giây liên tục trên một nguồn), thêm một buffer kiểu Redis Stream + worker gom batch, cùng cơ chế với bài Forum like không trùng với Redis, thay vì 1 queue job cho mỗi event.

Về số liệu trong bài: bài này không chạy lab thật trong lần viết này — không có môi trường Laravel/NestJS/Redis dựng sẵn ở máy viết bài để đo số. Mọi tên class, tên method, tên package được kiểm tra qua tài liệu chính thức (laravel.com/framework/docs, docs.nestjs.com, spatie.be/docs/laravel-activitylog, docs.bullmq.io) tại thời điểm viết (10/2026), ghi kèm số version khi nguồn có nêu. Con số nào xuất hiện trong bài mà không phải đo thật sẽ được gắn rõ "ví dụ minh hoạ, không phải số đo thật"; phần nào chỉ suy luận từ cơ chế, chưa dựng lại được trong lab sẽ ghi "chưa verify".

Thuật ngữ dùng trong bài

Thuật ngữNghĩa ngắn
activity log / audit logLịch sử hành động của người dùng trên hệ thống (login, CRUD, đổi quyền...), ghi lại để tra cứu sau. Khác log lỗi (error log) và số đo (metrics).
middlewareLớp xử lý nằm giữa request và route handler (Laravel). Chạy trước/sau handler cho mọi route khớp.
interceptorKhái niệm tương đương middleware ở NestJS, nhưng bọc quanh cả handler bằng RxJS Observable nên can thiệp được cả trước và sau khi handler trả kết quả.
event listenerHàm/class chạy khi một event được phát ra, tách rời khỏi code phát event (decoupling). Laravel: Event::listen/class Listener. NestJS: @OnEvent.
queue / jobHàng đợi tác vụ chạy sau, không chặn request. Laravel Queue (ShouldQueue), NestJS BullMQ (@nestjs/bullmq).
bufferChỗ giữ dữ liệu tạm trước khi ghi xuống nơi lưu lâu dài, thường để gom nhiều bản ghi thành một lô (batch) ghi rẻ hơn.
backpressureTín hiệu "đang quá tải, chậm lại" khi bên tiêu thụ (consumer/worker) không theo kịp bên sinh ra dữ liệu. Không xử lý thì hàng đợi phình vô hạn hoặc dữ liệu bị rớt.
idempotentChạy lại nhiều lần cho cùng kết quả như chạy một lần. Quan trọng với worker vì hàng đợi thường chỉ đảm bảo "giao ít nhất một lần" (at-least-once).
partitioningChia một bảng lớn thành nhiều bảng con theo tiêu chí (thường theo thời gian), để query và xoá dữ liệu cũ nhanh hơn, không phải quét cả bảng.
PII maskingChe/ẩn dữ liệu định danh cá nhân (PII — Personally Identifiable Information: email, số điện thoại, địa chỉ IP đầy đủ...) trước khi ghi vào nơi lưu lâu dài hoặc gửi đi nơi khác.
structured loggingGhi log dưới dạng có cấu trúc (JSON) với field cố định, thay vì một chuỗi văn bản tự do — máy đọc/lọc được, không cần regex.
correlation id / request idMột mã duy nhất gắn vào một request, in ra ở mọi tầng/log liên quan, để nối các dòng log của cùng một request lại với nhau.
retention policyQuy định giữ dữ liệu bao lâu rồi xoá/lưu trữ lạnh (archive). Audit log thường có yêu cầu giữ tối thiểu (pháp lý), khác với log debug chỉ cần giữ vài ngày.
write amplificationMột hành động nghiệp vụ sinh ra nhiều hơn một lần ghi xuống đĩa/DB. Ghi activity log đồng bộ cùng transaction nghiệp vụ là một dạng write amplification.

1. Bài toán gốc và bối cảnh ra đời

Hầu hết hệ thống B2B/SaaS, sau một thời gian chạy, đều nhận câu hỏi dạng: "Ai đã xoá đơn hàng này?", "Giá sản phẩm bị đổi lúc nào, ai đổi?", "Nhân viên X có quyền xem dữ liệu khách hàng Y không, họ có từng xem chưa?". Không có activity log, câu trả lời là không biết — chỉ có thể suy luận từ dữ liệu hiện tại (updated_at, version mới nhất), mất hết lịch sử trung gian.

Nhu cầu ghi activity log thường xuất hiện từ ba hướng:

Sai lầm hay gặp nhất ở bước thiết kế đầu tiên là lẫn ba loại dữ liệu quan sát khác nhau (mô tả kỹ ở bài Bộ công cụ để vận hành):

LoạiTrả lời câu hỏiVí dụAi đọc
Activity / audit logAi đã làm gì, trên đối tượng nào, lúc nào?"user 42 updated Product#7: price 100000 → 120000"Admin, auditor, support, pháp lý
Error logHệ thống bị lỗi gì, ở đâu?"SQLSTATE[23505] duplicate key..."Kỹ sư vận hành
MetricsCó bất thường không, từ khi nào?p95 route /checkout = 820msDashboard, alert

Cách làm đơn giản nhất — và cũng là cách hay bị chọn nhầm cho production — là chèn thêm một câu INSERT INTO activity_logs ngay trong cùng method, cùng transaction với hành động nghiệp vụ:

DB::transaction(function () use ($order, $request) {
    $order->update(['status' => 'cancelled']);

    ActivityLog::create([               // ← ghi log ngay trong transaction nghiệp vụ
        'user_id' => $request->user()->id,
        'action'  => 'order.cancelled',
        'subject' => $order->id,
    ]);
});

Chạy đúng lúc ít request thì không sao. Nhưng cách này cõng theo ba vấn đề âm thầm, càng lớn càng đau:

2. Mô hình tư duy cốt lõi

Giải pháp chung cho cả Laravel và NestJS là tách activity logging ra khỏi đường nghiệp vụ chính bằng ba tầng độc lập:

  1. Capture — một điểm chặn đứng (middleware/interceptor) hoặc một điểm bắt sự kiện (model observer/event) ghi lại "cái gì vừa xảy ra", không tự ghi xuống DB ngay.
  2. Async hand-off — việc ghi thật được đẩy ra khỏi request bằng event + queued listener, hoặc một hàng đợi riêng (BullMQ, Redis Stream), chạy ở một process khác.
  3. Storage — một nơi lưu tối ưu cho việc ghi nhiều, đọc theo user/subject, hiếm khi update — khác hẳn đặc tính của bảng nghiệp vụ.
flowchart LR
  subgraph WEB["Request - duong nong"]
    CL["Client"] --> MW["Middleware / Interceptor
bat request, gan request_id"] MW --> BIZ["Business handler
Controller / Service"] BIZ --> DB1["DB nghiep vu
orders, products..."] end BIZ -->|"emit event, khong cho ghi log"| EV["Event
Laravel Event / EventEmitter2"] BIZ -.->|"tra response ngay"| CL subgraph ASYNC["Hand-off bat dong bo"] EV --> Q["Queue
Laravel Queue / BullMQ"] end Q --> W["Worker
Queued Listener / Processor"] subgraph STORE["Storage"] W --> LOGDB["activity_logs
partition theo thang, index user_id"] end

Điểm mấu chốt là nhánh BIZ -.-> CL: response trả về người dùng không chờ việc ghi log hoàn tất. Nếu worker chết, request nghiệp vụ vẫn thành công — đổi lại là activity log có thể trễ vài giây, hoặc trong trường hợp xấu nhất, mất một event (bàn kỹ ở mục 4 và 5).

3. Internals và quyết định thiết kế

3.1 Tầng capture — Laravel

Ba cách bắt sự kiện, dùng cho ba nhu cầu khác nhau, theo tài liệu chính thức Laravel (nhánh laravel/docs, bản 12.x):

CáchBắt được gìKhi dùng
MiddlewareMọi request khớp route — method, URI, status code, ai gọiLog truy cập API, không cần biết chi tiết nghiệp vụ bên trong (ai gọi GET /api/orders)
Model Observer / Eloquent eventVòng đời model: created, updated, deleted, kèm field nào đổiLog CRUD chi tiết trên một model cụ thể, không phải sửa code ở mọi controller gọi tới model đó
Event tự phát (event(new ...))Đúng nghiệp vụ, đặt tên theo domain (ví dụ OrderCancelled)Hành động không map 1-1 với một model (đổi quyền, xuất báo cáo, duyệt đơn nhiều bước)

Middleware ghi context cho mọi log về sau (cách này trùng với kỹ thuật request id đã nói ở bài Bộ công cụ để vận hành):

class LogApiAccess
{
    public function handle(Request $request, Closure $next): Response
    {
        $response = $next($request);

        if ($request->user()) {
            event(new ActivityLogged(
                userId:  $request->user()->id,
                action:  $request->method() . ' ' . $request->path(),
                subject: null,
                properties: ['status' => $response->getStatusCode()],
            ));
        }

        return $response;   // response đã trả, event mới phát ra sau — không chặn client
    }
}

Model Observer bắt chi tiết field nào đổi, dùng Illuminate\Support\Facades\Event hoặc đăng ký observer trực tiếp trên model (API observer theo tài liệu Eloquent của Laravel 12.x):

class Product extends Model
{
    protected static function booted(): void
    {
        static::updated(function (Product $product) {
            if ($product->isDirty('price')) {
                event(new ActivityLogged(
                    userId:  auth()->id(),
                    action:  'product.price_changed',
                    subject: $product->id,
                    properties: [
                        'from' => $product->getOriginal('price'),
                        'to'   => $product->price,
                    ],
                ));
            }
        });
    }
}

Off-the-shelf: spatie/laravel-activitylog v5 (yêu cầu PHP 8.4+ và Laravel 12+ theo ghi chú phát hành v5 của Spatie) tự làm việc ở trên bằng một trait, không cần tự viết observer cho từng model:

use Spatie\Activitylog\Models\Concerns\LogsActivity;
use Spatie\Activitylog\Support\LogOptions;

class Product extends Model
{
    use LogsActivity;

    public function getActivitylogOptions(): LogOptions
    {
        return LogOptions::defaults()
            ->logOnly(['price', 'name'])
            ->logOnlyDirty();          // chỉ ghi khi field thực sự đổi
    }
}

Package này ghi đồng bộ vào bảng activity_log ngay trong request theo mặc định — tiện cho audit trail đơn giản, lưu lượng thấp (ví dụ trang quản trị nội bộ), nhưng quay lại đúng vấn đề ở mục 1 nếu site có lưu lượng lớn. Muốn bất đồng bộ phải tự thêm lớp queue ở trên (mục 3.2), package không làm sẵn việc đó.

3.2 Tầng hand-off bất đồng bộ — Laravel Queue

Event ActivityLogged ở trên chỉ là một DTO; việc ghi DB nằm trong listener, và listener đó implement ShouldQueue để Laravel tự đẩy nó vào hàng đợi thay vì chạy ngay (theo tài liệu Events của Laravel):

class ActivityLogged
{
    public function __construct(
        public readonly ?int $userId,
        public readonly string $action,
        public readonly ?int $subject,
        public readonly array $properties = [],
    ) {}
}

class LogActivityListener implements ShouldQueue
{
    public $queue = 'activity-log';     // queue riêng, không cạnh tranh với job nghiệp vụ
    public $tries = 3;
    public $afterCommit = true;         // chờ transaction nghiệp vụ COMMIT xong mới chạy

    public function handle(ActivityLogged $event): void
    {
        ActivityLog::create([
            'user_id'    => $event->userId,
            'action'     => $event->action,
            'subject_id' => $event->subject,
            'properties' => $this->maskPii($event->properties),
        ]);
    }

    private function maskPii(array $properties): array
    {
        foreach (['email', 'phone', 'ip'] as $field) {
            if (isset($properties[$field])) {
                $properties[$field] = Str::mask($properties[$field], '*', 3);
            }
        }
        return $properties;
    }
}

$afterCommit = true là chi tiết hay bị quên: nếu event được phát ra trong một DB::transaction() nghiệp vụ mà queue connection không chạy ngay (ví dụ driver database/redis), listener có thể cố ghi log cho một bản ghi mà transaction chứa nó chưa commit — ghi log cho dữ liệu "chưa từng tồn tại" nếu transaction sau đó rollback. Theo tài liệu Queue của Laravel, đặt $afterCommit trên listener (hoặc after_commit ở cấu hình connection) để job chỉ thực sự được đẩy vào hàng đợi sau khi mọi transaction đang mở đã commit xong.

3.3 Tầng capture và hand-off — NestJS

Tương đương middleware là NestInterceptor, bọc quanh handler bằng Observable của RxJS (theo tài liệu Interceptors của NestJS):

@Injectable()
export class ActivityLogInterceptor implements NestInterceptor {
  constructor(private readonly events: EventEmitter2) {}

  intercept(ctx: ExecutionContext, next: CallHandler): Observable<any> {
    const req = ctx.switchToHttp().getRequest();

    return next.handle().pipe(
      tap(() => {
        // fire-and-forget: không await, không chặn response đã trả cho client
        this.events.emit('activity.logged', {
          userId: req.user?.id,
          action: `${req.method} ${req.path}`,
          requestId: req.id,
        });
      }),
    );
  }
}

Lắng nghe event bằng @nestjs/event-emitter (EventEmitter2) — cách này không đảm bảo bền (durable): nếu process Node chết giữa lúc event đang xử lý, event mất, không có nơi nào giữ lại để chạy lại:

@Injectable()
export class ActivityLogListener {
  constructor(private readonly repo: ActivityLogRepository) {}

  @OnEvent('activity.logged', { suppressErrors: true })
  async handle(payload: ActivityPayload): Promise<void> {
    await this.repo.insert({
      ...payload,
      properties: this.maskPii(payload.properties),
    });
  }
}

Khi cần bền hơn — chấp nhận delay vài giây nhưng không chấp nhận mất event khi process chết — chuyển sang BullMQ (@nestjs/bullmq, dùng Redis để giữ job, theo tài liệu Queues của NestJS):

@Injectable()
export class ActivityLogInterceptor implements NestInterceptor {
  constructor(@InjectQueue('activity-log') private readonly queue: Queue) {}

  intercept(ctx: ExecutionContext, next: CallHandler): Observable<any> {
    const req = ctx.switchToHttp().getRequest();
    return next.handle().pipe(
      tap(() => {
        this.queue.add('log', {
          userId: req.user?.id,
          action: `${req.method} ${req.path}`,
        }, { removeOnComplete: true, attempts: 3 });
      }),
    );
  }
}

@Processor('activity-log')
export class ActivityLogProcessor extends WorkerHost {
  constructor(private readonly repo: ActivityLogRepository) { super(); }

  async process(job: Job<ActivityPayload>): Promise<void> {
    await this.repo.insert(this.maskPii(job.data));
  }
}

Khác biệt cốt lõi so với EventEmitter2: job nằm trong Redis cho tới khi worker ACK xong, nên worker restart giữa lúc xử lý thì BullMQ giao lại job đó — cùng bản chất "giao ít nhất một lần" (at-least-once) như Redis Stream ở bài Forum like không trùng với Redis.

3.4 Tầng storage

Bảng riêng, không chung với bảng nghiệp vụ, tối ưu cho ghi nhiều — đọc theo người dùng/đối tượng, hiếm khi update hoặc không bao giờ update (append-only):

CREATE TABLE activity_logs (
  id           bigint GENERATED ALWAYS AS IDENTITY,
  occurred_at  timestamptz NOT NULL DEFAULT now(),
  user_id      bigint,                 -- NULL nếu hành động hệ thống tự làm
  action       text NOT NULL,          -- 'order.cancelled', 'product.price_changed'
  subject_type text,
  subject_id   bigint,
  properties   jsonb NOT NULL DEFAULT '{}',
  request_id   text,                   -- nối ngược về log request/trace
  PRIMARY KEY (id, occurred_at)
) PARTITION BY RANGE (occurred_at);

CREATE TABLE activity_logs_2026_10 PARTITION OF activity_logs
  FOR VALUES FROM ('2026-10-01') TO ('2026-11-01');

CREATE INDEX ON activity_logs_2026_10 (user_id, occurred_at DESC);
CREATE INDEX ON activity_logs_2026_10 (subject_type, subject_id, occurred_at DESC);

Partition theo tháng để: (a) truy vấn "lịch sử của đối tượng X" chỉ cần quét partition liên quan nếu kèm điều kiện thời gian; (b) xoá dữ liệu quá hạn retention là DROP TABLE một partition — rẻ hơn rất nhiều so với DELETE hàng triệu dòng và để lại dead tuple cho autovacuum dọn (xem cơ chế dead tuple ở bài Bộ công cụ để vận hành).

Khi nào tách sang store riêng (Elasticsearch/ClickHouse/time-series)? Khi nhu cầu đọc chính là "tìm kiếm full-text trong properties" hoặc "truy vấn phân tích tổng hợp hàng tỉ dòng" — hai việc PostgreSQL làm được nhưng không phải thế mạnh ở quy mô lớn. Dấu hiệu cụ thể: dashboard audit chạy chậm, hoặc jsonb trong properties bị query bằng điều kiện không có index (properties->>'field' = ? trên hàng chục triệu dòng) nằm trong top pg_stat_statements theo total_exec_time. Không tách trước khi có dấu hiệu đó.

4. Đảm bảo và giới hạn

Thuộc tínhMô hình này đảm bảo gìGiới hạn
Độ trễ đường nghiệp vụKhông tăng đáng kể — việc ghi log không nằm trên critical path, response trả về ngay sau khi emit event/jobNếu interceptor/listener vẫn await việc ghi log (quên fire-and-forget) thì mất hết lợi ích này
Tính bền của eventLaravel Queue (driver database/redis) và BullMQ: at-least-once — job còn trong hàng đợi tới khi worker ACK xongEventEmitter2 thuần: không bền, event mất ngay nếu process chết giữa lúc xử lý
Thứ tự ghi logTheo thứ tự request tới trong cùng một queue/worker đơn luồngNhiều worker chạy song song có thể ghi log không đúng thứ tự thời gian thực tế xảy ra; cần dựa vào occurred_at ghi tại thời điểm capture, không phải thời điểm worker xử lý
Idempotency khi worker chạy lạiActivity log vốn append-only, chạy lại một job y hệt sinh ra một dòng log trùng, không sinh ra dữ liệu sai lệch nghiêm trọng như counterNếu cần đúng-một-lần tuyệt đối (ví dụ activity log dùng để tính phí theo số lần thao tác), phải gắn thêm key idempotency (event id) và unique constraint
Tính đầy đủ (completeness)Capture ở middleware/interceptor bắt được mọi request khớp route đã đăng kýHành động xảy ra ngoài tầng HTTP (job nền tự sửa dữ liệu, truy cập DB trực tiếp, script bảo trì) không được capture nếu không tự phát event ở đó

5. Failure modes

Failure modeVì sao xảy raHậu quảGiảm nhẹ
Queue backlogWorker chậm hơn tốc độ sinh event (traffic tăng đột biến, worker ít hơn nhu cầu)Log hiển thị trễ hàng phút tới hàng giờ; audit "gần real-time" nhưng thực tế khôngAlert theo độ sâu hàng đợi (Laravel: đếm job theo queue; BullMQ: getJobCounts()); scale thêm worker theo chiều ngang
Worker crash giữa lúc xử lý, mất eventDùng EventEmitter2 thuần (không bền) hoặc driver queue không persist (ví dụ Laravel sync/null driver ở production)Một phần hành động không có trong activity log — audit trail có lỗ hổng, khó phát hiện vì hệ thống không báo lỗi gìDùng driver queue có persist (database/redis cho Laravel, BullMQ cho NestJS) cho mọi log bắt buộc phải đầy đủ; chấp nhận at-least-once + idempotency key nếu cần chính xác tuyệt đối
Write storm trên endpoint nóngMột hành động hàng loạt (bulk update, import CSV, cron chạy duyệt hàng nghìn bản ghi) sinh ra hàng nghìn event activity log cùng lúcHàng đợi/DB log bị dồn đột ngột; nếu cùng Redis/DB với hệ thống chính thì ảnh hưởng luôn cả nghiệp vụGom log của hành động bulk thành một bản ghi tổng hợp ("user 42 updated 5000 products via import #12") thay vì 5000 dòng; hoặc dùng buffer + batch worker (mục 7) cho endpoint biết trước là nóng
Bảng log phình, không index đúng, kéo chậm cả DB chínhQuery báo cáo audit quét properties (jsonb) không có index, hoặc không partition nên mỗi query chạm toàn bảng nhiều năm dữ liệuQuery audit chạy hàng chục giây, chiếm hết connection pool ảnh hưởng tới cả truy vấn nghiệp vụ nếu dùng chung DBPartition theo thời gian (mục 3.4); index đúng theo pattern đọc thật (user_id/subject); tách sang replica đọc hoặc store riêng nếu báo cáo audit là nguồn đọc nặng

6. Vận hành trong production

6.1 Correlation id xuyên tầng

Activity log của một request phải nối được với log lỗi/trace của cùng request đó — dùng đúng cơ chế request id đã mô tả ở bài Bộ công cụ để vận hành: Laravel middleware gắn Log::shareContext(['request_id' => $id]), truyền id đó kèm theo khi phát event ActivityLogged để lưu luôn vào cột request_id của bảng log. NestJS tương đương dùng nestjs-pino (sinh req.id tự động mỗi request) và truyền req.id vào payload event/job giống cách interceptor ở mục 3.3 đã làm với req.id.

6.2 Structured logging

Activity log nên ghi dạng có cấu trúc để lọc theo field (user_id, action) mà không cần regex. Laravel: channel Monolog riêng với JsonFormatter (tương tự cấu hình đã nêu ở bài Bộ công cụ để vận hành) cho log hệ thống; còn chính bảng activity_logs vốn đã có cấu trúc qua cột + jsonb, không cần qua Monolog. NestJS: nestjs-pino cho log vận hành (request/response), còn activity log lưu DB như đã thiết kế ở mục 3.4 — hai luồng dữ liệu riêng, đừng trộn activity log vào file log ứng dụng rồi grep lại, sẽ chậm và dễ sót như đã nói ở mục 1.

6.3 Dashboard và cảnh báo

Theo dõi gìLaravelNestJS
Độ sâu hàng đợiĐếm job theo queue name (driver database: SELECT count(*) FROM jobs WHERE queue = 'activity-log'; driver redis: LLEN)BullMQ Queue.getJobCounts() — Bull Board/Taskforce để xem trực quan
Job thất bạiBảng failed_jobs, alert khi tăng bất thườngBullMQ event failed, hoặc job vào trạng thái dead-letter sau hết attempts
Worker còn sống khôngSupervisor/systemd giám sát process queue:work, alert nếu process chếtGiám sát process Node chạy @Processor, hoặc container healthcheck

6.4 Xoá dữ liệu kiểu GDPR mà vẫn giữ được audit trail

Yêu cầu xoá dữ liệu cá nhân (quyền được xoá) và yêu cầu giữ audit trail đầy đủ (compliance) mâu thuẫn nhau nếu xử lý ngây thơ. Cách phổ biến: không xoá dòng log, mà ẩn danh hoá (anonymize) phần định danh — giữ lại hành động và thời điểm, xoá liên kết tới cá nhân cụ thể:

UPDATE activity_logs
   SET properties = properties - 'email' - 'phone',
       user_id    = NULL              -- hoặc trỏ sang một user "deleted" dùng chung
 WHERE user_id = :deleted_user_id;

Việc này phải nằm trong quy trình xoá tài khoản (thường là một job chạy sau khi tài khoản bị xoá), không phải xoá cứng DELETE FROM activity_logs — xoá cứng làm mất luôn bằng chứng "ai đã làm gì" trước khi tài khoản bị xoá, đúng lúc dữ liệu đó có thể cần cho điều tra.

7. So sánh và ma trận quyết định

Mức 1: Đồng bộ, insert thẳngMức 2: Event + queue (bài này tập trung)Mức 3: Buffer + batch worker (Redis Stream)
Cách làmspatie/laravel-activitylog mặc định, hoặc insert trực tiếp trong requestMiddleware/Interceptor emit event → ShouldQueue listener / BullMQ Processor ghi DBCapture → XADD vào Redis Stream → worker đọc theo batch, gộp rồi ghi DB (giống mục 4 của bài Forum like)
Thêm latency requestCó — 1 write DB thêm, trong transaction nghiệp vụGần như không — chỉ chi phí emit event (trong-process), ghi DB xảy ra ở workerGần như không — chỉ chi phí 1 lệnh Redis XADD (dưới 1 ms)
Chịu được write stormKhông — storm nào cũng đập thẳng DB nghiệp vụChịu được mức vừa — mỗi event vẫn là 1 job, nhiều job đồng nghĩa nhiều lần ghi DB của worker, có thể dồn hàng đợiChịu tốt nhất — worker gom N event thành 1-vài câu INSERT theo lô
Số hệ thống phải vận hành thêm0 (dùng DB có sẵn)1 (queue driver + worker process)2 (Redis Stream + queue driver nếu vẫn giữ queue cho việc khác, + worker gom batch)
Chọn khiTrang quản trị nội bộ, lưu lượng thấp (vài chục action/phút), chấp nhận thêm vài ms latencyPhần lớn ứng dụng web/API thông thường. Có queue sẵn cho việc khác (mail, notification) thì tận dụng luônMột vài endpoint biết trước là nóng (webhook nhận hàng loạt, API được gọi rất dày từ hệ thống khác), đã có Redis vận hành ổn
flowchart TD
  A["Luu luong ghi log co du lon de gay write storm khong?"] -->|"Khong"| B{"Co can log tuc thi, chap nhan them vai ms latency?"}
  B -->|"Co, he thong noi bo, it dung"| M1["Muc 1: dong bo, insert thang
(hoac spatie/laravel-activitylog mac dinh)"] B -->|"Khong, uu tien request nhanh"| M2["Muc 2: Event + Queue
ShouldQueue listener / BullMQ Processor"] A -->|"Co, mot vai endpoint nong"| C["Da co Redis van hanh on, co nguoi truc?"] C -->|"Chua"| M2 C -->|"Roi"| M3["Muc 3: Buffer Redis Stream + worker gom batch"]

Laravel vs NestJS — cùng vai trò, tên khác nhau

Vai tròLaravelNestJS
Chặn ở tầng HTTPMiddlewareNestInterceptor (bọc bằng RxJS Observable, can thiệp được cả before và after)
Bắt vòng đời modelModel Observer / Eloquent eventKhông có cơ chế tương đương sẵn trong ORM; thường tự phát event trong service sau khi repository ghi xong
Decouple logicEvent + Listener (Illuminate\Support\Facades\Event)EventEmitter2 qua @nestjs/event-emitter (emit/@OnEvent)
Hàng đợi bền (persist)Laravel Queue — driver database/redis, listener implement ShouldQueueBullMQ qua @nestjs/bullmq (Queue.add / @Processor + WorkerHost)
Off-the-shelf activity logspatie/laravel-activitylog v5Không có package "chuẩn de facto" tương đương phổ biến; thường tự viết theo pattern ở mục 3.3
Structured loggingMonolog + JsonFormatternestjs-pino (Pino)

8. Góc nhìn tech lead: tác động tổ chức

9. Anti-patterns và bài học thực tế

Anti-patternVì sao hấp dẫn lúc đầuHậu quả khi lớnCách đúng
Ghi activity log cùng transaction với business writeĐơn giản, chắc chắn "log và data luôn khớp nhau"Request chậm hơn; bảng log có vấn đề làm business write rollback theo (xem mục 1); không scale khi traffic tăngTách bằng event + queued listener (mục 3), chấp nhận log là "best effort" / "eventual", không phải điều kiện thành công của nghiệp vụ
Cột properties kiểu JSON/TEXT không index, rồi quét toàn bảng cho báo cáo auditLinh hoạt, không cần migration khi thêm field mớiBáo cáo audit chạy hàng chục giây, quét hết pg_stat_statements.total_exec_time; dễ thành query tốn nhất hệ thống mà không ai để ý vì nó không nằm trên đường nghiệp vụ chínhPartition theo thời gian (mục 3.4), index các field tra cứu thường xuyên (user_id, subject) ở mức cột riêng, chỉ để field biến thiên vào jsonb và thêm GIN index nếu thật sự cần query theo field đó
Lưu PII thô (email, số điện thoại, IP đầy đủ) trong log hoặc trong propertiesTiện tra cứu, không cần join sang bảng userMỗi bản sao log (backup, export cho debug, gửi sang công cụ giám sát bên thứ ba) là một bản sao PII phải bảo vệ; vi phạm nguyên tắc tối thiểu hoá dữ liệu cá nhânMask trước khi ghi (mục 3.2 — maskPii), chỉ giữ ID tham chiếu (user_id) để join khi thật sự cần, không copy nguyên văn thông tin định danh vào log
Gọi HTTP đồng bộ tới một SaaS logging bên thứ ba ngay trong requestDễ tích hợp, có dashboard đẹp ngay, không cần tự vận hành gìRequest nghiệp vụ phụ thuộc vào uptime và latency của một dịch vụ bên ngoài; SaaS chậm hoặc timeout thì toàn bộ request bị treo theo, đúng kiểu coupling đã cảnh báo ở mục 1Gửi qua hàng đợi bất đồng bộ (mục 3.2/3.3) hoặc batch định kỳ, không bao giờ await một HTTP call ra ngoài hệ thống trên đường trả response cho người dùng

10. Câu hỏi phụ interviewer hay hỏi tiếp

Câu hỏiTrả lời ngắn
Log bị mất khi worker chết thì sao?Phụ thuộc driver. Laravel Queue driver database/redis và BullMQ đều persist job trước khi worker nhận — worker chết giữa lúc xử lý, job còn nằm trong hàng đợi, lần sau được giao lại (at-least-once). Chỉ mất nếu dùng cơ chế không bền như EventEmitter2 thuần hoặc queue driver sync.
Làm sao biết ai đã đổi field X lúc nào?Model Observer (Laravel) bắt isDirty('X') trong updated, ghi cả giá trị cũ (getOriginal) và mới vào properties. NestJS: service so sánh entity trước/sau khi update rồi tự phát event tương đương, vì ORM không có hook sẵn kiểu Eloquent.
Audit log có cần transaction cùng business data không?Không nên. Tách bằng event bất đồng bộ (mục 2–3). Cái cần chung transaction là business data với chính nó (ví dụ trừ tiền và tạo đơn hàng phải atomic); activity log là lớp quan sát thêm vào, chấp nhận trễ vài giây hoặc hiếm khi mất một dòng, đổi lại không làm chậm hay làm fail nghiệp vụ chính.
Vì sao không dùng package activity log có sẵn cho mọi trường hợp?Package như spatie/laravel-activitylog mặc định ghi đồng bộ — tốt cho trang quản trị nội bộ, lưu lượng thấp. Traffic lớn thì phải tự thêm lớp queue phía trên nó (dispatch event rồi mới gọi logic ghi), package không tự làm bất đồng bộ.
Thứ tự các dòng log có đảm bảo đúng thứ tự xảy ra không?Không đảm bảo theo thứ tự worker xử lý nếu chạy nhiều worker song song. Phải dựa vào timestamp ghi tại thời điểm capture (occurred_at gán lúc event phát ra), không phải lúc worker insert vào DB, và sort theo cột đó khi hiển thị.
Sao không ghi log thẳng vào file rồi dùng Loki/ELK, cần gì bảng DB riêng?File log hợp cho log vận hành (debug, trace lỗi), nơi câu hỏi là "request X bị gì". Activity log cần truy vấn có cấu trúc theo user/subject/thời gian cho báo cáo audit và cho UI admin hiển thị lịch sử — SQL với index đúng làm việc đó nhanh và rẻ hơn quét log tập trung.
Batch ghi log có làm mất dữ liệu nếu một event trong batch bị lỗi (ví dụ subject đã bị xoá)?Nếu dùng INSERT dạng INSERT ... SELECT ... JOIN lọc trước (giống cơ chế ở bài Forum like Redis), event có subject không còn tồn tại bị loại ra khỏi batch chứ không làm rollback cả batch — tránh "poison batch" làm worker kẹt.
Xoá tài khoản (GDPR) thì activity log của họ xử lý sao?Ẩn danh hoá, không xoá cứng (mục 6.4): giữ lại hành động + thời điểm cho mục đích audit, xoá/thay field định danh cá nhân. Xoá cứng làm mất bằng chứng đúng lúc có thể cần nhất (ví dụ điều tra trước khi tài khoản bị xoá).
Có nên cho user tự xem activity log của chính họ?Tuỳ sản phẩm — nhiều hệ thống SaaS B2B cho phép ("Lịch sử hoạt động"). Khi cho xem, càng phải mask kỹ các field nội bộ (IP nội bộ, id hệ thống khác) không liên quan tới trải nghiệm của chính người dùng đó.
Metrics (ví dụ Prometheus counter) có thay được activity log không?Không. Metric đếm được "có bao nhiêu lần action X xảy ra" nhưng không giữ được "ai, trên đối tượng nào, giá trị cũ/mới là gì" — đúng câu hỏi activity log phải trả lời (mục 1). Hai loại bổ sung nhau, không thay nhau.

Phụ lục: lab đã chạy

Không có lab chạy thật trong lần viết bài này. Không có sẵn môi trường Laravel + NestJS + Redis dựng cạnh nhau ở máy viết bài để đo số liệu (latency, TPS, số event mất khi kill worker...) như các bài khác trong repo (ví dụ Forum like không trùng với Redis có PostgreSQL và Redis container thật). Mọi nội dung kỹ thuật trong bài — tên class, tên method, tên package, hành vi ($afterCommit, at-least-once của BullMQ, cách EventEmitter2 không bền) — được kiểm tra qua tài liệu chính thức tại thời điểm viết (10/2026):

Các đoạn code trong bài là ví dụ viết tay theo đúng API của các nguồn trên, chưa được chạy/test thật trong một project cụ thể — khác với các bài có nhãn "đã chạy thật" trong repo này. Số liệu nào mang tính minh hoạ (ví dụ "hàng trăm event/giây", "vài giây trễ") đều là ước lượng để giải thích khái niệm, không phải số đo thật, và đã được ghi rõ ngay tại chỗ xuất hiện trong bài. Việc còn lại — nếu muốn verify đầy đủ — là dựng một project Laravel 12 + một project NestJS 12 cạnh nhau, cùng Redis/queue driver, rồi đo lại đúng các bảng ở mục 4, 5, 7 bằng số thật, theo đúng cách bài Forum like Redis đã làm.