Порядок — seq, курсоры и пагинация
Перед началом: модель диалогов из Модуля 2. Инструменты: PostgreSQL (транзакции, оконные функции), Go. Трудоёмкость: ~6 ч.
🔥 Инцидент: «Сообщение из будущего»
Пользователь жалуется: на телефоне его сообщение стоит после ответа
собеседника, на ноутбуке — перед. Начинаем разбираться: оба сообщения
записаны в одну миллисекунду, ORDER BY created_at на разных репликах
выбирает разный порядок среди «равных» строк. Второй звонок того же дня:
при пролистывании истории вверх пропало сообщение — пагинация
WHERE created_at > cursor потеряла строку ровно на границе страницы,
потому что сравнение по времени не отличает «строго после» от «в ту же
миллисекунду». Оба инцидента — об одном: время не является порядком.
📖 Теория
3.1 Почему created_at не годится
- Дубли значений. У миллисекундного разрешения есть коллизии; порядок среди равных не детерминирован.
- Курсоры. Пагинация «строго позже X» пропускает строки, «не раньше X» дублирует их.
- Read-позиция. «Прочитано до времени T» невыразимо безопасно:
непрочитанное =
count(created_at > T)— это диапазонный скан вместо разности двух чисел. - Часы. В распределённой системе время записи зависит от того, чьи часы тикают; порядок должен жить в данных, а не в метаданных.
Решение: у каждого диалога — монотонный счётчик last_seq, а у
сообщения — номер seq внутри диалога.
3.2 Выделение seq — в той же транзакции
sql-- в одной транзакции с INSERT сообщения и INSERT события в outbox UPDATE conversations SET last_seq = last_seq + 1, last_message_at = now() WHERE id = $1 RETURNING last_seq;
Почему UPDATE ... RETURNING, а не SELECT max(seq)+1:
max(seq)+1при параллельных отправках даст одинаковые номера — классическая ошибка, которую ловит UNIQUE-индекс;UPDATEберёт блокировку строки диалога: параллельные отправки в один диалог выстраиваются в очередь — а это и есть нужная сериализация;- отправки в разные диалоги не конкурируют вовсе (разные строки).
Инвариант закрепляем схемой:
sqlCREATE UNIQUE INDEX messages_conv_seq ON messages (conversation_id, seq);
3.3 Идемпотентная отправка: client_msg_id
Клиент ретраит отправку, когда ответ не пришёл (таймаут, обрыв).
Чтобы ретрай не создал дубль, клиент генерирует client_msg_id (UUID на
сообщение), а сервер дедуплицирует:
sqlCREATE UNIQUE INDEX messages_sender_client ON messages (sender_id, client_msg_id) WHERE client_msg_id IS NOT NULL;
Порядок в юзкейсе: сначала поиск дубля, потом выделение seq.
go// псевдокод SendMessage v2 tx := begin() if existing := findByClientID(tx, senderID, clientMsgID); existing != nil { return existing, nil // ретрай: сообщение уже есть, отвечаем 200 } seq := allocateSeq(tx, conversationID) // UPDATE ... RETURNING insertMessage(tx, ..., seq, clientMsgID) insertOutbox(tx, event(seq)) commit()
Гонка двух одновременных ретраев с одним client_msg_id ловится
UNIQUE-индексом: проигравший получает unique violation, делает
SELECT по паре и возвращает существующее сообщение.
3.4 Курсорная пагинация и дыры
История читается строго по seq, от новых к старым:
sqlSELECT id, seq, sender_id, text, created_at FROM messages WHERE conversation_id = $1 AND ($2::bigint IS NULL OR seq < $2) ORDER BY seq DESC LIMIT $3;
seq детерминирован, поэтому страницы стыкуются без пропусков и дублей.
Дыры в seq допустимы: уникальный индекс ловится, транзакция
откатывается — номер «сгорает». Клиент обязан относиться к seq как к
порядку, а не как к «количеству сообщений» (не рисовать «пропуск 41» по
отсутствию 41-го).
3.5 Read-позиция
«Прочитано» — это число, а не флаг:
sqlUPDATE conversation_participants SET last_read_seq = GREATEST(last_read_seq, $3) WHERE conversation_id = $1 AND user_id = $2;
GREATEST защищает от гонки: рецепт от старого устройства с меньшим
номером не откатит позицию назад. Непрочитанные считаются вычитанием:
last_seq - last_read_seq — без сканов ленты.
💻 Практика
- Миграция
000005_messages_seq.{up,down}.sql:
sqlALTER TABLE messages ADD COLUMN seq BIGINT; ALTER TABLE messages ADD COLUMN client_msg_id TEXT; UPDATE messages m SET seq = s.rn FROM (SELECT id, row_number() OVER ( PARTITION BY conversation_id ORDER BY created_at, id) AS rn FROM messages) s WHERE m.id = s.id; UPDATE conversations c SET last_seq = m.max_seq FROM (SELECT conversation_id, max(seq) AS max_seq FROM messages GROUP BY conversation_id) m WHERE c.id = m.conversation_id; ALTER TABLE messages ALTER COLUMN seq SET NOT NULL; CREATE UNIQUE INDEX messages_conv_seq ON messages (conversation_id, seq); CREATE UNIQUE INDEX messages_sender_client ON messages (sender_id, client_msg_id) WHERE client_msg_id IS NOT NULL;
- Перепишите
SendMessageпо псевдокоду §3.3. Сигнатура:
goSendMessage(ctx, conversationID, senderID uuid.UUID, text, clientMsgID string) (*models.Message, error)
-
Обновите
POST /conversations/{id}/messages(новый путь из Модуля 2):client_msg_idобязателен, повторный запрос с тем же id возвращает 200 с существующим сообщением, а не 409. -
История:
GET /conversations/{id}/messages?before_seq=&limit=— по §3.4. Отдельно проверьте край:limit=50на диалоге с 120 сообщениями — три страницы, ноль дублей и пропусков. -
POST /conversations/{id}/readс телом{"seq": 124}— по §3.5. Обновите путь в openapi и прогонитеmake validate-api. -
Тесты:
- 50 параллельных отправок в один диалог → seq образуют 1..50 без дублей;
- два запроса с одинаковым
client_msg_id→ одно сообщение, оба ответа содержат его id; - рецепт с меньшим seq после большего не откатывает
last_read_seq.
Чек-лист модуля: порядок детерминирован и не зависит от часов; пагинация стыкуется без дыр; ретраи безопасны; непрочитанные считаются разностью чисел.
🧩 Quiz
SELECT max(seq) + 1 для выделения номера — ошибка?WHERE seq < $cursor ORDER BY seq DESC LIMIT 50. Почему не <=?client_msg_id обязателен, хотя у сообщения уже есть серверный id?GREATEST, а не просто присваивание?✅ Итоги модуля
- Время больше не определяет порядок:
seqдетерминирован и сериализован черезUPDATE ... RETURNING. - Отправка идемпотентна:
client_msg_id+ UNIQUE + «поиск до выделения seq». - Пагинация по
seqстыкуется без дублей и пропусков; дыры допустимы и клиент к ним устойчив. - Read-позиция — одно число на участника, защищённое
GREATEST. - Непрочитанные — разность
last_seq - last_read_seq, без сканов.
🔗 Мостик к Модулю 4
Фундамент залит: диалоги, порядок, идемпотентность. Теперь собираем пользовательский опыт P2P-переписки: список диалогов с последним сообщением и непрочитанными, старт переписки из контактов, формирование заголовка личного диалога из имени собеседника — так, чтобы «диалог» ощущался живым уже на пустом.