Redis パイプラインの基礎となる原理の分析と実践

著者: vivo Internet Server チーム-Wang Fei


Redis は、クライアント/サーバー モデルとリクエスト/レスポンスに基づく TCP サービスです。バッチ コマンドの実行が発生した場合、Redis はバッチ処理のパフォーマンスを向上させるパイプラインを提供します。この記事では、Spring Boot フレームワークにおける Redis の Lettuce クライアントと Redisson クライアントによるパイプライン機能のサポート原則を分析する実践を組み合わせ、その実践中に発生した問題を分析します。これは、開発者がさまざまなクライアントによるパイプライン サポートの原則を理解し、回避するのに役立ちます。実際の使用上の問題。


I.はじめに


Redis はすでに mget や mset などのバッチ コマンドを提供していますが、一部の操作はバッチ操作をサポートしていないか、まったくサポートしていないため、Redis の高いパフォーマンスに反します。この目的を達成するために、Redis はパイプライン メカニズムに基づいた Redis Pipeline の新機能を提供します。 Redis Pipeline は、複数のコマンドを一度に送信し、実行後にその結果を一度に返すことで、往復の遅延を削減し、運用パフォーマンスを向上させることで、クライアントと Redis 間の通信の数を削減するテクノロジーです。現在、Redis Pipeline は多くのバージョンの Redis クライアントでサポートされています。 


2. パイプラインの基礎となる原理の分析


 2.1 Redis 単一コマンド実行の基本手順


Redis は、クライアント/サーバー モデルとリクエスト/レスポンスに基づく TCP サービスです。 Redis クライアントによって開始されたリクエストは通常​​、サーバーが応答した後に次の手順を実行します。

  1. クライアントは (クエリ/挿入) リクエストを開始し、ソケットの戻りをリッスンし、通常はブロッキングモードRedis サーバーからの応答を待ちます。

  2. サーバーはコマンドを処理し、処理結果をクライアントに返します。

  3. クライアントはサービスから戻り結果を受け取り、プログラムはブロッキング コードから戻ります。



2.2 RTT時間


データは、ネットワーク接続を介して Redis クライアントとサーバー間で送信されます。データ パケットがクライアントからサーバーに到着し、データがサーバーからクライアントに返されるまでにかかる時間を RTT (ラウンド トリップ タイム) と呼びます。 )。 Redis がサーバーに継続的にリクエストを行う場合、RTT 時間が 250ms の場合、Redis が 1 秒あたり 100k のリクエストを処理できたとしても、ネットワーク送信には多くの時間がかかるため、1 秒あたり最大 4 つのリクエストしか処理できないことが容易にわかります。となり、全体的なパフォーマンスが低下します。



2.3 Redis パイプライン


そこで効率化を図るためにPipelineが登場します。パイプライン化は RRT を削減するだけでなく、一度に実行される操作の数を実際に大幅に増加させます。これは、パイプラインを使用しない場合、データ構造にアクセスしてサーバー側で応答を生成するという観点から、毎回 1 つのコマンドを実行するコストが非常に低いためです。しかし、ネットワーク IO を実行するという観点から見ると、そのコストは実際には非常に高くなります。 read() と write() のシステムコールが必要となるため、ユーザーモードからカーネルモードに切り替える必要があり、このコンテキスト切り替えのコストは膨大です。


Pipeline を使用すると、1 つの read() 操作で複数のコマンドを読み取ることができ、1 つの write() 操作で複数のコマンドの応答を行うことができるため、クライアントは前のコマンドの実行を待たずに複数のコマンドを一度に送信できます。結果。RTT が削減されるだけでなく、IO 呼び出しの数も削減され (IO 呼び出しにはユーザー モードとカーネル モードの切り替えが含まれます)、最終的にプログラムの実行効率とパフォーマンスが向上します。以下に示すように:



Pipeline をサポートするには、実際にはサーバー側のサポートとクライアント側のサポートの両方が必要です。サーバーにとって必要なのは、同じ TCP 接続を介してクライアントから送信された複数のコマンドを処理できることです。これは、単一のコマンドを処理するのと同じように、ここで複数のコマンドが処理されることがわかります。 。クライアントは複数のコマンドをキャッシュし、バッファーがいっぱいになったときにコマンドを送信し、バッファーに書き込み、最後に Redis 応答を処理する必要があります。


3. Pipelineの基本的な使い方と性能比較


次の例では、100,000 個のセット構造に整数値を挿入します。jedis を使用して 1 つのコマンドを挿入し、jedis を使用してパイプライン モードを使用して挿入し、redisson を使用してパイプライン モードを使用して挿入し、その時間をテストします。

@Slf4jpublic class RedisPipelineTestDemo {    public static void main(String[] args) {        //连接redis        Jedis jedis = new Jedis("10.101.17.180", 6379);         //jedis逐一给每个set新增一个value        String zSetKey = "Pipeline-test-set";        int size = 100000;         long begin = System.currentTimeMillis();        for (int i = 0; i < size; i++) {            jedis.sadd(zSetKey + i, "aaa");        }        log.info("Jedis逐一给每个set新增一个value耗时:{}ms", (System.currentTimeMillis() - begin));         //Jedis使用Pipeline模式         Pipeline Pipeline = jedis.Pipelined();        begin = System.currentTimeMillis();        for (int i = 0; i < size; i++) {             Pipeline.sadd(zSetKey + i, "bbb");        }         Pipeline.sync();        log.info("Jedis Pipeline模式耗时:{}ms", (System.currentTimeMillis() - begin));         //Redisson使用Pipeline模式        Config config = new Config();        config.useSingleServer().setAddress("redis://10.101.17.180:6379");        RedissonClient redisson = Redisson.create(config);        RBatch redisBatch = redisson.createBatch();         begin = System.currentTimeMillis();        for (int i = 0; i < size; i++) {            redisBatch.getSet(zSetKey + i).addAsync("ccc");        }        redisBatch.execute();        log.info("Redisson Pipeline模式耗时:{}ms", (System.currentTimeMillis() - begin));         //关闭         Pipeline.close();        jedis.close();        redisson.shutdown();    }}



テスト結果は次のとおりです。

Jedis が各セットに値を 1 つずつ追加するには、162655 ミリ秒かかります。

Jedis パイプライン モードには 504 ミリ秒かかります

Redisson パイプライン モードには 1399 ミリ秒かかります

パイプライン モードの使用に対応するパフォーマンスは、単一コマンド実行のパフォーマンスよりも大幅に優れていることがわかりました。


4. プロジェクトでの実用化


実際の使用では、休日中に多くのアプリケーションがアプリケーションのアイコン スタイルを更新する必要があるシナリオがあります。操作がバックグラウンド構成を実行するときに、単一のユーザーに発行する必要があるアイコン スタイルは、事前に計算して保存できます。 Redis では、パフォーマンスを向上させるために、Redis のバッチ操作の問題が発生します。そのビジネス プロセスは次のとおりです。



Redis の操作パフォーマンスを向上させるために、バッチ実行に Redis パイプライン メカニズムを使用することにしました。


4.1 Redis クライアントの比較


Java テクノロジー スタックの場合、Redis で最も一般的に使用されるクライアントは、Jedis、Lettuce、および Redisson です。



現在のプロジェクトは主に SpringBoot をベースに開発されており、Redis のデフォルトクライアントは Lettuce であるため、Lettuce クライアントをベースに分析を行っています。


4.2 Spring環境におけるLettuceクライアントによるパイプラインの実装


Spring 環境では、Redis Pipeline の使用も非常に簡単です。 spring-data-redis が提供する

StringRedisTemplate を使用すると、Redis の操作が簡素化されます。必要なのは StringRedisTemplate の usePipelined メソッドを呼び出すことだけですが、パラメーターにSessionCallback と RedisCallback という2 つのコールバック メソッドが提供されます。


使用方法は次の 2 つがあります (ここでは集合構造体を操作する場合を例にします)。


RedisCallbackの使用方法:

public void testRedisCallback() {        List<Integer> ids= Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9);        Integer contentId = 1;        redisTemplate.executePipelined(new InsertPipelineExecutionA(ids, contentId));    }  @AllArgsConstructor    private static class InsertPipelineExecutionA implements RedisCallback<Void> {          private final List<Integer> ids;        private final Integer contentId;          @Override        public Void doInRedis(RedisConnection connection) DataAccessException {            RedisSetCommands redisSetCommands = connection.setCommands();              ids.forEach(id-> {                String redisKey = "aaa:" + id;                String value = String.valueOf(contentId);                redisSetCommands.sAdd(redisKey.getBytes(), value.getBytes());            });            return null;        }    }



SessionCallback の使用方法:

public void testSessionCallback() {        List<Integer> ids= Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9);        Integer contentId = 1;        redisTemplate.executePipelined(new InsertPipelineExecutionB(ids, contentId));    }  @AllArgsConstructor    private static class InsertPipelineExecutionB implements SessionCallback<Void> {          private final List<Integer> ids;        private final Integer contentId;          @Override        public <K, V> Void execute(RedisOperations<K, V> operations) throws DataAccessException {            SetOperations<String, String> setOperations = (SetOperations<String, String>) operations.opsForSet();            ids.forEach(id-> {                String redisKey = "aaa:" + id;                String value = String.valueOf(contentId);                setOperations.add(redisKey, value);            });            return null;        }    }



4.3 RedisCallBack と SessionCallback の比較


1. RedisCallBack と SessionCallback はどちらもコールバックを実装でき、これにより、同じ接続内で複数の Redis コマンドを一度に実行できます。


2. RedisCallback はネイティブを使用します

たとえば、RedisConnection は、上記の set の追加操作を実行するときにキーと値を変換する必要があり、可読性が低くなりますが、ネイティブ API は比較的完全な機能を提供します。


3. SessionCalback は適切なカプセル化を提供するため、このコールバック メソッドを最初に使用することを選択できます。


最終的なコード実装は次のとおりです。

