redis 数据结构 内存管理 持久化

为什么80%的码农都做不了架构师?>>>   hot3.png

  • Redis 内存数据结构与编码
    • OBJECT encoding key、DEBUG OBJECT key
    • 简单动态字符串(simple dynamic string)
    • 链表(linked list)
    • 字典(dict)
    • 跳表(skip list)
    • 整数集合(int set)
    • 压缩表(zip list)
    • Redis Object 类型与映射
  • Redis 内存管理策略
    • 键 过期时间、生存时间
    • 过期键删除策略
    • AOF 、RDB 处理过期键策略
    • Redis LRU 算法
  • Redis 持久化方式
    • AOF (Append-only file)
    • RDB (Redis DataBase)

Redis 数据类型特点与使用场景

redis 为我们提供了 5 种数据类型,基本上我们使用频率最高的就是 string ,而对其他四种数据类型使用的频次稍弱于 string 。

一方面是由于 string 使用起来比较简单,可以方便存储复杂大对象,使用场景比较多。还有一个原因就是由于 redis expire time 只能设置在 key 上,像 listhashsetzset 属于集合类型,会管理一组 item,我们无法在这些集合的 item 上设置过期时间,所以使用expire time 来处理集合的 cache 失效会变得稍微复杂些。但是 string 使用 expire time 来管理过期策略会比较简单,因为它包含的项少。这里说的集合是宽泛的类似集合。

导致我们习惯性的使用 string 而忽视其他四种数据类型的另一个深层次原因,大多是由于我们对另外四种数据类型的使用和原理不是太了解。这个时候往往会忽视在特定场景下使用某种数据类型可能会比 string 性能高出很多,比如使用 hash 结构来提高某个实体的某个项的修改等。

这里我们不打算罗列这 5 种数据类型的使用方法,这些资料网上有很多。我们主要讨论这 5 种数据类型的功能特点,这些特点分别适合用于处理哪些现实的业务场景,最重要的是我们如何组合性的使用这 5 种数据类型来解决复杂的 cache 问题。

String、List、Hash、Set、Zset

String

string 是 redis 提供的字符串类型。可以针对 string 类型独立设置 expire time 。通常用来存储长字符串数据,比如,某个对象的 json字符串。

string 类型我们在使用上最巧妙的是可以动态拼接 key。通常我们可以将一组 id 放在 set 里,然后动态查找 string 还是否存在,如果不存在说明已经过期或者由于数据修改主动 delete 了,需要再做一次 cache 数据 load 。

虽然 set 无法设置 item 的过期时间,但是我们可以将 set item 与 string key 关联来达到相同的效果。

redis 数据结构 内存管理 持久化_第1张图片

上图中的左边是一个 key 为 set:order:ids 的 set 集合,它可能是一个全量集合,也可能是某个查询条件获取出来的一个集合。

有时候复杂点的场景需要多个 set 集合来支撑计算,在 redis 服务器 里可能会有很多类似这样的集合。

这些集合我们可以称为 功能数据,这些数据是用来辅助 cache 计算的,当进行各种集合运算之后会得出当前查询需要返回的子集,最后我们才会去获取某个订单真正的数据。

这些 string:order:{orderId} 字符串 key 并不一定是为了服务一种场景,而是整个系统最底层的数据,各种场景最后都需要获取这些数据。那些 set 集合可以认为是查询条件数据,用来辅助查询条件的计算。

redis 为我们提供了 TYPE 命令来查看某个 key 的数据类型,如:string 类型:

SET string:order:100 order-100
TYPE string:order:100

string

List

list 在提高 throughput 的场景中非常适用,因为它特有的 LPUSHRPUSHLPOPRPOP 功能可以无缝的支持生产者、消费者架构模式。

这非常适合实现类似 Java Concurrency Fork/Join 框架中的 work-stealing 算法 (工作窃取) 。

java fork/join 框架使用并行来提高性能,但是会带来由于并发 take task 带来的 race condition (竞态条件) 问题,所以采用 work-stealing 算法 来解决由于竞争问题带来的性能损耗。

redis 数据结构 内存管理 持久化_第2张图片

上图中模拟了一个典型的支付 callback 峰值场景。在峰值出现的地方一般我们都会使用加 buffer 的方式来加快请求处理速度,这样才能提高并发处理能力,提高 throughput 。

支付 gateway 收到 callback 之后不做任何处理直接交给 分发器 。分发器 是一个无状态的 cluster ,每个 node 通过向 注册中心pull handler queue list ,也就是获取下游处理器注册到注册中心里的消息通道。

每一个分发器 node 会维护一个本地 queue list ,然后顺序推送消息到这些 queue list 即可。这里会有点小问题,就是 支付 gateway调用分发器的时候是如何做 load balance ,如果不是平均负载可能会有某个 queue list 高出其他 queue list 。

而分发器不需要做 soft load balance ,因为哪怕某个 queue list 比其他 queue list 多也无所谓,因为下游 message handler 会根据 work-stealing 算法来窃取其他消费慢的 queue list 。

redis list 的 LPUSHRPUSHLPOPRPOP 特性确实可以在很多场景下提高这种横向扩展计算能力。

Hash

hash 数据类型很明显是基于 hash 算法的,对于项的查找时间复杂度是 O(1) 的,在极端情况下可能出现项 hash 冲突问题,redis 内部是使用链表加 key 判断来解决的。具体 redis 内部的数据结构我们在后面有介绍,这里就不展开了。

hash 数据类型的特点通常可以用来解决带有映射关系,同时又需要对某些项进行更新或者删除等操作。如果不是某个项需要维护,那么一般可以通过使用 string 来解决。

如果有需要对某个字段进行修改,使用 string 很明显是会多出很多开销,需要读取出来反序列化成对象然后操作,然后再序列化写回 redis,这中间可能还有并发问题。

那我们可以使用 redis hash 提供的实体属性 hash 存储特性,我们可以认为 hash value 是一个 hash table ,实体的每一个属性都是通过 hash 得到属性的最终数据索引。

redis 数据结构 内存管理 持久化_第3张图片

上图使用 hash 数据类型来记录页面的 a/b metrics ,左边的是首页 index 的各个区域的统计,右边是营销 marketing 的各个区域统计。

在程序里我们可以很方便的使用 redis 的 atomic 特性对 hash 某个项进行累加操作。

HMSET hash:mall:page:ab:metrics:index topbanner 10 leftbanner 5 rightbanner 8 bottombanner 20 productmore 10 topshopping 8
OK
HGETALL hash:mall:page:ab:metrics:index
 1) "topbanner"
 2) "10"
 3) "leftbanner"
 4) "5"
 5) "rightbanner"
 6) "8"
 7) "bottombanner"
 8) "20"
 9) "productmore"
10) "10"
11) "topshopping"
12) "8"
HINCRBY hash:mall:page:ab:metrics:index topbanner 1
(integer) 11

使用 redis hash increment 进行原子增加操作。HINCRBY 命令可以原子增加任何给定的整数,也可以通过 HINCRBYFLOAT 来原子增加浮点类型数据。

Set

set 集合数据类型可以支持集合运算,不能存储重复数据。

set 最大的特点就是集合的计算能力,inter 交集union 并集diff 差集,这些特点可以用来做高性能的交叉计算或者剔除数据。

set 集合在使用场景上还是比较多和自由的。举个简单的例子,在应用系统中比较常见的就是商品、活动类场景。用一个 set 缓存有效商品集合,再用一个 set 缓存活动商品集合。如果商品出现上下架操作只需要维护有效商品 set ,每次获取活动商品的时候需要过滤下是否有下架商品,如果有就需要从活动商品中剔除。

当然,下架的时候可以直接删除缓存的活动商品,但是活动是从 marketing 系统中 load 出来的,就算我将 cache 里的活动商品删除,当下次再从 marketing 系统中 load 活动商品时候还是会有下架商品。当然这只是举例,一个场景有不同的实现方法。

redis 数据结构 内存管理 持久化_第4张图片

上图中左右两边是两个不同的集合,左边是营销域中的可用商品ids集合,右边是营销域中活动商品ids集合,中间计算出两个集合的交集。

SADD set:marketing:product:available:ids 1000100 1000120 1000130 1000140 1000150 1000160
SMEMBERS set:marketing:product:available:ids
1) "1000100"
2) "1000120"
3) "1000130"
4) "1000140"
5) "1000150"
6) "1000160"
SADD set:marketing:activity:product:ids 1000100 1000120 1000130 1000140 1000200 1000300
SMEMBERS set:marketing:activity:product:ids
1) "1000100"
2) "1000120"
3) "1000130"
4) "1000140"
5) "1000200"
6) "1000300"
SINTER set:marketing:product:available:ids set:marketing:activity:product:ids
1) "1000100"
2) "1000120"
3) "1000130"
4) "1000140"

在一些复杂的场景中,也可以使用 SINTERSTORE 命令将交集计算后的结果存储在一个目标集合中。 这在使用 pipeline 命令管道中特别有用,将 SINTERSTORE 命令包裹在 pipeline 命令串中可以重复使用计算出来的结果集。

由于 redis 是 Signle-Thread 单线程模型 ,基于这个特性我们就可以使用 redis 提供的 pipeline 管道 来提交一连串带有逻辑的命令集合,这些命令在处理期间不会被其他客户端的命令干扰。

Zset

zset 排序集合与 set 集合类似,但是 zset 提供了排序的功能。在介绍 set 集合的时候我们知道 set 集合中的成员是无序的,zset 填补了集合可以排序的空隙。

zset 最强大的功能就是可以根据某个 score 比分值 进行排序,这在很多业务场景中非常急需。比如,在促销活动里根据商品的销售数量来排序商品,在旅游景区里根据流入人数来排序热门景点等。

基本上人们在做任何事情都需要根据某些条件进行排序。

其实 zset 在我们应用系统中能用到地方到处都是,这里我们举一个简单的例子,在团购系统中我们通常需要根据参团人数来排序成团列表,大家都希望参加那些即将成团的团。

redis 数据结构 内存管理 持久化_第5张图片

上图是一个根据团购code创建的zset,score 分值 就是参团人数累加和。

ZADD zset:marketing:groupon:group:codes 5 G_PXYJY9QQFA 8 G_4EXMT6NZJQ 20 G_W7BMF5QC2P 10 G_429DHBTGZX 8 G_KHZGH9U4PP
ZREVRANGEBYSCORE zset:marketing:groupon:group:codes 1000 0
1) "G_W7BMF5QC2P"
2) "G_ZMZ69HJUCB"
3) "G_429DHBTGZX"
4) "G_KHZGH9U4PP"
5) "G_4EXMT6NZJQ"
6) "G_PXYJY9QQFA"
ZREVRANGEBYSCORE zset:marketing:groupon:group:codes 1000 0 withscores
 1) "G_W7BMF5QC2P"
 2) "20"
 3) "G_ZMZ69HJUCB"
 4) "10"
 5) "G_429DHBTGZX"
 6) "10"
 7) "G_KHZGH9U4PP"
 8) "8"
 9) "G_4EXMT6NZJQ"
