使用Go的Defer和Rust的Drop实现数据库事务机制的比较 - DEV


我学习 Rust 的极其缓慢的旅程仍在继续,被其他项目拖延了。我在 2021 年的注意力主要集中在 Go 和 PostgreSQL 上。
让我对 Rust 非常感兴趣的一件事是它为我提供的工具可以让我编写完全按照我期望的方式工作的代码,对其他开发人员强制执行这种行为,并帮助我避免我(或我团队中的其他人)出现的情况忘记做一些重要的事情,比如初始化一个值,关闭一个文件,或者一个 http 请求,或者一个数据库事务。
在 Go 中忘记关闭是可能会以难以找到的方式咬你!例如,对于数据库连接,记住始终回滚或提交事务非常重要。如果您忘记这样做,您可能会遇到这样的情况:您已经无法连接到自己,并且任何进一步的请求都会失败——您的服务会停止。
有几种方法可以做到这一点。最基本最直接的方法是每次返回时调用rollback或commit:

func someWork() error {
    tx, err := db.Begin()

    err := foo(tx)

    if err != nil {
        tx.Rollback()
        return err
    }

    err = bar(tx)

    if err != nil {
        tx.Rollback()
        return err
    }

    return tx.Commit()
}

这冒着我们在返回一些错误时忘记添加 tx.Rollback() 的风险——这很容易发生,特别是当我们重构代码并将代码从其他地方移动到这里时,这些代码以前不需要进行错误检查伴随着回滚。
一个更安全的选择是将调用推迟到回滚,以确保它始终被调用,因为延迟回滚调用是否在成功提交之后酒无关紧要:

func someWork() error {
    tx, err := db.Begin()
    defer tx.Rollback()

    err := foo(tx)

    if err != nil {
        return err
    }

    err = bar(tx)

    if err != nil {
        return err
    }

    return tx.Commit()
}

这更好,因为我们现在只需要记住调用一次回滚,并确保我们在一切正常时提交。然而,它仍然不完美,这就是另一支脚踏枪可能出现的地方。假设我们有一个循环,并且我们在循环的每次迭代中创建新事务。
这是一个循环,我们每次都开始一个新事务,但无论出于何种原因,我们都不想提交我们已经完成的工作(或者,我们确实希望从循环的某些迭代中提交工作,但不是全部 - - 例如,有错误,我们继续下一次迭代而不提交):
func deferInLoop() {
    for i := 0; i < loops; i++ {
        var result bool
        tx, err := db.Begin()
        defer tx.Rollback()

        if err != nil {
            fmt.Println(err.Error())
            continue
        }
        err = tx.QueryRow("SELECT true").Scan(&result)
        if err != nil {
            fmt.Println(err.Error())
            continue
        }
        log.Printf(
"loop count %d.  Result: %t", i, result)
    }
}

如果我们尝试执行它,我们会发现我们的连接耗尽并且服务崩溃:
2021/11/10 00:36:08 loop count 92.  Result: true
2021/11/10 00:36:08 loop count 93.  Result: true
2021/11/10 00:36:08 loop count 94.  Result: true
2021/11/10 00:36:08 loop count 95.  Result: true
2021/11/10 00:36:08 loop count 96.  Result: true
2021/11/10 00:36:08 loop count 97.  Result: true
2021/11/10 00:36:08 loop count 98.  Result: true
2021/11/10 00:36:08 loop count 99.  Result: true
pq: sorry, too many clients already
pq: sorry, too many clients already
pq: sorry, too many clients already
pq: sorry, too many clients already
pq: sorry, too many clients already
...
pq: sorry, too many clients already
pq: sorry, too many clients already
panic: runtime error: invalid memory address or nil pointer dereference
panic: runtime error: invalid memory address or nil pointer dereference
panic: runtime error: invalid memory address or nil pointer dereference
panic: runtime error: invalid memory address or nil pointer dereference
panic: runtime error: invalid memory address or nil pointer dereference
...
[signal SIGSEGV: segmentation violation code=0x1 addr=0x40 pc=0x10c3e62]

goroutine 1 [running]:
database/sql.(*Tx).rollback(0xc000028068, 0x0)
        /usr/local/go/src/database/sql/sql.go:2263 +0x22
database/sql.(*Tx).Rollback(0x0)
        /usr/local/go/src/database/sql/sql.go:2295 +0x1b
panic({0x120c840, 0x13e6ec0})

我们可以发现自己处于类似的情况,我们有开始事务的长期存在的函数,但直到很晚才返回——足够长的时间,该函数的并发调用加起来最终使我们自己再次挨饿,因为延迟回滚还没有被调用(如果事务变量在函数结束之前不会离开作用域,Rust 在这里也无济于事)。
回顾我们之前的课程,我们需要记住每次返回时回滚。为了避免每次出现错误时都必须调用回滚,我们推迟了回滚,以便每次函数返回时它都运行。但是,在某些情况下,延迟不会被足够早地调用,以免让我们避免连接饥饿。我们可以做什么?我们可以在这些情况下恢复到每次返回/继续时调用回滚:

func rollbackInLoop() {
    for i := 0; i < loops; i++ {
        var result bool
        tx, err := db.Begin()

        if err != nil {
            fmt.Println(err.Error())
            tx.Rollback()
            continue
        }
        err = tx.QueryRow("SELECT true").Scan(&result)
        if err != nil {
            fmt.Println(err.Error())
            tx.Rollback()
            continue
        }
        log.Printf(
"loop count %d.  Result: %t", i, result)
        tx.Rollback()
    }
}

或者,我们可以在一个单独的函数中执行该事务的工作,该函数将为循环的每次迭代返回。因此,在循环的下一次迭代开始事务之前调用 defer:

func deferInFunc() {
    for i := 0; i < loops; i++ {
        err := deferInFuncFetch(db, i)
        if err != nil {
            fmt.Println(err.Error())
            continue
        }
    }
}

func deferInFuncFetch(db *sql.DB, i int) error {
    var result bool
    tx, err := db.Begin()
    defer tx.Rollback()

    if err != nil {
        return err
    }
    err = tx.QueryRow("SELECT true").Scan(&result)
    if err != nil {
        return err
    }
    log.Printf(
"loop count %d", i)
    err = tx.Commit()

    if err != nil {
        return err
    }
    return nil
}

这些解决方案是有效的,但它们是我们必须记住要小心的事情——我们很容易忘记这些事情,因为在我们发现问题之前,没有任何事情警告我们我们已经做了一些导致问题的事情生产服务以奇怪且不可预测的方式失败。如果我们能够以一种您不会忘记做这些事情的方式编写我们的 Go 代码,那就太棒了。例如,默认情况下会及时自动调用回滚,或者如果我们错过了一个案例,编译器就会抛出错误。
这让我想知道如何在 Rust 中处理它。
 
Rust中处理
Rust 提供了可以在结构上实现的 Drop trait。drop 函数会像 C++ 中的析构函数一样被调用,以便您可以清理内容。当所有者离开时会发生这种情况,因此它使我们有机会在函数返回的时间点之前执行操作。例如,如果变量超出范围,则可能会在循环的每次迭代结束时调用 drop。
这为我们提供了一个很好的地方来确保在我们忘记时调用回滚。因此,我们可以以一种防止我们忘记的方式保证事务最终会回滚。
进一步调查,我们可以看到 Transaction 结构实现了 Drop trait。具体来说:

impl<'a> Drop for Transaction<'a> {
    fn drop(&mut self) {
        if let Some(transaction) = self.transaction.take() {
            let _ = self.connection.block_on(transaction.rollback());
        }
    }
}

因此,我们甚至不需要自己实现任何东西来确保调用回滚——不需要调用回滚,也不需要安排延迟回滚。在使用这个库时,如果我们忽略提交事务,那么它会回滚,并且及时。假设我们实现了一个类似于 Go 中的循环函数:
fn loop_() -> Result<(), Box<dyn std::error::Error>> {
    let mut client = Client::connect("host=localhost user=postgres", NoTls)?;

    for i in 0..200 {
        let mut transaction = client.transaction()?;

        let row = transaction.query_one(
"SELECT true", &[])?;
        let result: bool = row.get(0);

        println!(
"loop count {} result: {}", i, result);
    }

    Ok(())
}

这与我们希望的完全一样——200 次迭代,没有问题。这是因为每次循环迭代结束时,事务结构都会被删除,并在下一次迭代开始之前调用回滚。
查看另一个 Rust 库sqlx,我们发现使用了与 drop 相同的方法:
事务应以调用提交或回滚结束。如果在事务超出范围之前都没有调用,则调用回滚。换句话说,如果事务仍在进行中,则在删除时调用回滚。

因此,当我们忽略回滚时,有助于确保连接不会永远存在。drop 的有用之处在于我们不必依赖函数返回,或在单独的函数中运行事务。一个块结束,结构将被释放就足够了,以便调用默认回滚。
如果你想探索更多,我已经将我用来玩这个的代码上传到github.com/saward/footgun-defer