12、认识redis的集群模式(上)


通过前面章节的介绍我们知道,Redis 主从复制可以实现读写分离,Redis Sentinel 模式可以实现自动故障转移,解决了 Redis 主从复制模式的高可用问题,看起来是个非常美好的方案。但 Sentinel 还是存在一个问题,那就是 横向扩展问题。

在面对海量数据的时候,我们无法使用一个 Redis Master 存储全部数据,此时就需要一套分布式存储方案将数据进行切分,每个 Redis 主从复制组只存储一部分数据,这样就可以通过增加机器的方式增加 Redis 服务的整体存储能力。

Cluster理解

Redis Cluster 方案是 Redis 官方在 3.x 版本之后引入的分布式存储,Redis Cluster 区别于前文介绍的两种方案,其分片功能全部是在服务端实现的。Redis Cluster 采用分区的方式对数据存储,每个分区都是独立、平行的 Redis 主从复制组,架构如下图所示。
在这里插入图片描述

Redis Cluster 采用去中心化的多主多从方案,Redis Cluster 内部的 Redis 节点彼此之间互相连接,通过 PING 命令探活,使用 Gossip 协议发现节点变更。

Redis Cluster 内部按照 slot 对数据进行切分的,每个 Master 节点只负责维护一部分 slot 以及落到这些 slot 上的 KV 数据,但是 Redis Cluster 中每个节点都有全量的 slot 信息,这样的话,客户端无论连接到哪个节点,都可以知道自己访问的 Key 落到哪个 slot 里面以及这个 slot 位于哪个节点中。

Redis Cluster 作为 Redis 官方提供的集群方案,目前基本上已经取代了客户端分片方案和代理层分片方案,成为实际生产中应用最为广泛的 Redis 分布式存储方案了。在本模块的剩余内容中,我们将展开介绍 Redis Cluster 方案的核心原理和实现。

核心结构体解析

在 Redis Cluster 中,有四个非常核心的数据结构:clusterState 、 clusterNode、clusterSlotToKeyMapping 和 clusterLink。下面我们先来看看这四个结构体的核心字段。

clusterNode

每个 clusterNode 表示一个 Redis Cluster 节点,其中一个 clusterNode 实例是用来记录当前节点自身的一些信息,其他的 clusterNode 实例用来代表 Redis Cluster 中其他的 Redis 节点

clusterNode 中核心字段的功能

  • name 字段:长度为 40 的字符串,记录了该 clusterNode 的名称。
  • ctime 字段:记录了该 clusterNode 实例创建的时间戳。
  • ip、port、cport 字段:记录了该 clusterNode 节点的 ip 、端口号(监听客户端建连请求的端口)以及集群端口(监听其他节点建连请求的端口)。
  • hostname 字段:记录了该 clusterNode 节点的 hostname。它是在 Redis 7.0 中新加的一个字段
  • flags 字段:该 clusterNode 的状态集合,其中每一个比特位都标识了当前 clusterNode 的一个状态,这也是 Redis 里面常见的状态字段设计了。
  • link 字段:该字段是一个 clusterLink 指针,指向的 clusterLink 实例维护了当前 clusterNode 节点与该 clusterNode 实例表示的 Cluster 节点之间的连接信息。
  • configEpoch 字段:记录了该 clusterNode 节点的 epoch 值
  • slots、numslots 字段:slots 是一个 char 类型数组,记录该 clusterNode 维护的 slot(槽位)集合,按照 bitmap 的方式存储,默认 Redis Cluster 会将全量数据分为 16384 个 slot 存储,所以,slots 这个 char 数组的长度为 16384/8 = 2048;numslots 字段记录了当前 clusterNode 节点维护的 slot 个数
  • slaves、numslaves 字段:slaves 是一个 clusterNode 二级指针,指向一个 clusterNode 指针数组,其中每个 clusterNode 指针都指向了当前 clusterNode 的一个 Slave 节点;numslaves 字段记录了当前 clusterNode 节点的 Slave 个数,也就是 slaves 数组的长度。
  • slaveof 字段:slaveof 是一个 clusterNode 指针,如果当前 clusterNode 表示是一个 Slave 节点,则 slaveof 字段指向了表示其 Master 的 clusterNode 实例。
  • ping_sent、pong_received 字段:记录了当前 clusterNode 最后一次发送 PING 命令和最后一次收到 PONG 响应的时间戳。
  • repl_offset、repl_offset_time 字段:repl_offset 记录了最后一次与该 clusterNode 节点同步 Replication Offset 时得到的值;repl_offset_time 则是最后一次同步 Replication Offset 的时间戳。

