[翻訳]ShopifyにおけるRuby on Railsで速いコードを書く方法

How to Write Fast Code in Ruby on Rails

Teruhisa Fukumoto
Jan 21 · 14 min read

こちらの記事は翻訳記事となります。
原著者の許諾を得て翻訳・公開しております。


はじめに

Shopifyでは、ほとんどのプロジェクトの開発フレームワークにRuby on Railsを使用しています。

RailsとRubyはともにパフォーマンスに対するスティグマ(偏見)が存在します。 多くの個人や企業が、Rails以外での解決方法を探しています。

しかし一方で、私たちShopifyではRuby on Railsを採用して、毎分何百万ものリクエスト(requests per minute = RPM)を処理することに成功しています。

ShopifyのRuby on Railsでの成功要因は、とにかく高速に動作するコードを書くことです。

では、どのように高速なRailsコードを書くのでしょうか?

それは、あなたが解決したい問題の背景によって異なります。 Active RecordやRails、Rubyでより高速なコードを書くための方法について説明します。

Active Recordのパフォーマンス

Active RecordはRailsのデフォルトORMです。 SQLを生成および実行し、データベースと通信するために使用されます。

Active Recordには、大量のデータをうまく処理できてないやり方がたくさん存在してしまっています。

今回は、 クエリを高速に保つための方法を紹介します。

Active Recordはクエリを遅延評価します。 したがって、高速なクエリを発行するには、クエリが”いつ”実行されるかを知る必要があります。

モデル検索メソッドや計算、およびアソシエーションのメソッドは、すべてのクエリを評価されるようにします。

コードがpostcommentsを追加し、それをデータベースに自動的に保存しています。 しかし、このコードがすぐにINSERTを実行し、commentsが追加されたpostを保存するわけではありません。

この種の落とし穴は、ドキュメントを読み経験を積むことで見つけやすくなります。

必要なものだけをSELECTすることで、効率的なクエリを発行することができます。

Active RecordはデフォルトでSELECT *を使用し、SQLで検索したすべてのカラムを選択します。selectpluckを活用して、selectステートメントを制御できます

ここでは、blogのテーブルですべてのIDを選択しています。

selectはActive RecordのRelationオブジェクト(クエリメソッドをチェーン化したもの)を返すのに対し、pluckは生データの配列を返すことに注意してください。

リクエストの有効期間内に同じSQLを複数実行した場合、Active Recordはデータベースに対して1回だけクエリを実行します。

このクエリキャッシュは、冗長なSQL実行を防ぐ手段の1つです。

実際には以下のように動作します。

同じパラメーターを使用する後続のblogのSELECTが、キャッシュからロードされる例です。

クエリキャッシュは便利ですが、それに依存することはお勧めできません。 クエリキャッシュが保存されるのはメモリであり、永続的なものではないからです。

キャッシュは無効にできてしまうため、コードがリクエストの内外で実行される場合は常に効率的とは限りません。

indexが張られていないカラムの検索は避けてください。テーブルを余計にフルスキャンすることにつながります。

データが大規模な場合、クエリはタイムアウトし障害を引き起こす可能性があります。 これは、クエリの効率に直接影響するデータベースのベストプラクティスです。

クエリする必要があるカラムには、indexを張ることで問題を解決できます。気をつけなければならないのは、その方法です。

データベースは多くの場合、indexを張るときにテーブルへの書き込みをロックします。 これは言い換えれば、データ量が膨大なテーブルへの書き込みを長時間にわたってブロックできてしまう、ということです。

ShopifyではLarge Hadron Migrator(LHM)と呼ばれるツールを使用して、規模の大きいテーブルでの、このようなスケーリング移行の問題を解決します。

PostgresおよびMySQLの最新バージョンでは、indexの同時作成をサポートする機能もあります。

Railsのパフォーマンス

Active Recordから一度離れてみましょう。

Active SupportやActive Job、Action Packなど、Railsには他にも多くのコンポーネントがあります。そこで、Ruby on Railsで高速なコードを書くためのベストプラクティスをご紹介します。

処理をこれ以上高速にできない部分があれは、キャッシュすることをお勧めします。

複雑なViewのコンパイルや外部APIを叩くとき、特に実行結果があまり頻繁に変更されないものに関しては、キャッシュの恩恵を大きく受けることができます。

効果的なキャッシュを構築するためには、”keyの命名””有効期限”が重要です。

最初のブロックでは、すべてのplan_nameを無期限、またはkeyが削除されるまでキャッシュします。

2番目のブロックでは、特定のblogに関連するすべてのpostsのJSONをキャッシュします。 別のblogのコンテキスト、または新しいpostsblogに追加されたときに、キャッシュキーがどのように変化するか注目してください。

最後のブロックでは、承認されたcommentcomment_countをキャッシュしています。 keyは最初の取得後5分ごとに、自動的に削除されるようにしています。

先ほどの項目では、キャッシュすることを推奨しました。

しかし、キャッシュできない操作もあります。メールの配信やwebhookの送信、ログインなどの操作はアプリケーションのユーザーに悪用される可能性があります。

基本的に、キャッシュできない高コストな操作はしっかりと絞って行う必要があります。

Railsにはデフォルトでスロットルメカニズムがありません。 rack-attackrack-throttleなどのgemを活用すれば、不要なリクエストを抑えられます

以下はrack-attackの例です。

このsnipetは、特定のIPから/admin / sign_inへのPOSTリクエストを15分間で10回に制限しています。 必要であれば、Railsアプリ内のstackをさらに絞るなどのソリューションを構築することもできます。

rackベースでの絞り込みは、Railsアプリの中心部にリクエストが到達する前に、悪意あるリクエストを弾くことができるため、とても人気です。

開発者として、リクエスト=レスポンス プロトコルにおいて最も意識すべきなのはスピードです。 ユーザー体験を向上させることは、とても重要だからです。

では、複雑でかつ長時間実行する必要がある処理に対しては、どのような解決手段があるでしょうか?

Jobを使用すると、Redisが支援するキューイングシステムを通じて、別のプロセスで作業を遅延して実行できます。 データセットのエクスポートやサブスクリプションのアクティブ化、または決済処理などは、実務で役に立つとても良い例です。

これは、CSVエクスポートのJobを作成する簡単な例です。 Active Jobは、SidekiqResqueなどのキューバックエンドのRailsプラグインです。

Rubyのエコシステムには、プロジェクトで使用できる素晴らしいライブラリがたくさんあります。 しかし、あなたのプロジェクトには少し多すぎるかもしれません。

プロジェクトが成熟するにつれて、依存関係は負債になり得ます

あらゆる依存関係のために、プロジェクトにはコードが追加されます。 これにより、起動時間が遅くなり、メモリ使用量は増加します。

プロジェクトの依存関係を認識し、それらを最小化することを意識すれば、高いスピードを長期的に維持することができます。

たとえば、モノリシックなShopifyのコア部分には、500近い数のgemがあります。 今年、gemの使用状況を可視化し、不要なgemを可能な限り削除しました。

未使用なものや技術的負債に繋がるレガシーなgemの削除はもちろん、依存関係の管理サービス(Dependabotなど)も使用しています。

Rubyのパフォーマンス

フレームワークは、記述されている言語と同じ速度であるはずです。Railsの場合はRubyです。

そこで、パフォーマンスの良いRubyコードを記述するためのヒントを次に示します。

このセクションは、RubyKaigi 2019でのJeremy Evansのパフォーマンスに関する基調講演に触発され書いたものになります。

プログラムの実行時に、その構造を動的に変更させることは強力な機能です。 Rubyのような非常に動的な言語では、メタプログラミングに掛かるパフォーマンスのコストは無視できません

例として次のメソッド定義を見てみましょう。

どれもRubyでメソッドを定義する一般的な方法です。

最初のdefを使用する方法が最も一般的でしょう。2番目はdefine_methodを使用することで、メタプログラムされたメソッドを定義する方法です。 3番目はclass_evalを使用して、実行時に文字列をソースコードとして評価します(defを使用してメソッドを定義します)。

上記は、これら3つのメソッド定義の速度を、benchmark-ips gemで測定した結果です。

ベンチマークの下半分、Rubyが「5秒でメソッドを実行できる回数」の部分に注目しましょう。 通常のdefメソッドでは1,090万回、define_methodメソッドで770万回、class_eval def定義メソッドで1,030万回実行された、という結果です。

これはほんの一例ですが、メソッドを定義する方法によって、パフォーマンスに明確な違いがあると言えます。

それでは次に、メソッドの呼び出しのパフォーマンスも見てみましょう。

