作者:vivo 互联网服务器团队 - Wang Zhi
Redis 作为互联网业务首选的远程缓存工具而被大面积使用,作为访问客户端的 Jedis 同样被大面积使用。本文主要分析 Redis3.x 版本集群模式发生主从切换场景下 Jedis 的参数设置不合理引发服务雪崩的过程。
一、背景介绍
Redis 作为互联网业务首选的远程缓存工具而被被大家熟知和使用,在客户端方面涌现了 Jedis、Redisson、Lettuce 等,而 Jedis 属于其中的佼佼者。
目前笔者的项目采用 Redis 的 3.x 版本部署的集群模式(多节点且每个节点存在主从节点),使用 Jedis 作为 Redis 的访问客户端。
日前 Redis 集群中的某节点因为宿主物理机故障导致发生主从切换,在主从切换过程中触发了 Jedis 的重试机制进而引发了服务的雪崩。
本文旨在剖析 Redis 集群模式下节点发生主从切换进而引起服务雪崩的整个过程,希望能够帮助读者规避此类问题。
二、故障现场记录
【MQ-消息堆积告警】
综合各方面的指标信息,判定此次服务的雪崩主要原因应该是 Redis 主从切换导致,但是引发服务雪崩原因需要进一步的分析。
三、故障过程分析
在进行故障的过程分析之前,首先需要对目前的现象进行分析,需要回答下面几个问题:
3.1 流量陡降
说明:
通过 nginx 的日志可以看出存在大量的 connection timed out 的报错,可以归因为由于后端服务的响应时间过程导致 nginx 层和下游服务之间的读取超时。
由于大量的读取超时导致 nginx 判断为后端的服务不可用,进而触发了 no live upstreams 的报错,ng 无法转发到合适的后端服务。
通过 nginx 的日志可以将问题归因到后端服务异常导致整体请求量下跌。
3.2 耗时问题
<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 的连接重试机制有关,需要从 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); } }}
复制代码
(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。
五、总结
本文通过线上故障现场记录和分析,并最终引申到 Jedis 源码的底层逻辑分析,剖析了 Jedis 的不合理参数设置包括连接超时和最大重试次数导致服务雪崩的整个过程。
在 Redis 本身只作为缓存且后端的 MySQL 等 DB 能够承载非高峰期流量的场景下,建议合理设置 Jedis 超时参数进而减少 Redis 主从切换访问 Redis 的耗时,避免服务雪崩。
线上环境笔者目前的连接和读取超时时间设置为 100ms,最大重试次数为 2,按照现有的业务逻辑如遇 Redis 节点故障访问异常最多耗时 1s,能够有效避免服务发生雪崩。
评论