一次 Jedis 参数异常引发服务雪崩

Redis 作为互联网业务首选的远程缓存工具而被大面积使用,作为访问客户端的 Jedis 同样被大面积使用。本文主要分析 Redis3.x 版本集群模式发生主从切换场景下 Jedis 的参数设置不合理引发服务雪崩的过程。

一、背景介绍

Redis 作为互联网业务首选的远程缓存工具而被被大家熟知和使用,在客户端方面涌现了Jedis、Redisson、Lettuce等,而Jedis属于其中的佼佼者。

目前笔者的项目采用 Redis 的 3.x 版本部署的集群模式(多节点且每个节点存在主从节点),使用 Jedis 作为 Redis 的访问客户端。

日前 Redis 集群中的某节点因为宿主物理机故障导致发生主从切换,在主从切换过程中触发了 Jedis 的重试机制进而引发了服务的雪崩。

本文旨在剖析Redis集群模式下节点发生主从切换进而引起服务雪崩的整个过程,希望能够帮助读者规避此类问题。

二、故障现场记录

  • 消息堆积告警

【MQ-消息堆积告警】
告警时间:2022-11-29 23:50:21
检测规则: 消息堆积阈值:-》异常( > 100000)
告警服务:xxx-anti-addiction
告警集群:xx公共
告警对象:xxx-login-event-exchange/xxx-login-event-queue
异常对象(当前值): 159412

说明:
2022-11-29 23:50:21收到一条RMQ消息堆积的告警,正常情况下服务是不会有这类异常告警,出于警觉性开始进入系统排查过程。
排查的思路基本围绕系统相关的指标:系统的请求量,响应时间,下游服务的响应时间,线程数等指标。

图片

说明:
排查系统监控之后发现在故障发生时段服务整体的请求量有大幅下跌,响应的接口的平均耗时接近1分钟。
服务整体出于雪崩状态,请求耗时暴涨导致服务不可用,进而导致请求量下跌。

图片

说明:

  • 排查系统对应的线程数,发现在故障期间处于wait的线程数大量增加。

图片

说明:

  • 事后运维同学反馈在故障时间点Redis集群发生了主从切换,整体时间和故障时间较吻合。

综合各方面的指标信息,判定此次服务的雪崩主要原因应该是 Redis 主从切换导致,但是引发服务雪崩原因需要进一步的分析。

三、故障过程分析

在进行故障的过程分析之前,首先需要对目前的现象进行分析,需要回答下面几个问题:

  • 接口响应耗时增加为何会引起请求量的陡增?

  • Redis主从切换期间大部分的耗时为啥是2s?

  • 接口的平均响应时间为啥接近60s?

3.1 流量陡降

图片

说明:

  • 通过nginx的日志可以看出存在大量的connection timed out的报错,可以归因为由于后端服务的响应时间过程导致nginx层和下游服务之间的读取超时。

  • 由于大量的读取超时导致nginx判断为后端的服务不可用,进而触发了no live upstreams的报错,ng无法转发到合适的后端服务。

  • 通过nginx的日志可以将问题归因到后端服务异常导致整体请求量下跌。

3.2 耗时问题

图片

说明:

  • 通过报错日志定位到Jedis在获取连接的过程中抛出了connect timed out的异常。

  • 通过定位Jedis的源码发现默认的设置连接超时时间 DEFAULT_TIMEOUT = 2000。

图片

 
<redis-cluster name="redisCluster" timeout="3000" maxRedirections="6"> // 最大重试次数为6    <properties>        <property name="maxTotal" value="20" />        <property name="maxIdle" value="20" />        <property name="minIdle" value="2" />    </properties></redis-cluster>

说明:

  • 通过报错日志定位Jedis执行了6次重试,每次重试耗时参考设置连接超时默认时长2s,单次请求约耗时12s。

  • 排查部分对外接口,发现一次请求内部总共访问的Redis次数有5次,那么整体的响应时间会达到1m=60s。

  • 结合报错日志和监控指标,判定服务的雪崩和Jedis的连接重试机制有关,需要从Jedis的源码进一步进行分析。

四、Jedis 执行流程

4.1 流程解析

图片

说明:

  • Jedis处理Redis的命令请求如上图所示,整体在初始化连接的基础上根据计算的slot槽位获取连接后发送命令进行执行。

  • 在获取连接失败或命令发送失败的场景下触发异常重试,重新执行一次命令。

  • 异常重试流程中省略了重新获取Redis集群分布的逻辑,避免复杂化整体流程。

4.2 源码解析

(1)整体流程
 
public class JedisCluster extends BinaryJedisCluster implements JedisCommands,    MultiKeyJedisClusterCommands, JedisClusterScriptingCommands {
  @Override  public String set(final String key, final String value, final String nxxx, final String expx,      final long time) {    return new JedisClusterCommand<String>(connectionHandler, maxAttempts) {      @Override      public String execute(Jedis connection) {        // 真正发送命令的逻辑        return connection.set(key, value, nxxx, expx, time);      }    }.run(key); // 通过run触发命令的执行  }}

public abstract class JedisClusterCommand<T> {
  public abstract T execute(Jedis connection);
  public T run(String key) {    // 执行带有重试机制的方法    return runWithRetries(SafeEncoder.encode(key), this.maxAttempts, false, false);  }}

public abstract class JedisClusterCommand<T> {
  private T runWithRetries(byte[] key, int attempts, boolean tryRandomNode, boolean asking) {
    Jedis connection = null;    try {
      if (asking) {        // 省略相关的代码逻辑      } else {        if (tryRandomNode) {          connection = connectionHandler.getConnection();        } else {          // 1、尝试获取连接          connection = connectionHandler.getConnectionFromSlot(JedisClusterCRC16.getSlot(key));        }      }      // 2、执行JedisClusterCommand封装的execute命令      return execute(connection);
    } catch (JedisNoReachableClusterNodeException jnrcne) {      throw jnrcne;    } catch (JedisConnectionException jce) {      // 省略代码    } finally {      releaseConnection(connection);    }  }}

说明:

JedisCluster执行set命令为例,封装成JedisClusterCommand对象通过run触发runWithRetries进而执行set命令的execute方法。

runWithRetries方法封装了具体的重试逻辑,内部通过connectionHandler.getConnectionFromSlot

获取对应的Redis节点的连接。

(2)计算槽位
​​​​​​​​​​​​​​
public final class JedisClusterCRC16 {
  public static int getSlot(byte[] key) {    int s = -1;    int e = -1;    boolean sFound = false;    for (int i = 0; i < key.length; i++) {      if (key[i] == '{' && !sFound) {        s = i;        sFound = true;      }      if (key[i] == '}' && sFound) {        e = i;        break;      }    }    if (s > -1 && e > -1 && e != s + 1) {      return getCRC16(key, s + 1, e) & (16384 - 1);    }    return getCRC16(key) & (16384 - 1);  }}
(3)连接获取
 
public class JedisSlotBasedConnectionHandler extends JedisClusterConnectionHandler {
  @Override  public Jedis getConnectionFromSlot(int slot) {    JedisPool connectionPool = cache.getSlotPool(slot);    if (connectionPool != null) {      // 尝试获取连接      return connectionPool.getResource();    } else {      renewSlotCache();      connectionPool = cache.getSlotPool(slot);      if (connectionPool != null) {        return connectionPool.getResource();      } else {        return getConnection();      }    }  }}
class JedisFactory implements PooledObjectFactory<Jedis> {
  @Override  public PooledObject<Jedis> makeObject() throws Exception {    // 1、创建Jedis连接    final HostAndPort hostAndPort = this.hostAndPort.get();    final Jedis jedis = new Jedis(hostAndPort.getHost(), hostAndPort.getPort(), connectionTimeout,        soTimeout, ssl, sslSocketFactory, sslParameters, hostnameVerifier);
    try {       // 2、尝试进行连接      jedis.connect();    } catch (JedisException je) {      jedis.close();      throw je;    }
    return new DefaultPooledObject<Jedis>(jedis);
  }}
public class Connection implements Closeable {
  public void connect() {    if (!isConnected()) {      try {        socket = new Socket();        socket.setReuseAddress(true);        socket.setKeepAlive(true); // Will monitor the TCP connection is        socket.setTcpNoDelay(true); // Socket buffer Whetherclosed, to        socket.setSoLinger(true, 0); // Control calls close () method,
        // 1、设置连接超时时间 DEFAULT_TIMEOUT = 2000;        socket.connect(new InetSocketAddress(host, port), connectionTimeout);        // 2、设置读取超时时间        socket.setSoTimeout(soTimeout);
        outputStream = new RedisOutputStream(socket.getOutputStream());        inputStream = new RedisInputStream(socket.getInputStream());      } catch (IOException ex) {        broken = true;        throw new JedisConnectionException(ex);      }    }  }}

说明:
Jedis通过connectionPool维护和Redis的连接信息,在可复用的连接不够的场景下会触发连接的建立和获取。

创建连接对象通过封装成Jedis对象并通过connect进行连接,在Connection的connect的过程中设置连接超时connectionTimeout和读取超时soTimeout。

建立连接过程中如果异常会抛出JedisConnectionException异常,注意这个异常会在后续的分析中多次出现。

(4)发送命令
public class Connection implements Closeable {
  protected Connection sendCommand(final Command cmd, final byte[]... args) {    try {      // 1、必要时尝试连接      connect();      // 2、发送命令      Protocol.sendCommand(outputStream, cmd, args);      pipelinedCommands++;      return this;    } catch (JedisConnectionException ex) {      broken = true;      throw ex;    }  }
  private static void sendCommand(final RedisOutputStream os, final byte[] command,      final byte[]... args) {    try {      // 按照redis的命令格式发送数据      os.write(ASTERISK_BYTE);      os.writeIntCrLf(args.length + 1);      os.write(DOLLAR_BYTE);      os.writeIntCrLf(command.length);      os.write(command);      os.writeCrLf();
      for (final byte[] arg : args) {        os.write(DOLLAR_BYTE);        os.writeIntCrLf(arg.length);        os.write(arg);        os.writeCrLf();      }    } catch (IOException e) {      throw new JedisConnectionException(e);    }  }}

说明:

  • Jedis通过sendCommand向Redis发送Redis格式的命令。

  • 发送过程中会执行connect连接动作,逻辑和获取连接时的connect过程一致。

  • 发送命令异常会抛出JedisConnectionException 的异常信息。

(5)重试机制
public abstract class JedisClusterCommand<T> {
  private T runWithRetries(byte[] key, int attempts, boolean tryRandomNode, boolean asking) {
    Jedis connection = null;    try {
      if (asking) {      } else {        if (tryRandomNode) {          connection = connectionHandler.getConnection();        } else {          // 1、尝试获取连接          connection = connectionHandler.getConnectionFromSlot(JedisClusterCRC16.getSlot(key));        }      }      // 2、通过连接执行命令      return execute(connection);
    } catch (JedisNoReachableClusterNodeException jnrcne) {      throw jnrcne;    } catch (JedisConnectionException jce) {      releaseConnection(connection);      connection = null;      // 4、重试到最后一次抛出异常      if (attempts <= 1) {        this.connectionHandler.renewSlotCache();
        throw jce;      }      // 3、进行第一轮重试      return runWithRetries(key, attempts - 1, tryRandomNode, asking);    } finally {      releaseConnection(connection);    }  }}

说明:

  • Jedis执行Redis的命令时按照先获取connection后通过connection执行命令的顺序。

  • 在获取connection和通过connection执行命令的过程中如果发生异常会进行重试且在达到最大重试次数后抛出异常。

  • attempts=5为例,如果在获取connection过程中发生异常,那么最多重试5次后抛出异常。

综合上述的分析,在使用Jedis的过程中需要合理设置参数包括connectionTimeout & soTimeout & maxAttempts。

  • maxAttempts:出现异常最大重试次数。

  • connectionTimeout:表示连接超时时间。

  • soTimeout:读取数据超时时间。

五、总结

本文通过线上故障现场记录和分析,并最终引申到Jedis源码的底层逻辑分析,剖析了Jedis的不合理参数设置包括连接超时和最大重试次数导致服务雪崩的整个过程。

在Redis本身只作为缓存且后端的MySQL等DB能够承载非高峰期流量的场景下,建议合理设置Jedis超时参数进而减少Redis主从切换访问Redis的耗时,避免服务雪崩。

线上环境笔者目前的连接和读取超时时间设置为100ms,最大重试次数为2,按照现有的业务逻辑如遇Redis节点故障访问异常最多耗时1s,能够有效避免服务发生雪崩。

本文转自公众号 vivo互联网技术

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
Jedis使用总结 前段时间细节的了解了Jedis的使用,Jedis是redis的java版本的客户端实现。 本文做个总结,主要分享如下内容: 【pipeline】【分布式的id生成器】【分布式锁【watch】【multi】】【redis分布式】 好了,一个一个来。 一、 Pipeline 官方的说明是:starts a pipeline,which is a very efficient way to send lots of command and read all the responses when you finish sending them。简单点说pipeline适用于批处理。当有大量的操作需要一次性执行的时候,可以用管道。 示例: Jedis jedis = new Jedis(String, int); Pipeline p = jedis.pipelined(); p.set(key,value);//每个操作都发送请求给redis-server p.get(key,value); p.sync();//这段代码获取所有的response 这里我进行了20w次连续操作(10w读,10w写),不用pipeline耗时:187242ms,用pipeline耗时:1188ms,可见使用管道后的性能上了一个台阶。看了代码了解到,管道通过一次性写入请求,然后一次性读取响应。也就是说jedis是:request response,request response,...;pipeline则是:request request... response response的方式。这样无需每次请求都等待server端的响应。 二、 跨jvm的id生成器 谈到这个话题,首先要知道redis-server端是单线程来处理client端的请求的。 这样来实现一个id生成器就非常简单了,只要简单的调用jdeis.incr(key);就搞定了。 你或许会问,incr是原子操作吗,能保证不会出现并发问题吗,不是说了吗,server端是单线程处理请求的。 三、 【跨jvm的锁实现【watch】【multi】】 首先说下这个问题的使用场景,有些时候我们业务逻辑是在不同的jvm进程甚至是不同的物理机上的jvm处理的。这样如何来实现不同jvm上的同步问题呢,其实我们可以基于redis来实现一个锁。 具体事务和监听请参考文章:redis学习笔记之事务 暂时找到三种实现方式: 1. 通过jedis.setnx(key,value)实现 import java.util.Random; import org.apache.commons.pool.impl.GenericObjectPool.Config; import redis.clients.jedis.Jedis; import redis.clients.jedis.JedisPool; import redis.clients.jedis.Transaction; /** * @author Teaey */ public class RedisLock { //加锁标志 public static final String LOCKED = "TRUE"; public static final long ONE_MILLI_NANOS = 1000000L; //默认超时时间(毫秒) public static final long DEFAULT_TIME_OUT = 3000; public static JedisPool pool; public static final Random r = new Random(); //锁的超时时间(秒),过期删除 public static final int EXPIRE = 5 * 60; static { pool = new JedisPool(new Config(), "host", 6379); } private Jedis jedis; private String key; //锁状态标志 private boolean locked = false; public RedisLock(String key) { this.key = key; this.jedis = pool.getResource(); } public boolean lock(long timeout) { long nano = System.nanoTime(); timeout *= ONE_MILLI_NANOS; try { while ((System.nanoTime() - nano) < timeout) { if (jedis.setnx(key, LOCKED) == 1) { jedis.expire(key, EXPIRE); locked = true; return locked; } // 短暂休眠,nano避免出现活锁 Thread.sleep(3, r.nextInt(500)); } } catch (Exception e) { } return false; } public boolean lock() { return lock(DEFAULT_TIME_OUT); } // 无论是否加锁成功,必须调用 public void unlock() { try { if (locked) jedis.del(key); } finally { pool.returnResource(jedis); } } } 2. 通过事务(multi)实现 由于采纳第一张方法,第二种跟第三种实现只贴了关键代码,望谅解。^_^ public boolean lock_2(long timeout) { long nano = System.nanoTime(); timeout *= ONE_MILLI_NANOS; try { while ((System.nanoTime() - nano) < timeout) { Transaction t = jedis.multi(); // 开启事务,当server端收到multi指令 // 会将该client的命令放入一个队列,然后依次执行,知道收到exec指令 t.getSet(key, LOCKED); t.expire(key, EXPIRE); String ret = (String) t.exec().get(0); if (ret == null || ret.equals("UNLOCK")) { return true; } // 短暂休眠,nano避免出现活锁 Thread.sleep(3, r.nextInt(500)); } } catch (Exception e) { } return false; } 3. 通过事务+监听实现 public boolean lock_3(long timeout) { long nano = System.nanoTime(); timeout *= ONE_MILLI_NANOS; try { while ((System.nanoTime() - nano) < timeout) { jedis.watch(key); // 开启watch之后,如果key的值被修改,则事务失败,exec方法返回null String value = jedis.get(key); if (value == null || value.equals("UNLOCK")) { Transaction t = jedis.multi(); t.setex(key, EXPIRE, LOCKED); if (t.exec() != null) { return true; } } jedis.unwatch(); // 短暂休眠,nano避免出现活锁 Thread.sleep(3, r.nextInt(500)); } } catch (Exception e) { } return false; } 最终采用第一种实现,因为加锁只需发送一个请求,效率最高。 四、 【redis分布式】 最后一个话题,jedis的分布式。在jedis的源码里发现了两种hash算法(MD5,MURMUR Hash(默认)),也可以自己实现redis.clients.util.Hashing接口扩展。 List<JedisShardInfo> hosts = new ArrayList<JedisShardInfo>(); //server1 JedisShardInfo host1 = new JedisShardInfo("", 6380, 2000); //server2 JedisShardInfo host2 = new JedisShardInfo("", 6381, 2000); hosts.add(host1); hosts.add(host2); ShardedJedis jedis = new ShardedJedis(hosts); jedis.set("key", "");
以下是一个简单的 Jedis 连接池的实现,使用 Scala 语言编写: ```scala import redis.clients.jedis.JedisPool import redis.clients.jedis.JedisPoolConfig object JedisPoolManager { private val config = new JedisPoolConfig() config.setMaxTotal(10) // 最大连接数 config.setMaxIdle(5) // 最大空闲连接数 config.setMinIdle(1) // 最小空闲连接数 config.setTestOnBorrow(true) // 取出连接时测试是否可用 config.setTestOnReturn(true) // 归还连接时测试是否可用 private val pool = new JedisPool(config, "localhost", 6379) def getResource = pool.getResource def returnResource(jedis: Jedis) = { if (jedis != null) { jedis.close() } } } ``` 解释: 1. 首先创建一个 `JedisPoolConfig` 对象,用于配置连接池的参数,如最大连接数、最大空闲连接数、最小空闲连接数等。 2. 然后创建一个 `JedisPool` 对象,传入上面创建的 `JedisPoolConfig` 对象和 Redis 服务器的地址和端口号。 3. 定义 `getResource` 方法,用于从连接池中获取一个 Jedis 实例。 4. 定义 `returnResource` 方法,用于归还 Jedis 实例到连接池中。 5. 在 `returnResource` 方法中,使用 `jedis.close()` 方法关闭 Jedis 实例,将其归还到连接池中。注意,如果 Jedis 实例为 null,则不执行任何操作。 使用示例: ```scala import redis.clients.jedis.Jedis object Main extends App { val jedis: Jedis = JedisPoolManager.getResource jedis.set("foo", "bar") val result = jedis.get("foo") println(result) JedisPoolManager.returnResource(jedis) } ``` 在上面的示例中,首先通过 `JedisPoolManager.getResource` 方法获取一个 Jedis 实例,然后可以使用该实例进行 Redis 操作。完成操作后,通过 `JedisPoolManager.returnResource` 方法将 Jedis 实例归还到连接池中。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值