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?