golang context canceled异常排查

最近发现线上的golang服务请求时常莫名其妙的因context canceled中断。我们使用的是go-kratos微服务框架,底层http server来自golang官方库。最早怀疑是超时导致,但是打印出的请求时延远低于设定的超时时间,而且是context canceled,而不是context deadline exceeded,超时嫌疑排除。锁定有代码调用了context.WithCancel返回的cancel。

瞬时偶发异常pprof无用,只能逐步排查代码:

  1. 业务代码,未检索到使用context.WithCancel
  2. go-kratos,有使用context.WithTimeout,未使用context.WithCancel
  3. http官方库,有使用到context.WithCancel

嗯,看样子是http官方库的server.go有逻辑触发了cancel了

scss 复制代码
// 官方库go1.24.1 net/http/server.go
func (c *conn) readRequest(ctx context.Context) (w *response, err error) {
	//...
	//line 1083-1085
	ctx, cancelCtx := context.WithCancel(ctx)
	req.ctx = ctx
	req.RemoteAddr = c.remoteAddr

但问题是,这里的cancel通过分析,只有在请求发生panic时才可能触发,详见serve函数实现

scss 复制代码
func (c *conn) serve(ctx context.Context) {

没办法再重新查找线索

因为context可以多层嵌套,如果能弄清楚context的嵌套层次,应该对问题排查有帮助,找chatGpt hack了一个函数,把嵌套的context通通都打印了出来。发现官方库居然嵌套了两次WithCancel(多数嵌套的Context,使用的是WithValue,通过key-value值可以辅助定位)

scss 复制代码
func (c *conn) serve(ctx context.Context) {
	if ra := c.rwc.RemoteAddr(); ra != nil {
		c.remoteAddr = ra.String()
	}
	ctx = context.WithValue(ctx, LocalAddrContextKey, c.rwc.LocalAddr())
	var inFlightResponse *response
	// ...
	ctx, cancelCtx := context.WithCancel(ctx)
	c.cancelCtx = cancelCtx
	defer cancelCtx()

阅读源码可知,http库在完整读取一个请求,实际写入响应前,会预读下一次请求,若发生读取异常,会执行上面的cancel,终止请求。

复现。构造下,使用tcp直接发送http请求,sleep 100毫秒(以确保数据发送)。服务端api sleep 3s,然后再获取request.Context().Err(), 返现返回error: context canceled。

最后一个问题,业务进程与浏览器之间还有一个反向代理,为什么反向代理在发送完请求之后,会立刻关闭socket,因为浏览器到反向代理的socket关闭而进一步触发?

嗯,我用nginx构造触发了上面的context canceled,然后找到了 proxy_ignore_client_abort 参数,默认情况下是关闭的。这个参数的说明是:Determines whether the connection with a proxied server should be closed when a client closes the connection without waiting for a response.

打开这个开关,context canceled消失。

推测:

  1. 反向代理的默认行为,如果客户端中断请求,反向代理将关闭实际转发请求的socket,以中断请求。因为拿到响应也没地儿写了
  2. 开启proxy_ignore_client_abort,若客户端中断请求,反向代理会实际读取完成请求响应,然后再丢弃,以达到socket复用的效果

反向代理的这种行为是否合理?我认为是合理的,新建一个socket的成本要低于业务服务器处理业务的成本

最后贴一下相关源码

nginx配置

ini 复制代码
server {
    listen 10086;
    server_name localhost;  # 请替换为你的域名或服务器IP

    location / {
        proxy_pass http://127.0.0.1:3000;  # 替换为你的后端服务地址

        # 基本的头部转发,保持客户端信息
        proxy_http_version 1.1;
        proxy_ignore_client_abort on;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }

    # 错误页面可选配置
    error_page 502 503 504 /50x.html;
    location = /50x.html {
        root /usr/share/nginx/html;
    }
}

golang server端

go 复制代码
package main

import (
        "context"
        "errors"
        "fmt"
        "log"
        "net/http"
        "time"
)

func helloHandler(w http.ResponseWriter, r *http.Request) {
        // 仅处理 GET 请求
        time.Sleep(time.Second * 3)                                 // 模拟处理延迟
        log.Println(errors.Is(r.Context().Err(), context.Canceled)) // 检查请求是否被取消
        log.Println(r.Context().Err())
        if r.Method != http.MethodGet {
                http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
                return
        }

        // 写入响应
        fmt.Fprintln(w, "Hello, Golang HTTP Server!")
}

func main() {
        log.SetFlags(log.LstdFlags | log.Lshortfile) // 设置日志格式
        // 绑定路径
        http.HandleFunc("/hello", helloHandler)

        // 启动 HTTP 服务器
        fmt.Println("Server is running on http://localhost:3000")
        if err := http.ListenAndServe(":3000", nil); err != nil {
                panic(err)
        }
}

golang客户端

swift 复制代码
package main

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

func main() {
        // 连接到 HTTP 服务器(假设服务器在本地的 3000 端口)
        conn, err := net.Dial("tcp", "localhost:10086")
        if err != nil {
                fmt.Println("连接失败:", err)
                os.Exit(1)
        }
        defer conn.Close()

        // 构造一个简单的 HTTP GET 请求
        request := "GET /hello HTTP/1.1\r\n" +
                "Host: localhost:3000\r\n" +
                "Connection: close\r\n" +
                "\r\n"

        // 发送请求
        _, err = conn.Write([]byte(request))
        if err != nil {
                fmt.Println("发送失败:", err)
                os.Exit(1)
        }
        time.Sleep(100 * time.Millisecond)

        // 读取响应
        // reader := bufio.NewReader(conn)
        // for {
        //      line, err := reader.ReadString('\n')
        //      if err != nil {
        //              break
        //      }
        //      fmt.Print(line)
        // }
}
相关推荐
Livingbody2 分钟前
Transformers Pipeline 加载whisper模型实现语音识别ASR
后端
眠修11 分钟前
Nginx + Tomcat负载均衡群集
nginx·tomcat·负载均衡
WindSearcher24 分钟前
大模型微调相关知识
后端·算法
考虑考虑39 分钟前
Jpa中的@ManyToMany实现增删
spring boot·后端·spring
yuan199971 小时前
Spring Boot 启动流程及配置类解析原理
java·spring boot·后端
洗澡水加冰2 小时前
n8n搭建多阶段交互式工作流
后端·llm
陈随易2 小时前
Univer v0.8.0 发布,开源免费版 Google Sheets
前端·后端·程序员
六月的雨在掘金2 小时前
通义灵码 2.5 | 一个更懂开发者的 AI 编程助手
后端
朱龙凯3 小时前
MySQL那些事
后端
Re2753 小时前
剖析 MyBatis 延迟加载底层原理(1)
后端·面试