Redis的数据安全与性能保障

1.持久化选项

  Redis提供了2种不同的持久化方法来将数据存储到硬盘里面。一种方法叫快照(snapshotting),它可以将存在于某一时刻的所有数据都写入硬盘里。另一种方法叫只追加文件(append-only file,AOF),它会在执行写命令时,将被执行的写命令复制到硬盘里面。这两种持久化方法既可以同时使用,又可以单独使用,在某些特定情况下甚至可以两种都不使用,具体选择哪种方式根据用户数据以及应用来决定。

  将内存中的数据存储到硬盘的一个主要原因是为了在之后重用数据,或者是为了防止系统故障而将数据而将数据备份到一个远程位置。另外,存储在Redis的数据有可能是经过长时间计算得出的,或者有程序正在使用Redis存储的数据进行计算,所以用户会希望将这些数据存储起来以便以后使用,这样就不必在重新计算。对于一些Redis应用来说,“计算”可能只是简单将另一个数据库的数据复制到Redis里面,但对于另外一些Redis应用来说,Redis存储的数据可能是根据数十亿行日志进行聚合分析得出的。

  下面两组配置选项控制着Redis将数据写入硬盘里面的方式,目前稍微了解一下就可以了。

save 60 1000 #在60秒内,key值发生变化次数超过10000次则进行持久化,生产环境通常默认
stop-writes-on-bgsave-error no #rdb文件在导出过程中出错了的话,Redis将停止写入数据,避免数据不一致
rdbcompression yes #压缩数据
dbfilename dump.rdb #rdb文件名
 
appendonly no  #让系统自己决定什么时候进行持久化
appendfsync everysec #每秒写一次数据到磁盘,比如折中的办法,推荐
no-appendfsync-on-rewrite no #如果正在导出rdb数据,停止aof的写入(aof将保存在一个队列中,rdb备份完成后执行队列,不会丢失数据)
auto-aof-rewrite-percentage 100 #aof文件体积与上次相比增长率达到100%就进行重写(重写相当于记总账,比如对同一个key做了100次操作,我们只需要最后一次的操作,重写就会把多余的操作给忽略掉)
auto-aof-rewrite-min-size 64mb #和上一项组合使用,aof文件达到64M时进行重写(重写会节省掉空间,因为多余的操作被删除了

 

1.1 快照持久化

  Redis可以通过创建快照来获得存储在内存里面的数据在某个时间点上的副本。在创建快照之后,用户可以对快照进行备份,可以将快照复制到其它服务器从而创建具有数据的服务器副本,还可以将快照留在原地重启服务器时使用。

  根据配置,快照将被写入dbfilename选项指定的文件里面,并存储在dir选项指定的路径上面。如果在新的快照文件创建完毕之前,Redis、系统或者硬件这三者之中,那么Redis将丢失最近一次创建快照之后写入的所有数据。

  举个例子,假设Redis目前在内存有10G数据,上一个快照是下午2:35创建成功的。下午3:06,Redis又开始创建新的快照,并在下午3:08快照创建完成前,有35个键进行了更新。如果在下午3:06和3:08间,系统发生崩溃,导致redis无法完成新快照的创建工作,那么Redis将丢失下午2:35之后写入的所有数据。另一方面,如果系统恰好在新的快照文件创建完毕之后崩溃,那么Redis只丢失35个键的更新数据。

  创建快照的办法有以下几种。

  • 客户端可以通过向Redis发送BGSAVE命令来创建一个快照,对于支持BGSAVE命令的平台来说(基本都支持,除了windows),Redis会调用fork来创建一个子进程,然后子进程负责将快照写入硬盘,而父进程则继续处理命令请求。
  • 客户端还可以通过向Redis发送SAVE命令来创建快照,接到SAVE命令的Redis服务器在快照创建完成之前不在响应任何命令。SAVE并不常用,通常都是在没有足够的内存执行BGSAVE的时候,又或者即使等待持久化操作执行完毕也无所谓的情况下,才会使用这个命令。
  • 当Redis通过SHUTDOWN命令接收到关闭服务器的请求时,或者接到标准TERM信号时,会执行一个SAVE命令,阻塞所有客户端,不再执行客户端发送的任何命令,并在SAVE执行完成后关闭服务器。
  • 当一个Redis服务器连接另一个Redis服务器,并向对方发送SYNC命令来开始一次复制操作的时候,如果主服务器目前没有执行BGSAVE操作,或者主服务器并非刚刚执行完BGSAVE操作,那么主服务器就会执行BGSAVE命令。

 

  在只使用快照持久化来保存数据时,一定要记住:在系统发生崩溃,用户将丢失最近一次生成快照之后的所有数据。因此,不能接受这种数据损失的就使用AOF持久化。

  下面介绍几种适合快照持久化的场景。

  1. 个人开发

  在个人开发服务器上,主要考虑快照持久化带来的资源消耗。实验设置save 900 1规则。其中save选项告知Redis,它应该根据这个选项两个值执行BGSAVE。这个规则下,如果服务器距离上次成功生成快照超过了900秒,并在此执行了一次写入操作,那么Redis就会开始一次新的BGSAVE操作。

  如果打算在生产服务器上使用快照持久化并存储大量数据,那么开发生产服务器使用相同的save选项,把开发环境设置贴近生产环境,有助于判断快照是否生成的过于频繁或者过于稀少。(过于频繁会浪费资源,过于稀少会有丢失大量数据的隐患)

 

  2.对日志进行聚合计算

  在对日志进行聚合计算或者对页面浏览量进行分析的时候,要考虑的就是:如果Redis因为崩溃而未成功创建新的快照,那么我们能承受丢失多久的新数据。如果丢失一个小时,那么就配置save 3600 1.在决定好了之后,另一个需要解决的问题就是如何恢复因为故障而被中断的日志处理操作。(需要一些自定义方式来记录日志的相关信息)

 

  3.大数据

  当Redis存储的数据量只有几个GB的时候,使用快照来保存数据是没有问题的。Redis会创建子进程到硬盘里面,生成快照所需的时间非常短。但随着Redis占用内存越来越多,BGSAVE创建子进程的时间会越来越多。如果Redis的内存占用到数十个G,并且剩余空闲内存不多或者Redis运行在虚拟机上,那么执行BGSAVE可能会导致系统长时间停顿,也可能引发系统使用虚拟内存,从而使redis性能降低至无法使用的程度。

  执行BGSAVE而导致停顿时间有多长取决Redis所在系统:对于真实的硬件、VMWARE虚拟机或者KVM虚拟机来说,Redis每占用一个G内存,创建子进程的时间就要增加10-20毫秒;而对于XEN虚拟机来说,根据配置不同,每占用一个G内存,创建该进程的子进程所需时间就要增加200-300毫秒。因此,如果Redis进程占用20GB内存,那么标准硬件运行BGSAVE所创建的子进程将导致Redis停顿200-400毫秒;如果使用的是XEN虚拟机(亚马逊EC2和几个云计算都是用这个),那么相同创建子进程将导致Redis停顿4-6秒。

  为了防止redis因为创建子进程出现停顿,我们可以考虑关闭自动保存,通过手动发送BGSAVE或者SAVE来进行持久化。手动发送BGSAVE一样会引起停顿,唯一不同的是用户可以通过手动发送BGSAVE来控制停顿出现的时间。另一方面,虽然SAVE会一直阻塞redis直到快照生成完毕,但是因为不需要创建子进程,所以不会像BGSAVE一样因为创建子进程而导致redis停顿;并且因为没有子进程抢夺资源,所以SAVE创建快照速度会比BGSAVE快。

  在一台68G的XEN虚拟机上,对一个占用50G内存的redis服务器执行BGSAVE的话,创建子进程都要15秒以上,而生成快照则需要花费15-20分钟;但使用SAVE只需要3-5分钟就可以完成快照的生成工作。

  如果用户能够妥善处理持久化带来大量数据丢失,那么就可以选择持久化。

       

1.2 AOF持久化

  简单来说,AOF持久化会将被执行的写命令写到AOF文件的末尾,来记录数据的变化。因此,Redis只要从头到尾重新执行一次AOF文件包含的所有写命令,就可以恢复AOF文件所记录的数据集。AOF持久化可以通过appendonly yes配置来打开。

文件同步: 在向硬盘写入文件时,至少会发生3件事。当调用file.write()方法对文件进行写入,写入的内容会存储到缓冲区,
然后操作系统会在将来某个时刻将缓冲区的内容写入硬盘,而数据只有在被写入硬盘之后,才算真正保存到了硬盘里。用户可以
通过调用file.flush()方法来请求操作系统尽快将缓冲区数据写入硬盘,但具体何时执行写入由操作系统决定。除此之外,用
户可用命令操作系统将文件同步到硬盘,同步操作会一直阻塞知直到指定文件被写入硬盘为止。当同步操作执行完毕后,即使系统
出现故障也不会对同步的文件造成影响。
选项 同步频率
Always 每个Redis写命令都要同步写入硬盘,这样会降低redis速度
Everysec 每秒执行一次操作,显示的将多个写命令同步到磁盘
No 让操作系统来决定应该合适进行同步

  如果用户使用always的话,那么每个Redis写命令都会被写入硬盘,从而发生系统崩溃出现时的数据丢失到最小。不过遗憾的是,因为这种同步策略需要对硬盘进行大量写入,所以Redis处理命令的速度会受到硬盘性能的限制:转盘式硬盘在这种同步频率下每秒只能处理大约200个写命令,而固态每秒大概也只能处理几万个写。(而且固态硬盘的寿命会缩短)

  为了兼顾数据安全和写入性能,用户可以考虑使用appendfsync everysec选项,让Redis以每秒一次的频率对AOF文件进行同步。Redis每秒同步一次AOF文件时的性能和不使用任何持久化特性时性能相差无几,而通过每秒同步一次AOF文件,Redis可以保证,即使出现系统崩溃,最多也只丢失1秒内的数据。当硬盘忙于执行写入操作时,Redis还会放慢自己的手动一遍适应硬盘的最大写入速度。  如果用户使用always的话,那么每个Redis写命令都会被写入硬盘,从而发生系统崩溃出现时的数据丢失到最小。不过遗憾的是,因为这种同步策略需要对硬盘进行大量写入,所以Redis处理命令的速度会受到硬盘性能的限制:转盘式硬盘在这种同步频率下每秒只能处理大约200个写命令,而固态每秒大概也只能处理几万个写。(而且固态硬盘的寿命会缩短)

  最后,如果要使用 “no”选项,那么Redis将不对AOF文件执行任何显示的同步操作,而是由操作系统来决定合适对AOF文件进行同步。这选项在系统崩溃将导致使用这种选项的Redis服务器丢失不定数量的数据。另外,如果硬盘处理处理写入速度不够快的话,那么当缓冲区被等待写入硬盘的数据填满时,Redis的写入操作将被阻塞,并导致redis处理请求速度变慢。

 

1.3 重写/压缩AOF文件

  在上一小节后,读者可能会有疑惑:AOF持久化尽可以将丢失数据的时间降到1秒甚至不丢失数据,为什么有理由不用AOF呢?这个问题实际没有这么简单,因为Redis会不断的将被执行的写命令记录到AOF文件里面,所以随着Redis的不断运行,AOF体积也会不断增长,可能会撑满磁盘。还有一个问题就是,因为Redis在重启之后需要通过重新执行AOF文件记录的所有命令来还原数据集,所以如果AOF文件体积非常大,那么还原操作执行时间就可能会非常长。

  为了解决AOF文件不断增大的问题,可以使用BGREWRITEAOF命令,这个命令会通过移除AOF文件的冗余命令来重写AOF文件,使AOF文件的体积尽可能的小。BGREWRITEAOF的工作原理和BGSAVE创建快照的工作原理非常相似:Redis会创建一个子进程,然后由子进程负责对AOF文件进行重写。因为AOF重写也需要用到子进程,所以快照持久化也会因为创建子进程而导致性能问题和内存问题。更糟糕的是,如果不加以控制,AOF文件的体积可能会比快照文件的体积大好几倍,在进行AOF重写并删除旧AOF文件的时候,删除一个体积达到数十G的大的旧AOF文件可能会导致操作系统挂起数秒。

  跟快照持久化可以通过设置save选项来自动执行BGSAVE一样,AOF持久化也可以通过设置auto-aof-rewrite-percentage选项和auto-aof-rewrite-min-size选项来自动执行BGREWRITEAOF。举个例子,假设用户对Redis设置了配置选项auto-aof-rewrite-percentage100 和auto-aof-rewrite-min-size 64m,并且启用了aof持久化,那么当aof文件的体积大于64MB,并且AOF文件的体积比上一次重写执行得过于频繁的话,用户可以考虑将auto-aof-rewrite-percentage选项设置为100以上,这种做法可让redis的AOF文件体积变得更大之后才执行重写操作,不过也会让redis在启动时还原数据集所需时间更长。

  通过使用AOF持久化或者快照持久化,用户可以在系统重启或者崩溃的时候仍然保留数据。虽然负载上升,或者数据完整性变得更重要时,用户可能需要使用复制特性。

 

