kafka

Apache Pulsar 详解

2022-05-28  本文已影响0人  小波同学

一、Pulsar 介绍

Apache Pulsar 作为 Apache 软件基金会顶级项目,是下一代云原生分布式消息流平台,集消息、存储、轻量化函数计算为一体,采用计算与存储分离架构设计,支持多租户、持久化存储、跨区域复制、具有强一致性、高吞吐、低延迟及高可扩展性等流数据存储特性。

Pulsar 诞生于 2012 年,最初的目的是为在 Yahoo 内部,整合其他消息系统,构建统一逻辑、支撑大集群和跨区域的消息平台。当时的其他消息系统(包括 Kafka),都不能满足 Yahoo 的需求,比如大集群多租户、稳定可靠的 IO 服务质量、百万级 Topic、跨地域复制等,因此 Pulsar 应运而生,并于2016年底开源,现在是Apache软件基金会顶级开源项目。Pulsar在Yahoo的生产环境运行了三年多,助力Yahoo的主要应用,如YahooMail、Yahoo Finance、Yahoo Sports、Flickr、Gemini广告平台和Yahoo分布式键值存储系统Sherpa。

Pulsar 的关键特性如下:

社区:

目前 Apache Pulsar 在 Github 的 star 数量是10K+,共有470+个 contributor。并且正在持续更新,社区的活跃度比较好。

二、什么是云原生

既然说 Pulsar 是下一代云原生分布式消息流平台,那我们得知道什么是云原生吧。

云原生的概念是 2013 年 Matt Stine 提出的,到目前为止,云原生的概念发生了多次变更,目前最新对云原生的定义为:DevOps + 持续交付 + 微服务 + 容器。

而符合云原生架构的应用程序是:采用开源堆栈(k8s + docker)进行容器化,基于微服务架构提高灵活性和可维护性,借助敏捷方法、DevOps 支持持续迭代和运维自动化,利用云平台设施实现弹性伸缩、动态调度、优化资源利用率。

三、核心概念

3.1 Messages(消息)

消息的默认大小为 5 MB,可以通过以下方式配置消息的最大大小。

# The max size of a message (in bytes).
maxMessageSize=5242880
# The max size of the netty frame (in bytes). Any messages received larger than this value are rejected. The default value is 5 MB.
nettyMaxFrameSizeBytes=5253120

3.2 Producers(生产者)

生产者是关联到 topic 的程序,它发布消息到 Pulsar 的 broker 上。

3.2.1 Send modes(发送模式)

producer 可以以同步或者异步的方式发布消息到 broker。

3.2.2 Access mode(访问模式)

你可以为生产者提供不同类型的主题访问模式。

3.2.3 Compression(压缩)

你可以压缩生产者在传输期间发布的消息。Pulsar 目前支持以下类型的压缩:

3.2.4 Batching(批处理)

如果批处理开启,producer 将会累积一批消息,然后通过一次请求发送出去。批处理的大小取决于最大的消息数量及最大的发布延迟。

3.2.5 Chunking(分块)

3.2.5.1 处理一个 producer 和一个订阅 consumer 的分块消息

如下图所示,当生产者向主题发送一批大的分块消息和普通的非分块消息时。 假设生产者发送的消息为 M1,M1 有三个分块 M1-C1,M1-C2 和 M1-C3。 这个 broker 在其管理的 ledger 里面保存所有的三个块消息,然后以相同的顺序分发给消费者(独占/灾备模式)。 消费者将在内存缓存所有的块消息,直到收到所有的消息块。将这些消息合并成为原始的消息 M1,发送给处理进程。

3.2.5.2 处理多个 producer 和一个订阅 consumer 的分块消息

当多个生产者发布块消息到单个主题,这个 Broker 在同一个 Ledger 里面保存来自不同生产者的所有块消息。 如下所示,生产者1发布的消息 M1,M1 由 M1-C1, M1-C2 和 M1-C3 三个块组成。 生产者2发布的消息 M2,M2 由 M2-C1, M2-C2 和 M2-C3 三个块组成。 这些特定消息的所有分块是顺序排列的,但是其在 ledger 里面可能不是连续的。 这种方式会给消费者带来一定的内存负担。因为消费者会为每个大消息在内存开辟一块缓冲区,以便将所有的块消息合并为原始的大消息。

3.3 Consumers(消费者)

消费者通过订阅关联到主题,然后接收消息的程序。

