redis集羣優化,JedisCluster實現Pipeline功能,進而實現批處理

前提

redis在集羣模式下無法使用Pipeline進行批處理,在一定生產環境下降低了開發效率,於是手寫了一個pipeline處理工具,以實現一個基礎的批處理

我們知道,普通的情況下,redis client與server之間採用的是請求應答的模式,即:

Client: command1
Server: response1
Client: command2
Server: response2

在這種情況下,如果要完成10個命令,則需要20次交互才能完成。因此,即使redis處理能力很強,仍然會受到網絡傳輸影響,導致吞吐上不去。而在管道模式下,多個請求變成這樣:

Client: command1,command2…
Server: response1,response2…

在這種情況下,完成命令只需要2次交互。這樣網絡傳輸上能夠更加高效,加上redis本身強勁的處理能力,是不是有一種飛一樣的感覺。聽到這裏有沒有去優化應用的衝動? 然而到了cluster模式下,這樣的功能並不支持。 下面我們先來分析下,是什麼原因導致redis cluter沒辦法支持管道模式。首先需要了解集羣下的幾個特性:

  • 1、集羣將空間分拆爲16384個槽位(slot),每一個節點負責其中一些槽位。遷移時對整個slot遷移
  • 2、節點添加,或宕機或不可達的情況下可以正常使用
  • 3、不存在中心或者代理節點, 每個節點中都包含集羣所有的節點信息
  • 4、集羣中的節點不會代理請求:即如果client將命令發送到錯誤的節點上,操作會失敗,但會返回”-MOVED”或”-ASK”,供client進行永久或臨時的節點切換

以上信息中第3、4點信息比較重要。

我們先來看第3點,由於每個節點都包含所有的節點信息,因此client連接任一節點都可以獲取整個集羣的信息,這樣我們在配置JedisCluster時只需要配置其中一部分節點的信息就可以(配置多個是爲了高可用)。對應的獲取集羣命令爲:cluster nodes

127.0.0.1:9380> cluster nodes
b6d0cfe64dbae9590e6fc4c5a8e309debcbe0529 127.0.0.1:9380 myself,master - 0 0 2 connected 5461-10922
b9e5592558aae0f28c79c3750b264d5b2530f6a4 127.0.0.1:9381 master - 0 1466758609932 3 connected 10923-16383
b40095eb2023653eaea5b7b4e242a77a7817889a 127.0.0.1:9379 master - 0 1466758608932 1 connected 0-5460

每一行代表一個節點的信息,這裏共三個節點(測試用,沒有建slave節點),依次的信息爲:

{id} {ip:port} {flags如master/slave} {master id} {ping-sent} {pong-recv} {config-epoch} {link-state} {slot} {slot} … {slot}
參考: http://redis.io/commands/cluster-nodes

可以看到每個節點對應的slot信息都在這裏,{slot}格式一般是{begin}-{end}(如0-5460),表示從{begin}到{end}的所有slot都在當前節點中。因此我們可以通過slot找到對應機器的ip:port。 注意,新版client中使用cluster slots獲取對應數據 , 參考: http://redis.io/commands/cluster-slots 。

再來看第4點,由第3點可以知道client可以通過獲取所有節點信息,根據key計算得到對應的slot後可以找到對應的節點。所以說在節點穩定(沒有增減)的情況下,客戶端可以一直用緩存的集羣信息來發起各種命令。然而,如果節點發生變更客戶端是否能夠立即感知? 目前的client JedisCluster是無法感知的,他是通過執行命令後, 服務端返回的“-MOVED”信息感知節點的變化,並以此來刷新緩存信息。

瞭解以上信息以後,JedisCluster爲什麼不支持pipeline就比較清晰了。 因爲pipeline模式下命令將被緩存到對應的連接(OutputStream)上,而在真正向服務端發送數據時,節點可能發生了改變,數據就可能發向了錯誤的節點,這導致批量操作失敗,而要處理這種失敗是非常複雜的。至少目前JedisCluster並未提供這樣的機制。(對於單key來說,在發生這種情況的時候,進行簡單的節點數據刷新+重新發送當前命令來重試)。

看到這裏,你可能會感到沮喪(我猿類如此不易,且行且珍惜)。這裏提供一個簡單的思路,你可以根據單key的邏輯,如果某些key遇到”-MOVE”或”-ASK”則重試。 根據這個思路,你需要按順序記錄所有的命令,每次執行完成後找出異常的數據,刷新節點信息後重試,最終將重試(可能有多次)獲取到的結果根據順序信息插入返回列表。對於重試多次依然失敗的數據,交由業務處理。思路很簡單,然而redis命令太多了,要對PipelineBase的每個方法都這樣改造,我不想(因爲我懶呀),而且估計坑也很多,所以這個只能靠你自己去搞了。

下面我說下針對我們的業務做的一個JedisCluster pipeline實現。對應的業務有以下特點:

  • 數據爲每隔一段時間全量導入redis集羣,數據量約xx萬(xx較大)
  • 導入任務爲後臺執行,可重試,最終如果有部分失敗可接受
  • 集羣相對較穩定,不會頻繁的加減機器
  • 在線業務不使用該api

以上摘自博客https://blog.csdn.net/youaremoon/article/details/51751991

Pipeline工具類

package jedisTest;

/**
 * @author YDAlex
 * @version 1.0
 * @description 在集羣模式下實現pipeline功能
 * @data 2019/11/19.
 */
import java.io.Closeable;
import java.io.IOException;
import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Queue;
import java.util.Set;

import net.jcip.annotations.NotThreadSafe;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import redis.clients.jedis.BinaryJedisCluster;
import redis.clients.jedis.Client;
import redis.clients.jedis.HostAndPort;
import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisCluster;
import redis.clients.jedis.JedisClusterConnectionHandler;
import redis.clients.jedis.JedisClusterInfoCache;
import redis.clients.jedis.JedisPool;
import redis.clients.jedis.JedisSlotBasedConnectionHandler;
import redis.clients.jedis.PipelineBase;
import redis.clients.jedis.exceptions.JedisMovedDataException;
import redis.clients.jedis.exceptions.JedisRedirectionException;
import redis.clients.util.JedisClusterCRC16;
import redis.clients.util.SafeEncoder;

/**
 * 在集羣模式下提供批量操作的功能。
 * 由於集羣模式存在節點的動態添加刪除,且client不能實時感知(只有在執行命令時纔可能知道集羣發生變更),
 * 因此,該實現不保證一定成功,建議在批量操作之前調用 refreshCluster() 方法重新獲取集羣信息。
 * 應用需要保證不論成功還是失敗都會調用close() 方法,否則可能會造成泄露。
 * 如果失敗需要應用自己去重試,因此每個批次執行的命令數量需要控制。防止失敗後重試的數量過多。
 * 基於以上說明,建議在集羣環境較穩定(增減節點不會過於頻繁)的情況下使用,且允許失敗或有對應的重試策略。
 **/
@NotThreadSafe
public class JedisClusterPipeline extends PipelineBase implements Closeable {

    private static final Logger LOGGER = LoggerFactory.getLogger(JedisClusterPipeline.class);

    // 部分字段沒有對應的獲取方法,只能採用反射來做
    // 你也可以去繼承JedisCluster和JedisSlotBasedConnectionHandler來提供訪問接口
    private static final Field FIELD_CONNECTION_HANDLER;
    private static final Field FIELD_CACHE;
    static {
        FIELD_CONNECTION_HANDLER = getField(BinaryJedisCluster.class, "connectionHandler");
        FIELD_CACHE = getField(JedisClusterConnectionHandler.class, "cache");
    }

    private JedisSlotBasedConnectionHandler connectionHandler;
    private JedisClusterInfoCache clusterInfoCache;
    private Queue<Client> clients = new LinkedList<Client>();   // 根據順序存儲每個命令對應的Client
    private Map<JedisPool, Jedis> jedisMap = new HashMap<>();   // 用於緩存連接
    private boolean hasDataInBuf = false;   // 是否有數據在緩存區

    /**
     * 根據jedisCluster實例生成對應的JedisClusterPipeline
     * @param
     * @return
     */
    public static JedisClusterPipeline pipelined(JedisCluster jedisCluster) {
        JedisClusterPipeline pipeline = new JedisClusterPipeline();
        pipeline.setJedisCluster(jedisCluster);
        return pipeline;
    }

