你必須很努力

Day22 - Ruby on Rails 中的 Race Condition

2020/09/27
字數統計: 1.2k閱讀時間: 5 min

前言

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 作為範例)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 重現 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 處理,實作方式如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 悲觀鎖
# 一開始 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),使用悲觀鎖需在效能與資料正確性之間做取捨,可依問題產生嚴重性、衍伸損失等進行綜合評估決定是否使用

排隊等待畫面

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# 悲觀鎖
# 示範如何鎖住 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 處理,實作方式如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 樂觀鎖
# 一開始 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 或報錯誤訊息,讓工程師知道

參考資料

  1. Active Record 查詢 — Ruby on Rails 指南
  2. 樂觀鎖 與 悲觀鎖 Optimistic Locking & Pessimistic Locking
  3. 不使用 lock 又要避免 race condition,可能嗎?
  4. Race Conditions on Rails
  5. Rails 中避免 race condition 的最佳實踐(一)
  6. Rails 中避免 race condition 的最佳實踐(一)

小結

解決 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/2020/09/27/Day22-Ruby-on-Rails-中的-Race-Condition/

發表日期:2020-09-27

更新日期:2022-12-21

CATALOG
  1. 1. 前言
    1. 1.1. 常見情境可能有:
  2. 2. 如何重現 Race Condition
    1. 2.0.1. Race Condition 本人 (上述例子,每次執行,得到結果會不同)
  • 3. 如何處理
    1. 3.1. 悲觀鎖 (Pessimistic locking)
      1. 3.1.1. 排隊等待畫面
    2. 3.2. 樂觀鎖 (Optimistic locking)
  • 4. 參考資料
  • 5. 小結