cas是我們常用的一種解決併發問題的手段,小到CPU指令集,大到分散式存儲,都能看到cas的影子。本文假定你已經充分理解一般的cas方案,如果你還不知道cas是什麼,請自行百度 我們在進行關係型資料庫的更新操作時,基於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 (要FQ),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耗時一些,但事務的方式要遠高於其他兩個
對於這個試驗我要做個說明:
- 為了減小語言本身多線程併發的開銷,我選擇了go語言
- 測試前做了預熱
- 沒把建立連接的時間算進去
- 看似500併發的測試,其實還是受物理機CPU核數影響比較大,所以並不能真正模擬出實際高併發的場景
- 兩個結果中,網路的延遲應該比redis處理速度占時更多,甚至遠多於
- 這是一個非正式的測試結果,僅供橫向對比
- 即使4,5兩條成立,依舊不會影響lua腳本更好的結論,因為畢竟同樣的功能都跑了50w次,lua要比事務省時間
最後留下測試代碼以供參考: github地址
作者:cz