全社横断ポイント基盤構築
## 技術スタック Golang, Python, AWS_Lambda, RDS-aurora, DynamoDB, API_Gateway, S3, BigQuery, Terraform, serverless-framework, CircleCI, GitHub, OpenAPI, Re:dash # サービス概要 複数のサービスから呼ばれるポイント基盤システムの新規立ち上げ、開発、運用をおこなった。 # 開発の背景 私が所属している会社では複数の事業を展開しているものの、それぞれ個別のプロダクトとして立ち上げられグロースしてきたため相互送客がほとんどできていなかった。ユーザーも各サービスを同じ企業が運営しているという認識が希薄であった。そこでポイントシステムを導入することで、あるサービスを利用して溜まったポイントを別のサービスでも利用できるようにし、相互送客数向上とロイヤリティ向上を狙った。 このポイント施策において、私はアプリ開発部署の担当者として参画し、ポイントシステム実現のための設計〜リリースまでを担った。 # システム設計 ## 想定される使い方 - 相互送客を狙うため、複数の事業サービスのシステムから呼べるようにする。 - 本システムはポイントの計算や記録についての責務を負うものとする。ポイント値変動に関する通知、キャンペーン情報の登録・発信、ポイント利用に関するユーザーへの表示などについては、本システムを呼び出す側のシステムの責務とする。 - 冪等性を担保する(同じ操作が何度走っても1回分の処理結果のみが反映されるようにする) - ある時点の状態(ポイント残高や消費履歴)はユーザーIDごとの全データから算出できるようにする。 - バッチ処理では数十万ユーザーに対して一度にポイント付与される。 ## 設計 - マイクロサービスとして構築。(複数の事業サービスシステムから呼べるように) - 状態管理をせず、操作のたびにデータが溜まっていく台帳式を採用。過去分のレコードは削除しない。 - 付与を取り消す場合は付与取り消し処理データを追加して消し込む。 - アクセスパターンとしては、ポイントの付与、消費、与信(=消費枠の確保/ロック)、参照、履歴取得がある。 - その他の内部的な処理としては、失効、取消などが存在する。 ## データストア選定 以下の特性と理由から、DynamoDBを選択した。 - 容量の制限がなく、インスタンス管理も不要。メンテナンスコストが減る。 - ポイントの付与、与信、消費などの操作履歴を全て INSERT するため、大量のレコードが作成されることが想定される。その際にパフォーマンスの低下を招かないようにするため。 - 同時接続数の上下に柔軟に対応でき、スケーラビリティを確保するのが容易。 - トランザクション制御(順序や排他制御、ロールバックが行える)が可能。 - ポイントの付与や消費など本質的な機能に限定すれば、テーブル結合等複雑なテーブル操作は不要。 - 使用率などの分析などには不向きであるというデメリットはあるが、そこはBigqueryに連携しそちらを参照することでクリアする。 ## インフラ&CI/CD バックエンド処理を担当するLambdaとAPI Gatewayおよびその監視のためのCloudWatch Alermについてはserverless-frameworkを用いて、その他のリソース(DynamoDB, S3, バッチ処理のためのECRなど)はTerraformを用いてそれぞれ生成、管理した。全てのリソースをどちらかで記述し、CircleCIでCI/CDを実現した。 ポイント基盤用のライブラリをGoで開発し、serverless-frameworkで作成した複数のエンドポイントそれぞれでライブラリを呼び出すことで処理を実行する形式をとった。 # 担当業務 本システムは私を含む計2名のバックエンドエンジニアと、1名のプロダクト・オーナーによって開発された。私は設計、実装、テスト、運用を担当した。主に担当した領域は以下の通り。 - 本機能開発(付与、消費、失効処理等) - バッチ処理実装(管理者による一括ポイント付与機能等) - 履歴確認機能実装 - 負荷試験(大量件数のバッチ処理に耐え切れるか) - 障害対応(※後述) 以上のような担当作業のほか、NoSQL特有の複雑なJSONデータの集計を便利に行えるスクリプトを開発しチームで共有することで、よく使うメソッドは他のチームメンバーも使えるようにするなど作業の効率化につとめた。 以下では担当業務のいくつかを詳述する。 ## 本機能開発1:通知処理 ### 機能の内容 獲得や消費などでポイント値の変動が発生したとき、そのユーザーに通知をするための機能の開発である。メール、プッシュ通知のほか、アプリ内に実装済の独自実装の通知欄(アプリ内通知)の三種類の通知を送付できる機能が求められた。 ### 課題・問題点 1. キャンペーンなどで大量ユーザーに一括付与した場合などは、何万ユーザー単位で通知を送る必要がある。直列処理で通知する場合、一件の日次バッチが24時間で終わらない危険性すらあった。 1. ポイントそのものはDynamoDBで開発しているものの、ユーザーのメールアドレスなどは基幹システムのRDSに保管されているため、大量アクセスするとコネクションの上限値に抵触する危険性があった。 1. 通知機能に関して社内に似たような実装事例は存在したものの、実装者によって書き方が異なった。通知機能はプロダクト固有の価値とは言えない部分であるはずのもので共通化できる可能性があるにもかかわらず、実装者によって似たようなものが各プロダクトごとに散らばって書かれている状態であった。 ### 使用した技術・工夫したポイント ユーザーにポイントの変動を通知することはポイント基盤の責務とは別物と定め、どのプロダクトからも叩ける「通知基盤」として切り出して新たなマイクロサービスとして構築。さらに並列処理を用いて通知基盤を呼び出す部分をポイント基盤に実装し、大量通知を捌き切る機能を実現した。取り組みとしては 1. 基幹システムRDBのリードレプリカを増強。読み取り専用エンドポイントにアクセスし、数万件のリクエストに耐えられるか負荷検証を実施。 1. ポイント基盤側でgoroutineを用いた並列処理を実装。 なお、ポイント基盤側に「通知バッチ」を実装し、一回のリクエストで数万のユーザー情報を取得、送付すればこのような負荷検証は不要な設計ができたが、通知バッチとなるLambdaの上限稼働分数15分に抵触する危険性が十分にあったこと、AWS Batchなどのコンテナを用いた処理を作るのは工数のROIが合わないこと、さらに通知基盤として独立させる社内需要があったことなどから、上記の構成を選択した。 ## 本機能開発2:失効処理 ### 機能の内容 付与されたポイントにはそれぞれ使用期限が設定されている。 期限が切れたポイントのうち、未使用のポイント数のみを計算して「失効済み」として記録しておくための処理を実装した。 ### 課題・問題点 1. 実装そのものが複雑。ポイントの付与、消費、失効の処理はそれぞれが一対一対応するとは限らないため、計算が合っていることを追うことも簡単ではない。 1. テストケースを網羅することが困難。例えば消費をしたい場合はその前に失効ポイント数を確定させる必要があるなど、他の処理との関連性が密接にある。 ### 使用した技術・工夫したポイント まずテストケースをリストアップし、入念にピアレビューをして確定させた。その後テーブルドリブンなテストコード(ユニットテスト)を書き、これをすべてクリアできる本機能を実装していった。また、上記以外にも手動で統合テストを行った。 # 本システム開発全体を通した振り返り NoSQL DBを実戦投入した際のメリットとデメリットを体感できた。 ## よかった点 アクセス数が増大しようとスパイクしようと、よほどのことがなければオペレーションなしに自動でスケールしてくれる。新しくポイントを使いたいサービスが出てきたとしても、APIが叩かれる本数が増えるだけでポイント基盤そのものには改修が不要。ゆえに「RDBやモノリシックなシステムなど従来のやり方であれば負担していたであろうコストがなくなった」というメリットがあった。 ## 苦労した点、改善点 ### 失効処理の難しさ、補填作業の大変さ 台帳方式を採用したため、ポイント精算に間違いが発生し補填をするなどの処理がかかった場合大変複雑なロジックを制御しなければならなくなる点が挙げられる。 開発初期にはポイント計算ロジックに一部間違いがあり、ポイント残高がマイナス値をとるという事態が生じた。ポイント計算種別に「期限切れによる失効」があり、この失効期限と失効対象データの時刻計算に誤りが生じていたことが原因であった。 一度目の発覚に伴いポイント補填作業を行なったが、その際のポイント再計算ロジックにも誤りが含まれていたことが判明。「誤った失効ポイント」と「誤った補填ポイント」を考慮した上での再補填ポイントの算出をおこなった。 ポイントはお客様の資産であるため、あるべき総量より少ない値になっていた場合はお客様の資産を一部喪失したこととなる。本件が発覚時はエマージェンシー案件として全社報告し対応にあたった。影響範囲の特定、計算結果に誤りが混入するパターンを特定&再現実験、再現条件を含むテストコードの追加とソースコードの修正とフェーズを分けてもう一人のエンジニアと協力し速やかに対応した。 ### NoSQLの集計の難しさ SQLが使えないのでJSONを集計するPythonスクリプトを書く必要があったこと。普通のSQLならselect sum() で済むようなところがそうはいかないため、経験値のあるRDBの直感的な操作が使えない部分には苦労した。スクリプトを書いて対応したが、もはやPythonスクリプトを書く必要性が一つの業務単位として発生してしまうほど、少しでも込み入ったデータの結合や集計をしようとすると面倒なことがあることがわかった。