Перейти к основному содержимому
DevOpsMar 28, 2026

Deep EVM #26: Шардинг vs партиционирование — архитектура для огромных таблиц

OS
Open Soft Team

Engineering Team

Партиционирование vs шардинг: ключевые различия

Партиционирование и шардинг часто путают, но это принципиально разные подходы к масштабированию. Партиционирование делит таблицу на секции внутри одного сервера. Шардинг распределяет данные между несколькими серверами. Первое масштабирует производительность запросов, второе — объём данных и пропускную способность.

Когда одного сервера PostgreSQL перестаёт хватать — по объёму данных, по количеству записей в секунду или по количеству параллельных соединений — наступает время шардинга.

Когда партиционирования достаточно

Партиционирование решает проблемы:

  • Медленный VACUUM на больших таблицах
  • Неэффективные запросы с фильтром по дате
  • Необходимость удалять старые данные (DROP PARTITION вместо DELETE)
  • Раздутые индексы

Партиционирование НЕ решает:

  • Ограничение дискового пространства одного сервера
  • Ограничение write throughput одного сервера
  • Ограничение количества соединений

Стратегии шардинга

Application-level шардинг

Приложение само определяет, на какой сервер направить запрос:

struct ShardRouter {
    shards: Vec<PgPool>,
}

impl ShardRouter {
    fn shard_for_address(&self, address: &str) -> &PgPool {
        let hash = xxhash_rust::xxh64::xxh64(address.as_bytes(), 0);
        let shard_id = (hash % self.shards.len() as u64) as usize;
        &self.shards[shard_id]
    }

    async fn get_balance(
        &self, address: &str, token: &str
    ) -> Result<Balance> {
        let pool = self.shard_for_address(address);
        sqlx::query_as!(Balance,
            "SELECT * FROM balances WHERE address = $1 AND token = $2",
            address, token
        ).fetch_one(pool).await.map_err(Into::into)
    }
}

Плюсы: полный контроль, нет дополнительных зависимостей. Минусы: сложность управления, ребалансировка при добавлении шардов.

Consistent hashing

Для минимизации перемещения данных при добавлении шардов:

use std::collections::BTreeMap;

struct ConsistentHash {
    ring: BTreeMap<u64, usize>,  // hash -> shard_id
    replicas: usize,
}

impl ConsistentHash {
    fn new(shard_count: usize, replicas: usize) -> Self {
        let mut ring = BTreeMap::new();
        for shard_id in 0..shard_count {
            for replica in 0..replicas {
                let key = format!("shard-{}-replica-{}", shard_id, replica);
                let hash = xxhash_rust::xxh64::xxh64(key.as_bytes(), 0);
                ring.insert(hash, shard_id);
            }
        }
        Self { ring, replicas }
    }

    fn get_shard(&self, key: &str) -> usize {
        let hash = xxhash_rust::xxh64::xxh64(key.as_bytes(), 0);
        *self.ring.range(hash..).next()
            .unwrap_or_else(|| self.ring.iter().next().unwrap())
            .1
    }
}

При добавлении нового шарда перемещается только ~1/N данных.

Citus — расширение для распределённого PostgreSQL

Citus превращает PostgreSQL в распределённую базу данных:

-- На координаторе
CREATE EXTENSION citus;

-- Добавить рабочие узлы
SELECT citus_add_node('worker-1', 5432);
SELECT citus_add_node('worker-2', 5432);

-- Распределить таблицу
SELECT create_distributed_table('transactions', 'from_address');

Citus прозрачно маршрутизирует запросы и поддерживает распределённые JOIN’ы.

Выбор ключа шардинга

Выбор ключа шардинга — самое важное архитектурное решение:

КлючПлюсыМинусы
Адрес пользователяВсе данные пользователя на одном шардеHotspots для крупных аккаунтов
Hash транзакцииРавномерное распределениеCross-shard запросы для аналитики
ДатаПростая архивацияГорячая партиция для текущих данных
Chain IDИзоляция сетейНеравномерное распределение

Золотое правило: шардируйте по ключу, который чаще всего используется в WHERE.

Cross-shard запросы

Главная проблема шардинга — запросы, затрагивающие несколько шардов:

async fn global_total_volume(
    router: &ShardRouter,
) -> Result<f64> {
    let mut handles = Vec::new();

    for pool in &router.shards {
        let pool = pool.clone();
        handles.push(tokio::spawn(async move {
            sqlx::query_scalar!(
                "SELECT COALESCE(SUM(value), 0) FROM transactions"
            ).fetch_one(&pool).await
        }));
    }

    let mut total = 0.0;
    for handle in handles {
        total += handle.await??;
    }
    Ok(total)
}

Cross-shard запросы медленнее и сложнее. Минимизируйте их через правильный выбор ключа шардинга.

Read replicas как альтернатива

Прежде чем шардировать, рассмотрите read replicas:

struct DbRouting {
    writer: PgPool,      // Primary
    readers: Vec<PgPool>, // Replicas
    counter: AtomicUsize,
}

impl DbRouting {
    fn read_pool(&self) -> &PgPool {
        let idx = self.counter.fetch_add(1, Ordering::Relaxed)
            % self.readers.len();
        &self.readers[idx]
    }

    fn write_pool(&self) -> &PgPool {
        &self.writer
    }
}

Read replicas масштабируют чтение без сложности шардинга. Для многих приложений этого достаточно.

Архитектурная матрица решений

ПроблемаРешение
Медленные запросы по датеПартиционирование по range
Медленный VACUUMПартиционирование
Много чтений, мало записейRead replicas
Много записейШардинг
Огромный объём данныхШардинг + партиционирование
Аналитические запросыОтдельная OLAP-база (ClickHouse)

Комбинирование подходов

В реальных системах подходы комбинируются:

  1. Шардинг по chain_id — каждая сеть на своём сервере
  2. Партиционирование по дате внутри каждого шарда
  3. Read replicas для аналитических запросов
  4. Materialized views для агрегированных метрик

Эта архитектура масштабируется до сотен миллиардов записей.

Заключение

Шардинг — это архитектурное решение, которое нельзя легко отменить. Начинайте с партиционирования и read replicas. Переходите к шардингу только когда исчерпаны возможности вертикального масштабирования. И всегда выбирайте ключ шардинга, исходя из паттернов доступа к данным.