もしもISUCON10の予選問題がMongoDBだったら
はじめに
MongoDBと仲良くなるためにISUCON10の予選問題をMySQLからMongoDBに移植するという謎の修行に挑戦しました。
その結果ISUCON10の予選問題がめでたくMongoDBに移植されまして、リポジトリはこちらです。
リソースの関係上Goしか移植できていませんが、本家と同じ方法で問題が起動できますのでよかったら遊んでみてください。
この記事では、ISUCON10の予選問題をMySQLからMongoDBに移植した際の思い出をつらつらと書いていきます。
まずはGET /api/estate/:id を移植する
docker-compose up
したらMongoDBが起動するように構成を変更し、ISUCON10予選問題のWebアプリからMongoDBに接続できるようになったら、まずは簡単そうなAPIから移植していきました。
このAPIはリクエストパラメータからMySQLに簡単なクエリを叩き、その結果をレスポンスボディに詰めています。パラメータ id
が 1234
だったとしたら以下のようなクエリを叩いています。
SELECT * FROM estate WHERE id = 1234
このクエリをMongoDBに書き換えると以下のようなクエリになります。
db.estate.findOne({_id: 1234})
初めてMongoDBのクエリを見る方向けに説明すると
-
db
- 使用するデータベース
- ここでは
isuumo
というデータベースを表している
-
estate
- 使用するコレクション(RDBにおけるテーブル)
- コレクション名を指定して使う
- ここでは
estate
というコレクションを指定している
-
findOne
- コレクションから条件に一致する1つのドキュメント(RDBにおけるレコード)を取得するオペレーション
という感じです。コード自体は以下のような差分になります。
- err = db.Get(&estate, "SELECT * FROM estate WHERE id = ?", id)
+ err = mongodb.Collection("estate").FindOne(context.Background(), bson.M{"_id": id}).Decode(&estate)
bson
というのが気になると思いますが、MongoDBはBSONというJSONライクな表現でデータを取り扱う のでこういうコードになる、という説明に留めておきます。興味がある方はこちらをご参照ください。
こんな感じでコツコツと全てのMySQLで行われている処理をMongoDBに書き換えていきました。
GET /api/chair/low_priced を移植する
このAPIはMySQLで以下のクエリを叩き、結果をレスポンスに詰めていました。
SELECT * FROM chair WHERE stock > 0 ORDER BY price ASC, id ASC LIMIT 20
このクエリはMongoDBでは以下のようなクエリになります。
db.chair.find({stock: {$gt: 0}}).sort({price: 1}, {_id: 1}).limit(20)
-
chair
-
chair
というコレクションを指定している
-
-
find
- コレクションから条件に一致する全てのドキュメントを取得するオペレーション
-
$gt
-
>
に相当するオペレータ - このクエリの場合
stock
が0より大きい値のドキュメントを取得する
-
-
sort
-
ORDER BY
に相当するカーソルメソッド -
1
を指定するとASC
、-1
を指定するとDESC
に相当する - このクエリの場合
price
_id
の順でソートする
-
-
limit
-
LIMIT
に相当するカーソルメソッド
-
コードはだいたい以下のような差分となります。
var chairs []Chair
- query := `SELECT * FROM chair WHERE stock > 0 ORDER BY price ASC, id ASC LIMIT ?`
- err := db.Select(&chairs, query, Limit)
+ findOptions := options.Find().SetSort(bson.D{{"_id", 1}}).SetSort(bson.D{{"price", 1}}).SetLimit(Limit)
+ query := bson.D{{"stock", bson.D{{"$gt", 0}}}}
+ cur, err := mongodb.Collection("chair").Find(context.Background(), query, findOptions)
+ for cur.Next(context.TODO()) {
+ var chair Chair
+ err := cur.Decode(&chair)
+ chairs = append(chairs, chair)
+ }
mongo-go-driver だと複数ドキュメントを取得した場合、1ドキュメントごとにデコードするコードを書く必要があり、ぐぬぬという気持ちになりました。
どんどんMongoDBに書き換えていく
こんな感じでMongoDBのドキュメントとにらめっこしながら処理を書き換えていきました。
個人的に面白かったdiffとしては
とか
とかですかね。夢に bson
が出てきそうになりました。
MongoDBでなぞって検索を完全再現するのは諦めた
ISUCON10予選問題のハイライトのひとつに、なぞった緯度経度の範囲内に存在する物件を検索する「なぞって検索」の最適化が上げられると思います。
MongoDBでも地理空間クエリを扱うことは可能なのですが、GeoJSONとして保存する必要がありMySQLのようにWKTのジオメトリ文字列を扱うことはできないようでした。
できれば保存された値をそのまま使ってかつデータベース側で処理をするようにして完全再現したかったのですが、ちょっと難しそうに感じたので諦めて kellydunn/golang-geo を使ってアプリケーション側で同様の処理をするようにしました。
ここはMongoDBの空間インデックスを使って改善してみたいポイントですね。
FOR UPDATE
するのは諦めた
MongoDBで MongoDBはバージョン4.0からトランザクションが導入されたのですが、FOR UPDATE
のように行ロックを取ることはできません。
厳密に言えば FOR UPDATE
のような動作をさせるワークアラウンドはあるのですが、データベースから提供されている機能というわけではないです。
ISUCON10予選問題では POST /api/chair/buy/:id
内で FOR UPDATE
にて行ロックを取っているため、ここをどのように再現するか悩みました。
が、ここで頑張って行ロックを取れるようにしてもその後の改善で affected_rows
の値をみて0だったら404を返す、みたいにされるところだろうなと思ってしまったので、最初からそのような処理にしてしまいました。
その他諸々を整えて完成しベンチを走らせると...
こんな感じでMySQLをMongoDBに移植し、コード内と構成から完全にMySQLを削除していきました。
途中、MongoDB用の初期データを生成するのが遅すぎて別のISUCONが始まってしまうといったイベントが発生するなど。
これらの苦難を乗り越えて、無事に本家と同じ方法でベンチが通るところまで持ってくることができました。
私のMacbook Proでベンチを動かしたところ、MySQLの時はおおよそ 530 くらいのスコアが出ているのですが、MongoDBの時はおおよそ 320 くらいのスコアになるので、これからまずはMySQLのスコアを越えられるように改善していこうと思います!
※まだ私自身このアプリケーションの改善は行っていないので、もしかしたらスコアを上げていく過程でMySQLの時には発生し得なかった不具合があるかもしれません。
得た学び
Ruby on RailsからWebアプリケーション開発のキャリアをスタートさせた私にとって、Active Recordによって抽象化されまくっていたRDBMSに対する認識を矯正してくれたのがISUCONの過去問題だったのですが、MongoDBでも同じくMongoidによって抽象化されまくっていた認識を矯正してくれる感覚を得られました。
開発効率を考えるとデータベースを抽象化してくれるライブラリの存在は欠かせませんが、その裏でどのようなクエリが飛んでいるか理解することは重要だと改めて感じました。
また、データベースを置き換えるくらい一生懸命アプリケーションに向き合うと、それだけでいろんな改善点が見えてくることを感じました。
ISUCON本番でもこれくらい冷静かつ集中してアプリケーションと向き合えばよりスコアアップにつなげられるんだろうなあ、と思ったので次のISUCONに生かしたいです。
ということでISUCONを通じてまた新たな学びを得てしまったので、ISUCONは素晴らしいイベントです!
もしよければMongoDB版ISUCON10予選問題で遊んでみてください!
Discussion