基于 Linux 的高性能 C++ Web 服务器开发 个人项目 2023.01 - 2023.05

• 技术栈:C++、Linux、MySQL、HTML

• 项目概述:

该个人项目是基于Linux平台的高并发低延迟的C++ Web服务器开发。项目采用Reactor模式结合I/O多路复用(epoll)和线程池技术,通过状态机高效解析HTTP请求,支持GET和POST请求方法。服务器能够处理静态资源(如图片、文本、视频)以及用户交互请求,满足现代Web服务对性能和响应速度的要求。项目中的数据库交互模块基于MySQL实现,支持用户注册和登录功能,并通过数据库连接池优化查询性能,确保数据一致性与安全性。此外,项目还实现了一个同步/异步双模式日志系统,记录服务器的运行状态与关键事件,支持日志文件按日期归档,帮助系统调试与运维。

包括项目介绍,线程池相关,并发模型相关,HTTP报文解析相关,定时器相关,日志相关,压测相关,综合能力等。

项目介绍

1. 为什么要做这样一个项目?

实验室的项目偏向于机器视觉,感觉自身对于后台开发的知识有点薄弱,故此想学习有关服务器后台开发的相关知识;

满足高并发和高性能需求:现代Web应用面对大量用户,Web服务器需要高效处理并发连接。比如通过线程池、非阻塞I/O、事件驱动机制(如epoll),Web服务器可以有效管理成千上万的并发请求,确保服务不会因高流量而崩溃或变慢。

理解网络编程:通过使用线程池、非阻塞socket、epoll等技术,项目可以帮助熟悉Linux下的网络编程模型,深入理解如何处理并发连接、如何进行事件驱动的网络通信等核心技术。

实践HTTP协议和Web服务器:构建一个能够解析HTTP请求并进行响应的Web服务器,有助于理解HTTP协议的工作原理,学会如何处理GET和POST请求,增强对Web服务端架构的理解。

提升系统优化意识:通过进行性能测试和优化(例如使用Webbench测试并发性能),这个项目还帮助你了解系统性能瓶颈、提升程序的效率、理解并实现高效的并发模型。

2. 介绍下你的项目

在这个项目中,我们采用了多种技术组合,显著提升了服务器的并发能力和整体性能。首先,通过线程池和非阻塞Socket的结合,并配合epoll机制,支持边缘触发(ET)和水平触发(LT)两种模式,构建了高效的事件驱动模型。同时,我们实现了Reactor和模拟Proactor两种事件处理模式,进一步优化了I/O操作的响应效率。

在功能实现上,服务器通过状态机解析HTTP请求报文支持GET和POST两种请求方式,确保能够稳定处理不同类型的客户端需求。我们还集成了数据库交互功能,支持用户的注册和登录操作,同时提供图片和视频文件的请求服务,满足了Web应用的基础需求。

此外,我们设计了同步和异步两种日志系统,用于记录服务器的运行状态,为性能分析和问题排查提供了全面的数据支持。在性能测试中,利用Webbench工具对服务器进行了压力测试,结果表明,服务器能够稳定支持上万并发连接的数据交换,展现了出色的扩展性和稳定性。

总体来说,这款Web服务器不仅帮助我们实践了网络编程和服务器构建的核心技术,还具有较强的实用性和性能优势,在实际生产环境中也具有一定的应用价值。通过这个项目,我积累了关于高并发服务器设计、性能优化以及系统稳定性保障的实践经验。

3. 项目的重点难点,以及怎么解决难点的

  • 项目中的重点在于应用层上HTTP协议的使用,怎么去解析HTTP请求,怎么根据HTTP请求去做出应答这样一个流程我觉得是这个项目的重点。

  • 项目的难点我觉得在于如何将一个完整的服务器拆分成多个模块,同时在实现的时候又将其各个部分功能组合在一起。首先一个HTTP服务器要实现的是完成请求应答这样一件事,然后需要考虑到读写数据的缓冲区、同时多个连接需要处理多种事件、连接超时关闭避免资源消耗等这一系列问题,所以就有了各种模块。单独编写模块出来后还要考虑怎么去组合怎么去搭配,互相之间的接口要怎么设计,我觉得是我在项目中遇到的问题。

  • 难点的解决通过自顶向下的设计方式。先确定整体的功能也就是完成一次HTTP传输,再向下考虑需要用到什么组件,然后再依次实现各个组件,最后再将模块组合起来,期间需要多次修改接口。

4. 项目实现的效果以及瓶颈和不足

在这个项目中,我将服务器部署在单核 4G 的云服务器上,并进行了压力测试,测试结果显示 QPS(每秒查询量)只有 3000 左右,实际效果并不理想。我分析后认为,性能瓶颈主要与设计模式的选择以及测试服务器的性能限制有关。

具体来说,我的服务器采用的是单Reactor多线程的网络设计模式:主线程通过 I/O 多路复用负责监听所有事件,包括新连接的建立以及读写事件的处理,然后将读写事件和业务逻辑分发给线程池处理。这种设计的一个显著问题在于,所有事件的监听和分发都由一个 Reactor 对象负责,因此,当遇到突发高并发时,Reactor 可能会成为瓶颈,无法及时处理新连接,影响了整体的并发性能。

结合测试结果和设计分析,我认为性能提升的方向可以是优化设计模式,例如改为多Reactor模式,将不同的事件处理任务分配到多个 Reactor 中,以减轻主线程的压力。同时,也可以选择更高性能的服务器硬件,避免硬件性能本身的限制。这些优化方向是我未来希望进一步尝试和改进的。

线程池相关

这个阻塞队列(Blocking Queue)非常重要 它是平衡我们消费者线程和生产者线程之间的桥梁,线程池中的线程对象从阻塞队列中获取(poll)任务,主线程往线程池中添加(put)任务。

1. 手写线程池

线程池代码

#ifndef THREADPOOL_H
#define THREADPOOL_H
​
#include <list>
#include <cstdio>
#include <exception>
#include <pthread.h>
#include "../lock/locker.h"
#include "../CGImysql/sql_connection_pool.h"
​
template <typename T>
class threadpool
{
public:
    /*thread_number是线程池中线程的数量,max_requests是请求队列中最多允许的、等待处理的请求的数量*/
    threadpool(int actor_model, connection_pool *connPool, int thread_number = 8, int max_request = 10000);
    ~threadpool();
    bool append(T *request, int state);
    bool append_p(T *request);
​
private:
    /*工作线程运行的函数,它不断从工作队列中取出任务并执行之*/
    static void *worker(void *arg);//为什么要用静态成员函数呢-----class specific
    void run();
​
private:
    int m_thread_number;        //线程池中的线程数
    int m_max_requests;         //请求队列中允许的最大请求数
    pthread_t *m_threads;       //描述线程池的数组,其大小为m_thread_number
    std::list<T *> m_workqueue; //请求队列
    locker m_queuelocker;       //保护请求队列的互斥锁
    sem m_queuestat;            //是否有任务需要处理
    connection_pool *m_connPool;  //数据库
    int m_actor_model;          //模型切换(这个切换是指Reactor/Proactor)
};
​
template <typename T>
//线程池构造函数
threadpool<T>::threadpool( int actor_model, connection_pool *connPool, int thread_number, int max_requests) : m_actor_model(actor_model),m_thread_number(thread_number), m_max_requests(max_requests), m_threads(NULL),m_connPool(connPool)
{
    if (thread_number <= 0 || max_requests <= 0)
        throw std::exception();
    m_threads = new pthread_t[m_thread_number];     //pthread_t是长整型
    if (!m_threads)
        throw std::exception();
    for (int i = 0; i < thread_number; ++i)
    {
        //函数原型中的第三个参数,为函数指针,指向处理线程函数的地址。
        //若线程函数为类成员函数,
        //则this指针会作为默认的参数被传进函数中,从而和线程函数参数(void*)不能匹配,不能通过编译。
        //静态成员函数就没有这个问题,因为里面没有this指针。
        if (pthread_create(m_threads + i, NULL, worker, this) != 0)
        {
            delete[] m_threads;
            throw std::exception();
        }
        //主要是将线程属性更改为unjoinable,使得主线程分离,便于资源的释放,详见PS
        if (pthread_detach(m_threads[i]))
        {
            delete[] m_threads;
            throw std::exception();
        }
    }
}
template <typename T>
threadpool<T>::~threadpool()
{
    delete[] m_threads;
}
​
template <typename T>
//reactor模式下的请求入队
bool threadpool<T>::append(T *request, int state)
{
    m_queuelocker.lock();
    if (m_workqueue.size() >= m_max_requests)
    {
        m_queuelocker.unlock();
        return false;
    }
    //读写事件
    request->m_state = state;
    m_workqueue.push_back(request);
    m_queuelocker.unlock();
    m_queuestat.post();
    return true;
}
​
template <typename T>
//proactor模式下的请求入队
bool threadpool<T>::append_p(T *request)
{
    m_queuelocker.lock();
    if (m_workqueue.size() >= m_max_requests)
    {
        m_queuelocker.unlock();
        return false;
    }
    m_workqueue.push_back(request);
    m_queuelocker.unlock();
    m_queuestat.post();
    return true;
}
​
//工作线程:pthread_create时就调用了它
template <typename T>
void *threadpool<T>::worker(void *arg)
{
    //调用时 *arg是this!
    //所以该操作其实是获取threadpool对象地址
    threadpool *pool = (threadpool *)arg;
    //线程池中每一个线程创建时都会调用run(),睡眠在队列中
    pool->run();
    return pool;
}
​
//线程池中的所有线程都睡眠,等待请求队列中新增任务
template <typename T>
void threadpool<T>::run()
{
    while (true)
    {
        m_queuestat.wait();
        m_queuelocker.lock();
        if (m_workqueue.empty())
        {
            m_queuelocker.unlock();
            continue;
        }
        //
        T *request = m_workqueue.front();
        m_workqueue.pop_front();
        m_queuelocker.unlock();
        if (!request)
            continue;
        //Reactor
        if (1 == m_actor_model)
        {
            if (0 == request->m_state)
            {
                if (request->read_once())
                {
                    request->improv = 1;
                    connectionRAII mysqlcon(&request->mysql, m_connPool);
                    request->process();
                }
                else
                {
                    request->improv = 1;
                    request->timer_flag = 1;
                }
            }
            else
            {
                if (request->write())
                {
                    request->improv = 1;
                }
                else
                {
                    request->improv = 1;
                    request->timer_flag = 1;
                }
            }
        }
        //default:Proactor
        else
        {
            connectionRAII mysqlcon(&request->mysql, m_connPool);
            request->process();
        }
    }
}
#endif  

代码实现

该代码实现了一个基于 pthread 的线程池,用于处理异步请求。线程池通过生产者-消费者模式管理请求,并使用了条件变量和互斥锁来同步线程之间的工作。以下是具体分析:

头文件和命名空间

#ifndef THREADPOOL_H
#define THREADPOOL_H
  • 使用预处理指令来避免头文件的重复包含。

类定义

template <typename T>
class threadpool
  • 采用模板类,意味着 threadpool 可以处理任意类型的请求,T 类型用来表示请求。

构造和析构

构造函数

threadpool(int actor_model, connection_pool *connPool, int thread_number = 8, int max_request = 10000);
  • actor_model:用于指定线程池的工作模型(Reactor 或 Proactor)。

  • connPool:数据库连接池,用于在请求处理时获取数据库连接。

  • thread_number:线程池中线程的数量,默认为 8。

  • max_requests:请求队列中允许的最大请求数,默认为 10000。

实现:

if (thread_number <= 0 || max_requests <= 0)
    throw std::exception();
  • 线程数量和最大请求数不能小于等于 0,若小于则抛出异常。

m_threads = new pthread_t[m_thread_number];
  • 创建一个用于保存线程的数组。

for (int i = 0; i < thread_number; ++i)
{
    if (pthread_create(m_threads + i, NULL, worker, this) != 0)
    {
        delete[] m_threads;
        throw std::exception();
    }
    if (pthread_detach(m_threads[i]))
    {
        delete[] m_threads;
        throw std::exception();
    }
}
  • 循环创建线程,并将 worker 函数作为线程的执行函数。使用 pthread_detach 使主线程不需要等待每个线程的结束。

析构函数

threadpool<T>::~threadpool()
{
    delete[] m_threads;
}
  • 销毁线程数组,释放资源。

请求入队

append 方法(Reactor 模式)

bool threadpool<T>::append(T *request, int state)
  • state 用于指示请求状态。

  • 上锁请求队列,检查是否已满;如果满了返回 false

  • 否则将请求插入队列,并释放锁,最后通过信号量唤醒等待的线程。

append_p 方法(Proactor 模式)

bool threadpool<T>::append_p(T *request)
  • 功能类似于 append,只是省略了状态参数。

工作线程

worker 函数

static void *worker(void *arg);
  • 创建工作线程时调用的函数。获取 threadpool 对象并执行 run 方法。

运行函数

run 方法

void threadpool<T>::run()
{
    while (true)
    {
        m_queuestat.wait();
        m_queuelocker.lock();
        ...
    }
}
  • 主循环,线程将持续运行直到程序结束。

  • 调用 wait 方法等待任务到来,接着加锁保护队列。

请求处理逻辑

  1. Reactor 模式

  • 处理请求的状态:

  • m_state 为 0 时表示读取请求,调用 read_once 方法读取请求内容。

  • 如果能够读取,则进行处理;否则设置 timer_flag

  • 否则表示写入请求,调用 write 方法进行写入处理。

  1. Proactor 模式

  • 获取连接并处理请求,简化操作。

总结

  • 该线程池实现使用了模板,允许灵活处理多类型请求,适用于高并发环境。

  • 锁和信号量有效地管理了线程间的同步,确保了安全性。

  • 代码设计良好,分离了核心功能,比如请求入队、线程处理逻辑等,使得整体结构清晰,易于维护和扩展。

此实现适合需要高效处理大量异步请求的场景,如 Web 服务器或数据库请求管理。


2. 线程的同步机制有哪些?

——信号量、条件变量、互斥量等;

在多线程编程中,为了避免数据竞争和资源冲突,常用以下几种线程同步机制:

首先是互斥锁(Mutex),适用于多个线程需要修改共享资源的场景,比如修改变量、写日志等。互斥锁的特点是同一时间只有一个线程可以访问资源,其他线程需要等待,这就像一把钥匙,确保资源修改的安全性。

然后是读写锁(Read-Write Lock),主要用于“读多写少”的场景,比如在线图书馆,读者可以同时阅读,但如果有人要修改内容,所有读者都需要等待写操作完成。它的特点是支持并发读操作,减少锁竞争开销。