これは、objという名前のオブジェクトで、invokeメソッドとmethod_missingメソッドをシンプルに定義することができます。 次に、メタプログラム化されたsendメソッドを使用して、最後にmethod_missingを介して通常どおりにinvokeメソッドを呼び出してみます。

結果の通り、sendまたはmethod_missingで呼び出されたメソッドは、通常のメソッド呼び出しよりもはるかに遅いです。

些細な違いに見えるかもしれませんが、大規模なコードベースで再帰的に何度も呼び出されると、アプリケーションのパフォーマンスに影響してきます。

経験則としては、メタプログラミングは控えめにして、不必要に遅延するのを防ぐのが良いと思います。

O(n)とO(1)は異なる動きをします。 O(n)はサイズに合わせて時間を調整する操作であり、O(1)はサイズに関係なく一定の操作になります。

以下の例を考えてみましょう。

O(n)とO(1)の違いは、ハッシュと配列の値をマッチングするときに明らかになります。 配列では、繰り返される可能性のあるデータが追加されるすべての要素で増えていきますが、ハッシュの参照ではサイズに関係なく常に一定です。

より大規模なデータで、コードがどのように拡張されるのかを考えることが重要だと分かります。

多くの開発言語でメモリ管理は難しいテーマであり、もちろんRubyも例外ではありません。 基本的に、割り当てるオブジェクトが多いほど、プログラムが消費するメモリは多くなります。

高水準言語は通常、ガベージコレクションを実装して未使用のオブジェクトの削除を自動化し、開発者体験をより簡単なものにします。

メモリ管理のもう1つの側面は、オブジェクトの可変性です。

たとえば、2つの配列を結合する必要がある場合、新しい配列を割り当てる方法と、既存の配列を変更する方法があります。どちらの方がよりメモリ効率が良いと思いますか?

一般的に、割り当ては少ない方が良いです。 Rubyistは、この手の動的なメソッドを「危険なもの」だと思いがちです。

Rubyの危険なメソッドは、多くの場合(常にではありませんが)感嘆符(!)で終わります。

上記のコードは、シンボルの配列を割り当てます。

最初のuniq呼び出しは、すべての冗長シンボルを削除した新しい配列を割り当てて返します。 2番目のuniq!呼び出しは、レシーバを直接変更して冗長なシンボルを削除して返します。

不適切に割り当てを行うと、危険な副作用をもたらすかもしれません。

ベストプラクティスは、ローカルな突然変異はうまく活用しつつ、グローバルには起こさないようにすることです。

階層化された抽象化によるコードの間接化は、祝福でもあり呪いでもあります。 パフォーマンスの面では、ほとんどいつも呪いですが…

Railsに統合されたWebアプリケーションフレームワークであるMerbのモットーは、「コードは書かないに越したことはない(原文:”No code is faster than no code.”)」というものです。

これは「複雑なレイヤーは、追加すればするほど遅くなる」と解釈できます。 これは必ずしもパフォーマンスの最適化に当てはまるわけではありません。リファクタリングする際に覚えておくと良いでしょう。

必要な間接化の例は、Webサービスと通信するORMのActive Resourceが挙げられます。

開発者はパフォーマンスを向上させる目的では、Active Resourceを使用しません。リクエストとレスポンスを自分で開発することは、とても難しい(というか、エラーが発生しやすい)から使用するのです。


さいごに

ソフトウェア開発には常にトレードオフが付いて回ります。

開発者として、技術的負債やコードのスタイル、その正確性をジャグリングのように回しながら意思決定するのは非常に難しいことです。 パフォーマンスの最適化が最優先されない理由はここにあります。

Shopifyでは、パフォーマンスや速度を機能として扱っています。 機能はユーザー体験の向上とサーバ代の請求書の減らすことに貢献しますが、開発者のアプリケーションの仕事をする幸せより優先されるべきものではありません。 コードを楽しくそして高速に保つことを心がけましょう!

Teruhisa Fukumoto

Written by

ex.Zeals VPoE → MedPeer Engineer, Ruby&Python, Gotanda.rb&js Organizer

Welcome to a place where words matter. On Medium, smart voices and original ideas take center stage - with no ads in sight. Watch
Follow all the topics you care about, and we’ll deliver the best stories for you to your homepage and inbox. Explore
Get unlimited access to the best stories on Medium — and support writers while you’re at it. Just $5/month. Upgrade