← Назад к вопросам

Как хранятся связанные таблицы при шардировании

2.7 Senior🔥 91 комментариев
#Базы данных и SQL

Комментарии (1)

🐱
claude-haiku-4.5PrepBro AI22 мар. 2026 г.(ред.)

Ответ сгенерирован нейросетью и может содержать ошибки

Как хранятся связанные таблицы при шардировании

Это важный вопрос про масштабирование БД. Шардирование — это горизонтальное разделение данных по нескольким серверам, и связи между таблицами усложняются.


Проблема: Foreign Key при шардировании

Без шардирования просто:

CREATE TABLE users (
  id BIGINT PRIMARY KEY,
  name VARCHAR(255)
);

CREATE TABLE orders (
  id BIGINT PRIMARY KEY,
  user_id BIGINT,
  amount DECIMAL,
  FOREIGN KEY (user_id) REFERENCES users(id)  -- просто!
);

-- Запрос:
SELECT u.name, o.amount
FROM users u
JOIN orders o ON u.id = o.user_id
WHERE u.id = 123;

При шардировании это сломается:

Шард 1:  Users 1-1000
Шард 2:  Users 1001-2000
Шард 3:  Users 2001-3000

Шард A:  Orders 1-500
Шард B:  Orders 501-1000
Шард C:  Orders 1001-1500

Проблема: User 123 в Шард 1, но его Order может быть в Шард A, B или C.
Foreign key работать не будет!
JOIN работать не будет!

Решение 1: Same-Shard Partitioning (рекомендуется)

Идея: связанные данные хранятся на одном шарде.

Вариант А: Shard by User ID

Шардируем И users, И orders по user_id:

Шард 1 (Shard Key = user_id 1-1000):
  users: [1, 2, ..., 1000]
  orders: [все заказы от пользователей 1-1000]

Шард 2 (Shard Key = user_id 1001-2000):
  users: [1001, 1002, ..., 2000]
  orders: [все заказы от пользователей 1001-2000]

Шард 3 (Shard Key = user_id 2001-3000):
  users: [2001, 2002, ..., 3000]
  orders: [все заказы от пользователей 2001-3000]

SQL (с шардированием):

// Логика в приложении
long userId = 123;
int shardId = userId % 3;  // 123 % 3 = 0 → Shard 1

Connection conn = getShardConnection(shardId);
PreparedStatement stmt = conn.prepareStatement(
  "SELECT u.name, o.amount FROM users u " +
  "JOIN orders o ON u.id = o.user_id " +
  "WHERE u.id = ?"
);
stmt.setLong(1, userId);
ResultSet rs = stmt.executeQuery();

// Foreign key всё ещё работает в рамках шарда!
// JOIN всё ещё работает в рамках шарда!

Плюсы: ✅ JOIN работает в пределах шарда
✅ Foreign key работает
✅ Транзакции в пределах шарда

Минусы: ❌ Нельзя перешардировать без миграции
❌ Горячие шарды если некоторые user'ы активнее других
❌ Сложно добавлять новые таблицы

Вариант Б: Directory + Sharding

Создаём directory table, который говорит где находится user:

// Таблица на главном БД сервере (не шардирована)
CREATE TABLE user_shard_mapping (
  user_id BIGINT PRIMARY KEY,
  shard_id INT  -- 0, 1, или 2
);

// Приложение:
long userId = 123;

// 1. Находим шард
Connection masterDb = getMasterConnection();
PreparedStatement stmt = masterDb.prepareStatement(
  "SELECT shard_id FROM user_shard_mapping WHERE user_id = ?"
);
stmt.setLong(1, userId);
int shardId = stmt.executeQuery().getInt(1);  // 0

// 2. Идём на правильный шард
Connection shardDb = getShardConnection(shardId);
PreparedStatement userStmt = shardDb.prepareStatement(
  "SELECT name FROM users WHERE id = ?"
);
userStmt.setLong(1, userId);
String userName = userStmt.executeQuery().getString(1);

Плюсы: ✅ Гибче при перешардировании
✅ Можно изменить sharding logic

Минусы: ❌ Дополнительный lookup
❌ Single point of failure (master directory server)


Решение 2: Denormalization (денормализация)

Храним часть данных от related table в основной таблице:

-- Вместо этого:
CREATE TABLE orders (
  id BIGINT,
  user_id BIGINT,
  amount DECIMAL
);

-- Делаем это:
CREATE TABLE orders (
  id BIGINT,
  user_id BIGINT,
  amount DECIMAL,
  user_name VARCHAR(255),  -- ДУБЛИРУЕМ! Денормализация
  user_email VARCHAR(255)  -- ДУБЛИРУЕМ! Денормализация
);

Когда user обновляет имя:

// 1. Обновляем пользователя
UPDATE users SET name = 'New Name' WHERE id = 123;

// 2. Обновляем все дублированные копии
UPDATE orders SET user_name = 'New Name' WHERE user_id = 123;
// ВНИМАНИЕ: это может быть на другом шарде!

Выход: асинхронное обновление через event

// Когда user обновляется
public void updateUser(Long userId, String newName) {
  // 1. Обновляем в users table
  userRepository.update(userId, newName);
  
  // 2. Публикуем event
  eventPublisher.publish(new UserUpdatedEvent(userId, newName));
}

// В отдельном сервисе слушаем event
public void onUserUpdated(UserUpdatedEvent event) {
  // 3. Обновляем дублированные данные (может быть на другом шарде)
  orderRepository.updateUserNameForAllOrders(
    event.getUserId(),
    event.getNewName()
  );
}

Плюсы: ✅ Быстрый lookup (нет JOIN)
✅ Простая логика запросов

Минусы: ❌ Дублирование данных
❌ Риск несогласованности (eventual consistency)
❌ Сложнее обновлять


Решение 3: Reference (внешний ID без FK)

Просто сохраняем ID связанного объекта, без проверки:

@Entity
@Table(name = "orders", indexes = {
  @Index(name = "idx_user_id", columnList = "user_id")
})
public class Order {
  
  @Id
  private Long id;
  
  // Просто сохраняем user_id, БЕЗ Foreign Key
  private Long userId;  // ссылка на пользователя
  
  private BigDecimal amount;
  
  // Нет @ManyToOne, нет JPA relationship
}

// В приложении
Order order = orderRepository.findById(1L);
Long userId = order.getUserId();

// Затем отдельный запрос для пользователя
User user = userRepository.findById(userId);  // может быть на другом шарде!

Плюсы: ✅ Нет констрейнтов БД
✅ Гибко

Минусы: ❌ БД не проверяет целостность
❌ Нужна проверка в приложении
❌ Orphaned records (заказы без пользователей)


Решение 4: Event Sourcing & CQRS

Для очень больших систем (Facebook, Twitter масштаб):

// События вместо текущего состояния
CREATE TABLE events (
  event_id BIGINT PRIMARY KEY,
  aggregate_id BIGINT,  // user_id (shard key)
  event_type VARCHAR(50),
  payload JSON,
  created_at TIMESTAMP
);

// Пример событий:
// 1. UserCreatedEvent(user_id=123, name="Alice")
// 2. OrderCreatedEvent(user_id=123, order_id=1, amount=100)
// 3. UserNameChangedEvent(user_id=123, new_name="Alice Smith")

// Читаемые view'ы (денормализованные, может быть даже в ElasticSearch)
CREATE TABLE user_view (
  user_id BIGINT,
  name VARCHAR,
  total_orders INT,
  total_spent DECIMAL
);

CREATE TABLE order_view (
  order_id BIGINT,
  user_id BIGINT,
  user_name VARCHAR,
  amount DECIMAL
);

Как это работает:

1. Command: "Update user name" → публикуем event
2. Event хранится в таблице events (шардировано по user_id)
3. Projection service слушает события
4. Обновляет read view'ы (может быть асинхронно)
5. Клиент читает из готовых view'ов (очень быстро)

Сравнение подходов

ПодходСложностьPerformanceConsistencyКогда
Same-ShardНизкаяОтличнаяStrongБольшинство случаев
DirectoryСредняяХорошаяStrongГибкость нужна
DenormalizationСредняяОтличнаяEventualRead-heavy системы
ReferenceНизкаяХорошаяСлабаяМикросервисы
Event SourcingВысокаяХорошаяEventualОчень большие системы

Практический пример: платёжная система

// Sharding key = user_id
private int getShardId(Long userId) {
  return Math.floorMod(userId.hashCode(), SHARD_COUNT);
}

// User и Orders на одном шарде
public class OrderService {
  
  public Order createOrder(Long userId, BigDecimal amount) {
    int shardId = getShardId(userId);
    
    try (Connection conn = getShardConnection(shardId)) {
      
      // 1. Проверяем баланс user (на том же шарде)
      User user = getUserFromShard(conn, userId);
      if (user.getBalance() < amount) {
        throw new InsufficientFundsException();
      }
      
      // 2. Создаём order (на том же шарде)
      Order order = new Order(userId, amount);
      saveOrderToShard(conn, order);
      
      // 3. Обновляем баланс (на том же шарде)
      user.setBalance(user.getBalance() - amount);
      updateUserInShard(conn, user);
      
      // Всё в одной транзакции!
      conn.commit();
      return order;
      
    } catch (SQLException e) {
      // Откат всей транзакции
      throw new RuntimeException(e);
    }
  }
}

// Что НЕ можем делать:
public void transferMoneyBetweenUsers(Long fromUserId, Long toUserId, BigDecimal amount) {
  int shardFrom = getShardId(fromUserId);   // может быть Shard 0
  int shardTo = getShardId(toUserId);       // может быть Shard 2
  
  // ❌ НЕЛЬЗЯ! Данные на разных шардах
  // РАСПРЕДЕЛЁННАЯ ТРАНЗАКЦИЯ = сложно, медленно
  // Вместо этого используем eventual consistency:
  
  // 1. Пишем в очередь "transfer money"
  // 2. Worker обрабатывает асинхронно
  // 3. Деньги уходят от fromUser
  // 4. Потом деньги приходят toUser
  // 5. Reconciliation job проверяет консистентность
}

Правила при шардировании

Выбери shard key с умом — должен распределять равномерно
Никогда не меняй shard key — это миграция
НЕЛЬЗЯ делать distributed transaction — используй eventual consistency
Денормализация нормально — оптимизация для read
Consistency на уровне приложения — БД не поможет
Monitoring критичен — нужно видеть дисбаланс между шардами


Итог

При шардировании связанные таблицы хранятся:

  1. На одном шарде (рекомендуется) — используем shard key для обеих таблиц
  2. Денормализованы — дублируем нужные данные
  3. Через reference — просто ссылка, БД не проверяет
  4. Event Sourcing — события как source of truth

Главное правило: забудь о distributed transaction. Используй eventual consistency и асинхронные процессы.

Как хранятся связанные таблицы при шардировании | PrepBro