惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

量子位
Google DeepMind News
Google DeepMind News
爱范儿
爱范儿
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
NISL@THU
NISL@THU
T
Threat Research - Cisco Blogs
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
L
Lohrmann on Cybersecurity
V
Visual Studio Blog
Cyberwarzone
Cyberwarzone
D
Docker
The Hacker News
The Hacker News
C
CERT Recently Published Vulnerability Notes
Vercel News
Vercel News
Project Zero
Project Zero
S
Schneier on Security
aimingoo的专栏
aimingoo的专栏
I
Intezer
腾讯CDC
M
MIT News - Artificial intelligence
Hugging Face - Blog
Hugging Face - Blog
P
Palo Alto Networks Blog
C
CXSECURITY Database RSS Feed - CXSecurity.com
AWS News Blog
AWS News Blog
GbyAI
GbyAI
MongoDB | Blog
MongoDB | Blog
Cyber Security Advisories - MS-ISAC
Cyber Security Advisories - MS-ISAC
V
Vulnerabilities – Threatpost
G
Google Developers Blog
N
Netflix TechBlog - Medium
The Cloudflare Blog
Microsoft Security Blog
Microsoft Security Blog
Y
Y Combinator Blog
A
Arctic Wolf
S
Securelist
酷 壳 – CoolShell
酷 壳 – CoolShell
Cisco Talos Blog
Cisco Talos Blog
Recent Announcements
Recent Announcements
C
Cyber Attacks, Cyber Crime and Cyber Security
L
LINUX DO - 热门话题
T
Threatpost
Latest news
Latest news
Blog — PlanetScale
Blog — PlanetScale
Security Latest
Security Latest
Engineering at Meta
Engineering at Meta
大猫的无限游戏
大猫的无限游戏
H
Help Net Security
The GitHub Blog
The GitHub Blog
T
Tor Project blog
P
Proofpoint News Feed

Nic Lin's Blog

