На концерте на 3,000 мест у тебя есть примерно 30 минут между открытием дверей и первым актом. Это 100 сканирований в минуту при одном сканере. Не уложишься — получишь толпу у входа, злых промоутеров и проблему с безопасностью.
Я построил систему QR check-in для E7 Platform — тикетинг-платформы, через которую прошло 12,400+ билетов на десятках мероприятий. Система работает на телефонах персонала, функционирует офлайн и валидирует скан за менее чем 200мс. Вот архитектура.
Ограничения
- Скорость: менее 200мс от скана до зелёного/красного экрана. Персонал не может ждать.
- Офлайн: На площадках ужасный WiFi. Сканер должен работать без связи.
- Обнаружение дубликатов: Если билет сканируют дважды, второй скан должен показать предупреждение — даже на разных сканерах.
- Нет перепродажи: QR-код по возвращённому или переданному билету должен быть отклонён мгновенно.
- Простота для персонала: Сканер работает на любом телефоне с камерой. Никакого спецоборудования.
Паттерн 1: JWT-подписанные QR-коды (без обращения к БД при скане)
Ключевая идея: перенести обращение к базе данных на момент покупки, а не на момент скана.
Когда Stripe подтверждает оплату через вебхук, сервер генерирует QR-код с подписанным JWT:
import { SignJWT } from 'jose'
async function generateTicketQR(ticket: {
id: string
eventId: string
seat: string
}) {
const token = await new SignJWT({
tid: ticket.id,
eid: ticket.eventId,
seat: ticket.seat,
})
.setProtectedHeader({ alg: 'ES256' })
.setIssuedAt()
.setExpirationTime('30d')
.sign(privateKey)
return generateQRCode(token)
}
Почему ES256 (ECDSA), а не HS256 (HMAC):
- Приватный ключ остаётся на сервере (подписывает билеты)
- Публичный ключ отправляется на сканеры (верифицирует билеты)
- Если устройство-сканер потеряно или скомпрометировано, атакующий может проверять, но не подделывать билеты
QR кодирует ~180 байт. Камера любого телефона считывает за один кадр.
Паттерн 2: Офлайн-сканер
Сканер — это экран React Native за авторизацией персонала. При запуске он загружает полный манифест билетов мероприятия:
async function syncManifest(eventId: string) {
const manifest = await api.get(`/events/${eventId}/manifest`)
// Сохраняем локально: ticket ID → статус
await AsyncStorage.setItem(`manifest:${eventId}`, JSON.stringify(manifest))
return manifest
}
Манифест — это карта ticketId → { status, seat, checkedInAt, scannerId }. Для площадки на 3,000 мест это ~150КБ — синхронизируется менее чем за секунду даже на 3G.
Процесс скана (работает офлайн):
async function handleScan(qrData: string) {
// 1. Верификация JWT подписи (локально, без сети)
const payload = await verifyJWT(qrData, publicKey)
if (!payload) return { result: 'invalid' }
// 2. Проверка локального манифеста
const manifest = await getLocalManifest(payload.eid)
const ticket = manifest[payload.tid]
if (!ticket) return { result: 'invalid' }
if (ticket.status === 'refunded') return { result: 'invalid' }
if (ticket.checkedInAt) {
return {
result: 'duplicate',
checkedInAt: ticket.checkedInAt,
scannerId: ticket.scannerId,
}
}
// 3. Отмечаем как зарегистрированного локально
ticket.checkedInAt = Date.now()
ticket.scannerId = currentScannerId
await updateLocalManifest(payload.eid, manifest)
// 4. Синхронизация с сервером (fire-and-forget, ретраи в фоне)
syncQueue.push({ ticketId: payload.tid, checkedInAt: ticket.checkedInAt })
return { result: 'valid', seat: payload.seat }
}
Шаги 1–3 полностью локальные. Шаг 4 синхронизируется при наличии связи. Подробнее об офлайн-паттернах включая разрешение конфликтов.
Паттерн 3: Кросс-сканерная синхронизация через Redis
Когда связь есть, чекины синхронизируются через Redis pub/sub:
// Сервер: получает чекин от сканера A
async function recordCheckIn(ticketId: string, scannerId: string) {
const key = `checkin:${ticketId}`
// SET NX — успешно только если ещё не зачекинен
const set = await redis.set(key, scannerId, { NX: true, EX: 86400 })
if (!set) {
// Уже зачекинен другим сканером
const existingScanner = await redis.get(key)
return { duplicate: true, scannerId: existingScanner }
}
// Рассылка всем сканерам этого мероприятия
await redis.publish(
`event:${eventId}:checkins`,
JSON.stringify({
ticketId,
scannerId,
timestamp: Date.now(),
})
)
// Запись в Postgres (асинхронно, не блокирует ответ скана)
await db.ticket.update({
where: { id: ticketId },
data: { checkedInAt: new Date(), scannerId },
})
return { duplicate: false }
}
SET NX атомарен — два сканера, сканирующие один билет в одну миллисекунду, всё равно получат корректный результат. Первый побеждает, второй получает предупреждение о дубликате.
Каждый сканер подписан на Redis-канал и обновляет локальный манифест в реальном времени. Когда сканер возвращается онлайн после офлайн-периода, он проигрывает очередь и подтягивает свежий манифест.
Паттерн 4: Что с конфликтами?
Интересный edge case: сканер A чекинит билет №42 офлайн. Сканер B (онлайн) тоже сканирует билет №42 через минуту.
Разрешение:
- Чекин сканера B попадает на сервер первым (он онлайн). Сервер записывает его.
- Сканер A возвращается онлайн, проигрывает очередь. Сервер видит, что билет №42 уже зачекинен.
- Сервер отвечает
{ duplicate: true, scannerId: 'B', timestamp: ... }. - Локальный манифест сканера A обновляется. Ни потери данных, ни двойной записи.
Правило простое: серверный таймстамп побеждает, приоритет у первого. Конфликты редки на практике — за 6 месяцев продакшна у нас было 23 конфликтных события на 12,400+ билетов. Все разрешились автоматически.
Паттерн 5: Возвраты и трансферы
Билет, возвращённый после синхронизации манифеста, должен быть отклонён при скане. Это проходит через тот же Redis pub/sub:
// При обработке возврата
async function handleRefund(ticketId: string, eventId: string) {
await redis.publish(
`event:${eventId}:checkins`,
JSON.stringify({
ticketId,
status: 'refunded',
timestamp: Date.now(),
})
)
}
Онлайн-сканеры получают обновление мгновенно. Офлайн-сканеры — при следующей синхронизации. Окно уязвимости (офлайн-сканер не знает о возврате) смягчается серверной сверкой — офлайн-чекин проигрывается, сервер отклоняет его, сканер обновляется.
На практике возвраты во время мероприятия редки. За 6 месяцев: 3 случая. Все пойманы при синхронизации.
UX, который важен
Экран сканера имеет ровно три состояния:
- Зелёный + галочка: Валидный. Показывает номер места. Звуковой сигнал.
- Жёлтый + предупреждение: Уже сканирован. Показывает когда и каким сканером. Другой тон.
- Красный + крестик: Невалидный или возвращённый. Громкий отличающийся тон.
Обучение персонала заняло 5 минут. Комбинация цвет + звук означает, что им даже не нужно читать экран в тёмном зале.
Продакшн-числа
После 6 месяцев в продакшне на десятках мероприятий:
| Метрика | Значение |
|---|---|
| Обработано билетов | 12,400+ |
| Среднее время скан→результат | 120мс |
| P95 время скан→результат | 180мс |
| Аптайм | 99.95% |
| Инцидентов перепродажи | 0 |
| Конфликтов дубликатов | 23 (авторазрешены) |
| Пропускная способность | ~500 человек/час/сканер |
| Крупнейшее мероприятие | 3,000 мест, 4 сканера, 25 мин полный чекин |
Старый процесс по бумажным спискам на той же площадке: ~120 человек/час, 2+ часа на полный чекин.
Что бы я изменил
-
Дельта-синхронизация манифеста: Сейчас сканер тянет полный манифест при каждой синхронизации. Для мероприятий свыше 5K мест дельта-синхронизация (только изменённые билеты с последнего синка) сократила бы трафик. Пока не понадобилось — 150КБ полных синков хватает на текущем масштабе.
-
WebSocket для синхронизации сканеров: Сейчас используется Redis pub/sub → server-sent events на сканеры. WebSocket-соединение дало бы настоящую двунаправленную связь и меньшую задержку для кросс-сканерных обновлений.
-
Цепочка трансферов: Сейчас трансферы создают новый ticket ID. Цепочка (оригинал → трансфер → трансфер) дала бы лучший аудит для промоутеров.
Вывод
Основной паттерн прост: подписываем при покупке, верифицируем локально, синхронизируем в фоне. JWT-подписи устраняют бутылочное горлышко базы данных при скане. Офлайн-first дизайн означает, что WiFi на площадке не важен. Redis pub/sub решает кросс-сканерную консистентность, которую офлайн-режим в одиночку не обеспечит.
Сложной была не отдельная часть — а заставить их работать вместе надёжно при 100+ сканах в минуту с персоналом, которому дали 5 минут обучения.
Строишь систему мероприятий или тикетинг, которому нужен чекин на реальном масштабе? Напиши — я отгрузил именно эту архитектуру и могу сэкономить тебе месяцы крайних случаев.