大多数互联网业务,往往读多写少,数据库的读会首先成为数据库的性能瓶颈。如果希望能够线性的提升数据库的读性能,消除读写锁冲突从而提升数据库的写性能,可以使用读写分离技术。

       读写分离基于数据库的主从实现,将写操作集中到主机上,而将读操作负载到从节点上。一般结构如下:

redis mysql 读写分离 redis如何实现读写分离_java

     系统实现读写分离有两个要求:

  1. 服务器端支持主从复制,主节点和从节点保持数据一致
  2. 客户端能够将写操作分发到主节点,将读操作分发到从节点

     本文讨论基于Jedis的读写分离的实现。

    Jedis,Redis官方推荐的Java语言客户端,也是被广泛使用的客户端。Redis是使用广泛的基于键值对的分布式内存数据库。

一、Redis读写分离的支持

       Redis对读写分离的支持基于其提供的主从复制功能。主从复制的一个作用就是故障恢复,即当主节点出现故障,由从节点提供服务。但是这个节点的切换需要手动执行,而人工的参与,导致主从切换的延时太长,在此期间系统不能提供服务,对于线上的系统来说是不可接受的。

      为了解决这个问题,Redis提供了哨兵模式,实现了主从的自动切换。哨兵模式的结构示意图如下:

redis mysql 读写分离 redis如何实现读写分离_redis mysql 读写分离_02

       图中sentinel部分称为哨兵节点,哨兵节点监控主从节点的有效性,当主节点出现故障,哨兵节点会共同协商选取一个从节点成为主节点,在旧的主节点恢复后,将旧的主节点设置为新的主节点的从节点。

       客户端连接哨兵模式组成的系统时,只需要连接哨兵节点。由哨兵节点向客户端提供有效的主机节点和从节点节点。客户端获取主从节点信息后连接主(从)节点执行业务数据的操作。

       图中master节点和slave节点构成主从模式,这些节点统称为数据节点,向客户端提供数据的写入和读取服务。

       而对读写分离的支持由数据节点提供。写操作在主节点(master)上,从节点(slave)实时同步数据。读操作在从节点上,实现读操作负载均衡。示意图如下:

redis mysql 读写分离 redis如何实现读写分离_redis mysql 读写分离_03

二、Jedis读写分离现状 

        Jedis提供了哨兵模式的支持,哨兵模式支持的Java客户端编码一般为:

Set<String> sentinels = new HashSet<>(); // 所有的哨兵节点集合
sentinels.add("172.23.0.27:26379");
sentinels.add("172.23.0.28:26380");
sentinels.add("172.23.0.29:26381");
GenericObjectPoolConfig poolConfig = new GenericObjectPoolConfig(); // 资源池
JedisSentinelPool jedisSentinelPool = new JedisSentinelPool("mymaster", sentinels, poolConfig); // redis连接池
Jedis jedis = jedisSentinelPool.getResource(); //获取连接对象,通过连接对象执行数据操作

       但是在执行压力测试时会发现在一主两从的情况下,只有主节点的CPU使用达到100%,而两台的从节点的CPU使用在10~25%之间。

redis mysql 读写分离 redis如何实现读写分离_redis mysql 读写分离_04

      查看JedisSentinelPool类的getResource方法:

 

redis mysql 读写分离 redis如何实现读写分离_java_05

        如图中红色方框部分,只有获取到连接到主节点的Jedis对象才返回。原来获取到的只是到主节点的连接,所有指令都发送到主节点,在主从系统中只有主节点对外提供了服务,也就是说Jedis没有对读写分离提供支持。那么上面所提及的CPU占用可以理解了,主节点提供服务占用了100%的CPU,而从节点的CPU占用是因为数据同步引起的。

三、Jedis读写分离的实现

      由于Jedis没有对读写分离提供支持。那么使用哨兵模式并不能解决性能瓶颈问题。修改Jedis添加读写分离支持。

1. 设计思路

       在Jedis实现中,每一个Jedis类的对象是与Redis服务器的一个连接,所有的指令通过类方法发送到Redis服务器执行并获取返回的结果。那么要实现读写分离,就需要两个Jedis类对象,一个与主节点连接,发送写相关指令;一个与从节点连接,发送读相关指令。如图

