iT邦幫忙

2023 iThome 鐵人賽

DAY 9
0
自我挑戰組

Techschool Goalng Backend Master Class 的學習記錄系列 第 9

[Day 09] A clean way to implement database transaction in Golang Part 3

  • 分享至 

  • xImage
  •  

Test money transfer transaction

Modify Main_test.go to export *sql.DB

  • 在編寫stroe_test.go前需要先修正main_test.go
  • 因為NewStore需要用到 *sql.DB , 目前的main_test.go只有testQueries (*Queries) ,所以還需要將*sql.DB 設成全域變數。
main_test.go

var testQueries *Queries
var testDB *sql.DB

func TestMain(m *testing.M) {
    var err error
    testDB, err = sql.Open(dbDriver, dbSource)
    if err != nil {
        log.Fatal("cannot connect to db:", err)
    }

    testQueries = New(testDB)

    os.Exit(m.Run())
}

Store_test.go

  • 會使用Go Routine 來建立5筆Transaction,來模擬同時有多筆交易進行的情境。
package db

import (
	"context"
	"testing"

	"github.com/stretchr/testify/require"
)

func TestTransferTx(t *testing.T) {
	store := NewStore(testDB)

	// Create two accounts.
	account1 := createRandomAccount(t)
	account2 := createRandomAccount(t)

	n := 5
	amount := int64(10)

	// run n concurrent transfer transaction
	// Simulate multiple concurrent requests in real world
	errs := make(chan error)
	results := make(chan TransferTxResult)

	for i := 0; i < n; i++ {
		// 匿名函數的 goroutine
		go func() {
			result, err := store.TransferTx(context.Background(), TransferTxParams{
				FromAccountID: account1.ID,
				ToAccountID:   account2.ID,
				Amount:        amount,
			})
			errs <- err
			results <- result
		}()
	}

	// check results
	for i := 0; i < n; i++ {
		err := <-errs
		require.NoError(t, err)

		result := <-results
		require.NotEmpty(t, result)

		// check transfer
		transfer := result.Transfer
		require.NotEmpty(t, transfer)
		require.Equal(t, account1.ID, transfer.FromAccountID)
		require.Equal(t, account2.ID, transfer.ToAccountID)
		require.Equal(t, amount, transfer.Amount)
		require.NotZero(t, transfer.ID)
		require.NotZero(t, transfer.CreatedAt)

		// Need to to be sure that a transfer record is really created in the database
		_, err = store.GetTransfer(context.Background(), transfer.ID)
		require.NoError(t, err)

		// check entries
		fromEntry := result.FromEntry
		require.NotEmpty(t, fromEntry)
		require.Equal(t, account1.ID, fromEntry.AccountID)
		require.Equal(t, -amount, fromEntry.Amount)
		require.NotZero(t, fromEntry.ID)
		require.NotZero(t, fromEntry.CreatedAt)

		_, err = store.GetEntry(context.Background(), fromEntry.ID)
		require.NoError(t, err)

		toEntry := result.ToEntry
		require.NotEmpty(t, toEntry)
		require.Equal(t, account2.ID, toEntry.AccountID)
		require.Equal(t, amount, toEntry.Amount)
		require.NotZero(t, toEntry.ID)
		require.NotZero(t, toEntry.CreatedAt)

		_, err = store.GetEntry(context.Background(), toEntry.ID)
		require.NoError(t, err)

		// TODO: check accounts' balance

	}
}

Q & A:

  1. 為何在建立多個transaction時需要使用go routines?
    1. 使用 goroutines 是為了模擬同時有多筆交易進行的情境。在真實的系統中,多筆交易可能會在相近的時間點同時發生,且這些交易可能會相互影響。為了測試系統能否在高併發的情境下正常運作,我們會使用 goroutines 來模擬這種情境。
    2. 如果這裡不使用go routines的話, 會無法偵測資料庫的鎖定問題:當多筆交易試圖同時更新同一筆資料時,可能會遇到資料庫的鎖定或是競爭情況。只有在高併發的情境下,你才能觀察到並解決這些問題。
  2. go routinecheck 為何需要分成兩個不同的loop?
    1. 同步與非同步的操作區分:第一個 for 迴圈中的 goroutines 是非同步的操作,它會立即開啟多個 goroutines 並繼續執行後面的程式碼,而不會等待每個 goroutine 完成。這模擬了多個交易在同一時間發生的情境。
    2. 確保所有的 goroutines 都已開始執行:在進入結果檢查的 for 迴圈之前,我們希望確保所有的交易都已經開始執行。如果我們在同一個 for 迴圈中同時開始交易並檢查結果,可能在某些 goroutines 尚未開始執行時就已經進行結果檢查。
    3. 確保結果順序:由於 goroutines 的執行順序是不確定的,使用兩個 for 迴圈可以確保我們在所有交易都已經開始之後才開始檢查結果。這樣可以確保 errsresults 通道中的結果是按照開啟 goroutines 的順序進行檢查的。
  3. go routine 是非同步執行TransferTx,但不會遇到執行check loop時TransferTx還沒建立完成,而導致check fail嗎?
    1. 在你提供的程式碼中,TransferTx 的結果和錯誤都是透過 resultserrs 這兩個通道傳遞的。每個 goroutine 在完成其工作後會將結果或錯誤發送到這些通道中。這確保了在主程式進行結果檢查時,它會從這些通道中接收資料,並因此等待直到某個 goroutine 完成並發送資料。
    2. 因此,當主程式在結果檢查的 for 迴圈中從 errsresults 通道接收資料時,它實際上是在等待該 goroutine 完成。如果某個 goroutine 還沒完成,主程式就會在該通道上阻塞,直到接收到資料為止。這確保了我們不會在所有交易都已完成之前開始檢查結果。
    3. 所以,在 check **for** 迴圈中,每次迭代都會從 errsresults 通道接收資料,確保只有當某個 goroutine 完成其工作並發送資料時,主程式才會繼續執行並檢查該結果。這就確保了在檢查結果之前,相對應的 TransferTx 已經完全執行完成。

How to avoid DB DeadLock ?

  • 如果在Update前需要SELECT時,記得在SELECT裡加上FOR UPDATE ,可以避免兩個Transaction SELECT到同筆資料:
-- name: GetAccountForUpdate :one
SELECT *
FROM accounts
WHERE id = $1
LIMIT 1
FOR UPDATE;
  • 如果在Update的table同時存在FK的關係時,要加上NO KEY 這樣才不會造成Share Lock:
-- name: GetAccountForUpdate :one
SELECT *
FROM accounts
WHERE id = $1
LIMIT 1
FOR NO KEY
UPDATE;
  • 操作的順序要保持一致,這樣也可以避免Share Lock
if arg.FromAccountID < arg.ToAccountID {
			result.FromAccount, result.ToAccount, err = addMoney(ctx, q, arg.FromAccountID, -arg.Amount, arg.ToAccountID, arg.Amount)
		} else {
			result.ToAccount, result.FromAccount, err = addMoney(ctx, q, arg.ToAccountID, arg.Amount, arg.FromAccountID, -arg.Amount)
		}

上一篇
[Day 08] A clean way to implement database transaction in Golang Part 2
下一篇
[Day 10] DB transaction lock & How to handle deadlock in Golang Part 1
系列文
Techschool Goalng Backend Master Class 的學習記錄31
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言