Разработка · 7 мин чтения

Мультитенантность в SaaS: как разделять данные клиентов

Мультитенантность в SaaS решает конкретную инженерную задачу: как хранить данные сотен или тысяч клиентов в одном приложении так, чтобы клиент А физически не увидел ни строчки данных клиента Б, даже если в коде есть баг. За последние несколько лет я собирал бэкенды на Laravel и Node, где выбор модели изоляции данных в начале проекта определял, сколько времени потом уйдёт на миграции, когда клиентов станет не пять, а пятьсот. Ниже разбираю три рабочие модели, объясняю, где включать Row Level Security в PostgreSQL, и показываю, что обычно ломается, если про изоляцию тенантов забыли на старте.

Три модели мультитенантности: где хранятся данные клиентов

На старте SaaS-проекта я выбираю одну из трёх схем хранения. От этого выбора зависит цена масштабирования и то, насколько быстро я отвечу клиенту на вопрос про безопасность его данных.

Модель хранения Изоляция данных Сложность миграций схемы Когда выбираю
Общая таблица с tenant_id Логическая, на уровне строк Одна миграция сразу для всех клиентов MVP и рост примерно до пары сотен тенантов
Отдельная схема на тенанта Физическая, на уровне схемы БД Миграция прогоняется по каждой схеме отдельно Нужна выгрузка данных одного клиента без остальных
Отдельная база на тенанта Полная, вплоть до отдельного сервера Миграции и бэкапы разворачиваются на каждую базу отдельно Заказчик требует физическое разделение данных

На проектах с общей таблицей и колонкой tenant_id схема нормально держит нагрузку, пока в базе не начинают жить сотни активных компаний одновременно. Дальше растут составные индексы, и восстановление из бэкапа данных одного конкретного клиента превращается в отдельную задачу, потому что вытащить строки одного тенанта из общего дампа получится только отдельным скриптом выгрузки по tenant_id.

Отдельная схема на клиента снимает часть этой боли: pg_dump с параметром - schema вытаскивает данные ровно одного клиента. Плата за это такая: миграцию структуры нужно прогонять по каждой схеме отдельно, и при полутысяче тенантов это уже пайплайн, а не одна команда.

Отдельную базу на тенанта я предлагаю, когда заказчик прямо требует физическое разделение: банки, медицинские сервисы, госзаказчики. В остальных случаях это избыточно дорого на старте: каждая база, это отдельные бэкапы, отдельный мониторинг и отдельное подключение в пуле соединений.

Row Level Security в PostgreSQL и другие способы изоляции данных

Общая таблица с tenant_id не защищает от главного риска: разработчик забыл добавить условие в WHERE, и один клиент увидел заказы другого. Row Level Security в PostgreSQL закрывает эту дыру на уровне базы, а не на уровне кода приложения.

Включается это в два шага. Сначала ALTER TABLE orders ENABLE ROW LEVEL SECURITY; запрещает любые чтения и записи в таблицу, пока не описаны политики. Дальше политика вида CREATE POLICY tenant_isolation ON orders USING (tenant_id = current_setting('app.current_tenant')::uuid); разрешает видеть только строки текущего тенанта. Приложение перед каждым запросом выставляет переменную сессии командой SET app.current_tenant = '...', и дальше даже сырой SQL-запрос без единого условия WHERE вернёт только данные нужного клиента.

На практике я подключаю RLS вторым слоем поверх фильтрации в коде, а не вместо неё. Код фильтрует по tenant_id для скорости и понятных планов запроса, а RLS работает как страховка на случай человеческой ошибки: забытый WHERE, кривой JOIN, ORM, который сам собрал запрос без нужного условия.

Бесплатный материал

🎁 Полезный скрипт в подарок

Подпишитесь на Telegram - пришлю готовый скрипт по этой теме.

Без спама. Отписка в 1 клик.

Что ломается в мульти-арендной архитектуре, если забыть про tenant_id

На реальных проектах баги с изоляцией данных вылезают не в аутентификации, а в мелочах, которые не проверяют на код-ревью.

Составные индексы. Если индекс построен как (id) вместо (tenant_id, id), запрос вида «все заказы этого клиента за последний месяц» начинает сканировать таблицу целиком, а не диапазон. На таблице с парой миллионов строк разница между 5 миллисекундами и 2 секундами на запрос как раз в порядке колонок в индексе.

Уникальные ограничения. Email пользователя часто должен быть уникальным в рамках одного тенанта, а не во всей таблице целиком. Если UNIQUE стоит просто на колонке email, второй клиент не сможет завести сотрудника с тем же адресом почты, что уже есть у другого клиента, хотя это два разных бизнеса.

Фоновые задачи и очереди. Воркер, который обрабатывает задачу из очереди, обязан явно получить tenant_id из payload задачи и восстановить контекст тенанта, прежде чем трогать базу. Если контекст остаётся от предыдущей задачи в том же процессе, письмо или отчёт улетают не тому клиенту. На одном проекте я ловил именно такой баг: воркер на Node держал тенанта в глобальной переменной модуля, и под нагрузкой соседние задачи иногда успевали подмешаться друг в друга.

