控制器(九)

控制器

         在 Kafka 集群中会有一个或多个 broker,其中有一个 broker 会被选举为控制器(Kafka Controller),它负责管理整个集群中所有分区和副本的状态。当某个分区的 leader 副本出现故障时,由控制器负责为该分区选举新的 leader 副本。当检测到某个分区的 ISR 集合发生变化时,由控制器负责通知所有broker更新其元数据信息。当使用 kafka-topics.sh 脚本为某个 topic 增加分区数量时,同样还是由控制器负责分区的重新分配。

控制器的选举及异常恢复

       Kafka 中的控制器选举工作依赖于 ZooKeeper,成功竞选为控制器的 broker 会在 ZooKeeper 中创建 /controller 这个临时(EPHEMERAL)节点,此临时节点的内容参考如下:

{"version":1,"brokerid":0,"timestamp":"1529210278988"}

        其中 version 在目前版本中固定为1,brokerid 表示成为控制器的 broker 的 id 编号,timestamp 表示竞选成为控制器时的时间戳。

        在任意时刻,集群中有且仅有一个控制器。每个 broker 启动的时候会去尝试读取 /controller 节点的 brokerid 的值,如果读取到 brokerid 的值不为-1,则表示已经有其他 broker 节点成功竞选为控制器,所以当前 broker 就会放弃竞选;如果 ZooKeeper 中不存在 /controller 节点,或者这个节点中的数据异常,那么就会尝试去创建 /controller 节点。当前 broker 去创建节点的时候,也有可能其他 broker 同时去尝试创建这个节点,只有创建成功的那个 broker 才会成为控制器,而创建失败的 broker 竞选失败。每个 broker 都会在内存中保存当前控制器的 brokerid 值,这个值可以标识为 activeControllerId。

              ZooKeeper 中还有一个与控制器有关的 /controller_epoch 节点,这个节点是持久(PERSISTENT)节点,节点中存放的是一个整型的 controller_epoch 值。controller_epoch 用于记录控制器发生变更的次数,即记录当前的控制器是第几代控制器,我们也可以称之为“控制器的纪元”。

         controller_epoch 的初始值为1,即集群中第一个控制器的纪元为1,当控制器发生变更时,每选出一个新的控制器就将该字段值加1。每个和控制器交互的请求都会携带 controller_epoch 这个字段,如果请求的 controller_epoch 值小于内存中的 controller_epoch 值,则认为这个请求是向已经过期的控制器所发送的请求,那么这个请求会被认定为无效的请求。如果请求的 controller_epoch 值大于内存中的 controller_epoch 值,那么说明已经有新的控制器当选了。由此可见,Kafka 通过 controller_epoch 来保证控制器的唯一性,进而保证相关操作的一致性。

具备控制器身份的 broker 需要比其他普通的 broker 多一份职责,具体细节如下:

  • 监听分区相关的变化。为 ZooKeeper 中的 /admin/reassign_partitions 节点注册 PartitionReassignmentHandler,用来处理分区重分配的动作。为 ZooKeeper 中的 /isr_change_notification 节点注册 IsrChangeNotificetionHandler,用来处理 ISR 集合变更的动作。为 ZooKeeper 中的 /admin/preferred-replica-election 节点添加 PreferredReplicaElectionHandler,用来处理优先副本的选举动作。
  • 监听主题相关的变化。为 ZooKeeper 中的 /brokers/topics 节点添加 TopicChangeHandler,用来处理主题增减的变化;为 ZooKeeper中 的 /admin/delete_topics 节点添加 TopicDeletionHandler,用来处理删除主题的动作。
  • 监听 broker 相关的变化。为 ZooKeeper 中的 /brokers/ids 节点添加 BrokerChangeHandler,用来处理 broker 增减的变化。
  • 从 ZooKeeper 中读取获取当前所有与主题、分区及broker有关的信息并进行相应的管理。对所有主题对应的 ZooKeeper中的 /brokers/topics/<topic> 节点添加 PartitionModificationsHandler,用来监听主题中的分区分配变化。
  • 启动并管理分区状态机和副本状态机。
  • 更新集群的元数据信息。
  • 如果参数 auto.leader.rebalance.enable 设置为 true,则还会开启一个名为“auto-leader-rebalance-task”的定时任务来负责维护分区的优先副本的均衡。

               控制器在选举成功之后会读取 ZooKeeper 中各个节点的数据来初始化上下文信息(ControllerContext),并且需要管理这些上下文信息。比如为某个主题增加了若干分区,控制器在负责创建这些分区的同时要更新上下文信息,并且需要将这些变更信息同步到其他普通的 broker 节点中。

             不管是监听器触发的事件,还是定时任务触发的事件,或者是其他事件(比如 ControlledShutdown)都会读取或更新控制器中的上下文信息,那么这样就会涉及多线程间的同步。如果单纯使用锁机制来实现,那么整体的性能会大打折扣。针对这一现象,Kafka 的控制器使用单线程基于事件队列的模型,将每个事件都做一层封装,然后按照事件发生的先后顺序暂存到 LinkedBlockingQueue 中,最后使用一个专用的线程(ControllerEventThread)按照 FIFO(First Input First Output,先入先出)的原则顺序处理各个事件,这样不需要锁机制就可以在多线程间维护线程安全,具体可以参考下图。

6-14

 

         在 Kafka 的早期版本中,并没有采用 Kafka Controller 这样一个概念来对分区和副本的状态进行管理,而是依赖于 ZooKeeper,每个 broker 都会在 ZooKeeper 上为分区和副本注册大量的监听器(Watcher)。当分区或副本状态变化时,会唤醒很多不必要的监听器,这种严重依赖 ZooKeeper 的设计会有脑裂、羊群效应,以及造成 ZooKeeper 过载的隐患(旧版的消费者客户端存在同样的问题)。

        在目前的新版本的设计中,只有 Kafka Controller 在 ZooKeeper 上注册相应的监听器,其他的 broker 极少需要再监听 ZooKeeper 中的数据变化,这样省去了很多不必要的麻烦。不过每个 broker 还是会对 /controller 节点添加监听器,以此来监听此节点的数据变化(ControllerChangeHandler)。

          当 /controller 节点的数据发生变化时,每个 broker 都会更新自身内存中保存的 activeControllerId。如果 broker 在数据变更前是控制器,在数据变更后自身的 brokerid 值与新的 activeControllerId 值不一致,那么就需要“退位”,关闭相应的资源,比如关闭状态机、注销相应的监听器等。有可能控制器由于异常而下线,造成 /controller 这个临时节点被自动删除;也有可能是其他原因将此节点删除了。

         当 /controller 节点被删除时,每个 broker 都会进行选举,如果 broker 在节点被删除前是控制器,那么在选举前还需要有一个“退位”的动作。如果有特殊需要,则可以手动删除 /controller 节点来触发新一轮的选举。当然关闭控制器所对应的 broker,以及手动向 /controller 节点写入新的 brokerid 的所对应的数据,同样可以触发新一轮的选举。

优雅关闭

         如何优雅地关闭 Kafka?笔者在做测试的时候经常性使用 jps(或者 ps ax)配合 kill -9 的方式来快速关闭 Kafka broker 的服务进程,显然 kill -9 这种“强杀”的方式并不够优雅,它并不会等待 Kafka 进程合理关闭一些资源及保存一些运行数据之后再实施关闭动作。在有些场景中,用户希望主动关闭正常运行的服务,比如更换硬件、操作系统升级、修改 Kafka 配置等。如果依然使用上述方式关闭就略显粗暴。

       那么合理的操作应该是什么呢?Kafka 自身提供了一个脚本工具,就是存放在其 bin 目录下的 kafka-server-stop.sh,这个脚本的内容非常简单,具体内容如下:

PIDS=$(ps ax | grep -i 'kafka\.Kafka' | grep java | grep -v grep | awk '{print $1}')

if [ -z "$PIDS" ]; then
  echo "No kafka server to stop"
  exit 1
else 
  kill -s TERM $PIDS
fi

         可以看出 kafka-server-stop.sh 首先通过 ps ax 的方式找出正在运行 Kafka 的进程号 PIDS,然后使用 kill -s TERM $PIDS 的方式来关闭。但是这个脚本在很多时候并不奏效,这一点与 ps 命令有关系。在 Linux 操作系统中,ps 命令限制输出的字符数不得超过页大小 PAGE_SIZE,一般 CPU 的内存管理单元(Memory Management Unit,简称 MMU)的 PAGE_SIZE 为4096。也就是说,ps 命令的输出的字符串长度限制在4096内,这会有什么问题呢?我们使用 ps ax 命令来输出与 Kafka 进程相关的信息,如下图所示。

