KafKa知识点总结

什么是Kafka

	kafka最初是LinkedIn的一个内部基础设施系统。最初开发的起因是,LinkedIn虽然有了数据库和其他系统可以用来存储数据,但是缺乏一个可以帮助处理持续数据流的组件。所以在设计理念上,开发者不想只是开发一个能够存储数据的系统,如关系数据库、Nosql数据库、搜索引擎等等,更希望把数据看成一个持续变化和不断增长的流,并基于这样的想法构建出一个数据系统,一个数据架构。
	Kafka可以看成一个流平台,这个平台上可以发布和订阅数据流,并把他们保存起来,进行处理。Kafka有点像消息系统,允许发布和订阅消息流,但是它和传统的消息系统有很大的差异,首先,Kafka是个现代分布式系统,以集群的方式运行,可以自由伸缩。其次,Kafka可以按照要求存储数据,保存多久都可以,第三,流式处理将数据处理的层次提示到了新高度,消息系统只会传递数据,Kafka的流式处理能力可以让我们用很少的代码就能动态地处理派生流和数据集。所以Kafka不仅仅是个消息中间件。
	同时在大数据领域,Kafka还可以看成实时版的Hadoop,Hadoop可以存储和定期处理大量的数据文件,往往以TB计数,而Kafka可以存储和持续处理大型的数据流。Hadoop主要用在数据分析上,而Kafka因为低延迟,更适合于核心的业务应用上。

Kafka中的基本概念

消息和批次

消息,Kafka里的数据单元,也就是我们一般消息中间件里的消息的概念。消息由字节数组组成。消息还可以包含键,用以对消息选取分区。
为了提高效率,消息被分批写入Kafka。批次就是一组消息,这些消息属于同一个主题和分区。如果只传递单个消息,会导致大量的网络开销,把消息分成批次传输可以减少这开销。但是,这个需要权衡,批次里包含的消息越多,单位时间内处理的消息就越多,单个消息的传输时间就越长。如果进行压缩,可以提升数据的传输和存储能力,但需要更多的计算处理。

主题和分区

Kafka里的消息用主题进行分类,主题下有可以被分为若干个分区。分区本质上是个提交日志,有新消息,这个消息就会以追加的方式写入分区,然后用先入先出的顺序读取。
但是因为主题会有多个分区,所以在整个主题的范围内,是无法保证消息的顺序的,单个分区则可以保证。Kafka通过分区来实现数据冗余和伸缩性,因为分区可以分布在不同的服务器上,那就是说一个主题可以跨越多个服务器。前面我们说Kafka可以看成一个流平台,很多时候,我们会把一个主题的数据看成一个流,不管有多少个分区。

生产者和消费者、偏移量、消费者群组

就是一般消息中间件里生产者和消费者的概念。一些其他的高级客户端API,像数据管道API和流式处理的KafkaStream,都是使用了最基本的生产者和消费者作为内部组件,然后提供了高级功能。
生产者默认情况下把消息均衡分布到主题的所有分区上,如果需要指定分区,则需要使用消息里的消息键和分区器。
消费者订阅主题,一个或者多个,并且按照消息的生成顺序读取。消费者通过检查所谓的偏移量来区分消息是否读取过。偏移量是一种元数据,一个不断递增的整数值,创建消息的时候,Kafka会把他加入消息。在一个分区里,每个消息的偏移量是唯一的。每个分区最后读取的消息偏移量会保存到Zookeeper或者Kafka上,这样分区的消费者关闭或者重启,读取状态都不会丢失。
多个消费者可以构成一个消费者群组。怎么构成?共同读取一个主题的消费者们,就形成了一个群组。群组可以保证每个分区只被一个消费者使用。消费者和分区之间的这种映射关系叫做消费者对分区的所有权关系,很明显,一个分区只有一个消费者,而一个消费者可以有多个分区。

Broker和集群

一个独立的Kafka服务器叫Broker。broker的主要工作是,接收生产者的消息,设置偏移量,提交消息到磁盘保存;为消费者提供服务,响应请求,返回消息。在合适的硬件上,单个broker可以处理上千个分区和每秒百万级的消息量。
多个broker可以组成一个集群。每个集群中broker会选举出一个集群控制器。控制器会进行管理,包括将分区分配给broker和监控broker。集群里,一个分区从属于一个broker,这个broker被称为首领。但是分区可以被分配给多个broker,这个时候会发生分区复制。分区复制带来的好处是,提供了消息冗余。一旦首领broker失效,其他broker可以接管领导权。当然相关的消费者和生产者都要重新连接到新的首领上。

保留消息

在一定期限内保留消息是Kafka的一个重要特性,Kafkabroker默认的保留策略是:要么保留一段时间,要么保留一定大小。到了限制,旧消息过期并删除。但是每个主题可以根据业务需求配置自己的保留策略。

优点

1,多生产者和多消费者
2,基于磁盘的数据存储,换句话说,Kafka的数据天生就是持久化的。
3,高伸缩性,Kafka一开始就被设计成一个具有灵活伸缩性的系统,对在线集群的伸缩丝毫不影响整体系统的可用性。
4,高性能,结合横向扩展生产者、消费者和broker,Kafka可以轻松处理巨大的信息流,同时保证亚秒级的消息延迟。

常见场景
	1,活动跟踪
		跟踪网站用户和前端应用发生的交互,比如页面访问次数和点击,将这些信息作为消息发布到一个或者多个主题上,这样就可以根据这些数据为机器学习提供数据,更新搜素结果等等。
	2,传递消息
		标准消息中间件的功能
	3,收集指标和日志
		收集应用程序和系统的度量监控指标,或者收集应用日志信息,通过Kafka路由到专门的日志搜索系统,比如ES。
	4,提交日志
		收集其他系统的变动日志,比如数据库。可以把数据库的更新发布到Kafka上,应用通过监控事件流来接收数据库的实时更新,或者通过事件流将数据库的更新复制到远程系统。
		还可以当其他系统发生了崩溃,通过重放日志来恢复系统的状态。
	5,流处理
		操作实时数据流,进行统计、转换、复杂计算等等。随着大数据技术的不断发展和成熟,无论是传统企业还是互联网公司都已经不再满足于离线批处理,实时流处理的需求和重要性日益增长。近年来业界一直在探索实时流计算引擎和API,比如这几年火爆的Spark Streaming、Kafka Streaming、Beam和Flink,其中阿里双11会场展示的实时销售金额,就用的是流计算,是基于Flink,然后阿里在其上定制化的Blink。
硬件配置对Kafka性能的影响
	1,磁盘吞吐量/磁盘容量
		磁盘吞吐量会影响生产者的性能。因为生产者的消息必须被提交到服务器保存,大多数的客户端都会一直等待,直到至少有一个服务器确认消息已经成功提交为止。也就是说,磁盘写入速度越快,生成消息的延迟就越低。磁盘容量的大小,则主要看需要保存的消息数量。如果每天收到1TB的数据,并保留7天,那么磁盘就需要7TB的数据。
	2,内存
		Kafka本身并不需要太大内存,内存则主要是影响消费者性能。在大多数业务情况下,消费者消费的数据一般会从内存中获取,这比在磁盘上读取肯定要快的多。
	3,网络
		网络吞吐量决定了Kafka能够处理的最大数据流量。
	4,CPU
		Kafka对cpu的要求不高,主要是用在对消息解压和压缩上。所以cpu的性能不是在使用Kafka的首要考虑因素。
副本类型
	首领副本
		每个分区都有一个首领副本。为了保证一致性,所有生产者请求和消费者请求都会经过这个副本 。
	跟随者副本
		首领以外的副本都是跟随者副本。跟随者副本不处理来自客户端的请求,它们唯一一的任务就是从首领那里复制消息,保持与首领一致的状态。如果首领发生崩溃,其中的一个跟随者会被提升为新首领 。
顺序保证

Kafka可以保证同一个分区里的消息是有序的。也就是说,如果生产者一定的顺序发送消息,broker就会按照这个顺序把它们写入分区,消费者也会按照同样的顺序读取它们。在某些情况下, 顺序是非常重要的。例如,往一个账户存入100 元再取出来,这个与先取钱再存钱是截然不同的!不过,有些场景对顺序不是很敏感。
如果把retires设为非零整数,同时把max.in.flight.request.per.connection设为比1大的数,那么,如果第一个批次消息写入失败,而第二个批次写入成功,broker会重试写入第一个批次。如果此时第一个批次也写入成功,那么两个批次的顺序就反过来了。
一般来说,如果某些场景要求消息是有序的,那么消息是否写入成功也是很关键的,所以不建议把retires设为0 。可以把max.in.flight.request.per.connection 设为1,这样在生产者尝试发送第一批消息时,就不会有其他的消息发送给broker 。不过这样会严重影响生产者的吞吐量,所以只有在对消息的顺序有严格要求的情况下才能这么做。

