Как оптимально хранить список контактов в базе данных

    При разработке социального приложения, где пользователи импортируют записные книжки и получают уведомления о регистрации друзей, возникает задача эффективного хранения контактов. В статье разберём несколько подходов: от классической нормализованной таблицы до использования массивов в 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 (диапазонное или хеш-партиционирование). Это ускорит массовые операции и упростит обслуживание.

    Часто задаваемые вопросы

    Часто задаваемые вопросы