写点什么

Redis

用户头像
ltc
关注
发布于: 3 小时前

缓存

缓存雪崩

缓存雪崩 是指在某一个时间段,缓存集中过期失效。此刻无数的请求直接绕开缓存,直接请求数据库。

造成缓存雪崩的原因,有以下两种:

  1. reids 宕机

  2. 大部分数据失效


对于缓存雪崩的解决方案有以下两种:

  1. 搭建高可用的集群,防止单机的 redis 宕机。

  2. 设置不同的过期时间,防止同意之间内大量的 key 失效

缓存穿透

缓存穿透是指查询一条数据库和缓存都没有的一条数据,就会一直查询数据库,对数据库的访问压力就会增大,缓存穿透的解决方案,有以下两种:

  1. 缓存空对象:代码维护较简单,但是效果不好。

  2. 布隆过滤器:代码维护复杂,效果很好

缓存击穿

缓存击穿是指一个key非常热点,在不停的扛着大并发,大并发集中对这一个点进行访问,当这个 key 在失效的瞬间,持续的大并发就穿破缓存,直接请求数据库,瞬间对数据库的访问压力增大。


缓存击穿这里强调的是并发,造成缓存击穿的原因有以下两个:

  1. 该数据没有人查询过 ,第一次就大并发的访问。(冷门数据)

  2. 添加到了缓存,reids 有设置数据失效的时间 ,这条数据刚好失效,大并发访问(热点数据)


对于缓存击穿的解决方案就是加锁

缓存和数据库双写不一致

先删缓存,再更新数据库

采用延时双删策略

  • 先淘汰缓存

  • 再写数据库(这两步和原来一样)

  • 休眠 1 秒,再次淘汰缓存这么做,可以将 1 秒内所造成的缓存脏数据,再次删除。


这个 1 秒怎么确定的,具体该休眠多久呢?

应该自行评估项目的读数据业务逻辑的耗时。然后写数据的休眠时间则在读数据业务逻辑的耗时基础上,加几百 ms 即可。这么做的目的,就是确保读请求结束,写请求可以删除读请求造成的缓存脏数据。


如果采用了 mysql 的读写分离架构怎么办?

还是使用双删延时策略。只是,睡眠时间修改为在主从同步的延时时间基础上,加几百 ms。


采用这种同步淘汰策略,吞吐量降低怎么办?

可以将第二次删除作为异步的。


第二次删除,如果删除失败怎么办?

可以采取重试机制。

先更新数据库,再删缓存

Cache-aside pattern:

  • 失效:应用程序先从 cache 取数据,没有得到,则从数据库中取数据,成功后,放到缓存中。

  • 命中:应用程序从 cache 中取数据,取到后返回。

  • 更新:先把数据存到数据库中,成功后,再让缓存失效。


这种情况不存在并发问题么?

不是的。假设这会有两个请求,一个请求 A 做查询操作,一个请求 B 做更新操作,那么会有如下情形产生(1)缓存刚好失效

(2)请求 A 查询数据库,得一个旧值

(3)请求 B 将新值写入数据库

(4)请求 B 删除缓存

(5)请求 A 将查到的旧值写入缓存

发生上述情况有一个先天性条件,就是步骤(3)的写数据库操作比步骤(2)的读数据库操作耗时更短,才有可能使得步骤(4)先于步骤(5)。可是,数据库的读操作的速度远快于写操作的,因此步骤(3)耗时比步骤(2)更短,这一情形很难出现。

删除失败重试机制

方案一:

流程如下所示

  • 更新数据库数据

  • 缓存因为种种问题删除失败

  • 将需要删除的 key 发送至消息队列

  • 自己消费消息,获得需要删除的 key

  • 继续重试删除操作,直到成功

然而,该方案有一个缺点,对业务线代码造成大量的侵入。


方案二:

流程如下所示:

  • 更新数据库数据

  • 数据库会将操作信息写入 binlog 日志当中

  • 订阅程序提取出所需要的数据以及 key

  • 另起一段非业务代码,获得该信息

  • 尝试删除缓存操作,发现删除失败

  • 将这些信息发送至消息队列

  • 重新从消息队列中获得该数据,重试操作

上述的订阅 binlog 程序在 mysql 中有现成的中间件叫 canal,可以完成订阅 binlog 日志的功能。


内存回收

Redis 中可以通过 4 个独立的命令来给一个键设置过期时间:

  • expire key ttl:将 key 值的过期时间设置为 ttl 

  • pexpire key ttl:将 key 值的过期时间设置为 ttl 毫秒

  • expireat key timestamp:将 key 值的过期时间设置为指定的 timestamp 秒数

  • pexpireat key timestamp:将 key 值的过期时间设置为指定的 timestamp 毫秒数

不管使用哪一个命令,最终 Redis 底层都是使用 pexpireat 命令来实现的。另外,set 等命令也可以设置 key 的同时加上过期时间,这样可以保证设值和设过期时间的原子性。


过期策略

如果将一个过期的键删除,一般都会有三种策略:

  • 定时删除:为每个键设置一个定时器,一旦过期时间到了,则将键删除。这种策略对内存很友好,但是对 CPU 不友好,因为每个定时器都会占用一定的 CPU 资源。

  • 惰性删除:不管键有没有过期都不主动删除,等到每次去获取键时再判断是否过期,如果过期就删除该键,否则返回键对应的值。这种策略对内存不够友好,可能会浪费很多内存。

  • 定期扫描:系统每隔一段时间就定期扫描一次,发现过期的键就进行删除。这种策略相对来说是上面两种策略的折中方案,需要注意的是这个定期的频率要结合实际情况掌控好,使用这种方案有一个缺陷就是可能会出现已经过期的键也被返回。

在 Redis 当中,其选择的是策略 2 和策略 3 的综合使用。不过 Redis 的定期扫描只会扫描设置了过期时间的键,因为设置了过期时间的键 Redis 会单独存储,所以不会出现扫描所有键的情况。

淘汰策略

Redis 提供了一个参数 maxmemory 来配置 Redis 最大使用内存。

Redis 中提供了 8 种淘汰策略,可以通过参数 maxmemory-policy 进行配置:

  • volatile-lru:从已设置过期时间的数据集中挑选最近最少使用的数据淘汰。

  • volatile-ttl:从已设置过期时间的数据集中挑选将要过期的数据淘汰。

  • volatile-random:从已设置过期时间的数据集中任意选择数据淘汰。

  • volatile-lfu:从已设置过期时间的数据集挑选使用频率最低的数据淘汰。

  • allkeys-lru:从数据集中挑选最近最少使用的数据淘汰。

  • allkeys-lfu:从数据集中挑选使用频率最低的数据淘汰。

  • allkeys-random:从数据集(server.db[i].dict)中任意选择数据淘汰

  • no-enviction(驱逐):禁止驱逐数据,这也是默认策略。意思是当内存不足以容纳新入数据时,新写入操作就会报错,请求可以继续进行,线上任务也不能持续进行,采用 no-enviction 策略可以保证数据不被丢失。


热度数据管理

typedef struct redisObject {

    unsigned type:4;//对象类型(4 位=0.5 字节)

    unsigned encoding:4;//编码(4 位=0.5 字节)

    unsigned lru:LRU_BITS;//记录对象最后一次被应用程序访问的时间(24 位=3 字节)

    int refcount;//引用计数。等于 0 时表示可以被垃圾回收(32 位=4 字节)

    void *ptr;//指向底层实际的数据存储结构,如:SDS 等(8 字节)

} robj;


lru 属性是创建对象的时候写入,对象被访问到时也会进行更新。Redis 中维护了一个全局属性 lru_clock,这个属性是通过一个全局函数 serverCron 每隔 100 毫秒执行一次来更新的,记录的是当前 unix 时间戳。

最后决定删除的数据是通过 lru_clock 减去对象的 lru 属性而得出的。


那么为什么 Redis 要这么做呢?直接取全局时间不是更准确吗?

这是因为这么做可以避免每次更新对象的 lru 属性的时候可以直接取全局属性,而不需要去调用系统函数来获取系统时间,从而提升效率。

LRU 算法