10) "8"
11) "G_PXYJY9QQFA"
12) "5"

zset 本身提供了很多方法用来进行集合的排序,如果需要 score 分值可以使用 withscore 字句带出每一项的分值。

在一些比较特殊的场合可能需要组合排序,可能有多个 zset 分别用来对同一个实体在不同维度的排序,按时间排序、按人数排序等。这个时候就可以组合使用 zset 带来的便捷性,利用 pipeline 再结合多个 zset 最终得出组合排序集合。

Redis 内存数据结构与编码

我们已经了解了 redis 提供的 5 种数据类型,那么 redis 内部到底是如何支持这 5 种数据类型的,也就是说 redis 到底是使用什么样的数据结构来存储、查找我们设置在内存中的数据。

虽然我们使用 5 种数据类型来缓存数据,但是 redis 会根据我们存储数据的不同而选用不同的数据结构和编码。

redis 数据结构 内存管理 持久化_第6张图片

我们日常使用的是 redis 提供的 5 种数据类型,但是这 5 种数据类型在内存中的数据结构和编码有很多种。随着我们存储的数据类型的不同、数据量的大小不同都会引起内存数据结构的动态调整。

本节只是做数据结构和编码的一般性介绍,不做过多细节讨论,一方面是关于 redis 源码分析的资料网上有很多,还有一个原因就是 redis 每一个版本的实现有很大差异,一旦展开细节讨论每一个点每一个数据结构都会很复杂,所以我们这里就不展开讨论这些,只是起到抛砖引玉作用。

OBJECT encoding key、DEBUG OBJECT key

我们知道使用 type 命令可以查看某个 key 是否是 5 种数据类型之一,但是当我们想查看某个 key 底层是使用哪种数据结构和编码来存储的时候可以使用 OBJECT encoding 命令。

SET string:orderid:10101010 10101010
OK
OBJECT encoding string:orderid:10101010
"int"
SET string:orderid:10101010 "orderid:10101010"
OK
OBJECT encoding string:orderid:10101010
"embstr"

同样一个 key ,但是由于我们设置的值不同而 redis 选用了不同的内存数据结构和编码。虽然 redis 提供的 string 数据类型,但是 redis会自动识别我们 cache 的数据类型是 int 还是 string 。

如果我们设置的是字符串,且这个字符串长度不大于 39 字节那么将使用 embstr 来编码,如果大于 39 字节将使用 raw 来编码。redis 4.0 将这个阀值扩大了 45 个字节。

除了使用 OBJECT encoding 命令外,我们还可以使用 DEBUG OBJECT 命令来查看更多详细信息。

DEBUG OBJECT string:orderid:10101010
Value at:0x7fd190500210 refcount:1 encoding:int serializedlength:5 lru:6468044 lru_seconds_idle:8
DEBUG OBJECT string:orderid:10101010
Value at:0x7fd19043be60 refcount:1 encoding:embstr serializedlength:17 lru:6465804 lru_seconds_idle:1942

DEBUG OBJECT 能看到这个对象的 refcount 引用计数 、serializedlength 长度 、lru_seconds_idle 时间 ,这些信息决定了这个key 缓存清除策略。

简单动态字符串(simple dynamic string)

简单动态字符串简称 SDS ,在 redis 中所有涉及到字符串的地方都是使用 SDS 实现,当然这里不包括字面量。 SDS 与传统 C 字符串的区别就是 SDS 是结构化的,它可以高效的处理分配、回收、长度计算等问题。

struct sdshdr {
    unsigned int len;
    unsigned int free;
    char buf[];
};

这是 redis 3.0 版本的 sds.h 头文件定义,3.0.0 之后变化比较大。len 表示字符串长度,free 表示空间长度,buf 数组表示字符串。

SDS 有很多优点,比如,获取长度的时间复杂度 O(1) ,不需要遍历所有 char buf[] 组数,直接返回 len 值。

static inline size_t sdslen(const sds s) {
    struct sdshdr *sh = (void*)(s-(sizeof(struct sdshdr)));
    return sh->len;
}

当然还有空间分配检查、空间预分配、空间惰性释放等,这些都是 SDS 结构化字符串带来的强大的扩展能力。

链表(linked list)

链表数据结构我们是比较熟悉的,最大的特点就是节点的增、删非常灵活。redis List 数据类型底层就是基于链表来实现。这是 redis 3.0实现。

typedef struct list {
    listNode *head;
    listNode *tail;
    void *(*dup)(void *ptr);
    void (*free)(void *ptr);
    int (*match)(void *ptr, void *key);
    unsigned long len;
} list;
typedef struct listNode {
    struct listNode *prev;
    struct listNode *next;
    void *value;
} listNode;

在 redis 3.2.0 版本的时候引入了 quicklist 链表结构,结合了 linkedlist 和 ziplist 的优势。

