📝

もしもISUCON10の予選問題がMongoDBだったら

2021/04/01に公開

はじめに

MongoDBと仲良くなるためにISUCON10の予選問題をMySQLからMongoDBに移植するという謎の修行に挑戦しました。
その結果ISUCON10の予選問題がめでたくMongoDBに移植されまして、リポジトリはこちらです。
https://github.com/morihirok/isucon10-qualify

リソースの関係上Goしか移植できていませんが、本家と同じ方法で問題が起動できますのでよかったら遊んでみてください。

この記事では、ISUCON10の予選問題をMySQLからMongoDBに移植した際の思い出をつらつらと書いていきます。

まずはGET /api/estate/:id を移植する

docker-compose up したらMongoDBが起動するように構成を変更し、ISUCON10予選問題のWebアプリからMongoDBに接続できるようになったら、まずは簡単そうなAPIから移植していきました。

このAPIはリクエストパラメータからMySQLに簡単なクエリを叩き、その結果をレスポンスボディに詰めています。パラメータ id1234 だったとしたら以下のようなクエリを叩いています。

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ライクな表現でデータを取り扱う のでこういうコードになる、という説明に留めておきます。興味がある方はこちらをご参照ください。
https://www.mongodb.com/json-and-bson

こんな感じでコツコツと全ての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としては

https://github.com/morihirok/isucon10-qualify/commit/864a87826fb4fa8bc929bee8c70719a3c5451d69

とか

https://github.com/morihirok/isucon10-qualify/commit/3d32a6cb5ec34ccd5be376cfbceca2a2085f5dc6

とかですかね。夢に bson が出てきそうになりました。

MongoDBでなぞって検索を完全再現するのは諦めた

ISUCON10予選問題のハイライトのひとつに、なぞった緯度経度の範囲内に存在する物件を検索する「なぞって検索」の最適化が上げられると思います。

MongoDBでも地理空間クエリを扱うことは可能なのですが、GeoJSONとして保存する必要がありMySQLのようにWKTのジオメトリ文字列を扱うことはできないようでした。
https://docs.mongodb.com/manual/geospatial-queries/

できれば保存された値をそのまま使ってかつデータベース側で処理をするようにして完全再現したかったのですが、ちょっと難しそうに感じたので諦めて kellydunn/golang-geo を使ってアプリケーション側で同様の処理をするようにしました。

https://github.com/morihirok/isucon10-qualify/commit/7508fac07a01fa71e17ef25eb6c6c981a5a5eb6b

ここはMongoDBの空間インデックスを使って改善してみたいポイントですね。

MongoDBで FOR UPDATE するのは諦めた

MongoDBはバージョン4.0からトランザクションが導入されたのですが、FOR UPDATE のように行ロックを取ることはできません。
厳密に言えば FOR UPDATE のような動作をさせるワークアラウンドはあるのですが、データベースから提供されている機能というわけではないです。

https://www.mongodb.com/blog/post/how-to-select--for-update-inside-mongodb-transactions

ISUCON10予選問題では POST /api/chair/buy/:id 内で FOR UPDATE にて行ロックを取っているため、ここをどのように再現するか悩みました。
が、ここで頑張って行ロックを取れるようにしてもその後の改善で affected_rows の値をみて0だったら404を返す、みたいにされるところだろうなと思ってしまったので、最初からそのような処理にしてしまいました。

https://github.com/morihirok/isucon10-qualify/commit/c6d675398b3c3da515c4f735151b62957e18043c

その他諸々を整えて完成しベンチを走らせると...

こんな感じでMySQLをMongoDBに移植し、コード内と構成から完全にMySQLを削除していきました。
途中、MongoDB用の初期データを生成するのが遅すぎて別のISUCONが始まってしまうといったイベントが発生するなど。
https://github.com/morihirok/isucon10-qualify/commit/7556250f83619b335402ecae6cf70f1251069c0b

これらの苦難を乗り越えて、無事に本家と同じ方法でベンチが通るところまで持ってくることができました。

私の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