Redis使用最佳实践

type
status
date
slug
summary
tags
category
difficulty
icon
password

一、前言

由于业务层主要关注 Redis 的使用,所以这篇文章主要围绕如何高效使用 Redis 来阐述。本文目录如下:
notion image
本文可能会不断根据使用中遇到的问题而更新。

二、如何使用 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对应一个司机信息,如下面的代码:
上面代码使用内存情况如下:
notion image
如果改为 Hash 数据结构就可以减少内存的使用,如下代码:
内存使用情况如下:
notion image
如果需要对单个 key 设置过期时间的场景,不太适合使用这种方法。另外,这种方式可能会导致bigkey,所以应该考虑好业务是否能够这样做。
2)避免使用 bigkey(大键)
除了控制 key 的长度之外,你同样需要关注 value 的大小,如果大量存储 bigkey,也会导致 Redis 内存增长过快。
除此之外,客户端在读写 bigkey 时,还有产生性能问题(下文会具体详述)。
所以,要避免在 Redis 中存储 bigkey,建议是:
  • String:大小控制在 10KB 以下。
  • List/Hash/Set/ZSet:元素数量控制在 10000 以下。
3)选择合适的数据结构
选择合适的数据结构能够避免不必要的内存消耗,下面举个例子来说明选择合适的数据结构对内存使用的影响。
如果我们要记录司机是否参与过某个活动,一般来说可以用 Set 和 Bitmap 来存储。那么选择哪一种数据结构更节省内存呢?我们可以测试一下:
  1. 使用Bitmap存储
$ 127.0.0.1:6379> setbit active_map 1000000000 1
查看进程的内存使用:
notion image
从上面结果看到,只存了一个司机ID就使用了系统 6.3% 的内存。
  1. 使用Set存储
$ 127.0.0.1:6379> sadd active_map 1000000000 1
查看进程的内存使用:
notion image
从结果可以看出,使用Set存储只使用了系统 0.3% 的内存。
要找到上面问题的原因,就要从 Bitmap 的原理入手。由于 Bitmap 每个位对应着一个 offset,所以当 offset 很大的时候(如使用司机ID作为 offset 时),就不适合使用 Bitmap 来作为存储。
要避免上面的情况出现,就需要对 Redis 的数据结构有较深的理解。
另外 StringSet 在存储 int 数据时,会采用整数编码存储。HashZSet 在元素数量比较少时(可配置),会采用压缩列表(ziplist)存储,在存储比较多的数据时,才会转换为哈希表和跳表。
作者这么设计的原因,就是为了进一步节约内存资源。
那么你在存储数据时,就可以利用这些特性来优化 Redis 的内存。这里我给你的建议如下:
  • StringSet:尽可能存储 int 类型数据。
  • HashZSet:存储的元素数量控制在转换阈值之下,以压缩列表存储,节约内存。
  • 单纯用来判断元素是否已经存在(过滤功能),使用 Set 会比 Hash 更省内存。

三、如何发挥 Redis 的高性能?

使用 Redis 的目的就是为了提升应用的性能,所以绝对不能因为 Redis 的问题导致应用的性能出现问题。
1)避免使用 bigkey
存储 bigkey 除了前面讲到的使用过多内存之外,对 Redis 性能也会有很大影响,原因如下:
  1. 由于 Redis 处理请求是单线程的,当你的应用在写入一个 bigkey 时,更多时间将消耗在「内存分配」上,这时操作延迟就会增加。
  1. 删除一个 bigkey 在「释放内存」时,也会发生耗时。
  1. 而且,当你在读取这个 bigkey 时,也会在「网络数据传输」上花费更多时间,此时后面待执行的请求就会发生排队,Redis 性能下降。
notion image
所以,你的业务应用尽量不要存储 bigkey,避免操作延迟发生。
2)避免使用时间复杂度高的命令
Redis 是单线程模型处理请求,除了操作 bigkey 会导致后面请求发生排队之外,在执行复杂度过高的命令时,也会发生这种情况。
因为执行复杂度过高的命令,会消耗更多的 CPU 资源,主线程中的其它请求只能等待,这时也会发生排队延迟。
所以,你需要避免执行,如:SORTSINTERSTOREZUNIONSTOREZINTERSTORE 等聚合类命令。对于这种聚合类操作,我建议你把它放到客户端来执行,不要让 Redis 承担太多的计算工作。也要避免使用全量扫描的命令,如:KEYSHGETALLFLUSHALL/FLUSHDB 等。
3)使用 lazy-free 来删除 bigkey
如果你无法避免存储 bigkey,那么我建议你使用 lazy-free 机制来删除。(4.0+版本支持)
当删除一个 bigkey 时,可以使用 unlink 命令来代替 del 命令,unlink 命令删除 key 时,将会放到后台线程中去执行,这样可以在最大程度上,避免对主线程的影响。
notion image
4)避免集中过期 key
Redis 清理过期 key 是采用定时 + 懒惰的方式来做的,而且这个过程都是在主线程中执行。
如果你的业务存在大量 key 集中过期的情况,那么 Redis 在清理过期 key 时,也会有阻塞主线程的风险。
notion image
想要避免这种情况发生,你可以在设置过期时间时,增加一个随机时间,把这些 key 的过期时间打散,从而降低集中过期对主线程的影响。
5)使用pipeline来合并发送多个请求
使用pipeline来打包发送请求能够减少网络开销,如下图:
notion image
例如:使用 setex 命令来代替 setexpire 命令的组合。
6)使用长连接或者连接池
当使用短连接操作 Redis 时,每次都需要经过 TCP 三次握手、四次挥手,这个过程也会增加操作耗时。
同时,你的客户端应该使用连接池的方式访问 Redis,并设置合理的参数,长时间不操作 Redis 时,需及时释放连接资源。
Java连接池解决方案:
在 Java 中,要连接 Redis 并高效地管理连接,通常会使用连接池的方式。例如,使用 Jedis 连接 Redis 时,可以借助 JedisPool 来创建和管理连接池。

四、使用 Redis 时的坑

1)Redis Cluster 对 PHP 的不友好
由于 Redis Cluster 会对节点划分为多个槽,并且会根据 key 来分配到不同的槽中。但 Redis Cluster 是一个去中心化的集群,也就是说对集群中任意一个节点进行操作都一样,因为集群内部有节点对应的槽信息。
一般来说,对于 JavaGo 这些语言编写的程序会常驻内存,所以会缓存一份节点与槽相关的信息。
PHP 这种不是常驻内存的进程,就不会缓存节点与槽相关的信息,可能会出现读写到错的节点,从而导致重定向正确的节点。由于这个重定向的过程需要重新连接到新的节点,所以会导致 CPU 飙升的情况。
解决这个问题需要在 PHP 连接 Redis 时指定节点对应的槽信息,从而解决重定向导致的性能损失。
2)HotKey问题
HotKey 是指某一个 key 被频繁访问,由于同一个 key 只会被存储到同一个节点。所以,HotKey 会导致集群中某个节点的 CPU 飙升,从而出现性能问题。
解决这个问题,可以把 HotKey 冗余多份数据。如:bannerInfo_0bannerInfo_1bannerInfo_2 ... bannerInfo_99 冗余100份数据,然后通过用户ID或者生成一个随机数来映射到不同的 key 进行读取,从而减轻局部节点的压力。如下代码:
3)雪崩现象
使用缓存的目标是为了减少对数据库或者下游接口的访问,但是有时候 Redis 集群可能会不可用(宕机、网络中断问题),这时会导致直接访问数据库或者下游接口。这就是雪崩现象,如下图:
notion image
缓存雪崩可能会导致数据库或者下游服务直接被大量的流量打死,所以我们要保证在缓存雪崩后不会对数据库或者下游服务造成崩溃。
解决雪崩就是要保护数据库和下游服务,方案如下:
  1. 我们可以在访问数据库或者下游服务时,限制访问的流量,比如增加一个计数器来统计对数据库的访问次数,如果超出某个访问数量,就直接返回错误信息,从而保护数据库。
  1. 可以使用 熔断器 来保护数据库和下游服务,熔断器的作用就是统计访问下游服务的情况,当下游服务不可用的比例超过一定的比例后,就会禁止访问下游服务,直接返回错误信息,从而保护下游服务。
  1. 使用限流器来访问数据库和下游服务,当要访问数据库或者下游服务时,我们可以先从限流器中获取访问的资格(如从令牌桶中获取到令牌),然后再去访问数据库或者下游服务器。