2.复制

  对于有扩展平台以适应更高负载的工程师来说,复制是不可或缺的。复制可以让其他服务器拥有一个不断更新的数据副本,从而使得拥有数据副本的服务器可以用于处理客户端的读请求。关系数据库通常会使用一个主服务器向多个从服务器发送更新,并使用从服务器来处理所有的读请求。Redis也采用了同样的方法来实现自己的复制特性,作为一种扩展性能的一种手段。

  尽管redis很优秀,但是也会遇上没办法快速的处理请求的情况,特别是在对集合和有序集合进行操作的时候,设计的元素可能有上万个甚至上百万个,在这种情况下,执行操作所花费的时间可能需要以秒计算,而不是微秒和毫秒。但即使一个命令只需要花费10毫秒完成,单个redis实例1秒也只能处理100个命令。

  在需要扩展读请求的时候,或者在需要写入临时数据的时候,用户可以通过设置额外的redis从服务器来保存数据集的副本。在接收到主服务器发送的数据初始副本之后,客户端每次向主服务器进行写入时,从服务器都会实时得到更新。在部署好主从服务器后,客户端就可以向任意一个从服务器发送读请求了,而不必每次把请求发送给主服务器(客户端通常会随机的选择从服务器,从而将负载平均分配到各个从服务器上)

 

2.1 配置redis的配置选项

  当从服务器连接主服务器的时候,主服务器会执行BGSAVE操作。因此为了正确的使用复制特性,用户需要保证主服务器正确设置了dir选项和dbfilename选项,并且这两个选项所指示的路径和文件对于redis进程来说都是可写的。

  尽管有很多不同的选项可以控制从服务器的自身行为,但开启从服务器所必须的选项只有slaveof一个。如果用户在启动redis服务器的时候,指定了一个包含slaveof host port 选项的配置文件,那么redis服务器将根据该选项给定的ip地址和端口号来连接主服务器。对于一个正在运行的redis服务器,用户可以通过发送SLAVE no one命令来让服务器终止复制操作,不在接受主服务器的数据更新;也可以通过SLAVEOF host port 命令来让服务器开始复制一个新的主服务器。

  开启redis的主从复制特性并不需要进行太多的配置,主要是要了解。

 

2.2 Redis复制的启动过程

  从服务器在连接一个主服务器的时候,主服务器会创建一个快照文件并将其发送至从服务器,但这个只是主从复制执行过程的其中一部,下图列出了当从服务器连接主服务器时,主从服务器执行的所有操作。

表 4 - 2 从服务器连接主服务器时的步骤
步骤 主服务器操作 从服务器操作
1 ( 等待命令进入) 连接(或者重连接)主服务器,发送SYNC命令。
2

开始执行BGSAVE,并使用缓冲区记录 BGSAVE之后执行的所有写命令

根据配置选项来决定是继续使用现有的数据(如果有的话)来处理客户端的命令请求,

还是向发送请求的客户端返回错误

3

BGSAVE 执行完毕,向从服务器发送快照文件,

并在发送期间继续使用缓冲区记录被执行的写命令

丢弃所有旧数据(如果有的话),开始载入主服务器发来的快照文件
4

快照文件发送完毕,

开始向从服务器发送存储在缓冲区里面的写命令

完成对快照文件的解释操作,像往常一样开始接受命令请求
5

缓冲区存储的写命令发送完毕:从现在开始,

每执行一个写命令,就向从服务器发送相同的写命令

执行主服务器发来的所有存储在缓冲区里面的写命令;并从现在开始,

接收并执行主服务器传来的每个写命令

  通过上图的办法,redis在复制进行期间也会尽可能处理接收到的命令请求,但是,如果主从服务器之间的网络带宽不足,或者主服务器没有足够的内存来创建子进程和创建记录写命令的缓冲区,那么redis处理命令请求就会受到影响。因此,尽管这并不是必须的,但在实际中还是最好让主服务器只使用百分之50-65的内存,留下30-45的内存用于执行BGSAVE命令和创建记录写命令的缓冲区。

  设置从服务器步骤非常简单,用户既可以通过选项SLAVEOF host port 来将一个Redis服务器设置为从服务器,又可以通过向运行中的redis服务器发送SLAVEOF命令来将其设置为从服务器。如果用户使用的是SLAVEOF选项,那么Redis在启动时首先会载入当前可用的任何快照文件或者AOF文件,然后连接主服务器并执行上面图中的复制过程。如果用户使用的是SLAVEOF命令,那么Redis会立即尝试连接主服务器,并在连接成功之后,开始上图的复制过程 

从服务在进行同步时,会清空自己的所有数据:因为有些用户在第一次使用从服务器时会忘记这件事,
所以这里提醒一下:从服务器在与主服务器进行初始连接时,数据库中原有的所有数据都将丢失,并
被替换成主服务器发来的数据。
警告:Redis不支持主主复制 因为Redis予许用户在服务器启动之后使用SLAVEOF命令来设置从服务器选项,所
以有人认为可以通过将两个redis实例互相设置为对方的主服务器实现多主复制,但是,这是不可以的:因为被互
相设置的主服务器的两个Redis实例只会持续占用大量处理器资源并且连续不断尝试与对方通信,根据客户端连接
服务器的不同,客户端的请求可能会得到不一致的数据,或者完全得不到数据

  当多个从服务器尝试连接同一个主服务器的时候,就会出现下图的情况。

表 4-3 当一个从服务器连接一个已有的主服务器时,有时可以重用已有的快照文件

当有新的从服务器连接主服务器时

主服务器的操作

表4-2的步骤3尚未执行