clusterState

Redis Cluster 中每个节点都维护了一个 clusterState 实例,它用来记录当前 Cluster 节点视角下的 Redis Cluster 状态。在 redisServer 这个核心结构体中,维护了一个 cluster 字段,该字段就是 clusterState 指针。

clusterState 结构体的核心字段

  • myself 字段:一个 clusterNode 指针,指向了代表当前 Redis 节点的 clusterNode 实例。
  • currentEpoch 字段:记录了当前 Redis Cluster 节点看到的最新 Cluster 纪元。下面简单说明一下该字段与 clusterNode中的configEpohc 字段的区别:
    • currentEpoch 是整个 Redis Cluster 使用的统一纪元值,也被认为是统一的逻辑时钟。初始化时各个节点的 currentEpoch 值都为 0,后续在节点之间交互时,如果发现其他 Cluster 节点的 currentEpoch 值比自己的 currentEpoch 值大,则当前节点会更新自身的 currentEpoch 值与对端节点一致,直至整个 Redis Cluster 的 currentEpoch 全部一致。
    • 在后面我们会看到,currentEpoch 会用于 failover 操作中,举个例子,Slave 节点发现其 Master 节点下线时,就会发起 failover 操作,首先就是增加自身的 currentEpoch 值,使其成为集群中最大的 currentEpoch 值,然后 Slave 向所有节点发起投票请求,请求其他 Master 投票给自己,使自己能成为新一任 Master。其他节点收到包后,发现投票请求里面的 currentEpoch 比自己的 currentEpoch 大,证明自己的信息需要更新了,此时就会更新自己的 currentEpoch 值,并投票给该 Slave 节点。
    • configEpoch 是一组主从复制关系内的纪元值,而非整个 Redis Cluster 的纪元值。Redis Cluster 中各个 Master 节点的 configEpoch 值不同,但在一组主从复制关系中,Master 节点与其 Slave 节点拥有相同的 configEpoch 值。
    • 一组主从复制关系中的 Master 和 Slave 节点向其他节点发送消息的时候,都会附带 Master 的 configEpoch 值以及其管理的 slot 信息,其他节点收到消息之后记录这些信息。configEpoch 主要用于解决不同节点的配置冲突问题。举个例子,节点 A 是节点 B 的 Master 节点,节点 A 负责管理 slot 100 ~ 200,节点 C 收到节点 A 发来的心跳消息时就会记录该信息,然后 A 发生了故障,B 成为新一任 Master,此时就会增加 configEpoch。之后,B 也通过消息告诉节点 C 负责自己 slot 100 ~ 200,在节点 C 的视角中,就有 A、B 两个节点同时声称自己负责 slot 100 ~ 200,这就出现了冲突。此时,节点 C 会比较 B 和 A 的 configEpoch 值,以拥有较大 configEpoch 的消息为准,最终,C 会认为节点 B 负责 slot 100 ~ 200。
  • state 字段:记录当前 Redis Cluster 的状态,只有两个可选值,一个是 CLUSTER_OK 表示集群在线,另一个是 CLUSTER_FAIL 表示集群下线。
  • size 字段:记录了当前 Redis Cluster 中有效的 Master 节点个数。Master 只要负责管理至少一个 slot 时,才会有读写请求发送到该 Master 节点,它才是一个有效 Master 节点。
  • nodes、nodes_black_list 字段:nodes 字段是一个 dict 指针,记录了整个 Redis Cluster 中全部节点对应的 clusterNode 实例,其中的 Key 是节点名称(即 clusterNode->name),Value 是代表每个节点的 clusterNode 实例。nodes_black_list 是一个黑名单,用来排除一些指定的节点。
  • slots 字段:该字段是一个 clusterNode 数组,用于记录各个 slot 归属于哪些 Cluster 节点,例如,slots[i] = clusterNodeA 就表示 slot i 由节点 A 负责管理。
  • migrating_slots_to、importing_slots_from 字段:这两个字段都是 clusterNode 指针数组,分别记录了对应 slot 编号的迁移状态。例如,migrating_slots_to[i] = clusterNodeA 表示编号为 i 的 slot 要从当前节点迁移至节点 A,importing_slots_from[i] = clusterNodeA 表示编号为 i 的 slot 要从 A 节点迁移到当前节点。

clusterSlotToKeyMapping

clusterSlotToKeyMapping: cluster 模式下,存储key 与哈希槽映射关系的数组

在 7.0 版本中,Redis 会在 redisDb 中维护一个 clusterSlotToKeyMapping 指针(slots_to_keys 字段),在 clusterSlotToKeyMapping 中维护了一个长度为 16384 的 slotToKeys 数组,其中的下标是 slot 的编号,对应的 slotToKeys 将这个 slot 中全部的键值对串联成了一个双端链表。注意,并不是单独拷贝一份键值对数据,而是复用了存到 redisDb 中的 dictEntry 实例。

/* 见: https://github.com/redis/redis/blob/7.0/src/server.h#L917 */
typedef struct redisDb {
    // ...
    clusterSlotToKeyMapping *slots_to_keys; /* Array of slots to keys. Only used in cluster mode (db 0). */
} redisDb;

/* 见: https://github.com/redis/redis/blob/7.0/src/cluster.h#L8 中 */
#define CLUSTER_SLOTS 16384
struct clusterSlotToKeyMapping {
    slotToKeys by_slot[CLUSTER_SLOTS];
};

/* 见: https://github.com/redis/redis/blob/7.0/src/cluster.h#L155 */
typedef struct slotToKeys {
    uint64_t count;   // 这个slot中键值对的数量
    dictEntry *head;  // 指向dictEntry双端链表头节点
} slotToKeys;

dictEntry 里面除了键值对数据之外,还有一个 metadata 指针数组,在 Cluster 模式下,metadata 中存储的就是 clusterDictEntryMetadata 指针,如下所示,clusterDictEntryMetadata 里面就维护了指向前后两个 dictEntry 的指针:

typedef struct clusterDictEntryMetadata {
    dictEntry *prev;  // 同一个slot中的前一个dictEntry节点
    dictEntry *next;  // 同一个slot中的下一个dictEntry节点
} clusterDictEntryMetadata;

这两同一个slot中的多个数据就组成了一个双链表结构, 添加dictEntry时根据头插法添加到slotToKeys->head 链表中。

clusterLink

clusterLink 结构体抽象了 Redis Cluster 中两个节点之间的网络连接,其核心字段如下。

  • node 字段:它是一个 clusterNode 指针,指向的 clusterNode 实例表示了对端 Redis Cluster 节点
  • conn 字段:它是一个 connection 指针,指向的 connection 实例抽象了两个节点的网络连接
  • sndbuf 字段:sds 类型,当前 clusterLink 发送数据的缓冲区
  • rcvbuf 字段:rcvbuf 是一个 char 类型的数组,它是当前 clusterLink 接收数据的缓冲区,rcvbuf_len 和 rcvbuf_alloc 字段则记录了该缓冲区的使用情况和总长度。
  • ctime 字段:记录了当前 clusterLink 实例创建的时间戳。

下面我们通过一张图简单总结一下 clusterState、clusterNode 、clusterSlotToKeyMapping 以及 clusterLink 这四个核心结构体之间的关系
在这里插入图片描述

图中 redisSeerver位于: https://github.com/redis/redis/blob/7.0/src/server.h#L1455

struct redisServer {
    redisDb *db;
    struct clusterState *cluster;  /* State of the cluster */
}

集群中redis节点启动流程

  1. 初始化 clusterState 实例,也就是 redisServer.cluster 字段

  2. 加载 Cluster 配置文件nodes.conf; nodes.conf 文件正常情况下是 Redis Cluster 节点在运行过程中自动生成的,不建议我们手动进行修改。nodes.conf 配置文件中记录了当前节点中各个 clusterNode 实例的关键信息,这里加载 nodes.conf 配置文件就是在当前节点重启的时候恢复之前内存中的 clusterNode 实例

  3. 监听 cluster port 端口; 这里的 cluster port 端口默认是 redisServer.port + 10000 也就是16379, 。当然,我们也可以在 redis.conf 配置文件中,使用 cluster-port 配置项指定这个端口的具体值。
    同时, 注册函数用于监听集群中其它节点发来的建连请求(acceptHandler), 该函数会创建clusterLink实例, 然后会注册可读事件的回调(readHandler)

  4. 初始化当前 Cluster 节点自身中 clusterState 的各个字段,初始化表示当前节点的clusterNode 实例的各个字段

