Skip to main content
Назад к блогу
6 мин чтения

QR check-in на масштабе: 12K+ сканирований без единого сбоя

Как я построил систему QR check-in, которая обслуживает площадки на 3,000 мест с офлайн-поддержкой, JWT-подписью билетов и временем сканирования до 200мс. Архитектура, код и продакшн-числа.

performancesecurityreact-native

На концерте на 3,000 мест у тебя есть примерно 30 минут между открытием дверей и первым актом. Это 100 сканирований в минуту при одном сканере. Не уложишься — получишь толпу у входа, злых промоутеров и проблему с безопасностью.

Я построил систему QR check-in для E7 Platform — тикетинг-платформы, через которую прошло 12,400+ билетов на десятках мероприятий. Система работает на телефонах персонала, функционирует офлайн и валидирует скан за менее чем 200мс. Вот архитектура.

Ограничения

  1. Скорость: менее 200мс от скана до зелёного/красного экрана. Персонал не может ждать.
  2. Офлайн: На площадках ужасный WiFi. Сканер должен работать без связи.
  3. Обнаружение дубликатов: Если билет сканируют дважды, второй скан должен показать предупреждение — даже на разных сканерах.
  4. Нет перепродажи: QR-код по возвращённому или переданному билету должен быть отклонён мгновенно.
  5. Простота для персонала: Сканер работает на любом телефоне с камерой. Никакого спецоборудования.

Паттерн 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 через минуту.

Разрешение:

  1. Чекин сканера B попадает на сервер первым (он онлайн). Сервер записывает его.
  2. Сканер A возвращается онлайн, проигрывает очередь. Сервер видит, что билет №42 уже зачекинен.
  3. Сервер отвечает { duplicate: true, scannerId: 'B', timestamp: ... }.
  4. Локальный манифест сканера 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+ часа на полный чекин.

Что бы я изменил

  1. Дельта-синхронизация манифеста: Сейчас сканер тянет полный манифест при каждой синхронизации. Для мероприятий свыше 5K мест дельта-синхронизация (только изменённые билеты с последнего синка) сократила бы трафик. Пока не понадобилось — 150КБ полных синков хватает на текущем масштабе.

  2. WebSocket для синхронизации сканеров: Сейчас используется Redis pub/sub → server-sent events на сканеры. WebSocket-соединение дало бы настоящую двунаправленную связь и меньшую задержку для кросс-сканерных обновлений.

  3. Цепочка трансферов: Сейчас трансферы создают новый ticket ID. Цепочка (оригинал → трансфер → трансфер) дала бы лучший аудит для промоутеров.

Вывод

Основной паттерн прост: подписываем при покупке, верифицируем локально, синхронизируем в фоне. JWT-подписи устраняют бутылочное горлышко базы данных при скане. Офлайн-first дизайн означает, что WiFi на площадке не важен. Redis pub/sub решает кросс-сканерную консистентность, которую офлайн-режим в одиночку не обеспечит.

Сложной была не отдельная часть — а заставить их работать вместе надёжно при 100+ сканах в минуту с персоналом, которому дали 5 минут обучения.


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