调查存在问题的 Rails API 端点的性能
设置弹道
设置机架迷你分析器
重现环境
隔离
基准
消除 N+1 查询
进一步优化
由 Mux 主办的 DEV 全球展示挑战赛:展示你的项目!
最近我注意到我们的一个 API 接口占用了大量资源,每次请求耗时超过 3 秒。我成功地将平均内存使用量从 85MB 降低到 7MB,并将平均请求持续时间从 3000 毫秒缩短到 150 毫秒。我将在本文中详细介绍这个过程。
首先,让我们安装一下要用到的工具。
设置弹道
bullet是一个很棒的工具,可以帮助您识别和纠正 N+1 查询。
我们会将其添加到 Gemfile 文件中:
group :development do
gem 'bullet'
end
而这对我们来说config/application.rb:
config.after_initialize do
Bullet.enable = true
Bullet.rails_logger = true
end
设置机架迷你分析器
rack-mini-profiler是一个提供数据库和内存分析的中间件。让我们先配置好它,以便更深入地了解导致问题的原因。
我们需要在 Gemfile 的数据库 gem 下方rack-mini-profiler添加`and` 和` ,并运行 `来安装它`。memory_profilerbundle
gem 'pg'
gem 'rack-mini-profiler'
gem 'memory_profiler'
接下来,我们将把这个添加到config/initializers/mini_profiler.rb:
Rack::MiniProfiler.config.authorization_mode = :allow_all
启用 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
app/views/performance_tests/index.html:
<body></body>
config/routes.rb:
get '/performance_tests', to: 'performance_tests#index'
设置完成后,如果您/performance_tests在浏览器中打开,您应该会在左上角看到这个徽章。
重现环境
在调查生产环境性能问题时,您需要尽可能地让测试环境与生产环境保持一致。Rails 的开发模式下禁用了类缓存,因此请求完成时间可能会有很大的差异。我在本地机器上以生产模式运行了这些测试,使用的数据集与生产数据库中的数据集类似。
隔离
我们使用 Ember 作为前端框架,它会在页面加载时多次调用 API。我希望隔离出问题的 API 调用,以便尽可能快速地重复执行,直到问题解决。该端点需要身份验证标头,所以我直接使用 Chrome 的“复制为 cURL”功能,通过一条命令获取了所有需要的信息。
基准
现在环境和工具都已准备就绪,是时候动手实践,弄清楚到底发生了什么。我们讨论的端点是UsersController#index:
def index
users = User.joins(:roles).where(roles: { title: params[:roles] })
respond_with users, include: %w[roles groups]
end
在开始修改之前,我们首先需要做的是用当前状态的代码进行基准测试。这样才能确保我们即将进行的修改确实能够带来改进。
rack-mini-profiler 提供了几个可以通过查询参数传递的选项pp=,但我们将使用这两个选项:pp=profile-memory和pp=enable。
第一个请求的资源使用量似乎总是高于后续请求,所以我总是发出两次请求,并从第二个请求中获取基准数据。
好了,我们来控制一下记忆力:
# All Users (`/users?pp=profile-memory`)
Total allocated: 60047355 bytes (744989 objects)
Total retained: 1356020 bytes (8851 objects)
除了内存使用情况,我们还需要查看 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
😱
这太糟糕了!我们来解决这个问题。
消除 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
以下是基准测试结果includes:
Total allocated: 436705757 bytes (4119179 objects)
Total retained: 4646110 bytes (33480 objects)
Total Duration: 7209ms
SQL Duration: 355ms
SQL Queries: 1130
过早加载所有这些角色实际上导致内存使用量增加了 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;
我认为问题主要出在序列化器上,所以我们来看看里面发生了什么。
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
现在我们找到问题的关键了!每次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
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
我删除了除我们想要侧载的关联之外的所有关联,roles现在groups让我们检查一下数据。
Total allocated: 242932074 bytes (2392253 objects)
Total retained: 2511484 bytes (18008 objects)
Total Duration: 3650ms
SQL Duration: 202ms
SQL Queries: 571
我们取得了第一个重大进展!此时,我检查了前端应用程序中调用此端点的位置,并确认我们不再需要那些被移除的关联。
但是,总共有 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'
是的!让我们预加载employee,,group_assignments和assignments。
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
数字:
Total allocated: 80137296 bytes (825840 objects)
Total retained: 761444 bytes (5371 objects)
Total Duration: 1580ms
SQL Duration: 58ms
SQL Queries: 124
又一个重大改进。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;
以下是代码last_login_at:
def last_login_at
token = authentication_tokens.where(on_behalf: false).last
token&.last_used_at
end
这个问题比较棘手。我们不能简单地使用预加载,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
# 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
这样应该可以解决我们最后一个 N+1 问题了。让我们确认一下:
Total allocated: 69663419 bytes (872929 objects)
Total retained: 302956 bytes (1818 objects)
Total Duration: 1250ms
SQL Duration: 26ms
SQL Queries: 12
从 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
# 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
# app/serializers/fast/users/index/role_serializer.rb
class Fast::Users::Index::RoleSerializer
include FastJsonapi::ObjectSerializer
attributes :id, :title, :description
end
# app/serializers/fast/users/index/group_serializer.rb
class Fast::Users::Index::GroupSerializer
include FastJsonapi::ObjectSerializer
attributes :title, :description, :archived
end
数字:
Total allocated: 54130985 bytes (698850 objects)
Total retained: 189166 bytes (935 objects)
Total Duration: 707ms
SQL Duration: 21ms
SQL Queries: 6
虽然不是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
现在我们只需要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
现在,让我们来计算一下数据。
Total allocated: 10239567 bytes (92500 objects)
Total retained: 413751 bytes (2609 objects)
Total Duration: 165ms
SQL Duration: 17ms
SQL Queries: 6
🥳🎉
文章来源:https://dev.to/mculp/investigating-the-performance-of-a-problematic-rails-api-endpoint-3a65



