Python–Redis實戰:第四章:資料安全與效能保障:第6節:Redis事務

Mark發表於2018-11-19

上一篇文章:Python–Redis實戰:第四章:資料安全與效能保障:第5節:處理系統故障
下一篇文章:Python–Redis實戰:第四章:資料安全與效能保障:第7節:非事務型流水線

為了確保資料的正確性,我們必須認識到這一點:在多個客戶端同時處理相同的資料時,不謹慎的操作很容易會導致資料出錯。本節將介紹使用Redis事務來防止資料出錯的方法,以及在某些情況下,使用事務來提升效能的方法。

Redis的事務和傳統關聯式資料庫的事務並不相同。在關聯式資料庫中,使用者首先向資料庫伺服器傳送begin,然後執行各個相互一致的寫操作和讀操作,最後,使用者可以選擇傳送commit來確認之前所做的修改,後者傳送rollback來放棄那些修改。

在Redis裡面也有簡單的方法可以處理一連串相互一致的讀操作和寫操作。正如之前介紹的那樣,Redis的事務以特殊命令multi為開始,之後跟著使用者傳入的多個命令,最後以exec為結束。但是由於這種簡單的事務在exec命令被呼叫之前不會執行任何實際操作,所以使用者將沒辦法根據讀取到的資料來做決定。這個問題看上去似乎無足輕重,但實際上無法以一致的形式讀取資料將導致某一型別的問題變得難以解決,除此之外,因為在多個事務同時處理同一個物件時通常需要用到二階提交,所以如果事務不能以一致的形式讀取資料,那麼二階提交將無法實現,從未導致一些原本可以成功執行的事務淪落至失敗的地步。比如說:在市場裡面購買一件商品,就是其中一個會因為無法以一致的形式讀取資料而變得難以解決的問題,本節接下來將在實際環境中對這個問題進行介紹。

延遲執行事務有助於提升效能

因為Redis在執行事務的過程中,會延遲執行已入隊的命令知道客戶端傳送exec命令為止。因此,包括本書使用的Python客戶端在內的很多Redis客戶端都會等到事務包含的所有命令都出現了之後,才一次性地將multi命令、要在事務中執行的一系列命令,以及exec命令全部傳送給Redis,然後等待知道接受到所有命令的回覆為止。這種【一次性傳送多個命令,然後等待所有回覆出現】的做法通常被成為流水線,它可以通過減少客戶端與Redis伺服器之間的網路通訊次數來提示Redis在執行多個命令時的效能。

最近幾個月,Fake Game公司發現他們在一個社交網站上推出的角色扮演網頁遊戲正在變得越來越受歡迎。因此,關心玩家需求的Fake Game公司決定在遊戲裡面增加一個商品買賣市場,讓玩家們可以在市場裡面銷售和購買商品。本節接下來的內容將介紹設計和實現這個商品買賣市場的方法,並說明如何按需對這個商品買賣市場進行擴充套件。

定義使用者資訊和使用者包裹

下表展示了遊戲中用於表示使用者資訊和使用者包裹的結構:使用者資訊儲存在一個雜湊裡面,雜湊的各個鍵值對分別記錄了使用者的姓名、使用者擁有的錢數等屬性。使用者包裹使用一個集合來表示,它記錄了包裹裡面每件商品的唯一編號。

鍵名:user:17 儲存型別:hash
name Frank
funds 43
鍵名:inventory:17 儲存型別:set
ItemL
ItemM
ItemN
鍵名:user:27 儲存型別:hash
name Bill
funds 125
鍵名:inventory:27 儲存型別:set
ItemO
ItemP
ItemQ

商品買賣市場的需求非常簡單:一個使用者(賣家)可以將自己的商品按照給定的價格放到市場上進行銷售,當另一個使用者(買家)購買這個商品時,賣家就會收到錢。另外,本節實現的市場只根據商品的價格來進行排序,稍後章節將介紹如何在市場裡面實現其他排序。

為了將被銷售商品的全部資訊都儲存到市場裡面,我們會將商品的ID和賣家的ID拼接起來,並將拼接的結果用作成員儲存到市場有序集合裡面,而商品的售價則用作成員的分值。通過將所有資料都包含在一起,我們極大簡化了實現商品買賣市場所需的資料結構,並且,因為市場裡面的所有商品都按照價格排序,所以針對商品的分頁功能和查詢功能都可以很容易地實現。

下表展示了一個只包含數個商品的市場例子:

鍵名:market 儲存型別:zset
正在銷售的商品.物品的擁有者 物品的價格
ItemA.4 35
ItemC.7 48
ItemE.2 60
ItemG.3 73

上表表示的商品買賣市場,第一行資料表示:使用者4正在銷售商品Item,售價為35塊錢

既然我們已經知道了實現商品買賣市場所需的資料結構,那麼接下來該考慮如何實現市場的商品上架功能了。

將商品放到市場上銷售

