Как оптимально хранить список контактов в базе данных
При разработке социального приложения, где пользователи импортируют записные книжки и получают уведомления о регистрации друзей, возникает задача эффективного хранения контактов. В статье разберём несколько подходов: от классической нормализованной таблицы до использования массивов в PostgreSQL, и сравним их производительность при миллионах записей.
Проблема: миллионы контактов и быстрые запросы
Если 10 000 пользователей импортируют по 100-200 контактов, база данных получит 1-2 миллиона строк. Главный запрос - SELECT userId FROM contacts WHERE phoneNumber = '...' - должен выполняться мгновенно, чтобы уведомить пользователя A, что его контакт B зарегистрировался. Какое решение выбрать?
Вариант 1: Нормализованная таблица contacts
Создаём таблицу:
CREATE TABLE contacts (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
userId INT NOT NULL,
friendPhoneNumber VARCHAR(20) NOT NULL,
INDEX idx_user (userId),
INDEX idx_phone (friendPhoneNumber)
);Плюсы:
- Простота и прозрачность структуры
- Быстрый поиск по индексу
friendPhoneNumber - Подходит для любой SQL-СУБД (MySQL, PostgreSQL)
Минусы:
- Большой объём данных - 2 млн строк при 10 000 пользователей
- При росте до 100 000 пользователей (20 млн строк) SELECT по индексу всё ещё будет быстрым (единицы миллисекунд), но вставка и обновление могут замедлиться
На практике MySQL с InnoDB отлично справляется с десятками миллионов строк при правильных индексах. Запрос SELECT userId FROM contacts WHERE friendPhoneNumber = '+71234567890' по B-tree индексу выполняется за <1 мс даже на 50 млн записей.
Вариант 2: Массив в PostgreSQL
PostgreSQL поддерживает тип text[] или varchar[]. Можно хранить все контакты пользователя в одной строке:
CREATE TABLE users (
id SERIAL PRIMARY KEY,
contacts TEXT[]
);
CREATE INDEX idx_contacts ON users USING GIN (contacts);Запрос: SELECT id FROM users WHERE contacts @> ARRAY['+71234567890'].
Плюсы:
- Меньше строк - одна на пользователя вместо 200
- GIN-индекс оптимизирован для поиска элементов внутри массива
Минусы:
- Сложность модификации - добавить/удалить один контакт означает перезапись всего массива
- GIN-индекс медленнее B-tree при высоконагруженных вставках
- Неудобно для аналитики (количество контактов, группировки)
Производительность: при 10 000 пользователей и 2 млн контактов массив с GIN даёт сопоставимые результаты с нормализованной таблицей, но при частых обновлениях (например, пользователь удаляет 5 контактов) массив проигрывает.
Вариант 3: Денормализация с JSONB (PostgreSQL)
Можно хранить контакты в JSONB-колонке:
CREATE TABLE users (
id SERIAL PRIMARY KEY,
contacts JSONB
);
CREATE INDEX idx_contacts_gin ON users USING GIN (contacts jsonb_path_ops);Запрос: SELECT id FROM users WHERE contacts @> '"+71234567890"'::jsonb.
JSONB даёт гибкость (можно хранить не только номера, но и имена, теги), но производительность поиска ниже, чем у нормализованной таблицы, из-за накладных расходов на парсинг JSON.
Рекомендация: нормализованная таблица с индексами
Для задачи «проверить, есть ли у кого-то контакт с номером X» классическая таблица contacts с индексом по friendPhoneNumber - самый надёжный и производительный вариант. Она проста в поддержке, масштабируется горизонтально (шардирование по userId) и не требует специфических возможностей PostgreSQL. Если вы используете MySQL, это лучший выбор. Если PostgreSQL - тоже, так как B-tree индекс там не хуже.
Альтернатива: шардирование и партиционирование
При росте базы свыше 100 млн строк можно применить партиционирование по userId (диапазонное или хеш-партиционирование). Это ускорит массовые операции и упростит обслуживание.