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 | $ printf "incr x\r\nincr x\r\nincr 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变动,则打包好的管道中的部分命令可能会收到
MOVED
或ASK
错误,需要在代码中处理。一般而言,遇到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。如果调用了
EXEC
或DISCARD
,那就没有必要通过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
方式执行的情况。
- 在resharding和rebalance时,因为可能存在key部分迁移的中间态,需要注意批量命令的执行结果,可能出现部分需要重新通过
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。