はじめに
これは ドリコムAdventCalendar の1日目です
- はじめに
- 1日目:ドリコムを支える中間ポイントシステム
- date_discreterというgemを作りました
- 2日目
1日目:ドリコムを支える中間ポイントシステム
僕はドリコムに入社してからほぼずっと課金周りのシステムに携わっているのでその話を書きます。
一応自己紹介
- HN
- sue445
- お仕事
- 主なコミュニティ
プリキュアおじさん
弊社にて幼女からプリキュアおじさんと呼ばれた事案が発生したのでここにご報告いたします
— sue445 (@sue445) 2014, 8月 6
嫁はキュアピース
これがやよいすとの末路ですご確認ください http://t.co/UPWPqU30vJ #プリキュア #precure pic.twitter.com/tBXMbpB9Ca
— sue445 (@sue445) 2014, 8月 24
開発の背景
ソーシャルゲームの歴史
2012年くらいまではGREEやmixiといった国内ソーシャルゲームプラットフォームが台頭してブラウザ系のソーシャルゲームがメインでした。そこのプラットフォーム上でゲームを公開するにあたって、課金周りはプラットフォームで提供しているAPIを使うだけなので自社で課金システムを開発する必要はありませんでした。
しかしパズドラのヒットとともにソーシャルゲーム業界全体にiOS/Androidのネイティブアプリ化の波がやってきて弊社でもネイティブアプリを開発する流れがやってきました(たしか2013年頭くらい)
ここではiOSやAndroidのソーシャルゲームを「ネイティブアプリ」と呼称します
中間ポイントについて
ネイティブアプリのソーシャルゲームで遊んだことのある人なら分かるかもしれませんが、ネイティブアプリだとiTunesやGooglePlay(以下ストア)から直接ガチャをまわすのではなく、ストアでは一度中間ポイント(パズドラであれば魔法石)を購入して、それを使ってガチャを回していると思います。
これには理由があって、確かアメリカの法律によりストアから直接ガチャを回すことができないことになっているのが大きな理由です。
そこで各社のソーシャルゲームはストアでは中間ポイントだけを購入するような仕様になっています。
前受金と資金決済法について
細かい話ははしょりますが
- 購入されたけど使われていないポイントに関してはいつでもユーザに返金できるように管理する必要がある
- 毎月月初に前月末時点の前受金を経理に提出する必要がある
が日本国内の法律との兼ね合いで必要になると思います。
有償ポイントと無償ポイント
- ユーザがストアで購入した中間ポイントは有償ポイントと呼ぶ
- 運営が配布した中間ポイント*2は無償ポイントと呼ぶ
ユーザからの見た目にはどっちも中間ポイントということに変わりはないのですが、売上が発生するのは有償ポイントが使われた時だけです。(無償ポイントは0円なのでいくら使われても売上に計上できないのは当然ですね)
複雑な売上計算
- 会社としての売上は「中間ポイントが購入された時」ではなく、「ガチャなどで中間ポイントが消費された時」に発生する
- 有償ポイントは購入時の単価がそれぞれ異なるので、同じ1ポイントの消費でも購入時の単価によって売上が異なる
- 例)6ポイント500円 ≒ 1ポイント辺り83.3円、85ポイント5400円 ≒ 1ポイント辺り65.52円、無償ポイントは全て0円
ドリコムの中間ポイントシステム(dpoint)について
以上のような面倒くさい背景があり各アプリで個別に中間ポイントシステムを開発していくと工数が膨れ上がるため、全社で共通化した中間ポイントシステムを僕が開発しました。
drecomの中間pointシステムなので「dpoint」という名前です。
社内でも割と誤解されがちなのですが、ポイントサーバみたいなのがあるわけではありません。dpointはアプリ内に内包しているgem(ライブラリ)です。課金データは各アプリDBに同居しています。
dpointは社内のgemリポジトリにホスティングしています
自分の役割
自称「アーキテクチャ設計」兼「ライブラリメンテナ」兼「リリースマネージャ」です
最近メンテナが増えましたが、1年以上ほぼ僕1人でメンテしています ('A`)
重要なこと
開発当初の俺氏のスペック:Ruby歴1年未満(今は2年半くらい)
初めてRubyで作ったgemが課金の超コアなdpoint
dpointが導入されているアプリ
2013年9月以降にリリースされている弊社のネイティブアプリ全般で導入されています。
etc
余談ですがフルボッコヒーローズは事前登録でも開発に関わっていました。
課金フロー
iTunesでの中間ポイント購入
- 端末で購入処理を行う
- iTunesストア上で決済が完了したら端末にレシートが送信されてくる
- 端末からアプリサーバにレシートを送信する
- アプリサーバからAppleのレシート検証APIに端末から送られてきたレシートを送信して、レシートが正しいかどうかチェックする
- レシートが正しければ中間ポイントを付与する
GooglePlayでの中間ポイント購入
- 端末で購入処理を行う
- GooglePlay上で決済が完了したら端末にレシートが送信されてくる*3
- 端末からアプリサーバにレシートを送信する
- レシートをGooglePlayの管理コンソール上でダウンロードできる公開鍵で検証する
- レシートが正しければ中間ポイントを付与する
ポイント消費
自社のアプリで完結するのでそんなに難しいことはしていないです
dpointのリリースノート
詳しいリリースノートは書けないので日付と主なバージョンだけ
- 2013/05/27 : v0.0.1(初版)
- 2013/11/01 : v0.3.2(0系最終バージョン)
- 2013/11/15 : v1.0.0
- 2014/05/30 : v1.1.7(1系最終バージョン)
- 2014/05/09 : v2.0.0
- v1.1.7と日付前後していますがこの辺は1系と2系並行メンテしてました
- 2014/11/28 : v2.3.0(現行最新バージョン)
- activerecord-turntableによる水平分割に対応したバージョン
- activerecord-turntableについては ソーシャルゲームでDB水平分散 #mdb_casual // Speaker Deck を御覧ください
課金というヘビーなライブラリですがカジュアルに月2〜3回ペースでアップデートしています。(アプリの皆さん申し訳ありません。。。)
gemのボリューム
LOC 4500行くらい。課金系のライブラリなのでテストは割と手厚く行っています
date_discreterというgemを作りました
dpointのソースを見ていたら
# TODO: 汎用的なのでgem化したい
ってあったので、切り出してgem化して公開しました。
- http://rubygems.org/gems/date_discreter
- https://github.com/sue445/date_discreter
どういうgem?
月、日、時間の歯抜けを調べるためのgemです。
dpointでレポートの仕組みが
- 1時間に1回:課金系の実レコードの集計hourlyレポートを作成
- 1日1回:hourlyレポートを積み上げてdailyレポートを作成
- 1ヶ月に1回:dailyレポートを積み上げてmonthlyレポートを作成
のような積み上げ方式(キングスライム方式)になっているのですが、レポートを積み上げる時に途中のレポートが1つでも欠けていると正しく売上レポートが出ないのでhourlyやdailyの歯抜けを調べるための処理をgemにしました。
サンプルコード見てもらうのが一番手っ取り早いかと思います。
月の歯抜けを調べる
continuous_months = [ Date.parse("2014-10-01"), Date.parse("2014-11-01"), Date.parse("2014-12-01"), ] DateDiscreter.discrete_months(continuous_months) #=> [] DateDiscreter.continuous_months?(continuous_months) #=> true discrete_months = [ Date.parse("2014-10-01"), Date.parse("2014-12-01") ] DateDiscreter.discrete_months(discrete_months) #=> [#<Date: 2014-11-01 ((2456963j,0s,0n),+0s,2299161j)>] DateDiscreter.continuous_months?(discrete_months) #=> false
日の歯抜けを調べる
continuous_days = [ Date.parse("2014-12-01"), Date.parse("2014-12-02"), Date.parse("2014-12-03"), ] DateDiscreter.discrete_days(continuous_days) #=> [] DateDiscreter.continuous_days?(continuous_days) #=> true discrete_days = [ Date.parse("2014-12-01"), Date.parse("2014-12-03"), ] DateDiscreter.discrete_days(discrete_days) #=> [#<Date: 2014-12-02 ((2456994j,0s,0n),+0s,2299161j)>] DateDiscreter.continuous_days?(discrete_days) #=> false
時間の歯抜けを調べる
continuous_hours = [ Time.parse("2014-12-01 00:00:00"), Time.parse("2014-12-01 01:00:00"), Time.parse("2014-12-01 02:00:00"), ] DateDiscreter.discrete_hours(continuous_hours) #=> [] DateDiscreter.continuous_hours?(continuous_hours) #=> true discrete_hours = [ Time.parse("2014-12-01 00:00:00"), Time.parse("2014-12-01 02:00:00"), ] DateDiscreter.discrete_hours(discrete_hours) #=> [2014-12-01 01:00:00 +0900] DateDiscreter.continuous_hours?(discrete_hours) #=> false
課金システムを作ることがあれば(作ることがなくても)是非お使い下さい
dpoint改修時のつらみ
DBのスキーマ変更する場合は導入してるアプリ全部での影響を調べる必要がある
dpoint要因でアプリのメンテ時間を長くしたくないので、既存アプリへの影響が最小になるように気をつけています
数千万レコードあるテーブルに対して気軽にカラム追加できない。。。
PKだけで検索したら非効率な場合がある
created_at
でパーティションきってるテーブルに対してidだけで検索すると全パーティションに対して検索して非効率なので、idとcreated_atをセットで検索する必要があります。
idだけで絞り込んだ時のexplain
explain partitions SELECT `dpoint_some_models`.* FROM `dpoint_some_models` WHERE `dpoint_some_models`.`id` = 5648239 LIMIT 1 \G *************************** 1. row *************************** id: 1 select_type: SIMPLE table: dpoint_some_models partitions: p20140316,p20140323,p20140330,p20140406,p20140413,p20140420,p20140427,p20140504,p20140511,p20140518,p20140525,p20140601,p20140608,p20140615,p20140622,p20140629,p20140706,p20140713,p20140720,p20140727,p20140803,p20140810,p20140817,p20140824,p20140831,p20140907,p20140914,p20140921,p20140928,p20141005,p20141012,p20141019,p20141026,p20141102,p20141109,p20141116,p20141123,p20141130,p20141207,p20141214,p20141221,p20141228,p20150104,p20150111,p20150118,p20150125,p20150201,p20150208,p20150215,p20150222,p20150301,p20150308,p20150315,p20150322,p20150329,p20150405,p20150412,p20150419,p20150426,p20150503,p20150510,p20150517,p20150524,p20150531,p20150607,p20150614,p20150621 type: ref possible_keys: PRIMARY key: PRIMARY key_len: 8 ref: const rows: 1 Extra: 1 row in set (0.00 sec)
idとcreated_atで絞り込んだ時のexplain
explain partitions SELECT `dpoint_some_models`.* FROM `dpoint_some_models` WHERE (`dpoint_some_models`.`created_at` BETWEEN '2014-03-03 10:58:00' AND '2014-04-03 11:28:00') AND `dpoint_some_models`.`id` = 5648239 LIMIT 1 \G *************************** 1. row *************************** id: 1 select_type: SIMPLE table: dpoint_some_models partitions: p20140316,p20140323,p20140330,p20140406 type: range possible_keys: PRIMARY key: PRIMARY key_len: 16 ref: NULL rows: 4 Extra: Using where 1 row in set (0.00 sec)
Railsから呼ぶ時にはこういうmoduleをincludeしています*4
near_partition
: created_atの前後1日くらいを検索対象のパーティションにするscopexxxx_with_partition
: パーティションを絞りつつupdateやsaveを行うメソッド
正規化していたらレコード数多すぎてJOINできなくなった
ポイントの購入と消費を入れるテーブル(各500万レコードずつ)をJOINしようとしたらMAX_JOIN_SIZE エラーが出て月次レポートが作成できなくなった*5
- 1ヶ月分まとめて月次レポートを作成するのをやめて、毎時(hourly)レポートと日次(daily)レポートに分割して積み上げ集計するようにした
- この時の経緯によりレポートの抜けをチェックするためにdate_discreter が必要になった
- 非正規化した。(消費テーブルに購入の情報も持たせてJOINしないようにした)
の順で対応しました
糞でかいテストデータがあったのでgemのサイズがでかくなった
バグ修正&リグレッションテスト用に本番のデータをテストデータ*6として使っていたらそれに伴ってgemのサイズがでかくなってアプリから苦情を受けたので、実行に関係ないファイルはgemに含めないようにしました
gemspecをこういう風にすればOK
spec.files = `git ls-files`.split($/) # ファイルサイズがでかくて実行に関係ないファイルはgemから除外する EXCLUDE_DIRS = %w(spec/ tools/) EXCLUDE_DIRS.each do |exclude_dir| spec.files.reject! {|filename| filename.start_with?(exclude_dir) } end
この修正でgemのファイルサイズが13MBから48KBくらいまでに減りましたw
# Before ls -l pkg/dpoint-1.1.3.gem -rw-r--r-- 1 sue445 staff 13420032 3 14 01:52 pkg/dpoint-1.1.3.gem # After ls -l pkg/dpoint-1.1.3.gem -rw-r--r-- 1 sue445 staff 48128 3 14 01:52 pkg/dpoint-1.1.3.gem
gem単体でdb:migrateするのがつらい
Rails Engineならまだ違ったと思うのですが、dpoint作り始めた当時はRails Engineの存在は知らなかったので。。。
いろいろ試行錯誤した結果
db:drop
spec/config/database.yml
の内容を元にdb:create
lib/generators/templates/db/migrate/*.rb
を元にdb:migrate
相当のことを全部 spec_helper.rb の中で行っています
設定ファイルによってgemの振る舞いを変えるとテストのパターンが増えてしんどい
業務要件により特定のindexを適用するかどうかを設定ファイル中の値を見てmigrationファイルの中で分岐するするようにしたのですが、index有り無しの場合のテストをそれぞれJenkinsのジョブで作りました。
これにさらに activerecord-turntable で水平分割するかどうかの設定も入れることになってJenkinsのジョブが爆発しかけたのでマトリクステストを手軽に行えるためのgemを作りました
現在は
- Rails 4.0系 / 4.1系
- 前述のindexの有り / 無し
- activerecord-turntable の垂直分割有り / 無し
で 2 x 2 x 2 = 8通りのマトリクステストをJenkinsの1つのジョブで行っています。*7
paraductは既に実用段階です!
それでも減らないJenkinsのジョブ
paraduct入れて緩和されたとはいえ、いっこうにジョブが減る気配がない
各ジョブの説明
- dpoint : masterブランチのビルド
- dpoint_develop : developブランチのビルド
- dpoint_matrix : developブランチのマトリクステスト
- dpoint_MR : MergeRequestに対してビルドする
- dpoint_test_app : dpointを入れたダミーアプリで実際に
rake db:migrate
やパーティションの分割ができるかどうかを確認するための結合テスト用のジョブ
FAQ
なんでアプリ内DBに?
Appleの規約上、中間ポイントを複数アプリ間でまたがって使うことができないためです。
複数アプリ間で連携するのであればポイントサーバを立てる必要がありますが、アプリ内に閉じているのであれば外部サーバを立てる必要はないという判断です。(ポイントサーバのAPIをコールする分オーバーヘッドが発生するし、APIサーバに対してトランザクション制御するのは困難)
アプリDBにポイント系のデータも同居することでパフォーマンスの懸念があると思いますが、弊社では Fusion-io ioDrive に Percona Server *8 を載せて、データベース自体も カジュアルに 適切に垂直・水平分割することでdpointのパフォーマンス上の問題点はほとんどありませんでした。(dpointの処理が詰まったのはガチャ更新でいつもの10倍以上の課金が走った時くらいだけど、それは前述のmoduleを入れることで解決済み)
詳しくはこちらを御覧ください
ブコメレス
このライブラリのメンテ、相当大変だろうなぁと思いを馳せる。
バージョンの上がり方で察していただけると幸いです ('A`)
12/4: Twitterでのやりとりを追記
@tkosuga 遅レスすみません。「日付で収集」というのは具体的にどこのこと言ってるでしょうか?
— sue445 (@sue445) 2014, 12月 3
@sue445 はじめまして。正規化していたらレコード数多すぎてJOINできなくなったので非正規化した、という所の、毎時(hourly)レポートと日次(daily)レポートの部分が「日付で収集」と言っている個所です。
— terukazu kosuga (@tkosuga) 2014, 12月 3
@sue445 非正規化する前にパーティショニングでテーブル細かく分けた方が変更が少なくて楽だったんじゃないかと思って書いたのですが、その前の週単位でパーティショニングしてるとあったので、ぼくがちゃんと記事読んでなかったツイートです。
— terukazu kosuga (@tkosuga) 2014, 12月 3
@tkosuga あのエラーが出た当時は購入がuser_idのLINEAR HASHパーティションで消費がcreated_atのRANGEパーティション(週単位)だったのですが、JOINした時に2テーブル両方でパーティション絞れなくてエラーになってたと思います。
— sue445 (@sue445) 2014, 12月 3
@tkosuga 【承前】(レポートは全ユーザ横断なのでHASHパーティションが意味をなさなかった)
— sue445 (@sue445) 2014, 12月 3
@tkosuga 【承前】両方をRANGEパーティションにするにしても非正規化する前だと消費金額を計算するのに購入時のレコードが必要でJOINすると2テーブル両方でパーティションを絞れなかったので非正規化に踏み切りました
— sue445 (@sue445) 2014, 12月 3
@tkosuga 【承前】(ポイントの利用期限がなければ今日買ったポイントでも1年前に買ったポイントも同等に扱う必要があるので、JOINする限り購入側はパーティションを絞れない) 【終】
— sue445 (@sue445) 2014, 12月 3
簡単にまとめ
- 日付でRANGEパーティショニングしててもJOINした場合に、条件によっては両方のテーブルで同時にパーティションを絞れるとは限らない
- 自分の時は業務仕様的にJOINして2テーブル同時にパーティション絞る方法がなかったので非正規化に踏み切った(つらい)
- mysqlだと
explain partitions
で検索したパーティションも表示できる(上の方のexplain参照)
2日目
次は id:onk さんの onk.ninja - Mountable Engine だらけの Rails アプリ開発 です
*1:AppStoreの規約でも禁止されていたはず。GooglePlayは(少なくとも自分の開発当初は)なかったと思うけど、両プラットフォームで公開するに辺り厳しい方に合わせていることが多いと思います
*2:詫び石とか
*3:厳密にはPurchaseオブジェクトなのですがここではレシートと呼びます
*4:社内gemから適当に持ってきてるのでいくつか修正必要かも
*5:消費は購入時の情報も必要になるため正規化してた
*6:課金レコードがtsvで10MBくらい
*7:本当はRubyのメジャーバージョンごとでもやりたかったのですがrbenvと相性が悪かったので断念
*8:後日Railsアドベントカレンダーに詳しい説明を書く予定ですが、簡単に説明するといろいろカスタマイズされたMySQLのforkです