Help us understand the problem. What is going on with this article?

トランザクションとロックと二重課金

この記事は、 CAMエンジニア Advent Calendar 2019 23日目の記事です。
昨日は @cotsupa さんのShell Scriptで検索コマンドを作ってみましたでした。

 はじめに

こんにちは、@takehziです。
業務で二重課金を防止するタスクをやったので、その備忘録になります。

背景としては、ユーザーが決済ボタンをポチポチっと二回押してしまった際、
決済処理が二回走ってしまうという状態だったので、その改修をしました。
(フロントの方でも制御する処理は実装されています)

トランザクションとは

トランザクションについてふわっとした理解しかなかったので、まずそこから調べて見ました。
簡単に言うと、トランザクションとは連続したDB操作(1つ以上のsql操作)をひとまとまりとして扱う作業単位のことです。

DBはsqlを実行するだけで、どこからどこまでがワンセットの処理なのかはわかりません。
なのでこちら側から(DBに対して)トランザクションを設定することで、DB側はここからここまでがワンセットなんだなと認識でき、それらを1つの処理(グループ)として扱います。

トランザクションの結果は
・コミット(commit)
・ロールバック(rollback)
の2つしかありません。

トランザクション内のすべての処理が成功したら、commitを発行して関連するすべてのテーブルへの変更を有効にしてトランザクションを終了し、

もし処理のどれかが失敗したら、rollbackを発行して関連するすべてのテーブルへの変更を無効にし(トランザクション処理に入る前の状態にして)トランザクションを終了します。

ロック

二重課金の防止策として、DB対してロック制御を行う必要がありますが、
今回は「SELECT...FOR UPDATE」を用いて、占有ロック(悲観ロック)を行うことにしました。

また、二重課金が行われている最中に別のユーザーが決済を行う可能性は十分にあるので、
行ロックがかかるようにします。

行ロックをかけるにはインデックスを貼る必要があるので(あるいはプライマリーキーを条件に入れる)、
あらかじめインデックスを貼っておく必要もあります。

試しにSELECT...FOR UPDATEを使ってみます。

mysql> select * from user;
+----+--------------+------+
| id | name         | age  |
+----+--------------+------+
|  1 | takehzi      |   25 |
|  2 | komatsunana  |   23 |
|  3 | yoshiokariho |   26 |
+----+--------------+------+
3 rows in set (0.00 sec)

このテーブルに対してSELECT...FOR UPDATEを使い、takehziのageを25から20に変更します。

A)
==================================
//トランザクション開始
mysql> begin;
Query OK, 0 rows affected (0.00 sec)

//id=1のレコードを取得する際に行ロックをかける。
mysql> select * from user where id=1 for update;
+----+---------+------+
| id | name    | age  |
+----+---------+------+
|  1 | takehzi |   25 |
+----+---------+------+
1 row in set (0.00 sec)

===================================
B)
mysql> begin;
Query OK, 0 rows affected (0.00 sec)

mysql> select * from user where id=1 for update;
//...待機中

Aで行ロックがかかっているため、Bは待機中になります。
そして、Aでレコードを更新してコミットすると、ロックが解除されBのselect文が走ります。

================================
A)
//ageを25から20に変更
mysql> update user set age="20" where id=1;
Query OK, 1 row affected (0.01 sec)
Rows matched: 1  Changed: 1  Warnings: 0

//トランザクション終了
mysql> commit;
Query OK, 0 rows affected (0.00 sec)

==============================
B)
mysql> select * from user where id=1 for update;
+----+---------+------+
| id | name    | age  |
+----+---------+------+
|  1 | takehzi |   20 |
+----+---------+------+
1 row in set (9.26 sec)

AでcommitしたタイミングでBのsqlが実行されます。
Bで取得した値ですが、25ではなく、ちゃんと20になっていますね。

このSELECT...FOR UPDATEを利用して二重課金を制御しました。

二重課金問題と対応策

前提条件
・決済が完了したらtrackingIdが発行される。
・authorization(決済を行うメソッド)の処理は結果的にtrackingIdを返す。

これらの条件を踏まえた上でsessionテーブルを設けて二重課金の制御を行うことにしました。
trackingIdがあるかどうかで制御しています。
アーキテクチャ図は以下になります。
スクリーンショット 2019-12-22 23.29.38.png

実際のコードです。

  public void tempInsert(String serviceName, AmazonPayChargeParam param){

    assert StringUtils.isNoneBlank(serviceName);
    assert StringUtils.isNoneBlank(param.getReferenceId());
    assert StringUtils.isNoneBlank(param.getCustCode());
    assert StringUtils.isNoneBlank(param.getItemId());

    try{
      AmazonPaySession session = new AmazonPaySession();
      session.setReferenceId(param.getReferenceId());
      session.setCustCode(param.getCustCode());
      session.setItemId(param.getItemId());
      session.setServiceName(serviceName);

      amazonPaySessionRepository.insert(session);
    }catch (DuplicateKeyException dke){
      //重複insertによる一位制約エラーを制御。
      auditLogHelper.loggingParam(dke.getMessage());
    }
  }

まず仮データとしてsessionテーブルにinsertを処理を行います。(この時点でtrackingIdはnull)

public String authorization(Long apiAuthId, String serviceName, AmazonPayChargeParam param) {

    ChargeRequest chargeRequest = getChargeRequest(param);
    String trackingId = chargeRequest.getChargeReferenceId();

      AuthorizationDetails authDetails = null;
      switch(chargeRequest.getType()) {

//...略

        case BILLING_AGREEMENT_ID:

          //ここで行ロックをかける.重複側はtrackingIdが取得できる。
          AmazonPaySession sessionData = amazonPaySessionRepository.tempSelectForUpdate(serviceName, param.getReferenceId(), param.getCustCode(), param.getItemId());

          //重複側はtracking_idを取得して早期リターン。
          if(Objects.nonNull(sessionData.getTrackingId())){
            return sessionData.getTrackingId();
          }

      //課金処理
          authDetails = execBillingAgreement(serviceName, param, chargeRequest, apiAuthId, authDetails, trackingId, sessionData);

//...略

      //最終的にtrackingIdを返す
      return trackingId; 
  }

ここで、trackingIdの有無を判断し、
trackingIdがあれば(重複側)そのままそのtrackingIdを早期リターンします。

「trackingIdが無い=まだ決済が行われていない」ので、そのまま決済処理が行われ、
発行されたtrackingIdをsessionテーブルに追加するようにします。

このようにして、SELECT...FOR UPDATEを用いて重複課金を制御しました。

終わりに

かなり雑に書いてしまいましたが(後回しにしすぎて時間がなかった...)、
実際はもっと考慮すべき点があります。

・トランザクションの分離レベルはどうするのか(リード現象をどこまで許容するか)
・今回はREPEATABLE-READでファントムリードを許容しています。
・ACID特性を担保できているか(独立性は作る物によって落とし所が違うかと思います)
・ちゃんと行ロックになっているか。
・デッドロックが起きないか。

などなど、他にもシステムレベルやコードレベルで見た場合の考慮すべき点はまだまだありますが、
今回の改修における認識をざっとまとめて見ました。

正直まだまだ理解が曖昧なところがあるので、引き続き学習を頑張っていきたいと思います!
また、今回こうやって記事を書こうとした際に、思ったよりも認識が甘かったということに気づいたので、
こういったアウトプットを今後もっと積極的に行っていきたいと思います!

takehzi
エンジニアの卵 文系からエンジニアになりました。
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした