user@elrise.ru:~
2026-07-2620 min readphpsymfonydoctrinedbalarchitecturereplicationmulti-dc

Active-active multi-DC репликация в Symfony: четыре уровня решений на DBAL + Doctrine

В multi-DC Symfony-проектах задача «доставить запись из текущего DC в соседний» каждый раз собирается из одних и тех же кусков, и каждый раз — по-разному. Doctrine routing, PostFlush listener, DBAL-исключения, Symfony Messenger и outbox-таблица — все эти компоненты есть в стеке, но между ними нет единой границы ответственности. Эта заметка — архитектурное исследование пространства решений, в котором находится эта задача на уровне DBAL и Doctrine: какие уровни решений в нём различимы, что в каждом закрыто существующими инструментами, и где остаются места, для которых готового решения нет.

Готового бандла или библиотеки, которую я мог бы здесь рекомендовать, на момент написания статьи не существует. Здесь нет кода и нет инструкции «как включить». Есть карта, и есть узлы, которые в этой карте остаются серой зоной.

Не сравнение подходов

Я не сравниваю AWS Aurora Global, RDS cross-region read replica и GCP Cloud SQL DR replica как варианты managed-инфраструктуры — это уровень, который лежит ниже той части стека, которую я разбираю. Не сравниваю Kafka, RabbitMQ и SQS как брокеры — это уровень транспорта, который в active-active выбирается отдельно. В этой статье зафиксирован один срез: PHP/Symfony-приложение на Doctrine ORM 3.x и DBAL 4.x, которое должно доставить свою запись из текущего DC в соседний, и то, какие решения для этого приходится принять.

Откуда это

В моей практике раз за разом возвращалась одна и та же картина: одна и та же задача «доставить INSERT из DC-1 в DC-2 надёжно» собиралась заново под каждый проект. Разными руками, в разных местах кода, с разными retry-политиками и с разными способами отслеживать, что уже доставлено, а что ещё нет. Грабли были одни и те же — попытки повторить операцию при unique-key violation, потерянные outbox-строки при crash relay-процесса, рассинхрон worker-ов при failover, десериализация старых сообщений после breaking-change в классе. Каждый раз решение выглядело как набор узкоспециализированных хаков внутри одного проекта.

Это исследование — попытка собрать эти узлы в одну карту и посмотреть, можно ли из неё выделить общие ответственности, которые должны жить самостоятельно. Пока что ответ «можно, но в существующей экосистеме для них нет готового места». Этого достаточно, чтобы зафиксировать карту, не пытаясь её «доделать» прямо сейчас.

Что утверждается и что не утверждается

Что утверждается. На уровне DBAL + Doctrine задача active-active multi-DC репликации распадается на четыре независимых уровня решений: routing DBAL-соединений, извлечение реплицируемого действия из Doctrine UnitOfWork, классификация DBAL-исключений, и связка «outbox + idempotency + transport». На каждом из этих уровней существующие инструменты закрывают только часть ответственности. Стыки между уровнями — серая зона, в которой любая конкретная реализация вынуждена принимать собственные архитектурные решения. Без зафиксированной границы между «знанием о топологии» и «механизмом доставки» эта задача решается в каждом проекте отдельно.

Чего эта статья не устанавливает.

Для кого это написано


Карта: четыре уровня решений

Задача active-active multi-DC репликации выглядит монолитной только снаружи. Внутри стека — это последовательность из четырёх относительно независимых решений:

  1. DBAL connection routing — какое именно соединение использовать для записи и для чтения в текущем DC.
  2. Извлечение реплицируемого действия из Doctrine UnitOfWork — что именно реплицировать: какие Entity, какие поля, в какой момент ORM-цикла.
  3. Классификация DBAL-исключений — какой из failure-исходов retryable, какой — конфликт, какой — фатальная ошибка.
  4. Связка «outbox + idempotency + transport» — каким способом доставить действие в другой DC, каким способом гарантировать, что оно применилось ровно один раз.

Каждый уровень существует на своей части стека, у каждого свои инструменты и свои слепые зоны:

   ┌─────────────────────────┐
   │  Symfony HTTP / Worker  │   ← транспорт входа
   └────────────┬────────────┘
                │
   ┌────────────▼────────────┐
   │  Application + Domain   │
   └────────────┬────────────┘
                │
   ┌────────────▼────────────┐
   │  Doctrine ORM + DBAL    │   ← уровни 1 + 2 + 3
   └────┬─────────┬──────────┘
        │         │
   ┌────▼───┐ ┌───▼─────────────────────┐
   │ routing│ │ action extract          │
   │ conn 1 │ │ from UnitOfWork         │
   │ Уров.1 │ │ Уровень 2              │
   └────────┘ └─────────────────────────┘
                         │
                         ▼
              ┌──────────────────────────┐
              │ DBAL exception classifier│
              │ Уровень 3               │
              └──────────────────────────┘
                         │
                         ▼
       ┌──────────────────────────────────┐
       │ outbox + idempotency + transport │
       │ Уровень 4                        │
       └──────────────────────────────────┘

