基於redis的cas實現


  cas是我們常用的一種解決並發問題的手段,小到CPU指令集,大到分布式存儲,都能看到cas的影子。本文假定你已經充分理解一般的cas方案,如果你還不知道cas是什么,請自行百度

  

  我們在進行關系型數據庫的更新操作時,基於cas的更新常常是保證數據業務邏輯語義下的一致性的終極手段,一般用來解決“寫偏序”問題。關系型數據庫有基於where的條件更新,一些NoSQL也都有對cas的支持,可為什么redis在原生語義上不支持cas操作呢?例如:

setcas key oldvalue newvalue

 

  很多人不理解,redis處理速度本就很快,還需要cas么?我承認redis對於單個指令的處理速度很快,但很多時候我們要解決的是網絡問題,和應用程序STW(stop the world,一般指java那種長時間GC)

  一旦發生這種問題,形成了 get->判斷->停頓 ->set,就可能出現寫偏序或者更新丟失,redis也沒辦法幫你了

  為什么redis不支持原生的cas?

  這種功能對redis來說實現起來幾乎不費力氣:原本對數據處理的操作就是基於單線程的,壓根不會出現像其他語言的那種內存不可見問題,或者什么性能損失

  我找到了09年redis的一個mail list (要翻牆),redis的作者Salvatore Sanfilippo 開始解釋了為什么他不想加入cas功能,理由是至少沒法說服我,社區中很多人也表示“我們只需要關於string類型的cas操作就好啦”。然而時至今日你依舊沒有在redis.io的command列表中找到cas操作的蹤跡

  幸好,我們有兩種方式可以自己實現cas,且並不費力

  基於Lua腳本的cas實現

  目前我們使用的redis版本,都支持lua腳本的執行,並且性能非常好。甚至對於比較復雜的功能,redis-cli還提供了lua腳本的調試工具。下面是我自己實現的一個string的cas功能,相信已經能滿足大多數場景了:

local v = redis.call("get", KEYS[1]) local r = 1 if v == KEYS[2] or v == false then redis.call("set", KEYS[1], KEYS[3]) else r = 0 end return {r, v}

  不好意思,我用空格代替了換行,因為語句實現在是太簡單。此腳本中的KEYS[1](lua的數組從1開始)代表你要修改的key, KEYS[2]代表原值,KEYS[3]代表要修改為的值。最終返回兩個值:第一個值為1或者0,1代表修改成功,0代表修改失敗,無論成功失敗,第二個值會返回原值,這是為了方便你直接在cas失敗后重新進行計算,而不需要再get一下

  調用時依照一下方式:

eval 'lua腳本' 3 key oldvalue newvalue

  但我更建議你將這個腳本加載到redis中,在shell中執行:

> redis-cli script load 'lua腳本'
> "74ff40a09af2913b2651bfbc68d7bab7220daecd"

  第二行返回的就是這個腳本的sha1的哈希碼,下次調用這個腳本你可以直接:

evalsha 74ff40a09af2913b2651bfbc68d7bab7220daecd 3 key oldvalue newvalue

  你可能疑惑腳本中 v==false的意義,原因是,如果你調用redis.call去獲取一個不存在的key,會返回false。由於我使用的go-redis中無法把nil作為old value發送給redis (redis-clie也不行),所以這個腳本會在key不存在的情況下cas成功,無論你把oldvalue賦予了什么值。我想這在大多數場景中都不成問題。對於任意語言的redis框架,對應參數傳個空字符串就可以了。對於第二個返回值,這種情況下會返回nil, 能被框架成功解析成對應語言的null值(比如go就是nil)

  以下是實際的例子, 在redis-cli下:

> evalsha 74ff40a09af2913b2651bfbc68d7bab7220daecd 3 nosuchkey a b
> 1) (integer) 1
> 2) (nil)
> evalsha 74ff40a09af2913b2651bfbc68d7bab7220daecd 3 nosuchkey b c
> 1) (integer) 1
> 2) "b"

   

  基於Watch和Multi的cas實現

   如果你嘗試過自己搜索一下redis cas的解決方案,我想你看到的大多數文章都是基於“redis 事務”的,即watch和multi。曾經我做面試官的時候,詢問面試者一個他們解決方案,我說既然用到了redis,為什么不嘗試用“redis 事務”解決一下這個問題。他表示“不知道redis 事務”,而且根據“事務”二字順理成章的認為“事務會大大影響redis性能”

  實際上所謂的redis事務並不像關系型數據庫的事務那么復雜,舉個例子, 使用了redis 某種語言框架的偽代碼:

client = redis.newClient() //創建客戶端
client.watch("teacher") // 對應redis的指令 watch teacher
client.multi() // 對應redis的指令 multi
a = client.get("teacher") // 對應redis的指令 get teacher
if a == "annie"
  client.set("teacher", "joe") // 對應redis的指令 set teacher joe
else
  client.set("teacher", "han") // 對應redis的指令 set teacher han
client.exec() // 對應redis的指令 exec

  服務器為每一個被watch的key維護了一個鏈,當你的客戶端執行到watch teacher時,會被加到這個鏈上去。之后exec之前的所有get, set操作其實僅僅是進入了一個指令隊列,待到exec時,如果watch 的key 沒發生變更,則一起執行,否則不執行

  拿這種機制與數據庫事務對比,會發現無論這個所謂的"redis事務"中間隔了多長時間,其實也並不影響其他指令或者事務,而且一旦隊列中的指令執行,也是無法插入其他指令的,保證了隔離性

  

  性能上的對比

  好了,現在我們有兩個方案了,那個更好一點呢?我傾向於lua腳本的方案,一是因為這個腳本相對易讀,通用,減小開發人員代碼量。二就是因為性能。我進行了兩個簡單的實驗, 基於我的筆記本上的虛擬機中的docker...,虛擬機分配了2核2G內存

  單線程實驗

  三種交互方式:set——直接對測試key進行set操作, cas——通過lua腳本進行set,並且故意設計成一半成功一半不成功,watch——先watch,再set,最后exec

  並發數:1

  循環次數:10000

  跑了若干次的結果:

  

set  cas watch
2.0s-2.3s 2.1s-2.9s 4.3s-4.9s

  並發實驗

  三種交互方式:set——對測試key先get后set操作, cas——先get,再通過lua腳本進行set,watch——先watch,再get,再set,最后exec

  並發數:500

  循環次數:1000

  跑了若干次的結果:

 

set cas watch
1m13s-1m33s 1m30s-1m49s 2m23s-2m32s

   

  從以上結果可以看出來,在模擬對一個key進行高並發的操作時,lua腳本會略微比set耗時一些,但事務的方式要遠高於其他兩個

  對於這個試驗我要做個說明:

  1. 為了減小語言本身多線程並發的開銷,我選擇了go語言
  2. 測試前做了預熱
  3. 沒把建立連接的時間算進去
  4. 看似500並發的測試,其實還是受物理機CPU核數影響比較大,所以並不能真正模擬出實際高並發的場景
  5. 兩個結果中,網絡的延遲應該比redis處理速度占時更多,甚至遠多於
  6. 這是一個非正式的測試結果,僅供橫向對比
  7. 即使4,5兩條成立,依舊不會影響lua腳本更好的結論,因為畢竟同樣的功能都跑了50w次,lua要比事務省時間

  最后留下測試代碼以供參考: github地址

  

  作者:cz

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM