使用Go的Defer和Rust的Drop實現資料庫事務機制的比較 - DEV

banq發表於2021-12-02

我學習 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
...
<p>[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

 

相關文章