1. 自我介绍
面试官您好,我叫乔贝贝,24岁,目前就读于西北工业大学人工智能专业硕士二年级,常用语言是C++和python。我对C++11/14/20特性,C++动态内存管理,STL容器/算法比较熟悉。同时对C++并发编程以及网络编程有较长的使用经验,熟悉IO多路复用、异步通信,以及linux下的进程/线程调度和通信模型。除此之外,我还了解python、CSS、HTML这些语言,并对git、cmake、docker这些开发工具有一定的使用经验。
目前基于课题组核心业务完成两项项目,分别是分布式集群卫星协同训练通信框架和分布式及时通信应用软件,主要通过分布式微服务架构实现了跨服务通信、多线程安全并发以及心跳检测、服务路由、超时控制以及动态负载均衡等功能。目前就以上项目已发布两篇论文和一篇专利。
本人自学能力较强,善于将所学知识积极产出,目前累计发布博客八十多篇。
以上是我的自我介绍,谢谢。
2. 描述一下你的项目遇到了哪些困难,怎么解决的
1.因为TCP底层不保留消息的边界,并且调用异步发送时,数据被立即复制到内核发送缓冲区后,函数立即返回,不等待数据实际发送到网络。如果在第一个消息尚未完全发送(缓冲区仍有残留)时,再次调用异步发送第二个消息,这时候的数据就可能是乱序的。
解决:封装了一个发送接口,通过队列和回调机制确保异步发送的顺序性,异步发送时,将需要发送的数据插入队列,下一个消息按序排列到上一个消息后面,只有当上一个消息发送完毕,下一个消息才会从消息队列中pop出发送)。
实现的关键在于:多次发送时,异步的发送要保证回调触发后再次发送才能确保数据是有序的(队列)。异步发绑定一个回调函数,然后回调函数被触发,通过标志位判断是否发送成功,如果成功则在回调函数中取出消息队列的下一个数据继续调用异步发,同时继续绑定该回调函数。
2.在对消息进行相应回调函数的执行时,延迟太大(比如玩家需要叠加各自buff或者技能,此时可能会耗时1s甚至更多,如果没有独立的逻辑层进行操作,那么系统会一直停留在执行回调函数那一步,造成阻塞,直至操作结束),如何根据发送来的消息进行相对应的处理?
解法1:(单线程下)构建逻辑层单独处理,我们只需要将收到的消息发到消息队列中,然后继续接收下一个,回调函数的处理由逻辑层处理,将id对应的回调函数注册至回调队列中,接收的数据发送至逻辑队列,逻辑队列一个个的取消息,然后根据id执行对应的回调函数。
解法2:(多线程下不能用IOServicePool,该方法仍旧会造成阻塞,除非是IOServicePool和逻辑层共同使用)构建IOThreadPool,第二种模式是一个io_context跑在多个线程中,即多个线程共享一个io_context。这种模式下不会造成之前的那种阻塞情况,因为在就绪事件队列中的事件不是一个线程处理了,而是不同的线程共享一个就绪事件队列,不同线程会触发执行不同的回调函数,即使某个回调处理的比较慢,但由于其他事件被不同线程处理了,系统并不需要阻塞等待该回调处理完成之后在执行处理其他回调。
3.在设计分布式高并发系统的时候,遇到了单服务器性能上限导致并发数量提不上去的困顿,并且多个服务之间经常因为一些异常错误导致有不同情况的超时现象发生,比如客户端连接时长时间无法建立TCP连接,客户端和服务器连接已建立但是读写报文时数据传输卡住(网络波动导致报文丢失),以及服务端响应迟钝造成的超时现象。
最后我基于源ip哈希+轮询算法实现了动态负载均衡机制,并分布式部署服务增加并发连接上限。然后设计了超时控制解决了超时现象的处理。
3. 你觉得你的项目有哪些亮点
后端采用分布式设计,分为GateServer网关服务,多个ChatServer聊天服务StatusServer 状态服务以及VerifyServer 验证服务,并通过服务发现组件正确找到服务方,通过负载均衡、故障转移和超时控制,实现了服务的高可用性
各服务通过grpc通信,支持断线重连,基于asio实现tcp可靠长连接异步通信和转发,通过锁/无锁双队列维护异步消息的有序发送和消息回调的异步有序执行,采用多线程模式封装iocontext池/mysql/redis/grpc连接池提升并发性能。
此外设计了心跳检测,保证连接持续可用,设计了超时控制解决了客户端与服务器超时问题,设计了负载均衡解决了单服务器性能上限导致并发数量提升不上去的困境。
4. 线程池如何设计
线程池采用C++11风格编写,整体来说线程池通过单例封装,内部初始化N个线程,采用生产者消费者方式管理线程,包含任务队列,任务队列采用package task打包存储,提供对外接口commit 提交任务,采用bind 语法实现任务提交在commit内部自行绑定,通过智能指针伪闭包方式保证任务生命周期。同时使用C++11 fture 特性,允许外部等待任务执行完成。
5. 如何测试性能
测试性能分为三个方面:
- 压力测试,测试服务器连接上限
- 测试一定连接数下,收发效率稳定性
- 采用Ping-Pong 协议,收发效率稳定在10ms下,连接数上限
压力测试,看服务器性能,客户端初始多个线程定时间隔连接,单服务节点连接上限 2w 以上稳定连接,并未出现掉线情况
测试稳定性,单服务节点连接数1W情况下,收发稳定未出现丢包和断线,并且延迟稳定在10ms。保证10ms延迟情况下,增加连接数,测下连接数上限,这个看机器性能,8000~2W连接不等。
6. 心跳检测
在网络情况下,会出现各种各样的中断,有些是网络不稳定或者客户端主动断开连 接,这种服务器是可以检测到的。
PC 拔掉网线,还有一种情况客户端突然崩溃,有时候服务器会检测不到断开连接, 那么你这个用户就相当于僵尸连接。当服务器有太多僵尸连接就会造成服务器性能的损耗,如果我们不设计心跳,系统会通过调用TCP的保活机制,判断连接是否断开,如果再建立连接时未启动保活机制且无心跳,那么该连接会一直占用系统资源。
另外心跳还有一个作用,保证连接持续可用,比如mysql,redis这种连接池,如果 不设计心跳, 时间过长没有访问的时候连接会自动断开。
1)心跳 Ping 帧包含的操作码是 0x9:如果收到了一个心跳 Ping 帧,那么终端必须发送一个心跳 Pong 帧作为回应,除非已经收到了一个关闭帧。否则终端应该尽快回复 Pong 帧;
2)心跳 Pong 帧包含的操作码是 0xA:作为回应发送的 Pong 帧必须完整携带 Ping 帧中传递过来的 “应用数据” 字段。
7. 为何封装Mysql连接池
首先多个线程使用同一个mysql连接是不安全的,所以要为每个线程分配独立连接, 而连接数不能随着线程数无限增加,所以考虑连接池,每个线程想要操作mysql的时候 从连接池取出连接进行数据访问。
Mysql 连接池封装包括 Mgr 管理层和 Dao 数据访问层,Mgr 管理层是单例模式, Dao 层包含了一个连接池,采用生产者消费者模式管理可用连接,并且通过心跳定时访 问mysql 保活连接。
8. 超时控制
在整个远程调用的过程中,客户端和服务端都会出现调用超时,也分为不同的情况客户端主要体现在
- 建立连接的超时,长时间无法建立 TCP 连接
- 读写报文造成的超时,连接已建立,但发送请求或读取响应时数据传输卡住(如网络波动导致报文丢失)。
- 等待服务端响应造成的超时(如服务端挂了),服务端已接收请求并处理,但因业务逻辑复杂或故障,迟迟不返回结果
服务端主要体现在
- 读写报文超时
- 处理服务调用造成的超时(服务调用超时)
此项目在主要在三个地方添加了超时处理机制,考虑到编码一般不会出现问题
客户端建立连接时,若不控制连接时间,客户端可能无限阻塞,导致线程 / 连接池被占满
- 客户端发起连接请求,同时启动一个定时器(如设置超时时间为 2 秒),通过多路复用模型比如epoll同时监听 “连接成功事件” 和 “定时器超时事件”,若 2 秒内连接成功,返回连接实例;若超时,直接返回 “连接超时” 错误,避免继续等待。
客户端调用服务超时,服务端处理缓慢或故障时,客户端若一直等待会导致请求堆积,甚至引发级联超时。
- 客户端发起调用时,生成一个子 context,设置超时时间(如 3 秒),将 context 传递给远程调用函数,函数内部通过监听 context.Done() 通道检测超时,若 3 秒内服务端未返回响应,context 自动取消,调用流程终止,返回超时错误。(context 能跨函数传递,统一控制整个调用链的超时,避免单个慢调用拖垮整个客户端)
服务端处理服务超时,服务端处理业务逻辑时可能因耗时操作(如数据库查询、第三方接口调用)阻塞,占用线程资源。
- 和客户端连接超时控制类似,服务端接收到请求后,启动一个定时器(如设置处理超时时间为 2 秒),通过 epoll同时监听 “业务处理完成通道” 和 “超时信号通道”,若 2 秒内处理完成,正常返回响应;若超时,直接终止处理逻辑,返回 “处理超时” 错误,并释放线程资源。
定时器如何设计的?
使用 std::chrono
和 std::condition_variable
实现高精度定时器
借助 std::condition_variable
可以实现更精准的定时器,避免线程一直处于忙碌等待状态。
1 |
|
简单的一次性定时器,使用 std::this_thread::sleep_for
即可
9. 负载均衡
因为单机性能总会存在极限,所以最终还需要引入横向扩展,通过集群部署以进一步提高并发处理能力,重要部分是负载均衡。
负载均衡的主要作用是:
- 流量分发:将客户端请求均匀分配到集群中的多个节点,避免单个节点过载,充分利用所有节点的资源。(示例:当 10 万并发请求到达集群时,负载均衡器将请求分配到 100 个节点,每个节点处理 1000 并发,远低于单机极限)
- 高可用性:监控节点健康状态,自动屏蔽故障节点,将请求路由到正常节点,避免单点故障影响整体服务。
- 性能优化:根据节点当前负载(如 CPU 利用率、内存占用、连接数)动态调整分发策略,实现资源的最优利用
负载均衡算法:
- 轮询(Round Robin):按顺序依次分配请求,适用于节点性能一致的场景。
- 最少连接(Least Connections):将请求分配给当前连接数最少的节点,适应负载不均的情况。
- 源 IP 哈希(Source IP Hashing):通过客户端 IP 地址的哈希值固定路由到某节点,实现会话亲和性(如保持用户登录状态),该方法在面对大流量访问时很有用,可以将属性相同的连接归到一个服务器上的,这样属性不同的连接之间互不影响(比如微信红包)。
- 加权策略:根据节点性能配置权重(如高配节点权重设为 2,低配设为 1),按比例分配流量。
10. 待改进的点
服务发现功能太普通,我们客户端调用服务,那么就必须在本地维护我们的服务提供方的信息,从而完成我们服务的调用,那么维护的这个信息是和客户端耦合的,其次信息的更新也是不方便的,所以基于此,我们往往就是将服务提供方的信息放在另外一个注册中心,在注册中心维护着服务提供方的各种详细信息,包括像服务提供地址,暴露的服务和方法等,客户端和服务端只需要知道注册中心即可,这就是我们的服务发现。
后期可以采用zookeep来作为中间件实现,这是服务上的扩展,其次在功能上可以增加视频通信,语音通信,文件上传/下载。
其次,jsoncpp相比rapidjson以及boost.json性能较弱,后续修改为rapidjson
后续也可能改为brpc
11. 集群裂脑怎么解决
在Elasticsearch、ZooKeeper这些集群环境中,有一个共同的特点,就是它们有一个“大脑”。比如,Elasticsearch集群中有Master节点,ZooKeeper集群中有Leader节点。
集群中的Master或Leader节点往往是通过选举产生的。在网络正常的情况下,可以顺利的选举出Leader(后续以Zookeeper命名为例)。但当两个机房之间的网络通信出现故障时,选举机制就有可能在不同的网络分区中选出两个Leader。当网络恢复时,这两个Leader该如何处理数据同步?又该听谁的?这也就出现了“脑裂”现象。
通俗的讲,脑裂(split-brain)就是“大脑分裂”,本来一个“大脑”被拆分成两个或多个。试想,如果一个人有多个大脑,且相互独立,就会导致人体“手舞足蹈”,“不听使唤”。
解决方法:
法1:添加心跳机制,采用“双向心跳”(节点间互发心跳)+多通道通信(TCP+UDP 混合检测),降低网络分区漏检率。比如,添加心跳线。原来只有一条心跳线路,此时若断开,则接收不到心跳报告,判断对方已经死亡。若有2条心跳线路,一条断开,另一条仍然能够接收心跳报告,能保证集群服务正常运行。心跳线路之间也可以 HA(高可用),这两条心跳线路之间也可以互相检测,若一条断开,则另一条马上起作用。正常情况下,则不起作用,节约资源。
其次,使用磁盘锁的形式,保证集群中只能有一个Leader获取磁盘锁,对外提供服务,避免数据错乱发生。正在服务的一方只有在发现心跳线全部断开(察觉不到对端)时才启用磁盘锁。平时就不上锁了。脑裂期间,非主集群节点进入 “只读模式” 或限流状态,避免写入冲突(如 Elasticsearch 的分片副本只读策略)。
预防方法:强一致性选举协议(多数派原则)
在 ZooKeeper 里,有一个重要的概念叫 Quorums(法定人数),它在集群选举和判断集群是否可用方面起着关键作用。
先来看一个包含 3 个节点的集群。在这个集群中,Quorums 的值被设定为 2。这意味着什么呢?当集群里有节点失效时,只要剩下正常工作的节点数量达到或超过 2 个,集群就能够正常运行。也就是说,这个集群最多可以容忍 1 个节点出现故障。即使有 1 个节点失效了,剩下的 2 个节点依然能够通过选举产生一个 leader 节点,整个集群还是可以正常提供服务的。
再看一个有 4 个节点的集群。这个集群的 Quorums 值是 3。这表明,要想让集群正常运行,至少得有 3 个节点处于正常工作状态。如果失效的节点达到 2 个,那么正常工作的节点就只剩下 2 个,这没有达到 Quorums 的要求(3 个),此时整个集群就无法进行有效的选举,也没办法正常工作了。也就是说,尽管这个集群有 4 个节点,但它的容错能力和 3 个节点的集群一样,最多只能容忍 1 个节点失效。
综上所述,ZooKeeper 通过设置 Quorums 来保证在出现网络分区等问题时,集群能够正确判断自身状态,避免出现 “脑裂” 现象,确保集群的一致性和可用性。
12. json和protobuf的对比
jsoncpp将json数据解析为c++对象,将c++对象序列化为json数据。jsoncp经常在网络通信中使用,也就是服务器和客户端的通信一般使用json(可视化好);而protobuf一般在服务器之间的通信中使用(tcp是面向字节流的,所以我们需要将类结构序列化为字符串来传输,这便需要借助protobuf)。
Protobuf与JSON相比,其优势可以归纳为以下几点:
- 性能高,不需要像 HTTP 那样考虑各种浏览器行为,头部简单
- 由于采用二进制格式,数据序列化后体积更小,更适合网络传输,可以节省带宽和存储空间。
- grpc基于http2.0底层进行二进制数据传输,json是文本传输,而protobuf可以将类对象转换为二进制进行传输。
Protobuf与JSON相比,其劣势可以归纳为以下几点:
- 可读性较差:Protobuf使用二进制格式进行编码,这导致其数据不如JSON格式的文本数据那么容易直接阅读和理解。对于需要人工查看或编辑数据的场景,JSON更为友好。⭐
- 学习成本高:相对于JSON的简单直观,Protobuf的语法和使用方式需要一定的学习成本。开发人员需要熟悉Protobuf的语法规则、数据类型以及如何使用Protobuf编译器等工具。
- 不支持动态解析:Protobuf在编码和解码时需要预先定义数据结构,因此它不支持像JSON那样可以动态地解析任意结构的数据。这在处理一些需要灵活处理数据结构的场景时可能会受到限制。这项绝对导致,他不适合客户端 <—> 服务端之间的通讯,因为接口会经常更新,变化,不能动态适应,导致客户端也需要升级,是不现实的。⭐
- 调试和查看困难:由于Protobuf使用二进制编码,数据无法直接查看和调试。开发人员可能需要使用特定的工具来解析和查看Protobuf数据的内容,这增加了调试的复杂性。⭐
因此我们可以在服务器之间 通信使用protobuf,客户端和服务端通信使用json(必须使用json,因为protobuf的编码和解码是预先定义好的,不适用于动态变化的客户-服务端通信,只适用于一般是固定的服务-服务通信)。⭐
- 整数解码速率:protobuf<thrift<rapidjson<json
- 整数编码速率:protobuf<rapidjson<json<thrift。不知道为啥,Thrift 的序列化特别慢
- 双精度浮点数解码速率:protobuf<thrift<rapidjson<json
- 双精度浮点数编码速率:protobuf<rapidjson<json<thrift
- 对象解码速率:rapidjson<protobuf<thrift<json
- 对象编码速率:rapidjson<protobuf<json<thrift
- 字符串解码速率:thrift<rapidjson<protobuf<json
- 字符串编码速率:rapidjson<json<thrift<<protobuf
总结
- 在数字处理(整数/浮点数)中,protobuf的效率远超json
- 在对象中处理中,json效率高于protobuf
- 在字符串处理中,json效率高于protobuf
13. 除了jsoncpp之外还了解过什么json库
- Boost.JSON:Boost.JSON 是 Boost 库的一部分,具备高度的可移植性和稳定性。它的设计与标准库风格一致,能与其他 Boost 库很好地集成。
- picojson:picojson 是一个轻量级的 JSON 库,只有一个头文件,易于集成到项目中。它的代码简洁,适合对库大小有严格要求的场景。
- RapidJSON:RapidJSON 是一个快速的 JSON 解析 / 生成器,具有零拷贝功能。它专为性能优化设计,在解析和生成 JSON 数据时速度极快,并且占用内存少。同时,它采用头文件形式发布,使用时无需编译库,方便集成到项目中。
- jsoncpp:性能在其他三个库中间,适中的一种
对比维度 | Boost.JSON | RapidJSON | jsoncpp | picojson |
---|---|---|---|---|
性能 | 处理速度较快,在复杂 JSON 结构处理时稳定,内存占用合理,能满足多数有一定性能要求的项目 | 高性能,采用零拷贝技术,处理大规模数据时解析和生成速度极快,内存占用少 | 性能相对较弱,处理大规模数据时速度和内存表现不如前两者,小规模数据处理尚可 | 性能一般,处理大规模数据不如 RapidJSON 和 Boost.JSON,简单数据处理能满足基本需求 |
易用性 | API 风格与标准库一致,熟悉标准库和 Boost 库的开发者易掌握,但 Boost 库复杂,对初学者有难度 | 功能复杂,学习成本较高,需一定时间掌握使用方法 | API 传统,较易理解,需手动进行类型检查和转换,代码相对冗长 | API 简单直接,功能少,处理复杂数据需编写更多代码 |
功能特性 | 高度可移植和稳定,支持 Unicode 编码,能处理复杂结构,可与其他 Boost 库集成 | 支持 SAX 和 DOM 两种解析模式,有零拷贝解析,可灵活配置 | 支持多种编码格式,提供详细错误信息和调试工具,支持动态和静态解析 | 功能简单,仅提供基本解析和生成功能 |
集成难度 | 需编译和链接,集成相对复杂,但开发环境有工具支持 | 头文件库,集成简单,不过功能复杂需学习使用方法 | 需编译和链接,集成过程相对麻烦 | 只有一个头文件,无需编译,集成难度最低 |
社区支持和文档 | 社区强大,官方文档和教程完善,技术论坛讨论多 | 社区活跃,官方文档有详细说明和 API 参考,GitHub 开源项目多 | 社区相对冷清,但有一定用户基础和文档资源 | 社区活跃度低,文档和示例资源较少 |
- Boost.JSON:可与其他boost库集成,性能较好,部署比较麻烦
- RapidJSON:性能最好,部署简单(头文件库不需要编译链接),跨平台,但学习成本较高
- picojson:功能太简单,性能一般
- jsoncpp:适中
14. 除了grpc外还了解过其他的rpc框架吗
除了grpc外,还了解过brpc和thrift。
概念
gRPC:由 Google 开源,基于 HTTP/2 协议和 Protobuf 序列化协议,旨在提供高性能、开源且通用的 RPC 框架,广泛应用于微服务架构中。
brpc:由百度开源,基于 protobuf 协议,是一个基于 C++ 的高性能 RPC 框架,最初是为了满足百度内部大规模分布式系统的需求而开发的。
Thrift:由 Facebook 开发并开源,后来成为 Apache 项目。它支持多种编程语言和序列化协议,提供了完整的 RPC 解决方案,包括服务定义、代码生成、传输协议和序列化等。
底层实现:
- grpc基于PB并采用HTTP2.0协议传输数据
- brpc基于PB同样也支持HTTP2.0,但额外在网络传输方面进行了优化,比如连接池管理、零拷贝,在高并发场景下网络传输性能表现优异
- thrift支持多种序列协议,比如二进制协议和JSON,但整体性能低于PB,尤其在处理高并发时
语言支持:
- grpc支持大部分主流编程语言
- brpc服务端方面只能以C++为主
- thrift同样支持大部分主流编程语言
官方文档:
- brpc的官方文档写的非常好,而且分中文、英文两个版本,并且并发性能高于grpc,后续考虑使用brpc代替grpc。
使用grpc的原因
1)跨语言
主要是因为grpc的跨语言特性,因为grpc默认采用PB作为接口描述语言,并且服务之间通信的数据序列化和反序列化也是基于PB的,而PB是一种语言无关的高性能序列化框架,所以gRPC 框架是跨语言的通信框架(与编程语言无关性)。
2)服务类型多
有四种服务类型,一元rpc、服务端流式rpc、客户端流式rpc和双向流式rpc,尽管我只用了一元rpc。
3)普通的rpc服务就不介绍了,有了解的是brpc,主要是在高性能C++开发上使用的,也支持PB,而且相比grpc在并发上面更有优势。
简单的了解,brpc的队列是多生产单消费者的无锁队列,其外还实现了一个线程模型(没怎了解过)。
但是,brpc虽然支持PB,但是主要是面向C++的,依赖 Protobuf 的跨语言特性,通过.proto文件定义接口,生成其他语言的客户端 Stub,但服务端仅支持C++。
15. 惊群效应
惊群效应也有人叫做雷鸣群体效应,不过叫什么,简言之,惊群现象就是多进程(多线程)在同时阻塞等待同一个事件的时候(休眠状态),如果等待的这个事件发生,那么他就会唤醒等待的所有进程(或者线程),但是最终却只可能有一个进程(线程)获得这个时间的“控制权”,对该事件进行处理,而其他进程(线程)获取“控制权”失败,只能重新进入休眠状态,这种现象和性能浪费就叫做惊群。
为了更好的理解何为惊群,举一个很简单的例子,当你往一群鸽子中间扔一粒谷子,所有的各自都被惊动前来抢夺这粒食物,但是最终注定只可能有一个鸽子满意的抢到食物,没有抢到的鸽子只好回去继续睡觉,等待下一粒谷子的到来。这里鸽子表示进程(线程),那粒谷子就是等待处理的事件。
常见的惊群现象有哪些?
accept() 惊群:主进程创建了socket、bind、listen之后,fork()出来多个进程,每个子进程都开始循环处理(accept)这个listen_fd。每个进程都阻塞在accept上,当一个新的连接到来时候,所有的进程都会被唤醒,但是其中只有一个进程会接受成功,其余皆失败,重新休眠。
但现在的内核都解决该问题了。即,当多个进程/线程都阻塞在对同一个socket的接受调用上时,当有一个新的连接到来,内核只会唤醒一个进程,其他进程保持休眠,压根就不会被唤醒。
epoll() 惊群:如果多个进程/线程阻塞在监听同一个监听socket fd的epoll_wait上,当有一个新的连接到来时,所有的进程都会被唤醒。比如:主进程创建socket,bind,listen后,将该socket加入到epoll中,然后fork出多个子进程,每个进程都阻塞在epoll_wait上,如果有事件到来,则判断该事件是否是该socket上的事件如果是,说明有新的连接到来了,则进行接受操作。为了简化处理,忽略后续的读写以及对接受返回的新的套接字的处理,直接断开连接。
但注意,epoll_wait不会像接受那样只唤醒一个进程/线程,但也不会把所有的进程/线程都唤醒。这是因为系统只会让一个进程真正的接受这个连接,而剩余的进程会获得一个EAGAIN信号。看似部分进程被唤醒了,而事实上其余进程没有被唤醒的原因是因为某个进程已经处理完这个事件,无需唤醒其他进程,你可以在epoll获知这个事件的时候sleep。因此事实上,epoll_wait的惊群确实存在,只不过因为提前唤醒的线程已经处理完事件无需继续唤醒其他沉睡的线程罢了。
那为什么内核处理了accept的惊群,却不处理epoll_wait的惊群呢?
我想,应该是这样的:accept 确实应该只能被一个进程调用成功,内核很清楚这一点。但epoll不一样,他监听的文件描述符,除了可能后续被accept调用外,还有可能是其他网络IO事件的,而其他IO事件是否只能由一个进程处理,是不一定的,内核不能保证这一点,这是一个由用户决定的事情,例如可能一个文件会由多个进程来读写。所以,对epoll的惊群,内核则不予处理。
如何解决惊群现象呢?
使用互斥锁配合条件变量,所有线程通过调用条件变量的wait函数等待资源释放,资源释放后通过调用notify_one() 来唤醒其中的一个线程,其余线程仍处于沉睡。互斥锁保存在多进程/线程的共享区域中,比如mmap 的内存区域的地址。
对于epoll的惊群现象,需要分为两种情况:
epoll_create 在 fork 之前创建
与 accept 惊群的原因类似,当有事件发生时,等待同一个文件描述符的所有进程(线程)都将被唤醒,而且解决思路和 accept 一致。通过在给 epoll_ctl 函数的 event 参数设置 EPOLLEXCLUSIVE 标志,这样多个进程可以共享同一个 epoll 实例,并且当有事件发生时,只有一个进程会被唤醒。
当设置了
EPOLLEXCLUSIVE
标志后,内核在检测到事件发生时,会采用一种竞争机制来决定哪个进程可以被唤醒。内核会保证只有一个进程能够成功获取处理该事件的权利,其他进程则不会被唤醒,继续保持休眠状态。为什么需要全部唤醒?因为内核不知道,你是否在等待文件描述符来调用 accept() 函数,还是做其他事情(信号处理,定时事件)。
epoll_create 在 fork 之后创建
epoll_create 在 fork 之前创建的话,所有进程共享一个 epoll 红黑数。如果我们只需要处理 accept 事件的话,貌似世界一片美好了。但是 epoll 并不是只处理 accept 事件,accept 后续的读写事件都需要处理,还有定时或者信号事件。
当连接到来时,我们需要选择一个进程来 accept,这个时候,任何一个 accept 都是可以的。当连接建立以后,后续的读写事件,却与进程有了关联。一个请求与 a 进程建立连接后,后续的读写也应该由 a 进程来做。
当读写事件发生时,应该通知哪个进程呢?Epoll 并不知道,因此,事件有可能错误通知另一个进程,这是不对的。所以一般在每个进程(线程)里面会再次创建一个 epoll 事件循环机制,每个进程的读写事件只注册在自己进程的 epoll 种。
我们知道 epoll 对惊群效应的修复,是建立在共享在同一个 epoll 结构上的。epoll_create 在 fork 之后执行,每个进程有单独的 epoll 红黑树,等待队列,ready 事件列表。因此,惊群效应再次出现了。有时候唤醒所有进程,有时候唤醒部分进程,可能是因为事件已经被某些进程处理掉了,因此不用在通知另外还未通知到的进程了。
因此要处理该难题,我们需要弄一个让所有进程共享的东西,比如 mmap 的内存,比如文件,然后通过这个东西来控制进程的互斥。如果支持原子操作,则我们可以直接使用 mmap,然后 lock 就保存 mmap 的内存区域的地址。
16. 零拷贝
在Linux系统内部缓存和内存容量都是有限的,更多的数据都是存储在磁盘中。对于Web服务器来说,经常需要从磁盘中读取数据到内存,然后再通过网卡传输给用户。
我们可以看到,如果应用程序不对数据做修改,从内核缓冲区到用户缓冲区,再从用户缓冲区到内核缓冲区。两次数据拷贝都需要CPU的参与,并且涉及用户态与内核态的多次切换,加重了CPU负担。
可以看到,整个数据的传输过程,都要需要 CPU 亲自参与搬运数据的过程,而且这个过程,CPU 是不能做其他事情的。简单的搬运几个字符数据那没问题,但是如果我们用千兆网卡或者硬盘传输大量数据的时候,都用 CPU 来搬运的话,肯定忙不过来。
计算机科学家们发现了事情的严重性后,于是就发明了 DMA 技术,也就是直接内存访问(Direct Memory Access) 技术。什么是 DMA 技术?简单理解就是,在进行 I/O 设备和内存的数据传输的时候,数据搬运的工作全部交给 DMA 控制器,而 CPU 不再参与任何与数据搬运相关的事情,这样 CPU 就可以去处理别的事务。
可以看到, 整个数据传输的过程,CPU 不再参与数据搬运的工作,而是全程由 DMA 完成,但是 CPU 在这个过程中也是必不可少的,因为传输什么数据,从哪里传输到哪里,都需要 CPU 来告诉 DMA 控制器。
但整个部分仍需要在用户态-内核态进行两次拷贝转换。
传统IO文件传输的糟糕性能
如果服务端要提供文件传输的功能,我们能想到的最简单的方式是:将磁盘上的文件读取出来,然后通过网络协议发送给客户端。
传统 I/O 的工作方式是,数据读取和写入是从用户空间到内核空间来回复制,而内核空间的数据是通过操作系统层面的 I/O 接口从磁盘读取或写入。
代码通常如下,一般会需要两个系统调用:
1 | read(file, tmp_buf, len); |
首先,期间共发生了 4 次用户态与内核态的上下文切换,因为发生了两次系统调用,一次是 read()
,一次是 write()
,每次系统调用都得先从用户态切换到内核态,等内核完成任务后,再从内核态切换回用户态。
上下文切换到成本并不小,一次切换需要耗时几十纳秒到几微秒,虽然时间看上去很短,但是在高并发的场景下,这类时间容易被累积和放大,从而影响系统的性能。
其次,还发生了 4 次数据拷贝,其中两次是 DMA 的拷贝,另外两次则是通过 CPU 拷贝的,下面说一下这个过程:
- 第一次拷贝,把磁盘上的数据拷贝到操作系统内核的缓冲区里,这个拷贝的过程是通过 DMA 搬运的。
- 第二次拷贝,把内核缓冲区的数据拷贝到用户的缓冲区里,于是我们应用程序就可以使用这部分数据了,这个拷贝到过程是由 CPU 完成的。
- 第三次拷贝,把刚才拷贝到用户的缓冲区里的数据,再拷贝到内核的 socket 的缓冲区里,这个过程依然还是由 CPU 搬运的。
- 第四次拷贝,把内核的 socket 缓冲区里的数据,拷贝到网卡的缓冲区里,这个过程又是由 DMA 搬运的。
在这个过程中,发生了四次上下文切换,两次系统调用。
零拷贝
我们需要降低冗余数据拷贝、解放CPU,这也就是零拷贝Zero-Copy技术。
目前来看,零拷贝技术的几个实现手段包括:mmap+write、sendfile
1)mmap
在前面我们知道,read()
系统调用的过程中会把内核缓冲区的数据拷贝到用户的缓冲区里,于是为了减少这一步开销,我们可以用 mmap()
替换 read()
系统调用函数。
mmap()
系统调用函数会直接把内核缓冲区里的数据「映射」到用户空间,这样,操作系统内核与用户空间就不需要再进行任何的数据拷贝操作。这样就减少了一次用户态和内核态的CPU拷贝(将内核态数据拷贝到用户态),但是在内核空间内仍然有一次CPU拷贝(将硬盘中的数据拷贝到内核态中)。
具体过程如下:
- 应用进程调用了
mmap()
后,DMA 会把磁盘的数据拷贝到内核的缓冲区里。接着,应用进程跟操作系统内核「共享」这个缓冲区; - 应用进程再调用
write()
,操作系统直接将内核缓冲区的数据拷贝到 socket 缓冲区中,这一切都发生在内核态,由 CPU 来搬运数据; - 最后,把内核的 socket 缓冲区里的数据,拷贝到网卡的缓冲区里,这个过程是由 DMA 搬运的。
我们可以得知,通过使用 mmap()
来代替 read()
, 可以减少一次数据拷贝的过程(将内核态数据拷贝到用户态)。
但这还不是最理想的零拷贝,因为仍然需要通过 CPU 把内核缓冲区的数据拷贝到 socket 缓冲区里,而且仍然需要 4 次上下文切换,因为系统调用还是 2 次。
2)sendfile方式
sendfile系统调用是在 Linux 内核2.1版本中被引入,它建立了两个文件之间的传输通道。
sendfile方式只使用一个函数就可以完成之前的read+write 和 mmap+write的功能,这样就少了2次状态切换,由于数据不经过用户缓冲区,因此该数据无法被修改。
首先,它可以替代前面的 read()
和 write()
这两个系统调用,这样就可以减少一次系统调用,也就减少了 2 次上下文切换的开销。
其次,该系统调用,可以直接把内核缓冲区里的数据拷贝到 socket 缓冲区里,不再拷贝到用户态(但因为数据不经过用户态,因此数据无法被用户修改),这样就只有 2 次上下文切换,和 3 次数据拷贝。
但是这还不是真正的零拷贝技术,如果网卡支持 SG-DMA(The Scatter-Gather Direct Memory Access)技术(和普通的 DMA 有所不同),我们可以进一步减少通过 CPU 把内核缓冲区里的数据拷贝到 socket 缓冲区的过程。
- 第一步,通过 DMA 将磁盘上的数据拷贝到内核缓冲区里;
- 第二步,缓冲区描述符和数据长度传到 socket 缓冲区,这样网卡的 SG-DMA 控制器就可以直接将内核缓存中的数据拷贝到网卡的缓冲区里,此过程不需要将数据从操作系统内核缓冲区拷贝到 socket 缓冲区中,这样就减少了一次数据拷贝;
这就是所谓的零拷贝(Zero-copy)技术,因为我们没有在内存层面去拷贝数据,也就是说全程没有通过 CPU 来搬运数据,所有的数据都是通过 DMA 来进行传输的。
零拷贝技术的文件传输方式相比传统文件传输的方式,减少了 2 次上下文切换和数据拷贝次数,只需要 2 次上下文切换和数据拷贝次数,就可以完成文件的传输,而且 2 次的数据拷贝过程,都不需要通过 CPU,2 次都是由 DMA 来搬运。
17. 用户鉴权
Token
Token是一个令牌,客户端访问服务器时,验证通过后服务端会为其签发一张令牌,之后,客户端就可以携带令牌访问服务器,服务端只需要验证令牌的有效性(一般会加过期时间)即可。
Token 认证步骤:
- 客户端: 输入用户名和密码请求登录校验;
- 服务器: 收到请求,去验证用户名与密码;验证成功后,服务端会签发一个 Token 并把这个 Token 发送给客户端;
- 客户端: 收到 Token 以后需要把它存储起来,web 端一般会放在 localStorage 或 Cookie 中,移动端原生 APP 一般存储在本地缓存中;
- 客户端发送请求: 向服务端请求 API 资源的时候,将 Token 通过 HTTP 请求头 Authorization 字段或者其它方式发送给服务端;
- 服务器: 收到请求,然后去验证客户端请求里面带着的 Token ,如果验证成功,就向客户端返回请求的数据,否则拒绝返还。(401)。
什么是Refresh Token?
为了安全,我们的 Access Token 有效期一般设置时间较短,以避免被盗用。但过短的有效期会造成 Access Token 经常过期,过期后怎么办呢?
- 一种办法是:刷新 Access Token,让用户重新登录获取新 Token,会很麻烦;
- 另外一种办法是:再来一个 Token,一个专门生成 Access Token 的 Token,我们称为 Refresh Token;
- 有以下两种token:
- Access Token:用来访问业务接口,由于有效期足够短,盗用风险小,也可以使请求方式更宽松灵活;
- Refresh Token:用来获取 Access Token,有效期可以长一些,通过独立服务和严格的请求方式增加安全性;由于不常验证,也可以如前面的 Session 一样处理;
Refresh Token 认证步骤解析:
- 客户端: 输入用户名和密码请求登录校验;
- 服务端: 收到请求,验证用户名与密码;验证成功后,服务端会签发一个 Access Token 和 Refresh Token 并返回给客户端;
- 客户端: 把 Access Token 和 Refresh Token 存储在本地;
- 客户端发送请求: 请求数据时,携带 Access Token 传输给服务端;
- 服务端:验证 Access Token 有效:正常返回数据;验证 Access Token 过期:拒绝请求
- 客户端 ( Access Token 已过期) : 则重新传输 Refresh Token 给服务端;
- 服务端 ( Access Token 已过期) : 验证 Refresh Token ,验证成功后返回新的 Access Token 给客户端;
- 客户端: 重新携带新的 Access Token 请求接口;
Token 和 Session-Cookie 的区别?
Session-Cookie 和 Token 有很多类似的地方,但是 Token 更像是 Session-Cookie 的升级改良版。
作用不同:Session用于保持用户会话状态,服务器通过会话 ID 查找内存 / 数据库中的用户会话数据。而Token验证用户身份和权限,用于 API 或前后端分离场景的无状态认证。
存储地不同: Session 一般是存储在服务端;Token 是无状态的,一般由前端存储;
支持性不同: Session-Cookie 认证需要靠浏览器的 Cookie 机制实现,如果遇到原生 NativeAPP 时这种机制就不起作用了,或是浏览器的 Cookie 存储功能被禁用,也是无法使用该认证机制实现鉴权的;而 Token 验证机制丰富了客户端类型。
安全性不同:Session-Cookie 仅存储会话 ID(不直接包含用户数据),会话数据存储在服务器端,相对安全。JWT 通常包含用户身份和权限信息,需加密传输(HTTPS),JWT 建议短有效期 + 刷新令牌。
类型 | 优点 | 缺点 |
---|---|---|
Token | - 无状态,易扩展 - 跨域友好 - 适合分布式系统 - 细粒度权限控制(JWT 可包含权限数据) | - 令牌体积较大(JWT 包含完整用户信息) - 续签逻辑较复杂 - 本地存储可能被 CSRF 利用(若存 Cookie) |
Session-Cookie | - 浏览器自动管理,使用简单 - 会话数据集中在服务器端,安全性较高(仅存储会话 ID) | - 有状态,服务器需维护会话存储 - 分布式部署需解决会话共享 - 受同源策略和 Cookie 大小限制(通常 4KB 以内) |
为什么用户权限验证使用Token而不是Session?
如果使用Session-cookie进行用户鉴权,那么流程如下:
- 用户向服务器发送用户名和密码。
- 服务器验证通过后,在当前对话(session)里面保存相关数据,比如用户角色、登录时间等等。
- 服务器向用户返回一个 session_id,写入用户的 Cookie。
- 用户随后的每一次请求,都会通过 Cookie,将 session_id 传回服务器。
- 服务器收到 session_id,找到前期保存的数据,由此得知用户的身份。
这种模式的问题在于,扩展性(scaling)不好。单机当然没有问题,如果是服务器集群,或者是跨域的服务导向架构,就要求 session 数据共享,每台服务器都能够读取 session。
举例来说,A 网站和 B 网站是同一家公司的关联服务。现在要求,用户只要在其中一个网站登录,再访问另一个网站就会自动登录,请问怎么实现?
一种解决方案是 session 数据持久化,写入数据库或别的持久层。各种服务收到请求后,都向持久层请求数据。这种方案的优点是架构清晰,缺点是工程量比较大。另外,持久层万一挂了,就会单点失败。
另一种方案是服务器索性不保存 session 数据了,所有数据都保存在客户端,每次请求都发回服务器。JWT 就是这种方案的一个代表。
JWT
JWT 是 Auth0 提出的通过对JSON进行加密签名来实现授权验证的方案;
我们知道了Token的使用方式以及组成,我们不难发现,服务端验证客户端发送过来的Token时,还需要查询数据库获取用户基本信息,然后验证Token是否有效;这样每次请求验证都要查询数据库,增加了查库带来的延迟等性能消耗;
而JWT就是登录成功后将相关用户信息组成JSON对象,然后对这个对象进行某种方式的加密,返回给客户端; 客户端在下次请求时带上这个 Token;服务端再收到请求时校验 token合法性,其实也就是在校验请求的合法性。其实JWT的认证流程与Token的认证流程差不多,只是不需要再单独去查询数据库查找用户。就像下面这样:
1 | { |
以后,用户与服务端通信的时候,都要发回这个 JSON 对象。服务器完全只靠这个对象认定用户身份。为了防止用户篡改数据,服务器在生成这个对象的时候,会加上签名(详见后文)。
服务器就不保存任何 session 数据了,也就是说,服务器变成无状态了,从而比较容易实现扩展。
JWT 的数据结构
实际的 JWT 大概就像下面这样。
它是一个很长的字符串,中间用点(.
)分隔成三个部分。注意,JWT 内部是没有换行的,这里只是为了便于展示,将它写成了几行。
JWT 的三个部分依次如下。
- Header(头部)
- Payload(负载)
- Signature(签名):防止用户篡改数据
写成一行,就是下面的样子。
1 | Header.Payload.Signature |
1)Header 部分是一个 JSON 对象,描述 JWT 的元数据,通常是下面的样子。
1 | { |
上面代码中,alg
属性表示签名的算法(algorithm),默认是 HMAC SHA256(写成 HS256);typ
属性表示这个令牌(token)的类型(type),JWT 令牌统一写为JWT
。
最后,将上面的 JSON 对象使用 Base64URL 算法(详见后文)转成字符串。
2)Payload 部分也是一个 JSON 对象,用来存放实际需要传递的数据。JWT 规定了7个官方字段,供选用。
- iss (issuer):签发人
- exp (expiration time):过期时间
- sub (subject):主题
- aud (audience):受众
- nbf (Not Before):生效时间
- iat (Issued At):签发时间
- jti (JWT ID):编号
除了官方字段,你还可以在这个部分定义私有字段,下面就是一个例子。
1 | { |
注意,JWT 默认是不加密的,任何人都可以读到,所以不要把秘密信息放在这个部分。
这个 JSON 对象也要使用 Base64URL 算法转成字符串。
3)Signature 部分是对前两部分的签名,防止数据篡改。
首先,需要指定一个密钥(secret)。这个密钥只有服务器才知道,不能泄露给用户。然后,使用 Header 里面指定的签名算法(默认是 HMAC SHA256),按照下面的公式产生签名。
算出签名以后,把 Header、Payload、Signature 三个部分拼成一个字符串,每个部分之间用”点”(.
)分隔,就可以返回给用户。
JWT的使用方式
客户端收到服务器返回的 JWT,可以储存在 Cookie 里面,也可以储存在 localStorage。
此后,客户端每次与服务器通信,都要带上这个 JWT。你可以把它放在 Cookie 里面自动发送,但是这样不能跨域,所以更好的做法是放在 HTTP 请求的头信息Authorization
字段里面。
另一种做法是,跨域的时候,JWT 就放在 POST 请求的数据体里面。
JWT的缺点
- JWT 的最大缺点是,由于服务器不保存 session 状态,因此无法在使用过程中废止某个 token,或者更改 token 的权限。也就是说,一旦 JWT 签发了,在到期之前就会始终有效,除非服务器部署额外的逻辑。
- JWT 本身包含了认证信息,一旦泄露,任何人都可以获得该令牌的所有权限。为了减少盗用,JWT 的有效期应该设置得比较短。对于一些比较重要的权限,使用时应该再次对用户进行认证。
- 为了减少盗用,JWT 不应该使用 HTTP 协议明码传输,要使用 HTTPS 协议传输。
为什么客户端接收 JWT 后通常存储在 LocalStorage 而不是别的地方
- 持久化存储需求⭐
- LocalStorage 是浏览器提供的 持久化存储机制,数据会一直保留在客户端,直到被主动清除(如代码删除、浏览器清除缓存等),不会因浏览器关闭或页面刷新而丢失。
- 这非常适合 JWT 的典型场景:用户登录后,希望长期保持登录状态(直到过期或手动退出),无需每次打开浏览器都重新登录。
- 手动控制传输,避免自动发送⭐
- JWT 通常通过 HTTP请求头(如
Authorization: Bearer <token>
) 传递给服务器,而非像 Cookie 那样自动附加到每个请求中。 - 使用 LocalStorage 时,客户端可以完全控制何时、如何将 JWT 放入请求头,灵活性更高,尤其适合前后端分离架构(前端独立控制 API 请求)。
- 较大的存储容量⭐
- LocalStorage 的存储容量通常为 5-10MB,远大于 Cookie(4KB 左右),可以容纳 JWT 中可能包含的额外声明(如用户权限、自定义数据),而无需担心容量不足。
- 跨会话共享数据⭐
- 与 SessionStorage(仅当前会话有效,浏览器关闭后数据消失)不同,LocalStorage 中的数据可以在同一浏览器的多个标签页、窗口或会话中共享,适合需要多页面共享登录状态的应用。
说到持久化,你是怎么做token刷新的?
基于刷新令牌(Refresh Token)的机制
服务器在用户登录成功后,除了返回访问令牌(Access Token)外,还会返回一个刷新令牌(Refresh Token)。
访问令牌的有效期较短,用于正常的业务请求验证;刷新令牌的有效期较长,用于在访问令牌过期后获取新的访问令牌。
滑动窗口机制
- 每次用户进行有效操作时,服务器会自动更新访问令牌的有效期,延长其过期时间。这样可以在用户持续使用系统的过程中,避免频繁要求用户重新登录。
定时刷新
- 客户端在本地设置一个定时器,在访问令牌即将过期之前,自动向服务器发送刷新请求,获取新的访问令牌。
18. token是否存在泄漏问题,如何解决
Token泄漏的常见场景:
- 传输过程泄漏
- 通过未加密的 HTTP 传输,中间人攻击(MITM)可截获明文 Token。
- 请求参数或 URL 中携带 Token(如查询参数),可能被日志记录或浏览器缓存泄露。
- 客户端存储不当
- LocalStorage/SessionStorage:虽不会自动随请求发送,但易被前端恶意脚本(XSS 攻击)读取。
- Cookie:若未设置
HttpOnly
(防止 XSS)、Secure
(仅 HTTPS 传输)、SameSite
(防 CSRF)属性,可能被窃取或利用。 - 内存泄漏:客户端代码意外将 Token 暴露在日志、调试信息或未清理的内存中。
解决方法:
传输安全:强制使用 HTTPS
核心措施:通过 TLS/SSL 加密传输,确保 Token 在网络中以密文形式传输,防止中间人攻击。
避免 URL 携带 Token:将 Token 放在请求体(Body)而非 URL 查询参数中,避免被浏览器历史、服务器日志记录。
存储安全:合理选择存储方式并配置安全属性
- LocalStorage vs Cookie:
- LocalStorage:适合前端主动管理(如通过
Authorization
头携带) - Cookie:若使用 Cookie 存储,需配置:
HttpOnly
:禁止 JavaScript 读取,防御 XSS。Secure
:仅 HTTPS 传输,防止中间人窃取。SameSite=Strict/Lax
:防止 CSRF 攻击(Strict
仅允许同站请求,Lax
允许部分跨站场景)。
- LocalStorage:适合前端主动管理(如通过
- 避免内存泄漏:确保代码中不将 Token 打印到日志、控制台,或在组件卸载时清理 Token。
19. 验证码服务如果疯狂调用是否会造成服务崩溃,如何处理?
- 客户端限制
- 前端按钮禁用:用户点击 “获取验证码” 后,按钮禁用 60 秒(如倒计时),防止手动重复点击。
- 简单人机交互:首次请求直接发送,后续请求增加轻量验证(如滑动条、点击图片),提高攻击成本。
- 服务端频率限制
- 因为项目中每个连接都通过session类管理,每个session又和用户id绑定,因此通过Redis 记录每个用户的请求时间戳,每次请求时检查,每次成功发送后,强制用户等待 60 秒才能再次请求(存储最后一次成功时间,而非记录所有请求)。
- 防止攻击者通过更换用户 ID 但同一 IP 发起攻击,对 IP 设置更严格的限制(如每分钟 10 次)。
- 邮箱发送优化:
- 提前与邮箱服务商确认速率限制(如阿里云邮件推送:单 IP 每分钟 200 封),在服务端设置全局上限(如限制为服务商阈值的 80%)。
20. 好友列表如何获取?通过什么数据结构存储?
每个session和id绑定,通过session可溯源用户id,通过id向服务器申请好友数据。
存储方式: std::shared_ptr<UserInfo>
存储单个好友的信息
1 | class UserInfo { |
- 好友列表存储:通过
QListWidget
管理列表项,每个项关联ConUserItem
控件,用户数据(UserInfo
)存储在ConUserItem
的std::shared_ptr<UserInfo> _info
中。 - 数据流向:
ContactUserList
(容器)→QListWidgetItem
(列表项)→ConUserItem
(自定义控件)→std::shared_ptr<UserInfo>
(用户数据)。
21. 项目是如何保证通信低延迟的?
- 异步I/O机制:异步 I/O 允许程序在发起 I/O 操作后继续执行其他任务,当 I/O 操作完成时,会通过回调函数通知程序。这种机制避免了线程的阻塞等待,使得程序能够高效地处理多个连接和 I/O 操作,减少了等待时间,从而降低了通信延迟。
- 零拷贝:在数据传输过程中,Asio 库会尽可能地利用操作系统的零拷贝技术。零拷贝技术允许数据在内存中直接从磁盘或网络设备传输到应用程序的缓冲区,而无需进行多次数据拷贝。
- iocontext池处理异步操作,将异步操作分配到不同的线程中执行。挺高系统的并发能力,减少了操作的等待时间,从而降低了通信延迟。
- 使用双重队列进行数据管理,避免频繁的内存分配和释放操作导致的内存碎片,提高内存的使用效率
- 连接复用:建立和关闭 TCP 连接需要进行三次握手和四次挥手等操作,这些操作会带来一定的延迟。通过复用已有的连接,可以避免频繁的连接建立和关闭,从而降低了通信延迟。
22. 怎么确保信息成功发送的(如果程序判断发送,但是内存崩溃导致信息未发生,这种情况如何处理?
异步发送与回调机制
在使用 Asio 库等进行异步通信时,利用其回调机制来确认发送结果。当异步发送操作完成时,会触发相应的回调函数,通过检查回调函数中的错误码来判断信息是否成功发送。
当检测到发送失败时,实现重试机制。可以设置最大重试次数和重试间隔,避免无限重试导致资源浪费。
确认机制:接收端在接收到信息后,立即向发送端发送确认消息。发送端在一定时间内未收到确认消息,则认为信息发送失败,进行重试。(相当于TCP的ACK)⭐
前两点我们已经通过异步队列实现了,第三点我们需要在客户端实现一个类似于心跳检测的功能。
23. 高并发遇到的最大的技术难点是什么?怎么解决?
高并发意味着大流量,需要运用技术手段抵抗流量的冲击,这些手段好比操作流量,能让流量更平稳地被系统所处理,带给用户更好的体验。我们需要解决多个进程或线程同时(或者说在同一段时间内)访问同一资源产生的并发问题。
我们常见的高并发场景有:淘宝的双11、春运时的抢票、微博大V的热点新闻等。除了这些典型事情,每秒几十万请求的秒杀系统、每天千万级的订单系统、每天亿级日活的信息流系统等,都可以归为高并发。
解决方法
1)纵向扩展
它的目标是提升单机的处理能力,方案又包括:
1、提升单机的硬件性能:通过增加内存、 CPU核数、存储容量、或者将磁盘 升级成SSD 等堆硬 件 的 方 式 来 提升 。
2、提升单机的软件性能:使用缓存减少IO次数,使用并发或者异步的方式增加吞吐量。
2)横向扩展
横向扩展随之会发生两个主要问题:
问题1;用户访问IP多了,怎么解决?
问题2:数据库出现瓶颈 怎么办?
针对问题一:因为单机性能总会存在极限,所以最终还需要引入横向扩展,通过集群部署以进一步提高并发处理能力,重要部分是负载均衡。
负载均衡的主要作用是:
- 流量分发:将客户端请求均匀分配到集群中的多个节点,避免单个节点过载,充分利用所有节点的资源。(示例:当 10 万并发请求到达集群时,负载均衡器将请求分配到 100 个节点,每个节点处理 1000 并发,远低于单机极限)
- 高可用性:监控节点健康状态,自动屏蔽故障节点,将请求路由到正常节点,避免单点故障影响整体服务。
- 性能优化:根据节点当前负载(如 CPU 利用率、内存占用、连接数)动态调整分发策略,实现资源的最优利用
负载均衡算法:
- 轮询(Round Robin):按顺序依次分配请求,适用于节点性能一致的场景。
- 最少连接(Least Connections):将请求分配给当前连接数最少的节点,适应负载不均的情况。
- 源 IP 哈希(Source IP Hashing):通过客户端 IP 地址的哈希值固定路由到某节点,实现会话亲和性(如保持用户登录状态)。
- 加权策略:根据节点性能配置权重(如高配节点权重设为 2,低配设为 1),按比例分配流量。
针对问题二:数据库出现瓶颈怎么办?
解决方案有:MySql主从复制与读写分离
写请求走主库:所有写操作(INSERT/UPDATE/DELETE)直接发送到主库,确保数据唯一性和完整性。
读请求走从库:读操作(SELECT)分发到一个或多个从库,利用从库的计算和 IO 资源分担压力。
通过 “主库写、从库读” 的分工,将读压力分散到多个节点,提升系统并发能力和可用性。但需注意从库延迟、数据一致性和主库自身的写瓶颈问题,适用于读多写少的业务场景。一般来说都是通过 主从复制(Master-Slave)的方式来同步数据,再通过读写分离(MySQL-Proxy)来提升数据库的并发负载能力 这样的方案来进行部署与实施的。
24. 为什么使用redis
主要是为了减少延迟,降低服务器与客户端通信延迟。
缓存的主要目的是减少访问存储在应用程序主内存空间之外的数据所需的时间。
在不使用缓存的情况下,应用程序会针对每个请求与数据源进行交互。相反,当使用缓存时,仅需要对外部数据源的单个请求,随后的访问由缓存提供。
当应用程序依赖外部数据源时,这些源的延迟和吞吐量可能会造成性能瓶颈。提高性能的一种方法是在物理上更靠近应用程序的内存中存储和操作数据。
这就是 Redis 发挥作用的地方。Redis 旨在将所有数据存储在内存中,在读取或写入数据时提供最快的性能。
Redis 速度非常快。它提供亚毫秒级响应时间,每秒可处理数百万个请求,为要求严格的实时应用程序提供支持。
通常,您需要将经常访问的数据存储在 Redis 中,以便每当请求数据时,它都可以来自缓存而不是数据库。
然后,只要数据发生更改,您就可以使相关缓存失效,以便使缓存保持最新。
25. 项目里你是如何使用rpc的
Rpc作为分布式系统基础组件,是必不可少的。Rpc是分布式通信的基础,它底层调用了一个网络库,在服务端方面,会借助 Protobuf(PB)将自身的业务服务和对应的方法注册到网络库的回调机制中。这样,当有客户端发起请求时,网络库就能根据回调信息调用相应的业务逻辑。同时,服务端会将自己的 IP 地址和端口等服务信息注册到像 ZooKeeper(ZK)这样的服务发现组件上,以便客户端能够发现并连接到该服务。
对于调用方(客户端)而言,同样会使用Protobuf来定义要调用的服务方法和参数结构。在 RPC 框架中,自定义的 Channel 负责在调用时封装 RPC 协议,将调用的函数名、参数等信息按照协议格式进行打包。之后,通过负载均衡策略(如轮询等)从 ZooKeeper 服务路径下选取一个可用的服务节点,最后向该节点发起远程调用请求。
zookeeper 呢在这里其实主要就是起到了一个配置中心的目的。
zookeeper上面我们标识了每个类的方法所对应的分布式节点地址,当我们其他服务器想要RPC的时候,就去 zookeeper 上面查询对应要调用的服务在哪个节点上。
这里就相当于,我其他服务器来 zookeeper 查询User::is_exist ,然后会得到127.0.0.1:8001 这个值,这个值就是你布置这个功能的一个RPC节点的网络标识符,然后向这个节点去发送参数并且等待这个节点的相应。
rpc的一次调用:
- 客户端(Client)通过本地调用的方式调用服务(以接口方式调用);
- 客户端存根(Client Stub)接收到调用请求后负责将方法、入参等信息进行组装序列化成能够进行网络传输的消息体(将消息体对象序列化为二进制流);
- 客户端存根(Client Stub)找到远程的服务地址,并且将消息通过网络发送给服务端(通过sockets发送消息);
- 服务端存根(Server Stub)收到消息后进行反序列化操作,即解码(将二进制流反序列化为消息对象);
- 服务端存根(Server Stub)通过解码结果调用本地的服务进行相关处理;
- 服务端(Server)本地服务业务处理;
- 服务端(Server)将处理结果返回给服务端存根;
- 服务端存根(Server Stub)序列化处理结果(将结果消息对象序列化为二进制流);
- 服务端存根(Server Stub)将序列化结果通过网络发送至客户端(通过sockets发送消息);
- 客户端存根(Server Stub)接收到消息,进行反序列化解码(将结果二进制流反序列化为消息对象);
- 客户端得到最终的结果。
整体框架如下: