Создание синхронизации мобильных данных, когда я должен знать лучше. Да простит меня Интернет.

Я уже давно использую PouchDB/CouchDB для автономного приложения VueJS. Вначале наша команда решила, что нам, вероятно, следует сосредоточиться на выпуске новых функций и сэкономить время, необходимое для создания эффективного решения для синхронизации. Мы слышали, что CouchDB довольно хорош в этом (и что это, по сути, все, в чем он хорош, и что вы действительно не хотите создавать решение для синхронизации самостоятельно), и разработали наш уровень данных вокруг него.

Теперь мы расширяемся и обдумываем идею заменить его на что-то «лучшее», что мы делаем сами. Что может пойти не так!

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

Одной из таких задач является слияние. Я едва понимаю, что происходит в PouchDB и CouchDB, когда что-то идет не так, и мое сердце замирает каждый раз, когда Git жалуется на конфликты слияния. Короче говоря, конфликты слияния — это заноза в заднице.

Так что один из способов, которым я хотел бы справиться с этой проблемой, — полностью ее избежать.

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

Наше текущее решение

Мы вызываем кучу REST API и сохраняем каждый ресурс в виде документа в CouchDB. Это «мультитенантная» установка, в которой каждый клиент имеет свою собственную базу данных, но есть несколько пользователей/клиентов, которым требуется доступ к каждой базе данных.

Ожидается, что мы будем поддерживать не менее 7000 баз данных со средним размером около 5 МБ в несжатом формате JSON, но некоторые из них могут достигать 50 МБ.

Мы полагаемся на механизм репликации PouchDB/CouchDB, чтобы хранить копию всего набора данных клиента на его телефоне или планшете. Каждая база данных обычно имеет размер несколько мегабайт и содержит несколько тысяч документов.
Эти документы могут немного меняться в течение дня. Иногда из-за того, что пользователь вносит изменения, иногда из-за того, что серверные системы создают и обновляют данные, делают прогнозы и тому подобное.

Мы загружаем данные из PouchDB (которая сама использует IndexedDB) в хранилище VueX. После этого сам PouchDB используется очень мало.

CQRS-иш

Когда мы хотим внести изменения в данные, мы не меняем данные напрямую. Мы создаем документ (мы называем его командным документом) в PouchDB, который описывает изменение, которое мы хотим; обычно какой-то тип операции создания/обновления/удаления. Документ автоматически синхронизируется с CouchDB, где наш сервер подхватывает и обрабатывает его, что включает в себя вызов некоторых REST API и обработку ответа.

Эти вызовы API могут не работать по разным причинам. Если API недоступен, мы автоматически повторим попытку позже. Если API сообщает об ошибке 4XX, мы копируем ответ в командный документ и устанавливаем статус «сбой». Клиент получает обновленную версию командного документа и может сообщить об этом пользователю.

Нам удалось избежать большинства проблем слияния, установив некоторые правила:

  • Только сервер, который вызывает REST API, может создавать/обновлять/удалять ресурсные документы.
  • Только клиенты могут создавать/удалять командные документы; они владеют первой ревизией
  • Другие документы, которые создает клиент, не затрагиваются сервером.
  • Только сервер может обновлять командные документы; они владеют всеми будущими версиями

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

Одним из решений для этого было разрешить только одностороннюю репликацию для документов ресурсов и двустороннюю для командных документов.

Синхронизация документов ресурсов

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

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

Нам нужен надежный способ для клиента сообщить серверу, какая «версия» данных у него есть, например, GUID или порядковый номер. Затем сервер может сделать вывод, какие дельты нужны клиенту для обновления.

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

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

  • На сервере есть большой JSON, назовем его версией 1, который загружается клиентом.
  • Сервер периодически вызывает все необходимые REST API для создания нового JSON: версия 2.
  • Он делает некоторые грубые различия между версией 1 и новым JSON версии 2.
  • Клиент приходит, звонит, говорит, что у него версия 1
  • Сервер отвечает, отправляя все различия, которые он создал после версии 1; это значительно меньше, чем загрузка всего JSON
  • Клиент обрабатывает добавления, обновления и удаления и регистрирует новый номер версии.

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

Доказательство концепции

  • Приложение Spring Boot собирает данные из различных API и создает набор данных, который легко использовать в моем приложении. Технически этот уровень полировки не требовался для PoC, но у меня он собирал бинарную пыль в репозитории.
  • Я запускал это приложение несколько раз за последние несколько дней, сохраняя полученные данные в виде файлов JSON, чтобы получить представление о размере данных и скорости их изменения.
  • Я набросал алгоритм сравнения¹ на JavaScript, создав различия, которые я проверил визуально с помощью opendiff (позже будут тесты, если это стоит сохранить)
  • Я также провел несколько тестов с diff-merge-patch, который был многообещающим, но не позволял мне играть с уровнем детализации, т.е. какая часть JSON считается измененной при изменении значения атрибута; это просто значение атрибута или объект, который его содержит, или, возможно, его родитель?

Следующая часть включает получение данных с сервера. Я думаю, что стоит поэкспериментировать с различными транспортными механизмами, начиная от простого (длинного) опроса XHR и заканчивая WebSockets, EventSource и т. д. Поскольку все мои данные хранятся в файловой системе в виде файлов JSON, я пока придерживаюсь XHR, потому что это проще всего реализовать, а все остальное — преждевременная оптимизация.

Доказательство концепции

  • Я запустил python3 -m http.server 8000 в своей папке с файлами данных
  • Я написал простой клиент на JavaScript (работающий в узле), который использует axios для получения файла базовых данных и двух различий для каждого клиента.
  • Я проверил, что производные данные (база t0 + разница t1 + разница t2) соответствуют данным в момент времени t2.
  • Эй, это все еще не считается слиянием!

Все идет нормально.

Что дальше:

  • Придумываем альтернативу командным документам
  • Как насчет других типов данных, которые мы хотим создавать/обновлять/удалять на нескольких устройствах? Спойлеры: это включает в себя слияние и разрешение конфликтов. Разрешили ли мы этот вариант использования просто потому, что уже использовали CouchDB?
  • Сравнительный анализ с CouchDB: не трачу ли я на это время?

[1] Здесь я перефразирую Брюса Бэннера.