Real-Time Backend

Chat - история и доставка

Почему в WhatsApp галочки меняются с серых на синие, а у Signal иногда нет - при одинаковом UX под капотом разная архитектура?

  • **WhatsApp** (100 млрд сообщений/день) хранит `lastReadMessageId` на пользователя вместо per-message receipts - экономит 99% записей в БД для групп
  • **iMessage** исправил баг с откатом синих галочек при смене сети через FSM с однонаправленными переходами статусов
  • **Telegram** переходит на cursor pagination с Snowflake ID - OFFSET страницы 'ехали' при новых сообщениях создавая дубли
  • **Signal** использует sealed sender + encrypted storage service для sync истории между устройствами без доступа сервера к контенту

Read receipts: галочки доставки

Read receipts - механизм подтверждения что получатель прочитал сообщение. WhatsApp показывает одну серую галочку (отправлено), две серые (доставлено), две синие (прочитано). Каждый переход - это отдельный сигнал от разных источников.

В групповых чатах read receipts масштабируются плохо: 1000 участников = 1000 отдельных событий прочтения. Telegram в больших группах не показывает read receipts вообще - только общий счётчик просмотров.

  • **Sent** - ack от сервера с server timestamp (не device clock - клиент может врать)
  • **Delivered** - WebSocket push или FCM delivery report при офлайн-доставке
  • **Read** - событие от клиента при visibility/focus, throttled до 1 раза в 5с
  • В групповых чатах хранить `lastReadMessageId` на пользователя, не счётчики на каждое сообщение

WhatsApp показывает синие галочки (прочитано) для группового чата с 500 участниками. Как хранить это состояние?

Message Status: конечный автомат

Статус сообщения - это конечный автомат с однонаправленными переходами. Сообщение не может перейти из `read` обратно в `delivered`. Нарушение этого инварианта создаёт визуальные артефакты: синие галочки становятся серыми.

iMessage столкнулся с этой проблемой: при переключении между Wi-Fi и сотовой сетью delivery receipts приходили с задержкой и откатывали синие галочки обратно в серые. Apple исправил это именно через FSM с однонаправленными переходами.

Статус `pending` живёт только на клиенте-отправителе: это оптимистичный UI до получения server ack. В БД сервера сообщение появляется уже в статусе `sent`.

Сообщение в статусе `read`. Приходит поздний delivery receipt от другого устройства получателя. Что должен сделать сервер?

Cursor-based pagination истории

История сообщений нельзя пагинировать через OFFSET: при добавлении новых сообщений страницы смещаются, и пользователь видит дубли или пропуски. WhatsApp и Telegram используют cursor-based pagination по message ID или timestamp.

Telegram использует Snowflake-подобные ID: 64-битные числа где старшие биты - timestamp. Это даёт возможность пагинировать как по ID так и по времени, и ID остаётся монотонным даже при вставке реплаев.

  • OFFSET pagination - запрещён для чатов: при новых сообщениях страницы смещаются
  • Cursor по timestamp - проблема при одинаковых timestamps (batch inserts)
  • Cursor по ID (Snowflake) - стабильный, монотонный, содержит timestamp
  • Размер страницы: 50 сообщений оптимально - балансирует между RTT и объёмом данных

Клиент загрузил страницу 1 (сообщения 1-50) через OFFSET 0. Пока он читал, пришли 3 новых сообщения. При загрузке страницы 2 (OFFSET 50) что произойдёт?

History sync при переключении устройств

История должна синхронизироваться между устройствами: открыл Telegram на планшете после телефона - все сообщения на месте, прочитанные отмечены. Это нетривиальная задача при offline-сценариях.

Signal реализует E2E sync через sealed sender protocol: даже сервер не знает кто кому пишет. История зашифрована, sync идёт через Storage Service - отдельный сервис с encrypted blob per device. При новом устройстве история передаётся через QR-code link с временным ключом.

Soft delete для сообщений обязателен: `deleted_at` вместо физического удаления. При sync новое устройство должно получить информацию об удалённых сообщениях, иначе они появятся снова.

History sync - это просто загрузка всех сообщений при старте приложения

Sync включает три типа событий: новые сообщения, обновления статусов и удаления (tombstones). Без tombstones удалённые сообщения возвращаются на переустановленном приложении

БД с физическим удалением не может ответить на вопрос 'что изменилось с момента X' для удалённых записей. Soft delete с `deleted_at` - единственный способ передать информацию об удалении при incremental sync

Пользователь удалил сообщение ('для всех') пока второй его телефон был офлайн. Через час телефон включился и запросил sync. Как сервер должен передать информацию об удалении?

Итоги

  • **Read receipts = lastReadMessageId**: для групп хранить курсор на пользователя, не запись на каждое сообщение
  • **Message status = FSM**: переходы однонаправленные, поздние receipts не должны откатывать статус назад
  • **Cursor pagination по ID**: OFFSET нестабилен при добавлении новых сообщений, Snowflake ID решает проблему

Связанные темы

История и доставка сообщений опираются на несколько ключевых паттернов:

  • Snowflake ID generation — Монотонные распределённые ID для cursor pagination
  • Offline-first sync — Инкрементальный sync при восстановлении подключения
  • Chat архитектура — Базовые паттерны 1:1 и групповых каналов

Вопросы для размышления

  • WhatsApp не показывает read receipts если пользователь их отключил в настройках. Как это влияет на архитектуру - хранятся ли данные о прочтении на сервере?
  • При cursor pagination: как обработать сценарий когда cursor-сообщение было удалено?
  • Telegram хранит историю на серверах, Signal - только на устройствах. Какие trade-offs это создаёт для sync?

Связанные уроки

  • db-09-indexes-btree
Chat - история и доставка

0

1

Войти