spring集成redis哨兵slave写问题解决

redis哨兵实现读写分离和高可靠,使用过程中是不是会出现两种问题,一种是会报socket reset异常考虑分析可能是master节点故障,客户端仍然连接旧的master; 一种是报slave写错误,考虑是故障节点重启后,仍然往旧的master中写数据。

单独写了测试类复现分析这个头疼的问题。

架构

spring集成redis哨兵slave写问题解决_第1张图片
Client: spring data redis、 jedis

Redis 3台,1台master,2台slave,三台redis sentinel.

Redis Sentinel 集群看成是一个 ZooKeeper 集群,一般是由 3~5 个节点组成,
负责持续监控主从节点的健康,当主节点挂掉时,自动选择一个最优的从节点切换为主节点。

  • 客户端来连接集群时,会首先连接 sentinel,通过 sentinel 来查询主节点的地址,然后再去连接主节点进行数据交互。
  • 当主节点发生故障时,客户端会重新向 sentinel 要地址,sentinel 会将最新的主节点地址告诉客户端。如此应用程序将无需重启即可自动完成节点切换。

Redis配置

@Configuration
public class RedisConfiguration {

    @Value("${redis.sentinel.master}")
    private String master;

    @Value("${redis.sentinel.nodes}")
    private String nodes;

    @Bean
    public JedisPoolConfig jedisPoolConfig() {
        JedisPoolConfig jedisPoolConfig = new JedisPoolConfig();
        jedisPoolConfig.setMaxIdle(100);
        jedisPoolConfig.setMinIdle(8);
        jedisPoolConfig.setMaxTotal(500);
        jedisPoolConfig.setMaxWaitMillis(3000);
//        jedisPoolConfig.setMinEvictableIdleTimeMillis(minEvictableIdleTimeMillis);
//        jedisPoolConfig.setNumTestsPerEvictionRun(numTestsPerEvictionRun);
//        jedisPoolConfig.setTimeBetweenEvictionRunsMillis(timeBetweenEvictionRunsMillis);
        jedisPoolConfig.setTestOnBorrow(true);
        jedisPoolConfig.setTestWhileIdle(true);
        return jedisPoolConfig;
    }

    @Bean
    public CSBRedisSentinelConfiguration sentinelConfiguration() {
        CSBRedisSentinelConfiguration redisSentinelConfiguration = new CSBRedisSentinelConfiguration(master, nodes);
        redisSentinelConfiguration.setPassword("");

        return redisSentinelConfiguration;
    }

    @Bean
    public JedisConnectionFactory jedisConnectionFactory(CSBRedisSentinelConfiguration redisSentinelConfiguration,
                                                         JedisPoolConfig jedisPoolConfig) {
        JedisConnectionFactory jedisConnectionFactory = new JedisConnectionFactory(redisSentinelConfiguration, jedisPoolConfig);
//        jedisConnectionFactory.setPassword("");


        return jedisConnectionFactory;
    }

    @Bean
    public RedisTemplate redisTemplate(JedisConnectionFactory jedisConnectionFactory) {
        RedisTemplate redisTemplate = new RedisTemplate();
        redisTemplate.setConnectionFactory(jedisConnectionFactory);
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        redisTemplate.setValueSerializer(new StringRedisSerializer());
        redisTemplate.setHashKeySerializer(new StringRedisSerializer());
        redisTemplate.setHashValueSerializer(new StringRedisSerializer());
        //TODO 这个参数设置true的时候,会从threadLocal中获取holder,holder中的jedis是旧的,不会随sentinel检测的改变
        redisTemplate.setEnableTransactionSupport(true);

        return redisTemplate;
    }
}

测试程序

@Test
public void test(){
   System.out.println(redisTemplate.getClientList());

   while (true) {
      //往redis里写数据
      try {
         redisTemplate.opsForHash().put("Test", "test", System.currentTimeMillis() + "");
      } catch (Exception e) {
         System.out.println(e);
      }
   }
}

问题1

shutdown 当前master之后,查看连接池中当前host已经是新的master,但是connection 报错socket reset,仍然往旧的master写数据。

【操作】

  • 1) 重复往redis中写数据
  • 2) 手动shutdown master的6379端口
  • 3) 看到日志建立了新的master的连接:redis.clients.jedis.JedisSentinelPool :Created JedisPool to master at 192.168.129.226:6379
  • 4) 数据无法写入redis。

【异常】

