1、Redis简述
1.1 Redis集群模式有三种:主从复制、哨兵模式、Redis Cluster模式
主从复制:一个主节点多个从节点,主节点负责写和读操作,从节点负责复制主节点的数据并只能提供读操作。主出问题从节点顶上,但是并不能自动实现故障转移,需要人工介入回复节点或者把从节点变成主节点。
哨兵模式:其实就是在主从复制的基础上加了哨兵节点(通常需要部署多个哨兵节点),用于监控主从节点的状态,哨兵节点向节点发送PING命令,如果指定时间收不到PONG响应,该节点就会被标记为主观下线。如果一个节点被多个哨兵标记为主观下线,那么它就会被标记为客观下线(不可用了)。一个主节点被标记为客观下线后哨兵就从从节点中选择一个健康的升级为主节点并通知客户端更改配置。实现故障的自动转移。
Cluster模式:Redis的分布式集群解决方案。使用哈希槽的方式进行数据分片,每个槽分配给一个节点,每个分片都有一个主节点和多个从节点,主节点负责写,从节点负责读。访问数据时先根据key计算出对应的槽编号,然后槽编号找到对应的节点。Cluster模式兼顾主从复制功能和自动故障转移。
补充:Redis Cluster中,事务不能跨节点执行,Lua的使用亦一样,脚本中访问的所有键必须在同一节点,可以人为干预使相关的键在同一节点。Redis中的hashTag就是一种解决方案,键名中使用{}包裹一个内容,Redis会对这个{}中的内容进行hash并依次分配数据存储节点。
1.2 Redis是CP还是AP
CAP理论:一个分布式系统最多只能同时满足一致性(Consistency)、可用性(Availability)和分区容错性(Partition tolerance)三者中的两者。
一致性:每次读都会收到最新的写入数据或错误信息。
主要了解下强一致性中的线性一致性和顺序一致性:线性一致性是一种最强的一致性模型,他强调在分布式系统的任何时间点,读操作都应该返回最近的写操作的结果。强调的是实时性,确保操作在实际时间上的顺序是一致的 。而顺序一致性放宽了一些限制,系统维护一个全局的操作顺序以确保每个客户看到的操作顺序是一致的,不强调实时性,只要求操作的顺序是一致的,可以有一定的延迟。
比如Zookeeper就是一个CP系统,而且保证的是顺序一致性(ZAB算法),即各个节点之间的写入顺序要求一致,并且要半数以上的节点写入成功才算成功。ZK官网:https://zookeeper.apache.org/doc/r3.4.13/zookeeperProgrammers.html 可见,ZK不能保证每个时间点,两个不同的客户端具有相同的ZK数据视图,但是能保证每个节点上读取的一定是他最后一次更新的内容。具体理解就是当ZK在进行数据同步时,如果半数以上的节点同步成功,它就会提交事务,但此时集群内还可能有节点没有同步数据,如果此时读请求发送到未同步数据的节点那么就会读取到旧数据。但是ZK会保证这个节点最终也会按照顺序执行成功的。可以通过sync命令保证ZK的强一致性,当我们对一个Follower调用sync命令的时候,会使得它和Leader节点进行数据同步,并等待同步完成再返回,这样下一次的read就能拿到最新的数据了。
可用性:每个请求都能收到(非错误)的响应,但不能保证响应包含最新的写入数据。
通常通过停机时间来衡量一个系统的可用性,比如,某系统可用性达到5个9,就是99.999%,即全年停机时间不超过
( 1 − 0.99999 ) ∗ 365 ∗ 24 ∗ 60 = 5.256 m i n (1-0.99999)*365*24*60=5.256min (1−0.99999)∗365∗24∗60=5.256min
分区容错性:即使网络节点之间会丢弃或延迟任意数量的消息,但是系统仍然能够继续运行。
CAP理论详解:假定现在有一个分布式系统,其中两台服务N1、N2,各自都有一个子数据库V0,N1收到请求将其数据库V0更新为V1,正常情况下N1和N2网络正常,则数据同步,N2上的V0也同步为V1,N2的数据再响应N2的请求。
N1和N2的数据库V保证的就是的一致性,两者都对外提供响应就是可用性,两者之间的网络环境就是体现分区容错性。系统要支持类似的网络异常,即满足分区容错性。此时用户请求N1更新数据V0至V1,因为网络断开,N2的数据仍然为V0,这时有用户访问N2读取数据就有两种情况:一是保证一致性,阻塞等待,待网络正常N2节点数据同步后再返回用户数据V1。二是保证可用性,N2响应旧数据V0给用户。这就分布式系统说明了在满足分区容错性(基础条件)的条件下,可用性和一致性只能选择其一。
如上,我们在讨论CAP时其实有一个既定的事实那就是分布式系统,分布式环境下,网络分区是必然的,那就是要满足P,至于提升P,需要提升基础设施的稳定性,重在长期的稳定的运营改进。至于一致性和可用性,首先涉及钱财的场景肯定是保证一致性,另外典型的就是分布式数据库,比如HBase。分布式系统常用的Zookeeper也是CP。其次大多数大型互联网应用的场景中,主机众多,部署分散,集群规模不断扩大,节点故障、网络异常是常态,为了保证服务,其实多是保证高可用,这种情况下,其实说是舍弃了一致性,也并不准确,其实更多的是退而求其次保证最终一致性来保证数据的安全。
2、Redis实现朋友圈点赞
1、使用字符串存储每篇朋友圈id,作为有序集合的key.
2、使用ZSet存储每篇朋友圈的点赞用户信息,其中value是点赞用户的id,score是点赞时间的时间戳。
3、点赞操作:将用户id添加到zset中,score为当前时间戳。如果用户点过赞则更新时间。
4、取消点赞:将用户id从有序集合中删除。
5、查询点赞信息:使用有序集合的ZREVRANGEBYSCORE命令,按照时间戳逆序返回zset的value,即点赞用户的id。
代码实现:
java
import redis.clients.jedis.Jedis;
import redis.clients.jedis.Transaction;
import redis.clients.jedis.ZParams;
public class RedisLikeDemo {
private static final String LIKE_PREFIX = "liek";
//点赞
public static void likePost(String postId, String userId, Jedis jedis) {
String key = LIKE_PREFIX + postId;
Long nowTime = System.currentTimeMillis();
jedis.zadd(key, nowTime.doubleValue(), userId);//将用户id和当前时间戳加入有序集合
}
//取消点赞
public static void cancleLikePost(String postId, String userId, Jedis jedis) {
String key = LIKE_PREFIX + postId;
jedis.zrem(key, userId);//将用户id从有序集合中移除
}
//查看点赞用户列表
public static void getLikeList(String postId , Jedis jedis) {
String key = LIKE_PREFIX + postId;
ZParams zParams = new ZParams().desc();
return jedis.zrangeByScoreWithScores(key, "+inf", "-inf", 0, -1, zParams)
.stream()
.map(tuple -> {
String userId = tuple.getElement();
return userId;
}).collect(Collectors.toList());
}
}
2、Redis实现排行榜
每个用户的得分作为zset中元素的score,将用户id作为元素的value。使用zset的排序功能可以按照分数从高到底排序,但是如果分数相同,默认会按照value值排序,为了实现分数相同按照时间顺序排序,可以将分数score设置成一个浮点数,其中整数部分是成绩,小数部分是时间戳。
score = 分数 + 时间戳/1e13;
这样的如果当前时间戳是1680417299000,分数是90,得到的score就是90.1680417299000
如上相同分数的时间靠后的结果越大,如果要实现同分时时间顺序排序,可修改为:
score = 分数 + 1 - 时间戳/1e13;
java
import redis.clients.jedis.Jedis;
public class RedisScoreDemo {
private static final String ZSET_KEY = "score_zset";
public static void addMember(String member, Int score, long timeStamp, Jedis jedis) {
double final_score = score + 1 - timeStamp/1e13;
jedis.zadd(ZSET_KEY, final_score, member);
}
}
3、Redis实现查找附近的人
利用redis的Geospatial数据类型,结合用户经纬度信息进行存储和查询。
使用Redis的GEOADD命令将用户信息经纬度存储在一个指定的键值中,然后再使用Redis的DEORADIUS命令可以查询指定经纬度附近一定范围内的用户信息。
java
import redis.clients.jedis.GeoRadiusResponse;
import redis.clients.jedis.Jedis;
import java,util.List;
import java.util.stream.Collectors;
public class RedisGeoDemo {
private static final String USER_LOCATION_KEY = "user_location";
//存储用户信息
public static void saveUserLocation(String userId, double longitude, double latitude, Jedis jedis) {
jedis.geoadd(USER_LOCATION_KEY, longitude, latitude, userId);
}
//查询附近的人
public static void getNearByUsers(double longitude, double latitude, double radius, Jedis jedis) {
List<GeoRadiusResponse> response = jedis.georadius(USER_LOCATION_KEY, longitude, latitude, radius, "km");
return response.stream().map(GeoRadiusResponse::getMemberByString).collect(Collectors.toList());
}
}
4、Redis实现滑动窗口限流
每次有请求进来时记录下请求的时间戳和请求数据,只保留在特定时间窗口内的请求记录,丢弃窗口外的记录,统计窗口内的请求数量即统计窗口内被记录的数据量。通过Redis的ZSET来实现这个功能,比如login接口限定一分钟只能被调用100次:
将login接口资源名作为Redis的key,如limit_key_login,value中的score设置为请求的时间戳,member可以用请求详情的hash(或者UUID/MD5之类的),避免并发时,时间戳一致出现score和member一样导致被zadd幂等的问题。
实现步骤:
1、定义滑动窗口时间范围,比如60s
2、每收到一个请求就定义一个zset存储到redis中
3、通过ZREMRANGEBYSCORE命令删除分值小于窗口起始时间戳(当前时间戳-60s)的数据
4、使用ZCARD命令获取有序集合中的成员数量,即窗口内的请求量
java
import redis.clients.jedis.Jedis;
public class SlidingWindowRateLimiter {
private Jedis jedis;
private String key;
private int limit;
public boolean allowReques(String key) {
//当前时间戳
long currentTime = System.currentTimeMillis();
//窗口开始时间是当前时间-60秒
long windowStart = currentTime - 60*1000;
//删除窗口开始时间之前的所有数据
jedis.zremrangeByScore(key, "-inf", String.valueOf(windowStart));
//计算总请求数
long currentRequests = jedis.zcard(key);
//窗口足够则把当前请求加入
if(currentRequests < limit) {
jedis.zadd(key, currentTime, String.valueOf(currentTime));
return true;
}
return false;
}
}
高并发环境下,可能存在原子性的问题,考虑使用事务或者Lua脚本
java
import redis.clients.jedis.Jedis;
public class SlidingWindowRateLimiter {
private Jedis jedis;
private String key;
private int limit;
public SlidingWindowRateLimiter(Jedis jedis, String key, int limit) {
this jedis = jedis;
this key = key;
this limit = limit;
}
public boolean allowReques(String key) {
//当前时间戳
long currentTime = System.currentTimeMillis();
String luaScript = "local window_start = ARGV[1] - 60000\n" +
"redis.call('ZREMRANGEBYSCORE', KEY[1], '-inf', window_start)\n" +
"local current_requests = redis.call('ZCARD', KEY[1])\n" +
"if current_requests < tonumber(ARGV[2] then\n)" +
" redis.call('ZADD', KEY[1], ARGV[1],ARGV[1])\n" +
" return 1\n" +
"else\n" +
" return 0\n" +
"end"
Object result = jedis.eval(luaScript, 1, key, String.valueOf(currentTime), String.valueOf(limit));
return result == 1;
}
}
5、Redis如果挂了怎么办
限流&降级
做好预案,加开关之类的如果redis突然挂了,就直接不使用redis了并且开启限流,在前面直接栏掉一些流量或者使用本地缓存,但是使用本地缓存注意下数据的一致性问题和数据量问题。
备份
redis的备用实例,平时不对外提供服务,只保证和主实例的数据同步。
补充一点:压测时遇到过redis内存满的情况,那么这种情况会导致redis挂了么?首先redis本身设计上其实并不会因为内存满而崩溃,但为了保证服务的稳定和性能,还是需要通过适当的配置来管理内存使用和处理策略。redis支持多种内存淘汰策略,通过配置文件中的maxmemory-policy参数决定。
allkeys-lru:根据 LRU 算法删除键,不管数据是否设置超时属性,优先淘汰最近最少使用的键,直到腾出足够空间为止。
allkeys-random:会随机淘汰一些键。
volatile-lru:根据 LRU 算法删除过期键,优先淘汰设置了过期时间(TTL)的键中最近最少使用的键。
volatile-random:随机淘汰已过期 Key。
volatile-ttl:根据键值对象的 TTL 属性,会优先淘汰设置了过期时间的键中 TTL 值较小的键。如果没有,回退到 noeviction 策略。
volatile-lfu:优先淘汰设置了过期时间(TTL)的键中最不经常使用(LFU)的键。
allkeys-lfu:优先淘汰最不经常使用(LFU)的键,与 volatile-lfu 不同,allkeys-lfu 策略会淘汰所有键,而不仅是设置了过期时间(TTL)的键。
noeviction:不会删除任何数据,拒绝所有写入操作,并返回客户端错误信息"(error) OOM command not allowed whenused memory"
,此时 Redis 只响应读操作。
当 Redis 作为缓存使用的时候,推荐使用 allkeys-lru 淘汰策略。该策略会将使用频率最低的 Key 淘汰。默认情况下,使用频率最低则后期命中的概率也最低,所以将其淘汰。
当 Redis 作为半缓存半持久化使用时,可以使用 volatile-lru。但因为 Redis 本身不建议保存持久化数据,所以只作为备选方案。
6、Redis常见问题
缓存击穿
某个key缓存过期时大并发请求访问这个key,请求直接访问数据库导致数据库过载。
解决这个问题可以有两种方案:
针对某个热点数据,如果其过期时间是1小时,那么我们可以每59分钟通过定时任务去更新这个热点key,并重新设置过期时间。
还有一种解决方法就是加锁,当redis中根据某个key获得的value值为空时,先锁上,然后从数据库加载,加载完毕释放锁。若其他线程也在请求该Key时发现获取锁失败就先阻塞。
缓存穿透
缓存和数据库中都没有某个key。
解决这个问题可以在缓存中设置对应的空值,这样下次请求缓存可以返回结果null,注意设置过期时间。很多时候,缓存穿透发生是由于很多恶意流量的请求,这些请求生成了许多不存在的key来查询,缓存和数据库中都没有那么就容易导致缓存穿透。针对这种情况,可以使用布隆过滤器,将查询条件哈希到一个较大的布隆过滤器中,一定不存在的数据就直接被布隆过滤器拦截返回了。
缓存雪崩
大量key同时过期或者缓存服务宕机,所有请求直接访问数据库,数据库过载甚至宕机。
解决这个问题可以把不同的key的过期时间设置为不同的,并且通过定时刷新的方式更新过期时间。
另外比较典型的做法就是采用集群方式部署来避免单点故障。
数据不一致
缓存的使用常见的3种方案:
1、先更新数据库,再删除缓存。
2、延迟双删:先删除缓存,再更新数据库,再删除一次缓存。
3、cache-aside:更新数据库,根据binlog监听进行缓存删除。
如上我们首先分析一个问题:先删缓存还是先写数据库
先删缓存:好处是即使数据库更新失败了,我们的操作也只是清空了缓存而已,只需要重试就行。坏处在于这种方式放大了"读写并发"下数据不一致发生的概率。线程1删除缓存,此时线程2在读缓存时没查到数据就去数据库查然后更新到缓存中,如果在线程2读数据库之后更新缓存之前线程1继续执行更新了数据库数据,那么此时线程2写到缓存中的就是旧值。这种问题比较严重,因为接下来在缓存失效时间前这部分请求查询的结果就都是错的。
先写数据库:虽然缓存删除失败概率较低但是如果数据写入数据库成功而缓存删除失败了,那么数据就不一致了。
解决方案就是延迟双删:为了避免如上先写数据后删缓存的数据不一致问题我们选择先删缓存再写数据,这时其实如果并发不大的业务这样就可以了(并发不大的话先写数据后删缓存其实也行),但是并发较高的话就会有如上放大读写并发导致的不一致问题。这时就需要在写线程完成删除缓存更新数据库之后一段时间再删除一次缓存,一般建议1~2s就可以了。
其实先操作数据库再操作缓存还有一个典型的设计模式 Cashe-Aside-Pattern,缓存的删除可以在旁路异步执行。主要方式是借助数据库的binlog或者基于异步消息订阅。在代码逻辑中只要操作数据库就行了,数据库操作完成后可以发一个异步消息出来,然后有一个监听者在收到消息之后异步将缓存中的数据清除掉。或者干脆借助数据库的binlog,订阅到数据库变更之后异步清楚缓存。这两种方式都有一定延时,可以用在可接受秒级延迟的业务场景中。至于为什么不直接使用延迟双删,因为一般认为基于binlog的监听是比通过代码区删除缓存更可靠的。当然也可以更完美一点,先删缓存,再更新数据库,再监听binlog删除缓存。
其他还有一些模式,比如Read Through、Writer Through、Writer Behind Caching Pattern等可以另外了解下。
脑裂问题
简单概括就是集群产生了多个Master/Leader,即主节点,从而导致数据不一致、重复写入甚至数据丢失等问题。
产生原因
网络分区:Master处于一个网络,哨兵和Slave在另一个网络,此时哨兵发现和Master连不上了重新选举一个Master,这样就有两个主节点了。
主节点故障:Master节点出现问题,哨兵选举新的主节点,在这个过程中主节点又恢复了,这样可能导致一部分Slave节点认为它是主节点,而另一部分Slave新选出了一个Master。
如何避免脑裂问题
注意两个参数的配置:
min-slaves-to-write:主库能进行同步的最少从库数量
min-slave-max-lag:主从库之间进行数据复制时,从库给主库发送ACK的最大延迟秒数
举个例子:
min-slaves-to-write设置1,min-slave-max-lag 设置 10s
如果某个Master宕机12s,哨兵认为主节点客观下线,开始主从切换。同时因为原Master宕机12s,没有一个(min-slaves-to-write)从库能与原主库在10s内进行数据复制,这样一来,就因为不满足配置要求,原主节点无法接收新的客户端请求。主从切换完成后,也只有新的主库能接受新的请求了,就不会有脑裂问题。
但是,脑裂问题其实是无法彻底解决的,比如上面那个场景:
min-slaves-to-write设置1,min-slave-max-lag 设置 10s,另外设置down-after-milliseconds为8s,也就是8s联系不上主库,哨兵开始主从切换。如果切换过程需要5秒,主库宕机后第9s主库恢复了,由于min-slave-max-lag 设置 10s,此时新的请求又到了原主库上,直至新的主库被选出来并且新的主库向所有实例发送slaveof命令,这样就会发生数据丢失问题。所以实际应用中也只能尽量合理配置以规避问题,无法彻底解决。