Три архитектурных решения для multi-tenant B2B SaaS, о которых я пожалел, что не узнал раньше

от автора

Самая дорогая ошибка моего B2B SaaS имела ровно одну строчку

```python  # app/config.py  TENANT_ID = "tenant-1"  ```

Когда у меня был один тенант, всё работало корректно. На втором — половина админ-сущностей (врачи, услуги, прайс-листы) начала пропадать из интерфейса клиента. Не «не сохраняться» — а появляться в БД с чужим tenant_id. Я полтора дня смотрел на эту мистику, прежде чем понял: 30 endpoint’ов берут tenant_id из closure из config, а не из user.tenant_id. Очевидно в ретроспективе. Совершенно невидимо во время первого пилота.

Этот разбор — про три архитектурных решения для multi-tenant SaaS в регулируемой отрасли, которые принимаются в первую неделю и потом годами либо экономят месяцы рефакторинга, либо тихо копят техдолг. Стек: Python 3.11 / FastAPI / SQLAlchemy 2.x / PostgreSQL 16. Контекст: B2B SaaS для частных медицинских клиник, 152-ФЗ, real-time scheduling.

TL;DR

Multi-tenancy через tenant_id column + helper scoped_select с первого коммита — даже если тенант один. Причина внутри.

Защита от double-booking через PostgreSQL EXCLUDE USING gist + tsrange — реальные цифры: 12 конкурентных запросов на один слот → 1×200, 11×409, без application-level lock’ов.

152-ФЗ — это поля и helpers: data_category enum в audit_logs с автоклассификатором, прозрачная миграция с PBKDF2 на argon2id без forced reset, patient_consents со scoped версионированием.

Один баг с захардкоженным TENANT_ID показывает, почему все три решения нужно закладывать сразу.

Operational tooling: read-only integrity check на семь категорий аномалий + schema drift detection через alembic check на чистой PG.

Решение №1: multi-tenancy через tenant_id column

Три классических подхода к multi-tenancy в реляционной БД:

Стратегия

Изоляция

Backup/migrations

Стоимость

Когда подходит

Schema-per-tenant

На уровне Postgres

Сложная (N схем × M миграций)

Средняя

5–50 enterprise тенантов с разными SLA

Database-per-tenant

Железная

Очень сложная

Высокая

Compliance-driven, ≤10 тенантов

Shared DB + tenant_id

На уровне приложения

Простая (одна схема)

Низкая

SMB SaaS, сотни–тысячи тенантов

Я выбрал третий вариант. Каждая бизнес-таблица содержит tenant_id TEXT NOT NULL с индексом, все запросы обязаны фильтровать по нему. Цена этой простоты — единственная ошибка в фильтре превращается в IDOR на чувствительные данные между тенантами.

Чтобы это не забывать, в проекте есть тонкий helper:

```python# app/tenant_scope.pyfrom sqlalchemy import selectfrom sqlalchemy.sql import Selectdef scoped_select(model, user) -> Select:    """SELECT, автоматически ограниченный текущим тенантом.    Падает PermissionError, если у user нет tenant_id — это    намеренно: лучше 500-ка в логах, чем тихая утечка.    """    if not user.tenant_id:        raise PermissionError("User has no tenant_id; refusing to query")    return select(model).where(model.tenant_id == user.tenant_id)def scoped_get(db, model, id_, user):    return db.scalar(        select(model).where(model.id == id_, model.tenant_id == user.tenant_id)    )```

Принцип — лучше явная ошибка, чем тихая утечка. И в code review правило простое: любой select(BusinessModel) без scoped_select — блокирующее замечание.

Bug story в callout: одна строка, два дня дебага

В первой версии я использовал closure tenant_id из config’а во всех write-handler’ах:

```python> @router.post("/v1/doctors")> def create_doctor(payload, db, user):>     item = Doctor(>         id=f"doctor-{uuid4()}",>         tenant_id=tenant_id,           # ← closure из config!>         **payload.model_dump(),>     )> ```

Read-path использовал scoped_select(Doctor, user) — корректно. Write-path баковал config.TENANT_ID. Пока тенант один — рассинхрон невидим. Когда подключили вторую клинику, её администратор видел в списке врачей пусто, хотя только что добавил трёх. Эти трое лежали в БД с tenant_id="tenant-1". Фикс: tenant_id=user.tenant_id и регрессия в E2E «зарегистрировать тенанта B → создать сущность как user-B → залогиниться как user-A → убедиться, что не виден».

Урок: helper нужен с первого коммита, даже если тенант один. На десятом тенанте поздно — придётся ревизовать каждый endpoint.

