-
为什么需要Redis?
冷热分离。缓解数据量增长下,读写数据的压力
-
Redis如何运作?
数据从内存中读写,(手动)保存至硬盘以持久化。其中AOF文件存增量数据,RDB文件存全量数据。
Redis采用一个全局的hashtable(数组)保存所有的KV对。数组的每个位置都是一个独立的entry,保存着key(s)和value(s)。key为string,value为string/list/hash/set/zset中的任意一种。
-
Redis特点:
- 高性能(基于内存,单线程处理所有网络I/O和KV读写,多路I/O复用,Gossip协议同步,数据结构高效)
- 高可用(主从复制、哨兵集群)
- 高拓展(Cluster分片集群)
对于~8000以内的连接数,QPS可达100k/s。
案例
-
连续签到
(set expiration as 后天0点,如果明天未签到,一旦到了后天0点,连续签到数据就会消失)
-
消息通知
用list作消息队列,用于如将更新后的文章推送到ES
-
计数,用户信息,购物车信息等
用hash(表)存一个用户的多项计数需求
-
去重、踩、赞、共同好友
用set
-
排行榜
用zset
-
限流
以时间戳+username作为key,对此key调用incr,超过限制N则禁止访问
-
分布式锁
用setnx实现
底层实现
1. 维护/管理内存资源
-
命中率统计
-
LRU:每次读取一个key后,服务器会更新其lru时间
-
dirty标识:如果有客户端使用WATCH命令监视了该键,服务器会将这个键标记为dirty,让事务程序注意到这个键已经被修改过。每次修改都会对dirty加一,用于触发持久化和复制
-
数据过期:结合2种策略
-
惰性删除:如果服务器在读取一个键时发现该键已经过期,那么服务器会先删除这个过期键,然后才执行余下的其他操作。缺点是如果不访问,过期key就会一直存在内存中
-
定期删除:启用一个定时器定时监视所有key,删除过期的key。缺点是每次都遍历内存中所有数据,很耗cpu
二者结合,定期删除只随机抽取一部分key检查,剩下的靠惰性删除解决。两者都未cover到的,交给内存淘汰机制
-
-
内存淘汰
- noeviction:redis默认。内存不足时新写入操作直接报错
- allkeys-lru:内存不足时移除最近最少使用的key
- allkeys-random:随机移除某个key
- volatile-lru:在设置了过期时间的key中,移除最近最少使用的key。(一般是把redis同时当缓存和持久化存储时才用)
- volatile-random
- volatile-ttl:在设置了过期时间的key中,移除过期时间更早的key
这里的lru采用采样概率,不存双向链表(?)
2. 网络I/O多路复用
-
阻塞I/O下,app调用OS的recvfrom(),这个函数会阻塞线程直至数据回来。于是若要同时处理多个网络请求,就需要配合大量线程使用,每接收一个连接都要创造一个新线程。创建、上下文切换的开销很大,还有锁竞争的潜在风险(?)。
-
而非阻塞I/O下,多个网络连接用同一个线程。recvfrom()不断轮询看数据是否准备好。若数据未准备好,kernel不会阻塞在一个连接上,会直接返回错误并将错误码设为EAGAIN或EWOULDBLOCK。缺点是比较耗费CPU。
I/O复用模型就是为解决CPU占用而生。Linux提供poll/select/epoll三种方式,Mac:kqueue,Windows:select
当管理的连接数过少时,这种模型会退化成阻塞I/O,而且还多了一次对poll/select/epoll的系统调用。
此外,poll/select/epoll“本质仍是同步I/O”。I/O multiplexing解决的是数据准备阶段的问题,事件就绪后,从kernel复制数据到user space仍需由它们自己来完成,而这个读写过程还是阻塞的。
- select(O(n) time): 需要将fd集(包括readfds/writefds/exceptfds)从user space整个拷贝到kernel space。当有fd就绪,select调用者可以感知到(i.e. select会返回),但不知道具体是哪些个fd,必须轮询
- poll(O(n) time):几乎等同于select,但fd是基于链表存储,并不像select一样有上限
- epoll(O(1) time):user/kernel共享内存。感知基于event callback方式,仅感知活跃连接
3. 自实现的网络事件处理器(基于reactor模式)
在Redis中,I/O多路复用程序监听多个socket(每个socket连接都与程序的一个fd对应,I/O multiplexing实际是监视当前程序的fd)。被监听的socket准备好执行accept/close/read/write时,相应的事件会发出,进到多路复用程序的队列,然后被文件事件分派器转发给不同的事件处理器。
感谢各位大佬制图:
4. 不同数据类型的底层实现
(首先明确,所有Key均为string,以下数据类型为Value可用的一些数据类型)
1. String: sdshdr
struct sdshdr {
int len; // length of buf, used
int free; // length of buf, free
char buf[]; // length = len + free + 1 (for the ending '\0')
}
- len帮助O(1)获取string的长度,同时允许string中出现'\0'(二进制数据中会出现)而不会影响string的边界判断
- free帮助直接判断执行strcat时是否需要扩容;释放空间时,也未必需要回收内存,只需把释放出的大小存在free中,这样一来,下次要是要append或许就不用额外地扩容了
此外,每次sds被修改,Redis还会额外分配一些空间,以减少总的内存重分配次数
2. List: 双端linkedlist + ziplist --> quicklist
- 双端linkedlist:是多态的(用void*指针),可以保存不同类型的value
- ziplist:压缩,节省了linkedlist中pointers的空间。查首尾是O(1),查别的元素是O(n)
struct ziplist<T> { int32 zlbytes; // total number of bytes occupied int32 zltail_offset; // for jumping to the last element in the list int16 zllength; // number of entries in the ziplist T[] entries; // an entry: [ previous_entry_length | encoding | content ] int8 zlend; // the constant 0xFF. mark the end of ziplist. }
linkedlist的空间成本很高(光prev/next指针就去掉了16bytes,且linkedlist节点在内存中单独分配,会加剧内存的碎片化),所以后来Redis用quicklist代替了ziplist + linkedlist,将linkedlist 按段切分,每一段使用 ziplist 来紧凑存储:
3. Hash: dictht
struct dictht {
dictEntry **ht;
int size;
int sizemask;
}
- hash(key) & sizemask = key所在槽位。注意一个槽位里可能有多个entry(key冲突的情况)
渐进式Rehash(同样适用于Redis的全局哈希表!):
假设要把ht[0]中的数据全部迁移到ht[1]中,然后释放ht[0](比如对hashtable扩容以解决冲突时)。数据量大的场景下,迁移过程会明显阻塞用户请求。
解决方案:渐进式rehash -- 将整个迁移过程分摊到多次处理请求的过程中,每次用户请求时只迁移一点点
4. Zset: zskiplist + dictht
-
跳表:增加多层级索引以加速链表的查询,平均复杂度达到O(log n)。长这样:
(新节点的层高是按概率随机生成的)
-
dict中key为元素的值,对应skiplistNode的obj属性;value为元素的分值,对应skiplistNode的score属性
struct zset {
zskiplist *zsl;
dictht *dict;
}
struct zskiplist {
struct zskiplistNode *header, *tail;
unsigned long length; // number of nodes in the list
int level; // deepest level
}
struct zskiplistNode {
struct zskiplistLevel {
struct zskiplistNode *forward;
unsigned int span; // distance (nodes spanned) from current node to *forward
} level[];
struct zskiplistNode *backward;
robj *obj;
double score;
}
zskiplist以score为基准来排列
二者结合,dict使得查找为O(1),skiplist使得范围操作时无需再对dict进行排序
使用注意
1. 大key
string value字节数大于10K,或其他数据类型元素个数大于5000/总字节数大于10M,容易导致慢查询
- 读取成本高,读写耗时变长
- 主从复制异常,服务阻塞,不能正常响应请求
解决办法:
- 拆分value(实操比较复杂)
- 压缩value,读取时再解压
- 区分冷热(如榜单只缓存前10页,后续走db)
2. 热key
某key的QPS特别高,导致server实例出现CPU负载突增/不均。
解决办法:
- 在业务服务侧设置local cache,local cache里没有再去redis拉数据(e.g. Java的Guava,Go的BigCache)
- 拆分:复制这个热key(key1:value, key2:value)。访问时走不同的key,以此将QPS分散到不同的redis实例上。代价是更新时需要更新多个key,存在短暂的数据不一致风险
3. 慢查询
避免这些操作:
- 批量操作一次性传入过多的key/value,如mset/hmset/sadd/zadd等O(n)操作。建议单批次不要超过100,超过100之后性能下降明显
- zset大部分命令都是O(log(n)),当大小超过5k以上时,简单的zadd/zrem也可能导致慢查询
- 操作的单个value过大,超过10KB。也即,避免使用大Key
- 对大key的delete/expire操作也可能导致慢查询,Redis4.0之前不支持异步删除unlink,大key删除会阻塞Redis
4. 缓存穿透/缓存雪崩
缓存穿透:热点数据绕过缓存,直接查询数据库
- 比如大量查询某个不存在的数据(攻击)。这种数据通常不会缓存,查询会全部打到DB
- 解决办法:缓存空值,或使用bloom filter来存储合法的key
- 高并发场景下,如果一个热key过期,大量请求会同时击穿至DB
缓存雪崩:大量缓存同时过期
- 将缓存失效时间分散开,比如在原有的失效时间上增加一个随机值;热点数据设置尽量长的过期时间
- 使用缓存集群,避免单机宕机造成的缓存雪崩
Ref:
字节redis课件