Hết Part 3, sản phẩm chạy trọn một vòng: đăng nhập, upload, chuyển mã, xem tiến độ, phát lại. Tất cả nằm trong một khối mã và một database.

Phần lớn bài viết về microservice bắt đầu ở đây bằng câu "giờ ta tách ra cho đúng chuẩn". Part này không làm vậy. Nó tách, nhưng trước hết trả lời câu hỏi vì sao, rồi trả lời trung thực câu hỏi mất gì — vì cái mất lớn hơn nhiều so với những gì các sơ đồ kiến trúc thường thừa nhận.

🔍 Hết Part 4 bạn sẽ có gì
Hệ thống tách thành ba dịch vụ nói chuyện qua gRPC, với file .proto là hợp đồng chung. Hiểu chính xác vì sao transaction của Part 1 vỡ khi hai thao tác rơi vào hai dịch vụ, và outbox pattern vá nó thế nào. Một tầng cache có chống dồn toa. Correlation ID xuyên suốt mọi dịch vụ để còn gỡ lỗi được. Và một danh sách những thứ đã cố ý không làm, kèm lý do.

1. Khi nào thì tách — và khi nào thì không

Monolith của ba part vừa rồi không phải bản nháp. Nó là kiến trúc đúng cho tới khi có một lý do cụ thể buộc phải đổi. Dự án này có đúng một lý do như vậy, và nó nằm ở phần chuyển mã.

Nhu cầu tài nguyên của hai loại việc
Xac thuc, danh sach video, phat lai
  - moi request: vai mili giay, gan nhu khong ton CPU
  - can: NHIEU ban nho, phan hoi nhanh
  - luu luong: 10.000 request/phut la binh thuong

Chuyen ma
  - moi job: vai phut, an tron mot nhan CPU
  - can: IT ban nhung MAY MANH, co the co GPU
  - luu luong: 50 job/gio da la nhieu

=> Hai loai nay khong the dung chung mot cach nhan ban.
   Nhan ban de phuc vu HTTP thi thua CPU. Nhan ban de chuyen ma thi
   tra tien cho mot dong may manh ngoi cho request nhe.

Đó là một lý do thật. Còn những lý do sau đây, nghe rất hay nhưng không đủ để đánh đổi: "microservice là chuẩn hiện đại", "để mỗi đội sở hữu một dịch vụ" (khi cả dự án có ba người), "để dễ mở rộng sau này" (khi chưa biết sẽ mở rộng theo chiều nào).

⚠️ Cái giá thật, liệt kê thẳng
Một lời gọi hàm mất vài nano giây và không bao giờ thất bại vì mạng. Cũng lời gọi đó qua gRPC mất vài mili giây và có thể thất bại vì hàng chục lý do mới: mất gói, dịch vụ kia đang khởi động lại, phiên bản proto lệch nhau, timeout.

Ngăn xếp lỗi không còn đi xuyên qua chỗ hỏng nữa. Gỡ lỗi tại chỗ thành gỡ lỗi phân tán. Triển khai một bản thành phối hợp nhiều bản. Và — phần đắt nhất — transaction không còn ôm được cả hai thao tác, đó là mục 4.

Tách khi cái giá đó nhỏ hơn cái lợi cụ thể bạn đo được. Không tách vì nó nghe đúng.

2. Ranh giới đã vạch từ Part 1

Đây là lúc quyết định ở Part 1 — chia thư mục theo miền nghiệp vụ chứ không theo loại file — trả lời cho chính nó. Việc tách không phải viết lại, mà là đổi đường truyền giữa các module đã có.

TRƯỚC — một tiến trình, gọi nhau bằng inject auth media billing một database, một transaction ôm được tất cả lời gọi: vài nano giây, không bao giờ lỗi mạng tách SAU — ba tiến trình, gọi nhau bằng gRPC auth-svc DB riêng nhiều bản nhỏ media-svc DB riêng máy mạnh, ít bản billing-svc DB riêng sổ cái credit Cùng ba cái tên. Không còn transaction nào ôm được cả ba. Code nghiệp vụ gần như không đổi. Thứ đổi là những gì có thể hỏng giữa hai hộp.

Ba dịch vụ, và một API Gateway đứng trước tất cả — chính là ứng dụng NestJS cũ, giờ chỉ còn giữ controller HTTP và chuyển tiếp xuống dưới. Trình duyệt không hề biết có chuyện gì xảy ra.

