undefined

Redis 批量操作

一、为什么需要批量执行 redis 指令

Redis 协议采用的是客户端-服务器方式,客户端发送一条指令,服务端解析指令并执行,然后向客户端返回结果。客户端发起一次 redis 请求主要有如下开销:

  • socket IO 导致的上下文切换开销。一次 redis 请求在客户端和服务端分别会存在一次 read() 和 一次 write() 系统调用。
  • 高并发下资源竞争和系统调用。
    • 在客户端,如果采用调用多次 redis 指令来完成某个服务请求,那么在高并发下,多个请求会在多个线程中同时竞争 redis 连接资源多次,导致连接池压力增加,线程上下文切换频繁。如果每个请求只抢占一次 redis 连接并通过批量执行的方式一次处理多个请求,则单次请求的效率会显著提升
    • 在服务端,因为我们通常将 redis 绑定到一个 CPU 上,因此一般不存在系统调度、资源竞争的开销。但是由于redis对qps敏感,如果因为客户端使用不合理而造成qps放大效应,则redis可能更早触及性能瓶颈而导致系统响应严重下降

因此,如果每次服务调用需要触发多次 redis 请求,合理的使用批量执行技术,可以使系统运行更加高效,数据吞吐得到明显提升

二、redis 批量指令介绍

Redis主要提供了以下几种批量操作方式:

  • 批量get/set(multi get/set)
  • 管道(pipelining)
  • 事务(transaction)
  • 基于事务的管道(transaction in pipelining)

1. 批量命令

批量命令即redis对应的命令:

  • mget 适用于 string 类型
  • mset 适用于 string 类型
  • hmget 适用于 hash 类型
  • hmset 适用于 hash 类型

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

优点:性能优异,因为是单条指令操作,因此性能略优于其他批量操作指令

缺点:

  • 批量命令不保证原子性,存在部分成功部分失败的情况,需要应用程序解析返回的结果并做相应处理
  • 批量命令在 key 数目巨大时存在 RRT 与 key 数目成比例放大的性能衰减,会导致单实例响应性能(RRT)严重下降

集群行为

  • 客户端分片场景下,Jedis 不支持客户端 mget 拆分,需要在业务代码中根据分片规则自行拆分并发送到对应的 redis 实例,会导致业务逻辑代码中夹杂着 jedis 分片逻辑
  • 中间件分片场景下,Codis 等中间件分片服务中,会将 mget/mset 的多个key拆分成多个命令发往不同得 redis 实例,事实上已经丧失了 mget 强大的聚合执行能力
  • Cluster场景下,mget仅支持单个slot内批量执行,否则将会获得一个错误信息

2. 管道(pipelining)

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

1
2
$ printf "incr x\r\nincr x\r\nincr x\r\n" | nc localhost 6379
$ printf "get x\r\ndel x\r\n" | nc localhost 6379

优点:

  • 通过管道,可以将多个 redis 指令聚合到一个 redis 请求中批量执行
  • 可以使用各种 redis 命令,使用更灵活
  • 客户端一般会将命令打包,并控制每个包的大小,在执行大量命令的场景中,可以有效提升运行效率
    • 比如在采用 jedis 客户端时,每个包大小约为 8K
    • 大量命令会被分为多个包,以包为单位逐批发送到 redis 服务器执行
  • 由于所有命令被分批次发送到服务器端执行,因此相比较事务类型的操作先逐批发送,再一次执行(或取消),管道拥有微弱的性能优势。

缺点:没有任何事务保证,其他client的命令可能会在本pipeline的中间被执行。

集群行为:

  • 客户端分片,需要由应用程序或client对命令按分片拆分并通过多个管道发送到不同的分片redis服务器执行
  • 中间件分片,一般由中间件对管道进行拆分和结果合并
  • Cluster场景下,对pipeline的支持等同于单机,可以将同一节点中不同slot分片的节点通过批量操作一次执行,但是从实践来说,情况更加复杂,除非有充分的理由,否则不建议
    • 目前jedis不支持集群下的pipeline
    • 如果一定要使用pipeline,可以根据client端缓存的 hash slots <-> ip:port(node),对所有key进行分组,并将属于同一节点的命令打包通过jedis对象执行
    • 如果发生了resharding(rebalance),会导致slot变动,则打包好的管道中的部分命令可能会收到MOVEDASK错误,需要在代码中处理。一般而言,遇到MOVED需要触发一次映射刷新,遇到ASK则需要一次ASKING操作

3. 事务操作

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

  • 同一个事务中的所有命令会被串行地逐一执行。不可能出现有任何来自其他client的命令在这组命令中间被执行
  • 单个事务的所有命令,或者被全部执行,或者一个也不会被执行,因此事务保证了redis操作的原子性。命令EXEC触发事务中所有命令的执行,因此如果一个client在事务上下文中丢失了连接,那么不会有任何一条命令被执行;相反如果client已经调用了EXEC,那么所有命令都会被执行
  • 当使用append-only文件时,Redis保证仅使用一个write(2)系统调用来将事务结果写入磁盘。然而如果Redis server崩溃或者被系统管理员使用hard方式kill了进程,那么还是有可能只写入了部分操作。Redis在重启时可以检测到这一问题,并以error退出。这时,可以使用redis-check-aof工具来对append-only文件进行修复,它将会删除部分写入的事务,这样server就可以启动了

事务操作相关命令:

  • MULTI:标记一个事务段(transaction block)的开始。之后的所有命令都将被入队列直到EXEC命令发起执行
  • WATCH:监控所有紧跟的keys,之后的事务段(transaction block)根据这些keys是否在监控期间被改变而有条件执行。WATCH使用了一种check-and-set的乐观锁机制。
  • UNWATCH:清除本事务中之前所有被监控的keys。如果调用了EXECDISCARD,那就没有必要通过UNWATCH手动清除被监控的keys了。
  • EXEC:执行本事务中之前的所有命令,并将连接状态回复为normal。当使用了WATCH时,EXEC只有在所有被watch的keys都没有修改时才会执行所有命令。
  • DISCARD:清除本事务的所有被缓存(入列/QUEUED)的命令,并恢复当前连接的状态为normal。如果使用了WATCH,那么DISCARD之后所有被watch的keys会自动被unwatch。

优点:

  • 事务的执行具备原子性,即全部被执行或全部不执行,并且在持久化时也具备原子性。
  • 可以使用WATCH提供的乐观锁机制保证命令执行的排他性。

缺点:

  • 事务的所有命令会分批发送给redis实例,redis返回+QUEUED,表示命令已入列,但是不会执行任何命令。在收到EXEC命令时,一次执行本事务的所有命令。因此事务的性能略低于pipeline,但是相差不多。
  • 在keys竞争激烈时,WATCH提供的乐观锁由于竞争过多而性能低下,应该尽量避免。

集群行为:

  • 客户端分片和中间件分片均不支持transaction。因为transaction提供了原子级的执行保证,在instance之外是无法提供的。
  • Redis Cluster支持transaction,但是前提是transaction涉及的所有key都属于同一hash slot
    • 在resharding和rebalance时,因为可能存在key部分迁移的中间态,需要注意批量命令的执行结果,可能出现部分需要重新通过ASK方式执行的情况。

4. 基于管道的事务

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

三、性能分析

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