我摊牌了,这篇文章,值得99%的人收藏
一、入门
Redis是一款基于键值对的NoSQL数据库,它的值支持多种数据结构:
字符串(strings)、哈希(hashes)、列表(lists)、集合(sets)、有序集合(sorted sets)等。
• Redis将所有的数据都存放在内存中,所以它的读写性能十分惊人,用作数据库,缓存和消息代理。
Redis具有内置的复制,Lua脚本,LRU逐出,事务和不同级别的磁盘持久性,并通过Redis Sentinel和Redis Cluster自动分区提供了高可用性。
• Redis典型的应用场景包括:缓存、排行榜、计数器、社交网络、消息队列等
1.1NoSql入门概述
1)单机Mysql的美好时代
瓶颈:
-
数据库总大小一台机器硬盘内存放不下
-
数据的索引(B + tree)一个机器的运行内存放不下
-
访问量(读写混合)一个实例不能承受
2)Memcached(缓存)+ MySql + 垂直拆分
通过缓存来缓解数据库的压力,优化数据库的结构和索引
垂直拆分指的是:分成多个数据库存储数据(如:卖家库与买家库)
3)MySql主从复制读写分离
-
主从复制:主库来一条数据,从库立刻插入一条。
-
读写分离:读取(从库Master),写(主库Slave)
4)分表分库+水平拆分+MySql集群
主库的写压力出现瓶颈(行锁InnoDB取代表锁MyISAM)
分库:根据业务相关紧耦合在同一个库,对不同的数据读写进行分库(如注册信息等不常改动的冷库与购物信息等热门库分开)
分表:切割表数据(例如90W条数据,id 1-30W的放在A库,30W-60W的放在B库,60W-90W的放在C库)
MySql扩展的瓶颈
-
大数据下IO压力大
-
表结构更改困难
常用的Nosql
Redis
memcache
Mongdb
以上几种Nosql 请到各自的官网上下载并参考使用
Nosql 的核心功能点
KV(存储)
Cache(缓存)
Persistence(持久化)
……
1.2redis的介绍和特点:
问题:
传统数据库:持久化存储数据。
solr索引库:大量的数据的检索。
在实际开发中,高并发环境下,不同的用户会需要相同的数据。因为每次请求,
在后台我们都会创建一个线程来处理,这样造成,同样的数据从数据库中查询了N次。
而数据库的查询本身是IO操作,效率低,频率高也不好。
总而言之,一个网站总归是有大量的数据是用户共享的,但是如果每个用户都去数据库查询
效率就太低了。
解决:
将用户共享数据缓存到服务器的内存中。
特点:
1、基于键值对
2、非关系型(redis)
关系型数据库:存储了数据以及数据之间的关系,oracle,mysql
非关系型数据库:存储了数据,redis,mdb.
3、数据存储在内存中,服务器关闭后,持久化到硬盘中
4、支持主从同步
实现了缓存数据和项目的解耦。
redis存储的数据特点:
大量数据
用户共享数据
数据不经常修改。
查询数据
redis的应用场景:
网站高并发的主页数据
网站数据的排名
消息订阅
1.3redis——数据结构和对象的使用介绍
我们下载第一个
额案后基本一路默认就行了
安装后,服务自动启动,以后也不用自动启动。
出现这个表示我们连接上了。
1.3.1String
数据结构
struct sdshdr{
//记录buf数组中已使用字节的数量
int len;
//记录buf数组中未使用的数量
int free;
//字节数组,用于保存字符串
char buf[];
}
常见操作
127.0.0.1:6379> set hello world
OK
127.0.0.1:6379> get hello
“world”
127.0.0.1:6379> del hello
(integer) 1
127.0.0.1:6379> get hello
(nil)
127.0.0.1:6379>
应用场景
String是最常用的一种数据类型,普通的key/value存储都可以归为此类,value其实不仅是String,也可以是数字:比如想知道什么时候封锁一个IP地址(访问超过几次)。INCRBY命令让这些变得很容易,通过原子递增保持计数。
1.3.2LIST
数据结构
typedef struct listNode{
//前置节点
struct listNode *prev;
//后置节点
struct listNode *next;
//节点的值
struct value;
}
常见操作
lpush list-key item
(integer) 1
lpush list-key item2
(integer) 2
rpush list-key item3
(integer) 3
rpush list-key item
(integer) 4
lrange list-key 0 -1
-
“item2”
-
“item”
-
“item3”
-
“item”
lindex list-key 2
“item3”
lpop list-key
“item2”
lrange list-key 0 -1
-
“item”
-
“item3”
-
“item”
应用场景
Redis list的应用场景非常多,也是Redis最重要的数据结构之一。
我们可以轻松地实现最新消息排行等功能。
Lists的另一个应用就是消息队列,可以利用Lists的PUSH操作,将任务存在Lists中,然后工作线程再用POP操作将任务取出进行执行。
1.3.3HASH
数据结构
dictht是一个散列表结构,使用拉链法保存哈希冲突的dictEntry。
typedef struct dictht{
//哈希表数组
dictEntry **table;
//哈希表大小
unsigned long size;
//哈希表大小掩码,用于计算索引值
unsigned long sizemask;
//该哈希表已有节点的数量
unsigned long used;
}
typedef struct dictEntry{
//键
void *key;
//值
union{
void *val;
uint64_tu64;
int64_ts64;
}
struct dictEntry *next;
}
Redis的字典dict中包含两个哈希表dictht,这是为了方便进行rehash操作。在扩容时,将其中一个dictht上的键值对rehash到另一个dictht上面,完成之后释放空间并交换两个dictht的角色。
typedef struct dict {
dictType *type;
void *privdata;
dictht ht[2];
long rehashidx; /* rehashing not in progress if rehashidx == -1 */
unsigned long iterators; /* number of iterators currently running */
} dict;
rehash操作并不是一次性完成、而是采用渐进式方式,目的是为了避免一次性执行过多的rehash操作给服务器带来负担。
渐进式rehash通过记录dict的rehashidx完成,它从0开始,然后没执行一次rehash例如在一次 rehash 中,要把 dict[0] rehash 到 dict[1],这一次会把 dict[0] 上 table[rehashidx] 的键值对 rehash 到 dict[1] 上,dict[0] 的 table[rehashidx] 指向 null,并令 rehashidx++。
在 rehash 期间,每次对字典执行添加、删除、查找或者更新操作时,都会执行一次渐进式 rehash。
采用渐进式rehash会导致字典中的数据分散在两个dictht中,因此对字典的操作也会在两个哈希表上进行。
例如查找时,先从ht[0]查找,没有再查找ht[1],添加时直接添加到ht[1]中。
常见操作
hset hash-key sub-key1 value1
(integer) 1
hset hash-key sub-key2 value2
(integer) 1
hset hash-key sub-key1 value1
(integer) 0
hgetall hash-key
-
“sub-key1”
-
“value1”
-
“sub-key2”
-
“value2”
hdel hash-key sub-key2
(integer) 1
hdel hash-key sub-key2
(integer) 0
hget hash-key sub-key1
“value1”
hgetall hash-key
-
“sub-key1”
-
“value1”
1.3.4SET
常见操作
sadd set-key item
(integer) 1
sadd set-key item2
(integer) 1
sadd set-key item3
(integer) 1
sadd set-key item
(integer) 0
smembers set-key
-
“item2”
-
“item”
-
“item3”
sismember set-key item4
(integer) 0
sismember set-key item
(integer) 1
srem set-key item
(integer) 1
srem set-key item
(integer) 0
smembers set-key
-
“item2”
-
“item3”
应用场景
Redis为集合提供了求交集、并集、差集等操作,故可以用来求共同好友等操作。
1.3.5ZSET
数据结构
typedef struct zskiplistNode{
//后退指针
struct zskiplistNode *backward;
//分值
double score;
//成员对象
robj *obj;
//层
struct zskiplistLever{
//前进指针
struct zskiplistNode *forward;
//跨度
unsigned int span;
}lever[];
}
typedef struct zskiplist{
//表头节点跟表尾结点
struct zskiplistNode *header, *tail;
//表中节点的数量
unsigned long length;
//表中层数最大的节点的层数
int lever;
}
跳跃表,基于多指针有序链实现,可以看作多个有序链表。
与红黑树等平衡树相比,跳跃表具有以下优点:
-
插入速度非常快速,因为不需要进行旋转等操作来维持平衡性。
-
更容易实现。
-
支持无锁操作。
常见操作
zadd zset-key 728 member1
(integer) 1
zadd zset-key 982 member0
(integer) 1
zadd zset-key 982 member0
(integer) 0
zrange zset-key 0 -1
-
“member1”
-
“member0”
zrange zset-key 0 -1 withscores
-
“member1”
-
“728”
-
“member0”
-
“982”
zrangebyscore zset-key 0 800 withscores
-
“member1”
-
“728”
zrem zset-key member1
(integer) 1
zrem zset-key member1
(integer) 0
zrange zset-key 0 -1 withscores
-
“member0”
-
“982”
应用场景
以某个条件为权重,比如按顶的次数排序
ZREVRANGE命令可以用来按照得分来获取前100名的用户,ZRANK可以用来获取用户排名,非常直接而且操作容易。
Redis sorted set的使用场景与set类似,区别是set不是自动有序的,而sorted set可以通过用户额外提供一个优先级(score)的参数来为成员排序,并且是插入有序的,即自动排序。
1.4Spring整合Redis
引入依赖
- spring-boot-starter-data-redis
org.springframework.boot
spring-boot-starter-data-redis
配置Redis
- 配置数据库参数
RedisProperties
spring.redis.database=11#第11个库,这个随便
spring.redis.host=localhost
spring.redis.port=6379#端口
- 编写配置类,构造RedisTemplate
这个springboot已经帮我们配了,但是默认object,我想改成string
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.RedisSerializer;
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(factory);
// 设置key的序列化方式
template.setKeySerializer(RedisSerializer.string());
// 设置value的序列化方式
template.setValueSerializer(RedisSerializer.json());
// 设置hash的key的序列化方式
template.setHashKeySerializer(RedisSerializer.string());
// 设置hash的value的序列化方式
template.setHashValueSerializer(RedisSerializer.json());
template.afterPropertiesSet();
return template;
}
}
访问Redis
- redisTemplate.opsForValue()
- redisTemplate.opsForHash()
- redisTemplate.opsForList()
- redisTemplate.opsForSet()
- redisTemplate.opsForZSet()
@RunWith(SpringRunner.class)
@SpringBootTest
@ContextConfiguration(classes = CommunityApplication.class)
public class RedisTests {
@Autowired
private RedisTemplate redisTemplate;
@Test
public void testStrings() {
String redisKey = “test:count”;
redisTemplate.opsForValue().set(redisKey, 1);
System.out.println(redisTemplate.opsForValue().get(redisKey));
System.out.println(redisTemplate.opsForValue().increment(redisKey));
System.out.println(redisTemplate.opsForValue().decrement(redisKey));
}
@Test
public void testHashes() {
String redisKey = “test:user”;
redisTemplate.opsForHash().put(redisKey, “id”, 1);
redisTemplate.opsForHash().put(redisKey, “username”, “zhangsan”);
System.out.println(redisTemplate.opsForHash().get(redisKey, “id”));
System.out.println(redisTemplate.opsForHash().get(redisKey, “username”));
}
@Test
public void testLists() {
String redisKey = “test:ids”;
redisTemplate.opsForList().leftPush(redisKey, 101);
redisTemplate.opsForList().leftPush(redisKey, 102);
redisTemplate.opsForList().leftPush(redisKey, 103);
System.out.println(redisTemplate.opsForList().size(redisKey));
System.out.println(redisTemplate.opsForList().index(redisKey, 0));
System.out.println(redisTemplate.opsForList().range(redisKey, 0, 2));
System.out.println(redisTemplate.opsForList().leftPop(redisKey));
System.out.println(redisTemplate.opsForList().leftPop(redisKey));
System.out.println(redisTemplate.opsForList().leftPop(redisKey));
}
@Test
public void testSets() {
String redisKey = “test:teachers”;
redisTemplate.opsForSet().add(redisKey, “刘备”, “关羽”, “张飞”, “赵云”, “诸葛亮”);
System.out.println(redisTemplate.opsForSet().size(redisKey));
System.out.println(redisTemplate.opsForSet().pop(redisKey));
System.out.println(redisTemplate.opsForSet().members(redisKey));
}
@Test
public void testSortedSets() {
String redisKey = “test:students”;
redisTemplate.opsForZSet().add(redisKey, “唐僧”, 80);
redisTemplate.opsForZSet().add(redisKey, “悟空”, 90);
redisTemplate.opsForZSet().add(redisKey, “八戒”, 50);
redisTemplate.opsForZSet().add(redisKey, “沙僧”, 70);
redisTemplate.opsForZSet().add(redisKey, “白龙马”, 60);
System.out.println(redisTemplate.opsForZSet().zCard(redisKey));
System.out.println(redisTemplate.opsForZSet().score(redisKey, “八戒”));
System.out.println(redisTemplate.opsForZSet().reverseRank(redisKey, “八戒”));
System.out.println(redisTemplate.opsForZSet().reverseRange(redisKey, 0, 2));
}
@Test
public void testKeys() {
redisTemplate.delete(“test:user”);
System.out.println(redisTemplate.hasKey(“test:user”));
redisTemplate.expire(“test:students”, 10, TimeUnit.SECONDS);
}
}
这样还是稍微有点麻烦,我们其实可以绑定key
// 多次访问同一个key
@Test
public void testBoundOperations() {
String redisKey = “test:count”;
BoundValueOperations operations = redisTemplate.boundValueOps(redisKey);
operations.increment();
operations.increment();
operations.increment();
operations.increment();
operations.increment();
System.out.println(operations.get());
}
二、数据结构原理总结
==========
这部分在我看来是最有意思的,我们有必要了解底层数据结构的实现,这也是我最感兴趣的。
比如,你知道redis中的字符串怎么实现的吗?为什么这么实现?
你知道redis压缩列表是什么算法吗?
你知道redis为什么抛弃了红黑树反而采用了跳表这种新的数据结构吗?
你知道hyperloglog为什么用如此小的空间就可以有这么好的统计性能和准确性吗?
你知道布隆过滤器为什么这么有效吗?有没有数学证明过?
你是否还能很快写出来快排?或者不断优化性能的排序?是不是只会调库了甚至库函数怎么实现的都不知道?真的就是快排?
包括数据库,持久化,处理事件、客户端服务端、事务的实现、发布和订阅等功能的实现,也需要了解。
2.1数据结构和对象的实现
- 1) 字符串
redis并未使用传统的c语言字符串表示,它自己构建了一种简单的动态字符串抽象类型。
在redis里,c语言字符串只会作为字符串字面量出现,用在无需修改的地方。
当需要一个可以被修改的字符串时,redis就会使用自己实现的SDS(simple dynamic string)。比如在redis数据库里,包含字符串的键值对底层都是SDS实现的,不止如此,SDS还被用作缓冲区(buffer):比如AOF模块中的AOF缓冲区以及客户端状态中的输入缓冲区。
下面来具体看一下sds的实现:
struct sdshdr
{
int len;//buf已使用字节数量(保存的字符串长度)
int free;//未使用的字节数量
char buf[];//用来保存字符串的字节数组
};
sds遵循c中字符串以’\0’结尾的惯例,这一字节的空间不算在len之内。
这样的好处是,我们可以直接重用c中的一部分函数。比如printf;
sds相对c的改进
获取长度:c字符串并不记录自身长度,所以获取长度只能遍历一遍字符串,redis直接读取len即可。
缓冲区安全:c字符串容易造成缓冲区溢出,比如:程序员没有分配足够的空间就执行拼接操作。而redis会先检查sds的空间是否满足所需要求,如果不满足会自动扩充。
内存分配:由于c不记录字符串长度,对于包含了n个字符的字符串,底层总是一个长度n+1的数组,每一次长度变化,总是要对这个数组进行一次内存重新分配的操作。因为内存分配涉及复杂算法并且可能需要执行系统调用,所以它通常是比较耗时的操作。
redis内存分配:
1、空间预分配:如果修改后大小小于1MB,程序分配和len大小一样的未使用空间,如果修改后大于1MB,程序分配 1MB的未使用空间。修改长度时检查,够的话就直接使用未使用空间,不用再分配。
2、惰性空间释放:字符串缩短时不需要释放空间,用free记录即可,留作以后使用。
二进制安全
c字符串除了末尾外,不能包含空字符,否则程序读到空字符会误以为是结尾,这就限制了c字符串只能保存文本,二进制文件就不能保存了。
而redis字符串都是二进制安全的,因为有len来记录长度。
- 2) 链表
作为一种常用数据结构,链表内置在很多高级语言中,因为c并没有,所以redis实现了自己的链表。
链表在redis也有一定的应用,比如列表键的底层实现之一就是链表。(当列表键包含大量元素或者元素都是很长的字符串时)
发布与订阅、慢查询、监视器等功能也用到了链表。
具体实现:
//redis的节点使用了双向链表结构
typedef struct listNode {
// 前置节点
struct listNode *prev;
// 后置节点
struct listNode *next;
// 节点的值
void *value;
} listNode;
//其实学过数据结构的应该都实现过
typedef struct list {
// 表头节点
listNode *head;
// 表尾节点
listNode *tail;
// 链表所包含的节点数量
unsigned long len;
// 节点值复制函数
void *(*dup)(void *ptr);
// 节点值释放函数
void (*free)(void *ptr);
// 节点值对比函数
int (*match)(void *ptr, void *key);
} list;
总结一下redis链表特性:
双端、无环、带长度记录、
多态:使用 void*
指针来保存节点值, 可以通过 dup
、 free
、 match
为节点值设置类型特定函数, 可以保存不同类型的值。
- 3)字典
其实字典这种数据结构也内置在很多高级语言中,但是c语言没有,所以redis自己实现了。
应用也比较广泛,比如redis的数据库就是字典实现的。不仅如此,当一个哈希键包含的键值对比较多,或者都是很长的字符串,redis就会用字典作为哈希键的底层实现。
来看看具体是实现:
//redis的字典使用哈希表作为底层实现
typedef struct dictht {
// 哈希表数组
dictEntry **table;
// 哈希表大小
unsigned long size;
// 哈希表大小掩码,用于计算索引值
// 总是等于 size - 1
unsigned long sizemask;
// 该哈希表已有节点的数量
unsigned long used;
} dictht;
table
是一个数组, 数组中的每个元素都是一个指向dictEntry
结构的指针, 每个 dictEntry
结构保存着一个键值对。
图为一个大小为4的空哈希表。
我们接着就来看dictEntry的实现:
typedef struct dictEntry {
// 键
void *key;
// 值
union {
void *val;
uint64_t u64;
int64_t s64;
} v;
// 指向下个哈希表节点,形成链表
struct dictEntry *next;
} dictEntry;
(v可以是一个指针, 或者是一个 uint64_t
整数, 又或者是一个 int64_t
整数。)
next就是解决键冲突问题的,冲突了就挂后面,这个学过数据结构的应该都知道吧,不说了。
下面我们来说字典是怎么实现的了。
typedef struct dict {
// 类型特定函数
dictType *type;
// 私有数据
void *privdata;
// 哈希表
dictht ht[2];
// rehash 索引
int rehashidx; //* rehashing not in progress if rehashidx == -1
} dict;
type 和 privdata 是对不同类型的键值对, 为创建多态字典而设置的:
type 指向 dictType , 每个 dictType 保存了用于操作特定类型键值对的函数, 可以为用途不同的字典设置不同的类型特定函数。
而 privdata 属性则保存了需要传给那些类型特定函数的可选参数。
而dictType就暂时不展示了,不重要而且字有点多。。。还是讲有意思的东西吧
rehash(重新散列)
随着我们不断的操作,哈希表保存的键值可能会增多或者减少,为了让哈希表的负载因子维持在合理的范围内,有时需要对哈希表进行合理的扩展或者收缩。 一般情况下, 字典只使用 ht[0] 哈希表, ht[1] 哈希表只会在对 ht[0] 哈希表进行 rehash 时使用。
redis字典哈希rehash的步骤如下:
1)为ht[1]分配合理空间:如果是扩展操作,大小为第一个大于等于ht[0]*used*2的,2的n次幂。
如果是收缩操作,大小为第一个大于等于ht[0]*used的,2的n次幂。
2)将ht[0]中的数据rehash到ht[1]上。
3)释放ht[0],将ht[1]设置为ht[0],ht[1]创建空表,为下次做准备。
渐进rehash
数据量特别大时,rehash可能对服务器造成影响。为了避免,服务器不是一次性rehash的,而是分多次。
我们维持一个变量rehashidx,设置为0,代表rehash开始,然后开始rehash,在这期间,每个对字典的操作,程序都会把索引rehashidx上的数据移动到ht[1]。
随着操作不断执行,最终我们会完成rehash,设置rehashidx为-1.
需要注意:rehash过程中,每一次增删改查也是在两个表进行的。
- 4)整数集合
整数集合(intset)是 Redis 用于保存整数值的集合抽象数据结构, 可以保存 int16_t
、 int32_t
、 int64_t
的整数值, 并且保证集合中不会出现重复元素。
实现较为简单
typedef struct intset {
// 编码方式
uint32_t encoding;
// 集合包含的元素数量
uint32_t length;
// 保存元素的数组
int8_t contents[];
} intset;
各个项在数组中从小到大有序地排列, 并且数组中不包含任何重复项。
虽然 intset 结构将 contents 属性声明为 int8_t 类型的数组, 但实际上 contents 数组并不保存任何 int8_t 类型的值 —— contents 数组的真正类型取决于 encoding 属性的值:
如果 encoding 属性的值为 INTSET_ENC_INT16 , 那么 contents 就是一个 int16_t 类型的数组, 数组里的每个项都是一个 int16_t 类型的整数值 (最小值为 -32,768 ,最大值为 32,767 )。
如果 encoding 属性的值为 INTSET_ENC_INT32 , 那么 contents 就是一个 int32_t 类型的数组, 数组里的每个项都是一个 int32_t 类型的整数值 (最小值为 -2,147,483,648 ,最大值为 2,147,483,647 )。
如果 encoding 属性的值为 INTSET_ENC_INT64 , 那么 contents 就是一个 int64_t 类型的数组, 数组里的每个项都是一个 int64_t 类型的整数值 (最小值为 -9,223,372,036,854,775,808 ,最大值为 9,223,372,036,854,775,807 )。
升级
c语言是静态类型语言,不允许不同类型保存在一个数组。这样第一,灵活性较差,第二,有时会用掉不必要的内存
比如用long long储存1
为了提高整数集合的灵活性和节约内存,我们引入升级策略。
当我们要将一个新元素添加到集合里, 并且新元素类型比集合现有元素的类型都要长时, 集合需要先进行升级。
分为三步进行:
-
根据新元素的类型, 扩展整数集合底层数组的空间大小, 并为新元素分配空间。
-
将底层数组现有的所有元素都转换成与新元素相同的类型, 并将类型转换后的元素放置到正确的位上
-
将新元素添加到底层数组里面。
因为每次添加新元素都可能会引起升级, 每次升级都要对已有元素类型转换, 所以添加新元素的时间复杂度为 O(N) 。
因为引发升级的新元素比原数据都长,所以要么他是最大的,要么他是最小的。我们把它放在开头或结尾即可。
降级
略略略,不管你们信不信,整数集合不支持降级操作。。我也不知道为啥
- 5)压缩列表
压缩列表是列表键和哈希键的底层实现之一。
当一个列表键只包含少量列表项,并且列表项都是小整数或者短字符串,redis就会用压缩列表做列表键底层实现。
压缩列表是 Redis 为了节约内存而开发的, 由一系列特殊编码的连续内存块组成的顺序型(sequential)数据结构。
一个压缩列表可以包含任意多个节点(entry), 每个节点可以保存一个字节数组或者一个整数值。
具体实现:
具体说一下entry:
由三个部分组成:
1、previous_entry_length:记录上一个节点的长度,这样我们就可以从最后一路遍历到开头。
2、encoding:记录了content所保存的数据类型和长度。(具体编码不写了,不重要)
3、content:保存节点值,可以是字节数组或整数。(具体怎么压缩的等我搞明白再补)
连锁更新
前面说过, 每个节点的 previous_entry_length
属性都记录了前一个节点的长度:
-
如果前一节点的长度<
254
KB, 那么previous_entry_length
需要用1
字节长的空间 -
如果前一节点的长度>=
254
KB, 那么previous_entry_length
需要用5
字节长的空间
现在, 考虑这样一种情况: 在一个压缩列表中, 有多个连续的、长度介于 250 字节到 253 字节之间的节点 ,这时, 如果我们将一个长度大于等于 254 字节的新节点 new 设置为压缩列表的表头节点。。。。
然后脑补一下,就会导致连锁扩大每个节点的空间对吧?e(i)因为e(i-1)的扩大而扩大,i+1也是如此,以此类推。。。
删除节点同样会导致连锁更新。
这个事情只是想说明一个问题:插入删除操作的最坏时间复杂度其实是o(n*n),因为每更新一个节点都要o(n)。
但是,也不用太过担心,因为这种特殊情况并不多见,这些命令的平均复杂度依旧是o(n)。
2.2 跳表专栏
2.2.1跳表是啥
为什么选择了跳表而不是红黑树?
跳表是个啥东西(百度娘)
我们知道,节点插入时随机出一个层数,仅仅依靠一个简单的随机数操作而构建出来的多层链表结构,能保证它有一个良好的查找性能吗?为了回答这个疑问,我们需要分析skiplist的统计性能。
在分析之前,我们还需要着重指出的是,执行插入操作时计算随机数的过程,是一个很关键的过程,它对skiplist的统计特性有着很重要的影响。这并不是一个普通的服从均匀分布的随机数,它的计算过程如下:
-
首先,每个节点肯定都有第1层指针(每个节点都在第1层链表里)。
-
如果一个节点有第i层(i>=1)指针(即节点已经在第1层到第i层链表中),那么它有第(i+1)层指针的概率为p。
-
节点最大的层数不允许超过一个最大值,记为MaxLevel。
这个计算随机层数的伪码如下所示:
randomLevel()
level := 1
// random()返回一个[0…1)的随机数
while random() < p and level < MaxLevel do
level := level + 1
return level
randomLevel()的伪码中包含两个参数,一个是p,一个是MaxLevel。在Redis的skiplist实现中,这两个参数的取值为:
p = 1/4
MaxLevel = 32
2.2.2skiplist的算法性能分析
在这一部分,我们来简单分析一下skiplist的时间复杂度和空间复杂度,以便对于skiplist的性能有一个直观的了解。如果你不是特别偏执于算法的性能分析,那么可以暂时跳过这一小节的内容。
我们先来计算一下每个节点所包含的平均指针数目(概率期望)。节点包含的指针数目,相当于这个算法在空间上的额外开销(overhead),可以用来度量空间复杂度。
根据前面randomLevel()的伪码,我们很容易看出,产生越高的节点层数,概率越低。定量的分析如下:
节点层数至少为1。而大于1的节点层数,满足一个概率分布。
节点层数恰好等于1的概率为1-p。
节点层数大于等于2的概率为p,而节点层数恰好等于2的概率为p(1-p)。
节点层数大于等于3的概率为p2,而节点层数恰好等于3的概率为p2(1-p)。
节点层数大于等于4的概率为p3,而节点层数恰好等于4的概率为p3(1-p)。
…
因此,一个节点的平均层数(也即包含的平均指针数目),计算如下:
现在很容易计算出:
-
当p=1/2时,每个节点所包含的平均指针数目为2;
-
当p=1/4时,每个节点所包含的平均指针数目为1.33。这也是Redis里的skiplist实现在空间上的开销。
接下来,为了分析时间复杂度,我们计算一下skiplist的平均查找长度。查找长度指的是查找路径上跨越的跳数,而查找过程中的比较次数就等于查找长度加1。以前面图中标出的查找23的查找路径为例,从左上角的头结点开始,一直到结点22,查找长度为6。
为了计算查找长度,这里我们需要利用一点小技巧。我们注意到,每个节点插入的时候,它的层数是由随机函数randomLevel()计算出来的,而且随机的计算不依赖于其它节点,每次插入过程都是完全独立的。所以,从统计上来说,一个skiplist结构的形成与节点的插入顺序无关。
这样的话,为了计算查找长度,我们可以将查找过程倒过来看,从右下方第1层上最后到达的那个节点开始,沿着查找路径向左向上回溯,类似于爬楼梯的过程。我们假设当回溯到某个节点的时候,它才被插入,这虽然相当于改变了节点的插入顺序,但从统计上不影响整个skiplist的形成结构。
现在假设我们从一个层数为i的节点x出发,需要向左向上攀爬k层。这时我们有两种可能:
-
如果节点x有第(i+1)层指针,那么我们需要向上走。这种情况概率为p。
-
如果节点x没有第(i+1)层指针,那么我们需要向左走。这种情况概率为(1-p)。
用C(k)表示向上攀爬k个层级所需要走过的平均查找路径长度(概率期望),那么:
C(0)=0
C(k)=(1-p)×(上图中情况b的查找长度) + p×(上图中情况c的查找长度)
代入,得到一个差分方程并化简:
C(k)=(1-p)(C(k)+1) + p(C(k-1)+1)
C(k)=1/p+C(k-1)
C(k)=k/p
这个结果的意思是,我们每爬升1个层级,需要在查找路径上走1/p步。而我们总共需要攀爬的层级数等于整个skiplist的总层数-1。
那么接下来我们需要分析一下当skiplist中有n个节点的时候,它的总层数的概率均值是多少。这个问题直观上比较好理解。根据节点的层数随机算法,容易得出:
-
第1层链表固定有n个节点;
-
第2层链表平均有n*p个节点;
-
第3层链表平均有n*p^2个节点;
-
…
所以,从第1层到最高层,各层链表的平均节点数是一个指数递减的等比数列。容易推算出,总层数的均值为log1/pn,而最高层的平均节点数为1/p。
综上,粗略来计算的话,平均查找长度约等于:
- C(log1/pn-1)=(log1/pn-1)/p
即,平均时间复杂度为O(log n)。
当然,这里的时间复杂度分析还是比较粗略的。比如,沿着查找路径向左向上回溯的时候,可能先到达左侧头结点,然后沿头结点一路向上;还可能先到达最高层的节点,然后沿着最高层链表一路向左。但这些细节不影响平均时间复杂度的最后结果。另外,这里给出的时间复杂度只是一个概率平均值,但实际上计算一个精细的概率分布也是有可能的。
详情还请参见William Pugh的论文《Skip Lists: A Probabilistic Alternative to Balanced Trees》。
2.2.3skiplist与平衡树、哈希表的比较
skiplist和各种平衡树(如AVL、红黑树等)的元素是有序排列的,而哈希表不是有序的。因此,在哈希表上只能做单个key的查找,不适宜做范围查找。所谓范围查找,指的是查找那些大小在指定的两个值之间的所有节点。
在做范围查找的时候,平衡树比skiplist操作要复杂。在平衡树上,我们找到指定范围的小值之后,还需要以中序遍历的顺序继续寻找其它不超过大值的节点。如果不对平衡树进行一定的改造,这里的中序遍历并不容易实现。而在skiplist上进行范围查找就非常简单,只需要在找到小值之后,对第1层链表进行若干步的遍历就可以实现。
平衡树的插入和删除操作可能引发子树的调整,逻辑复杂,而skiplist的插入和删除只需要修改相邻节点的指针,操作简单又快速。
从内存占用上来说,skiplist比平衡树更灵活一些。一般来说,平衡树每个节点包含2个指针(分别指向左右子树),而skiplist每个节点包含的指针数目平均为1/(1-p),具体取决于参数p的大小。如果像Redis里的实现一样,取p=1/4,那么平均每个节点包含1.33个指针,比平衡树更有优势。
查找单个key,skiplist和平衡树的时间复杂度都为O(log n),大体相当;而哈希表在保持较低的哈希值冲突概率的前提下,查找时间复杂度接近O(1),性能更高一些。所以我们平常使用的各种Map或dictionary结构,大都是基于哈希表实现的。
从算法实现难度上来比较,skiplist比平衡树要简单得多。
2.2.4Redis中的skiplist和经典有何不同
最后我们看看,对于这个问题,Redis的作者 @antirez 是怎么说的:
There are a few reasons:
-
They are not very memory intensive. It’s up to you basically. Changing parameters about the probability of a node to have a given number of levels will make then less memory intensive than btrees.
-
A sorted set is often target of many ZRANGE or ZREVRANGE operations, that is, traversing the skip list as a linked list. With this operation the cache locality of skip lists is at least as good as with other kind of balanced trees.
-
They are simpler to implement, debug, and so forth. For instance thanks to the skip list simplicity I received a patch (already in Redis master) with augmented skip lists implementing ZRANK in O(log(N)). It required little changes to the code.
有几个原因:
1)它们的记忆力不是很强。基本上由你决定。更改有关节点具有给定数量级别的概率的参数将使内存密集度低于btree。
2)排序集通常是许多Zrange或Zrevrange操作的目标,即作为链表遍历跳过列表。通过此操作,跳过列表的缓存区域性至少与其他类型的平衡树一样好。
3)它们易于实现、调试等。例如,由于跳过列表的简单性,我收到了一个补丁(已经在redis master中),其中包含在o(log(n))中实现zrank的扩展跳过列表。它只需要对代码稍作修改。
2.3HyperLogLog 专栏
HyperLogLog 是一种概率数据结构,用来估算数据的基数。数据集可以是网站访客的 IP 地址,E-mail 邮箱或者用户 ID。
基数就是指一个集合中不同值的数目,比如 a, b, c, d 的基数就是 4,a, b, c, d, a 的基数还是 4。虽然 a 出现两次,只会被计算一次。
使用 Redis 统计集合的基数一般有三种方法,分别是使用 Redis 的 HashMap,BitMap 和 HyperLogLog。前两个数据结构在集合的数量级增长时,所消耗的内存会大大增加,但是 HyperLogLog 则不会。
Redis 的 HyperLogLog 通过牺牲准确率来减少内存空间的消耗,只需要12K内存,在标准误差0.81%的前提下,能够统计2^64个数据。所以 HyperLogLog 是否适合在比如统计日活月活此类的对精度要不不高的场景。
这是一个很惊人的结果,以如此小的内存来记录如此大数量级的数据基数。下面我们就带大家来深入了解一下 HyperLogLog 的使用,基础原理,源码实现和具体的试验数据分析。
2.3.1HyperLogLog 在 Redis 中的使用
Redis 提供了 PFADD
、 PFCOUNT
和 PFMERGE
三个命令来供用户使用 HyperLogLog。
PFADD
用于向 HyperLogLog 添加元素。
PFADD visitors alice bob carol
(integer) 1
PFCOUNT visitors
(integer) 3
如果 HyperLogLog 估计的近似基数在 PFADD 命令执行之后出现了变化, 那么命令返回 1 , 否则返回 0 。 如果命令执行时给定的键不存在, 那么程序将先创建一个空的 HyperLogLog 结构, 然后再执行命令。
PFCOUNT 命令会给出 HyperLogLog 包含的近似基数。在计算出基数后, PFCOUNT 会将值存储在 HyperLogLog 中进行缓存,知道下次 PFADD 执行成功前,就都不需要再次进行基数的计算。
PFMERGE 将多个 HyperLogLog 合并为一个 HyperLogLog , 合并后的 HyperLogLog 的基数接近于所有输入 HyperLogLog 的并集基数。
PFADD customers alice dan
(integer) 1
PFMERGE everyone visitors customers
OK
PFCOUNT everyone
(integer) 4
2.3.2内存消耗对比实验
我们下面就来通过实验真实对比一下下面三种数据结构的内存消耗,HashMap、BitMap 和 HyperLogLog。
我们首先使用 Lua 脚本向 Redis 对应的数据结构中插入一定数量的数,然后执行 bgsave 命令,最后使用 redis-rdb-tools 的 rdb 的命令查看各个键所占的内存大小。
下面是 Lua 的脚本
local key = KEYS[1]
local size = tonumber(ARGV[1])
local method = tonumber(ARGV[2])
for i=1,size,1 do
if (method == 0)
then
redis.call(‘hset’,key,i,1)
elseif (method == 1)
then
redis.call(‘pfadd’,key, i)
else
redis.call(‘setbit’, key, i, 1)
end
end
我们在通过 redis-cli 的 script load 命令将 Lua 脚本加载到 Redis 中,然后使用 evalsha 命令分别向 HashMap、HyperLogLog 和 BitMap 三种数据结构中插入了一千万个数,然后使用 rdb 命令查看各个结构内存消耗。
我们进行了两轮实验,分别插入一万数字和一千万数字,三种数据结构消耗的内存统计如下所示。
从表中可以明显看出,一万数量级时 BitMap 消耗内存最小, 一千万数量级时 HyperLogLog 消耗内存最小,但是总体来看,HyperLogLog 消耗的内存都是 14392 字节,可见 HyperLogLog 在内存消耗方面有自己的独到之处。
2.3.3基本原理
HyperLogLog 是一种概率数据结构,它使用概率算法来统计集合的近似基数。而它算法的最本源则是伯努利过程。
伯努利过程就是一个抛硬币实验的过程。抛一枚正常硬币,落地可能是正面,也可能是反面,二者的概率都是 1/2 。伯努利过程就是一直抛硬币,直到落地时出现正面位置,并记录下抛掷次数k。比如说,抛一次硬币就出现正面了,此时 k 为 1; 第一次抛硬币是反面,则继续抛,直到第三次才出现正面,此时 k 为 3。
对于 n 次伯努利过程,我们会得到 n 个出现正面的投掷次数值 k1, k2 … kn , 其中这里的最大值是k_max。
根据一顿数学推导,我们可以得出一个结论: 2^{k_ max} 来作为n的估计值。也就是说你可以根据最大投掷次数近似的推算出进行了几次伯努利过程。
下面,我们就来讲解一下 HyperLogLog 是如何模拟伯努利过程,并最终统计集合基数的。
HyperLogLog 在添加元素时,会通过Hash函数,将元素转为64位比特串,例如输入5,便转为101(省略前面的0,下同)。这些比特串就类似于一次抛硬币的伯努利过程。比特串中,0 代表了抛硬币落地是反面,1 代表抛硬币落地是正面,如果一个数据最终被转化了 10010000,那么从低位往高位看,我们可以认为,这串比特串可以代表一次伯努利过程,首次出现 1 的位数为5,就是抛了5次才出现正面。
所以 HyperLogLog 的基本思想是利用集合中数字的比特串第一个 1 出现位置的最大值来预估整体基数,但是这种预估方法存在较大误差,为了改善误差情况,HyperLogLog中引入分桶平均的概念,计算 m 个桶的调和平均值。
Redis 中 HyperLogLog 一共分了 2^14 个桶,也就是 16384 个桶。每个桶中是一个 6 bit 的数组。
HyperLogLog 将上文所说的 64 位比特串的低 14 位单独拿出,它的值就对应桶的序号,然后将剩下 50 位中第一次出现 1 的位置值设置到桶中。50位中出现1的位置值最大为50,所以每个桶中的 6 位数组正好可以表示该值。
在设置前,要设置进桶的值是否大于桶中的旧值,如果大于才进行设置,否则不进行设置。
此时为了性能考虑,是不会去统计当前的基数的,而是将 HyperLogLog 头的 card 属性中的标志位置为 1,表示下次进行 pfcount 操作的时候,当前的缓存值已经失效了,需要重新统计缓存值。在后面 pfcount 流程的时候,发现这个标记为失效,就会去重新统计新的基数,放入基数缓存。
在计算近似基数时,就分别计算每个桶中的值,带入到上文的 DV 公式中,进行调和平均和结果修正,就能得到估算的基数值。
2.3.4HyperLogLog 具体对象
我们首先来看一下 HyperLogLog 对象的定义
struct hllhdr {
char magic[4]; /* 魔法值 “HYLL” */
uint8_t encoding; /* 密集结构或者稀疏结构 HLL_DENSE or HLL_SPARSE. */
uint8_t notused[3]; /* 保留位, 全为0. */
uint8_t card[8]; /* 基数大小的缓存 */
uint8_t registers[]; /* 数据字节数组 */
};
HyperLogLog 对象中的 registers 数组就是桶,它有两种存储结构,分别为密集存储结构和稀疏存储结构,两种结构只涉及存储和桶的表现形式,从中我们可以看到 Redis 对节省内存极致地追求。
我们先看相对简单的密集存储结构,它也是十分的简单明了,既然要有 2^14 个 6 bit的桶,那么我就真使用足够多的 uint8_t 字节去表示,只是此时会涉及到字节位置和桶的转换,因为字节有 8 位,而桶只需要 6 位。
所以我们需要将桶的序号转换成对应的字节偏移量 offsetbytes 和其内部的位数偏移量 offsetbits。需要注意的是小端字节序,高位在右侧,需要进行倒转。
当 offset_bits 小于等于2时,说明一个桶就在该字节内,只需要进行倒转就能得到桶的值。
offset_bits 大于 2 ,则说明一个桶分布在两个字节内,此时需要将两个字节的内容都进行倒置,然后再进行拼接得到桶的值。
Redis 为了方便表达稀疏存储,它将上面三种字节表示形式分别赋予了一条指令。
-
ZERO : 一字节,表示连续多少个桶计数为0,前两位为标志00,后6位表示有多少个桶,最大为64。
-
XZERO : 两个字节,表示连续多少个桶计数为0,前两位为标志01,后14位表示有多少个桶,最大为16384。
-
VAL : 一字节,表示连续多少个桶的计数为多少,前一位为标志1,四位表示连桶内计数,所以最大表示桶的计数为32。后两位表示连续多少个桶。
Redis从稀疏存储转换到密集存储的条件是:
-
任意一个计数值从 32 变成 33,因为 VAL 指令已经无法容纳,它能表示的计数值最大为 32
-
稀疏存储占用的总字节数超过 3000 字节,这个阈值可以通过 hllsparsemax_bytes 参数进行调整。
2.4LRU专栏
2.4.1LRU介绍和代码实现
LRU全称是Least Recently Used,即最近最久未使用的意思。
LRU算法的设计原则是:如果一个数据在最近一段时间没有被访问到,那么在将来它被访问的可能性也很小。也就是说,当限定的空间已存满数据时,应当把最久没有被访问到的数据淘汰。(这一段是找的,让大家理解一下什么是LRU)。
说一下我们什么时候见到过LRU:其实老师们肯定都给大家举过这么个例子:你在图书馆,你把书架子里的书拿到桌子上。。但是桌子是有限的,你有时候不得不把一些书放回去。这就相当于内存和硬盘。这个例子都说过吧?
LRU就是记录你最长时间没看过的书,就把它放回去。在cache那里见过吧
然后最近在研究redis,又看到了这个LRU,所以就想写一下吧。
题目:设计一个结构,这个结构可以查询K-V,但是容量有限,当存不下的时候就要把用的年代最久远的那个东西扔掉。
其实思路很简单,我们维护一个双向链表即可,get也就是使用了,我们就把把它提到最安全的位置。新来的KV就依次放即可。
我们就先写这个双向链表结构
先写节点结构:
public static class Node {
public V value;
public Node last;//前
public Node next;//后
public Node(V value) {
this.value = value;
}
}
然后写双向链表结构: 我们没必要把链表操作都写了,分析一下,我们只有三个操作:
1、加节点
2、使用了某个节点就把它调到尾,代表优先级最高
3、把优先级最低的移除,也就是去头部
(不会的,翻我之前的链表操作都有写)
public static class NodeDoubleLinkedList {
private Node head;//头
private Node tail;//尾
public NodeDoubleLinkedList() {
this.head = null;
this.tail = null;
}
public void addNode(Node newNode) {
if (newNode == null) {
return;
}
if (this.head == null) {//头空
this.head = newNode;
this.tail = newNode;
} else {//头不空
this.tail.next = newNode;
newNode.last = this.tail;//注意让本节点前指针指向旧尾
this.tail = newNode;//指向新尾
}
}
/某个点移到最后/
public void moveNodeToTail(Node node) {
if (this.tail == node) {//是尾
return;
}
if (this.head == node) {//是头
this.head = node.next;
this.head.last = null;
} else {//中间
node.last.next = node.next;
node.next.last = node.last;
}
node.last = this.tail;
node.next = null;
this.tail.next = node;
this.tail = node;
}
/删除第一个/
public Node removeHead() {
if (this.head == null) {
return null;
}
Node res = this.head;
if (this.head == this.tail) {//就一个
this.head = null;
this.tail = null;
} else {
this.head = res.next;
res.next = null;
this.head.last = null;
}
return res;
}
}
链表操作封装完了就要实现这个结构了。
具体思路代码注释
public static class MyCache<K, V> {
//为了kv or vk都能查
private HashMap<K, Node> keyNodeMap;
private HashMap<Node, K> nodeKeyMap;
//用来做优先级
private NodeDoubleLinkedList nodeList;
private int capacity;//容量
public MyCache(int capacity) {
if (capacity < 1) {//你容量连1都不给,捣乱呢
throw new RuntimeException(“should be more than 0.”);
}
this.keyNodeMap = new HashMap<K, Node>();
this.nodeKeyMap = new HashMap<Node, K>();
this.nodeList = new NodeDoubleLinkedList();
this.capacity = capacity;
}
public V get(K key) {
if (this.keyNodeMap.containsKey(key)) {
Node res = this.keyNodeMap.get(key);
this.nodeList.moveNodeToTail(res);//使用过了就放到尾部
return res.value;
}
return null;
}
public void set(K key, V value) {
if (this.keyNodeMap.containsKey(key)) {
Node node = this.keyNodeMap.get(key);
node.value = value;//放新v
this.nodeList.moveNodeToTail(node);//我们认为放入旧key也是使用过
} else {
Node newNode = new Node(value);
this.keyNodeMap.put(key, newNode);
this.nodeKeyMap.put(newNode, key);
this.nodeList.addNode(newNode);//加进去
if (this.keyNodeMap.size() == this.capacity + 1) {
this.removeMostUnusedCache();//放不下就去掉优先级最低的
}
}
}
private void removeMostUnusedCache() {
//删除头
Node removeNode = this.nodeList.removeHead();
K removeKey = this.nodeKeyMap.get(removeNode);
//删除掉两个map中的记录
this.nodeKeyMap.remove(removeNode);
this.keyNodeMap.remove(removeKey);
}
}
2.4.2Redis中的LRU算法改进
redis通常使用缓存,是使用一种固定最大内存的使用。当数据达到可使用的最大固定内存时,我们需要通过移除老数据来获取空间。redis作为缓存是否有效的重要标志是如何寻找一种好的策略:删除即将需要使用的数据是一种糟糕的策略,而删除那些很少再次请求的数据则是一种好的策略。
在其他的缓存组件还有个命中率,仅仅表示读请求的比例。访问一个缓存中的keys通常不是分布式的。然而访问经常变化,这意味着不经常访问,相反,有些keys一旦不流行可能会转向最经常访问的keys。 因此,通常一个缓存系统应该尽可能保留那些未来最有可能被访问的keys。针对keys淘汰的策略是:那些未来极少可能被访问的数据应该被移除。
但有一个问题:redis和其他缓存系统不能够预测未来。
LRU算法
缓存系统不能预测未来,原因是:那些很少再次被访问的key也很有可能最近访问相当频繁。如果经常被访问的模式不会突然改变,那么这是一种很有效的策略。然而,“最近经常被访问”似乎更隐晦地标明一种 理念。这种算法被称为LRU算法。最近访问频繁的key相比访问少的key有更高的可能性。
举个例子,这里有4个不同访问周期的key,每一个“~”字符代表一秒,结尾的“|”表示当前时刻。
~~B~~B~~B~~B~~B~~B~~B~~B~~B~~B~~B~~B~|
~~~~~~~~~~C~~~~~~~~~C~~~~~~~~~C~~~~~~|
~~~~~D~~~~~~~~~~D~~~~~~~~~D~~~~~~~~~D|
A key每5秒请求一次,B周期是2秒,C、D都是10秒。
访问频率最高的是B,因为它的空闲时间最短,这意味着B是4个key中未来最有可能被访问的key。
同样的A和C目前的空闲时间是2s和6s也能很好地反映它们本身的周期。然而你可以看到不够严谨:D的访问周期是10秒,但它却是4个key中最近被访问的。
当然,在一个很长的运行周期中,LRU算法能工作得很好。通常有一个更高访问频率的key当然有一个更低的空闲周期。LRU算法淘汰最少被访问key,那些有最大空闲周期的key。实现上也相当容易,只需要额外跟踪最近被访问的key即可,有时甚至都需要:把所有我们想要淘汰的对象放到一个链表中,当一个对象访问就移除链表头部元素,当我们要淘汰元素是就直接淘汰链表尾部开始。
**redis中的LRU:起因**
最初,redis不支持LRU算法。当内存有效性成为一个必须被解决的问题时,后来才加上了。通过修改redis对象结构,在每个key对象增加24bit的空间。没有额外的空间使用链表把所有对象放到一个链表中(大指针),因此需要实现得更加有效,不能因为key淘汰算法而让整个服务改动太大。
24bits的对象已经足够去存储当前的unxi时间戳。这个表现,被称为“LRU 时钟”,key元数据经常被更新,所以它是一个有效的算法。
然后,有另一个更加复杂的问题需要解决:如何选择访问间隔最长的key,然后淘汰它。
redis内部采用一个庞大的hash table来保存,添加另外一个数据结构存储时间间隔显然不是一个好的选择。然而我们希望能达到一个LRU本身是一个近似的,通过LRU算法本身来实现。
redis原始的淘汰算法简单实现:\*\*当需要淘汰一个key时,随机选择3个key,淘汰其中间隔时间最长的key。\*\*基本上,我们随机选择key,淘汰key效果很好。后来随机3个key改成一个配置项"N随机key"。但把默认值提高改成5个后效果大大提高。考虑到它的效果,你根本不用修改他。
然而,你可能会想这个算法如何有效执行,你可以看到我们如何捣毁了很多有趣的数据。也许简单的N key,我们会遇到很多好的决策,但是当我们淘汰最好的,下一个周期又开始抓。
**验证规则第一条:用肉眼观察你的算法**
其中有一个观点已经应用到Redis 3.0正式版中了。在redis2.8中一个LRU缓存经常被使用在多个环境,用户关于淘汰的没有抱怨太多,但是很明显我可以提高它,通过不仅仅是增加额外的空间,还有额外的CPU时间。
然而为了提高某项功能,你必须观察它。有多个不同的方式去观察LRU算法。你可以通过写工具观察,例如模拟不同的工作负载、校验命中率和失误率。
程序非常简单:增加一些指定的keys,然后频繁地访问这些keys以至于每一个key都有一个下降的空闲时间。最终超过50%的keys被增加,一半的老key需要被淘汰。
一个完美理想的LRU实现,应该是没有最新加的key被淘汰,而是淘汰最初的50%的老key。
**规则二:不要丢弃重要信息**
借助最新的可视化工具,我可以在尝试新的方法观察和测试几分钟。使用redis最明显有效的提高算法就是,积累对立的垃圾信息在一个淘汰池中。
基本上,当N keys算法被采用时,通常会分配一个很大的线程pool(默认为16key),这个池按照空闲时间排序,所以只有当有一个大于池中的一个或者池为空的时候,最新的key只会进入到这个池中。
同时,一个新的redis-cli模式去测量LRU算法也增加了(看这个-lru-test选项)。
还有另外一个方式去检验LRU算法的好坏,通过一个幂等访问模式。这个工具通常校验用一个不同的测试,新算法工作工作效果好于真实世界负载。它也同样使用流水线和每秒打印访问日志,因此可以被使用不用为了基准不同的思想,至少可以校验和观察明显的速度回归
**规则三、最少使用原则(LFU算法)**
一切源于一个开放性问题:但你有多个redis 3.2数据库时,而淘汰算法只能在本机选择。因此,假如你全部空闲小的key都是DB0号机器,空闲时间长的key都是1号机器,redis每台机器都会淘汰各自的key。一个更好的选择当然是先淘汰DB1,最后再淘汰DB0。
当redis被当作缓存使用时很少有情况被分成不同的db上,这不是一个好的处理方式。然而这也是我为什么我再一次修改淘汰代码的原因。最终,我能够修改缓存池包括数据库id,使用单缓存池为多个db,代替多缓存池。这种实现很麻烦,但是通过优化和修改代码,最终它比普通实现要快到20%。
然而这时候,我对这个redis缓存淘汰算法的好奇心又被点燃。我想要提升它。我花费了几天想要提高LRU算法实现:或许可以使用更大的缓存池?通过历史时间选择最合适被淘汰的key?
经过一段时间,通过优化我的工具,我理解到:LRU算法受限于数据库中的数据样本,有时可能相反的场景效果非常好,因此要想提高非常非常难。实际上,能通过展示不同算法的图片上看这有点非常明显:每个周期10个keys几乎和理论的LRU算法表现一致。
当原始算法很难提高时,我开始测试新的算法。 如果我们倒回到博客开始,我们说过LRU实际上有点严格。哪些key需要我们真正想要保留:将来有最大可能被访问,最频繁被访问,而不是最近被访问的key。
淘汰最少被访问的key算法成为:LFU(Least Frequently Used),将来要被淘汰腾出新空间给新key。
理论上LFU的思想相当简单,只需要给每个key加一个访问计数器。每次访问就自增1,所以也就很容易知道哪些key被访问更频繁。
当然,LFU也会带起其他问题,不单单是针对redis,对于LFU实现:
1、不能使用“移除顶部元素”的方式,keys必须要根据访问计数器进行排序。每访问一次就得遍历所有key找出访问次数最少的key。
2、LFU不能仅仅是只增加每一访问的计数器。正如我们所讲的,访问模式改变随时变化,因此一个有高访问次数的key,后面很可能没有人继续访问它,因此我们的算法必须要适应超时的情况。
在redis中,第一个问题很好解决:我们可以在LRU的方式一样:随机在缓存池中选举,淘汰其中某项。第二个问题redis还是存在,因此一般对于LFU的思想必须使用一些方式进行减少,或者定期把访问计数器减半。
**24位的LFU实现**
LFU有它本身的实现,在redis中我们使用自己的24bit来记录LRU。
为了实现LFU仅仅需要在每个对象额外新增24bit:
1、一部分用于保存访问计数器;
2、足够用于决定什么时候将计数器减半的信息;
我的解决方法是把24bit分成两列:
16bits8bitslast decr timeLOG\_C
16位记录最后一次减半时间,那样redis知道上一次减半时间,另外8bit作为访问计数器。
你可能会想8位的计数器很快就会溢出,是的,相对于简单计数器,我采用逻辑计数器。逻辑计数器的实现:
uint8_t LFULogIncr(uint8_t counter) {
if (counter == 255) return 255;
double r = (double)rand()/RAND_MAX;
double baseval = counter - LFU_INIT_VAL;
if (baseval < 0) baseval = 0;
double p = 1.0/(baseval*server.lfu_log_factor+1);
if (r < p) counter++;
return counter;
}
基本上计数器的较大者,更小的可能计数器会增加:上面的代码计算p位于0~1之间,但计数器增长时会越来越小,位于0-1的随机数r,只会但满足r<p时计数器才会加一。
你可以配置计数器增长的速率,如果使用默认配置,会发生:
* 100次访问后,计数器=10;
* 1000次访问是是18;
* 10万次访问是142;
* 100万次访问后达到255,并不在继续增长;
下面,让我们看看计数器如果进行衰减。16位的被储存为unix时间戳保留到分钟级别,redis会随机扫描key填充到缓存池中,如果最后一个下降的时间大于N分钟前(可配置化),如果计数器的值很大就减半,或者对于值小的就直接简单减半。
这里又衍生出另外一个问题,就是新进来的key是需要有机会被保留的。由于LFU新增是得分都是0,非常容易被选举替换掉。在redis中,开始默认值为5。这个初始值是根据增长数据和减半算法来估算的。模拟显示得分小于5的key是首选。
**代码和性能**
上面描述的算法已经提交到一个非稳定版的redis分支上。我最初的测试显示:它在幂等模式下优于LRU算法,测试情况是每个key使用用相同数量的内存,然而真实世界的访问可能会有很大不同。时间和空间都可能改变得很不同,所以我会很开心去学习观察现实世界中LFU的性能如何,两种方式在redis实现中对性能的改变。
因此,新增了一个OBJECT FREQ子命令,用于报告给定key的访问计数器,不仅仅能有效提观察一个计数器,而且还能调试LFU实现中的bug。
注意运行中切换LRU和LFU,刚开始会随机淘汰一些key,随着24bit不能匹配上,然而慢慢会适应。 还有几种改进实现的可能。Ben Manes发给我这篇感兴趣的文章,描述了一种叫TinyLRU算法。链接
这篇文章包含一个非常厉害的观点:相比于记录当前对象的访问频率,让我们(概率性地)记录全部对象的访问频率,看到了,这种方式我们甚至可以拒绝新key,同样,我们相信这些key很可能得到很少的访问,所以一点也不需要淘汰,如果淘汰一个key意味着降低命中/未命中率。
我的感觉这种技术虽然很感兴趣GET/SET LFU缓存,但不适用与redis性质的数据服务器:用户期望keys被创建后至少存在几毫秒。拒绝key的创建似乎在redis上就是一种错误。
然而,redis保留了LFU信息,当一个key被覆盖时,举个例子:
SET oldkey some_new_value
24位的LFU计数器会从老的key复制到新对象中。
新的redis淘汰算法不稳定版本还有以下几个好消息:
1、跨DB策略。在过去的redis只是基于本地的选举,现在修复为所有策略,不仅仅是LRU。
2、易变ttl策略。基于key预期淘汰存活时间,如今就像其他策略中的使用缓存池。
3、在缓存池中重用了sds对象,性能更好。
这篇博客比我预期要长,但是我希望它反映出一个见解:在创新和对于已经存在的事物实现上,一种解决方案去解决一个特定问题,一个基础工具。由开发人员以正确的方式使用它。许多redis的用户把redis作为一个缓存的解决方案,因此提高淘汰策略这一块经常一次又一次被拿出来探讨。
2.6对象
-----
刚写了redis主要的数据结构:
动态字符串、双端链表、字典、压缩列表、整数集合、跳表等
redis肯定不能直接使用这些数据结构来实现数据库,它用这些数据库建立了一个对象系统,包含:
字符串对象、列表对象、哈希对象、集合对象、有序集合对象
我们可以针对不同的使用场景,为对象设置多种分不同的数据结构实现,从而优化对象在不同场景下的效率。
**1)键值对**
对于redis的键值对来说:key只有字符串类型,而v可以是各种类型,
我们习惯把“这个键所对应的值是一个列表”表达为这是一个“列表键。
TYPE 命令的实现方式也与此类似, 当我们对一个数据库键执行 TYPE 命令时, 命令返回的结果为数据库键对应的值对象的类型, 而不是键对象的类型:
# 键为字符串对象,值为列表对象
redis> RPUSH numbers 1 3 5
(integer) 6
redis> TYPE numbers
list
**2)对象**
我们看一下redis对象的组成:
typedef struct redisObject {
// 类型
unsigned type:4;
// 编码
unsigned encoding:4;
// 指向底层实现数据结构的指针
void *ptr;
// ...
} robj;
**通过 `encoding` 属性来设定对象所使用的编码, 而不是为特定类型的对象关联一种固定的编码, 极大地提升了 Redis 的灵活性和效率, 因为 Redis 可以根据不同的使用场景来为一个对象设置不同的编码, 从而优化对象在某一场景下的效率。**
字符串对象
字符串对象的编码可以是 int 、 raw 或者 embstr 。
如果一个字符串对象保存的是整数值, 并且这个整数值可以用 long 类型来表示, 那么字符串对象会将整数值保存在字符串对象结构的 ptr属性里面(将 void\* 转换成 long ), 并将字符串对象的编码设置为 int 。
如果字符串对象保存的是一个字符串值, 并且这个字符串值的长度大于 39 字节, 那么字符串对象将使用一个简单动态字符串(SDS)来保存这个字符串值, 并将对象的编码设置为 raw 。
如果字符串对象保存的是一个字符串值, 并且这个字符串值的长度小于等于 39 字节, 那么字符串对象将使用 embstr 编码的方式来保存这个字符串值。
embstr 编码是专门用于保存短字符串的一种优化编码方式, 这种编码和 raw 编码一样, 都使用 redisObject 结构和 sdshdr 结构来表示字符串对象,但 raw 编码会调用两次内存分配函数来分别创建 redisObject 结构和 sdshdr 结构,而 embstr 编码则通过调用一次内存分配函数来分配一块连续的空间, 空间中依次包含 redisObject 和 sdshdr 两个结构。
`embstr` 编码有以下好处:
1. `embstr` 编码创建删除字符串对象只需操作一次内存
2. 因为数据都保存在一块连续的内存, 所以这种编码的字符串对象比 `raw` 编码字符串对象能更好地利用缓存带来的优势。
**3)列表对象**
列表对象的编码可以是 `ziplist` 或者 `linkedlist` 。
当列表对象可以同时满足以下两个条件时, 列表对象使用 `ziplist` 编码:
1. 列表对象保存的所有字符串元素的长度都小于 `64` 字节;
2. 列表对象保存的元素数量小于 `512` 个;
不能满足这两个条件的列表对象需要使用 `linkedlist` 编码。
**4)哈希对象**
哈希对象的编码可以是 `ziplist` 或者 `hashtable` 。
当哈希对象可以同时满足以下两个条件时, 哈希对象使用 `ziplist` 编码:
1. 哈希对象保存的所有键值对的键和值的字符串长度都小于 `64` 字节;
2. 哈希对象保存的键值对数量小于 `512` 个;
不能满足这两个条件的哈希对象需要使用 `hashtable` 编码。
**5)集合对象**
集合对象的编码可以是 `intset` 或者 `hashtable` 。
当集合对象可以同时满足以下两个条件时, 对象使用 `intset` 编码:
1. 集合对象保存的所有元素都是整数值;
2. 集合对象保存的元素数量不超过 `512` 个;
不能满足这两个条件的集合对象需要使用 `hashtable` 编码。
**6)有序集合对象**
有序集合的编码可以是 `ziplist` 或者 `skiplist` 。
当有序集合对象可以同时满足以下两个条件时, 对象使用 `ziplist` 编码:
1. 有序集合保存的元素数量小于 `128` 个;
2. 有序集合保存的所有元素成员的长度都小于 `64` 字节;
不能满足以上两个条件的有序集合对象将使用 `skiplist` 编码。
**这里多说两句,各个语言的对象其实都差不多,底层实现也就那几个,比如java中的容器,c++的STL。java的hashset就是一个哈希而已,hashmap就是k带了一个v,而”有序的“Treemap使用了红黑树这种有平衡性的搜索二叉树。**
**redis的有序集合并没有再采取hash+红黑树的操作,而是把平衡树换成了跳表,实际上性能真的没差多少,甚至有时比红黑树有优势,比如跳表的性能较为平均,红黑树攒了很多次不平衡要调整可能会带来资源需求的一个高峰,再加上跳表实现简单的优点,红黑树真的没什么优势。**
并且就算是真的想用一种带平衡性的搜索树,现在竞赛也是用的华人之光发明的SB树。
有序集合的优点就是它的有序操作,比如拿最大最小值,红黑树时间o(logN),而哈希表只能一个一个遍历。缺点在于插入一个值的时间也是o(logN),跳表也是。而哈希表插入数是o(1).
要了解底层和这些优缺点
> 《三天给你聊清楚redis》第2天看看redis怎么被搞出来的(34962字)
>
三、单机实现
======
3.1、数据库概述
---------
redis服务器将所有数据库都保存在redis/redisServer中,数组db存放所有数据库,每一项是一个redisdb结构。dbnum代表数据库数量。
客户端有一个指针指向当前数据库,可以切换,也就是移动指针。
### 3.1.1键空间
现在稍微介绍一下redisdb结构,它的字典保存了所有键值对
键空间的键也就是数据库的键, 每个键都是一个字符串对象。
键空间的值也就是数据库的值, 每个值可以是字符串对象、列表对象、哈希表对象、集合对象、有序集合对象
所有数据库的操作,添加一个键值对, 删除一个键值对, 获取某个键值对, 等等,都是通过对键空间字典进行操作来实现的。
### 3.1.2维护
读写键空间的时候,服务器会执行一些额外操作,比如:
* 读一个键后(读操作写操作都要对键读取), 会根据键是否存在, 更新键空间命中(hit)次数或不命中(miss)次数。
* 读取一个键后, 服务器会更新键的 LRU (最后一次使用)时间, 这个值可以用于计算键的闲置时间。
* 如果服务器在读一个键时, 该键已经过期, 服务器会删除这个键, 然后执行其他操作。
* 如果客户使用 WATCH 监视某个键,在对这个键进行修改之后, 会将这个键记为脏(dirty),让事务程序知到这个键被修改
* 服务器每次修改一个键之后, 都会对脏(dirty)键计数器的值增一, 这个计数器会触发服务器的持久化以及复制操作执行
* 如果服务器开启了数据库通知功能, 那么在对键进行修改之后, 服务器将按配置发送相应的数据库通知。
### 3.1.3时间
用户可以给某个键设置生存时间,过期时间是一个UNIX时间戳,到时间自动删除这个键。
redisdb结构的expires字典保存了所有的键的过期时间,我们称这个字典为过期字典。
### 3.1.4三种过期键删除策略
1)定时删除:创建一个定时器,到时间立即执行删除操作(对内存友好,因为能保证过期了立马删除,但是对cpu不友好)
2)惰性删除:键过期不管,每次获取键时检查是否过期,过期就删除(对cpu友好,但是只有在使用的时候才可能删除,对内存不友好)
3)定期删除:隔一段时间检查一次(具体算法决定检查多少删多少,需要合理设置)
### 3.1.5淘汰策略
当Redis占用内存超出最大限制 (maxmemory) 时,可采用如下策略 (maxmemory-policy) ,让Redis淘汰一些数据,以腾出空间继续提供读写服务 :
noeviction: 对可能导致增大内存的命令返回错误 (大多数写命令,DEL除外) ;
volatile-ttl: 在设置了过期时间的key中,选择剩余寿命 (TTL) 最短的key,将其淘汰;
volatile-lru: 在设置了过期时间的key中,选择最少使用的key (RU) ,将其淘汰;
volatile-random: 在设置了过期时间的key中,随机选择一些key,将其淘汰;
allkeys-1Lru: 在所有的key中,选择最少使用的key (LRU) ,将其淘汰;
allkeys-random: 在所有的key中,随机选择一些key,将其淘汰;
### 3.2、持久化
因为redis是内存数据库,他把数据都存在内存里,所以要想办法实现持久化功能。
### 3.2.1、RDB
RDB持久化可以手动执行,也可以配置定期执行,可以把某个时间的数据状态保存到RDB文件中,反之,我们可以用RDB文件还原数据库状态。
**生成**
有两个命令可以生成RDB文件:
* SAVE 命令由服务器进程直接执行保存操作,所以该命令会阻塞服务器,服务器不能接受其他指令。
* BGSAVE 命令由子进程执行保存操作,所以该命令不会阻塞服务器,服务器可以接受其他指令。。
禁止BGSAVE和SAVE同时执行,也就是说执行其中一个就会拒绝另一个,这是为了避免父进程和子进程同时执行两个rdbsave,防止产生竞争条件。
**载入**
RDB载入工作是服务器启动时自动执行的。
**自动保存**
用户可以通过save选项设置多个保存条件,服务器状态中会保存所有用 save 选项设置的保存条件,当任意一个保存条件被满足时,服务器会自动执行 BGSAVE 命令。
比如
save 900 1
save 300 10
满足:服务器在900秒之内被修改至少一次或者300秒内修改至少十次。就会执行BGSAVE。
当服务器启动时,用户可以通过指定配置文件或者传入启动参数来设置save选项,服务器会把条件放到一个结构体里,结构体有一个数组,保存了所有条件。
serverCron函数默认100毫秒检查一次,他会遍历数组依次检查,符合条件就会执行BGSAVE。
**RDB文件结构**
一个完整 RDB 文件所包含的各个部分:

REDIS,长度5字节, 保存着 "REDIS" 五个字符。 通过这五个字符, 可以在载入文件时, 快速检查载入文件是否 RDB 文件。
db\_version ,长度 4 字节, 它的值是一个字符串表示的整数, 这个整数记录了 RDB 文件的版本号
databases 部分包含着零个或任意多个数据库, 以及各个数据库中的键值对数据
EOF 常量的长度为 1 字节, 这个常量标志着 RDB 文件正文内容的结束
check\_sum 是一个 8 字节长的无符号整数, 保存着一个校验和,以此来检查 RDB 文件是否出错或损坏
我并不想深入探究databases的组成。就是知道
* RDB 文件是一个经过压缩的二进制文件,由多个部分组成。
* 对于不同类型的键值对, RDB 文件会使用不同的方式来保存它们即可。
3.2.2、AOF
---------
AOF持久化是通过保存服务器执行的命令来记录状态的。还原的时候再执行一遍即可。
功能的实现可以分为命令追加、文件写入、文件同步三个步骤。
当 AOF 持久化功能处于打开状态时, 服务器在执行完一个写命令之后, 会以协议格式将被执行的写命令追加到服务器状态的 aof\_buf 缓冲区的末尾:
struct redisServer {
// ...
// AOF 缓冲区
sds aof_buf;
// ...
};
Redis 服务器进程就是一个事件循环
循环中的文件事件负责接收客户端的命令请求, 以及向客户端发送命令回复,
而时间事件则负责执行像 serverCron 函数这样需要定时运行的函数。
因为服务器在处理文件事件时可能会执行写命令, 使得一些内容被追加到 aof\_buf 缓冲区里面, 所以在服务器每次结束一个事件循环之前, 它都会调用 flushAppendOnlyFile 函数, 考虑是否需要将 aof\_buf 缓冲区中的内容写入和保存到 AOF 文件里面, 这个过程可以用伪代码表示:
def eventLoop():
while True:
# 处理文件事件,接收命令请求以及发送命令回复
# 处理命令请求时可能会有新内容被追加到 aof_buf 缓冲区中
processFileEvents()
# 处理时间事件
processTimeEvents()
# 考虑是否要将 aof_buf 中的内容写入和保存到 AOF 文件里面
flushAppendOnlyFile()
`flushAppendOnlyFile` 函数的行为由服务器配置的 `appendfsync` 选项的值来决定
值为 always 时, 服务器在每个事件循环都要将 aof\_buf 缓冲区中的所有内容写入到 AOF 文件并且同步 AOF 文件, 所以 always 的效率最慢的一个, 但从安全性来说, always 是最安全的, 因为即使出现故障停机, AOF 持久化也只会丢失一个事件循环中所产生的命令数据。
值为 everysec 时, 服务器在每个事件循环都要将 aof\_buf 缓冲区中的所有内容写入到 AOF 文件, 每隔超过一秒就要在子线程中对 AOF 文件进行一次同步: 从效率上来讲, everysec 模式足够快, 并且就算出现故障停机, 数据库也只丢失一秒钟的命令数据。
值为 no 时, 服务器在每个事件循环都要将 aof\_buf 缓冲区中的所有内容写入到 AOF 文件, 至于何时对 AOF 文件进行同步, 则由操作系统控制。
因为处于 no 模式下的 flushAppendOnlyFile 调用无须执行同步操作, 所以该模式下的 AOF 文件写入速度总是最快的, 不过因为这种模式会在系统缓存中积累一段时间的写入数据, 所以该模式的单次同步时长通常是三种模式中时间最长的: 从平摊操作的角度来看,no 模式和 everysec 模式的效率类似, 当出现故障停机时, 使用 no 模式的服务器将丢失上次同步 AOF 文件之后的所有写命令数据。
**重写**
AOF持久化是保存了一堆命令来恢复数据库,随着时间流逝,存的会越来越多,如果不加以控制,文件过大可能影响服务器甚至计算机。而且文件过大,恢复时需要时间也太长。
所以redis提供了重写功能,写出的新文件不会包含任何浪费时间的冗余命令。
接下来,我们就介绍重写的原理。
其实重写不会对现有的AOF文件进行读取分析等操作,而是通过当前服务器的状态来实现。
# 假设服务器对键list执行了以下命令s;
127.0.0.1:6379> RPUSH list "A" "B"
(integer) 2
127.0.0.1:6379> RPUSH list "C"
(integer) 3
127.0.0.1:6379> RPUSH list "D" "E"
(integer) 5
127.0.0.1:6379> LPOP list
"A"
127.0.0.1:6379> LPOP list
"B"
127.0.0.1:6379> RPUSH list "F" "G"
(integer) 5
127.0.0.1:6379> LRANGE list 0 -1
1) "C"
2) "D"
3) "E"
4) "F"
5) "G"
127.0.0.1:6379>
当前列表键list在数据库中的值就为\["C", "D", "E", "F", "G"\]。要使用尽量少的命令来记录list键的状态,最简单的方式不是去读取和分析现有AOF文件的内容,,而是直接读取list键在数据库中的当前值,然后用一条RPUSH list "C" "D" "E" "F" "G"代替前面的6条命令。
* 伪代码表示如下
def AOF_REWRITE(tmp_tile_name):
f = create(tmp_tile_name)
# 遍历所有数据库
for db in redisServer.db:
# 如果数据库为空,那么跳过这个数据库
if db.is_empty(): continue
# 写入 SELECT 命令,用于切换数据库
f.write_command("SELECT " + db.number)
# 遍历所有键
for key in db:
# 如果键带有过期时间,并且已经
过期,那么跳过这个键
if key.have_expire_time() and key.is_expired(): continue
if key.type == String:
# 用 SET key value 命令来保存字符串键
value = get_value_from_string(key)
f.write_command("SET " + key + value)
elif key.type == List:
# 用 RPUSH key item1 item2 ... itemN 命令来保存列表键
item1, item2, ..., itemN = get_item_from_list(key)
f.write_command("RPUSH " + key + item1 + item2 + ... + itemN)
elif key.type == Set:
# 用 SADD key member1 member2 ... memberN 命令来保存集合键
member1, member2, ..., memberN = get_member_from_set(key)
f.write_command("SADD " + key + member1 + member2 + ... + memberN)
elif key.type == Hash:
# 用 HMSET key field1 value1 field2 value2 ... fieldN valueN 命令来保存哈希键
field1, value1, field2, value2, ..., fieldN, valueN =\
get_field_and_value_from_hash(key)
f.write_command("HMSET " + key + field1 + value1 + field2 + value2 +\
... + fieldN + valueN)
elif key.type == SortedSet:
# 用 ZADD key score1 member1 score2 member2 ... scoreN memberN
# 命令来保存有序集键
score1, member1, score2, member2, ..., scoreN, memberN = \
get_score_and_member_from_sorted_set(key)
f.write_command("ZADD " + key + score1 + member1 + score2 + member2 +\
... + scoreN + memberN)
else:
raise_type_error()
# 如果键带有过期时间,那么用 EXPIREAT key time 命令来保存键的过期时间
if key.have_expire_time():
f.write_command("EXPIREAT " + key + key.expire_time_in_unix_timestamp())
# 关闭文件
f.close()
**AOF后台重写**
aof\_rewrite函数可以创建新的AOF文件,但是这个函数会进行大量的写入操作,所以调用这个函数的线程被长时间的阻塞,因为服务器使用单线程来处理命令请求;所以如果直接是服务器进程调用AOF\_REWRITE函数的话,那么重写AOF期间,服务器将无法处理客户端发送来的命令请求;
Redis不希望AOF重写会造成服务器无法处理请求,所以将AOF重写程序放到子进程(后台)里执行。这样处理的好处是:
1)子进程进行AOF重写期间,主进程可以继续处理命令请求;
2)子进程带有主进程的数据副本,使用子进程而不是线程,可以避免在锁的情况下,保证数据的安全性。
还有一个问题,可能重写的时候又有新的命令过来,造成信息不对等,所以redis设置了一个缓冲区,重写期间把命令放到重写缓冲区。
**总结**
AOF重写的目的是为了解决AOF文件体积膨胀的问题,使用更小的体积来保存数据库状态,整个重写过程基本上不影响Redis主进程处理命令请求;
AOF重写其实是一个有歧义的名字,实际上重写工作是针对数据库的当前状态来进行的,重写过程中不会读写、也不适用原来的AOF文件;
AOF可以由用户手动触发,也可以由服务器自动触发。
3.3、事件
------
**redis服务器是一个事件驱动程序。**
需要处理两类事件:
1)文件事件:redis是通过套接字与客户端或者其他服务器连接的,而文件事件就是服务器对套接字操作的抽象。
2)时间事件:服务器对一些定时操作的抽象。
### 3.3.1、文件事件
redis基于reactor模式开发了自己的网络事件处理器,这个处理器被称作文件事件处理器,它使用IO多路复用程序来同时监听多个套接字, 并根据套接字目前执行的任务来为套接字关联不同的事件处理器,当被监听的套接字准备好执行连接应答(accept)、读取(read)、写入(write)、关闭(close)等操作时, 与操作相对应的文件事件就会产生, 这时文件事件处理器就会调用套接字之前关联好的事件处理器来处理这些事件。
**虽然文件事件处理器以单线程方式运行, 但通过使用 I/O 多路复用程序来监听多个套接字, 文件事件处理器既实现了高性能的网络通信模型, 又可以很好地与 Redis 服务器中其他同样以单线程方式运行的模块进行对接, 这保持了 Redis 内部单线程设计的简单性。**
文件事件处理器的构成:
I/O 多路复用程序负责监听多个套接字, 并向文件事件分派器传送那些产生了事件的套接字。
I/O 多路复用程序会把所有产生事件的套接字放到一个队列, 以有序(sequentially)、同步(synchronously)、每次一个套接字的方式,向文件事件分派器传送套接字。

