ARTICLE / GO BACKEND

Todos los artículos

Sincronización offline-first y entrega de eventos con red inestable

Cómo diseñar una cola durable en el dispositivo, recepción idempotente, recibos y conciliación de estado para no perder ni duplicar operaciones.

El texto completo está en ruso.

GoArchitectureHighloadDistributed SystemsIntegrations

В складском или выездном процессе сеть пропадает не в удобный момент. Оператор уже отсканировал товар, терминал показал успех, а backend не получил операцию. При восстановлении связи устройство повторяет накопленное, часть запросов приходит дважды и не по порядку. Обычная связка «HTTP-запрос — успешный ответ» перестаёт быть границей надёжности.

Offline-first здесь означает не экран, который открывается без интернета. Это протокол, в котором устройство сохраняет намерение локально, сервер принимает повторы как штатный режим, а обе стороны умеют отличить доставку от применения бизнес-операции.

Ниже я называю событием запись локального журнала. По смыслу она может быть фактом сканирования или командой на переход состояния. Факт сервер регистрирует, а команду вправе отклонить по текущим бизнес-правилам; объединять эти семантики одним обработчиком нельзя.

Контекст и ограничения

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

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

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

Второе ограничение — порядок. Обычно он нужен внутри потока одного устройства, задания или сущности. Требование упорядочить все терминалы глобально почти всегда слишком дорого и не отражает реального процесса.

Рабочая модель: журнал, квитанции и сверка

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

flowchart LR
    A[Действие оператора] --> B[(Локальный журнал)]
    B --> C[Процесс синхронизации]
    C -->|пачка событий| D[API приёма]
    D -->|транзакция одной операции| E[(Inbox)]
    D -->|та же транзакция операции| F[(Бизнес-состояние)]
    D -->|квитанции| C
    C -->|подтверждённые записи| G[(Архив / удаление)]
    F --> H[Снимок для сверки]
    H --> C

Сначала сохранить намерение на устройстве

Действие считается принятым приложением только после записи в локальное долговечное хранилище. Не в память процесса и не в очередь фоновой задачи, которая исчезнет при перезапуске. Интерфейс может показать состояние «сохранено на устройстве», но не должен называть его подтверждённым сервером.

Запись содержит как минимум:

Отдельный stream_id нужен потому, что приложение могут переустановить, а счётчик начнётся заново. Физический device_id для последовательности недостаточен. Серверные решения нельзя строить только на времени терминала: часы могут спешить, отставать или измениться во время offline-периода.

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

Локальная очередь удаляет запись не после отправки запроса и не после ответа 200 на всю пачку. Ей нужна квитанция именно для данного event_id.

Возвращать результат для каждого события

У ответа синхронизации должна быть семантика, более точная, чем «пачка обработана». Практичный набор состояний:

Rejected тоже является квитанцией доставки, но не успехом бизнес-операции. Терминал убирает такую запись из активного retry, сохраняет причину и показывает понятный путь разрешения конфликта. Иначе одна некорректная операция бесконечно блокирует очередь.

Квитанция описывает только результат локального шага сервера. Если она запускает отдельный внешний процесс, его статус хранится и показывается отдельно от состояния локальной очереди.

Клиент применяет квитанции поштучно. Потеря ответа после фиксации серверной транзакции приведёт к повтору пачки, и сервер должен вернуть те же результаты. Новая попытка не создаёт новые event_id.

func applyReceipts(queue Queue, receipts []Receipt) error {
	for _, r := range receipts {
		switch r.Status {
		case Accepted, Duplicate:
			if err := queue.MarkDone(r.EventID, r.Result); err != nil {
				return fmt.Errorf("store success receipt: %w", err)
			}
		case Rejected:
			if err := queue.MarkRejected(r.EventID, r.Code); err != nil {
				return fmt.Errorf("store rejection receipt: %w", err)
			}
		case RetryLater:
			// Запись остаётся активной с серверной задержкой перед новой попыткой.
			if err := queue.Defer(r.EventID, r.RetryAfter); err != nil {
				return fmt.Errorf("store retry receipt: %w", err)
			}
		}
	}
	return nil
}

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

