牛骨文教育服务平台(让学习变的简单)

Nine people can’t make a baby in a month.  — Fred Brooks, The Mythical Man-Month 作者

通常一個HTTP request/response的工作時間理想上都要在 200ms 以內完成,要不然 web server 通常也會限制在 30 秒以內,不然就會出現 timeout 錯誤。一個運算時間太久的 request 除了讓使用者感受不佳之外,對於伺服器效能上的影響也很巨大。使用者可能等待不及重新reload,於是相同的任務又在重頭執行一遍。一個 request 長時間佔據了一個 rails process,也讓其他 reuqest 無法進行處理。

常見的非同步任務包括:

  • 寄出E-mail
  • 匯入大筆資料
  • 匯出大筆資料
  • 呼叫第三方服務

對於這種任務,非同步的處理就非常重要。非同步的意思是讓任務的處理在背景完成,而不在瀏覽器的HTTP request/response流程中完成,等完成之後再通知使用者即可。

Rails 4.2之後內建了一個統一的處理介面叫做ActiveJob,就像ActiveRecord透過不同的Adapter可以支援不同資料庫,ActiveJob也支援了非常多種不同的排程工具,最多人使用的有:

  • delayed_job 使用關聯式資料庫,非常方便安裝使用。
  • sidekiq 使用高效能的Redis: key-value store來儲存要執行的任務,並且善用多執行序來增加效能,號稱可以以一個process抵上20個delayed_job的processes。

我們來用sidekiq舉例,本機Mac需要安裝Redis:

brew install redis

而在Ubuntu伺服器上可以透過sudo apt-get install redis-server進行安裝。 在Gemfile新增gem "sidekiq"然後bundle

預設的ActiveJob Adapter是:inline,也就是沒有非同步。我們必須編輯config/environments/production.rb切換成改用:sidekiq如下:

# be sure to have the adapter gem in your Gemfile and follow the adapter specific
# installation and deployment instructions
config.active_job.queue_adapter = :sidekiq

接著編輯config/application.rb加入一行設定讓Rails可以找到job檔案:

config.eager_load_paths += %W( #{config.root}/app/jobs )

接下來要建立一個Worker非常容易,執行rails g job hard_worker會產生app/jobs/hard_worker_job.rb這個檔案,

# app/jobs/hard_worker_job.rb
class HardWorkerJob < ActiveJob::Base
  queue_as :default

  def perform(*args)
    # Do something later
  end
end

接著在需要非同步的地方使用以下程式,就會將工作排程進sidekiq:

HardWorkerJob.perform_later

最後,我們需要啟動另外的sidekiq process來執行這些非同步的任務:

bundle exec sidekiq

sidekiq提供了一個Web UI介面讓我們可以觀察目前有哪些任務在執行。如果搭配Devise的話,需要在Gemfile加上gem "sinatra", ">= 1.3.0", :require => nil,以及routes.rb加入:

require "sidekiq/web"
authenticate :user, lambda { |u| u.admin? } do
  mount Sidekiq::Web => "/sidekiq"
end

Action Mailer

我們在「ActionMailer: E-mail發送」那一章介紹過deliver_later方法,如果我們有設定好ActiveJob,那Rails就會用非同步寄信。

GlobalID

因為非同步的工作是另一個process在執行,在從Rails這端指派工作的時候,設計的參數會避免將物件進行序列化(serialize)動作,以免另一個process無法順利deserialize回來,例如這中間剛好程式碼有變更,造成類別的定義不同,更別提從enqueue到真正執行之間會有時間差,資料內容可能改變了。因此參數最好是簡單的基本型態,例如字串、數字、陣列或雜湊等等。例如你想要傳遞一個使用者物件當作參數,我們不傳整個user物件,而是傳user id而已:

HardWorkerJob.perform_later(user.id)

接著在worker那端設計成根據user id從資料庫再拉出來:

  def perform(user_id)
    user = User.find(user_id)
  end

事實上,由於這是非常常見的設計,Rails甚至自動會針對ActiveRecord物件進行轉換,例如你寫成

HardWorkerJob.perform_later(user)

那在Rails內部會自動幫你把user物件轉成一個GlobalID字串放進queue裡,讓以下的job可以直接運作:

  def perform(user)
    # user 就是 activerecord 物件了,Rails 自動幫你 query 資料庫轉換回來
  end

不過如果你面對的不是ActiveRecord物件,就要自行注意了。

固定排程

另一種執行非同步任務的方式,則是透過作業系統的Cron排程工具,你可以將需要執行的工作寫成一個rake指令,在主機上用crontab指令進行編輯。例如每天凌晨四點進行備份、每週一凌晨一點產生報表等等。

由於crontab的格式不是非常友善,我們可以透過whenever這個Gem來編輯,這也可以搭配Capistrano做自動化部署,非常方便。

參考資料