Репликация по DDIA: что я понял, только когда сам сломал прод

от автора

В понедельник утром бухгалтер из клиентской компании написала мне в Telegram: «У контрагента в SAP всё оплачено, а в Smartup долг 12 миллионов». Я открыл обе системы. Одна и та же накладная. Два разных состояния. Два источника правды — и оба врут.

Это было ровно то место в книге Designing Data-Intensive Applications, на котором я когда-то уверенно кивнул и пошёл дальше. Глава 5. Replication. «Ну да, master-slave, понятно». А когда через год сам построил систему с двумя ведущими — даже не назвав её так, — Клеппманн взял своё со штрафами и пенями.

Это история о том, как я понял пятую главу DDIA не из книги, а из логов.

Контекст: почему вообще двусторонняя синхронизация

Я работаю в SAP B1 партнёре в Ташкенте. У одного из клиентов параллельно живут две системы. SAP B1 — финансовый контур: главная книга, налоги, отчётность для аудита. Smartup — оперативка: продажи, склад, отгрузки, касса. Бизнес работает на обеих несколько лет. Выкинуть одну нельзя — обе глубоко вросли в процессы.

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

Первая версия дизайна была наивно симметричной. Псевдокод:

каждые 5 минут:    для каждой сущности:        если updated_at(SAP) > updated_at(Smartup):            тянем из SAP в Smartup        иначе если updated_at(Smartup) > updated_at(SAP):            тянем из Smartup в SAP

Что я думал, когда это писал: «элегантно, симметрично, last-write-wins сам разрулит конфликты».

Что я НЕ думал: я только что построил multi-leader replication. Просто без этого названия.

Первый конфликт, который положил систему

Сценарий из логов (цифры близкие к реальным, имена и ID изменены):

  • 09:14:12 — бухгалтер в SAP B1 правит сумму НДС на накладной INV-2403-19. updated_at = 09:14:12.

  • 09:14:17 — менеджер в Smartup отмечает на этой же накладной частичную оплату. updated_at = 09:14:17.

  • 09:15:00 — крон забирает оба объекта. Smartup новее, он становится «правдой». Правка НДС теряется.

  • 09:16:00 — следующий крон. Сумма в Smartup всё ещё со старым НДС, но updated_at новее. SAP перезаписывается обратно. Цикл замыкается.

Что я увидел в логах: бесконечный пинг-понг с ошибками валидации (SAP не принимает дробные копейки от Smartup), а бухгалтер вручную правила одну и ту же сумму три раза за день, не понимая, почему она «возвращается».

Клеппманн в пятой главе ровно это и описывает: при асинхронной репликации между несколькими ведущими два независимых клиента могут обновить одну и ту же запись параллельно, и система обязана либо обнаружить конфликт, либо иметь правило разрешения, которое не теряет данные. Last-write-wins по часам — это правило, которое теряет данные. Каждый раз. По определению.

Я знал это в теории с момента, когда читал книгу. Я не узнал это в продакшене, пока не увидел свои собственные строчки в логах.

Часы врут — и не так, как ты думаешь

Дальше стало хуже. SAP B1 хранит updated_at в локальной зоне HANA. Smartup — в UTC. Я выровнял это в коде. Но даже после выравнивания вылезло: HANA-сервер у клиента под нагрузкой отстаёт от NTP на 2–4 секунды. Smartup живёт в облаке, и его сервер на секунду опережает реальное время.

Разница в 3–5 секунд между серверами — это то, о чём в восьмой главе DDIA отдельно предупреждают: не строй логику на timestamp-ах с разных машин. Они согласованы сами с собой, но не друг с другом. Монотонные часы хороши для измерения интервалов на одной машине и бесполезны через сеть.

Идеальной упорядоченности событий между двумя независимыми системами не существует. Можно только договориться, кто главный для какого поля.

Что я понял из главы 5, перечитав её после инцидента

Перечитал главу не как студент, а как человек, у которого она в проде. То, что раньше было параграфами, стало списком ошибок, которые я уже совершил:

Single-leader было правильным выбором, а я его обошёл. Не потому что он не подходил, а потому что симметричный дизайн казался «честнее». Multi-leader оправдан только когда есть одно из трёх: геораспределённая запись с низкой задержкой, работа оффлайн с последующей синхронизацией, или разная семантика записи в разных регионах. Ни одно из этих условий у меня не выполнялось. Я выбрал сложную модель из эстетических соображений.

Replication lag — это не баг, это свойство системы. В моей конфигурации lag был 5 минут (период крона). Любой UI, который сразу после записи возвращался читать ту же запись через другую систему, видел старые данные. Read-your-own-writes consistency — это не магия, а явная фича, которую надо проектировать. У меня её не было.

LWW не разрешает конфликты, он их прячет. Каждый раз, когда last-write-wins «разруливает» конфликт, кто-то теряет данные. Иногда это допустимо — счётчик просмотров, лайк, последняя выбранная тема интерфейса. В финансах — никогда.

Conflict detection важнее conflict resolution. Если ты не можешь увидеть, что произошёл конфликт, ты не можешь его починить. У меня не было ни одной метрики «записи разъехались». Только жалоба бухгалтера в Telegram через два дня после факта.

Что я переделал

После недели логов и одной перечитки главы я переписал интеграцию по правилам, которые сегодня выглядят почти очевидными.

Owner per field, а не per record. Накладная — общий объект, но у каждого поля есть владелец. Сумма НДС — owner SAP B1 (бухгалтерия). Статус оплаты и остаток — owner Smartup (касса). Поля от не-owner системы игнорируются на приёме. Это не классический single-leader, но это и не multi-leader: каждое отдельное поле имеет ровно один источник правды.

Поток в одну сторону за раз. Никаких «если updated_at новее». Просто: SAP → Smartup для бухгалтерских полей, Smartup → SAP для операционных. Направление зафиксировано в схеме, а не определяется в рантайме.

Idempotency keys на каждое сообщение. Каждое изменение получает UUID. Принимающая сторона хранит таблицу применённых ключей за последние 7 дней. Если сообщение пришло дважды — второе игнорируется. Простое, но впервые я мог безопасно ретраить без страха задвоить запись.

Conflict logging вместо conflict resolution. Если по какой-то причине пришло обновление поля от не-owner системы — лог плюс алерт в Telegram. Не «разрулим автоматически». Не «перезапишем». Лог плюс человек. За полгода в этот лог попало 17 записей, и каждая оказалась реальным багом в одной из систем — например, скрипт миграции дёргал не то API, или менеджер кликнул не туда. Без лога это бы тихо сожрало данные.

Метрика расхождения. Раз в час join по ключевым полям. Если суммы по накладной в SAP и Smartup отличаются больше чем на 0.01 — алерт. Это та самая видимость конфликтов, которую я не построил с первого раза. Если её нет — у тебя в системе уже есть тихие конфликты, ты их просто не видишь.

`Выводы, которые я бы сказал себе год назад

Если ты пишешь «пусть данные синхронизируются» — ты пишешь репликацию. Назови её правильно. Multi-leader replication — это не «распределённый ETL», это известный класс систем с известным списком проблем, описанных три десятилетия. Не называя проблему по имени, ты лишаешь себя готовых решений.

Симметрия — антипаттерн. Когда обе стороны равноправны и обе могут писать произвольные поля — у тебя multi-leader. Multi-leader дороже single-leader на порядки в плане сложности. Платить эту цену стоит только если симметрия даёт реальное преимущество. В 90% enterprise-интеграций — не даёт.

DDIA читается дважды. Первый раз — чтобы знать слова. Второй раз — после своего первого инцидента, чтобы понять, что эти слова значат.

Если у вас в продакшене есть интеграция между двумя системами с двусторонней записью — откройте две метрики прямо сейчас. Первая: «расхождение часов между серверами». Вторая: «количество объектов, у которых обе стороны изменили один и тот же ключ в окне репликации». Если этих метрик нет — у вас есть скрытый multi-leader, и пятая глава DDIA уже ждёт. Я бы предпочёл встретиться с ней до инцидента, а не после.

Источники и дополнительное чтение

  • Martin Kleppmann. Designing Data-Intensive Applications: The Big Ideas Behind Reliable, Scalable, and Maintainable Systems. — O’Reilly Media, 2017. Главы 5 (Replication) и 8 (The Trouble with Distributed Systems) — основная теоретическая канва этой статьи. На русском вышла в издательстве «Питер» в 2018 под названием «Высоконагруженные приложения. Программирование, масштабирование, поддержка» — ISBN 978-5-4461-0512-0.

  • dataintensive.net — официальный сайт книги с актуальным списком источников по главам и обновлениями автора.

  • martin.kleppmann.com — блог Клеппманна, где он дописывает то, что не вошло в книгу: критика CAP, разборы свежих incidents, заметки про CRDT и local-first software.

  • Kyle Kingsbury (aphyr), Jepsen — анализы того, как реальные распределённые СУБД (PostgreSQL, MongoDB, Kafka, etcd, Cassandra) ломают свои же гарантии под сетевыми разделениями. Лучшее лекарство от веры в маркетинговые странички про «strong consistency».

  • Marc Shapiro, Nuno Preguiça, Carlos Baquero, Marek Zawirski. Conflict-free Replicated Data Types. — INRIA, 2011. Если после статьи захочется разрешать конфликты не логом, а структурой данных.

  • Eric Brewer. CAP Twelve Years Later: How the «Rules» Have Changed. — IEEE Computer, 2012. Сам автор CAP-теоремы объясняет, почему её обычно понимают неправильно — ровно то, что я описал в разделе про симметрию.

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