Можно ли сделать составной индекс уникальным?
Комментарии (2)
Ответ сгенерирован нейросетью и может содержать ошибки
Составной уникальный индекс в БД: как создать и использовать
Да, можно и нужно. Составной уникальный индекс (composite unique index) — это очень полезный инструмент для обеспечения целостности данных. За свои 10+ лет я активно использую их в production системах.
Что такое составной уникальный индекс
Это индекс, который гарантирует, что комбинация значений в нескольких столбцах уникальна, хотя каждый столбец по отдельности может иметь дубликаты.
-- Пример: не может быть двух пользователей с одинаковыми (email, organization_id)
CREATE UNIQUE INDEX idx_user_email_org ON users(email, organization_id);
Когда это необходимо
Сценарий 1: SaaS с мультитенантностью
В системе есть несколько организаций. Email должен быть уникален только внутри организации, не глобально:
CREATE TABLE users (
id UUID PRIMARY KEY,
email VARCHAR(255) NOT NULL,
organization_id UUID NOT NULL,
FOREIGN KEY (organization_id) REFERENCES organizations(id),
UNIQUE (email, organization_id) -- Составной уникальный ключ
);
Теперь возможны:
INSERT INTO users (id, email, organization_id)
VALUES ('1', 'john@example.com', 'org-1'); -- OK
INSERT INTO users (id, email, organization_id)
VALUES ('2', 'john@example.com', 'org-2'); -- OK! Разные организации
INSERT INTO users (id, email, organization_id)
VALUES ('3', 'john@example.com', 'org-1'); -- ERROR! Дубликат в org-1
Сценарий 2: Система версионирования
Комбинация (product_id, version) должна быть уникальна:
CREATE TABLE product_versions (
id BIGSERIAL PRIMARY KEY,
product_id UUID NOT NULL,
version VARCHAR(20) NOT NULL, -- "1.0.0", "1.0.1" и т.д.
content TEXT NOT NULL,
UNIQUE (product_id, version)
);
Сценарий 3: Учёт инвентаря
Одна комбинация товара и склада:
CREATE TABLE inventory (
id BIGSERIAL PRIMARY KEY,
product_id UUID NOT NULL,
warehouse_id UUID NOT NULL,
quantity INT,
UNIQUE (product_id, warehouse_id)
);
Синтаксис в разных БД
PostgreSQL (рекомендуется)
-- Вариант 1: В определении таблицы
CREATE TABLE users (
id UUID PRIMARY KEY,
email VARCHAR(255),
organization_id UUID,
UNIQUE (email, organization_id)
);
-- Вариант 2: Отдельный индекс
CREATE UNIQUE INDEX idx_email_org ON users(email, organization_id);
-- Вариант 3: Именованный constraint
CREATE TABLE users (
id UUID PRIMARY KEY,
email VARCHAR(255),
organization_id UUID,
CONSTRAINT unique_email_per_org UNIQUE (email, organization_id)
);
MySQL
-- Вариант 1: В определении таблицы
CREATE TABLE users (
id VARCHAR(36) PRIMARY KEY,
email VARCHAR(255),
organization_id VARCHAR(36),
UNIQUE KEY unique_email_org (email, organization_id)
);
-- Вариант 2: ALTER TABLE
ALTER TABLE users
ADD UNIQUE KEY unique_email_org (email, organization_id);
SQLite
CREATE TABLE users (
id TEXT PRIMARY KEY,
email TEXT,
organization_id TEXT,
UNIQUE (email, organization_id)
);
Django ORM
from django.db import models
class User(models.Model):
email = models.EmailField()
organization = models.ForeignKey(Organization, on_delete=models.CASCADE)
class Meta:
# Составной уникальный ключ
unique_together = [['email', 'organization']]
# Или в новом синтаксисе (Django 3.2+)
constraints = [
models.UniqueConstraint(
fields=['email', 'organization'],
name='unique_email_per_org'
)
]
Миграция:
from django.db import migrations, models
class Migration(migrations.Migration):
operations = [
migrations.AddConstraint(
model_name='user',
constraint=models.UniqueConstraint(
fields=['email', 'organization'],
name='unique_email_org'
),
),
]
SQLAlchemy
from sqlalchemy import Column, String, UUID, ForeignKey, UniqueConstraint
from sqlalchemy.orm import declarative_base
Base = declarative_base()
class User(Base):
__tablename__ = 'users'
id = Column(UUID, primary_key=True)
email = Column(String, nullable=False)
organization_id = Column(UUID, ForeignKey('organizations.id'))
__table_args__ = (
UniqueConstraint('email', 'organization_id',
name='unique_email_per_org'),
)
Миграция Alembic:
from alembic import op
import sqlalchemy as sa
def upgrade():
op.create_unique_constraint(
'unique_email_per_org',
'users',
['email', 'organization_id']
)
def downgrade():
op.drop_constraint(
'unique_email_per_org',
'users',
type_='unique'
)
Goose (Raw SQL)
-- migrations/0005_add_unique_email_org.sql
ALTER TABLE users
ADD CONSTRAINT unique_email_per_org UNIQUE (email, organization_id);
Важные особенности
1. NULL значения в составном индексе
Поведение зависит от БД. В большинстве БД NULL != NULL, поэтому несколько NULL допускаются:
-- В PostgreSQL это допускается (NULL обрабатывается специально)
INSERT INTO users VALUES ('1', NULL, 'org-1');
INSERT INTO users VALUES ('2', NULL, 'org-1'); -- OK, NULL позволяет дублирование
Если нужно избежать этого, добавьте NOT NULL:
class User(models.Model):
email = models.EmailField(null=False) # Обязательное поле
organization = models.ForeignKey(...)
2. Производительность SELECT запросов
Составной индекс используется для:
-- ✅ Быстро: индекс используется полностью
SELECT * FROM users WHERE email = 'john@example.com' AND organization_id = 'org-1';
-- ✅ Быстро: индекс используется частично (по первому столбцу)
SELECT * FROM users WHERE email = 'john@example.com';
-- ❌ Медленно: индекс не используется
SELECT * FROM users WHERE organization_id = 'org-1';
Порядок столбцов в индексе важен!
3. Операции вставки и обновления
# Django: обновление может вызвать IntegrityError
try:
user = User.objects.create(
email='john@example.com',
organization_id='org-1'
)
except IntegrityError as e:
# Такой пользователь уже существует в организации
print("Дубликат:", e)
user = User.objects.get(
email='john@example.com',
organization_id='org-1'
)
4. Удаление и переименование индекса
-- PostgreSQL
DROP INDEX unique_email_per_org;
-- MySQL
ALTER TABLE users DROP INDEX unique_email_org;
-- Переименование (PostgreSQL)
ALTER INDEX unique_email_org RENAME TO idx_user_email_org;
Real-world пример: E-commerce платформа
from django.db import models
from django.core.exceptions import IntegrityError
class Product(models.Model):
sku = models.CharField(max_length=50)
vendor = models.ForeignKey('Vendor', on_delete=models.CASCADE)
class Meta:
# SKU уникален только для каждого вендора
constraints = [
models.UniqueConstraint(
fields=['sku', 'vendor'],
name='unique_sku_per_vendor'
)
]
class Order(models.Model):
user = models.ForeignKey('User', on_delete=models.CASCADE)
created_at = models.DateTimeField(auto_now_add=True)
# Один заказ на день для пользователя
# (могут быть специальные дневные предложения)
class Meta:
constraints = [
models.UniqueConstraint(
fields=['user', models.F('created_at__date')],
name='one_order_per_day'
)
]
Чек-лист при работе с составными индексами
- Понять, что именно должно быть уникально (комбинация каких полей)
- Выбрать правильный порядок столбцов (часто запрашиваемые первыми)
- Убедиться, что NOT NULL для всех столбцов, если NULL не имеет смысла
- Протестировать INSERT/UPDATE и обработать IntegrityError
- Проверить EXPLAIN для SELECT запросов, что индекс используется
- Добавить миграцию и протестировать откат
- Документировать причину этого ограничения в коде
Итог
Составные уникальные индексы — это мощный инструмент для обеспечения целостности данных в многотенантных системах, версионировании и других сценариях, где уникальность связана с несколькими полями одновременно. Используйте их везде, где это имеет смысл по логике приложения.