根本原因是gin的c.shouldbindjson()等绑定函数默认同步全量读取请求体至内存,导致大json或multipart表单时单次请求占用数mb内存,引发高cpu(io.readall和json.unmarshal占60%+)及i/o超时,与读写分离无关。

为什么读写分离后 Gin 接口反而变慢了
根本原因不是数据库没分库,而是 Gin 的 c.ShouldBindJSON()、c.Bind() 等绑定函数默认把整个请求体同步读进内存再解析,尤其在 POST 大 JSON 或 multipart 表单时,单次请求就吃掉几 MB —— 这和读写分离无关,但会掩盖真实瓶颈。
常见现象:主库写操作快,从库查接口延迟飙升,pprof 显示 io.ReadAll 和 encoding/json.Unmarshal 占 CPU 60%+;日志里看到大量 read tcp: i/o timeout,其实是请求体还没读完就超时了。
- 对所有含 body 的路由,显式设置
c.Request.Body读取上限(如http.MaxBytesReader(c.Writer, c.Request.Body, 4) - 避免在中间件里调用
c.ShouldBindJSON();改用jsoniter.Unmarshal+ 手动读取io.LimitReader控制长度 - 写接口(POST/PUT)必须加
context.WithTimeout,超时时间建议 ≤ 3s;读接口可放宽至 5–8s,但需配合从库健康检查降级
Gin 中间件如何安全区分读/写流量
不能靠 HTTP 方法(GET/POST)简单判断——有些 GET 带复杂参数要写缓存,有些 POST 只是幂等查询。真正可靠的是路由前缀 + 显式标记。
示例中把写操作统一归到 /api/v1/write/,读操作走 /api/v1/read/,再用中间件分流:
r := gin.New()
r.Use(routeTagger()) // 自动给 c.Set("traffic_type", "read" or "write")
<p>func routeTagger() gin.HandlerFunc {
return func(c *gin.Context) {
path := c.Request.URL.Path
switch {
case strings.HasPrefix(path, "/api/v1/write/"):
c.Set("traffic_type", "write")
case strings.HasPrefix(path, "/api/v1/read/"):
c.Set("traffic_type", "read")
default:
c.Set("traffic_type", "unknown")
}
c.Next()
}
}</p>
- 禁止在中间件里直接调用 DB 写操作;所有 DB 访问必须通过封装好的
DBRead()/DBWrite()函数,内部自动路由到对应连接池 - 读中间件可加
cache.Lookup()提前返回,但写中间件必须先cache.Invalidate(),否则缓存穿透 - 若用 Redis 做二级缓存,读中间件里
redis.Get要设 100ms 超时,失败立即 fallback 到从库,不重试
连接池配置不当导致读写分离失效
现象是所有请求都打到主库,或从库连接数爆满但 QPS 上不去。根因是 Go 的 sql.DB 连接池未按读写拆分,或 SetMaxOpenConns 设得过大,触发内核文件描述符耗尽。
正确做法是为读、写分别初始化两个 *sql.DB 实例,各自独立配置:
var dbWrite, dbRead *sql.DB
<p>dbWrite, _ = sql.Open("mysql", writeDSN)
dbWrite.SetMaxOpenConns(30)
dbWrite.SetMaxIdleConns(10)</p><p>dbRead, _ = sql.Open("mysql", readDSN)
dbRead.SetMaxOpenConns(100)
dbRead.SetMaxIdleConns(30)</p>
- 写库连接池要小(≤ 30),因为写操作串行化强、事务锁多,连太多反而排队更久
- 读库连接池可大些(≤ 100),但必须配合
SetConnMaxLifetime(30 * time.Second)防止长连接僵死 - 禁止共用一个
sql.DB实例并靠 DSN 切换读写——Go 的连接池不识别语义,只会随机复用连接
goroutine 泄漏让读写分离策略彻底失控
高频读接口里如果用 go func() { dbRead.QueryRow(...) }() 启动 goroutine,且没做 recover 或限流,一次突发流量就能拉起上万 goroutine。它们不会立刻退出,而是在等待 DB 返回,持续占用连接池里的空闲连接,导致后续正常请求拿不到连接,全部卡在 dbRead.GetConn 阻塞点。
- 所有异步 DB 操作必须走固定大小的 Worker Pool,池大小 ≤
runtime.NumCPU() * 2 - Pool 的任务 channel 容量严格限制为 128,写满即丢弃(返回 429),不堆积
- 绝对禁止把
*gin.Context传进 goroutine;要用c.Copy()复制后再传,否则c.Abort()会 panic
最易被忽略的是:读写分离的“读”不等于“无状态”,从库也可能有慢查询、锁表、网络抖动。Gin 层必须有熔断开关,当从库错误率 > 5% 持续 30 秒,自动将读流量切回主库(降级),而不是硬扛超时。