Уровни в стеке идут сверху вниз: сначала решается, в какое соединение писать (Уровень 1), затем что за действие из этого вышло (Уровень 2), затем как обработать исключение (Уровень 3), затем через какой механизм доставить его в другой DC (Уровень 4). Эта карта — основной объект, который ниже разбирается уровень за уровнем.


Уровень 1. DBAL connection routing

Это самая верхняя часть пространства решений. Здесь решается, в какое DBAL-соединение пойдёт INSERT и из какого пойдёт SELECT. Doctrine в Symfony даёт для этого две абстракции: Connection (DBAL) и EntityManager (ORM поверх Connection). На уровне DBAL через конфигурацию можно описать несколько named connections, на уровне ORM — переключать EntityManager через ManagerRegistry или через явный ManagerRegistry::getManager().

В active-active сценарии внутри одного DC это выглядит так:

    Логика в приложении:
        $em->persist($entity);
        $em->flush();

    Doctrine внутри:
        $conn = $em->getConnection();
        $sql  = "INSERT INTO orders ...";
        $conn->executeStatement($sql, $params);

    DBAL routing в текущем DC:
        primary  → <active>     ← пишем сюда
        replica  → <standby>    ← читаем обычно отсюда
        при failover DC: DSN swap через Route53 / Consul

Приложение пишет в «текущее соединение», а реальный адрес уже меняется инфраструктурой под капотом. Когда DC переключается — Connection пересоздаётся с новым DSN, и дальше та же бизнес-логика продолжает писать.

Что закрыто средствами Symfony и Doctrine.

Это встроенная инфраструктура, и она закрывает маршрутизацию соединений в обычном смысле — какой Connection открыть, какие credentials передать, как пересоздать при ошибке. Дальше начинаются развилки, которые Symfony не делает за вас.

Архитектурные развилки на Уровне 1

Развилка A. Sticky в Doctrine — не фича, а контракт на уровне use-case-а. В Laravel sticky=true — это опция конфигурации, которая включает правило «после первой записи в этом request cycle все SELECT-ы идут в то же соединение». В Doctrine такой опции нет, и заводить её через KernelEvent или middleware означает перенести решение с уровня «глобально для проекта» на уровень «глобально для проекта, но с осознанной точкой включения». Конкретнее: sticky — это контракт между use-case-ом и инфраструктурой: use-case утверждает «пока я пишу в эту транзакцию, я хочу, чтобы все SELECT-ы в ней шли туда же». В Doctrine-сборке этот контракт реализуется либо через Connection::beginTransaction() с явным read-your-write до конца транзакции (тогда все запросы в транзакции идут в одну Connection), либо через дополнительный middleware на KernelEvent, который при первом INSERT/UPDATE помечает request-level флаг, и Connection-обёртка перехватывает SELECT. Оба варианта рабочие; первый — проще и явно scoped, второй — нужнее, когда транзакция неприменима. Архитектурная развилка: какой scope у sticky — уровень одного запроса, уровень одного use-case-а, или уровень одной транзакции.

Развилка B. Атомарный swap нескольких named connections при failover. Когда DC переключается на standby, DSN должен поменяться у всех Connection-ов одновременно: write / read / analytics / outbox — каждый из них по отдельной строке в Symfony-конфиге (dbal: connections). Connection::close() + re-instantiate работают, но без явного механизма синхронизации вы рискуете получить момент, когда write уже указывает на новый DC, а read ещё держит старый. Это превращается в транзакцию-кросс-DC со всеми вытекающими семантическими проблемами. Архитектурная развилка: сигнал failover-а раздаётся через Symfony EventDispatcher (kernel.dc_failover event), и каждый Connection-биндинг имеет свой subscriber, который делает close() + reconnect() в нужном порядке. Альтернатива — собрать все Connection-ы под единым фасадом и переключать фасад целиком. Первый вариант гранулярнее, второй — атомарнее. Оба применимы; фундаментальная развилка в том, что это соединения Symfony, и EventDispatcher у проекта уже есть.