条件变量(Condition Variable则用于线程需要等待某个条件满足才能继续工作,比如生产者-消费者模型中,消费者线程需要等到有新任务时才能被唤醒。条件变量的特点是线程可以挂起等待,条件满足时被通知继续执行。

信号量(Semaphore)用于控制同时访问资源的线程数量,比如数据库连接池,只允许固定数量的线程使用资源。它的特点是可以允许多个线程同时访问资源,但有上限,像停车场只允许有限的车进入。

最后是自旋锁(Spinlock),适用于锁的等待时间非常短的场景,比如在多核处理器上处理非常短的临界区。线程会忙等待,不会休眠,适合高并发但等待时间极短的情况。

不同的机制有各自的特点和使用场景,比如互斥锁适合简单的独占访问,读写锁适合读多写少,条件变量适合线程间的等待与唤醒,信号量控制并发访问数量,自旋锁则适合短时间的高频竞争。根据实际场景选择合适的同步机制,可以有效避免资源争用和死锁,确保程序高效运行。这些机制我在项目中都有实践过,所以对各自的特点和适用场景非常熟悉。



3. 线程池中的工作线程是一直等待吗?

——是的,等待新任务的唤醒;

在run函数中,我们为了能够处理高并发的问题,将线程池中的工作线程都设置为阻塞等待在请求队列是否不为空的条件上,因此项目中线程池中的工作线程是处于 一直阻塞等待 的模式下的。

4. 你的线程池工作线程处理完一个任务后的状态是什么?

——如果请求队列为空,则该线程进入线程池中等待;若不为空,则该线程跟其他线程一起进行任务的竞争;

(1) 当处理完任务后如果请求队列为空时,则这个线程重新回到阻塞等待的状态

(2) 当处理完任务后如果请求队列不为空时,那么这个线程将处于与其他线程竞争资源的状态,谁获得锁谁就获得了处理事件的资格。

5. 如果同时1000个客户端进行访问请求,线程数不多,怎么能及时响应处理每一个呢?

这个项目是基于 I/O 复用的并发模式设计的,并没有采用“一客户一线程”的模式。毕竟,如果每个客户连接都对应一个线程,当像淘宝这种大规模应用遇到双12这样的高并发场景时,服务器早就崩溃了。我们采用的是 epoll 进行事件驱动,当客户连接有事件需要处理时,epoll 会提醒,并将对应的任务加入请求队列,之后由线程池中的工作线程竞争处理。如果处理速度仍然跟不上,可以通过增大线程池容量或采用集群分布式架构来优化。

在高并发处理上,我们通过对子线程的循环调用来实现。在创建线程时,通过 pthread_detach 对线程进行分离,这样线程执行完成后资源会自动回收,无需手动处理。每个线程在创建后会进入一个 while 循环,通过轮询请求队列来处理任务。线程会在没有任务时阻塞等待,而一旦有任务加入请求队列,线程会抢占任务进行处理,直到队列为空,表示任务全部完成。

此外,该项目采用了 epoll 的 I/O 多路复用技术,通过边缘触发(ET)模式高效处理每个连接的请求。整体上,这种设计实现了高效的事件提醒和任务调度,确保服务器能够在高并发场景下保持良好的性能和稳定性。

6. 如果一个客户请求需要占用线程很久的时间,会不会影响接下来的客户请求呢,有什么好的策略呢?

是的,线程池内线程数量是有限的,如果某些客户请求占用线程的时间过长,会显著影响处理效率。当线程被长时间占用时,后续的客户请求只能在请求队列中等待,导致整体响应速度变慢,从而影响用户体验和服务器的并发性能。

为了解决这个问题,我们可以引入定时器机制来优化。具体做法是:为每个线程处理的请求对象设置一个处理超时时间。如果某个请求处理时间超过设定的阈值,首先发送信号通知线程处理超时,并设定一个短时间间隔后再次检测。如果在这个间隔后,线程仍被该请求占用,就可以直接断开该请求的连接,释放资源。这样可以避免长时间占用线程的“顽固”请求影响到其他客户的正常服务,从而提高整体的请求处理效率。

这种方式通过限制单个请求的最大处理时间,有效防止线程池的资源被过度占用,确保服务器在高并发场景下的稳定性和响应速度。

7. 线程池中有多少个线程,线程池数量如何设定(经纬恒润)

默认8个

调整线程池中的线程数量的主要目的是为了充分合理地利用 CPU 和内存等资源,从而最大化程序的性能表现。线程数量的设置通常与任务的类型以及服务器的硬件配置密切相关。

如果任务是CPU密集型,线程池的线程数量可以参考公式 Ncpu + 1,其中 Ncpu 表示 CPU 核心数。这样设置的目的是尽可能压榨 CPU 的计算能力,同时保留一个额外的线程,确保在某些线程因页缺失故障或其他原因暂停时,备用线程可以顶上,保证 CPU 时钟周期不被浪费。

如果任务是IO密集型,则推荐将线程数量设置为 2 * Ncpu。因为 IO 操作通常比较慢,线程的瓶颈不是 CPU,而是等待 IO 完成的时间。通过增加线程数量,可以在 IO 线程等待期间让 CPU 继续处理其他任务,避免 CPU 资源空闲。

此外,还有一个公式可以帮助确定最佳线程数量:

从公式可以看出,如果线程的等待时间较长,线程数量需要增加;如果线程的 CPU 时间较长,则线程数量可以减少。

总结来说,CPU 密集型任务倾向于设置线程数为 Ncpu + 1,IO 密集型任务则推荐设置为 2 * Ncpu,并结合具体任务的等待时间和计算时间合理调整线程数量,以达到性能最优。

8. 线程越多越好么

随着线程数越多,效率越来越高,但到一个峰值,再增加线程数量时,就会出现问题。线程太多要来回的切换,最终可能线程切换所用时间比执行时间业务所用时间还大。

并发模型相关

1. 简单说一下服务器使用的并发模型?

1.线程池
2.非阻塞socket
3.多路复用epoll(et+lt)
4.两种事件处理模式都实现了

——该项目选用的半同步半反应堆的并发模型。

以Proactor模式为例的工作流程即是:主线程充当异步线程,负责监听所有socket上的事件

若有新请求到来,主线程接收之以得到新的连接socket,然后往epoll内核事件表中注册该socket上的读写事件

如果连接socket上有读写事件发生,主线程从socket上接收数据,并将数据封装成请求对象插入到请求队列中

所有工作线程睡眠在请求队列上,当有任务到来时,通过竞争(如互斥锁)获得任务的接管权

2. 介绍一下几种I/O模型

I/O 模型主要有以下几种,它们各有特点和适用场景:

  1. 阻塞 I/O:这是最简单的 I/O 模型,应用程序在执行 I/O 操作时会被阻塞,直到操作完成。例如,当读取数据时,如果数据未准备好,程序会停在调用处等待。这种模型实现简单,但效率较低,因为在等待期间程序无法做其他事情。

  2. 非阻塞 I/O:在非阻塞 I/O 中,应用程序不会因为 I/O 操作被阻塞。如果 I/O 操作无法立即完成,会立即返回一个错误(如 EAGAIN),程序可以继续执行其他任务。虽然非阻塞 I/O提高了程序的灵活性,但通常需要通过轮询不断查询 I/O 是否完成,这种方式会增加 CPU 的负担。

  3. I/O 多路复用:这是处理高并发连接的经典模型,比如 selectpollepoll。它允许程序同时监控多个 I/O 事件,当某个 I/O 流就绪时,程序会被唤醒进行处理。通过这种方式,一个线程就可以高效管理多个网络连接,因此非常适合高并发场景。

  4. 信号驱动 I/O:在这个模型中,应用程序先为某个 I/O 流注册信号处理,当 I/O 流就绪时,操作系统会发送信号通知应用程序处理事件。虽然这种方式是异步的,但由于需要信号处理机制,使用较少,适用场景也有限。

  5. 异步 I/O:这是最先进的 I/O 模型,应用程序发起 I/O 操作后立即返回,继续执行其他任务,操作系统会在完成 I/O 操作后通知应用程序,甚至直接将数据传输完成。这种模型无需轮询或阻塞,效率最高,但实现复杂,依赖操作系统的支持。

总结来说,阻塞和非阻塞 I/O适用于简单场景,I/O 多路复用是高并发服务器的主流选择,而异步 I/O 则是性能最优的解决方案,但由于实现复杂,目前使用较少。根据需求选择合适的 I/O 模型,可以显著提升系统的性能和并发能力。

3. reactor、proactor、主从reactor模型的区别?

在服务器并发模型中,不同模式对 I/O 事件的处理方式有所不同,以下是几种常见的模式和它们的特点:

  1. Reactor 模式:在 Reactor 模式中,主线程(I/O 处理单元)负责监听文件描述符上的事件(如读写事件),一旦有事件发生,就将其通知工作线程(逻辑处理单元)。主线程将事件放入请求队列,由工作线程负责处理这些事件,包括读写数据、接受新连接以及具体的客户请求处理。这种模式通常基于同步 I/O(如 epoll_wait)实现。

  2. Proactor 模式:Proactor 模式中,主线程和内核负责完成所有 I/O 操作(如读写数据、接受新连接等),工作线程只负责业务逻辑处理,比如处理客户请求。这种模式通常由异步 I/O(如 aio_readaio_write)实现。由于异步 I/O 在实际开发中还不够成熟,使用较少,因此我们通常采用同步 I/O 模拟 Proactor 模式在同步 I/O 模拟 Proactor 模式的流程中:

  • 主线程会先将 socket 的读事件注册到 epoll 内核事件表中。

  • 调用 epoll_wait 等待 socket 上有数据可读。

  • 当数据可读时,epoll_wait 通知主线程,主线程循环读取数据,直到读取完成,然后将数据封装成请求对象插入请求队列。

  • 工作线程被唤醒后,从请求队列中取出请求对象,处理客户请求,并将 socket 的写事件注册到 epoll 内核事件表中。

  • 主线程再次调用 epoll_wait 等待 socket 可写,当通知可写时,主线程将处理结果写回客户端。

  1. 主从 Reactor 模式:主从 Reactor 模式的核心思想是,主反应堆线程专门负责监听并处理新的连接事件,而已建立连接的 I/O 事件交由多个 从反应堆线程 处理。具体来说:

  • 主反应堆线程使用 accept 方法接收新的连接,并根据一定的算法(如轮询或负载均衡)将连接分配给某个从反应堆线程。

  • 从反应堆线程负责管理已连接的 socket,并处理这些连接上的读写事件。

  • 从反应堆线程的数量可以根据 CPU 核数进行灵活配置,充分利用多核资源。

这三种模式各有特点:Reactor 模式更适合高并发场景,任务分工明确;Proactor 模式更高效,但异步 I/O 的成熟度限制了其广泛应用;主从 Reactor 模式通过分工主线程和从线程的职责,进一步提高了并发处理能力。我们在实际项目中采用的是同步 I/O 模拟 Proactor 模式,结合 epoll 的高效多路复用,既能满足并发性能需求,又兼顾了实现的成熟性和稳定性。

4. 同步 I/O 模拟 Proactor 模式和Reactor 模式区别是什么

同步 I/O 模拟 Proactor 模式和 Reactor 模式的主要区别在于 I/O 操作的分工数据读写的处理方式

Reactor 模式 中,主线程的职责是监听文件描述符上的事件(如连接请求或读写事件),当事件发生时,将事件分发给工作线程处理。工作线程不仅要完成实际的 I/O 操作(如读取或写入数据),还要处理业务逻辑。因此,Reactor 模式下的工作线程既处理 I/O,也处理业务逻辑。这种模式的实现较为简单,但如果 I/O 操作阻塞,可能会降低整体的处理效率。

而在 同步 I/O 模拟 Proactor 模式 中,主线程的职责不仅是监听事件,还要直接完成 I/O 操作(如读取或写入数据)。主线程会将读取到的数据封装成请求对象,并插入请求队列,工作线程从请求队列中取出任务后,只专注于业务逻辑处理。主线程还负责将处理结果写回客户端。这种模式将 I/O 操作和业务逻辑分离,避免了工作线程因 I/O 阻塞而降低效率,更适合高并发和复杂业务逻辑的场景。

总结

特性

Reactor 模式

同步 I/O 模拟 Proactor 模式

I/O 操作

由工作线程完成

由主线程完成

业务逻辑处理

工作线程完成

工作线程完成

主线程职责

事件监听、分发

事件监听、I/O 操作、任务分配

实现复杂度

较简单

较复杂

适用场景

I/O 操作少、简单业务逻辑场景

高并发、复杂业务逻辑场景

效率

工作线程可能因 I/O 阻塞影响效率

主线程完成 I/O,避免工作线程阻塞,提高效率

简单来说,Reactor 模式将 I/O 操作和业务处理都交给工作线程,而同步 I/O 模拟 Proactor 模式则让主线程负责 I/O 操作,工作线程只专注于业务逻辑。这种分工使同步 I/O 模拟 Proactor 模式在高并发场景下效率更高,因为它避免了工作线程因 I/O 操作而被阻塞的风险,同时更好地利用了多核 CPU 资源。

5. 你用了epoll,说一下为什么用epoll,还有其他复用方式吗?区别是什么?

——先说说其他的复用方式吧,比较常用的有三种:select/poll/epoll。本项目之所以采用epoll,参考问题(Why is epoll faster than select?

  • 在这个项目中,我们选择了 epoll 作为主要的 I/O 多路复用技术,因为它相比 select 和 poll 有明显的性能优势。

首先,epoll 没有最大并发连接的限制,能打开的文件描述符数量远大于 select 和 poll。比如在 1G 内存的系统上,epoll 能支持大约 10 万个端口。此外,epoll 的效率提升来源于它的事件驱动机制,epoll 只处理“活跃”的文件描述符(即有事件发生的连接),而不会因为文件描述符总数的增加而降低效率。相比之下,select 和 poll 需要遍历所有文件描述符集合。

epoll 通过 mmap() 文件映射的方式减少用户态和内核态之间的数据拷贝开销,这进一步提高了效率。其使用步骤包括三个关键函数:epoll_create() 用于创建内核事件表;epoll_ctl() 用于注册、修改或删除事件;epoll_wait() 用于监听就绪事件并返回待处理的文件描述符列表。

epoll 支持两种工作模式:LT(水平触发)和ET(边缘触发)。LT 是默认模式,即使事件没有被处理,内核仍会持续通知应用程序,因此兼容性较好。而 ET 模式是更高效的工作模式,它在事件发生时仅通知一次,要求应用程序立即处理所有事件。这种机制减少了事件被重复触发的次数,适合高性能服务器,但必须配合非阻塞 I/O 以避免线程被阻塞。

相比 select 和 poll,epoll 的优势还体现在以下几点:

  1. 文件描述符管理:select 受限于文件描述符上限,而 epoll 的文件描述符存储在红黑树中,数量仅受系统资源限制。

  2. 内核判断就绪事件:select 和 poll 每次调用都需要将所有文件描述符拷贝到内核并遍历判断,而 epoll 在 epoll_ctl() 阶段将文件描述符注册到内核的红黑树中,检测到事件后直接将其加入链表,epoll_wait() 只需检查链表内容即可。

  3. 就绪事件返回:epoll 返回的事件数组直接包含每个文件描述符的信息,而 select 和 poll 仅返回事件数量,仍需要遍历找到发生事件的文件描述符。

至于应用场景,当监控的文件描述符数量较少且都比较活跃时,select 或 poll 的简单线性结构可能更高效。而当文件描述符数量非常大且活跃文件描述符较少时,epoll 的事件驱动特性能够显著提高性能。这也是我们选择 epoll 的原因,因为它能很好地应对高并发的场景,尤其是单位时间内只有部分连接处于活跃状态时。

通过这些机制,我们的服务器实现了高效、低开销的并发处理,在高连接数和高并发情况下性能表现非常出色。

6. 为什么ET需要配合非阻塞 I/O而LT不需要

ET(边缘触发)模式需要配合非阻塞 I/O,而 LT(水平触发)模式则不需要,这是因为两种模式的事件通知机制有本质的区别。

LT 模式是默认模式,当文件描述符有事件发生时,内核会持续通知应用程序,只要事件未被处理完,下一次调用 epoll_wait() 时,内核仍会继续通知。这种机制对应用程序的处理逻辑要求较低,即使使用阻塞 I/O,内核也会重复提醒,确保事件不会被遗漏。

ET 模式是更高效的模式,它的特点是只在事件状态从“未就绪”变为“就绪”时通知一次,内核不会重复提醒。如果应用程序没有一次性处理完事件,后续即使有新数据到来,内核也不会再发送通知。这种机制减少了事件被重复触发的次数,提高了性能,但同时要求应用程序立即处理完所有事件。

由于 ET 模式只通知一次,如果使用阻塞 I/O,线程可能会因数据未准备好而被卡住,导致后续事件得不到及时处理,甚至可能出现数据丢失的情况。而非阻塞 I/O可以避免这种问题,即使没有数据可读或无法写入数据,操作会立即返回,线程不会被阻塞。应用程序可以通过循环读取或写入的方式,在一次事件通知后处理完所有数据,直到返回 EAGAIN 表示没有更多数据可处理。

总结来说,ET 模式的设计初衷是提高效率,减少事件触发的次数,但这也对应用程序提出了更高的要求,必须配合非阻塞 I/O 使用,确保线程不会因为 I/O 操作被长时间占用。而 LT 模式对阻塞或非阻塞 I/O 都兼容,因此对应用程序的处理逻辑要求较低。

7. epoll如何实现并发处理能力?(考拉悠然)

epoll 通过 I/O 多路复用和事件驱动机制实现高效的并发处理能力。它的核心思想是通过内核中的红黑树管理所有需要监听的文件描述符,并通过一个就绪链表记录发生事件的文件描述符。当调用 epoll_wait 时,epoll 只会返回真正有事件发生的文件描述符,而不是像传统的 selectpoll 那样遍历整个文件描述符集合。

这种机制有几个关键点:第一,epoll 没有文件描述符数量的限制,可以管理大量的连接,文件描述符的上限只受系统资源限制;第二,epoll 的事件通知是基于回调机制的,只有活跃的文件描述符会触发回调,内核将就绪的事件加入就绪链表,这大大提升了效率;第三,epoll 支持边缘触发(ET)和水平触发(LT)两种模式,其中 ET 模式进一步减少了事件的重复触发次数,更适合高并发场景。

通过这些特点,epoll 能够在高并发环境下高效地监听和处理大量连接事件,使其成为构建高性能网络服务器的核心技术之一。

详细内容

epoll 是 Linux 提供的一种高性能 I/O 多路复用机制,相较于传统的 selectpoll,在处理大量文件描述符时表现更加高效。其核心并发处理能力主要通过以下几个关键机制实现:


1. 事件驱动的非阻塞 I/O

epoll 的工作方式是基于 事件驱动模型 的,而不是轮询所有文件描述符的状态。这种机制减少了不必要的 CPU 消耗,提升了系统的并发处理能力:

  • 在注册的文件描述符上设置 非阻塞模式,避免因 I/O 操作导致的线程阻塞。

  • 通过 epoll_ctl 添加文件描述符时,可以指定事件类型(如 EPOLLINEPOLLOUTEPOLLET 等)。

  • 当文件描述符的状态发生变化时,内核会将其标记为 "就绪",并将事件通知给用户态应用。


2. 就绪通知机制

epoll 的效率提升,源自其 就绪通知 的设计:

  • 水平触发 (LT, Level-Triggered): 默认模式,文件描述符的状态只要保持 "就绪",每次调用 epoll_wait 都会返回该事件。适用于需要较高兼容性的场景。

  • 边沿触发 (ET, Edge-Triggered): 仅在文件描述符状态发生边沿变化时(如从 "未就绪" 到 "就绪")触发事件通知。ET 模式能减少重复通知,但要求用户必须消耗完所有数据以避免漏处理。

ET 模式结合非阻塞 I/O能大幅减少事件触发次数,从而提升并发性能。


3. 内核空间高效数据结构

epoll 使用了内核级高效的数据结构来支持高并发的文件描述符管理:

  • 红黑树: 用于存储和管理所有被监控的文件描述符。通过 epoll_ctl 添加、修改、删除文件描述符时,红黑树能够快速完成插入和查找操作。

  • 就绪链表: 当文件描述符触发事件时,内核会将其加入到一个就绪链表中。epoll_wait 调用时直接返回链表中的就绪事件,避免了遍历整个红黑树。

这种设计使得 epoll 在面对大量文件描述符时性能表现优秀,尤其是事件少的情况下,避免了 selectpoll 需要遍历全部文件描述符的问题。


4. 支持高并发连接

epoll 的并发处理能力在网络服务(如高并发 Web 服务器)场景中尤为突出,结合多线程或多进程架构,能进一步提高并发能力:

  • 多线程使用: 多个线程可以共享一个 epoll 实例,同时调用 epoll_wait 进行事件处理,从而实现负载均衡。例如,Nginx 就是基于这种模型来实现高性能的。

  • 线程池结合: 通过线程池处理 epoll 的事件,避免频繁的线程创建和销毁开销。

  • 异步模型支持: 在非阻塞套接字和 epoll 的基础上,可以实现异步处理大规模连接的能力(如 Redis、Node.js)。


5. 零拷贝优化(减少上下文切换)

epoll 将事件通知和文件描述符管理交由内核处理,通过用户态与内核态之间的高效数据共享(如共享就绪链表),减少上下文切换和数据拷贝的开销:

  • 应用程序只需要调用 epoll_wait,不需要主动轮询文件描述符。

  • 事件触发后,仅通知就绪的文件描述符,避免了扫描全部文件描述符的代价。


6. 高效的负载处理模式

epoll 允许用户动态调整监控的文件描述符集合,支持大规模连接数的动态管理:

  • 长连接管理: 对于长连接,epoll 可以高效监控其 I/O 状态,不需要频繁地创建和销毁连接。

  • 短连接优化: 在高并发短连接场景下,结合 ET 模式和非阻塞 I/O,可以快速处理 I/O 请求并关闭连接,提升吞吐量。


总结

epoll 的并发处理能力主要得益于其以下设计特点:

  1. 事件驱动模型:只处理实际发生的 I/O 事件。

  2. 高效的数据结构:红黑树和就绪链表分工明确。

  3. 边沿触发模式 (ET):减少事件通知次数,提高吞吐量。

  4. 多线程支持:结合线程池实现高并发处理。

  5. 零拷贝和上下文切换优化:减少不必要的 CPU 和内存开销。

这些特点使 epoll 成为高并发场景中处理大规模 I/O 的核心工具之一。

HTTP报文解析相关

1. 用了状态机啊,为什么要用状态机?

有限状态机是一种抽象的理论模型,用于描述有限个变量的状态变化过程,以一种可构造、可验证的方式呈现,比如封闭的有向图。在实现上,有限状态机可以通过 if-elseswitch-case 或函数指针来完成,其核心目标是封装逻辑,从而让程序的逻辑更加清晰易懂。在服务器编程中,状态机尤其适用于根据不同的状态或消息类型处理不同的业务逻辑,这使得程序逻辑结构更清晰,响应更准确。

在我们的项目中,因为需要解析和响应 HTTP 请求,我们采用了主从状态机的设计模式。具体来说:

  • 从状态机(parse_line):负责逐行读取 HTTP 报文数据。

  • 主状态机:对从状态机读取到的数据进行解析,并根据解析结果驱动从状态机的工作。

整个解析过程由状态机的状态驱动,每解析一部分都会更新 m_check_state 状态,从而指导状态机进行下一步的逻辑跳转。相比传统的顺序控制流程,有限状态机可以应对任意顺序的事件,并能对这些事件提供有意义的响应,即使事件发生的顺序与预期不同。

这种主从状态机的设计在服务器中显著提升了解析 HTTP 请求的灵活性和可维护性。通过清晰的状态转移和解耦的逻辑处理,使得程序不仅具备更好的扩展性,还能更高效地响应复杂的请求。

2. 状态机的转移图画一下

这张图片展示了一个HTTP请求解析的状态转移图。我们可以看到主状态机分为三个主要状态:CHECK_STATE_REQUESTLINECHECK_STATE_HEADERCHECK_STATE_CONTENT。每个状态都有相应的处理函数,分别是 parse_request_lineparse_headersparse_content

a. 分析过程

  1. 初始状态

  • 主状态机从 CHECK_STATE_REQUESTLINE 开始。

  • 在这个状态下,读取一行数据,并调用 parse_request_line 函数进行解析。

  1. 请求行解析

  • 如果请求行解析成功(LINE_OK),则进入下一个状态 CHECK_STATE_HEADER

  • 如果请求行解析失败(LINE_BAD),则返回错误状态。

  1. 头部解析

  • CHECK_STATE_HEADER 状态下,继续读取一行数据,并调用 parse_headers 函数进行解析。

  • 如果头部解析成功(LINE_OK),则继续读取数据。

  • 如果头部解析失败(LINE_BAD),则返回错误状态。

  1. 内容解析

  • 当所有头部信息都解析完成后,进入 CHECK_STATE_CONTENT 状态。

  • 在这个状态下,读取一行数据,并调用 parse_content 函数进行解析。

  • 如果内容解析成功(LINE_OK),则继续读取数据。

  • 如果内容解析失败(LINE_BAD),则返回错误状态。

  1. 完成解析

  • 对于 GET 请求,在 CHECK_STATE_HEADER 状态下解析完成后即认为请求解析完成。

  • 对于 POST 请求,在 CHECK_STATE_CONTENT 状态下解析完成后才认为请求解析完成。

  1. 错误处理

  • 在每个状态中,如果遇到语法错误或数据不完整,都会进入 LINE_BAD 状态,并返回错误。

b. 总结

这个状态转移图描述了HTTP请求的解析过程,通过不同的状态和对应的处理函数,确保请求能够被正确解析。每个状态都有明确的处理逻辑,确保在不同类型的请求(GET 和 POST)下都能正确处理。

3. https协议为什么安全?

HTTPS 的安全性主要体现在三个方面:

首先是加密传输,HTTPS 通过 SSL/TLS 协议对数据进行加密,即使传输中的数据被截获,也无法被破解或篡改。它使用对称加密算法(如 AES)来保护数据的隐私,确保数据只能被发送方和接收方解读。

其次是身份验证,HTTPS 使用数字证书验证服务器的身份,确保客户端连接的是可信赖的服务器,而不是伪装的攻击者。浏览器会检查服务器提供的证书是否由受信任的证书颁发机构(CA)签发,从而避免用户被钓鱼或连接到恶意服务器。

最后是数据完整性,通过消息摘要(Hash)技术,HTTPS 确保传输的数据在途中没有被篡改。如果数据在传输过程中被修改,接收方能够检测到,并拒绝接收这些数据。

总结:HTTPS 的核心安全性依赖于加密传输、身份验证和数据完整性,这些机制共同保证了数据在传输过程中的私密性、真实性和完整性,防止数据被窃听、伪造或篡改。

4. https的ssl连接过程

SSL/TLS握手协议的过程。首先,客户端向服务器发送它支持的加密协议和版本,比如SSL或TLS。然后,服务器从中选择一个合适的加密协议,并返回一个证书,这个证书里包含了公钥。接下来,客户端使用根证书来验证这个证书的有效性。验证通过后,客户端生成一对对称密钥,并通过证书中的公钥进行加密,然后发送给服务器端。服务器端收到后,使用私钥解密,获取到对称密钥,并开始使用这个对称密钥来加密数据。最后,客户端解密这些数据,整个SSL通信过程就开始了。”

5. GET和POST的区别

——强烈推荐!网上其他答案都没答到点:https://www.cnblogs.com/logsharing/

在HTTP协议中,GETPOST是两种常见的请求方式,它们的主要区别如下:

  1. 功能不同:GET主要用于获取数据,而POST用于提交或修改数据。

  2. 参数长度限制:GET的参数有长度限制(一般为2048字节),而POST没有长度限制。

  3. 数据传输方式:GET通过URL明文传输参数,参数可以直接在地址栏中看到;POST则将参数放在请求体中,除非借助工具,否则无法直接查看。

  4. 参数格式:GET的参数附加在URL后,以“?”分割URL和参数,多个参数用“&”连接;POST的参数则直接包含在HTTP请求体中。

  5. 缓存和历史记录:GET请求会被浏览器主动缓存,并保存在历史记录中,同时也可能记录在服务器日志中;POST请求不会被主动缓存,除非手动设置。

  6. 浏览器回退行为:GET请求在浏览器回退时是安全的(不会重复请求);而POST请求回退时可能会导致数据再次提交。

  7. 编码方式:GET请求的参数只能进行URL编码(仅支持ASCII字符),而POST支持多种编码方式,且对数据类型没有限制。

  8. 幂等性:GET是幂等的,对同一URL的多次请求返回的结果一致;而POST不是幂等的,多次请求可能产生不同的结果。

总结来说,GET适用于获取数据且安全性要求不高的场景,而POST更适合需要提交数据或对服务器状态产生修改的操作。

GET和POST是HTTP请求的两种基本方法,要说它们的区别,接触过WEB开发的人都能说出一二。

最直观的区别就是GET把参数包含在URL中,POST通过request body传递参数。

你可能自己写过无数个GET和POST请求,或者已经看过很多权威网站总结出的他们的区别,你非常清楚知道什么时候该用什么。

当你在面试中被问到这个问题,你的内心充满了自信和喜悦。

你轻轻松松的给出了一个“标准答案”:


  • GET在浏览器回退时是无害的,而POST会再次提交请求。

  • GET产生的URL地址可以被Bookmark,而POST不可以。

  • GET请求会被浏览器主动cache,而POST不会,除非手动设置。

  • GET请求只能进行url编码,而POST支持多种编码方式。

  • GET请求参数会被完整保留在浏览器历史记录里,而POST中的参数不会被保留。

  • GET请求在URL中传送的参数是有长度限制的,而POST么有。

  • 对参数的数据类型,GET只接受ASCII字符,而POST没有限制。

  • GET比POST更不安全,因为参数直接暴露在URL上,所以不能用来传递敏感信息。

  • GET参数通过URL传递,POST放在Request body中。

(本标准答案参考自w3schools)

“很遗憾,这不是我们要的回答!”

请告诉我真相。。。

如果我告诉你GET和POST本质上没有区别你信吗?


让我们扒下GET和POST的外衣,坦诚相见吧!


GET和POST是什么?HTTP协议中的两种发送请求的方法。

HTTP是什么?HTTP是基于TCP/IP的关于数据如何在万维网中如何通信的协议。

HTTP的底层是TCP/IP。所以GET和POST的底层也是TCP/IP,也就是说,GET/POST都是TCP链接。GET和POST能做的事情是一样一样的。你要给GET加上request body,给POST带上url参数,技术上是完全行的通的。

那么,“标准答案”里的那些区别是怎么回事?

在我大万维网世界中,TCP就像汽车,我们用TCP来运输数据,它很可靠,从来不会发生丢件少件的现象。但是如果路上跑的全是看起来一模一样的汽车,那这个世界看起来是一团混乱,送急件的汽车可能被前面满载货物的汽车拦堵在路上,整个交通系统一定会瘫痪。为了避免这种情况发生,交通规则HTTP诞生了。HTTP给汽车运输设定了好几个服务类别,有GET, POST, PUT, DELETE等等,HTTP规定,当执行GET请求的时候,要给汽车贴上GET的标签(设置method为GET),而且要求把传送的数据放在车顶上(url中)以方便记录。如果是POST请求,就要在车上贴上POST的标签,并把货物放在车厢里。当然,你也可以在GET的时候往车厢内偷偷藏点货物,但是这是很不光彩;也可以在POST的时候在车顶上也放一些数据,让人觉得傻乎乎的。HTTP只是个行为准则,而TCP才是GET和POST怎么实现的基本。

但是,我们只看到HTTP对GET和POST参数的传送渠道(url还是requrest body)提出了要求。“标准答案”里关于参数大小的限制又是从哪来的呢?




在我大万维网世界中,还有另一个重要的角色:运输公司。不同的浏览器(发起http请求)和服务器(接受http请求)就是不同的运输公司。 虽然理论上,你可以在车顶上无限的堆货物(url中无限加参数)。但是运输公司可不傻,装货和卸货也是有很大成本的,他们会限制单次运输量来控制风险,数据量太大对浏览器和服务器都是很大负担。业界不成文的规定是,(大多数)浏览器通常都会限制url长度在2K个字节,而(大多数)服务器最多处理64K大小的url。超过的部分,恕不处理。如果你用GET服务,在request body偷偷藏了数据,不同服务器的处理方式也是不同的,有些服务器会帮你卸货,读出数据,有些服务器直接忽略,所以,虽然GET可以带request body,也不能保证一定能被接收到哦。

好了,现在你知道,GET和POST本质上就是TCP链接,并无差别。但是由于HTTP的规定和浏览器/服务器的限制,导致他们在应用过程中体现出一些不同。

你以为本文就这么结束了?

我们的大BOSS还等着出场呢。。。

这位BOSS有多神秘?当你试图在网上找“GET和POST的区别”的时候,那些你会看到的搜索结果里,从没有提到他。他究竟是什么呢。。。

GET和POST还有一个重大区别,简单的说:

GET产生一个TCP数据包;POST产生两个TCP数据包。

长的说:

对于GET方式的请求,浏览器会把http header和data一并发送出去,服务器响应200(返回数据);

而对于POST,浏览器先发送header,服务器响应100 continue,浏览器再发送data,服务器响应200 ok(返回数据)。

也就是说,GET只需要汽车跑一趟就把货送到了,而POST得跑两趟,第一趟,先去和服务器打个招呼“嗨,我等下要送一批货来,你们打开门迎接我”,然后再回头把货送过去。

因为POST需要两步,时间上消耗的要多一点,看起来GET比POST更有效。因此Yahoo团队有推荐用GET替换POST来优化网站性能。但这是一个坑!跳入需谨慎。为什么?

1. GET与POST都有自己的语义,不能随便混用。

2. 据研究,在网络环境好的情况下,发一次包的时间和发两次包的时间差别基本可以无视。而在网络环境差的情况下,两次包的TCP在验证数据包完整性上,有非常大的优点。

3. 并不是所有浏览器都会在POST中发送两次包,Firefox就只发送一次。

现在,当面试官再问你“GET与POST的区别”的时候,你的内心是不是这样的?




数据库登录注册相关

1. 登录说一下?

在实现登录注册功能时,服务器需要以下步骤完成操作:

  1. 载入数据库数据:启动时,将数据库中的用户名和密码载入到服务器的map中,其中mapkey为用户名,value为对应的密码,便于快速查找和验证。

  2. 解析请求报文:服务器端解析浏览器发送的HTTP请求报文。如果解析到POST请求,则提取请求报文中的消息体。消息体包含用户名和密码,格式为用户名&密码,通过分隔符&提取出用户名和密码。

  3. 登录逻辑:根据提取的用户名和密码,在map或数据库中查找匹配记录。如果找到对应关系,则登录成功,否则登录失败。

  4. 注册逻辑:注册时,将新用户数据插入数据库之前,需要先检查是否存在重复用户名。如果没有重复,则将用户名和密码写入数据库,并更新到服务器的map中,完成注册。

  5. 页面跳转:根据URL中/后的第一个字符(m_url定位),通过分支语句实现页面跳转。具体规则如下:

  • 0:跳转到注册页面(GET请求)

  • 1:跳转到登录页面(GET请求)

  • 5:显示图片页面(POST请求)

  • 6:显示视频页面(POST请求)

  • 7:显示关注页面(POST请求)

通过以上流程,服务器实现了用户登录、注册功能和不同页面的跳转,确保了功能的逻辑性和页面切换的准确性。

2. 你这个保存状态了吗?如果要保存,你会怎么做?(cookie和session)

Cookie和Session是Web开发中常用的两种用户身份管理方式,它们的主要区别可以总结如下:

Cookie是由服务器生成的一串“身份标识”,例如“123456789happy”这样的字符串,存储在客户端(浏览器)中。每次客户端向服务器发送HTTP请求时,都会自动在报文中附带这串字符串。通过这串标识,服务器就能识别用户的身份。Cookie主要用于客户端状态的维护和简单数据的存储。

Session则是将用户的状态信息保存在服务器端。每当客户端发送HTTP请求时,服务器会在自己记录的用户数据中找到与该请求对应的会话信息,类似于核对一份用户的“名单”,从而确认用户身份。Session更加安全,因为用户的状态数据存储在服务器,不容易被客户端直接篡改。

总结

  • Cookie将用户状态信息存储在客户端,并通过标识来传递用户身份;

  • Session将用户状态信息存储在服务器端,通过服务端的记录来验证用户身份。

一般来说,Cookie适合轻量级的身份标识,而Session更适合涉及敏感信息和复杂状态管理的场景。

3. 登录中的用户名和密码你是load到本地,然后使用map匹配的,如果有10亿数据,即使load到本地后hash,也是很耗时的,你要怎么优化?

在大数据量情况下进行用户登录验证,直接将所有用户信息加载到内存中既耗时又耗资源,因此可以通过 Hash 和多级索引 的方式来加速用户验证。具体方法如下:

对于10亿用户数据,利用Hash算法大致将其划分为1000倍缩小的块,生成大约100万条Hash数据,每个Hash数据代表一个用户信息块(一级索引)。

然后,再对这100万条Hash数据进行进一步的Hash处理,缩减到1000条Hash数据(二级索引)。

在这种设计下,服务器只需要保存这1000条二级Hash数据。当用户请求登录时,首先对用户信息进行一次Hash操作,快速定位到所属的二级信息块;接着读取该块对应的一级Hash信息块,进一步精确定位到用户数据,从而完成验证。

这种方法通过分层Hash和分块存储,有效减少了需要遍历的数据量,极大提高了用户验证的速度和效率,非常适合处理大规模用户数据的场景。

4. 用的mysql啊,redis了解吗?用过吗?

是的,我对 Redis 非常了解,并且可以详细回答关于它的使用和相关概念。

a. 什么是 Redis?

Redis(Remote Dictionary Server)是一个开源的高性能内存型数据结构存储,可以用作数据库、缓存、消息队列等。它是基于键值对(key-value)的 NoSQL 数据库,支持丰富的数据结构,具有非常高的性能。


b. Redis 的特点

  1. 高性能:Redis 是内存数据库,所有数据都存储在内存中,读写速度非常快。

  2. 多种数据结构支持

  • String(字符串)

  • Hash(散列/哈希表)

  • List(列表)

  • Set(集合)

  • Sorted Set(有序集合)

  • Bitmap(位图)等。

  1. 持久化支持

  • 支持两种持久化方式:RDB(快照)和 AOF(Append-Only File),以确保数据在宕机时可以恢复。

  1. 分布式和高可用

  • 提供了主从复制(Master-Slave)、哨兵(Sentinel)机制以及 Redis Cluster(集群)实现高可用和分布式存储。

  1. 丰富的功能

  • 发布订阅(Pub/Sub)

  • 分布式锁(基于 setnx 指令实现)

  • Geospatial(地理位置计算)等。

  1. 语言支持广泛

  • 几乎支持所有主流编程语言,如 Python、Java、C++、Go 等。


c. Redis 的使用场景

  1. 缓存

  • Redis 常用作缓存层,加速数据查询,比如缓存用户会话、热点数据、商品详情等。

  • 比如在电商网站中,商品的访问量很大,可以通过 Redis 缓存商品信息,减少数据库压力。

  1. 会话管理

  • 将用户的会话(Session)数据存储在 Redis 中,常见于用户登录、认证信息存储。

  • 和传统方式相比,Redis 的速度快,数据操作灵活。

  1. 排行榜/计数器

  • Redis 的有序集合(Sorted Set)非常适合做实时排行榜,比如游戏积分排行。

  • 还可以用作页面访问计数等功能。

  1. 分布式锁

  • Redis 的 setnxexpire 指令可以用来实现简单的分布式锁。

  • 比如在高并发环境下,保证同一资源不被多线程同时修改。

  1. 消息队列

  • Redis 的列表(List)和发布/订阅(Pub/Sub)功能可以用来实现简单的消息队列。

  1. 实时分析和统计

  • Redis 的位图(Bitmap)和 HyperLogLog 数据结构可以高效统计大数据量中的唯一用户。


d. Redis 的优点

  1. 速度快

  • 所有操作都在内存中进行,基于单线程模型,避免了多线程的上下文切换问题。

  1. 多功能性

  • 支持多种数据结构,功能丰富,适应多种场景。

  1. 高并发

  • Redis 可以轻松处理上百万的并发请求。

  1. 扩展性

  • 支持主从架构、哨兵和集群模式,支持水平扩展。

  1. 简单易用

  • Redis 的 API 非常简单,几乎只需要熟悉基本的 Key-Value 操作即可。


e. Redis 的缺点

  1. 内存依赖

  • 由于 Redis 是内存数据库,数据完全存储在内存中,内存容量成为限制。

  1. 数据一致性问题

  • 因为是 NoSQL 数据库,强一致性较难保证(尤其在主从复制和 AOF 持久化时)。

  1. 单线程性能限制

  • Redis 虽然是单线程处理模型,但对单机的 CPU 性能依赖较大。


f. Redis 的常见命令

  1. String 操作

  • SET key value:设置一个键值对。

  • GET key:获取 key 的值。

  • INCR key:将 key 的值加 1,适用于计数。

  • EXPIRE key seconds:设置 key 的过期时间。

  1. Hash 操作

  • HSET key field value:设置哈希表的字段值。

  • HGET key field:获取哈希表的字段值。

  1. List 操作

  • LPUSH key value:从左侧插入列表。

  • LRANGE key start stop:获取列表中的指定范围元素。

  1. Set 操作

  • SADD key value:向集合添加元素。

  • SMEMBERS key:获取集合中的所有成员。

  1. Sorted Set 操作

  • ZADD key score member:向有序集合添加元素及其分数。

  • ZRANGE key start stop:按索引范围获取有序集合的成员。

  1. 持久化操作

  • SAVE:触发 RDB 快照保存。

  • BGSAVE:异步触发 RDB 快照保存。


g. 我在项目中的使用经验

  1. 缓存加速

  • 在用户登录系统中,使用 Redis 存储用户 Session 信息,通过 Key 的过期时间来管理登录有效期。

  • 在电商项目中,将 Redis 用于商品详情页面的缓存,加速页面加载速度,减少数据库压力。

  1. 分布式锁

  • 在高并发的场景中,使用 Redis 的 setnxexpire 实现分布式锁,确保只有一个线程能操作共享资源,避免数据冲突。

  1. 实时排行榜

  • 在一个社交平台中,利用 Redis 的 Sorted Set 实现用户点赞数的排行榜功能,支持实时更新和快速查询。

  1. 消息队列

  • 在任务分发系统中,使用 Redis 的 List 数据结构实现简单的消息队列,处理异步任务。


h. 总结

Redis 是一个功能强大、性能卓越的内存数据库,适用于多种场景。在实际项目中,Redis 的灵活性和高性能为我解决了许多问题,如加速缓存、实现分布式锁和消息队列等。如果项目中需要处理高并发、低延迟的场景,Redis 是一个非常好的选择。

定时器相关

1. 为什么要用定时器?

在服务器中,为了防止连接资源的浪费,需要定期删除非活跃的连接。非活跃连接是指浏览器和服务器建立连接后,长时间没有数据交换,但依然占用服务器的文件描述符,导致连接资源的浪费。

为了解决这个问题,服务器会使用定时事件机制。定时事件是指在固定时间后触发某段代码,该代码会负责处理非活跃连接,例如从内核事件表中删除对应的事件、关闭文件描述符,并释放连接资源。通过这种方式,服务器可以有效管理资源,避免因为非活跃连接过多而导致性能下降或资源耗尽的问题。

这种机制在高并发服务器中尤为重要,可以保障服务器的稳定性和资源的高效利用。

2. 说一下定时器的工作原理

在高并发服务器中,为了定期检测非活跃连接并释放资源,可以利用定时器和信号机制实现这一功能。具体过程如下:

服务器会为每一个连接创建一个定时器,并用结构体将定时事件封装起来。所有定时器会按超时时间以升序双向链表的形式串联在一起。服务器通过定时器对每个连接进行定时,定期检测是否有非活跃连接。

通过 alarm 函数周期性地触发 SIGALRM 信号。当 SIGALRM 信号被触发时,信号处理函数会通过管道将信号通知给程序的主循环。主循环接收到信号后,开始处理升序链表中的所有定时器,判断哪些连接在该时间段内没有交换数据。如果发现非活跃连接,就会关闭连接并释放其占用的资源。

a. 为什么信号逻辑放在主循环中?

信号处理函数的作用是尽量简化,仅发送信号来通知程序主循环,由主循环执行信号对应的处理逻辑。这种设计可以避免在信号处理函数中执行复杂逻辑,减少对正常程序流程的干扰,提高系统稳定性。

b. 为什么管道写端需要非阻塞?

在信号通知的逻辑中,通过创建管道,信号处理函数将信号值写入管道的写端,而主循环通过管道读端结合 I/O 复用系统(如 selectepoll)来监测读事件。这里需要将管道写端设置为非阻塞模式,原因是:

  • 当管道的缓冲区满了,调用 send 写入数据时,如果是阻塞模式,会导致信号处理函数阻塞,从而延长信号处理的执行时间。

  • 信号处理函数的任务是快速响应信号并通知主循环,阻塞会降低信号处理效率,甚至可能影响服务器的整体性能。

因此,将管道写端设置为非阻塞模式,即使缓冲区已满,写入操作也会立即返回错误,而不会阻塞信号处理函数的执行,确保信号能够及时传递给主循环。

c. 总结

通过定时器和信号机制,服务器可以高效检测非活跃连接并释放资源,同时通过管道实现信号通知。采用非阻塞管道写端,确保信号处理函数执行迅速、可靠,从而提升服务器的稳定性和性能。

3. 双向链表啊,删除和添加的时间复杂度说一下?还可以优化吗?

在定时器管理中,使用双向链表实现的时间复杂度和触发机制存在一定的不足。以下是对其特性、缺点以及优化方式的总结:

a. 时间复杂度

  1. 删除定时器:在双向链表中,删除定时器的时间复杂度为 O(1),因为链表提供了快速删除的能力。

  2. 添加或修改定时器:时间复杂度为 O(n),因为需要遍历链表找到插入或更新的位置(特别是当新定时器的时间较大时,需要遍历到尾节点)。


b. 缺点

在使用双向链表和 SIGALRM 信号处理的情况下,每次以固定的时间间隔触发定时任务处理函数可能会造成触发浪费。具体来说:

  • 假设定时器的时间片 TIMESLOT=5ms,即每隔 5ms 触发一次 SIGALRM 信号,主循环会处理链表上的定时任务。

  • 如果当前最近的超时任务还有 20ms 超时,那么在这 20ms 时间内,信号会触发 4 次,但这 4 次中前 3 次实际上是不必要的,因为没有超时任务需要处理,造成了额外的性能消耗。

这种机制在高并发场景下,会带来不必要的性能浪费。


c. 优化方法

ⅰ. 1. 基于双向链表的优化

可以通过以下改进减少触发浪费:

  • 添加新定时器时优化插入位置

  • 在插入新定时器时,除了判断其是否小于头节点(快速插入到链表头部),还应该检测是否大于尾节点(快速插入到链表尾部)。

  • 仅当新定时器的超时时间既不小于头节点,也不大于尾节点时,才需要进行常规插入,逐一比较找到插入位置。

  • 这种方式减少了无意义的链表遍历操作,提升了添加新定时器的效率。

ⅱ. 2. 使用最小堆

相比双向链表,最小堆是一种更高效的定时器管理方式,解决了触发浪费问题。主要特点如下:

  • 时间复杂度

  • 添加、删除、修改定时器的时间复杂度均为 O(log n)

  • 最小堆的优势

  • 最小堆始终将超时时间最短的定时器置于堆顶,因此只需要触发一次定时任务即可。

  • 例如,如果堆顶定时器还有 20ms 超时,只需设定一个 20ms 的定时器,无需每隔 5ms 重复触发 SIGALRM 信号。

  • 当堆顶的定时器被触发超时时,会移除堆顶元素,同时将剩余的定时器重新调整堆结构。

使用最小堆可以显著减少无意义的信号触发次数,提高定时器管理的效率和性能。


d. 总结

  • 使用双向链表时,删除定时器的时间复杂度为 O(1),但插入和修改的复杂度为 O(n),并且容易造成信号触发浪费。优化方案是添加时快速判断是否插入到头部或尾部。

  • 使用最小堆可以更高效地管理定时器,其插入、删除和修改的时间复杂度均为 O(log n),并能显著减少不必要的信号触发。因此,最小堆在性能和触发效率上更优,更适合高并发场景。

4. 最小堆优化?说一下时间复杂度和工作原理

在使用最小堆优化定时器管理时,添加和删除定时器的时间复杂度分别为 O(log n)O(1)。其工作原理如下:

最小堆始终将超时时间最短的定时器置于堆顶。因此,每次只需将堆顶定时器的超时时间设置为定时任务处理函数的触发时间。一旦定时任务处理函数被触发,堆顶的定时器必然到期,函数即可处理该定时器。

接着,从剩余定时器中通过堆操作找出新的超时时间最小的定时器(堆顶),将其超时时间设置为下一次触发的定时时间。如此循环反复,定时器触发始终保持高效且精确。

这种机制通过动态调整最小堆,确保只处理到期的定时器任务,避免了不必要的信号触发和资源浪费,同时提供了高效的定时器管理能力,非常适合高并发和实时性要求高的场景。

日志相关

1. 说下你的日志系统的运行机制?

为了实现高效的日志系统,可以通过单例模式设计一个日志类,用于记录服务器的运行状态、错误信息和访问数据。该日志系统具有按天分类超行分文件的功能,并根据实际需求支持同步写入异步写入两种模式。以下是实现的关键点和逻辑总结:


a. 日志系统的功能设计

  1. 单例模式

  • 日志系统采用懒汉模式的单例设计,确保在程序运行期间只有一个日志实例,避免多线程环境中的资源竞争。

  • 通过局部变量实现线程安全的单例获取方式。

  1. 日志分类

  • 按天分类:日志写入前会检查当前日期是否为日志创建时的日期。如果日期发生变化,则自动按新的日期创建新日志文件。

  • 超行分文件:每个日志文件有最大行数限制。如果当前文件的行数超过限制,则在文件名末尾添加计数后缀(如 log_1.txt, log_2.txt)创建新文件。

  1. 同步和异步写入

  • 同步模式:日志内容直接写入文件。

  • 异步模式:利用生产者-消费者模型将日志写入操作异步处理:

  • 日志内容被工作线程写入阻塞队列

  • 单独创建一个写线程从队列中取出内容并写入日志文件,避免工作线程因 I/O 操作而阻塞,提高程序整体运行效率。


b. 核心逻辑实现

  1. 日志写入逻辑

  • 每次写入日志时,会检查以下条件:

  • 当前日期是否与日志创建日期一致:

  • 如果一致,继续写入。

  • 如果日期变化,创建新日志文件并更新日期。

  • 当前文件行数是否超出最大行限制:

  • 如果超出,在当前文件末尾添加后缀计数,创建新的日志文件。

  • 日志内容格式化后,写入文件或阻塞队列。

  1. 同步写入模式

  • 直接判断是否需要分文件(按天分类或超行分文件)。

  • 格式化日志内容后,立即将信息写入日志文件。

  1. 异步写入模式

  • 日志内容被格式化后写入阻塞队列

  • 创建一个专用的写线程,从阻塞队列中取出日志内容,判断是否需要分文件,并最终将内容写入日志文件。


c. 异步写入的生产者-消费者模型

  • 生产者

  • 工作线程作为日志内容的生产者,将日志内容通过 push 操作写入阻塞队列。

  • 消费者

  • 写线程作为消费者,通过 pop 操作从阻塞队列中取出日志内容,判断是否需要分文件并最终写入日志文件。

这种设计有效解耦了日志写入和业务逻辑,提升了日志系统的效率,特别是在高并发场景下。


d. 总结

这套日志系统通过单例模式实现全局唯一实例,提供了按天分类、超行分文件的功能,支持同步和异步两种写入方式。在同步模式下,直接写入日志文件;在异步模式下,采用生产者-消费者模型,利用阻塞队列和写线程高效处理日志写入任务。该设计既保证了日志记录的灵活性和可扩展性,又在高并发环境下提升了服务器的运行效率。

2. 为什么要异步?和同步的区别是什么?

在日志系统中,同步写入异步写入是两种常见的日志写入方式,各有优劣。在高并发场景下,异步写入通过引入生产者-消费者模型,可以有效提升系统性能,避免同步写入中的性能瓶颈。


a. 同步日志写入的问题

  • 工作原理:在同步模式下,日志写入函数与工作线程串行执行,日志内容直接通过 I/O 操作写入文件。

  • 问题

  1. 系统调用频繁:同步写入会产生大量的系统调用,尤其是在高并发场景中,这可能导致性能下降。

  2. 阻塞问题:当单条日志信息较大时,I/O 操作会阻塞整个日志系统,工作线程必须等待日志写入完成后才能继续处理其他任务。

  3. 系统瓶颈:由于 I/O 操作的阻塞特性,在高峰期时,写日志可能成为整个系统的瓶颈,导致服务器的并发能力下降。


b. 异步日志写入的优点

为了克服同步写入的问题,异步模式通过引入生产者-消费者模型进行日志写入:

  • 生产者-消费者模型

  • 是并发编程中的经典模型,通过共享缓冲区实现线程间的数据同步。

  • 在日志系统中:

  • 生产者线程:负责将日志内容写入共享缓冲区(阻塞队列)。

  • 消费者线程:从共享缓冲区取出日志内容并写入文件。

  • 阻塞队列

  • 生产者-消费者模型封装为阻塞队列,使用循环数组实现,作为两者共享的缓冲区。

  • 队列为空时,消费者线程阻塞;队列满时,生产者线程阻塞,从而实现数据同步。

  • 异步日志的工作原理

  1. 日志内容由生产者线程先存入阻塞队列。

  2. 单独创建一个写线程(消费者线程),负责从阻塞队列中取出日志内容并写入日志文件。

  • 优点

  1. 减少阻塞:工作线程只需将日志内容写入阻塞队列即可,写日志的操作由独立的线程完成,避免了工作线程的阻塞。

  2. 提升性能:异步写入消除了日志写入与主线程间的串行依赖,大大提高了系统的并发能力。

  3. 灵活性:异步模式在高并发场景下表现尤为突出,能够更好地应对流量高峰。


c. 写入方式的判断

在日志系统中,可以通过初始化时设置队列大小来决定采用同步还是异步写入方式:

  • 队列大小为 0:表示不使用队列,直接采用同步写入模式,日志内容加锁后直接写入文件。

  • 队列大小大于 0:表示使用阻塞队列,开启异步模式,将日志内容加入队列,由写线程负责从队列中取出并写入文件。


d. 总结

  • 同步写入适用于日志内容较小、并发压力不大的场景,但在高并发环境下,由于 I/O 阻塞会导致服务器处理性能下降,甚至出现系统瓶颈。

  • 异步写入通过引入生产者-消费者模型,利用阻塞队列分离日志内容的生产与写入操作,避免了工作线程因 I/O 阻塞而停滞,大大提升了系统的并发性能。

  • 根据实际需求,日志系统可以通过设置队列大小选择同步或异步写入方式,灵活适应不同场景。

3. 现在你要监控一台服务器的状态,输出监控日志,请问如何将该日志分发到不同的机器上?(消息队列)

在监控一台服务器的状态并将日志分发到不同机器上时,可以通过消息队列实现高效、可靠的日志分发。以下是详细的实现方式:


a. 使用消息队列分发日志的整体架构

消息队列的作用是充当生产者消费者之间的中间件,实现异步解耦、分布式日志分发,确保日志高效传递到不同的机器。

ⅰ. 1. 架构设计

  • 生产者

  • 负责监控服务器状态,采集监控数据并生成日志。

  • 将生成的日志内容推送到消息队列中。

  • 消息队列(Broker)

  • 负责接收生产者发送的日志消息,并将其存储到队列中。

  • 支持多消费者订阅,通过路由规则将日志分发到不同的机器。

  • 消费者

  • 分布式部署在不同的机器上,从消息队列中拉取日志消息进行处理(如存储、分析或展示)。


b. 实现步骤

ⅰ. 1. 选择消息队列工具

常用的消息队列工具包括:

  • Kafka:高吞吐量,适合大规模日志数据分发。

  • RabbitMQ:支持灵活的路由规则,适合复杂的日志分发场景。

  • Redis:轻量级消息队列,适合简单的日志分发场景。

  • RocketMQ:适合分布式系统,具有高可靠性和可扩展性。

ⅱ. 2. 配置生产者

  • 在监控服务器上,运行日志采集程序(生产者)。

  • 将采集到的日志以消息的形式推送到消息队列中。

  • 生产者需要配置如下内容:

  • 队列名称:用于指定生产者写入的消息队列。

  • 消息格式:标准化日志格式(如 JSON、文本等)。

  • 消息推送模式:可以选择同步发送或异步发送。

ⅲ. 3. 配置消息队列(Broker)

  • 部署消息队列服务(如 Kafka、RabbitMQ 等)。

  • 配置队列规则:

  • 队列主题(Topic):按日志类型(如错误日志、性能日志)划分不同的主题。

  • 分区策略:为队列分配分区,提升日志分发效率。

  • 路由规则:通过交换机或分区键将日志消息分发到不同的消费者队列。

  • 开启高可用(HA)模式,确保日志分发的可靠性。

ⅳ. 4. 配置消费者

  • 在目标机器上部署消费者服务,负责从消息队列中拉取日志。

  • 消费者需要配置如下内容:

  • 订阅的队列或主题:指定需要拉取的日志类型。

  • 消费模式:如负载均衡模式(每个消费者分摊队列中的日志消息)。

  • 消息处理逻辑:如存储到文件、插入数据库、发送到监控平台等。

  • 配置消费确认机制(如手动 ACK 或自动 ACK),确保日志处理的可靠性。


c. 日志分发的核心逻辑

  1. 日志生成

  • 服务器监控程序采集日志(如 CPU、内存使用率、网络流量等),将日志数据打包成消息。

  • 日志消息通过 API 推送到消息队列。

  1. 消息队列分发

  • 消息队列根据主题、路由键或分区规则,将日志消息分发到不同的消费者队列。

  1. 日志处理

  • 消费者在目标机器上从消息队列中拉取日志,进行存储或分析。


d. 关键技术点

  1. 高可靠性

  • 使用消息队列的持久化功能,将日志消息存储在磁盘中,防止消息丢失。

  • 配置生产者和消费者的重试机制,确保消息可靠传递。

  1. 扩展性

  • 如果需要分发到更多机器,可以动态添加消费者,消息队列会自动负载均衡分发消息。

  • 通过分区和主题分类,提升分发性能。

  1. 灵活性

  • 使用路由键或主题,可以实现针对性分发。例如,错误日志分发到错误处理系统,性能日志分发到性能监控系统。


e. 总结

使用消息队列分发日志的流程如下:

  1. 生产者:监控服务器生成日志,并推送到消息队列。

  2. 消息队列:根据主题或路由规则,将日志消息分发到不同的消费者队列。

  3. 消费者:在目标机器上拉取日志并进行存储或分析。

这种方式通过异步解耦和分布式架构,解决了日志分发的性能瓶颈,提高了系统的可靠性和扩展性,非常适合高并发和大规模日志监控场景。

压测相关

1. 服务器并发量测试过吗?怎么测试的?

在系统性能测试中,有几个重要的指标用于衡量系统的吞吐量和性能表现:

  1. TPS(Transactions Per Second)

  • 表示每秒服务器能够处理的事务数,通常用于描述服务器的整体吞吐能力。

  1. QPS(Queries Per Second)

  • 表示每秒服务器响应的查询请求数量,是系统处理能力的重要指标,特别是在 Web 应用中。

  1. 并发数

  • 指系统在同一时刻同时处理的请求数,反映了系统同时服务多个请求的能力。

  1. 响应时间

  • 每个请求从发送到接收到服务器响应所需的时间,通常使用平均响应时间作为衡量标准。

a. 指标关系

上述指标之间有如下关系:
QPS(TPS) = 并发数 / 平均响应时间
这意味着,在同等响应时间下,系统的并发能力越强,QPS(TPS)就越高。


b. 压力测试的相关指标

在压力测试中,还有以下重要的指标:

  • 每分钟响应请求数(pages/min):表示服务器在每分钟内成功处理的请求数。

  • 每秒传输数据量(bytes/sec):表示服务器每秒处理的流量,反映了数据传输的吞吐能力。


c. 使用 Webbench 进行压力测试

使用 Webbench 进行压力测试,可以模拟大规模并发访问以评估服务器的性能。例如:

  • 创建 1000个客户端,并发访问服务器持续 10秒

  • 在正常情况下,Webbench 生成的测试数据表明服务器可以处理接近 8万个 HTTP 请求,表明其在高并发下的良好吞吐能力。


d. 总结

系统的吞吐能力由 TPS(QPS)、并发数、响应时间 等指标共同衡量。通过压力测试工具(如 Webbench),可以模拟高并发环境,对服务器的处理能力进行评估。分析这些指标的关系,可以帮助发现系统瓶颈并进行优化,以提升服务器的并发性能和整体吞吐量。

2. webbench是什么?介绍一下原理

WebBench 是一款简单高效的 Linux 下压力测试工具,用于评估 Web 服务器的负载能力。其工作原理和使用方式如下:


a. 工作原理

  1. 父子进程协作

  • WebBench 的父进程会 fork 出多个子进程,每个子进程在指定的测试时间内对目标 Web 服务器循环发送实际访问请求。

  • 子进程记录其发出的总请求数等信息,并通过管道将数据传递给父进程。

  1. 管道通信

  • 子进程通过管道的写端,将测试结果发送给父进程。

  • 父进程通过管道的读端接收来自所有子进程的信息。

  1. 结果统计

  • 子进程在测试时间结束后退出。

  • 父进程等待所有子进程退出后,统计测试结果并输出,例如总请求数、成功请求数等。

  1. 并发模拟

  • WebBench 可以最多模拟 3万个并发连接,通过高并发请求对目标网站进行压力测试,从而评估服务器的负载能力。


b. 常用参数

  • -c:指定子进程个数,即模拟的并发数。例如,-c 1000 表示模拟 1000 个并发请求。

  • -t:指定测试时间。例如,-t 10 表示测试运行 10 秒。


c. 示例

  • 命令webbench -c 1000 -t 10 http://example.com

  • 该命令会创建 1000 个子进程,模拟 1000 个并发请求访问目标网站,并持续测试 10 秒。

  • 测试完成后,WebBench 输出统计结果,包括总请求数、成功请求数等。


d. 总结

WebBench 是一款轻量级的压力测试工具,通过 fork 子进程模拟高并发连接,评估 Web 服务器的处理能力。它可以快速测试服务器在高负载下的表现,并输出直观的测试结果,例如总请求数和成功率。其简单易用的特点使其成为 Web 压力测试的重要工具,尤其适用于 Linux 环境。

3. 测试的时候有没有遇到问题?

---在一次使用 Webbench 对服务器进行压力测试时,我们创建了 1000 个客户端并发访问服务器,测试持续 10 秒。理论上服务器应该能处理接近 8 万个请求,但测试结果显示只有 7 个请求被成功处理,且没有任何请求失败或错误返回。然而,测试结束后通过浏览器访问服务器时发现,服务器已经无法正常响应,必须重启才能恢复。

针对这个问题,我们从两方面进行排查:HTTP 请求的处理逻辑和连接的接收逻辑。首先,通过查看日志发现,成功处理的 7 个请求都是标准的 GET 请求,且返回正确,说明 HTTP 请求处理逻辑是没有问题的。于是我们重点排查了服务器接收 HTTP 请求的连接部分,涉及的流程是 socket -> bind -> listen -> accept

最终定位到问题出在使用了 epoll 的 ET(边缘触发)模式。ET 模式的特点是,当文件描述符有事件发生时,只通知一次,要求应用程序立即处理所有连接。我们的服务器在处理连接时,没有一次性 accept 所有已准备好的连接,而是只处理了一个连接就返回了,这导致剩余的连接没有被处理,TCP 已建立队列被迅速填满,服务器无法接收新的连接请求。

针对这个问题,我们提出了两种解决方案:第一,将监听套接字设置为 LT(水平触发)模式,在 LT 模式下,即使未一次性处理完所有连接,epoll_wait 仍会重复触发,从而逐一处理所有连接。第二,在 ET 模式下,改进逻辑,用 while 循环包裹 accept 操作,确保一次性处理完队列中的所有连接,直到返回 EAGAIN。

最终,我们采用了第二种方法,既保留了 ET 模式的高性能特点,又通过循环确保所有连接都能被正确处理,从而解决了这个问题。这次经历让我意识到,在高并发场景下,理解 epoll 的触发模式并合理设计处理逻辑是至关重要的。

Bug描述:
在使用 Webbench 对服务器进行压力测试时,创建 1000 个客户端并发访问服务器,测试持续 10 秒。正常情况下,服务器应处理接近 8 万个 HTTP 请求。然而测试结果显示,仅有 7 个请求被成功处理,0 个请求失败,且服务器没有返回任何错误信息。但此时通过浏览器访问服务器时,发现服务器无法正常响应请求,必须重启服务器后才能恢复正常。


排查过程:
通过查询服务器运行日志,针对 HTTP 请求连接的接收部分HTTP 请求处理逻辑 两个模块进行排查。

  1. 日志显示,成功处理的 7 个请求的报文均为标准的 GET / HTTP/1.0 格式,且服务器正确响应。这排除了 HTTP 请求处理逻辑的错误可能性。

  2. 排查重点转向 服务器接收 HTTP 请求连接的部分,涉及的流程为:

  • socket -> bind -> listen -> accept


错误原因分析:
问题出现在错误使用 epoll 的 ET(边缘触发)模式上:

  • 在 ET 模式下,epoll_wait 检测到文件描述符上有事件发生后,会将该事件通知应用程序,要求应用程序立即处理。

  • 应用程序在处理事件时,必须使用非阻塞 I/O,将数据一次性读取完毕,直到返回 EAGAIN(表示数据读取完毕)。否则,未处理完的事件不会再次触发通知,导致后续事件被遗漏。

具体场景问题:

  • 当客户端连接较少时,即使监听套接字(listenfd)使用 ET 非阻塞模式,未一次性处理完所有事件,仍不会出现明显问题,因为连接队列不会被填满。

  • 当 1000 个客户端同时发起连接时,大量连接涌入导致 TCP 已建立连接队列(established queue)被迅速填满。然而服务器的 accept 操作未在 while 循环中一次性处理所有已准备好的连接,只处理了一个连接后返回,导致队列中剩余的连接得不到处理,也无法接收新的连接请求。最终,服务器出现连接阻塞,无法继续工作。


解决方案:
根据问题分析,提出以下两种解决方法:

  1. 将监听套接字(listenfd)设置为 LT(水平触发)模式:在 LT 模式下,即使未一次性处理完所有事件,epoll_wait 仍会重复触发,确保所有连接都能被逐一处理。

  2. 在 ET(边缘触发)非阻塞模式下,使用 while 循环包裹 accept 操作:通过循环,一次性处理 TCP 就绪队列中的所有连接,直到返回 EAGAIN,确保没有连接被遗漏。

这两种方法均可有效解决连接队列阻塞问题,从而支持大规模并发访问。

以下是两种解决方案的代码示例:


解决方案 1:将监听套接字设置为 LT(水平触发)模式

在 LT 模式下,不需要特别处理事件读取的细节。即使事件未处理完,epoll_wait 会重复触发,确保所有连接都能被逐一处理。

#include <sys/epoll.h>
#include <unistd.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <arpa/inet.h>
#include <string.h>
#include <errno.h>

#define MAX_EVENTS 1000
#define PORT 8080

int main() {
    int listenfd, connfd, epfd;
    struct sockaddr_in server_addr, client_addr;
    socklen_t client_len = sizeof(client_addr);
    struct epoll_event ev, events[MAX_EVENTS];

    // 创建监听套接字
    listenfd = socket(AF_INET, SOCK_STREAM, 0);
    if (listenfd < 0) {
        perror("Socket creation failed");
        exit(EXIT_FAILURE);
    }

    // 设置为非阻塞模式
    int flags = fcntl(listenfd, F_GETFL, 0);
    fcntl(listenfd, F_SETFL, flags | O_NONBLOCK);

    // 绑定地址和端口
    server_addr.sin_family = AF_INET;
    server_addr.sin_addr.s_addr = INADDR_ANY;
    server_addr.sin_port = htons(PORT);
    bind(listenfd, (struct sockaddr*)&server_addr, sizeof(server_addr));

    // 开始监听
    listen(listenfd, 128);

    // 创建 epoll 实例
    epfd = epoll_create(1);
    if (epfd < 0) {
        perror("epoll_create failed");
        exit(EXIT_FAILURE);
    }

    // 注册监听套接字到 epoll,使用 LT 模式
    ev.events = EPOLLIN; // 水平触发
    ev.data.fd = listenfd;
    epoll_ctl(epfd, EPOLL_CTL_ADD, listenfd, &ev);

    while (1) {
        int nfds = epoll_wait(epfd, events, MAX_EVENTS, -1);
        for (int i = 0; i < nfds; ++i) {
            if (events[i].data.fd == listenfd) {
                // 接受新的连接
                connfd = accept(listenfd, (struct sockaddr*)&client_addr, &client_len);
                if (connfd < 0) {
                    perror("accept failed");
                    continue;
                }
                printf("Accepted new connection: %d\n", connfd);

                // 设置新连接为非阻塞
                flags = fcntl(connfd, F_GETFL, 0);
                fcntl(connfd, F_SETFL, flags | O_NONBLOCK);

                // 注册新连接到 epoll
                ev.events = EPOLLIN;
                ev.data.fd = connfd;
                epoll_ctl(epfd, EPOLL_CTL_ADD, connfd, &ev);
            } else {
                // 处理客户端请求
                char buffer[1024];
                int n = read(events[i].data.fd, buffer, sizeof(buffer));
                if (n > 0) {
                    printf("Received data: %s\n", buffer);
                    write(events[i].data.fd, "HTTP/1.1 200 OK\r\n\r\nHello World", 33);
                } else if (n == 0) {
                    // 关闭连接
                    printf("Connection closed: %d\n", events[i].data.fd);
                    close(events[i].data.fd);
                } else {
                    perror("read failed");
                }
            }
        }
    }

    close(listenfd);
    close(epfd);
    return 0;
}

解决方案 2:在 ET(边缘触发)非阻塞模式下使用 while 循环处理 accept

在 ET 模式下,需要确保一次性处理所有已准备好的连接。通过 while 循环包裹 accept,直到返回 EAGAIN

#include <sys/epoll.h>
#include <unistd.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <arpa/inet.h>
#include <string.h>
#include <errno.h>

#define MAX_EVENTS 1000
#define PORT 8080

int main() {
    int listenfd, connfd, epfd;
    struct sockaddr_in server_addr, client_addr;
    socklen_t client_len = sizeof(client_addr);
    struct epoll_event ev, events[MAX_EVENTS];

    // 创建监听套接字
    listenfd = socket(AF_INET, SOCK_STREAM, 0);
    if (listenfd < 0) {
        perror("Socket creation failed");
        exit(EXIT_FAILURE);
    }

    // 设置为非阻塞模式
    int flags = fcntl(listenfd, F_GETFL, 0);
    fcntl(listenfd, F_SETFL, flags | O_NONBLOCK);

    // 绑定地址和端口
    server_addr.sin_family = AF_INET;
    server_addr.sin_addr.s_addr = INADDR_ANY;
    server_addr.sin_port = htons(PORT);
    bind(listenfd, (struct sockaddr*)&server_addr, sizeof(server_addr));

    // 开始监听
    listen(listenfd, 128);

    // 创建 epoll 实例
    epfd = epoll_create(1);
    if (epfd < 0) {
        perror("epoll_create failed");
        exit(EXIT_FAILURE);
    }

    // 注册监听套接字到 epoll,使用 ET 模式
    ev.events = EPOLLIN | EPOLLET; // 边缘触发
    ev.data.fd = listenfd;
    epoll_ctl(epfd, EPOLL_CTL_ADD, listenfd, &ev);

    while (1) {
        int nfds = epoll_wait(epfd, events, MAX_EVENTS, -1);
        for (int i = 0; i < nfds; ++i) {
            if (events[i].data.fd == listenfd) {
                // 使用 while 循环处理所有就绪连接
                while (1) {
                    connfd = accept(listenfd, (struct sockaddr*)&client_addr, &client_len);
                    if (connfd < 0) {
                        if (errno == EAGAIN || errno == EWOULDBLOCK) {
                            // 所有连接已处理完毕
                            break;
                        } else {
                            perror("accept failed");
                            break;
                        }
                    }
                    printf("Accepted new connection: %d\n", connfd);

                    // 设置新连接为非阻塞
                    flags = fcntl(connfd, F_GETFL, 0);
                    fcntl(connfd, F_SETFL, flags | O_NONBLOCK);

                    // 注册新连接到 epoll
                    ev.events = EPOLLIN | EPOLLET;
                    ev.data.fd = connfd;
                    epoll_ctl(epfd, EPOLL_CTL_ADD, connfd, &ev);
                }
            } else {
                // 处理客户端请求
                char buffer[1024];
                while (1) {
                    int n = read(events[i].data.fd, buffer, sizeof(buffer));
                    if (n > 0) {
                        printf("Received data: %s\n", buffer);
                        write(events[i].data.fd, "HTTP/1.1 200 OK\r\n\r\nHello World", 33);
                    } else if (n == 0) {
                        // 关闭连接
                        printf("Connection closed: %d\n", events[i].data.fd);
                        close(events[i].data.fd);
                        break;
                    } else {
                        if (errno == EAGAIN || errno == EWOULDBLOCK) {
                            // 数据读取完成
                            break;
                        } else {
                            perror("read failed");
                            break;
                        }
                    }
                }
            }
        }
    }

    close(listenfd);
    close(epfd);
    return 0;
}

总结

  • LT 模式 简化了事件处理逻辑,适合初学者或不需要高性能的场景。

  • ET 模式 性能更高,但需要额外的处理逻辑(如 while 循环确保处理完所有事件)。

根据实际需求选择适合的模式和代码实现方式。

综合能力

1. 你的项目解决了哪些其他同类项目没有解决的问题?

——自己造轮子;

2. 说一下前端发送请求后,服务器处理的过程,中间涉及哪些协议?

——HTTP协议、TCP、IP协议等,计算机网络的知识。

参考链接:

https://zhuanlan.zhihu.com/p/368154495

https://blog.nowcoder.net/n/b4f527cbf63b446d84c5367bbb904d1a

https://blog.csdn.net/qq_39969848/article/details/142316685?ops_request_misc=%257B%2522request%255Fid%2522%253A%25224e9fdbddcaaeae4aba9e86de2a5f26fb%2522%252C%2522scm%2522%253A%252220140713.130102334..%2522%257D&request_id=4e9fdbddcaaeae4aba9e86de2a5f26fb&biz_id=0&utm_medium=distribute.pc_search_result.none-task-blog-2~all~sobaiduend~default-1-142316685-null-null.142^v100^pc_search_result_base4&utm_term=tinywebserver%E9%9D%A2%E8%AF%95%E9%97%AE%E9%A2%98&spm=1018.2226.3001.4187