网络:TCP、HTTP、gRPC 与服务发现

2022-02-11T10:00:00+08:00 | 68分钟阅读 | 更新于 2022-02-11T10:00:00+08:00

@

学习目标

能力目标

完成本章学习后,你将能够:

  1. 理解 TCP 与 HTTP 的层次关系,清晰区分传输层与应用层的职责边界,解释 HTTP 为什么基于 TCP 而非直接使用 UDP。
  2. 画出 TCP 报文头部结构,说明三次握手与四次挥手每一步的含义,并能解释"为什么握手是三次不是两次"“为什么挥手是四次”。
  3. 掌握 TCP 可靠传输的核心机制,包括滑动窗口、Nagle 算法、延迟确认与拥塞控制四件套(慢启动、拥塞避免、快重传、快恢复)。
  4. 梳理 HTTP/1.0 到 HTTP/3 的演进脉络,对比 HTTP/JSON 与 gRPC/Protobuf 的适用场景,能在项目中做出合理选型。
  5. 搭建基于 ETCD 的服务注册与发现系统,理解 Raft 共识、TTL 租约、Watch 机制,并掌握一致性哈希在负载均衡中的应用。
  6. 理解 IO 多路复用(select/poll/epoll)的本质,讲清事件驱动模型,并能解释 Go netpoll 如何基于 epoll/kqueue 支撑百万级 goroutine 并发连接。
  7. 掌握 TCP 异常场景与排查:SYN Flood 攻击原理与 SYN Cookie 防御、服务器大量 CLOSE_WAIT 的根因(被动关闭方未 close)与资源泄漏排查思路。

前置知识

  • 了解 OSI 七层模型与 TCP/IP 四层模型的基本概念
  • 能用 Go 语言编写简单的 HTTP 服务(net/http 包基本用法)
  • 了解进程间通信的基本概念(管道、套接字等)

动手做三件事

  1. 用 Go 的 net 包写一个 TCP echo 服务,用 Wireshark 抓包观察三次握手与四次挥手全过程。
  2. 分别用 net/httpgoogle.golang.org/grpc 实现同一个"用户查询"接口,用 benchstat 对比两者的 QPS 与延迟。
  3. go.etcd.io/etcd/client/v3 注册一个服务,再写一个消费者 Watch 服务列表变化并打印日志。

一、TCP vs HTTP:管道与货物

1.1 用生活类比先建立直觉

想象一个城市的自来水系统。TCP 是输水管道——它负责把水从水厂可靠地送到你家,保证水不丢、不乱序、不溢出。管道本身不关心水里装的是饮用水还是灌溉水,它只管"可靠输送"。HTTP 则是管道里传的货物格式——比如瓶装水、桶装水、水杯,它定义了水的包装方式、标签内容和投递规则。

