记录些Spring+题集(49)

线程池任务,如何设置优先级?

线程池的基本原理

一般而言,大家都使用线程池并发执行、并发调度任务,通过池化的架构去节省线程创建和销毁带来的性能损耗。

默认情况下,有了线程池之后,大家都通过提交任务的方式, 提交任务到线程池,由线程池去调度。

提交到线程池的任务,按照线程池的调度规则进行调度。线程池的调度规则,大致如下:

图片

如何线程池的核心线程都很忙,任务就需要排队了,进入线程池的内部工作队列,大致如下图所示:

图片

工作线程执行完手上的任务后,会在一个无限循环中,反复从内部工作队列(如LinkedBlockingQueue )获取任务来执行。

对于有优先级要求的场景下,怎么设置线程池?

普通的线程池, 任务之间是没有优先级特权的, 可以理解为 先进先出 的公平调度模式。

有的的时候, 任务之间是有 优先级特权的, 不是按照 先进先出 调度, 而是需要按照 优先级进行调度。

所以,如果不同的任务之间,存在一些优先级的变化,咋整呢?

办法很简单,就是替换掉 线程池里边的工作队列,使用 优先级的无界阻塞队列 ,去管理 异步任务。

首先,来看看几种典型的工作队列

  • ArrayBlockingQueue:使用数组实现的有界阻塞队列,特性先进先出

  • LinkedBlockingQueue:使用链表实现的阻塞队列,特性先进先出,可以设置其容量,默认为Interger.MAX_VALUE,特性先进先出

  • PriorityBlockingQueue:使用平衡二叉树堆,实现的具有优先级的无界阻塞队列

  • DelayQueue:无界阻塞延迟队列,队列中每个元素均有过期时间,当从队列获取元素时,只有过期元素才会出队列。队列头元素是最块要过期的元素。

  • SynchronousQueue:一个不存储元素的阻塞队列,每个插入操作,必须等到另一个线程调用移除操作,否则插入操作一直处于阻塞状态

使用 优先级的无界阻塞队列 PriorityBlockingQueue 替代 没有优先级的队列 ArrayBlockingQueue 或者 LinkedBlockingQueue。

 如果是普通的任务,没有优先级的话, 一般情况,建议大家参考 rocketmq的源码, 使用 有界的 LinkedBlockingQueue 作为 任务队列。

rocketmq的源码, 用到大量的线程池,具体如下图:

图片

这些任务队列,用的都是有界的 LinkedBlockingQueue ,具体如下图:

图片

如果任务有优先级, 就需要引入任务队列并进行管理了。

这时候,就需要 使用 优先级的无界阻塞队列 PriorityBlockingQueue ,下面是一个例子。

优先级任务线程池的设计与实操

实现一个 优先级任务线程池,有2个关键点:

  • 使用PriorityBlockingQueue  作为 线程池的任务队列。

  • 提交的任务 具备 排序能力。

使用 PriorityBlockingQueue 作为 线程池的任务队列

还是基于ThreadPoolExecutor 进行线程池的构造, 我们知道, ThreadPoolExecutor的构造函数有一个workQueue参数,这里可以传入 PriorityBlockingQueue   优先级队列。

图片

在 buildWorkQueue() 方法里边,构造一个 PriorityBlockingQueue<E>,它的构造函数可以传入一个比较器Comparator,能够满足要求。

图片

这里主要有两点:

  • 替换线程池默认的阻塞队列为 PriorityBlockingQueue,响应的传入的线程类需要实现 Comparable<T> 才能进行比较。

  • PriorityBlockingQueue 的数据结构决定了,优先级相同的任务无法保证 FIFO,需要自己控制顺序。

提交的任务 具备 排序能力

ThreadPoolExecutor的submit、invokeXxx、execute方法入参都是Runnable、Callable,均不具备可排序的属性。

我们可以弄一个实现类 PriorityTask,加一些额外的属性,让它们具备排序能力。

图片

对自定义的优先级线程池,进行测试

提交三种不同优先级的任务,进行测试

  • 优先级高的后面提交

  • 优先级低的前面提交

图片

优先级高的前面执行

图片

优先级低的后面执行

图片

PriorityBlockingQueue 队列的问题

主要是,PriorityBlockingQueue是无界的,它的offer方法永远返回true。

这样,会带来两个问题:

第一,OOM风险

第二,最大线程数 失效

第三,拒绝策略 失效

怎么解决呢?

方法一:可以继承PriorityBlockingQueue , 重写一下这个类的offer方法,如果元素超过指定数量直接返回false,否则调用原来逻辑。

方式二:扩展线程池的submit、invokeXxx、execute方法,在里边进行 任务数量的 统计、检查、限制。

优先建议大家使用 方式一。

说说 PriorityQueue 的堆结构

面试进行到这里,很容易出现 PriorityQueue的堆结构的问题

因为, PriorityBlockingQueue  是 PriorityQueue 的阻塞版本

PriorityQueue 是 Java 提供的堆实现

PriorityQueue在默认情况下是一个最小堆,如果使用最大堆调用构造函数就需要传入 Comparator 改变比较排序的规则。

// 构造小顶堆
PriorityQueue<Integer> priorityQueue = new PriorityQueue<>((o1, o2) -> o1 - o2);

// 构造大顶堆
PriorityQueue<Integer> priorityQueue = new PriorityQueue<>((o1, o2) -> o2 - o1);

PriorityQueue 实现了接口 Queue,它常用的函数如表所示

这就是为什么堆被称为优先级队列的原因

操作抛异常不抛异常
插入新的元素add(e)offer(e)
删除堆顶元素removepoll
返回堆顶元素elementpeek

虽然Java中的PriorityQueue实现了Queue接口,但它并不是一个队列,也不是按照“先入先出”的顺序删除元素的。

PriorityQueue的删除顺序与元素添加的顺序无关。PriorityQueue是 按照最小堆 的 次序,进行元素操作的。

所以,PriorityQueue是一个堆,每次调用函数remove或poll都将删除位于堆顶的元素。

同理,PriorityQueue的函数element和peek都返回位于堆顶的元素,即根据堆的类型返回值最大或最小的元素,这与元素添加的顺序无关。

来到算法的基础知识,什么是堆?

堆(也称为优先级队列)是一种特殊的树形数据结构。