typedef struct quicklist {
    quicklistNode *head;
    quicklistNode *tail;
    unsigned long count;        /* total count of all entries in all ziplists */
    unsigned int len;           /* number of quicklistNodes */
    int fill : 16;              /* fill factor for individual nodes */
    unsigned int compress : 16; /* depth of end nodes not to compress;0=off */
} quicklist;
typedef struct quicklistNode {
    struct quicklistNode *prev;
    struct quicklistNode *next;
    unsigned char *zl;
    unsigned int sz;             /* ziplist size in bytes */
    unsigned int count : 16;     /* count of items in ziplist */
    unsigned int encoding : 2;   /* RAW==1 or LZF==2 */
    unsigned int container : 2;  /* NONE==1 or ZIPLIST==2 */
    unsigned int recompress : 1; /* was this node previous compressed? */
    unsigned int attempted_compress : 1; /* node can't compress; too small */
    unsigned int extra : 10; /* more bits to steal for future usage */
} quicklistNode;

quicklist 提供了灵活性同时也兼顾了 ziplist 的压缩能力,quicklist->encoding 指定了两种压缩算法。 quicklist->compress 表示我们可以进行 quicklist node 的深度压缩能力。redis 提供了两个有关于压缩的配置。

list-max-ziplist-size:ziplist长度控制
list-compress-depth:控制链表两端节点的压缩个数,越是靠近两端的节点被访问的机率越大,所以可以将访问机率大的节点不压缩,其他节点进行压缩

对比 redis 3.2 的 quicklist 与 redis 3.0 ,很明显 quicklist 提供了更加丰富的压缩功能。redis 3.0 的版本是每个 listnode 直接缓存值,而 quicklistnode 还有强大的有关于压缩能力。

LPUSH list:products:mall 100 200 300
(integer) 3
OBJECT encoding list:products:mall
"quicklist"

字典(dict)

dict 字典是基于 hash算法 来实现,是 Hash 数据类型的底层存储数据结构。我们来看下 redis 3.0.0 版本的 dict.h 头文件定义。

typedef struct dict {
    dictType *type;
    void *privdata;
    dictht ht[2];
    long rehashidx;
    int iterators; 
} dict;
typedef struct dictht {
    dictEntry **table;
    unsigned long size;
    unsigned long sizemask;
    unsigned long used;
} dictht;
typedef struct dictEntry {
    void *key;
    union {
        void *val;
        uint64_t u64;
        int64_t s64;
        double d;
    } v;
    struct dictEntry *next;
} dictEntry;

说到 hash table 有两个东西是我们经常会碰到的,首先就是 hash 碰撞 问题,redis dict 是采用链地址法来解决,dictEntry->next 就是指向下个冲突 key 的节点。

还有一个经常碰到的就是 rehash 的问题,提到 rehash 我们还是有点担心性能的。那么redis 实现是非常巧妙的,采用 惰性渐进式 rehash 算法 。

在 dict struct 里有一个 ht[2] 组数,还有一个 rehashidx 索引。redis 进行 rehash 的大致算法是这样的,首先会开辟一个新的 dictht 空间,放在 ht[2] 索引上,此时将 rehashidx 设置为0,表示开始进入 rehash 阶段,这个阶段可能会持续很长时间,rehashidx 表示 dictEntry 个数。

每次当有对某个 ht[1] 索引中的 key 进行访问时,获取、删除、更新,redis 都会将当前 dictEntry 索引中的所有 key rehash 到 ht[2] 字典中。一旦 rehashidx=-1 表示 rehash 结束。

跳表(skip list)

skip list 是 zset 的底层数据结构,有着高性能的查找排序能力。

我们都知道一般用来实现带有排序的查找都是用 Tree 来实现,不管是各种变体的 B Tree 还是 B+ Tree,本质都是用来做顺序查找。

skip list 实现起来简单,性能也与 B Tree 相接近。

typedef struct zskiplistNode {
    robj *obj;
    double score;
    struct zskiplistNode *backward;
    struct zskiplistLevel {
        struct zskiplistNode *forward;
        unsigned int span;
    } level[];
} zskiplistNode;

typedef struct zskiplist {
    struct zskiplistNode *header, *tail;
    unsigned long length;
    int level;
} zskiplist;

zskiplistNode->zskiplistLevel->span 这个值记录了当前节点距离下个节点的跨度。每一个节点会有最大不超过 zskiplist->level 节点个数,分别用来表示不同跨度与节点的距离。

每个节点会有多个 forward 向前指针,只有一个 backward 指针。每个节点会有对象 *obj 和 score 分值,每个分值都会按照顺序排列。

整数集合(int set)

int set 整数集合是 set 数据类型的底层实现数据结构,它的特点和使用场景很明显,只要我们使用的集合都是整数且在一定的范围之内都会使用整数集合编码。

SADD set:userid 100 200 300
(integer) 3
OBJECT encoding set:userid
"intset"

int set 使用一块连续的内存来存储集合数据,它是数组结构不是链表结构。

typedef struct intset {
    uint32_t encoding;
    uint32_t length;
    int8_t contents[];
} intset;

intset->encoding 用来确定 contents[] 是什么类型的整数编码,以下三种值之一。

#define INTSET_ENC_INT16 (sizeof(int16_t))
#define INTSET_ENC_INT32 (sizeof(int32_t))
#define INTSET_ENC_INT64 (sizeof(int64_t))

redis 会根据我们设置的值类型动态 sizeof 出一个对应的空间大小。如果我们集合原来是 int16 ,然后往集合里添加了int32 整数将触发升级,一旦升级成功不会触发降级操作。

压缩表(zip list)

zip list 压缩表是 listzsethash 数据类型的底层数据结构之一。它是为了节省内存通过压缩数据存储在一块连续的内存空间中。

typedef struct zlentry {
    unsigned int prevrawlensize, prevrawlen;
    unsigned int lensize, len;
    unsigned int headersize;
    unsigned char encoding;
    unsigned char *p;
} zlentry;

它最大的优点就是压缩空间,空间利用率很高。缺点就是一旦出现更新可能就是连锁更新,因为数据在内容空间中都是连续的,最极端情况下就是可能出现顺序连锁扩张。

压缩列表会由多个 zlentry 节点组成,每一个 zlentry 记录上一个节点长度和大小,当前节点长度 lensize 和大小 len包括编码 encoding 。

这取决于业务场景,redis 提供了一组配置,专门用来针对不同的场景进行阈值控制。

hash-max-ziplist-entries 512
hash-max-ziplist-value 64
list-max-ziplist-entries 512
list-max-ziplist-value 64
zset-max-ziplist-entries 128
zset-max-ziplist-value 64

上述配置分别用来配置 ziplist 作为 hash 、listzset 数据类型的底层压缩阈值控制。

Redis Object 类型与映射

redis 内部每一种数据类型都是对象化的,也就是我们所说的5种数据类型其实内部都会对应到 redisObject 对象,然后在由 redisObject 来包装具体的存储数据结构和编码。

typedef struct redisObject {
    unsigned type:4;
    unsigned encoding:4;
    unsigned lru:REDIS_LRU_BITS; 
    int refcount;
    void *ptr;
} robj;

这是一个很 OO 的设计,redisObject->type 是 5 种数据类型之一,redisObject->encoding 是这个数据类型所使用的数据结构和编码。

我们看下 redis 提供的 5 种数据类型与每一种数据类型对应的存储数据结构和编码。

/* Object types */
#define REDIS_STRING 0
#define REDIS_LIST 1
#define REDIS_SET 2
#define REDIS_ZSET 3
#define REDIS_HASH 4
#define REDIS_ENCODING_RAW 0     
#define REDIS_ENCODING_INT 1    
#define REDIS_ENCODING_HT 2
#define REDIS_ENCODING_ZIPMAP 3
#define REDIS_ENCODING_LINKEDLIST 4
#define REDIS_ENCODING_ZIPLIST 5 
#define REDIS_ENCODING_INTSET 6  
#define REDIS_ENCODING_SKIPLIST 7  
#define REDIS_ENCODING_EMBSTR 8 

REDIS_ENCODING_ZIPMAP 3 这个编码可以忽略了,在特定的情况下有性能问题,在 redis 2.6 版本之后已经废弃,为了兼容性保留。

redis 数据结构 内存管理 持久化_第7张图片

上图是 redis 5 种数据类型与底层数据结构和编码的对应关系,但是这种对应关系在每一个版本中都会有可能发生变化,这也是 redisObject 的灵活性所在,有着 OO 的这种多态性。

redisObject->refcount 表示当前对象的引用计数,在 redis 内部为了节省内存采用了共享对象的方法,当某个对象被引用的时候这个 refcount 会加 1,释放的时候会减 1

redisObject->lru 表示当前对象的 空转时长,也就是 idle time ,这个时间会是 redis lru 算法用来释放对象的时间依据。可以通过 OBJECT idletime 命令查看某个 key 的空转时长 lru 时间。

 

Redis 内存管理策略

redis 在服务端分别为不同的 db index 维护一个 dict 这个 dict 称为 key space 键空间 。每一个 redis client 只能属于一个 db index ,在 redis 服务端会维护每一个链接的 redisClient 。

typedef struct redisClient {
    uint64_t id;
    int fd;
    redisDb *db;
} redisClient;

在服务端每一个 redis 客户端都会有一个指向 redisDb 的指针。

typedef struct redisDb {
    dict *dict;
    dict *expires;
    dict *blocking_keys;
    dict *ready_keys;
    dict *watched_keys;
    struct evictionPoolEntry *eviction_pool;
    int id;
    long long avg_ttl;
} redisDb;

key space 键空间就是这里的 redisDb->dict 。redisDb->expires 是维护所有键空间的每一个 key 的过期时间。

键 过期时间、生存时间

对于一个 key 我们可以设置它多少秒、毫秒之后过期,也可以设置它在某个具体的时间点过期,后者是一个时间戳。

EXPIRE 命令可以设置某个 key 多少秒之后过期
PEXPIRE 命令可以设置某个 key 多少毫秒之后过期

EXPIREAT 命令可以设置某个 key 在多少秒时间戳之后过期
PEXPIREAT 命令可以设置某个 key 在多少毫秒时间戳之后过期

PERSIST 命令可以移除键的过期时间

其实上述命令最终都会被转换成对 PEXPIREAT 命令。在 redisDb->expires 指向的 key 字典中维护着一个到期的毫秒时间戳。

TTL、PTTL 可以通过这两个命令查看某个 key 的过期秒、毫秒数。

redis 内部有一个 事件循环,这个事件循环会检查键的过期时间是否小于当前时间,如果小于则会删除这个键。

过期键删除策略

在使用 redis 的时候我们最关心的就是键是如何被删除的,如何高效的准时的删除某个键。其实 redis 提供了两个方案来完成这件事情。

redis 采用 惰性删除 、 定期删除 双重删除策略。

当我们访问某个 key 的时候 redis 会检查它是否过期,这是惰性删除。

robj *lookupKeyRead(redisDb *db, robj *key) {
    robj *val;

    expireIfNeeded(db,key);
    val = lookupKey(db,key);
    if (val == NULL)
        server.stat_keyspace_misses++;
    else
        server.stat_keyspace_hits++;
    return val;
}
int expireIfNeeded(redisDb *db, robj *key) {
    mstime_t when = getExpire(db,key);
    mstime_t now;

    if (when < 0) return 0; /* No expire for this key */

    if (server.loading) return 0;

    now = server.lua_caller ? server.lua_time_start : mstime();
    if (server.masterhost != NULL) return now > when;

    /* Return when this key has not expired */
    if (now <= when) return 0;

    /* Delete the key */
    server.stat_expiredkeys++;
    propagateExpire(db,key);
    notifyKeyspaceEvent(REDIS_NOTIFY_EXPIRED,"expired",key,db->id);
    return dbDelete(db,key);
}

redis 也会通过 事件循环 周期性的执行 key 的过期删除动作,这是定期删除。

int serverCron(struct aeEventLoop *eventLoop, long long id, void *clientData) {
    /* Handle background operations on Redis databases. */
    databasesCron();
}
void databasesCron(void) {
    /* Expire keys by random sampling. Not required for slaves
     * as master will synthesize DELs for us. */
    if (server.active_expire_enabled && server.masterhost == NULL)
        activeExpireCycle(ACTIVE_EXPIRE_CYCLE_SLOW);
}

惰性删除 是每次只要有读取、写入都会触发惰性删除代码。周期删除 是由 redis EventLoop 来触发的。redis 内部很多维护性工作都是基于 EventLoop 。

AOF 、RDB 处理过期键策略

既然键会随时存在过期问题,那么涉及到持久化 redis 是如何帮我们处理的。

当 redis 使用 RDB 方式持久化时,每次持久化的时候就会检查这些即将被持久化的 key 是否已经过期,如果过期将直接忽略,持久化那些没有过期的键。当 redis 作为 master 主服务器 启动的时候,在载入 rdb 持久化键时也会检查这些键是否过期,将忽略过期的键,只载入没过期的键。

当 redis 使用 AOF 方式持久化时,每次遇到过期的 key redis 会追加一条 DEL 命令 到 AOF 文件,也就是说只要我们顺序载入执行 AOF 命令文件就会删除过期的键。

如果 redis 作为从服务器启动的化,它一旦与 master 主服务器 建立链接就会清空所有数据进行完整同步,当然新版本的 redis 支持 SYCN2 的半同步。如果是已经建立了 master/slave 主从同步之后,主服务器会发送 DEL 命令给所有从服务器执行删除操作。

Redis LRU 算法

在使用 redis 的时候我们会设置 maxmemory 选项,64 位的默认是 0 不限制。线上的服务器必须要设置的,要不然很有可能导致 redis 宿主服务器直接内存耗尽最后链接都上不去。

所以基本要设置两个配置:

maxmemory 最大内存阈值
maxmemory-policy 到达阈值的执行策略

可以通过 CONFIG GET maxmemory/maxmemory-policy 分别查看这两个配置值,也可以通过 CONFIG SET 去分别配置。

maxmemory-policy 有一组配置,可以用在很多场景下:

noeviction:客户端尝试执行会让更多内存被使用的命令直接报错
allkeys-lru: 在所有key里执行lru算法
volatile-lru:在所有已经过期的key里执行lru算法
allkeys-random:在所有key里随机回收
volatile-random:在已经过期的key里随机回收
volatile-ttl:回收已经过期的key,并且优先回收存活时间(TTL)较短的键

关于 cache 的命中率可以通过 info 命令查看 键空间的命中率和未命中率。

# Stats
keyspace_hits:33
keyspace_misses:5

maxmemory 在到达阈值的时候会采用一定的策略去释放内存,这些策略我们可以根据自己的业务场景来选择,默认是 noeviction 。

redis LRU 算法有一个取样的优化机制,可以通过一定的取样因子来加强回收的 key 的准确度。CONFIG GET maxmemory-samples 查看取样配置,具体可以参考更加详细的文章。

Redis 持久化方式

redis 本身提供持久化功能,有两种持久化机制,一种是数据持久化 RDB ,一种是命令持久化 AOF,这两种持久化方式各有优缺点,也可以组合使用,一旦组合使用 redis 在载入数据的时候会优先载入 aof 文件,只有当 AOF 持久化关闭的时候才会载入 rdb 文件。

RDB (Redis DataBase)

RDB 是 redis 数据库,redis 会根据一个配置来触发持久化。

#save  

save 900 1
save 300 10
save 60 10000
CONFIG GET save
1) "save"
2) "3600 1 300 100 60 10000"

表示在多少秒之类的变化次数,一旦达到这个触发条件 redis 将触发持久化动作。redis 在执行持久化的时候有两种模式 BGSAVE、SAVE 。BGSAVE 是后台保存,redis 会 fork 出一个子进程来处理持久化,不会 block 用户的执行请求。而 SAVE 则会 block 用户执行请求。

