Redis实战总结
Remote Dictionary Server(远程字典服务器),目前最热门的NoSQL数据库之一,它是基于KV键值对的方式存储数据;随着网络时代的发展,关系数据在支持海量实时数据访问、高并发、高性能、高扩展存在限制;从而就诞生了非关系型数据库NoSQL。
非关系数据库特点:
- 存储非结构化数据,如文本、图片、视频等等;
- 表之前没有强关联,可拓展性较强;
- 保证数据最终一致性,遵循BASE理论,Basically Avaiable(基本可用)、Soft State (软状态)、Eventually Consistent(最终一致性);
- 支持海量数据存储以及高并发高效读写;
市面上常见的No SQL数据库:https://hostingdata.co.uk/nosql-database/ (opens new window)
对于Redis常常被用作数据缓存,应为它本身就是内存数据库,所有对热点数据缓存是非常适合的,而且特别快。
为什么Redis这么流行呢?首先它支持的编程语言种类很多,数据类型比较多,功能也很丰富,它支持集群、分布式。
# 1. 应用场景
常常用于缓存热点数据,当然了由于它的功能特别丰富,也有用作分布式锁、分布式全局统一ID、分布式会话等等。
Redis本身就是字典服务型数据,它是基于KV键值对方式存储数据的,我们都知道基于KV方式存储数据,访问时间复杂度为O(N),Redis其他很多数据类型也都是基于KV方式实现的,主要的数据类型包括:String(计数器、全局ID、锁、限流)、Hashes(购物车商品数据)、Set(交/并/差集计算、抽奖、用户关注、推荐模型、签到、打卡)、ZSet(排行数据、热搜排行)、List(队列、列表数据)、Bitmaps(用户访问在线统计)、Hyperloglogs(日活、月活、UV)、Geospatial(经纬度计算)、Stream(可持久化消息队列)。
# 2. 发布订阅
发布监听模式其实很简单,就同常见的监听器一个原理,订阅器首先会想redis订阅一个主题Topic,然后发布者在发布消息时,会从主题匹配的模式中获取订阅者,然后将消息广播到订阅者。
redis 中支持通配符匹配方式订阅,*
标识0个或多个字符,?
标识一个字符,订阅示例如图:
Jedis客户端实现代码:
@Test
public void pubMessage(){
Jedis jedis = new Jedis();
jedis.publish("News-Notice", "This Notices Mesagess");
jedis.publish("News-NBA", "This NBA Offical");
jedis.publish("News-Tech", "This Java Technology");
}
@Test
public void subNotice() {
Jedis jedis = new Jedis();
jedis.psubscribe(new JedisPubSubImpl(), "News-Notice");
}
@Test
public void subNBA() {
Jedis jedis = new Jedis();
jedis.psubscribe(new JedisPubSubImpl(), "News-NB?");
}
@Test
public void subAll() {
Jedis jedis = new Jedis();
jedis.psubscribe(new JedisPubSubImpl(), "News-*");
}
static class JedisPubSubImpl extends JedisPubSub {
// 取得订阅的消息后的处理
public void onMessage(String channel, String message) {
System.out.println("onMessage-arg2-->"+channel + "=" + message);
}
// 初始化订阅时候的处理
public void onSubscribe(String channel, int subscribedChannels) {
System.out.println("onSubscribe-->"+channel + "=" + subscribedChannels);
}
// 取消订阅时候的处理
public void onUnsubscribe(String channel, int subscribedChannels) {
System.out.println("onUnsubscribe-->"+channel + "=" + subscribedChannels);
}
// 初始化按表达式的方式订阅时候的处理
public void onPSubscribe(String pattern, int subscribedChannels) {
System.out.println("onPSubscribe-->"+pattern + "=" + subscribedChannels);
}
// 取消按表达式的方式订阅时候的处理
public void onPUnsubscribe(String pattern, int subscribedChannels) {
System.out.println("onPMessage-->"+pattern + "=" + subscribedChannels);
}
// 取得按表达式的方式订阅的消息后的处理
public void onPMessage(String pattern, String channel, String message) {
System.out.println("onPMessage-arg3-->"+pattern + "=" + channel + "=" + message);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
以上介绍的只是基本的发布订阅模式,Redis在5.0版本后通过Stream实现了一个消息队列,比上述介绍的更加强大,Stream提供了消息的持久化和主备复制功能,可以让任何客户端访问任何时刻的数据,并且能记住每一个客户端的访问位置,还能保证消息不丢失。
# 3. 事务
在Redis中单个指令是原子性的,要么成功、要么失败,指令之间是不会相互干扰;
但是在多个指令需要实现原子性操作时,就需要事务的支持,在Redis中事务支持是很简单的,特点如下:
- Redis开启事务后,会把一些列命令放入队列中并照队列中命令存放顺序执行;
- 每个指令直接是不会相互干扰的;
- 事务是不可以嵌套的。
redis 事务使用到四个指令:MULTI (开启事务)、 EXEC (执行事务内指令)、 DISCARD(取消事务)、WATCH(乐观锁实现),如下Jedis实例
Jedis jedis = new Jedis();
Transaction trans = jedis.multi();
trans.set("trans_key", "trans_hello");
trans.setbit("bit_key", 2, true);
trans.set("number_key", "0");
trans.incr("number_key");
List<Object> exec = trans.exec();
// 观察number_key是否被修改,若修改当前事务自动失效
Jedis jedis = new Jedis();
jedis.watch("number_key");
Transaction multi = jedis.multi();
multi.incr("number_key");
List<Object> exec = trans.exec();
//String discard = multi.discard();
2
3
4
5
6
7
8
9
10
11
12
13
14
15
提示
当事务内指令由于某条指令发生错误,事务是不会回滚的,错误指令之前的指令是生效的,那么Redis为什么不实现事务回滚机制呢?
Redis 命令只会因为错误的语法而失败(并且这些问题不能在入队时发现),或是命令用在了错误类型的键上面:这也就是说,从实用性的角度来说,失败的命令是由编程错误造成的,而这些错误应该在开发的过程中被发现,而不应该出现在生产环境中。
因为不需要对回滚进行支持,所以 Redis 的内部可以保持简单且快速,其实就是实现起来跟简单,没必要搞那么复杂。
其实Redis可以通过Lua脚本实现原子操作,当Redis执行Lua脚本时,所有客户端都会被阻塞直到Lua脚本调用返回; 可参考:Lua脚本参数说明 (opens new window)
如下实现每10秒限流5次的操作:
private final static String script = "local num = redis.call('incr', KEYS[1])\n" +
"if tonumber(num) == 1\n" +
"then\n" +
" redis.call('expire', KEYS[1], ARGV[1])\n" +
" return 1\n" +
"elseif tonumber(num) > tonumber(ARGV[2]) then\n" +
" return 0\n" +
"else\n" +
" return 1\n" +
"end";
for (int i = 0; i < 20; i++) {
Object lua_key = getJedis().eval(script, Collections.singletonList("lua_key"), Arrays.asList("10", "5"));
System.err.println(lua_key);
if ((Long) lua_key == 1L) {
System.err.println("---------》》 放行访问");
}else {
System.err.println("---------》》 超过访问阀值,容错处理:当前流量多大,请稍后重试。。。。");
}
if (i == 10) {
try {
TimeUnit.SECONDS.sleep(4);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# 4. 主从
主从复制其实很简单,目前市面上很多数据库都支持,在redis中只需要添加配置参数即可实现,如下:
# 实现主从复制配置参数
# replicaof <masterip> <masterport>
replicaof 127.0.0.1 6378
2
3
当然了,除了使用上述配置实现主从配置,还可以在服务启动、客户端执行相关命令实现主从复制功能,非常简单,很适合动态切换。同时从节点也可以主动切断与主节点的联系。
# 启动时指定master节点
./redis-server --slaveof 127.0.0.1 6379
# 客户端执行命令
salveof 127.0.0.1 6379
# 从节点客户端执行一下命令,切断与主节点之间的联系
slaveof no one
2
3
4
5
6
7
8
在redis中主从复制是需要遵循一定的规则的,其主要分为两类,一类是全量同步,从节点第一次接入主节点时触发该机制,一次性全量同步所有的数据,另一类是增量同步,若从节点由于网络故障没有能即使的同步主节点的数据,当网络重新接入时,会触发该机制,当不能进行增量同步时,主节点会创建全量快照进行同步。
正常情况下,一个全量重同步要求在磁盘上创建一个RDB文件,然后将它从磁盘加载进内存,然后从节点以此进行数据同步。如果磁盘性能很低的话,这对主节点是一个压力很大的操作。Redis支持无磁盘复制机制,主节点的子进程生成RDB后直接发送RDB文件给从节点,无需使用磁盘作为中间储存介质。
主从复制机制只解决数据灾备问题,并没有解决高可用问题,如果主节点挂掉了,就不能对外提供服务,也就产生了单点问题;若需要恢复服务只能手动升级从节点作为主节点提供服务。
# 5. 哨兵
Redis为了保证服务高可用,解决单点问题,解决手动切换主从麻烦,及时实现故障恢复,提供了哨兵机制,实现自动主从切换的能力。而且客户端接入非常简单,只需要使用哨兵服务地址即可,通过哨兵服务来获取主节点的IP和端口信息。
Set<String> hosts = new HashSet<>();
hosts.add("127.0.0.1:26379");
hosts.add("127.0.0.1:26378");
hosts.add("127.0.0.1:26377");
JedisSentinelPool jedisSentinelPool = new JedisSentinelPool("mymaster", hosts);
Jedis resource = jedisSentinelPool.getResource();
//开始操作
//--------------------
2
3
4
5
6
7
8
启动哨兵服务:
./redis-sentinel ../sentinel.conf
./redis-server ../sentinel.conf --sentinel
2
3
其实Sentinel本质上就是一个特殊的redis服务,Sentinel是通过redis info 命令来监控leader和follower的。
为了保证监控服务器的可用性,需要对Sentinel做集群的部署,Sentinel 既监控所有的Redis服务,Sentinel 之间也相互监控。注意: Sentinel本身没有主从之分,地位是平等的,只有Redis服务节点有主从之分。
这里就有个问题了,Sentinel 唯一的联系,就是他们监控相同的master,那一个Sentinel节点是怎么知道其他的Sentinle节点存在的呢?
因为Sentinel是一个特殊状态的Redis节点,它也有发布订阅的功能。哨兵上线时,给所有的Reids节点(master/slave) 的名字为 _sentinel_:hello
的channle发送消息。
每个哨兵都订阅了所有Reids节点名字为 _sentinel_:hello
的channle,所以能互相感知对方的存在,而进行监控。
其中Sentinel主要有三个作用:
- 监控(Monitoring): Sentinel 会不断地检查你的主节点服务器和从节点服务器是否运作正常。
- 通知(Notification): 当被监控的某个 Redis 服务器出现问题时, Sentinel 可以通过 API 向管理员或者其他应用程序发送通知。
- 自动故障迁移(Automatic failover): 当一个主服务器不能正常工作时, Sentinel 会开始一次自动故障迁移操作, 它会将失效主服务器的其中一个从服务器升级为新的主服务器, 并让失效主服务器的其他从服务器改为复制新的主服务器; 当客户端试图连接失效的主服务器时, 集群也会向客户端返回新主服务器的地址, 使得集群可以使用新主服务器代替失效服务器。
为了保证Sentinel 高可用,Sentinel 需要做群成处理,集群至少需要三个节点,尽可能保证奇数个,防止脑裂。那么这里又出现问题了,如果Sentinel做集群部署,它又是怎么去实现监控Redis主从节点呢,又那个Senttinel节点触发故障转移呢?
由于Sentinel集群中每个节点都是平等,所以必须要有一个领导来做故障转移,发号施令的工作,那么这个Leader是怎么产生的呢?也就需要通过选举来实现了,Redis Sentinel 选举采用的是类似Raft^1 (opens new window)算机制实现的。 当Sentinel 发现Master下线后,触发故障转移,在转移之前由于Sentinel的每个节点都是平等的,所以Sentinel集群节点内部需要选举产生一个Leader,由Leader来主持工作,选择哪一个Redis从节点作为主节点。
最后在Sentinel 选出Leader之后,由Sentinel Leader向某个从节点发送SLAVEOF|REPLICAOF no one
命令,让它成为独立主节点。
然后向其他从节点发送SLAVEOF|REPLICAOF host port
(本机IP 端口),让它们成为这个主节点的从节点,故障转移完成。
对于所有的slave节点,一共有四个因素影响选举的结果,分别是断开连接时长、优先级排序、复制数量、进程id。
- 如果与哨兵连接断开的比较久,超过了某个阈值,就直接失去了选举权;
- 如果拥有选举权,那就看谁的优先级高,这个在配置文件里可以设置(replica-priority 100),数值越小优先级越高;
- 如果优先级相同,就看谁从master中复制的数据最多(复制偏移量最大),选最多的那个;
- 如果复制数量也相同,就选择进程id最小的那个。
关于Sentinel模式常用配置说明,其他参数详见配置文件sentinel.conf
:
# 是否允许外部网络访问,yes-允许,默认no
protected-mode no
# 是否后台启动,默认no,yes-允许
daemonize no
# 哨兵端口号
port 26379
# 哨兵工作目录
dir "/tmp"
# 监控的master端口
sentinel monitor mymaster 127.0.0.1 6378 2
sentinel auth-pass mymaster 123456
sentinel down-after-milliseconds mymaster 30000
sentinel failover-timeout mymaster 180000
sentinel parallel-syncs mymaster 1
# sentinel monitor <master-name> <ip> <redis-port> <quorum> # quorum为多少个哨兵节点选举投票通过,选出Master
# sentinel auth-pass <master-name> <password> # master授权密码
# sentinel down-after-milliseconds <master-name> <milliseconds> master 宕机后,哨兵才会任务主观下线了
#1.同一个sentinel对同一“个master两次failover之间的间隔时间。
#2.当一个slave从一个错误的master那里同步数据开始计算时间。直到slave被纠正为向正确的master那里同步数据时。
#3.当想要取消一个正在进行的failover所需要的时间。
#4.当进行failover时,配置所有slaves指向新的master所需的最大时间。
# 默认3分钟,默认值太长了
# sentinel failover-timeout <master-name> <milliseconds>
#这个配直项指定了在发生failover主备切抉时最多可以有多少个slave同时对新的master进行同步,这个数字越小,完成failover所需的时间就越长,
#但是如果这个数字越大,就意味着越多的slave因为replication而不可用。
#可以通过将这个值设为1来保证每次只有一个slave 处于不能处理命令请求的状态
# sentinel parallel-syncs <master-name> <numreplicas>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
其实Sentinel也有缺点,由于基于主从方式,在故障转移的时候容易发生数据丢失,因为主从模式下,只有主节点可以写,其他从节点不能写。因此当数据量很大时,我们需要考虑分布式的方案,就是把数据拆分存储(数据分片),降低数据丢失风险。
# 6. 分片
如果要实现Redis数据的分片,我们有三种方案:
- 第一种是在客户端实现相关的逻辑,例如用取模或者一致性哈希算法对key进行分片,查询和修改都先判断key的路由。
- 第二种是把做分片处理的逻辑抽取出来,运行一个独立的代理服务,客户端连接到这个代理服务,代理服务做请求的转发(如:Twemproxy、Codis)。
- 第三种就是基于服务端实现(Cluster方案)。
这里主要阐述一下客户端基于一致性Hash的分片机制:
GenericObjectPoolConfig<ShardedJedis> config = new GenericObjectPoolConfig<>();
JedisShardInfo shardInfoA = new JedisShardInfo("127.0.0.1", 6379);
JedisShardInfo shardInfoB = new JedisShardInfo("127.0.0.1", 6378);
JedisShardInfo shardInfoC = new JedisShardInfo("127.0.0.1", 6377);
ShardedJedisPool shardedJedisPool = new ShardedJedisPool(config, Arrays.asList(shardInfoA, shardInfoB, shardInfoC));
ShardedJedis resource = shardedJedisPool.getResource();
resource.set("", "");
// 后续操作
//....................
2
3
4
5
6
7
8
9
10
11
其实我们常见的一种分配方式有基于哈希取模 (hash(key) % n)
的方式,但是这种方式属于一种静态的分配方式,取模后的值受到N值影响,在这里也就是受到节点数量的影响,当节点数量变化后,取摸计算的值就不一样了。由此大神们又考虑了新的解决方案,基于hash算法的变种算法(一致性哈希算法
)。
# 6.1. 一致性哈希算法原理
把所有的哈希值空间组织成一个虚拟的圆环(哈希环),整个空间按顺时针方向组织。因为是环形空间,0和2^32-1是重叠的。
- 假设我们有四台机器要哈希环来实现映射(分布数据),我们先根据机器的名称或IP计算哈希值,然后分布到哈希环中(红点)。
- 现有4条数据或4个访问请求,对key计算后,得到哈希环中的位置(绿点),沿哈希环顺时针找到的第一个Node,就是数据存储的节点。
- 在这种情况下,新增了一个Node5节点,只影响一部分数据的分布。
- 删除了一个节点Node4,只影响相邻的一个节点,请求数据顺延至下一个相邻节点。
- 一致性哈希解决 了动态增减节点时,所有数据都需要重新分布的问题,它只会影响到下一个相邻的节点,对其他节点没有影响。但是这样的一致性哈希算法有一个缺点,因为节点不一定是均匀地分布的,特别是在节点数比较少的情况下,所以数据不能得到均匀分布。解决这个问题的办法是引入虚拟节点(Virtual Node)。 比如: 2个节点,5条数据,只有1条分布到Node2, 4条分布到Node1,不均匀。
- Node1设置了两个虚拟节点,Node2也设置了两个虚拟节点(紫色点)。这时候有3条数据分布到Node1,2条数据分布到Node2。
- 一致性哈希在分布式系统中,负载均衡、分库分表等场景中都有应用,跟LRU一样,是一个很基础的算法。
# 7. 集群
Redis Cluster是在Redis 3.0的版本正式推出的,用来解决分布式的需求,同时也可以实现高可用。它是去中心化的,客户端可以连接到任意一个可用节点,redis基于自身服务端实现的一种去中心话的集群分布式实现方案;
在数据分片中有几个关键的问题需要解决:
- 数据怎么相对均匀地分片;
- 客户端怎么访问到相应的节点和数据;
- 重新分片的过程,怎么保证正常服务。
Redis Cluster可以看成是由多个Redis实例组成的数据集合。客户端不需要关注数据的子集到底存储在哪个节点,只需要关注这个集合整体。以3主3从为例,节点之间两两交互,共享数据分片、节点状态等信息。
Redis Cluster中的每个节点都维护一份自己当前整个集群的状态,主要包括:
- 当前集群状态;
- 集群中各节点所负责的 slots 信息,及其 migrate 状态;
- 集群中各节点的 master-slave 状态;
- 集群中各节点的存活状态及怀疑 Fail 状态。
集群搭建可参考:redis_cluster.html (opens new window)
客户端接入集群实例:
Set<HostAndPort> nodes = new HashSet<>();
nodes.add(HostAndPort.from("127.0.0.1:6379"));
nodes.add(HostAndPort.from("127.0.0.1:6378"));
nodes.add(HostAndPort.from("127.0.0.1:6377"));
nodes.add(HostAndPort.from("127.0.0.1:7379"));
nodes.add(HostAndPort.from("127.0.0.1:7378"));
nodes.add(HostAndPort.from("127.0.0.1:7377"));
JedisCluster jedisCluster = new JedisCluster(nodes);
String set = jedisCluster.set("cluster_hello_key", "cluster_hello_value");
System.err.println(set);
jedisCluster.close();
2
3
4
5
6
7
8
9
10
11
集群命令参数:
# cluster
cluster info #打印集群的信息
cluster nodes #列出集群当前已知的所有节点(node),以及这些节点的相关信息
# nodes
cluster meet<ip> <port> #将 ip 和 port 所指定的节点添加到集群当中,让它成为集群的一份子
cluster forget <node_id> #从集群中移除 node_id 指定的节点(保证空槽道)
cluster replicate <node_id> #将当前节点设置为 node_id 指定的节点的从节点
cluster saveconfig #将节点的配置文件保存到硬盘里面
# slot
cluster addslots <slot> [slot ...] #将一个或多个槽(slot)指派(assign)给当前节点
cluster delslots <slot> [slot ...] #移除一个或多个槽对当前节点的指派
cluster flushslots #移除指派给当前节点的所有槽,让当前节点变成一个没有指派任何槽的节点
cluster setslot node <node_id> #将槽 slot 指派给 node_id 指定的节点,如果槽已经指派给另一个节点,那么先让另一个节点删除该槽>,然后再进行指派
cluster setslot <slot> migrating <node_id> #将本节点的槽 slot 迁移到node_id指定的节点中
cluster setslot<slot> importing <node_id> #从 node_id 指定的节点中导入槽 slot 到本节点
cluster setslot <slot> stable #取消对槽 slot 的导入(import)或者迁移(migrate)
# key
cluster keyslot <key> #计算键 key 应该被放置在哪个槽上
cluster countkeysinslot <slot> #返回槽 slot 目前包含的键值对数量
cluster getkeysinslot <slot> <count> #返回 count 个 slot 槽中的键
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
下面主要总结Redis集群的原理: Redis Cluster是无中心架构,数据存储按照slot存储分布在多个节点,节点间数据共享,可动态调整数据分布;且可线性扩展到1000个节点(官方推荐不超过1000个) ,节点可动态添加或删除。 当部分节点不可用时,集群仍可用,通过增加Slave做standby数据副本,能够实现故障自动failover,节点之间通过gossip[/ˈɡɑːsɪp/]协议^2 (opens new window)交换状态信息,用投票机制完成Slave到Master的角色提升。
- 数据基于Slot分布 Redis既没有用哈希取模,也没有用一致性哈希,而是用虚拟槽来实现的。Redis创建了16384个槽(slot) ,每个节点负责一定区间的slot。比如Node1负责0-5461, Node2负责5462-10922,Node3负责11282-16383。
对象分布到Redis节点上时,对key用CRC16算法^3 (opens new window)计算再%16384^4 (opens new window),得到一个slot的值,数据落到负责这个slot的Redis节点上。Redis的每个master节点都会维护自己负责的slot,用一个bit序列实现,比如:序列的第0位是1,就代表第一个slot是它负责;序列的第1位是0,代表第二个slot不归它负责。 存储数据key计算对应的slot是不会被改变的,会改变只是slot与Redis节点的关系,因为一个节点分配的slot是可以改变的,这样就保证了数据分配不会受到节点数量变化导致不均匀的问题,数据是否合理分配只受到slot分配的影响。
正常情况下,数据存储是根据存储key计算获取对应的slot位置的,当然由于应用场景的要求,不想让通过计算分配到不同的节点上,而是想让多个key的数据坐落在同一个节点上,那么可以使用哈希标签(Key hash tags)实现,如:user{test}:info=12314 \n user{test}:account=12314
,大括号中的值就是用于计算slot的位置的。
客户端重定向 在集群中,当客户端访问某一个节点时,通过key计算的slot不在当前链接的节点上时,服务器会返回目标slot所属的服务器节点,然后在重新向至目标服务节点过程。不过开发接入Redis时,客户端程序一般会换成服务节点对应的slot,无需重定向。
故障转移 当发生故障或者节点增加下线时,需要迁移数据。由于key和slot的关系是永远不会变的,当新增了节点的时候,需要把原有的slot分配给新的节点负责,并且把相关的数据迁移过来
redis-cli --cluster add-node 127.0.0.1:7291 127.0.0.1:7297
#新增的节点没有哈希槽,不能分布数据,在原来的任意一个节点上执行
redis-cli --cluster reshard 127.0.0.1:7291
2
3
主从切换 当slave发现自己的master变为FAIL状态时,便尝试进行Failover,以便成为新的master。由于挂掉的master可能会有多个slave,从而存在多个slave竞争成为master节点的过程,其过程如下:
- slave发现自己的master变为FAIL
- 将自己记录的集群currentEpoch加1,并广播FAILOVER_AUTH_REQUEST信息
- 其他节点收到该信息,只有master响应,判断请求者的合法性,并发送FAILOVER_AUTH_ACK,对每一个epoch只发送一次ack
- 尝试failover的slave收集FAILOVER_AUTH_ACK
- 超过半数后变成新Master
- 广播Pong通知其他集群节点。
Redis Cluster既能够实现主从的角色分配,又能够实现主从切换,相当于集成了Replication和Sentinal的功能。
为什么是16384(2^14)个?
在redis节点发送心跳包时需要把所有的槽放到这个心跳包里,以便让节点知道当前集群信息,16384=16k,在发送心跳包时使用char进行bitmap压缩后是2k(2 * 8 (8 bit) * 1024(1k) = 16K),也就是说使用2k的空间创建了16k的槽数。 虽然使用CRC16算法最多可以分配65535(2^16-1)个槽位,65535=65k,压缩后就是8k(8 * 8 (8 bit) * 1024(1k) =65K),也就是说需要需要8k的心跳包,作者认为这样做不太值得;并且一般情况下一个redis集群不会有超过1000个master节点,所以16k的槽位是个比较合适的选择。
# 8. 缓存过期策略
既然Redis是内存数据库,那么不可能任由应用不限的往里面加入数据,这样岂不是把物理内存撑爆了;当然这么牛逼的数据库肯定有一套成熟的机制了。
在Redis中有两套内存回收方案,一种通过key过期的方式回收资源,另一种是基于内存阀闸(maxmemory的值)控制回收的。
# 8.1. key过期策略
- 定时淘汰 每个设置过期时间的key都需要创建一个定时器,到过期时间就会立即清除。该策略可以立即清除过期的数据,对内存很友好;但是会占用大量的CPU资源去处理过期的数据,从而影响缓存的响应时间和吞吐量。
- 惰性淘汰 只有当访问一个key的时候,判断key是否过期,若过期了则马上清理掉,这中策略相对于定时淘汰倒是节省了很多CPU资源,但是对内存来说不是特别友好,因为只有访问key的时候才会判断,在极端情况下,若没有key被访问,那么这些key就会占用大量的内存。
- 定期淘汰 每隔一定的时间,会扫描一定数量的数据库的expires字典中一定数量的key,并清除其中已过期的key。该策略是前两者的一个折中方案。通过调整定时扫描的时间间隔和每次扫描的限定耗时,可以在不同情况下使得CPU 和内存资源达到最优的平衡效果。
其实,Redis中同时使用了惰性过期和定期过期两种过期策略。
# 8.2. 内存淘汰策略
Redis 的内存淘汰策略,是指当内存使用达到最大内存极限时,需要使用淘汰算法来决定清理掉哪些数据,以保证新数据的存入。redis最大内存的设置使用属性maxmemory <bytes>
配置
如果不设置maxmemory
或者设置为0,64位系统不限制内存,32位系统最多使用3GB内存。当内存达到maxmemory
阀值时,会根据内存回收策略回收资源,内存回收策略可以通过一下命令进行配置:
maxmemory-policy noeviction
# 或者
config set maxmemory-policy noeviction
2
3
各种策略含义如下:
策略 | 说明 |
---|---|
volatile-lru | 根据 LRU 算法删除设置了超时属性(expire)的键,直到腾出足够内存为止。如果没有可删除的键对象,回退到 noeviction 策略。 |
allkeys-lru | 根据 LRU 算法删除键,不管数据有没有设置超时属性,直到腾出足够内存为止。 |
volatile-lfu | 在带有过期时间的键中选择最不常用的。 |
allkeys-lfu | 在所有的键中选择最不常用的,不管数据有没有设置超时属性。 |
volatile-random | 在带有过期时间的键中随机选择。 |
allkeys-random | 随机删除所有键,直到腾出足够内存为止。 |
volatile-ttl | 根据键值对象的 ttl 属性,删除最近将要过期数据。如果没有,回退到 noeviction 策略 |
noeviction | 默认策略,不会删除任何数据,拒绝所有写入操作并返回客户端错误信息(error)OOM command not allowed when used memory,此时 Redis 只响应读操作。 |
关于内存策略可参考官方文档说明:https://redis.io/topics/lru-cache
# 9. 数据持久化
Redis 速度快,很大一部分原因是因为它所有的数据都存储在内存中。如果断电或者宕机,都会导致内存中的数据丢失。为了实现重启后数据不丢失,Redis提供了两种持久化的方案,一种是RDB快照(RedisDataBase),一种是AOF(Append OnlyFile)
- RDB RDB 是 Redis 默认的持久化方案。当满足一定条件的时候,会把当前内存中的数据写入磁盘,生成一个快照文件dump.rdb(可配置)。Redis重启会通过加载dump.rdb文件恢复数据。 通常出发RDB持久化时,可以通过自动触发和手动触发两种的。
自动触发规则
save 900 1 # 900 秒内至少有一个key被修改(包括添加) save 400 10 # 400 秒内至少有10个key 被修改 save 60 10000 # 60 秒内至少有10000个key被修改
1
2
3- 服务通过
shutdown
命令正常关闭时也会触发RDB
- 服务通过
手动触发 手动触发可以通过命令
save
和bgsave
,顾名思义bgsave
机制会通过fork子线程方式生成快照的,而save
命令是会阻塞当前Redis服务器的,一般不建议使用,因为当数据量很大时,耗时比较长,而bgsave
只会在fork子线程的时候阻塞,暂停时间很短暂。
- AOF 在Redis中默认不开启。AOF采用日志的形式来记录每个写操作,并追加到文件中。开启后,执行更改Redis数据的命令时,就会把命令写入到AOF 文件中。Redis重启时会根据日志文件的内容把写指令从前到后执行一次以完成数据的恢复工作。
启用aof配置
# 开关
appendonly no
# 文件名
appendfilename "appendonly.aof"
appendfsync everysec #aof写入时机
2
3
4
5
在redis中记录日志aof文件也是有时机的,可以通过配置appendfsync
来配置,由于操作系统的缓存机制,aof数据并不会真正的写入到磁盘,而是进入到磁盘的一个缓存区域,当到达触发时机时,会刷入磁盘中。redis提供如下三中机制:
- no 表示不执行 fsync,由操作系统保证数据同步到磁盘,速度最快,但是不太安全;
- always 表示每次写入都执行 fsync,以保证数据同步到磁盘,效率很低;
- everysec 表示每秒执行一次 fsync,可能会导致丢失这 1s 数据。通常选择 everysec ,兼顾安全性和效率。
既然aof时以日志的形式记录Redis中记录命令呢,其实就跟程序开发记录日志一样,肯定得有一种日志归档压缩机制,不然aof就会无限的增大,浪费空间了。
其实Redis也提供了相关机制,那就日志重写机制,当日志文件达到设置的大小时就会触发重写机制,当然也可以通过命令bgrewrieaof
手动触发。
# 重写触发机制
auto-aof-rewrite-percentage 100
auto-aof-rewrite-min-size 64mb
no-appendfsync-on-rewrite no
aof-load-truncated yes
2
3
4
5
相关配置:
配置项 | 说明 |
---|---|
auto-aof-rewrite-percentage | 默认值为100。aof自动重写配置,当目前aof文件大小超过上一次重写的aof文件大小的百分之多少进行重写, 即当aof文件增长到一定大小的时候,Redis能够调用bgrewriteaof 自动启动新的日志重写过程。 |
auto-aof-rewrite-min-size | 默认64M。设置允许重写的最小 aof 文件大小,避免了达到约定百分比但尺寸仍然很小的情况还要重写。 |
no-appendfsync-on-rewrite | 在aof重写或者写入rdb文件的时候,会执行大量IO,此时对于everysec和always的aof模式来说,执行fsync会造成阻塞过长时间,no-appendfsync-on-rewrite字段设置为默认设置为no。如果对延迟要求很高的应用,这个字段可以设置为yes,否则还是设置为no,这样对持久化特性来说这是更安全的选择。设置为yes表示rewrite期间对新写操作不fsync,暂时存在内存中,等rewrite完成后再写入,默认为no,建议修改为yes。Linux的默认fsync策略是30秒。可能丢失30秒数据。 |
aof-load-truncated | aof文件可能在尾部是不完整的,当redis启动的时候,aof文件的数据被载入内存。重启可能发生在redis所在的主机操作系统宕机后,尤其在ext4文件系统没有加上data=ordered选项,出现这种现象。redis宕机或者异常终止不会造成尾部不完整现象,可以选择让redis退出,或者导入尽可能多的数据。如果选择的是yes,当截断的aof文件被导入的时候,会自动发布一个log给客户端然后load。如果是no,用户必须手动redis-check-aof修复AOF文件才可以。默认值为yes。 |
RDB是一个非常紧凑(compact)的文件,它保存了redis在某个时间点上的数据集。这种文件非常适合用于进行备份和灾难恢复。生成 RDB 文件的时候,redis 主进程会 fork()一个子进程来处理所有保存工作,主进程不需要进行任何磁盘IO操作。 RDB在恢复大数据集时的速度比AOF 的恢复速度要快。
RDB方式数据没办法做到实时持久化/秒级持久化。因为bgsave每次运行都要执行fork 操作创建子进程,频繁执行成本过高。在一定间隔时间做一次备份,所以如果redis意外down掉的话,就会丢失最后一次快照之后的所有修改(数据有丢失)。
而AOF持久化的方法提供了多种的同步频率,即使使用默认的同步频率每秒同步一次,Redis最多也就丢失1秒的数据而已。 对于具有相同数据的的Redis,AOF文件通常会比RDF文件体积更大(RDB存的是数据快照) 虽然 AOF 提供了多种同步的频率,默认情况下,每秒同步一次的频率也具有较高的性能。在高并发的情况下,RDB比AOF具好更好的性能保证。
如果数据相对来说比较重要,希望将损失降到最小,则可以使用AOF方式进行持久化,当然也可以二者结合使用。
# 10. 数据一致性
# 10.1. 缓存使用场景
针对读多写少的高并发场景,我们可以使用缓存来提升查询速度。 当我们使用Redis作为缓存的时候,一般流程是这样的:
- 如果数据在Redis存在,应用就可以直接从Redis拿到数据,不用访问数据库。
- 如果Redis里面没有,先到数据库查询,然后写入到Redis,再返回给应用。
# 10.2. 一致性问题定义
由于缓存的数据是很少修改的,所以在绝大部分的情况下可以命中缓存。但是,一旦被缓存的数据发生变化的时候,我们既要操作数据库的数据,也要操作Redis的数据,所以问题来了。 现在我们有两种选择:先操作Redis 的数据再操作数据库的数据;先操作数据库的数据再操作Redis。
那么我们底选哪一种? 首先需要明确不管选择哪一种方案,我们肯定是希望两个操作要么都成功,要么都一个都不成功。不然就会发生Redis跟数据库的数据不一致的问题。但是Redis的数据和数据库的数据是不可能通过事务达到统一的,我们只能根据相应的场景和所需要付出的代价来采取一些措施降低数据不一致的问题出现的概率,在数据一致性和性能之间取得一个权衡。 对于数据库的实时性一致性要求不是特别高的场合,比如T+1的报表,可以采用定时任务查询数据库数据同步到Redis的方案。由于我们是以数据库的数据为准的,所以给缓存设置一个过期时间,是保证最终一致性的解决方案。
通常我们将高频数据添加到Redis时并不是直接从数据库获取写入的,而是需要根据业务流程或者接口计算后在写入Redis的,一般都不直接删除Redis中已有的数据,而不会使用相关更新命令set
替换旧数据的;那么这里由引出一个问题,更新Redis的数据和更新数据的操作需要先操作哪一个呢?这里就需要做一个权衡了:
- 先更新数据库,再删除缓存
正常情况:更新数据库成功,删除缓存成功 异常情况:更新数据库失败,捕获异常,不做删除缓存处理,数据不会出现不一致的问题;更新数据库成功,删除缓存失败,数据库已经更新了,缓存清理失败,数据存在不一致问题
- 先删除缓存,再更新数据库
正常情况:删除缓存成功,更数据库成功 异常情况:删除缓存失败,捕获异常,不操作数据,数据不会出现不一致问题;删除缓存成功,更新数据库失败,数据需要以数据库为准,那么不会出现数据一致性问题。 但是,当出现多线程并发访问时,会出问题: 线程A需要更新数据,首先删除了Redis缓存; 线程B查询数据,发现缓存不存在,到数据库查询旧值,写入Redis,返回; 线程A更新了数据库,这个时候,Redis是旧的值,数据库是新的值,发生了数据不一致的情况。
针对上述出现的场景,怎么取舍,是哪一种,需要根据实际业务场景选择。同时,出现上述异常情况问题,我们不能不管不顾,也需要做一些补偿确保可靠性。对于第1种可以采取当缓存删除失败后,可以使用重试补偿机制,直到最终删除成功,当然这里的重试不能影响我们正常业务,可以考虑异步删除(消息队列、异步线程重试等等)。第2种可以使用加锁方式控制保证原子性,但是这种方式太耗性能,吞吐量太低;这时候可以考虑使用延时双删方案,也就时当线程删缓存,更新数据库后,延长一段时间后,再次删除缓存,保证缓存最终一致性。
至于到底使用哪一种,需要根据业务场景做一个取舍。
# 11. 为何如此快
Redis之所以快,因为它时全内存数据存储方式,单线程处理方法,在IO上采用用了多路复用(evport/epoll/kqueue/select),而且它的全库数据都是采用key-value方式存储的,在不考虑其他因素的情况下时间复杂度为O(1)。
为什么Redis被设计为单线程的呢?正常不应该是多线程并发处理会更快吗? 首先Redis是基于内存存储数据的,而且单线程处理,没有创建线程、销毁线程带来的开销,没有线程获取CPU时间片上下文切换的开销,同时也避免了多线程竞争资源的问题。 而且官方也说了单线程已经够用了,CPU不是redis的瓶颈。Redis 的瓶颈最有可能是机器内存或者网络带宽,既然单线程容易实现,CPU不会成为瓶颈,那就顺理成章地采用单线程的方案了。官方说明:https://redis.io/topics/faq (opens new window)
# 12. 海量高并发下产生的问题
# 12.1. 如何发现热点数据
通常我们会把系统配置数据、热点搜索、高频热点话等认为热点的数据缓存起来,提高读取数据效率。但是一般情况怎么判断那些数据时热点数据,需要放入缓存呢?
如果需要判断热点数据,那么就需要一种数据统计分析机制,可以使用第三方数据统计软件、客户端链接监控、TCP抓包等等手段实现。当前面对大数据场景下,具有一定规模的单位都会有数据分析系统,可以通过数据分析系统实现获取热点数据。
# 12.2. 发生缓存雪崩
缓存雪崩就是Redis的大量热点数据同时过期(失效),因为设置了相同的过期时间,刚好这个时候Redis请求的并发量又很大,就会导致所有的请求落到数据库。为了避免缓存雪崩,可以采取一下措施:
- 加互斥锁或者使用队列,针对同一个key只允许一个线程到数据库查询,这种数据获取会影响吞吐量,可以使用在一些特殊的场景;
- 缓存定时预先更新,避免同时失效,可以启用单独定时任务实现;
- 通过加随机数,使key在不同的时间过期
- 缓存永不过期,不太现实,会导致内存压力过大,当达到设置的内存阀值时也会被清理。
# 12.3. 出现缓存穿透&缓存击穿
缓存穿透:是指应用程序使用同一个key不断的向数据库和缓存数据库Redis请求数据,但是Redis和数据都没有数据,这就导致不可能命中缓存和查询到数据,当并发量极大时,数据库压力就会非常大,其实这种可以理解为非正常的请求了,网关应该要拦截的。
缓存击穿: 是指缓存中没有数据而数据库中有的数据(一般是缓存时间到期),这时由于并发用户特别多,同时读缓存没读到数据,又同时去数据库去取数据,引起数据库压力瞬间增大,造成过大压力。注意这里于雪崩性质不一样,雪崩是大量的key同时过期,这里是应用程序访问同一个key。
对于缓存穿透,我们可以在缓存外层加一层拦截器,请求到达拦截器时,先判断是否存在,若不存在则不做处理;否则继续后续处理,这里判断数据是否存在可以使用布隆过滤器^5 (opens new window)实现。 对于缓存击穿,可以实现限流、分流相关机制缓解大量请求处理。