【Go】Gin 超时中间件的坑:fatal error: concurrent map writes
Gin 社区超时中间件的坑:导致线上 Pod 异常重启
在最近的项目中,我们遇到了因为 Gin 超时中间件(timeout
) 引发的生产事故:Pod 异常退出并重启。
问题现场
pod无故重启,抓取标准输出日志,问题指向超时中间件
堆栈报错信息如下
为什么会并发写入呢? 报错指向Go社区的超时中间件,社区搜索相关issue, 果然有相关问题 ttps://github.com/gin-contrib/timeout/pull/55
我们的代码封装
func timeoutMiddleWare(timeoutInt int) gin.HandlerFunc {return timeout.New(timeout.WithTimeout(time.Duration(timeoutInt)*time.Second),timeout.WithResponse(func(c *gin.Context) {c.JSON(http.StatusGatewayTimeout, response.Failed(http.StatusGatewayTimeout, nil))}),)
}
问题复现与成因
先说原因:
超时中间件额外开了一个协程去执行业务逻辑,超时中间件的逻辑在另外的的协程中,当请求超时发生时会出现了两个 goroutine 同时对响应进行写操作,而gin的源码响应中有写入map的操作,这会导致 重复写入,并触发 map 并发写 错误(Go 的 map 在并发写时会直接 panic), 从而导致Pod 异常退出,K8s 会立刻重启容器。
源码分析:
// github.com/gin-contrib/timeout v1.0.1
var bufPool *BufferPool
const (defaultTimeout = 5 * time.Second
)
// New wraps a handler and aborts the process of the handler if the timeout is reached
func New(opts ...Option) gin.HandlerFunc {t := &Timeout{timeout: defaultTimeout,handler: nil,response: defaultResponse,}// Loop through each optionfor _, opt := range opts {if opt == nil {panic("timeout Option not be nil")}// Call the option giving the instantiatedopt(t)}if t.timeout <= 0 {return t.handler}bufPool = &BufferPool{}return func(c *gin.Context) {finish := make(chan struct{}, 1)panicChan := make(chan interface{}, 1)w := c.Writerbuffer := bufPool.Get()tw := NewWriter(w, buffer)c.Writer = twbuffer.Reset()// 这里开了一个协程去执行业务逻辑go func() {defer func() {if p := recover(); p != nil {panicChan <- p}}()t.handler(c)finish <- struct{}{}}()select {case p := <-panicChan:tw.FreeBuffer()c.Writer = wpanic(p)case <-finish:c.Next()tw.mu.Lock()defer tw.mu.Unlock()dst := tw.ResponseWriter.Header()for k, vv := range tw.Header() {dst[k] = vv}tw.ResponseWriter.WriteHeader(tw.code)if _, err := tw.ResponseWriter.Write(buffer.Bytes()); err != nil {panic(err)}tw.FreeBuffer()bufPool.Put(buffer)case <-time.After(t.timeout):c.Abort()tw.mu.Lock()defer tw.mu.Unlock()tw.timeout = truetw.FreeBuffer()bufPool.Put(buffer)// v1.0.1 报错的代码c.Writer = wt.response(c)c.Writer = tw// v1.1.0 修复后的PR代码cc := c.Copy() // 重新拷贝了一份gin.Context进行响应cc.Writer = wt.response(cc)}}// t.response 实际是调用gin.Context.String()
func defaultResponse(c *gin.Context) {c.String(http.StatusRequestTimeout, http.StatusText(http.StatusRequestTimeout))
}// gin源码 v1.10.0:
func (c *Context) String(code int, format string, values ...interface{}) {c.Render(code, render.String{Format: format, Data: values})
}// Render writes the response headers and calls render.Render to render data.
func (c *Context) Render(code int, r render.Render) {c.Status(code)if !bodyAllowedForStatus(code) {// 关键在这里 r.WriteContentType(c.Writer)c.Writer.WriteHeaderNow()return}if err := r.Render(c.Writer); err != nil {panic(err)}
}// WriteContentType (JSON) writes JSON ContentType.
func (r JSON) WriteContentType(w http.ResponseWriter) {writeContentType(w, jsonContentType)
}// !!!WriteContentType 最终会往header(map)中写入值,引发并发问题 !!!func writeContentType(w http.ResponseWriter, value []string) {header := w.Header()if val := header["Content-Type"]; len(val) == 0 {header["Content-Type"] = value}
}
社区修复
修复详情相关 PR:fix(response): conflict when handler completed and concurrent map writes by demouth · Pull Request #
解决办法
所有使用 go get github.com/gin-contrib/timeout
的项目需要升级:
- Go 版本 ≥ 1.23.0
- 拉取最新包:
go get github.com/gin-contrib/timeout@v1.1.0