1.Redis使用
Redis基础教程
Redis简介: Redis是一个由ANSI C语言编写,性能优秀、支持网络、可持久化的Key-Value类型数据库,Key为字符类型,Value的类型常用的为五种类型:String、Hash 、List 、 Set 、 Ordered Set(ZSet)
2.Redis内部内存管理
Redis的核心对象: redisObject,Redis对象的类型、内部编码、内存回收、共享对象等功能,都需要redisObject支持;
这样设计的好处是: 可以针对不同的使用场景,对5中常用类型设置多种不同的数据结构实现,从而优化对象在不同场景下的使用效率。
- type: 代表一个 value 对象具体是何种数据类型。
- encoding: 是不同数据类型在 redis 内部的存储方式,比如:type=string 代表 value 存储的是一个普通字符串,那么对应的 encoding 可以是 raw 或者是 int,如果是 int 则代表实际 redis 内部是按数值型类存储和表示这个字符串的,当然前提是这个字符串本身可以用数值表示,比如:"123" "456"这样的字符串。
- vm字段: 只有打开了 Redis 的虚拟内存功能,此字段才会真正的分配内存,该功能默认是关闭状态的。 Redis 使用 redisObject 来表示所有的 key/value 数据是比较浪费内存的,当然这些内存管理成本的付出主要也是为了给 Redis 不同数据类型提供一个统一的管理接口,实际作者也提供了多种方法帮助我们尽量节省内存使用。
当我们执行set hello world命令时,会有以下数据模型:
- dictEntry: Redis给每个key-value键值对分配一个dictEntry,里面有着key和val的指针,next指向下一个dictEntry形成链表,这个指针可以将多个哈希值相同的键值对链接在一起,由此来解决哈希冲突问题(链地址法)。
- sds: 键key “hello” 是以SDS(简单动态字符串)存储,后面详细介绍。
- redisObject 值val “world” 存储在redisObject中。实际上,redis常用5中类型都是以redisObject来存储的;而redisObject中的type字段指明了Value对象的类型,ptr字段则指向对象所在的地址。
redis所有的数据结构类型如下(重要,后面会用)
2.1 String
字符串对象的底层实现: 可以是int、raw、embstr 编码(上面的表对应有名称介绍)。
embstr编码是通过调用一次内存分配函数来分配一块连续的空间,而raw需要调用两次。
int编码字符串对象和embstr编码字符串对象在一定条件下会转化为raw编码字符串对象。
- embstr:<=39字节的字符串。
- int:8个字节的长整型。
- raw:大于39个字节的字符串。
注意:
1.存储StringObject的编码: 用embstr编码还是用raw编码是Redis3.0版本后区分的
2.为什么是39字节? 读这里 redis的embstr编码
3.值类型转换: String 在 redis 内部存储默认就是一个字符串,被 redisObject 所引用,当遇到 incr,decr 等操作时会转成数值型进行计算,此时 redisObject 的 encoding 字段为int。
4.大小限制:最大为512Mb,基本可以存储任意图片啦。
5.时间复杂度: O(1),读写一样的快。
2.2 List
List对象的底层实现:是quicklist(快速列表,是ziplist 压缩列表 和linkedlist 双端链表 的组合)。
特点:Redis中的列表支持两端插入和弹出,并可以获得指定位置(或范围)的元素,可以充当数组、队列、栈等。
typedef struct listNode {
struct listNode *prev; // 前置节点
struct listNode *next; // 后置节点
void *value; // 节点的值
} listNode;
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;
- rpush: listAddNodeHead ---O(1)
- lpush: listAddNodeTail ---O(1)
- push: listInsertNode ---O(1)
- index: listIndex ---O(N)
- pop:ListFirst/listLast ---O(1)
- llen:listLength ---O(N)
2.2.1 linkedlist(双端链表)
此结构比较像Java的LinkedList,有兴趣可以阅读一下源码。
从图中可以看出Redis的linkedlist双端链表有以下特性:
- 节点带有prev、next指针、head指针和tail指针;
- 获取前置节点、后置节点、表头节点和表尾节点的复杂度都是O(1);
- len属性获取节点数量也为O(1);
与双端链表相比优劣点: 压缩列表可以节省内存空间,但是进行修改或增删操作时,复杂度较高;当节点数量较少时,可以使用压缩列表;但是节点数量多时,还是使用双端链表划算。
2.2.2 ziplist(压缩列表)
用压缩列表条件: 当一个列表键只包含少量列表项,且是小整数值或长度比较短的字符串时,那么redis就使用ziplist(压缩列表)来做列表键的底层实现。
结构特点:ziplist是Redis为了节约内存而开发的,是由一系列特殊编码的连续内存块(而不是像双端链表一样每个节点是指针)组成的顺序型数据结构;具体结构相对比较复杂,有兴趣读者可以看 Redis 哈希结构内存模型剖析。
在新版本中list链表使用 quicklist 代替了 ziplist和 linkedlist:
quickList: zipList 和 linkedList 的混合体。它将 linkedList 按段切分,每一段使用 zipList 来紧凑存储,多个 zipList 之间使用双向指针串接起来。因为链表的附加空间相对太高,prev 和 next 指针就要占去 16 个字节 (64bit 系统的指针是 8 个字节),另外每个节点的内存都是单独分配,会加剧内存的碎片化,影响内存管理效率。
quicklist的压缩特点: quicklist 默认的压缩深度是 0,也就是不压缩。为了支持快速的 push/pop 操作,quicklist 的首尾两个 ziplist 不压缩,此时深度就是 1。为了进一步节约空间,Redis 还会对 ziplist 进行压缩存储,使用 LZF 算法压缩。
2.3 Hash
Hash对象的底层实现:可以是ziplist(压缩列表)或者hashtable(字典或者也叫哈希表)。
Hash对象使用ziplist(压缩列表)的条件:只有同时满足下面两个条件时,才会使用ziplist
- 1.哈希中元素数量小于512个;
- 2.哈希中所有键值对的键和值字符串长度都小于64字节。
hashtable哈希表可以实现O(1)复杂度的读写操作,因此效率很高。源码如下:
typedef struct dict {
dictType *type; // 类型特定函数
void *privdata; // 私有数据
dictht ht[2]; // 哈希表
// rehash 索引
// 当 rehash 不在进行时,值为 -1
int rehashidx; /* rehashing not in progress if rehashidx == -1 */
// 目前正在运行的安全迭代器的数量
int iterators; /* number of iterators currently running */
} dict;
typedef struct dictht {
dictEntry **table; // 哈希表数组
unsigned long size; // 哈希表大小
unsigned long sizemask; // 哈希表大小掩码,用于计算索引值 ,总是等于 size - 1
unsigned long used; // 该哈希表已有节点的数量
} dictht;
typedef struct dictEntry {
void *key;
union {void *val;uint64_t u64;int64_t s64;} v;
struct dictEntry *next; // 指向下个哈希表节点,形成链表
} dictEntry;
typedef struct dictType {
unsigned int (*hashFunction)(const void *key); // 计算哈希值的函数
void *(*keyDup)(void *privdata, const void *key); // 复制键的函数
void *(*valDup)(void *privdata, const void *obj); // 复制值的函数
int (*keyCompare)(void *privdata, const void *key1, const void *key2);// 对比键的函数
void (*keyDestructor)(void *privdata, void *key); // 销毁键的函数
void (*valDestructor)(void *privdata, void *obj); // 销毁值的函数
} dictType;
上面源码可以简化成如下结构:
图解析:这个结构类似于JDK7以前的HashMap
Redis中的字典使用hashtable作为底层实现的话,每个字典会带有两个哈希表,一个平时使用,另一个仅在rehash(重新散列)时使用。随着对哈希表的操作,键会逐渐增多或减少。为了让哈希表的负载因子维持在一个合理范围内,Redis会对哈希表的大小进行扩展或收缩(rehash),也就是将ht【0】里面所有的键值对分多次、渐进式的rehash到ht【1】里。
2.4 Set
Set集合对象的底层实现:可以是intset(整数集合)或者hashtable(字典或者也叫哈希表)。
使用intset(整数集合)条件:当一个集合只含有整数,并且元素不多时会使用intset(整数集合)作为Set集合对象的底层实现。
typedef struct intset {
uint32_t encoding;// 编码方式
uint32_t length;// 集合包含的元素数量
int8_t contents[];// 保存元素的数组
} intset;
- sadd:intsetAdd---O(1)
- smembers:intsetGetO(1)---O(N)
- srem:intsetRemove---O(N)
- slen:intsetlen ---O(1)
intset底层实现特点:intset底层实现为有序,无重复数组保存集合元素。 intset这个结构里的整数数组的类型可以是16位的,32位的,64位的。如果数组里所有的整数都是16位长度的,如果新加入一个32位的整数,那么整个16的数组将升级成一个32位的数组。升级可以提升intset的灵活性,又可以节约内存,但不可逆。
2.5 ZSet
ZSet有序集合对象底层实现:可以是ziplist(压缩列表)或者skiplist(跳跃表)。
使用skiplist(跳跃表)条件:当一个有序集合的元素数量比较多或者成员是比较长的字符串时,Redis就使用skiplist(跳跃表)作为ZSet对象的底层实现。
typedef struct zskiplist {
struct zskiplistNode *header, *tail; // 表头节点和表尾节点
unsigned long length; // 表中节点的数量
int level; // 表中层数最大的节点的层数
} zskiplist;
typedef struct zskiplistNode {
robj *obj; // 成员对象
double score; // 分值
struct zskiplistNode *backward; // 后退指针
// 层
struct zskiplistLevel {
struct zskiplistNode *forward; // 前进指针
unsigned int span; // 跨度---前进指针所指向节点与当前节点的距离
} level[];
} zskiplistNode;
- zadd---zslinsert---平均O(logN), 最坏O(N)
- zrem---zsldelete---平均O(logN), 最坏O(N)
- zrank--zslGetRank---平均O(logN), 最坏O(N)
skiplist特点:skiplist的查找时间复杂度是LogN,可以和平衡二叉树相当,但实现起来又比它简单。跳跃表(skiplist)是一种有序数据结构,它通过在某个节点中维持多个指向其他节点的指针,从而达到快速访问节点的目的。
3.Redis数据库
- 在Redis服务器状态结构中,“dbnum”属性记录了服务器的数据库数量,它的值可以通过服务器配置的“databases”选项决定,默认为16。
- “db”属性是一个数组,保存着服务器中的所有数据库,其中每一个数据库都对应一个“redisDb”数据结构:
struct redisDb {
dict *dict;//数据库键空间字典,保存数据库中所有的键值对
dict *expires;//过期字典,保存数据库中所有键的过期时间
dict *watched_keys;//字典,正在被WATCH命令监视的键
};
3.1 数据库键空间
概念:Redis数据库结构的“dict”属性是Redis数据库的键空间,底层由字典实现。
特点:所有在数据库上的增删改查,实际上都是通过对键空间字典进行相应操作来实现的。除此之外,还需要进行一些额外的维护操作,主要有如下操作内容:
- 1.键空间命中次数:在读取一个键之后,服务器会根据键是否存在来更新服务器的键空间命中次数或不命中次数(这两个值可以在'info stats'命令返回中的'keyspace_hits'属性和'keyspace_misses'属性中查看)。
- 2.键空间闲置时间:在读取一个键之后,服务器会更新键的LRU属性值(最后一次使用时间,使用'object idletime'命令可以查看键的闲置时间)。
- 3.过期键的删除:在读取一个键时,若发现该键已过期,则删除这个键。
- 4.脏键的标记:如果有客户端使用'watch'命令监视了某个键,服务器在对被监视的键进行修改之后,会将这个键标记为脏,从而让事务程序注意到这个键已经被修改过了。
- 5.脏键的管理:服务器每次修改一个键之后,都会对脏键计数器(即Redis服务器状态的'dirty'属性)的值加一,这个计数器会触发服务器的持久化以及复制操作。
- 6.键改动后通知:如果服务器开启了数据库通知功能,在对键进行修改之后,服务器将按配置发送相应的数据库通知。
3.2 过期字典
Redis数据库结构的“expires”属性保存了Redis数据库所有拥有过期时间的键以及它们对应的过期时间,底层同样由字典实现。数据库键过期时间的设置和删除,实际上都是对过期字典的操作。其中,字典的键是一个个指针,分别指向键空间字典中的一个个键对象(共享对象,节省内存空间);字典的值则是一个个long long类型的整数表示的毫秒精度的UNIX时间戳,保存数据库键的过期时间。
3.2.1 键过期时间设置
- expire:以秒为单位,设置Redis键的生存时间。
- pexpire:以毫秒为单位,设置Redis键的生存时间。
- expireat:以秒为单位,设置Redis键的过期时间。
-pexpireat:以毫秒为单位,设置Redis键的过期时间。
注:实际上'expire'、'pexpire'、'expireat'命令最后都会转换为'pexpireat'命令来执行。
3.2.2 键过期时间查看
- ttl:以秒为单位,返回键的剩余生存时间。
- pttl:以毫秒为单位,返回键的剩余生存时间。
3.2.3 键过期判定
检查当前Unix时间戳是否大于键的过期时间,是则过期,否则不过期。
3.2.4 过期键的理论删除策略
- 1.定时删除
设置一个键过期时间的同时,创建一个定时器。每个带有过期时间的键都对应着一个定时器。
这种策略对内存是最友好的,但对CPU时间是最不友好的。创建一个定时器需要用到Redis服务器中的时间事件,而当前时间事件的实现方式为无序链表,查找一个事件的时间复杂度为O(N),并不能高效地处理大量时间事件。
- 2.惰性删除
访问一个键的时候再检测该键是否过期,是则删除之。
这种策略对CPU时间是最友好的,但对内存是最不友好的。没被访问到的过期键永远不会被删除,可以看做内存泄露。对于运行状态非常依赖于内存的Redis来说,这种策略显然会影响到Redis的性能。
- 3.定期删除
这种策略是对前两种策略的整合与折中方案。使用这种策略需要控制好删除操作每次执行的时长和执行的频率,否则会退化为前两种策略的其中一种。
3.2.5 Redis采用的过期键删除策略
Redis服务器过期键删除策略:是惰性删除和定期删除两种策略配合使用的方案。
- 1.惰性删除策略的实现
所有读写数据库的Redis命令在执行之前都会先检查输入键是否已过期,过期则删除之。
- 2.定期删除策略的实现
概念: 在规定时间内,分多次遍历服务器中的各个数据库,从数据库的过期字典中随机检查一部分键的过期时间,并删除其中的过期键。
步骤:
a. 定期删除程序每次运行时,都会从一定数量的数据库中取出一定数量的随机键进行检查,并删除其中的过期键。
b. 使用一个全局变量记录当前删除程序检查的是第几个数据库,下一次运行都会接着上一次的进度进行处理。
c. 随着删除程序的不断执行,服务器中所有的数据库都会被检查一遍,然后这个全局变量被重置为0,开始新一轮的检查工作。
3.2.6 AOF、RDB和复制功能对过期键的处理
- 1.生成RDB文件
在执行save或bgsave命令创建一个新的RDB文件时,程序会对数据库中的键进行检查,已过期的键不会被保存到新创建的RDB文件中。
- 2.载入RDB文件
2.1 主服务器模式:载入RDB文件时,程序会对文件中保存的键进行检查,只有未过期的键会被载入到数据库中。
2.2 从服务器模式:文件中保存的所有键都会被载入到数据库中。不过因为主从服务器在进行数据同步的时候,从服务器的数据库会被清空,所以过期键对载入RDB文件的从服务器也不会造成影响。
- 3.AOF文件写入
当过期键被惰性删除或定期删除之后,程序会向AOF文件追加一条del命令,来显式地记录该键已被删除。
- 4.AOF重写
程序会对数据库中的键进行检查,已过期的键不会被保存到重写后的AOF文件中。
- 5.复制
当服务器运行在复制模式下时,从服务器的过期键删除动作由主服务器控制:
5.1 主服务器在删除一个过期键之后,会显式地向所有从服务器发送一个del命令,告知从服务器删除这个过期键。
5.2 从服务器在执行客户端发送的读命令时,即使碰到过期键也不会将其删除,而是将过期键的值继续返回给客户端。
5.3 从服务器只有在接到主服务器发送来的del命令之后,才会删除过期键。
1.Redis的存储机制
Redis存储机制分成两种Snapshot和AOF。无论是那种机制,Redis都是将数据存储在内存中。
1.1 Snapshot存储机制(工作原理):
是将数据先存储在内存,然后当数据累计达到某些设定的伐值的时候,就会触发一次DUMP操作,将变化的数据一次性写入数据文件(RDB文件)。
1.2 AOF 工作原理:
是将数据也是先存在内存,但是在存储的时候会使用调用fsync来完成对本次写操作的日志记录,这个日志揭露文件其实是一个基于Redis网络交互协议的文本文件。
AOF调用fsync也不是说全部都是无阻塞的,在某些系统上可能出现fsync阻塞进程的情况,对于这种情况可以通过配置修改,但默认情况不要修改。
AOF最关键的配置就是关于调用fsync追加日志文件的平率,有两种预设频率,always每次记录进来都添加,everysecond 每秒添加一次。两个配置各有所长后面分析。由于是采用日志追加的方式来持久化数据,所以引出了第二个日志的概念:rewrite. 后面介绍它的由来。
1.3 存储模式性能和安全比较:
1.3.1 性能
Snapshot方式的性能是要明显高于AOF方式的,原因有两点:
- 采用2进制方式存储数据,数据文件比较小,加载快速。
- 存储的时候是按照配置中的save策略来存储,每次都是聚合很多数据批量存储,写入的效率很好,而AOF则一般都是工作在实时存储或者准实时模式下。相对来说存储的频率高,效率却偏低。
1.3.2 数据安全
AOF数据安全性高于Snapshot存储,原因:
- Snapshot存储是基于累计批量的思想,也就是说在允许的情况下,累计的数据越多那么写入效率也就越高,但数据的累计是靠时间的积累完成的,那么如果在长时间数据不写入RDB,但Redis又遇到了崩溃,那么没有写入的数据就无法恢复了,但是AOF方式偏偏相反,根据AOF配置的存储频率的策略可以做到最少的数据丢失和较高的数据恢复能力。
1.3.3 Redis中的Rewrite的功能
何时用Rewrite:AOF的存储是按照记录日志的方式去工作的,那么成千上万的数据插入必然导致日志文件的扩大,Redis这个时候会根据配置合理触发Rewrite操作;
所谓Rewrite就是:将日志文件中的所有数据都重新写到另外一个新的日志文件中,但是不同的是,对于老日志文件中对于Key的多次操作,只保留最终的值的那次操作记录到日志文件中,从而缩小日志文件的大小。
这里有两个配置需要注意:
- 1.auto-aof-rewrite-percentage 100 (当前写入日志文件的大小占到初始日志文件大小的某个百分比时触发Rewrite)
- 2.auto-aof-rewrite-min-size 64mb (本次Rewrite最小的写入数据量)
Redis应用场景
Redis在互联网公司一般有以下应用:
- String:缓存、限流、计数器、分布式锁、分布式Session
- Hash:存储用户信息、用户主页访问量、组合查询
- List:微博关注人时间轴列表、简单队列
- Set:赞、踩、标签、好友关系
- Zset:排行榜
再比如电商在大促销时,会用一些特殊的设计来保证系统稳定,扣减库存可以考虑如下设计:
上图中,直接在Redis中扣减库存,记录日志后通过Worker同步到数据库,在设计同步Worker时需要考虑并发处理和重复处理的问题。
1.Reids基础教程
概念解读:
套接字: TCP用主机的IP地址加上主机上的端口号作为TCP连接的端点,这种端点就叫做套接字(socket)或插口。
什么是时间复杂度
原子操作(atomic operation): 是不需要synchronized",这是多线程编程的老生常谈了。所谓原子操作是指不会被线程调度机制打断的操作;这种操作一旦开始,就一直运行到结束,中间不会有任何 context switch (切换到另一个线程)。
参考
Redis 存储机制
Redis-基本数据类型与内部存储结构
Redis为何这么快——数据存储角度
Redis架构设计
Reids基础教程
Redis的内部运作机制
redis的embstr编码
Redis 命令参考