(本文首发于“数据库架构师”公号,订阅“数据库架构师”公号,一起学习数据库技术)
本篇为Redis性能问题诊断系列的第二篇,本文主要从应用发起的典型命令使用上进行讲解,由于Redis为单线程服务架构,对于一些命令如果使用不当会极大的影响Redis的性能表现,这里也会对不合理的使用方式给出优化解决方案。
一、Redis慢日志功能
分析Redis访问变慢,其中有个最基础的方法就是先去看Redis是否有慢日志【就像MySQL的慢SQL一样】。Redis提供了一个简单的慢命令统计记录功能,它会记录有哪些命令在执行时耗时较长。Redis慢日志功能由两个核心参数控制:
slowlog-log-slower-than 1000
#慢日志命令执行阈值,这里指超过1ms就会被记录【单位为微秒】
slowlog-max-len 4096
#保留慢日志命令的个数,类似一个先进先出的队列,超过4096个最早的就会被清理
Redis的这个慢日志功能比较粗糙简单,有个严重的不足:没有持久化记录能力。
由于Redis的慢日志记录都在内存中,不像MySQL会持久化到文件里,那么如果慢日志产生较快,即使设置的slowlog-max-len比较大也会很快被填满,诊断问题时也就不能统计到那个时间段产生的所有慢命令详情。
为了避免产生的慢日志被清理,目前一个折中的解决方案是写一个收集程序周期性的将新增慢命令查出并记录到MySQL或者本地文件中,以备事后分析。但是这个频率一般都是分钟级,Redis处理的吞吐能力又太大,在慢命令较多的情况下往往也不能全部记录下来。
配置好慢日志相关阈值后,可以执行以下命令查询最近的慢日志记录了:
127.0.0.1:6379> SLOWLOG get 5
1) 1) (integer) 42343
2) (integer) 1653659194 #慢日志产生的时间戳
3) (integer) 73536 #慢日志执行的耗时
4) 1) "KEYS" #慢日志命令详情
2) "permission::userMenuList:*"
5) "192.168.1.11:20504" #慢日志命令发起来源IP【4.0及以后版本支持】
6) ""2) 1) (integer) 42342
2) (integer) 1653659194
3) (integer) 73650
4) 1) "KEYS"
2) "userPermission:*"
5) "192.168.1.10:20362"
6) ""
3) 1) (integer) 42341
2) (integer) 1653659193
3) (integer) 81505
4) 1) "KEYS"
2) "userRole:*"
5) "192.168.1.13:19926"
6) ""
二、几种典型导致Redis变慢的不合理使用方式
1.使用keys命令进行正则匹配
Keys的正则匹配是阻塞式的、全量扫描过滤,这对于单线程服务的Redis来说是致命的,仅仅几十万个Key的匹配查询在高并发访问下就有可能将Redis打崩溃!这其实就像MySQL的无索引查询大表数据,全表扫描状态下几个并发查询就可能会将数据库堵死。
redis> SLOWLOG get 5
1) 1) (integer) 42343
2) (integer) 1653659194
3) (integer) 73536
4) 1) "KEYS"
2) "Testper::userList:*"
5) "192.168.1.10:20504"
6) ""
2) 1) (integer) 42342
2) (integer) 1653659194
3) (integer) 73650
4) 1) "KEYS"
2) "TestuserPermission:*"
5) "192.168.1.11:20362"
6) ""
3) 1) (integer) 42341
2) (integer) 1653659193
3) (integer) 81505
4) 1) "KEYS"
2) "TestuserRole:*"
5) "192.168.1.12:19926"
6) ""
上述示例中使用Keys来模糊查询某些Key,每次的执行都在70ms以上,严重影响了正常的Redis响应时长和吞吐。
针对这种问题的一个解决方案是使用scan代替keys。
这是一个查询迭代命令,用于迭代当前数据库中的缓存数据。它是一个基于游标的迭代器,每次被调用之后, 都会向用户返回一个新的游标, 用户在下次迭代时需要使用这个新游标作为Scan命令的游标参数, 以此来延续之前的迭代过程。具体的命令语法这里不再详述。
2.大量使用了复杂度较高的命令
(1)应用中高频使用了 O(N) 及以上复杂度的命令,例如:SUNION、SORT、ZUNIONSTORE、ZINTERSTORE 聚合类命令。SORT命令的时间复杂度:O(N+M*log(M)), N 为要排序的列表或集合内的元素数量, M 为要返回的元素数量。
这种导致Redis请求变慢的原因是,Redis 在操作数据排序时,时间复杂度过高,要花费更多的 CPU计算资源。
(2)使用 O(N) 复杂度的命令,但 N 的值非常大,比如hgetall、smembers、lrange、zrange等命令。
这种变慢的原因在于,Redis 一次需要返回给客户端的数据过多,需要花费更多时间在数据组装和网络传输中。对于hgetall、smembers这种命令,需要警惕项目刚上线之初hash、set或者list存储的成员个数较少,但是随着业务发展成员数量极有可能会膨胀的非常大,如果仍然采用上述命令不加控制,会极大拖累整个Redis服务的响应时间。
针对这两种情况还都可以从资源使用率层面来分析,如果应用程序访问 Redis 的QPS不是很大,但 Redis 实例的 CPU 使用率却很高,那么很有可能是使用了复杂度过高的命令导致的。
因为Redis 是单线程处理请求的,如果你经常使用以上复杂度较高的命令,那么当 Redis 处理程序请求时,一旦前面某个命令发生耗时较长,就会导致后面的请求发生阻塞排队,对于应用程序来说,响应延迟也会变长。
3.存储使用了bigkey
在分析慢日志发现很多请求并不是复杂度高的命令,都是一些del、set、hset等的低复杂度命令,那么就要评估是否写入了大key。
在往Redis写入数据时,需要为新数据分配内存块,相对应的,当删除数据时,Redis也会释放对应的内存空间。如果一个 key 写入Redis的值非常大,那么在分配内存时就会相对比较耗时。同样的当删除这个 key 时,释放内存也会比较耗时,这种被称为bigKey。
当然这个描述仍然比较宽泛,因为Redis中的数据库结构类型比较多,更完善的一些说法可以这么定义:将含有较大数据或含有大量成员、列表数的Key定义为bigkey。
我们一般要求研发使用Redis时,对于String类型Value大小不要超过1KB。
大Key带来的问题比较多,主要有下面几种情况:
- 由于大Key的内存分配及释放开销变大,直接影响就是导致应用访问Redis的响应变慢;
- 删除时会造成较长时间的阻塞并有可能造成集群主备节点切换【4.0之前的版本有这个问题】;
- 内存占用过多甚至达到maxmemory配置,会造成新写入阻塞或一些不应该被提前删除的Key被逐出,甚至导致OOM发生;
- 并发读请求因为Key过大会可能打满服务器带宽,如果单机多实例部署则同时会影响到该服务器上的其它服务【假设一个bigkey为1MB,客户端每秒访问量为1000,那么每秒产生1000MB的流量】;
- 运维麻烦,比如RedisCluster的数据跨节点均衡,因为均衡迁移原理是通过migrate命令来完成的,这个命令实际是通过dump + restore + del三个命令组合成原子命令完成,如果是bigkey,可能会使迁移失败,而且较慢的migrate也会阻塞Redis正常请求;
- 分片集群RedisCluster中的出现严重的数据倾斜,导致某个节点的内存使用过大;
那么对于已经写入的数据,如何分析找出里面的bigkey进行优化呢?可以通过Redis官方客户端redis-cli的bigkeys参数来定位大Key分布。
shell> redis-cli -h 127.0.0.1 -p 18708 -a xxxx --bigkeys -i 0.01
[00.00%] Biggest string found so far 'urlcount:www.guprocessorSuccessMid' with 1 bytes
[00.01%] Biggest string found so far 'TestDomain:www:config:scheduler' with 3847 bytes
[00.03%] Biggest string found so far 'TestDomain:www:config:scheduler' with 211306 bytes
[00.88%] Biggest set found so far 'specialTestJobSet:www' with 20 members
[01.69%] Biggest list found so far 'TestDomain:www:urlList' with 9762 items
[07.13%] Biggest list found so far 'TestDomain:bx:urlList' with 457676 items
[07.39%] Biggest set found so far 'specialTestJobSet:www' with 100 members
[13.99%] Biggest string found so far 'TestDomain:wwwe:config:scheduler' with 540731 bytes
[18.74%] Biggest set found so far 'TestJobSet' with 300 members
[58.09%] Biggest string found so far 'TestDomain:wwwrt:config:scheduler' with 739024 bytes
[64.19%] Biggest string found so far 'TestDomain:bx:config:scheduler' with 1335468 bytes
-------- summary -------
Sampled 62522 keys in the keyspace!
Total key length in bytes is 2471471 (avg len 39.53)
Biggest list found 'TestDomain:bx:urlList' has 457676 items
Biggest string found 'TestDomain:bx:config:scheduler' has 1335468 bytes
Biggest set found 'TestJobSet' has 300 members
208 lists with 2408539 items (00.33% of keys, avg size 11579.51)
0 hashs with 0 fields (00.00% of keys, avg size 0.00)
62283 strings with 32642667 bytes (99.62% of keys, avg size 524.10)
0 streams with 0 entries (00.00% of keys, avg size 0.00)
31 sets with 1354 members (00.05% of keys, avg size 43.68)
0 zsets with 0 members (00.00% of keys, avg size 0.00)
从输出结果我们可以看到,每种数据类型所占用的最大长度或含有最多成员的 key 是哪一个,以及每种数据类型在整个实例中的占比和平均大小及成员数量。
其实,使用这个命令的原理就是 Redis 在内部执行了 SCAN 命令,遍历整个实例中所有的 key,然后针对 key 的类型,分别执行 STRLEN、HLEN、LLEN、SCARD、ZCARD 命令,来获取 String 类型的长度、集合类型(Hash、List、Set、ZSet)的成员个数.
注意,使用该--bigkeys进行大key的统计时要注意:
- 对于集合类型的Hash、List、Set、ZSet仅仅统计的是包含的成员个数,个数多并代表占用的内存大,仅仅是个参考;
- 对于高并发访问的集群,使用该命令会造成QPS增加,带来额外的性能开销,建议在业务低峰或者从节点进行扫描。
那针对 bigkey 导致延迟的问题,有什么好的解决方案呢?
1)对大Key进行拆分
如将一个含有数万成员的HASH Key拆分为多个HASH Key,并确保每个Key的成员数量在合理范围。特别是在RedisCluster架构下中,大Key的拆分对各节点间的内存平衡能够起到显著作用。
2)优化使用删除Key的命令。
Redis自4.0起提供了UNLINK命令,该命令可以替换DEL,能够以非阻塞的方式放到后台线程中缓慢逐步的清理大Key所占用的内存块,从而减轻了对Redis的影响;
Redis 6.0 以上版本,建议开启 lazy-free 机制(配置参数:lazyfree-lazy-user-del = yes,6.2版本之后默认开启了),这样在 DEL删除大Key时,释放内存的动作也是在后台线程中执行的;
3)尽量不写入大Key首先评估使用其他的存储形式,比如文档性数据库 MongoDB等;如果还无法避免使用BigKey,可以将大Key进行压缩后存储,并尽量根据业务精简Value的内容;建议单个Key的大小不要超过1K;
4.不合理使用批处理命令
网上有不少关于批量处理的一些优化,使用mget、mset代替多次的get、set等,减少网络IO开销以此提高redis的处理效率,特别是对于一些php短连接效果尤其明显。
但是对于这些批量处理命令原生的mget、mset,非原生命令如pipeline,一定要注意控制单次批量操作的元素个数,否则会阻塞其它请求命令!建议控制在500以内。针对该种场景的优化方案:
- 降低使用 O(N) 以上复杂度的命令,对于数据的计算聚合操作等可以适当的放在应用程序侧处理;
- 使用O(N) 复杂度的命令时,保证 N 尽量的小(推荐 N <= 500),每次处理的更小的数据量,降低阻塞的时长;
- 对于Hgetall、Smembers操作的集合对象,应从应用层面保证单个集合的成员个数不要过大,可以进行适当的拆分等。
5.大批量Key集中过期
经常遇见反馈我的应用没有上线变更调整,但是访问的Redis经常出现超时的问题。分析后现象大部分表现为:超时问题出现的时间点有规律,比如每隔一个小时出现一次,或者每天零点过后发生。
如果出现了这种情况,那么需要从两个方面排查一下:
- 是否有定时任务的脚本程序,定时或者间隔性的操作Redis
- Redis的Key数量出现集中过期清理
第一种情况这里不做过多解读,重点分析下Redis的Key数量为什么会出现集中过期,集中过期为什么会造成Redis的访问变慢。
这就需要我们了解 Redis 的Key过期策略是怎样的。Redis 的过期数据采用被动过期 + 主动过期两种策略:
- 被动过期:只有应用发起访问某个key 时,才判断这个key是否已过期,如果已过期,则从Redis中删除
- 主动过期:在Redis 内部维护了一个定时任务,默认每隔 100 毫秒(1秒10次)从全局的过期哈希表中随机取出 20 个 key,判断然后删除其中过期的 key,如果过期 key 的比例超过了 25%,则继续重复此过程,直到过期 key 的比例下降到 25% 以下,或者这次任务的执行耗时超过了 25 毫秒,才会退出循环
注意:Redis的key主动过期清理的定时任务,是在 Redis 主线程中执行的,也就意味着会阻塞正常的请求命令。进一步说就是如果在执行主动过期的过程中,出现了需要大量删除过期 key 的请求,那么此时应用程序在访问 Redis 时,必须要等待这个过期任务执行结束,Redis 才可以继续处理新请求。此时现象就是上面说的应用访问 Redis 延时突然变大了。
特别是由于批量清理Key这个操作的命令是内部发起的并不会记录在慢日志中,但我们的应用程序却感知到了延迟变大,其实时间都花费在了删除过期 key 上,这种情况就经常被忽略。
如果确实是集中过期 key 导致的访问变慢,那么可以采用如下处理方案:
业务Key设置过期时间时,预计的过期时间加上一个随机过期时间段,比如5分钟,将集中过期时间打散,降低 Redis批量清理时的压力。
由于这种情况分析比较麻烦,强烈建议对过期key的数量进行监控,对于短时间过期较多key的情况进行预警,通过执行info命令获取过期Key数量【expired_keys】的统计值:
# Stats
total_connections_received:1359356
total_commands_processed:2705619999
instantaneous_ops_per_sec:157
total_net_input_bytes:232498789314
total_net_output_bytes:279219680360
instantaneous_input_kbps:11.01
instantaneous_output_kbps:17.07
rejected_connections:0
sync_full:2
sync_partial_ok:1
sync_partial_err:0
expired_keys:215099347
evicted_keys:0
keyspace_hits:984222771
keyspace_misses:610235483
pubsub_channels:1
pubsub_patterns:0
latest_fork_usec:9484
说明:expired_keys为一个累计值,可以在监控系统中配置为1分钟的增加值,当1分钟过期的key超过一定阈值时进行预警。
6.预估内存不足,使用的数据内存达到了最大值
由于服务器内存有限,一般使用Redis时都会配置当前实例可用的最大内存maxmemory,那么当使用的内存达到了 maxmemory 后,虽然配置了数据的自动淘汰策略,但是在此之后每次写入新数据,操作延迟都会变长。核心原因在于,当 Redis 内存达到 maxmemory 后,每次写入新的数据之前,Redis 必须先从实例中剔除一部分数据,让整个实例的内存维持在 maxmemory 之下,然后才能把新数据写进来。这里很多同学会有误解,以为只要配置了maxmemory就可以了,实际上由于Redis特殊的清理策略,无法避免会对正常的使用造成影响!
为了降低内存自动清理对服务的影响,可以配置Redis的最大内存数据清理策略,主要有以下几种:
- allkeys-lru:清理最近最少使用(LRU)的Key,不管 key 是否设置了过期时间
- volatile-lru:清理最近最少使用(LRU)的Key,但是只回收有设置过期的Key
- allkeys-random:随机清理部分Key,不管 key 是否设置了过期时间
- allkeys-lfu:不管 key 是否设置了过期,清理访问频次最低的 key(4.0+版本支持)
- volatile-lfu:清理访问频次最低且设置了过期时间 key(4.0+版本支持)
- volatile-random:随机清理部分设置了过期时间的部分Key
- volatile-ttl:清理有设置过期的Key,尝试先回收离 TTL 最短时间的Key
- noeviction:不清理任何Key,当到达内存最大限制时,当客户端尝试执行命令时会导致更多内存占用时直接返回错误(大多数写命令,除了 DEL 和一些例外)。
使用哪种清理策略,我们需要根据实际的应用场景来选择,比如有些业务用于存储强调准确性,即使访问有损了也不能逐出数据,那么就要配置noeviction;还有些业务是缓存,有些清理那些早期写入的Key,则可以选择volatile-lru或allkeys-lru。
介绍下常使用的是 allkeys-lru / volatile-lru 的淘汰策略,它们的处理逻辑是,每次从实例中随机取出一批 key(maxmemory-samples控制数量),然后淘汰一个最少访问的key,然后把剩余的 key 暂存到一个池子中,继续随机取一批 key,并与之前池子中的 key 比较,再淘汰一个最少访问的 key。以此循环往复,直到实例内存降到 maxmemory值以下才停止。所以这段时间是会影响新的数据写入的,应用层就会有超时或者请求响应变慢的问题发生。
针对内存达到上限的情况,可以采用如下优化方案:
- 合理预估内存占用,避免达到内存的使用上限。这里有两种方法可以参考:
(1)根据写入Key的类型、数量及平均大小计算预估,不同的数据类型有不同的数据结构及编码方式,后续开文专门介绍;
(2)写入一小部分比例的真实业务数据,然后进行预估。
- 设置合理的Key过期时间,满足业务的最小保留时间即可。
- 数据量过大建议拆分成多套Redis或者使用RedisCluster分片集群,建议单集群最大内存不超过20G。
- 数据清理策略改为随机模式,随机清理比 LRU 要快很多(不过这个要根据业务情况评定,业务优先满足原则)。
- 如果使用的是 Redis 4.0 及以上版本,开启 layz-free 机制,把淘汰 key 释放内存的操作放到后台线程中执行(配置 lazyfree-lazy-eviction = yes)
- 增加剩余可用内存的监控,提前预警并进行最大内存上限的扩容或者提前清理释放内存。
7.实际请求量超过了Redis的处理能力
Redis处理速度再快,也有达到上限的时候。特别是一些大促活动时,业务流量往往出现暴涨,很容易就会达到Redis的处理瓶颈。这种在业务上的表现除了访问Redis变慢,一些简单的命令如get、set也开始出现在慢日志中。
这时如果查看Redis的CPU使用情况,基本是100%的状态,那么大概率就是达到Redis的处理能力上限了。
为了解决这种问题,就需要评估当前集群的处理吞吐力,参考官方的测评结果QPS 10W行不行?我们在上一篇文章介绍基本的压测有过说明,每个Redis所在的服务器配置不一样,处理能力就不一样。
更进一步说,每个Redis承载的服务模型不同,比如使用的命令类型、访问比例等,那么处理的吞吐也会有很大不同。针对这种情况最好的方案就是业务上线前,可以模拟真实的业务进行压力测评,给出一个大概的吞吐处理能力。如果评估单节点无法承载过多请求,建议进行读写分离架构或者拆分为多套集群扩容.
最后就是不要忽略运维监控,可以对使用的CPU使用率、访问的QPS等进行有效监控,提前发现是否达到集群的处理瓶颈,并决定是否进行扩容或架构调整。
说明:Redis监控指标还是比较多的,不管是性能指标、内存使用、持久化、网络连接等,后面会专门发文介绍,大家到时也可以关注下。
看完本文有收获?请转发分享给更多人
关注「数据库架构师」,提升数据库技能