architecture

Шардирование

Горизонтальное разделение данных для масштабирования баз данных

#scaling #sharding #databases #partitioning

Шардирование

Шардирование (sharding) — техника горизонтального разделения данных между несколькими базами данных для масштабирования записи и хранения.

Основные концепции

Что такое шард?

interface Shard {
  id: string;
  host: string;
  port: number;
  database: string;
  // Диапазон данных, за который отвечает шард
  range?: {
    min: any;
    max: any;
  };
}

const shards: Shard[] = [
  {
    id: 'shard-1',
    host: 'db1.example.com',
    port: 5432,
    database: 'users_shard_1',
    range: { min: 0, max: 999999 }
  },
  {
    id: 'shard-2',
    host: 'db2.example.com',
    port: 5432,
    database: 'users_shard_2',
    range: { min: 1000000, max: 1999999 }
  },
  {
    id: 'shard-3',
    host: 'db3.example.com',
    port: 5432,
    database: 'users_shard_3',
    range: { min: 2000000, max: 2999999 }
  }
];

Стратегии шардирования

1. Range-based Sharding

Разделение по диапазонам значений ключа.

class RangeBasedSharding {
  constructor(private shards: Shard[]) {}
  
  getShardForUser(userId: number): Shard {
    for (const shard of this.shards) {
      if (userId >= shard.range!.min && userId <= shard.range!.max) {
        return shard;
      }
    }
    throw new Error(`No shard found for user ${userId}`);
  }
  
  async getUser(userId: number): Promise<User> {
    const shard = this.getShardForUser(userId);
    const connection = await this.connectToShard(shard);
    
    return connection.query(
      'SELECT * FROM users WHERE id = ?',
      [userId]
    );
  }
  
  async createUser(user: User): Promise<User> {
    const shard = this.getShardForUser(user.id);
    const connection = await this.connectToShard(shard);
    
    await connection.query(
      'INSERT INTO users (id, name, email) VALUES (?, ?, ?)',
      [user.id, user.name, user.email]
    );
    
    return user;
  }
  
  private async connectToShard(shard: Shard) {
    return createConnection({
      host: shard.host,
      port: shard.port,
      database: shard.database
    });
  }
}

Преимущества:

  • Простая реализация
  • Легко добавлять новые диапазоны
  • Эффективные range queries

Недостатки:

  • Неравномерное распределение данных
  • Hotspots (горячие шарды)

2. Hash-based Sharding

Разделение по хэшу ключа.

class HashBasedSharding {
  constructor(private shards: Shard[]) {}
  
  private hash(key: string): number {
    let hash = 0;
    for (let i = 0; i < key.length; i++) {
      const char = key.charCodeAt(i);
      hash = ((hash << 5) - hash) + char;
      hash = hash & hash; // Convert to 32-bit integer
    }
    return Math.abs(hash);
  }
  
  getShardForKey(key: string): Shard {
    const hashValue = this.hash(key);
    const shardIndex = hashValue % this.shards.length;
    return this.shards[shardIndex];
  }
  
  async getUser(userId: string): Promise<User> {
    const shard = this.getShardForKey(userId);
    const connection = await this.connectToShard(shard);
    
    return connection.query(
      'SELECT * FROM users WHERE id = ?',
      [userId]
    );
  }
  
  async getUserOrders(userId: string): Promise<Order[]> {
    // Все заказы пользователя в одном шарде
    const shard = this.getShardForKey(userId);
    const connection = await this.connectToShard(shard);
    
    return connection.query(
      'SELECT * FROM orders WHERE user_id = ?',
      [userId]
    );
  }
  
  private async connectToShard(shard: Shard) {
    return createConnection({
      host: shard.host,
      port: shard.port,
      database: shard.database
    });
  }
}

Преимущества:

  • Равномерное распределение данных
  • Нет hotspots

Недостатки:

  • Сложно делать range queries
  • Проблемы при добавлении шардов (rehashing)

3. Consistent Hashing

Решение проблемы rehashing.

class ConsistentHashing {
  private ring: Map<number, Shard> = new Map();
  private virtualNodes = 150; // Виртуальных узлов на шард
  
