大多数互联网业务,往往读多写少,数据库的读会首先成为数据库的性能瓶颈。如果希望能够线性的提升数据库的读性能,消除读写锁冲突从而提升数据库的写性能,可以使用读写分离技术。
读写分离基于数据库的主从实现,将写操作集中到主机上,而将读操作负载到从节点上。一般结构如下:
系统实现读写分离有两个要求:
- 服务器端支持主从复制,主节点和从节点保持数据一致
- 客户端能够将写操作分发到主节点,将读操作分发到从节点
本文讨论基于Jedis的读写分离的实现。
Jedis,Redis官方推荐的Java语言客户端,也是被广泛使用的客户端。Redis是使用广泛的基于键值对的分布式内存数据库。
一、Redis读写分离的支持
Redis对读写分离的支持基于其提供的主从复制功能。主从复制的一个作用就是故障恢复,即当主节点出现故障,由从节点提供服务。但是这个节点的切换需要手动执行,而人工的参与,导致主从切换的延时太长,在此期间系统不能提供服务,对于线上的系统来说是不可接受的。
为了解决这个问题,Redis提供了哨兵模式,实现了主从的自动切换。哨兵模式的结构示意图如下:
图中sentinel部分称为哨兵节点,哨兵节点监控主从节点的有效性,当主节点出现故障,哨兵节点会共同协商选取一个从节点成为主节点,在旧的主节点恢复后,将旧的主节点设置为新的主节点的从节点。
客户端连接哨兵模式组成的系统时,只需要连接哨兵节点。由哨兵节点向客户端提供有效的主机节点和从节点节点。客户端获取主从节点信息后连接主(从)节点执行业务数据的操作。
图中master节点和slave节点构成主从模式,这些节点统称为数据节点,向客户端提供数据的写入和读取服务。
而对读写分离的支持由数据节点提供。写操作在主节点(master)上,从节点(slave)实时同步数据。读操作在从节点上,实现读操作负载均衡。示意图如下:
二、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%之间。
查看JedisSentinelPool类的getResource方法:
如图中红色方框部分,只有获取到连接到主节点的Jedis对象才返回。原来获取到的只是到主节点的连接,所有指令都发送到主节点,在主从系统中只有主节点对外提供了服务,也就是说Jedis没有对读写分离提供支持。那么上面所提及的CPU占用可以理解了,主节点提供服务占用了100%的CPU,而从节点的CPU占用是因为数据同步引起的。
三、Jedis读写分离的实现
由于Jedis没有对读写分离提供支持。那么使用哨兵模式并不能解决性能瓶颈问题。修改Jedis添加读写分离支持。
1. 设计思路
在Jedis实现中,每一个Jedis类的对象是与Redis服务器的一个连接,所有的指令通过类方法发送到Redis服务器执行并获取返回的结果。那么要实现读写分离,就需要两个Jedis类对象,一个与主节点连接,发送写相关指令;一个与从节点连接,发送读相关指令。如图
图中一个客户端对象包含两个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类。
绿色部分为修改内容,为了增加灵活性使用环境变量作为参数,以便使用者自由选择使用原始Jedis客户端还是使用添加了读写分离功能的客户端。默认使用支持读写分离的客户端。
四、性能测试
前面说到读写分离的目的是为了消除读和写的性能屏障。为了验证添加了读写分离功能的客户端的性能,与原始Jedis客户端作了性能比较。
1. 测试环境
服务器端:
测试系统中部署了3个哨兵和1主2从6个节点,部署图如下:
其中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次测试的平均值绘制下图:
可以看到使用myJedis后,写的性能提升了近3倍,读的性能提升了1倍多。通过读写分离确实消除了性能瓶颈,大大提升了读和写的性能。
如果各位看官,对于实现读写分离的客户端有好的思路,欢迎交流。