0%

Redis 典型应用场景及最佳实践

Redis 的诸如消息队列、分布式锁等典型应用场景介绍。

典型应用场景

消息队列

消息队列 是指利用 高效可靠消息传递机制 进行与平台无关的 数据交流,并基于数据通信来进行分布式系统的集成。通过提供 消息传递消息排队 模型,它可以在 分布式环境 下提供 应用解耦弹性伸缩冗余存储流量削峰异步通信数据同步 等等功能。

点对点消息队列

Redis 列表实现多种 PUSH 和 POP 操作。所以常用来做异步队列使用。将需要延后处理的任务结构体序列化成字符串塞进 Redis 的列表,另一个线程从这个列表中轮询数据进行处理。

简单消息队列

通过 LPUSH & RPOP 或者 RPUSH & LPOP 的方式,可以构建简单的消息队列。但会存在一个性能风险点,就是消费者如果想要及时的处理数据,就要在程序中写个类似 while(true) 这样的逻辑,不停的去调用 LPOPRPOP 命令,这就会给消费者程序带来些不必要的性能损失。

即时消费队列

为了解决简单消息队列的问题,Redis 还提供了 LPUSH & BRPOPRPUSH & BLPOP 这种阻塞式读取队列,客户端在没有读到队列数据时,自动阻塞,直到有新的数据写入队列,再开始读取新数据。这种方式就节省了不必要的 CPU 开销。但要注意,过多的阻塞命令可能会导致这些命令将客户端线程池占满,使其他命令无法发送。

但依旧会有一个问题:因为 Redis 单线程的特点,在即时消费数据时,同一个消息会不会同时被多个 consumer 消费掉,但是需要我们考虑消费不成功的情况。List 队列中的消息一经发送出去,便从队列里删除。如果由于网络原因消费者没有收到消息,或者消费者在处理这条消息的过程中崩溃了,就再也无法还原出这条消息。究其原因,就是缺少消息确认机制。

确认消费队列

为了保证消息的可靠性,消息队列都会有完善的消息确认机制(Acknowledge),即消费者向队列报告消息已收到或已处理的机制。 RPOPLPUSHBRPOPLPUSH 从一个 List 中获取消息的同时把这条消息复制到另一个 List 里(可以当做备份),而且这个过程是原子的。这样就可以在业务流程安全结束后,再删除队列元素,实现消息确认机制。

延时消息队列

当然,还有更特殊的场景,可以通过 ZSET 来实现延时消息队列,原理就是将消息加到 ZSET 结构后,将要被消费的时间戳设置为对应的 score 即可,只要业务数据不会是重复数据就 OK。

订阅与发布消息队列

“发布/订阅”模式同样可以实现进程间的消息传递,其原理如下:”发布/订阅”模式包含两种角色,分别是发布者和订阅者。订阅者可以订阅一个或者多个频道(channel),而发布者可以向指定的频道(channel)发送消息,所有订阅此频道的订阅者都会收到此消息。

Redis 通过 PUBLISHSUBSCRIBE 等命令实现了订阅与发布模式, 这个功能提供两种信息机制, 分别是订阅/发布到频道和订阅/发布到模式。

Stream 消息队列

Redis 发布订阅(PUB / SUB)有个缺点就是消息无法持久化,如果出现网络断开、Redis 宕机等,消息就会被丢弃。而且也没有 ACK 机制来保证数据的可靠性,假设一个消费者都没有,那消息就直接被丢弃了。简单来说,发布订阅(PUB / SUB)可以分发消息,但无法记录历史消息。

Redis 5.0 版本新增了一个更强大的数据结构 —— Stream。

它就像是个仅追加内容的消息链表,把所有加入的消息都串起来,每个消息都有一个唯一的 ID 和对应的内容。而且消息是持久化的。它提供了消息的持久化和主备复制功能,可以让任何客户端访问任何时刻的数据,并且能记住每一个客户端的访问位置,还能保证消息不丢失。

当然也会有一些其他的问题,例如:宕机可能造成的 AOF 指令丢失、重新选举造成的数据缺失等问题。

分布式锁

分布式锁的目的其实很简单,就是为了保证多台服务器在执行某一段代码时保证只有一台服务器执行。

Redis 单节点分布式锁

命令 SET resource-name anystring NX EX max-lock-time 是一种用 Redis 来实现锁机制的简单方法。

如果上述命令返回OK,那么客户端就可以获得锁(如果上述命令返回 Nil,那么客户端可以在一段时间之后重新尝试),并且可以通过DEL命令来释放锁。客户端加锁之后,如果没有主动释放,会在过期时间之后自动释放。

可以通过如下优化使得上面的锁系统变得更加鲁棒:

  • 不要设置固定的字符串,而是设置为随机的大字符串,可以称为 token。
  • 通过脚步删除指定锁的 key,而不是DEL命令。

但是这种方式仍然会有一些问题:

  • 锁被提前释放。假如线程 A 在加锁和释放锁之间的逻辑执行的时间过长(或者线程 A 执行过程中被堵塞),以至于超出了锁的过期时间后进行了释放,但线程 A 在临界区的逻辑还没有执行完,那么这时候线程 B 就可以提前重新获取这把锁,导致临界区代码不能严格的串行执行。
  • 锁被误删。假如以上情形中的线程 A 执行完后,它并不知道此时的锁持有者是线程 B,线程 A 会继续执行 DEL 指令来释放锁,如果线程 B 在临界区的逻辑还没有执行完,线程 A 实际上释放了线程 B 的锁。

为了避免以上情况,建议不要在执行时间过长的场景中使用 Redis 分布式锁,同时一个比较安全的做法是在执行 DEL 释放锁之前对锁进行判断,验证当前锁的持有者是否是自己。当然,最好使用 Lua 脚本使得一系列动作原子性地执行。

解锁脚本的一个例子将类似于以下:

if redis.call("get",KEYS[1]) == ARGV[1]
then
    return redis.call("del",KEYS[1])
else
    return 0
end

补充

这并不是一个完美的方案,只是相对完全一点,因为它并没有完全解决当前线程执行超时锁被提前释放后,其它线程乘虚而入的问题。

怎么能解决锁被提前释放这个问题呢?

可以利用锁的可重入特性,让获得锁的线程开启一个定时器的守护线程,每 expireTime / 3 执行一次,去检查该线程的锁是否存在,如果存在则对锁的过期时间重新设置为 expireTime,即利用守护线程对锁进行“续命”,防止锁由于过期提前释放。

当然业务要实现这个守护进程的逻辑还是比较复杂的,可能还会出现一些未知的问题。

目前互联网公司在生产环境用的比较广泛的开源框架 Redisson 很好地解决了这个问题,非常的简便易用,且支持 Redis 单实例、Redis M-S、Redis Sentinel、Redis Cluster 等多种部署架构。

Redis 多节点分布式锁

基于 Redis 单机实现的分布式锁其实都存在一个问题,就是加锁时只作用在一个 Redis 节点上,即使 Redis 通过 Sentinel 保证了高可用,但由于 Redis 的复制是异步的,Master 节点获取到锁后在未完成数据同步的情况下发生故障转移(此时故障转移的节点无该锁的 Key),此时其他客户端上的线程可以不合期望地获取到锁,毫无疑问会丧失锁的安全性。

正因为如此,在Redis的分布式环境中,Redis 的作者 antirez 提供了RedLock 的算法来实现一个分布式锁。以下是 RedLock 算法概述。

假设有 N(N>=5)个 Redis 节点,这些节点完全互相独立,不存在主从复制或者其他集群协调机制,确保在这 N 个节点上使用与在 Redis 单实例下相同的方法获取和释放锁。

获取锁的过程,客户端应执行如下操作:

  • 获取当前 Unix 时间,以毫秒为单位。
  • 按顺序依次尝试从 5 个实例使用相同的 key 和具有唯一性的 value(例如 UUID)获取锁。当向 Redis 请求获取锁时,客户端应该设置一个网络连接和响应超时时间,这个超时时间应该小于锁的失效时间。例如锁自动失效时间为 10 秒,则超时时间应该在 5-50 毫秒之间。这样可以避免服务器端 Redis 已经挂掉的情况下,客户端还在一直等待响应结果。如果服务器端没有在规定时间内响应,客户端应该尽快尝试去另外一个 Redis 实例请求获取锁。
  • 客户端使用当前时间减去开始获取锁时间(步骤 1 记录的时间)就得到获取锁使用的时间。当且仅当从大多数(N/2+1,这里是 3 个节点)的 Redis 节点都取到锁,并且使用的时间小于锁失效时间时,锁才算获取成功。
  • 如果取到了锁,key 的真正有效时间等于有效时间减去获取锁所使用的时间(步骤 3 计算的结果)。
  • 如果因为某些原因,获取锁失败(没有在至少 N/2+1 个 Redis 实例取到锁或者取锁时间已经超过了有效时间),客户端应该在所有的 Redis 实例上进行解锁(使用 Redis Lua 脚本)。

释放锁的过程相对比较简单:客户端向所有 Redis 节点发起释放锁的操作,包括加锁失败的节点,也需要执行释放锁的操作,antirez在算法描述中特别强调这一点,这是为什么呢?

原因是可能存在某个节点加锁成功后返回客户端的响应包丢失了,这种情况在异步通信模型中是有可能发生的:客户端向服务器通信是正常的,但反方向却是有问题的。虽然对客户端而言,由于响应超时导致加锁失败,但是对 Redis 节点而言,SET指令执行成功,意味着加锁成功。因此,释放锁的时候,客户端也应该对当时获取锁失败的那些 Redis 节点同样发起请求。

除此之外,为了避免 Redis 节点发生崩溃重启后造成锁丢失,从而影响锁的安全性,antirez 还提出了延时重启的概念,即一个节点崩溃后不要立即重启,而是等待一段时间后再进行重启,这段时间应该大于锁的有效时间。

布隆过滤器

典型线上问题

数据倾斜

对于集群系统,一般缓存是分布式的,即不同节点负责一定范围的缓存数据。缓存数据分散度不够,导致大量的缓存数据集中到了一台或者几台服务节点上,称为数据倾斜。一般来说数据倾斜是由于负载均衡实施的效果不好引起的。

数据量倾斜

在某些情况下,实例上的数据分布不均衡,某个实例上的数据特别多。

大 Key 导致倾斜

某个实例上正好保存了 bigkey。bigkey 的 value 值很大(String 类型),或者是 bigkey 保存了大量集合元素(集合类型),会导致这个实例的数据量增加,内存资源消耗也相应增加。

应对方式

  • 在业务层生成数据时,要尽量避免把过多的数据保存在同一个键值对中。
  • 如果 bigkey 正好是集合类型,可以把 bigkey 拆分成很多个小的集合类型数据,分散保存在不同的实例上。

Slot 分配不均导致倾斜

Redis 将 key 根据 CRC16 算法取 hash 值然后对 slot 个数取模,得到的就是 slot 位置。

运维在构建切片集群时候,需要手动分配哈希槽,并且把 16384 个槽都分配完,否则 Redis 集群无法正常工作。由于是手动分配,则可能会导致部分实例所分配的 Slot 过多,导致数据倾斜。

应对方式

使用CLUSTER SLOTS 命令来查看 Slot 分配情况,使用 CLUSTER SETSLOT、CLUSTER GETKEYSINSLOT、MIGRATE 这三个命令来进行 Slot 数据的迁移。

Hash Tag 导致倾斜

指当一个 Key 包含 {} 的时候,就不对整个 Key 做 Hash,而仅对 {} 包括的字符串做 Hash。假设 Hash 算法为 sha1,Key 值为 user:{user1}:idsuser:{user1}:tweets ,其 Hash 值都等同于 sha1(user1)

Hash Tag 优势

如果不同 Key 的 Hash Tag 内容都是一样的,那么,这些 Key 对应的数据会被映射到同一个 Slot 中,同时会被分配到同一个实例上。

Hash Tag 劣势

如果不合理使用,会导致大量的数据可能被集中到一个实例上发生数据倾斜,集群中的负载不均衡。

数据访问倾斜

一般来说,数据访问倾斜就是热 Key 问题导致的。在集群中,虽然每个集群实例上的数据量相差不大,但是某个实例上的数据是热点数据,被访问得非常频繁。

产生热 Key 的原因

  1. 用户消费的数据远大于生产的数据(热卖商品、热点新闻、热点评论、明星直播)

    在日常工作生活中一些突发的事件,例如:双十一期间某些热门商品的降价促销,当这其中的某一件商品被数万次点击浏览或者购买时,会形成一个较大的需求量,这种情况下就会造成热点问题。

    同理,被大量刊发、浏览的热点新闻、热点评论、明星直播等,这些典型的读多写少的场景也会产生热点问题。

  2. 请求分片集中,超过单 Server 的性能极限

    在服务端读数据进行访问时,往往会对数据进行分片切分,此过程中会在某一主机 Server 上对相应的 Key 进行访问,当访问超过 Server 极限时,就会导致热点 Key 问题的产生。

    如果热点过于集中,热点 Key 的缓存过多,超过目前的缓存容量时,就会导致缓存分片服务被打垮现象的产生。当缓存服务崩溃后,此时再有请求产生,会缓存到后台 DB 上,由于DB 本身性能较弱,在面临大请求时很容易发生请求穿透现象,会进一步导致雪崩现象,严重影响设备的性能。

常用的热 Key 问题解决方案

  1. 备份热 Key:把热点数据复制多份,在每一个数据副本的 key 中增加一个随机后缀,让它和其它副本数据不会被映射到同一个 Slot 中。这里相当于把一份数据复制到其他实例上,这样在访问的时候也增加随机前缀,将对一个实例的访问压力,均摊到其他实例上。
  2. 热点数据 Proxy 缓存:在与 Redis 通信层中增加 Proxy 层,Proxy 层负责代理 Redis 集群请求、探测热点数据以及发现后存储热点数据。

Proxy 通过 zk 集群来存储反馈的热点数据,然后本地所有节点监听该热点数据,进而加载到本地 JVM 缓存中。

最后由于 Proxy 是可以水平扩充的,因此可以任意增强热点数据的访问能力。

最佳实践

使用缓存最好遵循以下流程:使用前评估、使用时注意、使用后监控统计。

使用前评估

在方案设计时,如需要使用缓存则应分几部分提前进行评估。

对于缓存的必要性,使用缓存的目的是提高读取的效率、承接更多的流量,最终的目的是为了节省机器成本。而低频访问的数据则违背了这个目的,造成了成本的浪费。当然也有可能是为了保护下游服务免遭突发流量的冲击,在设计时也需要考虑进去。

对于缓存本身,评估其用到的数据结构、评估其 Key 失效时间(避免同时过期或永不过期)、预估单个 Key 占用内存大小范围(避免大 Key 造成的请求阻塞)、预估最好情况和最差情况形成的 Key 数量以及预估未来一段时间的 Key 数量增长量。

对于缓存实例,建议不同业务线之间缓存集群分离,不同核心业务之间的缓存实例分离,核心业务与非核心业务之间的缓存实例分离,不同非核心业务之间根据成本控制及后果考虑是否分离。对于业务线/业务间混用的情况,需要通过规范限制前缀名进行隔离,避免出现覆盖的情况。

对于缓存的读写流程,一般情况下,读的顺序是先缓存后数据库,写的顺序是先数据库后缓存。

使用时注意

RDB备份机制:假设每个实例使用4GB内存,则我们的系统需要大于8GB内存,因为RDB备份时使用 copy-on-write 机制,需要 fork 出一个子进程,并且复制一份内存,因此需要双份的内存储大小。

HGETALL 等集合操作:对于存储较多 Value 的 Key,尽量不用集合操作,会造成请求阻塞,影响其他应用使用。

使用后监控统计

在使用缓存后,要关注其监控指标,对慢请求、大对象、内存使用情况进行重点关注,也要对命中率及过期策略进行统计。