发布于 2026-01-06 3 阅读
0

调查问题 Rails API 端点的性能设置 要点 设置 rack-mini-profiler 重现环境 隔离基准测试 消除 N+1 查询 进一步优化 由 Mux 呈现的 DEV 全球展示挑战赛:展示你的项目!

调查存在问题的 Rails API 端点的性能

设置弹道

设置机架迷你分析器

重现环境

隔离

基准

消除 N+1 查询

进一步优化

由 Mux 主办的 DEV 全球展示挑战赛:展示你的项目!

最近我注意到我们的一个 API 接口占用了大量资源,每次请求耗时超过 3 秒。我成功地将平均内存使用量从 85MB 降低到 7MB,并将平均请求持续时间从 3000 毫秒缩短到 150 毫秒。我将在本文中详细介绍这个过程。

部署前后的延迟图

首先,让我们安装一下要用到的工具。

设置弹道

bullet是一个很棒的工具,可以帮助您识别和纠正 N+1 查询。

我们会将其添加到 Gemfile 文件中:

group :development do
  gem 'bullet'
end
Enter fullscreen mode Exit fullscreen mode

而这对我们来说config/application.rb

  config.after_initialize do
    Bullet.enable = true
    Bullet.rails_logger = true
  end
Enter fullscreen mode Exit fullscreen mode

设置机架迷你分析器

rack-mini-profiler是一个提供数据库和内存分析的中间件。让我们先配置好它,以便更深入地了解导致问题的原因。

我们需要在 Gemfile 的数据库 gem 下方rack-mini-profiler添加`and` 和` ,并运行 `来安装它`。memory_profilerbundle

gem 'pg'
gem 'rack-mini-profiler'
gem 'memory_profiler'
Enter fullscreen mode Exit fullscreen mode

接下来,我们将把这个添加到config/initializers/mini_profiler.rb

Rack::MiniProfiler.config.authorization_mode = :allow_all
Enter fullscreen mode Exit fullscreen mode

启用 rack-mini-profiler 后,它会保存之前请求的分析输出,并在下一个加载的 HTML 页面中插入一个徽章。但我们目前使用的是纯 API 应用,所以为了看到这个徽章,我们需要提供一个 HTML 页面。

注意:如果您计划将此代码提交到您的存储库中,则需要添加某种授权authorize_request

这是我的PerformanceTestsController

class PerformanceTestsController < ActionController::Base
  before_action do
    Rack::MiniProfiler.authorize_request
  end

  def index
  end
end
Enter fullscreen mode Exit fullscreen mode

app/views/performance_tests/index.html

<body></body>
Enter fullscreen mode Exit fullscreen mode

config/routes.rb

  get '/performance_tests', to: 'performance_tests#index'
Enter fullscreen mode Exit fullscreen mode

设置完成后,如果您/performance_tests在浏览器中打开,您应该会在左上角看到这个徽章。

替代文字

重现环境

在调查生产环境性能问题时,您需要尽可能地让测试环境与生产环境保持一致。Rails 的开发模式下禁用了类缓存,因此请求完成时间可能会有很大的差异。我在本地机器上以生产模式运行了这些测试,使用的数据集与生产数据库中的数据集类似。

隔离

我们使用 Ember 作为前端框架,它会在页面加载时多次调用 API。我希望隔离出问题的 API 调用,以便尽可能快速地重复执行,直到问题解决。该端点需要身份验证标头,所以我直接使用 Chrome 的“复制为 cURL”功能,通过一条命令获取了所有需要的信息。

复制为 cURL

基准

现在环境和工具都已准备就绪,是时候动手实践,弄清楚到底发生了什么。我们讨论的端点是UsersController#index

def index
  users = User.joins(:roles).where(roles: { title: params[:roles] })          

  respond_with users, include: %w[roles groups]
end
Enter fullscreen mode Exit fullscreen mode

在开始修改之前,我们首先需要做的是用当前状态的代码进行基准测试。这样才能确保我们即将进行的修改确实能够带来改进。

rack-mini-profiler 提供了几个可以通过查询参数传递的选项pp=,但我们将使用这两个选项:pp=profile-memorypp=enable

第一个请求的资源使用量似乎总是高于后续请求,所以我总是发出两次请求,并从第二个请求中获取基准数据。

好了,我们来控制一下记忆力:

# All Users (`/users?pp=profile-memory`)
Total allocated: 60047355 bytes (744989 objects)
Total retained:  1356020 bytes (8851 objects)

Enter fullscreen mode Exit fullscreen mode

除了内存使用情况,我们还需要查看 rack-mini-profiler 的徽章,它会显示响应时间和 SQL 查询信息。我们可以通过添加pp=enable查询参数,然后/performance_tests按照上面 rack-mini-profiler 设置部分所述的方式打开它来实现这一点。

# All Users (`/users?pp=enable`)
Total Duration: 7795ms
SQL Duration: 373ms
SQL Queries: 1139

Enter fullscreen mode Exit fullscreen mode

😱

这太糟糕了!我们来解决这个问题。

消除 N+1 查询

每个请求执行的 SQL 查询数量表明我们遇到了 N+1 查询问题,所以我们先来看一下这个问题。我们做一个改动,然后再次运行基准测试。

让我们修改一下joins(:roles)includes(:roles, :groups)以便我们的角色和组能够预先加载。

def index
  users = User.includes(:roles, :groups).where(roles: { title: params[:roles] })

  respond_with users, include: %w[roles groups]
end
Enter fullscreen mode Exit fullscreen mode

以下是基准测试结果includes

Total allocated: 436705757 bytes (4119179 objects)
Total retained:  4646110 bytes (33480 objects)

Total Duration: 7209ms
SQL Duration: 355ms
SQL Queries: 1130
Enter fullscreen mode Exit fullscreen mode

过早加载所有这些角色实际上导致内存使用量增加了 7 倍!虽然执行时间和查询次数有所减少,但这显然不是我们希望的解决方案。

让我们使用 rack-mini-profiler HTML 徽章来查看正在执行的查询。

机架式迷你轮廓仪

当我展开1130 sql链接时,我看到了很多类似的条目:

app/serializers/user_serializer.rb:72:in `employee_id'
app/controllers/v1/users_controller.rb:53:in `index'
app/controllers/application_controller.rb:48:in `set_current_attrs'
SELECT  "employees".* FROM "employees" WHERE "employees"."user_id" = $1 LIMIT $2; 
Enter fullscreen mode Exit fullscreen mode

我认为问题主要出在序列化器上,所以我们来看看里面发生了什么。

class UserSerializer < ActiveModel::Serializer
  attributes :id,
             :email,
             :first_name,
             :last_name,
             :last_login_at,
             :employee_id

  has_one :employee
  has_many :assignments
  has_many :direct_roles
  has_many :roles, through: :assignments
  has_many :group_assignments
  has_many :groups, through: :group_assignments

  def employee_id
    object.employee&.id
  end
end
Enter fullscreen mode Exit fullscreen mode

现在我们找到问题的关键了!每次User序列化对象时,我们都会针对这里列出的每个关联发出查询。我们可以尝试使用 `equals` 来预加载这些关联,但如果我们根本includes不需要这些关联来执行操作呢?index

让我们快速看一下show旁边发生的index事情UsersController

  def index
    users = User.includes(:roles, :groups).where(roles: { title: params[:roles] })

    respond_with users, include: %w[roles groups]
  end

  def show
    respond_with @user, include: %i[roles roles_tags assignments groups group_assignments groups.roles]
  end

Enter fullscreen mode Exit fullscreen mode

show它们通过同一个UserSerializer类进行序列化。看起来这些关联关系被添加到序列化器中,以便包含在show端点中。

目前,我只对 `<string>` 进行优化index,因此show任何其他使用该 `<string>` 的操作UserSerializer都不需要受到影响。我认为未来的方向是创建一个index专门针对 `<string>` 的序列化器,并使用稀疏字段集——我们只会在响应中包含所需的数据。

# app/controllers/users_controller.rb
def index
  users = User.includes(:roles, :groups).where(roles: { title: params[:roles] })

  respond_with users, include: [:roles, :groups], each_serializer: Users::Index::UserSerializer
end

# app/serializers/users/index/user_serializer.rb
class Users::Index::UserSerializer < ActiveModel::Serializer
  attributes :id,
             :email,
             :first_name,
             :last_name,
             :last_login_at,
             :employee_id

  has_many :roles, through: :assignments
  has_many :groups, through: :group_assignments

  def employee_id
    object.employee&.id
  end
end
Enter fullscreen mode Exit fullscreen mode

我删除了除我们想要侧载的关联之外的所有关联,roles现在groups让我们检查一下数据。

Total allocated: 242932074 bytes (2392253 objects)
Total retained:  2511484 bytes (18008 objects)

Total Duration: 3650ms
SQL Duration: 202ms
SQL Queries: 571
Enter fullscreen mode Exit fullscreen mode

我们取得了第一个重大进展!此时,我检查了前端应用程序中调用此端点的位置,并确认我们不再需要那些被移除的关联。

但是,总共有 571 个查询。让我们检查 Bullet 输出到 Rails 日志中的信息,看看是否检测到任何 N+1 查询。

USE eager loading detected
  User => [:employee]
  Add to your finder: :includes => [:employee]
Call stack
  /Users/mculp/sf/cs/app/serializers/users/index/user_serializer.rb:66:in `employee_id'

USE eager loading detected
  User => [:group_assignments]
  Add to your finder: :includes => [:group_assignments]
Call stack
  /Users/mculp/sf/cs/app/models/user.rb:229:in `roles'

USE eager loading detected
  User => [:assignments]
  Add to your finder: :includes => [:assignments]
Call stack
  /Users/mculp/sf/cs/app/controllers/v1/users_controller.rb:49:in `index'
Enter fullscreen mode Exit fullscreen mode

是的!让我们预加载employee,,group_assignmentsassignments

  def index
    users = User
              .includes(:roles, :groups, :employee, :group_assignments, :assignments)
              .where(roles: { title: params[:roles] })

    respond_with users, each_serializer: Users::Index::UserSerializer, include: [:roles, :groups]
  end
Enter fullscreen mode Exit fullscreen mode

数字:

Total allocated: 80137296 bytes (825840 objects)
Total retained:  761444 bytes (5371 objects)

Total Duration: 1580ms
SQL Duration: 58ms
SQL Queries: 124
Enter fullscreen mode Exit fullscreen mode

又一个重大改进。Bullet 不再在 Rails 日志中发出大量错误信息了。

检查 rack-mini-profiler 后,我发现我们仍然有 N+1:

app/models/user.rb:476:in `last_login_at'
app/controllers/v1/users_controller.rb:49:in `index'
app/controllers/application_controller.rb:48:in `set_current_attrs'
SELECT  "authentication_tokens".* FROM "authentication_tokens" WHERE "authentication_tokens"."user_id" = $1 AND "authentication_tokens"."on_behalf" = $2 ORDER BY "authentication_tokens"."id" DESC LIMIT $3;
Enter fullscreen mode Exit fullscreen mode

以下是代码last_login_at

  def last_login_at
    token = authentication_tokens.where(on_behalf: false).last
    token&.last_used_at
  end
Enter fullscreen mode Exit fullscreen mode

这个问题比较棘手。我们不能简单地使用预加载,authentication_tokens因为这个方法每次调用都会发出一个查询。

但是,我们可以创建一个新的作用域关联并进行预加载。

  # app/models/user.rb
  has_one :last_login_authentication_token,
    -> { where(on_behalf: false) },
    class_name: 'AuthenticationToken'

  def last_login_at
    last_login_authentication_token&.last_used_at
  end
Enter fullscreen mode Exit fullscreen mode
  # app/controllers/users_controller.rb
  def index
    eager_load_associations = [
      :roles, :groups, :employee, :group_assignments,
      :assignments, :last_login_authentication_token
    ]

    users = User.includes(eager_load_associations).where(roles: { title: params[:roles] })

    respond_with users, each_serializer: Users::Index::UserSerializer, include: [:roles, :groups]
  end

Enter fullscreen mode Exit fullscreen mode

这样应该可以解决我们最后一个 N+1 问题了。让我们确认一下:

Total allocated: 69663419 bytes (872929 objects)
Total retained:  302956 bytes (1818 objects)