    public JedisClusterPipeline() {
    }

    public void setJedisCluster(JedisCluster jedis) {
        connectionHandler = getValue(jedis, FIELD_CONNECTION_HANDLER);
        clusterInfoCache = getValue(connectionHandler, FIELD_CACHE);
    }

    /**
     * 刷新集羣信息,當集羣信息發生變更時調用
     * @param
     * @return
     */
    public void refreshCluster() {
        connectionHandler.renewSlotCache();
    }

    /**
     * 同步讀取所有數據. 與syncAndReturnAll()相比,sync()只是沒有對數據做反序列化
     */
    public void sync() {
        innerSync(null);
    }

    /**
     * 同步讀取所有數據 並按命令順序返回一個列表
     *
     * @return 按照命令的順序返回所有的數據
     */
    public List<Object> syncAndReturnAll() {
        List<Object> responseList = new ArrayList<Object>();

        innerSync(responseList);

        return responseList;
    }

    private void innerSync(List<Object> formatted) {
        HashSet<Client> clientSet = new HashSet<Client>();

        try {
            for (Client client : clients) {
                // 在sync()調用時其實是不需要解析結果數據的,但是如果不調用get方法,發生了JedisMovedDataException這樣的錯誤應用是不知道的,因此需要調用get()來觸發錯誤。
                // 其實如果Response的data屬性可以直接獲取,可以省掉解析數據的時間,然而它並沒有提供對應方法,要獲取data屬性就得用反射,不想再反射了,所以就這樣了
                Object data = generateResponse(client.getOne()).get();
                if (null != formatted) {
                    formatted.add(data);
                }

                // size相同說明所有的client都已經添加,就不用再調用add方法了
                if (clientSet.size() != jedisMap.size()) {
                    clientSet.add(client);
                }
            }
        } catch (JedisRedirectionException jre) {
            if (jre instanceof JedisMovedDataException) {
                // if MOVED redirection occurred, rebuilds cluster's slot cache,
                // recommended by Redis cluster specification
                refreshCluster();
            }

            throw jre;
        } finally {
            if (clientSet.size() != jedisMap.size()) {
                // 所有還沒有執行過的client要保證執行(flush),防止放回連接池後後面的命令被污染
                for (Jedis jedis : jedisMap.values()) {
                    if (clientSet.contains(jedis.getClient())) {
                        continue;
                    }

                    flushCachedData(jedis);
                }
            }

            hasDataInBuf = false;
            close();
        }
    }

    @Override
    public void close() {
        clean();

        clients.clear();

        for (Jedis jedis : jedisMap.values()) {
            if (hasDataInBuf) {
                flushCachedData(jedis);
            }

            jedis.close();
        }

        jedisMap.clear();

        hasDataInBuf = false;
    }

    private void flushCachedData(Jedis jedis) {
        try {
            jedis.getClient().getAll();
        } catch (RuntimeException ex) {
        }
    }

    @Override
    protected Client getClient(String key) {
        byte[] bKey = SafeEncoder.encode(key);

        return getClient(bKey);
    }

    @Override
    protected Client getClient(byte[] key) {
        Jedis jedis = getJedis(JedisClusterCRC16.getSlot(key));

        Client client = jedis.getClient();
        clients.add(client);

        return client;
    }

    private Jedis getJedis(int slot) {
        JedisPool pool = clusterInfoCache.getSlotPool(slot);

        // 根據pool從緩存中獲取Jedis
        Jedis jedis = jedisMap.get(pool);
        if (null == jedis) {
            jedis = pool.getResource();
            jedisMap.put(pool, jedis);
        }

        hasDataInBuf = true;
        return jedis;
    }

    private static Field getField(Class<?> cls, String fieldName) {
        try {
            Field field = cls.getDeclaredField(fieldName);
            field.setAccessible(true);

            return field;
        } catch (NoSuchFieldException | SecurityException e) {
            throw new RuntimeException("cannot find or access field '" + fieldName + "' from " + cls.getName(), e);
        }
    }

