《Redis开发与运维》读书笔记(四)客户端

第四章 客户端

Redis用单线程来处理多个客户端的访问。

4.1 客户端通信协议

Redis被几乎所有主流的编程语言支持,除了流行以外,原因还有:

  1. 客户端与服务端之间的通信协议是在TCP协议之上构建的。

  2. Redis定制了 RESP(REdis Serialization Protocol,Redis序列号协议来实现客户端与服务端的正常交互,这种协议简单高效,既能被机器解析,又容易被人类识别。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    # 客户端发送一条set hello world命令给服务端(每行用\r\n分隔)
    *3
    $3
    SET
    $5
    hello
    $5
    world
    # 收到回复
    +OK

4.1.1 发送命令格式

RESP命令格式如下,CRLF代表 \r\n

1
2
3
4
5
6
*<参数数量> CRLF
$<参数1的字节数量> CRLF
<参数1> CRLF
...
$<参数N的字节数量> CRLF
<参数N> CRLF

实际传输时会用 \r\n 代替换行,所以是条连续的字符串。

4.1.2 返回结果格式

Redis返回结果分为五种类型:

  • 状态回复:在 RESP 中第一个字节为 +
  • 错误回复:在 RESP 中第一个字节为 -
  • 整数回复:在 RESP 中第一个字节为 :
  • 字符串回复:在 RESP 中第一个字节为 $
  • 多条字符串回复:在 RESP 中第一个字节为 *

redis-cli 是按照 RESP 进行结果解析的,所以只能看到最终的执行结果,而看不到中间结果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# set返回结果为OK,看不到加号
127.0.0.1:6379> set hello world
OK
# 可以通过nc命令、telnet命令、甚至用socket程序进行模拟
# 首先用nc连接redis
nc 127.0.0.1 6379
set hello world
+OK
# 错误回复,任意输入一条乱码指令
sethx
-ERR unknown command 'sethx'
# 整数回复
incr counter
:1
# 字符串回复"$5\r\nworld\r\n"
get hello
$5
world
# 多条字符串回复
mset java jedis python redis-py
+OK
mget java python
*2
$5
jedis
$8
redis-py

不管是字符串回复还是多条字符串回复,当有 nil 值时,会返回 $-1


4.2 Java客户端Jedis

4.2.1 获取Jedis

在Java中获取第三方开发包通常有两种方式:

  • 下载JAR包并加入到项目中。
  • 使用集成构建工具,如Maven、Gradle等。
1
2
3
4
5
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
<version>2.8.2</version>
</dependency>

选择开发包的版本通常有两种方案:

  • 选择稳定的版本。
  • 选择更新活跃的版本。

4.2.2 Jedis的基本使用方法

一个简单的使用示例:

1
2
3
4
5
6
// 1. 生成一个Jedis对象,负责和指定的Redis实例进行通信
Jedis jedis = new Jedis("127.0.0.1", 6379);
// 2. jedis执行set操作
jedis.set("hello", "world");
// 3. jedis执行get操作
String value = jedis.get("hello");

初始化Jedis时的几个参数:

  • host:Redis实例所在机器的IP。
  • port:Redis实例的端口。
  • connectionTimeout:客户端连接超时。
  • soTimeout:客户端读写超时。

一个示例说明Jedis对五种Redis数据结构的操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
//1.string
jedis.set("hello", "world");
jedis.get("hello");
jedis.incr("counter");

//2.hash
jedis.hset("myhash", "f1", "v1");
jedis.hset("myhash", "f2", "v2");
jedis.hgetAll("myhash"); //[f1=v1, f2=v2]

//3.list
jedis.rpush("mylist", "1");
jedis.rpush("mylist", "2");
jedis.rpush("mylist", "3");
jedis.lrange("mylist", 0, -1); //[1, 2, 3]

//4.set
jedis.sadd("myset", "a");
jedis.sadd("myset", "b");
jedis.sadd("myset", "a");
jedis.smembers("myset"); //[b, a]

//5.zset
jedis.zadd("myzset", 99, "tom");
jedis.zadd("myzset", 66, "peter");
jedis.zadd("myzset", 33, "james");
jedis.zrangeWithScores("myzset", 0, -1);
// [[["james"], 33.0], ["peter"], 66.0, ["tom"], 99.0]

Jedis并没有提供序列号工具,所以需要开发者自己引入如XML、Json、谷歌的Protobuf、Facebook的Thrift等。此处Protobuf使用案例略去。

4.2.3 Jedis连接池的使用方法

前面介绍的是Jedis的直连方式(指Jedis每次都会新建TCP连接,使用后再断开),这对于频繁访问Redis的应用场景并不合适。

因此,生产环境中一般使用连接池的方式来对Jedis连接进行管理

连接池的方式可以预先初始化好Jedis连接,每次只需从Jedis连接池借用即可,借用和归还操作都在本地进行,只有少量的并发同步开销,远远小于新建TCP连接的开销。且连接池可以有效的保护和控制资源的使用。

Jedis提供了 JedisPool 这个类来对应连接池,同时使用了 Apache 的通用对象池工具 common-pool 作为资源的管理工具

(1)Jedis连接池

1
2
3
4
// common-pool连接池默认配置
GenericObjectPoolConfig poolConfig = new GenericObjectPoolConfig();
// 初始化Jedis连接池
JedisPool jedisPool = new JedisPool(poolConfig, "127.0.0.1", 6379);

(2)获取Jedis对象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Jedis jedis = null;
try {
// 1.从连接池获取jedis对象
jedis = jedisPool.getResource();
// 2.执行操作
jedis.get("hello");
} catch (Exception ex) {
logger.error(ex.getMessage(), ex);
} finally {
if(jedis != null) {
// 如果使用JedisPool,close操作不是关闭连接,而是归还连接池
jedis.close();
}
}

jedis.close() 实现方式如下:

1
2
3
4
5
6
7
8
9
10
11
public void close() {
if(dataSource != null){ // 使用Jedis连接池
if(client.isBroken()){ //只会归还连接给连接池
this.dataSource.returnBrokenResouce(this);
} else {
this.dataSource.returnResouce(this);
}
} else { //直连
client.close();//关闭连接
}
}

GenericObjectPoolConfig 可配置参数:

1
2
3
4
5
6
7
8
9
10
11
GenericObjectPoolConfig poolConfig = new GenericObjectPoolConfig();
// 设置最大连接数为默认的5倍
poolConfig.setMaxTotal(GenericObjectPoolConfig.DEFAULT_MAX_TOTAL * 5);
// 设置最大空闲连接数为默认的3倍
poolConfig.setMaxIdle(GenericObjectPoolConfig.DEFAULT_MAX_IDLE * 3);
// 设置最小空闲连接数为默认的2倍
poolConfig.setMinIdle(GenericObjectPoolConfig.DEFAULT_MIN_IDLE * 2);
// 设置开启jmx功能
poolConfig.setJmxEnabled(true);
// 设置连接池没有连接后客户端的最大等待时间
poolConfig.setMaxWaitMillis(3000);

除了以上常用属性外,还有如下图:

4.2.4 Redis中的Pipeline的使用方法

《Redis开发与运维》读书笔记(三)功能中介绍了Pipeline的基本原理,我们知道Redis提供了 mgetmset 方法,但没提供 mdel 方法,可以借助Pipeline模拟批量删除。

1
2
3
4
5
6
7
8
9
10
11
public void mdel(List<String> keys) {
Jedis jedis = new Jedis("127.0.0.1");
// 1.生成Pipeline对象
Pipeline pipeline = jedis.pipelined();
// 2.Pipeline封装命令,此时并未真正执行
for(String key : keys){
pipeline.del(key);
}
// 3.执行命令
pipeline.sync();
}

除了 pipeline.sync() 还可以使用 pipeline.syncAndReturnAll() 将pipeline命令进行返回。

1
2
3
4
5
6
7
8
Jedis jedis = new Jedis("127.0.0.1");
Pipeline pipeline = jedis.pipelined();
pipeline.set("hello", "world");
pipeline.incr("counter");
List<Object> resultList = pipeline.syncAndReturnAll();
for(Object object : resultList){
System.out.println(object);
}

输入以下结果:

1
2
OK
1

4.2.4 Jedis的Lua脚本

Jedis提供了三个重要的函数实现Lua脚本的执行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
* 执行脚本
* @script Lua脚本内容
* @keyCount 键的个数
* @params 相关参数KEYS和ARGV
*/
Object eval(String script, int keyCount, String...params);
/**
* 执行脚本的SHA1校验和
* @sha1 脚本的SHA1
* @keyCount 键的个数
* @params 相关参数KEYS和ARGV
*/
Object evalsha(String sha1, int keyCount, String...params);
/**
* 加载脚本到Redis
* @script 脚本内容
*/
String scriptLoad(String script);

使用示例如下:

1
2
3
# Lua脚本:return redis.call('get', KEYS[1])
127.0.0.1:6379> eval "return redis.call('get', KEYS[1])" 1 hello
world

在Jedis中执行:

1
2
3
4
String key = "hello";
String script = "return redis.call('get', KEYS[1])";
Object result = jedis.eval(script, 1, key);
System.out.println(object);// world

scriptLoad() 要结合 evalsha() 一起使用。

1
2
3
4
String scriptSha = jedis.scriptLoad(script);
String key = "hello";
Object result = jedis.evalsha(script, 1, key);
System.out.println(object);// world

Jedis使用时只要注意以下几点即可:

  1. Jedis操作放在 try-catch-finally 块中更加合理。
  2. 区分直连和连接池两种实现方式的优缺点。
  3. jedis.close() 的两种实现方式。
  4. Jedis依赖了 common-pool ,有关其的参数需要根据不同的使用场景,各不相同,需要具体问题具体分析。
  5. 如果key和value涉及到字节数组,需要自己选择适合的序列化方法。

4.3 Python客户端redis-py

此部分内容暂略。


4.4 客户端管理

Redis提供了客户端相关的API来对其状态进行监控和管理

4.4.1 客户端API

1. client list

此命令能列出与Redis服务端相连的所有客户端连接信息。

1
2
3
127.0.0.1> client list
id=xxxx......
id=xxxx......

每行输出代表一个客户端信息,其中包含十几个属性,理解这些属性对于开发和运维人员十分重要。

(1)标识
  • id:客户端连接的唯一标识,此id随着Redis的连接自增,重启Redis后会重置为0。
  • addr:客户端连接的ip和端口。
  • fd:socket的文件描述符,与 lsof 命令结果中的fd是同一个,如果 fd=-1 代表当前客户端不是外部客户端,而是Redis内部的伪装客户端。
  • name:客户端的名字,后面的 client nameclient getName 会对其进行说明。
(2)输入缓冲区

Redis为每个客户端分配了输入缓冲区,它的作用是将客户端发送的命令临时保存,同时Redis会从输入缓冲区拉取命令并执行,输入缓冲区为客户端发送命令到Redis执行命令提供了缓冲功能

  • qbuf:缓冲区的总容量。
  • qbuf-free:缓冲区的剩余容量。

Redis不能通过配置来指定缓冲区容量,只要求每个客户端缓冲区不能超过1G,会根据输入内容自动动态调整,若超过则会被关闭。

1
2
/* Protocol and I/O related defines */
#define REDIS_MAX_QUERYBUF_LEN (1024*1024*1024) /* 1GB max query buffer */

输入缓冲区使用不当会产生两个问题:

  • 一旦某客户端的输入缓冲区超过1G,客户端将会被关闭。
  • 输入缓冲区不受 maxmemory 控制,假设一个Redis实例设置 maxmemory 为4G,已经存储了2G数据,但是如果此时输入缓冲区使用了3G,已经超过 maxmemory 限制,可能会产生数据丢失键值淘汰OOM等情况。

那么造成输入缓冲区过大的原因有哪些?

  • Redis处理速度跟不上输入缓冲区的输入速度,并且每次进入输入缓冲区的命令包含了大量 bigkey ,从而造成了输入缓冲区过大的情况。
  • Redis发生阻塞,短期不能处理命令,造成客户端输入的命令积压在了输入缓冲区。

如何快速发现和监控输入缓冲区异常?

  • 定期执行 client list 命令,收集 qbufqbuf-free 找到异常的连接记录并分析,最终找到可能出问题的客户端。
  • 通过 info 命令的 info clients 模块,找到最大的输入缓冲区。

两种方法的优劣势:

(3)输出缓冲区

输出缓冲区用来保存命令执行的结果返回给客户端,为Redis和客户端交互返回结果提供缓冲

和输入缓冲区不同的是,输出缓冲区可以通过 client-output-buffer-limit 来进行设置,并且输出缓冲区按客户端不同分为了三类:

  • 普通客户端
  • 发布订阅客户端
  • slave客户端

对应的配置规则:

1
client-output-buffer-limit <class> <hard limit> <soft limit> <soft seconds>
  • <class> :客户端类型,分为三种:
    • normal:普通客户端
    • slave:slave客户端
    • pubsub:发布订阅客户端
  • <hard limit> :如果客户端使用的缓冲区大于 <hard limit> ,客户端会立即被关闭。
  • <soft limit><soft seconds> :如果客户端使用的缓冲区大于 <soft limit> ,并且持续了 <soft seconds> 秒,客户端会立即被关闭。

Redis的默认配置:

1
2
3
client-output-buffer-limit normal 0 0 0
client-output-buffer-limit slave 256mb 64mb 60
client-output-buffer-limit pubsub 32mb 8mb 60

输出缓冲区和输入缓冲区一样不受 maxmemory 限制,超过时会产生数据丢失键值淘汰OOM等情况。

输出缓冲区由两部分组成:

  • 固定缓冲区(16KB),返回较小的执行结果
  • 动态缓冲区,返回较大的执行结果,如大的字符串、hgetallsmembers 命令的结果等。

通过Redis源码中 redis.h 的 redisClient 结构体可以看到两个缓冲区的实现细节:

1
2
3
4
5
6
7
8
9
10
typedef struct redisClient {
// 动态缓冲区列表
list *reply;
// 动态缓冲区列表的长度(对象个数)
unsigned long reply_bytes;
// 固定缓冲区已经使用的字节数
int bufpos;
// 字节数组作为固定缓冲区
char buf[REDIS_REPLY_CHUNK_BYTES];
} redisClient;

固定缓冲区使用的是字节数组,动态缓冲区使用的是列表。当固定缓冲区存满后将Redis新的返回结果存放在动态缓冲区的队列中,队列中的每个对象就是每个返回结果。

client list 中:

  • obl 代表固定缓冲区的长度。
  • oll 代表动态缓冲区列表的长度。
  • omem 代表使用的字节数。

监控输出缓冲区的方法也有两种:

  • 定期执行 client list 命令,收集 oblollomem 找到异常的连接记录并分析,最终找到可能出问题的客户端。
  • 通过 info 命令的 info clients 模块,找到输出缓冲区列表最大对象数。

相比输入缓冲区,输出缓冲区出现异常的概率相对比较大:

  • 进行上述监控,设置阈值、超过阈值及时处理、

  • 限制普通客户端输出缓冲区的 <hard limit> <soft limit> <soft seconds> ,把错误扼杀在摇篮中,如下设置:

    1
    client-output-buffer-limit normal 20mb 10mb 120
  • 适当增大slave的输出缓冲区的 <hard limit> <soft limit> <soft seconds> ,如果master节点写入较大,slave客户端的输出缓冲区可能会比较大,一旦slave客户端连接因为输出缓冲区溢出被kill,会造成复制重连。

  • 限制容易让输出缓冲区增大的命令,如高并发下的monitor命令就是一个危险的命令。

  • 及时监控内存,一旦发现内存抖动频繁,可能就是输出缓冲区过大。

(4)客户端的存活状态
  • age:客户端已连接的时间。
  • idle:最近一次的空闲时间。
(5)客户端的限制
  • maxclients:限制最大客户端连接数,超过时新的连接会被拒绝,默认为10000。可以通过 config set maxclients 对最大客户端连接数进行动态设置。一般最大连接数为10000在大部分场景够用,但一些业务方使用不当(如未主动关闭连接)导致存在大量idle连接,会造成很大的隐患。
  • timeout:限制连接的最大空闲时间,超过时新的连接将会被关闭,默认为0。很多开发人员使用JedisPool时不会对连接池对象做空闲检测和验证,如果 timeout>0 可能会导致出现 JedisConnection 异常,对应用业务造成一定影响,但是如果Redis的客户端使用不当或者客户端本身的一些问题,造成没有及时释放客户端连接,可能会造成大量的idle连接占据着很多连接资源,一旦超过maxclients后果也是不堪设想。所以实际开发与运维中,需要将timeout设置为大于0,如300秒等。同时在客户端使用上添加空闲检测和验证等措施,如JedisPool使用 common-pool 提供的三个属性:minEvictableIdleTimeMillistestWhileIdletimeBetweenEvictionRunsMillis
(6)客户端类型

client list 中的 flag 是用于标识当前客户端的类型。

序号 客户端类型 说明
1 N 普通客户端
2 M 当前客户端是master节点
3 S 当前客户端是slave节点
4 O 当前客户端正在执行monitor命令
5 x 当前客户端正在执行事务
6 b 当前客户端正在等待阻塞事件
7 i 当前客户端正在等待VM I/O,但是此状态目前已经废弃不用
8 d 一个受监视的键已被修改,EXEC命令将失败
9 u 客户端未被阻塞
10 c 回复完整输出后,关闭连接
11 A 尽可能快地关闭连接
(7)其他

client list 命令结果的全部属性:

序号 参数 含义
1 id 客户端连接id
2 addr 客户端连接IP和端口
3 fd socket的文件描述符
4 name 客户端连接名
5 age 客户端连接存活时间
6 idle 客户端连接空闲时间
7 flags 客户端类型标识
8 db 当前客户端正在使用的数据库索引下标
9 sub/psub 当前客户端订阅的频道或者模式数
10 multi 当前事务已执行命令个数
11 qbuf 输入缓冲区总容量
12 qbuf-free 输入缓冲区剩余容量
13 obl 固定缓冲区的长度
14 oll 动态缓冲区列表的长度
15 omem 固定缓冲区和动态缓冲区使用的容量
16 events 文件描述符事件(r/w): r和w分别代表客户端套接字可读和可写
17 cmd 当前客户端最后一次执行的命令,不包含参数

2. client setName 和 client getName

1
2
3
4
5
# 设置客户端名字
client setName XX
# 此时再执行client list可以看到当前客户端name属性,或者执行以下
client getName
# 使用IP+端口还是命名根据场景自由选择

3. client kill

手动杀掉客户端连接:

1
client kill ip:port

4. client pause

1
2
3
4
5
6
7
8
9
# 阻塞客户端指定时间,毫秒
client pause timeout

# 假设执行
client pause 10000
# 此时在另外一个客户端ping
ping
pong
(9.72S)

此命令适用场景:(生产环境暂停客户端成本很高)

  • client pause 只对普通和发布订阅客户端有效,对于主从复制(从节点内部伪装了一个客户端)是无效的,所以此命令可以用来让主从复制保持一致。
  • client pause 可以用一种可控的方式将客户端连接从一个Redis节点切换到另一个Redis节点。

5. montior

montior命令用于监控Redis正在执行的命令,如下图所示。

每个客户端都有自己的输出缓冲区,monitor能监听所有命令,一旦Redis并发量过大,输出缓冲区会暴涨,瞬间占用大量内存,如下图。

4.4.2 客户端相关配置

  • timeout:检测客户端空闲连接的超时时间,一旦idle时间达到了timeout,客户端会被关闭,如果设置了0就不进行检测。

  • maxclients:客户端最大连接数,该参数会受到,操作系统设置的限制。

  • tcp-keepalive:检测TCP连接活性的周期,默认值为0,也就是不进行检测。若有需要,建议设置为60,每隔60秒对创建的TCP连接进行活性检测,防止大量死连接占用系统资源。

  • tcp-backlog:TCP三次握手后,会将接受的连接放入队列中。该参数表示队列的大小,默认为511,在Linux系统中若 /proc/sys/net/core/somaxconn 小于该参数,则在Redis启动时会看到如下日志,并建议将somaxconn调大。

    1
    # WARNING: The TCP backlog setting of 511 cannot be enforced because /proc/sys/net/core/somaxconn is set to the lower value of 128.

    修改命令:

    1
    echo 511 > /proc/sys/net/core/somaxconn

4.4.3 客户端统计片段

info clients 执行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
127.0.0.1:6379> info clients
# clients
# 当前节点的客户端连接数,一旦超过maxclients,新的客户端连接将被拒绝
connected_clients:1414
# 当前所有输出缓冲区中队列对象个数的最大值
client_longest_output_list:0
# 当前所有输入缓冲区中占有的最大容量
client_biggest_input_buf:2097152
# 正在执行阻塞命令的客户端个数,如blpop、brpop、brpoplpush
blocked_clients:0

127.0.0.1:6379> info stats

# status
# 自Redis启动以来处理的客户端连接总数
total_connections_received:80
...
# 自Redis启动以来拒绝的客户端连接总数
rejected_connections:0

4.5 客户端常见异常

4.5.1 无法从连接池获取到连接

JedisPool中的Jedis对象个数有限,默认为8。假设8个对象都被占有,新的请求会等待,如果设置了maxWaitMillis>0,在设置时间内无法获取到Jedis对象,抛出异常:

1
2
3
4
redis.clients.jedis.exceptions.JedisConnectionException: Could not get a resource from the pool
...
Caused by: java.util.NoSuchElementException: Timeout waiting for idle object
...

如果设置了 blockWhenExhausted=false ,调用者发现无空闲资源不会等待,立即抛出异常。

1
2
3
4
redis.clients.jedis.exceptions.JedisConnectionException: Could not get a resource from the pool
...
Caused by: java.util.NoSuchElementException: Pool exhausted
...

造成资源紧张的原因:

  • 客户端:

    • 高并发下连接池设置过小,出现供不应求的情况。正常情况下,因为Jedis的处理效率够高,不需要高于8太多。

    • 不能正确的使用连接池,比如未进行释放。

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      // 使用默认配置定义JedisPool
      GenericObjectPoolConfig poolConfig = new GenericObjectPoolConfig();
      JedisPool jedisPool = new JedisPool(poolConfig, "127.0.0.1", 6379);

      // 借用8次连接,没有执行归还操作
      for (int i = 0;i <= 8;i++) {
      Jedis jedis = null;
      try {
      jedis = jedisPool.getResource();
      jedis.ping();
      } catch (Exception e) {
      e.printStackTrace();
      }
      }

      // 调用者再借用Jedis时,抛出异常
      jedisPool.getResource().ping();
    • 存在慢查询操作,这些操作归还速度较慢,造成池子满了。

  • 服务端:客户端正常,但服务器由于某些原因造成客户端命令执行过程阻塞,也会抛出该异常。

4.5.2 客户端读写超时

读写超时,会抛出异常:

1
redis.clients.jedis.exceptions.JedisConnectionException: java.net.SocketTimepoutException: Read timed out

造成该问题的原因:

  • 读写超时时间设置的过短。
  • 命令本身较慢。
  • 客户端与服务端网络通信不正常。
  • Redis自身发生阻塞。

4.5.3 客户端连接超时

连接超时,会抛出异常:

1
redis.clients.jedis.exceptions.JedisConnectionException: java.net.SocketTimepoutException: Connect timed out

造成该问题的原因:

  • 连接超时时间设置的过短,可以通过代码设置:

    1
    2
    // 毫秒
    jedis.getClient().setConnectionTimeout(time);
  • Redis发生阻塞,造成tcp-backlog已满,导致新的连接失败。

  • 客户端与服务端网络不正常。

4.5.4 客户端缓冲区异常

Jedis在调用Redis时,如果出现客户端数据流异常,会抛出异常:

1
redis.clients.jedis.exceptions.JedisConnectionException: Unexpected end of stream

造成该问题的原因:

  • 输出缓冲区满。如将普通客户端的输出缓冲区设置为1M 1M 60:

    1
    config set client-output-buffer-limit "normal 1048576 1048576 60 slave 268435456 67108864 60 pubsub 33554432 8388608 60"

    如果使用get命令获取一个bigkey(如3M),就会出现此异常。

  • 长时间闲置连接被服务端主动断开。

  • 不正常并发读写:Jedis对象同时被多个线程并发操作。

4.5.5 Lua脚本正在执行

如果Redis此时正在执行Lua脚本,且超过了lua-time-limit,此时调用Redis会收到异常:

1
redis.clients.jedis.exceptions.JedisDataException: BUSY Redis is busy running a script. You can only SCRIPT KILL or SHUTDOWN NOSAVE.

4.5.6 Redis正在加载持久化文件

Jedis在调用Redis时,如果Redis正在加载持久化文件,会抛出异常:

1
redis.clients.jedis.exceptions.JedisDataException: LOADING Redis is loading the dataset in memory 

4.5.7 Redis使用的内存超过maxmemory配置

Jedis执行写操作时,若Redis使用内存大于maxmemory的设置,就会收到异常:

1
redis.clients.jedis.exceptions.JedisDataException: OOM command not allowed when used memory > 'maxmemory'

此时应该调整maxmemory,并找到造成内存增长的原因。

4.5.8 客户端连接数过大

若客户端连接数超过了maxclients,新申请的连接会抛出异常:

1
redis.clients.jedis.exceptions.JedisDataException: ERR max number of clients reached

此时新的客户端连接执行任何命令,返回结果都是:

1
2
127.0.0.1:6379> get hello
(error) ERR max number of clients reached

此时无法执行Redis命令进行修复,不过可以从两方面着手解决:

  • 客户端:若maxclients参数不是很小,应用方的客户端连接数基本不会超过,通常是因为应用方对Redis客户端使用不当导致。若应用方是分布式结构的话,可以通过下线部分应用节点(特别是占用连接较多的节点)使Redis连接数先降下来。让大部分节点先正常运行,再通过寻找程序BUG或调整maxclients的方式进行问题修复。
  • 服务端:若此时客户端无法处理,而当前Redis为高可用模式(如Redis Sentinel和Redis Cluster),可以考虑将当前Redis做故障转移,参考《Redis开发与运维》读书笔记(十)集群

4.6 客户端案例分析

4.6.1 Redis内存陡增

(1)现象

服务端现象:Redis主节点内存陡增,几乎用满maxmemory,而从节点内存并没有变化。

客户端现象:客户端产生了OOM异常,即Redis主节点使用的内存超过了maxmemory的设置,无法写入新数据。

1
redis.clients.jedis.exceptions.JedisDataException: LOADING Redis is loading the dataset in memory 

(2)分析原因

从现象看,有两种可能原因:

  • 确实有大量写入,但主从复制出现了问题:查询了Redis复制相关信息,但情况正常,主从数据基本一致。

    1
    2
    3
    4
    5
    6
    # 主节点键个数
    127.0.0.1:6379> dbsize
    (integer) 2126870
    # 主节点键个数
    127.0.0.1:6380> dbsize
    (integer) 2126870
  • 其他原因造成主节点内存使用过大:排查是否由客户端缓冲区造成主节点内存陡增,使用info clients命令查询相关信息:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
      127.0.0.1:6379> info clients
    # Clients
    connected_clients:1891
    client_longest_output_list:225698
    client_biggest_input_buf:0
    blocked_clients:0



    很明显输出缓冲区不太正常,最大的客户端输出缓冲区队列已经超过20W个对象。需要通过client list命令找到omem不正常的连接,一般大部分客户端的omem为0,因为处理速度足够快。

    ```shell
    $ redis-cli client list | grep -v "omem=0"
    # 找到一条记录,说明是客户端在执行monitor导致
    id=7 addr=10.10.XX.78:56358 fd=6 name= age=91 idle=0 flags=0 db=0 sub=0 psub=0 multi=i qbuf=0 qbuf-free=0 obl=0 oll=224869 omem=2129300608 events=rw cmd=monitor

(3)处理方法

只需使用client kill命令杀掉这个连接,让其他客户端恢复正常写数据即可。但重要的是如何在日后及时发现和避免这种问题的发生:

  • 从运维层面禁止monitor命令,例如使用rename-command命令重置monitor命令为一个随机字符串,若monitor没有做rename-command,也可以进行相应的监控,如client list。
  • 从开发层面进行培训,禁止在生产环境使用monitor命令,但该命令对于测试还是比较有用,完全禁止不太现实。
  • 限制输出缓冲区大小。
  • 使用专业的Redis运维工具,如CacheCloud,这类问题会收到报警。

4.6.2 客户端周期性的超时

(1)现象

客户端现象:客户端出现大量超时,经过分析发现周期性出现超时。

1
Caused by: redis.clients.jedis.exceptions.JedisConnectionException: java.net.SocketTimeoutException: connection timed out

服务端现象:服务端并没有明显的异常,只是有一些慢查询操作。

(2)分析

  • 网络原因:服务端和客户端之间网络可能出现周期性问题,但经过观察网络正常。
  • Redis本身:经过观察Redis日志统计,并无异常暴露。
  • 客户端:因为周期性出现问题,所以和慢查询日志的历史记录进行对比,发现只要慢查询出现时,客户端就会产生大量连接超时,时间点一致。

所以定位到问题是慢查询导致,通过执行hlen发现有200万个元素,这种操作必然会导致Redis阻塞,通过和应用方沟通了解到他们有个定时任务,每5分钟执行一次hgetall操作。

1
2
127.0.0.1:6379> hlen user_fan_hset_sort
(integer) 2883279

该问题的快速定位得益于客户端监控工具的收集数据。

(3)处理方法

解决这个问题,只需业务方及时处理自己的慢查询即可,为了避免后续出现相同问题,需要注意:

  • 从运维层面,监控慢查询,一旦超过阈值,就发出报警。
  • 从开发层面,加强对Redis的理解,避免不正确的使用方式。
  • 使用专业的Redis运维工具,如CacheCloud。

参考:

🔗 《Redis开发与运维》