謝明真 - 高效領導力的課後筆記 NFT 開發實戰!基礎智能合約入門 (3) NFT 開發實戰!基礎智能合約入門 (2) NFT 開發實戰!基礎智能合約入門 (1) 如何自我檢測 log4j CVE 漏洞 Rails 如何在資料寫入時記錄來源 IP 位置 如何經營工程師 Youtube 頻道 - Part 8 營收篇 如何經營工程師 Youtube 頻道 - Part 7 酸民文化篇 如何經營工程師 Youtube 頻道 - Part 5 設備器材篇 如何經營工程師 Youtube 頻道 - Part 4 後製剪輯篇 如何經營工程師 Youtube 頻道 - Part 3 文案企劃篇 如何經營工程師 Youtube 頻道 - Part 2 設備器材篇 如何經營工程師 Youtube 頻道 - Part 1 制訂頻道方向篇 如何經營工程師 Youtube 頻道 - Part 0 Rails 中避免 race condition 的最佳實踐(一) 10 分鐘整合 google sheet 做自動化開發功能週報 經營 Side Project 300 天所帶來的收穫及挑戰 我的 Youtube 影片製作流程 API 設計時必須注意的 HTTP header 底線問題 如何提升你的程式可讀性之實務技巧(三) 如何提升你的程式可讀性之實務技巧(二) 如何提升你的程式可讀性之實務技巧(一) Ruby 中使用 freeze 優化效能的時機 避免 React 中的 useEffect 無限 render 在 Rails 內輕量使用 Vue Component 的最佳實踐 如何在區域網路用 Docker 架設有 SSL 的 Gitlab 從被問到問人,那些我常問的面試問題 [Rails] 如何漂亮寫出可維護的 query (Maintainable Rails Query) 在已知長度情況下優化 slice 的性能 [ReactNative] 如何在 iOS APP 上主動要求用戶評分 Rails 的 scope 為什麼用 lambda? Proc 與 lambda 不同之處 淺談 Active Record 的 Lazy load 特性 Rails 專案搭配 Github Actions 進行 RSpec 自動化測試 JavaScript 中 require, import 的差別及效能 React 效能優化基本招 ES6 箭頭函式 (Arrow functions) 2 個月擁有 6000 用戶 Side project 這樣做(一) 如何讓自己成為失敗的軟體工程師 如何用 Rack::Attack 阻擋 DDOS / 惡意流量 用 OpenSSL 自簽開發用 HTTPS SSL 憑證 為機器加上登入訊息,在 ubuntu 設置登入歡迎詞 Ruby Memoization 性能優化之記憶化 淺談 SSH agent forwarding 和 proxy command 的安全風險與應用 [Rails] Service / Library / Concern 的差異 避免過度的 Defensive Programming 防禦性程式設計 1:1 攪亂器,如何用 Ruby 做可逆推序號 Rails 中的欄位及方法命名原則 [Rails] 用 puma-dev 作為本地開發伺服器 (支援 https 自簽憑證) 將 Rails 專案從手動部屬遷移使用 Capistrano 自動化部屬 工程師提昇自己的教學和簡報技術的方法 [筆記] Rails 3.2 升級 Rails 6.beta 經驗分享 Class method 氾濫帶來什麼問題 RDBMS 課程心得與筆記 常用的 Rails 開發規範 Rest-Client 如何做 Basic Authentication 驗證 [Rails] 何為 tld_lebgth? 遵循 Semantic Versioning 軟體開發語意化版本管理 請直接在 MySQL 裡面直接用 utf8mb4 取代 utf8 如何解決在 awesome print 中遇到 ActionController::Parameters unable to convert unpermitted 如何在 Mac 上升級 PostgreSQL 並遷移資料 如何解決 Mysql2::Error: Incorrect string value 讀書心得 - 「信任因子:信任如何影響大腦運作、激勵員工、達到組織目標」 我是如何寫部落格筆記的 讀書心得 - 「先問,為什麼?:顛覆慣性思考的黃金圈理論,啟動你的感召領導力」 [Rails] 解決 Reset Password 帶來的 token 洩漏問題 我的軟體工程師生涯:如何挑選適合你的公司 Rails 中的 delegate 用法 淺述 SSR SPA 優缺點 Rails 非同步工作請用 Global ID [React] Class Component 傳遞 props 的 2 種方式 好用的隱私權政策 URL 自動生成 Rails 5.1 之後的 tag helper Rails 5.2 Encrypted Credentials 最近面試被給的建議和書單 一般架構需要用到 K8S 嗎 透過 commit SHA 找 github Pull request 從零搭建,如何讓 Rails 跑在 Kubernetes(k8s)(二) 從零搭建,如何讓 Rails 跑在 Kubernetes(k8s)(一) React Stateless Functional Components 搞懂 React 中的 state 和 props 物件導向基本原則 SOLID (Ruby Sample) 在以太坊智能合約上是可以預測隨機數的 在台灣租屋必須注意的事 Rails 5 簡單雙向加解密 如何用 ABA 培養自律型員工 不要在 rake task 中定義 method, 請用 RAKE::DSL rails 非hash只想用array輸出page 如何處理陣列裡有重複的值 [Rails] 如何重設你的專案名稱 Ruby on Rails install on Mac 安裝步驟 使用 Friendly_id 與 Babosa 美化你的Rails 網址 Junior Rails 兩個月實戰心得 Devise使用Google實作登入 [iterm2] 如何新增alias 一個新鮮人找尋Rails工作的面試經驗 如何讓兩個資料表建立關聯 routing 的 namespace strong parameter user story 的格式 user story 是什麼?
Rails 中避免 race condition 的最佳實踐(二)
Nic Lin · 2020-09-11 · via Nic Lin's Blog

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 的部分,希望能提供給讀者更多的思考方式。

參考來源