Представьте, что вы разрабатываете SaaS-приложение.
Одна компания регистрируется.
Затем регистрируется другая компания.
Затем еще одна.
Все они используют одно и то же приложение, но их данные должны оставаться полностью разделенными.
Компания А никогда не должна видеть следующие данные Компании Б:
- Пользователей
- Заказы
- Клиентов
- Товары
- Счета
- Отчеты
- Настройки
Именно здесь ключевое значение приобретает мультитенантность (многоарендность).
Мультитенантность — это не просто добавление колонки tenant_id в таблицу.
Это архитектурное решение, которое затрагивает вашу базу данных, аутентификацию, авторизацию, запросы, хранилище, кэш, очереди, API и даже стратегию развертывания.
В этой статье мы рассмотрим, как работает мультитенантность и как правильно спроектировать ее в приложении на Laravel.
1. Что такое мультитенантность?
Тенант (арендатор) — это изолированный клиент или организация, использующие одно и то же приложение.
Например:
SaaS Application
│
├── Company A
│ ├── Users
│ ├── Products
│ ├── Orders
│ └── Reports
│
├── Company B
│ ├── Users
│ ├── Products
│ ├── Orders
│ └── Reports
│
└── Company C
├── Users
├── Products
├── Orders
└── ReportsСамо приложение является общим.
Данные изолированы логически.
Например:
users -------------------------------- id | tenant_id | name -------------------------------- 1 | 10 | Jalis 2 | 10 | Rahim 3 | 20 | Karim 4 | 20 | Hasan
Пользователи 1 и 2 принадлежат Тенанту 10.
Пользователи 3 и 4 принадлежат Тенанту 20.
Когда Халис входит в систему, у него должен быть доступ только к данным, принадлежащим Тенанту 10.
2. Мультитенантность отличается от поддержки филиалов
Это важное различие.
Тенант обычно представляет собой независимую организацию или клиента.
Филиал обычно является подразделением внутри тенанта.
Например:
Tenant: ABC Corporation │ ├── Dhaka Branch ├── Chittagong Branch └── Sylhet Branch
Таким образом, архитектура может выглядеть следующим образом:
Tenant
│
├── Branch
│ ├── Users
│ ├── Orders
│ └── Inventory
│
└── Branch
├── Users
├── Orders
└── InventoryЭто различие становится чрезвычайно важным в системах ERP, POS, HRM, складского учета и SaaS.
У пользователя может быть доступ к:
Tenant → All branches
или:
Tenant → Only one branch
Это разные правила авторизации.
3. Зачем нужна мультитенантность?
Без изоляции тенантов у вас может быть приложение следующего вида:
users products orders customers invoices
Но как узнать, какой компании принадлежит каждая запись?
Вам нужна связь, такая как:
tenant_id
Например:
products -------------------------------- id | tenant_id | name -------------------------------- 1 | 10 | Laptop 2 | 10 | Mouse 3 | 20 | Keyboard 4 | 20 | Monitor
Теперь приложение может определять принадлежность.
Запрос вроде:
Product::where('tenant_id', $tenantId)->get();возвращает только те товары, которые принадлежат этому тенанту.
Но ручное добавление этого условия повсюду может стать опасным.
Здесь на помощь приходят глобальные скоупы (global scopes) и архитектура тенантности в Laravel.
4. Варианты архитектуры мультитенантности
Существует три распространенные стратегии работы с базами данных.
1. Shared Database + Shared Tables2. Shared Database + Separate Schemas3. Separate Database Per Tenant
Каждая из них имеет свои преимущества и недостатки.
5. Стратегия 1 — Общая база данных, общие таблицы
Это один из наиболее распространенных подходов для SaaS-приложений.
Все тенанты используют одну и то же базу данных и одни и те же таблицы.
Каждая таблица, принадлежащая тенанту, содержит:
tenant_id
Например:
orders -------------------------------- id tenant_id customer_id total status created_at
Данные могут выглядеть следующим образом:
Tenant 10
Order 1
Order 2
Order 3Tenant 20
Order 4
Order 5Tenant 30
Order 6
Order 7Приложение разделяет данные логически.
Преимущества
- Простая инфраструктура
- Более низкая стоимость хостинга
- Простое управление базой данных
- Простые миграции
- Удобное создание отчетов по всем тенантам при необходимости
- Подходит для множества малых и средних тенантов
Недостатки
Самый большой риск — это утечка данных.
Если в каком-то запросе случайно пропустить:
where('tenant_id', ...)один тенат потенциально сможет получить доступ к данным другого тенанта.
Именно поэтому изоляция тенантов должна быть тщательно продумана.
6. Стратегия 2 — Отдельная схема для каждого тенанта
В этой архитектуре тенанты могут иметь отдельные схемы внутри одной СУБД.
Концептуально:
Database
│
├── tenant_10
│ ├── users
│ ├── orders
│ └── products
│
├── tenant_20
│ ├── users
│ ├── orders
│ └── products
│
└── tenant_30
├── users
├── orders
└── productsПриложение переключает активную схему в зависимости от тенанта.
Это может обеспечить более надежную изоляцию, чем простое использование tenant_id.
Однако управление миграциями и большим количеством схем может стать более сложным.
7. Стратегия 3 — Отдельная база данных для каждого тенанта
Здесь каждый тенант получает собственную базу данных.
Application │ ├── Tenant A → Database A ├── Tenant B → Database B └── Tenant C → Database C
Например:
tenant_10_db tenant_20_db tenant_30_db
Преимущества
Надежная изоляция данных.
База данных одного тенанта физически отделена от базы данных другого тенанта.
Это может быть полезно, когда:
- Тенанты имеют крупный размер
- Требования к изоляции данных высоки
- Клиентам требуются выделенные базы данных
- Резервное копирование и восстановление должны выполняться для каждого тенанта отдельно
- Требования комплаенса (соответствия стандартам) строги
Недостатки
Инфраструктура становится более сложной.
Теперь вам нужно управлять:
Multiple Databases Multiple Connections Migrations Backups Monitoring Provisioning Credentials
Если у вас тысячи тенантов, управление базами данных становится серьезной архитектурной проблемой.
8. какую стратегию выбрать?
Универсального ответа нет.
Простым решением может быть:
Small / Medium SaaS
↓
Shared Database + tenant_idStrong Isolation Requirements
↓
Separate SchemaLarge Enterprise / Dedicated Infrastructure
↓
Separate DatabaseНо при принятии решения также следует учитывать:
- Количество тенантов
- Объем данных
- Соответствие требованиям (комплаенс)
- Стоимость
- Стратегию резервного копирования
- Производительность
- Операционную сложность
- Требования клиентов
Не выбирайте отдельные базы данных только потому, что это звучит более масштабируемо.
Иногда грамотно спроектированная архитектура с общей базой данных является лучшим решением.
9. Проектирование таблицы тенантов
Базовая таблица тенантов может выглядеть так:
tenants ------------------------- id name slug domain status created_at updated_at
Например:
id: 10 name: ABC Corporation slug: abc-corporation domain: abc.example.com status: active
Слаг (slug) или домен помогают идентифицировать тенанта.
10. Как определить текущего тенанта?
Приложение должно отвечать на вопрос:
«Какой тенант в данный момент выполняет этот запрос?»
Существует несколько способов.
Поддомен
abc.example.com xyz.example.com
Поддомен идентифицирует тенанта.
Пользовательский домен
app.abc.com app.xyz.com
Аутентифицированный пользователь
Вошедший в систему пользователь принадлежит тенанту:
users ------------------------- id tenant_id name email
Тогда:
$user->tenant_id
может идентифицировать текущего тенанта.
Сессия
Приложение также может сохранять выбранного тенанта в сессии.
Правильный метод зависит от архитектуры продукта.
11. Контекст тенанта
Один из полезных паттернов — устанавливать текущего тенанта на раннем этапе жизненного цикла запроса.
Например:
HTTP Request
↓
Identify Tenant
↓
Set Tenant Context
↓
Authentication
↓
Authorization
↓
Controller
↓
Business LogicПосле этого остальная часть приложения может получить доступ к контексту текущего тенанта.
Концептуально:
$tenant = app(CurrentTenant::class)->get();
Точная реализация может отличаться.
Важная идея заключается в следующем:
Идентификация тенанта должна происходить централизованно, а не независимо внутри каждого контроллера.
12. Глобальные скоупы могут помочь
Предположим, у нас есть:
products ------------------------- id tenant_id name price
Вместо того чтобы писать:
Product::where('tenant_id', $tenantId)->get();всюду, мы можем использовать глобальный скоуп.
Концептуально:
protected static function booted(): void
{
static::addGlobalScope('tenant', function ($query) {
$query->where(
'tenant_id',
app(CurrentTenant::class)->id()
);
});
}Теперь:
Product::all();
автоматически учитывает тенанта.
Это может значительно снизить риск случайной утечки данных.
Тем не менее, глобальные скоупы нужно использовать с осторожностью.
13. Не доверяйте фронтенду
Это важнейший принцип безопасности.
Никогда не предполагайте, что раз фронтенд отправляет:
{
"tenant_id": 10
}пользователю разрешено использовать Тенант 10.
Злоумышленник может изменить запрос:
{
"tenant_id": 20
}Принадлежность тенанту должна определяться на основе доверенного контекста на стороне сервера.
Например:
Authenticated User
↓
User's Tenant
↓
Current Tenant
↓
Query DataА не:
Frontend tenant_id
↓
Trust it14. Модели с учетом тенантов
В архитектуре с общей базой данных вы можете создать общий паттерн для моделей, принадлежащих тенантам.
Например:
TenantModel
↓
Product
Order
Customer
Invoice
PaymentБазовая модель может обрабатывать стандартное поведение тенанта.
Концептуально:
abstract class TenantModel extends Model
{
protected static function booted(): void
{
static::creating(function ($model) {
$model->tenant_id = app(CurrentTenant::class)->id();
});
}
}Затем:
class Product extends TenantModel
{
}При создании продукта тенант может назначаться автоматически.
Опять же, точная архитектура зависит от приложения.
15. Автоматическое назначение tenant_id
Одна из распространенных ошибок — вручную делать это повсюду:
Product::create([
'name' => $request->name,
'price' => $request->price,
'tenant_id' => auth()->user()->tenant_id,
]);Затем другой разработчик забывает об этом где-то:
Product::create([
'name' => $request->name,
'price' => $request->price,
]);Теперь у вас может появиться запись без принадлежности к тенанту.
Централизованная стратегия назначения тенантов может снизить этот риск.
16. Связи также должны быть безопасными с точки зрения тенантов
Представьте:
Tenant A
Customer #1Tenant B
Customer #2Теперь заказ (Order) принадлежит клиенту (Customer).
Опасная реализация может позволить:
Tenant A
↓
Order
↓
Customer #2если приложение проверяет только ID клиента.
Приложение должно проверять принадлежность.
Концептуально:
Order Tenant
=
Customer TenantСвязи в мультитенантных приложениях должны уважать границы тенантов.
17. Уникальные ограничения должны учитывать тенанты
Предположим, каждый тенант может иметь уникальный SKU товара.
Вы можете подумать:
UNIQUE(sku)
Но это означает, что уникальные SKU должны быть во всей базе данных.
Возможно, Тенанту А и Тенанту Б должно быть разрешено использовать:
SKU-100
В этом случае ограничение должно быть следующим:
UNIQUE(tenant_id, sku)
Теперь:
Tenant A → SKU-100 Tenant B → SKU-100
разрешено.
Но:
Tenant A → SKU-100 Tenant A → SKU-100
отклоняется.
Это небольшое решение на уровне базы данных с большим архитектурным влиянием.
18. Мягкое удаление (Soft Deletes) и тенантность
Предположим, продукт подвергается мягкому удалению.
Вам нужно учитывать оба фактора:
tenant_id deleted_at
Запросы должны учитывать:
Current Tenant + Not Deleted
Функционал SoftDeletes в Laravel обрабатывает вторую часть.
Ваша архитектура тенантности должна обрабатывать первую.
19. Файловое хранилище также должно учитывать тенантов
Изоляция тенантов заключается не только в записях базы данных.
Представьте, что пользователи загружают:
profile.jpg invoice.pdf product.jpg
Не помещайте файлы каждого тенанта в одну неструктурированную директорию.
Лучшая структура может выглядеть так:
storage/
tenants/
10/
products/
invoices/
users/ 20/
products/
invoices/
users/Теперь хранилище логически разделено.
Это особенно важно при генерации публичных URL или подписанных URL для скачивания.
20. Кэш также может утекать данные тенантов
Это часто забываемая проблема.
Представьте:
Cache::remember('products', 3600, function () {
return Product::all();
});Это выглядит нормально.
Но в мультитенантном приложении Тенант А может заполнить кэш.
Затем Тенант Б может получить те же закэшированные данные.
Вместо этого ключи кэша должны включать контекст тенанта:
products:tenant:10 products:tenant:20
Тот же принцип применим к:
- Redis
- Кэшу приложения
- Кэшу запросов
- Статистике дашборда
- Кэшу разрешений
Каждое закэшированное значение, специфичное для тенанта, требует ключей с учетом тенанта.
21. Очереди также нуждаются в контексте тенанта
Представьте, что пользователь загружает большой CSV-файл.
Laravel создает задачу в очереди:
ImportProductsJob
Запрос уже знает тенанта.
Но воркер очереди может обработать задачу позже.
Поэтому задача должна знать:
Which tenant created this job?
Распространенный подход — сохранять:
tenant_id
вместе с соответствующими данными задачи.
Концептуально:
User Request
↓
Tenant 10
↓
Create Job
↓
Queue
↓
Worker
↓
Restore Tenant Context
↓
Process DataЭто становится чрезвычайно важным при фоновой обработке.
22. Отчеты должны учитывать тенантов
Представьте дашборд, показывающий:
Total Sales Total Customers Total Orders Revenue
Запрос вроде:
Order::sum('total');опасен в общей базе данных.
Он может рассчитать общую сумму для каждого тенанта.
Запрос должен учитывать текущего тенанта.
Таким образом, изоляция тенантов должна существовать не только в операциях CRUD, но также в:
- Отчетах
- Аналитике
- Дашбордах
- Экспортах
- Поиске
- Уведомлениях
23. API нужна изоляция тенантов
Предположим, ваше API имеет:
GET /api/orders GET /api/customers GET /api/products
Самой по себе аутентификации недостаточно.
API также должен определять:
Who is the user? Which tenant does the user belong to? What can the user access inside that tenant?
Например:
API Request
↓
Authentication
↓
Tenant Identification
↓
Permission Check
↓
Tenant-Scoped Query
↓
ResponseЭтот паттерн должен быть единообразным во всем API.
24. Суперадмин — особый случай
В SaaS-приложении часто есть администратор уровня платформы.
Например:
Platform │ ├── Tenant A ├── Tenant B └── Tenant C
Администратору платформы может потребоваться видеть всех тенантов.
Это создает важное различие:
Tenant User
↓
Only current tenantPlatform Admin
↓
Can manage tenantsБудьте осторожны при обходе скоупов тенантов.
Такой метод, как:
withoutGlobalScopes()
может быть мощным инструментом, но его не следует использовать бездумно.
Всякий раз, когда изоляция тенантов обходится, должны быть веские основания и строгая авторизация.
25. Тестирование мультитенантности
Мультитенантность требует большего, чем обычное функциональное тестирование.
Например:
Create Tenant A Create Tenant BCreate Product for Tenant A Create Product for Tenant B
Затем протестируйте:
Tenant A user → Can see A product Tenant A user → Cannot see B productTenant B user → Can see B product Tenant B user → Cannot see A product
Также протестируйте:
Create Read Update Delete Search Export Reports API Files Queues Cache
Очень важный тест:
Может ли Тенант А когда-либо получить доступ к данным Тенанта Б?
Этот вопрос следует проверять снова и снова.
26. Распространенные ошибки мультитенантности
Ошибка 1 — Ручное добавление tenant_id везде
Это увеличивает вероятность забыть его.
Ошибка 2 — Доверие tenant_id из запроса
Клиент может его изменить.
Ошибка 3 — Защита только фронтенда
Пользователи могут вызывать API напрямую.
Ошибка 4 — Забытые связи
Заказ может случайно ссылаться на клиента другого тенанта.
Ошибка 5 — Забытый кэш
Закэшированные данные Тенанта А могут появиться у Тенанта Б.
Ошибка 6 — Забытые очереди
Фоновые задачи могут потерять контекст тенанта.
Ошибка 7 — Забытое файловое хранилище
Файлы тенантов могут перемешаться.
Ошибка 8 — Забытые уникальные ограничения
Глобальная уникальность может быть некорректной для данных, специфичных для тенанта.
Ошибка 9 — Отсутствие тестов автоматической изоляции
Будущий разработчик может случайно привнести утечку данных.
27. Практичная архитектура на Laravel
SaaS-приложение с общей базой данных может выглядеть так:
Laravel Application │ ├── Authentication │ ├── Tenant Context │ └── CurrentTenant │ ├── Authorization │ └── Roles & Permissions │ ├── Tenant-Aware Models │ ├── Product │ ├── Order │ ├── Customer │ └── Invoice │ ├── Tenant-Aware Services │ ├── Tenant-Aware Jobs │ ├── Tenant-Aware Cache │ ├── Tenant-Aware Storage │ └── Tenant-Aware Reports
Ключевой принцип заключается в следующем:
Учет тенанта должен быть частью архитектуры, а не доработкой задним числом.
28. Простой жизненный цикл запроса
Мультитенантный запрос в Laravel концептуально может работать следующим образом:
HTTP Request
↓
Identify Tenant
↓
Set CurrentTenant
↓
Authenticate User
↓
Check Tenant Membership
↓
Check Permission
↓
Tenant-Scoped Query
↓
Business Logic
↓
ResponseЭтот централизованный поток делает приложение более понятным.
29. Мультитенантность — это архитектурное решение
Главный урок заключается в том, что мультитенантность затрагивает гораздо больше, чем просто базу данных.
Вам нужно подумать о:
Database Authentication Authorization Models Relationships Unique Constraints Storage Cache Queues APIs Reports Notifications Testing Deployment Backups
Если вы только добавили:
tenant_id
в свои таблицы, вы еще не до конца спроектировали мультитенантную систему.
Вы ее только начали.
Заключение
Мультитенантность — это одно из тех архитектурных решений, которые гораздо проще правильно спроектировать в самом начале, чем переделывать позже.
Прежде чем создавать SaaS-приложение, спросите себя:
Как мы будем идентифицировать текущего тенанта?Где будет храниться принадлежность к тенанту?Как запросы будут автоматически ограничиваться скоупом?Как мы предотвратим межтенантные связи?Как будут разделяться файлы?Как будут разделяться ключи кэша?Как задачи в очереди узнают своего тенанта?Как отчеты и API будут соблюдать границы тенантов?Как мы будем тестировать изоляцию тенантов?
И самое главное:
Что произойдет, если Тенант А попытается получить доступ к данным Тенанта Б?
Если архитектура может последовательно ответить на этот вопрос, вы на верном пути.
Хорошая мультитенантная система — это не просто система, где несколько клиентов могут использовать одно приложение.
Это система, в которой несколько клиентов могут безопасно использовать одно приложение, в то время как их данные, права доступа, конфигурация и бизнес-операции остаются должным образом изолированными.
Сначала спроектируйте изоляцию тенантов. Масштабируйте приложение во вторую очередь.
Комментарии (0)
Пока нет комментариев — будьте первым.