Nic Lin's Blog

喜歡在地上滾的工程師

Rails 中避免 race condition 的最佳實踐(二)

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 做更深入的測試,仿黑客做重複且平行的請求來補強也會是一個重點

所以針對這個部分,有兩件事可以做

  1. unit test
  2. 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 極盡接近的時間開始執行

  1. 第一個 transaction 正在寫入時,第二個 transaction 等待釋放鎖
  2. 第一個 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 做組合技

一般扣款的邏輯會是

  1. 進資料庫撈資料
  2. 檢查餘額是否足夠
  3. 扣款
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

  1. 直接跟資料庫找能扣錢的資料,並且扣錢
  2. 如果沒扣到,更新筆數為 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 的部分,希望能提供給讀者更多的思考方式。

參考來源

comments powered by Disqus