RocketMQ ⾼级原理记录

根据黑马笔记整理及记录,简单概述消息模型等,记下不太清楚的地方,方便下次学习


消息模型

RocketMQ主要由 Producer、Broker、Consumer 三部分组成,其中Producer 负责⽣产消息,Consumer 负责消费消息,Broker 负责存储消息。Broker 在实际部署过程中对应⼀台服务器,每个Broker 可以存储多个Topic的消息,每个Topic的消息也可以分⽚存储于不同的 Broker。MessageQueue ⽤于存储消息的物理地址,每个Topic中的消息地址存储于多个 Message Queue 中。ConsumerGroup 由多个Consumer 实例构成。

消息消费者

消费者同样会把同⼀类Consumer组成⼀个集合,叫做消费者组,这类Consumer通常消费同⼀类消息且消费逻辑⼀致。消费者组使得在消息消费⽅⾯,实现负载均衡和容错的⽬标变得⾮常容易。要注意的是,消费者组的消费者实例必须订阅完全相同的Topic。RocketMQ ⽀持两种消息模式:集群消费(Clustering)和⼴播消费(Broadcasting)。

RocketMQ 有两种消费模式:

集群消费模式:CLUSTERING,可以理解为同组公共消费。公共资源我拿了你就没有。即同一 Topic 下,一个 ConsumerGroup 下如果有多个实例(可以是多个进程,或者多个机器),那么这些实例会均摊消费这些消息,但我消费了这条消费你就不会再消费。消费者默认是集群消费方式。适用于大部分消息业务。

广播消费模式:BROADCASTING,可以理解为同组各自消费。即同一 Topic 下,同一消息会被多个实例各自都消费一次。所以,广播消费模式中的 ConsumerGroup 概念没有太大的意义。这适用于一些分发消息的场景。

集群消费模式下,相同Consumer Group的每个Consumer实例平均分摊消息。
⼴播消费模式下,相同Consumer Group的每个Consumer实例都接收全量的消息。

广播集群案例

主题(Topic)

表示⼀类消息的集合,每个主题包含若⼲条消息,每条消息只能属于⼀个主题,是RocketMQ进⾏消息订阅的基本单位。同⼀个Topic下的数据,会分⽚保存到不同的Broker上,⽽每⼀个分⽚单位,就叫做MessageQueue。MessageQueue是⽣产者发送消息与消费者消费消息的最⼩单位。

代理服务器(Broker Server)

消息中转⻆⾊,负责存储消息、转发消息。代理服务器在RocketMQ系统中负责接收从⽣产者发送来的消息并存储、同时为消费者的拉取请求作准备。代理服务器也存储消息相关的元数据,包括消费者组、消费进度偏移和主题和队列消息等。

Broker Server是RocketMQ真正的业务核⼼,包含了多个重要的⼦模块:

  • Remoting Module:整个Broker的实体,负责处理来⾃clients端的请求。
  • Client Manager:负责管理客户端(Producer/Consumer)和维护Consumer的Topic订阅信息
  • Store Service:提供⽅便简单的API接⼝处理消息存储到物理硬盘和查询功能。
  • HA Service:⾼可⽤服务,提供Master Broker 和 Slave Broker之间的数据同步功能。
  • Index Service:根据特定的Message key对投递到Broker的消息进⾏索引服务,以提供消息的快速
    查询。

集群模式

⽽Broker Server要保证⾼可⽤需要搭建主从集群架构。RocketMQ中有两种Broker架构模式:

  • 普通集群
    • 这种集群模式下会给每个节点分配⼀个固定的⻆⾊,master负责响应客户端的请求,并存储消息。slave则只负责对master的消息进⾏同步保存,并响应部分客户端的读请求。消息同步⽅式分为同步同步和异步同步。这种集群模式下各个节点的⻆⾊⽆法进⾏切换,也就是说,master节点挂了,这⼀组Broker就不可⽤
      了。
  • Dledger⾼可⽤集群
    • Dledger是RocketMQ⾃4.5版本引⼊的实现⾼可⽤集群的⼀项技术,是⼀个第三⽅的技术。这个模式下的集群会随机选出⼀个节点作为master,⽽当master节点挂了后,会从slave中⾃动选出⼀个节点升级成为master。

