How to speed up a very frequently made query using raw SQL and without ORM?

Viewed 245

I have an API endpoint that accounts for a little less than half of the average response time (on averaging taking about 514 ms, yikes). The endpoint simply returns some statistics about stored data scoped to particular time periods, such as this week, last week, this month, and so on...

There are a number of ways that we could reduce it's impact, like getting the clients to hit it less and with more particular queries such as only querying for "this week" when only that data is used. Here we focus on what can be done at the database-level first. In our current implementation we generate this data for all "time scopes" on-the-fly and the number of queries is enormous and made multiple times per second. No caching is used, but maybe there is a way to use Rails's cache_key, or the low-level Rails.cache?

The current implementation look something like this:

class FooSummaries
  include SummaryStructs

  def self.generate_for(user)
    @user = user
    summaries = Struct::Summaries.new
    TimeScope::TIME_SCOPES.each do |scope|
      foos = user.foos.by_scope(scope.to_sym)
      summary = Struct::Summary.new
      # e.g: summaries.last_week = build_summary(foos)
      summaries.send("#{scope}=", build_summary(summary, foos))
    end
    summaries
  end

  private_class_method

  def self.build_summary(summary, foos)
    summary.all_quuz = @user.foos_count
    summary.all_quux = all_quux(foos)
    summary.quuw = quuw(foos).to_f

    %w[foo bar baz qux].product(
      %w[quux quuz corge]
    ).each do |a, b|
      # e.g: summary.foo_quux = quux(foos, "foo")
      summary.send("#{a.downcase}_#{b}=", send(b, foos, a) || 0)
    end

    summary
  end

  def self.all_quuz(foos)
    foos.count
  end

  def self.all_quux(foos)
    foos.sum(:quux)
  end

  def self.quuw(foos)
    foos.quuwable.total_quuw
  end

  def self.corge(foos, foo_type)
    return if foos.count.zero?
    count = self.quuz(foos, foo_type) || 0
    count.to_f / foos.count
  end

  def self.quux(foos, foo_type)
    case foo_type
    when "foo"
      foos.where(foo: true).sum(:quux)
    when "bar"
      foos.bar.where(foo: false).sum(:quux)
    when "baz"
      foos.baz.where(foo: false).sum(:quux)
    when "qux"
      foos.qux.sum(:quux)
    end
  end

  def self.quuz(foos, foo_type)
    case trip_type
    when "foo"
      foos.where(foo: true).count
    when "bar"
      foos.bar.where(foo: false).count
    when "baz"
      foos.baz.where(foo: false).count
    when "qux"
      foos.qux.count
    end
  end
end

To avoid making changes to the model, or creating migrations to create a table to store this data (both of which may be valid and better solutions) I decided maybe it would be easier to construct one large sql query that will be executed at once in the hopes that it will be faster to build the query string and execute it without the overhead of active record set up and tear down of SQL queries.

The new approach looks something like this, it is horrifying to me and I know there must be a more elegant way:

class FooSummaries
  include SummaryStructs

  def self.generate_for(user)
    results = ActiveRecord::Base.connection.execute(build_query_for(user))
    results.each do |result|
       # build up summary struct from query results
    end
  end

  def self.build_query_for(user)
    TimeScope::TIME_SCOPES.map do |scope|
      time_scope = TimeScope.new(scope)
      %w[foo bar baz qux].map do |foo_type|
        %[
          select
            '#{scope}_#{foo_type}',
            sum(quux) as quux,
            count(*), as quuz,
            round(100.0 * (count(*) / #{user.foos_count.to_f}), 3) as corge
          from
            "foos"
          where
            "foo"."user_id" = #{user.id}
            and "foos"."foo_type" = '#{foo_type.humanize}'
            and "foos"."end_time" between '#{time_scope.from}' AND '#{time_scope.to}'
            and "foos"."foo" = '#{foo_type == 'foo' ? 't' : 'f'}'
          union
        ]
      end
    end.join.reverse.sub("union".reverse, "").reverse
  end
end

The funny way of replacing the last occurance of union also horrifies but it seems to work. There must be a beter way as there are probably many things that are wrong with the above implementation(s). It may be helpful to note that I use Postgresql and have no problem with writing queries that are not portable to other DB's. Any advice is truly appreciated!

Thanks for reading!

Update: I found a solution that works for me and sped up the endpoint that uses this service object by 500% ! Essentially the idea is, instead of building a query string and then executing it for each set of parameters, we create a prepared statement using prepare followed by an exec_prepared passing in parameters to the query. Since this query is made many times over this is a useful optmization because, as per the documentation:

A prepared statement is a server-side object that can be used to optimize performance. When the PREPARE statement is executed, the specified statement is parsed, analyzed, and rewritten. When an EXECUTE command is subsequently issued, the prepared statement is planned and executed. This division of labor avoids repetitive parse analysis work, while allowing the execution plan to depend on the specific parameter values supplied.

We prepare the query like so:

  def prepare_query!
    ActiveRecord::Base.transaction do
      connection.prepare("foos_summary",
                         %[with scoped_foos as (
                            select
                              *
                            from
                              "foos"
                            where
                              "foos"."user_id" = $3
                               and ("foos"."end_time" between $4 and $5)
                           )
                           select
                             $1::text as scope,
                             $2::text as foo_type,
                             sum(quux)::float as quux,
                             sum(eggs + bacon + ham)::float as food,
                             count(*) as count,
                             round((sum(quux) / nullif(
                               (select
                                  sum(quux)
                                from
                                  scoped_foos), 0))::numeric,
                             5)::float as quuz
                           from
                             scoped_foos
                           where
                             (case $6
                               when 'Baz'
                                 then (baz = 't')
                               else
                                 (baz = 'f' and foo_type = $6)
                               end
                             )
                           ])
    end

You can see in this query we use a common table expression for more readability and to avoid writing the same select query twice over. Then we execute the query, passing in the parameters we need:

  def connection
    @connection ||= ActiveRecord::Base.connection.raw_connection
  end

  def query_results
    prepare_query! unless query_already_prepared?

    @results ||= TimeScope::TIME_SCOPES.map do |scope|
      time_scope = TimeScope.new(scope)
      %w[bacon eggs ham spam].map do |foo_type|
        connection.exec_prepared("foos_summary",
                                 [scope,
                                  foo_type,
                                  @user.id,
                                  time_scope.from,
                                  time_scope.to,
                                  foo_type.humanize])
      end
    end
  end

Where query_already_prepared? is a simple check in the prepared statements table maintained by postgres:

  def query_already_prepared?
    connection.exec(%(select
                        name
                      from
                        pg_prepared_statements
                      where name = 'foos_summary')).count.positive?
  end

A nice solution, I thought! Hopefully the technique illustrated here will help others with a similar problems.

0 Answers
Related