二、RocketMQ消息存储源码分析

这篇具有很好参考价值的文章主要介绍了二、RocketMQ消息存储源码分析。希望对大家有所帮助。如果存在错误或未考虑完全的地方,请大家不吝赐教,您也可以点击"举报违法"按钮提交疑问。

RocketMQ源码深入剖析

6 Broker源码分析

Broker模块涉及到的内容非常多,本课程重点讲解以下技术点:

1、Broker启动流程分析

2、消息存储设计

3、消息写入流程

4、亮点分析:NRS与NRC的功能号设计

5、亮点分析:同步双写数倍性能提升的CompletableFuture

6、亮点分析:Commitlog写入时使用可重入锁还是自旋锁?

7、亮点分析:零拷贝技术之MMAP提升文件读写性能

8、亮点分析:堆外内存机制

二、RocketMQ消息存储源码分析,rocketmq,rocketmq

6.1 Broker启动流程分析

在RocketMQ中Broker的处理是最多的,所以我们先分析Broker的启动流程。核心流程图如下

二、RocketMQ消息存储源码分析,rocketmq,rocketmq

二、RocketMQ消息存储源码分析,rocketmq,rocketmq

二、RocketMQ消息存储源码分析,rocketmq,rocketmq

6.2 消息存储设计

二、RocketMQ消息存储源码分析,rocketmq,rocketmq

Kafka 中文件的布局是以 Topic/partition ,每一个分区一个物理文件夹,在分区文件级别实现文件顺序写,如果一个Kafka集群中拥有成百上千个主题,每一个主题拥有上百个分区,消息在高并发写入时,其IO操作就会显得零散(消息分散的落盘策略会导致磁盘IO竞争激烈成为瓶颈),其操作相当于随机IO,即 Kafka 在消息写入时的IO性能会随着 topic 、分区数量的增长,其写入性能会先上升,然后下降。而 RocketMQ在消息写入时追求极致的顺序写,所有的消息不分主题一律顺序写入 commitlog 文件,并不会随着 topic 和 分区数量的增加而影响其顺序性。在消息发送端,消费端共存的场景下,随着Topic数的增加Kafka吞吐量会急剧下降,而RocketMQ则表现稳定。因此Kafka适合Topic和消费端都比较少的业务场景,而RocketMQ更适合多Topic,多消费端的业务场景。

6.3 存储文件设计

RocketMQ 主要存储的文件包括 Commitlog 文件、 ConsumeQueue 文件、 IndexFile。RocketMQ 将所有主题的消息存储在同一文件,确保消息发送时顺序写文件,尽最大的能力确保消息发送的高性能与高吞吐量。但由于一般的消息中间件是基于消息主题的订阅机制,这样便给按照消息主题检索消息带来了极大的不便。为了提高消息消费的效率, RocketMQ 引入了 ConsumeQueue 消息队列文件,每个消息主题包含多个消息消费队列,每个消息队列有一个消息文件。RocketMQ 还引入了IndexFile 索引文件,其主要设计理念就是为了加速消息的检索性能,可以根据消息的属性快速从 Commitlog 文件中检索消息。整体如下:

二、RocketMQ消息存储源码分析,rocketmq,rocketmq

1 ) CommitLog :消息存储文件,所有消息主题的消息都存储在 CommitLog 文件中2 ) ConsumeQueue :消息消费队列,消息到达 CommitLog 文件后,将异步转发到消息消费队列,供消息消费者消费3 ) IndexFile :消息索引文件,主要存储消息 Key与Offset 的对应关系

6.3.1消息存储结构

CommitLog 以物理文件的方式存放,每台 Broker 上的 CommitLog 被本机器所有 ConsumeQueue 共享,在CommitLog 中,一个消息的存储长度是不固定的, RocketMQ采取一些机制,尽量向CommitLog 中顺序写 ,但是随机读。commitlog 文件默认大小为lG ,可通过在 broker 置文件中设置 mapedFileSizeCommitLog 属性来改变默认大小。

二、RocketMQ消息存储源码分析,rocketmq,rocketmq

Commitlog文件存储的逻辑视图如下,每条消息的前面4个字节存储该条消息的总长度。但是一个消息的存储长度是不固定的。

二、RocketMQ消息存储源码分析,rocketmq,rocketmq

ConsumeQueue

ConsumeQueue 是消息的逻辑队列,类似数据库的索引文件,存储的是指向物理存储的地址。每个Topic下的每个 Message Queue 都有一个对应的 ConsumeQueue 文件。

二、RocketMQ消息存储源码分析,rocketmq,rocketmq

二、RocketMQ消息存储源码分析,rocketmq,rocketmq

ConsumeQueue中存储的是消息条目,为了加速 ConsumeQueue 消息条目的检索速度与节省磁盘空间,每一个 Consumequeue条目不会存储消息的全量信息,消息条目如下:

二、RocketMQ消息存储源码分析,rocketmq,rocketmq

ConsumeQueue 即为Commitlog 文件的索引文件, 其构建机制是 当消息到达 Commitlog 文件后 由专门的线程产生消息转发任务,从而构建消息消费队列文件(ConsumeQueue )与下文提到的索引文件。存储机制这样设计有以下几个好处:1 ) CommitLog 顺序写 ,可以大大提高写入效率。(实际上,磁盘有时候会比你想象的快很多,有时候也比你想象的慢很多,关键在如何使用,使用得当,磁盘的速度完全可以匹配上网络的数据传输速度。目前的高性能磁盘,顺序写速度可以达到600MB/s ,超过了一般网卡的传输速度,这是磁盘比想象的快的地方 但是磁盘随机写的速度只有大概100KB/s,和顺序写的性能相差 6000 倍!)2 )虽然是随机读,但是利用操作系统的 pagecache 机制,可以批量地从磁盘读取,作为 cache 存到内存中,加速后续的读取速度。同时因为ConsumeQueue中每一条消息的索引是固定长度,所以也能够确保消息消费时的时间复杂度保持在O(1)。3 )为了保证完全的顺序写,需要 ConsumeQueue 这个中间结构 ,因为ConsumeQueue 里只存偏移量信息,所以尺寸是有限的,在实际情况中,大部分的 ConsumeQueue 能够被全部读入内存,所以这个中间结构的操作速度很快,可以认为是内存读取的速度。此外为了保证 CommitLog和ConsumeQueue 的一致性, CommitLog 里存储了 Consume Queues 、Message Key、 Tag 等所有信息,即使 ConsumeQueue 丢失,也可以通过 commitLog 完全恢复出来。

IndexFile

index 存的是索引文件,这个文件用来加快消息查询的速度。消息消费队列 RocketMQ 专门为消息订阅构建的索引文件 ,提高根据主题与消息检索消息的速度 ,使用Hash索引机制,具体是Hash槽与Hash冲突的链表结构

二、RocketMQ消息存储源码分析,rocketmq,rocketmq

Config

config 文件夹中 存储着Topic和Consumer等相关信息。主题和消费者群组相关的信息就存在在此。topics.json : topic 配置属性subscriptionGroup.json :消息消费组配置信息。delayOffset.json :延时消息队列拉取进度。consumerOffset.json :集群消费模式消息消进度。consumerFilter.json :主题消息过滤信息。

二、RocketMQ消息存储源码分析,rocketmq,rocketmq

6.3.2 消息存储结构源码对应

二、RocketMQ消息存储源码分析,rocketmq,rocketmq

二、RocketMQ消息存储源码分析,rocketmq,rocketmq

二、RocketMQ消息存储源码分析,rocketmq,rocketmq

6.4 消息写入流程

RocketMQ使用Netty处理网络,broker收到消息写入的请求就会进入SendMessageProcessor类中processRequest方法。

二、RocketMQ消息存储源码分析,rocketmq,rocketmq

最终进入DefaultMessageStore类中asyncPutMessage方法进行消息的存储

二、RocketMQ消息存储源码分析,rocketmq,rocketmq

然后消息进入commitlog类中的asyncPutMessage方法进行消息的存储

二、RocketMQ消息存储源码分析,rocketmq,rocketmq

整个存储设计层次非常清晰,大致的层次如下图:

二、RocketMQ消息存储源码分析,rocketmq,rocketmq

业务层:也可以称之为网络层,就是收到消息之后,一般交给SendMessageProcessor来分配(交给哪个业务来处理)。DefaultMessageStore,这个是存储层最核心的入口。

存储逻辑层:主要负责各种存储的逻辑,里面有很多跟存储同名的类。

