前言

2023-9-12 12:12:04
2023-09-14 16:13:04

公开发布于
2024-5-22 00:16:21


面试框架

Spring

SpringMVC

深入理解SpringMVC工作原理,像大牛一样手写SpringMVC框架

SpringBoot

[SpringBoot源码分析]SpringBoot是如何启动的

MySQL 数据库

Redis 中间件

一文搞懂redis

ElasticSearch 搜索框架

「扫盲」Elasticsearch

Kafka 消息队列

Kafka 科普

Kafka 架构、核心机制和场景解读

Kafka 是一款非常优秀的开源消息引擎,以消息吞吐量高、可动态扩容、可持久化存储、高可用的特性,以及完善的文档和社区支持成为目前最流行的消息队列中间件。

1.整体架构

面试框架【面试准备】_缓存

一个典型的 Kafka 体系架构包括若干 Producer、若干 Broker、若干 Consumer,以及一个 ZooKeeper 集群,如图所示。其中 ZooKeeper 是 Kafka 用来负责集群元数据的管理、控制器 的选举等操作的。Producer 将消息发送到 Broker,Broker 负责将收到的消息存储到磁盘中,而 Consumer 负责从 Broker 订阅并消费消息。
整个 Kafka 体系结构中引入了以下 3 个术语。

  • Producer:生产者,也就是发送消息的一方。生产者负责创建消息,然后将其投递到 Kafka 中。
  • Consumer:消费者,也就是接收消息的一方。消费者连接到 Kafka 上并接收消息,进 而进行相应的业务逻辑处理。
  • Broker:服务代理节点。对于 Kafka 而言,Broker 可以简单地看作一个独立的 Kafka 服务节点或 Kafka 服务实例。大多数情况下也可以将 Broker 看作一台 Kafka 服务器,前提是这台服务器上只部署了一个 Kafka 实例。一个或多个 Broker 组成了一个 Kafka 集群。一般而言, 我们更习惯使用首字母小写的 broker 来表示服务代理节点。

2.Kafka基础概念

面试框架【面试准备】_缓存_02

在 Kafka 中还有两个特别重要的概念——主题(Topic)与分区(Partition)。Kafka 中的消息以 topic 题为单位进行归类,生产者负责将消息发送到特定的 topic (发送到 Kafka 集群中的每一条消息都要指定一个主题),而消费者负责订阅主题并进行消费。

主题是一个逻辑上的概念,它还可以细分为多个分区,一个分区只属于单个主题,很多时候也会把分区称为主题分区(Topic-Partition)。同一主题下的不同分区包含的消息是不同的,分区在存储层面可以看作一个可追加的日志(Log)文件,消息在被追加到分区日志文件的时候都会分配一个特定的偏移量(offset)。offset 是消息在分区中的唯一标识,Kafka 通过它来保证消息在分区内的顺序性,不过 offset 并不跨越分区,也就是说,Kafka 保证的是分区有序而不是主题有序。

如上图(左)所示,主题中有 3 个分区,消息被顺序追加到每个分区日志文件的尾部。Kafka 中的分区可以分布在不同的服务器(broker)上,也就是说,一个主题可以横跨多个 broker,以此来提供比单个 broker 更强大的性能。

每一条消息被发送到 broker 之前,会根据分区规则选择被存储到哪个具体的分区。如果分区规则设定得合理,所有的消息都可以均匀地分配在不同的分区中。如果一个主题只对应一个文件,那么这个文件所在的机器 I/O 将会成为这个主题的性能瓶颈,而分区解决了这个问题。 在创建主题的时候可以通过指定的参数来设置分区的个数,当然也可以在主题创建完成之后去修改分区的数量,通过增加分区的数量可以实现水平扩展。

3.消费者与消费组

面试框架【面试准备】_线程池_03


在 Kafka 的消费理念中还有一层消费组(Consumer Group))的概念,每个消费者都有一个对应的消费组。当消息发布到主题后,只会被投递给订阅它的每个消费组中的一个消费者。

如上图所示,某个主题中共有 4 个分区(Partition): P0、P1、P2、P3。有两个消费组 A 和 B 都订阅了这个主题,消费组 A 中有 4 个消费者(C0、C1、C2 和 C3),消费组 B 中有 2 个消费者(C4 和 C5)。按照 Kafka 默认的规则,最后的分配结果是消费组 A 中的每一个消费者分配到 1 个分区,消费组 B 中的每一个消费者分配到 2 个分区,两个消费组之间互不影响。 每个消费者只能消费所分配到的分区中的消息。换言之,每一个分区只能被一个消费组中的一 个消费者所消费。

我们再来看一下消费组内的消费者个数变化时所对应的分区分配的演变。假设目前某消费组内只有一个消费者 C0,订阅了一个主题,这个主题包含 7 个分区:P0、P1、P2、P3、P4、 P5、P6。也就是说,这个消费者 C0 订阅了 7 个分区,具体分配情形参考下图(左上)。

面试框架【面试准备】_线程池_04

此时消费组内又加入了一个新的消费者 C1,按照既定的逻辑,需要将原来消费者 C0 的部 分分区分配给消费者 C1 消费,如上图(右上) 所示。消费者 C0 和 C1 各自负责消费所分配到的分区, 彼此之间并无逻辑上的干扰。

紧接着消费组内又加入了一个新的消费者 C2,消费者 C0、C1 和 C2 按照上图(左下)中的方式 各自负责消费所分配到的分区。

消费者与消费组这种模型可以让整体的消费能力具备横向伸缩性,我们可以增加(或减少) 消费者的个数来提高(或降低)整体的消费能力。对于分区数固定的情况,一味地增加消费者 并不会让消费能力一直得到提升,如果消费者过多,出现了消费者的个数大于分区个数的情况, 就会有消费者分配不到任何分区。参考上图(右下),一共有 8 个消费者,7 个分区,那么最后的消费者 C7 由于分配不到任何分区而无法消费任何消息。

消费者分区分配策略:RangeAssignor、RoundRobinAssignor、StickyAssignor(0.11)。

面试框架【面试准备】_面试_05

消费者分区分配策略可以自定义实现,比如自定义实现上图的“组内广播”。

4.存储视图

面试框架【面试准备】_面试_06


主题和分区都是提供给上层用户的抽象,而在副本层面或更加确切地说是 Log 层面才有实际物理上的存在。同一个分区中的多个副本必须分布在不同的 broker 中,这样才能提供有效的数据冗余。

为了防止 Log 过大, Kafka 又引入了日志分段(LogSegment)的概念,将 Log 切分为多个 LogSegment,相当于一个巨型文件被平均分配为多个相对较小的文件,这样也便于消息的维护和清理。事实上,Log 和 LogSegment 也不是纯粹物理意义上的概念,Log 在物理上只以文件夹的形式存储,而每个 LogSegment 对应于磁盘上的一个日志文件和两个索引文件,以及可能的其他文件(比如以 “.txnindex”为后缀的事务索引文件) 。

为了便于消息的检索,每个 LogSegment 中的日志文件(以“.log”为文件后缀)都有对应的两个索引文件:偏移量索引文件(以“.index”为文件后缀)和时间戳索引文件(以“.timeindex” 为文件后缀)。每个 LogSegment 都有一个基准偏移量 baseOffset,用来表示当前 LogSegment 中第一条消息的 offset。偏移量是一个 64 位的长整型数,日志文件和两个索引文件都是根据基 准偏移量(baseOffset)命名的,名称固定为 20 位数字,没有达到的位数则用 0 填充。比如第一个 LogSegment 的基准偏移量为 0,对应的日志文件为 00000000000000000000.log。

5.多副本

Kafka 为分区引入了多副本(Replica)机制,通过增加副本数量可以提升容灾能力。同一分区的不同副本中保存的是相同的消息(在同一时刻,副本之间并非完全一样),副本之间是一主多从的关系,其中 leader 副本负责处理读写请求,follower 副本只负责与 leader 副本的消息同步。副本处于不同的 broker 中,当 leader 副本出现故障时,从 follower 副本中重新选举新的 leader 副本对外提供服务。Kafka 通过多副本机制实现了故障的自动转移,当 Kafka 集群中某个 broker 失效时仍然能保证服务可用。

面试框架【面试准备】_缓存_07

如上图所示,Kafka 集群中有 4 个 broker,某个主题中有 3 个分区,且副本因子(即副本个数)也为 3,如此每个分区便有 1 个 leader 副本和 2 个 follower 副本。生产者和消费者只与 leader 副本进行交互,而 follower 副本只负责消息的同步,很多时候 follower 副本中的消息相对 leader 副本而言会有一定的滞后。
分区中的所有副本统称为 AR (Assigned Replicas) 。所有与 leader 副本保持一定程度同步的副本(包括 leader 副本在内)组成 ISR (In-Sync Replicas) ,ISR 集合是 AR 集合中的一个子 集。消息会先发送到 leader 副本,然后 follower 副本才能从 leader 副本中拉取消息进行同步, 同步期间内 follower 副本相对于 leader 副本而言会有一定程度的滞后。
前面所说的“一定程度的同步”是指可忍受的滞后范围,这个范围可以通过参数进行配置。与 leader 副本同步滞后过多的副本(不包括 leader 副本)组成 OSR (Out-of-Sync Replicas) ,由此可见,AR =ISR+OSR。 在正常情况下,所有的 follower 副本都应该与 leader 副本保持一定程度的同步,即 AR=ISR, OSR 集合为空。
leader 副本负责维护和跟踪 ISR 集合中所有 follower 副本的滞后状态,当 follower 副本落后太多或失效时,leader 副本会把它从 ISR 集合中剔除。如果 OSR 集合中有 follower 副本“追上” 了 leader 副本,那么 leader 副本会把它从 OSR 集合转移至 ISR 集合。默认情况下,当 leader 副 本发生故障时,只有在 ISR 集合中的副本才有资格被选举为新的 leader,而在 OSR 集合中的副 本则没有任何机会(不过这个原则也可以通过修改相应的参数配置来改变)。

面试框架【面试准备】_缓存_08

ISR 与 HW 和 LEO 也有紧密的关系。HW 是 High Watermark 的缩写,俗称高水位,它标识 了一个特定的消息偏移量(offset),消费者只能拉取到这个 offset 之前的消息。

如上图所示,它代表一个日志文件,这个日志文件中有 9 条消息,第一条消息的offset
(LogStartOffset)为 0,最后一条消息的 offset 为 8,offset 为 9 的消息用虚线框表示,代表下一条待写入的消息。日志文件的 HW 为 6,表示消费者只能拉取到 offset 在 0 至 5 之间的消息, 而 offset 为 6 的消息对消费者而言是不可见的。

LEO 是 Log End Offset 的缩写,它标识当前日志文件中下一条待写入消息的 offset,上图中 offset 为 9 的位置即为当前日志文件的 LEO,LEO 的大小相当于当前日志分区中最后一条消息的 offset 值加 1。分区 ISR 集合中的每个副本都会维护自身的 LEO,而 ISR 集合中最小的 LEO 即为分区的 HW,对消费者而言只能消费 HW 之前的消息。

注意要点:很多资料中会将上图中的 offset 为 5 的位置看作 HW,而把 offset 为 8 的位置 看作 LEO,这显然是不对的。

性能机制

零拷贝

Kafka使用了 Zero Copy 技术提升了消费的效率。(sendfile 系统调用在 Linux 内核版本 2.1 中被引入,目的是简化通过网络在两个通道之间进行的数据传输过程。sendfile 允许操作系统从 Page Cache 直接将文件发送至 socket 缓存区,节省了内核空间 & 用户空间的两次冗余的 cpu 拷贝操作,最后只需要通过 DMA 传输将数据复制到网卡。这样使得调用变得更加简洁:不仅减少了 CPU 拷贝的次数,由于文件传输拷贝仅发生在内核空间,还减少了上下文切换的次数。)

面试框架【面试准备】_线程池_09

RocketMQ 消息队列

事务消息

生产者事务和消费者事务的最终一致性

Guava Cache 本地缓存

浅析本地缓存技术-Guava Cache | 京东物流技术团队

3 guava cache的使用方式

Cache<String, String> localCache = CacheBuilder.newBuilder()
                .initialCapacity(5)
                .maximumSize(10)
                .concurrencyLevel(3)
                .expireAfterWrite(10, TimeUnit.SECONDS)
                .build();

以上示例实例化了一个本地缓存,接下来介绍一下初始化的各项参数的含义:
initialCapacity:内部哈希表的最小容量,也就是cache的初始容量。
maximumSize:cache的最大缓存数。
concurrencyLevel:并发等级,也可以定义为同时操作缓存的线程数,由

int getConcurnencyLevel() { return this.concurrencyLevel = -1 ? 4 : this.concurrencyLevel; }

