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
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.
--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);
}
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);
});
}
}
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.
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 đó.
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.
}
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.
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.
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 { 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());
}
}
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.
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