首先要理解, 缓存就是以空间换事件,能提高系统性能和减少请求响应时间。
应用: CPU Cache缓存内存数据以解决CPU处理速度和内存访问速度不匹配的问题, 内存缓存的是硬盘数据 以解决硬盘访问速度慢的问题, 操作系统的快表也可以看作是一个缓存存储器(加快虚拟地址到物理地址的映射)。
所以在数据库之上加一层缓存,可以明显加快访问速度; 同时缓存也可以支持更大的并发量。
本地缓存
存在于应用内部,不用额外的网络开销,速度很快。 常用于数据量不大的单体架构。
常见的单体架构: 使用Nginx做负载均衡,部署若干个相同的应用到不同服务器,使用用一个数据库,并使用本地缓存。
常用的本地缓存:
jdk自带的 HashMap 和 ConcurrentHashMap, 只有缓存功能,没有过期时间、淘汰机制、命中率统计等基本功能,一般不用。
Ehcache、Guava Cache、Spring Cache:
Echcache比另两个更重,但能嵌入到hibernate和mybatis中作为多级缓存,能将缓存数据持久化到本地磁盘。
Guava Cache 和 Spring Cache差不多, Guava Cache用的较多。
Caffeine
Caffeine和Guava相似, 但在各个方面都比Guava做的更好, 一般都能替代Guava。
缺点:
分布式缓存
是独立的,能提供内存数据库服务。
脱离应用独立存在,位于应用和数据库之间,多个应用可以共用一个分布式缓存。
分布式缓存最常用的就是Redis了。
缺点:
多级缓存
最常用的多级缓存: L1本地缓存 + L2 分布式缓存。
多级缓存会更多的增加维护负担,且在大部分场景带来的提升效果并不大。
适用场景:
Redis(Remote Dictionary Server,远程字典服务器)是一种基于内存的键值型的NoSql数据库:
特征:
键值型 —— 是指Redis中存储的数据都是以key、value对的形式存储,而value的形式多种多样,可以是字符串、数值、json。
NoSql —— 可以翻译做Not Only Sql(不仅仅是SQL),或者是No Sql(非Sql的)数据库。是相对于传统关系型数据库而言,有很大差异的一种特殊的数据库,因此也称之为非关系型数据库。
Redis官方: https://redis.io/
SQL | NoSQL | |
---|---|---|
数据结构 | 结构化 | 非结构化 |
数据关联 | 关联的 | 非关联的 |
查询方式 | SQL查询 | 非SQL |
事务特性 | ACID | BASE |
存储方式 | 硬盘 | 内存 |
扩展性 | 垂直 | 水平 |
使用场景 | 1. 数据结构固定 2. 相关业务对数据安全、一致性要求较高 |
1. 数据结构不固定 2. 对安全性、一致性要求不高 3. 对性能有要求 |
结构化与非结构化
关联与非关联
查询方式
事务
存储方式
扩展性
一般选择在linux系统下安装。
添加gcc依赖:
yum install -y gcc tcl
下载安装包,并上传
解压缩:
tar -xzvf redis-6.2.6.tar.gz
进入redis目录:
cd redis-6.2.6
运行编译命令:
make && make install
默认的安装路径是在 /usr/local/bin
目录下。
该目录已经默认配置到环境变量,因此可以在任意目录下运行这些命令。其中:
(一般选择开机自启)
默认启动:任意目录下执行命令(属于前台启动,会阻塞窗口,一般不用)
redis-server
指定配置启动: 修改Redis配置文件,使其能后台启动。
修改:在 解压的redis安装包下(/usr/local/src/redis-6.2.6
)的redis.conf,修改内容:
# 允许访问的地址,默认是127.0.0.1,会导致只能在本地访问。修改为0.0.0.0则可以在任意IP访问,生产环境不要设置为0.0.0.0
bind 0.0.0.0
# 守护进程,修改为yes后即可后台运行
daemonize yes
# 密码,设置后访问Redis必须输入密码
requirepass zzc
其他常见配置:
# 监听的端口
port 6379
# 工作目录,默认是当前目录,也就是运行redis-server时的命令,日志、持久化等文件会保存在这个目录
dir .
# 数据库数量,设置为1,代表只使用1个库,默认有16个库,编号0~15
databases 1
# 设置redis能够使用的最大内存
maxmemory 512mb
# 日志文件,默认为空,不记录日志,可以指定日志文件名
logfile "redis.log"
启动、停止:
# 进入redis安装目录
cd /usr/local/src/redis-6.2.6
# 启动
redis-server redis.conf
# 利用redis-cli来执行 shutdown 命令,即可停止 Redis 服务,
# 因为之前配置了密码,因此需要通过 -u 来指定密码
redis-cli -u zzc shutdown
开机自启:
新建一个系统服务文件:
vi /etc/systemd/system/redis.service
内容为:
[Unit]
Description=redis-server
After=network.target
[Service]
Type=forking
ExecStart=/usr/local/bin/redis-server /usr/local/src/redis-6.2.6/redis.conf
PrivateTmp=true
[Install]
WantedBy=multi-user.target
重载系统服务:
systemctl daemon-reload
然后,就可以用下面命令来操作redis了:
# 启动
systemctl start redis
# 停止
systemctl stop redis
# 重启
systemctl restart redis
# 查看状态
systemctl status redis
执行下面的命令,可以让redis开机自启:
systemctl enable redis
命令行客户端:
redis-cli [options] [commonds]
其中常见的options有:
-h 192.168.205.129
:指定要连接的redis节点的IP地址,默认是127.0.0.1-p 6379
:指定要连接的redis节点的端口,默认是6379-a zzc
:指定redis的访问密码 (也可以后续使用auth 密码 来进入访问)其中的commonds就是Redis的操作命令,例如:
ping
:与redis服务端做心跳测试,服务端正常会返回pong
不指定commond时,会进入redis-cli
的交互控制台。
图形化桌面客户端:
https://github.com/lework/RedisDesktopManager-Windows/releases
Redis是典型的key-value数据库,key一般是字符串,而value包含很多不同的数据类型:
类型 | 例子 |
---|---|
String | hello world |
Hash | {name: “Tom”, age: 21} |
List | [A -> B -> C] |
Set | {A, B, C} |
SortedSet | {A:1, B:2, C:3} |
GED | { A : (120.3, 30.5) } |
BitMap | 0110110101110101011 |
HyperLog | 0110110101110101011 |
常见数据类型: String, Hash, List, Set, Zset(有序集合)
后添加的数据类型: BitMap,HyperLogLog, GEO, Stream
String 类型的应用场景:缓存对象、常规计数、分布式锁、共享 session 信息等。(因为Redis是单线程,能确保命令的原子性)
常规计数:
SET num:1 0
INCR num:1 # +1
INCR num:1 # +1
GET num:1 # num:1为2
分布式锁:
lock_key是key键; unique_value是客户端唯一标识;NX表示不存在才插入, 插入成功即加锁成功; PX 10000是过期时间10s
SET lock_key unique_value NX PX 10000
解锁就是将lock_key删除,且必须是加锁的客户端进行解锁,即unique_value是否为加锁客户端。
加解锁是两个操作,所以需要Lua脚本来保证锁的原子性,因为Redis是以原子性的方式执行Lua脚本。
// 释放锁时,先比较 unique_value 是否相等,避免锁的误释放
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end
共享Session信息
一般后台管理系统会使用Session保存用户的会话登录状态,但在分布式系统中不能共享Session信息,所以使用Redis统一存储管理Session信息, 所有服务器都去同一个Redis获取Session。
List 类型的应用场景:消息队列(但是有两个问题:1. 生产者需要自行实现全局唯一 ID;2. 不能以消费组形式消费数据)等。
消息队列:
消息队列存取消息,要满足三个需求:消息保序,处理重复消息,保证消息可靠性。
缺点: 不支持多个消费者消费同一条消息,即不支持消费组。因为消息一旦取出,就从List中删除了。
在官网的commands处可以查看所有命令。
可在reids命令行中,使用 help @xxx 来查看
redis中的 key键 可以有各种不同层级的前缀,以避免key冲突,推荐格式为:
项目名:业务名:类型:id
如项目为zzc,有user和product两种不同类型的数据,可以定义为:
- user相关的key:zzc:user:1
- product相关的key:zzc:product:1
另外,如果Value是一个Java对象,可以将对象序列化为JSON字符串后存储。如:
KEY | VALUE |
---|---|
heima:user:1 | {“id”:1, “name”: “Jack”, “age”: 21} |
String类型,即字符串类型,是Redis中最简单的存储类型。
其value是字符串,不过根据字符串的格式不同,又可以分为3类:
不管是哪种格式,底层都是字节数组形式存储,只不过是编码方式不同。字符串类型的最大空间不能超过512m.
命令:
Hash类型,其value是一个无序字典(其value由field和value组成),类似于Java中的HashMap结构。
Hash结构可以将对象中的每个字段独立存储,可以针对单个字段做CRUD:
Key | value | |
---|---|---|
field | value | |
zzc:user:1 | name | Tom |
age | 29 | |
zzc:user:2 | name | Rose |
age | 18 |
Hash的常见命令有:
HSET key field value:添加或者修改hash类型key的field的值
HGET key field:获取一个hash类型key的field的值
HMSET:批量添加多个hash类型key的field的值
HMGET:批量获取多个hash类型key的field的值
HGETALL:获取一个hash类型的key中的所有的field和value
HKEYS:获取一个hash类型的key中的所有的field
HINCRBY:让一个hash类型key的字段值自增并指定步长
HSETNX:添加一个hash类型的key的field值,前提是这个field不存在,否则不执行
Redis中的List类型与Java中的LinkedList类似,可以看做是一个双向链表结构。既可以支持正向检索和也可以支持反向检索。
特征也与LinkedList类似:
常用来存储一个有序数据,例如:朋友圈点赞列表,评论列表等。
List的常见命令有:
表的哈希结构
Redis的Set结构与Java中的HashSet类似,可以看做是一个value为null的HashMap。因为也是一个hash表,因此具备与HashSet类似的特征:
无序
元素不可重复
查找快
支持交集、并集、差集等功能
Set的常见命令有:
Redis的SortedSet是一个可排序的set集合,与Java中的TreeSet有些类似,但底层数据结构却差别很大。SortedSet中的每一个元素都带有一个score属性,可以基于score属性对元素排序,底层的实现是一个跳表(SkipList)加 hash表。
SortedSet具备下列特性:
因为SortedSet的可排序特性,经常被用来实现排行榜这样的功能。
SortedSet的常见命令有:
注意:所有的排名默认都是升序,如果要降序则在命令的Z后面添加REV即可,例如:
升序获取sorted set 中的指定元素的排名:ZRANK key member
降序获取sorted set 中的指定元素的排名:ZREVRANK key memeber
Bitmap,即位图,是一串连续的二进制数组(0和1),可以通过偏移量(offset)定位元素。 底层是用String类型实现的。(String以二进制方式处理其buf[]数组)
常用命令:
用于统计基数的数据类型, 基数统计是指统计一个集合中不重复的元素个数。
每个 HyperLogLog 键只需要花费 12 KB 内存,就可以计算接近 264 个不同元素的基数。 HyperLogLog的统计规则是基于概率完成的,误算率大约0.81%。
优点: 在输入元素的数量或者体积非常非常大时,计算基数所需的内存空间总是固定的、并且是很小的。
(内部实现设计了大量数学问题)
命令(只有3个):
主要用于存储地理位置信息。
内部实现是直接使用了Sorted Set。 GEO 类型使用 GeoHash 编码方法实现了经纬度到 Sorted Set 中元素权重分数的转换,这其中的两个关键机制就是「对二维地图做区间划分」和「对区间进行编码」。一组经纬度落在某个区间后,就用区间的编码值来表示,并把编码值作为 Sorted Set 元素的权重分数。
这样一来,就可以利用 Sorted Set 提供的“按权重进行有序范围查找”的特性,实现 LBS 服务中频繁使用的“搜索附近”的需求。
常用命令:
Str
推荐使用的客户端有: Jedis,Letture,Redisson
Jedis和Lettuce:这两个主要是提供了Redis命令对应的API,方便操作Redis
SpringDataRedis对这两种做了抽象和封装,因此可以直接通过SpringDataRedis来学习。
Redisson:是在Redis基础上实现了分布式的可伸缩的java数据结构,例如Map、Queue等,而且支持跨进程的同步机制:Lock、Semaphore等待,比较适合用来实现特殊的功能需求。
官网: https://github.com/redis/jedis
(普通Maven项目)引入依赖:
<dependency>
<groupId>redis.clientsgroupId>
<artifactId>jedisartifactId>
<version>3.7.0version>
dependency>
<dependency>
<groupId>org.junit.jupitergroupId>
<artifactId>junit-jupiterartifactId>
<version>5.7.0version>
<scope>testscope>
dependency>
建立连接,测试,释放资源:
private Jedis jedis;
@BeforeEach
void setUp() {
// 1.建立连接
// jedis = new Jedis("192.168.205.129", 6379);
jedis = JedisConnectionFactory.getJedis();
// 2.设置密码
jedis.auth("zzc");
// 3.选择库
jedis.select(0);
}
@Test
void testString() {
// 存入数据
String result = jedis.set("name", "Tom");
System.out.println("result = " + result);
// 获取数据
String name = jedis.get("name");
System.out.println("name = " + name);
}
@Test
void testHash() {
// 插入hash数据
jedis.hset("user:1", "name", "Jack");
jedis.hset("user:1", "age", "21");
// 获取
Map<String, String> map = jedis.hgetAll("user:1");
System.out.println(map);
}
@AfterEach
void tearDown() {
if (jedis != null) {
jedis.close();
}
}
Jedis本身是线程不安全的,并且频繁的创建和销毁连接会有性能损耗,因此推荐使用 Jedis连接池代替Jedis的直连方式。
package com.zzc.jedis.util;
import redis.clients.jedis.*;
public class JedisConnectionFactory {
private static JedisPool jedisPool;
static {
// 配置连接池
JedisPoolConfig poolConfig = new JedisPoolConfig();
poolConfig.setMaxTotal(8);
poolConfig.setMaxIdle(8);
poolConfig.setMinIdle(0);
poolConfig.setMaxWaitMillis(1000);
// 创建连接池对象,参数:连接池配置、服务端ip、服务端端口、超时时间、密码
jedisPool = new JedisPool(poolConfig, "192.168.205.129", 6379, 1000, "zzc");
}
//调用此方法,获取Jedis连接
public static Jedis getJedis(){
return jedisPool.getResource();
}
}
(推荐使用其中的 StringRedisTemplate )
SpringData是Spring中数据操作的模块,包含对各种数据库的集成,其中对Redis的集成模块就叫做SpringDataRedis,官网地址:https://spring.io/projects/spring-data-redis
功能:
常用API:
API | 返回值类型 | 说明 |
---|---|---|
redisTemplate.opsForValue() | ValueOperations | 操作String类型 |
redisTemplate.opsForHash() | HashOperations | 操作Hash类型 |
redisTemplate.opsForList() | ListOperations | 操作List类型 |
redisTemplate.opsForSet() | SetOperations | 操作Set类型 |
redisTemplate.opsForZSet() | ZSetOperations | 操作SortedSet类型 |
redisTemplate | 通用命令 |
springboot 已提供对 SpringDataRedis 的支持。
新建spring项目,勾选lombok,Spring Data Redis (Access+Driver)
引入依赖:
<dependencies>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-data-redisartifactId>
dependency>
<dependency>
<groupId>org.apache.commonsgroupId>
<artifactId>commons-pool2artifactId>
dependency>
<dependency>
<groupId>com.fasterxml.jackson.coregroupId>
<artifactId>jackson-databindartifactId>
dependency>
dependencies>
配置application.yaml
spring:
redis:
host: 192.168.205.129
port: 6379
password: zzc
lettuce:
pool:
max-active: 8
max-idle: 8
min-idle: 0
max-wait: 100ms
注入 RedisTemplate,编写测试:
@SpringBootTest
class RedisStringTests {
@Autowired
private RedisTemplate redisTemplate;
@Test
void testString() {
// 写入一条String数据
redisTemplate.opsForValue().set("name", "Tom");
// 获取string数据
Object name = stringRedisTemplate.opsForValue().get("name");
System.out.println("name = " + name);
}
}
RedisTemplate可以接收任意Object作为值写入Redis,只不过写入前会把Object序列化为字节形式,默认是采用JDK序列化,最终得到的结果可读性性差,且内存占用也大。
所以使用时,可自定义RedisTemplate的序列化方式:
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory){
// 创建RedisTemplate对象
RedisTemplate<String, Object> template = new RedisTemplate<>();
// 设置连接工厂
template.setConnectionFactory(connectionFac tory);
// 创建JSON序列化工具
GenericJackson2JsonRedisSerializer jsonRedisSerializer =
new GenericJackson2JsonRedisSerializer();
// 设置Key的序列化
template.setKeySerializer(RedisSerializer.string());
template.setHashKeySerializer(RedisSerializer.string());
// 设置Value的序列化
template.setValueSerializer(jsonRedisSerializer);
template.setHashValueSerializer(jsonRedisSerializer);
// 返回
return template;
}
}
得到的结果,除了定义的数据外,还有带有对象的class类型。如:
{
"@class": "com.zzc.redis.pojo.User",
"name": "Tom",
"age": 19
}
整体可读性有了很大提升,并且能将Java对象自动的序列化为JSON字符串,并且查询时能自动把JSON反序列化为Java对象。不过,其中记录了序列化时对应的class名称,目的是为了查询时实现自动反序列化。这会带来额外的内存开销。
为了节省内存空间,我们可以不使用JSON序列化器来处理value,而是统一使用String序列化器,要求只能存储String类型的key和value。当需要存储Java对象时,手动完成对象的序列化和反序列化。
这种用法比较普遍,因此SpringDataRedis就提供了RedisTemplate的子类:StringRedisTemplate,它的key和value的序列化方式默认就是String方式。所以就不用自己去定义RedisTemplate的序列化方式,而是直接使用:
@Autowired
private StringRedisTemplate stringRedisTemplate;
// JSON序列化工具
private static final ObjectMapper mapper = new ObjectMapper();
@Test
void testSaveUser() throws JsonProcessingException {
// 创建对象
User user = new User("Rose", 21);
// 手动序列化
String json = mapper.writeValueAsString(user);
// 写入数据
stringRedisTemplate.opsForValue().set("user:200", json);
// 获取数据
String jsonUser = stringRedisTemplate.opsForValue().get("user:200");
// 手动反序列化
User user1 = mapper.readValue(jsonUser, User.class);
System.out.println("user1 = " + user1);
}
例如: 登录业务要保存用户信息: login:user:10
这样设计的好处:
可读性强
避免key冲突
方便管理
更节省内存 —— string类型底层编码有int,embstr,raw三种。 embstr是连续内存空间,小于44字节时使用; raw的内存空间不连续,采用一个指针指向另外片空间, 访问性能略有影响,还可能产生内存碎片。
使用命令 object encoding key值, 可以查看对应value值的编码。
key的推荐大小
BigKey的危害:
发现BigKey:
redis-cli提供的–bigkeys参数,可以遍历分析所有key,并返回Key的整体统计信息与每个数据的Top1的big key
redis-cli -a 密码 --bigkeys
scan扫描,自己编程判断key长度。
使用第三方工具,如Redis-Rdb-Tools分析RDB快照文件。
网络监控。
删除BigKey:
例如:
存储一个User对象,有三种存储方式:
json字符串
user:1 | {“name”: “Jack”, “age”: 21} |
---|
优点:实现简单
缺点:数据耦合,不够灵活
字段打散
user:1:name | Jack |
---|---|
user:1:age | 21 |
优点:可以灵活访问对象任意字段
缺点:占用空间大,不能做统一控制
hash (推荐)
user:1 | name | jack |
age | 21 |
优点:底层使用ziplist,空间占用小,可以灵活访问对象的任意字段
缺点:代码相对复杂
假如hash类型的key,其数量有100万。
key | value |
id:0 | value0 |
..... | ..... |
id:999999 | value999999 |
解决: 拆分为小的hash,如将id/100作为key,id%100作为field:
key | field | value |
key:0 | id:00 | value0 |
..... | ..... | |
id:99 | value99 | |
key:1 | id:00 | value100 |
..... | ..... | |
id:99 | value199 | |
.... | ||
key:9999 | id:00 | value999900 |
..... | ..... | |
id:99 | value999999 |
redis 一次命令的响应时间 = 1次网络往返 + 1次redis命令执行
redis N次命令的响应时间 = N次网络往返 + N次redis命令执行
因为redis处理命令是极快的,所以大部分耗时是发生在网络传输。 所以,可以将多条指令批量传给redis:
redis批量处理的方法: Mxxx命令, Pipeline管道方法
Mxxx命令, 如mset, hmset命令, 例子:使用mset批量插入10万条数据
@Test
void testMxx() {
String[] arr = new String[2000];
int j;
long b = System.currentTimeMillis();
for (int i = 1; i <= 100000; i++) {
j = (i % 1000) << 1;
arr[j] = "test:key_" + i;
arr[j + 1] = "value_" + i;
if (j == 0) {
jedis.mset(arr);
}
}
long e = System.currentTimeMillis();
System.out.println("time: " + (e - b));
}
Pipeline
MSET虽然可以批处理,但是却只能操作部分数据类型,因此如果有对复杂数据类型的批处理需要,建议使用Pipeline
@Test
void testPipeline() {
// 创建管道
Pipeline pipeline = jedis.pipelined();
long b = System.currentTimeMillis();
for (int i = 1; i <= 100000; i++) {
// 放入命令到管道
pipeline.set("test:key_" + i, "value_" + i);
if (i % 1000 == 0) {
// 每放入1000条命令,批量执行
pipeline.sync();
}
}
long e = System.currentTimeMillis();
System.out.println("time: " + (e - b));
}
如果Redis是一个集群,那批处理命令的多个key必须落在一个插槽中,否则就会导致执行失败。但问题是,在批处理时,一次插入的很多条数据,很有可能不会都落在相同的节点上,这会导致报错。
一般有四种解决方案:
串行命令 | 串行slot | 并行slot | hash_tag | |
---|---|---|---|---|
实现思路 | for循环命令,依次执行每个命令 | 在客户端先计算每个key的slot,进行分组,每组再进行批处理。 (串行执行各组命令) | 同样将key根据slot分组,但并行执行各组命令 | 将所有的key设置相同的有效部分,则所有key的slot一定相同 |
网络耗时 | N次 | m次, m = 这批key的slot个数 | 1次 | 1次 |
优点 | 实现简单 | 耗时较短 | 耗时非常短 | 耗时非常短,实现简单 |
缺点 | 耗时很久 | 实现较复杂;且slot越多,耗时越久 | 实现复杂 | 容易出现数据倾斜 |
所以一般选择并行slot。
Redis的持久化虽然可以保证数据安全,但也会带来很多额外的开销,因此持久化请遵循下列建议:
在Redis执行时耗时超过某个阈值的命令,称为慢查询。
危害:由于Redis是单线程的,所以当客户端发出指令后,他们都会进入到redis底层的queue来执行,如果此时有一些慢查询的数据,就会导致大量请求阻塞。
慢查询的配置:
slowlog-log-slower-than: 慢查询阈值,单位是微秒。默认是10000,建议1000
slowlog-max-len:慢查询日志(本质是一个队列)的长度。默认是128,建议1000
查看慢查询:
Redis会绑定在0.0.0.0:6379,这样将会将Redis服务暴露到公网上,而Redis如果没有做身份认证,会出现严重的安全漏洞。
而Redis可以免密登录,Redis有一种ssh免秘钥登录的方式,生成一对公钥和私钥,私钥放在本地,公钥放在redis端。 但是Redis的漏洞在于在不登录的情况下,也能把秘钥送到Linux服务器,从而产生漏洞。
总结,漏洞出现的核心的原因有以下几点:
为了避免这样的漏洞,有以下建议:
当Redis内存不足时, 肯能导致Key被频繁删除,响应时间变长,QPS不稳定等。 当内存使用率达90%以上就要注意了,并定位到内存占用原因。
查看Redis内存分配:
内存占用 | 说明 |
---|---|
数据内存 | 是Redis最主要的部分,存储Redis的键值信息。主要问题是BigKey问题、内存碎片问题 |
进程内存 | Redis主进程本身运行占用的内存,如代码、常量池等; 一般只有几兆,可以忽略。 |
缓冲区内存 | 一般包括客户端缓冲区、AOF缓冲区、复制缓冲区等。客户端缓冲区又包括输入缓冲区和输出缓冲区两种。这部分内存占用波动较大,不当使用BigKey,可能导致内存溢出。 |
其中缓冲区内存 的占用波动较大,是需要重点分析的地方。 常见的内存缓冲区有三种:
以上会出问题的是客户端的输出缓冲区,如果Redis需要处理大量的big value,那么会导致 输出结果过多,如果输出缓存区过大,会导致redis直接断开,而默认配置是不限制大小的,导致内存可能一下子被占满,会直接导致redis断开,所以解决方案有两个:
1、设置一个大小
2、增加我们带宽的大小,避免我们出现大量数据从而直接超过了redis的承受能力
单体Redis(主从Redis)已经能达到万级别的QPS,也具备很强的高可用特性。如果主从能满足业务需求的情况下,尽量不搭建Redis集群。
集群虽然具备高可用特性,能实现自动故障恢复,但是如果使用不当,也会存在一些问题:
集群完整性问题 —— 在Redis的默认配置中,如果发现任意一个插槽不可用,则整个集群都会停止对外服务。
集群带宽问题 —— 集群节点之间会不断的互相Ping来确定集群中其它节点的状态。(ping信息:slot信息 + 集群状态信息, 集群节点越多,ping信息越大, 10节点的信息就可达1kb)
解决:
数据倾斜问题
命令的集群兼容性问题 —— 批处理命令要求key必须落在相同的slot上,解决方法在前面的集群批处理中。
lua和事务问题 —— lua和事务都是要保证原子性问题,如果key不在一个节点,那么是无法保证lua的执行和事务的特性的,所以在集群模式是没有办法执行lua和事务的
原因一般有两种: 大量数据同时过期,Redis 故障宕机。
当大量缓存数据在同一时间过期时,如果此时有大量的用户请求,都无法在 Redis 中处理,或者Redis宕机了,于是全部请求都直接访问数据库,从而导致数据库的压力骤增,严重的会造成数据库宕机,从而形成一系列连锁反应,造成整个系统崩溃。
应对方案:
均匀设置过期时间: 我们可以在原有的失效时间基础上增加一个随机值(比如 1 到 10 分钟)这样每个缓存的过期时间都不重复了,也就降低了缓存集体失效的概率。
互斥锁: 如果发现访问的数据不在 Redis 里,就加个互斥锁,保证同一时间内只有一个请求来构建缓存,当缓存构建完成后,再释放锁。未能获取互斥锁的请求,要么等待锁释放后重新读取缓存,要么就返回空值或者默认值。
双key策略: 缓存数据使用两个 key,主 key会设置过期时间,备 key不设置过期,两者只是 key 不一样,但是 value 值是一样的,相当于是副本。 当访问不到主key时,就字节返回 备key,再更新 主key和备key 的数据。
后台更新缓存:业务线程不再负责更新缓存, 缓存也不设有效期, 缓存的更新都交给后台线程定时更新。
Redis故障宕机时,应对:
服务熔断或请求限流机制;
服务熔断就是 暂停业务应用 对缓存服务的访问,直接返回错误,不用再继续访问数据库。
请求限流就是 只将少部分请求发送到数据库进行处理,再多的请求就在入口直接拒绝服务。
构建 Redis 主从 或者 高可用集群;
如果缓存中的某个热点数据过期了,此时大量的请求访问了该热点数据,就无法从缓存中读取,直接访问数据库,数据库很容易就被高并发的请求冲垮。(如秒杀活动等)
应对方案:
当发生缓存雪崩或击穿时,数据库中还是保存了应用要访问的数据,一旦缓存恢复相对应的数据,就可以减轻数据库的压力,而缓存穿透就不一样了。
缓存穿透: 当用户访问的数据,既不在缓存中,也不在数据库中,导致请求在访问缓存时,发现缓存缺失,再去访问数据库时,发现数据库中也没有要访问的数据,也没办法构建缓存数据。那么当有大量这样的请求到来时,数据库的压力就会骤增。
发生缓存穿透的情况一般有两种:
应对方案:
布隆过滤器由「初始值都为 0 的位图数组」和「 N 个哈希函数」两部分组成。
当我们在写入数据库数据时,在布隆过滤器里进行标记,这样下次查询数据是否在数据库时,只需要查询布隆过滤器,如果查询到数据没有被标记,说明不在数据库中。(会出现误判情况)
流程:
如果考虑删除元素的话,布隆过滤器需要带计数器,需要占用更多空间。
Redis的SET命令有个 NX参数,表示“key不存在时才插入”,可以用来实现分布式锁:
注意点: 需要设置过期时间,以免客户端异常无法解锁; 锁变量对应不同客户端应该是唯一值,用于标识,解锁人必须是加锁人。
加锁命令如下:(PX 10000指过期时间10s)
SET lock_key unique_value NX PX 10000
解锁时,需要先比较unique_value是否一致,再删除lock_key, 这儿的两个操作需要保证原子性,所以用Lua脚本来自行命令:
// 释放锁时,先比较 unique_value 是否相等,避免锁的误释放
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end
使用Redis实现分布式锁,优点:
缺点:
超时时间不好设置,太长会影响性能,太短不能保护共享资源。
合理设置超时时间的建议:
可以基于续约的方式设置超时时间:先给锁设置一个超时时间,然后启动一个守护线程,让守护线程在一段时间后,重新设置这个锁的超时时间。 不过实现比较复杂。
Redis 主从复制模式中的数据是异步复制的,这样导致分布式锁的不可靠性。如果在 Redis 主节点获取到锁后,在没有同步到其他节点时,Redis 主节点宕机了,此时新的 Redis 主节点依然可以获取锁,所以多个应用服务就可以同时获取到锁。
Redis对集群下分布式锁的可靠性保证的做法:
Redis 官方已经设计了一个分布式锁算法 Redlock(红锁)来保证集群环境下分布式锁的可靠性。
Redlock 算法的基本思路,是让客户端和多个独立的 Redis 节点依次请求申请加锁,如果客户端能够和半数以上的节点成功地完成加锁操作,那么我们就认为,客户端成功地获得分布式锁,否则加锁失败。
Redlock 算法加锁三个过程:
可看出,加锁成功要同时满足两个条件: 超过半数的 Redis 节点成功的获取到了锁,并且总耗时没有超过锁的有效时间。
加锁成功后,客户端需要重新计算这把锁的有效时间,计算的结果是「锁最初设置的过期时间」减去「客户端从大多数节点获取锁的总耗时(t2-t1)」。如果计算的结果已经来不及完成共享数据的操作了,我们可以释放锁,以免出现还没完成数据操作,锁就过期了的情况。
加锁失败后,客户端向所有 Redis 节点发起释放锁的操作,释放锁的操作和在单节点上释放锁的操作一样,只要执行释放锁的 Lua 脚本就可以了。
单机Redis存在有以下问题:
有两种方案:
RDB,也被叫做Redis数据快照。简单来说就是把内存中的所有数据都记录到磁盘中。当Redis实例故障重启后,从磁盘读取快照文件,恢复数据。(快照文件称为RDB文件,默认是保存在当前运行目录。)
RDB持久化会在以下四种情况下执行:
执行save命令 —— save命令会导致主进程执行RDB,这个过程中其它所有命令都会被阻塞。(通常只有在数据迁移时可能用到。)
执行bgsave命令 —— bgsave命令会开启独立进程完成RDB,主进程可以持续处理用户请求,不受影响。
**Redis停机时 ** —— Redis停机时会执行一次save命令,实现RDB持久化。
触发RDB条件时 —— Redis内部有触发RDB的机制,可以在redis.conf文件中找到:
# 900秒内,如果至少有1个key被修改,则执行bgsave , 如果是save "" 则表示禁用RDB
save 900 1
#save 300 10
#save 60 10000
# 是否压缩 ,建议不开启,压缩也会消耗cpu,磁盘的话不值钱
rdbcompression yes
# RDB文件名称
dbfilename dump.rdb
# 文件保存的路径目录
dir ./
RDB的bgsave命令原理:
bgsave开始时会fork主进程得到子进程,子进程共享主进程的内存数据。完成fork后读取主进程的内存数据并写入 RDB 文件。
fork采用的是copy-on-write技术(写时复制,即内存数据只能被读,要写入的话需要先拷贝一份,然后写入拷贝的数据中)
AOF,即 追加文件。Redis处理的每一个写命令都会记录在AOF文件,可以看做是命令日志文件。
如:
set num 1234
记在AOF文件是:
$3
set
$3
num
$4
1234
配置:
AOF默认是关闭的,需要修改redis.conf配置文件来开启AOF
# 是否开启AOF功能,默认是no
appendonly yes
# AOF文件的名称
appendfilename "appendonly.aof"
#记录的频率:
# 表示每执行一次写命令,立即记录到AOF文件
appendfsync always
# 写命令执行完先放入AOF缓冲区,然后表示每隔1秒将缓冲区数据写到AOF文件,是默认方案
appendfsync everysec
# 写命令执行完先放入AOF缓冲区,由操作系统决定何时将缓冲区内容写回磁盘
appendfsync no
三种记录频率对比:
配置 | 刷盘时机 | 优点 | 缺点 |
---|---|---|---|
Always | 同步刷盘 | 可靠性高,几乎不会丢数据 | 性能影响大 |
everysec | 每秒刷盘 | 性能适中 | 最多丢失1秒数据 |
no | 操作系统控制 | 性能最好 | 可能丢失大量数据 |
AOF文件重写 —— bgrewriteaof
由于是记录命令,AOF文件比RDB文件大的多;而且对同一个key的多次写操作,只有最后一次写操作有意义。
所以,通过 bgrewriteaof 命令,可以让AOF文件执行重写功能,用最少的命令达到相同效果。
Redis也会在触发阈值时自动去重写AOF文件。阈值也可以在redis.conf中配置:
# AOF文件比上次文件 增长超过多少百分比则触发重写
auto-aof-rewrite-percentage 100
# AOF文件体积最小多大以上才触发重写
auto-aof-rewrite-min-size 64mb
RDB | AOF | |
---|---|---|
持久化方式 | 定时对整个内存做快照 | 记录每一次执行的命令 |
数据完整性 | 不完整,两次备份之间会丢失 | 相对完整,取决于刷盘策略 |
文件大小 | 会压缩,文件体积小 | 记录命令,文件体积很大 |
宕机恢复速度 | 很快 | 慢 |
数据恢复优先级 | 低,因为数据完整性不如AOF | 高,因为数据完整性更高 |
系统资源占用 | 高,大量CPU和内存消耗 | 低,主要是磁盘IO资源;但AOF重写时会占用大量CPU和内存资源 |
使用场景 | 可以容忍数分钟的数据丢失,追求更快的启动速度 | 对数据安全性要求较高时 |
单节点Redis的并发能力是有上限的,要进一步提高Redis的并发能力,就需要搭建主从集群,实现读写分离。
主从第一次建立连接,会执行一次全量同步,将master节点的所有数据都拷贝给slave节点,
执行时机:
流程如下:
master如何得知salve是第一次来连接:
因为slave原本也是一个master,有自己的replid和offset,当第一次变成slave,与master建立连接时,发送的replid和offset是自己的replid和offset。
master判断发现slave发送来的replid与自己的不一致,说明这是一个全新的slave,就知道要做全量同步了。
master会将自己的replid和offset都发送给这个slave,slave保存这些信息。以后slave的replid就与master一致了。
增量同步,就是只更新slave与master存在差异的部分数据。
时机:slave节点断开又恢复,并且在repl_baklog中能找到offset时
要点:repl_backlog文件,是一个固定大小的环形数组。
repl_baklog中会记录Redis处理过的命令日志及offset,包括master当前的offset 和slave的offset。slave与master的offset之间的差异,就是salve需要增量拷贝的数据了。
不过,如果slave节点断开了,时间一久,master继续写入新数据,其offset就会覆盖旧的数据,直到将slave现在的offset也覆盖。 即尚未同步的数据被覆盖了,slave恢复后,发现自己的offset没有了,就只能做全量同步了。
可以从以下几个方面来优化Redis主从就集群:
主从从架构图:
在一台虚拟机上开启3个实例,需要准备3份不同的配置文件和目录。
创建目录,分别为7001,7002,7003:
# 进入/tmp/redis-test目录
cd /tmp/redis-test
# 创建目录
mkdir 7001 7002 7003
拷贝配置文件到每个实例目录:
# 方式一:逐个拷贝
cp /usr/local/src/redis-6.2.6/redis.conf 7001
cp /usr/local/src/redis-6.2.6/redis-6.2.4/redis.conf 7002
cp /usr/local/src/redis-6.2.6/redis-6.2.4/redis.conf 7003
# 方式二:管道组合命令,一键拷贝
echo 7001 7002 7003 | xargs -t -n 1 cp /usr/local/src/redis-6.2.6/redis-6.2.4/redis.conf
修改每个实例的端口、工作目录
sed -i -e 's/6379/7001/g' -e 's/dir .\//dir \/tmp\/7001\//g' 7001/redis.conf
sed -i -e 's/6379/7002/g' -e 's/dir .\//dir \/tmp\/7002\//g' 7002/redis.conf
sed -i -e 's/6379/7003/g' -e 's/dir .\//dir \/tmp\/7003\//g' 7003/redis.conf
修改每个实例的声明ip
虚拟机本身有多个IP,为了避免将来混乱,我们需要在redis.conf文件中指定每一个实例的绑定ip信息,格式如下:
# redis实例的声明 IP
replica-announce-ip 192.168.205.129
可以用命令完成:
# 逐一执行
sed -i '1a replica-announce-ip 192.168.205.129' 7001/redis.conf
sed -i '1a replica-announce-ip 192.168.205.129' 7002/redis.conf
sed -i '1a replica-announce-ip 192.168.205.129' 7003/redis.conf
# 或者一键修改
printf '%s\n' 7001 7002 7003 | xargs -I{} -t sed -i '1a replica-announce-ip 192.168.205.129' {}/redis.conf
启动
# 第1个
redis-server 7001/redis.conf
# 第2个
redis-server 7002/redis.conf
# 第3个
redis-server 7003/redis.conf
开启主从关系:
修改配置文件(永久生效):
在redis.conf添加配置:slaveof 主节点ip 主节点端口
连接服务,执行slaveof命令(临时生效):
slaveof 主节点ip 主节点端口
例如:
通过redis-cli命令连接7002,执行命令:
# 连接 7002
redis-cli -p 7002
# 执行slaveof
slaveof 192.168.205.129 7001
通过redis-cli命令连接7003,执行命令:
# 连接 7003
redis-cli -p 7003
# 执行slaveof
slaveof 192.168.205.129 7001
然后连接 7001节点,查看集群状态:
# 连接 7001
redis-cli -p 7001
# 查看状态
info replication
Redis提供了哨兵(Sentinel)机制来实现主从集群的自动故障恢复。
哨兵的作用如下:
哨兵结构:
集群监控:
Sentinel基于心跳机制监测服务状态,每隔1秒向集群的每个实例发送ping命令:
主观下线:如果某sentinel节点发现某实例未在规定时间响应,则认为该实例主观下线。
客观下线:若超过指定数量的sentinel都认为该实例主观下线,则该实例客观下线。quorum值最好超过Sentinel实例数量的一半。
集群故障恢复:
一旦发现master故障,sentinel需要在salve中选择一个作为新的master,选择依据是这样的:
选出一个新的master后,还要实现主从切换:
当 sentinel 集群确认有 master 客观下线了,就会开始故障转移流程,故障转移流程的需要在 sentinel 集群选择一个 leader,让 leader 来负责完成故障转移。 故障转移完成后,所有Sentinel又会恢复平等。(Leader仅仅是为故障转移操作出现的角色。)
一般使用分布式领域的共识算法来选出leader, Redis是使用Raft算法的领头选举方法 在sentinel集群中选出leader。
Leader: sentinel中负责进行故障转移的角色。
Follower:进行投票的角色。
Candidate:进行选举的角色。
epoch: 年代,相当于Raft算法中的term。 Sentinel集群正常运行的时候每个节点epoch相同。 Follower想要进行选举时,会转换状态为Candidate,并让自己的epoch + 1。
流程:
某个Sentinel认定master客观下线的节点后,该Sentinel会先看看自己有没有投过票,如果自己已经投过票给其他Sentinel了,在2倍故障转移的超时时间自己就不会成为Leader。相当于它是一个Follower。
如果该Sentinel还没投过票,那么它就成为Candidate, 并进行以下操作:
is-master-down-by-addr
命令请求投票。命令会带上自己的epoch。其他Sentinel会收到Candidate的is-master-down-by-addr
命令。如果Sentinel当前epoch和Candidate传给他的epoch一样,说明他已经把自己master结构体里的leader和leader_epoch改成其他Candidate,相当于把票投给了其他Candidate。投过票给别的Sentinel后,在当前epoch内自己就只能成为Follower。
Candidate会不断的统计自己的票数,直到他发现认同他成为Leader的票数超过一半而且超过它配置的quorum。Sentinel比Raft协议增加了quorum,这样一个Sentinel能否当选Leader还取决于它配置的quorum。
如果在一个选举时间内,Candidate没有获得超过一半且超过它配置的quorum的票数,自己的这次选举就失败了。
如果在一个epoch内,没有一个Candidate获得更多的票数。那么等待超过2倍故障转移的超时时间后,Candidate增加epoch重新投票。
如果某个Candidate获得超过一半且超过它配置的quorum的票数,那么它就成为了Leader。
与Raft协议不同,Leader并不会把自己成为Leader的消息发给其他Sentinel。其他Sentinel等待Leader从slave选出master后,检测到新的master正常工作后,就会去掉客观下线的标识,从而不需要进入故障转移流程。
要写明哨兵的信息:
spring:
redis:
sentinel:
master: mymaster
nodes:
- 192.168.205.129:27001
- 192.168.205.129:27002
- 192.168.205.129:27003
在项目的启动类中,配置读写分离:
@Bean
public LettuceClientConfigurationBuilderCustomizer clientConfigurationBuilderCustomizer(){
return clientConfigurationBuilder -> clientConfigurationBuilder.readFrom(ReadFrom.REPLICA_PREFERRED);
}
这个bean中配置的就是读写策略,包括四种:
- MASTER:从主节点读取
- MASTER_PREFERRED:优先从master节点读取,master不可用才读取replica
- REPLICA:从slave(replica)节点读取
- REPLICA _PREFERRED:优先从slave(replica)节点读取,所有的slave都不可用才读取master
主从和哨兵可以解决高可用、高并发读的问题。但是依然有两个问题没有解决:
海量数据存储问题
高并发写的问题
使用分片集群可以解决上述问题,分片集群特征:
集群中有多个master,每个master保存不同数据
每个master都可以有多个slave节点(至少有一个slave)
master之间通过ping监测彼此健康状态
客户端请求可以访问集群任意节点,最终都会被转发到正确节点
结构如图:
Redis会把每一个master节点映射到 0~16383 共16384个插槽(hash slot)上,数据key不是与节点绑定,而是与插槽绑定。redis会根据key的有效部分计算插槽值,分两种情况:
例如:key是num,那么就根据num计算,如果是{itcast}num,则根据itcast计算。计算方式是利用CRC16算法得到一个hash值,然后对16384取余,得到的结果就是slot值。
提问:
Redis如何判断某个key应该在哪个实例?
公式:HASH_SLOT = CRC16(key) % NUMER_OF_SLOTS
CRC16 算法产生的校验码有 16 位,理论上可以产生 65536(2^16,0 ~ 65535)个值。
如何将同一类数据固定的保存在同一个Redis实例?
哈希槽为什么是16384个?
集群伸缩:
创建一个新的redis实例,假设端口为7004:
新建一个文件夹,修改 redis.conf 配置文件,启动。
添加该新节点到集群:
redis-cli --cluster add-node 192.168.205.129:7004 192.168.205.129:7001
192.168.205.129:7001是集群中的一个节点,只要告知集群中的一个节点,其他节点也会知道。
查看集群状态:
redis-cli -p 7001 cluster nodes
转移插槽,假设将0~3000的插槽从7001转移到7004:
在7001节点建立连接;
redis-cli --cluster reshard 192.168.205.129:7001
输入要转移到的插槽;
输入要接收插槽的节点id;(显示集群状态时开头的一长串字符就是id)
询问插槽从哪里来的:
集群扩容缩容期间仍可以提供服务: 这本质上就是进行重新分片,动态迁移哈希槽。 Redis Cluster提供了两种重定向机制:
具体过程:
Redis Cluster的各个节点基于 Gossip协议 进行通信共享信息,每个节点都维护一份集群的状态信息。
Redis Cluster的节点之间会相互发送多种Gossip消息:
MEET
在Redis Cluster中的某个Redis节点上执行 CLUSTER MEET ip port
命令,可以向指定的Redis节点发送一条MEET信息,用于将其添加进Redis Cluster成为新的Redis节点。
PING/PONG
Redis Cluster中的节点都会定时地向其他节点发送PING消息,来交换各个节点状态信息,检查各个节点状态,包括在线状态、疑似下线状态PFAL和已下线状态 FAIL。
FAIL
Redis Cluster中的节点A发现B节点PFALL,并且在下线报告的有效期限内集群中半数以上的节点将B节点标记为PFALL,节点A就会向集群广播一条FALL消息,通知其他节点将故障节点B标记为FALL。
有了Redis Cluster之后,不需要专门部署Sentinel集群服务了。Redis Cluster相当于是内置了Sentinel机制,Redis Cluster内部的各个Redis节点通过Gossip协议互相探测健康状态,在故障时可以自动切换。
自动故障转移:
当集群中有一个master宕机,该实例与其它实例失去连接,集群中它的状态:
手动故障转移:
在一个slave执行cluster failover命令可以手动让集群中的某个master宕机,然后该slave节点转变为master节点。
cluster failover命令流程:
RedisTemplate底层同样基于lettuce实现了分片集群的支持,而使用的步骤与哨兵模式基本一致:
1)引入redis的starter依赖
2)配置分片集群地址
3)配置读写分离
与哨兵模式相比,其中只有分片集群的配置方式略有差异,如下:
spring:
redis:
cluster:
nodes:
- 192.168.205.129:7001
- 192.168.205.129:7002
- 192.168.205.129:7003
- 192.168.205.129:8001
- 192.168.205.129:8002
- 192.168.205.129:8003
传统的缓存策略一般是请求到达Tomcat后,先查询Redis,如果未命中则查询数据库;这样做的问题:
请求要经过Tomcat处理,Tomcat的性能成为整个系统的瓶颈
Redis缓存失效时,会对数据库产生冲击
多级缓存就是充分利用请求处理的每个环节,分别添加缓存,减轻Tomcat压力,提升服务性能:
——
在多级缓存架构中,Nginx内部需要编写本地缓存查询、Redis查询、Tomcat查询的业务逻辑,因此这样的nginx服务不再是一个反向代理服务器,而是一个编写业务的Web服务器了。
因此这样的业务Nginx服务也需要搭建集群来提高并发,再有专门的nginx服务来做反向代理,
另外,我们的Tomcat服务将来也会部署为集群模式
——
可见,多级缓存的关键有两个:
一个是在nginx中编写业务,实现nginx本地缓存、Redis、Tomcat的查询
另一个就是在Tomcat中实现JVM进程缓存
缓存在日常开发中启动至关重要的作用,由于是存储在内存中,数据的读取速度是非常快的,能大量减少对数据库的访问,减少数据库的压力。我们把缓存分为两类:
Caffeine是一个基于Java8开发的,提供了近乎最佳命中率的高性能的本地缓存库。目前Spring内部的缓存使用的就是Caffeine。
使用:
@Test
void testBasicOps() {
// 构建cache对象
Cache<String, String> cache = Caffeine.newBuilder().build();
// 存数据
cache.put("gf", "小明");
// 取数据
String gf = cache.getIfPresent("gf");
System.out.println("gf = " + gf);
// 取数据,包含两个参数:
// 参数一:缓存的key
// 参数二:Lambda表达式,表达式参数就是缓存的key,方法体是查询数据库的逻辑
// 优先根据key查询JVM缓存,如果未命中,则执行参数二的Lambda表达式
String defaultGF = cache.get("defaultGF", key -> {
// 根据key去数据库查询数据
return "小红";
});
System.out.println("defaultGF = " + defaultGF);
}
缓存清除 —— Caffeine提供了三种缓存驱逐策略:
基于容量:设置缓存的数量上限
// 创建缓存对象
Cache<String, String> cache = Caffeine.newBuilder()
.maximumSize(1) // 设置缓存大小上限为 1
.build();
基于时间:设置缓存的有效时间
// 创建缓存对象
Cache<String, String> cache = Caffeine.newBuilder()
// 设置缓存有效期为 10 秒,从最后一次写入开始计时
.expireAfterWrite(Duration.ofSeconds(10))
.build();
基于引用:设置缓存为软引用或弱引用,利用GC来回收缓存数据。性能较差,不建议使用。
注意:在默认情况下,当一个缓存元素过期的时候,Caffeine不会自动立即将其清理和驱逐。而是在一次读或写操作后,或者在空闲时间完成对失效数据的驱逐。
多级缓存的实现离不开Nginx编程,而Nginx编程又离不开OpenResty。
OpenResty® 是一个基于 Nginx的高性能 Web 平台,用于方便地搭建能够处理超高并发、扩展性极高的动态 Web 应用、Web 服务和动态网关。具备下列特点:
官方网站: https://openresty.org/cn/
在多级缓存架构中, 我们想要一台nginx服务器存放静态资源,并做反向代理到OpenResty集群(即nginx集群),在nginx集群做缓存业务(lua脚本实现)。
Redis3.0时的实现:
Redis7.0时的实现:
redis3.2,List 的底层实现改为 quicklist。
redis5.0,引入listpack,redis7.0,Hash和ZSet的底层实现的ziplist替换为listpack
Redis使用一个 [哈希表] 来保存所有键值对,能以O(1)的速度查找键值对。 这个哈希表其实就是一个数组,数组中的元素叫做哈希桶。
redisObject构成:
Redis虽然是用C语言实现的,但它没有用C语言的char*字符数组来实现字符串,而是自己封装了一个字符串,叫 简单动态字符串(simple dynamic string,SDS)。
C的char*数组的缺点:
SDS的结构:
SDS的扩容规则:(newlen为扩容后至少需要的长度)
SDS节省内存空间:
flags的几种类型,区别在于使结构中的 len 和 alloc 变量的数据类型不同。
如:sdshdr16 类型的 len 和 alloc 的数据类型都是 uint16_t,表示字符数组长度和分配空间大小不能超过 2 的 16 次方。 (sdshdr32 则都是 uint32_t)
在struct 声明了 __attribute__ ((packed))
,作用是:告诉编译器 取消结构体在编译过程中的优化对齐,按照实际占用字节数进行对齐。
如果使用对齐方式,假设结构体中有有一个1字节的char 和 一个4字节的int,最终占用为8字节,char会和int对齐,也分配4字节。
链表节点 listNode:
typedef struct listNode {
struct listNode *prev; //前置节点
struct listNode *next; //后置节点
void *value; //节点的值
} listNode;
双向链表 list:
typedef struct list {
//链表头节点
listNode *head;
//链表尾节点
listNode *tail;
//节点值复制函数
void *(*dup)(void *ptr);
//节点值释放函数
void (*free)(void *ptr);
//节点值比较函数
int (*match)(void *ptr, void *key);
//链表节点数量
unsigned long len;
} list;
压缩列表(ziplist) 被设计成一种内存紧凑型的数据结构,占用一块连续的内存空间,不仅可以利用 CPU 缓存,而且会针对不同长度的数据,进行相应编码,这种方法可以有效地节省内存开销。
所以,List、Hash、Zset在元素数量小于512个,元素大小小于64字节时,都会使用压缩列表。(后被listpack替代)
缺点:
结构:由连续内存块组成的顺序型数据结构,类似数组
查找节点数据的时间复杂度为 O(N),因为每个节点的类型都可能不同,不过查第一个和最后一个节点的时间复杂度为O(1)。
连锁更新问题:
ziplist新增或修改某个元素时, 如果空间不够,ziplist需要重新分配内存空间。
而如果新加入元素较大,可能导致下一个元素的prevlen占用空间由1字节变为5字节,使下个元素也要重新分配空间,如果多的4字节使下个元素占用超过254字节,又使后面元素的prevlen变化,也要重新分配…,这种特殊情况下的连续多次空间扩张 就是连锁更新。
Redis采用链式哈希来解决哈希冲突。
哈希表结构:
typedef struct dictht {
dictEntry **table; //哈希表数组
unsigned long size; //哈希表大小
unsigned long sizemask; //哈希表大小掩码,用于计算索引值
unsigned long used; //该哈希表已有的节点数量
} dictht;
哈希表节点结构:
typedef struct dictEntry {
void *key; // 键
// 值
union {
void *val;
uint64_t u64;
int64_t s64;
double d;
} v;
//指向下一个哈希表节点,形成链表
struct dictEntry *next;
} dictEntry;
rehash
当哈希表快放满时,为了避免过多的哈希冲突,会进行rehash。
rehash的触发条件如下:
负载因子 = 哈表表节点数 / 哈希表大小
redis实际使用哈希表时,会定义一个dict结构体,里面再定义两个哈希表,第二个哈希表的*table平时是null,只在rehash时使用。
typedef struct dict {
…
//两个Hash表,交替使用,用于rehash操作
dictht ht[2];
…
} dict;
rehash过程:
不过在上述第二步的拷贝数据的过程,如果数据量很大,会影响Redis的性能,所以Redis采用渐进式rehash:
在渐进式rehash中,两个表都有数据,所以会先到 「哈希表 1」查找,再到「哈希表 2」找。 而新增数据只在「哈希表 2」进行。
当Set只包含整数值,且元素数量不大时,就会使用整数集合这个数据结构作为底层实现。
整数集合本质上是一块连续内存空间,结构如下:
typedef struct intset {
//编码方式
uint32_t encoding;
//集合包含的元素数量
uint32_t length;
//保存元素的数组
int8_t contents[];
} intset;
contents数组的元素类型取决于encoding的值。(INTSET_ENC_INT16,INTSET_ENC_INT32,INTSET_ENC_INT64 对应 int16_t,int32_t,int64_t)
升级操作:
整数集合会有一个升级规则,就是当我们将一个新元素加入到整数集合里面,这个新元素的类型(int32_t)比数组里所有元素的类型(int16_t)都要长时,就进行升级。
升级过程不会分配新数组,而是在原本的数组上扩展空间,从后往前将原数据放到正确位置,最后放新加入元素。
(整数集合只能升级,不能降级)
Zset的底层实现用到了跳表,具体的说是 跳表 + 哈希表,但其中的哈希表只是用于以O(1)速度获取元素权重, 其他操作都是由跳表实现的。
Zset的结构:
typedef struct zset {
dict *dict; // 哈希表
zskiplist *zsl; // 跳表
} zset;
跳表,是在链表的基础上改进而来,是一种“多层”的的有序链表,优点在于能快速定位数据:O(logN)。
Zset的跳表节点如下:
typedef struct zskiplistNode {
//Zset 对象的元素值
sds ele;
//元素权重值
double score;
//后向指针
struct zskiplistNode *backward;
//节点的level数组,保存每层上的前向指针和跨度
struct zskiplistLevel {
struct zskiplistNode *forward;
unsigned long span;
} level[];
} zskiplistNode;
跨度span 可以计算该节点在跳表中的排位。
跳表结构如下:
typedef struct zskiplist {
struct zskiplistNode *header, *tail;
unsigned long length; // 跳表长度
int level; // 跳表的最大层数
} zskiplist;
一般来说,相邻两层的节点数量的比例最好为 2 : 1,这样的跳表的查询复杂度可以降低到O(logN)。 不过,在新增或删除节点时,要调整跳表节点以维持比例的方法的话,会带来额外的开销。所以,Redis使用了一种巧妙的方法:
跳表在创建节点的时候,随机生成每个节点的层数,并不严格维持相邻两层的节点数量比例为 2 : 1 的情况。
具体做法: 跳表在创建节点时候,会生成范围为[0-1]的一个随机数,如果这个随机数小于 0.25(相当于概率 25%),那么层数就增加 1 层,然后继续生成下一个随机数,直到随机数的结果大于 0.25 结束,最终确定该节点的层数。
注: 跳表的头节点的层数为该跳表的最大层高,Redis 7.0 默认为 32层,Redis 5.0 为 64。
quicklist 就是一个链表,而链表中的每个元素又是一个压缩列表。 quicklist通过控制 节点中的压缩列表的大小或者元素个数,来减小连锁更新的危害。
在向 quicklist 添加一个元素的时候,不会像普通的链表那样,直接新建一个链表节点。而是会检查插入位置的压缩列表是否能容纳该元素,如果能容纳就直接保存到 quicklistNode 结构里的压缩列表,如果不能容纳,才会新建一个新的 。
quicklist的节点结构:
typedef struct quicklistNode {
struct quicklistNode *prev; //前一个quicklistNode
struct quicklistNode *next; //后一个quicklistNode
unsigned char *zl; //quicklistNode指向的压缩列表
unsigned int sz; //压缩列表的的字节大小
unsigned int count; //ziplist中的元素个数
....
} quicklistNode;
quicklist结构:
typedef struct quicklist {
quicklistNode *head; //quicklist的链表头
quicklistNode *tail; //quicklist的链表尾
unsigned long count; //所有压缩列表中的总元素个数
unsigned long len; //quicklistNodes的个数
...
} quicklist;
quicklist还是使用了ziplist来保存元素,所以连锁更新的问题仍然存在。 为了替代ziplist,Redis 在 5.0 新设计一个数据结构叫 listpack, 其节点不再包含前一个节点的长度。(ziplist因为要保存前一个节点的长度,才会有连锁更新问题)
listpack结构:
虽然没有了prevlen,但 listpack仍能向前遍历, 从当前项的起始位置开始,向左解析,就可以得到前一项的元素的 len 了。
String类型是由 int 和 SDS(简单动态字符串)实现的。
如果字符串对象保存的是整数值,并可以用long表示,那么,redisObject的encoding设为 int, ptr 设为该整数值(void*转换为long)。
如果字符串对象保存的是字符串,且长度小于44字节,那么,redisObject将使用SDS来保存字符串,encoding设为 embstr。 (redisObject 和 SDS 一起分配内容,它们在一块连续内存中)
如果字符串对象保存的是字符串,且长度大于44字节,那么,redisObject将使用SDS来保存字符串,encoding设为 raw。 (redisObject 和 SDS 各自分配内存,要调用两次内存分配)
embstr 优点: 只用分配一次内存,也只用释放一次内存;redisObject 和数据放在一起,能更好的利用CPU缓存提升性能。
缺点: embstr编码的字符串是只读的,不能修改。只能转换为raw再执行修改命令。(整个redisObject和sds都需要重新分配空间)
Redis3.2以前,List 类型的底层数据结构是由双向链表或压缩列表实现的:
在Redis 3.2 版本,List 数据类型底层数据结构就只由 quicklist 实现了,替代了双向链表和压缩列表。
Hash 类型的底层数据结构是由压缩列表或哈希表实现的:
在Redis 7.0,压缩列表数据结构已经废弃了,交由 listpack 数据结构来实现了。
Set 类型的底层数据结构是由哈希表或整数集合实现的:
Zset 类型的底层数据结构是由压缩列表或跳表实现的:
在 Redis 7.0,压缩列表数据结构已经废弃了,交由 listpack 数据结构来实现了。
Redis 单线程指的是: 接收客户端请求->解析请求 ->进行数据读写等操作->发送数据给客户端。 这个过程是由**一个线程(主线程)**来完成的。
不过,Redis并不是单线程的,Redis启动时,还会启动后台线程(BIO):
Redis2.6, 会启动2个后台线程,处理 关闭文件、AOF刷盘 任务。
Redis4.0,新增一个后台线程,进行 异步释放Redis内存,即 lazyfree线程。
例如 执行 unlink key / flushdb async / flushall async 等命令,会把这些删除操作交给后台线程来执行,这样就不会阻塞 Redis主线程。
因此,当我们要删除一个大 key 的时候,不要使用 del 命令删除,因为 del 是在主线程处理的,这样会导致 Redis 主线程卡顿,应该使用 unlink 命令来异步删除大key。
这些后台线程处理的任务都是很耗时的任务,交给主线程处理很容易发生阻塞。 后台线程相当于一个消费者,生产者把耗时任务丢到任务队列中,消费者(BIO)不停轮询这个队列,拿出任务去执行。
关闭文件、AOF 刷盘、释放内存这三个任务都有各自的任务队列:
BIO_CLOSE_FILE: 关闭文件任务队列:当队列有任务后,后台线程会调用 close(fd) ,将文件关闭;
BIO_AOF_FSYNC:AOF刷盘任务队列:当 AOF 日志配置成 everysec 选项后,主线程会把 AOF 写日志操作封装成一个任务,也放到队列中。当发现队列有任务后,后台线程会调用 fsync(fd),将 AOF 文件刷盘,
BIO_LAZY_FREE: lazy free 任务队列:当队列有任务后,后台线程会 free(obj) 释放对象 / free(dict) 删除数据库所有对象 / free(skiplist) 释放跳表对象;
在Redis6.0之前,Redis的网络I/O和执行命令都是单线程。
组成:
流程:
初始化:
服务端启动后,会创建一个 listen-socket, 绑定服务端的IP和port,并进入监听状态。
与客户端建立连接:
客户端请求连接时,会创建 connect-socket, 向listen-socket 发起连接请求。 当两者成功连接后(TCP3次握手成功),服务端会为已连接的客户端创建一个 代表该客户端的client-socket,用于与客户端通信。
初始化完成后,主线程进入事件循环函数中:
调用 epoll_wait 函数 等待事件到来:
Redis6.0改成 多线程 处理网络IO,默认只有写请求是多线程的,读请求和执行命令仍是单线程。
配置文件Redis.conf,相关配置项:
// 读请求也使用io多线程
io-threads-do-reads yes
// io-threads N,表示启用 N-1 个 I/O 多线程(主线程也算一个 I/O 线程)
io-threads 4
关于线程数的选择,官方建议4核CPU设置为2或3,8核CPU设置为6, 线程数一定要小于机器核数。
因此,Redis6.0之后,Redis在启动时,默认会额外创建6个线程(1个主线程 + 6个线程 )
Redis会把设置了过期时间的key存储到一个 过期字典(expires dict)中,也就是说过期字典保存了所有key的过期时间。
当查询一个key时,会先检查该key是否存在于过期字典:
Redis中采用的过期数据的删除策略有两种: 定期删除 + 惰性删除
Redis持久化,对过期键的处理:
Redis主从模式,对过期键的处理:
在 Redis 的运行内存达到了某个阀值,就会触发内存淘汰机制,这个阀值就是配置文件中设置的最大运行内存,配置项为 maxmemory。
Redis有八种内存淘汰策略:
LRU:Least Recently Used 最近最少使用,淘汰最长时间未被使用的。
传统LRU算法使用链表结构,最新操作的元素会被移到链表头部。进行内存淘汰时,直接删除链表尾部元素即可。
Redis对LRU算法的实现:
为了节省内存,Redis不使用链表,而是在 对象结构体redisObject中添加一个额外的字段:lru,用于记录数据最后一次访问时间。
进行内存淘汰时,会使用随机采样的方式来淘汰数据,它是随机取 n 个值(此值可配置),然后淘汰最久没有使用的那个。
LFU:Least Frequently Used 最近最不常用使用,淘汰一定时间内使用次数最少的。 核心思想是:如果数据过去被访问多次,那么将来被访问的频率也更高。
Cache Aside Pattern, 较常用的模式,适合读请求比较多的场景。
Cache Aside Pattern中服务端需要同时维护数据库和缓存,且以db的结果为准。
读写策略:
策略要点:
为什么删除cache,而不是更新cache?
删除cache更直接,因为cache中的一些数据不是db照搬过来,而是需要额外的计算才能放入cache,所以更新cache是一笔不小的开销, 而且cache中的数据也不一定会被命中。
同时,并发场景下,更新cache产生数据不一致性问题的概率会更大。
写数据过程中,能先删cache,后更新db吗?
不可以!这样造成db和cache数据不一致的概率会大很多。
如: 请求1更新数据A,请求2随后读取数据A:
如果是先更新db,后删除cache,出现数据不一致的情况为: 请求1先读数据A,且数据A不在缓存中,请求2后更新数据A:
但这情况不太可能发生,因为cache写入速度比db快很多。
缺点:
Read/Write Through Pattern
服务端会把cache视为主要数据存储,从中读写数据, 并负责把数据写入db。(比较少见,redis也没有提供写db的功能)
读写策略:
Write Behind Pattern
和读写穿透类似,也是cache负责cache和db的读写。区别在于: Read/Write Through是同步更新cache和db, Write Behind 是只更新缓存,再以异步批量的方式更新db。
这种方式的写性能很高,适合数据经常变化又对数据一致性要求不高的场景,如浏览量、点赞量。
消息队列中的消息是异步写入磁盘,MySQL中断 Innodb Buffer Pool机制 都是异步缓存写入策略。
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协议。
在RESP2中,通过首字节的字符来区分不同数据类型,常用的数据类型包括5种:
单行字符串:首字节是 ‘+’ ,后面跟上单行字符串,以CRLF( “\r\n” )结尾。例如返回"OK": “+OK\r\n”
错误(Errors):首字节是 ‘-’ ,与单行字符串格式一样,只是字符串是异常信息,例如:“-Error message\r\n”
数值:首字节是 ‘:’ ,后面跟上数字格式的字符串,以CRLF结尾。例如:“:10\r\n”
多行字符串:首字节是 ‘$’ ,表示二进制安全的字符串,行之间同样以 CRLF( “\r\n” )分割,最大支持512MB,
数组:首字节是 ‘*****’,后面跟上数组元素个数,再跟上若干行元素,元素数据类型不限,如:
*3\r\n
$3\r\nset\r\n
$4\r\nname\r\n
$6\r\n灿灿\r\n
Lua 是一种轻量小巧的脚本语言,用标准C语言编写并以源代码形式开放, 其设计目的是为了嵌入应用程序中,从而为应用程序提供灵活的扩展和定制功能。
Nginx本身也是C语言开发,因此也允许基于Lua做拓展。
Lua经常嵌入到C语言开发的程序中,例如游戏开发、游戏插件等。
CentOS7默认安装了Lua语言环境。
在springboot中使用:用ResourceScriptSource加载lua脚本,再用redis客户端执行。
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.ClassPathResource;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.scripting.support.ResourceScriptSource;
@Configuration
public class LuaConfiguration {
@Bean(name = "set")
public DefaultRedisScript<Boolean> redisScript() {
DefaultRedisScript<Boolean> redisScript = new DefaultRedisScript<>();
redisScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("luascript/lock-set.lua")));
redisScript.setResultType(Boolean.class);
return redisScript;
}
}
@RestController
public class LuaLockController {
@Resource(name = "set")
private DefaultRedisScript<Boolean> redisScript;
@Resource
private StringRedisTemplate stringRedisTemplate;
@GetMapping("/lua")
public ResponseEntity lua() {
List<String> keys = Arrays.asList("testLua", "hello lua");
Boolean execute = stringRedisTemplate.execute(redisScript, keys, "100");
return null;
}
}
数据类型 | 描述 |
---|---|
nil | 值就是nil,是一个无效值(条件判断中相当于false) |
boolean | true和false |
number | 双精度类型的实浮点数 |
string | 字符串由一对双引号或单引号表示 |
function | 由C或Lua编写的函数 |
table | lua的表, 相当于一个关联数组,数组的索引可以是数字、字符串或表类型。使用{}定义 |
声明变量时,无需指定数据类型,而 local 用来声明变量为局部变量:
-- 声明字符串,可以用单引号或双引号,
local str = 'hello'
-- 字符串拼接可以使用 ..
local str2 = 'hello' .. 'world'
-- 声明数字
local num = 21
-- 声明布尔类型
local flag = true
-- 声明数组 ,key为角标的 table
local arr = {'java', 'python', 'lua'}
-- 声明table,类似java的map
local map = {name='Jack', age=21}
-- 访问数组,lua数组的角标从1开始
print(arr[1])
-- 访问table
print(map['name'])
print(map.name)
遍历数组:
-- 声明数组 key为索引的 table
local arr = {'java', 'python', 'lua'}
-- 遍历数组
for index,value in ipairs(arr) do
print(index, value)
end
遍历普通table
-- 声明map,也就是table
local map = {name='Jack', age=21}
-- 遍历table
for key,value in pairs(map) do
print(key, value)
end
条件控制,类似Java的条件控制,例如if、else语法:
if(布尔表达式)
then
--[ 布尔表达式为 true 时执行该语句块 --]
else
--[ 布尔表达式为 false 时执行该语句块 --]
end
语法:
function 函数名(argument1, argument2..., argumentn)
-- 函数体
return 返回值
end
如:
定义一个函数,用来打印数组:
function printArr(arr)
for index, value in ipairs(arr) do
print(value)
end
end