AMELA · LOVEREC ·Architecture & Risk Analysis
🇻🇳 Tiếng Việt
🔴 CPU 100% RISK worker/apps/video-service branch: origin/uat 2026-05-21

video-service
9 consumer · 1 process · 1 GB

Tất cả 9 SQS consumer của video-service đang chia chung 1 Node process (PM2 instances=1, max_memory=1G). Khi 1 module spike — đặc biệt là hot-path xem video — toàn bộ event loop bị nuốt, kéo theo publish flow chết theo. Trên UAT đã xuất hiện hiện tượng CPU 100% tại 1 thời điểm; nếu lên PRD sẽ autoscale toàn bộ service không cần thiết và lãng phí.

TÓM TẮT
  • 0 code render media — service là I/O thuần
  • 7 CPU hotspot ẩn (dedupe O(n²), SQL CASE loop, .map(async) bug)
  • 3 module hot-path xem video nuốt event loop của publish
  • 1 dòng fix dedupe = giảm 100× CPU spike publish
  • Đề xuất tách 5 service riêng → autoscale chọn lọc
01 | Hiện trạng

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.

🟦
video-service
PM2: instances=1 · max_memory_restart=1G · Node.js 22.15
🔴 Hot path 🟠 Burst 🟡 Medium 🟢 Low
video-current-time 🔥 Rất cao
lrc-video-current-time-update
Heartbeat ~5s/viewer · upsert LastView
CPU light × rate cực cao
video-seen-time 🔥 Cao
lrc-video-time-seen-update
User qua mốc 30s · point split, revenue share, tx multi-step
CPU HIGH per msg (743 LOC)
video-view 🔥 Cao
lrc-create-video-view-notification
Mỗi view 1 msg · INSERT ON DUPLICATE ranking tables
⚠ BUG Promise.all([arr.map])
video-publishing 🟠 Burst
lrc-publishing-video
Creator nhấn 公開 · fanout email/noti tới N follower
CPU HIGH khi pool lớn (O(n²))
video-reaction 🟡 Medium
lrc-video-reaction-notification
Heart/star · combine reactor + format() template
Cao khi video viral
video-comment 🟡 Medium
lrc-video-comment-notification
Comment · processNotificationWithSqs render noti
Rate vừa phải
member-ranking-when-view 🟡 Medium
lrc-update-member-ranking-when-view-video
Sau seen-time · convert point→money, fanout
⚠ BUG .map(async) không await
video-statistic 🟢 Thấp-Med
lrc-video-statistic-update-notification
ADD_GIFTS · build SQL CASE batch update
Light
s3-video-deletion 🟢 Thấp
lrc-s3-video-deletion
Admin/cron · S3 delete (origin/sample/thumb/converted)
I/O thuần
⚠ Insight

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.

02 | CPU Hotspots

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.

TIER 1 · TRÊN PUBLISH FLOW
CRITICAL
01

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 sizeSố phép so sánhBlock event loop
1k~500k~10ms
10k~50M~500ms
50k~1.25B~10s
100k~5B~30s+
=> Fix 1 dòng: Array.from(new Set([...])) → O(n)
HIGH
02

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

publish svc ×N format() HTML format() HTML format() HTML format() HTML SQS lrc-send-mail N message ~3KB body
=> 100k subscriber × 3KB = 300MB JSON strings trong RAM
MEDIUM
03

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

for (const memberBatch of chunk(membersToSendEmail, 20)) { await Promise.all(memberBatch.map((subscriber) => this.sendMailQueue.add({ receiver: subscriber.email, content: format(publicVideoForSubscribers.content, {...}), // ← CPU ... }), )); }
TIER 2 · SISTER MODULES CÙNG PROCESS
HOT
04

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)

BUG
05

.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

BUG
06

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)

TIER 3 · HẠ TẦNG
INFRA
07

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

INFRA
08

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

03 | Flow publish chi tiết

Khi creator nhấn 公開: bên trong publishingService

10 bước xử lý sequential. Bước có ⚠ là CPU/memory hotspot.

1
Backend API → SQS lrc-publishing-video
Creator nhấn 公開 / cron publicTime đến → enqueue message {videoId}
2
Promise.all 3 query: Video + VideoPartner + VideoCategory
findOne Video (15 fields) + find VideoPartner + find VideoCategory cho videoId
3
Memory Story logic
Count video memory + count group + create VideoGroupName / VideoGroup nếu cần (logic mới)
4
Promise.all: setStories cache + 3 statistic update
setStories (Redis hSet) · updateMemberStatistic · updateMemberCategoryStatistics · delete cache
5
Promise.all: partners + owner + categories
Member.find(partnerIds) + findOne owner + Category.createQueryBuilder
6
Owner notification: push + email render + send
pushNotificationQueue.add (1) + format(publicVideoForOwner) + sendMailQueue.add (1)
7
Partners forEach: push + email render + send per partner
Mỗi partner: 1 push + 1 format() render + 1 sendMail
8
⚠ getListMemberSendNotification(ownerId) — O(n²) DEDUPE
find ALL followers + find ALL subscribers → reduce với .includes() → 100k member = 5B ops = block event loop ~30s
9
⚠ chunk(500) → query MemberNotificationSetting per chunk
Filter SUBSCRIBE vs FOLLOWING → thêm 2 nested query MemberSubscribe / MemberFollow per chunk
10
⚠ Fanout: chunk(500) → Member.find → chunk(20) → format() + sendMail per subscriber
N subscriber × format() template × 3KB body × JSON.stringify × SQS API call → CPU + memory spike
04 | Cascade failure

3 cơ chế domino kéo cả service chết

Khi publish + hot-path xem video gặp nhau trong 1 process.

CƠ CHẾ 1

Event loop bị nuốt

hot-path msg SQL CASE point split ⏰ publish msg chờ SQS visibility timeout (300s) → retry duplicate fanout!

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.

CƠ CHẾ 2

DB connection pool starvation

TypeORM pool (~10 conn) ↑ hot-path tx multi-step + Promise.all([arr.map]) leak publish Promise.all chờ connection → timeout

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.

CƠ CHẾ 3

Memory spike → PM2 kill

RAM usage (1GB limit) hot-path getListMember (100k IDs) N email JSON ⚠ chạm ngưỡng 1G PM2 kill toàn process

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.

05 | Risk matrix

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.
06 | Đề xuất

Tách 1 process thành 5 service riêng

Autoscale chọn lọc, isolated failure, max_memory match workload thật.

❌ BEFORE

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
✅ AFTER

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
🔴 HOT PATH

video-realtime-service

Hot path xem video — rate cao, autoscale theo viewer online.

▸ video-view
▸ video-current-time
▸ video-seen-time
▸ video-statistic
🟠 BURST

video-publishing-service

Burst pattern — 1 publish có thể fanout 100k. Tách để autoscale theo fanout backlog, không phá hot-path.

▸ video-publishing (split: light I/O ở đây)
▸ +new: lrc-public-video-fanout (heavy fanout)
🟡 ENGAGEMENT

video-engagement-service

Per-user action — notification + email per react/comment. Rate vừa, spike khi video viral.

▸ video-reaction
▸ video-comment
🟣 REVENUE

revenue-service (mở rộng)

Logic tính money đã có service riêng — gộp member-ranking vào đây cho consistency.

▸ member-ranking-when-view-video (move từ video)
▸ + existing revenue-service modules
🟢 STORAGE

media-storage-service

I/O thuần S3, idle hầu hết thời gian. Có thể chạy instance nhỏ.

▸ s3-video-deletion
07 | Lộ trình

Quick wins → Medium → Long-term

Effort × ROI — ưu tiên fix ROI cao trước.

1

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)
2

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.

3

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.

08 | References

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