Redis开发规范一二三

本文为《Redis运维经验一二三》的姐妹篇,主要阐述使用redis开发标准化的规范,简化运维成本,提升工作效率。

你首先需要了解的东西

在使用redis之前你首先要了解:①redis是单线程作业,所谓的并发是通过epoll实现的并发活跃连接;②redis与memcached相对优点明显,缺点不明显,因此还在犹豫的同学放心的选择redis吧;③在实际生产中因为客户端效率以及各节点通讯开销,redis几乎不可能达到官网上写的10w的qps;④在实际的使用过程中,redis最大的瓶颈一般是CPU,由于它是单线程作业所以很容易跑满一个逻辑CPU,可以使用redis代理或者是分布式方案来提升redis的CPU使用率。

切忌多个应用使用一个redis实例

就如我们上面所说,redis是单线程作业,因此不要把多个应用放在一个redis实例,这样会降低每个业务的吞吐量,必要的时候一个应用我们也可以针对不用的功能点使用多个redis实例。

事先做好内存容量规划

在申请一个redis实例之前一定要对自己的容量需求有一个清楚的了解,因为redis在持久化过程中可能会使用额外的内存造成操作系统swapped,这将是一种毁灭性的故障现象,默认情况下我们会将一台服务器上所有redis的内存上限设置为:(物理内存-4G)*70%,当然这个70%不是固定的,会根据读写频率进行适当调整。

如果你不能准确计算你的redis实例所需内存容量,那就测试吧,按照实际的生产需求生成一定数量的数据,然后给出一个相对准确的数据。

注:redis在bgsave的时候cow使用的内存并非写多少数据用多少内存,redis一次申请的内存单位是页(默认是4k),因此在高吞吐又是随机性很高的写操作的背景下,cow使用一倍的内存不是不可能出现。

选择合适的数据类型

Redis提供了string、hash、list、set、sortset五种数据类型,了解各自的应用场景以及功能对于一个开发者来说非常重要,这个网站或许会给你一些帮助Redis命令参考中文版

举个小小的例子:某个系统中,某位同学把一个对象的属性以json的格式存储在string中,这本来是没有错的。但是,在实际的业务请求中,几乎没有获取全部数据的业务场景,有的只是获取一个ID,获取一个属性这样的需求,但是程序每次都要get这个key,然后在程序中分割输出的value,然后获取所需的数据。其实这是一种很不科学的做法,如果用hash set来存储,每次只需要使用hget来获取指定的field即可,这样做优化了redis的存储,优化了redis的流量输出(在比较简单极端的环境中,大量的value较大的O(1)操作能够打满一台服务器的网卡流量,在生产环境中已经出现过)、优化了php的运算过程,一举数的啊。这说明了正确使用数据类型的重要性。

Key命名很重要

Redis是nosql,所以不要指望他能像rdb那样随心所欲的设计数据结构。Redis只有两层(key-value)或者三层结构(key-field-value),所以一个合适key命名是非常重要的。正常我们可以把以“:“为分割符,把几个属性或者对象名组合成一个key。这里我们举一个例子:我们要模拟这样的业务场景,需要有人员基础信息,有人员积分信息,有好友关系,还需要按照积分来排序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
redis ./redis.sock[2]> set uid:1 'name:aaa,age:28'
OK
redis ./redis.sock[2]> set uid:2 'name:cdf,age:29'
OK
redis ./redis.sock[2]> set uid:3 'name:uud,age:50'
OK
redis ./redis.sock[2]> set uid:point:1 100
OK
redis ./redis.sock[2]> set uid:point:2 110
OK
redis ./redis.sock[2]> set uid:point:3 90
OK
redis ./redis.sock[2]> sadd uid:flist:1 2
(integer) 1
redis ./redis.sock[2]> sadd uid:flist:1 3
(integer) 1
redis ./redis.sock[2]> sort uid:flist:1 by uid:point:*
1) "3"
2) "2"
redis ./redis.sock[2]> sort uid:flist:1 by uid:point:* get uid:*
1) "name:uud,age:50"
2) "name:cdf,age:29"

当然这只是告诉大家key可以这么命名,基于redis本身的命令我们可以这么玩,并不推荐大家大量使用sort这样复杂度为O(N)的请求,另外我们也可以直接在代码里面去组合key的名称来获取相应的value。

