Redis性能优化方案

Published on 2017 - 06 - 05

非事务型流水线

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

MGET、MSET、HMGET、 HMSET、RPUSH和LPUSH、SADD、ZADD等。这些命令简化了那些需要重复执行相同命令的操作,并且极大地提升了性能。尽管效果可能没有以上提到的命令那么显著,但使用非事务型流水线(non-transactional 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的整体性能。让我们来看一个这方面的例子。

前面曾经编写并更新过一个名为update_token()的函数,它负责记录用户最近浏览过的商品以及用户最近访问过的页面,并更新用户的登录cookie。代码清单7展示的这个函数每次执行都会调用2个或者5个Redis命令,使得客户端和Redis之间产生2次或者5次通信往返。

def update_token(conn, token, user, item=None):
    timestamp = time.time()                             #A
    conn.hset('login:', token, user)                    #B
    conn.zadd('recent:', token, timestamp)              #C
    if item:
        conn.zadd('viewed:' + token, item, timestamp)   #D
        conn.zremrangebyrank('viewed:' + token, 0, -26) #E
        conn.zincrby('viewed:', item, -1)               #F
#A Get the timestamp
#B Keep a mapping from the token to the logged-in user
#C Record when the token was last seen
#D Record that the user viewed the item
#E Remove old items, keeping the most recent 25
#F Update the number of times the given item had been viewed

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

def update_token_pipeline(conn, token, user, item=None):
    timestamp = time.time()
    pipe = conn.pipeline(False)                         #A
    pipe.hset('login:', token, user)
    pipe.zadd('recent:', token, timestamp)
    if item:
        pipe.zadd('viewed:' + token, item, timestamp)
        pipe.zremrangebyrank('viewed:' + token, 0, -26)
        pipe.zincrby('viewed:', item, -1)
    pipe.execute()                                      #B
#A Set up the pipeline
#B Execute the commands in the pipeline

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

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

def benchmark_update_token(conn, duration):
    for function in (update_token, update_token_pipeline):      #A
        count = 0                                               #B
        start = time.time()                                     #B
        end = start + duration                                  #B
        while time.time() < end:
            count += 1
            function(conn, 'token', 'user', 'item')             #C
        delta = time.time() - start                             #D
        print function.__name__, count, delta, count / delta    #E
#A Execute both the update_token() and the update_token_pipeline() functions
#B Set up our counters and our ending conditions
#C Call one of the two functions
#D Calculate the duration
#E Print information about the results

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

描述 带宽 延迟值 每秒调用 update_table() 的次数 每秒调用 update_table_ pipeline() 的次数
本地服务器,Unix域套接字 大于1Gb (gigabit) 0.015ms 3 761 6 394
本地服务器,本地连接 大于1Gb 0.015ms 3 257 5 991
远程服务器,共享交换机 1Gb 0.271ms 739 2 841
远程服务器,通过VPN连接 1.8Mb (megabit) 48ms 3.67 18.2

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

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

关于性能方面的注意事项

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

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

$ redis-benchmark  -c 1 -q                               #A
PING (inline): 34246.57 requests per second
PING: 34843.21 requests per second
MSET (10 keys): 24213.08 requests per second
SET: 32467.53 requests per second
GET: 33112.59 requests per second
INCR: 32679.74 requests per second
LPUSH: 33333.33 requests per second
LPOP: 33670.04 requests per second
SADD: 33222.59 requests per second
SPOP: 34482.76 requests per second
LPUSH (again, in order to bench LRANGE): 33222.59 requests per second
LRANGE (first 100 elements): 22988.51 requests per second
LRANGE (first 300 elements): 13888.89 requests per second
LRANGE (first 450 elements): 11061.95 requests per second
LRANGE (first 600 elements): 9041.59 requests per second
#A We run with the '-q' option to get simple output, and '-c 1' to use a single client

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%,或者客户端向你返回了“Cannot assign requested address”(无法分配指定的地址)错误,那么你可能是不小心在每次发送命令时都创建了新的连接。

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

性能或者错误 可能的原因 解决方法
单个客户端的性能达到 redis-benchmark的50%~60% 这是不使用流水线时的预期性能
单个客户端的性能达到redis-benchmark的25%~30% 对于每个命令或者每组命令都创建了新的连接 重用已有的Redis连接
客户端返回错误:“Cannot assign requested address”(无法分配指定的地址) 对于每个命令或者每组命令都创建了新的连接 重用已有的Redis连接

尽管表5列出的性能问题以及问题的解决方法都非常简短,但绝大部分常见的性能问题都是由表格中列出的原因引起的(另一个引起性能问题的原因是以不正确的方式使用Redis的数据结构)。

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

参考文档