可以看出,这个线程数默认为4。
expireAfterWrite:缓存写入后刷新时间。
从缓存中获取数据调用的方法为get(K key, Callable<? extends V> loader)方法,此方法的含义是根据键key获取数据,若key不存在,则通过执行指定的Callable方法来构造缓存,示例代码如下所示:

Map<String, Dicdetail> dicDetailMap = ObLocateCache.locateConfigCache.get(key.toString(), new Callable<Map<String, Dicdetail>>() {
                @Override
                public Map<String, Dicdetail> call() throws Exception {
                    return getConfigParameterFromMaster(baseDomain, KeyConstants.WMS5_LOCATE_MANUAL);
                }
            });

从cache中删除数据分为被动删除和主动删除两种:
1.被动删除:

基于数据大小删除:LRU+FIFO
基于过期时间删除:在指定时间内没有被访问
基于引用删除:通过weakKeys和weakValues方法指定Cache只保存对缓存记录key和value的弱引用。这样当没有其他强引用指向key和value时,key和value对象就会被垃圾回收器回收

2.主动删除:

//删除指定的key对应数据
cache.invalidate("s");
//将一批对应的数据删除
cache.invalidateAll(Arrays.asList("st","r","ing"));
//全部删除
cache.invalidateAll();

guava cache的数据结构跟ConcurrentHashMap类似,二者最基本的区别是ConcurrentMap会一直保存所有添加的元素,直至将添加的元素移除。相对地,guava cache为了限制内存占用,通常都设定为自动回收元素。

guava cache的核心类为LocalCache,LocalCache实现了ConcurrentMap接口。其中有一个Segment数组,如下所示

面试框架【面试准备】_缓存_10

Caffine 本地缓存

常见本地缓存、分布式缓存解决方案详解

Quartz 定时框架

SpringBoot——Quartz定时框架的使用详解和总结

特点

  1. 支持分布式高可用,我们需要某个定时任务在多个节点中只有某个节点可以执行时,就需要Quartz来实现,否则使用@Scheduled等方式会造成所有节点都执行一遍。
  2. 支持持久化,Quartz有专门的数据表来实现定时任务的持久化。
  3. 支持多任务调度和管理,Quartz可以在数据库中存储多个定时任务进行作业调度,可以实现定时任务的增删改查等管理。

组成

Quartz由三部分组成:

  1. 任务:JobDetail
  2. 触发器:Trigger(分为SimpleTrigger和CronTrigger)
  3. 调度器:Scheduler

JobDetail Trigger Scheduler Cron表达式

RateLimiter 限流器

限速神器RateLimiter源码解析 | 京东云技术团队

使用DCL

常用的限流算法

常用的限流算法有如下几种:

  • 算法一、信号量算法。 维护最大的并发请求数(如连接数),当并发请求数达到阈值时报错或等待,如线程池。
  • 算法二、漏桶算法。 模拟一个按固定速率漏出的桶,当流入的请求量大于桶的容量时溢出。
  • 算法三、令牌桶算法。 以固定速率向桶内发放令牌。请求处理时,先从桶里获取令牌,只服务有令牌的请求。

本次要介绍的RateLimiter使用的是令牌桶算法。RateLimiter是google的guava包中的一个轻巧限流组件,它主要有两个java类文件,RateLimiter.java和SmoothRateLimiter.java。两个类文件共有java代码301行、注释420行,注释比java代码还要多,写的非常详细,后面的介绍也有相关内容是翻译自其注释,有些描述英文原版更加准确清晰,有兴趣的也可以结合原版注释进行更详细的了解。

算法介绍

在介绍之前,先说一下RateLimiter中的几个名词:

  • 许可( permit ): 代表一个令牌,获取到许可的请求才能放行。
  • 资源利用不足( underunilization ): 许可的发放一般是匀速的,但请求未必是匀速的,有时会有无请求(资源利用不足)的场景,令牌桶会有贮存机制。
  • 贮存许可( storedPermit ): 令牌桶支持对空闲资源进行许可贮存,许可请求时优先使用贮存许可。
  • 新鲜许可( freshPermit ): 当贮存许可为空时,采用透支方式,下发新鲜许可,同时设置下次许可生效时间为本次新鲜许可的结束时间。