Cấu trúc kho mã sau khi tách
media-forge/
├── proto/                    # HOP DONG chung, khong thuoc ve rieng ai
│   ├── auth.proto
│   ├── media.proto
│   └── billing.proto
├── apps/
│   ├── gateway/              # HTTP + WebSocket, khong co nghiep vu
│   ├── auth-svc/             # users, refresh_tokens
│   ├── media-svc/            # videos, media_assets, jobs
│   └── billing-svc/          # credit_entries
└── libs/
    └── proto-types/          # kieu TypeScript sinh tu .proto
🔍 Mỗi service tự đăng ký kết nối Postgres riêng của nó
Phần còn lại của bài chỉ nói đến logic nghiệp vụ (controller, entity, outbox) — TypeOrmModule.forRootAsync(...) của media-svcbilling-svc không được nhắc lại, nhưng cả hai vẫn cần đúng một cấu hình đã dựng ở Part 1 mục 7.3: DataSource riêng, và quan trọng nhất là namingStrategy: new SnakeNamingStrategy(). Thiếu dòng đó, mọi câu SQL thô trong BillingService (WHERE e.user_id = :userId) chạm đúng lỗi Postgres thật column e.user_id does not exist — TypeORM mặc định sinh cột theo camelCase (userId), không phải snake_case.

3. gRPC: hợp đồng viết ra được

Ba dịch vụ hoàn toàn có thể gọi nhau bằng HTTP và JSON. gRPC thắng ở chỗ khác: nó bắt bạn viết hợp đồng ra thành file, và sinh kiểu cho cả hai đầu từ file đó.

Với REST, hợp đồng nằm trong tài liệu hoặc trong đầu người viết. Bên gọi tự khai báo một interface, bên trả về đổi tên một trường, và không có gì báo lỗi cho tới lúc chạy. Đây chính xác là vấn đề "hai danh sách trôi khỏi nhau" đã gặp ở Part 1 với schema zod, chỉ khác là lần này hai danh sách nằm ở hai kho mã khác nhau — nên còn dễ trôi hơn.

proto/billing.proto
syntax = "proto3";
package billing;

service BillingService {
  rpc GetBalance (GetBalanceRequest) returns (BalanceReply);
  rpc Charge (ChargeRequest) returns (ChargeReply);
}

message GetBalanceRequest {
  string user_id = 1;
}

message BalanceReply {
  int64 balance = 1;
}

message ChargeRequest {
  string user_id = 1;
  int64 amount  = 2;
  // Khoa binh than: goi lai voi cung job_id thi khong tru them lan nua.
  string job_id = 3;
}

message ChargeReply {
  bool   charged     = 1;
  int64  new_balance = 2;
}
🔍 Những con số 1, 2, 3 quan trọng hơn tên trường
Protobuf mã hoá theo số thứ tự, không theo tên. Đổi user_id thành userId mà giữ nguyên số 1 thì hai bên vẫn hiểu nhau hoàn hảo. Nhưng đổi số 1 thành số 4 là phá vỡ mọi client cũ.

Hệ quả thực tế: không bao giờ dùng lại một số đã bỏ. Xoá một trường thì đánh dấu reserved 3; để không ai vô tình dùng lại số đó cho ý nghĩa khác — nếu không, một dịch vụ cũ chưa kịp triển khai sẽ đọc dữ liệu mới bằng cách hiểu cũ, âm thầm và sai.
Cài và sinh kiểu
npm i @nestjs/microservices @grpc/grpc-js @grpc/proto-loader
npm i -D ts-proto

# ts-proto chi la plugin sinh kieu TypeScript cho protoc, khong thay the duoc
# ban than trinh bien dich protoc. npx tu tai mot binary thay the neu may
# chua co, nhung binary do la mot goi npm khong ro nguon goc — cai qua trinh
# quan ly goi he dieu hanh de kiem soat dung phien ban dang dung:
brew install protobuf                    # macOS
sudo apt install -y protobuf-compiler    # Ubuntu/Debian

npx protoc \
  --proto_path=./proto \
  --plugin=./node_modules/.bin/protoc-gen-ts_proto \
  --ts_proto_out=./libs/proto-types \
  --ts_proto_opt=nestJs=true,useOptionals=messages \
  ./proto/*.proto

Lệnh này sinh ra interface TypeScript cho cả hai phía. Bên phục vụ implement đúng interface đó, bên gọi inject đúng interface đó — nên đổi .proto mà quên cập nhật một bên là lỗi biên dịch, không phải lỗi lúc chạy lúc 2 giờ sáng.

⚠️ Thiếu --proto_path thì file sinh ra nằm sai chỗ
protoc phản chiếu đường dẫn tương đối của input vào cây thư mục output. Bỏ --proto_path=./proto và chỉ truyền ./proto/*.proto, kết quả nằm ở libs/proto-types/proto/billing.ts — không phải libs/proto-types/billing.ts như mọi câu import ... from '@app/proto-types/billing' trong phần còn lại của bài giả định. Thiếu cờ này, mọi import đó báo Cannot find module '@app/proto-types/billing', dù lệnh sinh kiểu chạy exit 0 không báo lỗi gì.
tsconfig.json — path alias cho proto-types
{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@app/proto-types/*": ["libs/proto-types/*"]
    }
  }
}

Alias này cho cả billing-svc lẫn media-svc cùng import chung một bộ kiểu sinh ra ở libs/proto-types, kiểu import type { ChargeRequest } from '@app/proto-types/billing' — không cần đường dẫn tương đối dài dòng giữa hai app nằm ở hai thư mục khác nhau.

apps/billing-svc/src/billing.controller.ts — phía phục vụ
import { Controller } from '@nestjs/common';
import { GrpcMethod } from '@nestjs/microservices';
import type {
  ChargeRequest,
  ChargeReply,
  GetBalanceRequest,
  BalanceReply,
} from '@app/proto-types/billing';

@Controller()
export class BillingGrpcController {
  constructor(private readonly billing: BillingService) {}

  // Ten service va ten method phai khop CHINH XAC voi file .proto.
  @GrpcMethod('BillingService', 'Charge')
  async charge(request: ChargeRequest): Promise<ChargeReply> {
    const result = await this.billing.chargeForJob(
      request.jobId,
      request.userId,
      request.amount,
    );
    return { charged: result.charged, newBalance: result.balance };
  }

  @GrpcMethod('BillingService', 'GetBalance')
  async getBalance(request: GetBalanceRequest): Promise<BalanceReply> {
    const balance = await this.billing.getBalance(request.userId);
    return { balance };
  }
}
apps/media-svc/src/billing.client.ts — phía gọi
import { Injectable, OnModuleInit } from '@nestjs/common';
import { Client, ClientGrpc, Transport } from '@nestjs/microservices';
import { firstValueFrom, timeout } from 'rxjs';
import type { BillingServiceClient } from '@app/proto-types/billing';

@Injectable()
export class BillingClient implements OnModuleInit {
  @Client({
    transport: Transport.GRPC,
    options: { package: 'billing', protoPath: 'proto/billing.proto', url: 'billing-svc:50051' },
  })
  private readonly client!: ClientGrpc;

  private service!: BillingServiceClient;

  onModuleInit(): void {
    this.service = this.client.getService<BillingServiceClient>('BillingService');
  }

  async charge(userId: string, amount: number, jobId: string): Promise<boolean> {
    // gRPC tra ve Observable. KHONG bao gio goi ma khong dat han thoi gian —
    // thieu no thi mot dich vu treo se keo theo ca chuoi goi phia tren.
    const reply = await firstValueFrom(
      this.service.charge({ userId, amount, jobId }).pipe(timeout(3000)),
    );
    return reply.charged;
  }

  async getBalance(userId: string): Promise<number> {
    const reply = await firstValueFrom(
      this.service.getBalance({ userId }).pipe(timeout(3000)),
    );
    return reply.balance;
  }
}

3.1. Streaming và hạn chót

gRPC hỗ trợ luồng theo cả hai chiều, và tiến độ chuyển mã là ví dụ tự nhiên: thay vì media-svc bắn từng thông điệp rời rạc, nó mở một luồng và đẩy liên tục cho tới khi xong.

proto/media.proto — server streaming
service MediaService {
  // Mot request, NHIEU reply chay ve dan.
  rpc WatchJob (WatchJobRequest) returns (stream JobProgress);
}

message WatchJobRequest {
  string job_id = 1;
}

message JobProgress {
  string job_id  = 1;
  int32  percent = 2;
  string status  = 3;
}
apps/media-svc/src/media.controller.ts
import { Controller } from '@nestjs/common';
import { GrpcMethod } from '@nestjs/microservices';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { Observable } from 'rxjs';
import type { WatchJobRequest, JobProgress } from '@app/proto-types/media';

// Ten rieng, KHONG trung voi ProgressEvent co san cua DOM.
interface JobProgressEvent {
  jobId: string;
  percent: number;
  status: string;
}

@Controller()
export class MediaGrpcController {
  constructor(private readonly progress: EventEmitter2) {}

  @GrpcMethod('MediaService', 'WatchJob')
  watchJob(request: WatchJobRequest): Observable<JobProgress> {
    return new Observable<JobProgress>((subscriber) => {
      const handler = (event: JobProgressEvent): void => {
        if (event.jobId !== request.jobId) return;
        subscriber.next({ jobId: event.jobId, percent: event.percent, status: event.status });
        if (event.percent === 100) subscriber.complete();
      };

      this.progress.on('progress', handler);

      // Ham don dep — chay khi ben goi ngat luong hoac het han.
      // Thieu no thi moi ket noi ro ra mot listener.
      return () => this.progress.off('progress', handler);
    });
  }
}
⚠️ Hạn chót phải truyền tiếp, không chỉ đặt tại chỗ
Gateway cho request 10 giây. Nó gọi media-svc, media-svc gọi billing-svc. Nếu media-svc đặt hạn 10 giây của riêng nó cho lời gọi xuống billing, tổng cộng có thể thành 20 giây — trong khi gateway đã bỏ cuộc từ giây thứ 10 và người dùng đã thấy lỗi.

Cách đúng là mỗi tầng trừ đi thời gian đã tiêu rồi truyền phần còn lại xuống dưới. gRPC có sẵn khái niệm này gọi là deadline, và nó lan truyền qua metadata — đây chính là ngân sách thời gian của Part 2, giờ đi qua ranh giới tiến trình.

4. Chỗ transaction vỡ

Đây là phần quan trọng nhất của Part 4, và nó là hệ quả trực tiếp của Part 1.

Ở monolith, chuyển mã xong thì trừ credit và đánh dấu job hoàn thành nằm gọn trong một transaction. Hoặc cả hai cùng xảy ra, hoặc không cái nào — đó chính là chữ A trong ACID, và ta đã tin vào nó suốt ba part.

Monolith — một transaction ôm cả hai
await this.dataSource.transaction(async (manager) => {
  await manager.update(Job, jobId, { status: 'completed' });
  await manager.insert(CreditEntry, { userId, jobId, delta: -amount });
});
// Chay het hoac khong chay gi. Database bao dam.

Sau khi tách, hai dòng đó nằm ở hai dịch vụ với hai database. Viết ngây thơ thì thành:

Sau khi tách — và ba cách nó hỏng
await this.jobs.update(jobId, { status: 'completed' }); // DB cua media-svc
await this.billingClient.charge(userId, amount, jobId);  // qua mang, DB khac

// Hong 1: charge nem loi -> job "completed" nhung khong ai bi tru tien.
// Hong 2: charge thanh cong, phan hoi mat tren duong ve -> ta tuong that bai,
//         thu lai. Khoa binh than cuu duoc lan nay.
// Hong 3: tien trinh chet giua hai dong -> khong con ai biet buoc 2 con no.

Đảo thứ tự hai dòng không cứu được gì, chỉ đổi loại thiệt hại: trừ tiền xong rồi chết thì người dùng mất credit mà video vẫn ghi là chưa xong. Không có thứ tự nào đúng, vì vấn đề không nằm ở thứ tự — nó nằm ở chỗ không còn ai đảm bảo cả hai cùng xảy ra.

🔍 Vì sao không dùng transaction phân tán
Có một cơ chế đúng về lý thuyết cho chuyện này, gọi là two-phase commit: một bên điều phối hỏi tất cả "sẵn sàng chưa", rồi ra lệnh "commit đi".

Thực tế gần như không ai dùng cho microservice. Nó khoá dữ liệu ở mọi bên trong suốt hai vòng đi về, nên thông lượng sụp. Và nếu bên điều phối chết giữa hai pha, tất cả các bên ngồi giữ khoá chờ một mệnh lệnh không bao giờ tới — chính cái tình huống mà microservice sinh ra để tránh.

Cách được dùng thay thế là chấp nhận nhất quán sau cùng: hai bên có thể lệch nhau trong một khoảng ngắn, nhưng chắc chắn gặp nhau. Outbox là cách rẻ nhất để đạt điều đó.

4.1. Outbox pattern

Ý tưởng nằm ở một chỗ rất nhỏ: thay vì gọi mạng bên trong transaction, ta ghi ý định gọi vào chính database đang mở transaction. Ghi ý định và cập nhật job cùng một transaction, nên chúng lại nguyên tử với nhau như cũ. Việc gửi đi thật do một tiến trình khác đọc bảng đó mà làm.

MỘT transaction — DB của media-svc jobs: completed outbox: charge Cùng commit hoặc cùng rollback — như Part 1. đọc Relay tiến trình riêng thử lại tới khi được billing -svc Không có lời gọi mạng nào bên trong transaction. Relay chết → bản ghi vẫn nằm đó, lần chạy sau lấy tiếp. billing-svc sập → thử lại mãi, không mất gì. Đổi lại: có một khoảng thời gian job đã xong mà tiền chưa trừ.
apps/media-svc/src/outbox/outbox.entity.ts
@Entity('outbox')
export class OutboxMessage {
  @PrimaryGeneratedColumn('uuid')
  id!: string;

  @Column()
  topic!: string; // 'billing.charge'

  @Column({ type: 'jsonb' })
  payload!: Record<string, unknown>;

  // null = chua gui. Co gia tri = da gui xong.
  @Index()
  @Column({ name: 'sent_at', type: 'timestamptz', nullable: true })
  sentAt!: Date | null;

  @Column({ default: 0 })
  attempts!: number;

  @CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
  createdAt!: Date;
}
apps/media-svc/src/job/job.service.ts
async completeJob(jobId: string, userId: string, amount: number): Promise<void> {
  await this.dataSource.transaction(async (manager) => {
    await manager.update(Job, jobId, { status: 'completed' });

    // KHONG goi mang o day. Chi ghi y dinh xuong cung mot database.
    await manager.insert(OutboxMessage, {
      topic: 'billing.charge',
      payload: { userId, amount, jobId },
      sentAt: null,
    });
  });
  // Ra khoi transaction: hoac ca hai dong cung ton tai, hoac khong dong nao.
}
apps/media-svc/src/outbox/outbox.relay.ts
import { Injectable } from '@nestjs/common';
import { Interval } from '@nestjs/schedule';
import { DataSource } from 'typeorm';
import { OutboxMessage } from './outbox.entity';
import { BillingClient } from '../billing.client';

@Injectable()
export class OutboxRelay {
  constructor(
    private readonly dataSource: DataSource,
    private readonly billingClient: BillingClient,
  ) {}

  @Interval(1000)
  async flush(): Promise<void> {
    await this.dataSource.transaction(async (manager) => {
      const pending = await manager
        .createQueryBuilder(OutboxMessage, 'o')
        .setLock('pessimistic_write')
        .setOnLocked('skip_locked') // relay khac dang giu dong nao thi bo qua dong do
        .where('o.sent_at IS NULL')
        .orderBy('o.created_at', 'ASC')
        .limit(50)
        .getMany();

      for (const message of pending) {
        try {
          await this.dispatch(message);
          await manager.update(OutboxMessage, message.id, { sentAt: new Date() });
        } catch {
          // Khong danh dau da gui. Vong sau lay lai dung ban ghi nay.
          await manager.increment(OutboxMessage, { id: message.id }, 'attempts', 1);
        }
      }
    });
  }

  private async dispatch(message: OutboxMessage): Promise<void> {
    if (message.topic === 'billing.charge') {
      const { userId, amount, jobId } = message.payload as {
        userId: string;
        amount: number;
        jobId: string;
      };
      await this.billingClient.charge(userId, amount, jobId);
      return;
    }
    // Topic la chuoi tu do, khong phai enum — mot ban ghi voi topic sai
    // (do bug hoac do doi migration) khong duoc am tham bo qua.
    throw new Error(`Unknown outbox topic: ${message.topic}`);
  }
}
⚠️ Thiếu ScheduleModule.forRoot() thì @Interval không bao giờ chạy
@Interval(1000) chỉ hoạt động nếu module gốc của media-svc (module chứa OutboxRelay) có ScheduleModule.forRoot() trong mảng imports, và phải import { ScheduleModule } from '@nestjs/schedule' để lấy nó.

Thiếu dòng import đó, flush() không bao giờ được gọi — không có lỗi nào ném ra, ứng dụng khởi động bình thường, log không báo gì bất thường. Message nằm mãi trong bảng outbox với sent_at luôn là null, và chỉ lộ ra khi có người thắc mắc vì sao tiền không bao giờ bị trừ.

skip_locked là thứ cho phép chạy nhiều relay song song mà không cần phối hợp gì: mỗi tiến trình lấy những dòng chưa ai giữ, không tiến trình nào chờ tiến trình nào, và không dòng nào bị xử lý hai lần trong cùng một thời điểm.

💡 Outbox đảm bảo "ít nhất một lần", không phải "đúng một lần"
Relay gửi thành công rồi chết trước khi kịp ghi sent_at. Vòng sau nó gửi lại — billing-svc nhận cùng một lệnh trừ tiền hai lần.

Không có cách nào loại bỏ hoàn toàn khả năng này, và đó không phải khiếm khuyết của outbox mà là bản chất của việc truyền tin qua mạng. Cách sống chung là bên nhận phải bình thản — và nó đã bình thản từ Part 3: ràng buộc duy nhất trên job_id cộng với ON CONFLICT DO NOTHING. Lệnh trừ tiền thứ hai vào tới nơi, chạm ràng buộc, không làm gì cả.

Đây là lý do idempotency và outbox luôn đi thành cặp. Có outbox mà bên nhận không bình thản thì bạn đã đổi lỗi "mất thông điệp" lấy lỗi "trừ tiền hai lần" — không khá hơn chút nào.

5. Cache: nhanh hơn, và cái bẫy dồn toa

Mỗi lần media-svc trả về danh sách video, nó gọi sang billing-svc hỏi số dư. Số dư thay đổi rất ít nhưng bị hỏi rất nhiều — đúng dạng dữ liệu nên cache.

Cache-aside — bản cơ bản
async getBalance(userId: string): Promise<number> {
  const key = `balance:${userId}`;
  const cached = await this.redis.get(key);
  if (cached !== null) return Number(cached);

  const balance = await this.billingClient.getBalance(userId);
  await this.redis.set(key, String(balance), 'EX', 60);
  return balance;
}

Bản này chạy tốt cho tới một buổi tối đông người. Khoá hết hạn đúng lúc có 500 request đang cần nó: cả 500 cùng thấy cache rỗng, cả 500 cùng gọi sang billing-svc. Một dịch vụ đang phục vụ 0 request/giây bỗng nhận 500 request trong cùng một mili giây, và nó sập — không phải vì tải thật cao, mà vì tải dồn thành một cục.

apps/media-svc/src/cache/balance.cache.ts
import { Injectable } from '@nestjs/common';
import { RedisService } from '../../redis/redis.service';
import { BillingClient } from '../billing.client';

@Injectable()
export class BalanceCache {
  constructor(
    private readonly redis: RedisService,
    private readonly billingClient: BillingClient,
  ) {}

  private readonly inFlight = new Map<string, Promise<number>>();

  async getBalance(userId: string): Promise<number> {
    const key = `balance:${userId}`;

    const cached = await this.redis.get(key);
    if (cached !== null) return Number(cached);

    // Trong MOT tien trinh: moi nguoi cho chung mot lan goi.
    // Cung y tuong voi bien `refreshing` cua Part 2.
    const existing = this.inFlight.get(key);
    if (existing !== undefined) return existing;

    const promise = this.loadAndStore(key, userId).finally(() => {
      this.inFlight.delete(key);
    });
    this.inFlight.set(key, promise);
    return promise;
  }

  private async loadAndStore(key: string, userId: string): Promise<number> {
    const balance = await this.billingClient.getBalance(userId);

    // Han dung ngau nhien 60-75s. Neu tat ca cung dat 60s thi cac khoa
    // tao ra trong cung mot dot se cung het han trong cung mot dot.
    const ttl = 60 + Math.floor(Math.random() * 15);
    await this.redis.set(key, String(balance), 'EX', ttl);
    return balance;
  }
}

Hai kỹ thuật, hai tầng khác nhau. inFlight gộp các request trong cùng một tiến trình; hạn dùng ngẫu nhiên tránh việc nhiều khoá được tạo cùng lúc rồi cũng hết hạn cùng lúc. Muốn chặt hơn nữa — gộp cả giữa các tiến trình — thì cần một khoá trong Redis: ai lấy được khoá thì đi nạp, những người còn lại chờ một nhịp ngắn rồi đọc lại cache.

⚠️ Xoá cache khi số dư đổi, đừng chờ hết hạn
Người dùng nạp tiền xong, mở trang, và thấy số dư cũ trong tối đa 60 giây. Họ sẽ nạp lần nữa.

billing-svc phải phát một sự kiện sau mỗi lần số dư đổi, và các dịch vụ có cache thì xoá khoá tương ứng. Với dữ liệu người dùng nhìn thấy và tự tay thay đổi, thời gian hết hạn là lưới an toàn — không phải cơ chế cập nhật chính.

6. Gỡ lỗi khi lỗi nằm ở đâu đó

Trong monolith, một ngăn xếp lỗi chỉ thẳng vào dòng gây ra vấn đề. Sau khi tách, request đi qua bốn tiến trình và bạn có bốn tập nhật ký không liên quan gì tới nhau. Đây là cái giá đã hứa ở mục 1, và nó có thật.

Thứ tối thiểu phải có là correlation ID: một mã sinh ra ở cửa vào, đi theo request qua mọi tầng, và xuất hiện trong mọi dòng nhật ký.

apps/gateway/src/common/correlation.middleware.ts
import { AsyncLocalStorage } from 'node:async_hooks';

export const requestContext = new AsyncLocalStorage<{ correlationId: string }>();

@Injectable()
export class CorrelationMiddleware implements NestMiddleware {
  use(request: Request, response: Response, next: NextFunction): void {
    const correlationId = String(request.headers['x-correlation-id'] ?? randomUUID());
    response.setHeader('X-Correlation-Id', correlationId);

    // AsyncLocalStorage giu gia tri nay xuyen suot moi await phia sau,
    // ma khong phai truyen tay qua tung ham.
    requestContext.run({ correlationId }, () => next());
  }
}
apps/gateway/src/common/correlation.interceptor.ts
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { Metadata } from '@grpc/grpc-js';
import { requestContext } from './correlation.middleware';

@Injectable()
export class CorrelationInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler) {
    // Dinh correlation id vao metadata cua MOI loi goi gRPC di ra.
    const metadata = new Metadata();
    metadata.set('x-correlation-id', requestContext.getStore()?.correlationId ?? 'unknown');
    context.switchToRpc().getContext().add(metadata);

    return next.handle();
  }
}

Bên nhận đọc metadata đó và mở AsyncLocalStorage của chính nó. Kết quả là một mã duy nhất chạy suốt từ trình duyệt xuống tận worker. Khi có người báo lỗi, bạn tìm theo mã đó và thấy đủ hành trình thay vì bốn mảnh rời.

Nhật ký sau khi có correlation ID
gateway     [8f3a...] POST /media/abc/transcode -> 202 (12ms)
media-svc   [8f3a...] job d41 queued
media-svc   [8f3a...] job d41 processing 0%
media-svc   [8f3a...] job d41 completed (194s)
media-svc   [8f3a...] outbox billing.charge queued
billing-svc [8f3a...] charge user=42 amount=10 job=d41 -> ok
💡 Ba thứ đo, đừng đo mọi thứ
Độ trễ theo phân vị, không phải trung bình. Trung bình 80 ms nghe rất ổn trong khi phân vị 99 là 4 giây — tức là cứ 100 người thì một người đợi 4 giây, và họ là những người sẽ viết đánh giá xấu. Trung bình giấu đi đúng cái cần thấy.

Độ sâu hàng đợi. Nó tăng đều nghĩa là worker không theo kịp — dấu hiệu này xuất hiện trước khi người dùng kịp phàn nàn.

Tỷ lệ lỗi theo từng lời gọi giữa các dịch vụ, không gộp chung. Gộp lại thì một dịch vụ hỏng hoàn toàn có thể chỉ làm tỷ lệ chung nhích lên 2% và không ai để ý.

7. Những thứ cố ý không làm

Danh sách này quan trọng ngang phần đã làm, vì mỗi mục đều là thứ hay bị thêm vào quá sớm.

Đã cân nhắc và bỏ qua
Service discovery
  Ba dich vu voi ten co dinh trong docker-compose la du.
  Can khi so ban thay doi lien tuc va dia chi khong doan truoc duoc.

Circuit breaker
  Deadline + thu lai da xu ly phan lon truong hop. Ngat mach chi dang
  gia khi mot dich vu hong LAU va viec cu goi vao no lam hong ca ben goi.

Service mesh
  Them mot lop ha tang nua de van hanh. Voi ba dich vu, chi phi hoc va
  van hanh lon hon loi ich.

Event sourcing
  So cai credit da la append-only — do la phan duy nhat can lich su day du.
  Ap cho toan he thong la doi mot van de de gap lay mot van de kho hon.

Mẫu số chung: mỗi thứ trên giải quyết một vấn đề có thật, nhưng là vấn đề của quy mô mà dự án này chưa chạm tới. Thêm chúng bây giờ là trả chi phí vận hành ngay hôm nay cho một lợi ích của năm sau — mà năm sau thì bài toán có thể đã khác.

8. Nhìn lại cả bốn part

Bắt đầu từ một thư mục rỗng, giờ có một hệ thống nhận video, chuyển mã, tính tiền, đẩy tiến độ theo thời gian thực và phát lại có tua được — chạy trên nhiều tiến trình, nhiều dịch vụ, nhiều database.

Nhưng thứ đáng mang theo không phải danh sách công nghệ. Đọc lại bốn part sẽ thấy cùng một bài toán quay lại năm lần, mỗi lần khoác một cái áo khác:

Cùng một bài toán, năm lần
Part 1  Hai request cung tru tien mot tai khoan
        -> SELECT FOR UPDATE

Part 2  Hai tab cung xoay mot refresh token
        -> khoa dong + khoang an han
        Nhieu request cung nhan 401 va cung doi refresh
        -> gop vao MOT promise

Part 3  Mot thong diep bi xu ly hai lan sau khi worker chet
        -> unique index + ON CONFLICT DO NOTHING

Part 4  Mot lenh tru tien duoc gui hai lan tu outbox
        -> van la unique index do
        500 request cung nap lai mot khoa cache vua het han
        -> gop vao MOT promise + han dung ngau nhien

Ba loai loi giai, dung cho ca nam truong hop:
  1. Khoa lai        — cho xep hang
  2. Lam nguyen tu   — de khong co khe ho o giua
  3. Gop thanh mot   — de chi co mot ban chay that

Công nghệ trong bài sẽ cũ đi. NestJS sẽ lên phiên bản mới, gRPC có thể bị thay bằng thứ khác, ffmpeg sẽ có cờ mới. Ba dòng cuối của bảng trên thì không — chúng đúng từ trước khi có Node và sẽ còn đúng sau đó.

Nếu chỉ giữ lại một câu từ cả loạt bài này, hãy giữ câu ở Part 1: đoạn code trừ tiền đầu tiên không có lỗi cú pháp, không có lỗi logic, và đúng với mọi bài test chạy tuần tự. Nó chỉ sai khi có hai người bấm cùng lúc — mà đó là điều kiện mặc định của mọi hệ thống có người dùng thật.

🔍 Muốn đi tiếp thì đi đâu
Loạt bài này dừng ở chỗ hệ thống chạy đúng. Ba hướng tự nhiên để đi tiếp:

Phát video kiểu thích ứng — cắt thành HLS hoặc DASH để trình duyệt tự đổi chất lượng theo tốc độ mạng, thay vì tải một file duy nhất.

Lưu trữ đối tượng — chuyển từ đĩa cục bộ sang S3 hoặc tương đương, và khi đó phần X-Accel-Redirect ở Part 2 đổi thành URL ký sẵn của nhà cung cấp.

Kiểm thử tải — dựng kịch bản đủ nặng để tự tay thấy từng nút thắt trong bài này lộ ra theo đúng thứ tự đã mô tả. Đọc về chúng là một chuyện; nhìn thanh phân vị 99 dựng đứng lên trên màn hình của chính mình là chuyện khác.

Các phần trong loạt bài

Part 1: Nền móng, thiết kế CSDL & ACID Part 2: Xác thực, nginx, rate limit & streaming Part 3: child_process, worker_threads, cluster & realtime Quay lại Blog

Bình luận