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)
        // }
}
相关推荐
uzong4 小时前
技术故障复盘模版
后端
GetcharZp4 小时前
基于 Dify + 通义千问的多模态大模型 搭建发票识别 Agent
后端·llm·agent
桦说编程5 小时前
Java 中如何创建不可变类型
java·后端·函数式编程
IT毕设实战小研5 小时前
基于Spring Boot 4s店车辆管理系统 租车管理系统 停车位管理系统 智慧车辆管理系统
java·开发语言·spring boot·后端·spring·毕业设计·课程设计
wyiyiyi5 小时前
【Web后端】Django、flask及其场景——以构建系统原型为例
前端·数据库·后端·python·django·flask
阿华的代码王国6 小时前
【Android】RecyclerView复用CheckBox的异常状态
android·xml·java·前端·后端
Jimmy6 小时前
AI 代理是什么,其有助于我们实现更智能编程
前端·后端·ai编程
AntBlack7 小时前
不当韭菜V1.1 :增强能力 ,辅助构建自己的交易规则
后端·python·pyqt
bobz9658 小时前
pip install 已经不再安全
后端
寻月隐君8 小时前
硬核实战:从零到一,用 Rust 和 Axum 构建高性能聊天服务后端
后端·rust·github