Ruby 测试性能优化建议 - 续篇

lanzhiheng · 2021年02月04日 · 最后由 lanzhiheng 回复于 2021年02月18日 · 321 次阅读

今天就来盘点一下这几个新的测试优化策略,在它们的帮助下我一度把测试总时间降低到一分钟左右。原文发布于 https://www.lanzhiheng.com/posts/suggestion-of-test-optimization-next


这篇文章是作为《测试性能优化建议》的补充,在社区朋友和同事的指导下尝试了一些新的方案。今天就来盘点一下这几个新的测试优化策略,在它们的帮助下我一度把测试总时间降低到一分钟左右。

选择合适的 ActiveJob::QueueAdapters

一般来说我们的业务都会有用到 ActiveJob,用来让一些耗时较长的任务能够以异步的方式去执行,而不会堵塞主任务的流程。线上任务我们一般都会采用SideKiq来完成这个事情,然而测试呢?

如果你像笔者一样用的是这个配置

# huiliu-web/config/environments/test.rb

Rails.application.configure do
  ...
  config.active_job.queue_adapter = :async
end

那你就要注意安全了,这个是 Rails 原生提供的队列适配器ActiveJob::QueueAdapters::AsyncAdapter。以下是对它的说明

The adapter uses a Concurrent Ruby thread pool to schedule and execute jobs. Since jobs share a single thread pool, long-running jobs will block short-lived jobs. Fine for dev/test; bad for production.

稍微可能要注意一下的是异步不代表不占用资源,它们只是延后运行,依旧会占用系统资源,而且依赖的还是 Ruby 的并发库(目前应该并发不怎么高吧,需要另外研究)。由于笔者的测试里面有大量的异步 Jobs(ActiveStorage 里面销毁图片其实都是通过异步的方式),这种 Jobs 一旦多起来,而且在测试过程中与测试代码交替运行,占用掉了一部分系统资源,测试就慢了。笔者无意中把它改成

Rails.application.configure do
  ...
  config.active_job.queue_adapter = :sidekiq
end

发现测试时间减少了一分钟,现在在 Macbook 上大概是 1 分 40 秒左右能跑完 800 个测试。

test-in-1m-40s.png

所以当你的测试很慢的时候,可以检查一下上面这个配置,因为有很多 Jobs 其实并不需要在测试过程中去完成,直接丢弃掉就好。把适配器配置改成 sidekiq,其实就是把一些异步的 Jobs 放在 Redis 里面,等启动了 Sidekiq 服务之后才会去执行。不过在测试环境用:sidekiq做配置其实是一个错误示范。

为什么测试里面不应该用 Sidekiq?

测试以轻量级为准,依赖的服务越少越好,这也是官方提供config.active_job.queue_adapter = :async这个配置的原因。然而用这个的时候要想一下,那些异步任务对于测试来说真的重要吗?其实大部分都没那么重要,那些 Jobs 其实可有可无,因为测试过程中就会去清理数据库的数据,那些遗留的静态文件,可以通过别的手段去删除。因此这些 Jobs 完全丢弃掉也没事

我现在用的配置是

Rails.application.configure do
  config.active_job.queue_adapter = :test
end

就没有任何 Jobs 产生了,可以看看这篇文章

另外就是如果是本地启动 sidekiq 服务,那么很可能会出现你的 Jobs 都会运行在 development 环境,而不是期望的 test 环境。因为我们一般是这样去启动 sidekiq 的

bundle exec sidekiq

这其实 sidekiq 相关的 job 都会访问开发环境的数据库,而不是测试环境的,你会发现一堆莫名且秒的错误。另外,类似此类代码的判断语句就没用了

module Sms
  def to(mobile, content)
    return if Rails.env.test?

    res = @conn.post('/v1/send.json') do |req|
      req.body = {
        mobile: mobile,
        message: normalize(content)
      }
    end
  end
end

代码会往下执行,调用螺丝帽的短信接口发送大量短信.....

自己写的测试导致的短信轰炸。

笔者因此造成了公司百来块钱的损失。如果你的测试不依赖于 Jobs 所带来的副作用,干脆关掉的好config.active_job.queue_adapter = :test

非要用sidekiq还得想办法区分两个环境(用不同的 redis 连接),不然可能会相互干扰。而且边跑测试 sidekiq 这边的 jobs 也会被执行,虽说比用:async好一些,但还不如用:test直接把大量没用的 Jobs 抛弃掉的好。

这个优化带来的效益还是挺高的,终于能够在 2 分钟内跑完测试了。所以篇幅稍微长了一些,也顺便分享一下自己遇到的坑吧。

并行测试

引入parallel_tests让用例可以并行运行的做法也带来了一些收益。不过这个方法我还没完全引入项目中,因为 Redis 数据库在并行环境下工作不太好协调,偶然会出现个别用例运行不通过的情况。不过它确实,确实,确实有些帮助,引入它之后总算能在 50~60s 左右跑完所有测试了。结果如下

> RAILS_ENV=test bundle exec rake parallel:spec
8 processes for 58 specs, ~ 7 specs per process

test-in-parallel.png

8 processes for 58 specs, ~ 7 specs per process,因为我电脑是 8 核,所以用 8 个进程来跑。不过这个也是可以自己去配置的,它的原理大概是

ParallelTests splits tests into even groups (by number of lines or runtime) and runs each group in a single process with its own database.

为了更好的数据隔离,它会根据我们设定的进程数来创建对应数量的数据库,然后把测试分割到不同的数据库里面跑。理论上应该会有所加速。对于一般的 ActiveRecord 数据是没多大问题,不过 Redis 中的数据就很容易发生冲突了。不同的 CPU 核心如果访问同一个 Redis 链接,你写我读,很容易数据就紊乱了,目前还在调试阶段,等哪天解决了 Redis 的访问问题再集成到项目中去。

PS: 不过在初始化的时候要创建 8 个数据库,在 CI 流程里面是不是也是一种耗时操作?

Test Prof

TestProf is a collection of different tools to analyze your test suite performance.

大佬的推荐下,我给项目引入了test-prof。主要用于检测测试的性能瓶颈。作用跟 RSpec 的--profile有点像。从文档来看,它检测的东西会更底层且更细粒度一些。除此之外它还能够集成ruby-prof以及stackprof这些常见的优化器,能够根据不同的指令检测不同 “部件” 的性能问题。比如你可以检测出数据库访问方面最慢的那些测试

EVENT_PROF=sql.active_record rspec

[TEST PROF INFO] EventProf results for sql.active_record

Total time: 00:05.045
Total events: 6322

Top 5 slowest suites (by time):

也可以单独检测数据构造得最慢的那些测试

FDOC=1 rspec

[TEST PROF INFO] FactoryDoctor report

Total (potentially) bad examples: 2
Total wasted time: 00:13.165

据说已经有不少的开源项目都受益于它,大大降低了测试时间

test-prof-for-opensource.png

可惜的是,受限于笔者自身的能力以及工作时间,暂时无法立即在项目中深度使用该工具,也只能先写个简单的介绍了。毕竟一项项地去检测并优化还是比较耗时间的,无法一蹴而就。只好先在这里埋下伏笔,等日后深度使用之后再写一篇相关的使用心得了。

尾声

这篇文章是对《测试性能优化建议》的简要补充,优化这种东西如果真要做的话是永无止境的。从目前的结果来看,关闭异步任务对性能影响比较大,能够提速一分多钟(1m40s 左右跑完)。引入并行之后再能进一步把速度缩短到 1 分钟之内(50s 左右),只不过目前还没找到好的方法来解决依赖 Redis 的相关测试在并行场景下偶发的问题。

目前比较期待的是test-prof在性能优化方面的表现。哎,无奈时间有限,要深度使用估计要等过年之后了,希望到时候也能够发一篇总结性的文章吧。

为什么测试里面不应该用 Sidekiq?

测试也应判断异步任务是否被调用了。

sidekiq 的 wiki 里有一页就是专门说如何做测试的。直接根据 wiki 操作就好了,也不用真正意义上执行任务,只是判断任务是否入队列即可。 https://github.com/mperham/sidekiq/wiki/Testing

测试是什么?

每种 queue_adapter 背后都有一个 queue,而且每个 spec 都在共享同一个 queue。

test_adapter => 一个实例变量 array
sidekiq_adapter => redis
async_adapter => 也是内存中的queue,线程池的线程会不断从queue取job,执行

每跑完一个 spec 应该还要清空每个 adapter 背后的 queue,否则有可能发生这种情况:一个 spec 其实没有 enqueue job,但是 have_been_enqueued 断言还是能通过,因为上一个 spec 也 enqueue 了同一个 job

ActiveJob::QueueAdapters::TestAdapter 暴露了它的 queue:enqueued_jobs, performed_jobs. 所以我们可以轻易清空 queue

# spec/support/active_job_helper.rb
RSpec.configure do |config|
  config.around(:each) do |example|
    example.run
  ensure
    queue_adapter.enqueued_jobs.clear
    queue_adapter.performed_jobs.clear
  end

  def queue_adapter
    ActiveJob::Base.queue_adapter
  end

end

在用 test_adapter 时,默认所有 job 都不会被执行,只会被 enqueue。但是有时候我们期望一个异步 job 可以执行,得到一些副作用。我们可以加一个 inline 功能

RSpec.configure do |config|
  config.around(:each, :inline_jobs) do |example|
    origin_perform_enqueued_jobs = queue_adapter.perform_enqueued_jobs
    origin_perform_enqueued_at_jobs = queue_adapter.perform_enqueued_at_jobs
    queue_adapter.perform_enqueued_jobs = true
    queue_adapter.perform_enqueued_at_jobs = true
    example.run
  ensure
    queue_adapter.perform_enqueued_jobs = origin_perform_enqueued_jobs
    queue_adapter.perform_enqueued_at_jobs = origin_perform_enqueued_at_jobs
  end

  def queue_adapter
    ActiveJob::Base.queue_adapter
  end
ThxFly 回复

了解,我理解应该就是按需检测一下 Worker.jobs.size就好了。感谢指点。🙏

需要 登录 后方可回复, 如果你还没有账号请 注册新账号