Применять событие и фиксировать дедупликацию атомарно

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

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

Команды над состоянием лучше формулировать условно: «подтвердить задание версии 7», а не «сделать состояние таким-то». Проверка версии отделяет транспортный повтор от настоящего конфликта. Для фактов, например регистрации независимых сканирований, строгая последовательность может быть не нужна; для переходов конечного автомата она часто обязательна.

Срок хранения inbox должен покрывать максимальное окно offline и контролируемого replay. После очистки поздний повтор безопасен только тогда, когда сам бизнес-инвариант — версия, уникальный ключ или допустимый переход — всё ещё не позволяет применить эффект второй раз.

Если после приёма требуется внешний вызов, транзакция inbox не может атомарно включить чужой API. Тогда внутри неё записывается следующий шаг в outbox, а внешний эффект получает собственный idempotency key. Offline-first не отменяет границы распределённой транзакции.

Синхронизировать не только события, но и состояние

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

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

Типичные поломки и как их ловят

Квитанция отправлена раньше фиксации транзакции

Если API вернул accepted, а транзакция затем откатилась, терминал законно удалит единственную копию операции. Квитанция создаётся из зафиксированного результата. При сомнительном сетевом исходе клиент повторяет запрос с тем же идентификатором. Периодическая сверка принятых идентификаторов с бизнес-состоянием ловит ошибку реализации этой границы.

Курсор перепрыгнул через дыру

Пачки могут попасть на разные экземпляры backend и завершиться в другом порядке. Подтверждать только «максимальный sequence» опасно: номер 12 не доказывает наличие 11. Сервер либо хранит непрерывную подтверждённую границу вместе с информацией о пропусках, либо возвращает квитанции по событиям. Граница продвигается через любой окончательный исход — accepted или rejected. Если пропавшей записи уже нет в журнале, поток не разблокируется молча: клиент сверяет журнал, затем начинает новый stream_id через явную процедуру восстановления. Возраст дыры становится отдельным сигналом.

Восстановление связи создаёт лавину повторной отправки

Многие устройства одновременно начинают отправлять большие очереди. Без ограничения пачки, случайного разброса backoff и обратного давления со стороны сервера восстановление сети превращается в новую аварию. API должен ограничивать объём запроса и число параллельных синхронизаций, а ответ retry_later — задавать управляемую задержку.

Постоянная ошибка маскируется как временная

Неизвестная версия схемы, удалённое задание и нарушенный инвариант не исправятся после двадцатого retry. Ошибки классифицируются на границе домена и имеют стабильные коды. Доля rejected по версии клиента помогает заметить несовместимый релиз, не собирая полезные нагрузки в журналах.

Идентичность устройства переиспользована

После сброса терминала новый журнал может получить старые номера последовательности. stream_id, выданный для установки или локальной базы, изолирует потоки. При этом аутентификация должна связывать поток с разрешённым устройством и пользователем, а не доверять идентификатору из полезной нагрузки.

Локальная очередь растёт без предела

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

Что измерять

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

На сервере я бы отслеживал:

Нужен и бизнес-инвариант. Например, завершённое задание не должно иметь необработанных обязательных операций. Такая проверка полезнее графика успешных HTTP-ответов: транспорт может быть зелёным, пока процесс уже расходится.

Когда так делать не стоит

Offline-first существенно усложняет клиент, API и поддержку. Для приложения со стабильной связью и дешёвым повторным вводом обычный синхронный запрос может быть лучшим решением. Не каждая операция должна работать offline только потому, что это технически возможно.

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

Локальное долговечное хранение также требует модели угроз: шифрования, срока жизни данных, выхода пользователя и потери устройства. Если чувствительные данные нельзя безопасно держать на терминале, объём автономной функции придётся ограничить.

Вывод для архитектурного ревью

Надёжный offline-first поток держится на трёх явных границах: устройство сохраняет намерение до отправки, сервер атомарно связывает дедупликацию с бизнес-эффектом, а клиент удаляет запись только по квитанции конкретного события. Снимки и версии закрывают расхождение состояния, которое одной доставкой не исправить.

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