Redis总结笔记

Redis使用多种数据结构如String、List、Hash、Set和Zset,以及特殊数据类型如Bitmap和HyperLogLog。AOF日志和RDB快照是Redis的持久化方式,AOF通过记录操作命令实现,RDB是全量快照。主从复制保证高可用性,哨兵系统负责故障检测和自动故障转移。Redis还提供了Stream数据类型用于消息队列,支持消费组和消息确认机制。
摘要由CSDN通过智能技术生成

Redis常用数据结构和应用场景
1.String
字符串对象的内部编码(encoding)有3种:int、raw和embstr
如果一个字符串对象保存的是整数值,并且这个整数值可以用long类型来表示,那么字符串对象会将整数值保存在字符串结构的ptr属性里面(将void*转换成long),并将字符串对象的编码设置为int。

如果字符串对象保存的是一个字符串,并且这个字符申的长度小于等于 32 字节(redis 2.+版本),那么字符串对象将使用一个简单动态字符串(SDS)来保存这个字符串,并将对象的编码设置为embstr, embstr编码是专门用于保存短字符串的一种优化编码方式:

如果字符串对象保存的是一个字符串,并且这个字符串的长度大于 32 字节(redis 2.+版本),那么字符串对象将使用一个简单动态字符串(SDS)来保存这个字符串,并将对象的编码设置为raw:

embstr编码和raw编码的优劣

  1. embstr只分配一次,分配一个连续的空间块来保存redisObject和SDS,释放同样只调用一次内存释放函数,能够更好地利用CPU性能。
  2. embstr编码的字符串是只读的,如果字符串增加长度需要重新为字符串分配内存时,redisObject和SDS都需要重新分配空间。当对embstr进行任何修改时,程序都会先将对象的编码从embstr转成raw,然后再修改。
    应用场景:可以在分布式系统中单独存储Session

String内部实现
● SDS 不仅可以保存文本数据,还可以保存二进制数据。因为 SDS 使用 len 属性的值而不是空字符来判断字符串是否结束,并且 SDS 的所有 API 都会以处理二进制的方式来处理 SDS 存放在 buf[] 数组里的数据。所以 SDS 不光能存放文本数据,而且能保存图片、音频、视频、压缩文件这样的二进制数据。
● SDS 获取字符串长度的时间复杂度是 O(1)。因为 C 语言的字符串并不记录自身长度,所以获取长度的复杂度为 O(n);而 SDS 结构里用 len 属性记录了字符串长度,所以复杂度为 O(1)。
● Redis 的 SDS API 是安全的,拼接字符串不会造成缓冲区溢出。因为 SDS 在拼接字符串之前会检查 SDS 空间是否满足要求,如果空间不够会自动扩容,所以不会导致缓冲区溢出的问题。

2.List
● 如果字符串的长度增加需要重新分配内存时,整个redisObject和sds都需要重新分配空间,所以
内部实现
List 类型的底层数据结构是由双向链表或压缩列表实现的:
● 如果列表的元素个数小于 512 个(默认值,可由 list-max-ziplist-entries 配置),列表每个元素的值都小于 64 字节(默认值,可由 list-max-ziplist-value 配置),Redis 会使用压缩列表作为 List 类型的底层数据结构;
● 如果列表的元素不满足上面的条件,Redis 会使用双向链表作为 List 类型的底层数据结构;
但是在 Redis 3.2 版本之后,List 数据类型底层数据结构就只由 quicklist 实现了,替代了双向链表和压缩列表。

应用场景
消息队列
消息队列在存取消息时,必须要满足三个需求,分别是消息保序、处理重复的消息和保证消息可靠性。

  1. 使用LPUSH 和RPOP 或者RPUSH和LPOP (满足消息报序需求)
    问题:生产者往List写入数据,不会主动的通知消费者有新消息写入,如果消费者想要及时处理消息,就需要在程序中不停的调用RPOP命令,会导致CPU一直消费在执行RPOP上,造成不必要的性能损失。
    为了解决这个问题,Redis提供了 BRPOP 命令。BRPOP命令也称为阻塞式读取,客户端在没有读到队列数据时,自动阻塞,直到有新的数据写入队列,再开始读取新数据。和消费者程序自己不停地调用RPOP命令相比,这种方式能节省CPU开销。

  2. 处理重复的消息
    消费者实现重复消息的判断
    ● 每个消息都有一个全局的ID
    ● 消费者要记录已经处理过的消息的ID,每当处理一条消息,消费者可以对比收到的消息ID和记录已经处理过的消息ID来判断是否已经处理过。
    ● 当我们LPUSH的时候 可以在消息前面加一个全局的ID

  3. 保证消息的可靠性
    当消费者程序从 List 中读取一条消息后,List 就不会再留存这条消息了。所以,如果消费者程序在处理消息的过程出现了故障或宕机,就会导致消息没有处理完成,那么,消费者程序再次启动后,就没法再次从 List 中读取消息了。
    为了留存消息,List 类型提供了 BRPOPLPUSH 命令,这个命令的作用是让消费者程序从一个 List 中读取消息,同时,Redis 会把这个消息再插入到另一个 List(可以叫作备份 List)留存。
    这样一来,如果消费者程序读了消息但没能正常处理,等它重启后,就可以从备份 List 中重新读取消息并进行处理了。

● 消息保序:使用 LPUSH + RPOP;
● 阻塞读取:使用 BRPOP;
● 重复消息处理:生产者自行实现全局唯一 ID;
● 消息的可靠性:使用 BRPOPLPUSH

List 作为消息队列有什么缺陷?
List 不支持多个消费者消费同一条消息,因为一旦消费者拉取一条消息后,这条消息就从 List 中删除了,无法被其它消费者再次消费。Redis 从 5.0 版本开始提供的 Stream 数据类型了,Stream 同样能够满足消息队列的三大需求,而且它还支持「消费组」形式的消息读取。

Hash
内部实现
Hash 类型的底层数据结构是由压缩列表或哈希表实现的:
● 如果哈希类型元素个数小于 512 个(默认值,可由 hash-max-ziplist-entries 配置),所有值小于 64 字节(默认值,可由 hash-max-ziplist-value 配置)的话,Redis 会使用压缩列表作为 Hash 类型的底层数据结构;
● 如果哈希类型元素不满足上面条件,Redis 会使用哈希表作为 Hash 类型的 底层数据结构。
在 Redis 7.0 中,压缩列表数据结构已经废弃了,交由 listpack 数据结构来实现了。