再打个电话的比方:TCP 是电话线路(保证你说的话对方能听到、按顺序听到),HTTP 是你们对话中使用的语言和语法(比如"你好,请问…“这种约定俗成的格式)。

graph TB
    subgraph 应用层
        H[HTTP
定义请求/响应格式] G[gRPC
定义RPC调用格式] end subgraph 传输层 T[TCP
可靠传输管道] U[UDP
不可靠传输管道] end H -->|基于| T G -->|基于| T

桥接: 从"管道 vs 货物"到"TCP vs HTTP”——TCP 提供的是面向连接、可靠、有序的字节流传输服务(管道),HTTP 则是在这条管道上定义了请求-响应的文本协议(货物格式)。一个管道上可以跑多种货物(HTTP、gRPC、自定义协议都可以基于 TCP)。

1.2 工程要点

TCP(Transmission Control Protocol)工作在传输层,核心特征是:面向连接、可靠传输、有序到达、流量控制与拥塞控制。HTTP(HyperText Transfer Protocol)工作在应用层,基于 TCP 实现,核心特征是:请求-响应模型、无状态、文本协议

TCP 报文头部是理解所有 TCP 机制的基础,下图展示了它的完整结构:

graph LR
    A[源端口
16位] --> B[目的端口
16位] B --> C[序号
32位] C --> D[确认号
32位] D --> E[数据偏移
4位] E --> F[保留
6位] F --> G[标志位
URG/ACK/PSH
RST/SYN/FIN] G --> H[窗口大小
16位] H --> I[校验和
16位] I --> J[紧急指针
16位] J --> K[选项
0到40字节]

关键字段说明:

字段位数作用
源端口 / 目的端口各 16 位标识发送方和接收方的应用进程
序号(seq)32 位标识本报文段数据第一个字节的序号,保证有序性
确认号(ack)32 位期望收到对方下一个报文段的第一个字节序号
标志位6 位SYN 建立连接、ACK 确认、FIN 关闭连接、RST 重置连接、PSH 推送、URG 紧急
窗口大小16 位接收窗口大小,用于流量控制(告诉对方"我还能接收多少数据")
校验和16 位检验头部和数据在传输中是否出错

⚠️ 新手必踩的坑: 很多人混淆 TCP 的 seq(序号)和 ack(确认号)。记住:seq 是"我这次发送的数据从第几个字节开始",ack 是"我期待你下次从第几个字节开始发"。比如收到 seq=100 长度 50 的数据,回复 ack=150(100+50),表示"前 149 个字节我都收到了,你从 150 开始发"。


二、TCP 连接:三次握手与四次挥手

2.1 用生活类比先建立直觉

三次握手就像打电话:

A:“喂,能听到我说话吗?"(SYN)

B:“能听到。你能听到我说话吗?"(SYN+ACK)

A:“能听到,那我们开始聊吧。"(ACK)

为什么是三次不是两次?如果只有两次(A 问,B 答),B 无法确认 A 是否收到了自己的回答——万一 A 的听力有问题呢?第三次确认保证双方都知道"对方能听到自己”。

四次挥手就像结束通话:

A:“我说完了,挂了啊。"(FIN)

B:“好的,我知道你说完了。"(ACK)

B:“我也说完了。"(FIN)

A:“好的,再见。"(ACK)

为什么是四次?因为 TCP 是全双工的(双方都能同时发数据)。A 说完了不代表 B 也说完了,所以要分别关闭各自的发送通道——这就是"半关闭”。

桥接: 电话类比映射到 TCP 连接管理——三次握手建立全双工通道的"双向可达"确认,四次挥手则分别关闭两个方向的通道。TIME_WAIT 就像挂电话后还等两秒,防止对方最后一句话没听到。

2.2 工程要点

三次握手

sequenceDiagram
    participant C as 客户端
    participant S as 服务端
    Note over C: CLOSED
    Note over S: LISTEN
    C->>S: SYN, seq=x
    Note over C: SYN_SENT
    S->>C: SYN+ACK, seq=y, ack=x+1
    Note over S: SYN_RCVD
    C->>S: ACK, seq=x+1, ack=y+1
    Note over C: ESTABLISHED
    Note over S: ESTABLISHED

为什么是三次不是两次?核心原因是防止历史连接(已失效的 SYN)被误建。如果只有两次握手,服务端收到一个延迟到达的旧 SYN 也会直接建立连接,浪费资源。三次握手让客户端有机会在第三次 ACK 时检查收到的 SYN+ACK 是否合法,如果发现是历史连接,可以发 RST 中止。

四次挥手

sequenceDiagram
    participant A as 主动关闭方
    participant P as 被动关闭方
    Note over A,P: ESTABLISHED
    A->>P: FIN, seq=u
    Note over A: FIN_WAIT_1
    P->>A: ACK, ack=u+1
    Note over P: CLOSE_WAIT
    Note over A: FIN_WAIT_2
    P->>A: FIN, seq=v
    Note over P: LAST_ACK
    A->>P: ACK, ack=v+1
    Note over A: TIME_WAIT 等待2MSL
    Note over P: CLOSED
    Note over A: CLOSED

TIME_WAIT 的作用(等待 2MSL):

  1. 保证最后一个 ACK 能到达:如果主动关闭方最后的 ACK 丢失,被动关闭方会超时重发 FIN,主动关闭方还能收到并重发 ACK。2MSL = 一个 MSL 发送 + 一个 MSL 接收。
  2. 防止旧连接的报文干扰新连接:2MSL 后,旧连接的所有报文都会从网络中消失。

⚠️ 新手必踩的坑: TIME_WAIT 状态出现在主动关闭方。在高并发短连接场景下,服务端主动断连会产生大量 TIME_WAIT,占满端口。解决方案:使用长连接(HTTP/1.1 Keep-Alive)、调整 net.ipv4.tcp_tw_reuse 或让客户端主动关闭。

下面是一个可运行的 Go TCP echo 服务,你可以用 Wireshark 抓包观察握手和挥手的完整过程:

package main

import (
    "bufio"
    "fmt"
    "net"
)

func main() {
    // 步骤1:监听TCP端口(服务端进入LISTEN状态)
    ln, err := net.Listen("tcp", ":8080")
    if err != nil {
        fmt.Println("监听失败:", err)
        return
    }
    defer ln.Close()

    fmt.Println("TCP echo 服务启动,监听 :8080")

    for {
        // 步骤2:接受连接(完成三次握手)
        conn, err := ln.Accept()
        if err != nil {
            fmt.Println("接受连接失败:", err)
            continue
        }
        go handleConn(conn)
    }
}

func handleConn(conn net.Conn) {
    // 步骤3:函数结束时关闭连接(触发四次挥手)
    defer conn.Close()

    reader := bufio.NewReader(conn)
    for {
        // 步骤4:读取客户端数据
        line, err := reader.ReadString('\n')
        if err != nil {
            return
        }
        // 步骤5:原样回写(echo)
        conn.Write([]byte("echo: " + line))
    }
}

三、TCP 可靠传输:滑动窗口与拥塞控制

3.1 用生活类比先建立直觉

滑动窗口就像快递传送带: 你不用等前一个包裹签收了再发下一个——传送带上可以同时有多个包裹(窗口大小),只要签收了前面的,传送带就往后移,新的包裹就可以放上去。窗口越大,传送带上同时跑的包裹越多,吞吐量越高。但窗口大小受两个因素限制:接收方说"我仓库还能放 10 个”(接收窗口 rwnd),以及网络拥堵程度说"这条路最多再放 5 个”(拥塞窗口 cwnd),实际窗口取两者较小值。

拥塞控制就像开车: 刚上路先慢速试探(慢启动,指数增长),觉得路还通畅就继续加速到巡航速度(拥塞避免,线性增长),一旦看到前面堵了就赶紧刹车(快重传+快恢复),降到安全速度后重新慢慢加速。

graph LR
    subgraph 发送端窗口
        W1[已确认
窗口左边界] W2[已发送
未确认] W3[允许发送
尚未发送] W4[窗口外
暂不可发] end W1 --> W2 W2 --> W3 W3 --> W4 W2 -.->|收到ACK
窗口右移| W1

桥接: 传送带类比映射到 TCP 滑动窗口——窗口左边界随 ACK 推进,窗口大小 = min(rwnd, cwnd)。拥塞控制则是动态调节 cwnd 的策略,防止网络被压垮。

3.2 工程要点

可靠传输机制一览

机制作用原理
滑动窗口流量控制接收方通过 ACK 报文中的窗口大小告诉发送方"我还能接收多少数据”
Nagle 算法小包合并将多个小数据包合并为一个大包发送,减少网络开销
延迟确认减少 ACK 数量接收方不立即发 ACK,等一会如果有数据要发就捎带确认
慢启动探测网络容量cwnd 从 1 开始指数增长(1-2-4-8…),到达阈值后进入线性增长
拥塞避免避免压垮网络cwnd 线性增长(每 RTT +1),直到出现丢包
快重传快速发现丢包收到 3 个重复 ACK 立即重传丢失段,不等超时
快恢复快速恢复发送速率降 cwnd 到一半(不是从 1 开始),进入拥塞避免

发送窗口 = min(拥塞窗口 cwnd, 接收窗口 rwnd)

拥塞控制四件套的状态转换:

graph LR
    A[慢启动
cwnd指数增长] -->|cwnd达到阈值| B[拥塞避免
cwnd线性增长] B -->|超时丢包| A B -->|3个重复ACK| C[快重传] C --> D[快恢复
cwnd减半] D --> B A -->|3个重复ACK| C

在 Go 中可以控制 Nagle 算法和延迟确认:

package main

import (
    "fmt"
    "net"
    "syscall"
)

func main() {
    // 步骤1:建立TCP连接
    conn, err := net.Dial("tcp", "localhost:8080")
    if err != nil {
        fmt.Println("连接失败:", err)
        return
    }
    defer conn.Close()

    // 步骤2:获取底层TCP连接
    tcpConn, ok := conn.(*net.TCPConn)
    if !ok {
        fmt.Println("不是TCP连接")
        return
    }

    // 步骤3:禁用Nagle算法(低延迟场景:游戏、实时交互)
    // SetNoDelay(true)  = 禁用Nagle,小包立即发送
    // SetNoDelay(false) = 启用Nagle,小包合并发送(默认)
    tcpConn.SetNoDelay(true)

    // 步骤4:获取原始文件描述符以设置更多选项
    rawConn, _ := tcpConn.SyscallConn()
    rawConn.Control(func(fd uintptr) {
        // 步骤5:启用TCP_NODELAY已在上面完成
        // 这里可以设置TCP_QUICKACK禁用延迟确认(Linux特有)
        syscall.SetsockoptInt(int(fd), syscall.IPPROTO_TCP, 7, 1)
    })

    fmt.Println("连接已建立,Nagle已禁用")
}

⚠️ 新手必踩的坑: Nagle 算法和延迟确认一起使用会导致"延迟叠加”——发送方等小包合并(Nagle),接收方等捎带确认(延迟 ACK),结果双方都在等,造成最高 200ms 的延迟。实时系统建议禁用 Nagle(SetNoDelay(true))。


四、HTTP 演进与 gRPC

4.1 用生活类比先建立直觉

HTTP 版本演进就像道路升级:

  • HTTP/1.0 是一条单车道土路:每运一趟货都要重新修路(新建 TCP 连接),运完就拆路(断开连接),效率极低。
  • HTTP/1.1 是一条多车道公路:路修好后保持开放(长连接 Keep-Alive),可以连续运多趟货。但同一车道只能排队走,前面的大卡车慢了后面全堵(队头阻塞)。
  • HTTP/2 是一条高架快速路:多个车道并行(多路复用),货物标签压缩了(HPACK 头部压缩),收费站还能提前通知你货到了(服务器推送)。但底层还是 TCP,TCP 层丢了包所有车道都得等。
  • HTTP/3 是直升机运输:不走公路了,走空中(基于 UDP 的 QUIC),一条航线堵了不影响其他航线,彻底消除队头阻塞。

HTTP vs gRPC 就像写信 vs 发电报:

HTTP/JSON 是写信——人能读懂,格式灵活,但信封大、内容冗余。gRPC/Protobuf 是发电报——先编码成紧凑的二进制(Protobuf),速度快、体积小,但需要双方都有"密码本”(.proto 文件)才能解码。

桥接: 道路类比映射到 HTTP 版本的连接复用与多路复用演进;写信 vs 电报类比映射到 HTTP/JSON(人可读、通用)与 gRPC/Protobuf(二进制、强类型、高性能)的选型取舍。

4.2 工程要点

graph LR
    A[HTTP/1.0
短连接
每次请求新建TCP] --> B[HTTP/1.1
长连接Keep-Alive
管道化] B --> C[HTTP/2
多路复用
HPACK头部压缩
服务器推送] C --> D[HTTP/3
QUIC基于UDP
消除队头阻塞]

HTTP 与 gRPC 对比:

对比项HTTP/JSONgRPC/Protobuf
传输协议HTTP/1.1 或 HTTP/2基于 HTTP/2
编码格式文本(JSON),人可读二进制(Protobuf),需 .proto 定义
性能一般(序列化开销大)高(二进制编码,体积小 3-10 倍)
类型安全无(运行时解析)强类型(编译期检查)
流式传输不支持(或用 WebSocket)原生支持双向流
浏览器支持原生支持需要 gRPC-Web 代理
适用场景对外 API、前后端交互微服务内部通信、高性能场景
代码生成手动编写自动生成多语言客户端

⚠️ 新手必踩的坑: gRPC 虽然性能高,但浏览器不能直接调用 gRPC 接口(因为浏览器对 HTTP/2 的 Trailer 头支持有限)。如果前端需要调用,要么用 gRPC-Web(需要 Envoy 代理),要么在网关层把 gRPC 转成 HTTP/JSON(grpc-gateway)。


五、服务发现:注册中心与 ETCD

5.1 用生活类比先建立直觉

配置文件 vs 注册中心就像纸质通讯录 vs 在线通讯录:

纸质通讯录(配置文件):你把朋友的电话抄在本子上,每次打电话前翻本子查。问题:朋友换了号码,你的本子不会自动更新,你得手动改,改完还得通知所有抄了同一本子的人。改一次要重启服务才能生效。

在线通讯录(注册中心):所有人把自己的号码实时更新到云端通讯录(注册),你打电话前实时查云端(发现),朋友换号了云端自动更新,你立刻就能看到。

graph TB
    P[服务提供者
启动时注册] -->|Put key带TTL租约| E[ETCD集群
Raft共识] E -->|Watch推送变更| C[服务消费者] C -->|Get服务列表| E P -.->|KeepAlive心跳续约| E P -.->|宕机时TTL过期| E E -.->|Delete过期key
通知消费者| C

桥接: 在线通讯录类比映射到 ETCD 服务发现——提供者注册时带 TTL 租约(号码的有效期),定期续约(证明自己还活着),宕机后 TTL 到期自动删除(号码自动失效),消费者通过 Watch 实时感知变化(通讯录实时更新)。

5.2 工程要点

注册中心 vs 配置文件

对比项配置文件注册中心
变更感知静态,需重启生效动态,实时感知
扩缩容手动改配置 + 重启自动注册/注销
健康检查自动剔除不健康实例
适用规模小规模、固定部署大规模、动态扩缩容

ETCD 服务注册代码

package main

import (
    "context"
    "fmt"
    "log"
    "time"

    clientv3 "go.etcd.io/etcd/client/v3"
)

func main() {
    // 步骤1:创建ETCD客户端
    cli, err := clientv3.New(clientv3.Config{
        Endpoints:   []string{"localhost:2379"},
        DialTimeout: 5 * time.Second,
    })
    if err != nil {
        log.Fatal(err)
    }
    defer cli.Close()

    // 步骤2:创建租约(TTL=10秒,服务宕机10秒后自动注销)
    resp, err := cli.Grant(context.TODO(), 10)
    if err != nil {
        log.Fatal(err)
    }
    leaseID := resp.ID

    // 步骤3:注册服务(key=服务名/IP:端口,value=元数据JSON)
    key := "/services/user-service/192.168.1.100:8080"
    val := `{"name":"user-service","addr":"192.168.1.100:8080","weight":1}`
    _, err = cli.Put(context.TODO(), key, val, clientv3.WithLease(leaseID))
    if err != nil {
        log.Fatal(err)
    }
    fmt.Println("服务注册成功:", key)

    // 步骤4:自动续约(保持心跳,证明服务还活着)
    ch, err := cli.KeepAlive(context.TODO(), leaseID)
    if err != nil {
        log.Fatal(err)
    }
    go func() {
        for ka := range ch {
            fmt.Println("收到续约响应, TTL:", ka.TTL)
        }
        fmt.Println("续约通道关闭,服务可能已下线")
    }()

    // 步骤5:模拟服务运行
    select {}
}

ETCD 服务发现代码(Watch 监听)

package main

import (
    "context"
    "fmt"
    "log"
    "time"

    clientv3 "go.etcd.io/etcd/client/v3"
)

func main() {
    // 步骤1:创建ETCD客户端
    cli, err := clientv3.New(clientv3.Config{
        Endpoints:   []string{"localhost:2379"},
        DialTimeout: 5 * time.Second,
    })
    if err != nil {
        log.Fatal(err)
    }
    defer cli.Close()

    // 步骤2:首次获取所有服务实例(全量拉取)
    resp, err := cli.Get(context.TODO(), "/services/user-service/", clientv3.WithPrefix())
    if err != nil {
        log.Fatal(err)
    }
    fmt.Printf("当前共 %d 个实例:\n", len(resp.Kvs))
    for _, ev := range resp.Kvs {
        fmt.Printf("  %s -> %s\n", ev.Key, ev.Value)
    }

    // 步骤3:Watch监听服务变化(增量推送)
    fmt.Println("\n开始监听服务变化...")
    rch := cli.Watch(context.TODO(), "/services/user-service/", clientv3.WithPrefix())
    for wresp := range rch {
        for _, ev := range wresp.Events {
            switch ev.Type {
            case clientv3.EventTypePut:
                fmt.Printf("[上线] %s -> %s\n", ev.Kv.Key, ev.Kv.Value)
            case clientv3.EventTypeDelete:
                fmt.Printf("[下线] %s\n", ev.Kv.Key)
            }
        }
    }
}

ETCD vs Zookeeper vs Consul

对比项ETCDZookeeperConsul
共识算法RaftZABRaft
数据模型扁平 KV树形层级KV + 服务模型
服务发现需自行实现需自行实现内置(含健康检查)
健康检查TTL 心跳临时节点 + SessionHTTP/TCP/Script 多种探针
多数据中心支持(需额外配置)不支持原生支持
Watch 机制基于事件推送基于轮询通知长轮询
语言GoJavaGo
复杂度轻量重(JVM 依赖)中等
典型使用方KubernetesKafka/HBaseHashiCorp 生态

⚠️ 新手必踩的坑: ETCD 的 TTL 不要设太短。如果 TTL=3 秒,而你的网络偶尔抖动一下导致续约延迟超过 3 秒,服务就会被误判下线。建议 TTL 设为 10-30 秒,续约间隔为 TTL 的 1/3。


六、负载均衡与一致性哈希

6.1 用生活类比先建立直觉

客户端发现 vs 服务端发现就像自己找餐厅 vs 用外卖平台:

客户端发现:你自己打开大众点评查附近有哪些餐厅(查注册中心),然后自己选一家直接走过去(直连服务实例)。好处是没有中间商,效率高;坏处是每个客户端都要实现选店逻辑。

服务端发现:你直接打开外卖平台说"我要点餐"(请求网关),平台自己分配最近的门店(负载均衡器转发)。好处是客户端简单;坏处是多了一层代理,有额外延迟。

一致性哈希就像环形街道上的快递分配:

想象一条环形街道(哈希环),上面有若干个快递站(节点)。每个包裹(数据 key)也映射到环上某个位置,然后顺时针走,遇到的第一个快递站就负责这个包裹。好处是:新增或删除快递站时,只有相邻路段的包裹需要重新分配,其他快递站不受影响。

但如果快递站分布不均匀(都挤在一起),就会导致某个站负责的路段特别长(数据倾斜)。解决方案:给每个真实快递站虚拟出多个"影子站点"(虚拟节点),均匀撒在环上,让包裹分配更均匀。

桥接: 环形街道类比映射到一致性哈希——节点和 key 都映射到 0 到 2^32-1 的环空间上,key 顺时针找到的第一个节点负责处理。虚拟节点解决节点稀疏时的数据倾斜问题。

6.2 工程要点

graph TB
    subgraph 客户端发现模式
        CC[消费者] -->|查询注册中心| RC[注册中心]
        CC -->|直连服务实例| SA[服务A]
        CC -->|直连服务实例| SB[服务B]
    end
    subgraph 服务端发现模式
        CS[消费者] -->|请求| GW[网关/负载均衡器]
        GW -->|转发| SC[服务A]
        GW -->|转发| SD[服务B]
        GW -->|查询注册中心| RS[注册中心]
    end

常见负载均衡算法:

算法原理适用场景
轮询依次分配请求到每个实例实例性能相近
加权轮询按权重比例分配,性能强的多分实例性能不同
最少连接分配给当前连接数最少的实例长连接场景
一致性哈希相同 key 总是路由到同一实例需要会话保持/缓存命中
随机随机选一个实例简单场景,请求量大时趋近均匀

一致性哈希环示意图:

graph TB
    subgraph 一致性哈希环
        N1[节点A
hash位置100] N2[节点B
hash位置200] N3[节点C
hash位置300] K1[key1
hash位置150] K2[key2
hash位置250] K3[key3
hash位置350] end K1 -->|顺时针找到节点B| N2 K2 -->|顺时针找到节点C| N3 K3 -->|顺时针环绕找到节点A| N1

一致性哈希 Go 实现(含虚拟节点):

package main

import (
    "crypto/sha256"
    "encoding/binary"
    "fmt"
    "sort"
    "sync"
)

// ConsistentHash 一致性哈希结构
type ConsistentHash struct {
    mu       sync.RWMutex
    hashRing []uint32          // 哈希环上的所有虚拟节点哈希值(有序)
    virtual  int               // 每个真实节点的虚拟节点数
    nodeMap  map[uint32]string // 虚拟节点哈希值到真实节点名称
}

// 步骤1:创建一致性哈希实例
func NewConsistentHash(virtual int) *ConsistentHash {
    return &ConsistentHash{
        virtual: virtual,
        nodeMap: make(map[uint32]string),
    }
}

// 步骤2:计算哈希值
func hash(key string) uint32 {
    h := sha256.Sum256([]byte(key))
    return binary.BigEndian.Uint32(h[:4])
}

// 步骤3:添加节点(为每个真实节点创建virtual个虚拟节点)
func (c *ConsistentHash) AddNode(node string) {
    c.mu.Lock()
    defer c.mu.Unlock()
    for i := 0; i < c.virtual; i++ {
        vKey := fmt.Sprintf("%s#%d", node, i)
        h := hash(vKey)
        c.hashRing = append(c.hashRing, h)
        c.nodeMap[h] = node
    }
    sort.Slice(c.hashRing, func(i, j int) bool {
        return c.hashRing[i] < c.hashRing[j]
    })
}

// 步骤4:移除节点
func (c *ConsistentHash) RemoveNode(node string) {
    c.mu.Lock()
    defer c.mu.Unlock()
    var newRing []uint32
    for _, h := range c.hashRing {
        if c.nodeMap[h] != node {
            newRing = append(newRing, h)
        } else {
            delete(c.nodeMap, h)
        }
    }
    c.hashRing = newRing
}

// 步骤5:根据key找到负责的节点
func (c *ConsistentHash) GetNode(key string) string {
    c.mu.RLock()
    defer c.mu.RUnlock()
    if len(c.hashRing) == 0 {
        return ""
    }
    h := hash(key)
    // 二分查找第一个大于等于h的虚拟节点
    idx := sort.Search(len(c.hashRing), func(i int) bool {
        return c.hashRing[i] >= h
    })
    // 环绕:如果超过末尾,回到环首
    if idx == len(c.hashRing) {
        idx = 0
    }
    return c.nodeMap[c.hashRing[idx]]
}

func main() {
    ch := NewConsistentHash(150) // 每个节点150个虚拟节点
    ch.AddNode("NodeA")
    ch.AddNode("NodeB")
    ch.AddNode("NodeC")

    // 测试key分布
    keys := []string{"user:1", "user:2", "user:3", "order:1", "order:2"}
    for _, k := range keys {
        fmt.Printf("%s -> %s\n", k, ch.GetNode(k))
    }

    // 模拟节点下线
    ch.RemoveNode("NodeB")
    fmt.Println("\nNodeB 下线后:")
    for _, k := range keys {
        fmt.Printf("%s -> %s\n", k, ch.GetNode(k))
    }
}

⚠️ 新手必踩的坑: 虚拟节点数不是越多越好。虚拟节点越多,环上分布越均匀,但查找效率下降(二分查找的数组变长)。经验值:每个真实节点 150 个虚拟节点,既能保证均匀性,又不影响性能。如果节点少于 10 个,可以提高到 200-300。


七、HTTP 请求方法:GET/POST/PUT/DELETE 的语义

7.1 用生活类比先建立直觉

把 HTTP 请求方法想成餐厅里你对服务员说的不同动作

  • GET = 看菜单:你只问"有什么菜",不改变后厨任何东西,也不会凭空多一道菜。
  • POST = 新点一道菜:你下了一单,后厨多了一道正在做的菜(服务器多了一份资源)。
  • PUT = 整盘换掉:你嫌这道菜不满意,要求"把整盘撤掉,按我给的配方重做一份"(用请求体整体替换原资源)。
  • DELETE = 退掉这道菜:你要求后厨把这道菜撤了(删除资源)。

这里有个关键区分:安全方法(GET 这类,只是读取,不会改动服务器状态)和幂等方法(PUT、DELETE 这类,做一遍和做 N 遍结果一样——反复"退掉同一道菜"和退一次结果相同)。POST 既不幂等也不安全。

桥接: 餐厅动作类比映射到 REST 语义——每个 HTTP 方法对应对"资源"的一种标准操作,客户端和服务端据此达成约定,而不必为每个接口发明一套自定义动词。

7.2 工程要点

RESTful 风格下,方法与其语义、是否安全/幂等的对应关系:

方法语义安全幂等是否带请求体典型用途
GET获取资源否(参数在 URL/Query)查询、详情、列表
POST新建资源提交表单、创建订单、登录
PUT完整替换资源全量更新用户资料
DELETE删除资源通常无删除评论、注销账号
PATCH局部更新资源只改昵称、只改状态

注意:HTTP 方法只是"约定",服务端代码完全可以写错——比如把 GET 接口写成会删库。语义是规范不是强制,遵循它能让接口可预测、可缓存(GET 可被代理/CDN 缓存,POST 默认不会)。

方法到 CRUD 的映射:

graph LR
    G[GET
查] -->|读取资源| R[(Resource)] P[POST
增] -->|新建资源| R U[PUT
改] -->|整体替换| R D[DELETE
删] -->|移除资源| R

下面用 Go 的 net/http 演示每个方法对应的处理函数(同一个路由按方法分派):

package main

import (
    "fmt"
    "io"
    "net/http"
)

func userHandler(w http.ResponseWriter, r *http.Request) {
    // 步骤1:按请求方法分派,一个路径承载不同语义
    switch r.Method {
    case http.MethodGet:
        // 步骤2:GET 只读取,不改动状态,且可缓存
        fmt.Fprintln(w, "GET: 返回用户 1 的资料")
    case http.MethodPost:
        // 步骤3:POST 新建,请求体里有要创建的数据
        body, _ := io.ReadAll(r.Body)
        fmt.Fprintf(w, "POST: 创建了新用户,数据=%s\n", body)
    case http.MethodPut:
        // 步骤4:PUT 整体替换,幂等
        body, _ := io.ReadAll(r.Body)
        fmt.Fprintf(w, "PUT: 用 %s 整体替换了用户 1\n", body)
    case http.MethodDelete:
        // 步骤5:DELETE 删除,幂等
        fmt.Fprintln(w, "DELETE: 已删除用户 1")
    default:
        // 步骤6:不支持的方法返回 405
        http.Error(w, "不支持的方法", http.StatusMethodNotAllowed)
    }
}

func main() {
    http.HandleFunc("/users/1", userHandler)
    http.ListenAndServe(":8080", nil)
}

八、Cookie 与 Session:HTTP 无状态下的身份管理

8.1 用生活类比先建立直觉

HTTP 天生无状态——每次请求对服务器来说都像第一次见你,它不记得你上一秒刚登录过。这就像去健身房:你每次进门,前台都不认识你。

  • Cookie = 你手腕上的手环:手环由健身房发给你、戴在你自己手上,下次进门你主动亮出来,前台一看手环就知道你是谁。存储在你(客户端)身上
  • Session = 健身房柜子里存的东西:你的会员资料、储物内容都存在健身房(服务端)的柜子里,手环上只写了"柜号 9527"。前台通过柜号去柜子里查你的资料。存储在健身房(服务端)

为什么不直接把手环当柜子用?因为手环戴在你手上,你(或别人)可以篡改、偷看;而柜子在健身房里,更安全。所以敏感信息(密码、权限)放 Session(柜子),Cookie(手环)只放一个查柜子的"柜号"——也就是 session_id

桥接: 手环 vs 柜子类比映射到 Cookie vs Session——Cookie 是客户端存储的凭证,Session 是服务端存储的状态,二者通过 session_id 关联,既解决无状态问题又保证敏感数据不落客户端。

8.2 工程要点

Cookie 与 Session 的核心区别:

对比项CookieSession
存储位置客户端(浏览器)服务端(内存/Redis/数据库)
安全性低,用户可见可改高,用户拿不到真实数据
容量上限小(单条约 4KB,总数有限)大(受服务端存储限制)
生命周期可设 Expires/Max-Age服务端设过期时间,靠 Cookie 失效联动
网络开销每次请求自动携带,增大报文仅在 Cookie 里带一个 session_id,开销小
依赖关系依赖 Cookie 携带 session_id(也可用 URL 重写兜底)

⚠️ 新手必踩的坑: Session 不"存在 Cookie 里"。很多人误以为 Session 数据写在 Cookie 中。事实是:Cookie 只存一个 session_id 字符串,真正的用户状态在服务器端;一旦服务端重启且 Session 存在内存里没持久化,所有登录态就丢了。生产环境必须把 Session 存到 Redis 等共享存储,多实例才能共享登录态。

Session 的实现流程(以 Go 为例):

sequenceDiagram
    participant U as 浏览器
    participant S as 服务端
    U->>S: POST /login 提交账号密码
    S->>S: 校验通过,生成session_id
在内存/Redis 存 session 数据 S-->>U: Set-Cookie: session_id=9527 Note over U: 后续请求自动带 Cookie U->>S: GET /profile (Cookie: session_id=9527) S->>S: 用 session_id 查服务端 session S-->>U: 返回用户资料

最小可运行的 Session 实现(内存版,仅演示原理;生产请换 Redis):

package main

import (
    "encoding/uuid"
    "fmt"
    "net/http"
    "sync"
    "time"
)

// 步骤1:用带锁的 map 充当服务端 Session 存储(生产环境换 Redis)
var (
    mu       sync.RWMutex
    sessions = make(map[string]map[string]string)
)

// 步骤2:登录接口——校验后创建 Session,并把 session_id 写入 Cookie
func login(w http.ResponseWriter, r *http.Request) {
    // 真实场景这里要校验账号密码,demo 直接信任
    sid := uuid.NewString() // 生成唯一 session_id(柜号)
    mu.Lock()
    sessions[sid] = map[string]string{"user": "alice", "role": "admin"}
    mu.Unlock()

    // 步骤3:通过 Set-Cookie 把手环发给浏览器(HttpOnly 防 JS 读取,Secure 仅 HTTPS)
    http.SetCookie(w, &http.Cookie{
        Name:     "session_id",
        Value:    sid,
        Path:     "/",
        HttpOnly: true,
        Secure:   false, // 本地演示用 false,生产必须为 true
        MaxAge:   int(30 * time.Minute.Seconds()),
    })
    fmt.Fprintln(w, "登录成功,已发放 session")
}

// 步骤4:受保护接口——从 Cookie 取 session_id,再去服务端查 Session
func profile(w http.ResponseWriter, r *http.Request) {
    c, err := r.Cookie("session_id")
    if err != nil {
        http.Error(w, "未登录", http.StatusUnauthorized)
        return
    }
    mu.RLock()
    data, ok := sessions[c.Value]
    mu.RUnlock()
    if !ok {
        http.Error(w, "会话已过期", http.StatusUnauthorized)
        return
    }
    fmt.Fprintf(w, "你好 %s,角色=%s\n", data["user"], data["role"])
}

func main() {
    http.HandleFunc("/login", login)
    http.HandleFunc("/profile", profile)
    http.ListenAndServe(":8080", nil)
}

九、IO 多路复用:一个服务员如何照看一百桌客人

9.1 用生活类比先建立直觉

select/poll/epoll 回答的是同一个问题:一个线程(或少量线程)如何同时盯住成千上万个连接?

类比餐厅:一个服务员(一个线程)要照看 100 桌客人(100 个连接)。

  • 笨办法(阻塞式):服务员走到 1 号桌,一直站在那等客人点单,客人不点他就不动——后面 99 桌全被饿死。这就是"一个连接一个线程"的阻塞模型,线程开销大、撑不住高并发。
  • select/poll:服务员每隔一会儿挨桌问一遍"你们谁要点单?"(轮询所有 fd)。哪怕只有 1 桌举手,他也得从头问到尾。
  • epoll:服务员手里有个"举手名单"——哪桌举手(就绪)才被记下来,直接去那桌,不用挨个问。

所谓事件驱动,本质就是"不是你主动去问,而是有事件了我通知你,你只处理就绪的连接"。

桥接: IO 多路复用的核心是"少量线程管理海量 fd,只处理就绪的"。Go 的 netpoll 正是建立在 epoll(Linux)/ kqueue(macOS/BSD)/ IOCP(Windows)之上的事件驱动封装,把"多路复用"藏到了 goroutine 背后。

9.2 工程要点

select / poll / epoll 对比:

机制底层结构是否遍历全部 fd最大连接数取就绪复杂度
select位图 fd_set是(每次拷贝+遍历)1024(FD_SETSIZE 限制)O(n)
poll链表 pollfd是(遍历)无硬限制O(n)
epoll红黑树 + 就绪链表否(只返回就绪的)取决于内存O(1) 取就绪 / 注册 O(log n)

epoll 三个核心调用:

  • epoll_create:创建 epoll 实例(内核里的红黑树 + 就绪链表)。
  • epoll_ctl:把要监听的 fd 注册/修改/删除到红黑树,并指定关心的事件(可读/可写)。
  • epoll_wait:阻塞等待,只返回"就绪"的 fd 列表,无需遍历全部。

触发模式(重要考点):

  • 水平触发(LT,默认):只要 fd 缓冲区还有数据没读完,每次 epoll_wait 都会通知你。编程简单,不易漏事件。
  • 边缘触发(ET):只在状态变化时通知一次(从不可读变可读那一刻)。必须一次性把数据读干净(循环读到 EAGAIN),否则会丢事件。ET 性能更高但编程更苛刻。

Go netpoll 原理(重点):

Go 标准库 net 包不允许你直接调 epoll,而是封装成"网络轮询器 netpoll"。关键思想:

  • 写代码时像阻塞式:conn.Read / conn.Write,每连接一个 goroutine(goroutine-per-connection)。
  • 但当 goroutine 在 socket 上读且暂时无数据时,Go 运行时会把该 goroutine 挂起(park),把 fd 注册到 netpoll(epoll),不会阻塞底层的 OS 线程(M)
  • 数据到达时,epoll 触发,运行时把对应 goroutine 重新唤醒并加入调度。
  • 于是少量 OS 线程就能服务海量 goroutine —— 这是 Go “高并发网络” 的核心秘密。
sequenceDiagram
    participant G as goroutine
    participant M as OS线程(M)
    participant NP as netpoll(epoll)
    participant S as Socket
    G->>M: conn.Read()
    M->>NP: 注册fd可读事件, 挂起G
    Note over G: G被park, 不占用M
    S-->>NP: 数据到达(网卡中断)
    NP-->>M: 通知fd就绪
    M->>G: 唤醒G, 重新调度
    G->>S: 读取数据, 继续处理

⚠️ 新手必踩的坑: 很多人以为"用了 Go 就等于有了 epoll 的高并发红利"。netpoll 只解决网络 IO 的阻塞问题;如果你的处理函数里做阻塞的系统调用(同步文件读写、cgo 调用、密集 CPU 计算)且没有让出,仍然会卡住 M,拖垮整体并发。只有网络 IO 才有 goroutine 挂起-唤醒的红利。

下面用一个 goroutine-per-connection 的并发 echo 服务,直观感受 netpoll 带来的"看似阻塞、实则高并发":

package main

import (
    "bufio"
    "fmt"
    "net"
)

func main() {
    // 步骤1:监听端口(底层进入 LISTEN,accept 由 netpoll 异步驱动)
    ln, err := net.Listen("tcp", ":8080")
    if err != nil {
        fmt.Println("监听失败:", err)
        return
    }
    defer ln.Close()

    fmt.Println("并发 echo 服务启动")
    for {
        // 步骤2:Accept 看起来是阻塞调用,实则底层用 netpoll 挂起 G,
        //        不占用 OS 线程,可同时服务大量连接
        conn, err := ln.Accept()
        if err != nil {
            continue
        }
        // 步骤3:每来一个连接就起一个 goroutine,像写阻塞代码一样简单
        go handleConn(conn)
    }
}

func handleConn(conn net.Conn) {
    // 步骤4:conn.Close 必须执行,否则连接泄漏(见下章 CLOSE_WAIT)
    defer conn.Close()

    reader := bufio.NewReader(conn)
    for {
        // 步骤5:ReadString 内部同样由 netpoll 驱动,
        //        读不到数据时 G 被挂起,M 去服务别的连接
        line, err := reader.ReadString('\n')
        if err != nil {
            return // 客户端断开,goroutine 退出,资源回收
        }
        conn.Write([]byte("echo: " + line))
    }
}

考点总结: IO 多路复用解决"一个线程管很多连接";select/poll 要全量遍历、epoll 只返回就绪;Go 用 netpoll 把 epoll 封装成"每连接一 goroutine"的阻塞式写法,靠 G 挂起-唤醒实现高并发。


十、SYN 超时与 SYN Flood 攻击:半连接队列的攻防

10.1 用生活类比先建立直觉

三次握手时,服务端收到客户端 SYN 后,要进入 SYN_RCVD 状态等待对方 ACK。这段等待期间,服务端要为这个"还没完全建立"的连接预留一个座位(半连接队列)。

类比餐厅:客人(客户端)打电话说"我要订位"(SYN),服务员在预订本上记一笔(半连接),然后回"好的,给你留了,请确认"(SYN+ACK)。如果客人迟迟不回"我确定要来"(ACK),这个预订位就一直占着。

  • SYN 超时:客人没回确认,服务员会隔一会儿再打一次电话确认(重传 SYN+ACK),重试几次还不行就放弃、擦掉预订。重试间隔指数退避(如 1s、2s、4s…),总耗时可能一两分钟。
  • SYN Flood(洪泛攻击):一群坏人用假号码疯狂打电话订位,接到回复就挂,永远不确认。预订本被填满,真正的客人订不到位 —— 这就是拒绝服务(DoS)。

桥接: 半连接队列(syn backlog)是有限资源;SYN Flood 正是用海量伪造 SYN 把队列打满。SYN Cookie 的思路是"不预先留座,凭票入场"——把座位信息编码进回复里,客人第三次确认时校验票据,合法才真正安排座位,从而无需占用队列。

10.2 工程要点

正常握手中服务端的重传行为(Linux 默认):

  • 收到 SYN → 进入 SYN_RCVD,启动重传 SYN+ACK,默认重试 5 次net.ipv4.tcp_synack_retries),间隔指数退避(约 1s、2s、4s、8s、16s,总计约 1 分钟),之后丢弃半连接。
  • 在此期间,半连接占用半连接队列(syn backlog);三次握手完成、进入 ESTABLISHED 但还没被 accept() 取走的连接,则占用全连接队列(accept queue,由 listen() 的 backlog 参数决定)。
