+3

Series duthaho đi phỏng vấn: bài toán Bulk Download

Mọi người đi qua nhớ ủng hộ bài viết gốc của mình nhé: https://open.substack.com/pub/duthaho/p/toi-i-phong-van-bai-toan-bulk-download


Bắt đầu buổi phỏng vấn

(Anh Khoa): Chào duthaho, tôi là Khoa. Cảm ơn bạn đã dành thời gian tham gia buổi hôm nay. Như đã trao đổi, chúng ta sẽ có một phiên về System Design.

(duthaho): Dạ, em chào anh Khoa. Em đã sẵn sàng ạ.

(Anh Khoa): Tốt. Chúng ta bắt đầu luôn nhé. Giả sử hệ thống của bạn là một nền tảng lưu trữ media, tương tự như Google Drive hoặc Dropbox. Người dùng lưu trữ hàng triệu file audio/video trên đó. Yêu cầu nghiệp vụ mới là: "Người dùng muốn có một nút 'Bulk Download'. Họ có thể chọn hàng nghìn file một lúc—trường hợp xấu nhất có thể lên đến 500GB—và hệ thống phải nén các file này lại và cho phép họ tải về."

Bạn sẽ thiết kế tính năng này như thế nào?

(duthaho): Dạ, đây là một case study rất hay ạ. Trước khi đưa ra giải pháp, em xin phép được đặt một vài câu hỏi để làm rõ yêu cầu, vì các yêu cầu này sẽ ảnh hưởng trực tiếp đến lựa chọn kiến trúc.

  1. Về chức năng: Các file nguồn này đang được lưu trữ ở đâu? Trên server của mình hay trên một object storage như S3 ạ?

  2. Về phi chức năng (NFR): Người dùng có thể chờ bao lâu? Họ nhấn nút và chờ màn hình loading (đồng bộ), hay họ chấp nhận nhận thông báo sau (bất đồng bộ)? Với 500GB thì em đoán chắc chắn là bất đồng bộ.

  3. Về tải (Load): Tần suất sử dụng tính năng này có cao không? Ví dụ, chúng ta có bao nhiêu request "Bulk Download" đồng thời trong một phút?

  4. Về đầu ra: Yêu cầu là "1 hoặc nhiều file zip". Logic chia file zip là gì? Ví dụ: chia theo mỗi 10GB, hay cứ nén thành một file lớn duy nhất?

(Anh Khoa): Rất tốt, đó chính xác là những câu hỏi tôi mong đợi. Chúng ta hãy giả định thế này:

  1. File đang nằm trên AWS S3.

  2. Chính xác, đây phải là một quy trình bất đồng bộ (asynchronous). Người dùng không phải chờ.

  3. Tần suất ở mức trung bình, khoảng 100-200 request/phút.

  4. Hiện tại, cứ gộp thành một file zip lớn nhất có thể. Nếu có vấn đề, chúng ta sẽ tính đến việc chia nhỏ sau.

Mời bạn trình bày kiến trúc tổng quan.

(duthaho): Dạ, với các yêu cầu này, em sẽ đề xuất một kiến trúc xử lý nền (background processing) sử dụng Message Queue.

Luồng hoạt động sẽ như sau:

  1. Client gọi đến API Server với danh sách các file ID cần nén.

  2. API Server sẽ không xử lý ngay. Thay vào đó, nó sẽ:

    • Tạo một job_id.

    • Lưu thông tin job (danh sách file, user_id, status là PENDING) vào một Job Database (ví dụ DynamoDB hoặc PostgreSQL).

    • Đẩy một message chứa job_id vào một Message Queue (ví dụ AWS SQS).

    • Trả về job_id cho client ngay lập tức.

  3. Client sẽ dùng job_id này để polling (hỏi) API, hoặc chúng ta có thể nâng cấp dùng WebSocket để đẩy (push) trạng thái real-time.

  4. Một cụm Worker Service (có thể là các container trên ECS/Kubernetes) sẽ lắng nghe từ Queue.

  5. Một worker nhận được job_id, nó sẽ cập nhật status trong DB thành PROCESSING.

  6. Worker thực hiện việc nén, sau đó upload file zip kết quả lên S3.

  7. Cuối cùng, worker cập nhật status thành COMPLETED và lưu lại URL của file zip.

(Anh Khoa): Ok, kiến trúc tổng quan này khá chuẩn. Giờ chúng ta đi sâu vào điểm mấu chốt. Con worker của bạn sẽ xử lý 500GB dữ liệu như thế nào mà không bị Out Of Memory (OOM)?

(duthaho): Dạ, đây là điểm mấu chốt của bài toán. Tuyệt đối không được tải toàn bộ 500GB về bộ nhớ của worker. Em sẽ sử dụng kỹ thuật Streaming.

Luồng xử lý của worker sẽ là:

  1. Mở một Write Stream (luồng ghi) để tạo file zip (có thể ghi tạm ra đĩa của worker).

  2. Với mỗi file trong danh sách:

    • Mở một Read Stream (luồng đọc) từ S3.

    • Đọc từng chunk nhỏ (ví dụ 10MB) từ stream S3.

    • Nén (compress) chunk đó.

    • Ghi (pipe) chunk đã nén vào Write Stream của file zip.

  3. Sau khi lặp hết các file, worker sẽ đóng stream zip lại.

  4. Upload file zip tạm đó lên S3.

Bằng cách này, bộ nhớ sử dụng tại mọi thời điểm chỉ bằng kích thước của chunk (10MB) chứ không phải 500GB.

