学习目标
读完本文,你应该能够:
- 手写 TCP Echo Server/Client,理解网络编程基础
- 实现一个简单的 HTTP Server,解析请求行和头部
- 实现 TCP 粘包/拆包的解决方案(长度前缀、分隔符、定长)
- 理解 DNS Resolver 的工作原理并实现简化版
- 掌握 HTTP/2 流复用和 gRPC 四种通信模式的应用场景
- 回答关于 TCP 三次握手、TIME_WAIT、epoll 等高频原理问题
一、TCP Echo Server/Client
1.1 Echo Server
package main
import (
"bufio"
"fmt"
"net"
)
func handleConnection(conn net.Conn) {
defer conn.Close()
reader := bufio.NewScanner(conn)
for reader.Scan() {
line := reader.Text()
fmt.Fprintf(conn, "ECHO: %s\n", line)
}
}
func main() {
listener, err := net.Listen("tcp", ":8080")
if err != nil {
panic(err)
}
defer listener.Close()
fmt.Println("Echo server started on :8080")
for {
conn, err := listener.Accept()
if err != nil {
fmt.Printf("accept error: %v\n", err)
continue
}
go handleConnection(conn)
}
}
1.2 Echo Client
应用场景
在实际项目中,这个知识点经常应用于:
- 典型场景一:说明具体应用场景
- 典型场景二:说明另一个应用场景
- 面试要点:提醒面试中可能被问到的点
package main
import (
"bufio"
"fmt"
"net"
"os"
)
func main() {
conn, err := net.Dial("tcp", "localhost:8080")
if err != nil {
panic(err)
}
defer conn.Close()
go func() {
serverReader := bufio.NewScanner(conn)
for serverReader.Scan() {
fmt.Println(serverReader.Text())
}
}()
reader := bufio.NewScanner(os.Stdin)
for reader.Scan() {
fmt.Fprintf(conn, "%s\n", reader.Text())
}
}
二、TCP 粘包/拆包处理(中兴必考!)
2.1 问题根源
TCP 是字节流协议,没有消息边界。多次 write 可能被合并发送,一次 read 可能读到多个消息。
2.2 方案一:长度前缀编码
type LengthPrefixCodec struct{}
func (c *LengthPrefixCodec) Encode(msg []byte) ([]byte, error) {
// 4字节大端整数 + 消息体
length := len(msg)
header := make([]byte, 4)
binary.BigEndian.PutUint32(header, uint32(length))
return append(header, msg...), nil
}
func (c *LengthPrefixCodec) Decode(conn net.Conn) ([]byte, error) {
// 先读4字节头
header := make([]byte, 4)
if _, err := io.ReadFull(conn, header); err != nil {
return nil, err
}
length := int(binary.BigEndian.Uint32(header))
// 再读消息体
body := make([]byte, length)
if _, err := io.ReadFull(conn, body); err != nil {
return nil, err
}
return body, nil
}
2.3 方案二:分隔符编码
type DelimiterCodec struct {
delimiter []byte
}
func (c *DelimiterCodec) Encode(msg []byte) []byte {
return append(msg, c.delimiter.
### 应用场景
在实际项目中,这个知识点经常应用于:
1. **典型场景一**:说明具体应用场景
2. **典型场景二**:说明另一个应用场景
3. **面试要点**:提醒面试中可能被问到的点
..)
}
func (c *DelimiterCodec) Decode(data []byte) [][]byte {
return bytes.Split(data, c.delimiter)
}
2.4 方案三:定长编码
const msgSize = 1024
type FixedLengthCodec struct{}
func (c *FixedLengthCodec) Encode(msg []byte) ([]byte, error) {
if len(msg) > msgSize {
return nil, fmt.Errorf("message too large")
}
buf := make([]byte, msgSize)
copy(buf, msg)
return buf, nil
}
func (c *FixedLengthCodec) Decode(data []byte) ([]byte, error) {
### 应用场景
在实际项目中,这个知识点经常应用于:
1. **典型场景一**:说明具体应用场景
2. **典型场景二**:说明另一个应用场景
3. **面试要点**:提醒面试中可能被问到的点
// 去除尾部空字节
return bytes.TrimRight(data, "\x00"), nil
}
复习提示:生产环境推荐:长度前缀编码最通用,HTTP/Protobuf/TCP 网关都使用此方案。
分隔符适合文本协议(如 SMTP),定长适合固定格式数据。
三、简易 HTTP Server
type Server struct {
mux *ServeMux
handler Handler
}
type ServeMux struct {
m map[string]Handler
mu sync.RWMutex
}
func (mux *ServeMux) Handle(pattern string, handler Handler) {
mux.mu.Lock()
defer mux.mu.Unlock()
mux.m[pattern] = handler
}
func (mux *ServeMux) Find(pattern string) (Handler, bool) {
mux.mu.RLock()
defer mux.mu.RUnlock()
if h, ok := mux.m[pattern]; ok {
return h, true
}
// 简单通配符支持
for p, h := range mux.m {
if strings.HasSuffix(p, "/*") {
if strings.HasPrefix(pattern, strings.TrimSuffix(p, "*")) {
return h, true
}
}
}
return nil, false
}
type Request struct {
Method string
Path string
Header http.Header
Body []byte
}
type Response struct {
StatusCode int
Header http.Header
Body []byte
}
func (s *Server) ServeConn(conn net.Conn) {
defer conn.Close()
reader := bufio.NewReader(conn)
method, path, _ := readRequestLine(reader)
header := readHeaders(reader)
body, _ := io.ReadAll(reader)
req := &Request{Method: method, Path: path, Header: header, Body: body}
resp := s.mux.Dispatch(req)
writeResponse(conn, resp)
}
func (mux *ServeMux) Dispatch(req *Request) *Response {
handler, ok := mux.Find(req.Path)
if !ok {
return &Response{StatusCode: 404, Body: []byte("Not Found")}
}
resp := handler(req)
return resp
}
四、DNS Resolver 简化版
type DNSResolver struct {
server string // DNS服务器地址
}
func NewDNSResolver(server string) *DNSResolver {
return &DNSResolver{server: server}
}
func (r *DNSResolver) Resolve(domain string) ([]net.IP, error) {
// 构造 DNS 查询报文
query := r.buildQuery(domain)
// 发送到 DNS 服务器
conn, err := net.Dial("udp", r.server+":53")
if err != nil {
return nil, err
}
defer conn.Close()
if _, err := conn.Write(query); err != nil {
return nil, err
}
// 读取响应
reply := make([]byte, 512)
if _, err := conn.Read(reply); err != nil {
return nil, err
}
return r.parseResponse(reply)
}
func (r *DNSResolver) buildQuery(domain string) []byte {
// DNS 报文格式:ID(2) + Flags(2) + QDCount(2) + ANCount(2) + NSCount(2) + ARCount(2) + Question...
header := make([]byte, 12)
binary.BigEndian.PutUint16(header[0:], 0x0100) // standard query, recursion desired
binary.BigEndian.PutUint16(header[4:], 1) // 1 question
// 编码域名
var qd []byte
for _, label := range strings.Split(domain, ".") {
qd = append(qd, byte(len(label)))
qd = append(qd, []byte(label)...)
}
qd = append(qd, 0) // 终止符
header = append(header, qd...)
header = append(header, 0, 1) // QTYPE=A, QCLASS=IN
return header
}
func (r *DNSResolver) parseResponse(reply []byte) ([]net.IP, error) {
// 解析响应中的 Answer 部分
// 简化版:实际解析较为复杂
var ips []net.IP
// ... 省略详细解析逻辑
return ips, nil
}
五、ICMP Ping 工具
type Ping struct {
id int
seq int
Conn net.PacketConn
}
func (p *Ping) Send(target string) error {
addr, err := net.ResolveIPAddr("ip4:icmp", target)
if err != nil {
return err
}
msg := make([]byte, 8)
msg[0] = 8 // ICMP Echo Request
msg[1] = 0 // Code
binary.BigEndian.PutUint16(msg[2:], uint16(p.id))
binary.BigEndian.PutUint16(msg[4:], uint16(p.seq))
checksum := checksum(msg)
binary.BigEndian.PutUint16(msg[6:], checksum)
_, err = p.Conn.WriteTo(msg, addr)
return err
}
func checksum(data []byte) uint16 {
sum := uint32(0)
for i := 0; i < len(data)-1; i += 2 {
sum += uint32(data[i])<<8 | uint32(data[i+1])
}
if len(data)%2 == 1 {
sum += uint32(data[len(data)-1]) << 8
}
for sum > 0xffff {
sum = (sum >> 16) + (sum & 0xffff)
}
return uint16(^sum)
}
六、TCP 网络原理追问
6.1 三次握手为什么不能两
应用场景
在实际项目中,这个知识点经常应用于:
- 典型场景一:说明具体应用场景
- 典型场景二:说明另一个应用场景
- 面试要点:提醒面试中可能被问到的点
次?
面试官
如果只有两次握手会发生什么问题?面试官
候选人
假设只有两次握手:
- 客户端发 SYN
- 服务端回 ACK
此时服务端认为连接已建立,但客户端可能还没收到 SYN-ACK。
如果客户端的 SYN 丢失,客户端不会重传(因为没有确认机制),而服务端会一直等待。
三次握手的核心目的是双方都能确认对方的收发能力正常。
TCP 连接状态机:三次握手 + 四次挥手
看图要点:面试必问「为什么握手 3 次、挥手 4 次」。握手 3 次是为了双方都确认对方的收发能力;挥手 4 次是因为 TCP 全双工,被动方收到 FIN 后只能先回 ACK(此时还能发剩余数据),等自己也没数据了再发 FIN。主动关闭方最后进入 TIME_WAIT 保持 2MSL,防止最后 ACK 丢失导致连接未关闭、并让网络中旧报文消散。追问:高并发下 TIME_WAIT 过多会占端口,可用
SO_REUSEADDR或调小tcp_max_tw_buckets缓解。
6.2 TIME_WAIT 的作用
主动关闭方状态变化:
FIN_WAIT_1 → FIN_WAIT_2 → TIME_WAIT(2MSL) → CLOSED
被动关闭方状态变化:
CLOSE_WAIT → LAST_ACK → CLOSED
2MSL 的原因:
- 确保最后一个 ACK 能到达服务端
- 让本连接持续时间内产生的所有报文都从网络中消失
6.3 epoll 为什么快
// Go netpoller 伪代码
func epollWait(events []EpollEvent, maxEvents int, timeout int) int {
return syscall.EpollWait(epfd, events, maxEvents, timeout)
}
// 事件驱动模型
f
### 应用场景
在实际项目中,这个知识点经常应用于:
1. **典型场景一**:说明具体应用场景
2. **典型场景二**:说明另一个应用场景
3. **面试要点**:提醒面试中可能被问到的点
unc poll() {
for {
n := epollWait(activeEvents, maxEvents, -1)
for i := 0; i < n; i++ {
fd := activeEvents[i].Fd
if activeEvents[i].Events&EPOLLIN != 0 {
// 可读事件
dispatchRead(fd)
}
if activeEvents[i].Events&EPOLLOUT != 0 {
// 可写事件
dispatchWrite(fd)
}
}
}
}
复习提示:
epoll vs select/poll:
- select/poll:每次调用 O(n) 遍历所有 fd
- epoll:O(1) 获取就绪 fd(红黑树注册 + 就绪链表返回)
- epoll 支持边缘触发(ET)和水平触发(LT)
七、HTTP/2 流复用概念
// HTTP/2 帧结构简化演示
type Frame struct {
Length uint32
Type byte // 0:DATA, 1:HEADERS, 2:PRIORITY, etc.
Flags byte
StreamID uint32
Payload []byte
}
// 多路复用的核心思想
// 一个 TCP 连接上并发多个 Stream
// 每个 Stream 有自己的 Stream ID
// 通过 Frame 头部标识属于哪个 Stream
八、gRPC 四种通信模式
| 模式 | 描述 | 典型场景 |
|---|---|---|
| Unary | 单向请求响应 | CRUD 操作 |
| Server Streaming | 服务端推送流 | 实时数据流、日志订阅 |
| Client Streaming | 客户端推送流 | 大数据批量上传 |
| Bidirectional Streaming | 双向流 | 聊天室、游戏同步 |
九、自测题
面试官
应用层心跳包 vs TCP keepalive 有什么区别?怎么选?面试官
候选人
TCP keepalive:内核级功能,默认2小时检测一次,粒度粗。
应用层心跳:自定义协议,可配置间隔,携带业务信息。
建议:需要快速检测连接失效时用应用层心跳;普通长连接用 TCP keepalive 即可。
建议:需要快速检测连接失效时用应用层心跳;普通长连接用 TCP keepalive 即可。
面试官
如何实现一个带连接池的 HTTP Client?面试官
候选人
核心要素:
MaxIdleConns:空闲连接最大数IdleConnTimeout:空闲超时时间Transport:复用底层 TCP 连接- 健康检查:定期检测连接是否有效
Go 标准库的http.Transport已实现大部分功能,直接使用即可。
十、本章小结
复习提示:
- TCP 粘包是网络编程的核心问题,长度前缀是最通用的解决方案
- epoll 比 select/poll 快的关键在于 O(1) 事件分发,Go 的 netpoller 在此基础上包装
- HTTP/2 流复用解决了 HTTP/1.1 的队头阻塞问题,QUIC 进一步解决了传输层队头阻塞
- 下一篇将讲解存储系统实现:B+树、MVCC、连接池、Redis Client 等
参考
- 《Go 网络编程》第 3 章:TCP 粘包处理
- TCP/IP 详解卷一:第 18-20 章(TCP 状态机)
- epoll 源码:https://man7.org/linux/man-pages/man2/epoll_wait.2.html