SQL can improve the performance and efficiency of your Ruby on Rails application. No need for heavy technologies. No need for switching to another programming language or framework.
SQL is a powerful tool that can take your Ruby on Rails expertise to the next level. Nowadays many technologies claim to replace SQL or deem Ruby on Rails as “too slow”. But it remains a popular and efficient choice for many web projects. This article will explore the importance of SQL for Ruby on Rails experts. It will show why it’s a must-have skill in today’s development landscape.
Why Ruby on Rails can be slow
Imagine the following class that’s used in a Rails controller to feed data to a page in your application. This class may look familiar to you. I’ve encountered similar classes in real-world applications:
class ServicesStatsQuery attr_accessor :user def initialize(user) self.user = user end def call projects_full = [] projects_empty = [] Service.where(user: user, status: "approved", active: true) .order(category_id: :asc).each do |service| ratings_average = 0 ratings_count = 0 ratings_total = 0 Rating.where(reviewee: user).each do |rating| project = Project.find(rating.project_id) if project.category_id == service.category_id ratings_count += 1 ratings_total += rating.rating end end ratings_average = (ratings_total / ratings_count.to_f).round(1).to_s if ratings_count != 0 && ratings_total != 0 completed_projects_count = Project.where(vendor: user, status: "Complete", category_id: service.category_id).size service_hash = { category_id: service.category_id, category_name: Category.find(Category.find(service.category_id).parent_id).name, subcategory_name: Category.find(service.category_id).name, completed_projects_count: completed_projects_count, ratings_count: ratings_count, ratings_average: ratings_average, } resolved_hash = service_hash.transform_keys{ |k| k.to_s.camelize(:lower) } if completed_projects_count > 0 projects_full.push(resolved_hash) else projects_empty.push(resolved_hash) end end projects_full.sort_by!{ |k| -k["completedProjectsCount"] } projects_full.push(*projects_empty) projects_full end end
Your application is running in production and serving the needs of hundreds of users. You’ve noticed that the related page is becoming slower and slower with each passing day. This can be a frustrating problem to solve. It’s natural to feel tempted to consider drastic measures. You can think of switching to a faster programming language. Or you might plan to use heavy technology.
Before taking such drastic steps take a step back and understand the root cause of the issue.
There are a few things that could be contributing to the slowness of this code:
N+1 queries: The code is making a separate database query for each rating. Then it gets the associated project. That can become slow if there are a large number of ratings.
Nested
each
loops: The code is using this method to iterate over the Service. For each service in the nested loop, it iterates through all related Rating records. That can also be slow if there are many records. Instead, it would be better to use SQL joins to fetch all the data in a single query.Redundant queries: The code is calling
Category.find
many times. Instead, better to fetch all the category data using a single query. And then use it to populate theservice_hash
object.
Optimizing this code will need a combination of SQL query optimization. That should cut unnecessary database queries and improve the performance of the code.
How to analyze slow Ruby On Rails code
There are plenty of tools out there, like bullet, benchmark-ips, or any others.
But before taking these radical steps use what you already have. Open logs (use command tail -f log/development.log
for local). Observe what happens there while you open the slow page in your browser. If you spot many repeating lines like this one you have an N+1 problem:
00:03:22 web.1 | Rating Load (1.6ms) SELECT "ratings".* FROM "ratings" WHERE "ratings"."user_id" = $1 [["user_id", 15]]
Pay attention to the phrase “Rating Load” as it serves as an indicator. If you notice “CACHE Rating Load”, there’s no need to worry. The object is already in memory, retrieved from there without accessing the database. This suggests that eager loading is already working.
Another tool is to visually inspect the code. You can feel discomfort and uncertainty when trying to understand how the code pieces relate to each other. Yet, this is not related to performance. The most crucial aspect is that the code contains a nested loop, which indicates that its complexity is squared. As more objects are iterated, a squared function grows rapidly:
Sorry for getting into the math, but it’s important to understand that your code should ideally grow linearly or at least logarithmically. This is the best way to ensure that your code’s performance remains optimal for many years to come.
If you are unsure about what all of that means, now is a good time to start learning about the theory of algorithms and data structures.
Use SQL to improve Rails application performance
To fetch all the required data in a single query while performing the necessary aggregation, sorting, and grouping, consider using this SQL query:
select s.category_id as "categoryId", pc.name as "categoryName", c.name as "subcategoryName", count(p.id) as "completedProjectsCount", r.count as "ratingsCount", r.avg as "ratingsAverage" from services s join categories c on c.id = s.category_id join categories pc on pc.id = c.parent_id left join lateral ( select count(r.id), cast(round(coalesce(avg(r.rating), 0), 1) as text) as avg from ratings r join projects rp on rp.id = r.project_id and s.category_id = rp.category_id where r.reviewee_id = :user_id ) r on true left join projects p on p.vendor_id = :user_id and p.status = 'Complete' and p.category_id = s.category_id where s.status = 'approved' and s.active and s.user_id = :user_id group by s.category_id, pc.name, c.name, r.count, r.avg order by count(p.id) desc, s.category_id asc
One way to test this query is to replace the placeholder :user_id
with an actual user id. Then, we can open the DB console and paste the query to see if it produces the expected results.
Make sure the query is successful and returns the desired results. Then we can replace the slow Ruby code with this efficient SQL query:
class ServicesStatsQuery SQL = <<~SQL select s.category_id as "categoryId", pc.name as "categoryName", c.name as "subcategoryName", count(p.id) as "completedProjectsCount", r.count as "ratingsCount", r.avg as "ratingsAverage" from services s join categories c on c.id = s.category_id join categories pc on pc.id = c.parent_id left join lateral ( select count(r.id), cast(round(coalesce(avg(r.rating), 0), 1) as text) as avg from ratings r join projects rp on rp.id = r.project_id and s.category_id = rp.category_id where r.reviewee_id = :user_id ) r on true left join projects p on p.vendor_id = :user_id and p.status = 'Complete' and p.category_id = s.category_id where s.status = 'approved' and s.active and s.user_id = :user_id group by s.category_id, pc.name, c.name, r.count, r.avg order by count(p.id) desc, s.category_id asc SQL attr_accessor :user def initialize(user) self.user = user end def call sql = ActiveRecord::Base.sanitize_sql_array([SQL, user_id: user.id]) ActiveRecord::Base.connection.execute(sql).to_a end end
This SQL query can provide fast results for most Ruby On Rails applications. Although, it assumes that the DB has appropriate indexes defined.
Native SQL is much faster than the pure Ruby on Rails code
It’s time to perform measurements. First, install the benchmark-ips gem. Then you can prepare the following code and save it in a file. For example, t.rb
, in the root directory of your Rails application:
require 'benchmark/ips' user = User.find(10) service = ServicesStatsQuery.new(user) Benchmark.ips do |x| x.report("ruby") { service.call_ruby } x.report("sql") { service.call_sql } x.compare! end
We change the ServicesStatsQuery
a bit to have the two methods defined call_ruby
and call_sql
instead of one method call
:
class ServicesStatsQuery SQL = <<~SQL select s.category_id as "categoryId", pc.name as "categoryName", c.name as "subcategoryName", count(p.id) as "completedProjectsCount", r.count as "ratingsCount", r.avg as "ratingsAverage" from services s join categories c on c.id = s.category_id join categories pc on pc.id = c.parent_id left join lateral ( select count(r.id), cast(round(coalesce(avg(r.rating), 0), 1) as text) as avg from ratings r join projects rp on rp.id = r.project_id and s.category_id = rp.category_id where r.reviewee_id = :user_id ) r on true left join projects p on p.vendor_id = :user_id and p.status = 'Complete' and p.category_id = s.category_id where s.status = 'approved' and s.active and s.user_id = :user_id group by s.category_id, pc.name, c.name, r.count, r.avg order by count(p.id) desc, s.category_id asc SQL attr_accessor :user def initialize(user) self.user = user end def call_ruby projects_full = [] projects_empty = [] Service.where(user: user, status: "approved", active: true).order(category_id: :asc).each do |service| ratings_average = 0 ratings_count = 0 ratings_total = 0 Rating.where(reviewee: user).each do |rating| project = Project.find(rating.project_id) if project.category_id == service.category_id ratings_count += 1 ratings_total += rating.rating end end ratings_average = (ratings_total / ratings_count.to_f).round(1).to_s if ratings_count != 0 && ratings_total != 0 completed_projects_count = Project.where(vendor: user, status: "Complete", category_id: service.category_id).size service_hash = { category_id: service.category_id, category_name: Category.find(Category.find(service.category_id).parent_id).name, subcategory_name: Category.find(service.category_id).name, completed_projects_count: completed_projects_count, ratings_count: ratings_count, ratings_average: ratings_average, } resolved_hash = service_hash.transform_keys{ |k| k.to_s.camelize(:lower) } if completed_projects_count > 0 projects_full.push(resolved_hash) else projects_empty.push(resolved_hash) end end projects_full.sort_by!{ |k| -k["completedProjectsCount"] } projects_full.push(*projects_empty) projects_full end def call_sql sql = ActiveRecord::Base.sanitize_sql_array([SQL, user_id: user.id]) ActiveRecord::Base.connection.execute(sql).to_a end end
Run it with the command: rails runner t.rb
and see the results:
Warming up -------------------------------------- ruby 1.000 i/100ms sql 48.000 i/100ms Calculating ------------------------------------- ruby 5.658 (± 0.0%) i/s - 29.000 in 5.147460s sql 451.144 (± 8.6%) i/s - 2.256k in 5.042193s Comparison: sql: 451.1 i/s ruby: 5.7 i/s - 79.74x slower
Performance boost of almost 80 times! The slow-loading page that took over 30 seconds now loads within 300ms. This significant improvement is not the programming language or framework merit.
Demo: Faster SQL in Ruby on Rails App
I have developed a demo application using both Ruby and SQL versions and made it available on GitHub as an open source project. This means that you have the opportunity to experiment with the code and run benchmarks on your own.
Determine whether to use the measurement tools or not
You can analyze performance issues and measure optimization results without fancy tools. benchmark-ips is for demonstration purposes. The best tools are reading the code, and understanding it. Then analyze its complexity. Finally, use SQL knowledge. These tools are enough. Nothing can replace them. Well, except in the distant future, ChatGPT may offer more help.
There are always plenty of built-in tools by your hand. Learn how to use them. For example, moving further, the written SQL can be slow. You will have to check why. Use explain analyze for that. This is a built-in tool in all modern SQL databases.
Why Ruby on Rails expert should know SQL
Ruby on Rails is a powerful web application framework. It provides developers with high-level abstractions and conventions. That allows to build web applications quickly and efficiently. But, despite its powerful abstractions, Ruby on Rails experts need to know SQL.
SQL allows Ruby on Rails experts to write efficient and optimized database queries. That can improve the performance of a web application. By understanding SQL, a Ruby on Rails expert can design the database schema and data access layer. If that’s done well, the application maximizes performance, scalability, and maintainability.
SQL is a widely-used and powerful language. People use it beyond interacting with the database. Ruby on Rails experts use SQL to perform complex calculations and data transformations. They can generate reports that are challenging to achieve using Ruby code alone.
SQL allows Ruby on Rails experts to have more control over their web applications. They write more efficient and optimized code. They make better data-driven decisions.
How can a Ruby on Rails expert learn SQL
Nowadays, there are plenty of courses and books available on the subject. So it’s hard to recommend specific resources. You can choose whatever you like and suits your learning style.
But, if you are like me and prefer hands-on learning, you might find the following resource helpful - https://pgexercises.com/.
You might also find this article I wrote previously interesting - Financial plan on PostgreSQL.
Make your Ruby on Rails app 80x faster with SQL was originally published by Andrei Kaleshka at WideFix Blog on March 30, 2023.
Top comments (0)