6-15

 

        细心的读者可以留意到白色部分中的信息并没有打印全,因为已经达到了4096的字符数的限制。而且打印的信息里面也没有 kafka-server-stop.sh 中 ps ax | grep -i 'kafka.Kafka' 所需要的“kafka.Kafka”这个关键字段,因为这个关键字段在4096个字符的范围之外。与Kafka进程有关的输出信息太长,所以 kafka-server-stop.sh 脚本在很多情况下并不会奏效。

注意要点:Kafka 服务启动的入口就是 kafka.Kafka,采用 Scala 语言编写 object。

        那么怎么解决这种问题呢?我们先来看一下ps命令的相关源码(Linux 2.6.x 源码的 /fs/proc/base.c 文件中的部分内容):

static int proc_pid_cmdline(struct task_struct *task, char * buffer)
{
   int res = 0;
   unsigned int len;
   struct mm_struct *mm = get_task_mm(task);
   if (!mm)
      goto out;
   if (!mm->arg_end)
      goto out_mm;   /* Shh! No looking before we're done */

   len = mm->arg_end - mm->arg_start;
 
   if (len > PAGE_SIZE)
      len = PAGE_SIZE;
 
   res = access_process_vm(task, mm->arg_start, buffer, len, 0);
(....省略若干....)

         我们可以看到 ps 的输出长度 len 被硬编码成小于等于 PAG_SIZE 的大小,那么我们调大这个 PAGE_SIZE 的大小不就可以了吗?这样是肯定行不通的,因为对于一个 CPU 来说,它的 MMU 的页大小 PAGE_SIZE 的值是固定的,无法通过参数调节。要想改变 PAGE_SIZE 的大小,就必须更换成相应的 CPU,显然这也太过于“兴师动众”了。还有一种办法是,将上面代码中的 PAGE_SIZE 换成一个更大的其他值,然后重新编译,这个办法对于大多数人来说不太适用,需要掌握一定深度的 Linux 的相关知识。

        那么有没有其他的办法呢?这里我们可以直接修改 kafka-server-stop.sh 脚本的内容,将其中的第一行命令修改如下:

PIDS=$(ps ax | grep -i 'kafka' | grep java | grep -v grep | awk '{print $1}')

        即把“.Kafka”去掉,这样在绝大多数情况下是可以奏效的。如果有极端情况,即使这样也不能关闭,那么只需要按照以下两个步骤就可以优雅地关闭 Kafka 的服务进程:

  1. 获取 Kafka 的服务进程号 PIDS。可以使用 Java 中的 jps 命令或使用 Linux 系统中的 ps 命令来查看。
  2. 使 用kill -s TERM $PIDS 或 kill -15 $PIDS 的方式来关闭进程,注意千万不要使用 kill -9 的方式。

       为什么这样关闭的方式会是优雅的?Kafka 服务入口程序中有一个名为“kafka-shutdown- hock”的关闭钩子,待 Kafka 进程捕获终止信号的时候会执行这个关闭钩子中的内容,其中除了正常关闭一些必要的资源,还会执行一个控制关闭(ControlledShutdown)的动作。使用 ControlledShutdown 的方式关闭 Kafka 有两个优点:一是可以让消息完全同步到磁盘上,在服务         下次重新上线时不需要进行日志的恢复操作;二是 ControllerShutdown 在关闭服务之前,会对其上的 leader 副本进行迁移,这样就可以减少分区的不可用时间。

          若要成功执行 ControlledShutdown 动作还需要有一个先决条件,就是参数 controlled.shutdown.enable 的值需要设置为true,不过这个参数的默认值就为 true,即默认开始此项功能。ControlledShutdown 动作如果执行不成功还会重试执行,这个重试的动作由参数 controlled.shutdown.max.retries 配置,默认为3次,每次重试的间隔由参数 controlled.shutdown.retry.backoff.ms 设置,默认为5000ms。

下面我们具体探讨 ControlledShutdown 的整个执行过程。

6-16

 

         参考上图,假设此时有两个 broker,其中待关闭的 broker 的 id 为 x,Kafka 控制器所对应的 broker 的 id 为 y。待关闭的 broker 在执行 ControlledShutdown 动作时首先与 Kafka 控制器建立专用连接(对应上图中的步骤①),然后发送 ControlledShutdownRequest 请求,ControlledShutdownRequest 请求中只有一个 brokerId 字段,这个 brokerId 字段的值设置为自身的 brokerId 的值,即 x(对应上图中的步骤②)。

       Kafka 控制器在收到 ControlledShutdownRequest 请求之后会将与待关闭 broker 有关联的所有分区进行专门的处理,这里的“有关联”是指分区中有副本位于这个待关闭的 broker 之上(这里会涉及 Kafka 控制器与待关闭 broker 之间的多次交互动作,涉及 leader 副本的迁移和副本的关闭动作,对应上图中的步骤③)。

ControlledShutdownRequest 的结构如下图所示。

6-17

 

       如果这些分区的副本数大于1且 leader 副本位于待关闭 broker 上,那么需要实施 leader 副本的迁移及新的 ISR 的变更。具体的选举分配的方案由专用的选举器 ControlledShutdownLeaderSelector 提供。

         如果这些分区的副本数只是大于1,leader 副本并不位于待关闭 broker 上,那么就由 Kafka 控制器来指导这些副本的关闭。如果这些分区的副本数只是为1,那么这个副本的关闭动作会在整个 ControlledShutdown 动作执行之后由副本管理器来具体实施。

       对于分区的副本数大于1且 leader 副本位于待关闭 broker 上的这种情况,如果在 Kafka 控制器处理之后 leader 副本还没有成功迁移,那么会将这些没有成功迁移 leader 副本的分区记录下来,并且写入 ControlledShutdownResponse 的响应(对应往上第二张图中的步骤④,整个 ControlledShutdown 动作是一个同步阻塞的过程)。ControlledShutdownResponse 的结构如下图所示。

6-18

 

           待关闭的 broker 在收到 ControlledShutdownResponse 响应之后,需要判断整个 ControlledShutdown 动作是否执行成功,以此来进行可能的重试或继续执行接下来的关闭资源的动作。执行成功的标准是 ControlledShutdownResponse 中 error_code 字段值为0,并且 partitions_remaining 数组字段为空。

注意要点:往上第三张图中也有可能 x=y,即待关闭的 broker同时是 Kafka 控制器,这也就意味着自己可以给自己发送 ControlledShutdownRequest 请求,以及等待自身的处理并接收 ControlledShutdownResponse 的响应,具体的执行细节和 x!=y 的场景相同。

         在了解了整个 ControlledShutdown 动作的具体细节之后,我们不难看出这一切实质上都是由 ControlledShutdownRequest 请求引发的,我们完全可以自己开发一个程序来连接 Kafka 控制器,以此来模拟对某个 broker 实施 ControlledShutdown 的动作。为了实现方便,我们可以对 KafkaAdminClient 做一些扩展来达到目的。

首先参考 org.apache.kafka.clients.admin.AdminClient 接口中的惯有编码样式来添加两个方法:

    public abstract ControlledShutdownResult controlledShutdown(
            Node node, final ControlledShutdownOptions options);

    public ControlledShutdownResult controlledShutdown(Node node){
        return controlledShutdown(node, new ControlledShutdownOptions());
    }

          第一个方法中的 ControlledShutdownOptions 和 ControlledShutdownResult 都是 KafkaAdminClient 的惯有编码样式,ControlledShutdownOptions 中没有实质性的内容,具体参考如下:

@InterfaceStability.Evolving
public class ControlledShutdownOptions extends 
AbstractOptions<ControlledShutdownOptions> {
}

ControlledShutdownResult 的实现如下:

@InterfaceStability.Evolving
public class ControlledShutdownResult {
    private final KafkaFuture<ControlledShutdownResponse> future;
    public ControlledShutdownResult(
    KafkaFuture<ControlledShutdownResponse> future) {
        this.future = future;
    }
    public KafkaFuture<ControlledShutdownResponse> values(){
        return future;
    }
}

            ControlledShutdownResult 中没有像 KafkaAdminClient 中惯有的那样对 ControlledShutdownResponse 进行细致化的处理,而是直接将 ControlledShutdownResponse 暴露给用户,这样用户可以更加细腻地操控内部的细节。

        第二个方法中的参数 Node 是我们需要执行 ControlledShutdown 动作的 broker 节点,Node 的构造方法至少需要三个参数:id、host 和 port,分别代表所对应的 broker 的 id 编号、IP 地址和端口号。一般情况下,对用户而言,并不一定清楚这个三个参数的具体值,有的要么只知道要关闭的 broker 的 IP 地址和端口号,要么只清楚具体的 id 编号,为了程序的通用性,我们还需要做进一步的处理。详细看一下 org.apache.kafka.clients.admin.KafkaAdminClient 中的具体做法:

public ControlledShutdownResult controlledShutdown(
            Node node,
            final ControlledShutdownOptions options) {
        final KafkaFutureImpl<ControlledShutdownResponse> future 
                = new KafkaFutureImpl<>();
        final long now = time.milliseconds();

        runnable.call(new Call("controlledShutdown", 
                calcDeadlineMs(now, options.timeoutMs()),
                new ControllerNodeProvider()) {
            @Override
            AbstractRequest.Builder createRequest(int timeoutMs) {
                int nodeId = node.id();
                if (nodeId < 0) {
                    List<Node> nodes = metadata.fetch().nodes();
                    for (Node nodeItem : nodes) {
                        if (nodeItem.host().equals(node.host()) 
                                && nodeItem.port() == node.port()) {
                            nodeId = nodeItem.id();
                            break;
                        }
                    }
                }
                return new ControlledShutdownRequest.Builder(nodeId, 
                        ApiKeys.CONTROLLED_SHUTDOWN.latestVersion());
            }

            @Override
            void handleResponse(AbstractResponse abstractResponse) {
                ControlledShutdownResponse response = 
                        (ControlledShutdownResponse) abstractResponse;
                future.complete(response);
            }

            @Override
            void handleFailure(Throwable throwable) {
                future.completeExceptionally(throwable);
            }
        }, now);

        return new ControlledShutdownResult(future);
    }

           我们可以看到在内部的 createRequest 方法中对 Node 的 id 做了一些处理,因为对 ControlledShutdownRequest 协议的包装只需要这个 id 的值。程序中首先判断 Node 的 id 是否大于0,如果不是则需要根据 host 和 port 去 KafkaAdminClient 缓存的元数据 metadata 中查找匹配的 id。注意到代码里还有一个标粗的 ControllerNodeProvider,它提供了 Kafka 控制器对应的节点信息,这样用户只需要提供 Kafka 集群中的任意节点的连接信息,不需要知晓具体的 Kafka 控制器是谁。

       最后我们再用一段测试程序来模拟发送 ControlledShutdownRequest 请求及处理 ControlledShutdownResponse,详细参考如下:

String brokerUrl = "hostname1:9092";
Properties props = new Properties();
props.put(CommonClientConfigs.BOOTSTRAP_SERVERS_CONFIG, brokerUrl);
//1. 初始化KafkaAdminClient
AdminClient adminClient = AdminClient.create(props);

//2. 需要关闭的节点node,暂不清楚node的id,故设置为-1
Node node = new Node(-1, "hostname2", 9092);
//3. 使用KafkaAdminClient发送ControlledShutdownRequest请求及
//阻塞等待ControlledShutdownResponse响应
ControlledShutdownResponse response =
        adminClient.controlledShutdown(node).values().get();
if (response.error() == Errors.NONE
        && response.partitionsRemaining().isEmpty()) {
    System.out.println("controlled shutdown completed");
}else {
    System.out.println("controlled shutdown occured error with: "
            + response.error().message());
}

       其中 brokerUrl 是连接的任意节点,node 是需要关闭的 broker 节点,当然这两个可以是同一个节点,即代码中的 hostname1 等于 hostname2。使用 KafkaAdminClient 的整个流程为:首先连接集群中的任意节点;接着通过这个连接向 Kafka 集群发起元数据请求(MetadataRequest)来获取集群的元数据 metadata;然后获取需要关闭的 broker 节点的 id,如果没有指定则去 metadata 中查找,根据这个 id 封装 ControlledShutdownRequest 请求;之后再去 metadata 中查找 Kafka 控制器的节点,向这个 Kafka 控制器节点发送请求;最后等待 Kafka 控制器的 ControlledShutdownResponse 响应并做相应的处理。

       注意 ControlledShutdown 只是关闭 Kafka broker 的一个中间过程,所以不能寄希望于只使用 ControlledShutdownRequest 请求就可以关闭整个 Kafka broker 的服务进程。

分区leader的选举

       分区 leader 副本的选举由控制器负责具体实施。当创建分区(创建主题或增加分区都有创建分区的动作)或分区上线(比如分区中原先的 leader 副本下线,此时分区需要选举一个新的 leader 上线来对外提供服务)的时候都需要执行 leader 的选举动作,对应的选举策略为         OfflinePartitionLeaderElectionStrategy。这种策略的基本思路是按照 AR 集合中副本的顺序查找第一个存活的副本,并且这个副本在 ISR 集合中。

        一个分区的 AR 集合在分配的时候就被指定,并且只要不发生重分配的情况,集合内部副本的顺序是保持不变的,而分区的 ISR 集合中副本的顺序可能会改变。

        注意这里是根据 AR 的顺序而不是 ISR 的顺序进行选举的。举个例子,集群中有3个节点:broker0、broker1 和 broker2,在某一时刻具有3个分区且副本因子为3的主题 topic-leader 的具体信息如下:

[root@node1 kafka_2.11-2.0.0]# bin/kafka-topics.sh --zookeeper localhost:2181/ kafka --describe --topic topic-leader
Topic:topic-leader	PartitionCount:3	ReplicationFactor:3	Configs: 
    Topic: topic-leader	Partition: 0	Leader: 1	Replicas: 1,2,0	Isr: 2,0,1
    Topic: topic-leader	Partition: 1	Leader: 2	Replicas: 2,0,1	Isr: 2,0,1
    Topic: topic-leader	Partition: 2	Leader: 0	Replicas: 0,1,2	Isr: 0,2,1

        此时关闭 broker0,那么对于分区2而言,存活的 AR 就变为[1,2],同时 ISR 变为[2,1]。此时查看主题 topic-leader 的具体信息(参考如下),分区2的 leader 就变为了1而不是2。

[root@node1 kafka_2.11-2.0.0]# bin/kafka-topics.sh --zookeeper localhost:2181/ kafka --describe --topic topic-leader
Topic:topic-leader	PartitionCount:3	ReplicationFactor:3	Configs: 
    Topic: topic-leader	Partition: 0	Leader: 1	Replicas: 1,2,0	Isr: 2,1
    Topic: topic-leader	Partition: 1	Leader: 2	Replicas: 2,0,1	Isr: 2,1
    Topic: topic-leader	Partition: 2	Leader: 1	Replicas: 0,1,2	Isr: 2,1

         如果 ISR 集合中没有可用的副本,那么此时还要再检查一下所配置的 unclean.leader.election.enable 参数(默认值为 false)。如果这个参数配置为 true,那么表示允许从非 ISR 列表中的选举 leader,从 AR 列表中找到第一个存活的副本即为 leader。

      当分区进行重分配的时候也需要执行 leader 的选举动作,对应的选举策略为 ReassignPartitionLeaderElectionStrategy。这个选举策略的思路比较简单:从重分配的AR列表中找到第一个存活的副本,且这个副本在目前的 ISR 列表中。

        当发生优先副本的选举时,直接将优先副本设置为 leader 即可,AR 集合中的第一个副本即为优先副本(PreferredReplicaPartitionLeaderElectionStrategy)。

       还有一种情况会发生 leader 的选举,当某节点被优雅地关闭(也就是执行 ControlledShutdown)时,位于这个节点上的 leader 副本都会下线,所以与此对应的分区需要执行 leader 的选举。与此对应的选举策略(ControlledShutdownPartitionLeaderElectionStrategy)为:从 AR 列表中找到第一个存活的副本,且这个副本在目前的 ISR 列表中,与此同时还要确保这个副本不处于正在被关闭的节点上。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值