使用场景
- 数据(热点)⾼并发的读写
- 海量数据的读写
- 对扩展性要求⾼的数据
分布式缓存和本地缓存的区别
| | 分布式缓存 | 本地缓存 | | —- | —- | —- | | 缓存一致性 | 较好 | 较弱,每个实例都有自己的缓存 | | 堆内存占用 | 不占用 | 占用,影响垃圾回收 | | 速度 | 较慢,因为需要网络传输和序列化 | 较快 | | 使用场景 | 要求数据一致性,访问量大的场景 | 对数据一致性没有特别高的要求,且访问次数多的场景 |
本地缓存的实现:
- 使用特定数据结构,比如ConcurrentHashMap
- 使⽤开源的缓存框架 Ehcache,Ehcache 封装了对于内存操作的功能
- Guava Cache 是 Google 开源的⼯具集, 提供了缓存的边界操作⼯具
Redis通信协议
Redis 的通信协议是 Redis Serialization Protocol,简称 RESP。
特征如下:
- 二进制安全
- 在TCP层
- 基于请求-响应的模式
Redis和Memcache的区别
| | Redis | Memcache | | —- | —- | —- | | 存储方式 | 持久化 | 断电丢失 | | 支持数据类型不同 | String,hash,list,set,zset | 只支持key-value | | 速度 | 快 | 慢 |
常用数据结构
- String(字符串) 最大容量为512M
- list(列表)list 是字符串列表,按照插⼊顺序排序。元素可以在列表的头部(左边)或者尾部(右边)进⾏添加。最大容量为2^32-1 个。可以做消息队列。
- hash(哈希)Redis hash 是⼀个键值对(key-value)集合。Redis hash 是⼀个 String 类型的 field 和 value 的映射表,hash 特别适合⽤于存储对象。最大容量为2^32-1 个。
- set(集合)Redis 的 set 是 String 类型的⽆序集合。最大容量为2^32-1 个。
- zset(sorted set:有序集合)Redis zset 和 set ⼀样也是 String 类型元素的集合,且 不允许重复的成员。不同的 zset 是每个元素都会关联⼀个 double 类型的分数。zset 通过这个分数来为集合中所有元素进⾏从⼩到⼤的排序。zset 的成员是唯⼀的,但分数(score)却可以重复。最大容量为2^32-1 个。适合做排行榜。
String底层实现
Redis 底层实现了简单动态字符串的类型(SSD),来表示 String 类型。 没有直接使⽤ C 语⾔定义的字符串类型。
使用ssd的好处:struct sdshdr{
//记录 buf 数组中已使⽤字节的数量
//等于 SDS 保存字符串的⻓度
int len;
//记录 buf 数组中未使⽤字节的数量
int free;
//字节数组,⽤于保存字符串
char buf[];
}
- 避免缓冲区溢出:进⾏字符修改时候,可以根据 len 属性来检查空间是否满⾜要求;
- 减少内存分配次数:len 和 free 两个属性,可以协助进⾏空间预分配以及惰性空间释放;
- ⼆进制安全:SSD 不是以空字符串来判断是否结束,⽽是以 len 属性来判断字符串是否结束;
- 常数级别获取字符串⻓度:获取字符串的⻓度只需要读取 len 属性就可以获取;
- 兼容 C 字符串函数:可以重⽤ C 语⾔库的 的⼀部分函数
zset底层实现
zset有两种实现,分别是ziplist或skiplist。
只有当保存的元素数量小于128,并且保存的所有元素长度都小于64字节才会使用压缩列表,否则使用跳跃表。
- ziplistziplist所有元素使用两个紧挨在一起的压缩列表节点来保存,第一个节点保存元素的成员,第二个保存分值。并且集合元素按照分值从小到大顺序排列,小的靠近表头,大的放置在表尾。
- skiplist一个结构同时会包含一个字典和一个跳跃表,字典的键保存元素的值,字典的值保存元素的分值;跳跃表节点的object属性保存元素的成员,score属性保存元素的分值。跳表具有如下性质:
- 由很多层结构组成
- 每一层都是一个有序的链表
- 最底层(Level 1)的链表包含所有元素
- 如果一个元素出现在 Level i 的链表中,则它在 Level i 之下的链表也都会出现。
- 每个节点包含两个指针,一个指向同一链表中的下一个元素,一个指向下面一层的元素。
跳跃表的搜索
例子:查找元素 117
- 比较 21, 比 21 大,往后面找
- 比较 37, 比 37大,比链表最大值小,从 37 的下面一层开始找
- 比较 71, 比 71 大,比链表最大值小,从 71 的下面一层开始找
- 比较 85, 比 85 大,从后面找
- 比较 117, 等于 117, 找到了节点。
跳跃表的插入
先确定元素需要占据的层数K,由抛硬币决定,是正面就继续抛;然后在1-K层都插入这个元素;如果K大于当前层数最大值,则添加新的层。
跳跃表的删除
各个层中找到包含x的节点,使用delete from list方法删除节点。
过期删除策略
常见的删除策略:
- 定时删除:设置过期时间的同时,创建一个timer,过期时间一到就主动删除
- 惰性删除:放任不管,每次获取时,才判断是否过期,过期就删除,属于被动删除
- 定期删除:每隔一段时间就对数据库进行一次删除过期键的操作
Redis采用惰性删除+定期删除的方式管理键。既减小cpu压力的同时,也保证了数据的准确性。
内存淘汰机制
由于可能发生,既没有被惰性删除也没有被定期删除,但内存很快满了的情况出现,所以需要一定的内存淘汰机制。有6中淘汰策略:
- no-eviction:不会继续服务写请求,读请求可以继续进行。这样可以保证不会丢失数据,但是会让线上的业务不能持续进行。这是默认的淘汰策略。
- volatile-lru:尝试淘汰设置了过期时间的 key,最少使用的 key 优先被淘汰。没有设置过期时间的key不会被淘汰,这样可以保证需要持久化的数据不会突然丢失。(这个是使用最多的)
- volatile-ttl:跟上面一样,除了淘汰的策略不是 LRU,而是 key 的剩余寿命 ttl 的值,ttl 越小越优先被淘汰,即淘汰将要过期的数据。
- volatile-random:从已设置过期时间的数据集(server.db[i].expires)中随机选择数据淘汰。
- allkeys-lru:区别于 volatile-lru,这个策略要淘汰的 key 对象是全体的 key 集合,而不只是过期的 key 集合。这意味着没有设置过期时间的 key 也会被淘汰。
allkeys-random:从全体的key集合(server.db[i].dict)中任意选择数据淘汰。
持久化机制
RDB:将Redis在内存中的数据库记录定时dump到磁盘上的RDB持久化。
- AOF:将Redis的操作日志以追加的方式写入文件。
RDB
RDB 持久化是指在指定的时间间隔内将内存中的数据集快照写⼊磁盘,实际操作过程是 fork ⼀个⼦进程,先将数据集写⼊临时⽂件,写⼊成功后,再替换之前的⽂件,⽤⼆进制压缩存储。
优点:
- RDB 是紧凑的⼆进制⽂件,⽐较适合备份,全量复制等场景
- RDB 恢复数据远快于 AOF
缺点:
- 无法实现实时或者秒级持久化
- 新老版本无法兼容RDB
实现细节
Redis有两个命令来生成RDB文件:
save(在主进程进行操作,会导致阻塞)
bgsave(fork子进程来处理)
如果RDB还在执行过程中,主进程要对某一个数据进行写操作,这个时候怎么办?如果数据变动了,那么快照的意义就失去了,如果说等RDB执行完,那么主进程就会被阻塞掉,那就会影响Redis的性能。
Redis借助操作系统的Copy-On-Write机制让主进程继续写,生成快照的子进程继续执行。具体过程是这样,如果主进程要修改一个T的键值数据,Copy-On-Write机制会先把这块数据复制一份副本为T,RDB执行的进程仍然读取T的值,主进程操作副本T
,这样主进程和子进程互不干扰。
AOF
AOF 持久化以⽇志的形式记录服务器所处理的每⼀个写、删除操作,查询操作不会记录,以⽂本的⽅式记录,可以打开⽂件看到详细的操作记录。
优点:
- 更好地保护数据不丢失
- append-only模式写入性能比较高
- 适合做灾难性的误删除紧急恢复
缺点:
- 对于同一份文件,AOF文件要比RDB快照大
- 会对QPS有所影响
- 数据库恢复慢,不适合做冷备
实现细节
AOF是在Redis执行完命令后,再去写日志。从执行完命令到写日志这里有个时间差。如果在这个时间差里出现宕机的情况,那么会丢失一部分数据,为了提高可靠性,AOF提供3种级别的日志写策略:
策略 | 介绍 | 优点 | 缺点 |
---|---|---|---|
Always | 同步写回:每个写命令执行完,立马同步地将日志写回磁盘; | 可靠性高 | 每次写入都要落盘写日志影响性能 |
EverySec | 每秒写回:每个写命令执行完,只是先把日志写到 AOF 文件的内存缓冲区,每隔一秒把缓冲区中的内容写入磁盘 | 性能适中 | 宕机的话会丢失1秒的数据 |
No | 操作系统控制的写回:每个写命令执行完,只是先把日志写到 AOF 文件的内存缓冲区,由操作系统决定何时将缓冲区内容写回磁盘 | 性能最高 | 可靠性有所下降,宕机会丢失一定的数据 |
混合持久化
重启 Redis 时,如果使用 RDB 来恢复内存状态,会丢失大量数据。而如果只使用 AOF 日志重放,那么效率又太过于低下。Redis 4.0 提供了混合持久化方案,将 RDB 文件的内容和增量的 AOF 日志文件存在一起。这里的 AOF 日志不再是全量的日志,而是自 RDB 持久化开始到持久化结束这段时间发生的增量 AOF 日志,通常这部分日志很小。
于是在 Redis 重启的时候,可以先加载 RDB 的内容,然后再重放增量 AOF 日志,就可以完全替代之前的 AOF 全量重放,重启效率因此得到大幅提升。
Redis事务
Redis 中的事务是⼀组命令的集合,是 Redis 的最⼩执⾏单位,⼀个事务要么都执⾏,要么都不执⾏;Reids 事务保证⼀个事务内的命令依次执⾏,⽽不会被其他命令插⼊;Redis 事务的原理是先将属于⼀个事务的命令发送给 Redis,然后依次执⾏这些命令。
相关命令
- discard 命令:取消事务,丢弃事务中所有命令。
- exec 命令:执⾏所有事务内的命令。
- multi 命令:标记⼀个事务开始。
- unwatch 命令:取消 watch 命令对所有 key 的监视。
watch 命令:监视⼀个(或多个)key,如果在执⾏事务之前这个(这些)key 被其他命令所改动,事务将被打断。
注意点
不⽀持回滚,如果事务中有错误的操作,⽆法回滚到处理前的状态,需要开发者处理。
在执⾏完当前事务内所有指令前,不会同时执⾏其他客户端的请求。
Redis Pipeline
Redis 客户端与服务端通信模型使⽤的 TCP 协议进⾏连接, 那么在单个指令的执⾏过程中,都 会存在“交互往返”的时间。 Redis本身提供了一些批量命令,如mget、mset,但不满足大部分命令,因此出现了Pipeline。
Pipeline将一组Redis命令进行组装,一次性传输给Redis,在讲这些命令执行结果,按照顺序返回客户端。使用场景
与批量命令的区别
批量命令保证原子性,而Pipeline非原子。
- 批量命令是一个命令对应多个key,而Pipeline支持多个命令。
- 批量命令是Redis服务端实现,Pipeline需要服务端和客户端共同实现。
三大缓存问题
缓存穿透
查询缓存中没有,数据库也没有的数据会导致缓存穿透。
解决方法:
- 布隆过滤将所有查询的参数都存储到一个bitmap中,查询之前先去bitmap里面验证,如果存在就进行底层缓存的数据查询;如果不存在就进行拦截。可以用于实现数据字典,进行数据的判重,集合求交集。
缓存空对象直接缓存一个空对象,但是会有两个问题:
分散失效时间
- DB访问限制,进行限流
- 多级缓存设计
- LRU
缓存击穿
缓存中没有,但是数据库中有的数据,这时由于并发用户多,就会造成数据库压力瞬间增大。
解决方法:
- 设置热点数据永不过期
- 加互斥锁,使写数据的只有一个线程执行: ```java static Lock reenLock = new ReentrantLock();
public List
} else {
result = getDataFromCache();// 先查一下缓存
if (result.isEmpty()) {
System.out.println("我没拿到锁,缓存也没数据,先小憩一下");
Thread.sleep(100);// 小憩一会儿
return getData04();// 重试
}
}
}
return result;
缓存更新策略
先更新数据库,再更新缓存
- 会导致线程安全问题两个线程一起更新数据,就会造成脏数据的问题。
更新缓存的复杂度相对较高因为一般存入缓存的数据都要经过一系列的计算。
先删除缓存,再更新数据库
可能会导致数据不一致的问题,比如,刚删掉缓存,另一个线程马上读取请求,缓存还是旧的。
解决方法只能是写数据成功后,再更新一次缓存。先更新数据库,再删除缓存
可能会造成短暂的数据不一致,在更新数据库成功后和删除缓存之前,会有一定的数据不一致现象,不过可以接受。
Redis为什么快
数据都存放到了内存中
- Redis是单线程的,也就意味着避免了不必要的上下文切换和竞争
-
五种IO模型
阻塞I/O模型、非阻塞I/O模型、I/O复用模型、信号驱动I/O模型、异步I/O模型。
Redis的IO多路复用
IO多路复用是指一个线程处理多个IO请求,redis的网络事件处理器为文件事件处理器,它使用IO多路复用来同时监听多个套接字,并根据套接字目前执行的任务来为套接字关联不同的事件处理器。
当上一个套接字产生的事件被处理完毕之后(该套接字为事件所关联的事件处理器执行完毕), I/O 多路复用程序才会继续向文件事件分派器传送下一个套接字。 如果是客户端要连接 redis,那么会为socket关联连接应答处理器
- 如果是客户端要写入数据到 redis,那么会为 scoket关联命令请求处理器
- 如果是客户端要从 redis读数据,那么会为socket关联命令回复处理器
热点key问题
产生原因:
- 用户消费的数据远大于生产的数据,比如双十一的热门商品的促销
- 请求分片集中,超过单Server的性能极限
导致危害:
- 流量集中,达到物理网卡上限
- 请求过多,缓存分片服务被打垮
- DB击穿,引起业务雪崩
解决方案:
- 使用Memcache+Redis
- 使用本地缓存
-
什么是分布式锁,有什么作用?
分布式锁是控制分布式系统之间同步访问共享资源的⼀种⽅式。 在单机或者单进程环境下,多线程并发的情况下,使⽤锁来保证⼀个代码块在同⼀时间内只能由⼀个线程执⾏。⽐如 Java 的
Synchronized 关键字和 Reentrantlock 类。
多进程或者分布式集群环境下,如何保证不同节点的线程同步执⾏呢? 这就是分布式锁。分布式锁有哪些实现?
Memcached 分布式锁 Memcached 提供了原⼦性操作命令 add,才能 add 成功,线程获取到锁。key 已存在的情况下,则 add 失败,获取锁也失败;
- Redis 分布式锁 Redis 的 setnx 命令为原⼦性操作命令。只有在 key 不存在的情况下,才能 set 成功。和 Memcached 的 add ⽅法⽐较类似;
- ZooKeeper 分布式锁 利⽤ ZooKeeper 的顺序临时节点,来实现分布式锁和等待队列;
- Chubby 实现分布式锁 Chubby 底层利⽤了 Paxos ⼀致性算法,实现粗粒度分布式锁服务。
Redis怎么实现分布式锁
简单⽅案
最简单的⽅法是使⽤ setnx(set if not exist) 命令。释放锁的最简单⽅式是执⾏ del指令。
问题:
锁超时:如果⼀个得到锁的线程在执⾏任务的过程中挂掉,来不及显式地释放锁,这块资源将会
永远被锁住(死锁),别的线程再也别想进来。
优化⽅案1
setnx 没办法设置超时时间,如果利⽤ expire 来设置超时时间,那么这两步操作不是原⼦性操
作。
利⽤ set 指令增加了可选参数⽅式来替代 setnx。set 指令可以设置超时时间。
但是这样仍然有问题,考虑以下场景:
- 客户端1获取锁成功。
- 客户端1在某个操作上阻塞了很长时间。
- 过期时间到了,锁自动释放了。
- 客户端2获取到了对应同一个资源的锁。
- 客户端1从阻塞中恢复过来,释放掉了客户端2持有的锁。
之后,客户端2在访问共享资源的时候,就没有锁为它提供保护了。
优化方案2
在1方案的基础上增加随机字符串,用来保证客户端释放的锁必须是自己的,由于有三个步骤,get、判断、释放,所以最好放在lua脚本中保证原子性,否则可能出现以下场景:
- 客户端1获取锁成功。
- 客户端1访问共享资源。
- 客户端1为了释放锁,先执行’GET’操作获取随机字符串的值。
- 客户端1判断随机字符串的值,与预期的值相等。
- 客户端1由于某个原因阻塞住了很长时间。
- 过期时间到了,锁自动释放了。
- 客户端2获取到了对应同一个资源的锁。
- 客户端1从阻塞中恢复过来,执行DEL操纵,释放掉了客户端2持有的锁。
但是这样仍然是有问题的,比如分布式集群场景下,主从同步不及时,加锁失败。
分布式集群场景解决方案
可以使用Redlock来解决此问题,为了取到锁,客户端应该执行以下操作:
- 获取当前Unix时间,以毫秒为单位。
- 依次尝试从5个实例,使用相同的key和具有唯一性的value(例如UUID)获取锁。当向Redis请求获取锁时,客户端应该设置一个网络连接和响应超时时间,这个超时时间应该小于锁的失效时间。例如你的锁自动失效时间为10秒,则超时时间应该在5-50毫秒之间。这样可以避免服务器端Redis已经挂掉的情况下,客户端还在死死地等待响应结果。如果服务器端没有在规定时间内响应,客户端应该尽快尝试去另外一个Redis实例请求获取锁。
- 客户端使用当前时间减去开始获取锁时间(步骤1记录的时间)就得到获取锁使用的时间。当且仅当从大多数(N/2+1,这里是3个节点)的Redis节点都取到锁,并且使用的时间小于锁失效时间时,锁才算获取成功。
- 如果取到了锁,key的真正有效时间等于有效时间减去获取锁所使用的时间(步骤3计算的结果)。
- 如果因为某些原因,获取锁失败(没有在至少N/2+1个Redis实例取到锁或者取锁时间已经超过了有效时间),客户端应该在所有的Redis实例上进行解锁(即便某些Redis实例根本就没有加锁成功,防止某些节点获取到锁但是客户端没有得到响应而导致接下来的一段时间不能被重新获取锁)。
怎么提高缓存命中率
- 节点取余分区使用特定的数据,如Redis的键或用户的ID,再根据公式:hash(key)%N,计算出hash值,用来决定数据落到那个节点。优点:简单,常用于分库分表。缺点:节点数量发生改变,映射关系需要重新计算。
- 一致性Hash分区一致性Hash算法也是使用取模的方法,节点取余分区是对服务器的数量进行取模,而一致性Hash算法是对2^32取模。简单来说,一致性Hash算法将整个哈希值空间组织成一个虚拟的圆环,如假设某哈希函数H的值空间为0-2^32-1(即哈希值是一个32位无符号整形),整个哈希环如下:整个空间按顺时针方向组织,圆环的正上方的点代表0,0点右侧的第一个点代表1,以此类推,2、3、4、5、6……直到2^32-1,也就是说0点左侧的第一个点代表2^32-1, 0和2^32-1在零点中方向重合,我们把这个由2^32个点组成的圆环称为Hash环。下一步将各个服务器使用Hash进行一个哈希,具体可以选择服务器的IP或主机名作为关键字进行哈希,这样每台机器就能确定其在哈希环上的位置。
优点:加入和删除节点只影响哈希环中相邻的节点,对其他节点没有影响。
缺点:
- 需要手动处理因为节点改变而影响的数据,因此常用于缓存场景。
- 不适合少量节点,因为它会影响大范围的节点映射。
- 普通的一致性哈希分区在增减节点时需要增加一倍或减少一半节点才能保证数据和负载均衡。
- 虚拟槽分区虚拟槽分区巧妙地使用了哈希空间,使用分散度良好的哈希函数把所有数据映射到一个固定范围的整数集合中,整数定义为槽(slot)。Redis Cluster采用虚拟槽分区,所有的键根据哈希函数映射到0-16383整数槽内。计算公式:slot = CRC16(key)&16383,每一个节点负责维护一部分槽以及槽所映射的键值数据,如图所示:
优点:
- 单节点单机器
- 主从节点
- 哨兵模式
- 集群模式
主从复制
Redis 中的主从复制,也就是 Master-Slave 模型,多个 Redis 实例间的数据同步以及 Redis 集群中数据同步会使⽤主从复制。
主从复制主要是数据同步, 数据同步分为全量同步和增量同步。
- 全量同步一般发生在Slave初始化阶段,Slave需要将Master所有数据都进行同步复制。
- 增量同步Slave正常工作时,Master节点进行的写操作都会同步到Slave节点上。
优点:可靠性提高了,实现读写分离可以提高读写效率。
缺点:主节点存在单点问题,而且需要手动设置故障转移。
哨兵模式
哨兵模式主要可以解决故障自动转移,不用人为干预。部署哨兵模式必须要有三个节点才能最大化其价值。
三个定时任务:
- 每10秒每个sentinel对master和slave执行info主要用来发现slave节点和确认主从关系。
- 每2秒每个sentinel通过master结点的channel交换信息(pub/sub)可以交互对节点的看法和自身信息。
- 每1秒每个sentinel对其他sentinel和redis执行ping用来检测故障。
优点:保证高可用,各个节点自动故障转移。
缺点:主从模式,依旧存在单点问题。
Redis Cluster
在上文介绍过,RedisCluster使用虚拟槽分配节点,即CRC16(key)&16383(一共有16384个),一般结构为三主三从。
集群自动故障转移过程分为故障发现和节点恢复。节点下线分为主观下线和客观下线,当超过半数主节点认为故障节点为主观下线时标记它为客观下线状态。从节点负责对客观下线的主节点触发故障恢复流程,保证集群的可用性。
优点:
避免了单点故障,实现高可用。
缺点:
- 使整个缓存更加复杂
- key批量操作支持有限:例如mget、mset必须在一个slot。
- key事务和Lua支持有限:操作的key必须在一个节点。
- key是数据分区的最小粒度:不支持bigkey分区。
- 不支持多个数据库:集群模式下只有一个db 0。
- 复制只支持一层:不支持树形复制结构。
实际上大多数情况下,Redis Sentinel已经能够胜任,满足业务需求。