根据根节点的值与子节点的值的大小关系,堆又分为最大堆和最小堆。

  • 在最大堆中,每个节点的值总是大于或等于其任意子节点的值,因此最大堆的根节点就是整个堆的最大值

  • 在最小堆中,每个节点的值总是小于或等于其任意子节点的值,因此最小堆的根节点就是整个堆的最小值

例如,图(a)所示是一个最大堆,图(b)所示是一个最小堆。

图片

堆通常用完全二叉树实现。在完全二叉树中,除最低层之外,其他层都被节点填满,最低层尽可能从左到右插入节点。上图中的两个堆都是完全二叉树。

完全二叉树又可以用数组实现,因此堆也可以用数组实现。如果从堆的根节点开始从上到下按层遍历,并且每层从左到右将每个节点按照 0、1、2 等的顺序编号,将编号为 0 的节点放入数组中下标为 0 的位置,编号为1的节点放入数组中下标为1的位置,以此类推就可以将堆的所有节点都添加到数组中。上图(a)中的堆可以用数组表示成下图(a)所示的形式,而上图(b)中的堆可以用数组表示成下图(b)所示的形式。

图片

如果数组中的一个元素的下标为 i,那么它在堆中对应节点的父节点在数组中的下标为 (i - 1) / 2,而它的左右子节点在数组中的下标分别为 2 * i + 1和 2 * i + 2

在堆中添加元素

为了在最大堆中添加新的节点,有以下三个步骤:

  1. 先从上到下、从左到右找出第 1 个空缺的位置,并将新节点添加到该空缺位置

  2. 如果新节点的值比它的父节点的值大,那么交换它和它的父节点

  3. 重复这个交换过程,直到新节点的值小于或等于它的父节点,或者它已经到达堆的顶部位置。

在最小堆中添加新节点的过程与此类似,唯一的不同是要确保新节点的值要大于或等于它的父节点。

所以,堆的添加操作是一个自下而上的操作。

举个例子,如果上图(a)的最大堆中添加一个新的元素 95:

  • 由于节点 60 的右子节点是第1个空缺的位置,因此创建一个新的节点95并使之成为节点60的右子节点

  • 此时新节点95的值大于它的父节点60的值,这违背了最大堆的定义,于是交换它和它的父节点

  • 由于新节点95的值仍然大于它的父节点90的值,因此再交换新节点95和它的父节点90。此时堆已经满足最大堆的定义。

整体过程如下图:

图片

堆的插入操作可能需要交换节点,以便把节点放到合适的位置,交换的次数最多为二叉树的深度,因此如果堆中有 n 个节点,那么它的插入操作的时间复杂度是 O(logn)

在堆中删除元素

通常只删除位于堆顶部的元素

以删除最大堆的顶部节点为例:

  1. 将堆最低层最右边的节点移到堆的顶部

  2. 如果此时它的左子节点或右子节点的值大于它,那么它和左右子节点中值较大的节点交换

  3. 如果交换之后节点的值仍然小于它的子节点的值,则再次交换,直到该节点的值大于或等于它的左右子节点的值,或者到达最低层为止

删除最小堆的顶部节点的过程与此类似,唯一的不同是要确保节点的值要小于它的左右子节点的值。

所以,堆的删除操作是一个自上而下的操作。

举个例子,删除上图(a)中最大堆的顶部元素之后:

  1. 将位于最低层最右边的节点60移到最大堆的顶部,如下图(c)所示

  2. 此时节点60比它的左子节点80和右子节点90的值都小,因此将它和值较大的右子节点90交换,交换之后的堆如图(d)所示

  3. 此时节点60大于它的左子节点30,满足最大堆的定义

图片

堆的删除操作可能需要交换节点,以便把节点放到合适的位置,交换的次数最多为二叉树的深度,因此如果堆中有 n 个节点,那么它的删除操作的时间复杂度是 O(logn)

MySQL主从复制不一致,主要原因是?

一、什么原因会造成MySQL主从复制不一致

导致主从不一致的原因主要有:

1、人为原因导致从库与主库数据不一致(从库写入)

2、主从复制过程中,主库异常宕机

3、设置了ignore/do/rewrite等replication等规则

4、binlog非row格式

5、异步复制本身不保证,半同步存在提交读的问题,增强半同步起来比较完美。但对于异常重启(Replication Crash Safe),从库写数据(GTID)的防范,还需要策略来保证。

6、从库中断很久,binlog应用不连续,监控并及时修复主从

7、从库启用了诸如存储过程,从库禁用存储过程等

8、数据库大小版本/分支版本导致数据不一致?,主从版本统一

9、备份的时候没有指定参数 例如mysqldump --master-data=2 等

10、主从sql_mode 不一致

11、一主二从环境,二从的server id一致

12、MySQL自增列 主从不一致

13、主从信息保存在文件里面,文件本身的刷新是非事务的,导致从库重启后开始执行点大于实际执行点

14、采用5.6的after_commit方式半同步,主库当机可能会引起主从不一致,要看binlog是否传到了从库

15、启用增强半同步了(5.7的after_sync方式),但是从库延迟超时自动切换成异步复制

二、如何预防及解决?

预防和解决的方案有:

1、master:innodb_flush_log_at_trx_commit=1 & sync_binlog=1

2、slave:master_info_repository = “TABLE”&relay_log_info_repository = “TABLE”&relay_log_recovery=1

3、设置从库库为只读模式

4、可以使用5.7增强半同步避免数据丢失等

5、binlog row格式

6、必须引定期的数据校验机制

7、当使用延迟复制的时候,此时主从数据也是不一致的(计划内),但在切换中,不要把延迟从提升为主库

8、mha在主从切换的过程中,因主库系统宕机,可能造成主从不一致(mha本身机制导致这个问题)

三、MySQL 5.7的复制架构,在有异步复制、半同步、增强半同步、MGR等的生产中,该如何选择?

(一)生产环境中

几种复制场景都有存在的价值。下面分别描述一下:

1.从成熟度上来选择,推荐:异步复制(GTID+ROW)

2.从数据安全及更高性能上选择:增强半同步 (在这个结构下也可以把innodb_flush_log_trx_commit调整到非1, 从而获得更好的性能)

3.对于主从切换控制觉的不好管理,又对数据一致性要求特别高的场景,可以使用MGR

(二)理由

1.异步复制,相对来讲非常成熟,对于环境运维也比较容易上手

2.增强半同步复制,可以安全的保证数据传输到从库上,对于单节点的配置上不用要求太严格,特别从库上也可以更宽松一点,而且在一致性和性能有较高的提升,但对运维上有一定的要求

3.MGR组复制。相对增强半同步复制,MGR更能确保数据的一致性,事务的提交,必须经过组内大多数节点(n/2+1)决议并通过,才能得以提交。MGR架构对运维难度要更高,不过它也更完美

总的来讲,从技术实现上来看:MGR> 增强半同步>异步复制。

Eureka怎么AP?Nacos既CP又AP,怎么实现的?

注册中心集群的数据一致性问题

服务注册中心必然是高可用的,这意味着它不能是单点的,而必须是一个注册中心集群。

接下来的问题是:

在一个微服务注册中心集群中,如何确保微服务 Provider 提供者的注册信息或元数据信息保持一致性?

首先,回顾一下CAP定理。

CAP定理

分布式系统中有一个重要理论:CAP。

  1. C:一致性(Consistency)

    在分布式系统中,数据会在多个副本中存在,一些问题可能导致在写入数据时,部分副本成功,部分副本失败,从而导致数据不一致。一致性 C 的要求是,数据更新操作成功后,多个副本的数据必须保持一致。

  2. A:可用性(Availability)

    无论何时,客户端对集群进行读写操作,请求都应能得到正常的响应。

  3. P:分区容错性(Partition Tolerance)

    当发生通信故障,集群被分割成多个无法通信的分区时,集群仍应能正常运行。

图片

微服务注册中心是AP还是CP

回到微服务注册中心的场景。

微服务注册中心的中间件非常多,比如传统的分布式协调组件 Zookeeper, 比如 传统的微服务注册中心 Eureka,比如 阿里的微服务注册中心Nacos,还有 google的 分布式协调组件 etcd,等等。

微服务注册中心是AP还是CP?

首先要明确的是 Eureka 是AP 高并发类型,不是CP强一致类型,而是弱数据一致性的。

ZooKeeper 是CP类型的注册中心,就是尽可能的保证强数据一致性,ZooKeeper首先牺牲A,另外,在某些情况下可以牺牲可用性P。

所以, Eureka 与ZooKeeper 完全是两个极端。

Eureka 则选择了 A,ZooKeeper 优先选择了 C。

Eureka 具有高可用性,在任何时候,服务消费者都能正常获取服务列表,但不保证数据的强一致性,消费者可能会拿到过期的服务列表

Nacos 则做了兼容,既能支持AP模式,也能支持CP模式。

Spring Cloud Alibaba Nacos 在 1.0.0 正式支持 AP 和 CP 两种一致性协议,其中,CP 一致性协议的实现是基于简化的 Raft 协议的强一致性实现。

Eureka 的数据同步方式

多个副本之间的 复制方式

首先看看 数据同步的方式,或者说多个副本的 复制方式。通常,分布式系统中的数据在多个副本间的复制方式,大体上可以分为以下两种:

  • 主从复制

这种是 Master-Slave 模式,存在一个 master 主副本,其他则是 Slave 从副本,所有的写操作都会被提交到主副本,然后由主副本更新到其他从副本。

因此,写压力都会聚集在主副本上,这成为了系统的瓶颈,而从副本则可以分担读请求。

  • 对等复制

这种是 Peer to Peer 模式,副本之间不存在主从之分,任何一个副本都可以接收写操作,然后各个副本之间会相互进行数据更新。

Peer to Peer 对等复制模式的优势:

任何一个副本都可以接收写请求,不存在写压力的瓶颈,但是在各个副本间进行数据同步时可能会出现数据冲突。

Eureka 就是采用了 Peer to Peer 模式。

Eureka  的Peer to Peer 模式同步过程

在 Eureka Server 启动之后,它会利用本地的 Eureka Client 向其他 Eureka Server 节点中的一个节点发起请求,以获取注册的服务信息,并将这些信息复制到其他 peer 节点。

每当 Eureka Server 的自身信息发生变化,例如微服务的客户端向它发起注册、续约或注销请求时,它会将最新的信息推送给其他 Eureka Server,以保持数据的同步性。

图片

循环复制问题

当然,这里有一个问题:循环复制问题。

具体来说,如果自身的信息变更是由另一个 Eureka Server 同步过来的,那么如果再将这些信息同步回去,就会出现数据同步的死循环。

图片

在 Eureka Server 执行复制操作时,它会使用一个名为 HEADER_REPLICATION 的 http header 来区分复制操作。

如果一个请求携带了 HEADER_REPLICATION 这个 header,那么这个请求就不再是普通应用实例微服务的客户端的正常请求,而是来自其他 server 的复制请求。这样,当 Eureka Server 收到复制请求时,它就不会再执行复制操作,从而避免了死循环。

还有一个问题,就是数据冲突。

比如 server A 向 server B 发起同步请求,如果 A 的数据比 B 的还旧,那么 B 不可能接受 A 的数据。在这种情况下,B 如何知道 A 的数据是旧的呢?这时 A 又应该怎么办呢?

数据的新旧通常是通过版本号来定义的,Eureka 使用 lastDirtyTimestamp 这个类似版本号的属性来实现。

lastDirtyTimestamp 是注册中心中服务实例的一个属性,它表示此服务实例最近一次变更时间。

图片

节点间的复制,可能会出错,如何进行错误的检测和弥补呢?

此外,Eureka 集群中,还有一个重要的机制:hearbeat 心跳,即续约操作,用于完成数据的最终修复。由于节点间复制可能出现错误,我们可以通过心 beat 机制来发现并修复这些错误。

总结一下,Eureka 的数据同步方式

  • Eureka 使用 Peer to Peer 模式进行数据复制。

  • Eureka 通过 http header就是 HEADER_REPLICATION  解决循环复制问题。

  • Eureka 通过 lastDirtyTimestamp 解决复制冲突。

  • Eureka 通过心跳机制实现数据修复。

Nacos 满足AP,又满足CP

与Eureka 、Zookeeper集群不同Nacos 既能支持AP,又能支持 CP。

Nacos 支持 CP+AP 模式,这意味着 Nacos 可以根据配置识别为 CP 模式或 AP 模式,默认情况下为 AP 模式。

  • 如果注册Nacos的client节点注册时ephemeral=true,那么Nacos集群对这个client节点的效果就是AP,采用distro协议实现;

  • 而注册Nacos的client节点注册时ephemeral=false,那么Nacos集群对这个节点的效果就是CP的,采用raft协议实现。

根据client注册时的属性,AP,CP同时混合存在,只是对不同的client节点效果不同。

因此,Nacos 能够很好地满足不同场景的业务需求。

快速了解 Distro 协议

Distro 协议是 Nacos 自主研发的一种 AP 分布式协议,专为临时实例设计,确保在部分 Nacos 节点宕机时,整个临时实例仍可正常运行。

作为一款具有状态的中间件应用的内置协议,Distro 确保了各 Nacos 节点在处理大量注册请求时的统一协调和存储。

Distro 协议 与Eureka Peer to Peer 模式同步过程, 大致是类似的。

Distro 协议的同步过程,大致如下:

  • 每个节点是平等的都可以处理写请求,同时将新数据同步至其他节点。

  • 每个节点只负责部分数据,定时发送自己负责数据的校验值,到其他节点来保持数据⼀致性。

  • 每个节点独立处理读请求,并及时从本地发出响应。

接下来的几节将通过不同的场景介绍 Distro 协议的工作原理。

Distro 节点新加入集群场景

新加入的 Distro 节点,会进行全量数据拉取。

具体操作是依次访问所有 Distro 节点,通过向其他机器发送请求,来拉取全量数据

图片

在完成全量拉取操作后,Nacos 的每台机器都维护了当前所有注册的非持久化实例数据。

心跳场景

在 Distro 集群启动后,各台机器之间会定期发送心跳。

心跳信息主要包括各机器上的所有数据的元信息(使用元信息是为了确保网络中数据传输量维持在较低水平)。这种数据校验以心跳形式进行,即每台机器在固定时间间隔内向其他机器发起一次数据校验请求。

图片

如果在数据校验过程中,某台机器发现其他机器上的数据与本地数据不一致,会发起一次全量拉取请求,将数据补全。

写操作场景

对于⼀个已经启动完成的 Distro 集群,在⼀次客户端发起写操作的流程中,当注册非持久化的实例的写请求打到某台 Nacos 服务器时,Distro 集群处理的流程图如下。

图片

整个步骤包括几个部分(图中从上到下顺序):

  • 前置的 Filter 拦截请求,并根据请求中包含的 IP 和 port 信息计算其所属的 Distro 责任节点,并将该请求转发到所属的 Distro 责任节点上。

  • 责任节点上的 Controller 对写请求进行解析。

  • Distro 协议定期执行 Sync 任务,将本机所负责的所有实例信息同步到其他节点上。

读操作场景

由于每台机器上都存储了全量数据,因此在每次读操作中,Distro 机器会直接从本地获取数据,实现快速响应。

图片

这种机制确保了 Distro 协议可以作为 AP 协议,对读操作进行及时响应。

  • 在网络分区状况下,所有读操作仍可正常返回结果;

  • 当网络恢复时,各 Distro 节点会将各数据片段进行合并恢复。

总结一下,Distro 的数据同步

Distro 协议是 Nacos 针对临时实例数据开发的⼀致性协议。

数据存储在缓存中,并在启动时进行全量数据同步,定期执行数据校验

遵循 Distro 协议的设计理念,每个 Distro 节点均能接收读写请求。Distro 协议的请求场景主要分为以下三种情况:

  1. 当该节点接收到属于该节点负责的实例的写请求时,直接写入。

  2. 当该节点接收到不属于该节点负责的实例的写请求时,将在集群内部路由,转发给对应的节点,从而完成读写。

  3. 当该节点接收到任何读请求时,都直接在本机查询并返回(因为所有实例都被同步到了每台机器上)。

作为 Nacos 的内置临时实例一致性协议,Distro 协议确保了在分布式环境中,每个节点上的服务信息状态能够及时通知其他节点,支持数十万量级服务实例的存储和一致性维护。

快速了解Raft协议

Spring Cloud Alibaba Nacos 在 1.0.0 正式支持 AP 和 CP 两种一致性协议,其中的CP一致性协议实现,是基于简化的 Raft 的 CP 一致性。

Raft 适用于一个管理日志一致性的协议,相比于 Paxos 协议, Raft 更易于理解和去实现它。

为了提高理解性,Raft 将一致性算法分为了几个部分,包括领导选取(leader selection)、日志复制(log replication)、安全(safety),并且使用了更强的一致性来减少了必须需要考虑的状态。

相比Paxos,Raft算法理解起来更加直观。

Raft算法将Server划分为3种状态,或者也可以称作角色:

  • Leader:负责Client交互和log复制,同一时刻系统中最多存在1个。

  • Follower:被动响应请求RPC,从不主动发起请求RPC。

  • Candidate:一种临时的角色,只存在于leader的选举阶段,某个节点想要变成leader,那么就发起投票请求,同时自己变成candidate。如果选举成功,则变为candidate,否则退回为follower

状态或者说角色的流转如下:

图片

在Raft中,问题被分解为:领导选取、日志复制、安全和成员变化。

通过复制日志来实现状态机的复制:

日志:每台机器都保存一份日志,日志来源于客户端的请求,包含一系列的命令。

状态机:状态机会按顺序执行这些命令。

一致性模型:在分布式环境中,确保多台机器的日志保持一致,从而使状态机回放时的状态保持一致。

Raft算法选主流程

Raft中使用心跳机制来出发leader选举。当服务器启动的时候,服务器成为follower。只要follower从leader或者candidate收到有效的RPCs就会保持follower状态。如果follower在一段时间内(该段时间被称为election timeout)没有收到消息,则它会假设当前没有可用的leader,然后开启选举新leader的流程。

1.Term

Term的概念类比中国历史上的朝代更替,Raft 算法将时间划分成为任意不同长度的任期(term)。

任期用连续的数字进行表示。每一个任期的开始都是一次选举(election),一个或多个候选人尝试成为领导者。如果一个候选人赢得选举,它将在该任期的剩余时间内担任领导者。在某些情况下,选票可能会被平分,导致没有选出领导者,此时将开始新的任期并立即进行下一次选举。Raft 算法确保在给定的任期中只有一个领导者。

2.RPC

Raft 算法中服务器节点之间通信使用远程过程调用(RPCs),并且基本的一致性算法只需要两种类型的 RPCs,为了在服务器之间传输快照增加了第三种 RPC。

RPC有三种:

  • RequestVote RPC:候选人在选举期间发起

  • AppendEntries RPC:领导人发起的一种心跳机制,复制日志也在该命令中完成

  • InstallSnapshot RPC:领导者使用该RPC来发送快照给太落后的追随者

3.选举流程

(1)follower增加当前的term,转变为candidate。

(2)candidate投票给自己,并发送RequestVote RPC给集群中的其他服务器。

(3)收到RequestVote的服务器,在同一term中只会按照先到先得投票给至多一个candidate。且只会投票给log至少和自身一样新的candidate。

图片

初始节点

图片

Node1 转为 Candidate 发起选举

图片

Node 确认选举

图片

Node1 成为 leader,发送 Heartbeat

candidate节点保持(2)的状态,直到下面三种情况中的一种发生。

  • 该节点赢得选举,即收到大多数节点的投票,然后转变为 leader 状态。

  • 另一个服务器成为 leader,即收到合法心跳包(term 值大于或等于当前自身 term 值),然后转变为 follower 状态。

  • 一段时间后仍未确定胜者,此时会启动新一轮的选举。

为了解决当票数相同时无法确定 leader 的问题,Raft 使用随机选举超时时间。

4.日志复制

日志复制(Log Replication)的主要目的是确保节点的一致性,在此阶段执行的操作都是为了确保一致性和高可用性。

当 Leader 选举产生后,它开始负责处理客户端的请求。所有的事务(更新操作)请求都必须先由 Leader 处理。日志复制(Log Replication)就是为了确保执行相同的操作序列所做的工作。

在 Raft 中,当接收到客户端的日志(事务请求)后,先把该日志追加到本地的Log中,然后通过heartbeat把该Entry同步给其他Follower,Follower接收到日志后记录日志然后向Leader发送ACK,当Leader收到大多数(n/2+1)Follower的ACK信息后将该日志设置为已提交并追加到本地磁盘中,通知客户端并在下个heartbeat中Leader将通知所有的Follower将该日志存储在自己的本地磁盘中。

如何实现Raft算法

Nacos server在启动时,会通过RunningConfig.onApplicationEvent()方法调用RaftCore.init()方法。

启动选举
public static void init() throws Exception {
 
    Loggers.RAFT.info("initializing Raft sub-system");
 
    // 启动Notifier,轮询Datums,通知RaftListener
    executor.submit(notifier);
     
    // 获取Raft集群节点,更新到PeerSet中
    peers.add(NamingProxy.getServers());
 
    long start = System.currentTimeMillis();
 
    // 从磁盘加载Datum和term数据进行数据恢复
    RaftStore.load();
 
    Loggers.RAFT.info("cache loaded, peer count: {}, datum count: {}, current term: {}",
        peers.size(), datums.size(), peers.getTerm());
 
    while (true) {
        if (notifier.tasks.size() <= 0) {
            break;
        }
        Thread.sleep(1000L);
        System.out.println(notifier.tasks.size());
    }
 
    Loggers.RAFT.info("finish to load data from disk, cost: {} ms.", (System.currentTimeMillis() - start));
 
    GlobalExecutor.register(new MasterElection()); // Leader选举
    GlobalExecutor.register1(new HeartBeat()); // Raft心跳
    GlobalExecutor.register(new AddressServerUpdater(), GlobalExecutor.ADDRESS_SERVER_UPDATE_INTERVAL_MS);
 
    if (peers.size() > 0) {
        if (lock.tryLock(INIT_LOCK_TIME_SECONDS, TimeUnit.SECONDS)) {
            initialized = true;
            lock.unlock();
        }
    } else {
        throw new Exception("peers is empty.");
    }
 
    Loggers.RAFT.info("timer started: leader timeout ms: {}, heart-beat timeout ms: {}",
        GlobalExecutor.LEADER_TIMEOUT_MS, GlobalExecutor.HEARTBEAT_INTERVAL_MS);
}

在init方法主要做了如下几件事:

  1. 获取Raft集群节点 peers.add(NamingProxy.getServers());

  2. Raft集群数据恢复 RaftStore.load();

  3. Raft选举 GlobalExecutor.register(new MasterElection());

  4. Raft心跳 GlobalExecutor.register(new HeartBeat());

  5. Raft发布内容

  6. Raft保证内容一致性

选举流程

其中,raft集群内部节点间是通过暴露的Restful接口,代码在 RaftController 中。RaftController控制器是raft集群内部节点间通信使用的,具体的信息如下

POST HTTP://{ip:port}/v1/ns/raft/vote : 进行投票请求

POST HTTP://{ip:port}/v1/ns/raft/beat : Leader向Follower发送心跳信息

GET HTTP://{ip:port}/v1/ns/raft/peer : 获取该节点的RaftPeer信息

PUT HTTP://{ip:port}/v1/ns/raft/datum/reload : 重新加载某日志信息

POST HTTP://{ip:port}/v1/ns/raft/datum : Leader接收传来的数据并存入

DELETE HTTP://{ip:port}/v1/ns/raft/datum : Leader接收传来的数据删除操作

GET HTTP://{ip:port}/v1/ns/raft/datum : 获取该节点存储的数据信息

GET HTTP://{ip:port}/v1/ns/raft/state : 获取该节点的状态信息{UP or DOWN}

POST HTTP://{ip:port}/v1/ns/raft/datum/commit : Follower节点接收Leader传来得到数据存入操作

DELETE HTTP://{ip:port}/v1/ns/raft/datum : Follower节点接收Leader传来的数据删除操作

GET HTTP://{ip:port}/v1/ns/raft/leader : 获取当前集群的Leader节点信息

GET HTTP://{ip:port}/v1/ns/raft/listeners : 获取当前Raft集群的所有事件监听者
RaftPeerSet
心跳机制

Raft中使用心跳机制来触发leader选举。

心跳定时任务是在GlobalExecutor 中,通过 GlobalExecutor.register(new HeartBeat())注册心跳定时任务,具体操作包括:

  • 重置Leader节点的heart timeout、election timeout;

  • sendBeat()发送心跳包

public class HeartBeat implements Runnable {
    @Override
    public void run() {
        try {

            if (!peers.isReady()) {
                return;
            }

            RaftPeer local = peers.local();
            local.heartbeatDueMs -= GlobalExecutor.TICK_PERIOD_MS;
            if (local.heartbeatDueMs > 0) {
                return;
            }

            local.resetHeartbeatDue();

            sendBeat();
        } catch (Exception e) {
            Loggers.RAFT.warn("[RAFT] error while sending beat {}", e);
        }
    }
}

简单说明了下Nacos中的Raft一致性实现,更详细的流程,可以下载源码,查看 RaftCore 进行了解。

源码可以通过以下地址检出:

git clone https://github.com/alibaba/nacos.git

图片

480 万商品,京东如何架构商品治理平台?

背景

作为一家即时零售电商平台,京东到家致力于在一小时内将各类优质商品送达消费者手中,同时也在努力提升商品的价值和平台的满意度。

京东到家商品管理系统,其主要职责:

对商品的创建、修改和展示的全流程进行干预和审核,旨在发现并解决商品信息中如:敏感词、虚假宣传、错误信息等不符合平台规范和质量要求的问题,确保商品与实物的一致性,以及信息的准确性。

系统架构介绍

京东到家的各个业务线都采用了标准化的微服务架构设计,各个系统在迭代过程中只需按需申请对应的组件。

以下是治理系统所使用的技术组件:

  • 日志服务:提供日志采集和查询服务。

  • RPC调用:利用京东的 JSF 平台,实现服务间注册、服务间调用和服务治理等功能,支持请求超时自动阻断。

  • 服务监控:使用统一监控与告警服务平台,实现秒级监控、多方位监控、服务告警、全链路追踪等功能。

  • 分布式调度引擎:基于 TBSchedule 分布式调度引擎框架构建的服务,负责定时任务的执行和分发。

  • 高性能存储:使用 Redis 集群、MySQL 集群等。

  • 消息中间件:采用京东的 MQ 中间件,实现业务解耦。

系统架构如下:

图片

系统架构

早期的治理系统

第一个需求与大多数业务系统相似,即基于数据的增删改查,构建一套敏感词管理模块,同时为商品主系统提供敏感词的校验能力。

第二个需求是为运营团队提供一个核验结果的报表,主要逻辑是通过上传 Excel,内部解析后调用接口获取相应的数据结果,基于 MySQL 进行存储,然后提供查询和展示功能,方便运营人员使用。

然而,由于缺乏设计和长远的考虑,因此当时的治理系统与商品主系统耦合严重,早期治理系统业务架构图示如下:

图片

早期治理系统业务架构

随着平台对商品信息合规性的规定日益严谨,针对商品分类、净重、图片等各项治理需求也相继出现。

然而,在上图的设计之中,我们可以明显看出,治理系统是基于具体业务来构建对外接口的。

因此,随着业务需求的持续扩大,两个系统之间交互的接口数量也会急剧增长,这是我们不愿意看到的。

另外,治理的最终目标是期望商品问题能够得到解决,而不仅仅是发现,因此,将问题暴露给运营或商家是必要的。然而,目前存在两个问题:

  1. 商品系统在其主要流程中过度依赖治理的核验功能,且随着业务的扩展,依赖程度会逐步增加。

  2. 商品系统只能将前置拦截的核验结果告知商家,业务覆盖面不足。

再加上许多问题属于弱合规性(不需要强制拦截但仍需要解决),因此,需要将商品治理业务的核心从商品系统转向治理系统

为了实现商品治理的高效率,对治理系统的设计和定位进行了调整,提出了两个基本原则:

  • 治理系统需要完成整个治理业务的闭环,作为商品问题发现和解决的总入口和总出口。

  • 治理系统需要具备高扩展性,当增加特定化治理需求时能够迅速响应。

治理系统架构升级

抽象思维显神威

在理清治理系统的业务架构升级思路之后,我们首先需要确定的一个问题就是:治理系统最基础的原子能力是什么?

以各个主系统为例,‘商品系统最基础的原子能力即:商品的创建、修改和提供查询能力。

库存系统最基础的原子能力即:商品库存信息的维护及查询能力。

根据治理业务的发展规划,我们也基本确定出治理系统的原子能力,即:发现商品存在的合规问题,并向外提供查询和辅助解决的能力

对于合规问题的定义,我们做出了如下解释,即:不符合电商平台商品展示规范的如敏感词、虚假渲传等问题。

例如商品名称中包含敏感词,会被视为敏感词问题,需要说明的是:在编码阶段,一种可量化的具体规则可以对应一种合规问题,且同一商品可能同时存在多个不同的合规问题。

目前到家治理系统所涉猎的合规问题主要有:

合规问题大类对外描述问题细节
商品毛重问题商品毛重不准确商品毛重与实际商品不符、商品毛重超过最大运力限制等
商品信息不正确商品信息不正确,请检查具体内容商品名称包含敏感词、商品分类与实际商品不符、虚假宣传等
商家商品经营范围问题当前售卖商品超出商家经营范围当前售卖商品超出商家经营范围等
图片信息问题商品图片信息存在问题商品无主图、商品主图为默认图、商品主图为黑底图等
未来计划
商品价格问题----
商品画像问题----
...

为了方便理解,我们可以将每一种合规问题看作是一种策略,而针对策略的顶层接口又定义了四个核心方法:

  • 核验方法:根据业务规则实现的具体核验逻辑

  • 自定义过滤能力:根据业务特点,减少无用处理

  • 问题关联的字段:每一个问题都需要关联具体的影响字段或被影响字段

  • 映射关联的枚举:每一个问题都需要关联具体的问题原因

具体的实现逻辑如下图所示:

图片

商品毛重信息填写错误为例,下图为处理前后的展示结果:

图片

图片

关于毛重的问题,我们可以将其与相关的枚举和文案映射联系起来,即:当商品毛重出现偏差(问题类型)时,建议的毛重为 XXX(文案映射)。其关联的字段包括商品的重量和名称。通过结合一定的过滤逻辑和验证算法,我们可以完成对毛重问题的抽象处理。以此方式,我们在处理新的治理问题时,可以借鉴这种做法。

熟悉设计模式的读者可能已经发现,这个设计方案实际上是策略模式和模板方法模式的混合体。在编码阶段,我们也会用到工厂模式,在编码层面整体的变化如下图所示:

图片

上述方案落地之后,产研团队对治理业务的未来发展有了基本的共识,同时,需求的实现也变得更加简单。我们不再需要关注其他系统的逻辑,而是专注于合规问题的业务规则实现。

业务部门和产品团队能够通过数据分析来确定未来的治理重点和需求规划,研发人员也通过优雅的方式解决了系统间耦合和业务代码重复的问题。

难点问题巧手破

在我们初步设定治理系统的业务架构设计后,后续迭代过程中,我们遇到了两个比较棘手的问题,一个是业务问题,一个是技术问题。

业务难点问题

业务部门要求 APP 展示的商品主图不能与默认图(如空白图、品牌商标图等不能体现商品信息的图片)相同,然而商品图片的校验逻辑一直由图片核验系统承接。

这就引起了一个问题:治理系统是否需要集成图片核验逻辑,如果不集成,那又该如何将其图片违规问题纳入至治理体系中?

有经验的开发者可能会建议使用消息队列(MQ)的方式,由图片核验系统将校验结果发送至治理系统,以解决此问题。

实际上,我们也是这么做的,只是做得更加彻底。

在设计模式中,我们通常会将一系列类似业务整合成一个公共接口向外提供能力,我们将其称为:门面模式或外观模式。

针对上述类似问题,我们找到了一个通用的处理方法,即:将治理系统作为门面,其他系统作为组件,各系统都可以主动的向治理系统提供需要治理的内容。

该方案确定后,各种棘手的业务场景也变得简单起来,同时,此举还扩大了治理系统的边界,例如商品无图合规问题,商品差评率高的问题,只需要对应系统将相关数据/结果以消息队列(MQ)的形式发送至治理系统,然后由治理系统为其绑定具体的合规问题即可。

在编码层面,我们采用最简单的消息队列(MQ)解耦方式实现,示意图如下:

图片

在进行治理迭代的过程中,有一系列的需求是针对平台商品的图片进行治理,以破损图逻辑为例。

在最开始的处理逻辑中,大家查询资料整合信息,发现平台偶尔出现的破损图是由于图片在下载过程中未下载完整后流中断,触发上传引起的。

因此在第一版的逻辑中,我们查阅资料作出了如下逻辑判断:当图片下载完成触发上传前,对比请求体中的ContentLength与实际图片字节大小,问题初步解决。

技术难点问题

然而,不久之后,问题再次爆发,我们发现事情并非想象中那么简单。

由于我们的平台对接了众多的商家系统,各个系统的图片服务器和后台逻辑都不尽相同,因此我们无法对所有图片都采用文件大小比对的方式进行处理。’

因此,我们重新进行了调研,并实现了针对破损图的核验能力。

图片

即通过下载后的图片内容进行处理和分析,利用算法与目标问题的业务特征进行识别,从而基本解决了这个问题。

同时,基于该思路我们也衍生出针对黑底图默认图的处理方式,在图片问题的治理上更进一步。

治理触达终落地

基于上述的方案和设计,治理系统在问题发现的流程上已经趋于完善,接下来,产品提出了新的要求,即:部分问题实现自动治理及问题触达商家。

机器学习的模型,大致可分为两种:分类模型和生成模型。

抛开它们的具体含义,我们可以借鉴这种设计理念,将治理系统划分为两个部分,即:发现解决

上述的业务提取和技术问题、业务问题都是用于侦测问题的,当我们将解决问题的目标纳入治理体系,只需要对现有架构进行适度的扩展就能满足需求。

以商品毛重信息填写错误为例,我们只需要在上述的提取中添加两个待实现方法:

  • 是否需要自动处理:毛重问题需要自动处理

  • 自动处理的具体实现规则:当实际毛重大于某一阈值时,将商品系统下架处理(依托于商品对外接口能力)

在核验结果存储前,依据具体的执行逻辑以及数据反馈结果来判断是人工处理还是系统处理即可。

对于触达需求,其实现更加简单,因为在初始阶段我们就定义了治理业务交流的基本元素是具体的治理问题,我们只需要将已存储的数据通过接口或消息队列的形式展示即可。

至此,整个治理体系从编码层面也就建设完成,其核心逻辑在三个环节:

  1. 商品变动MQ/其他系统治理内容通知触发具体合规问题核验。

  2. 针对核验结果进行判断:人工处理或系统自动处理(处理的能力需借助于商品对外接口)。

  3. 核验结果对外露出。

下图为治理系统当前整体业务结构图:

图片

治理系统整体架构图

治理业务全景图

自从治理平台业务框架升级以来,已经连续稳定运行了九个多月。

在此期间,我们已成功治理了 480 万以上的平台商品,构建了 8 种识别能力、3 种处理方式和 2 种触达方式。

同时,我们依托商品和标品系统,为商品快速建品、基础信息建设和治理审核等方面提供了有力保障。

以下是到家治理的全景图:

图片

治理业务全景图

未来规划

现行的治理体系主要围绕商品系统的核心环节进行设计和构建,其影响范围相对较小。

实际上,我们可以将商品治理的成果扩展应用到商品体系之外的其他系统。

例如下图中的各个业务场景:

图片

以搜索推荐为例,我们可以针对各种合规问题制定相应的扣分规则,在搜索侧构建数据时,将商品的合规分数纳入其中,并根据分数大小进行排序,以满足搜索条件。

同时,我们也需要将一些算法无法识别的问题纳入治理体系,例如:商品差评率高、退货率高等。

总结

随着业务的不断发展,对商品信息质量的要求将越来越高。到家治理系统需要与各上下游系统紧密联动,提供更加精细化的商品管控能力。

我们期待在未来,我们的治理能力能够更加优秀,为用户提供更真实、贴近实际的商品数据和更优质的服务。

页面调10 个上游接口,如何做高并发?

解决这个问题,需要  全链路、多层次、多维度进行 优化和改造

图片

全链路如何拆分后,进行优化和改造呢?

可以把简单的把全链路拆分如下:

  • 接受下游请求环节

  • 上游请求分裂后入列环节

  • 执行上游调用环节

  • 上游结果聚合和响应环节

图片

多层次如何拆分后,进行优化和改造呢?

核心的措施是:异步化, 但是,要分层进行异步化

可以简单的, 把用户的api调用解耦为三层,   如下图所示:

  • 应用层:编程模型的异步化

  • 框架层:IO线程的异步化

  • OS层:IO模型的异步化

解耦之后,再庖丁解牛,一层一层的进行异步化架构。

图片

全链路异步,让你的 SpringCloud 性能优化10倍+NIO学习圣经

多维度优化和改造,主要有哪些呢?

其实有很多, 但是主要聚焦的点是:

图片

业务线程,IO线程,如何进行优化和改造呢?

可以把简单的把全链路拆分如下:

  • 接受下游请求环节

  • 上游请求分裂后入列环节

  • 执行上游调用环节

  • 上游结果聚合和响应环节