    @SuppressWarnings({"unchecked" })
    private static <T> T getValue(Object obj, Field field) {
        try {
            return (T)field.get(obj);
        } catch (IllegalArgumentException | IllegalAccessException e) {
            LOGGER.error("get value fail", e);

            throw new RuntimeException(e);
        }
    }

}

集羣連接工具類

package jedisTest;

import redis.clients.jedis.HostAndPort;
import redis.clients.jedis.JedisCluster;
import redis.clients.jedis.JedisPoolConfig;

import java.util.HashSet;
import java.util.Set;

/**
 * @author YDAlex
 * @version 1.0
 * @description Jedis連接池連接redis實例
 * @data 2019/11/18.
 */
public class jedisClusterPoolUtils {
    private static JedisCluster pool;
    public static void main(String[] args) {

    }

    public  static JedisCluster getJedisFromPool(){
        Set<HostAndPort> clusterNodes = new HashSet<HostAndPort>();
        clusterNodes.add(new HostAndPort("192.168.159.200", 16001));
        clusterNodes.add(new HostAndPort("192.168.159.200", 16002));
        clusterNodes.add(new HostAndPort("192.168.159.200", 16003));
        clusterNodes.add(new HostAndPort("192.168.159.200", 16004));
        clusterNodes.add(new HostAndPort("192.168.159.200", 16005));
        clusterNodes.add(new HostAndPort("192.168.159.200", 16006));


        if (pool == null){
            synchronized (jedisClusterPoolUtils.class){
                if (pool == null){
                    JedisPoolConfig config = new JedisPoolConfig();
                    config.setMaxIdle(30);
                    config.setMaxTotal(100);
                    config.setTestOnBorrow(true);

                    pool = new JedisCluster(clusterNodes,1500,100,config);
                }
            }
        }
        return  pool;
    }
}

測試

import jedisTest.jedisClusterPoolUtils;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import redis.clients.jedis.JedisCluster;
import jedisTest.JedisClusterPipeline;
import java.io.IOException;
import java.util.List;


/**
 * @author YDAlex
 * @version 1.0
 * @description 使用jedis操作redis集羣,批處理練習
 * @data 2019/11/18.
 */
public class JedisClientDemo3 {
    private JedisCluster jedis = null;

    public static void main(String[] args) {

    }

    @Before
    public void setup(){
        jedis = jedisClusterPoolUtils.getJedisFromPool();
    }

    /**
     * 測試redis中的批處理
     */
    @Test
    public void testBatchDealWith() {
        //需求:將一個String類型的key從1遞增到10000,分別使用普通方式和批處理方式進行驗證。
        //方式1:普通方式
        long beginTime = System.currentTimeMillis();
        for (int i = 1; i <= 10000; i++) {
            jedis.incr("num");
        }
        String numValue = jedis.get("num");

        long endTime = System.currentTimeMillis();
        System.out.printf("普通方式,遞增10000次後的結果是:%s,耗時:%d ms %n%n", numValue, (endTime - beginTime));
        //結果:普通方式,遞增10000次後的結果是:10000,耗時:3878 ms

        System.out.println("\n____________________________________________\n");

        //方式2:批處理方式

        beginTime = System.currentTimeMillis();
        JedisClusterPipeline jcp = JedisClusterPipeline.pipelined(jedis);
        jcp.refreshCluster();
        List<Object> batchResult = null;
        try {
            // batch write
            for (int i = 0; i < 10000; i++) {
                jcp.set("numOther", "v1" + i);
            }
            jcp.sync();

            // batch read
            for (int i = 0; i < 10000; i++) {
                jcp.get("numOther");
            }
            batchResult = jcp.syncAndReturnAll();
        } finally {
            jcp.close();
        }

        endTime = System.currentTimeMillis();
        System.out.printf("批處理方式,遞增 %d 次後的結果是:%s,耗時:%d ms %n%n", batchResult.size(),jedis.get("numOther"), (endTime - beginTime));
        //結果:批處理方式,遞增10000次後的結果是:v19999,耗時:100 ms

    }

    @After
    public void close() throws IOException {
        if (jedis != null){
            jedis.close();
        }
    }
}

參考自博客https://blog.csdn.net/youaremoon/article/details/51751991

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章