加载 nodes.conf 文件

nodes.conf 配置文件中的每一行都表示一个 clusterNode 实例,每行按照空格分隔,至少存储了 8 部分信息,各部分含义如下图所示:
在这里插入图片描述

  • 第一部分(name)记录了 clusterNode 实例的名称

  • 第二部分记录了 clusterNode 实例的 ip、port、cport

  • 第三个部分记录了 clusterNode 实例中 flags 信息, flags 字段中的每一位都会被解析为一个字符串,并通过逗号分割方式记录到这里; flags的值例如myself表示该 clusterNode 实例表示当前 Redis Cluster 节点自身, master表示该 clusterNode 实例表示一个 Master 节点 等

  • 第四部分记录了该节点的主从关系。如果当前这行记录的是一个 Master 节点,则该部分为中划线;如果当前这行记录的是一个 Slave 节点,则该部分记录的是其 Master 的 name 值。

  • 第五、六部分记录了对应 clusterNode 实例中的 ping_sent、pong_received 字段值,也就是当前节点最后一次向该节点发送 PING 命令以及最后一次收到 PONG 命令的时间戳。

  • 第七部分记录了 clusterNode 实例中的 configEpoch 字段。

  • 第八部分记录了该节点与当前节点之间的连接状态,主要是根据 clusterNode 的 link 字段判断的。

  • 第九部分记录了该节点负责的 slot。上图第二行记录中,编号在[0, 5959]、[10922, 11422] 这两个范围中的 slot 由该节点负责维护。

  • 第十部分记录了该节点相关的 slot 迁移情况,这部分信息只会在当前节点对应记录中存在(即 flags 中包含 myself 标识的记录 )。上图第二行记录中,编号为 5960 的 slot 从该节点迁移到 5fc4589638723b1707fd65345e763befb36454d 这个节点,编号为 10921 的 slot 从5fc4589638723b1707fd65345e763befb36454d 这个节点迁移到该记录表示的节点。

握手流程

redis集群中的节点启动后, 就需要感知其它节点的存在。

CLUSTER MEET 命令

我们可以通过 CLUSTER NODES 命令查询节点能感知到的整个Cluster 的信息,该命令的返回与上面介绍的 nodes.conf 文件中的格式类似。

在 Redis Cluster 节点第一次启动的时候,它只能感知到自身的存在,我们可以手动执行 CLUSTER MEET 命令让当前节点感知到指定目标节点:

 CLUSTER MEET <ip> <port> [<cport>]

当 Redis 收到 CLUSTER MEET 命令之后, 会创建目标节点对应的 clusterNode 实例并添加到 clusterState->nodes 字典中。这里注意新建 clusterNode 的两个字段。

  • 一个是 name 字段,因为此时还不知道对端节点的真实名称,所以这里会随机生成一个长度为 40 的字符串暂时作为其 name,也是其在 clusterState->nodes 字典中的 Key。
  • 另一个是 flags 字段,初始值为 HANDSHAKE|MEET(省略 CLUSTER_NODE_ 前缀),HANDSHAKE 表示当前节点后续会向目标节点发送 PING 请求完成握手,MEET 表示后续会向目标节点发送 MEET 请求加入集群。另外,加入 cluster->nodes 之前还会先遍历该字典,保证没有节点出现地址重复。

下面我们来看 A、B 两个 Redis Cluster 节点握手的示例,下图展示了节点 A 处理完 CLUSTER MEET 命令之后的状态:
在这里插入图片描述

建立连接

Redis Cluster 会在在定时任务中建连操作以及后续的握手操作。

  1. redisServer.cluster->nodes中挑选满足条件的节点
  2. 创建clusterLink实例以及底层的 connection 实例, 与挑选的节点建立网络连接, 其中clusterLink中的node是对端Cluster 节点的 clusterNode实例