注意节约内存

内存是redis的命根子,所以在使用过程中我们需要尽我们所能去节约有限的内存,需要注意的是Redis使用jemalloc作为内存分配器,内存的使用与分配并不是按照实际的key/field/value的字节数分配,而是按照一定的范围分配,详细请自行检索jemalloc size class categories相关信息。因此我们一般可以从这几个方面来节约内存的使用:

  1. Value尽量都是整型,因为纯整型可以直接存储在指针位上,无需额外分配一个sds存储value
  2. 对于同一类对象,且数量不大可以考虑使用hset代替string,因为hset默认在小与512个fields时会使用压缩存储算法,当然我们可以把hash-max-ziplist-entries设置为1000(超过1000时hset的CPU开销会加大),比如:
1
2
set ob:1 123
set ob:2 234

可以改成:

1
2
hset ob 1 123
hset ob 2 234
  1. key的名称也要做到尽量的简单,比如我们2.5举的例子中,key名称中所有的uid:是不是都可以去掉呢?在不影响应用的前提下,去掉可以节省大量内存,特别是存在大量string的时候。

注意请求的时间复杂度

我们说过redis是单线程作业,因此一个复杂度高的请求对于一个高并发低延迟的系统是致命的,它会大大的拉低系统的整体吞吐,如果一定需要请把这些复杂度较高的请求(比如O(N))放在一个slave server。

  1. 案例一:某天某系统发现请求响应十分缓慢,查看php日志发现了大量的redis超时请求,再查看redis发现流量以及tcp请求均属于正常状态,但是redis使用的CPU出现100%现象。于是我们对该实例进行了请求采样,很快就发现了问题:该实例的平均QPS为69.24,大约26.54%的请求是keys请求,每次keys请求平均耗时约为13w微秒,key的总请求时间为该实例总请求时间的99.99%。把一个redis实例搞成69.24的QPS,这证明复杂度为O(N)的请求很快就能整跨一个redis实例、整垮一个系统。屏蔽掉keys请求之后,redis的qps直线上升,运营同学马上报来好消息,系统正常了系统正常了,玩家可以访问了。

  2. 案例二:故事依然是那样的开头,故障排查解决。其中一个优化点就是:将复杂度为O(log(N)+M)的ZREVRANGE操作转换为list的O(1)操作,单单这点优化qps从5000上升到6500,约提升了30%。

减少不必要的请求

很多的代码框架都会产生大量的不必要请求,这个在MySQL使用上很严重,在redis上也经常出现。但是redis的使用通常是在一些高并发低延迟的场景中,因此不必要的请求会大大的拉低有效请求数。比如你的redis的吞吐大约是2w的qps,如果你的不必要请求大约占了40%,那意味着有效的用户请求才12000次/秒,如果干掉这部分不必要请求,那意味着有20000次/秒的有效用户请求,实际的业务吞吐量能提升67%。

  1. 案例一:某业务系统的使用redis,根据开发同学反馈好像系统的吞吐量不高,于是我们对该redis实例进行了请求采样分析。采样100万次请求结果大概是这样的:该实例平均10554QPS,存在一次超过10000微秒的请求,平均响应时间为56.25微秒(Median :56.25,75% :114.0,90%:206.0,99%:561.0),其中“EXISTS”占37.34%请求。原来开发的同学在每次的请求之前都做了一次exists的请求,确认需要执行操作的key是否存在,其实大可不必,因为redis的所有请求对于不存在的key都会有输出返回,所以干掉所有的exists请求以后,在tps不变的情况下,该实例的有效用户请求能够提升:59.6%。

  2. 案例二:同案例一,有一位同学喜欢在每次写数据之前先del一次,不管这个key存不存在(也许是延续使用MySQL开发的好习惯,先drop再create…)。其实大可不必啊,为啥呢?因为redis的写都是覆盖写,无需先删除一次。

合理利用管道操作

Redis提供一个pipeline的管道操作模式,将多个指令汇总到队列中批量执行,可以减少tcp交互产生的时间,一般情况下能够有10%~30%不等的性能提升。但是需要注意的是,pipeline与multi不同,无法保证请求之间的原子性,因此需要考虑使用场景。如果业务场景允许,这也是一个性能提升的点。