vlambda博客
学习文章列表

Redis突然报错,今晚又不能回家了...

今天在容器环境发布服务,我发誓我就加了一行日志,在点击发布按钮后,我悠闲地掏出泡着枸杞的保温杯,准备来一口老年人大保健......


图片来自 Pexels


正当我一边喝,一边沉思今晚吃点啥的问题时,还没等我想明白,报警系统把我的黄粱美梦震碎成一地鸡毛。


我急忙去 Sentry 上查看上报错误日志,发现全都是:
redis.clients.jedis.exceptions.JedisConnectionExceptionCould not get a resource from the pool

我没动过 Redis 啊......
Redis突然报错,今晚又不能回家了...

内心激动的我无以言表,但是外表还是得表现镇定,此时我必须的做出选择:回滚or重启。


我也不知道是从哪里来的蜜汁自信,我坚信这跟我没关系,我不管,我就要重启。


时间每一秒对于等待重启过程中的我来说变得无比的慢,就像小时候犯了错,在老师办公室等待父母到来那种感觉。


重启的过程中我继续去看报错日志,猛地发现一条:

Redis突然报错,今晚又不能回家了...

什么鬼,谁打日志打成这样?当我点开准备看看是哪位大侠打的日志的时候,我惊奇的发现:

***************************
APPLICATION FAILED TO START
***************************
......
......

原来是服务没起来。此刻我的内心是凌乱的,无助的,彷徨不安的。服务没起来,哪里来的 Redis 请求?


能解释通的就是应该是来自于定时任务刷新数据对 Redis 的请求。这里也说明另一个问题:虽然端口占用,但是服务其实还是发布起来了,不然不可能运行定时任务。

但是还有另一个问题,Redis 为什么报错,且报错的原因还是:
java.lang.IllegalStateExceptionPool not open

Jedis 线程池未初始化。项目既然能去执行定时任务,为什么不去初始化 Redis 相关配置呢?想想都头疼。这里可以给大家留个坑尽管猜。


我们今天的重点不是项目为啥没起来,而是 Redis 那些年都报过哪些错,让你夜不能寐。以下错误都基于 Jedis 客户端。


忘记添加白名单


之所以把这个放在第一位,是因为上线不规范,亲人不能睡。


上线之前检查所有的配置项,只要是测试环境做过的操作,一定要拿个小本本记下。


在现如今使用个啥啥都要授权的时代你咋能就忘了白名单这种东西呢!

Redis突然报错,今晚又不能回家了...

无法从连接池获取到连接


如果连接池没有可用 Jedis 连接,会等待 maxWaitMillis(毫秒),依然没有获取到可用 Jedis 连接,会抛出如下异常:

redis.clients.jedis.exceptions.JedisExceptionCould not get a resource from the pool
    at redis.clients.util.Pool.getResource(Pool.java:51)
    at redis.clients.jedis.JedisPool.getResource(JedisPool.java:226)
    at com.yy.cs.base.redis.RedisClient.zrangeByScoreWithScores(RedisClient.java:2258)
  ......
java.util.NoSuchElementExceptionTimeout waiting for idle object
    at org.apache.commons.pool2.impl.GenericObjectPool.borrowObject(GenericObjectPool.java:448)
    at org.apache.commons.pool2.impl.GenericObjectPool.borrowObject(GenericObjectPool.java:362)
    at redis.clients.util.Pool.getResource(Pool.java:49)
    at redis.clients.jedis.JedisPool.getResource(JedisPool.java:226)
  ......


其实出现这个问题,我们从两个方面来探测一下原因:
  • 连接池配置有问题

  • 连接池没问题,使用有问题


连接池配置


Jedis Pool 有如下参数可以配置:

Redis突然报错,今晚又不能回家了...

①如何确定 maxTotal 呢?


最大连接数肯定不是越大越好,首先 Redis 服务端已经配置了允许所有客户端连接的最大连接数,那么当前连接 Redis 的所有节点的连接池加起来总数不能超过服务端的配置。


其次对于单个节点来说,需要考虑单机的 Redis QPS,假设同机房 Redis 90% 的操作耗时都在 1ms,那么 QPS 大约是 1000。


