序
Rails 中避免 race condition 的最佳實踐(一)
- 前言
- Lock 的種類
- 悲觀鎖
- 樂觀鎖
- 悲觀鎖定的最佳實踐
- 超過兩層請用 AR transaction + Lock
- Lock first
- 規範鎖定順序
- 使用 Bang 語法
Rails 中避免 race condition 的最佳實踐(二) => 本篇
- Testing race condition
- Unit test with RSpec
- Benchmarking tool
- Snapshot read & Current read
- Single Query
- 總結
Testing race condition
從兩個角度出發,通常開發者需要驗證自己的邏輯是否正確,撰寫 unit test 為其中一個重要的環節
不過多數的實作狀況,我們僅會測試邏輯是否如規格所預期,較少測試可能有 race condition 所造成的 dirty object 問題
這個部分需要視商業邏輯的情況來決定,並非每一處都要如此撰寫
而另一個環節是如果團隊上有搭配 QA 做更深入的測試,仿黑客做重複且平行的請求來補強也會是一個重點
所以針對這個部分,有兩件事可以做
- unit test
- benchmarking tool
Unit test with RSpec
先假設我們有一個兌換 coupon 的實作 service 如 Coupon::RedeemService
如果該 coupon 已經被兌換過,那麼其他人就不能再兌換
實作的測試思路如下
RSpec.describe Coupon::RedeemService do
context "when coupon find dirty status" do
# 先建立 coupon,並透過真實的 SQL query 複製一個 object 出來
# 為的是模擬真實從 SQL 中同時撈出並放入 object 的情況
before(:all) do
@coupon = create(:coupon)
@dirty_coupon = Coupon.find(@coupon.id)
end
# 實體化 service 並帶入 coupon
let(:service) { described_class.new(@coupon) }
subject { service.perform }
# 做出 dirty coupon,模擬已經在其他 transaction 先行完成狀態變更
before(:each) do
@dirty_coupon.update(status: "redeemed")
end
# 預期 service 應該要執行失敗
it "returns false to avoids race conditions" do
# 這兩個 object 從 DB 拿出來時是一樣的 ID
expect(@coupon.id).to eq(@dirty_coupon.id)
# 已經確定有一個已經被我們搞髒了,所以狀態已經不同
expect(@coupon.status).not_to eq(@dirty_coupon.status)
# 確保執行結果為錯
expect(subject).to be false
# Return error message
expect(service.errors).to include(I18n.t("can_not_cancel"))
end
end
end
Benchmarking tool
一般 benchmarking tool 都會拿來做基本的壓力測試
不過在測 Race condition 上也會是一個好工具,透過 Rails 的 route 超易寫的特性
你可以開一個 route 指向測試用的 controller action
resources :webhooks do
get :race_condition_testing
end
然後這個 action 就簡單的 call 封裝的業務邏輯 service,來觀察結果
def race_condition_testing
Coupon::RedeemService.new(current_user, @coupon_serial).perform
end
不過要注意的是專案的 web server 是不是接受多線程,否則有可能會測不出來,現在新的專案多半都是 puma 應該是沒問題
這邊除了一般常見的 ab(Apache HTTP server benchmarking tool)
也推薦使用 wrk
MAC 環境下快速安裝 brew install wrk
然後就可以打 wrk -t12 -c400 -d30s http://127.0.0.1:8080/race_condition_testing
可以測試只能兌換一次的兌換券,發送一堆平行請求,是否只加到一筆錢?
Snapshot read & Current read
Database 中的 Isolation level 有四種
- Read Uncommitted
- Read Committed
- 能防止 Dirty Read
- Repeatable Reads
- 能防止 Dirty Read, Non-repeatable read
- Serializable
- 能防止 Dirty Read, Non-repeatable read, Phantom read
以 MySQL 5.7 來說預設是 Repeatable Reads,是兼顧效能又能夠避免 dirty read 的設定
但是,當我們認為將邏輯包在 transaction 內,也都做了 lock 和 validate 來確保操作的 atomicity
還會發生沒有驗證到資料卻給予執行的狀況嗎?
「會」
我們來看一個情況,假設 transaction 內容操作為
1. lock users
2. lock accounts
3. insert redeem_historys (user_id => 1)
有 2 個一樣的 transaction,以 concurrency 極盡接近的時間開始執行
- 第一個 transaction 正在寫入時,第二個 transaction 等待釋放鎖
- 第一個 transaction commit 並釋放鎖後,第二個 transaction 馬上執行
SELECT 1 AS one FROM redeem_historys WHERE user_id = 1
這邊在測試的時候,照理講在第一個 transaction 結束後,第二個 tx 要能看到這筆被 insert 資料才對
但如果我不用顯式的語法包在 transaction 內 SELECT FROM redeem_historys WHERE user_id = 1 FOR UPDATE
的話,以不斷瘋狂的實測結果來說
「是有機率看不到這筆資料的」
因為 transaction 開啟時,是會記錄當下開啟的時間,在這個 transaction 內只能看到開啟時間以前被 commited 進資料庫的東西,換言之也就是以時間戳記避開 dirty read 的問題
但如果發生上述的極端情況會像是這樣
(假設左右代表時間軸)
TX_1: begin——————————— commit
TX_2: ......begin——————————— commit
那麼 TX_1 開啟後,TX_2 也看不到比他早 commit 的資料
也就是說 TX_2 在執行的過程中,因為 MVCC 版本控制的關係,就算 TX_1 最後比較早 commit,但在自己的整個 transaction 內都看不到 TX_1 insert 的任何資料,也就會造成誤判
所以你會發現,就算有了 lock 包了 transaction 也寫了 validate 為什麼還是會有問題
唯一的解法就是,在 TX 內使用顯式的語法進行搜尋 SELECT FROM redeem_historys WHERE user_id = 1 FOR UPDATE
否則會在 validate 時讀到 MVCC 給你的 snapshot read 而不是 current read
ActiveRecord::Base.transaction do
# Lock first
order.lock!
coupon.lock!
account.lock!
# 使用 lock! 顯式語法確保 current read
if CouponRedemmer.where(user_id: @user.id, serial: @serial).lock!.exists?
# Logic
end
end
Single Query
在情況簡單的狀況下,如果能一條 query 就解決,也是一種避免 race condition 的方法,因為在大部分的資料庫系統,single query 都為 atomic 的,一定會一次完成,中間不會被別人插入造成順序影響結果
Rails 中提供的 update_counters 方法就可以使用
例如更新文章的 likes_count 計數
post_id = 1
Post.update_counters(post_id, likes_count: 10)
# UPDATE "posts" SET "likes_count" = COALESCE("likes_count", 0) + 10 WHERE "posts"."id" = 1
或是透過 update_all
做組合技
一般扣款的邏輯會是
- 進資料庫撈資料
- 檢查餘額是否足夠
- 扣款
def sub_fund(amount)
return false if self.amount < amount
self.amount -= 1000
save
end
因為避免 race condition 所以我們上了 lock
def sub_fund(amount)
with_lock do
return false if self.amount < amount
self.amount -= 1000
save!
end
end
但其實可以換個方式思考,讓邏輯變成 single query
- 直接跟資料庫找能扣錢的資料,並且扣錢
- 如果沒扣到,更新筆數為 0,失敗
def sub_funds!(user, amount)
success_update_count = Account.where(user: user).where("amount >= ?", 1000).update_all("amount = amount - ?", amount)
# 成功更新回 true,否則丟 raise 出去
success_update_count == 1 || raise(Error, "cannot sub funds(account_id: #{id}, amount: #{amount})")
end
這個部分是透過 single query 勢必是 atomic 的特性,將邏輯重新組合,適用於較單純且不能容錯的場景
總結
這幾年處理貨幣交易遇到的 high concurrency 場景,其中複雜的不只是邏輯,還有架構,有時候還需要考慮到 microservie 情況下有不同的 backend 爭奪資源,統整及學習以上的筆記,該筆記是與同事請教、上網問大神、爬文章整理出來的
雖然是以 Ruby on Rails 為實作探討,但其資料庫的觀念亦或是設計 convention 的部分,希望能提供給讀者更多的思考方式。