下图展示了节点 A 向节点 B 发起建连成功之后的状态:
在这里插入图片描述

发送MEET消息

建连成功后, 会做下面三件事

  1. 给新建的连接注册可读事件的监听
  2. 向对端 Cluster 节点发送 MEET 消息, 邀请目标节点加入集群
  3. 将 MEET 标记位从对端 Cluster 节点相应的 clusterNode->flags 字段中清理掉

下图展示了节点 A 向节点 B 发送完 MEET 消息之后的状态:
在这里插入图片描述

关于发送的消息

集群节点中, 有三种消息MEET、PING 和PONG, 下面简单介绍一下这三种消息的含义

  • MEET 消息:当 Cluster 节点接收到客户端发送的 CLUSTER MEET 命令时,不会马上发送, 而是会在下一个 serverCron() 周期中向目标节点发送 MEET 消息,邀请目标节点加入集群。
  • PING 消息:用来检测对端节点是否在线的探活消息。
  • PONG 消息:当 Cluster 节点收到对端节点发来的 MEET 消息或者 PING 消息时,会返回一条 PONG 消息作为响应。

在发送这三种消息的时候,Redis Cluster 节点都会在其中携带当前节点能感知到的节点信息,这也是 Cluster 实现 Gossip 协议的关键所在,后面也会将这些消息统称为 Cluster Message

在发送 Cluster Message之前,首先需要确认需要携带哪些节点的信息,主要分为两大部分:

  • 第一部分是当前节点能感知到的 1/10 个节点的信息(至少 3 个节点); 也就是从当前节点的 clusterState->nodes 集合中,随机选择满足一些条件的 1/10 的节点信息,打包到 Cluster Message 中。
  • 第二部分是 clusterState->nodes 集合中处于 PFAIL 状态的节点。这里会将所有处于 PFAIL 状态的、疑似故障的节点信息,全部添加到此次要发送的 Cluster Message 中。

一条 Cluster Message 消息分为:消息基本信息、发送节点信息、集群信息、具体消息以及扩展内容五部分
在这里插入图片描述

  • 基本信息,包括:消息签名(sig 字段)、消息版本(ver 字段)、消息长度(totlen 字段)、消息类型(type 字段)、携带的节点信息条数(count 字段)

  • 发送节点信息: 包括:发送节点的名称(sender 字段)、当前节点的 configEpoch 信息(configEpoch 字段)、主从复制的 Replication Offset(offset 字段)、节点的 ip、port 以及 cport当前节点的 flags 状态信息当前节点负责的 slot 集合(myslots 字段)、当前节点的主节点名称(slaveof 字段)。

  • 集群信息: 当前的 currentEpoch 值

  • 具体消息: clusterMsgData实例

    union clusterMsgData {
        // PING、MEET、PONG三种消息都是使用ping这个结构体
        struct {
            //clusterMsgDataGossip数组,每个clusterMsgDataGossip就包含一个节点的信息
            clusterMsgDataGossip gossip[1];
        } ping;
        ... // 省略其他结构体的定义
    }
    

    在 ping.gossip 数组中的每个 clusterMsgDataGossip 元素,都对应了一个节点信息,其中包含了节点的名称、当前节点最后一次向其发送 PING 消息以及收到 PONG 响应的时间戳、节点的 IP、port、cport 信息以及节点的 flags 标识。

完成 clusterMsg 实例的创建和填充之后, 当前节点会将, clusterMsg 添加到发送缓冲区中,也就是对应 clusterLink 连接的 sndbuf 缓冲区中,同时还会开始监听该连接上的可写事件

处理 MEET 消息

握手流程发送 MEET 消息的核心内容已经介绍完了,下面继续分析对端节点在接收到 MEET 消息时的操作。

发送MEET消息后, 对端(这里是B节点)不断读取连接中的数据并暂存到 clusterLink->rcvbuf 缓冲区中。

  1. 校验数据完整性
  2. 更新当前 Cluster 节点自身的 IP 地址。在当前 Cluster 节点自己的地址发生变更的时候,我们可以通过新建连接获取本机的最近地址,这个地址就是当前 Cluster 变更后的 IP 地址。
  3. 接下来,根据请求中携带的对端节点名称,从 clusterState->nodes 字典中查找对应的 clusterNode 实例(即代码中的 sender 变量)。在两个节点第一次握手的时候,当前 Cluster 节点肯定是查找不到对端(A节点) Cluster 节点对应的 clusterNode 实例的。此时,当前节点会为发送 MEET 消息的对端节点(A)创建一个 clusterNode 实例(其 name 字段值是随机生成的,flags 字段中设置了 HANDSHAKE 标志位),并记录到 clusterState->nodes 字典中。
  4. 接下来,当前节点(B)会解析消息中携带的 clusterMsgDataGossip 数组。但是,在第一次接收到未知节点(也就是不在 clusterState->nodes 字典中节点)发来的 MEET 消息时,并不会直接信任它的 Gossip 信息,所以此次调用没有进行什么有效操作。
  5. 最后,返回一个 PONG 消息给对端节点(A)。PONG 消息的组装和发送逻辑与前文分析 MEET 消息的完全一致,这里不再重复。

下图展示了 B 节点处理完 MEET 消息之后两个节点的状态:
在这里插入图片描述

处理 PONG 消息

我们再回到发送 MEET 消息的这一侧,在开始介绍 PONG 消息处理之前,先需要明确一点:因为接收 PONG 消息的 clusterLink 连接中的 node 字段指向了表示对端节点的 clusterNode 实例,所以当前节点能够清晰知道发送 PONG 消息的节点身份。

根据 PONG 消息中携带的节点名称从 clusterState->nodes 集合中查找节点,依旧是查找不到,如上图所示,PONG 消息携带的会是 Name B,而 A 节点记录的是 Random Name B

  • 使用 PONG 携带的节点名称替换随机生成的对端节点名称,Name B替换Random Name B
  • 修改对端节点的 flags:一个是删除 HANDSHAKE 标志位,表示握手结束;
  • 设置 MASTER 和 SLAVE 标志位
  • 将对端 Cluster 节点(B)对应的 pong_received 字段更新为当前时间戳
  • 将 ping_sent 字段更新为 0 ,为下次发送 PING 请求做准备。

下图展示了节点 A 处理完节点 B 返回的 PONG 消息之后的状态:
在这里插入图片描述

发送 PING 消息

继续上面的示例,在节点 A 处理完节点 B 返回的 PONG 消息之后,就已经可以正确感知到节点 B 了,并且明确知晓自己与节点 B 之间网络连接。接下来,A 节点就可以通过该连接定时向节点 B 发送 PING 命令进行探活了。但是,此时的节点 B 缺失了节点 A 的很多信息,例如

  • 不知道节点 A 的 name 值是什么。因为处理 MEET 消息时创建的 clusterNode 实例中,name 是随机生成的,并不是节点 A 真正的 name。
  • 不知道自身与节点 A 的连接是哪个。因为被动创建的 clusterLink 实例中的 node 为 NULL。

节点 A、B 之间的连接状态如下图所示,只存在 A 到 B 的主动连接,不存在 B 到 A 的主动连接:
在这里插入图片描述

在节点 B 的下一次执行定时任务迭代自身 clusterState->nodes 字典的时候,就会发现节点 A 对应的 clusterNode 实例(其 name 此时还是 Random Node A)中,link 字段为 NULL。此时,会触发前文介绍的建连操作,创建节点 B 到节点 A 的主动连接。

通过前文的分析可知,节点 A 的 flags 中只设置了 HANDSHAKE 标志位,未设置 MEET 标记位,所以这里建连完成之后,B发送一条 PING 消息。节点 A 收到 PING 消息之后会返回一条 PONG 消息,节点 B 在收到 PONG 消息之后,会更新节点 A 的 name,清除 HANDSHAKE 标志位,并更新 ping_sent 和 pong_received 时间戳。

下图展示了节点 B 主动与节点 A 建连的全流程:
在这里插入图片描述

到此为止,A、B 两个 Redis Cluster 节点之间的握手流程才算完整结束了。

个人公众号: 行云代码

参考文章

https://juejin.cn/book/7144917657089736743/section/7147530637849657384

https://juejin.cn/book/7144917657089736743/section/7147530618316947497

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

uncleqiao

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值