redis mysql 读写分离 redis如何实现读写分离_客户端_06

       图中一个客户端对象包含两个Jedis类对象master和slave,master连接到主节点,slave连接到其中的一个从节点。master执行写操作,如set、del、push、pop等;slave执行读操作,如get、len、scan、sort等操作。

       通过Jedis类可以事务操作(multi)和管道操作(pipeline),当执行这两类操作时Redis不允许跨机器,所以将这两类操作固定在主节点上。

2. 功能实现

2.1 SentinelJedis类

       创建SentinelJedis类继承于Jedis类,这个类就是上面图中所说的客户端对象。为尽量减少对Jedis客户端源码的修改,继承于Jedis类,这样JedisSentinelPool类的getResource方法不用修改。部分代码如下:

public class SentinelJedis extends Jedis {
  private Jedis master;
  private Jedis slave;
  public void setMaster(Jedis master) {
  this.master = master;
} 
public void setSlave(Jedis slave) {
  this.slave = slave;
}

public Transaction multi() {
  return master.multi();
}
protected void checkIsInMultiOrPipeline() {
  master.checkIsInMultiOrPipeline();
}
public Pipeline pipelined() {
  return master.pipelined();
}

public String set(byte[] key, byte[] value) {
  return master.set(key, value);
}
public byte[] get(byte[] key) {
  master.checkIsInMultiOrPipeline();
  return slave.get(key);
}

    .....
}

        其实这个类的实现就是Jedis类的委托模式。

       执行multi函数和pipeline函数时直接返回主节点的相关对象,让事务和管道操作固定在主节点上。

      对于Redis的指令操作函数,则需要去判断指令是写操作还是操作,当为写操作时直接调用master操作;当为读操作时,首先判断主节点当前的状态是否在事务或管道操作中(如果在这两操作状态中,则会抛出异常,因为在完成两个操作之前不允许单独执行其他命令),如果不是则调用slave进行操作。如:

      写:

public List<byte[]> brpop(int timeout, byte[]... keys) {
  return master.brpop(timeout, keys);
}

    读:

public ScanResult<String> scan(String cursor) {
  master.checkIsInMultiOrPipeline();
  return slave.scan(cursor);
}

    其他相关服务器的操作一律在主节点上执行。针对集群和哨兵模式的函数,则抛出异常。如:

public String clusterMeet(String ip, int port) {
  throw new UnsupportedOperationException("当前为哨兵模式,不支持集群操作");
}

public String slaveof(String host, int port) {
  throw new UnsupportedOperationException("已经以哨兵模式连接,不支持运行时修改主机身份");
}

2.2 SentinelJedisFactory类

       由于JedisSentinelPool类的getResource方法最终调用的JedisFactory类的makeObject方法创建客户端对象(Jedis对象),那么现在要将原始的Jedis类对象替换为上面的SentinelJedis类对象,就要重写此方法。创建SentinelJedisFactory类继承于JedisFactory类。代码如下:

public class SentinelJedisFactory extends JedisFactory {
  // 所有的从节点的连接工厂类列表
  private volatile List<JedisSocketFactory> slavesSocketFactory = new ArrayList<>();
  private HostAndPort master = null;

  protected SentinelJedisFactory(JedisClientConfig clientConfig) {
    super(clientConfig);
  }

  private static Pattern p = Pattern.compile("slave[0-9]+:ip=(.*),port=([0-9]+),.*",Pattern.MULTILINE);
  private AtomicInteger seed = new AtomicInteger(0); // 用于轮询从节点


  // 通过主节点的REPLICATION信息,初始化从节点的连接工厂类列表
  private void initSlaves(Jedis masterJedis) {
    slavesSocketFactory.clear();
    String repInfo = masterJedis.info("REPLICATION");
    Matcher m = p.matcher(repInfo);
    boolean find = m.find();
    while (find) {
      HostAndPort hp = new HostAndPort(m.group(1), Integer.parseInt(m.group(2)));
      JedisSocketFactory j = new DefaultJedisSocketFactory(hp, clientConfig);
      slavesSocketFactory.add(j);
      find = m.find();
    }
  }

