Создание синхронизации мобильных данных, когда я должен знать лучше. Да простит меня Интернет.
Я уже давно использую 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] Здесь я перефразирую Брюса Бэннера.