Java 程序员Java

性能追击:万字长文30+图揭秘8大主流服务器程序线程模型展示

2021-11-02  本文已影响0人  马小莫QAQ

看大佬如何用30+图片揭秘8大主流服务器程序线程模型:

最近拍的照片比较少,不知道配什么图好,于是自己画了一个,凑合着用,让大家见笑了。

本文我们来探索一下主流的各种应用服务器的网络处理模型,看看大家都是怎么设计网络程序的。在本文中,我会从Node.js、Apache Server、Nginx、Netty、Redis、Tomcat、MySQL、Zuul等常用的服务器程序,给大家逐一分析,分析各种服务器程序的性能,心中有数,才能手中有术,从此性能是熟客。

虽然涉及到很多底层知识,各种框架的原理,但是我都会尽量配上直白易懂的图文,方便大家理解。

1、Node.js

我们继续讲讲Node.js的运行模式,揭开它高性能背后的实现机制。

1.1、Node.js运行模式

Node.js是单线程的Event Loop:

其中请求的任务会被封装成如下的结构:

varevent= createEvent({
  params:request.params, // 传递请求参数
  result:null, // 存放请求结果
  callback:function(){} // 指定回调函数
});

当然了,在客户端请求到Node.js服务器的时候,肯定会有一个创建已连接套接字的过程,然后把这个已连接套接字描述符与具体的执行代码关联起来,这样再异步处理完成之后,才知道要响应给哪个客户端。

1.2、Node.js异步案例

以上的运行模式说明还是需要结合例子来说明比较好理解。

如果没有通过回调函数进行异步处理,我们可能会写出如下代码:

var result = db.query("select * from t_user");
// do something with result here...
console.log("do something else...");

这个代码在执行查询result的时候,查询速度可能很慢,等待查询出结果后,才可以执行后面的console.log操作,因为这是在一个线程上执行的。

但是Node.js不是这么玩的,Node.js的运行模式下,只有一个Event Loop线程,如果这个线程被阻塞,这将导致无法接收新的请求。为了避免这种情况,我们按照Node.js的回调方式重写代码:

db.query("select * from t_user", function(rows) {
  var result = rows;
  // do something with result here...
});
console.log("do something else...");

现在,Node.js可以异步处理查询请求了,并且把 查询请求委托给Worker Thread,等待Worker Thread得到查询结果之后,再把结果连同回调匿名函数封装成事件发布到事件队列,等待Event Loop线程执行该回调函数 。这样console.log代码就可以立刻得到执行,而不会因为查询请求导致被阻塞住了。

1.3、Node.js并发模型优缺点

从以上分析可知,Node.js通过事件驱动,把阻塞的IO任务丢到线程池中进行异步处理,也就是说, Node.js适合I/O密集型任务

但是,如果碰到CPU密集型任务的时候,Node.js中的EventLoop线程就会自己处理任务,这样会导致在事件队列中的CPU密集型任务没有处理完,那么后面的任务就不会被执行到了,从而导致后续的请求响应变慢。

如下图,本来socket2和socket3很快就可以处理完的,但是由于socket1的任务一直占用着CPU时间,导致socket2和socket3都不能及时得到处理,从表现上看,就是响应变慢了。

如果CPU是单核的还好,充分的利用了CPU内核,但是如果CPU是多核的,这种情况就会导致其他内存处于闲置状态,造成资源浪费。

所以,Node.js不适合CPU密集型任务。

Node.js适合请求和响应内容小,无需大量计算逻辑的场景,这能够充分发挥Node.js运行模式的优势。类似的场景有聊天程序。

2、Apache

Apache于1995年首次发布,并迅速占领了市场,成为世界上最受欢迎的Web服务器。配合世界上最好的语言——PHP搭建网站,在那个年代可谓是打遍天下无敌手。

这里我们来探讨下Apache Web服务器使用的两个工作模型:

Apache使用到了 Multi Processing Module 模块(MPM)来实现多进程或者多线程处理器。

2.1、Apache MPM Prefork

一句话总结: Prefork是一个非线程型的、预派生的MPM

这种模型是 每个请求一个进程 的模型,由一个父进程创建了许多子进程,这些子进程等待请求的到达并且进行处理,每个请求均由单独的进程进行处理。

需要注意的是,每个进程都会使用RAM和CPU等系统资源,使用的RAM数量都是相等的。如果同时有很多请求,那么apache会产生很多子进程,这将导致大量的资源利用率。

2.2、Apache MPM Worker

一句话总结: Worker是支持混合的多线程多进程的MPM 。如下图:

子进程借助内部固定数量的线程来处理请求,该数量由配置文件中的参数“ThreadsPerChild 指定。

该模型一般使用多个子进程,每个子进程有多个线程,每个线程在某个确定的时间只处理一个连接,消耗内存较少。这种Apache模型可以用较少的系统资源来满足大量请求,因为这种模型下,有限数量的进程将为许多请求提供服务。

PHP攻城狮提问题:为啥mod_php中不能使用MPM Worker?

即使是一个请求用一个线程,Apache在高并发场景下,运行效率也是很差的。因为,如果一个请求需要数据库中的一些数据以及磁盘中的文件等涉及到IO操作的处理,则该线程将进入等待。因此, Apache中的某些线程(Worker模式)或者进程(Prefork模式)只是停下来下来等待某些任务完成,这些线程或者进程吃掉了系统资源。

而接下来我们介绍对并发场景处理更高效的主角:Nginx, 从根本上说,Apache和Nginx差别很大。Nginx的诞生是为了解决Apache中的c10k问题。

想象以下,从猪圈里冲出一群猪,Apache Server能够抵挡得住吗,也许不行,但是,Nginx,一定可以。这就是Nginx的强大之处。

3、Nginx

Nginx是一种开源Web服务器,自从最初作为Web服务器获得成功以来,现在还用作反向代理,HTTP缓存和负载均衡器。

Nginx旨在提供 低内存使用率 和高并发性。Nginx不会为每个Web请求创建新的流程,而是使用异步的,事件驱动的方法,在单个线程中处理请求。

3.1、Nginx的进程模型

3.1.1、Nginx的进程数

我们在操作系统各种启动Nginx之后,一般会发现几个Nginx进程,如下图:

这里有一个master进程,3个workder进程。为什么启动Nginx会有3个worker进程呢,这是因为我们在配置文件中指定了工作进程数:

worker_processes  3;

3.2、进程模型

Nginx是多进程模型,在启动Nginx之后,以daemon的方式在后台运行,后台进程包含一个Master进程和多个worker进程,模型如下:

CM: Cache Manager, CL Cache Loader

Master进程主要用于管理Worker进程,主要负责如下功能:

Worker进程主要用于处理网络事件,我们一般设置的Worker进程数为机器的CPU核数,以最有效的利用硬件资源。为此,可以进行如下配置:

worker_processes auto;

通过使用共享缓存来实现子进程的缓存,会话持久性,限流,会话日志等。

3.3、工作原理

大致来说,Master进程执行以下步骤:

socket();
bind();
listen();
fork();

fork出若干个Worker进程,Worker仅执行以下步骤:

accept();  // accept_mutex锁
register IO handler;
epoll() or kqueue();
handle_events();
...

accept_mutex锁作用:保证同一时刻只有一个Worker进程在accept连接,从而解决惊群问题。当客户连接到达时候,只有成功获取到了锁的进程才会执行accept。

惊群问题:一个程序派生出N个子进程,它们各自调用accept并因此而被投入内核睡眠。当第一个客户连接到达的时候,所有N个子进程均被唤醒,这是因为所有子进程所用的监听描述符指向了同一个socket结构。尽管有N个子进程被唤醒,但是只有最先运行的子进程获得那个客户连接,其余的N-1个子进程继续恢复睡眠。

我们重点来看看Worker进程的工作原理。

3.3.1、Worker进程工作原理

每个Worker进程都是运行于非阻塞、事件驱动的Reactor模型。

一个客户端请求在服务端的大致处理流程如下图所示:

单线程版本的Reactor模型:

而Worker进程中基本的处理逻辑则如上图所示:

3.3.2、如何处理繁重的工作?

与Node.js类似,Nginx中也会有一些繁重的工作。比如第三方模块中使用了阻塞调用,有时候该模块开发人员都没有意识到这个阻塞调用的缺点,如果直接在Worker进程中执行,就会导致整个事件处理周期都被阻塞了,必须等待操作完成才可以继续处理后续的事先。显然,这不是我们期望的效果。

使用线程池机制解决繁重工作或者第三方阻塞操作性能问题

以下操作可能导致Nginx进入阻塞状态:

以上这些情况都需要执行比较长的时间,遇到这种情况,Nginx会将需要执行很长时间的任务放入线程池处理队列中,通过线程池异步处理这些任务:

通过引入线程池,从而消除了对Worker进程的阻塞,将Nginx的性能提升到了新的高度。更加重要的是,以前那些与Nginx不兼容的第三方类库,都可以相对容易的使用,并且不影响Nginx的性能。

我们在更新完Nginx的配置之后,一般执行以下命令即可:

nginx -s reload

这行命令会检查磁盘上的配置,并向主进程发送SIGNUP信号。

主进程收到SIGNUP信号时,会执行如下操作:

这种重新加载配置过程可能导致CPU的内存使用量小幅度提升,但是这个性能牺牲是值得的。

3.4、优雅的升级

Nginx的二进制升级过程也实现了不停服的效果。

升级过程与政策重新加载配置的方法类型,新的Nginx主进程与原始主进程并行运行,他们共享监听套接字,两个进程都处于活动状态,他们各自的工作进程都在处理流量,然后可以可以指示旧的Master和Worker进程正常退出。

3.5、Nginx的优势

在每个请求一个进程,阻塞式的连接方法中,每个连接都需要大量额外的资源开销,并且会导致频繁的上下文切换;可以尽可能消耗少的内存,每个连接几乎没有额外的开销,Nginx进程数可以设置为CPU核心数,上下文切换相对较少。

那么问题来了,我们自己写网络程序的时候,有没有可以帮助我们提高网络性能的程序框架呢?有,那就是大名鼎鼎的Netty,接下来就来说他。

4、Netty

4.1、Netty主从Reactor模式

Netty也不例外,是基于Reactor模型设计和开发的。

Netty采用了主从Reactor模式,主Reactor只负责建立连接,获取已连接套接字,然后把已连接套接字的IO事件转给从Reactor线程进行处理。

我们先来大致讲讲Netty中的几个与Reactor有关的抽象概念:

具体上,Netty抽象出了以下模型进行实现Reactor主从模式:

Netty基于Pipeline管道的模式来处理Channel事件,从Netty的使用API中也可以了解到。

4.2、Netty主从Reactor+Worker线程池模式

为了降低具体业务逻辑对从Reactor的影响,我们可以单独把业务逻辑处理放到一个线程池中处理,这样无论是对于监听套接字的事件处理,还是对于已连接套接字事件的处理,都不会因为业务处理程序而导致阻塞了,如下图所示,更详细的说明参考我的博客 IT宅(itzhai.com) 或者公众号 Java架构杂谈(itread) 中的文章更新 网络编程范式:高性能服务器就这么回事 | C10K,Event Loop,Reactor,Proactor :

我们可以通过创建一个 DefaultEventExecutorGroup 线程池来处理业务逻辑。

大致程序框架如下图所示:

// 声明一个bossGroup作为主Reactor,本质是一个线程池,每个线程是一个EventLoop
EventLoopGroup bossGroup = new NioEventLoopGroup();
// 声明一个workerGroup作为从Reactor,本质是一个线程池,每个线程是一个EventLoop
EventLoopGroup workerGroup = new NioEventLoopGroup();
// 构建业务处理Group
DefaultEventExecutorGroup defaultEventExecutorGroup =
        new DefaultEventExecutorGroup(10,
        new ThreadFactory() {
            private AtomicInteger threadIndex = new AtomicInteger(0);
            @Override
            public Thread newThread(Runnable r) {
                return new Thread(r, "BusinessThread-" + this.threadIndex.incrementAndGet());
            }
        });

try {
    // 创建服务端启动类
    ServerBootstrap bootstrap = new ServerBootstrap();
    bootstrap.group(bossGroup, workerGroup)
            .channel(NioServerSocketChannel.class)
            ...
            .childHandler(new ChannelInitializer<SocketChannel>() {
                @Override
                protected void initChannel(SocketChannel ch) throws Exception {
                    // 在pipeline中添加自定义的handler
                    ch.pipeline().addLast(defaultEventExecutorGroup, new BizHandler());
                }
            });
    ChannelFuture future = bootstrap.bind(port).sync();
    future.channel().closeFuture().sync();
} finally {
    bossGroup.shutdownGracefully();
    workerGroup.shutdownGracefully();
}

Netty是基于NIO的,而Java中的NIO是JDK1.4开始支持,内部是基于IO多路复用实现的,具体的实现思路不再详细说,底层都是IO复用技术,通过Channel借助于Buffer处理感知到的IO事件。

有了NIO为啥还要有Netty?

这是两个不同层次的东西,NIO只是一个IO类库,实现同步非阻塞IO,而Netty是基于NIO实现的高性能网络框架,基于主从Reactor设计的。

NIO类库API复杂,需要处理多线程编程,自己写Reactor模式,并且客户端断线重连、半包读写,失败缓存、网络阻塞和异常码流等问题处理起来难度很大。而Netty对于NIO遇到的这些问题都做了很好的封装,主要优点体现在:

5、Redis

相信大家已经听过无数遍“Redis是单线程的”这句话了, Redis真的是单线程的吗,又是如何支撑那么大的并发量 ,并且运用到了这么多的互联网应用中的呢?

其实,Redis的单线程指的是Redis内部会有一个主处理线程,充分利用了非阻塞、IO多路复用模型,实现的一个Reactor架构。但是在某些情况下,Redis会生成线程或者子进程来执行某些比较繁重的任务。

5.1、Redis线程模型

还是那个Reactor模型,只不过我们再次踏入了不同的国界,于是又出现了一种新的的表述方式。

Redis基于Reactor模型开发了网络事件处理器,这个处理器被称为文件事件处理器。不过叫什么不重要,重要的是原理都是一样的。以下是Redis的线程模型:

这个图基本上涵盖了Redis进程处理的主要事情:

大致一个交互流程就这样完成了,是不是很简单呢。

5.2、为啥Redis单线程也这么高效?

前面已经讲了这么多Reactor模式的好处,相信大家心里也有个底了,大致总结下:

对于开发人员来说,最关注的一点就是:单线程降低了开发的复杂度,再也不需要处理各种静态条件了,就连Hash的惰性Rehash,Lpush等线程不安全的命令都可以进行无锁编程了。

5.3、Redis真的是单线程的吗?

我再问一句大家,Redis真的是单线程的吗,从Reactor模型上来说,单线程肯定会存在瓶颈的;

为此,Redis引入了多线程机制。

Redis 4.0初步引入多线程

在Redis 4.0中,Redis开始使具有更多线程。这个版本仅限于在后台删除对象,其中包括非阻塞的删除操作。UNLINK操作,只会将键从元数据中删除,并不会立刻删除数据,真正的删除操作会在一个后台线程异步执行。

Redis 6.0真正引入多线程

虽然基于Reactor模型,单线程也可以支持很大的并发量,但是要是IO读写多了,待处理的已连接套接字多了,需要执行的命令也多了,那么,单线程依旧是瓶颈,这个时候我们就要引入主从Reactor模型,甚至主从Reactor模型+Worker线程池了。

在Redis 6.0中,如果要开启多线程,可以进行设置:

io-threads 线程数 
io-threads-do-reads yes  // 默认IO线程只会用于写操作,如果要在读操作和协议解析的时候启用IO线程,则可以设置该选项为yes,但是Redis团队声称它并没有多大帮忙

不过呢,Redis为了避免产生线程并发安全的问题,在执行命令阶段仍然是单线程顺序执行的,只是在网络数据读写和协议解析阶段才用到了多线程。

为了进一步了解这个特性,我们可以阅读以下 redis.conf配置文件的说明。在这里,这个特性被命名为: THREADED I/O ,下面是翻译整理自里面的一些说明。

THREADED I/O

Redis大多是单线程的,但是有一些线程操作,例如UNLINK,执行缓慢的I/O访问等是在后台线程上执行的操作。

将io-threads设置为1只会像传统一样只启用单线程

使用8个以上的线程不会有太大帮助,并且建议实际存在性能问题的时候才使用IO线程,否则就没有必要使用了。

启用IO线程后,我们仅将IO线程用于写操作,即对write(2)系统调用进行线程化并将客户端缓冲区传输到套接字。 但是,也可以使用以下配置指令通过以下方式启用读取线程和协议解析:

io-threads-do-reads yes

通常,线程读取没有太大帮助。

Redis用的是类似单线程版的Reactor + IO线程池(Worker线程池),不过与我们前面提到的单线程Reactor + Worker线程池模式有所不同,再回顾下Reactor + Worker线程池模式:

Redis是在所谓的Reactor线程(主线程)中把IO读事件一批一批地交给IO线程池进行读取,读取完毕之后,统一执行所有请求的命令,然后才是一次性把所有请求的响应写到socket,如下图所示:

等待队列中的待处理时间平均分给每个IO线程,IO线程池只是负责IO读写和解析数据,IO线程池充分利用了CPU多核处理的能力,提高了IO读写速度。

Redis 6.0真的是单线程的吗?

6、Tomcat

作为一个Java程序员,怎么能不认识Tomcat呢,Tomcat的线程模型又是怎样的?不用往下看,我们都能猜出Tomcat肯定会利用Reactor模式来优化网络处理,不过这个优化过程却是跟随者技术的发展慢慢演变的。

6.1、Tomcat整体架构

Tomcat是HTTP服务器,同时还是一个Servlet容器,可以执行Java Servlet,并将JavaServer Pages(JSP)和JavaServerFaces(JSF)转换为Java Servlet。

我们先来看看Tomcat各个组件的整体架构。Tomcat采用了分层和模块化的体系结构,如下所示,这个结构有点像套娃,一层套一层的,这也同时是Tomcat server.xml配置文件的层级结构:

Server是顶层组件,代表着一个Tomcat实例,在配置文件中一般如下:

<Server port="8005" shutdown="SHUTDOWN"> ...... </Server>

Server下面可以包含多个Service,每个服务都有自己的Container和Connector。

6.1.1、Container

Container用于管理各种Servlet,处理Connector传过来的Request请求。

大家可以看到,Container内部若隐若现的好像还有内幕..是的,上图中我把内幕隐藏起来了,接口Container内部,我们可以看到这样的结构:

6.1.2、Connector

Connector用于处理请求,处理Socket套接字,把原始的网络数据包装成Request对象给Container进行处理,并封装Response对象用于响应套接字输出。

如上图,一个Service可以有多个Connector, 每个Connector实现不同的连接协议,通过不同的端口提供服务。

这里已经看到我们要关注的重点了,是的,Connector就是处理网络的关键模块,这个模块的效率直接决定了Tomcat的性能!!!

接下来,我们打开Connector潘多拉的盒子,看看里面究竟有什么不可告人的秘密。

话不多说,我直接上图,这么爽快不断附图片的博客还真不多,IT宅(itzhai.com)的Java架构杂谈 算一个,重点来了,这里我们先列出传统的BIO运行模型的组件图:

其中 ProtocolHandler 中主要的组件有:

EndPoint
Processor

不过既然知道EndPoint是直接负责对接套接字Api的,那我们就知道了核心的网络编程性能关键就在EndPoint这个组件里面,在这里可以使用各种IO编程范式来进行网络性能优化。EndPoint里面又有几个抽象概念:

Acceptor
Handler
AsyncTimeout

既然EndPoint组件是网络处理关键的性能所在,我们就重点来看看这块的设计吧。

6.2、Tomcat连接器性能分析

首先来看看传统的BIO线程模型。

6.2.1、Tomcat之BIO线程模型

BIO线程模型即传统的以多线程处理请求的方式获取到一个新的已连接套接字之后,都丢到线程池里面,交给一个线程处理,从读取IO数据,处理业务,到响应IO数据都是在同一个线程中处理。如下图,我只把相关的组件给画出来:

如上图,Acceptor线程获取到新的已连接套接字之后,直接把新的已连接套接字交给Executor线程池进行处理。

这种模式,受能够创建线程数的限制,导致不能支撑很大并发,并且越多的因IO导致阻塞的线程,会导致越多的线程上下文切换,浪费了系统资源。

接下来我们看看NIO线程模型,该模型基于 主从Reactor + Worker线程池 网络编程模型。

6.2.2、Tomcat之NIO线程模型

对应的实现类为: Http11NioProtocol ,同步非阻塞IO实现的HTTP/1.1协议处理器,Tomcat 8默认采用该模式。

以下是该模型的组件架构图:

其中Poller线程中维护了一个Selector对象,用来实现基于NIO网络事件处理。

基于NIO的Tomcat,避免了由于IO导致的阻塞,减少了线程开销,以及线程上下文切换开销,能够支撑更大的并发量。

6.2.3、Tomcat之NIO2线程模型

Http11Nio2Protocol :异步IO实现的HTTP/1.1协议处理器,Tomcat 8之后开始支持,基于Java的AIO API实现的异步IO。

相关组件架构图如下:

对应的异步IO处理类是Nio2EndPoint,获取已连接套接字的类为Nio2Acceptor。

由于IO异步化了,所以Nio中的Poller类也就没有了存在的必要。不管是accept获取已连接套接字还是IO读写,都改为了异步处理,当可以做IO操作的时候,会由Java异步IO框架调用对应IO操作的 CompletionHandler 类进行后续处理。

这里的SocketProcessor实现了Runnable接口,其中的run方法即是原本丢给Worker线程处理的,包括IO读写。但是现在,SocketProcessor再也不需要多一次IO操作的系统调用开销了。

6.2.4、Tomcat之APR线程模型

我们再简要介绍下,APR,对应的实现为 Http11AprProtocol :apr(Apache Portable Runtime/Apache可移植运行时),是一个高度可移植的库,它是Apache HTTP Server 2.x的核心。

在Tomcat中使用APR库,其实就是在Tomcat中使用JNI的方式来读取文件以及进行网络传输,可以大大提升Tomcat对静态文件的处理性能。如果服务开启了HTTPS的话,也可以提升SSL的处理性能。

7、MySQL

7.1、MySQL线程模型

首先我们来看一个参数: thread_handling ,一个控制MySQL连接线程的参数,它有以下三个取值:

no-threads
one-thread-per-connection
loaded-dynamically

看起来,MySQL并没有使用Reactor或者Proactor优化网络IO效率。

那么我们就来看看传统的一个请求创建一个线程的模型下,MySQL内部是如何工作的吧,如下是该线程模型工作图示:

7.2、限制MySQL并发效率的因素

限制MySQL并发效率的主要因素主要有互斥锁、数据库锁或IO。

为什么MySQL没有使用Reactor模式优化IO?

关于这个问题,我想主要有以下原因:MySQL的架构设计,就决定了在通过索引查找数据的过程中,需要不断地加载数据页,采用Reactor模式,编码复杂度将更高。

鲁迅说:真的不考虑以下其他的数据库吗?

7.3、InnoDB对并发流量的守卫战

基于以上提及的MySQL性能问题,InnoDB存储引擎做了一些防守:在有助于最大程度地减少线程之间的上下文切换的情况下, InnoDB 可以使用多种技术来限制并发线程数。当 InnoDB 从用户会话接收到新请求时,如果同时执行的线程数已超预定义限制,则新请求将休眠一小段时间,然后再次尝试。睡眠后无法重新安排的请求被放入先进/先出队列,并最终得到处理。

涉及的参数:

通过这种设计, 尽可能地让一次查询请求尽快地完成(如一次join查询操作,可能包含多个InnoDB查询请求),而不会导致频繁的InnoDB线程上下文切换开销 。

8、Zuul

既然是Netflix开源的微服务网关,先来看看Zuul 1的性能情况。

这是一个多线程的系统架构。Zuul 1是基于Servlet构建的。IO操作是通过从线程池中获取一个线程来执行IO来完成的,在执行IO操作的过程中,请求线程被阻塞。

当后端延迟增加或者由于错误而导致请求重试,活动的链接和请求线程数就会增加,这种情况下可能会导致服务负载激增,为了抵御这些风险,于是便有了Hystrix熔断器,用于提供过载保护。

Zuul 2内部也是用到了事件循环。在异步的运行方式下,通常每个CPU内核对应一个线程,用于处理所有的请求和响应,请求和响应通过事件和回调进行处理。

因为每个连接不用创建新的线程,只需要付出文件描述符和监听器的成本,所以连接的成本很低。

在异步模式下,队列中的连接和事件的增加成本远低于线程堆积的成本。但是假设后端处理不过来,响应时间还是会不可避免的增加。

以上就是有关8大主流服务器的学习笔记,希望可以对大家学习有所帮助,喜欢的小伙伴可以帮忙转发+关注,感谢大家!LZ也会不定时地更新干货,以此来帮助大家的学习,充实自己!

原文链接:https://www.tuicool.com/articles/UbIFVfn

上一篇下一篇

猜你喜欢

热点阅读