見出し画像

スタートアップによる電子チケット(抽選・先着)システム新規開発の舞台裏

こんにちは!少しだけご無沙汰です、ユートニックの衛藤です!
今回は、2024年に本格的にローンチされた、電子チケット(抽選・先着)システムの話について書きたいと思います!久々に、技術的な内容です。


電子チケット販売は大きく分けて2種類に分かれます。

  • 抽選販売

    • 申し込みユーザーからランダムに当選者を確定させる

  • 先着販売

    • 早い者勝ちで売り切れたら終了

これら2つについて、概要踏まえて記載をしていきます。

抽選システムの実現

構成図概要

抽選システムの構成概要はこのようなイメージです。

全体構成概要

利用技術の簡易解説

この構成で使っている技術の概要は以下の通りです。

  • Cron Schedule Pub/Sub

  • 当選開始用の Cloud Functions

    • Pub/Subのスケジューラーから起動されるCloud Functionsです。

  • ユーザー個別処理用の Cloud Tasks

    • Cloud Functionsでは、当選ユーザーそれぞれに対し、Cloud TaskのQueueにTaskが追加され、定期的なレートでTaskがDispatchされていきます。レートは使用している外部サービスのレート制限に合わせて設定します。

  • 個別当選処理用の Cloud Functions (https webhook)

    • 当選ユーザーそれぞれで実行されるCloud Functionsです。ここで決済処理やチケットの確保が行われます。https関数で、Cloud Tasksから呼び出される末端のエンドポイントとなります、

抽選で抑えるべき点

電子チケットの抽選で抑える点は、ライブの規模に関わらず、必ず処理が終わるようにする、ということです。つまり、当選人数規模によって処理時間が大幅に変わってくるため、タイムアウトなどにより処理が途中で終わってしまうわけにはいきません。

Cloud TaskによるFan-out処理

例えば、Cloud Functionsですべて処理をしようとすると、イベント関数の場合は9分、HTTP関数の場合で60分でタイムアウトが発生してしまいます。

そこで、Cloud Taskと組み合わせることで、タイムアウト問題を実質クリアすることができました。Fan-outとは、1つの関数から複数の関数を並列に実行し、処理を分散させることです。

今回の例だと、1つの起動されたFunctionsでの責務はCloud Taskへのキューイングのみとなり、その後の処理はCloud Taskへ委譲することになります。

この設計により、Pub/Subから起動されたFunctionsはほぼ一瞬で処理が終了し、実質タイムアウトを気にせずに処理を進めることが可能となりました。

通知もCloud Taskにより実質タイムアウトを気にせず運用

当選者・落選者への通知も、同様にCloud Functions + Cloud Tasksの運用により、実質的に処理タイムアウトを気にすることなく運用することができています。

先着システムの実現

先着で抑えるべき点

先着購入での注意点は、在庫数&座席割当てです。ライブ公演の座席は有限です。つまり在庫が決まっており、座席は必ず一人のユーザーとしか紐付きません。

万が一、在庫より多く販売してしまった、同じ座席に二人のユーザーが割り当てられている、などということが発生してしまうと非常事態となってしまうため、慎重に実装を行う必要があります。特にライブの場合、当日にこのような自体が発生すると現場が混乱してしまい、失敗に終わってしまいます。

トランザクションの出番!Firestoreによる制御

Firestoreはトランザクションをサポートしています。

Cloud Firestore は、データを読み書きするアトミック オペレーションをサポートしています。一連のアトミック オペレーションでは、すべてのオペレーションが正常に完了するか、またはどのオペレーションも適用されないかのいずれかです。

https://firebase.google.com/docs/firestore/manage-data/transactions?hl=ja

以下引用の通り、Firestoreは同時編集でトランザクションを自動的に再試行します。そのため、トランザクション関数内で在庫数を常にチェックするようにすることで在庫数以上に売れてしまう自体を回避することが可能です。

同時編集の場合、Cloud Firestore はトランザクション全体を再実行します。たとえば、トランザクションがドキュメントを読み取り、別のクライアントがそれらのドキュメントを変更すると、Cloud Firestore はトランザクションを再試行します。この機能により、常に整合性のある最新データに対してトランザクションが実行されます。

https://firebase.google.com/docs/firestore/manage-data/transactions?hl=ja#node.js

以下、簡単なトランザクションサンプル実装です。

try {
  // トランザクション内に必要な処理をすべて各
  await db.runTransaction(async (t) => {
    // t.get() によりドキュメントはトランザクション中はロックされる
    // ロック中に他ユーザーによるupdateなどが入るとトランザクション自体が自動的に一定数再試行される
    const doc = await t.get(ticketDocRef)
    const purchasedCount = doc.data().purchasedCount + 1

    if (doc.data().stock < purchasedCount) {
      // 同時的に他ユーザーによる購入が発生し、結果在庫不足になる場合はエラー
      throw new Error('売り切れました')
    }
    
    // トランザクションで販売数をアップデート
    t.update(ticketRef, { purchasedCount })
  });

  console.log('Transaction success!');
} catch (e) {
  console.log('Transaction failure:', e);
}

座席割当も同様に、必ずチケットと1対1で紐づくようにする必要があります。このケースも在庫数と同様にFirestoreのトランザクションを利用することで、実現することができます。

先着販売開始時のアクセス集中

他にも、先着販売開始と同時に一気にアクセスが来ることによるスケール問題もありますが、CloudRun + Firestoreによる運用のため、ある程度はGoogle Cloud側のオートスケーリングに身を任せることが出来ました。

ただし、まだ出来ることはありそうで、例えば以下のようなトラフィックを徐々に増やすことで最適にスケールさせることも可能になるかもしれません。

Cloud Firestore がトラフィックの増加に合わせてドキュメントを準備できるように、新しいコレクションまたは辞書順で近いドキュメントに対するトラフィックを徐々に増やしていく必要があります。新しいコレクションに対するオペレーションは、毎秒 500 回を上限とし、その後、5 分ごとにトラフィックを 50% 増やしていくことをおすすめします。書き込みトラフィックも同様に増やすことができますが、Cloud Firestore の標準アカウントには上限があります。オペレーションがキー範囲全体に比較的均等に分散するよう注意してください。これは「500/50/5」ルールといいます。

https://firebase.google.com/docs/firestore/best-practices?hl=ja#ramping_up_traffic

まとめ

以上、チケット抽選・先着システムの概要をお話しました。
スタートアップはリソース・時間が常に不足しており、どれだけ効率的に信頼性のあるシステムを開発出来るかが重要になってきます。

クラウドネイティブなこの時代、さまざまなクラウドプロダクトを組み合わせることで、最小限のリソースである程度の規模のシステムを構築するということが可能です。

ユートニックでは、Google Cloudをメインで使っていますが、今後も各プロダクトの特性をしっかりと理解した上で、モダンで最適な設計を意識して開発していければと思っています。

一緒に働く仲間を募集中

最後に、ユートニックでは現在エンジニア積極採用中です!
特にフロントエンドエンジニアでスタートアップに興味がありましたらお気軽にお問い合わせください!(カジュアル面談も大歓迎です)

以下リンクまた、僕のXのDMでも結構ですのでぜひご連絡ください!


この記事が気に入ったらサポートをしてみませんか?