Статья

Что происходит после кнопки «Отправить»: архитектура мессенджера без магии

Что происходит после кнопки «Отправить»: архитектура мессенджера без магии

ProfileProfile·2 ч назад·
7
··3·
Пользователь нажимает «Отправить», пузырь появляется почти мгновенно — и кажется, что сообщение просто перелетело на другой телефон. На самом деле хороший мессенджер решает одновременно несколько неприятных задач: сеть может оборваться после записи, клиент повторит запрос, получатель подключён с трёх устройств, события придут не по порядку, push задержится, а сервер realtime перезапустится.
Главный архитектурный принцип: транспорт уведомляет, долговременное хранилище доказывает. WebSocket ускоряет доставку, но не должен быть единственным источником истины. Если событие потерялось, клиент обязан восстановить состояние из журнала сообщений.

Шаг 1. Идентификатор рождается на клиенте

До отправки клиент создаёт message_id — UUID/ULID или другой уникальный ключ — и локально показывает сообщение со статусом pending. Повтор после таймаута отправляет тот же идентификатор. На сервере уникальное ограничение по диалогу и client_message_id превращает повтор в безопасное чтение существующей записи.
Без идемпотентности типичная авария выглядит так: сервер записал сообщение, ответ потерялся, клиент повторил — получатель увидел два одинаковых пузыря. Проверка по тексту и времени ненадёжна: человек вправе дважды отправить «да». Нужен явный ключ операции.

Шаг 2. Авторизация относится к диалогу, не к сокету

Установленный WebSocket не является вечным пропуском. Для отправки сервер проверяет токен, членство в диалоге, блокировки, лимиты и право на тип вложения. Идентификатор автора берётся из аутентифицированной сессии, а не из тела запроса. При изменении членства активные соединения должны потерять доступ.
Полезно разделить gateway и доменную запись. Gateway валидирует форму и сессию, сервис сообщений выполняет транзакцию. Тогда HTTP fallback и WebSocket используют одну бизнес-логику и не расходятся в правах.

Шаг 3. Транзакция делает сообщение фактом

В одной транзакции создаются запись сообщения, позиция в диалоге и событие outbox. Порядок нужен в пределах конкретного диалога, а не глобально для всей системы. Его можно задавать монотонным sequence, выделяемым под блокировкой строки диалога или через специализированный sequencer.
Время клиента полезно для интерфейса, но не для порядка: часы могут спешить, отставать и меняться. Серверное created_at тоже способно совпасть. Пара conversation_id + sequence даёт стабильную пагинацию и восстановление пропусков.
Сначала сохранить, потом доставлять. Не наоборот: событие, которое увидели подписчики, но которого нет в истории, почти невозможно корректно объяснить.

Шаг 4. Outbox связывает БД и realtime

Если после commit приложение отдельно публикует событие, между этими действиями есть окно: процесс может упасть, и сообщение останется в базе без доставки. Transactional outbox записывает событие рядом с сообщением. Воркер читает outbox, публикует в broker/realtime и отмечает обработку. Повторная публикация допустима, потому что потребители дедуплицируют по event_id.
Ровно один раз в распределённой системе обычно заменяется на at-least-once плюс идемпотентность. Это честнее и устойчивее. Дубликат можно отфильтровать; потерянное событие без журнала — восстановить трудно.

Шаг 5. Fan-out по устройствам

Событие маршрутизируется не «пользователю вообще», а активным сессиям его устройств. Отправитель тоже получает серверную версию: sequence, время, нормализованные вложения и статус accepted. Это заменяет локальный pending и синхронизирует второй ноутбук автора.
Для маленьких систем достаточно канала на пользователя. При масштабе понадобятся реестр соединений, shard по user_id, broker и backpressure. Медленный клиент не должен удерживать общую очередь: ограничивайте буфер, закрывайте безнадёжные соединения и заставляйте их восстановиться из истории.

Accepted, delivered и read — разные факты

Accepted означает, что сервер сохранил сообщение. Delivered — хотя бы одно устройство получателя подтвердило получение или синхронизировало позицию. Read — пользователь открыл диалог до указанного sequence. Хранить ack на каждое сообщение и устройство дорого; часто достаточно монотонных курсоров delivered_up_to и read_up_to.
Интерфейс не должен обещать больше, чем измеряет. Push, отправленный провайдеру, ещё не означает доставку человеку. А открытый диалог на фоновом устройстве не всегда означает чтение — учитывайте видимость окна и активность.