(Anh Khoa): Tốt. Giờ tôi thêm một yếu tố phức tạp. Giả sử các file người dùng chọn không chỉ nằm trên S3 của chúng ta, mà còn có thể đến từ các nguồn bên ngoài, ví dụ: một link HTTP công khai, hoặc từ Google Drive của họ. Hệ thống của bạn sẽ thay đổi thế nào?

(duthaho): Dạ, khi làm việc với nhiều nguồn dữ liệu khác nhau, code của worker rất dễ bị rối bởi các câu if-else. Để giải quyết, em sẽ áp dụng Adapter Pattern.

  1. Em sẽ định nghĩa một interface chung, ví dụ IStreamReader, với các phương thức chuẩn như open(), read(chunk_size), close().

  2. Sau đó, em sẽ tạo các class "adapter" cụ thể cho từng nguồn: S3StreamReader, HttpStreamReader, GoogleDriveStreamReader...

  3. Khi worker nhận job, một Factory sẽ xem source_type (là 's3', 'http', hay 'gdrive') để khởi tạo các đối tượng Adapter tương ứng.

  4. Logic nén file của worker sẽ không thay đổi. Nó chỉ làm việc với interface IStreamReader chung mà không cần quan tâm chi tiết file đến từ đâu.

(Anh Khoa): Rất hay. Nhưng điều đó dẫn đến một vấn đề mới. Giả sử 99 file trên S3 của ta rất nhanh, nhưng có 1 file 50GB nằm trên một server HTTP chậm ở châu Âu. Nó sẽ trở thành bottleneck (nút thắt cổ chai) và "chặn" toàn bộ job trong nhiều giờ. Bạn xử lý sao?

(duthaho): Đây là một vấnê đề về hiệu năng. Nếu server HTTP đó hỗ trợ HTTP Range Requests (cho phép tải từng phần), em sẽ áp dụng kỹ thuật Parallel Chunking (chia nhỏ song song).

Một Orchestrator (bộ điều phối) sẽ thay thế worker đơn giản.

  1. Orchestrator sẽ thấy file 50GB này và chia nó thành 50 sub-task, mỗi sub-task xử lý 1GB.

  2. Nó đẩy 50 sub-task này vào Queue.

  3. Nhiều worker sẽ cùng lúc lấy các sub-task này về, download và nén song song các phần.

  4. Kết quả sẽ là 50 file zip nhỏ.

  5. Orchestrator sẽ theo dõi khi nào cả 50 sub-task hoàn thành và cung cấp cho người dùng một danh sách 50 file zip đó (thay vì cố gắng merge chúng lại, vốn cũng rất tốn kém).

(Anh Khoa): Ok, tôi thích ý tưởng đó. Giờ là câu hỏi cuối cùng về độ tin cậy. Giả sử trong 50 sub-task đó, có một sub-task liên tục bị lỗi. Worker thử lại (retry) nhiều lần vẫn lỗi. Chúng ta không thể để nó retry mãi, vừa tốn tài nguyên, vừa có thể làm sập server của đối tác (nếu là lỗi 429). Bạn sẽ thiết kế cơ chế chịu lỗi (fault tolerance) như thế nào?

(duthaho): Dạ, đối với xử lý lỗi tinh vi, em sẽ kết hợp 3 pattern:

  1. Exponential Backoff: Khi có lỗi, worker sẽ không retry ngay. Nó sẽ đợi 1s, rồi 2s, 4s, 8s... Điều này giúp hệ thống "thở" và tránh tạo bão retry.

  2. Phân tích mã lỗi: Khi retry, worker sẽ kiểm tra mã lỗi. Nếu là lỗi 404 (Not Found) hay 403 (Forbidden), đây là lỗi vĩnh viễn, việc retry là vô ích -> worker sẽ báo lỗi ngay. Nếu là 429 (Too Many Requests), logic backoff sẽ tự nhiên giúp giảm tải.

  3. Circuit Breaker Pattern: Nếu một nguồn (ví dụ: server HTTP ở châu Âu) liên tục trả về lỗi 500, Orchestrator sẽ kích hoạt "ngắt mạch". Tức là nó sẽ chủ động từ chối mọi sub-task mới đến nguồn đó trong 5 phút. Điều này bảo vệ hệ thống của ta khỏi lãng phí tài nguyên và bảo vệ cả hệ thống của đối tác.

Sau khi Circuit Breaker được kích hoạt, Orchestrator sẽ đánh dấu toàn bộ job là FAILED và thông báo rõ cho người dùng là "Không thể xử lý file X do nguồn Y gặp sự cố".

(Anh Khoa): Rất tốt, duthaho. Bạn đã phân tích vấn đề rất có cấu trúc, đi từ yêu cầu cơ bản, xây dựng kiến trúc, sau đó đào sâu vào các vấn đề khó như quản lý bộ nhớ, mở rộng hệ thống và thiết kế chịu lỗi. Câu trả lời của bạn rất rõ ràng và logic.

Cảm ơn bạn. Buổi phỏng vấn của chúng ta kết thúc ở đây. Bạn có câu hỏi nào cho tôi không?

(duthaho): Dạ, em cảm ơn anh Khoa. Em muốn hỏi là trong các dự án thực tế tại công ty, văn hóa kỹ thuật của mình ưu tiên việc dùng các managed service (như SQS, DynamoDB) để phát triển nhanh, hay ưu tiên việc tự vận hành (self-hosted) các giải pháp open-source (như RabbitMQ, PostgreSQL) để tối ưu chi phí về lâu dài ạ?

(Anh Khoa): ...


Project thực tế của mọi người như thế nào, hãy comment trả lời giúp anh Khoa nhé 😄


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí