HardПрактика14 min

Dropbox / Google Drive: файловая синхронизация

Проектирование сервиса синхронизации файлов: chunking, дедупликация, delta-sync, conflict resolution, шаринг, нотификации

Задача звучит просто: "положить файл в папку, чтобы он появился на других устройствах". Но дьявол в деталях: 5 GB файлы, миллионы клиентов, конкурентные правки, офлайн-режим, экономия трафика. Разберём архитектуру.

Функциональные требования

  1. Клиент (desktop, web, mobile) загружает файлы в облако.
  2. Изменения синхронизируются на все устройства пользователя.
  3. Поддержка больших файлов (до 50 GB).
  4. Delta sync: изменилась одна страница в документе -- отправить только diff, а не весь файл.
  5. Шаринг файлов/папок с правами (view, edit).
  6. Версионирование: откатить файл на предыдущую версию.
  7. Дедупликация: если два пользователя загрузили один и тот же фильм, хранить один раз.
  8. Конфликты: два пользователя отредактировали один файл -> создать filename (conflicted copy) и показать обоих.
  9. Offline-режим: работа без сети, синхронизация при восстановлении.

Нефункциональные требования

  1. Durability: 11 девяток (как у S3). Потеря файла -- неприемлема.
  2. Availability: 99.99% для metadata service, 99.9% для block store.
  3. Throughput: до 1 Gbps upload per client, параллельно 100K активных клиентов на регион.
  4. Latency: propagation changes < 5s в нормальных условиях.
  5. 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. При загрузке:

  1. Клиент шлёт HEAD /blocks/{sha} -- существует ли уже?
  2. Если да -- не грузить, использовать ссылку.
  3. Если нет -- залить.

Это и есть дедупликация. У 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
}
### 2. Delta sync

Сервер хранит предыдущий список хэшей 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;
        }
    }
}
Важно: **chunk refcount** увеличивается в той же транзакции, что и version. Если commit откатился -- никто не держит ссылок на чанки, они попадут в GC.

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:

  1. Background job выбирает blocks где ref_count = 0 старше 7 дней.
  2. Удаляет объекты из S3.
  3. Удаляет строку из 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.