02 · 手写 Web 容器:从 Socket 到 MVC
“Web 框架的核心,就是’URL 到函数的映射’加’一串中间件’。“
开场:Tomcat 做了什么
小林:MiniTomcat 那门课要写 Servlet 容器。但 Go 已经有
net/http了,为什么还要自己写?老陈:你用
net/http写过一个 hello world 吗?小林:写过,三行。
老陈:那我问你:
http.ListenAndServe内部做了什么?小林:……监听端口,accept 连接,处理请求?
老陈:具体点。HTTP 报文是怎么被解析的?路由是怎么匹配的?keep-alive 是怎么处理的?
小林:……不知道。
老陈:这就是问题。 你每天都在用 HTTP,但你不知道:
- 一个 HTTP 请求报文长什么样
- 为什么
Content-Length和Transfer-Encoding不能同时出现- 为什么有些路由用 radix tree 而有些用 map
- 为什么 Go 的
http.Server要单独设置 ReadTimeout / WriteTimeout这些知识在你排查”请求卡住了”、“连接泄漏了”、“路由 404 了”这些问题时,是必需的。
第一部分:HTTP 协议
请求报文格式
GET /api/users?id=123 HTTP/1.1\r\n ← 请求行
Host: example.com\r\n ←
User-Agent: curl/7.68.0\r\n │
Accept: */*\r\n ├ 请求头
Content-Length: 25\r\n │
\r\n ← 空行(分隔头部和 body)
{"name":"alice","age":30} ← 请求体(可选)
请求行的三个部分:
GET /api/users?id=123 HTTP/1.1
│ │ │
│ │ └─ 协议版本
│ └─ 请求 URI(含 query string)
└─ 方法(GET/POST/PUT/DELETE/...)
响应报文格式
HTTP/1.1 200 OK\r\n
Content-Type: application/json\r\n
Content-Length: 26\r\n
\r\n
{"id":123,"name":"alice"}
★ 为什么 Content-Length 和 Transfer-Encoding 不能同时用
这是 HTTP 最容易出错的地方之一。
两种确定 body 长度的方式:
① Content-Length: 直接告诉对方 body 有多少字节
Content-Length: 25
→ 接收方读满 25 字节就认为消息结束
② Transfer-Encoding: chunked: 分块传输
4\r\n ← 第一块 4 字节
Wiki\r\n
5\r\n ← 第二块 5 字节
pedia\r\n
0\r\n ← 0 表示结束
\r\n
→ 发送方不需要提前知道总长度(适合流式数据)
为什么不能同时用?
如果两个都发,接收方不知道该信哪个
→ 如果信 Content-Length,chunked 的数据会被截断或解析错误
→ 如果信 chunked,Content-Length 就是多余且矛盾的
★ HTTP/1.1 规范明确规定:两者不能同时出现
★ 如果同时出现,必须以 Transfer-Encoding 为准,
并且忽略 Content-Length(RFC 7230 3.3.3)
★ 这也是"请求走私"(Request Smuggling)攻击的原理:
前端代理信 Content-Length,后端服务器信 chunked
→ 两者对"请求边界"的理解不同
→ 攻击者可以"夹带"一个请求进去
HTTP/1.1 的 keep-alive
HTTP/1.0: 每个请求都要新建 TCP 连接(三次握手 + 慢启动)
HTTP/1.1: 默认 keep-alive,一个连接可以复用
Connection: keep-alive ← 默认
Connection: close ← 告诉对方"处理完就关掉"
keep-alive 的问题:队头阻塞(Head-of-Line Blocking)
一个 TCP 连接上,请求必须串行:
请求1 → 响应1 → 请求2 → 响应2 → ...
如果请求1 很慢(比如后端处理了 5 秒)
→ 请求2 即使只需要 1ms,也要等 5 秒
HTTP/2 的解法:多路复用(Multiplexing)
一个连接上可以并发多个"流"(Stream):
流1: 请求1 ─────────► 响应1
流2: 请求2 ──► 响应2
流3: 请求3 ──────────────► 响应3
★ 互不阻塞
第二部分:手写 HTTP 服务器
版本 1:最简版(不用 net/http)
package main
import (
"bufio"
"fmt"
"io"
"net"
"strings"
"time"
)
type Request struct {
Method string
Path string
Query string
Proto string
Headers map[string]string
Body []byte
}
type Response struct {
StatusCode int
Headers map[string]string
Body []byte
}
type Handler func(req *Request) *Response
// ============ 解析 HTTP 请求 ============
func parseRequest(reader *bufio.Reader) (*Request, error) {
req := &Request{
Headers: make(map[string]string),
}
// 1. 解析请求行
line, err := reader.ReadString('\n')
if err != nil {
return nil, err
}
line = strings.TrimSpace(line)
parts := strings.Split(line, " ")
if len(parts) != 3 {
return nil, fmt.Errorf("请求行格式错误: %q", line)
}
req.Method = parts[0]
req.Proto = parts[2]
// 分离 path 和 query
uri := parts[1]
if idx := strings.Index(uri, "?"); idx >= 0 {
req.Path = uri[:idx]
req.Query = uri[idx+1:]
} else {
req.Path = uri
}
// 2. 解析头部
for {
line, err := reader.ReadString('\n')
if err != nil {
return nil, err
}
line = strings.TrimSpace(line)
if line == "" {
break // ★ 空行表示头部结束
}
idx := strings.Index(line, ":")
if idx < 0 {
continue
}
key := strings.TrimSpace(line[:idx])
value := strings.TrimSpace(line[idx+1:])
req.Headers[strings.ToLower(key)] = value // ★ header 名不区分大小写
}
// 3. 解析 body
if cl, ok := req.Headers["content-length"]; ok {
var length int
fmt.Sscanf(cl, "%d", &length)
if length > 0 {
body := make([]byte, length)
if _, err := io.ReadFull(reader, body); err != nil {
return nil, err
}
req.Body = body
}
}
// TODO: 处理 Transfer-Encoding: chunked
return req, nil
}
// ============ 序列化响应 ============
var statusText = map[int]string{
200: "OK",
201: "Created",
400: "Bad Request",
404: "Not Found",
500: "Internal Server Error",
}
func writeResponse(w io.Writer, resp *Response) error {
var sb strings.Builder
// 状态行
text := statusText[resp.StatusCode]
if text == "" {
text = "Unknown"
}
fmt.Fprintf(&sb, "HTTP/1.1 %d %s\r\n", resp.StatusCode, text)
// 默认头部
if resp.Headers == nil {
resp.Headers = make(map[string]string)
}
if _, ok := resp.Headers["Content-Type"]; !ok {
resp.Headers["Content-Type"] = "text/plain; charset=utf-8"
}
resp.Headers["Content-Length"] = fmt.Sprintf("%d", len(resp.Body))
resp.Headers["Date"] = time.Now().UTC().Format(time.RFC1123)
for k, v := range resp.Headers {
fmt.Fprintf(&sb, "%s: %s\r\n", k, v)
}
sb.WriteString("\r\n") // ★ 空行
if _, err := w.Write([]byte(sb.String())); err != nil {
return err
}
if len(resp.Body) > 0 {
if _, err := w.Write(resp.Body); err != nil {
return err
}
}
return nil
}
// ============ 服务器 ============
type Server struct {
addr string
handler Handler
}
func (s *Server) ListenAndServe() error {
listener, err := net.Listen("tcp", s.addr)
if err != nil {
return err
}
defer listener.Close()
fmt.Printf("服务器启动在 %s\n", s.addr)
for {
conn, err := listener.Accept()
if err != nil {
continue
}
go s.handleConn(conn) // 每个连接一个 goroutine
}
}
func (s *Server) handleConn(conn net.Conn) {
defer conn.Close()
reader := bufio.NewReader(conn)
for {
// ★ 设置读超时(防止慢连接攻击)
conn.SetReadDeadline(time.Now().Add(30 * time.Second))
req, err := parseRequest(reader)
if err != nil {
if err == io.EOF {
return // 客户端关闭连接
}
// 解析错误,返回 400
writeResponse(conn, &Response{
StatusCode: 400,
Body: []byte("Bad Request"),
})
return
}
resp := s.handler(req)
// ★ 根据 Connection 头决定是否保持连接
if strings.EqualFold(req.Headers["connection"], "close") {
resp.Headers["Connection"] = "close"
writeResponse(conn, resp)
return
}
resp.Headers["Connection"] = "keep-alive"
if err := writeResponse(conn, resp); err != nil {
return
}
}
}
// ============ 演示 ============
func main() {
server := &Server{
addr: ":8080",
handler: func(req *Request) *Response {
if req.Path == "/" {
return &Response{
StatusCode: 200,
Headers: map[string]string{"Content-Type": "text/html"},
Body: []byte("<h1>Hello from hand-written server!</h1>"),
}
}
if req.Path == "/echo" && req.Method == "POST" {
return &Response{
StatusCode: 200,
Body: req.Body,
}
}
return &Response{
StatusCode: 404,
Body: []byte("Not Found: " + req.Path),
}
},
}
if err := server.ListenAndServe(); err != nil {
panic(err)
}
}测试:
go run server.go
# 另一个终端
curl -v http://localhost:8080/
curl -X POST -d 'hello' http://localhost:8080/echo★ 四个必须处理的边界情况
① 慢连接攻击(Slowloris)
攻击方式:
客户端建立连接,但极慢地发送请求头(比如每 10 秒发 1 字节)
→ 服务器线程/goroutine 被占住
→ 大量这样的连接耗尽服务器资源
防护:
★ 设置读超时
conn.SetReadDeadline(time.Now().Add(30 * time.Second))
② 请求体过大
攻击方式:
Content-Length: 10000000000 (10GB)
→ 服务器分配 10GB 内存 → OOM
防护:
★ 限制 body 大小
const maxBodySize = 10 << 20 // 10MB
if length > maxBodySize {
return error
}
// 用 io.LimitReader
body, err := io.ReadAll(io.LimitReader(reader, maxBodySize))
③ 头部过大
防护:
reader := bufio.NewReaderSize(conn, 8192) // 限制 buffer 大小
// 或者限制头部总大小
④ 连接泄漏
场景:
keep-alive 连接,客户端不关闭
→ goroutine 一直挂着
→ 大量连接累积
防护:
① 读超时(上面已做)
② 限制总连接数
③ 定期清理空闲连接
老陈:这就是为什么生产环境必须用成熟的 HTTP 服务器,而不是自己写的。
Go 的
http.Server提供了这些保护:srv := &http.Server{ ReadTimeout: 10 * time.Second, // 读整个请求的最大时间 WriteTimeout: 10 * time.Second, // 写响应的最大时间 IdleTimeout: 60 * time.Second, // ★ keep-alive 的空闲超时 ReadHeaderTimeout: 5 * time.Second, // ★ 只读头部的超时 MaxHeaderBytes: 1 << 20, // 1MB }★
IdleTimeout是最容易被忽略的。 不设置的话,空闲的 keep-alive 连接会永远挂着。
第三部分:路由树
为什么不用 map
// 简单但不够
routes := map[string]Handler{
"/users": handler1,
"/users/:id": handler2, // ★ 路径参数,map 做不了
}问题:
- 不支持路径参数(
/users/123) - 不支持前缀匹配(
/static/*) - 无法高效找到”最长匹配”
Radix Tree(压缩前缀树)
普通的 Trie(前缀树):
插入: /user, /users, /user/:id
root
│
u
│
s
│
e
│
r ──── (结束: /user)
│
s ──── (结束: /users)
│
/
│
: ──── (结束: /user/:id)
问题:每个字符一个节点,树很深,浪费空间。
Radix Tree(压缩路径上没有分叉的节点):
插入: /user, /users, /user/:id
root
│
"/user" ← 压缩成一个节点
│
┌────┴────┐
│ │
(结束) "s" ← 继续分叉
│ │
/user (结束)
│
"/"
│
":id"
│
(结束)
优势:
- 节点数大幅减少
- 查找是 O(路径长度),与路由数量无关
- 天然支持前缀匹配和路径参数
Go 实现
package main
import (
"fmt"
"strings"
)
type nodeType int
const (
nodeStatic nodeType = 0 // 静态路径
nodeParam nodeType = 1 // 参数路径 :id
nodeCatchAll nodeType = 2 // 通配路径 *filepath
)
type node struct {
typ nodeType
path string // 这个节点代表的路段
indices string // 子节点首字母索引(加速查找)
children []*node
handler Handler
paramName string // 参数名(param 节点用)
}
type Router struct {
trees map[string]*node // 每个 method 一棵树
}
func NewRouter() *Router {
return &Router{
trees: make(map[string]*node),
}
}
func (r *Router) AddRoute(method, path string, h Handler) {
if r.trees[method] == nil {
r.trees[method] = &node{path: "/"}
}
r.trees[method].insert(path, h)
}
func (n *node) insert(path string, h Handler) {
fullPath := path
for {
// 1. 找到与当前节点 path 的公共前缀
commonLen := 0
maxLen := min(len(n.path), len(path))
for commonLen < maxLen && n.path[commonLen] == path[commonLen] {
commonLen++
}
// 2. 如果公共前缀小于当前节点的路径,需要分裂
if commonLen < len(n.path) {
// 分裂当前节点
child := &node{
typ: n.typ,
path: n.path[commonLen:],
indices: n.indices,
children: n.children,
handler: n.handler,
}
n.path = n.path[:commonLen]
n.indices = string(child.path[0])
n.children = []*node{child}
n.handler = nil
n.typ = nodeStatic
}
// 3. 路径已经完全匹配
if commonLen == len(path) {
n.handler = h
return
}
// 4. 继续处理剩余路径
path = path[commonLen:]
// 查找是否有匹配的子节点
if idx := strings.IndexByte(n.indices, path[0]); idx >= 0 {
n = n.children[idx]
continue
}
// 5. 创建新子节点
// 判断是不是参数节点
typ := nodeStatic
paramName := ""
if path[0] == ':' {
typ = nodeParam
end := strings.IndexByte(path, '/')
if end < 0 {
end = len(path)
}
paramName = path[1:end]
} else if path[0] == '*' {
typ = nodeCatchAll
paramName = path[1:]
}
child := &node{
typ: typ,
path: path,
paramName: paramName,
}
n.indices += string(path[0])
n.children = append(n.children, child)
n = child
}
}
// 查找
type MatchResult struct {
Handler Handler
Params map[string]string
}
func (r *Router) Lookup(method, path string) (*MatchResult, bool) {
root, ok := r.trees[method]
if !ok {
return nil, false
}
params := make(map[string]string)
n := root
remaining := path
for {
if len(remaining) == 0 {
if n.handler != nil {
return &MatchResult{n.handler, params}, true
}
return nil, false
}
// 1. 匹配当前节点的 path
if len(n.path) > 0 {
if !strings.HasPrefix(remaining, n.path) {
return nil, false
}
remaining = remaining[len(n.path):]
if len(remaining) == 0 && n.handler != nil {
return &MatchResult{n.handler, params}, true
}
}
// 2. 找子节点
if len(remaining) == 0 {
return nil, false
}
// 优先精确匹配静态节点
if idx := strings.IndexByte(n.indices, remaining[0]); idx >= 0 {
child := n.children[idx]
if child.typ == nodeStatic {
n = child
continue
}
}
// 其次尝试参数节点
for _, child := range n.children {
if child.typ == nodeParam {
// 提取参数值(到下一个 / 为止)
end := strings.IndexByte(remaining, '/')
if end < 0 {
end = len(remaining)
}
params[child.paramName] = remaining[:end]
rest := remaining[end:]
if len(rest) == 0 && child.handler != nil {
return &MatchResult{child.handler, params}, true
}
// 继续在子树里找
n = child
remaining = rest
if len(n.path) > 0 {
// 跳过 child 自己的 path
}
goto nextIter
}
if child.typ == nodeCatchAll {
params[child.paramName] = remaining
return &MatchResult{child.handler, params}, true
}
}
return nil, false
nextIter:
}
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
// ============ 演示 ============
func demoRouter() {
r := NewRouter()
r.AddRoute("GET", "/", func(req *Request) *Response {
return &Response{200, nil, []byte("首页")}
})
r.AddRoute("GET", "/users", func(req *Request) *Response {
return &Response{200, nil, []byte("用户列表")}
})
r.AddRoute("GET", "/users/:id", func(req *Request) *Response {
return &Response{200, nil, []byte("单个用户")}
})
r.AddRoute("GET", "/users/:id/posts", func(req *Request) *Response {
return &Response{200, nil, []byte("用户的文章")}
})
r.AddRoute("GET", "/static/*filepath", func(req *Request) *Response {
return &Response{200, nil, []byte("静态文件")}
})
tests := []struct{ method, path string }{
{"GET", "/"},
{"GET", "/users"},
{"GET", "/users/123"},
{"GET", "/users/123/posts"},
{"GET", "/static/css/main.css"},
{"GET", "/notfound"},
{"POST", "/users"},
}
for _, t := range tests {
result, ok := r.Lookup(t.method, t.path)
if ok {
resp := result.Handler(nil)
fmt.Printf("%-6s %-25s → %s params=%v\n",
t.method, t.path, resp.Body, result.Params)
} else {
fmt.Printf("%-6s %-25s → 404\n", t.method, t.path)
}
}
}
func main() {
demoRouter()
}输出:
GET / → 首页 params=map[]
GET /users → 用户列表 params=map[]
GET /users/123 → 单个用户 params=map[id:123]
GET /users/123/posts → 用户的文章 params=map[id:123]
GET /static/css/main.css → 静态文件 params=map[filepath:css/main.css]
GET /notfound → 404
POST /users → 404
第四部分:完整的 MVC 框架
把我们学的所有东西组合起来:
type Context struct {
Req *Request
Resp *Response
Params map[string]string
values map[string]interface{}
index int // 中间件链的当前位置
chain []Middleware
}
type Middleware func(c *Context, next func())
type Engine struct {
router *Router
mws []Middleware
pool sync.Pool
}
func New() *Engine {
e := &Engine{router: NewRouter()}
e.pool = sync.Pool{
New: func() interface{} {
return &Context{values: make(map[string]interface{}, 8)}
},
}
return e
}
func (e *Engine) Use(mw ...Middleware) {
e.mws = append(e.mws, mw...)
}
func (e *Engine) GET(path string, h Handler) {
e.router.AddRoute("GET", path, h)
}
func (e *Engine) ServeHTTP(req *Request) *Response {
// 1. 路由匹配
result, ok := e.router.Lookup(req.Method, req.Path)
if !ok {
return &Response{404, nil, []byte("404 Not Found")}
}
// 2. 从池里取 Context
c := e.pool.Get().(*Context)
defer func() {
// 重置并归还
for k := range c.values {
delete(c.values, k)
}
c.index = 0
e.pool.Put(c)
}()
c.Req = req
c.Params = result.Params
c.Resp = &Response{Headers: make(map[string]string)}
// 3. 构建中间件链(全局中间件 + 最终 handler)
c.chain = make([]Middleware, 0, len(e.mws)+1)
c.chain = append(c.chain, e.mws...)
c.chain = append(c.chain, func(c *Context, next func()) {
resp := result.Handler(c.Req)
c.Resp = resp
})
// 4. 执行链
c.Next()
return c.Resp
}
func (c *Context) Next() {
if c.index >= len(c.chain) {
return
}
i := c.index
c.index++
c.chain[i](c, c.Next)
}
// 中间件示例
func Logger() Middleware {
return func(c *Context, next func()) {
start := time.Now()
next()
log.Printf("%s %s cost=%v", c.Req.Method, c.Req.Path, time.Since(start))
}
}
func Recovery() Middleware {
return func(c *Context, next func()) {
defer func() {
if r := recover(); r != nil {
log.Printf("panic: %v\n%s", r, debug.Stack())
c.Resp = &Response{500, nil, []byte("Internal Server Error")}
}
}()
next()
}
}思考题 ·【应用层】
你的 Go Web 服务用的是 gin。某天发现:GET /api/v1/users/:id 这个路由的 P99 延迟突然从 10ms 涨到 500ms,但其他路由正常。CPU 和内存都正常。请分析可能的原因,给出诊断方法。
参考答案
现象的关键
★ 只有一个路由慢,其他正常
★ CPU 和内存正常
这排除了:
· 全局的 GC 问题(会影响所有路由)
· 全局的锁竞争(会影响所有路由)
· CPU 瓶颈
剩下可能:
· 这个路由的处理逻辑本身慢
· 这个路由依赖的某个资源慢
· 路由匹配本身慢(★ 容易忽略)
可能原因 1:路由冲突导致的匹配变慢
这是 gin/httprouter 特有的坑。
场景:你后来加了一个新路由
原有: GET /api/v1/users/:id
新增: GET /api/v1/users/me ← 冲突!
gin 的 radix tree 遇到这种情况会:
· 把 :id 节点分裂
· 或者报 panic: "conflicts with existing wildcard"
★ 如果没 panic,而是走了"回溯"路径:
匹配 /api/v1/users/123 时:
1. 先尝试匹配 "me" → 不匹配
2. 回溯,尝试匹配 :id → 匹配
★ 多了一次回溯
但一次回溯只有几纳秒,不会造成 500ms。
更严重的场景:大量冲突
如果你有这些路由:
/api/v1/users/:id
/api/v1/users/me
/api/v1/users/:id/posts
/api/v1/users/me/posts ← 又一个冲突
...
★ 树的形状会变得复杂,匹配路径变长
★ 但仍然只是微秒级
所以路由冲突不太可能是 500ms 的原因。 但值得检查。
诊断:
// 打印所有路由,检查冲突
routes := router.Routes()
for _, r := range routes {
fmt.Printf("%-6s %s\n", r.Method, r.Path)
}可能原因 2:这个路由的处理逻辑里有串行 IO ★ 最可能
// ❌ 典型问题:N+1 查询
func GetUser(c *gin.Context) {
id := c.Param("id")
// 1. 查用户
user, _ := db.GetUser(id)
// 2. 查用户的订单
orders, _ := db.GetOrders(user.ID)
// 3. ★ 对每个订单查详情
for _, o := range orders {
o.Items, _ = db.GetOrderItems(o.ID) // ★ N 次查询!
}
// 4. 查用户的权限
perms, _ := db.GetPermissions(user.ID)
// 5. 查用户的统计
stats, _ := db.GetStats(user.ID)
c.JSON(200, gin.H{"user": user, "orders": orders})
}问题:5 类查询串行执行,每个 2ms,总共 10ms。看起来还行。
但如果某个用户的订单特别多(比如 1000 个):
- 1000 次
GetOrderItems× 2ms = 2 秒! - 这就是 P99 500ms 的来源——少数”大用户”
诊断:
// 加详细的耗时日志
func GetUser(c *gin.Context) {
start := time.Now()
defer func() {
log.Infof("GetUser total=%v", time.Since(start))
}()
t1 := time.Now()
user, _ := db.GetUser(id)
log.Infof(" GetUser=%v", time.Since(t1))
t2 := time.Now()
orders, _ := db.GetOrders(user.ID)
log.Infof(" GetOrders=%v count=%d", time.Since(t2), len(orders)) // ★ 看 count
t3 := time.Now()
// ...
log.Infof(" GetOrderItems=%v", time.Since(t3))
// ...
}或者用 trace:
import "golang.org/x/net/context"
import "go.opentelemetry.io/otel"
func GetUser(c *gin.Context) {
ctx, span := tracer.Start(c.Request.Context(), "GetUser")
defer span.End()
// 每个子操作也加 span
// → 在 Jaeger 里能看到完整的火焰图
}解决:
方案 A:批量查询(最重要)
// ❌ N+1
for _, o := range orders {
o.Items, _ = db.GetOrderItems(o.ID)
}
// ✅ 批量
orderIDs := make([]int64, len(orders))
for i, o := range orders {
orderIDs[i] = o.ID
}
itemsMap, _ := db.GetOrderItemsBatch(orderIDs) // 一次查询
for _, o := range orders {
o.Items = itemsMap[o.ID]
}方案 B:并行查询(无依赖的操作)
// 用户、权限、统计之间没有依赖 → 可以并行
var wg sync.WaitGroup
var perms []Permission
var stats Stats
wg.Add(2)
go func() {
defer wg.Done()
perms, _ = db.GetPermissions(user.ID)
}()
go func() {
defer wg.Done()
stats, _ = db.GetStats(user.ID)
}()
wg.Wait()
// 10ms → 4ms方案 C:分页 / 限制
// 订单只返回最近 20 条
orders, _ := db.GetOrders(user.ID, 20)
// 或者异步加载详情方案 D:缓存
// 用户信息缓存
func GetUser(id int64) (*User, error) {
key := fmt.Sprintf("user:%d", id)
if cached, ok := cache.Get(key); ok {
return cached.(*User), nil
}
user, err := db.GetUser(id)
if err == nil {
cache.Set(key, user, 5*time.Minute)
}
return user, err
}可能原因 3:慢查询(数据库层面)
场景:
大部分用户的数据量小,查询快
少数用户的数据量大(比如有几万条订单)
→ 索引失效 / 扫描行数暴增
→ 查询从 1ms 变成 500ms
诊断:
-- MySQL 慢查询日志
SHOW VARIABLES LIKE 'slow_query_log';
SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 0.1; -- 100ms
-- 分析
EXPLAIN SELECT * FROM orders WHERE user_id = 123;
-- ★ 看 type、rows、Extra
-- type=ALL → 全表扫描,没有索引!
-- rows=100000 → 扫描 10 万行解决:
-- 加索引
CREATE INDEX idx_orders_user_id ON orders(user_id);
-- 复合索引(如果查询是 WHERE user_id=? AND status=?)
CREATE INDEX idx_orders_user_status ON orders(user_id, status);可能原因 4:下游服务超时
如果这个路由调用了其他微服务:
· 下游服务偶发慢
· 没有超时控制 → 一直等
诊断:
- 链路追踪(Jaeger / SkyWalking)
- 看下游服务的 P99
解决:
// ★ 必须设置超时
ctx, cancel := context.WithTimeout(ctx, 50*time.Millisecond)
defer cancel()
resp, err := client.GetUser(ctx, id)
if err != nil {
if errors.Is(err, context.DeadlineExceeded) {
// 降级:返回部分数据或缓存
return cachedUser, nil
}
return nil, err
}★ 超时预算(Timeout Budget):
总预算 100ms:
├─ 数据库查询: 30ms
├─ 下游服务 A: 30ms
├─ 下游服务 B: 20ms
└─ 缓冲: 20ms
★ 每个环节都要设置超时,且总和 < 总预算
★ 否则级联超时会导致雪崩
可能原因 5:Gin 的 Context 对象池问题(Go 特有)
Gin 用 sync.Pool 复用 Context
★ 如果你在 handler 里保存了 Context 的引用,
并在 handler 返回后继续使用 → 数据竞争 / 读到脏数据
// ❌ 危险
func GetUser(c *gin.Context) {
go func() {
time.Sleep(time.Second)
// ★ 此时 c 已经被放回池子,可能被其他请求复用!
log.Info(c.Param("id")) // 读到的是别人的数据!
}()
c.JSON(200, user)
}
// ✅ 正确:复制一份
func GetUser(c *gin.Context) {
cp := c.Copy() // ★ 返回一个"脱离池子"的副本
go func() {
time.Sleep(time.Second)
log.Info(cp.Param("id")) // 安全
}()
c.JSON(200, user)
}诊断流程(我的建议)
第 1 步: 加详细的耗时日志 / 链路追踪 10 分钟
目的:确认时间花在哪个子操作
第 2 步: 如果是数据库慢 → EXPLAIN 5 分钟
第 3 步: 检查是否有 N+1 查询 15 分钟
第 4 步: 检查下游服务是否设置了超时 10 分钟
第 5 步: 检查是否有"大用户"(数据倾斜) 10 分钟
SELECT user_id, COUNT(*) FROM orders
GROUP BY user_id ORDER BY COUNT(*) DESC LIMIT 10;
一句话总结
“单个路由慢,其他正常”几乎总是”这个路由的数据/依赖有问题”,而不是框架或运行时的问题。
最常见的三个:
- N+1 查询(最常见)
- 数据倾斜(少数用户的数据量特别大)
- 缺少索引或下游缺超时
诊断的关键是把”端到端延迟”拆成”每个子操作的延迟”。 这一步做不到,后面都是猜。
小结:这一节你应该带走的东西
-
HTTP 报文的格式:请求行、头部、空行、body。
Content-Length和Transfer-Encoding不能同时用(这是请求走私的原理)。 -
手写 HTTP 服务器的四个防护:读超时(防 Slowloris)、限制 body 大小、限制头部大小、IdleTimeout(防连接泄漏)。
-
Radix Tree 是路由的标准数据结构:压缩无分叉的路径,查找 O(路径长度),支持参数路径。
-
Web 框架 = 路由树 + 中间件链 + Context。就这么简单。
-
Context 对象池的陷阱:异步使用必须
Copy()。 -
Go 的
http.Server必须设置四个超时:ReadTimeout、WriteTimeout、IdleTimeout、ReadHeaderTimeout。
第 7 章总结
三个核心认知
-
IoC 是思想,DI 是技术。Go 用构造函数注入,因为它有编译期检查、不可变、明确。
-
wire vs dig = 编译期 vs 运行期。这是”把信息提前到编译期”原则在框架层面的体现。
-
框架的本质是”控制反转”:框架调用你,你只填回调。复杂度要么在框架,要么在用户代码——Go 选择了后者。
你现在拥有的
ch07-framework/
├── di/ 反射版 DI 容器(依赖图解析、循环依赖检测)
├── aop/ 中间件链 + 装饰器
├── http/ 手写 HTTP 服务器(协议解析、keep-alive、超时防护)
├── router/ Radix Tree 路由
└── mvc/ 完整的迷你 Web 框架
下一章
最后一章。我们要回答两个问题:
① 什么情况下,我应该自己造?(架构决策的核心判断) ② 音视频系统是怎么工作的?(攻克视频技术 + 搞定音频技术)
老陈的预告:造轮子的时机判断,比造轮子的能力更稀缺。这一章结束,你要能对着一个需求,说出”这里该用什么、为什么、以及什么时候该自己写”。