Total Duration: 1250ms
SQL Duration: 26ms
SQL Queries: 12
Enter fullscreen mode Exit fullscreen mode

从 SQL 的角度来看,一切顺利!其余时间都花在了实例化和序列化对象上。

让我们来看看我们可以在这方面做出哪些改进。

进一步优化

fast_jsonapi

fast_jsonapi这是 Netflix 工程团队的一项杰作,它承诺比 Netflix 的序列化速度快 25 倍ActiveModel::Serializers

我们希望确保该库的每一次更改,在当前 1000 条记录的基准测试中,序列化时间至少比 Active Model Serializers 快 25 倍。

听起来好得难以置信,但试一试也无妨!

  # app/controllers/users_controller.rb
  def index
    eager_load_associations = [
      :roles, :groups, :employee, :group_assignments,
      :assignments, :last_login_authentication_token
    ]

    users = User.includes(eager_load_associations).where(roles: { title: params[:roles] })

    respond_with users, 
      each_serializer: Fast::Users::Index::UserSerializer,
      include: [:roles, :groups]
  end
Enter fullscreen mode Exit fullscreen mode
# app/serializers/fast/users/index/user_serializer.rb
class Fast::Users::Index::UserSerializer
  include FastJsonapi::ObjectSerializer

  attributes :id,
             :email,
             :first_name,
             :last_name,
             :employee_id,
             :last_login_at

  has_many :roles, through: :assignments, serializer: Fast::Users::Index::RoleSerializer
  has_many :groups, through: :group_assignments, serializer: Fast::Users::Index::GroupSerializer

  attribute :employee_id do |object|
    object.employee&.id
  end
end
Enter fullscreen mode Exit fullscreen mode
# app/serializers/fast/users/index/role_serializer.rb
class Fast::Users::Index::RoleSerializer
  include FastJsonapi::ObjectSerializer

  attributes :id, :title, :description
end
Enter fullscreen mode Exit fullscreen mode
# app/serializers/fast/users/index/group_serializer.rb
class Fast::Users::Index::GroupSerializer
  include FastJsonapi::ObjectSerializer

  attributes :title, :description, :archived
end
Enter fullscreen mode Exit fullscreen mode

数字:

Total allocated: 54130985 bytes (698850 objects)
Total retained:  189166 bytes (935 objects)

Total Duration: 707ms
SQL Duration: 21ms
SQL Queries: 6
Enter fullscreen mode Exit fullscreen mode

虽然不是25倍,但这仍然是一个相当可观的提升。我们会继续保持这个水平。

缓存

fast_jsonapi它还内置了对象缓存功能,cache_key底层使用 Rails 的缓存失效机制。我认为它很适合我们的使用场景,所以我们来试试看。

我们使用 Redis 作为缓存存储,它已在以下位置设置config/environments/production.rb

  if ENV['REDIS_URL']
    config.cache_store = :redis_store, ENV['REDIS_URL'], { expires_in: 12.hours }
  end
Enter fullscreen mode Exit fullscreen mode

现在我们只需要cache_options在序列化器中添加这行代码来缓存每个User对象:

# app/serializers/fast/users/index/user_serializer.rb
class Fast::Users::Index::UserSerializer
  include FastJsonapi::ObjectSerializer

  cache_options enabled: true, cache_length: 12.hours

  attributes :id,
             :email,
             :first_name,
             :last_name,
             :employee_id,
             :last_login_at

  has_many :roles, through: :assignments, serializer: Fast::Users::Index::RoleSerializer
  has_many :groups, through: :group_assignments, serializer: Fast::Users::Index::GroupSerializer

  attribute :employee_id do |object|
    object.employee&.id
  end
end
Enter fullscreen mode Exit fullscreen mode

现在,让我们来计算一下数据。

Total allocated: 10239567 bytes (92500 objects)
Total retained:  413751 bytes (2609 objects)

Total Duration: 165ms
SQL Duration: 17ms
SQL Queries: 6
Enter fullscreen mode Exit fullscreen mode

🥳🎉

文章来源:https://dev.to/mculp/investigating-the-performance-of-a-problematic-rails-api-endpoint-3a65