Go大师课程(二): 数据库事务

288 阅读6分钟

Go大师课程系列将学习

这篇,学习如何使用PostgreSQL、Golang和Docker来设计一个简单的银行后端系统。分为以下三个部分

  • 数据库事务
  • 使用 Go 实现 DB 事务

数据库事务

什么是 DB 事务?

嗯,基本上,它是一个单一的工作单元,通常由多个数据库操作组成。

image.png 例如,在我们的简单银行中,我们想要10 USD从转账account 1account 2

image.png

该交易包含 5 个操作:

  1. 我们创建一个transfer数量等于10的记录。
  2. 我们创建了一条金额等于的entry记录,因为资金正在从该账户中转出。account 1``-10
  3. 我们entry为 创建另一条记录account 2,但金额等于10,因为资金正在转入该帐户。
  4. 然后我们通过减去balance来更新。account 1``10
  5. 最后,我们通过添加来更新balance它。account 2``10

这就是我们在本文中要实现的交易。我们稍后会讲到这一点。

为什么需要使用DB事务?

主要有两个原因:

  1. 我们希望我们的工作单元可靠且一致,即使在系统出现故障的情况下。
  2. 我们希望在同时访问数据库的程序之间提供隔离。

ACID 属性

为了实现这两个目标,数据库事务必须满足以下ACID属性:

image.png

  • AAtomicity,这意味着要么事务的所有操作都成功完成,要么整个事务失败,并且一切都回滚,数据库保持不变。
  • CConsistency,这意味着在事务执行后数据库状态应该保持有效。更准确地说,写入数据库的所有数据都必须根据预定义的规则有效,包括约束、级联和触发器。
  • IIsolation,这意味着所有并发运行的事务都不应相互影响。隔离有多个级别,定义一个事务所做的更改何时对其他事务可见。我们将在另一堂课中详细了解它。
  • 最后一个属性是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)


}

image.png