Недавно ходил в кино и, пока стоял в очереди на вход, поймал себя на мысли, что проектирую систему, которой пользуется контролер. На первый взгляд задача примитивная: есть база билетов, контролер сканирует QR, система должна проверить билет и пустить человека. Главное условие - один билет используется ровно один раз. Несмотря на то что билеты недорогие, предположим, что нам очень-очень важно, чтобы каждый билет использовался не более чем одним человеком.

Я прикинул, и понял, что проблем там гораздо больше, чем кажется.

1. Офлайн-валидация

Самое простое решение - сделать QR-код с цифровой подписью (например, на базе ECC или RSA). В коде зашиты данные билета и подпись системы. Контролер может проверить валидность билета вообще без интернета, просто зная публичный ключ. Телефон мгновенно говорит - да, это наш билет.

Но тут же вылезает проблема двойного прохода. Если контролеров несколько, и они не связаны сетью, то по одному билету (или его скриншоту) можно пройти через каждый гейт. Контролер №1 пометит у себя в памяти, что билет 123 использован, но контролер №2 об этом не узнает. Офлайн-схема работает только там, где вход всего один, а мы хотим scalability.

2. Общее состояние на сервере

Чтобы контролер на первом входе знал, что билет уже просканировали на втором, нам нужно единое хранилище. А значит, придется идти на сервер и работать с базой данных.

Для надежности при конкурентных запросах, погашение билета должно быть атомарным. Например, такой запрос в реляционной базе (PostgreSQL) вполне подходит:

UPDATE tickets 
SET status = 'wasted', used_at = NOW()
WHERE id = :ticket_id AND status = 'active'
RETURNING *;

Если запрос вернул строку - все ок, человек проходит. Если ничего не вернул - значит билет уже использовали или его нет.

Но здесь мы упираемся в классическую задачу двух генералов. Представим, что контролер стоит в подвале ТЦ, где сеть работает через раз. Он нажал кнопку сканировать, телефон отправил запрос. Сервер успешно получил его, обновил статус билета в базе и отправил ответ: ОК. Но на обратном пути ответ потерялся.

У контролера на экране вылетает ошибка тайм-аута. Он не знает, прошел запрос или нет, поэтому пробует сканировать еще раз, но сервер теперь честно отвечает: билет в статусе wasted, а значит уже использован. Контролер не впускает клиента. Мы получили ситуацию, когда человек купил билет, система его погасила, а в зал его не пускают.

3. Идемпотентная обработка

Чтобы разрулить проблему с повторными попытками, сканирование билета должно быть идемпотентным. Если по-простому: сколько бы раз мы ни отправляли один и тот же запрос, состояние системы должно измениться только один раз. Для этого, обычно, используют ключ идемпотентности.

Внимательный читатель может заметить: наш предыдущий запрос с UPDATE и так идемпотентен. Сколько бы раз мы его ни ретраили, статус билета в базе не изменится - он останется wasted.

Зачем тогда вообще городить огород с ключом идемпотентности?

Дело в том, что идемпотентности на уровне состояния базы данных недостаточно для надежной работы API в нашем случае. Без внешнего ключа, который идентифицирует конкретную попытку, сервер не может отличить обычный ретрай (из-за плохого интернета) от попытки переиспользования одного билета разными людьми. Нам нужно уметь запоминать результат конкретной сессии сканирования, чтобы вернуть успешный ответ контролеру, даже если технически статус билета изменился пару секунд назад.

Итак, приложение контролера генерирует уникальный ключ (например UUID) при открытии окна сканирования конкретного билета. Пока это окно открыто, ключ остается неизменным. Это позволяет делать безопасные ретраи - бэкенд будет дедуплицировать запросы по этому ключу. Пример реализации, с использованием транзакции (read commited) и уникального индекса:

begin ;

-- 1. Пытаемся зафиксировать попытку

INSERT INTO processed_requests (ticket_id, idempotency_key)
VALUES (:ticket_id, :idempotency_key)
ON CONFLICT (ticket_id, idempotency_key) 
DO NOTHING RETURNING *

-- 2. [логика приложения]: 
--     если rows_affected == 0 (запись уже есть)
--     rollback;
--     вернуть SUCCESS; (Так как мы доверяем тому, что в этой таблице 
--     записи оказываются только после успешного завершения всей транзакции)

-- 3. Если это новый запрос — пытаемся погасить билет

UPDATE tickets 
SET status = 'wasted', used_at = NOW()
WHERE id = :ticket_id AND status = 'active'
RETURNING *;

-- 4. [логика приложения]:
--     если rows_affected == 0 (билет не обновился):
--     rollback;
--     вернуть ERROR; (Билет уже был использован кем-то другим 
--     или просто не существует)

commit; 

-- 5. если коммит прошел успешно -> вернуть SUCCESS;

В двух словах, почему это работает: транзакция гарантирует атомарность - мы либо записали ключ идемпотентности И пометили билет как использованный, либо не сделали вообще ничего. А уникальный индекс защищает от гонок: если два одинаковых запроса прилетят одновременно, СУБД выстроит их в очередь. Первый вставит запись, а второй наткнется на конфликт и честно уйдет в ветку DO NOTHING.

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

4. Потеря контекста

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

Клиент отходит, контролер закрывает окно сканирования этого билета. В этот момент контекст старой попытки стирается, и тот самый idempotency_key, по которому мы могли бы сделать ретрай, теряется.

Через пару минут клиент возвращается. Контролер сканирует билет заново. Приложение создает совершенно новый запрос с новым idempotency_key.

Бекенд смотрит в базу: билет уже использован, причем idempotency_key не совпадает. Для системы это выглядит так, будто кто-то другой пытается пройти по чужому билету. Контролер видит ошибку: Билет уже использован.

И тут у нас тупик. Контролер не может помнить лицо каждого из сотни человек в очереди (если он, конечно, не Хеймдалль, который видит и помнит каждую душу в девяти мирах). Он не знает, тот ли это парень, который отходил, или это уже халявщик со скриншотом того же билета.

Мы упираемся в Head-of-Line Blocking. Чтобы система была на 100% надежной, контролеру нельзя разрешать переходить к следующему билету, пока он не получит окончательный ответ от сервера по текущему. Именно поэтому Google придумал QUIC ! (нет)

Компромисс: Время последнего сканирования

Можно сделать умный костыль. Если билет уже использован, бекенд возвращает время последнего успешного сканирования. Например: Ошибка: билет использован 2 минуты назад

Идея в том, что контролер посмотрит на это время и подумает: Ага, минуту назад это был он, я его помню, пусть проходит.

По сути, мы открываем дорогу всем желающим пройти по одному билету в течение этих двух минут. Если у группы людей есть один и тот же QR-код (скриншот), они могут попытаться пройти по нему друг за другом. Мы переложили техническую проблему на человеческий фактор, который в условиях стресса и очереди работает плохо.

Итог

Вероятно, решение лежит уже не в рамках статичного QR-кода, а в изменении протокола взаимодействия - например, через динамические токены или challenge-response схемы с одноразовыми подтверждениями. Я это направление детально не прорабатывал, но выглядит так, что без этого ограничения не обойтись

Если у кого-то есть идеи получше или я намудрил - пишите, будет интересно почитать. А я пойду спать - на часах 4:25 ночи ..