I/O 多路复用程序可以监听多个套接字的 ae.h/AE\_READABLE 事件和 ae.h/AE\_WRITABLE 事件
1)当套接字变得可读时(客户端对套接字执行 write 操作,或者执行 close 操作), 或者有新的可应答(acceptable)套接字出现时(客户端对服务器的监听套接字执行 connect 操作), 套接字产生 AE\_READABLE 事件。
2)当套接字变得可写时(客户端对套接字执行 read 操作), 套接字产生 AE\_WRITABLE 事件。
**如果一个套接字又可读又可写的话, 那么服务器将先读套接字, 后写套接字。**
下面介绍各种处理器:
1)连接应答处理器:服务器进行初始化时, 程序会将连接应答处理器和服务器监听套接字的 AE\_READABLE 事件关联, 当有客户端连接(connect)服务器监听套接字的时候, 套接字就会产生 AE\_READABLE 事件, 引发连接应答处理器执行, 并执行相应的套接字应答操作。
2)命令请求处理器:客户端连接到服务器后, 服务器会将客户端套接字的 AE\_READABLE 事件和命令请求处理器关联起来, 当客户端发送命令请求时, 套接字就会产生 AE\_READABLE 事件, 引发命令请求处理器执行, 并执行相应的套接字读入操作
3)命令回复处理器:服务器有命令回复需要传送给客户端, 服务器会将客户端套接字的 AE\_WRITABLE 事件和命令回复处理器关联起来, 当客户端准备好接收服务器传回的命令回复时, 就会产生 AE\_WRITABLE 事件, 引发命令回复处理器执行, 并执行相应的套接字写入操作。
**一次完整的连接事件实例:**
### 3.3.2、时间事件
redis时间事件可以分为两类:定时事件、周期性事件,他们的特点就像他们的名字一样。
而一个时间事件主要有三部分:
id:服务器为时间事件创建的全局唯一id,按时间递增,越新的越大
when:unix时间戳,记录到达时间
timeProc:时间事件处理器,是一个函数,时间事件到达时,服务器就会调用处理器来处理事件。
**目前版本的redis只使用周期性事件**
来看看实现:
服务器把所有时间事件放在一个链表中,每当时间事件执行器执行时,它就遍历链表,调用相应的事件处理器。
**但是注意:链表是无序的,不按when属性来排序,当时间事件执行器运行时,必须遍历整个链表。但是,无序链表并不影响时间事件处理器的性能,因为在目前版本中,redis服务器只使用serverCron一个时间事件,就算在benchmark模式下也只有两个事件,服务器几乎是把链表退化成指针使用了。**
### 3.3.3、事件的调度和执行
文件事件和时间事件之间是合作关系, 服务器会轮流处理这两种事件,对两种事件的处理都是同步、有序、原子地进行的,处理事件的过程中也不会进行抢占,所以时间事件的实际处理时间通常会比设定的到达时间晚一些。
大概流程为:
是否关闭服务器?---->等待文件事件产生---->处理已经产生的文件事件---->处理已经达到的时间事件---->是否关闭服务器?........
3.4、客户端
-------
redis服务器是典型的一对多服务器,通过使用由IO多路复用技术实现的文件事件处理器,redis服务器使用了单线程单进程的方式来处理请求。
### 3.4.1客户端的属性
* **描述符**
客户端状态的 `fd` 属性记录了客户端正在使用的套接字描述符:
typedef struct redisClient {
// ...
int fd;
// ...
} redisClient;
* 伪客户端fd 值为 -1 : 伪客户端处理的命令请求来源于 AOF 文件或者 Lua 脚本, 而不是网络, 所以这种客户端不需要套接字连接。
* 普通客户端 fd 值为大于 -1 的整数: 普通客户端使用套接字来与服务器进行通讯, 所以服务器会用 fd 属性来记录客户端套接字的描述符。
* **标志**
客户端的标志属性 `flags` 记录了客户端的角色(role), 以及客户端目前所处的状态:
typedef struct redisClient {
// ...
int flags;
// ...
} redisClient;
`flags` 属性的值可以是单个标志:
flags = <flag>
也可以是多个标志的二进制或, 比如:
flags = <flag1> | <flag2> | ...
每个标志使用一个常量表示, 一部分标志记录了客户端的角色:
* 在主从服务器进行复制操作时, 主服务器会成为从服务器的客户端, 而从服务器也会成为主服务器的客户端。 REDIS\_MASTER 标志表示客户端代表的是一个主服务器, REDIS\_SLAVE 标志表示客户端代表的是一个从服务器。
* REDIS\_LUA\_CLIENT 标识表示客户端是专门用于处理 Lua 脚本里面包含的 Redis 命令的伪客户端。
另一部分标志记录了客户端目前所处的状态:
以下内容为摘抄
REDIS_MONITOR 标志表示客户端正在执行 MONITOR 命令。
REDIS_UNIX_SOCKET 标志表示服务器使用 UNIX 套接字来连接客户端。
REDIS_BLOCKED 标志表示客户端正在被 BRPOP 、 BLPOP 等命令阻塞。
REDIS_UNBLOCKED 标志表示客户端已经从 REDIS_BLOCKED 标志所表示的阻塞状态中脱离出来,
不再阻塞。 REDIS_UNBLOCKED 标志只能在 REDIS_BLOCKED 标志已经打开的情况下使用。
REDIS_MULTI 标志表示客户端正在执行事务。
REDIS_DIRTY_CAS 标志表示事务使用 WATCH 命令监视的数据库键已经被修改,
REDIS_DIRTY_EXEC 标志表示事务在命令入队时出现了错误,
以上两个标志都表示事务的安全性已经被破坏, 只要这两个标记中的任意一个被打开,
EXEC 命令必然会执行失败。
这两个标志只能在客户端打开了 REDIS_MULTI 标志的情况下使用。
REDIS_CLOSE_ASAP 标志表示客户端的输出缓冲区大小超出了服务器允许的范围,
服务器会在下一次执行 serverCron 函数时关闭这个客户端,
以免服务器的稳定性受到这个客户端影响。
积存在输出缓冲区中的所有内容会直接被释放, 不会返回给客户端。
REDIS_CLOSE_AFTER_REPLY 标志表示有用户对这个客户端执行了 CLIENT_KILL 命令,
或者客户端发送给服务器的命令请求中包含了错误的协议内容。
服务器会将客户端积存在输出缓冲区中的所有内容发送给客户端, 然后关闭客户端。
REDIS_ASKING 标志表示客户端向集群节点(运行在集群模式下的服务器)发送了 ASKING 命令。
REDIS_FORCE_AOF 标志强制服务器将当前执行的命令写入到 AOF 文件里面,
REDIS_FORCE_REPL 标志强制主服务器将当前执行的命令复制给所有从服务器。
执行 PUBSUB 命令会使客户端打开 REDIS_FORCE_AOF 标志,
执行 SCRIPT_LOAD 命令会使客户端打开
REDIS_FORCE_AOF标志和 REDIS_FORCE_REPL 标志。
在主从服务器进行命令传播期间, 从服务器需要向主服务器发送 REPLICATION ACK 命令,
在发送这个命令之前, 从服务器必须打开主服务器对应的客户端的
REDIS_MASTER_FORCE_REPLY 标志, 否则发送操作会被拒绝执行。
以上提到的所有标志都定义在 redis.h 文件里面。
**PUBSUB** **命令**和 SCRIPT LOAD **命令的特殊性**
**通常情况下, Redis 只会将那些对数据库进行了修改的命令写入到 AOF 文件, 并复制到各个从服务器**: 如果一个命令没有对数据库进行任何修改, 那么它就会被认为是只读命令, 这个命令不会被写入到 AOF 文件, 也不会被复制到从服务器。
以上规则适用于绝大部分 Redis 命令, 但 PUBSUB 命令和 SCRIPT\_LOAD 命令是其中的例外。
PUBSUB 命令虽然没有修改数据库, 但 PUBSUB 命令向频道的所有订阅者发送消息这一行为带有副作用, 接收到消息的所有客户端的状态都会因为这个命令而改变。 因此, 服务器需要使用 REDIS\_FORCE\_AOF 标志, 强制将这个命令写入 AOF 文件, 这样在将来载入 AOF 文件时, 服务器就可以再次执行相同的 PUBSUB 命令, 并产生相同的副作用。
SCRIPT\_LOAD 命令的与 PUBSUB 命令类似
### 3.4.2输入缓冲区
客户端状态的输入缓冲区用于保存客户端发送的命令请求:
typedef struct redisClient {
// ...
sds querybuf;
// ...
} redisClient;
redisClient 实例:

### 3.4.3命令相关
在服务器将客户端发送的命令请求保存到客户端状态的 `querybuf` 属性之后, 服务器将对命令请求的内容进行分析, 并将得出的命令参数以及命令参数的个数分别保存到客户端状态的 `argv` 属性和 `argc` 属性:
typedef struct redisClient {
// ...
robj **argv;
int argc;
// ...
} redisClient;
`argv` 属性是一个数组, 数组中的每个项都是一个字符串对象: 其中 `argv[0]` 是要执行的命令, 而之后的其他项则是传给命令的参数。
`argc` 属性则负责记录 `argv` 数组的长度。
### 3.3.4实现函数
当服务器从协议内容中分析并得出 argv 属性和 argc 属性的值之后, 服务器将根据项 argv\[0\] 的值, 在命令表中查找命令所对应的命令实现函数。
(命令表是一个字典,字典的键是一个 SDS 结构, 保存了命令的名字, 字典的值是命令所对应的 redisCommand 结构, 这个结构保存了命令的实现函数、 命令的标志、 命令应该给定的参数个数、 命令的总执行次数和总消耗时长等统计信息。)
### 3.3.5、输出缓冲区
执行命令所得的命令回复会被保存在客户端状态的输出缓冲区里面, 每个客户端都有两个输出缓冲区:
* 固定大小的缓冲区用于保存那些长度比较小的回复, 比如 `OK` 、简短的字符串值、整数值、错误回复,等等。
* 可变大小的缓冲区用于保存那些长度比较大的回复, 比如一个非常长的字符串值, 一个由很多项组成的列表, 一个包含了很多元素的集合, 等等。
### 3.3.6、其它
客户端状态的 `authenticated` 属性用于记录客户端是否通过了身份验证,还有几个和时间有关的属性,叙述是一件挺无聊的事情,不再写。
3.4、命令的执行过程
-----------
### []( )
3.4.1发送命令请求
当用户在客户端中键入一个命令请求时, 客户端会将这个命令请求转换成协议格式, 然后通过连接到服务器的套接字, 将协议格式的命令请求发送给服务器。
### 3.4.2读取命令请求
当客户端与服务器之间的连接套接字因为客户端的写入而变得可读时, 服务器将调用命令请求处理器来执行以下操作:
读取套接字中协议格式的命令请求, 并将其保存到客户端状态的输入缓冲区里面。
对输入缓冲区中的命令请求进行分析, 提取出命令请求中包含的命令参数, 以及命令参数的个数, 然后分别将参数和参数个数保存到客户端状态的 argv 属性和 argc 属性里面。
调用命令执行器, 执行客户端指定的命令。
### 3.4.3命令执行器:查找命令实现
命令执行器要做的第一件事就是根据客户端状态的 argv\[0\] 参数, 在命令表(command table)中查找参数所指定的命令, 并将找到的命令保存到客户端状态的 cmd 属性里面。
命令表是一个字典, 字典的键是一个个命令名字,比如 "set" 、 "get" 、 "del" ,等等; 而字典的值是一个个 redisCommand 结构, 每个 redisCommand 结构记录了一个 Redis 命令的实现信息。

