【Go】Gin 超时中间件的坑:fatal error: concurrent map writes

发布于:2025-08-13 ⋅ 阅读:(20) ⋅ 点赞:(0)

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 option
	for _, opt := range opts {
		if opt == nil {
			panic("timeout Option not be nil")
		}
		// Call the option giving the instantiated
		opt(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.Writer
		buffer := bufPool.Get()
		tw := NewWriter(w, buffer)
		c.Writer = tw
		buffer.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 = w
			panic(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 = true
			tw.FreeBuffer()
			bufPool.Put(buffer)

			// v1.0.1 报错的代码
			c.Writer = w
			t.response(c)
			c.Writer = tw
			
			// v1.1.0 修复后的PR代码
			cc := c.Copy() // 重新拷贝了一份gin.Context进行响应
			cc.Writer = w
			t.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

网站公告

今日签到

点亮在社区的每一天
去签到