Deep EVM #26: Шардинг vs партиционирование — архитектура для огромных таблиц
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) |
Комбинирование подходов
В реальных системах подходы комбинируются:
- Шардинг по chain_id — каждая сеть на своём сервере
- Партиционирование по дате внутри каждого шарда
- Read replicas для аналитических запросов
- Materialized views для агрегированных метрик
Эта архитектура масштабируется до сотен миллиардов записей.
Заключение
Шардинг — это архитектурное решение, которое нельзя легко отменить. Начинайте с партиционирования и read replicas. Переходите к шардингу только когда исчерпаны возможности вертикального масштабирования. И всегда выбирайте ключ шардинга, исходя из паттернов доступа к данным.