而业务系统期望 QPS 能达到 10000,那么理论上需要的连接数=10000/1000=10。


考虑到网络抖动不可能每次操作都这么准时,所以实际配置值应该比当前预估值大一些。

②maxIdle 和 minIdle 如何确定?

maxIdle 从默认值来看是等于 maxTotal。这么做的原因在于既然已经分配了 maxTotal 个连接,如果 maxIdle<maxTotal,而当前并发请求数已经达到 maxIdle 的情况下,还是需要新建立连接,那么建立连接的开销其实有点得不偿失。不如直接保持 maxTotal 个连接的存在。


如果你的系统只是在高峰期才会达到 maxTotal 的量,那么你可以通过 minIdle 来控制低峰期最低有多少个连接的存活。


所以连接池参数的配置直接决定了你能否获取连接以及获取连接效率问题。


使用有问题


说到使用,真的就是仁者见仁智者也会犯错,谁都不能保证他写的代码一次性考虑周全。


比如有这么一段代码:

Redis突然报错,今晚又不能回家了...
是不是没有问题。再好好想想,这里从线程池中获取了 Jedis 连接,用完了是不是要归还?不然这个连接一直被某个人占用着,线程池慢慢连接数就被消耗完。


所以正确的写法:

Redis突然报错,今晚又不能回家了...

多个线程使用同一个 Jedis 连接


这种错误一般发生在新手身上会多一些。
Redis突然报错,今晚又不能回家了...

这段代码乍看是不是感觉良好,不过你跑起来了之后就知道有多痛苦:

redis.clients.jedis.exceptions.JedisConnectionExceptionUnexpected end of stream.
    at redis.clients.util.RedisInputStream.ensureFill(RedisInputStream.java:199)
    at redis.clients.util.RedisInputStream.readByte(RedisInputStream.java:40)
    at redis.clients.jedis.Protocol.process(Protocol.java:151)
......


这个报错是不是让你一头雾水,不知所措。出现这种报错是服务端无法分辨出一条完整的消息从哪里结束,正常情况下一个连接被一个线程使用,上面这种情况多个线程同时使用一个连接发送消息,那服务端可能就无法区分到底现在发送的消息是哪一条的。


类型转换错误


这种错误虽然很低级,但是出现的几率还不低。

java.lang.ClassCastExceptioncom.test.User cannot be cast to com.test.User
         at redis.clients.jedis.Connection.getBinaryMultiBulkReply(Connection.java:199)
         at redis.clients.jedis.Jedis.hgetAll(Jedis.java:851)
         at redis.clients.jedis.ShardedJedis.hgetAll(ShardedJedis.java:198)

上面这个错乍一看是不是很吃惊,为啥同一个类无法反序列化。因为开发这个功能的同学用了一个序列化框架 Kryo 先将 User 对象序列化后存储到 Redis。


后来 User 对象增加了一个字段,而反序列化的 User 与新的 User 对象对不上导致无法反序列化。

客户端读写超时


出现客户端读超时的原因很多,这种情况就要综合来判断。

redis.clients.jedis.exceptions.JedisConnectionException:
    java.net.SocketTimeoutExceptionRead timed out
    ......


出现这种情况的原因我们可以综合分析:
  • 首先检查读写超时时间是否设置的过短,如果确定设置的很短,调大一点观察一下效果。

  • 其次检查出现超时的命名是否本身执行较大的存储或者拉数据任务。如果数据量过大,那么就要考虑做业务拆分。

  • 前面这两项如果还不能确定,那么就要检查一下网络问题,确定当前业务主机和 Redis 服务器主机是否在同机房,机房质量怎么样。

  • 机房质量如果还是没问题,那能做的就是检查当前业务中 Redis 读写是否发生有可能发生阻塞,是否业务量大到这种程度,是否需要扩容。


大 Key 造成的 CPU 飙升


我们有个新项目中 Redis 主要存储教师端的讲义数据(浓缩讲义非全部), QPS 达到了15k,但是通过监控查看命中率特别低,仅 15% 左右。这说明有很多讲义是没有被看的,Cache 这样使用是对内存的极大浪费。


