从解决Redis访问超时的问题谈起——故事比结果要精彩
这周终于解决了Redis访问经常超时的问题,终于可以踏实睡觉了。从上周就开始纠结在这个问题上,可以用寝食难安来形容,感觉这个问题就像个定时炸弹一样,虽然根据手搜的访问量,极少的Timeout Error对用户基本不会造成影响,但是这种问题如果不及时遏制导致Redis整体被拖慢造成的危害是相当严重滴!毕竟在手搜的架构中,Redis是极其重要的一环。
先来讲个故事(说说问题上下文),有兴趣来手搜工作的Python程序员认真听:
前段时间我们新上了个业务,为了满足需求我们对进程级的cache做了些改动,根据业务对cache key进行了拆分,导致的情况是同一份数据会被缓存多份,只因为展示类型不同(可简单理解为不同的展示用的key不同,数据相同)。有经验的同学应该能够想到这么做的结果会是怎么样的——同一份数据会请求两次Redis(因为cache key不同)。(可以看的出来,对于多种展示需求的业务在展示层做cache并不合适) 功能测试没问题,so,上线。晚高峰来了。运维同学和值班同学被报警短信“轰炸”了,大量应用服务器端口访问超时(Nagios监控)。因为那天只有我们的改动上线了,所以可以定位为我们代码的问题。遂,备机上。 次日看日志发现大量的Redis的Timeout Error(timeout=1s),虽然日志中在我们这个系统上线之前就已经存在很多这种日志,显然我们这次改动的上线加剧了这一状况。
从直观的感觉上,我们的功能导致Redis请求增多,因此这时就聚焦在如何较少Redis的请求上。 在分析了一个页面的加载到底会请求多少次Redis之后发现一个很大的问题,因为针对具体业务的设计,一个页面在加载时访问Redis的次数跟页面上展示的内容成正比。因此页面上的内容越多,对Redis的请求会越频繁,因此一次正常的页面访问很可能会产生100次甚至更多的Redis请求。 问题已经很明显了,因为这种设计,必须完全依赖本地缓存,无缓存情况访问页面的速度是不可接受的。
知道问题在哪就好办了,经过不断的思考和尝试,找到一种方案。减少页面上的内容肯定是不行的,那么就只能合并请求了。
说了一大堆,其实问题的本质在于要把之前串行的Redis请求换成并行。另外值得一说的是Redis本身的响应是非常快的,另外还有一些时间消耗在网络上。 串行换成并行的方案可以简单理解为把之前的: GET 1, GET 2 换成: MGET 1 2 这样。改完之后在无缓存的情况下页面加载时间几乎提升了一倍。 OK,既然可行,那就测试,在测试,上线。Yeah!没人被短信轰炸。
你以为故事到这里就结束了,那就太Naive了。Nagios监控只是用来看端口访问是否符合预期的(1s内响应),并不能发现所有的问题。
上线几天之后发现状态码为500的情况增多了,且波动很大。So,继续看日志(有人问,你们不是用Sentry吗?为毛看日志?呃,内个,内个,业务调整...)。 咱们还是来看日志吧,依然是大量的Timeout Error,还是Redis的问题。依据上面的方案我们已经完全减少了Redis请求次数,为何还是慢。
有兴趣的同学可以思考下,为何还是慢?之前的方案会造成哪种影响。
喝口水,继续讲故事:(终于明白知乎上为何经常有人讲故事了,讲故事原来比写正经文章要容易)
为何还是慢呢?之前的改动相当于是把100次请求合并成1次,造成的影响是,虽然减少了网络上的耗时,但是会增加Redis的耗时。假设之前每次请求读10个key(mget),合并成1次请求就变成一次读取1000个key。这块可能是问题原因,那么继续定位吧。 要怎么确定呢?Redis并不熟,但是MySQL有Slowlog,Redis应该也有。于是找到了几个命令:
CONFIG GET slowlog-log-slower-than (10000us,Redis中的执行单位是微秒,相当于10ms) CONFIG GET slowlog-max-len (最多存储128条慢日志,应该是默认的吧?) SLOWLOG LEN (128, 靠,存满了) SLOWLOG GET (默认去10条)然后看到了慢日志出现的频率非常高!并且MGET命令中key的数量相当大——1w多(不是上面那个方案导致的),执行耗时在50ms左右。 好了,再次Get到问题所在,解决吧。 然后发现无法从这个日志上去找是哪个项目产生的,因为Redis不记录这个日志产生的client ip。现在回忆知道是哪台服务器发的请求也定位不到。因为这种错误的产生就不是正常业务逻辑代码产生的。
具体调试过程简单概括为:在所有可能产生巨量MGET请求前添加判断,打印log到sentry(没错,我们又上了Sentry服务),主要还是在获取列表的部分(其实最后查到问题的根源完全跟列表没关系),依然没有捕获到任何日志。于是开始到Redis服务器tcpdump所有请求,定位到一台服务器,至少知道了是哪个项目产生的。然后就是运气了,不断的查看正常访问日志。终于发现了问题。太TMD走运了,那一刻都要哭了。这个结果完全不在所有的预料情况之中。
从MGET的key上可以发现所有的新闻都是属于某一个频道,所以我们的预期是这些请求一定是在访问这些新闻所属频道列表页面时产生的。最终发现跟列表页面没半毛钱关系。
看吧,这就是“大型”系统了,充满了不可预测。这样真的不好!
这个问题产生的原因是某个页面中有一部分内容是可以指定获取某频道数据的,但是这部分内容又是存在Redis中的。好吧,之前完全没考虑到会有这种间接的请求。
知道具体问题了,顺藤摸瓜,找到了对应的存在Redis中的内容,奇怪的是这部分内容没有设置要从某个频道获取多少条数据。这个内容的模型大致是这样的:
class Content: channel = int() data_size = int()设置了频道,没设置要获取的数量,默认是0。
故事结束,来看代码。
假设我的这个blog系统用的是Redis,那么怎么处理某个频道的分页呢?因为是非关系型数据库。因此在存储数据的时候,我得这样:
import redis cache = redis.StrictRedis(host='localhost', port=6379) for post in posts: cache.set('blog:post:%s:title' % post.id, post.title) cache.zadd('blog:channel:1',time.time(), post.id)
这样存下去如果我要从这个频道(假设id为1)下获取十条数据:
start = 0 count = 10 post_ids = cache.zrange('blog:channel:1', start, start + count - 1) post_tiltes = cache.mget(map(labda _id: 'blog:post:%s:title' % _id, post_ids))
这样的代码有问题吗?
咋看之下没有问题,因为redis的zset是从0开始计数的,获取10个元素,那就是0到9。那么看下这个描述:
Both start and stop are zero-based indexes, where 0 is the first element, 1 is the next element and so on. They can also be negative numbers indicating offsets from the end of the sorted set, with -1 being the last element of the sorted set, -2 the penultimate element and so on.
大意是,索引从0开始,-1表示最后的一个位置。因此如果你想获取某个频道(id为1)的所有文章id就是这样:
post_ids = cache.zrange('blog:channel:1', 0, -1)
因为-1表示最后一个元素的位置。
再回到上面故事的结尾,那块内容要获取的频道新闻数量为0,而我们的代码和我写的类似,没对这种-1可能性做判断,因此一次就取出来频道下的所有新闻,so,产生了那个结果。
总结时间
在复杂的业务下的复杂的系统结构中,要找到一个隐藏的问题确实不容易,因为写代码的人不断的变化,负责业务的人也在不断变化,最后都不知道这个业务为何存在,是否还在使用,也不知道这段代码是否正常,毕竟每个人还在负责新的业务,不断的添加新的代码。这样的结果就是系统进一步复杂,这样下去的结果就是再经过几年人员的变迁之后,任何一个人想要理解这个系统(这些系统)中的业务逻辑,代码逻辑都将变的十分困难。
代码应该是历史的结果,不应该把历史平铺到代码中,git log才是历史应该存在的地方。 说人话就是:业务的增加不仅仅是增加代码,有时也是要对现有结构进行抽象和重构。