Go大师课程系列将学习
这篇,学习如何使用PostgreSQL、Golang和Docker来设计一个简单的银行后端系统。分为以下三个部分
-
测试驱动开发
-
更新账户余额(错误方式)
-
修复死锁
测试驱动开发
今天我将使用一种不同的实现方法,即测试驱动开发 (TDD)。其理念是:我们首先编写测试来打破当前代码的缺陷。然后我们逐渐改进代码,直到测试通过。
css
func TestTransferTx(t *testing.T) {
store := NewStore(testDB)
account1 := createRandomAccount(t)
account2 := createRandomAccount(t)
// run n concurrent transfer transactions
n := 5
amount := int64(10)
errs := make(chan error)
results := make(chan TransferTxResult)
for i := 0; i < n; i++ {
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)
_, 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
}
}
它创建了 5 个 goroutine 来执行 5 个并发的转账交易,每个交易都会将相同金额的钱从账户 1 转移到账户 2。然后它遍历结果列表以检查创建的转账和入账对象。
现在要完成这个测试,我们需要检查输出账户及其余额。
让我们从账户开始。首先是fromAccount
,资金流出的地方。我们检查它不应该为空。并且它ID
应该等于account1.ID
。
类似地toAccount
,资金流入其中。帐户对象不应为空。并且其ID
应等于account2.ID
。
css
func TestTransferTx(t *testing.T) {
...
// check results
for i := 0; i < n; i++ {
...
// 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)
// TODO: check accounts' balance
}
}
接下来我们将检查账户余额。我们计算diff1
输入account1.Balance
和输出之间的差额fromAccount.Balance
。这diff1
是从账户 1 流出的金额。
diff2
类似地,我们计算输出toAccount.Balance
和输入之间的差额account2.Balance
。这diff2
就是进入账户2的金额。
css
func TestTransferTx(t *testing.T) {
...
// check results
for i := 0; i < n; i++ {
...
// check accounts' balance
diff1 := account1.Balance - fromAccount.Balance
diff2 := toAccount.Balance - account2.Balance
require.Equal(t, diff1, diff2)
require.True(t, diff1 > 0)
require.True(t, diff1%amount == 0) // 1 * amount, 2 * amount, 3 * amount, ..., n * amount
}
}
如果交易正常进行那么diff1
和diff2
应该相同,并且它们应该是一个正数。
此外,该差额应能被每次交易中转移的资金量整除amount
。原因是,账户 1 的余额在第一笔交易后将减少 1 倍金额,在第二笔交易后减少 2 倍金额,第三笔交易后减少 3 倍金额,依此类推。
因此,如果我们计算k = diff1 / amount
,那么k
必须是1和之间的整数n
,其中n
是执行的交易数量。
go
func TestTransferTx(t *testing.T) {
...
// check results
existed := make(map[int]bool)
for i := 0; i < n; i++ {
...
// check accounts' balance
...
k := int(diff1 / amount)
require.True(t, k >= 1 && k <= n)
require.NotContains(t, existed, k)
existed[k] = true
}
}
此外,k
对于每笔交易, 必须是唯一的,这意味着k
第一笔交易应该是 1,第二笔交易应该是 2,第三笔交易应该是 3,依此类推,直到k
等于n
。
为了检查这一点,我们需要声明一个名为 的新变量,其existed
类型为map[int]bool
。然后在循环中,检查映射existed
不应包含k
。然后我们将其设置existed[k]
为true
。
最后,在 for 循环之后,我们应该检查两个账户最终更新的余额。
store.GetAccount()
首先,我们通过调用后台上下文和帐户 1 的查询从数据库中获取更新后的帐户ID
1。此查询不应返回错误。我们以相同的方式从数据库中获取更新后的帐户 2。
scss
func TestTransferTx(t *testing.T) {
...
// check results
existed := make(map[int]bool)
for i := 0; i < n; i++ {
...
}
// check the final updated balance
updatedAccount1, err := store.GetAccount(context.Background(), account1.ID)
require.NoError(t, err)
updatedAccount2, err := store.GetAccount(context.Background(), account2.ID)
require.NoError(t, err)
require.Equal(t, account1.Balance-int64(n)*amount, updatedAccount1.Balance)
require.Equal(t, account2.Balance+int64(n)*amount, updatedAccount2.Balance)
}
现在n
交易后,账户 1 的余额必须减少n * amount
。所以我们要求updatedAccount1.Balance
等于该值。amount
是 类型int64
,所以我们需要在进行乘法之前将其转换n
为。int64
我们对 执行相同的操作updatedAccount2.Balance
,只是它的值应该增加n * amount
i 而不是减少。
就这样!我们完成了测试。但在运行之前,我要写一些日志以更清楚地查看结果。
首先,让我们打印出交易前的账户余额。然后在所有交易执行后打印出更新后的余额。我还想查看每笔交易后的结果余额,所以让我们也在 for 循环中添加一个日志。
好的,这是我们的最终测试:
css
func TestTransferTx(t *testing.T) {
store := NewStore(testDB)
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 := store.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 = 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)
// 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)
// check balances
fmt.Println(">> tx:", fromAccount.Balance, toAccount.Balance)
diff1 := account1.Balance - fromAccount.Balance
diff2 := toAccount.Balance - account2.Balance
require.Equal(t, diff1, diff2)
require.True(t, diff1 > 0)
require.True(t, diff1%amount == 0) // 1 * amount, 2 * amount, 3 * amount, ..., n * amount
k := int(diff1 / amount)
require.True(t, k >= 1 && k <= n)
require.NotContains(t, existed, k)
existed[k] = true
}
// check the final updated balance
updatedAccount1, err := store.GetAccount(context.Background(), account1.ID)
require.NoError(t, err)
updatedAccount2, err := store.GetAccount(context.Background(), account2.ID)
require.NoError(t, err)
fmt.Println(">> after:", updatedAccount1.Balance, updatedAccount2.Balance)
require.Equal(t, account1.Balance-int64(n)*amount, updatedAccount1.Balance)
require.Equal(t, account2.Balance+int64(n)*amount, updatedAccount2.Balance)
}
让我们运行它吧!
在第 行失败83
,我们期望fromAccount
不为空。但目前它当然是空的,因为我们还没有实现该功能。
因此让我们回到store.go
文件来实现它!
更新账户余额(错误方式)
更改帐户余额的一个简单直观的方法是先从数据库中获取该帐户,然后在其余额中添加或减少一定金额,并将其更新回数据库。
然而,如果没有适当的锁定机制,这通常会被错误地执行。我将向你展示如何操作!
首先我们调用q.GetAccount()
来获取fromAccount
记录并将其分配给account1
变量。如果err
不是nil
,我们就返回它。
go
func (store *Store) TransferTx(ctx context.Context, arg TransferTxParams) (TransferTxResult, error) {
var result TransferTxResult
err := store.execTx(ctx, func(q *Queries) error {
...
// move money out of account1
account1, err := q.GetAccount(ctx, arg.FromAccountID)
if err != nil {
return err
}
result.FromAccount, err = q.UpdateAccount(ctx, UpdateAccountParams{
ID: arg.FromAccountID,
Balance: account1.Balance - arg.Amount,
})
if err != nil {
return err
}
}
return result, err
}
否则,我们调用q.UpdateAccount()
来更新此帐户的余额。ID 应为arg.FromAccountID
,余额将更改为,account1.Balance - arg.Amount
因为资金正在从此帐户中支出。
更新后的账户记录将保存到result.FromAccount
。如果出现错误,则直接返回。
在此之后,我们已将资金转出fromAccount
。现在我们可以做类似的事情将这些资金转入toAccount
。
go
func (store *Store) TransferTx(ctx context.Context, arg TransferTxParams) (TransferTxResult, error) {
var result TransferTxResult
err := store.execTx(ctx, func(q *Queries) error {
...
// move money out of account1
...
// move money into account2
account2, err := q.GetAccount(ctx, arg.ToAccountID)
if err != nil {
return err
}
result.ToAccount, err = q.UpdateAccount(ctx, UpdateAccountParams{
ID: arg.ToAccountID,
Balance: account2.Balance + arg.Amount,
})
if err != nil {
return err
}
}
return result, err
}
这里,账户 ID 应该是arg.ToAccountID
。结果将存储在 中result.ToAccount
。新的余额应该是account2.Balance + arg.Amount
因为有钱要存入这个账户。
好的,实现已经完成。但是,我要告诉你这是不正确的。让我们重新运行测试,看看结果如何!
测试仍然失败。但这次错误出现在第 94 行,在该行中,我们比较了从账户 1 流出的金额和进入账户 2 的金额。
在日志中我们可以看到第一笔交易是正确的。账户1的余额减少了10
,从380
到370
。而账户2的余额增加了相同的金额,从390
到400
。
但在第二笔交易中,它无法正常工作。账户 2 的余额增加更多10
,达到410
。而账户 1 的余额保持不变,为370
。
为了理解原因,让我们看一下GetAccount
查询:
ini
-- name: GetAccount :one
SELECT * FROM accounts
WHERE id = $1 LIMIT 1;
这只是一个正常的SELECT
,所以它不会阻止其他交易读取同一条Account
记录。
因此,2 个并发交易可以获取账户 1 的相同值,原始余额为380
。这解释了为什么它们370
执行后都有更新后的余额。
无锁查询
为了演示这种情况,让我们psql
在 2 个不同的终端选项卡中启动控制台并运行 2 个并行事务。
在第一个交易中,让我们运行一个正常SELECT
查询来获取帐户记录ID = 1
。
ini
SELECT * FROM accounts WHERE id = 1;
该账户余额为748美元。
现在我将在另一个事务中运行此查询。
可以看到,相同的账户记录没有被阻止,而是立即返回。这不是我们想要的。所以让我们回滚这两个交易,并学习如何修复它。
带锁查询
我将开始 2 个新事务。但这次,我们将FOR UPDATE
在语句末尾添加子句SELECT
。
sql
SELECT * FROM accounts WHERE id = 1 FOR UPDATE;
现在第一个事务仍然立即获得记录。但是当我们在第二个事务上运行此查询时:
它被阻塞并且必须等待第一个事务 COMMIT 或 ROLLBACK。
让我们回到那笔交易并将账户余额更新为 500:
ini
UPDATE accounts SET balance = 500 WHERE id = 1;
更新之后,第二个事务仍然被���止。但是,一旦我们提交第一个事务:
我们可以看到第二笔交易立即被解除阻止,并且获得了最新更新的账户余额 500 欧元。这正是我们想要实现的!
更新带锁账户余额
让我们回到文件account.sql
并添加一个新查询以获取要更新的帐户:
sql
-- name: GetAccountForUpdate :one
SELECT * FROM accounts
WHERE id = $1 LIMIT 1
FOR UPDATE;
然后我们打开终端并运行make sqlc
以重新生成代码。现在在文件中,生成了account.sql.go
一个新的函数。GetAccountForUpdate()
go
const getAccountForUpdate = `-- name: GetAccountForUpdate :one
SELECT id, owner, balance, currency, created_at FROM accounts
WHERE id = $1 LIMIT 1
FOR UPDATE
`
func (q *Queries) GetAccountForUpdate(ctx context.Context, id int64) (Account, error) {
row := q.db.QueryRowContext(ctx, getAccountForUpdate, id)
var i Account
err := row.Scan(
&i.ID,
&i.Owner,
&i.Balance,
&i.Currency,
&i.CreatedAt,
)
return i, err
}
我们可以在转账交易中使用它。在这里,为了获取第一个账户,我们调用q.GetAccountForUpdate()
而不是q.GetAccount()
。我们做同样的事情来获取第二个账户。
go
func (store *Store) TransferTx(ctx context.Context, arg TransferTxParams) (TransferTxResult, error) {
var result TransferTxResult
err := store.execTx(ctx, func(q *Queries) error {
...
// move money out of account1
account1, err := q.GetAccountForUpdate(ctx, arg.FromAccountID)
if err != nil {
return err
}
result.FromAccount, err = q.UpdateAccount(ctx, UpdateAccountParams{
ID: arg.FromAccountID,
Balance: account1.Balance - arg.Amount,
})
if err != nil {
return err
}
// move money into account2
account2, err := q.GetAccountForUpdate(ctx, arg.ToAccountID)
if err != nil {
return err
}
result.ToAccount, err = q.UpdateAccount(ctx, UpdateAccountParams{
ID: arg.ToAccountID,
Balance: account2.Balance + arg.Amount,
})
if err != nil {
return err
}
}
return result, err
}
好了,现在我们希望它能正常工作。让我们重新运行测试。
不幸的是,它仍然失败了。这次错误是deadlock detected
。那么我们该怎么办呢?
别担心!我会向你展示如何调试这种死锁情况。
调试死锁
为了弄清楚为什么会发生死锁,我们需要打印出一些日志来查看哪个事务正在调用哪个查询以及调用的顺序。
为此,我们必须为每个交易分配一个名称,并TransferTx()
通过上下文参数将其传递给函数。
现在,在这个测试的 for 循环中,我将创建一个txName
变量来存储交易的名称。我们使用函数fmt.Sprintf()
和计数器i
来创建不同的名称:tx 1
、、等等。tx 2``tx 3
然后在 go program 内部,我们不会传入后台上下文,而是传入一个带有事务名称的新上下文。
css
func TestTransferTx(t *testing.T) {
...
// run n concurrent transfer transaction
for i := 0; i < n; i++ {
txName := fmt.Sprintf("tx %d", i+1)
go func() {
ctx := context.WithValue(context.Background(), txKey, txName)
result, err := store.TransferTx(ctx, TransferTxParams{
FromAccountID: account1.ID,
ToAccountID: account2.ID,
Amount: amount,
})
errs <- err
results <- result
}()
}
// check results
...
}
为了将交易名称添加到上下文中,我们调用context.WithValue()
,传入后台上下文作为其父级,以及一对键值,其中值是交易名称。
在文档中,它说上下文键不应为字符串类型或任何内置类型,以避免包之间的冲突。通常我们应该struct{}
为上下文键定义一个类型的变量。
所以我要txKey
在文件中添加一个新变量store.go
,因为稍后我们必须使用这个键从函数的输入上下文中获取交易名称TransferTx()
。
go
var txKey = struct{}{}
func (store *Store) TransferTx(ctx context.Context, arg TransferTxParams) (TransferTxResult, error) {
...
}
...
这里,第二个括号struct{}{}
表示我们正在创建一个新的类型空对象struct{}
。
现在在函数中,上下文将保存交易名称。我们可以通过调用从上下文中获取的值来TransferTx()
取回它。ctx.Value()``txKey
现在我们有了事务名称,我们可以用它写一些日志。让我们打印出这个事务名称和第一个操作:create transfer
。然后对其余操作执行相同操作:
go
func (store *Store) TransferTx(ctx context.Context, arg TransferTxParams) (TransferTxResult, error) {
var result TransferTxResult
err := store.execTx(ctx, func(q *Queries) error {
var err error
txName := ctx.Value(txKey)
fmt.Println(txName, "create transfer")
result.Transfer, err = q.CreateTransfer(ctx, CreateTransferParams{
FromAccountID: arg.FromAccountID,
ToAccountID: arg.ToAccountID,
Amount: arg.Amount,
})
if err != nil {
return err
}
fmt.Println(txName, "create entry 1")
result.FromEntry, err = q.CreateEntry(ctx, CreateEntryParams{
AccountID: arg.FromAccountID,
Amount: -arg.Amount,
})
if err != nil {
return err
}
fmt.Println(txName, "create entry 2")
result.ToEntry, err = q.CreateEntry(ctx, CreateEntryParams{
AccountID: arg.ToAccountID,
Amount: arg.Amount,
})
if err != nil {
return err
}
// move money out of account1
fmt.Println(txName, "get account 1")
account1, err := q.GetAccountForUpdate(ctx, arg.FromAccountID)
if err != nil {
return err
}
fmt.Println(txName, "update account 1")
result.FromAccount, err = q.UpdateAccount(ctx, UpdateAccountParams{
ID: arg.FromAccountID,
Balance: account1.Balance - arg.Amount,
})
if err != nil {
return err
}
// move money into account2
fmt.Println(txName, "get account 2")
account2, err := q.GetAccountForUpdate(ctx, arg.ToAccountID)
if err != nil {
return err
}
fmt.Println(txName, "update account 2")
result.ToAccount, err = q.UpdateAccount(ctx, UpdateAccountParams{
ID: arg.ToAccountID,
Balance: account2.Balance + arg.Amount,
})
if err != nil {
return err
}
})
return result, err
}
好的,现在日志已添加,我们可以重新运行测试以查看进展情况。
但为了便于调试,我们不应该运行太多并发事务。所以我将把它改为n
而2
不是5
。
go
func TestTransferTx(t *testing.T) {
...
n := 2
amount := int64(10)
errs := make(chan error)
results := make(chan TransferTxResult)
// run n concurrent transfer transaction
...
}
那么我们来运行测试吧!
瞧,我们仍然陷入僵局。但这一次,我们有了发生事件的详细日志。
正如您在此处看到的:
- 事务 2 运行了它的前两个操作:
create transfer
和create entry 1
。 - 然后事务 1 开始运行其
create transfer
操作。 - 事务 2 返回并继续运行其后两个操作:
create entry 2
和get account 1
。 - 最后,事务 1 轮到并运行其接下来的 4 个操作:
create entry 1
,,,和。create entry 2``get account 1``update account 1
- 此刻,我们陷入了僵局。
现在我们确切地知道了发生了什么。我们要做的就是找出发生这件事的原因。
在 psql 控制台中复制死锁
这里我在 TablePlus 中打开了simple_bank
数据库。目前,它有 2 个账户,其原始余额相同100 USD
。
我还准备了转账交易,其中包含 SQL 查询列表,这些查询应按照我们在 Golang 代码中实现的方式运行:
sql
BEGIN;
SELECT * FROM accounts WHERE id = 1;
INSERT INTO transfers (from_account_id, to_account_id, amount) VALUES (1, 2, 10) RETURNING *;
INSERT INTO entries (account_id, amount) VALUES (1, -10) RETURNING *;
INSERT INTO entries (account_id, amount) VALUES (2, 10) RETURNING *;
SELECT * FROM accounts WHERE id = 1 FOR UPDATE;
UPDATE accounts SET balance = 90 WHERE id = 1 RETURNING *;
SELECT * FROM accounts WHERE id = 2 FOR UPDATE;
UPDATE accounts SET balance = 110 WHERE id = 2 RETURNING *;
ROLLBACK;
- 交易从
BEGIN
声明开始。 - 首先,我们来记录一下从到 的新
INSERT
情况。transfer``account 1``account 2``amount``10
- 然后我们创下了 的
INSERT
新entry
纪录。account 1``amount``-10
- 另
INSERT
一项entry
记录account 2
是。amount``+10
- 接下来我们
SELECT
account 1
进行更新。 - 我们
UPDATE
将其balance
设为100-10
,即90
美元。 - 同样,我们
SELECT
account 2
进行更新。 - 我们
UPDATE
将其余额设为100+10
,相当于110
美元。 ROLLBACK
最后,当发生死锁时我们执行操作。
现在就像我们之前所做的一样,我将打开终端并运行 2 个 psql 控制台以并行执行 2 个事务。
让我们用 开始第一个事务BEGIN
。然后打开另一个选项卡并访问 psql 控制台。用 开始第二个事务BEGIN
。
现在,我们应该按照日志中的步骤进行操作。首先,transaction 2
应该运行它的前 2 个查询来创建transfer
和entry 1
记录:
插入成功!现在我们必须转到transaction 1
并运行第一个查询来创建transfer
记录。
现在返回transaction 2
并运行其第 3 个查询来创建,entry 2
并运行第 4 个查询来获取account 1
更新。
现在我们看到这个查询被阻塞了。它正在等待transaction 2
提交或回滚后才能继续。
这听起来很奇怪,因为事务 2 仅在表中创建一条记录,transfers
而我们从accounts
表中获取一条记录。为什么INSERT
进入 1 表可以阻止进入SELECT
其他表?
为了确认这一点,让我们打开这个有关锁监控的Postgres Wiki 页面。
vbnet
SELECT blocked_locks.pid AS blocked_pid,
blocked_activity.usename AS blocked_user,
blocking_locks.pid AS blocking_pid,
blocking_activity.usename AS blocking_user,
blocked_activity.query AS blocked_statement,
blocking_activity.query AS current_statement_in_blocking_process
FROM pg_catalog.pg_locks blocked_locks
JOIN pg_catalog.pg_stat_activity blocked_activity ON blocked_activity.pid = blocked_locks.pid
JOIN pg_catalog.pg_locks blocking_locks
ON blocking_locks.locktype = blocked_locks.locktype
AND blocking_locks.database IS NOT DISTINCT FROM blocked_locks.database
AND blocking_locks.relation IS NOT DISTINCT FROM blocked_locks.relation
AND blocking_locks.page IS NOT DISTINCT FROM blocked_locks.page
AND blocking_locks.tuple IS NOT DISTINCT FROM blocked_locks.tuple
AND blocking_locks.virtualxid IS NOT DISTINCT FROM blocked_locks.virtualxid
AND blocking_locks.transactionid IS NOT DISTINCT FROM blocked_locks.transactionid
AND blocking_locks.classid IS NOT DISTINCT FROM blocked_locks.classid
AND blocking_locks.objid IS NOT DISTINCT FROM blocked_locks.objid
AND blocking_locks.objsubid IS NOT DISTINCT FROM blocked_locks.objsubid
AND blocking_locks.pid != blocked_locks.pid
JOIN pg_catalog.pg_stat_activity blocking_activity ON blocking_activity.pid = blocking_locks.pid
WHERE NOT blocked_locks.granted;
这个长而复杂的查询使我们能够查找被阻止的查询以及阻止它们的原因。因此,让我们在 TablePlus 中复制并运行它。
如您所见,被阻止的语句是SELECT FROM accounts FOR UPDATE
。 而阻止它的语句是INSERT INTO transfers
。 因此,这两个不同表上的查询确实可以相互阻塞。
让我们深入了解为什么SELECT
查询必须等待查询INSERT
。
如果我们回到Postgres Wiki并向下滚动一点,我们将看到另一个查询,它允许我们列出数据库中的所有锁。
我将稍微修改一下这个查询,因为我想查看更多信息:
less
SELECT
a.datname,
a.application_name,
l.relation::regclass,
l.transactionid,
l.mode,
l.locktype,
l.GRANTED,
a.usename,
a.query,
a.pid
FROM
pg_stat_activity a
JOIN pg_locks l ON
l.pid = a.pid
ORDER BY
a.pid;
- 该
a.datname
字段将显示数据库名称。 - 我们来添加一下,
a.application_name
看看锁来自哪个应用程序。 - regclass
l.relation
实际上是表的名称, L.transactionid
是锁所属事务的ID。L.mod
是锁的型号。- 我们还要添加一下
l.lock_type
来查看锁的类型。 L.granted
告诉我们锁是否被授予。a.usename
是运行查询的用户名。a.query
是持有或尝试获取锁的查询。- 该查询开始的时间
a.query_start
或其时间age
不是很重要,所以我将删除它们。 - 最后一个字段是
a.pid
,它是运行事务的进程 ID。
如您所见,我们从pg_state_activity
表中选择别名为a
,并在进程 ID 列上与pg_locks
表别名为进行连接。l
这是按查询开始时间排序的,但实际上我认为按进程 ID 排序更好,因为我们有 2 个不同的进程,它们运行 2 个 psql 控制台和 2 个并行事务。因此更容易看出哪个锁属于哪个事务。
好的,让我们运行它吧!
这里我们可以看到一些来自TablePlus
应用程序的锁,这些锁并不相关。我们关心的只是来自psql
控制台的锁。
所以我要添加一个 WHERE 子句来仅获取应用程序名称等于 的锁psql
。
数据库名称也不重要,因为simple_bank
在我们的例子中它总是存在的。所以我也会删除它a.datname
。
好的,让我们再次运行这个查询:
less
SELECT
a.application_name,
l.relation::regclass,
l.transactionid,
l.mode,
l.locktype,
l.GRANTED,
a.usename,
a.query,
a.pid
FROM
pg_stat_activity a
JOIN pg_locks l ON
l.pid = a.pid
WHERE
a.application_name = 'psql'
ORDER BY
a.pid;
现在我们可以看到,只有 1 个锁尚未被授予。它来自SELECT FROM accounts
对进程 ID 的查询3053
。
之所以没有被授予,是因为它试图获取ShareLock
类型为 的transactionid
锁,其中事务 ID 为。而此事务 ID 锁正由具有查询的其他进程 ID2442
持有。exclusively``3047``INSERT INTO transfers
但是为什么一个SELECT FROM accounts
表需要从运行表的其他事务中获取锁INSERT INTO transfers
?
好吧,如果我们看一下数据库模式,我们可以看到账户和转账表之间的唯一联系是外键约束:
sql
ALTER TABLE "entries" ADD FOREIGN KEY ("account_id") REFERENCES "accounts" ("id");
ALTER TABLE "transfers" ADD FOREIGN KEY ("from_account_id") REFERENCES "accounts" ("id");
ALTER TABLE "transfers" ADD FOREIGN KEY ("to_account_id") REFERENCES "accounts" ("id");
表的from_account_id
和to_account_id
列transfers
引用表id
的列accounts
。因此,UPDATE
帐户 ID 上的任何内容都会影响此外键约束。
这就是为什么当我们选择一个账户进行更新时,它需要获取锁以防止冲突并确保数据的一致性。
话虽如此,现在如果我们继续在事务 1 上运行其余查询来创建entry 1
、创建entry 2
并选择account 1
更新:
我们将会遇到死锁,因为这个查询还必须等待来自事务 2 的锁,而事务 2 也在等待来自事务 1 的锁。
这清楚地解释了死锁是如何发生的。但是如何解决它呢?
修复死锁[不好的方法]
我们知道,死锁是由外键约束引起的,因此避免死锁的一个简单方法就是删除这些约束。
让我们尝试注释掉init_schema.up.sql
文件中的这些语句:
java
-- ALTER TABLE "entries" ADD FOREIGN KEY ("account_id") REFERENCES "accounts" ("id");
-- ALTER TABLE "transfers" ADD FOREIGN KEY ("from_account_id") REFERENCES "accounts" ("id");
-- ALTER TABLE "transfers" ADD FOREIGN KEY ("to_account_id") REFERENCES "accounts" ("id");
make migratedown
然后在终端中运行以删除数据库模式。然后运行make migrateup
以重新创建没有外键约束的新数据库模式。
好了,现在我们再运行测试,测试会通过,因为约束消失了,所以选择账户进行更新时不需要锁。没有锁就意味着没有死锁。
然而,这不是最好的解决方案,因为我们不想失去保持数据一致性的良好约束。
因此,让我们恢复这些更改,运行make migratedown
,然后make migrateup
再次恢复这些约束。现在测试将再次因死锁而失败。
让我们学习一个更好的方法来解决这个问题。
修复死锁[更好的方法]
我们已经知道,只需要事务锁,因为 Postgres 担心事务 1 将更新account ID
,这会影响表的外键约束transfers
。
然而,如果我们看一下UpdateAccount
查询,我们就会发现它只会改变账户余额。
ini
-- name: UpdateAccount :one
UPDATE accounts
SET balance = $2
WHERE id = $1
RETURNING *;
帐户 ID 永远不会改变,因为它是帐户表的主键。
因此,如果我们可以告诉 Postgres 我正在选择此帐户进行更新,但不会触及其主键,那么 Postgres 将不需要获取事务锁,因此不会出现死锁。
幸运的是,这非常容易做到。在查询中,我们只需要更清楚地说明,GetAccountForUpdate
而不仅仅是:SELECT FOR UPDATE``SELECT FOR NO KEY UPDATE
sql
-- name: GetAccountForUpdate :one
SELECT * FROM accounts
WHERE id = $1 LIMIT 1
FOR NO KEY UPDATE;
这将告诉 Postgres 我们不更新ID
帐户表的键或列。
现在我们make sqlc
在终端中运行来重新生成这个查询的 golang 代码。
go
const getAccountForUpdate = `-- name: GetAccountForUpdate :one
SELECT id, owner, balance, currency, created_at FROM accounts
WHERE id = $1 LIMIT 1
FOR NO KEY UPDATE
`
func (q *Queries) GetAccountForUpdate(ctx context.Context, id int64) (Account, error) {
row := q.db.QueryRowContext(ctx, getAccountForUpdate, id)
var i Account
err := row.Scan(
&i.ID,
&i.Owner,
&i.Balance,
&i.Currency,
&i.CreatedAt,
)
return i, err
}
好的,代码已更新。让我们再次运行测试!
通过了!太棒了!我们的调试和修复已经完成了。
更新账户余额[更好的方法]
在我们结束之前,我将向您展示一种更好的方法来实现更新帐户余额操作。
目前,我们必须执行 2 个查询来获取帐户并更新其余额:
go
func (store *Store) TransferTx(ctx context.Context, arg TransferTxParams) (TransferTxResult, error) {
var result TransferTxResult
err := store.execTx(ctx, func(q *Queries) error {
...
// move money out of account1
account1, err := q.GetAccountForUpdate(ctx, arg.FromAccountID)
if err != nil {
return err
}
result.FromAccount, err = q.UpdateAccount(ctx, UpdateAccountParams{
ID: arg.FromAccountID,
Balance: account1.Balance - arg.Amount,
})
if err != nil {
return err
}
// move money into account2
...
})
return result, err
}
我们可以通过仅使用 1 个查询直接向账户余额中添加一定数量的钱来改进这一点。
为此,我将添加一个名为"AddAccountBalance
该query/account.sql
文件"的新 SQL 查询。
ini
-- name: AddAccountBalance :one
UPDATE accounts
SET balance = balance + $1
WHERE id = $2
RETURNING *;
它与 UpdateAccount 查询类似,不同之处在于,这里我们设置了balance = balance + $2
。
我们运行make sqlc
一下生成代码,成功在Queries
结构体中添加了一个新函数:
go
const addAccountBalance = `-- name: AddAccountBalance :one
UPDATE accounts
SET balance = balance + $1
WHERE id = $2
RETURNING id, owner, balance, currency, created_at
`
type AddAccountBalanceParams struct {
Balance int64 `json:"balance"`
ID int64 `json:"id"`
}
func (q *Queries) AddAccountBalance(ctx context.Context, arg AddAccountBalanceParams) (Account, error) {
row := q.db.QueryRowContext(ctx, addAccountBalance, arg.Balance, arg.ID)
var i Account
err := row.Scan(
&i.ID,
&i.Owner,
&i.Balance,
&i.Currency,
&i.CreatedAt,
)
return i, err
}
但是结构体balance
中的参数AddAccountBalanceParams
看起来有点令人困惑,因为我们只是向余额中添加一些钱,而不是将账户余额更改为这个值。
因此,此参数的名称应Amount
改为。我们可以告诉 sqlc 为我们执行此操作吗?
是的,我们可以!在 SQL 查询中,$2
我们可以说而不是sqlc.arg(amount)
,而$1
应该说 而不是 。sqlc.arg(id)
ini
-- name: AddAccountBalance :one
UPDATE accounts
SET balance = balance + sqlc.arg(amount)
WHERE id = sqlc.arg(id)
RETURNING *;
这amount
将id
成为生成的参数的名称。让我们在终端中运行 make sqlc 来重新生成代码。
go
const addAccountBalance = `-- name: AddAccountBalance :one
UPDATE accounts
SET balance = balance + $1
WHERE id = $2
RETURNING id, owner, balance, currency, created_at
`
type AddAccountBalanceParams struct {
Amount int64 `json:"amount"`
ID int64 `json:"id"`
}
func (q *Queries) AddAccountBalance(ctx context.Context, arg AddAccountBalanceParams) (Account, error) {
row := q.db.QueryRowContext(ctx, addAccountBalance, arg.Amount, arg.ID)
var i Account
err := row.Scan(
&i.ID,
&i.Owner,
&i.Balance,
&i.Currency,
&i.CreatedAt,
)
return i, err
}
这次,我们可以看到参数的名称已经变成了我们想要的。太棒了!
现在回到文件store.go
,我将删除该GetAccountForUpdate
调用,然后更改UpdateAccount()
为AddAccountBalance()
:
go
func (store *Store) TransferTx(ctx context.Context, arg TransferTxParams) (TransferTxResult, error) {
var result TransferTxResult
err := store.execTx(ctx, func(q *Queries) error {
...
// move money out of account1
result.FromAccount, err = q.AddAccountBalance(ctx, AddAccountBalanceParams{
ID: arg.FromAccountID,
Amount: -arg.Amount,
})
if err != nil {
return err
}
// move money into account2
result.ToAccount, err = q.AddAccountBalance(ctx, AddAccountBalanceParams{
ID: arg.ToAccountID,
Amount: arg.Amount,
})
if err != nil {
return err
}
return nil
})
return result, err
}
请注意,Amount
添加到account1
应该是-amount
因为资金正在流出。
大功告成!让我们重新运行测试。
耶!通过了!让我们运行整个包测试。
全部通过了!