V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
The Go Programming Language
http://golang.org/
Go Playground
Go Projects
Revel Web Framework
HarrisIce
V2EX  ›  Go 编程语言

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

  •  
  •   HarrisIce · 2024-03-07 11:57:58 +08:00 · 4115 次点击
    这是一个创建于 372 天前的主题,其中的信息可能已经有所发展或是发生改变。

    背景

    我个人习惯,开发带数据库的后端时,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 么,还是直接放到业务逻辑部分的代码中?
    25 条回复    2024-03-09 11:25:36 +08:00
    BeautifulSoap
        1
    BeautifulSoap  
       2024-03-07 12:28:20 +08:00   ❤️ 2
    事务塞 context 里,然后从 context 取事务。所有方法不管你有没有用到,总之规定好第一个参数就默认是 ctx context.Context ,算是 go 写业务的标准做法了

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

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

    repository 了解下,想好好写业务的话直接的数据库操作之类的不应该放到 doamin 层
    BeautifulSoap
        2
    BeautifulSoap  
       2024-03-07 12:33:03 +08:00
    @BeautifulSoap "要么就是数据库操作太简单一行 sql 结束" 这里说错了,不是数据操作太简单,而是业务太简单,涉及不到多数据的互动保存,或者干脆就是把很多本应放入业务逻辑层的逻辑给塞进 repository 这一层里了。
    mooyo
        3
    mooyo  
       2024-03-07 12:40:16 +08:00
    看完你写的我还是没搞清楚原子性和 Go 有什么关系。。。
    Nazz
        4
    Nazz  
       2024-03-07 12:58:23 +08:00 via Android
    atomic/mutex 和事务/分布式锁
    heww
        5
    heww  
       2024-03-07 13:17:30 +08:00
    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
    seth19960929
        6
    seth19960929  
       2024-03-07 13:36:50 +08:00
    1. 没看明白你说的原子性, 你说的超出了 golang 的认知原子性(你要表达的是同一次请求原子性?)
    2. 不需要开启事务的查询, 直接从连接池中获取 client 来查询, 需要开启事务的代码, 从连接池中获取 client, 然后所有的查询用这个 client 去操作
    3. 封

    ctx 肯定要传递, 不然你查询 redis, mysql. 客户端取消了请求, 你传 ctx 就可以直接中止查询, 不然请求 goroutine 还在跑
    thevita
        7
    thevita  
       2024-03-07 14:44:10 +08:00   ❤️ 1
    同 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, ...)
    }


    事务对业务的侵入性也小,有一行代码的事
    gitrebase
        8
    gitrebase  
       2024-03-07 15:17:05 +08:00
    @BeautifulSoap 想问问佬,关于 Redis 的操作也放在 Repository 层做吗
    lxdlam
        9
    lxdlam  
       2024-03-07 15:28:19 +08:00   ❤️ 1
    将一个接口 wrap 进一个 db transaction 本身只保证了 db 操作的“原子性”,这还建立在本身 db 的 txn 处于正确的 isolation level 下。

    所谓的接口原子性要考虑的问题远比这个复杂,如果使用了其他的后端服务,诸如 Redis 写入、第三方系统 API 调用,当非原子操作产生时,这些服务是否均支持回滚?是否保证回滚时的一致性?这样就需要从业务逻辑去考虑,然后落实到技术层面去解决,比如 Redis 是否需要 transaction 去配合?对于不支持原子的操作技术上如何取消?无法取消的事务如何在业务跟技术层面去做补偿?
    BeautifulSoap
        10
    BeautifulSoap  
       2024-03-07 15:43:31 +08:00   ❤️ 1
    @gitrebase emmm 虽然是个比较复杂的问题,但就结果来说 redis 相关的操作最终放入 repository 层的情况会更多。因为即便是用 Redis ,很多时候和用 mysql 的目的也是一样的——都是为了读写 Entity 。涉及到 Entity 的读取恢复的话,那就是 repository 的职责了。
    8355
        11
    8355  
       2024-03-07 15:52:18 +08:00
    你说的接口原子性到底是啥?
    同一时间同一操作的接口原子性?
    还是说同一时间业务系统接口是原子性?

    如果是前者不就是接口并发锁吗?
    如果是后者不就是单机单线程系统吗,有处理中的请求其他请求直接阻塞等待前置处理完毕?
    qinze113
        12
    qinze113  
       2024-03-07 16:48:05 +08:00
    最好拆分业务逻辑
    qloog
        13
    qloog  
       2024-03-07 17:02:21 +08:00   ❤️ 2
    我的分层是这样的:
    handler -> service -> dao/repository -> model

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

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

    @gitrebase 提到的 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
    keakon
        14
    keakon  
       2024-03-07 17:16:06 +08:00
    分布式的事务(比如涉及多个数据库和服务)建议用最终一致性,关系型数据库先提交,成功后再提交其他部分,失败走任务队列重试,直到成功。
    EchoGroot
        15
    EchoGroot  
       2024-03-07 17:24:49 +08:00
    关于 gorm 的使用,我是这么干的
    + https://github.com/EchoGroot/kratos-examples

    涉及相关功能
    + 通过 grom 操作 postgres 数据库
    + 封装 gorm 的辅助工具类,提供了基础的 CRUD 方法,通过泛型实现。 命名参照 mybatisplus 的 mapper
    + 使用 BeforeCreate 钩子函数,自动生成 id
    + 封装分页查询操作
    + 使用可选函数封装数据库连接初始化
    nobject
        16
    nobject  
       2024-03-07 17:25:58 +08:00
    1. 接口原子性没太明白,如果是数据库操作的原子性的话,就是开启事务。如果是接口的,那加个分布式锁?
    2. tx 一般在 service 层有个接口实现在 ctx 中注入 orm ,接口的实现在 repo 层,一般就如下使用方式:
    ```
    type Transaction interface {
    TX(ctx context.Context, fn func(ctx context.Context) error) error
    }

    func (d *Repo) TX(ctx context.Context, fn func(ctx context.Context) error) error {
    return d.db.Transaction(func(tx *gorm.DB) (err error) {
    defer func() {
    if e := recover(); e != nil {
    err = fmt.Errorf("%#v", e)
    // log
    }
    }()
    ctx = context.WithValue(ctx, contextTxKey{}, tx)
    err = fn(ctx)
    return err
    })
    }

    ```
    service 层调用各 repo:
    ```

    s.tm.TX(ctx, func(ctx context.Context) error {
    if err := s.Repo1.Create(ctx, ...); err != nil{
    return err
    }
    return nil
    })
    ```
    3. 个人会封装一层,用于常规的 curd 实现,然后各个 dao 不通用的部分单独写
    qloog
        17
    qloog  
       2024-03-07 17:36:56 +08:00
    补充:如果想简单操作,也可以在 dao 里(一个 dao 操作多个 model 也还好)统一操作, 利用 gorm 的 Transaction 函数:

    ```go
    // dao/example.go
    // 开始事务
    tx := db.Begin()
    if tx.Error != nil {
    log.Fatalf("failed to begin transaction: %v", tx.Error)
    }

    // 事务中执行业务逻辑
    err = tx.Transaction(func(tx *gorm.DB) error {
    // 插入一条数据
    err := tx.Create(&Model{Name: "test"}).Error
    if err != nil {
    return err
    }

    // 查询数据
    var model Model
    err = tx.First(&model, "name = ?", "test").Error
    if err != nil {
    return err
    }

    fmt.Printf("Found: %v\n", model)

    // 更新数据
    err = tx.Model(&model).Update("name", "updated").Error
    if err != nil {
    return err
    }

    fmt.Printf("Updated: %v\n", model)

    return nil
    })

    // 根据事务结果提交或回滚
    if err != nil {
    tx.Rollback()
    log.Fatalf("transaction failed: %v", err)
    } else {
    tx.Commit()
    fmt.Println("transaction committed successfully")
    }
    ```

    类似于 @thevita 提到的
    assiadamo
        18
    assiadamo  
       2024-03-07 17:38:25 +08:00
    我想如果按 userId 或业务 Id ,通过取模等方法将操作函数放入同一个线程/goroutin/channel ,类似 actor 模型,是不是也能解决你说的“原子性”的问题
    ninjashixuan
        19
    ninjashixuan  
       2024-03-07 17:52:08 +08:00
    确实见过直接放在 handler 的,直接 middleware 控制 commit rollback 的做法,buffalo 框架好像就是这样做,但这样感觉事务颗粒度就太细了影响性能吧。 一般我只在 service 开始开启,但传递确实可以塞在 ctx 里。
    HarrisIce
        20
    HarrisIce  
    OP
       2024-03-07 19:50:54 +08:00
    没想到晚上来看竟然这么多大佬回复,多谢各位。

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

    另外多谢 @BeautifulSoap @heww @thevita @qloog @lxdlam @EchoGroot @nobject 的详细解释,看起来确实还是放在 ctx 里主流一些,容我研究研究。
    nodesolar
        21
    nodesolar  
       2024-03-08 09:14:57 +08:00
    1. 事务
    2. 最终一致性
    EchoGroot
        23
    EchoGroot  
       2024-03-08 12:51:15 +08:00
    @distleilei 没看出啥,麻烦详细说下
    skyqqcc581
        24
    skyqqcc581  
       2024-03-09 10:47:53 +08:00
    题主想表达的应该是,一个 http 请求 可能涉及多个事务,假设
    A B 事务成功
    C 事务失败时 是否应该把 A B 一起回滚?

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

    所以直接在整个 http 请求的生命周期里用同一个事务,假设生命周期内存在任意错误,则全部回滚吧?
    Makabaka01
        25
    Makabaka01  
       2024-03-09 11:25:36 +08:00
    gorm 正常用还是要封装一下的,但是你没必要自己写啊,人家提供了 gen 工具,帮你生成 repo https://gorm.io/gen/index.html
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   实用小工具   ·   5523 人在线   最高记录 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 26ms · UTC 08:59 · PVG 16:59 · LAX 01:59 · JFK 04:59
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.