前言
Race Condition 可翻譯成「競爭條件」,在中文版 Wiki 上看不懂的話,可看英文版 Wiki 的描述,會比較清楚,以下為白話文翻譯:
同筆資料同時被 2 thread 以上操作,導致結果的不正確
常見情境可能有:
- 搶票系統、搶購限量商品時 (ex: 限量 100 張票,卻賣了 101 張)
- 使用者送出資料時,剛好這時 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 種:
- 悲觀鎖 (Pessimistic locking)
- 樂觀鎖 (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 | # 悲觀鎖 |
樂觀鎖 (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
或報錯誤訊息,讓工程師知道
參考資料
- Active Record 查詢 — Ruby on Rails 指南
- 樂觀鎖 與 悲觀鎖 Optimistic Locking & Pessimistic Locking
- 不使用 lock 又要避免 race condition,可能嗎?
- Race Conditions on Rails
- Rails 中避免 race condition 的最佳實踐(一)
- 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/
備註:之後文章修改更新,以個人部落格為主