  constructor(private shards: Shard[]) {
    this.buildRing();
  }
  
  private buildRing(): void {
    for (const shard of this.shards) {
      // Создаем виртуальные узлы для каждого шарда
      for (let i = 0; i < this.virtualNodes; i++) {
        const virtualKey = `${shard.id}:${i}`;
        const hash = this.hash(virtualKey);
        this.ring.set(hash, shard);
      }
    }
  }
  
  private hash(key: string): number {
    let hash = 0;
    for (let i = 0; i < key.length; i++) {
      const char = key.charCodeAt(i);
      hash = ((hash << 5) - hash) + char;
      hash = hash & hash;
    }
    return Math.abs(hash);
  }
  
  getShardForKey(key: string): Shard {
    const keyHash = this.hash(key);
    
    // Находим ближайший узел на кольце
    const sortedHashes = Array.from(this.ring.keys()).sort((a, b) => a - b);
    
    for (const hash of sortedHashes) {
      if (hash >= keyHash) {
        return this.ring.get(hash)!;
      }
    }
    
    // Если не нашли, возвращаем первый узел (кольцо замыкается)
    return this.ring.get(sortedHashes[0])!;
  }
  
  addShard(shard: Shard): void {
    this.shards.push(shard);
    
    // Добавляем виртуальные узлы для нового шарда
    for (let i = 0; i < this.virtualNodes; i++) {
      const virtualKey = `${shard.id}:${i}`;
      const hash = this.hash(virtualKey);
      this.ring.set(hash, shard);
    }
  }
  
  removeShard(shardId: string): void {
    // Удаляем все виртуальные узлы шарда
    const toRemove: number[] = [];
    
    for (const [hash, shard] of this.ring.entries()) {
      if (shard.id === shardId) {
        toRemove.push(hash);
      }
    }
    
    toRemove.forEach(hash => this.ring.delete(hash));
    this.shards = this.shards.filter(s => s.id !== shardId);
  }
}

4. Geographic Sharding

Разделение по географическому признаку.

enum Region {
  US_EAST = 'us-east',
  US_WEST = 'us-west',
  EU = 'eu',
  ASIA = 'asia'
}

class GeographicSharding {
  private shardsByRegion = new Map<Region, Shard>();
  
  constructor(shards: Array<{ region: Region; shard: Shard }>) {
    shards.forEach(({ region, shard }) => {
      this.shardsByRegion.set(region, shard);
    });
  }
  
  getShardForUser(userRegion: Region): Shard {
    const shard = this.shardsByRegion.get(userRegion);
    if (!shard) {
      throw new Error(`No shard for region ${userRegion}`);
    }
    return shard;
  }
  
  async getUser(userId: string, region: Region): Promise<User> {
    const shard = this.getShardForUser(region);
    const connection = await this.connectToShard(shard);
    
    return connection.query(
      'SELECT * FROM users WHERE id = ?',
      [userId]
    );
  }
  
  private async connectToShard(shard: Shard) {
    return createConnection({
      host: shard.host,
      port: shard.port,
      database: shard.database
    });
  }
}

Shard Key выбор

Хороший Shard Key

// ✅ Хорошо: user_id как shard key
// - Высокая кардинальность
// - Равномерное распределение
// - Запросы обычно по одному пользователю
class UserSharding {
  getShardKey(user: User): string {
    return user.id; // UUID или auto-increment
  }
}

// ✅ Хорошо: tenant_id для multi-tenant приложений
class TenantSharding {
  getShardKey(data: any): string {
    return data.tenantId; // Все данные тенанта в одном шарде
  }
}

Плохой Shard Key

// ❌ Плохо: timestamp как shard key
// - Все новые записи идут в один шард (hotspot)
class BadTimestampSharding {
  getShardKey(data: any): number {
    return Math.floor(data.timestamp / 86400000); // День
  }
}

// ❌ Плохо: status как shard key
// - Низкая кардинальность
// - Неравномерное распределение
class BadStatusSharding {
  getShardKey(order: Order): string {
    return order.status; // 'pending', 'completed', 'cancelled'
  }
}

// ❌ Плохо: country как shard key для глобального приложения
// - Очень неравномерное распределение
class BadCountrySharding {
  getShardKey(user: User): string {
    return user.country; // US будет огромным, другие маленькими
  }
}

Работа с шардированными данными

Запросы к одному шарду

class ShardedUserService {
  constructor(private sharding: HashBasedSharding) {}
  
  // Простой запрос - один шард
  async getUser(userId: string): Promise<User> {
    const shard = this.sharding.getShardForKey(userId);
    const connection = await this.connectToShard(shard);
    
    return connection.query(
      'SELECT * FROM users WHERE id = ?',
      [userId]
    );
  }
  
  // Связанные данные - один шард (если правильно спроектировано)
  async getUserWithOrders(userId: string): Promise<UserWithOrders> {
    const shard = this.sharding.getShardForKey(userId);
    const connection = await this.connectToShard(shard);
    
    const user = await connection.query(
      'SELECT * FROM users WHERE id = ?',
      [userId]
    );
    
    const orders = await connection.query(
      'SELECT * FROM orders WHERE user_id = ?',
      [userId]
    );
    
    return { ...user, orders };
  }
  
  private async connectToShard(shard: Shard) {
    return createConnection({
      host: shard.host,
      port: shard.port,
      database: shard.database
    });
  }
}

Scatter-Gather запросы

class ScatterGatherQuery {
  constructor(private shards: Shard[]) {}
  
  // Запрос ко всем шардам
  async getAllUsers(): Promise<User[]> {
    const promises = this.shards.map(async (shard) => {
      const connection = await this.connectToShard(shard);
      return connection.query('SELECT * FROM users');
    });
    
    const results = await Promise.all(promises);
    
    // Объединяем результаты
    return results.flat();
  }
  
  // Поиск с пагинацией
  async searchUsers(
    query: string,
    limit: number,
    offset: number
  ): Promise<User[]> {
    // Запрашиваем больше данных с каждого шарда
    const perShardLimit = limit + offset;
    
    const promises = this.shards.map(async (shard) => {
      const connection = await this.connectToShard(shard);
      return connection.query(
        'SELECT * FROM users WHERE name LIKE ? LIMIT ?',
        [`%${query}%`, perShardLimit]
      );
    });
    
    const results = await Promise.all(promises);
    
    // Объединяем и сортируем
    const allUsers = results.flat();
    allUsers.sort((a, b) => a.name.localeCompare(b.name));
    
    // Применяем пагинацию
    return allUsers.slice(offset, offset + limit);
  }
  
  // Агрегация
  async getTotalUserCount(): Promise<number> {
    const promises = this.shards.map(async (shard) => {
      const connection = await this.connectToShard(shard);
      const result = await connection.query(
        'SELECT COUNT(*) as count FROM users'
      );
      return result.count;
    });
    
    const counts = await Promise.all(promises);
    return counts.reduce((sum, count) => sum + count, 0);
  }
  
  private async connectToShard(shard: Shard) {
    return createConnection({
      host: shard.host,
      port: shard.port,
      database: shard.database
    });
  }
}

Транзакции в шардированной среде

Локальные транзакции

class ShardedTransactions {
  async updateUserInShard(userId: string, updates: Partial<User>): Promise<void> {
    const shard = this.sharding.getShardForKey(userId);
    const connection = await this.connectToShard(shard);
    
    // Транзакция внутри одного шарда - работает нормально
    await connection.transaction(async (tx) => {
      await tx.query(
        'UPDATE users SET name = ? WHERE id = ?',
        [updates.name, userId]
      );
      
      await tx.query(
        'INSERT INTO user_audit_log (user_id, action) VALUES (?, ?)',
        [userId, 'name_updated']
      );
    });
  }
}

Распределенные транзакции (избегать!)

// ❌ Плохо: распределенная транзакция
class DistributedTransaction {
  async transferMoney(
    fromUserId: string,
    toUserId: string,
    amount: number
  ): Promise<void> {
    const fromShard = this.sharding.getShardForKey(fromUserId);
    const toShard = this.sharding.getShardForKey(toUserId);
    
    if (fromShard.id !== toShard.id) {
      // Требуется 2PC (Two-Phase Commit) - медленно и сложно
      throw new Error('Cross-shard transactions not supported');
    }
    
    // Если в одном шарде - обычная транзакция
    const connection = await this.connectToShard(fromShard);
    await connection.transaction(async (tx) => {
      await tx.query(
        'UPDATE accounts SET balance = balance - ? WHERE user_id = ?',
        [amount, fromUserId]
      );
      
      await tx.query(
        'UPDATE accounts SET balance = balance + ? WHERE user_id = ?',
        [amount, toUserId]
      );
    });
  }
}

Saga Pattern для распределенных операций

class TransferSaga {
  async transferMoney(
    fromUserId: string,
    toUserId: string,
    amount: number
  ): Promise<void> {
    const transferId = generateId();
    
    try {
      // Шаг 1: Резервируем деньги у отправителя
      await this.reserveMoney(fromUserId, amount, transferId);
      
      // Шаг 2: Добавляем деньги получателю
      await this.addMoney(toUserId, amount, transferId);
      
      // Шаг 3: Подтверждаем списание
      await this.confirmWithdrawal(fromUserId, transferId);
      
    } catch (error) {
      // Компенсирующие транзакции
      await this.cancelReservation(fromUserId, transferId);
      throw error;
    }
  }
  
  private async reserveMoney(
    userId: string,
    amount: number,
    transferId: string
  ): Promise<void> {
    const shard = this.sharding.getShardForKey(userId);
    const connection = await this.connectToShard(shard);
    
    await connection.query(
      'UPDATE accounts SET balance = balance - ?, reserved = reserved + ? WHERE user_id = ?',
      [amount, amount, userId]
    );
    
    await connection.query(
      'INSERT INTO pending_transfers (id, user_id, amount, status) VALUES (?, ?, ?, ?)',
      [transferId, userId, amount, 'reserved']
    );
  }
  
  private async addMoney(
    userId: string,
    amount: number,
    transferId: string
  ): Promise<void> {
    const shard = this.sharding.getShardForKey(userId);
    const connection = await this.connectToShard(shard);
    
    await connection.query(
      'UPDATE accounts SET balance = balance + ? WHERE user_id = ?',
      [amount, userId]
    );
  }
  
  private async confirmWithdrawal(
    userId: string,
    transferId: string
  ): Promise<void> {
    const shard = this.sharding.getShardForKey(userId);
    const connection = await this.connectToShard(shard);
    
    await connection.query(
      'UPDATE accounts SET reserved = reserved - (SELECT amount FROM pending_transfers WHERE id = ?) WHERE user_id = ?',
      [transferId, userId]
    );
    
    await connection.query(
      'UPDATE pending_transfers SET status = ? WHERE id = ?',
      ['completed', transferId]
    );
  }
  
  private async cancelReservation(
    userId: string,
    transferId: string
  ): Promise<void> {
    const shard = this.sharding.getShardForKey(userId);
    const connection = await this.connectToShard(shard);
    
    await connection.query(
      'UPDATE accounts SET balance = balance + (SELECT amount FROM pending_transfers WHERE id = ?), reserved = reserved - (SELECT amount FROM pending_transfers WHERE id = ?) WHERE user_id = ?',
      [transferId, transferId, userId]
    );
    
    await connection.query(
      'UPDATE pending_transfers SET status = ? WHERE id = ?',
      ['cancelled', transferId]
    );
  }
  
  private async connectToShard(shard: Shard) {
    return createConnection({
      host: shard.host,
      port: shard.port,
      database: shard.database
    });
  }
}

Решардирование

Добавление нового шарда

class ReshardingManager {
  async addShard(newShard: Shard): Promise<void> {
    // 1. Добавляем новый шард в конфигурацию
    this.shards.push(newShard);
    
    // 2. Создаем схему в новом шарде
    await this.createSchema(newShard);
    
    // 3. Мигрируем данные
    await this.migrateData(newShard);
    
    // 4. Обновляем роутинг
    this.updateRouting();
  }
  