所有从服务器都会接收到相同的快照文件和相同的缓冲区写命令

表4-2的步骤3 正在执行或者已经执行完毕

当主服务器与较早进行连接的从服务器执行完复制所需的5 个步骤之后,主服务器会与新连接的从服务器执行一次新的步骤1至步骤5

  在大部分情况下, Redis都会尽可能地减少复制所需的工作,然而,如果从服务器连接主服务器的时间并不凑巧,那么主服务器就需要多做一些额外的工作。另一方面,当多个从服务器同时连接主服务器的时候,同步多个从服务器所占用的带宽可能会使得其他命令请求难以传递给主服务器,与主服务器位于同一网络中的其他硬件的网速可能也会因此而降低。

 

2.3 主从链

  有些用户发现,创建多个从服务器可能会造成网络不可用---当复制需要通过互联网进行或者需要在不同数据中心之间进行时,尤为如此。因为 Redis的主服务器和从服务器并没有特别不同的地方,所以从服务器也可以拥有自己的从服务器,并由此形成主从链( master/slave chaining)

  从服务器对从服务器进行复制在操作上和从服务器对主服务器进行复制的唯一区别在于如果从服务器X拥有从服务器r,那么当从服务器X在执行表42中的步骤4时,它将断开与从服务器y的连接,导致从服务器y需要重新连接并重新同步( resync)

  当读请求的重要性明显高于写请求的重要性,并且读请求的数量远远超出一台Redis服务器可以处理的范围时,用户就需要添加新的从服务器来处理读请求。随着负载不断上升,主服务器可能会无法快速地更新所有从服务器,或者因为重新连接和重新同步从服务器而导致系统超载为了缓解这个问题,用户可以创建一个由Redis主从节点( master/slave node)组成的中间层来分担主服务器的复制工作,如下图

  尽管主从服务器之间并不一定要像上图那样组成一个树状结构,但记住并理解这种树状结构对于 Redis复制来说是可行的(possible)并且是合理的( reasonable)将有助于读者理解之后的内容。曾经介绍过,AOF持久化的同步选项可以控制数据丢失的时间长度:通过将每个写命令同步到硬盘里面,用户几乎可以不损失任何数据(除非系统崩溃或者硬盘驱动器损坏)但这种做法会对服务器的性能造成影响;另一方面,如果用户将同步的频率设置为每秒一次,那么服务器的性能将回到正常水平,但故障可能会造成1秒的数据丢失。通过同时使用复制和AOF持久化,我们可以将数据持久化到多台机器上面。

  为了将数据保存到多台机器上面,用户首先需要为主服务器设置多个从服务器,然后对每个从服务器设置 appendonly yes选项和 appendfsync everysec选项(如果有需要的话也可以对主服务器进行相同的设置).这样的话,用户就可以让多台服务器以每秒一次的频率将数据同步到硬盘上了。但这还只是第一步:因为用户还必须等待主服务器发送的写命令到达从服务器,并且在执行后续操作之前,检查数据是否已经被同步到了硬盘里面。

 

3.处理系统故障

  用户必须做好相应的准备来应对Redis的系统故障。本章在系统故障这个主题上花费了大量的篇幅,这是因为如果我们决定要将Redis用作应用程序唯一的数据存储手段,那么就必须确保Redis不会丢失任何数据。跟提供了ACID保证的传统关系数据库不同,在使用Redis为后端构建应用程序的时候,用户需要多做一些工作才能保证数据的一致性。Redis是一个软件,它运行在硬件之上,即使软件和硬件都设计得完美无瑕,也有可能会出现停电、发电机因为燃料耗尽而无法发电或者备用电池电量消尽等情况。这一节接下来将对Redis提供的一些工具进行介绍,说明如何使用这些工具来应对潜在的系统故障。下面先来看看在出现系统故障时,用户应该采取什么措施。

 

3.1 验证快照文件和AOF文件

  无论是快照持久化还是AOF持久化,都提供了在遇到系统故障时进行数据恢复的工具。Redis提供了两个命令行程序redis-check-aof和redis-check-dump,它们可以在系统故障发生之后,检查AOF文件和快照文件的状态.并在有需要的情况下对文件进行修复。在不给定任何参数的情况下运行这两个程序,就可以看见它们的基本使用方法:

 redis-check-aof
Usage: redis-check-aof (--fix) <file.aof>
$ redis-check-dump
Usage: redis-check-dump <dump.rdb>

  如果用户在运行redis-check-aof程序时给定了一fix参数,那么程序将对AOF文件进行修复。程序修复AOF文件的方法非常简单:它会扫描给定的AOF文件,寻找不正确或者不完整的命令,当发现第一个出错命令的时候,程序会删除出错的命令以及位于出错命令之后的所有命令,只保留那些位于出错命令之前的正确命令。在大多数情况下,被删除的都是AOF文件末尾的不完整的写命令。

  遗憾的是,目前并没有办法可以修复出错的快照文件。尽管发现快照文件首个出现错误的地方是有可能的,但因为快照文件本身经过了压缩,而出现在快照文件中间的错误有可能会导致快照文件的剩余部分无法被读取。因此,用户最好为重要的快照文件保留多个备份,并在进行数据恢复时,通过计算快照文件的SHA1散列值和SHA256散列值来对内容进行验证。(当今的Linux平台和Unix平台都包含类似shalsum和sha256sum这样的用于生成和验证散列值的命令行程序。)

  在了解了如何验证持久化文件是否完好无损.并且在有需要时对其进行修复之后,我们接下来要考虑的就是如何更换出现故障的Redis服务器

 

3.2 更换故障主服务器

  在运行一组同时使用复制和持久化的 Redis 服务器时,用户迟早都会遇上某个或某些 Redis服务器停止运行的情况。造成故障的原因可能是硬盘驱动器出错、内存出错或者电量耗尽,但无论服务器因为何种原因出现故障,用户最终都要对发生故障的服务器进行更换。现在让我们来看看,在拥有一个主服务器和一个从服务器的情况下,更换主服务器的具体步骤

  假设 A 、B 两台机器都运行着 Redis, 其中机器 A 的 Redis 为主服务器,而机器 B 的 Redis为从服务器。不巧的是,机器 A 刚刚因为某个暂时无法修复的故障而断开了网络连接,因此用户决定将同样安装了Redis 的机器 C 用作新的主服务器。更换服务器的计划非常简单:首先向机器 B 发送一个 SAVE 命令,让它创建一个新的快照文件,接着将这个快照文件发送给机器 C, 并在机器 C 上面启动 Redis, 最后,让机器 B 成为机器C 的从服务器) 

1.    user@vpn-master ~:$ ssh root@machine-b.vpn                            通过 VPN 网络连接机器B。
2.    Last login : Wed Mar 28 15:21:06 2012 from ...
3.    root@machine-b ~:$ redis-cli
4.    redis 127.0.0.1:6379 > SAVE                                                        执行 SAVE 命令,并在命令完成之后,使用 QUIT命令退出客户端
5.    OK 
6.    redis 127.0.0.1:6379> QUIT
7.    root@machine-b ~:$ scp \                                                              将快照文件发送至新的主服务器-机器 C 。
8.    > /var/local/redis/dump.rdb machine-c.vpn:/var/local/redis/    
9.    dump.rdb  100%  525MB 8.1MB/s  01:05
10.    root@machine-b ~:$ ssh machine-c.vpn
11.    Last login : Tue Mar 27 12:42:31 2012 from ...
12.    root@machine-c ~ :$ sudo /etc/init.d/redis-server start                            连接新的主服务器并启动Redis 
13.    Starting Redis server...
14.    root@machine-c ~ :$ exit
15.    root@machine-b  ~ redis-cli
16.    redis 127.0.0.1:6379> SLAVEOF machine-c.vpn 6379                              告知机器 B 的 Redis,让它将机器C用作新的主服务器
17.    0K
18.    redis 127.0.0.1:6379> QUIT
19.    root@machine-b ~:$ exit
20.    user®vpn-master ~:$

  上述命令对于使用和维护 Unix 系统或者 Linux 系统的人来说应该都不会陌生。在这些命令当中,比较有趣的要数在机器 B 上运行的 SAVE 命令,以及将机器B 设置为机器 C 的从服务器的 SLAVEOF 命令。

  另一种创建新的主服务器的方法,就是将从服务器升级 (turn) 为主服务器,并为升级后的主服务器创建从服务器。以上列举的两种方法都可以让 Redis 回到之前的一个主服务器和一个从服务器的状态,而用户接下来要做的就是更新客户端的配置,让它们去读写正

确的服务器 除此之外,如果用户需要重启Redis的话,那么可能还需要对服务器的持久化配置进行更新。

 

4.Redis事务

  为了保证数据的正确性,我们必须认识到这一点:在多个客户端同时处理相同的数据时,不谨慎的操作很容易导致数据出错。本节将介绍使用Redis事务来防止数据出错的方法,以及在某些情况下,使用事务来提升性能的方法。

  Redis的事务和传统关系数据库的事务并不相同。在关系数据库中,用户首先向数据库服务器发送BEGIN,然后执行各个相互一致(consistent)的写操作和读操作,最后,用户可以选择发送COMMIT来确认之前所做的修改.或者发送ROLLBACK来放弃那些修改。

  在Redis里面也有简单的方法可以处理一连串相互一致的读操作和写操作。Redis的事务以特殊命令MULTI为开始,之后跟着用户传入的多个命令,最后以EXEC为结束。但是由于这种简单的事务在EXEC命令被调用之前不会执行任何实际操作,所以用户将没办法根据读取到的数据来做决定。这个问题看上去似乎无足轻重,但实际上无法以一致的形式读取数据将导致某一类型的问题变得难以解决.除此之外.因为在多个事务同时处理同一个对象时通常需要用到二阶提交(two-phase commit),所以如果事务不能以一致的形式读取数据,那么二阶提交将无法实现.从而导致一些原本可以成功执行的事务沦落至执行失败的地步。比如说:“在市场里面购买一件商品”就是其中一个会因为无法以一致的形式读取数据而变得难以解决的问题.本节接下来将在实际环境中对这个问题进行介绍。

延迟执行事务有助于提升性能:因为Redis在执行事务的过程中,会延迟执行已入队的命令直到客户端发送EXEC命令为止.
本章使用的Python客户端在内的很多Redis客户端都会等到事务包含的所有命令都出现了之后,才一次性地将MULTI命令. 
要在事务中执行的一系列命令.以及EXEC命令全部发送给Redis,然后等待直到接收到所有命令的回复为止.这种“一次性发
送多个命令,然后等待所有回复出现”的做法通常被称为流水线(pipelining),它可以通过减少客户端与Redis服务器之
间的网络通信次数来提升Redis在执行多个命令时的性能.

  本节使用一个商品买卖的例子来举例,设计和实现这个商品买卖市场的方法,并说明如何对这个商品买卖市场进行扩展。

 

4.1 定义用户信息和用户包裹

  下图展示了游戏中用于表示用户信息和用户包裹(inventory)的结构:用户信息存储在一个散列里面.散列的各个键值对分别记录了用户的姓名、用户拥有的钱数等属性。用户包裹使用一个集合来表示.它记录了包褰里面每件商品的唯一编号。

  商品买卖市场的需求非常简单:一个用户(卖家)可以将自己的商品按照给定的价格放到市场上进行销售.当另一个用户(买家)购买这个商品时,卖家就会收到钱。另外,本节实现的市场只能根据商品的价格来进行排序。

  为了将被销售商品的全部信息都存储到市场里面,我们会将商品的ID和卖家的ID拼接起来,并将拼接的结果用作成员存储到市场有序集合(market ZSET )里面,而商品的售价则用作成员的分值。通过将所有数据都包含在一起,我们极大地简化了实现商品买卖市场所需的数据结构.并且因为市场里面的所有商品都按照价格排序.所以针对商品的分页功能和杳找功能都可以很容易地实现。下图展示了一个只包含数个商品的市场例子。既然我们已经知道了实现商品买卖市场所需的数据结构,那么接下来该考虑如何实现市场的商品上架功能了。

 

4.2 将商品放到市场上销售

  为了将商品放到市场上进行销售,程序除了要使用MULTI命令和EXEC命令之外,还需要配合使用WATCH命令,有时候甚至会用到UNWATCH或DISCARD命令。在用户使用WATCH命令对键进行监视之后,直到用户执行EXEC命令的这段时间里面,如果有其他客户端抢先对任何被监视的键进行了替换、更新或删除等操作,那么当用户尝试执行EXEC命令的时候.事务将失败并返回一个错误(之后用户可以选择重试事务或者放弃事务)。通过使用WATCH、MULTI/EXEC .UNWATCH/D1SCARD等命令,程序可以在执行某些重要操作的时候,通过确保自己正在使用的数据没有发生变化来避免数据出错。

什么是DISCARD? UNWATCH命令可以在WATCH命令执行之后、MULTI命令执行之前对连接进行重置(reset);同样地,
DISCARD命令也可以在MULTI命令执行之后、EXEC命令执行之前对连接进行重置.这也就是说,用户在使用WATCH监视一
个或多个键,接着使用MULTI开始一个新的事务,并将多个命令入队到事务队列之后,仍然可以通过发送DISCARD命令来
取消WATCH命令并清空所有已入队命令.本章展示的例子都没有用到DISCARD,主要原因在于我们已经清楚地知道自己是否
想要执行MULTI/EXEC或者UNWATCH,所以没有必要在这些例子里面使用DISCARD.

  在将一件商品放到市场上进行销售的时候,程序需要将被销售的商品添加到记录市场正在销售的有序集合里面,并且在添加操作执行的过程中,监视卖家的包裹以确保被销售的商品的确存在于卖家的包提当中,下图展示了这一操作的具体实现

1.    def list_item(conn, itemid, sellerid, price ) :
2.           inventory = "inventory: %s"%sellerid
3.           item = "%s.%s"%(itemid, sellerid)
4.           end = time.time() + 5
5.           pipe = conn.pipeline()
6.           while time.time() < end:
7.                 try:
8.                      pipe.watch(inventory)                                      监视用户包括发生的变化
9.                      if not pipe . sismetnber (inventory, itemid)     检查用户是否热然持有将要被销售的商品
10.                              pipe.unwatch()       如果指定的商品不在用户的包括里面,那么停止对包括键的监视并返回一个空值
11.                              return None
12.                     pipe.multi()
13.                     pipe.zadd("market:", item, price)
14.                     pipe.srem(inventory, itemid)
15.                     pipe.execute()                                 如果执行execute方法没有引发WatchError异常,那么说明事务执行成功,并对包括键的监视也结束
16.                     return True                            
17.                 except redis.exceptions.WatchError:                        用户包括已经发生变化,重试
18.                     pass
19.                 return False

  List_item()函数的行为就和我们之前描述的一样:它首先执行一些初始化步骤,然后对卖家的包裹进行监视,验证卖家想要销售品是否仍然存在于卖家的包裹当中,如果是的话,函数就会将被销售的商品添加到买卖市场里面,并从卖家的包裹中移除该商品。正如函数中的while循环所示,在使用WATCH命令对包裹进行监视的过程中,如果包裹被更新或者修改,那么程序将接收到错误并进行重试。

  下图展示了当Frank (用户ID为17)尝试以97块钱的价格销售ItemM时,list_item() 函数的执行过程。

  因为程序会确保用户只能销售他们自己所拥有的商品,所以在一般情况下,用户都可以顺利地将自己想要销售的商品添加到商品买卖市场上面,但是正如之前所说.如果用户的包裹在WATCH执行之后直到EXEC执行之前的这段时间内发生了变化,那么添加操作将执行失败并重试。

  在弄懂了怎样将商品放到市场上销售之后,接下来让我们来了解一下怎样从市场上购买商品。

 

4.3 购买商品

  下图中的purchase_item()函数展示了从市场里面购买一件商品的具体方法:程序首先使用WATCH对市场以及买家的个人信息进行监视,然后获取买家拥有的钱数以及商品的售价,并检杳买家是否有足够的钱来购买该商品,如果买家没有足够的钱,那么程序会取消事务;相反地,如果买家的钱足够,那么程序首先会将买家支付的钱转移给卖家.然后将售出的商品移动至买家的包裹,并将该商品从市场中移除。当买家的个人信息或者商品买卖市场出现变化而导致WatchError异常出现时,程序将进行重试,其中最大重试时间为10秒。

1.    def purchase_item(conn, buyerid, itemid, sellerid, lprice ) :
2.          buyer = "users : %s"%buyerid
3.          seller = "users : %s"%sellerid
4.           item = "%s.%s"%(itemid, sellerid)
5.           inventory = "inventory : %s"%buyerid
6.           end = time.time() + 10
7.           pipe = conn.pipeline()
8.           while time.time() < end :
9.                try10.                      pipe.watch("market:", buyer)                      对商品买卖市场以及买家的个人信息进行监视
11.                      price = pipe.zscore("market:", item)                      
12.                      funds = int(pipe.hget(buyer, "funds"))              检查买家想要购买的商品的价格是否出现了变化,以及买家是否有足够的钱来购买这件商品
13.                      if price != lprice or price > funds : 
14.                           pipe.unwatch()
15.                           return None
16.                      pipe.multi()
17.                      pipe.hincrby(seller, "funds", int(price))                   先将买家支付的钱转移给卖家,然后将被购买的商品移交给买家
18.                      pipe.hincrby(buyer, "funds"; int(-price))
19.                      pipe.sadd(inventory, itemid)
20.                      pipe.zrem("market:", item)
21.                      pipe.execute()
22.                      return True
23.                except redis.exceptions.WatchError :                          如果买家的个人信息或者商品买卖市场在交易的过程中出现了变化,那么进行重试
24.                     pass
25.            return False

  在执行商品购买操作的时候.程序除了需要花费大量时间来准备相关数据之外,还需要对商品买卖市场以及买家的个人信息进行监视:监视商品买卖市场是为了确保买家想要购买的商品仍然有售(或者在商品已经被其他人买走时进行提示),而监视买家的个人信息则是为了验证买家是否有足够的钱来购买自己想要的商品.

  当程序确认商品仍然存在并且买家有足够钱的时候,程序会将被购买的商品移动到买家的包裹里面,并将买家支付的钱转移给卖家。

  在观察了市场上展示的商品之后,Bill(用户ID为27)决定购买Frank在市场上销件的ItemM.图4-5和图4-6展示了购买操作执行期间,数据结构是如何变化的

  正如之前的代码清单4-6所示,如果商品买卖市场有序集合(market ZSET)或者Bill的个人信息在WATCH和EXEC执行之间发生了变化,那么purchase_item ()将进行重试,或者重试操作超时之后放弃此次购买操作。

