- C++ 网络编程详细讲解(内容待补充)
- 对应面试问答见 C++ 基础
- 完整系列:C++ 基础-初级、C++ 基础-中级、C++ 基础-高级、C++ STL、03-Resources/cpp/C++ 并发编程、03-Resources/cpp/C++ 网络编程、03-Resources/cpp/C++ 数据结构、03-Resources/cpp/C++ 设计模式、03-Resources/cpp/C++ 基本算法、03-Resources/cpp/C++ Linux 编程
- 当前覆盖:网络/数据库/日志/单元测试框架、epoll 多路复用、Reactor/Proactor、零拷贝、TCP 粘包
用过哪些 C++ 网络框架?都有什么优缺点?
这是一道高频「经验类」面试题,考察点不在「背框架名」,而在你是否真正用过、能否说清为什么选它。核心逻辑:先用一句话给框架定性(Reactor/Proactor、C/C++、跨平台与否),再讲优缺点,最后落到「你实际怎么用的」。
常见 C++ 网络框架对比:
| 框架 | 语言/风格 | 核心模型 | 跨平台 | 优点 | 缺点 |
|---|---|---|---|---|---|
| libevent | C / 回调 | Reactor + bufferevent | 是(epoll/kqueue/select 等后端) | 老牌稳定、轻量、自带 HTTP/DNS/bufferevent | C 接口回调地狱、无内置线程模型、多线程需自己管理 |
| libuv | C / 回调 | 事件循环 + 线程池 | 是(Node.js 底层) | 跨平台极好、自带异步文件/线程池/DNS | C 风格、手动内存管理、回调嵌套深 |
| Boost.Asio | C++ / 头文件 | Proactor(也可 Reactor) | 是 | 同步/异步统一接口、协程支持、类型安全、跨平台 | 编译慢、模板报错难读、学习曲线陡 |
| muduo | C++ / Reactor | one loop per thread + 非阻塞 epoll | 否(仅 Linux) | 多线程模型清晰、源码可读、性能好 | 仅 Linux、接口偏旧、以 TCP 为主 |
- 需要极致跨平台 + 异步文件/线程池:libuv;只想用 C++ 且要统一异步接口:Boost.Asio。
- 追求高性能、只跑 Linux、想要清晰的多线程模型:muduo。
- 老项目维护、C 环境:libevent。
典型使用:muduo 的 EchoServer 只需继承 TcpServer 并实现回调,多线程由 setThreadNum 一句搞定:
// muduo 风格 echo server(简化示意)
#include <muduo/net/TcpServer.h>
#include <muduo/net/EventLoop.h>
using namespace muduo;
using namespace muduo::net;
void onMessage(const TcpConnectionPtr& conn, Buffer* buf, Timestamp t) {
conn->send(buf); // 原样回显
}
int main() {
EventLoop loop;
InetAddress listenAddr(8888);
TcpServer server(&loop, listenAddr, "echo");
server.setMessageCallback(onMessage);
server.setThreadNum(4); // one loop per thread
server.start();
loop.loop();
return 0;
}不要只报框架名,主动补一句你「在什么项目、用它解决了什么」——比如「用 Asio 写了个异步网关,因为要同时处理大量长连接,Proactor 模型在高并发下比线程池省资源」。
用过哪些 C++ 数据库框架?都有什么优缺点?
同样考察「真实经验 + 选型理由」。数据库框架分两类:底层 C API(直接操作连接/语句句柄)与 C++ 封装/抽象层(RAII、类型安全、跨数据库)。
常见数据库框架对比:
| 框架 | 定位 | 优点 | 缺点 |
|---|---|---|---|
| MySQL C API (libmysqlclient) | MySQL 官方 C 接口 | 功能最全、性能好、文档全 | C 风格手动管理 MYSQL/MYSQL_RES、易内存泄漏、SQL 字符串拼接易注入 |
| libpq | PostgreSQL 官方 C 接口 | 功能全、支持异步/COPY、性能好 | C 风格、连接与结果管理繁琐、非类型安全 |
| SQLite C API | 嵌入式数据库 | 零配置、单文件、事务、轻量 | C 风格、手动管理 sqlite3_stmt 生命周期、并发写受限 |
| SOCI | C++ 数据库抽象层 | 类型安全、RAII、后端插件化(MySQL/PG/SQLite/ODBC/Oracle) | 文档较少、相对小众、抽象有少量开销 |
| ODBC | 跨数据库通用标准 | 一套接口连任意数据库、厂商驱动多 | API 繁琐、偏底层、性能一般 |
- 只连一种库、追求性能:直接用官方 C API(MySQL C API / libpq / SQLite)。
- 要跨数据库、要 C++ 类型安全:SOCI 或 ODBC。
- 嵌入式/本地缓存:SQLite。
SOCI 的 RAII 用法示例——session 自动管理连接,into 绑定变量,无需手动释放结果集:
#include <soci/soci.h>
#include <soci/sqlite3/soci-sqlite3.h>
#include <iostream>
#include <string>
int main() {
soci::session sql(soci::sqlite3, "test.db");
sql << "CREATE TABLE IF NOT EXISTS users(id INT, name TEXT)";
sql << "INSERT INTO users VALUES(1, 'alice')";
std::string name;
// into 自动绑定并取值,异常由 soci_error 统一捕获
sql << "SELECT name FROM users WHERE id = :id", soci::use(1), soci::into(name);
std::cout << name << std::endl; // alice
return 0;
}
底层 C API 千万不要忘 mysql_free_result / PQclear / sqlite3_finalize,否则长连接下内存持续增长;这正是 C++ 封装层(RAII)存在的价值。
用过哪些 C++ 日志框架?都有什么优缺点?
日志框架对比的维度:性能(是否异步/零拷贝)、易用性(头文件 vs 链接库)、可配置性(格式/输出目标/滚动)、与项目集成成本。
| 框架 | 定位 | 优点 | 缺点 |
|---|---|---|---|
| spdlog | 现代 C++ 头文件库 | 极快、异步日志、多 sink、编译期级别开关零开销、头文件即用 | 滚动/归档需自行组合 sink、依赖 fmt |
| glog | Google 生产级 | 分级 + 崩溃栈、线程安全、条件日志、FLAGS 控制 | 输出文件命名/格式固定、不易定制、需全局初始化、不建议作为库对外依赖 |
| log4cxx | Apache(log4j 移植) | 配置极灵活(XML/properties)、Appender/Layout 丰富、可动态调整 | 较重、初始化复杂、性能一般、依赖 APR |
- 新项目默认选 spdlog:头文件 + 异步 + 高性能,几乎零学习成本。
- 需要崩溃时自动落栈、与 gflags 集成:glog。
- 需要运行时改配置、多目标复杂路由:log4cxx。
spdlog 的典型用法:一行初始化,异步 + 多 sink(控制台 + 滚动文件):
#include <spdlog/spdlog.h>
#include <spdlog/sinks/rotating_file_sink.h>
#include <spdlog/async.h>
int main() {
// 异步日志 + 滚动文件(5MB x 3 个)
auto logger = spdlog::rotating_logger_mt<spdlog::async_factory>(
"app", "logs/app.log", 1024 * 1024 * 5, 3);
spdlog::set_default_logger(logger);
spdlog::set_pattern("[%Y-%m-%d %H:%M:%S.%e] [%l] %v");
spdlog::info("用户 {} 登录,IP={}", "alice", "10.0.0.1");
spdlog::error("请求处理失败,code={}", 500);
return 0;
}主动提到「异步日志 + 双缓冲/队列」或「编译期用宏屏蔽低级别日志、接近零开销」这类性能点,能体现你对日志在线上性能影响的敏感度。
用过哪些 C++ 单元测试框架?都有什么优缺点?
单元测试框架对比维度:断言表达能力、编译速度、集成度(fixture/参数化/mock)、生态与 CI。
| 框架 | 定位 | 优点 | 缺点 |
|---|---|---|---|
| GoogleTest + GMock | 工业标准 | 功能最全:断言丰富、fixture、参数化、死亡测试、GMock、CI/CMake 集成好 | 编译慢、体积大、宏较多 |
| Catch2 | 头文件、BDD 风格 | 表达式分解(失败时打印 a、b 实际值)、SECTION 结构清晰、单/双文件 | 头文件重、编译慢、高级功能弱于 gtest |
| doctest | 极轻量头文件 | 编译最快、可作单头文件嵌入、支持子用例 | 功能较少、参数化/模拟需自己搭、生态小 |
| Boost.Test | Boost 生态 | fixture/数据驱动丰富、浮点比较、自动注册 | 依赖 Boost、编译慢、宏复杂、文档分散 |
- 大型工程、需要 Mock:GoogleTest + GMock。
- 快速上手、BDD 风格、失败信息友好:Catch2。
- 编译速度敏感、想把测试塞进头文件发布:doctest。
Catch2 的 REQUIRE 失败时会打印两侧实际值,排查体验好:
#define CATCH_CONFIG_MAIN
#include <catch2/catch.hpp>
int add(int a, int b) { return a + b; }
TEST_CASE("加法正确", "[math]") {
REQUIRE(add(1, 2) == 3); // 失败时打印两侧实际值
REQUIRE(add(0, 0) == 0);
}提到「把单测接入 CI、用覆盖率(gcov/lcov)驱动」,或「用 GMock 隔离外部依赖(DB/网络)」,能证明你在真实工程里实践过 TDD/CI。
介绍下 socket 的多路复用?epoll 有哪些优点?
IO 多路复用:让单个线程同时监听多个 socket,当其中某个(或某几个)就绪时再处理,避免「一个连接一个线程」的资源浪费。Linux 下主流机制从 select → poll → epoll 演进。
select / poll / epoll 对比:
| 机制 | fd 管理 | 内核实现 | 获取就绪事件 | 触发模式 | 跨平台 |
|---|---|---|---|---|---|
| select | fd_set 位图,默认上限 1024 | 每次调用整体拷贝到内核 | O(n) 遍历所有 fd | 仅水平触发 | 是 |
| poll | pollfd 动态数组,无数量上限 | 每次调用整体拷贝 | O(n) 遍历 | 仅水平触发 | 是 |
| epoll | epoll_ctl 注册到内核 | 红黑树 + 就绪链表 | O(1) 只取就绪 fd | 支持 ET/LT | 否(仅 Linux) |
epoll 的核心优点:
- 无 fd 数量上限:select 受
FD_SETSIZE(1024) 限制,epoll 只受系统内存限制。 - O(1) 获取就绪事件:epoll_wait 直接从就绪链表取事件,不像 select/poll 需要 O(n) 遍历全部 fd。
- 事件注册而非每次全量拷贝:epoll_ctl 把 fd 加入内核红黑树(增删改 O(log n)),事件状态由内核维护,用户态无需每次重新传入整个 fd 集合。
- mmap 共享内存减少拷贝:内核与用户空间通过共享内存交互就绪事件,避免 select/poll 每次
copy_from_user。 - 支持 ET/LT 两种触发模式,ET 可减少重复通知次数。
ET 与 LT 的区别:
| 模式 | 全称 | 通知时机 | 要求 |
|---|---|---|---|
| LT(水平触发,默认) | Level Triggered | 只要缓冲区还有未读数据,每次 epoll_wait 都通知 | 无所谓,可阻塞 IO |
| ET(边缘触发) | Edge Triggered | 仅在「不可读→可读」等状态变化瞬间通知一次 | 必须非阻塞 IO,一次读到 EAGAIN |
epoll 使用骨架:
#include <sys/epoll.h>
#include <unistd.h>
int epfd = epoll_create1(0); // 创建 epoll 实例
struct epoll_event ev{}, events[64];
ev.events = EPOLLIN | EPOLLET; // ET 模式监听读事件
ev.data.fd = listen_fd;
epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, &ev); // 注册到红黑树
for (;;) {
int n = epoll_wait(epfd, events, 64, -1); // 只返回就绪 fd
for (int i = 0; i < n; ++i) {
if (events[i].data.fd == listen_fd) {
// accept 新连接(ET 下要循环 accept 直到 EAGAIN)
} else {
// 处理已就绪的读写事件
}
}
}ET 模式下必须把 fd 设为非阻塞,并且循环读写直到返回 EAGAIN,否则数据可能被「遗忘」在缓冲区里、再也不触发通知。
追问 epoll 内部结构时,能答出「红黑树管注册、就绪链表管派发、mmap 减拷贝」这三点,是区分「背过」和「真懂」的关键。
Reactor 和 Proactor 模式的区别?epoll 属于哪种?
Reactor 与 Proactor 是两种经典的事件驱动 IO 模型,核心区别在于**「谁来完成真正的 IO 读写」以及「事件通知的是就绪还是完成」**。
- Reactor(反应器):事件循环监听 IO 就绪事件,就绪后由用户代码同步调用
read/write完成实际读写。本质是同步非阻塞 IO + 事件回调(分发)。 - Proactor(前摄器):用户先发起异步 IO 请求(注册读/写 + 用户缓冲区),内核/运行时异步完成实际读写,完成后通过回调/事件通知用户「数据已就绪」。本质是异步 IO + 完成事件回调。
对比:
| 维度 | Reactor | Proactor |
|---|---|---|
| IO 触发方式 | 同步非阻塞(IO 就绪事件) | 异步 IO(IO 完成事件) |
| 谁执行读写 | 用户代码主动 read/write | 内核/运行时完成,用户只取结果 |
| 通知内容 | 「可读 / 可写」 | 「读 / 写已完成」 |
| 典型实现 | epoll/kqueue/select(muduo、Netty) | Boost.Asio、io_uring、IOCP(Windows) |
| 编程复杂度 | 事件回调 + 自己管理缓冲区 | 回调/协程,代码更直观但需支持异步 IO |
Reactor 骨架:epoll 通知「可读」后,用户代码自己同步 read。
#include <sys/epoll.h>
#include <unistd.h>
// Reactor:epoll 监听就绪事件,用户代码同步 read
void handle_read(int fd) {
char buf[1024];
ssize_t n = read(fd, buf, sizeof(buf)); // 此刻 fd 已就绪,同步读取
if (n > 0) { /* 处理 buf */ }
}一句话区分:Reactor 是「可以读了,你去读」;Proactor 是「已经读好了,你用吧」。前者关注「就绪」、用户同步完成;后者关注「完成」、内核异步代劳。
epoll 属于 Reactor(同步非阻塞 + 就绪事件回调);Boost.Asio(Linux 下默认用 epoll 模拟)与 io_uring / IOCP 属于 Proactor(真正的异步 IO)。能主动说出「io_uring 让 Linux 也有了原生 Proactor」,能明显拉开与只背结论的差距。
零拷贝技术(mmap、sendfile、splice)的原理?
传统「读文件 → 发 socket」要走 4 次用户态/内核态切换 + 4 次数据拷贝(磁盘 → 内核缓冲区 → 用户缓冲区 → socket 缓冲区 → 网卡)。零拷贝的目标是减少内核态 ↔ 用户态之间的 CPU 数据搬运,让数据尽量在内核内流转或共享,降低 CPU 与上下文切换开销。
三种常见手段对比:
| 手段 | 原理 | 减少的拷贝 | 适用场景 |
|---|---|---|---|
| mmap + write | 把文件映射到进程地址空间,内核缓冲区与用户空间共享同一物理页,省去「内核 → 用户」拷贝 | 1 次 | 大文件读写、随机访问 |
| sendfile | 内核内直接把数据从「文件页缓存」搬到「socket 缓冲区」,全程不进用户态 | 2 次(少「内核→用户」+「用户→内核」) | 静态文件 / HTTP 文件下载 |
| splice | 在内核内用管道连接两个 fd,数据在两者间「拼接」搬运,无需经过用户态 | 与 sendfile 相当 | 两个 fd 间转发(如代理) |
对比维度:拷贝次数、是否经过用户态、是否需要用户缓冲区。
#include <sys/sendfile.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
// sendfile:内核内完成「文件 → socket」的传输,不经过用户态
int in_fd = open("file.bin", O_RDONLY);
struct stat st;
fstat(in_fd, &st);
off_t offset = 0;
ssize_t sent = sendfile(sock_fd, in_fd, &offset, st.st_size); // 直接发送sendfile 的经典限制:源必须是可 mmap 的普通文件(不能是普通 socket,旧内核),且一次只能发给一个目标 fd——这正是它后来被 splice/tee 补充、以及部分场景仍选 mmap+write 的原因。
追问「nginx/kafka 怎么发文件」时答 sendfile;再补一句「sendfile 之后内核还支持 SG-DMA(Scatter-Gather DMA),进一步省掉 socket 缓冲区那次拷贝」直接拉满。mmap 虽省一次拷贝,但带来**缺页中断 + TLB 抖动 + 写保护(COW)**等代价,小文件反而不如直接 read/write。
TCP 粘包/拆包如何解决?如何设计应用层协议?
TCP 是字节流协议:数据像水流一样连续到达,没有消息边界。发送端多次 send 可能被合并发送(粘包),一条消息也可能被拆成多段到达(拆包),接收端无法仅靠 TCP 自身还原出「一条消息」的边界。
粘包/拆包的根因:TCP 面向字节流 + 内核缓冲区 + Nagle 算法合并小包 + MTU 分片。
三种经典解法:
| 方案 | 做法 | 优点 | 缺点 |
|---|---|---|---|
| 定长消息 | 每条消息固定 N 字节,不足补零 | 实现最简单 | 浪费带宽、长度不灵活 |
| 分隔符 | 消息末尾加特殊分隔符(如 \r\n) | 灵活、可读(HTTP/Redis) | 需转义、需扫描 |
| 长度字段(头+体) | 消息头固定长度存放「body 长度」,再读 body | 高效、无转义、工业主流 | 需处理半包/粘包缓冲 |
应用层协议设计:消息头(固定长度字段) + 消息体(变长)。
#include <cstdint>
#include <cstring>
#include <vector>
#include <arpa/inet.h>
struct Header { uint32_t body_len; }; // 网络字节序
// 编码:包头(4 字节长度)+ 包体
std::vector<char> encode(const std::string& body) {
Header h;
h.body_len = htonl(body.size());
std::vector<char> out(sizeof(h) + body.size());
memcpy(out.data(), &h, sizeof(h));
memcpy(out.data() + sizeof(h), body.data(), body.size());
return out;
}
// 解码:接收端维护累积缓冲区,先读满 4 字节头,再按长度读满 body
// while (buf.size() >= 4) {
// uint32_t len = ntohl(*(uint32_t*)buf.data());
// if (buf.size() < 4 + len) break; // 半包,等待下次数据到达
// 取出 [4, 4 + len) 为一条完整消息;buf 左移继续解析
// }
面试常追问「HTTP/Redis 怎么解决粘包」:HTTP 用 \r\n\r\n 分隔头部 + Content-Length 定 body;Redis 用行分隔(RESP 协议)。核心都在「接收端维护一个累积缓冲区,按协议规则切出完整消息」。
无论哪种方案,接收端都必须维护接收缓冲区 + 处理半包(数据不够时先缓存、等后续到达再继续解析)——这是拆包无法靠 TCP 解决、只能由应用层逻辑兜底的部分。
到底什么是 TCP 连接?
TCP 连接不是一条物理的、独占的信道,而是一个逻辑/虚拟概念:它由「四元组 + 两端各自维护的状态」共同构成。数据在网络里仍然是逐个 IP 包逐跳转发,路由器并不知道「这是一条 TCP 连接」。所谓「连接建立」,本质是让通信双方在各自内核里创建并同步一套状态(序列号、确认号、窗口、拥塞状态、状态机),并对四元组达成一致。
TCP 连接的构成
─────────────────────────────────────────────────────────
客户端内核 服务端内核
┌─────────────────┐ ┌─────────────────┐
│ 源IP:源端口 │ │ 目的IP:目的端口 │
│ 目的IP:目的端口 │ │ 源IP:源端口 │
│ seq / ack │◄── 状态同步 ──►│ seq / ack │
│ 窗口 / 拥塞窗口 │ (三次握手) │ 窗口 / 拥塞窗口 │
│ 状态机(11 种) │ │ 状态机(11 种) │
└─────────────────┘ └─────────────────┘
逻辑连接 = 四元组 + 两端状态,无物理独占信道
─────────────────────────────────────────────────────────连接的三个层面:
| 层面 | 内容 |
|---|---|
| 标识 | 四元组(源IP、源端口、目的IP、目的端口)唯一确定一条连接 |
| 状态 | 两端各自维护序列号、确认号、接收/发送窗口、拥塞状态、TCP 状态机 |
| 物理/网络 | 底层仍是逐包路由的 IP 报文,无任何独占链路 |
强调「连接是端到端的状态,不是物理链路」——这是理解三次握手、四次挥手、TIME_WAIT 这些状态机问题的基础。一句「TCP 连接是两端协议栈共同维护的一段状态 + 一个四元组」就能让答案站得住脚。
TCP 是用来解决什么问题?
IP 层只提供尽力而为(best-effort)的数据报投递:可能丢包、乱序、重复、损坏,也不区分「这条包属于哪条流」。TCP 在 IP 之上补齐了可靠、有序、面向字节流的端到端传输能力,把不可靠的网络变成对应用层友好的可靠通道。
IP 的「不可靠」与 TCP 的「解法」一一对应:
| IP 层的问题 | TCP 的对应机制 |
|---|---|
| 丢包 | 超时重传 / 快速重传 |
| 乱序 | 序列号 + 接收端排序 |
| 重复 | 序列号去重 |
| 数据损坏 | 校验和 |
| 发送方过快压垮接收方 | 滑动窗口(流量控制) |
| 发送方过快压垮网络 | 拥塞控制(慢启动/拥塞避免/快重传/快恢复) |
| 无连接、无流的概念 | 三次握手建立连接、字节流语义 |
一句话总结:TCP 把「不可靠的 IP 分组传输」变成「可靠的字节流传输」。它解决的核心问题就是「如何在一个会丢、会乱、会重的网络上,让两个进程像读写文件一样可靠地收发字节流」。
主动补一句「TCP 面向字节流、没有消息边界,所以才有粘包/拆包问题」,并交叉链接到 03-Resources/cpp/C++ 网络编程,能把这道基础题顺势引到你准备好的知识点上。
一台服务器,最多能建立多少 TCP 连接?
这个问题的关键在「理论上限由四元组决定,实际瓶颈是内存和文件描述符」。很多人第一反应是「端口号只有 65535」,这是典型误区——连接由四元组区分,服务端监听端口虽固定,但对端组合空间巨大。
一条连接唯一由四元组标识:
{ 源IP, 源端口, 目的IP, 目的端口 }
服务端监听 80 端口时:
目的IP、目的端口固定
源IP 有 2^32 种,源端口 2^16 种
→ 理论组合 ≈ 2^48,远超 65535真实约束:
| 约束 | 说明 | 相关参数/命令 |
|---|---|---|
| 内存 | 每条连接占几 KB~几十 KB(socket 结构、sk_buff、收发缓冲) | 这是最硬的约束 |
| 文件描述符 | 每连接一个 fd,受进程/系统上限限制 | ulimit -n、/proc/sys/fs/file-max |
| 内核参数 | 半连接/全连接队列、端口范围、缓冲区大小 | net.ipv4.ip_local_port_range、net.core.somaxconn、net.ipv4.tcp_rmem/wmem |
| CPU | 大量连接的定时器、事件处理 | 百万连接需 epoll + 多队列 |
单机几十万到百万级连接是可行的,但必须调优:放宽 fd 上限、调大缓冲区、用 epoll、开多队列网卡(RSS)、必要时多进程/多 IP 分摊。能答出「端口数不是上限、内存才是」就已经超越了大多数候选人。
TCP 和 UDP 有什么区别?
TCP 与 UDP 是传输层的一对「可靠 vs 轻量」设计,几乎每个维度都相反。核心一句:TCP 面向连接、可靠、字节流;UDP 无连接、不可靠、数据报。
| 维度 | TCP | UDP |
|---|---|---|
| 连接 | 面向连接(三次握手建立) | 无连接,直接发 |
| 可靠性 | 可靠(确认 + 重传 + 序号) | 不可靠,尽力而为 |
| 数据形态 | 字节流,无消息边界 | 数据报,保留报文边界 |
| 顺序 | 保证有序 | 不保证 |
| 传输方式 | 一对一、全双工 | 一对一/一对多/多对多(组播广播) |
| 流量/拥塞控制 | 有 | 无 |
| 头部开销 | 20~60 字节 | 固定 8 字节 |
| 典型场景 | 文件传输、HTTP、SSH、数据库 | DNS、音视频通话、直播、游戏同步、IoT 上报 |
TCP(字节流) UDP(数据报)
发送 3 次 send 发送 3 次 sendto
─────────────► 合并/拆分 ─────────────► 独立成包
接收端看到连续字节,无边界 接收端 3 次 recvfrom,各得一个完整包
→ 有粘包/拆包问题 → 天然有边界,无粘包问题
字节流 vs 数据报是面试最爱追问点:TCP 没有消息边界所以有粘包/拆包问题(详见 03-Resources/cpp/C++ 网络编程);UDP 每个 sendto 对应一个完整数据报,接收端一次拿一个,天然有边界但可能丢、可能乱。
为什么要 TCP,IP 层实现控制不行么?
可以但不应该,这是经典「端到端原则(end-to-end argument)」的直接结论:可靠传输这类功能必须由通信的两端(传输层)自己保证,而不应下沉到中间的 IP 层去实现。
端到端原则:
可靠性应由「端系统」保证,网络层保持「尽力而为、无状态」
若 IP 层强制可靠:
每个路由器都要为每条流维护状态、缓存重传
→ 路由器要存储海量连接状态,转发性能崩、扩展性崩
→ 且应用需求千差万别(实时性 vs 可靠性),一刀切不可行
正确分层:
应用层 ──► 想要什么语义自己选
传输层 ──► TCP(可靠) / UDP(轻量),端到端实现
网络层 ──► IP:简单、无状态、尽力转发理由要点:
- 扩展性:让每台路由器为每个连接维护重传/状态,核心网络会被状态撑爆,违背「简单无状态、快速转发」的设计初衷。
- 端到端原则:真正的可靠性由发送端确认才算数,中间某一段可靠并不能保证整条路径可靠。
- 应用多样性:视频通话宁可丢包也不愿等待重传(选 UDP),文件下载必须一字不差(选 TCP)——把选择权留在传输层/应用层,而不是在网络层一刀切。
能点出「端到端原则(Saltzer/Reed/Clark 的 end-to-end argument)」这个出处,是这道题最亮的加分点,直接拉开与「背答案」的差距。
说说 TCP 的三次握手?
三次握手的目的是双方确认彼此的收发能力都正常,并同步初始序列号 ISN。三次之后,客户端确认自己能发能收、服务端能发能收,双方各自进入 ESTABLISHED。
客户端 服务端
CLOSED LISTEN
│ ① SYN, seq=x │
│────────────────────────────────────►│ 收到后进入 SYN_RECV
│ │
│ ② SYN+ACK, seq=y, ack=x+1 │
│◄────────────────────────────────────│
│ │
│ ③ ACK, seq=x+1, ack=y+1 │
│────────────────────────────────────►│
ESTABLISHED ESTABLISHED为什么是三次(不是两次、不是四次):
- 防历史/失效连接:两次握手时,若客户端第一次发送的 SYN 因网络延迟迟到,服务端误以为新连接、建立后空等,浪费资源;三次握手让客户端对「是否真的要建立」做最终确认。
- 确认双向能力:第一次证明「客户端→服务端」可达、客户端能发;第二次证明「服务端→客户端」可达、服务端能收发;第三次证明客户端能收。三缺一都无法确认双向通信能力。
- 不是四次:服务端的 SYN 与 ACK 可以合并成同一个报文(SYN+ACK),所以三次就够。
「为什么是三次」几乎必被追问,核心答「防止失效的重复 SYN 建立无效连接 + 确认双方收发能力」两点。能再补一句「第三次握手可以携带数据(快速打开 TFO 的思想)」属于进阶加分。
TCP 初始序列号 ISN 怎么取值的?
ISN 不能固定为 0 或可预测值,而必须随机化,现代实现用一个「基于时间的计数器 + 随机偏移(密钥 hash)」生成。
ISN 生成思路(Linux):
ISN = 单调递增的时间分量(每 4μs +1,RFC793)
+ 随机偏移(基于源/目的 IP、端口、密钥的 hash)
→ 结果既随时间单调、又难以被外部预测为什么要随机化:
- 防序列号预测攻击:攻击者若能预测 ISN,就能伪造合法连接注入数据或劫持会话(历史上 TCP 劫持的经典手法)。
- 防新旧连接串包:同一四元组复用后,若 ISN 从固定值开始,上一次连接的迟到报文可能被新连接误认;随机 ISN 让旧报文序号大概率落在窗口之外,被丢弃。
- 防半开连接误认:配合时间戳(timestamp)进一步防止「旧包被新连接接收」。
能说出「ISN 随时间递增 + 随机 hash,防止预测攻击和旧包串扰」即可;再补一句「Linux 还带 timestamp 选项辅助防回绕(PAWS)」直接证明你读过实现。
TCP 三次握手时,发送 SYN 之后就宕机了会怎么?
分两种情况看,核心是「对端通过超时重传兜底,最终放弃」。
客户端发 SYN 后宕机:
客户端 服务端
│ ① SYN │
│──────────► │ 进入 SYN_RECV
│ ② SYN+ACK │
│◄────────── │ 未收到 ACK,按指数退避重传 SYN+ACK
│ (宕机,无 ACK) │ 1s → 2s → 4s → 8s → 16s → 32s
│ │ 重传 tcp_synack_retries 次(默认5)后放弃
│ │ → 回收连接,回到 LISTEN- 服务端收不到第三次 ACK,会把连接放入半连接队列(SYN queue),按
1s、2s、4s、8s、16s、32s指数退避重传 SYN+ACK,默认tcp_synack_retries = 5次后放弃并回收(总耗时约 63s)。 - 若宕机后重启:重启后的客户端收到迟到的 SYN+ACK,因本地无此连接状态,会回一个 RST,服务端收到 RST 立即释放连接。
- 若服务端(应答方)在发 SYN+ACK 后宕机,同理客户端会重传 ACK 直到超时。
大量「SYN 发出后无 ACK」的半连接堆积正是 SYN Flood 攻击的利用点(见下题),内核用 net.ipv4.tcp_synack_retries 和半连接队列长度 tcp_max_syn_backlog 来控制。
什么是 SYN Flood 攻击?
SYN Flood 是经典 DoS 攻击:攻击者伪造大量源 IP 不可达的 SYN 报文,服务端对每个 SYN 回 SYN+ACK 并进入 SYN_RECV,但这些半连接永远等不到第三次 ACK,最终把半连接队列(SYN queue)打满,导致正常连接无法建立。
攻击者 服务端
伪造源IP的 SYN ─────────────► 每收到一个 SYN 就:
1) 分配半连接条目
2) 回 SYN+ACK(发往伪造的源IP,无人回应)
3) 进入 SYN_RECV,等待 ACK
大量伪造 SYN ─────────────► 半连接队列被打满
正常用户的 SYN 被丢弃 → 服务不可用防御手段:
| 手段 | 原理 |
|---|---|
SYN Cookie(tcp_syncookies=1) | 半连接队列满时不真正分配资源,而是把连接信息编码进 SYN+ACK 的 seq(Cookie),收到合法 ACK 再重建连接,几乎零资源消耗 |
| 增大 backlog | net.core.somaxconn + tcp_max_syn_backlog 扩大队列 |
| 减少重传次数 | 调小 tcp_synack_retries,让无效半连接更快释放 |
| 限流/清洗 | 防火墙、负载均衡对来源限速,或用 CDN/高防清洗 |
SYN Cookie 是必答点,能说清「不提前分配资源、把状态编码进 Cookie、靠合法 ACK 才真正建连」的机制即为亮点。能再提「半连接队列 vs 全连接队列(accept queue)是两个不同的队列」,说明你真懂 Linux 实现。
TCP 协议是如何保证可靠传输的?
TCP 的「可靠」不是单一机制,而是一整套序号 + 确认 + 重传 + 校验 + 流量/拥塞控制的组合拳:
| 机制 | 作用 |
|---|---|
| 序列号 seq | 给每个字节编号,保证有序、去重 |
| 确认 ack | 接收方告知「已按序收到」 |
| 校验和 checksum | 检测传输过程中的比特错误 |
| 超时重传 RTO | 定时器超时未确认则重传 |
| 快速重传 | 收到 3 个重复 ACK 立即重传,不等超时 |
| 滑动窗口(流量控制) | 用接收窗口 rwnd 防止发送方压垮接收方 |
| 拥塞控制 | 慢启动/拥塞避免/快重传/快恢复,防止压垮网络 |
可靠传输核心闭环:
发送方 接收方
发 seq=100~199 收到后回 ack=200(表示 200 之前已按序收到)
│── 数据 ──► 若有乱序,暂存并按 seq 排序
│◄── ack=200 重复/损坏/失序按规则丢弃或缓存
超时或 3 个重复 ACK → 重传几个要点:
- 重传:超时重传的 RTO 是动态计算的(RTT 加权平均 + 偏差),比固定超时更贴合网络。
- 快速重传:接收方收到乱序包会重复发送「期望的下一个 ACK」,发送方连续收到 3 个相同 ACK 就认为该包丢了,立即重传而不等 RTO。
- 流量控制:接收方通过窗口字段告诉发送方「我还能收多少」,窗口为 0 时发送方停发。
- 拥塞控制:区别于流量控制(针对接收方),它针对整条网络的拥塞,通过慢启动、拥塞避免动态调整发送速率。
被追问时能把「超时重传 vs 快速重传(3 个重复 ACK)」和「流量控制(rwnd,对端)vs 拥塞控制(cwnd,网络)」两对概念分清,基本就拿到了这道题的高分。
说说 TCP 的四次挥手?
TCP 是全双工,两个方向各自独立关闭,所以挥手需要「双方各发一次 FIN + 各回一次 ACK」,共四次。主动关闭方(FIN-WAIT)与被动关闭方(CLOSE-WAIT)状态如下:
主动方 被动方
ESTABLISHED ESTABLISHED
│ ① FIN, seq=u(我不再发数据) │
│──────────────────────────────────────►│ 进入 CLOSE_WAIT
│ ② ACK, ack=u+1 │(可能还有数据要发)
│◄──────────────────────────────────────│
│ 进入 FIN_WAIT_2,等待对方 FIN │
│ ③ FIN, seq=w, ack=u+1 │(数据发完,也关闭发送)
│◄──────────────────────────────────────│
│ ④ ACK, ack=w+1 │
│──────────────────────────────────────►│
TIME_WAIT(等 2MSL) LAST_ACK → CLOSED为什么是四次(不是三次):
- FIN 只关闭「自己→对方」这一个方向。被动方收到 FIN 后,可能还有数据没发完,所以先回 ACK 表示「知道了」,等自己的数据发完再发自己的 FIN。
- 服务端的「ACK ②」和「FIN ③」因为中间隔着未发完的数据,通常无法合并,于是变成四次。
极端情况下也有「三次挥手」:若被动方收到 FIN 时恰好没有数据要发,它可以把 ACK 和 FIN 合并成一个报文(FIN+ACK),此时挥手就变成 3 个报文(FIN → FIN+ACK → ACK),但标准流程仍是四次。
为什么 TCP 挥手需要有 TIME_WAIT 状态?
TIME_WAIT 出现在主动关闭方,持续 2MSL(MSL 是报文最大生存时间,Linux 默认 30s,所以 TIME_WAIT 默认 60s)。它存在两个不可替代的原因:
原因一:保证最后一个 ACK 可靠送达
主动方发 ④ACK 后直接关,若 ACK 丢失,被动方会重传 FIN
→ 主动方必须保留状态,能再次回应 ACK
→ 否则被动方会一直 LAST_ACK 重传、无法正常关闭
原因二:让旧连接的迟到报文在网络中消亡
2MSL = 一个报文去 + 一个报文回的最大时间
→ 保证本连接所有残留在网络里的报文都已消失
→ 防止「旧连接的迟到报文」被复用同四元组的新连接误收两个原因的对应关系:
| 原因 | 解决的问题 |
|---|---|
| 保证最后 ACK 送达 | 可靠关闭连接(被动方能收到最终 ACK) |
| 等旧报文消亡 | 防止旧连接报文串到新连接(串包/串数据) |
TIME_WAIT 的代价与处理:
| 代价 | 处理方式 |
|---|---|
| 占用端口/内存资源 | 服务端大量主动关闭时出现大量 TIME_WAIT |
| 端口复用 | SO_REUSEADDR 允许复用 |
| 内核调优 | tcp_tw_reuse(客户端可复用);⚠️ tcp_tw_recycle 已因 NAT 环境下时间戳问题被废弃,不要用 |
两个原因缺一不可,都要答到:「可靠关闭(防 ACK 丢失)+ 防止旧包串新连接(2MSL 让旧报文自然消亡)」。再补一句「2MSL = 60s(Linux,MSL=30s)」「tcp_tw_recycle 已废弃」会显得非常专业。
除了四次挥手,还有什么方法断开连接?
除了标准的四次挥手(FIN 正常关闭),TCP 还有一条「快速通道」——RST(Reset)异常终止,此外还有 keepalive 探测、半关闭等边界手段。
两种关闭方式对比:
FIN(正常关闭) RST(异常关闭)
──────────────── ────────────────
四次挥手,优雅关闭 单报文,立即断开
保证数据可靠交付完 丢弃发送/接收缓冲中未处理数据
主动方进入 TIME_WAIT 主动方直接 CLOSED,不进入 TIME_WAIT
对端收到后进入 CLOSE_WAIT 对端收到后直接复位RST 的典型触发场景:
| 场景 | 说明 |
|---|---|
| 收到不存在的连接的报文 | 对端发来数据但本地无此四元组状态 → 回 RST |
| 连接过程中发生错误 | 收到非法报文、序列号越界等 → 发 RST 强制终止 |
| 主动「不想等」 | 应用设置 SO_LINGER(linger=0) 时,close 会直接发 RST |
| 半开连接被探测到 | keepalive 探测失败也可能触发 RST/关闭 |
其他边界手段:
- 半关闭(half-close):
shutdown(SHUT_WR)只关闭「自己→对方」的发送方向,仍可继续接收对方数据——这正是四次挥手中间态的来源。 - keepalive 保活:长时间无数据时周期探测对端是否存活,检测到对端已消失则回收连接。
能主动答出「RST 是异常终止、单报文、不进入 TIME_WAIT、会丢缓冲数据」,并和「FIN 是优雅关闭、四次挥手、进入 TIME_WAIT」形成对比,就把这道「开放式追问」稳稳接住了。再补 SO_LINGER 触发 RST 属于加分。
TCP 超时重传机制是为了解决什么问题?
TCP 提供的是可靠传输,其前提是「发送的每个字节都能被对端正确收到」。但底层 IP 网络是尽力而为(best-effort)的:数据包可能丢失、损坏、乱序、重复。超时重传(RTO,Retransmission Timeout)解决的核心问题就是「如何发现并补救丢包」——当发送方在某个时限内没收到对端的确认(ACK),就认为数据段可能丢了,于是重发。
它的工作逻辑:
- 发送方每发出一个数据段,就启动一个重传定时器。
- 若在定时器到期前收到该段的 ACK,取消定时器,认为传输成功。
- 若定时器到期仍未收到 ACK,判定该段丢失,重新发送,并把定时器时间翻倍(指数退避)。
关键点在于超时时间(RTO)如何设定:设太短会误判、频繁重发浪费带宽;设太长则丢包后迟迟不重发、延迟高。所以 RTO 由 RTT 动态估算而来:
RTT 测量 -> 平滑往返时间 SRTT
SRTT = (1-α)·SRTT + α·RTT (α 常取 1/8)
RTTVAR = (1-β)·RTTVAR + β·|SRTT - RTT| (β 常取 1/4)
RTO = SRTT + 4·RTTVAR (保证留有余量)- Karn 算法:重传的数据段不参与 RTT 采样——因为无法区分收到的 ACK 是对「原始包」还是「重发包」的确认,混入采样会污染 RTT 估计。
- 指数退避:连续重传时每次把 RTO 翻倍(如 1s → 2s → 4s …),上限通常 60s;连续超时多次仍未收到 ACK,说明对端可能已不可达,TCP 最终放弃并关闭连接。
能说清「RTO 不是固定值,而是由 SRTT + RTTVAR 动态算出的」,并补充 Karn 算法 + 指数退避,是「真懂超时重传」的分水岭。
TCP 中何时会出现 RST(reset)报文?
RST(Reset) 是 TCP 首部标志位之一,作用是异常终止连接,表示「这个连接我不认识/不想要了,直接重置,不需要四次挥手」。它出现的典型场景:
| 场景 | 触发原因 | 举例 |
|---|---|---|
| 请求不存在的端口 | 对端主机可达,但目标端口无进程监听 | connect 到未监听的端口,内核回 RST |
| 异常终止连接 | 一方想立刻断开,不等四次挥手 | SO_LINGER 设为 0,close 时发 RST |
| 半开连接(half-open) | 一方崩溃/重启,另一方继续发数据,对方已不认识该连接 | 收到对「未知四元组」的数据包时回 RST |
| SYN 请求到已建立连接的端口 | 收到与当前连接不匹配的 SYN | 服务器端口已有连接,又收到异常 SYN |
| 超时放弃后 | 多次重传仍无响应,主动发 RST 关闭 | 连接存活超时 |
| NAT/防火墙策略 | 中间设备拒绝该连接 | 防火墙拦截后主动发 RST |
RST 与 FIN 的区别:FIN 是优雅关闭(走完整四次挥手、双方确认后才断);RST 是暴力重置(立即断开,不确认,接收方直接丢弃发送/接收缓冲区的未处理数据)。
正常关闭: A --FIN--> B --ACK--> A --FIN--> B --ACK--> A (四次挥手)
异常重置: A --RST--> B (B 收到后立即关闭,无需 ACK)收到 RST 后,本端会立即丢弃连接上所有未读数据并关闭 socket,且不会向对端回 ACK。因此 RST 意味着「数据可能丢」,只应在确实不需要可靠交付时使用。
能主动区分 RST(异常重置,无 ACK)与 FIN(优雅关闭,有四次挥手),并说出「SO_LINGER=0 会发 RST」这种工程细节,会让回答更扎实。
TCP 有超时重传为什么还需要快速重传机制?
因为超时重传太慢。RTO 通常被限制为最小 200ms(RFC 6298 建议下限 1s,实际实现多为 200ms),且一旦丢包会触发拥塞控制把 cwnd 打回慢启动,代价巨大。在高带宽、低延迟网络里,「等一个 RTO 再重传」会带来明显卡顿。
快速重传(Fast Retransmit) 利用 TCP 的累计确认(cumulative ACK)特性:接收方每收到一个乱序数据段,就立即重发上一个按序到达段的 ACK,形成重复 ACK(duplicate ACK)。发送方收到 3 个重复 ACK,就判断「该 ACK 后面的那个段丢了」,不等超时,立即重传。
发送方发 [1][2][3][4][5],其中 [2] 丢失:
接收方收到 [1] -> ACK 2 (期望 2)
接收方收到 [3] -> 重复 ACK 2 (乱序,重发对 [1] 的确认)
接收方收到 [4] -> 重复 ACK 2 (第 2 个重复 ACK)
接收方收到 [5] -> 重复 ACK 2 (第 3 个重复 ACK)
发送方收到 3 个重复 ACK 2 -> 立刻重传 [2],无需等待 RTO两者对比:
| 维度 | 超时重传(RTO) | 快速重传(Fast Retransmit) |
|---|---|---|
| 触发条件 | 定时器超时 | 收到 3 个重复 ACK |
| 反应速度 | 慢(≥200ms,且指数退避) | 快(几乎实时) |
| 对拥塞窗口 | 直接降到 1 MSS 重进慢启动 | 配合快速恢复,cwnd 只减半 |
| 适用场景 | 整个窗口丢光、无 ACK 可依 | 后续数据仍能到达、能产生重复 ACK |
1 个可能是乱序重排,2 个也可能是网络抖动,3 个重复 ACK 通常意味着对端后续数据仍在正常到达、唯独中间某个段丢了——这时快速重传最可靠,也避免对乱序误判。
强调「快速重传解决的是速度问题,超时重传是兜底:当窗口整个丢了、连重复 ACK 都产生不了时,仍要靠超时重传恢复」,说明两者互补而非替代。
TCP 的 SACK 的引入是为了解决什么问题?
SACK(Selective Acknowledgment,选择性确认) 解决的是**「重传粒度太粗」的问题。传统 TCP 只有累计确认**:ACK = n 表示「n 之前的字节都收到了」,但无法告诉发送方 n 之后具体收到了哪些。
带来的问题:在快速重传场景下,发送方只知道「某个段丢了」,却不知道丢的是哪几个段。它可能:
- 重复重传后面其实已经收到的段(浪费带宽);
- 只重传一个段,但实际丢了多个段,还得再等一轮重复 ACK,恢复慢。
SACK 通过在 TCP 选项里携带已收到的「块(block)」的左右边界,让接收方明确告诉发送方「我收到了 [L1,R1)、[L2,R2) 这些区间,只缺中间的洞」:
没有 SACK:发送方收到 3 个重复 ACK,只知道「有个段丢了」,
可能把整个窗口的段都重传一遍。
有 SACK: 接收方 ACK 里附带「已收到 [1001,1500)、[2001,2500)」,
发送方精准知道缺 [1501,2000],只重传这一个洞。TCP SACK 选项(示意):
+--------+--------+----------------+
| Kind=5 | Length | Left Edge ... |
+--------+--------+----------------+
| Right Edge ... (可多个块) |
+----------------------------------+DSACK(Duplicate SACK) 是 SACK 的扩展,接收方把自己重复收到的段也报给发送方,用于判断是「数据真丢了」还是「ACK 丢了/乱序」,避免不必要的重传。
能说清「SACK 是让重传变精准:从『重传整个窗口』到『只重传丢失的洞』」,并能关联到上一题快速重传的「3 个重复 ACK」配合使用,逻辑链完整。注意 SACK 需双方在握手时协商 TCP_SACK 选项才生效。
TCP 滑动窗口的作用是什么?
滑动窗口(Sliding Window) 是 TCP 实现流量控制(flow control)的核心机制,作用是让发送方根据接收方的处理能力来调节发送速率,避免「发送过快,接收方缓冲区被撑爆、只能丢包」。
核心思想:接收方在 ACK 里携带窗口大小(rwnd, receive window),告诉发送方「我还能接收多少字节」;发送方未确认的数据量不能超过这个窗口。窗口随数据发送而前移(滑动),随 ACK 到达而右移。
发送方视角(字节流):
|<-- 已确认 -->|<------ 已发送未确认 ------>|<-- 可继续发送 -->|<-- 不可发送 -->|
^ 窗口左沿 ^ 窗口右沿
|<---------- 发送窗口 --------->| (受 rwnd 与 cwnd 共同约束)- 窗口左沿:随 ACK 到达向右移动。
- 窗口右沿:随对端通告的 rwnd 大小伸缩。
- 发送窗口 = min(rwnd, cwnd):rwnd 由接收方决定(流量控制),cwnd 由网络拥塞决定(拥塞控制)。
典型现象:
| 情况 | 结果 |
|---|---|
| 接收方缓冲区快满,rwnd 变小甚至为 0 | 发送方减速或暂停(零窗口) |
| 接收方处理快,rwnd 变大 | 发送方可加快 |
| 窗口太小 | 带宽利用率低(小窗口导致频繁 ACK/停顿) |
rwnd = 4096,已发送未确认 2048 -> 还能发 2048
接收方处理后通告 rwnd = 8192 -> 窗口右移,可继续发更多滑动窗口管的是「发送节奏」,而 TCP 本身是字节流、无消息边界,窗口滑动不会替你切分消息——这就回到了 03-Resources/cpp/C++ 网络编程,接收端仍需自行处理消息边界。
当 rwnd = 0 时发送方停止发送,但要靠**零窗口探测(zero window probe)**定时发探测包,否则接收方窗口恢复的通知若丢失,双方会永久僵住(死锁)。
能说出「发送窗口 = min(rwnd, cwnd)」,把**流量控制(rwnd)和拥塞控制(cwnd)**两个概念区分开,是这道题的核心得分点。
说说 TCP 拥塞控制的步骤?
拥塞控制(Congestion Control) 解决的是「网络本身拥塞」的问题(区别于滑动窗口的接收方流量控制):当网络中间路由器缓存溢出、开始丢包时,TCP 需要主动降低发送速率,避免雪崩。它由发送方维护一个拥塞窗口 cwnd,控制未确认数据量。
经典算法(Reno)分四个阶段,配套两个核心变量:cwnd(拥塞窗口)与 ssthresh(慢启动阈值):
1. 慢启动(Slow Start)
- 初始
cwnd = 1 MSS,每收到一个 ACK,cwnd翻倍(指数增长)。 - 直到
cwnd >= ssthresh,转入拥塞避免。
2. 拥塞避免(Congestion Avoidance)
cwnd每过一个 RTT 只线性 +1 MSS(加法增大),温和探测网络容量。- 若发生超时:
ssthresh = cwnd/2,cwnd = 1 MSS,重新慢启动。
3. 快速重传(Fast Retransmit)
- 收到 3 个重复 ACK,立即重传丢失段,不等超时。
4. 快速恢复(Fast Recovery)
- 收到 3 个重复 ACK 后:
ssthresh = cwnd/2,cwnd = ssthresh + 3 MSS(而非降到 1)。 - 之后每收到一个重复 ACK,
cwnd += 1;收到新数据的 ACK,cwnd = ssthresh,回到拥塞避免。
cwnd
^
| * * * * <- 拥塞避免:线性增长
| *
| *
| * <- 慢启动:指数增长
| *
+--------------------------> 时间
ssthresh 阈值处切换超时意味着「整个窗口可能都丢了、网络几乎不可用」,所以要直接回慢启动;3 个重复 ACK 说明「只是丢了几个段,后续数据还在流动」,只需减半 + 快速恢复。这就是 Reno 对两种丢包信号区别对待的原因。
能说出「慢启动指数、拥塞避免加法增大(AIMD)、超时回慢启动、重复 ACK 走快速恢复」四条主线,并提到 NewReno/BIC/CUBIC 等现代算法(CUBIC 是 Linux 默认),即可拿满分。
ARP 和 RARP 分别是什么?有什么区别?
两者都是地址解析协议,工作在网络接口层(链路层),解决「逻辑地址 ↔ 物理地址」的映射问题,但方向相反。
- ARP(Address Resolution Protocol):由 IP 地址查 MAC 地址。主机发送数据前,需要用目标 IP 找到下一跳的 MAC 地址,才能组装以太网帧。
- RARP(Reverse ARP):由 MAC 地址查 IP 地址。早期无盘工作站开机时只知道自己的 MAC,用它向服务器查询自己的 IP。
工作流程对比:
ARP: A 要发给 IP=192.168.1.2,但不知其 MAC
-> 广播 ARP 请求「谁是 192.168.1.2?告诉我你的 MAC」
-> 目标机单播应答「我是,我的 MAC 是 xx:xx:xx:xx:xx:xx」
-> A 把 IP-MAC 映射写入 ARP 缓存
RARP: 无盘机只知道自己的 MAC
-> 广播 RARP 请求「我是 xx:xx:xx:xx:xx:xx,我的 IP 是多少?」
-> RARP 服务器应答「你的 IP 是 192.168.1.5」| 维度 | ARP | RARP |
|---|---|---|
| 映射方向 | IP → MAC | MAC → IP |
| 谁发起 | 任意需要通信的主机 | 无盘工作站/无 IP 设备 |
| 广播类型 | 广播请求、单播应答 | 广播请求、单播应答 |
| 现状 | 仍在广泛使用 | 已被 DHCP/BOOTP 取代 |
| 缓存 | 有 ARP 缓存表 | 无(基本废弃) |
ARP 无认证机制,容易被ARP 欺骗(ARP spoofing):攻击者伪造 ARP 应答,把某 IP 映射到自己的 MAC,实现中间人攻击或断网。
主动指出「RARP 已被 DHCP/BOOTP 取代(DHCP 还能下发更多配置),现在几乎没有实际部署」,并顺带提 ARP 缓存 + ARP 欺骗,体现知识的新鲜度与安全意识。
TCP/IP 四层模型是什么?
TCP/IP 四层模型是实际互联网采用的协议分层模型(相比 OSI 更贴近真实实现),由下到上分四层,每层职责清晰、只依赖下层、对上层透明:
| 层 | 名称 | 主要职责 | 典型协议 |
|---|---|---|---|
| 4 | 应用层 | 为应用程序提供网络服务,定义数据格式 | HTTP、HTTPS、DNS、FTP、SMTP、SSH |
| 3 | 传输层 | 端到端通信,进程间数据传输、可靠/不可靠传输 | TCP、UDP |
| 2 | 网络层(网际层) | 寻址与路由,把数据包从源送到目的主机 | IP、ICMP、IGMP、ARP |
| 1 | 网络接口层(链路层) | 物理传输,帧封装、物理地址(MAC)、差错检测 | 以太网、WiFi、PPP |
+------------------+
| 应用层 | HTTP / DNS / FTP ...
+------------------+
| 传输层 | TCP / UDP(端口,端到端)
+------------------+
| 网络层 | IP(IP 地址,路由转发)
+------------------+
| 网络接口层 | 以太网 / WiFi(MAC 地址,帧)
+------------------+数据封装(发送时自上而下层层加头,接收时自下而上层层拆头):
应用层数据
-> + TCP 头 = 段(Segment)
-> + IP 头 = 包(Packet)
-> + 帧头/尾 = 帧(Frame)TCP/IP 的应用层 ≈ OSI 的「应用层 + 表示层 + 会话层」;传输层 ↔ 传输层;网络层 ↔ 网络层;网络接口层 ≈ 「数据链路层 + 物理层」。四层模型是「合并版」,更简洁实用。
能一边说分层、一边说出「各层对应的地址标识:应用层用端口、传输层用端口、网络层用 IP、链路层用 MAC」,说明你理解了分层的本质是「不同层级用不同地址寻址」。
OSI 七层模型是什么?
OSI(Open Systems Interconnection)七层模型是 ISO 提出的理论参考模型,把网络通信划分为七个层次,比 TCP/IP 四层更细、更偏教学/标准化。自上而下:
| 层 | 名称 | 职责 | 典型协议/设备 | 数据单位 |
|---|---|---|---|---|
| 7 | 应用层 | 提供用户接口与应用服务 | HTTP、DNS、SMTP、FTP | 数据(Data) |
| 6 | 表示层 | 数据格式转换、加密、压缩 | TLS/SSL、JPEG、ASCII | 数据 |
| 5 | 会话层 | 建立/管理/终止会话 | RPC、NetBIOS | 数据 |
| 4 | 传输层 | 端到端可靠传输、流量/拥塞控制 | TCP、UDP | 段(Segment) |
| 3 | 网络层 | 逻辑寻址与路由 | IP、ICMP、路由器 | 包(Packet) |
| 2 | 数据链路层 | 帧封装、物理寻址(MAC)、差错检测 | 以太网、交换机、ARP | 帧(Frame) |
| 1 | 物理层 | 比特流传输、电气/物理特性 | 网线、集线器、光纤 | 比特(Bit) |
7 应用层 <-- 人机交互
6 表示层 <-- 加解密、编码
5 会话层 <-- 会话管理
4 传输层 <-- 端到端(TCP/UDP)
3 网络层 <-- IP 路由
2 数据链路层 <-- MAC 帧
1 物理层 <-- 比特/信号记忆口诀:「物 数 网 传 会 表 应」(从下往上:物理层、数据链路层、网络层、传输层、会话层、表示层、应用层)。
OSI 是先定标准再实现,划分过细、实现复杂,实际落地失败但教学价值高;TCP/IP 是先有实现再归纳,更贴近真实互联网。面试中两者常被放在一起问「区别/对应关系」。
能答出「OSI 是理论模型、七层划分过细;TCP/IP 是事实标准、四层合并了 5/6/7 层」,并举例「TLS 在 OSI 里算表示层、在 TCP/IP 里归应用层」,说明你真的理解了两者的映射关系。
Cookie、Session、Token 之间有什么区别?
三者都是 Web 应用中**维持「状态/身份」**的手段,解决的是「HTTP 无状态」带来的「如何记住用户」的问题,但机制和存放位置不同。
| 维度 | Cookie | Session | Token |
|---|---|---|---|
| 本质 | 浏览器本地存储的一小段键值对 | 服务端保存的用户会话数据 | 客户端持有的凭证(一串编码/签名数据) |
| 存放位置 | 客户端(浏览器) | 服务端(内存/DB/Redis) | 客户端(一般 localStorage/内存) |
| 谁生成 | 服务端 set-cookie 下发,浏览器自动回传 | 服务端创建并维护 | 服务端签发,客户端自持 |
| 携带方式 | 每次请求自动带上 | 通过 Cookie 里的 session_id 关联 | 手动放 Header(如 Authorization) |
| 状态性 | 无状态(纯数据) | 有状态(服务端要存) | 无状态(JWT 可自校验) |
| 典型用途 | 记住偏好、保存 session_id | 登录态、购物车 | 无状态 API 鉴权、SSO |
三者的关系与典型流程:
Cookie + Session 模式(传统,有状态):
登录 -> 服务端生成 session_id -> 存服务端 -> 通过 Set-Cookie 下发
之后每次请求 -> 浏览器自动带 Cookie(session_id) -> 服务端查 Session
Token 模式(无状态,如 JWT):
登录 -> 服务端签发 Token(含用户信息+签名)-> 返回客户端
之后每次请求 -> 客户端手动在 Header 带 Token -> 服务端验签即可- Cookie 是「浏览器里的一小块存储」,本身只是载体,不一定是身份凭证。
- Session 是「服务端的用户数据」,靠 Cookie 里的 session_id 关联,有状态。
- Token 是「客户端自持的凭证」,服务端验签即可、无需存会话(JWT 为代表)。
Cookie 存在 CSRF 风险(浏览器自动携带);Session 存服务端会带来扩展性问题(多机共享需 Redis 等);Token 一旦泄露则难以主动作废(除非加黑名单)。三者不是互相替代,而是「载体 + 数据 + 凭证」的分工。
别只背定义,主动画清「Session 是数据、Cookie 是存放 session_id 的载体、Token 是自持凭证」的分工关系,并点出「分布式场景下 Session 要共享存储、Token 天然无状态更好扩展」,即能拉开差距。
JWT Token 能说说吗?
JWT(JSON Web Token) 是一种无状态、自包含的身份凭证标准(RFC 7519),由三部分组成,用 . 分隔:
Header.Payload.Signature
Header = base64url({ "alg": "HS256", "typ": "JWT" })
Payload = base64url({ "sub": "10086", "name": "alice", "exp": 1720000000 })
Signature = HMACSHA256( base64url(Header) + "." + base64url(Payload), secret )- Header(头):声明签名算法(HS256/RS256 等)与类型。
- Payload(载荷):存放 claims(声明),如
sub(主体)、exp(过期时间)、iat(签发时间)、自定义字段。⚠️ 只是 base64 编码,不是加密,任何人可解码,不能放敏感信息。 - Signature(签名):用密钥对「头+载荷」签名,用于防篡改——改了 Payload 验签就会失败。
完整结构示例:
eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMDA4NiJ9.签名部分
^ Header ^ Payload ^ Signature服务端签发的 JWT 自带用户信息与有效期,服务端无需保存会话,验签即可恢复身份——因此多实例、微服务、跨域 API 都能直接验证,天然支持水平扩展。
// JWT 使用思路(伪代码,仅示意流程)
std::string token = jwt::create()
.set_issuer("myapp")
.set_subject("10086")
.set_expires_at(std::chrono::system_clock::now() + std::chrono::hours(2))
.sign(jwt::algorithm::hs256{"secret"}); // 签发
auto decoded = jwt::decode(token); // 校验并解析
auto verifier = jwt::verify().allow_algorithm(jwt::algorithm::hs256{"secret"});
verifier.verify(decoded); // 验签,失败则拒绝- Payload 不加密,别放密码等敏感字段(要加密得用 JWE)。
- 无法主动失效:Token 签发后到
exp前一直有效,登出/改密需靠黑名单或短有效期缓解。 - 密钥/算法安全:别用
alg: none,HS256 密钥要够长,否则可被伪造。
能答出「JWT = Header.Payload.Signature 三段 base64url,签名只保证完整性/防篡改、不保证机密性」,并说出「无状态、自包含、适合分布式,但难主动吊销」,就是高分回答。
简单谈谈你对 DNS 的理解?
DNS(Domain Name System) 是互联网的域名解析系统,作用是把人类易记的域名(如 www.baidu.com)转换成机器可路由的 IP 地址(如 110.242.68.4)。它本质上是一个分层、分布式、带缓存的数据库。
分层结构(域名从右到左逐级解析):
. 根域名服务器(Root,全球 13 组)
└── com 顶级域 TLD(.com/.org/.cn ...)
└── baidu.com 二级域(权威服务器)
└── www 主机名解析流程(递归 + 迭代结合):
客户端 -> 本地 DNS 服务器(递归查询,本地帮你查到底)
本地 DNS -> 根服务器(问 .com 在哪) -> 返回 .com 服务器
-> .com 服务器(问 baidu.com 在哪) -> 返回 baidu.com 权威服务器
-> baidu.com 权威服务器(www 的 IP?)-> 返回最终 IP
-> 缓存结果并返回给客户端常见记录类型:
| 记录 | 含义 |
|---|---|
| A | 域名 → IPv4 地址 |
| AAAA | 域名 → IPv6 地址 |
| CNAME | 别名(指向另一个域名) |
| MX | 邮件服务器 |
| NS | 该域名的权威 DNS 服务器 |
| TXT | 文本记录(SPF/DKIM 验证、域名验证) |
- 递归查询:客户端只问一次,本地 DNS 负责查到底再返回(客户端省事)。
- 迭代查询:本地 DNS 一层层问根、TLD、权威服务器(本地 DNS 承担查询过程)。 现实是「客户端 → 本地 DNS 用递归,本地 DNS → 各级服务器用迭代」。
主动提到「本地缓存 + TTL 加速解析、CDN 用 DNS 做就近调度(GSLB)、以及 DNS 污染/劫持、dig/nslookup 排查」,能把这道「理解题」答出工程深度。
简单谈谈你对 CDN 的理解?
CDN(Content Delivery Network,内容分发网络) 是在互联网上部署大量边缘节点(缓存服务器),把内容提前缓存到离用户最近的节点,用户就近获取,从而降低延迟、减轻源站压力、抗攻击。
核心原理:
- 内容缓存:静态资源(图片、JS、CSS、视频、文件)被分发/缓存到各地边缘节点。
- 就近调度:用户请求被 GSLB(全局负载均衡)按「地理位置、运营商、节点负载」调度到最近的边缘节点。
- 回源:边缘节点没有命中缓存时,才向源站(origin)拉取,并缓存下来。
+-----------+
用户A(北京) -->| 边缘节点 |\
+-----+-----+ \
| \ 未命中则回源
用户B(上海) -->+-----+-----+ +---------+
| 边缘节点 |---| 源站(源)|
+-----------+ +---------+
就近访问 -> 命中缓存直接返回,不绕远路到源站CDN 解决了什么:
| 问题 | CDN 的解法 |
|---|---|
| 用户离源站远、延迟高 | 边缘节点就近响应,减少物理距离 |
| 源站流量压力大 | 边缘命中缓存,流量在边缘消化 |
| 单点故障/热点 | 多节点冗余 + 负载分担 |
| DDoS/突发流量 | 边缘吸收攻击与突发,保护源站 |
CDN 调度常依赖 DNS:用户解析域名时,DNS 根据用户 IP 返回「最近边缘节点的 IP」(智能 DNS/GSLB),从而把用户导流到就近节点。常见技术还有 HTTP 302 重定向、Anycast。
能答出「CDN 的价值 = 就近缓存 + 智能调度 + 回源 + 抗 DDoS」,并说出「缓存命中率、刷新/预热、TTL、回源」这些关键概念,说明你懂的不只是名词。
从网络角度来看,用户从输入网址到网页显示,背后发生了什么?
这是一道串联型面试题,考察你是否能把 DNS、TCP、TLS、HTTP、浏览器渲染串成一条完整链路。以输入 https://www.example.com/page 为例,按时间顺序:
1. URL 解析/浏览器缓存 -> 判断协议(https)、域名、路径、是否命中浏览器/系统缓存
2. DNS 解析 -> 域名 -> IP(本地缓存 -> 递归 -> 根/TLD/权威)
3. TCP 三次握手 -> SYN -> SYN+ACK -> ACK,建立可靠连接
4. TLS 握手(HTTPS) -> 协商加密套件、校验证书、交换密钥
5. 发送 HTTP 请求 -> GET /page HTTP/1.1 + 各种 Header
6. 服务器处理并响应 -> HTTP 响应(状态码 + 响应头 + 正文)
7. 浏览器接收并渲染 -> 解析 HTML -> 构建 DOM/CSSOM -> 布局 -> 绘制
8. 连接复用/关闭 -> keep-alive 复用,或四次挥手关闭各阶段要点:
| 阶段 | 关键点 |
|---|---|
| URL 解析 | 拆分协议/域名/端口/路径;先查浏览器缓存、系统 hosts、本地 DNS 缓存 |
| DNS 解析 | 见 03-Resources/cpp/C++ 网络编程,递归 + 缓存 + TTL |
| TCP 握手 | SYN/SYN+ACK/ACK 三次握手,确认双方收发能力 |
| TLS 握手 | 证书链校验、密钥交换(ECDHE)、对称加密会话 |
| HTTP 请求 | 方法、路径、Host、Cookie、User-Agent 等 Header |
| 服务器响应 | 状态码(200/301/404…)、响应头、正文(可能是 CDN 边缘返回) |
| 渲染 | HTML 解析 + 并行加载子资源(CSS/JS/图片,可能走 CDN) |
- HTTPS 会多 TLS 握手(约 1~2 个 RTT),比 HTTP 慢,但可用 HTTP/2 多路复用、TLS 1.3(1-RTT)优化。
- 一个页面往往发起多个连接/请求:HTML 里引用的 CSS/JS/图片会继续发起新的 DNS/TCP/HTTP 流程(除非 keep-alive 复用)。
答这道题要有顺序、有层次,并主动点出「HTTPS 多了 TLS、静态资源走 CDN、keep-alive 复用连接、DNS 有缓存」等工程细节,体现你把零散知识点串成体系的能力。
常见的登录鉴权方式有哪些?各自的优缺点是?
登录鉴权的本质是「确认你是谁(认证)+ 决定你能干什么(授权)」。常见方式可归纳为四类:
| 方式 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| Session + Cookie | 服务端存会话,客户端带 session_id | 简单成熟、可主动失效、状态可控 | 有状态,分布式要共享存储;有 CSRF 风险 |
| Token(JWT) | 服务端签发自包含凭证,客户端自持 | 无状态、易扩展、跨域友好 | 难主动吊销;Payload 不加密;体积随声明增大 |
| OAuth 2.0 / OIDC | 第三方授权,发授权码/令牌 | 免密接入第三方、权限粒度可控 | 流程复杂、实现易踩坑 |
| SSO(单点登录) | 一次登录,多系统共享身份(CAS/OIDC) | 用户体验好、集中管理 | 中心化依赖、单点故障风险 |
补充几种常见变体:
- HTTP Basic Auth:
Authorization: Basic base64(user:pass),极简但明文(需配合 HTTPS),无会话管理。 - API Key / App Secret:服务间调用或开放平台用的静态密钥,简单但易泄露、难轮换。
- 生物识别/OTP 二次认证(MFA):作为第一道认证之上的额外保护。
Session 模式: 登录 -> 服务端存 Session -> 下发 session_id(Cookie)
请求 -> 带 Cookie -> 服务端查 Session -> 判身份
JWT 模式: 登录 -> 服务端签 JWT -> 客户端自持
请求 -> Header 带 Token -> 服务端验签 -> 判身份
OAuth 模式: 用户 -> 同意授权 -> 第三方发 code -> 换 token -> 访问资源
无论哪种方式都应全站 HTTPS、Cookie 设 HttpOnly/Secure/SameSite、Token 存内存或安全存储、口令加盐哈希存储,并注意防 CSRF、XSS、重放攻击。
不要只罗列名词,要能说「选型逻辑」:单体老系统用 Session、微服务/移动端/跨域 API 用 JWT、对接第三方用 OAuth、企业内部多系统用 SSO。再补一句「认证与授权要分离(OIDC 在 OAuth2 上补了身份层)」,基本就是优秀回答。
HTTP 1.0 和 2.0 有什么区别?
HTTP/1.0(1996,RFC 1945)是早期版本,默认短连接:一个请求完成即关闭 TCP 连接,复用需手动发 Connection: keep-alive。HTTP/2.0(2015,RFC 7540,源自 Google SPDY)把 HTTP 从「文本、串行」升级为「二进制分帧、多路复用」,在应用层大幅提升并发与传输效率。
核心区别对比:
| 维度 | HTTP/1.0 | HTTP/2.0 |
|---|---|---|
| 连接模型 | 短连接,一次请求一条 TCP(复用需手动 keep-alive) | 单条 TCP 连接多路复用多个流 |
| 传输格式 | 文本(明文、按行) | 二进制分帧(帧 Frame + 流 Stream) |
| 头部压缩 | 无,每次重复携带完整头部 | HPACK 静态表 + 哈夫曼编码压缩 |
| 并发/队头阻塞 | 串行,一个请求阻塞后续 | 应用层多路复用,多个请求并行 |
| 服务器推送 | 不支持 | 支持 Server Push |
| 流控制/优先级 | 无 | 有(Stream 优先级、WINDOW_UPDATE) |
中间还有一个 HTTP/1.1(1997):引入了长连接 keep-alive(默认开启)、管道化(Pipelining)、Host 头等,但管道化仍受**队头阻塞(Head-of-Line Blocking)**困扰——一个慢响应会堵住整条连接。HTTP/2 的多路复用正是为解决这一问题而生。
HTTP/2 的二进制分帧与多路复用示意:
HTTP/1.0(串行,一条请求一个连接)
连接1: [请求A] [响应A] ── 关闭
连接2: [请求B] [响应B] ── 关闭
↑ 每次都要 TCP 三次握手,资源开销大
HTTP/2(单连接多路复用)
连接: Stream1 ─ [HEADERS][DATA]──响应A
Stream2 ─ [HEADERS][DATA]──响应B ← 多流交织在同一 TCP 连接
Stream3 ─ [HEADERS][DATA]──响应C
帧可交错,靠 Stream ID 区分归属追问「HTTP/2 解决了什么」时,扣住**「二进制分帧 → 多路复用 → 解决队头阻塞 + 头部压缩」这条因果链;再补一句「HTTP/2 只解决应用层**队头阻塞,TCP 层的丢包重传队头阻塞要到 HTTP/3 才解决」,能体现对协议演进的完整理解。
别把 HTTP/1.0 和 HTTP/1.1 混为一谈:1.0 默认短连接、1.1 默认长连接;「多路复用、二进制帧」是 2.0 的招牌特性。
相关:多路复用仍建立在 TCP 字节流之上,帧的切分依赖应用层协议设计,见 03-Resources/cpp/C++ 网络编程。
HTTP 2.0 和 3.0 有什么区别?
HTTP/2 仍然跑在 TCP 上,HTTP/3(2022,RFC 9114)把传输层换成了基于 UDP 的 QUIC,从根上解决了 TCP 带来的队头阻塞与握手慢两个痛点。
核心区别对比:
| 维度 | HTTP/2.0 | HTTP/3.0 |
|---|---|---|
| 传输层 | TCP(可靠字节流) | UDP + QUIC(自带可靠、有序、拥塞控制) |
| 加密 | TLS(可协商,多握手) | TLS 1.3 内建于 QUIC,强制加密 |
| 连接建立 | TCP 三次握手 + TLS 握手(多 RTT) | 0-RTT / 1-RTT 快速握手 |
| 队头阻塞 | 应用层多路复用,但 TCP 丢包会阻塞整条连接 | 流独立,一个流丢包不影响其他流 |
| 连接迁移 | IP/端口变化需重新握手 | 用 Connection ID 标识连接,切网不断连 |
QUIC 快速握手示意:
HTTP/2(TCP + TLS,需要多轮握手)
客户端 服务端
│── TCP SYN ──→│
│←─ SYN+ACK ──│ ① TCP 三次握手(1 RTT)
│── ACK ─────→│
│── ClientHello →│ ② TLS 握手(1~2 RTT)
│←─ ServerHello ─│
合计约 2~3 RTT 才能发出第一个 HTTP 请求
HTTP/3(QUIC,握手与 TLS 合并,支持 0-RTT)
客户端 服务端
│─ Initial(CHLO+密钥) →│ ① 1-RTT 即建立连接并加密
│─ 0-RTT 数据直接发出 →│ ② 复用会话时首个请求可 0-RTT 发送QUIC 的三大卖点:① 建连快(0-RTT)② 无 TCP 队头阻塞(流独立)③ 连接迁移(Connection ID)。代价是 UDP 上的可靠传输、拥塞控制、TLS 全部要 QUIC 自己实现,内核态 → 用户态(如 Google quiche、Cloudflare quiche / mvfst)带来额外开销,且部分老旧网络/中间设备对 UDP 不友好。
点破「HTTP/3 解决的是 TCP 的队头阻塞,而 HTTP/2 只解决应用层队头阻塞」这一句,是这题最能拉开差距的回答;再补「QUIC 让 HTTP 的语义不变、只换了传输层,上层 API 几乎无感」。
相关:TCP 字节流与 UDP 报文模型的差异(也是粘包产生的根因),见 03-Resources/cpp/C++ 网络编程。
常见的 HTTP 状态码有哪些?
HTTP 状态码由三位数字组成,第一位代表类别,共五类:
1xx 信息响应:请求已收到,继续处理
2xx 成功:请求被成功接收、理解、处理
3xx 重定向:需要进一步操作才能完成请求
4xx 客户端错误:请求有语法错误或无法完成(错在客户端)
5xx 服务端错误:服务器处理请求失败(错在服务端)常见状态码速查:
| 状态码 | 含义 | 一句话记忆 |
|---|---|---|
| 200 OK | 请求成功 | 默认成功 |
| 201 Created | 已创建(POST 新建资源) | 新建成功 |
| 204 No Content | 成功但无响应体 | 成功、无 body |
| 206 Partial Content | 部分内容(断点续传/分片) | Range 请求 |
| 301 Moved Permanently | 永久重定向 | 永久搬家 |
| 302 Found | 临时重定向 | 临时借用 |
| 304 Not Modified | 未修改(走缓存) | 用缓存 |
| 400 Bad Request | 请求语法错误 | 请求有毛病 |
| 401 Unauthorized | 未认证(需登录) | 没登录 |
| 403 Forbidden | 已认证但无权限 | 没权限 |
| 404 Not Found | 资源不存在 | 找不到 |
| 405 Method Not Allowed | 方法不允许 | 方法不对 |
| 429 Too Many Requests | 请求过于频繁(限流) | 限流 |
| 500 Internal Server Error | 服务器内部错误 | 服务器崩了 |
| 502 Bad Gateway | 网关错误(上游无效响应) | 网关拿不到好响应 |
| 503 Service Unavailable | 服务不可用(过载/维护) | 服务挂了 |
| 504 Gateway Timeout | 网关超时(上游没及时响应) | 上游超时 |
高频追问是**「301 和 302 区别」「502 和 504 区别」:301 是永久重定向(浏览器会缓存新地址),302 是临时**;502 是上游返回了无效响应,504 是上游超时未响应。再补「304 与缓存协商(ETag/Last-Modified)」能体现对缓存机制的理解。
别把 401 和 403 搞混:401 是「没登录/没认证」,403 是「登录了但没权限」——面试常拿这俩考基本功。
HTTP 请求包含哪些内容,请求头和请求体有哪些类型?
一个 HTTP 请求报文由四部分组成:请求行 → 请求头 → 空行 → 请求体。请求行包含「方法 + 目标路径 + 协议版本」,空行(\r\n\r\n)是头部与体的分界。
POST /api/user HTTP/1.1 ← 请求行:方法 路径 版本
Host: www.example.com ← 请求头(若干行 key: value)
Content-Type: application/json
Content-Length: 27
{"name": "alice", "age": 20} ← 请求体(可有可无)请求头按作用可分为几类:
| 类型 | 说明 | 常见字段 |
|---|---|---|
| 通用头 | 请求/响应都可用 | Cache-Control、Connection、Date |
| 请求头 | 描述请求/客户端 | Host、User-Agent、Accept、Accept-Encoding、Authorization、Cookie、Referer |
| 实体头 | 描述 body 本身 | Content-Type、Content-Length、Content-Encoding、Last-Modified |
| 自定义/扩展头 | 应用自定义 | X-Request-Id、X-Forwarded-For 等 |
请求体类型由 Content-Type 决定,常见几种:
| Content-Type | 用途 | 特点 |
|---|---|---|
application/x-www-form-urlencoded | 普通表单提交 | key=value&key2=value2,URL 编码 |
multipart/form-data | 文件上传 + 表单混合 | 用 boundary 分隔多段,可传二进制 |
application/json | JSON 数据 | 现代 API 主流,结构化强 |
text/plain | 纯文本 | 简单原始文本 |
application/octet-stream | 二进制流 | 任意二进制文件 |
关键区分 Content-Length(定长 body)与 Transfer-Encoding: chunked(分块传输):前者靠长度字段切 body,后者靠每块的十六进制长度 + 结束块 0\r\n\r\n 切分——这正是「服务端如何知道 body 读多少」的答案。
请求体的长度**不能只靠「读到连接关闭」**判断:HTTP/1.1 长连接下必须依赖 Content-Length 或 chunked,否则会与下一条请求粘连。
相关:Content-Length 切 body 就是应用层协议解决 TCP 粘包/半包的具体实践,见 03-Resources/cpp/C++ 网络编程;服务端读请求的时机由 IO 就绪事件驱动,见 03-Resources/cpp/C++ 网络编程。
HTTP 和 HTTPS 有什么区别?
HTTPS = HTTP over TLS/SSL:在 HTTP 与 TCP 之间插入一层 TLS 加密,对报文做**机密性(加密)、完整性(防篡改)、身份认证(防冒充)**三件事。HTTP 则是明文传输。
核心区别对比:
| 维度 | HTTP | HTTPS |
|---|---|---|
| 加密 | 明文,可被嗅探/篡改 | TLS 加密(对称 + 非对称 + 哈希) |
| 端口 | 80 | 443 |
| 证书 | 无需 | 需 CA 签发证书(身份认证) |
| 连接层 | HTTP 直接跑在 TCP 上 | HTTP → TLS → TCP |
| 性能 | 无加解密开销 | 握手 + 加解密有额外 CPU/时延开销 |
| 安全性 | 无 | 防窃听、防篡改、防中间人 |
TLS 握手(ECDHE 简化)时序:
客户端 服务端
│──── ClientHello ────────────→│ ① 客户端给出支持的加密套件/随机数
│←── ServerHello + 证书 ──────│ ② 服务端选套件、发证书与随机数
│←── ServerKeyExchange/Finished│
│── 校验证书 + 生成预主密钥 ───→│ ③ 双方算出会话密钥(对称)
│──── Finished(加密生效)────→│
│⇄═════ 之后的 HTTP 报文全部对称加密传输 ═════⇄HTTPS 用混合加密:非对称加密(RSA/ECDHE)只用来安全协商出会话密钥,之后的海量数据用对称加密(AES),兼顾安全与性能;再配合 HMAC/哈希保证完整性。证书链由 CA 逐级签发,防止中间人伪造。
能答出「对称加密保性能 + 非对称加密保安全 + 证书保身份」这套组合拳,并说出「HTTPS 只保证传输安全,不保证服务器本身可信、也不能防端点被攻破」,就比只背「HTTPS 就是加密版 HTTP」高一个层次。
HTTPS ≠ 绝对安全:它防的是传输途中的窃听/篡改,防不了应用层漏洞(SQL 注入、越权)与客户端被恶意软件控制。
相关:HTTPS 仍基于 TCP 的可靠字节流,连接与粘包机制见 03-Resources/cpp/C++ 网络编程。
HTTP 中 GET 和 POST 的区别是什么?
两者都是 HTTP 请求方法,语义上:GET 用于获取资源(只读、幂等、可缓存),POST 用于提交/创建数据(通常改变服务端状态、非幂等)。这是最本质的区别,其余差异多由此派生。
核心区别对比:
| 维度 | GET | POST |
|---|---|---|
| 语义 | 获取资源 | 提交/创建数据 |
| 参数位置 | URL 查询串 ?a=1&b=2 | 请求体(body) |
| 幂等性 | 幂等(多次调用结果相同) | 非幂等(多次提交可能重复创建) |
| 缓存 | 可被缓存、可收藏为书签 | 一般不缓存 |
| 安全性(可见性) | 参数暴露在 URL(会被浏览器历史/日志记录) | 参数在 body,相对不易泄漏 |
| 长度限制 | URL 长度受限(浏览器/服务器) | body 长度基本无上限 |
| 编码 | 仅 URL 编码 | 支持多种 Content-Type |
GET /api/user?id=123&name=alice HTTP/1.1 ← 参数在 URL,明文可见
Host: www.example.com
POST /api/user HTTP/1.1 ← 参数在 body
Host: www.example.com
Content-Type: application/json
Content-Length: 27
{"id": 123, "name": "alice"}这题的「高分答法」是先讲语义与幂等(GET 幂等可缓存、POST 非幂等改状态),再讲参数位置与可见性(GET 在 URL、POST 在 body),最后补「RESTful 规范建议:GET 不应有副作用,创建/更新用 POST/PUT」——体现的是设计思维而非死记。
「GET 不安全、POST 安全」是常见误区:两者本质都不安全,POST 只是参数不进 URL、不易被日志/历史直接看到;真要安全必须上 HTTPS,否则 body 一样明文传输。
相关:POST 的 body 靠 Content-Length/chunked 定界,见 03-Resources/cpp/C++ 网络编程;body 切分底层是 TCP 粘包问题,见 03-Resources/cpp/C++ 网络编程。
WebSocket 与 HTTP 有什么区别?
WebSocket(RFC 6455)是在 HTTP 之上通过 Upgrade 握手升级得到的全双工、长连接协议,用于服务端主动推送与双向实时通信。HTTP 则是请求-响应、一问一答的无状态协议(服务端不能主动推)。
核心区别对比:
| 维度 | HTTP | WebSocket |
|---|---|---|
| 通信方向 | 单向请求-响应(服务端不能主动发) | 全双工,双方随时互发 |
| 连接生命周期 | 短连接(1.1 可复用但每次请求-响应) | 一次握手后长连接保持 |
| 消息格式 | 请求行/头/体(文本) | 二进制帧(opcode + payload) |
| 每次通信开销 | 每次携带完整头部 | 帧头极小(2~14 字节),无重复头 |
| 应用场景 | 静态页面、普通 API | 聊天、游戏、行情推送、实时协作 |
| 状态 | 无状态 | 有连接状态 |
WebSocket 握手(本质是一次 HTTP Upgrade):
客户端 服务端
│── GET /chat HTTP/1.1 ──────────────→│
│ Host: example.com │
│ Upgrade: websocket │ ① 客户端申请升级
│ Connection: Upgrade │
│ Sec-WebSocket-Key: xxx(base64) │
│←── HTTP/1.1 101 Switching Protocols ─│ ② 服务端同意升级
│ Sec-WebSocket-Accept: yyy │
│⇄══════ 之后切换为 WebSocket 帧双向通信 ══════⇄与「HTTP 长轮询 / 轮询」对比:长轮询靠客户端反复请求模拟推送,每次都有 HTTP 头部开销、且存在响应时延;WebSocket 升级后帧头极小、双向即时,是真正为实时设计的协议。
主动提「心跳保活(Ping/Pong 帧)」和「断开重连 + 消息补发」这两个工程点,能证明你不只会背协议,还处理过 WebSocket 长连接的可靠性问题。
相关:WebSocket 长连接的服务端通常用 epoll/Reactor 高效承载海量连接,见 03-Resources/cpp/C++ 网络编程 与 03-Resources/cpp/C++ 网络编程;帧边界同样是应用层协议设计,见 03-Resources/cpp/C++ 网络编程。
服务端是如何解析 HTTP 请求的数据?
服务端解析 HTTP 请求的本质是:在 TCP 字节流上,用「累积缓冲区 + 状态机」按 HTTP 文法把一段连续字节切成「请求行 / 头部 / body」并交给业务。因为 TCP 是字节流、无消息边界,必须处理半包(数据没到齐)与粘包(多条请求连在一起)。
整体流程:
accept 新连接 → 注册到 epoll 监听可读
↓ epoll 通知可读
read 数据追加进「接收缓冲区」
↓
在缓冲区中查找 "\r\n\r\n" 定界头部
↓(未找到 → 半包,继续等下一次 read)
解析请求行:方法、路径、版本
解析请求头:逐个 "Key: Value" 行
↓
按 Content-Length 或 Transfer-Encoding: chunked 确定 body 长度
↓(body 没读满 → 继续缓存等待)
取出完整 body → 交给业务处理(Router/Handler)
↓
按 Keep-Alive 决定是否继续在同一连接解析下一条请求简化 C++ 解析骨架(状态机思路):
#include <string>
#include <string_view>
// 简化示意:从累积缓冲区中切出一条完整 HTTP 请求
// 返回 true 表示已切出一条完整请求并填入 req;否则数据未到齐(半包)
bool parse_request(std::string& buf, std::string& req) {
auto header_end = buf.find("\r\n\r\n"); // 头部与 body 的分界
if (header_end == std::string::npos) {
return false; // 头部还没收全,等下一次 read
}
// 解析头部中的 Content-Length
size_t content_length = 0;
std::string_view headers(buf.data(), header_end);
if (auto pos = headers.find("Content-Length: "); pos != std::string_view::npos) {
content_length = std::stoul(std::string(
headers.substr(pos + 16))); // 取出长度值
}
size_t total = header_end + 4 + content_length;
if (buf.size() < total) {
return false; // body 还没到齐(半包)
}
req = buf.substr(0, total); // 切出完整请求
buf.erase(0, total); // 剩余字节留给下一条(粘包)
return true;
}
生产级解析用状态机而非简单的 find:按「读请求行 → 读头部 → 读 body」逐步推进,任何一步数据不足就挂起状态、等下次可读继续,从而正确处理半包/粘包。成熟实现可参考 http-parser(Node.js 用)或 llhttp 的自动机。
解析器还要防两类攻击:① 超大头部/超长 body 耗尽内存(限制 max_header_size、max_body_size);② 慢速攻击(Slowloris)——恶意客户端一小段一小段地发、占住连接不释放,需设置读超时。
把「epoll 通知可读 → 累积缓冲区 → 状态机切包 → 处理半包粘包」这条链路串起来讲,并主动提到「用 \r\n\r\n 定界头部 + Content-Length/chunked 定 body」,基本就覆盖了这题的全部考察点。
相关:这套「累积缓冲区 + 按协议切包」正是解决 TCP 粘包/拆包的通用方法,见 03-Resources/cpp/C++ 网络编程;读事件由 epoll 驱动,见 03-Resources/cpp/C++ 网络编程。