学而实习之 不亦乐乎

Jedis 使用及原理分析

2020-12-29 21:06:55

Jedis是Redis官方推荐的Java连接开发工具,是redis的Java版本的客户端实现,每个操作都发送请求给 redis-server。redis-server 端是单线程来处理 client 端请求的。为了保证效率,redis 数据都是缓存在内存中。同时 redis 会周期性的把更新的数据写入磁盘或者把修改操作写入追加的记录文件,并且在此基础上实现了 master-slave (主从)同步。Redis 支持主从同步。数据可以从主服务器向任意数量的从服务器上同步,从服务器可以是关联其他从服务器的主服务器。

一、基本使用

Jedis 的基本使用非常简单,只需要创建 Jedis 对象的时候指定 host,port, password即可。当然,Jedis对象又很多构造方法,根据具体情况进行设置使用即可。

Jedis jedis = new Jedis("localhost", 6379);  //指定Redis服务Host和port
jedis.auth("xxxx"); //如果Redis服务连接需要密码,制定密码
String value = jedis.get("key"); //访问Redis服务
jedis.close(); //使用完关闭连接

Jedis基本使用十分简单,在每次使用时,构建Jedis对象即可。在Jedis对象构建好之后,Jedis底层会打开一条Socket通道和Redis服务进行连接。所以在使用完Jedis对象之后,需要调用Jedis.close()方法把连接关闭,减少对系统资源的占用。

二、连接池使用

当应用非常频繁地创建和销毁Jedis对象,对性能的影响是很大的,因为构建 Socket 的通道是很耗时的操作。我们应该使用连接池来减少 Socket 对象的创建和销毁过程。

Jedis连接池是基于 apache-commons pool 2 实现的。在构建连接池对象的时候,需要提供池对象的配置。

JedisPoolConfig config = new JedisPoolConfig();
config.setMaxIdle(8);
config.setMaxTotal(18);
JedisPool pool = new JedisPool(config, "127.0.0.1", 6379, 2000, "password");
Jedis jedis = pool.getResource();
String value = jedis.get("key");
......
jedis.close();
pool.close();

使用 Jedis 连接池之后,在每次用完连接对象后一定要把连接关闭。Jedis 对 close() 方法进行了改造,如果是连接池中的连接对象,调用 close() 方法将会是把连接对象返回到对象池,若不是则关闭连接。可以查看如下代码

@Override
public void close() { //Jedis的close方法
    if (dataSource != null) {
        if (client.isBroken()) {
            this.dataSource.returnBrokenResource(this);
        } else {
            this.dataSource.returnResource(this);
        }
    } else {
        client.close();
    }
}


//另外从对象池中获取Jedis链接时,将会对dataSource进行设置
// JedisPool.getResource()方法
public Jedis getResource() {
    Jedis jedis = super.getResource();  
    jedis.setDataSource(this);
    return jedis;
}

三、高可用连接

连接池可以大大提高应用访问 Reids 服务的性能,减去大量的 Socket 的创建和销毁过程。但是 Redis 为了保障高可用,服务一般都是 Sentinel 部署方式。当 Redis 服务中的主服务挂掉之后,会仲裁出另外一台 Slaves 服务充当 Master。这个时候,我们的应用即使使用了 Jedis 连接池,Master 服务挂了,我们的应用将还是无法连接到新的 Master 服务。为了解决这个问题,Jedis 也提供了相应的 Sentinel 实现,能够在 Redis Sentinel 主从切换时候,通知应用,把应用连接到新的 Master 服务。

注意:Jedis版本必须2.4.2或更新版本

Set<String> sentinels = new HashSet<>();
sentinels.add("172.18.18.207:26379");
sentinels.add("172.18.18.208:26379");
JedisPoolConfig config = new JedisPoolConfig();
config.setMaxIdle(5);
config.setMaxTotal(20);
JedisSentinelPool pool = new JedisSentinelPool("mymaster", sentinels, config);
Jedis jedis = pool.getResource();
jedis.set("jedis", "jedis");
......
jedis.close();
pool.close();

Jedis Sentinel 的使用也是十分简单的,只是在 JedisPool 中添加了 Sentinel 和 MasterName 参数。Jedis Sentinel 底层基于 Redis 订阅实现 Redis 主从服务的切换通知。当 Reids 发生主从切换时,Sentinel 会发送通知主动通知 Jedis 进行连接的切换。JedisSentinelPool 在每次从连接池中获取链接对象的时候,都要对连接对象进行检测,如果此链接和 Sentinel 的 Master 服务连接参数不一致,则会关闭此连接,重新获取新的 Jedis 连接对象。

public Jedis getResource() {
    while (true) {
        Jedis jedis = super.getResource();
        jedis.setDataSource(this);


        // get a reference because it can change concurrently
        final HostAndPort master = currentHostMaster;
        final HostAndPort connection = new HostAndPort(jedis.getClient().getHost(), jedis.getClient().getPort());
        if (master.equals(connection)) {
            // connected to the correct master
            return jedis;
        } else {
            returnBrokenResource(jedis);
        }
    }
}

当然,JedisSentinelPool 对象要时时监控 RedisSentinel 的主从切换。在其内部通过 Reids 的订阅实现。具体的实现看 JedisSentinelPool 的两个方法就很清晰

