Hết Part 2, video đã nằm an toàn trên đĩa máy chủ và có thể phát lại được — nhưng vẫn nguyên định dạng gốc. Người dùng tải lên một file quay bằng điện thoại 4K nặng 1,8 GB, và bất kỳ ai muốn xem cũng phải tải về đúng 1,8 GB đó.
Part này làm phần việc nặng nhất của cả hệ thống: chuyển mã ra nhiều độ phân giải. Và ngay khi chạm vào nó, Node lộ ra giới hạn cốt lõi của mình — một luồng. Toàn bộ phần còn lại của bài là các cách khác nhau để vượt qua giới hạn đó, mỗi cách hợp với một loại công việc.
child_process và đọc được tiến độ thật của nó.
Một hồ worker_threads làm việc nặng CPU thuần trong Node. Nhiều instance ứng dụng chạy song
song sau nginx. Một hàng đợi có thử lại và có nơi chứa job hỏng. Và tiến độ chuyển mã chạy về trình
duyệt theo thời gian thực, đúng tab của đúng người dùng, kể cả khi worker và WebSocket nằm ở hai tiến
trình khác nhau.
1. Vì sao không thể chuyển mã ngay trong handler
Cách viết thẳng thắn nhất là gọi ffmpeg ngay sau khi nhận xong file. Nó sai theo hai tầng, và tầng thứ hai mới là tầng chết người.
Tầng thứ nhất: chuyển mã một video 10 phút mất vài phút. Giữ một kết nối HTTP mở suốt vài phút thì gần như
chắc chắn có ai đó ở giữa cắt nó — nginx với proxy_read_timeout, hoặc chính trình duyệt, hoặc
mạng di động của người dùng. Công việc vẫn chạy tiếp trong khi người dùng đã thấy lỗi.
Tầng thứ hai: nếu bạn dùng một thư viện chuyển mã viết bằng JavaScript, hoặc thậm chí chỉ ngồi tính hash của file trong handler, thì toàn bộ ứng dụng đứng hình. Không phải chậm — đứng. Event loop của Node là một luồng; trong lúc nó chạy vòng lặp tính toán của bạn thì không request nào khác được phục vụ, không callback nào chạy, health check cũng không trả lời nổi.
import { Controller, Get } from '@nestjs/common';
@Controller()
export class BlockController {
@Get('block')
block(): string {
const end = Date.now() + 5000;
while (Date.now() < end) {
// Nam giay tinh toan thuan tuy — dai dien cho bat ky viec nang CPU nao.
}
return 'xong';
}
@Get('ping')
ping(): string {
return 'pong';
}
}
Thêm BlockController vào controllers: [...] của AppModule — cây thư
mục ở Part 1 mục 3 không giữ lại AppController mà nest new sinh ra, nên đây là
controller đầu tiên nằm thẳng ở gốc ứng dụng.
Gọi /block ở một tab, rồi gọi /ping ở tab khác. /ping — một hàm trả
về hằng số — sẽ chờ đủ năm giây. Đó là toàn bộ vấn đề, gói trong mười dòng.
# Tab 1 — chay nen roi doi mot chut cho no kip khoi dong
( curl -s -o /dev/null -w 'block: %{time_total}s\n' http://localhost:3000/block & ); sleep 0.4
# Tab 2 — goi /ping trong luc /block dang chay
curl -s -o /dev/null -w 'ping: %{time_total}s\n' http://localhost:3000/ping
# -> block: 5.005s
# -> ping: 4.600s <-- mot ham `return 'pong'` ma mat 4,6 giay
# Doi chung: goi lai /ping luc event loop ranh
curl -s -o /dev/null -w 'ping: %{time_total}s\n' http://localhost:3000/ping
# -> ping: 0.001s
Chênh lệch 0,001 giây so với 4,6 giây cho cùng một handler là con số đáng nhớ nhất của cả
mục này. /ping không hề chậm đi — nó không được chạy chút nào cho tới khi
/block nhả event loop ra.
2. child_process: gọi ffmpeg cho đúng
ffmpeg là một chương trình viết bằng C, chạy như một tiến trình riêng của hệ điều hành. Nó có bộ nhớ riêng, có luồng riêng, và quan trọng nhất: nó bận bao nhiêu cũng không ảnh hưởng gì tới event loop của Node. Node chỉ khởi động nó rồi ngồi nghe.
Và vì nó là một chương trình riêng chứ không phải một gói npm, npm i không cài được nó — phải
cài ở mức hệ điều hành. Đây là phụ thuộc ngoài duy nhất của cả loạt bài:
brew install ffmpeg # macOS
sudo apt install -y ffmpeg # Ubuntu/Debian
ffmpeg -version # phai in ra so phien ban, khong phai "command not found"
Bước kiểm cuối đáng làm thật chứ không phải cho có: thiếu ffmpeg thì lỗi hiện ra ở tận trong worker dưới
dạng spawn ffmpeg ENOENT, cách xa nguyên nhân thật, và mục 2.2 ngay dưới đây sẽ cho thấy vì
sao kiểu lỗi đó đặc biệt khó chịu.
2.1. spawn chứ không phải exec
Bốn hàm trong node:child_process hay bị dùng lẫn lộn, và chỉ một cái đúng cho việc này.
exec chay qua shell, gom TOAN BO output vao bo nho roi moi tra ve
-> ffmpeg in hang nghin dong tien do => phinh bo nho
-> chuoi lenh di qua shell => lo hong chen lenh
execFile khong qua shell (tot hon), nhung van gom het output
fork tao mot tien trinh NODE moi, co san kenh IPC
-> dung cho worker Node, khong dung cho ffmpeg
spawn tra ve STREAM stdout/stderr, khong gom vao bo nho
-> dung cho tien trinh chay lau va noi nhieu. Chinh la ffmpeg.
exec với chuỗi ghép là lỗ hổng chèn lệnhexec(`ffmpeg -i ${path} out.mp4`) đưa cả chuỗi cho shell diễn giải. Người dùng đặt tên file
là a.mp4; rm -rf /var/media thì shell hiểu đó là hai lệnh và chạy cả hai. spawn nhận đối số dưới dạng mảng, và không có shell nào ở giữa. Mỗi phần
tử tới thẳng ffmpeg như một đối số nguyên vẹn, dấu chấm phẩy trong tên file chỉ là một ký tự trong tên
file. Đây là lý do đủ để chọn spawn ngay cả khi bỏ qua mọi lý do về bộ nhớ.
2.2. Đọc tiến độ thật từ ffmpeg
ffmpeg không có API báo tiến độ. Nó in trạng thái ra stderr — vâng, stderr, dù
đó không phải lỗi — theo từng dòng dạng khoá bằng giá trị nếu bạn thêm cờ -progress pipe:2.
Việc của ta là đọc dòng đó và chia cho tổng thời lượng.
import { spawn } from 'node:child_process';
import { createInterface } from 'node:readline';
export interface TranscodeOptions {
readonly input: string;
readonly output: string;
readonly height: 480 | 720 | 1080;
readonly durationSeconds: number;
readonly onProgress: (percent: number) => void;
readonly signal: AbortSignal;
}
export async function transcode(options: TranscodeOptions): Promise<void> {
const args = [
'-hide_banner',
'-nostdin', // KHONG doi input tu ban phim. Thieu co nay ffmpeg co the treo im lang.
'-i',
options.input,
'-vf',
`scale=-2:${options.height}`, // -2 = tu tinh chieu rong sao cho chia het cho 2
'-c:v',
'libx264',
'-preset',
'veryfast',
'-crf',
'23',
'-c:a',
'aac',
'-movflags',
'+faststart', // day metadata len dau file => trinh duyet phat duoc ngay
'-progress',
'pipe:2',
'-y',
options.output,
];
const child = spawn('ffmpeg', args, { signal: options.signal });
// spawn bao loi KHOI DONG qua event 'error', KHONG qua ma thoat. Khong bat o
// day thi thieu ffmpeg nem ra mot loi khong ai bat va giet ca worker, thay vi
// hong mot job roi duoc thu lai nhu muc 4.1.
const failed = new Promise<never>((_, reject) => {
child.on('error', reject);
});
// Doc theo DONG. Doc theo chunk thi mot dong co the bi cat lam doi.
const lines = createInterface({ input: child.stderr });
const readProgress = (async () => {
for await (const line of lines) {
// ffmpeg in "out_time_ms=12345678" — don vi thuc te la MICRO giay,
// bat ke ten bien noi gi.
const match = /^out_time_ms=(\d+)$/.exec(line);
if (match?.[1] !== undefined) {
const seconds = Number(match[1]) / 1_000_000;
const percent = Math.min(99, Math.round((seconds / options.durationSeconds) * 100));
options.onProgress(percent);
}
}
})();
const closed = new Promise<number>((resolve) => child.on('close', resolve));
// Chay dua voi `failed`: hong ngay luc khoi dong thi bat duoc lien, khong treo.
const code = await Promise.race([
(async () => {
await readProgress;
return closed;
})(),
failed,
]);
if (code !== 0) {
throw new Error(`ffmpeg thoat voi ma ${code}`);
}
}
'error' và mã thoát là hai đường khác nhauspawn. Tiến trình con báo hỏng theo
hai đường tách biệt: chạy được rồi thất bại thì báo qua mã thoát, còn
không khởi động nổi (không tìm thấy chương trình, sai quyền, hết file descriptor) thì
báo qua event 'error' — và event đó không bao giờ đi kèm một mã thoát nào.Bỏ qua
'error' thì ENOENT trở thành lỗi không ai bắt.
try/catch quanh transcode() không chạy, và cả tiến trình
worker chết — tức là một job hỏng kéo theo mọi job khác đang chạy cùng worker đó. Toàn bộ cơ chế thử lại
ở mục 4.1 dựng trên giả định "job hỏng thì ném lỗi"; thiếu bốn dòng này thì giả định đó sai ngay từ kiểu
hỏng đầu tiên người đọc gặp phải.
Đọc bằng readline chứ không bắt sự kiện 'data' là chi tiết dễ bỏ qua. Stream
chia dữ liệu thành các mẩu theo kích thước bộ đệm, hoàn toàn không quan tâm tới dấu xuống dòng — nên một
mẩu có thể kết thúc giữa chữ out_ti và mẩu sau bắt đầu bằng me_ms=.... Biểu thức
chính quy trượt, tiến độ đứng im ở những chỗ ngẫu nhiên, và bạn mất một buổi chiều để tìm ra tại sao.
100% chỉ được đặt sau khi
close trả về mã 0. Nguyên tắc chung: phần trăm là ước lượng, mã
thoát là sự thật.
2.3. Huỷ, hết giờ, và tiến trình mồ côi
Một job chuyển mã có thể chạy mãi không dứt: file hỏng làm ffmpeg quay vòng, hoặc người dùng tải lên thứ gì đó khiến nó chờ input. Phải có giới hạn cứng, và phải chắc rằng khi bỏ cuộc thì tiến trình con thật sự chết.
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 30 * 60 * 1000);
try {
await transcode({ ...options, signal: controller.signal });
} finally {
clearTimeout(timer); // Quen dong nay thi tien trinh song them 30 phut vo ich.
}
signal gửi SIGTERM. Phần lớn tiến trình dừng ngay, nhưng ffmpeg đang ở giữa một
thao tác ghi đĩa dài có thể chưa xử lý tín hiệu. Nếu quá trình dừng cũng bị treo, cần một tầng nữa:
// Leo thang hai buoc. `signal` chi gui SIGTERM — xin dung, cho don dep.
options.signal.addEventListener('abort', () => {
const force = setTimeout(() => {
// Van con song sau 10 giay -> SIGKILL. Khong the bat, khong the bo qua.
if (child.exitCode === null) child.kill('SIGKILL');
}, 10_000);
child.on('close', () => clearTimeout(force));
});
transcode()function stop(child: ChildProcess) nghe gọn hơn, nhưng
không gọi được: child là biến cục bộ bên trong transcode(),
và hàm đó không trả nó ra ngoài. Người gọi chỉ có AbortSignal trong tay, không có tiến
trình con. Nên tầng leo thang phải đặt ở đúng chỗ child đang sống — nếu không thì
SIGKILL không bao giờ được gửi, dù code trông vẫn đầy đủ.
# Trong khi mot job dang chuyen ma, dem so tien trinh ffmpeg:
ps -ax -o command | grep -c '[f]fmpeg -hide_banner'
# -> 1 (dang chay)
# ...goi controller.abort(), roi doi vai giay va dem lai:
ps -ax -o command | grep -c '[f]fmpeg -hide_banner'
# -> 0 (khong con tien trinh mo coi nao)
Đây là phép thử đáng làm vì nó bắt được đúng thứ khó thấy nhất: transcode() ném
AbortError ngay lập tức, nên nhìn từ phía code thì mọi thứ có vẻ đã dừng — nhưng tiến trình
ffmpeg thật sự chết hay không thì chỉ ps mới trả lời được.
SIGKILL — do container hết bộ nhớ chẳng hạn — thì ffmpeg
không chết theo. Nó thành mồ côi và tiếp tục ăn CPU cho tới khi xong một việc mà không
còn ai quan tâm. Khởi động lại vài lần là máy chủ đầy ffmpeg ma. Trong Docker, cách chắc nhất là chạy container với
--init: một tiến trình khởi tạo tối giản
đứng ở PID 1, nhận nuôi tiến trình mồ côi và dọn chúng đi. Đây cũng chính là lý do
docker-compose.yml của dự án đặt init: true cho dịch vụ worker.
3. worker_threads: khi việc nặng nằm trong chính JavaScript
ffmpeg giải quyết được phần chuyển mã vì nó là chương trình có sẵn. Nhưng có những việc bạn phải tự viết bằng JavaScript và chúng vẫn nặng CPU: tính hash SHA-256 của file 2 GB để phát hiện trùng lặp, dựng dải ảnh thu nhỏ cho thanh tua, so khớp vân tay nội dung.
Đẻ hẳn một tiến trình Node mới cho mỗi việc như vậy thì tốn: mỗi tiến trình là một bản V8 riêng, khoảng
30–40 MB và mất hàng chục mili giây để khởi động. worker_threads nhẹ hơn nhiều — cùng
tiến trình, nhưng mỗi luồng có event loop riêng, nên vòng lặp tính toán của nó không đụng gì tới luồng
chính.
import { parentPort } from 'node:worker_threads';
import { createHash } from 'node:crypto';
import { createReadStream } from 'node:fs';
interface HashTask {
readonly filePath: string;
}
// NGHE tung viec mot, thay vi doc workerData. Ho worker o duoi tai su dung
// mot worker cho nhieu viec, ma workerData thi chi dat duoc DUNG MOT LAN
// luc tao Worker — dung no thi worker chi lam duoc dung mot viec roi thanh
// vo dung.
parentPort?.on('message', (task: HashTask) => {
const hash = createHash('sha256');
createReadStream(task.filePath)
.on('data', (chunk) => hash.update(chunk))
.on('end', () => parentPort?.postMessage({ digest: hash.digest('hex') }))
.on('error', (error: Error) => parentPort?.postMessage({ error: error.message }));
});
workerData và postMessage là hai mô hình khác nhau
workerData là ảnh chụp
một lần, đóng băng lúc new Worker(...) — nó hợp với worker kiểu
làm một việc rồi chết. Còn hồ worker ở ngay dưới đây tồn tại chính là để
tái sử dụng, nên nó phải gửi việc bằng postMessage, và worker phải có
parentPort.on('message') để nghe.Trộn hai mô hình thì biên dịch vẫn sạch nhưng chạy là hỏng ngay:
new Worker(script) không truyền workerData nên task là
undefined, worker chết với
TypeError: Cannot read properties of undefined (reading 'filePath'), và
pool.run() treo vĩnh viễn vì không bao giờ có 'message' nào quay về.
Tạo một worker cho mỗi file thì lại rơi vào đúng cái bẫy chi phí khởi động vừa nói, chỉ nhỏ hơn. Cách làm đúng là một hồ worker dựng sẵn, cỡ hồ bằng số nhân CPU:
import { Worker } from 'node:worker_threads';
import { availableParallelism } from 'node:os';
interface Pending<T> {
readonly data: unknown;
readonly resolve: (value: T) => void;
readonly reject: (error: Error) => void;
}
export class WorkerPool<T> {
private readonly idle: Worker[] = [];
private readonly queue: Pending<T>[] = [];
private spawned = 0;
// availableParallelism ton trong gioi han CPU cua container.
// os.cpus().length dem nhan cua CA MAY CHU — trong Docker la con so sai.
constructor(
private readonly script: string,
private readonly size = availableParallelism(),
) {}
private create(): Worker {
this.spawned += 1;
return new Worker(this.script);
}
run(data: unknown): Promise<T> {
return new Promise<T>((resolve, reject) => {
this.queue.push({ data, resolve, reject });
this.pump();
});
}
private pump(): void {
const job = this.queue.shift();
if (job === undefined) return;
const worker = this.idle.pop() ?? (this.spawned < this.size ? this.create() : undefined);
if (worker === undefined) {
this.queue.unshift(job); // Het worker ranh — xep lai hang, cho luot sau.
return;
}
worker.once('message', (result: { digest?: string; error?: string }) => {
if (result.error !== undefined) job.reject(new Error(result.error));
else job.resolve(result as T);
this.idle.push(worker);
this.pump(); // Tra worker ve ho roi keo viec tiep theo.
});
worker.postMessage(job.data);
}
// Moi Worker la mot handle giu event loop song. Khong co ham nay thi tien
// trinh KHONG BAO GIO tu thoat sau khi lam xong viec — xem muc 4.4, noi
// worker phai dung tu te trong gioi han thoi gian cua Docker.
async destroy(): Promise<void> {
const all = [...this.idle];
this.idle.length = 0;
await Promise.all(all.map((worker) => worker.terminate()));
this.spawned -= all.length;
}
}
3.1. Chọn cái nào
child_process worker_threads
Bo nho rieng hoan toan chung, chia se duoc buffer
Khoi dong 30-50ms 3-5ms
Truyen du lieu serialize qua IPC SharedArrayBuffer: 0 chi phi
postMessage: van phai copy
Su co mot tien trinh chet mot luong chet co the keo
khong anh huong ai ca tien trinh theo
Chay duoc gi BAT KY chuong trinh nao chi JavaScript
Dung khi goi ffmpeg, imagemagick hash, nen anh, tinh toan
bat cu binary nao thuan JS trong Node
Quy tắc rút gọn:
chương trình bên ngoài thì child_process, JavaScript của chính bạn thì
worker_threads. Dự án này dùng cả hai vì nó có cả hai loại việc — ffmpeg là chương trình bên ngoài, còn hàm băm là
JavaScript.
postMessage mặc định sao chép dữ liệu — gửi một buffer 500 MB sang worker là
tốn thêm 500 MB và một khoảng lặng đáng kể. Có hai cách tránh: SharedArrayBuffer để
hai luồng cùng nhìn một vùng nhớ, hoặc danh sách transfer để
chuyển quyền sở hữu: worker.postMessage(buffer, [buffer]);
Sau dòng này, buffer ở luồng gửi trở thành rỗng — nó đã sang bên kia, không có bản sao nào được tạo. Kiểu dữ liệu không cảnh báo bạn về điều đó, nên đây là một trong số ít chỗ phải nhớ bằng đầu.
4. Hàng đợi: tách công việc khỏi request
Giờ ghép lại. Endpoint chuyển mã không chạy ffmpeg — nó ghi một dòng vào bảng jobs, đẩy một
thông điệp vào hàng đợi, rồi trả về ngay.
import { Controller, Get, HttpCode, HttpStatus, Param, Post, UseGuards } from '@nestjs/common';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { RateLimitGuard } from '../common/rate-limit/rate-limit.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import type { JwtPayload } from '../auth/jwt-payload';
import { JobService, type JobState } from './job.service';
@Controller()
@UseGuards(JwtAuthGuard, RateLimitGuard)
export class JobController {
constructor(private readonly jobs: JobService) {}
@Post('media/:videoId/transcode')
@HttpCode(HttpStatus.ACCEPTED) // 202: da nhan, chua lam xong
async transcode(
@Param('videoId') videoId: string,
@CurrentUser() user: JwtPayload,
): Promise<{ jobId: string }> {
const job = await this.jobs.enqueue(videoId, user.sub);
return { jobId: job.id };
}
// Nguon su that goi lai luc connect/reconnect — xem muc 6-7.
@Get('jobs/active')
async active(@CurrentUser() user: JwtPayload): Promise<JobState[]> {
return this.jobs.findActiveForUser(user.sub);
}
}
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Job } from './job.entity';
import { JobQueue } from './job.queue';
export interface JobState {
readonly id: string;
readonly videoTitle: string;
readonly status: 'queued' | 'processing' | 'completed' | 'failed';
readonly percent: number;
}
@Injectable()
export class JobService {
constructor(
@InjectRepository(Job) private readonly jobs: Repository<Job>,
private readonly queue: JobQueue,
) {}
async enqueue(videoId: string, userId: string, height: 480 | 720 | 1080 = 720): Promise<Job> {
const job = await this.jobs.save(this.jobs.create({ videoId }));
await this.queue.push({ id: job.id, videoId, userId, height, attempt: 0 });
return job;
}
async markFailed(jobId: string, error: string): Promise<void> {
await this.jobs.update(jobId, { status: 'failed', error, finishedAt: new Date() });
}
// Nguon su that cho FE khi connect/reconnect (muc 6) — WebSocket chi tang
// toc, bang jobs moi la thu luon dung. "Active" nghia la job chua xong
// (queued/processing) hoac vua xong/hong trong 24 gio gan nhat, de UI con
// thay duoc buoc chuyen trang thai cuoi cung sau khi reconnect.
async findActiveForUser(userId: string): Promise<JobState[]> {
const cutoff = new Date(Date.now() - 24 * 60 * 60 * 1000);
const jobs = await this.jobs
.createQueryBuilder('job')
.innerJoinAndSelect('job.video', 'video')
.where('video.user_id = :userId', { userId })
.andWhere("(job.status IN ('queued', 'processing') OR job.updated_at >= :cutoff)", { cutoff })
.orderBy('job.created_at', 'DESC')
.getMany();
return jobs.map((job) => ({
id: job.id,
videoTitle: job.video.originalName ?? job.videoId,
status: job.status,
// Phan tram song chi ton tai qua Redis pub/sub trong luc dang chay —
// khong luu vao Postgres. WebSocket se day gia tri that ngay sau khi
// connect (xem useJobProgress.ts o muc 7).
percent: job.status === 'completed' ? 100 : 0,
}));
}
}
JobController và JobService chưa từng xuất hiện
this.jobs.enqueue(...) ở trên giả định một service đã tồn tại sẵn, nhưng
JobService — nơi thật sự ghi dòng vào bảng jobs rồi đẩy vào
JobQueue — chưa từng được viết ra ở đâu trong cả bốn phần. Khai báo đầy đủ ở trên, cùng với
việc bọc endpoint vào một JobController thật — kèm luôn GET /jobs/active mà
mục 6-7 phía dưới dùng để đồng bộ lại sau khi mất kết nối, cũng chưa từng có route nào phục vụ nó.
202 Accepted chứ không phải 200 OK. Khác biệt không chỉ nằm ở con số: nó nói với
client rằng kết quả chưa có, và client phải theo dõi bằng cách khác — chính là WebSocket ở mục 6.
Hàng đợi ở đây dùng Redis Streams thay vì danh sách. Lý do là XACK: một thông điệp lấy ra
nhưng chưa được xác nhận vẫn nằm trong danh sách chờ, nên worker chết giữa chừng thì job không biến mất.
Với LPOP thì thông điệp rời hàng đợi ngay lúc đọc, worker chết là mất luôn.
import { Injectable, OnModuleInit } from '@nestjs/common';
import { RedisService } from '../redis/redis.service';
export interface TranscodeJob {
readonly id: string; // trung voi Job.id trong Postgres
readonly videoId: string;
readonly userId: string;
readonly height: 480 | 720 | 1080;
readonly attempt: number;
// Gan boi pull() sau khi doc tu Redis Streams — push() khong co gia tri nay.
readonly messageId?: string;
// Chi co khi day sang hang doi chet (muc 4.1).
readonly lastError?: string;
}
const STREAM = 'jobs:transcode';
const GROUP = 'workers';
function parseMessage(fields: string[]): TranscodeJob {
// xadd luu 'data' <json> — fields la mang phang [key, value, key, value, ...].
const raw = fields[fields.indexOf('data') + 1] as string;
return JSON.parse(raw) as TranscodeJob;
}
@Injectable()
export class JobQueue implements OnModuleInit {
constructor(private readonly redis: RedisService) {}
async onModuleInit(): Promise<void> {
// MKSTREAM: tao stream neu chua co. '$' = bo qua nhung message co tu
// truoc, chi doc nhung gi den sau khi group nay duoc tao.
try {
await this.redis.xgroup('CREATE', STREAM, GROUP, '$', 'MKSTREAM');
} catch (err) {
// BUSYGROUP: group da ton tai tu lan khoi dong truoc — khong phai loi that.
if (!String(err).includes('BUSYGROUP')) throw err;
}
}
async push(payload: TranscodeJob): Promise<string> {
const id = await this.redis.xadd(STREAM, '*', 'data', JSON.stringify(payload));
if (id === null) throw new Error('XADD khong tra ve id.');
return id;
}
async pull(consumerName: string): Promise<TranscodeJob | null> {
// BLOCK 5000: cho toi 5 giay neu hang doi rong, khong quay vong hoi lien tuc.
// Chu thich kieu tra ve bang tay: chu ky bien the nay cua xreadgroup khong
// duoc ioredis suy ra dung, TypeScript thay "unknown" neu khong ep kieu.
const result = (await this.redis.xreadgroup(
'GROUP', GROUP, consumerName,
'COUNT', 1,
'BLOCK', 5000,
'STREAMS', STREAM, '>',
)) as [string, [string, string[]][]][] | null;
if (result === null || result.length === 0) return null;
const [messageId, fields] = result[0]![1][0]!;
return { ...parseMessage(fields), messageId };
}
async ack(messageId: string): Promise<void> {
await this.redis.xack(STREAM, GROUP, messageId);
}
}
TranscodeJob, parseMessage và lớp bao quanh không có sẵn
this.redis nên phải nằm trong một class thật, và
TranscodeJob — kiểu dữ liệu xuất hiện trong cả ba chữ ký hàm — chưa từng được định nghĩa ở
đâu trước đó. Khai báo đầy đủ ở trên để phần còn lại của mục này biên dịch được.
Ba class vừa viết — JobController, JobService, JobQueue — vẫn cần
một module công bố chúng, giống hệt MediaModule ở Part 2 mục 6:
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Job } from './job.entity';
import { JobQueue } from './job.queue';
import { JobService } from './job.service';
import { JobController } from './job.controller';
import { AuthModule } from '../auth/auth.module';
@Module({
// AuthModule vi JobController dung JwtAuthGuard — cung ly do nhu MediaModule
// o Part 2 muc 6.
imports: [TypeOrmModule.forFeature([Job]), AuthModule],
controllers: [JobController],
providers: [JobQueue, JobService],
// WorkerModule o muc 4.3 tiem lai ca hai.
exports: [JobQueue, JobService],
})
export class JobModule {}
Thêm JobModule vào imports của AppModule, rồi thử ngay:
curl -sk --resolve media-forge.local:8443:127.0.0.1 \
-X POST "https://media-forge.local:8443/media/$VID/transcode" \
-H "Authorization: Bearer $TOK" -w '\nhttp=%{http_code}\n'
# -> {"jobId":"06f95a5e-..."}
# -> http=202
# Thong diep da nam trong Redis Streams chua?
docker exec forge-redis redis-cli XLEN jobs:transcode
# -> 1
curl -sk --resolve media-forge.local:8443:127.0.0.1 \
"https://media-forge.local:8443/jobs/active" -H "Authorization: Bearer $TOK"
# -> [{"id":"06f95a5e-...","videoTitle":"...","status":"queued","percent":0}]
Chưa có worker nào chạy nên job đứng ở queued — đúng như mong đợi. Nó nằm yên trong stream
chờ người tới lấy, và đó chính là điều 202 hứa với client.
4.1. Thử lại, và nơi chứa job không bao giờ chạy được
Job thất bại có hai loại rất khác nhau. Loại thứ nhất là tạm thời: hết chỗ trống trên đĩa, mạng chớp tắt, database bận. Thử lại là chữa được. Loại thứ hai là vĩnh viễn: file không phải video, codec không hỗ trợ. Thử lại một nghìn lần vẫn hỏng — và nếu bạn cứ thử, một job hỏng sẽ chiếm worker mãi mãi và chặn tất cả các job phía sau.
Hai loại thất bại cần hai nơi khác nhau để đợi: một hàng đợi trễ cho lần thử lại kế tiếp, một hàng đợi
chết cho thứ không thử lại nữa. Thêm hai method vào JobQueue đã viết ở mục 4:
// them vao class JobQueue o muc 4
private readonly DELAYED_KEY = 'jobs:delayed'; // sorted set: score = thoi diem (ms) duoc phep chay
private readonly DEAD_LETTER_STREAM = 'jobs:dead';
// Khong day thang vao stream chinh — xep vao sorted-set voi diem la thoi
// diem duoc phep chay. promoteDelayed() (goi dinh ky) day nhung job da toi
// han sang stream chinh.
async pushDelayed(payload: TranscodeJob, delayMs: number): Promise<void> {
await this.redis.zadd(this.DELAYED_KEY, Date.now() + delayMs, JSON.stringify(payload));
}
async promoteDelayed(): Promise<number> {
const due = await this.redis.zrangebyscore(this.DELAYED_KEY, 0, Date.now());
for (const raw of due) {
// ZREM truoc: hai worker cung quet dung luc thi chi mot ben con xoa
// duoc, ben kia nhan 0 — khong day trung.
const removed = await this.redis.zrem(this.DELAYED_KEY, raw);
if (removed > 0) await this.push(JSON.parse(raw) as TranscodeJob);
}
return due.length;
}
// Khong dung consumer group — day la noi con nguoi doc lai bang XRANGE,
// khong ai XACK ca.
async pushDeadLetter(payload: TranscodeJob): Promise<string> {
const id = await this.redis.xadd(this.DEAD_LETTER_STREAM, '*', 'data', JSON.stringify(payload));
if (id === null) throw new Error('XADD khong tra ve id.');
return id;
}
promoteDelayed() cần một nơi gọi định kỳ — giống hệt OutboxRelay ở Part 4, chỉ
khác nó chạy trong tiến trình worker:
import { Injectable } from '@nestjs/common';
import { Interval } from '@nestjs/schedule';
import { JobQueue } from './job.queue';
@Injectable()
export class DelayedJobPromoter {
constructor(private readonly queue: JobQueue) {}
@Interval(1000)
async flush(): Promise<void> {
await this.queue.promoteDelayed();
}
}
Giờ mới tới vòng lặp chính — một method riêng của JobRunner ở mục 4.4, không phải hàm rời:
const MAX_ATTEMPTS = 3;
// Method cua JobRunner, KHONG PHAI mot ham doc lap — mot function() thuong
// khong co `this` gan san, trong khi than ham ben duoi dung this.queue,
// this.jobs. Xem class JobRunner day du o muc 4.4.
private async processOne(job: TranscodeJob): Promise<void> {
try {
await this.runTranscode(job);
await this.queue.ack(job.messageId!);
} catch (error) {
const attempt = job.attempt + 1;
// Ghi so lan da thu vao CSDL. Redis giu con so nay trong thong diep, nhung
// nguoi di dieu tra mot job hong thi nhin vao bang `jobs` truoc tien —
// de cot `attempts` dung im o 0 la de no noi doi.
await this.jobRepo.update(job.id, { attempts: attempt });
if (attempt >= MAX_ATTEMPTS) {
// Het luot: day sang hang doi chet. KHONG xoa — con nguoi can xem lai.
await this.queue.pushDeadLetter({ ...job, lastError: String(error) });
await this.queue.ack(job.messageId!);
await this.jobs.markFailed(job.id, String(error));
return;
}
// Cho lau dan: 2s, 4s, 8s. Thu lai ngay lap tuc chi lam nang them
// dung cai he thong dang gap su co.
const delayMs = 2 ** attempt * 1000;
await this.queue.pushDelayed({ ...job, attempt }, delayMs);
await this.queue.ack(job.messageId!);
}
}
Việc tối thiểu cần làm: đặt cảnh báo khi hàng đợi chết có phần tử, và giữ đủ ngữ cảnh (id video, thông báo lỗi cuối) để đẩy lại được sau khi vá.
4.2. Tính bình thản khi thử lại
Có một cái bẫy tinh vi. Worker chuyển mã xong, trừ credit của người dùng, rồi chết trước khi kịp
XACK. Redis thấy thông điệp chưa được xác nhận nên giao lại cho worker khác — và người dùng
bị trừ tiền lần thứ hai cho cùng một video.
Đây lại đúng là gia đình của lỗi ở Part 1, chỉ khác là lần này thủ phạm không phải hai request đồng thời mà là một thông điệp được xử lý hai lần. Lời giải là làm cho thao tác bình thản: chạy hai lần cho kết quả giống hệt chạy một lần.
export interface ChargeForJobResult {
readonly charged: boolean;
readonly balance: number;
}
async chargeForJob(jobId: string, userId: string, amount: number): Promise<ChargeForJobResult> {
return this.dataSource.transaction(async (manager) => {
// So cai append-only tu Part 1, gio them mot rang buoc duy nhat tren job_id.
// Lan chay thu hai va vao rang buoc nay va bi tu choi.
const result = await manager
.createQueryBuilder()
.insert()
.into(CreditEntry)
.values({ userId, jobId, delta: -amount, reason: 'transcode' })
.orIgnore() // ON CONFLICT DO NOTHING
.execute();
// Bi bo qua vi trung job_id thi RETURNING khong tra dong nao — result.raw rong.
const charged = result.raw.length > 0;
const row = await manager
.createQueryBuilder(CreditEntry, 'e')
.select('COALESCE(SUM(e.delta), 0)', 'balance')
.where('e.user_id = :userId', { userId })
.getRawOne<{ balance: string }>();
return { charged, balance: Number(row?.balance ?? 0) };
});
}
Một chỉ mục duy nhất trên job_id và một mệnh đề ON CONFLICT DO NOTHING — thế là
xong. Không cần cờ, không cần bảng phụ theo dõi "đã xử lý chưa", không có cửa sổ đua nào. Database giữ
ràng buộc, và nó giữ đúng kể cả khi hai worker chạy cùng lúc trên hai máy khác nhau.
result.identifiers nói dối khi bị ON CONFLICT DO NOTHING bỏ qua
result.identifiers.length > 0 để biết dòng có được chèn hay
không. Đo thật trên Postgres cho thấy điều ngược lại: khi bị xung đột bỏ qua,
identifiers không phải mảng rỗng — nó là mảng
có đúng một phần tử, mang giá trị null ([null]), nên
.length > 0 vẫn đúng ở cả hai lần gọi và luôn báo charged: true. Tín hiệu
đáng tin là result.raw: rỗng khi bị bỏ qua, có một dòng khi chèn thành công, vì nó phản ánh
đúng những gì mệnh đề RETURNING thực sự trả về.
4.3. Worker là một tiến trình như thế nào
Từ đầu part này worker được nhắc tới như một thứ có sẵn. Nó không có sẵn — phải tự dựng, và điểm hay là nó dùng lại toàn bộ module của ứng dụng, chỉ bỏ đi phần HTTP.
import { NestFactory } from '@nestjs/core';
import { WorkerModule } from './worker.module';
import { JobRunner } from './job.runner';
async function bootstrap(): Promise<void> {
// createApplicationContext, KHONG phai create(). Co DI, co ConfigService,
// co TypeORM — nhung khong mo cong nao, khong co controller nao.
const app = await NestFactory.createApplicationContext(WorkerModule);
app.enableShutdownHooks();
const runner = app.get(JobRunner);
await runner.loop();
}
void bootstrap();
Xong thì có ba dấu vết kiểm được: jobs.status chuyển sang completed với cả
started_at lẫn finished_at; một file <videoId>-720p.mp4 xuất
hiện trong uploads/; và một dòng credit_entries với delta = -10 kèm
job_id — đúng giá 720p trong PRICE_BY_HEIGHT.
import { Module } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ScheduleModule } from '@nestjs/schedule';
import { AppConfigModule } from '../config/config.module';
import { sharedOptions } from '../database/typeorm.options';
import type { AppConfig } from '../config/configuration';
import { RedisModule } from '../redis/redis.module';
import { BillingModule } from '../billing/billing.module';
import { JobModule } from '../job/job.module';
import { Video } from '../media/video.entity';
import { Job } from '../job/job.entity';
import { DelayedJobPromoter } from '../job/delayed-job-promoter';
import { JobRunner } from './job.runner';
// Dung lai toan bo module nghiep vu — chia thu muc theo mien nghiep vu
// tra co tuc dung o day (xem Part 1, muc 1).
@Module({
imports: [
AppConfigModule,
TypeOrmModule.forRootAsync({
inject: [ConfigService],
useFactory: (config: ConfigService<AppConfig, true>) => ({
...sharedOptions,
url: config.get('DATABASE_URL', { infer: true }),
}),
}),
// Video CUNG Job — JobRunner tiem thang Repository<Job>, va JobModule
// khong export forFeature cua rieng no nen phai khai lai o day.
TypeOrmModule.forFeature([Video, Job]),
ScheduleModule.forRoot(), // bat buoc de @Interval o DelayedJobPromoter chay that
RedisModule,
BillingModule,
JobModule,
],
providers: [JobRunner, DelayedJobPromoter],
})
export class WorkerModule {}
ScheduleModule.forRoot() thì @Interval im lặng không chạy
@Interval (dùng ở DelayedJobPromoter trên, và ở OutboxRelay tại
Part 4) đến từ gói @nestjs/schedule — cần npm i @nestjs/schedule và đăng ký
ScheduleModule.forRoot() trong module gốc của mỗi tiến trình dùng nó. Thiếu bước này ứng
dụng vẫn khởi động bình thường, không báo lỗi gì cả — chỉ riêng hàm gắn @Interval không bao
giờ được gọi. Đây là kiểu lỗi khó phát hiện nhất: mọi thứ trông như chạy đúng cho tới khi có ai hỏi vì
sao hàng đợi trễ không bao giờ được đẩy lại.
Giờ JobRunner đã đủ: vòng lặp, xử lý một job, và dừng tử tế. Chạy thật để xem nó ăn việc —
đây là lần đầu worker có mọi mảnh nó cần.
Đây là chỗ trả cổ tức cho quyết định ở Part 1: chia thư mục theo miền nghiệp vụ chứ không theo loại file.
WorkerModule nhập BillingModule và JobModule y như
AppModule làm, nên hàm trừ credit mà worker gọi là chính xác hàm mà API gọi — không
có bản sao nào để trôi khỏi nhau.
{
"scripts": {
"start:dev": "nest start --watch",
"start:worker": "node dist/worker/main.js",
"start:worker:dev": "nest start --watch --entryFile worker/main"
}
}
--watch dùng chung một dist/--watch đều build vào cùng
thư mục dist/, mà nest-cli.json từ Part 1 đang đặt
"deleteOutDir": true. Bật worker lên là nó xoá sạch dist/ và
giết luôn tiến trình API đang chạy: Error: Cannot find module '.../dist/main'.Triệu chứng dễ đi lạc: mọi request sau đó trả
502 Bad Gateway từ nginx,
nên thoạt nhìn giống lỗi nginx chứ không giống lỗi của cái vừa bật. Cách chữa gọn nhất là tắt cờ đó
đi:// nest-cli.json
"compilerOptions": { "deleteOutDir": false, ... }
services:
worker:
build: . # can mot Dockerfile — xem callout ngay duoi
command: npm run start:worker
init: true # PID 1 tu te — don tien trinh ffmpeg mo coi, xem canh bao muc 2.3
environment:
# PHAI khop voi dich vu postgres da dung o Part 1 muc 4.5:
# user forge, mat khau forge, database media_forge.
DATABASE_URL: postgres://forge:forge@postgres:5432/media_forge
REDIS_URL: redis://redis:6379
UPLOAD_DIR: /var/media # trong container, khac ./uploads o may minh
volumes:
- media:/var/media # dung chung thu muc voi API
depends_on:
postgres: { condition: service_healthy }
redis: { condition: service_started }
deploy:
replicas: 2 # hai worker chay song song, Redis Streams tu chia viec
# Khai bao o cap cao nhat, ngang hang voi `services:` — thieu khoi nay thi
# Compose bao volume `media` khong ton tai.
volumes:
forge-pgdata: # da co tu Part 1
media: # moi, dung chung giua API va worker
build: . cần một Dockerfile, và nó phải có ffmpeg
npm run start:dev) và chỉ để
Postgres, Redis, nginx trong Docker — nên chưa có Dockerfile nào cả.
build: . cần một file như vậy, và điểm đáng lưu ý là image của worker
phải có ffmpeg bên trong, không chỉ Node:FROM node:24-alpine
RUN apk add --no-cache ffmpeg # BAT BUOC cho worker
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
Nếu chỉ muốn thử hàng đợi chứ chưa cần đóng gói, cách nhanh hơn nhiều là bỏ qua cả khối Compose này và chạy worker thẳng trên máy bằng
npm run start:worker:dev — phần còn lại của mục 4 và mục 6
đều hoạt động y hệt.
4.4. Dừng worker mà không bỏ dở job
Triển khai bản mới nghĩa là gửi SIGTERM cho worker. Nếu nó chết ngay lập tức, job đang chuyển
mã dở dang bị bỏ lại — thông điệp chưa XACK nên rồi cũng được giao lại, nhưng công sức mã hoá
mấy phút vừa rồi mất trắng, và có một file tạm nằm lại trên đĩa.
import { Injectable, OnApplicationShutdown } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { hostname } from 'node:os';
import { dirname, join } from 'node:path';
import { Job } from '../job/job.entity';
import { JobQueue } from '../job/job.queue';
import { JobService } from '../job/job.service';
import type { TranscodeJob } from '../job/job.queue';
import { Video } from '../media/video.entity';
import { BillingService } from '../billing/billing.service';
import { RedisService } from '../redis/redis.service';
import { transcode } from './transcoder';
// Gia tren moi do phan giai — dung cho demo, thuc te se doc tu bang gia.
const PRICE_BY_HEIGHT: Record<TranscodeJob['height'], number> = { 480: 5, 720: 10, 1080: 20 };
// So lan thu toi da truoc khi day sang hang doi chet (muc 4.1).
const MAX_ATTEMPTS = 3;
@Injectable()
export class JobRunner implements OnApplicationShutdown {
private stopping = false;
private current: Promise<void> | null = null;
private readonly consumerName = `${hostname()}-${process.pid}`;
constructor(
private readonly queue: JobQueue,
private readonly jobs: JobService,
@InjectRepository(Job) private readonly jobRepo: Repository<Job>,
@InjectRepository(Video) private readonly videos: Repository<Video>,
private readonly billing: BillingService,
private readonly redis: RedisService,
) {}
async loop(): Promise<void> {
while (!this.stopping) {
const job = await this.queue.pull(this.consumerName);
if (job === null) continue; // het 5 giay cho ma khong co viec
this.current = this.processOne(job);
await this.current;
this.current = null;
}
}
// Nest goi ham nay khi nhan SIGTERM (nho enableShutdownHooks).
async onApplicationShutdown(): Promise<void> {
this.stopping = true; // khong nhan viec moi nua
await this.current; // nhung lam not viec dang do
}
// Method cua JobRunner, KHONG PHAI mot ham doc lap — mot function() thuong
// khong co `this` gan san, ma than ham duoi day dung this.queue, this.jobs.
private async processOne(job: TranscodeJob): Promise<void> {
try {
await this.runTranscode(job);
await this.queue.ack(job.messageId!);
} catch (error) {
const attempt = job.attempt + 1;
if (attempt >= MAX_ATTEMPTS) {
// Het luot: day sang hang doi chet. KHONG xoa — con nguoi can xem lai.
await this.queue.pushDeadLetter({ ...job, lastError: String(error) });
await this.queue.ack(job.messageId!);
await this.jobs.markFailed(job.id, String(error));
return;
}
// Cho lau dan: 2s, 4s, 8s. Thu lai ngay lap tuc chi lam nang them
// dung cai he thong dang gap su co.
const delayMs = 2 ** attempt * 1000;
await this.queue.pushDelayed({ ...job, attempt }, delayMs);
await this.queue.ack(job.messageId!);
}
}
private async runTranscode(job: TranscodeJob): Promise<void> {
const video = await this.videos.findOneOrFail({ where: { id: job.videoId } });
await this.jobRepo.update(job.id, { status: 'processing', startedAt: new Date() });
const controller = new AbortController();
const output = join(dirname(video.originalKey), `${video.id}-${job.height}p.mp4`);
await transcode({
input: video.originalKey,
output,
height: job.height,
durationSeconds: video.duration ?? 60,
signal: controller.signal,
onProgress: (percent) => {
// Worker khong biet gi ve WebSocket. No chi phat ra Redis (muc 6).
void this.redis.publish('progress', JSON.stringify({ userId: job.userId, jobId: job.id, percent }));
},
});
// Tru credit BINH THAN — an toan neu job nay bi xu ly lai (muc 4.2).
await this.billing.chargeForJob(job.id, job.userId, PRICE_BY_HEIGHT[job.height]);
await this.jobRepo.update(job.id, { status: 'completed', finishedAt: new Date() });
// Kenh RIENG voi 'progress' — client can biet job da xong HOAN TOAN,
// khong chi la mot moc phan tram nua trong hang dai cac moc trung gian.
void this.redis.publish('job:done', JSON.stringify({ userId: job.userId, jobId: job.id }));
}
}
JobRunner đến đây mới đủ mảnh: vòng lặp, xử lý một job, và dừng tử tế. Giờ mới chạy thật được
— trước mục này file job.runner.ts chưa tồn tại đầy đủ, mà worker.module.ts thì
đã khai providers: [JobRunner] từ mục 4.3.
# Cua so 1: API (nhu tu truoc toi gio)
npm run start:dev
# Cua so 2: worker
npm run start:worker:dev
# Cua so 3: day mot job cho video da upload o Part 2
curl -sk --resolve media-forge.local:8443:127.0.0.1 \
-X POST "https://media-forge.local:8443/media/$VID/transcode" \
-H "Authorization: Bearer $TOK"
# -> {"jobId":"1639da5a-..."} http=202
# Nghe tien do worker phat ra, trong luc no chay
docker exec forge-redis redis-cli SUBSCRIBE progress
# -> {"userId":"...","jobId":"1639da5a-...","percent":22}
# -> {"userId":"...","jobId":"1639da5a-...","percent":47}
# -> {"userId":"...","jobId":"1639da5a-...","percent":70}
job:done thì UI kẹt ở "đang xử lý" mãi mãiuseJobProgress.ts ở mục 7 nghe 'job:progress' và
'job:done' như hai sự kiện tách biệt — chỉ nhánh job:done mới chuyển
status sang 'completed'. Nếu worker chỉ phát 'progress' (kể cả
lúc 100%), phía client vẫn thấy thanh chạy đủ nhưng nhãn trạng thái đứng nguyên ở
'processing' — vì không nhánh nào trong useJobProgress.ts đọc
percent để tự suy ra job đã xong.
processOne được ghép vào từ mục 4.1loop() gọi this.processOne(job) — thân của nó đã viết riêng ở mục 4.1 khi bàn
về thử lại, và vừa được ghép vào ngay trên, ngay trước runTranscode. Cùng với
runTranscode — nơi ghép transcode() (mục 2.2),
RedisService.publish (mục 6), và BillingService.chargeForJob (mục 4.2) lại với
nhau — class JobRunner ở trên giờ đầy đủ.
SIGTERM và SIGKILL; Kubernetes cho 30 giây. Một job
chuyển mã cần vài phút — nên đoạn code trên sẽ bị cắt ngang bất kể nó lịch sự đến đâu. Phải nâng hạn:
stop_grace_period: 10m trong Compose, hoặc
terminationGracePeriodSeconds: 600 trong Kubernetes. Và vẫn phải chấp nhận rằng một job đủ
dài sẽ bị cắt — đó là lý do XACK đặt sau khi làm xong chứ không phải trước, và là
lý do thao tác trừ tiền phải bình thản như mục 4.2.
services:
worker:
# ... build, command, init, environment, volumes, depends_on da chot o muc 4.3 ...
stop_grace_period: 10m # doi toi 10 phut truoc khi Docker gui SIGKILL
deploy:
replicas: 2
5. cluster: dùng hết số nhân CPU
Worker lo phần nặng rồi, nhưng bản thân API vẫn chỉ chạy trên một luồng, tức là một nhân. Máy chủ 8 nhân thì 7 nhân ngồi không.
cluster nhân bản tiến trình: một tiến trình chính đẻ ra N tiến trình con, tất cả cùng nghe
trên một cổng, và hệ điều hành chia kết nối cho chúng.
Mỗi tiến trình con phải tự chạy được một bản NestJS đầy đủ — tức là gọi lại đúng đoạn
NestFactory.create(AppModule) và app.listen(...) đã thấy ở Part 1 và Part 2. Bọc
đoạn đó vào một hàm export function bootstrap() trong src/main.ts, và chỉ tự gọi
nó khi file chạy trực tiếp — để cluster.ts phía dưới import lại đúng hàm này mà không chạy nó
thêm một lần nữa lúc import:
import { NestFactory } from '@nestjs/core';
import { ConfigService } from '@nestjs/config';
import { ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module';
import { TimeoutInterceptor } from './common/timeout/timeout.interceptor';
import type { NestExpressApplication } from '@nestjs/platform-express';
import type { AppConfig } from './config/configuration';
export async function bootstrap(): Promise<void> {
const app = await NestFactory.create<NestExpressApplication>(AppModule);
app.set('trust proxy', 1); // Part 2, muc 4.2
app.useGlobalPipes(
new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }),
); // Part 2, muc 1
app.useGlobalInterceptors(new TimeoutInterceptor()); // Part 2, muc 5.2
const config = app.get(ConfigService<AppConfig, true>);
await app.listen(config.get('PORT', { infer: true }));
}
// Chi tu chay khi file nay la entry point that su (npm run start:dev goi
// thang main.ts) — tranh chay bootstrap() hai lan khi cluster.ts import ham nay.
if (require.main === module) {
void bootstrap();
}
export trước
async function bootstrap, và bọc lời gọi tự động trong
if (require.main === module). Mọi thứ khác trong main.ts phải
giữ nguyên — bỏ ValidationPipe là mọi DTO thành đồ trang trí trở lại (Part
2 mục 1), bỏ TimeoutInterceptor là mất tầng giữa của ngân sách thời gian (Part 2 mục
5.2).Và đọc cổng vẫn phải qua
ConfigService, không quay lại
process.env.PORT: cờ noPropertyAccessFromIndexSignature bật từ Part 1 mục 5
khiến đúng dòng đó không biên dịch được —
TS4111: Property 'PORT' comes from an index signature. Đó chính là lỗi mẫu mà Part 1 đã
dùng để giới thiệu cờ này.
import { bootstrap } from './main';
import cluster from 'node:cluster';
import { availableParallelism } from 'node:os';
if (cluster.isPrimary) {
const count = availableParallelism();
for (let i = 0; i < count; i += 1) {
cluster.fork();
}
cluster.on('exit', (worker, code, signal) => {
// Chet vi loi thi dung ngay mot cai moi. Con neu la dung co y
// (SIGTERM khi trien khai ban moi) thi de yen cho no thoat.
if (signal !== 'SIGTERM') {
console.error(`worker ${worker.process.pid} chet (${code}), dung lai`);
cluster.fork();
}
});
} else {
// bootstrap() CUA src/main.ts (tien trinh API, co app.listen()) — KHONG
// PHAI bootstrap() cua src/worker/main.ts (tien trinh worker, khong HTTP,
// xem muc 4.3). Hai file dinh nghia hai ham trung ten nhung khac nhau.
void bootstrap();
}
npx nest build
node dist/cluster.js
# Bao nhieu ban NestJS da khoi dong?
# -> mot dong "Nest application successfully started" cho MOI nhan CPU
# Va bay gio lam lai dung phep thu o muc 1:
( curl -s -o /dev/null -w 'block: %{time_total}s\n' http://localhost:3000/block & ); sleep 0.5
curl -s -o /dev/null -w 'ping: %{time_total}s\n' http://localhost:3000/ping
# -> ping: 0.004s <-- muc 1 la 4,6 giay
# -> block: 5.004s
Đây là chỗ mục 1 và mục 5 khép lại thành một vòng. Cùng hai endpoint đó, cùng cách gọi đó: ở mục 1
/ping phải chờ 4,6 giây vì chỉ có một event loop, còn bây giờ nó trả lời
trong 0,004 giây — vì hệ điều hành đưa request sang một tiến trình con khác, tiến trình
đang không bận quay vòng.
Lưu ý cái này không chữa được nguyên nhân gốc: tiến trình bị chiếm vẫn đứng hình đúng năm giây,
và nếu đủ người gọi /block cùng lúc thì cả tám tiến trình đều tắc. cluster mua
thêm chỗ chịu tải, không xoá bỏ việc phải đẩy việc nặng ra ngoài như mục 2 và mục 3 đã làm.
Nhưng khi có nhiều tiến trình, ba thứ trước đây đúng bỗng thành sai:
1. Trang thai trong bo nho
Cache bang Map trong tien trinh -> 8 ban khac nhau, khong dong bo.
=> Redis.
2. Ket noi WebSocket
Nguoi dung noi vao tien trinh 3. Tien do phat tu worker khong den duoc.
=> Redis pub/sub (muc 6).
3. Cong viec dinh ky
@Cron chay o CA 8 tien trinh -> gui email 8 lan.
=> Khoa trong Redis, hoac tach han ra mot tien trinh scheduler rieng.
cluster hay nhiều containerdocker compose --scale), cách thường dùng là
một tiến trình Node cho mỗi container và để bộ điều phối nhân bản container. Bạn được
kiểm tra sức khoẻ, triển khai cuốn chiếu và giới hạn tài nguyên từng bản — những thứ
cluster không có. cluster vẫn là lựa chọn hợp lý khi chạy trên một máy chủ đơn lẻ, hoặc khi muốn tận dụng hết
nhân của một container lớn. Điều đáng nói là
ba vấn đề ở trên giống hệt nhau trong cả hai cách — nên hiểu chúng ở đây thì lên
Kubernetes không gặp bất ngờ nào.
5.1. nginx cân tải nhiều instance
upstream app {
least_conn; # gui toi instance dang it ket noi nhat
server app1:3000 max_fails=3 fail_timeout=30s;
server app2:3000 max_fails=3 fail_timeout=30s;
server app3:3000 max_fails=3 fail_timeout=30s;
keepalive 32;
}
least_conn hợp hơn kiểu chia lượt đều cho hệ thống này, vì thời gian xử lý mỗi request rất
lệch nhau: một lượt đăng nhập mất mấy mili giây, một lượt upload mất mấy phút. Chia đều theo lượt thì
instance nào xui nhận liên tiếp mấy upload sẽ quá tải trong khi các instance khác rảnh.
max_fails=3 fail_timeout=30s nghĩa là ba lần lỗi liên tiếp thì nginx tạm loại instance đó
khỏi vòng quay 30 giây. Một instance đang khởi động lại không kéo theo lỗi cho người dùng.
6. Realtime: đẩy tiến độ về đúng người
Người dùng bấm chuyển mã và nhận về 202. Giờ họ cần thấy thanh tiến trình nhích. Cách thô sơ
là gọi hỏi liên tục mỗi giây — nhưng một nghìn người xem tiến độ là một nghìn request mỗi giây cho một con
số hầu như không đổi.
WebSocket đảo chiều: một kết nối mở, server chủ động đẩy khi có gì mới. Nhưng nó đặt ra một câu hỏi mà mục 5 đã báo trước — worker biết tiến độ, nhưng kết nối WebSocket nằm ở một tiến trình khác.
# Cho @Interval o DelayedJobPromoter (muc 4.1)
npm i @nestjs/schedule
# Cho WebSocket o muc nay
npm i @nestjs/websockets @nestjs/platform-socket.io socket.io
import { OnGatewayConnection, WebSocketGateway, WebSocketServer } from '@nestjs/websockets';
import type { Server, Socket } from 'socket.io';
import { JwtService } from '@nestjs/jwt';
import type { JwtPayload } from '../auth/jwt-payload';
@WebSocketGateway({ path: '/ws', cors: false })
export class ProgressGateway implements OnGatewayConnection {
@WebSocketServer() private readonly server!: Server;
constructor(private readonly jwt: JwtService) {}
async handleConnection(client: Socket): Promise<void> {
try {
// Xac thuc NGAY luc bat tay. Ket noi chua xac thuc thi dong luon —
// de no mo la de nguoi la giu tai nguyen cua ban.
const token = String(client.handshake.auth['token'] ?? '');
const payload = await this.jwt.verifyAsync<JwtPayload>(token);
// Moi nguoi dung mot phong rieng. Khong ai nghe duoc tien do cua ai.
await client.join(`user:${payload.sub}`);
} catch {
client.disconnect(true);
}
}
// Duoc goi khi co thong diep tu Redis pub/sub.
emitProgress(userId: string, jobId: string, percent: number): void {
this.server.to(`user:${userId}`).emit('job:progress', { jobId, percent });
}
// Su kien RIENG, khac 'job:progress' — bao cho client biet job da xong
// hoan toan (kem tru credit), khong chi la mot moc phan tram nua.
emitDone(userId: string, jobId: string): void {
this.server.to(`user:${userId}`).emit('job:done', { jobId });
}
}
redis ở đâyprogress thuộc về ProgressSubscriber ngay
bên dưới, không phải ProgressGateway — gateway chỉ cần JwtService để xác thực
bắt tay. Thêm tham số redis: RedisService vào constructor mà không dùng tới sẽ biên dịch
lỗi TS6138 dưới noUnusedLocals mà chính Part 1 khuyến nghị bật.
// Worker khong biet gi ve WebSocket. No chi phat ra Redis.
await this.redis.publish(
'progress',
JSON.stringify({ userId: job.userId, jobId: job.id, percent }),
);
import { Injectable, OnModuleInit } from '@nestjs/common';
import type { Redis } from 'ioredis';
import { RedisService } from '../redis/redis.service';
import { ProgressGateway } from './progress.gateway';
interface ProgressEvent {
readonly userId: string;
readonly jobId: string;
readonly percent: number;
}
interface DoneEvent {
readonly userId: string;
readonly jobId: string;
}
@Injectable()
export class ProgressSubscriber implements OnModuleInit {
private readonly subscriber: Redis;
constructor(
private readonly redis: RedisService,
private readonly gateway: ProgressGateway,
) {
// MOT ket noi Redis RIENG chi de dang ky nghe. Ket noi dang o che do
// subscribe khong chay duoc lenh thuong nao khac.
this.subscriber = this.redis.duplicate();
}
onModuleInit(): void {
// Dang ky ca hai kenh — tham so channel cua callback tu phan biet duoc
// thong diep nao thuoc kenh nao, khong can hai handler rieng.
this.subscriber.subscribe('progress', 'job:done');
this.subscriber.on('message', (channel: string, raw: string) => {
if (channel === 'job:done') {
const event = JSON.parse(raw) as DoneEvent;
this.gateway.emitDone(event.userId, event.jobId);
return;
}
const event = JSON.parse(raw) as ProgressEvent;
// Tien trinh nao dang giu ket noi cua nguoi do thi gui duoc.
// Cac tien trinh con lai goi to() vao mot phong rong — khong sao ca.
this.gateway.emitProgress(event.userId, event.jobId, event.percent);
});
}
}
redis.duplicate() — nơi kết nối riêng thật sự được tạothis.subscriber ở trên chưa từng được gán giá trị nếu chỉ nhìn đoạn
onModuleInit. RedisService kế thừa từ ioredis nên có sẵn
.duplicate() — tạo một kết nối mới cùng cấu hình, đúng thứ cần cho một kênh luôn ở chế độ
subscribe.
Cả tám tiến trình đều nhận được thông điệp, nhưng chỉ tiến trình đang thật sự giữ kết nối của người dùng đó mới gửi được gì. Bảy tiến trình còn lại phát vào một phòng trống — vô hại, và đó chính là điều làm cho mô hình này đơn giản đến mức đáng tin.
Hai class trên vẫn cần một module khai chúng ra, và nó vướng đúng cái bẫy đã gặp ở Part 2 mục 6:
ProgressGateway tiêm JwtService, nên module phải import AuthModule.
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { ProgressGateway } from './progress.gateway';
import { ProgressSubscriber } from './progress.subscriber';
@Module({
// AuthModule vi ProgressGateway tiem JwtService de xac thuc bat tay —
// cung ly do nhu MediaModule o Part 2 muc 6.
imports: [AuthModule],
providers: [ProgressGateway, ProgressSubscriber],
})
export class GatewayModule {}
Thêm GatewayModule vào imports của AppModule — lưu ý là của
AppModule chứ không phải WorkerModule: worker chỉ phát ra Redis, nó không giữ
kết nối WebSocket nào.
>>> WS da ket noi
>>> transcode 202 {"jobId":"f919b4e9-a6d5-4473-a048-d95623e8bb06"}
>>> job:progress 22
>>> job:progress 47
>>> job:progress 70
>>> job:progress 94
>>> job:progress 99
>>> job:done f919b4e9-a6d5-4473-a048-d95623e8bb06
Đường đi của mỗi con số đó dài hơn vẻ ngoài của nó: ffmpeg in ra stderr trong tiến trình
worker → transcode() đọc bằng readline (mục 2.2) →
redis.publish('progress', ...) → tiến trình API nghe qua kết nối duplicate() →
server.to('user:...') → trình duyệt. Hai tiến trình hoàn toàn tách biệt, nối với nhau bằng
đúng một kênh Redis — và 99 rồi mới tới job:done đúng như nguyên tắc "phần trăm
là ước lượng, mã thoát là sự thật" ở mục 2.2.
6.1. nginx và cái bắt tay nâng cấp giao thức
WebSocket bắt đầu bằng một request HTTP thường có header Upgrade. nginx mặc định dùng
HTTP/1.0 cho upstream và cắt bỏ các header liên quan đến kết nối — nên nếu không cấu hình, WebSocket sẽ
không bao giờ bắt tay xong.
location /ws {
proxy_pass http://app;
proxy_http_version 1.1; # bat buoc: 1.0 khong nang cap duoc
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
# Mac dinh 60s. Ket noi im lang qua 60s se bi cat — ma WebSocket thi
# im lang la binh thuong.
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
least_conn, chúng bị rải ra khắp nơi
và kết nối thất bại một cách rất khó hiểu — thường biểu hiện là client cứ kết nối rồi rớt thành vòng
lặp. Thuật toán cân tải là thuộc tính của cả khối
upstream, không thể khác nhau theo từng
location dùng chung một khối. Hai cách chữa: tách một khối upstream riêng chỉ
để /ws dùng — upstream app_ws {
ip_hash;
server app1:3000;
server app2:3000;
server app3:3000;
}
— rồi đổi
proxy_pass trong location /ws sang http://app_ws (khối
app còn lại ở mục 5.1 giữ nguyên least_conn cho các request thường), hoặc ép
client dùng thẳng WebSocket: io(url, { transports: ['websocket'] }) — bỏ hẳn giai đoạn
polling và bỏ luôn vấn đề.
6.2. Mất kết nối và những gì bỏ lỡ
WebSocket sẽ rớt: wifi chuyển sang 4G, máy tính ngủ, nginx khởi động lại. Kết nối lại thì tự động, nhưng những sự kiện phát ra trong lúc mất kết nối thì đã trôi qua — pub/sub không lưu gì cả.
Cách chữa không nằm ở WebSocket mà ở chỗ khác: coi kênh realtime là thứ tăng tốc, không phải
nguồn sự thật. Trạng thái thật luôn nằm trong bảng jobs.
api() gọi dưới đây là hàm tự làm mới token đã viết ở Part 2, mục 8.1 — mọi request qua
GET /jobs/active đi kèm access token mới nhất mà không cần lặp lại logic refresh ở đây.
import { api } from './api';
socket.on('connect', async () => {
// Moi lan noi lai — ke ca lan dau — hoi mot phat trang thai hien tai.
// Sau do WebSocket lo phan cap nhat.
const res = await api('/jobs/active');
const jobs = (await res.json()) as JobState[];
setJobs(jobs);
});
socket.on('job:progress', (event: { jobId: string; percent: number }) => {
setJobs((prev) =>
prev.map((job) => (job.id === event.jobId ? { ...job, percent: event.percent } : job)),
);
});
Bốn dòng trong connect khiến toàn bộ bài toán "bỏ lỡ sự kiện" biến mất. Mất mạng bao lâu cũng
được: nối lại là đồng bộ lại từ đầu, rồi chạy tiếp bằng sự kiện.
7. Mốc #3: bảng tiến độ chạy theo thời gian thực
import { useEffect, useState } from 'react';
import { io, type Socket } from 'socket.io-client';
import { api } from '../lib/api';
export interface JobState {
readonly id: string;
readonly videoTitle: string;
readonly status: 'queued' | 'processing' | 'completed' | 'failed';
readonly percent: number;
}
export function useJobProgress(accessToken: string): readonly JobState[] {
const [jobs, setJobs] = useState<readonly JobState[]>([]);
useEffect(() => {
const socket: Socket = io('/', {
path: '/ws',
transports: ['websocket'], // bo qua long-polling, xem canh bao o muc 6.1
auth: { token: accessToken },
});
const sync = (): void => {
void api('/jobs/active')
.then((res) => res.json() as Promise<JobState[]>)
.then(setJobs);
};
socket.on('connect', sync);
socket.on('job:progress', ({ jobId, percent }: { jobId: string; percent: number }) => {
setJobs((prev) => {
// Job MOI, tao sau luc connect: chua co trong danh sach nen map() se
// khong khop gi ca. Phai hoi lai /jobs/active de lay ten video, neu
// khong no VO HINH cho toi khi tai lai trang.
if (!prev.some((job) => job.id === jobId)) {
sync();
return prev;
}
return prev.map((job) => (job.id === jobId ? { ...job, percent, status: 'processing' } : job));
});
});
socket.on('job:done', ({ jobId }: { jobId: string }) => {
setJobs((prev) =>
prev.map((job) => (job.id === jobId ? { ...job, percent: 100, status: 'completed' } : job)),
);
});
// Dong ket noi khi component bi go bo. Quen dong nay thi moi lan
// dieu huong lai mo them mot socket nua.
return () => {
socket.disconnect();
};
}, [accessToken]);
return jobs;
}
import type { JSX } from 'react';
import { useJobProgress } from '../hooks/useJobProgress';
export function JobList({ token }: { token: string }): JSX.Element {
const jobs = useJobProgress(token);
return (
<ul className="space-y-3">
{jobs.map((job) => (
<li key={job.id} className="rounded-lg border border-slate-200 p-4">
<div className="flex justify-between text-sm">
<span className="font-medium">{job.videoTitle}</span>
<span className="text-slate-500">{job.percent}%</span>
</div>
<div className="mt-2 h-2 overflow-hidden rounded-full bg-slate-200">
<div
className="h-full bg-emerald-500 transition-[width] duration-300"
style={{ width: `${job.percent}%` }}
/>
</div>
</li>
))}
</ul>
);
}
/jobs/active đúng một lần lúc connect, còn hai handler kia đều là
prev.map(...) — gặp một jobId chưa có trong danh sách thì chúng
im lặng không làm gì. Hệ quả đúng vào kịch bản dùng chính của mốc này: upload xong, bấm
chuyển mã, worker chạy và ghi completed vào database — mà bảng
không nhúc nhích, phải tải lại trang job mới hiện ra. Vài dòng
if (!prev.some(...)) sync() ở trên là để đóng đúng lỗ hổng đó.
/ws và /jobsio('/', { path: '/ws' }) và api('/jobs/active') đều là đường dẫn tương đối,
tức là chúng đi tới dev server của Vite trước. Thiếu cấu hình thì hỏng theo hai kiểu đều
im lặng: WebSocket báo
WebSocket connection to 'ws://localhost:5173/ws/...' failed, còn
/jobs/active trả về 200 kèm nguyên trang index.html (SPA
fallback) thay vì JSON — bảng chỉ hiện ra rỗng, không có lỗi nào đỏ lên.Bổ sung vào
vite.config.ts đã dựng ở Part 2 mục 8.2:'/jobs': gateway, // JobController khai @Controller() khong tien to
'/ws': { ...gateway, ws: true },
Cuối cùng, ráp JobList vào màn hình đã dựng ở Part 2 mục 8.2 — truyền token lấy từ
getAccessToken(), và thêm một nút gọi POST /media/:videoId/transcode để có cách
tạo job từ giao diện:
import { api, getAccessToken } from '../lib/api';
import { JobList } from './JobList';
// ...trong component, sau khi upload xong:
async function startTranscode(id: string): Promise<void> {
await api(`/media/${id}/transcode`, { method: 'POST' });
}
const token = getAccessToken();
// ...trong phan JSX:
{videoId !== null && (
<button type="button" onClick={() => void startTranscode(videoId)}
className="rounded bg-emerald-600 px-3 py-2 text-sm text-white">
Chuyển mã 720p
</button>
)}
{token !== null && (
<div className="space-y-2">
<h2 className="text-sm font-semibold text-slate-700">Tiến độ chuyển mã</h2>
<JobList token={token} />
</div>
)}
Giờ mốc #3 mới thật sự nhìn thấy được: chọn một file, bấm Chuyển mã 720p, và một dòng mới xuất hiện trong bảng rồi chạy tới 100% — không cần chạm vào nút tải lại trang.
transition-[width] duration-300 làm thanh tiến trình trượt mượt giữa hai lần cập nhật. Không
có nó, tiến độ nhảy giật từng nấc mỗi khi ffmpeg báo về, và cảm giác là hệ thống đang lag chứ không phải
đang chạy.
child_process — ở một tiến trình của hệ điều hành. worker_threads — ở một
luồng khác trong cùng tiến trình. cluster — ở một bản sao của chính ứng dụng. Hàng đợi — ở
một thời điểm khác, tách hẳn khỏi request. Chọn sai công cụ thì hoặc trả giá vô ích (đẻ tiến trình cho một việc 5 ms), hoặc chẳng giải quyết được gì (đưa việc nặng CPU sang một hàm
async — nó vẫn chạy trên chính luồng đó).
Part 4 tách nó ra thành các dịch vụ riêng nối với nhau bằng gRPC, và nhìn thẳng vào cái giá phải trả: transaction của Part 1 vỡ ngay khi hai thao tác rơi vào hai dịch vụ khác nhau. Chỗ đó là nơi outbox pattern lần đầu tiên có nghĩa thật, thay vì là một sơ đồ đẹp trong sách.
Bình luận