為了將商品放到市場上進行銷售,程式除了要使用multi命令和exec命令之外,還需要配合使用watch命令,有時候甚至還會用到unwatch和discard命令。在使用者使用watch命令對鍵進行監視之後,直到使用者執行exec命令的這段時間裡面,如果有其他客戶端搶先對任何被監視的鍵進行了替換、更新或刪除等操作,那麼當使用者嘗試執行exec命令的時候,事務將失敗並返回一個錯誤(之後使用者可以選擇重試事務或者放棄事務)。通過使用watch、multi/exec、unwatch/discard等命令。程式可以在執行某些重要操作的時候,通過確保資金正在使用的資料沒有發生變化來避免資料出錯。

什麼是discard?

unwatch命令可以在watch命令執行之後,multi命令執行之前對連線進行重置(reset);同樣地,discard命令也可以在multi命令執行之後、exec命令執行之前對連線進行重置。這也就是說,使用者在使用watch監視一個或多個鍵。接著使用multi開始一個新的事物,並將多個命令入隊到事物佇列之後,仍然可以通過傳送discard命令來取消watch命令並清空所有已入隊命令。本章展示的例子都沒有用到discard,主要原因在於我們已經清楚的知道自己是否想要執行multi/exec或者unwatch,所以沒有必要在這些例子裡面使用discard。

在將一件商品放到市場上進行銷售的時候,程式需要將被銷售的商品新增到記錄市場正在銷售商品的有序集合裡面,並且在新增操作執行的過程中,監視賣家的包裹以確保被銷售的商品的確存在於賣家的包裹當中。

下面程式碼展示了這一操作的具體實現:

import time
import redis

def list_item(conn,itemid,sellerid,price):
    inventory="inventory:%s"%sellerid
    item="%s.%s"%(itemid,sellerid)
    end=time.time()+5
    pipe=conn.pipeline()

    while time.time()<end:
        try:
            #監視使用者包裹發生的變化
            pipe.watch(inventory)
            #檢查使用者是否仍然持有將要被銷售的商品
            if not pipe.sismember(inventory,itemid):
                pipe.unwatch()
                #如果指定的商品不在使用者的包裹裡面,那麼停止對包裹鍵的監視並返回一個空值
                return None
            #把被銷售的商品新增到商品買賣市場裡面
            pipe.multi()
            pipe.zadd("market:",item,price)
            pipe.srem(inventory,itemid)
            #如果執行execute方法沒有引發WatchError異常,那麼說明事務執行成功,並且對包裹鍵的監視也已經結束。
            pipe.execute()
            return True

        except redis.exceptions.WatchError:
            #使用者的包裹已經發生了變化,重試
            pass
    return False

上面函式的行為就和我們之前描述的一樣,它首先執行一些初始化步驟,然後對賣家的包裹進行監視,驗證賣家想要銷售的商品是否仍然存在於賣家的包裹當中,如果是的話,函式就會將被銷售的商品新增到買賣市場裡面,並從賣家的包裹中移除該商品。正如函式中的while迴圈所示,在使用watch命令對包裹進行監視的過程中,如果包裹被更新或者修改,那麼程式將接收到錯誤並進行重試。

下表展示了當Frank(使用者ID為17)嘗試以97塊錢的價格銷售ItemM時,list_item()函式執行的過程:

watch(`inventory:17`) #監視包裹發生的任何變化
鍵名:inventory:17 型別:set
ItemL
ItemM
ItemN
sismermber(`inventory:17`,`ItemM`) #確保被銷售的物品仍然存在於Frank的包裹裡面
鍵名:inventory:17 型別:set
ItemL
ItemM
ItemN
鍵名:market 型別:zset
ItemA.4:35
ItemC.7:48
ItemE.2:60
ItemG.3:73
#因為沒有一個Redis命令可以在移除集合元素的同時,將被移除的元素改名並新增到有序集合裡面
#所以這裡使用了zadd和srem兩個命令來實現這一操作
zadd(`market`,`ItemM.17`,97)
srem(`inventory:17`,`ItemM`)
鍵名:inventory:17 型別:set
ItemL
ItemN
鍵名:market 型別:zset
ItemA.4:35
ItemC.7:48
ItemE.2:60
ItemG.3:73
ItemM.17:97

因為程式會確保使用者只能銷售他們自己所擁有的,所以在一般情況下,使用者都可以順利地將自己想要銷售的商品新增到商品買賣市場上面,但是正如之前所說,如果使用者的包裹在watch執行之後直到exec執行之前的這段時間內傳送了變化,那麼新增操作將執行失敗並重試。

在弄懂了怎樣將商品放到市場上銷售之後,接下來讓我們來了解一下怎樣從市場上購買商品。

購買商品

下面的函式展示了從市場裡面購買一件商品的具體方法:程式首先使用watch對市場以及買家的個人資訊進行監視,然後獲取買家擁有的錢數以及商品的售價,並檢查買家是否有足夠的錢來購買該商品。如果買家沒有足夠的錢,那麼程式會取消事務;相反,如果買家的錢足夠,那麼程式首先會將買家支付的錢轉移給賣家,然後將售出的商品移動至買家的包裹,並將該商品從市場中移除。當買家的個人資訊或者商品買賣市場出現變化而導致WatchError移除出現時,程式進行重試,其中最大重試時間為10秒:

import time
import redis

def purchase_item(conn,buyerid,itemid,sellerid,lprice):
    buyer=`users:%s`%buyerid
    seller=`users:%s`%sellerid
    item="%s.%s"%(itemid,sellerid)

    inventory="inventory:%s"%buyerid
    end=time.time()+10
    pipe=conn.pipeline()

    while time.time()<end:
        try:
            #對商品買賣市場以及買家對個人資訊進行監視
            pipe.watch("market:",buyer)

            #檢查買家想要購買的商品的價格是否出現了變化
            #以及買家是否有足夠的錢來購買這件商品
            price=pipe.zscore("market:",item)
            funds=int(pipe.hget(buyer,`funds`))
            if price!=lprice or price>funds:
                pipe.unwatch()
                return None

            #先將買家支付的錢轉移給賣家,然後再將購買的商品移交給買家
            pipe.multi()
            pipe.hincrby(seller,"funds",int(price))
            pipe.hincrby(buyer,`funds`,int(-price))
            pipe.sadd(inventory,itemid)
            pipe.zrem("market:",item)
            pipe.execute()
            return True
        except redis.exceptions.WatchError:
            #如果買家的個人資訊或者商品買賣市場在交易的過程中出現了變化,那麼進行重試。
            pass
    return False

在執行商品購買操作定位時候,程式除了需要花費大量時間來準備相關資料之外,還需要對商品買賣市場以及買家的個人資訊進行監視:監視商品買賣市場是為了確保買家想要購買的商品仍然有售(或者在商品已經被其他人買走時進行提示),而監視買家的個人資訊則是為了驗證買家是否有足夠的錢來購買自己想要的商品。

當程式確認商品仍然存在並且買家有足夠的錢的時候,程式會將被購買的商品移動到買家的包裹裡面,並將買家支付的錢轉移給賣家。

在觀察了市場上展示的商品之後,Bill(使用者ID為27)決定購買Frank在市場上銷售的ItemM,下圖展示了購買操作執行期間,資料結構的變化:

watch(`market:`.`users:27`) #對物品買賣市場以及Bill的個人資訊進行監視
鍵名:market 型別:zset
ItemA.4:35
ItemC.7:48
ItemE.2:60
ItemG.3:73
ItemM.17:97
鍵名:users:27 型別:hash
name:Bill
funds:125
鍵名:users:17 型別:hash
name:Bill
funds:43
#驗證物品的售價是否併為改變
#以及Bill是否有足夠的錢來購買該物品
price=pipe.zscore("market:","ItemM.17")
funds=int(pipe.hget("users:27",`funds`))
if price!=97 or price>funds:
pipe.sadd("inventory:27","ItemM")
pipe.zrem("market:","ItemM.17")
鍵名:market 型別:zset
ItemA.4:35
ItemC.7:48
ItemE.2:60
ItemG.3:73
鍵名:users:27 型別:hash
name:Bill
funds:28
鍵名:users:17 型別:hash
name:Bill
funds:140

如果商品買賣市場有序集合或者Bill的個人資訊在watch和exec執行之前發生了變化,那麼purchase_item()將進行重試,或者在重試操作超時之後放棄此購買操作。

為什麼Redis沒有實現典型的加鎖功能?

在訪問以寫入為目的的資料的時候關係資料會對被訪問的資料進行加鎖,知道事務被提交或者被回滾為止。如果有其他客戶端檢視對被加鎖的資料行進行寫入,那麼該客戶端將被阻塞,直到第一個事務執行完畢為止,加速在實際使用中非常有效,基本上所有關旭資料庫都實現了這種加鎖功能,它的缺點在於,持有鎖的客戶端執行越慢,等待解鎖的客戶端被阻塞的時間就越長。

因為加鎖有可能會造成長時間的等待,所以Redis為了儘可能減少客戶端的等待時間,並不會在執行watch命令時對資料進行加鎖。相反的,Redis只會在資料已經被其他客戶端搶先修改的情況下,通知執行了watch命令的客戶端,這種做法被稱為樂觀鎖,而關聯式資料庫實際執行的加鎖操作則被稱為悲觀鎖。樂觀鎖在實際使用中同樣非常有效,因為客戶端永遠不必花時間去等待第一個獲得鎖的客戶端:他們只需要在自己的事務執行失敗時進行重試就可以了。

這一節介紹瞭如何組合使用watch、multi和exec命令對多種型別的資料進行操作,從而實現遊戲中的商品買賣市場。除了目前已有的商品買賣功能之外,我們還可以為整個市場新增商品拍賣和商品限時銷售等功能,或者讓市場支援更多不同型別的商品排序方式,又或者基於後面的技術,給市場新增更高階的搜尋和過濾公佈。

當有多個客戶端同時對相同的資料進行操作時,正確的使用事務可以有效的防止資料錯誤的發生。而接下來的一節將展示在無須擔心資料被其他客戶端修改了的情況下,如果以更快地速度執行操作。

上一篇文章:Python–Redis實戰:第四章:資料安全與效能保障:第5節:處理系統故障
下一篇文章:Python–Redis實戰:第四章:資料安全與效能保障:第7節:非事務型流水線

相關文章