在之前的文章中,我们对redis批量处理指令mget进行了压测并分析了性能瓶颈,显然通过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的开销显得更加无法忽略。
-
(高并发下)资源竞争和系统调度调度开销
一般来说,这一开销在客户端的影响更为明显,在高压力下,如果采用循环(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批量指令介绍
Redis主要提供了以下几种批量操作方式:
- 批量get/set(multi get/set)
- 管道(pipelining)
- 事务(transaction)
- 基于事务的管道(transaction in pipelining)
2.1 批量命令
批量命令即redis对应的命令:
严格来说上述命令不属于批量操作,而是在一个指令中处理多个key。
优势:
- 性能优异,因为是单条指令操作,因此性能略优于其他批量操作指令。
缺点:
- 批量命令不保证原子性,存在部分成功部分失败的情况,需要应用程序解析返回的结果并做相应处理。
- 批量命令在key数目巨大时存在RRT与key数目成比例放大的性能衰减,会导致单实例响应性能(RRT)严重下降,更多分析请参考之前的文章。
集群行为
- 客户端分片场景下,Jedis不支持客户端mget拆分,需要在业务代码中根据分片规则自行拆分并发送到对应得redis实例,会导致业务逻辑代码中夹杂着jedis分片逻辑
- 中间件分片场景下,Codis等中间件分片服务中,会将mget/mset的多个key拆分成多个命令发往不同得redis实例,事实上已经丧失了mget强大的聚合执行能力。
- Cluster场景下,mget仅支持单个slot内批量执行,否则将会获得一个错误信息。
代码示例:
Jedis jedis = pool.getResource();
try{
long duration = System.currentTimeMillis();
jedis.mget(keys);
duration = System.currentTimeMillis() - duration;
log(duration);
}finally {
if(jedis!=null) jedis.close();
}
2.2 管道(pipelining)
管道(pipelining)方式意味着客户端可以在一次请求中发送多个命令。例如在下例中,一次将多个命令传给redis,redis将在一个round trip中完成多命令并依次返回结果。
$ printf "incr x\r\nincr x\r\nincr x\r\n" | nc localhost 6379
:1
:2
:3
$ printf "get x\r\ndel x\r\n" | nc localhost 6379
$1
3
:1
在上面的例子中,首先通过管道执行了三次incr x
指令,第二次通过管道执行了get x
和del x
两个指令。
优势
- 通过管道,可以将多个redis指令聚合到一个redis请求中批量执行
- 可以使用各种redis命令,使用更灵活
- 客户端一般会将命令打包,并控制每个包的大小,在执行大量命令的场景中,可以有效提升运行效率。
- 比如在采用jedis客户端时,每个包大小大约为8K
- 大量命令会被分为多个包,以包为单位逐批发送到redis服务器执行
- 由于所有命令被分批次发送到服务器端执行,因此相比较事务类型的操作先逐批发送,再一次执行(或取消),管道拥有微弱的性能优势。
缺点
- 没有任何事务保证,其他client的命令可能会在本pipeline的中间被执行。
集群行为
- 客户端分片,需要由应用程序或client对命令按分片拆分并通过多个管道发送到不同的分片redis服务器执行
- 中间件分片,一般由中间件对管道进行拆分和结果合并
- Cluster场景下,对pipeline的支持等同于单机,可以将同一节点中不同slot分片的节点通过批量操作一次执行,但是从实践来说,情况更加复杂,除非有充分的理由,否则不建议 (将来Jedis可能会支持对同一slot的所有key支持pipeline)。
- 目前jedis不支持集群下的pipeline
- 如果一定要使用pipeline,可以根据client端缓存的hashslots <-> ip:port(node),对所有key进行分组,并将属于同一节点的命令打包通过jedis对象执行
- 如果发生了resharding(rebalance),会导致slot变动,则打包好的管道中的部分命令可能会收到
MOVED
或ASK
错误,需要在代码中处理。一般而言,遇到MOVED
需要触发一次映射刷新,遇到ASK
则需要一次ASKING
操作。 - 在Jedis标准cluster操作中,JedisCluster整合了对
JedisRedirectionException
的处理,如果要使用pipeline,需要自己封装相应接口,并通过Jedis对象进行pipeline操作,处理相应的重定向错误,并对发生重定向的 部分 子命令进行重试,复杂度将会明显上升。
代码示例
Jedis jedis = pool.getResource();
try{
Pipeline p = jedis.pipelined();
try {
// TODO
// p.get("XXX");
// p.get("XXX");
resp = p.syncAndReturnAll();
} finally {
p.close();
}
} catch (Exception e) {
// TODO
}finally { if(jedis!=null) jedis.close(); }
2.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
命令发起执行
- 标记一个事务段(transaction block)的开始。之后的所有命令都将被入队列直到
-
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部分迁移的中间态,需要注意批量命令的执行结果,可能出现部分需要重新通过
代码示例
Jedis jedis = pool.getResource();
try {
Transaction tx = jedis.multi();
try {
tx.get("XXX");
tx.set("YYY","ZZZ")
resp = tx.exec();
} finally { tx.close(); }
} catch (Exception e) {
// TODO
}finally { jedis.close(); }
2.4 基于管道的事务
在Redis中,管道是通过RESP,即redis协议来实现的,它允许在一个消息包中按照指定格式传递多个命令。而事务是通过命令实现的,因此管道和事务之间并不冲突,事务可以承载与管道之上。在某些场景,需要在一次请求处理中发起多次事务的场景下,通过引入管道,可以获得略高于单独执行多次事务的性能,但是两者的差距非常小,小到可以忽略。
代码示例
Jedis jedis = pool.getResource();
try {
Pipeline p = jedis.pipelined();
try {
p.multi();
//p.get(..);
//p.set(..,..);
resp = p.exec();
} finally { p.close(); }
} catch (Exception e) {
// TODO
} finally { jedis.close(); }
3.压测用例分析
针对上述4种批量操作,设计如下case:
- 条件:在本地单机redis中创建1,000,000对key-value,key长8字节,value长5字节
- 测试过程:
- 使用set/mset/pipeline/transaction/transaction in pipeline这五种方式分别重新设置所有key的值,记录各自的运行时长
- 使用get/mget/pipeline/transaction/transaction in pipeline这五种方式分别遍历所有key的值,记录各自的运行时长
单位:ms
SET性能压测结果
步进 | 1 | 10 | 100 | 1000 | 10,000 | 100,000 | ALL |
---|---|---|---|---|---|---|---|
set | 42128 | - | - | - | - | - | - |
mset | 43767 | 5142 | 1512 | 1188 | 1077 | 1072 | - |
pipeline | 44039 | 6252 | 2312 | 1818 | 1714 | 1790 | 1836 |
transaction | 45053 | 6866 | 2780 | 2292 | 2371 | 2618 | 2357 |
trans in pipeline | 45063 | 6806 | 2760 | 2200 | 2304 | 2402 | 2372 |
表现为折线图如下:
GET性能压测结果
步进 | 1 | 10 | 100 | 1000 | 10,000 | 100,000 | ALL |
---|---|---|---|---|---|---|---|
get | 39875 | - | - | - | - | - | - |
mget | 41499 | 4666 | 1159 | 794 | 935 | 982 | - |
pipeline | 41693 | 5808 | 1677 | 1297 | 1354 | 1212 | 1551 |
transaction | 43960 | 5902 | 2191 | 1685 | 1825 | 2015 | 2033 |
trans in pipeline | 44848 | 6140 | 2026 | 1699 | 1822 | 1949 | 1985 |
表现为折线图如下:
从上述测试结果中可以看出,不同的处理方式,最终性能曲线基本一致。
- mset性能最好,吞吐量最高,因为mset是作为单条命令执行,在命令解析和执行上都更有效率
- pipeline好于transaction in pipeline,因为事务会导致命令入列和出列会稍许浪费cpu时间
- transaction in pipeline微弱领先于transaction,但是几乎没有区别,可以理解为pipeline在命令传输上更有效率。
- 总得来说,在批量模式下,四种操作都比普通的get/set性能上有几大的提升。
- 在当前生产环境中使用较多的Redis Cluster环境中,上述四种批量操作的使用场景都比较有限,其中transaction不支持,pipeline建议仅用于单slot且目前支持的客户端很少,mget/mset也仅仅可以操作于单slot中的key。