分割と整合性をがんばる話ソーシャルゲームの整合性対策
自己紹介清水 佑吾
@yamionp
株式会社 gumi 勤務
Python歴約2年半
サーバーさわりはじめて約10年
前職はISP
水平分割がやりたくて転職
関わったもの
HTML + FlashLite
Cocos2d-x
使用環境
Python 2.7
Django
MySQL 5.5/5.6 (RDS)
Redis
RabbitMQ
アジェンダ
2012年前期 負荷対策
2012年中期 トランザクション
2012年後期 デッドロック
負荷対策期
サービスがヒット
更新処理が限界に
当時最強のインスタンスを用意
もう大丈夫!
・・・が、ダメっっ!
というわけで
Player
KVSMemcache
RDB
TokyoTryant
FriendGuildTradeMaster
垂直分割機能単位で格納先DBを変える
性能問題に突き当たる度に分割対象を選定
外部キーを外して別DBに移すだけの簡単なお仕事
1機能に負荷が集中すると対処不能
KVSにもじゃんじゃん逃す
機能をまたがる処理Friend Playerフレンドが増えたので
ポイントUP
Friend ++
Point +10
save
save失敗!
rollbackフレンドが増えたのに ポイントが増えないい…
同時に使う機能は分割できない
負荷の多いPlayer/Card/Quest/Itemの分割が難しい
たとえ分割しても負荷は変わらないことも
そこで
Master Guild
Player
KVSMemcache Redis
RDB
Trade Friend
体力等カード等のCache
性能問題には一定の解決をみた
が…
多発する不整合
消えた更新
なぜか消えるカード
なぜか増えるカード
増えるカード
プレイヤーをまたがる処理Player A Player B
Trade
Card Delete
Card Add
save
save
失敗!
rollback こちらは残ったまま
消えるカード
ユーザー「合成したらカードが消えたんですが!
プレイヤーをまたがる処理Shard1
ID:1 PlayerA
Shard2
ID:1 PlayerCID:1 -
プレイヤーをまたがる処理Shard1
ID:1 PlayerA
Shard2
ID:1 PlayerCID:1 -
上書き!
分割キーを消してはいけない
機能をまたぐ場合の問題も 残ったまま
ただし負荷は下がった高負荷状態にならないのでエラーも少ない
ログだけ丁寧に仕込んで個別ケース対応
KVSに大事なデータを置かない
ゲームに致命的にならない範囲でエラー時はユーザーが得になる方に倒す
バグは直す
そして新プロジェクトへ
アジェンダ
2012年前期 負荷対策
2012年中期 トランザクション
2012年後期 デッドロック
不整合と戦う
偉い人「100万人きても大丈夫なようにしといて!」
1から抜本的に見直し
負荷は水平分割で対処する
XA Transactionによる一貫性担保
ロックによる排他制御
水平分割を前提とした構成
全部DBにいれる
Guild
PlayerRDB
マスターデータはjson化
変更がないのでデプロイ時にAppサーバーに配布
メモリ上に展開するので非常に高速
ますますキャッシュレスに
DBのみで実装する
プレイヤーに紐づくデータはすべてDBに
自動回復系ステータス(体力、BPなど)もDB
トランザクションに収められる!
正規化を徹底
自動回復系ステータス
いままではKVSに格納
Master Guild
Player
KVSMemcache Redis
RDB
Trade Friend
よくおきる不整合
お金追加体力減算
失敗!
begin
commitrollback
自動回復系ステータス
今まではKVSに格納していた
DBだけ更新、KVSだけ更新がおきていた
ユーザーに得になる場合は裏技として2chで祭り
ユーザーの損になる場合はCSが爆発する
KVSだけ更新というパターンは0
ほとんどの場合お金かアイテムかカードが一緒に増える
KVSに居るメリットが実は無い
実装
現在値、最大値、最終更新時刻を持つ
最終更新時間と現在値から自動回復済の値を計算して使う
減算時のみUPDATE
正規化
正規化
意味の重複する値を保存しない
レベルの値は無く、合計経験値のみ保存
参照時に経験値からレベルを計算
レベルからパラメータを計算。
Beforeid int
card_id int hp int
attack intdefense int
magic_attack int magic_defense int
exp int level int
After
id int
card_id int
exp int
驚きのダイエット 効果!
XAトランザクション
普通のトランザクション
begin;
SELECT…;INSERT INTO…;commit; 反映
XAトランザクション
xa begin
SELECT…;INSERT INTO…;xa end
反映
xa prepare
xa commit
xa beginSELECT…;
INSERT INTO…;xa end
xa prepare
xa commit
commit 成功を保証
DB1 DB2
prepare
prepare
prepare
prepare
prepare
prepare
App
commit
commit
commit
prepare
prepare
prepare
App
commit
commit
commit
もし途中でエラーになったら
prepare
prepare
prepare
prepare
prepareApp
失敗!rollback
rollback
rollback
prepare
prepareApp
rollback
rollback
無事に処理前の状態に!
複数のDBを跨ったtrxが可能XAに参加するいずれかの段階でエラーが起こればロールバックが可能
複数DBの状態が 処理成功 or 処理なし のいずれかのみを保証できるようになった
中途半端な状態がなくなる
体力のみ減る、カードだけ増えるなどがなくなる
が、
DjangoはXA Transactionに非対応
水平分割にも非対応
自社開発!
これらを簡単に使うために
エラーハンドリングを毎回書くのは無駄
スキル的にもきびしい
トランザクションに何を含めるかだけ書けるように
エンジニアが書くべきこと
トランザクションに何を含めるか
範囲はモデルの機能ではなくリクエストごとに決まる
最適なロック順番は個別の処理ごとに異なる
ロック・トランザクションを要求する
# player1とplayer2のDBにトランザクション開始 with commit_on_success([player1_id, player2_id]): # ロック付きで取得 player1 = Player.get_for_update(player1_id) player2 = Player.get_for_update(player2_id) # 減算を実行 player1.decrement_ap(5) player1.increment_money(10) player2.decrement_money(10)
def increment_ap(self, quantity): # 自身がロック済みであることを要求 self.require_for_update() # 減算 self.ap -= quantity # UPDATE self.save()
入れ子のトランザクションを扱えない
トランザクションに何を含めるかはモデルにはわからない
ちなみに
commit途中で死んだら?
commit
commit
commit
prepare
prepare
prepare
App
commit
commit突然の死!!
commit
commit
commitXA Recover
preparecommit
cron
処理を完遂!
というのが理想
innodbのxaは切断時にpreparedだと勝手にrollbackしてしまう
2005年ぐらいから指摘されていて、patchも送られた
が、patchの取り込みに失敗
どうしようもない
ログベースの個別対応orz
ある日の夜
イベントリリース!
しばらくは問題なく動作していたが…
ページが開けない!と苦情が
CloudWatch
AppサーバーCPU使用率もリクエスト数も問題ないが...
DBのCPU使用率が張り付いていた
即JetProfilerを起動
ç
テキスト
クリック一つて即Eplainグラフィカル&レーティングしてくれる。 DBにくわしくなくてもいかにもダメそうな感じ
インデックスがなかった
特定クエリが処理時間の9割以上を占めていた
緊急メンテに入りインデックスを追加
インデックスをはったら5%以下に
ほとんど同じ状況で 別パターン
無駄インデックス問題
特定クエリが処理時間の3割以上を占めていた
スローではないが一クエリ当たりの時間が多い
Explainしたら index merge
インデックスを削除したら100倍高速化
アジェンダ
2012年前期 負荷対策
2012年中期 トランザクション
2012年後期 デッドロック
排他制御
ロック
CAS
CASの話はしません
ロック
innodbはレコードロックが可能
ロックの実現にはインデックスが使われる
存在するインデックスより狭い範囲のロックはできない
ロック範囲ID player_id value
1 401 A
2 401 B
3 402 B
4 403 C
PrimaryKey Index
SELECT * FROM player WHERE player_id = 401 FOR UPDATE
ロック範囲ID player_id value
1 401 A
2 401 B
3 402 B
4 403 C
PrimaryKey Index
ロック範囲
SELECT * FROM player WHERE value = “B” FOR UPDATE
ロック範囲ID player_id value
1 401 A
2 401 B
3 402 B
4 403 C
PrimaryKey Index
期待するロック範囲実際のロック範囲
実際のロック範囲はオプティマイザーの気分次第
必要なインデックスが無いと不必要に大きな範囲のロックをとってしまう
インデックスが無駄にあると意図しないインデックスを使われてロックをとられてしまう
何が起きるか
ある日
ゲームが重い
画面が開けない
レイドボスを攻撃したのに重くて叩けなかった
イベントが動かない!
生涯発生中に自分がプレイしても得に問題なかった
だがエラー報告が大量発生
サーバー負荷は大したことなかった
CPU/RAM/Disk/Networkすべて低レベル
ロードバランサーのレスポンスタイムがどんどん劣化
JetProfiler
ロック状態
何が起きていたか
デッドロックによってロック待ちとタイムアウトが発生
ロック
ID player_id value1 401 A2 401 B3 402 B4 403 C
App
1
2
デッドロック
ID player_id value1 401 A2 401 B3 402 B4 403 C
App
1
App 2
デッドロック
MySQLさんは親切
同じDB内のデッドロックは検知して解除してくれる
分割しているとMySQLは検知できない
XAでトランザクションをまとめているので複数DBにまたがって止まる
回避するにはロック順番を統一する
ロックする前にソート(id, Player_id,)
DBをソート
テーブルをソート
レコードをソート
大きくロックを取る player単位、レイドボス単位
参照処理に更新を混ぜない
負荷も跳ね上がる。更新にはほとんどの場合ロックが必要
参照がロックをとる
ロック機会の圧倒的増大
デッドロック祭り
止まってしまうサービス
まってくれない終電
MySQL「XAはSERIALIZABLE」
どのみち更新に必要なデータはFOR UPDATEで取得する必要がある
じつはいらなくね・・・?
REPEATABLE READにしたら速度もあがって問題なくなりました
まとめ単にKVSに移すのは問題の先延ばしにしかならない
きちんと使えばRDBだけで十分さばける
マスターオンリー障害対策用のSlaveはいるがクエリは裁かない
デッドロック対策の前に適切なインデックスを
インデックスショットガン。だめ、絶対。
NewRelicとJetProfilerは神 超オススメです
ご清聴ありがとうございました
質疑応答