Развилка C. Read-after-write между запросами. sticky=true (или его Doctrine-эквивалент) решает read-after-write внутри одного запроса. Но это не закрывает read-after-write между запросами: пользователь сделал POST в DC-1, после редиректа попал на GET в DC-2, и там реплика, которая ещё не видела его запись (лаг 0.5–1 сек). Это фундаментальное свойство любой асинхронной репликации, framework его не отменяет. Архитектурно use-case-у нужен собственный контракт на уровень согласованности, и Symfony 7 + Doctrine 3 его не предлагает как встроенный. Конкретный паттерн: метод репозитория с явным аргументом согласованности — findById($id, ReadConsistency::EVENTUAL|STRICT). EVENTUAL идёт в текущий read-connection, STRICT — форсирует read-connection в primary этого DC через явный Connection::executeStatement или через отдельный repository-метод. Это контракт, который use-case-обязан заявить, а инфраструктурный слой — исполнить. Без него read-after-write лотерея.

Развилка D. Инвариант «не вызывать flush на replica-EntityManager». Когда в проекте несколько EntityManager-ов, скажем em.write и em.read, и они ссылаются на разные Connection-ы (один на primary, другой на replica), архитектурная ошибка — вызвать $em.read->flush(). Это либо молчаливо сломает replica, либо (если replica read-only) выбросит исключение. Без явного запрета в коде такое легко сделать в крупной кодовой базе. Архитектурные варианты: разделить через рефлексию в DI (отдельный интерфейс WriteEntityManagerInterface, реализация которого не существует для replica), либо через проверку роли connection на этапе flush(). Первый вариант строже (compile-time ошибка при попытке инжекта), второй — мягче (runtime-проверка). Оба валидны; развилка — где в проекте эта дисциплина живёт.

На Уровне 1 это всё. Самые важные развилки — A (sticky как контракт) и C (read-after-write между запросами), они протекают выше по стеку в Уровни 2, 3 и 4. Сложности репликации в полный рост проявляются дальше.


Уровень 2. Doctrine UnitOfWork → ReplicationAction

Когда INSERT в текущем DC прошёл, нужно решить, что именно реплицировать в другой DC. Здесь мы переходим из territory Doctrine ORM в зону приложения: что считается «реплицируемой записью» и как из ORM-цикла извлечь серилизуемое представление этого действия.

ORM-цикл, на который приходится подписываться:

   UnitOfWork.computeChangeSet()
          │
          ▼
   UnitOfWork.executeInserts / Updates / Deletes
          │
          ▼
   [ commit в текущем DC ]
          │
          ▼
   PostFlush event            ← момент, на котором
   или OnFlush + снимок        ← приложение может
                                 «увидеть» действие
          │
          ▼
   ReplicationAction {                ← серилизуемое
     table,                           представление
     op,
     entity_id,
     payload_json,
     target_dc_hints,
     idempotency_key
   }

PostFlush listener — стандартный hook Doctrine (см. Doctrine ORM Events). Но к моменту, когда он срабатывает, UnitOfWork уже синхронизировал состояние, и достать что-то осмысленное из Entity напрямую — нельзя: там уже лежит «актуальное» состояние, а не «дельта». Реальные реализации поэтому либо подписываются на OnFlush (когда change set ещё доступен, см. UnitOfWork::getEntityChangeSet()) и параллельно держат свой снимок, либо комбинируют PrePersist + PostFlush. Это первая конкретная сложность уровня 2.

Что закрыто средствами Symfony и Doctrine.

Эти примитивы закрывают верхнюю половину Уровня 2: подписка на ORM-события, доступ к состоянию, серилизация, миграции. Дальше — развилки, которые этот набор не фиксирует.

Архитектурные развилки на Уровне 2

Развилка A. Снимок до flush-а: где взять дельту. PostFlush — стандартный hook, но к моменту его срабатывания UnitOfWork уже синхронизировал состояние: «актуальное» значение Entity в памяти уже равно тому, что ушло в БД, и достать дельту из Entity нельзя — её уже нет. OnFlush, который срабатывает раньше и даёт доступ к getEntityChangeSet(), решает эту задачу, но требует от listener-а собственного хранилища «что изменилось в этой транзакции»: пока OnFlush смотрит change set, listener держит в памяти (или в request scope) собранное представление действия и достаёт его в PostFlush. Архитектурная развилка — где именно этот snapshot живёт: в RequestStack (тогда он per-request, и worker-ы вне HTTP-контекста требуют отдельного скоупа), в SplObjectStorage (тогда GC ручной), или в отдельном in-memory service с thread-local семантикой (тогда жизненный цикл привязан к транзакции или процессу worker-а). Каждый из этих вариантов имеет конкретные последствия: например, snapshot в RequestStack не работает для CLI-команд и worker-ов, что важно для проектов, где репликация идёт из background jobs.