如下为一个许可发放示例,矩形代表整个令牌桶,许可产生速度为1个/秒,令牌桶里有一个贮存桶,容量为2。

面试框架【面试准备】_缓存_11

以上示例中,在T1贮存容量为0,许可请求时直接返回1个新鲜许可,贮存容量随着时间推移,增长至最大值2,在T2时收到3个许可的请求,此时会先从贮存桶中取出2个,然后再产生1个新鲜许可,0.5s后在T3时刻又来了1个许可请求,由于最近的许可0.5s后才会下发,因此先sleep0.5s再下发。
RateLimiter的核心功能是限速,我们首先想到的限速方案是记住最后一次下发令牌许可(permit)时间,下次许可请求时,如果与最后一次下发许可时间的间隔小于1/QPS,则进行sleep至1/QPS,否则直接发放,但该方法不能感知到资源利用不足的场景。一方面,隔了很长一段再来请求许可,则可能系统此时相对空闲,可下发更多的许可以充分利用资源;另一方面,隔了很长一段时间再来请求许可,也可能意味着处理请求的资源变冷(如缓存失效),处理效率会下降。因此在RateLimiter中,增加了资源利用不足(underutilization)的管理,在代码中体现为贮存许可(storedPermits),贮存许可值最开始为0,随着时间的增加,一直增长为最大贮存许可数。许可获取时,首先从贮存许可中获取,然后再根据下次新鲜许可获取时间来进行新鲜许可获取。这里要说的是RateLimiter是记住了下次令牌发放的时间,类似于透支的功能,当前许可获取时立刻返回,同时记录下次获取许可的时间。

TreadPool 线程池

Java—线程池ThreadPoolExecutor详解

要求:线程资源必须通过线程池提供,不允许在应用自行显式创建线程;
说明:使用线程池的好处是减少在创建和销毁线程上所花的时间以及系统资源的开销,解决资源不足的问题。如果不使用线程池,有可能造成系统创建大量同类线程而导致消耗内存或者“过度切换”的问题。

by 《阿里巴巴Java手册》

七大参数:

int corePoolSize:该线程池中核心线程数最大值
int maximumPoolSize:线程总数最大值
long keepAliveTime:非核心线程空闲超时时间
TimeUnit unit:是keepAliveTime 的单位
BlockingQueue workQueue:存放任务的阻塞队列
ThreadFactory threadFactory:创建线程的方式
RejectedExecutionHandler handler:饱和策略

BlockingQueue workQueue:存放任务的阻塞队列

当核心线程都在工作的时候,新提交的任务就会被添加到这个工作阻塞队列中进行排队等待;如果阻塞队列也满了,线程池就新建非核心线程去执行任务。workQueue维护的是等待执行的Runnable对象。
常用的 workQueue 类型:(无界队列、有界队列、同步移交队列)

  1. SynchronousQueue:同步移交队列,适用于非常大的或者无界的线程池,可以避免任务排队,SynchronousQueue队列接收到任务后,会直接将任务从生产者移交给工作者线程,这种移交机制高效。它是一种不存储元素的队列,任务不会先放到队列中去等线程来取,而是直接移交给执行的线程。只有当线程池是无界的或可以拒绝任务的时候,SynchronousQueue队列的使用才有意义,maximumPoolSize 一般指定成 Integer.MAX_VALUE,即无限大。要将一个元素放入SynchronousQueue,就需要有另一个线程在等待接收这个元素。若没有线程在等待,并且线程池的当前线程数小于最大值,则ThreadPoolExecutor就会新建一个线程;否则,根据饱和策略,拒绝任务。newCachedThreadPool默认使用的就是这种同步移交队列。吞吐量高于LinkedBlockingQueue。
  2. LinkedBlockingQueue:基于链表结构的阻塞队列,FIFO原则排序。当任务提交过来,若当前线程数小于corePoolSize核心线程数,则线程池新建核心线程去执行任务;若当前线程数等于corePoolSize核心线程数,则进入工作队列进行等待。LinkedBlockingQueue队列没有最大值限制,只要任务数超过核心线程数,都会被添加到队列中,这就会导致总线程数永远不会超过 corePoolSize,所以maximumPoolSize 是一个无效设定。newFixedThreadPool和newSingleThreadPool默认是使用的是无界LinkedBlockingQueue队列。吞吐量高于ArrayBlockingQueue。
  3. ArrayBlockingQueue:基于数组结构的有界阻塞队列,可以设置队列上限值,FIFO原则排序。当任务提交时,若当前线程小于corePoolSize核心线程数,则新建核心线程执行任务;若当先线程数等于corePoolSize核心线程数,则进入队列排队等候;若队列的任务数也排满了,则新建非核心线程执行任务;若队列满了且总线程数达到了maximumPoolSize最大线程数,则根据饱和策略进行任务的拒绝。
  4. DelayQueue:延迟队列,队列内的元素必须实现 Delayed 接口。当任务提交时,入队列后只有达到指定的延时时间,才会执行任务
  5. PriorityBlockingQueue:优先级阻塞队列,根据优先级执行任务,优先级是通过自然排序或者是Comparator定义实现。

