Spring Boot(十三):整合Redis哨兵,叢集模式實踐

空山新雨的技術空間發表於2020-03-26

前面的兩篇文章(Redis的持久化方案一文掌握Redis的三種叢集方案)分別介紹了Redis的持久化與叢集方案 —— 包括主從複製模式、哨兵模式、Cluster模式,其中主從複製模式由於不能自動做故障轉移,當節點出現故障時需要人為干預,不滿足生產環境的高可用需求,所以在生產環境一般使用哨兵模式或Cluster模式。那麼在Spring Boot專案中,如何訪問這兩種模式的Redis叢集,可能遇到哪些問題,是本文即將介紹的內容。

Spring Boot 2 整合Redis

spring boot中整合Redis非常簡單,在pom.xml中新增依賴

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>複製程式碼

spring boot 2的spring-boot-starter-data-redis中,預設使用的是lettuce作為redis客戶端,它與jedis的主要區別如下:

  1. Jedis是同步的,不支援非同步,Jedis客戶端例項不是執行緒安全的,需要每個執行緒一個Jedis例項,所以一般通過連線池來使用Jedis
  2. Lettuce是基於Netty框架的事件驅動的Redis客戶端,其方法呼叫是非同步的,Lettuce的API也是執行緒安全的,所以多個執行緒可以操作單個Lettuce連線來完成各種操作,同時Lettuce也支援連線池

如果不使用預設的lettuce,使用jedis的話,可以排除lettuce的依賴,手動加入jedis依賴,配置如下

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
    <exclusions>
        <exclusion>
            <groupId>io.lettuce</groupId>
            <artifactId>lettuce-core</artifactId>
        </exclusion>
    </exclusions>
</dependency>
<dependency>
    <groupId>redis.clients</groupId>
    <artifactId>jedis</artifactId>
    <version>2.9.0</version>
</dependency>複製程式碼

在配置檔案application.yml中新增配置(針對單例項)

spring:
  redis:
    host: 192.168.40.201
    port: 6379
    password: passw0rd
    database: 0 # 資料庫索引,預設0
    timeout: 5000  # 連線超時,單位ms
    jedis:  # 或lettuce, 連線池配置,springboot2.0中使用jedis或者lettuce配置連線池,預設為lettuce連線池
      pool:
        max-active: 8 # 連線池最大連線數(使用負值表示沒有限制)
        max-wait: -1 # 連線池分配連線最大阻塞等待時間(阻塞時間到,丟擲異常。使用負值表示無限期阻塞)
        max-idle: 8 # 連線池中的最大空閒連線數
        min-idle: 0 # 連線池中的最小空閒連線數複製程式碼

然後新增配置類。其中@EnableCaching註解是為了使@Cacheable、@CacheEvict、@CachePut、@Caching註解生效

@Configuration
@EnableCaching
public class RedisConfig {

    @Bean
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
        RedisTemplate<String, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(factory);

        // 使用Jackson2JsonRedisSerialize 替換預設的jdkSerializeable序列化
        Jackson2JsonRedisSerializer jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer(Object.class);
        ObjectMapper om = new ObjectMapper();
        om.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
        om.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);
        jackson2JsonRedisSerializer.setObjectMapper(om);

        StringRedisSerializer stringRedisSerializer = new StringRedisSerializer();

        // key採用String的序列化方式
        template.setKeySerializer(stringRedisSerializer);
        // hash的key也採用String的序列化方式
        template.setHashKeySerializer(stringRedisSerializer);
        // value序列化方式採用jackson
        template.setValueSerializer(jackson2JsonRedisSerializer);
        // hash的value序列化方式採用jackson
        template.setHashValueSerializer(jackson2JsonRedisSerializer);
        template.afterPropertiesSet();
        return template;
    }
}複製程式碼

上述配置類注入了自定義的RedisTemplate<String, Object>, 替換RedisAutoConfiguration中自動配置的RedisTemplate<Object, Object>類(RedisAutoConfiguration另外還自動配置了StringRedisTemplate)。

此時,我們可以通過定義一個基於RedisTemplate的工具類,或通過在Service層新增@Cacheable、@CacheEvict、@CachePut、@Caching註解來使用快取。比如定義一個RedisService類,封裝常用的Redis操作方法,

@Component
@Slf4j
public class RedisService {

    @Autowired
    private RedisTemplate<String, Object> redisTemplate;

    /**
     * 指定快取失效時間
     *
     * @param key 鍵
     * @param time 時間(秒)
     * @return
     */
    public boolean expire(String key, long time) {
        try {
            if (time > 0) {
                redisTemplate.expire(key, time, TimeUnit.SECONDS);
            }
            return true;
        } catch (Exception e) {
            log.error("exception when expire key {}. ", key, e);
            return false;
        }
    }

    /**
     * 根據key獲取過期時間
     *
     * @param key 鍵 不能為null
     * @return 時間(秒) 返回0代表為永久有效
     */
    public long getExpire(String key) {
        return redisTemplate.getExpire(key, TimeUnit.SECONDS);
    }