Dledger技术做的事情:

  1. 接管Broker的CommitLog消息存储
  2. 从集群中选举出master节点
  3. 完成master节点往slave节点的消息同步。

Dledger集群的Raft选举算法

Dledger的关键部分是在他的节点选举上。Dledger是使⽤Raft算法来进⾏节点选举的。简单介绍下Raft算法的选举过程:

⾸先:每个节点有三个状态,Leaderfollowercandidate(候选⼈)。正常运⾏的情况下,集群中会有⼀个leader,其他都是follower,follower只响应Leader和Candidate的请求,⽽客户端的请求全部由Leader处理,即使有客户端请求到了⼀个follower,也会将请求转发到leader。

  1. 集群刚启动时,每个节点都是follower状态,之后集群内部会发送⼀个timeout信号,所有follower就转成candidate去拉取选票,获得⼤多数选票的节点选为leader,其他候选⼈转为follower。如果⼀个timeout信号发出时,没有选出leader,将会重新开始⼀次新的选举。⽽Leader节点会往其他节点发送⼼跳信号,确认他的leader状态。

  2. 然后会启动定时器,如果在指定时间内没有收到Leader的⼼跳,就会转为Candidate状态,然后向其他成员发起投票请求,如果收到半数以上成员的投票,则Canddate会晋升为Leader。然后leader也有可能会退化成follower。

  3. 然后,在Raft协议中,会将时间分为⼀些任意时间⻓度的时间⽚段,叫做term。term会使⽤⼀个全局唯⼀,连续递增的编号作为标识,也就是起到了⼀个逻辑时钟的作⽤。
    在这里插入图片描述
    RaftTerms:

在每⼀个term时间⽚⾥,都会进⾏新的选举,每⼀个Candidate都会努⼒争取成为leader。获得票数最多的节点就会被选举为Leader。被选为Leader的这个节点,在⼀个term时间⽚⾥就会保持leader状态。这样,就会保证在同⼀时间段内,集群中只会有⼀个Leader。在某些情况下,选票可能会被各个节点⽠分,形成不了多数派,那这个term可能直到结束都没有leader,直到下⼀个term再重新发起选举,这也就没有了Zookeeper中的脑裂问题。⽽在每次重新选举的过程中, leader也有可能会退化成为follower。也就是说,在这个集群中, leader节点是会不断变化的。

  1. Raft协议会通过⼼跳机制发起leader选举。节点都是从follower状态开始的,如果收到
    了来⾃leader或者candidate的⼼跳RPC请求,那他就会保持follower状态,避免争抢成为candidate。⽽leader会往其他节点发送⼼跳信号,来确认⾃⼰的地位。如果follower⼀段时间(两个timeout信号)内没有收到Leader的⼼跳信号,他就会认为leader挂了,发起新⼀轮选举。

  2. 选举开始后,每个follower会增加⾃⼰当前的term,并将⾃⼰转为candidate。然后向其他节点发起投票请求,请求时会带上⾃⼰的编号和term,也就是说都会默认投⾃⼰⼀票。之后candidate状态可能会发⽣以下三种变化:

  3. 赢得选举,成为leader: 如果它在⼀个term内收到了⼤多数的选票,将会在接下的剩余term时间内称为leader,然后就可以通过发送⼼跳确⽴⾃⼰的地位。(每⼀个server在⼀个term内只能投⼀张选票,并且按照先到先得的原则投出)

  4. 其他节点成为leader: 在等待投票时,可能会收到其他server发出⼼跳信号,说明其他leader已经产⽣了。这时通过⽐较⾃⼰的term编号和RPC过来的term编号,如果⽐对⽅⼤,说明leader的term过期了,就会拒绝该RPC,并继续保持候选⼈身份; 如果对⽅编号不⽐⾃⼰⼩,则承认对⽅的地位,转为follower。

  5. 选票还被⽠分,选举失败: 如果没有candidate获取⼤多数选票, 则没有leader产⽣, candidate们等待超时后发起另⼀轮选举. 为了防⽌下⼀次选票还被⽠分,必须采取⼀些额外的措施, raft采⽤随机electiontimeout(随机休眠时间)的机制防⽌选票被持续⽠分。通过将timeout随机设为⼀段区间上的某个值, 因此很⼤概率会有某个candidate率先超时然后赢得⼤部分选票。

以三个节点的集群为例,选举过程会是这样的:

  1. 集群启动时,三个节点都是follower,发起投票后,三个节点都会给⾃⼰投票。这样⼀轮投票下来,三个节点的term都是1,是⼀样的,这样是选举不出Leader的。

  2. 当⼀轮投票选举不出Leader后,三个节点会进⼊随机休眠,例如A休眠1秒,B休眠3秒,C休眠2秒。⼀秒后,A节点醒来,会把⾃⼰的term加⼀票,投为2。然后2秒时,C节点醒来,发现A的term已经是2,⽐⾃⼰的1⼤,就会承认A是Leader,把⾃⼰的term也更新为2。实际上这个时候,A已经获得了集群中的多数票,2票,A就会被选举成Leader。这样,⼀般经过很短的⼏轮选举,就会选举出⼀个Leader来。

  3. 到3秒时,B节点会醒来,也同样会承认A的term最⼤,他是Leader,⾃⼰的term也会更新为2。这样集群中的所有Candidate就都确定成了leader和follower.

  4. 然后在⼀个任期内,A会不断发⼼跳给另外两个节点。当A挂了后,另外的节点没有收到A的⼼跳,就会都转化成Candidate状态,重新发起选举。

Dledger还会采⽤Raft协议进⾏多副本的消息同步:
数据同步会通过两个阶段,⼀个是uncommitted阶段,⼀个是commited阶段。

  1. Leader Broker上的Dledger收到⼀条数据后,会标记为uncommitted状态,然后他通过⾃⼰的DledgerServer组件把这个uncommitted数据发给Follower Broker的DledgerServer组件。

  2. 接着Follower Broker的DledgerServer收到uncommitted消息之后,必须返回⼀个ack给Leader Broker的Dledger。然后如果Leader Broker收到超过半数的Follower Broker返回的ack之后,就会把消息标记为committed状态。

  3. 再接下来, Leader Broker上的DledgerServer就会发送committed消息给Follower Broker上的DledgerServer,让他们把消息也标记为committed状态。这样,就基于Raft协议完成了两阶段的数据同步。

消息(Message)

消息系统所传输信息的物理载体,⽣产和消费数据的最⼩单位,每条消息必须属于⼀个主题Topic。RocketMQ中每个消息拥有唯⼀的Message ID,且可以携带具有业务标识的Key。系统提供了通过Message ID和Key查询消息的功能。

并且Message上有⼀个为消息设置的标志,Tag标签。⽤于同⼀主题下区分不同类型的消息。来⾃同⼀业务单元的消息,可以根据不同业务⽬的在同⼀主题下设置不同标签。标签能够有效地保持代码的清晰度和连贯性,并优化RocketMQ提供的查询系统。消费者可以根据Tag实现对不同⼦主题的不同消费逻辑,实现更好的扩展性。

RocketMQ消息的存储分为三个部分:

CommitLog:存储消息的元数据。所有消息都会顺序存⼊到CommitLog⽂件当中。CommitLog由多个⽂件组成,每个⽂件固定⼤⼩1G。每个文件以第⼀条消息的偏移量为⽂件名。
ConsumerQueue:存储消息在CommitLog的索引。⼀个MessageQueue⼀个⽂件,记录当前MessageQueue被哪些消费者组消费到了哪⼀条CommitLog。
IndexFile:为了消息查询提供了⼀种通过key或时间区间来查询消息的⽅法,这种通过IndexFile来查找消息的⽅法不影响发送与消费消息的主流程

消息存储结构

在这里插入图片描述

另外还有⼏个⽂件可以了解下。

