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