Vấn đề thực tế: Khi cái ‘setInterval’ âm thầm giết chết Database
Đúng 2 giờ sáng, điện thoại mình rung bần bật. Cảnh báo từ Prometheus đổ về liên tục: CPU của Database RDS đang duy trì ở mức 98-100%. User bắt đầu ‘réo’ tên admin trên group cộng đồng vì thông báo nhảy chậm, hoặc tệ hơn là mất hút. Sau khi soi log, mình phát hiện ra thủ phạm cực kỳ quen thuộc: HTTP Polling.
Để triển khai tính năng ‘ting ting’ mỗi khi có người like hay comment, team dev trước đó đã chọn giải pháp ‘mì ăn liền’: cho Frontend cứ 5 giây lại gọi API GET /notifications một lần. Với 500 user thì hệ thống vẫn ‘cười khẩy’. Nhưng khi chạm mốc 10.000 user online cùng lúc, Database phải gánh hơn 120.000 request mỗi phút chỉ để check xem có dữ liệu mới hay không. Đây là một thảm họa về hiệu năng mà bất cứ ai làm hệ thống lớn cũng sẽ gặp phải ít nhất một lần.
Tại sao Polling không phải là câu trả lời cho bài toán Scale?
Về cơ bản, HTTP là giao thức request-response truyền thống. Client hỏi, Server mới trả lời. Trong bài toán thông báo real-time, việc ‘hỏi thăm’ liên tục gây ra ba vấn đề nhức nhối:
- Cực kỳ lãng phí: 99% request chỉ để nhận về một mảng
[]trống rỗng. Tuy nhiên, Server vẫn phải tốn công xác thực, truy vấn DB và đóng gói JSON. - Độ trễ khó chịu: Nếu bạn set polling 10 giây, user có thể phải đợi tới đúng 10 giây đó mới thấy thông báo hiện ra.
- Ngốn băng thông: Header của HTTP request tuy nhỏ nhưng khi nhân với hàng triệu lượt gọi, con số này sẽ làm hóa đơn Cloud của bạn tăng vọt.
Mình nhận ra hệ thống cần một cơ chế ‘Push’ chủ động. Server phải là bên lên tiếng ngay khi có biến động xảy ra.
Những hướng đi mình từng cân nhắc
Lúc đó, mình nhanh chóng lướt qua các phương án khả thi nhất:
1. WebSockets (Socket.io) thuần túy
Mở một đường ống kết nối song công (full-duplex) giữa client và server. Khi có tin mới, server chỉ cần gọi socket.emit() là xong. Cách này cực nhanh nhưng lại vướng một rào cản: Khó mở rộng theo chiều ngang (Horizontal Scaling).
Giả sử mình chạy 2 instance Node.js (Server A và Server B) sau Load Balancer. User 1 kết nối vào Server A, nhưng logic xử lý thông báo lại nằm ở Server B. Lúc này Server B không cách nào tìm thấy User 1 để mà gửi socket. Kết quả là thông báo ‘đi lạc’ hoàn toàn.
2. Server-Sent Events (SSE)
Giải pháp này nhẹ hơn WebSocket vì chỉ truyền một chiều từ Server xuống Client. Tuy nhiên, nó vẫn ‘bó tay’ trước vấn đề chia sẻ trạng thái giữa các instance trong một Cluster.
Cứu tinh xuất hiện: Node.js + Socket.io + Redis Pub/Sub
Để giải quyết bài toán scale ngang, mình cần một ‘người đưa thư’ trung gian mà mọi instance Node.js đều có thể nghe và nói. Redis Pub/Sub (Publish/Subscribe) chính là mảnh ghép hoàn hảo. Quy trình vận hành sẽ như sau:
- User kết nối Socket.io vào bất kỳ instance nào (A, B hoặc C) tùy theo Load Balancer điều phối.
- Khi có sự kiện mới, Backend sẽ Publish một thông điệp vào một ‘channel’ chung trên Redis.
- Tất cả các instance Node.js đang Subscribe channel đó sẽ nhận được thông điệp cùng lúc.
- Instance nào đang trực tiếp giữ kết nối với User mục tiêu sẽ thực hiện
emitdữ liệu xuống trình duyệt.
Nhờ cơ chế này, hệ thống chạy mượt mà dù bạn có đang chạy 2 hay 200 server Node.js đi chăng nữa.
Triển khai thực tế: Từ ý tưởng đến dòng code
Trước hết, bạn cần một server Redis. Cách nhanh nhất là dùng Docker để lên môi trường trong 30 giây:
docker run -d --name redis-notify -p 6379:6379 redis
Kế đến, hãy khởi tạo dự án Node.js và cài đặt các thư viện tiêu chuẩn. Ở đây mình dùng ioredis vì tính ổn định và hỗ trợ Cluster cực tốt:
npm install express socket.io ioredis
Đây là file server.js rút gọn mình đã dùng để ‘chữa cháy’ đêm đó:
const express = require('express');
const http = require('http');
const { Server } = require('socket.io');
const Redis = require('ioredis');
const app = express();
const server = http.createServer(app);
const io = new Server(server);
// Kết nối Redis: Một để bắn, một để nghe
const redisPub = new Redis();
const redisSub = new Redis();
// Đăng ký nghe channel 'notifications'
redisSub.subscribe('notifications', (err) => {
if (err) console.error('Kết nối Redis thất bại:', err);
});
// Nhận tin từ Redis và đẩy xuống đúng Client
redisSub.on('message', (channel, message) => {
if (channel === 'notifications') {
const data = JSON.parse(message);
// Gửi thông báo đến Room riêng của User
io.to(`user:${data.userId}`).emit('new_notification', data.content);
}
});
io.on('connection', (socket) => {
const userId = socket.handshake.query.userId;
if (userId) {
socket.join(`user:${userId}`);
console.log(`User ${userId} đã online.`);
}
});
// Endpoint giả lập sự kiện từ Backend khác
app.get('/test-notify', (req, res) => {
const { userId, message } = req.query;
const payload = { userId, content: message, time: Date.now() };
redisPub.publish('notifications', JSON.stringify(payload));
res.send('Đã đẩy thông báo lên Redis!');
});
server.listen(3000, () => console.log('Hệ thống sẵn sàng tại port 3000'));
Trong quá trình xử lý dữ liệu phức tạp giữa các service, mình thường dùng toolcraft.app/vi/tools/developer/json-formatter để kiểm tra cấu trúc JSON nhanh. Nó giúp mình phát hiện ngay các lỗi thiếu field hay sai kiểu dữ liệu mà không cần cài thêm extension nặng nề hay gõ lệnh terminal rắc rối.
Kết quả: Tại sao cách này lại hiệu quả đến vậy?
Sau khi deploy, biểu đồ CPU của Database giảm một mạch từ 100% xuống còn loanh quanh 5-7%. Chúng ta đã đạt được ba mục tiêu lớn:
- Khai tử Polling: Database không còn phải trả lời hàng triệu câu hỏi ‘Vô tri’ mỗi phút.
- Tối ưu tài nguyên: Code chỉ chạy khi thực sự có dữ liệu mới được đẩy vào Redis.
- Kiến trúc linh hoạt (Loose Coupling): Hệ thống thông báo giờ đây tách biệt hoàn toàn. Bạn có thể dùng Python, Go hay PHP để publish vào Redis, Node.js vẫn sẽ nhận và đẩy socket như thường.
Nếu bạn đang đau đầu vì hệ thống bị chậm, đừng vội nâng cấp cấu hình server tốn kém. Đôi khi chỉ cần thay đổi cách tư duy về giao tiếp dữ liệu với Redis và Socket.io là bạn đã có thể yên tâm ngủ ngon mà không lo điện thoại réo giữa đêm rồi.