存储I/O层:主要负责存储的具体的消息与I/O处理。

6.5 源码分析中亮点

6.5.1 NRS与NRC的功能号设计

RocketMQ的通讯使用的是Netty,作为客户端核心类有两种:RemotingCommand与NettyRemotingClient。

RemotingCommand主要处理消息的组装:包括消息头、消息序列化与反序列化。

NettyRemotingClient主要处理消息的发送:包括同步、异步、单向、注册等操作。

二、RocketMQ消息存储源码分析,rocketmq,rocketmq

二、RocketMQ消息存储源码分析,rocketmq,rocketmq

因为RocketMQ消息种类比较众多,所以对于消息的发送,使用了一个类似于功能号的设计。

客户端发送消息时定义一个code,对应一个功能,服务端注册一个业务处理,对应一个code的业务处理。

code对应码表RequestCode类。

例如:从生产者客户端代码,跳入到NRC的代码:NettyRemotingClient

二、RocketMQ消息存储源码分析,rocketmq,rocketmq

MQClientAPIImpl类中的sendMessage()中

二、RocketMQ消息存储源码分析,rocketmq,rocketmq

NettyRemotingClient类

二、RocketMQ消息存储源码分析,rocketmq,rocketmq

二、RocketMQ消息存储源码分析,rocketmq,rocketmq而在NRS中,只需要将服务端需要处理的ExecutorService注册到NRS组件中即可。

在启动流程中BrokerController类中的initialize()中

二、RocketMQ消息存储源码分析,rocketmq,rocketmq

二、RocketMQ消息存储源码分析,rocketmq,rocketmq

注意:功能号的设计并对客户端和服务端不是一对一的,在服务端往往处理是可以将不同的功能号对应到一个处理的任务中。

6.5.2 同步双写数倍性能提升的CompletableFuture

在RocketMQ4.7.0之后,RocketMQ大量使用Java中的异步编程接口CompletableFuture。尤其是在Broker端进行消息接收处理时。

具体使用及源码见 :马士兵教育官网 - IT职业领路人 (mashibing.com)

比如:DefaultMessageStore类中asyncPutMessage方法

二、RocketMQ消息存储源码分析,rocketmq,rocketmq

二、RocketMQ消息存储源码分析,rocketmq,rocketmq

Future接口正是设计模式中Future模式的一种实现:如果一个请求或任务比较耗时,可以将方法调用改为异步,方法立即返回,任务则使用主线程外的其他线程异步执行,主线程继续执行。当需要获取计算结果时,再去获取数据。

在Master-Slave主从架构下,Master 节点与 Slave 节点之间数据同步/复制的方式有同步双写和异步复制两种模式。同步双写是指Master将消息成功落盘后,需要等待Slave节点复制成功(如果有多个Slave,成功复制一个就可以)后,再告诉客户端消息发送成功。

二、RocketMQ消息存储源码分析,rocketmq,rocketmq

RocketMQ 4.7.0 以后合理使用CompletableFuture对同步双写进行性能优化,使得对消息的处理流式化,大大提高了Broker的接收消息的处理能力。

6.5.3 Commitlog写入时使用可重入锁还是自旋锁?

RocketMQ在写入消息到CommitLog中时,使用了锁机制,即同一时刻只有一个线程可以写CommitLog文件。CommitLog 中使用了两种锁,一个是自旋锁,另一个是重入锁。源码如下:

二、RocketMQ消息存储源码分析,rocketmq,rocketmq

二、RocketMQ消息存储源码分析,rocketmq,rocketmq

这里注意lock锁的标准用法是try-finally处理(防止死锁问题)

另外这里锁的类型可以自主配置。

RocketMQ 官方文档优化建议:异步刷盘建议使用自旋锁,同步刷盘建议使用重入锁,调整Broker配置项useReentrantLockWhenPutMessage,默认为false;

二、RocketMQ消息存储源码分析,rocketmq,rocketmq

同步刷盘时,锁竞争激烈,会有较多的线程处于等待阻塞等待锁的状态,如果采用自旋锁会浪费很多的CPU时间,所以“同步刷盘建议使用重入锁”。

异步刷盘是间隔一定的时间刷一次盘,锁竞争不激烈,不会存在大量阻塞等待锁的线程,偶尔锁等待就自旋等待一下很短的时间,不要进行上下文切换了,所以采用自旋锁更合适。

6.5.4 零拷贝技术之MMAP提升文件读写性能

RocketMQ底层对commitLog、consumeQueue之类的磁盘文件的读写操作都采用了mmap技术。具体到代码里面就是利用JDK里面NIO的MapperByteBuffer的map()函数,来先将磁盘文件(CommitLog文件、consumeQueue文件)映射到内存里来。

假如没有使用mmap技术的时候,使用最传统和基本普通文件进行io操作会产生数据多拷贝问题。比如从磁盘上把数据读取到内核IO缓冲区里面,然后再从内核IO缓冲区中读取到用户进程私有空间里去,然后我们才能拿到这个数据。

MMAP内存映射是在硬盘上文件的位置和应用程序缓冲区(application buffers)进行映射(建立一种一一对应关系),由于mmap()将文件直接映射到用户空间,所以实际文件读取时根据这个映射关系,直接将文件从硬盘拷贝到用户空间,只进行了一次数据拷贝,不再有文件内容从硬盘拷贝到内核空间的一个缓冲区。

二、RocketMQ消息存储源码分析,rocketmq,rocketmq

MMAP属于零拷贝技术的一种。

零拷贝(英语: Zero-copy) 技术是指计算机执行操作时,CPU不需要先将数据从某处内存复制到另一个特定区域。这种技术通常用于通过网络传输文件时节省CPU周期和内存带宽。

mmap技术在地址映射的过程中对文件的大小是有限制的,在1.5G~2G之间,所以,RocketMQ就会把单个的commitLog文件大小控制在1GB,consumeQueue文件大小控制在5.72MB,这样就在读写的时候,方便的进行内存映射了。

Broker启动时MMAP相关源码如下:

MappedFile类的init方法

二、RocketMQ消息存储源码分析,rocketmq,rocketmq

生产者发送消息时MMAP相关消息写入源码如下:

二、RocketMQ消息存储源码分析,rocketmq,rocketmq

CommitLog类中的doAppend方法:具体进行消息格式的排列:

二、RocketMQ消息存储源码分析,rocketmq,rocketmq

CommitLog之Message格式(可做参考)

二、RocketMQ消息存储源码分析,rocketmq,rocketmq

6.5.5 堆外内存机制

一般情况下RocketMQ是通过MMAP内存映射,生产时消息写入内存映射文件,然后消费的时候再读。但是RocketMQ还提供了一种机制。堆外内存机制:TransientStorePool,短暂的存储池(堆外内存)。

6.5.5.1 开启条件及限制

开启堆外内存需要修改配置文件broker:transientStorePoolEnable=true

二、RocketMQ消息存储源码分析,rocketmq,rocketmq

同时如果开启了堆外内存缓冲区的话,集群模式必须是异步刷盘的模式同时该Broker必须为主节点,通过查看源码我们可以可以看到这一限制:

DefaultMessageStore. DefaultMessageStore()

二、RocketMQ消息存储源码分析,rocketmq,rocketmq

二、RocketMQ消息存储源码分析,rocketmq,rocketmq

从堆外内存的流程图也可以看出,堆外内存的消息写入明显要多一个步骤,所以堆外内存缓冲区的设置一定要求是异步才行。

6.5.5.2 堆外缓冲区流程

二、RocketMQ消息存储源码分析,rocketmq,rocketmq

RocketMQ单独创建一个ByteBuffer内存缓存池,用来临时存储数据,数据先写入该内存映射中,然后由commit线程定时将数据从该内存复制到与目标物理文件对应的内存映射中。RocketMQ引入该机制主要的原因是提供一种内存锁定,将当前堆外内存一直锁定在内存中,避免被进程将内存交换到磁盘。同时因为是堆外内存,这么设计可以避免频繁的GC。

6.5.5.3 源码分析

在DefaultMessageStore类中,开启堆外缓冲配置则进行堆外内存池初始化(TransientStorePool)

二、RocketMQ消息存储源码分析,rocketmq,rocketmq

在创建MappedFile时如果检测到有堆外内存配置。这里就会把堆外内存通过borrowBuffer()赋给writeBuffer

二、RocketMQ消息存储源码分析,rocketmq,rocketmq

从6.4章节中的消息写入流程继续,消息最终会进入mappedFile中处理。

二、RocketMQ消息存储源码分析,rocketmq,rocketmq

经过几个转跳后,进入appendMessagesInner方法中

二、RocketMQ消息存储源码分析,rocketmq,rocketmq

以上就可知如果有堆外内存缓冲区的话,ByteBuffer的来源是不同的。不过这里要注意,如果是堆外内存缓冲区,消息写入到ByteBuffer的话,还只是写入一个临时区域(不像默认模式本身就是mmap映射的内存,直接写入就进入了磁盘和内存的映射),所以,这里还需要一个步骤,就是从临时区域到正式区域。RocketMQ这里使用的是定时任务处理(并且是借用异步刷盘的定时任务来处理)。

这里需要回到CommitLog的构造方法,在CommitLog构造的时候,会选择启动一个定时任务来处理堆外内存

二、RocketMQ消息存储源码分析,rocketmq,rocketmq

二、RocketMQ消息存储源码分析,rocketmq,rocketmq

二、RocketMQ消息存储源码分析,rocketmq,rocketmq

二、RocketMQ消息存储源码分析,rocketmq,rocketmq

二、RocketMQ消息存储源码分析,rocketmq,rocketmq

二、RocketMQ消息存储源码分析,rocketmq,rocketmq

上述跳转比较麻烦,但是记住一个点就是堆外内存的数据写入本质上是分成两个阶段:

一个阶段先写入堆外内存,另外一个阶段通过定时任务再写入文件。

6.5.5.4 堆外内存缓冲的意义

二、RocketMQ消息存储源码分析,rocketmq,rocketmq

从图中可以发现,默认方式,Mmap+PageCache的方式,读写消息都走的是pageCache(MappedByteBuffer类),这样子读写都在pagecache里面不可避免会有锁的问题,在并发的读写操作情况下,会出现缺页中断降低,内存加锁,污染页的回写(脏页面)。

而如果采用堆外缓冲区,DirectByteBuffer(堆外内存)+PageCache的两层架构方式,这样子可以实现读写消息分离,写入消息时候写到的是DirectByteBuffer——堆外内存中,读消息走的是PageCache(MappedByteBuffer类),带来的好处就是,避免了内存操作的很多容易堵的地方,降低了时延,比如说缺页中断降低,内存加锁,污染页的回写。

所以使用堆外缓冲区的方式相对来说会比较好,但是肯定的是,需要消耗一定的内存,如果服务器内存吃紧就不推荐这种模式,同时的话,堆外缓冲区的话也需要配合异步刷盘才能使用(因为写数据分成了两步,同步刷盘延迟就会比较大)。文章来源地址https://www.toymoban.com/news/detail-565832.html

到了这里,关于二、RocketMQ消息存储源码分析的文章就介绍完了。如果您还想了解更多内容,请在右上角搜索TOY模板网以前的文章或继续浏览下面的相关文章,希望大家以后多多支持TOY模板网!

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处: 如若内容造成侵权/违法违规/事实不符,请点击违法举报进行投诉反馈,一经查实,立即删除!

领支付宝红包 赞助服务器费用

相关文章

  • 13.RocketMQ之消息的存储与发送

    分布式队列因为有高可靠性的要求,所以数据要进行持久化存储。 消息生成者发送消息 Broker收到消息,将消息进行持久化,在存储中新增一条记录 返回ACK给生产者 Broker消息给对应的消费者,然后等待消费者返回ACK 如果消息消费者在指定时间内成功返回ack,那么MQ认为消息消

    2024年02月11日
    浏览(32)
  • RocketMQ源码阅读-Broker消息接收

    Broker接收 Producer 发送的消息。 Broker在RocketMQ中也是一个独立的Model,rocketmq-broker。 Broker的核心类为SendMessageProcessor。 同样从单元测试入手,看Broker接收消息的流程。 SendMessageProcessor的单元测试类为org.apache.rocketmq.broker.processor.SendMessageProcessorTest。 包含上面这些方法,其中ini

    2024年01月17日
    浏览(24)
  • [RocketMQ] Broker asyncPutMessage处理消息以及存储的高性能设计措施 (十一)

    asyncPutMessage方法真正的用来存储消息。 1.asyncPutMessage存储普通消息 DefaultMessageStore#asyncPutMessage() checkStoreStatus, checkMessage, checkLmqMessage校验。 CommitLog#asyncPutMessage存储消息, 更新耗时时间和失败次数。 1.1 checkStoreStatus检查存储状态 如果DefaultMessageStore是shutdown状态, 返回SERVICE_NOT_

    2024年02月13日
    浏览(35)
  • RocketMQ 存储优化技术 解析——图解、源码级解析

    🍊 Java学习:Java从入门到精通总结 🍊 深入浅出RocketMQ设计思想:深入浅出RocketMQ设计思想 🍊 绝对不一样的职场干货:大厂最佳实践经验指南 📆 最近更新:2023年1月13日 🍊 个人简介:通信工程本硕 for NJU💪、Java程序员🌕。做过科研paper,发过专利,优秀的程序员不应该只

    2024年02月19日
    浏览(29)
  • 【Alibaba中间件技术系列】「RocketMQ技术专题」RocketMQ消息发送的全部流程和落盘原理分析

    RocketMQ目前在国内应该是比较流行的MQ 了,目前本人也在公司的项目中进行使用和研究,借着这个机会,分析一下RocketMQ 发送一条消息到存储一条消息的过程,这样会对以后大家分析和研究RocketMQ相关的问题有一定的帮助。 分析的总体技术范围发送到存储,本文的主要目的是

    2024年02月10日
    浏览(32)
  • 【深入浅出RocketMQ原理及实战】「消息队列架构分析」帮你梳理RocketMQ或Kafka的选择理由以及二者PK

    前提背景 大家都知道,市面上有许多开源的MQ,例如,RocketMQ、Kafka、RabbitMQ等等,现在Pulsar也开始发光,今天我们谈谈笔者最常用的RocketMQ和Kafka,想必大家早就知道二者之间的特点以及区别,但是在实际场景中,二者的选取有可能会范迷惑,那么今天笔者就带领大家分析一下

    2024年02月19日
    浏览(39)
  • 【深入浅出RocketMQ原理及实战】「底层原理挖掘系列」透彻剖析贯穿RocketMQ的消息消费长轮训机制体系的原理分析

    使用系统控制读取操作的DefaultMQPushConsumer可以自动调用传入的处理方法来处理收到的消息。通过设置各种参数和传入处理消息的函数,使用DefaultMQPushConsumer的主要目的是方便配置和处理消息。在收到消息后,系统会自动保存Offset,并且如果加入了新的DefaultMQPushConsumer,系统会

    2024年02月11日
    浏览(26)
  • RocketMQ源码分析

    RocketMQ 是阿里巴巴集团基于高可用分布式集群技术,自主研发的云正式商用的专业消息中间件,既可为分布式应用系统提供异步解耦和削峰填谷的能力,同时也具备互联网应用所需的海量消息堆积、高吞吐、可靠重试等特性,是阿里巴巴双 11 使用的核心产品。 如果想要了解

    2024年02月13日
    浏览(19)
  • 【深入浅出RocketMQ原理及实战】「底层原理挖掘系列」透彻剖析贯穿RocketMQ的消息顺序消费和并发消费机制体系的原理分析

    首先, DefaultMQPushConsumerImpl 是一个实现了 RocketMQ 的消费者客户端接口的类。该类的主要作用是从 RocketMQ 的 Broker 获取消息并进行消费。 主要可以通过pullMessage方法进行获取对应的操作,如下图所示。 在消费消息时, DefaultMQPushConsumerImpl 会将获取到的消息放入一个 processQueue

    2024年02月11日
    浏览(29)
  • 【RocketMQ】RocketMQ标签、过滤及消息重复消费

    参考文档: 官方文档 Topic 与 Tag 都是业务上用来归类的标识,区别在于 Topic 是一级分类,而 Tag 可以理解为是二级分类。使用 Tag 可以实现对 Topic 中的消息进行过滤。 注: Topic:消息主题,通过 Topic 对不同的业务消息进行分类。 Tag:消息标签,用来进一步区分某个 Topic 下

    2024年02月07日
    浏览(32)

觉得文章有用就打赏一下文章作者

支付宝扫一扫打赏

博客赞助

微信扫一扫打赏

请作者喝杯咖啡吧~博客赞助

支付宝扫一扫领取红包,优惠每天领

二维码1

领取红包

二维码2

领红包