Set
Set 类型是一个无序并唯一的键值集合,它的存储顺序不会按照插入的先后顺序进行存储。

内部实现
Set 类型的底层数据结构是由哈希表或整数集合实现的:
● 如果集合中的元素都是整数且元素个数小于 512 (默认值,set-maxintset-entries配置)个,Redis 会使用整数集合作为 Set 类型的底层数据结构;
● 如果集合中的元素不满足上面条件,则 Redis 使用哈希表作为 Set 类型的底层数据结构。

Zset
Zset 类型(有序集合类型)相比于 Set 类型多了一个排序属性 score(分值),对于有序集合 ZSet 来说,每个存储元素相当于有两个值组成的,一个是有序集合的元素值,一个是排序值。
有序集合保留了集合不能有重复成员的特性(分值可以重复),但不同的是,有序集合中的元素可以排序。
内部实现
Zset 类型的底层数据结构是由压缩列表或跳表实现的:
● 如果有序集合的元素个数小于 128 个,并且每个元素的值小于 64 字节时,Redis 会使用压缩列表作为 Zset 类型的底层数据结构;
● 如果有序集合的元素不满足上面的条件,Redis 会使用跳表作为 Zset 类型的底层数据结构;
在 Redis 7.0 中,压缩列表数据结构已经废弃了,交由 listpack 数据结构来实现了。

应用场景:
排行榜 :根据元素的权重来进行排序

BitMap
介绍
Bitmap,即位图,是一串连续的二进制数组(0和1),可以通过偏移量(offset)定位元素。BitMap通过最小的单位bit来进行0|1的设置,表示某个元素的值或者状态,时间复杂度为O(1)。
内部实现
Bitmap 本身是用 String 类型作为底层数据结构实现的一种统计二值状态的数据类型。
String 类型是会保存为二进制的字节数组,所以,Redis 就把字节数组的每个 bit 位利用起来,用来表示一个元素的二值状态,你可以把 Bitmap 看作是一个 bit 数组。
HyperLogLog
#介绍
Redis HyperLogLog 是 Redis 2.8.9 版本新增的数据类型,是一种用于「统计基数」的数据集合类型,基数统计就是指统计一个集合中不重复的元素个数。但要注意,HyperLogLog 是统计规则是基于概率完成的,不是非常准确,标准误算率是 0.81%。简单来说 HyperLogLog 提供不精确的去重计数。
在 Redis 里面,每个 HyperLogLog 键只需要花费 12 KB 内存,就可以计算接近 2^64 个不同元素的基数,和元素越多就越耗费内存的 Set 和 Hash 类型相比,HyperLogLog 就非常节省空间。

百万级网页 UV 计数
Redis HyperLogLog 优势在于只需要花费 12 KB 内存,就可以计算接近 2^64 个元素的基数,和元素越多就越耗费内存的 Set 和 Hash 类型相比,HyperLogLog 就非常节省空间。
所以,非常适合统计百万级以上的网页 UV 的场景。

GEO
在日常生活中,我们越来越依赖搜索“附近的餐馆”、在打车软件上叫车,这些都离不开基于位置信息服务(Location-Based Service,LBS)的应用。LBS 应用访问的数据是和人或物关联的一组经纬度信息,而且要能查询相邻的经纬度范围,GEO 就非常适合应用在 LBS 服务的场景中。
#内部实现
GEO 本身并没有设计新的底层数据结构,而是直接使用了 Sorted Set 集合类型。
GEO 类型使用 GeoHash 编码方法实现了经纬度到 Sorted Set 中元素权重分数的转换,这其中的两个关键机制就是「对二维地图做区间划分」和「对区间进行编码」。一组经纬度落在某个区间后,就用区间的编码值来表示,并把编码值作为 Sorted Set 元素的权重分数。
这样一来,我们就可以把经纬度保存到 Sorted Set 中,利用 Sorted Set 提供的“按权重进行有序范围查找”的特性,实现 LBS 服务中频繁使用的“搜索附近”的需求。
滴滴叫车
这里以滴滴叫车的场景为例,介绍下具体如何使用 GEO 命令:GEOADD 和 GEORADIUS 这两个命令。
假设车辆 ID 是 33,经纬度位置是(116.034579,39.030452),我们可以用一个 GEO 集合保存所有车辆的经纬度,集合 key 是 cars:locations。

Stream
#介绍
Redis Stream专门为消息队列设计的数据类型
在 Redis 5.0 Stream 没出来之前,消息队列的实现方式都有着各自的缺陷,例如:
● 发布订阅模式,不能持久化也就无法可靠的保存消息,并且对于离线重连的客户端不能读取历史消息的缺陷;
● List 实现消息队列的方式不能重复消费,一个消息消费完就会被删除,而且生产者需要自行实现全局唯一 ID。
基于以上问题,Redis 5.0 便推出了 Stream 类型也是此版本最重要的功能,用于完美地实现消息队列,它支持消息的持久化、支持自动生成全局唯一 ID、支持 ack 确认消息的模式、支持消费组模式等,让消息队列更加的稳定和可靠。
#常见命令
生产者通过 XADD 命令插入一条消息:

XADD mymq * name xiaolin
“165425495388-0”
插入后会返回全局唯一的ID :“1654254953808-0”
● 第一部分:1654254953808是数据插入时,以毫秒为单位计算的当前服务器时间
● 第二部分:表示插入消息在当前毫秒内的消息序号,是以0开始编号的。

消费者通过XREAD命令从消息队列中读取消息时,可以指定一个消息ID,并从这个消息ID的下一条消息开始进行读取

从 ID 号为 1654254953807-0 的消息开始,读取后续的所有消息(示例中一共 1 条)。

XREAD STREAMS mymq 1654254953807-0

    1. “mymq”
        1. “1654254953808-0”
          1. “name”
          2. “xiaolin”
            如果想要实现阻塞读(当没有数据时,阻塞住),可以调用 XRAED 时设定 BLOCK 配置项,实现类似于 BRPOP 的阻塞读取操作。
            比如,下面这命令,设置了 BLOCK 10000 的配置项,10000 的单位是毫秒,表明 XREAD 在读取最新消息时,如果没有消息到来,XREAD 将阻塞 10000 毫秒(即 10 秒),然后再返回。

命令最后的“$”符号表示读取最新的消息

