Day22 — Ruby on Rails 中的 Race Condition

被端走的小菜
7 min readSep 26, 2020

--

前言

Race Condition 可翻譯成「競爭條件」,在中文版 Wiki 上看不懂的話,可看英文版 Wiki 的描述,會比較清楚,以下為白話文翻譯:

同筆資料同時被 2 thread 以上操作,導致結果的不正確

常見情境可能有:

  1. 搶票系統、搶購限量商品時 (ex: 限量 100 張票,卻賣了 101 張)
  2. 使用者送出資料時,剛好這時 server 負載較重 (處理比較慢),使用者以為還沒處理完成,於是在前端連點,雖然 model 有做 validates :email, uniqueness: true ,但 DB 沒再次驗證,也有可能發生此問題 (可參考: ActiveRecord - 資料驗證及回呼)

後續的文章會以此 repo 作為範例

如何重現 Race Condition

以 Ruby on Rails 為例,想看 Race Condition 本人的話

rails console 貼上以下這段 (本文以此 repo 作為範例)

# 重現 Race Condition
# 一開始 Order.last.total_price = 0
Order.last.update(total_price: 0)
threads = [100, 10].map do |n|
Thread.new do |_t|
order = Order.last
order.total_price += n
order.save
end
end
threads.each(&:join)
puts "預期結果是: 110, 實際結果是: #{Order.last.total_price}"
# 預期結果是: 110, 實際結果是: 10
# 預期結果是: 110, 實際結果是: 100
# 預期結果是: 110, 實際結果是: 110
# 上述每次執行,實際結果會不一樣

Race Condition 本人 (上述例子,每次執行,得到結果會不同)

如何處理

將要進行操作的 table 先鎖住 (Lock),處理方式可分成 2 種:

  1. 悲觀鎖 (Pessimistic locking)
  2. 樂觀鎖 (Optimistic locking)

悲觀鎖 (Pessimistic locking)

悲觀鎖,如其名,不相信任何人,一次只允許一筆資料針對 table 操作,此時會先鎖住該 table (鎖又可分成表鎖、行鎖,這邊以行鎖為例),避免被人竄改,其他人要操作只能等他被釋放後,才能進行操作

白話文就是所有人排隊領號碼牌,叫號依序處理,能解決 Race Condition,但也會影響效能,畢竟一次只能處理一筆資料

Ruby on Rails 中,悲觀鎖,可使用 with_lock 處理,實作方式如下:

# 悲觀鎖
# 一開始 Order.last.price = 0
Order.last.update(total_price: 0)
threads = [100, 10].map do |n|
Thread.new do |_t|
order = Order.last
order.with_lock do
order.total_price += n
order.save
end
end
end
threads.each(&:join)
puts "預期結果是: 110, 實際結果是: #{Order.last.total_price}"
# 預期結果是: 110, 實際結果是: 110

上述確實解決了 Race Condition ,但變成其他人要排隊等待 (可看下方 GIF),使用悲觀鎖需在效能與資料正確性之間做取捨,可依問題產生嚴重性、衍伸損失等進行綜合評估決定是否使用

排隊等待畫面

# 悲觀鎖
# 示範如何鎖住 table (行鎖)
# console 1
Order.last.update(total_price: 0)
order = Order.last
order.with_lock do
puts "total_price is #{order.total_price}"
order.total_price += 10
byebug
order.save
end
# console 2
puts "total_price is #{Order.last.total_price}"
# 行鎖: 其他 Order 不受影響
Order.first.update(total_price: 0)
Order.last.increment!(:total_price)
# 此時應該會卡住,因為 console 1 with_lock 關係,需等 console 1 釋放 order 後, console 2 才能針對該筆資料進行操作
puts "預期結果是: 11, 實際結果是: #{Order.last.total_price}"
# 預期結果是: 11, 實際結果是: 11

樂觀鎖 (Optimistic locking)

與悲觀鎖意思相反,認為資料不會頻繁被操作,因此允多人針對 table 操作,不代表我就爛什麼都不管,在 Ruby on Rails 中有提供 lock_version 這方法,可加在想使用樂觀鎖的 table 上,可參考此 commit

Ruby on Rail 中,樂觀鎖,可使用 lock_version 處理,實作方式如下:

# 樂觀鎖
# 一開始 Order.last.total_price = 0
Order.last.update(total_price: 0)
begin
order1 = Order.last
order2 = Order.last
order1.total_price += 10
order1.save
order2.total_price += 100
order2.save # ActiveRecord::StaleObjectError: Attempted to update a stale object: Order.
rescue ActiveRecord::StaleObjectError => e
# 要自己處理異常
end
puts "預期結果是: 10, 實際結果是: #{Order.last.reload.total_price}"
# 預期結果是: 10, 實際結果是: 10

樂觀鎖好處是能同時處理多筆資料,但錯誤的話,會收到 ActiveRecord::StaleObjectError,要自己處理,像是可以寫個 retry 或報錯誤訊息,讓工程師知道

小結

解決 Race Condition 後,需留意是否可能衍伸另個問題,像是 Deadlock 可看 Wiki 哲學家就餐問題 這篇,推薦看上方參考資料,可看看不同大大們對於 Race Condition 的介紹與解法

本篇特別感謝 David 、 Johnson(詹昇) 協助 (依英文字母順序排列)

鐵人賽文章連結:https://ithelp.ithome.com.tw/articles/10244812
medium 文章連結:https://link.medium.com/AUCVQnUb69
本文同步發布於 小菜的 Blog https://riverye.com/

備註:之後文章修改更新,以個人部落格為主

--

--

被端走的小菜

大家好,我是被端走的小菜。以個人部落格更新為主:https://riverye.com/