Redis学习总结
其他文章
Redis的结构及应用场景
redis应用上基本大多数都是作为缓存。
好处:
- 为的是服务无状态:就是没有特殊状态的服务,各个请求对于服务器来说统一无差别处理,请求自身携带了所有服务端所需要的所有参数(服务端自身不存储跟请求相关的任何数据,不包括数据库存储信息) (token)
- 数据无锁化:依靠redis做一些原子性操作,比如自减一、自加一等。
Redis单线程(6.x之前)
单线程为什么还那么快?
因为它的所有数据都在内存中,所有运算都是内存级别的运算,而且单线程避免了多线程的切换性能损耗问题。正因为Redis是单线程,所以要小心使用Redis指令,对于那些耗时的指令比如(keys),谨慎使用,一不小心可能会导致Redis卡顿。
Redis单线程如何处理那么多的并发客户端连接?
Redis的IO 多路复用:redis利用epoll来实现IO多路复用,将连接信息和事件放到队列中,依次放到文件事件分派器,事件分派器将事件分发给事件处理器。
Redis是单线程还是多线程的
1、无论什么版本,工作线程只有一个。
2、6.x之前是单线程的,单线程是指Redis的网络IO和键值对读写是单线程的,这也是Redis对外提供键值存储服务的主要流程;但redis的其他功能,比如持久化、异步删除、集群数据同步等,其实是其他线程完成的。
3、6.x之后,出现了IO多线程。引入IO多线程对比单线程的好处:
* 执行时间缩短、更快
* 更好的压榨系统及硬件的资源(网卡能够高效的使用)
4、如下图 ,客户端(c1/c2)被读取的顺序不能被保障。
6.x以前:
知识点:
1、 客户端连接由内核维护,内核采用epoll的工作模型(多路复用器): 不负责数据读写,只管理读写的事件。 redis工作线程先看图中①,读取有没有事件,如果读取有事件,redis进行图中②,和内核读取数据。
2、redis的单指令操作是原子性的、pipeline(一个客户端的指令集合)是原子性的、lua脚本的方式是原子性的。
* pipeling: 客户端攒了一堆指令,一起发送,先攒后发
* 事务: 先发,在服务器端攒着,后执行
* 事务的执行时期是原子的,但是事务不是原子的: 事务执行的时候,也不会有其他指令并行执行,其他指令会等事务执行完,串行执行。
* 事务中指令失败: 如果语法性失败,清空队列,其他不执行;如果非语法性失败,失败的指令失败,其他继续执行
* redis事务不支持回滚
* 建议: 少使用事务,事务内指令尽量少、尽量快
6.x之后:
知识点:
1、redis一个worker工作进程,在redis配置中可以配置出多个IO进程。
2、多个IO进程在和内核获取数据时,可以并行获取;也就是input可以并行执行。
3、但是计算还是要在worker进程中,串行执行。
4、最后计算后的结果,在IO进程中并行输出。
6.x之后其实是通过并行获取内核数据,也就是图中内核queue到程序内存并行执行,从而提高网卡缓存到内核的吞吐量;并且并行更好的利用了cpu的多核。如下图:
Redis存在线程安全问题么?为什么?
redis内部是单线程串行的,redis虽然可以保证串行,但是在外界使用时,业务上要自行保证顺序。
String应用场景:
- 单值缓存
set key value
get key
- 对象缓存
set key:1 value(json格式)
mset key:1:name value key2:1:sex value
mget key:1:name key2:1:sex
- 分布式锁
# 返回1代表获取锁成功
setnx product:10001 true
# 返回0代表获取锁失败
setnx product:10001 true
....业务执行....
# 执行完业务释放锁
DEL product:10001
# 防止程序以外终止造成死锁
SET product:10001 true ex 10 nx
- 计数器
INCR article:readcount:id
GET article:readcount:id
-
Web集群的session共享
-
分布式系统全局系列号
# redis批量生成序列化提升性能
INCRBY orderId 1000
Hash应用场景
- 对象缓存
HMSET user 1:name v1 1:sex 0
HMGET user 1:name 1:sex
- 电商购物车
Hash与String对比
-
优点
1、同类数据归类整合存储,方便数据管理。
2、相比string操作消耗内存与cpu更小。
3、相比string存储更节省空间。 -
缺点
1、过期功能不能使用在field上,只能用在key上
2、redis集群架构下不适合大规模使用
List应用场景
-
实现常用的数据结构
Stack(栈) = LPUSH+LPOP
Queue(队列) = LPUSH+RPOP
Blocking MQ(阻塞队列) = LPUSH+BRPOP -
微博消息和微信公众号消息
Set应用场景
- 微信抽奖小程序
- 微信微博点赞、收藏、标签
- 集合操作:并集、交集、差集
集合操作实现关注模型
Zset应用场景
- Zset集合操作实现排行榜
scan渐进式遍历键
SCAN cursor [MATCH pattern] [COUNT count]
scan参数提供了三个参数,第一个是cursor整数值(hash桶索引值),第二个是key的正则模式,第三个是一次遍历的key的数量(参考值,底层遍历的数量不一定),并不是符合条件的结果数量。第一次遍历时,cursor值为0,然后将返回结果中第一个整数值作为下一次遍历的cursor。一直遍历到返回的cursor值为0时结束。
注意:但是scan并非完美无瑕,如果在scan的过程中如果有键的变化(增加、删除、修改),那么遍历效果可能会碰到如下问题:
新增的键可能没有遍历到,遍历出了重复的键等情况,也就是说scan并不能保证完整的遍历出来的所有的键。
Redis的缓存穿透与雪崩(面试高频,工作常用~)
服务的高可用问题
Redis缓存的使用,极大的提升了应用程序的性能和效率,特别是数据查询方面。但同时,它也带来了一些问题。其中,最要害的问题,就是数据一致性问题。从严格意义上讲,这个问题无解。如果对数据的一致性要求很高,那么就不能使用缓存。
另外的一些典型问题就是,缓存穿透、缓存雪崩和缓存击穿。目前,业界也都有比较流行的解决方案。
缓存穿透
概念
缓存穿透的概念很简单,用户想要查询一个数据,发现redis内存数据库没有,也就是缓存没有命中,于是向持久层数据库查询。发现也没有,于是本次查询失败。当用户很多的时候,缓存都没有命中,于是都去请求了持久层数据库。这会给持久层数据库造成很大的压力,这时候就出现了缓存穿透。
解决方案
1、布隆过滤器
布隆过滤器是一种数据结构,对所有可能查询的参数以hash形式存储,在控制层先校验,不符合则丢弃,从而避免了对底层存储系统的查询压力;
2、缓存空对象
当存储层不命中后,即使返回的空对象也将其缓存起来,同时设置一个过期时间,之后再访问这个数据将会从缓存中获取,保护了后端数据源;
但是这种方法存在两个问题:
- 如果空值能被缓存起来,这就意味着缓存需要更多的空间存储更多的键,因为这当中可能会有很多的空值的键;
- 即使对空值设置了过期时间,还是会存在缓存层和存储层的数据会有一段时间窗口的不一致,这对需要保持数据一致性的业务会有影响。
布隆过滤器
对于恶意攻击,向服务器请求大量不存在的数据造成的缓存穿透,还可以用布隆过滤器先做一次过滤,对于不 存在的数据布隆过滤器一般都能够过滤掉,不让请求再往后端发送。当布隆过滤器说某个值存在时,这个值可能不存在;当它说不存在时,那就肯定不存在。
布隆过滤器就是一个大型的位数组和几个不一样的无偏 hash 函数。所谓无偏就是能够把元素的 hash 值算得比较均匀。
向布隆过滤器中添加 key 时,会使用多个 hash 函数对 key 进行 hash 算得一个整数索引值然后对位数组长度进行取模运算得到一个位置,每个 hash 函数都会算得一个不同的位置。再把位数组的这几个位置都置为 1 就完成了 add 操作。
向布隆过滤器询问 key 是否存在时,跟 add 一样,也会把 hash 的几个位置都算出来,看看位数组中这几个位 置是否都为 1,只要有一个位为 0,那么说明布隆过滤器中这个key 不存在。如果都是 1,这并不能说明这个 key 就一定存在,只是极有可能存在,因为这些位被置为 1 可能是因为其它的 key 存在所致。如果这个位数组比较稀疏,这个概率就会很大,如果这个位数组比较拥挤,这个概率就会降低。
这种方法适用于数据命中不高、 数据相对固定、 实时性低(通常是数据集较大)的应用场景, 代码维护较为复杂, 但是缓存空间占用很少。
可以用redisson实现布隆过滤器:
引入依赖
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.6.5</version>
</dependency>
示例伪代码:
package com.redisson;
import org.redisson.Redisson;
import org.redisson.api.RBloomFilter;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
public class RedissonBloomFilter {
public static void main(String[] args) {
Config config = new Config();
config.useSingleServer().setAddress("redis://localhost:6379");
//构造Redisson
RedissonClient redisson = Redisson.create(config);
RBloomFilter<String> bloomFilter = redisson.getBloomFilter("nameList");
//初始化布隆过滤器:预计元素为100000000L,误差率为3%,根据这两个参数会计算出底层的bit数组大小
bloomFilter.tryInit(100000000L,0.03);
//将cheng插入到布隆过滤器中
bloomFilter.add("cheng");
//判断下面号码是否在布隆过滤器中
System.out.println(bloomFilter.contains("zhang"));//false
System.out.println(bloomFilter.contains("jin"));//false
System.out.println(bloomFilter.contains("cheng"));//true
}
}
使用布隆过滤器需要把所有数据提前放入布隆过滤器,并且在增加数据时也要往布隆过滤器里放,布隆过滤器缓存过滤伪代码:
//初始化布隆过滤器
RBloomFilter<String> bloomFilter = redisson.getBloomFilter("nameList");
//初始化布隆过滤器:预计元素为100000000L,误差率为3%
bloomFilter.tryInit(100000000L,0.03);
//把所有数据存入布隆过滤器
void init(){
for (String key: keys) {
bloomFilter.put(key);
}
}
String get(String key) {
// 从布隆过滤器这一级缓存判断下key是否存在
Boolean exist = bloomFilter.contains(key);
if(!exist){
return "";
}
// 从缓存中获取数据
String cacheValue = cache.get(key);
// 缓存为空
if (StringUtils.isBlank(cacheValue)) {
// 从存储中获取
String storageValue = storage.get(key);
cache.set(key, storageValue);
// 如果存储数据为空, 需要设置一个过期时间(300秒)
if (storageValue == null) {
cache.expire(key, 60 * 5);
}
return storageValue;
} else {
// 缓存非空
return cacheValue;
}
}
注意:布隆过滤器不能删除数据,如果要删除得重新初始化数据。
缓存击穿
概念
这里需要注意和缓存穿透的区别。缓存击穿,是指一个key非常热点,在不停的扛着大并发,大并发集中对这一个点进行访问,当这个key在失效的瞬间,持续的大并发就穿破缓存,直接请求数据库,就像在屏障上凿开了一个洞。
当某个key在过期的瞬间,有大量的请求并发访问,这类数据一般是热点数据,由于缓存过期,会同时访问数据库来查询最新数据,并且回写缓存,会导致数据库压力瞬间变大。
解决方案
1、设置热点数据不过期
从缓存层面来看,没有设置过期时间,所以不会出现热点key过期后产生的问题。
2、加互斥锁
分布式锁:使用分布式锁,保证对于每个key同时只有一个线程去查询后端服务,其他线程没有获得分布式锁的权限,因此只需要等待即可。这种方式将高并发的压力转移到了分布式锁,因此对分布式锁考验很大。
缓存雪崩
概念
缓存雪崩,是指在某一时间段,缓存集中过期失效。Redis宕机!
产生雪崩的原因之一,比如零点抢购,商品时间集中放入缓存,假设缓存一小时。那么1点的时候,大量缓存集体过期,对于这批商品的访问查询,都落到了数据库。对于数据库而言,就会产生周期性的压力波峰,于是所有请求都会到达存储层,存储层调用会暴增,造成存储层也会挂掉的情况。
其实集中过期,倒不是非常致命,比较致命的缓存雪崩,是缓存服务器某个节点宕机或断网。因此自然形成的缓存雪崩,一定是在某一时间段集中创建缓存,这个时候,数据库也是可以顶住压力的。无非就是对数据库产生周期性的压力而已。而缓存服务节点的宕机,对数据库服务器造成的压力是不可预知的,很有可能瞬间就把数据库压垮。
解决方案
1、Redis高可用
这个思想的含义是,既然Redis也有可能挂掉,那多增加几台redis服务器,这样一台挂了还有其他的可以继续工作,其实就是搭建集群。
2、限流降级
这个解决方案的思想是,在缓存失效后,通过加锁或者队列来控制读取数据库写缓存的线程数量。比如对某个key只允许一个线程查询数据和写缓存,其他线程等待。
3、数据预热
数据加热的含义就是在正式部署之前,先把可能的数据先访问一遍,这样部分可能大量访问的数据就会加载到缓存中。在即将发生大并发访问前手动触发加载缓存不同的key,设置不同的过期时间,让缓存失效的时间尽量均匀。
Redis的过期键的删除策略
- 惰性过期: 只有当访问一个key时,才会判断key是否已过期,过期则清除。该策略可以最大化节省CPU资源,却对内存非常不友好。极端情况可能出现大量过期key没有再次被访问,从而不会被清除,占用大量内存。
- 定时过期: 给每个设置超时时间的key设置一个定时器,时间到了则删除,这种方式可以节约内存,但是对CPU资源不太友好。
- 定期过期: 每隔一定的时间,会扫描一定数量的数据库的expires字典中一定数量的key,并清除其中已经过期的key。该策略是前两者的一个这种方案。通过调整定时扫描的时间间隔和每次扫描的限定耗时,可以在不同情况下使得CPU和内存资源达到最优的平衡效果。
(expires字典会保存所有设置了过期时间的key的过期时间数据,其中,key是指向键空间中的某个键的指针,value是该键的毫秒精度的UNIX时间戳表示的过期时间。键空间是指该Redis集群中保存的所有键。)
Redis中同时使用了惰性过期和定期过期两种过期策略。
Redis与数据库的双写一致性问题解决方案
首先一致性又分为:
- 强一致性: 任何一次读都能读到某个数据的最近一次写的数据
- 弱一致性: 数据更新后,如果能容忍后续的访问只能访问部分或者全部访问不到,则是弱一致性
解决双写一致性方案:
- 延迟双删:
- 延迟双删策略是分布式系统中数据库存储和缓存数据保持一致性的常用策略,但它不是强一致性。
- 实现思路:先删除缓存然后更新DB在最后延迟N秒后再去执行一次缓存删除。
- 弊端:小概率会出现不一致情况、耦合程度高。
- 通过MQ进行重试删除:
- 更新完DB后进行缓存删除,如果删除失败则向MQ发送一条消息,然后消费者不断进行删除尝试。
- binlog异步删除:
- 实现思路: 低耦合的解决方案是使用canal。canal伪装成mysql从机,监听主句mysql的二进制日志文件,当数据发生变化时,发送给MQ。最终消费进行删除。
管道(Pipeline)与Lua脚本
管道(Pipeline):
管道示例:
Pipeline pl = jedis.pipelined();
for (int i = 0; i < 10; i++) {
pl.incr("pipelineKey");
pl.set("k" + i, "v"+i);
//模拟管道报错
// pl.setbit("k", ‐1, true);
}
List<Object> results = pl.syncAndReturnAll();
System.out.println(results);
概念
命令在客户端积攒,客户端打包将所有命令一次性发送后,再一次性读取服务器端响应。可以大大降低网络传输开销。
需要注意的是,用pipeling打包命令发送,redis必须处理完所有命令前先缓存起所有命令的处理结果。 打包的命令越多,缓存消耗的内存也越多。所以并不是打包的越多越好。
Pipeling不能保证原子性,而可以防止并发安全问题,pipeline是在客户端将命令打包,一起发送至服务端串行执行。pipeline中发送的每个command都会被server立即执行,如果执行失败,将会在此后的响应中得到信息;也就是pipeline并不是表达“所有command都一起成功”的语义,管道中前面命令失败,后面命令不会有影响,继续执行。
Lua脚本
Lua脚本示例
//初始化商品10016的库存
jedis.set("product_stock_10016", "15");
String script = " local count = redis.call('get', KEYS[1]) " +
" local a = tonumber(count) " +
" local b = tonumber(ARGV[1]) " +
" if a >= b then " +
" redis.call('set', KEYS[1], a‐b) " +
" return 1 " +
" end " +
" return 0 ";
Object obj = jedis.eval(script,
Arrays.asList("product_stock_10016"), Arrays.asList("10"));
System.out.println(obj);
概念
Redis在2.6推出了脚本功能,允许开发者使用Lua语言编写脚本传到Redis中执行。使用脚本的好处如下:
1、减少网络开销:本来多次网络请求的操作,可以用一个请求完成,原先多次请求的逻辑放在redis服务器 上完成。使用脚本,减少了网络往返时延。这点跟管道类似。
2、原子操作:Redis会将整个脚本作为一个整体执行,中间不会被其他命令插入。管道不是原子的,不过 redis的批量操作命令(类似mset)是原子的。
3、替代redis的事务功能:redis自带的事务功能很鸡肋,而redis的lua脚本几乎实现了常规的事务功能, 官方推荐如果要使用redis的事务功能可以用redis lua替代。
从Redis2.6.0版本开始,通过内置的Lua解释器,可以使用EVAL命令对Lua脚本进行求值。EVAL命令的格式如下:
EVAL script numkeys key [key ...] arg [arg ...]
script参数是一段Lua脚本程序,它会被运行在Redis服务器上下文中,这段脚本不必(也不应该)定义为一 个Lua函数。numkeys参数用于指定键名参数的个数。键名参数 key [key …] 从EVAL的第三个参数开始算 起,表示在脚本中所用到的那些Redis键(key),这些键名参数可以在 Lua中通过全局变量KEYS数组,用1为基址的形式访问( KEYS[1] , KEYS[2] ,以此类推)。 在命令的最后,那些不是键名参数的附加参数 arg [arg …] ,可以在Lua中通过全局变量ARGV数组访问, 访问的形式和KEYS变量类似( ARGV[1] 、 ARGV[2] ,诸如此类)。
在 Lua 脚本中,可以使用 redis.call() 函数来执行Redis命令。
注意,不要在Lua脚本中出现死循环和耗时的运算,否则redis会阻塞,将不接受其他的命令, 所以使用时要注意不能出现死循环、耗时的运算。redis是单进程、单线程执行脚本。管道不会阻塞redis。