Развилка B. Серилизация Entity в Messenger Message. Entity в Doctrine содержит lazy-loaders и прокси-объекты, а также bidirectional references в коллекциях. Прямой serialize() ломается на циклах и на прокси. Symfony Serializer закрывает эту задачу через нормализаторы и #[Groups], но требует аккуратного проставления групп: какие поля «видны» для репликации, какие — нет. Конкретная архитектурная развилка — какой уровень серилизации выбрать. Вариант первый: DTO-класс на каждую Entity, отображаемый вручную (Order::toReplicationMessage()). Это явный и проверяемый контракт, но boilerplate-heavy. Вариант второй: #[Groups] на полях Entity плюс SerializationContext в Symfony Serializer. Меньше кода, но контракт «размазан» по классам Entity и требует дисциплины при добавлении новых полей (любое поле без группы уйдёт в реплику как null, если только группы не inverse). Вариант третий: отдельный Normalizer под Message-тип, который знает только нужные поля. Максимально явный, минимально boilerplate. Развилка — где жить этому контракту: на Entity, на отдельном DTO, или в Normalizer-е.

Развилка C. Маркер «реплицируемая Entity». Symfony 7 + Doctrine 3 не предлагают attribute для маркировки Entity на репликацию. Варианты, которые есть: PHP-attribute вроде #[Replicable] (читается через reflection в Doctrine-слушателе), Doctrine MappedSuperclass или интерфейс-маркер вроде ReplicableEntityInterface (отслеживается через ClassMetadata), или YAML-конфиг бандла. У каждого варианта — своя failure mode. Attribute решается декларативно и автодополняется IDE, но требует reflection в рантайме. Интерфейс решается декларативно и поддерживает type-checker (psalm/phpstan), но не виден из имени класса и плохо ищется. YAML решается централизованно и не требует PHP-кода для маркировки, но распадается между кодовой базой и конфигом. Критичный момент развилки — это не «как технически пометить», а «что делать с unmapped Entity». Свободный дефолт «всё реплицируется» опасен для финансовых write-путей, потому что audit-only поле или региональный флаг поедет в target DC и вызовет конфликт схемы. Консервативный дефолт «ничего не реплицируется без явной маркировки» требует дисциплины при добавлении новых Entity, но устраняет класс утечек. Развилка — какой дефолт у проекта.

Развилка D. Partial replication: только часть полей в другой DC. Не все поля Entity должны идти в другой DC: audit-поля (created_by, request_id), персональные данные, региональные флаги. У Doctrine нет стандартного способа маркировать поля для репликации, и это та же проблема, что и у маркера Entity, но на уровне отдельных свойств. Решение — #[Groups] плюс SerializationContext из развилки B, либо явное DTO-отображение. Развилка — на каком уровне описывать список полей, идущих в реплику: на Entity, на DTO, или в конфиге. Это пространство одного решения с тремя проявлениями.

Уровень 2 — та точка, где граница между Doctrine ORM и приложением становится видимой: ORM-цикл завершён, но приложение ещё не решило, что с этим делать. Listener на этом стыке — естественное место для отдельной ответственности, и без явного описания этой ответственности она размазывается.


Уровень 3. Классификация DBAL-исключений

Когда INSERT в другой DC завершился неудачей (или когда relay-процесс из outbox пытается доставить ReplicationAction), важно понять, можно ли повторить операцию или ошибка фатальная. Это уровень, на котором DBAL-исключения превращаются в один из трёх вердиктов: retryable, conflict, fatal.

DBAL-исключение несёт SQLSTATE-код (для PostgreSQL) или MySQL ER_* / SQLSTATE (для MySQL). На практике классификация строится именно по этому коду через Doctrine\DBAL\Exception\*getSQLState().

КатегорияSQLSTATE (пример)MySQL эквивалентЧто с ним делать
retryable40001 (deadlock detected), 40P01 (connection failure), 08006 (server gone)1213 (deadlock), 2006 (server has gone away)повторить с backoff
conflict (duplicate key)23505 (unique violation), 23000 (integrity violation)1062 (duplicate entry)resolver: проверить, кто первый закоммитил
fatal42P01 (undefined table), 42703 (undefined column), 42601 (syntax error)1146 (table doesn’t exist), 1064 (syntax error)записать в лог + уведомить оператора, не retry

