PHP歴14年のソシャゲサーバーエンジニアがGoでガチャ抽選APIを実装してみた話
前回までのあらすじ
前回の記事で「なぜPHP一筋14年のオッサンがGoに手を出すのか」という話と、Docker + Go + MySQLの環境構築までを書いた。
今回はいよいよ本題、ガチャ抽選APIの中身を実装したので、設計判断と実際に動かした結果をまとめる。ソシャゲのガチャは「重み付き抽選」「天井(スパーク)」「排出率の開示義務」あたりが本業でも毎回論点になる機能なので、まずはGoで一番シンプルな形を作ってみた。
前回の仕様からの訂正
前回の記事で「レアリティごとの重みは合計100,000になるよう設計する」と書いたが、実装を進める中で以下の2点を変更した。
-
weight合計を100000に固定する設計をやめた
一般的なガチャ運用を考えると、排出率を調整するたびに全アイテムのweightを合計100000ちょうどになるよう再配分するのは現実的ではない(アイテムの追加・削除のたびに全件の再計算が必要になってしまう)。そのため、合計値を特定の数値に丸めず、登録されているアイテムのweightをそのまま合計し、その合計に対する比率で抽選する方式に変更した。今回のサンプルデータでは結果的に「アイテム1件あたりweight=1」という均等割りにしているが、これは登録内容次第でいくらでも変わる。 -
排出履歴の取得は今回見送った
前回の仕様では単発/10連/カウンター参照/排出履歴の4エンドポイントを想定していたが、今回実装したのは単発・10連を兼ねる抽選エンドポイントのみ。gacha_historiesテーブルへの保存(書き込み)は行っているが、それを取得するAPIはまだ実装していない。抽選ロジックそのものに集中するための優先順位づけとして、今回のスコープからは外した。
今回作ったもの
エンドポイントは2本。
| Method | Path | 内容 |
|---|---|---|
| GET | /gachas/{gacha_id} |
ガチャ情報を取得 |
| POST | /gachas/{gacha_id}/draw |
ガチャを1連 or 10連実行 |
機能としてはこのあたりを入れた。
- 重み付き抽選(単発 / 10連)
- 天井(同一ユーザー・同一ガチャで累計100回引くと次回SSR確定)
- 抽選履歴のDB保存
逆に、認証・課金・ピックアップガチャ、排出率の検証用エンドポイントや履歴取得APIみたいなものはあえて今回のスコープから外した。「抽選ロジックそのもの」に絞りたかったので、本業だったら絶対必須になる機能を切り落とす判断も含めて設計、という感じで進めている(検証エンドポイントと履歴取得は次回以降でやる予定)。
設計で意識したポイント
1. 確率はfloatではなく整数のweightで管理する
レアリティごとの排出率は、gacha_itemsテーブルのweightカラムから都度導出する方式にした。マスタとして排出率(%)を別で持たせない。
CREATE TABLE gacha_items (
gacha_id BIGINT UNSIGNED NOT NULL,
item_id BIGINT UNSIGNED NOT NULL,
weight INT UNSIGNED NOT NULL,
PRIMARY KEY (gacha_id, item_id),
KEY idx_gacha (gacha_id)
) ENGINE=InnoDB;
今回のサンプルデータでは、アイテム1件ごとにweight = 1を割り当て、レアリティごとのアイテム件数比でそのまま出現比率が決まるようにした(R: 1500件 / SR: 400件 / SSR: 100件 → 75% / 20% / 5%)。抽選は0〜(weight合計)未満の整数乱数を引いて累積分布で判定する。float誤差を気にしなくていいのはPHPで長年やってきた身としてもありがたい。
// rarityOrder はレアリティの走査順を固定するためのもの。
// mapのイテレーション順はGoでは保証されないため、累積分布の計算やテストの再現性のために順序を固定する。
var rarityOrder = []model.Rarity{model.RaritySSR, model.RaritySR, model.RarityR}
// drawRarity は各レアリティのweight合計に基づいて1つのレアリティを重み抽選する。
func drawRarity(rng Randomizer, weightByRarity map[model.Rarity]uint64) (model.Rarity, error) {
total := sumAllWeights(weightByRarity)
if total == 0 {
return "", ErrZeroWeight
}
v, err := rng.Intn(int(total))
if err != nil {
return "", err
}
n := uint64(v)
var cumulative uint64
for _, rarity := range rarityOrder {
cumulative += weightByRarity[rarity]
if n < cumulative {
return rarity, nil
}
}
return model.RarityR, nil
}
Goのmapはイテレーション順が保証されないので、累積分布を計算するために走査順を固定のsliceで持っておく、というのは地味だけどハマりどころだった。
2. 天井カウントは履歴からのCOUNTではなく専用テーブル
user_pity_countersというテーブルを別で持たせて、抽選のたびにインクリメント/リセットする方式にした。履歴テーブルからCOUNT(*) WHERE is_pity = falseのようなクエリで都度集計する方式も考えたが、ソシャゲ運用だとgacha_historiesは数千万行規模まで育つので、集計コストを避けるために専用テーブルを切る、という判断にした。取得時はSELECT ... FOR UPDATEで行ロックし、同時に同じユーザーから連打された場合の更新ロストを防いでいる。
3. 10連は1トランザクションで原子性を担保
10連ガチャは「単発を10回回す」のと抽選ロジック自体は同じだが、履歴の保存と天井カウンタの更新をまとめて1トランザクションでコミットするようにしている。
func (s *DrawService) Draw(ctx context.Context, gachaID, userID uint64, count int) (*DrawResult, error) {
if count != 1 && count != 10 {
return nil, ErrInvalidCount
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, err
}
defer func() { _ = tx.Rollback() }()
gacha, err := repository.FindGachaByID(ctx, tx, gachaID)
if err != nil {
return nil, err
}
now := time.Now()
if now.Before(gacha.StartsAt) || now.After(gacha.EndsAt) {
return nil, ErrGachaClosed
}
// ...(中略)排出アイテム取得・天井カウント取得
pityCount, err := repository.GetPityCount(ctx, tx, userID, gachaID)
if err != nil {
return nil, err
}
results := make([]DrawResultItem, 0, count)
for i := 0; i < count; i++ {
isPity := pityCount >= gacha.PityThreshold
rarity := model.RaritySSR
if !isPity {
rarity, err = drawRarity(random, weightByRarity)
if err != nil {
return nil, err
}
}
// ...(中略)アイテム抽選
if rarity == model.RaritySSR {
pityCount = 0
} else {
pityCount++
}
if err := repository.InsertGachaHistory(ctx, tx, history); err != nil {
return nil, err
}
results = append(results, drawResultItem)
}
if err := repository.UpsertPityCount(ctx, tx, userID, gachaID, pityCount); err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, err
}
return &DrawResult{Results: results, PityCount: pityCount}, nil
}
途中でエラーが起きたらdefer tx.Rollback()で全部巻き戻る。10連の6回目で天井に到達するようなケース(天井カウントがバッチの途中で閾値を超える)もこのロジックだけで自然に処理できるようにしてある。
余談だが、実装中に「天井条件を判定してrarityにSSRをセットしたのに、その直後にもう一度無条件でdrawRarityを呼んでいて上書きしてしまう」というバグを一度踏んだ。ビルドは通るし普段は気づきにくいが、天井カウントが閾値を超えても実際にはSSRが確定しない、という厄介な不具合だった。天井のような「めったに発火しない分岐」はテストしにくい典型例だと痛感した。
4. 乱数はインターフェースで抽象化する
// Randomizer は乱数生成を抽象化する。テスト時に固定値を返す実装に差し替えられるようにするため。
type Randomizer interface {
Intn(n int) (int, error)
}
抽選本体(Draw)はcrypto/randベースのCryptoRandomizerを使っている。math/rand/v2ベースのMathRandomizerも用意していて、こちらは次回作る予定の排出率検証(大量試行シミュレーション)用に、速度優先の乱数として使う想定。
実際に動かしてみた
docker compose up -dでAPIとMySQLを立ち上げ、R 1500件 / SR 400件 / SSR 100件(天井回数100)のサンプルガチャを用意して実際に叩いてみた。
ガチャ情報取得
$ curl -s http://localhost:8080/gachas/1
[{"id":1,"name":"テストガチャ1","pity_threshold":100}]
まだid・name・pity_thresholdだけのシンプルな内容。レアリティ別の提供割合を返すのは次回以降の宿題。
単発ガチャ
$ curl -s -X POST http://localhost:8080/gachas/1/draw \
-H "Content-Type: application/json" \
-d '{"user_id": 501, "count": 1}'
{"results":[{"item_id":2328,"item_name":"Item SR228","rarity":"SR","is_pity":false}],"pity_count":1}
10連ガチャ
$ curl -s -X POST http://localhost:8080/gachas/1/draw \
-H "Content-Type: application/json" \
-d '{"user_id": 502, "count": 10}'
{"results":[
{"item_id":333,"item_name":"Item R621","rarity":"R","is_pity":false},
{"item_id":710,"item_name":"Item R204","rarity":"R","is_pity":false},
{"item_id":947,"item_name":"Item R47","rarity":"R","is_pity":false},
{"item_id":1469,"item_name":"Item R1065","rarity":"R","is_pity":false},
{"item_id":1120,"item_name":"Item R1314","rarity":"R","is_pity":false},
{"item_id":762,"item_name":"Item R252","rarity":"R","is_pity":false},
{"item_id":478,"item_name":"Item R576","rarity":"R","is_pity":false},
{"item_id":1250,"item_name":"Item R1244","rarity":"R","is_pity":false},
{"item_id":995,"item_name":"Item R99","rarity":"R","is_pity":false},
{"item_id":866,"item_name":"Item R168","rarity":"R","is_pity":false}
],"pity_count":10}
R比率75%の設定通り、10連だとRに寄る結果になりやすい。1回のリクエストで10件の履歴保存 + 天井カウンタ更新が1トランザクションで走っている。
天井(ピティ)の境界値を確認する
天井カウントを直接99にセットしてから引くと、閾値未満なので通常抽選のまま(今回はSRが出た)。
// pity_count=99 の状態から1回引いた結果
{"results":[{"item_id":2116,"item_name":"Item SR36","rarity":"SR","is_pity":false}],"pity_count":100}
改めてカウントを100(閾値ちょうど)にセットして引き直すと、is_pity: trueでSSRが確定した。
// pity_count=100 の状態から1回引いた結果
{"results":[{"item_id":2604,"item_name":"Item SSR48","rarity":"SSR","is_pity":true}],"pity_count":0}
is_pityの有無できちんと「自然排出」と「天井による確定」を区別できていて、SSRを引いた瞬間にカウントは0にリセットされる、という想定通りの挙動になっていることを確認できた。
バリデーション・異常系
$ curl -s -o /dev/null -w "%{http_code}\n" -X POST http://localhost:8080/gachas/1/draw \
-H "Content-Type: application/json" \
-d '{"user_id":501,"count":5}'
400
# {"error":"invalid count"}
$ curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8080/gachas/999
404
# {"error":"gacha not found"}
想定していたバリデーションエラー・404もそのまま動作した。
AIレビューで拾ってもらった実装の穴
コードレビューにCodeRabbitを使ったところ、素の実装だといくつか実運用では致命的になりうる指摘をもらった。
- レアリティのweight合計が0のとき(アイテム未設定のガチャなど)、乱数生成
Intn(0)を呼んでpanicする -
h.svc.Drawのエラーを全部500として返していて、count不正やガチャ未公開のようなクライアント起因のエラーまで500(サーバーエラー)扱いになっていた -
sql.Openしただけでは疎通確認にならない(PingContextが無い) -
http.Serverにタイムアウトを設定していない - MySQLの公開ポートが全インターフェースに公開されていた(
127.0.0.1限定に修正) -
rows.Close()/tx.Rollback()の戻り値を握りつぶしていた(errcheck) - エラー文言の末尾にピリオドが付いていた(Go規約違反、
ST1005) - ビルドしたバイナリをうっかりリポジトリにコミットしていた
排他制御(FOR UPDATE)は元々今回のスコープからは意図的に外していた部分だったが、天井カウンタの更新はさすがに直しておかないと事故るので先に対応した。この辺は改めて別記事で深掘りしたい。
まとめ
- 重み付き抽選・天井・履歴保存までを一通りGoで実装した
- 排出率はfloatを使わず整数weightの累積分布で計算し、天井カウンタは専用テーブルで管理
- 実際に動かして、通常抽選・天井の境界値・バリデーションエラーまで期待通りの挙動を確認できた
- AIレビューのおかげで、ステータスコードの誤りや排他制御漏れなど「後から効いてくるバグ」を実装直後に潰せた
- 天井のような「めったに発火しない分岐」は自分でも見落とすバグを仕込んでしまい、境界値テストの大事さを再確認した
次回は排出率の検証エンドポイントと抽選履歴取得APIを実装したうえで、同じ仕様書のままPHP 8 / Rubyで同じガチャAPIを実装して、3言語で書き比べてみる予定。