1 Node process — 9 SQS consumer chia event loop
PM2 chỉ chạy 1 instance, giới hạn 1GB RAM. Tất cả module dưới đây share CPU + memory + DB connection pool.
Tên gọi "video-service" gây hiểu lầm: thực chất là "video event + revenue calculation + notification fanout" hỗn hợp. Hot-path xem video (top row, 3 module) chạy rate gấp hàng nghìn lần publish — đây mới là nguồn CPU thường trực, publish chỉ là giọt nước tràn ly.
7 điểm "render-ẩn" đốt CPU trong service "I/O"
Không có ffmpeg/sharp, nhưng vẫn có CPU work dạng dedupe, template render, SQL CASE build, APM stack capture.
O(n²) dedupe
khi gom followers + subscribers
getListMemberSendNotification dùng
.reduce + .includes để dedupe. Mỗi push
quét lại toàn bộ array đã build → quadratic. Block event loop sync nhiều giây.
common/modules/notification/service.ts:4043
| Pool size | Số phép so sánh | Block event loop |
|---|---|---|
| 1k | ~500k | ~10ms |
| 10k | ~50M | ~500ms |
| 50k | ~1.25B | ~10s |
| 100k | ~5B | ~30s+ |
Array.from(new Set([...])) → O(n)Render N email HTML strings
ngay trong publish service
Loop subscribers → mỗi người 1 lần format() template + JSON.stringify body
~2-5KB cho SQS. Multiplicative với pool size. Đây chính là "render" mà user muốn loại khỏi service.
apps/video-service/src/video-publishing/service.ts:648-674
sendMailQueue.add()
chunk 20 concurrent trong loop
await Promise.all(chunk(20)...) serialize từng batch.
Mỗi .add() = JSON.stringify + APM span + SQS API call.
I/O nhiều nhưng vẫn có CPU + GC pressure.
apps/video-service/src/video-publishing/service.ts:648
video-seen-time SQL CASE loop
Loop từ paidBlockIndex+1 đến currentBlockIndex
build SQL CASE strings, nested partner forEach per block, transaction multi-step.
video-seen-time/service.ts (743 LOC)
.map(async) không await
items.map(async i => await queue.add(...)) spawn promise nhưng không await →
handleMessage trả về sớm, batch SQS bị delete trước khi work xong → message thất lạc.
member-ranking-when-view-video/service.ts:50
Promise.all([arr.map]) wrap-array
Promise.all([arr.map(...)]) wrap mảng trong mảng → resolved ngay
vì element là array (đã resolved). Inner queries không await → tx commit trước → leak query runner.
common/modules/video/service.ts (updateVideoRanking, 8 actions)
APM full-trace bật trên UAT
captureSpanStackTraces: true + captureBody: 'all' +
captureHeaders: true. V8 stack walk per span × N message/giây = đốt CPU.
apps/video-service/src/main.ts:24-37
9 consumer share 1 event loop
PM2 instances: 1 + 9 SQS module registered cùng module.ts → không thể autoscale chọn lọc.
Spike 1 module = autoscale toàn bộ service.
ecosystem.config.js · apps/video-service/src/module.ts
Khi creator nhấn 公開: bên trong publishingService
10 bước xử lý sequential. Bước có ⚠ là CPU/memory hotspot.
lrc-publishing-video{videoId}3 cơ chế domino kéo cả service chết
Khi publish + hot-path xem video gặp nhau trong 1 process.
Event loop bị nuốt
SQS handler chạy sequential trong batch. 1 msg seen-time chạy lâu → publish msg vừa đến phải xếp hàng → visibility timeout hết → SQS retry → fanout duplicate.
DB connection pool starvation
Hot-path dùng tx multi-step + bug Promise.all wrap-array leak query runner → giảm pool effective. Publish gọi 3-tier Promise.all (find video + partners + categories) phải chờ connection → timeout.
Memory spike → PM2 kill
Publish load 100k member IDs + spawn N email body strings (~300MB) → đụng max_memory_restart: '1G' → PM2 kill cả Node process → cả 9 consumer chết, bao gồm hot-path đang serve user xem video.
Module concurrent với publish × mức nguy hiểm
"Khi publish đang fanout, ai dễ gây chết?"
| Module concurrent | Risk | Lý do |
|---|---|---|
| video-current-time | 🔴 CAO | Rate cao nhất — heartbeat ~5s per active viewer. Mỗi user online sinh dòng message liên tục → spam event loop. |
| video-seen-time | 🔴 CAO | CPU/transaction nặng nhất per message. Hot path xem video. SQL CASE build trong loop + nested partner forEach. |
| video-view (updateVideoRanking) | 🟡 MEDIUM | BUG Promise.all([arr.map]) → tx hang/leak → DB pool drain. |
| video-reaction | 🟡 MEDIUM | Notification combination merge + format() template. Spike khi video viral. |
| member-ranking-when-view-video | 🟡 MEDIUM | BUG .map(async) không await → unbounded concurrent SQS calls. |
| video-comment | 🟢 LOW-MED | Rate vừa phải. Notification render + push. |
| video-statistic | 🟢 LOW | Chỉ ADD_GIFTS action, không phải hot path. |
| s3-video-deletion | 🟢 LOW | Chạy lúc admin xoá / cron giờ nhàn. I/O thuần. |
Tách 1 process thành 5 service riêng
Autoscale chọn lọc, isolated failure, max_memory match workload thật.
1 process · 9 consumer
- ✗1 instance, 1GB RAM, 9 consumer share event loop
- ✗Spike 1 module → autoscale toàn bộ
- ✗OOM → cả 9 consumer chết theo
- ✗Hot-path xem video block publish
- ✗Khó tune visibility timeout / batch size per concern
5 process · isolated
- ✓Mỗi service set RAM + concurrency match workload
- ✓Autoscale per queue backlog
- ✓OOM 1 service không kéo theo service khác
- ✓Hot-path xem video chạy ổn định, không bị publish burst phá
- ✓Code ownership rõ ràng theo concern
video-realtime-service
Hot path xem video — rate cao, autoscale theo viewer online.
video-publishing-service
Burst pattern — 1 publish có thể fanout 100k. Tách để autoscale theo fanout backlog, không phá hot-path.
video-engagement-service
Per-user action — notification + email per react/comment. Rate vừa, spike khi video viral.
revenue-service (mở rộng)
Logic tính money đã có service riêng — gộp member-ranking vào đây cho consistency.
media-storage-service
I/O thuần S3, idle hầu hết thời gian. Có thể chạy instance nhỏ.
Quick wins → Medium → Long-term
Effort × ROI — ưu tiên fix ROI cao trước.
Quick wins — 4 fix trong 1-2 ngày
Effort: 1-2 ngày ROI: ~80% giảm spike- ▸ Fix O(n²) dedupe — 1 dòng
Array.from(new Set(...)) - ▸ Tắt APM
captureSpanStackTraces,captureBody: 'all'trên PRD (env-conditional) - ▸ Fix
.map(async)trong member-ranking-when-view-video - ▸ Fix
Promise.all([arr.map])wrap-array trong VideoService.updateVideoRanking (8 actions)
Medium — tách hot-path service
Effort: 1 sprint ROI: Isolated failure
Tách video-realtime-service (4 module xem video) ra PM2 instance riêng. Giữ publishing + engagement + s3 ở video-service hiện tại. Hot-path không còn chia event loop với fanout — giải quyết 80% case rớt UAT.
Long-term — 5 service tách + fanout worker
Effort: 2-3 sprint ROI: Architecture clean
Hoàn thiện chia 5 service. Thêm dedicated lrc-public-video-fanout queue: publish service chỉ enqueue 1 message
{videoId}, fanout worker stream subscribers từ DB cursor (không load all). Move email HTML render sang send-mail worker (SQS payload nhỏ chỉ giữ templateId + IDs).
Move member-ranking sang revenue-service.
Files verified trên branch origin/uat
- 📄apps/video-service/src/main.ts — APM init
- 📄apps/video-service/src/module.ts — 9 modules registered
- 📄apps/video-service/src/video-publishing/service.ts — publish flow (690 LOC)
- 📄apps/video-service/src/video-seen-time/service.ts — hot path point calc (743 LOC)
- 📄apps/video-service/src/video-current-time/service.ts — heartbeat handler
- 📄apps/video-service/src/video-view/service.ts — delegate to VideoService.updateVideoRanking
- 📄apps/video-service/src/member-ranking-when-view-video/service.ts:50 — ⚠ .map(async) bug
- 📄common/modules/notification/service.ts:4043 — ⚠ getListMemberSendNotification O(n²)
- 📄common/modules/video/service.ts — ⚠ Promise.all([arr.map]) bug (8 cases)
- 📄common/queue/sqs-base.consumer.ts — batchSize 10, sequential msg handle
- 📄common/queue/sqs-base.producer.ts — sendEachMessage: JSON.stringify + APM span
- 📄common/queue/constant.ts — ConsumerDefaultOption: visibility 300s, polling 1s
- 📄ecosystem.config.js — PM2: instances=1, max_memory=1G