LRU 全称为:Least Recently Used。即:最近最长时间未被使用。这个主要针对的是使用时间。

在 Redis 当中,并没有采用传统的 LRU 算法,因为传统的 LRU 算法存在 2 个问题:

  • 需要额外的空间进行存储。

  • 可能存在某些 key 值使用很频繁,但是最近没被使用,从而被 LRU 算法删除。

为了避免以上 2 个问题,Redis 当中对传统的 LRU 算法进行了改造,通过抽样的方式进行删除

配置文件中提供了一个属性 maxmemory_samples 5,默认值就是 5,表示随机抽取 5 个 key 值,然后对这 5 个 key 值按照 LRU 算法进行删除,所以很明显,key 值大,删除的准确度越高。

LFU 算法

LFU 全称为:Least Frequently Used。即:最近最少频率使用,这个主要针对的是使用频率。这个属性也是记录在redisObject 中的 lru 属性内。

当我们采用 LFU 回收策略时,lru 属性的高 16 位用来记录访问时间(last decrement time:ldt,单位为分钟),低 8 位用来记录访问频率(logistic counter:logc),简称 counter


访问频次递增


LFU 计数器每个键只有 8 位,它能表示的最大值是 255,所以 Redis 使用的是一种基于概率的对数器来实现 counter 的递增。

给定一个旧的访问频次,当一个键被访问时,counter 按以下方式递增:

  1. 提取 0 和 1 之间的随机数 R

  2. counter - 初始值(默认为 5),得到一个基础差值,如果这个差值小于 0,则直接取 0,为了方便计算,把这个差值记为 baseval

  3. 概率 P 计算公式为:1/(baseval * lfu_log_factor + 1)

  4. 如果 R < P 时,频次进行递增(counter++)。

公式中的 lfu_log_factor 称之为对数因子,默认是 10 ,可以通过配置来进行控制


访问频次递减


counter 的减少速度由参数 lfu-decay-time 进行控制,默认是 1,单位是分钟。默认值 1 表示:N 分钟内没有访问,counter 就要减 N

具体算法如下:

  1. 获取当前时间戳,转化为分钟后取低 16 位(为了方便后续计算,这个值记为 now)。

  2. 取出对象内的 lru 属性中的高 16 位(为了方便后续计算,这个值记为 ldt)。

  3. 当 lru > now 时,默认为过了一个周期(16 位,最大 65535),则取差值 65535-ldt+now:当 lru <= now 时,取差值 now-ldt(为了方便后续计算,这个差值记为 idle_time)。

  4. 取出配置文件中的 lfu_decay_time 值,然后计算:idle_time / lfu_decay_time(为了方便后续计算,这个值记为num_periods)。

  5. 最后将counter减少:counter - num_periods


单线程原理


Redis 服务器是一个事件驱动程序,服务器需要处理以下两类事件:

  • 文件事件:Redis 服务器通过套接字与客户端(或者其他 Redis 服务器)进行连接,而文件事件就是服务器对套接字操作的抽象;服务器与客户端的通信会产生相应的文件事件,而服务器则通过监听并处理这些事件来完成一系列网络通信操作,比如连接acceptreadwriteclose等;

  • 时间事件:Redis 服务器中的一些操作(比如 serverCron 函数)需要在给定的时间点执行,而时间事件就是服务器对这类定时操作的抽象,比如过期键清理,服务状态统计等。


Redis 将文件事件和时间事件进行抽象,时间轮训器会监听 I/O 事件表,一旦有文件事件就绪,Redis 就会优先处理文件事件,接着处理时间事件。在上述所有事件处理上,Redis 都是以单线程形式处理,所以说 Redis 是单线程的。


Redis 基于 Reactor 模式开发了自己的 I/O 事件处理器,也就是文件事件处理器,Redis 在 I/O 事件处理上,采用了 I/O 多路复用技术,同时监听多个套接字,并为套接字关联不同的事件处理函数,通过一个线程实现了多客户端并发处理。


主从复制

参考自:https://www.cnblogs.com/kismetv/p/9236731.html#t1

全量同步

(1)从节点判断无法进行部分复制,向主节点发送全量复制的请求;或从节点发送部分复制的请求,但主节点判断无法进行部分复制

(2)主节点收到全量复制的命令后,执行 bgsave,在后台生成 RDB 文件,并使用一个缓冲区(称为复制缓冲区)记录从现在开始执行的所有写命令

(3)主节点的 bgsave 执行完成后,将 RDB 文件发送给从节点;从节点首先清除自己的旧数据,然后载入接收的 RDB 文件,将数据库状态更新至主节点执行 bgsave 时的数据库状态

(4)主节点将前述复制缓冲区中的所有写命令发送给从节点,从节点执行这些写命令,将数据库状态更新至主节点的最新状态

(5)如果从节点开启了 AOF,则会触发 bgrewriteaof 的执行,从而保证 AOF 文件更新至主节点的最新状态


部分同步

由于全量复制在主节点数据量较大时效率太低,因此 Redis2.8 开始提供部分复制,用于处理网络中断时的数据同步。

  1. 客户端向服务器发送 SLAVEOF 命令,让当前服务器成为 Slave;

  2. 当前服务器根据自己是否保存 Master runid 来判断是否是第一次复制,如果是第一次同步则跳转到 3,否则跳转到 4;

  3. 向 Master 发送 PSYNC ? -1 命令来进行完整同步;

  4. 向 Master 发送 PSYNC runid offset;

  5. Master 接收到 PSYNC 命令后首先判断 runid 是否和本机的 id 一致,如果一致则会再次判断 offset 偏移量和本机的偏移量相差有没有超过复制积压缓冲区大小,如果没有那么就给 Slave 发送 CONTINUE,此时 Slave 只需要等待 Master 传回失去连接期间丢失的命令;

  6. 如果 runid 和本机 id 不一致或者双方 offset 差距超过了复制积压缓冲区大小,那么就会返回 FULLRESYNC runid offset,Slave 将 runid 保存起来,并进行完整同步。

心跳机制

主从节点还维持着心跳机制:PING 和 REPLCONF ACK。心跳机制对于主从复制的超时判断、数据安全等有作用。

  1. 主->从:PING

每隔指定的时间,主节点会向从节点发送 PING 命令,这个 PING 命令的作用,主要是为了让从节点进行超时判断。

PING 发送的频率由 repl-ping-slave-period 参数控制,单位是秒,默认值是 10s。

  1. 从->主:REPLCONF ACK

在命令传播阶段,从节点会向主节点发送 REPLCONF ACK 命令,频率是每秒 1 次;命令格式为:REPLCONF ACK {offset},其中 offset 指从节点保存的复制偏移量。REPLCONF ACK 命令的作用包括:

  • 实时监测主从节点网络状态:该命令会被主节点用于复制超时的判断。此外,在主节点中使用 info Replication,可以看到其从节点的状态中的 lag 值,代表的是主节点上次收到该 REPLCONF ACK 命令的时间间隔,在正常情况下,该值应该是 0 或 1。

  • 检测命令丢失:从节点发送了自身的 offset,主节点会与自己的 offset 对比,如果从节点数据缺失(如网络丢包),主节点会推送缺失的数据(这里也会利用复制积压缓冲区)。注意,offset 和复制积压缓冲区,不仅可以用于部分复制,也可以用于处理命令丢失等情形;区别在于前者是在断线重连后进行的,而后者是在主从节点没有断线的情况下进行的。

  • 辅助保证从节点的数量和延迟:Redis 主节点中使用 min-slaves-to-write 和 min-slaves-max-lag 参数,来保证主节点在不安全的情况下不会执行写命令;所谓不安全,是指从节点数量太少,或延迟过高。例如 min-slaves-to-write 和 min-slaves-max-lag 分别是 3 和 10,含义是如果从节点数量小于 3 个,或所有从节点的延迟值都大于 10s,则主节点拒绝执行写命令。而这里从节点延迟值的获取,就是通过主节点接收到 REPLCONF ACK 命令的时间来判断的,即前面所说的 info Replication 中的 lag 值。


持久化

RDB

RDB 持久化是将当前进程中的数据生成快照保存到硬盘(因此也称作快照持久化),保存的文件后缀是 rdb;当 Redis 重新启动时,可以读取快照文件恢复数据。