sequenceDiagram
    participant C as 攻击者(伪造源IP)
    participant S as 服务端
    Note over S: 半连接队列(有限)
    C->>S: SYN (海量伪造)
    S->>S: 分配半连接, 进入SYN_RCVD
    S-->>C: SYN+ACK (石沉大海)
    Note over S: 队列被占满
    C->>S: 更多伪造SYN...
    Note over S: 正常连接无法入队 → DoS

SYN Flood 解决策略:

策略原理说明
SYN Cookie服务端不维护半连接状态,而是把连接关键参数(源/目的端口、seq 等)用密钥签名编码进 SYN+ACK 的序列号(cookie)。收到第三次 ACK 时校验 cookie,合法才建连接队列被打满也无关,因为根本不占队列。Linux 开启 net.ipv4.tcp_syncookies=1
调大 backlog增大半连接队列(tcp_max_syn_backlog)和全连接队列(listen backlog)只能缓解小规模,治标不治本
SYN 代理 / 防火墙在网关处先代客户端完成三次握手,确认合法后再转发给后端防御前置,但会破坏端到端语义
限速 / 源 IP 封禁对单一源 IP 的 SYN 速率做限制配合其他手段使用

⚠️ 新手必踩的坑: 全连接队列(accept queue)满时,客户端可能表现为"连接偶尔超时/重连"。常见于后端 accept() 消费太慢(处理不及时),即使 SYN 正常也会卡在 ESTABLISHED 但未被 accept 的状态。排查时 ss -lntRecv-Q 是否逼近 Send-Q(即 backlog 上限)。

