maco's life

主にエンジニアリングと読書について書いていきます。

ActiveRecordのfind_or_create_byが投げるクエリを検証した

RubyのORMのActiveRecordにfind_or_create_byというメソッドがある。このメソッドはデータがあったらselectした結果が返ってきて、ない場合はinsertをしてその結果を返してくれるという夢のような機能を実現してくれているらしい。 このデータがあったらselect、ない場合はinsertのような処理は扱うときによく考えないとロック周りでDBが詰まってつらい思いすることがある。そこでこのメソッドはどんなクエリを投げるのか検証してみた。

検証方法

  • ActiveRecord::Baseを系統したGamePlayCountモデルに対してrails cで操作を行う。
  • GamePlayCountは対象のgame_idを遊ぶとcountを++していくテーブルだと仮定する。

テーブル構造はこんな感じ。

CREATE TABLE `game_play_counts` (
  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
  `game_id` int(10) unsigned NOT NULL DEFAULT '0',
  `count` int(10) unsigned NOT NULL DEFAULT '0',
  `created_at` datetime NOT NULL,
  `updated_at` datetime NOT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `game_id` (`game_id`),
)

今回はGamePlayCountに対して下記の操作を行う。

  • find_or_create_by単体で使う
  • find_or_create_byとlockを組み合わせて使う
  • 検証する際は一つのケースに対してtransaction内、外両方のパターンを試してみる

検証結果はrails cでActiveRecord::Base.logger = Logger.new(STDOUT)を実行してログに流れるSQLを順番に抽出したものを貼っています。早速検証スタートだドン!

find_or_create_by単体で使う

検証1

find_or_create_byをそのまま呼んでみます。

GamePlayCount.find_or_create_by(:game_id => 8)
SELECT `game_play_counts`.* FROM `game_play_counts` WHERE `game_play_counts`.`game_id` = 8 LIMIT 1
BEGIN
INSERT INTO `game_play_counts` (`game_id`, ...) VALUES ('8' ...)
COMMIT

一度トランザクションの外でSELECTして、トランザクション内でINSERTする結果となりました。こちらで明示的にトランザクションを指定したわけでないけど、ActiveRecordではINSERTしようとすると勝手にトランザクションがはられるようです。

検証2

明示的にトランザクションを指定して試します。

ActiveRecord::Base.transaction do
  GamePlayCount.find_or_create_by(:game_id => 9)
end
BEGIN
SELECT  `game_play_counts`.* FROM `game_play_counts` WHERE `game_play_counts`.`game_id` = 9 LIMIT 1
INSERT INTO `game_play_counts` (`game_id`, ...) VALUES ('9', ...)
COMMIT

こちらはトランザクション内でSELECT, INSERTを行うクエリが発行されました。この場合、同時にアクセスが来た場合に片方がduplicateになりそうですね。

lockの検証

find_or_create_byとlockの組み合わせを実行する前にlock単体でどういったクエリを投げるか確認します。

検証3

GamePlayCount.lock(:true)
SELECT `game_play_counts`.* FROM `game_play_counts` FOR UPDATE

テーブルに対してFOR UPDATEかけた...トランザクション外だから影響ないけどテーブルロック一歩手間ですね。

検証4

次にトランザクション内でlockを呼んでみます。もうこれテーブルロックでしょ

ActiveRecord::Base.transaction do
  GamePlayCount.lock(:true)
end
BEGIN
COMMIT
SELECT `game_play_counts`.* FROM `game_play_counts` FOR UPDATE

トランザクション内でFOR UPDATEかけると思ったけれど、意外なことに外で実行されました。これはこれで安全設計かもだけどちょっと違和感が...。ちなみにlockの後にfind指定で実行したらきちんとトランザクション内で実行されました。

find_or_create_byとlockを組み合わせて使う

find_or_create_byとlockを呼ぶとどうなるのか。大変気になるので早速検証します。

検証5

まずは明示的にトランザクション囲まないケースから試します。

GamePlayCount.lock(true).find_or_create_by(:game_id => 10)

データがない状態

SELECT `game_play_counts`.* FROM `game_play_counts` WHERE `game_play_counts`.`game_id` = 10 LIMIT 1 FOR UPDATE
BEGIN
INSERT INTO `game_play_counts` (`game_id`, ...) VALUES ('10', ...)
COMMIT

トランザクション外でFOR UPDATEが実行されて、後にトランザクション内でINSERTが走りました。気持ちだけFOR UPDATEつけといたよ感あるけどロックは取れません。

データが既にある状態

SELECT `game_play_counts`.* FROM `game_play_counts` WHERE `game_play_counts`.`game_id` = 10 LIMIT 1 FOR UPDATE

トランザクション外なので同様にロックは取れません。

検証6

明示的にトランザクション囲んだケースを試します。

ActiveRecord::Base.transcation do
  GamePlayCount.lock(true).find_or_create_by(:game_id => 11)
end

データがない状態

BEGIN
SELECT  `game_play_counts`.* FROM `game_play_counts` WHERE `game_play_counts`.`game_id` = 11 LIMIT 1 FOR UPDATE
INSERT INTO `game_play_counts` (`game_id`, ...) VALUES ('11', ...)
COMMIT

トランザクション内でFOR UPDATEしてINSERTもする挙動となりました。最初にFOR UPDATEの空打ちをしているので、ギャップロックが気になります。高い並列度でよばれたら詰まることがありそうですね。

データが既にある状態

BEGIN
SELECT  `game_play_counts`.* FROM `game_play_counts` WHERE `game_play_counts`.`game_id` = 11 LIMIT 1 FOR UPDATE
COMMIT

対象のデータがある場合はトランザクション内でFOR UPDATEをしていて、ロックは取れていました。

まとめ

今回は興味本位でfind_or_create_byを使うとどういったクエリになるか確認しました。find_or_create_byもduplicateのリスクがあるし、lockと組み合わせると初回アクセス時にギャップロックしてしまうしで、使い方によっては良くないことがあるなという感じです。エラー無く処理したいということならichirin2501先生のスライドにあるINSERT ... ON DUPLICATE KEY UPDATEを使うのも良さそうです。INSERT ... ON DUPLICATE KEY UPDATEは呼ばれる度にAUTO INCREMENT値がどんどん増えていってしまうので、BIGINTを使うなりしてOut of rangeになるリスクを避ける必要があるのでご注意ください。

またActiveRecordで気になるメソッドがあったら随時クエリをのぞいていけたならと思います。

何でエンジニアをするのか

今年が終わりそうなので、自分がエンジニアをやる理由を振り返ってみる

  • 新しい技術に触れる瞬間が楽しい
  • コード書いている時間が楽しい
  • 一つのアーキテクトを作り上げるのが楽しい
  • 作り上げたものがユーザに届くのが嬉しい
  • 生じる問題をエンジニアリングで解決するのが楽しい
  • 結果自分のスキルになって成長を感じるのが嬉しい
  • etc..

以上です。

多段SSHする際の.ssh/configの設定

多段SSHの設定いつも忘れるのでブログる。

まず設定の例から書くと

Host fumidai
  HostName hoge
  User macotasu
  Port 10022
Host target-server
  HostName fuga
  User macotasu
  Proxycommand ssh -CW %h:%p fumidai

こんな感じでsshの設定をかいて多段sshにすることができた。

よく理解していなくてハマったのがProxycommandの挙動である。 Proxycommandを使うと、Proxy先のhostからsshするようになる。 なのでtarget-serverへのsshの設定はfumidaiから見た設定を書く必要がある。 この基本的なところを理解していないと、複雑な環境設定がしているところへの 多段SSHで混乱してはまるなーってなった。(実際ハマった)

アニメイトラボに入社しました

2年8ヶ月勤めていた面白法人カヤックを退職して、アニメイトラボで働き始めました。

