概要
- 15年前ぐらいに設計、実装したシステムの設計と実装を公開します。
- 目的は、誰かに特許を取られてしまっても困るので。自分で特許を取るほどでもないので共有資産として残しておきます。特許というか、どちらかと言うと論文で発表するほうが向いていそうです。
- 航空会社のマイルで使われているポイント管理機能の実現方法です。このようなポイントシステムの要求はビジネスサイドではよくありそうですが、設計内容が公開されている事例を見つけられませんでした。
類似ポイントシステム
- JALやANAのマイルシステム
- まさにこれと同じ機能の実現方法です。
- 設計と実装が比較的難しいので、当時はこの機能を実現しているサービスは見つけられませんでした。
- 2022年の今となってはこのポイントの仕組みはメジャーになりつつあります。様々な大手ポイントサイトで使われています。設計と実装の難易度が高かかったり、ユースケースが限られていたりするので中小規模のところでは採用されていないです。
- ビックカメラやヨドバシカメラのポイントシステム
- 最終利用日から1年で有効期限が切れる。
- この仕組の実装は簡単なのであちこちで採用されています。
- 「有効期限つき」ポイントシステムをつくる
- この記事で紹介されている設計では、「ポイントの消費を取り消す」ユースケースを処理できません。
要求
アクター
- ユーザ
- 経理
- システム管理者
機能要求
- 基本機能
- ユーザに対してポイントを加算、減算できるシステム。
- 加算に対して、任意の有効期限をつける。
- 有効期限が近いポイントから消費していく。
- 運用
- 減算を取り消すときには、減算前のポイントの有効期限を復元する。
- ある時点の残高を算出できる。
- ポイントの整合性をチェックできる。
- ユーザ向け機能
- 有効期限ごとに残高を表示できる。
- 現在の残高を取得できる。
- 経理
- できるだけオンラインで処理できるようにする
- 特定時点のBS(バランスシート)の算出
- 特定時点のPL(プロフィット/ロス)の算出
- BSとPLの一致
- システム
- 長期間運用しても遅くならないクエリーで取り出せる。
- 減算、加算の整合性を検算できる。(=BSとPLを作っておいて、整合性を検証)
ユースケースの例
- 2022年6月まで有効の100ポイントを加算
- 2022年7月まで有効の100ポイントを加算
- 150ポイントを消費。(1から100ポイント、2から50ポイントを引く)
アプローチ
いくつか設計のアプローチがあるので整理します。どのような特徴に注目して設計を行うかによっていくつかのアプローチがあります。それぞれの設計とメリット、デメリットをまとめておきます。
- トランザクションモデル
- 入金と出金の処理のトリガーはPLなので、必要最低限のPLを保存する。
- 会計的アプローチ
- PLとBS (有効期限ごと)を分離して保存する。
- オブジェクト指向
- 入金、出金をオブジェクトとして考える。
- 入金、出金、1つ1つがオブジェクトとなる。
1. トランザクションモデル
方向性は、入金と出金の履歴は最低限取らなければいけないのでそれぞれの履歴をエンティティとして表現します。
Userエンティティは、ユーザ情報のマスタです。
DepositHistoryは入金の履歴を表現するエンティティです。amountにはポイントの額を入れます。used_amountはその入金に対して利用済みの金額を保存するためのカラムです。デフォルトは0になります。 expiration_dateはその入金の有効期限の日付を入力します。
WithdrawHistoryは出金の履歴を表現するエンティティです。
入金と出金を1つのエンティティで表現しても良かったのですが、used_amountとexpiration_dateの2つのアトリビュートが出金には非従属なので正規化してエンティティで分けました。
このアプローチの良い点は、テーブルが2つで済むのでシンプルです。悪い点は、出金と入金の紐付けが行えないので出金の取り消しができません。
2. 会計的アプローチ
PLとBS (有効期限ごと)を分離して保存する。ユーザや管理者が見たいのは主にPLかBLのどちらかです。なので、ユーザ側の要求に近い状態でデータ自体も保存してしまえば集計も楽というアプローチです。
良い点は集計が容易です。悪い点は、1つ目と同じように入金と出金の紐付けがないので出金の取り消しが行なえません。
3. オブジェクト指向
入金、出金を個別に扱います。入金に対しての出金を厳密に関連付けることで取り消しを行えるようにします。
データとしてはPLを持ちます。BSはこのPLのデータを使って集計して出すか、参照頻度が高いのであれば別途BS用のテーブルを用意しても良いと思います。
BS用のテーブルを用意すると、集計は行いやすいですがその反面、PLの更新があるたびにBSのテーブルの更新も行う必要が出てきます。システムのユースケース頻度やトラフィックの多さによって使い分けるのが良いと思います。
この手法の良い点は、出金の取り消しも容易に行なえます。悪い点はテーブルが3つになるのでデータの管理が煩雑です。出金時のクエリーを複数回発行する必要があり、計算量が収束しないので処理が遅くなる可能性があります。
メリット・デメリット
Matrixで整理しておきます。
アプローチ1 | アプローチ2 | アプローチ3 | |
有効残高算出 | o | o | o |
減算・加算の時系列一覧 | △ O(2n) | o | △ O(2n) |
残高の計算量 | O(n) | O(1) | O(n) |
とある時点のスナップショット計算量 | O(n) | O(n) | O(log(n)) |
BSとPLの検算 | x | o | o |
減算の取り消し | x | x | o |
時間を指定したBSの出力 | o | o | o |
期間を指定したPLの出力 | o | o | o |
採用する手法
3番を採用します。
実装
- 実装の方向性
- 各エンティティにまたがった排他制御が必要なのでRDBMSで実装するのが一般的かと思います。
- 行ロックはデッドロックを回避するために左側のエンティティから順番にかけていくように実装します。
加算
- Depositにレコードを入れるだけ。
残高の表示
- Depositから有効期限が未来のレコードを抽出し、DepoistWithdraw.amountのsum()を除算
減算
- トランザクション
- 残高をチェック。
- Depositから有効期限に近い順にソート
- 影響のあるDepositを排他ロック
- Withdrawへレコードの追加
- DepositWithdrawへレコードの追加
減算取り消し
- トランザクション
- 関連するDepositWithdrawを排他ロック
- Withdrawの削除
- 関連するDepositWithdrawの削除
一覧
加算と減算を別のテーブルで管理しているので加算と減算の一覧を時系列に出す処理が重くなってしまいます。計算量、メモリ使用量がO(n)です。
このシステムの設計における一番大きなデメリットがこの点。
- DepositとWithdrawをUNION ALLして時間でソートする。
- 上記の処理は重いのでユースケースレベルで加算と減算それぞれのページを用意する事によってパフォーマンスは担保できる。
ユーザの削除
- PLを簡単に出すために、退会したユーザに対しては減算処理をして残高を0にしておく必要がある。そうでないと、BSを出すときに退会ユーザを除外するのが難しくなる。現時点でのBSを出すのは簡単だが、過去のとある時点のBSを出す際にはクエリーが不可能ではないが困難になる。
ポイントの失効
- BSを計算する際に、expiration_dateを元に算出することになる。有効期限が切れたポイントに関連するPLを算出するのはクエリーが複雑になる。
- 考察
- PLを集計しやすいように、ポイントが失効したら減算するレコードを入れるバッチを作っても良いが、実行タイミングは失効と同時に行う必要があるので実行が困難。
検算
BSのスナップショットは持っていないので、PLの整合性を検証するのみになります。PLを複数テーブルで管理しているのでその整合性を確認します。
- ”OK” if sum(DepositWithdraw.amount) == sum(Withdraw.amount)
- “OK” if Deposit.amount => sum(DepositWithdraw.amount)
実装が正しく動いているか確認するための保険としてdailyで上記のバッチを実行しておくのが良いです。
考察
- この設計で今までに2つのサービスを構築しましたが不整合無く稼働しています。
- 運用を開始してから、ビジネスサイドやファイナンスサイドからの要求に対しても基本的には答えられています。
- 監査、経理、マーケティング用途の集計要求にも答えられています。(設計が少し複雑なのでエンジニアかデータに詳しい人じゃないと複雑な抽出を行うクエリーを書けないという問題はあります)
Comments
3. オブジェクト指向の「残高の表示」において「Depositから有効期限が未来のレコードを抽出し、DepoistWithdraw.amountのsum()を除算」というところで、
これはレコードを一旦全て取得するということでしょうか?
違います。この部分はクエリー1回で取得できます。
select sum(Deposit.amount) – sum(DespositWirhdraw.amount) From Deposit left join DepositWithdraw on Deposit.id = DepositWithdraw.deposit_id where Deposit.user_id = and now () <= Deposit.expiration_date
スードコードで、上記のような感じです。
なるほどですね、Prisma ORMを使っていたので、そのメソッドでどうにかできないかと考えていましたが、素直にクエリをとった方が早そうですね。
ちなみに、減算、減算取り消しなども一回のクエリで実行できるのでしょうか?
減算は1回でできません。(SQLスキルが高い場合はできるかもしれません)
排他制御内で、
– 減算する対象のdepositを取得
– 減算のレコードをinsert
– 減算と残高の関連付けをinsert
する処理を行わないといけないためです。
減算の取り消しは2クエリーです。
排他制御内で、減算のレコードと、減算と加算の関連のテーブルにあるレコードの削除が必要です。
—-
それぞれそんなに複雑ではないのでORMを使っても書けると思います。
本文において、正しくは除算ではなく減算ですね。