А почему не PostgreSQL Row-Level Security? Это первый вопрос, который мне задают, и он справедливый. RLS дал бы tenant isolation на уровне БД, а не приложения — теоретически надёжнее. На практике RLS требует SET app.tenant_id = '...' в начале каждой транзакции, ломает stateless connection pooling (PgBouncer в transaction mode становится сложнее настраивать), и плохо работает с migrations и admin-задачами, где хочется обойти ограничение легально. Для solo-проекта на ранней стадии overhead RLS не оправдан — scoped_select + lint-правило в code review даёт 95% защиты при 5% сложности. На поздней стадии (десятки enterprise-тенантов с compliance-требованиями) RLS становится разумным дополнением, не заменой.

Решение №2: PostgreSQL EXCLUDE USING gist против double-booking

Два администратора одновременно бронируют один и тот же слот у одного врача. Оба видят слот свободным. Оба вызывают POST /appointments. Оба получают 200 OK. Двойная запись.

Защита «в коде» (SELECT для проверки → INSERT) под параллельной нагрузкой не работает. Между двумя запросами в одной транзакции другая транзакция успевает вставить пересекающийся слот. Я это увидел на первом же load-тесте.

Эволюция защиты на проекте шла в три стадии. Привожу реальные цифры из benchmark’ов на PostgreSQL 16, single-node:

Стадия 1 — application-level check. 12 конкурентных запросов на один слот → 4×200, 8×409. Видимы двойные записи. create_unique_appointments p95 = 26ms.

Стадия 2 — transactional advisory locks (pg_advisory_xact_lock по (doctor_id, time_window) перед INSERT’ом). 12 конкурентных → 1×200, 11×409. Race-condition закрыт, но create_unique_appointments p95 поднялся до 36.86ms из-за serialization overhead.

Стадия 3 — hard DB invariant через EXCLUDE USING gist. Те же 12 конкурентных → 1×200, 11×409, create_unique_appointments p95 = 31.69ms, conflict-path p95 = 11.22ms. Защита переместилась с приложения в schema, advisory lock больше не нужен.

DDL constraint:

``sql-- Alembic upgradeCREATE EXTENSION IF NOT EXISTS btree_gist;ALTER TABLE appointments ADD CONSTRAINT appointments_doctor_slot_excl  EXCLUDE USING gist (    doctor_id WITH =,    tsrange(starts_at, ends_at, '[)') WITH &&  )  WHERE (status NOT IN ('cancelled', 'no_show'));```

Что здесь происходит:

doctor_id WITH = — два диапазона рассматриваются как конфликтующие, только если у них одинаковый doctor_id.

tsrange(starts_at, ends_at, '[)') — полуоткрытый интервал; запись 10:00–10:30 и 10:30–11:00 не пересекаются.

&& — оператор пересечения диапазонов.

WHERE (status NOT IN ('cancelled', 'no_show')) — отменённые и неявки исключены, пациент после отмены может перезаписаться в тот же слот.

btree_gist нужен, потому что = для скалярного doctor_id через стандартный gist-оператор не работает; расширение добавляет btree-семантику внутрь gist-индекса.

Маппинг ошибки в HTTP в FastAPI:

``pythonfrom psycopg.errors import ExclusionViolationtry:    db.add(appointment)    db.flush()except IntegrityError as exc:    if isinstance(exc.orig, ExclusionViolation):        raise HTTPException(            status_code=409,            detail={                "code": "APPOINTMENT_SLOT_CONFLICT",                "message": "Слот занят другой записью",            },        )    raise```

tsrange или tstzrange? Я выбрал tsrange (naive timestamp без timezone), потому что в этом проекте все timestamp’ы хранятся в UTC, а конверсия в локальное время клиники делается на app-уровне через tenant.timezone. tstzrange работал бы, но добавил бы лишний слой неявных конверсий и потенциальных багов с DST. Если у вас политика хранения «timestamp with timezone везде» — берите tstzrange, разница только в том, как Postgres внутренне трактует диапазоны.

Подводные камни:

EXCLUDE создаёт gist-индекс, который растёт быстрее B-tree на больших объёмах. По [статье на Хабре про btree_gist benchmark](https://habr.com/ru/articles/820455/) — деградация записи до 2× и чтения до 20% на горячих таблицах. Для таблицы записей пациентов это окей (кардинальность невысокая), для таблицы событий с миллионами строк — задумайтесь.

WHERE-клауза partial constraint работает только при INSERT/DELETE. Cancellation должна быть UPDATE status='cancelled', не DELETE — иначе запись «исчезает», и слот через секунду занимает кто-то ещё, а отменённую запись уже не восстановить.

— В тестах нужен тот же диалект (PG, не SQLite). SQLite принимает DDL без ошибок, но никаких exclusion constraint не создаёт — баг в тестах не отловить.

Что я получил в итоге: при росте c 1 до 10 клиник в проде ни одной двойной записи. Не нужен Redis, не нужен distributed lock, не нужно application-level retry. Pos­tgreSQL делает всю работу.

Решение №3: 152-ФЗ как код

Для медицинских данных в РФ 152-ФЗ требует: хранения в российских ЦОДах, защиты данных при передаче и хранении, журнала доступа к ПДн, отдельного согласия на обработку специальных категорий, возможности удаления/анонимизации по запросу. Что из этого превращается в код:

Шифрование паролей: миграция PBKDF2 → argon2id без forced reset

Изначально пароли хешились через PBKDF2-HMAC-SHA256 со 120 000 итераций — стандарт OWASP до 2023 года. Современная рекомендация OWASP/NIST — argon2id (memory-hard, устойчивее к GPU-атакам). Резкая миграция через forced password reset недопустима в B2B (десятки сотрудников клиники, регистратор не сможет залогиниться утром понедельника), поэтому работает прозрачная схема: новые пароли пишутся как argon2id, старые верифицируются по своему формату, при успешном логине молча перехешируются в argon2id.

Ключ — детектирование формата по префиксу хеша:

```pythonimport hashlibimport secretsfrom argon2 import PasswordHasherfrom argon2 import exceptions as argon2_exceptionsPASSWORD_ITERATIONS = 120_000  # legacy PBKDF2 для backward-compatPASSWORD_PREFIX = "pbkdf2_sha256"ARGON2_PREFIX = "$argon2"PASSWORD_HASHER = PasswordHasher()  # argon2id с дефолтными параметрамиdef hash_password(password: str) -> str:    """Все новые пароли — argon2id."""    return PASSWORD_HASHER.hash(password)def verify_password(password: str, encoded: str) -> bool:    if encoded.startswith(ARGON2_PREFIX):        try:            return PASSWORD_HASHER.verify(encoded, password)        except (argon2_exceptions.VerifyMismatchError,                argon2_exceptions.InvalidHashError):            return False    if encoded.startswith(f"{PASSWORD_PREFIX}$"):        _, iterations, salt, digest = encoded.split("$", 3)        computed = hashlib.pbkdf2_hmac(            "sha256", password.encode(), salt.encode(), int(iterations)        )        return secrets.compare_digest(computed.hex(), digest)    return Falsedef password_needs_rehash(encoded: str) -> bool:    if encoded.startswith(f"{PASSWORD_PREFIX}$"):        return True  # любой PBKDF2 → upgrade в argon2id    if encoded.startswith(ARGON2_PREFIX):        return PASSWORD_HASHER.check_needs_rehash(encoded)    return False```В `/auth/login` после успешной верификации:```pythonif password_needs_rehash(user.password_hash):    user.password_hash = hash_password(plain_password)    db.commit()```

Параметры argon2id и trade-off.** Я использую дефолты argon2-cffi: memory_cost=65536 (64 MiB), time_cost=3, parallelism=4. На моём VPS это даёт login latency около 80ms — приемлемо для пользователя, дорого для атакующего (брутфорс на GPU становится непрактичным из-за memory-bound nature алгоритма). Если пилотных клиник будет под 100 одновременно логиниться, можно снизить memory_cost до 32 MiB, но пока я в это ограничение даже близко не упёрся.

Почему argon2id, а не bcrypt или scrypt? bcrypt стабилен, но не memory-hard и проигрывает GPU-фермам. scrypt memory-hard, но менее tunable между tradeoff’ами latency/memory. argon2id — победитель Password Hashing Competition 2015, рекомендация OWASP с 2023, и его подход «hybrid» комбинирует устойчивость argon2i к side-channel атакам с GPU-resistance argon2d. Для регулируемой B2B это разумный default.

Audit log с классификацией категорий ПДн

Каждая операция с данными пациента пишется в audit_logs с полем data_category. Этот enum нужен, чтобы при запросе субъекта (или Роскомнадзора) можно было одним SELECT’ом выгрузить только релевантные категории, а не парсить entity_type/action по строкам:

```pythonclass AuditLog(Base):    __tablename__ = "audit_logs"    id: Mapped[str] = mapped_column(String, primary_key=True)    tenant_id: Mapped[str] = mapped_column(String, index=True)    actor_user_id: Mapped[Optional[str]] = mapped_column(String, nullable=True)    entity_type: Mapped[str] = mapped_column(String)    entity_id: Mapped[str] = mapped_column(String)    action: Mapped[str] = mapped_column(String)    # 152-ФЗ ст. 10 п. 2 — отдельная маркировка спец.категорий ПДн.    data_category: Mapped[str] = mapped_column(String, default="general", index=True)    before_json: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)    after_json: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)    ip: Mapped[Optional[str]] = mapped_column(String, nullable=True)    created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow)    __table_args__ = (        Index("ix_audit_logs_tenant_created", "tenant_id", text("created_at DESC")),    )````data_category` — энум на три значения, который проставляется не вручную, а автоматическим классификатором по `entity_type`:```python_AUDIT_SPECIAL_HEALTH_ENTITIES = frozenset({    "patient", "patient_consent",    "appointment", "encounter", "notification",    "compliance_retention",})_AUDIT_IDENTITY_ENTITIES = frozenset({"user", "user_group"})def _classify_audit_entity(entity_type: str) -> str:    if entity_type in _AUDIT_SPECIAL_HEALTH_ENTITIES:        return "special_health"    if entity_type in _AUDIT_IDENTITY_ENTITIES:        return "identity"    return "general"```

special_health включает не только диагнозы — туда попадают appointment и notification`, потому что сам факт записи к конкретному врачу + причина обращения уже считаются специальной категорией ПДн в строгой трактовке 152-ФЗ ст. 10. Conservative-классификатор лучше, чем недоклассификация: если регулятор спросит «какие данные о здоровье вы собираете» — лучше ошибиться в сторону «больше», чем «меньше».

Чем 152-ФЗ отличается от GDPR. Один в один шаблон не переносится: 152-ФЗ ближе к американскому HIPAA в том, что отдельно выделяет специальные категории с дополнительными требованиями к согласию и хранению, плюс residency-требование (только РФ-ЦОДы) и обязательная регистрация в реестре операторов ПДн в РКН. GDPR-фреймворк controller/processor/lawful-basis по форме похож, но регулятор и бумажки другие.

Operational tooling

Два инструмента, без которых я бы не доверял БД пилотного проекта.

1. Read-only integrity check. Скрипт на семь стратегий, каждая ловит класс аномалий, который SQL-constraint не отлавливает:

Стратегия

Что ловит

orphan-fk + tenant-scope

Записи на несуществующие parents и cross-tenant FK leaks

state-machine + date-invariants

status='finalized' AND finalized_at IS NULL, ends_at <= starts_at

logical-duplicates + soft-delete + audit-log

Дубликаты телефона/паспорта per tenant, анонимизированные с PII, data_category вне enum

Запускается против локального restore prod-дампа (не против самого prod — long-running scan на горячих таблицах блокирует writers). Exit code 1 при наличии findings — годится как CI-gate перед мажорной миграцией.

2. Schema drift detection.** Перед каждым релизом — full cycle на чистой PG:

```bashmake postgres-upDATABASE_URL=... alembic upgrade headDATABASE_URL=... alembic check  # автоген diff против моделей```

alembic check не идеален: не видит JSONB→JSON рассинхрон между Postgres и SQLite, не различает Index(unique=True) vs UniqueConstraint. Но базовые drift’ы между models.py и реальной схемой ловит надёжно. На локальной dev-БД с накопленным мусором этот workflow не работает — drift маскируется существующими объектами.

Пять выводов

1. Multi-tenant с первого коммита через scoped_select helper и user.tenant_id — даже если тенант один. Захардкоженный config.TENANT_ID — это техдолг, который выстреливает на втором тенанте и требует ревизии каждого endpoint’a.

2. Защиту от race-condition бронирований делать DB-level через EXCLUDE USING gist + tsrange — дешевле и надёжнее application-level locking. Реальные цифры: 12 конкурентных запросов → 1×200, 11×409, p95 31.69ms. Не требует Redis или distributed координации.

3. 152-ФЗ-compliance — это поля и helpers, не маркетинговая галочка. data_category enum в audit_logs с автоклассификатором по entity_type, прозрачная миграция PBKDF2 → argon2id через password_needs_rehash, отдельный patient_consents со scoped версионированием.

4. DB integrity check как операционный артефакт: семь категорий аномалий, которые SQL constraints не ловят. Запуск против локального restore прод-дампа перед мажорным релизом — недорогая страховка.

5. Schema drift detection в CIalembic upgrade head + alembic check на чистой PG. На dev-БД с накопленным мусором не работает: drift маскируется существующими объектами.

ссылка на оригинал статьи https://habr.com/ru/articles/1033488/