Nic Lin's Blog

喜歡在地上滾的工程師

[Rails] 如何高效的確定資料是否存在?

一個很常的議題是討論 Ruby on Rails 很慢,但其實追根究底起來,一般網站慢的問題痛點都在於讀取 Database 的 response time 太久,讓人有很慢的錯覺,其實不管用哪套框架,如果在讀資料的時候慢,是會對使用體驗非常差的。

你的 application 慢在哪?

因為 Rails 有良好的 ORM 可以使用,所以我們大可不必自己手寫 SQL Query,但也因為這樣衍生出表面上看起來 code 很好讀懂,卻在讀取較大資料時明顯的發現有效能並不好的問題。

造成讀取數據慢的幾項常見原因:

  • N+1 queries
  • 一次拉出太多不必要的資料,塞爆 Database memory
  • 沒有建立 index, 或是用了錯誤的 index
  • 沒有適當的 cache 資料,凡事伸手向 Database 拿

這些問題在一開始資料還小的時候(小於百萬),都可以靠 database cpu 硬幹過去,可能不會有太明顯的感受,但 client 端口變多、資料增長速度快的情況下,可能光 api 就被 call 掛了,這樣就會收到滿滿的客訴抱怨了

從 Rails 的語句下手

確認資料是否存在的四種常見語句

  • present?
  • empty?
  • any?
  • exists?

這裡先講結論,建議從 ActiveRecord 中拉資料出來確認是否存在時,偏好使用 exists?

為什麼?我們舉例來看

Order.where(created_at: 7.days.ago..1.day.ago).done.present?

# SELECT "orders".* FROM "orders" WHERE ("orders"."created_at" BETWEEN
# '2017-02-22 21:22:27.133402' AND '2017-02-28 21:22:27.133529') AND
# "orders"."aasm_state" = $1  [["aasm_state", "done"]]

Order.where(:created_at => 7.days.ago..1.day.ago).done.any?

# SELECT COUNT(*) FROM "orders" WHERE ("orders"."created_at" BETWEEN
# '2017-02-22 21:22:16.885942' AND '2017-02-28 21:22:16.886077') AND
# "orders"."aasm_state" = $1  [["aasm_state", "done"]]

Order.where(:created_at => 7.days.ago..1.day.ago).done.empty?

# SELECT COUNT(*) FROM "orders" WHERE ("orders"."created_at" BETWEEN
# '2017-02-22 21:22:16.885942' AND '2017-02-28 21:22:16.886077') AND
# "orders"."aasm_state" = $1  [["aasm_state", "done"]]

Order.where(:created_at => 7.days.ago..1.day.ago).done.exists?

# SELECT 1 AS one FROM "builds" WHERE ("builds"."created_at" BETWEEN
# '2017-02-22 21:23:04.066301' AND '2017-02-28 21:23:04.066443') AND
# "orders"."aasm_state" = $1 LIMIT 1  [["aasm_state", "done"]]

從第一個例子來看,我們較常使用的 .present? 效率是非常低的,他等於是讀取了所有相關的資料,只為了確定是否存在?

如果在這個時間區間內的資料量更大,會更明顯感受到這是非常低效的作法,更具體的說明是,我為了要確認這個圖書館有沒有書,我把所有的書都拿出來,然後跟你說,有書。

那麼第二個和第三個的作法,用了 .any?.empty?,從 SQL query 來看,用了 COUNT(*),在 Rails 中,有針對 COUNT 進行優化,所以會將這個資料放進內存中,整體上來說,並沒有什麼副作用或是效能太慢的問題。

則最後一種方法 .exists?,則是最佳解,這應該是你要檢查資料是否存在的首選項,因為從 SQL query 來看,他用了 SELECT 1 LIMIT 1 的方法,這是非常快的,就算你的資料表數據量非常龐大。

具體來說,我要確認圖書館有沒有書,只要找到一本,我就確定有書。

速度分析參考

present? =>  2892.7 ms
any?     =>   400.9 ms
empty?   =>   403.9 ms
exists   =>     1.1 ms

如果說 200ms 的 response time 是可以接受的程度,那麼這個好習慣可以讓你盡可能的避免寫出有效能問題的 code

所以我該總是使用 exists? 嗎

基本上這是一個很好的 default 寫法,在確認資料存在的情況下。

但有些例外並不適用,例如前幾行 code 已經將相關數據緩存進來的時候,那麼你再次調用 .exists?,其實就會再多一條 SQL query

舉例來說

user = User.find_by(name: "NicLin")

user.orders.load    # eager loads all the builds into the association cache

user.orders.any?    # no database hit
user.orders.exists? # hits the database

# 如果你改變了 cache, 將會再次 hits the database
user.orders(true).any?    # hits the database
user.orders(true).exists? # hits the database

結論

建議使用 .exists?, 依據不同情況改善

參考來源

Faster Rails: How to Check if a Record Exists

comments powered by Disqus