Текстовый эквивалент таблицы: Doctrine\DBAL\Exception\UniqueConstraintViolationException несёт SQLSTATE 23505 — это значит «уже есть запись с таким PK». В active-active это либо потому, что в target DC запись уже реплицирована из предыдущей попытки, либо потому, что в target DC независимо создали Entity с тем же id. Ни то, ни другое не retryable — нужно либо резолвить конфликт (понять, кто первый закоммитил), либо тихо пропустить. С другой стороны, Doctrine\DBAL\Exception\DeadlockException (SQLSTATE 40001, MySQL 1213) — это транзиентный deadlock, повтор операции через десятки миллисекунд обычно успешен.

Что закрыто средствами Doctrine.

Этого достаточно для построения классификатора своими руками — но не достаточно для готовой классификации «retryable / conflict / fatal». Дальше развилки.

Архитектурные развилки на Уровне 3

Развилка A. SQLSTATE-классификатор: portable vs driver-aware. У DBAL-исключений есть getSQLState(), который возвращает пятисимвольный код. SQLSTATE-классификация — переносима между PostgreSQL и SQL Server, но в MySQL это отдельная история: Doctrine\DBAL\Driver\PDO\Exception тянет из PDO-кода, и там SQLSTATE есть не всегда в ожидаемом формате. Архитектурная развилка — на каком уровне писать классификатор. Вариант первый: match по SQLSTATE-кодам ('40001' => Retryable, '23505' => Conflict, '42P01' => Fatal…). Переносимо, но не покрывает MySQL-специфичные коды вроде 1213 (deadlock) в их нативной форме. Вариант второй: два уровня — Retryable/Conflict/Fatal для общих SQLSTATE + driver-specific overrides для MySQL/Oracle. Более полно, но требует тестов для каждого драйвера. Вариант третий: мапинг на уровне DBAL — слушать не SQLSTATE, а тип исключения: UniqueConstraintViolationException всегда Conflict, DeadlockException всегда Retryable, ConnectionLost всегда Retryable. Этот вариант проще, явно express’ивен и использует уже встроенную в DBAL семантику. Развилка — баланс между portable SQLSTATE (шире, но менее точно) и DBAL exception type (точнее, но привязано к Doctrine).

Развилка B. Retry-budget: finite задержки или circuit breaker. Retryable исключение можно ретраить снова и снова — но network partition может длиться часами, и бесконечный retry превращается в скрытую DoS-атаку на собственный worker-пул. Архитектурная развилка — какой механизм ограничения использовать. Вариант первый: max_attempts в Symfony Messenger retry_strategy middleware (простой), но он считает retry на сообщение, а не на конкретный outbox-row. Вариант второй: специализированный retry-декоратор на handler-е (из Уровня 4), который знает outbox-id и может сказать «эта конкретная строка уже пыталась пять раз, дальше — в dead-letter queue». Вариант третий: circuit breaker на уровне handler-а или service-фасада — после N подряд retryable-исключений открывается circuit, handler возвращает ошибку немедленно без обращения к БД, и внешний scheduler решает, когда circuit закрыть обратно. Каждый вариант решает свою часть проблемы: max_attempts хорош для одиночных сбоев, retry-декоратор с outbox-id — для долгоиграющих репликаций, circuit breaker — для региональных отказов. Развилка — какой профиль сбоев вы оптимизируете.

Развилка C. Conflict resolution: last-writer-wins или ручной merge. Когда классификатор вернул Conflict (например, 23505 unique violation в active-active), ошибка не retryable, и реакция по умолчанию — пропустить или записать в dead-letter queue. Но финансовые write-пути обычно требуют более тонкой реакции: «кто первый закоммитил, тот и выиграл; вторичная запись считается погашенной». Это last-writer-wins по timestamp. Развилка — какой арбитраж использовать. last-writer-wins по timestamp дёшев, но в active-active надёжен только при синхронизированных часах (NTP-страж, иначе лотерея). Альтернатива — vector clock per record (сложнее, точнее). Ещё альтернатива — «никогда не доверять target» (target DC всегда ждёт нового state от primary DC). Каждая из этих политик имеет своё место; развилка — на каком уровне она живёт: на уровне Outbox-relay (сравнение timestamp-ов), на уровне handler-а (сравнение с уже-применённой записью в target DC), или на уровне use-case-а (явное разрешение конфликта в домене).

Развилка D. Outbox-context: как retry-цикл связан с outbox-строкой. Когда handler выполняет операцию, и операция fails с Retryable-исключением, retry-цикл начинает новую попытку. Но outbox-relay, который отправил это сообщение, тоже мог retry-нуть отправку, если broker или сеть «подавился». Это значит, что handler может получить сообщение второй раз из-за relay-retry, а не из-за своей неудачи. Если handler и relay работают независимо, возникает двойная доставка. Архитектурно это решается через единый message-id (UUID outbox-строки), который handler проверяет в начале выполнения: «уже выполнено? пропустить». Это вход в Уровень 4, но точка развилки здесь: где живёт этот message-id и кто его проверяет — handler, middleware, или outbox-relay-сторона. Каждый вариант распределяет работу между уровнями 3 и 4 по-разному.

Уровень 3 — самый дешёвый по количеству кода (классификатор на 30–60 строк), и самый дорогой по ошибкам в реализации. Неправильный вердикт на этом уровне либо теряет данные (флага retryable на conflict), либо зацикливает без лимита, либо записывает погашенные записи как успешные. В финансовых write-путях этот уровень должен быть хорошо оттестирован.

Уровень 3 — самый дешёвый в смысле кода (классификатор на 30 строк), и самый важный в смысле правильности. Неправильный вердикт на этом уровне либо теряет данные (флага retryable на conflict), либо зацикливает (флага retryable на fatal без лимита). В финансовых write-путях этот уровень нельзя отдавать на откуп «случайной обёртке».


Уровень 4. Outbox + idempotency + transport

Когда ReplicationAction зафиксирован (Уровень 2) и классификатор (Уровень 3) понимает, что делать при ошибке, остаётся главное: как именно действие попадёт в другой DC. Здесь три ответственности, которые легко перепутать, но они ортогональны.

   ┌──────────────────────────────┐
   │ Бизнес-логика                │
   │ BEGIN;                       │
   │ INSERT INTO orders ...;      │
   │ INSERT INTO outbox ...;      │  ← атомарная транзакция
   │ COMMIT;                      │     в текущем DC
   └──────────────┬───────────────┘
                  │
                  ▼
   ┌──────────────────────────────┐
   │ Relay worker (отдельный)     │
   │ SELECT FROM outbox           │
   │   WHERE status='pending';    │
   │ publish в Messenger;         │
   │ UPDATE outbox SET sent=      │
   │   publisher_confirm_ts;      │  ← только после publisher confirms
   └──────────────┬───────────────┘
                  │
                  ▼
   ┌──────────────────────────────┐
   │ Symfony Messenger            │
   │ dispatch(ReplicationAction)  │
   │ в transport DC-2             │
   └──────────────┬───────────────┘
                  │
                  ▼
   ┌──────────────────────────────┐
   │ Worker в DC-2                │
   │ consume ReplicationAction    │
   │ Idempotency-Key check        │  ← либо применить,
   │ apply action к локальной DB  │     либо вернуть кэш
   └──────────────────────────────┘

Transport. В Symfony это Symfony Messenger: MessageBus, Middleware, transport (Doctrine / AMQP / Redis / SQS / InMemory). Messenger поддерживает multi-transport через TransportNamesStamp: одно сообщение можно диспатчить в разные transports по условию. Транспорт — это «как донести байты», и в Symfony Messenger он про DC-топологию не знает. Маршрутизация между DC делается либо через отдельные транспорты per DC (например, amqp_dc1, amqp_dc2), либо через application-level URL-per-DC, либо через runtime-инжекцию.

Outbox. Это классический паттерн Transactional Outbox: бизнес-транзакция в текущем DC коммитит INSERT/UPDATE в рабочие таблицы и INSERT в outbox-таблицу атомарно. Отдельный relay-процесс читает outbox-таблицу, публикует в transport и помечает строку sent. Это закрывает класс «двойной записи» (DB и broker в одной транзакции — невозможно физически; DB и outbox — атомарны).

Idempotency. Это требование не к transport, а к API на стороне target DC. Каждое state-changing действие, прилетающее из другого DC, либо несёт Idempotency-Key (UUID), либо умеет отбрасывать дубли. Pattern известен как Stripe-style Idempotency-Key. В Symfony это либо middleware на Kernel Request, либо проверка в Messenger middleware, с хранением ключа в локальной dedup-таблице с TTL.

Что закрыто инструментами из коробки.

Что здесь НЕ закрыто.

Уровень 4 — самый высокий по количеству ручной работы. «Правильная» реализация требует согласования outbox + transport-retry-stance + idempotency-store + classifier-а — это четыре разные ответственности в одной связке, и без отдельной модульной границы между ними каждая из них живёт в своём месте в проекте.


Граница между знанием о топологии и механизмом доставки

Через все четыре уровня проходит одна и та же граница: где в коде заканчивается «знание о том, какие DC существуют и какой из них обслуживает какую запись», и где начинается «механизм доставки этой записи в нужный DC». В существующих PHP-проектах, которые я видел, эти две ответственности склеены. Один и тот же listener знает и про DC-топологию (откуда взять id-DC), и про retry (как повторить), и про outbox-формат (как записать в таблицу), и про idempotency (как проверить ключ). Когда что-то одно меняется — добавляется новый DC, или вводится новый формат payload-а, или меняется retry-политика — приходится трогать всё.

Каждая из двух ответственностей имеет свой естественный интерфейс.

Знание о топологии. У него один вход — какой-то идентификатор (UUID заказа, UUID операции, hash от естественного PK), и один выход — конкретный DC, который обслуживает эту запись. Источник этого знания — конфиг (ENV, Consul, k8s ConfigMap, файл) + правило вычисления (deterministic hash, codec-based extraction из UUID, lookup-таблица). Внутри себя эта ответственность может быть очень простой: provider, который возвращает текущий DC, и resolver, который по идентификатору возвращает target DC. Между provider и resolver — минимум логики.

Механизм доставки. У него другой вход — уже вычисленный target DC + replication action + retry policy, и другой выход — доставленный action или явный отчёт об ошибке. Внутри — outbox, transport, retry-декоратор, classifier исключений, idempotency-check. Каждая из этих частей — отдельный модуль со своим контрактом, и они должны быть заменяемы без влияния на топологический слой.

Граница между ними — это контракт вида: «если у меня есть id и action, доставь его в правильный DC, с заданной retry-политикой, и сообщи мне, что произошло». Пока этот контракт не зафиксирован, каждый проект реализует его сам, по-разному, и в каждом проекте репликация работает чуть-чуть иначе. Эта разница — главный источник того, почему multi-DC Symfony-проекты не имеют общего «как мы делаем репликацию», а имеют общее «каждый делает как умеет».

Есть и третий кандидат на границу, который обычно живёт на стыке уровней 1 и 2: что вообще считать «реплицируемой записью». Это маркер вроде атрибута на Entity. Маркер ставит границу между «Entity, чьи изменения мы фиксируем для репликации» и «Entity, чьи изменения остаются локальными». В Symfony устоявшегося соглашения об этом маркере нет, и где он живёт — атрибут, YAML-конфиг или convention — это открытый вопрос для каждого проекта.


Узлы боли, которые повторяются в проектах

Этот список — не «вот проект, в котором всё плохо», а конкретные места, где отсутствие стандарта приводит к расхождениям между проектами.

Узел 1. Sticky vs read-your-write между request-ами. sticky=true в Laravel решает read-your-write внутри одного request cycle. После второго запроса от пользователя, который попал в другую реплику через балансер, read-your-write перестаёт работать. Это фундаментальное свойство асинхронной репликации; framework его не отменяет, но почти ни один проект не фиксирует явно, что «fresh after write» — это требование, а не дефолт.

Узел 2. Версионирование сообщений через деплой. Symfony docs это описывают и дают протокол «V1 → V2, дождаться дренажа, удалить V1». На практике этот протокол не автоматизирован. Каждый проект проходит один и тот же путь: деплоится breaking change в message-классе, worker-ы в одном DC уезжают на V2, в другом DC ещё работают на V1, и появляются worker-ы, которые не могут десериализовать V1-сообщения. Это повторяемая ошибка, и она не закрывается автоматикой Symfony Messenger.

Узел 3. Outbox-relay и его durability. Outbox-pattern предполагает, что relay читает pending-строки и помечает их sent. Если relay умер между чтением и пометкой — строка будет прочитана заново (это нормально). Если relay умер после пометки, но до отправки в broker — событие потеряно (это плохо). Решение «пометка после publisher confirms» решает второе, но требует интеграции transport-уровня с relay-уровнем. Symfony Messenger из коробки этого паттерна не предлагает.

Узел 4. Idempotency-key на стороне target DC. Worker в DC-2, принимая ReplicationAction, должен проверить «Я это сообщение уже обработал?». Если да — вернуть ранее зафиксированный результат. Если нет — применить и записать ключ в dedup-таблицу. Это Stripe-style pattern, и в Symfony его нет «из коробки».

Узел 5. Online schema migration в active-active. Если в DC-1 делается ALTER, в DC-2 должна приехать совместимая версия схемы. gh-ost поддерживает --test-on-replica — миграция на реплике без cut-over, чтобы проверить совместимость. Но в Symfony/Doctrine Migrations нативной поддержки такого flow нет — Migrations Bundle просто запускает SQL на каждом DC последовательно, и если порядок DDL между DC критичен, его приходится согласовывать руками.