struct redisServer {
long long dirty;/* Changes to DB from the last save */
time_t lastsave; /* Unix time of last successful save */
long long dirty_before_bgsave;
pid_t rdb_child_pid;/* PID of RDB saving child */
struct saveparam *saveparams; /* Save points array for RDB */
}
struct saveparam {
    time_t seconds;
    int changes;
};

redisServer 包含的信息很多,其中就包含了有关于 RDB 持久化的信息。redisServer->dirty 至上次 save 到目前为止的 change 数。redisServer->lastsave 上次 save 时间。

saveparam struct 保存了我们通过 save 命令设置的参数,__time_t 是个 long__ 时间戳。

typedef __darwin_time_t     time_t; 
typedef long    __darwin_time_t;    /* time() */
int serverCron(struct aeEventLoop *eventLoop, long long id, void *clientData) {
         for (j = 0; j < server.saveparamslen; j++) {
            struct saveparam *sp = server.saveparams+j;
            if (server.dirty >= sp->changes &&
                server.unixtime-server.lastsave > sp->seconds &&
                (server.unixtime-server.lastbgsave_try >
                 REDIS_BGSAVE_RETRY_DELAY ||
                 server.lastbgsave_status == REDIS_OK))
            {
                redisLog(REDIS_NOTICE,"%d changes in %d seconds. Saving...",
                    sp->changes, (int)sp->seconds);
                rdbSaveBackground(server.rdb_filename);
                break;
            }
         }
}

redis 事件循环会周期性的执行 serverCron 方法,这段代码会循环遍历 server.saveparams 参数链表。

如果 server.dirty 大于等于 我们参数里配置的变化并且 server.unixtime-server.lastsave 大于参数里配置的时间并且 __server.unixtime-server.lastbgsave_try 减去 bgsave 重试延迟时间或者当前 server.lastbgsave_status==REDIS_OK 则执行 rdbSaveBackground__ 方法。

AOF (Append-only file)

AOF 持久化是采用对文件进行追加对方式进行,每次追加都是 redis 处理的 命令。有点类似 command sourcing 命令溯源 的模式。

只要我们可以将所有的命令按照执行顺序在重放一遍就可以还原最终的 redis 内存状态。

AOF 持久化最大的优势是可以缩短数据丢失的间隔,可以做到秒级的丢失率。RDB 会丢失上一个保存周期到目前的所有数据,只要没有触发 save 命令设置的 save seconds changes 阈值数据就会一直不被持久化。

struct redisServer {
 /* AOF buffer, written before entering the event loop */
 sds aof_buf;
 }
struct sdshdr {
    unsigned int len;
    unsigned int free;
    char buf[];
};

aof_buf 是命令缓存区,采用 sds 结构缓存,每次当有命令被执行当时候都会写一次到 aof_buf 中。有几个配置用来控制 AOF 持久化的机制。

appendonly no 
appendfilename "appendonly.aof"

appendonly 用来控制是否开启 AOF 持久化,appendfilename 用来设置 aof 文件名。

appendfsync always
appendfsync everysec
appendfsync no

appendfsync 用来控制命令刷盘机制。现在操作系统都有文件 cache/buffer 的概念,所有的写入和读取都会走 cache/buffer,并不会每次都同步刷盘,因为这样性能一定会受影响。所以 redis 也提供了这个选项让我们来自己根据业务场景控制。

always :每次将 aof_buf 命令写入 aof 文件并且执行实时刷盘。
everysec :每次将 aof_buf
 命令写入 aof 文件,但是每隔一秒执行一次刷盘。
no :每次将 __aof_buf 命令写入 aof__ 文件不执行刷盘,由操作系统来自行控制。

AOF 也是采用后台子进程的方式进行,与主进程共享数据空间也就是 aof_buf,但是只要开始了 AOF_ 子进程之后 redis 事件循环文件事件处理器_ 会将之后的命令写入另外一个 __aof_buf ,这样就可以做到平滑的切换。

AOF 会不断的追加命令进 aof 文件,随着时间和并发量的加大 aof 文件会极速膨胀,所以有必要对这个文件大小进行优化。redis 基于 rewrite 重写对文件进行压缩。

no-appendfsync-on-rewrite no/yes
auto-aof-rewrite-percentage 100
auto-aof-rewrite-min-size 64mb

no-appendfsync-on-rewrite 控制是否在 bgrewriteaof 的时候还需要进行命令追加,如果追加可能会出现磁盘 IO 跑高现象。

上面说过,当 AOF 进程在执行的时候原来的事件循环还会正常的追加命令进 aof 文件,同时还会追加命令进另外一个aof_buf ,用来做新 aof 文件的重写。这是两条并行的动作,如果我们设置成 yes 就不追加原来的 aof_buf 因为新的 aof 文件已经包含了之后进来的命令。

auto-aof-rewrite-percentage、auto-aof-rewrite-min-size 64mb 这两个配置前者是文件增长百分比来进行 rewrite ,后者是按照文件大小增长进行 rewrite 。

 

案例:沪江团购系统大促 hot-top 接口 cache 设计

下面我们分享一个使用多个 zset 、string 来优化 团购系统 前台接口的例子。由于篇幅和时间限制,这里只介绍跟本次案例相关的信息。

hot-top 接口是指热点、排名接口的意思,表示它的浏览量、并发量比较高,一般大促的时候都会有几个这种性能要求比较高的接口。

我们先来分析一个查询接口所包含的常规信息。

