数据类型
String
比如说 ,我想知道什么时候封锁一个IP地址。Incrby命令
计数器,可以对String进行自增自减运算,从而实现计数器功能。
Redis这种内存数据库模型的读写性能非常高,很适合存储频繁的读写计数量
Hash
- 存储用户信息(id,name,age)
- Hset(key,field,value)
- Hset(userKey,id,101)
- Hset(userKey,name,admin)
- Hset(userKey,age,23)
- Hget(userKey,id)
- Hset(userKey,id,102)
为什么不使用String 类型来存储?
- Set(userKey,用信息的字符串)
- Get(userKey)
- 不建议使用String 类型
List
- 实现最新消息的排行,还可以利用List的push命令,将任务存在list集合中,同时使用另一个命令,将任务从集合中取出[pop]。
- List是一个双向链表的结构,可以模拟消息队列。不过最好使用RabbitMQ【电商中的秒杀就可以采用这种方式来完成一个秒杀活动】
Set
- 特殊之处:可以自动排重。比如说微博中将每个人的好友存在集合(Set)中,这样求两个人的共通好友的操作。我们只需要求交集即可。
ZSet
- 以某一个条件为权重,进行排序。
- 京东:商品详情的时候,都会有一个综合排名,还可以按照价格进行排名。
数据结构
字典
dictht 是一个散列表的结构,使用拉链法保存哈希冲突。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16typedef 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;Redis的字典dict中包含两个dictht,这是为了方便进行rehash操作。在扩容的时候,讲一个dictht上的键值对rehash到另外一个dictht上面,完成之后释放空间并交换两个dictht的角色。
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上,因此对字典的查找操作也需要到对应的dictht去执行。
跳跃表
是有序集合的底层实现之一。跳跃表是基于多指针有序链表实现的,可以看成多个有序链表。在查找时,从上层指针开始查找,找到对应的区间之后再到下一层去查找。与红黑树相比,跳跃表有以下的有点:
- 插入速度非常的快速,因为不需要进行旋转操作维护平衡性
- 更容易实现
- 支持无锁操作
数据持久化
RDB
在指定的时间间隔内的内存中的所有数据以快照的方式写入磁盘,恢复时就将快照文件直接读到内存中
会创建一个子进程来进行持久化,先将数据写入一个临时文件中,等持久化都结束之再用这个临时文件替换上次的持久化文件,在整个过程中主进程是不进行IO操做的
优点:
- 节省磁盘空间恢复速度快
缺点:
- 比较浪费性能,最后一次持久化之后的数据可能丢失,如果数据量很大,保存快照的时间会很长。
- redis 中的更新次数只是一个阈值,不是绝对的更新次数,由 serverCorn 来检查是在特定的时间内满足大于等于设定的数值。
AOF
将写命令添加到AOF文件的末尾
优点:
- 备份机制稳定,丢失数据概率更低,可读的日志文本
缺点:
- 文件体积大,占用更多的磁盘空间,每次读写都是同步的,有一定的性能压力。
- 恢复速度比较慢
使用AOF持久化开启的时候,文件的载入优先于RDB文件
使用AOF持久化需要设置同步选项,从而确保写命令什么时候会同步到磁盘文件上。这是因为对文件进行写入并不会马上将内容同步到磁盘上,而是先存储到缓冲区,然后由操作系统决定什么时候同步到磁盘。
always每个写命令都会同步,会严重降低服务起的性能。
everysec每秒同步一次,选项比较合适,可以保证系统崩溃的时候只会丢失一秒左右的数据,并且Redis每秒执行一次同步对服务器性能几乎没有任何影响。
no 让操作系统来决定什么时候同步,不能给服务器带来很大的提升,而且也会增大系统崩溃时候数据丢失。
随着服务器的写命令的增对,AOF文件会越来越大,Redis提供了一种AOF重写的特性,能够去除AOF文件中冗余的写命令。
AOF持久化功能的实现分为命令的追加,文件写入,文件同步,执行的写命令首先被追加在服务器的 aof_buf 缓冲池区的末尾。
Redis 的服务进程就是一个事件循环,这个循环中文件事件负责接受客户端发来命令,并返回响应的命令恢复。时间事件是定时运行一些函数。因为服务器在处理文本事件的时候,可能会执行写命令,并使得一些内容被添加到缓冲区中,所以在服务器每次结束一个循环之前,都会考虑调用 flushAppendOnlyFile 函数是不是要将 aof_buf 中的内容写入和保存到 AOF 文件中。flushAppendOnlyFile 函数的行文会根据服务器配置的 appednfsync 选项的值来决定。
服务器首先会将 aof_buf 中的内容写入到 OS Cache 中,然后再进行同步,从OS Cache 中同步到本地磁盘文件中。
AOF 文件载入的时候,Redis 服务器首先创建一个 fake client 客户端,来从 AOF 文件中循环取出写命令进行执行。
AOF 重写为了解决文件体积膨胀的问题,创建一个新的 AOF 文件来替代现有的 AOF 文件,新旧两个 AOF 文件所保存的数据库状态相同,但是新的 AOF 文件不会包含任何浪费冗余的命令。AOF 文件重写并不需要对现有的 AOF
文件进行读取,分析,写入的操作,这个功能是通过对读取服务器当前数据库状态来实现的。首先从数据库中读取现有键值对现在的值,然后用一条写命令去记录键值对,代替之前记录这个键值对的多条命令,这就是 AOF 重写功能的实现原理。
Redis 是单线程来处理客户端的请求,如果重写函数进行大量写入操作的时候会长时间阻塞,这时候服务器无法处理客户端的发来的命令请求,所以决定将重写函数放在一个子进程中去处理,一方面子进程在重写的时候,父进程可以继续处理命令。
子进程带有服务器进程的数据副本,使用子进程而不是线程,可以避免使用锁的情况下保证数据的安全。
但是子进程进行处理的时候,父进程还在执行命令,可能对现有的数据库状态进行修改。从而使得数据库状态和重写后的状态不一致。所以在 Redis 中设置了一个 AOF 重写缓冲池,这个缓冲池在服务器创建子进程的时候使用,当Redis 服务器执行完一个写命令之后,会同时将这个写命令发送给 AOF 缓冲区
事务
一个事务包含了多个写命令,服务器在执行事务期间,不会改去执行其他客户端的请求。
事务中的多个写命令被一次性的发送给服务器,而不是一条一条的发送,这种方式被称为流水线,它可以减少客户端与服务端之间的网络通信次数从而提升性能。
Redis最简单的事务实现方式是使用 MULT I和 EXEC 命令将事务包围起来。
事件
Redis服务器是一个事件驱动程序。 文本事件和时间时间
文件事件和时间时间是合作关系,服务器会轮流处理这两种事件。
文件事件
服务器通过套接字与客户端或者其他服务器进行通信,文件事件就是对套接字操作的抽象。
Redis基于Reactor 模式开发了自己的网路事件处理器,使用I/O多路复用程序来同时监听多个套接字,并将到达的事件传送给文件事件分派器,文件分派器会根据套接字产生的事件类型调用相应的事件处理器。
尽管多个文件事件可以并发的出现,但是在底层这些事件被顺序的放入一个队列中,顺序、同步的执行。
I/O 多路复用器可以监听多个套接字的可读和可写事件。如果一个套接字又可读又可写的话,那么服务器将先读套接字,后写套接字。
时间事件
服务器有一些操作需要在给定的时间点执行,时间事件是对类定时的操作抽象。
时间事件又分为:
- 定时事件:是让一段程序在指定的时间之内执行一次
- 周期性事件:是让一段程序每隔指定时间就执行一次
Redis将所有时间事件都放在一个无序链表中,通过遍历整个链表查找出已到达的时间事件,并调用相应事件处理器。
服务器一般情况下只执行 serverCron 函数一个时间事件。并且这个事件是周期性事件。
复制
通过使用slaveof host port 命令来让一个服务器成为另一服务器的从服务器。
一个从服务器只能有一个主服务器,并且不支持主从复制。
连接过程
- 主服务器创建快照文件,发送给从服务器,并在发送期间使用缓冲去记录执行的写命令,快照文件发送完毕之后,开始向从服务器发送存储缓冲区中的写命令。
- 从服务器丢弃旧的数据,载入主服务器发来的快照文件,之后从服务器开始接受主服务器发来的写命令。
- 主服务器每次执行一次写命令,就向从服务器发送相同的写命令。
主从链
随着负载的不断上升,主服务器可能无法很快的更新从服务器,或者重新连接和重新同步从服务器将导致系统超载。为了解决这个问题我们可以建立一个中间层来分担主服务器的复制工作。中间服务层的服务器是最上层的从服务器,又是最下层服务器的主服务器。
Sentinel
哨兵监听集群中的服务器,并在主服务器进入下线状态时,自动从从服务器中选择新的主服务器。
w
分片
假设有 4 个 Redis 实例 R0,R1,R2,R3,还有很多表示用户的键 user:1,user:2,… ,有不同的方式来选择一个指定的键存储在哪个实例中。
- 最简单的方式是范围分片,例如用户 id 从 0~1000 的存储到实例 R0 中,用户 id 从 1001~2000 的存储到实例 R1 中,等等。但是这样需要维护一张映射范围表,维护操作代价很高。
- 还有一种方式是哈希分片,使用 CRC32 哈希函数将键转换为一个数字,再对实例数量求模就能知道应该存储的实例。
根据执行分片的位置,可以分为三种分片方式:
- 客户端分片:客户端使用一致性哈希等算法决定键应当分布到哪个节点。
- 代理分片:将客户端请求发送到代理上,由代理转发请求到正确的节点上。
- 服务器分片:Redis Cluster。
键的过期时间
Redis可以为每个键设置过期时间,每当键过期时,会自动删除该键。
对于散列表这种容器,只能为整个键设置过期时间,而不能为键里面的单个元素设置过期时间。
数据淘汰策略
可以设置内存最大使用量,当内存使用量超出时,会施行数据淘汰策略。
Redis 具体有 6 种淘汰策略:
策略 | 描述 |
---|---|
volatile-lru | 从已设置过期时间的数据集中挑选最近最少使用的数据淘汰 |
volatile-ttl | 从已设置过期时间的数据集中挑选将要过期的数据淘汰 |
volatile-random | 从已设置过期时间的数据集中任意选择数据淘汰 |
allkeys-lru | 从所有数据集中挑选最近最少使用的数据淘汰 |
allkeys-random | 从所有数据集中任意选择数据进行淘汰 |
noeviction | 禁止驱逐数据 |
作为内存数据,处于对性能和内存消耗的考虑,Redis的淘汰算法实际实现上并非针对所有的key,而是抽样一小部分并且从中选出被淘汰的key
使用redis缓存数据时候,为了提高缓存命中率,需要保证缓存数据都是热点数据。可以将内存最大使用量设置为热点数据占用的内存量,然后启用allkeys-lru淘汰策略,将最近少的数据淘汰。
常见问题
缓存穿透
缓存穿透是指查询一个数据库一定不存的数据。正常的使用缓存流程大致是,数据查询先查询缓存,如果key不存或者key已经过期,在对数据库进行查询,并把查询到的对象放入缓存中。如果数据库查询对象为空,则不放进缓存。
如果出现这个情况,如果传入的参数为-1,会是怎么样呢?这个-1,就是一定不存的对象,就会每次都去查询数据库,而每次查询都是空,每次由都不会进行缓存。如果有恶意攻击,对数据库造成压力,甚至会压垮数据库。即便是采用UUID,也很容易找到一个不存的Key,进行攻击。
解决方式:
- 采用缓存空值的方式,如果从数据库查询到对象为空,也放入缓存,只是设定的缓存过期时间较短,比如设置为60秒。
布隆过滤器
1
2
3
4
5
6
7
8
9
10
11
12
13
14public String getByKey(String key) {
// 通过key获取value
String value = redisService.get(key);
if (StringUtil.isEmpty(value)) {
if (bloomFilter.mightContain(key)) {
value = userService.getById(key);
redisService.set(key, value);
return value;
} else {
return null;
}
}
return value;
}
对所有可能查询的参数以hash形式存储,在控制层先进行校验,不符合则丢弃。还有最常见的则是采用布隆过滤器,将所有可能存在的数据哈希到一个足够大的bitmap中,一个一定不存在的数据会被这个bitmap拦截掉,从而避免了对底层存储系统的查询压力。
缓存雪崩
缓存雪崩,是指在某一段时间,缓存集中过期失效。
产生雪崩的原因之一,就是一段时间集中放入缓存,如果缓存集中过期,在缓存上都无法命中数据,所有的查询都落在数据库上,对数据库而言,就会产生周期性的压力波峰。
解决方案:
- 对不同类别,缓存不同的周期,在同一类别上的商品上加上一个随机因子,这样就可以分散的缓存过期时间,而且,热门类目的商品缓存时间长一点,冷门类目的商品缓存时间短一点,也能节省缓存服务的资源。
- 做二级缓存,或者双缓存策略。A1为原始缓存,A2为拷贝缓存,A1失效时,可以访问A2,A1缓存失效时间设置为短期,A2设置为长期。
- 在缓存失效后,通过加锁或者队列来控制读数据库写缓存的线程数量。比如对某个key只允许一个线程查询数据和写缓存,其他线程等待
其实集中过期,倒不是非常致命,比较致命的缓存雪崩,是缓存服务器某个节点宕机或断网。因为自然形成的缓存雪崩,一定是在某个时间段集中创建缓存,那么那个时候数据库能顶住压力,这个时候,数据库也是可以顶住压力的。无非就是对数据库产生周期性的压力而已。而缓存服务节点的宕机,对数据库服务器造成的压力是不可预知的,很有可能瞬间就把数据库压垮。
缓存击穿
缓存击穿,是指一个key非常热点,在不停的扛着大并发,大并发集中对这一个点进行访问,当这个key在失效的瞬间,持续的大并发就穿破缓存,直接请求数据库,就像在屏障上开了一个洞。
对于这样的热点数据,我们可以把他们呢设置为永不过期就好。
为何用Redis而不用map做缓存?
缓存分为本地缓存和分布式缓存。以java为例,使用自带的map或者guava实现的本地缓存,最主要的特点是轻量快速,生命周期随着JVM的销毁而结束,并且在多个实例的情况下,每个实例都需要保存一份缓存,缓存不具有一致性。
使用redis称为分布式缓存,在多个实例的情况下,各个实例公用一份缓存数据,缓存具有一致性。缺点是需要保持redis服务的高可用,整个程序架构上复杂。
redis可以独立部署,这样网站代码更新后redis缓存的数据还在,本地内存每次网站更新都会释放掉数据存到redis中,多个项目间可以共享缓存数据,如果是本地内存是无法跨项目共享的。
本地缓存不容易查看,以及修改redis有丰富的工具来进行管理。
redis可以用更大的内存空间做缓存,map不行,一般JVM也就分几个G的数据就够大了。
redis的缓存可以持久化,map是内存对象,程序一重新启动就没有了
redis可以处理每秒百万级的并发,是专业的缓存服务,map只是一个普通的对象
redis缓存有过期的机制,map本身没有此功能。
redis有丰富的API,map简单
redis可以实现分布式部署,只要涉及到多台进程啥的,map就实现不了
redis有很多数据结构,方便操作。
如果你缓存的东西太多,容易让JVM挂掉。
reids是C语言写的,稳定性和性能更好。
Client
服务器状态接口是使用clients 链表来凝结起来多个客户端状态,新添加的客户端状态会被放到链表的尾部。
客户端状态的 flags 属性使用不同的标志来表示客户端的角色,以及客户端的状态。
输入缓冲区记录了客户端发送的命令请求,这个缓冲区的大小不能超过 1GB
客户端有两个输入缓冲区,用来保存服务端对命令的响应。一个是可变的,另外一个是不可变的。
分布式锁
set key value [ex seconds] [px milliseconds] [nx | xx]
- ex second:设置键的过期时间
- px milliseconds:设置键的过期时间为 milliseconds 毫秒
- nx:只有键不存在时候,才对键进行设置操作
- xx:只在键已经存在时候,才对键进行设置操作
- set 操作成功完成时,返回 OK ,否则返回 nil
SDS 和 C 字符串的区别
C 字符串 | SDS |
---|---|
获取字符串长度的复杂度为 O(N) 。 | 获取字符串长度的复杂度为 O(1) 。 |
API 是不安全的,可能会造成缓冲区溢出。 | API 是安全的,不会造成缓冲区溢出。 |
修改字符串长度 N 次必然需要执行 N 次内存重分配。 |
修改字符串长度 N 次最多需要执行 N 次内存重分配。 |
只能保存文本数据。 | 可以保存文本或者二进制数据。 |
可以使用所有 <string.h> 库中的函数。 |
可以使用一部分 <string.h> 库中的函数。 |
根据传统, C 语言使用长度为 N+1
的字符数组来表示长度为 N
的字符串, 并且字符数组的最后一个元素总是空字符 '\0'
。
比如说, 图 2-3 就展示了一个值为 "Redis"
的 C 字符串
C 语言使用的这种简单的字符串表示方式, 并不能满足 Redis 对字符串在安全性、效率、以及功能方面的要求, 本节接下来的内容将详细对比 C 字符串和 SDS 之间的区别, 并说明 SDS 比 C 字符串更适用于 Redis 的原因。
常数复杂度获取字符串长度
因为 C 字符串并不记录自身的长度信息, 所以为了获取一个 C 字符串的长度, 程序必须遍历整个字符串, 对遇到的每个字符进行计数, 直到遇到代表字符串结尾的空字符为止, 这个操作的复杂度为 O(N) 。
举个例子, 图 2-4 展示了程序计算一个 C 字符串长度的过程。
和 C 字符串不同, 因为 SDS 在 len
属性中记录了 SDS 本身的长度, 所以获取一个 SDS 长度的复杂度仅为 O(1) 。
举个例子, 对于图 2-5 所示的 SDS 来说, 程序只要访问 SDS 的 len
属性, 就可以立即知道 SDS 的长度为 5
字节:
又比如说, 对于图 2-6 展示的 SDS 来说, 程序只要访问 SDS 的 len
属性, 就可以立即知道 SDS 的长度为 11
字节。
设置和更新 SDS 长度的工作是由 SDS 的 API 在执行时自动完成的, 使用 SDS 无须进行任何手动修改长度的工作。
通过使用 SDS 而不是 C 字符串, Redis 将获取字符串长度所需的复杂度从 O(N) 降低到了 O(1) , 这确保了获取字符串长度的工作不会成为 Redis 的性能瓶颈。
比如说, 因为字符串键在底层使用 SDS 来实现, 所以即使我们对一个非常长的字符串键反复执行 STRLEN 命令, 也不会对系统性能造成任何影响, 因为 STRLEN 命令的复杂度仅为 O(1) 。
杜绝缓冲区溢出
除了获取字符串长度的复杂度高之外, C 字符串不记录自身长度带来的另一个问题是容易造成缓冲区溢出(buffer overflow)。
举个例子, <string.h>/strcat
函数可以将 src
字符串中的内容拼接到 dest
字符串的末尾:
1 | char *strcat(char *dest, const char *src); |
因为 C 字符串不记录自身的长度, 所以 strcat
假定用户在执行这个函数时, 已经为 dest
分配了足够多的内存, 可以容纳 src
字符串中的所有内容, 而一旦这个假定不成立时, 就会产生缓冲区溢出。
举个例子, 假设程序里有两个在内存中紧邻着的 C 字符串 s1
和 s2
, 其中 s1
保存了字符串 "Redis"
, 而 s2
则保存了字符串 "MongoDB"
, 如图 2-7 所示。
如果一个程序员决定通过执行:
1 | strcat(s1, " Cluster"); |
将 s1
的内容修改为 "Redis Cluster"
, 但粗心的他却忘了在执行 strcat
之前为 s1
分配足够的空间, 那么在 strcat
函数执行之后, s1
的数据将溢出到 s2
所在的空间中, 导致 s2
保存的内容被意外地修改, 如图 2-8 所示。
与 C 字符串不同, SDS 的空间分配策略完全杜绝了发生缓冲区溢出的可能性: 当 SDS API 需要对 SDS 进行修改时, API 会先检查 SDS 的空间是否满足修改所需的要求, 如果不满足的话, API 会自动将 SDS 的空间扩展至执行修改所需的大小, 然后才执行实际的修改操作, 所以使用 SDS 既不需要手动修改 SDS 的空间大小, 也不会出现前面所说的缓冲区溢出问题。
举个例子, SDS 的 API 里面也有一个用于执行拼接操作的 sdscat
函数, 它可以将一个 C 字符串拼接到给定 SDS 所保存的字符串的后面, 但是在执行拼接操作之前, sdscat
会先检查给定 SDS 的空间是否足够, 如果不够的话, sdscat
就会先扩展 SDS 的空间, 然后才执行拼接操作。
比如说, 如果我们执行:
1 | sdscat(s, " Cluster"); |
其中 SDS 值 s
如图 2-9 所示, 那么 sdscat
将在执行拼接操作之前检查 s
的长度是否足够, 在发现 s
目前的空间不足以拼接 " Cluster"
之后, sdscat
就会先扩展 s
的空间, 然后才执行拼接 " Cluster"
的操作, 拼接操作完成之后的 SDS 如图 2-10 所示。
注意图 2-10 所示的 SDS : sdscat
不仅对这个 SDS 进行了拼接操作, 它还为 SDS 分配了 13
字节的未使用空间, 并且拼接之后的字符串也正好是 13
字节长, 这种现象既不是 bug 也不是巧合, 它和 SDS 的空间分配策略有关, 接下来的小节将对这一策略进行说明。
减少修改字符串时带来内存重分配次数
正如前两个小节所说, 因为 C 字符串并不记录自身的长度, 所以对于一个包含了 N
个字符的 C 字符串来说, 这个 C 字符串的底层实现总是一个 N+1
个字符长的数组(额外的一个字符空间用于保存空字符)。
因为 C 字符串的长度和底层数组的长度之间存在着这种关联性, 所以每次增长或者缩短一个 C 字符串, 程序都总要对保存这个 C 字符串的数组进行一次内存重分配操作:
- 如果程序执行的是增长字符串的操作, 比如拼接操作(append), 那么在执行这个操作之前, 程序需要先通过内存重分配来扩展底层数组的空间大小 —— 如果忘了这一步就会产生缓冲区溢出。
- 如果程序执行的是缩短字符串的操作, 比如截断操作(trim), 那么在执行这个操作之后, 程序需要通过内存重分配来释放字符串不再使用的那部分空间 —— 如果忘了这一步就会产生内存泄漏。
举个例子, 如果我们持有一个值为 "Redis"
的 C 字符串 s
, 那么为了将 s
的值改为 "Redis Cluster"
, 在执行:
1 | strcat(s, " Cluster"); |
之前, 我们需要先使用内存重分配操作, 扩展 s
的空间。
之后, 如果我们又打算将 s
的值从 "Redis Cluster"
改为 "Redis Cluster Tutorial"
, 那么在执行:
1 | strcat(s, " Tutorial"); |
之前, 我们需要再次使用内存重分配扩展 s
的空间, 诸如此类。
因为内存重分配涉及复杂的算法, 并且可能需要执行系统调用, 所以它通常是一个比较耗时的操作:
- 在一般程序中, 如果修改字符串长度的情况不太常出现, 那么每次修改都执行一次内存重分配是可以接受的。
- 但是 Redis 作为数据库, 经常被用于速度要求严苛、数据被频繁修改的场合, 如果每次修改字符串的长度都需要执行一次内存重分配的话, 那么光是执行内存重分配的时间就会占去修改字符串所用时间的一大部分, 如果这种修改频繁地发生的话, 可能还会对性能造成影响。
为了避免 C 字符串的这种缺陷, SDS 通过未使用空间解除了字符串长度和底层数组长度之间的关联: 在 SDS 中, buf
数组的长度不一定就是字符数量加一, 数组里面可以包含未使用的字节, 而这些字节的数量就由 SDS 的 free
属性记录。
通过未使用空间, SDS 实现了空间预分配和惰性空间释放两种优化策略。
空间预分配
空间预分配用于优化 SDS 的字符串增长操作: 当 SDS 的 API 对一个 SDS 进行修改, 并且需要对 SDS 进行空间扩展的时候, 程序不仅会为 SDS 分配修改所必须要的空间, 还会为 SDS 分配额外的未使用空间。
其中, 额外分配的未使用空间数量由以下公式决定:
- 如果对 SDS 进行修改之后, SDS 的长度(也即是
len
属性的值)将小于1 MB
, 那么程序分配和len
属性同样大小的未使用空间, 这时 SDSlen
属性的值将和free
属性的值相同。 举个例子, 如果进行修改之后, SDS 的len
将变成13
字节, 那么程序也会分配13
字节的未使用空间, SDS 的buf
数组的实际长度将变成13 + 13 + 1 = 27
字节(额外的一字节用于保存空字符)。 - 如果对 SDS 进行修改之后, SDS 的长度将大于等于
1 MB
, 那么程序会分配1 MB
的未使用空间。 举个例子, 如果进行修改之后, SDS 的len
将变成30 MB
, 那么程序会分配1 MB
的未使用空间, SDS 的buf
数组的实际长度将为30 MB + 1 MB + 1 byte
。
通过空间预分配策略, Redis 可以减少连续执行字符串增长操作所需的内存重分配次数。
举个例子, 对于图 2-11 所示的 SDS 值 s
来说, 如果我们执行:
1 | sdscat(s, " Cluster"); |
那么 sdscat
将执行一次内存重分配操作, 将 SDS 的长度修改为 13
字节, 并将 SDS 的未使用空间同样修改为 13
字节, 如图 2-12 所示。
如果这时, 我们再次对 s
执行:
1 | sdscat(s, " Tutorial"); |
那么这次 sdscat
将不需要执行内存重分配: 因为未使用空间里面的 13
字节足以保存 9
字节的 " Tutorial"
, 执行 sdscat
之后的 SDS 如图 2-13 所示。
在扩展 SDS 空间之前, SDS API 会先检查未使用空间是否足够, 如果足够的话, API 就会直接使用未使用空间, 而无须执行内存重分配。
通过这种预分配策略, SDS 将连续增长 N
次字符串所需的内存重分配次数从必定 N
次降低为最多 N
次。
惰性空间释放
惰性空间释放用于优化 SDS 的字符串缩短操作: 当 SDS 的 API 需要缩短 SDS 保存的字符串时, 程序并不立即使用内存重分配来回收缩短后多出来的字节, 而是使用 free
属性将这些字节的数量记录起来, 并等待将来使用。
举个例子, sdstrim
函数接受一个 SDS 和一个 C 字符串作为参数, 从 SDS 左右两端分别移除所有在 C 字符串中出现过的字符。
比如对于图 2-14 所示的 SDS 值 s
来说, 执行:
1 | sdstrim(s, "XY"); // 移除 SDS 字符串中的所有 'X' 和 'Y' |
会将 SDS 修改成图 2-15 所示的样子。
注意执行 sdstrim
之后的 SDS 并没有释放多出来的 8
字节空间, 而是将这 8
字节空间作为未使用空间保留在了 SDS 里面, 如果将来要对 SDS 进行增长操作的话, 这些未使用空间就可能会派上用场。
举个例子, 如果现在对 s
执行:
1 | sdscat(s, " Redis"); |
那么完成这次 sdscat
操作将不需要执行内存重分配: 因为 SDS 里面预留的 8
字节空间已经足以拼接 6
个字节长的 " Redis"
, 如图 2-16 所示。
通过惰性空间释放策略, SDS 避免了缩短字符串时所需的内存重分配操作, 并为将来可能有的增长操作提供了优化。
与此同时, SDS 也提供了相应的 API , 让我们可以在有需要时, 真正地释放 SDS 里面的未使用空间, 所以不用担心惰性空间释放策略会造成内存浪费。
二进制安全
C 字符串中的字符必须符合某种编码(比如 ASCII), 并且除了字符串的末尾之外, 字符串里面不能包含空字符, 否则最先被程序读入的空字符将被误认为是字符串结尾 —— 这些限制使得 C 字符串只能保存文本数据, 而不能保存像图片、音频、视频、压缩文件这样的二进制数据。
举个例子, 如果有一种使用空字符来分割多个单词的特殊数据格式, 如图 2-17 所示, 那么这种格式就不能使用 C 字符串来保存, 因为 C 字符串所用的函数只会识别出其中的 "Redis"
, 而忽略之后的 "Cluster"
。
虽然数据库一般用于保存文本数据, 但使用数据库来保存二进制数据的场景也不少见, 因此, 为了确保 Redis 可以适用于各种不同的使用场景, SDS 的 API 都是二进制安全的(binary-safe): 所有 SDS API 都会以处理二进制的方式来处理 SDS 存放在 buf
数组里的数据, 程序不会对其中的数据做任何限制、过滤、或者假设 —— 数据在写入时是什么样的, 它被读取时就是什么样。
这也是我们将 SDS 的 buf
属性称为字节数组的原因 —— Redis 不是用这个数组来保存字符, 而是用它来保存一系列二进制数据。
比如说, 使用 SDS 来保存之前提到的特殊数据格式就没有任何问题, 因为 SDS 使用 len
属性的值而不是空字符来判断字符串是否结束, 如图 2-18 所示。
通过使用二进制安全的 SDS , 而不是 C 字符串, 使得 Redis 不仅可以保存文本数据, 还可以保存任意格式的二进制数据。
兼容部分 C 字符串函数
虽然 SDS 的 API 都是二进制安全的, 但它们一样遵循 C 字符串以空字符结尾的惯例: 这些 API 总会将 SDS 保存的数据的末尾设置为空字符, 并且总会在为 buf
数组分配空间时多分配一个字节来容纳这个空字符, 这是为了让那些保存文本数据的 SDS 可以重用一部分 <string.h>
库定义的函数。
举个例子, 如图 2-19 所示, 如果我们有一个保存文本数据的 SDS 值 sds
, 那么我们就可以重用 <string.h>/strcasecmp
函数, 使用它来对比 SDS 保存的字符串和另一个 C 字符串:
1 | strcasecmp(sds->buf, "hello world"); |
这样 Redis 就不用自己专门去写一个函数来对比 SDS 值和 C 字符串值了。
与此类似, 我们还可以将一个保存文本数据的 SDS 作为 strcat
函数的第二个参数, 将 SDS 保存的字符串追加到一个 C 字符串的后面:
1 | strcat(c_string, sds->buf); |
这样 Redis 就不用专门编写一个将 SDS 字符串追加到 C 字符串之后的函数了。
通过遵循 C 字符串以空字符结尾的惯例, SDS 可以在有需要时重用 <string.h>
函数库, 从而避免了不必要的代码重复。