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)
        // }
}
相关推荐
猫头虎15 分钟前
2025年02月11日 Go生态洞察:Go 1.24 发布亮点全面剖析
开发语言·后端·python·golang·go·beego·go1.19
wuk9981 小时前
互联网应用主流框架整合 Spring Boot开发
java·spring boot·后端
程序员NEO2 小时前
10分钟上线一个Web应用?我没开玩笑,用这个AI智能体就行
人工智能·后端
倔强青铜三3 小时前
Python的Lambda,是神来之笔?还是语法毒瘤?
人工智能·后端·python
a cool fish(无名)3 小时前
rust-方法语法
开发语言·后端·rust
未来之窗软件服务3 小时前
基于 Nginx 与未来之窗防火墙构建下一代自建动态网络防护体系—仙盟创梦IDE
网络·ide·nginx·服务器安全·仙盟创梦ide·东方仙盟
随意石光3 小时前
秒杀功能、高并发系统关注的问题、秒杀系统设计
后端
随意石光3 小时前
Spring Cloud Alibaba Seata、本地事务、分布式事务、CAP 定理与 BASE 理论、Linux 安装 Seata、Seata的使用
后端
程序员清风3 小时前
程序员入职公司实习后应该学什么?
java·后端·面试
智慧源点3 小时前
基于DataX的数据同步实战
后端