Redis 持久化
Redis 为了达到最快的读写速度将数据都读到内存中,并通过异步的方式将数据写入磁盘。所以 redis 具有快速和数据持久化的特征。如果不将数据放在内存中,磁盘 I/O 速度为严重影响 redis 的性能。在内存越来越便宜的今天,redis 将会越来越受欢迎。
如果设置了最大使用的内存,则数据已有记录数达到内存限值后不能继续插入新值。
不过 Redis 也提供了持久化的选项。
Redis 为了达到最快的读写速度将数据都读到内存中,并通过异步的方式将数据写入磁盘。所以 redis 具有快速和数据持久化的特征。如果不将数据放在内存中,磁盘 I/O 速度为严重影响 redis 的性能。在内存越来越便宜的今天,redis 将会越来越受欢迎。
如果设置了最大使用的内存,则数据已有记录数达到内存限值后不能继续插入新值。
不过 Redis 也提供了持久化的选项。
容量:512M
1 | set aa 'str' |
操作总结
1 | set/get/del/append/strlen key |
字符串、jpg图片、序列化对象、一些复杂的计数功能的缓存
存储 String 类型键值对的映射表、对象
容量:每个 Hash 可存 2^32 - 1(约 40 亿)个键值对
1 | hmset user username 'name' password '123456' # 定义一个有两个元素的Hash表 |
单点登录(存<CookieId, 用户信息>,设置 30 分钟为缓存过期时间,能很好地模拟出类似 Session 的效果)。
String列表
容量:每个 List 可存 2^32 - 1 个元素
1 | lpush ball basketball soccer # 按顺序从左侧压入,如果ball列表不存在则创建 |
操作总结
1 | lpush/rpush/lrange |
简单的消息队列
基于 Redis 的分页功能(利用 lrang 命令,性能极佳,用户体验好)
字符串的无序集合,使用 Hash 实现(key 和 value 相同的 Hash)
容量:2^32 - 1 个成员
1 | sadd myset li1 # 向集合myset中添加一个元素li1,若不存在则创建 |
全局去重(为什么不用 JDK 自带的 Set 去重?因为我们的系统一般都是集群部署)
计算共同喜好、全部喜好、自己独有的喜好等功能(交集、并集、差集)
字符串的有序集合,每个元素都关联一个 double 类型的权重参数 score,集合中的元素能够按 score 进行排列。
容量:2^32 - 1 个成员
1 | zadd myzset 0 abc |
排行榜
取 Top N 操作
延时任务(https://www.cnblogs.com/rjzheng/p/8972725.html)
范围查找
实现上类似于 Java 的 SortedSet 和 HashMap 的结合体,value 唯一(Set 结构的特点),每个 value 一个 score 代表该 value 的排序权重。
zset 内部是使用一种叫做跳跃列表的结构实现的。
Redis 中的 set、zset 等结构在 Redis 中并不是由一个单独的数据结构实现的,而是会根据情况有所变化。
set和Java中的HashSet有点像,它本身是HashMap的封装,key是集合中的对象,而value直接用NULL代替。
但是注意一些特殊情况:
t_set.c/setTypeCreate()
zset同样有两种形态:ziplist编码和skiplist编码。
执行zadd命令代码:t_zset.c/zaddGenericCommand()
创建zset对象:object.c/createZsetZiplistObject()
、object.c/createZsetObject()
Redis 中的动态数组有以下特点:
len
属性和free
属性的存在,对于修改字符串SDS实现了空间预分配和惰性空间释放两种策略:1 | struct SDS<T> { |
下面的函数将 t 数组拷贝到 s 中,如果长度不够则需要进行扩容:
1 | /* Append the specified binary-safe string pointed by 't' of 'len' bytes to the |
SDS 有 embstr 和 raw 两种存储结构,它们的区别是:
Redis 早期版本存储 list 数据结构采用(元素少时 ziplist、多时 linkedlist )的方案,但是:
因此在之后的版本中转换为了 quicklist 存储。
quicklist 是 ziplist 和 linkedlist 的混合体,它将 linkedlist 按段切分,每一段使用 ziplist 来紧凑存储,多个 ziplist 之间使用双向指针串接起来。
1 | struct ziplist { |
ziplist 是一种压缩存储的数组结构,当 Redis 中的集合数据结构很小,则它会使用这种紧凑的存储形式存储,元素之间紧挨着存储,查找就是对数组进行遍历找到目标对象。
1 | import redis |
可以上服务器上使用debug object
命令验证数据结构的类型:
1 | > zadd hgc_test 1.0 go 2.0 python 2.0 java |
1 | struct ziplist<T> { |
00xxxxxx
表示最大长度为 63 的短字符串,01xxxxxx xxxxxxxx
表示中等长度的字符串;ziplist 是紧凑存储的,没有冗余空间,因此插入一个新元素就需要调用 realloc 重新分配内存空间,并将之前的内容一次性拷贝到新的内存空间中。
重新分配空间是比较耗时的,因此 ziplist 不适合存储大量数据。
修改或删除一个元素后其后一个位置的元素中的 prevlen 也需要级联更新,prevlen 字段又是变长的,所以可能会导致连锁反应。
为什么 hash 结构中会采用 ziplist 而不是 dict,主要原因如下:
dict 是 Redis 中使用最广泛的数据结构:
1 | struct RedisDb { |
1 | struct zset { |
dict 中的 hashtable 结构和 Java 中的 HashMap 类似,使用一个数组来保存所有的哈希桶,通过siphash函数来将 key 散列到数组中的某个桶上,每个哈希桶都是一个链表,也就是说如果发生哈希冲突,则将新元素直接插入到桶的头部。
1 | struct dictEntry { |
正常情况下,当 hashtable 中元素的个数等于第一维数组的长度时、来了一个新增/修改/删除操作,就会触发扩容,扩容的新数组是原数组大小的 2 倍。
存在一个特殊情况:如果 Redis 正在做 bgsave,为了减少内存页的过多分离 (Copy On Write),Redis 尽量不去扩容 (
dict_can_resize
),但是如果 hash 表已经非常满了,元素的个数已经达到了第一维数组长度的 5 倍 (dict_force_resize_ratio
),说明 hash 表已经过于拥挤了,这个时候就会强制扩容。
一般情况下 dict 中只有一个 hashtable 有值,但是在扩容时会分配另一个新的 hashtable,然后执行渐进式的数据迁移,避免一次性对所有 key 执行 rehash,而是将 rehash 操作分散到了对 dict 的各个增删改查操作中去了。
dict 之所以这样设计,是为了避免 rehash 期间单个请求的响应时间剧烈增加。
当旧 hashtable 中无元素时,即代表迁移完毕,这时会将新旧 hashtable 的指针交换,旧的会被删除,而新的则取而代之。
当 hash 表因为元素的逐渐删除变得越来越稀疏时,Redis 会对 hash 表进行缩容来减少 hash 表的第一维数组空间占用。缩容的条件是元素个数低于数组长度的 10%。
缩容不会考虑 Redis 是否正在做 bgsave,因为 COW 的特性是当内存页上的数据被修改时会复制一页做修改,如果删除操作并不会触发删除内存页的数据,操作系统回收内存机制导致的。
get a
和llen b
中的a和b是不同数据结构的对象,他们统统被存储在一个叫全局哈希表的地方。
哈希表中的每个哈希桶存储的不是值本身,而是指向它们的指针。
代码定义在:redis.h/redisDb
缺点:
全局哈希表实际上就是dict,可以看源码中的定义。
优点:
O(1)
的操作复杂度。zset 中除了 dict(字典)外,还会用一个 skiplist 来提供按score排序的要求,以实现指定 score 的范围来获取 value 列表的功能。
1 | struct zslnode { |
插入时会先自顶向下找到新节点在跳表中底层的插入位置,插入每一层时都有概率晋升到更高一层,在 Redis 中是 25%。
删除每一层上的对应节点。
如果不影响排序则直接更新,否则会先删除再重新插入。
布隆过滤器用于实现contains
的需求,而 HyperLogLog 主要用于实现count
。
同样是一个特别大的位数组,HyperLogLog 将位数组拆分为桶,每个桶是连续的 6 个位,计数时并非单独对某个桶计数,而是:
count = max(count, oldCount)
,即每次记录最大的计数。用于实现轻量级的发布订阅功能。
当数据量少时,使用hash明显更加节省内存,因为数据少时hash会转成ziplist的结构,而string每个kv都需要一大堆的额外空间存储元数据。
不会,还有一个定时任务每隔100ms执行rehash,而且每次执行时长不会超过1ms,以免影响其他任务。
Master
TODO
Slave
Sentinel
获取集群信息
1 | redis-cli -p 26379 info Sentinel |
获取 master 节点地址
1 | redis-cli -p 26379 SENTINEL get-master-addr-by-name mymaster |
在 Sentinel 模式下,客户端不是直接连接服务器的,而是先访问 Sentinel 拿到集群信息再尝试连接 Master。当 Master 发生故障时,客户端会重新向 Sentinel 要地址,并自动完成节点切换。
其实 Sentinel 的内核与其他形式的 Redis 服务器基本一致,只是支持的命令不同、负责的任务也不同。
同理,客户端也可以通过pubsub功能来订阅集群中的其他信息,关键事件如下:
在Sentinel的主事件循环中可以看到它每100毫秒执行的定时任务:
1 | int serverCron(struct aeEventLoop *eventLoop, long long id, void *clientData) { |
PING 命令
。PING
命令的时间超过 down-after-milliseconds
选项所指定的值, 那么这个实例会被 Sentinel 标记为主观下线。 一个有效回复可以是: +PONG 、 -LOADING 或者 -MASTERDOWN。1 | void sentinelHandleDictOfRedisInstances(dict *instances) { |
1 | /* Perform scheduled operations for the specified Redis instance. */ |
1 | /* Perform scheduled operations for the specified Redis instance. */ |
INFO 命令
。 当一个主服务器被 Sentinel 标记为客观下线时, Sentinel 向下线主服务器的所有从服务器发送 INFO 命令的频率会从 10 秒一次改为每秒一次。1 | void sentinelSendPeriodicCommands(sentinelRedisInstance *ri) { |
sentinelInfoReplyCallback
:1 | // 处理 INFO 命令的回复 |
1 | void sentinelHandleRedisInstance(sentinelRedisInstance *ri) { |
Sentinel服务器只需配置Master的地址,其他Slave的信息是通过定时(10秒)向Master发送info命令来获取的,info命令返回的信息中,包含了主从拓扑关系,其中包括每个slave的地址和端口号。有了这些信息后,哨兵就会记住这些节点的拓扑信息,在后续发生故障时,选择合适的slave节点进行故障恢复。
哨兵除了向master发送info之外,还会向每个master节点特殊的pubsub
中发送master当前的状态信息和哨兵自身的信息,其他哨兵节点通过订阅这个pubsub,就可以拿到每个哨兵发来的信息。这么做的目的主要有2个:
故障发生时,需要立即启动故障恢复机制,那么Sentinel怎么及时地知道哪些节点发生故障了呢?这主要是通过向所有其他节点发送PING命令
来实现的。
每个哨兵节点每隔1秒向master、slave、其他哨兵节点发送ping命令,如果对方能在指定时间内响应,说明节点健康存活。如果未在规定时间内(可配置)响应,那么该哨兵节点认为此节点主观下线。
至于Sentinel怎么知道其他节点的地址,其实就是通过前面提到的info命令来感知的。
master-down-after-milliseconds
选项所指定的时间内, 对向它发送 PING 命令
的 Sentinel 返回一个有效回复(有效回复只有+PONG、-LOADING 错误或 -MASTERDOWN 错误), 那么 Sentinel 就会将这个服务器标记为主观下线。注意是在
master-down-after-milliseconds
时间内一直返回无效回复。
is-master-down-by-addr
命令互相交流之后,得出的服务器下线判断。 (一个 Sentinel 可以通过向另一个 Sentinel 发送 SENTINEL is-master-down-by-addr 命令来询问对方是否认为给定的服务器已下线。)客观下线只适用于 Master,对 Slave 或 Sentinel 则不会达到客观下线状态。
单纯的主从架构并不能挽救 Master 挂掉的情况,因此引入了 Sentinel 集群。Sentinel 会不断地检查集群主服务器和从服务器是否运作正常,并在超过 n 个 Sentinel 同意后判断主节点失效(配置sentinel monitor mymaster 127.0.0.1 6379 2
表示这个n=2),不过要注意,无论设置多少个 Sentinel 同意才能判断一个服务器失效, 一个 Sentinel 都需要获得系统中多数 Sentinel 的支持, 才能发起一次自动故障迁移。
故障转移主要分为Sentinel选举和故障转移(Master替换)两个步骤,Sentinel选主流程如下:
Raft leader election
算法选举 Sentinel 中的 Leader,对我们的当前 epoch 进行自增, 并尝试在这个epoch中当选,之后,所有 Sentinel 都以更高的 epoch 为准,并主动用更新的 epoch 代替自己的配置。Slave选举的规则如下:
也就是说,多个Slave的优先级按照:slave-priority配置 > 数据完整性 > runid较小者进行选择。
之后所有Sentinel要进行投票选出一个Leader:
选出Leader后,Leader需要从现有的Slave中选出
提升新的Master的流程如下:
客户端感知新master流程如下:
哨兵在故障切换完成之后,会向自身节点的指定pubsub中写入一条信息,客户端可以订阅这个pubsub来感知master的变化通知。我们的客户端也可以通过在哨兵节点主动查询当前最新的master,来拿到最新的master地址。
另外,哨兵还提供了“钩子”机制,我们也可以在哨兵配置文件中配置一些脚本逻辑,在故障切换完成时,触发“钩子”逻辑,通知客户端发生了切换,让客户端重新在哨兵上获取最新的master地址。
一般来说,推荐采用第一种方式进行处理,很多客户端SDK中已经集成好了从哨兵节点获取最新master的方法,我们直接使用即可。
配置安全性:
epoch
一起被保存到磁盘;故障自动迁移的一致性:
网络分区(network partition)
时可能会有 Sentinel 包含老的配置,而当这个 Sentinel 服务器接收到其他 Sentinel 的版本更新配置时就会进行更新。min-slaves-to-write
选项, 让主服务器在连接的从实例少于给定数量时停止执行写操作, 与此同时, 应该在每个运行 Redis 主服务器或从服务器的机器上运行 Redis Sentinel 进程。故障迁移虽然能提供主从切换来保证挂掉的Master能被其他Slave顶替上来,但是这个顶替过程大概需要多长时间呢?具体又是哪些步骤会比较耗时?
判断Master下线
Sentinel会PING Master,如果距离上次PING成功的时间超过了master-down-after-milliseconds
时间,则表示主观下线了。
将Master标记为SDOWN后,这个Sentinel会通过发事件消息来通知其他Sentinel。
Cluster中是通过gossip协议来通知其他节点的。
重新选主
Slave提升
这个实时性的讨论并不是纯粹的极客行为,因为切换要多长时间是评估我们服务可用性的重要指标,并且提供后续优化的指导方向。
TILT 模式是一种特殊的保护模式,Sentinel 每隔 100ms 会向实例发一次PING
命令,并将上一次 PING 成功的时间和当前时间比对,从而知道与该实例有多长时间没有进行任何成功通讯:
Sentinel严重依赖计算机的时间功能,一旦计算机的时间功能出现故障, 或者计算机非常忙碌, 又或者进程因为某些原因而被阻塞时, Sentinel 可能也会跟着出现故障。
进入 TILT 模式后,Sentinel 仍然会继续监视所有目标,但是:
is-master-down-by-addr
命令时, Sentinel 返回负值: 因为这个 Sentinel 所进行的下线判断已经不再准确。TILT 相当于降级,如果 Sentinel 可以在 TILT 模式下正常维持 30s,那么 Sentinel 会退出 TILT 模式。
当 Lus 脚本执行时间超过阈值,Redis 会返回BUSY
错误,当出现这种情况时, Sentinel 在尝试执行故障转移操作之前, 会先向服务器发送一个 SCRIPT KILL
命令, 如果服务器正在执行的是一个只读脚本的话, 那么这个脚本就会被杀死, 服务器就会回到正常状态。
虽然Sentinel利用Raft选举不会发生脑裂,但是在一些极端的情况下还是有可能会发生脑裂的,比如:
这种脑裂并不会影响可用性,但是却破坏了数据的一致性,甚至会导致数据丢失:在Sentinel重连上原Master后,会将其归入到新Master的Slave,这时脑裂期间的数据就会被从新Master上复制过来的数据覆盖掉了,导致数据的丢失。
脑裂的解决办法主要是以下两个配置参数:
判断客户端下线是可以的,因为判断ODOWN的条件是有不少于quorum数量的Sentinel同意即可。
不可执行主从切换,因为一个哨兵要执行主从切换,得获得半数以上哨兵的投票同意,也就是3个哨兵。
哨兵实例越多,误判率越低,但是判断主库下线和选举Leader时实例要拿到的赞成票也越多,主从切换花费的时间也相对会更多。
如果客户端对Redis的响应时间有要求,则很有可能会报警。
这个值的作用是:判断距离上次PING成功的时间超过了这个值,就标记实例主观下线。
调大的话Sentinel需要更长的时间才能判断集群出问题了,也即影响到Redis的可用性。
redis cluster在设计的时候,就考虑到了去中心化,去中间件,也就是说,集群中的每个节点都是平等的关系,都是对等的,每个节点都保存各自的数据和整个集群的状态。所有的 redis 节点彼此互联(PING-PONG 机制),内部使用二进制协议优化传输速度和带宽,而且这些连接保持活跃,这样就保证了我们只需要连接集群中的任意一个节点,就可以获取到其他节点的数据。客户端与 redis 节点直连,不需要中间 proxy 层,客户端不需要连接集群所有节点,连接集群中任何一个可用节点即可。
Redis 不能保证强一致性,因为:
网络分区出现期间,客户端 Z1 可以向主节点 B 发送写命令的最大时间是有限制的, 这一时间限制称为节点超时时间(node timeout), 是 Redis 集群的一个重要的配置选项。
Codis 集群中包含了 4 类关键组件。
Codis如何处理一次请求:
以4个方面来讨论
比较:
分区将原来比较大的数据集分离存储到多个存储媒介上,分区后Redis可以管理更大的内存空间和计算能力,但同时多主机又会面临很多分布式集群的可用性、一致性等问题。
分区策略:
com.alibaba.dubbo.rpc.cluster.loadbalance.ConsistentHashLoadBalance
CRC16(key) % 16384
。所以我们在测试的时候看到 set 和 get 的时候,直接跳转到了 7000 端口的节点。源码中,Redis采用一个大小固定为CLUSTER_SLOTS
的clusterNode数组slots
来保存每个桶的负责节点,这是个字节数组,每个位表示当前节点是否负责这个槽:
1 | // 节点状态 |
分区可以在程序的不同层次实现。
Redis Cluster采用的是查询路由的方式。
在Cluster模式下,Redis接收任何命令都会首先计算键对应的桶编号,再根据桶找出所对应的节点,如果节点是自身,则处理键命令;否则回复MOVED
重定向错误,通知客户端请求正确的节点,这个过程称为MOVED重定向。
在客户端初次连接Redis集群时,如果客户端是Smart Client,它会获取集群的节点信息及slot的分布信息,并在本地缓存一份 hash slot 与node关系的路由表,这样不必每次访问服务器时都因为重定向而经过多次网络调用。
redis-cli不是smart client,它没有缓存路由表的功能;Java客户端Redisson是smart client,它在初始化时会调用redis实例的CLUSTER NODES
命令来获取集群中每个Master负责的slot范围,并启动一个定时任务来每秒刷新本地缓存的集群状态:
1 | /** |
下面是手动调用cluster nodes
可以得到的响应,从中可以看到每个master所负责的slot范围:
1 | hgc@hgc-X555LD:~$ redis-cli -h 10.32.64.12 -p 16371 -c |
如果Cluster发生了扩容缩容或failover导致客户端缓存的信息过期,客户端只需要MOVED时重新更新本地缓存即可。
但是这里有一个问题,如果扩容缩容时正在发生槽迁移,这时正在迁移中的槽在哪个节点是不确定的,可能会导致客户端本地缓存的频繁更新。因此,Redis迁移过程中,会对正在迁移的槽打标记(server.cluster->migrating_slots_to
),如果客户端访问的key命中了正在迁移中的槽,则服务器会返回ASK
而不是MOVED
,客户端接收到ASK
后不会重新更新本地的槽缓存。
代码:redis.c/processCommand
1 | /* If cluster is enabled perform the cluster redirection here. |
有些特性在分区的情况下会受到限制:
当要把Redis当作持久化存储时,需要注意分区的性质
Redis Cluster采用Gossip协议完成集群状态数据及路由数据等元数据的管理。
一种简单的集群内状态同步思路是:每次节点都将自己本地的集群状态数据广播到集群内所有N个节点,其他节点判断接收到的数据比本地的新则更新本地数据。但是这种方式的缺点是通信量剧增,网络带宽变得紧张。
因此Redis采用Gossip协议来进行集群内元数据的同步,而且:
1、每次只随机选择K(K << N)个其他节点来同步状态;
集群内每个节点维护定时任务默认每秒执行10次,每秒会随机选取5个节点找出最久没有通信的节点发送ping消息,用于保证Gossip信息交换的随机性。每100毫秒都会扫描本地节点列表,如果发现节点最近一次接受pong消息的时间大于cluster_node_timeout/2,则立刻发送ping消息,防止该节点信息太长时间未更新。根据以上规则得出每个节点每秒需要发送ping消息的数量=1+10*num(node.pong_received>cluster_node_timeout/2),因此cluster_node_timeout参数对消息发送的节点数量影响非常大。当我们的带宽资源紧张时,可以适当调大这个参数,如从默认15秒改为30秒来降低带宽占用率。过度调大cluster_node_timeout会影响消息交换的频率从而影响故障转移、槽信息更新、新节点发现的速度。因此需要根据业务容忍度和资源消耗进行平衡。同时整个集群消息总交换量也跟节点数成正比。
1 | /* This is executed 10 times every second */ |
2、状态信息并不是全量同步,而是随机选M(M << N)个节点的状态同步到其他节点。
M值最小为3,最大为N - 2
,一般情况下M = N / 10
。
1 | /* Send a PING or PONG packet to the specified node, making sure to add enough |
当新的节点加入时,我们该如何重新分配数据,让新的节点也对外提供服务。当有节点退出时,我们该如何把存在该节点上的数据分配到其他机器上,让其他机器来提供这部分数据的服务。即集群的扩缩容问题。
新节点加入时,需要把一部分数据迁移到新节点来达到集群的负载均衡。
在Redis集群中,数据的存储是以slot为单位的,因此:
节点的迁移过程主要分为3个步骤:
以下迁移过程的伪代码来自:Redis集群详解(中)
1 | def move_slot(source,target,slot): |
以下命令告知目标节点准备导入slot:
1 | cluster setslot <slot> IMPORTING <nodeId> |
以下命令告知目标节点准备导出slot:
1 | cluster setslot <slot> MIGRATING <nodeId> |
每个节点保存的集群状态中记录了迁移中的slot,其中,迁出的slot放到migrating_slots_to
中,迁入的slot放到importing_slots_from
:
1 | typedef struct clusterState { |
接下来,将待迁移slot中的key批量转移到目标节点:
1 | # 返回count个slot中的键 |
migrate命令就是向节点发送了N个RESTORE-ASKING命令,实现代码如下:
1 | /* Create RESTORE payload and generate the protocol to call the command. */ |
迁入节点接收到restore-asking命令后,执行节点的恢复操作,即获取key,解析出value,然后写入数据库:
1 | /* RESTORE key ttl serialized-value [REPLACE] */ |
迁移过程中,在外部客户端的视角看来,在任意时间点上,key只会存在于某个节点上,而不会同时存在于两个节点上。
现在,待迁移槽中的key都已经被迁移了,但是对其他节点来说,该slot仍是由迁出节点负责的,它们接收到相关请求后仍然会路由到迁出节点,所以迁移的最后一步需要向集群中的所有主节点通知槽已经被分配给目标节点。
1 | cluster setslot <slot> node <nodeId> |
迁移过程中:
1 | int processCommand(redisClient *c) { |
getNodeByQuery
根据key的散列结果查询命令应该被打到的节点,可以看到这个函数里有对ASKING标识的特殊处理:1 | clusterNode *getNodeByQuery(redisClient *c, struct redisCommand *cmd, robj **argv, int argc, int *hashslot, int *error_code) { |
与新节点的加入相反的是,旧节点退出时需要把其上的数据迁移到其他节点上,确保该节点上的数据能够被正常访问。
槽的迁移过程和上边扩容中描述的没有区别,主要区别是在迁移完毕后需要轮询每个节点发送cluster forget
命令,让它们能忘记下线的节点。
节点在接收cluster forget
命令后,会将目标节点的状态从自己保存的集群状态中移除,并将其加入黑名单中60s,这期间其他节点不会再去更新自己维护的该节点的信息,也就是说这60秒内该节点无法重新加入集群内。
1 | def delnode_cluster_cmd(downNode): |
集群规模并不是没有限制的,理论上每个节点一个slot集群可以扩容到16384个节点,但是Redis官方给出的规模上限是一个集群1000个节点,因为实例间的通信开销会随着实例规模增加而增大。
下面来讨论下集群内部有哪些交互,并分析它们会对性能有什么样的影响。
集群每个节点都会记录slot和实例间的映射关系,用于请求的重定向。
每个实例都需要通过Gossip协议将数据同步到其他节点,大致流程为:
clusterMsgDataGossip
这个结构来表示,大小为104字节。每个实例在发送Gossip消息时,除了传递自身的状态信息,默认还会传递集群十分之一实例的状态信息,比如,对于一个包含了 1000 个实例的集群来说,每个实例发送一个 PING 消息时,会包含 100 个实例的状态信息,总的数据量是 10400 字节,再加上发送实例自身的信息,一个 Gossip 消息大约是 10KB。从上可知,实例间的数据同步受到通信消息大小和通信频率这两方面的影响。
当集群规模扩大后,PING/PONG会占用大量的集群内网络带宽,降低集群服务正常请求的吞吐量。
单实例每秒会发送的PING消息数量大致可以算出是(注意cron是100ms执行一次):
1 | PING 消息发送数量 = 1 + 10 * 实例数(最近一次接收 PONG 消息的时间超出 cluster-node-timeout/2) |
其中,1 是指单实例常规按照每 1 秒发送一个 PING 消息,10 是指每 1 秒内实例会执行 10 次检查,每次检查后会给 PONG 消息超时的实例发送消息。
假设单个实例检测发现,每 100 毫秒有 10 个实例的 PONG 消息接收超时,那么,这个实例每秒就会发送 101 个 PING 消息,约占 1.2MB/s 带宽。如果集群中有 30 个实例按照这种频率发送消息,就会占用 36MB/s 带宽,这就会挤占集群中用于服务正常请求的带宽。
因此实例间的通信开销优化主要是:
PING消息发送数量
公式可以看出,每秒发送一条PING消息的频率不算高,如果要降低可能导致集群内数据同步延迟;每100ms做一次检测并给延迟超过cluster-node-timeout/2
的节点发送PING消息,这个配置是可以适当调大的。tcpdump host 192.168.10.3 port 16379 -i 网卡名 -w /tmp/r1.cap
Redis故障恢复主要分为以下3个步骤:
一些 CP 特性且中心化的集群来说,当出现节点宕机时经常需要选举新的 Leader 节点,但是 Redis-Cluster 是去中心化的,某个 Master 的宕机并不会影响其他节点的工作。但是,当节点失联时,需要考虑网络的抖动情况,毕竟不能因为某几个请求意外超时就推断集群失败了,部分节点判断一个节点失联只会标记这个节点状态为PFAIL(主观下线),之后如果多数节点投票通过才会真正标记这个节点FAIL(下线)。
投票过程是集群中所有 master 参与的,每个节点都存有整个集群所有主节点及从节点的信息,它们之间通过互相 ping-pong 来判断节点是否可以连上,如果半数以上 master 节点与当前 master 节点通信超时(cluster-node-timeout),则认为当前 master 节点挂掉,标记这个节点状态为FAIL。
当 master 挂掉时,并不意味着集群已无法再提供服务了,集群要进入fail(不可用)
状态需要满足以下条件之一:
当集群不可用时,任何操作都将返回((error) CLUSTERDOWN The cluster is down)
错误。需要注意的是,必须要 3 个或以上的主节点,否则在创建集群时会失败。
1、Redis每个节点会不断向其他节点发送PING
消息来检测其他节点是否可达,如果超时会先断开连接:
代码:cluster.c/clusterCron
1 | /* This is executed 10 times every second */ |
2、此时节点A PING目标节点B失败,A会尝试重连,并将重连时间记录到ping_sent变量中:
1 | /* This is executed 10 times every second */ |
3、节点A发现PING B的延时时间超过了node_timeout之后,就会标记该节点为PFAIL(Possible FAILure),即主观下线:
1 | void clusterCron(void) { |
1、A将B标记为PFAIL后,A会通过Gossip通知到其他节点。
2、所有节点会维护一个下线报告列表(Fail Report),主要维护一个节点被哪些节点报告处于下线状态,此时,C会记录“B被A报告下线了”。
1 | int clusterProcessPacket(clusterLink *link) { |
3、C添加下线报告之后,会进行B节点的客观下线状态(FAIL)判定。
当集群中有超过半数的节点都认为节点B处于PFAIL后才会判断B为FAIL,且需要注意的是,A将PFAIL通知给C后,C自己本身也得认为B处于PFAIL状态才会开始客观下线判定。
当C认为B正式FAIL后,它就会立刻向集群所有节点广播这个消息。
1 | /* This function checks if a given node should be marked as FAIL. |
4、当C标记了B为FAIL状态,则它会广播到整个集群中的所有节点(包括子节点),其他节点都会更新自己维护的节点B的状态信息为FAIL
1 | /* Send a FAIL message to all the nodes we are able to contact. |
1、当B的两个子节点接收到B的FAIL状态消息时,它们会更新自己本地内存中的集群状态
1 | int clusterProcessPacket(clusterLink *link) { |
2、随后,在clusterCron定时任务中就会开始发起故障迁移,竞选成为新的Master
1 | void clusterCron(void) { |
3、资格检查
Slave节点会不停的与Master节点通信来复制Master节点的数据,如果一个Slave节点长时间不与Master节点通信,那么很可能意味着该Slave节点上的数据已经落后Master节点过多(因为Master节点再不停的更新数据但是Slave节点并没有随之更新)。Redis认为,当一个Slave节点过长时间不与Master节点通信,那么该节点就不具备参与竞选的资格。
1 | void clusterHandleSlaveFailover(void) { |
4、休眠时间计算
B的所有子节点(B1、B2)在判断自己具备选举资格时,就开始执行竞选,竞选协议是Raft,选举过程中,所有参与选举的节点首先随机休眠一段时间。
整个休眠时间由两个部分组成:
1 | DELAY = 500 milliseconds + random delay between 0 and 500 milliseconds + SLAVE_RANK * 1000 milliseconds. |
1 | void clusterHandleSlaveFailover(void) { |
CLUSTERMSG_TYPE_FAILOVER_AUTH_REQUEST
类型的消息。FAILOVER_AUTH_ACK
消息。CLUSTERMSG_TYPE_FAILOVER_AUTH_REQUEST
类型消息也会直接忽略。1 | void clusterHandleSlaveFailover(void) { |
1 | void clusterHandleSlaveFailover(void) { |
上边我们已经通过故障发现和子节点选举机制用B1这个子节点替换掉了它的Master节点B,那么留下来的节点B和B2应该怎么处理呢?实际上Redis会让它们变成B1的Slave节点。
1、对B2来说,B1升级成Master后会给B2发送消息,让它知道自己已经升级成Master了。
1 | void clusterHandleSlaveFailover(void) { |
2、对B来说,B1已经成为了Master,B从故障恢复后再次加入集群时,会成为B1的Slave。
一种数据丢失的场景是主从复制时Master挂掉了,这点我在《Redis 复制》讨论过。
另一种数据丢失的场景存在于Cluster集群中,并且并不是特别容易出现,也就是Cluster发生了脑裂,分区恢复时脑裂期间的数据被覆盖:
这种情况是可能发生的,因为客户端会记忆槽所在的节点,而不是每次请求都通过重定向定位到槽实际所在的节点上。
在《Redis核心技术与实战》中提到了一种会导致脑裂的情况:
主从切换后,从库会升级为新主库,这时如果老主库重新上线了,会成为新主库的Slave,执行全量同步,而全量同步执行的最后阶段,需要清空本地的数据,加载新主库发送过来的RDB文件,这期间写入的数据就会丢失了。
可以通过两个配置来解决这个脑裂问题:
min-slaves-to-write
min-slaves-to-write
我们可以把 min-slaves-to-write 和 min-slaves-max-lag 这两个配置项搭配起来使用,分别给它们设置一定的阈值,假设为 N 和 T。这两个配置项组合后的要求是,主库连接的从库中至少有 N 个从库,和主库进行数据复制时的 ACK 消息延迟不能超过 T 秒,否则,主库就不会再接收客户端的请求了。即使原主库是假故障,它在假故障期间也无法响应哨兵心跳,也不能和从库进行同步,自然也就无法和从库进行 ACK 确认了。这样一来,min-slaves-to-write 和 min-slaves-max-lag 的组合要求就无法得到满足,原主库就会被限制接收客户端请求,客户端也就不能在原主库中写入新数据了。等到新主库上线时,就只有新主库能接收和处理客户端请求,此时,新写的数据会被直接写到新主库中。而原主库会被哨兵降为从库,即使它的数据被清空了,也不会有新数据丢失。
举个例子:假设我们将min-slaves-to-write 设置为 1,把 min-slaves-max-lag 设置为 12s,把哨兵的 down-after-milliseconds 设置为 10s,主库因为某些原因卡住了 15s,导致哨兵判断主库客观下线,开始进行主从切换。同时,因为原主库卡住了 15s,没有一个从库能和原主库在 12s 内进行数据复制,原主库也无法接收客户端请求了。这样一来,主从切换完成后,也只有新主库能接收请求,不会发生脑裂,也就不会发生数据丢失的问题了。
Cluster集群通过CRC16算法将key hash到节点槽上,这个过程还是存在很多不确定性,可能很多数据会被hash到固定的某几个槽上,造成数据分布的不均匀,或者某些key是热点数据,被访问得尤其频繁。
数据倾斜的危害主要是保存热点数据的节点处理压力会增大,速度变慢,甚至内存资源耗尽而崩溃。
数据倾斜的成因主要有3个:
数据倾斜可以通过重分配slot来解决。
但是热点数据往往是少部分数据被频繁访问,这种情况下重分配slot是无法解决的,为此可以通过热点数据多副本的方法来解决,比如同一key添加一个前缀然后hash到其他slot上。
但是多副本只能用于只读热点key,对于有读有写的热点数据,就只能给实例本身增加资源了,比如改成配置更高的机器。
以上图片来自极客时间的《Redis核心技术与实战》。
对原理的分析都是基于Redis3.0版本的代码,现在最新的版本应该是6.0,很多功能都是后面的版本引入的,因此这篇里不会描述多线程这些功能。
特性及优势:
但是也不能忽略 Redis 本身的一些缺点:
Redis 相对 Memcached 来说有以下优点:
Redis 和 Memcached 之间存在以下区别:
Redis 的事务并不严格:
* A(原子性):Redis 支持事务,所有命令会被保存到一个事务队列中,服务器接收到 exec 时才会被真正执行,注意如果中间出错,事务不会回滚,后面的指令还会继续执行;而且如果涉及到复杂的逻辑判断,则只能通过lua 脚本实现“伪原子性”,说它是“伪原子性”是因为虽然脚本可以一次性执行多条命令,如果中间某个命令出错还是会无法保证“要么全部执行,要么都不执行”的要求。
* I(隔离性):Redis 是单线程模型,因此可以保证隔离性。
* D(持久性):Redis 是内存数据库,服务器意外崩溃会导致内存中的数据丢失,除非开启 AOF,并且配置成每次写入都记日志,但是这样又会极大地影响效率,所以一般会配置成混合模式的持久化。
64位机器会占用比32位机器更多的内存,因为指针在64位机器上占用更多空间,但同时64位机器也可以有更大的内存空间。
sds:string 使用,变长字符串,不够的情况下重新分配空间并将老字符串数据拷贝过去;
dict:字典应用很多,包括 Redis 数据库中保存所有 key-value、hash、set、zset。dict 类似 Java 中的 HashMap,将 key 散列到哈希桶数组中,每个哈希桶都是一个链表,插入就是插入到链表头部,当元素超过了容量的一半后会启动渐进式 rehash 进行扩容。
ziplist:相当于一个数组,查询时需要遍历一次,每次插入都需要 realloc 重新分配一个新的更大的数组,然后把老数组内容拷贝过去。
quicklist:由于 linkedlist 附加空间成本高且容易产生碎片,因此 Redis 里的 quicklist 设计成了 linkedlist 和 ziplist 的结合,它将 linkedlist 按段切分,每一段使用 ziplist 存储;
skiplist:skiplist 用于实现 zset 中按 score 排序的要求,插入时先自顶向下查位置,然后按概率计算该节点应该分配到几层。
从业务层面来看,如果要存好多字段的对象、而且这个对象的每个字段都会单独拿出来用,则可以考虑使用 hash,否则没有太多限制条件。
从性能角度来看,如果存的字段少,hash 会使用 ziplist 结构存储,性能多少受点影响,而且还要考虑转换结构和渐进式扩容对性能的损耗。
从节约空间的角度来看,string 的 key 一般都会加个前缀,一般会比 hash 占用更多的空间,不过差距不大。
用 zset 存,score 是金额拼上时间,金额放高位,MAX_INT 和时间作差放低位,查询时使用ZREVRANGE
命令查询。
分两种情况:hash 在数据量小时结构是 ziplist,这时插入不会做冲突检测,插入到目标位置后就向后统一移动数据,给新插入的数据项流出空间;在数据量大时结构是 dict,这种结构和 Java 中的 HashMap 类似,使用链表来处理冲突。
redis 的淘汰分两种:
server.c/processCommand
:Redis 有两种持久化方式:RDB 和 AOF
RDB 是快照,AOF 记录了写操作,效率起见,一般 RDB 作为 checkpoint,checkpoint 后的数据通过 AOF 恢复。
RDB 二进制文件可以直接加载到内存速度较快;AOF 要重放命令,所以速度比较慢。
RDB 需要全量备份,AOF 可以增量备份,二者的应用场景不同。
master 会启动一个后台进程进行持久化(RDB or AOF),第一次连接时会将 RDB 文件发给 slave,slave 先保存到磁盘,之后加载到内存中;如果不是第一次连接,slave 连接 master 后通过 PSYNC 命令告知自己同步的起始位置,master 将增量部分 AOF 文件发送给 slave。
能。
因为 Redis 的复制是通过 fork 子进程实现的,父进程仍然可以接收请求。
不会。
因为 Redis 复制是通过 fork 子进程实现的,由于 COW 机制,子进程只能看到老数据。
延迟无法避免,比如主从之间的网络抖动、slave 发生阻塞(如 IO)等情况。
解决办法有两种:
min-slave-to-write N
和min-slave-max-lag M
,控制 Master,只有在至少有 N 个 slave 正在工作,并且滞后时间均小于 M 秒的情况下,Master 将不接受写入请求;slave-serve-stale-data
,控制从库对主库失去响应或复制进行过程中从库的表现,为 yes 则从库会继续响应客户端的请求,为 no 则除去 INFO 和 SLAVOF 命令之外的任何请求都会返回一个错误SYNC with master in progress
;从复制、Sentinel 到 Cluster
见《Redis 客户端》。
redis cluster 默认分配了 16384 个 slot,当我们 set 一个 key 时,会用CRC16算法来取模得到所属的 slot,然后将这个 key 分到哈希槽区间的节点上,具体算法就是:CRC16(key) % 16384
。所以我们在测试的时候看到 set 和 get 的时候,直接跳转到了 7000 端口的节点。
哈希槽与一致性哈希的区别:哈希槽由客户端来重定向到目标 slot 所在节点,一致性哈希需要由服务器端重定向到目标节点,而且需要按顺时针方向一个一个节点递归地找。
流程见《主从同步》。
如果是全量同步,同步时会先同步 RDB 文件,再同步增量写命令;
如果是部分重同步,则只同步增量写命令。
如果上次传输中断,则下次同步时从中断位置开始执行部分重同步。
序列化(Serialization):事件A必须在事件B之前发生。
互斥(Mutual exclusion):事件A和B不能同时发生。
线程A:
1 | do sth... |
线程B:
1 | wait for A |
B会等待A发来消息后再执行后续的指令。
1、并发写
线程A
1 | x = 5 |
线程B
1 | x = 7 |
这两个线程并发执行,最后打印出来的结果不确定是5还是7。
2、并发更新
线程A:
1 | count = count + 1 |
线程B:
1 | count = count + 1 |
两个线程的操作都是读后写,可能就会发生同时读出旧值然后都+1,最终结果并没有+2的情况。
3、通过发消息互斥执行
通过发消息保证共享变量的安全更新。
支持PV操作,P原子地减少信号量值,当值为0时阻塞,V原子地增加信号量值。
信号量的优点:
一个线程发消息给另一个线程告知某件事情的发生。
线程A:
1 | statement a1 |
线程B:
1 | sem.wait() |
只有A signal发出消息后,B才能从wait离开继续执行。
在Java中,发信号的功能可以通过Object的wait/notify、Lock的Condition、LockSupport、Semaphore实现。
不知道怎么翻译,叫做汇聚?作者给出的是类似下面这样的例子:
线程A:
1 | statement a1 |
线程B:
1 | statement b1 |
注意signal和wait不要写反了,写反了会死锁。
在Java中,可以通过CyclicBarrier
实现。
使用信号量可以实现互斥量,实际上互斥量可以看作Semaphore(1)
,使用以下代码就可以实现两个线程的互斥执行:
线程A:
1 | mutex.wait() |
线程B:
1 | mutex.wait() |
上边mutex包围的代码就称为临界区代码(critical section)。
将上边的互斥量泛化,我们让多个线程可以同时执行一块临界区代码。
其实就是用Semaphore(n)
就可以实现n个线程同时执行了。
只有所有线程都到达某个位置才能一块继续执行下去,栅栏可以通过以下代码实现(有bug,会出现死锁):
1 | count = 0 |
1 | rendezvous // 汇聚 |
如果直接拿来执行,容易发现只有1个线程能执行下去,因为:
barrier.wait
后barrier的值变为-4;barrier.signal
释放了1个,barrier的值变为-3,此时只有一个线程被放过去了,另外还有3个线程仍阻塞,且第5个线程随后也会进入阻塞状态。修改后的代码如下:
1 | rendezvous // 汇聚 |
分析问题时:
两个线程交替打印数组的功能,虽然比较简单,但是面试时问的还蛮多的,如果用Semaphore实现会比较简单,用Java的wait/notify或Condition实现则会稍微麻烦一点。
1 | public class OneByOneTest { |
1 | public class OneByOneConditionTest { |
1 | public class WaitNotiftTest { |
解决生产者/消费者问题需要维护一个队列,生产者向队列添加,消费者从队列获取,同步问题出现在队列为空或满的情况,因此我们需要对队列进行同步化。
为了简化问题,可以使用 juc 引入的 BlockingQueue(阻塞队列),这种数据结构能在下面两种情况下阻塞当前线程
1 | public class ProducerConsumerTest { |
下面是使用 BlockingQueue 实现的生产者/消费者代码
注意要使用put/take这组方法,而不是offer/poll,因为后者会在满/空时直接返回(而非阻塞等待)。
1 | public class Producer implements Runnable { |
读写问题中有两类线程:
1 | public class ReaderWriterTest { |
上面的代码有个问题,就是写线程可能被饿死,因为第一个读线程通过rootEmpty.acquire
进来后,后续的读线程都不必再等待,可以直接进入临界区,而同时执行的写线程就永远都等在rootEmpty.acquire
上了。
改成如下的方式:
1 | public class ReaderWriterTest { |
我们先来看下最开始最直观的一种错误解法,这种解法会导致死锁:
1 | public class DiningPhilosophersTest { |
显然,n位哲学家刚开始都没有处于就餐状态,如果他们同时拿起左边的叉子,然后尝试取右边的叉子,就会直接导致死锁。
注意,上面发生死锁的必要条件是“n位哲学家同时就餐”,如果n位无法同时就餐,那这个问题也就迎刃而解了,所以我们额外引入一个footman
信号量,它的数量控制在n - 1
:
1 | private Semaphore footman = new Semaphore(count - 1); |
另外一种解决办法是让一个哲学家先获取右边的叉子再获取左边的叉子,这样其实解除了环路等待条件:假设有5个哲学家,其中4个哲学家拿到左手的叉子后,第五个哲学家会尝试取第一个叉子,也就是第一个哲学家左手的叉子,他们两个不满足死锁的条件。
这种思路的代码比较简单,就先忽略了。
这是一种相对比较极端的解,每个哲学家都需要等两边的人不在就餐的情况下才能就餐,否则他什么都不做:
1 | private static final int count = 2; |
这种解存在的主要问题是会发生饥饿,比如2个哲学家的情况下,可能1号会一直处于就餐状态,2号一直处于循环检测的状态,于是就发生了饥饿。
1 | #define N 10 //最多10个顾客 |
1 | #include <stdio.h> |
一个火车站有多个窗口,它们同时卖票,而票数使用一个 ticket 变量进行计算,对票数有查询和修改两个操作,这两个操作不能同时进行,并且写操作可能不是原子的,两个写操作也不能同时进行
在 Java 体系中,提到并发就不得不提到 JMM,因为所有并发安全都是围绕内存来展开的,可以说不懂内存结构就不懂并发。
在实际回收垃圾对象前,我们必须标识出哪些对象该被回收,即垃圾检测。
Object obj = new Object()
的 obj 就是一个强引用。OutOfMemoryError
错误,使程序异常终止,也不会回收强引用对象来释放内存,除非已经没有引用关联这些对象了。java.lang.ref
包中。Reference 抽象类是除强引用外的所有引用类型的父类,有以下几种子类
1 | MyObject obj = new MyObject(); |
堆中的每一个对象的对象域包含一个引用计数器。该计数器的维护规则如下:
但是一般垃圾回收器并不会采用这种算法,主要是因为引用计数算法存在循环引用的问题(注意不是栈帧里的引用,而是堆中实例的互相引用)
1 | public class ReferenceCountingGC { |
如上边代码所示,执行objA = null
和objB = null
后,它们二者的 instance 域仍然互相是对方的引用。
若一个对象没有引用链与任一个 GC Roots 相连时,此对象可回收
包括虚拟机栈中引用的对象、方法区中类的静态成员变量引用的对象、方法区中的常量引用的对象、本地方法栈中 Native 方法引用的对象
根部(Roots):表示引用链的头部
引用链(Reference Chain):多个引用形成的一条链
引用:是 reference 类型的对象,其中存储的数据代表的是另外一块内存的起始位置,有强引用(Strong)、软引用(Soft)、弱引用(Weak)、虚引用(Phantom)四种。
此算法的基本思想就是选取一系列 GC Roots 对象作为起点,开始向下遍历搜索其他相关的对象,搜索所走过的路径成为引用链,遍历完成后,如果一个对象到 GCRoots 对象没有任何引用链,则证明此对象是不可用的,可以被当做垃圾进行回收。
那么问题又来了,如何选取 GCRoots 对象呢?在 Java 语言中,可以作为 GCRoots 的对象包括下面几种:
如上图所示,Obj8、Obj9、Obj10 都没有到 GC Root 的引用链,因此它们会被标记为垃圾,即便 Obj9 和 Obj10 之间有引用关系。
对于可达性分析算法而言,未到达的对象并非是“非死不可”的,若要宣判一个对象死亡,至少需要经历两次标记阶段。
1 | /* |
先标记所有需要清除的对象,再统一回收。是最基础的垃圾回收算法,后续的收集算法都是基于这种思路并对其缺点进行改进而得到的。
问题
首先标记出所有需要回收的对象,使用可达性分析算法判断一个对象是否为可回收,在标记完成后统一回收所有被标记的对象。下图是算法具体的一次执行过程后的结果对比。
将可用内存划分为大小相等的两半,对每一块使用指针碰撞(从已分配内存向空闲内存空间移动对象大小的空间)的方法为对象分配空间,如果这一块内存用完,就将还存活的对象复制到另一半块上,将原来的这一半一次清理掉。
HotSpot 中使用的是 Eden-Survivor 方法,大体上每次使用一个 Eden 和一个 Survivor 来分配对象空间,当回收时,将这两块中还存活的对象一次性复制到另一块 Survivor 中,Eden 和 Survivor 的比例为8:1
。如果 Survivor 的空间不够了,就会使用老年代进行分配担保(Handle Promotion)。
优点:
像标记-清除算法清理后的内存空间并不规整,可能会有很多碎片,因此只能使用空闲列表(Free List)的方式分配内存。
缺点:
将内存分为两等块,每次使用其中一块。当这一块内存用完后,就将还存活的对象复制到另外一个块上面,然后再把已经使用过的内存空间一次清理掉。图是算法具体的一次执行过程后的结果对比。
标记过程和Mark-Sweep一样,但是不直接清除,而是让存活的对象向前移,再清理端边界外的内存。
标记过程还是和标记-清除算法一样,之后让所有存活的对象都向一端移动,然后直接清理掉边界以外的内存,标记 - 整理算法示意图如下
标记-整理算法往往与标记-清除同时使用,优先执行标记-清除,当内存空间碎片过多时,才运行标记-整理压缩内存空间。
将 Java 堆分为新生代和老生代,根据各个年代的特点采取最适当的收集算法。在新生代中死得快,就选用复制算法(要复制的少),老生代中对象存活率高,就使用标记整理或标记清除算法。
GC文章有些常用的概念:
对于虚拟机中线程私有的区域,如程序计数器、虚拟机栈、本地方法栈都不需要进行垃圾回收,因为它们是自动进行的,随着线程的消亡而消亡,不需要我们去回收,比如栈的栈帧结构,当进入一个方法时,就会产生一个栈帧,栈帧大小也可以借助类信息确定,然后栈帧入栈,执行方法体,退出方法时,栈帧出栈,于是其所占据的内存空间也就被自动回收了。
而对于虚拟机中线程共享的区域,则需要进行垃圾回收,如堆和方法区,线程都会在这两个区域产生自身的数据,占据一定的内存大小,并且这些数据又可能会存在相互关联的关系,所以,这部分的区域不像线程私有的区域那样可以简单自动的进行垃圾回收,此部分区域的垃圾回收非常复杂,而垃圾回收也主要是针对这部分区域。
对于可达性分析而言,我们知道,首先需要选取 GCRoots 结点,而 GCRoots 结点主要在全局性的引用(如常量或类静态属性)与执行上下文(如栈帧中的局部变量表)中。方法区可以很大,这对于寻找 GCRoots 结点来说会非常耗时。当选取了 GCRoots 结点之后,进行可达性分析时必须要保证一致性,即在进行分析的过程中整个执行系统看起来就好像被冻结在某个时间点上,不可以在分析的时候,对象的关系还在动态变化,这样的话分析的准确性就得不到保证,所以可达性分析是时间非常敏感的。
为了保证分析结果的准确性,就会导致GC 进行时必须停顿所有 Java 执行线程(Stop the world),为了尽可能的减少 Stop the world 的时间,Java 虚拟机使用了一组称为OopMap的数据结构,该数据结构用于存放对象引用的地址,这样,进行可达性分析的时候就可以直接访问 OopMap 就可以获得对象的引用,从而加快分析过程,减少 Stop the world 时间。
OopMap 数据结构有利于进行 GC,是不是虚拟机无论何时想要进行 GC 都可以进行 GC,即无论虚拟机在执行什么指令都可以进行 GC?答案是否定的,因为要想让虚拟机无论在执行什么指令的时候都可以进行 GC 的话,需要为每条指令都生成 OopMap,显然,这样太浪费空间了。为了节约宝贵的空间,虚拟机只在”特定的位置“存放了 OopMap 数据结构,这个特定的位置我们称之为安全点。程序执行时并非在所有地方都能够停顿下来开始 GC(可达性分析),只有到达安全点的时候才能暂停。安全点可以由方法调用、循环跳转、异常跳转等指令产生,因为这些指令会让程序长时间执行。
现在我们已经知道了安全点的概念,即进行 GC 必须要到达安全点,那么在发生 GC 时如何让所有线程到达安全点再暂停呢?有两种方法:
现在问题又来了,当程序不执行的时候,如何让所有线程达到安全点呢?典型的就是线程处于 Sleep 状态或者 Blocked 状态,这时候线程是无法跑到安全点再中断自己的,虚拟机也肯定不可能等待该线程被唤醒并重新分配 CPU 时间后,跑到安全点再暂停。为了解决这个问题,引入安全区域的概念。安全区域是对安全点的扩展,可以看成由很多安全点组成,安全区域是指一段代码片段之中,引用关系不会发生变化。在这个区域的任何地方开始 GC 都是安全的。当线程执行到安全区域的代码时,首先标示自己已经进入了安全区域,那么,在这段时间里 JVM 发起 GC 时,就不用管标示自己为安全区域状态的线程了。在线程要离开安全区域时,它要检查系统是否已经完成了根节点枚举(或者整个 GC 过程),若完成,线程继续执行;否则,它必须等待直到收到可以安全离开安全区域的信号。
根据作用区域的不同,GC 主要分为 3 种:
对象的晋升机制:
定义GC Cause的代码位置:src/share/vm/gc/shared/gcCause.hpp
和 src/share/vm/gc/shared/gcCause.cpp
:
1 | const char* GCCause::to_string(GCCause::Cause cause) { |
JVM会因这些Cause触发回收:/src/hotspot/share/gc/cms/concurrentMarkSweepGeneration.cpp
列举一些经典的GC Cause及参考的解决方案:
-Xms
和-Xmx
的值设置得不一样,刚开始只会分配-Xms
大小的堆空间,每次不够时再向操作系统申请,这时必须进行一次GC。-Xms
和-Xmx
、-XX:-MaxNewSize
和-XX:NewSize
、-XX:MetaSpaceSize
和-XX:MaxMetaSpaceSize
这样的值设置成一样的。System.gc()
。System.gc()
一般用于清理DiectBuffer对象,因为DirectBuffer会申请堆外空间。System.gc()
的去留需要根据即使情况来判断。-XX:MaxMetaSpaceSize
这个属性限制,如果空间不够且无法继续扩容,则将触发OOM。发生过早晋升的根本原因可能是:Young/Eden区过小;分配速率过大。
晋升年龄受一个阈值MaxTenuringThreshold
控制,如果设置得过大,会导致该晋升的对象一直停留在年轻代,每次YoungGC都需要复制大量对象,失去了老年代的作用;如果设置得过小,大量对象被晋升到Old区,失去了年轻代的作用。不同情况下JVM内存成分不同,对象的生命周期分布也不同,因此晋升年龄是动态调整的。/src/hotspot/share/gc/shared/ageTable.cpp#compute_tenuring_threshold
可以看到 Hotspot 遍历所有对象时,从所有年龄为 0 的对象占用的空间开始累加,如果加上年龄等于 n 的所有对象的空间之后,使用 Survivor 区的条件值(Target-SurvivorRatio / 100,TargetSurvivorRatio 默认值为 50)进行判断,若大于这个值则结束循环,将 n 和 MaxTenuringThreshold 比较,若 n 小,则阈值为 n,若 n 大,则只能去设置最大阈值为 MaxTenuringThreshold。动态年龄触发后导致更多的对象进入了 Old 区,造成资源浪费。
如果是Young/Eden过小,可以调整比例,一般可以在Heap 内存不变的情况下适当增大 Young 区,一般情况下 Old 的大小应当为活跃对象的 2~3 倍左右,考虑到浮动垃圾问题最好在 3 倍左右,剩下的都可以分给 Young 区。
如果是分配速率过大,可以分析一下代码是不是哪些地方动态加载类过快了;或者直接扩大元空间,适应这种速度。
CMS FullGC频繁
CMS的原理是一次Young GC后,负责处理CMS的一个后台线程concurrentMarkSweep会不断地轮询,使用shouldConcurrentCollect()
检测是否达到回收条件。如果达到条件则调用collect_in_background()
启动一次Background模式GC。
判断是否进行回收的代码:/src/hotspot/share/gc/cms/concurrentMarkSweepGeneration.cpp
比较常见的有:-XX:+UseCMSInitiatingOccupancyFraction
触发、上次Young GC失败触发。
单次CMS GC(老年代GC)耗时过长
CMS回收主要耗时阶段是Init Mark和Final Remark,因为这两个阶段都需要STW,
见Old区垃圾回收细节:CMSCollector::collect_in_background
、CMSCollector::collect
-XX:+ DisableExplicitGC
来禁止 RMI 调用 System.gc。)-XX:MaxTenureThreshold
参数定义;-XX:PretenureSizeThreshold
参数定义;-XX:TargetSurvivorRatio
的时候,从这个年龄段往上年龄的对象进入老年代;在进行MinorGC之前,JVM的空间分配担保机制可能会触发3.2、3.3、3.4的发生,也就是触发一次FullGC。
所谓的空间分配担保机制,就是在MinorGC之前,虚拟机会检查老年代最大可用连续内存空间是否大于新生代所有对象的总空间。
最后,当发生 FullGC 之后空间还是不够,将抛出 OutOfMemoryError。
对象的内存分配,绝大部分都是在堆上分配,少数经过JIT编译后被拆散为标量类型并间接在栈上分配。
在堆上的分配又可以有如下分配,主要在新生代的 Eden 区分配,如果启动了本地线程分配缓冲,将按照线程优先在TLAB上分配,少数直接在 Tenured 区分配,虚拟机也提供了一些参数供我们来控制对象内存空间的分配。
总而言之,对象分配具有以下几种策略:
1 | -Xms20M -Xmx20M -Xmn10M |
新生代可用的空间:9M = 8M(Eden 空间容量) + 1M(一个 Survivor 空间的容量)
老年代可用的空间:10M
分配完 alloc1、alloc2、alloc3 之后,无法再分配 alloc4,会发生分配失败,则需要进行一次 Minor GC,survivor to 区域的容量为 1M,无法容纳总量为 6M 的三个对象,则会通过担保机制将 alloc1、allo2 转移到老年代,然后再将 alloc4 分配在 Eden 区。
大对象需要大块连续内存空间,大对象的出现容易提前触发 GC 以获取更大的连续空间来供分配大对象,可以设置-XX:PretenureSizeThreshold
的值来控制多大的对象直接分配到 Tenured 区,默认是 0,即所有对象不管多大都先在 Eden 区中分配空间。
1 | /** |
因为设置了-XX:PretenureSizeThreshold=3145728
控制大小超过 3M 的对象直接进入 Tenured 区,可以看到 5M 的对象直接被分配到了 Tenured 区。
每个对象有一个对象年龄计数器,与前面的对象的存储布局中的 GC 分代年龄对应。对象出生在 Eden 区、经过一次 Minor GC 后仍然存活,并能够被 Survivor 容纳,则设置年龄为 1,对象在 Survivor 区每次经过一次 Minor GC,年龄就加 1,当年龄达到阈值(默认 15),就晋升到老年代,虚拟机提供了-XX:MaxTenuringThreshold
来进行设置。
1 | /** |
如 GC 日志中所示,总共发生了两次 Minor GC:
因此,最后的结果是 alloc1、alloc2 在老年代,alloc3 在 Eden 区。
除了对象年龄自然达到-XX:MaxTenuringThreshold
而被转移到 Tenured 区外,如果在 Survivor 区中相同年龄所有对象大小的总和大于 Survivor 区的一半,则年龄大于等于该年龄的对象也可以直接转移到 Tenured 区、而无需等年龄达到-XX:MaxTenuringThreshold
。
1 | /** |
发生了两次 Minor GC:
最终,alloc1、alloc2、alloc3 在老年代,alloc4 在 Eden 区。
老年代连续空间大于新生代对象总大小、或者历次晋升的平均大小,就会执行 Minor GC,否则将进行 Full GC。GC 期间,如果 Survivor 区空闲空间小于存活对象,则需要老年代进行分配担保,把 Survivor 区无法容纳的对象直接转移到老年代。
例子在上一节中已经给出,这里不再赘述。
ConcurrentMarkSweepGeneration::compute_new_size()
1 | void ConcurrentMarkSweepGeneration::compute_new_size() { |
两个区域 A 和 B,初始对象在 A,继续存活的对象被转移到 B。
这两个区域并不需要根据 1:1 划分内存空间,而是将内存划分为一块较大的 Eden Space 和两块较小的 Survivor Space,在 HotSpot 中默认大小比例为 8:1。
当执行年轻代回收时会将 Eden 区存活的对象复制到一个空闲的 Survivor,下一次 GC 时将 Eden 区和这个 Survivor 区存活的对象复制到另一个 Survivor 区,因此总是会有一块 Survivor 区是空闲的。
当 Survivor 空间不够用的时候,需要依赖于老年代的空间担保。
一块区域,标记可达对象(可达性分析),然后回收不可达对象,这会引入碎片,因此在空间碎片过多导致无法继续分配时往往会执行一次整理来压缩空间。
相对标记清理算法来说多了碎片整理的过程,可以整理出更大的内存放更大的对象。
复制收集算法在对象存活率较高时就要执行较多的复制操作,效率将会变低。更关键的是,如果不想浪费 50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况,所以在老年代一般不能直接选用这种算法。
根据老年代的特点,有人提出了另外一种“标记-整理”(Mark-Compact)算法,标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存(有点 copy 的意思,但是比 copy 省空间。比清理好的一点是没有碎片)。
新生代:初始对象,生命周期短的
永久代:长时间存在的对象
整个 java 的垃圾回收是新生代和年老代的协作,这种叫做分代回收。
在大的分代回收的思想下面,不同的代区可以选择不同的收集器,而不同的收集器在不同的代区又会用到不同的算法。
方法区与 Java 堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
Java 虚拟机规范对方法区的限制非常宽松,除了和 Java 堆一样不需要连续的内存和可以选择固定大小或者可扩展外,还可以选择不实现垃圾收集。
方法区的垃圾回收主要回收两部分内容:
直接内存并不是虚拟机运行时数据区的一部分,也不是 Java 虚拟机规范中定义的内存区域。但是这部分内存也被频繁地使用,而且也可能导致 OutOfMemoryError 异常出现。
NIO 类可以直接通过 Native 函数分配堆外内存,然后通过一个存储在 Java 堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在 Java 堆和 Native 堆中来回复制数据。
使用堆外内存时需要注意:
由 DirectMemory 导致的内存溢出,一个明显的特征是在 Heap Dump 文件中不会看见明显的异常,如果我们发现 OOM 之后 Dump 文件很小,而程序中有直接或间接使用了 NIO ,那就可以考虑检查一下是不是这方面的原因。
垃圾收集器是内存回收算法的具体实现,随着 JDK 的升级我们已经有很多种垃圾收集器可供选择:
1 | jmap -heap <PID> |
Serial(串行)收集器是最基本、发展历史最悠久的串行收集器,JDK 1.5 之前默认都是此收集器,因为那时候 CPU 都是单核的。
简单而高效(与其他收集器的单线程相比),对于限定单个 CPU 的环境来说,Serial 收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得更高的单线程收集效率。
ParNew 收集器就是 Serial 收集器的多线程版本(即并发模式),除了使用多线程进行垃圾收集外,其余行为包括 Serial 收集器可用的所有控制参数、收集算法(复制算法)、Stop The World、对象分配规则、回收策略等与 Serial 收集器完全相同,两者共用了相当多的代码。
吞吐量(Throughput),即 CPU 用于运行用户代码的时间与 CPU 总消耗时间的比值,即“吞吐量 = 运行用户代码时间 /(运行用户代码时间 + 垃圾收集时间)”。
假设虚拟机总共运行了 100 分钟,其中垃圾收集花掉 1 分钟,那吞吐量就是 99%。
Parallel Scavenge 收集器无法与 CMS 收集器配合使用。
CMS 收集器运行过程中各步骤所涉及的并发和所需的停顿时间如下图所示:
CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。
顾名思义,CMS 采用标记清除算法,它的工作流程分为以下 6 个步骤:
下面以一个真实环境中的FullGC日志为例:
1 | 2020-08-20T04:37:36.159+0800: 638682.623: [GC (CMS Initial Mark) [1 CMS-initial-mark: 1930043K(2097152K)] 2000027K(4793536K), 0.2664430 secs] [Times: user=0.11 sys=0.02, real=0.26 secs] |
上面的GC日志中:
1930043K
:当前老年代使用的容量;2097152K
:老年代可用的最大容量;2000027K
:整个堆目前使用的容量;4793536K
:整个堆的可用容量;0.2664430 secs
:这个阶段的持续时间;[Times: user=0.11 sys=0.02, real=0.26 secs]
:对应 user、system 和 real 的时间统计。6.513/6.529 secs
:这个阶段的持续时间与时钟时间;[Times: user=2.11 sys=0.40, real=6.53 secs]
:时间统计,但是因为是并发执行的,并不仅仅包含 GC 线程的工作。0.024/0.026 secs
:持续时间与时钟时间;Times: user=0.03 sys=0.01, real=0.03 secs
:时间统计。Final Remark
重新标记阶段,会发生STW,暂停所有用户线程,从 GC Root 开始重新扫描整个堆,标记存活的对象。这一阶段是为了修正并发标记期间因用户线程继续运行而导致标记产生变动的那一部分对象的标记记录。这一阶段停顿时间一般比初始标记阶段稍长,但远比并发标记时间短。需要注意的是,虽然 CMS 只回收老年代的垃圾对象,但是这个阶段依然需要扫描新生代,因为很多 GC Root 都在新生代,而这些 GC Root 指向的对象又在老年代,这称为跨代引用。YG occupancy: 571811 K (2696384 K)
:年轻代当前占用量及容量;Rescan (parallel) , 0.0743374 secs
:Rescan 是当应用暂停的情况下完成对所有存活对象的标记,这个阶段是并行处理的;weak refs processing, 0.0004330 secs
:第 1 个子阶段,处理弱引用;class unloading, 3.9423498 secs
:第 2 个子阶段,卸载不再使用的 class;scrub symbol table, 0.5589452 secs ... scrub string table, 0.0015701 secs
:最后一个子阶段,清理符号表和字符表。1 CMS-remark: 1930043K(2097152K)
:这一阶段之后老年代的使用量与总量;2501855K(4793536K)
:这一阶段后堆的使用量与总量(包括年轻代);4.5824373 secs
:这一阶段的持续时间,也就是 STW 的时间。[Times: user=0.47 sys=0.04, real=4.58 secs]
:这一阶段统计的持续时间。普通串行标记清除算法与并行标记清除算法(CMS)的比较如下图所示:
如上图可知,并发标记清除算法与串行标记清除算法之间的区别主要在于,前者将标记过程分成了 3 个部分,其中占用时间最长的Concurrent Mark
不需要stw
。
由于整个过程中耗时最长的并发标记和并发清除过程收集器线程都可以与用户线程一起工作,所以从总体上来说,CMS 收集器的内存回收过程是与用户线程一起并发执行的。
并发收集、低停顿,因此 CMS 收集器也被称为并发低停顿收集器(Concurrent Low Pause Collector)。
G1(Garbage-First)收集器是当今收集器技术发展最前沿的成果之一。它是一款面向服务端应用的垃圾收集器。
G1 可以用于年轻代和老年代,且算法分 3 个步骤,所以配置种类比较多。
只作用于年轻代的配置:
作用于老年代的配置:
其他配置:
在 G1 算法中,采用了另外一种完全不同的方式组织堆内存,堆内存被划分为多个大小相等的内存块,称为Region,每个 Region 是逻辑连续的一段内存,结构如下:
由上图可见:
堆内存中一个 Region 的大小可以通过 -XX:G1HeapRegionSize
参数指定,大小区间只能是 1M、2M、4M、8M、16M 和 32M,总之是 2 的幂次方,如果 G1HeapRegionSize
为默认值,则在堆初始化时计算 Region 的实践大小。
G1 可以独立管理整个堆空间,但是能够采用不同方式来处理新创建对象和已经存活了一段时间、经历过多次 GC 的老对象,以获取更好的收集效果。G1 中提供了三种模式垃圾回收模式:Young GC、Mixed GC 和 Full GC,在不同的条件下被触发。
发生在年轻代的 GC 算法,一般对象(除了巨型对象)都是在 Eden Region 中分配内存,当所有 Eden Region 被耗尽无法申请内存时,就会触发一次 Young GC,这种触发机制和之前的 Young GC 差不多,执行完一次 Young GC,活跃对象会被拷贝到 Survivor Region 或者晋升到 Old Region 中,空闲的 Region 会被放入空闲列表中,等待下次被使用。
当越来越多的对象晋升到老年代 Old Region 时,为了避免堆内存被耗尽,虚拟机会触发一个混合的垃圾收集器,即 Mixed GC,该算法并不是一个 old gc,除了回收整个 Young Region,还会回收一部分的 Old Region,这里需要注意:是一部分老年代,而不是全部老年代,可以选择哪些 Old Region 进行收集,从而可以对垃圾回收的耗时时间进行控制。
Mixed GC 的执行过程有点类似 CMS,主要分为以下几个步骤:
如果对象内存分配速度过快,Mixed GC 来不及回收,导致老年代被填满,就会触发一次 Full GC,G1 的 Full GC 算法就是单线程执行的 Serial Old GC,使用标记-整理算法,会导致异常长时间的暂停时间,需要进行不断的调优,尽可能的避免 Full GC。
G1 使用多个 CPU 来缩短 Stop The World 停顿时间,与用户线程并发执行。
G1 建立了可预测的停顿时间模型,能让使用者明确指定在一个长度为 M 毫秒的时间片段内,消耗在垃圾收集上的时间不得超过 N 毫秒。
收集器 | 串行、并行or并发 | 新生代/老年代 | 算法 | 目标 | 适用场景 |
---|---|---|---|---|---|
Serial | 串行 | 新生代 | 复制算法 | 响应速度优先 | 单 CPU 环境下的 Client 模式 |
Serial Old | 串行 | 老年代 | 标记-整理 | 响应速度优先 | 单 CPU 环境下的 Client 模式、CMS 的后备预案 |
ParNew | 并行 | 新生代 | 复制算法 | 响应速度优先 | 多 CPU 环境时在 Server 模式下与 CMS 配合 |
Parallel Scavenge | 并行 | 新生代 | 复制算法 | 吞吐量优先 | 在后台运算而不需要太多交互的任务 |
Parallel Old | 并行 | 老年代 | 标记-整理 | 吞吐量优先 | 在后台运算而不需要太多交互的任务 |
CMS | 并发 | 老年代 | 标记-清除 | 响应速度优先 | 集中在互联网站或 B/S 系统服务端上的 Java 应用 |
G1 | 并发 | both | 标记-整理+复制算法 | 响应速度优先 | 面向服务端应用,将来替换 CMS |
GC问题可能会有很多表象,比如:GC耗时增大、线程Block增多、慢查询增多、CPU负载高等。
为了排查根因,有几种比较有效的判断方法:
1 | int main() { |
1 | class A { |
1 | class A { |
强引用比较简单,虚引用很少见,容易混淆的是弱引用和软引用:
JVM 在分配空间时,若果 Heap 空间不足,就会进行相应的 GC,但是这次 GC 并不会收集软引用关联的对象,但是在 JVM 发现就算进行了一次回收后还是不足(Allocation Failure),JVM 会尝试第二次 GC,回收软引用关联的对象。
这个问题等价于为什么在不同的代中使用不同的垃圾收集器。
主要原因来自新生代和老年代的区别,新生代新陈代谢快,采用复制算法,Survivor 区可以相对较小,不会有太大的空间浪费,并且保证了较高的效率;老年代反之。
效率低,标记和清除都需要一次线性扫描,相当于比别的算法慢一倍,而且产生大量内存碎片,内存碎片的问题也出现在 C 语言的 malloc/free 中。
并行指各垃圾收集器线程可以同时运行,此时用户线程仍然处于等待状态。
并发指用户线程可以和垃圾收集器同时(可能是交替)运行,它们不在同一个CPU上执行。
从 3 次标记过程的特征可以看出,CMS 将耗时长的部分并行化了,从而保证整个 gc 过程的高性能。
Spring Cloud 是一系列框架的有序集合,它利用 Spring Boot 的开发便利性简化了分布式系统的开发,比如服务发现、服务网关、服务路由、链路追踪等。Spring Cloud 并不重复造轮子,而是将市面上开发得比较好的模块集成进去,进行封装,从而减少了各模块的开发成本。换句话说:Spring Cloud 提供了构建分布式系统所需的“全家桶”。
Spring Cloud 常常被拿来和 Dubbo 比较,实际上 Dubbo 只实现了服务治理,接入 Dubbo 的服务能够实现自动上下线、能通过 Dubbo 协议(其实 Dubbo 还支持其他很多协议)互联,但是 Dubbo 并不提供网关、配置中心、链路追踪等一系列微服务架构常用的技术,需要单独引入。