Привет! Меня зовут Сергей и я техлид. Причем уже долгое время. За время своей работы я успел поработать с разными решениями, надо разными проектами и столкнуться с разными задачами. Проекты я всегда старался выбирать свежие. Когда надо построить большую и сложную систему с нуля.

Идея этой статьи пришла ко мне год назад. При посещении Highload я был у стенда одной большой компании, которая очень любит алгоритмы. На нем предлагалась решить архитектурную задачу за мерч. Когда дошла очередь до меня, мне выпала задача — построить онлайн редактор с нуля. Это отличный вариант! Как раз такое мне уже приходилось проектировать. В процессе решения, автор задачи сказал, что я усложняю и вообще онлайн редктор — это гораздо проще. После разбора нескольких корнер кейсов, он согласился, что это всего лишь первый взгляд. Далее уже достаточно долго мы обсуждали а как вообще можно построить онлайн редактор. В тот момент и появилась идея поделиться своим опытом публично, но вот только сейчас дошли руки. Итак, приступим.

Мотивция

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

Представим, что мы решили сделать решение по редактированию страниц «в лоб». С какой проблемой мы столкнемся? Посмотрим на схему нижу.

  • На схеме видно, что Пользователь 1 создал страницу. Далее отредактировал ее и получилась версия 2.

  • Внезапно Пользователю 1 пришла какая-то идея и он решил сделать еще одну версию страницы добавив в нее новый контент.

  • В это же время пришел второй пользователь и начал редактировать страницу с версии 2. Ведь он пока не видит самые актуальные изменения пользователя 1.

  • Пользователь 1 сохраняет изменения. Убеждается, что все успешно сохранилось и уходит со страницы.

  • Пользователь 2 сохраняется свои изменения. Убеждается, что все успешно сохранилось. Делает еще одну версию страницы 5.

  • Пользователь 1 возвращается на страницу и не видит своих изменений. Он смотрит историю версий и видит, что его изменения были «завалены» за версиями 4 и 5.

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

Базовые варианты решения

  • Решение конфликтов при сохранении статьи. При сохранении статьи смотреть были ли промежуточные версии. Если да, то предложить пользователю решить конфликт самому подобно git системам.

  • Онлайн редактор. Дать пользователям возможность одновременно редактировать страницу где каждый пользователь видит изменения другого.

Если с первым вариантом все более менее понятно, то со вторым сложнее. Хоть он и кажется более привлекательным для пользовательского опыта. Давайте рассмотрим ряд проблем, которые могут возникнуть в реализации:

  • Построение архитектуры. Ограниченный набор технологий. В любой организации есть ограниченное множество технологий с которым готовы работать специалисты. Давайте представим самый популярный набор: Redis, Scylladb, Kafka, Posgresql. Наша система достаточно нагруженная. Предполагается использование системы одновременно десятками тысяч пользователей.

  • Решение конфликтов. Необходимость «подружить» ваш текущий редактор с совместным онлайн редактированием. Решение конфликтов изменений в редакторе может оказаться настоящей проблемой для тестирования в виду своей сложности и вариативности. Подробности далее. Кроме того, не все редакторы текста в браузере одинаково хорошо поддерживают онлайн редактирование. А если и поддерживают, то не всегда политики лицензий позволяют легко им воспользоваться в РФ.

Начальное условие

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

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

Решаем первую проблему: построение архитектуры

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

На схеме видно, что у нас есть некий frontend, который по пока неопредленному нами протоколу идет в gateway. От него в backend сервис и далее сохраняем состояние в некоторую хранилку данных.

Первый шаг с которым стоит определиться - это протокол взаимодействия frontend и backend. Давайте рассмотрим два варианта:

  • Polling/Long Polling

  • Websocket

У каждого подхода есть свои плюс и минусы. Посмотрим плюсы и минусы простого решения на websocket’ах и потом решим стоит ли идти в более сложное с polling моделью.