手动触发

save 命令和 bgsave 命令都可以生成 RDB 文件。

save 命令会阻塞 Redis 服务器进程,直到 RDB 文件创建完毕为止,在 Redis 服务器阻塞期间,服务器不能处理任何命令请求。


而 bgsave 命令会创建一个子进程,由子进程来负责创建 RDB 文件,父进程(即 Redis 主进程)则继续处理请求。


bgsave 命令执行过程中,只有 fork 子进程时会阻塞服务器,而对于 save 命令,整个过程都会阻塞服务器,因此 save 已基本被废弃,线上环境要杜绝 save 的使用。此外,在自动触发 RDB 持久化时,Redis 也会选择 bgsave 而不是 save 来进行持久化。

自动触发

save m n

自动触发最常见的情况是在配置文件中通过 save m n,指定当 m 秒内发生 n 次变化时,会触发 bgsave。


save m n 的实现原理

Redis 的 save m n,是通过 serverCron 函数、dirty 计数器、和 lastsave 时间戳来实现的。

  • serverCron 是 Redis 服务器的周期性操作函数,默认每隔 100ms 执行一次;该函数对服务器的状态进行维护,其中一项工作就是检查 save m n 配置的条件是否满足,如果满足就执行 bgsave。

  • dirty 计数器是 Redis 服务器维持的一个状态,记录了上一次执行 bgsave/save 命令后,服务器状态进行了多少次修改(包括增删改);而当 save/bgsave 执行完成后,会将 dirty 重新置为 0。注意 dirty 记录的是服务器进行了多少次修改,而不是客户端执行了多少修改数据的命令。

  • lastsave 时间戳也是 Redis 服务器维持的一个状态,记录的是上一次成功执行 save/bgsave 的时间。


save m n 的原理如下

每隔 100ms,执行 serverCron 函数;在 serverCron 函数中,遍历 save m n 配置的保存条件,只要有一个条件满足,就进行 bgsave。对于每一个 save m n 条件,只有下面两条同时满足时才算满足:

(1)当前时间-lastsave > m

(2)dirty >= n

其他自动触发机制

除了 save m n 以外,还有一些其他情况会触发 bgsave:

  • 在主从复制场景下,如果从节点执行全量复制操作,则主节点会执行 bgsave 命令,并将 rdb 文件发送给从节点

  • 执行 shutdown 命令时,自动执行 rdb 持久化

执行流程

bgsave 命令的执行流程如下图所示



图片中的 5 个步骤所进行的操作如下:

1)  Redis 父进程首先判断:当前是否在执行 save,或 bgsave/bgrewriteaof 的子进程,如果在执行则 bgsave 命令直接返回。bgsave/bgrewriteaof 的子进程不能同时执行,主要是基于性能方面的考虑:两个并发的子进程同时执行大量的磁盘写操作,可能引起严重的性能问题。

2)  父进程执行 fork 操作创建子进程,这个过程中父进程是阻塞的,Redis 不能执行来自客户端的任何命令

3)  父进程 fork 后,bgsave 命令返回”Background saving started”信息并不再阻塞父进程,并可以响应其他命令

4)  子进程创建 RDB 文件,根据父进程内存快照生成临时快照文件,完成后对原有文件进行原子替换

5)  子进程发送信号给父进程表示完成,父进程更新统计信息

RDB 文件格式

RDB 文件格式如下图所示:



其中各个字段的含义说明如下:

1)  REDIS:常量,保存着”REDIS”5 个字符。

2)  db_version:RDB 文件的版本号,注意不是 Redis 的版本号。

3)  SELECTDB 0 pairs:表示一个完整的数据库(0 号数据库),同理 SELECTDB 3 pairs 表示完整的 3 号数据库;只有当数据库中有键值对时,RDB 文件中才会有该数据库的信息(上图所示的 Redis 中只有 0 号和 3 号数据库有键值对);如果 Redis 中所有的数据库都没有键值对,则这一部分直接省略。其中:SELECTDB 是一个常量,代表后面跟着的是数据库号码;0 和 3 是数据库号码;pairs 则存储了具体的键值对信息,包括 key、value 值,及其数据类型、内部编码、过期时间、压缩信息等等。

4)  EOF:常量,标志 RDB 文件正文内容结束。

5)  check_sum:前面所有内容的校验和;Redis 在载入 RBD 文件时,会计算前面的校验和并与 check_sum 值比较,判断文件是否损坏。

启动时加载

RDB 文件的载入工作是在服务器启动时自动执行的,并没有专门的命令。但是由于 AOF 的优先级更高,因此当 AOF 开启时,Redis 会优先载入 AOF 文件来恢复数据;只有当 AOF 关闭时,才会在 Redis 服务器启动时检测 RDB 文件,并自动载入。服务器载入 RDB 文件期间处于阻塞状态,直到载入完成为止。


Redis 载入 RDB 文件时,会对 RDB 文件进行校验,如果文件损坏,则日志中会打印错误,Redis 启动失败。

AOF

RDB 持久化是将进程数据写入文件,而 AOF 持久化(即 Append Only File 持久化),则是将 Redis 执行的每次写命令记录到单独的日志文件中;当 Redis 重启时再次执行 AOF 文件中的命令来恢复数据。

与 RDB 相比,AOF 的实时性更好,因此已成为主流的持久化方案。


AOF 的执行流程包括:

  • 命令追加(append):将 Redis 的写命令追加到缓冲区 aof_buf;

  • 文件写入(write)和文件同步(sync):根据不同的同步策略将 aof_buf 中的内容同步到硬盘;

  • 文件重写(rewrite):定期重写 AOF 文件,达到压缩的目的。


命令追加(append)

Redis 先将写命令追加到缓冲区,而不是直接写入文件,主要是为了避免每次有写命令都直接写入硬盘,导致硬盘 IO 成为 Redis 负载的瓶颈。

命令追加的格式是 Redis 命令请求的协议格式,它是一种纯文本格式,具有兼容性好、可读性强、容易处理、操作简单避免二次开销等优点。在 AOF 文件中,除了用于指定数据库的 select 命令是由 Redis 添加的,其他都是客户端发送来的写命令。

文件写入(write)和文件同步(sync)

Redis 提供了多种 AOF 缓存区的同步文件策略,策略涉及到操作系统的 write 函数和 fsync 函数,说明如下:

为了提高文件写入效率,在现代操作系统中,当用户调用 write 函数将数据写入文件时,操作系统通常会将数据暂存到一个内存缓冲区里,当缓冲区被填满或超过了指定时限后,才真正将缓冲区的数据写入到硬盘里。这样的操作虽然提高了效率,但也带来了安全问题:如果计算机停机,内存缓冲区中的数据会丢失;因此系统同时提供了 fsync、fdatasync 等同步函数,可以强制操作系统立刻将缓冲区中的数据写入到硬盘里,从而确保数据的安全性。

 

AOF 缓存区的同步文件策略由参数 appendfsync 控制,各个值的含义如下:

  • always:命令写入 aof_buf 后立即调用系统 fsync 操作同步到 AOF 文件,fsync 完成后线程返回。这种情况下,每次有写命令都要同步到 AOF 文件,硬盘 IO 成为性能瓶颈,Redis 只能支持大约几百 TPS 写入,严重降低了 Redis 的性能;即便是使用固态硬盘(SSD),每秒大约也只能处理几万个命令,而且会大大降低 SSD 的寿命。

  • no:命令写入 aof_buf 后调用系统 write 操作,不对 AOF 文件做 fsync 同步;同步由操作系统负责,通常同步周期为 30 秒。这种情况下,文件同步的时间不可控,且缓冲区中堆积的数据会很多,数据安全性无法保证。

  • everysec:命令写入 aof_buf 后调用系统 write 操作,write 完成后线程返回;fsync 同步文件操作由专门的线程每秒调用一次。everysec 是前述两种策略的折中,是性能和数据安全性的平衡,因此是 Redis 的默认配置,也是我们推荐的配置。

文件重写(rewrite)

随着时间流逝,Redis 服务器执行的写命令越来越多,AOF 文件也会越来越大;过大的 AOF 文件不仅会影响服务器的正常运行,也会导致数据恢复需要的时间过长。

文件重写是指定期重写 AOF 文件,减小 AOF 文件的体积。需要注意的是,AOF 重写是把 Redis 进程内的数据转化为写命令,同步到新的 AOF 文件;不会对旧的 AOF 文件进行任何读取、写入操作!

关于文件重写需要注意的另一点是:对于 AOF 持久化来说,文件重写虽然是强烈推荐的,但并不是必须的;即使没有文件重写,数据也可以被持久化并在 Redis 启动的时候导入;因此在一些实现中,会关闭自动的文件重写,然后通过定时任务在每天的某一时刻定时执行。

 

文件重写之所以能够压缩 AOF 文件,原因在于:

  • 过期的数据不再写入文件

  • 无效的命令不再写入文件:如有些数据被重复设值(set mykey v1, set mykey v2)、有些数据被删除了(sadd myset v1, del myset)等等

  • 多条命令可以合并为一个:如 sadd myset v1, sadd myset v2, sadd myset v3 可以合并为 sadd myset v1 v2 v3。不过为了防止单条命令过大造成客户端缓冲区溢出,对于 list、set、hash、zset 类型的 key,并不一定只使用一条命令;而是以某个常量为界将命令拆分为多条。这个常量在 redis.h/REDIS_AOF_REWRITE_ITEMS_PER_CMD 中定义,不可更改,3.0 版本中值是 64。


通过上述内容可以看出,由于重写后 AOF 执行的命令减少了,文件重写既可以减少文件占用的空间,也可以加快恢复速度。

文件重写的触发

文件重写的触发,分为手动触发和自动触发:

手动触发:直接调用 bgrewriteaof 命令,该命令的执行与 bgsave 有些类似:都是 fork 子进程进行具体的工作,且都只有在 fork 时阻塞。

自动触发:根据 auto-aof-rewrite-min-size 和 auto-aof-rewrite-percentage 参数,以及 aof_current_size 和 aof_base_size 状态确定触发时机。

  • auto-aof-rewrite-min-size:执行 AOF 重写时,文件的最小体积,默认值为 64MB。

  • auto-aof-rewrite-percentage:执行 AOF 重写时,当前 AOF 大小(即 aof_current_size)和上一次重写时 AOF 大小(aof_base_size)的比值。

只有当 auto-aof-rewrite-min-size 和 auto-aof-rewrite-percentage 两个参数同时满足时,才会自动触发 AOF 重写,即 bgrewriteaof 操作。


文件重写的流程

文件重写流程如下图所示



关于文件重写的流程,有两点需要特别注意:(1)重写由父进程 fork 子进程进行;(2)重写期间 Redis 执行的写命令,需要追加到新的 AOF 文件中,为此 Redis 引入了 aof_rewrite_buf 缓存。

对照上图,文件重写的流程如下:

1) Redis 父进程首先判断当前是否存在正在执行 bgsave/bgrewriteaof 的子进程,如果存在则 bgrewriteaof 命令直接返回,如果存在 bgsave 命令则等 bgsave 执行完成后再执行。

2) 父进程执行 fork 操作创建子进程,这个过程中父进程是阻塞的。

3.1) 父进程 fork 后,bgrewriteaof 命令返回”Background append only file rewrite started”信息并不再阻塞父进程,并可以响应其他命令。Redis 的所有写命令依然写入 AOF 缓冲区,并根据 appendfsync 策略同步到硬盘,保证原有 AOF 机制的正确。

3.2) 由于 fork 操作使用写时复制技术,子进程只能共享 fork 操作时的内存数据。由于父进程依然在响应命令,因此 Redis 使用 AOF 重写缓冲区(图中的 aof_rewrite_buf)保存这部分数据,防止新 AOF 文件生成期间丢失这部分数据。也就是说,bgrewriteaof 执行期间,Redis 的写命令同时追加到 aof_buf 和 aof_rewirte_buf 两个缓冲区。

4) 子进程根据内存快照,按照命令合并规则写入到新的 AOF 文件。

5.1) 子进程写完新的 AOF 文件后,向父进程发信号,父进程更新统计信息,具体可以通过 info persistence 查看。

5.2) 父进程把 AOF 重写缓冲区的数据写入到新的 AOF 文件,这样就保证了新 AOF 文件所保存的数据库状态和服务器当前状态一致。

5.3) 使用新的 AOF 文件替换老文件,完成 AOF 重写。

启动时加载

前面提到过,当 AOF 开启时,Redis 启动时会优先载入 AOF 文件来恢复数据;只有当 AOF 关闭时,才会载入 RDB 文件恢复数据。


当 AOF 开启,但 AOF 文件不存在时,即使 RDB 文件存在也不会加载(更早的一些版本可能会加载,但 3.0 不会)

文件校验

与载入 RDB 文件类似,Redis 载入 AOF 文件时,会对 AOF 文件进行校验,如果文件损坏,则日志中会打印错误,Redis 启动失败。但如果是 AOF 文件结尾不完整(机器突然宕机等容易导致文件尾部不完整),且 aof-load-truncated 参数开启,则日志中会输出警告,Redis 忽略掉 AOF 文件的尾部,启动成功。aof-load-truncated 参数默认是开启的。


RDB 和 AOF 的优缺点

RDB 和 AOF 各有优缺点:

RDB 持久化

优点:RDB 文件紧凑,体积小,网络传输快,适合全量复制;恢复速度比 AOF 快很多。当然,与 AOF 相比,RDB 最重要的优点之一是对性能的影响相对较小。

缺点:RDB 文件的致命缺点在于其数据快照的持久化方式决定了必然做不到实时持久化,而在数据越来越重要的今天,数据的大量丢失很多时候是无法接受的,因此 AOF 持久化成为主流。此外,RDB 文件需要满足特定格式,兼容性差(如老版本的 Redis 不兼容新版本的 RDB 文件)。

AOF 持久化

与 RDB 持久化相对应,AOF 的优点在于支持秒级持久化、兼容性好,缺点是文件大、恢复速度慢、对性能影响大。

持久化策略选择

对于 RDB 持久化,一方面是 bgsave 在进行 fork 操作时 Redis 主进程会阻塞,另一方面,子进程向硬盘写数据也会带来 IO 压力;对于 AOF 持久化,向硬盘写数据的频率大大提高(everysec 策略下为秒级),IO 压力更大,甚至可能造成 AOF 追加阻塞问题,此外,AOF 文件的重写与 RDB 的 bgsave 类似,会有 fork 时的阻塞和子进程的 IO 压力问题。相对来说,由于 AOF 向硬盘中写数据的频率更高,因此对 Redis 主进程性能的影响会更大。

在实际生产环境中,根据数据量、应用对数据的安全要求、预算限制等不同情况,会有各种各样的持久化策略;如完全不使用任何持久化、使用 RDB 或 AOF 的一种,或同时开启 RDB 和 AOF 持久化等。此外,持久化的选择必须与 Redis 的主从策略一起考虑,因为主从复制与持久化同样具有数据备份的功能,而且主机 master 和从机 slave 可以独立的选择持久化方案。

fork 阻塞:CPU 的阻塞

在 Redis 的实践中,众多因素限制了 Redis 单机的内存不能过大,例如:

  • 当面对请求的暴增,需要从库扩容时,Redis 内存过大会导致扩容时间太长;

  • 当主机宕机时,切换主机后需要挂载从库,Redis 内存过大导致挂载速度过慢;

  • 以及持久化过程中的 fork 操作。


父进程通过 fork 操作可以创建子进程;子进程创建后,父子进程共享代码段,不共享进程的数据空间,但是子进程会获得父进程的数据空间的副本。在操作系统 fork 的实际实现中,基本都采用了写时复制技术,即在父/子进程试图修改数据空间之前,父子进程实际上共享数据空间;但是当父/子进程的任何一个试图修改数据空间时,操作系统会为修改的那一部分(内存的一页)制作一个副本。

虽然 fork 时,子进程不会复制父进程的数据空间,但是会复制内存页表(页表相当于内存的索引、目录);父进程的数据空间越大,内存页表越大,fork 时复制耗时也会越多。

 

在 Redis 中,无论是 RDB 持久化的 bgsave,还是 AOF 重写的 bgrewriteaof,都需要 fork 出子进程来进行操作。如果 Redis 内存过大,会导致 fork 操作时复制内存页表耗时过多;而 Redis 主进程在进行 fork 时,是完全阻塞的,也就意味着无法响应客户端的请求,会造成请求延迟过大。


AOF 追加阻塞:硬盘的阻塞

在 AOF 中,如果 AOF 缓冲区的文件同步策略为 everysec,则:在主线程中,命令写入 aof_buf 后调用系统 write 操作,write 完成后主线程返回;fsync 同步文件操作由专门的文件同步线程每秒调用一次。

这种做法的问题在于,如果硬盘负载过高,那么 fsync 操作可能会超过 1s;如果 Redis 主线程持续高速向 aof_buf 写入命令,硬盘的负载可能会越来越大,IO 资源消耗更快;如果此时 Redis 进程异常退出,丢失的数据也会越来越多,可能远超过 1s。

为此,Redis 的处理策略是这样的:主线程每次进行 AOF 会对比上次 fsync 成功的时间;如果距上次不到 2s,主线程直接返回;如果超过 2s,则主线程阻塞直到 fsync 同步完成。因此,如果系统硬盘负载过大导致 fsync 速度太慢,会导致 Redis 主线程的阻塞;此外,使用 everysec 配置,AOF 最多可能丢失超过 1s 的数据。

 

AOF 追加阻塞问题定位的方法:

(1)监控 info Persistence 中的 aof_delayed_fsync:当 AOF 追加阻塞发生时(即主线程等待 fsync 而阻塞),该指标累加。

(2)AOF 阻塞时的 Redis 日志:

Asynchronous AOF fsync is taking too long (disk is busy?). Writing the AOF buffer without waiting for fsync to complete, this may slow down Redis.

(3)如果 AOF 追加阻塞频繁发生,说明系统的硬盘负载太大;可以考虑更换 IO 速度更快的硬盘,或者通过 IO 监控分析工具对系统的 IO 负载进行分析,如 iostat(系统级 io)、iotop(io 版的 top)、pidstat 等。

哨兵模式

Redis Sentinel,即 Redis 哨兵,在 Redis 2.8 版本开始引入。哨兵的核心功能是主节点的自动故障转移。下面是 Redis 官方文档对于哨兵功能的描述:

  • 监控(Monitoring):哨兵会不断地检查主节点和从节点是否运作正常。

  • 自动故障转移(Automatic failover):当主节点不能正常工作时,哨兵会开始自动故障转移操作,它会将失效主节点的其中一个从节点升级为新的主节点,并让其他从节点改为复制新的主节点。

  • 配置提供者(Configuration provider):客户端在初始化时,通过连接哨兵来获得当前 Redis 服务的主节点地址。

  • 通知(Notification):哨兵可以将故障转移的结果发送给客户端。

其中,监控和自动故障转移功能,使得哨兵可以及时发现主节点故障并完成转移;而配置提供者和通知功能,则需要在与客户端的交互中才能体现。


基本原理

关于哨兵的原理,关键是了解以下几个概念。

(1)定时任务:每个哨兵节点维护了 3 个定时任务。定时任务的功能分别如下:通过向主从节点发送 info 命令获取最新的主从结构;通过发布订阅功能获取其他哨兵节点的信息;通过向其他节点发送 ping 命令进行心跳检测,判断是否下线。

  • 每个哨兵节点每 10 秒会向主节点和从节点发送 info 命令获取最新拓扑结构图,哨兵配置时只要配置对主节点的监控即可(sentinel monitor mymaster 192.168.1.3 26379 2),通过向主节点发送 info,获取从节点的信息,并当有新的从节点加入时可以马上感知到;

  • 每个哨兵节点每隔 2 秒会向 redis 数据节点的指定频道上发送该哨兵节点对于主节点的判断以及当前哨兵节点的信息,同时每个哨兵节点也会订阅该频道,来了解其它哨兵节点的信息及对主节点的判断,其实就是通过消息 publish 和 subscribe 来完成的;

  • 每隔 1 秒每个哨兵会向主节点、从节点及其余哨兵节点发送一次 ping 命令做一次心跳检测,这个也是哨兵用来判断节点是否正常的重要依据;

(2)主观下线:在心跳检测的定时任务中,如果其他节点超过一定时间(down-after-milliseconds)没有回复,哨兵节点就会将其进行主观下线。顾名思义,主观下线的意思是一个哨兵节点“主观地”判断下线;与主观下线相对应的是客观下线。

(3)客观下线:哨兵节点在对主节点进行主观下线后,会通过 sentinel is-master-down-by-addr 命令询问其他哨兵节点该主节点的状态;如果判断主节点下线的哨兵数量达到一定数值,则对该主节点进行客观下线。

需要特别注意的是,客观下线是主节点才有的概念;如果从节点和哨兵节点发生故障,被哨兵主观下线后,不会再有后续的客观下线和故障转移操作。

(4)选举领导者哨兵节点:当主节点被判断客观下线以后,各个哨兵节点会进行协商,选举出一个领导者哨兵节点,并由该领导者节点对其进行故障转移操作。

监视该主节点的所有哨兵都有可能被选为领导者,选举使用的算法是 Raft 算法;Raft 算法的基本思路是先到先得:即在一轮选举中,哨兵 A 向 B 发送成为领导者的申请,如果 B 没有同意过其他哨兵,则会同意 A 成为领导者。一般来说,哨兵选择的过程很快,谁先完成客观下线,一般就能成为领导者。

(5)故障转移:选举出的领导者哨兵,开始进行故障转移操作,该操作大体可以分为 3 个步骤:

  • 在从节点中选择新的主节点:选择的原则是,首先过滤掉不健康的从节点;然后选择优先级最高的从节点(由 slave-priority 指定);如果优先级无法区分,则选择复制偏移量最大的从节点;如果仍无法区分,则选择 runid 最小的从节点。

  • 更新主从状态:通过 slaveof no one 命令,让选出来的从节点成为主节点;并通过 slaveof 命令让其他节点成为其从节点。

  • 将已经下线的主节点设置为新的主节点的从节点,当其重新上线后,它会成为新的主节点的从节点。

集群模式


数据分区方案

数据分区有顺序分区、哈希分区等,其中哈希分区由于其天然的随机性,使用广泛;集群的分区方案便是哈希分区的一种。


哈希分区的基本思路是:对数据的特征值(如 key)进行哈希,然后根据哈希值决定数据落在哪个节点。常见的哈希分区包括:哈希取余分区、一致性哈希分区、带虚拟节点的一致性哈希分区等。


衡量数据分区方法好坏的标准有很多,其中比较重要的两个因素是:(1)数据分布是否均匀 (2)增加或删减节点对数据分布的影响。由于哈希的随机性,哈希分区基本可以保证数据分布均匀;因此在比较哈希分区方案时,重点要看增减节点对数据分布的影响。


(1)哈希取余分区


哈希取余分区思路非常简单:计算 key 的 hash 值,然后对节点数量进行取余,从而决定数据映射到哪个节点上。该方案最大的问题是,当新增或删减节点时,节点数量发生变化,系统中所有的数据都需要重新计算映射关系,引发大规模数据迁移。


(2)一致性哈希分区


一致性哈希算法将整个哈希值空间组织成一个虚拟的圆环,如下图所示,范围为 0-2^32-1;对于每个数据,根据 key 计算 hash 值,确定数据在环上的位置,然后从此位置沿环顺时针行走,找到的第一台服务器就是其应该映射到的服务器。与哈希取余分区相比,一致性哈希分区将增减节点的影响限制在相邻节点。


一致性哈希分区的主要问题在于,当节点数量较少时,增加或删减节点,对单个节点的影响可能很大,造成数据的严重不平衡。


(3)带虚拟节点的一致性哈希分区


该方案在一致性哈希分区的基础上,引入了虚拟节点的概念。Redis 集群使用的便是该方案,其中的虚拟节点称为槽(slot)。槽是介于数据和实际节点之间的虚拟概念;每个实际节点包含一定数量的槽,每个槽包含哈希值在一定范围内的数据。引入槽以后,数据的映射关系由数据 hash->实际节点,变成了数据 hash->槽->实际节点。


在使用了槽的一致性哈希分区中,槽是数据管理和迁移的基本单位。槽解耦了数据和实际节点之间的关系,增加或删除节点对系统的影响很小。


槽的数量一般远小于 2^32,远大于实际节点的数量;在 Redis 集群中,槽的数量为 16384。


Redis 集群将数据映射到实际节点的过程:


(1)Redis 对数据的特征值(一般是 key)计算哈希值,使用的算法是 CRC16。


(2)根据哈希值,计算数据属于哪个槽。


(3)根据槽与节点的映射关系,计算数据属于哪个节点。

节点通信机制

两个端口


在哨兵系统中,节点分为数据节点和哨兵节点:前者存储数据,后者实现额外的控制功能。在集群中,没有数据节点与非数据节点之分:所有的节点都存储数据,也都参与集群状态的维护。为此,集群中的每个节点,都提供了两个 TCP 端口:


普通端口:即我们在前面指定的端口(7000 等)。普通端口主要用于为客户端提供服务(与单机节点类似);但在节点间数据迁移时也会使用。

集群端口:端口号是普通端口+10000(10000 是固定值,无法改变),如 7000 节点的集群端口为 17000。集群端口只用于节点之间的通信,如搭建集群、增减节点、故障转移等操作时节点间的通信;不要使用客户端连接集群接口。为了保证集群可以正常工作,在配置防火墙时,要同时开启普通端口和集群端口。


Gossip 协议


节点间通信,按照通信协议可以分为几种类型:单对单、广播、Gossip 协议等。重点是广播和 Gossip 的对比。


广播是指向集群内所有节点发送消息;优点是集群的收敛速度快(集群收敛是指集群内所有节点获得的集群信息是一致的),缺点是每条消息都要发送给所有节点,CPU、带宽等消耗较大。


Gossip 协议的特点是:在节点数量有限的网络中,每个节点都“随机”的与部分节点通信(并不是真正的随机,而是根据特定的规则选择通信的节点),经过一番杂乱无章的通信,每个节点的状态很快会达到一致。Gossip 协议的优点有负载(比广播)低、去中心化、容错性高(因为通信有冗余)等;缺点主要是集群的收敛速度慢。

消息类型


集群中的节点采用固定频率(每秒 10 次)的定时任务进行通信相关的工作:判断是否需要发送消息及消息类型、确定接收节点、发送消息等。如果集群状态发生了变化,如增减节点、槽状态变更,通过节点间的通信,所有节点会很快得知整个集群的状态,使集群收敛。


节点间发送的消息主要分为 5 种:meet 消息、ping 消息、pong 消息、fail 消息、publish 消息。不同的消息类型,通信协议、发送的频率和时机、接收节点的选择等是不同的。


  • MEET 消息:在节点握手阶段,当节点收到客户端的 CLUSTER MEET 命令时,会向新加入的节点发送 MEET 消息,请求新节点加入到当前集群;新节点收到 MEET 消息后会回复一个 PONG 消息。

  • PING 消息:集群里每个节点每秒钟会选择部分节点发送 PING 消息,接收者收到消息后会回复一个 PONG 消息。PING 消息的内容是自身节点和部分其他节点的状态信息;作用是彼此交换信息,以及检测节点是否在线。PING 消息使用 Gossip 协议发送,接收节点的选择兼顾了收敛速度和带宽成本,具体规则如下:(1)随机找 5 个节点,在其中选择最久没有通信的 1 个节点 (2)扫描节点列表,选择最近一次收到 PONG 消息时间大于 cluster_node_timeout/2 的所有节点,防止这些节点长时间未更新。

  • PONG 消息:PONG 消息封装了自身状态数据。可以分为两种:第一种是在接到 MEET/PING 消息后回复的 PONG 消息;第二种是指节点向集群广播 PONG 消息,这样其他节点可以获知该节点的最新信息,例如故障恢复后新的主节点会广播 PONG 消息。

  • FAIL 消息:当一个主节点判断另一个主节点进入 FAIL 状态时,会向集群广播这一 FAIL 消息;接收节点会将这一 FAIL 消息保存起来,便于后续的判断。

  • PUBLISH 消息:节点收到 PUBLISH 命令后,会先执行该命令,然后向集群广播这一消息,接收节点也会执行该 PUBLISH 命令。


数据结构

节点需要专门的数据结构来存储集群的状态。所谓集群的状态,是一个比较大的概念,包括:集群是否处于上线状态、集群中有哪些节点、节点是否可达、节点的主从状态、槽的分布……


节点为了存储集群状态而提供的数据结构中,最关键的是 clusterNode 和 clusterState 结构:前者记录了一个节点的状态,后者记录了集群作为一个整体的状态。

clusterNode


clusterNode 结构保存了一个节点的当前状态,包括创建时间、节点 id、ip 和端口号等。每个节点都会用一个 clusterNode 结构记录自己的状态,并为集群内所有其他节点都创建一个 clusterNode 结构来记录节点状态。

clusterState


clusterState 结构保存了在当前节点视角下,集群所处的状态。除此之外,clusterState 还包括故障转移、槽迁移等需要的信息。


集群命令

的实现这一部分将以 cluster meet(节点握手)、cluster addslots(槽分配)为例,说明节点是如何利用上述数据结构和通信机制实现集群命令的。

cluster meet


假设要向 A 节点发送 cluster meet 命令,将 B 节点加入到 A 所在的集群,则 A 节点收到命令后,执行的操作如下:


  1. A 为 B 创建一个 clusterNode 结构,并将其添加到 clusterState 的 nodes 字典中

  2. A 向 B 发送 MEET 消息

  3. B 收到 MEET 消息后,会为 A 创建一个 clusterNode 结构,并将其添加到 clusterState 的 nodes 字典中

  4. B 回复 A 一个 PONG 消息

  5. A 收到 B 的 PONG 消息后,便知道 B 已经成功接收自己的 MEET 消息

  6. 然后,A 向 B 返回一个 PING 消息

  7. B 收到 A 的 PING 消息后,便知道 A 已经成功接收自己的 PONG 消息,握手完成

  8. 之后,A 通过 Gossip 协议将 B 的信息广播给集群内其他节点,其他节点也会与 B 握手;一段时间后,集群收敛,B 成为集群内的一个普通节点

cluster addslots


集群中槽的分配信息,存储在 clusterNode 的 slots 数组和 clusterState 的 slots 数组中;二者的区别在于:前者存储的是该节点中分配了哪些槽,后者存储的是集群中所有槽分别分布在哪个节点。


cluster addslots 命令接收一个槽或多个槽作为参数,例如在 A 节点上执行 cluster addslots {0..10}命令,是将编号为 0-10 的槽分配给 A 节点,具体执行过程如下:


  1. 遍历输入槽,检查它们是否都没有分配,如果有一个槽已分配,命令执行失败;方法是检查输入槽在 clusterState.slots[]中对应的值是否为 NULL。

  2. 遍历输入槽,将其分配给节点 A;方法是修改 clusterNode.slots[]中对应的比特为 1,以及 clusterState.slots[]中对应的指针指向 A 节点

  3. A 节点执行完成后,通过节点通信机制通知其他节点,所有节点都会知道 0-10 的槽分配给了 A 节点


ASK 错误

集群伸缩的核心是槽迁移。在槽迁移过程中,如果客户端向源节点发送命令,源节点执行流程如下:



客户端收到 ASK 错误后,从中读取目标节点的地址信息,并向目标节点重新发送请求,就像收到 MOVED 错误时一样。但是二者有很大区别:ASK 错误说明数据正在迁移,不知道何时迁移完成,因此重定向是临时的,SMART 客户端不会刷新 slots 缓存;MOVED 错误重定向则是(相对)永久的,SMART 客户端会刷新 slots 缓存。


故障转移

集群的实现与哨兵思路类似:通过定时任务发送 PING 消息检测其他节点状态;节点下线分为主观下线和客观下线;客观下线后选取从节点进行故障转移。

与哨兵一样,集群只实现了主节点的故障转移;从节点故障时只会被下线,不会进行故障转移。因此,使用集群时,应谨慎使用读写分离技术,因为从节点故障会导致读服务不可用,可用性变差。


节点数量:在故障转移阶段,需要由主节点投票选出哪个从节点成为新的主节点;从节点选举胜出需要的票数为 N/2+1;其中 N 为主节点数量(包括故障主节点),但故障主节点实际上不能投票。因此为了能够在故障发生时顺利选出从节点,集群中至少需要 3 个主节点(且部署在不同的物理机上)。

故障转移时间:从主节点故障发生到完成转移,所需要的时间主要消耗在主观下线识别、主观下线传播、选举延迟等几个环节;具体时间与参数 cluster-node-timeout 有关,一般来说:

故障转移时间(毫秒) ≤ 1.5 * cluster-node-timeout + 1000

cluster-node-timeout 的默认值为 15000ms(15s),因此故障转移时间会在 20s 量级。

数据结构

SDS

Redis 没有直接使用 C 字符串(即以空字符’\0’结尾的字符数组)作为默认的字符串表示,而是使用了 SDS。SDS 是简单动态字符串(Simple Dynamic String)的缩写。

SDS 结构

sds 的结构如下:

struct sdshdr {

int len;

int free;

char buf[];

};

其中,buf 表示字节数组,用来存储字符串;len 表示 buf 已使用的长度,free 表示 buf 未使用的长度。下面是两个例子。




通过 SDS 的结构可以看出,buf 数组的长度=free+len+1(其中 1 表示字符串结尾的空字符);所以,一个 SDS 结构占据的空间为:free 所占长度+len 所占长度+ buf 数组的长度=4+4+free+len+1=free+len+9。

SDS 与 C 字符串的比较

SDS 在 C 字符串的基础上加入了 free 和 len 字段,带来了很多好处:

  • 获取字符串长度:SDS 是 O(1),C 字符串是 O(n)

  • 缓冲区溢出:使用 C 字符串的 API 时,如果字符串长度增加(如 strcat 操作)而忘记重新分配内存,很容易造成缓冲区的溢出;而 SDS 由于记录了长度,相应的 API 在可能造成缓冲区溢出时会自动重新分配内存,杜绝了缓冲区溢出。

  • 修改字符串时内存的重分配:对于 C 字符串,如果要修改字符串,必须要重新分配内存(先释放再申请),因为如果没有重新分配,字符串长度增大时会造成内存缓冲区溢出,字符串长度减小时会造成内存泄露。而对于 SDS,由于可以记录 len 和 free,因此解除了字符串长度和空间数组长度之间的关联,可以在此基础上进行优化:空间预分配策略(即分配内存时比实际需要的多)使得字符串长度增大时重新分配内存的概率大大减小;惰性空间释放策略使得字符串长度减小时重新分配内存的概率大大减小。

  • 存取二进制数据:SDS 可以,C 字符串不可以。因为 C 字符串以空字符作为字符串结束的标识,而对于一些二进制文件(如图片等),内容可能包括空字符串,因此 C 字符串无法正确存取;而 SDS 以字符串长度 len 来作为字符串结束标识,因此没有这个问题。

此外,由于 SDS 中的 buf 仍然使用了 C 字符串(即以’\0’结尾),因此 SDS 可以使用 C 字符串库中的部分函数;但是需要注意的是,只有当 SDS 用来存储文本数据时才可以这样使用,在存储二进制数据时则不行(’\0’不一定是结尾)。

SDS 与 C 字符串的应用

Redis 在存储对象时,一律使用 SDS 代替 C 字符串。例如 set hello world 命令,hello 和 world 都是以 SDS 的形式存储的。而 sadd myset member1 member2 member3 命令,不论是键(”myset”),还是集合中的元素(”member1”、 ”member2”和”member3”),都是以 SDS 的形式存储。除了存储对象,SDS 还用于存储各种缓冲区。

只有在字符串不会改变的情况下,如打印日志时,才会使用 C 字符串。

redisObject

前面说到,Redis 对象有 5 种类型;无论是哪种类型,Redis 都不会直接存储,而是通过 redisObject 对象进行存储。

redisObject 对象非常重要,Redis 对象的类型、内部编码、内存回收、共享对象等功能,都需 redisObject 支持,下面将通过 redisObject 的结构来说明它是如何起作用的。

redisObject 的定义如下:

typedef struct redisObject {

  unsigned type:4;

  unsigned encoding:4;

  unsigned lru:REDIS_LRU_BITS; /* lru time (relative to server.lruclock) */

  int refcount;

  void *ptr;

} robj;

redisObject 的每个字段的含义和作用如下:

type

type 字段表示对象的类型,占 4 个比特;目前包括 REDIS_STRING(字符串)、REDIS_LIST (列表)、REDIS_HASH(哈希)、REDIS_SET(集合)、REDIS_ZSET(有序集合)。

当我们执行 type 命令时,便是通过读取 RedisObject 的 type 字段获得对象的类型。

encoding

encoding 表示对象的内部编码,占 4 个比特。

对于 Redis 支持的每种类型,都有至少两种内部编码,例如对于字符串,有 int、embstr、raw 三种编码。通过 encoding 属性,Redis 可以根据不同的使用场景来为对象设置不同的编码,大大提高了 Redis 的灵活性和效率。

通过 object encoding 命令,可以查看对象采用的编码方式。

lru

lru 记录的是对象最后一次被命令程序访问的时间,占据的比特数不同的版本有所不同。

通过对比 lru 时间与当前时间,可以计算某个对象的空转时间;object idletime 命令可以显示该空转时间(单位是秒)。object idletime 命令的一个特殊之处在于它不改变对象的 lru 值。


lru 值除了通过 object idletime 命令打印之外,还与 Redis 的内存回收有关系:如果 Redis 打开了 maxmemory 选项,且内存回收算法选择的是 volatile-lru 或 allkeys—lru,那么当 Redis 内存占用超过 maxmemory 指定的值时,Redis 会优先选择空转时间最长的对象进行释放。

refcount

refcount 与共享对象

refcount 记录的是该对象被引用的次数,类型为整型,占 4 个字节。refcount 的作用,主要在于对象的引用计数和内存回收。当创建新对象时,refcount 初始化为 1;当有新程序使用该对象时,refcount 加 1;当对象不再被一个新程序使用时,refcount 减 1;当 refcount 变为 0 时,对象占用的内存会被释放。

Redis 中被多次使用的对象(refcount>1),称为共享对象。Redis 为了节省内存,当有一些对象重复出现时,新的程序不会创建新的对象,而是仍然使用原来的对象。这个被重复使用的对象,就是共享对象。目前共享对象仅支持整数值的字符串对象。

共享对象的具体实现

Redis 的共享对象目前只支持整数值的字符串对象。之所以如此,实际上是对内存和 CPU(时间)的平衡:共享对象虽然会降低内存消耗,但是判断两个对象是否相等却需要消耗额外的时间。对于整数值,判断操作复杂度为 O(1);对于普通字符串,判断复杂度为 O(n);而对于哈希、列表、集合和有序集合,判断的复杂度为 O(n^2)。

虽然共享对象只能是整数值的字符串对象,但是 5 种类型都可能使用共享对象(如哈希、列表等的元素可以使用)。

就目前的实现来说,Redis 服务器在初始化时,会创建 10000 个字符串对象,值分别是 0~9999 的整数值;当 Redis 需要使用值为 0~9999 的字符串对象时,可以直接使用这些共享对象。10000 这个数字可以通过调整参数 REDIS_SHARED_INTEGERS(4.0 中是 OBJ_SHARED_INTEGERS)的值进行改变。

共享对象的引用次数可以通过 object refcount 命令查看。

ptr

ptr 指针指向具体的数据,如前面的例子中,set hello world,ptr 指向包含字符串 world 的 SDS。ptr 指针占据的字节数与系统有关,例如 64 位系统中占 8 个字节。

分布式锁

详解:https://www.cnblogs.com/ironPhoenix/p/6048467.html

不足:https://blog.csdn.net/hh1sdfsf56456/article/details/79474434

实现:https://www.jianshu.com/p/7e47a4503b87

普通实现

说道 Redis 分布式锁大部分人都会想到:setnx+lua,或者知道set key value px milliseconds nx。后一种方式的核心实现命令如下:

- 获取锁(unique_value可以是UUID等)SET resource_name unique_value NX PX 30000
- 释放锁(lua脚本中,一定要比较value,防止误解锁)if redis.call("get",KEYS[1]) == ARGV[1] then return redis.call("del",KEYS[1])else return 0end
复制代码

这种实现方式有 3 大要点:

  1. set 命令要用set key value px milliseconds nx

  2. value 要具有唯一性;

  3. 释放锁时要验证 value 值,不能误解锁;

事实上这类琐最大的缺点就是它加锁时只作用在一个 Redis 节点上,即使 Redis 通过 sentinel 保证高可用,如果这个 master 节点由于某些原因发生了主从切换,那么就会出现锁丢失的情况:

  1. 在 Redis 的 master 节点上拿到了锁;

  2. 但是这个加锁的 key 还没有同步到 slave 节点;

  3. master 故障,发生故障转移,slave 节点升级为 master 节点;

  4. 导致锁丢失。

正因为如此,Redis 作者 antirez 基于分布式环境下提出了一种更高级的分布式锁的实现方式:Redlock

Redlock 实现

antirez 提出的 redlock 算法大概是这样的:

在 Redis 的分布式环境中,我们假设有 N 个 Redis master。这些节点完全互相独立,不存在主从复制或者其他集群协调机制。我们确保将在 N 个实例上使用与在 Redis 单实例下相同方法获取和释放锁。现在我们假设有 5 个 Redis master 节点,同时我们需要在 5 台服务器上面运行这些 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 实例根本就没有加锁成功,防止某些节点获取到锁但是客户端没有得到响应而导致接下来的一段时间不能被重新获取锁)。

Redlock 源码

redisson 已经有对 redlock 算法封装,接下来对其用法进行简单介绍,并对核心源码进行分析(假设 5 个 redis 实例)。

  • POM 依赖

<!-- https://mvnrepository.com/artifact/org.redisson/redisson --><dependency>    <groupId>org.redisson</groupId>    <artifactId>redisson</artifactId>    <version>3.3.2</version></dependency>
复制代码

用法

首先,我们来看一下 redission 封装的 redlock 算法实现的分布式锁用法,非常简单,跟重入锁(ReentrantLock)有点类似:

Config config1 = new Config();config1.useSingleServer().setAddress("redis://192.168.0.1:5378")        .setPassword("a123456").setDatabase(0);RedissonClient redissonClient1 = Redisson.create(config1);
Config config2 = new Config();config2.useSingleServer().setAddress("redis://192.168.0.1:5379") .setPassword("a123456").setDatabase(0);RedissonClient redissonClient2 = Redisson.create(config2);
Config config3 = new Config();config3.useSingleServer().setAddress("redis://192.168.0.1:5380") .setPassword("a123456").setDatabase(0);RedissonClient redissonClient3 = Redisson.create(config3);
String resourceName = "REDLOCK_KEY";
RLock lock1 = redissonClient1.getLock(resourceName);RLock lock2 = redissonClient2.getLock(resourceName);RLock lock3 = redissonClient3.getLock(resourceName);// 向3个redis实例尝试加锁RedissonRedLock redLock = new RedissonRedLock(lock1, lock2, lock3);boolean isLock;try { // isLock = redLock.tryLock(); // 500ms拿不到锁, 就认为获取锁失败。10000ms即10s是锁失效时间。 isLock = redLock.tryLock(500, 10000, TimeUnit.MILLISECONDS); System.out.println("isLock = "+isLock); if (isLock) { //TODO if get lock success, do something; }} catch (Exception e) {} finally { // 无论如何, 最后都要解锁 redLock.unlock();}
复制代码

唯一 ID

实现分布式锁的一个非常重要的点就是 set 的 value 要具有唯一性,redisson 的 value 是怎样保证 value 的唯一性呢?答案是 UUID+threadId。入口在 redissonClient.getLock("REDLOCK_KEY"),源码在 Redisson.java 和 RedissonLock.java 中:

protected final UUID id = UUID.randomUUID();String getLockName(long threadId) {    return id + ":" + threadId;}
复制代码

获取锁

获取锁的代码为 redLock.tryLock()或者 redLock.tryLock(500, 10000, TimeUnit.MILLISECONDS),两者的最终核心源码都是下面这段代码,只不过前者获取锁的默认租约时间(leaseTime)是 LOCK_EXPIRATION_INTERVAL_SECONDS,即 30s:

<T> RFuture<T> tryLockInnerAsync(long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) {    internalLockLeaseTime = unit.toMillis(leaseTime);    // 获取锁时需要在redis实例上执行的lua命令    return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, command,              // 首先分布式锁的KEY不能存在,如果确实不存在,那么执行hset命令(hset REDLOCK_KEY uuid+threadId 1),并通过pexpire设置失效时间(也是锁的租约时间)              "if (redis.call('exists', KEYS[1]) == 0) then " +                  "redis.call('hset', KEYS[1], ARGV[2], 1); " +                  "redis.call('pexpire', KEYS[1], ARGV[1]); " +                  "return nil; " +              "end; " +              // 如果分布式锁的KEY已经存在,并且value也匹配,表示是当前线程持有的锁,那么重入次数加1,并且设置失效时间              "if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +                  "redis.call('hincrby', KEYS[1], ARGV[2], 1); " +                  "redis.call('pexpire', KEYS[1], ARGV[1]); " +                  "return nil; " +              "end; " +              // 获取分布式锁的KEY的失效时间毫秒数              "return redis.call('pttl', KEYS[1]);",              // 这三个参数分别对应KEYS[1],ARGV[1]和ARGV[2]                Collections.<Object>singletonList(getName()), internalLockLeaseTime, getLockName(threadId));}
复制代码

获取锁的命令中,

  • KEYS[1]就是 Collections.singletonList(getName()),表示分布式锁的 key,即 REDLOCK_KEY;

  • ARGV[1]就是 internalLockLeaseTime,即锁的租约时间,默认 30s;

  • ARGV[2]就是 getLockName(threadId),是获取锁时 set 的唯一值,即 UUID+threadId:


释放锁

释放锁的代码为 redLock.unlock(),核心源码如下:

protected RFuture<Boolean> unlockInnerAsync(long threadId) {    // 释放锁时需要在redis实例上执行的lua命令    return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,            // 如果分布式锁KEY不存在,那么向channel发布一条消息            "if (redis.call('exists', KEYS[1]) == 0) then " +                "redis.call('publish', KEYS[2], ARGV[1]); " +                "return 1; " +            "end;" +            // 如果分布式锁存在,但是value不匹配,表示锁已经被占用,那么直接返回            "if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then " +                "return nil;" +            "end; " +            // 如果就是当前线程占有分布式锁,那么将重入次数减1            "local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); " +            // 重入次数减1后的值如果大于0,表示分布式锁有重入过,那么只设置失效时间,还不能删除            "if (counter > 0) then " +                "redis.call('pexpire', KEYS[1], ARGV[2]); " +                "return 0; " +            "else " +                // 重入次数减1后的值如果为0,表示分布式锁只获取过1次,那么删除这个KEY,并发布解锁消息                "redis.call('del', KEYS[1]); " +                "redis.call('publish', KEYS[2], ARGV[1]); " +                "return 1; "+            "end; " +            "return nil;",            // 这5个参数分别对应KEYS[1],KEYS[2],ARGV[1],ARGV[2]和ARGV[3]            Arrays.<Object>asList(getName(), getChannelName()), LockPubSub.unlockMessage, internalLockLeaseTime, getLockName(threadId));
}
复制代码


应用场景

缓存热点数据

计数器

队列(List)

位操作(大数据处理)

最新列表(List)

排行榜(ZSet)

滑动窗口限流(ZSet)

用户头像

ltc

关注

ltc 2019.07.04 加入

还未添加个人简介

评论

发布
暂无评论
Redis