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.
.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ã.
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).
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ó.
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.
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
TypeOrmModule.forRootAsync(...) của media-svc và
billing-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.
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;
}
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.
npm i @nestjs/microservices @grpc/grpc-js @grpc/proto-loader @nestjs/event-emitter
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.
Còn một mảnh nữa mà phần sau của bài luôn giả định là đã có:
mỗi dịch vụ cần một entry point riêng. Khác với gateway, chúng không mở cổng HTTP nào —
createMicroservice thay cho create, và cổng gRPC thay cho cổng web:
import 'reflect-metadata';
import { NestFactory } from '@nestjs/core';
import { MicroserviceOptions, Transport } from '@nestjs/microservices';
import { join } from 'node:path';
import { BillingModule } from './billing.module';
async function bootstrap(): Promise<void> {
// createMicroservice, KHONG phai create(): khong co cong HTTP, khong
// controller REST — chi mot server gRPC nghe theo hop dong .proto.
const app = await NestFactory.createMicroservice<MicroserviceOptions>(BillingModule, {
transport: Transport.GRPC,
options: {
package: 'billing', // PHAI khop `package billing;` trong .proto
protoPath: join(process.cwd(), 'proto/billing.proto'),
url: process.env['GRPC_URL'] ?? '0.0.0.0:50051',
},
});
await app.listen();
}
void bootstrap();
media-svc và auth-svc dùng đúng khuôn đó, chỉ đổi module, đổi
package/protoPath, và đổi cổng thành 50052 / 50053 —
đúng những con số mà các @Client({ url: 'media-svc:50052' }) ở dưới trỏ tới.
--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ì.
{
"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.
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 };
}
}
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.
service MediaService {
// Mot request, NHIEU reply chay ve dan.
rpc WatchJob (WatchJobRequest) returns (stream JobProgress);
// Worker cua media-svc goi khi chuyen ma xong — day la duong vao that su
// cua outbox o muc 4.1. Thieu RPC nay thi completeJob() khong ai goi duoc.
rpc CompleteJob (CompleteJobRequest) returns (CompleteJobReply);
}
message WatchJobRequest {
string job_id = 1;
}
message JobProgress {
string job_id = 1;
int32 percent = 2;
string status = 3;
}
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);
});
}
}
this.progress ở trên là EventEmitter2 — nội bộ một tiến trình, không tự vượt
được ranh giới nào cả. Ai gọi .emit('progress', ...)? Worker chuyển mã vẫn publish lên Redis
như Part 3, nhưng SHAPE phải đổi. Part 3 báo tiến độ bằng hai tín hiệu tách biệt: kênh
'progress' mang percent chặn cứng ở 99 (mục 4.2), và một kênh
'job:done' riêng — không mang percent lẫn status — báo lúc thật sự
xong. Cách đó hợp lý khi đích đến là hai sự kiện WebSocket khác tên. Nhưng rpc WatchJob trả
về một luồng JobProgress DUY NHẤT, mọi thông điệp cùng một hình dạng — không có chỗ cho một
tín hiệu phụ đi ngoài luồng. Worker phải gộp lại thành một kênh, một hình dạng, đi tới
status: 'completed' ở percent: 100 thay vì tách rời:
// Part 3 — hai kenh, hai hinh dang khac nhau:
await this.redis.publish('progress', JSON.stringify({ userId, jobId, percent })); // percent toi da 99
// ... roi rieng, sau khi that su xong:
await this.redis.publish('job:done', JSON.stringify({ userId, jobId })); // khong percent, khong status
// media-svc — mot kenh, mot hinh dang, di het toi 100:
await this.redis.publish('progress', JSON.stringify({ jobId, percent: 0, status: 'processing' }));
// ... trong luc chay, moi lan ffmpeg bao tien do:
await this.redis.publish('progress', JSON.stringify({ jobId, percent, status: 'processing' }));
// ... xong that (sau khi completeJob() da ghi outbox — muc 4.1):
await this.redis.publish('progress', JSON.stringify({ jobId, percent: 100, status: 'completed' }));
Thiếu mắt nối kế tiếp thì MediaGrpcController ở trên không bao giờ nhận được gì, dù cú pháp
biên dịch hoàn toàn đúng:
import { Injectable, OnModuleInit } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import type { Redis } from 'ioredis';
import { RedisService } from '../redis/redis.service';
interface ProgressEvent {
readonly jobId: string;
readonly percent: number;
readonly status: string;
}
// Nguoi lam cau noi. Worker publish len Redis (khong biet gi ve EventEmitter2),
// class nay nghe roi phat lai NOI BO tien trinh — dung vai tro cua
// ProgressSubscriber o Part 3, nhung gio dat trong chinh media-svc.
@Injectable()
export class ProgressBridge implements OnModuleInit {
private readonly subscriber: Redis;
constructor(
private readonly redis: RedisService,
private readonly emitter: EventEmitter2,
) {
this.subscriber = this.redis.duplicate();
}
async onModuleInit(): Promise<void> {
await this.subscriber.subscribe('progress');
this.subscriber.on('message', (_channel, raw) => {
const event = JSON.parse(raw) as ProgressEvent;
this.emitter.emit('progress', event);
});
}
}
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.
3.2. Auth-svc: cùng logic, khoác vỏ gRPC
Sơ đồ ở mục 2 đã vẽ auth-svc như dịch vụ thứ ba, nhưng chưa cho code nào — ranh giới của nó đơn giản hơn billing-svc/media-svc vì không có gì mới về mặt nghiệp vụ. Toàn bộ logic xoay vòng refresh token, phát hiện dùng lại, khoảng ân hạn đã viết đầy đủ ở Part 2 mục 3. Việc ở đây chỉ là đặt đúng lớp vỏ.
syntax = "proto3";
package auth;
service AuthService {
rpc Login (LoginRequest) returns (TokenPair);
rpc Refresh (RefreshRequest) returns (TokenPair);
rpc Logout (LogoutRequest) returns (Empty);
rpc LogoutEverywhere (LogoutEverywhereRequest) returns (Empty);
}
message LoginRequest { string email = 1; string password = 2; }
message RefreshRequest { string refresh_token = 1; }
message LogoutRequest { string refresh_token = 1; }
message LogoutEverywhereRequest { string user_id = 1; }
message TokenPair { string access_token = 1; string refresh_token = 2; }
message Empty {}
Bên phục vụ gọi lại đúng AuthService đã có ở Part 2 — constructor 6 tham số,
login/refresh/logout/logoutEverywhere giữ nguyên chữ
ký. Viết ngây thơ thì để lỗi tự bay lên:
@GrpcMethod('AuthService', 'Refresh')
async refresh(request: RefreshRequest): Promise<TokenPair> {
const pair = await this.auth.refresh(request.refreshToken); // co the nem UnauthorizedException
return { accessToken: pair.accessToken, refreshToken: pair.refreshToken };
}
Gọi thử với một refresh token không tồn tại, đo bằng client gRPC thô — UnauthorizedException
của Part 2 không đi xuyên qua ranh giới gRPC:
code: 2 (UNKNOWN)
details: "Internal server error"
HttpException sang mã trạng thái gRPC
UnauthorizedException thành
401. Qua @GrpcMethod, không có filter tương đương nào chạy mặc định — bất kỳ
exception nào không phải RpcException đều rơi xuống thành UNKNOWN kèm thông
điệp rỗng, xoá sạch lý do thật ("Refresh token không hợp lệ", "Phiên đăng nhập đã bị thu hồi"...).
Gateway nhận được cái này thì chỉ còn cách trả 500, dù lỗi thật là 401.
Vá bằng một cặp đối xứng — bên phục vụ bọc lại, bên gọi đọc đúng mã rồi ném lại:
import { Controller, UnauthorizedException } from '@nestjs/common';
import { GrpcMethod, RpcException } from '@nestjs/microservices';
import { status as GrpcStatus } from '@grpc/grpc-js';
import type {
LoginRequest,
RefreshRequest,
LogoutRequest,
LogoutEverywhereRequest,
TokenPair,
Empty,
} from '@app/proto-types/auth';
import { AuthService } from './auth.service';
@Controller()
export class AuthGrpcController {
constructor(private readonly auth: AuthService) {}
@GrpcMethod('AuthService', 'Login')
async login(request: LoginRequest): Promise<TokenPair> {
const pair = await this.callAuth(() => this.auth.login(request.email, request.password));
return { accessToken: pair.accessToken, refreshToken: pair.refreshToken };
}
@GrpcMethod('AuthService', 'Refresh')
async refresh(request: RefreshRequest): Promise<TokenPair> {
const pair = await this.callAuth(() => this.auth.refresh(request.refreshToken));
return { accessToken: pair.accessToken, refreshToken: pair.refreshToken };
}
@GrpcMethod('AuthService', 'Logout')
async logout(request: LogoutRequest): Promise<Empty> {
await this.callAuth(() => this.auth.logout(request.refreshToken));
return {};
}
@GrpcMethod('AuthService', 'LogoutEverywhere')
async logoutEverywhere(request: LogoutEverywhereRequest): Promise<Empty> {
await this.callAuth(() => this.auth.logoutEverywhere(request.userId));
return {};
}
// NestJS khong tu dich UnauthorizedException sang ma trang thai gRPC —
// boc lai thanh RpcException de AuthClient (gateway) doc dung ma + thong diep.
private async callAuth<T>(fn: () => Promise<T>): Promise<T> {
try {
return await fn();
} catch (err) {
if (err instanceof UnauthorizedException) {
throw new RpcException({ code: GrpcStatus.UNAUTHENTICATED, message: err.message });
}
throw err;
}
}
}
import { Injectable, OnModuleInit, UnauthorizedException } from '@nestjs/common';
import { Client, ClientGrpc, Transport } from '@nestjs/microservices';
import { status as GrpcStatus } from '@grpc/grpc-js';
import { catchError, firstValueFrom, throwError, timeout, type Observable } from 'rxjs';
import type { AuthServiceClient, TokenPair } from '@app/proto-types/auth';
function mapAuthError(err: { code?: number; details?: string; message?: string }): Observable<never> {
if (err?.code === GrpcStatus.UNAUTHENTICATED) {
return throwError(() => new UnauthorizedException(err.details ?? err.message));
}
return throwError(() => err);
}
@Injectable()
export class AuthClient implements OnModuleInit {
@Client({
transport: Transport.GRPC,
options: { package: 'auth', protoPath: 'proto/auth.proto', url: 'auth-svc:50053' },
})
private readonly client!: ClientGrpc;
private service!: AuthServiceClient;
onModuleInit(): void {
this.service = this.client.getService<AuthServiceClient>('AuthService');
}
async login(email: string, password: string): Promise<TokenPair> {
return firstValueFrom(this.service.login({ email, password }).pipe(timeout(3000), catchError(mapAuthError)));
}
async refresh(refreshToken: string): Promise<TokenPair> {
return firstValueFrom(this.service.refresh({ refreshToken }).pipe(timeout(3000), catchError(mapAuthError)));
}
async logout(refreshToken: string): Promise<void> {
await firstValueFrom(this.service.logout({ refreshToken }).pipe(timeout(3000), catchError(mapAuthError)));
}
async logoutEverywhere(userId: string): Promise<void> {
await firstValueFrom(this.service.logoutEverywhere({ userId }).pipe(timeout(3000), catchError(mapAuthError)));
}
}
Đo lại đúng thất bại vừa gặp: cùng request refresh-token-không-tồn-tại, giờ gateway trả đúng
401 kèm thông điệp thật, không còn 500 rỗng nghĩa.
Phần đáng chú ý nhất nằm ở HTTP controller của gateway — nó gần như là AuthController của
Part 2 đem dán lại nguyên văn:
const COOKIE_NAME = 'refresh_token';
@Controller('auth')
export class AuthController {
constructor(
private readonly authClient: AuthClient,
private readonly config: ConfigService<AppConfig, true>,
) {}
@Post('login')
async login(@Body() dto: LoginDto, @Res({ passthrough: true }) response: Response): Promise<{ accessToken: string }> {
const pair = await this.authClient.login(dto.email, dto.password);
this.setRefreshCookie(response, pair.refreshToken);
return { accessToken: pair.accessToken };
}
@Post('refresh')
async refresh(
@Req() request: Request,
@Body() dto: RefreshDto,
@Res({ passthrough: true }) response: Response,
): Promise<{ accessToken: string }> {
const presented = this.readPresentedToken(request, dto);
if (presented === undefined) throw new UnauthorizedException('Thieu refresh token');
const pair = await this.authClient.refresh(presented);
this.setRefreshCookie(response, pair.refreshToken);
return { accessToken: pair.accessToken };
}
// logout, logout-everywhere, setRefreshCookie, readPresentedToken: xem Part 2 muc 3.5 —
// tung dong giu nguyen, chi ten thanh vien constructor doi tu "auth" sang "authClient".
}
this.auth.xxx(...) đổi thành
this.authClient.xxx(...). Toàn bộ setRefreshCookie,
readPresentedToken, cấu hình
httpOnly/secure/sameSite/ path/maxAge —
không đổi một ký tự. Đây chính xác là luận điểm đã nêu ở đầu Part 4: cái đổi là đường truyền, không phải
nghiệp vụ.
Kịch bản "đua hai tab" của Part 2 mục 3.3 kiểm lại được nguyên vẹn qua ranh giới gRPC mới: hai request
/auth/refresh cùng lúc với cùng refresh token cũ — cả hai đều thành công (một xoay vòng bình
thường, một rơi vào nhánh ân hạn), để lại đúng ba dòng trong bảng refresh_tokens: token gốc
(đã dùng), và hai token mới sinh ra từ hai nhánh của cuộc đua. Gọi lại token gốc
sau 31 giây (ngoài khoảng ân hạn 30s) → 401, và toàn bộ family bị thu hồi
thật trong Postgres — 0 dòng còn lại cho family đó. Đúng hành vi đã đo ở phiên rà lỗi đầu
tiên của loạt bài (Part 2 mục 3.3), vẫn đúng sau khi đi qua thêm một tầng gRPC.
3.3. Bắc cầu gRPC streaming sang WebSocket
Ở monolith, ProgressGateway (Part 3 mục 7) subscribe trực tiếp vào Redis pub/sub — vì gateway
và worker khi đó là cùng một tiến trình, cùng một Redis. Sau khi tách, Redis mà
ProgressBridge vừa dùng ở mục 3.1 là hạ tầng RIÊNG của media-svc, không phải thứ dùng chung.
Cho gateway tự ý kết nối vào Redis nội bộ của một dịch vụ khác là phá đúng ranh giới vừa dựng ở mục 2 —
dịch vụ khác chỉ được phép nói chuyện qua hợp đồng công khai, tức là rpc WatchJob.
import { Injectable, OnModuleInit } from '@nestjs/common';
import { Client, ClientGrpc, Transport } from '@nestjs/microservices';
import type { Observable } from 'rxjs';
import type { JobProgress, MediaServiceClient } from '@app/proto-types/media';
@Injectable()
export class MediaClient implements OnModuleInit {
@Client({
transport: Transport.GRPC,
options: { package: 'media', protoPath: 'proto/media.proto', url: 'media-svc:50052' },
})
private readonly client!: ClientGrpc;
private service!: MediaServiceClient;
onModuleInit(): void {
this.service = this.client.getService<MediaServiceClient>('MediaService');
}
// Tra ve Observable, khong await mot lan — day la stream, khong phai mot cau tra loi.
watchJob(jobId: string): Observable<JobProgress> {
return this.service.watchJob({ jobId });
}
}
Khác với monolith (một kết nối Redis, phát cho mọi phòng cùng lúc), gateway giờ mở
một luồng gRPC riêng cho mỗi job mà client thật sự hỏi tới — client Socket.io gửi
'job:subscribe' kèm jobId, gateway mới mở watchJob(jobId):
import {
ConnectedSocket,
MessageBody,
OnGatewayConnection,
OnGatewayDisconnect,
SubscribeMessage,
WebSocketGateway,
WebSocketServer,
} from '@nestjs/websockets';
import type { Server, Socket } from 'socket.io';
import { JwtService } from '@nestjs/jwt';
import type { Subscription } from 'rxjs';
import type { JwtPayload } from '../auth/jwt-payload';
import { MediaClient } from './media.client';
@WebSocketGateway({ path: '/ws', cors: false })
export class ProgressGateway implements OnGatewayConnection, OnGatewayDisconnect {
@WebSocketServer() private readonly server!: Server;
private readonly subscriptions = new Map<string, Set<Subscription>>();
constructor(
private readonly jwt: JwtService,
private readonly media: MediaClient,
) {}
async handleConnection(client: Socket): Promise<void> {
try {
const token = String(client.handshake.auth['token'] ?? '');
const payload = await this.jwt.verifyAsync<JwtPayload>(token);
await client.join(`user:${payload.sub}`);
client.data.userId = payload.sub;
} catch {
client.disconnect(true);
}
}
// Ngat ket noi: huy moi stream gRPC dang mo cho client nay. Thieu buoc nay
// thi moi lan reconnect lai them mot stream ro ri, khong bao gio dong.
handleDisconnect(client: Socket): void {
const subs = this.subscriptions.get(client.id);
if (subs === undefined) return;
for (const sub of subs) sub.unsubscribe();
this.subscriptions.delete(client.id);
}
@SubscribeMessage('job:subscribe')
handleJobSubscribe(@ConnectedSocket() client: Socket, @MessageBody() body: { jobId: string }): void {
const room = `user:${client.data.userId as string}`;
const subscription = this.media.watchJob(body.jobId).subscribe({
next: (progress) => {
this.server.to(room).emit('job:progress', { jobId: progress.jobId, percent: progress.percent });
if (progress.status === 'completed') {
this.server.to(room).emit('job:done', { jobId: progress.jobId });
}
},
error: () => this.forgetSubscription(client.id, subscription),
complete: () => this.forgetSubscription(client.id, subscription),
});
const bucket = this.subscriptions.get(client.id) ?? new Set<Subscription>();
bucket.add(subscription);
this.subscriptions.set(client.id, bucket);
}
private forgetSubscription(clientId: string, subscription: Subscription): void {
subscription.unsubscribe();
this.subscriptions.get(clientId)?.delete(subscription);
}
}
Phía client cũng phải đổi theo — useJobProgress.ts ở Part 3 chỉ ngồi chờ thụ động, không bao
giờ nói cho gateway biết nó quan tâm job nào. Thêm đúng một dòng vào chỗ đã nhận danh sách job đang chạy:
socket.on('connect', () => {
void api('/jobs/active')
.then((res) => res.json() as Promise<JobState[]>)
.then((active) => {
setJobs(active);
// Gateway chi mo watchJob() cho job nao client CHU DONG hoi —
// thieu dong nay thi 'job:progress'/'job:done' khong bao gio toi.
for (const job of active) {
if (job.status === 'queued' || job.status === 'processing') {
socket.emit('job:subscribe', { jobId: job.id });
}
}
});
});
ffmpeg thật chạy ở worker của media-svc → publish lên Redis nội bộ media-svc (kênh
'progress') → ProgressBridge re-emit qua EventEmitter2 →
rpc WatchJob chảy ra ngoài → MediaClient của gateway nhận →
ProgressGateway phát lại qua Socket.io. Đo bằng client Socket.io thật kết nối
/ws, nhận đúng chuỗi job:progress: 0%, 96%, 100%, rồi
job:done — sáu chặng, một client thật, không chặng nào bị bỏ qua.
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.
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:
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.
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 đó.
CreditEntry từ Part 1 mang @ManyToOne(() => User, { onDelete: 'CASCADE' }),
tức Postgres tạo một khoá ngoại thật tới bảng users. Sau khi tách, users thuộc
auth-svc còn credit_entries thuộc billing-svc — hai
database khác nhau, và khoá ngoại thì không đi xuyên qua ranh giới đó được.Lần gọi
Charge đầu tiên sẽ chết với
QueryFailedError: insert or update on table "credit_entries" violates foreign key constraint. Cách chữa đúng là bỏ hẳn quan hệ đó ở billing-svc và chỉ giữ lại cột
user_id trơn: sau khi tách, tính hợp lệ của user_id là việc của auth-svc,
không phải của ràng buộc database. Đây là cái giá thứ hai của việc tách, ít được nhắc hơn nhưng gặp sớm
hơn nhiề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.
@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;
}
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.
}
Hàm này cần một đường vào từ bên ngoài, nếu không nó chỉ là code chết. Thêm vào
MediaGrpcController ở mục 3.1 — worker gọi đúng RPC CompleteJob vừa khai trong
media.proto:
@GrpcMethod('MediaService', 'CompleteJob')
async completeJob(request: CompleteJobRequest): Promise<CompleteJobReply> {
await this.jobs.completeJob(request.jobId, request.userId, Number(request.amount));
return { ok: true };
}
docker compose stop billing-svc # dich vu dich CHET han
# Goi CompleteJob qua gRPC (worker se lam dung viec nay)
# -> OK {"ok":true} <-- van thanh cong, du khong ai tru tien duoc
docker exec ...media-db-1 psql -U media -d media \
-c "SELECT sent_at IS NULL AS chua_gui, attempts FROM outbox ORDER BY created_at DESC LIMIT 1;"
# -> chua_gui = t, attempts = 7 (relay dang thu lai moi giay)
# -> vai giay sau: attempts = 13, 30, 95 ... van chua gui duoc
docker compose start billing-svc # dich vu song lai
docker exec ...media-db-1 psql -U media -d media \
-c "SELECT sent_at IS NOT NULL AS da_gui FROM outbox ORDER BY created_at DESC LIMIT 1;"
# -> da_gui = t
docker exec ...billing-db-1 psql -U billing -d billing \
-c "SELECT delta, reason, job_id FROM credit_entries ORDER BY created_at DESC LIMIT 1;"
# -> -10 | transcode | 16a4bf70-...
Đây là toàn bộ giá trị của outbox, đo được bằng một lệnh docker compose stop: dịch vụ đích
chết hoàn toàn mà CompleteJob vẫn trả về thành công, job vẫn được đánh dấu xong, và khoản trừ
tiền không mất đi đâu cả — nó nằm trong bảng outbox đợi, rồi tự đi tiếp khi
bên kia sống lại. Viết ngây thơ theo kiểu gọi mạng thẳng trong transaction thì đúng tình huống này là
"hỏng 1" ở đầu mục 4: job completed mà không ai bị trừ tiền.
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}`);
}
}
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.
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.
4.2. Dựng cả cụm lên
Mọi @Client({ url: 'billing-svc:50051' }) ở trên chỉ phân giải được nhờ đúng một thứ: tên
dịch vụ trong Compose. Đây cũng chính là lý do mục 7 nói không cần service discovery — nhưng cái file làm
điều đó thành sự thật thì phải có mặt.
services:
billing-db: { image: postgres:17-alpine, environment: { POSTGRES_USER: billing, POSTGRES_PASSWORD: billing, POSTGRES_DB: billing } }
media-db: { image: postgres:17-alpine, environment: { POSTGRES_USER: media, POSTGRES_PASSWORD: media, POSTGRES_DB: media } }
auth-db: { image: postgres:17-alpine, environment: { POSTGRES_USER: auth, POSTGRES_PASSWORD: auth, POSTGRES_DB: auth } }
redis: { image: redis:7-alpine }
billing-svc:
build: .
command: ['node', '-r', 'ts-node/register', '-r', 'tsconfig-paths/register', 'apps/billing-svc/src/main.ts']
environment:
DATABASE_URL: postgres://billing:billing@billing-db:5432/billing
GRPC_URL: '0.0.0.0:50051'
depends_on: { billing-db: { condition: service_healthy } }
media-svc:
build: .
command: ['node', '-r', 'ts-node/register', '-r', 'tsconfig-paths/register', 'apps/media-svc/src/main.ts']
environment:
DATABASE_URL: postgres://media:media@media-db:5432/media
BILLING_GRPC_URL: 'billing-svc:50051' # ten dich vu = ten host
GRPC_URL: '0.0.0.0:50052'
depends_on: { media-db: { condition: service_healthy } }
auth-svc:
build: .
command: ['node', '-r', 'ts-node/register', '-r', 'tsconfig-paths/register', 'apps/auth-svc/src/main.ts']
environment:
DATABASE_URL: postgres://auth:auth@auth-db:5432/auth
JWT_SECRET: ${JWT_SECRET} # PHAI giong het gateway
GRPC_URL: '0.0.0.0:50053'
depends_on: { auth-db: { condition: service_healthy } }
gateway:
build: .
command: ['node', '-r', 'ts-node/register', '-r', 'tsconfig-paths/register', 'apps/gateway/src/main.ts']
environment:
JWT_SECRET: ${JWT_SECRET} # PHAI giong het auth-svc
PORT: '3000'
ports:
- '3000:3000' # dich vu DUY NHAT mo cong ra ngoai
depends_on: [auth-svc, media-svc]
ports: nào cả — chúng chỉ nói chuyện với nhau trong mạng nội
bộ của Compose, và đó là điều đúng: gRPC ở đây không có TLS lẫn xác thực ở tầng vận chuyển. Ánh xạ chúng
ra ngoài chỉ nên làm tạm lúc gỡ lỗi.JWT_SECRET phải giống hệt giữa auth-svc và gateway: auth-svc ký token,
gateway tự xác thực chữ ký mà không gọi ngược lại (mục 3.2). Lệch nhau thì mọi request có token đều trả
401 và không có gì trong log chỉ ra lý do.
export JWT_SECRET=$(openssl rand -hex 32)
docker compose up -d --build
curl -s -X POST http://localhost:3000/auth/login \
-H 'Content-Type: application/json' \
-d '{"email":"demo@test.local","password":"matkhau123"}'
# -> {"accessToken":"eyJhbGciOi..."} + Set-Cookie: refresh_token=...
curl -s -o /dev/null -w '%{http_code}\n' -X POST http://localhost:3000/auth/login \
-H 'Content-Type: application/json' \
-d '{"email":"demo@test.local","password":"saibetbet"}'
# -> 401 (GrpcStatus.UNAUTHENTICATED -> UnauthorizedException, muc 3.2)
Request đó đi qua đúng hai tầng: HTTP tới gateway, rồi gRPC xuống auth-svc, rồi ngược lại. Trình duyệt
không biết gì về chuyện đó — đúng lời hứa ở mục 2. Và mã 401 ở lệnh thứ hai là bằng chứng cụ
thể rằng mapAuthError của mục 3.2 làm đúng việc: một mã lỗi gRPC đã được dịch lại thành một
mã lỗi HTTP đúng nghĩa, thay vì rơi ra thành 500.
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.
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.
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.
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.
Còn thiếu đường vào: BalanceCache mới chỉ là một provider chưa ai gọi, và
media.proto chưa có RPC nào trả số dư. Thêm một RPC nữa, đi qua cache chứ
không gọi thẳng billing-svc:
// proto/media.proto
rpc GetBalance (GetBalanceRequest) returns (BalanceReply);
// apps/media-svc/src/media.controller.ts
@GrpcMethod('MediaService', 'GetBalance')
async getBalance(request: GetBalanceRequest): Promise<BalanceReply> {
return { balance: await this.balances.getBalance(request.userId) };
}
# Xoa khoa de cache LANH, roi ban 50 loi goi GetBalance cung luc,
# vua chay vua theo doi Redis:
docker exec ...redis-1 redis-cli DEL "balance:$USER_ID"
docker exec ...redis-1 redis-cli MONITOR &
# ...ban 50 request...
# Dem lenh cham vao khoa do:
# GET : 50 <-- ca 50 deu kiem cache
# SET : 1 <-- nhung CHI MOT lan thuc su goi sang billing-svc
#
# va dong SET do:
# "set" "balance:961EC050-..." "-10" "EX" "63"
# ^^ khong phai 60
Hai con số đó là toàn bộ mục này, đo được: 50 lần đọc cache, đúng 1 lần nạp — 49 request
kia được inFlight gộp vào cùng một lời hứa thay vì mỗi đứa một lời gọi mạng. Và hạn dùng ghi
ra là 63 chứ không phải 60, tức phần ngẫu nhiên
60 + random(15) đang thật sự hoạt động — nếu mọi khoá đều đúng 60 thì cả đám tạo cùng một đợt
sẽ lại cùng hết hạn một đợt, và ta quay về đúng chỗ vừa trá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ý.
import { Injectable, NestMiddleware } from '@nestjs/common';
import { AsyncLocalStorage } from 'node:async_hooks';
import { randomUUID } from 'node:crypto';
// PHAI lay tu 'express'. Thieu dong nay thi TypeScript im lang dung
// Request/Response cua DOM, va bao loi o cho khac han: "Headers has no
// index signature" va "Property 'setHeader' does not exist".
import type { Request, Response, NextFunction } from 'express';
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());
}
}
import { Metadata } from '@grpc/grpc-js';
import { requestContext } from './correlation.middleware';
// Dinh correlation id vao metadata cua moi loi goi gRPC di ra. Moi method
// sinh tu ts-proto deu nhan Metadata lam THAM SO THU HAI:
// this.service.charge({ userId, amount, jobId }, correlationMetadata())
export function correlationMetadata(): Metadata {
const metadata = new Metadata();
metadata.set('x-correlation-id', requestContext.getStore()?.correlationId ?? 'unknown');
return metadata;
}
NestInterceptorcontext.switchToRpc().getContext().add(metadata). Nó
biên dịch hoàn toàn sạch — vì Nest khai getContext() trả về
any — nhưng gateway là ứng dụng HTTP, nên lúc chạy
getContext() trả về đối tượng request của Express, thứ không hề có method
add().Bật nó lên rồi gọi thử: mọi request trả
500 kèm
TypeError: context.switchToRpc(...).getContext(...).add is not a function. Metadata của lời
gọi gRPC đi ra phải được gắn ở chỗ gọi client, không phải ở tầng xử lý request HTTP
đi vào — hai chiều khác nhau, và chỉ tầng đầu mới thấy được ngữ cảnh RPC.
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.
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
Độ 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.
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:
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.
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.
Bình luận