Tips for Writing Fast Rails: Part 1
Rails is a powerful framework. You can write a lot of features in a short period of time. In the process you can easily write code that performs poorly.
At OmbuLabs we like to maintain Ruby on Rails applications . In the process of maintaining them, adding features and fixing bugs, we like to improve the code and its performance (because we are good boy scouts !)
Here are some tips based on our experience.
Prefer where
instead of select
When you are performing a lot of calculations, you should load as little as possible into memory. Always prefer a SQL query vs. an object’s method call.
With ActiveRecord , it’s easy to forget which methods load ActiveRecord::Base objects into memory and which perform a simple query instead.
The bigger the table, the slower the object load. If you have a table with 80 columns (sigh!), loading each record will take a lot longer than a table with 3 columns. So, you must avoid object loads as much as possible. Only load objects into memory when you really need them.
For example:
shop_ids.map do |shop_id|
products.select { |p| p.shop_id == shop_id }.first
end
select
will load all the products
into memory and then compare the ids.
This will be slower than just using where
.
This will be much faster:
shop_ids.map do |shop_id|
products.where(shop_id: shop_id).first
end
Because it will perform the query and only after the query returns it will load the objects into memory.
Prefer pluck
instead of map
If you are interested in only a few values per row, you should use pluck
instead of map
.
For example:
Order.where(number: 'R545612547').map &:id
# Order Load (5.0ms) SELECT `orders`.* FROM `orders` WHERE `orders`.`number` = 'R545612547' ORDER BY orders.created_at DESC
=> [1]
As with select
, map
will load the order
into memory and it will get the
id
attribute.
Using pluck
will be faster, because it doesn’t need to load an entire object
into memory.
So this will be much faster:
Order.where(number: 'R545612547').pluck :id
# SQL (0.8ms) SELECT `orders`.`id` FROM `orders` WHERE `orders`.`number` = 'R545612547' ORDER BY orders.created_at DESC
=> [1]
For this particular case, pluck
is six times faster than map
.
Avoid N+1 Queries
There are some rare cases where you want an N+1 query in your application. For instance, when you are using a Russian Doll Caching strategy, it’s a good idea. (full explanation in this interview with DHH : https://youtu.be/ktZLpjCanvg?t=4m27s )
If you are not using this caching strategy, you should get rid of all your N+1 query problems by including the tables that you need before running the query.
For example:
Order.where("created_at > ?", 1.hour.ago)
.find_each do |order|
puts order.ship_address.try(:firstname)
end
Order Load (7866.0ms) SELECT `orders`.* FROM `orders` WHERE (created_at > '2016-10-05 18:05:48') ORDER BY `orders`.`id` ASC LIMIT 1000
Address::ShipAddress Load (0.5ms) SELECT `addresses`.* FROM `addresses` WHERE `addresses`.`type` IN ('Address::ShipAddress') AND `addresses`.`order_id` = 2619178 LIMIT 1
Address::ShipAddress Load (0.5ms) SELECT `addresses`.* FROM `addresses` WHERE `addresses`.`type` IN ('Address::ShipAddress') AND `addresses`.`order_id` = 2619179 LIMIT 1
Address::ShipAddress Load (0.5ms) SELECT `addresses`.* FROM `addresses` WHERE `addresses`.`type` IN ('Address::ShipAddress') AND `addresses`.`order_id` = 2619180 LIMIT 1
Address::ShipAddress Load (0.5ms) SELECT `addresses`.* FROM `addresses` WHERE `addresses`.`type` IN ('Address::ShipAddress') AND `addresses`.`order_id` = 2619181 LIMIT 1
Address::ShipAddress Load (0.5ms) SELECT `addresses`.* FROM `addresses` WHERE `addresses`.`type` IN ('Address::ShipAddress') AND `addresses`.`order_id` = 2619182 LIMIT 1
# ... to N
This code will perform one query on the orders
table and N queries on
the addresses
table.
This will be faster:
Order.eager_load(:ship_address)
.where("orders.created_at > ?", 1.hour.ago)
.find_each do |order|
puts order.ship_address.try(:firstname)
end
This code will perform only one query. eager_load
will perform a query
with a LEFT OUTER JOIN
with the associated table (addresses
).
If you use Order.includes(:ship_address)
it will perform two
queries one for the orders
table and another one for the addresses
table.
To understand the difference between includes
and eager_load
, read
this article about Rails 4 preloading .
A good way to find N+1 queries is using bullet to get warnings as you develop your application.
Conclusion
Sometimes it takes only a few lines of code to improve the performance of your Rails application. Before you start refactoring your code to perform faster, you should make sure that you have coverage for the methods that you’re improving.
If you found this article interesting, check out Erik Michaels-Ober’s talk about Writing Fast Ruby : https://www.youtube.com/watch?v=fGFM_UrSp70 . It has great tips for improving performance in your Ruby application or library.
And, if you need help improving the performance of your Rails application, get in touch ! We are constantly looking for new projects and opportunities to improve your Rails performance.