记一次redis性能优化

前沿

最近工作中在优化redis访问性能,这里总结一下优化过程中redis使用方法的一些心得体会,以及在spring-data-redis开源项目提交代码的一些经验。具体来说,包括如下三部分:

  • 连接池参数配置对性能的影响,包括testOnBorrow、testOnReturn、testWhileIdle等Apache Pool库的参数设置。该部分最后会给出连接池参数设置的best practice,对于性能比较敏感的应用直接使用该最优设置即可,合理的连接池设置可以提升性能2-10倍。

  • redis集群模式如何提高访问效率,相对sentinal和单机模式,集群模式数据分散在不同的节点上,因此性能调优更为重要,合理性能调优甚至可以提升访问性能10倍以上。这部分主要讨论了redis cluster模式下使用过程中遇到的一些问题,比如数据分片、mGet的使用性能、redis cluster节点的负载均衡等。

  • 优化过程中还查到spring-data-redis库两个bug(或者可以改进的improvement),已经向开源库提交code fix改进性能。这部分主要讨论下如何在开源项目提交代码,开源项目的代码风格,单元测试、集成测试、CI等代码质量控制等几个方面

Reids连接池参数设置对性能的影响

前不久在梳理项目性能的过程中,通过哨兵监控发现了一个很诡异的现象。如下图所示,mergeData方法只包含redis的setex和get两个操作,redis的监控显示两个操作的rt大概加起来是1.5ms,然而该方法的平均rt占了3.74ms左右,多出了2ms多的时间,觉得非常奇怪,排查了一下多出的2ms的时间到底去哪儿了。

刚开始怀疑是不是多线程情况下io同步阻塞,导致CPU轮换时间增大,即线程较多导致线程上下文切换时间变大。实际上可以分析哨兵上redis客户端命令的监控原理,哨兵是对客户端jedis中BinaryJedis.get方法拦截,生成新的方法get$sentryProxy,统计该方法的执行时间,而该代理方法包含了从发送命令到同步读数据完成,因此该设想不成立。

使用visualvm模拟线上环境排查问题,发现该方法除了get setex两个操作,还消耗大量时间去getConnecton和releaseConnection,问题已经很明显了,客户端从连接池获取、返还连接时耗费了大量时间。

优化方案:

  • 修改redis的连接池配置,比如预先分配连接,并设置minIdel = 1,保证至少有一个可用的连接。

  • 同时设置testOnBorrow和testOnReturn两个值为false,减少不必要IO请求。

  • 连接可用性主要有testWhileIdle = true来保证

小结:优化后性能基本得到一倍以上的提升,方法执行时间减少为原来的一半。虽然改动很小,但是很小的改动带来的性能提升实在令人吃惊。哨兵的方法级监控和redis的客户端监控帮助我们排查问题,经常查看监控还是有很大的好处,可以主动发现一些问题并提前解决问题。

Redis连接池参数性能优化在cluster模式下更明显

上面写到优化redis连接池的testOnBorrow,testOnReturn 等参数,Jedis连接池使用了apache的连接池Common Pool,JedisFactory的代码可以看出,testOnBorrow,testOnReturn两个参数如果设为true,每次获取connection时候都要发送Ping指令到redis集群,浪费大量IO时间。如果每次获取和返还时不检测连接的可用性,怎么保证每次拿到的接连是可用的呢,一般来说可以设置参数testWhenIdle=true来保证连接的可用性,改参数会定期检查空闲连接的状态。


class JedisFactory implements PooledObjectFactory {

        @Override
        public boolean validateObject(PooledObject pooledJedis) {
        final BinaryJedis jedis = pooledJedis.getObject();
              try {
                      HostAndPort hostAndPort = this.hostAndPort.get();
                      String connectionHost = jedis.getClient().getHost();
                      int connectionPort = jedis.getClient().getPort();
                      return hostAndPort.getHost().equals(connectionHost)
                      && hostAndPort.getPort() == connectionPort && jedis.isConnected()
                      && jedis.ping().equals("PONG");
            } catch (final Exception e) {
                      return false;
                  }
        }

在另一个系统中Redis采用Cluster模式部署,如下图所示参数优化上线后rt下降更加明显,从原来的耗时600ms下降到60ms,性能提升甚至达到10倍左右。原因在于:cluster模式下使用了mGet命令,而缓存在redis中数据没有使用hashtag,因此需要从各节点读取数据。那么客户端就需要维护一个Redis集群中各节点信息,包括有哪些节点,每个节点被分配的slots等。相比NCR的单点连接,cluster模式除了简单的执行get set等命令外,还需要与Redis服务端进行额外的通信,会多发送很多PING命令到Redis集群。

Redis Cluter模式下的性能问题分析

为什么Cluster Node命令总是发送到集群中的同一台机器

在redis集群中有一个节点CPU经常被撑爆,而且Cluster Nodes命中非常多,占用较多的CPU资源,严重影响了Redis集群的整体性能。Nodes命令主要用来获取集群的节点信息,包括有哪些活跃的节点,该节点的ip和port,master还是slave,包含哪些slots等信息。由于数据路由是在客户端做的,客户端需要知道redis服务器的节点信息,并根据此信息对数据做路由。下面是Nodes命令返回的结果:

d1861060fe6a534d42d8a19aeb36600e18785e04 127.0.0.1:6379 myself - 0 1318428930 1 connected 0-1364
3886e65cc906bfd9b1f7e7bde468726a052d1dae 127.0.0.1:6380 master - 1318428930 1318428931 2 connected 1365-2729
d289c575dcbc4bdd2931585fd4339089e461a27d 127.0.0.1:6381 master - 1318428931 1318428931 3 connected 2730-4095

那么为什么获取cluster信息的命中总是发送到同一台节点呢,原因出在下面Spring-data-redis的代码上:

@Override

public ClusterTopology getTopology() {

        if (cached != null && time + 100 > System.currentTimeMillis()) {
              return cached;
        }
        Map errors = new LinkedHashMap();
        for (Entry entry : cluster.getClusterNodes().entrySet()) {
                Jedis jedis = null;
                try {
                jedis = entry.getValue().getResource();
                time = System.currentTimeMillis();
                Set nodes = Converters.toSetOfRedisClusterNodes(jedis.clusterNodes());
                synchronized (lock) {
                         cached = new ClusterTopology(nodes);
                }
                return cached;
        } catch (Exception ex) {
              errors.put(entry.getKey(), ex);
        } finally {
              if (jedis != null) {
              entry.getValue().returnResource(jedis);
        }
}

for循环遍历所有的节点,从第一个开始尝试发送Nodes命令,在获取到信息后返回。我们看到这段代码,第一反应是map中数据的存储是无序的,那么遍历应该也是无序的,那么请求应该是随机发送到集群中的任意节点才对。仔细想下就能发现问题了,由于Entry<String, JedisPool>中的key是节点名称name:port,集群中的节点名称和port相对固定,因此很容易就往第一个获取到的连接去发送cluster命令。

hashmap.entrySet的顺序性,主要有hashcode和插入前后保证,由于节点较少不存在hash冲突,因此基本有hashcode来确定前后顺序,就是说map中数据看似无序实则有序。由于集群中节点的名称和port基本不会改变,因此entrySet的顺序也不会改变,Cluster Nodes命令就会一直发送到同一个节点上。 该问题向spring-data官方反馈:https://jira.spring.io/browse/DATAREDIS-890,目前该bug的code fix已经merge到spring官方master分支,在2.1.3(Lovalace SR3) Release发布。

为什么NODES命令在Redis服务端如此消耗CPU

经过线上监控可知,redis服务端NODES命令达到1400+ qps时,该节点CPU飙升到100%,严重影响redis集群的问题性。经过上文分析可知,redis客户端最快每100ms拉一个NODE信息,我们客户端节点数大概150左右,1400 qps数据能够对的上。可以看出,该问题的暴露与客户端节点数也密切相关。

那么问题来了,为何NODES命令才1400qps就把cpu打满了,redis服务端在接受到该命令后究竟做了什么操作?答案在下图,CLUSTER_SLOTS常量等于16384,因此redis每次都要循环很多次去组装每个节点的slot信息。CPU至少需要循环16384乘以N次,N为redis集群master的个数。因此,随着redis集群规模的扩大、以及客户端节点数的增加,NODES命令打满CPU的问题会越来越严重。

  /* Slots served by this instance */
    start = -1;
    for (j = 0; j < CLUSTER_SLOTS; j++) {
        int bit;

        if ((bit = clusterNodeGetSlotBit(node,j)) != 0) {
            if (start == -1) start = j;
        }
        if (start != -1 && (!bit || j == CLUSTER_SLOTS-1)) {
            if (bit && j == CLUSTER_SLOTS-1) j++;

            if (start == j-1) {
                ci = sdscatprintf(ci," %d",start);
            } else {
                ci = sdscatprintf(ci," %d-%d",start,j-1);
            }
            start = -1;
        }
    }

其实redis系统命令NODES的性能问题,在2018年已经有反馈给redis官方:Slow performance of CLUSTER SLOTS,有计划对该命令做性能优化,比如可以采用在内存维护加一个cache,动态更新每个节点的slot信息等方案。

mGet为什么特别慢

项目设计初时没有对数据进行合理的hash tag分片,因此需要从多Redis节点获取数据,使用spring的mGet方法从redis cluster多节点获取数据。调整了三个参数后,mGet还是比较预期慢不少,通过排查mGet命令发现,其中70%的时间用来sleep,而且是程序主动去sleep。

   while (!done) {
        done = true;
        for (Map.Entry>> entry : futures.entrySet()) {
            if (!entry.getValue().isDone() && !entry.getValue().isCancelled()) {
                done = false;
            } else {
                try {
                    String futureId = ObjectUtils.getIdentityHexString(entry.getValue());
                    if (!saveGuard.contains(futureId)) {
                        result.add(entry.getValue().get());
                        saveGuard.add(futureId);
                    }
                } catch (ExecutionException e) {
                    RuntimeException ex = convertToDataAccessExeption((Exception) e.getCause());
                    exceptions.put(entry.getKey().getNode(), ex != null ? ex : e.getCause());
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                    RuntimeException ex = convertToDataAccessExeption((Exception) e.getCause());
                    exceptions.put(entry.getKey().getNode(), ex != null ? ex : e.getCause());
                    break;
                }
            }
        }
        try {
            Thread.sleep(10);
        } catch (InterruptedException e) {
            done = true;
            Thread.currentThread().interrupt();
        }
    }

上面代码可以看出,在等待各节点返回中休眠10ms后再次检查数据有没有到达,spring的实现看起来有点不太能理解。对于该问题,向spring官方反馈:https://jira.spring.io/projects/DATAREDIS/issues/DATAREDIS-889,收到如下答复。

The code of clustered mget sends multiple commands, needs to await completion and needs to merge results. 50ms sounds as if synchronization looped about five times over the synchronization code until all responses arrived.

There are various things to consider:
1. Cross-slot requests need to be split up into multiple requests. This also means that a single mget call can be split up into a number of requests that hit the same server just to server the purpose of having a single slot per command.
2. With version 1.8.x, we're required to use Java 6 APIs and we can't use synchronization utilities such as composing CompletableFuture.
3. Without sleep, we basically burn CPU cycles in that loop. So when a mget command awaits synchronization, we would basically use 100% of your CPU (core) within a busy spin that cannot be interrupted (e.g. shutdown). To avoid busy spin, allow interruption and allow the CPU core to do other things, we added a sleep timeout.

Have you tried switching clients? Lettuce does mget routing itself and uses Java 8 API for that purpose which eliminates the need to sleep.

总体上就是受限于JDK6,不想忙等待占有大量CPU,采用sleep的方式减少对CPU的占用。同时建议我们使用Lettuce库,该库采用Netty作为底层通信框架,使用异步IO性能有很大提升。

这里有的同学可能会问,直接循环调用get()方法同步阻塞不就好了,其实不然。由于mGet需要从多个节点获取数据,有些节点任务执行可能会抛异常,这里需要提前获取这些已经完成的任务,如果有异常就直接返回了,不用再等待其他的数据返回。采用这种实现方案,实际是在CPU消耗和总耗时之间的折中。JDK8中引入CompletableFuture支持按照顺序返回结果,先完成的先返回可以提高并发执行的效率。

由于临时切换客户端,无论是开发还是测试成本都比较高,目前的解决办法是把mGet转化成多个get请求,在线程池中并发执行,RT时间从60ms下降到6ms左右,如下图所示性能提升10倍。后期有时间可以好好研究一下Lettuce客户端的使用方法和性能指标,貌似Jedis客户端API目前很少更新,处于不太活跃的状态,Spring目前已经把开发重心从Jedis转移到Lettuce客户端了,Spring-Session中使用的默认Redis访问API就是Lettuce。

IMG20191114_224207.png

前文说过mGet命令中70%的时间用来sleep,那么按道理RT应该从60ms降到21ms左右,为什么改用自己的线程池执行能降到6ms左右呢。原因在于,Spring内部实际上也是把多个mGet拆开多个get并在线程池里面执行,该线程池为spring封装类ThreadPoolTaskExecutor,参数coreSize为1,严重影响了并发的执行效率。我们使用自己的线程池,把coreSize设置为Runtime.getRuntime().availableProcessors() * 4, 可以进一步提高并发数,提高访问性能。可能有的同学又要问,Redis服务端是单线程的,为什么提高客户端并发数可以提高性能呢,Redis服务端不是一条一条地执行命令的吗?
原因主要有两点:

  • 并发提交请求可以节约一定IO通信时间,Redis服务端接收请求是并发的,这部分实际节约应该比较有限

  • Redis服务端部署是集群模式,读请求是并发分散到各节点的

这里有一点需要特别说明的,Redis Cluster部署模式下,尽量把相关联的数据通过hashtag强制存储到一个节点上,可以大大提高使用效率。由于历史原因,数据已经存储在不同节点上,或者有强烈需要从多节点获取数据的,应该尽量避免直接使用Spring的Jedis库中的mGet方法。可以自己封装线程池单独执行多个get操作,也可以使用Lettuce客户端访问数据。

Spring开源项目提交代码经验分享

由于笔者开源提交代码经验不多,简单分享下:

  • 给Spring创建Jira任务,经过讨论方案可行后,可以提交pull request。
  • Git中CI检查必须通过,开源项目中的单元测试、集成测试比较严格,新增的代码需要新的测试用例证明代码正确性,git commit后会自动跑CI。spring-data-redis采用travis作为CI平台,和Git结合比较好,可以看到每次代码提交后测试用例的执行情况。
  • pull request提交到master分支,spring一般在发布版本时单独从master拉出分支。这样的好处是可以比较灵活,可以单独对不同的发布分支加Patch,同时频繁发布时也比较灵活。

回到之前的那个Nodes命令不均衡的问题,查询Redis文档可以知道Cluster中每个节点都可以外对提供Nodes查询节点信息,不管是master还是slave:

The CLUSTER NODES command can be sent to any node in the cluster and provides the state of the cluster and the information for each node according to the local view the queried node has of the cluster.

因此,只要在遍历map之前做一下随机就可以了,解决办法非常简单直接。好处是可以把Nodes命令分散到Cluster的各个节点上,由于默认每隔100ms每个客户端节点就会发送该命令,如果客户端数量比较多,该命令都落到同一个服务端节点上,对性能肯定会造成比较大的影响。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 199,064评论 5 466
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 83,606评论 2 376
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 146,011评论 0 328
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 53,550评论 1 269
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 62,465评论 5 359
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 47,919评论 1 275
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,428评论 3 390
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,075评论 0 254
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,208评论 1 294
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,185评论 2 317
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,191评论 1 328
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,914评论 3 316
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,482评论 3 302
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,585评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,825评论 1 255
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,194评论 2 344
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 41,703评论 2 339