はじめに
サーバーサイドエンジニアのkurisu(ryomak)です。
Webアプリケーションでは、複数のリクエストにより、データの不整合が起こらないように、排他制御によって同時アクセスを防ぎ、システム全体の整合性を保つことが求められます。排他制御において、カード決済システムとしても守らなければならない厳しい制約*1がある場合、一般的な手法が必ずしも最適解とはならないことがあります。本記事では、バーチャルカード発行の具体的な事例を通して、排他制御を実現するために検討したアプローチについてご紹介します。
この記事に書かれていること
リソースが存在しない場合の排他制御
ロック対象のリソースが存在しない時にでも対応できる排他制御のアプローチについての検討
ユーザ体験とのバランス
ロック制御だけではなく、ロジックのシンプルさとユーザ体験を両立するアプローチについての検討
背景
バーチャルカードとは
物理カードなしで、オンラインでのECサイトなどで支払いができるカードです。オンライン決済をすぐに利用したい方や紛失時の不便を解消するため導入しました。 smartbank.co.jp
システムアーキテクチャ
まずは前提となるカード発行におけるシステムアーキテクチャです。B/43におけるカード決済システムではどのような制約があるか紹介します。特に重要な箇所のみピックアップしてますが、全体像が気になる方はこちらをご参照ください。
graph LR subgraph Client[クライアント] App[B/43アプリ] end subgraph AWS[AWS環境] subgraph PCI_DSS[PCI DSS準拠環境] IssuingApi[issuing-api<br/>オーソリ受信やカード発行処理を行う。カード番号などセキュアなデータを管理] end subgraph NonPCI_DSS[PCI DSS非準拠環境] CoreApi[core-api<br/>B/43ほぼ全てのロジックを提供する] end end App --> IssuingApi App --> CoreApi IssuingApi --> CoreApi %% サブグラフの色指定 style PCI_DSS fill:#F6F8FC,stroke:#333,stroke-width:1px,color:#333 style NonPCI_DSS fill:#FFF5F0,stroke:#333,stroke-width:1px,color:#333 %% 個別ノードの色指定(必要に応じて追加) style IssuingApi fill:#CCE5FF,stroke:#333,stroke-width:1px,color:#333 style CoreApi fill:#FFE6E6,stroke:#333,stroke-width:1px,color:#333 style App fill:#DFFFE2,stroke:#333,stroke-width:1px,color:#333
core-apiがほぼすべてのロジックを持っており、issuing-apiでは、オーソリ受信や3Dセキュア、カード情報の管理をしています。今回はこのカード発行処理にフォーカスします。
PCI DSS要件による制約
issuing-apiはカード情報を保持しているため、PCI DSS準拠の要件によって下のような制約が発生します。
- core-api(非準拠)からissuing-api(準拠)へリクエストができない
- PCI DSS非準拠環境から、準拠環境へのリクエストは禁止されている
- ユーザのデータやカード状態などのロジックの大部分はcore-apiに存在
- カードの発行状態の確認はcore-apiで行う必要がある
- issuing-apiではユーザの情報がデータとしては存在しない。ヘッダーにある認証情報からユーザIDは取得可能
PCI DSSについては、以下をご覧ください。
PCI DSS v4.0準拠までの道のりとこれからについて - inSmartBank
バーチャルカード発行における課題と方針
バーチャルカード発行フロー
バーチャルカード発行フローは、簡易的に表すと以下のようになっています。issuing-apiの視点にたつと以下のような流れでカード発行を行います。
- core-apiに対して、ユーザのカード発行状態を確認する
- カード発行状態が問題なければ、外部サービスにカード発行リクエストを投げる
- 外部サービスから返却されたカード情報を、DBに保存する
- 再度core-apiに対して、カード発行リクエストを投げて、ユーザに対してカードを紐づける
sequenceDiagram participant C as クライアント participant IA as issuing-api participant DB as issuing-db participant ES as 外部サービス participant CA as core-api C->>IA: カード発行リクエスト送信 IA->>CA: カード発行状態の取得 CA-->>IA: 結果 IA->>ES: 外部サービスへカード発行リクエスト ES-->>IA: カード情報 IA->>DB: カードデータの保存 IA->>CA: カード発行リクエスト CA-->>IA: レスポンス(ユーザが使える状態になる) IA-->>C: レスポンス(カード発行完了)
カード発行における課題
バーチャルカードは再発行が可能になっており、検討した異常系の中で、以下ケースに対して2つの課題が存在しました。
(ケース)
- 意図しないバグが発生した時、2重サブミットや複数デバイスからリクエストされる
(課題)
- 意図せずにカードが再発行されてしまう可能性がある
- また、それによって、外部サービスへのカード発行リクエストも複数回行うため、利用されないカード番号が無駄に発行されてしまう
対応方針
上記課題から、同じユーザに対して同時にカード発行リクエストが処理されることを防ぎ、重複発行を防止する必要がありました。そのため、issuing-apiでは「ユーザー単位でn秒間に1回の発行制限」を導入することにしました。
前提:データベース環境
弊社で利用しているデータベース環境は以下になります。
- データベース: Aurora MySQL 3.04.3 (MySQL 8.0.28 互換)
- トランザクション分離レベル: Repeatable Read
実装方針
「ユーザー単位でn秒間に1回の発行制限」を実現するために、ユーザテーブルに対して、SELECT FOR UPDATE
で排他ロックを取ってから、発行制限チェック、発行処理を行うのが良くある手段かと思います。issuing-apiには、ヘッダーに付与されている認証情報からユーザIDの抽出は可能ですが、ユーザテーブルが存在せず、ロック対象のレコードがありません。そのため、代替案として以下のアプローチについて検討を行いました。
検討したアプローチ)
- トランザクションを利用した実装
- 組み込み関数GET_LOCKの利用
- 操作回数チェック
- その他
採用したアプローチ
操作回数チェック
複数のアプローチを検討した結果、「操作回数チェック」を採用しました。
概要
ユーザーの操作毎にをレコードを記録し、一定期間内の操作回数を制限する方法です。ロックは取らずに、レコードに記録してから、n秒以内のレコード数をcountチェックします。正確な操作回数チェックを行うために、Sliding window logというアルゴリズムを検討しました。指定の期間での操作回数をチェックする方針です。ユーザIDはヘッダーの認証情報から抽出したものを利用します。
参考) 様々なrate limitアルゴリズム - Carpe Diem
テーブル設計
CREATE TABLE カード発行制限 ( id BIGINT PRIMARY KEY AUTO_INCREMENT, ユーザーID VARCHAR(255) NOT NULL, 要求日時 TIMESTAMP NOT NULL, INDEX idx_user_time (ユーザーID, 要求日時) );
シーケンス図
sequenceDiagram participant C as クライアント participant A as API participant DB as データベース participant SS as 外部サービス C->>A: 発行リクエスト A->>DB: 操作履歴記録 A->>DB: n秒以内の操作回数取得 alt 2レコード以上存在する場合 A-->>C: エラー返却 else 1レコードのみの場合 A->>SS: カード発行処理 end
2重サブミットされた時の挙動例
- 操作履歴取得時に、どちらも操作履歴数が2になるのでどちらもエラーになります。
- 処理時間等でCOMMITのタイミングが変わった場合でも、どちらか一方のリクエストは操作履歴数が2になりエラーになります。
sequenceDiagram participant t1 as リクエスト1 participant db as Database participant t2 as リクエスト2 t1 ->> db : INSERT(操作履歴) t2 ->> db : INSERT(操作履歴) t1 ->> db : COMMIT; t2 ->> db : COMMIT; t1 ->> db : SELECT COUNT(*) FROM 操作履歴 WHERE 期間内 t2 ->> db : SELECT COUNT(*) FROM 操作履歴 WHERE 期間内 t1 ->> t1 : n秒以内の操作数チェック t2 ->> t2 : n秒以内の操作数チェック note over t1: エラー note over t2: エラー
クエリ例
-- 1. 操作履歴の記録 INSERT INTO カード発行制限 (ユーザーID, 要求日時) VALUES ('xxx', NOW()); -- 2. 期間内の操作回数取得 SELECT COUNT(*) FROM ( SELECT 1 FROM カード発行制限 WHERE ユーザーID = 'xxx' AND 要求日時 >= NOW() - INTERVAL n SECOND LIMIT 2 ) AS t;
このアプローチを採用した際に考えていたメリデメは以下です。
メリット
- シンプルな実装
- ロック機構を使用しない処理フロー
- 他のアプローチで必要となるロック管理や状態管理が不要
- ユーザの画面の状態と実際のカード状態の整合性が一致しやすい
- 2重サブミットが発生し、並列で来たリクエストのインサートがほぼ同タイミングの場合、どちらもエラーになるため、アプリケーション表示と実際のカード発行状態がどちらも未発行になるので整合性が保たれる
- 運用面での利点
- 障害時の状態確認が容易
- ロックのハンドリングが不要
デメリット
- n秒以内に連続してリクエストが送られると、すべてエラーとなる
- 通常の利用シーンでは連続リクエストが想定されないため、大きな問題にはならない
- 厳密なロック制御を採用していない
- リクエストが並列に送信された場合、処理タイミングにより挙動が多少変動する
- 基本的には最初のリクエストのみが許容され、2件目以降はエラーとして防止される
- インサートがほぼ同時だった場合、どちらもエラーになるが、ユーザーの画面上の状態と実際のカード発行状態が一致するメリットにも繋がる
採用を見送ったアプローチ
トランザクションを利用した実装
概要
SELECT FOR UPDATE
をそのまま利用せずに、まずは、SELECTでカード発行データを取得し、存在している場合は、FOR UPDATE
による排他制御、存在しない場合は、INSERT
によって直列になるように担保しながら、n秒以内のカード発行がないかをチェックする方法です。ユーザIDはヘッダーの認証情報から抽出したものを利用します。
テーブル設計
CREATE TABLE 最新のカード発行 ( id BIGINT PRIMARY KEY AUTO_INCREMENT, ユーザーID VARCHAR(255) NOT NULL, 最新リクエスト日時 TIMESTAMP, UNIQUE KEY uk_user (ユーザーID) );
シーケンス図
sequenceDiagram participant C as mobile-bff participant A as issuing-api participant DB as issuing-db participant SS as 外部サービス C->>A: 発行リクエスト A->>DB: BEGIN; A->>DB: 最新のカード発行データ取得 alt レコードが存在する場合 A->>DB: 最新のカード発行データ取得(FOR UPDATE) alt 最新リクエスト日時がn秒以内 A-->>C: エラー end A->>DB: UPDATE(最新リクエスト日時) else レコードが存在しない場合 A->>DB: INSERT(カード発行) end A->>DB: Commit; A->>SS: カード発行処理 A-->>C: レスポンス
2重サブミットされた時の挙動例
- 初回発行のリクエストが2重サブミットされた時
- リクエスト1は成功して、リクエスト2はINSERTでエラーが生じてロールバックされる
sequenceDiagram participant t1 as リクエスト1 participant db as Database participant t2 as リクエスト2 t1 ->> db : BEGIN t2 ->> db : BEGIN t1 ->> db : 最新のカード発行データ取得 t1 ->> db: INSERT(最新のカード発行) t2 ->> db : 最新のカード発行データ取得 t2 ->> db : INSERT(最新のカード発行) db -->> t2 : DuplicateEntry t1 ->> db : COMMIT t2 -->> db : ROLLBACK note over t2: エラー note over t1: カード発行処理実行
- 再発行のリクエストが2重サブミットされた時
- SELECT FOR UPDATEによるロックにより、排他制御が実現できる
sequenceDiagram participant t1 as リクエスト1 participant db as Database participant t2 as リクエスト2 t1 ->> db : BEGIN t2 ->> db : BEGIN t1 ->> db : 最新のカード発行データ取得 t1 ->> db: 最新のカード発行データ取得 FOR UPDATE t2 ->> db : 最新のカード発行データ取得 FOR UPDATE t1 ->> t1 : n秒以内の操作チェック t1 ->> db : 最新のカード発行更新 t1 ->> db : COMMIT note over t1: カード発行処理実行 db -->> t2 : 結果 t2 ->> t2 : n秒以内の操作チェック t2 ->> db : COMMIT note over t2: エラー
クエリ例
-- トランザクション開始 BEGIN; -- 最新のカード発行データを取得 SELECT * FROM 最新のカード発行 WHERE ユーザーID = 'xxx' -- 存在する場合、for updateでロックをかける SELECT * FROM 最新のカード発行 WHERE ユーザーID = 'xxx' FOR UPDATE; -- 存在しない場合、インサート INSERT INTO 最新のカード発行 (ユーザーID,最終リクエスト日時) VALUES ('user_xxxx','{now}') -- n秒以内の場合はエラー -- n秒以上経過している場合は更新 UPDATE 最新のカード発行 SET 最新リクエスト日時 = NOW() WHERE ユーザーID = 'xxx'; COMMIT;
採用を見送った理由
新規作成の場合ロック対象となるリソースが存在せず、親であるユーザも存在しないため、分岐をする必要があり、冗長な実装が必要になります。
最初から SELECT FOR UPDATE
しなかったのは、対象レコードが存在しない場合、デッドロックになる可能性が存在するからです。以下の記事が参考になりますのでご覧ください。
組み込み関数GET_LOCKの利用
MySQL組み込みのアドバイザリーロック関数の、GET_LOCK
を利用したアプローチです。
概要
処理の最初にGET_LOCK
で、ユーザ毎の処理をロックした後、n秒以内のカード発行がないかをチェックする方法です。GET_LOCK
は、対象リソースが存在しない場合でも、ロックが取れるため、トランザクションでのアプローチに比べて、分岐がシンプルになります。
テーブル設計
CREATE TABLE 操作履歴 ( id BIGINT PRIMARY KEY AUTO_INCREMENT, ユーザーID VARCHAR(255) NOT NULL, リクエスト日時 TIMESTAMP NOT NULL, INDEX idx_user_time (ユーザーID, リクエスト日時) );
シーケンス図
以下のようなフローになります
sequenceDiagram participant C as mobile-bff participant A as issuing-api participant DB as issuing-db participant SS as 外部サービス C->>A: 発行リクエスト A->>DB: GET_LOCK取得 A->>DB: 期間内の操作履歴取得 alt 期間内に操作履歴が存在する場合 A->>DB: RELEASE_LOCK A-->>C: エラー返却 else 期間内に操作履歴が存在しない場合 A->>SS: カード発行処理 A->>DB: INSERT(操作履歴) A->>DB: RELEASE_LOCK end A-->>C: レスポンス
2重サブミットされた時の挙動例
- リクエストが2重サブミットされた時、ロックが解放されるまでリクエスト2はブロックされます。
sequenceDiagram participant t1 as リクエスト1 participant db as Database participant t2 as リクエスト2 t1 ->> db : GET_LOCK('user_id',n) db -->> t1 : ロック取得 t2 ->> db : GET_LOCK('user_id',n)<br>ブロックされる t1 ->> db : 期間内の操作履歴取得 t1 ->> t1 : n秒以内の操作チェック t1 ->> db: INSERT(操作履歴) note over t1: カード発行処理実行 t1 ->> db : RELEASE_LOCK('user_id') db -->> t2 : ロック取得 t2 ->> db : 期間内の操作履歴取得 t2 ->> t2 : n秒以内の操作チェック t2 ->> db : RELEASE_LOCK('user_id') note over t2: エラー
クエリ例
-- ユーザー単位のロックを取得 SELECT GET_LOCK('カード発行_ユーザー_{ユーザID}', 10); -- 期間内の操作履歴を取得 SELECT COUNT(*) FROM 操作履歴 WHERE ユーザーID = 'xxx' AND リクエスト日時 >= NOW() - INTERVAL n SECOND; -- 操作履歴の記録 INSERT INTO 操作履歴 (ユーザーID, リクエスト日時)VALUES ('xxx', NOW()); -- 処理完了後のロック解放 SELECT RELEASE_LOCK('カード発行_ユーザー_{ユーザID}');
採用を見送った理由
リソースがなくても、シンプルにロックできるのはメリットだと考えました。一方で、実装側でのロックの解放漏れを防ぐ必要があったり、エラー等でコネクション切断される等のイレギュラーケースにも対応する必要があります。エラーハンドリングの複雑さが増すことを懸念し、採用を見送りました。
その他
DB以外にも、Redisを使用した排他制御も検討しました。Redisは分散システムにおける排他制御の実装に広く利用されており、高速な処理と柔軟なロック機構を提供します。
参考) Redisを使った分散ロック (SETNX, Redlock) - Carpe Diem
採用を見送った理由
issuing-apiにRedisを新たに導入するためには、システムコンポーネントの追加が別途必要となります。また、PCI DSS準拠環境での構築になるため、セキュリティ診断としての対象項目が増えることになります。プロジェクトのスケジュールとコストの制約上、現実的な選択肢とはなりませんでした。
検討一覧
それぞれのアプローチをまとめると以下になります。
アプローチ | メリット | デメリット |
---|---|---|
操作回数チェック | ・ロック機構を使わずシンプルなフローで実装可能 ・ユーザーUIと実際のカード状態の整合性を維持しやすい |
・定期的にリクエストが来るとエラーとして判断し続ける ・厳格にロックしていないので、並列に来たリクエストのタイミングによって挙動が異なる |
トランザクションを利用した実装 | ・DBのネイティブなトランザクション機能を活用できる | ・スナップショット問題や並列性の低下が発生する可能性 |
組み込み関数GET_LOCKの利用 | ・シンプルにロック取得が可能(対象が存在しなくてもロックできる) | ・ロックの解放漏れやエラーハンドリングが複雑になる可能性 |
その他: Redisによるチェック | ・高速かつ柔軟な分散ロック機構 ・複数ノード間での整合性が取れる |
・新規システム導入時の構築工数増加 |
まとめ
今回の実装では、分散ロックやトランザクション、GET_LOCKといった各手法を検討した結果、ロックを取らない「操作回数チェック」を採用しました。当初は漏れのないロック制御を目指して検討を重ねましたが、結果として選んだのはシンプルなアプローチでした。意図しない多重発行を防ぐと同時に、システムの複雑性を抑えた選択ができたと考えております。
スマートバンクではサーバーサイドエンジニアを募集しております!気になる方は連絡お待ちしております!!
*1:PCI DSSのこと