上一篇文章: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节:非事务型流水线

Mark
662 声望344 粉丝

talk is cheap,show me the code