• Tomcat 调优之从 Linux 内核源码层面看 Tcp backlog


    前两天看到一群里在讨论 Tomcat 参数调优,看到不止一个人说通过 accept-count 来配置线程池大小,我笑了笑,看来其实很多人并不太了解我们用的最多的 WebServer Tomcat,这篇文章就来聊下 Tomcat 调优,重点介绍下线程池调优及 TCP 半连接、全连接队列调优

    Tomcat 线程池

    先来说下线程池调优,就拿 SpringBoot 内置的 Tomcat 来说,确实是支持线程池参数配置的,但不是 accept-count 参数,可以通过 threads.max 和 threads.minSpare 来配置线程池最大线程数和核心线程数。

    如果没有设置,则会使用默认值

    threads.max: 200
    threads.minSpare: 10
    
    • 1
    • 2

    Tomcat 底层用到的 ThreadPoolExecutor 也不是 JUC 原生的线程池,而是自定义的,做了一些调整来支持 IO 密集型场景使用,具体介绍可以看之前写的两篇文章。

    动态线程池(DynamicTp),动态调整 Tomcat、Jetty、Undertow 线程池参数篇

    以面试官视角万字解读线程池 10 大经典面试题!

    通过这两篇文章能了解到 Tomcat 自定义线程池的执行流程及原理,然后可以接入动态线程池框架 DynamicTp,将 Tomcat 线程池交由 DynamicTp 管理,使之能享受到动态调参、监控告警的功能。

    在配置中心配置 tomcat 线程池核心参数

    spring:
      dynamic:
        tp:
          tomcatTp:
            corePoolSize: 100
            maximumPoolSize: 400
            keepAliveTime: 60
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    Tomcat 线程池调优主要思想就是动态化线程池参数,上线前通过压测初步确定一套较优的参数值,上线后通过监控、告警实时感知线程池负载情况,动态调整参数适应流量的变化。

    线程池调优就说这些吧,下面主要介绍下 Tcp backlog 及半连接、全连接队列相关内容。

    划重点

    1. threads.max 和 threads.minSpare 是用来配置 Tomcat 的工作线程池大小的,是线程池维度的参数

    2. accept-count 和 max-connections 是 TCP 维度的配置参数

    TCP 状态机

    Client 端和 Server 端基于 TCP 协议进行通信时,首先需要经过三次握手建连的,通信结束时需要通过四次挥手断连的。注意所谓的连接其实是个逻辑上的概念,并不存在真实连接的,那 TCP 是怎么面向连接传输的呢?

    TCP 定义了个复杂的有限状态机模型,通信双方通过维护一个连接状态,来达到看起来像有一条连接的效果。如下是 TCP 状态机状态流转图,这个图非常重要,建议大家一定要掌握。图片来自 TCP 状态机

    1. 图上半部分描述了三次握手建立连接过程中状态的变化

    2. 图下半部分描述了四次挥手断开连接过程中状态的变化

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Ftp5P6BD-1666148527089)(https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/e8567e4b8a1a4bdcb0a1262fc50d6886~tplv-k3u1fbpfcp-zoom-1.image)]

    图 2 是通过三次握手建立连接的过程,老八股文了,建议结合图 1 状态机变化图看,图片来源三次握手

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-N1vrAaM4-1666148527090)(https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/c6493c9c56744cb68978c8d2f171aad8~tplv-k3u1fbpfcp-zoom-1.image)]

    图 3 是通过四次挥手断开连接的过程,建议结合图 1 状态机变化图看,图片来源四次挥手

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-sJamxueF-1666148527091)(https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/2a61e6f00dad4173a9db2aabb97ae845~tplv-k3u1fbpfcp-zoom-1.image)]

    服务端程序调用 listen() 函数后,TCP 状态机从 CLOSED 转变为 LISTEN,并且 linux 内核会创建维护两个队列。一个是半连接队列(Syn queue),另一个是全连接队列(Accept queue)。

    建连主要流程如下:

    1. 客户端向服务端发送 SYN 包请求建立连接,发送后客户端进入 SYN_SENT 状态

    2. 服务端收到客户端的 SYN 请求,将该连接存放到半连接队列(Syn queue)中,并向客户端回复 SYN + ACK,随后服务端进入 SYN_RECV 状态

    3. 客户端收到服务端的 SYN + ACK 后,回复服务端 ACK 并进入 ESTABLISHED 状态

    4. 服务端收到客户端的 ACK 后,从半连接队列中取出连接放到全连接队列(Accept queue)中,服务端进入 ESTABLISHED 状态

    5. 服务端程序调用 accept() 方法,从全连接队列中取出连接进行处理请求

    连接队列大小

    上述提到了半连接队列、全连接队列,这两队列都有大小限制的,超过的连接会被丢掉或者返回 RST 包。

    半连接队列大小主要受:listen backlog、somaxconn、tcp_max_syn_backlog 这三参数影响

    全连接队列大小主要受:listen backlog 和 somaxconn 这两参数影响

    tcp_max_syn_backlog 和 somaxconn 都是 linux 内核参数,在 /proc/sys/net/ipv4/ 和 /proc/sys/net/core/ 下,可以通过 /etc/sysctl.conf 文件来修改,默认值为 128。

    listen backlog 参数其实就是我们调用 listen 函数时传入的第二个参数。回到主题,Tomcat 的 accept-count 其实最后就会传给 listen 函数做 backlog 用。

    int listen(int sockfd, int backlog);
    
    • 1

    可以在配置文件中配置 tomcat accept-count 大小,默认为 100

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-g4GaFUyY-1666148527091)(https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/1b66dbeff4a54f958036865483b7b644~tplv-k3u1fbpfcp-zoom-1.image)]

    以下代码注释中也注明了 acceptCount 就是 backlog

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-HTIy4ZqT-1666148527091)(https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/b0c76f9d13a74f4b8647dfb8b8cf8a57~tplv-k3u1fbpfcp-zoom-1.image)]

    以 Nio2Endpoint 为例看下代码,bind 方法首先会根据配置的核心线程数、最大线程数创建 worker 线程池。然后调用 jdk nio2 中的 AsynchronousServerSocketChannelImpl 的 bind 方法,该方法内会调用 Net.listen() 进行 socket 监听。通过这几段代码,我们可以清晰的看到 Tomcat accept-count = Tcp backlog,默认值为 100。

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-jEUx9fwO-1666148527092)(https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/3b8acfe4bd5f45d18cd0876d1da48348~tplv-k3u1fbpfcp-zoom-1.image)]

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-DukON1WX-1666148527092)(https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/4e18aea94fe64475938eb6ab90ed4207~tplv-k3u1fbpfcp-zoom-1.image)]

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-jP6PkqId-1666148527092)(https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/89e2b531ae444fee9bc03e2bbecd5004~tplv-k3u1fbpfcp-zoom-1.image)]

    上面说到了半全两个连接队列,至于这两个连接队列大小怎么确定,其实不同 linux 内核版本算法也都不太一样,我们就以 v3.10 来看。

    以下是 linux 内核 socket.c 中的源码,也就是我们调用 listen() 函数会执行的代码

    /*
     * Perform a listen. Basically, we allow the protocol to do anything
     * necessary for a listen, and if that works, we mark the socket as
     * ready for listening.
     */
    SYSCALL_DEFINE2(listen, int, fd, int, backlog)
    {
        struct socket *sock;
        int err, fput_needed;
        int somaxconn;
    
        sock = sockfd_lookup_light(fd, &err, &fput_needed);
        if (sock) {
                somaxconn = sock_net(sock->sk)->core.sysctl_somaxconn;
                if ((unsigned int)backlog > somaxconn)
                        backlog = somaxconn;
    
                err = security_socket_listen(sock, backlog);
                if (!err)
                        err = sock->ops->listen(sock, backlog);
    
                fput_light(sock->file, fput_needed);
        }
        return err;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25

    可以看到,此处会拿内核参数 somaxconn 和 传入的 backlog 做比较,取二者中的较小者作为全连接队列大小。

    全连接队列大小 = min(backlog, somaxconn)。

    接下来 backlog 会依次传递给如下函数,格式约定(源代码文件名#函数名)

    af_inet.c#inet_listen() -> inet_connection_sock.c#inet_csk_listen_start() -> request_sock.c#reqsk_queue_alloc()

    reqsk_queue_alloc() 函数代码如下,主要就是用来计算半连接队列大小的。

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-nnSOoQBM-1666148527093)(https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/e10ef1a9810045f19418c4b8bf81f8c2~tplv-k3u1fbpfcp-zoom-1.image)]

    计算逻辑可以简化为下述公式,简单描述 roundup_pow_of_two 算法就是向上取最接近的最大 2 的指数次幂,注意此处 backlog 已经是 min(backlog, somaxconn)

    半连接队列大小 = roundup_pow_of_two(max(8, min(backlog, tcp_max_syn_backlog))+1)

    代码里 max_qlen_log 在一个 for 循环里计算,比如算出的半连接队列大小 nr_table_entries = 16 = 2^4,那么 max_qlen_log = 4,该值在判断半连接队列是否溢出时会用到。

    举个例子,如果 listen backlog = 10、somaxconn = 128、tcp_max_syn_backlog = 128,那么半连接队列大小 = 16,全连接队列大小 = 10。

    所以要知道,在做连接队列大小调优的时候,一定要综合上述三个参数,只修改某一个起不到想要的效果。

    连接队列大小查看

    全连接队列大小

    可以通过 linux 提供的 ss 命令来查看全连接队列的大小

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-bOeqixZk-1666148527093)(https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/501cd78e53124904bbece23a3498924c~tplv-k3u1fbpfcp-zoom-1.image)]

    参数说明,参数很多,其他参数可以自己 help 查看说明

    l:表示显示 listening 状态的 socket

    n:不解析服务名称

    t:只显示 tcp sockets

    这个命令结果怎么解读呢?

    主要看前三个字段,Recv-Q 和 Send-Q 在 State 为 LISTEN 和非 LISTEN 状态时代表不同的含义。

    State: LISTEN

    Recv-Q: 全连接队列的当前长度,也就是已经完成三次握手等待服务端调用 accept() 方法获取的连接数量

    Send-Q: 全连接队列的最大长度,也就是我们上述分析的 backlog 和somaxconn 的最小值

    State: 非 LISTEN

    Recv-Q: 已接受但未被应用进程读取的字节数

    Send-Q: 已发送但未收到确认的字节数

    以上区别从如下内核代码也可以看出,ss 命令就是从 tcp_diag 模块获取的数据

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-odJax2VV-1666148527093)(https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/c90446527f654a04b85a13b3fea9beb5~tplv-k3u1fbpfcp-zoom-1.image)]

    半连接队列大小

    半连接队列没有像 ss 这种命令直接查看,但服务端处于 SYN_RECV 状态的连接都在半连接队列里,所以可以通过如下命令间接统计

    netstat -natp | grep SYN_RECV | wc -l
    
    • 1

    半连接队列最大长度可以使用我们上述分析得到的公式计算得到

    半全连接队列溢出

    全连接队列溢出

    当请求量很大,全连接队列比较小时,就有可能发生全连接队列溢出的情况。

    此代码是 linux 内核用来判断全连接队列是否已满的函数,可以看到判断用的是大于号,这也就是我们用 ss 命令可能会看到 Recv-Q > Send-Q 的原因

    1. sk_ack_backlog 是当前全连接队列的大小

    2. sk_max_ack_backlog 是全连接队列的最大长度,也就是 min(listen_backlog, somaxconn)

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-dTZVOzd0-1666148527094)(https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/4d72013dd4a5410ab053f25aaf3b0172~tplv-k3u1fbpfcp-zoom-1.image)]

    当全连接队列满了发生溢出时,会根据 /proc/sys/net/ipv4/tcp_abort_on_overflow 内核参数来决定怎么处理后续的 ack 请求,tcp_abort_on_overflow 默认值为 0。

    1. 当 tcp_abort_on_overflow = 0 时,如果全连接队列已满,服务端会直接扔掉客户端发送的 ACK,此时服务端处于 SYN_RECV 状态,客户端处于 ESTABLISHED 状态,服务端的超时重传定时器会重传 SYN + ACK 包给客户端(重传次数由/proc/sys/net/ipv4/tcp_synack_retries 指定,默认值为 5,重试间隔为 1s、2s、4s、8s、16s,共 31s,第 5 次发出后还要等 32s 才知道第 5 次也超时了,所以总共需要 63s)。超过 tcp_synack_retries 后,服务端不会在重传,这时如果客户端发送数据过来,服务端会返回 RST 包,客户端会报 connection reset by peer 异常

    2. 当 tcp_abort_on_overflow = 1 时,如果全连接队列已满,服务端收到客户端的 ACK 后,会发送一个 RST 包给客户端,表示结束掉这个握手过程和这个连接,客户端会报 connection reset by peer 异常

    一般情况下 tcp_abort_on_overflow 保持默认值 0 就行,能提高建立连接的成功率

    半连接队列溢出

    我们知道,服务端收到客户端发送的 SYN 包后会将该连接放入半连接队列中,然后回复 SYN+ACK,如果客户端一直不回复 ACK 做第三次握手,这样就会使得服务端有大量处于 SYN_RECV 状态的 TCP 连接存在半连接队列里,超过设置的队列长度后就会发生溢出。

    下述代码是 linux 内核判断是否发生半连接队列溢出的函数

    // 代码在 include/net/inet_connection_sock.h 中
    static inline int inet_csk_reqsk_queue_is_full(const struct sock *sk)
    {
        return reqsk_queue_is_full(&inet_csk(sk)->icsk_accept_queue);
    }
    
    // 代码在 include/net/request_sock.h 中
    static inline int reqsk_queue_is_full(const struct request_sock_queue *queue)
    {
       /*
        * qlen 是当前半连接队列大小
        * max_qlen_log 上述解释过,如果半连接队列大小 = 16 = 2^4,那么该值就是4
        * 非常巧妙的用了移位运行来判断半连接队列是否溢出,底层满满的都是细节
        */
        return queue->listen_opt->qlen >> queue->listen_opt->max_qlen_log;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16

    我们常说的 SYN Flood 洪水攻击 是一种典型的 DDOS 攻击,就是利用了这个点,给服务端发送一个 SYN 包后客户端就下线了,服务端会超时重传 SYN+ACK 包,上述也说了总共需要 63s 才停止重传,也就是说服务端需要经过 63s 后才断开该连接,这样就会导致半连接队列快速被耗尽,不能处理正常的请求。

    那是怎么防止攻击的呢?

    linux 提供个一个内核参数 /proc/sys/net/ipv4/tcp_syncookies 来应对该攻击,当半连接队列满了且开启 tcp_syncookies = 1 配置时,服务端在收到 SYN 并返回 SYN+ACK 后,不将该连接放入半连接队列,而是根据这个 SYN 包 TCP 头信息计算出一个 cookie 值。将这个 cookie 作为第二次握手 SYN+ACK 包的初始序列号 seq 发过去,如果是攻击者,就不会有响应,如果是正常连接,客户端回复 ACK 包后,服务端根据头信息计算 cookie,与返回的确认序列号进行比对,如果相同,则是一个正常建立连接。

    下述代码是计算 cookie 的函数,可以看到跟这些字段有关(源 ip、源端口、目标 ip、目标端口、客户端 syn 包序列号、时间戳、mssind)

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-vOoHdR6g-1666148527094)(https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/f63e6a2ff90d4a49be16f619d949d6e1~tplv-k3u1fbpfcp-zoom-1.image)]

    下面看下第一次握手,收到 SYN 包后服务端的处理代码,代码太多,简化提出跟半连接队列溢出相关代码

    int tcp_v4_conn_request(struct sock *sk, struct sk_buff *skb)
    {
       /*
        * 如果半连接队列已满,且 tcp_syncookies 未开启,则直接丢弃该连接
        */
        if (inet_csk_reqsk_queue_is_full(sk) && !isn) {
            want_cookie = tcp_syn_flood_action(sk, skb, "TCP");
            if (!want_cookie)
                    goto drop;
        }
    
       /*
        * 如果全连接队列已满,并且没有重传 SYN+ACk 包的连接数量大于1,则直接丢弃该连接
        * inet_csk_reqsk_queue_young 获取没有重传 SYN+ACk 包的连接数量
        */
        if (sk_acceptq_is_full(sk) && inet_csk_reqsk_queue_young(sk) > 1) {
            NET_INC_STATS_BH(sock_net(sk), LINUX_MIB_LISTENOVERFLOWS);
            goto drop;
        }
    
        // 分配 request sock 内核对象
        req = inet_reqsk_alloc(&tcp_request_sock_ops);
        if (!req)
            goto drop;
    
        if (want_cookie) {
            // 如果开启了 tcp_syncookies 且半连接队列已满,则计算 cookie
            isn = cookie_v4_init_sequence(sk, skb, &req->mss);
            req->cookie_ts = tmp_opt.tstamp_ok;
        } else if (!isn) {
             /* 如果没有开启 tcp_syncookies 并且 max_syn_backlog - 半连接队列当前大小 < max_syn_backlog >> 2,则丢弃该连接 */
            else if (!sysctl_tcp_syncookies &&
                     (sysctl_max_syn_backlog - inet_csk_reqsk_queue_len(sk) <
                      (sysctl_max_syn_backlog >> 2)) &&
                     !tcp_peer_is_proven(req, dst, false)) {
                LIMIT_NETDEBUG(KERN_DEBUG pr_fmt("drop open request from %pI4/%u\n"),
                               &saddr, ntohs(tcp_hdr(skb)->source));
                goto drop_and_release;
            }
            isn = tcp_v4_init_sequence(skb);
        }
        tcp_rsk(req)->snt_isn = isn;
        // 构造 syn+ack 响应包
        skb_synack = tcp_make_synack(sk, dst, req,
            fastopen_cookie_present(&valid_foc) ? &valid_foc : NULL);
        if (likely(!do_fastopen)) {
            int err;
            // 发送 syn+ack 响应包
            err = ip_build_and_send_pkt(skb_synack, sk, ireq->loc_addr,
                 ireq->rmt_addr, ireq->opt);
            err = net_xmit_eval(err);
            if (err || want_cookie)
                    goto drop_and_free;
    
            tcp_rsk(req)->snt_synack = tcp_time_stamp;
            tcp_rsk(req)->listener = NULL;
            // 添加到半连接队列,并且开启超时重传定时器
            inet_csk_reqsk_queue_hash_add(sk, req, TCP_TIMEOUT_INIT);
        } else if (tcp_v4_conn_req_fastopen(sk, skb, skb_synack, req))
            goto drop_and_free;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61

    查看溢出命令

    当连接队列溢出时,可以通过 netstart -s 命令查询

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-U2jAybIE-1666148527094)(https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/b28b9f38d09e41b7b5042be9dda94149~tplv-k3u1fbpfcp-zoom-1.image)]

      # 表示全连接队列溢出的次数,累计值
      119005 times the listen queue of a socket overflowed
    
      # 表示半连接队列溢出的次数,累计值
      119085 SYNs to LISTEN sockets dropped
    
    • 1
    • 2
    • 3
    • 4
    • 5

    如果发现这两个值一直在增加,就说明发生了队列溢出,需要看情况调大队列大小

    常用组件 backlog 大小

    1. Redis 默认 backlog = 511

    2. Nginx 默认 backlog = 511

    3. Mysql 默认 backlog = 50

    4. Undertow 默认 backlog = 1000

    5. Tomcat 默认 backlog = 100

    总结

    这篇文章以 Tomcat 性能调优为切入点,首先简单讲了下 Tomcat 线程池调优。然后借 Tomcat 配置参数 accept-count 引出了 Tcp backlog,从 linux 内核源码层面详细讲解了下 TCP backlog 参数以及半连接、全连接队列的相关知识,包括连接队列大小设置,以及队列溢出怎么排查,这些东西也是我们服务端开发需要掌握的,在性能调优,问题排查时会有一定的帮助。

    个人开源项目

    DynamicTp 是一个基于配置中心实现的轻量级动态线程池管理工具,主要功能可以总结为动态调参、通知报警、运行监控、三方包线程池管理等几大类。

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-XyUnpoIq-1666148527095)(https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/c5925af903a74793860361d6191b9b9e~tplv-k3u1fbpfcp-zoom-1.image)]

    目前累计 2k star,欢迎大家试用,感谢你的 star,欢迎 pr,业务之余一起给开源贡献一份力量

    官网https://dynamictp.cn

    gitee 地址https://gitee.com/dromara/dynamic-tp

    github 地址https://github.com/dromara/dynamic-tp

  • 相关阅读:
    Mancunian Hoards Black Money,贪心,思维
    Java---刷题01
    node.js--模块化
    【元宇宙欧米说】一个科幻 NFT,一场关于创作者经济的探索
    POJ 1990 MooFest 树状数组
    嵌入式C常见面试题
    PHP基于thinkphp的在线机票销售系统#毕业设计
    【数据结构 | 入门】 入坑篇 (浙江大学数据结构学习笔记)
    音频的“隐形保镖”——音频数字水印
    第五届“传智杯”全国大学生计算机大赛(练习赛) [传智杯 #5 练习赛] 复读
  • 原文地址:https://blog.csdn.net/qq_14926159/article/details/127403982