首先一个查询接口肯定是有 query condition 查询条件 ,然后是 sort 排序信息_ 、最后是 page 分页信息_ 。这是一般接口所承担的基本职责,当然,特殊场景下还需要支持 master/slave replication 时关于数据 session 一致性 的要求,需要提供跟踪标记来回master 查询数据,这里就不展开了。

我们可以抽象出这几个维度的信息:

query condition
查询条件,companyid=100,sellerid=1010101 诸如此类。
sort
排序信息,一般是默认一个列排序,但是在复杂的场景下会有可能让接口使用者定制排序字段,比如一些租户信息列。
page
分页信息,简单理解就是数据记录排完序之后的第几行到第几行。

由于这里我们纯粹用 redis 来提高 cache 能力,不涉及到有关于任何搜索的能力,所以这里忽略其他复杂查询的情况。其实我们在复杂的地方使用了 elastcsearch 来提高搜索能力。

上述我们分析总结出了一个查询接口的基本信息,这里还有一个有关于高并发接口的设计原则就是将 hot-top 接口和一般 search 接口分离开,因为只有分而治之才能分别根据特点选用不同的技术。如果我们不分职责将所有的查询场景封装在一个接口里,那么在后面优化接口性能的时候基本就很麻烦了,有些场景是无法或者很难用 cache 来解决的,因为接口里耦合了各种场景逻辑,就算勉强能实现性能也不会高。

前面做这些铺垫是为了能在介绍案例的时候达成一个基本的共识。现在我们来看下这个团购系统的 hot-top 接口的具体逻辑。

在大促的时候需要展现团购列表,这个接口的访问量是非常大的,团购活动需要根据参团人数倒序排序,并且分页返回指定数量的团列表。

我们假设这个接口名为 getTopGroups(getTopGroupsRequest request)

query condition 查询条件问题

我们来仔细分析下,首先不同的查询条件从 DB 里查询出来的数据是不一样的,也就是说查询出来的团列表是不一样的,可能有 company 公司 、channel 渠道 等过滤条件。由于一个团购活动下不会有太多团,顶多上百个是极限了,所以一个查询条件出来的团列表也顶多几十个,而且根据场景分析热点查询条件不会超过十个,所以我们选择将 查询条件 hash 出一个 code 来缓存本次查询条件的全量团列表集合,但是这些结果集是没有任何排序的。

sort 排序问题

再看根据参团人数排序问题,我们立刻就可以想到使用 zset 来处理团排序问题,因为只有一个排序维度,所以一个 zset 就够了。我们使用一个 __zset__来缓存所有团的参团人数集合,它是一个全量的团排序集合。

那么我们如何将用户的查询条件出来的团列表根据参团人数排序尼,刚好可以使用 zset 的交集运算直接计算出当前这个集合的 zset 子集。

page 分页问题

通过对已经排序之后的团列表 zset 使用 zrange 来获取出分页集合。

我们来看下完整的流程,如何处理查询、排序、分页的。

redis 数据结构 内存管理 持久化_第8张图片

上图从 query condition 计算 hash code ,然后通过 DB 查询出当前条件全量团列表。
zset:marketing:groupon:hottop:available:group key 表示全量团的参团人数,用一个 zset 来缓存。接着将这两个 zset 计算交集,就可以得出当前查询所需要的带有参团人数的 zset ,最后在使用 zrevrange 获取分页区间。

ZADD zset:marketing:groupon:hottop:condition:2986080 0 G4ZD5732YZQ 0 G5VW3YF42UC 0 GF773FEJ7CC 0 GFW8DUEND8S 0 GKPKKW8XEY9 0 GL324DGWMZM
(integer) 6
ZADD zset:marketing:groupon:hottop:available:group 5 GN7KQH36ZWK 10 GS7VB22AWD4 15 GF773FEJ7CC 17 G5VW3YF42UC 18 G4ZD5732YZQ 32 GTYJKCEJBRR 40 GKPKKW8XEY9 45 GL324DGWMZM 50 GFW8DUEND8S 60 GYTKY4ACWLT
(integer) 10
ZINTERSTORE zset:marketing:groupon:hottop:condition:interstore 2 zset:marketing:groupon:hottop:condition:2986080 zset:marketing:groupon:hottop:available:group
(integer) 6
ZRANGE zset:marketing:groupon:hottop:condition:interstore 0 -1 withscores
 1) "GF773FEJ7CC"
 2) "15"
 3) "G5VW3YF42UC"
 4) "17"
 5) "G4ZD5732YZQ"
 6) "18"
 7) "GKPKKW8XEY9"
 8) "40"
 9) "GL324DGWMZM"
10) "45"
11) "GFW8DUEND8S"
12) "50"
ZREVRANGE zset:marketing:groupon:hottop:condition:interstore 2 4 withscores
1) "GKPKKW8XEY9"
2) "40"
3) "G4ZD5732YZQ"
4) "18"
5) "G5VW3YF42UC"
6) "17"

有了返回的团 code 集合之后就可以通过 mget 来批量获取 string 类型的团详情信息,这里就不贴出代码了。

由于篇幅和时间关系,这里就不展开太多的业务场景介绍了。这其中还涉及到计算 cache 过期时间的问题,这也跟促销活动的运营规则有关系,还涉及到有可能 query condition hash 冲突问题等。

转载于:https://my.oschina.net/oosc/blog/1616637

你可能感兴趣的:(redis 数据结构 内存管理 持久化)