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)
        // }
}
相关推荐
猪猪拆迁队14 分钟前
虚拟工厂仿真引擎的架构设计:让一条产线可编程、可观测、可干预
后端·ai编程
字节跳动数据库39 分钟前
文章分享——相似函数处理方法
人工智能·后端·程序员
云技纵横39 分钟前
@Transactional 失效的 7 种场景:第 5 种最难排查
后端
用户6757049885021 小时前
你知道 Go 结构体和结构体指针调用的区别吗?一文带你彻底搞懂!
后端·go
程序员cxuan1 小时前
读懂 Claude Code 架构分析系列,第一篇,开始!
人工智能·后端·架构
用户6757049885021 小时前
面试官问“装饰器模式”,这样回答薪资多要 3000!
后端
tntxia1 小时前
Geo Scene域名修改引起的一些问题
后端
用户298698530141 小时前
Java 实现 Word 文档加密与权限解除
java·后端
vanuan2 小时前
给你的A2A-Agent加把锁-认证鉴权实战指南
后端
Yeats_Liao2 小时前
14:Servlet中的页面跳转-Java Web
java·后端·架构