XREAD BLOCK 10000 STREAMS mymq $
(nil)
(10.00s)

Stream 独有特性

  1. Stream可以使用XGROUP创建消费组,消息队列中的某个消息一旦被消费组中的某一个消费者读取了,就不能再被该消费组内的其他消费者读取了,即同一个消费组里的消费者不能消费同一条消息。
  2. 不同消费组的消费者可以消费同一条消息(但是有前提条件,创建消息组的时候,不同消费组指定了相同位置开始读取消息)。两个消费组都是从第一条消息开始读取

基于 Stream 实现的消息队列,如何保证消费者在发生故障或宕机再次重启后,仍然可以读取未处理完的消息?
Streams 会自动使用内部队列(也称为 PENDING List)留存消费组里每个消费者读取的消息,直到消费者使用 XACK 命令通知 Streams“消息已经处理完成”,如果消费者没有成功处理消息,它就不会给Stream发送XACK命令,消息仍然会保留。消费者可以在重启后,用 XPENDING 命令查看已读取、但尚未确认处理完成的消息。

总结
● 消息保序:XADD/XREAD
● 阻塞读取:XREAD block
● 重复消息处理:Stream 在使用 XADD 命令,会自动生成全局唯一 ID;
● 消息可靠性:内部使用 PENDING List 自动保存消息,使用 XPENDING 命令查看消费组已经读取但是未被确认的消息,消费者使用 XACK 确认消息;
● 支持消费组形式消费数据

1、Redis Stream 消息会丢失吗?

Redis生产者和消费者都不会造成消息丢失,因为都会发一个XACK进行消息确认
但是Redis的消息中间件会丢失消息
● AOF持久化配置每秒写盘,这个操作是异步的,Redis宕机会会存在消息丢失的可能。
● 主从复制也是异步的,主从切换时,也会存在消息丢失的可能。

2、Redis Stream 消息可堆积吗?
Redis 的数据都存储在内存中,这就意味着一旦发生消息积压,则会导致 Redis 的内存持续增长,如果超过机器内存上限,就会面临被 OOM 的风险。
所以 Redis 的 Stream 提供了可以指定队列最大长度的功能,就是为了避免这种情况发生。
当指定队列最大长度时,队列长度超过上限后,旧消息会被删除,只保留固定长度的新消息。这么来看,Stream 在消息积压时,如果指定了最大长度,还是有可能丢失消息的。
但 Kafka、RabbitMQ 专业的消息队列它们的数据都是存储在磁盘上,当消息积压时,无非就是多占用一些磁盘空间。
因此,把 Redis 当作队列来使用时,会面临的 2 个问题:
● Redis 本身可能会丢数据;
● 面对消息挤压,内存资源会紧张;

Redis发布/订阅机制不可以作为消息队列
发布订阅机制存在以下缺点,都是跟丢失数据有关:

  1. 发布/订阅机制没有基于任何数据类型实现,所以不具备「数据持久化」的能力,也就是发布/订阅机制的相关操作,不会写入到 RDB 和 AOF 中,当 Redis 宕机重启,发布/订阅机制的数据也会全部丢失。
  2. 发布订阅模式是“发后既忘”的工作模式,如果有订阅者离线重连之后不能消费之前的历史消息。
  3. 当消费端有一定的消息积压时,也就是生产者发送的消息,消费者消费不过来时,如果超过 32M 或者是 60s 内持续保持在 8M 以上,消费端会被强行断开,这个参数是在配置文件中设置的,默认值是 client-output-buffer-limit pubsub 32mb 8mb 60。

Redis线程模型
Redis 单线程指的是「接收客户端请求->解析请求 ->进行数据读写等操作->发送数据给客户端」这个过程是由一个线程(主线程)来完成的,这也是我们常说 Redis 是单线程的原因。
但是Redis并不是单线程的,在Redis启动的时候,会启动后台线程:

  1. 在Redis2.6,会启动2个后台线程,分别处理关闭文件、AOF刷盘

  2. 在Redis4.0后,新增了一个主线程,用来异步释放Redis内存,也就是lazyfree线程。
    例如执行unlink key/flushdb async/flushall async等命令,会将这些删除操作交给后台线程来执行,避免Redis主线程的卡顿,因此当删除大key的时候,尽量使用unlink key使用后台线程来进行删除。

  3. Redis为什么这么快?

  4. Redis的大部分操作都是在内存中完成,并且采用了搞笑的数据结构,性能瓶颈不在CPU,而在机器的内存和网络带宽。

  5. Redis采用单线程,避免了线程竞争切换带来的性能开销。

  6. Redis采用了I/O多路复用机制处理大量客户端Socket请求,一个线程处理多个IO流,运行在单线程运行的情况下,同时存在监听多个Socket和已连接的Socket,一旦请求到达,就交给Redis线程处理。

  7. Redis持久化机制
    Redis共有三种持久化机制

  8. AOF日志:每执行一条写操作命令,就把该命令以追加的方式写入到一个文件里。

  9. RDB快照:将某一个时刻的内存数据,以二进制的方式写入磁盘。

  10. 混合持久化方式:集成AOF和RDB的优点。

AOF实现

AOF是先执行命令,再写入日志,这个写入日志也是由主线程执行的

好处和风险
● 避免额外的检查开销:先执行命令再执行日志,只有在命令执行成功之后才将命令写入AOF日志中,这样就不用额外开销就能保证AOF日志里的命令都可运行;如果先写日志再执行命令,这样Redis在用日志恢复数据时可能会出错。
● 不会阻塞当前命令的执行,但会阻塞后面命令的执行,因为日志也是主线程执行,可能会对后面的命令造成阻塞。
AOF持久化潜在的风险:

  1. 数据可能丢失:执行写操作命令和记录日志是两个过程, 如果先执行命令,还没有写入日志服务器就宕机了,这个数据就会有丢失的风险。
  2. 由于执行写操作命令和记录日志都是主线程执行,并且不会阻塞当前的写命令,但是执行日志操作可能会给【下一个】命令带来阻塞风险 (后来的写操作要等待日志写完毕)。

Redis写入AOF日志的过程:
当应用程序向文件写入数据时,内核通常先将数据复制到内核缓冲区中,然后排入队列,然后由内核决定何时写入硬盘。

  1. Redis 执行完写操作命令后,会将命令追加到 server.aof_buf 缓冲区;
  2. 然后通过 write() 系统调用,将 aof_buf 缓冲区的数据写入到 AOF 文件,此时数据并没有写入到硬盘,而是拷贝到了内核缓冲区 page cache,等待内核将数据写入硬盘;
  3. 具体内核缓冲区的数据什么时候写入到硬盘,由内核决定。
    ● Always,这个单词的意思是「总是」,所以它的意思是每次写操作命令执行完后,同步将 AOF 日志数据写回硬盘;
    ● Everysec,这个单词的意思是「每秒」,所以它的意思是每次写操作命令执行完后,先将命令写入到 AOF 文件的内核缓冲区,然后每隔一秒将缓冲区里的内容写回到硬盘;
    ● No,意味着不由 Redis 控制写回硬盘的时机,转交给操作系统控制写回的时机,也就是每次写操作命令执行完后,先将命令写入到 AOF 文件的内核缓冲区,再由操作系统决定何时将缓冲区内容写回硬盘。
    其实这三种策略只是在控制fsync()函数的调用时机。

AOF日志文件重写机制
随着写操作的命令越来越多,文件的大小越来越大,当AOF文件的大小超过所设定的阈值后,就会启用AOF重写机制来重写AOF文件。 (之所以重写一个AOF文件再去覆盖而不是选择复用现有的文件,就是怕AOF重写的过程中失败了,现有的AOF文件就会造成污染)。

主线程负责写命令到内存和写AOF日志

  1. 写入AOF日志的操作是在主进程完成,是因为它写入的内容不多,一般不太影响命令的操作,只有在触发重写时,AOF文件大于64MB,这个过程很耗时就不放在主进程里。

  2. AOF重写机制:会读取Redis中的最新键值对,然后写到新的AOF文件中,再去覆盖旧的AOF。这就相当于压缩了AOF文件,使得大小变小了。
    ● AOF重写机制是使用后台子进程bgwriteaof来完成。这里之所以用子进程而不是线程是因为线程之间会共享内存,那么在修改共享内存数据的时候,需要加锁来保证数据的安全,这样会降低性能。使用子进程,父进程创建子进程时,父子进程共享内存,使用的是【写时复制】,当某个进程进行修改资源,系统会创建一份副本给该调用者,对于其余进程是透明的,不受影响。所以重写 AOF 子进程会读取数据库里的所有数据,并逐一把内存数据的键值对转换成一条命令,再将命令记录到重写日志(新的 AOF 文件)。当子进程重写AOF期间,主进程修改已经存在key-value造成父子进程数据不一致会 让主进程在子进程重写AOF期间,Redis执行完一个写命令之后,会同时将这个写命令写入到【AOF缓冲区】和【AOF重写缓冲区】。当子进程处理完重写AOF日志后会向主进程发送一条异步信号,当主进程接收到该信号时,会调用一个信号处理函数,该函数主要做两件事:
    a. 将AOF重写缓存区中的所有内容追加到新的AOF文件中
    b. 新的AOF文件进行改名,覆盖现有AOF文件。

  3. 也就是说,在 bgrewriteaof 子进程执行 AOF 重写期间,主进程需要执行以下三个工作:
    ● 执行客户端发来的命令;
    ● 将执行后的写命令追加到 「AOF 缓冲区」 server.aof_buf;
    ● 将执行后的写命令追加到 「AOF 重写缓冲区」;

  4. 在整个 AOF 后台重写过程中,除了发生写时复制会对主进程造成阻塞,还有信号处理函数执行时也会对主进程造成阻塞,在其他时候,AOF 后台重写都不会阻塞主进程。

子进程是怎么拥有主进程一样的数据副本的呢?

主进程在通过 fork 系统调用生成 bgrewriteaof 子进程时,操作系统会把主进程的「页表」复制一份给子进程,这个页表记录着虚拟地址和物理地址映射关系,而不会复制物理内存,也就是说,两者的虚拟空间不同,但其对应的物理空间是同一个。 这样一来,子进程就共享了父进程的物理内存数据了,这样能够节约物理内存资源,页表对应的页表项的属性会标记该物理内存的权限为只读。
不过,当父进程或者子进程在向这个内存发起写操作时,CPU 就会触发写保护中断,这个写保护中断是由于违反权限导致的,然后操作系统会在「写保护中断处理函数」里进行物理内存的复制 (这里只会复制主进程修改的物理内存数,没修改物理内存还是与子进程共享),并重新设置其内存映射关系,将父子进程的内存读写权限设置为可读写,最后才会对内存进行写操作,这个过程被称为「写时复制(Copy On Write)」注意:如果这个阶段修改的是一个 bigkey,也就是数据量比较大的 key-value 的时候,这时复制的物理内存数据的过程就会比较耗时,有阻塞主进程的风险。写时复制顾名思义,在发生写操作的时候,操作系统才会去复制物理内存,这样是为了防止 fork 创建子进程时,由于物理内存数据的复制时间过长而导致父进程长时间阻塞的问题。
写入 AOF 日志的操作虽然是在主进程完成的,因为它写入的内容不多,所以一般不太影响命令的操作。操作系统复制父进程页表的时候,父进程也是阻塞中的,不过页表的大小相比实际的物理内存小很多,所以通常复制页表的过程是比较快的。如果父进程的内存数据非常大,那么页表也会很大,父进程在通过fork创建子进程的时候,阻塞的时间也会更久。也就会有两个阶段导致父进程阻塞:

  1. 创建子进程,由于赋值父进程页表等数据结构,阻塞时间跟页表的大小有关,页表越大阻塞越长
  2. 创建完子进程后,如果子进程或者父进程修改了共享数据,就会发生写时复制,这期间会拷贝物理内存,如果内存越大,阻塞时间也就越长。
  3. 还有主线程对客户端的命令写入到内存
  4. 主进程写追加命令到AOF日志缓冲区
  5. 主进程写追加命令到AOF重写缓冲区
  6. 主进程执行信号处理函数时(将AOF缓冲重写缓冲区追加到重写AOF文件;改名AOF,替换原有文件)也会阻塞。
  7. 在使用 Always 策略的时候,主线程在执行完命令后,会把数据写入到 AOF 日志文件,然后会调用 fsync() 函数,将内核缓冲区的数据直接写入到硬盘,等到硬盘写操作完成后,该函数才会返回。

2.重写AOF(写时不一致)导致主从不一致
为了解决重写AOF日志时,主进程修改key导致的主从不一致,Redis设置了一个重写缓冲区,这个缓冲区在创建 bgrewriteaof 子进程之后开始使用。

也就是说,在 bgrewriteaof 子进程执行 AOF 重写期间,主进程需要执行以下三个工作:
● 执行客户端发来的命令;
● 将执行后的写命令追加到 「AOF 缓冲区」;
● 将执行后的写命令追加到 「AOF 重写缓冲区」;(只有在创建子进程后开始使用该步骤)
子进程的AOF重写工作:扫描数据库中所有数据,逐一把内存中数据的键值对转换成一条条命令,写到重写日志中。当完成工作后,会异步向主进程发送一个信号(进程中的通信方式,且是异步的)。主进程收到信号,会调用一个处理函数,该函数主要做以下工作:
● 将 AOF 重写缓冲区中的所有内容追加到新的 AOF 的文件中,使得新旧两个 AOF 文件所保存的数据库状态一致;
● 新的 AOF 的文件进行改名,覆盖现有的 AOF 文件。
此信号处理函数执行时也会对主进程造成阻塞,执行完毕后主进程才能执行命令。

RDB快照
Redis的快照是全量快照,都是把内存中的所有数据都记录到磁盘中。
AOF记录的是操作命令,而RDB记录的是二进制数据,记录的是一个瞬间的内存数据。在恢复数据时,RDB的恢复效率要比AOF要高一些,因为RDB直接将数据读入内存即可,而AOF还需要额外执行操作的步骤。
save: 在主线程里面生成RDB文件,如果写入的RDB文件时间太长,会阻塞线程。
bgsave:会创建一个子线程来生成RDB文件,避免主线程的阻塞。
RDB文件的加载是在服务器启动的时候自动执行的,还可以通过配置文件来实现每隔一段时间执行如:
● save 900 1 #900 秒之内,对数据库进行了至少 1 次修改;
看似save命令,其实也是创建子线程来生成RDB快照文件,并且Redis快照是一个全量快照。都是把内存中所有数据都记录到磁盘中。

执行快照时,由于是创建子进程来进行构建RDB文件,所以主进程还是可以继续工作的,同时也是采用写时复制技术。不过不同的是:bgsave创建的子进程由于共享父进程数据,可以直接读取主进程里的内存数据,并将数据写入到RDB文件。主线程如果修改了共享的数据,那么CPU就会触发写保护中断,操作系统会在写保护中断处理函数里进行物理内存的复制,但是子进程无法将主进程修改的数据写入RDB文件,只能交由下一次bgsave快照。因此当如果子进程刚写完RDB快照,Redis服务器就宕机了,那么在子进程写RDB文件这期间,主进程修改的数据将会丢失。 并且极端情况下,如果所有的共享内存都被修改,则此时的内存占用是原先的 2 倍。

RDB 和 AOF 合体
混合使用 AOF 日志和内存快照,也叫混合持久化。
混合持久化:在AOF重写日志时,fork出来的重写子进程先将与主进程共享的内存数据以RDB方式写入到AOF文件,然后主线程处理的操作命令会被记录在重写缓冲区里,重写缓冲区里的增量命令会以AOF方式写入到AOF文件,写入完成后通知主进程将含有RDB格式和AOF格式的AOF文件替换旧的AOF文件。

好处:
● Redis在启动加载数据的时候是以RDB方式加载,减少了其他命令执行的时间,而加载RDB内容后,会使用AOF方式加载内容,减少数据的丢失。

大Key对AOF日志的影响

  1. Always策略就是每次写入AOF文件数据后,就调用fsync()函数将内核缓冲区中的数据直接写入到硬盘。如果写入一个大Key,主线程在调用fsync()函数,阻塞的时候会比较久。
  2. Everysec策略就会创建一个异步任务来执行fsync()函数,由于是异步执行,所以大Key的持久化不会影响主线程。
  3. No策略就是永不执行fsync()函数,所以大Key持久化的过程不会影响主线程。

大Key对AOF重写和RDB的影响

  1. 当AOF日志写入很多大key,AOF日志文件的大小会很大,那么很快就会触发AOF重写机制。
  2. 随着Redis存在越来越多的大Key,那么Redis就会占用很多内存,对应的页表也就会越大。如果页表大了,复制页表的过程也相当耗时,那么主进程执行fork函数的时候也会发生阻塞现象,那么就无法处理后续客户端发来的命令。
  3. 创建完子进程后,如果父进程修改共享数据的大key,这期间会拷贝物理内存,如果大Key占用的物理内存很大,就会比较耗时,可能会阻塞父进程。
  4. 引发网络阻塞,阻塞工作线程。每次获取大key产生的网络流量也是巨大的。
  5. 阻塞工作线程。如果使用 del 删除大 key 时,会阻塞工作线程,这样就没办法处理后续的命令,除非使用unlink来进行删除,是异步的 不会阻塞工作线程。

Redis的过期删除策略
Redis会把所有设置了过期时间的key 带上过期时间存储到过期字典中。
typedef struct redisDb {
dict dict; / 数据库键空间,存放着所有的键值对 */
dict expires; / 键的过期时间 */

} redisDb;

● 过期字段的key是一个指针,指向某个键对象
● 过期字典的value是一个 long long ,保存key的过期时间
● 过期字典实际上是一个哈希表,可以让我们用O(1)的时间复杂度来快速查找。当我们查询一个Key时,Redis首先检查该key是否在过期字典中。如果不存在,则正常读取键值,如果存在则会获取该key的过期时间来和当前系统时间进行比对,如果比系统时间大,那么就没有过期,否则判定该key已过期。
过期策略

● 定时删除
● 惰性删除
● 定期删除

1.定时删除策略
定期删除策略:在设置Key的过期时间时,同时创建一个定时事件,当时间到达时,由时间处理器自动执行key的删除操作。
● 优点:可以保证过期key会被尽快删除,内存尽快释放。
● 缺点: 在过期key比较多的情况下,删除过期key可能会占用相当一部分CPU时间,在内存不紧张CPU紧张的时候,会对服务器的响应时间和吞吐量造成影响。定时删除策略对CPU不友好。

2.惰性删除策略(对CPU友好,对内存不友好)
惰性删除策略:不主动删除key,只有每次从Redis访问该key的时候,都会检查该key是否过期,如果过期则删除该key。
● 因为每次访问,才会检查key是否过期,因此次策略只会使用很少的的系统资源,惰性删除对CPU最友好
● 如果一个key过期,又一直没有访问它,那么它会一直会留在数据库中,只要它一直不被访问就一直占用内存空间。