4)缓存穿透
缓存穿透与缓存雪崩有点类似,但却不完全相同,缓存雪崩是整个缓存服务都不可用,而缓存穿透只是部分缓存失效。
当某些缓存失效时(过期或者缓存key不存在),会直接访问数据库或者下游服务,这也会对数据库和下游服务造成一定的压力。有些黑客可以通过伪造一些不存在的key来访问接口,从而导致大量请求直接打到下游服务或者数据库。
要解决缓存穿透问题,可以使用以下方案:
  1. 当访问不存在的数据时,也在 Redis 中缓存一个空对象,这样就可以避免直接访问数据库。如下图:
notion image
这种方案有2个问题:
  1. 就是如果空值能够被缓存起来,这就意味着缓存需要更多的空间存储更多的键,因为这当中可能会有很多的空值的键。
  1. 即使对空值设置了过期时间,还是会存在缓存层和存储层的数据会有一段时间窗口的不一致,这对于需要保持一致性的业务会有影响。
  1. 增加一层过滤层,先使用 布隆过滤器 来对key进行过滤。当key在布隆过滤器中不存在,那么就直接返回空对象。如果存在就先从缓存层读取数据,如果缓存层不存在,就从数据库中读取数据,并且设置到缓存层。如下图所示:
notion image
5)数据预热
由于在应用刚启动时,缓存还没有被更新,所以这时可能会导致大量的流量直接访问到数据库或者下游服务。
为了解决这个问题,我们应该考虑在应用刚启动时,是否需要对缓存进行预热。
6)分布式锁问题
分布式锁是为了防止多个进程同时进入临界代码而创建的,也就是用来保护某段公共数据,如下图所示:
notion image
一般使用 Redis 来实现分布式锁时需要注意以下问题:
  1. 上锁时必须使用原子操作命令,如命令:set(key,1,30,NX),否则将可能因为在上锁过程,进程突然崩溃而导致死锁。如下面代码可能会导致死锁:
由于在上锁过程中分为 setnx 命令和 expire 命令两个命令完成,所以在上锁过程中可能因为进程崩溃而导致 expire 命令失败,而导致死锁。也就是说,上锁过程必须使用原子命令来完成。
  1. 由于临界代码块处理时间太长,而超出了锁的超时时间,从而锁被动释放。这时其他请求就会获得锁,从而导致临界代码块被重入了。要解决这个问题,可以在获取锁后,创建一个守护线程,守护线程用来把锁的超时时间延长,如下图所示:
notion image
  1. 如果不幸发生了上述情况,就是临界代码块被重入了,这时可能会出现如下情况:
notion image
notion image
上述的情况会导致,其他线程删除了不属于自己的锁。解决这个问题可以使用以下方法:
但由于解锁的时候使用的是非原子命令,所以也可能会出现问题。可以使用 Lua 脚本来将这两个命令一起执行,从而解决因为非原子命令导致的并非问题。

五、CheckList

场景
达成
备注
缓存 key 是否简洁(在保证可读性的情况下,减少缓存key的字节数)
• [ ]
是否存在大key?是否有避免大key的方案(例如打散)?
• [ ]
在使用缓存的时候是否有考察过所使用的数据结构是否合理?
• [ ]
是否使用了高复杂度的命令,如 hgetall、mgets、keys 等?是否能够改为其他命令?
• [ ]
如果必须使用高复杂度的命令,是否评估过对 Redis 的性能影响。
• [ ]
key 是否都设置了过期时间?(如果不能设置超时时间,可以在代码注释原因)
• [ ]
key的过期时间是否存在集中过期?(减少集中缓存穿透,可以增加个随机数)
• [ ]
是否存在hotkey?hotkey是否已经冗余多份数据解决节点被集中访问问题?
• [ ]
缓存雪崩是否有容灾方案?
• [ ]
缓存穿透是否有保护机制?
• [ ]
是否需要缓存预热?是否已经进行缓存预热?
• [ ]
连接 Redis cluster 时是否有指定槽点?(避免CPU使用率暴增)
• [ ]
删除大key时是否使用了懒删除(unlink)?(避免阻塞其他请求)
• [ ]
上一篇
Kafka原理与实践
下一篇
49. 字母异位词分组

评论
Loading...