ExpertПрактика22 min

Multi-Tenant SaaS архитектура

Модели изоляции данных, Row-Level Security, Tenant Resolution, масштабирование, биллинг и onboarding. PHP и Go реализации

Что такое 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 для всех внешних ресурсов (кеш, файлы, очереди).

Проверь себя

5 из 8

Какой подход к Tenant Resolution наиболее безопасен для API-интеграций между сервисами?

Почему критически важно сбрасывать tenant context (RESET app.current_tenant) после каждого запроса?

Какой PostgreSQL-командой нужно дополнить ALTER TABLE ... ENABLE ROW LEVEL SECURITY, чтобы RLS работал даже для владельца таблицы?

Почему UNIQUE(email) недостаточно в multi-tenant системе?

Какую SQL-команду нужно выполнить в начале каждого HTTP-запроса для корректной работы RLS?