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 хвилин навчання.


Будуєш систему заходів або тикетинг, якому потрібен чекін на реальному масштабі? Напиши — я відвантажив саме цю архітектуру і можу зекономити тобі місяці крайніх випадків.