项目在上线中期就频繁出现 Redis 所在机器 CPU 使用率频频报警,单看这么低的命中率也很难想象到底是什么导致 CPU 超。后面观察到报警时刻的 response 数据基本都在 15k-30 k 左右。


观察了 Redis 的错误日志,有一些页交换错误的日志。联系起来看可以得出结论:Redis 获取大对象时该对象首先被序列化到通信缓冲区中,然后写入客户端套接字,这个序列化是有成本的,涉及到随机 I/O 读写。


另外 Redis 官方也不建议使用 Redis 存储大数据,虽然官方建议值是一个 value 最大值不能超过 512M,试想真的存储一个 512M 的数据到缓存和到关系型数据库的区别应该不大,但是成本就完全不一样。

Too Many Cluster Redirections


这个错误信息一般在 cluster 环境中出现,主要原因还是单机出现了命令堆积。

redis.clients.jedis.exceptions.JedisClusterMaxRedirectionsExceptionToo many Cluster redirections?
at redis.clients.jedis.JedisClusterCommand.runWithRetries(JedisClusterCommand.java:97)
at redis.clients.jedis.JedisClusterCommand.runWithRetries(JedisClusterCommand.java:152)
at redis.clients.jedis.JedisClusterCommand.runWithRetries(JedisClusterCommand.java:131)
at redis.clients.jedis.JedisClusterCommand.runWithRetries(JedisClusterCommand.java:152)
at redis.clients.jedis.JedisClusterCommand.runWithRetries(JedisClusterCommand.java:131)
at redis.clients.jedis.JedisClusterCommand.runWithRetries(JedisClusterCommand.java:152)
at redis.clients.jedis.JedisClusterCommand.runWithRetries(JedisClusterCommand.java:131)
at redis.clients.jedis.JedisClusterCommand.run(JedisClusterCommand.java:30)
at redis.clients.jedis.JedisCluster.get(JedisCluster.java:81)

Redis 是单线程处理请求的,如果一条命令执行的特别慢(可能是网络阻塞,可能是获取数据量大),那么新到来的请求就会放在 TCP 队列中等待执行。但是等待执行的命令数如果超过了设置的队列大小,后面的请求就会被丢弃。


出现上面这个错误的原因是:
  • 集群环境中 client 先通过key 计算 slot,然后查询 slot 对应到哪个服务器,假设这个 slot 对应到 server1,那么就去请求 server1。

  • 此时如果 server1 整由于执行慢命令而被阻塞且 TCP 队列也已满,那么新来的请求就会直接被拒绝。

  • client 以为是 server1不可用,随即请求另一个服务器 server2。server2 检查到该 slot 由 server1 负责且 server1 心跳检查正常,所以告诉 client 你还是去找 server1 吧。

  • client 又来请求 server1,但是 server1 此时还是阻塞中,又回到 3。当请求的次数超过拒绝服务次数之后,就会抛出异常。


再次说明,大命令要不得。对于这种错误,最首要的就是要优化存储结构或者获取数据方式。其次,增加 TCP 队列长度。再次,扩容也是可以解决的。


集群扩容之后找不到 Key


现在有如下集群,6 台主节点,6 台从节点:
  • redis-master001~redis-master006

  • redis-slave001~redis-slave006


之前 Redis 集群的 16384 个槽均匀分配在 6 台主节点中,每个节点 2730 个槽。


现在线上主节点数已经出现到达容量阈值,需要增加 3 主 3 从。


为保证扩容后,槽依然均匀分布,需要将之前 6 台的每台机器上迁移出 910 个槽,方案如下:

Redis突然报错,今晚又不能回家了...
Redis突然报错,今晚又不能回家了...

分配完之后,每台节点 1820 个 slot。迁移完数据之后,开始报如下异常:

 Exception in thread "mainredis.clients.jedis.exceptions.JedisMovedDataExceptionMOVED 1539 34.55.8.12:6379
at redis.clients.jedis.Protocol.processError(Protocol.java:93)
at redis.clients.jedis.Protocol.process(Protocol.java:122)
at redis.clients.jedis.Protocol.read(Protocol.java:191)
at redis.clients.jedis.Connection.getOne(Connection.java:258)
at redis.clients.jedis.ShardedJedisPipeline.sync(ShardedJedisPipeline.java:44)
at org.hu.e63.MovieLens21MPipeline.push(MovieLens21MPipeline.java:47)
at org.hu.e63.MovieLens21MPipeline.main(MovieLens21MPipeline.java:53

报这种错误肯定就是 slot 迁移之后找不到了。


我们看一下代码:

Redis突然报错,今晚又不能回家了...
之所以这种方式会出问题还是在于我们没有明白 Redis Cluster 的工作原理。


Key 通过 Hash 被均匀的分配到 16384 个槽中,不同的机器被分配了不同的槽,那么我们使用的 API 是不是也要支持去计算当前 Key 要被落地到哪个槽。

你可以去看看 Pipelined 的源码它支持计算槽吗。动脑子想想 Pipelined 这种批量操作也不太适合集群工作。


所以我们用错了 API。如果在集群模式下要使用 JedisCluster API,示例代码如下:

JedisPoolConfig config = new JedisPoolConfig();
//可用连接实例的最大数目,默认为8;
//如果赋值为-1,则表示不限制,如果pool已经分配了maxActive个jedis实例,则此时pool的状态为exhausted(耗尽)
private  Integer MAX_TOTAL = 1024;
//控制一个pool最多有多少个状态为idle(空闲)的jedis实例,默认值是8
private  Integer MAX_IDLE = 200;
//等待可用连接的最大时间,单位是毫秒,默认值为-1,表示永不超时。
//如果超过等待时间,则直接抛出JedisConnectionException
private  Integer MAX_WAIT_MILLIS = 10000;
//在borrow(用)一个jedis实例时,是否提前进行validate(验证)操作;
//如果为true,则得到的jedis实例均是可用的
private  Boolean TEST_ON_BORROW = true;
//在空闲时检查有效性, 默认false
private  Boolean TEST_WHILE_IDLE = true;
//是否进行有效性检查
private  Boolean TEST_ON_RETURN = true;
config.setMaxTotal(MAX_TOTAL);
config.setMaxIdle(MAX_IDLE);
config.setMaxWaitMillis(MAX_WAIT_MILLIS);
config.setTestOnBorrow(TEST_ON_BORROW);
config.setTestWhileIdle(TEST_WHILE_IDLE);
config.setTestOnReturn(TEST_ON_RETURN);
Set<HostAndPort> jedisClusterNode = new HashSet<HostAndPort>();
jedisClusterNode.add(new HostAndPort("192.168.0.31"6380));
jedisClusterNode.add(new HostAndPort("192.168.0.32"6380));
jedisClusterNode.add(new HostAndPort("192.168.0.33"6380));
jedisClusterNode.add(new HostAndPort("192.168.0.34"6380));
jedisClusterNode.add(new HostAndPort("192.168.0.35"6380));
JedisCluster jedis = new JedisCluster(jedisClusterNode, 100010005, config);


以上介绍了看似平常实则在日常开发中只要一不注意就会发生的错误。出错了先别慌,保留日志现场,如果一眼能看出问题就修复,如果不能就赶紧回滚,不然再过一会就是一级事故你的年终奖估计就没了。


Redis 正确使用小技巧


①正确设置过期时间


把这个放在第一位是因为这里实在是有太多坑。


如果你不设置过期时间,那么你的 Redis 就成了垃圾堆,假以时日你领导看到了告警,再看一下你的代码,估计你可能就 “没了”!


如果你设置了过期时间,但是又设置了特别长,比如两个月,那么带来的问题就是极有可能你的数据不一致问题会变得特别棘手。

我就遇到过这种,用户信息缓存中包含了除基本信息外的各种附加属性,这些属性又是随时会变的,在有变化的时候通知缓存进行更新,但是这些附加信息是在各个微服务中,服务之间调用总会有失败的时候,只要发生那就是缓存与数据不一致之日。


但是缓存又是 2 个月过期一次,遇到这种情况你能怎么办,只能手动删除缓存,重新去拉数据。


所以过期时间设置是很有技巧性的。

②批量操作使用 Pipeline 或者 Lua 脚本


使用 Pipeline 或 Lua 脚本可以在一次请求中发送多条命令,通过分摊一次请求的网络及系统延迟,从而可以极大的提高性能。

③大对象尽量使用序列化或者先压缩再存储


如果存储的值是对象类型,可以选择使用序列化工具比如 protobuf,Kyro。对于比较大的文本存储,如果真的有这种需求,可以考虑先压缩再存储,比如使用 snappy 或者 lzf 算法。

④Redis 服务器部署尽量与业务机器同机房


如果你的业务对延迟比较敏感,那么尽量申请与当前业务机房同地区的 Redis 机器。同机房 Ping 值可能在 0.02ms,而跨机房能达到 20ms。当然如果业务量小或者对延迟的要求没有那么高这个问题可以忽略。

Redis 服务器内存分配策略的选择:

首先我们使用 info 命令来查看一下当前内存分配中都有哪些指标:

info
$2962
# Memory
used_memory:325288168         
used_memory_human:310.22M  #数据使用内存
used_memory_rss:337371136
used_memory_rss_human:321.74M #总占用内存
used_memory_peak:327635032
used_memory_peak_human:312.46M  #峰值内存
used_memory_peak_perc:99.28%
used_memory_overhead:293842654
used_memory_startup:765712
used_memory_dataset:31445514
used_memory_dataset_perc:9.69%
total_system_memory:67551408128
total_system_memory_human:62.91G   # 操作系统内存
used_memory_lua:43008
used_memory_lua_human:42.00K
maxmemory:2147483648
maxmemory_human:2.00G
maxmemory_policy:allkeys-lru    # 内存超限时的释放空间策略
mem_fragmentation_ratio:1.04    # 内存碎片率(used_memory_rss / used_memory)
mem_allocator:jemalloc-4.0.3    # 内存分配器
active_defrag_running:0
lazyfree_pending_objects:0


上面我截取了 Memory 信息。根据参数:mem_allocator 能看到当前使用的内存分配器是 jemalloc。

Redis 支持三种内存分配器:tcmalloc,jemalloc 和 libc(ptmalloc)。

在存储小数据的场景下,使用 jemalloc 与 tcmalloc 可以显著的降低内存的碎片率。


根据这里的评测:
https://matt.sh/redis-quicklist

保存 200 个列表,每个列表有 100 万的数字,使用 jemalloc 的碎片率为 3%,共使用 12.1GB 内存,而使用 libc 时,碎片率为 33%,使用了 17.7GB 内存。


但是保存大对象时 libc 分配器要稍有优势,例如保存 3000 个列表,每个列表里保存 800 个大小为 2.5k 的条目,jemalloc 的碎片率为 3%,占用 8.4G,而 libc 为 1%,占用 8GB。


现在有一个问题: 当我们从 Redis 中删除数据的时候,这一部分被释放的内存空间会立刻还给操作系统吗?


比如有一个占用内存空间(used_memory_rss)10G 的 Redis 实例,我们有一个大 Key 现在不使用需要删除数据,大约删了 2G 的空间。那么理论上占用内存空间应该是 8G。


如果你使用 libc 内存分配器的话,这时候的占用空间还是 10G。这是因为 malloc() 方法的实现机制问题,因为删除掉的数据可能与其他正常数据在同一个内存分页中,因此这些分页就无法被释放掉。


当然这些内存并不会浪费掉,当有新数据写入的时候,Redis 会重用这部分空闲空间。


如果此时观察 Redis 的内存使用情况,就会发现 used_memory_rss 基本保持不变,但是 used_memory 会不断增长。

小结


今天给大家分享 Redis 使用过程中可能会遇到的问题,也是我们稍不留神就会遇到的坑。


很多问题在测试环境我们就能遇到并解决,也有一些问题是上了生产之后才发生的,需要你临时判断该怎么做。


总之别慌,你遇到的这些问题都是前人曾经走过的路,只要仔细看日志都是有解决方案的。

简介:目前就职广州欢聚时代,专注音视频服务端技术,对音视频编解码技术有深入研究。日常主要研究怎么造轮子和维护已经造过的轮子,深耕直播类 APP 多年,对垂直直播玩法和应用有广泛的应用经验,学习技术不局限于技术,欢迎大家一起交流。

编辑:陶家龙

征稿:有投稿、寻求报道意向技术人请联络 [email protected]

精彩文章推荐: