通过mget批量执行指令可以节约网络连接和数据传输开销,在高并发场景下可以节约大量系统资源。本文中,我们更进一步,比较一下redis提供的几种批量执行指令的性能。

1. 为什么需要批量执行redis指令

众所周知,Redis协议采取的是客户端-服务器方式,即在一次round trip中,客户端发送一条指令,服务端解析指令并执行,然后向客户端返回结果。这是一种典型的tcp交互方式。

粗略的分,客户端发起一次Redis请求主要有如下开销:

  • socket IO导致的上下文切换开销 (严重读写系统开销

熟悉OS/Linux的童鞋都知道,一次redis请求在客户端和服务端分别至少会存在一次read()和一次write(),作为系统调用,read/write的成本高于普通的函数调用,因此,在单个命令重复调用场景下,大量的read/write系统调用会产生明显的系统开销。

  • 指令执行开销 (轻微指令开销

Redis采用C实现,使用了轻量级的hash表、skipList跳表等数据结构实现了高效的缓存。因此,单条执行大多数指令的成本非常低。因此,相对而言,IO的开销显得更加无法忽略。

  • (高并发下)资源竞争和系统调度调度开销 (Redis竞争抖动

客户端的影响非常明显。在高压力下,如果采用循环(loop)方式调用多次指令来完成某个服务请求,那么在高并发下,多个请求会在多个线程中同时竞争redis连接资源多次,导致连接池压力增加,线程上下文切换更加频发,最终会导致请求RTT(round-trip time)急剧恶化。如果每个请求只抢占一次redis连接并通过批量执行的方式一次处理多个请求,则单次请求的RTT会有显著提升。

在服务端,因为我们通常将redis绑定到CPU(不管是通过物理机还是通过docker),因此一般而言不存在系统调度/资源竞争的开销。但是由于redis对QPS敏感,如果因为客户端使用不合理而造成QPS放大效应,则redis可能更早触及性能瓶颈而导致系统响应严重下降。

笔者曾经在一次性能调优中发现,每次服务请求访问redis次数高达数十次,使得redis请求次数达到服务QPS的数十倍,触发了redis服务器的极限(大概5~10万QPS)而导致服务性能低下,多个请求对redis连接池进行了激烈竞争,并且由于redis响应速度的下降导致大量线程在获取连接处阻塞并频繁进行线程切换。在改进实现采用了批量指令处理后,服务性能瞬间达到了数十倍的提升。因此,如果每次服务掉用需要触发多次redis请求,合理地适用批量执行技术,可以使系统运行更加有效,数据吞吐得到明显提升。

2. redis批量指令介绍

2.1. 批量命令即redis对应的命令

严格来说上述命令不属于批量操作,而是在一个指令中处理多个key。

  • 优势:性能优异,因为是单条指令操作,因此性能略优于其他批量操作指令
  • 劣势:批量命令不保证原子性,存在部分成功部分失败的情况,需要应用程序解析返回的结果并做相应处理   批量命令在key数目巨大时存在RRT与key数目成比例放大的性能衰减,会导致单实例响应性能(RRT)严重下降

2.2 管道 pipeline

管道(pipelining)方式意味着客户端可以在一次请求中发送多个命令。

  • 优势:
  1. 通过管道,可以将多个redis指令聚合到一个redis请求中批量执行
  2. 可以使用各种redis命令,使用更灵活
  3. 客户端一般会将命令打包,并控制每个包的大小,在执行大量命令的场景中,可以有效提升运行效率
  4. 由于所有命令被分批次发送到服务器端执行,因此相比较事务类型的操作先逐批发送,再一次执行(或取消),管道拥有微弱的性能优势
  • 劣势:
  1. 没有任何事务保证,其他client的命令可能会在本pipeline的中间被执行

2.3 事务操作

事务(Transactions)操作允许在一步中执行一组redis操作,并对这一组redis命令有如下保证:

  1. 同一个事务中的所有命令会被串行地逐一执行。不可能出现有任何来自其他client的命令在这组命令中间被执行。
  2. 单个事务的所有命令,或者被全部执行,或者一个也不会被执行,因此事务保证了redis操作的原子性。命令EXEC触发事务中所有命令的执行,因此如果一个client在事务上下文中丢失了连接,那么不会有任何一条命令被执行;相反如果client已经调用了EXEC,那么所有命令都会被执行。
  3. 当使用append-only文件时,Redis保证仅使用一个write(2)系统调用来将事务结果写入磁盘。然而如果Redis server崩溃或者被系统管理员使用hard方式kill了进程,那么还是有可能只写入了部分操作。Redis在重启时可以检测到这一问题,并以error退出。这时,可以使用redis-check-aof工具来对append-only文件进行修复,它将会删除部分写入的事务这样server就可以启动了。
  • 优势:
  1. 事务的执行具备原子性,即全部被执行或全部不执行,并且在持久化时也具备原子性
  2. 可以使用WATCH提供的乐观锁机制保证命令执行的排他性
  • 劣势:
  1. 事务的所有命令会分批发送给redis实例,redis返回+QUEUED,表示命令已入列,但是不会执行任何命令。在收到EXEC命令时,一次执行本事务的所有命令。因此事务的性能略低于pipeline,但是相差不多。
  2. 在keys竞争激烈时,WATCH提供的乐观锁由于竞争过多而性能低下,应该尽量避免。

2.4 基于管道的事务

在Redis中,管道是通过RESP,即redis协议来实现的,它允许在一个消息包中按照指定格式传递多个命令。而事务是通过命令实现的,因此管道和事务之间并不冲突,事务可以承载与管道之上。在某些场景,需要在一次请求处理中发起多次事务的场景下,通过引入管道,可以获得略高于单独执行多次事务的性能,但是两者的差距非常小,小到可以忽略。

3. 压测用例分析

针对上述4种批量操作,设计如下case:

  • 条件:在本地单机redis中创建1,000,000对key-value,key长8字节,value长5字节
  • 测试过程:
  1. 使用set/mset/pipeline/transaction/transaction in pipeline这五种方式分别重新设置所有key的值,记录各自的运行时长
  2. 使用get/mget/pipeline/transaction/transaction in pipeline这五种方式分别遍历所有key的值,记录各自的运行时长

单位:ms

3.1 SET性能压测结果

redis ttl批量 redis批量操作_redis ttl批量

redis ttl批量 redis批量操作_客户端_02

3.2 GET性能压测结果

redis ttl批量 redis批量操作_Redis_03

redis ttl批量 redis批量操作_redis_04

3.3 结论

从上述测试结果中可以看出,不同的处理方式,最终性能曲线基本一致。

  • mset性能最好,吞吐量最高,因为mset是作为单条命令执行,在命令解析和执行上都更有效率
  • pipeline好于transaction in pipeline,因为事务会导致命令入列和出列会稍许浪费cpu时间
  • transaction in pipeline微弱领先于transaction,但是几乎没有区别,可以理解为pipeline在命令传输上更有效率。
  • 总得来说,在批量模式下,四种操作都比普通的get/set性能上有几大的提升。
  • 在当前生产环境中使用较多的Redis Cluster环境中,上述四种批量操作的使用场景都比较有限,其中transaction不支持,pipeline建议仅用于单slot且目前支持的客户端很少,mget/mset也仅仅可以操作于单slot中的key。