Go 多表事务,到底写在哪一层?

model?dao?repository?logic?——这篇先给结论,再把纯 Go、GORM、go-zero 三种代表性的落地写法、代码位置、以及踩坑一次讲清楚。

一句话结论 为什么不写 model 四个词辨析 分层判据 调用流程图 纯 Go 写法 GORM 写法 go-zero 写法 go-zero 为什么这样 tx 怎么往下传 跨库跨服务 速查表 踩坑清单

写在「业务编排层」,不写在 model / dao / repository

无论你叫它 logic(go-zero)、service / usecase(DDD 分层),还是 biz——事务的 Begin / Commit / Rollback 都属于这一层。

一句话判据:事务是「业务用例」的边界,不是「一张表」的边界。谁知道"这几步必须同时成功或同时失败",谁就来开事务。

❌ model / dao
只负责「一张表怎么读写」。它不知道"下单"要同时动三张表。
⚠️ repository
只保证「一个聚合内」的一致性。跨聚合的事务它管不了,也不能管。
✅ logic / service / usecase
唯一清楚"业务原子单位"在哪的一层 → 事务边界在这里
但要注意分工方式:业务层负责「开事务、编排、提交/回滚」;数据层(model / dao / repository)负责「提供一个能接收外部传进来的 tx / session 的方法」。两边配合,谁也不越界。

如果把多表事务写进 model,会发生什么

用一个最经典的场景推演:下单 = 建订单(orders) + 扣库存(products) + 扣余额(users)。

方案 A:写在 OrderModel 里

// ❌ 反例:OrderModel 里塞了别的表
func (m *OrderModel) CreateOrder(ctx context.Context, req *Req) error {
    return m.conn.TransactCtx(ctx, func(ctx context.Context, s sqlx.Session) error {
        // 1. 建订单 —— 自己表,OK
        s.ExecCtx(ctx, "insert into orders ...")
        // 2. 扣库存 —— 咦,这是 products 表的 SQL,怎么在我这?
        s.ExecCtx(ctx, "update products set stock=stock-1 ...")
        // 3. 扣余额 —— users 表也来了
        s.ExecCtx(ctx, "update users set balance=balance-? ...")
        return nil
    })
}
问题:OrderModel 变成了「上帝 model」。库存的扣减规则改了,要动 OrderModel;余额加校验了,还要动 OrderModel。三张表的业务逻辑被焊死在一个文件里,职责越界 + 无法复用

方案 B:每个 model 各自开事务

// ❌ 反例:三个独立事务,根本不是原子操作
func (l *CreateOrderLogic) CreateOrder(req *types.Req) error {
    l.svcCtx.OrderModel.Insert(l.ctx, order)      // 事务1:提交 ✓
    l.svcCtx.ProductModel.DecrStock(l.ctx, pid, 1) // 事务2:提交 ✓
    l.svcCtx.UserModel.DecrBalance(l.ctx, uid, amt) // 事务3:余额不足 → 失败 ✗
    // 结果:库存扣了、钱没扣、订单却生成了 → 数据不一致
    return nil
}
问题:三个方法各自拿各自的连接,各自独立提交。单表事务在多表场景下等于没有事务——中间的失败无法回滚前面已提交的。

方案 C:model 之间互相调用

问题:让 OrderModel 依赖 ProductModel、UserModel 来"凑"事务,会形成 层内横向依赖,紧接着就是 循环依赖(ProductModel 也想调 OrderModel)。数据层一旦横向依赖,整个依赖方向就乱了。
✅ 正解:三个 model 各自只写「自己表 + 可传入 session」的方法;由 logic 开一个事务,把同一个 session 传给三个 model。见后面的 go-zero 写法

model / dao / repository / logic 到底是啥关系

这四个词本质上只有两个层次:数据访问层(前三个,只是叫法不同)+ 业务编排层(最后一个)。

名词来自粒度职责能放事务边界吗
model go-zero 官方叫法 一张表 表结构映射 + CRUD + 可选缓存。goctl model 生成 ✗ 只做单表,事务由 logic 开
dao Java / MyBatis 传统叫法 一张表 Data Access Object,和 model 基本同义,只是换了个词 ✗ 同上
repository DDD 领域驱动设计 一个聚合根 以「聚合」为单位存取,对外隐藏存储细节。一个 repository 可以管多张表(聚合内的表) △ 聚合内多表可以;跨聚合不行
logic go-zero 目录名 一个用例 接收请求 → 编排多个 model / RPC / MQ → 返回。唯一懂业务语义的一层 ✓ 事务边界就该在这
service / usecase DDD / 分层架构 一个用例 和 go-zero 的 logic 是同一个位置,只是命名不同 ✓ 同上
关键区分点:repository 和 model/dao 最大的不同是粒度。model/dao = 「一表一文件」;repository = 「一聚合一文件」,聚合内部的多张表天然属于同一个 repository,所以聚合内的多表一致性由 repository 自己保证是合理的。但「订单聚合」和「库存聚合」之间的事务,repository 依然管不了。

三档判定:你的事务该由谁负责

按「需要保证一致性的数据,跨越了多大的边界」来分三档。

① 单表 / 单聚合
数据层自己搞定。甚至可以不开事务——单条 SQL 本身原子。多条就由 repository 内部包一个事务。
② 同库多表 / 跨聚合
业务层开事务,数据层方法接收 tx/session 参数。这是本文的核心场景,也是 go-zero 的标准做法。
③ 跨库 / 跨服务
本地事务失效。用分布式事务(DTM / Saga / TCC)或最终一致性(本地消息表 + MQ)。
记忆口诀:谁的范围大,谁负责开」。一个事务的边界 = 它需要覆盖的最大范围。跨了几个数据对象,就往上抬几层。

一次多表事务的完整调用链

业务层握着事务的开关,数据层只是被"注入"了同一个连接。

logic / service(业务编排层)—— 事务边界在这里 BEGIN 开启事务 编排多表操作 共用同一个 session COMMIT 出错则 ROLLBACK defer 兜底回滚 ↓ 把 session(等价于 *sql.Tx)作为参数,传给下面的每个数据层方法 ↓ 数据层感知不到"事务",它只知道:这次用传入的连接,而不是自己新建连接 model / dao / repository(数据访问层)—— 只提供「可接收 session」的方法 OrderModel.Insert(ctx, s, …) ProductModel.DecrStock(ctx, s, …) UserModel.DecrBalance(ctx, s, …) 同一个 DB 连接 / 同一个事务 三条 SQL 要么全成功,要么全回滚
图 1:业务层持有事务,数据层被注入 session——依赖方向永远自上而下,不会横向依赖

纯 Go(database/sql)怎么写

最能看清本质的写法:*sql.Tx 作为参数显式往下传。

数据层:方法接收 tx

// internal/repository/order.go —— 数据层,不知道事务的存在
type OrderRepo struct{ db *sql.DB }

// tx 为 nil 时用自己的 db(非事务场景);不为 nil 时用调用方给的事务
func (r *OrderRepo) Create(ctx context.Context, tx *sql.Tx, o *Order) (int64, error) {
    ex := r.execer(tx)
    res, err := ex.ExecContext(ctx,
        "insert into orders(user_id,amount) values(?,?)", o.UserID, o.Amount)
    if err != nil { return 0, err }
    return res.LastInsertId()
}

// 统一取「执行器」:有 tx 用 tx,没 tx 用 db
func (r *OrderRepo) execer(tx *sql.Tx) interface {
    ExecContext(context.Context, string, ...any) (sql.Result, error)
} {
    if tx != nil { return tx }
    return r.db
}

业务层:开事务、编排、提交

// internal/usecase/place_order.go —— 业务编排层,事务边界在这里
func (uc *PlaceOrderUC) PlaceOrder(ctx context.Context, req Req) error {
    tx, err := uc.db.BeginTx(ctx, nil)
    if err != nil { return err }
    defer tx.Rollback()   // 已提交后再 Rollback 会返回 ErrTxDone,无害

    // 1. 建订单
    orderID, err := uc.orderRepo.Create(ctx, tx, &Order{UserID: req.UID, Amount: req.Amount})
    if err != nil { return err }   // return 触发 defer 回滚

    // 2. 扣库存
    if err := uc.productRepo.DecrStock(ctx, tx, req.PID, req.Num); err != nil {
        return err
    }

    // 3. 扣余额(余额不足 → 直接回滚前两步)
    if err := uc.userRepo.DecrBalance(ctx, tx, req.UID, req.Amount); err != nil {
        return err
    }

    return tx.Commit()   // 全部成功才提交
}
要点:defer tx.Rollback() 是 Go 的标准兜底写法,任何提前 return 都会回滚;② 数据层方法用「tx 可为 nil」的设计,让同一个方法既能用于事务内,也能用于非事务,避免写两套。

GORM 怎么写

GORM 把「事务」收敛成了一个回调方法,但位置原则完全一样。

// 业务层:db.Transaction 定义事务边界
func (s *OrderService) PlaceOrder(ctx context.Context, req Req) error {
    return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
        // 注意:下面全部用 tx,绝不能用 s.db,否则就脱离事务了!
        if err := tx.Create(&Order{UserID: req.UID, Amount: req.Amount}).Error; err != nil {
            return err   // 返回 error → 自动回滚
        }
        if err := s.productRepo.DecrStockWithTx(tx, req.PID, req.Num); err != nil {
            return err
        }
        if err := s.userRepo.DecrBalanceWithTx(tx, req.UID, req.Amount); err != nil {
            return err
        }
        return nil   // 返回 nil → 自动提交
    })
}

// 数据层:方法接收 *gorm.DB(本质是 tx 或 db)
func (r *ProductRepo) DecrStockWithTx(tx *gorm.DB, pid int64, num int) error {
    return tx.Exec("update products set stock=stock-? where id=? and stock>=?", num, pid, num).Error
}
GORM 第一大坑:Transaction 回调里误用了外层的 s.db 而不是 tx——代码不报错,SQL 也执行了,但那条语句不在事务里,回滚时它不会跟着回滚。这类 bug 极难排查。
GORM 还有 tx.SavePoint("sp1") / tx.RollbackTo("sp1") 支持嵌套保存点,适合"某一步失败不影响整体"的场景。

go-zero 怎么写(官方推荐做法)

go-zero 里没有 repository、没有 dao——model 就是数据访问层,logic 就是业务编排层。事务边界在 logic。

先看目录:go-zero 的分层

service/order/api/ ├── order.api # 接口定义 ├── order.go # 启动入口 └── internal/ ├── config/config.go # 配置 ├── handler/ # 路由层:只解析参数、调 logic ├── logic/ # ★ 业务编排层 = 事务边界 │ └── createorderlogic.go ├── svc/servicecontext.go # 依赖装配:注入各个 model ├── types/ # 请求/响应结构体 └── model/ # ★ 数据访问层(一表一 model,goctl 生成) ├── ordermodel.go # 可定制:接口声明 + customModel ├── ordermodel_gen.go # 生成物,勿手改(DO NOT EDIT) ├── vars.go └── err.go
关键设计:goctl 生成的 ordermodel.go 里,接口顶部写着这样一句注释:
// OrderModel is an interface to be customized, add more methods here, and implement the added methods in customOrderModel.
—— 这就是 go-zero 官方指的路:事务方法要你自己加进这个接口,并在 custom 结构里实现。生成物 *_gen.go 则明确标了 DO NOT EDIT

做法 A(推荐):logic 开事务 + model 用 WithSession

缓存版 model 的标准写法:把同一个 session"注入"给每个 model。

// internal/logic/createorderlogic.go —— 事务边界在 logic
func (l *CreateOrderLogic) CreateOrder(req *types.CreateOrderReq) (*types.CreateOrderResp, error) {
    var orderID int64

    // 开启事务:任意 model 都能当"事务发起方",因为它们连的是同一个库
    err := l.svcCtx.OrderModel.TransactCtx(l.ctx, func(ctx context.Context, session sqlx.Session) error {

        // 1. 建订单:把 session 注入 OrderModel
        orderModel := l.svcCtx.OrderModel.WithSession(session)
        res, err := orderModel.Insert(ctx, &model.Order{
            Userid: req.UserId, Amount: req.Amount, Status: 0,
        })
        if err != nil { return err }
        orderID, err = res.LastInsertId()
        if err != nil { return err }

        // 2. 扣库存:同一个 session 注入 ProductModel
        productModel := l.svcCtx.ProductModel.WithSession(session)
        if err := productModel.DecrStock(ctx, req.ProductId, req.Num); err != nil {
            return err   // 返回 err → 整体回滚
        }

        // 3. 扣余额
        userModel := l.svcCtx.UserModel.WithSession(session)
        if err := userModel.DecrBalance(ctx, req.UserId, req.Amount); err != nil {
            return err
        }

        return nil   // 返回 nil → 提交
    })
    if err != nil {
        return nil, err
    }
    return &types.CreateOrderResp{Id: orderID}, nil
}

做法 B:logic 开事务 + session 直接写 SQL

适合「跨表的复杂 SQL」或「临时逻辑」,不走 model 封装。

func (l *CreateOrderLogic) CreateOrder(req *types.CreateOrderReq) error {
    return l.svcCtx.OrderModel.TransactCtx(l.ctx,
        func(ctx context.Context, session sqlx.Session) error {
            // 建订单
            res, err := session.ExecCtx(ctx,
                "insert into orders(userid,amount) values(?,?)", req.UserId, req.Amount)
            if err != nil { return err }

            // 扣库存(带条件更新,防超卖)
            ret, err := session.ExecCtx(ctx,
                "update products set stock=stock-? where id=? and stock>=?",
                req.Num, req.ProductId, req.Num)
            if err != nil { return err }
            affected, err := ret.RowsAffected()
            if err != nil { return err }
            if affected == 0 {
                return errorx.New("库存不足")   // 主动返回错误 → 回滚订单
            }
            return nil
        })
}

做法 C:model 里补上事务能力(非缓存版必做)

goctl 生成的默认接口里没有 TransactCtx,需要你在可定制文件里补。

// internal/model/ordermodel.go —— 这是「可定制」文件,不是 _gen.go,可以改

// 1. 把事务方法加进接口(否则 logic 通过接口类型调不到)
type OrderModel interface {
    orderModel   // goctl 生成的 CRUD 方法
    TransactCtx(ctx context.Context, fn func(context.Context, sqlx.Session) error) error
    WithSession(session sqlx.Session) OrderModel
}

// 2. 在 custom 结构里实现
func (m *customOrderModel) TransactCtx(
    ctx context.Context, fn func(context.Context, sqlx.Session) error) error {
    // 缓存版:m.CachedConn 直接就有 TransactCtx
    // 非缓存版:m.conn 是私有字段,需在同一包内访问(本文件就在 model 包内,OK)
    return m.conn.TransactCtx(ctx, fn)
}

func (m *customOrderModel) WithSession(session sqlx.Session) OrderModel {
    return NewOrderModel(sqlx.NewSqlConnFromSession(session))
}

// 3. 自定义业务方法:接收 session,用于事务内
func (m *customOrderModel) DecrStockWithSession(
    ctx context.Context, session sqlx.Session, pid, num int64) error {
    query := fmt.Sprintf("update %s set stock=stock-? where id=? and stock>=?", m.table)
    _, err := session.ExecCtx(ctx, query, num, pid, num)
    return err
}
go-zero 最容易踩的坑:goctl非缓存版(不带 --cache)生成的 withSession小写私有方法,接口里也只在非缓存分支声明:
{{if not .withCache}}withSession(session sqlx.Session) XxxModel{{end}}
这意味着在 model 包外部(logic 层)根本调不到它。所以非缓存版必须自己在 xxxmodel.go 里加一个大写导出WithSession / TransactCtx,并加进接口声明。
缓存版 vs 非缓存版:--cache 生成时,defaultXxxModel 嵌入的是 sqlc.CachedConn,它本身就有公开的 TransactCtxWithSession(session sqlx.Session) CachedConn——效果同样是"把 session 绑进去"。但接口层面依然需要你手动声明才能从 logic 调到。

go-zero 为什么把事务放在 logic

这不是"约定俗成",而是被 go-zero 的依赖结构逼出来的必然结果。

svc 统一装配,model 之间互不认识
所有 model 都是平级挂在 ServiceContext 上的。model 层内部不允许横向依赖——OrderModel 拿不到 ProductModel 的实例。想让它们协作,只能由上层(logic)同时持有两者。
model 只有「一张表」的知识
goctl 是按表生成 model 的:一张表 → 一个 model → 只知道自己那张表的字段和 CRUD。它天然不具备"业务原子单位"这个视野。
logic 才是「用例」的载体
go-zero 的 logic 就是「一个接口一个 logic」,天然等于「一个用例」。用例 = 事务边界,这是一一对应的。
Session 机制让"注入"变得干净
go-zero 用 sqlx.Session 抽象了「一个连接(可能是事务)」。WithSession 返回一个绑定了该 session 的新 model 实例——数据层不用改签名就能参与事务,这是它相对"每个方法都加 tx 参数"的优雅之处。

核心 API 一览(已核对 go-zero 源码)

API所在作用
SqlConn.TransactCtx(ctx, fn)sqlx开启事务执行 fn,fn 返回 nil 提交、返回 err 回滚。内部自带熔断与 tracing
SqlConn.Transact(fn)sqlx不带 ctx 的旧版,等价于 TransactCtx(context.Background(), …)
sqlx.Sessionsqlx事务会话抽象,有 ExecCtx / QueryRowCtx / QueryRowsCtx 等。注意:它本身没有 Transact 方法,不支持嵌套事务
CachedConn.WithSession(s)sqlc返回一个绑定 session 的 CachedConn,用于缓存版 model 参与事务
NewSqlConnFromSession(s)sqlx把 session 包成 SqlConn,用于非缓存版 WithSession 的实现
缓存 + 事务的经典坑:带缓存的 model 在事务内做 Update/Delete 时会删缓存。如果事务最终回滚了,缓存却已经被删掉——这个删除动作是不会回滚的。实践建议:对一致性要求高的写操作,事务成功后再主动清一次缓存,或让缓存 TTL 足够短来兜底。

tx / session 怎么往下传给数据层

两种流派,Go 社区有明确倾向。

方式写法优点缺点
① 显式参数
(推荐)
repo.Create(ctx, tx, data) 函数签名就把"要不要参与事务"写清楚了,编译器可检查,符合 Go 显式优于隐式的风格 每个方法都要多一个参数;调用链长时要一路透传
② 放进 context ctx = withTx(ctx, tx)
repo.Create(ctx, data)
方法签名干净,深层调用不用改 隐式魔法:看签名不知道在不在事务里;忘记传就静默脱离事务,出事极难排查
③ 会话绑定
(go-zero)
model.WithSession(s).Insert(ctx, d) 签名不变,语义清晰——拿到的是"绑了事务的新实例",兼顾了①的显式和②的简洁 每次用都要先 WithSession 一次,容易漏
推荐:① 或 ③。尤其是 context 传 tx 这招,很多团队图一时省事用了,最后都栽在"某个方法忘了从 ctx 取 tx,悄悄用了新连接"上。Go 的哲学是让错误在编译期暴露,事务这种一旦出错就是资金损失的东西,更不该藏在 ctx 里。

跨库 / 跨服务:本地事务不管用了

go-zero 是微服务框架,订单、库存、用户往往在不同的服务、不同的库里。此时 TransactCtx 无能为力。

方案一:分布式事务(DTM)
go-zero 官方生态推荐 dtm-labs:引入 _ "github.com/dtm-labs/driver-gozero",在 logic 层用 Saga / TCC 编排各服务的子事务与补偿动作。位置依然在 logic——只是把"一个 DB 事务"换成了"一个分布式事务"。
方案二:最终一致性(MQ)
本地消息表:下单时在同一个本地事务里写入"订单 + 待发送消息",再由后台任务投递 MQ,库存服务消费后扣减并 ACK。失败则重试 + 幂等。

注意位置不变:即便是分布式事务,编排的位置还是在 logic(业务层)。变的只是编排的工具——从 TransactCtx 换成 saga.Add(...).Submit()。各服务内部仍然各自用本地事务保证自己的子操作原子性。

一页速查:到底写哪

场景事务边界数据层做什么代表写法
单条写 SQL不用事务直接执行SQL 本身原子
同一张表多条操作数据层内部自己包一个事务TransactCtx 写在 customModel 里
一个聚合内多张表repository 内部聚合根统一存取DDD repository 自包事务
同库跨表 / 跨聚合logic / service方法接收 session / txTransactCtx + WithSession
跨库 / 跨服务logic / service各自本地事务 + 幂等DTM Saga / TCC,或本地消息表 + MQ
需要查了再改(读改写)logic / service提供 FindOneWithSession事务内用 for update 行锁防并发
handler / api 解析参数 logic / service ★ 事务边界 model / dao / repo 接收 session 执行 依赖方向:handler → logic → model(单向,不可反向、不可横向) 判据三连问 ① 这几步必须同生共死吗?→ 是,就需要事务 ② 它们落在几张表/几个库?→ 决定边界抬到哪层 ③ 谁同时认识这些数据对象?→ 那就是开事务的地方
图 2:依赖永远自上而下,事务边界随"认识范围"上移

常见踩坑清单

说明与对策
事务里误用外层 dbGORM 里写了 s.db.Create() 而不是 tx.Create(),语句静默脱离事务。对策:回调内一律用 tx,可加 lint 规则检查。
事务开得太长在事务里调 RPC、发 HTTP、写文件、sleep——连接被长时间占用,连接池迅速耗尽。对策:事务内只做 DB 操作,外部调用移到事务外。
go-zero 私有 withSession非缓存版生成的 withSession 是小写私有,logic 层调不到。对策:xxxmodel.go 自行加导出方法并声明进接口。
嵌套事务sqlx.Session 没有 Transact 方法,不支持嵌套。被调用方若再开事务会拿到新连接。对策:用保存点,或把 session 一路传下去。
回滚了但缓存已删缓存版 model 在事务内删缓存,回滚后缓存不会恢复。对策:提交后再清一次 / 短 TTL 兜底。
忘了 Commit / Rollback纯 Go 写法下极易漏,连接一直不释放。对策:始终 defer tx.Rollback();能用回调封装(如 GORM / go-zero 的 TransactCtx)就别手写。
并发下读改写先查余额再扣,两个请求同时读到 100 各扣 80,结果 -60。对策:用条件更新 where balance >= ?select … for update 加行锁。
把事务写进 model 求"方便"短期省事,长期形成上帝 model + 循环依赖。对策:守住"谁懂业务语义谁开事务"这条线。