abort:这个⽂件是RocketMQ⽤来判断程序是否正常关闭的⼀个标识⽂件。正常情况下,会在启动时创建,⽽关闭服务时删除。但是如果遇到⼀些服务器宕机,或者kill -9这样⼀些⾮正常关闭服务的情况,这个abort⽂件就不会删除,因此RocketMQ就可以判断上⼀次服务是⾮正常关闭的,后续就会做⼀些数据恢复的操作。
checkpoint:数据存盘检查点
config/*.json:这些⽂件是将RocketMQ的⼀些关键配置信息进⾏存盘保存。例如Topic配置、消费
者组配置、消费者组消息偏移量Offset 等等⼀些信息。

刷盘机制

RocketMQ需要将消息存储到磁盘上,这样才能保证断电后消息不会丢失。同时这样才可以让存储的消息量可以超出内存的限制。RocketMQ为了提⾼性能,会尽量保证磁盘的顺序写。消息在写⼊磁盘时,有两种写磁盘的⽅式,同步刷盘和异步刷盘
在这里插入图片描述

同步刷盘
在返回写成功状态时,消息已经被写⼊磁盘。具体流程是,消息写⼊内存的PageCache后,⽴刻通知刷盘线程刷盘, 然后等待刷盘完成,刷盘线程执⾏完成后唤醒等待的线程,返回消息写成功的状态。

## 刷盘策略配置
flushDiskType = SYNC_FLUSH 

异步刷盘
在返回写成功状态时,消息可能只是被写⼊了内存的PageCache,然后立即通知Producer消息已存储成功,可以继续处理业务逻辑。写操作的返回快,吞吐量⼤;当内存⾥的消息量积累到⼀定程度时,统⼀触发写磁盘动作(只是唤醒后台刷盘线程,不确定线程执行的时机),快速写⼊。刷盘的最终实现是调用NIO的MappedByteBuffer.force() 将数据刷新到磁盘…

配置⽅式

刷盘⽅式是通过Broker配置⽂件⾥的flushDiskType 参数设置的,这个参数被配置成SYNC_FLUSH、ASYNC_FLUSH中的 ⼀个,分别代表同步和异步

## 刷盘策略配置
flushDiskType = ASYNC_FLUSH 

消息主从复制

如果Broker以⼀个集群的⽅式部署,会有⼀个master节点和多个slave节点,消息需要从Master复制到Slave上。⽽消息复制的⽅式分为同步复制和异步复制。

同步复制

同步复制是等Master和Slave都写⼊消息成功后才反馈给客户端写⼊成功的状态。在同步复制下,如果Master节点故障,Slave上有全部的数据备份,这样容易恢复数据。但是同步复制会增⼤数据写⼊的延迟,降低系统的吞吐量。

异步复制

异步复制是只要master写⼊消息成功,就反馈给客户端写⼊成功的状态。然后再异步的将消息复制给Slave节点。在异步复制下,系统拥有较低的延迟和较⾼的吞吐量。但是如果master节点故障,⽽有些数据没有完成复制,就会造成数据丢失。

配置⽅式:
消息复制⽅式是通过Broker配置⽂件⾥的brokerRole参数进⾏设置的,这个参数可以被设置成ASYNC_MASTER、 SYNC_MASTER、SLAVE三个值中的⼀个。

ASYNC_MASTER/异步复制 master、SYNC_MASTER/同步双写 master,当前是从节点则配置为第三点 slave ,对于从节点就配置 slave ,对于主节点才会有同步和异步的复制区别。

负载均衡

Producer负载均衡

Producer发送消息时,默认会轮询⽬标Topic下的所有MessageQueue,并采⽤递增取模的⽅式往不同的MessageQueue上发送消息,以达到让消息平均落在不同的queue上的⽬的。⽽由于MessageQueue是分布在不同的Broker上的,所以消息也会发送到不同的broker上。

在这里插入图片描述

同时⽣产者在发送消息时,可以指定⼀个MessageQueueSelector。通过这个对象来将消息发送到⾃⼰指定的MessageQueue上。这样可以保证消息局部有序。

Consumer负载均衡

消息重试

生产端的消息重试

生产端的消息重试是指:Producer往Broker上发消息没有发送成功,比如网络原因导致生产者发送消息到MQ失败,即发送端没有收到Broker的ACK,导致最终Consumer无法消费消息,此时RocketMQ会自动进行重试,生产端默认3次。。

生产者端的消息重试配置比较简单,只需要在定义生产者的时候,调用producer.setRetryTimesWhenSendFailed(xxx)方法设置消息发送失败的最大重试次数。如下:

// 同步发送消息,如果5秒内没有发送成功,则重试3次
DefaultMQProducer producer = new DefaultMQProducer("DefaultProducer");
producer.setRetryTimesWhenSendFailed(3);
producer.send(msg, 5000L);

消费端的消息重试

同样的,由于网络原因,Broker发送消息给消费者后,没有受到消费端的ACK响应,所以Broker又会尝试将消息重新发送给Consumer,在实际开发过程中,我们更应该考虑的是消费端的重试。消费端的消息重试可以分为顺序消息的重试以及无序消息的重试。

顺序消息的重试

对于顺序消息,当消费者消费消息失败后,消息队列 RocketMQ 会自动不断进行消息重试(每次间隔时间为 1 秒),这时应用会出现消息消费被阻塞的情况。因此,在使用顺序消息时,务必保证应用能够及时监控并处理消费失败的情况,避免阻塞现象的发生。

无序消息的重试
对于无序消息(普通、延时、事务消息),当消费者消费消息失败时,可以通过设置返回状态达到消息重试的结果。

对于⼴播模式的消息, 是不存在消息重试的机制的,即消息消费失败后,不会再重新进⾏发送,⽽只是继续消费新的消息。
对于普通的消息,当消费者消费消息失败后,你可以通过设置返回状态达到消息重试的结果。
集群消费⽅式下,消息消费失败后期望消息重试,需要在消息监听器接⼝的实现中明确进⾏配置。
可以有三种配置重试⽅式:

  1. 返回Action.ReconsumeLater-推荐
  2. 返回null
  3. 抛出异常
public class MessageListenerImpl implements MessageListener {
	@Override
	public Action consume(Message message, ConsumeContext context) {
		//处理消息
		doConsumeMessage(message);
		//⽅式1:返回 Action.ReconsumeLater,消息将重试
		return Action.ReconsumeLater;
		//⽅式2:返回 null,消息将重试
		return null;
		//⽅式3:直接抛出异常, 消息将重试
		throw new RuntimeException("Consumer Message exceotion");
	}
}

如果希望消费失败后不重试,可以直接返回Action.CommitMessage。

public class MessageListenerImpl implements MessageListener {
	@Override
	public Action consume(Message message, ConsumeContext context) {
		try {
			doConsumeMessage(message);
		} catch (Throwable e) {
			//捕获消费逻辑中的所有异常,并返回 Action.CommitMessage;
			return Action.CommitMessage;
		}
		//消息处理正常,直接返回 Action.CommitMessage;
		return Action.CommitMessage;
	}
}

一条消息无论重试多少次,这些重试消息的 Message ID 不会改变。所以就需要我们消费者端做好消费幂等操作。但是在4.7.1版本中,每次重试MessageId都会重建。

RocketMQ 默认允许每条消息最多重试 16 次,每次重试的间隔时间与延迟等级一致,如果消息重试 16 次后仍然失败,消息将不再投递。

RocketMQ也允许Consumer 启动的时候设置最大重试次数,重试时间间隔将按照如下策略:
最大重试次数小于等于 16 次,则重试时间间隔如目录四:消息重试次数的描述;
最大重试次数大于 16 次,超过 16 次的重试时间间隔均为每次 2 小时;

Properties properties = new Properties();
//  配置对应 Group ID的最大消息重试次数为 20 次
properties.put(PropertyKeyConst.MaxReconsumeTimes, "20");
Consumer consumer =ONSFactory.createConsumer(properties);

消息重试原理

RocketMQ会为每个消费者组都设置一个Topic名称为“%RETRY%+consumerGroup”的重试队列(这里需要注意的是,这个Topic的重试队列是针对消费组,而不是针对每个Topic设置的),用于暂时保存因为各种异常而导致Consumer端无法消费的消息。

考虑到异常恢复需要一些时间,RocketMQ会为重试队列设置多个重试级别,每个重试级别都有与之对应的重新投递延时,重试次数越多投递延时就越大。RocketMQ对于重试消息的处理是先保存至Topic名称为“SCHEDULE_TOPIC_XXXX”的延迟队列中,后台定时任务按照对应的时间进行Delay后重新保存至“%RETRY%+consumerGroup”的重试队列中。

重试消息如何处理

死信队列

当⼀条消息消费失败,RocketMQ就会⾃动进⾏消息重试。⽽如果消息超过最⼤重试次数,RocketMQ就会认为这个消息有问题。但是此时,RocketMQ不会⽴刻将这个有问题的消息丢弃,⽽会将其发送到这个消费者组对应的⼀种特殊队列:死信队列。

死信队列的名称是%DLQ%+ConsumGroup
在这里插入图片描述

⼀个死信队列对应⼀个ConsumGroup,⽽不是对应某个消费者实例或TOPIC。如果⼀个ConsumeGroup没有产⽣死信队列,RocketMQ就不会为其创建相应的死信队列。⼀个死信队列包含了这个ConsumeGroup⾥的所有死信消息,⽽不区分该消息属于哪个Topic。死信队列中的消息不会再被消费者正常消费。死信队列的有效期跟正常消息相同。默认3天,对应broker.conf中的fileReservedTime属性。超过这个最⻓时间的消息都会被删除,⽽不管消息是否消费过。

通常,⼀条消息进⼊了死信队列,意味着消息在消费处理的过程中出现了⽐较严重的错误,并且⽆法⾃⾏恢复。此时,⼀般需要⼈⼯去查看死信队列中的消息,对错误原因进⾏排查。然后对死信消息进⾏处理,⽐如转发到正常的Topic重新进⾏消费,或者丢弃。

默认创建出来的死信队列,他⾥⾯的消息是⽆法读取的,在控制台和消费者中都⽆法读取。这是因为这些默认的死信队列,他们的权限perm被设置成了2:禁读(这个权限有三种 2:禁读,4:禁写,6:可读可写)。需要⼿动将死信队列的权限配置成6,才能被消费(可以通过mqadmin指定或者web控制台)。

消息幂等

幂等的概念

在MQ系统中,对于消息幂等有三种实现语义:

  1. at most once 最多⼀次:每条消息最多只会被消费⼀次
  2. at least once ⾄少⼀次:每条消息⾄少会被消费⼀次
  3. exactly once 刚刚好⼀次:每条消息都只会确定的消费⼀次

at most once是最好保证的。RocketMQ中可以直接⽤异步发送、sendOneWay等⽅式就可以保证。
at least once这个语义,RocketMQ也有同步发送、事务消息等很多⽅式能够保证。
exactly once是MQ中最理想也是最难保证的⼀种语义,需要有⾮常精细的设计才⾏。RocketMQ只能保证at least once,保证不了exactly once。所以,使⽤RocketMQ时,需要由业务系统⾃⾏保证消息的幂等性。

消息幂等的必要性

在互联⽹应⽤中,尤其在⽹络不稳定的情况下,消息队列 RocketMQ 的消息有可能会出现重复,这个重复简单可以概括为以下情况:

发送时消息重复

当⼀条消息已被成功发送到服务端并完成持久化,此时出现了⽹络闪断或者客户端宕机,导致服务端对客户端应答失败。 如果此时⽣产者意识到消息发送失败并尝试再次发送消息,消费者后续会收到两条内容相同并且 Message ID 也相同的消息。

投递时消息重复
消息消费的场景下,消息已投递到消费者并完成业务处理,当客户端给服务端反馈应答的时候⽹络闪断。 为了保证消息⾄少被消费⼀次,消息队列 RocketMQ 的服务端将在⽹络恢复后再次尝试投递之前已被处理过的消息,消费者后续会收到两条内容相同并且 Message ID 也相同的消息。

负载均衡时消息重复(包括但不限于⽹络抖动、Broker 重启以及订阅⽅应⽤重启)

当消息队列 RocketMQ 的 Broker 或客户端重启、扩容或缩容时,会触发 Rebalance,此时消费者可能会收到重复消息。

处理⽅式
在RocketMQ中,是⽆法保证每个消息只被投递⼀次的,所以要在业务上⾃⾏来保证消息消费的幂等性。
⽽要处理这个问题,RocketMQ的每条消息都有⼀个唯⼀的MessageId,这个参数在多次投递的过程中是不会改变的,所以业务上可以⽤这个MessageId来作为判断幂等的关键依据。
但是,这个MessageId是⽆法保证全局唯⼀的,也会有冲突的情况。所以在⼀些对幂等性要求严格的场景,最好是使⽤业务上唯⼀的⼀个标识⽐较靠谱。例如订单ID。⽽这个业务标识可以使⽤Message的Key来进⾏传递

消息不丢失

消息不丢失

面试题

RocketMQ【2023面试题】

RocketMQ面试题

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值