Python--Redis實戰:第四章:數據安全與性能保障:第6節:Redis事務

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

爲了確保數據的正確性,咱們必須認識到這一點:在多個客戶端同時處理相同的數據時,不謹慎的操做很容易會致使數據出錯。本節將介紹使用Redis事務來防止數據出錯的方法,以及在某些狀況下,使用事務來提高性能的方法。redis

Redis的事務和傳統關係數據庫的事務並不相同。在關係數據庫中,用戶首先向數據庫服務器發送begin,而後執行各個相互一致的寫操做和讀操做,最後,用戶能夠選擇發送commit來確認以前所作的修改,後者發送rollback來放棄那些修改。數據庫

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

延遲執行事務有助於提高性能

由於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節:非事務型流水線
相關文章
相關標籤/搜索