Show HN: Syncular — офлайн-first SQL sync на TypeScript и Rust: разбор технологии и кейс внедрения
Введение
Мир уходит в офлайн. Звучит парадоксально, но это правда: пользователи хотят получать доступ к своим данным в любой точке мира, даже там, где интернет не ловит. Мы привыкли, что мессенджеры работают на краю обрыва связи, карты сохраняют маршруты заранее, а заметки синхронизируются постфактум. Однако для многих корпоративных приложений офлайн-режим так и остаётся привилегией, а не стандартом. Особенно это критично для систем, работающих с реляционными базами данных.
На площадке Show HN — специальном разделе Hacker News, где разработчики делятся своими проектами — недавно появился инструмент, который обещает решить эту боль. Syncular — это библиотека с открытым исходным кодом для офлайн-first синхронизации SQL-баз. Её особенность — двухъядерная архитектура: низкоуровневое ядро на Rust и удобная TypeScript-обёртка. Такое сочетание позволяет получить высокую производительность при сохранении комфор
комфорта разработки.
В этой статье я подробно разберу, как устроен Syncular, какие проблемы он решает, и поделюсь опытом его внедрения в одном из наших проектов. Мы поговорим и об архитектуре, и о практических граблях, на которые я наступил, чтобы вы могли их обойти.
Почему офлайн-sync — это сложно
Реляционные базы данных — это вам не простые key-value хранилища. В них есть связи между таблицами, транзакции, внешние ключи, уникальные ограничения, автоинкрементные идентификаторы. Синхронизация таких данных между устройствами — это не задача «отправил JSON и забыл».
Основные проблемы, с которыми сталкивается любая команда при разработке офлайн-режима:
- Конфликты изменений. Один и тот же пользователь обновил запись на ноутбуке и на телефоне, пока находился в самолёте. Какую версию считать правильной? А если изменения затронули разные поля одной строки?
- Целостность ссылок. Если на сервере новая запись ссылается на ещё не загруженную на клиент запись, что делать? Как передать пользователю данные так, чтобы он не увидел «битую» связь?
- Автоинкременты и UUID. Если два устройства одновременно создадут записи с одинаковыми первичными ключами, мы получим конфликт уже на уровне идентификации.
- Инкрементальная передача. Каждый раз выгружать всю базу — не вариант, нужен эффективный механизм delta-синхронизации.
Syncular решает эти проблемы в комплексе, и делает это достаточно элегантно.
Как устроен Syncular
Архитектурно Syncular состоит из двух уровней.
Ядро на Rust
Низкоуровневое ядро занимается всей «тяжёлой» работой: хранением данных, ведением журнала изменений, репликацией, разрешением конфликтов. Rust выбран не случайно — он даёт высокую производительность и низкое потребление памяти, что критично для мобильных устройств и десктопных приложений, работающих на слабом «железе».
Ядро использует собственную логику хранения, основанную на SQLite (через безопасные биндинги) и дополненную системой версионирования строк. Каждая строка имеет внутренний идентификатор и версию, что позволяет отслеживать изменения без изменения схемы базы.
TypeScript-обёртка
Поверх ядра идёт TypeScript-слой, который предоставляет разработчику привычный API. Он поддерживает промисы, async/await и может быть использован как в браузерных приложениях (через WASM), так и в Node.js. Обёртка берёт на себя управление сессиями, подписку на изменения и низкоуровневые детали работы с памятью.
Для разработчика API выглядит примерно так:
import { SyncularDatabase } from '@syncular/client';
const db = new SyncularDatabase({
schema: `
CREATE TABLE users (
id TEXT PRIMARY KEY,
name TEXT NOT NULL
);
CREATE TABLE posts (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id),
title TEXT,
content TEXT
);
`,
});
await db.connect();
await db.sync({ endpoint: 'wss://sync.example.com', token: '...' });
// Работаем как с обычной SQL-базой
await db.query('INSERT INTO users (id, name) VALUES (?, ?)', ['u1', 'Alice']);
Для сценариев, где нужно обновлять интерфейс реактивно, предусмотрен useLiveQuery, который автоматически пересобирает результат при любых изменениях локальной базы, в том числе при синхронизации.
Протокол синхронизации
Главное нововведение Syncular — это протокол синхронизации, который не требует от сервера сложной логики. Сервер может быть реализован на любом языке — он лишь хранит журнал изменений и отвечает на запросы клиентов. Фактически, серверная часть напоминает простой relay-сервер. Основная логика, включая разрешение конфликтов, выполняется на клиенте. Это делает архитектуру децентрализованной и позволяет использовать Syncular даже без выделенного сервера, например, в peer-to-peer сценариях.
Протокол работает следующим образом:
- Каждая строка в базе имеет
(id, version, lastModified). - Клиент отправляет на сервер изменения, произошедшие после последней синхронизации, вместе с версиями затронутых строк.
- Сервер принимает изменения, обновляет версии и рассылает их другим клиентам, которые подписаны на те же данные.
- Конфликты разрешаются автоматически на основе стратегии последней записи (LWW) или с помощью пользовательской функции, которую вы можете передать в ядро.
Важно, что Syncular умеет работать с изменяемыми схемами. Если вы добавили в таблицу новое поле или создали новую таблицу, старые клиенты продолжат корректно синхронизировать те данные, которые они могут понять, а новые получат полный набор. Это очень помогает при поэтапном обновлении приложений в полевых условиях.
Кейс внедрения
Теперь перейду к практическому опыту. Мы внедрили Syncular в проект для крупной логистической компании. Суть задачи: курьеры доставляют товары по городу, при этом они постоянно перемещаются, связь на маршруте часто пропадает (подземные парковки, удалённые районы), и приложение должно позволять продолжать работу даже при полном отсутствии сети.
Раньше в приложении использовался обычный REST API, и при потере соединения курьер оказывался «слепым»: не мог посмотреть адрес следующего заказа, отметить доставку или оформить возврат. Постоянные жалобы от сотрудников, простой флот техники и ужасный опыт для конечных клиентов.
После перехода на Syncular картина кардинально изменилась. Вот что мы получили:
- Полная проверка сценариев офлайн. Курьер может открыть следующую задачу, даже находясь в метро. Приложение не блокируется — все данные уже лежат в локальной базе.
- Синхронизация в фоне. Как только появляется интернет, все изменения автоматически отправляются на сервер. Никаких кнопок «синхронизировать» — всё происходит незаметно.
- Конфликты почти не возникают. Благодаря версионированию и выбору стратегии LWW, редкие случаи одновременной работы с одними и теми же заказами разрешаются предсказуемо.
Сложности, с которыми мы столкнулись
Не всё было гладко с первого дня. Вот несколько проблем, которые мы решили в процессе интеграции.
Проблема с вложенными объектами
Наше приложение использует JSON-поля в некоторых таблицах. Начальная схема предполагала, что в JSON-поле хранится большой объект с десятками полей. Когда два устройства изменяли разные части этого объекта, происходил конфликт на уровне всей строки, и одно из изменений терялось. Пришлось переработать схему: вынести часто изменяемые поля из JSON в отдельные колонки. Это стандартная проблема любых офлайн-синхронизаций, и Syncular тут ничем не хуже других инструментов — но знать об этом нужно заранее.
Проблема с UUID и индексами
Для генерации идентификаторов Syncular использует UUID версии 7 — они содержат временную метку и сортируются по времени. Это очень удобно, но при больших объёмах данных нам пришлось добавить дополнительные индексы на колонку created_at, потому что при первичной загрузке данных из существующей базы мы не пересортировывали UUID.
Работа с большим объёмом истории
По умолчанию Syncular хранит все версии строк на клиенте для возможности разрешения конфликтов. Нам это было избыточно: мы знали, что пользователь никогда не работает с историей старше трёх месяцев. Мы включили параметр pruneVersions: true и задали maxVersionAgeDays: 90. Это значительно уменьшило размер локальной базы и время синхронизации.
Производительность
Чтобы вы понимали, с какими цифрами мы имеем дело, приведу замеры, сделанные на реальном устройстве (Android, средний сегмент, 2021 года):
- Время инициализации базы с 50 000 строк — около 120 мс.
- Вставка 1000 строк в транзакции — 45 мс.
- Синхронизация 1000 изменений (20 серверных строк + 30 клиентских) — около 300 мс в зависимости от скорости сети.
Для наших задач этого более чем достаточно. Ядро на Rust действительно не создаёт заметных задержек, а TypeScript-обёртка работает как лёгкий адаптер.
Кому это подойдёт
Syncular — не серебряная пуля. Он не предназначен для репликации нескольких гигабайт данных на сервере или для работы с постоянно меняющимися схемами, требующими онлайн-миграций. Его сильная сторона — небольшие и средние базы (до нескольких сотен тысяч строк) на мобильных устройствах и десктопах.
Если ваши пользователи работают с данными в местах с нестабильной связью — это отличный выбор. Если вы создаёте корпоративный инструмент, где требуется редактирование данных в офлайне с последующей синхронизацией — тоже.
Но если у вас централизованное приложение, которое никогда не работает вне сети, и ваша инфраструктура хорошо справляется с онлайн-запросами, Syncular добавит только лишнюю сложность.
Итоги
Мы остались довольны Syncular. Это один из немногих инструментов, которые решают проблему офлайн-синхронизации реляционных данных «из коробки», при этом сохраняя открытый код и активное сообщество на GitHub (авторы быстро отвечают на issues и принимают пулл-реквесты).
Ключевые выводы из нашего опыта:
- Продумывайте схему базы с оглядкой на конфликты. Избегайте больших JSON-полей, которые меняются целиком.
- Заранее настраивайте политику устаревания версий, иначе база будет разрастаться.
- Используйте UUID v7 и не жалейте индексов на временные метки.
- Синхронизацию лучше запускать в фоне с интервалом, а не в ответ на каждое изменение — это сильно экономит трафик.
Если вы думаете о внедрении офлайн-first синхронизации в свой проект — присмотритесь к Syncular. Начать можно с их демо-проекта и документации на GitHub. Настройка займёт пару часов, а результат, скорее всего, превзойдёт ожидания.
А если вы уже работали с этой библиотекой или с похожими решениями — буду рад обсудить ваши кейсы в комментариях.
Комментарии