Cách tôi bắt tay vào tối ưu một hệ thống backend
Nếu ai đó đưa cho bạn một hệ thống backend đang chạy, và yêu cầu bạn tối ưu hoá nó bạn sẽ làm như thế nào? Các bước thực hiện ra sao? Dưới đây là cách làm của tôi.
Tổng quan các bước mà tôi áp dụng:
-
Đo lường các thông số của hệ thống hiện tại
→ Mục tiêu là hiểu rõ hệ thống đang hoạt động như thế nào, thông số nào đang ở mức giới hạn. -
Xác định nút thắt
→ Tìm ra điểm nghẽn trong hệ thống: tầng phần cứng, tầng ứng dụng, tầng database. -
Giải quyết nút thắt bằng hai nguyên lý:
- Tăng khả năng xử lý ở chỗ đang xảy ra tắc nghẽn.
- Giảm thiểu áp lực lên nút nghẽn.
Ví dụ: Tôi muốn giải quyết tình trạng tắc đường?
Giả sử tôi là một kỹ sư giao thông, và thành phố đang gặp vấn đề kẹt xe nghiêm trọng.
-
Đo lường tình trạng hiện tại
Đầu tiên, tôi thu thập dữ liệu về số lượng xe di chuyển mỗi giờ, thời gian chờ đèn đỏ, mật độ xe tại các giao lộ… -
Xác định nút thắt
Dữ liệu cho thấy một ngã tư nào đó đang bị quá tải vào giờ cao điểm, dẫn đến kẹt xe kéo dài. -
Giải quyết nút thắt
-
Tăng khả năng xử lý:
- Mở rộng thêm làn đường, tăng số làn xe. Xây cầu vượt hoặc hầm chui (cách này hơi tốn tiền 😂, cũng giống như ở hệ thống backend bạn tăng cấu hình server, chạy nhiều instance song song ...).
-
Giảm áp lực lên nút thắt:
- Phân luồng giao thông, điều chỉnh đèn giao thông để tối ưu luồng xe.
- Khuyến khích xử dụng các phương tiện công cộng : đây chính là batch processing =)) Ở cuối bài viết này tôi có một ví dụ về kỹ thuật này.
- Ở tầng vĩ mô hơn: Triển khai các chính sách giãn dân, di dời khu công nghiệp, trường đại học... ra khỏi trung tâm
Tôi luôn bắt đầu việc tối ưu hóa bằng cách đặt ra một câu hỏi đơn giản: "Hệ thống của tôi đang gặp vấn đề ở đâu?" Nếu không biết vấn đề nằm ở đâu, mọi nỗ lực tối ưu hóa chỉ như mò mẫm trong bóng tối.
Trước tiên, tôi xác định các thông số quan trọng:
- Thông lượng (throughput): Hệ thống có thể xử lý bao nhiêu request trong một giây.
- Thời gian phản hồi (latency): Thời gian từ khi request gửi đi đến khi nhận được kết quả.
- Mức tài nguyên tiêu thụ: CPU, RAM, ổ đĩa, băng thông mạng...
- Số lượng kết nối đồng thời: Hệ thống có thể duy trì bao nhiêu kết nối cùng lúc mà không gặp sự cố.
Công cụ đo lường thông số
- Thông lượng & thời gian phản hồi: Dùng Apache JMeter, k6, Locust để đo tải hệ thống.
- Mức tài nguyên tiêu thụ: Sử dụng Prometheus + Grafana để giám sát tài nguyên hệ thống theo thời gian thực.
- Số lượng kết nối đồng thời: Dùng wrk hoặc siege để kiểm tra số lượng kết nối tối đa mà hệ thống có thể xử lý.
- Mức độ mở rộng: Thực hiện test scaling bằng cách sử dụng Kubernetes HPA (Horizontal Pod Autoscaler) hoặc kiểm tra hiệu suất trên cloud.
Xác định nút thắt cổ chai
Sau khi có số liệu, tôi bắt đầu truy tìm nút thắt cổ chai. Nút thắt này có thể từ phần cứng đến tầng ứng dụng hay database.
1. Tầng phần cứng
Nếu phần cứng của server chỉ có thể chịu được tối đa 1000 request/s thì code của tôi có tối ưu đến mấy cũng không thể vượt qua con số đó. Một số vấn đề thường gặp:
- RAM quá ít
- Ổ đĩa đọc ghi chậm
- Băng thông nghẽn cổ chai
- Quá tải CPU do xử lý quá nhiều request đồng thời
Nếu vấn đề nằm ở đây, có lẽ cách dễ nhất là... vung tiền nâng cấp tài nguyên. Cụ thể, có thể nâng cấp RAM, CPU, bộ nhớ, hoặc triển khai cluster kèm load balancing (bản chất là thêm server, thêm instance). Mục tiêu sau cùng là tăng được con số 1000 kia. Nhưng tôi không muốn tập trung vào phần cứng trong bài viết này, vì vấn đề thú vị hơn nằm ở tầng application và database.
2. Tầng application
Vấn đề có thể đến từ:
- Xử lý quá nhiều thứ trong một request: Mỗi request nên tập trung vào một tác vụ chính thay vì xử lý quá nhiều công việc cùng lúc. Ví dụ, nếu Messi, Ronaldo đăng một bài viết trên Instagram, tôi không thể đợi gửi notification đến hàng triệu followers của anh ấy rồi mới trả response được. Thay vào đó, tôi sẽ xử lý bất đồng bộ bằng cách đẩy việc gửi thông báo vào queue và phản hồi ngay sau khi lưu đủ thông tin cần thiết.
- Dùng quá nhiều tác vụ đồng bộ có thể khiến hệ thống bị nghẽn.
- Code không tối ưu: Có những middleware nào mà tất cả hay hầu hết các request đều phải chạy qua? hãy tập trung vào nó.
- Sử dụng các bên thứ ba: Nếu dịch vụ bên thứ ba bị nghẽn hoặc chậm, hệ thống của bạn cũng có thể bị ảnh hưởng. Một cách giải quyết phổ biến là caching để hạn chế số lần cần phải gọi đến bên thứ ba. Nếu không thể caching, tôi sẽ xử lý bất đồng bộ để giảm độ trễ. Ví dụ, thay vì đợi phản hồi từ bên thứ ba ngay trong request chính, tôi sẽ đẩy công việc đó vào queue và phản hồi ngay sau khi lưu đủ thông tin cần thiết.
Tôi luôn tự hỏi: Có cách nào giảm tải bớt công việc mà application phải làm không?
3. Tầng database
Đây là nơi thường xuyên gặp vấn đề nhất. Giả sử database của tôi chỉ có thể xử lý tối đa 1000 query/s, và mỗi request cần chạy 2 query, vậy thì hệ thống chỉ có thể xử lý tối đa 500 request/s.
Làm sao để giải quyết bài toán này? Tôi có hai lựa chọn:
-
Tăng con số tối đa kia
- Connection pools: Tái sử dụng lại các connection, giúp giảm độ trễ do đợi thiết lập kết nối.
- Database replica: Phân chia công việc đọc bằng cách sử dụng database replica để giảm tải cho database chính, giúp tăng khả năng xử lý đọc dữ liệu mà không ảnh hưởng đến hiệu suất ghi.
- Tối ưu query: Query càng đơn giản, database càng xử lý được nhiều request hơn. Dùng indexing hiệu quả để tăng tốc độ truy vấn.
- Sharding: Phân chia dữ liệu thành nhiều phần nhỏ để tăng tốc độ truy xuất.
-
Giảm con số query trên mỗi request
- Caching: Caching không chỉ giúp tầng application nhanh chóng lấy được data mà còn giúp giảm tải xuống database, từ đó giúp giảm số query trên mỗi request.
- Batch processing: Gom nhiều request lại xử lý cùng lúc thay vì gửi từng query riêng lẻ. Nó giống như việc khuyến khích mọi người đi các phương tiện công cộng như xe bus tàu hoả để giảm áp lực lên hạ tầng giao thông vậy.
- Tôi đã giảm được 1 query / request bằng một thay đổi nhỏ trong authentication flow như thế nào?
Bài toán thực tế: Xử lý request like trong một nền tảng mạng xã hội
Một bài toán kinh điển: Messi, Ronaldo có vài trăm triệu người theo dõi trên Instagram. Khi anh ấy đăng một bài viết lượng người dùng tiếp cần được là rất lớn, giả sử có 1.000.000 người bấm like trong phút đầu tiên. Tức là trung bình 17.000 like request mỗi giây. Mỗi request cần 2 query:
-
Một để tạo like record
-
Một để update likeCount trong bảng post
Hệ thống cần thực hiện 34.000 query/s – một con số không hề nhỏ.
Giải pháp: Batch processing Thay vì ghi luôn vào database tôi sẽ chỉ lưu vào redis sau đó mới lưu vào database theo dạng lô.
Bước 1: Xử lý Like/Unlike (toggleLike
)
Khi người dùng bấm like/unlike, hệ thống sẽ:
- Dùng Redis lock để tránh spam hoặc bấm nhiều lần cùng lúc.
- Kiểm tra trạng thái like từ Redis (nếu không có, truy vấn từ MongoDB).
- Cập nhật số lượt like trong Redis (
likes:{postId}
). - Thêm action vào hàng đợi Redis (
pending_likes:{postId}
) thay vì ghi trực tiếp vào MongoDB. - Đẩy job
syncLikes
vào hàng đợi vớijobId = postId
, trì hoãn 5 giây để đồng bộ dữ liệu, nếu trong năm giây tiếp theo có một request like mới thời gian delay sẽ được reset, ý tưởng như hàmdebouce
Bước 2: Job Đồng Bộ (syncLikes
)
Một job sẽ chạy để đồng bộ Redis với MongoDB:
- Lock tiến trình để tránh nhiều job chạy đồng thời (
lock:syncLikes:{postId}
). - Di chuyển các actions bằng cách đổi tên từ
pending_likes
sangprocessing_likes
để xử lý an toàn. Tránh việc miss action khi có request đến và được thêm vàopending_likes
trong thời gian đồng bộ. - Lọc bỏ hành động trùng lặp (nếu user like/unlike nhiều lần, chỉ hành động cuối cùng được ghi nhận).
- Ghi dữ liệu hàng loạt vào MongoDB (
bulkWrite
):- Upsert like (nếu user đã like).
- Xóa unlike (nếu user bỏ like).
- Cập nhật lại số lượt like trong MongoDB dựa trên dữ liệu Redis.
- Xóa
processing_likes
và release lock.
async toggleLike(userId: string, postId: string) {
const likeKey = `likes:${postId}`;
const userLikeKey = `user_like:${userId}:${postId}`;
const lockKey = `lock:${userId}:${postId}`;
const lock = await this.redis.set(lockKey, 'locked', 'NX', 'EX', 2);
if (!lock) return { message: 'Processing like request, try again.' };
try {
let hasLiked = await this.redis.get(userLikeKey);
if (hasLiked === null) {
const existingLike = await this.likeModel.exists({ userId, postId });
hasLiked = existingLike ? 'true' : 'false';
await this.redis.set(userLikeKey, hasLiked, 'EX', 3600);
}
if (hasLiked === 'false') {
await this.redis.incr(likeKey);
await this.redis.set(userLikeKey, 'true', 'EX', 3600);
// **Push to Redis List Instead of DB**
await this.redis.rpush(`pending_likes:${postId}`, JSON.stringify({ userId, action: 'like' }));
} else {
await this.redis.decr(likeKey);
await this.redis.del(userLikeKey);
await this.redis.rpush(`pending_likes:${postId}`, JSON.stringify({ userId, action: 'unlike' }));
}
// Ensure `syncLikes` job runs
await this.likeQueue.add('syncLikes', { postId }, { jobId: postId, delay: 5000 });
return { message: hasLiked === 'true' ? 'Unliked' : 'Liked' };
} finally {
await this.redis.del(lockKey);
}
}
async function syncLikes(postId: string) {
const pendingLikesKey = `pending_likes:${postId}`;
const processingLikesKey = `processing_likes:${postId}`;
const likeKey = `likes:${postId}`;
const lockKey = `lock:syncLikes:${postId}`;
// Acquire lock
const lock = await this.redis.set(lockKey, 'locked', 'NX', 'EX', 10);
if (!lock) return; // If another sync is running, exit
try {
// Move pending actions to a separate processing queue
await this.redis.rename(pendingLikesKey, processingLikesKey);
} catch (error) {
if (error.message.includes('no such key')) return; // No likes to process
throw error;
}
const actions = await this.redis.lrange(processingLikesKey, 0, -1);
if (actions.length === 0) return;
const bulkOps = [];
const userActions = new Map();
for (const action of actions) {
const { userId, actionType } = JSON.parse(action);
userActions.set(userId, actionType);
}
for (const [userId, actionType] of userActions) {
if (actionType === 'like') {
bulkOps.push({
updateOne: {
filter: { userId, postId },
update: { $set: { userId, postId, createdAt: new Date() } },
upsert: true,
},
});
} else {
bulkOps.push({ deleteOne: { filter: { userId, postId } } });
}
}
if (bulkOps.length) {
// TODO: handle batch size if needed
await this.likeModel.bulkWrite(bulkOps);
}
const redisLikeCount = await this.redis.get(likeKey);
if (redisLikeCount) {
await this.postModel.updateOne({ _id: postId }, { $set: { likeCount: parseInt(redisLikeCount) } });
}
// Finally, clear processed queue
await this.redis.del(processingLikesKey);
} finally {
await this.redis.del(lockKey); // Release lock
}
Phối hợp với phía frontend để giải quyết vấn đề tắc nghẽn.
Những gì trình bày ở trên là dưới góc độ một backend developer. Còn dướí góc nhìn một full-stack developer, tôi cũng đã áp dụng một vài kỹ thuật để giúp giảm tải áp lực lên backend. Cụ thể:
- Lazy load: Chỉ call api khi cần thay vì toàn bộ ngay từ đầu
virtual scroll
.... - Calling queue: Hạn chế số lượng request đồng thời từ frontend, tránh tình trạng gửi quá nhiều request khi mới reload trang khiến server bị quá tải.
- Debounce & Throttling: Khi người dùng nhập dữ liệu vào input search, tôi chỉ gửi request sau một khoảng thời gian ngắn không có thay đổi hoặc giới hạn số lần gửi request trong một khoảng thời gian nhất định.
Lời kết
Mỗi bài toán tối ưu hóa đều bắt đầu từ việc xác định nút thắt, và kết thúc bằng việc đưa ra lựa chọn phù hợp nhất với tình hình thực tế. Không có một công thức chung, chỉ có sự đánh đổi giữa hiệu suất, chi phí và độ phức tạp trong triển khai. Nhưng một khi bạn tìm được điểm nghẽn, giải pháp sẽ dần hiện ra.
Ngoài ra, mỗi solution cũng sẽ sinh ra vài bài toán nhỏ hơn, ví dụ caching sẽ xuất hiện các vấn đề như: redis penetration, redis avalance... Có thể tôi sẽ nêu và bàn luận cách xử lý ở các bài viết tiếp theo.
Bài viết có sự hỗ trợ rất nhiều từ ChatGPT =)))
All rights reserved