Reconnect: клиент спрашивает, чего не видел

После разрыва клиент передаёт последнюю известную позицию по диалогам или общий sync cursor. Сервер возвращает изменения после курсора, включая редактирования, удаления, реакции и членство. Только после заполнения разрыва realtime снова считается актуальным.
Нельзя полагаться на порядок между REST-ответом и сокетом. Пока идёт синхронизация, новые события буферизуются и затем применяются по sequence с дедупликацией. Если курсор слишком стар или журнал сжат, сервер отдаёт snapshot и новую точку отсчёта.

Оффлайн и push

Push — будильник, не контейнер истины. В уведомление кладут минимум: тип события, безопасный preview при разрешении и идентификатор для синхронизации. После открытия приложение получает сообщение с сервера. Так истёкший или повторный push не создаёт дубликаты.
Нужно учитывать настройки конкретного диалога, тихие часы, упоминания, устройство на переднем плане и приватность экрана блокировки. При E2EE сервер может не иметь текста; preview либо формируется устройством, либо заменяется нейтральным «Новое сообщение».

Редактирование и удаление — тоже события

Изменение не должно перезаписывать историю так, будто прежнего состояния не было. Храните updated_at/version и выпускайте событие с ожидаемой версией. Конфликт редактирования требует правила: last-write-wins для простого чата или явного отклонения. Удаление для всех — доменная операция с авторизацией и tombstone, чтобы старое устройство не воскресило контент.

Вложения живут отдельной жизнью

Файл обычно загружается напрямую в объектное хранилище по короткой подписанной ссылке. Сервер создаёт upload intent, проверяет размер и тип, после завершения запускает антивирус/транскодирование и только затем разрешает привязать объект к сообщению. Нельзя доверять расширению или MIME от клиента.

E2EE: защищаем содержание, но видим метаданные

При сквозном шифровании клиент шифрует сообщение для устройств получателей; сервер хранит ciphertext и маршрутизирует его. Управление ключами, новые устройства, отзыв доступа, резервное восстановление и проверка собеседника сложнее самого вызова криптографической библиотеки. Самодельные протоколы здесь особенно опасны.
E2EE не скрывает автоматически участников, время, размеры и частоту сообщений. Эти метаданные тоже чувствительны. Минимизируйте логи, сроки хранения и доступ операторов. Поиск на сервере, модерация и предпросмотр становятся продуктовым компромиссом, который надо объяснить пользователю.

Наблюдаемость и SLO

Измеряйте latency по этапам: client→accepted, commit→published, published→device ack, reconnect gap recovery. Отдельно: доля дублей, глубина outbox, возраст старейшего события, reconnect rate, ошибки авторизации и рассинхронизация sequence. Trace_id должен связывать запрос, запись, outbox и доставку без логирования текста сообщения.
Полезны синтетические клиенты в разных регионах: отправка, обрыв после commit, повтор, получение на двух устройствах, уход офлайн и восстановление. Нагрузочный тест без хаоса проверит скорость, но не корректность. Выключайте broker, задерживайте БД, дублируйте событие, меняйте порядок и смотрите, сохраняются ли инварианты.

Минимальный production-чек-лист

Client-generated id; уникальное ограничение; транзакция message+outbox; порядок внутри диалога; дедупликация потребителей; различимые статусы; cursor sync; backpressure; отзыв прав активной сессии; безопасная загрузка файлов; push как сигнал; трассировка без содержания; тест потери ack. Если чего-то нет, система может выглядеть быстрой, но одна плохая сеть покажет правду.

Вывод

Мессенджер надёжен не потому, что держит постоянное соединение. Он надёжен, когда каждое сообщение имеет идентичность, фактическую запись, воспроизводимый порядок и путь восстановления. Realtime делает опыт быстрым; база и журнал делают его правдивым; идемпотентность позволяет пережить неопределённость сети.
3

Пока нет комментариев

Архитектура мессенджера: WebSocket, доставка, порядок, outbox и E2EE