Redis 基础知识

Redis 基础知识

Redis 是一个使用 ANSI C 编写的开源、支持网络、基于内存、可选持久化的高性能键值对数据库。本文是我阅读了这篇文章后进行笔记记录。

Redis 的基本数据类型

常用的有5种,分别为:字符串 String、列表 List、哈希 Hash、集合 Set、有序集合 Zset。后续又加入了Bitmaps、HyperLogLogs和GEO。

Redis 底层的数据结构包括:简单的动态数组 SDS、链表、字典、跳表、整数集合、压缩列表、对象。

redis常见应用场景

  • 缓存:针对查多写少的热点数据进行缓存,提高查询效率。
  • 会话缓存:解决分布式session问题。
  • 时效性:例如短信验证码、token等具有时效性的数据。
  • 计数器:需要原子递增保持计数。
  • 消息队列:使用list,可以作为一个简单的消息队列使用。
  • 社交列表:使用set存储用户的粉丝列表,同时支持求交集、并集、差集,例如统计两个人的共同好友。
  • 排行榜:使用sorted set,根据score值进行排序。
  • 分布式锁:通过setnx来实现分布式锁,相比于zookeeper的分布式锁实现性能更高,但可靠性略低。

Redis Rehash的方式

  • 普通 Rehash,分配空间->逐个迁移->交换哈希表。这种一次性迁移的方法会导致服务器在一段时间内停止服务。
  • 渐进式 Rehash,区别在于将迁移工作分散到对哈希表的每次添加、删除、查找和更新操作上。但是需要缩容时如果一直不处理可能造成内存浪费。

Redis 的单线程运行模式

本质上 Redis 并不是单纯的单线程服务模型,一些辅助工作比如持久化刷盘、惰性删除等任务是由 BIO 线程来完成的,这里说的单线程主要是说与客户端交互完成命令请求和回复的工作线程。至于为啥会设置成单线程可以从以下几点分析:

  • CPU 并非瓶颈,多线程模式是为了发挥多核 CPU 的优势,提高 CPU 利用率而出现的。而 Redis 的所有操作都是基于内存的,处理事件极快,内存才是瓶颈。所以使用多线程来切换线程提高 CPU 利用率的需求并不强烈。
  • 复杂的Value类型:Redis 有着丰富的数据结构,其中常用的 Hash、Zset、List 等结构在 Value 很大时,CRUD 的操作会很复杂,多线程场景下就需要加同步锁,会带来线程上下文切换(耗时),还有可能造成死锁的问题。其实这个问题可以通过将 key 做 hash 分配给相同的线程处理就可以解决了,这样做的话就需要增加 key 的 hash 以及多线程负载均衡的处理。这些问题也可以通过集群化 Redis 来解决。

Redis 的事件处理

Redis 作为单线程服务要处理的工作一点也不少,Redis 是事件驱动的服务器,主要的事件类型有文件事件时间事件,其中时间事件是理解单线程逻辑模型的关键。

时间事件

时间事件包括两类:1、定时事件:任务在等待指定大小时间后执行,执行完成就不再执行,只触发一次;2、周期事件:任务每隔一定时间就执行,执行完成后等待下一次的执行,会周期性的触发。

Redis中大部分是周期事件,周期事件主要是服务器定时对自身运行情况进行检测和调整,从而保证稳定性。主要包括以下事件:

  • 删除数据库中的 key
  • 触发 RDB 和 AOF 持久化
  • 主从同步
  • 集群化保活
  • 关闭清理死客户端连接
  • 统计更新服务器的内存、key数量等信息

Redis 持久化

Redis 提供 RDB 和 AOF 两种持久化的方式,其中 RDB 将数据库快照以二进制保存到磁盘中,AOF 以协议文本方式,将所有对数据库进行过写入的命令和参数记录到AOF文件。

RDB

通过周期事件的检测来判断是否需要进行 RDB 持久化操作,可通过修改 redis.config 实现自定义的配置,重启后生效。默认的配置为:

save 900 1  //每 900 秒有 1 条数据被修改则触发 RDB
save 300 10  //每 300 秒有 10 条数据被修改则触发 RDB
save 60 10000  //每 60 秒有 10000 条数据被修改则触发 RDB
dbfilename "dump.rdb" //快照文件的名称
dir "/data/dbs/redis/rdbstro" //快照文件的目录

也可通过手动执行 save 和 bgsave 命令的方式来触发 RDB 持久化。save 是阻塞式持久化,执行命令时
Redis 主进程把内存数据写入到 RDB 文件中直到创建完成,期间将不能处理任何命令。bgsave 是非阻塞式持久化,通过调用 fork 函数创建子进程来完成持久化操作。如果在主进程中启动一个子线程将会造成内存数据的竞争,所以为了避免使用锁降低性能,Redis选择创建新的子进程独立拥有一份父进程的内存拷贝以此为基础进行 RDB 持久化。但是需要注意的是,fork 会消耗一定时间,并且父子进程所占据的内存是相同的,当 Redis 键值较大时,fork 的时间会很长,这段时间内 Redis 是无法响应其他命令的。除此之外,Redis 占据的内存空间会翻倍。

AOF

在下一次 RDB 持久化之前发生宕机将导致增量数据的丢失,AOF 能提供更加可靠的保证。在使用 AOF 持久化方式时,Redis 会将收到的每一个写命令都通过 Write 函数追加到文件中类似于 MySql 的 binlog文件。
AOF持久化
如上图所示,AOF 持久化的包括以下流程:

  1. 命令追加到 AOF 缓冲区。
  2. 将 AOF 缓冲区的数据写入 AOF 文件并通过 fsync 命令强制刷盘,刷盘的策略可通过 appendfsync 配置项来管理,该项有三个可选值,分别为 always(每次写入命令都会触发)、everysec(每秒主动触发)、no(从不主动触发,依赖于操作系统自身控制)。
  3. 重写操作是为了实现 AOF 文件的压缩,只保留当前内存中每个 key 的 value 和过期时间。和 RDB bgsave 类似,也是通过 fork 一个子进程的方式异步完成重写操作。重写期间,主进程会继续响应客户端的请求。为了解决重写期间内存数据被更新导致数据不一致的问题,在重写期间,新的写命令会写入 AOF 缓冲区和 AOF 重写缓冲区。当子进程完成 AOF 重写工作之后,它会向父进程发送一个信号,父进程在接收到该信号之后,会调用一个信号处理函数。将 AOF 重写缓冲区中的所有内容写入到新的 AOF 文件中,保证新 AOF 文件保存的数据库状态和服务器当前状态一致,对新的 AOF 文件进行重命名,原子地覆盖现有 AOF 文件,完成新旧文件的替换,这两个操作会阻塞主进程的其他操作。
  4. 重启加载 AOF 文件,实现数据恢复。

数据恢复的优先级

如果配置了 AOF,重启时则只加载 AOF 文件恢复数据,如果只配置了 RDB,启动时将加载 dump 文件恢复数据。Redis 4.0 提供了更好的混合持久化方案:创建出一个同时包含 RDB 数据和 AOF 数据的 AOF 文件, 其中 RDB 数据位于 AOF 文件的开头, 它们储存了服务器开始执行重写操作时的数据库状态,至于那些在重写操作执行之后执行的 Redis 命令, 则会继续以 AOF 格式追加到 AOF 文件的末尾, 也即是 RDB 数据之后。

Redis 内存回收和淘汰机制

为了保证让 Redis 服务安全稳定地运行,让内存保存在一定的阈值内是非常重要的,这就需要内存回收机制和淘汰机制来支持。Redis 主要使用惰性删除和依赖于周期事件的定期删除策略来实现过期键值的回收。

定期删除

每隔特定的时间对数据库进行一次扫描,检测并删除其中过期的键值对。Redis 的实现算法是一个自适应闭环并且概率化的抽样扫描过程,可以理解为过期键多就多跑几次,少则少跑几次。

惰性删除

键值对过期暂时不进行删除,至于删除的时机于键值对的使用有关,当获取键时先查看其是否过期,过期就删除,否则就保留。

淘汰机制

设置了一个 max-memory 的阈值,当内存使用量到达阈值时,新的键值对无法写入,此时就需要内存淘汰机制,在 Redis 的配置中有以下几种淘汰策略:

  • noeviction: 当内存不足以容纳新写入数据时,新写入操作会报错;
  • allkeys-lru:当内存不足以容纳新写入数据时,在键空间中移除最近最少使用的 key;
  • allkeys-random:当内存不足以容纳新写入数据时,在键空间中随机移除某个 key;
  • volatile-lru:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,移除最近最少使用的 key;
  • volatile-random:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,随机移除某个 key;
  • volatile-ttl:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,有更早过期时间的 key 优先移除;

一般选择第二种 allkeys-lru 基于 LRU 策略进行淘汰。

Redis 的数据同步机制

单点故障导致缓存失效,主存储服务将承受所有的请求,导致压力倍增。监控程序将宕机的 Redis 节点拉起,并依赖持久化方案进行快速恢复。为了提高 Redis 的高可用,就出现了主从架构,从节点对主节点的数据进行备份,如果主节点挂了,可以立刻切换到状态最好的从节点为新主节点,对外提供写服务。

CAP理论作为分布式存储的的定理,它指出对于一个分布式计算系统来说,不可能同时满足以下三点:

  • C Consistent 一致性
  • A Availability 可用性
  • P Partition Tolerance 分区容错性

因为分布式系统大多都是分开部署的,分区容错性肯定是要满足的,但数据一致性和服务可用性没法兼顾,Redis 选择的是可用性,通过最终一致性满足一致性要求。

基于 Redis 的分布式锁和 Redlock 算法

最初分布式锁借助于 setnxexpire 命令,但这两个命令并不是原子操作。如果执行 setnx 获取锁后客户端挂掉了,这样就没法执行 expire 设置过期时间导致锁一直没法被释放。因此在 2.8 版本中对 setnx 增加了参数扩展,使得 setnxexpire 具备了原子操作性。
其他问题:应用 A 加锁时设置超时 30s,但是执行时间超过了 30s,锁时间过期释放了,这个时候应用 B 成功获取到锁(A、B 同时重复执行,问题一),应用 B 执行的过程中应用 A 执行完成删除了锁(其实是把应用 B 的锁删除了,问题二)
针对问题一可以启动一个守护线程在锁即将过期时进行续时,具体可参考 redisson watchdog 实现。
针对问题二可以在加锁时设置一个 uuid,释放锁时先进行比对,只有一样才能执行删除,可以使用 EVAL 或 EVALSHA 执行 lua 脚本保证两步操作的原子性。
集群问题:主从复制,主节点加锁成功,还未同步从节点主节点宕机,从节点晋升为主节点,但是锁记录丢失,导致重复加锁。针对该问题的解决方案可参考 redisson redlock

Redlock

Redlock 算法是在单 Redis 节点基础上引入的高可用模式。在 Redis 分布式环境中,在所有 Redis 实例上使用与在 Redis 单实例下相同的方法获取锁和释放锁。步骤如下:

  1. 获取当期 Unix 事件,以毫秒为单位。
  2. 尝试全部的 Redis 实例,使用相同的 key 和具有唯一性的 value 获取锁,当向 Redis 请求获取锁时,客户端应该设置一个网络连接和响应超时事件,这个超时时间应该小于锁的失效时间,这样可以避免客户端死等。
  3. 客户端使用当期时间减去开始获取锁的时间就得到获取锁使用的时间。当且仅当从半数以上的 Redis 节点获取到锁,并且使用的时间小于锁的失效时间时,锁才算获取成功。
  4. 因为某些原因,获取锁失败后,需要在所有的 Redis 实例上进行解锁操作。

该算法强依赖了机器的时间戳,如果某台机器发生了时钟跳跃将导致问题,但 Redlock 的作者认为这个问题可以通过适当的运维方式避免。

Redis 集群

这块现在不是太懂,后续补上

常见问题及解决方案

缓存穿透

指查询一个 一定不存在 的数据,由于缓存不命中时被动写(缓存中不存在的数据,去 DB 中查询到该数据,然后写入到缓存中),每次查询都会去 DB 中查询。如果大流量查询此数据,严重的话可能会把 DB 搞挂。有如下解决方案:

  1. 缓存空对象:当从 DB 查询数据为空,我们也要把这个空结果进行缓存,具体的值需要使用 特殊的标识,能和真正的缓存数据区分开。另外,还需要设置 过短的有效期
  2. 布隆过滤器:在查 DB 之前先查 BF,BF 里面放存在的 key,如果 BF 里存在则往下查 DB,否则直接返回不存在。由于 BF 设计思想导致 存在的不一定存在,不存在的一定不存在,所以也会导致误判,并且 BF 不支持删除元素。

缓存雪崩

指 Redis 服务由于某些原因无法提供服务,所有请求全部打到 DB 中,导致 DB 负荷大增,最终挂掉,导致整个服务不可用的情况。有如下解决方案:

  1. 缓存高可用:可以使用 Redis Sentinel 或 Redis Cluster 来搭建高可用的redis服务集群。
  2. 限流:通过限制 DB 每秒请求数,避免 DB 被打挂的情况,保证服务可用。同时配合 Sentinel、Hystrix 实现服务的熔断降级,虽然会影响到部分用户的体验,总比所有用户都无法使用要好。
  3. 本地缓存,即使 Redis 挂了,也可以将DB查询到的结果缓存到本地,避免后续请求持续打到 DB 中。但是多引入一层缓存将带来很多额外的问题,故不推荐此方案。

缓存击穿

指某个 极度热点 数据在某个时间点过期时,恰好在这个时间点对这个key有大量的并发请求过来,大量请求瞬间击垮 DB(注意和 缓存穿透 区分)。有如下解决方案:

  1. 使用互斥锁,请求发现缓存不存在后,在查询 DB 之前,获取分布式锁(查询 DB 更新缓存的钥匙)保证有且只有一个线程去查询 DB,并将查询结果更新至缓存。
  2. 手动过期:缓存上不设置过期时间,功能上将过期时间放在 key 对应的 value 里。首先获取缓存,通过 value 的过期时间判断是否过期。没过期的直接返回,过期了的获取分布式锁,获取锁失败的直接返回,成功的负责更新缓存。这样做可以无需用户等待,但是会有很小的时延性。