命令名字的大小写不影响命令表的查找结果
因为命令表使用的是大小写无关的查找算法, 无论输入的命令名字是大写、小写或者混合大小写, 只要命令的名字是正确的, 就能找到相应的 redisCommand 结构。
比如说, 无论用户输入的命令名字是 "SET" 、 "set" 、 "SeT" 又或者 "sEt" , 命令表返回的都是同一个 redisCommand 结构。
redis> SET msg "hello world"
OK
redis> set msg "hello world"
OK
redis> SeT msg "hello world"
OK
redis> sEt msg "hello world"
OK
### 3.4.4命令执行器:执行预备操作
* 到目前为止, 服务器已经将执行命令所需的命令实现函数(保存在客户端状态的 cmd 属性)、参数(保存在客户端状态的 argv 属性)、参数个数(保存在客户端状态的 argc 属性)都收集齐了, 但是在真正执行命令之前, 程序还需要进行一些预备操作, 从而确保命令可以正确、顺利地被执行, 这些操作包括:
* 检查客户端状态的 cmd 指针是否指向 NULL , 如果是的话, 那么说明用户输入的命令名字找不到相应的命令实现, 服务器不再执行后续步骤, 并向客户端返回一个错误。
* 根据客户端 cmd 属性指向的 redisCommand 结构的 arity 属性, 检查命令请求所给定的参数个数是否正确, 当参数个数不正确时, 不再执行后续步骤, 直接向客户端返回一个错误。 比如说, 如果 redisCommand 结构的 arity 属性的值为 -3 , 那么用户输入的命令参数个数必须大于等于 3 个才行。
* 检查客户端是否已经通过了身份验证, 未通过身份验证的客户端只能执行 AUTH 命令, 如果未通过身份验证的客户端试图执行除 AUTH 命令之外的其他命令, 那么服务器将向客户端返回一个错误。
* 如果服务器打开了 maxmemory 功能, 那么在执行命令之前, 先检查服务器的内存占用情况, 并在有需要时进行内存回收, 从而使得接下来的命令可以顺利执行。 如果内存回收失败, 那么不再执行后续步骤, 向客户端返回一个错误。
* 如果服务器上一次执行 BGSAVE 命令时出错, 并且服务器打开了 stop-writes-on-bgsave-error 功能, 而且服务器即将要执行的命令是一个写命令, 那么服务器将拒绝执行这个命令, 并向客户端返回一个错误。
* 如果客户端当前正在用 SUBSCRIBE 命令订阅频道, 或者正在用 PSUBSCRIBE 命令订阅模式, 那么服务器只会执行客户端发来的 SUBSCRIBE 、 PSUBSCRIBE 、 UNSUBSCRIBE 、 PUNSUBSCRIBE 四个命令, 其他别的命令都会被服务器拒绝。
* 如果服务器正在进行数据载入, 那么客户端发送的命令必须带有 l 标识(比如 INFO 、 SHUTDOWN 、 PUBLISH ,等等)才会被服务器执行, 其他别的命令都会被服务器拒绝。
* 如果服务器因为执行 Lua 脚本而超时并进入阻塞状态, 那么服务器只会执行客户端发来的 SHUTDOWN nosave 命令和 SCRIPT KILL 命令, 其他别的命令都会被服务器拒绝。
* 如果客户端正在执行事务, 那么服务器只会执行客户端发来的 EXEC 、 DISCARD 、 MULTI 、 WATCH 四个命令, 其他命令都会被放进事务队列中。
* 如果服务器打开了监视器功能, 那么服务器会将要执行的命令和参数等信息发送给监视器。
当完成了以上预备操作之后, 服务器就可以开始真正执行命令了。
### 3.4.5命令执行器:调用命令的实现函数
在前面的操作中, 服务器已经将要执行命令的实现保存到了客户端状态的 `cmd` 属性里面, 并将命令的参数和参数个数分别保存到了客户端状态的 `argv` 属性和 `argc` 属性里面, 当服务器决定要执行命令时, 它只要执行以下语句就可以了:
// client 是指向客户端状态的指针
client->cmd->proc(client);
因为执行命令所需的实际参数都已经保存到客户端状态的 `argv` 属性里面了, 所以命令的实现函数只需要一个指向客户端状态的指针作为参数即可。
### 3.4.6命令执行器:执行后续工作
在执行完实现函数之后, 服务器还需要执行一些后续工作:
* 如果服务器开启了慢查询日志功能, 那么慢查询日志模块会检查是否需要为刚刚执行完的命令请求添加一条新的慢查询日志。
* 根据刚刚执行命令所耗费的时长, 更新被执行命令的 redisCommand 结构的 milliseconds 属性, 并将命令的 redisCommand 结构的 calls 计数器的值增一。
* 如果服务器开启了 AOF 持久化功能, 那么 AOF 持久化模块会将刚刚执行的命令请求写入到 AOF 缓冲区里面。
* 如果有其他从服务器正在复制当前这个服务器, 那么服务器会将刚刚执行的命令传播给所有从服务器。
当以上操作都执行完了之后, 服务器对于当前命令的执行到此就告一段落了, 之后服务器就可以继续从文件事件处理器中取出并处理下一个命令请求了。
### 3.4.7将命令回复发送给客户端
前面说过, 命令实现函数会将命令回复保存到客户端的输出缓冲区里面, 并为客户端的套接字关联命令回复处理器, 当客户端套接字变为可写状态时, 服务器就会执行命令回复处理器, 将保存在客户端输出缓冲区中的命令回复发送给客户端。
当命令回复发送完毕之后, 回复处理器会清空客户端状态的输出缓冲区, 为处理下一个命令请求做好准备。
### 3.4.8客户端接收并打印命令回复
当客户端接收到协议格式的命令回复之后, 它会将这些回复转换成人类可读的格式, 并打印给用户观看(假设使用的是 Redis 自带的 客户端)
3.5、事务
------