Thiết kế hệ thống lưu trữ và chia sẻ file kiểu Google Drive
1. Tổng quan
Bài này thiết kế một hệ thống lưu trữ và chia sẻ file tương tự Google Drive. Nội dung đi theo trình tự: chốt yêu cầu, xây mô hình dữ liệu, định nghĩa API, mô tả luồng upload và đồng bộ. Sau đó bài tập trung vào ba điểm khó là tranh chấp ghi, tính nhất quán và tải đột biến.
Đọc xong, bạn có một bản thiết kế dùng được cho phỏng vấn system design. Bạn cũng có các đoạn code Spring Boot minh hoạ những phần cốt lõi.
2. Yêu cầu
2.1. Yêu cầu chức năng
- Upload và download file, kích thước tối đa 10 GB.
- Tổ chức file theo cây thư mục, hỗ trợ đổi tên và di chuyển.
- Chia sẻ file hoặc thư mục cho người khác với quyền
VIEWERhoặcEDITOR. Hỗ trợ cả link công khai. - Lưu lịch sử phiên bản và cho phép khôi phục bản cũ.
- Đồng bộ thay đổi giữa nhiều thiết bị của cùng một người dùng.
2.2. Quy mô và ràng buộc
Giả định để tính toán:
Người dùng: 50 triệu, DAU: 10 triệu
Upload trung bình: 2 file/người/ngày, ~ 500 KB/file
=> ~ 20 triệu file/ngày, ~ 230 file/giây (đỉnh x5 ≈ 1.200/giây)
=> ~ 10 TB dữ liệu mới/ngày, ~ 3,6 PB/năm (chưa tính bản sao)
Tỉ lệ đọc:ghi ≈ 5:1
Từ các con số này có thể rút ra ba ràng buộc:
- Không được mất dữ liệu. Độ bền phải đạt mức “11 số 9”. Vì vậy nội dung file phải nằm trên object storage như S3, không nằm trên ổ đĩa của app server.
- Metadata cần nhất quán mạnh. Một file không được có hai “phiên bản mới nhất”. Phần này dùng cơ sở dữ liệu quan hệ.
- App server không được làm nút cổ chai băng thông. Byte của file nên đi thẳng từ client tới storage.
3. Kiến trúc tổng thể
Client ──► API Gateway (auth, rate limit) ──► Metadata Service ──► PostgreSQL (sharded theo owner_id)
│ │
│ presigned URL ├──► Redis (cache quyền, rate limit)
▼ └──► Outbox ──► Kafka ──► Notification / Search / Thumbnail
Object Storage (S3) ◄── CDN (download file chia sẻ nhiều)
Ý tưởng chính là tách metadata khỏi nội dung. File được cắt thành các block 4 MB. Mỗi block được định danh bằng SHA-256 của chính nội dung đó (content-addressed). Cách làm này mang lại ba lợi ích:
- Dedup miễn phí. Hai block giống nhau chỉ lưu một lần.
- Resume upload. Mạng rớt thì client chỉ cần gửi lại những block còn thiếu.
- Delta sync. Sửa một đoạn nhỏ trong file 1 GB chỉ phải upload vài block.
4. Mô hình dữ liệu
CREATE TABLE files (
id UUID PRIMARY KEY,
owner_id BIGINT NOT NULL,
parent_id UUID REFERENCES files(id),
name VARCHAR(255) NOT NULL,
is_folder BOOLEAN NOT NULL,
head_version INT NOT NULL DEFAULT 0,
size_bytes BIGINT NOT NULL DEFAULT 0,
deleted_at TIMESTAMPTZ,
UNIQUE (parent_id, name)
);
CREATE TABLE file_versions (
file_id UUID NOT NULL REFERENCES files(id),
version INT NOT NULL,
block_list TEXT[] NOT NULL, -- danh sách hash theo thứ tự
created_by BIGINT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (file_id, version)
);
CREATE TABLE blocks (
hash CHAR(64) PRIMARY KEY, -- SHA-256 hex
size_bytes INT NOT NULL,
ref_count BIGINT NOT NULL DEFAULT 0
);
CREATE TABLE shares (
file_id UUID NOT NULL REFERENCES files(id),
grantee_id BIGINT NOT NULL,
role VARCHAR(10) NOT NULL, -- VIEWER | EDITOR
PRIMARY KEY (file_id, grantee_id)
);
CREATE TABLE change_journal (
seq BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL,
file_id UUID NOT NULL,
action VARCHAR(20) NOT NULL
);
Vai trò của từng phần trong schema:
head_versionlà cột khoá lạc quan cho mỗi file (xem mục 6.1).file_versions.block_listbiến mỗi phiên bản thành một danh sách hash. Nhờ vậy, khôi phục bản cũ chỉ là tạo một version mới trỏ tớiblock_listcũ, không phải copy byte nào.blocks.ref_countgiúp garbage collector biết block nào không còn ai dùng để xoá khỏi S3.change_journallà nhật ký thay đổi theo từng người dùng. Thiết bị đồng bộ bằng cách đọc từ mộtseq(cursor) trở đi.UNIQUE (parent_id, name)chặn hai file trùng tên trong một thư mục ngay ở tầng DB.
Lưu ý: Kiểm tra quyền theo cây thư mục, kiểu “được share thư mục cha thì thấy file con”, là truy vấn đệ quy và tốn kém. Hãy cache kết quả quyền trong Redis theo key
perm:{userId}:{fileId}với TTL ngắn. Khi share thay đổi thì xoá cache.
5. API và luồng upload
Upload diễn ra theo ba bước:
POST /api/files/{fileId}/uploads {baseVersion, blocks:[hash...]} -> {missing:[{hash,url}]}
PUT <presigned-url> (client gửi thẳng block lên S3)
POST /api/files/{fileId}/commit {baseVersion, blocks:[hash...]} -> {version}
GET /api/changes?cursor=1234 -> {changes:[...], nextCursor}
Dependency Maven cần dùng:
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>s3</artifactId>
<version>2.28.0</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.kafka</groupId>
<artifactId>spring-kafka</artifactId>
</dependency>
5.1. Xác định block còn thiếu
Phần logic dedup nên viết thuần Java để dễ test:
public class ChunkPlanner {
public List<String> missingBlocks(List<String> requested, Set<String> existing) {
return requested.stream()
.distinct()
.filter(hash -> !existing.contains(hash))
.toList();
}
}
Client gửi danh sách hash theo thứ tự. Server tra bảng blocks để lấy tập existing. Chỉ những hash chưa có mới cần upload. distinct() xử lý trường hợp một file chứa nhiều block giống nhau, ví dụ vùng toàn byte 0.
5.2. Cấp presigned URL
@RestController
@RequestMapping("/api/files")
public class UploadController {
private final S3Presigner presigner;
private final BlockRepository blockRepository;
private final ChunkPlanner planner = new ChunkPlanner();
public UploadController(S3Presigner presigner, BlockRepository blockRepository) {
this.presigner = presigner;
this.blockRepository = blockRepository;
}
@PostMapping("/{fileId}/uploads")
public UploadPlan initiate(@PathVariable UUID fileId, @RequestBody UploadRequest req) {
Set<String> existing = blockRepository.findExisting(req.blocks());
List<BlockUrl> urls = planner.missingBlocks(req.blocks(), existing).stream()
.map(hash -> new BlockUrl(hash, presign(hash)))
.toList();
return new UploadPlan(urls);
}
private String presign(String hash) {
return presigner.presignPutObject(r -> r
.signatureDuration(Duration.ofMinutes(15))
.putObjectRequest(o -> o.bucket("drive-blocks").key("blocks/" + hash)))
.url().toString();
}
}
record UploadRequest(int baseVersion, List<String> blocks) {}
record BlockUrl(String hash, String url) {}
record UploadPlan(List<BlockUrl> missing) {}
Với mỗi block thiếu, server ký một URL PUT có hiệu lực 15 phút. Client gửi byte thẳng lên S3, nên app server chỉ xử lý vài KB JSON thay vì hàng MB dữ liệu. Ví dụ phản hồi:
{"missing":[{"hash":"9f86d0...","url":"https://drive-blocks.s3.amazonaws.com/blocks/9f86d0...?X-Amz-Signature=..."}]}
Lưu ý: Vì key là hash, client có thể gửi nội dung không khớp với hash đã khai báo. Lúc commit, server phải kiểm tra lại, ví dụ bằng checksum
x-amz-checksum-sha256mà S3 hỗ trợ. Nếu bỏ qua bước này, một người dùng có thể “đầu độc” block dùng chung của người khác.
5.3. Test cho logic dedup
class ChunkPlannerTest {
private final ChunkPlanner planner = new ChunkPlanner();
@Test
void shouldReturnOnlyBlocksNotYetStored() {
List<String> requested = List.of("a1", "b2", "a1", "c3");
Set<String> existing = Set.of("b2");
assertThat(planner.missingBlocks(requested, existing))
.containsExactly("a1", "c3");
}
@Test
void shouldReturnEmptyWhenEverythingIsDeduplicated() {
assertThat(planner.missingBlocks(List.of("a1"), Set.of("a1"))).isEmpty();
}
}
Test thứ hai mô phỏng trường hợp hai người upload cùng một file cài đặt. Người thứ hai không phải gửi byte nào, và upload “tức thì”.
6. Các điểm khó và cách giải
6.1. Tranh chấp: hai thiết bị cùng sửa một file
Laptop và điện thoại cùng sửa từ version 7. Nếu áp dụng “ai đến sau thì ghi đè”, thay đổi của một bên sẽ mất. Cách giải là dùng optimistic locking: client gửi kèm baseVersion, và commit chỉ thành công khi head_version vẫn bằng giá trị đó.
@Service
public class CommitService {
private final JdbcTemplate jdbc;
public CommitService(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
@Transactional
public int commit(UUID fileId, long userId, int baseVersion, List<String> blocks, long size) {
int updated = jdbc.update("""
UPDATE files SET head_version = head_version + 1, size_bytes = ?
WHERE id = ? AND head_version = ? AND deleted_at IS NULL
""", size, fileId, baseVersion);
if (updated == 0) {
throw new VersionConflictException(fileId, baseVersion);
}
int newVersion = baseVersion + 1;
jdbc.update("INSERT INTO file_versions(file_id, version, block_list, created_by) VALUES (?,?,?,?)",
fileId, newVersion, blocks.toArray(String[]::new), userId);
jdbc.update("UPDATE blocks SET ref_count = ref_count + 1 WHERE hash = ANY(?)",
(Object) blocks.toArray(String[]::new));
jdbc.update("INSERT INTO change_journal(user_id, file_id, action) VALUES (?,?,'MODIFIED')",
userId, fileId);
return newVersion;
}
}
Câu UPDATE ... WHERE head_version = ? vừa kiểm tra vừa tăng version trong một thao tác nguyên tử. Bên commit sau nhận updated == 0. Lúc đó API trả 409 Conflict. Client không ghi đè mà lưu bản của mình thành file mới, ví dụ report (bản xung đột của Lan's Laptop).docx. Dropbox và Google Drive cũng xử lý file nhị phân theo cách này.
Không nên dùng lock bi quan (SELECT ... FOR UPDATE giữ suốt quá trình upload). Upload có thể kéo dài nhiều phút, và giữ lock lâu như vậy sẽ làm nghẽn DB. Việc chỉnh sửa cộng tác theo thời gian thực như Google Docs là một bài toán khác, cần OT hoặc CRDT, và nằm ngoài phạm vi bài này.
6.2. Nhất quán giữa DB, storage và các dịch vụ khác
Có hai câu hỏi cần trả lời.
Thứ nhất: block đã lên S3 nhưng commit thất bại thì sao? Không sao cả. Metadata chỉ trỏ tới block sau khi commit thành công. Block “mồ côi” có ref_count = 0 sẽ bị một job GC xoá sau 24 giờ. Thứ tự luôn là ghi byte trước, ghi metadata sau, nên không bao giờ có metadata trỏ tới dữ liệu chưa tồn tại.
Thứ hai: làm sao báo cho thiết bị khác, search index và dịch vụ thumbnail? Nếu gọi kafkaTemplate.send() ngay trong transaction, bạn gặp bài toán dual-write: DB rollback nhưng message đã đi, hoặc ngược lại. Cách giải là transactional outbox. Ghi sự kiện vào bảng outbox cùng transaction với commit, rồi một relay đẩy sự kiện sang Kafka:
@Component
public class OutboxRelay {
private final JdbcTemplate jdbc;
private final KafkaTemplate<String, String> kafka;
public OutboxRelay(JdbcTemplate jdbc, KafkaTemplate<String, String> kafka) {
this.jdbc = jdbc;
this.kafka = kafka;
}
@Scheduled(fixedDelay = 500)
@Transactional
public void publish() {
List<Map<String, Object>> rows = jdbc.queryForList("""
SELECT id, owner_id, payload FROM outbox
ORDER BY id LIMIT 100 FOR UPDATE SKIP LOCKED
""");
for (Map<String, Object> row : rows) {
kafka.send("file-events", row.get("owner_id").toString(), (String) row.get("payload")).join();
jdbc.update("DELETE FROM outbox WHERE id = ?", row.get("id"));
}
}
}
Giải thích các điểm chính:
FOR UPDATE SKIP LOCKEDcho phép chạy nhiều instance relay mà không gửi trùng cùng một batch.- Key Kafka là
owner_id, nên mọi sự kiện của một người dùng vào cùng partition và giữ đúng thứ tự. - Relay vẫn có thể gửi lặp, ví dụ khi crash sau
sendnhưng trướcDELETE. Vì vậy consumer phải idempotent: lưu(file_id, version)đã xử lý và bỏ qua bản trùng.
Thiết bị không cần nhận nội dung qua Kafka. Notification service chỉ đẩy một tín hiệu “có thay đổi” qua WebSocket. Thiết bị sau đó gọi GET /api/changes?cursor=... để đọc change_journal. Nếu thiết bị offline ba ngày, nó chỉ cần đọc tiếp từ cursor cũ, không mất sự kiện nào.
6.3. Tải đột biến
Có ba kịch bản thường gặp.
Một file chia sẻ công khai bị lan truyền, ví dụ đề thi được share cho hàng trăm nghìn người. Download đi qua CDN bằng signed URL. Block là bất biến vì key là hash, nên có thể cache vĩnh viễn (Cache-Control: immutable). Kết quả là origin gần như không chịu tải.
Đầu giờ sáng, hàng triệu máy đồng bộ cùng lúc. Client dùng exponential backoff có jitter. Server giới hạn tốc độ theo người dùng bằng Redis:
@Component
public class RateLimiter {
private final StringRedisTemplate redis;
public RateLimiter(StringRedisTemplate redis) {
this.redis = redis;
}
public boolean tryAcquire(long userId, int limitPerMinute) {
String key = "rl:%d:%d".formatted(userId, Instant.now().getEpochSecond() / 60);
Long count = redis.opsForValue().increment(key);
if (count != null && count == 1) {
redis.expire(key, Duration.ofSeconds(70));
}
return count != null && count <= limitPerMinute;
}
}
Mỗi người dùng có một bộ đếm cho mỗi phút. INCR là thao tác nguyên tử trên Redis, nên nhiều app server dùng chung bộ đếm vẫn đếm đúng. Khi vượt ngưỡng, API trả 429 Too Many Requests kèm header Retry-After. Fixed window cho phép burst gấp đôi ở ranh giới hai phút. Nếu cần chính xác hơn, hãy dùng sliding window hoặc token bucket viết bằng Lua script.
Một tài khoản có hàng triệu file. Shard PostgreSQL theo owner_id để toàn bộ cây thư mục của một người nằm trên cùng một shard. Nhờ vậy, đổi tên hay di chuyển thư mục chỉ là transaction cục bộ. Việc di chuyển chỉ đổi parent_id của một dòng, không đụng tới file con.
Lưu ý: Thư mục được share giữa các tài khoản sẽ nằm trên shard của chủ sở hữu. Người được share truy cập chéo shard qua Metadata Service, không join trực tiếp giữa các DB.
6.4. Những gì không nên làm
- Lưu file trong cột
BYTEA/BLOB. Backup phình to, replication chậm, và DB thành nút cổ chai. - Cho file đi qua app server. Băng thông và bộ nhớ của server tăng tuyến tính theo kích thước file.
- Xoá block ngay khi xoá file. Block có thể đang được file khác hoặc version khác dùng. Hãy luôn để GC dựa trên
ref_countvà soft delete vớideleted_atđể hỗ trợ thùng rác. - Dùng chunk kích thước cố định cho mọi loại file khi cần delta sync tốt. Chèn một byte vào đầu file sẽ làm lệch mọi block phía sau. Content-defined chunking (rolling hash) giải quyết được, nhưng phức tạp hơn. Chỉ áp dụng khi dữ liệu thực tế cho thấy lợi ích rõ ràng.
7. Kết luận
Thiết kế một hệ thống kiểu Google Drive xoay quanh một quyết định trung tâm: tách metadata (cơ sở dữ liệu quan hệ, nhất quán mạnh) khỏi nội dung (object storage, block bất biến định danh bằng hash). Từ đó, dedup, resume upload, lịch sử phiên bản và cache CDN gần như có sẵn.
Các điểm khó có lời giải khá chuẩn mực:
- Tranh chấp ghi: optimistic locking với
head_versionvà tạo bản xung đột thay vì ghi đè. - Nhất quán: ghi byte trước, commit metadata sau, kết hợp transactional outbox và consumer idempotent.
- Tải đột biến: presigned URL, CDN, rate limit bằng Redis và shard theo chủ sở hữu.
Khi dùng bài này làm khung cho phỏng vấn hoặc dự án thật, bạn hãy bắt đầu từ phép ước lượng quy mô ở mục 2. Mỗi con số ở đó quyết định một lựa chọn kiến trúc phía sau.