Redis
Redis
Redis是一个key-value的数据库,key一般是String类型,不过value的类型多种多样:String、Hash、List、Set、SortedSet
String类型
String类型,也就是字符串类型,是Redis中最简单的存储类型。底层是字节数组形式存储,只不过是编码方式不同。字符串类型的最大空间不能超过512M。value是字符串,不过根据字符串的格式不同,又可以分为:
- string:普通字符串
- int:整数类型,可以自增、自减操作
- float:浮点类型,可以做自增、自减操作
String类型的常用命令
- SET:添加或者修改已经存在的一个String类型的键值对
- GET:根据key获取String类型的value
- MSET:批量添加多个String类型的键值对
- MGET:根据多个key获取多个String类型的value
- INCR:让一个整型的key自增1
- INCRBY:让一个整型的key自增并指定步长
- INCRBYFLOAT:让一个浮点类型的数字自增并指定步长
- SETNX:添加一个String类型的键值对,前提是这个key不存在,否则不执行
- SETEX:添加一个String类型的键值对,并且指定有效期
Hash类型
Hash类型,也叫散列,其value是一个无序字典,类似于java中的HashMap结构。
String结构是将对象序列化为json字符串后存储,当需要修改对象某个字段时很不方便。
Hash结构可以将对象中的每个字段独立存储,可以针对单个字段做CRUD。
Hash类型的常用命令
- HSET key field value:添加或者修改hash类型key的field的值
- HGET key field:获取一个hash类型key的field的值
- HMSET:批量添加多个hash类型key的field的值
- HMGET:批量获取多个hash类型key的field的值
- HGETALL:获取一个hash类型的key中的所有的field和value
- HKEYS:获取一个hash类型的key中的所有的field
- HVALS:获取一个hash类型的key中的所有的value
- HINCRBY:让一个hash类型key的字段值自增并指定步长
- HSETNX:添加一个hash类型的key的field值,前提是这个field不存在,否则不执行
List类型
Redis中的List类型与java中的LinkedList类似,可以看做是一个双向链表结构。既可以支持正向检索和也可以支持反向检索。特征也与LinkedList类似:
- 有序
- 元素可以重复
- 插入和删除快
- 查询速度一般
List类型的常用命令
- LPUSH key element …:向列表左侧插入一个或多个元素
- LPOP key:移除并返回列表左侧的第一个元素,没有则返回nil
- RPUSH key element …:向列表右侧插入一个或多个元素
- RPOP key:移除并返回列表右侧的第一个元素
- LRANGE key star end:返回一段角标范围内的所有元素
- BLPOP和BRPOP:与LPOP和RPOP类似,只不过在没有元素时等待指定时间,而不是直接返回nil
Set类型
Redis的Set结构与java中的HashSet类似,可以看做是一个value为null的HashMap。因为也是一个hash表,因此具备与HashSet类似的特征:
- 无序
- 元素不可重复
- 查找快
- 支持交集、并集、差集等功能
Set类型的常用命令
- 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的并集
SortedSet类型
Redis的SortedSet是一个可排序的set集合,与java中的TreeSet有些类似,但底层数据结构却差别很大。SortedSet中的每一个元素都带有一个score属性,可以基于score属性对元素排序,底层的实现是一个跳表(SkipList)加hash表。经常被用来实现排行榜这样的功能。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:让scored set中的指定元素自增,步长为指定的increment值
- ZRANGE key min max:按照score排序后,获取指定排名范围内的元素
- ZRANGEBYSCORE key min max:按照score排序后,获取指定score范围内的元素
- ZDIFF、ZINTER、ZUNION:求差集、交集、并集
Redis序列化
public RedisTemplate<String, Objec> 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
}
缓存更新策略
- 低一致性需求:使用Redis自带的内存淘汰机制
- 高一致性需求:主动更新,并以超时剔除作为兜底方案
- 读操作:
- 缓存命中则直接返回
- 缓存未命中则查询数据库,并写入缓存,设定超时时间
- 写操作:
- 先写数据库,然后再删除缓存
- 要确保数据库与缓存操作的原子性
- 读操作:
缓存穿透
缓存穿透是指客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远不会生效,这些请求都会打到数据库。常见的解决方案有两种:
-
缓存空对象
- 优点:实现简单,维护方便
- 缺点:额外的内存消耗;可能造成短期的不一致
-
布隆过滤
- 优点:内存占用较少,没有多余key
- 缺点:实现复杂;存在误判可能
-
增强id的复杂度,避免被猜测id规律
-
做好数据的基础格式校验
-
加强用户权限校验
-
做好热点参数的限流
缓存雪崩
缓存雪崩是指在同一时段大量的缓存key同时失效或者Redis服务宕机,导致大量请求到达数据库,带来巨大压力。解决方案:
- 给不同的key的TTL添加随机值
- 利用Redis集群提高服务的可用性
- 给缓存业务添加降级限流策略
- 给业务添加多级缓存
缓存击穿
缓存击穿也叫热点key问题,就是一个被高并发访问并且缓存重建业务较复杂的key突然失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击。常见的解决方案:
-
互斥锁
-
逻辑过期
优点 缺点 互斥锁 没有额外的内存消耗;保证一致性;实现简单 线程需要等待,性能受影响;可能有死锁风险 逻辑过期 线程无需等待,性能较好 不保证一致性;有额外内存消耗;实现复杂
超卖问题
超卖问题是典型的多线程安全问题,针对这一问题的常见解决方案就是加锁。
- 悲观锁
认为线程安全问题一定会发生,因此在操作数据之前先获取锁,确保线程串行执行。例如synchronized、lock都属于悲观锁
- 优点:简单粗暴
- 缺点:性能一般
- 乐观锁
认为线程安全问题不一定会发生,因此不加锁,只是在更新数据时去判断有没有其它线程对数据做了修改
(1)如果没有修改则认为是安全的,自己才更新数据
(2)如果已经被其它线程修改说明发生了安全问题,此时可以重试或异常
- 优点:性能好
- 缺点:存在成功率低的问题
// 获取代理对象事务
父类 对象 = (子类)AopContext.currentProxy();
return 对象.当前对象的方法 // 比如this.方法
lua脚本
lua脚本的写法
-- 比较线程标示与锁中的标志是否一致
if (redis.call('get', KEYS[1]) == ARGV[1]) then
-- 释放锁 del key
return redis.call('del', KEYS[1])
end
return 0
Java的代码调用
<dependency>
<groupId>org.aspectj</groupId>
<artifactId>aspectjweaver</artifactId>
</dependency>
private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;
static {
UNLOCK_SCRIPT = new DefaultRedisScript<>();
UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
UNLOCK_SCRIPT.setResultType(Long.class);
}
strigRedisTemplate.execute(UNLOCK_SCRIPT, 脚本的参数)
基于Redis的分布式锁优化
基于setnx实现的分布式锁存在的问题:
- 不可重入
同一个线程不无法多次获取同一把锁 - 不可重试
获取锁只尝试一次就返回false,没有重试机制 - 超时释放
锁超时释放虽然可以避免死锁,但如果是业务执行耗时较长,也会导致锁释放,存在安全隐患 - 主从一致性
如果Redis提供了主从集群,主从同步存在延迟,当主宕机时,如果从并同步主中的锁数据,则会出现锁实现
Redisson
Redisson是一个Redis的基础上实现的Java驻内存数据网络。它不仅提供了一系列的分布式的Java常用对象,还提供了许多分布式服务,其中就包含了各种分布式锁的实现。
- 可重入锁
- 公平锁
- 联锁
- 红锁
- 读写锁
- 信号量
- 可过期性信号量
- 闭锁
Redisson入门
- 引入依赖
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.13.6</version>
</dependency>
- 配置Redisson客户端
@Configuration
public class RedisConfig {
@Bean
public RedissonClient redissonClient() {
// 配置类
Config config = new Config();
// 添加redis地址,这里添加了单点的地址,也可以使用config.useClusterServers()添加集群地址
config.useSingleServer().setAddress("redis://192.168.159.101:6379").setPassword("123321");
// 创建客户端
return Redisson.create(config);
}
}
- 使用Redisson的分布式锁
@Resource
private RedissonClient redissonClient;
@Test
void testRedisson() throws InterruptedException {
// 获取锁(可重入),指定锁的名称
RLock lock = redissonClient.getLock("anyLock");
// 尝试获取锁,参数分别是:获取锁的最大等待时间(期间会重试),锁自动释放时间,时间单位
boolean isLock = lock.tryLock(1, 10, TimeUnit.SECONDS);
// 判断释放获取成功
if (isLock) {
try {
System.out.println("执行业务");
}finally {
// 释放锁
lock.unlock();
}
}
}
Redisson可重入锁原理
获取锁的Lua的脚本
local key = KEYS[1]; -- 锁的key
local threadId = ARGV[1]; -- 线程唯一标识
local releaseTime = ARGV[2]; -- 锁的自动释放时间
-- 判断是否存在
if (redis.call('exists', key) == 0) then
-- 不存在,获取锁
redis.call('hset', key, threadId, '1');
-- 设置有效期
redis.call('expire', key, releaseTime);
return 1; -- 返回结果
end;
-- 锁已经存在,判断threadId是否是自己
if (redis.call('hexists', key, threadId) == 1) then
-- 不存在,获取锁,重入次数+1
redis.call('hincrby', key, threadId, '1');
-- 设置有效期
redis.call('expire', key, releaseTime);
return 1; -- 返回结果
end;
return 0; -- 代码走到这里,说明获取锁的不是自己,获取锁失败
释放锁的Lua脚本
local key = KEYS[1]; -- 锁的key
local threadId = ARGV[1]; -- 线程唯一标识
local releaseTime = ARGV[2]; -- 锁的自动释放时间
-- 判断当前锁是否还是被自己持有
if (redis.call('HEXISTS', key, threadId) == 0) then
return nil; -- 如果已经不是自己,则直接返回
end;
-- 是自己的锁,则重入次数-1
local count = redis.call('HINCRBY', key, threadId, -1);
-- 判断是否重入次数是否已经为0
if (count > 0) then
-- 大于0说明不能释放锁,重置有效期然后返回
redis.call('EXPIRE', key, releaseTime);
return nil;
else -- 等于0说明可以释放锁,直接删除
redis.call('DEL', key);
return nil;
end;
总结
1. 不可重入Redis分布式锁
- 原理:利用setnx的互斥性;利用ex避免死锁;释放锁时判断线程标示
- 缺陷:不可重入、无法重试、锁超时失效
2. 可重入的Redis分布式锁
- 原理:利用hash结构,记录线程标示和重入次数;利用watchDog延续锁时间;利用信号量控制锁重试等待
- 缺陷:redis宕机引起锁失效问题
3. Redisson的multiLock
- 原理:多个独立的redis节点,必须在所有节点都获取重入锁,才算获取锁成功
Redis异步秒杀
lua脚本
-- 优惠券id
local voucherId = ARGV[1]
-- 用户id
local userId = ARGV[2]
-- 库存key
local stockKey = 'seckill:stock:' .. voucherId
-- 订单key
local orderKey = 'seckill:order:' .. voucherId
-- 判断库存是否充足
if(tonumber(redis.call('get', stockKey)) <= 0) then
-- 库存不足,返回1
return 1
end
-- 判断用户是否下单SISMEMBER orderKey userId
if(redis.call('sismember', orderKey, userId) == 1) then
-- 存在,说明是重复下单,返回2
return 2
end
-- 扣库存incrby stockKey -1
redis.call('incrby', stockKey, -1)
-- 下单(保存用户)sadd orderKey userId
redis.call('sadd', orderKey, userId)
Redis消息队列
消息队列,字面意思就是存放消息的队列。最简单的消息队列模型包括3个角色:
- 消息队列:存储和管理信息,也被称为消息代理
- 生产者:发送消息到消息队列
- 消费者:从消息队列获取消息并处理消息
Redis提供了三种不同的方式来实现消息队列: - list结构:基于List结构模拟消息队列
- PubSub:基本的点对点消息模型
- Stream:比较完善的消息队列模型
基于List结构模拟消息队列
消息队列,字面意思就是存放消息的队列。而Redis的list数据结构是一个双向链表,很容易模拟出队列效果
队列是入口和出口不在一边,因此我们可以利用:LPUSH结合RPOP、或者RPUSH结合LPOP来实现
不过要注意的是,当队列中没有消息时RPOP或LPOP操作会返回null,并不像JVM的阻塞队列那样会阻塞并等待消息
因此这里应该使用BRPOP或者BLPOP来实现阻塞效果
优点:
- 利用Redis存储,不受限于JVM内存上限
- 基于Redis的持久化机制,数据安全性有保证
- 可以满足消息有序性
缺点:
- 无法避免消息丢失
- 只支持单消费者
基于PubSub结构模拟
**PubSub(发布订阅)**是Redis2.0版本引入的消息传递模型。顾名思义,消费者可以订阅一个或多个channel,生产者向对应channel发送消息后,所有订阅者都能收到相关消息。
- SUBSCRIBE channel [channel]:订阅一个或多个频道
- PUBLISH channel msg:向一个频道发送消息
- PSUBSCRIBE pattern[pattern]:订阅与pattern格式匹配的所有频道
优点:
- 采用发布订阅模型,支持多生产、多消费
缺点:
- 不支持数据持久化
- 无法避免消息丢失
- 消息堆积有上限,超出时数据丢失
基于Stream的消息队列
Stream是Redis5.0引入的一种新数据类型,可以实现一个功能非常完善的消息队列
发送消息的命令:
XADD key [NOMKSTREAM] [MAXLEN|MINID [=|~] threshold [LIMIT count]] *|ID field value [field value ...]
- NOMKSTREAM:如果队列不存在,是否自动创建队列。默认是自动创建
- [MAXLEN|MINID [=|~] threshold [LIMIT count]]:设置消息队列的最大消息数量
- ID:消息的唯一id,*代表由Redis自动生成,格式是“时间戳-递增数字”
- field value [field value …]:发送到队列中的消息,称为Entry。格式是多个key-value键值对
读取消息的命令:
XREAD [COUNT count] [BLOCK milliseconds] STREAMS key [key ...] ID [ID ...]
- [COUNT count]:每次读取消息的最大数量
- [BLOCK milliseconds]:当没有消息时,是否阻塞,阻塞时长
- STREAMS key [key …]:要从哪个队列读取消息,key就是队列名
- ID:起始id,只返回大于该ID的消息。0代表从第一个消息开始;$:代表从最新的消息开始
Stream类型消息队列的xread命令特点:
- 消息可回溯
- 一个消息可以被多个消费者读取
- 可以阻塞读取
- 有消息漏读的风险
基于Stream的消息队列-消费者组
消费者组:将多个消费者划分到一个组中,监听同一个队列
-
消息分流
队列中的消息会分流给组内的不同消费者,而不是重复消费,从而加快消息处理的速度
-
消息标示
消费者组会维护一个标示,记录最后一个被处理的消息,哪怕消费者宕机重启,还会从标示之后读取消息。确保每一个消息都会被消费
-
消息确认
消费者获取消息后,消息处于pending状态,并存入一个pending-list。当处理完成后需要通过XACK来确认消息,标记消息为已处理,才会从pending-list移除
创建消费者组:
XGROUP CREATE key groupName ID [MKSTREAM]
- key:队列名称
- groupName:消费者组名称
- ID:起始ID标示:$代表队列中最后一个消息,0则代表队列中第一个消息
- MKSTREAM:队列不存在时自动创建队列
其它常见命令:
# 删除指定的消费者组
XGROUP DESTORY key groupName
# 给指定的消费者组添加消费者
XGROUP CREATECONSUMER key groupname consumername
# 删除消费者组中的指定消费者
XGROUP DELCONSUMER key groupname consumername
从消费者组读取消息:
XREADGROUP GROUP group consumer [COUNT count] [BLOCK milliseconds] [NOACK] STREAMS key [key ...] ID [ID ...]
- group:消费者名称
- consumer:消费者名称,如果消费者不存在,会自动创建一个消费者
- count:本次查询的最大数量
- BLOCK milliseconds:当没有消息时最长等待时间
- NOACK:无需手动ACK,获取到消息后自动确认
- STREAMS key:指定队列名称
- ID:获取消息的起始ID:
- ”>“:从下一个未消费的消息开始
- 其它:根据指定id从pending-list中获取已消费但未确认的消息,例如0,是从pending-list中的第一个消息开始
Stream类型消息队列的XREADGROUP命令特点:
- 消息可回溯
- 可以多消费者争抢消息,加快消费速度
- 可以阻塞读取
- 没有消息漏读的风险
- 有消息确认机制,保证消息至少被消费一次
Redis消息队列
List | PubSub | Stream | |
---|---|---|---|
消息持久化 | 支持 | 不支持 | 支持 |
阻塞读取 | 支持 | 支持 | 支持 |
消息堆积处理 | 受限于内存空间,可以利用多消费者加快处理 | 受限于消费者缓冲区 | 受限于队列长度,可以利用消费者组提高消费速度,减少堆积 |
不支持 | 不支持 | 支持 | |
不支持 | 不支持 | 支持 |
关注推送
关注推送也叫做Feed流,直译为投喂。为用户持续的提供“沉浸式”的体验,通过无限下拉刷新获取新的信息。
Feed流的模式
Feed流产品有两种常见模式:
- Timeline:不做内容筛选,简单的按照内容发布时间排序,常用于好友或关注。例如朋友圈
- 优点:信息全面,不会有缺失。并且实现也相对简单
- 缺点:信息噪音较多,用户不一定感兴趣,内容获取效率低
- 智能排序:利用智能算法屏蔽掉违规的、用户不感兴趣的内容。推送用户感兴趣信息来吸引用户
- 优点:投喂用户感兴趣信息,用户粘度很高,容易沉迷
- 缺点:如果算法不精准,可能起到反作用
Feed流的实现方案
拉模式 | 推模式 | 推拉结合 | |
---|---|---|---|
写比例 | 低 | 高 | 中 |
读比例 | 高 | 低 | 中 |
用户读取延迟 | 高 | 低 | 低 |
实现难度 | 复杂 | 简单 | 很复杂 |
使用场景 | 很少使用 | 用户量少、没有大V | 过千万的用户量,有大V |
Java的用法
// 查询收件箱
Set<ZSetOperations.TypedTuple<String>> typedTuples = stringRedisTemplate.opsForZSet().reverseRangeByScoreWithScores(key, 0, max, offset, 2)
GEO数据结构
GEO就是Geolocation的简写形式,代表地理坐标。Redis在3.2版本中加入了对GEO的支持,允许存储地理坐标信息,帮助我们根据经纬度来检索数据。常见的命令有:
- GEOADD:添加一个地理空间信息,包含:经度(longitude)、纬度(latitude)、值(member)
- GEODIST:计算指定的两个点之间的距离并返回
- GEOHASH:将指定member的坐标转为hash字符串形式返回
- GEOPOS:返回指定member的坐标
- GEORADIUS:指定圆心、半径,找到该圆内包含的所有member,并按照与圆心之间的距离排序后返回。6.2以后已废弃
- GEOSEARCH:在指定范围内搜索member,并按照与指定点之间的距离排序后返回。范围可以是圆形或矩形。6.2新功能
- GEOSEARCHSTORE:与GEOSEARCH功能一致,不过可以把结果存储到一个指定的key。6.2新功能
SpringDataRedis的2.3.9版本并不支持Redis6.2提供的GEOSEARCH命令,因此我们需要提示其版本,修改自己的POM文件
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
<exclusions>
<exclusion>
<groupId>org.springframework.data</groupId>
<artifactId>spring-data-redis</artifactId>
</exclusion>
<exclusion>
<groupId>lettuce-core</groupId>
<artifactId>io.lettuce</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.springframework.data</groupId>
<artifactId>spring-data-redis</artifactId>
<version>2.6.2</version>
</dependency>
<dependency>
<groupId>lettuce-core</groupId>
<artifactId>io.lettuce</artifactId>
<version>6.1.6.RELEASE</version>
</dependency>
Java的用法
// 按照距离排序、分页
GeoResults<RedisGeoCommands.GeoLocation<String>> results = stringRedisTemplate.opsForGeo().search(key, GeoReference.fromCoordinate(x, y), new Distance(5000), RedisGeoCommands.GeoSearchCommandArgs.newGeoSearchArgs().includeDistance().limit(end));
List<GeoResult<RedisGeoCommands.GeoLocation<String>>> list = results.getContent();
// 截取from - end 的部分
list.stream().skip(from).forEach() //...忽略
// 导入数据到Geo
List<RedisGeoCommands.GeoLocation<String>> locations = new ArrayList<>();
for() {
locations.add(new RedisGeoCommands.GeoLocation<>(shop.getId().toString(), new Point(shop.getX(), shop.getY())));
}
stringRedisTemplate.opsForGeo().add(key, locations);
BitMap
Redis中是利用string类型数据结构实现BitMap,因此最大上限是512M,转换为bit则是2^32个bit位。BitMap的操作命令有:
- SETBIT:向指定位置(offset)存入一个0或1
- GETBIT:获取指定位置(offset)的bit值
- BITCOUNT:统计BitMap中值为1的bit位的数量
- BITFIELD:操作(查询、修改、自增)BitMap中bit数组中的指定位置(offset)的值
- BITFIELD_RO:获取BitMap中bit数组,并以十进制形式返回
- BITOP:将多个BitMap的结果做位运算(与、或、异或)
- BITPOS:查找bit数组中指定范围内第一个0或1出现的位置
Java的用法
// 月签到
stringRedisTemplate.opsForValue().setBit(key, LocalDateTime.now().getDayOfMonth() - 1, true);
// 获取本月截止今天为止的所有签到记录
stringRedisTemplate.opsForValue().bitField(key, BitFieldSubCommands.create().get(BitFieldSubCommands.BitFieldType.unsigned(LocalDateTime.now().getDayOfMonth())).valueAt(0));
RDB持久化
RDB全称Redis Database Backup file(Redis数据备份文件),也被叫做Redis数据快照。简单来说就是把内存中的所有数据都记录到磁盘中。当Redis实例故障重启后,从磁盘读取快照文件,恢复数据
快照文件成为RDB文件,默认是保存在当前运行目录
Redis内部有触发RDB的机制,可以在redis.conf文件中找到,格式如下:
# 900秒内,如果至少有1个key被修改,则执行bgsave,如果是save "" 则表示禁用RDB
save 900 1
save 300 10
save 60 10000
RDB的其它配置也可以在redis.conf文件中设置:
# 是否压缩,建议不开启,压缩也会消耗cpu,磁盘的话不值钱
rdbcompression yes
# RDB文件名称
dbfilename dump.rdb
# 文件保存的路径目录
dir ./
bgsave开始时会fork主进程得到子进程,子进程共享主进程的内存数据。完成fork后读取内存数据并写入RDB文件
fork采用的是copy-on-write技术
- 当主进程执行读操作时,访问共享内存
- 当主进程执行写操作时,则会拷贝一份数据,执行写操作
AOF持久化
AOF全称为Append Only File(追加文件)。Redis处理的每一个写命令都会记录在AOF文件,可以看做是命令日志文件
AOF默认是关闭的,需要修改redis.conf配置文件来开启AOF:
# 是否开启AOF功能,默认是no
appendonly yes
# AOF文件的名称
appendfilename "appendonly.aof"
AOF的命令记录的频率也可以通过redis.conf文件来配
# 表示每执行一次写命令,立即记录到AOF文件
appendfsync always
# 写命令执行完先放入AOF缓冲区,然后表示每隔1秒将缓冲去数据写到AOF文件,是默认方案
appendfsync everysec
# 写命令执行完先放入AOF缓冲区,由操作系统决定何时将缓冲区内容写回磁盘
appendfsync no
配置项 | 刷盘时机 | 优点 | 缺点 |
---|---|---|---|
always | 同步刷盘 | 可靠性高,几乎不丢数据 | 性能影响大 |
everysec | 每秒刷盘 | 性能适中 | 最多丢失1秒数据 |
no | 操作系统控制 | 性能最好 | 可靠性较差,可能丢失大量数据 |
因为是记录命令,AOF文件会比RDB文件大的多。而且AOF会记录对同一个key的多次写操作,但只有最后一次写操作才有意义。通过执行bgrewriteaof命令,可以让AOF文件执行重写功能,用最少的命令达到相同效果
Redis也会在触发阈值时自动去重写AOF文件。阈值也可以在redis.conf中配置:
# AOF文件比上次文件 增长超过多少百分比则触发重写
auto-aof-rewrite-percentage 100
# AOF文件体积最小多大以上才触发重写
auto-aof-rewrite-min-size 64mb
RDB和AOF比较
RDB和AOF各有自己的优缺点,如果对数据安全性要求较高,在实际开发中往往会结合两者来使用
RDB | AOF | |
---|---|---|
持久化方式 | 定时对整个内存做快照 | 记录每一次执行的命令 |
数据完整性 | 不完整,两次备份之间会丢失 | 相对完整,取决于刷盘策略 |
文件大小 | 会有压缩,文件体积小 | 记录命令,文件体积很大 |
宕机恢复速度 | 很快 | 慢 |
数据恢复优先级 | 低,因为数据完整性不如AOF | 高,因为数据完整性更高 |
系统资源占用 | 高,大量CPU和内存消耗 | 低,主要是磁盘IO资源,但AOF重写时会占用大量CPU和内存资源 |
使用场景 | 可以容忍数分钟的数据丢失,追求更快的启动速度 | 对数据安全性要求较高常见 |
数据同步原理
全量同步
- slave节点执行replicaof命令第一次连接请求增量同步
- master节点判断replid,发现不一致,拒绝增量同步
- master将完整内存数据执行bgsave生成RDB,发送RDB到slave
- slave清空本地数据,加载master的RDB
- master将RDB期间的命令记录在repl_baklog,并持续将log中的命令发送给slave
- Replication Id: 简称replid,是数据集的标记,id一致则说明是同一数据集。每一个master都有唯一的replid,slave则会集成master节点的replid
- offset:偏移量,随着记录在repl_baklog中的数据增多而逐渐增大。slave完成同步时也会记录当前同步的offset。如果slave的offset小于master的offset,说明slave数据落后于master,需要更新
因此slave做数据同步,必须向master声明自己的replication id和offset,master才可以判断到底需要同步哪些数据
增量同步
主从第一次同步是全量同步,但如果slave重启后同步,则执行增量同步
- slave重启,请求增量同步
- master节点判断replid,发现一致,回复continue
- master去repl_baklog中获取offset后的数据,发送offset后的命令给slave
repl_baklog大小有上限,写满后会覆盖最早的数据。如果slave断开时间过久,导致尚未备份的数据被覆盖,则无法基于log做增量同步,只能再次全量同步
Redis主从集群优化:
- 在master中配置repl-diskless-sync yes启用无磁盘复制,避免全量同步时的磁盘IO
- Redis单节点上的内存占用不要太大,减少RDB导致的过多磁盘IO
- 适当提高repl_baklog的大小,发现slave宕机时尽快实现故障恢复,尽可能避免全量同步
Redis哨兵
哨兵的作用
Redis提供了哨兵机制来实现主从集群的自动故障恢复。哨兵的结构和作用如下:
- 监控:Sentinel会不断检查你的master和slave是否按预期工作
- 自动故障恢复:如果master故障,Sentinel会将一个slave提升为master。当故障实例恢复后也以新的master为主
服务状态监控
Sentinel基于心跳机制监测服务状态,每隔1秒向集群的每隔实例发送ping命令:
- 主观下线:如果某sentinel节点发现某实例未在规定时间响应,则认为该实例主观下线
- 客观下线:若超过指定数量的sentinel都认为该实例主观下线,则该实例客观下线。quorum值最好超过Sentinel实例数量的一半
选举新的master
一旦发现master故障,sentinel需要在salve中选择一个作为新的master,选择依据是这样的:
- 首先会判断slave节点与master节点断开时间长短,如果超过指定值(down-after-milliseconds * 10)则会排除该slave节点
- 然后判断slave节点的slave-priority值,越小优先级越高,如果是0则永不参与选举
- 如果slave-prority一样,则判断slave节点的offset值,越大说明数据越新,优先级越高
- 最后是判断slave节点的运行id大小,越小优先级越高
如何实现故障转移
当选中了其中一个slave为新的master后,故障的转移的步骤如下:
- sentinel给备选的slave1节点发送slaveof no one命令,让该节点成为master
- sentinel给所有其它slave发送slaveof 192.168.150.101 7002命令,让这些slave成为新master的从节点,开始从新的master上同步数据
- 最后,sentinel将故障节点标记为slave,当故障节点恢复后会自动成为新的master的slave节点
RedisTemplate的哨兵模式
-
在pom文件中引入redis的starter依赖:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency>
-
然后再配置文件application.yml中指定sentinel相关信息:
spring: redis: sentinel: master: mymaster # 指定master名称 nodes: # 指定redis-snetinel集群信息 - 192.169.150.101:27001 - 192.169.150.101:27002 - 192.169.150.101:27003
-
配置主从读写分离
@Bean public LettuceClientConfigurationBuilderCustomizer configurationBuilderCustomizer() { return configBuilder -> configBuilder.readFrom(ReadFrom.REPLICA_PREFERRED); }
这里的ReadFrom是配置Redis的读取策略,是一个枚举,包括下面选择:
- MASTER:从主节点读取
- MASTER_PREFERRED:优先从master节点读取,master不可用才读取replica
- REPLICA:从slave(replica)节点读取
- REPLICA_PREFERRED:优先从slave(replica)节点读取,所有的slave都不可用才读取master
分片集群
分片集群结构
主从和哨兵可以解决高可用、高并发读的问题。但是依然有两个问题没有解决:
- 海量数据存储问题
- 高并发写的问题
使用分片集群可以解决上述问题,分片集群特征:
- 集群中有多个master,每隔master保存不同数据
- 每隔master都可以有多个slave节点
- master之间通过ping检测彼此健康状态
- 客户端请求可以访问集群任意节点,最终都会被转发到正确节点
散列插槽
Redis会把每一个master节点映射到0~16384个插槽上,查看集群信息时就能看到:
数据key不是与节点绑定,而是与插槽绑定。redis会根据key的有效部分计算插槽值,分两种情况:
- key中包含“{}”,且“{}”中至少包含1个字符,“{}”中的部分是有效部分
- key中不包含“{}”,整个key都是有效部分
例如:key是num,那么就根据num计算,如果是{itcast}num,则根据itcast计算。计算方式是利用CRC16算法得到一个hash值,然后对16384取余,得到的结果就是slot值
多级缓存
进程缓存
缓存在日常开发中启动至关重要的作用,由于是存储在内存中,数据的读取速度是非常快的,能大量减少对数据库的访问,减少数据库的压力。
- 分布式缓存(Redis)
- 优点:存储容量更大、可靠性更好、可以在集群间共享
- 缺点:访问缓存有网络开销
- 场景:缓存数据量较大、可靠性要求较高、需要在集群间共享
- 进程本地缓存(HashMap、GuavaCache)
- 优点:读取本地内存,没有网络开销,速度更快
- 缺点:存储容量有限、可靠性较低、无法共享
- 场景:性能要求较高,缓存数据较小
Caffeine
Caffeine是一个基于Java8开发的,提供了近乎最佳命中率的高性能的本地缓存库。目前Spring内部的缓存使用的就是Caffeine
@Test
void test() {
// 创建缓存对象
Cache<String, String> cache = Caffeine.newBuilder().build();
// 存数据
cache.put(key, value);
// 取数据,不存在则返回
String value = cache.getIfPresent(key);
// 取数据,不存在则去数据库查询
String value = cache.get(key, key -> {
// 查询数据库
});
}
Caffeine提供了三种缓存驱逐策略
-
基于容量:设置缓存的数量上限
// 创建缓存对象 Cache<String, String> cache = Caffeine.newBuilder() .maximumSize(1) // 设置缓存大小上限为1 .build();
-
基于时间:设置缓存的有效时间
// 创建缓存对象 Cache<String, String> cache = Caffeine.newBuilder() .expireAfterWrite(Duration.ofSeconds(10)) // 设置缓存有效期为10秒,从最后一次写入开始计时 .build();
-
基于引用:设置缓存为软引用或若引用,利用GC来回收缓存数据。性能较差,不建议使用
在默认情况下,当一个缓存元素过期的时候,Caffeine不会自动立即将其清理和驱逐。而是在一次读或写操作后,或者在空闲时间完成对失效数据的驱逐
Lua语法
数据类型
数据类型 | 描述 |
---|---|
nil | 这个最简单,只有值nil属于该类,表示一个无效值(在条件表达式中相当于false) |
boolean | 包含两个值:false和true |
number | 表示双精度类型的实浮点数 |
string | 字符串由一对双引号或单引号来表示 |
function | 由C或Lua编写的函数 |
table | Lua中的表(table)其实是一个“关联数组”,数组的索引可以是数字、字符串或表类型。在Lua里,table的创建是通过“构造表达式”来完成,最简单构造表达式是{},用来创建一个空表 |
变量
Lua声明变量的时候,并不需要指定数据类型
-- 声明字符串
local str = 'hello'
-- 声明数字
local num = 21
-- 声明布尔类型
local flag = true
-- 声明数组 key为索引的table
local arr = {'java', 'python', 'lua'}
-- 声明table,类似java的map
local map = {name = 'Jack', age = 21}
访问table
-- 访问数组,lua数组的角标从1开始
print(arr[1])
-- 访问table
print(map['name'])
print(map.name)
循环
数组、table都可以利用for循环来遍历
-
遍历数组
-- 声明数组 key为索引的table local arr = {'java', 'python', 'lua'} -- 遍历数组 for index, value in ipairs(arr) do print(index, value) end
-
遍历table
-- 声明map,也就是table local map = {name='Jack', age=21} -- 遍历table for key, value in pairs(map) do print(key, value) end
函数
定义函数的语法
function 函数名(argument1, argument2..., argument)
-- 函数体
return 返回值
end
例如,定义一个函数,用来打印数组
function printArr(arr)
for index, value in ipairs(arr) do
print(value)
end
end
条件控制
类似java的条件控制,例如if、else语法
if(布尔表达式)
then
-- [ 布尔表达式为 true 时执行该语句块 --]
else
-- [ 布尔表达式为 false 时执行该语句块 -- ]
end
与java不同,布尔表达式中的逻辑运算是基于英文单词
操作符 | 描述 | 实例 |
---|---|---|
and | 逻辑与操作符。若A为false,则返回A,否则返回B | (A and B) 为 false |
or | 逻辑或操作符。若A为true,则返回A,否则返回B | (A or B) 为 true |
not | 逻辑非操作符。与逻辑运算结果相反,如果条件为true,逻辑非为false | not(A and B) 为 true |
优雅的key结构
Redis的Key虽然可以自定义,但最好遵循下面的几个最佳实践约定:
- 遵循基本格式:[业务名称]:[数据名]:[id]
- 长度不超过44字节
- 不包含特殊字符
优点:
- 可读性强
- 避免key冲突
- 方便管理
- 更节省内存:key是string类型,底层编码包含int、embstr和raw三种。embstr在小于44字节使用,采用连续内存空间,内存占用更小
BigKey问题
BigKey
BigKey通常以Key的大小和Key中成员的数量来综合判定
- Key本身的数据量过大:一个String类型的Key,它的值为5MB
- Key中的成员数过多:一个ZSET类型的Key,它的成员数量为10,000个
- Key中成员的数据量过大:一个Hash类型的Key,它的成员数量虽然只有1,000个但这些成员的Value(值)总大小为100MB
建议:(1)单个key的value小于10KB;(2)对于集合类型的key,建议元素数量小于1000
BigKey的危害
-
网络阻塞
对BigKey执行读请求时,少量的QPS就可能导致带宽使用率被占满,导致Redis实例,乃至所在物理机变慢
-
数据倾斜
BigKey所在的Redis实例内存使用率远超其他实例,无法使数据分片的内存资源达到均衡
-
Redis阻塞
对元素较多的hash、list、zset等做运算会耗时较久,使主线程被阻塞
-
CPU压力
对BigKey的数据序列化和反序列化会导致CPU的使用率飙升,影响Redis实例和本机其它应用
发现BigKey
-
redis-cli --bigkeys
利用redis-cli提供的–bigkeys参数,可以遍历分析所有key,并返回key的整体统计信息与每个数据的top1的big key
-
scan扫描
自己编程,利用scan扫描redis中的所有key,利用strlen、hlen等命令判断key的长度
-
第三方工具
利用第三方工具,如redis-rdb-tools分析rdb快照文件,全面分析内存使用情况
-
网络监控
自定义工具,监控进出redis的网络数据,超出预警值时主动告警
删除BigKey
BigKey内存占用较多,即便时删除这样的key也需要耗费很长时间,导致Redis主线程阻塞,引发一系列问题
-
redis3.0及以下版本
如果是集合类型,则遍历BigKey的元素,先逐个删除子元素,最后删除BigKey
-
redis4.0以后
redis在4.0后提供了异步删除的命令:unlink
选择合适的数据结构
恰当的数据类型
- json字符串(优点:实现简单粗暴;缺点:数据耦合,不够灵活)
- 字段打散(优点:可以灵活访问对象任意字段;缺点:占用空间大、没办法做统一控制)
- hash(优点:底层使用ziplist,空间占用小,可以灵活访问对象的任意字段;缺点:代码相对复杂)
- hash的entry数量超过500时,会使用哈希表而不是ziplist,内存占用较多,可以通过hash-max-ziplist-entries配置entry上限,但是如果entry过多就会导致BigKey问题
- string结构底层没有太多内存优化,内存占用较多,想要批量获取这些数据比较麻烦
持久化配置
Redis的持久化虽然可以保证数据安全,但也会带来很多额外的开销,因此持久化请遵循下列建议:
- 用来做缓存的Redis实例尽量不要开启持久化功能
- 建议关闭RDB持久化功能,使用AOF持久化
- 利用脚本定期在slave节点做RDB,实现数据备份
- 设置合理的rewrite阈值,避免频繁的bgrewrite
- 配置no-appendfsync-on-rewrite=yes,禁止在rewrite期间做AOF,避免因AOF引起的阻塞
部署有关建议:
- Redis实例的物理机要预留足够内存,应对fork和rewrite
- 单个Redis实例内存上限不要太大,例如4G或8G。可以加快fork的速度、减少主从同步、数据迁移压力
- 不要与CPU密集型应用部署在一起
- 不要与高硬盘负载应用一起部署。例如:数据库、消息队列
慢查询
慢查询:在Redis执行时耗时超过某个阈值的命令,称为慢查询
慢查询的阈值可以通过配置指定:
- slowlog-log-slower-than:慢查询阈值,单位是微秒。默认是10000,建议1000
慢查询会被放入慢查询日志中,日志的长度有上限,可以通过配置指定:
- slowlog-max-len:慢查询日志(本质是一个队列)的长度。默认是128,建议1000
修改这两个配置可以使用:config set命令
查看慢查询日志列表:
- slowlog len:查询慢查询日志长度
- slowlog get [n]:读取n条慢查询日志
- slowlog reset:清空慢查询列表
命令及安全配置
漏洞出现的核心的原因有以下几点:
- Redis未设置密码
- 利用了Redis的config set命令动态修改Redis配置
- 使用了Root账号权限启动Redis
为了避免这样搞的漏洞,这里给出一些建议:
- Redis一定要设置密码
- 禁止线上使用下面命令:keys、flushall、flushdb、config set等命令。可以利用rename-command禁用
- bind:限制网卡,禁止外网网卡访问
- 开启防火墙
- 不要使用Root账户启动Redis
- 尽量不是有默认的端口
内存配置
数据内存的问题
当Redis内存不足时,可能导致Key频繁被删除、响应时间变长、QPS不稳定等问题。当内存使用率达到90%以上时就需要我们警惕,并快速定位到内存占用的原因
内存占用 | 说明 |
---|---|
数据内存 | 是Redis最主要的部分,存储Redis的键值信息。主要问题是BigKey问题、内存碎片问题 |
进程内存 | Redis主进程本身运行肯定需要占用内存,如代码、常量池等等;这部分内存大约几兆,在大多数生产环境中与Redis数据占用的内存相比可以忽略 |
缓冲区内存 | 一般包括客户端缓冲区、AOF缓冲区、赋值缓冲区等。客户端缓冲区又包括输入缓冲区和输出缓冲区两种。这部分内存占用波动较大,不当使用BigKey,可能导致内存溢出 |
Redis提供了一些命令,可以查看到Redis目前的内存分配状态
- info memory
- memory xxx
内存缓冲区配置
内存缓冲区常见的有三种:
- 复制缓冲区:主从复制的repl_backlog_buf,如果太小可能导致频繁的全量复制,影响性能。通过repl-backlog-size来设置,默认1mb
- AOF缓冲区:AOF刷盘之前的缓存区域,AOF执行rewrite的缓冲区。无法设置容量上限
- 客户端缓冲区:分为输入缓冲区和输出缓冲区,输入缓冲区最大1G且不能设置。输出缓冲区可以设置client-output-buffer-limit
集群还是主从
集群最佳实践
集群虽然具备高可用特性,能实现自动故障恢复,但是如果使用不当,也会存在一些问题:
- 集群完整性问题
- 集群带宽问题
- 数据倾斜问题
- 客户端性能问题
- 命令的集群兼容性问题
- lua和事务问题
集群完整性问题
在Redis的默认配置中,如果发现任意一个插槽不可用,则整个集群都会停止对外服务:
为了保证高可用特性,这里建议将cluster-require-full-coverage配置为false
集群带宽问题
集群节点之间会不断地互相ping来确定集群中其它节点的状态。每次ping携带的信息至少包括:
- 插槽信息
- 集群状态信息
集群中节点越多,集群状态信息数据量也越大,10个节点的相关信息可能达到1kb,此时每次集群互通需要的带宽会非常高
- 避免大集群,集群节点数不要太多,最好少于1000,如果业务庞大,则建立多个集群
- 避免在单个物理机中运行太多Redis实例
- 配置合适的cluster-node-timeout值