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