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

  • 2020 年 3 月 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;          }      }       ...  }  

出於篇幅,完整程式碼請查閱本文示例源碼: https://github.com/ronwxy/springboot-demos/tree/master/springboot-redis-sentinel

或在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  

完整示例可查閱源碼: https://github.com/ronwxy/springboot-demos/tree/master/springboot-redis-sentinel

上述配置只指定了哨兵節點的地址與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  

完整示例可查閱源碼: https://github.com/ronwxy/springboot-demos/tree/master/springboot-redis-cluster

一文掌握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,技術管理心得等
歡迎關注作者微信公眾號:空山新雨的技術空間,一起學習成長

微信公眾號