  private async migrateData(targetShard: Shard): Promise<void> {
    // Определяем, какие данные нужно мигрировать
    const dataToMigrate = await this.identifyDataToMigrate(targetShard);
    
    for (const { sourceShard, keys } of dataToMigrate) {
      const sourceConn = await this.connectToShard(sourceShard);
      const targetConn = await this.connectToShard(targetShard);
      
      // Мигрируем батчами
      for (let i = 0; i < keys.length; i += 1000) {
        const batch = keys.slice(i, i + 1000);
        
        // Читаем из источника
        const data = await sourceConn.query(
          `SELECT * FROM users WHERE id IN (${batch.map(() => '?').join(',')})`,
          batch
        );
        
        // Пишем в целевой шард
        await targetConn.query(
          `INSERT INTO users (id, name, email) VALUES ${data.map(() => '(?, ?, ?)').join(',')}`,
          data.flatMap(u => [u.id, u.name, u.email])
        );
        
        // Удаляем из источника (опционально, после проверки)
        // await sourceConn.query(
        //   `DELETE FROM users WHERE id IN (${batch.map(() => '?').join(',')})`,
        //   batch
        // );
      }
    }
  }
  
  private async identifyDataToMigrate(
    targetShard: Shard
  ): Promise<Array<{ sourceShard: Shard; keys: string[] }>> {
    // Логика определения данных для миграции
    // зависит от стратегии шардирования
    return [];
  }
  
  private async connectToShard(shard: Shard) {
    return createConnection({
      host: shard.host,
      port: shard.port,
      database: shard.database
    });
  }
  
  private updateRouting(): void {
    // Обновляем маршрутизацию запросов
  }
  
  private async createSchema(shard: Shard): Promise<void> {
    const connection = await this.connectToShard(shard);
    await connection.query(`
      CREATE TABLE IF NOT EXISTS users (
        id VARCHAR(36) PRIMARY KEY,
        name VARCHAR(255),
        email VARCHAR(255),
        created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
      )
    `);
  }
}

Best Practices

1. Проектирование схемы

// ✅ Хорошо: денормализация для избежания cross-shard запросов
interface UserShard {
  id: string;
  name: string;
  email: string;
  // Денормализованные данные
  totalOrders: number;
  lastOrderDate: Date;
}

interface OrderShard {
  id: string;
  userId: string; // Shard key
  // Денормализованные данные пользователя
  userName: string;
  userEmail: string;
  items: OrderItem[];
  total: number;
}

2. Мониторинг

class ShardMonitor {
  async getShardMetrics(): Promise<ShardMetrics[]> {
    const metrics = await Promise.all(
      this.shards.map(async (shard) => {
        const connection = await this.connectToShard(shard);
        
        const [size, count, load] = await Promise.all([
          connection.query('SELECT pg_database_size(current_database())'),
          connection.query('SELECT COUNT(*) FROM users'),
          connection.query('SELECT * FROM pg_stat_activity')
        ]);
        
        return {
          shardId: shard.id,
          size: size.pg_database_size,
          recordCount: count.count,
          activeConnections: load.length,
          timestamp: new Date()
        };
      })
    );
    
    return metrics;
  }
  
  detectHotspots(metrics: ShardMetrics[]): string[] {
    const avgLoad = metrics.reduce((sum, m) => sum + m.activeConnections, 0) / metrics.length;
    
    return metrics
      .filter(m => m.activeConnections > avgLoad * 1.5)
      .map(m => m.shardId);
  }
  
  private async connectToShard(shard: Shard) {
    return createConnection({
      host: shard.host,
      port: shard.port,
      database: shard.database
    });
  }
}

interface ShardMetrics {
  shardId: string;
  size: number;
  recordCount: number;
  activeConnections: number;
  timestamp: Date;
}

Заключение

Шардирование — мощная техника масштабирования, но с компромиссами:

Преимущества:

  • Практически неограниченное масштабирование
  • Распределение нагрузки
  • Изоляция отказов

Недостатки:

  • Сложность реализации
  • Проблемы с cross-shard запросами
  • Сложность транзакций
  • Необходимость решардирования

Когда использовать:

  • Данные не помещаются на один сервер
  • Нагрузка на запись превышает возможности одного сервера
  • Есть естественный shard key (user_id, tenant_id)