sds数据结构,采用空间预分配和惰性空间释放来提升效率,缺点就是耗费内存。
struct sdshdr {
int len; //长度
int free; //剩余空间
char buf[]; //字符串数组
};
空间预分配:当一个sds被修改成更长的buf时,除了会申请本身需要的内存外,还会额外申请一些空间。
惰性空间:当一个sds被修改成更短的buf时,并不会把多余的内存还回去,而是会保存起来。
总结:这种设计的核心思想就是空间换时间,只要 free
还剩余足够的空间时,下次string变长的时候,不会像系统申请内存。
链表被广泛用于实现Redis的各种功能,比如列表键、发布与订阅、慢查询、监视器等。
struct listNode {
struct listNode * prev; //前置节点
struct listNode * next; //后置节点
void * value;//节点的值
};
不仅仅是数据类型为hash的才用到hash结构,redis本身所有的k、v就是一个大hash。例如我们经常用的set key value,key
就是hash的键,value
就是hash的值。
struct dict {
...
dictht ht[2]; //哈希表
rehashidx == -1 //rehash使用,没有rehash的时候为-1
}
rehash:每个字典有两个hash表,一个平时使用,一个rehash的时候使用。rehash是渐进式的,分别是由以下触发:
hash冲突:采用单向链表的方式解决hash冲突,新的冲突元素会被放到链表的表头。
有序集合可以被用于一些排序场景,底层采用跳跃表实现。
struct zskiplistNode {
struct zskiplistLevel {
struct zskiplistNode *forward;//前进指针
unsigned int span;//跨度
} level[];
struct zskiplistNode *backward;//后退指针
double score;//分值
robj *obj; // 成员对象
};
层高:每个跳跃表节点的层高在1-32之间。
跳跃:通过层来实现跨节点跳跃,达到加速访问的效果。
比如o1到o3只需要通过L4层跨度为2实现跨节点跳跃。
set的底层为了实现内存的节约,会根据集合的类型和数目而采用不同的数据结构来保存,当集合的元素都是整型且数量不多时会采用整数集合来存储。
struct intset {
uint32_t encoding;//编码方式
uint32_t length;//集合包含的元素数量
int8_t contents[];//保存元素的数组
};
整数集合底层实现为数组,在添加元素的时候,根据需要会修改这个数组的类型(比如int16升级成int32)。
在大型应用架构中,redis作为缓存层已经是非常普遍的现象,其中一个原因就是它非常快。
redis是内存型数据库,大多数操作都是基于内存的。
我们知道redis的数据结构是有特殊设计的,比如string类型采用sds数据结构来存储,每次string的空间不够时,总是尝试去申请更多的内存,每次string空间多余的时候,也不是把多余的空间还给系统,通过这种方式来减少内存的申请达到一种快,有序集合采用的跳跃表可以通过不同的层来达到加速访问节点的效果也是快速的体现,渐进式rehash也是高效快速的体现。
redis的主体模式还是单线程的,除了一些持久化相关的fork。单线程相比多线程的好处就是锁的问题,上下文切换的问题。官方也解释到:redis的性能不在cpu,而在内存。
IO多路复用就是多个TCP连接复用一个线程,如果采用多个请求起多个进程或者多个个线程的模式还是比较重的,除了要考虑到进程或者线程的切换之外,还要用户态去遍历检查事件是否到达,效率低下。redis支持select、poll、epoll模式的多路复用,默认情况下,会选择系统支持的最好的模式。通过IO多路复用技术,用户态不用去遍历fds集合,通过内核通知告诉事件的到达,效率比较高。
客户端执行一条命令的过程大概是这样:
命令请求->命令排队->命令执行->结果返回。 这个过程我们叫做RTT(Round trip time)往返时间。在实际工作中,我们可能遇到这样一种场景:我们需要不停的incr一个key,但是这个key不能永久存在,得加一个过期时间,于是我们的程序大概长这样:
incr key #一次RTT
expire key time #一次RTT
#总共两次RTT
这样我们发现整个过程需要两个RTT。一般我们生产环境都是用的连接池,在连接池有足够的连接的时候,可能我还不需要去创建新的连接,这样就省了TCP三次握手的开销,当需要创建新的连接的时候,这时候还要去建立连接,整个开销又上去了。针对这种批处理命令,为了减少往返的开销,于是管道pipeline
诞生了,通过管道我们可以把两条命令合并发送:
# 伪代码
pipeline->send(incr key)
pipeline->send(expire key time)
pipeline->execute() #一次RTT
#总共一次RTT
这样就可以将2次的RTT减少成1次RTT。
节约资源:pipeline可以将多次的请求合并成一次请求,减少网络开销,节约时间。但是通过pipeline合并的请求不能太多,太多的话,可能占了大量的带宽,造成网络拥堵,同时太多的命令会造成客户端等待时间较长,推荐将大批量的命令拆成多个小批的pipeline。
非原子性:pipeline并非是原子性的,假设你通过pipeline发了incr key
、expire key
两条指令。但是redis执行expire的时候失败了,这样相当于你的key没有设置过期时间,这一点是需要注意的。
redis自己设计了一套序列化协议RESP,主要有容易实现、解析快、可读性好几个特点。协议的每部分都是\r\n
结束的,可以通过特殊符号来区分出数据的类型:
+
号开头。-
号开头。:
号开头。$
号开头。*
号开头。因为通过redis客户端看不出来效果,我这里用nc这个tcp工具来模拟下。
nc 127.0.0.1 6379 #连接上redis
#发起ping
ping
+PONG
#发送一个不存在的命令
hi
-ERR unknown command 'hi'
#age+1
incr age
:1
#get
$2
go
#mget
mget name1 name2
*2
$2
go
$4
java
上面的每行都是\r\n结束的,Redis协议的实现性能可以和二进制协议的实现性能相媲美, 并且由于 Redis协议的简单性,大部分语言都可以实现这个协议。
什么原子性?程序在执行过程中,要么全部都执行,要么全部都不执行,不可能执行了一半,滞留了一半。我们知道redis是IO多路复用模型,即一个线程来处理多个TCP连接,这样的好处就是,即使客户端并发请求,也得排队处理,一定程度上解决了多线程模型带的并发问题,但是单线程模型并不能解决原子性问题。
还是以incr和expire为例:
命令1:incr key
命令2:expire key time
不管你是pipeline还是非pipeline,这样两条操作你都是无法保持原子性的,命令1的失败不影响命令2的执行,命令2的执行也不影响命令1的结果。
假设现在有两个客户端希望修改某个值,它们操作的流程是先获取原先的值,再更新新值=老值+1 但是由于时间顺序的问题,可能它们是这样执行的顺序:
# key的value刚开始是10
客户端1:get key #10
客户端2:get key #10
客户端1:set key 11 #11
客户端2:set key 11 #11
会发现客户端2更新的值丢失了,原因在于客户端1在获取到key的值之后,没来的及更新,这时客户端2的get进来了,导致客户端2获取的是老值。
对于这种场景,可以用redis的incr来替代,incr是原子的操作的,它把get和set合并在一起,再利用redis的单线程特性,第一个incr进来的时候,第二个incr一定是等待的,这样就不会存在更新丢失的问题。类型的原子命令还有decr 、setnx。
结合watch监控和事务也可以解决,每个客户端可以通过watch来监控自己即将要更新的key,这样在事务更新的时候,如果发现自己监控的key被修改了,那么拒绝执行,事务执行失败,这样就不会存在更新覆盖的问题。watch的本质还是乐观锁,当客户端执行watch的时候,实际上是watch的key会维护一个客户端的队列,这样就知道这个key被哪些客户端监视了。当其中的一个客户端执行完毕之后,那么它会从这个队列中移除,并且会把这个列表中剩余的所有客户端的CLIENT_DIRTY_CAS
标识打开,这样剩余的客户端在执行exec的时候,发现自己的CLIENT_DIRTY_CAS
已经被打开,那么就会拒绝执行。
客户端1 > watch key
OK
客户端1 > MULTI
OK
客户端1 > SET key 100
QUEUED
客户端1 > EXEC
1) OK
客户端1 > GET key
"100"
客户端2 > watch key
OK
客户端2 > MULTI
OK
客户端2 > SET key 101
QUEUED
客户端2 > EXEC
(nil)
客户端2 > GET key
"100"
客户端1和客户端2都尝试来修改key的值,然而因为客户端1先更新了。那么客户端2在更新的时候通过watch发现值已经被修改了,就会拒绝执行,返回个nil
redis在2.6之后开始支持开发者编写lua脚本传到redis中,使用lua脚本的好处是:
我们来看看lua是如何保证原子性的,假设现在有个逻辑,我们要先判断key不存在,再设置key,存在的话,就不设置了。
不用lua:
#伪代码
客户端1:if key not exist
客户端2:if key not exist
客户端1:set key 10086
客户端2:set key 10086 #多余的
由于上述不具备原子性,导致客户端2多执行了一次。
使用lua:
127.0.0.1:6379> eval "if redis.call('get', KEYS[1]) == false then redis.call('set', KEYS[1], ARGV[1]) return 0 else return 1 end" 1 key "10086"
(integer) 0 #执行成功
127.0.0.1:6379> get key1
"10086"
使用了lua之后,首先redis本身会把整个lua脚本当成一个整体,运行期间不会收到其他命令的干扰。使用lua脚本之后,我们可以编写自己的复杂业务来保证原子性。当lua脚本很长时,在命令行里执行不太优雅,redis提供load lua的命令,导入lua脚本文件,导入成功后会返回一个sha1编码的id,后期通过这个id可以反复执行。
当生产环境去删除一个大key的时候,可能会造成线上阻塞,这是一个非常危险的操作,可以根据实际情况选择以下方法:
高性能架构中,我们一般会在db层之上加个cache层,因为cache的数据是在内存中的,这样当大量数据访问的时候,如果cache的命中率高,那么就可以阻挡大量的请求打到我们的db中去,起到保护db和加速访问的作用。如果cache miss了,那么当数据库中读到数据的时候,我们也会写入一份到缓存中去,这样下次请求的时候也会从缓存获得数据。如果某个cache一直miss,或者某个cache miss之后突然并发进来大量请求以及缓存在某一瞬间大面积失效咋办?
当我们访问一个非法的数据的时候(缓存和数据库都不存在的数据),每次先去缓存获取,获取不到,然后去数据库获取,依然获取不到,比如user_id=-1这种(一个用户的id是不可能为负数的)。出现这种情况,每次必然是要去数据库请求一次不存在的数据,这时候因为没有数据,所以也不会写入缓存,下一次同样的请求还是会重蹈覆辙。
解决:
热点数据在某一时刻缓存过期,然后突然大量请求打到db中,这时如果db扛不住,可能就挂了,引起线上连锁反应。
解决:
当某一些时刻,突然大量缓存失效,所有的请求都打到了db,与缓存击穿不同的是,雪崩是大量的key,击穿是一个key,这时db的压力也不言而喻。
解决:
save:SAVE是手动保存方式,它会使redis进程阻塞,直至RDB文件创建完毕,创建期间所有的命令都不能处理。
127.0.0.1:6379> save
OK
27004:M 31 Jul 15:06:11.761 * DB saved on disk
bgsave:与SAVE命令不同的是BGSAVE,BGSAVE可以不阻塞redis进程,通过BGSAVE redis会fork一个子进程去执行rdb的保存工作,主进程继续执行命令。
127.0.0.1:6379> BGSAVE
Background saving started
27004:M 31 Jul 15:07:08.665 * Background saving terminated with success
BGSAVE执行期间与其他一些IO命令会存在一些互斥:
BGSAVE与BGREWRITEAOF都是由两个子进程处理,目标也是不同的文件,本身没什么冲突,主要是两个都可能要大量的IO,这对服务本身来说不是很友好。
用户可以通过配置,让每隔一段时间来执行bgsave:
save 900 1 #900s内至少修改了1次
save 300 10 #300s内至少修改了10次
save 60 10000 #60s内至少修改了10000次
以上条件只要满足了一个就可以执行bgsave。
这里涉及到两个参数来记录次数和时间,分别是dirty计数器和lastsave。
以上两个指标是基于redis的serverCron来完成的,serverCron是一个定期执行的程序,默认每隔100ms执行一次。每次serverCron执行的时候会遍历所有的条件,然后检查计数是否ok,时间是否ok,都ok的话就执行一次bgsave,并且记录最新的lastsave时间,重置dirty为0。
导入:redis没有专门的用户导入的命令,redis在启动的时候会检测是否有RDB文件,有的话,就自动导入。
27004:M 31 Jul 14:46:51.793 # Server started, Redis version 3.2.12
27004:M 31 Jul 14:46:51.793 * DB loaded from disk: 0.000 seconds
27004:M 31 Jul 14:46:51.793 * The server is now ready to accept connections on port 6379
DB loaded from
disk就是载入rdb的描述,服务在载入RDB期间是阻塞的。当然如果也开启了AOF,那么就会优先使用AOF来恢复,只有在服务器未开启AOF的时候,才会选择RDB来恢复数据,导入的时候也会自动过滤过期的key。
AOF就是一个命令追加的模式,假设执行了:
RPUSH list 1 2 3 4
RPOP list
LPOP list
LPUSH list 1
最终以redis协议方式存储:
*2$6SELECT$10*6$5RPUSH$4list$11$12$13$14*2$4RPOP$4list*2$4LPOP$4list*3$5LPUSH$4list$11
aof先是写到aof_buf的缓冲区中,redis提供三种方案将buf的缓冲区的数据刷到磁盘,当然也是serverCron来根据策略处理的。
appendfsync always
appendfsync everysec
appendfsync no
在现代操作系统中,为了提高文件写入的效率,当我们调用write写入一个数据的时候,操作系统并不会立刻写入磁盘,而是放在一个缓冲区里,当缓冲区满了或者到了一定时间后,才会真正的刷入到磁盘中。这样存在一定风险,就是内存的数据没等到刷入磁盘的时候,机器宕机了,那么数据就丢失了,于是操作系统也提供了同步函数fsync,让用户可以自己决定什么时候同步。
AOF重写:随着命令越来越多,aof的体积会越来越大,例如:
incr num
incr num
incr num
incr num
执行4条incr num,num的最终的值是4,然后可以直接用一条set num 4 代替,这样存储就节省了很多。重写也不是分析现有aof,重写就是从数据库读取现有的key,然后尽量用一条命令代替。并不是所有的都可以用一条命令代替,例如sadd 每次最多只能add 64个,如果超过64个就要分批了。
创建新的aof -> 遍历数据库 ->遍历所有的key->忽略过期的->写入aof。
fork:aof的重写涉及大量的IO,在当前进程里去做肯定不合适,理所当然也是fork一个子进程来做,不使子线程的原因是避免一些锁的问题。 使用子进程需要考虑的问题就是在子进程写入的时候,主进程还在源源不断的接收新的请求,那么针对这种情况redis设置了一个aof重写缓冲区,缓冲区在子进程创建的时候开始使用,那么在新的请求来的时候,除了写入aof缓冲区外,还要写入aof重写缓冲区,此过程不阻塞。 那么在子进程重写完了之后,会发信号给主进程,主进程收到信号后,会把重写缓冲区的数据再次同步给新的aof文件,然后rename新的aof,原子的覆盖老的aof,完成重写,这个过程是阻塞的。
何时执行重写:
auto-aof-rewrite-percentage 100
auto-aof-rewrite-min-size 64mb
导入: redis启动的时候,会创建一个伪客户端,然后执行aof文件里面的命令。
rdb:对于主从模式来说,主服务器载入rdb文件的时候会自动过滤过期的key,从服务器载入rdb文件的时候不会过滤过期的key,因为主从在进行同步数据的时候,从会清空自己的数据。
aof:当服务器以AOF持久化模式运行时,如果数据库中的某个键已经过期,但它还没有被惰性删除或者定期删除,那么AOF文件不会因为这个过期键而产生任何影响。当过期键被惰性删除或者定期删除之后,程序会向AOF文件追加(append)一条DEL命令,来显式地记录该键已被删除。aof重写的时候会自动过滤过期的key。
一般主从模式下,主负责提供写,从负责提供读。从服务器在执行客户端发送的读命令时,即使碰到过期键也不会将过期键删除,而是继续像处理未过期的键一样来处理过期键。主服务器在删除一个过期键之后,会显式地向所有从服务器发送一个DEL命令,告知从服务器删除这个过期键。
淘汰策略是一个灵活的配置选项,一般根据业务来选择合适的淘汰策略,当然是我们进行add key或者update一个更大的key,这时候如果内存不足会触发我们设置的淘汰策略。
redis内部有定期执行的函数servercron
,它默认每隔100ms执行一次,它的作用主要是以下:
lruclock
属性用于保存服务器的lru时钟,每个对象也有一个lru时钟,通过服务器的lru减去对象的lru,可以得出对象的空转时间,serverCron默认会以每10s一次更新一次lruclock,所以这也是一个模糊值。INFO stats
...
instantaneous_ops_per_sec:1
INFO stats
...
used_memory_peak:2026832
used_memory_peak_human:1.93M
aof_rewrite_scheduled
,每次serverCron执行的时候会检查当前是否有BGSAVE或BGREWRITEAOF在执行,如果没有且aof_rewrite_scheduled已标记,那么就会执行BGREWRITEAOF。当slave执行slave of之后,会发个sync命令给master,master在收到sync之后,开始在后台执行bgsave,同时这期间的写记录在缓冲区中,当bgsave完成之后,master会把rdb发给从,slave根据rdb加载数据,同时master把缓冲区里的变更也发给slave,后续master的变更记录都会通过命令传播的形式传给slave。然而如果在某个时刻因为网络原因slave和manster断线了,这时候如果slave连接上了,会发生什么?断线期间的变更怎么办?
什么是脑裂问题:在redis集群中,如果存在两个master节点就是脑裂问题,这时候客户端连着哪个master,就往哪个master上写数据,导致数据不一致。
脑裂问题是如何产生的:一般可能是由于master所处的网络发生了问题,导致master和其余的slave无法正常通信,但是master和客户端的通信是ok的,这时哨兵会从剩下的slave中选举一个master,当原master网络恢复后,就会被降级成slave。
脑裂产生的影响:在原master失联的期间,和它通信的client会把变更记录在原master中,当原master网络恢复并成为新master的slave的时候,它会清空自己的数据,同步新master的数据。那么在网络失联的期间,往原master写入数据都是丢失的。
如何解决:主要通过两个配置参数来解决:
min-slaves-to-write 1
min-slaves-max-lag 10
如果两个配置都不满足的话,那么master就拒绝客户端的请求,通过以上配置可以将丢失的数据控制在10s内。
我们知道redis是单线程的,命令是一个一个处理的,所以用redis做分布式锁是ok的?最常用的就是:
set key value PX seconds NX
锁时间到了:一般生产环境我们为了安全会给锁加个自动过期时间,这样就算出现意外没有解锁,锁也会自动过期,降低损失风险。然而如果锁的时间到了,我们的业务还没处理完怎么办?一般解决方法如下:
主从模式下:在主从模式下,一个主至少有一个从,主负责写,从负责读,当我们设置锁的时候,是写在master上,但是在将数据同步到slave前master出现问题,导致触发选举新的slave为master,而此时锁的信息在新master上是丢失的,这样就会导致并发不安全。
总结:redis的分布式锁在要求强一致性的情况下可能并不适合,但是在某些场景下还是适合的,比如:就算在某个时候锁失效了,存在多个请求进入安全区,多个请求可能也就是多执行几次db查询,对整体业务并无大碍。
什么是热key:经常被访问的key就是热key,比如双11的商品信息,秒杀的商品信息。当热key的并发量非常大,使得QPS达到几十万级别,这时候如果没有做好防护,可能出现问题就是灾难级别的。
如何解决: