双写一致性本质是业务层数据兜底机制,必须在handler内显式控制写入顺序与错误分支,不可依赖中间件透传;失败后需按错误类型区分重试、告警或人工介入。

双写逻辑不是“加个中间件就能跑通”的事,它本质是数据一致性兜底机制,必须在业务写入路径上做精确拦截和分支控制,否则容易漏写、错序、或掩盖上游失败。
双写必须发生在业务 handler 内部,不能靠中间件自动透传
很多人试图用中间件统一拦截 c.Next() 后的响应状态,再触发新服务写入——这不可靠。原因有三:
- 中间件看到的是 HTTP 层结果,无法感知底层 DB 事务是否真正提交(比如 MySQL 写成功但 Redis 缓存更新失败,中间件仍会收到 200)
-
c.Abort()或 panic 可能跳过后续中间件,导致双写逻辑被跳过 - 旧服务写入失败时,中间件若盲目发起新服务写入,会造成数据漂移(旧系统没写成,新系统却写成了)
正确做法是:在 handler 显式调用两个写操作,并用错误判断控制流向。例如:
func updateUser(c *gin.Context) {
var req UserUpdateReq
if err := c.ShouldBindJSON(&req); err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// 先写老服务(比如 legacy MySQL)
if err := legacyDB.UpdateUser(req.ID, req.Name); err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "legacy update failed"})
return
}
// 再写新服务(比如 go-zero user rpc)
if _, err := newUserSvc.Update(c.Request.Context(), &user.UpdateRequest{Id: req.ID, Name: req.Name}); err != nil {
// 这里不直接返回错误,而是记录告警 + 启动补偿任务
log.Warn("new service write failed, fallback to async retry", "id", req.ID, "err", err)
enqueueRetryTask("update_user", req.ID, req)
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
双写失败后必须区分“可重试”和“需人工介入”的场景
不是所有失败都适合自动重试。以下情况需立刻拦截并报警:
- 新老服务写入结果语义冲突(如老服务写入 status=active,新服务因校验失败写入 status=pending)
- 新服务返回非幂等错误(如
rpc error: code = InvalidArgument),说明请求本身有问题,重试无意义 - 连续 3 次重试都失败,且错误码为
Unavailable或DeadlineExceeded,大概率是下游服务不可用,应触发熔断而非死循环重试
推荐用结构化错误码分类,例如:
switch status.Code(err) {
case codes.InvalidArgument:
// 记录 audit log,通知产品/研发核查输入
case codes.Unavailable, codes.DeadlineExceeded:
// 上报 Prometheus counter,触发告警
case codes.Aborted, codes.FailedPrecondition:
// 启动异步补偿 + 发送 Slack 通知给值班人
}
路由层要支持灰度分流,但不能把双写逻辑耦合进路由配置
灰度开关(比如按 user_id % 100 RouterGroup 的中间件里,但它只决定“走哪条 handler”,不负责“怎么写”。常见错误是把双写逻辑塞进灰度中间件:
// ❌ 错误示范:双写混在灰度中间件里
func GrayMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
uid := c.GetInt64("user_id")
if uid%100
<p>正确方式是让灰度中间件只设置上下文标记,由 handler 自行判断:</p>
<pre class="brush:php;toolbar:false;">// ✅ 正确:灰度中间件只打标
func GrayMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
uid := c.GetInt64("user_id")
if uid%100
<p>双写最易被忽略的点是时间窗口管理:老服务事务提交完成、到新服务开始写入之间存在毫秒级空隙,若此时发生 crash,可能丢失新服务写入。生产环境必须用本地消息表或事务性发件箱(outbox pattern)兜底,而不是依赖内存中的 defer 或 goroutine。</p>











