model?dao?repository?logic?——这篇先给结论,再把纯 Go、GORM、go-zero 三种代表性的落地写法、代码位置、以及踩坑一次讲清楚。
无论你叫它 logic(go-zero)、service / usecase(DDD 分层),还是 biz——事务的 Begin / Commit / Rollback 都属于这一层。
一句话判据:事务是「业务用例」的边界,不是「一张表」的边界。谁知道"这几步必须同时成功或同时失败",谁就来开事务。
用一个最经典的场景推演:下单 = 建订单(orders) + 扣库存(products) + 扣余额(users)。
// ❌ 反例: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 }) }
// ❌ 反例:三个独立事务,根本不是原子操作 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 }
这四个词本质上只有两个层次:数据访问层(前三个,只是叫法不同)+ 业务编排层(最后一个)。
| 名词 | 来自 | 粒度 | 职责 | 能放事务边界吗 |
|---|---|---|---|---|
| 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 是同一个位置,只是命名不同 | ✓ 同上 |
按「需要保证一致性的数据,跨越了多大的边界」来分三档。
业务层握着事务的开关,数据层只是被"注入"了同一个连接。
最能看清本质的写法:*sql.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 把「事务」收敛成了一个回调方法,但位置原则完全一样。
// 业务层: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 }
Transaction 回调里误用了外层的 s.db 而不是 tx——代码不报错,SQL 也执行了,但那条语句不在事务里,回滚时它不会跟着回滚。这类 bug 极难排查。
tx.SavePoint("sp1") / tx.RollbackTo("sp1") 支持嵌套保存点,适合"某一步失败不影响整体"的场景。
go-zero 里没有 repository、没有 dao——model 就是数据访问层,logic 就是业务编排层。事务边界在 logic。
goctl 生成的 ordermodel.go 里,接口顶部写着这样一句注释:// OrderModel is an interface to be customized, add more methods here, and implement the added methods in customOrderModel.*_gen.go 则明确标了 DO NOT EDIT。
缓存版 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 }
适合「跨表的复杂 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 }) }
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 }
goctl 在非缓存版(不带 --cache)生成的 withSession 是小写私有方法,接口里也只在非缓存分支声明:{{if not .withCache}}withSession(session sqlx.Session) XxxModel{{end}}xxxmodel.go 里加一个大写导出的 WithSession / TransactCtx,并加进接口声明。
--cache 生成时,defaultXxxModel 嵌入的是 sqlc.CachedConn,它本身就有公开的 TransactCtx 和 WithSession(session sqlx.Session) CachedConn——效果同样是"把 session 绑进去"。但接口层面依然需要你手动声明才能从 logic 调到。
这不是"约定俗成",而是被 go-zero 的依赖结构逼出来的必然结果。
ServiceContext 上的。model 层内部不允许横向依赖——OrderModel 拿不到 ProductModel 的实例。想让它们协作,只能由上层(logic)同时持有两者。goctl 是按表生成 model 的:一张表 → 一个 model → 只知道自己那张表的字段和 CRUD。它天然不具备"业务原子单位"这个视野。sqlx.Session 抽象了「一个连接(可能是事务)」。WithSession 返回一个绑定了该 session 的新 model 实例——数据层不用改签名就能参与事务,这是它相对"每个方法都加 tx 参数"的优雅之处。| API | 所在 | 作用 |
|---|---|---|
SqlConn.TransactCtx(ctx, fn) | sqlx | 开启事务执行 fn,fn 返回 nil 提交、返回 err 回滚。内部自带熔断与 tracing |
SqlConn.Transact(fn) | sqlx | 不带 ctx 的旧版,等价于 TransactCtx(context.Background(), …) |
sqlx.Session | sqlx | 事务会话抽象,有 ExecCtx / QueryRowCtx / QueryRowsCtx 等。注意:它本身没有 Transact 方法,不支持嵌套事务 |
CachedConn.WithSession(s) | sqlc | 返回一个绑定 session 的 CachedConn,用于缓存版 model 参与事务 |
NewSqlConnFromSession(s) | sqlx | 把 session 包成 SqlConn,用于非缓存版 WithSession 的实现 |
Update/Delete 时会删缓存。如果事务最终回滚了,缓存却已经被删掉——这个删除动作是不会回滚的。实践建议:对一致性要求高的写操作,事务成功后再主动清一次缓存,或让缓存 TTL 足够短来兜底。
两种流派,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 一次,容易漏 |
go-zero 是微服务框架,订单、库存、用户往往在不同的服务、不同的库里。此时 TransactCtx 无能为力。
_ "github.com/dtm-labs/driver-gozero",在 logic 层用 Saga / TCC 编排各服务的子事务与补偿动作。位置依然在 logic——只是把"一个 DB 事务"换成了"一个分布式事务"。注意位置不变:即便是分布式事务,编排的位置还是在 logic(业务层)。变的只是编排的工具——从 TransactCtx 换成 saga.Add(...).Submit()。各服务内部仍然各自用本地事务保证自己的子操作原子性。
| 场景 | 事务边界 | 数据层做什么 | 代表写法 |
|---|---|---|---|
| 单条写 SQL | 不用事务 | 直接执行 | SQL 本身原子 |
| 同一张表多条操作 | 数据层内部 | 自己包一个事务 | TransactCtx 写在 customModel 里 |
| 一个聚合内多张表 | repository 内部 | 聚合根统一存取 | DDD repository 自包事务 |
| 同库跨表 / 跨聚合 | logic / service | 方法接收 session / tx | TransactCtx + WithSession |
| 跨库 / 跨服务 | logic / service | 各自本地事务 + 幂等 | DTM Saga / TCC,或本地消息表 + MQ |
| 需要查了再改(读改写) | logic / service | 提供 FindOneWithSession | 事务内用 for update 行锁防并发 |
| 坑 | 说明与对策 |
|---|---|
| 事务里误用外层 db | GORM 里写了 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 + 循环依赖。对策:守住"谁懂业务语义谁开事务"这条线。 |