Узел 6. Worker-stop на failover. Когда primary DC переключается на standby, Symfony Messenger worker-ы в старом primary должны быть остановлены, в новом — запущены. Symfony docs это описывают через messenger:stop-workers и Supervisor. В реальном multi-DC процедура failover-а должна включать: stop workers в старом primary, start в новом, проверить outbox tail (нет ли pending-строк, которые начали повторную обработку). Без этого worker-ы могут повторно обработать сообщения или применить их не к тому DC.

Узел 7. Дрейф схемы данных между DC. Даже если DDL был согласован, на уровне данных могут возникать расхождения: ReplicationAction для delete-записи прилетел, но target DC уже удалил запись независимо. Conflict resolution на этот случай — либо «никогда не доверять target» (ждать нового state из DC-1), либо last-writer-wins по timestamp — но timestamp в active-active надёжен только при синхронизированных часах. Эти вопросы остаются на уровне приложения, framework их не решает.

Каждый из этих узлов — конкретное место в коде, где это пространство решений становится «ручной работой проекта», и где в разных проектах рождаются разные куски glue.


Что должна закрывать будущая работа (теоретическое обоснование)

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

Маркер реплицируемого Entity. Атрибут или маркер вроде #[Replicable] или #[SoftReplication] на уровне Entity. Этот маркер должен быть частью схемы Doctrine (attribute) или декларативного конфига — но не convention. Без маркера каждое новое Entity автоматически попадает под «всё реплицируемо, что не попадает под exception» — это wrong default для финансовых сценариев.

Извлечение ReplicationAction. Это listener или набор listener-ов (pre-flush снимок + post-flush серилизация), которые превращают ORM-операцию в серилизуемое сообщение, привязанное к конкретному target DC. Внутри — UnitOfWork inspection, серилизация Entity без lazy-loaders, формирование action с метаданными (id записи, dc-маршрут, idempotency-key, retry-policy).

DBAL exception classifier. Helper или сервис, который превращает DBAL-исключение в вердикт {retryable | conflict | fatal} и рекомендацию по retry (delay, max-attempts, классификатор для backoff strategy). В реализации нужен либо match на SQLSTATE, либо более тонкая логика для специфических случаев вроде deadlock retry-budget.

Retry decorator на handler-ы. Декоратор, оборачивающий Symfony Messenger handler, который применяет retry-политику с учётом classifier-а из предыдущего пункта. Это то, чего не хватает стандартного Symfony Messenger retry_strategy middleware для финансовых write-путей: classifier по типу исключения, не «увеличивать счётчик и через delay снова попробовать».

Outbox-relay с publisher confirms. Relay-процесс, который читает pending outbox-строки, диспатчит в Messenger и помечает строку sent только после подтверждения от broker. Это закрывает durability outbox-а и убирает класс «потерянных из-за crash-а relay-а» событий.

Idempotency-store. Сервис, который принимает Idempotency-Key и значение, либо возвращает предыдущее значение, либо записывает новое. С TTL, без сетевых вызовов в другой DC (dedup — локальный по построению).

Топологический provider и resolver. Это ответственность из раздела про границу. Provider возвращает «я в таком-то DC». Resolver по идентификатору возвращает «эта запись обслуживается таким-то DC». Источник provider-а — внешний (ENV, Consul, k8s). Контракт resolver-а — абстрактный интерфейс, не зависящий от того, как именно id-DC извлекается из идентификатора записи.

Эти ответственности — не «что должна содержать библиотека X», а «какие слои этого пространства решений требуют отдельного концептуального описания, прежде чем их можно собрать вместе». Границы между ними — на уровнях 1+2 (топология), 2+3+4 (доставка) и инвариант «маркер на Entity» между уровнями 1 и 2. Эти семь ответственностей — теория; код появится тогда, когда теория зафиксирована и проверена на конкретных сценариях.

Требования к среде для такой работы:


Что будет, если оставить как есть

Если в этой карте продолжит не быть стандарта, через 2–3 года произойдёт следующее.

Это не апокалиптика. Это эволюция текущего состояния без изменений. Если это пространство решений зафиксировать и ответственности разделить — сценарий другой, но это уже предмет отдельной работы.


Источники

Если вы строите в этой же области — буду рад обсудить. Особенно интересует, как в ваших проектах разведены знание о топологии и механизм доставки, и какие из семи ответственностей выше вы закрыли стандартным путём, а какие пришлось реализовать руками.

Обсуждение

Комментарии (0)

Пока никто не комментировал.