くりにっき

フルスタックキュアエンジニアです

ドリコムを支える中間ポイントシステム

はじめに

これは ドリコムAdventCalendar の1日目です

1日目:ドリコムを支える中間ポイントシステム

僕はドリコムに入社してからほぼずっと課金周りのシステムに携わっているのでその話を書きます。

一応自己紹介

  • HN
    • sue445
  • お仕事
    • 社内ライブラリや社内ツールの開発
    • その他サーバサイド全般
    • ドリコム歴 = Ruby歴 = 2年半くらい
    • TDDおじさんから火消しまで 「はい、喜んで!」(血涙)
  • 主なコミュニティ

プリキュアおじさん

嫁はキュアピース

開発の背景

ソーシャルゲームの歴史

2012年くらいまではGREEmixiといった国内ソーシャルゲームプラットフォームが台頭してブラウザ系のソーシャルゲームがメインでした。そこのプラットフォーム上でゲームを公開するにあたって、課金周りはプラットフォームで提供しているAPIを使うだけなので自社で課金システムを開発する必要はありませんでした。

しかしパズドラのヒットとともにソーシャルゲーム業界全体にiOS/Androidのネイティブアプリ化の波がやってきて弊社でもネイティブアプリを開発する流れがやってきました(たしか2013年頭くらい)

ここではiOSAndroidソーシャルゲームを「ネイティブアプリ」と呼称します

中間ポイントについて

ネイティブアプリのソーシャルゲームで遊んだことのある人なら分かるかもしれませんが、ネイティブアプリだとiTunesやGooglePlay(以下ストア)から直接ガチャをまわすのではなく、ストアでは一度中間ポイント(パズドラであれば魔法石)を購入して、それを使ってガチャを回していると思います。

これには理由があって、確かアメリカの法律によりストアから直接ガチャを回すことができないことになっているのが大きな理由です。

そこで各社のソーシャルゲームはストアでは中間ポイントだけを購入するような仕様になっています。

*1

前受金と資金決済法について

細かい話ははしょりますが

  • 購入されたけど使われていないポイントに関してはいつでもユーザに返金できるように管理する必要がある
  • 毎月月初に前月末時点の前受金を経理に提出する必要がある

が日本国内の法律との兼ね合いで必要になると思います。

有償ポイントと無償ポイント

  • ユーザがストアで購入した中間ポイントは有償ポイントと呼ぶ
  • 運営が配布した中間ポイント*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での中間ポイント購入

f:id:sue445:20141130174443p:plain

  1. 端末で購入処理を行う
  2. iTunesストア上で決済が完了したら端末にレシートが送信されてくる
  3. 端末からアプリサーバにレシートを送信する
  4. アプリサーバからAppleのレシート検証APIに端末から送られてきたレシートを送信して、レシートが正しいかどうかチェックする
  5. レシートが正しければ中間ポイントを付与する

GooglePlayでの中間ポイント購入

f:id:sue445:20141130174523p:plain

  1. 端末で購入処理を行う
  2. GooglePlay上で決済が完了したら端末にレシートが送信されてくる*3
  3. 端末からアプリサーバにレシートを送信する
  4. レシートをGooglePlayの管理コンソール上でダウンロードできる公開鍵で検証する
  5. レシートが正しければ中間ポイントを付与する

ポイント消費

f:id:sue445:20141130174535p:plain

自社のアプリで完結するのでそんなに難しいことはしていないです

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(現行最新バージョン)

課金というヘビーなライブラリですがカジュアルに月2〜3回ペースでアップデートしています。(アプリの皆さん申し訳ありません。。。)

gemのボリューム

f:id:sue445:20141130214201p:plain

LOC 4500行くらい。課金系のライブラリなのでテストは割と手厚く行っています

date_discreterというgemを作りました

dpointのソースを見ていたら

# TODO: 汎用的なのでgem化したい

ってあったので、切り出してgem化して公開しました。

どういう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日くらいを検索対象のパーティションにするscope
  • xxxx_with_partition : パーティションを絞りつつupdateやsaveを行うメソッド
    • Railssaveupdate_attributes で発行されるUPDATE文には WHERE id = ? しかつかないので、パーティショニングしてると1回のsaveやupdateで全パーティションを探索するため効率が悪いです。
    • UPDATEがパフォーマンスで問題になることはほぼほぼないのですが、後述の通常の10倍の課金が走った時にUPDATE詰まりでアプリが止まったのでその対策です。
    • パフォーマンス上ボトルネックになりうるUPDATEのみに適用したかったので alias_method_chain はつけていません

正規化していたらレコード数多すぎてJOINできなくなった

ポイントの購入と消費を入れるテーブル(各500万レコードずつ)をJOINしようとしたらMAX_JOIN_SIZE エラーが出て月次レポートが作成できなくなった*5

  1. 1ヶ月分まとめて月次レポートを作成するのをやめて、毎時(hourly)レポートと日次(daily)レポートに分割して積み上げ集計するようにした
    • この時の経緯によりレポートの抜けをチェックするためにdate_discreter が必要になった
  2. 非正規化した。(消費テーブルに購入の情報も持たせて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の存在は知らなかったので。。。

いろいろ試行錯誤した結果

  1. db:drop
  2. spec/config/database.yml の内容を元に db:create
  3. 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入れて緩和されたとはいえ、いっこうにジョブが減る気配がない

f:id:sue445:20141130214505p:plain

各ジョブの説明

FAQ

なんでアプリ内DBに?

Appleの規約上、中間ポイントを複数アプリ間でまたがって使うことができないためです。

複数アプリ間で連携するのであればポイントサーバを立てる必要がありますが、アプリ内に閉じているのであれば外部サーバを立てる必要はないという判断です。(ポイントサーバのAPIをコールする分オーバーヘッドが発生するし、APIサーバに対してトランザクション制御するのは困難)

アプリDBにポイント系のデータも同居することでパフォーマンスの懸念があると思いますが、弊社では Fusion-io ioDrivePercona Server *8 を載せて、データベース自体も カジュアルに 適切に垂直・水平分割することでdpointのパフォーマンス上の問題点はほとんどありませんでした。(dpointの処理が詰まったのはガチャ更新でいつもの10倍以上の課金が走った時くらいだけど、それは前述のmoduleを入れることで解決済み)

詳しくはこちらを御覧ください

ブコメレス

id:dekokun

このライブラリのメンテ、相当大変だろうなぁと思いを馳せる。

バージョンの上がり方で察していただけると幸いです ('A`)

12/4: Twitterでのやりとりを追記

簡単にまとめ

  • 日付で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です