惰性删除的源码实现:在db.c文件中 expireIfNeeded
int expireIfNeeded(redisDb *db, robj key) {
// 判断 key 是否过期
if (!keyIsExpired(db,key)) return 0;

/
删除过期键 */

// 如果 server.lazyfree_lazy_expire 为 1 表示异步删除,反之同步删除;
return server.lazyfree_lazy_expire ? dbAsyncDelete(db,key) :
dbSyncDelete(db,key);
}
Redis在访问key之前,都会调用 expireIfNeeded判断是否过期,如果没有过期不做任何处理,返回正常值给客户端,如果过期,就删除key,返回null给客户端,根据lazyfree_lazy_expire来异步删除还是同步删除,
3. 定期删除策略:
通过限制删除操作执行的时长和频率,来减少删除操作对CPU的影响,同时也能删除一部分的数据减少过期键对空间的占用。
每隔一段时间【随机】从数据库中取出一定数量的key进行检查,并删除其中过期的key。默认间隔检查时间默认值是hz 10 (默认每秒进行10次检查),随机抽查的数量是20,写死在代码中的。每次检查数据库并不是遍历过期字典中的所有 key,而是从数据库中随机抽取一定数量的 key 进行过期检查。
Redis定期删除策略
接下来,详细说说 Redis 的定期删除的流程:

  1. 从过期字典中随机抽取 20 个 key;
  2. 检查这 20 个 key 是否过期,并删除已过期的 key;
  3. 如果本轮检查的已过期 key 的数量,超过 5 个(20/4),也就是「已过期 key 的数量」占比「随机抽取 key 的数量」大于 25%,则继续重复步骤 1;如果已过期的 key 比例小于 25%,则停止继续删除过期 key,然后等待下一轮再检查。
    可以看到,定期删除是一个循环的流程。
    那 Redis 为了保证定期删除不会出现循环过度,导致线程卡死现象,为此增加了定期删除循环流程的时间上限,默认不会超过 25ms。

内存淘汰策略
如果Redis的运行内存已经超过Redis设置的最大内存之后,则会使用内存淘汰策略删除符合条件的key。
淘汰策略共有8种,可以分为【不进行数据淘汰】和【进行数据淘汰】两类策略。

  1. 不进行数据淘汰策略,Redis3.0之后默认的策略。当运行内存超过最大设置的内存,不淘汰任何数据,这时如果有新的数据写入,则会触发OOM,如果只有删除和查询,那么还是可以正常工作的。
  2. 进行数据淘汰策略
    又可分为在设置了过期时间的数据中淘汰和在所有数据范围内进行淘汰。
    ● volatile-random:随机淘汰设置了过期时间的任意键值;
    ● volatile-ttl:优先淘汰更早过期的键值。
    ● volatile-lru(Redis3.0 之前,默认的内存淘汰策略):淘汰所有设置了过期时间的键值中,最久未使用的键值;
    ● volatile-lfu(Redis 4.0 后新增的内存淘汰策略):淘汰所有设置了过期时间的键值中,最少使用的键值;
    在所有数据范围内进行淘汰:
    ● allkeys-random:随机淘汰任意键值;
    ● allkeys-lru:淘汰整个键值中最久未使用的键值;
    ● allkeys-lfu(Redis 4.0 后新增的内存淘汰策略):淘汰整个键值中最少使用的键值。

Redis中的LRU算法和LFU算法
LRU:最近最少使用算法
Redis中没有使用链表来管理缓存数据,因为这会带来额外的空间开销,如果有大量数据被访问,就会带来很多链表移动操作,相当耗时,会降低Redis缓存性能。
Redis内LRU算法实现:在Redis对象结构体重添加一个额外的字段,用于记录此数的最后一次访问时间。当Redis内存淘汰的时候,会使用随机采样的方式淘汰数据,随机取5个值,然后淘汰最近没有使用的那个。
● 优点:不用为所有的数据维护一个大链表,节省了空间。
● 不用每次访问都移动链表项,提升了缓存性能。
但是LRU会造成缓存污染的情况,比如一次应用读取了大量的数据,这些数据只被读取这一次,但是会在内存中停留很久,造成缓存污染。

在Redis4.0之后引入了LFU算法(最近最不常用)来解决:相比于LRU算法,LFU在结构体中多记录了一个数据访问的频次。
typedef struct redisObject {

// 24 bits,用于记录对象的访问信息
unsigned lru:24;  
...

} robj;

在LRU算法中,Redis对象头的24bits的lru字段用来存储key的访问时间戳,因此lru模式下,可以根据对象头中的lru字段记录值来比较最后一次Key访问的时间长。
在LFU算法中,Redis对象头被拆分成两部分,高16bit存储时间戳,低8bit用来存储访问次数,越低越容易被淘汰,初始值为5。每次Key被访问都会先做一个衰减操作,如果上一次访问的时间和这一次访问的时间越长,那么这个衰减值越大,衰减过后,再对这个值进行累加,累加是根据概率倍增的,如果logc越大,那么它越难增加。

高可用
1.主从复制
主服务器可以读写操作,从服务器一般只用作读操作。
第一次同步
服务器B执行replicaof 服务器A:服务器A端口号,就可让服务器B变成服务器A的从服务器

主从服务器之间的第一次同步可分为三个阶段:
● 第一阶段是建立连接,协商同步
● 第二阶段是主服务器同步数据给从服务器
● 第三阶段是主服务器发送新写操作给从服务器

主服务器是采用bgsave命令,产生一个子进程来生成RDB文件,因此不会阻塞主进程,但是当子进程写RDB文件的时候,主进程这时候进行写操作就会造成主从不一致的现象。
为了保证主从一致,主服务器在以下三种情况进行写操作命令,会写入到replication buffer缓冲区里面。

  1. 主服务器生成RDB文件期间
  2. 主服务器发送RDB文件期间
  3. 从服务器加载RDB文件期间

第三阶段:
当主服务器生成的RDB文件发送完,从服务器接收到RDB文件后,会丢弃所有旧的数据,将RDB数据加载到从服务器上,并且会回复一个确认消息给主服务器。接着主服务器会将replication buffer缓冲区里所有记录写操作命令发送给从服务器,从服务器执行来自主服务器replication buffer缓冲区的命令,这时候主从就一致了。