10月~11月に転職活動をしていて、いくつかの魅力的な企業からオファーを頂いていたのですが、アニメイトラボは設立間もない会社で地盤整えていくフェーズだったのが楽しそうだったり、以前から一緒に働いてみたいと思っていた方が在籍していたりなどのご縁もあって、入社することを決めました。

オフィスは千駄ヶ谷にあって、会社の近くに銭湯があるのが良い感じです。

前職でお世話になった方々と「次が決まったら教えてね」というお話もあったので、ご挨拶を兼ねてブログに書きました。

今後とも皆様よろしくお願いいたします。

DDLとかの検証にDummyデータをさくっと用意するSQL

前にダミーデータサクッとつくりたいわっておもって、便利ストアドプロシージャつくったのでブログにも書いておく。

gist.github.com

SELECTして結果をINSERTするところをコピペしていけば、ストアドプロシージャなんかつかわないでもいいんだろうけど、DRYなコードにしたいじゃん?

そういうことです。

NoPaste作った

丸一日かかってしまったけど、ちゃんと作ってみた

github.com

構成

  • Kossy
  • DBIx::Sunny
  • etc...

DBIx::Sunny初めて使ったけど、シュッと使えて便利だった

感想

今まで一人で一からアプリケーションを書くことは、あまりなかったから良い経験になった

それと共に、自分のPerlを使った実装力がないなーっておもってつぶやいた

地球で二番目っぽいので満足でした 😇

ISUCON5 本戦での学び

ISUCON5の本戦をchatzmersとして参戦してきました。 結果からいうと惨敗でした。

敗因としてRobert C. Pike氏の下記の言葉に全て詰まっていると思います。

推測するな、計測せよ

どういうこと?

今回使用されていたデータベースがpostgresでした。 僕たちは

  • 日常的に業務でmysqlに触れていてpostgresより詳しい
  • 本戦の予行演習で mysqlのチューニング方法を予習していた

以上の判断から最初にpostgresをmysqlに置き換えることをしました。 限られた時間しかない中、もしかしたらdbにボトルネックがないかもしれない という状況で先に移行をするという決断をしたのは判断ミスだったなと思います。

今回で言えばslowqueryをみたり、cpuの使用率をみたら今回はDBに全く 負荷がなかったことはわかっていたようなので、 予選の時にできていた計測して確実に潰していくというスタンスは本戦でも 徹底していくべきでした。

mysqlに移行して何がよくなかったのか

単純に移行に時間がかかりました。 やった作業と時間がかかったポイントとしては

  • 初期データをmysqlでいれられる状態のものに置き換える
  • 移行した際に文字化け問題に悩まされる(これは最後まで解決できなかった)
  • 移行に伴いケアレスミスを連発して、少しずつ時間を削ってしまった

以上の点で苦戦しました。

結局何をしたのか

僕達ができたこととしては、

  • dbとappのserverを分割
  • dbサーバー * 1 + appサーバー * 2の構成にする
  • dbをpostgresからmysqlにする
  • その他アプリの微修正(スコアに全く影響がなかったので割愛)

以上ができたこととなります。 複数台構成にしたので、単純にスコアが初期スコアの1000から約2倍の1900ほどの上がり successで無事11位になれました。

まとめ

結果として力不足を露呈する形となりましたが、学びは多かったです。

僕らが現職で入社した時に行った社内ISUCONではスコアは良かったもののfailに終わってしまい、 スコアが残らなかったという苦い経験をしました。 それを加味して今回は、本戦が始まる前に確実にsuccessにして終わらせよう という話をしていたのでそれは有言実行できて良かったです。 二年半前からはちょっと成長ができたのかな...?

来年は上位目指して頑張りたいとおもいます。

最後に

運営の皆様本当にお疲れ様でした! ISUCONというイベントが行われる大切さと、参加することで得られる多くの経験を知ることできました。 様々な背景をもつエンジニアの方々一つの目標に向かって競い合う機会は滅多になく、そんな有意義なイベントが 今年も開催できたのは運営の大変な努力があったおかげだと考えると、感謝の極みです!本当にありがとうございました!