如何让JedisCluster支持Pipeline

关注“Java艺术”一起来充电吧!

hmset等批量操作命令与pipeline最大的区别是,前者是原子性命令,比如hmset,如果一次插入的field过多,会导致命令耗时增加;后者非原子性,只是批量的传输要执行命令,减少网络耗时。pipeline提升性能的关键,一是RTT,节省往返时间,二是I/O系统调用,read系统调用,需要从用户态,切换到内核态。

mset这类批量操作命令只能指定一个key,在Cluster集群下,不存在跨节点问题。而Pipeline由于支持所有命令的操作,支持多key,在Cluster集群模式下,会出现key映射到不同solt槽,可能会落到不同的节点上。这也是JedisCluster不提供Pipeline支持的原因。

HashTag

HashTag机制可以影响key被分配到的slot,从而可以使用那些被限制在slot中操作,比如rename。

我在项目重构阶段就遇到这个问题,代码中为了保证数据更新的原子性,使用了一个临时key写入数据,当所有数据更新完成后,再用rename将临时key替换回原来的key,因redis集群由原来的主从集群改为cluster集群后,rename导致代码抛出异常,原因是rename前的key与rename后的key映射到的槽位不在同一个节点上。

HashTag即是用{}包裹key的一个子串,如{user}1, {user}2。在设置了HashTag的情况下,集群会根据HashTag决定key分配到的slot, 两个key拥有相同的HashTag=>{user}, 它们会被分配到同一个slot,允许我们使用mget、rename命令。

#           rename
#offers-tmp ---->offers-active
{offers}-tmp ----> {offers}-active

通常情况下,HashTag不支持嵌套,即将第一个'{'和第一个'}'中间的内容作为HashTag。使用HashTag可能会导致过多的key分配到同一个slot中,造成数据倾斜影响系统的吞吐量,务必谨慎使用。

让JedisCluster支持Pipeline

Pipeline需要客户端和服务端的支持。这是官网对Pipeline的介绍:https://redis.io/topics/pipelining

对于服务端来说,所需要的是能够处理一个客户端通过同一个 TCP 连接发来的多个命令,但并不是所有命令都接收完才执行,和处理单个命令一样,每读到一条完整的命令就放入命令等待队列等待执行,每处理完一条命令就响应给客户端,直到客户端调用socket.getInputStream()的输入流的read方法读取响应。(Jedis具体实现看RedisInputStream与Connection。)

对于客户端,则是要将多个命令写入缓冲区,缓冲区满了就发送,然后再写入缓冲区buf,最后一次需要调用flush将未满的缓冲区的命令都发送出去,最后才处理 Redis 的应答(即read),缓冲区大小为8192字节。

使用Cluster集群模式,需要客户端缓存每个节点的槽位信息。JedisCluster在发送命令前会根据CRC16(key) %16384计算出key所在的槽位,根据槽位获取对应的节点连接池,再从连接池中获取一个Jedis连接。

JedisClustet是通过JedisSlotBasedConnectionHandler获取连接的,在JedisCluster的方法中,会创建一个JedisSlotBasedConnectionHandler,它有一个字段cache,类型为JedisClusterInfoCache。JedisClusterInfoCache缓存了每个主节点对应的连接池nodes,以及每个槽位对应的连接池。

private Map nodes = new HashMap();
private Map slots = new HashMap();

在JedisClusterInfoCache方法中调用discoverClusterNodesAndSlots方法,获取所有节点和槽位信息。即遍历配置的所有节点,只要有一个节点能连接上就可以获取到集群的槽位信息,获取到槽位配置信息后终止遍历。在连接上一个节点后,发送一条cluster slots命令获取槽位分配信息。

172.24.1.1:6379> cluster slots 
1) 1) (integer) 0 // 槽位开始
   2) (integer) 5460 // 槽位结束
   3) 1) "172.24.1.1"  // 节点1(主节点)的ip
      2) (integer) 6380 // 节点1的端口
      3) "c70e6b2122bd336790d7f8c7bbbc88b59ea95ac1" 
   4) 1) "172.24.1.2" // 节点2的ip
      2) (integer) 6379 // 节点2的端口
      3) "c3d94b9de931247446dea98e8afd2ce5059fa377"
.........

cluster slots返回是一个数组,即Cluster中所有小主从集群的信息,数组每个元素又是一个数组,通过遍历数组拿到每段槽位的主节点信息,并创建一个连接池,在源码中有一句注释。

// at this time, we just use master, discard slave information

“此时,我们只使用master,放弃slave信息”。所以并不会为从节点创建连接池。nodes字段缓存的是所有小集群的主节点的连接池。完全就是弃用从节点了,只有当主节点挂掉,连接池中的连接不可用时,才会刷新nodes,配置的从节点才会用到。

正如例子中的,JedisCluster为槽位0~5460的主节点创建一个连接池JedisPool,而slots则会缓存0到5460slot持有这个JedsiPool的引用。

// 0到5460,可看getAssignedSlotArray方法
for (Integer slot : targetSlots) {
     slots.put(slot, targetPool);
}
private List getAssignedSlotArray(List slotInfo) {
    List slotNums = new ArrayList();
    for (int slot = ((Long) slotInfo.get(0)).intValue(); slot <= ((Long) slotInfo.get(1))
         .intValue(); slot++) {
       slotNums.add(slot);
    } 
    return slotNums;
}
 
   

所以,只要能够获取JedisCluster对象的JedisSlotBasedConnectionHandler字段,再拿到JedisSlotBasedConnectionHandler对象的JedisClusterInfoCache字段,我们就能自己实现Pipeline。

 public class JedisClusterPipeline{
     // 采用反射获取部分字段
     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;

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

     private JedisClusterPipeline() {
     }
     /**
      * 根据jedisCluster实例生成对应的JedisClusterPipeline
      *
      * @param
      * @return
      */
     public static JedisClusterPipeline pipelined(JedisCluster jedisCluster) {
         JedisClusterPipeline pipeline = new JedisClusterPipeline();
         pipeline.setJedisCluster(jedisCluster);
         return pipeline;
     }

}

还需继承PipelineBase以获得Pipeline API的支持,实现Closeable接口close方法做连接释放操作。

public class JedisClusterPipeline extends PipelineBase 
              implements Closeable {
    ......
    // 根据顺序存储每个命令对应的Client
    private Queue clients = new LinkedList<>();
    // 缓存Pipline持有的连接
    private Map jedisMap = new HashMap<>();
}

继承PipelineBase需要实现getClient方法。能够通过key获取一个连接Jedis(Client)。首先通过CRC16计算出key所在的槽位,再根据槽位获取到一个连接。

  @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;
  }

根据槽位获取连接就是从JedisClusterInfoCache的slots字段获取槽位对应的连接池,拿到连接池就可以从连接池中获取连接了。

由于使用Pipeline时可能存在多个key落到同一个节点上,所以只需要确保一个节点只从连接池中获取一个连接就可以了,所以使用一个Map(jedisMap)来缓存当前Pipeline持有的Jedis

   private Jedis getJedis(int slot) {
        // 根据pool从缓存中获取Jedis
        JedisPool pool = clusterInfoCache.getSlotPool(slot);
        Jedis jedis = jedisMap.get(pool);
        if (null == jedis) {
            jedis = pool.getResource();
            jedisMap.put(pool, jedis);
        }
        return jedis;
    }

往Pipeline每写入一条命令,都是往Jedis(Client(Socket))的输出流写入,命令会缓存在输出流缓冲区中,缓冲区满则发送,最后需要调用flush命令将缓冲区剩余数据都传输到远端redis服务器。以输出流为例,看下RedisOutputStream输出流是怎么实现的。

public final class RedisOutputStream extends FilterOutputStream {
  protected final byte[] buf;
  protected int count;
  // out = socket.getOutputStream() 
  // @see: redis.clients.jedis.Connection
  public RedisOutputStream(final OutputStream out) {
    this(out, 8192);
  }
  public RedisOutputStream(final OutputStream out, final int size) {
    super(out);
    if (size <= 0) {
      throw new IllegalArgumentException("Buffer size <= 0");
    }
    buf = new byte[size];
  }
 }

RedisOutputStream源码可以看出,输出流的缓冲区大小默认为8192字节,接着看下往输出流里面写入命令都做了写什么。

// 将命令字节数组写入到RedisOutputStream
public void write(final byte[] b) throws IOException {
    write(b, 0, b.length);
}  

public void write(final byte[] b, final int off, final int len) throws IOException {
    // 如果字节数组大于缓冲区大小,则先将缓冲区数据写入到输出流,再直接将要写入的数据写入到输出流
    if (len >= buf.length) {
       flushBuffer();
       out.write(b, off, len);
     } else {
       // 如果字节数据大于可写入的缓冲区大小,则将缓冲区数据写入到输出流
       if (len >= buf.length - count) {
         flushBuffer();
       }
       // 再将字节数组写入到缓冲区
       System.arraycopy(b, off, buf, count, len);
       count += len;
    }
}
 // 将缓冲区内容真正写入到输出流
 private void flushBuffer() throws IOException {
    if (count > 0) {
      out.write(buf, 0, count);
      count = 0;
    }
}

所以写入完要执行的全部命令后,需要调用当前Pipeline所持有的所有Clinet的getAll()方法,将Client的输出流缓冲区命令都传输到远端redis执行,并开始从响应的输入流中读取返回结果。

public List sync() {
        List responseList = new ArrayList<>();
        try {
            // 遍历获取所有客户端结果
            for (Client client : clients) {
                // 获取所有服务端响应readProtocolWithCheckingBroken【Protocol.read(inputStream);】
                List unformatted = client.getAll();
                for (Object o : unformatted) {
                    // 从pipelinedResponses队列中弹出一个Response写入数据
                    Response data = generateResponse(o);
                    if (null != responseList) {
                        responseList.add(data.get());
                    }
                }
            }
            return responseList;
        } catch (JedisRedirectionException jre) {
            if (jre instanceof JedisMovedDataException) {
                // 如果发生重定向(槽位重定向),则重建群集的插槽缓存
                refreshCluster();
            }
            throw jre;
        } finally {
            this.close();
       }
}
 
   

generateResponse是父类PipelineBase的方法,PipelineBase继承Queable。

使用Pipeline连续的往RedisOutputStream写入命令,每写入一条命令就会返回一个Response对象,同时这个Response被放入一个Queue队列中,这步一会分析。而这个Response对象就跟Futute功能一样,你可以调用Response的get方法获取返回结果,只是此时调用get会直接抛出JedisDataException异常。

public class Response {
  protected T response = null;
  .....
  private boolean set = false;
  .......
  public void set(Object data) {
    this.data = data;
    set = true;
  }

  public T get() {
    .......
    if (!set) {
      throw new JedisDataException(
          "Please close pipeline or multi block before calling this method.");
    }
   .....
    return response;
  }
}

PipelineBase继承Queable,Queable有一个Queue类型字段pipelinedResponses,所以我们在自己实现的JedisClusterPipeline的sync方法中调用generateResponse方法,就会从队列中弹出一个Response对象并写入结果,此时调用get方法才能获取到返回结果。

public class Queable {
  private Queue> pipelinedResponses = new LinkedList>();
  .......
  // 从Response队列中头部弹出一个Response,并给Response写入结果
  protected Response generateResponse(Object data) {
    Response response = pipelinedResponses.poll();
    if (response != null) {
      response.set(data);
    }
    return response;
  }
  // 根据build new一个Response,并放入队列中
  // builder是用于解析结果的,比如T为Long,则build会将结果字符串解析为Long
  // public static final Builder LONG = new Builder() {
  //      public Long build(Object data) {
  //         return (Long)data;
  //     }
  // };
  protected  Response getResponse(Builder builder) {
    Response lr = new Response(builder);
    pipelinedResponses.add(lr);
    return lr;
  }
  .........
}

Jedis的Pipeline实现利用了Queue的先入先出特性,按命令的执行顺序响应结果。但这种先写入的命令先响应结果,在Cluster下,就会导致结果与命令不对应。因为多个Client,每个Client都执行一些命令,你无法保证获取结果顺序,在JedisCluster下实现Pipeline最好放弃响应结果。或者忽略响应结果的顺序问题。如果强需求获取命令的对应返回结果,那么此Pipeline并不能满足你。JedisCluster为什么不支持Pipeline是有道理的。

最后是close方法的实现,就是将当前pipeline持有的所有Jedis连接释放回连接池,遍历所有Jedis调用其close方法即可。同时flushCachedData方法是调用jedis.getClient().getAll()获取所有返回结果,其实就是清理Client的响应输入流,避免Jedis被复用时读取到错误的结果。

@Override
public void close() {
     super.clean();
     clients.clear();
     for (Jedis jedis : jedisMap.values()) {
           flushCachedData(jedis);
           jedis.close();
      }
      jedisMap.clear();
}

clean方法是父类Queable的方法,调用清空Response队列。

protected void clean() {
    pipelinedResponses.clear();
}

至此,一个简单的JedisClusterPipeline就完成了。

需要注意的地方

由于Cluster集群模式存在节点的动态添加或删除,且client不能实时感知,所以,建议在批量操作之前调用重新获取一遍集群信息。或是发生失败时再重新获取集群信息,毕竟会改变的概率很小,完全不用为这种小概率买单,前提是能容忍偶然的失败。

应用需要保证不论成功还是失败都会调用所有Jedis的close方法释放连接,且释放连接回连接池之前要清理Client。

在使用hmset这类批量命令时,如果field较多可以分批次写入,避免因命令执行耗时导致的阻塞。这点尤其要重视,我们项目中目前也存在这个问题。Pipeline建议命令总和不超过8192字节的缓冲区大小。

如何让JedisCluster支持Pipeline_第1张图片

公众号ID:javaskill

扫码关注最新动态

你可能感兴趣的:(如何让JedisCluster支持Pipeline)