  @Override
  public PooledObject<Jedis> makeObject() throws Exception {
    synchronized (seed) {
      // 获取主节点Jedis对象
      PooledObject<Jedis> masterObject = super.makeObject();
      Jedis masterJedis = masterObject.getObject();
      // 判断主节点有没有变化,如果变化,重新初始化从节点列表
      HostAndPort masterN = new HostAndPort(masterJedis.getClient().getHost(),    masterJedis.getClient().getPort());
      if (!masterN.equals(master)) {
        master = masterN;
        initSlaves(masterJedis);
      }
      // 轮询从节点,创建从节点Jedis对象
      seed.compareAndSet(slavesSocketFactory.size(), 0);
      Jedis slaveJedis = new Jedis(slavesSocketFactory.get(seed.getAndIncrement()), clientConfig);
      slaveJedis.connect();
      SentinelJedis sentinelJedis = new SentinelJedis();
      sentinelJedis.setMaster(masterJedis);
      sentinelJedis.setSlave(slaveJedis);
      // 返回自定义客户端对象
      return new DefaultPooledObject<>(sentinelJedis);
     }
  }
}

      首先通过父类的makeObject方法获取到连接主节点的Jedis类对象,然后通过主节点的REPLICATION信息获取所有的从节点消息,并初始`化从节点的连接工厂类列表,通过轮询方式生成连接到从节点的Jedis类对象。最终返回创建的自定义客户端对象即SentinelJedis类的对象。对于不同的SentinelJedis类对象,它的slave对象是通过轮询从节点方式获取,这样就可以实现从节点压力的负载均衡。

2.3 修改JedisSentinelPool类

      对JedisSentinelPool类只需要修改一个构造函数,将默认的客户端对象构建工厂类JedisFactory修改为SentinelJedisFactory类。

redis mysql 读写分离 redis如何实现读写分离_客户端_07

       绿色部分为修改内容,为了增加灵活性使用环境变量作为参数,以便使用者自由选择使用原始Jedis客户端还是使用添加了读写分离功能的客户端。默认使用支持读写分离的客户端。

四、性能测试

       前面说到读写分离的目的是为了消除读和写的性能屏障。为了验证添加了读写分离功能的客户端的性能,与原始Jedis客户端作了性能比较。

1. 测试环境

服务器端:

       测试系统中部署了3个哨兵和1主2从6个节点,部署图如下:

redis mysql 读写分离 redis如何实现读写分离_java_08

     其中mymaster为哨兵节点配置的主节点的名称。

 客户端:

语言:JAVA

JDK:OpenJDK 1.8.0_292

Java客户端:Jedis 3.6.3

2. 测试场景和代码

模拟的测试场景是1个客户写,10个客户读,统计在固定时间(10分钟)内完成的写操作和读操作的次数。

主要代码如下:

public RedisTest() {
  Set<String> sentinels = new HashSet<>(); // 哨兵节点集合
  sentinels.add("172.23.0.27:26379");
  sentinels.add("172.23.0.28:26380");
  sentinels.add("172.23.0.29:26381");
  GenericObjectPoolConfig poolConfig = new GenericObjectPoolConfig();
  poolConfig.setMaxTotal(20);
  // 创建连接池对象
  jedisSentinelPool = new JedisSentinelPool("mymaster", sentinels, poolConfig);
}
public void start() {
  ExecutorService es = Executors.newFixedThreadPool(11);
  es.submit(new Writer()); // 启动写操作线程
  for (int i = 0; i < 10; i++) { // 启动读操作线程
    es.submit(new Reader());
  }
  es.shutdown();
  try {
    countDownLatch.await();
  } catch (InterruptedException e) {
    e.printStackTrace();
  }
}

其中Writer类为写操作类,使用set函数写入键值对。

while ((System.currentTimeMillis() - start) < time) { // 统计时间
  jedis.set(String.valueOf(key++), value); // key为一个long,value为一个固定的字符串
  count++;  // 统计写入次数
}

Reader类为读操作类,使用get函数读取值。

while ((System.currentTimeMillis() - start) < time) { // 统计时间
  jedis.get("0"); // 读取
  count++; // 统计读取次数
}

3. 测试结果

       分别按测试场景对原始的Jedis客户端(以下简称Jedis)和添加了读写分离功能的Jedis客户端(以下简称myJedis)进行了3次测试,结果如下:


Jedis

myJedis

1

2

3

1

2

3

3070592

3068779

3025923

11497528

11498389

11493228

31747832

31667653

31256035

64962349

65183892

64819549

       其中读操作的数据是将10个线程的结果相加后的数据。

       以3次测试的平均值绘制下图:

redis mysql 读写分离 redis如何实现读写分离_客户端_09

       可以看到使用myJedis后,写的性能提升了近3倍,读的性能提升了1倍多。通过读写分离确实消除了性能瓶颈,大大提升了读和写的性能。

       如果各位看官,对于实现读写分离的客户端有好的思路,欢迎交流。