命令传播

分摊主服务器的压力
为了避免主服务器的从服务器数量过多,造成主服务器忙于fork()子进程,如果主服务器的内存数据非常大,在拷贝页表的时候会阻塞主进程。并且传输RDB文件会占用主服务器的网络带宽,因此可以给从服务器也设置从服务器。

增量复制
如果主从服务器的网络连接断开又恢复,在Redis2.8之前会采用全量复制,从2.8开始采用增量复制的方式。

● repl_backlog_buffer,是一个「环形」缓冲区,用于主从服务器断连后,从中找到差异的数据;
● replication offset,标记上面那个缓冲区的同步进度,主从服务器都有各自的偏移量,主服务器使用 master_repl_offset 来记录自己「写」到的位置,从服务器使用 slave_repl_offset 来记录自己「读」到的位置。

主要有三个步骤:

  1. 从服务器在恢复网络后,会发送psync {runId} {offset}命令给主服务器。此时的offset是 slave_repl_offset记录的从主服务器传播过来命令读到的位置。
  2. 主服务器接收到从服务器发送过来的命令后,会发送一个continue响应从服务器接下来采用增量复制的方式同步数据
  3. 主服务器将从服务器断线期间,所执行的命令发送给从服务器,然后从服务器执行这些命令。
    repl_backlog_buffer:是一个环形缓冲区,当主服务器进行命令传播时,不仅会将写命令发送给从服务器还会将写命令写入到 repl_backlog_buffer缓冲区里。
    网路重连后,主服务器会根据从服务器发送过来psync中offset偏移量和自己写偏移位置master_repl_offset求差值,依次来判断,如果从服务器读取的数据还在repl_backlog_buffer缓冲区里,主服务器便采用增量同步的方式,如果从服务器读取的数据不再缓冲区里(repl_backlog_buffer是一个环形,后面写入的数据会覆盖之前的数据),则会采用全量同步的方式。

总结
主从复制共有三种模式:全量复制、基于长连接的命令传播、增量复制。
主从服务器第一次同步的时候,就是采用全量复制,此时主服务器会两个耗时的地方,分别是生成 RDB 文件和传输 RDB 文件。为了避免过多的从服务器和主服务器进行全量复制,可以把一部分从服务器升级为「经理角色」,让它也有自己的从服务器,通过这样可以分摊主服务器的压力。
第一次同步完成后,主从服务器都会维护着一个长连接,主服务器在接收到写操作命令后,就会通过这个连接将写命令传播给从服务器,来保证主从服务器的数据一致性。
如果遇到网络断开,增量复制就可以上场了,不过这个还跟 repl_backlog_size 这个大小有关系。
如果它配置的过小,主从服务器网络恢复时,可能发生「从服务器」想读的数据已经被覆盖了,那么这时就会导致主服务器采用全量复制的方式。所以为了避免这种情况的频繁发生,要调大这个参数的值,以降低主从服务器断开后全量同步的概率。
#面试题

  1. 如何判断Redis某个结点是否正常工作

● Redis判断结点是否正常工作是根据ping-pong,如果发送一半以上的ping 都没有pong回应,集群则会认为这个结点挂掉了,会断开这个结点的连接。
● Redis默认每隔10s对从结点发送ping命令,判断从结点的存活性和连接状态
● 从结点每隔1s发送replconf ack {offset}命令,给主节点上报自身复制的偏移量,以此来实时监测主从结点网络状态,检查数据是否丢失,如果丢失会从主节点拉取丢失数据。

2.主从复制架构中,过期key的处理
主结点处理了一个key或者通过淘汰算法淘汰了一个key,这个时间主节点模拟一条del命令发送给从结点,从节点接收到该命令就会删除对应的Key.

3.Redis是同步复制还是异步复制?
Redis主节点每次收到写命令之后,先写到内部的缓冲区,然后再异步发送给从结点。

4.主从复制中两个Buffer(replication buffer、repl backlog buffer)
● repl_backlog_buffer是在增量复制阶段出现,一个主节点只分配一个repl_backlog_buffer,用来记录对从结点命令传播的命令,超出大小限制会覆盖之前的数据。
● replication buffer:是在全量复制阶段和增量复制阶段都会出现:主结点会给每个新连接的从结点分配一个replication buffer。(全量复制在进行写RDB和传输RDB的情况下会记录主进程的写命令,然后再传输给从服务器),(增量复制的时候主服务器找到主从差异后也会将增量命令写入到replication buffer)当 replication buffer 满了,会导致连接断开,删除缓存,从节点重新连接,重新开始全量复制。

5.如何应对主从不一致的问题
之所以出现主从不一致的情况,是因为主从结点之间的命令复制是异步的,所以无法实现强一致性(不能时时刻刻保证一致)。
原理:当主节点命令传播阶段,主节点接收到新的写命令后,会发送给从节点。但是主节点不会等到从节点实际执行完命令后,再把结果发送给客户端,而是主要主节点执行完毕,就会响应给客户端,这时候主从就不一致了。
如何应对主从不一致?
第一种方法,尽量保证主从节点间的网络连接状况良好,避免主从节点在不同的机房。
第二种方法,可以开发一个外部程序来监控主从节点间的复制进度。

6.主从切换如何减少数据丢失
主从切换过程中,产生数据丢失的情况有两种:
● 异步复制同步丢失
● 集群产生脑裂数据丢失
我们不可能保证数据完全不丢失,只能做到使得尽量少的数据丢失。

异步复制同步丢失

Redis中主从结点数据复制,是异步的。当客户端发送写请求给主节点,主节点会返回响应给客户端,接着主节点将写请求异步同步给各个从结点,但是此时主节点还没来得及同步给从结点时发生了断点,那么主节点内存中的数据会丢失。
减少异步复制的数据丢失的方案
Redis中有一个参数min-slaves-max-lag,表示主节点数据复制和同步都超过了设定的值,那么主节点就会拒绝接收任何请求。假如设置为10s,如果数据同步所需要的时间超过10s,就会认为master未来宕机后损失的数据很多,master拒绝写入新请求。这样就能将 master 和 slave 数据差控制在10s内,即使 master 宕机也只是这未复制的 10s 数据。那么对于客户端,当客户端发现 master 不可写后,我们可以采取降级措施,将数据暂时写入本地缓存和磁盘中,在一段时间(等 master 恢复正常)后重新写入 master 来保证数据不丢失

  1. 集群产生脑裂现象
    主节点的网络发生了问题,它与所有从节点都失联了,但是此时主节点和客户端的连接是正常,这时候客户端发送命令,这时候这些数据都被主节点缓存到了缓冲区里面,因为主节点网路问题,这些数据无法被同步到从结点。这时候,哨兵也发现主节点失联了,它就认为主节点挂了,于是会重新选举主节点,边会帮当前主节点变为从结点A,然后从结点A会向新的主节点请求数据,第一次同步是全量同步,会将从结点的数据全部清空,这时候客户端在A写入的数据也就全部丢失了,这就是集群产生脑裂数据丢失的问题。

