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». На каждом из этих уровней существующие инструменты закрывают только часть ответственности. Стыки между уровнями — серая зона, в которой любая конкретная реализация вынуждена принимать собственные архитектурные решения. Без зафиксированной границы между «знанием о топологии» и «механизмом доставки» эта задача решается в каждом проекте отдельно.

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

Не выбирается managed-инфраструктура (Aurora Global, RDS cross-region replica, Patroni + etcd, Cloud SQL DR replica) — это уровень инфраструктуры, отдельная тема.

Не сравнивается transport (Kafka, RabbitMQ, SQS, Doctrine transport) — каждый из них встраивается в это пространство по-своему, но выбор transport-а не меняет структуру уровней, которые здесь рассматриваются.

Не утверждается, что какой-то из ORM-паттернов (read/write split, sticky, Outbox, PostFlush listener) «лучше» остальных — речь про то, как они сочетаются друг с другом, а не какой из них выбрать.

Не претендуется на полноту обзора всех PHP-инструментов для каждого узла — это рамка, не каталог.

Не описывается конкретный API новой библиотеки. Библиотеки на момент написания статьи нет.

Не даётся готового рецепта «как включить». Это карта, не how-to.

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

Архитекторы multi-DC Symfony-проектов. Чтобы видеть, на каких уровнях принимаются решения в этой задаче, какие инструменты что закрывают, и где будет ручная работа.

Tech-lead команд, поддерживающих high-load Symfony-приложения с репликацией. Для аудита существующего кода: какой уровень сейчас реализован, где ручной glue, что остаётся риском.

Разработчики, переходящие из Laravel или non-Symfony стека. Чтобы понимать, какие привычки из sticky Laravel переносятся в Doctrine частично, а какие — нет.

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

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

DBAL connection routing — какое именно соединение использовать для записи и для чтения в текущем DC.

Извлечение реплицируемого действия из Doctrine UnitOfWork — что именно реплицировать: какие Entity, какие поля, в какой момент ORM-цикла.

Классификация DBAL-исключений — какой из failure-исходов retryable, какой — конфликт, какой — фатальная ошибка.

Связка «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.

Множественные named connections через Doctrine\DBAL\Configuration и параметры dbal: в Symfony. Описано в Doctrine DBAL Configuration.

ManagerRegistry для переключения EntityManager-ов — стандартная часть Symfony + Doctrine Bridge. Использование ManagerRegistry::getManager('read') явное и проверяемое статически; никакого «неявного выбора» под капотом нет.

Это встроенная инфраструктура, и она закрывает маршрутизацию соединений в обычном смысле — какой 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.

PostFlushEventListener — стандартный hook Doctrine ORM, через который можно подписаться на завершение ORM-цикла.

API UnitOfWork — getScheduledEntityInsertions(), getScheduledEntityDeletions(), getEntityChangeSet(). Доступ к этим методам документирован и стабилен.

Symfony\Component\Serializer — стандартный путь превращения Entity в массив/DTO для Messenger Message. Имеет группы серилизации #[Groups], нормализаторы и денормализаторы.

Doctrine\Migrations — инструмент для переноса схемы между DC.

Эти примитивы закрывают верхнюю половину Уровня 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 эквивалент | Что с ним делать
retryable | 40001 (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: проверить, кто первый закоммитил
fatal | 42P01 (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.

Иерархия DBAL-исключений через Doctrine\DBAL\Exception — стандартный набор: UniqueConstraintViolationException, DeadlockException, ConnectionLost, и др. Каждое исключение несёт getSQLState() — то есть код ошибки, общий для PostgreSQL и стандартизованный, и специфичные для каждого драйвера коды.

Statement::executeStatement() с retry-statistics: DBAL пробрасывает нативные ошибки через свои исключения, не теряя SQLSTATE или драйверный код. Это значит, что информация для классификатора доступна в каждом catch-блоке.

Этого достаточно для построения классификатора своими руками — но не достаточно для готовой классификации «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-путях этот уровень должен быть хорошо оттестирован.

Conflict resolution для unique-key violations в active-active. Недостаточно знать, что произошло нарушение; нужно ещё понимать «кто первый закоммитил» и привело ли это к расхождению данных. В большинстве случаев репликация из primary DC вторична: если primary закоммитил первым, вторичные записи в target DC считаются «погашенными» — last-writer-wins по timestamp. Но timestamp в active-active надёжен только при синхронизированных часах (NTP), иначе это лотерея.

Долгие retryable-ошибки. Network partition, при которой другой DC недоступен часами, формально retryable, но ретраить бесконечно нельзя. Нужен либо exponential backoff с лимитом, либо circuit breaker на уровне handler-а, либо переход в уведомление оператора. Это всё app-level, не DBAL.

Outbox-context. Когда retry-цикл идёт для ReplicationAction, нужно помнить, что эта конкретная запись из outbox-таблицы уже пыталась доставиться. Если retry происходит «где-то в handler-е», не зная про outbox, нет способа отличить «повтор по причине транзитной ошибки» от «повтор после успешного apply, но не отмеченного sent в outbox». Эта связка — снова стык между уровнями 3 и 4.

Уровень 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.

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

Symfony Messenger multi-transport с TransportNamesStamp. Это описано в официальной документации.

Message versioning — Symfony docs прямо описывают протокол обратной совместимости: версионированные message classes (SendInvoiceV2 рядом с SendInvoice), опциональные constructor args, остановка worker-ов при деплое через messenger:stop-workers. Это цитата из Symfony Messenger — message versioning: «Because Messenger processes messages asynchronously, some messages may still be pending in the queue when you deploy a new version of your application. If you change a message class, those older messages may no longer deserialize correctly.»

AMQP multi-transport с приоритетами, описанный в Symfony docs по prioritized transports.

Stop workers on deploy через messenger:stop-workers и Supervisor (stopwaitsecs) — задокументировано, но требует интеграции с процесс-менеджером.

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

App-level retry-декоратор на handler-ы. Стандартный Symfony Messenger pipeline retry_strategy middleware делает retry с задержкой по счётчику — но это retry на уровне transport, а не на уровне бизнес-операции. Для финансовых write-путей это критично: transport retry может повторно отправить тот же message при сбое worker-а, и операция либо дублируется, либо теряется. Symfony docs это описывают, но готового декоратора с классификатором исключений (retryable из Уровня 3) и разными retry-strategies (exponential / linear / none) нет.

Outbox из коробки. В Symfony нет ни Messenger middleware, ни опции в transport, которые делали бы outbox-pattern. Каждая реализация — самописная: либо DBAL-таблица outbox + scheduled command, либо сторонняя библиотека.

Outbox-relay с publisher confirms. Без подтверждения от broker (publisher confirms в Kafka / AMQP) relay-процесс не должен помечать строку sent. Это требование durability для outbox-а, и в стандартном Symfony его нет.

Idempotency-store с TTL. Ни один стандартный PHP-бандл не предлагает IdempotencyStoreInterface «из коробки». Stripe-style pattern реализуется в каждом проекте заново: иногда одна строка INSERT ... ON CONFLICT DO NOTHING RETURNING, иногда отдельная таблица с процессом очистки.

Message versioning как автоматика. Symfony docs дают протокол, но не дают инструмента, который бы отслеживал «все ли V1-сообщения дренированы из очереди перед удалением V1-класса». Это ручная операция, и она повторяемая в каждом проекте.

Уровень 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. Эти семь ответственностей — теория; код появится тогда, когда теория зафиксирована и проверена на конкретных сценариях.

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

Symfony 7.x+ (для текущего контракта Doctrine Events и Messenger API).

Doctrine ORM 3.x, DBAL 4.x (для текущего API UnitOfWork и иерархии исключений).

Поддержка как минимум одного AMQP-совместимого или Kafka-совместимого broker-а для publisher confirms.

Наличие слоя ENV/Consul/k8s ConfigMap для топологического provider-а — без него работа не имеет смысла.

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

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

Каждый новый multi-DC Symfony-проект будет писать свой glue поверх Doctrine и Messenger. Дрейф между проектами увеличится: каждый проект изобретёт свой classifier-исключений и свой формат outbox-строки.

При апгрейде Doctrine ORM (3.x → 4.x) или Symfony Messenger (7.x → 8.x) поведение PostFlush / UnitOfWork / TransportNamesStamp может измениться. В каждом проекте эти изменения придётся вручную проходить и тестировать. Накопленный «доменный glue» не имеет единого места, где можно отследить, что работает, а что нет.

В финансовых write-путях вероятность «неправильной retry-политики» (silent data duplication) останется существенной. Проекты, которые научились это ловить через интеграционные тесты, будут продолжать работать. Проекты, которые не научились, будут наступать на грабли.

Стандарт «Idempotency-Key обязателен» будет принят в части проектов и проигнорирован в части. Stripe-style pattern останется рекомендательной нормой, а не инвариантом.

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

Источники

Symfony Messenger — symfony.com/doc/current/messenger.html. Использовано официальное описание multi-transport, message versioning и deploy workers. Цитата про message versioning: «If you change a message class, those older messages may no longer deserialize correctly.»

Doctrine ORM Events — doctrine-project.org/projects/doctrine-orm/en/current/reference/events.html. Использовано описание PostFlush, OnFlush и доступа к UnitOfWork через change set API.

Doctrine DBAL Configuration — doctrine-project.org/projects/doctrine-dbal/en/current/reference/configuration.html. Множественные named connections и конфигурация dbal: в Symfony.

Doctrine DBAL Exceptions — doctrine-project.org/projects/doctrine-dbal/en/current/reference/exceptions.html. Иерархия Doctrine\DBAL\Exception\* и доступ к SQLSTATE.

Doctrine ORM UnitOfWork — doctrine-project.org/projects/doctrine-orm/en/current/reference/unitofwork.html. getScheduledEntityInsertions(), getEntityChangeSet().

Laravel Database — read/write split + sticky — laravel.com/docs/11.x/database. Использовано как reference-реализация sticky mode, которой в Doctrine нет.

Transactional Outbox pattern — microservices.io/patterns/data/transactional-outbox.html. Каноническое описание паттерна.

Stripe API — Idempotency-Key — stripe.com/blog/idempotency. Оригинальное описание pattern-а, который в каждом PHP-проекте реализуется отдельно.

PostgreSQL Appendix A — SQLSTATE codes — postgresql.org/docs/current/errcodes-appendix.html. Полный список SQLSTATE для PostgreSQL; используется для классификации на retryable / conflict / fatal.

MySQL Server Error Reference — dev.mysql.com/doc/mysql-errors/8.0/en/server-error-reference.html. Эквивалент для MySQL.

GitHub gh-ost — github.com/github/gh-ost. --test-on-replica режим для онлайн schema migration в active-active.

AWS Aurora Global Database — docs.aws.amazon.com/AmazonRDS/latest/AuroraUserGuide/aurora-global-database.html. Цитируется только в контексте «инфраструктура, которая реплицирует binlog и не знает про idempotency на app-уровне».

AWS RDS Read Replicas — docs.aws.amazon.com/AmazonRDS/latest/UserGuide/USER_ReadRepl.html. Cross-region promotion и async replication как baseline managed-варианта.

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