3.3.1 Receive modes(接收模式)

消息可以通过同步或者异步的方式从 broker 接收。

3.3.2 Listeners(监听)

客户端类库提供了它们对于 consumer 的监听实现。举一个 Java 客户端的例子,它提供了 MessageListener 接口。在这个接口中,一旦接受到新的消息,received 方法将被调用。

3.3.3 Acknowledgement(确认)

消费者成功处理了消息,需要发送确认给 broker,以让 broker 丢掉这条消息(否则它将存储着此消息)。

消息的确认可以一个接一个,也可以累积一起。累积确认时,消费者只需要确认最后一条它收到的消息。所有之前(包含此条)的消息,都不会被重新发给那个消费者。

累积消息确认不能用于 shared 订阅模式,因为 shared 订阅为同一个订阅引入了多个消费者。

3.4 Topics(主题)

和其它的发布订阅系统一样,Pulsar 中的 topic 是带有名称的通道,用来从 producer 到 consumer 传输消息。Topic 的名称是符合良好结构的 URL。

{persistent|non-persistent}://tenant/namespace/topic

3.4.1 Partitioned topics(分区主题)

普通主题仅由单个 broker 提供服务,这限制了主题的最大吞吐量。分区主题是由多个 broker 处理的一种特殊类型的主题,因此允许更高的吞吐量。

分区的主题实际上实现为 N 个内部主题,其中 N 是分区的数量。当将消息发布到分区主题时,每个消息都被路由到几个 broker 中的一个。分区在 broker 间的分布由 Pulsar 自动处理。

如上图,Topic1 主题有 5 个分区(P0 到 P4),划分在 3 个 broker 上。因为分区比 broker 多,前两个 broker 分别处理两个分区,而第三个 broker 只处理一个分区(同样,Pulsar 自动处理分区的分布)。

此主题的消息将广播给两个消费者。路由模式决定将每个消息发布到哪个分区,而订阅模式决定将哪些消息发送到哪个消费者。

在大多数情况下,可以分别决定路由和订阅模式。通常,吞吐量问题应该指导分区/路由决策,而订阅决策应该根据应用程序语义进行指导。

就订阅模式的工作方式而言,分区主题和普通主题之间没有区别,因为分区仅决定消息由生产者发布和由消费者处理和确认之间发生了什么。

分区主题需要通过管理 API 显式创建,分区的数量可以在创建主题时指定。

3.4.1.1 Routing modes(路由模式)

当发布消息到分区 topic,你必须要指定路由模式。路由模式决定了每条消息被发布到的分区(其实是内部主题)。

下面是三种默认可用的路由模式:

3.4.1.2 Ordering guarantee(顺序保证)

消息的顺序与路由模式和消息的 key 有关:

Ordering guarantee Description Routing Mode and Key
Per-key-partition(按 key 分区) 具有相同 key 的所有消息将被按顺序放置在同一个分区中。 使用 SinglePartition 或 RoundRobinPartition 模式,Key 由每个消息提供。
Per-producer(按 producer) 来自同一生产者的所有消息将是有序的。 使用 SinglePartition 模式,并且没有为每个消息提供 Key。
3.4.1.3 Hashing scheme(哈希方案)

HashingScheme 是一个 enum,表示在选择要为特定消息使用的分区时可用的标准哈希函数集。

有两种类型的标准哈希函数可用:JavaStringHashMurmur3_32Hash。生产者的默认哈希函数是 JavaStringHash。请注意,当生产者可以来自不同的多语言客户端时,JavaStringHash 是没有用的,在这个用例下,建议使用 Murmur3_32Hash

3.4.2 persistent/Non-persistent topics(持久/非持久主题)

默认情况下, Pulsar 会保存所有没确认的消息到 BookKeeper 中。持久 Topic 的消息在 Broker 重启或者 Consumer 出现问题时保存下来。

除了持久 Topic , Pulsar 也支持非持久 Topic 。这些 Topic 的消息只存在于内存中,不会存储到磁盘。

因为 Broker 不会对消息进行持久化存储,当 Producer 将消息发送到 Broker 时, Broker 可以立即将 ack 返回给 Producer ,所以非持久 Topic 的消息传递会比持久 Topic 的消息传递更快一些。相对的,当 Broker 因为一些原因宕机、重启后,非持久 Topic 的消息都会消失,订阅者将无法收到这些消息。