为什么Redis没有实现典型的加锁功能? 在访问以写入为目的数据的时候(SQL中的SELECT FOR UPDATE ),关系数据库会对
被访问的数据行进行加锁,直到事务被提交(COMMIT )或者被回滚(ROLLBACK)为止.如果有其他客户端试图对被加锁的数据行
进行写入,那么该客户端将被阻塞,直到第一个事务执行完毕为止.加锁在实际使用中非常有效,基本上所有关系数据库都实现了
这种加锁功能,它的缺点在于,持有锁的客户端运行越慢,等待解锁的客户墙被阻塞的时间就越长.
因为加锁有可能会造成长时间的等待,所以Redis为了尽可能地减少客户端的等待时间,并不会在执行WATCH命令时对数据进行
加锁.相反地.Redis只会在数据已经被其他客户端抢先修改了的情况下,通知执行了 WATCH命令的客户端,这种做法被称为乐观
锁(optimisticlocking),而关系数据库实际执行的加锁操作则被称为悲观锁(pessimistic locking).乐观锁在实际使用中
同样非常有效,因为客户端永远不必花时间去等待第一个取得锁的客户端一它们只需要在自己的事务执行失败时进行重试就可以了

  当有多个客户端同时对相同的数据进行操作时,正确地使用事务可以有效地防止数据错误发生。而接下来的一节将向我们展示,在无需担心数据被其他客户端修改了的情况下,如何以更快的速度执行操作。

 

4.4 非事务型流水线

  在首次介绍MULTI和EXEC的时候讨论过它们的“事务”性质——被MULTI和EXEC包裹的命令在执行时不会被其他客户端打扰。而使用事务的其中一个好处就是底层的客户端会通过使用流水线来提高事务执行时的性能。本节将介绍如何在不使用事务的情况下.通过使用流水线来进一步提升命令的执行性能。

  曾经介绍过一些可以接受多个参数的添加命令和更新命令,如MGET、MSET、HMGET、HMSET, RPUSH和LPUSH、SADD、ZADD等。这些命令简化了那些需要重复执行相同命令的操作,并且极大地提升了性能尽管效果可能没有以上提到的命令那么显著,但使用非事务物流水线(non-transaclional pipeline)同样可以获得相似的性能提升,并且可以让用户同时执行多个不同的命令。

  在需要执行大量命令的情况下,即使命令实际上并不需要放在事务里面执行,但是为了通过一次发送所有命令来减少通信次数并降低延迟值,用户也可能会将命令包裹在MULTI和EXEC里面执行。遗憾的是,MULTI和EXEC并不是免费的一它们也会消耗资源.并且可能会导致其他也要的命令被延迟执行。不过好消息是,我们实际上可以在不使用MULTI和EXEC的情况下.获得流水线带来的所有好处 在前面章节都使用了以下语句来在Python中执行MULTI和EXEC命令:

pipe = conn.pipeline()

  如果用户在执行pipeline()时传入True作为参数,或者不传入任何参数,那么客户端将使用MULTI和EXEC包裹起用户要执行的所有命令。另一方面,如果用户在执行pipeline()时传入False为参数.那么客户端同样会像执行事务那样收集起用户要执行的所有命令,只是不再使用MULTI和EXEC包裹这些命令。如果用户需要向Redis发送多个命令,并且对于这些命令来说,一个命令的执行结果并不会影响另一个命令的输入,而且这些命令也不需要以事务的方式来执行的话,那么我们可以通过向pipeline()方法传入False来进一步提升Redis的整体性能。让我们来看一个这方面的例子。

  这里简单举一个例子,这是没升级之前的代码

1.    def  update_token(conn, token, user, item=None ) :
2.            timestamp = time.time()                                     获取时间戳
3.            conn.hset('login:', token, user)                             创建令牌与已登录用户之间的映射
4.            conn.zadd('recent:', token, timestamp)                记录令牌最后一次出现时间
5.            if item : 
6.                conn.zadd('viewed:' + token, item, timestamp)              把用户浏览过的商品记录起来
7.                conn. zremrangebyrank ( 'viewed: ' + token, 0, -26)             移除旧商品,只记录最新浏览的25件商品
8.                conn.zincrby('viewed:',  item, -1)                      更新给定商品的被浏览次数

  如果Redis和Web服务器通过局域网进行连接,那么它们之间的每次通信往返大概需要耗费一两毫秒,因此需要进行2次或若5次通信往返的update_token()函数大概需要花费2-10秒来执行,按照这个速度计算,单个Web服务器线程每秒可以处理100〜500个请求。尽管这种速度已经非常可观,但我们还可以在这个速度的基础上更进一步:通过修改update_token()函数,让它创建一个非事务型流水线,然后使用这个流水线来发送所有请求.这样我们就得到了代码下图 展示的 update_token_pipeline ()函数。

1.    def update_token_pipeline(conn, token, user, item=None ) :
2.           timestamp = time.time()
3.           pipe = conn.pipeline(False)                               设置流水线 
4.           pipe.hset('login: ', token, user)
5.           pipe.zadd('recent:', token, timestamp)
6.           if item:
7.               pipe.zadd('viewed:' + token, item, timestamp)
8.               pipe.zremrangebyrank('viewed:' + token, 0, -26)
9.               pipe.zincrby('viewed: ', item, -1)
10.              pipe.execute()                                                    执行那些被流水线包裹的命令

  通过将标准的Redis连接替换成流水线连接,程序可以将通信往返的次数减少至原来的½到⅕ ,并将update_token_pipeline()函数的预执行时间降低至1〜2毫秒,按照这个速度来计算的话如果一个Web服务器只需要执行update_token_pipeline()来更新商品的浏览信息,那么这web服务器每秒可以处理500〜1000个请求。从理论上来看,update_token_pipeline ()函数的效果非常棒,但是它的实际运行速度又是怎样的呢?

  为了回答这个问题,我们将对update_token()函数和update_token_pipeline()函数进行一些简单的测试。我们将分别通过快速低延迟网络和慢速高延迟网络来访问同一台机器.并测试运行在机器上面的Redis每秒可以处理的请求数量,下面图片展示了进行性能测试的函数,这个函数会在给定的时限内重复执行update_token()函数或者update_token_pipeline ()函数,然后计算被测试的函数每秒执行了多少次。

1.    def benchmark_update_token(conn, duration ) :
2.          for function in (update_token, update_token_pipeline ) :    测试会分别执行update_token()函数和update_token_pipeline()函数
3.                 count = 0                                                            设置计数器以及测试结束的条件
4.                 start = time.time()
5.                 end = start + duration
6.                 while time.time() < end:
7.                           count += 1
8.                           function(conn, 1 token1, 1 user1, 1 item')                    调用两个函数的其中一个
9.                 delta = time.time() - start                                                       计算函数的执行时长
10.                print function. __ name __ , count, delta, count / delta           打印测试结果

  下图展示了在不同带宽以及不同延迟值的网络上执行性能测试函数所得到的数据

  在不同类型的网络上执行流水线和非流水线连接:对于高速网络,测试程序几乎达到了单核处理器可以编码/解码Redis命令的极限:而对于低速网络,测试程序的运行则受到网络带宽和延迟值的影响。

描述 带宽 延迟值 每秒调用update_table()的次数 每秒调用update_table_pipeline()的次数
本地服务 器,Unix域套接字 大于1 Gb (gigabit) 0.015ms 3761 6394
本地服务器,本地连接 大于1Gb 0.015ms 3257 5991
远程服务器,共享交换机 1Gb 0.271ms 739 2841
远程服务器,通过VPN连接 1.8Mb (megabit) 48ms 3.67 18.2

  根据上图的结果高延迟网络使用流水线时的速度要比不使用流水线时的速度快5倍,低延迟网络使用流水线也可以带来接近4倍的速度提升.而本地网络的测试结果实际上已经达到了 Python在单核环境下使用Redis协议发送和接收短命令序列的性能极限。

  现在我们已经知道如何在不使用事务的情况下,通过使用流水线来提升Redis的性能了,那么除了流水线之外,还有其他可以提升Redis性能的常规(standard)方法吗?

 

4.5 关于性能方面的注意事项

  习惯了关系数据库的用户在刚开始使用Redis的时候,通常会因为Redis带来的上百倍的性能提升而感到欣喜若狂,却没有认识到Redis的性能实际上还可以做进一步的提高 虽然上一节介绍的非事务型流水线可以尽可能地减少应用程序和Redis之间的通信往返次数,但是对于一个已经存在的应用程序,我们应该如何判断这个程序能否被优化呢?我们又应该如何对它进行优化呢?

  要对Redis的性能进行优化,用户首先需要弄清楚各种类型的Redis命令到底能跑多块,而这一点可以通过调用Redis附带的性能测试程序redis-benchmark来得知,下图展示了一个相应的例子。如果有兴趣的话,读者也可以试着用redis-benchmark来了解Redis在自己服务器上的各种性能特征。

1.    代码清单4-10 在装有英特尔酷睿2双核2.4GHz 处理器的台式电脑上运行 redis-benchmark
2.    $ redis-benchmark -c 1 -q
3.    PING (inline) : 34246.57 requests per second
4.    PING: 34843.21 requests per second
5.    MSET (10 keys) : 24213.08 requests per second
6.    SET: 32467.53 requests per second
7.    GET: 33112.59 requests per second
8.    INCR: 32679.74 requests per second
9.    LPUSH: 33333.33 requests per second
10.    LPOP: 33670.04 requests per second
11.    SADD: 33222.59 requests per second
12.    SPOP: 34482.76 requests per second

  redis-benchmark的运行结果展示了一些常用Redis命令在1秒内可以执行的次数。如果用户在不给定任何参数的情况下运行redis-benchmark,那么redis-benchmark将使用50个客户端来进行性能测试,但是为了在redis-benchmark和我们自己的客户端之间进行性能对比,让redis-benchmark只使用一个客户端要比使用多个客户端更方便一些。

  在考察redis-benchmark的输出结果时,切记不要将输出结果看作是应用程序的实际性能,这是因为redis-benchmark不会处理执行命令所获得的命令回复,所以它节约了大量用于对命令回复进行语法分析的时间。在一般情况下,对于只使用单个客户端的redis-benchmark来说,根据被调用命令的复杂度,一个不使用流水线的Python客户端的性能大概只有redis-benchmark所示性能的50%〜60%。

  另一方面,如果你发现自己客户端的性能只有redis-benchmark所示性能的25%至30%,或者客户端向你返回了 “Cannol assign requested address"(无法分配指定的地址)错误,那么你可能是不小心在每次发送命令时都创建了新的连接。

  下表出了用单个客户端的redis-benchmark与Python客户端之间的性能对比结果,并介绍了一些常见的造成客户端性能低下或者出错的原因。

4-5 比较了Redis在通常情况下的性能表现以及redis-benchmark

使用单客户端进行测试时的结果,并说明了一些可能引起性能问题的原因

 性能或者错误  可能的原因  解决方法
 单个客户端的性能达到redis-benchmark的50%〜60  这是不使用流水线时的预期性能  无
 单个客户端的性能达到redis-benchmark的25%〜 30  对于每个命令或者每组命令都创建了新的连接  重用已有的Redis连接

 客户端返回错误:“Cannot assign requested address”

(无法分配指定的地址)

 对于每个命令或者每组 命令都创建了新的连接  重用己有的Redis连接

  尽管上表能问题以及问题的解决方法都非常简短,但绝大部分常见的性能问题都是由表格中列出的原因引起的(另一个引起性能问题的原因是以不正确的方式使用Redis的数据结构)。如果遇到了没有碰到的性能问题,大家可以访问redis论坛来解决。

  大部分Redis客户端库都提供了某种级别的内置连接池(connection pool),以python的Redis客户端为例,对于每个Redis服务器,用户只需要创建一个redis.Redis()对象,该对象就会按需创建连接、重用已有的连接并关闭超时的连接(在使用多个数据库的情况下,即使客户端只连接了一个Redis服务器,它也需要为每一个被使用的数据库创建一个连接),并且Python客户端的连接池还可以安全地应用于多线程环境和多进程环境。

 

本文资料来源“redis实战”书籍

posted @ 2021-03-25 11:11  小家电维修  阅读(149)  评论(0编辑  收藏  举报