0

System Design: Thiết kế hệ thống lưu trữ tương tự dropbox

image.png

1. Mục tiêu hệ thống

Dropbox là hệ thống lưu trữ và đồng bộ file trên nhiều thiết bị. Người dùng có thể tạo, sửa, xoá, đổi tên file trên một thiết bị, sau đó thay đổi này cần được đồng bộ lên cloud và lan truyền xuống các thiết bị khác.

Mục tiêu chính của hệ thống:

  • Upload và download file an toàn, kể cả file lớn lên tới hàng chục GB.
  • Đồng bộ thay đổi giữa local device và cloud.
  • Hỗ trợ resume khi upload/download bị gián đoạn.
  • Chỉ truyền phần dữ liệu thay đổi khi có thể, thay vì luôn truyền cả file.
  • Xử lý conflict khi cùng một file bị sửa ở nhiều nơi.
  • Đảm bảo metadata nhất quán với blob data.
  • Mở rộng được cho hàng triệu user và hàng tỷ file.

2. Functional Requirements

Hệ thống cần hỗ trợ các chức năng chính sau:

  1. User có thể upload file.
  2. User có thể download file.
  3. User có thể tạo folder, xoá file, rename file, move file.
  4. Sync agent trên máy user phát hiện thay đổi local và upload lên cloud.

3. Non-functional Requirements

Các yêu cầu phi chức năng quan trọng:

  • Reliability: không mất dữ liệu user.
  • Scalability: scale được metadata service, storage, sync service.
  • Availability: user vẫn có thể truy cập file nhiều nhất có thể.
  • Consistency: metadata cần nhất quán đủ để tránh mất file hoặc ghi đè sai.
  • Low bandwidth usage: tránh upload/download lại toàn bộ file nếu chỉ thay đổi nhỏ.
  • Security: file cần được bảo vệ bằng authentication, authorization, encryption.
  • Resumability: upload/download lớn không bị restart từ đầu khi mạng rớt.

4. High-level Architecture

flowchart LR
    Client[Desktop / Mobile / Web Client]
    Agent[Sync Agent]
    LB[Load Balancer / API Gateway]
    Auth[Auth Service]
    Meta[Metadata Service]
    Sync[Sync Service / Change Feed]
    Upload[Upload Service]
    Object[(Object Storage: S3/GCS)]
    DB[(Metadata DB)]
    Cache[(Cache)]
    MQ[(Message Queue)]

    Client --> LB
    Agent --> LB
    LB --> Auth
    LB --> Meta
    LB --> Upload
    LB --> Sync

    Meta --> DB
    Meta --> Cache
    Upload --> Object
    Upload --> DB
    Sync --> DB
    Meta --> MQ
    MQ --> Sync

Các thành phần chính:

  • Client / Sync Agent: chạy trên device của user, theo dõi local folder, upload/download changes.
  • API Gateway: entry point cho request, xử lý routing, auth, rate limit.
  • Auth Service: xác thực user và token.
  • Metadata Service: quản lý file metadata như path, size, owner, version, hash, parent folder.
  • Object Storage: lưu nội dung file thật.
  • Sync Service / Change Feed: cho client biết cloud có thay đổi gì từ lần sync trước.
  • Upload Service: tạo upload session, quản lý multipart upload, verify manifest.
  • Metadata DB: lưu metadata của file/folder/version.
  • Message Queue: phát event khi file thay đổi để phục vụ sync, notification, indexing.

5. Core Data Model

5.1 User

User {
    user_id
    email
    password_hash / oauth_provider
    created_at
}

5.2 File Metadata

FileMetadata {
    file_id
    owner_id
    parent_folder_id
    name
    path
    type                 // file or folder
    size
    current_version_id
    is_deleted
    created_at
    updated_at
}

5.3 File Version

FileVersion {
    version_id
    file_id
    revision_number
    content_hash
    size
    manifest_id
    created_at
    created_by_device_id
}

5.4 Chunk Metadata

Chunk {
    chunk_id
    hash
    size
    storage_key
    created_at
}

5.5 Manifest

Manifest mô tả file được ghép từ những chunk nào.

Manifest {
    manifest_id
    file_id
    version_id
    file_size
    chunk_size
    total_chunks
    full_file_checksum
    chunks: [
        {
            part_number
            byte_range
            size
            checksum
            chunk_id
            storage_key
        }
    ]
}

Manifest rất quan trọng vì nó giúp:

  • Biết thứ tự chunk để assemble file.
  • Biết byte range của từng chunk.
  • Verify integrity bằng checksum.
  • Resume upload/download.
  • So sánh version local và remote để chỉ tải chunk bị thay đổi.