第二个环节涉及到了 业务线程池,第三个环节设计到了 IO线程池

都需要合理的设置线程池的大小、拒绝策略。 并且结合不同的框架和组件,结合自己的业务场景实现异步。

比如:

方案一:使用 CompletableFuture (自定义业务线程池)  + httpClient (池化同步io框架) + CountDownLatch闭锁(聚合结果)

方案二:CloseableHttpAsyncClient(池化异步io框架) + CountDownLatch闭锁(聚合结果)

方案三:自研Netty 异步IO框架+ CountDownLatch闭锁(聚合结果)

网络传输维度,如何进行优化和改造呢?

核心就是两点:

  • 连接复用

  • 多路复用

首先看 连接复用。也就是 短链接,变成长连接

http1.0协议头里可以设置Connection:Keep-Alive。在header里设置Keep-Alive可以在一定时间内复用连接,具体复用时间的长短可以由服务器控制,一般在15s左右。

到http1.1之后Connection的默认值就是Keep-Alive,如果要关闭连接复用需要显式的设置Connection:Close。

图片

多路复用代替原来的序列和阻塞机制。所有就是请求的都是通过一个 TCP 连接并发完成。

因为在多路复用之前所有的传输是基于基础文本的,在多路复用中是基于二进制数据帧的传输、消息、流,所以可以做到乱序的传输。

多路复用对同一域名下所有请求都是基于流,所以不存在同域并行的阻塞。

多路复用复用场景,多次请求如下图:

图片

http2.0

由于http1.2 目前普及度不够,一般还是考虑 http1.1连接复用

方案一实操

使用 CompletableFuture (自定义业务线程池)  + httpClient (池化同步io框架) + CountDownLatch闭锁(聚合结果)

static final HttpClient httpClient = HttpClientBuilder.create().build();

@Benchmark
@Test
public void HttpClient() {
    List<String> apis = Arrays.asList(
        "http://192.168.56.121/echo?i=1",
        "http://192.168.56.121/echo?i=2",
        "http://192.168.56.121/echo?i=3"
    );

    CountDownLatch latch = new CountDownLatch(apis.size());
    Map<String, String> results = new ConcurrentHashMap<>();
    for (String api : apis) {
        CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
            HttpResponse response = null;
            try {
                response = httpClient.execute(new HttpGet(api));
            } catch (IOException e) {
                return null;
            }

            if (HttpStatus.SC_OK == response.getStatusLine().getStatusCode()) {
                try {
                    return EntityUtils.toString(response.getEntity());
                } catch (Exception e) {
                    log.error("error", e);
                    throw new RuntimeException(e);
                }
            }
            return null;
        });
        future.whenComplete((result, throwable) -> {
            latch.countDown();
            results.put(api, result);
        }
                           );
    }

    try {
        latch.await(10000000, TimeUnit.MILLISECONDS);
    } catch (Exception e) {
        log.error("error", e);

        //            throw new RuntimeException(e);
    }

    //        System.out.println(results.toString());
}
方案一性能基线测试

这里使用linux + jmh 框架,完成性能基线测试

达到 5000qps

图片

方案二实操

方案二:CloseableHttpAsyncClient(池化异步io框架) + CountDownLatch闭锁(聚合结果)

这里去掉了业务线程池。提升了性能

@Benchmark
@Test
public void HttpAsyncClient() {

    List<String> urls = Arrays.asList(
        "http://192.168.56.121/echo?i=1",
        "http://192.168.56.121/echo?i=2",
        "http://192.168.56.121/echo?i=3"
    );

    CountDownLatch latch = new CountDownLatch(urls.size());
    Map<String, String> results = new ConcurrentHashMap<>();

    for (int i = 0; i < urls.size(); i++) {
        final String url = urls.get(i);
        Future<HttpResponse> f0 = asyncClient.execute(new HttpGet(url), new FutureCallback<HttpResponse>() {
            public void completed(HttpResponse response) {

                latch.countDown();

                if (HttpStatus.SC_OK == response.getStatusLine().getStatusCode()) {
                    try {
                        String result = EntityUtils.toString(response.getEntity());
                        results.put(url, result);
                    } catch (IOException e) {
                        log.error("error", e);
                        //                            throw new RuntimeException(e);
                    }
                }

            }

            public void failed(Exception ex) {
                latch.countDown();
                log.error("error", ex);
                //                    ex.printStackTrace();
            }

            public void cancelled() {
                latch.countDown();
            }
        });
    }


    try {
        latch.await(10000000, TimeUnit.MILLISECONDS);
    } catch (
        InterruptedException e) {
        throw new RuntimeException(e);
    }

    //        System.out.println("results = " + results);
}
方案二性能基线测试

这里使用linux + jmh 框架,完成性能基线测试

达到 4300qps

图片

理论上性能会更优,但是,看上去性能更差。

方案三实操

方案三:自研Netty 异步IO框架+ CountDownLatch闭锁(聚合结果)

网上找了一个 生产环境上的 qps达到 9000的 自研Netty 异步IO框架

完成了实验

@Benchmark
@Test
public void nettyGet() {

    //        setup();
    List<String> urls = Arrays.asList(
        "/echo?i=1",
        "/echo?i=2",
        "/echo?i=3"
    );

    CountDownLatch latch = new CountDownLatch(urls.size());
    Map<Integer, Object> results = new ConcurrentHashMap<>();


    ReqOptions options = new ReqOptions(TypeReference.from(String.class));
    for (int i = 0; i < urls.size(); i++) {

        int finalI = i;
        longConnHttpClient.getAsync("/echo?i=" + i, options, response -> {

            Object content = response.content();
            results.put(finalI, content);

            latch.countDown();
        }, e -> {
            e.printStackTrace();
            latch.countDown();
        });

    }

    try {
        latch.await(10000000, TimeUnit.MILLISECONDS);
    } catch (
        InterruptedException e) {
        throw new RuntimeException(e);
    }
    //        System.out.println("results = " + results);

}
方案三性能基线测试

这里使用linux + jmh 框架,完成性能基线测试

第一次个版本,性能就 300 ops

图片

进行优化之后,第二次验证, 也就4300qps, 但是,看上去性能也很差。

图片

不用jmh进行基线测试, 达到 6000多qps,这下明白了。

图片

为啥 技术越高明,性能反而不高呢?

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值