3.4.3 Dead letter topic(死信主题)

死信主题允许你在用户无法成功消费某些消息时使用新消息。在这种机制中,无法使用的消息存储在单独的主题中,称为死信主题。你可以决定如何处理死信主题中的消息。

下面的例子展示了如何在 Java 客户端中使用默认的死信主题:

Consumer<byte[]> consumer = pulsarClient.newConsumer(Schema.BYTES)
    .topic(topic)
    .subscriptionName("my-subscription")
    .subscriptionType(SubscriptionType.Shared)
    .deadLetterPolicy(DeadLetterPolicy.builder()
          .maxRedeliverCount(maxRedeliveryCount)
          .build())
    .subscribe();

默认的死信主题格式:

<topicname>-<subscriptionname>-DLQ

如果你想指定死信主题的名称,请使用下面的 Java 客户端示例:

Consumer<byte[]> consumer = pulsarClient.newConsumer(Schema.BYTES)
    .topic(topic)
    .subscriptionName("my-subscription")
    .subscriptionType(SubscriptionType.Shared)
    .deadLetterPolicy(DeadLetterPolicy.builder()
          .maxRedeliverCount(maxRedeliveryCount)
          .deadLetterTopic("your-topic-name")
          .build())
    .subscribe();      

死信主题依赖于消息的重新投递。由于确认超时或否认确认,消息将被重新发送。如果要对消息使用否定确认,请确保在确认超时之前对其进行否定确认。
目前,在共享和 Key_Shared 订阅模式下启用了死信主题。

3.4.4 Retry letter topic(重试主题)

对于许多在线业务系统,由于业务逻辑处理中出现异常,消息会被重复消费。若要配置重新消费失败消息的延迟时间,你可以配置生产者将消息发送到业务主题和重试主题,并在消费者上启用自动重试。当在消费者上启用自动重试时,如果消息没有被消费,则消息将存储在重试主题中,因此消费者在指定的延迟时间后将自动接收来自重试主题的失败消息。

默认情况下,不启用自动重试功能。你可以将 enableRetry 设置为 true,以启用消费者的自动重试。

下面来看个如何使用从重试主题来消费消息的示例:

Consumer<byte[]> consumer = pulsarClient.newConsumer(Schema.BYTES)
    .topic(topic)
    .subscriptionName("my-subscription")
    .subscriptionType(SubscriptionType.Shared)
    .enableRetry(true)
    .receiverQueueSize(100)
    .deadLetterPolicy(DeadLetterPolicy.builder()
            .maxRedeliverCount(maxRedeliveryCount)
            .retryLetterTopic("persistent://my-property/my-ns/my-subscription-custom-Retry")
            .build())
    .subscriptionInitialPosition(SubscriptionInitialPosition.Earliest)
    .subscribe();

3.5 Subscriptions(订阅模式)

Pulsar 支持 exclusive(独占)、failover(灾备)、 shared(共享)和 key_shared(key 共享) 四种消息订阅模式,这四种模式的示意图如下:

3.5.1 Exclusive(独占模式)

独占模式是 Pulsar 默认的消息订阅模式,在这种模式下,只能有一个 consumer 消费消息,如果有多于一个 consumer 消费此 topic 则会出错,消费示意图如下:

3.5.2 Failover(灾备模式)

灾备模式下,一个 topic 也是只有单个 consumer 消费一个订阅关系的消息,与独占模式不同之处在于,灾备模式下,每个消费者会被排序,当前面的消费者无法连接上 broker 后,消息会由下一个消费者消费,消费示意图如下:

3.5.3 Shared(共享模式)

共享模式下,消息可被多个 consumer 同时消费,无法保证消息的顺序,并且无法使用 one by one 和 cumulative 的 ack 模式,消息通过 roundrobin 的方式投递到每一个消费者,消费示意图如下:

3.5.4 Key_Shared(Key 共享模式)

Key_Shared 模式是 Shared 模式的一种,不同的是它按 key 对消息做投递,相同的 key 的消息会被投递到同一个 consumer 上,消费示意图如下:

3.6 Message retention and expiry(消息保留和过期)

默认策略

两个特性

注:所有消息保留和过期在 namespace 层面管理。

3.7 Message deduplication(消息去重)

实现消息去重的一种方式是确保消息仅生成一次,即生产者幂等。这种方式的缺点是把消息去重的工作交由应用去做。

在 Pulsar 中, Broker 支持配置开启消息去重,用户不需要为了消息去重去调整 Producer 的代码。启用消息去重后,即使一条消息被多次发送到 Topic 上,这条消息也只会被持久化到磁盘一次。

如下图,未开启消息去重时, Producer 发送消息 1 到 Topic 后, Broker 会把消息 1 持久化到 BookKeeper ,当 Producer 又发送消息 1 时, Broker 会把消息 1 再一次持久化到 BookKeeper 。开启消息去重后,当 Producer 再次发送消息 1 时, Broker 不会把消息 1 再一次持久化到磁盘。

3.7.1 去重原理

Producer 对每一个发送的消息,都会采用递增的方式生成一个唯一的 sequenceID,这个消息会放在 message 的元数据中传递给 Broker 。同时, Broker 也会维护一个 PendingMessage 队列,当 Broker 返回发送成功 ack 后, Producer 会将 PendingMessage 队列中的对应的 Sequence ID 删除,表示 Producer 任务这个消息生产成功。Broker 会记录针对每个 Producer 接收到的最大 Sequence ID 和已经处理完的最大 Sequence ID。

当 Broker 开启消息去重后, Broker 会对每个消息请求进行是否去重的判断。收到的最新的 Sequence ID 是否大于 Broker 端记录的两个维度的最大 Sequence ID,如果大于则不重复,如果小于或等于则消息重复。消息重复时, Broker 端会直接返回 ack,不会继续走后续的存储处理流程。

3.8 Delayed message delivery(消息延迟传递)

延时消息功能允许 Consumer 能够在消息发送到 Topic 后过一段时间才能消费到这条消息。在这种机制中,消息在发布到 Broker 后,会被存储在 BookKeeper 中,当到消息特定的延迟时间时,消息就会传递给 Consumer 。

下图为消息延迟传递的机制。Broker 在存储延迟消息的时候不会进行特殊的处理。当 Consumer 消费消息的时候,如果这条消息设置了延迟时间,则会把这条消息加入 DelayedDeliveryTracker 中,当到了指定的发送时间时,DelayedDeliveryTracker 才会把这条消息推送给消费者。

注:延迟消息传递仅在共享订阅模式下有效。在独占和故障转移订阅模式下,将立即分派延迟的消息。

3.8.1 示例

# Whether to enable the delayed delivery for messages.
# If disabled, messages are immediately delivered and there is no tracking overhead.
delayedDeliveryEnabled=true

# Control the ticking time for the retry of delayed message delivery,
# affecting the accuracy of the delivery time compared to the scheduled time.
# Default is 1 second.
delayedDeliveryTickTimeMillis=1000
// message to be delivered at the configured delay interval
producer.newMessage().deliverAfter(3L, TimeUnit.Minute).value("Hello Pulsar!").send();

3.8.2 消息延迟传递原理

在 Pulsar 中,可以通过两种方式实现延迟投递。分别为 deliverAfter 和 deliverAt。

deliverAfter 可以指定具体的延迟时间戳,deliverAt 可以指定消息在多长时间后消费。两种方式本质时一样的,deliverAt 方式下,客户端会计算出具体的延迟时间戳发送给 Broker 。

DelayedDeliveryTracker 会记录所有需要延迟投递的消息的 index 。index 由 Timestamp、 Ledger ID、 Entry ID 三部分组成,其中 Ledger ID 和 Entry ID 用于定位该消息,Timestamp 除了记录需要投递的时间,还用于延迟优先级队列排序。DelayedDeliveryTracker 会根据延迟时间对消息进行排序,延迟时间最短的放在前面。当 Consumer 在消费时,如果有到期的消息需要消费,则根据 DelayedDeliveryTracker index 的 Ledger ID、 Entry ID 找到对应的消息进行消费。如下图, Producer 依次投递 m1、m2、m3、m4、m5 这五条消息,m2 没有设置延迟时间,所以会被 Consumer 直接消费。m1、m3、m4、m5 在 DelayedDeliveryTracker 会根据延迟时间进行排序,并在到达延迟时间时,依次被 Consumer 进行消费。

3.9 多租户模式

Pulsar 的云原生架构天然支持多租户,每个租户下还支持多 Namespace(命名空间),非常适合做共享大集群,方便维护。此外,Pulsar 天然支持租户之间资源的逻辑隔离,只要用户的运营管控后台和监控足够强大,便可以做到动态隔离大流量租户,防止互相干扰,还能实现大集群资源的充分利用。

