В теории нет разницы
между теорией и практикой,
а на практике есть.
Йоги Берра
Допущения, исходный ландшафт.
- РИБ 1С
- топология — звезда
- транспорт - брокер сообщений
- гарантированная доставка сообщений
- гранулярность сообщения — один объект 1С
Синхронизация данных между узлами распределённой системы это очень сложная задача. В данной публикации я коснусь следующих проблем:
- идемпотентная обработка сообщений обмена;
- последовательная или причинная согласованность данных;
- обнаружение и разрешение коллизий данных;
- управление асинхронными распределёнными транзакциями;
- защита от сбоев и восстановление работоспособности узлов.
По этому поводу написано много научных трудов. На основании этих трудов были реализованы такие базы данных как Amazon DynamoDB, Apache Cassandra, Riak и другие. Кроме этого были реализованы фреймворки для синхронизации данных как, например, Microsoft Sync Framework.
В данной публикации я главным образом хочу рассказать об алгоритме векторных часов. Немного коснусь проблемы реализации асинхронных распределённых транзакций.
Сразу хочу оговориться, что я не силён в математике, и буду излагать всё своими словами, так как понял сам, что в свою очередь может привести к неточности в научной терминологии. Заранее прошу меня за это извинить.
Логические часы (версия) — простой счётчик на уровне узла, который увеличивается на 1 при каждом выполнении локальной транзакции изменения данных. Счётчик может использоваться на уровне всей базы данных, таблицы, записи или даже поля таблицы. Область применения счётчика называется гранулярностью отслеживания изменений.
Векторные часы (вектор) — массив логических часов или структура данных, которая включает в себя один или несколько компонентов, по одному для каждого узла обмена данными. Каждый узел отвечает за изменение своих логических часов и обновление соответствующего этому узлу компонента вектора.
В планах обмена 1С таким вектором являются номера принятого и отправленного сообщений, а гранулярностью отслеживания изменений — вся база данных узла обмена.
Рассмотрим простое кодирование вектора на следующем примере.
Предположим, что у нас есть три узла A, B и C, которые участвуют в обмене между собой. Объектом синхронизации является документ "Перемещение товаров".
Значение версии для узла A является число 1, для узла B – 3, а для узла C – 5. Предположим, что было решено использовать первый компонент вектора для узла A, второй для узла B, а третий для узла C. Таким образом вектор для данного объекта можно записать так: [1,3,5].
Часто для кодирования вектора используют идентификаторы узлов обмена. Данный вектор можно было бы закодировать, например, в формате JSON таким образом:
[
{ "A": 1 },
{ "B": 3 },
{ "C": 5 }
]
Сравнение векторов различных узлов для одного и того же объекта синхронизации позволяет сделать выводы о том, что данные объекты идентичны, какой-то из объектов был изменён раньше или позже на шкале логического времени или был изменён параллельно, то есть одновременно и независимо от другого объекта. Параллельность изменения объектов (конкурентное изменение) свидетельствует о возникновении коллизии данных (конфликта).
В случае успешного приёма сообщения узлом-приёмником от узла-отправителя выполняется операция слияния или объединения векторов. Полученный таким образом новый для узла-приёмника вектор сохраняется в его базе данных.
[1,0,0] U [1,2,3] = [1,2,3]
Операция слияния векторов это применение функции MAX ко всем компонентам (логическим часам) векторов.
[1,0,0]
[1,2,3]
-----------
max [1,2,3]
Вектора помогают решить следующие проблемы синхронизации.
Равенство всех соответствующих компонентов сравниваемых между собой векторов говорит об их идентичности, что позволяет исключить повторную обработку одних и тех же сообщений обмена данными.
[1,0,0] = [1,0,0] вектора идентичны
Если один или несколько компонентов одного вектора больше, чем соответствующие компоненты другого, то это позволяет понять какая транзакция с этим объектом произошла раньше или позже на оси глобального логического времени для данной распределённой системы.
[1,0,0] > [2,1,1] последовательность векторов
В данном случае вектор [1,0,0] был создан раньше вектора [2,1,1], так как все три компонента второго вектора больше соответствующих значений первого.
Если вектор [1,0,0] принадлежит узлу-отправителю, то входящее от него сообщение может быть отброшено, так как скорее всего узел-отправитель был восстановлен после сбоя и отправил это устаревшее сообщение повторно.
Если же наоборот вектор [1,0,0] принадлежит узлу-получателю, то входящее от узла-отправителя сообщение должно быть принято как обновление данных.
Если один компонент одного вектора меньше соответствующего компонента другого вектора, а другой - больше, то это говорит о том, что изменение объекта данных произошло параллельно в разных узлах независимо друг от друга. Таким образом выявляется конфликт конкурентного изменения данных, который требует своего разрешения.
[1,0,0] | [0,0,1] вектора параллельны
В данном примере первые и третьи компоненты двух векторов не равны и один больше, а второй меньше. Вектора не идентичны. Определить какой из этих векторов был создан раньше, чем другой, невозможно.
Компоненты вектора: [A,B,C].
Узел B это центральный узел РИБ.
Начальное состояние векторов [0,0,0] – данные отсутствуют.
Узел A создаёт новый объект в точке A1.
Увеличивает значение своего компонента на 1.
Результирующий вектор: [1,0,0].
Узел A отправляет сообщение в центральный узел B.
Узел B принимает сообщение от узла A в точке B1.
Выполняет сравнение векторов: [0,0,0] > [1,0,0].
Конфликта не обнаружено.
Выполняется слияние векторов: [0,0,0] U [1,0,0] = [1,0,0].
Выполняется запись объекта данных и нового вектора.
Узел B отправляет сообщение в узел С.
Все остальные шаги аналогичны.
В конечном итоге данные во всех узлах синхронизированы и имеют один и тот же идентичный вектор [1,0,1].
Компоненты вектора: [A,B,C].
Узел B это центральный узел РИБ.
Начальное состояние векторов [1,0,1].
Узел A изменяет данные объекта в точке A1.
Увеличивает значение своего компонента на 1.
Результирующий вектор: [2,0,1].
Узел A отправляет сообщение в центральный узел B.
Узел C изменяет данные объекта в точке C1.
Увеличивает значение своего компонента на 1.
Результирующий вектор: [1,0,2].
Узел C отправляет сообщение в центральный узел B.
Узел B принимает сообщение от узла A в точке B1.
Выполняет сравнение векторов: [1,0,1] > [2,0,1].
Конфликта не обнаружено.
Выполняется слияние векторов: [1,0,1] U [2,0,1] = [2,0,1].
Выполняется запись объекта данных и нового вектора.
Узел B отправляет сообщение в узел С.
Узел B принимает сообщение от узла С в точке B2.
Выполняет сравнение векторов: [2,0,1] | [1,0,2].
Обнаружен конфликт параллельного изменения данных.
Узел C принимает сообщение от узла B в точке C2.
Выполняет сравнение векторов: [1,0,2] | [2,0,1].
Обнаружен конфликт параллельного изменения данных.
В данном случае очень важно, чтобы разрешение конфликта в точках синхронизации B2 и C2 было выполнено аналогичным образом и гарантировало получение идентичного результата.
Узел B, кроме всего прочего, отвечает за отправку результата разрешения конфликта в точке B2 в узел A для синхронизации данных в точке A2.
В приведённом ранее примере возможно два результата разрешения конфликта:
1. Побеждает вектор [2,0,1] – данные узла A.
2. Побеждает вектор [1,0,2] – данные узла C.
Типовой механизм РИБ 1С при разрешении конфликтов всегда отдаёт предпочтение центральному узлу. В приведённом ранее примере победила бы версия данных узла A, имеющая вектор [2,0,1].
Другими словами, тот узел, который первым успел бы доставить свои изменения в узел B. Результат такой синхронизации по сути непредсказуем.
Однако не всегда типовой алгоритм разрешения коллизий РИБ 1С является приемлемым. В таком случае необходимо реализовать свою логику разрешения конфликтов.
Предположим, что у нас имеется функция, реализующая алгоритм автоматического разрешения конфликта, отличный от типового алгоритма РИБ 1С. Эта функция применяется во всех узлах для разрешения конфликтов и гарантирует получение идентичного результата.
Узел C принимает сообщение от узла B в точке C2.
Выполняется запись объекта данных и нового вектора [2,0,1].
Таким образом узел C отменяет все свои предыдущие транзакции с данным объектом данных.
Узел B принимает сообщение от узла C в точке B2.
Узел B игнорирует сообщение от узла С.
Узел B ничего не отправляет в узел A.
Узел B отправляет в узел C сообщение об ошибке транзакции.
Узел C принимает от узла B сообщение об ошибке транзакции и при необходимости выполняет запись объекта данных и вектора [2,0,1].
Запись объекта данных и вектора выполняется только в том случае, если этого ещё не было сделано в точке C2 (см. выше).
Таким образом узел C отменяет все свои предыдущие транзакции с данным объектом данных.
Узел C принимает сообщение от узла B в точке C2.
Узел C игнорирует сообщение от узла B.
Узел B принимает сообщение от узла C в точке B2.
Выполняется запись объекта данных и нового вектора [1,0,2].
Таким образом узел B отменяет все свои предыдущие транзакции с данным объектом данных.
Узел B отправляет в узел A сообщение об ошибке транзакции.
Узел A принимает сообщение об отмене транзакции из узла B.
Выполняет запись объекта данных и нового вектора [1,0,2].
Таким образом узел A отменяет все свои предыдущие транзакции с данным объектом данных.
Из рассмотренного выше примера можно сделать интересный вывод.
Если есть некий объект данных, который может быть изменён в нескольких узлах одновременно, то рано или поздно может возникнуть конфликт параллельного изменения данных. В конечном итоге для обеспечения согласованности данных это должно привести к откату проигравшей локальной транзакции в одном или даже нескольких узлах РИБ.
Таким образом для конкурентных объектов любая локальная транзакция потенциально является распределённой. При этом, учитывая тот факт, что обмен данными выполняется асинхронно при помощи брокера сообщений, такая транзакция является не только распределённой, но и асинхронной.
Для сравнения широко известный протокол двухфазной фиксации 2PC (two-phase commit) по сути является синхронным протоколом, даже не смотря на то, что некоторые его реализации предусматривают сохранение и восстановление состояния распределённой транзакции в случае возникновения технического сбоя. На практике главным недостатком 2PC является низкая производительность, а также всё-таки недостаточная устойчивость к техническим сбоям.
В микросервисной архитектуре для реализации распределённых транзакций широко используется шаблон проектирования "Сага" (Saga pattern). Применение этого шаблона включает в себя реализацию и применение компенсирующих транзакций.
Системы документооборота для обеспечения согласованности данных используют конечные автоматы (state machine) или, проще говоря, состояния документов.
На мой взгляд, в контексте РИБ 1С, использующей в качестве транспорта брокер сообщений, целесообразно говорить о реализации компенсирующих сообщений по аналогии с шаблоном проектирования "Сага".
Типовой механизм РИБ 1С позволяет разрешать коллизии данных в автоматическом режиме, но ценой строгих ограничений. Центральный узел всегда прав. Кто из периферийных узлов первый "достучится" до центрального узла, тот и молодец.
Реализация автоматического разрешения коллизий данных очень сложна. На практике почти невозможна. Таким образом чаще всего стратегией разрешения конфликтов является вмешательство человека — оператора программы. Для того, чтобы принять адекватное решение, оператору могут потребоваться все конфликтующие между собой версии данных.
По большому счёту платформа 1С имеет все необходимые средства для генерации значений логических часов, создания таблиц для хранения значений векторных часов и различных версий объектов для разрешения коллизий данных.
Применение распределённых алгоритмов в контексте РИБ 1С конечно же возможно, но ... потребует очень больших знаний, умений и трудозатрат. В прочем это касается не только мира 1С =)
Ссылки на дополнительные материалы:
1. Обнаружение и разрешение коллизий данных: альтернативная реализация типовой стратегии РИБ 1С
2. Мартин Клеппман: Высоконагруженные приложения. Программирование, масштабирование, поддержка. 2018 год.
3. Why Logical Clocks are Easy (Как легко понять логические часы)
4. Calvin: обеспечение принципов ACID для высоконагруженных распределенных систем