Очевидные преимущества:

  • Позволяет работать в две стороны без дополнительных соединений и затратах на переподключение

  • Более простая логика на backend. Если брать какой-нибудь Java + Webflux, то код с websocket’ами будет существенно проще

Очевидные недостатки:

  • Безопасность. Сложнее управлять сессией пользователя. Мы не можем позволить пользователю держать открытый коннект весь день, тк за это время его права на страницу могут измениться.

Кажется, что мы можем что-то придумать как обойти недостатки. А вот плюсы существенные.

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

С учетом этого, рассмотрим несколько вариантов.

С использование scylladb (мой наивный начальный вариант):

На этой схеме достаточно простой алгоритм действий:

  • Пользователь 1 открывает сокетное соединение и отправляет данные об изменении страницы

  • Пользователь 2 открывает сокетное соединение на такой же странице и так же начинает ее менять

  • Данные по каждому запросу попадают в scylladb

  • Каждый backend сервис опрашивает базу раз в короткое время и проверяет есть ли новая информация по страницам, которые сервис обрабатывает

Чаще всего на этом моменте я получаю следующий вопрос: зачем ходить в базу? Ведь можно добавить Kafka и рассылать сообщения через нее. На самом деле тут не все так просто. Кафка - это pull модель, где каждый сервис постоянно ходит и вычитывает по группам новые сообщения.

Во-первых, это потребует очень тонкой настройке в поллинге.

Во-вторых, чтобы каждый инстанс получал одинаковые сообщения (сокетное соединение по странице может быть открыто на любом сервере) необходимо иметь отдельную группу. Учитывая, что экземпляры приложения могут подниматься, падать, перезапускаться, это становится проблемой.

Следующий вопрос, который сразу же приходит на ум: нельзя ли сделать так, чтобы база была одновременно и хранилкой и пуш очередью, которая отправляет сообщения во все инстансы. Конечно, можно! И тут мы приходим ко второму варианту.

В Redis из коробки есть очередь для рассылки сообщения сразу одновременно во все инстансы Кроме того, Redis — это еще и очень быстрая хранилка данных. Учитывая, что данные в онлайн редактировании не имеют отношений и нам важна скорость, выглядит симпатично.

Какой вариант выбрать? По схеме кажется, что ответ очевиден. Ведь именно во второй схеме код будет проще, часть работы на себя возьмет Redis. Но на самом деле все не так очевидно.

Redis — очень классная система со своими ограничениями. Данные в Redis хранятся в оперативной памяти. Если мы хотим быстрой работы и не сильно тюнить Redis, то вероятность потери промежуточных данных гораздо выше, чем в первой схеме. Критично ли это для системы? Каждый архитектор должен решить самостоятельно в зависимости от бизнес потребности. В моем случае, это было не так критично.

Кроме того, схема с scylladb легче масштабируется, тк в ней из коробки есть master‑master. Построить отказоустойчевый кластер где множество нод scylladb могут легко и читать и писать кажется проще. Недостаток в виде поллинга базы можно решить добавлением еще одной технологии для организации пуш очереди как дополнительной. Но само с собой это приведет к дополнительной точке отказа, усложнению архитектуры и неожиданным финансовым тратам.

При этом не стоит забывать, что Redis крайне шустрый. Возможно вам и не понадобиться коробочный master‑master и вы сможете обойтись, например, простым sentinel redis кластером.

Пару слов о проектировании кодовой базы

На мой взгляд, здесь отлично подходят реактивный подход по двум причинам:

  • Нам нужен асинхронный инструменнт для эффективной работы с websocket»ами. Мы не хотим, чтобы на каждый новый коннект создавал поток.

  • Нам нужен механизм Backpressure.

Все это уже есть из коробки в реактивных технологиях. Я использовал Java 21 + Webflux.

Решаем вторую проблему: разрешение конфликтов в редакторе

Хорошо. У нас есть понятный бэк. Можно приступать и делать. Но что по поводу frontend решения? К сожалению, и здесь все не так очевидно. Изучив тему, мы поняли, что есть множество подходов к организации работы с конфликтами. Вот основные:

  • Блокировка (Locking): Этот метод предполагает, что только один пользователь может редактировать документ в определенный момент времени. Этот вариант мы отбросили сразу, тк не он с высокой вероятностью вызывает отторжение пользователями.

  • Оптимистическая синхронизация (Optimistic Synchronization): При использовании этого метода каждый клиент работает с копией документа. Как я описывал выше, в случае проблем с синхронизацией пользователь самостоятельно решает конфликт. С точки зрения бизнеса такой вариант не показался подходящим.

  • Operational Transformation (OT): при использовании OT каждая операция, выполненная пользователем над документом, представлена в виде атомарного действия. Когда операции передаются между клиентами и сервером, они могут претерпевать преобразования, чтобы быть применимыми к текущему состоянию документа и сохранять его согласованность.

  • CRDT (Conflict‑Free Replicated Data Types): CRDT — это структуры данных, которые гарантируют согласованность данных без необходимости разрешения конфликтов. Они спроектированы таким образом, чтобы автоматически сливать изменения, сделанные разными пользователями, без необходимости блокировки или оптимистической синхронизации.

По описанию для решения нашей изначально поставленной задачи подходят только два последних подхода. Но какой же выбрать? Для начала рассмотрит ОТ, так как невооруженным глазом это кажется простым решением. Ведь именно OT используется во многих системах (confluence, google document и тд).

Поясню алгоритм на картинке:

На схеме показано проблема одновременно редактирования одной и той же строки. Оба пользователя изначально видят строку «ab», но каждый хочет изменить ее по‑своему. Первый пользователь хочет дописать букву «с» и увидит «abc». Второй пользователь вводит enter и ожидает переход на вторую пустую строку. Однако если мы изначально приняли изменения второго пользователя, а потом первого, то необходимо учитывать начальное состояние. Иначе мы получим ситуацию когда в первой строчке написано «ab», а на второй «c». Это не то, что ожидали оба пользователя.

Все, что нам необходимо сделать: это определить ряд действий в системе, которые влияют на состояние страницы. Каждое новое состояние зависит от того на основе которого произошло. Надо просто написать обработчики этих действий.

На практике все гораздо сложнее. Подход действительно крутой и рабочий. Но тестировать все символы, вставку макросов, абзацев, перемещение по странице картинок и много много другого крайне тяжело. А работа с таблицами — это вообще отдельный набор действий. Отлаживать такое решение крайне сложно. Слишком много корнер кейсов тк очень большая вариация событий.

Рассмотрим альтернативу: CRDT.

Здесьу нас есть два браузера. Изначально мы начинаем с единым состоянием. В первом браузере пользователь меняет одну из html нод и «сообщает» информацию об этом второму браузеру. На секунду опустим детали маршрутизации, чтобы не усложнять алгоритм. Второй браузер получает изменения от первого и тут же применяет.

Реализовать этого в разы проще: в простейшем варианте мы просто даем идентификатор каждой ноде в DOM дереве. Отправляем изменившуюся ноду и применяем изменения у всех. Да, есть есть проблемы с редактированием одной ноды несколькими пользователями. Но кажется, что такое действие крайне редкое в пользовательском опыте.

В итоге, CRDT подход достаточно хорошо зашел в нашу систему и мы остановились на нем.

Итог

В итоге получилось рабочее решение, хоть и достаточно сложное в реализации. Многие вещи я оставил за скобками: возможные проблемы на проде с Redis, оптимизации через snapshot'ы, организация кода и тд. Каждый из эти вопросов требует отдельной статьи и сюда просто не поместится. Но я надеюсь было интересно посмотреть возможный вариант решения. Делитесь своим опытом. Может быть кто‑то сделал гораздо проще и все работало.

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