JOIN между таблицами. Каждый JOIN обязан нести tenant_id в условии, а не полагаться на то, что foreign key сам ограничит выборку нужным клиентом. Внешний ключ гарантирует целостность ссылки, а не то, что у второго тенанта нет строки с таким же локальным ID.

Аутентификация и авторизация тенантов в SaaS

Тенант должен попадать в контекст запроса раньше, чем выполнится первый SQL-запрос. Я решаю это на уровне middleware: тенант определяется по поддомену, по заголовку запроса или по claim в JWT-токене, и дальше кладётся в контейнер приложения на весь жизненный цикл запроса.

В Laravel это удобно закрывать глобальным scope модели. Модель Order в методе boot регистрирует addGlobalScope с классом, реализующим интерфейс Scope, и этот класс добавляет условие where по tenant_id из текущего резолвнутого тенанта. После этого обычный вызов Order::all() в контроллере уже не вернёт чужие заказы, даже если разработчик забыл дописать фильтр вручную.

Для модели с отдельной базой на клиента подключение переключается динамически: приложение меняет конфиг соединения через config(['database.connections.tenant.database' => $tenant->database_name]), затем вызывает DB::purge('tenant') и DB::reconnect('tenant'), чтобы Eloquent пересоздал соединение с новой базой перед тем, как обработать запрос.

Токен при этом должен нести tenant_id как отдельный claim, а не полагаться на то, что все ID пользователей в системе уникальны глобально. Проверка на бэкенде обязана сверять tenant_id из токена с tenant_id из URL или поддомена, иначе валидный токен одного клиента сработает и на чужом поддомене.

Когда переезжать на отдельные базы: масштабирование мультитенантной архитектуры

Три сигнала, после которых я предлагаю клиенту менять модель хранения.

Первый, один тенант вырос настолько, что его нагрузка мешает остальным. Классика Postgres: у одного клиента раз в день запускается тяжёлый отчёт на несколько минут, и в это время у всех остальных клиентов подтормаживают обычные запросы, потому что все делят один инстанс базы.

Второй, юридические требования. Если контракт требует хранить данные конкретного клиента физически отдельно от остальных, логическая изоляция через tenant_id это требование не закрывает, нужна отдельная база или выделенный контур.

Третий, объём данных одного клиента перерос остальных на порядок. Держать таблицу, где 90% строк принадлежит одному крупному клиенту, а 10% размазаны между сотней мелких, неудобно и для бэкапов, и для миграций.

Процесс выделения нового клиента в отдельную инфраструктуру я обычно автоматизирую: при апгрейде тарифа веб-хук уходит в n8n, сценарий поднимает новую базу, прогоняет миграции и переносит данные тенанта из общей схемы, без ручных SQL-команд посреди рабочего дня. Если проект только начинается и такой архитектуры ещё нет, обычно я закладываю это на этапе разработки веб-приложения под мультитенантную архитектуру, чтобы переезд на отдельные базы через год не превращался в переписывание половины бэкенда.

Частые вопросы

Сколько стоит переезд с общей таблицы на отдельные базы для клиентов?

Зависит от объёма данных и от того, сколько мест в коде завязано на общую схему подключения. Такая доработка обычно попадает в категорию комплексной интеграции бэкенда, и я оцениваю её от 100 000 ₽ как отдельный проект на Laravel, с миграцией данных и тестированием изоляции на реальных копиях данных, а не только на тестовых записях.

Можно ли совмещать модели мультитенантности в одном проекте?

Да, и я так делаю регулярно. Мелкие клиенты живут в общей таблице с tenant_id и RLS сверху, а два три крупных клиента с особыми требованиями к безопасности выносятся в отдельные схемы или базы. Приложение резолвит тип подключения для тенанта на этапе middleware, бизнес-логика при этом не меняется.

Как проверить изоляцию данных между тенантами перед запуском?

Пишу тесты, которые явно создают двух тенантов с одинаковыми по структуре данными и проверяют, что запрос от имени тенанта А физически не может получить ни одной строки тенанта Б, даже если попытаться подставить чужой ID напрямую в параметр запроса. Отдельно прогоняю сценарий с фоновыми задачами: кладу в очередь задачи от разных тенантов вперемешку и смотрю, не теряется ли контекст на воркере.

Нужна ли отдельная база для каждого клиента с самого начала?

В большинстве проектов нет. Общая таблица с tenant_id и Row Level Security закрывают потребности до сотен активных клиентов, а отдельные базы имеет смысл вводить точечно для конкретных крупных или юридически особых клиентов, когда для этого появляется реальная причина, а не заранее про запас.

Есть задача?

Обсудим в мессенджере

Расскажите, что нужно сделать — отвечу в течение 4 часов в рабочее время. Первая консультация бесплатно.

Продолжая пользование настоящим сайтом Вы выражаете своё согласие на обработку Ваших персональных данных (файлов куки) с использованием Yandex.Metrika.
Понятно