非同步處理
最后更新于:2022-04-01 02:31:48
> 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](https://github.com/collectiveidea/delayed_job) 使用關聯式資料庫,非常方便安裝使用。
* [sidekiq](http://sidekiq.org/) 使用高效能的[Redis](http://redis.io/): 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](http://en.wikipedia.org/wiki/Cron)排程工具,你可以將需要執行的工作寫成一個rake指令,在主機上用crontab指令進行編輯。例如每天凌晨四點進行備份、每週一凌晨一點產生報表等等。
由於crontab的格式不是非常友善,我們可以透過[whenever](https://github.com/javan/whenever)這個Gem來編輯,這也可以搭配Capistrano做自動化部署,非常方便。
## 參考資料
* [Active Job Basics](http://edgeguides.rubyonrails.org/active_job_basics.html)
* [ActiveJob::QueueAdapters](http://api.rubyonrails.org/classes/ActiveJob/QueueAdapters.html)
* [Distributed Ruby and Rails](http://www.slideshare.net/ihower/distributed-ruby-and-rails)