Задача звучит просто: "положить файл в папку, чтобы он появился на других устройствах". Но дьявол в деталях: 5 GB файлы, миллионы клиентов, конкурентные правки, офлайн-режим, экономия трафика. Разберём архитектуру.
Функциональные требования
- Клиент (desktop, web, mobile) загружает файлы в облако.
- Изменения синхронизируются на все устройства пользователя.
- Поддержка больших файлов (до 50 GB).
- Delta sync: изменилась одна страница в документе -- отправить только diff, а не весь файл.
- Шаринг файлов/папок с правами (view, edit).
- Версионирование: откатить файл на предыдущую версию.
- Дедупликация: если два пользователя загрузили один и тот же фильм, хранить один раз.
- Конфликты: два пользователя отредактировали один файл -> создать
filename (conflicted copy)и показать обоих. - Offline-режим: работа без сети, синхронизация при восстановлении.
Нефункциональные требования
- Durability: 11 девяток (как у S3). Потеря файла -- неприемлема.
- Availability: 99.99% для metadata service, 99.9% для block store.
- Throughput: до 1 Gbps upload per client, параллельно 100K активных клиентов на регион.
- Latency: propagation changes < 5s в нормальных условиях.
- Efficiency: типичное изменение документа -- изменение 1 страницы, трафик должен отразить это.
Оценки нагрузки
- 500M пользователей, средний объём 10 GB -> 5 PB хранилища (до учёта дедупликации).
- Дедупликация даёт обычно ~30% экономии -> ~3.5 PB.
- 100M DAU, средняя активность 10 изменений/день, средний diff 100 KB -> 1B изменений/день, 11K writes/sec avg, peak 50K/sec.
- Metadata: каждый файл -- запись ~500 байт. 500M пользователей * 1000 файлов среднем = 500B записей * 500 bytes = 250 TB метаданных. Шардируем по user_id.
- Block store: chunks по 4 MB. На 5 PB -> 1.25B chunks.
- Notifications: 100M concurrent WebSocket.
High-Level Architecture
┌───────────┐ ┌──────────────────┐
│ Desktop │ ──── upload ───── │ Upload Service │
│ Client │ ─── metadata ───► │ (блок загрузка) │
│ (file │ └────────┬─────────┘
│ watcher) │ │
└─────┬─────┘ │ write blocks
│ ▼
│ ┌──────────────────┐
│ │ Block Store │
│ │ (S3/GCS-like, │
│ │ content-addr) │
│ └──────────────────┘
│
│ metadata API
▼
┌─────────────────┐ commit ┌──────────────────┐
│ Metadata │ ─────────► │ Notification │
│ Service │ │ Service │
│ (PostgreSQL │ │ (WebSocket, │
│ sharded) │ │ Redis pub/sub) │
└────────┬────────┘ └────────┬─────────┘
│ │
│ │ push
▼ ▼
┌────────────────┐ ┌──────────────────┐
│ Version & │ │ Other devices │
│ Audit log │ │ of same user │
│ (WAL + DWH) │ │ (pull diff) │
└────────────────┘ └──────────────────┘
Ключевое разделение: metadata (дерево файлов, versions, ACL) и блоки (собственно байты). Metadata компактна, обрабатывается через транзакционную БД. Блоки -- content-addressable, идут в объектное хранилище.
API дизайн
Upload (chunked)
POST /api/v1/upload/session # create upload session
PATCH /api/v1/upload/session/{id} # upload chunk
POST /api/v1/upload/session/{id}/commit # finalize, create file version
Metadata
POST /api/v1/files # create file entry
GET /api/v1/files/{id}
PATCH /api/v1/files/{id} # rename, move
DELETE /api/v1/files/{id}
GET /api/v1/files/{id}/versions
POST /api/v1/files/{id}/restore/{ver}
GET /api/v1/folders/{id}/delta?cursor= # delta since cursor
DTO
<?php
declare(strict_types=1);
final readonly class ChunkUploadRequest
{
public function __construct(
public string $sessionId,
public int $offset,
public string $sha256, // of this chunk
public int $size,
public string $payload, // binary body
) {}
}
final readonly class CommitUploadRequest
{
/**
* @param list<string> $chunkHashes // ordered list of chunk sha256
*/
public function __construct(
public string $sessionId,
public string $path,
public string $fileSha256,
public int $totalSize,
public array $chunkHashes,
public ?string $parentVersionId = null, // for conflict detection
) {}
}
final readonly class FileMetadata
{
/**
* @param list<string> $chunkHashes
*/
public function __construct(
public string $id,
public string $path,
public int $size,
public string $sha256,
public array $chunkHashes,
public string $versionId,
public \DateTimeImmutable $modifiedAt,
public string $modifiedBy,
) {}
}
Metadata в Postgres, шардировано по user_id (или namespace_id -- более общо, т.к. есть teams / shared folders).
-- Namespace = personal account или shared team folder.
CREATE TABLE namespaces (
id UUID PRIMARY KEY,
owner_id UUID NOT NULL,
kind SMALLINT NOT NULL, -- 1=personal, 2=team, 3=shared
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- File node = inode. Path хранится отдельно через parent_id (дерево).
CREATE TABLE nodes (
id UUID PRIMARY KEY,
namespace_id UUID NOT NULL,
parent_id UUID,
name TEXT NOT NULL,
kind SMALLINT NOT NULL, -- 1=file, 2=folder
deleted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (namespace_id, parent_id, name) WHERE deleted_at IS NULL
);
CREATE INDEX idx_nodes_ns_parent ON nodes (namespace_id, parent_id) WHERE deleted_at IS NULL;
-- File version = immutable snapshot. Active version = latest by modified_at.
CREATE TABLE file_versions (
id UUID PRIMARY KEY,
node_id UUID NOT NULL REFERENCES nodes(id),
namespace_id UUID NOT NULL,
size BIGINT NOT NULL,
sha256 TEXT NOT NULL,
chunk_hashes TEXT[] NOT NULL, -- ordered
modified_at TIMESTAMPTZ NOT NULL,
modified_by UUID NOT NULL,
parent_version UUID -- для конфликтов
);
CREATE INDEX idx_versions_node_time ON file_versions (node_id, modified_at DESC);
-- Block dedup table. Ключ -- sha256, значение -- ссылка на объект в S3.
CREATE TABLE blocks (
sha256 TEXT PRIMARY KEY,
size INT NOT NULL,
object_key TEXT NOT NULL, -- путь в S3 / GCS
ref_count BIGINT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Delta log: cursor-based sync.
CREATE TABLE change_log (
seq BIGSERIAL PRIMARY KEY,
namespace_id UUID NOT NULL,
node_id UUID NOT NULL,
change_type SMALLINT NOT NULL, -- 1=created, 2=modified, 3=deleted, 4=moved
version_id UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
) PARTITION BY RANGE (created_at);
CREATE INDEX idx_changelog_ns ON change_log (namespace_id, seq);
-- Sharing / ACL.
CREATE TABLE acl (
node_id UUID NOT NULL,
subject_id UUID NOT NULL, -- user or group
permission SMALLINT NOT NULL, -- 1=view, 2=comment, 3=edit
PRIMARY KEY (node_id, subject_id)
);
Block store -- S3-совместимый. Ключ блока = blocks/{sha256[0:2]}/{sha256[2:4]}/{sha256}. Двухуровневый префикс распределяет по partition'ам.
Ключевые компоненты
1. Chunking и content-addressable storage
Клиент бьёт файл на чанки фиксированного размера (4 MB у Dropbox, 8 MB у Google Drive) или через content-defined chunking (Rabin fingerprint, как в rsync/restic). CDC даёт лучшую дедупликацию при вставках в середину файла.
Каждый чанк хэшируется SHA-256. При загрузке:
- Клиент шлёт
HEAD /blocks/{sha}-- существует ли уже? - Если да -- не грузить, использовать ссылку.
- Если нет -- залить.
Это и есть дедупликация. У Dropbox это дало 30-40% экономии хранилища.
package chunker
import (
"crypto/sha256"
"encoding/hex"
"io"
)
const ChunkSize = 4 * 1024 * 1024 // 4 MB
// Chunk represents a single content-addressable block.
type Chunk struct {
SHA256 string
Data []byte
Size int
}
// SplitFile reads a stream and yields fixed-size chunks.
// For CDC (content-defined chunking), replace with a rolling-hash splitter.
func SplitFile(r io.Reader) ([]Chunk, error) {
chunks := make([]Chunk, 0, 16)
buf := make([]byte, ChunkSize)
for {
n, err := io.ReadFull(r, buf)
if n > 0 {
sum := sha256.Sum256(buf[:n])
chunk := Chunk{
SHA256: hex.EncodeToString(sum[:]),
Data: append([]byte(nil), buf[:n]...),
Size: n,
}
chunks = append(chunks, chunk)
}
if err == io.EOF || err == io.ErrUnexpectedEOF {
return chunks, nil
}
if err != nil {
return nil, err
}
}
}
// DedupUploader uploads only missing blocks and returns the ordered hash list.
type BlockAPI interface {
Exists(sha string) (bool, error)
Upload(sha string, data []byte) error
}
func Upload(api BlockAPI, chunks []Chunk) ([]string, error) {
hashes := make([]string, len(chunks))
for i, c := range chunks {
exists, err := api.Exists(c.SHA256)
if err != nil {
return nil, err
}
if !exists {
if err := api.Upload(c.SHA256, c.Data); err != nil {
return nil, err
}
}
hashes[i] = c.SHA256
}
return hashes, nil
}
Сервер хранит предыдущий список хэшей oldHashes. Клиент после изменения файла вычисляет новый newHashes. Посылает на сервер только те чанки, которые отсутствуют в block store. Обычно меняется 1-2 чанка из 1000 -- трафик на уровне килобайтов.
Для бинарных файлов (DOCX, ZIP), где вставка сдвигает всё -- CDC с rolling hash (как в rsync). Dropbox и Google Drive используют его вариант.
3. Commit transaction
Commit -- это момент, когда клиент говорит "мои чанки залиты, зафиксируй новую версию":
<?php
declare(strict_types=1);
final class UploadCommitService
{
public function __construct(
private readonly \PDO $db,
private readonly BlockStore $blocks,
private readonly ChangeLogPublisher $publisher,
) {}
public function commit(CommitUploadRequest $req, string $userId, string $namespaceId): FileMetadata
{
$this->db->beginTransaction();
try {
// 1. Verify all chunks exist in block store.
$missing = $this->blocks->missingChunks($req->chunkHashes);
if (!empty($missing)) {
throw new \DomainException('chunks missing: ' . implode(',', $missing));
}
// 2. Conflict detection: latest version must match parent_version_id.
$node = $this->findOrCreateNode($namespaceId, $req->path);
$latest = $this->db->prepare(
'SELECT id FROM file_versions WHERE node_id = :nid ORDER BY modified_at DESC LIMIT 1'
);
$latest->execute(['nid' => $node['id']]);
$latestId = $latest->fetchColumn();
if ($latestId !== false && $latestId !== $req->parentVersionId) {
// Conflict: create parallel version with "(conflicted copy)" suffix
$node = $this->createConflictedCopy($node, $namespaceId, $userId);
}
// 3. Insert version.
$versionId = $this->insertVersion(
nodeId: $node['id'],
namespaceId: $namespaceId,
size: $req->totalSize,
sha256: $req->fileSha256,
chunks: $req->chunkHashes,
userId: $userId,
);
// 4. Bump refcounts.
$this->blocks->incrementRefs($req->chunkHashes);
// 5. Append to change log.
$this->publisher->publish($namespaceId, $node['id'], ChangeType::Modified, $versionId);
$this->db->commit();
return $this->buildMetadata($node, $versionId, $req);
} catch (\Throwable $e) {
$this->db->rollBack();
throw $e;
}
}
}
4. Conflict resolution
Модель -- optimistic concurrency через parent_version_id. Клиент при upload указывает версию, от которой он редактировал. Если серверная latest не совпадает -- конфликт. Разрешение: создать второй параллельный файл "report (conflicted copy from device X, 2026-04-18).docx". Оба файла живут, пользователь сам разбирается.
Для текстовых / collaborative документов -- это не работает, нужен OT/CRDT (см. Google Docs кейс). Dropbox изначально не занимается co-editing, это инструмент sync'a.
5. Notification / file watcher
Клиент поддерживает постоянный WebSocket с Notification Service. После commit сервер публикует событие {"namespace_id": ..., "node_id": ..., "version": ...} в Redis pub/sub, на которое подписаны активные сессии. Клиент, получив событие, делает GET /folders/{id}/delta?cursor=<last_seq> и подтягивает изменения.
Если клиент был offline -- при подключении просто берёт delta с последнего cursor'а. Change log partitioned by month, старые записи compact'ятся (схлопываются в effective state).
6. GC для чанков
Как только ref_count блока падает до 0 -- он кандидат на удаление. GC:
- Background job выбирает blocks где ref_count = 0 старше 7 дней.
- Удаляет объекты из S3.
- Удаляет строку из
blocks.
7 дней retention -- защита от race (версия ещё не закоммичена, но chunks уже залиты; если упасть между upload и commit, chunks окажутся orphan на сутки, пока не пройдёт GC window).
7. Sharing
ACL на уровне ноды. При shared folder все её потомки наследуют ACL (через родительскую ссылку при resolve). Для эффективности можно материализовать: хранить effective_acl на каждой ноде и обновлять при изменениях.
Publicshare (ссылка "любой с ссылкой может смотреть") -- отдельная сущность public_share (token, node_id, permission, expires_at).
Масштабирование и bottlenecks
Bottleneck 1: metadata PostgreSQL
При 500B записей один кластер не держит. Шардируем по namespace_id (все файлы одного юзера -- один shard). Cross-shard запросы редки (шаринг между пользователями нуждается в distributed read, но это 0.1% запросов).
Bottleneck 2: block store
S3 сам масштабируется. Надо только следить за шардированием по prefix (двухуровневый hash prefix даёт равномерное распределение).
Bottleneck 3: large file upload
50 GB файл upload'ится часами. Решения:
- Resumable upload: session с offset, клиент возобновляет с последнего подтверждённого байта.
- Parallel chunks: 10+ параллельных chunks.
- Direct-to-S3: клиент получает presigned URL и льёт напрямую в S3, минуя upload-сервер.
Bottleneck 4: notification fan-out
Пользователь работает на 3 устройствах -> на каждую коммит надо отправить 3 сообщения. Для команды из 100 чел с shared folder -- 300 сообщений на каждое изменение. Redis pub/sub справляется, но при 10M активных WebSocket конексий -- нужны sharded pub/sub и haproxy-level sticky-routing.
Bottleneck 5: hot namespaces (team with 10K members)
Все изменения одной огромной папки -> hot partition в change_log. Решения:
- партиционирование change_log по namespace_id (а не только по времени);
- rate limiting на клиенте (batch polls).
Trade-offs и альтернативы
| Решение | Плюс | Минус |
|---|---|---|
| Fixed-size chunking (4MB) | Простой, предсказуемый | Плохой dedup при вставках |
| CDC (Rabin) | Лучший dedup | Сложнее, больше CPU |
| SHA-256 | Широко поддерживается | Сложнее blake2 |
| Optimistic concurrency | Просто, работает для sync | Конфликты у real-time users |
| Change log cursor | Устойчив к offline | Нужен GC старых записей |
| WebSocket notifications | Низкая latency | 10M соединений -- дорого |
| Long-polling notifications | Проще | Выше latency |
| S3-compatible block store | Дёшево, масштабируемо | Eventually consistent (исправлено с 2020) |
Альтернатива sync-клиенту -- FUSE / virtual drive (Google Drive File Stream): файлы на диске только как "заглушки", скачиваются on-demand. Экономит место, но медленнее первое открытие.
Для real-time co-editing нужна другая архитектура (см. Google Docs кейс). Dropbox Paper и Google Docs -- это надстройка над sync, а не замена.
Выводы
Основная идея -- разделить immutable content (чанки, адресуемые хэшом) и мутабельные метаданные (дерево, версии, ACL). Это даёт дедупликацию, версионирование, delta sync и cheap branching для конфликтов. Sync сводится к "отправь только новые хэши + обнови metadata". Notifications + cursor-based delta позволяют клиентам работать offline и быстро догоняться. Bottleneck -- metadata shard-инг; block store масштабируется почти линейно в S3.