    /**
     * 判斷key是否存在
     *
     * @param key  鍵
     * @return true 存在 false不存在
     */
    public boolean hasKey(String key) {
        try {
            return redisTemplate.hasKey(key);
        } catch (Exception e) {
            log.error("exception when check key {}. ", key, e);
            return false;
        }
    }

   ...
}複製程式碼

出於篇幅,完整程式碼請查閱本文示例原始碼: github.com/ronwxy/spri…

或在Service層使用註解,如

@Service
@CacheConfig(cacheNames = "users")
public class UserService {

    private static Map<String, User> userMap = new HashMap<>();

    @CachePut(key = "#user.username")
    public User addUser(User user){
        user.setUid(UUID.randomUUID().toString());
        System.out.println("add user: " + user);
        userMap.put(user.getUsername(), user);
        return user;
    }

    @Caching(put = {
            @CachePut( key = "#user.username"),
            @CachePut( key = "#user.uid")
    })
    public User addUser2(User user) {
        user.setUid(UUID.randomUUID().toString());
        System.out.println("add user2: " + user);
        userMap.put(user.getUsername(), user);
        return user;
    }
    ...
}複製程式碼

Spring Boot 2 整合Redis哨兵模式

Spring Boot 2 整合Redis哨兵模式除了配置稍有差異,其它與整合單例項模式類似,配置示例為

spring:
  redis:
    password: passw0rd
    timeout: 5000
    sentinel:
      master: mymaster
      nodes: 192.168.40.201:26379,192.168.40.201:36379,192.168.40.201:46379 # 哨兵的IP:Port列表
    jedis: # 或lettuce
      pool:
        max-active: 8
        max-wait: -1
        max-idle: 8
        min-idle: 0複製程式碼

完整示例可查閱原始碼: github.com/ronwxy/spri…

上述配置只指定了哨兵節點的地址與master的名稱,但Redis客戶端最終訪問操作的是master節點,那麼Redis客戶端是如何獲取master節點的地址,並在發生故障轉移時,如何自動切換master地址的呢?我們以Jedis連線池為例,通過原始碼來揭開其內部實現的神祕面紗。

在 JedisSentinelPool 類的建構函式中,對連線池做了初始化,如下

 public JedisSentinelPool(String masterName, Set<String> sentinels,
      final GenericObjectPoolConfig poolConfig, final int connectionTimeout, final int soTimeout,
      final String password, final int database, final String clientName) {
    this.poolConfig = poolConfig;
    this.connectionTimeout = connectionTimeout;
    this.soTimeout = soTimeout;
    this.password = password;
    this.database = database;
    this.clientName = clientName;

    HostAndPort master = initSentinels(sentinels, masterName);
    initPool(master);
 }

private HostAndPort initSentinels(Set<String> sentinels, final String masterName) {

    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());

        List<String> masterAddr = jedis.sentinelGetMasterAddrByName(masterName);

        // connected to sentinel...
        sentinelAvailable = true;

        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) {
        // resolves #1036, 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();
        }
      }
    }
    //省略了非關鍵程式碼

    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;
  }
複製程式碼

initSentinels 方法中主要乾了兩件事:

  1. 遍歷哨兵節點,通過get-master-addr-by-name命令獲取master節點的地址資訊,找到了就退出迴圈。get-master-addr-by-name命令執行結果如下所示
[root@dev-server-1 master-slave]# redis-cli -p 26379
127.0.0.1:26379> sentinel get-master-addr-by-name mymaster
1) "192.168.40.201"
2) "7001"
127.0.0.1:26379>複製程式碼
  1. 對每一個哨兵節點通過一個 MasterListener 進行監聽(Redis的釋出訂閱功能),訂閱哨兵節點+switch-master頻道,當發生故障轉移時,客戶端能收到哨兵的通知,通過重新初始化連線池,完成主節點的切換。 MasterListener.run方法中監聽哨兵部分程式碼如下
 j.subscribe(new JedisPubSub() {
            @Override
            public void onMessage(String channel, String message) {
              log.fine("Sentinel " + host + ":" + port + " published: " + message + ".");

              String[] switchMasterMsg = message.split(" ");

              if (switchMasterMsg.length > 3) {

                if (masterName.equals(switchMasterMsg[0])) {
                  initPool(toHostAndPort(Arrays.asList(switchMasterMsg[3], switchMasterMsg[4])));
                } else {
                  log.fine("Ignoring message on +switch-master for master name "
                      + switchMasterMsg[0] + ", our master name is " + masterName);
                }

              } else {
                log.severe("Invalid message received on Sentinel " + host + ":" + port
                    + " on channel +switch-master: " + message);
              }
            }
          }, "+switch-master");複製程式碼

initPool 方法如下:如果發現新的master節點與當前的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);
    }
  }複製程式碼

通過以上兩步,Jedis客戶端在只知道哨兵地址的情況下便能獲得master節點的地址資訊,並且當發生故障轉移時能自動切換到新的master節點地址。

Spring Boot 2 整合Redis Cluster模式

Spring Boot 2 整合Redis Cluster模式除了配置稍有差異,其它與整合單例項模式也類似,配置示例為

spring:
  redis:
    password: passw0rd
    timeout: 5000
    database: 0
    cluster:
      nodes: 192.168.40.201:7100,192.168.40.201:7200,192.168.40.201:7300,192.168.40.201:7400,192.168.40.201:7500,192.168.40.201:7600
      max-redirects: 3  # 重定向的最大次數
    jedis:
      pool:
        max-active: 8
        max-wait: -1
        max-idle: 8
        min-idle: 0複製程式碼

完整示例可查閱原始碼: github.com/ronwxy/spri…

一文掌握Redis的三種叢集方案 中已經介紹了Cluster模式訪問的基本原理,可以通過任意節點跳轉到目標節點執行命令,上面配置中 max-redirects 控制在叢集中跳轉的最大次數。

檢視JedisClusterConnection的execute方法,

public Object execute(String command, byte[]... args) {

    Assert.notNull(command, "Command must not be null!");
    Assert.notNull(args, "Args must not be null!");

    return clusterCommandExecutor
            .executeCommandOnArbitraryNode((JedisClusterCommandCallback<Object>) client -> JedisClientUtils.execute(command,
                    EMPTY_2D_BYTE_ARRAY, args, () -> client))
            .getValue();
}複製程式碼

叢集命令的執行是通過ClusterCommandExecutor.executeCommandOnArbitraryNode來實現的,

public <T> NodeResult<T> executeCommandOnArbitraryNode(ClusterCommandCallback<?, T> cmd) {

    Assert.notNull(cmd, "ClusterCommandCallback must not be null!");
    List<RedisClusterNode> nodes = new ArrayList<>(getClusterTopology().getActiveNodes());
    return executeCommandOnSingleNode(cmd, nodes.get(new Random().nextInt(nodes.size())));
}

private <S, T> NodeResult<T> executeCommandOnSingleNode(ClusterCommandCallback<S, T> cmd, RedisClusterNode node,
        int redirectCount) {

    Assert.notNull(cmd, "ClusterCommandCallback must not be null!");
    Assert.notNull(node, "RedisClusterNode must not be null!");

    if (redirectCount > maxRedirects) {
        throw new TooManyClusterRedirectionsException(String.format(
                "Cannot follow Cluster Redirects over more than %s legs. Please consider increasing the number of redirects to follow. Current value is: %s.",
                redirectCount, maxRedirects));
    }

    RedisClusterNode nodeToUse = lookupNode(node);

    S client = this.resourceProvider.getResourceForSpecificNode(nodeToUse);
    Assert.notNull(client, "Could not acquire resource for node. Is your cluster info up to date?");

    try {
        return new NodeResult<>(node, cmd.doInCluster(client));
    } catch (RuntimeException ex) {

        RuntimeException translatedException = convertToDataAccessException(ex);
        if (translatedException instanceof ClusterRedirectException) {
            ClusterRedirectException cre = (ClusterRedirectException) translatedException;
            return executeCommandOnSingleNode(cmd,
                    topologyProvider.getTopology().lookup(cre.getTargetHost(), cre.getTargetPort()), redirectCount + 1);
        } else {
            throw translatedException != null ? translatedException : ex;
        }
    } finally {
        this.resourceProvider.returnResourceForSpecificNode(nodeToUse, client);
    }
}複製程式碼

上述程式碼邏輯如下

  1. 從叢集節點列表中隨機選擇一個節點
  2. 從該節點獲取一個客戶端連線(如果配置了連線池,從連線池中獲取),執行命令
  3. 如果丟擲ClusterRedirectException異常,則跳轉到返回的目標節點上執行
  4. 如果跳轉次數大於配置的值 max-redirects, 則丟擲TooManyClusterRedirectionsException異常

可能遇到的問題

  1. Redis連線超時

檢查服務是否正常啟動(比如 ps -ef|grep redis檢視程式,netstat -ano|grep 6379檢視埠是否起來,以及日誌檔案),如果正常啟動,則檢視Redis伺服器是否開啟防火牆,關閉防火牆或配置通行埠。

  1. Cluster模式下,報連線到127.0.0.1被拒絕錯誤,如 Connection refused: no further information: /127.0.0.1:7600

這是因為在redis.conf中配置 bind 0.0.0.0bind 127.0.0.1導致,需要改為具體在外部可訪問的IP,如 bind 192.168.40.201。如果之前已經起了叢集,併產生了資料,則修改redis.conf檔案後,還需要修改cluster-config-file檔案,將127.0.0.1替換為bind 的具體IP,然後重啟。

  1. master掛了,slave升級成為master,重啟master,不能正常同步新的master資料

如果設定了密碼,需要在master, slave的配置檔案中都配置masterauth password

相關閱讀:

  1. Redis的持久化方案
  2. 一文掌握Redis的三種叢集方案

作者:空山新雨,一枚仍在學習路上的IT老兵 近期作者寫了幾十篇技術部落格,內容包括Java、Spring Boot、Spring Cloud、Docker,技術管理心得等
歡迎關注作者微信公眾號:空山新雨的技術空間,一起學習成長

微信公眾號

相關文章