Что такое Multi-Tenancy
Multi-tenancy -- архитектурный подход, при котором одна инстанция приложения обслуживает множество клиентов (тенантов). Каждый тенант -- изолированная организация со своими пользователями, данными и настройками.
Single-Tenant vs Multi-Tenant
Single-Tenant: Multi-Tenant:
┌──────────┐ ┌──────────┐ ┌──────────────────────────────┐
│ App #1 │ │ App #2 │ │ Shared Application │
│ DB #1 │ │ DB #2 │ │ ┌────────┬────────┬──────┐ │
│ Tenant A │ │ Tenant B │ │ │Tenant A│Tenant B│Tenant│ │
└──────────┘ └──────────┘ │ │ │ │ C │ │
│ └────────┴────────┴──────┘ │
Больше ресурсов, │ Shared Database │
проще изоляция, └──────────────────────────────┘
дороже Меньше ресурсов,
сложнее изоляция,
дешевле
Когда нужна Multi-Tenancy
| Сценарий | Подходит | Не подходит |
|---|---|---|
| SaaS платформа (CRM, ERP, PM) | Да | |
| B2B с сотнями клиентов | Да | |
| Финансы/медицина (строгий compliance) | Да (часто) | |
| Один крупный enterprise-клиент | Да | |
| Marketplace для малого бизнеса | Да | |
| Государственные системы | Да (обычно) |
Модели изоляции данных
Модель 1: Shared Database, Shared Schema (Row-Level)
Все тенанты в одних таблицах, различаются по tenant_id.
-- Every table has tenant_id column
CREATE TABLE projects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
name VARCHAR(255) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Index for tenant-scoped queries (ALWAYS needed)
CREATE INDEX idx_projects_tenant ON projects (tenant_id);
-- Composite index for common filtered queries
CREATE INDEX idx_projects_tenant_name ON projects (tenant_id, name);
Преимущества: Минимальные ресурсы, простой деплой, легкая миграция схемы. Недостатки: Нет жесткой изоляции, риск утечки данных при баге, noisy neighbor.
Модель 2: Shared Database, Separate Schemas
Каждый тенант имеет свою PostgreSQL-схему.
-- Create schema for new tenant
CREATE SCHEMA tenant_acme;
-- Tables in tenant-specific schema
CREATE TABLE tenant_acme.projects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- No tenant_id needed! Schema provides isolation.
CREATE TABLE tenant_acme.tasks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID NOT NULL REFERENCES tenant_acme.projects(id),
title VARCHAR(500) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Switch tenant context by setting search_path
SET search_path TO tenant_acme, public;
Преимущества: Лучшая изоляция, per-tenant backup/restore, индивидуальная миграция. Недостатки: Больше схем = больше объектов в PostgreSQL, сложнее миграции.
Модель 3: Separate Databases per Tenant
Полная изоляция -- каждый тенант имеет свою базу данных.
┌───────────────────┐
│ Connection Pool │
│ ┌─────┐ ┌─────┐ │ ┌─────────────┐
│ │Acme │ │Beta │ │───►│ db_acme │
│ │Conn │ │Conn │ │ └─────────────┘
│ └─────┘ └─────┘ │ ┌─────────────┐
│ ┌─────┐ │───►│ db_beta │
│ │Gamma│ │ └─────────────┘
│ │Conn │ │ ┌─────────────┐
│ └─────┘ │───►│ db_gamma │
└───────────────────┘ └─────────────┘
Преимущества: Полная изоляция, compliance, индивидуальное масштабирование, простой backup. Недостатки: Дорого (по серверу на тенанта), сложный деплой, cross-tenant аналитика требует ETL.
Сравнительная таблица
| Критерий | Shared Schema | Separate Schemas | Separate DBs |
|---|---|---|---|
| Изоляция данных | Низкая | Средняя | Высокая |
| Стоимость | $ | $$ | $$$ |
| Сложность деплоя | Низкая | Средняя | Высокая |
| Масштабирование | Единое | Единое | Per-tenant |
| Миграции схемы | Одна миграция | N миграций | N миграций |
| Cross-tenant запросы | Просто (JOIN) | Возможно (schema prefix) | ETL/federation |
| Backup/restore | Общий | Per-schema | Per-database |
| Compliance (GDPR) | Сложно | Средне | Просто |
| Max тенантов | 100K+ | 1K-10K | 100-1K |
| Noisy neighbor | Высокий риск | Средний риск | Нет |
Row-Level Security (PostgreSQL RLS)
PostgreSQL RLS -- встроенный механизм, который добавляет автоматический фильтр WHERE tenant_id = current_tenant ко всем запросам.
Настройка RLS
-- Enable RLS on table
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
-- Force RLS even for table owner (important!)
ALTER TABLE projects FORCE ROW LEVEL SECURITY;
-- Policy: tenant sees only own data
CREATE POLICY tenant_isolation ON projects
USING (tenant_id = current_setting('app.current_tenant')::uuid);
-- Policy for INSERT: auto-set tenant_id
CREATE POLICY tenant_insert ON projects
FOR INSERT
WITH CHECK (tenant_id = current_setting('app.current_tenant')::uuid);
-- Policy for UPDATE: cannot change tenant_id
CREATE POLICY tenant_update ON projects
FOR UPDATE
USING (tenant_id = current_setting('app.current_tenant')::uuid)
WITH CHECK (tenant_id = current_setting('app.current_tenant')::uuid);
-- Policy for DELETE
CREATE POLICY tenant_delete ON projects
FOR DELETE
USING (tenant_id = current_setting('app.current_tenant')::uuid);
-- Apply RLS to all tenant tables
DO $$
DECLARE
tbl RECORD;
BEGIN
FOR tbl IN
SELECT tablename FROM pg_tables
WHERE schemaname = 'public'
AND tablename NOT IN ('tenants', 'migrations', 'global_settings')
LOOP
EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', tbl.tablename);
EXECUTE format('ALTER TABLE %I FORCE ROW LEVEL SECURITY', tbl.tablename);
EXECUTE format(
'CREATE POLICY tenant_isolation ON %I
USING (tenant_id = current_setting(''app.current_tenant'')::uuid)',
tbl.tablename
);
END LOOP;
END $$;
Как работает RLS
Приложение: SET app.current_tenant = 'acme-uuid';
SELECT * FROM projects;
PostgreSQL: SELECT * FROM projects
WHERE tenant_id = 'acme-uuid'; ← Автоматически!
Приложение: INSERT INTO projects (name) VALUES ('New');
PostgreSQL: INSERT INTO projects (tenant_id, name)
VALUES ('acme-uuid', 'New');
-- WITH CHECK: если tenant_id ≠ 'acme-uuid' → ERROR
PHP: Middleware для установки Tenant Context
<?php
declare(strict_types=1);
namespace App\EventSubscriber;
use App\Service\TenantResolver;
use Doctrine\DBAL\Connection;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\Event\TerminateEvent;
use Symfony\Component\HttpKernel\KernelEvents;
/**
* Sets PostgreSQL session variable for RLS on every request.
*
* Must run BEFORE any database query.
* Resets tenant context on request termination.
*/
final class TenantContextSubscriber implements EventSubscriberInterface
{
public function __construct(
private readonly TenantResolver $resolver,
private readonly Connection $connection,
private readonly \Psr\Log\LoggerInterface $logger,
) {}
public static function getSubscribedEvents(): array
{
return [
KernelEvents::REQUEST => ['onKernelRequest', 200], // High priority
KernelEvents::TERMINATE => ['onKernelTerminate', 0],
];
}
public function onKernelRequest(RequestEvent $event): void
{
if (!$event->isMainRequest()) {
return;
}
$tenantId = $this->resolver->resolve($event->getRequest());
if ($tenantId === null) {
return; // Public route, no tenant context needed
}
// Set PostgreSQL session variable for RLS
$this->connection->executeStatement(
"SET app.current_tenant = :tenantId",
['tenantId' => $tenantId],
);
// Store for later use (controllers, services)
$event->getRequest()->attributes->set('_tenant_id', $tenantId);
$this->logger->debug('Tenant context set', ['tenant_id' => $tenantId]);
}
public function onKernelTerminate(TerminateEvent $event): void
{
// Reset tenant context to prevent leaking between requests
// Important for persistent connections (connection pooling)
try {
$this->connection->executeStatement("RESET app.current_tenant");
} catch (\Throwable) {
// Connection may already be closed
}
}
}
Go: Middleware для Tenant Context
package middleware
import (
"context"
"database/sql"
"fmt"
"log/slog"
"net/http"
)
type contextKey string
const tenantKey contextKey = "tenant_id"
// TenantContext middleware sets PostgreSQL session variable for RLS.
func TenantContext(db *sql.DB, resolver TenantResolver, logger *slog.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tenantID, err := resolver.Resolve(r)
if err != nil {
http.Error(w, `{"error":"tenant not found"}`, http.StatusNotFound)
return
}
if tenantID == "" {
// Public route: no tenant context
next.ServeHTTP(w, r)
return
}
// Get a dedicated connection for this request
conn, err := db.Conn(r.Context())
if err != nil {
logger.Error("failed to get db connection", "error", err)
http.Error(w, `{"error":"internal"}`, http.StatusInternalServerError)
return
}
defer conn.Close()
// Set tenant context on this connection
_, err = conn.ExecContext(r.Context(),
fmt.Sprintf("SET app.current_tenant = '%s'", tenantID),
)
if err != nil {
logger.Error("failed to set tenant context", "error", err)
http.Error(w, `{"error":"internal"}`, http.StatusInternalServerError)
return
}
// Reset on connection return (prevent leaking)
defer func() {
conn.ExecContext(context.Background(), "RESET app.current_tenant")
}()
// Store tenant ID and connection in context
ctx := context.WithValue(r.Context(), tenantKey, tenantID)
ctx = context.WithValue(ctx, "db_conn", conn)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
// TenantFromContext extracts tenant ID from request context.
func TenantFromContext(ctx context.Context) string {
if v, ok := ctx.Value(tenantKey).(string); ok {
return v
}
return ""
}
// ConnFromContext extracts the tenant-scoped DB connection.
func ConnFromContext(ctx context.Context) *sql.Conn {
if v, ok := ctx.Value(contextKey("db_conn")).(*sql.Conn); ok {
return v
}
return nil
}
Tenant Resolution
Как определить, к какому тенанту относится запрос.
Стратегии определения тенанта
1. Subdomain: acme.saas.com → tenant = "acme"
2. Header: X-Tenant-ID: abc → tenant = "abc"
3. JWT Claim: {tenant_id: "x"} → tenant = "x"
4. Path Prefix: /api/tenants/x/.. → tenant = "x"
5. Custom Domain: acme.com (CNAME) → tenant = "acme"
PHP: Tenant Resolver
<?php
declare(strict_types=1);
namespace App\Service;
use Symfony\Component\HttpFoundation\Request;
/**
* Resolves tenant from incoming request.
* Supports multiple resolution strategies with fallback chain.
*/
final class TenantResolver
{
/** @var TenantResolutionStrategy[] */
private readonly array $strategies;
public function __construct(
private readonly TenantRepository $tenantRepository,
) {
// Priority order: JWT > Header > Subdomain > Path
$this->strategies = [
new JwtTenantStrategy(),
new HeaderTenantStrategy(),
new SubdomainTenantStrategy(),
new PathTenantStrategy(),
];
}
/**
* Returns tenant ID or null for public routes.
*/
public function resolve(Request $request): ?string
{
foreach ($this->strategies as $strategy) {
$tenantSlug = $strategy->resolve($request);
if ($tenantSlug !== null) {
// Lookup tenant in database and validate
$tenant = $this->tenantRepository->findBySlug($tenantSlug);
if ($tenant === null) {
throw new TenantNotFoundException("Tenant not found: $tenantSlug");
}
if (!$tenant->isActive()) {
throw new TenantSuspendedException("Tenant suspended: $tenantSlug");
}
return $tenant->getId();
}
}
return null; // No tenant resolved (public route)
}
}
interface TenantResolutionStrategy
{
public function resolve(Request $request): ?string;
}
/**
* Resolve from subdomain: acme.saas.com → "acme"
*/
final class SubdomainTenantStrategy implements TenantResolutionStrategy
{
private const BASE_DOMAIN = 'saas.com';
public function resolve(Request $request): ?string
{
$host = $request->getHost();
// Skip if it's the base domain itself
if ($host === self::BASE_DOMAIN || $host === 'www.' . self::BASE_DOMAIN) {
return null;
}
// Extract subdomain
$suffix = '.' . self::BASE_DOMAIN;
if (!str_ends_with($host, $suffix)) {
return null;
}
$subdomain = substr($host, 0, -strlen($suffix));
// Validate format (alphanumeric + hyphens)
if (!preg_match('/^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$/', $subdomain)) {
return null;
}
return $subdomain;
}
}
/**
* Resolve from X-Tenant-ID header (for API clients).
*/
final class HeaderTenantStrategy implements TenantResolutionStrategy
{
public function resolve(Request $request): ?string
{
$tenantId = $request->headers->get('X-Tenant-ID');
return $tenantId !== null && $tenantId !== '' ? $tenantId : null;
}
}
/**
* Resolve from JWT token claim.
*/
final class JwtTenantStrategy implements TenantResolutionStrategy
{
public function resolve(Request $request): ?string
{
// JWT already parsed by security layer
$tenantId = $request->attributes->get('_jwt_tenant_id');
return $tenantId !== null && $tenantId !== '' ? $tenantId : null;
}
}
/**
* Resolve from URL path: /api/v1/tenants/{slug}/...
*/
final class PathTenantStrategy implements TenantResolutionStrategy
{
public function resolve(Request $request): ?string
{
$path = $request->getPathInfo();
if (preg_match('#^/api/v1/tenants/([a-z0-9-]+)/#', $path, $matches)) {
return $matches[1];
}
return null;
}
}
Go: Tenant Resolver
package tenant
import (
"fmt"
"net/http"
"regexp"
"strings"
)
// Resolver determines tenant from HTTP request.
type Resolver interface {
Resolve(r *http.Request) (string, error)
}
// ChainResolver tries multiple strategies in order.
type ChainResolver struct {
strategies []Resolver
repo Repository
}
func NewChainResolver(repo Repository) *ChainResolver {
return &ChainResolver{
strategies: []Resolver{
&JWTResolver{},
&HeaderResolver{},
&SubdomainResolver{baseDomain: "saas.com"},
&PathResolver{},
},
repo: repo,
}
}
func (c *ChainResolver) Resolve(r *http.Request) (string, error) {
for _, strategy := range c.strategies {
slug, err := strategy.Resolve(r)
if err != nil {
return "", err
}
if slug == "" {
continue
}
// Validate tenant exists and is active
t, err := c.repo.FindBySlug(r.Context(), slug)
if err != nil {
return "", fmt.Errorf("find tenant %q: %w", slug, err)
}
if t == nil {
return "", fmt.Errorf("tenant not found: %s", slug)
}
if !t.IsActive {
return "", fmt.Errorf("tenant suspended: %s", slug)
}
return t.ID, nil
}
return "", nil // No tenant resolved (public route)
}
// SubdomainResolver extracts tenant from subdomain.
type SubdomainResolver struct {
baseDomain string
}
var subdomainRe = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$`)
func (s *SubdomainResolver) Resolve(r *http.Request) (string, error) {
host := r.Host
// Remove port if present
if idx := strings.IndexByte(host, ':'); idx != -1 {
host = host[:idx]
}
suffix := "." + s.baseDomain
if !strings.HasSuffix(host, suffix) {
return "", nil
}
subdomain := strings.TrimSuffix(host, suffix)
if !subdomainRe.MatchString(subdomain) {
return "", nil
}
return subdomain, nil
}
// HeaderResolver reads X-Tenant-ID header.
type HeaderResolver struct{}
func (h *HeaderResolver) Resolve(r *http.Request) (string, error) {
return r.Header.Get("X-Tenant-ID"), nil
}
// JWTResolver reads tenant_id from parsed JWT claims.
type JWTResolver struct{}
func (j *JWTResolver) Resolve(r *http.Request) (string, error) {
if claims, ok := r.Context().Value("jwt_claims").(map[string]any); ok {
if tid, ok := claims["tenant_id"].(string); ok {
return tid, nil
}
}
return "", nil
}
// PathResolver extracts tenant from URL path.
type PathResolver struct{}
var pathRe = regexp.MustCompile(`^/api/v1/tenants/([a-z0-9-]+)/`)
func (p *PathResolver) Resolve(r *http.Request) (string, error) {
matches := pathRe.FindStringSubmatch(r.URL.Path)
if len(matches) > 1 {
return matches[1], nil
}
return "", nil
}
Data Isolation Patterns
Global vs Tenant Tables
Не все данные принадлежат тенанту. Некоторые таблицы глобальные.
-- GLOBAL tables (no tenant_id, no RLS)
CREATE TABLE plans ( -- Subscription plans
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(100) NOT NULL,
price_monthly NUMERIC(10,2) NOT NULL
);
CREATE TABLE feature_flags ( -- Global feature toggles
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(100) NOT NULL UNIQUE,
enabled BOOLEAN NOT NULL DEFAULT false
);
CREATE TABLE tenants ( -- Tenant registry
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
slug VARCHAR(100) NOT NULL UNIQUE,
name VARCHAR(255) NOT NULL,
plan_id UUID REFERENCES plans(id),
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- TENANT tables (with tenant_id, RLS enabled)
CREATE TABLE projects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
name VARCHAR(255) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
email VARCHAR(255) NOT NULL,
role VARCHAR(50) NOT NULL DEFAULT 'member',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Email unique WITHIN tenant, not globally
CONSTRAINT uq_users_tenant_email UNIQUE (tenant_id, email)
);
Cross-Tenant Queries (Admin/Analytics)
<?php
declare(strict_types=1);
/**
* Admin service bypasses RLS for cross-tenant operations.
* Uses a separate database connection without tenant context.
*/
final class AdminAnalyticsService
{
public function __construct(
private readonly Connection $adminConnection, // No RLS
private readonly Connection $tenantConnection, // With RLS
) {}
/**
* Get usage statistics across all tenants.
* Only accessible by platform admins (not tenant admins).
*/
public function getTenantUsageStats(): array
{
// This connection does NOT have app.current_tenant set
return $this->adminConnection->fetchAllAssociative("
SELECT
t.slug AS tenant,
t.name AS tenant_name,
p.name AS plan,
(SELECT COUNT(*) FROM users u WHERE u.tenant_id = t.id) AS user_count,
(SELECT COUNT(*) FROM projects pr WHERE pr.tenant_id = t.id) AS project_count,
t.created_at
FROM tenants t
JOIN plans p ON p.id = t.plan_id
WHERE t.is_active = true
ORDER BY user_count DESC
");
}
/**
* Execute a query in the context of a specific tenant.
* Used by support team for troubleshooting.
*/
public function queryAsTenant(string $tenantId, string $sql): array
{
$this->tenantConnection->executeStatement(
"SET app.current_tenant = :tid",
['tid' => $tenantId],
);
try {
return $this->tenantConnection->fetchAllAssociative($sql);
} finally {
$this->tenantConnection->executeStatement("RESET app.current_tenant");
}
}
}
Tenant-Specific Configuration
<?php
declare(strict_types=1);
/**
* Tenant-specific configuration stored in JSON column.
*
* Each tenant can customize behavior without schema changes:
* - Feature toggles
* - UI preferences
* - Integration settings
* - Notification rules
*/
final class TenantConfigService
{
public function __construct(
private readonly \PDO $db,
private readonly \Redis $cache,
) {}
public function get(string $tenantId, string $key, mixed $default = null): mixed
{
$config = $this->loadConfig($tenantId);
return $config[$key] ?? $default;
}
public function set(string $tenantId, string $key, mixed $value): void
{
$this->db->prepare(
"UPDATE tenants
SET config = jsonb_set(COALESCE(config, '{}'), :path, :value::jsonb),
updated_at = NOW()
WHERE id = :tenantId"
)->execute([
'tenantId' => $tenantId,
'path' => '{' . $key . '}',
'value' => json_encode($value),
]);
// Invalidate cache
$this->cache->del("tenant_config:$tenantId");
}
private function loadConfig(string $tenantId): array
{
$cacheKey = "tenant_config:$tenantId";
$cached = $this->cache->get($cacheKey);
if ($cached !== false) {
return json_decode($cached, true);
}
$stmt = $this->db->prepare(
"SELECT COALESCE(config, '{}') FROM tenants WHERE id = :id"
);
$stmt->execute(['id' => $tenantId]);
$config = json_decode($stmt->fetchColumn() ?: '{}', true);
$this->cache->setex($cacheKey, 300, json_encode($config));
return $config;
}
}
Scaling Patterns
Shard by Tenant ID
При росте числа тенантов одна база данных не справляется. Шардинг по tenant_id распределяет нагрузку.
package sharding
import (
"context"
"database/sql"
"fmt"
"hash/fnv"
)
// ShardRouter routes queries to the correct database shard.
type ShardRouter struct {
shards []*sql.DB
}
func NewShardRouter(shards []*sql.DB) *ShardRouter {
return &ShardRouter{shards: shards}
}
// GetShard returns the database for a given tenant.
// Uses consistent hashing to distribute tenants across shards.
func (r *ShardRouter) GetShard(tenantID string) *sql.DB {
h := fnv.New32a()
h.Write([]byte(tenantID))
idx := int(h.Sum32()) % len(r.shards)
return r.shards[idx]
}
// ExecForTenant executes a query on the correct shard.
func (r *ShardRouter) ExecForTenant(
ctx context.Context,
tenantID string,
query string,
args ...any,
) (sql.Result, error) {
shard := r.GetShard(tenantID)
// Set RLS context on the shard connection
conn, err := shard.Conn(ctx)
if err != nil {
return nil, fmt.Errorf("get connection: %w", err)
}
defer conn.Close()
_, err = conn.ExecContext(ctx,
fmt.Sprintf("SET app.current_tenant = '%s'", tenantID),
)
if err != nil {
return nil, fmt.Errorf("set tenant context: %w", err)
}
return conn.ExecContext(ctx, query, args...)
}
// FanOut executes a query across ALL shards (for admin analytics).
func (r *ShardRouter) FanOut(
ctx context.Context,
query string,
) ([]map[string]any, error) {
type result struct {
rows []map[string]any
err error
}
ch := make(chan result, len(r.shards))
for _, shard := range r.shards {
go func(db *sql.DB) {
rows, err := queryToMaps(ctx, db, query)
ch <- result{rows: rows, err: err}
}(shard)
}
var allRows []map[string]any
for range r.shards {
res := <-ch
if res.err != nil {
return nil, res.err
}
allRows = append(allRows, res.rows...)
}
return allRows, nil
}
Noisy Neighbor Protection
<?php
declare(strict_types=1);
/**
* Rate limiter per tenant with tier-based limits.
*
* Prevents one tenant from consuming all resources
* and degrading performance for others.
*/
final class TenantRateLimiter
{
private const LIMITS = [
'free' => ['rpm' => 60, 'burst' => 10],
'startup' => ['rpm' => 600, 'burst' => 50],
'business' => ['rpm' => 3000, 'burst' => 200],
'enterprise' => ['rpm' => 10000, 'burst' => 500],
];
public function __construct(
private readonly \Redis $redis,
) {}
/**
* Check if request is allowed for this tenant.
* Uses sliding window counter algorithm.
*/
public function isAllowed(string $tenantId, string $planTier): bool
{
$limits = self::LIMITS[$planTier] ?? self::LIMITS['free'];
$window = 60; // 1 minute window
$key = "ratelimit:{$tenantId}";
$now = microtime(true);
$windowStart = $now - $window;
// Atomic sliding window with Lua script
$script = <<<'LUA'
local key = KEYS[1]
local now = tonumber(ARGV[1])
local window_start = tonumber(ARGV[2])
local limit = tonumber(ARGV[3])
local window = tonumber(ARGV[4])
-- Remove expired entries
redis.call('ZREMRANGEBYSCORE', key, '-inf', window_start)
-- Count current requests in window
local count = redis.call('ZCARD', key)
if count < limit then
-- Allowed: add this request
redis.call('ZADD', key, now, now .. ':' .. math.random())
redis.call('EXPIRE', key, window + 1)
return 1
else
return 0
end
LUA;
return (bool) $this->redis->eval(
$script,
[$key, $now, $windowStart, $limits['rpm'], $window],
1,
);
}
/**
* Get current usage stats for a tenant.
*/
public function getUsage(string $tenantId): array
{
$key = "ratelimit:{$tenantId}";
$now = microtime(true);
$count = $this->redis->zCount($key, $now - 60, '+inf');
return [
'current_rpm' => $count,
'window' => '60s',
];
}
}
Tenant-Aware Caching
package cache
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/redis/go-redis/v9"
)
// TenantCache prefixes all cache keys with tenant ID.
// Prevents data leakage between tenants through cache.
type TenantCache struct {
client *redis.Client
}
func NewTenantCache(client *redis.Client) *TenantCache {
return &TenantCache{client: client}
}
// Get retrieves a cached value for the current tenant.
func (c *TenantCache) Get(ctx context.Context, tenantID, key string, dest any) error {
prefixed := c.prefixKey(tenantID, key)
data, err := c.client.Get(ctx, prefixed).Bytes()
if err != nil {
return fmt.Errorf("cache get %s: %w", prefixed, err)
}
return json.Unmarshal(data, dest)
}
// Set stores a value in cache with tenant prefix and TTL.
func (c *TenantCache) Set(ctx context.Context, tenantID, key string, value any, ttl time.Duration) error {
prefixed := c.prefixKey(tenantID, key)
data, err := json.Marshal(value)
if err != nil {
return fmt.Errorf("marshal cache value: %w", err)
}
return c.client.Set(ctx, prefixed, data, ttl).Err()
}
// InvalidateTenant removes ALL cached data for a tenant.
// Used when tenant is deleted or data needs full refresh.
func (c *TenantCache) InvalidateTenant(ctx context.Context, tenantID string) error {
pattern := fmt.Sprintf("t:%s:*", tenantID)
var cursor uint64
for {
keys, nextCursor, err := c.client.Scan(ctx, cursor, pattern, 100).Result()
if err != nil {
return fmt.Errorf("scan keys: %w", err)
}
if len(keys) > 0 {
c.client.Del(ctx, keys...)
}
cursor = nextCursor
if cursor == 0 {
break
}
}
return nil
}
func (c *TenantCache) prefixKey(tenantID, key string) string {
return fmt.Sprintf("t:%s:%s", tenantID, key)
}
Billing Integration
Usage Tracking per Tenant (Metering)
<?php
declare(strict_types=1);
/**
* Tracks resource usage per tenant for billing.
*
* Metering dimensions:
* - API calls per month
* - Storage used (bytes)
* - Active users
* - Custom resources (projects, integrations, etc.)
*/
final class UsageMeteringService
{
public function __construct(
private readonly \Redis $redis,
private readonly \PDO $db,
) {}
/**
* Increment a usage counter.
* Uses Redis for real-time counting, flushes to PG periodically.
*/
public function increment(string $tenantId, string $metric, int $amount = 1): void
{
$period = date('Y-m'); // Monthly billing period
$key = "usage:{$tenantId}:{$metric}:{$period}";
$this->redis->incrBy($key, $amount);
// TTL: keep for current month + 5 days buffer
$daysInMonth = (int) date('t');
$this->redis->expire($key, ($daysInMonth + 5) * 86400);
}
/**
* Get current usage for a tenant.
*/
public function getUsage(string $tenantId, string $period = null): array
{
$period ??= date('Y-m');
$metrics = ['api_calls', 'storage_bytes', 'active_users', 'projects'];
$usage = [];
foreach ($metrics as $metric) {
$key = "usage:{$tenantId}:{$metric}:{$period}";
$usage[$metric] = (int) ($this->redis->get($key) ?: 0);
}
return $usage;
}
/**
* Flush Redis counters to PostgreSQL for persistent storage.
* Run via cron every hour.
*/
public function flushToPersistent(string $tenantId, string $period): void
{
$usage = $this->getUsage($tenantId, $period);
$this->db->prepare(
"INSERT INTO tenant_usage (tenant_id, period, metrics, updated_at)
VALUES (:tenantId, :period, :metrics, NOW())
ON CONFLICT (tenant_id, period)
DO UPDATE SET metrics = :metrics2, updated_at = NOW()"
)->execute([
'tenantId' => $tenantId,
'period' => $period,
'metrics' => json_encode($usage),
'metrics2' => json_encode($usage),
]);
}
}
Quota Enforcement
<?php
declare(strict_types=1);
/**
* Enforces plan-based quotas per tenant.
*
* Quotas defined in plan:
* - max_users: 5 (free), 50 (startup), unlimited (enterprise)
* - max_projects: 3, 20, unlimited
* - max_storage_gb: 1, 10, 100
* - max_api_calls_monthly: 1000, 50000, unlimited
*/
final class QuotaEnforcer
{
public function __construct(
private readonly UsageMeteringService $metering,
private readonly TenantPlanService $planService,
) {}
/**
* Check if tenant can perform an action.
*
* @throws QuotaExceededException
*/
public function enforce(string $tenantId, string $resource): void
{
$plan = $this->planService->getPlan($tenantId);
$limit = $plan->getLimit($resource);
// Unlimited resource
if ($limit === -1) {
return;
}
$current = $this->metering->getUsage($tenantId);
$currentValue = $current[$resource] ?? 0;
if ($currentValue >= $limit) {
throw new QuotaExceededException(
resource: $resource,
limit: $limit,
current: $currentValue,
plan: $plan->getName(),
);
}
}
/**
* Get quota status for display in dashboard.
*/
public function getQuotaStatus(string $tenantId): array
{
$plan = $this->planService->getPlan($tenantId);
$usage = $this->metering->getUsage($tenantId);
$status = [];
foreach ($plan->getLimits() as $resource => $limit) {
$current = $usage[$resource] ?? 0;
$status[$resource] = [
'limit' => $limit === -1 ? 'unlimited' : $limit,
'used' => $current,
'remaining' => $limit === -1 ? 'unlimited' : max(0, $limit - $current),
'percentage' => $limit === -1 ? 0 : round(($current / $limit) * 100, 1),
];
}
return $status;
}
}
Onboarding Automation
Provisioning Pipeline
<?php
declare(strict_types=1);
/**
* Automated tenant provisioning pipeline.
*
* Steps:
* 1. Create tenant record in global DB
* 2. Create PostgreSQL schema (or set up RLS)
* 3. Run schema migrations for tenant
* 4. Seed default data (roles, settings, templates)
* 5. Create admin user
* 6. Configure DNS (subdomain or custom domain)
* 7. Send welcome email
*/
final class TenantProvisioningService
{
public function __construct(
private readonly \PDO $db,
private readonly TenantSchemaManager $schemaManager,
private readonly DnsService $dns,
private readonly NotificationService $notifier,
private readonly \Psr\Log\LoggerInterface $logger,
) {}
/**
* Provision a new tenant.
* Each step is idempotent for safe retries.
*/
public function provision(TenantProvisionRequest $request): Tenant
{
$this->logger->info('Starting tenant provisioning', [
'slug' => $request->slug,
'plan' => $request->planId,
]);
// Step 1: Create tenant record
$tenant = $this->createTenant($request);
try {
// Step 2: Set up database (schema or RLS entries)
$this->schemaManager->setupForTenant($tenant->getId());
// Step 3: Run migrations
$this->schemaManager->runMigrations($tenant->getId());
// Step 4: Seed default data
$this->seedDefaults($tenant->getId());
// Step 5: Create admin user
$this->createAdminUser($tenant->getId(), $request->adminEmail);
// Step 6: Configure DNS
if ($request->customDomain !== null) {
$this->dns->configureCname($request->customDomain, $request->slug);
}
// Step 7: Mark as provisioned
$this->markProvisioned($tenant->getId());
// Step 8: Send welcome email
$this->notifier->sendWelcome($request->adminEmail, $tenant);
$this->logger->info('Tenant provisioned successfully', [
'tenant_id' => $tenant->getId(),
]);
return $tenant;
} catch (\Throwable $e) {
$this->logger->error('Tenant provisioning failed', [
'tenant_id' => $tenant->getId(),
'step' => $e->getMessage(),
]);
$this->markProvisioningFailed($tenant->getId(), $e->getMessage());
throw $e;
}
}
private function createTenant(TenantProvisionRequest $request): Tenant
{
$this->db->prepare(
"INSERT INTO tenants (slug, name, plan_id, status, created_at)
VALUES (:slug, :name, :planId, 'provisioning', NOW())
ON CONFLICT (slug) DO NOTHING"
)->execute([
'slug' => $request->slug,
'name' => $request->name,
'planId' => $request->planId,
]);
$stmt = $this->db->prepare("SELECT * FROM tenants WHERE slug = :slug");
$stmt->execute(['slug' => $request->slug]);
return Tenant::fromRow($stmt->fetch(\PDO::FETCH_ASSOC));
}
private function seedDefaults(string $tenantId): void
{
// Set tenant context for RLS
$this->db->exec("SET app.current_tenant = '$tenantId'");
// Default roles
$roles = [
['name' => 'Admin', 'permissions' => json_encode(['*'])],
['name' => 'Member', 'permissions' => json_encode(['read', 'write'])],
['name' => 'Viewer', 'permissions' => json_encode(['read'])],
];
$stmt = $this->db->prepare(
"INSERT INTO roles (tenant_id, name, permissions)
VALUES (:tenantId, :name, :permissions)
ON CONFLICT (tenant_id, name) DO NOTHING"
);
foreach ($roles as $role) {
$stmt->execute([
'tenantId' => $tenantId,
'name' => $role['name'],
'permissions' => $role['permissions'],
]);
}
$this->db->exec("RESET app.current_tenant");
}
private function createAdminUser(string $tenantId, string $email): void
{
$this->db->exec("SET app.current_tenant = '$tenantId'");
$this->db->prepare(
"INSERT INTO users (tenant_id, email, role, created_at)
VALUES (:tenantId, :email, 'admin', NOW())
ON CONFLICT (tenant_id, email) DO NOTHING"
)->execute([
'tenantId' => $tenantId,
'email' => $email,
]);
$this->db->exec("RESET app.current_tenant");
}
private function markProvisioned(string $tenantId): void
{
$this->db->prepare(
"UPDATE tenants SET status = 'active', provisioned_at = NOW()
WHERE id = :id"
)->execute(['id' => $tenantId]);
}
private function markProvisioningFailed(string $tenantId, string $error): void
{
$this->db->prepare(
"UPDATE tenants SET status = 'failed', provision_error = :error
WHERE id = :id"
)->execute(['id' => $tenantId, 'error' => $error]);
}
}
Go: Tenant Provisioning
package provisioning
import (
"context"
"database/sql"
"fmt"
"log/slog"
)
// Provisioner creates and configures new tenants.
type Provisioner struct {
db *sql.DB
dns DNSService
mailer MailerService
logger *slog.Logger
}
// ProvisionRequest contains all data needed for new tenant.
type ProvisionRequest struct {
Slug string
Name string
PlanID string
AdminEmail string
CustomDomain string
}
// Provision creates a new tenant with all required resources.
func (p *Provisioner) Provision(ctx context.Context, req ProvisionRequest) (string, error) {
p.logger.Info("starting tenant provisioning", "slug", req.Slug)
// Start transaction for tenant creation
tx, err := p.db.BeginTx(ctx, nil)
if err != nil {
return "", fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback()
// Create tenant record
var tenantID string
err = tx.QueryRowContext(ctx,
`INSERT INTO tenants (slug, name, plan_id, status, created_at)
VALUES ($1, $2, $3, 'provisioning', NOW())
ON CONFLICT (slug) DO UPDATE SET slug = EXCLUDED.slug
RETURNING id`,
req.Slug, req.Name, req.PlanID,
).Scan(&tenantID)
if err != nil {
return "", fmt.Errorf("create tenant: %w", err)
}
// Seed default data within tenant context
if err := p.seedDefaults(ctx, tx, tenantID); err != nil {
return "", fmt.Errorf("seed defaults: %w", err)
}
// Create admin user
_, err = tx.ExecContext(ctx,
`INSERT INTO users (tenant_id, email, role, created_at)
VALUES ($1, $2, 'admin', NOW())
ON CONFLICT (tenant_id, email) DO NOTHING`,
tenantID, req.AdminEmail,
)
if err != nil {
return "", fmt.Errorf("create admin: %w", err)
}
// Mark as active
_, err = tx.ExecContext(ctx,
`UPDATE tenants SET status = 'active', provisioned_at = NOW() WHERE id = $1`,
tenantID,
)
if err != nil {
return "", fmt.Errorf("activate tenant: %w", err)
}
if err := tx.Commit(); err != nil {
return "", fmt.Errorf("commit: %w", err)
}
// Non-transactional steps (can be retried independently)
if req.CustomDomain != "" {
if err := p.dns.ConfigureCNAME(ctx, req.CustomDomain, req.Slug); err != nil {
p.logger.Error("DNS setup failed (non-critical)", "error", err)
}
}
if err := p.mailer.SendWelcome(ctx, req.AdminEmail, req.Name); err != nil {
p.logger.Error("welcome email failed (non-critical)", "error", err)
}
p.logger.Info("tenant provisioned", "tenant_id", tenantID, "slug", req.Slug)
return tenantID, nil
}
func (p *Provisioner) seedDefaults(ctx context.Context, tx *sql.Tx, tenantID string) error {
roles := []struct {
Name string
Permissions string
}{
{"Admin", `["*"]`},
{"Member", `["read","write"]`},
{"Viewer", `["read"]`},
}
for _, role := range roles {
_, err := tx.ExecContext(ctx,
`INSERT INTO roles (tenant_id, name, permissions)
VALUES ($1, $2, $3)
ON CONFLICT (tenant_id, name) DO NOTHING`,
tenantID, role.Name, role.Permissions,
)
if err != nil {
return fmt.Errorf("seed role %s: %w", role.Name, err)
}
}
return nil
}
Архитектурная диаграмма
Multi-Tenant SaaS Architecture
acme.saas.com beta.saas.com gamma.saas.com
│ │ │
▼ ▼ ▼
┌────────────────────────────────────────────┐
│ Load Balancer │
│ (nginx / ALB / Cloudflare) │
└────────────────────┬───────────────────────┘
│
▼
┌────────────────────────────────────────────┐
│ Application Servers │
│ ┌──────────────────────────────────────┐ │
│ │ Tenant Resolution Layer │ │
│ │ Subdomain → Header → JWT → Path │ │
│ └───────────────────┬──────────────────┘ │
│ │ │
│ ┌───────────────────▼──────────────────┐ │
│ │ Rate Limiter (Redis) │ │
│ │ Per-tenant: Free=60rpm, │ │
│ │ Business=3000rpm │ │
│ └───────────────────┬──────────────────┘ │
│ │ │
│ ┌───────────────────▼──────────────────┐ │
│ │ Quota Enforcer │ │
│ │ Check limits before action │ │
│ └───────────────────┬──────────────────┘ │
│ │ │
│ ┌───────────────────▼──────────────────┐ │
│ │ Business Logic (Controllers) │ │
│ └───────────────────┬──────────────────┘ │
└──────────────────────┼─────────────────────┘
│
┌────────────┼────────────┐
│ │ │
▼ ▼ ▼
┌──────────────┐ ┌──────────┐ ┌──────────────┐
│ PostgreSQL │ │ Redis │ │ Object Store │
│ + RLS │ │ Cache │ │ (S3/Minio) │
│ │ │ + Rate │ │ Per-tenant │
│ SET tenant │ │ Limits │ │ buckets/ │
│ = 'acme' │ │ │ │ prefixes │
│ │ │ t:acme:* │ │ │
│ Automatic │ │ t:beta:* │ │ s3://acme/.. │
│ WHERE filter │ │ │ │ s3://beta/.. │
└──────────────┘ └──────────┘ └──────────────┘
Security Checklist
| Проверка | Описание | Критичность |
|---|---|---|
| RLS включен | FORCE ROW LEVEL SECURITY на всех tenant-таблицах | Критическая |
| Tenant context reset | Сброс app.current_tenant после каждого запроса |
Критическая |
| Cache isolation | Prefix t:{tenant_id}: на всех кеш-ключах |
Высокая |
| File storage isolation | Отдельные bucket/prefix для каждого тенанта | Высокая |
| Cross-tenant API | Отдельная роль/connection без RLS для admin | Высокая |
| Index на tenant_id | Каждая таблица с tenant_id имеет индекс | Средняя |
| Unique constraints | UNIQUE(tenant_id, email), не просто UNIQUE(email) | Высокая |
| Connection reset | При connection pooling сбрасывать SET переменные | Критическая |
| Logs isolation | tenant_id в каждой записи лога | Средняя |
| Backup isolation | Возможность восстановить данные одного тенанта | Средняя |
Выводы
Multi-Tenant архитектура -- это баланс между изоляцией, стоимостью и сложностью. Для большинства SaaS стартапов оптимальный выбор -- Shared Database с Row-Level Security. RLS в PostgreSQL обеспечивает надежную изоляцию на уровне базы данных без изменения кода запросов. По мере роста добавляйте шардинг по tenant_id, tenant-aware кеширование и per-tenant rate limiting. Критически важно: всегда сбрасывайте tenant context после каждого запроса и используйте prefix для всех внешних ресурсов (кеш, файлы, очереди).