真正可用的耗时统计必须绑定到响应写出那一刻,而非c.next()前后;需用自定义responsewriter重写writeheader和write捕获真实写出时刻,并确保中间件注册在recovery之后、全局生效且线程安全。

直接用 c.Next() 前后打点测耗时,看似简单,但实际会漏掉 panic、abort、重定向等关键路径的响应时间,状态码也常记成 0 或 200。真正可用的耗时统计必须绑定到响应写出那一刻,而不是中间件函数执行周期。
为什么 c.Next() 前后计时不准
耗时统计不是“中间件运行了多久”,而是“这个请求从路由匹配完成到响应写出花了多久”。c.Next() 只是触发后续 handler 链,但它不保证 handler 一定执行完——比如某个鉴权中间件调了 c.Abort(),handler 根本没跑;或者 handler panic 后被 recovery() 拦住,状态码写在 recovery 里,但你的计时早已结束。
-
start := time.Now()放在中间件开头,会把前置中间件(如鉴权、日志解析)耗时也计入,污染指标 -
c.Next()返回后立刻读time.Since(start),此时响应可能还没写出(尤其是流式响应或异步写),真实延迟被低估 - panic 场景下,
recovery()中间件默认在 logger 之后执行,你读到的c.Writer.Status()还是初始值 200
必须用自定义 ResponseWriter 捕获真实写出时刻
Gin 的 c.Writer 是个接口,底层是 responseWriter 类型。仅靠 c.Writer.Status() 不够——它返回的是已设置的状态码,但不反映是否真正写出。要捕获“响应开始写出”这一瞬间,得包装 http.ResponseWriter,重写 WriteHeader 和 Write。
-
WriteHeader是状态码和 header 实际发出的唯一入口,所有 2xx/4xx/5xx 都从此触发 -
Write被调用说明 body 开始写出,对 chunked 或 streaming 接口尤其关键 - 不要只重写
Write:像304、204这类无 body 响应,Write根本不会被调,只走WriteHeader - 示例核心逻辑:
type statsWriter struct { http.ResponseWriter statusCode int written bool start time.Time } func (w *statsWriter) WriteHeader(code int) { w.statusCode = code w.written = true w.ResponseWriter.WriteHeader(code) } func (w *statsWriter) Write(b []byte) (int, error) { if !w.written { w.statusCode = http.StatusOK w.written = true } return w.ResponseWriter.Write(b) }
中间件注册顺序决定统计完整性
统计中间件必须放在 recovery() 之后、其他业务中间件之前——否则 panic 时根本来不及记录状态码和耗时;同时不能包裹在 Logger() 之类依赖 c.Writer.Status() 的中间件内,避免读取时机错位。
- 正确顺序:
r.Use(gin.Recovery(), statsMiddleware(), authMiddleware(), ...) - 错误顺序:
r.Use(statsMiddleware(), gin.Recovery())→ panic 发生时,statsMiddleware已退出,WriteHeader(500)在 recovery 里发,你完全感知不到 - 别把统计中间件加在某个 group 下:全局统计必须注册为
r.Use(),否则 /api/v1 下的接口漏统计,/health 就有数据 - 如果用了
gin.LoggerWithConfig(),确保它的Formatter不提前读c.Writer.Status(),否则和你的统计竞争同一字段
状态码和耗时必须原子写入,避免并发错乱
多个中间件共用同一个 *gin.Context,闭包变量(如 start time.Time)在并发请求下会被覆盖。Gin 提供 c.Set() 和 c.Get(),但它们只是 map 操作,不保证线程安全——不过 Gin 内部已对 context map 加锁,可放心用。
- 写入:
c.Set("stats_start", time.Now()),而非闭包变量start - 读取:
if v, ok := c.Get("stats_start"); ok { start := v.(time.Time) } - 别用
log.Printf直接输出:高并发下 stdout 锁争用严重,改用zerolog或zap的异步写入模式 - 采样控制很重要:全量打点 IO 压力大,建议按路径或状态码采样,比如只记录
status >= 400或cost > 500ms的请求
最易被忽略的点是:统计中间件必须自己接管 WriteHeader,而不是依赖 c.Writer.Status() —— 因为后者只是缓存值,不是写出事实。哪怕 Gin v1.9+ 修复了部分问题,304、204、panic recovery 这三类场景仍需 ResponseWriter 包装才能兜住。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