private HostAndPort initSentinels(Set<String> sentinels, final String masterName) {
    HostAndPort master = null;
    boolean sentinelAvailable = false;
    log.info("Trying to find master from available Sentinels...");
    for (String sentinel : sentinels) {
        final HostAndPort hap = HostAndPort.parseString(sentinel);
        log.fine("Connecting to Sentinel " + hap);
        Jedis jedis = null;
        try {
            jedis = new Jedis(hap.getHost(), hap.getPort());
            //从RedisSentinel中获取Master信息
            List<String> masterAddr = jedis.sentinelGetMasterAddrByName(masterName);
            sentinelAvailable = true; // connected to sentinel...
            if (masterAddr == null || masterAddr.size() != 2) {
                log.warning("Can not get master addr, master name: " + masterName + ". Sentinel: " + hap + ".");
                continue;
            }
            master = toHostAndPort(masterAddr);
            log.fine("Found Redis master at " + master);
            break;
        } catch (JedisException e) {
            // it should handle JedisException there's another chance of raising JedisDataException
            log.warning("Cannot get master address from sentinel running @ " + hap + ". Reason: " + e + ". Trying next one.");
        } finally {
            if (jedis != null) {
                jedis.close();
            }
        }
    }
    if (master == null) {
        if (sentinelAvailable) {
            // can connect to sentinel, but master name seems to not monitored
            throw new JedisException("Can connect to sentinel, but " + masterName + " seems to be not monitored...");
        } else {
            throw new JedisConnectionException("All sentinels down, cannot determine where is " + masterName + " master is running...");
        }
    }
    log.info("Redis master running at " + master + ", starting Sentinel listeners...");
    //启动后台线程监控RedisSentinal的主从切换通知
    for (String sentinel : sentinels) {
        final HostAndPort hap = HostAndPort.parseString(sentinel);
        MasterListener masterListener = new MasterListener(masterName, hap.getHost(), hap.getPort());
        // whether MasterListener threads are alive or not, process can be stopped
        masterListener.setDaemon(true);
        masterListeners.add(masterListener);
        masterListener.start();
    }
    return master;
}



private void initPool(HostAndPort master) {
    if (!master.equals(currentHostMaster)) {
        currentHostMaster = master;
        if (factory == null) {
            factory = new JedisFactory(master.getHost(), master.getPort(), connectionTimeout, soTimeout, password, database, clientName, false, null, null, null);
            initPool(poolConfig, factory);
        } else {
            factory.setHostAndPort(currentHostMaster);
            // although we clear the pool, we still have to check the returned object
            // in getResource, this call only clears idle instances, not
            // borrowed instances
            internalPool.clear();
        }
        log.info("Created JedisPool to master at " + master);
    }
}

可以看到,JedisSentinel的监控时使用MasterListener这个对象来实现的。看对应源码可以发现是基于Redis的订阅实现的,其订阅频道为"+switch-master"。当MasterListener接收到switch-master消息时候,会使用新的Host和port进行initPool。这样对连接池中的连接对象清除,重新创建新的连接指向新的Master服务。

四、客户端分片

对于大应用来说,单台Redis服务器肯定满足不了应用的需求。在Redis3.0之前,是不支持集群的。如果要使用多台Reids服务器,必须采用其他方式。很多公司使用了代理方式来解决Redis集群。对于Jedis,也提供了客户端分片的模式来连接“Redis集群”。其内部是采用Key的一致性hash算法来区分key存储在哪个Redis实例上的。

JedisPoolConfig config = new JedisPoolConfig();
config.setMaxTotal(500);
config.setTestOnBorrow(true);
List<JedisShardInfo> jdsInfoList = new ArrayList<>(2);
jdsInfoList.add(new JedisShardInfo("192.168.2.128", 6379));
jdsInfoList.add(new JedisShardInfo("192.168.2.108", 6379));
pool = new ShardedJedisPool(config, jdsInfoList, Hashing.MURMUR_HASH, Sharded.DEFAULT_KEY_TAG_PATTERN);
jds.set(key, value);
......
jds.close();
pool.close();

当然,采用这种方式也存在两个问题

扩容问题:
因为使用了一致性哈稀进行分片,那么不同的key分布到不同的 Redis-Server 上,当我们需要扩容时,需要增加机器到分片列表中,这时候会使得同样的 key 算出来落到跟原来不同的机器上,这样如果要取某一个值,会出现取不到的情况。

单点故障问题:
当集群中的某一台服务挂掉之后,客户端在根据一致性 hash 无法从这台服务器取数据。
对于扩容问题,Redis 的作者提出了一种名为 Pre-Sharding 的方式。即事先部署足够多的 Redis 服务。
对于单点故障问题,我们可以使用 Redis 的 HA 高可用来实现。利用 Redis-Sentinal 来通知主从服务的切换。当然,Jedis 没有实现这块。我将会在下一篇文章进行介绍。

五、小结

对于 Jedis 的基本使用还是很简单的。要根据不用的应用场景选择对于的使用方式。
另外,Spring 也提供了 Spring-data-redis 包来整合 Jedis 的操作,另外 Spring 也单独分装了 Jedis。