Redis使用最佳实践
type
status
date
slug
summary
tags
category
difficulty
icon
password
一、前言
由于业务层主要关注
Redis
的使用,所以这篇文章主要围绕如何高效使用 Redis 来阐述。本文目录如下:
本文可能会不断根据使用中遇到的问题而更新。
二、如何使用 Redis 更省内存?
由于
Redis
会把所有数据保存到内存中,而内存是计算机宝贵的资源。所以,在使用 Redis 时应该尽量节省内存的使用。1)控制 key 的长度
最简单直接的内存优化,就是控制 key 的长度。
在开发业务时,你需要提前预估整个 Redis 中写入 key 的数量,如果 key 数量达到了百万级别,那么,过长的 key 名也会占用过多的内存空间。所以,你需要保证 key 在简单、清晰的前提下,尽可能把 key 定义得短一些。
例如,原有的 key 为
dapi:sign_pickup_member_list:123
,则可以优化为 dapi:sg_pk_mb_ls:123
。如不能简化,也可以使用驼峰来代替下划线命名,如:
dapi:signPickupMemberList:123
。这样一来,就可以节省大量的内存。另外,这个这个方案可以减少网络传输的数据量,减少网络传输的时间等。
另外还可以把一些同类的数据放到一个集合key中,比如我们要把司机信息保存到缓存中,一般一个key对应一个司机信息,如下面的代码:
上面代码使用内存情况如下:
如果改为 Hash 数据结构就可以减少内存的使用,如下代码:
内存使用情况如下:
如果需要对单个 key 设置过期时间的场景,不太适合使用这种方法。另外,这种方式可能会导致bigkey,所以应该考虑好业务是否能够这样做。
2)避免使用 bigkey(大键)
除了控制 key 的长度之外,你同样需要关注 value 的大小,如果大量存储 bigkey,也会导致 Redis 内存增长过快。
除此之外,客户端在读写 bigkey 时,还有产生性能问题(下文会具体详述)。
所以,要避免在 Redis 中存储 bigkey,建议是:
- String:大小控制在
10KB
以下。
- List/Hash/Set/ZSet:元素数量控制在
10000
以下。
3)选择合适的数据结构
选择合适的数据结构能够避免不必要的内存消耗,下面举个例子来说明选择合适的数据结构对内存使用的影响。
如果我们要记录司机是否参与过某个活动,一般来说可以用 Set 和 Bitmap 来存储。那么选择哪一种数据结构更节省内存呢?我们可以测试一下:
- 使用Bitmap存储
$ 127.0.0.1:6379> setbit active_map 1000000000 1
查看进程的内存使用:
从上面结果看到,只存了一个司机ID就使用了系统 6.3% 的内存。
- 使用Set存储
$ 127.0.0.1:6379> sadd active_map 1000000000 1
查看进程的内存使用:
从结果可以看出,使用Set存储只使用了系统 0.3% 的内存。
要找到上面问题的原因,就要从 Bitmap 的原理入手。由于 Bitmap 每个位对应着一个 offset,所以当 offset 很大的时候(如使用司机ID作为 offset 时),就不适合使用 Bitmap 来作为存储。
要避免上面的情况出现,就需要对 Redis 的数据结构有较深的理解。
另外
String
、Set
在存储 int 数据时,会采用整数编码存储。Hash
、ZSet
在元素数量比较少时(可配置),会采用压缩列表(ziplist)存储,在存储比较多的数据时,才会转换为哈希表和跳表。作者这么设计的原因,就是为了进一步节约内存资源。
那么你在存储数据时,就可以利用这些特性来优化 Redis 的内存。这里我给你的建议如下:
String
、Set
:尽可能存储 int 类型数据。
Hash
、ZSet
:存储的元素数量控制在转换阈值之下,以压缩列表存储,节约内存。
- 单纯用来判断元素是否已经存在(过滤功能),使用
Set
会比Hash
更省内存。
三、如何发挥 Redis 的高性能?
使用 Redis 的目的就是为了提升应用的性能,所以绝对不能因为 Redis 的问题导致应用的性能出现问题。
1)避免使用 bigkey
存储 bigkey 除了前面讲到的使用过多内存之外,对 Redis 性能也会有很大影响,原因如下:
- 由于 Redis 处理请求是单线程的,当你的应用在写入一个 bigkey 时,更多时间将消耗在「内存分配」上,这时操作延迟就会增加。
- 删除一个 bigkey 在「释放内存」时,也会发生耗时。
- 而且,当你在读取这个 bigkey 时,也会在「网络数据传输」上花费更多时间,此时后面待执行的请求就会发生排队,Redis 性能下降。
所以,你的业务应用尽量不要存储 bigkey,避免操作延迟发生。
2)避免使用时间复杂度高的命令
Redis 是单线程模型处理请求,除了操作 bigkey 会导致后面请求发生排队之外,在执行复杂度过高的命令时,也会发生这种情况。
因为执行复杂度过高的命令,会消耗更多的 CPU 资源,主线程中的其它请求只能等待,这时也会发生排队延迟。
所以,你需要避免执行,如:
SORT
、SINTERSTORE
、ZUNIONSTORE
、ZINTERSTORE
等聚合类命令。对于这种聚合类操作,我建议你把它放到客户端来执行,不要让 Redis 承担太多的计算工作。也要避免使用全量扫描的命令,如:KEYS
,HGETALL
、FLUSHALL
/FLUSHDB
等。3)使用 lazy-free 来删除 bigkey
如果你无法避免存储 bigkey,那么我建议你使用 lazy-free 机制来删除。(4.0+版本支持)
当删除一个 bigkey 时,可以使用
unlink
命令来代替 del
命令,unlink
命令删除 key 时,将会放到后台线程中去执行,这样可以在最大程度上,避免对主线程的影响。4)避免集中过期 key
Redis 清理过期 key 是采用定时 + 懒惰的方式来做的,而且这个过程都是在主线程中执行。
如果你的业务存在大量 key 集中过期的情况,那么 Redis 在清理过期 key 时,也会有阻塞主线程的风险。
想要避免这种情况发生,你可以在设置过期时间时,增加一个随机时间,把这些 key 的过期时间打散,从而降低集中过期对主线程的影响。
5)使用pipeline来合并发送多个请求
使用pipeline来打包发送请求能够减少网络开销,如下图:
例如:使用
setex
命令来代替 set
和 expire
命令的组合。6)使用长连接或者连接池
当使用短连接操作 Redis 时,每次都需要经过 TCP 三次握手、四次挥手,这个过程也会增加操作耗时。
同时,你的客户端应该使用连接池的方式访问 Redis,并设置合理的参数,长时间不操作 Redis 时,需及时释放连接资源。
Java连接池解决方案:
在 Java 中,要连接 Redis 并高效地管理连接,通常会使用连接池的方式。例如,使用 Jedis 连接 Redis 时,可以借助 JedisPool 来创建和管理连接池。
四、使用 Redis 时的坑
1)Redis Cluster 对 PHP 的不友好
由于
Redis Cluster
会对节点划分为多个槽,并且会根据 key 来分配到不同的槽中。但 Redis Cluster 是一个去中心化的集群,也就是说对集群中任意一个节点进行操作都一样,因为集群内部有节点对应的槽信息。一般来说,对于
Java
、Go
这些语言编写的程序会常驻内存,所以会缓存一份节点与槽相关的信息。但
PHP
这种不是常驻内存的进程,就不会缓存节点与槽相关的信息,可能会出现读写到错的节点,从而导致重定向正确的节点。由于这个重定向的过程需要重新连接到新的节点,所以会导致 CPU 飙升的情况。解决这个问题需要在 PHP 连接 Redis 时指定节点对应的槽信息,从而解决重定向导致的性能损失。
2)HotKey问题
HotKey
是指某一个 key 被频繁访问,由于同一个 key 只会被存储到同一个节点。所以,HotKey 会导致集群中某个节点的 CPU 飙升,从而出现性能问题。解决这个问题,可以把
HotKey
冗余多份数据。如:bannerInfo_0
、bannerInfo_1
、bannerInfo_2
... bannerInfo_99
冗余100份数据,然后通过用户ID或者生成一个随机数来映射到不同的 key 进行读取,从而减轻局部节点的压力。如下代码:3)雪崩现象
使用缓存的目标是为了减少对数据库或者下游接口的访问,但是有时候 Redis 集群可能会不可用(宕机、网络中断问题),这时会导致直接访问数据库或者下游接口。这就是雪崩现象,如下图:
缓存雪崩可能会导致数据库或者下游服务直接被大量的流量打死,所以我们要保证在缓存雪崩后不会对数据库或者下游服务造成崩溃。
解决雪崩就是要保护数据库和下游服务,方案如下:
- 我们可以在访问数据库或者下游服务时,限制访问的流量,比如增加一个计数器来统计对数据库的访问次数,如果超出某个访问数量,就直接返回错误信息,从而保护数据库。
- 可以使用 熔断器 来保护数据库和下游服务,熔断器的作用就是统计访问下游服务的情况,当下游服务不可用的比例超过一定的比例后,就会禁止访问下游服务,直接返回错误信息,从而保护下游服务。
- 使用限流器来访问数据库和下游服务,当要访问数据库或者下游服务时,我们可以先从限流器中获取访问的资格(如从令牌桶中获取到令牌),然后再去访问数据库或者下游服务器。
4)缓存穿透
缓存穿透与缓存雪崩有点类似,但却不完全相同,缓存雪崩是整个缓存服务都不可用,而缓存穿透只是部分缓存失效。
当某些缓存失效时(过期或者缓存key不存在),会直接访问数据库或者下游服务,这也会对数据库和下游服务造成一定的压力。有些黑客可以通过伪造一些不存在的key来访问接口,从而导致大量请求直接打到下游服务或者数据库。
要解决缓存穿透问题,可以使用以下方案:
- 当访问不存在的数据时,也在 Redis 中缓存一个空对象,这样就可以避免直接访问数据库。如下图:
这种方案有2个问题:
- 就是如果空值能够被缓存起来,这就意味着缓存需要更多的空间存储更多的键,因为这当中可能会有很多的空值的键。
- 即使对空值设置了过期时间,还是会存在缓存层和存储层的数据会有一段时间窗口的不一致,这对于需要保持一致性的业务会有影响。
- 增加一层过滤层,先使用 布隆过滤器 来对key进行过滤。当key在布隆过滤器中不存在,那么就直接返回空对象。如果存在就先从缓存层读取数据,如果缓存层不存在,就从数据库中读取数据,并且设置到缓存层。如下图所示:
5)数据预热
由于在应用刚启动时,缓存还没有被更新,所以这时可能会导致大量的流量直接访问到数据库或者下游服务。
为了解决这个问题,我们应该考虑在应用刚启动时,是否需要对缓存进行预热。
6)分布式锁问题
分布式锁是为了防止多个进程同时进入临界代码而创建的,也就是用来保护某段公共数据,如下图所示:
一般使用 Redis 来实现分布式锁时需要注意以下问题:
- 上锁时必须使用原子操作命令,如命令:
set(key,1,30,NX)
,否则将可能因为在上锁过程,进程突然崩溃而导致死锁。如下面代码可能会导致死锁:
由于在上锁过程中分为
setnx
命令和 expire
命令两个命令完成,所以在上锁过程中可能因为进程崩溃而导致 expire
命令失败,而导致死锁。也就是说,上锁过程必须使用原子命令来完成。- 由于临界代码块处理时间太长,而超出了锁的超时时间,从而锁被动释放。这时其他请求就会获得锁,从而导致临界代码块被重入了。要解决这个问题,可以在获取锁后,创建一个守护线程,守护线程用来把锁的超时时间延长,如下图所示:
- 如果不幸发生了上述情况,就是临界代码块被重入了,这时可能会出现如下情况:
上述的情况会导致,其他线程删除了不属于自己的锁。解决这个问题可以使用以下方法:
但由于解锁的时候使用的是非原子命令,所以也可能会出现问题。可以使用 Lua 脚本来将这两个命令一起执行,从而解决因为非原子命令导致的并非问题。
五、CheckList
场景 | 达成 | 备注 |
缓存 key 是否简洁(在保证可读性的情况下,减少缓存key的字节数) | • [ ] | ㅤ |
是否存在大key?是否有避免大key的方案(例如打散)? | • [ ] | ㅤ |
在使用缓存的时候是否有考察过所使用的数据结构是否合理? | • [ ] | ㅤ |
是否使用了高复杂度的命令,如 hgetall、mgets、keys 等?是否能够改为其他命令? | • [ ] | ㅤ |
如果必须使用高复杂度的命令,是否评估过对 Redis 的性能影响。 | • [ ] | ㅤ |
key 是否都设置了过期时间?(如果不能设置超时时间,可以在代码注释原因) | • [ ] | ㅤ |
key的过期时间是否存在集中过期?(减少集中缓存穿透,可以增加个随机数) | • [ ] | ㅤ |
是否存在hotkey?hotkey是否已经冗余多份数据解决节点被集中访问问题? | • [ ] | ㅤ |
缓存雪崩是否有容灾方案? | • [ ] | ㅤ |
缓存穿透是否有保护机制? | • [ ] | ㅤ |
是否需要缓存预热?是否已经进行缓存预热? | • [ ] | ㅤ |
连接 Redis cluster 时是否有指定槽点?(避免CPU使用率暴增) | • [ ] | ㅤ |
删除大key时是否使用了懒删除(unlink)?(避免阻塞其他请求) | • [ ] | ㅤ |
- 作者:Episkey
- 链接:https://episkey.top/article/llredis
- 声明:本文采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处。