Golang Go语言中大家做后端开发时,都是怎么处理接口操作的原子性的?

发布于 1周前 作者 ionicwang 来自 Go语言

背景

我个人习惯,开发带数据库的后端时,gorm 的代码直接写在业务逻辑层,供 gin 的 handler 调用,然后每个业务逻辑层的代码都会带一个tx *gorm.DB(事务),请求到 gin 的 handler 解析完参数后立马开一个 tx ,后边调用的所有业务逻辑代码都带上 tx ,以便一步失败后能够直接 rollback 掉整个请求所有已经执行的业务逻辑。

但是,实际工作中也见过很多大佬写的代码,包括一些开源项目,实际上看到的大家的 dao 封装时根本都不传 tx ,也没怎么见到过在接口做原子性的,一般都是在 dao 封装的时候保证这个函数中涉及的查询和操作整体原子。

疑问

  1. 像我那样的“接口原子性”有实际意义吗? 99%的场景其实没用么?
  2. go 的 database 框架中的 tx ,reddit 确实有见到过 best practice 把 tx 在整个 gin 的 handler 传做接口原子的,这个思路是对的吗?
  3. 顺带问个 gorm 的问题,你们用 gorm 的话,还会把它再封装一层 dao 么,还是直接放到业务逻辑部分的代码中?

Golang Go语言中大家做后端开发时,都是怎么处理接口操作的原子性的?

更多关于Golang Go语言中大家做后端开发时,都是怎么处理接口操作的原子性的?的实战系列教程也可以访问 https://www.itying.com/category-94-b0.html

26 回复

事务塞 context 里,然后从 context 取事务。所有方法不管你有没有用到,总之规定好第一个参数就默认是 ctx context.Context ,算是 go 写业务的标准做法了

至于在哪里开启事务,我喜欢在相关复杂业务逻辑起始的地方,比如 doamin service 里,然后同时 rollback 也是在 domain service (当然 tx 这东西肯定要包装抽象一下的不能直接用)。至于别人为什么不开事务,要么就是数据库操作太简单一行 sql 结束,要么就是根本没考虑倒需要在业务层用事务(以我经验,大部分人属于后者,就是纯粹的没有项目经验想不到那一层)

> 顺带问个 gorm 的问题,你们用 gorm 的话,还会把它再封装一层 dao 么,还是直接放到业务逻辑部分的代码中?

repository 了解下,想好好写业务的话直接的数据库操作之类的不应该放到 doamin 层

更多关于Golang Go语言中大家做后端开发时,都是怎么处理接口操作的原子性的?的实战系列教程也可以访问 https://www.itying.com/category-94-b0.html


“要么就是数据库操作太简单一行 sql 结束” 这里说错了,不是数据操作太简单,而是业务太简单,涉及不到多数据的互动保存,或者干脆就是把很多本应放入业务逻辑层的逻辑给塞进 repository 这一层里了。

看完你写的我还是没搞清楚原子性和 Go 有什么关系。。。

atomic/mutex 和事务/分布式锁

ORM? 那个 interface (可以时 db 也可以是 tx ) 放 ctx ,操作数据库时从 ctx 获取 DB 来处理,这样处理数据库的部分不用关心用的是 tx 还是 db ,除非那部分操作需要 tx 。http server 那边通过一个 middleware 把 ORM interface 注入到 ctx 并开启一个 tx ,http handler 失败了 rollback ,成功了 commit 。这种情况下 http handler 内部的操作还需要 tx 的话 orm 里应该运行的是 nested tx 。

可以参考几年前我给 harbor 添加的这部分实现(主要为了解决之前的版本一个 API 成功一部分导致的脏数据问题),把 middleware 和 orm 换成自己的就可以了。

middlwares

https://github.com/goharbor/harbor/blob/main/src/server/middleware/orm/orm.go
https://github.com/goharbor/harbor/blob/main/src/server/middleware/transaction/transaction.go


db operation

https://github.com/goharbor/harbor/blob/main/src/pkg/blob/dao/dao.go#L111

db operation requires tx

https://github.com/goharbor/harbor/blob/main/src/pkg/project/dao/dao.go#L91

  1. 没看明白你说的原子性, 你说的超出了 golang 的认知原子性(你要表达的是同一次请求原子性?)
    2. 不需要开启事务的查询, 直接从连接池中获取 client 来查询, 需要开启事务的代码, 从连接池中获取 client, 然后所有的查询用这个 client 去操作
    3. 封

    ctx 肯定要传递, 不然你查询 redis, mysql. 客户端取消了请求, 你传 ctx 就可以直接中止查询, 不然请求 goroutine 还在跑

同 1 楼,放 ctx 里,

开启事务:


transactions.RunInTx(ctx, fun(ctx context.Context) error {
// biz logic
})


repository 实现

func (r *Repository) FindXXX(ctx context.Context, id string) (xxx, error) {
return transactions.Db(ctx).Find(ctx, …)
}


事务对业务的侵入性也小,有一行代码的事

想问问佬,关于 Redis 的操作也放在 Repository 层做吗

将一个接口 wrap 进一个 db transaction 本身只保证了 db 操作的“原子性”,这还建立在本身 db 的 txn 处于正确的 isolation level 下。

所谓的接口原子性要考虑的问题远比这个复杂,如果使用了其他的后端服务,诸如 Redis 写入、第三方系统 API 调用,当非原子操作产生时,这些服务是否均支持回滚?是否保证回滚时的一致性?这样就需要从业务逻辑去考虑,然后落实到技术层面去解决,比如 Redis 是否需要 transaction 去配合?对于不支持原子的操作技术上如何取消?无法取消的事务如何在业务跟技术层面去做补偿?

emmm 虽然是个比较复杂的问题,但就结果来说 redis 相关的操作最终放入 repository 层的情况会更多。因为即便是用 Redis ,很多时候和用 mysql 的目的也是一样的——都是为了读写 Entity 。涉及到 Entity 的读取恢复的话,那就是 repository 的职责了。

你说的接口原子性到底是啥?
同一时间同一操作的接口原子性?
还是说同一时间业务系统接口是原子性?

如果是前者不就是接口并发锁吗?
如果是后者不就是单机单线程系统吗,有处理中的请求其他请求直接阻塞等待前置处理完毕?

最好拆分业务逻辑

我的分层是这样的:
handler -> service -> dao/repository -> model

事务的开启是在 service , 操作数据的是在 dao 或 repo 层,model 仅仅定义数据字段和表名(无任何 db 操作)。

PS:也像楼主一样考虑过,将这些事务放在一起,且放置于 dao 里,也就不用传递 tx 了,但会带来一个问题: 一个 dao 要操作多个 model (或者说表), 我目前是倾向于一个 dao 操作一个 model ,这样 dao 的职责就很清晰, 也方便 cli 工具自动生成 dao 。

提到的 redis 操作,我把他们都放在了 cache 目录里(和 dao 、service 在同一级), 然后供 dao/repo 调用,也就是 dao/repo 扮演了数据操作的角色,不关是 接口、db 、redis 、MongoDB 、ES 等都在这里操作,供上层的 service 调用,一个 service 可以调用多个 dao, 只依赖 dao 定义的接口,方便使用 wire 做依赖注入。

代码可参考: https://github.com/go-microservice/moment-service/blob/main/internal/service/post_svc.go#L88

分布式的事务(比如涉及多个数据库和服务)建议用最终一致性,关系型数据库先提交,成功后再提交其他部分,失败走任务队列重试,直到成功。

关于 gorm 的使用,我是这么干的
+ https://github.com/EchoGroot/kratos-examples

涉及相关功能
+ 通过 grom 操作 postgres 数据库
+ 封装 gorm 的辅助工具类,提供了基础的 CRUD 方法,通过泛型实现。 命名参照 mybatisplus 的 mapper
+ 使用 BeforeCreate 钩子函数,自动生成 id
+ 封装分页查询操作
+ 使用可选函数封装数据库连接初始化

  1. 接口原子性没太明白,如果是数据库操作的原子性的话,就是开启事务。如果是接口的,那加个分布式锁?
    2. tx 一般在 service 层有个接口实现在 ctx 中注入 orm ,接口的实现在 repo 层,一般就如下使用方式:
    <br>type Transaction interface {<br> TX(ctx context.Context, fn func(ctx context.Context) error) error<br>}<br><br>func (d *Repo) TX(ctx context.Context, fn func(ctx context.Context) error) error {<br> return d.db.Transaction(func(tx *gorm.DB) (err error) {<br> defer func() {<br> if e := recover(); e != nil {<br> err = fmt.Errorf("%#v", e)<br> // log<br> }<br> }()<br> ctx = context.WithValue(ctx, contextTxKey{}, tx)<br> err = fn(ctx)<br> return err<br> })<br>}<br><br>
    service 层调用各 repo:
    <br><br>s.tm.TX(ctx, func(ctx context.Context) error {<br> if err := s.Repo1.Create(ctx, ...); err != nil{<br> return err<br> }<br> return nil<br>})<br>
    3. 个人会封装一层,用于常规的 curd 实现,然后各个 dao 不通用的部分单独写

补充:如果想简单操作,也可以在 dao 里(一个 dao 操作多个 model 也还好)统一操作, 利用 gorm 的 Transaction 函数:

go<br>// dao/example.go<br>// 开始事务<br> tx := db.Begin()<br> if tx.Error != nil {<br> log.Fatalf("failed to begin transaction: %v", tx.Error)<br> }<br><br> // 事务中执行业务逻辑<br> err = tx.Transaction(func(tx *gorm.DB) error {<br> // 插入一条数据<br> err := tx.Create(&amp;Model{Name: "test"}).Error<br> if err != nil {<br> return err<br> }<br><br> // 查询数据<br> var model Model<br> err = tx.First(&amp;model, "name = ?", "test").Error<br> if err != nil {<br> return err<br> }<br><br> fmt.Printf("Found: %v\n", model)<br><br> // 更新数据<br> err = tx.Model(&amp;model).Update("name", "updated").Error<br> if err != nil {<br> return err<br> }<br><br> fmt.Printf("Updated: %v\n", model)<br><br> return nil<br> })<br><br> // 根据事务结果提交或回滚<br> if err != nil {<br> tx.Rollback()<br> log.Fatalf("transaction failed: %v", err)<br> } else {<br> tx.Commit()<br> fmt.Println("transaction committed successfully")<br> }<br>

类似于 提到的

我想如果按 userId 或业务 Id ,通过取模等方法将操作函数放入同一个线程/goroutin/channel ,类似 actor 模型,是不是也能解决你说的“原子性”的问题

确实见过直接放在 handler 的,直接 middleware 控制 commit rollback 的做法,buffalo 框架好像就是这样做,但这样感觉事务颗粒度就太细了影响性能吧。 一般我只在 service 开始开启,但传递确实可以塞在 ctx 里。

没想到晚上来看竟然这么多大佬回复,多谢各位。

评论中蛮多说没搞明白原子性什么意思,我没表达清,其实就是想说单纯说 mysql 的事务 2333 ,还没有用到 redis ,单纯希望在围绕 mysql 的业务流中,业务流和你使用的 atomic 包一样都是原子的不可再切分的。

另外多谢 的详细解释,看起来确实还是放在 ctx 里主流一些,容我研究研究。

  1. 事务
    2. 最终一致性

没看出啥,麻烦详细说下

题主想表达的应该是,一个 http 请求 可能涉及多个事务,假设
A B 事务成功
C 事务失败时 是否应该把 A B 一起回滚?

因为假设封装了 dao 实现这个并不容易

所以直接在整个 http 请求的生命周期里用同一个事务,假设生命周期内存在任意错误,则全部回滚吧?

gorm 正常用还是要封装一下的,但是你没必要自己写啊,人家提供了 gen 工具,帮你生成 repo https://gorm.io/gen/index.html

在Go语言中进行后端开发时,处理接口操作的原子性是一个关键问题,直接关系到数据的一致性和系统的稳定性。以下是几种常见的处理方法:

  1. 使用Mutex(互斥锁): 对于需要保护的共享资源,可以使用Go内置的sync.Mutexsync.RWMutex(读写锁)来实现。通过锁定和解锁操作,确保同一时间只有一个goroutine能访问共享资源,从而实现原子性。

  2. 使用Channel(通道): Go的channel提供了另一种实现原子操作的方式。通过goroutine间的通信,可以确保某些操作按顺序执行,从而避免并发问题。channel的发送和接收操作是原子性的,适合用于控制并发流。

  3. 利用数据库的事务特性: 如果接口操作涉及到数据库,可以利用数据库的事务(Transaction)来确保操作的原子性。事务可以确保一系列操作要么全部成功,要么全部失败,从而保持数据的一致性。

  4. 使用第三方库: Go社区提供了许多并发控制和原子操作的第三方库,如sync/atomic包中的原子操作函数,可以直接操作基本数据类型的原子值。这些库提供了更高级和细粒度的控制手段。

  5. 设计无锁算法: 在某些场景下,可以通过设计无锁算法(如CAS操作)来避免锁的使用,从而提高并发性能。但这通常需要深入理解并发编程和算法设计。

总之,处理接口操作的原子性需要根据具体场景选择合适的方法,并结合Go语言的特性进行实现。

回到顶部