Как хранятся связанные таблицы при шардировании
Комментарии (1)
Ответ сгенерирован нейросетью и может содержать ошибки
Как хранятся связанные таблицы при шардировании
Это важный вопрос про масштабирование БД. Шардирование — это горизонтальное разделение данных по нескольким серверам, и связи между таблицами усложняются.
Проблема: 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'ов (очень быстро)
Сравнение подходов
| Подход | Сложность | Performance | Consistency | Когда |
|---|---|---|---|---|
| Same-Shard | Низкая | Отличная | Strong | Большинство случаев |
| Directory | Средняя | Хорошая | Strong | Гибкость нужна |
| Denormalization | Средняя | Отличная | Eventual | Read-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 критичен — нужно видеть дисбаланс между шардами
Итог
При шардировании связанные таблицы хранятся:
- На одном шарде (рекомендуется) — используем shard key для обеих таблиц
- Денормализованы — дублируем нужные данные
- Через reference — просто ссылка, БД не проверяет
- Event Sourcing — события как source of truth
Главное правило: забудь о distributed transaction. Используй eventual consistency и асинхронные процессы.