目录
- 一、概念
- 二、操作命令
- 1、Redis命令官网
- 2、数据结构列表
- 3、Redis通用命令使用介绍
- 4、String使用介绍(使用redis-cli命令行操作)
- 4.1、简单介绍
- 4.2、set ……、set …… ex ……(简写:setex)、set …… px ……、set …… nx(简写:setnx)、set …… xx
- 4.3、mset
- 4.4、getset
- 4.5、get
- 4.6、mget
- 4.7、del(所有类型可用)
- 4.8、incr、incrby、decr、decrby(仅限Integer类型(String类型可以转换成Integer类型))
- 4.9、incrbyfloat(仅限浮点类型(String类型可以转换成浮点类型))
- 4.10、exists(所有类型可用)
- 4.11、type(所有类型可用)
- 4.12、expire、pexpire(所有类型可用)
- 4.13、persist(所有类型可用)
- 4.14、ttl、pttl(所有类型可用)
- 5、List使用介绍(使用redis-cli命令行操作)
- 6、Hash使用介绍(使用redis-cli命令行操作)
- 7、Set使用介绍(使用redis-cli命令行操作)
- 8、Sorted Set(使用介绍(使用redis-cli命令行操作))
- 三、环境搭建
- 四、代码
- 五、文档
- 六、应用
- 七、最佳实践
- 八、Redis基础数据结构
- 九、Redis数据类型组成关系
- 十、Redis网络模型
- 十一、Redis通信协议
- 十二、Redis内存回收
一、概念
1、特征
Redis诞生于2009年全称是Remote Dictionary Server,远程词典服务器,是一个基于内存的键值型NoSQL数据,特征如下:
- 键值(key-value)型,value支持多种不同数据结构,功能丰富
- 单线程,每个命令具备原子性
- 低延迟,速度快(基于内存、IO多路复用、良好的编码)。
- 支持数据持久化
- 支持主从集群、分片集群
- 支持多语言客户端
2、关系型数据库和非关系型数据库的区别
3、键的结构
Redis的key允许由多个单词形成层级结构,多个单词之间用:
隔开,格式如下:
项目名:业务名:类型:id
这个格式并非固定,也可以根据自己的需求来删除或添加词条。
例如我们的项目名称叫 heima
,有user
和product
两种不同类型的数据,我们可以这样定义key:
user相关的key:heima:user:1
product相关的key:heima:product:1
4、Redis的Java客户端
Spring data redis底层默认使用lettuce,但是lettuce存在并发问题,所以一般将底层替换成Jedis。
5、缓存更新策略
5.1、概念
说明: 缓存更新策略将会影响代码编写过程中的逻辑。
首先用一张图说明缓存作用:
可以很清晰的看到Redis缓存是做前锋的,避免对关系型数据库造成影响
然后罗列一下几种缓存更新策略,如下:
通过比对实现难度和最终效果,我们采用主动更新策略
然后罗列一下几种主动更新策略,如下:
通过比对实现难度和最终效果,我们采用01
但是01
的实现方式也有两种,可以分为删除缓存策略
还是更新缓存策略
,其中删除缓存等待用户操作的时候才会把最新数据放到缓存中,而更新缓存是在用户往数据库新增或者更新数据的时候就把最新数据放到缓存中
既然提到这2种主动缓存更新策略,那就得说一下缓存数据有效性,以及查询数据时可能出现的缓存穿透、缓存击穿、缓存雪崩问题的解决方案;
- 对于
删除缓存策略
,可以在删除缓存后通过用户主动获取数据(添加最新缓存数据)来保证数据有效性
,当查询缓存时,可以通过缓存空值
方式来解决缓存穿透问题,可以通过分布式锁
方式解决缓存击穿问题,可以通过设置不同缓存过期时间
方式解决缓存雪崩问题 - 对于
更新缓存策略
,有两种实现方案- 方案1(完全符合):在添加 / 更新数据库的同时更新Redis缓存,可以
保证缓存数据有效性
,由于缓存数据不会过期
,并且我们查询数据的时候不会查询数据库,那其实不会产生缓存穿透、缓存击穿、缓存雪崩问题
,但是这会造成很多无效写操作,并且还会占据很多缓存空间 - 方案2(不太符合):在添加数据到数据库的时候添加Redis缓存,并且为缓存数据添加逻辑过期时间,但是在更新数据库数据的时候不更新Redis缓存,而是等待过期逻辑过期时间到期才更新Redis缓存数据,
无法解决数据有效性问题
(更新数据库不更新Redis缓存,不给Redis造成太大压力),当查询缓存时,如果从Redis查询不到值的时候直接返回null,所以不会产生缓存穿透问题
,可以通过缓存逻辑过期时间
方式来解决缓存击穿问题,不会产生缓存雪崩问题
- 方案3(不太符合):在添加数据到数据库的时候添加Redis缓存,并且为缓存数据添加真实过期时间,并且为
布隆过滤器添加数据标识
,但是更新的时候不会在更新Redis缓存,无法解决数据有效性问题
,当查询缓存时,可以通过布隆过滤器
解决缓存穿透问题,可以通过分布式锁
方式解决缓存击穿问题,可以通过设置不同缓存过期时间
方式解决缓存雪崩问题
- 方案1(完全符合):在添加 / 更新数据库的同时更新Redis缓存,可以
基于实现难度、资源消耗、数据时效性考虑,我们采用删除缓存策略
,下面进行详细介绍在读写操作时的作用:
- 读操作:
- 缓存命中则直接返回
- 缓存未命中则查询数据库,并写入缓存,也不一定要设置超时时间,看具体情况吧!
- 写操作:
- 先写数据库,然后再删除缓存(原因:如果顺序反过来,将有可能造成缓存中有旧数据)
- 要确保数据库与缓存操作的原子性
- 单体系统(在同一个程序中操作数据库和Redis):将缓存与数据库操作放在一个事务
- 分布式系统(在不同程序中操作数据库和Redis):利用分布式事务方案
5.2、代码
概述: 先写数据库,然后再删除缓存,确保数据库与缓存操作的原子性,这就是代码。
解释: 如果用户修改数据库数据的操作会影响Redis中缓存值的准确性,那就需要在更新数据库值之后就删除Redis缓存值,当用户需要获取结果时会自动更新Redis缓存值,这样也能减轻缓存资源占用
代码:
// 更新店铺信息
@Override
@Transactional
public Result update(Shop shop) {
Long id = shop.getId();
if (id == null) {
return Result.fail("店铺id不能为空");
}
// 1.更新数据库
updateById(shop);
// 2.删除缓存
stringRedisTemplate.delete(CACHE_SHOP_KEY + id);
return Result.ok();
}
6、缓存穿透
6.1、含义
缓存穿透是指客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远不会生效,这些请求都会打到数据库。
6.2、解决办法
- 缓存空对象(
最常使用
,简单好用,但是一定要设置缓存超时时间,并且要比普通值的缓存时间短) - 布隆过滤(
不常使用
,原因是在查询缓存之前需要提前将缓存放入Redis,并且在布隆过滤器添加值(将字符串指定字节位设置为true),这样未来在查询的时候布隆过滤器才能起到作用,我感觉布隆过滤器的用途是:提前将数据放入Redis,并且设置过期时间,然后在查询缓存的时候查不到,说明已经过期了,那就从数据库取值即可)
举一个缓存空值的例子:
6.3、缓存空值代码举例
application.yaml:
spring:
redis:
# ————————————————————单机配置————————————————————
host: 127.0.0.1
port: 6379
依赖:
<!--redis依赖-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!--common-pool-->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
</dependency>
<!--hutool-->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.7.17</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
代码:
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
import java.util.concurrent.TimeUnit;
import java.util.function.Function;
import static com.hmdp.utils.RedisConstants.CACHE_NULL_TTL;
@Slf4j
@Component
public class CacheClient {
@Autowired
private StringRedisTemplate stringRedisTemplate;
public void setWithLogicalExpire(String key, Object value, Long time, TimeUnit unit) {
// 设置逻辑过期
RedisData redisData = new RedisData();
redisData.setData(value);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(unit.toSeconds(time)));
// 写入Redis
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData));
}
// 通过缓存空值方式来解决缓存穿透
public <R,ID> R queryWithPassThrough(
String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit){
String key = keyPrefix + id;
// 1.从redis查询商铺缓存
String json = stringRedisTemplate.opsForValue().get(key);
// 2.判断是否存在
if (StrUtil.isNotBlank(json)) {
// 3.存在,直接返回
return JSONUtil.toBean(json, type);
}
// 判断命中的是否是空值,不等于空那就是空字符串,也就是我们缓存的空值,所以直接返回即可
if (json != null) {
// 返回一个错误信息
return null;
}
// 4.不存在,根据id查询数据库
R r = dbFallback.apply(id);
// 5.不存在,返回错误
if (r == null) {
// 将空值写入redis
stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
// 返回错误信息
return null;
}
// 6.存在,写入redis
this.set(key, r, time, unit);
return r;
}
public void set(String key, Object value, Long time, TimeUnit unit) {
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value), time, unit);
}
}
6.4、布隆过滤器代码举例
application.yaml:
spring:
redis:
# ————————————————————单机配置————————————————————
host: 127.0.0.1
port: 6379
依赖:
<!--redis依赖-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!--common-pool-->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
</dependency>
<!--增加布隆过滤器-->
<!-- https://mvnrepository.com/artifact/com.google.guava/guava -->
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>31.0.1-jre</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
Redis工具类代码:
import com.google.common.base.Preconditions;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
@Component
@Slf4j
public class RedisUtil {
@Autowired
private StringRedisTemplate redisTemplate;
/**
* 根据给定的布隆过滤器添加值
*/
public <T> void addByBloomFilter(BloomFilterHelper<T> bloomFilterHelper, String key, T value) {
Preconditions.checkArgument(bloomFilterHelper != null, "bloomFilterHelper不能为空");
int[] offset = bloomFilterHelper.murmurHashOffset(value);
for (int i : offset) {
log.info("key : " + key + " " + "value : " + i);
redisTemplate.opsForValue().setBit(key, i, true);
}
}
/**
* 根据给定的布隆过滤器判断值是否存在
*/
public <T> boolean includeByBloomFilter(BloomFilterHelper<T> bloomFilterHelper, String key, T value) {
Preconditions.checkArgument(bloomFilterHelper != null, "bloomFilterHelper不能为空");
int[] offset = bloomFilterHelper.murmurHashOffset(value);
for (int i : offset) {
log.info("key : " + key + " " + "value : " + i);
if (!redisTemplate.opsForValue().getBit(key, i)) {
return false;
}
}
return true;
}
}
布隆过滤器工具类代码:
import com.google.common.base.Preconditions;
import com.google.common.hash.Funnel;
import com.google.common.hash.Hashing;
public class BloomFilterHelper<T> {
private int numHashFunctions;
private int bitSize;
private Funnel<T> funnel;
public BloomFilterHelper(Funnel<T> funnel, int expectedInsertions, double fpp) {
Preconditions.checkArgument(funnel != null, "funnel不能为空");
this.funnel = funnel;
// 计算bit数组长度
bitSize = optimalNumOfBits(expectedInsertions, fpp);
// 计算hash方法执行次数
numHashFunctions = optimalNumOfHashFunctions(expectedInsertions, bitSize);
}
public int[] murmurHashOffset(T value) {
int[] offset = new int[numHashFunctions];
long hash64 = Hashing.murmur3_128().hashObject(value, funnel).asLong();
int hash1 = (int) hash64;
int hash2 = (int) (hash64 >>> 32);
for (int i = 1; i <= numHashFunctions; i++) {
int nextHash = hash1 + i * hash2;
if (nextHash < 0) {
nextHash = ~nextHash;
}
offset[i - 1] = nextHash % bitSize;
}
return offset;
}
/**
* 计算bit数组长度
*/
private int optimalNumOfBits(long n, double p) {
if (p == 0) {
// 设定最小期望长度
p = Double.MIN_VALUE;
}
int sizeOfBitArray = (int) (-n * Math.log(p) / (Math.log(2) * Math.log(2)));
return sizeOfBitArray;
}
/**
* 计算hash方法执行次数
*/
private int optimalNumOfHashFunctions(long n, long m) {
int countOfHash = Math.max(1, (int) Math.round((double) m / n * Math.log(2)));
return countOfHash;
}
}
7、缓存击穿
7.1、概念
缓存击穿问题也叫热点Key问题,就是一个被高并发访问并且缓存重建业务较复杂的key突然失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击。
7.2、解决办法
- 互斥锁 (
最常使用
,简单好用,但是一定要设置锁超时时间) - 逻辑过期(
很少使用,也能考虑
,首先很难保证数据准确性,毕竟可能返回旧数据,另外在数据库中数据新增或者更新的时候都需要更新缓存数据,这一点比较麻烦)
时序图:
优缺点:
基于互斥锁方式解决缓存击穿问题举例:
基于逻辑过期方式解决缓存击穿问题举例:
7.3、互斥锁代码举例
application.yaml:
spring:
redis:
# ————————————————————单机配置————————————————————
host: 127.0.0.1
port: 6379
依赖:
<!--redis依赖-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!--common-pool-->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
</dependency>
<!--hutool-->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.7.17</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
代码:
import cn.hutool.core.util.BooleanUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import java.util.concurrent.TimeUnit;
import java.util.function.Function;
// 不仅解决缓存击穿问题,也通过缓存空值方式来解决缓存穿透问题
@Slf4j
@Component
public class CacheClient {
@Autowired
private StringRedisTemplate stringRedisTemplate;
// 通过缓存空值来解决缓存穿透问题
public <R, ID> R queryWithMutex(
String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit) {
String key = keyPrefix + id;
// 1.从redis查询商铺缓存
String shopJson = stringRedisTemplate.opsForValue().get(key);
// 2.判断是否存在
if (StrUtil.isNotBlank(shopJson)) {
// 3.存在,直接返回
return JSONUtil.toBean(shopJson, type);
}
// 判断命中的是否是空值,不等于空那就是空字符串,也就是我们缓存的空值,所以直接返回即可
if (shopJson != null) {
// 返回一个错误信息
return null;
}
// 4.实现缓存重建
// 4.1.获取互斥锁
String lockKey = "lock:shop:" + id;
R r = null;
try {
boolean isLock = tryLock(lockKey);
// 4.2.判断是否获取成功
if (!isLock) {
// 4.3.获取锁失败,休眠并重试
Thread.sleep(50);
return queryWithMutex(keyPrefix, id, type, dbFallback, time, unit);
}
// 4.4.获取锁成功,根据id查询数据库
r = dbFallback.apply(id);
// 5.不存在,返回错误
if (r == null) {
// 将空值(空串)写入redis,一定要加上时间
stringRedisTemplate.opsForValue().set(key, "", 2, TimeUnit.MINUTES);
// 返回错误信息
return null;
}
// 6.存在,写入redis
this.set(key, r, time, unit);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}finally {
// 7.释放锁
unlock(lockKey);
}
// 8.返回
return r;
}
// 添加互斥锁
private boolean tryLock(String key) {
// 做法类似于Redisson,作用:带等待时间的分布式锁
// 大部分情况下,10分钟完全可以完成一个业务功能,如果还不能完成,那肯定就是业务功能出现问题了,当然也可以使用Redisson分布式锁,毕竟它有看门口功能进行过期时间的无限续期
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag);
}
// 解除互斥锁
private void unlock(String key) {
stringRedisTemplate.delete(key);
}
public void set(String key, Object value, Long time, TimeUnit unit) {
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value), time, unit);
}
}
7.4、逻辑过期代码举例
application.yaml:
spring:
redis:
# ————————————————————单机配置————————————————————
host: 127.0.0.1
port: 6379
依赖:
<!--redis依赖-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!--common-pool-->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
</dependency>
<!--hutool-->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.7.17</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
代码:
import cn.hutool.core.util.BooleanUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.function.Function;
// 不仅解决缓存击穿问题,甚至都不会面临缓存穿透问题,毕竟不存在直接就返回null了
@Slf4j
@Component
public class CacheClient {
@Autowired
private StringRedisTemplate stringRedisTemplate;
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
// 存储缓存数据的同时设置过期时间,这件事情在创建数据肯定要做,但是更新数据的时候一般不执行该方法
public void setWithLogicalExpire(String key, Object value, Long time, TimeUnit unit) {
// 设置逻辑过期
RedisData redisData = new RedisData();
redisData.setData(value);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(unit.toSeconds(time)));
// 写入Redis
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData));
}
// 查询缓存数据,不会面临缓存穿透问题,原因是没有就直接返回null了,否则才进行缓存击穿处理
public <R, ID> R queryWithLogicalExpire(
String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit) {
String key = keyPrefix + id;
// 1.从redis查询商铺缓存
String json = stringRedisTemplate.opsForValue().get(key);
// 2.判断是否存在
if (StrUtil.isBlank(json)) {
// 3.不存在,直接返回,说明数据根本不存在,否则缓存中绝对有了
return null;
}
// 4.命中,需要先把json反序列化为对象
RedisData redisData = JSONUtil.toBean(json, RedisData.class);
R r = JSONUtil.toBean((JSONObject) redisData.getData(), type);
LocalDateTime expireTime = redisData.getExpireTime();
// 5.判断是否过期
if(expireTime.isAfter(LocalDateTime.now())) {
// 5.1.未过期,直接返回店铺信息
return r;
}
// 5.2.已过期,需要缓存重建
// 6.缓存重建
// 6.1.获取互斥锁
String lockKey = "lock:shop:" + id;
boolean isLock = tryLock(lockKey);
// 6.2.判断是否获取锁成功
if (isLock){
// 6.3.成功,开启独立线程,实现缓存重建
CACHE_REBUILD_EXECUTOR.submit(() -> {
try {
// 查询数据库
R newR = dbFallback.apply(id);
// 重建缓存
this.setWithLogicalExpire(key, newR, time, unit);
} catch (Exception e) {
throw new RuntimeException(e);
}finally {
// 释放锁
unlock(lockKey);
}
});
}
// 6.4.返回过期的商铺信息
return r;
}
private boolean tryLock(String key) {
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag);
}
private void unlock(String key) {
stringRedisTemplate.delete(key);
}
public void set(String key, Object value, Long time, TimeUnit unit) {
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value), time, unit);
}
}
8、缓存雪崩
8.1、含义
缓存雪崩是指在同一时段大量的缓存key同时失效或者Redis服务宕机,导致大量请求到达数据库,带来巨大压力。
8.2、解决办法
- 给不同的Key的TTL添加随机值:解决同一时段大量的缓存key同时失效问题
- 利用Redis集群提高服务的可用性:尽量避免Redis服务宕机
- 给缓存业务添加降级限流策略:为Redis服务宕机问题兜底
- 给业务添加多级缓存:避免完全依赖Redis,鸡蛋放到一个篮子里容易出问题
9、Lua脚本
9.1、Lua教程
访问链接:https://www.runoob.com/lua/lua-tutorial.html
9.2、Lua介绍
9.2.1、概念
Redis提供了Lua脚本功能,在一个脚本中编写多条Redis命令,确保多条命令执行时的原子性
。
9.2.2、Redis为Lua语言内置的lua函数
redis.call('命令名称', 'key', '其它参数', ...)
举例:
// 总结:在redis中怎么写命令,这里就怎么写命令,只是之前命令参数之间用空格分隔,现在用逗号分隔而已
// 1、获取键值
redis.call('get', stockKey))
// 2、将键name设置为值jack
redis.call('set', 'name', 'jack')
// 3、设置多个键值
redis.call('xadd', 'stream.orders', '*', 'userId', userId, 'voucherId', voucherId, 'id', orderId)
// 4、判断键值中是否已经存在该值
redis.call('sismember', orderKey, userId)
// 5、键值自增
redis.call('incrby', stockKey, -1)
9.2.3、在Redis-cli中执行Lua脚本函数
语法:
举例:
情况1:假设我们要执行 redis.call('set', 'name', 'jack')
这个脚本函数,语法如下:
情况2:如果脚本中的key、value不想写死,也可以作为参数传递。key类型参数会放入KEYS数组,其它参数会放入ARGV数组,在脚本中可以从KEYS和ARGV数组获取这些参数,如下:
总结:
情况1和情况2都是我们经常使用的做法,只是使用场景不同,对于情况2来说,我们在java代码中也是经常用的,不过代码调用的时候只是传递了键集合和值可变参数,而Redis依赖底层会将这种调用形式转换成上述的EVAL脚本形式,比如:将键参数个数加上等
9.3、代码
9.3.1、前置准备:application.yaml和依赖
application.yaml:
spring:
redis:
# ————————————————————单机配置————————————————————
host: 127.0.0.1
port: 6379
依赖:
<!--redis依赖-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!--common-pool-->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
</dependency>
<!--hutool-->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.7.17</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
9.3.2、unlock.lua(放在resources下的lua脚本,等待被调用)
-- 比较线程标示与锁中的标示是否一致
if(redis.call('get', KEYS[1]) == ARGV[1]) then
-- 释放锁 del key
return redis.call('del', KEYS[1])
end
return 0
9.3.3、ILock接口
public interface ILock {
/**
* 尝试获取锁
* @param timeoutSec 锁持有的超时时间,过期后自动释放
* @return true代表获取锁成功; false代表获取锁失败
*/
boolean tryLock(long timeoutSec);
/**
* 释放锁
*/
void unlock();
}
9.3.4、SimpleRedisLock实现类
import cn.hutool.core.lang.UUID;
import org.springframework.core.io.ClassPathResource;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import java.util.Collections;
import java.util.concurrent.TimeUnit;
// 主要看静态代码块和unlock解锁方法,这两块在用lua脚本
public class SimpleRedisLock implements ILock {
private String name;
private StringRedisTemplate stringRedisTemplate;
public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
this.name = name;
this.stringRedisTemplate = stringRedisTemplate;
}
private static final String KEY_PREFIX = "lock:";
private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";
private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;
// 调用lua脚本前的准备工作
static {
UNLOCK_SCRIPT = new DefaultRedisScript<>();
// 读取lua脚本,也就是上面resources下的lua脚本文件
UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
// 设置返回值类型
UNLOCK_SCRIPT.setResultType(Long.class);
}
@Override
public boolean tryLock(long timeoutSec) {
// 获取线程标示
String threadId = ID_PREFIX + Thread.currentThread().getId();
// 获取锁
Boolean success = stringRedisTemplate.opsForValue()
.setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS);
return Boolean.TRUE.equals(success);
}
@Override
public void unlock() {
// 调用lua脚本
// UNLOCK_SCRIPT:lua脚本
// Collections.singletonList(KEY_PREFIX + name):键集合
// ID_PREFIX + Thread.currentThread().getId():值可变参数
stringRedisTemplate.execute(
UNLOCK_SCRIPT,
Collections.singletonList(KEY_PREFIX + name),
ID_PREFIX + Thread.currentThread().getId());
}
}
9.4、更复杂的lua脚本代码
9.4.1、前置准备:application.yaml和依赖
application.yaml:
spring:
redis:
# ————————————————————单机配置————————————————————
host: 127.0.0.1
port: 6379
依赖:
<!--redis依赖-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!--common-pool-->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
</dependency>
<!--hutool-->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.7.17</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
9.4.2、seckill.lua(放在resources下的lua脚本,等待被调用)
-- 1.参数列表
-- 1.1.优惠券id
local voucherId = ARGV[1]
-- 1.2.用户id
local userId = ARGV[2]
-- 1.3.订单id
local orderId = ARGV[3]
-- 2.数据key
-- 2.1.库存key
-- ..是字符串连接符
local stockKey = 'seckill:stock:' .. voucherId
-- 2.2.订单key
local orderKey = 'seckill:order:' .. voucherId
-- 3.脚本业务
-- 3.1.判断库存是否充足 get stockKey
if(tonumber(redis.call('get', stockKey)) <= 0) then
-- 3.2.库存不足,返回1
return 1
end
-- 3.2.判断用户是否下单 SISMEMBER orderKey userId
if(redis.call('sismember', orderKey, userId) == 1) then
-- 3.3.存在,说明是重复下单,返回2
return 2
end
-- 3.4.扣库存 incrby stockKey -1
redis.call('incrby', stockKey, -1)
-- 3.5.下单(保存用户)sadd orderKey userId
redis.call('sadd', orderKey, userId)
-- 3.6.发送消息到队列中, XADD stream.orders * k1 v1 k2 v2 ...
redis.call('xadd', 'stream.orders', '*', 'userId', userId, 'voucherId', voucherId, 'id', orderId)
return 0
9.4.3、IVoucherOrderService 接口
import com.hmdp.dto.Result;
import com.hmdp.entity.VoucherOrder;
import com.baomidou.mybatisplus.extension.service.IService;
public interface IVoucherOrderService extends IService<VoucherOrder> {
Result seckillVoucher(Long voucherId);
}
9.4.4、VoucherOrderServiceImpl 实现类
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.hmdp.dto.Result;
import com.hmdp.entity.VoucherOrder;
import com.hmdp.mapper.VoucherOrderMapper;
import com.hmdp.service.ISeckillVoucherService;
import com.hmdp.service.IVoucherOrderService;
import com.hmdp.utils.RedisIdWorker;
import com.hmdp.utils.UserHolder;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.io.ClassPathResource;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import java.util.Collections;
@Slf4j
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
@Resource
private ISeckillVoucherService seckillVoucherService;
@Resource
private RedisIdWorker redisIdWorker;
@Resource
private StringRedisTemplate stringRedisTemplate;
private static final DefaultRedisScript<Long> SECKILL_SCRIPT;
static {
SECKILL_SCRIPT = new DefaultRedisScript<>();
SECKILL_SCRIPT.setLocation(new ClassPathResource("seckill.lua"));
SECKILL_SCRIPT.setResultType(Long.class);
}
// 下订单接口,很多功能全部都使用Redis的lua脚本文件完成,可以保证原子性
@Override
public Result seckillVoucher(Long voucherId) {
Long userId = UserHolder.getUser().getId();
long orderId = redisIdWorker.nextId("order");
// 1.执行lua脚本
Long result = stringRedisTemplate.execute(
SECKILL_SCRIPT,
Collections.emptyList(),
voucherId.toString(), userId.toString(), String.valueOf(orderId)
);
int r = result.intValue();
// 2.判断结果是否为0
if (r != 0) {
// 2.1.不为0 ,代表没有购买资格
return Result.fail(r == 1 ? "库存不足" : "不能重复下单");
}
// 3.返回订单id
return Result.ok(orderId);
}
}
10、Redission
10.1、概念
Redisson是一个在Redis的基础上实现的Java驻内存数据网格(In-Memory Data Grid)。它不仅提供了一系列的分布式的Java常用对象,还提供了许多分布式服务,其中就包含了各种分布式锁的实现。
10.2、官方地址
- 官网地址: https://redisson.org
- GitHub地址:
Redisson文档
10.3、为什么不使用Redis的setnx命令来实现分布式锁?
10.3.1、缺点
当然可以使用Redis的setnx命令来实现分布式锁,但是它是有缺点的,缺点如下:
10.3.1、使用Redis的setnx命令来实现分布式锁的代码
10.3.1.1、前置准备:application.yaml和依赖
application.yaml:
spring:
redis:
# ————————————————————单机配置————————————————————
host: 127.0.0.1
port: 6379
依赖:
<!--redis依赖-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!--common-pool-->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
</dependency>
<!--hutool-->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.7.17</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
10.3.1.2、unlock.lua(放在resources下的lua脚本,等待被调用)
-- 比较线程标示与锁中的标示是否一致
if(redis.call('get', KEYS[1]) == ARGV[1]) then
-- 释放锁 del key
return redis.call('del', KEYS[1])
end
return 0
10.3.1.3、ILock接口
public interface ILock {
/**
* 尝试获取锁
* @param timeoutSec 锁持有的超时时间,过期后自动释放
* @return true代表获取锁成功; false代表获取锁失败
*/
boolean tryLock(long timeoutSec);
/**
* 释放锁
*/
void unlock();
}
10.3.1.4、SimpleRedisLock实现类
import cn.hutool.core.lang.UUID;
import org.springframework.core.io.ClassPathResource;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import java.util.Collections;
import java.util.concurrent.TimeUnit;
// 主要看静态代码块和unlock解锁方法,这两块在用lua脚本
public class SimpleRedisLock implements ILock {
private String name;
private StringRedisTemplate stringRedisTemplate;
public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
this.name = name;
this.stringRedisTemplate = stringRedisTemplate;
}
private static final String KEY_PREFIX = "lock:";
private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";
private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;
// 调用lua脚本前的准备工作
static {
UNLOCK_SCRIPT = new DefaultRedisScript<>();
// 读取lua脚本,也就是上面resources下的lua脚本文件
UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
// 设置返回值类型
UNLOCK_SCRIPT.setResultType(Long.class);
}
@Override
public boolean tryLock(long timeoutSec) {
// 获取线程标示
String threadId = ID_PREFIX + Thread.currentThread().getId();
// 获取锁
Boolean success = stringRedisTemplate.opsForValue()
.setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS);
return Boolean.TRUE.equals(success);
}
@Override
public void unlock() {
// 调用lua脚本
// UNLOCK_SCRIPT:lua脚本
// Collections.singletonList(KEY_PREFIX + name):键集合
// ID_PREFIX + Thread.currentThread().getId():值可变参数
stringRedisTemplate.execute(
UNLOCK_SCRIPT,
Collections.singletonList(KEY_PREFIX + name),
ID_PREFIX + Thread.currentThread().getId());
}
}
10.4、Redisson分布式锁原理
10.4.1、解决不可重入问题
10.4.1.1、方案
在redis中以hash结构来存储线程id和重入次数,类似于synchronized的做法,从而实现可重入锁
10.4.1.2、画图介绍
10.4.1.3、代码分析
首先找到tryLock
方法,然后进入RedissonLock实现类,如下:
进入tryLock
方法之后,可以看到leaseTime
的值是-1
,这个将会影响是否启用看门狗机制,后续在介绍解决锁超时释放问题的时候详细介绍,如下:
然后在进入tryLock
方法内部,可以看到先获取了threadId
,并且执行了tryAcquire
方法,如下:
然后我们进入tryAcquire
方法内部,看到调用了tryAcquireAsync
方法,如下:
然后我们进入tryAcquireAsync
方法内部,可以看到无论leaseTime
是否等于-1
,都会调用tryLockInnerAsync
方法,如下:
我们进入RedissonLock类的tryLockInnerAsync
方法,如下:
上面这段lua脚本的作用就是判断是否加锁了,如果没有加锁,那就直接加锁,如果已经加锁,那就判断加锁的是当前线程吗,如果不是当前线程,那就直接返回锁过期时间。如果加锁的是当前线程,那就让重入次数加1,从而完成锁重入,如果加锁/锁重入成功,那就返回nil(空值),否则返回锁剩余有效时间
上述代码使用Fature方式,所以在调用get()方法的时候会阻塞,因此我们往上追溯到tryAcquire()
方法处,如果获取锁(新锁 / 重入锁)成功,那ttl
就返回null
,然后tryLock
方法就返回true,然后就能执行业务代码了,真正实现了可重入锁,代码如下:
10.4.2、解决不可重试问题
10.4.2.1、方案
如果当前锁是其他线程的,那就可以等待其他线程释放锁(等待过程不是while循环重试,而是使用Redis的pubsub事件通知方式进行重试,降低CPU使用率),其他线程释放锁之后重新尝试获取锁,获取不到那就等待其他线程释放锁,其他线程释放锁之后重新尝试获取锁,以此循环……
10.4.2.2、代码分析
大家可以先看上面关于解决可重入锁问题
的解释,我们在上面提到,如果没有获取锁,那tryAcquire
方法返回锁在Redis中的过期时间,那么ttl
就不是null,如下:
我们接着上面截图代码继续往下看,可以看到等待锁释放的过程,当然Redisson没有使用死循环,而是使用Redis的pubsub监听方式,如下:
如果监听到其他线程释放锁了,那么代码会继续尝试获取锁,如下:
假设又获取锁失败了,那代码会继续往下执行,如下:
如果本次获取锁又失败了,那就会执行while循环,从而解决锁不可重试问题,如下:
10.4.3、解决超时释放问题
10.4.3.1、方案
我们自己实现的redis分布式锁中的过期时间是指定的,由于业务执行时间不是完全确定的,甚至执行10分钟也不是不可能,但是我们不能把这个值设置太大了,否则会影响锁的自动过期释放,所以我们需要让锁可以自动续期,目前采用看门狗机制通过锁自动续期来解决锁超时释放问题
10.4.3.2、代码解读
首先找到tryLock
方法,然后进入RedissonLock实现类,如下:
进入tryLock
方法之后,可以看到leaseTime
的值是-1
,该值代表启用看门狗机制,所以特别注意要想启用看门口机制,就不用设置过期释放时间leaseTime
,如下:
然后在进入tryLock
方法内部,可以看到先获取了threadId
,并且执行了tryAcquire
方法,如下:
然后我们进入tryAcquire
方法内部,看到调用了tryAcquireAsync
方法,如下:
然后我们进入tryAcquireAsync
方法内部,本次聊的是看门口机制,所以leaseTime
是-1
,代码如下:
我们进入scheduleExpirationRenewal
方法,如下:
我们直接看renewExpiration
方法内部代码吧,如下:
private void renewExpiration() {
ExpirationEntry ee = EXPIRATION_RENEWAL_MAP.get(getEntryName());
if (ee == null) {
return;
}
// 创建任务
Timeout task = commandExecutor.getConnectionManager().newTimeout(new TimerTask() {
@Override
public void run(Timeout timeout) throws Exception {
ExpirationEntry ent = EXPIRATION_RENEWAL_MAP.get(getEntryName());
if (ent == null) {
return;
}
Long threadId = ent.getFirstThreadId();
if (threadId == null) {
return;
}
// 2、为锁过期时间进行续期
RFuture<Boolean> future = renewExpirationAsync(threadId);
future.onComplete((res, e) -> {
if (e != null) {
log.error("Can't update lock " + getName() + " expiration", e);
return;
}
if (res) {
// 3、重新调用自身,用于下次为锁过期时间进行续期
renewExpiration();
}
});
}
// 1、看门狗超时时间默认30s,也就是10s之后会执行当前任务
}, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS);
// 4、将任务放入ExpirationEntry对象中,用于后续取消任务,毕竟任务取消之后看门狗不能一直在吧
ee.setTimeout(task);
}
上面已经把看门狗机制进行了解释,也就是过一段时间会有看门狗进行过期时间刷新,但是当锁被取消之后,看门狗也要消失的,我们来看Redisson
的unlock
方法,如下:
进入unlock
方法,我们进入unlockAsync
方法,如下:
我们进入unlockAsync
方法,然后在进入cancelExpirationRenewal
方法,如下:
然后进入cancelExpirationRenewal
方法,如下:
这样就完成了锁释放时对看门狗任务的移除工作
10.4.4、解决主从一致性问题
10.4.4.1、方案
假设现在把锁告诉了redis主节点,这个时候主节点挂机了,那redis从节点有没有redis锁数据,这个时候就出现问题了,所以最完美的解决办法就是让所有的redis节点都加上锁,这样才能真正解决主从一致性问题
10.4.4.2、代码
配置类:
使用方式:
10.5、Redisson用途
10.5.1、用途概述
可用于以下几种用途:
- 可重入锁(Reentrant Lock)
- 公平锁(Fair Lock)
- 联锁(MultiLock)
- 红锁(RedLock,类似联锁)
- 读写锁(ReadWriteLock,只有读读不阻塞,其他组合都阻塞)
- 信号量(Semaphore)
- 可过期性信号量(PermitExpirableSemaphore)
- 闭锁(CountDownLatch)
点击我查看详细代码: 分布式锁和同步器
10.5.2、waitTime和leaseTime参数区别
- waitTime:等待获取锁的最大超时时间,一般默认值是-1,也就是不超时,一直等待
- leaseTime:Redis锁的过期时间,一般默认值是-1,也就是不过期
10.5.3、lock()和tryLock()方法区别
- lock():没有返回值,在获取到分布式锁之前都是阻塞的,会使用看门狗机制进行锁自动续期
- lock(long leaseTime, TimeUnit unit):没有返回值,在获取到分布式锁之前都是阻塞的,锁过期时间是leaseTime,单位是unit,不会使用看门狗机制进行锁自动续期
- tryLock():返回值是布尔类型,代表是否获取锁,代码会直接返回,不会阻塞
- tryLock(long time, TimeUnit unit):返回值是布尔类型,代表是否获取锁,获取锁超时时间是time,单位是unit,锁过期时间leaseTime是-1,代表会使用看门狗机制进行锁自动续期
- tryLock(long waitTime, long leaseTime, TimeUnit unit):返回值是布尔类型,代表是否获取锁,获取锁超时时间是time,单位是unit;如果leaseTime是-1,那代表会使用看门狗机制进行锁自动续期,否则不会使用看门狗机制进行锁自动续期,并且锁过期时间是leaseTime,单位是unit
11、RDB和AOF
11.1、RDB
11.1.1、概念
RDB全称Redis Database Backup file(Redis数据备份文件),也被叫做Redis数据快照。简单来说就是把内存中的所有数据都记录到磁盘中。当Redis实例故障重启后,从磁盘读取快照文件,恢复数据。
注意: Redis停机时会执行一次RDB。
11.1.2、RDB触发机制(配置文件+手动输入命令操作)
11.1.2.1、Redis内部触发机制:配置文件
11.1.2.2、Redis命令行手动触发方式:手动操作(不建议使用)
11.1.3、RDB备份原理
总结: 写时复制技术,详细做法如下:
11.1.4、总结
1、RDB方式bgsave的基本流程?
- fork主进程得到一个子进程,共享内存空间
- 子进程读取内存数据并写入新的RDB文件
- 用新RDB文件替换旧的RDB文件。
2、RDB会在什么时候执行?
- 默认是服务停止时。
3、save 60 1000代表什么含义?
- 代表60秒内至少执行1000次修改则触发RDB
4、 RDB的缺点?
- RDB执行间隔时间长,两次RDB之间写入数据有丢失的风险
- fork子进程、压缩、写出RDB文件都比较耗时
11.2、AOF
11.2.1、概念
AOF全称为Append Only File(追加文件)。Redis处理的每一个写命令都会记录在AOF文件中,可以看做是命令日志文件。
11.2.2、如何修改Redis配置文件
11.2.2.1、普通配置
11.2.2.2、AOF文件瘦身配置
AOF文件用来记录用户输入的命令,所以AOF文件会比RDB文件大的多。而且AOF会记录对同一个key的多次写操作,但只有最后一次写操作才有意义。通过手动执行bgrewriteaof命令,可以让AOF文件执行重写功能,用最少的命令达到相同效果。但是手动执行命令不太靠谱,然后Redis也会在触发阈值时自动去重写AOF文件,其中阈值可以在redis.conf中配置,下面是默认值,一般不用修改:
# AOF文件比上次文件 增长超过多少百分比则触发重写
auto-aof-rewrite-percentage 100
# AOF文件体积最小多大以上才触发重写
auto-aof-rewrite-min-size 64mb
11.3、RDB和AOF对比
注意: 在搭建Redis集群的时候,哨兵模式主从集群之间的数据同步使用RDB文件,所以RDB方式一定不能少
12、主从集群、哨兵集群、分片集群相关原理
12.1、主从集群
12.1.1、数据同步原理
首先介绍几个概念:
- Replication Id:简称replid,这是数据集的标记。每一个master都有唯一的replid,slave则会继承master节点的replid,只有replid一致才说明是同一数据集
- repl_baklog:存储尚未备份到RDB文件的命令
- offset:偏移量,offset随着记录在repl_baklog中的数据增多而逐渐增大,slave完成同步时也会记录当前同步的offset,如果slave的offset小于master的offset,说明slave数据落后于master,需要更新repl_baklog中的命令
- 总结:当slave向master做数据同步,必须向master声明自己的replid和offset,然后master才可以判断到底需要同步哪些数据
-
如果replid不一致,那就需要进行全量同步
-
如果replid一致,但是从节点的offset小于主节点的offset,那就进行增量同步
-
如果replid一致,但是由于repl_baklog大小有上限,当repl_baklog命令环写满后会覆盖最早的数据,如果slave断开时间过久,导致尚未备份的数据被覆盖,则无法基于log做增量同步,只能再次全量同步
-
12.1.2、从节点第一次加入主节点进行全量同步流程
- 全量同步的流程slave节点请求增量同步
- master节点判断replid,发现不一致,拒绝增量同步
- master将完整内存数据生成RDB,发送RDB到slave
- slave清空本地数据,加载master的RDB
- master将RDB期间的命令记录在repl_baklog,并持续将log中的命令发送给slave
- slave执行接收到的命令,保持与master之间的同步
第一阶段用命令来解释:
12.1.3、从节点重启之后尝试再次加入主节点进行数据同步
注意: 可以适当提高repl_baklog
的大小,发现slave
宕机时尽快实现故障恢复,尽可能避免全量同步
12.1.4、总结
12.2、哨兵集群
12.2.1、哨兵作用
对于主从集群来说,如果主节点宕机,那么整个集群的写功能直接瘫痪,而引入哨兵之后,哨兵可以发现宕机的主从节点,当主节点宕机之后,哨兵可以将从节点提升为主节点,如果从节点宕机之后,哨兵不会把读请求发送到该从节点
12.2.2、监控服务状态
12.2.3、选举新的master
一旦发现master故障,sentinel需要在salve中选择一个作为新的master,选择依据是这样的:
- 首先会判断slave节点与master节点断开时间长短,如果超过指定值(down-after-milliseconds * 10)则会排除该slave节点
- 然后判断slave节点的slave-priority值,越小优先级越高,如果是0则永不参与选举
- 如果slave-prority一样,则判断slave节点的offset值,越大说明数据越新,优先级越高
- 最后是判断slave节点的运行id大小,越小优先级越高。
12.2.4、实现故障转移
12.2.5、总结
12.2.6、RedisTemplate的哨兵模式
在Sentinel集群监管下的Redis主从集群,其节点会因为自动故障转移而发生变化,Redis的客户端必须感知这种变化,及时更新连接信息。Spring的RedisTemplate底层利用lettuce实现了节点的感知和自动切换。
1、在pom文件中引入redis的starter依赖
2、然后在配置文件application.yml中指定sentinel相关信息
3、配置主从读写分离
这里的ReadFrom是配置Redis的读取策略,是一个枚举,包括下面选择:
- MASTER:从主节点读取
- MASTER_PREFERRED:优先从master节点读取,master不可用才读取replica
- REPLICA:从slave(replica)节点读取
- REPLICA _PREFERRED:优先从slave(replica)节点读取,所有的slave都不可用才读取master
12.3、分片集群
12.3.1、分片集群结构
12.3.2、散列插槽
总结:
1、Redis如何判断某个key应该在哪个实例?
- 将16384个插槽分配到不同的实例
- 根据key的有效部分计算哈希值,对16384取余
- 余数作为插槽,寻找插槽所在实例即可
2、如何将同一类数据固定的保存在同一个Redis实例?
- 这一类数据使用相同的有效部分,例如key都以{typeId}为前缀
12.3.3、故障转移
13、小知识点
13.1、RedisTemplate的默认JDK序列化方式、RedisTemplate的自定义Jackson序列化方式、StringRedisTemplate字符串序列化方式,到底用哪个?
注意:用StringRedisTemplate
字符串序列化方式,不用其他的
先解释下用StringRedisTemplate
字符串序列化方式的优缺点:
-
优点:不用添加任何多余依赖,不用添加任何配置类,序列化之后效果也很棒,直接看到的就是字符串,即使是对象也不会存储全类名,统统都是字符串
-
缺点:存储到Redis时的序列化和从Redis读出结果的反序列化都需要自己来操作
再解释下其他序列化方式被弃用的原因,如下:
-
不用
RedisTemplate
的默认JDK序列化方式原因:默认情况下RedisTemplate
使用JDK序列化方式,但是在将数据写入Redis之前会把Object序列化为字节形式,然后存储在Redis中的键值就变成了下图模样,缺点是:可读性差(通过Redis客户端连接之后根本看不出来存储的是啥)、内存占用较大(本来就存储中国这两个字,直接能给我序列化出一大坨东西)
-
不用
RedisTemplate
的自定义Jackson序列化方式原因:虽然数据被很好的序列化,既不占用太多内存,也方便阅读,并且可以接收Object类型数据,不用我们操太多心,但是“成也萧何败也萧何”呀,当接收Object类型数据之后,在存储到Redis里面的时候,不仅会存储数据信息,还会存储对象信息,比如我将一个User对象交给这种方式的Redis进行存储,那Redis中存储的数据就像这种(下面第1张图),假设未来我把User的全路径位置改变了,那在Redis反序列的时候就会报错,这一点是我不能接受的,总不能被一个好处影响了我代码不能改动吧。当然这种方式还需要添加Jackson依赖(下面依赖),也需要对RestTemplate进行特殊配置(下面配置类)
Redis中对User对象的序列化结果:
Jackson依赖:<dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-databind</artifactId> </dependency>
RedisTemplate配置类:
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.GenericJackson2JsonRedisSerializer; import org.springframework.data.redis.serializer.RedisSerializer; @Configuration public class RedisConfig { @Bean public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory){ // 创建RedisTemplate对象 RedisTemplate<String, Object> template = new RedisTemplate<>(); // 设置连接工厂 template.setConnectionFactory(connectionFactory); // 创建JSON序列化工具 GenericJackson2JsonRedisSerializer jsonRedisSerializer = new GenericJackson2JsonRedisSerializer(); // 设置Key的序列化 template.setKeySerializer(RedisSerializer.string()); template.setHashKeySerializer(RedisSerializer.string()); // 设置Value的序列化 template.setValueSerializer(jsonRedisSerializer); template.setHashValueSerializer(jsonRedisSerializer); // 返回 return template; } }
二、操作命令
1、Redis命令官网
2、数据结构列表
- String:安全的二进制字符串
- List:有序可重复字符串集合,内部数据结构是链表,其中有序的含义是:按照插入顺序进行排序
- Set:无序不重复字符串集合
- Sorted set:无序不重复字符串集合,但是每一个字符串值都和一个浮点数相关联,这个浮点数叫做score分数,元素总是按照分数进行排序,所以我们可以按照分数来获取字符串元素值
- Hash:Map类型,其中键和值都是字符串
- Bit arrays:可以使用特殊命令来处理字符串值,如位数组:您可以设置和清除单个位,将所有设置为 1 的位计数,找到第一个设置或未设置的位,等等。常用来统计打卡情况。
- HyperLogLogs:这是一种概率数据结构,用于估计集合的基数。不要害怕,它比看起来更简单…。常用来统计日活、月活等。
- Streams:提供抽象日志数据类型的类似地图条目的仅附加集合。
3、Redis通用命令使用介绍
- KEYS:查看符合模板的所有key;比如:
keys 正则表达式(*可能可以使用,其他暂时不能确定)
- DEL:删除一个指定的key;比如:
del 键名
- EXISTS:判断key是否存在;比如:
exists 键名
- EXPIRE:给一个key设置有效期,有效期到期时该key会被自动删除;比如:
expire 键名 秒值
- TTL:查看一个KEY的剩余有效期,-1代表不过期;比如:
ttl 键名
- Type:查看键类型;比如:
type 键名
- object encoding:查看值存储类型;比如:
object encoding 键名
- info server:查看redis版本信息
- scan:逐步获取所有key,用来替代
keys *
(生产环境不能使用,避免影响主要流程执行),默认情况下标从0开始,每次查询10条结果,模式是*,知道某一次下标变成0的时候,说明已经将所有key都扫描完了;比如:第一次:scan 0
,下一次:scan 上次结果返回的下标
通过help [command]
可以查看一个命令的具体用法,例如:
4、String使用介绍(使用redis-cli命令行操作)
4.1、简单介绍
String类型,也就是字符串类型,是Redis中最简单的存储类型。
其value是字符串,不过根据字符串的格式不同,又可以分为3类:
- string:普通字符串
- int:整数类型,可以做自增、自减操作
- float:浮点类型,可以做自增、自减操作
不管是哪种格式,底层都是字节数组形式存储,只不过是编码方式不同。字符串类型的最大空间不能超过512m.
常用命令汇总:
- SET:添加或者修改已经存在的一个String类型的键值对
- GET:根据key获取String类型的value
- MSET:批量添加多个String类型的键值对
- MGET:根据多个key获取多个String类型的value
- INCR:让一个整型的key自增1
- INCRBY:让一个整型的key自增并指定步长,例如:incrby num 2 让num值自增2
- INCRBYFLOAT:让一个浮点类型的数字自增并指定步长
- SETNX:添加一个String类型的键值对,前提是这个key不存在,否则不执行
- SETEX:添加一个String类型的键值对,并且指定有效期
4.2、set ……、set …… ex ……(简写:setex)、set …… px ……、set …… nx(简写:setnx)、set …… xx
概念: 设置键和值
返回值:
设置成功,将返回提示信息:OK
模式: set key value [EX seconds] [PX milliseconds] [NX|XX]
脚本:
// 过期时间为-1,即不过期
set key1 value1
// 过期时间单位是10秒
set key1 value1 ex 10
// 过期单位是10000毫秒
set key1 value1 px 10000
// key不存在才能设置成功,否则失败返回(nil)
set key1 value1 nx
// key存在才能设置成功,否则失败返回(nil)
set key1 value1 xx
说明:
- value替换:如果不设置nx或者xx,无论key存在与否都不会失败,如果key不存在,那么就添加value,如果key存在,那就就替换掉原来的value值
4.3、mset
概念: 批量设置键和值,可以设置一个或者多个
返回值:
设置成功,将返回提示信息:OK
模式: mset key value [key value ...]
脚本:
// 其中键a、b、c的值分别是1、2、3
mset a 1 b 2 c 3
说明:
- 键值写法:mset后面可以写多个键值对,单个键值对之间用空格隔离,多个键值对之间也用空格隔离
4.4、getset
概念: 获取原有键的值,并为键设置新值
返回值:
如果原有键存在,那么将返回原有键中的值;如果原有键不存在,那么将返回(nil)
模式: getset key value
脚本:
// 设置key1的值为新值;如果key1原来存在,那么将返回key1的原来值;如果key1不存在,将返回(nil)
getset key1 value1
4.5、get
概念: 获取单个键的值
返回值:
如果键存在,将返回键中的值;如果键不存在,将返回(nil)
模式: get key
脚本:
get key1
4.6、mget
概念: 批量获取键的值,可以获取一个或者多个
返回值:
如果所有键中的值都不存在,将返回:
1) (nil)
如果部分键中的值不存在,不存在的键值返回(nil)
,存在的键值将返回具体的值,如下:
1) (nil)
2) (nil)
3) "1"
4) (nil)
5) "2"
6) (nil)
模式: mget key [key ...]
脚本:
// 返回值是一个值数组
mget a b c
4.7、del(所有类型可用)
概念: 删除键,可以删除一个或者多个
返回值:
返回删除成功的键的数量,即使键不存在,也不会报错
模式: del key [key ...]
脚本:
del a b
4.8、incr、incrby、decr、decrby(仅限Integer类型(String类型可以转换成Integer类型))
概念: 只能对值能强转成Integer类型的键操作,含义如下:incr(增加1)、incrby(增加指定数量)、decr(减小1)、decrby(减小指定数量),另外incrby也可以增加负数,那相当代替了decr和decrby的作用
返回值:
返回增加/减小之后的数值
模式:
// 增加1
incr key
// 增加指定数量
incrby key increment
// 减小1
decr key
// 减小指定数量
decrby key decrement
脚本:
// 增加1
incr a
// 增加50
incrby a 50
// 减小1
decr a
// 减小50
decrby key 50
4.9、incrbyfloat(仅限浮点类型(String类型可以转换成浮点类型))
概念: 只能对值能强转成float类型的键操作,含义如下:incrbyfloat(增加或者减少指定数量,如果是正数就是增加,否则负数就是减少)
返回值:
返回增加/减小之后的数值
模式:
// 增加指定数量
INCRBYFLOAT key num
// 减小指定数量
INCRBYFLOAT key num
脚本:
// 增加指定数量
INCRBYFLOAT mykey 0.1
// 减小指定数量
INCRBYFLOAT mykey -5
4.10、exists(所有类型可用)
概念: 判断键是否存在,,可以判断一个或者多个,返回存在的键的个数
返回值:
返回存在的键的数量,即使键不存在,也不会报错
模式: exists key [key ...]
脚本:
exists a b
4.11、type(所有类型可用)
概念: 判断值类型
返回值:
如果键存在,将返回键值,比如string、list等;如果键不存在,将返回none
模式: type key
脚本:
// 键a中存储的是string类型
type a
4.12、expire、pexpire(所有类型可用)
概念: 设置键的过期时间,其中expire设置的过期时间单位是秒,而pexpire设置的过期时间单位是毫秒
返回值:
如果键存在,将返回1;如果键不存在,将返回0
模式:
// 过期时间是秒
expire key seconds
// 过期时间是毫秒
pexpire key milliseconds
脚本:
// 设置键a的过期时间是10s
expire a 10
// 设置键a的过期时间是10000毫秒,也就是10s
pexpire a 10000
4.13、persist(所有类型可用)
概念: 设置键不过期
返回值:
如果键存在,将返回1;如果键不存在,将返回0
模式: persist key
脚本:
// 设置键a不过期
persist a
4.14、ttl、pttl(所有类型可用)
概念: ttl(获取键的存活时间,以秒为单位)、pttl(获取键的存活时间,以毫秒为单位),如果键不过期,那么将返回-1
返回值:
如果键存在,将按照单位返回键的存活时间,如果键不过期,将返回-1;如果键不存在,将返回-2
模式: ttl key
、pttl key
脚本:
// 获取键a的存活时间,以秒为单位
ttl a
// 获取键a的存活时间,以毫秒为单位
pttl a
5、List使用介绍(使用redis-cli命令行操作)
5.1、简单介绍
Redis中的List类型与Java中的LinkedList类似,可以看做是一个双向链表结构。既可以支持正向检索和也可以支持反向检索。
列表最多可存储 232 - 1 元素 (4294967295, 每个列表可存储40多亿)。
特征也与LinkedList类似:
- 有序
- 元素可以重复
- 插入和删除快
- 查询速度一般
常用来存储一个有序数据,例如:朋友圈点赞列表,评论列表等。
常用命令汇总:
- LPUSH key element … :向列表左侧插入一个或多个元素
- RPUSH key element … :向列表右侧插入一个或多个元素
- LPOP key:移除并返回列表左侧的第一个元素,没有则返回nil
- RPOP key:移除并返回列表右侧的第一个元素
- LRANGE key star end:返回一段角标范围内的所有元素
- BLPOP和BRPOP:与LPOP和RPOP类似,只不过在没有元素时等待指定时间,而不是直接返回nil
5.2、lpush(l:left)、rpush
概念: lpush代表从左侧往list集合中添加元素,rpush代表从右侧往list集合中添加元素
返回值:
返回添加成功的元素数量
模式: lpush key value [value ...]
、rpush key value [value ...]
脚本:
// 从左侧往mylist集合中添加元素
lpush mylist 1 2 3
// 从右侧往mylist集合中添加元素
rpush mylist "hello world" 4 5
5.3、lrange(l:list)
概念: 查看范围内的集合元素;我们可以从左往右数,第一个元素下标从0开始,往后下标依次递增;也可以从右往左数,最后一个元素下标是-1,往前下标依次递减,那么从左往右看集合中倒数第二个元素的下标是-2;并且这两种下标计算方式可以混用,比如lrange 集合名称 0 -1
代表查看集合中的所有元素
返回值:
如果集合中存在元素,将返回集合元素列表,例如:
1) "3"
2) "2"
3) "1"
4) "hello world"
5) "4"
6) "5"
如果集合中不存在元素,将返回提示信息,如下:
(empty list or set)
模式: lrange key start stop
脚本:
// 查看列表中的所有元素
lrange mylist 0 -1
5.4、rpop、lpop(l:left)
概念: rpop代表从右侧弹出一个元素,lpop代表从左侧弹出一个元素
返回值:
如果集合中存在元素,返回值是弹出的元素值,元素弹出之后,集合中的该元素将会被删除;如果集合不存在元素,那么返回(nil)
模式: rpop key
、lpop key
脚本:
// mylist是集合名称,从集合右侧弹出元素
rpop mylist
// mylist是集合名称,从集合左侧弹出元素
lpop mylist
5.5、ltrim(说明:1、l:list;2、获取限定数量的最新数据)
概念: 删除范围之外的元素,其中范围代表集合的范围;我们可以从左往右数,第一个元素下标从0开始,往后下标依次递增;也可以从右往左数,最后一个元素下标是-1,往前下标依次递减,那么从左往右看集合中倒数第二个元素的下标是-2;并且这两种下标计算方式可以混用,确实和lrange
的用法相似
返回值:
无论什么情况,都是返回OK
模式: ltrim key start stop
脚本:
// 只保留集合中下标从0到2(包括边界)的元素,其他的集合元素都将被删除
ltrim mylist 0 2
作用:
比如我们只想在集合中保留最新的10个元素,我们可以这样执行命令:
// 添加元素到集合头部
lpush mylist <some element>
// 只保留集合中的前10个最新的元素
ltrim mylist 0 9
5.6、llen(l:list)
概念: 获取集合长度
返回值:
如果集合存在,将返回集合中的元素数量;如果集合不存在,将返回0
模式: llen key
脚本:
// 获取集合名称为mylist的元素个数
llen mylist
5.7、brpop(说明:和lpush结合用作队列)、blpop(说明:1、l:left;2、不常用)
概念: 列表上的阻塞操作,可以阻塞式弹出一个
元素,可以把集合当队列来用;我们使用lpush
(从左边插入元素)和brpop
(弹出右边的元素)组合,就可以把集合当做队列来使用,满足队列的先进先出原则,并且我们可以设置阻塞时间(单位是秒);如果阻塞时间是0,那就可以无限期阻塞,直到队列中可以弹出元素,才会结束;如果阻塞时间是正整数,在时间结束之前没有弹出元素,将返回(nil)
,在时间内可以弹出元素,就返回弹出的元素;如果集合中原有就有元素,那是可以立即弹出元素的;另外多个集合中只要有一个集合弹出元素,阻塞就会停止,并且会返回集合名称和弹出的元素值
返回值:
如果可以弹出元素,那就返回弹出的元素,如下:
1) "mylist1"
2) "1"
(9.44s)
如果不能弹出元素,在时间结束之间,将会一直阻塞,在时间结束的时候,将会返回(nil)
,如下:
(nil)
(1.02s)
模式: brpop key [key ...] timeout
、blpop key [key ...] timeout
脚本:
// 不限期阻塞,从mylist或者mylist1的右侧弹出元素
brpop mylist mylist1 0
// 最多阻塞1s,从mylist左侧弹出元素
blpop mylist 1
5.8、小拓展
对于聚合类型,比如List、Streams、Sets、Sorted Sets 和 Hashes,有以下几点需要说明:
- 当我们将元素添加到聚合数据类型时,如果目标键不存在,则在添加元素之前创建一个空的聚合数据类型。
- 当我们从聚合数据类型中删除元素时,如果最终值为空,则键会自动销毁。 Stream 数据类型是此规则的唯一例外。
- 调用只读命令,例如 LLEN(返回列表的长度),或使用del命令操作不存在的聚合类型,总是返回0,就好像命令找到了空聚合类型。
综上所述: 如果操作过后,聚合类型为空,那么将删除该聚合类型;当向聚合类型中添加元素的时候,如果聚合类型不存在,那么将创建一个空的聚合类型
5.9、思考
6、Hash使用介绍(使用redis-cli命令行操作)
6.1、简单介绍
Hash类型,也叫散列,其value是一个无序字典,类似于Java中的HashMap结构。
每个 hash 可以存储 232 - 1 键值对(40多亿)。
String结构是将对象序列化为JSON字符串后存储,当需要修改对象某个字段时很不方便:
Hash结构可以将对象中的每个字段独立存储,可以针对单个字段做CRUD:
常用命令汇总:
- 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 HVALS:获取一个hash类型的key中的所有的value
- HINCRBY:让一个hash类型key的字段值自增并指定步长
- HSETNX:添加一个hash类型的key的field值,前提是这个field不存在,否则不执行
6.2、hset、hmset
概念: 设置hash中的键和值
返回值: hset(如果hash中的key之前已经存在,设置成功将返回0;如果hash中的key之前不存在,设置成功将返回1)、hmset(设置成功返回OK)
模式: hset key field value
、hmset key field value [field value ...]
脚本:
// 键是user:1,设置键中的username的值是xiaoming
hset user:1 username xiaoming
// 键是user:1,设置键中的username是xiaoming,age是10
hmset user:1 username xiaoming age 10
6.3、hget、hmget、hgetall
概念: 获取hash中键对应的值
返回值:
hget(如果键存在,则返回值,如果键不存在,则返回(nil)):
hmget(如果键存在,则返回值,如果键不存在,则返回(nil))
1) "xiaoming"
2) (nil)
hgetall(返回hash中的所有键和值,其中前面的是键,后面的是值)
1) "username"
2) "xiaoming"
3) "age"
4) "10"
5) "address"
6) "china"
7) "high"
8) "2m"
模式: hget key field
、hmget key field [field ...]
、hgetall key
脚本:
// 键是user:1,获取username的值
hget user:1 username
// 键是user:1,获取username和age的值
hmget user:1 username age
// 键是user:1,获取所有键和值
hgetall user:1
6.4、hincrby
概念: 增加hash中键对应的值的数量
返回值: 设置之后的值
模式: hincrby key field increment
脚本:
// 年龄增加2岁
hincrby user:1 age 2
6.5、hkeys
概念: 获取hash中值键集合
返回值: hash中值键集合
1) "field1"
2) "field2"
模式: hkeys key
脚本:
// 获取用户hash的键集合
hkeys user
6.6、hvals
概念: 获取hash中所有值集合
返回值: hash中所有值集合
1) "Hello"
2) "World"
模式: hvals key
脚本:
// 获取用户hash的值集合
hvals user
6.7、hsetnx
概念: hash中键不存在才能设置成功,否则设置失败
返回值: 成功返回1,失败返回0
模式: hsetnx key field value
脚本:
// 如果用户hash中不存在键为name的情况,那么将设置name的值为“明快de玄米61”,然后返回1;如果用户hash中已经存在键为name的情况,那就就不设置,返回0
hsetnx user name "明快de玄米61"
7、Set使用介绍(使用redis-cli命令行操作)
7.1、简单介绍
Redis的Set结构与Java中的HashSet类似,可以看做是一个value为null的HashMap。
集合中最大的成员数为 232 - 1 (4294967295, 每个集合可存储40多亿个成员)。
因为也是一个hash表,因此具备与HashSet类似的特征:
- 无序
- 元素不可重复
- 查找快
- 支持交集、并集、差集等功能
常用命令汇总:
- SADD key member … :向set中添加一个或多个元素
- SREM key member … : 移除set中的指定元素
- SCARD key: 返回set中元素的个数
- SISMEMBER key member:判断一个元素是否存在于set中
- SMEMBERS:获取set中的所有元素
- SINTER key1 key2 … :求key1与key2的交集
- SDIFF key1 key2 … :求key1与key2的差集
- SUNION key1 key2 …:求key1和key2的并集
7.2、sadd
概念: 添加元素到Set集合中
返回值: 添加成功的元素个数
模式: sadd key member [member ...]
脚本:
// 添加1和2到myset集合中
sadd myset 1 2
7.3、spop
概念: 从set结合中随机弹出特定数量的元素,弹出元素将被删除
返回值:
被弹出的元素列表,如下:
1) "1"
模式: spop key [count]
脚本:
// 从myset集合中随机弹出1个元素
spop myset 1
7.4、smembers
概念: 输出set集合中的所有元素
返回值: 集合中的所有元素列表,如下:
1) "1"
2) "2"
模式: smembers key
脚本:
// 输出myset集合中的所有元素
smembers myset
7.5、sismember
概念: 判断元素是否在set集合中
返回值: 如果元素在集合中,就返回1;如果元素不在集合中,就返回0
模式: sismember key member
脚本:
// 判断元素1是否在myset集合中
sismember myset 1
7.6、scard
概念: 查看set集合中的元素数量
返回值: set集合中的元素总数量
模式: scard key
脚本:
// 获取set集合中的元素总数量
scard myset
7.7、sunionstore
概念: 将一个或者一个以上集合的并集赋值给一个新的集合
返回值: 新集合中的元素总量
模式: sunionstore destination key [key ...]
脚本:
// 创建myset1
sadd myset1 1 2
// 创建myset2
sadd myset2 2 3
// 将myset1和myset2中的元素合并的myset3集合中
sunionstore myset3 myset1 myset2
7.8、sinter
概念: 获取一个或者多个集合的交集
返回值: 集合交集列表,如果是单个集合,将返回该集合中的全部元素,例如sinter myset1 myset2
的结果如下:
1) "2"
模式: sinter key [key ...]
脚本:
// 获取myset1和myset2的交集
sinter myset1 myset2
7.9、sdiff
概念: 获取一个或者多个集合对“第一个集合”的差集
返回值: 集合差集列表,如果是单个集合,将返回空,例如sdiff myset1 myset2
的结果如下:
1) "1"
模式: sdiff key [key ...]
脚本:
// 获取myset1和myset2的差集
sinter myset1 myset2
7.10、sunion
概念: 获取一个或者多个集合的并集
返回值: 集合并集列表,如果是单个集合,将返回集合总的全部元素,例如sunion myset1 myset2
的结果如下:
1) "1"
2) "2"
3) "3"
模式: sunion key [key ...]
脚本:
// 获取myset1和myset2的并集
sunion myset1 myset2
7.11、srandmember
概念: 随机弹出一个或者多个集合中的元素,并且不会删除集合中的这些元素;根据count的大小不同将返回不同的结果,具体规则如下:
返回值: 如果不写count值,就返回一个元素,例如"1"
;如果count大于1,将返回元素列表,如下:
1) "3"
2) "2"
3) "1"
4) "3"
5) "3"
6) "2"
模式: srandmember key [count]
脚本:
// 随机返回一个元素
srandmember myset3
// 随机返回6个元素,不会重复,由于myset3中只有三个元素,所以会返回全部3个元素
srandmember myset3 6
// 随机返回6个元素,允许重复,将会返回6个元素
srandmember myset3 -6
7.12、srem
概念: 删除Set集合中的元素
返回值: 删除成功的元素个数
模式: srem key member [member ...]
脚本:
// 删除myset集合中的1和2
srem myset 1 2
8、Sorted Set(使用介绍(使用redis-cli命令行操作))
8.1、简单介绍
Redis的SortedSet是一个可排序的set集合,与Java中的TreeSet有些类似,但底层数据结构却差别很大。SortedSet中的每一个元素都带有一个score属性,可以基于score属性对元素排序,底层的实现是一个跳表(SkipList)加 hash表。
Redis zset 和 set 一样也是string类型元素的集合,且不允许重复的成员。
SortedSet具备下列特性:
- 可排序
- 元素不重复
- 查询速度快
因为SortedSet的可排序特性,经常被用来实现排行榜这样的功能。
常用命令汇总:
- ZADD key score member:添加一个或多个元素到sorted set ,如果已经存在则更新其score值
- ZREM key member:删除sorted set中的一个指定元素
- ZSCORE key member : 获取sorted set中的指定元素的score值
- ZRANK key member:获取sorted set 中的指定元素的排名
- ZCARD key:获取sorted set中的元素个数
- ZCOUNT key min max:统计score值在给定范围内的所有元素的个数
- ZINCRBY key increment member:让sorted set中的指定元素自增,步长为指定的increment值
- ZRANGE key min max:按照score排序后,获取指定排名范围内的元素
- ZRANGEBYSCORE key min max:按照score排序后,获取指定score范围内的元素
- ZDIFF、ZINTER、ZUNION:求差集、交集、并集
8.2、zadd
概念: 插入元素到排序集合中
返回值: 插入成功的元素数量
模式: zadd key [NX|XX] [CH] [INCR] score member [score member ...]
脚本:
// 其中0、1、2分别是a、b、c的分数
zadd hackers 0 a 1 b 2 c
说明:
对于NX和XX的含义可以看:https://redis.io/commands/zadd/
8.3、zrange、zrevrange
概念: zrange按照分数从大到小排序,而zrevrange按照分数从大到小排序,并且根据开始下标和结束下标来控制范围,其中的开始下标和结束下标解释如下:我们可以从左往右数,第一个元素下标从0开始,往后下标依次递增;也可以从右往左数,最后一个元素下标是-1,往前下标依次递减,那么从左往右看集合中倒数第二个元素的下标是-2;并且这两种下标计算方式可以混用
返回值: 如果不加WITHSCORES,将按照相关排序返回范围内的集合中的元素,如下:
1) "a"
2) "b"
3) "c"
如果加WITHSCORES,将按照相关排序返回范围内的集合中的元素,并且返回对应的分数,如下:
1) "a"
2) "0"
3) "b"
4) "1"
5) "c"
6) "2"
模式: zrange key start stop [WITHSCORES]
、zrevrange key start stop [WITHSCORES]
脚本:
// 按照分数从小到大排序,目前获取的是集合中的全部元素
zrange hackers 0 -1
// 按照分数从小到大排序,并且添加withscores之后还会输出分数,目前获取的是集合中的全部元素
zrange hackers 0 -1 withscores
8.4、zrangebyscore、zrevrangebyscore
概念: zrangebyscore按照分数范围筛选(小分数边界在前面,大分数边界在后面,范围包括边界处的分数),然后按照分数从大到小排序,而zrevrangebyscore按照分数范围筛选(大分数边界在前面,小分数边界在后面,范围包括边界处的分数),然后按照分数从大到小排序,它们是根据分数来限制范围,而不是下标
返回值: 如果不加WITHSCORES,将按照相关分数排序后返回分数范围内的集合中的元素,如下:
1) "a"
2) "b"
3) "c"
如果加WITHSCORES,将按照相关分数排序后返回分数范围内的集合中的元素,并且返回对应的分数,如下:
1) "a"
2) "0"
3) "b"
4) "1"
5) "c"
6) "2"
模式: zrangebyscore key min max [WITHSCORES] [LIMIT offset count]
、zrevrangebyscore key max min [WITHSCORES] [LIMIT offset count]
脚本:
// 按照分数范围进行筛选,其中小分数边界在前面,大分数边界在后面,范围包括边界处的分数,然后按照分数进行从小到大排序,并且输出分数
zrangebyscore hackers 1 2 withscores
// 按照分数范围进行筛选,其中大分数边界在前面,小分数边界在后面,范围包括边界处的分数,然后按照分数进行从大到小排序,并且输出分数
zrevrangebyscore hackers 2 1 withscores
8.5、zremrangebyscore
概念: 根据分数范围进行筛选之后,在删除
返回值: 删除成功的元素个数
模式: zremrangebyscore key min max
脚本:
// 根据分数删除分数范围内的集合元素,前面是小分数,后面是大分数,包括分数边界
zremrangebyscore hackers 0 1
8.6、zrank
概念: 获取元素在集合中的排行(按照分数从大到小排序),排行从0开始
返回值: 元素在集合中的排行(按照分数从大到小排序),排行从0开始
模式: zrank key member
脚本:
// 获取元素c元素在集合hackers中的排行(按照分数从大到小排序),排行从0开始
zrank hackers c
8.7、zrem
概念: 删除排序集合中的元素
返回值: 删除成功的元素数量
模式: zrem key member [member ...]
脚本:
// 其中one、two都是元素,不是分数
ZREM myzset "one" "two"
8.8、zscore
概念: 查询排序集合中的元素得分
返回值: 返回集合中的元素得分,如果该元素不存在集合中,那么返回null
模式: zscore key member
脚本:
// 其中three都是元素
zscore myzset "three"
8.9、zcard
概念: 查询排序集合中的元素个数
返回值: 返回集合中的元素个数,如果键不存在,那么返回0
模式: zcard key
脚本:
// 其中myzset是键名
zscore myzset
8.10、zcount
概念: 在查询排序集合中,返回在分数范围(包含边界值)内的元素个数
返回值: 返回分数范围内的元素个数
模式: zcount key min max
脚本:
// 其中myzset是键名,用来查找集合中分数大于等于1,并且小于等于2的元素数量
zcount myzset 1 2
8.11、zincrby
概念: 让集合中的指定元素自增,步长为指定的increment值
返回值: 集合中元素修改之后的得分
模式: zincrby key increment member
脚本:
// 其中myzset是键名,让集合中为one的元素分数值增加2分
zincrby myzset 2 one
8.12、zdiff
概念: 求差集
返回值: 关于第一个集合的差集元素集合
模式: zdiff numkeys key [key ...]
脚本:
// 其中myzset1和myzset2都是键名
zincrby myzset1 myzset2
8.13、zinter
概念: 求交集
返回值: 多个集合的交集
模式: zinter numkeys key [key ...]
脚本:
// 其中myzset1和myzset2都是键名
zinter myzset1 myzset2
8.14、zunion
概念: 求并集
返回值: 多个集合的并集
模式: zunion numkeys key [key ...]
脚本:
// 其中myzset1和myzset2都是键名
zunion myzset1 myzset2
三、环境搭建
1、windows
(1)单机版
1)下载
目前官方不提供windows版本的Redis,不过我们可以点击Windows版Redis来选择合适版本,然后点击Downloads
按钮,如下(注意:别点下图中的zip下载,那是源码zip):
然后在里面选择zip版本进行下载,如下:
这里我给大家提供Redis-x64-3.2.100.zip
安装包,如下:
链接:https://pan.baidu.com/s/1GzbARFP1cq4LjKUeXlYa_Q
提取码:zuw8
2)安装
解压即安装成功
3)启动
双击redis-service.exe
即可启动
2、linux
(1)单机版
1)下载
首先打开Redis官网下载页面,然后选择合适的版本进行下载
下面为大家提供redis-6.2.13.tar.gz
版本的安装包,如下:
链接:https://pan.baidu.com/s/1k910snMEfzbJP07uQqhOwA?pwd=1xuo
提取码:1xuo
2)安装gcc编译器
yum -y install gcc
说明: Redis是使用C语言
编写的,我们使用源文件安装方式需要编译C语言
源文件,所以需要安装gcc编译器
3)安装Redis
首先将redis-6.2.13.tar.gz
上传到虚拟机的/usr/local/src
目录下,如下:
在xshell中执行cd /usr/local/src
命令进入压缩包所在目录
在上述src目录下执行tar -zxvf redis*.tar.gz
命令解压安装包到当前目录,写*
目的是都可以执行这些命令
在上述src目录下执行cd redis*
进入解压Redis目录
在上述解压Redis目录下执行make && make install
命令编译Redis源文件,如下:
等待执行成功即可
4)修改Redis配置文件
在redis解压目录中找到redis.conf
文件,然后根据需要进行修改,下面我说两种情况,大家根据情况选择
情况1(推荐,安全): 设置Redis密码
-
将
bind 127.0.0.1 -::1
改为bind 0.0.0.0
作用:不限制连接Redis的ip,支持Redis连接工具连接Redis -
将
daemonize no
改为daemonize yes
作用:开启Redis守护模式,允许redis后台运行,所以在启动redis的时候就不用加&
了 -
将
dir ./
改为dir /usr/local/src/redis-6.2.13
,请将/usr/local/src/redis-6.2.13
替换成你自己的redis安装地址哦!
作用:首先dir
的值是存储aof数据文件
、rdb数据文件
、日志文件
的根目录,而dir ./
代表redis-server
命令启动的目录就是dir
目录,由于redis-server
的位置是/usr/local/bin/redis-server
,所以/usr/local/bin
就是根目录,这样放置数据文件不好,所以把dir
的值修改成特定位置 -
将
logfile ""
改为logfile "redis.log"
作用:设置Redis日志文件名称,用来存储日志信息,然后该日志文件将会存储在redis.conf
配置文件中dir
目录下 -
将
# requirepass foobared
改为requirepass admin123456
作用:设置Redis连接密码,其中admin123456
就是我的连接密码,大家可以替换成自己的 -
将
appendonly no
改成appendonly yes
作用:Redis数据持久化
情况2(不推荐,不安全): 不设置Redis密码
-
将
bind 127.0.0.1 -::1
改为bind 0.0.0.0
作用:不限制连接Redis的ip,支持Redis连接工具连接Redis -
将
protected-mode yes
改为protected-mode no
作用:取消Redis保护模式,可以不设置密码登录,记得一定把配置中对requirepass
的密码设置那一行注释,不然密码还是会起效的 -
将
daemonize no
改为daemonize yes
作用:开启Redis守护模式,允许redis后台运行,所以在启动redis的时候就不用加&
了 -
将
dir ./
改为dir /usr/local/src/redis-6.2.13
,请将/usr/local/src/redis-6.2.13
替换成你自己的redis安装地址哦!
作用:首先dir
的值是存储aof数据文件
、rdb数据文件
、日志文件
的根目录,而dir ./
代表redis-server
命令启动的目录就是dir
目录,由于redis-server
的位置是/usr/local/bin/redis-server
,所以/usr/local/bin
就是Redis数据文件存储的dir根目录,这样放置数据文件不好,所以把dir
的值修改成特定位置 -
将
logfile ""
改为logfile "redis.log"
作用:设置Redis日志文件名称,用来存储日志信息,然后该日志文件将会存储在redis.conf
配置文件中dir
目录下 -
将
appendonly no
改成appendonly yes
作用:Redis数据持久化
5)启动Redis
# 通过cd命令进入redis解压目录下,然后执行以下命令,主要是用到修改之后的redis.conf配置文件
redis-server redis.conf
6)关闭Redis
情况1: Redis有密码
我们在虚拟机中执行redis-cli -a 密码
命令,比如redis-cli -a admin123456
就可以登录redis客户端控制台,然后输入shutdown
回车即可关闭Redis,之后Ctrl+C
退出Redis客户端就可以了
情况2: Redis没有密码
我们在虚拟机中执行redis-cli
命令就可以登录redis客户端控制台,然后输入shutdown
回车即可关闭Redis,之后Ctrl+C
退出Redis客户端就可以了
7)拓展:启动、停止方式1
执行命令打开并新建配置文件,文件名称是应用名称,可以用作快捷调用,后缀是固定的service
vim /lib/systemd/system/redis.service
然后粘贴以下内容到上述文件中并保存,注意修改你的启动命令,也就是ExecStart
后面的值
[Unit]
Description=redis-server
After=network.target
[Service]
Type=forking
ExecStart=/usr/local/bin/redis-server /usr/local/src/redis-6.2.13/redis.conf
PrivateTmp=true
[Install]
WantedBy=multi-user.target
- Description:描述信息,没啥作用,根据文件作用做修改
- ExecStart:启动命令,对于
Reids
来说,编译Redis
源码之后就会在/usr/local/bin/
目录下生成对应启动脚本文件,所以启动脚本文件用这个也是ok的
执行以下命令重启系统服务(不会重启虚拟机):
systemctl daemon-reload
可以通过如下命令对Redis执行操作:
# 说明:虽然文件名称是redis.service,但是执行命令时可以省略.service
# 运行Redis
systemctl start redis
# 停止Redis
systemctl stop redis
# 重启Redis
systemctl restart redis
# 查看Redis运行状态
systemctl status redis
# Redis开机自启
systemctl enable redis
# 关闭Redis开机自启
systemctl disable redis
8)拓展:停止方式2
执行
ps -ef | grep redis
找到redis进程,然后执行
kill -9 Redis进程号
来关闭Redis
9)拓展:Redis自带客户端使用方式
# 连接方式:
# 1、有密码
redis-cli -a 密码
# 2、需要指定redis端口,并且有密码
redis-cli -p 端口 -a 密码
# 3、连接Redis主从分片集群
# 说明:如果不添加-c,那就不是以集群方式连接到客户的,按照这种情况来说,由于Redis主节点只存储一部分数据,当执行添加键值操作的时候,而有些数据的hash值不在当前节点可接受范围内,那么就需要存储到其他节点,对于这种无法存储的情况会报错的。如果添加-c,那就是以集群方式连接到客户的,这样即使出现了上面的情况,集群会把添加键值的请求转发到其他节点。
redis-cli -c -p 端口 -a 密码
# 客户端命令
# 1、查看集群副本信息
info replication
(2)哨兵版
1)下载
2)安装gcc编译器
3)安装Redis
请参考上述安装Linux单机版本中的前3步
,由于我在自己笔记本上搭建集群,所以就使用一台虚拟机,但是Redis端口不同呢,利用这种方式来模拟Redis哨兵集群搭建的过程
4)搭建Redis主从副本集群
根据上述安装步骤,Redis
依然安装在/usr/local/src/redis-6.2.13
目录下面
首先我们创建三个目录来存储Redis
主从集群配置文件redis.conf
mkdir -p /usr/local/src/redisMasterSlaveCluster/6001
mkdir -p /usr/local/src/redisMasterSlaveCluster/6002
mkdir -p /usr/local/src/redisMasterSlaveCluster/6003
创建完成如下图:
然后把/usr/local/src/redis-6.2.13
目录下的原始redis.conf
文件分别复制到上述6001、6002、6003目录下,我们来规划主节点和从节点,其中端口6001为主节点,而6002和6003为从节点,然后按照下面要求进行配置文件的修改
说明:下面没有做特殊说明的,那就是主、从节点都需要做修改
-
将
port 6379
改为port 节点端口号
作用:修改redis端口号,由于6001、6002、6003目录下都存在redis.conf
,那就把目录名称当做端口号来执行修改操作 -
将
bind 127.0.0.1 -::1
改为bind 0.0.0.0
作用:不限制连接Redis的ip,支持Redis连接工具连接Redis -
将
daemonize no
改为daemonize yes
作用:开启Redis守护模式,允许redis后台运行,所以在启动redis的时候就不用加&
了 -
将
dir ./
改为dir 6001、6002、6003目录全路径
,比如:6001目录
下的redis.conf
中就需要将dir ./
改为dir "/usr/local/src/redisMasterSlaveCluster/6001"
作用:首先dir
的值是存储aof数据文件
、rdb数据文件
、日志文件
的根目录,而dir ./
代表redis-server
命令启动的目录就是dir
目录,由于redis-server
的位置是/usr/local/bin/redis-server
,所以/usr/local/bin
就是根目录,这样放置数据文件不好,所以把dir
的值修改成特定位置 -
将
logfile ""
改为logfile "redis.log"
作用:设置Redis日志文件名称,用来存储日志信息,然后该日志文件将会存储在redis.conf
配置文件中dir
目录下 -
将
# requirepass foobared
改为requirepass admin123456
作用:设置Redis连接密码,其中admin123456
就是我的连接密码,大家可以替换成自己的 -
将
# masterauth <master-password>
替换成masterauth "admin123456"
作用:设置连接主节点密码,我会把Redis节点密码都设置成admin123456
,所以在哨兵模式下,即使出现故障的时候,无论哪个节点成为了主节点,其他从节点都是可以连接上主节点的 -
将
appendonly no
改成appendonly yes
作用:Redis数据持久化 -
(只要求从节点执行,目前是6002和6003节点)将
# replicaof <masterip> <masterport>
替换成replicaof 192.168.56.10 6001
作用:从节点要连接上主节点,那就需要知道主节点的ip和port信息,这就是用来指定主节点的连接信息的,其中192.168.56.10
是主节点所在虚拟机ip,而6001
主节点Redis端口
现在就把主从集群准备好了,我们可以通过执行命令redis-server redis.conf全路径
来依次启动主节点(端口为6001的节点)和从节点(端口分别为6002和6003的节点),例如具体命令如下
# 1、启动主节点
redis-server /usr/local/src/redisMasterSlaveCluster/6001/redis.conf
# 2、启动6002从节点
redis-server /usr/local/src/redisMasterSlaveCluster/6002/redis.conf
# 3、启动6003从节点
redis-server /usr/local/src/redisMasterSlaveCluster/6003/redis.conf
现在我们就把一主二从的Redis主从副本集群搭建成功了。
对于搭建成功的主从集群,我们介绍一下它的特点:
- 主节点
支持读写
,但是从节点只支持读
。大家可以通过redis-cli -p 端口 -a 密码
连接Redis本地客户端去尝试,也可以通过Redis远程客户端去尝试 - 无法自动完成故障转移。如果主节点挂掉,从节点只会无限尝试连接主节点,这个过程可以从6002和6003从节点的日志中看到
正是由于Redis主从副本集群无法完成故障转移,所以我们需要Redis哨兵集群来帮助完成自动故障转移
5)搭建Redis哨兵集群
首先我们创建三个目录来存储Redis
哨兵集群配置文件sentinel.conf
mkdir -p /usr/local/src/redisSentinel/7001
mkdir -p /usr/local/src/redisSentinel/7002
mkdir -p /usr/local/src/redisSentinel/7003
创建完成如下图:
然后把/usr/local/src/redis-6.2.13
目录下的原始sentinel.conf
文件分别复制到上述7001、7002、7003目录下,之后按照下面要求进行配置文件的修改
-
将
port 26379
改为port 哨兵节点端口号
作用:修改redis哨兵端口号,由于7001、7002、7003目录下都存在sentinel.conf
,那就把目录名称当做端口号来执行修改操作 -
将
daemonize no
改为daemonize yes
作用:开启守护模式,允许redis哨兵后台运行,所以在启动redis哨兵的时候就不用加&
了 -
将
dir ./
改为dir 7001、7002、7003目录全路径
,比如:7001目录
下的redis.conf
中就需要将dir ./
改为dir "/usr/local/src/redisSentinel/7001"
作用:首先dir
的值是存储日志文件
的根目录,而dir ./
代表redis-sentinel
命令启动的目录就是dir
目录,由于redis-sentinel
的位置是/usr/local/bin/redis-sentinel
,所以/usr/local/bin
就是根目录,这样放置数据文件不好,所以把dir
的值修改成特定位置 -
将
logfile ""
改为logfile "sentinel.log"
作用:设置Redis哨兵日志文件名称,用来存储日志信息,然后该日志文件将会存储在sentinel.conf
配置文件中dir
目录下 -
将
# sentinel monitor <master-name> <ip> <redis-port> <quorum>
替换成sentinel monitor mymaster 192.168.56.10 6001 2
作用:Redis哨兵存在的意义就是监听我们上面搭建的Redis主从副本集群,由于所有信息都可以通过主节点获取到,所以在哨兵配置文件sentinel.conf中需要配置redis主节点连接信息,其中mymaster
是主节点名称,这是我们自己定义的,192.168.56.10
是Redis主节点ip,6001
是Redis主节点端口,2
表示当有两个哨兵节点认为节点无法连接,那就需要让节点下线 -
将
# sentinel auth-pass <master-name> <password>
改成sentinel auth-pass mymaster admin123456
作用:由于Redis主节点设置了连接密码,所以我们需要指定Redis连接密码才能让Redis哨兵连接上Redis主节点,其中admin123456
就是我的Redis主节点密码 -
将
sentinel down-after-milliseconds mymaster 30000
改成sentinel down-after-milliseconds mymaster 5000
作用:设置Redis哨兵节点和Redis节点连接的最大中断时间,现在的设置是最多5s
连接不上某节点,那就说明该节点下线了
现在就把Redis哨兵集群准备好了,我们可以通过执行命令redis-sentinel sentinel.conf全路径
来依次启动端口为7001、7002、7003的三个Redis哨兵节点,例如具体命令如下
# 1、启动7001哨兵节点
redis-sentinel /usr/local/src/redisSentinel/7001/sentinel.conf
# 2、启动7002哨兵节点
redis-sentinel /usr/local/src/redisSentinel/7002/sentinel.conf
# 3、启动7003哨兵节点
redis-sentinel /usr/local/src/redisSentinel/7003/sentinel.conf
现在我们就把一个主节点(端口:6001)、两个从节点(端口:6002、6003)、三个哨兵节点(端口:7001、7002、7003)的Redis哨兵集群搭建成功了。
上面Redis主从副本集群无法实现自动故障转移,但是Redis哨兵集群可以,假设此时我们把Redis6001主节点关闭,那么Redis哨兵集群会发现该变化,然后将剩余的Redis从节点提升为主节点,并且完成其他从节点对主节点的监听功能。如果此时Redis6001节点完成了手动故障恢复,它此时会以从节点的身份加入Redis集群,这就实现了自动故障转移。
我们聊一下在Redis主从副本集群的基础上引入Redis哨兵集群的目的,引入哨兵就是为了解决Redis主节点宕机导致集群整体无法使用的问题,所以即使我们在代码中连接的是Redis哨兵集群,但是集群本质没有改变,依然是Redis主节点支持读写
,但是从节点只支持读
的状态
(3)分片版
上面介绍了Redis哨兵集群,但是哨兵集群有一个很大的问题是写能力受限,毕竟只有一个节点支持写操作
,其他节点都只支持读操作
。另外一个问题是Redis哨兵集群所有节点都存储全量数据,但是再好的机器也顶不住纵向扩容呀,所以很有可能出现空间不够用的情况。而我们Redis分片集群就能解决这些问题,并且依然支持数据副本备份、自动故障转移的能力
这种分片类似于Elasticsearch分片方式,我们下面来搭建一个三主三从的Redis分片集群,拓扑图如下:
1)下载
2)安装gcc编译器
3)安装Redis
请参考上述安装Linux单机版本中的前3步
,由于我在自己笔记本上搭建集群,所以就使用一台虚拟机,但是Redis端口不同呢,利用这种方式来模拟Redis哨兵集群搭建的过程
4)准备Redis节点
根据上述安装步骤,Redis
依然安装在/usr/local/src/redis-6.2.13
目录下面
首先我们创建6个目录来存储Redis
分片集群配置文件redis.conf
mkdir -p /usr/local/src/redisShardedCluster/8001
mkdir -p /usr/local/src/redisShardedCluster/8002
mkdir -p /usr/local/src/redisShardedCluster/8003
mkdir -p /usr/local/src/redisShardedCluster/8004
mkdir -p /usr/local/src/redisShardedCluster/8005
mkdir -p /usr/local/src/redisShardedCluster/8006
创建完成如下图:
然后把/usr/local/src/redis-6.2.13
目录下的原始redis.conf
文件分别复制到上述8001、8002、8003、8004、8005、8006目录下,然后按照下面要求进行配置文件的修改
-
将
port 6379
改为port 节点端口号
作用:修改redis端口号,由于8001、8002、8003、8004、8005、8006目录下都存在redis.conf
,那就把目录名称当做端口号来执行修改操作 -
将
bind 127.0.0.1 -::1
改为bind 0.0.0.0
作用:不限制连接Redis的ip,支持Redis连接工具连接Redis -
将
daemonize no
改为daemonize yes
作用:开启Redis守护模式,允许redis后台运行,所以在启动redis的时候就不用加&
了 -
将
dir ./
改为dir 8001、8002、8003、8004、8005、8006目录全路径
,比如:8001目录
下的redis.conf
中就需要将dir ./
改为dir "/usr/local/src/redisShardedCluster/8001"
作用:首先dir
的值是存储aof数据文件
、rdb数据文件
、日志文件
的根目录,而dir ./
代表redis-server
命令启动的目录就是dir
目录,由于redis-server
的位置是/usr/local/bin/redis-server
,所以/usr/local/bin
就是根目录,这样放置数据文件不好,所以把dir
的值修改成特定位置 -
将
logfile ""
改为logfile "redis.log"
作用:设置Redis日志文件名称,用来存储日志信息,然后该日志文件将会存储在redis.conf
配置文件中dir
目录下 -
将
# requirepass foobared
改为requirepass admin123456
作用:设置Redis连接密码,其中admin123456
就是我的连接密码,大家可以替换成自己的 -
将
# masterauth <master-password>
替换成masterauth "admin123456"
作用:设置连接主节点密码,我会把Redis节点密码都设置成admin123456
,所以在分片集群模式下,即使出现故障的时候,无论哪个节点成为了主节点,其他从节点都是可以连接上主节点的 -
将
appendonly no
改成appendonly yes
作用:Redis数据持久化 -
将
pidfile /var/run/redis_6379.pid
改成pidfile /var/run/redis_cluster.pid
作用:这个文件的作用我还没有探究清晰,但是都用这个名称不太好,还是换一个吧 -
将
# cluster-enabled yes
改成cluster-enabled yes
作用:开启Redis分片集群模式 -
将
# cluster-config-file nodes-6379.conf
改成cluster-config-file node-cluster.conf
作用:Redis分片集群使用该conf文件存储集群自身所需的数据,我们只需要设置名称即可,集群会自动创建,不需要我们管它 -
将
# cluster-node-timeout 15000
改成cluster-node-timeout 5000
作用:如果集群主节点之间超过5s没有通信,那么从节点将会变成主节点,通过主从切换来完成自动故障转移
现在就把主从集群准备好了,我们可以通过执行命令redis-server redis.conf全路径
来依次启动8001、8002、8003、8004、8005、8006节点,具体命令如下
redis-server /usr/local/src/redisShardedCluster/8001/redis.conf
redis-server /usr/local/src/redisShardedCluster/8002/redis.conf
redis-server /usr/local/src/redisShardedCluster/8003/redis.conf
redis-server /usr/local/src/redisShardedCluster/8004/redis.conf
redis-server /usr/local/src/redisShardedCluster/8005/redis.conf
redis-server /usr/local/src/redisShardedCluster/8006/redis.conf
5)创建Redis集群
虽然上面已经把Redis节点准备好了,但是节点之间是无法互通的,所以我们需要创建集群,执行如下命令即可:
redis-cli --cluster create --cluster-replicas 1 192.168.56.10:8001 192.168.56.10:8002 192.168.56.10:8003 192.168.56.10:8004 192.168.56.10:8005 192.168.56.10:8006 -a admin123456
解释一下:
--cluster-replicas 1
:指定集群中每个master的副本个数为1,此时节点总数 ÷ (replicas + 1) 得到的就是master的数量。因此节点列表中的前n个就是master,其它节点都是slave节点,随机分配到不同master- 6个ip:port:这是上述创建的6个Redis节点的ip和port
- -a admin123456:由于Redis节点有密码,所以需要填写密码
上述命令执行完成之后,还需要我们输入yes
并回车,我们照做就是,然后集群就创建完成了
上面我们提到Redis分片集群解决了节点数据写入能力不够的问题,现在搭建的是一个三主三从节点,所以有三个主节点可以用了数据写入,所以节点数据写入能力得到了大大提升。
上面我们还提到Redis分片集群可以解决纵向扩容的问题,现在三个主节点中存储的部分数据,而不是全量数据,相当于把全部数据分成了三个地方存储,并且从节点可以为对应主节点提供副本能力,这样横向扩容和数据安全都保障了
上面我们还提到Redis分片集群依然支持自动故障转移的功能,由于每一个主节点目前都有一个从节点,所以即使主节点挂了,那么从节点会被提升为主节点,从而实现自动故障转移
3、docker
(1)单机版
// 拉取镜像
docker pull redis:6.0.8
// 创建redis.conf所属的目录
mkdir /docker/reids/conf
// 将以下链接中的redis.conf文件复制到conf目录下
链接:https://pan.baidu.com/s/1OLPyYh0NcwmXlHIKhFr3dQ?pwd=oq8y
更改参数说明:1、开启密码验证,搜索“requirepass”可见;2、注释“bind 127.0.0.1”,允许外部连接;3、设置“daemonize no”,避免和docker run中-d参数冲突,导致容器启动失败;4、设置“appendonly yes ”,开启容器持久化
// 创建容器
docker run -d -p 6379:6379 --privileged=true --name="redis6.0.8" -v /docker/redis/conf/redis.conf:/etc/redis/redis.conf -v /docker/redis/data:/data redis:6.0.8 redis-server /etc/redis/redis.conf
说明:redis-server /etc/redis/redis.conf代表在redis-server启动的时候使用/etc/redis/redis.conf
// 进入容器
docker exec -it redis6.0.8 /bin/bash
4、k8s
(1)单机版
apiVersion: v1
kind: ConfigMap
metadata:
name: redis.conf
namespace: redis
data:
redis.conf: |-
protected-mode no
port 6379
tcp-backlog 511
timeout 0
tcp-keepalive 300
daemonize no
supervised no
pidfile /var/run/redis_6379.pid
loglevel notice
logfile ""
databases 16
always-show-logo yes
save 900 1
save 300 10
save 60 10000
stop-writes-on-bgsave-error yes
rdbcompression yes
rdbchecksum yes
dbfilename dump.rdb
dir ./
replica-serve-stale-data yes
replica-read-only yes
repl-diskless-sync no
repl-diskless-sync-delay 5
repl-disable-tcp-nodelay no
replica-priority 100
requirepass admin123456
lazyfree-lazy-eviction no
lazyfree-lazy-expire no
lazyfree-lazy-server-del no
replica-lazy-flush no
appendonly yes
appendfilename "appendonly.aof"
appendfsync everysec
no-appendfsync-on-rewrite no
auto-aof-rewrite-percentage 100
auto-aof-rewrite-min-size 64mb
aof-load-truncated yes
aof-use-rdb-preamble yes
lua-time-limit 5000
slowlog-log-slower-than 10000
slowlog-max-len 128
latency-monitor-threshold 0
notify-keyspace-events Ex
hash-max-ziplist-entries 512
hash-max-ziplist-value 64
list-max-ziplist-size -2
list-compress-depth 0
set-max-intset-entries 512
zset-max-ziplist-entries 128
zset-max-ziplist-value 64
hll-sparse-max-bytes 3000
stream-node-max-bytes 4096
stream-node-max-entries 100
activerehashing yes
client-output-buffer-limit normal 0 0 0
client-output-buffer-limit replica 256mb 64mb 60
client-output-buffer-limit pubsub 32mb 8mb 60
hz 10
dynamic-hz yes
aof-rewrite-incremental-fsync yes
rdb-save-incremental-fsync yes
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
labels:
app: redis
name: redis
namespace: redis
spec:
replicas: 1
selector:
matchLabels:
app: redis
serviceName: redis
template:
metadata:
labels:
app: redis
spec:
containers:
- command:
- redis-server
- /etc/redis/redis.conf
image: 'redis:6.2.6'
imagePullPolicy: IfNotPresent
name: redis
ports:
- containerPort: 6379
name: client
protocol: TCP
- containerPort: 16379
name: gossip
protocol: TCP
volumeMounts:
- mountPath: /data
name: redis-data
- mountPath: /etc/redis/
name: redis-conf
volumes:
- configMap:
name: redis.conf
name: redis-conf
volumeClaimTemplates:
- metadata:
name: redis-data
spec:
accessModes:
- ReadWriteMany
storageClassName: "managed-nfs-storage"
resources:
requests:
storage: 100Mi
---
apiVersion: v1
kind: Service
metadata:
labels:
app: redis
name: redis
namespace: redis
spec:
ports:
- name: client
port: 6379
protocol: TCP
targetPort: 6379
- name: gossip
port: 16379
protocol: TCP
targetPort: 16379
selector:
app: redis
type: NodePort
5、Redis连接工具
(1)RedisDesktopManager
点击RedisDesktopManager-Windows,然后找到合适版本,点击Downloads
按钮,如下:
然后下载resp-XXX.zip
即可,如下:
我给大家提供resp-2022.5.0.0.exe
安装包,如下:
链接:https://pan.baidu.com/s/1MpAVdolmV4z5WPWuDxtn0A?pwd=3eui
提取码:3eui
四、代码
链接:https://pan.baidu.com/s/1manMY7Rm3-ueELqdQR9S9g?pwd=qjyd
提取码:qjyd
五、文档
链接:https://pan.baidu.com/s/1Bt9x4QdmNJJATEPyZDUQ4Q?pwd=82se
提取码:82se
六、应用
1、存储session
可以查看我写的另外一篇博客 解决分布式系统中的session共享问题
2、存储token
生成token和验证token的整体流程如下:
- 用户访问首页
- 在gateway网关代码处发现用户没有携带token,或者携带token无效(无法在redis中通过该token获取到用户信息)
- 后端代码设置接口返回值状态码为401
- 前端代码将页面重定向到登录页面
- 用户输入用户名和密码进行登录
- 判断用户存在,并且密码能对上,就可以生成token(可以使用uuid),并且将用户信息存在redis中,其中key是token,value是用户信息;然后后端代码将token存储到cookie中
- 当登录成功之后,前端代码重定向到首页(即初次访问页面,不一定是首页哦),完成页面展示
对应流程图如下:
3、存储验证码
3.1、发送短信验证码
流程:
示例:
京东登录:
京东注册:
3.2、验证短信验证码
4、优惠卷秒杀
请看:9.4、更复杂的lua脚本代码
5、点赞 或者 取消点赞
5.1、实现逻辑
- 使用redis中的set或者zset集合来存储用户点赞id集合
- 通过redis中的set或者zset集合判断用户是否点赞过
5.2、代码实现1(B站黑马程序员Redis课程第80集)
代码来源:黑马程序员Redis课程
5.3、代码实现2(gitee-Echo)
详细代码来源:gitee-Echo
先看逻辑:
在看代码:
/**
* 点赞
* @param entityType
* @param entityId
* @param entityUserId 赞的帖子/评论的作者 id
* @param postId 帖子的 id (点赞了哪个帖子,点赞的评论属于哪个帖子,点赞的回复属于哪个帖子)
* @return
*/
@PostMapping("/like")
@ResponseBody
public String like(int entityType, int entityId, int entityUserId, int postId) {
User user = hostHolder.getUser();
// ================看这里:点赞================
likeService.like(user.getId(), entityType, entityId, entityUserId);
// ================看这里:点赞数量================
long likeCount = likeService.findEntityLikeCount(entityType, entityId);
// 点赞状态
int likeStatus = likeService.findEntityLikeStatus(user.getId(), entityType, entityId);
Map<String, Object> map = new HashMap<>();
map.put("likeCount", likeCount);
map.put("likeStatus", likeStatus);
// 触发点赞事件(系统通知) - 取消点赞不通知
if (likeStatus == 1) {
Event event = new Event()
.setTopic(TOPIC_LIKE)
.setUserId(hostHolder.getUser().getId())
.setEntityType(entityType)
.setEntityId(entityId)
.setEntityUserId(entityUserId)
.setData("postId", postId);
eventProducer.fireEvent(event);
}
if (entityType == ENTITY_TYPE_POST) {
// 计算帖子分数
String redisKey = RedisKeyUtil.getPostScoreKey();
redisTemplate.opsForSet().add(redisKey, postId);
}
return CommunityUtil.getJSONString(0, null, map);
}
/**
* 进入个人主页
* @param userId 可以进入任意用户的个人主页
* @param model
* @return
*/
@GetMapping("/profile/{userId}")
public String getProfilePage(@PathVariable("userId") int userId, Model model) {
User user = userService.findUserById(userId);
if (user == null) {
throw new RuntimeException("该用户不存在");
}
// 用户
model.addAttribute("user", user);
// ================看这里:获赞数量================
int userLikeCount = likeService.findUserLikeCount(userId);
model.addAttribute("userLikeCount", userLikeCount);
// 关注数量
long followeeCount = followService.findFolloweeCount(userId, ENTITY_TYPE_USER);
model.addAttribute("followeeCount", followeeCount);
// 粉丝数量
long followerCount = followService.findFollowerCount(ENTITY_TYPE_USER, userId);
model.addAttribute("followerCount", followerCount);
// 当前登录用户是否已关注该用户
boolean hasFollowed = false;
if (hostHolder.getUser() != null) {
hasFollowed = followService.hasFollowed(hostHolder.getUser().getId(), ENTITY_TYPE_USER, userId);
}
model.addAttribute("hasFollowed", hasFollowed);
model.addAttribute("tab", "profile"); // 该字段用于指示标签栏高亮
return "/site/profile";
}
// 点赞操作都在下面类中
@Service
public class LikeService {
@Autowired
private RedisTemplate redisTemplate;
/**
* 点赞
* @param userId 点赞的用户 id
* @param entityType
* @param entityId
* @param entityUserId 被赞的帖子/评论的作者 id
*/
public void like(int userId, int entityType, int entityId, int entityUserId) {
redisTemplate.execute(new SessionCallback() {
@Override
public Object execute(RedisOperations redisOperations) throws DataAccessException {
String entityLikeKey = RedisKeyUtil.getEntityLikeKey(entityType, entityId);
String userLikeKey = RedisKeyUtil.getUserLikeKey(entityUserId);
// 判断用户是否已经点过赞了
boolean isMember = redisOperations.opsForSet().isMember(entityLikeKey, userId);
redisOperations.multi(); // 开启事务
if (isMember) {
// 如果用户已经点过赞,点第二次则取消赞
redisOperations.opsForSet().remove(entityLikeKey, userId);
redisOperations.opsForValue().decrement(userLikeKey);
}
else {
redisTemplate.opsForSet().add(entityLikeKey, userId);
redisOperations.opsForValue().increment(userLikeKey);
}
return redisOperations.exec(); // 提交事务
}
});
}
/**
* 查询某实体被点赞的数量
* @param entityType
* @param entityId
* @return
*/
public long findEntityLikeCount(int entityType, int entityId) {
String entityLikeKey = RedisKeyUtil.getEntityLikeKey(entityType, entityId);
return redisTemplate.opsForSet().size(entityLikeKey);
}
/**
* 查询某个用户对某个实体的点赞状态(是否已赞)
* @param userId
* @param entityType
* @param entityId
* @return 1:已赞,0:未赞
*/
public int findEntityLikeStatus(int userId, int entityType, int entityId) {
String entityLikeKey = RedisKeyUtil.getEntityLikeKey(entityType, entityId);
return redisTemplate.opsForSet().isMember(entityLikeKey, userId) ? 1 : 0;
}
/**
* 查询某个用户获得赞数量
* @param userId
* @return
*/
public int findUserLikeCount(int userId) {
String userLikeKey = RedisKeyUtil.getUserLikeKey(userId);
Integer count = (Integer) redisTemplate.opsForValue().get(userLikeKey);
return count == null ? 0 : count;
}
}
6、点赞排行榜
6.1、实现逻辑
- 使用redis中的zset集合来存储用户点赞id集合,按照创建时间当做分数进行排序
- order by field(字段名称,字段值……):这种写法还是有点意思的,可以按照字段值对结果进行排序,讲解链接:https://blog.csdn.net/weixin_65846839/article/details
6.2、代码实现(B站黑马程序员Redis课程第80集)
代码来源:黑马程序员Redis课程
7、关注、取关、共同关注
7.1、实现逻辑
- 使用zset结构记录我关注的用户id,并且以创建时间排序
- 使用zset结构记录关注我的用户id,并且以创建时间排序
- 虽然下面代码中没提到共同关注,但是zset可以求交集,因此可以实现共同关注功能
7.2、代码实现(gitee-Echo)
代码来源:gitee-Echo
先看逻辑:
在看代码:
/**
* 关注(目前只做了关注用户)
*/
@Controller
public class FollowController implements CommunityConstant{
@Autowired
private FollowService followService;
@Autowired
private HostHolder hostHolder;
@Autowired
private UserService userService;
@Autowired
private EventProducer eventProducer;
/**
* 关注
* @param entityType
* @param entityId
* @return
*/
@PostMapping("/follow")
@ResponseBody
public String follow(int entityType, int entityId) {
User user = hostHolder.getUser();
followService.follow(user.getId(), entityType, entityId);
// 触发关注事件(系统通知)
Event event = new Event()
.setTopic(TOPIC_FOLLOW)
.setUserId(hostHolder.getUser().getId())
.setEntityType(entityType)
.setEntityId(entityId)
.setEntityUserId(entityId);
eventProducer.fireEvent(event);
return CommunityUtil.getJSONString(0, "已关注");
}
/**
* 取消关注
* @param entityType
* @param entityId
* @return
*/
@PostMapping("/unfollow")
@ResponseBody
public String unfollow(int entityType, int entityId) {
User user = hostHolder.getUser();
followService.unfollow(user.getId(), entityType, entityId);
return CommunityUtil.getJSONString(0, "已取消关注");
}
/**
* 某个用户的关注列表(人)
* @param userId
* @param page
* @param model
* @return
*/
@GetMapping("/followees/{userId}")
public String getFollowees(@PathVariable("userId") int userId, Page page, Model model) {
User user = userService.findUserById(userId);
if (user == null) {
throw new RuntimeException("该用户不存在");
}
model.addAttribute("user", user);
page.setLimit(5);
page.setPath("/followees/" + userId);
page.setRows((int) followService.findFolloweeCount(userId, ENTITY_TYPE_USER));
// 获取关注列表
List<Map<String, Object>> userList = followService.findFollowees(userId, page.getOffset(), page.getLimit());
if (userList != null) {
for (Map<String, Object> map : userList) {
User u = (User) map.get("user"); // 被关注的用户
map.put("hasFollowed", hasFollowed(u.getId())); // 判断当前登录用户是否已关注这个关注列表中的某个用户
}
}
model.addAttribute("users", userList);
return "/site/followee";
}
/**
* 某个用户的粉丝列表
* @param userId
* @param page
* @param model
* @return
*/
@GetMapping("/followers/{userId}")
public String getFollowers(@PathVariable("userId") int userId, Page page, Model model) {
User user = userService.findUserById(userId);
if (user == null) {
throw new RuntimeException("该用户不存在");
}
model.addAttribute("user", user);
page.setLimit(5);
page.setPath("/followers/" + userId);
page.setRows((int) followService.findFollowerCount(ENTITY_TYPE_USER, userId));
// 获取关注列表
List<Map<String, Object>> userList = followService.findFollowers(userId, page.getOffset(), page.getLimit());
if (userList != null) {
for (Map<String, Object> map : userList) {
User u = (User) map.get("user"); // 被关注的用户
map.put("hasFollowed", hasFollowed(u.getId())); // 判断当前登录用户是否已关注这个关注列表中的某个用户
}
}
model.addAttribute("users", userList);
return "/site/follower";
}
/**
* 判断当前登录用户是否已关注某个用户
* @param userId 某个用户
* @return
*/
private boolean hasFollowed(int userId) {
if (hostHolder.getUser() == null) {
return false;
}
return followService.hasFollowed(hostHolder.getUser().getId(), ENTITY_TYPE_USER, userId);
}
}
/**
* 关注相关
*/
@Service
public class FollowService implements CommunityConstant {
@Autowired
private RedisTemplate redisTemplate;
@Autowired
private UserService userService;
/**
* 关注
* @param userId
* @param entityType
* @param entityId
*/
public void follow(int userId, int entityType, int entityId) {
redisTemplate.execute(new SessionCallback() {
@Override
public Object execute(RedisOperations redisOperations) throws DataAccessException {
// 生成 Redis 的 key
String followeeKey = RedisKeyUtil.getFolloweeKey(userId, entityType);
String followerKey = RedisKeyUtil.getFollowerKey(entityType, entityId);
// 开启事务管理
redisOperations.multi();
// 插入数据
redisOperations.opsForZSet().add(followeeKey, entityId, System.currentTimeMillis());
redisOperations.opsForZSet().add(followerKey, userId, System.currentTimeMillis());
// 提交事务
return redisOperations.exec();
}
});
}
/**
* 取消关注
* @param userId
* @param entityType
* @param entityId
*/
public void unfollow(int userId, int entityType, int entityId) {
redisTemplate.execute(new SessionCallback() {
@Override
public Object execute(RedisOperations redisOperations) throws DataAccessException {
// 生成 Redis 的 key
String followeeKey = RedisKeyUtil.getFolloweeKey(userId, entityType);
String followerKey = RedisKeyUtil.getFollowerKey(entityType, entityId);
// 开启事务管理
redisOperations.multi();
// 删除数据
redisOperations.opsForZSet().remove(followeeKey, entityId);
redisOperations.opsForZSet().remove(followerKey, userId);
// 提交事务
return redisOperations.exec();
}
});
}
/**
* 查询某个用户关注的实体的数量
* @param userId 用户 id
* @param entityType 实体类型
* @return
*/
public long findFolloweeCount(int userId, int entityType) {
String followeeKey = RedisKeyUtil.getFolloweeKey(userId, entityType);
return redisTemplate.opsForZSet().zCard(followeeKey);
}
/**
* 查询某个实体的粉丝数量
* @param entityType
* @param entityId
* @return
*/
public long findFollowerCount(int entityType, int entityId) {
String followerKey = RedisKeyUtil.getFollowerKey(entityType, entityId);
return redisTemplate.opsForZSet().zCard(followerKey);
}
/**
* 判断当前用户是否已关注该实体
* @param userId
* @param entityType
* @param entityId
* @return
*/
public boolean hasFollowed(int userId, int entityType, int entityId) {
String followeeKey = RedisKeyUtil.getFolloweeKey(userId, entityType);
return redisTemplate.opsForZSet().score(followeeKey, entityId) != null ;
}
/**
* 分页查询某个用户关注的人(偷个懒,此处没有做对其他实体的关注)
* @param userId
* @param offset
* @param limit
* @return
*/
public List<Map<String, Object>> findFollowees(int userId, int offset, int limit) {
String followeeKey = RedisKeyUtil.getFolloweeKey(userId, ENTITY_TYPE_USER);
Set<Integer> targetIds = redisTemplate.opsForZSet().reverseRange(followeeKey, offset, offset + limit - 1);
if (targetIds == null) {
return null;
}
List<Map<String, Object>> list = new ArrayList<>();
for (Integer targetId : targetIds) {
Map<String, Object> map = new HashMap<>();
User user = userService.findUserById(targetId);
map.put("user", user);
Double score = redisTemplate.opsForZSet().score(followeeKey, targetId);
map.put("followTime", new Date(score.longValue()));
list.add(map);
}
return list;
}
/**
* 分页查询某个用户的粉丝(偷个懒,此处没有做对其他实体的粉丝)
* @param userId
* @param offset
* @param limit
* @return
*/
public List<Map<String, Object>> findFollowers(int userId, int offset, int limit) {
String followerKey = RedisKeyUtil.getFollowerKey(ENTITY_TYPE_USER, userId);
Set<Integer> targetIds = redisTemplate.opsForZSet().reverseRange(followerKey, offset, offset + limit - 1);
if (targetIds == null) {
return null;
}
List<Map<String, Object>> list = new ArrayList<>();
for (Integer targetId : targetIds) {
Map<String, Object> map = new HashMap<>();
User user = userService.findUserById(targetId);
map.put("user", user);
Double score = redisTemplate.opsForZSet().score(followerKey, targetId);
map.put("followTime", new Date(score.longValue()));
list.add(map);
}
return list;
}
}
8、用户签到
8.1、redis命令
8.2、使用bitmap实现用户签到
8.3、使用bitmap计算本月截止今天的用户连续签到次数
9、统计独立访客 UV 和 统计日活跃用户 DAU
9.1、UV和DAU的区别
DAU是日活跃用户数,通过用户ID排重统计数据。 UV是独立访客。通过用户IP排重统计数据。
9.2、UV和PU含义简介
9.3、HyperLogLog概念
直接在redis中测试命令使用,你会发现无论你接入多少重复元素,统计总数都是一样的,即使数据量非常大,误差也是很小的:
9.4、代码1(B站黑马程序员Redis课程第95集)
代码来源:黑马程序员
9.5、代码2(Gitee-Echo)
代码来源:Gitee-Echo
先看逻辑:
在看代码:
// 统计UV和DAU
@Component
public class DataInterceptor implements HandlerInterceptor {
@Autowired
private DataService dataService;
@Autowired
private HostHolder hostHolder;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 统计 UV
String ip = request.getRemoteHost();
dataService.recordUV(ip);
// 统计 DAU
User user = hostHolder.getUser();
if (user != null) {
dataService.recordDAU(user.getId());
}
return true;
}
}
// 具体统计实现代码,感觉DAU可以用HyperLogLog来做,用bitmap有点麻烦了
/**
* 网站数据统计(UV / DAU)
*/
@Service
public class DataService {
@Autowired
private RedisTemplate redisTemplate;
private SimpleDateFormat df = new SimpleDateFormat("yyyy-MM-dd");
/**
* 将指定的 IP 计入当天的 UV
* @param ip
*/
public void recordUV(String ip) {
String redisKey = RedisKeyUtil.getUVKey(df.format(new Date()));
redisTemplate.opsForHyperLogLog().add(redisKey, ip);
}
/**
* 统计指定日期范围内的 UV
* @param start
* @param end
* @return
*/
public long calculateUV(Date start, Date end) {
if (start == null || end == null) {
throw new IllegalArgumentException("参数不能为空");
}
// 整理该日期范围内的 key
List<String> keyList = new ArrayList<>();
Calendar calendar = Calendar.getInstance();
calendar.setTime(start);
while (!calendar.getTime().after(end)) {
String key = RedisKeyUtil.getUVKey(df.format(calendar.getTime()));
keyList.add(key);
calendar.add(Calendar.DATE, 1); // 加1天
}
// 合并这些天的 UV
String redisKey = RedisKeyUtil.getUVKey(df.format(start), df.format(end));
redisTemplate.opsForHyperLogLog().union(redisKey, keyList.toArray());
// 返回统计结果
return redisTemplate.opsForHyperLogLog().size(redisKey);
}
/**
* 将指定的 IP 计入当天的 DAU
* @param userId
*/
public void recordDAU(int userId) {
String redisKey = RedisKeyUtil.getDAUKey(df.format(new Date()));
redisTemplate.opsForValue().setBit(redisKey, userId, true);
}
/**
* 统计指定日期范围内的 DAU
* @param start
* @param end
* @return
*/
public long calculateDAU(Date start, Date end) {
if (start == null || end == null) {
throw new IllegalArgumentException("参数不能为空");
}
// 整理该日期范围内的 key
List<byte[]> keyList = new ArrayList<>();
Calendar calendar = Calendar.getInstance();
calendar.setTime(start);
while (!calendar.getTime().after(end)) {
String key = RedisKeyUtil.getDAUKey(df.format(calendar.getTime()));
keyList.add(key.getBytes());
calendar.add(Calendar.DATE, 1); // 加1天
}
// 进行 or 运算
return (long) redisTemplate.execute(new RedisCallback() {
@Override
public Object doInRedis(RedisConnection redisConnection) throws DataAccessException {
String redisKey = RedisKeyUtil.getDAUKey(df.format(start), df.format(end));
redisConnection.bitOp(RedisStringCommands.BitOperation.OR,
redisKey.getBytes(), keyList.toArray(new byte[0][0]));
return redisConnection.bitCount(redisKey.getBytes());
}
});
}
}
七、最佳实践
1、Redis键值设计
键:
键值:
2、BigKey问题
2.1、什么是BigKey
2.2、BigKey的危害
2.3、如何发现BigKey
2.3.1、判断标准
2.3.2、获取方式
2.3.3、使用scan指令获取BigKey(采用java代码+jedis连接方式)
pom.xml:
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
<version>3.1.0</version>
</dependency>
代码:
import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
import redis.clients.jedis.ScanResult;
import java.util.List;
public class Test {
public static void main(String[] args) {
JedisPool jedisPool = new JedisPool("127.0.0.1", 6379);
Jedis jedis = jedisPool.getResource();
// 判断标准
// 字符串类型:值大于10kb就是BigKey
int strMaxLen = 10 * 1024;
// 集合类型:元素数量大于1000个就是BigKey
int collectMaxLen = 1000;
int maxLen = 0;
long len = 0;
String cursor = "0";
do {
// 扫描并获取一部分key
ScanResult<String> result = jedis.scan(cursor);
// 记录cursor
cursor = result.getCursor();
List<String> list = result.getResult();
if (list == null || list.isEmpty()) {
break;
}
// 遍历
for (String key : list) {
// 判断key的类型
String type = jedis.type(key);
switch (type) {
case "string":
len = jedis.strlen(key);
maxLen = strMaxLen;
break;
case "hash":
len = jedis.hlen(key);
maxLen = collectMaxLen;
break;
case "list":
len = jedis.llen(key);
maxLen = collectMaxLen;
break;
case "set":
len = jedis.scard(key);
maxLen = collectMaxLen;
break;
case "zset":
len = jedis.zcard(key);
maxLen = collectMaxLen;
break;
default:
break;
}
if (len >= maxLen) {
System.out.printf("发现BigKey : %s, 类型: %s, 值长度 或者 集合大小: %d %n", key, type, len);
}
}
} while (!cursor.equals("0"));
}
}
2.4、删除BigKey
注意:
- 对于redis3.0以及以下版本,如果想遍历集合类型,也不建议直接获取所有元素,而是建议使用对应类型的scan命令,例如:
hscan
、sscan
、zscan
,之后逐次获取元素后进行删除操作 - 当然不能一删了之,肯定需要将BigKey转换成小一点的key,并且做好数据迁移
3、选择合适的数据类型
3.1、案例1(用户对象)
案例:
说明:
存储用户对象常见于用户中心系统中,当用户登录之后,需要把用户信息存储到redis中,便于用户认证,以及也会存储一些角色信息、权限信息
上图中黑马老师建议使用hash进行存储,不过我们公司目前使用String类型存储,我个人认为都是可以的,用hash存储可以单独更新用户名称、组织机构、角色、权限信息,而使用string存储需要先将值取出来,然后处理之后再进行覆盖操作
3.2、案例2(hash结构拆分)
案例:
说明:
拆分方式1:
使用string结构来存储这些hash数据,可以避免BigKey问题,但是string类型数据量会非常大,每一个string类型数据都需要占据额外的空间,所以最终占用的内存还是很多的
拆分方式2:
将id进行拆分后以100个元素为一个hash结构进行存储,这样可以避免BigKey的问题,并且能减少冗余空间使用;后续我们取值的时候,也可以使用这种方式进行拆分,然后直接去对应位置取值即可
4、批处理优化
4.1、针对单节点、哨兵模式(Jedis代码)
4.1.1、常规命令:mset、hmset
黑马老师的代码:
我自己编写的代码:
4.1.2、管道命令:set……
黑马老师的代码:
我自己编写的代码:
完整代码在代码目录下
4.2、针对分片集群模式(Jedis代码)
4.2.1、常规命令:mset、hmset
黑马老师的解释:
我的代码:
将同一个插槽的整合到一起,然后一起发送,这样就不会报错了,完整代码在代码目录下
4.3、针对单节点、哨兵模式、分片集群模式(spring-data-redis代码)
spring-data-redis
依赖底层已经完成对各种模式的适配,包括对分片集群的key分片后再发送操作,这些都在底层已经完成了
5、持久化配置
解释一下上面几条配置:
- 上1:如果条件允许,单独搞一台redis就来做缓存,分布式缓存不比本地缓存香多了,对微服务应该绝对是利器
- 上2:RDB一般用来做备份,毕竟很长时间才来备份一次,所以会存在丢失数据的风险,但是时间太短就备份对资源消耗太大,也是不建议的,但是做备份是再好不过的;而AOF一般会每秒刷新一次,并且即使出现问题也基本不会丢失数据
- 上3:说的就是RDB一般用来做备份的事情,所以AOF和RDB都需要,其中AOF保证数据丢失,而RDB做数据备份,并不是踩一捧一的意思
- 上4:在发展过程中AOF写入的命令可能有些就不需要了,比如我先插入一条数据,然后在删除这条数据,其实这两条命令直接消消乐了,因此需要使用bgrewrite来进行命令消消乐,但是阈值得设置好,避免很快出现AOF的情况
- 上5:在一般情况下,aof每隔1s会进行一次命令保存,保存过程叫做fsync
如果耗时超过2s,主进程将进行阻塞,其中在aof的rewrite期间就可能会出现主线程阻塞情况
为了避免出现主进程阻塞情况,所以我们可以设置aof的rewrite期间不进行fsync操作,也就是将下面参数设置成yes
6、慢查询
6.1、概念
6.2、设置慢查询时间阈值、慢查询存储队列长度
- 一般情况下,单个命令可以在50微秒执行完成,所以设置成1000微秒是可以发现慢查询命令的
- 慢查询记录将会倍存储到慢查询命令队列中,我们可以通过以下命令来查看或者设置慢查询队列的长度
6.3、查看慢查询命令
7、命令及安全配置
7.1、演示安全事故
我们可以将ssh公钥放到/root/.ssh/authorized_keys
文件中,那我们就可以在windows机器上使用ssh命令直接登录服务器
如果redis没有设置密码,并且允许任何ip进行登录,那我们可以直接登录redis,之后通过config命令将redis数据存储目录改变成/root/.ssh/
,并且将rdb文件名称变成authorized_keys
,然后将公钥存储到该文件中,这样相当于我们已经将公钥放到了服务器上,此时我们就可以直接免密登录服务器了
7.2、避免安全问题
解释一下上面几条建议:
-
建议1:设置密码在安装redis的时候已经说过了
-
建议2:可以在redis配置文件中进行禁止设置,支持两种禁止方式,方式1是“直接禁止使用”,方式2是“将命令名称改成其他不太好记忆的名称,并且禁用原命令名称”
-
建议5:如果我们使用root账户启动redis,那么就可以通过config命令将redis数据存储目录改成
/root/.ssh/
,如果我们使用其他账户登录redis,其实就不支持上述操作了 -
建议6:默认情况下redis使用6379端口,但是这个端口容易收到攻击,所以不建议使用该端口
8、内存安全和配置
内存划分:
常用命令:
执行memory stats
命令如下:
常见内存缓冲区:
9、选择哨兵模式还是分片模式集群
一般情况下,单机Redis就已经够用了,但是为了安全起见,可以使用哨兵模式集群,尽量不要选择分片模式集群,毕竟搭建、维护等问题比较多
分片模式集群的弊端:
八、Redis基础数据结构
1、动态字符串SDS
1.1、Redis既然使用C语言编写,但是为什么不直接使用C语言的字符串?
- 获取字符串长度需要运算:C语言没有存储字符串长度的地方
- 非二进制安全:在C语言中,字符串默认以
\0
作为结尾符,所以要求字符串中不能包含\0
- 不可修改:不能被修改
1.2、结构
类名解释:
支持多种类型,分别是sdshdr5
、sdshdr8
、sdshdr16
、sdshdr32
、sdshdr64
,然后每一种动态字符串类型的最大长度都有限制,默认情况下可以容纳的字节数是2的n次方(n就是类型后面的数字)
当然我们还可以使用类中的flags字段进行区分,其中sdshdr5
、sdshdr8
、sdshdr16
、sdshdr32
、sdshdr64
分别对应flags字段的值是0
、1
、2
、3
、4
字段解释:
- len:直接存储字符串长度,所以获取字符串长度不需要计算了
- alloc:当前字符串类型能容纳的总字符串长度,不会按照字符串类型一次性分配完,会按照扩容要求进行分配
- flags:标识不同的字符串类型,不同字符串类型可以容纳的字符串长度也是不同的,要划分多种字符串类型的原因是避免多分配空间
\0
解释:
- C语言的字符串默认以
\0
结尾,所以继续沿袭C语言的方式
1.2、扩容机制
扩容方式解释:
- 动态字符串类型:有sdshdr5、sdshdr8、sdshdr16、sdshdr32、sdshdr64这几种,然后每一种动态字符串类型的最大长度都有限制,默认情况下可以容纳的字节数是2的n次方(n就是类型后面的数字)
- len:记录实际字符串长度
- alloc
- 1、扩容之后没有达到该字符串类型最大要求,那就继续使用这种类型,然后按照扩容要求对该值扩大
- 2、扩容之后达到该字符串类型最大要求,那就更换字符串类型
1.3、优点
解释:
- 获取字符串长度的时间复杂度O(1):在SDS结构中,以及使用len字段记录了字符串长度
- 支持动态扩容:支持多种字符串类型
- 减少内存分配次数:拥有良好的扩容机制,尽量减少内存分配次数
- 二进制安全:C语言中字符串默认以
\0
结尾,在读取字符串的时候以\0
作为结束符,所以强制要求字符串中不能包含\0
,而SDS动态字符串以len属性值来截取对应长度字符串,而不是以\0
作为结束符,所以字符串中间可以包含\0
,这其实是属于数据结构的范畴
2、IntSet
2.1、结构
解释:
- 编码方式:支持2、4、8字节的整数范围
- 数据某下标值获取方式:开始下标 + (单个节点长度 * 目标下标)
2.2、扩容方式
解释:
- 扩容方式:
- 新数据占用字节不少过当前编码方式字节范围:根据二分查找后找到合适位置,然后进行数组扩容,之后将合适位置后面一个数据往后移动一位,之后将新数据插入到合适为止即可
- 新数据占用字节超过当前编码方式字节范围:按照上图进行扩容操作,首先升级编码方式,然后倒叙拷贝原有数据(倒序目的:依然在原有物理空间上扩容,避免覆盖原有数据)
2.3、优点
2.4、扩容代码解释
我们来看intsetAdd方法,如下:
现在来看intsetUpgradeAndAdd方法,如下:
继续回到intsetAdd方法,如下:
我们看一下intsetSearch方法:
3、Dict
3.1、结构
我们知道Redis作为一个键值型数据库,我们可以根据键实现快速的增删改查,而键与值的映射关系正是通过Dict来实现的。
看一下关系图,将会更加清晰,其中dictht不用来存储具体数据,只是存储相关引用信息。
3.2、扩容方式
扩容:
收缩:
3.3、rehash
rehash概念:
渐进式rehash概念:
rehash完成结果图:
3.4、总结
3.5、扩容、收缩、rehash代码解释
我们看一下_dictNextPower
方法:
4、ZipList
4.1、ZipList结构
结构组成详细解释:
解释:
- zltail作用:可以很快确定尾节点entry的地址
- zllen作用:可以很快得出节点数量,不用再挨个计算了
4.2、ZipListEntity结构
在讲解ZipList结构中,entry只是作为一个小的组成部分,但是它里面的结构也是很复杂的,所以我们把entry单独拿出来讲解
解释:
- 从前往后遍历:对于previous_entry_length来说,要么是1个字节,要么是5个字节,然后encoding中记录数据类型和数据长度,这样一个entry就可以遍历完成了
- 从后往前遍历:后面节点记录这前面节点的长度,也就是previous_entry_length字段,这样就可以找到前面整个entry了,之后便可以从后往前依次遍历所有entry
4.3、Encoding编码结构
上面提到了entry的结构,其中encoding结构也是比较复杂的,下面将详细探讨
4.3.1、字符串数据
注意: 图中的tlbytes、tltail、tllen首字母写错了,应该是z
才对
4.3.2、整数数据
注意: 当编码方式确定的时候整数类型就确定了,对于最后一种类型,需要后面四位在0001~1101
之间,毕竟0000和1110都已经被其他encoding占用了,我们只能占用该范围的,这个范围的10禁止是1~13
,然后减1之后就是0~12
4.4、ZipList的Entry的连锁更新问题
说明: 如果存在连续的节点长度都在250~253
之间,当在这些节点前面新增一个大于等于254字节的entry,那么后面所有的节点长度以及记录的前一节点长度都需要跟着改变,并且这种情况目前无法改善,但是很少出现,当然删除也会有影响,比如存在连续的节点长度都是大于等于254的情况,然后删除掉其中一个节点,那么后面很多节点都要改变长度以及记录的前一节点长度
4.5、特性
5、QuickList
5.1、结构
上面已经讲述了ZipList数据结构,这种数据类型有优点,也有缺点,所以我们在介绍另外一种数据结构—QuickList,它兼容了ZipList的优点,并且也弥补了ZipList的缺点
详细看一下数据结构:
画图表示是这样的:
在quickList数据结构中,有一个fill字段,默认值是-2,其实代表每个ZipList的内存占用不能超过8kb,详细解释如下:
然后在quickList数据结构中有一个compress
字段,默认值是0,其实代表每个ZipList首尾压缩的数量,详细解释如下:
5.2、特性
6、SkipList
6.1、结构
解释:
- 元素按照升序排列其实并不是真正的元素值,而是元素节点的score分数,可以通过下面zskiplistNode结构来看
- 同一个节点上可能存在多个指针,所以下面zskiplistNode结构中level就是一个数组,代表可以容纳多个指针元素
大家看一下
比较完整的结构画图表示如下:
6.2、特性
7、RedisObject
7.1、结构
7.2、所有编码方式
7.3、不同数据类型对应的编码方式
7.4、总结
对于String
、List
、Hash
、Set
、Zset
等数据类型,最终都是通过RedisObject
来表示的,然后RedisObject
是以上面那几种数据结构组成的。
所以组成关系如下:String
、List
、Hash
、Set
、Zset
》 RedisObject
》 SDS
、IntSet
、Dict
、ZipList
、QuickList
、SkipList
、RedisObject
九、Redis数据类型组成关系
1、String
- 如果数据是字符串:
- 长度小于44字节:采用
EMBSTR
编码(简单动态字符串SDS的一种编码方式),此时RedisObject和EMBSTR是连续空间,申请内存时只需要调用一次内存分配函数,然后效率更高。Redis底层内存 以 2n 次方来分配内存,然后这种方式的所有内存长度加起来是64位,正好符合要求,这样不会浪费内存空间 - 长度大于等于44字节:采用
RAW
编码(简单动态字符串SDS的一种编码方式),存储上限是512Mb
,需要在RedisObject对象的ptr参数位置通过指针方式指向真正数据
- 长度小于44字节:采用
- 如果数据是整数:采用
INT
编码(不是简单动态字符串SDS),由于数据大小在LONG_MAX范围中,所以将数据直接保存在RedisObject对象的ptr参数位置即可,这样也可以节省内存
上述各种数据类型对应的画图表示方式如下:
2、List
List的结构画图表示如下:
3、Set
- 如果数据都是整数:
- 当元素数量不超过
set-max-intset-entries
返回值时,采用IntSet
编码方式(可以在redis客户端中通过config get set-max-intset-entries
命令获取具体值) - 当元素数量超过
set-max-intset-entries
返回值时(逐渐增加,直到超过,没超过之前依然使用IntSet
编码方式),需要将IntSet
编码方式转换成Dict
编码方式(可以在redis客户端中通过config get set-max-intset-entries
命令获取具体指)
- 当元素数量不超过
- 如果数据很普通,即不完全是整数:使用
Dict
编码方式,其中key是值,而value是null
画图表示如下:
采用IntSet
结构:
采用Dict
结构:
4、ZSet
首先说一下ZSet的要求:
现在可以来说明组成结构了
- 如果ZipList中的元素数量(包括score和value)小于
zset-max_ziplist_entries
(默认值是128,可以在redis客户端中通过config get zset-max_ziplist_entries
命令获取具体值),并且每个元素都小于zset-max_ziplist_value
字节(默认值是64,可以在redis客户端中通过config get zset-max_ziplist_value
命令获取具体值):采用ZipList结构
,大家都知道ZipList是一个集合,所以我们让score和value存储到紧挨着的2个entry
中,其中value
值在前面,而score
在后,并且按照score进行升序排序哦!如果想满足上面提出的要求,通过遍历即可 - 如果
zset-max_ziplist_entries
大于0,但是不符合上面条件的时候:需要将ZipList
结构转换成Dict+SkipList
结构,针对上面ZSet的要求,通过SkipList满足按照score排序要求,然后通过Dict以member当做key,可以实现member唯一,以及通过member查询分数的要求 - 如果
zset-max_ziplist_entries
等于0 :直接使用Dict+SkipList
结构
画图表示如下:
采用ZipList结构:
采用Dict+SkipList结构:
5、Hash
- 如果ZipList中的元素数量(包括score和value)小于
hash-max_ziplist_entries
(默认值是512,可以在redis客户端中通过config get hash-max_ziplist_entries
命令获取具体值),并且每个元素都小于hash-max_ziplist_value
字节(默认值是64,可以在redis客户端中通过config get hash-max_ziplist_value
命令获取具体值):采用ZipList结构
,大家都知道ZipList是一个集合,所以我们让key和value存储到紧挨着的2个entry
中,其中key
在前面,而value
在后 - 如果不符合上面条件的时候:需要将
ZipList
结构转换成Dict
结构
画图表示如下:
采用ZipList结构:
采用Dict结构:
十、Redis网络模型
1、用户空间和内核空间
总体分析:
命令调用分析:
数据传输分析:
五种IO模型:
综上所述:
时间主要花费在等待数据就绪
和在内核空间以及用户空间缓冲区来回拷贝数据
上,所以在后续分析IO的时候,可以着重从这两点进行优化效率
2、阻塞IO
3、非阻塞IO
4、IO多路复用
4.1、产生原因
其实无论阻塞IO或者非阻塞IO,其实在等待阶段的表现都不是很好,所以如果在处理socket请求的时候,有一些socket就绪,但是当前正在处理的没有就绪,那程序就卡死了,这并不是我们想要的效果
4.2、原理
使用select命令监听多个socket连接,当socket可读就绪时,然后告诉用户应用,之后用户应用根据返回的就绪FD信息,然后调用recvfrom命令进行数据读取,可以解决数据等待就绪耗费时间的问题
4.3、select模式
4.3.1、代码分析
分析:
- 先来解释下fd:每一个文件都对应一个fd数字值,所以fd就是文件的代表,由于linux中一切皆文件,所以socket连接也是文件,也对应一个fd
- 在看select函数
- nfds:最大fd+1,其实最大fd代表需要被监听的最大fd
- readfds:读事件的fd集合
- timeout:等待事件达成超时时间
- null:一直等待,直到有fd就绪
- 0:只遍历一遍fd集合,无论是否存在相关事件,都需要返回满足事件要求的全部数量
- 大于0:最多等待固定时间,期间遍历fd集合之后如果存在相关事件,也是需要立即返回事件要求的全部数量
- 然后解释下fd_set:在select结构中的多个字段类型都是fd_set,比如readfds(读事件fd集合),根据计算结果显示,fd_set一共可以容纳1024个bit位,然后最大fd是1024,这是个很大的局限,也是select模式退出历史舞台的决定原因
- 最后解释下__fd_mask:它是fd_set结构中的组成部分,虽然它是数值类型,但其实fd_set中使用的是字节来存储事件就绪状态,而不是数值
4.3.2、流程分析
分析:
- 流程写的已经很详细了,
只说一下fd_set的变化情况
,在用户空间中我们需要监听fd是1、2、5的情况,那么在用户空间中会将对应位的值设置成1,(最右侧是位置1),然后会将fd_set拷贝到内核空间;在内核空间
遍历过程中,如果就绪就设置对应位的值为1,否则设置为0,由于只有fd为1的情况才是读就绪,所以只有位置1是1,其他都是0;最后将fd_set从内核空间拷贝到用户空间,并覆盖原来位置的fd_set数据 - 再说下select函数,当完成上面提到的覆盖操作之后,
select函数会收到符合监听状态的事件数量
- 如果select函数返回结果大于0,那么将会逐个遍历fd_set集合中数据,然后找到状态为1的fd,然后在
调用recvform函数进行数据读取
4.3.3、存在问题
4.4、poll模式
4.4.1、代码分析
分析:
- 先看下poll函数,从结构上来说,它和select函数有相似之处,只是它把需要关注的时间隐藏到了pollfd函数中
- 再看下pollfd函数,从结构上来说,它包含了fd和监听事件信息,由于不受数组长度限制,这样已经从结构上摆脱了监听fd数量的限制,比select模式最多监听1024个fd强多了
4.4.2、流程分析
可以类比select模式流程来理解,基本是完全一样的流程,只是监听事件模式存储位置不一样了
4.4.3、存在问题
4.5、epoll模式
4.5.1、代码和流程分析
分析:
- 第2步和第3步中的epoll_ctl和epoll_wati函数中的eptd参数就是第1步epoll_create函数返回值,这样做可以让每次添加的fd都挂到内核空间的eventpoll结构上
- 第1步epoll_create函数已经在内核空间中创建了eventpoll结构,包括结构中的rb_root和list_head
- 第2步epoll_ctl函数已经将需要监听的fd信息添加到了内核空间eventpoll结构中的rb_root红黑树上
- 第3步epoll_wait函数将会得到就绪的fd数量
- 内核空间进行的过程:当创建eventpoll结构之后,就可以添加需要监听的fd了,fd会被放到rb_boot红黑树中,每次调用epoll_wait的时候,我们会遍历rb_root红黑树,如果存在就绪事件,那就调用fd中的回调函数在list_head链表中添加对应就绪fd信息;当需要返回epoll_wait函数的事件就绪结果的时候,同时会将内核空间中list_head数据拷贝到用户空间的events结构中;如果epoll_wait函数的事件就绪结果大于0,那么将通过events取值,然后在
调用recvform函数进行数据读取数据
4.5.2、相比select和poll模式,epoll模式解决的问题?
4.5.3、事件通知模式
分析:
- LT:将数据从内核空间的list_head拷贝到用户空间的event之后,如果数据没有读取完成,那么剩余数据会被再次挂到内核空间的list_head上
- ET:将数据从内核空间的list_head拷贝到用户空间的event之后,内核空间的list_head上的数据会被立即销毁
4.5.4、基于epoll模式的web服务基本流程
5、信号驱动IO
6、异步IO
缺点: 将所有压力都给到了内核空间,这样对系统不好
7、同步和异步IO划分方式
- 综上所述:只有异步IO的第二阶段是由内核空间完成了将数据复制到用户空间的过程,其他都是用户空间自己完成的,所以其他IO都是同步IO
8、Redis是单线程还是多线程呢?
8.1、Redis到底是单线程还是多线程
8.2、Redis为什么选择单线程
9、Redis网络模式
9.1、分析Redis网络模式
过程分析:
-
main方法启动的时候,会执行initServer()函数
-
之后执行aeCreateEventLoop()函数,类似于epoll_create()函数,作用是在内核空间创建rb_root(红黑树结构,记录监听的FD)和list_head(链表结构,记录就绪FD)
-
然后说listenToPort()函数,参数中的端口默认是6379,ip默认是127.0.0.1
-
然后说createSocketAcceptHandler()函数,类似于epoll_ctl()函数,作用是将服务端socket的fd注册到rb_root中,并且对应的回调函数是acceptTcpHandler()函数,也就是图中的连接应答处理器(tcpAccepthandler)
-
继续看aeMain()函数,里面while循环可以监听list_head(链表结构,记录就绪FD)
-
我们直接看到aeProcessEvents()函数,里面有一个beforesleep()函数,这个作用我们最后再说
-
接着看aeApiPoll()函数,类似于epoll_wait()函数,作用是判断list_head是否为空
-
假设list_head不为空,那就需要对就绪fd进行处理,这就是for循环里面需要做的事情了,接下来来看连接应答服务器中做的事情,也就是socket服务端处理器
-
-
接下来来看连接应答服务器中做的事情,也就是socket服务端处理器,for循环中结构体就是下图代码,首先获取socket服务端fd,然后接受客户端socket的fd,并将客户端socket的fd注册到rb_root(红黑树结构,记录监听的FD)
-
对应的回调函数是readQueryFromClient()函数,也就是图中的命令请求处理器(readQueryFromClient)
-
-
如果此时socket客户端就绪,那就需要执行命令请求处理器(readQueryFromClient)函数,做的事情就是将读取到的数据写入querybuf缓冲区
-
然后再从querybuf缓冲区中读出redis命令
-
最后使用processCommand()函数执行redis命令
-
然后在通过addReply()函数将执行结果写入client的buf或者reply
-
之后将client放到clients_pending_write队列中
-
-
之前我们看过main()》aeMain()》aeProcessEvents()函数,里面有一个前置处理器beforesleep()函数
-
上面提到所有给socket客户端回复的结果都放在server.clients_pending_write队列中
-
beforeSleep函数可以读取该队列
-
然后把client和sendReplyToClient回调函数绑定在一起,sendReplyToClient回调函数绑定的是socket客户端写事件
-
当写事件发生时,将会调用命令回复处理器sendReplyToClient函数,进而将server.clients_pending_write队列结果返回给socket客户端
-
9.2、Redis6之后哪些地方会使用多线程呢?
- IO多路复用+事件派发:速度很快,不浪费时间
- 处理客户端socket发送的数据:IO操作,很浪费时间,即命令请求处理器
- 将执行结果发送给客户端socket:IO操作,很浪费时间,即命令回复处理器
9.3、Redis中命令处理部分是单线程吗?
是的,到目前位置的redis版本,其中命令处理部分都是单线程
十一、Redis通信协议
其中常见数据类型如下:
十二、Redis内存回收
1、内存过期策略
1.1、总结
1.2、惰性删除(惰性清理)策略
1.3、周期删除(定期清理)策略
注意: 其中initServer和aeMain函数都是上方main()方法中调用的函数
1.4、Redis的键值对数据结构
上图数据结构画图表示如下:
1.5、问题思考
- 1、Redis是如何知道一个key是否过期呢?
- 答:利用两个Dict,分别记录key和value,以及key和ttl
- 2、是不是ttl到期就立即删除了呢?
- 答:不是,有两种过期删除策略,分别是惰性删除和周期删除策略,它们是共同起作用的
2、内存淘汰策略
2.1、为什么要做内存淘汰?
2.2、如何设置Redis最大内存?
1、在启动Redis服务器时,可以使用–maxmemory 选项来设置最大内存。例如,要将最大内存设置为1GB,可以使用以下命令启动Redis服务器:
redis-server --maxmemory 1gb
或者,在Redis配置文件redis.conf中添加以下行:
maxmemory 1gb
重启Redis服务器后,最大内存设置将生效。
2、在Redis运行时,可以使用CONFIG SET命令动态地设置最大内存。例如,要将最大内存设置为1GB,可以使用以下命令:
redis-cli config set maxmemory 1gb
这将立即生效,无需重启Redis服务器。
在设置最大内存时,可以使用单位来指定内存大小。常用的单位有b(字节)、k(千字节)、m(兆字节)和g(吉字节)。例如,1gb表示1GB,500mb表示500MB。如果不指定单位,默认为字节。
需要注意的是,当Redis达到最大内存限制时,会触发内存淘汰策略。可以通过maxmemory-policy配置项来指定淘汰策略,默认为noeviction(不淘汰,直接返回错误)。常用的淘汰策略有volatile-lru(淘汰设置了过期时间的键,使用LRU算法)和allkeys-lru(淘汰所有键,使用LRU算法)。可以在Redis配置文件中设置该配置项,或者在运行时使用CONFIG SET命令进行动态设置。
2.3、内存淘汰策略
2.4、LRU和LFU计算方式
2.5、淘汰策略画图表示
流程解释:
- 首先判断内存是否充足,如果充足就不需要内存淘汰了,直接进行正常流程即可
- 如果内存不足,那就看内存淘汰策略是否是不驱逐,如果是,那就直接给客户端返回报错信息
- 如果内存不足,并且不属于驱逐淘汰策略,那就需要看下淘汰策略是针对全部数据还是设置了ttl的数据
- 然后在判断内存策略,如果是随机进行淘汰,那就在db库(16个库)中随机删除一个key
- 如果不是随机淘汰,那就准备一个eviction_pool池子,然后找一个db库(16个库)随机挑选一些key,然后根据淘汰策略算出idleTime值
- 然后按照idleTime升序方式存入eviction_pool池子,然后遍历所有db库(16个库),然后在找出一个key放入eviction_pool池子
- 最终从eviction_pool池子中找出最大idleTime值的key,然后进行删除即可
- 之后再判断内存是否ok,如果不ok,那就继续查找并删除key,如果ok,那就可以执行其他流程