背景
最近线上的核心redis实例,频繁出现指令执行慢的问题,导致很多业务超时,一些业务监控频繁出现超时告警。因此写篇文章分享一下排查过程,并跟大家分享几个实用的指令,然后借此讨论一下redis的内存淘汰策略,以及redis是否是单线程,还有业务上context的设置等问题。
问题及原因分析
redis在我们当前的项目中,大多都是用于缓存,以及分布式锁等场景。一些接口首先会去redis查数据或获取锁并且context会设置超时时间,当redis超时或获取锁失败时接口就会报错。恰巧最近核心的redis实例,频繁出现指令执行慢的问题,最后导致业务告警不断。查看redis的慢查询日志发现大量的keys指令(keys指令是模糊匹配,会遍历当前db中所有的key,时间复杂度为O(n))
这些keys指令几乎都在相同时刻内打到redis,并且耗时大都在250ms左右。我们都知道redis是单线程,当有n个这种keys指令并发的请求到redis时,假定每次keys指令消耗x毫秒,其他业务指令就会因此等待至少 n*x毫秒。所以可以判定就是这部分keys指令导致redis响应超时。这部分keys指令来自我们后管服务,当运营同学在后管新增或修改了部分业务数据时,后管这边就会去查询redis中关联的缓存,并删除掉旧的缓存数据。这是历史遗留的设计。之所以 最近 会导致redis超时,通过看 n*x 这个式子,可以知道要么n增加了,要么x增加了。最终排查发现是有同学近期将业务db存放了用户维度的key,导致key数量暴涨,每次keys指令的执行时间 x 变长了很多
解决方案
解决方案很简单,从以下几方面入手:
- 将用户维度的数据存放到新的db,跟业务数据缓存db区分开来
- 由于keys指令的O(n)的特性,线上禁用keys指令,改用scan指令【scan指令的原理及注意事项】
- 大key的删除del,也有可能导致redis主线程响应延迟,所以线上del命令统一改用unlink异步删除。unlink的原理其实有点类似于用 空间换时间,当执行unlink时主线程会将当前key失效掉(业务就访问不到),然后由子线程异步的清除当前key所占用的内存空间,在此期间如果有新的业务写这个key,redis会分配新的内存空间。需要注意的是,并非所有key的unlink操作都是异步的,对于string类型(由于其占用的内存是连续的),或set或hash等数据结构(如果其元素个数不超过64),删除这些key并不会消耗特别多的时间,主线程就会进行同步删除。【redis非阻塞删除】
关于redis
redis是否单线程
通过上面unlink的异步删除原理,可以知道redis其实并非单线程。我们通常所说redis是单线程的,指的是Redis中处理网络请求和指令的执行是单个线程串行操作的。而其他的如数据持久化、异步删除、集群数据同步等,是由额外的线程执⾏的。至于为什么网络请求和指令的执行,只用单个线程来执行。是因为采用多线程就要考虑并发问题,就要引入新的并发机制保证Lua 脚本、事务的原⼦性等(比如mysql采用了多线程就要引入MVCC等机制保证并发的安全)。当然随着网络硬件性能的不断提升,redis在6.0版本针对网络请求也引入了多线程机制,但是对于指令的执行还是只有一个线程串行执行。所以对于我们这个场景,可见即使采用redis6.0的多线程模式,也解决不了
redis的内存淘汰策略
对于redis缓存,我们通常都会设置一个key的过期时间,但是key一旦过期了,就会被立刻删除吗?(注意:这里说的删除 是指内存的清空,key一旦过期,从外部就访问不到这个key)其实对于过期的key redis主要采用两种内存淘汰机制,一个是惰性删除,一个是定期删除。惰性删除顾名思义就是这个key过期之后,只有被再次访问到时(get, set, keys, ttl ...)其占用的内存才会被回收。定期删除就是有个子线程会定期的执行,删除掉过期的key。当然当有大量key过期时,仅有这两种机制还是会有可能导致redis内存超标,所以当超过最大内存时,也可以设置redis的内存淘汰策略。更详细的描述可以参考 这篇文章
业务接口context的合理设置讨论
如下 这个业务接口:先设置一个带有超时时间的context,然后传递此context,访问redis获取缓存数据,如果获取缓存失败或者不存在则会传递同样的context去查数据库获取数据。
因为查询数据库和redis用的是同一个context,当redis超时时(默认建立此redis客户端是会设定读写超时时间,以及重试次数等策略),此context也有可能已超时,再将此超时的context用于数据库查询,可能导致此时数据查询失败。最后呈现的结果就是redis超时,导致数据库也无法兜底,接口返回报错。较为合理的设置的话,我认为有两点。
- 还是只用一个context,合理设置context的超时时间(1秒感觉太少)
- 在redis查询失败后,查询数据库之前,基于context.Background 重新设置一个带有新的超时时间的context用于查询数据库