最近发现线上的golang服务请求时常莫名其妙的因context canceled中断。我们使用的是go-kratos微服务框架,底层http server来自golang官方库。最早怀疑是超时导致,但是打印出的请求时延远低于设定的超时时间,而且是context canceled,而不是context deadline exceeded,超时嫌疑排除。锁定有代码调用了context.WithCancel返回的cancel。
瞬时偶发异常pprof无用,只能逐步排查代码:
- 业务代码,未检索到使用context.WithCancel
- go-kratos,有使用context.WithTimeout,未使用context.WithCancel
- 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消失。
推测:
- 反向代理的默认行为,如果客户端中断请求,反向代理将关闭实际转发请求的socket,以中断请求。因为拿到响应也没地儿写了
- 开启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)
// }
}