org.springframework.data.redis.RedisConnectionFailureException:
java.net.SocketException:
Connection reset by peer: socket write error;
nested exception is redis.clients.jedis.exceptions.JedisConnectionException: java.net.SocketException:
Connection reset by peer:
socket write error

【定位】

RedisTemplate
由于配置了setEnableTransactionSupport,所以获取conn的时候进了transaction分支
spring集成redis哨兵slave写问题解决_第2张图片
RedisConnectionUtils
这里manager根据RedisConnectionFactory获取ConnectionHolder,然后从holder中获取jedis的connection
spring集成redis哨兵slave写问题解决_第3张图片
TransactionSynchronizationManager
spring集成redis哨兵slave写问题解决_第4张图片
从resources中获取threadLocal变量保证线程安全,多次操作获取到同一个连接,这里存储的jedis是旧的master(224),而JedisSentinelPool中的currentHostMaster已经是新的host(226)
spring集成redis哨兵slave写问题解决_第5张图片

【解决】

RedisTemplate.setEnableTransactionSupport设置为true
1)redisTemplate在spring的transaction中执行,threadLocal中存储的是jedisConnection的JDKAop动态代理,每次获取连接时从sentinelPool取到当前的master连接。
2)redisTemplate不在spring的transaction中执行,jedis存储在线程的threadLocal中,不会更新。同一个线程会一直使用该连接,即使该jedis对应的socket已经shutdown或者已经降级为slave,shutdown的情况下会报socket reset, 降级为slave之后再写会报slave写错误(redis配置了slave read only)。

redis不需要使用事务的时候,RedisTemplate.setEnableTransactionSupport设置为false

【思考】

redisTemplate配置的事务会下发multi exec discard指令,用到了redis自身的事务支持,保证了多个redis指令同时生效的原子性,事务执行期间系统宕机不会存在执行部分指令的情况。

spring的transaction-manager配置的事务保证了业务层面的ACID,在 Spring 语境里配置一个 RedisTransactionManager,然后再用 @Transactional 注释来声明 Redis 事务的范围(或者在xml中配置),让 Spring 管理redis连接的生命周期,自动关闭 Redis 连接。

【Redis对事务的支持】

http://www.redis.cn/topics/transactions.html

【spring-data-redis支持事务】

5.10.1. @Transactional Support
https://docs.spring.io/spring-data/redis/docs/2.1.5.RELEASE/reference/html/

By default, transaction Support is disabled and has to be explicitly enabled for each RedisTemplate in use by setting setEnableTransactionSupport(true). Doing so forces binding the current RedisConnection to the current Thread that is triggering MULTI. If the transaction finishes without errors, EXEC is called. Otherwise DISCARD is called. Once in MULTI, RedisConnection queues write operations. All readonly operations, such as KEYS, are piped to a fresh (non-thread-bound) RedisConnection.

问题2

偶尔可以看到slave写异常的日志,出现概率极低。

正常情况下,redis的server端发生切换,sentinel客户端会收到更新报文,currentMasterHost会更新为最新的master。往slave写说明客户端的master没更新。可能的原因是server端master-slave切换的报文未被client端收到,从而客户端的master仍然是旧的master,此时已经降级为slave了。

分析

jedis的sentinelPool初始化时

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

sentinel中每个节点都有一个listener
listener中做的事情就是定期查询当前的master信息,如果发生了变化,则更新jedis客户端的master信息。

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

如果redis的server端的master发生了变化,那么client端通过listener可以感知到并且进行更新。

但是,如果客户端在查询server端的master信息时,发生了网络异常或其他情况导致client端没有接收到这个订阅信息,那么client端将无法更新master信息。

复现

在server端shutdown redis master之前,可以通过关闭client端的网卡来模拟这个异常,然后再将client端的网卡打开,基本复现该问题。

发现github上已经有人提出这个问题,Jedis bug
问题版本jedis 2.9.0及以下

[2.10.0], [2.9.1]已修复
https://github.com/xetorthio/jedis/pull/1566

【修复代码】

JedisSentinelPool的MasterListener中,增加refresh代码。根据master的name获取最新的master的houst和port并init。

/*
 * Added code for active refresh
 */
List masterAddr = j.sentinelGetMasterAddrByName(masterName);  
if (masterAddr == null || masterAddr.size() != 2) {

  log.warning("Can not get master addr, master name: "+ masterName+". Sentinel: "+host+":"+port+".");
}else{
    initPool(toHostAndPort(masterAddr)); 
}

你可能感兴趣的:(java,spring,redis)