二、常用数据类型
2.1 (key)键的常用操作命令
- keys *查看当前库所有key (匹配:keys *1),keys可用来模糊匹配查询键
- exists key判断某个key是否存在
- type key 查看你的key是什么类型
- del key 删除指定的key数据
- unlink key 根据value选择非阻塞删除
- 仅将keys从keyspace元数据中删除,真正的删除会在后续异步操作。
- expire key 10 10秒钟:为给定的key设置过期时间
- ttl key 查看还有多少秒过期,-1表示永不过期,-2表示已过期
- select命令切换数据库
- dbsize 查看当前数据库的key的数量
- flushdb 清空当前库
- flushall 通杀全部库
2.2 (String)字符串类型常用操作
String类型是二进制安全的。意味着Redis的string可以包含任何数据。比如jpg图片或者序列化的对象。String类型是Redis最基本的数据类型,一个Redis中字符串value最多可以是512M
-
set key value [EX seconds|PX milliseconds|EXAT timestamp|PXAT milliseconds-timestamp|KEEPTTL] [NX|XX] [GET]
*NX:当数据库中key不存在时,可以将key-value添加数据库
*XX:当数据库中key存在时,可以将key-value添加数据库,与NX参数互斥
*EX:key的超时秒数
*PX:key的超时毫秒数,与EX互斥
-
eg:set key1 v1 EX 100 NX 设置键值对,100秒过期,当库不存在时生效
-
get 查询对应键值
-
append 将给定的 追加到原值的末尾
-
strlen 获得值的长度
-
setnx 只有在 key 不存在时 设置 key 的值
-
incr 将 key 中储存的数字值增1,只能对数字值操作,如果为空,新增值为1
-
decr 将 key 中储存的数字值减1,只能对数字值操作,如果为空,新增值为-1
-
incrby / decrby <步长>将 key 中储存的数字值增减。自定义步长。
-
mset … 同时设置一个或多个 key-value对
-
mget …同时获取一个或多个 value
-
msetnx … 同时设置一个或多个 key-value 对,当且仅当所有给定 key 都不存在。原子性,有一个失败则都失败
-
getrange <起始位置><结束位置>获得值的范围,类似java中的substring,前包,后包
-
setrange <起始位置> 用 覆写所储存的字符串值,从<起始位置>开始(索引从0****开始)。
-
setex <** 过期时间** **>**设置键值的同时,设置过期时间,单位秒。
-
getset 以新换旧,设置了新值同时获得旧值。
2.3(List)列表类型常用操作
Redis 列表是简单的字符串列表,按照插入顺序排序。可以添加一个元素到列表的头部(左边)或者尾部(右边)。
它的底层实际是个双向链表,对两端的操作性能很高,通过索引下标的操作中间的节点性能会较差。
- lpush/rpush … 从左边/右边插入一个或多个值。
- lpop/rpop 从左边/右边吐出一个值。类似栈,弹出后在list中该元素就不存在了。值在键在,值光键亡。
- rpoplpush 从列表右边吐出一个值,插到列表左边。
- lrange
按照索引下标获得元素(从左到右) ,索引两边界都包含 - lrange mylist 0 -1 0左边第一个,-1右边第一个,(0-1表示获取所有)
- lindex 按照索引下标获得元素(从左到右)
- llen 获得列表长度
- linsert before 在的后面插入插入值
- lrem 从左边删除n个value(从左到右)
- lset将列表key下标为index的值替换成value
list的底层数据结构
list类型3.0之前用的是ziplist(压缩链表)实现,3.0以后用quickList。6.0以后用 listpack
详见(https://baijiahao.baidu.com/s?id=1717927930457604251&wfr=spider&for=pc)
2.4 (Set) 集合类型常用操作
Redis set对外提供的功能与list类似是一个列表的功能,特殊之处在于set是可以自动排重的,当你需要存储一个列表数据,又不希望出现重复数据时,set是一个很好的选择,并且set提供了判断某个成员是否在一个set集合内的重要接口,这个也是list所不能提供的。Redis的Set是string类型的无序集合。它底层其实是一个value为null的hash表,所以添加,删除,查找的复杂度都是****O(1)。一个算法,随着数据的增加,执行时间的长短,如果是O(1),数据增加,查找数据的时间不变
- sadd … 将一个或多个 member 元素加入到集合 key 中,已经存在的 member 元素将被忽略
- smembers 取出该集合的所有值。
- sismember 判断集合是否为含有该值,有1,没有0
- scard返回该集合的元素个数。
- srem … 删除集合中的某个元素。
- spop 随机从该集合中吐出一个值。取出后值就不再存在于集合中了
- srandmember 随机从该集合中取出n个值。不会从集合中删除 。
- smove value 把集合中一个值从一个集合移动到另一个集合 eg:smove key1 key2 value1 (将值value1从集合key1移动到key2)
- sinter 返回两个集合的交集元素。
- sunion 返回两个集合的并集元素。
- sdiff 返回两个集合的差集元素(key1中的,不包含key2中的)
set的底层数据结构用的是哈希表,和Java中set类似
2.5 (Hash)哈希类型常用操作
Redis hash是一个string类型的field和value的映射表,hash特别适合用于存储对象,类似Java里面的Map<String,Object>
- hset 给集合中的 键赋值
- hget 从集合取出 value
- hmset … 批量设置hash的值
- hexists查看哈希表 key 中,给定域 field 是否存在。
- hkeys 列出该hash集合的所有field。
- hvals 列出该hash集合的所有value。
- hincrby 为哈希表 key 中的域 field 的值加上增量 1 -1。
- hsetnx 将哈希表 key 中的域 field 的值设置为 value ,当且仅当域 field 不存在 。
- hgetall 获得所有的键键值对
Hash类型对应的数据结构是两种:ziplist(压缩列表),hashtable(哈希表)。当field-value长度较短且个数较少时,使用ziplist,否则使用hashtable。
2.6 (zset) 有序集合
Redis有序集合zset与普通集合set非常相似,是一个没有重复元素的字符串集合。
不同之处是有序集合的每个成员都关联了一个评分(score),这个评分(score)被用来按照从最低分到最高分的方式排序集合中的成员。集合的成员是唯一的,但是评分可以是重复了 。
因为元素是有序的, 所以你也可以很快的根据评分(score)或者次序(position)来获取一个范围的元素。
访问有序集合的中间元素也是非常快的,因此你能够使用有序集合作为一个没有重复成员的智能列表。
- zadd …将一个或多个 member 元素及其 score 值加入到有序集 key 当中。
- zrange
[WITHSCORES] 返回有序集 key 中,下标在(start,stop]之间的元素,带WITHSCORES,可以让分数一起和值返回到结果集。 - zrangebyscore key minmax [withscores] [limit offset count]返回有序集 key 中,所有 score 值介于 min 和 max 之间(包括等于 min 或 max )的成员。有序集成员按 score 值递增(从小到大)次序排列。
- zrevrangebyscore key maxmin [withscores] [limit offset count] 同上,改为从大到小排列。
- zincrby 为元素的score加上增量
- zrem 删除该集合下,指定值的元素
- zcount 统计该集合,分数区间内的元素个数
- zrank 返回该值在集合中的排名,从0开始。
zset底层使用了两个数据结构
(1)hashtable,hashtable的作用就是关联元素value和权重score,保障元素value的唯一性,可以通过元素value找到相应的score值。
(2)跳跃表,跳跃表的目的在于给元素value排序,根据score的范围获取元素列表。
这两个数据结构一个是跳表,一个是哈希表。这样的好处是既能进行高效的范围查询,也能进行高效单点查询。
跳表结构设计
从跳表中查出51的节点,步骤如下:
从第2层开始,1节点比51节点小,向后比较。
21节点比51节点小,继续向后比较,后面就是NULL了,所以从21节点向下到第1层
在第1层,41节点比51节点小,继续向后,61节点比51节点大,所以从41向下
在第0层,51节点为要查找的节点,节点被找到,共查找4次。
从此可以看出跳跃表比有序链表效率要高
2.7 (GEO)地理坐标类型
GEO就是Geolocation的简写形式,代表地理坐标。Redis在3.2版本中加入了对GEO的支持,允许存储地理坐标信息,帮助我们根据经纬度来检索数据。常见的命令有:
- GEOADD:添加一个地理空间信息,包含:经度(longitude)、纬度(latitude)、值(member)
- GEODIST:计算指定的两个点之间的距离并返回
- GEOHASH:将指定member的坐标转为hash字符串形式并返回
- GEOPOS:返回指定member的坐标
- GEORADIUS:指定圆心、半径,找到该圆内包含的所有member,并按照与圆心之间的距离排序后返回。6.2以后已废弃
- GEOSEARCH:在指定范围内搜索member,并按照与指定点之间的距离排序后返回。范围可以是圆形或矩形。6.2.新功能
- GEOSEARCHSTORE:与GEOSEARCH功能一致,不过可以把结果存储到一个指定的key。 6.2.新功能
geo实际上使用过zset类型来保存数据的,坐标信息经过一定的算法保存在score
2.8 (BitMap)位图
bitmap也叫位图,也就是用一个bit位来表示一个东西的状态,我们都知道bit位是二进制,所以只有两种状态,0和1。bitmap的出现就是为了大数据量而来的,但是前提是统计的这个大数据量每个的状态只能有两种,因为每一个bit位只能表示两种状态(0和1)。
Redis中是利用string类型数据结构(用0和1两种字符组成的字符串)实现BitMap,因此最大上限是512M,转换为bit则是 2^32个bit位。
BitMap的操作命令有:
- SETBIT:向指定位置(offset)存入一个0或1
- GETBIT :获取指定位置(offset)的bit值
- BITCOUNT :统计BitMap中值为1的bit位的数量
- BITFIELD :操作(查询、修改、自增)BitMap中bit数组中的指定位置(offset)的值
- BITFIELD_RO :获取BitMap中bit数组,并以十进制形式返回
- BITOP :将多个BitMap的结果做位运算(与 、或、异或)
- BITPOS :查找bit数组中指定范围内第一个0或1出现的位置
2.9 ( HyperLogLog)基数统计
首先搞懂两个概念:
UV:全称U**nique **Visitor,也叫独立访客量,是指通过互联网访问、浏览这个网页的自然人。1天内同一个用户多次访问该网站,只记录1次。
PV:全称P**age **View,也叫页面访问量或点击量,用户每访问网站的一个页面,记录1次PV,用户多次打开页面,则记录多次PV。往往用来衡量网站的流量。
UV统计在服务端做会比较麻烦,因为要判断该用户是否已经统计过了,需要将统计过的用户信息保存。但是如果每个访问的用户都保存到Redis中,数据量会非常恐怖。
Hyperloglog(HLL)是从Loglog算法派生的概率算法,用于确定非常大的集合的基数,而不需要存储其所有值。相关算法原理可以参考:https://juejin.cn/post/6844903785744056333#heading-0
Redis中的HLL是基于string结构实现的,单个HLL的内存永远小于16kb,内存占用低的令人发指!作为代价,其测量结果是概率性的,有小于0.81%的误差。不过对于UV统计来说,这完全可以忽略。
- PFADD key element …添加指定元素到 HyperLogLog 中。
- PFCOUNT key … 返回给定 HyperLogLog 的基数估算值。
- PFMERGE destkey sourcekey [sourcekey …]将多个 HyperLogLog 合并为一个 HyperLogLog
三、Redis的Java客户端
3.1 Jedis
3.1.1 Jedis使用的基本步骤:
Jedis使用的基本步骤:
- 引入依赖
- 创建Jedis对象,建立连接
- 使用Jedis,方法名与Redis命令一致
- 释放资源
//jedis依赖
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
<version>4.2.0</version>
</dependency>
//建立连接
Jedis jedis= new Jedis("172.168.0.100",6379);
//密码
jedis.auth("root");
//选择库
jedis.select(0);
//操作
jedis.set("name","guojing");
String name = jedis.get("name");
System.out.println(name);
//释放连接
jedis.close();
3.1.2 Jedis连接池
public class JedisConnectionFactory {
private static final JedisPool JEDIS_POOL;
static {
JedisPoolConfig config = new JedisPoolConfig();
//最大连接数
config.setMaxTotal(10);
//最大空闲数
config.setMaxIdle(10);
//最小空闲数
config.setMinIdle(0);
//最大等待时间
config.setMaxWaitMillis(200);
config.setMinEvictableIdleTimeMillis(200);
JEDIS_POOL = new JedisPool(config,"172.168.0.100",6379,1000,"root");
}
//静态方法返回jedis实例
public static Jedis getJedis(){
return JEDIS_POOL.getResource();
}
}
3.2 redisTemplate
SpringDataRedis中提供了RedisTemplate工具类,其中封装了各种对Redis的操作。并且将不同数据类型的操作API封装到了不同的类型中:
API | 返回值类型 | 说明 |
---|---|---|
redisTemplate.opsForValue() | ValueOperations | 操作String类型数据 |
redisTemplate.opsForHash() | HashOperations | 操作Hash类型数据 |
redisTemplate.opsForList() | ListOperations | 操作List类型数据 |
redisTemplate.opsForSet() | SetOperations | 操作Set类型数据 |
redisTemplate.opsForZSet() | ZSetOperations | 操作SortedSet类型数据 |
redisTemplate | 通用的命令 |
3.2.1 redis使用fastjson序列化
package com.guo.redis.config;
import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import com.fasterxml.jackson.annotation.PropertyAccessor;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.jsontype.impl.LaissezFaireSubTypeValidator;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.StringRedisSerializer;
/**
* @author guozhijiang
* @version 1.0
* @description: TODO
* @date 2022/5/11 19:04
*/
@Configuration
@EnableCaching
public class RedisConfig {
@Bean
public RedisTemplate<Object,Object> redisTemplate(RedisConnectionFactory connectionFactory){
RedisTemplate<Object,Object> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(connectionFactory);
FastJson2JsonRedisSerializer serializer = new FastJson2JsonRedisSerializer(Object.class);
ObjectMapper mapper = new ObjectMapper();
mapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
mapper.activateDefaultTyping(LaissezFaireSubTypeValidator.instance, ObjectMapper.DefaultTyping.NON_FINAL, JsonTypeInfo.As.PROPERTY);
serializer.setObjectMapper(mapper);
//hash key 序列化
redisTemplate.setHashKeySerializer(new StringRedisSerializer());
//hash value 序列化
redisTemplate.setHashValueSerializer(serializer);
//value 序列化
redisTemplate.setValueSerializer(serializer);
// 使用StringRedisSerializer来序列化和反序列化redis的key值
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.afterPropertiesSet();
return redisTemplate;
}
}
package com.guo.redis.config;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.parser.ParserConfig;
import com.alibaba.fastjson.serializer.SerializerFeature;
import com.fasterxml.jackson.databind.JavaType;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.type.TypeFactory;
import org.springframework.data.redis.serializer.RedisSerializer;
import org.springframework.data.redis.serializer.SerializationException;
import org.springframework.util.Assert;
import java.nio.charset.Charset;
/**
* Redis使用FastJson序列化
*
* @author guo
*/
public class FastJson2JsonRedisSerializer<T> implements RedisSerializer<T>
{
@SuppressWarnings("unused")
private ObjectMapper objectMapper = new ObjectMapper();
public static final Charset DEFAULT_CHARSET = Charset.forName("UTF-8");
private Class<T> clazz;
static
{
ParserConfig.getGlobalInstance().setAutoTypeSupport(true);
}
public FastJson2JsonRedisSerializer(Class<T> clazz)
{
super();
this.clazz = clazz;
}
@Override
public byte[] serialize(T t) throws SerializationException
{
if (t == null)
{
return new byte[0];
}
return JSON.toJSONString(t, SerializerFeature.WriteClassName).getBytes(DEFAULT_CHARSET);
}
@Override
public T deserialize(byte[] bytes) throws SerializationException
{
if (bytes == null || bytes.length <= 0)
{
return null;
}
String str = new String(bytes, DEFAULT_CHARSET);
return JSON.parseObject(str, clazz);
}
public void setObjectMapper(ObjectMapper objectMapper)
{
Assert.notNull(objectMapper, "'objectMapper' must not be null");
this.objectMapper = objectMapper;
}
protected JavaType getJavaType(Class<?> clazz)
{
return TypeFactory.defaultInstance().constructType(clazz);
}
}
redisTemplate使用json序列化之后,可实现自动序列化和反序列化,缺点就是再序列化时将类型也一并保存到了redis中,占用了额外的内存空间;
#序列化后的json串,将Java对象类型也保存在了内存中
{
"@type": "com.guo.redis.pojo.User",
"age": 45,
"name": "郭靖"
}
3.2.2 stringRedisTemplate
为了节省内存空间,我们有时候不会使用JSON序列化器来处理value,而是统一使用String序列化器,要求只能存储String类型的key和value。当需要存储Java对象时,手动完成对象的序列化和反序列化。
Spring默认提供了一个StringRedisTemplate类,它的key和value的序列化方式默认就是String方式。省去了我们自定义RedisTemplate的过程,我们只需要手动实现序列化和反序列化只可
User u = new User("洪七公", 99);
//序列化
String string = JSON.toJSONString(u);
stringRedisTemplate.opsForValue().set("stringUser",string);
String stringUser = stringRedisTemplate.opsForValue().get("stringUser");
//反序列化
User user1 = JSON.parseObject(stringUser, User.class);
System.out.println(user1);
四、数据持久化
4.1 RDB
RDB全称Redis Database Backup file(Redis数据备份文件),也被叫做Redis数据快照。简单来说就是把内存中的所有数据都记录到磁盘中。当Redis实例故障重启后,从磁盘读取快照文件,恢复数据。快照文件称为RDB文件,默认是保存在当前运行目录。
redis创建rdb文件有两种方式:
①手动执行SAVE(会阻塞主进程,不建议使用)或BGSAVE时;
②redis服务停止时候默认持久化rdb;
③满足配置条件进行rdb持久化时,执行bgsave;
# 900秒内,如果至少有1个key被修改,则执行bgsave ,如果是save "" 则表示禁用RDB
save 900 1
save 300 10
save 60 10000
# 是否压缩 ,建议不开启,压缩也会消耗cpu,磁盘的话不值钱
rdbcompression yes
# RDB文件名称
dbfilename dump.rdb
#当rdb进程失败时停止写入,默认为yes
stop-writes-on-bgsave-error yes
# 文件保存的路径目录
dir ./
rdb恢复:将备份文件复制到redis的启动目录下(执行redis-server的目录),然后重启redis即可恢复数据;
bgsave执行流程:
- Redis父进程首先判断:当前是否在执行save,或bgsave/bgrewriteaof(后面会详细介绍该命令)的子进程,如果在执行则bgsave命令直接返回;bgsave/bgrewriteaof 的子进程不能同时执行,主要是基于性能方面的考虑:两个并发的子进程同时执行大量的磁盘写操作,可能引起严重的性能问题;
- 父进程执行fork操作创建子进程,这个过程中父进程是阻塞的,Redis不能执行来自客户端的任何命令;
- 父进程fork后,bgsave命令返回”Background saving started”信息并不再阻塞父进程,并可以响应其他命令;
- 子进程创建RDB文件,根据父进程内存快照生成临时快照文件,完成后对原有文件进行原子替换;
- 子进程发送信号给父进程表示完成,父进程更新统计信息;
fork采用的是copy-on-write技术:
当主进程执行读操作时,访问共享内存;
当主进程执行写操作时,则会拷贝一份数据,执行写操作;
4.2 AOF
AOF全称为Append Only File(追加文件)。Redis处理的每一个写命令都会记录在AOF文件,可以看做是命令日志文件;
#aof文件样式
$3 #命令长度
set #命令
$3
num
$2
123
4.2.1 AOF相关配置
# 是否开启AOF功能,默认是no
appendonly yes
# AOF文件的名称
appendfilename "appendonly.aof"
# 表示每执行一次写命令,立即记录到AOF文件
appendfsync always
# 写命令执行完先放入AOF缓冲区,然后表示每隔1秒将缓冲区数据写到AOF文件,是默认方案
appendfsync everysec
# 写命令执行完先放入AOF缓冲区,由操作系统决定何时将缓冲区内容写回磁盘
appendfsync no
AOF写入磁盘策略比较
配置项 | 刷盘时机 | 优点 | 缺点 |
---|---|---|---|
Always | 同步刷盘 | 可靠性高,几乎不丢数据 | 性能影响大 |
everysec | 每秒刷盘 | 性能适中 | 最多丢失1秒数据 |
no | 操作系统控制 | 性能最好 | 可靠性较差,可能丢失大量数据 |
4.2.2 bgrewriteaof(重写aof文件)
因为aof文件是记录命令,AOF文件会比RDB文件大的多。而且AOF会记录对同一个key的多次写操作,但只有最后一次写操作才有意义。通过执行bgrewriteaof命令,可以让AOF文件执行重写功能,用最少的命令达到相同效果。
除了手动执行bgrewriteaof命令来重写aof文件,Redis也会在触发阈值时自动去重写AOF文件。阈值也可以在redis.conf中配置:
# AOF文件比上次文件 增长超过多少百分比则触发重写
auto-aof-rewrite-percentage 100
# AOF文件体积最小多大以上才触发重写
auto-aof-rewrite-min-size 64mb
4.3 RDB和AOF比较
RDB和AOF各有自己的优缺点,如果对数据安全性要求较高,在实际开发中往往会结合两者来使用。
RDB | AOF | |
---|---|---|
持久化方式 | 定时对整个内存做快照 | 记录每一次执行的命令 |
数据完整性 | 不完整,两次备份之间会丢失 | 相对完整,取决于刷盘策略 |
文件大小 | 会有压缩,文件体积小 | 记录命令,文件体积很大 |
宕机恢复速度 | 很快 | 慢 |
数据恢复优先级 | 低,因为数据完整性不如AOF | 高,因为数据完整性更高 |
系统资源占用 | 高,大量CPU和内存消耗 | 低,主要是磁盘IO资源 但AOF重写时会占用大量CPU和内存资源 |
使用场景 | 可以容忍数分钟的数据丢失,追求更快的启动速度 | 对数据安全性要求较高常见 |
五、主从集群
5.1 搭建主从集群
假设master(127.0.0.1 7001) slave1(127.0.0.1 7002) slave2(127.0.0.1 7003)
主从配置都在从节点进行配置;
有临时和永久两种模式:
-
修改slave配置文件(永久生效),对所有的从节点都进行如下配置即可;
-
在redis.conf中添加一行配置:slaveof 或者 replicaof ,若master有密码,还需配置密码 masterauth
#replicaof <masterip> <masterport> replicaof 127.0.0.1 7001 masterauth root #其他相关配置 #redis 2.8.18开始支持无磁盘复制,直接发送RDB文件给副本节点,不需要保存到磁盘再发送。当网络带宽充足时可用此项提高效率 repl-diskless-sync no #默认为no
-
-
使用redis-cli客户端连接到从节点redis服务,执行slaveof命令(重启后失效):
replicaof 127.0.0.1 7001 slaveof 127.0.0.1 7001
5.2 数据同步原理
5.2.1 全量同步
主从第一次同步为全量同步
repl_baklog想相关的连个概念:
•Replication Id:简称replid,是数据集的标记,id一致则说明是同一数据集。每一个master都有唯一的replid,slave则会继承master节点的replid
•offset:偏移量,随着记录在repl_baklog中的数据增多而逐渐增大。slave完成同步时也会记录当前同步的offset。如果slave的offset小于master的offset,说明slave数据落后于master,需要更新。
因此slave做数据同步,必须向master声明自己的replication id 和offset,master才可以判断到底需要同步哪些数据
全量同步的流程?
- slave节点请求增量同步
- master节点判断replid,发现不一致,拒绝增量同步,并将master的信息(包含replid发送给slave,slave保存起来)
- master将完整内存数据生成RDB,发送RDB到slave
- slave清空本地数据,加载master的RDB
- master将RDB期间的命令记录在repl_baklog,并持续将log中的命令发送给slave
- slave执行接收到的命令,保持与master之间的同步
5.2.2 增量同步流程:
当主从完成第一次全量同步后,master和slave拥有相同的replid,以后,每当master执行一条写命令,都会将命令记录在repl_baklog中,对应的offset递增。当slave收到master的同步命令时。对应的slave的offest也递增。如果slave宕机重启后,此时master的offset会大于slave的offset。slave会发送自己的replid和offest请求增量同步,master会将slave的offset和自己offset差值对应的repl_baklog中的命令发送slave,实现增量同步;
**注意:**repl_baklog类似于一个线性的环形队列。repl_baklog大小有上限,写满后会覆盖最早的数据。如果slave断开时间过久,导致尚未备份的数据被覆盖,则无法基于log做增量同步,只能再次全量同步。
说明:当slave宕机后,slave 在正常关闭前会调用 rdbSaveInfoAuxFields 函数把当前的复制 ID(replid) 和复制偏移量(master_repl_offset)作为辅助字段保存到 RDB 文件中,后面该 slave 重启的时候,就可以从 RDB 文件中读取复制 ID 和复制偏移量,然后使用这两个变量来进行增量同步。
但当master宕机后,再重启会生成新的replid。(虽然master的rdb中也备份了replid,但是重启后还是会生成新的replid,并不是用之前的replid。我认为是replid和offset是跟repl_baklog对对应,当master宕机后repl_backlog不复存在了,就算replid保持不变也没法进行增量同步了,所以会生成新的replid。只是推断,还未找到答案)所以,master重启会进行全量备份。特别注意,如果master没有持久化,重启后没有数据,对应的从库也会跟着被清空。所以建议主从一定要做持久化;
5.2.3 主从情况下,key过期的处理方式
- 副本不过期key,而是等待master来过期key,当master一个key过期时或者是被LRU算法淘汰,master会发送一个
DEL
命令到所有的副本。 - 因为有时master不能及时的传输
DEL
过来,所以副本还是会存在逻辑上已经过期的key。为了解决这个问题,副本仅在读取操作时使用逻辑时钟来报告key不存在,这样就可以避免一个过期的key仍然存在可读取。 - Lua脚步执行期间不执行任何key过期操作。
5.2.4 常见问题
1.Redis主从集群优化:
•在master中配置repl-diskless-sync yes启用无磁盘复制,避免全量同步时的磁盘IO。
•Redis单节点上的内存占用不要太大,减少RDB导致的过多磁盘IO
•适当提高repl_baklog的大小,发现slave宕机时尽快实现故障恢复,尽可能避免全量同步
•限制一个master上的slave节点数量,如果实在是太多slave,则可以采用主-从-从链式结构,减少master压力简述全量同步和增量同步区别?
2.简述全量同步和增量同步区别?
•全量同步:master将完整内存数据生成RDB,发送RDB到slave。后续命令则记录在repl_baklog,逐个发送给slave。
•增量同步:slave提交自己的offset到master,master获取repl_baklog中从offset之后的命令给slave
3.什么时候执行全量同步?(replid不一致,repl_baklog满)
•slave节点第一次连接master节点时
•slave节点断开时间太久,repl_baklog中的offset已经被覆盖时
4.什么时候执行增量同步?(replid一致,slave 的offset未被覆盖)
•slave节点断开又恢复,并且在repl_baklog中能找到offset时
六、redis哨兵(sentinel)
6.1 sentinel的作用
•监控:Sentinel 会不断检查您的master和slave是否按预期正常工作。
•自动故障恢复:如果master故障,Sentinel会将一个slave提升为master。当故障实例恢复后也以新的master为主
•通知:Sentinel充当Redis客户端的服务发现来源,当集群发生故障转移时,会将最新信息推送给Redis的客户端
6.1.1 服务状态监控
Sentinel基于心跳机制监测服务状态,每隔1秒向集群的每个实例发送ping命令:
•主观下线:如果某sentinel节点发现某实例未在规定时间响应,则认为该实例主观下线。
•客观下线:若超过指定数量(quorum)的sentinel都认为该实例主观下线,则该实例客观下线。quorum值最好超过Sentinel实例数量的一半。
6.2.2 故障恢复
一旦发现master故障,sentinel需要在salve中选择一个作为新的master,选择依据是这样的:
•首先会判断slave节点与master节点断开时间长短,如果超过指定值(down-after-milliseconds * 10)则会排除该slave节点(时间越长说明数据完整性越低)
•然后判断slave节点的slave-priority值,越小优先级越高,如果是0则永不参与选举(配置文件配置slave-priority)
•如果slave-prority一样,则判断slave节点的offset值,越大说明数据越新,优先级越高(越大说明同步的数据越多,数据完整性越高)
•最后是判断slave节点的运行id大小,越小优先级越高。
当选中了其中一个slave为新的master后(例如slave1),故障的转移的步骤如下:
•sentinel给备选的slave1节点发送slaveof no one命令,让该节点成为master
•sentinel给所有其它slave发送slaveof 192.168.150.101 7002(新 master的ip ) 命令,让这些slave成为新master的从节点,开始从新的master上同步数据。
•最后,sentinel将故障节点标记为slave,当故障节点恢复后会自动成为新的master的slave节点
6.2 搭建哨兵集群
新建三个文件夹 s1,s2,s3.。用作三个sentinel集群的启动目录;
创建sentinel.conf配置文件(在三个目录下创建三个文件,各自修改端口和工作目录即可)
port 27001 #端口
sentinel announce-ip "127.0.0.1"
sentinel monitor mymaster 127.0.0.1 7001 2 #redis 主节点的ip和端口
sentinel auth-pass mymaster root #redis master密码
sentinel down-after-milliseconds mymaster 5000 #master和slave多久不使用后标记为s_down状态
sentinel failover-timeout mymaster 60000 #相邻两次failover(故障切换)的间隔时间
dir "/usr/study/redis/s1" #工作目录
logfile "/usr/study/redis/s1/senlog.log" #日志路径
daemonize yes #守护线程启动
启动sentinel
redis-sentinel sentinel.conf
6.3 RedisTemplate的哨兵模式
在Sentinel集群监管下的Redis主从集群,其节点会因为自动故障转移而发生变化,Redis的客户端必须感知这种变化,及时更新连接信息。Spring的RedisTemplate底层利用lettuce实现了节点的感知和自动切换。
引入redis依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
springboot 配置文件(application.yml)中配置sentinel信息
spring:
redis:
sentinel:
master: mymaster # 指定master名称
nodes: # 指定redis-sentinel集群信息
- 192.168.150.101:27001
- 192.168.150.101:27002
- 192.168.150.101:27003
配置完sentinel后。不必再配置redis的信息。系统会自动根据sentinel获取redis的主从配置;
配置读写分离
@Bean
public LettuceClientConfigurationBuilderCustomizer configurationBuilderCustomizer(){
return configBuilder -> configBuilder.readFrom(ReadFrom.REPLICA_PREFERRED);
}
这里的ReadFrom是配置Redis的读取策略,是一个枚举,包括下面选择:
•MASTER:从主节点读取
•MASTER_PREFERRED:优先从master节点读取,master不可用才读取replica
•REPLICA:从slave(replica)节点读取
•REPLICA _PREFERRED:优先从slave(replica)节点读取,所有的slave都不可用才读取master(常用)
七、数据结构
7.1 动态字符串SDS
redis是用C语言来编写的。不过Redis没有直接使用C语言中的字符串,因为C语言字符串存在很多问题:
-
获取字符串长度的需要通过运算
-
非二进制安全
-
不可修改
// c语言,声明字符串 char* s = "hello" // 本质是字符数组: {'h', 'e', 'l', 'l', 'o', '\0'}
Redis构建了一种新的字符串结构,称为简单动态字符串(Simple Dynamic String),简称SDS。
Redis是C语言实现的,其中SDS是一个结构体,源码如下
struct __attribute__ ((__packed__)) sdshdr8 {
uint8_t len; /* buf已保存的字符串字节数,不包含结束标示*/
uint8_t alloc; /* buf申请的总的字节数,不包含结束标示*/
unsigned char flags; /* 不同SDS的头类型,用来控制SDS的头大小*/
char buf[];
};
redis总共定义了五种动态字符串的结构体。每种的区别在于结构体中header(上图钱三个属性)属性的类型不同。每次会根据字符串的大小来选择合适的结构体;例如uint8_t 表示8bit一个字节,len和alloc的值最大就是255。则存储的最大字符长度不能超过255.
一个包含字符串“name”的sds结构如下
动态扩容机制
SDS之所以叫做动态字符串,是因为它具备动态扩容的能力,例如一个内容为“hi”的SDS:
假如我们要给SDS追加一段字符串“,Amy”,这里首先会申请新内存空间:
- 如果新字符串(旧+新:hi,Amy)小于1M,则新空间为扩展后字符串长度的两倍+1;
- 如果新字符串大于1M,则新空间为扩展后字符串长度+1M+1。称为内存预分配。
sds的优点
①获取字符串长度的时间复杂度为O(1)
②支持动态扩容
③减少内存分配次数
④二进制安全
7.2 Intset
IntSet是Redis中set集合的一种实现方式,基于整数数组来实现,并且具备长度可变、有序等特征。
typedef struct intset {
uint32_t encoding; /* 编码方式,支持存放16位、32位、64位整数*/
uint32_t length; /* 元素个数 */
int8_t contents[]; /* 整数数组,保存集合数据*/
} intset;
//注意,contents[]的数据类型由encoding来确定
//其中的encoding包含三种模式,表示存储的整数大小不同:
/* Note that these encodings are ordered, so:
* INTSET_ENC_INT16 < INTSET_ENC_INT32 < INTSET_ENC_INT64. */
#define INTSET_ENC_INT16 (sizeof(int16_t)) /* 2字节整数,范围类似java的short*/
#define INTSET_ENC_INT32 (sizeof(int32_t)) /* 4字节整数,范围类似java的int */
#define INTSET_ENC_INT64 (sizeof(int64_t)) /* 8字节整数,范围类似java的long */
intset查找的时候采用的是二分查找法。所以为了方便查找,Redis会将intset中所有的整数按照升序依次保存在contents数组中;
intSet encoding 升级
现在,假设有一个intset,元素为{5,10,20},采用的编码是INTSET_ENC_INT16,则每个整数占2字节:
我们向该其中添加一个数字:50000,这个数字超出了int16_t的范围,intset会自动升级编码方式到合适的大小。
以当前案例来说流程如下:
①升级编码为INTSET_ENC_INT32, 每个整数占4字节,并按照新的编码方式及元素个数扩容数组
②倒序依次将数组中的元素拷贝到扩容后的正确位置(因为升级后每个元素所占的内存空间将会变大,如果顺序复制前面的元素会将后面元素的内存覆盖掉,也就将元素覆盖掉了)
③将待添加的元素放入数组末尾
④最后,将inset的encoding属性改为INTSET_ENC_INT32,将length属性改为4
Intset可以看做是特殊的整数数组,具备一些特点:
①Redis会确保Intset中的元素唯一、有序
②具备类型升级机制,可以节省内存空间
③底层采用二分查找方式来查询
7.3 Dict(HashTable)
7.3.1 dict 概念
我们知道Redis是一个键值型(Key-Value Pair)的数据库,我们可以根据键实现快速的增删改查。而键与值的映射关系正是通过Dict来实现的。
Dict由三部分组成,分别是:哈希表(DictHashTable)、哈希节点(DictEntry)、字典(Dict)
//dict
typedef struct dict {
dictType *type; // dict类型,内置不同的hash函数
void *privdata; // 私有数据,在做特殊hash运算时用
dictht ht[2]; // 一个Dict包含两个哈希表,其中一个是当前数据,另一个一般是空,rehash时使用
long rehashidx; // rehash的进度,-1表示未进行
int16_t pauserehash; // rehash是否暂停,1则暂停,0则继续
} dict;
//hashtable
typedef struct dictht {
// entry数组
// 数组中保存的是指向entry的指针
dictEntry **table;
// 哈希表大小
unsigned long size;
// 哈希表大小的掩码,总等于size - 1
unsigned long sizemask;
// entry个数
unsigned long used;
} dictht;
//entry
typedef struct dictEntry {
void *key; // 键
union {
void *val;
uint64_t u64;
int64_t s64;
double d;
} v; // 值
// 下一个Entry的指针
struct dictEntry *next;
} dictEntry;
当我们向Dict添加键值对时,Redis首先根据key计算出hash值(h),然后利用 h & sizemask来计算元素应该存储到数组中的哪个索引位置。我们存储k1=v1,假设k1的哈希值h =1,则1&3 =1,因此k1=v1要存储到数组下标1位置。
7.3.2 Dict扩容
Dict中的HashTable就是数组结合单向链表的实现,当集合中元素较多时,必然导致哈希冲突增多,链表过长,则查询效率会大大降低,因此当元素增大时,就需要扩大hashtable中的存储键值对的数组;
Dict在每次新增键值对时都会检查负载因子(LoadFactor = used/size 实际元素个数/数组的长度) ,满足以下两种情况时会触发哈希表扩容:
- 哈希表的 LoadFactor >= 1,并且服务器没有执行 BGSAVE 或者 BGREWRITEAOF 等后台进程;
- 哈希表的 LoadFactor > 5 ;
Dict除了扩容以外,每次删除元素时,也会对负载因子做检查,当LoadFactor < 0.1 时,会做哈希表收缩:
7.3.3 Dict的rehash
不管是扩容还是收缩,必定会创建新的哈希表,导致哈希表的size和sizemask变化,而key的查询与sizemask有关。因此必须对哈希表中的每一个key重新计算索引,插入新的哈希表,这个过程称为rehash。过程是这样的:
①计算新hash表的realeSize,值取决于当前要做的是扩容还是收缩:
- 如果是扩容,则新size为第一个大于等于dict.ht[0].used + 1的2^n(大于且最接近实际元素个数的2的次方幂)
- 如果是收缩,则新size为第一个大于等于dict.ht[0].used的2^n (不得小于4)
②按照新的realeSize申请内存空间,创建dictht,并赋值给dict.ht[1]
③设置dict.rehashidx = 0,标示开始rehash
④将dict.ht[0]中的每一个dictEntry都rehash到dict.ht[1]
⑤将dict.ht[1]赋值给dict.ht[0],给dict.ht[1]初始化为空哈希表,释放原来的dict.ht[0]的内存
Dict的rehash并不是一次性完成的。试想一下,如果Dict中包含数百万的entry,要在一次rehash完成,极有可能导致主线程阻塞。因此Dict的rehash是分多次、渐进式的完成,因此称为渐进式rehash**。流程如下:**
①计算新hash表的size,值取决于当前要做的是扩容还是收缩:
- 如果是扩容,则新size为第一个大于等于dict.ht[0].used + 1的2^n
- 如果是收缩,则新size为第一个大于等于dict.ht[0].used的2^n (不得小于4)
②按照新的size申请内存空间,创建dictht,并赋值给dict.ht[1]
③设置dict.rehashidx = 0,标示开始rehash
④每次执行新增、查询、修改、删除操作时,都检查一下dict.rehashidx是否大于-1,如果是则将dict.ht[0].table[rehashidx]的entry链表rehash到dict.ht[1],并且将rehashidx++。直至dict.ht[0]的所有数据都rehash到dict.ht[1]
⑤将dict.ht[1]赋值给dict.ht[0],给dict.ht[1]初始化为空哈希表,释放原来的dict.ht[0]的内存
⑥将rehashidx赋值为-1,代表rehash结束
⑦在rehash过程中,新增操作,则直接写入ht[1],查询、修改和删除则会在dict.ht[0]和dict.ht[1]依次查找并执行。这样可以确保ht[0]的数据只减不增,随着rehash最终为空
7.3.4 Dict总结
Dict的结构:
- 类似java的HashTable,底层是数组加链表来解决哈希冲突
- Dict包含两个哈希表,ht[0]平常用,ht[1]用来rehash
Dict的伸缩:
- 当LoadFactor大于5或者LoadFactor大于1并且没有子进程任务时,Dict扩容
- 当LoadFactor小于0.1时,Dict收缩
- 扩容大小为第一个大于等于used + 1的2^n
- 收缩大小为第一个大于等于used 的2^n
- Dict采用渐进式rehash,每次访问Dict时执行一次rehash
- rehash时ht[0]只减不增,新增操作只在ht[1]执行,其它操作在两个哈希表
7.4 zipList(压缩链表)
7.4.1 zipList简介
ZipList 是一种特殊的“双端链表” ,由一系列特殊编码的连续内存块组成。可以在任意一端进行压入/弹出操作, 并且该操作的时间复杂度为 O(1)。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-f7haY1B1-1657784260532)(F:\study\study-notes\redis笔记.assets\image-20220624165007697.png)]
属性 | 类型 | 长度 | 用途 |
---|---|---|---|
zlbytes | uint32_t | 4 字节 | 记录整个压缩列表占用的内存字节数 |
zltail | uint32_t | 4 字节 | 记录压缩列表表尾节点距离压缩列表的起始地址有多少字节,通过这个偏移量,可以确定表尾节点的地址。 |
zllen | uint16_t | 2 字节 | 记录了压缩列表包含的节点数量。 最大值为UINT16_MAX (65534),如果超过这个值,此处会记录为65535,但节点的真实数量需要遍历整个压缩列表才能计算得出。 |
entry | 列表节点 | 不定 | 压缩列表包含的各个节点,节点的长度由节点保存的内容决定。 |
zlend | uint8_t | 1 字节 | 特殊值 0xFF (十进制 255 ),用于标记压缩列表的末端。 |
7.4.2 ZipListEntry
ZipList 中的Entry并不像普通链表那样记录前后节点的指针,因为记录两个指针要占用16个字节,浪费内存。而是采用了下面的结构:
- previous_entry_length:前一节点的长度,占1个或5个字节。
-
- 如果前一节点的长度小于254字节,则采用1个字节来保存这个长度值
- 如果前一节点的长度大于254字节,则采用5个字节来保存这个长度值,第一个字节为0xfe,后四个字节才是真实长度数据
- encoding:编码属性,记录content的数据类型(字符串还是整数)以及长度,占用1个、2个或5个字节
- contents:负责保存节点的数据,可以是字符串或整数
注意:ZipList中所有存储长度的数值均采用小端字节序,即低位字节在前,高位字节在后。例如:数值0x1234,采用小端字节序后实际存储值为:0x3412
因为多个entry存在于连续的内存空间中。从第一个entry开始,每次地址偏移当前节点的所占字节大小,可得到下一个entry的地址。以此方法可从前向后遍历;又因ziplist中有zltail保存了最后一个entry的地址偏移量。可直接定位到最后一个节点,再根据每一个entry中的previous_entry_length(前一个entry所占字节大小)偏移量,地址向后偏移该值,可得到前一个entry的开始地址。以此方法向前遍历。 虽然ziplist的entry没有保存前后节点的指针信息。仍可像双向链表一样实现前后遍历的功能;
7.4.3 ZipList的连锁更新问题
ZipList的每个Entry都包含previous_entry_length来记录上一个节点的大小,长度是1个或5个字节:
- 如果前一节点的长度小于254字节,则采用1个字节来保存这个长度值
- 如果前一节点的长度大于等于254字节,则采用5个字节来保存这个长度值,第一个字节为0xfe,后四个字节才是真实长度数据
现在,假设我们有N个连续的、长度为250~253字节之间的entry,因此entry的previous_entry_length属性用1个字节即可表示;
但是,突然新增了一个字节数大于255的entry,此时他的下一个节点就要用5个字节来保存previous_entry_length,这样节点本身的字节数也大于了255,此节点的下一个节点也要更新previous_entry_length。。。如此,就会出现连锁更新问题。新增、删除都可能导致连锁更新的发生。
7.4.4 ZipList特性总结
①压缩列表的可以看做一种连续内存空间的"双向链表"
②列表的节点之间不是通过指针连接,而是记录上一节点和本节点长度来寻址,内存占用较低
③如果列表数据过多,导致链表过长,可能影响查询性能
④增或删较大数据时有可能发生连续更新问题
7.5 QuickList
7.5.1 zipList面临的几个问题
-
问题1:ZipList虽然节省内存,但申请内存必须是连续空间,如果内存占用较多,申请内存效率很低。怎么办?
为了缓解这个问题,我们必须限制ZipList的长度和entry大小。
-
问题2:但是我们要存储大量数据,超出了ZipList最佳的上限该怎么办?
我们可以创建多个ZipList来分片存储数据。
-
问题3:数据拆分后比较分散,不方便管理和查找,这多个ZipList如何建立联系?
Redis在3.2版本引入了新的数据结构QuickList,它是一个双端链表,只不过链表中的每个节点都是一个ZipList。
以下是QuickList的和QuickListNode的结构源码:
typedef struct quicklist {
// 头节点指针
quicklistNode *head;
// 尾节点指针
quicklistNode *tail;
// 所有ziplist的entry的数量
unsigned long count;
// ziplists总数量
unsigned long len;
// ziplist的entry上限,默认值 -2
int fill : QL_FILL_BITS;
// 首尾不压缩的节点数量
unsigned int compress : QL_COMP_BITS;
// 内存重分配时的书签数量及数组,一般用不到
unsigned int bookmark_count: QL_BM_BITS;
quicklistBookmark bookmarks[];
} quicklist;
//
typedef struct quicklistNode {
// 前一个节点指针
struct quicklistNode *prev;
// 下一个节点指针
struct quicklistNode *next;
// 当前节点的ZipList指针
unsigned char *zl;
// 当前节点的ZipList的字节大小
unsigned int sz;
// 当前节点的ZipList的entry个数
unsigned int count : 16;
// 编码方式:1,ZipList; 2,lzf压缩模式
unsigned int encoding : 2;
// 数据容器类型(预留):1,其它;2,ZipList
unsigned int container : 2;
// 是否被解压缩。1:则说明被解压了,将来要重新压缩
unsigned int recompress : 1;
unsigned int attempted_compress : 1; //测试用
unsigned int extra : 10; /*预留字段*/
} quicklistNode;
为了避免QuickList中的每个ZipList中entry过多,Redis提供了一个配置项:list-max-ziplist-size来限制。
-
如果值为正,则代表ZipList的允许的entry个数的最大值
-
如果值为负,则代表ZipList的最大内存大小,分5种情况:
①-1:每个ZipList的内存占用不能超过4kb
②-2:每个ZipList的内存占用不能超过8kb
③-3:每个ZipList的内存占用不能超过16kb
④-4:每个ZipList的内存占用不能超过32kb
⑤-5:每个ZipList的内存占用不能超过64kb
其默认值为 -2:
在redis客户端,可通过命令 config get list-max-ziplist-size 来查看该配置
除了控制ZipList的大小,QuickList还可以对节点的ZipList做压缩。通过配置项list-compress-depth来控制。因为链表一般都是从首尾访问较多,所以首尾是不压缩的。这个参数是控制首尾不压缩的节点个数:
- 0:特殊值,代表不压缩
- 1:标示QuickList的首尾各有1个节点不压缩,中间节点压缩
- 2:标示QuickList的首尾各有2个节点不压缩,中间节点压缩
以此类推
默认值为0
7.5.2 QuickList的特点
•是一个节点为ZipList的双端链表;
•节点采用ZipList,解决了传统链表的内存占用问题;
•控制了ZipList大小,解决连续内存空间申请效率问题和查询效率问题;
•中间节点可以压缩,进一步节省了内存;
7.6 skipList(跳表)
SkipList**(跳表)**首先是链表,但与传统链表相比有几点差异:
- 元素按照升序排列存储
- 节点可能包含多个指针,指针跨度不同。
typedef struct zskiplist {
// 头尾节点指针
struct zskiplistNode *header, *tail;
// 节点数量
unsigned long length;
// 最大的索引层级,默认是1
int level;
} zskiplist;
typedef struct zskiplistNode {
sds ele; // 节点存储的值
double score;// 节点分数,排序、查找用
struct zskiplistNode *backward; // 前一个节点指针
//因为下一个指针有多个,所以用数组来保存
struct zskiplistLevel {
struct zskiplistNode *forward; // 下一个节点指针
unsigned long span; // 索引跨度
} level[]; // 多级索引数组
} zskiplistNode;
SkipList的特点:
- 跳跃表是一个双向链表,每个节点都包含score和element值
- 节点按照score值排序,score值一样则按照ele字典排序
- 每个节点都可以包含多层指针,层数是1到32之间的随机数
- 不同层指针到下一个节点的跨度不同,层级越高,跨度越大
- 增删改查效率与红黑树基本一致,实现却更简单
7.7 redisObject
Redis中的任意数据类型的键和值都会被封装为一个RedisObject,也叫做Redis对象,源码如下:
从源码分析可知,redisObject所占内存大小为 4(type)+4(encoding)+24(lru)=4 byte +4(refcount)+8(ptr)=16byte。string类型的每个键值对键和值都要封装为redisObject。会占用额外的内存。list可以存多个值,但是也只有一个redisObject。可见,有时候用list或者set可节省内存空间
Redis中会根据存储的数据类型不同,选择不同的编码方式,共包含11种不同类型:
八、五种数据类型数据结构
8.1 String
String是Redis中最常见的数据存储类型:
-
其基本编码方式是RAW,基于简单动态字符串(SDS)实现,存储上限为512mb。
-
如果存储的SDS长度小于44字节,则会采用EMBSTR编码,此时object head与SDS是一段连续空间。申请内存时只需要调用一次内存分配函数,效率更高。
为啥是44?因为44+16(redisObject的头)+3(sds头)+1(结束标识\0)=64。redis内存申请分配就是按64byte为单位的
-
如果存储的字符串是整数值,并且大小在LONG_MAX范围内,则会采用INT编码:直接将数据保存在RedisObject的ptr指针位置(刚好8字节),不再需要SDS了。
可通过 object encoding [key] 来查看当前键值的编码方式
8.2 List
Redis的List结构类似一个双端链表,可以从首、尾操作列表中的元素:
-
在3.2版本之前,Redis采用ZipList和LinkedList来实现List,当元素数量小于512并且元素大小小于64字节时采用ZipList编码,超过则采用LinkedList编码。
-
在3.2版本之后,Redis统一采用QuickList来实现List
8.3 set
Set是Redis中的单列集合,满足下列特点:
- 不保证有序性
- 保证元素唯一
- 求交集、并集、差集
Set是Redis中的集合,不一定确保元素有序,可以满足元素唯一、查询效率要求极高。
- 为了查询效率和唯一性,set采用HashTable编码(Dict)。Dict中的key用来存储元素,value统一为null。
- 当存储的所有数据都是整数,并且元素数量不超过set-max-intset-entries(默认是512)时,Set会采用IntSet编码,以节省内存
8.4 zset
ZSet也就是SortedSet,其中每一个元素都需要指定一个score值和member值:
- 可以根据score值排序后
- member必须唯一
- 可以根据member查询分数
因此,zset底层数据结构必须满足键值存储、键必须唯一、可排序这几个需求。之前学习的哪种编码结构可以满足?
- SkipList:可以排序,并且可以同时存储score和ele值(member)
- HashTable(Dict):可以键值存储,并且可以根据key找value
zset集合为了兼顾排序功能和键值对的功能。会将元素按照SkipList,HashTable的结构分别保存两份。用skiplist来支持排序功能,用hashtable来支持键值对查找功能。所以,zset将数据保存了两份,会占更大的内存空间。
// zset结构
typedef struct zset {
// Dict指针
dict *dict;
// SkipList指针
zskiplist *zsl;
} zset;
//zset初始化方法
robj *createZsetObject(void) {
zset *zs = zmalloc(sizeof(*zs));
//声明redisobject对象
robj *o;
// 创建Dict
zs->dict = dictCreate(&zsetDictType,NULL);
// 创建SkipList
zs->zsl = zslCreate();
o = createObject(OBJ_ZSET,zs);
//编码用skiplist
o->encoding = OBJ_ENCODING_SKIPLIST;
return o;
}
当元素数量不多时,HT和SkipList的优势不明显,而且更耗内存。因此zset还会采用ZipList结构来节省内存,不过需要同时满足两个条件:
①元素数量小于zset_max_ziplist_entries,默认值128
②每个元素都小于zset_max_ziplist_value字节,默认值64
如果开始时用的是ZipList结构,当元素继续增加至不满足以上两个条件时,会从ziplist结构转为ht和skiplist结构;
ziplist本身没有排序功能,而且没有键值对的概念,因此需要有zset通过编码实现:
- ZipList是连续内存,因此score和element是紧挨在一起的两个entry, element在前,score在后
- score越小越接近队首,score越大越接近队尾,按照score值升序排列
8.5 Hash
Hash结构与Redis中的Zset非常类似:
- 都是键值存储
- 都需求根据键获取值
- 键必须唯一
区别如下:
- zset的键是member,值是score;hash的键和值都是任意值
- zset要根据score排序;hash则无需排序
因此,Hash底层采用的编码与Zset也基本一致,只需要把排序有关的SkipList去掉即可:
- Hash结构默认采用ZipList编码,用以节省内存。 ZipList中相邻的两个entry 分别保存field和value
- 当数据量较大时,Hash结构会转为HT编码,也就是Dict,触发条件有两个:
- ZipList中的元素数量超过了hash-max-ziplist-entries(默认512)
- ZipList中的任意entry大小超过了hash-max-ziplist-value(默认64字节)
九、redis网络模型
9.1 用户空间和内核空间
任何Linux发行版,其系统内核都是Linux。我们的应用都需要通过Linux内核与硬件交互。
为了避免用户应用导致冲突甚至内核崩溃,用户应用与内核是分离的:
进程的寻址空间会划分为两部分:内核空间、用户空间
- 用户空间只能执行受限的命令(Ring3),而且不能直接调用系统资源,必须通过内核提供的接口来访问
- 内核空间可以执行特权命令(Ring0),调用一切系统资源
Linux系统为了提高IO效率,会在用户空间和内核空间都加入缓冲区:
- 写数据时,要把用户缓冲数据拷贝到内核缓冲区,然后写入设备
- 读数据时,要从设备读取数据到内核缓冲区,然后拷贝到用户缓冲区
9.2 阻塞IO
在《UNIX网络编程》一书中,总结归纳了5种IO模型:
- 阻塞IO(Blocking IO)
- 非阻塞IO(Nonblocking IO)
- IO多路复用(IO Multiplexing)
- 信号驱动IO(Signal Driven IO)
- 异步IO(Asynchronous IO)
顾名思义,阻塞IO就是两个阶段都必须阻塞等待:
阶段一:
①用户进程尝试读取数据(比如网卡数据)
②此时数据尚未到达,内核需要等待数据
③此时用户进程也处于阻塞状态
阶段二:
①数据到达并拷贝到内核缓冲区,代表已就绪
②将内核数据拷贝到用户缓冲区
③拷贝过程中,用户进程依然阻塞等待
④拷贝完成,用户进程解除阻塞,处理数据
可以看到,阻塞IO模型中,用户进程在两个阶段都是阻塞状态。
9.3 非阻塞IO
顾名思义,非阻塞IO的recvfrom操作会立即返回结果而不是阻塞用户进程。
阶段一:
①用户进程尝试读取数据(比如网卡数据)
②此时数据尚未到达,内核需要等待数据
③返回异常给用户进程
④用户进程拿到error后,再次尝试读取
⑤循环往复,直到数据就绪
阶段二:
①将内核数据拷贝到用户缓冲区
②拷贝过程中,用户进程依然阻塞等待
③拷贝完成,用户进程解除阻塞,处理数据
可以看到,非阻塞IO模型中,用户进程在第一个阶段是非阻塞,第二个阶段是阻塞状态。虽然是非阻塞,但性能并没有得到提高。而且忙等机制会导致CPU空转,CPU使用率暴增。
9.4 IO多路复用
9.4.1 IO多路复用概念
无论是阻塞IO还是非阻塞IO,用户应用在一阶段都需要调用recvfrom来获取数据,差别在于无数据时的处理方案:
- 如果调用recvfrom时,恰好没有数据,阻塞IO会使CPU阻塞,非阻塞IO使CPU空转,都不能充分发挥CPU的作用。
- 如果调用recvfrom时,恰好有数据,则用户进程可以直接进入第二阶段,读取并处理数据
而在单线程情况下,只能依次处理IO事件,如果正在处理的IO事件恰好未就绪(数据不可读或不可写),线程就会被阻塞,所有IO事件都必须等待,性能自然会很差。
就比如服务员给顾客点餐,分两步:
- 顾客思考要吃什么(等待数据就绪)
- 顾客想好了,开始点餐(读取数据)
要提高效率有几种办法?
- 方案一:增加更多服务员(多线程)
- 方案二:不排队,谁想好了吃什么(数据就绪了),服务员就给谁点餐(用户应用就去读取数据)(多路复用)
用户进程如何知道内核中数据是否就绪呢?
文件描述符(File Descriptor):简称FD,是一个从0 开始的无符号整数,用来关联Linux中的一个文件。在Linux中,一切皆文件,例如常规文件、视频、硬件设备等,当然也包括网络套接字(Socket)。
IO多路复用:是利用单个线程来同时监听多个FD,并在某个FD可读、可写时得到通知,从而避免无效的等待,充分利用CPU资源。
阶段一:
①用户进程调用select,指定要监听的FD集合
②内核监听FD对应的多个socket
③任意一个或多个socket数据就绪则返回readable
④此过程中用户进程阻塞
阶段二:
①用户进程找到就绪的socket
②依次调用recvfrom读取数据
③内核将数据拷贝到用户空间
④用户进程处理数据
IO多路复用是利用单个线程来同时监听多个FD,并在某个FD可读、可写时得到通知,从而避免无效的等待,充分利用CPU资源。不过监听FD的方式、通知的方式又有多种实现,常见的有:select、poll、epoll
差异:
- select和poll只会通知用户进程有FD就绪,但不确定具体是哪个FD,需要用户进程逐个遍历FD来确认
- epoll则会在通知用户进程FD就绪的同时,把已就绪的FD写入用户空间
9.4.2 IO多路复用之select
select是Linux最早是由的I/O多路复用技术
// 定义类型别名 __fd_mask,本质是 long int
typedef long int __fd_mask;
/* fd_set 记录要监听的fd集合,及其对应状态 */
typedef struct {
// fds_bits是long类型数组,长度为 1024/32 = 32
// 共1024个bit位,每个bit位代表一个fd,0代表未就绪,1代表就绪
__fd_mask fds_bits[__FD_SETSIZE / __NFDBITS];
// ...
} fd_set;
// select函数,用于监听fd_set,也就是多个fd的集合
int select(
int nfds, // 要监视的fd_set的最大fd + 1
fd_set *readfds, // 要监听读事件的fd集合
fd_set *writefds,// 要监听写事件的fd集合
fd_set *exceptfds, // // 要监听异常事件的fd集合
// 超时时间,null-用不超时;0-不阻塞等待;大于0-固定等待时间
struct timeval *timeout
);
select模式存在的问题:
- 需要将整个fd_set从用户空间拷贝到内核空间,select结束还要再次拷贝回用户空间
- select无法得知具体是哪个fd就绪,需要遍历整个fd_set
- fd_set监听的fd数量不能超过1024
9.4.3 IO多路复用之poll
poll模式对select模式做了简单改进,但性能提升不明显,部分关键代码如下:
// pollfd 中的事件类型
#define POLLIN //可读事件
#define POLLOUT //可写事件
#define POLLERR //错误事件
#define POLLNVAL //fd未打开
// pollfd结构
struct pollfd {
int fd; /* 要监听的fd */
short int events; /* 要监听的事件类型:读、写、异常 */
short int revents;/* 实际发生的事件类型 */
};
// poll函数
int poll(
struct pollfd *fds, // pollfd数组,可以自定义大小
nfds_t nfds, // 数组元素个数
int timeout // 超时时间
);
IO流程:
①创建pollfd数组,向其中添加关注的fd信息,数组大小自定义
②调用poll函数,将pollfd数组拷贝到内核空间,转链表存储,无上限
③内核遍历fd,判断是否就绪
④数据就绪或超时后,拷贝pollfd数组到用户空间,返回就绪fd数量n
⑤用户进程判断n是否大于0
⑥大于0则遍历pollfd数组,找到就绪的fd
与select对比:
•select模式中的fd_set大小固定为1024,而pollfd在内核中采用链表,理论上无上限
•监听FD越多,每次遍历消耗时间也越久,性能反而会下降
9.4.5 IO多路复用之epoll
epoll模式是对select和poll的改进,它提供了三个函数:
struct eventpoll {
//...
struct rb_root rbr; // 一颗红黑树,记录要监听的FD
struct list_head rdlist;// 一个链表,记录就绪的FD
//...
};
// 1.创建一个epoll实例,内部是event poll,返回对应的句柄epfd
int epoll_create(int size);
// 2.将一个FD添加到epoll的红黑树中,并设置ep_poll_callback
// callback触发时,就把对应的FD加入到rdlist这个就绪列表中
int epoll_ctl(
int epfd, // epoll实例的句柄
int op, // 要执行的操作,包括:ADD、MOD、DEL
int fd, // 要监听的FD
struct epoll_event *event // 要监听的事件类型:读、写、异常等
);
// 3.检查rdlist列表是否为空,不为空则返回就绪的FD的数量
int epoll_wait(
int epfd, // epoll实例的句柄
struct epoll_event *events, // 空event数组,用于接收就绪的FD
int maxevents, // events数组的最大长度
int timeout // 超时时间,-1用不超时;0不阻塞;大于0为阻塞时间
);
9.4.6 IO多路复用三种方式总结
select模式存在的三个问题:
- 能监听的FD最大不超过1024
- 每次select都需要把所有要监听的FD都拷贝到内核空间
- 每次都要遍历所有FD来判断就绪状态
poll模式的问题:
- poll利用链表解决了select中监听FD上限的问题,但依然要遍历所有FD,如果监听较多,性能会下降
epoll模式中如何解决这些问题的?
- 基于epoll实例中的红黑树保存要监听的FD,理论上无上限,而且增删改查效率都非常高
- 每个FD只需要执行一次epoll_ctl添加到红黑树,以后每次epol_wait无需传递任何参数,无需重复拷贝FD到内核空间
- 利用ep_poll_callback机制来监听FD状态,无需遍历所有FD,因此性能不会随监听的FD数量增多而下降
9.4.7 IO多路复用-事件通知机制
当FD有数据可读时,我们调用epoll_wait(或者select、poll)可以得到通知。但是事件通知的模式有两种:
- LevelTriggered:简称LT,也叫做水平触发。只要某个FD中有数据可读,每次调用epoll_wait都会得到通知。
- EdgeTriggered:简称ET,也叫做边沿触发。只有在某个FD有状态变化时,调用epoll_wait才会被通知。
LT:事件通知频率较高,会有重复通知,影响性能
ET:仅通知一次,效率高。可以基于非阻塞IO循环读取解决数据读取不完整问题
select和poll仅支持LT模式,epoll可以自由选择LT和ET两种模式
举个栗子:
①假设一个客户端socket对应的FD已经注册到了epoll实例中
②客户端socket发送了2kb的数据
③服务端调用epoll_wait,得到通知说FD就绪
④服务端从FD读取了1kb数据
⑤回到步骤3(再次调用epoll_wait,形成循环)
结果:
如果我们采用LT模式,因为FD中仍有1kb数据,则第⑤步依然会返回结果,并且得到通知
如果我们采用ET模式,因为第③步已经消费了FD可读事件,第⑤步FD状态没有变化,因此epoll_wait不会返回,数据无法读取,客户端响应超时。
9.5 Redis网络模型
Redis到底是单线程还是多线程?
- 如果仅仅聊Redis的核心业务部分(命令处理),答案是单线程
- 如果是聊整个Redis,那么答案就是多线程
在Redis版本迭代过程中,在两个重要的时间节点上引入了多线程的支持:
- Redis v4.0:引入多线程异步处理一些耗时较旧的任务,例如异步删除命令unlink
- Redis v6.0:在核心网络模型中引入 多线程,进一步提高对于多核CPU的利用率
因此,对于Redis的核心网络模型,在Redis 6.0之前确实都是单线程。是利用epoll(Linux系统)这样的IO多路复用技术在事件循环中不断处理客户端情况。
为什么Redis要选择单线程?
- 抛开持久化不谈,Redis是纯内存操作,执行速度非常快,它的性能瓶颈是网络延迟而不是执行速度,因此多线程并不会带来巨大的性能提升。
- 多线程会导致过多的上下文切换,带来不必要的开销
- 引入多线程会面临线程安全问题,必然要引入线程锁这样的安全手段,实现复杂度增高,而且性能也会大打折扣
Redis 6.0版本中引入了多线程,目的是为了提高IO读写效率。因此在解析客户端命令、写响应结果时采用了多线程。核心的命令执行、IO多路复用模块依然是由主线程执行。
9.6 Redis通信协议(RESP协议)
Redis是一个CS架构的软件,通信一般分两步(不包括pipeline和PubSub):
①客户端(client)向服务端(server)发送一条命令
②服务端解析并执行命令,返回响应结果给客户端
因此客户端发送命令的格式、服务端响应结果的格式必须有一个规范,这个规范就是通信协议。
而在Redis中采用的是RESP(Redis Serialization Protocol)协议:
- Redis 1.2版本引入了RESP协议
- Redis 2.0版本中成为与Redis服务端通信的标准,称为RESP2
- Redis 6.0版本中,从RESP2升级到了RESP3协议,增加了更多数据类型并且支持6.0的新特性–客户端缓存
但目前,默认使用的依然是RESP2协议,也是我们要学习的协议版本(以下简称RESP)。
在RESP中,通过首字节的字符来区分不同数据类型,常用的数据类型包括5种:
- 单行字符串:首字节是 ‘+’ ,后面跟上单行字符串,以CRLF( “\r\n” )结尾。例如返回"OK": “+OK\r\n”
- 错误(Errors):首字节是 ‘-’ ,与单行字符串格式一样,只是字符串是异常信息,例如:“-Error message\r\n”
- 数值:首字节是 ‘:’ ,后面跟上数字格式的字符串,以CRLF结尾。例如:“:10\r\n”
- 多行字符串:首字节是 ‘$’ ,表示二进制安全的字符串,最大支持512MB:
- 如果大小为0,则代表空字符串:“$0\r\n\r\n”
- 如果大小为-1,则代表不存在:“$-1\r\n”
- 数组:首字节是 ‘*****’,后面跟上数组元素个数,再跟上元素,元素数据类型不限:
*3\r\n //元素个数
$3\r\nset\r\n //数组元素set
$4\r\nname\r\n //数组元素name
$6\r\n哈哈\r\n //数组元素‘哈哈’
十、Redis内存策略
10.1 Redis内存回收
Redis之所以性能强,最主要的原因就是基于内存存储。然而单节点的Redis其内存大小不宜过大,会影响持久化或主从同步性能。
我们可以通过修改配置文件来设置Redis的最大内存:
# 格式:
# maxmemory <bytes>
# 例如:
maxmemory 1gb
当内存使用达到上限时,就无法存储更多数据了。为了解决这个问题,Redis提供了一些策略实现内存回收:
- 内存过期策略
- 内存淘汰策略
10.1.1 过期策略
可以通过expire命令给Redis的key设置TTL(存活时间):expire key 5 (指定key的值过期时间5秒)
Redis本身是一个典型的key-value内存存储数据库,因此所有的key、value都保存在之前学习过的Dict结构中。不过在其database结构体中,有两个Dict:一个用来记录key-value;另一个用来记录key-TTL。
typedef struct redisDb {
dict *dict; /* 存放所有key及value的地方,也被称为keyspace*/
dict *expires; /* 存放每一个key及其对应的TTL存活时间,只包含设置了TTL的key*/
dict *blocking_keys; /* Keys with clients waiting for data (BLPOP)*/
dict *ready_keys; /* Blocked keys that received a PUSH */
dict *watched_keys; /* WATCHED keys for MULTI/EXEC CAS */
int id; /* Database ID,0~15 */
long long avg_ttl; /* 记录平均TTL时长 */
unsigned long expires_cursor; /* expire检查时在dict中抽样的索引位置. */
list *defrag_later; /* 等待碎片整理的key列表. */
} redisDb;
10.1.2 过期策略-惰性删除
**惰性删除:**顾名思义并不是在TTL到期后就立刻删除,而是在访问一个key的时候,检查该key的存活时间,如果已经过期才执行删除。
// 查找一个key执行写操作
robj *lookupKeyWriteWithFlags(redisDb *db, robj *key, int flags) {
// 检查key是否过期
expireIfNeeded(db,key);
return lookupKey(db,key,flags);
}
// 查找一个key执行读操作
robj *lookupKeyReadWithFlags(redisDb *db, robj *key, int flags) {
robj *val;
// 检查key是否过期 if (expireIfNeeded(db,key) == 1) {
// ...略
}
return NULL;
}
int expireIfNeeded(redisDb *db, robj *key) {
// 判断是否过期,如果未过期直接结束并返回0
if (!keyIsExpired(db,key)) return 0;
// ... 略
// 删除过期key
deleteExpiredKeyAndPropagate(db,key);
return 1;
}
10.1.3 过期策略-周期删除
周期删除:顾明思议是通过一个定时任务,周期性的抽样部分过期的key,然后执行删除。执行周期有两种:
- Redis服务初始化函数initServer()中设置定时任务,按照server.hz的频率来执行过期key清理,模式为SLOW
- Redis的每个事件循环前会调用beforeSleep()函数,执行过期key清理,模式为FAST
SLOW模式规则:
①执行频率受server.hz影响,默认为10,即每秒执行10次,每个执行周期100ms。
②执行清理耗时不超过一次执行周期的25%.默认slow模式耗时不超过25ms
③逐个遍历db,逐个遍历db中的bucket,抽取20个key判断是否过期
④如果没达到时间上限(25ms)并且过期key比例大于10%,再进行一次抽样,否则结束
FAST模式规则(过期key比例小于10%不执行 ):
①执行频率受beforeSleep()调用频率影响,但两次FAST模式间隔不低于2ms
②执行清理耗时不超过1ms
③逐个遍历db,逐个遍历db中的bucket,抽取20个key判断是否过期
④如果没达到时间上限(1ms)并且过期key比例大于10%,再进行一次抽样,否则结束
10.1.4 RDB对过期键的处理
- 生成RDB文件
在执行SAVE
命令或者BGSAVE
命令创建一个新的RDB文件时,程序会对数据库中的键进行检查,已过期的键不会被保存到新创建的RDB文件中。
- 载入RDB文件
在启动Redis服务器时,如果服务器只开启了RDB持久化,那么服务器将会载入RDB文件:
-
如果服务器以主服务器模式运行,在载入RDB文件时,程序会对文件中保存的键进行检查,未过期的键会被载入到数据库中,过期键会被忽略。
-
如果服务器以从服务器模式运行,在载入RDB文件时,文件中保存的所有键,不论是否过期,都会被载入到数据库中。
因为主从服务器在进行数据同步(完整重同步)的时候,从服务器的数据库会被清空,所以一般情况下,过期键对载入RDB文件的从服务器不会造成影响。
10.1.5 AOF对过期键的处理
- 如果数据AOF文件写入
库中的某个键已经过期,并且服务器开启了AOF持久化功能,当过期键被惰性删除或者定期删除后,程序会向AOF文件追加一条DEL
命令,显式记录该键已被删除。
- AOF文件重写
在执行AOF文件重写时,程序会对数据库中的键进行检查,已过期的键不会被保存到重写后的AOF文件中。
10.1.6 在主从复制模式下,从服务器的过期键删除动作由主服务器控制
- 主服务器在删除一个过期键后,会显式地向所有从服务器发送一个DEL命令,告知从服务器删除这个过期键。
- 从服务器在执行客户端发送的读命令时,即使发现该键已过期也不会删除该键,照常返回该键的值。
- 从服务器只有接收到主服务器发送的DEL命令后,才会删除过期键。
总结:
RedisKey的TTL记录方式:
- 在RedisDB中通过一个Dict记录每个Key的TTL时间
过期key的删除策略:
- 惰性清理:每次查找key时判断是否过期,如果过期则删除
- 定期清理:定期抽样部分key,判断是否过期,如果过期则删除。
定期清理的两种模式:
- SLOW模式执行频率默认为10,每次不超过25ms
- FAST模式执行频率不固定,但两次间隔不低于2ms,每次耗时不超过1ms
10.2 淘汰策略
内存淘汰:就是当Redis内存使用达到设置的上限时,主动挑选部分key删除以释放更多内存的流程。Redis会在处理客户端命令的方法processCommand()中尝试做内存淘汰:
int processCommand(client *c) {
// 如果服务器设置了server.maxmemory属性,并且并未有执行lua脚本
if (server.maxmemory && !server.lua_timedout) {
// 尝试进行内存淘汰performEvictions
int out_of_memory = (performEvictions() == EVICT_FAIL);
// ...
if (out_of_memory && reject_cmd_on_oom) {
rejectCommand(c, shared.oomerr);
return C_OK;
}
// ....
}
}
Redis支持8种不同策略来选择要删除的key:
- noeviction: 不淘汰任何key,但是内存满时不允许写入新数据,默认就是这种策略。
- volatile-ttl: 对设置了TTL的key,比较key的剩余TTL值,TTL越小越先被淘汰
- allkeys-random:对全体key ,随机进行淘汰。也就是直接从db->dict中随机挑选
- volatile-random:对设置了TTL的key ,随机进行淘汰。也就是从db->expires中随机挑选。
- allkeys-lru: 对全体key,基于LRU算法进行淘汰
- volatile-lru: 对设置了TTL的key,基于LRU算法进行淘汰
- allkeys-lfu: 对全体key,基于LFU算法进行淘汰
- volatile-lfu: 对设置了TTL的key,基于LFI算法进行淘汰
比较容易混淆的有两个:
- LRU(Least Recently Used),最少最近使用。用当前时间减去最后一次访问时间,这个值越大则淘汰优先级越高。
- LFU(Least Frequently Used),最少频率使用。会统计每个key的访问频率,值越小淘汰优先级越高。
Redis的数据都会被封装为RedisObject结构:
typedef struct redisObject {
unsigned type:4; // 对象类型
unsigned encoding:4; // 编码方式
unsigned lru:LRU_BITS; // LRU:以秒为单位记录最近一次访问时间,长度24bit
// LFU:高16位以分钟为单位记录最近一次访问时间,低8位记录逻辑访问次数
int refcount; // 引用计数,计数为0则可以回收
void *ptr; // 数据指针,指向真实数据
} robj;
LFU的访问次数之所以叫做逻辑访问次数,是因为并不是每次key被访问都计数,而是通过运算:
①生成0~1之间的随机数R
②计算 (旧次数 * lfu_log_factor + 1),记录为P
③如果 R < P ,则计数器 + 1,且最大不超过255
④访问次数会随时间衰减,距离上一次访问时间每隔 lfu_decay_time 分钟,计数器 -1
十一、Redis使用过程中常见问题
11.1 缓存更新策略
操作缓存和数据库时有三个问题需要考虑:
1.删除缓存还是更新缓存?
- 更新缓存:每次更新数据库都更新缓存,无效写操作较多
- 删除缓存:更新数据库时让缓存失效,查询时再更新缓存(常用)
2.如何保证缓存与数据库的操作的同时成功或失败?
- 单体系统,将缓存与数据库操作放在一个事务
- 分布式系统,利用TCC等分布式事务方案
3.先操作缓存还是先操作数据库?
- 先删除缓存,再操作数据库
- 先操作数据库,再删除缓存
缓存更新策略的最佳实践方案:
1.低一致性需求:使用Redis自带的内存淘汰机制
2.高一致性需求:主动更新,并以超时剔除作为兜底方案
-
读操作:
- 缓存命中则直接返回
- 缓存未命中则查询数据库,并写入缓存,设定超时时间
-
写操作:
- 先写数据库,然后再删除缓存
- 要确保数据库与缓存操作的原子性
11.2 缓存穿透
缓存穿透是指客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远不会生效,这些请求都会到达数据库,给数据库造成巨大的压力。
常见的解决方案有两种:
-
缓存空对象
优点:实现简单,维护方便
缺点:额外的内存消耗
可能造成短期的不一致
-
布隆过滤
优点:内存占用较少,没有多余key
缺点:实现复杂
存在误判可能
- 缓存穿透产生的原因是什么?
- 用户请求的数据在缓存中和数据库中都不存在,不断发起这样的请求,给数据库带来巨大压力
- 缓存穿透的解决方案有哪些?
- 缓存null值
- 布隆过滤
- 增强id的复杂度,避免被猜测id规律
- 做好数据的基础格式校验
- 加强用户权限校验
- 做好热点参数的限流
11.3 缓存击穿
缓存击穿问题也叫热点Key问题,就是一个被高并发访问并且缓存重建业务较复杂的key(一个key)突然失效了,大量的请求没有命中缓存都开始查询数据库并重新重建缓存。无数的请求访问会在瞬间给数据库带来巨大的冲击。
常见的解决方案有两种:
-
互斥锁:
当进程未命中缓存时,先尝试获取互斥锁,如果获得互斥锁。就查询数据库并重建缓存,缓存重建完成后释放锁。如果无法获取互斥锁,说明已经有线程在重建缓存,当前线程无需查询数据库重建缓存,可在短暂的休眠之后继续查询缓存是否命中;
-
逻辑过期
为缓存数据增加一个逻辑过期时间,每次命中缓存后查看当前数据是否已经逻辑过期了,如果当前缓存数据逻辑过期,就尝试获取互斥锁,如果未获得锁,直接返回过期数据;如果锁获取成功,就开启一个新的线程重建缓存。当前线程直接返回过期数据;
互斥锁的实现:
通常用redis的setnx命令来实现互斥锁,多个线程用setnx操作同一个键,如果操作成功说明锁获取成功。操作失败说明锁获取失败。释放锁直接删除该键值即可;
11.4 缓存雪崩
缓存雪崩是指在同一时段大量的缓存key同时失效或者Redis服务宕机,导致大量请求到达数据库,带来巨大压力。
解决方案:
- 给不同的Key的TTL添加随机值,防止大量的key在同一时间过期
- 利用Redis集群提高服务的可用性,防止redis宕机
- 给缓存业务添加降级限流策略,防止高并发恶意访问
- 给业务添加多级缓存
11.5 redis实现分布式锁
11.5.1 悲观锁和乐观锁
- 悲观锁
- 认为线程安全问题一定会发生,因此在操作数据之前先获取锁,确保线程串行执行。
- 例如Synchronized、Lock都属于悲观锁
- 乐观锁
- 认为线程安全问题不一定会发生,因此不加锁,只是在更新数据时去判断有没有其它线程对数据做了修改。
- 如果没有修改则认为是安全的,自己才更新数据。
- 如果已经被其它线程修改说明发生了安全问题,此时可以重试或异常
乐观锁的常见实现方法:
-
版本号法
对公共资源设置一个版本标识,没更新一次就修改一次版本好,最后通过版本号来判断有没有被修改过;此方法可解决CAS的ABA问题;
-
CAS法
即compareAndSet(int expect, int update) 在更新数据前先检查当前最新值是否与操作之前获得的该书值是否相等,如果相等,表示当前资源没有被其他线程修改过,则更新该值。否则,循环此操作;
总结:
1.悲观锁:添加同步锁,让线程串行执行
- 优点:简单粗暴
- 缺点:性能一般
2.乐观锁:不加锁,在更新时判断是否有其它线程在修改
- 优点:性能好
- 缺点:存在成功率低的问题
11.5.2 分布式锁
满足分布式系统或集群模式下多进程可见并且互斥的锁,常见有如下三种实现方式
MySQL | Redis | Zookeeper | |
---|---|---|---|
互斥 | 利用mysql本身的互斥锁机制 | 利用setnx这样的互斥命令 | 利用节点的唯一性和有序性实现互斥 |
高可用 | 好 | 好 | 好 |
高性能 | 一般 | 好 | 一般 |
安全性 | 断开连接,自动释放锁 | 利用锁超时时间,到期释放 | 临时节点,断开连接自动释放 |
我们通过setnx来设置分布式锁,如果只是用setnx key来设置锁,用del key 来释放锁,会存在以下问题。假如线程A先获取到锁,然后在执行业务的时候出现问题,导致锁被超时释放(setnx key被删除),此时线程B进来获取到锁,正在执行业务。然后线程A突然正常执行,在B还未执行完业务的时候。线程
A执行释放锁命令(del key)将锁释放了。实际上它此时释放的是线程B的锁,这样就会导致线程安全问题;
改进之前的分布式锁实现,满足:
1.在获取锁时存入线程标示(可以用UUID表示)
2.在释放锁时先获取锁中的线程标示,判断是否与当前线程标示一致
- 如果一致则释放锁
- 如果不一致则不释放锁
改进之后,释放锁的时候就有两个命令操作
get key #获取值,
#比较判断锁标识是否与当前线程一致
del key #释放锁
如果在执行完第一条命令还没有执行第二条命令时,发生了问题或者进行了线程切换。还是会发生线程安全问题;所以,要想保证线程安全,就得保证以上两条命令的原子性;
11.5.3 Redis的Lua脚本
Redis提供了Lua脚本功能,在一个脚本中编写多条Redis命令,确保多条命令执行时的原子性。Lua是一种编程语言,它的基本语法大家可以参考网站:https://www.runoob.com/lua/lua-tutorial.html
这里重点介绍Redis提供的调用函数,语法如下:
# 执行redis命令
redis.call('命令名称', 'key', '其它参数', ...)
# 执行 set name jack
redis.call('set', 'name', 'jack')
#我们要先执行set name Rose,再执行get name,则脚本如下:
# 先执行 set name jack
redis.call('set', 'name', 'jack')
# 再执行 get name
local name = redis.call('get', 'name')
# 返回
return name
写好脚本以后,需要用Redis命令来调用脚本,调用脚本的常见命令如下:
# 调用脚本
EVAL "return redis.call('set', 'name', 'jack')" 0 # 0表示KEYS[]的大小
#如果脚本中的key、value不想写死,可以作为参数传递。key类型参数会放入KEYS数组,其它参数会放入ARGV数组,在脚本中可以从KEYS和ARGV数组获取这些参数:
EVAL "return redis.call('set', KEYS[1],ARGV[1])" 1 name Rose
#注意:KEYS和ARGV的下标从1开始,不是从0开始
释放锁的业务流程是这样的:
1.获取锁中的线程标示
2.判断是否与指定的标示(当前线程标示)一致
3.如果一致则释放锁(删除)
4.如果不一致则什么都不做
如果用Lua脚本来表示则是这样的:
-- 这里的 KEYS[1] 就是锁的key,这里的ARGV[1] 就是当前线程标示
-- 获取锁中的标示,判断是否与当前线程标示一致
if (redis.call('GET', KEYS[1]) == ARGV[1]) then
-- 一致,则删除锁
return redis.call('DEL', KEYS[1])
end
-- 不一致,则直接返回
return 0
11.5.4 RedisTemplate执行lua脚本
@Component
public class LuaDemo {
@Autowired
RedisTemplate redisTemplate;
static DefaultRedisScript<Long> redisScript;
static {
redisScript = new DefaultRedisScript();
//加载lua脚本
redisScript.setLocation(new ClassPathResource("test.lua"));
redisScript.setResultType(Long.class);
}
public void execLua(List<String> keys,Object... args){
Long execute = (Long)redisTemplate.execute(redisScript, keys, args);
System.out.println(execute);
}
}
11.6 Redisson
Redisson是一个在Redis的基础上实现的Java驻内存数据网格(In-Memory Data Grid)。它不仅提供了一系列的分布式的Java常用对象,还提供了许多分布式服务,其中就包含了各种分布式锁的实现。
官网地址: https://redisson.org
GitHub地址: https://github.com/redisson/redisson
11.6.1 使用redisson
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.13.6</version>
</dependency>
//配置Redisson客户端:
@Configuration
public class RedisConfig {
@Bean
public RedissonClient redissonClient() {
// 配置类
Config config = new Config();
// 添加redis地址,这里添加了单点的地址,也可以使用config.useClusterServers()添加集群地址
config.useSingleServer().setAddress("redis://192.168.150.101:6379").setPassowrd("123321");
// 创建客户端
return Redisson.create(config);
}
}
@Resource
private RedissonClient redissonClient;
@Test
void testRedisson() throws InterruptedException {
// 获取锁(可重入),指定锁的名称
RLock lock = redissonClient.getLock("anyLock");
// 尝试获取锁,参数分别是:获取锁的最大等待时间(期间会重试),锁自动释放时间,时间单位
boolean isLock = lock.tryLock(1, 10, TimeUnit.SECONDS);
// 判断释放获取成功
if(isLock){
try {
System.out.println("执行业务");
}finally {
// 释放锁
lock.unlock();
}
}
}
11.6.2 Redisson 可重入锁原理
// 创建锁对象 RLock lock = redissonClient.getLock("lock");
@Test
void method1() {
boolean isLock = lock.tryLock();
if(!isLock){
log.error("获取锁失败,1");
return;
}
try {
log.info("获取锁成功,1");
method2();
} finally {
log.info("释放锁,1");
lock.unlock();
}
}
void method2(){
boolean isLock = lock.tryLock();
if(!isLock){
log.error("获取锁失败, 2");
return;
}
try {
log.info("获取锁成功,2");
} finally {
log.info("释放锁,2"); lock.unlock();
}
}
获取锁的lua脚本
local key = KEYS[1]; -- 锁的key
local threadId = ARGV[1]; -- 线程唯一标识
local releaseTime = ARGV[2]; -- 锁的自动释放时间
-- 判断是否存在
if(redis.call('exists', key) == 0) then
-- 不存在, 获取锁
redis.call('hset', key, threadId, '1');
-- 设置有效期
redis.call('expire', key, releaseTime);
return 1; -- 返回结果
end;
-- 锁已经存在,判断threadId是否是自己
if(redis.call('hexists', key, threadId) == 1) then
-- 是自己, 获取锁,重入次数+1
redis.call('hincrby', key, threadId, '1');
-- 设置有效期
redis.call('expire', key, releaseTime);
return 1; -- 返回结果
end;
return 0; -- 代码走到这里,说明获取锁的不是自己,获取锁失败
释放锁的lua脚本
local key = KEYS[1]; -- 锁的key
local threadId = ARGV[1]; -- 线程唯一标识
local releaseTime = ARGV[2]; -- 锁的自动释放时间
-- 判断当前锁是否还是被自己持有
if (redis.call('hexists', key, threadId) == 0) then
return nil; -- 如果已经不是自己,则直接返回
end;
-- 是自己的锁,则重入次数-1
local count = redis.call('HINCRBY', key, threadId, -1);
-- 判断是否重入次数是否已经为0
if (count > 0) then
-- 大于0说明不能释放锁,重置有效期然后返回
redis.call('EXPIRE', key, releaseTime);
return nil;
else -- 等于0说明可以释放锁,直接删除
redis.call('DEL', key);
return nil;
end;