6. Upload File Flow

Với file nhỏ, client có thể upload trực tiếp bằng một request hoặc upload qua pre-signed URL. Với file lớn, không nên gửi toàn bộ file qua backend vì giới hạn POST body, timeout, memory và network instability.

Flow upload file lớn:

sequenceDiagram
    participant C as Client
    participant B as Backend Upload Service
    participant S as Object Storage
    participant M as Metadata DB

    C->>B: Create upload session(file metadata)
    B->>M: Create upload session record
    B-->>C: upload_id + pre-signed URLs / multipart info
    C->>C: Split file into chunks
    C->>S: Upload chunks directly
    C->>B: Complete upload(manifest)
    B->>S: Verify uploaded parts
    B->>M: Commit file metadata + version
    B-->>C: Upload success

Các bước chi tiết:

  1. Client gọi backend tạo upload session.
  2. Backend tạo upload_id.
  3. Client split file thành chunk, ví dụ 8MB, 16MB hoặc 64MB mỗi chunk.
  4. Client tính checksum cho từng chunk.
  5. Client upload chunk trực tiếp lên object storage bằng pre-signed URL.
  6. Client gửi complete-upload kèm manifest.
  7. Backend verify chunk list, checksum, size.
  8. Backend commit metadata và tạo file version mới.

Điểm quan trọng:

Client không upload file 50GB qua một POST request duy nhất.
Client upload từng chunk trực tiếp lên object storage.
Backend chỉ quản lý metadata, permission, session và commit.

7. Resumable Upload

Vấn đề: upload file 50GB có thể bị rớt mạng giữa chừng. Nếu bắt user upload lại từ đầu thì rất tệ.

Giải pháp: dùng upload session và chunk manifest.

Khi bắt đầu upload:

upload_id = upload_abc123
file = video.mp4
total_parts = 800
chunk_size = 64MB

Client lưu local manifest:

UploadManifest {
    upload_id
    file_path
    file_size
    chunk_size
    total_parts
    parts: [
        { part_number: 1, checksum: h1, status: uploaded },
        { part_number: 2, checksum: h2, status: uploaded },
        { part_number: 3, checksum: h3, status: pending }
    ]
}

Nếu mạng fail, khi app chạy lại:

  1. Client load upload_id từ local manifest.
  2. Client hỏi backend/object storage: upload session này đã có những part nào?
  3. Backend trả về list uploaded parts.
  4. Client compare với manifest.
  5. Client upload lại only missing parts.
  6. Sau khi đủ parts, client gọi complete upload.

Không nên nói “hỏi S3 next id là gì”, vì chunk có thể upload song song và out of order. Cách chính xác hơn là:

Client asks which parts are already uploaded, then uploads only missing parts.

Ví dụ:

total parts: 1..800
uploaded parts: 1, 2, 3, 5, 6
missing parts: 4, 7, 8, ..., 800

Client cần retry part 4 trước, không phải cứ upload “next part”.


8. Download File Flow

Download cũng nên dùng object storage và pre-signed URL.

Flow:

  1. Client gọi backend: GET /files/{file_id}/download.
  2. Backend check permission.
  3. Backend lấy storage_key hoặc manifest của version hiện tại.
  4. Backend trả về pre-signed download URL hoặc list chunk URLs.
  5. Client download file trực tiếp từ object storage.

Với file nhỏ, download một object là đủ. Với file lớn hoặc delta sync, client có thể download theo chunk.


9. Detect Local Changes

Sync agent trên desktop/mobile cần phát hiện khi user tạo, sửa, xoá hoặc rename file trong Dropbox folder.

Cách làm:

  1. Dùng OS file watcher:
    • macOS: FSEvents
    • Windows: ReadDirectoryChangesW
    • Linux: inotify
  2. Khi OS báo event, agent không upload ngay.
  3. Agent debounce event để tránh xử lý khi file vẫn đang được app ghi.
  4. Agent đọc current file state từ local filesystem.
  5. Agent compare với local index.
  6. Nếu file thay đổi thật, agent upload change lên cloud.

Flow:

User edits file
        ↓
OS sends file event
        ↓
Sync agent receives event
        ↓
Debounce until file is stable
        ↓
Read current file state from filesystem
        ↓
Compare with local index
        ↓
Upload if changed
        ↓
Update local index

Current file state là gì?

Current file state là trạng thái thật hiện tại của file trên disk:

path
size
modified_time
file_id / inode
is_deleted
content_hash

Nó nằm ở filesystem của user, ví dụ:

/Users/drake/Dropbox/report.pdf

Local index là gì?

Local index là database local của sync agent, ví dụ SQLite/RocksDB:

~/.dropbox/sync_index.db

Nó lưu trạng thái cuối cùng mà agent biết đã sync:

path
file_id
last_synced_version
last_synced_hash
size
modified_time
sync_status

Ý tưởng:

Current file state = file thật trên disk bây giờ.
Local index = trạng thái lần cuối agent đã sync.

Nếu hai cái khác nhau, nghĩa là file có thay đổi.


10. Discover Cloud Changes

Sync agent cũng cần biết có thay đổi nào xảy ra trên cloud do device khác tạo ra.

Không nên poll toàn bộ danh sách file mỗi lần vì rất tốn kém. Nếu user có 1 triệu file, việc list all rồi compare local index là không scale.

Giải pháp tốt hơn: server cung cấp change feed theo cursor.

API ví dụ:

GET /changes?cursor=abc123

Response:

{
  "changes": [
    {
      "type": "modified",
      "file_id": "file_1",
      "path": "/docs/a.txt",
      "version": "r20"
    },
    {
      "type": "deleted",
      "file_id": "file_2",
      "path": "/docs/old.txt",
      "version": "r21"
    },
    {
      "type": "renamed",
      "file_id": "file_3",
      "old_path": "/x.txt",
      "new_path": "/y.txt",
      "version": "r22"
    }
  ],
  "next_cursor": "abc124"
}

Client lưu cursor trong local index. Lần sau chỉ hỏi:

Give me changes since my last cursor.

Flow:

Agent starts
        ↓
Load last sync cursor
        ↓
Poll cloud change feed
        ↓
Receive remote changes since cursor
        ↓
Apply changes to local filesystem
        ↓
Update local index
        ↓
Save new cursor

Ngoài polling, hệ thống có thể dùng long polling, websocket hoặc push notification để báo client có changes mới. Nhưng ngay cả khi có push notification, client vẫn nên gọi change feed bằng cursor để tránh mất event.


11. Apply Cloud Changes to Local File System

Khi nhận remote change, agent xử lý theo từng loại:

11.1 Remote create

Nếu cloud có file mới:

  1. Agent download file/chunks.
  2. Ghi file vào local Dropbox folder.
  3. Update local index.

11.2 Remote modify

Nếu cloud có version mới:

  1. Agent kiểm tra local file có bị sửa riêng không.
  2. Nếu local không đổi, download version mới và ghi đè local file.
  3. Nếu local cũng đổi, tạo conflict.

11.3 Remote delete

Nếu cloud delete file:

  1. Nếu local file không đổi, delete local file.
  2. Nếu local file đã bị sửa offline, tạo conflict hoặc preserve local copy.

11.4 Remote rename/move

Nếu cloud rename/move:

  1. Agent rename/move file local.
  2. Update path trong local index.

Điểm quan trọng:

No conflict  => apply remote change directly.
Conflict     => preserve both versions / merge / ask user.

Không được silently overwrite local changes vì có thể làm mất dữ liệu user.


12. Conflict Detection

Conflict xảy ra khi local và remote cùng thay đổi từ một base version.

Ví dụ:

Base version: r10
Local file changed from r10
Cloud file changed to r20

Local index biết file lần cuối sync là r10. Nhưng bây giờ:

  • Local file hash khác r10.
  • Remote version cũng khác r10.

Nghĩa là có conflict.

Cách xử lý phổ biến:

report.docx
report (Drake's conflicted copy).docx

Một version giữ nội dung remote, một version giữ nội dung local. Tùy policy, app có thể:

  • Giữ cloud version làm file chính, local version làm conflicted copy.
  • Giữ local version làm file chính, cloud version làm conflicted copy.
  • Merge tự động nếu là text file và merge được.
  • Yêu cầu user resolve nếu file phức tạp.

Nguyên tắc quan trọng:

Never silently discard user data.

13. Delta Sync bằng Chunk Manifest

Khi file lớn chỉ thay đổi một phần nhỏ, không nên download/upload lại toàn bộ file.

Giải pháp: chia file thành chunk và lưu manifest cho từng version.

Remote manifest:

file_id: report.pdf
version: r20
chunks:
  part 1: hash_a
  part 2: hash_b
  part 3: hash_c

Local index:

file_id: report.pdf
version: r10
chunks:
  part 1: hash_a
  part 2: hash_old
  part 3: hash_c

Agent compare hash trong manifest:

part 1: same       => skip
part 2: different  => download/upload this chunk
part 3: same       => skip

Điểm quan trọng:

Compare manifests, not raw chunk content.

Client không download toàn bộ chunks để compare. Client chỉ download remote manifest, vì manifest nhỏ hơn file rất nhiều. Sau đó client chỉ tải chunk missing hoặc changed.

Fixed-size chunk vs content-defined chunking

Nếu dùng fixed-size chunk, ví dụ mỗi chunk 5MB, thì insert 1 byte ở đầu file có thể làm nhiều chunk sau đó bị lệch, dẫn tới nhiều hash thay đổi.

Ví dụ:

Before:
ABCDE | FGHIJ | KLMNO

After insert X at beginning:
XABCD | EFGHI | JKLMN | O

Dù chỉ thêm 1 byte, gần như mọi chunk phía sau thay đổi.

Để tối ưu hơn, có thể dùng content-defined chunking. Cách này cắt chunk dựa trên nội dung, nên khi insert/delete ở giữa file, nhiều chunk cũ vẫn được giữ lại.

Trong interview, có thể nói:

For a simple design, fixed-size chunks are acceptable.
For better delta sync efficiency, we can use content-defined chunking.

14. Metadata Consistency

Một vấn đề quan trọng là metadata và blob data phải nhất quán.

Không được để metadata nói file đã upload thành công nhưng object storage lại thiếu chunk.

Flow an toàn:

  1. Tạo upload session ở trạng thái pending.
  2. Client upload chunks lên object storage.
  3. Client gọi complete upload.
  4. Backend verify chunks tồn tại và checksum hợp lệ.
  5. Backend commit metadata trong DB transaction.
  6. File version chuyển sang committed.

Trạng thái upload:

pending
uploading
completed
failed
aborted

Nếu upload bị bỏ dở, background cleanup job có thể xoá incomplete chunks sau một thời gian.


15. API Design

Create upload session

POST /upload-sessions

Request:

{
  "path": "/videos/movie.mp4",
  "file_size": 53687091200,
  "chunk_size": 67108864,
  "content_hash": "full_file_hash"
}

Response:

{
  "upload_id": "upload_abc123",
  "chunk_size": 67108864,
  "total_parts": 800,
  "expires_at": "2026-06-01T00:00:00Z"
}

Get uploaded parts

GET /upload-sessions/{upload_id}/parts

Response:

{
  "uploaded_parts": [
    {
      "part_number": 1,
      "etag": "etag_1",
      "checksum": "hash_1"
    }
  ]
}

Complete upload

POST /upload-sessions/{upload_id}/complete

Request:

{
  "manifest": {
    "file_size": 53687091200,
    "chunk_size": 67108864,
    "total_chunks": 800,
    "chunks": [
      {
        "part_number": 1,
        "byte_range": "0-67108863",
        "size": 67108864,
        "checksum": "hash_1",
        "etag": "etag_1"
      }
    ]
  }
}

Download file

GET /files/{file_id}/download?version=current

Response:

{
  "download_url": "https://object-storage/presigned-url"
}

Get changes

GET /changes?cursor=abc123

Response:

{
  "changes": [],
  "next_cursor": "abc124",
  "has_more": false
}

16. Storage Design

Nội dung file nên lưu trong object storage, không lưu trực tiếp trong database.

Object storage key có thể là:

/chunks/{chunk_hash}
/files/{file_id}/versions/{version_id}
/uploads/{upload_id}/parts/{part_number}

Nếu dùng content-addressed storage theo chunk hash, hệ thống có thể deduplicate chunk giống nhau giữa các file/version.

Ví dụ:

chunk_hash = SHA256(content)
storage_key = /chunks/sha256_abcd

Nếu nhiều file dùng cùng chunk, ta chỉ lưu một lần và reference từ nhiều manifest.


17. Scaling Metadata Service

Metadata service thường là bottleneck vì mọi operation đều cần metadata.

Các cách scale:

  1. Partition/shard theo user_id hoặc namespace_id.
  2. Dùng cache cho hot metadata.
  3. Tách read/write path nếu cần.
  4. Dùng database transaction cho operation quan trọng như rename/move.
  5. Index các field thường query:
    • owner_id
    • parent_folder_id
    • file_id
    • path
    • updated_at

Vấn đề rename folder lớn:

Nếu folder có hàng triệu child files, update path cho tất cả child ngay lập tức rất nặng. Có thể tránh bằng cách lưu tree theo parent_folder_id thay vì materialized full path. Khi cần hiển thị path, resolve từ parent chain hoặc dùng cached path.


18. Change Feed Design

Mỗi thay đổi metadata nên tạo một event:

ChangeEvent {
    event_id
    user_id
    namespace_id
    file_id
    type
    version
    timestamp
}

Client sync bằng cursor:

cursor = last_event_id hoặc encoded checkpoint

Khi client gọi /changes?cursor=..., server trả về các event sau cursor đó.

Yêu cầu:

  • Event phải ordered trong cùng namespace.
  • Cursor phải durable.
  • Client phải xử lý idempotent vì có thể nhận duplicate event.
  • Nếu cursor quá cũ, server có thể yêu cầu client resync full metadata tree.

19. Idempotency

Network có thể retry request, nên API cần idempotent.

Ví dụ upload complete có thể bị client gọi 2 lần do timeout. Backend cần đảm bảo không tạo hai version trùng nhau.

Dùng:

idempotency_key
upload_id
client_mutation_id

Ví dụ:

POST /upload-sessions/{upload_id}/complete
Idempotency-Key: abc-xyz

Nếu request lặp lại, backend trả về cùng kết quả như lần đầu.


20. Security

Các điểm bảo mật:

  1. User phải authenticate bằng token.
  2. Mọi file operation phải check authorization.
  3. Pre-signed URL phải có expiry ngắn.
  4. Object storage không public trực tiếp.
  5. File nên được encrypt at rest.
  6. TLS cho network transfer.
  7. Có rate limit để tránh abuse.
  8. Virus scanning hoặc malware scanning có thể chạy async.
  9. Audit log cho sharing và permission changes.

21. Handling Deletes

Delete nên là soft delete trước:

is_deleted = true
deleted_at = timestamp

Lợi ích:

  • Cho phép restore.
  • Hỗ trợ version history.
  • Tránh mất dữ liệu do accidental delete.
  • Sync agent có thể truyền delete event xuống devices khác.

Sau retention period, background job có thể hard delete object nếu không còn reference.


22. Sharing Model

Một thiết kế đơn giản:

Permission {
    resource_id
    resource_type       // file/folder
    principal_type      // user/group/link
    principal_id
    role                // viewer/editor/owner
}

Khi user truy cập file:

  1. Check owner.
  2. Check explicit permission.
  3. Nếu file nằm trong shared folder, inherit permission từ folder.
  4. Nếu public link enabled, verify link token và expiry.

23. Offline Mode

Sync agent nên hoạt động được khi offline:

  • Local edits vẫn được ghi nhận vào local index dưới dạng pending changes.
  • Khi online lại, agent upload pending changes.
  • Agent cũng poll cloud changes từ last cursor.
  • Nếu local và remote cùng thay đổi, tạo conflict.

Local queue:

PendingOperation {
    op_id
    type
    path
    base_version
    local_hash
    status
}

Khi network trở lại:

process pending local changes
poll cloud changes
resolve conflicts
update local index

24. Failure Scenarios

Upload chunk fail

Retry chunk đó với exponential backoff.

Complete upload timeout

Client retry complete request với same idempotency_key.

Metadata committed nhưng event chưa publish

Dùng transactional outbox pattern: metadata write và outbox event nằm trong cùng DB transaction. Background publisher đọc outbox và publish event vào queue.

Client mất event

Client dùng cursor để đọc lại change feed. Push notification chỉ là optimization, không phải source of truth.

Local file đổi trong lúc upload

Agent so sánh size/modified_time/file_id trước và sau khi upload. Nếu file đã đổi, abort upload session cũ và tạo upload session mới cho version mới.


25. Trade-offs

Strong consistency vs eventual consistency

Metadata cần consistency mạnh hơn object delivery. Nhưng sync giữa devices có thể eventual consistent.

Fixed-size chunk vs content-defined chunk

Fixed-size chunk dễ implement hơn nhưng delta sync kém khi insert/delete ở đầu file. Content-defined chunk tối ưu bandwidth hơn nhưng phức tạp và tốn CPU hơn.

Polling vs push notification

Polling đơn giản nhưng tốn tài nguyên. Push nhanh hơn nhưng có thể mất event, nên vẫn cần change feed với cursor.

Store full path vs parent pointer

Full path query nhanh hơn nhưng rename folder lớn khó. Parent pointer rename rẻ hơn nhưng resolve path phức tạp hơn.



All Rights Reserved

Viblo
Let's register a Viblo Account to get more interesting posts.