减少脑裂方案:
当主节点发现从节点下线数量太多或者网络延迟过大,那么主节点就会禁止写操作,直接把错误返回给客户端。
在 Redis 的配置文件中有两个参数我们可以设置:
● min-slaves-to-write x,主节点必须要有至少 x 个从节点连接,如果小于这个数,主节点会禁止写数据。
● min-slaves-max-lag x,主从数据复制和同步的延迟不能超过 x 秒,如果主从同步的延迟超过 x 秒,主节点会禁止写数据。
这样无论主节点是真故障还是网路延迟都不会接收客户端的请求,也就不会写入新数据,那么当新主节点被选举出来,去情况这个结点的数据也不会造成数据丢失了。

为什么要有哨兵
Redis 在 2.8 版本以后提供的哨兵(Sentinel)机制,它的作用是实现主从节点故障转移。它会监测主节点是否存活,如果发现主节点挂了,它就会选举一个从节点切换为主节点,并且把新主节点的相关信息通知给从节点和客户端。

哨兵机制是如何工作的?
哨兵是运行在特殊模式下的Redis进程,所以它也是一个节点,相当于是一个观察者节点,观察主从节点。
监控
选主
通知
重点学习:

  1. 哨兵结点是如何监控节点的?又是如何判断主节点是否真的故障了?
  2. 根据什么规则选择一个从节点切换为主节点?
  3. 怎么把新主节点的相关信息通知给从节点和客户端?

1.如何判断主节点真的故障了?
哨兵每隔1s给所有主从节点发送PING命令来判断它们是否正常运行。如果如果主从节点没有在规定的down-after-milliseconds时间内响应哨兵就会标记它为主观下线。
对于主节点还有客观下线:是为了防止主节点其实并没有故障,而是因为主节点的系统压力比较大或者网络发生了阻塞导致没有在规定时间内响应哨兵的响应。
为了避免哨兵误判的情况会部署哨兵集群来降低误判率。通过多个哨兵一起判断来避免单个哨兵因为自身网络状态不好而产生的误判,因为不可能同时多个机器网络不稳定。
客观下线的逻辑:
当哨兵判断主节点主观线下后,就会向其他哨兵发送is-master-down-by-addr命令,其他哨兵接收到该命令就会根据自身和主节点的网络状况,做出投票赞成或者拒绝投票的响应。赞成的票数包括自身那一票如果值达到quorum就会被该哨兵标记为客观下线。

由哨兵集群中的Leader进行故障转移(Leader来进行主从切换)

Leader的选举:Leader是由候选者来当选,候选者就是标记该主节点为客观下线的哨兵。(可能会存在多个候选者)
候选者会向其他哨兵发送命令,表明自己希望成功Leader来执行主从切换,每个哨兵只有一个票能投给候选者(投完后就不能参与投票),只有候选者的票能投给自己也能投给别人。

哪个哨兵节点判断主节点为「客观下线」,这个哨兵节点就是候选者,所谓的候选者就是想当 Leader 的哨兵。
候选者如何选举成为 Leader?
每个哨兵都有一次投票机会,可以投自己或者投别人,但是只有候选者才能投自己。
满足候选者的条件:
● 拿到半数以上的赞成票
● 拿到票数的同时还要大于等于哨兵配置文件中得quorum值。(quorum值一般设置为哨兵数量/2+1)
当有两个哨兵节点判断到主节点为客观下线,这时就这两个哨兵就都变成候选者了。
每位候选者都会先给自己偷一票,然后向其他哨兵发起投票请求。如果投票者先收到候选者A的投票请求,就会先投给它,之后如果接收到候选者B的请求,那么会拒绝投票B。

● quorum个哨兵认为主节点下线了就会将主节点标记为客观下线
● 哨兵拿到半数以上的赞成票,并且票数大于等于quorum才会被选举为Leader 成为 Leader 来执行主从切换。

哨兵至少需要3个
如果哨兵集群中只有 2 个哨兵节点,此时如果一个哨兵想要成功成为 Leader,必须获得 2 票,而不是 1 票。
所以,如果哨兵集群中有个哨兵挂掉了,那么就只剩一个哨兵了,如果这个哨兵想要成为 Leader,这时票数就没办法达到 2 票,就无法成功成为 Leader,这时是无法进行主从节点切换的。
一般quorum的值建议设置为哨兵个数的一半+1。可以避免能够判定主节点客观下线,但不能主从切换,导致【判定主节点客观下线】这件事白做了一样,可以避免这种无用功。

主从故障转移的过程是怎样的?
网络状态、优先级、复制进度、ID号
主从故障转移操作包含以下四个步骤:
● 第一步:在已下线主节点(旧主节点)属下的所有「从节点」里面,挑选出一个从节点,并将其转换为主节点。
● 第二步:让已下线主节点属下的所有「从节点」修改复制目标,修改为复制「新主节点」;
● 第三步:将新主节点的 IP 地址和信息,通过「发布者/订阅者机制」通知给客户端;
● 第四步:继续监视旧主节点,当这个旧主节点重新上线时,将它设置为新主节点的从节点;

首先根据down-after-milliseconds *10 来判断,发生断连次数超过10就判定网络不好。

步骤一:选出新节点
要在已下线主节点属下所有【从节点】挑选一个状态良好,数据完整的节点,然后向这个【从节点】发送slaveof no one 将这个从节点转化为主节点。
首先将已经下线的从节点剔除,再剔除网络状态不好的节点(主从节点断连的最大连接超时时间:down-after-milliseconds毫秒内,主从节点都没有通过网络连接上,就认为断连)如果断连的次数超过10次,就说明网络状况不好,不适合作为新主节点。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值