Tenant(租户)和 Namespace(命名空间)是 Pulsar 支持多租户的两个核心概念。
在租户级别,Pulsar 为特定的租户预留合适的存储空间、应用授权和认证机制。
在命名空间级别,Pulsar 有一系列的配置策略(Policy),包括存储配额、流控、消息过期策略和命名空间之间的隔离策略。

Pulsar 的多租户性质主要体现在 Topic 的 URL 中,结构如下:

persistent://tenant/namespace/topic

租户、命名空间、topic 更直观的关系可以看下图:


3.10 统一消息模型

Pulsar 做了队列模型与流模型的统一,在 Topic 级别只需保存一份数据,同一份数据可多次消费。以流式、队列等方式计算不同的订阅模型,大大的提升了灵活度。

同时 Pulsar 通过事务采用 Exactly-Once(刚好一次)的语义,在进行消息传输过程中,可以确保数据不丢不重。

3.11 Segmented Streams(分片流)

3.12 Geo Replication(跨地域复制)

Pulsar 中的跨地域复制是将 Pulsar 中持久化的消息在多个集群间备份。
在 Pulsar 2.4.0 中新增了复制订阅模式(Replicated-subscriptions),在某个集群失效情况下,该功能可以在其他集群恢复消费者的消费状态, 从而达到热备模式下消息服务的高可用。

在这个图中,每当 P1、P2 和 P3 生产者分别将消息发布到 Cluster-A、Cluster-B 和 Cluster-C 上的 T1 主题时,这些消息就会立即跨集群复制。一旦消息被复制,C1 和 C2 消费者就可以从他们各自的集群中消费这些消息。

没有跨地域复制,C1 和 C2 消费者就不能使用 P3 生产者发布的消息。

四、云原生架构

4.1 Pulsar 集群架构

单个 Pulsar 集群由以下三部分组成:

Pulsar 分理出 Broker 与 Bookie 两层架构,Broker 为无状态服务,用于发布和消费消息,而 BookKeeper 专注于存储。Pulsar 存储是分片的,这种架构可以避免扩容时受限制,实现数据的独立扩展和快速恢复。

4.2 Brokers

Pulsar 的 broker 是一个无状态组件,主要负责运行另外的两个组件:

出于性能考虑,消息通常从 Managed Ledger 缓存中分派出去,除非积压超过缓存大小。如果积压的消息对于缓存来说太大了,则 Broker 将开始从 BookKeeper 那里读取 Entries(Entry 同样是 BookKeeper 中的概念,相当于一条记录)。

最后,为了支持全局 Topic 异地复制,Broker 会控制 Replicators 追踪本地发布的条目,并把这些条目用Java客户端重新发布到其他区域。

4.3 ZooKeeper 元数据存储

Pulsar 使用 Apache ZooKeeper 进行元数据存储、集群配置和协调。

4.4 BookKeeper 持久化存储

Apache Pulsar 为应用程序提供有保证的信息传递,如果消息成功到达 broker,就认为其预期到达了目的地。

为了提供这种保证,未确认送达的消息需要持久化存储直到它们被确认送达。这种消息传递模式通常称为持久消息传递,在 Pulsar 内部,所有消息都被保存并同步 N 份,例如,2 个服务器保存四份,每个服务器上面都有镜像的 RAID 存储。

Pulsar 用 Apache BookKeeper 作为持久化存储。BookKeeper 是一个分布式的预写日志(WAL)系统,有如下几个特性特别适合 Pulsar 的应用场景:

BookKeeper是一个可横向扩展的、错误容忍的、低延迟的分布式存储服务,BookKeeper中最基本的单位是记录,实际上就一个字节数组,而记录的数组称之为ledger,BK会将记录复制到多个bookies,存储ledger的节点叫做bookies,从而获得更高的可用性和错误容忍性。从设计阶段BK就考虑到了各种故障,Bookies可以宕机、丢数据、脏数据,但是只要整个集群中有足够的Bookies服务的行为就是正确的。

在Pulsar中,每个分区topic是由若干个ledger组成的,而ledger是一个append-only的数据结构,只允许单个writer,ledger中的每条记录会被复制到多个bookies中,一个ledger被关闭后(例如broker宕机了或者达到了一定的大小)就只支持读取,而当ledger中的数据不再需要的时候(例如所有的消费者都已经消费了这个ledger中的消息)就会被删除。