考点总结: SYN 超时是半连接等待 ACK 的重传;SYN Flood 用伪造 SYN 打满半连接队列造成 DoS;SYN Cookie 通过"不预占队列、凭票校验"从根本上防御,是 Linux 默认启用的核心手段。


十一、四次挥手与 CLOSE_WAIT:被动关闭方泄漏的排查

11.1 用生活类比先建立直觉

回顾四次挥手:主动关闭方发 FIN,被动关闭方回 ACK 后进入 CLOSE_WAIT,然后被动关闭方也要发自己的 FIN 才能真正进入 LAST_ACK。

类比挂电话:对方(主动方)说"我说完了"(FIN),你说"收到"(ACK),此时你进入 CLOSE_WAIT——你还没说"我也说完了"。如果你一直不把剩下的话讲完并挂机(不调用 close()),这通电话就一直占着线。

大量 CLOSE_WAIT = 被动关闭方收到了 FIN 却迟迟不 close,这是典型的"应用层没正确释放连接"的 bug,不是网络问题。

桥接: TIME_WAIT 在主动关闭方、是正常且短暂的;CLOSE_WAIT 在被动关闭方、且长期存在就是异常——几乎总能在代码里找到"某个分支忘记 close 连接"的 bug。

11.2 工程要点

四次挥手状态机(被动关闭方视角)—— 注意 CLOSE_WAIT 的卡点:

stateDiagram-v2
    [*] --> ESTABLISHED
    ESTABLISHED --> CLOSE_WAIT: 收到对端FIN, 回ACK
    CLOSE_WAIT --> LAST_ACK: 应用层调用close() 发出FIN
    LAST_ACK --> [*]: 收到对端ACK
    note right of CLOSE_WAIT
        卡在这里 = 应用层没 close()
        连接泄漏, fd 一直占用
    end note

为什么会大量出现 CLOSE_WAIT:

根因说明典型场景
异常分支未 close处理函数中途 return/panic,没有 defer conn.Close()Go 里忘记 defer;异常路径直接 return
读 EOF 后没关闭读到对方 FIN(io.EOF)后没继续走关闭流程只判断 err==nil 才处理,忽略 EOF
连接池配置不当连接用完后没归还/没设超时,长期闲置却不释放HTTP client 无 MaxIdleConns 限制
下游慢 / 死锁被动方在 close 前还依赖别的逻辑(如写回响应卡住)死锁、阻塞调用

排查与解决步骤:

  1. 确认数量与对端ss -ant | grep CLOSE_WAIT | wc -l 看规模;ss -antp | grep CLOSE_WAIT 看是哪个本地端口、连向哪个远端 IP —— 锁定是哪个上游/客户端断连触发。
  2. 检查代码释放逻辑:搜所有 net.Conn / socket 使用处,确认每个分支都有 defer conn.Close();处理 io.EOF 时要主动关闭。
  3. 看 fd 是否耗尽:CLOSE_WAIT 每个都占一个 fd 和 socket 缓冲区。数量暴涨会导致 too many open files,新连接无法建立。用 lsof -p <pid> | wc -l 核对 fd 上限。
  4. 加监控与超时:给连接设置 SetReadDeadline / SetIdleTimeout,连接池配置 MaxIdleConnsIdleConnTimeout,避免闲置连接堆积。
  5. 重启止血:紧急情况下重启进程释放所有 fd,但必须从代码层面修复根因,否则必然复发。

⚠️ 新手必踩的坑: 不要把 CLOSE_WAIT 和 TIME_WAIT 混为一谈去"调内核参数"。TIME_WAIT 靠 tcp_tw_reuse 等能缓解;CLOSE_WAIT 调内核没用——它根源在应用层没 close。改代码才是正解。

Go 中正确释放连接的范式:

func handleConn(conn net.Conn) {
    // 步骤1:第一时间 defer close,确保任何分支/panic 都能释放 fd
    defer conn.Close()

    // 步骤2:设置读超时,避免对端半关闭后本端无限阻塞
    conn.SetReadDeadline(time.Now().Add(30 * time.Second))

    reader := bufio.NewReader(conn)
    for {
        // 步骤3:正确处理 EOF —— 读到对端 FIN 时主动退出,触发本端 close
        line, err := reader.ReadString('\n')
        if err != nil {
            // io.EOF 表示对端已 FIN;无论何种 err,defer 都会 close
            return
        }
        if _, err := conn.Write([]byte("echo: " + line)); err != nil {
            return // 写出失败也及时退出释放
        }
    }
}

考点总结: CLOSE_WAIT 是被动关闭方收到 FIN 后、发出自己的 FIN 之前的状态;大量 CLOSE_WAIT 几乎一定是"应用层忘记 close 连接"导致的资源泄漏。排查靠 ss/lsof 定位 + 代码审查 defer close,根因在应用层而非内核参数。


十二、OSI 七层模型与 TCP/IP 四层模型

12.1 用生活类比先建立直觉

寄一封信,要经过"写信 → 装信封 → 贴邮票交邮局 → 分拣转运 → 卡车/飞机运输 → 最后一公里投递"多个环节,每个环节只管自己那一步,互不关心其他环节的细节。网络通信也一样:每一层只解决一类问题,层与层之间用"接口"对接,上层不关心下层怎么实现

OSI(Open Systems Interconnection)把通信拆成 7 层;而现实中 TCP/IP 只用了 4 层(把上三层合并为"应用层")。理解分层的意义在于:当你排查"网页打不开"时,能一步步定位是哪一层出问题——是网线松了(物理层)?IP 配错了(网络层)?端口没开(传输层)?还是 HTTP 报 404(应用层)?

12.2 工程要点

七层模型自顶向下,每层职责、代表协议,以及与 TCP/IP 四层模型的对应关系:

graph TB
    A7[应用层 Application
HTTP/DNS/FTP/SSH/SMTP] --> A6[表示层 Presentation
加密/编码/压缩] A6 --> A5[会话层 Session
会话建立与维持] A5 --> A4[传输层 Transport
TCP/UDP · 端到端] A4 --> A3[网络层 Network
IP/ICMP/路由 · 寻址] A3 --> A2[数据链路层 Data Link
MAC/以太网/交换机] A2 --> A1[物理层 Physical
光纤/双绞线/比特流]
OSI 层职责代表协议 / 设备TCP/IP 四层归属
应用层(7)为用户程序提供网络服务HTTP、DNS、FTP、SMTP、SSH应用层(合并 5/6/7)
表示层(6)数据格式转换、加密、压缩TLS、ASCII、JPEG、Protobuf应用层
会话层(5)建立、管理、终止会话RPC、NetBIOS应用层
传输层(4)端到端可靠/不可靠传输、端口寻址TCP、UDP传输层
网络层(3)路由寻址、跨网络转发IP、ICMP、OSPF网络层(网际层)
数据链路层(2)相邻节点帧传输、MAC 寻址以太网、PPP、交换机网络接口层(合并 1/2)
物理层(1)比特流转电信号/光信号双绞线、光纤、集线器网络接口层

关键记忆:IP 解决"到哪台机器"(网络层),端口解决"机器上的哪个程序"(传输层),HTTP 解决"程序之间聊什么"(应用层)。TCP 报文头里既有"目的端口"也有"序号",因为它同时干了传输层的事。

下面用 Go 读取本机网络接口,直观感受"数据链路层(MAC)“与"网络层(IP)“的边界:

package main

import (
    "fmt"
    "net"
)

func main() {
    // 步骤1:列出本机所有网络接口——它正好横跨“数据链路层 + 网络层”
    ifaces, err := net.Interfaces()
    if err != nil {
        panic(err)
    }
    for _, iface := range ifaces {
        // 步骤2:HardwareAddr 是 MAC 地址,属于数据链路层(二层)
        fmt.Printf("接口 %s  MAC(二层)=%s\n", iface.Name, iface.HardwareAddr)
        // 步骤3:每个接口绑定的 IP 属于网络层(三层)
        addrs, _ := iface.Addrs()
        for _, addr := range addrs {
            fmt.Printf("    IP(三层)=%s\n", addr.String())
        }
    }
}

考点总结: 七层模型是"概念框架”,TCP/IP 四层是"事实标准”;应用/表示/会话三层在 TCP/IP 里被合并为应用层。IP 管寻址、TCP/UDP 管端到端、HTTP 等管业务语义。


十三、从输入 URL 到页面展示全过程

13.1 用生活类比先建立直觉

在 APP 上点外卖,全过程是:你输入商家名(URL)→ 平台把商家名翻译成具体地址(DNS 解析)→ 骑手接单建立配送通道(TCP 三次握手)→ 验证身份/加密优惠券(TLS 握手)→ 平台下单、商家出餐回传(HTTP 请求/响应)→ 你打开包装、摆盘、开吃(浏览器解析渲染)。每一步都依赖前一步完成,少一环都吃不上饭。

这是字节、金山等公司高频面试题,本质考查你对"网络分层如何协同完成一次访问"的整体观。

13.2 工程要点

graph TD
    U[输入 URL
https://example.com/path] --> D[DNS 解析
域名 → IP] D --> T[TCP 三次握手
建立可靠连接] T --> L[TLS 握手
协商密钥/加密] L --> H[HTTP 请求/响应
GET /path] H --> R[浏览器解析渲染
DOM/CSS/JS 绘制]

分步拆解:

  1. DNS 解析:浏览器先查本地缓存 → hosts → 本地 DNS → 根/顶级/权威 DNS,递归拿到 example.com 的 IP。
  2. TCP 三次握手:与服务器 IP:443 建立可靠连接(见第二章)。
  3. TLS 握手(https):协商加密套件、交换证书、生成会话密钥,之后 HTTP 报文被加密传输。
  4. HTTP 请求/响应:发送 GET /path,服务器返回 HTML(可能带 301/302 重定向、304 缓存等)。
  5. 浏览器解析渲染:解析 HTML 构建 DOM,下载并解析 CSS/JS,布局(Layout)与绘制(Paint),期间可能触发更多 HTTP 请求(图片、接口)。

下面用 Go 模拟"DNS 解析 → TCP 连接 → TLS 握手 → 发 HTTP 请求"的前四步,把抽象流程落到代码:

package main

import (
    "crypto/tls"
    "fmt"
    "net"
    "net/http"
    "time"
)

func main() {
    host := "example.com"

    // 步骤1:DNS 解析(对应流程第 1 步)
    ips, err := net.LookupIP(host)
    if err != nil {
        fmt.Println("DNS 解析失败:", err)
        return
    }
    fmt.Printf("DNS 解析 %s -> %v\n", host, ips)

    // 步骤2:建立 TCP 连接(三次握手,对应第 2 步)
    conn, err := net.DialTimeout("tcp", host+":443", 3*time.Second)
    if err != nil {
        fmt.Println("TCP 连接失败:", err)
        return
    }
    fmt.Println("TCP 三次握手完成")
    defer conn.Close()

    // 步骤3:TLS 握手(对应第 3 步),把 TCP 连接升级为加密通道
    tlsConn := tls.Client(conn, &tls.Config{ServerName: host})
    if err := tlsConn.Handshake(); err != nil {
        fmt.Println("TLS 握手失败:", err)
        return
    }
    fmt.Println("TLS 握手完成,通道已加密")

    // 步骤4:在加密通道上发 HTTP 请求(对应第 4 步)
    req, _ := http.NewRequest("GET", "https://"+host, nil)
    resp, err := http.Transport{}.RoundTrip(req) // 仅为示意;生产用默认 Transport
    _ = resp
    _ = tlsConn
    fmt.Println("已发出 HTTP 请求(浏览器随后进入解析渲染阶段)")
}

⚠️ 新手必踩的坑: DNS 解析、TCP 建连、TLS 握手每一步都有耗时,合称"连接建立延迟"。高并发短连接下这部分开销很可观,所以才有了 HTTP Keep-Alive、连接复用、DNS 缓存和 HTTP/3(QUIC 把握手与传输合并以降本延迟)。


十四、TCP 粘包:为什么一次读会拿到多包数据

14.1 用生活类比先建立直觉

TCP 是一条"水流管道",它只保证字节流有序、可靠到达,不保留你每次 write 的边界。想象你往水管里分段倒水:第一次倒一杯(“AA”),第二次倒一杯(“BB”)。水龙头出水是连续的,接水的人可能一次接出"AA BB"两杯混在一起,也可能先接出"AA",剩下"B B"下次才接到——这就是粘包(几次发送粘成一包)和半包/拆包(一包被拆成几次读)。

TCP 之所以"无边界",原因有三:① Nagle 算法把小包合并成大包再发;② 滑动窗口/拥塞控制决定一次能发多少,并不按你的 write 次数切分;③ 接收方 read 一次可能没读完缓冲区里剩下的字节。

14.2 工程要点

sequenceDiagram
    participant S as 发送方
    participant P as TCP 字节流(无边界)
    participant R as 接收方
    S->>P: write("AA")
    S->>P: write("BB")
    Note over P: Nagle/窗口可能合并
    P->>R: "AABB"(一次 read 拿到两包 → 粘包)
    Note over R: 需要按协议拆出 "AA" 和 "BB"

拆包/封包的常见方案:

方案做法优点缺点
定长每包固定 N 字节,不足补位实现最简单浪费带宽,不灵活
分隔符\n/\r\n/特殊字符切分直观(如文本协议)数据里不能出现分隔符
长度字段包头用固定字节存 body 长度通用、高效、无歧义需自定义协议头

长度字段法最常用,下面用 Go 实现一个"4 字节大端长度 + body"的封包/拆包:

package main

import (
    "bufio"
    "bytes"
    "encoding/binary"
    "fmt"
    "io"
)

// 步骤1:封包——在 body 前加 4 字节大端长度前缀
func encode(body []byte) []byte {
    buf := new(bytes.Buffer)
    var lenField uint32 = uint32(len(body))
    binary.Write(buf, binary.BigEndian, lenField) // 长度字段 4 字节
    buf.Write(body)                                // 真实数据
    return buf.Bytes()
}

// 步骤2:拆包——先读 4 字节长度,再按长度精确读 body
func decode(r *bufio.Reader) ([]byte, error) {
    var lenField uint32
    // 先读长度字段(固定 4 字节,保证读全)
    if err := binary.Read(r, binary.BigEndian, &lenField); err != nil {
        return nil, err
    }
    body := make([]byte, lenField)
    // 再精确读取 lenField 字节的 body(io.ReadFull 会读满才返回)
    if _, err := io.ReadFull(r, body); err != nil {
        return nil, err
    }
    return body, nil
}

func main() {
    // 步骤3:模拟"发送端封包、接收端拆包"
    raw := encode([]byte("hello"))
    raw = append(raw, encode([]byte("world")...)...) // 两个包粘在一起
    r := bufio.NewReader(bytes.NewReader(raw))

    for i := 0; i < 2; i++ {
        msg, err := decode(r)
        if err != nil {
            break
        }
        fmt.Printf("拆出第 %d 包: %s\n", i+1, msg)
    }
}

⚠️ 新手必踩的坑: 粘包不是 TCP 的 bug,而是它的"字节流"本质。解决粘包必须在应用层定义消息边界(长度字段最通用)。bufio.Reader + io.ReadFull 是 Go 里处理半包/粘包的标准套路。


十五、HTTP 方法补充:HEAD 与 401/403 状态码

15.1 用生活类比先建立直觉

HEAD 就像"只看外卖包装、不拆开吃":你向餐厅问"这份套餐长啥样、多重、新鲜吗",餐厅把包装信息(响应头:Content-Length、Last-Modified 等)都告诉你,但不给你食物本身(没有响应体)。它和 GET 的唯一区别就是"服务器不返回 body"。

401 vs 403 的区别就像进小区门禁

  • 401 Unauthorized(未认证):门卫说"你谁啊?刷卡/刷脸证明一下身份"——你没登录凭证缺失/无效,补上合法凭证就能进。
  • 403 Forbidden(已认证但无权限):门卫说"我知道你是业主,但这栋楼你没权限进"——你身份合法,但角色/权限不够,换账号也没用。

15.2 工程要点

HEAD 与 GET 的差异:

对比项GETHEAD
响应体有(资源内容)无(只有响应头)
典型用途获取资源探活、获取元信息(大小/修改时间)、断点续传前先查长度
安全性/幂等安全、幂等安全、幂等
缓存可缓存可缓存(头信息)

401 与 403 的场景对照:

状态码含义触发场景解决方式
401未认证 / 凭证缺失或无效没带 Token、Token 过期、签名错误重新登录、刷新 Token
403已认证但无权访问角色不是 admin、资源不属于该用户换有权限的账号(而非重新登录)

用 Go 演示一个接口如何区分返回 HEAD、401、403:

package main

import (
    "fmt"
    "net/http"
)

func resourceHandler(w http.ResponseWriter, r *http.Request) {
    auth := r.Header.Get("Authorization")

    // 步骤1:没有凭证 → 401(未认证)
    if auth == "" {
        http.Error(w, "未认证", http.StatusUnauthorized) // 401
        return
    }
    // 步骤2:有凭证但权限不够 → 403(无权限)
    if auth != "Bearer admin-token" {
        http.Error(w, "无权限", http.StatusForbidden) // 403
        return
    }

    // 步骤3:HEAD 只返回头、不返回 body
    w.Header().Set("Content-Length", "11")
    w.Header().Set("Content-Type", "text/plain")
    if r.Method == http.MethodHead {
        // HEAD:不写 body,客户端只拿到响应头
        w.WriteHeader(http.StatusOK)
        return
    }
    // GET:返回真实内容
    fmt.Fprintln(w, "hello world")
}

func main() {
    http.HandleFunc("/resource", resourceHandler)
    http.ListenAndServe(":8080", nil)
}

⚠️ 新手必踩的坑: 401 和 403 常被混用。记住一句话——401 是"你是谁"(没证明身份),403 是"你不行"(身份 OK 但权限不够)。前端拿到 401 通常跳转登录页,拿到 403 则提示"无权限"而非让用户输入密码。


十六、HTTP 长连接、pipeline 与 HTTP/2 多路复用

16.1 用生活类比先建立直觉

同一家餐厅(一个 TCP 连接)点三道菜:

  • HTTP/1.0 短连接:每点一道菜就重开一家餐厅、吃完就关门,再点再开——开销爆炸。
  • HTTP/1.1 Keep-Alive(长连接):餐厅一直开着,你一道一道点、一道一道上(请求 1→响应 1→请求 2→响应 2)。比短连接省事,但上一道没上完,下一道得排队(队头阻塞)。
  • HTTP/1.1 pipeline:你一口气把三道菜全点了(不等上菜),厨房按序出三道菜。省了"等上菜再点"的往返,但响应仍必须按请求顺序返回,且任一响应慢了照样堵后面。
  • HTTP/2 多路复用:餐厅把每道菜拆成"小碟",多道菜的小碟在同一条流水线并行穿梭、互不排队,哪道先好先上哪道——彻底消除应用层队头阻塞。

16.2 工程要点

graph LR
    subgraph K[HTTP/1.1 Keep-Alive 长连接]
        K1[请求1] --> K2[响应1]
        K2 --> K3[请求2] --> K4[响应2]
    end
    subgraph P[HTTP/1.1 Pipeline 管道化]
        P1[请求1] --> P2[请求2] --> P3[响应1] --> P4[响应2]
    end
    subgraph M[HTTP/2 多路复用]
        M1[流A: 帧交织] --> M3[单TCP连接并行]
        M2[流B: 帧交织] --> M3
    end
特性Keep-AlivePipelineHTTP/2 多路复用
连接复用是(一个连接多个请求)
请求发送等上一个响应才发下一个不等响应连续发并发发,帧乱序交织
响应返回严格按顺序严格按顺序按流 ID 并行,无队头阻塞
队头阻塞有(响应慢则后面全等)有(响应须按序)应用层无(但 TCP 层仍有)

Go 的 net/http 客户端默认就开启了 Keep-Alive 连接复用,下面演示如何显式配置复用:

package main

import (
    "fmt"
    "io"
    "net/http"
    "time"
)

func main() {
    // 步骤1:构建带连接池的客户端(默认就复用 Keep-Alive 连接)
    client := &http.Client{
        Transport: &http.Transport{
            MaxIdleConns:        100,             // 最大空闲连接数
            MaxIdleConnsPerHost: 10,              // 每 host 最大空闲连接
            IdleConnTimeout:     90 * time.Second, // 空闲连接保活时长
        },
    }

    // 步骤2:连续发多个请求,复用同一条 TCP 连接(Keep-Alive)
    for i := 0; i < 3; i++ {
        resp, err := client.Get("http://example.com")
        if err != nil {
            fmt.Println("请求失败:", err)
            continue
        }
        // 必须读完并关闭 body,连接才能被放回池里复用
        io.Copy(io.Discard, resp.Body)
        resp.Body.Close()
        fmt.Printf("第 %d 次请求完成,连接已归还复用\n", i+1)
    }
}

⚠️ 新手必踩的坑: HTTP/2 的多路复用消除了应用层队头阻塞,但底层仍是一条 TCP 连接——一旦这个 TCP 连接丢包,所有 HTTP/2 流都会因 TCP 重传而等待,这就是"TCP 层队头阻塞"。HTTP/3(QUIC over UDP)才从根上解决。


十七、TCP 与 UDP 的区别及 UDP 适用场景

17.1 用生活类比先建立直觉

  • TCP 像打电话:先拨号建连(三次握手),说话按序、不丢字、对方听不清会让你重说(可靠、有序、重传)。代价是"建立连接 + 确认"带来的延迟和开销。
  • UDP 像发明信片/群发短信:写好直接扔进邮筒(无需建连),能不能到、到没到、到得全不全、顺序对不对——一概不保证。好处是极快、极轻、支持一对多广播。

所以结论很自然:要可靠、要顺序、不怕一点延迟,用 TCP(网页、文件、接口);要极低延迟、能容忍少量丢包、或要广播,用 UDP(实时音视频、DNS、游戏)

17.2 工程要点

graph TB
    subgraph TCP
        T1[面向连接] --> T2[可靠·有序·重传]
        T2 --> T3[流量/拥塞控制]
        T3 --> T4[适用: 网页/文件/API]
    end
    subgraph UDP
        U1[无连接] --> U2[不可靠·可能乱序]
        U2 --> U3[无控制·极轻量]
        U3 --> U4[适用: 音视频/DNS/游戏]
    end
对比项TCPUDP
是否连接面向连接(三次握手)无连接,直接发
可靠性可靠:丢包重传、去重、有序不可靠:可能丢、乱序、重复
流量/拥塞控制有(滑动窗口、拥塞控制)
头部开销大(20 字节起)小(8 字节)
速度/延迟慢一点(建连+确认)快、低延迟
数据边界字节流,无边界(粘包)保留数据报边界
一对多仅点对点支持单播/广播/多播
典型场景HTTP、FTP、SSH、数据库实时音视频、DNS、游戏、QUIC

下面用 Go 写一个 UDP echo 服务,感受"无连接、数据报边界":

package main

import (
    "fmt"
    "net"
)

func main() {
    // 步骤1:UDP 无需 listen 三次握手,直接绑定端口
    addr, _ := net.ResolveUDPAddr("udp", ":8080")
    conn, err := net.ListenUDP("udp", addr)
    if err != nil {
        panic(err)
    }
    defer conn.Close()
    fmt.Println("UDP echo 服务启动 :8080")

    buf := make([]byte, 1024)
    for {
        // 步骤2:ReadFromUDP 一次拿到“一个完整数据报”(保留边界,不像 TCP 会粘包)
        n, clientAddr, err := conn.ReadFromUDP(buf)
        if err != nil {
            continue
        }
        fmt.Printf("收到来自 %s 的数据报: %s\n", clientAddr, buf[:n])
        // 步骤3:原样回写(UDP 也是无连接的,需指定对端地址)
        conn.WriteToUDP(buf[:n], clientAddr)
    }
}

⚠️ 新手必踩的坑: UDP 不保证到达,所以"发完就当成功"是错的——若需可靠,要么换 TCP,要么在应用层自己加确认/重传(见第二十一章)。另外 UDP 单次数据报受 MTU 限制(通常 ≤ 1472 字节有效载荷),超大包会被 IP 层分片,丢一片整包作废。


十八、TCP 校验和:十六位反码求和如何计算与检错

18.1 用生活类比先建立直觉

寄贵重包裹时,快递单上会写"内容物重量"。收件人收到后称一下,如果重量对不上,就知道中途被拆过或漏了——校验和就是 TCP 报文头里的"重量标签",用来发现传输中比特翻转(0 变 1、1 变 0)导致的错误。

TCP 校验和的计算范围包括:伪头部(源/目的 IP、协议号、TCP 长度)+ TCP 头部 + 数据,全部按 16 位(2 字节) 分组,做"反码求和"(one’s complement sum),最后取反得到 16 位校验和。

18.2 工程要点

graph LR
    A[按 16 位分组] --> B[全部相加
进位回卷] B --> C[对和取反
得到校验和] C --> D[接收方: 数据+校验和
再算一次应为 0]

计算步骤如下:

  1. 把待校验数据按 16 位(2 字节)切分,奇数长度时末尾单字节高位补 0。
  2. 所有 16 位数正常相加(二进制求和)。
  3. 若加法产生进位(超出 16 位),把溢出的高位加回低位(称为"回卷"),直到无进位。
  4. 对最终的和取反码(按位取反),即得到校验和。

接收方把"收到的数据 + 发送方填的校验和"整体再做一次反码求和,结果应为 0(全 0)表示无错。

下面用 Go 实现这个算法(真实 TCP 还会拼伪头部,这里演示核心反码求和逻辑):

package main

import (
    "encoding/binary"
    "fmt"
)

// checksum 计算 16 位反码求和校验和
func checksum(b []byte) uint16 {
    var sum uint32
    // 步骤1:按 2 字节为单位累加
    for i := 0; i < len(b)-1; i += 2 {
        sum += uint32(binary.BigEndian.Uint16(b[i:]))
    }
    // 奇数长度,最后 1 字节当高位补 0
    if len(b)%2 == 1 {
        sum += uint32(b[len(b)-1]) << 8
    }
    // 步骤2:进位回卷——把高 16 位不断加回低 16 位
    for sum>>16 != 0 {
        sum = (sum & 0xffff) + (sum >> 16)
    }
    // 步骤3:取反得到校验和
    return uint16(^sum)
}

func main() {
    data := []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06}
    csum := checksum(data)
    fmt.Printf("发送方校验和 = 0x%04x\n", csum)

    // 步骤4:把"数据 + 校验和"作为线上字节,接收方再算一次
    wire := append(append([]byte{}, data...), byte(csum>>8), byte(csum))
    if checksum(wire) == 0 {
        fmt.Println("接收方验证通过(结果为 0,表示未出错)")
    } else {
        fmt.Println("接收方验证失败:数据在传输中损坏")
    }
}

⚠️ 新手必踩的坑: 校验和只能检错不能纠错,且碰撞概率非零(只是极低)。它防的是"链路比特翻转"这类物理层错误;要防"恶意篡改"得靠 TLS 的 MAC/签名,校验和不算安全机制。


十九、client 如何实现长连接:心跳、连接池与断线重连

19.1 用生活类比先建立直觉

长连接像"长期保持的友谊":

  • 心跳保活:朋友间偶尔发个"在吗"(心跳包),证明彼此还在线;长时间不说话,中间人(NAT/防火墙)可能把这条"通道"回收了。
  • 断线重连:某天发现消息发不出去(连接断了),自动重拨(重建连接),而不是从此失联。
  • 连接池:你有好几个备用电话,谁空闲用谁,避免每次聊天都重新办卡(建连开销大)。

服务端常有 net.ipv4.tcp_keepalive_time 等机制,但那是内核级、间隔长(默认 2 小时),业务级心跳(如每 5~30 秒)才是及时感知对端存活的可靠手段。

19.2 工程要点

sequenceDiagram
    participant C as 客户端
    participant S as 服务端
    C->>S: 建立长连接
    loop 每 N 秒
        C->>S: PING 心跳
        S-->>C: PONG
    end
    Note over C,S: 某次 PING 超时/写失败
    C->>C: 触发断线重连
    C->>S: 重建连接

三大手段:

  1. 心跳保活:定时发小包,超时未收到响应即判定连接已死。
  2. 断线重连:捕获 write/read 错误后,带退避(backoff)地重建连接。
  3. 连接池:复用已建连接,限制最大连接数,空闲回收,避免频繁建连。

下面用 Go 演示带心跳 + 自动重连的客户端(服务端可用前面章节的 TCP echo 服务):

package main

import (
    "bufio"
    "fmt"
    "net"
    "time"
)

// LongConn 封装带心跳与自动重连的长连接
type LongConn struct {
    addr   string
    conn   net.Conn
    closed bool
}

// connect 建立(或重建)TCP 连接
func (lc *LongConn) connect() {
    for {
        c, err := net.DialTimeout("tcp", lc.addr, 3*time.Second)
        if err == nil {
            lc.conn = c
            fmt.Println("连接建立:", lc.addr)
            return
        }
        fmt.Println("连接失败,3 秒后重试:", err)
        time.Sleep(3 * time.Second)
    }
}

// heartbeat 定时发心跳,失败则触发重连
func (lc *LongConn) heartbeat(interval time.Duration) {
    ticker := time.NewTicker(interval)
    defer ticker.Stop()
    for range ticker.C {
        if lc.closed {
            return
        }
        if _, err := lc.conn.Write([]byte("PING\n")); err != nil {
            fmt.Println("心跳失败,触发重连:", err)
            lc.connect() // 断线重连
        }
    }
}

func main() {
    lc := &LongConn{addr: "localhost:8080"}
    lc.connect()
    go lc.heartbeat(5 * time.Second) // 每 5 秒一次心跳

    // 模拟正常收发(读响应)
    reader := bufio.NewReader(lc.conn)
    for i := 0; i < 3; i++ {
        lc.conn.Write([]byte(fmt.Sprintf("hello %d\n", i)))
        line, _ := reader.ReadString('\n')
        fmt.Println("收到:", line)
        time.Sleep(2 * time.Second)
    }
}

⚠️ 新手必踩的坑: 心跳间隔不是越短越好——太短会空耗带宽和连接;太长则故障发现慢。经验值 5~30 秒,且心跳超时次数(如连续 2 次 PING 无 PONG)才判定断线,避免网络抖动误杀。连接池务必设置 IdleConnTimeout,否则空闲连接被服务端先关、客户端再用就拿到失效连接。


二十、Java NIO 与 Go netpoll 的区别与优劣

20.1 用生活类比先建立直觉

餐厅照看 100 桌客人:

  • Java NIO(Reactor 模式):请一个"领班"(Selector 线程)专门盯着所有桌的举手,哪桌举手他去哪桌;具体服务可以再交给"服务员线程池"。核心是一小撮线程事件回调地干活,业务逻辑写在回调里(容易"回调地狱")。
  • Go netpoll:给每桌都配一个"专属服务员"(goroutine),服务员表面上一直站在那桌等点单(写起来像阻塞代码),实际上没客人的时候他"隐身"去帮别的桌了(G 被 park,不占 OS 线程)。1000 桌就有 1000 个 goroutine,但底层只用少量 OS 线程。

两者底层都依赖操作系统的 epoll/kqueue,区别在编程模型与调度归属:Java 把"多路复用 + 线程调度"交给程序员和 JVM,Go 把"多路复用 + 协程调度"收进了运行时。

20.2 工程要点

graph TB
    subgraph Java[NIO + Reactor]
        JS[Selector 单线程
监听所有 Channel] --> JC[事件就绪] JC --> JW[Worker 线程池
处理业务·回调] end subgraph Go[Go netpoll + goroutine] GS[netpoll(epoll)] --> GG[每个连接一个 goroutine] GG --> GR[运行时调度 G/M/P
阻塞写法实则挂起] end
对比项Java NIO(Reactor)Go netpoll
并发单元线程 / 线程池goroutine(轻量,KB 级栈)
编程模型事件回调(Channel/Selector)阻塞式顺序代码(goroutine-per-connection)
调度归属程序员 + JVM 线程池Go 运行时(G-M-P 调度器)
心智负担高(回调、状态机、线程安全)低(像写同步代码)
百万连接可行但线程/回调复杂天然友好(goroutine 极轻)
缺点回调嵌套、上下文切换单 goroutine 阻塞系统调用仍卡 M

Go 的 goroutine-per-connection 模型(即 netpoll 封装后的样子):

package main

import (
    "bufio"
    "fmt"
    "net"
)

func main() {
    ln, _ := net.Listen("tcp", ":8080")
    defer ln.Close()
    for {
        conn, err := ln.Accept()
        if err != nil {
            continue
        }
        // 每个连接一个 goroutine:代码像阻塞写法,
        // 底层由 netpoll 在“无数据可读”时挂起 G、不占 OS 线程
        go handle(conn)
    }
}

func handle(conn net.Conn) {
    defer conn.Close()
    reader := bufio.NewReader(conn)
    for {
        line, err := reader.ReadString('\n')
        if err != nil {
            return
        }
        fmt.Print("收到:", line)
        conn.Write([]byte("ok\n"))
    }
}

⚠️ 新手必踩的坑: 两者性能上限都受 epoll 限制,差异主要在开发效率与可维护性。Go 让你用"同步思维"写高并发,代价是运行时调度带来少量额外开销;Java NIO 控制更精细、但 Reactor 编码复杂。无论哪种,都要避免在处理逻辑里做阻塞系统调用(文件 IO、cgo),否则再好的多路复用也救不了。


二十一、UDP 如何实现可靠传输(应用层可靠化)

21.1 用生活类比先建立直觉

UDP 本身像"随手扔出的明信片",不保证对方收到。但我们可以在明信片上自己加规则,把它变成"挂号信":

  • 给每张明信片编号(序列号 seq);
  • 对方收到后回一张"收到第几号"的短卡(确认 ACK);
  • 发完没收到回卡就超时重传
  • 连续发多张时,靠滑动窗口控制一次能发几张,避免淹没对方。

这正是 TCP 的思路搬到了应用层。现实中 QUIC(HTTP/3 的传输层)和 KCP(游戏常用)都是在 UDP 之上自建可靠/低延迟传输的典型。

21.2 工程要点

sequenceDiagram
    participant S as 发送端(UDP)
    participant R as 接收端(UDP)
    S->>R: [seq=0] 数据
    R-->>S: ACK seq=0
    S->>R: [seq=1] 数据
    Note over S: 超时未收到 ACK
    S->>R: [seq=1] 重传
    R-->>S: ACK seq=1

核心机制映射到 TCP:

可靠要素实现方式(应用层)对应 TCP 概念
有序序列号 seq序号
不丢接收方回 ACK + 发送方超时重传确认与重传
去重按 seq 判断已收到则丢弃去重
控速滑动窗口,限制未确认包数量流量控制

下面用 Go 实现最小化的"停等协议"(stop-and-wait)可靠 UDP:发送一包,等 ACK,超时(400ms)就重传,最多重试 5 次。

package main

import (
    "fmt"
    "net"
    "time"
)

// 在 UDP 之上用“序号 + 确认 + 超时重传”实现可靠传输(停等版)
func main() {
    // 接收端:监听 9002,收到数据后回 ACK(携带相同序号)
    recvAddr, _ := net.ResolveUDPAddr("udp", "127.0.0.1:9002")
    recvConn, _ := net.ListenUDP("udp", recvAddr)
    sendAddr, _ := net.ResolveUDPAddr("udp", "127.0.0.1:9001")
    sendConn, _ := net.ListenUDP("udp", sendAddr)
    peer, _ := net.ResolveUDPAddr("udp", "127.0.0.1:9002")

    // 步骤1:接收端 goroutine——收数据、回 ACK
    go func() {
        buf := make([]byte, 1024)
        for {
            n, _, _ := recvConn.ReadFromUDP(buf)
            seq := buf[0]        // 取出序号
            data := buf[1:n]     // 取出数据
            fmt.Printf("接收端: 收到 seq=%d data=%s,回 ACK\n", seq, data)
            recvConn.WriteToUDP([]byte{seq}, sendAddr) // 把 ACK 发回发送端
        }
    }()

    // 步骤2:发送端——停等发送,超时未收到 ACK 则重传
    seq := byte(0)
    payload := []byte("hello reliable udp")
    for attempt := 0; attempt < 5; attempt++ {
        pkt := append([]byte{seq}, payload...) // 第一字节是序号
        sendConn.WriteToUDP(pkt, peer)

        // 步骤3:设置 400ms 读超时,等待 ACK
        sendConn.SetReadDeadline(time.Now().Add(400 * time.Millisecond))
        ackBuf := make([]byte, 1)
        _, _, err := sendConn.ReadFromUDP(ackBuf)
        if err == nil && ackBuf[0] == seq {
            fmt.Printf("发送端: 收到 ACK=%d,发送成功\n", seq)
            return
        }
        fmt.Printf("发送端: 第 %d 次超时,重传 seq=%d\n", attempt+1, seq)
    }
}

⚠️ 新手必踩的坑: 停等协议一次只发一包、效率极低,真实可靠 UDP(如 KCP/QUIC)用滑动窗口 + 选择重传(SACK) 实现高吞吐。还要注意:UDP 数据报有大小上限,应用层分片时每片都要带 seq,否则大消息在 IP 层分片后丢一片就整包作废。


二十二、gRPC 框架与 Protocol Buffers 高性能原理

22.1 用生活类比先建立直觉

把"远程调用一个函数"想成寄一份需要对方代做的快递

  • 你(客户端)把"要做的菜名 + 食材"写进一张标准格式的单子(序列化),交给快递公司(网络传输)。
  • 快递公司按统一规则打包、运输(gRPC 框架 + HTTP/2)。
  • 对方(服务端)收到单子,按格式还原出任务(反序列化),做完再把"成品"寄回来。

为什么不用普通 JSON 写信?因为 JSON 是"人写的散文",每次都要逐字解析、字段名重复写(浪费带宽);Protobuf 是"填好的电报码"——字段用编号表示、数字用变长编码,体积小、解析快。

桥接: gRPC 不是"又一个协议",而是把"定义接口(.proto)→ 自动生成两端代码(stub)→ 用 HTTP/2 传 Protobuf"这套流水线标准化了。理解它的高性能,要把它拆成四层去看。

22.2 工程要点

gRPC 四层模型:

graph TB
    subgraph 应用层[gRPC 应用层]
        A1[.proto 定义
Service / Message] A2[Stub 桩代码
自动生成·客户端/服务端] end subgraph 框架层[gRPC 框架层] F1[RPC 语义
方法映射/状态码] F2[消息分帧
Length-Prefixed] end subgraph 编码层[序列化层] P[Protobuf
二进制·强类型] end subgraph 传输层[传输层] H[HTTP/2
多路复用·流] T[TCP] end A2 --> F1 --> F2 --> P --> H --> T

四层各自解决一件事:

  1. Stub(桩代码):根据 .proto 自动生成,客户端像调本地函数一样调远程方法,服务端只填业务逻辑——屏蔽"网络"细节。
  2. gRPC 框架层:把"方法名 + 参数 + 返回值"映射成 RPC 语义,并给消息加长度前缀帧(同第十四章拆包思路)。
  3. Protobuf 序列化:把结构体编码成二进制。
  4. HTTP/2 传输:提供多路复用、流控、头部压缩。

HTTP/2 给 gRPC 带来的关键能力:

HTTP/2 特性对 gRPC 的意义
多路复用(单连接多流)一个 TCP 连接上并发跑多个 RPC,消除队头阻塞、省连接
二进制帧天然适配"帧 = 一个消息",gRPC 把每个消息装进 DATA 帧
HPACK 头部压缩大量 RPC 的方法路径/元数据重复,压缩后开销极低
流(Stream)原生支撑 gRPC 的 4 种模式:一元/服务端流/客户端流/双向流

Protobuf 二进制编码为什么快且小:

  • 字段编号代替字段名:JSON 里 "userName":"alice" 每个包都重复写键名;Protobuf 只写 字段号=1, 类型=string, 值=alice,字段名在 .proto 里定义一次。
  • Varint 变长整数:小数字用 1 字节,大数字才用多字节(如 300 只占 2 字节,而 JSON "300" 要 3 字符 + 引号)。
  • 无反射按需解析:二进制按字段号直接定位,JSON 要全文扫描 + 字符串匹配。

下面用纯标准库写一个迷你 RPC 框架,把"服务注册 / 序列化 / 网络传输 / stub"四要点一次讲清(序列化用 encoding/gob 演示二进制思路,真实 gRPC 用 Protobuf):

package main

import (
	"bytes"
	"encoding/binary"
	"encoding/gob"
	"fmt"
	"io"
	"net"
	"sync"
)

// ============ 要点1:服务注册 ============
// 注册表:方法名 -> 处理函数。gRPC 启动时把 .proto 里每个方法注册进来,
// 调用时按名字查表分发(对应 RegisterXXXServer)。
type Registry struct {
	mu       sync.Mutex
	handlers map[string]func(interface{}) (interface{}, error)
}

func NewRegistry() *Registry {
	return &Registry{handlers: make(map[string]func(interface{}) (interface{}, error))}
}

func (r *Registry) Register(name string, h func(interface{}) (interface{}, error)) {
	r.mu.Lock()
	defer r.mu.Unlock()
	r.handlers[name] = h
}

func (r *Registry) Invoke(name string, req interface{}) (interface{}, error) {
	r.mu.Lock()
	h, ok := r.handlers[name]
	r.mu.Unlock()
	if !ok {
		return nil, fmt.Errorf("方法未注册: %s", name)
	}
	return h(req)
}

// ============ 要点2:序列化(用 gob 演示二进制思路,真实 gRPC 用 Protobuf) ============
// Envelope 把"方法名 + 参数"打包,对应 .proto 里的 service.method 与 message。
type Envelope struct {
	Method string
	Args   interface{}
}

// ============ 要点3:网络传输(TCP + 4 字节长度前缀帧,见第十四章) ============
func sendEnvelope(conn net.Conn, e *Envelope) error {
	var buf bytes.Buffer
	if err := gob.NewEncoder(&buf).Encode(e); err != nil {
		return err
	}
	header := make([]byte, 4)
	binary.BigEndian.PutUint32(header, uint32(buf.Len())) // 长度前缀,避免粘包
	if _, err := conn.Write(header); err != nil {
		return err
	}
	_, err := conn.Write(buf.Bytes())
	return err
}

func recvEnvelope(r io.Reader) (*Envelope, error) {
	var header [4]byte
	if _, err := io.ReadFull(r, header[:]); err != nil { // 先精确读 4 字节长度
		return nil, err
	}
	n := binary.BigEndian.Uint32(header[:])
	body := make([]byte, n)
	if _, err := io.ReadFull(r, body); err != nil { // 再按长度读 body
		return nil, err
	}
	var e Envelope
	if err := gob.NewDecoder(bytes.NewReader(body)).Decode(&e); err != nil {
		return nil, err
	}
	return &e, nil
}

// ============ 要点4:Stub(客户端桩,像调本地函数) ============
// 客户端只调 Call,无需关心网络/编码细节——这正是 gRPC 生成 stub 的精髓。
type Client struct {
	conn net.Conn
}

func (c *Client) Call(method string, args interface{}) (interface{}, error) {
	if err := sendEnvelope(c.conn, &Envelope{Method: method, Args: args}); err != nil {
		return nil, err
	}
	resp, err := recvEnvelope(c.conn)
	if err != nil {
		return nil, err
	}
	return resp.Args, nil
}

func main() {
	reg := NewRegistry()
	// 注册一个方法(真实 gRPC 由 protoc 生成 Register 调用)
	reg.Register("Math.Add", func(req interface{}) (interface{}, error) {
		p := req.([]int)
		return p[0] + p[1], nil
	})

	// 用内存管道 net.Pipe 模拟"网络",跑通 客户端 -> 服务端 全链路
	clientConn, serverConn := net.Pipe()
	go func() {
		for {
			e, err := recvEnvelope(serverConn) // 收请求
			if err != nil {
				return
			}
			result, _ := reg.Invoke(e.Method, e.Args) // 查注册表执行
			_ = sendEnvelope(serverConn, &Envelope{Method: e.Method, Args: result}) // 回包
		}
	}()

	cli := &Client{conn: clientConn}
	// 步骤:客户端像调本地函数一样发起 RPC
	out, _ := cli.Call("Math.Add", []int{3, 4})
	fmt.Printf("RPC Math.Add(3,4) = %v\n", out) // 输出 7
}

⚠️ 新手必踩的坑: Protobuf 的"强类型"是双刃剑——一旦 .proto 字段类型或编号改动且两端不同步,就会解析错乱甚至直接失败。所以 .proto 的向后兼容(加字段用新编号、不复用旧编号、不删已用字段)是生产红线。gRPC-Web / 网关转换又引入额外 hops,跨语言项目务必把 .proto 当契约严格管理。


二十三、TLS/SSL 握手与 CA 证书有效性

23.1 用生活类比先建立直觉

TLS 握手像两个人初次合作前交换"只有彼此懂的暗号本"

  • 先确认对方身份(看介绍信/证书),再协商一个临时暗号(会话密钥),之后所有对话都用暗号加密。
  • CA(证书颁发机构)像公证处:你没法挨个认识所有人,但你可以信任公证处;只要对方拿出公证处盖章的介绍信(由受信任 CA 签名的证书),你就敢信他。

桥接: 证书就是"公钥 + 身份"的防伪介绍信;CA 是大家都预先信任的公证处;握手的目的 = 验证身份 + 安全地协商出一把只有双方有的钥匙。

23.2 工程要点

TLS 1.2 握手(RSA 密钥交换简化版):

sequenceDiagram
    participant C as 客户端
    participant S as 服务端
    C->>S: ClientHello
支持的加密套件/随机数 S->>C: ServerHello + 证书(含公钥)
+ Server随机 Note over C: 验证证书链 → CA C->>S: 用证书公钥加密 PreMasterSecret S->>S: 私钥解密出 PreMasterSecret Note over C,S: 双方各自算出相同会话密钥 C->>S: Finished(加密) S->>C: Finished(加密) Note over C,S: 之后 HTTP 用会话密钥加密

TLS 1.3 握手(1-RTT,更短更快): 把密钥交换参数提前塞进 ClientHello,服务端一次性回证书 + 密钥 + Finished,少一轮往返;并默认前向保密(只用 ECDHE,不用静态 RSA)。

证书链与 CA 为什么必要:

概念作用
根 CA自签名,预置在操作系统/浏览器的信任库,是"信任锚"
中间 CA由根 CA 签发,专门发叶子证书,保护根密钥不出库
叶子证书你的域名证书,由中间 CA 签发
证书链验证从叶子一路向上验签名,直到某个根 CA 在信任库里
为什么需要 CA否则任何人都能伪造"我是 example.com",无法确认身份

CRL / OCSP(证书吊销): 证书有有效期,但私钥可能提前泄露。CA 提供证书吊销列表(CRL)在线状态协议(OCSP),让客户端实时/定期确认"这张证书是否已被作废"。OCSP Stapling 则由服务端主动把 OCSP 响应附带给客户端,减少客户端额外请求。

下面用 Go 连接 example.com:443,打印证书链并演示校验过程:

package main

import (
	"crypto/tls"
	"crypto/x509"
	"fmt"
	"net"
	"time"
)

func main() {
	host := "example.com"

	// 步骤1:建立 TCP 连接(三次握手)
	conn, err := net.DialTimeout("tcp", host+":443", 5*time.Second)
	if err != nil {
		fmt.Println("TCP 连接失败:", err)
		return
	}
	defer conn.Close()

	// 步骤2:在 TCP 之上做 TLS 握手,ServerName 用于 SNI + 证书校验
	cfg := &tls.Config{ServerName: host}
	tlsConn := tls.Client(conn, cfg)
	if err := tlsConn.Handshake(); err != nil {
		fmt.Println("TLS 握手失败:", err)
		return
	}
	fmt.Println("TLS 握手成功,协商密码套件:", tlsConn.ConnectionState().CipherSuite)

	// 步骤3:拿到服务端证书链(叶子 -> 中间 CA -> ...)
	state := tlsConn.ConnectionState()
	fmt.Printf("\n共 %d 张证书:\n", len(state.PeerCertificates))
	for i, cert := range state.PeerCertificates {
		fmt.Printf("  [%d] %s  签发者=%s\n", i, cert.Subject.CommonName, cert.Issuer.CommonName)
	}

	// 步骤4:手动验证证书链(Go 握手时已自动校验,这里演示原理)
	// 用系统信任库里的根 CA 作为信任锚
	roots, _ := x509.SystemCertPool()
	if roots == nil {
		fmt.Println("未找到系统根证书池")
		return
	}
	leaf := state.PeerCertificates[0]
	intermediates := x509.NewCertPool()
	for _, c := range state.PeerCertificates[1:] {
		intermediates.AddCert(c)
	}
	opts := x509.VerifyOptions{
		Roots:         roots,
		Intermediates: intermediates,
		DNSName:       host,
	}
	if _, err := leaf.Verify(opts); err != nil {
		fmt.Println("证书链校验失败:", err)
	} else {
		fmt.Println("证书链校验通过(叶子 -> 中间 CA -> 根 CA 可信)")
	}
}

⚠️ 新手必踩的坑: TLS 握手失败常见两类——① 证书域名不匹配(SNI/ServerName 写错,或证书没覆盖该域名);② 证书过期或自签名未被信任(自签证书需手动加入信任库,生产别用自签当公网证书)。另注意:Go 默认不开启 OCSP/CRL 实时校验,失效证书的实时吊销需要额外实现(tls.Config.VerifyPeerCertificate 自定义)。


二十四、TCP 三次握手最后一次 ACK 丢失会怎样

24.1 用生活类比先建立直觉

第三次握手 ACK 就像你对服务员说"确认订位"那句话。如果这句话半路丢了

  • 你(客户端)说完就以为订成功了,开开心心等着;
  • 服务员(服务端)没听到确认,还在预订本上留着你的位子(半连接队列),过一会儿再打电话问你"还订吗"(重传 SYN+ACK)。

24.2 工程要点

客户端发出第三次 ACK 后立即进入 ESTABLISHED;服务端没收到这个 ACK,仍停留在 SYN_RCVD,继续占用半连接队列并重传 SYN+ACK(Linux 默认 net.ipv4.tcp_synack_retries=5 次,指数退避约 1 分钟)。

sequenceDiagram
    participant C as 客户端
    participant S as 服务端
    C->>S: SYN
    S->>C: SYN+ACK
    C->>S: ACK (第三次, 但丢失!)
    Note over C: 客户端进入 ESTABLISHED
    Note over S: 仍在 SYN_RCVD
    S-->>C: 重传 SYN+ACK (tcp_synack_retries)
    alt 客户端随后发数据
        C->>S: 数据(带ACK)
        Note over S: 收到带ACK的数据, 握手完成 → ESTABLISHED
    else 客户端一直空闲
        S-->>C: 重传耗尽, 丢弃半连接
        Note over S: 半连接释放
        C->>S: 首次发数据
        S-->>C: RST (连接根本没建立)
    end

两种结局:

  1. 客户端随后发数据(最常见):服务端收到"带 ACK 的数据包"后判定第三次握手完成,双方进入 ESTABLISHED——连接其实建成功了,只是服务端晚一点才确认。
  2. 客户端一直空闲:服务端重传耗尽后丢弃半连接;客户端却以为连上了,等它首次发数据时,服务端回 RST(因为根本没这条连接)。

与 SYN Flood 的关系: 第三次 ACK 丢失会让服务端在 SYN_RCVD 滞留更久、半连接队列占用时间更长——这正是 SYN Flood 利用的同一薄弱环节(伪造 SYN 让队列长期被占)。SYN Cookie(第十章)同样缓解此场景:不预占队列,靠第三次 ACK 里的 cookie 校验才真正建连。

下面用 Go 模拟这个状态机,直观看"第三次 ACK 丢失"两种结局:

package main

import "fmt"

// 模拟三次握手状态机:观察"第三次 ACK 丢失"的两种结局
func main() {
	const synackRetries = 5 // 对应 tcp_synack_retries

	// 场景A:客户端建连后立刻发数据
	fmt.Println("=== 场景A:第三次 ACK 丢失,但客户端随后发数据 ===")
	serverState := "SYN_RCVD" // 服务端没收到第三次 ACK
	fmt.Println("服务端状态:", serverState, "(停留在半连接队列)")
	// 客户端发来"带 ACK 的数据"
	serverState = "ESTABLISHED" // 收到数据即完成握手
	fmt.Println("客户端发数据 → 服务端判定握手完成, 状态:", serverState)
	fmt.Println("结果: 连接建立成功 ✅")

	// 场景B:客户端建连后一直空闲
	fmt.Println("\n=== 场景B:第三次 ACK 丢失,且客户端一直空闲 ===")
	serverState = "SYN_RCVD"
	retransmits := 0
	for retransmits < synackRetries {
		retransmits++
		fmt.Printf("  服务端重传 SYN+ACK 第 %d 次...\n", retransmits)
	}
	serverState = "CLOSED" // 重传耗尽, 丢弃半连接
	fmt.Println("重传耗尽, 服务端释放半连接, 状态:", serverState)
	fmt.Println("结果: 客户端首次发数据时被 RST ❌ (连接从未真正建立)")
}

⚠️ 新手必踩的坑: 很多"连接偶尔连不上"其实是这第三种情况——客户端认为连上了、服务端却早丢了半连接,表现成"第一次请求超时/RST,重试就成功"。排查时看服务端 SYN_RCVD 数量与 net.ipv4.tcp_synack_retries,并确认中间网络设备没把 ACK 悄悄丢了(参见第二十五章排查思路)。


二十五、客户端连接不上服务端的排查思路

25.1 用生活类比先建立直觉

“打电话打不通"要逐层排查:是对方关机了(服务没起)?信号差/断线(网络不通)?拨错号码(DNS/端口错)?总机转不过去(防火墙/安全组挡了)?还是对方听不懂你的话(应用层协议错)?不要一上来就改代码——先用工具定位是哪一层的问题

25.2 工程要点

分层排查流程(从下往上,哪层断就在哪层修):

graph TD
    D[连接失败] --> A[DNS 对吗?
nslookup/dig] A -->|解析错| A1[改域名/改 /etc/hosts] A -->|解析对| B[网络通吗?
ping/traceroute] B -->|不通| B1[路由/交换机/链路问题] B -->|通| C[端口在监听吗?
ss -lnt] C -->|没监听| C1[服务进程没起/崩了] C -->|监听| E[端口能连吗?
telnet/nc/curl] E -->|连不上| E1[防火墙/安全组/iptables] E -->|连得上| F[应用层对吗?
curl 看响应] F -->|协议错| F1[服务逻辑/TLS 配置]

常用工具与命中问题:

工具作用排查哪层
ping / traceroute测 IP 连通性与路由跳点网络层
nslookup / dig查域名解析出的 IPDNS(应用/网络边界)
telnet <ip> <port> / nc测 TCP 端口是否可达传输层
curl -v测 HTTP/HTTPS 应用层与 TLS应用层
ss -lnt / netstat -lnt看本机端口监听与队列传输层(服务端)
ss -ant / netstat -ant看连接状态(SYN_SENT/CLOSE_WAIT 等)传输层状态
tcpdump / Wireshark抓包看握手是否完成全层(终极手段)
安全组 / iptables云/主机防火墙规则传输层(中间设备)

下面用 Go 写一个连接自检工具,逐级报告失败在哪一步(DNS → TCP → HTTP),copy 即用:

package main

import (
	"fmt"
	"io"
	"net"
	"net/http"
	"time"
)

// 分步探测:DNS -> TCP 建连 -> HTTP 响应,哪步失败一目了然
func diagnose(host, port string) {
	addr := host + ":" + port
	url := "http://" + addr

	// 步骤1:DNS 解析
	ips, err := net.LookupIP(host)
	if err != nil {
		fmt.Printf("[DNS] 失败 ❌ %v  → 检查域名/本机 DNS 配置\n", err)
		return
	}
	fmt.Printf("[DNS] 解析 %s -> %v ✅\n", host, ips)

	// 步骤2:TCP 建连(三次握手)
	conn, err := net.DialTimeout("tcp", addr, 3*time.Second)
	if err != nil {
		fmt.Printf("[TCP] 连 %s 失败 ❌ %v → 检查端口监听/防火墙/安全组\n", addr, err)
		return
	}
	fmt.Printf("[TCP] 三次握手 %s 成功 ✅\n", addr)
	conn.Close()

	// 步骤3:HTTP 应用层
	resp, err := http.Get(url)
	if err != nil {
		fmt.Printf("[HTTP] 请求 %s 失败 ❌ %v → 检查服务逻辑/TLS/路径\n", url, err)
		return
	}
	defer resp.Body.Close()
	body, _ := io.ReadAll(io.LimitReader(resp.Body, 200))
	fmt.Printf("[HTTP] 状态码 %d ✅ body前200字节: %s\n", resp.StatusCode, body)
}

func main() {
	diagnose("example.com", "80")
}

⚠️ 新手必踩的坑: 云上"连不上"八成是安全组/防火墙——服务本机 ss -lnt 明明在监听、本机 curl 也通,但外部就是连不上,多半是入站规则没放行该端口。还有一类坑:服务只监听了 127.0.0.1(回环),外部 IP 自然连不上,必须监听 0.0.0.0 或具体网卡地址。


二十六、Linux 服务器最大并发连接数

26.1 用生活类比先建立直觉

服务器能同时服务多少连接,像一家餐厅最多能同时招待多少桌客人——受三件事限制:① 碗筷数量(文件描述符 fd,每桌要一套);② 餐厅面积(内存,每桌占点空间);③ 门口排队区大小(TCP 半/全连接队列与端口相关参数)。哪个先到上限,并发就卡在哪。

26.2 工程要点

关键限制因素:

限制项是什么怎么看 / 怎么调
文件描述符(fd)每个 TCP 连接占 1 个 fd;fd 耗尽就无法建新连接进程级 ulimit -n;系统级 /proc/sys/fs/file-max
内存每个连接内核约占 3~10KB(缓冲区等)10 万连接约 300MB~1GB;调小 tcp_rmem/tcp_wmem 可省内存
端口范围仅客户端连出站时受 ephemeral port 限制(约 2.8 万);服务端监听单端口可 accept 海量连接,不受 65535 限制客户端调 net.ipv4.ip_local_port_range
tcp_max_syn_backlog半连接队列上限高并发建连时调大,配 SYN Cookie
somaxconn / listen backlog全连接队列上限listen() 的 backlog 与系统 somaxconn 取小值
tcp_tw_reuse复用 TIME_WAIT 端口客户端短连接高频场景开启,缓解端口耗尽

关键澄清:“65535 端口"限制只针对客户端发起连接(源端口有限)。服务端用一个固定端口(如 80)accept,理论上可服务"fd 上限 × 内存上限"个并发连接,远不止 65535。

C10K / C10M: C10K(单机 1 万并发)靠 epoll/kqueue 多路复用解决(第九章、二十章);C10M(单机 1 千万并发)进一步要求内核旁路/用户态协议栈(如 DPDK、eBPF)、零拷贝、巨页、NUMA 亲和——把内核协议栈的锁与拷贝开销绕开。Go 的 goroutine-per-connection 让"写 C10K 级服务"极其简单,但仍是 epoll 之上,到 C10M 量级仍需内核级优化。

下面用 Go 读取系统瓶颈参数,并起一个会一直 accept 的连接计数器,直观感受 fd 上限:

package main

import (
	"fmt"
	"net"
	"os"
	"syscall"
)

// 步骤1:读取系统级 fd 上限(file-max)
func systemFileMax() string {
	b, err := os.ReadFile("/proc/sys/fs/file-max")
	if err != nil {
		return "无法读取(非Linux?)"
	}
	return string(b)
}

func main() {
	fmt.Println("系统最大 fd (file-max):", systemFileMax())

	// 步骤2:读取进程级 fd 软限制(ulimit -n)
	var lim syscall.Rlimit
	_ = syscall.Getrlimit(syscall.RLIMIT_NOFILE, &lim)
	fmt.Printf("进程 fd 限制: 软=%d 硬=%d\n", lim.Cur, lim.Max)

	// 步骤3:起一个监听,持续 accept 并计数,观察何时因 fd 耗尽而报错
	ln, err := net.Listen("tcp", ":18080")
	if err != nil {
		fmt.Println("监听失败:", err)
		return
	}
	defer ln.Close()
	fmt.Println("开始 accept 连接(Ctrl+C 停止),每连一个消耗 1 个 fd...")

	count := 0
	for {
		conn, err := ln.Accept()
		if err != nil {
			// 常见报错: too many open files = fd 耗尽(受 ulimit -n 限制)
			fmt.Println("accept 失败,可能已达 fd 上限:", err)
			return
		}
		count++
		if count%1000 == 0 {
			fmt.Printf("当前并发连接数: %d\n", count)
		}
		_ = conn // 真实服务里应有 goroutine 处理并 defer Close
	}
}

⚠️ 新手必踩的坑: 想撑高并发,先确认三件事——ulimit -n 够大、fs.file-max 够大、内存够。还有个隐蔽坑:TIME_WAIT 堆积会占用 fd(主动关闭方),高并发短连接下要么用长连接、要么开 tcp_tw_reuse、要么让客户端侧主动关闭,别让服务端当主动关闭方。


二十七、自测题与动手练习

自测题

  1. TCP 三次握手为什么是三次而不是两次? 请从防止历史连接的角度解释。如果只有两次握手会出现什么问题?

  2. TCP 四次挥手为什么是四次? 什么是"半关闭”?TIME_WAIT 状态为什么要等待 2MSL 而不是立即关闭?

  3. HTTP/2 的多路复用解决了 HTTP/1.1 的什么问题? HTTP/2 在 TCP 层是否还存在队头阻塞?HTTP/3 是如何解决的?

  4. gRPC 为什么选择基于 HTTP/2 而不是直接基于 TCP? Protobuf 相比 JSON 有哪些优势和劣势?

  5. 一致性哈希的虚拟节点解决了什么问题? 如果不使用虚拟节点,在节点数量较少时会出现什么情况?

  6. IO 多路复用中 select、poll、epoll 的核心区别是什么? 为什么 epoll 在海量连接下性能远优于 select?Go 的 netpoll 是如何利用 epoll 实现"每连接一 goroutine"的高并发的?

  7. 什么是 SYN Flood 攻击? 它利用的是 TCP 握手过程中的哪个薄弱环节?SYN Cookie 是如何在不占用半连接队列的情况下防御的?

  8. 服务器出现大量 CLOSE_WAIT 通常说明什么问题? 它与 TIME_WAIT 有何本质区别?请给出排查步骤和根治方法。

  9. OSI 七层模型与 TCP/IP 四层模型如何对应? 请说出网络层、传输层、应用层各自解决的核心问题,以及 IP、TCP、HTTP 分别工作在哪一层。

  10. 从输入 URL 到页面展示,中间经历了哪些关键步骤? 其中 DNS 解析、TCP 握手、TLS 握手各自的作用是什么?为什么 HTTP/3 能降低建连延迟?

  11. 什么是 TCP 粘包?应该如何在应用层拆包? 试比较定长、分隔符、长度字段三种方案的优劣,并说明 bufio.Reader + io.ReadFull 为什么能解决半包/粘包。

  12. gRPC 相比直接用 TCP 自定义协议,高性能来自哪几层? 请从 HTTP/2 多路复用、Protobuf 二进制编码、Stub 代码生成三个角度说明;如果你要自己从零设计一个 RPC 框架,必须解决哪四件事(服务注册 / 序列化 / 网络传输 / Stub)?

  13. TLS 1.2 与 TLS 1.3 握手各需要几轮往返? 证书链为什么要从叶子一路验到根 CA?CRL 与 OCSP 解决的是什么问题,为什么需要它们?

  14. Linux 单机最大并发连接数受哪些因素限制? 为什么说"65535 端口"限制只针对客户端?如果一台服务端出现 too many open files,应从哪几个参数排查?

动手练习

  1. 抓包分析 TCP 全过程: 用 Go 写一个 TCP echo 服务和客户端,用 Wireshark 抓取完整通信过程,标注三次握手、数据传输、四次挥手的每一个数据包,截图说明每个包的标志位和序号变化。

  2. ETCD 服务注册与发现 Demo:go.etcd.io/etcd/client/v3 实现一个完整的服务注册与发现系统。要求:服务启动时自动注册,每隔 3 秒续约,消费者通过 Watch 实时感知服务上下线,模拟服务宕机后 10 秒内消费者收到下线通知。

  3. 一致性哈希负载均衡器: 实现一个支持虚拟节点的一致性哈希负载均衡器。要求:支持动态增删节点,测试在 3 个节点增加到 4 个节点时 key 的迁移比例(理论值应接近 1/4,而非全部重新分配)。


二十八、本章小结

本章从 TCP 传输层出发,沿着"连接管理 → 可靠传输 → 应用层协议 → 服务治理"的脉络,系统讲解了网络通信的核心知识:

  1. TCP 是可靠传输的基石:三次握手建立全双工连接(防历史连接),四次挥手分别关闭两个方向(半关闭),TIME_WAIT 保证最终 ACK 到达并防止旧报文干扰。

  2. 滑动窗口与拥塞控制保障效率:发送窗口 = min(cwnd, rwnd),既不超出接收方处理能力,也不压垮网络。拥塞控制四件套(慢启动、拥塞避免、快重传、快恢复)动态调节发送速率。

  3. HTTP 从 1.0 到 3 持续演进:短连接到长连接到多路复用到 QUIC/UDP,核心驱动是降低延迟和提高并发。gRPC 基于 HTTP/2 + Protobuf,在微服务内部通信中提供高性能、强类型、流式传输能力。

  4. 服务发现实现动态治理:注册中心(ETCD)通过 TTL 租约 + Watch 机制实现服务实例的自动注册与发现,比静态配置文件更适应云原生环境的动态扩缩容。

  5. 负载均衡与一致性哈希解决流量分配:客户端发现直连效率高,服务端发现通过网关简化客户端。一致性哈希通过虚拟节点保证节点增删时最小化数据迁移,是分布式缓存和路由的基石。

  6. IO 多路复用是高并发网络的基础:select/poll 全量遍历、epoll 只返回就绪;Go netpoll 在 epoll 之上封装出"阻塞式写法 + goroutine 挂起唤醒"的并发模型,让少量 OS 线程服务海量连接。

  7. TCP 异常多源于状态机卡点与资源未释放:SYN Flood 打满半连接队列(SYN Cookie 防御),CLOSE_WAIT 暴涨则是被动关闭方忘记 close 的连接泄漏——根因在应用层,调内核参数无效。

复习提示:
  • TCP 三次握手防历史连接:SYN 中携带 ISN,确保旧连接的重复报文被丢弃。
  • HTTP/2 多路复用 ≠ 更快:它消除队头阻塞,但单连接速度不提升;真正提速来自头部压缩和服务器推送。
  • gRPC 不适合公共 API:Protobuf 不向后兼容、二进制格式难调试,对外接口优先选 JSON + REST。
  • 服务发现的冷启动问题:新节点注册后需要时间预热缓存,直接承接流量可能击穿后端,需配合限流和重试。
About Me

没什么想介绍的,一个很大众的码农…

喜欢代码,车,马,真的是 🐎

讨厌别人让我给自己的代码写注释 最厌烦别人的程序没有写注释

目标

学AI,加油!加油!