Go大师课程系列将学习
这篇,学习如何使用PostgreSQL、Golang和Docker来设计一个简单的银行后端系统。分为以下三个部分
- 数据库事务
- 使用 Go 实现 DB 事务
数据库事务
什么是 DB 事务?
嗯,基本上,它是一个单一的工作单元,通常由多个数据库操作组成。
例如,在我们的简单银行中,我们想要
10 USD
从转账account 1
到account 2
。
该交易包含 5 个操作:
- 我们创建一个
transfer
数量等于10的记录。 - 我们创建了一条金额等于的
entry
记录,因为资金正在从该账户中转出。account 1``-10
- 我们
entry
为 创建另一条记录account 2
,但金额等于10
,因为资金正在转入该帐户。 - 然后我们通过减去
balance
来更新。account 1``10
- 最后,我们通过添加来更新
balance
它。account 2``10
这就是我们在本文中要实现的交易。我们稍后会讲到这一点。
为什么需要使用DB事务?
主要有两个原因:
- 我们希望我们的工作单元可靠且一致,即使在系统出现故障的情况下。
- 我们希望在同时访问数据库的程序之间提供隔离。
ACID 属性
为了实现这两个目标,数据库事务必须满足以下ACID
属性:
A
是Atomicity
,这意味着要么事务的所有操作都成功完成,要么整个事务失败,并且一切都回滚,数据库保持不变。C
是Consistency
,这意味着在事务执行后数据库状态应该保持有效。更准确地说,写入数据库的所有数据都必须根据预定义的规则有效,包括约束、级联和触发器。I
是Isolation
,这意味着所有并发运行的事务都不应相互影响。隔离有多个级别,定义一个事务所做的更改何时对其他事务可见。我们将在另一堂课中详细了解它。- 最后一个属性是
D
,代表Durability
。它的基本意思是,成功交易写入的所有数据必须保留在持久存储中,即使在系统发生故障的情况下也不会丢失。
使用 Go 实现 DB 事务
通用事务
首先我们先创建一个Store.go
package db
import (
"github.com/jackc/pgx/v5/pgxpool"
)
// Store defines all functions to execute db queries and transactions
type Store interface {
Querier
}
// SQLStore provides all functions to execute SQL queries and transactions
type SQLStore struct {
connPool *pgxpool.Pool
*Queries
}
// NewStore creates a new store
func NewStore(connPool *pgxpool.Pool) Store {
return &SQLStore{
connPool: connPool,
Queries: New(connPool),
}
}
执行通用数据库事务
package db
import (
"context"
"fmt"
)
// ExecTx executes a function within a database transaction
func (store *SQLStore) execTx(ctx context.Context, fn func(*Queries) error) error {
tx, err := store.connPool.Begin(ctx)
if err != nil {
return err
}
q := New(tx)
err = fn(q)
if err != nil {
if rbErr := tx.Rollback(ctx); rbErr != nil {
return fmt.Errorf("tx err: %v, rb err: %v", err, rbErr)
}
return err
}
return tx.Commit(ctx)
}
现在我们有了在事务中运行的查询,我们可以使用该查询调用输入函数,并返回错误。
-
如果错误不是nil,那么我们需要通过调用来回滚事务tx.Rollback()。它还会返回回滚错误。
-
如果回滚错误也不是nil,那么我们必须报告 2 个错误。因此,我们应该fmt.Errorf()在返回之前使用以下方法将它们合并为 1 个错误:
-
如果回滚成功,我们只返回原始交易错误。
最后,如果事务中的所有操作都成功,我们只需使用 提交事务tx.Commit(),并将其错误返回给调用者。
函数已经写完了execTx()。请注意,这个函数是未导出的(以小写字母 e 开头),因为我们不希望外部包直接调用它。相反,我们将为每个特定交易提供一个导出函数。
实施汇款交易TransferTx()
回想一下,它将创建一个新的转账记录,添加 2 个新的账户条目,并在单个数据库交易中更新 2 个账户的余额。
该函数的输入将是一个上下文和一个类型的参数对象TransferTxParams。它将返回一个TransferTxResult对象或一个错误。
func (store *Store) TransferTx(ctx context.Context, arg TransferTxParams) (TransferTxResult, error) {
}
该TransferTxParams结构包含在两个账户之间转账所需的所有必要输入参数:
type TransferTxParams struct {
FromAccountID int64 `json:"from_account_id"`
ToAccountID int64 `json:"to_account_id"`
Amount int64 `json:"amount"`
}
- FromAccountID是汇款账户的 ID。
- ToAccountID是将要汇款的账户的 ID。
最后一个字段是Amount需要发送的金额。 该TransferTxResult结构包含转账交易的结果。它有 5 个字段:
type TransferTxResult struct {
Transfer Transfer `json:"transfer"`
FromAccount Account `json:"from_account"`
ToAccount Account `json:"to_account"`
FromEntry Entry `json:"from_entry"`
ToEntry Entry `json:"to_entry"`
}
- 已创建的Transfer记录。
- FromAccount减去其余额后。
- 之后ToAccount其余额就被添加了。
- FromEntry记录资金转出账户的FromAccount。
- 以及ToEntry记录资金转入的账户ToAccount。
梳理好这些之后,就可以写TransferTx函数
// TransferTx performs a money transfer from one account to the other.
// It creates the transfer, add account entries, and update accounts' balance within a database transaction
func (store *SQLStore) TransferTx(ctx context.Context, arg TransferTxParams) (TransferTxResult, error) {
var result TransferTxResult
err := store.execTx(ctx, func(q *Queries) error {
var err error
result.Transfer, err = q.CreateTransfer(ctx, CreateTransferParams{
FromAccountID: arg.FromAccountID,
ToAccountID: arg.ToAccountID,
Amount: arg.Amount,
})
if err != nil {
return err
}
result.FromEntry, err = q.CreateEntry(ctx, CreateEntryParams{
AccountID: arg.FromAccountID,
Amount: -arg.Amount,
})
if err != nil {
return err
}
result.ToEntry, err = q.CreateEntry(ctx, CreateEntryParams{
AccountID: arg.ToAccountID,
Amount: arg.Amount,
})
if err != nil {
return err
}
// TODO: update accounts' balance
return nil
})
return result, err
}
更新帐户余额的最后一步将更加复杂,因为它涉及到locking并防止潜在的deadlock。
所以我认为值得单独开一堂课来详细讨论。我们先在这里添加一个 TODO 注释
测试汇款交易
现在假设我们的转账交易已完成,有 1 条转账记录,并创建了 2 个账户条目。我们必须对其进行测试,以确保其按预期运行。
我要创建一个新store_test.go文件。它与我们的位于同一个db包中store.go。然后让我们为该TransferTx()函数定义一个新的单元测试。测试将分为以下几个步骤 在进行n个并行转账交易后,
-
检查转账操作是否成功。
-
检查交易记录。
-
分别检查:
-
- 账户信息
-
- 账户余额
-
- 最终验证更新后的余额。
package db
import (
"context"
"fmt"
"testing"
"github.com/stretchr/testify/require"
)
func TestTransferTx(t *testing.T) {
account1 := createRandomAccount(t)
account2 := createRandomAccount(t)
fmt.Println(">> before:", account1.Balance, account2.Balance)
n := 5
amount := int64(10)
errs := make(chan error)
results := make(chan TransferTxResult)
// run n concurrent transfer transaction
for i := 0; i < n; i++ {
go func() {
result, err := testStore.TransferTx(context.Background(), TransferTxParams{
FromAccountID: account1.ID,
ToAccountID: account2.ID,
Amount: amount,
})
errs <- err
results <- result
}()
}
// check results
existed := make(map[int]bool)
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)
_, err = testStore.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 = testStore.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 = testStore.GetEntry(context.Background(), toEntry.ID)
require.NoError(t, err)
// check accounts
fromAccount := result.FromAccount
require.NotEmpty(t, fromAccount)
require.Equal(t, account1.ID, fromAccount.ID)
toAccount := result.ToAccount
require.NotEmpty(t, toAccount)
require.Equal(t, account2.ID, toAccount.ID)
}