序列化

创建生产者对象必须指定序列化器,默认的序列化器并不能满足我们所有的场景。我们完全可以自定义序列化器。只要实现org.apache.kafka.common.serialization.Serializer接口即可。

自定义序列化需要考虑的问题

自定义序列化容易导致程序的脆弱性。举例,在我们上面的实现里,我们有多种类型的消费者,每个消费者对实体字段都有各自的需求,比如,有的将字段变更为long型,有的会增加字段,这样会出现新旧消息的兼容性问题。特别是在系统升级的时候,经常会出现一部分系统升级,其余系统被迫跟着升级的情况。
解决这个问题,可以考虑使用自带格式描述以及语言无关的序列化框架。比如Protobuf,或者Kafka官方推荐的Apache Avro。
Avro会使用一个JSON文件作为schema来描述数据,Avro在读写时会用到这个schema,可以把这个schema内嵌在数据文件中。这样,不管数据格式如何变动,消费者都知道如何处理数据。
但是内嵌的消息,自带格式,会导致消息的大小不必要的增大,消耗了资源。我们可以使用schema注册表机制,将所有写入的数据用到的schema保存在注册表中,然后在消息中引用schema的标识符,而读取的数据的消费者程序使用这个标识符从注册表中拉取schema来反序列化记录。
注意:Kafka本身并不提供schema注册表,需要借助第三方,现在已经有很多的开源实现,比如Confluent Schema Registry,可以从GitHub上获取。

分区

我们在新增ProducerRecord对象中可以看到,ProducerRecord包含了目标主题,键和值,Kafka的消息都是一个个的键值对。键可以设置为默认的null。
键的主要用途有两个:一,用来决定消息被写往主题的哪个分区,拥有相同键的消息将被写往同一个分区,二,还可以作为消息的附加消息。
如果键值为null,并且使用默认的分区器,分区器使用轮询算法将消息均衡地分布到各个分区上。
如果键不为空,并且使用默认的分区器,Kafka对键进行散列(Kafka自定义的散列算法,具体算法原理不知),然后根据散列值把消息映射到特定的分区上。很明显,同一个键总是被映射到同一个分区。但是只有不改变主题分区数量的情况下,键和分区之间的映射才能保持不变,一旦增加了新的分区,就无法保证了,所以如果要使用键来映射分区,那就要在创建主题的时候把分区规划好,而且永远不要增加新分区。

自定义分区器

某些情况下,数据特性决定了需要进行特殊分区,比如电商业务,北京的业务量明显比较大,占据了总业务量的20%,我们需要对北京的订单进行单独分区处理,默认的散列分区算法不合适了, 我们就可以自定义分区算法,对北京的订单单独处理,其他地区沿用散列分区算法。或者某些情况下,我们用value来进行分区。

消费者和消费者群组、分区再均衡
	消费者的含义,同一般消息中间件中消费者的概念。在高并发的情况下,生产者产生消息的速度是远大于消费者消费的速度,单个消费者很可能会负担不起,此时有必要对消费者进行横向伸缩,于是我们可以使用多个消费者从同一个主题读取消息,对消息进行分流。

	消费者群组:
		Kafka里消费者从属于消费者群组,一个群组里的消费者订阅的都是同一个主题,每个消费者接收主题一部分分区的消息。但是,当我们增加更多的消费者,超过了主题的分区数量,就会有一部分的消费者被闲置,不会接收到任何消息。往消费者群组里增加消费者是进行横向伸缩能力的主要方式。所以我们有必要为主题设定合适规模的分区,在负载均衡的时候可以加入更多的消费者。但是要记住,一个群组里消费者数量超过了主题的分区数量,多出来的消费者是没有用处的。

	分区再均衡:
		当消费者群组里的消费者发生变化,或者主题里的分区发生了变化,都会导致再均衡现象的发生。从前面的知识中,我们知道,Kafka中,存在着消费者对分区所有权的关系,
		这样无论是消费者变化,比如增加了消费者,新消费者会读取原本由其他消费者读取的分区,消费者减少,原本由它负责的分区要由其他消费者来读取,增加了分区,哪个消费者来读取这个新增的分区,这些行为,都会导致分区所有权的变化,这种变化就被称为再均衡。
		再均衡对Kafka很重要,这是消费者群组带来高可用性和伸缩性的关键所在。不过一般情况下,尽量减少再均衡,因为再均衡期间,消费者是无法读取消息的,会造成整个群组一小段时间的不可用。消费者通过向称为群组协调器的broker(不同的群组有不同的协调器)发送心跳来维持它和群组的从属关系以及对分区的所有权关系。如果消费者长时间不发送心跳,群组协调器认为它已经死亡,就会触发一次再均衡。在0.10.1及以后的版本中,心跳由单独的线程负责,相关的控制参数为max.poll.interval.ms。

	消费者分区分配的过程:
		消费者要加入群组时,会向群组协调器发送一个JoinGroup请求,第一个加入群主的消费者成为群主,群主会获得群组的成员列表,并负责给每一个消费者分配分区。分配完毕后,群组把分配情况发送给群组协调器,协调器再把这些信息发送给所有的消费者,每个消费者只能看到自己的分配信息,只有群主知道群组里所有消费者的分配信息。这个过程在每次再均衡时都会发生。
使用Kafka消费者
	订阅:
		创建消费者后,使用subscribe()方法订阅主题,这个方法接受一个主题列表为参数,也可以接受一个正则表达式为参数;正则表达式同样也匹配多个主题。如果新创建了新主题,并且主题名字和正则表达式匹配,那么会立即触发一次再均衡,消费者就可以读取新添加的主题。比如,要订阅所有和test相关的主题,可以subscribe(“tets.*”)
	
	轮询:
		为了不断的获取消息,我们要在循环中不断的进行轮询,也就是不停调用poll方法。
		poll方法的参数为超时时间,控制poll方法的阻塞时间,它会让消费者在指定的毫秒数内一直等待broker返回数据。poll方法将会返回一个记录(消息)列表,每一条记录都包含了记录所属的主题信息,记录所在分区信息,记录在分区里的偏移量,以及记录的键值对。
		poll方法不仅仅只是获取数据,在新消费者第一次调用时,它会负责查找群组,加入群组,接受分配的分区。如果发生了再均衡,整个过程也是在轮询期间进行的。

	多线程下的消费者:
		KafkaConsumer的实现不是线程安全的,所以我们在多线程的环境下,使用KafkaConsumer的实例要小心,应该每个消费数据的线程拥有自己的KafkaConsumer实例。
提交和偏移量

当我们调用poll方法的时候,broker返回的是生产者写入Kafka但是还没有被消费者读取过的记录,消费者可以使用Kafka来追踪消息在分区里的位置,我们称之为偏移量。消费者更新自己读取到哪个消息的操作,我们称之为提交。
消费者是如何提交偏移量的呢?消费者会往一个叫做_consumer_offset的特殊主题发送一个消息,里面会包括每个分区的偏移量。发生了再均衡之后,消费者可能会被分配新的分区,为了能够继续工作,消费者者需要读取每个分区最后一次提交的偏移量,然后从指定的地方,继续做处理。如果提交的偏移量小于消费者实际处理的最后一个消息的偏移量,处于两个偏移量之间的消息会被重复处理,如果提交的偏移量大于客户端处理的最后一个消息的偏移量,那么处于两个偏移量之间的消息将会丢失,所以, 处理偏移量的方式对客户端会有很大的影响 。KafkaConsumer API提供了很多种方式来提交偏移量 。

		自动提交:
			最简单的提交方式是让消费者自动提交偏移量。 如果 enable.auto.comnit被设为 true,消费者会自动把从poll()方法接收到的最大偏移量提交上去。提交时间间隔由auto.commit.interval.ms控制,默认值是5s。自动提交是在轮询里进行的,消费者每次在进行轮询时会检査是否该提交偏移量了,如果是,那么就会提交从上一次轮询返回的偏移量。
			不过,在使用这种简便的方式之前,需要知道它将会带来怎样的结果。
			假设我们仍然使用默认的5s提交时间间隔, 在最近一次提交之后的3s发生了再均衡,再均衡之后,消费者从最后一次提交的偏移量位置开始读取消息。这个时候偏移量已经落后了3s,所以在这3s内到达的消息会被重复处理。可以通过修改提交时间间隔来更频繁地提交偏移量, 减小可能出现重复消息的时间窗, 不过这种情况是无法完全避免的 。
			在使用自动提交时,每次调用轮询方法都会把上一次调用返回的偏移量提交上去,它并不知道具体哪些消息已经被处理了,所以在再次调用之前最好确保所有当前调用返回的消息都已经处理完毕(enable.auto.comnit被设为 true时,在调用 close()方法之前也会进行自动提交)。一般情况下不会有什么问题,不过在处理异常或提前退出轮询时要格外小心。
			自动提交虽然方便,不过并没有为我们留有余地来避免重复处理消息。
		
		手动提交:
			我们通过控制偏移量提交时间来消除丢失消息的可能性,并在发生再均衡时减少重复消息的数量。消费者API提供了另一种提交偏移量的方式,开发者可以在必要的时候提交当前偏移量,而不是基于时间间隔。把auto.commit. offset设为 false,自行决定何时提交偏移量。使用commitsync()提交偏移量最简单也最可靠。这个方法会提交由poll()方法返回的最新偏移量,提交成功后马上返回,如果提交失败就抛出异常。
			注意: commitsync()将会提交由poll()返回的最新偏移量,所以在处理完所有记录后要确保调用了commitsync(),否则还是会有丢失消息的风险。如果发生了再均衡,从最近批消息到发生再均衡之间的所有消息都将被重复处理。

		异步提交:
			手动提交时,在broker对提交请求作出回应之前,应用程序会一直阻塞。这时我们可以使用异步提交API,我们只管发送提交请求,无需等待broker的响应。
			在成功提交或碰到无法恢复的错误之前,commitsync()会一直重试,但是commitAsync不会。它之所以不进行重试,是因为在它收到服务器响应的时候,可能有一个更大的偏移量已经提交成功。
			假设我们发出一个请求用于提交偏移量2000,,这个时候发生了短暂的通信问题,服务器收不到请求,自然也不会作出任何响应。与此同时,我们处理了另外一批消息,并成功提交了偏移量3000。如果commitAsync()重新尝试提交偏移量2000,它有可能在偏移量3000之后提交成功。这个时候如果发生再均衡,就会出现重复消息。
			commitAsync()也支持回调,在 broker作出响应时会执行回调。回调经常被用于记录提交错误或生成度量指标。

		同步和异步组合:
			一般情况下,针对偶尔出现的提交失败,不进行重试不会有太大问题,因为如果提交失败是因为临时问题导致的,那么后续的提交总会有成功的。但如果这是发生在关闭消费者或再均衡前的最后一次提交,就要确保能够提交成功。因此,在消费者关闭前一般会组合使用 commitAsync()和 commitsync()。

		特定提交:
			在我们前面的提交中,提交偏移量的频率与处理消息批次的频率是一样的。但如果想要更频繁地提交该怎么办?
			如果poll()方法返回一大批数据,为了避免因再均衡引起的重复处理整批消息,想要在批次中间提交偏移量该怎么办?这种情况无法通过调用commitSync()或commitAsync()来实现,因为它们只会提交最后一个偏移量,而此时该批次里的消息还没有处理完。
			消费者API允许在调用 commitsync()和commitAsync()方法时传进去希望提交的分区和偏移量的map。假设我们处理了半个批次的消息,最后一个来自主题“customers”,分区3的消息的偏移量是5000,你可以调用commitsync()方法来提交它。不过,因为消费者可能不只读取一个分区,因为我们需要跟踪所有分区的偏移量,所以在这个层面上控制偏移量的提交会让代码变复杂。
再均衡监听器
	在提交偏移量一节中提到过,消费者在退出和进行分区再均衡之前,会做一些清理工作比如,提交偏移量、关闭文件句柄、数据库连接等。
	在为消费者分配新分区或移除旧分区时,可以通过消费者API执行一些应用程序代码,在调用 subscribe()方法时传进去一个 ConsumerRebalancelistener实例就可以了。
	ConsumerRebalancelistener有两个需要实现的方法。
	1) public void onPartitionsRevoked( Collection< TopicPartition> partitions)方法会在再均衡开始之前和消费者停止读取消息之后被调用。如果在这里提交偏移量,下一个接管分区的消费者就知道该从哪里开始读取了
	2) public void onPartitionsAssigned( Collection< TopicPartition> partitions)方法会在重新分配分区之后和消费者开始读取消息之前被调用。
从特定偏移量处开始记录
	到目前为止,我们知道了如何使用poll()方法从各个分区的最新偏移量处开始处理消息。
	不过,有时候我们也需要从特定的偏移量处开始读取消息。
	如果想从分区的起始位置开始读取消息,或者直接跳到分区的末尾开始读取消息,可以使 seekToBeginning(Collection<TopicPartition> tp)和 seekToEnd( Collection<TopicPartition>tp)这两个方法。
	不过,Kaka也为我们提供了用于查找特定偏移量的API。它有很多用途,比如向后回退几个消息或者向前跳过几个消息(对时间比较敏感的应用程序在处理滞后的情况下希望能够向前跳过若干个消息)。在使用 Kafka以外的系统来存储偏移量时,它将给我们带来更大的惊喜--让消息的业务处理和偏移量的提交变得一致。
	试想一下这样的场景:应用程序从Kaka读取事件(可能是网站的用户点击事件流),对它们进行处理(可能是使用自动程序清理点击操作并添加会话信息),然后把结果保存到数据库。假设我们真的不想丢失任何数据,也不想在数据库里多次保存相同的结果。
	我们可能会,毎处理一条记录就提交一次偏移量。尽管如此,在记录被保存到数据库之后以及偏移量被提交之前,应用程序仍然有可能发生崩溃,导致重复处理数据,数据库里就会出现重复记录。
	如果保存记录和偏移量可以在一个原子操作里完成,就可以避免出现上述情况。记录和偏移量要么都被成功提交,要么都不提交。如果记录是保存在数据库里而偏移量是提交到Kafka上,那么就无法实现原子操作不过,如果在同一个事务里把记录和偏移量都写到数据库里会怎样呢?那么我们就会知道记录和偏移量要么都成功提交,要么都没有,然后重新处理记录。
	现在的问题是:如果偏移量是保存在数据库里而不是 Kafka里,那么消费者在得到新分区时怎么知道该从哪里开始读取?这个时候可以使用seek()方法。在消费者启动或分配到新分区时,可以使用seck()方法查找保存在数据库里的偏移量。我们可以使用使用 Consumer Rebalancelistener和seek()方法确保我们是从数据库里保存的偏移量所指定的位置开始处理消息的。
优雅退出

如果确定要退出循环,需要通过另一个线程调用 consumer. wakeup()方法。如果循环运行在主线程里,可以在 ShutdownHook里调用该方法。要记住, consumer. wakeup()是消费者唯一一个可以从其他线程里安全调用的方法。调用 consumer. wakeup()可以退出poll(),并抛出 WakeupException异常。我们不需要处理 Wakeup Exception,因为它只是用于跳出循环的一种方式。不过,在退出线程之前调用 consumer.close()是很有必要的,它会提交任何还没有提交的东西,并向群组协调器发送消息,告知自己要离开群组,接下来就会触发再均衡,而不需要等待会话超时。

反序列化

不过就是序列化过程的一个反向,原理和实现可以参考生产者端的实现,同样也可以自定义反序列化器。

独立消费者

到目前为止,我们讨论了消费者群组,分区被自动分配给群组里的消费者,在群组里新增或移除消费者时自动触发再均衡。不过有时候可能只需要一个消费者从一个主题的所有分区或者某个特定的分区读取数据。这个时候就不需要消费者群组和再均衡了,只需要把主题或者分区分配给消费者,然后开始读取消息并提交偏移量。
如果是这样的话,就不需要订阅主题,取而代之的是为自己分配分区。一个消费者可以订阅主题(并加入消费者群组),或者为自己分配分区,但不能同时做这两件事情。

AR和ISR

AR:是一个分区的所有副本。
ISR:表示一个分区里面属于同步状态的副本。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值