public void executeB(List<Integer> userIds, Integer iconId) {        redisTemplate.executePipelined(new InsertPipelineExecution(userIds, iconId));}  @AllArgsConstructorprivate static class InsertPipelineExecution implements SessionCallback<Void> {      private final List<Integer> userIds;     private final Integer iconId;      @Override     public <K, V> Void execute(RedisOperations<K, V> operations) throws DataAccessException {         SetOperations<String, String> setOperations = (SetOperations<String, String>) operations.opsForSet();         userIds.forEach(userId -> {             String redisKey = "aaa:" + userId;             String value = String.valueOf(iconId);             setOperations.add(redisKey, value);         });         return null;     }}



4.4 ソースコード分析


では、なぜパイプライン メソッドを使用するとパフォーマンスが大幅に向上するのでしょうか。まずはソース コードを分析してみましょう。


4.4.1 パイプライン モードで接続を取得する関連原則の分析:

@Override    public List<Object> executePipelined(SessionCallback<?> session, @Nullable RedisSerializer<?> resultSerializer) {         Assert.isTrue(initialized, "template not initialized; call afterPropertiesSet() before using it");        Assert.notNull(session, "Callback object must not be null");         //1. 获取对应的Redis连接工厂        RedisConnectionFactory factory = getRequiredConnectionFactory();        //2. 绑定连接过程        RedisConnectionUtils.bindConnection(factory, enableTransactionSupport);        try {            //3. 执行命令流程, 这里请求参数为RedisCallback, 里面有对应的回调操作           return execute((RedisCallback<List<Object>>) connection -> {                //具体的回调逻辑                connection.openPipeline();                boolean PipelinedClosed = false;                try {                    //执行命令                    Object result = executeSession(session);                    if (result != null) {                        throw new InvalidDataAccessApiUsageException(                                "Callback cannot return a non-null value as it gets overwritten by the Pipeline");                    }                    List<Object> closePipeline = connection.closePipeline();      PipelinedClosed = true;                    return deserializeMixedResults(closePipeline, resultSerializer, hashKeySerializer, hashValueSerializer);                } finally {                    if (!PipelinedClosed) {                        connection.closePipeline();                    }                }            });        } finally {            RedisConnectionUtils.unbindConnection(factory);        }    }



① ここでパイプライン機能を使用するには、対応する Redis 接続ファクトリーを取得する必要があります。

LettuceConnectionFactory メソッド、ここで取得される接続ファクトリはLettuceConnectionFactory です。


② バインディング接続プロセスは、具体的には、現在の接続を現在のスレッドにバインドすることを指します。コア メソッドは doGetConnection です。

public static RedisConnection doGetConnection(RedisConnectionFactory factory, boolean allowCreate, boolean bind,            boolean enableTransactionSupport) {         Assert.notNull(factory, "No RedisConnectionFactory specified");         //核心类,有缓存作用,下次可以从这里获取已经存在的连接        RedisConnectionHolder connHolder = (RedisConnectionHolder) TransactionSynchronizationManager.getResource(factory);         //如果connHolder不为null, 则获取已经存在的连接, 提升性能        if (connHolder != null) {            if (enableTransactionSupport) {                potentiallyRegisterTransactionSynchronisation(connHolder, factory);            }            return connHolder.getConnection();        }         ......         //第一次获取连接,需要从Redis连接工厂获取连接        RedisConnection conn = factory.getConnection();         //bind = true 执行绑定        if (bind) {             RedisConnection connectionToBind = conn;            ......            connHolder = new RedisConnectionHolder(connectionToBind);             //绑定核心代码: 将获取的连接和当前线程绑定起来            TransactionSynchronizationManager.bindResource(factory, connHolder);            ......             return connHolder.getConnection();        }         return conn;    }



コアクラス RedisConnectionHolder があります。見てみましょう

RedisConnectionHolder connHolder = 

(RedisConnectionHolder) 

TransactionSynchronizationManager.getResource(factory);


@Nullable    public static Object getResource(Object key) {        Object actualKey = TransactionSynchronizationUtils.unwrapResourceIfNecessary(key);        Object value = doGetResource(actualKey);        if (value != null && logger.isTraceEnabled()) {            logger.trace("Retrieved value [" + value + "] for key [" + actualKey + "] bound to thread [" +                    Thread.currentThread().getName() + "]");        }        return value;    }




里面有一个核心方法doGetResource

(actualKey),大家很容易猜测这里涉及到一个map结构,如果我们看源码,也确实是这样一个结构。

@Nullable    private static Object doGetResource(Object actualKey) {        Map<Object, Object> map = resources.get();        if (map == null) {            return null;        }        Object value = map.get(actualKey);        // Transparently remove ResourceHolder that was marked as void...        if (value instanceof ResourceHolder && ((ResourceHolder) value).isVoid()) {            map.remove(actualKey);            // Remove entire ThreadLocal if empty...            if (map.isEmpty()) {                resources.remove();            }            value = null;        }        return value;    }



resources是一个ThreadLocal类型,这里会涉及到根据RedisConnectionFactory获取到连接connection的逻辑,如果下一次是同一个actualKey,那么就直接使用已经存在的连接,而不需要新建一个连接。第一次这里map为null,就直接返回了,然后回到doGetConnection方法,由于这里bind为true,我们会执行TransactionSynchronizationManager.bindResource(factory, connHolder);,也就是将连接和当前线程绑定了起来。

public static void bindResource(Object key, Object value) throws IllegalStateException {        Object actualKey = TransactionSynchronizationUtils.unwrapResourceIfNecessary(key);        Assert.notNull(value, "Value must not be null");        Map<Object, Object> map = resources.get();        // set ThreadLocal Map if none found        if (map == null) {            map = new HashMap<>();            resources.set(map);        }        Object oldValue = map.put(actualKey, value);        ......    }


③ 我们回到executePipelined,在获取到连接工厂,将连接和当前线程绑定起来以后,就开始需要正式去执行命令了, 这里会调用execute方法

@Override@Nullablepublic <T> T execute(RedisCallback<T> action) {    return execute(action, isExposeConnection());}


这里我们注意到execute方法的入参为RedisCallback<T>action,RedisCallback对应的doInRedis操作如下,这里在后面的调用过程中会涉及到回调。

connection.openPipeline();boolean PipelinedClosed = false;try {    Object result = executeSession(session);    if (result != null) {        throw new InvalidDataAccessApiUsageException(                "Callback cannot return a non-null value as it gets overwritten by the Pipeline");    }    List<Object> closePipeline = connection.closePipeline();  PipelinedClosed = true;    return deserializeMixedResults(closePipeline, resultSerializer, hashKeySerializer, hashValueSerializer);} finally {    if (!PipelinedClosed) {        connection.closePipeline();    }}


我们再来看execute(action, 

isExposeConnection())方法,这里最终会调用

<T>execute(RedisCallback<T>action, boolean exposeConnection, boolean Pipeline)方法。

@Nullablepublic <T> T execute(RedisCallback<T> action, boolean exposeConnection, boolean Pipeline) {     Assert.isTrue(initialized, "template not initialized; call afterPropertiesSet() before using it");    Assert.notNull(action, "Callback object must not be null");     //获取对应的连接工厂    RedisConnectionFactory factory = getRequiredConnectionFactory();    RedisConnection conn = null;    try {        if (enableTransactionSupport) {            // only bind resources in case of potential transaction synchronization            conn = RedisConnectionUtils.bindConnection(factory, enableTransactionSupport);        } else {            //获取对应的连接(enableTransactionSupport=false)               conn = RedisConnectionUtils.getConnection(factory);        }         boolean existingConnection = TransactionSynchronizationManager.hasResource(factory);         RedisConnection connToUse = preProcessConnection(conn, existingConnection);         boolean PipelineStatus = connToUse.isPipelined();        if (Pipeline && !PipelineStatus) {            connToUse.openPipeline();        }         RedisConnection connToExpose = (exposeConnection ? connToUse : createRedisConnectionProxy(connToUse));                 //核心方法,这里就开始执行回调操作        T result = action.doInRedis(connToExpose);         // close Pipeline        if (Pipeline && !PipelineStatus) {            connToUse.closePipeline();        }         // TODO: any other connection processing?        return postProcessResult(result, connToUse, existingConnection);    } finally {        RedisConnectionUtils.releaseConnection(conn, factory, enableTransactionSupport);    }}



我们看到这里最开始也是获取对应的连接工厂,然后获取对应的连接

(enableTransactionSupport=false),具体调用是

RedisConnectionUtils.getConnection(factory)方法,最终会调用

RedisConnection doGetConnection(RedisConnectionFactory factory, boolean allowCreate, boolean bind, boolean enableTransactionSupport),此时bind为false

public static RedisConnection doGetConnection(RedisConnectionFactory factory, boolean allowCreate, boolean bind,        boolean enableTransactionSupport) {     Assert.notNull(factory, "No RedisConnectionFactory specified");     //直接获取与当前线程绑定的Redis连接    RedisConnectionHolder connHolder = (RedisConnectionHolder) TransactionSynchronizationManager.getResource(factory);     if (connHolder != null) {        if (enableTransactionSupport) {            potentiallyRegisterTransactionSynchronisation(connHolder, factory);        }        return connHolder.getConnection();    }     ......     return conn;}



前面我们分析过一次,这里调用

RedisConnectionHolder connHolder = 

(RedisConnectionHolder) TransactionSynchronizationManager.getResource(factory);会获取到之前和当前线程绑定的Redis,而不会新创建一个连接。


然后会去执行T result = action.

doInRedis(connToExpose),这里的action为RedisCallback,执行doInRedis为:

//开启Pipeline功能connection.openPipeline();boolean PipelinedClosed = false;try {    //执行Redis命令    Object result = executeSession(session);    if (result != null) {        throw new InvalidDataAccessApiUsageException(                "Callback cannot return a non-null value as it gets overwritten by the Pipeline");    }    List<Object> closePipeline = connection.closePipeline();  PipelinedClosed = true;    return deserializeMixedResults(closePipeline, resultSerializer, hashKeySerializer, hashValueSerializer);} finally {    if (!PipelinedClosed) {        connection.closePipeline();    }}



这里最开始会开启Pipeline功能,然后执行

Object result = executeSession(session);

private Object executeSession(SessionCallback<?> session) {    return session.execute(this);}


这里会调用我们自定义的execute方法

@AllArgsConstructorprivate static class InsertPipelineExecution implements SessionCallback<Void> {      private final List<Integer> userIds;     private final Integer iconId;      @Override     public <K, V> Void execute(RedisOperations<K, V> operations) throws DataAccessException {         SetOperations<String, String> setOperations = (SetOperations<String, String>) operations.opsForSet();         userIds.forEach(userId -> {             String redisKey = "aaa:" + userId;             String value = String.valueOf(iconId);             setOperations.add(redisKey, value);         });         return null;     }}



进入到foreach循环,执行DefaultSetOperations的add方法。

@Overridepublic Long add(K key, V... values) {     byte[] rawKey = rawKey(key);    byte[][] rawValues = rawValues((Object[]) values);    //这里的connection.sAdd是后续回调要执行的方法   return execute(connection -> connection.sAdd(rawKey, rawValues), true);}



这里会继续执行redisTemplate的execute方法,里面最终会调用我们之前分析过的<T>T execute(RedisCallback<T>action, boolean exposeConnection, boolean Pipeline)方法。

@Nullablepublic <T> T execute(RedisCallback<T> action, boolean exposeConnection, boolean Pipeline) {     Assert.isTrue(initialized, "template not initialized; call afterPropertiesSet() before using it");    Assert.notNull(action, "Callback object must not be null");     RedisConnectionFactory factory = getRequiredConnectionFactory();    RedisConnection conn = null;    try {         ......        //再次执行回调方法,这里执行的Redis基本数据结构对应的操作命令        T result = action.doInRedis(connToExpose);                 ......         // TODO: any other connection processing?        return postProcessResult(result, connToUse, existingConnection);    } finally {        RedisConnectionUtils.releaseConnection(conn, factory, enableTransactionSupport);    }}



这里会继续执行T result = 

action.doInRedis(connToExpose);,这里其实执行的doInRedis方法为:

connection -> connection.sAdd(rawKey, rawValues)


4.4.2 Pipeline方式下执行命令的流程分析:


① 接着上面的流程分析,这里的sAdd方法实际调用的是DefaultStringRedisConnection的sAdd方法

@Overridepublic Long sAdd(byte[] key, byte[]... values) {    return convertAndReturn(delegate.sAdd(key, values), identityConverter);}


② 这里会进一步调用

DefaultedRedisConnection的sAdd方法


@Override@Deprecateddefault Long sAdd(byte[] key, byte[]... values) { return setCommands().sAdd(key, values);}


③ 接着调用LettuceSetCommands的sAdd方法

@Overridepublic Long sAdd(byte[] key, byte[]... values) {     Assert.notNull(key, "Key must not be null!");    Assert.notNull(values, "Values must not be null!");    Assert.noNullElements(values, "Values must not contain null elements!");     try {        // 如果开启了 Pipelined 模式,获取的是 异步连接,进行异步操作        if (isPipelined()) {    Pipeline(connection.newLettuceResult(getAsyncConnection().sadd(key, values)));            return null;        }
if (isQueueing()) { transaction(connection.newLettuceResult(getAsyncConnection().sadd(key, values))); return null; } //常规模式下,使用的是同步操作 return getConnection().sadd(key, values); } catch (Exception ex) { throw convertLettuceAccessException(ex); }}



这里我们开启了Pipeline, 实际会调用

Pipeline(connection.newLettuceResult(getAsyncConnection().sadd(key, values))); 也就是获取异步连接getAsyncConnection,然后进行异步操作sadd,而常规模式下,使用的是同步操作,所以在Pipeline模式下,执行效率更高。


从上面的获取连接和具体命令执行相关源码分析可以得出使用Lettuce客户端Pipeline模式高效的根本原因:

  1. 普通模式下,每执行一个命令都需要先打开一个连接,命令执行完毕以后又需要关闭这个连接,执行下一个命令时,又需要经过连接打开和关闭的流程;而Pipeline的所有命令的执行只需要经过一次连接打开和关闭。

  2. 普通模式下命令的执行是同步阻塞模式,而Pipeline模式下命令的执行是异步非阻塞模式。


五、项目中遇到的坑


前面介绍了涉及到批量操作,可以使用Redis Pipelining机制,那是不是任何批量操作相关的场景都可以使用呢,比如list类型数据的批量移除操作,我们的代码最开始是这么写的:

public void deleteSet(String updateKey, Set<Integer> userIds) {        if (CollectionUtils.isEmpty(userIds)) {            return;        }         redisTemplate.executePipelined(new DeleteListCallBack(userIds, updateKey));    } @AllArgsConstructorprivate static class DeleteListCallBack implements SessionCallback<Object> {     private Set<Integer> userIds;     private String updateKey;     @Override    public <K, V> Object execute(RedisOperations<K, V> operations) throws DataAccessException {        ListOperations<String, String> listOperations = (ListOperations<String, String>) operations.opsForList();        userIds.forEach(userId -> listOperations.remove(updateKey, 1, userId.toString()));        return null;    }}



在数据量比较小的时候没有出现问题,直到有一条收到了Redis的内存和cpu利用率的告警消息,我们发现这么使用是有问题的,核心原因在于list的lrem操作的时间复杂度是O(N+M),其中N是list的长度, M是要移除的元素的个数,而我们这里还是一个一个移除的,当然会导致Redis数据积压和cpu每秒ops升高导致cpu利用率飚高。也就是说,即使使用Pipeline进行批量操作,但是由于单次操作很耗时,是会导致整个Redis出现问题的。


后面我们进行了优化,选用了list的ltrim命令,一次命令执行批量remove操作:

public void deleteSet(String updateKey, Set<Integer> deviceIds) {        if (CollectionUtils.isEmpty(deviceIds)) {            return;        }         int maxSize = 10000;        redisTemplate.opsForList().trim(updateKey, maxSize + 1, -1);    }


由于ltrim本身的时间复杂度为O(M), 其中M要移除的元素的个数,相比于原始方案的lrem,效率提升很多,可以不需要使用Redis Pipeline,优化结果使得Redis内存利用率和cpu利用率都极大程度得到缓解。



六、Redisson 对 Redis Pipeline 特性支持


在redisson官方文档中额外特性介绍中有说到批量命令执行这个特性, 也就是多个命令在一次网络调用中集中发送,该特性是RBatch这个类支持的,从这个类的描述来看,主要是为Redis Pipeline这个特性服务的,并且主要是通过队列和异步实现的。

/** * Interface for using Redis Pipeline feature. * <p> * All method invocations on objects got through this interface * are batched to separate queue and could be executed later * with <code>execute()</code> or <code>executeAsync()</code> methods. * * * @author Nikita Koksharov * */public interface RBatch {     /**     * Returns stream instance by <code>name</code>     *     * @param <K> type of key     * @param <V> type of value     * @param name of stream     * @return RStream object     */    <K, V> RStreamAsync<K, V> getStream(String name);         /**     * Returns stream instance by <code>name</code>     * using provided <code>codec</code> for entries.     *     * @param <K> type of key     * @param <V> type of value     * @param name - name of stream     * @param codec - codec for entry     * @return RStream object     */    <K, V> RStreamAsync<K, V> getStream(String name, Codec codec);         ......        /**     * Returns list instance by name.     *     * @param <V> type of object     * @param name - name of object     * @return List object     */    <V> RListAsync<V> getList(String name);     <V> RListAsync<V> getList(String name, Codec codec);     ......     /**     * Executes all operations accumulated during async methods invocations.     * <p>     * If cluster configuration used then operations are grouped by slot ids     * and may be executed on different servers. Thus command execution order could be changed     *     * @return List with result object for each command     * @throws RedisException in case of any error     *     */    BatchResult<?> execute() throws RedisException;     /**     * Executes all operations accumulated during async methods invocations asynchronously.     * <p>     * In cluster configurations operations grouped by slot ids     * so may be executed on different servers. Thus command execution order could be changed     *     * @return List with result object for each command     */    RFuture<BatchResult<?>> executeAsync();     /**     * Discard batched commands and release allocated buffers used for parameters encoding.     */    void discard();     /**     * Discard batched commands and release allocated buffers used for parameters encoding.     *     * @return void     */    RFuture<Void> discardAsync();  }


简单的测试代码如下:

@Slf4jpublic class RedisPipelineTest {    public static void main(String[] args) {        //Redisson使用Pipeline模式        Config config = new Config();        config.useSingleServer().setAddress("redis://xx.xx.xx.xx:6379");        RedissonClient redisson = Redisson.create(config);        RBatch redisBatch = redisson.createBatch();         int size = 100000;        String zSetKey = "Pipeline-test-set";        long begin = System.currentTimeMillis();                 //将命令放入队列中        for (int i = 0; i < size; i++) {            redisBatch.getSet(zSetKey + i).addAsync("ccc");        }        //批量执行命令        redisBatch.execute();        log.info("Redisson Pipeline模式耗时:{}ms", (System.currentTimeMillis() - begin));         //关闭        redisson.shutdown();    }}


核心方法分析:


1.建Redisson客户端RedissonClient redisson = redisson.create(config), 该方法最终会调用Reddison的构造方法Redisson(Config config)。

protected Redisson(Config config) {        this.config = config;        Config configCopy = new Config(config);         connectionManager = ConfigSupport.createConnectionManager(configCopy);        RedissonObjectBuilder objectBuilder = null;        if (config.isReferenceEnabled()) {            objectBuilder = new RedissonObjectBuilder(this);        }        //新建异步命令执行器      commandExecutor = new CommandSyncService(connectionManager, objectBuilder);        //执行删除超时任务的定时器      evictionScheduler = new EvictionScheduler(commandExecutor);        writeBehindService = new WriteBehindService(commandExecutor);}


该构造方法中会新建异步命名执行器CommandAsyncExecutor commandExecutor和用户删除超时任务的EvictionScheduler evictionScheduler。


2.创建RBatch实例RBatch redisBatch = redisson.createBatch(), 该方法会使用到步骤1中的commandExecutor和evictionScheduler实例对象。

@Overridepublic RBatch createBatch(BatchOptions options) {    return new RedissonBatch(evictionScheduler, commandExecutor, options);} public RedissonBatch(EvictionScheduler evictionScheduler, CommandAsyncExecutor executor, BatchOptions options) {        this.executorService = new CommandBatchService(executor, options);        this.evictionScheduler = evictionScheduler;}



其中的options对象会影响后面批量执行命令的流程。


3. 异步给set集合添加元素的操作addAsync,这里会具体调用RedissonSet的addAsync方法

@Overridepublic RFuture<Boolean> addAsync(V e) {    String name = getRawName(e);    return commandExecutor.writeAsync(name, codec, RedisCommands.SADD_SINGLE, name, encode(e));}


(1)接着调用CommandAsyncExecutor的异步写入方法writeAsync。

@Overridepublic <T, R> RFuture<R> writeAsync(String key, Codec codec, RedisCommand<T> command, Object... params) {    RPromise<R> mainPromise = createPromise();    NodeSource source = getNodeSource(key);    async(false, source, codec, command, params, mainPromise, false);    return mainPromise;}



(2) 接着调用批量命令执行器

CommandBatchService的异步发送命令。

@Overridepublic <V, R> void async(boolean readOnlyMode, NodeSource nodeSource,        Codec codec, RedisCommand<V> command, Object[] params, RPromise<R> mainPromise, boolean ignoreRedirect) {    if (isRedisBasedQueue()) {        boolean isReadOnly = options.getExecutionMode() == ExecutionMode.REDIS_READ_ATOMIC;        RedisExecutor<V, R> executor = new RedisQueuedBatchExecutor<>(isReadOnly, nodeSource, codec, command, params, mainPromise,                false, connectionManager, objectBuilder, commands, connections, options, index, executed, latch, referenceType);        executor.execute();    } else {        //执行分支        RedisExecutor<V, R> executor = new RedisBatchExecutor<>(readOnlyMode, nodeSource, codec, command, params, mainPromise,                false, connectionManager, objectBuilder, commands, options, index, executed, referenceType);        executor.execute();    }     }



(3) 接着调用了RedisBatchExecutor.

execute方法和BaseRedisBatchExecutor.

addBatchCommandData方法。

@Overridepublic void execute() {    addBatchCommandData(params);} protected final void addBatchCommandData(Object[] batchParams) {    MasterSlaveEntry msEntry = getEntry(source);    Entry entry = commands.get(msEntry);    if (entry == null) {        entry = new Entry();        Entry oldEntry = commands.putIfAbsent(msEntry, entry);        if (oldEntry != null) {            entry = oldEntry;        }    }     if (!readOnlyMode) {        entry.setReadOnlyMode(false);    }     Codec codecToUse = getCodec(codec);    BatchCommandData<V, R> commandData = new BatchCommandData<V, R>(mainPromise, codecToUse, command, batchParams, index.incrementAndGet());    entry.getCommands().add(commandData);}


这里的commands以主节点为KEY,以待发送命令队列列表为VALUE(Entry),保存一个MAP.然后会把命令都添加到entry的commands命令队列中, Entry结构如下面代码所示。

public static class Entry {     Deque<BatchCommandData<?, ?>> commands = new LinkedBlockingDeque<>();    volatile boolean readOnlyMode = true;     public Deque<BatchCommandData<?, ?>> getCommands() {        return commands;    }     public void setReadOnlyMode(boolean readOnlyMode) {        this.readOnlyMode = readOnlyMode;    }     public boolean isReadOnlyMode() {        return readOnlyMode;    }          public void clearErrors() {        for (BatchCommandData<?, ?> commandEntry : commands) {            commandEntry.clearError();        }    } }



4. 批量执行命令redisBatch.execute(),这里会最终调用CommandBatchService的executeAsync方法,该方法完整代码如下,我们下面来逐一进行拆解。

public RFuture<BatchResult<?>> executeAsync() {                 ......         RPromise<BatchResult<?>> promise = new RedissonPromise<>();        RPromise<Void> voidPromise = new RedissonPromise<Void>();        if (this.options.isSkipResult()                && this.options.getSyncSlaves() == 0) {            ......        } else {            //这里是对异步执行结果进行处理,可以先忽略, 后面会详细讲,先关注批量执行命令的逻辑            voidPromise.onComplete((res, ex) -> {                ......            });        }         AtomicInteger slots = new AtomicInteger(commands.size());         ......                 //真正执行的代码入口,批量执行命令        for (Map.Entry<MasterSlaveEntry, Entry> e : commands.entrySet()) {            RedisCommonBatchExecutor executor = new RedisCommonBatchExecutor(new NodeSource(e.getKey()), voidPromise,                                                    connectionManager, this.options, e.getValue(), slots, referenceType);            executor.execute();        }        return promise;    }



里面会用到我们在3.3步骤所生成的commands实例。


(1)接着调用了基类RedisExecutor的execute方法

public void execute() {                 ......         connectionFuture.onComplete((connection, e) -> {            if (connectionFuture.isCancelled()) {                connectionManager.getShutdownLatch().release();                return;            }             if (!connectionFuture.isSuccess()) {                connectionManager.getShutdownLatch().release();                exception = convertException(connectionFuture);                return;            }             //调用RedisCommonBatchExecutor的sendCommand方法, 里面会将多个命令放到一个List<CommandData<?, ?>> list列表里面        sendCommand(attemptPromise, connection);             writeFuture.addListener(new ChannelFutureListener() {                @Override                public void operationComplete(ChannelFuture future) throws Exception {                    checkWriteFuture(writeFuture, attemptPromise, connection);                }            });        });         ......    }


(2)接着调用

RedisCommonBatchExecutor的sendCommand方法,里面会将多个命令放到一个List<commanddata> list列表里面。

@Override    protected void sendCommand(RPromise<Void> attemptPromise, RedisConnection connection) {        boolean isAtomic = options.getExecutionMode() != ExecutionMode.IN_MEMORY;        boolean isQueued = options.getExecutionMode() == ExecutionMode.REDIS_READ_ATOMIC                                || options.getExecutionMode() == ExecutionMode.REDIS_WRITE_ATOMIC;         //将多个命令放到一个List<CommandData<?, ?>> list列表里面      List<CommandData<?, ?>> list = new ArrayList<>(entry.getCommands().size());        if (source.getRedirect() == Redirect.ASK) {            RPromise<Void> promise = new RedissonPromise<Void>();            list.add(new CommandData<Void, Void>(promise, StringCodec.INSTANCE, RedisCommands.ASKING, new Object[] {}));        }        for (CommandData<?, ?> c : entry.getCommands()) {            if ((c.getPromise().isCancelled() || c.getPromise().isSuccess())                    && !isWaitCommand(c)                        && !isAtomic) {                // skip command                continue;            }            list.add(c);        }                 ......        //调用RedisConnection的send方法,将命令一次性发到Redis服务器端      writeFuture = connection.send(new CommandsData(attemptPromise, list, options.isSkipResult(), isAtomic, isQueued, options.getSyncSlaves() > 0));    }



(3)接着调用RedisConnection的send方法,通过Netty通信发送命令到Redis服务器端执行,这里也验证了Redisson客户端底层是采用Netty进行通信的。

public ChannelFuture send(CommandsData data) {        return channel.writeAndFlush(data);}


5. 接收返回结果,这里主要是监听事件是否完成,然后组装返回结果, 核心方法是步骤4提到的CommandBatchService的executeAsync方法,里面会对返回结果进行监听和处理, 核心代码如下:

public RFuture<BatchResult<?>> executeAsync() {    ......         RPromise<BatchResult<?>> promise = new RedissonPromise<>();    RPromise<Void> voidPromise = new RedissonPromise<Void>();    if (this.options.isSkipResult()            && this.options.getSyncSlaves() == 0) {        ......    } else {        voidPromise.onComplete((res, ex) -> {            //对返回结果的处理            executed.set(true);            ......            List<Object> responses = new ArrayList<Object>(entries.size());            int syncedSlaves = 0;            for (BatchCommandData<?, ?> commandEntry : entries) {                if (isWaitCommand(commandEntry)) {                    syncedSlaves = (Integer) commandEntry.getPromise().getNow();                } else if (!commandEntry.getCommand().getName().equals(RedisCommands.MULTI.getName())                        && !commandEntry.getCommand().getName().equals(RedisCommands.EXEC.getName())                        && !this.options.isSkipResult()) {                                         ......                    //获取单个命令的执行结果                    Object entryResult = commandEntry.getPromise().getNow();                    ......                    //将单个命令执行结果放到List中                    responses.add(entryResult);                }            }                         BatchResult<Object> result = new BatchResult<Object>(responses, syncedSlaves);            promise.trySuccess(result);            ......        });    }     ......    return promise;}


这里会把单个命令的执行结果放到responses里面,最终返回RPromise<batchresult>promise。


从上面的分析来看,Redisson客户端对Redis Pipeline的支持也是从多个命令在一次网络通信中执行和异步处理来实现的。


七、总结


Redis提供了Pipelining进行批量操作的高级特性,极大地提高了部分数据类型没有批量执行命令导致的执行耗时而引起的性能问题,但是我们在使用的过程中需要考虑Pipeline操作中单个命令执行的耗时问题,否则带来的效果可能适得其反。最后扩展分析了Redisson客户端对Redis Pipeline特性的支持原理,可以与Lettuce客户端对Redis Pipeline支持原理进行比较,加深Pipeline在不同Redis客户端实现方式的理解。


参考资料:




END

猜你喜欢



本文分享自微信公众号 - vivo互联网技术(vivoVMIC)。
如有侵权,请联系 [email protected] 删除。
本文参与“OSC源创计划”,欢迎正在阅读的你也加入,一起分享。

老乡鸡“开源”了 deepin-IDE 终于实现了自举! 好家伙,腾讯真把 Switch 变成了「思维驰学习机」 腾讯云4月8日故障复盘及情况说明 RustDesk 远程桌面启动重构 Web 客户端 微信基于 SQLite 的开源终端数据库 WCDB 迎来重大升级 TIOBE 4 月榜单:PHP 跌至历史最低点 FFmpeg 之父 Fabrice Bellard 发布音频压缩工具 TSAC 谷歌发布代码大模型 CodeGemma 不要命啦?做的这么好还开源 - 开源图片 & 海报编辑器工具
{{o.name}}
{{m.name}}

おすすめ

転載: my.oschina.net/vivotech/blog/11052076