Bookkeeper的主要优势在于它可以保证在出现故障时在ledger的读取一致性。因为ledger只能被同时被一个writer写入,因为没有竞争,BK可以更高效的实现写入。在Broker宕机后重启时,Plusar会启动一个恢复的操作,从ZK中读取最后一个写入的Ledger并读取最后一个已提交的记录,然后所有的消费者也都被保证能看到同样的内容。

4.4.1 brokers 与 bookies 交互

下图展示了 brokers 和 bookies 是如何交互的:

相比 Kafka、RocketMQ 等 MQ,Pulsar 基于 BookKeeper 的存储、计算分离架构,使得 Pulsar 的消息存储可以独立于 Broker 而扩展。

4.4.2 Ledgers

Ledger 是一个只追加的数据结构,并且只有一个写入器,这个写入器负责多个 BookKeeper 存储节点(就是 Bookies)的写入。 Ledger 的条目会被复制到多个 bookies。 Ledgers 本身有着非常简单的语义:

4.5 Pulsar 代理

Pulsar 客户端和 Pulsar 集群交互的一种方式就是直连 Pulsar brokers 。 然而,在某些情况下,这种直连既不可行也不可取,因为客户端并不知道 broker 的地址。 例如在云环境或者 Kubernetes 以及其他类似的系统上面运行 Pulsar,直连 brokers 就基本上不可能了。

Pulsar proxy 为这个问题提供了一个解决方案,为所有的 broker 提供了一个网关,如果选择运行了Pulsar Proxy,所有的客户都会通过这个代理而不是直接与 brokers 通信。

4.6 Service discovery(服务发现)

连接到 Pulsar brokers 的客户端需要能够使用单个 URL 与整个 Pulsar 实例通信。

你可以使用自己的服务发现系统。如果你使用自己的系统,只有一个要求:当客户端端点执行 HTTP 请求,比如 http://pulsar.us-west.example.com:8080,客户端需要被重定向到一些活跃在集群所需的 broker,无论通过 DNS、HTTP 或 IP 重定向或其他手段。

五、Pulsar 相关组件

5.1 层级存储

分层存储的卸载机制就充分利用了这种面向分片式架构(segment oriented architecture)。 当需要开始卸载数据时,消息日志中的分片就依次被同步至分层存储中, 直到消息日志中所有的分片(除了当前分片之外)都已被写入分层存储后。

默认情况下写入到 BookKeeper 的数据会复制三个物理机副本。 然而,一旦分片被封存在 BookKeeper 中后,该分片就不可更改并且可以复制到归档存储中去。 长期存储可以达到节省存储费用的目的。通过使用 Reed-Solomon error correction 机制,还可减少物理备份数量。

5.2 Pulsar IO(Connector)连接器

5.3 Pulsar Functions(轻量级计算框架)

六、Pulsar与Kafka对比

模型概念

消息消费模式

消息确认(ack)

消息保留

Apache Kafka和Apache Pulsar都有类似的消息概念。 客户端通过主题与消息系统进行交互。每个主题都可以分为多个分区。 然而,Apache Pulsar和Apache Kafka之间的根本区别在于Apache Kafka是以分区为存储中心,而ApachePulsar是以Segment为存储中心。

对比总结:

Apache Pulsar将高性能的流(Apache Kafka所追求的)和灵活的传统队列(RabbitMQ所追求的)结合到一个统一的消息模型和API中。 Pulsar使用统一的API为用户提供一个支持流和队列的系统,且具有同样的高性能。

性能对比:

Pulsar 表现最出色的就是性能,Pulsar 的速度比 Kafka 快得多,美国德克萨斯州一家名为GigaOm(https://gigaom.com/) 的技术研究和分析公司对 Kafka 和 Pulsar 的性能做了比较,并证实了这一点。

Kafka目前存在的痛点:

参考:
https://blog.csdn.net/q1472750149/article/details/121857246

https://blog.csdn.net/yamaxifeng_132/article/details/117366174

https://blog.csdn.net/yy8623977/article/details/123490513

https://blog.csdn.net/Sharon0408/article/details/122542030

https://blog.csdn.net/riemann_/article/details/122765847

上一篇下一篇

猜你喜欢

热点阅读