注意: 只有当任务相互独立没有任何依赖的时候,线程池或工作队列设置有界是合理的;若任务之间存在依赖性,需要使用无界的线程池,如newCachedThreadPool,否则有可能会导致死锁问题。

执行流程:

一般流程即为:创建worker线程;添加任务入workQueue队列;worker线程执行任务。

当一个任务被添加进线程池时:

  1. 当前线程数量未达到 corePoolSize,则新建一个线程(核心线程)执行任务
  2. 当前线程数量达到了 corePoolSize,则将任务移入阻塞队列等待,让空闲线程处理;
  3. 当阻塞队列已满,新建线程(非核心线程)执行任务
  4. 当阻塞队列已满,总线程数又达到了 maximumPoolSize,就会按照拒绝策略处理无法执行的任务,比如RejectedExecutionHandler抛出异常。

四个线程池

newCacheThreadPool():可缓存线程池
newFixedThreadPool():定长线程池单线程的线程
newScheduledThreadPool():定时线程池
newSingleThreadExecutor():单线程化的线程池

newCachedThreadPool将创建一个可缓存的线程,如果当前线程数超过处理任务时,回收空闲线程;当需求增加时,可以添加新线程去处理任务。

  1. 线程数无限制,corePoolSize数值为0, maximumPoolSize 的数值都是为 Integer.MAX_VALUE。
  2. 若线程未回收,任务到达时,会复用空闲线程;若无空闲线程,则新建线程执行任务。
  3. 因为复用性,一定程序减少频繁创建/销毁线程,减少系统开销。
  4. 工作队列可以选用SynchronousQueue。

newFixedThreadPool创建一个固定长度的线程池,每次提交一个任务的时候就会创建一个新的线程,直到达到线程池的最大数量限制。

  1. 定长,可以控制线程最大并发数, corePoolSize 和 maximumPoolSize 的数值都是nThreads。
  2. 超出的线程会在队列中等待。
  3. 工作队列可以选用LinkedBlockingQueue。

newScheduledThreadPool创建一个固定长度的线程池,并且以延迟或者定时的方式去执行任务。

newSingleThreadExecutor顾名思义,是一个单线程的Executor,只创建一个工作线程执行任务,若这个唯一的线程异常故障了,会新建另一个线程来替代,newSingleThreadExecutor可以保证任务依照在工作队列的排队顺序来串行执行。

  1. 有且仅有一个工作线程执行任务;
  2. 所有任务按照工作队列的排队顺序执行,先进先出的顺序。
  3. 单个线程的线程池就是线程池中只有一个线程负责任务,所以 corePoolSize 和 maximumPoolSize 的数值都是为 1;当这个线程出现任何异常后,线程池会自动创建一个线程,始终保持线程池中有且只有一个存活的线程。
  4. 工作队列可以选用LinkedBlockingQueue。

四种拒绝策略

ThreadPoolExecutor的饱和策略可以通过调用setRejectedExecutionHandler来修改。JDK提供了几种不同的RejectedExecutionHandler实现,每种实现都包含有不同的饱和策略:AbortPolicy、CallerRunsPolicy、DiscardPolicy和DiscardOldestPolicy。
拒绝策略如下:

  • CallerRunsPolicy : 调用线程处理任务
  • AbortPolicy : 抛出异常
  • DiscardPolicy : 直接丢弃
  • DiscardOldestPolicy : 丢弃队列中最老的任务,执行新任务

最后

我们都有光明的未来

祝大家考研上岸
祝大家工作顺利
祝大家得偿所愿
祝大家如愿以偿
点赞收藏关注哦