Rails is simple. Rails is beautiful. Rails is magical. But when the amount of users starts to grow, it’s time to go back to the basics and refactor your code to improve the performance.
This article is NOT about architecture (you can read a great article about that here. This article is about some very basic tips and tools that all Rails developers should use when trying to improve their application performance. Things like:
- Caching: Rails provides three different kinds of caching: page, action and fragment caching. Page and action caching do basically the same: cache all the action. The main difference is that action caching gets to the Rails stack and it processes the controller
before_filter
hooks. This is mandatory if you are using authentication, for example.
Anyway, action and page caching are deprecated in Rails 4. The real thing here is fragment caching: to cache any part of a process, including part of view rendering. This is specially useful in expensive calculations that don’t change in a long time, like periodical statistics. In Playfulbet, I cache using memcached and dalli.
For a better understanding of Rails caching, I would recommend to read the episode Obie Fernandez wrote about it in The Rails 3 Way. Rails documentation is fine, too.
- Eager loading: Eager loading is a way to fetch all the associated objects with a single DB query.
An example:
user.bets.map { |bet| bet.option.odds }
This code will result in 1 + n calls to the DB, where n is the number of bets that the user has.
user.bets.includes(:option).map { |bet| bet.option.odds }
This code will result in 2 calls to the DB.
You also can include a deep hierarchy of associations using a hash: user.bets.includes(:option => {:market => :event})
. Eager loading also accepts a :conditions
option.
There is another issue with eager loading: that it is not lazy. That means that the query will get executed in the controller, not in the view: and that could lead to unnecessary queries (for example, if part of the view is cached).
You can learn more about it in this Railscast episode.
- Materialize: If you need to materialize attributes, just do it. As a rule of thumb, accessing the DB is “cheaper” than processing in Ruby (it’s a general rule, there are plenty of exceptions). Reading an attribute is better than calling a method that calculates it. Pretty obvious, no?
The “materialize” rule has another meaning: to avoid recurring queries materializing the value in a column. A basic example of this philosophy is counter-cache, a DB column automatically updated by Rails that saves the total amount of a multiple association. Like this, it completely removes the burden of a recurring SELECT COUNT(*)
.
There is a third meaning: to materialize in a model an attribute of another model that the former is using recurringly. You need to be EXTRA cautious about that -somehow it’s a violation of basic OOP principles- and only use it when it will make a big difference in performance. For example, when there are many classes between the accessor and the accessed.
- Use
pluck
: If you are using queries that return a large amount of records, that’s a big burden for your server’s memory. That’s specially not optimal if you only need a single column of the record.
pluck
, a method added to Rails in 3.2, solves that problem. Essentially, it selects a single column in your SQL query. I will give you a real example from Playfulbet code. In the first case, we make a Bet
query loading thousands of User
objects, which are quite heavy:
user_ids = User.where("created_at < ?", Time.now.beginning_of_month).map(&:id)
Bet.where(:user_id => user_ids)
On the other hand, this loads only an array of integers for making the same query:
user_ids = User.where("created_at < ?", Time.now.beginning_of_month).pluck(:id)
Bet.where(:user_id => user_ids)
- Learn how your 3rd party code works: In this case, prevention is better than cure: choose very carefully your gems. Go for those who have a full test suite and GitHub activity. And even those have to be learnt, as the use you are doing of them can be optimized for your own purposes.
A simple example: the very popular will_paginate
performs a SELECT COUNT
query to calculate the number of pages. This query can be avoided if you materialize its result and pass it directly to the paginate method in the :total_entries
option. In this case, I paginate the bets of a user without querying the total amount and using counter-cache:
@bets = @user.bets.paginate(:page => params[:page], :total_entries => @user.bets_count)