DB死锁:使用GoLang的完整案例研究

713 阅读9分钟

在这个故事中,我们将经历如何在Postgres数据库中调试死锁的过程。如何避免死锁,或者如何实时地减少死锁情况。为此,我将深入研究数据库事务,为一个事务性的用例提供并发请求。

我选择了golang、Postgres和一些支持事务性功能的go lang模块。

什么是数据库事务?

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

例如:让我们考虑我们是一个简单的银行系统,持有用户的账户信息。

  • 发起转账细节。
  • 为这些账户创建交易。例如:对于从账户A到账户B的100美元账户转账,在交易表中会有两条记录:账户A→-100美元,账户B→+100美元。
  • 最后更新A和B的账户余额为-100元和+100元。

因此,总的来说,从账户A到账户B的简单金额转移将有5个操作。

发起转账。

为账户A创建交易。

为账户B创建交易。

更新账户A的余额。

更新账户B的余额。

为什么我们需要数据库事务?

  1. 即使在系统故障时,RDBMS也能提供可靠和一致的数据。
  2. 在同时访问数据库的进程/程序之间提供数据的隔离。

RDBMS通过其ACID属性支持上述功能**。**

如何在事务中运行数据库?

答案很简单,使用Begin,然后是SQL语句,最后是提交。对于失败的事务,则是回滚。

对于成功的事务

BEGIN;-- SQL Statements--

在成功提交后,数据库表将拥有修改后的值。

对于失败的事务

BEGIN;-- SQL Statements -- Exception occurred--

在回滚后,所有对表的修改都会丢失,数据库会保留之前的成功状态。

让我们在goLang中运行一个代码示例

我们将采用我之前解释过的同一个例子(账户转移)。

Go Lang的实现

我已经考虑了Golang和一些模块,它们将有助于展示这个用例

GOLang Migrate → 用于数据库迁移,更多细节请点击我之前的博客。

SQLC → 用于数据库CRUD操作的自动生成的代码,这纯粹是为了支持Postgres。

测试案例 → 使用TDD和测试案例来解释和实现。

由于我们没有使用任何ORM模块,我想如果我们使用任何ORM模块,就很难解释DB事务,因为它隐藏了底层的DB功能,而且ORM比普通SQL慢。

让我们在go文件中使用开始、提交和回滚来创建事务功能。下面的方法为一组DB操作设置了事务的边界环境。

medium.com/media/7b549…

实际启动事务的方法在事务中运行。下面的方法将按照业务规则,以特定的顺序执行一些SQL语句。你可以看到我在更新源和目标之前已经查询了账户细节。这是没有必要的,我们可以直接更新余额而不需要获取数据。为了展示,我采用了这种方法。

medium.com/media/d899e…

现在我们将写一个测试案例来演示并发请求。在下面的测试案例中,我使用了goroutines来进行并发请求,并使用通道来捕获结果,然后进行验证。下面的测试案例启动了从Source到Target的5次并发汇款,每次10美元。因此,最后,在5个并发请求中,从Source到Target的50美元。

medium.com/media/34b06…

在目前的代码库中,如果我运行这个测试用例,测试用例是失败的,因为在并发模式下,每个进程不会得到前次提交的更新的余额。所以会有一个不一致的余额,导致断言失败。

如果你观察一下日志 在交易之前,源账户的余额是728,目标账户是799。

第一笔交易→S(718), T(809)

第二笔交易 → S(708), T(819)

第三笔交易→S(708), T(829)

第二笔交易后,源账户的余额没有更新,仍与前一笔交易相同。但目标账户更新了它的余额,导致了一致性问题。

原因是我们使用了一个简单的选择查询来获得账户的详细信息,使用SELECT * FROM account_details WHERE id = 1; 。这是一个普通的SELECT语句,不会阻止更新。所以并发的请求会得到相同的数据,最终导致不一致。

让我们在不同的psql窗口中运行并行事务,并尝试获取数据。

它立即返回,没有阻塞。

解决方案1 :

将SELECT查询改为`SELECT * FROM account_details WHERE id = 1 FOR UPDATE

第一个窗口在交易中立即返回

第二个窗口被阻塞了,并且依赖于前一个事务。它必须要提交或回滚。

如果我通过更新余额来提交第一个事务,第二个事务将得到更新的值。

现在我已经更新了查询并再次执行了测试案例。让我们看看这是否有效,并在实际方法中添加了一些日志用于调试。

测试用例返回以下错误,一个死锁

这些是我们得到的日志,如果你观察一下,并发请求的顺序是不能保证的。让我们在PSQL的两个独立窗口中复制同样的事情。

第二个事务。

选择查询在这里被阻塞了。

第一个事务

让我们看一下PG锁,为什么Select查询被阻止了。

查询检查被阻止的查询列表和阻止它们的原因。

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;

其结果是:。

blocked_pid|blocked_user|blocking_pid|blocking_user|blocked_statement                                     |current_statement_in_blocking_process                                                              |

如果你观察到,第二个事务中的选择查询被我们在第一个事务中做的插入所阻挡,这个插入是在一个不同的表中。让我们来了解一下原因,但在此之前,让我们看看数据库中所有尚未释放的锁。

SELECT a.datname,         l.relation::regclass,         l.transactionid,         l.mode,         l.GRANTED,         a.usename,         a.query,         a.query_start,         a.pidFROM pg_stat_activity aJOIN pg_locks l ON l.pid = a.pidwhere granted = falseORDER BY a.pid;
----------------------------------------------
datname|relation|transactionid|mode     |granted|usename  |query                                                 |query_start        |pid|

这个锁的模式是ShareLock。让我们分析一下为什么在我们的查询列表中存在ShareLock,在此之前,让我们继续第一个事务,看看直到出现死锁。

第一个交易完成。

为什么会出现死锁?

如果你观察模式,transfer表有外键约束source_account_id和target_account_id在account_details(id)表中。任何对account_details表的更新都会影响这些外键约束。因此,当我们使用SELECT For UPDATE ON account_details时,它需要获得外键约束的锁,并保证数据的一致性。

解决方法

最简单的方法是放弃交易表中的外键约束。但这并不推荐。

一个完美的解决方案

如果你看一下更新查询,我们从不改变表的主键。我们修改的是底层列。那么,如果我们让Select查询知道我们不是在更新ID,我们是在更新其他的列。这应该可以解决我们的问题。更新SELECT查询,如下所示

SELECT * FROM account_details where account_id = 1 

让我们重新运行这个测试案例。

解决方案2

如果你观察代码,有一个不必要的选择语句来获取账户的余额。在SQL中,这是不需要的,相反,我们可以写更新查询,将余额从原来的值修改为新的值。

UPDATE account_details SET balance = balance + $(amount) where ID = :ID;

让我们修改代码并重新进行测试。

经验之谈。

有两种方法可以解决死锁情况,一种是解决基本问题,另一种是逃避或避免死锁情况。我们在上面的用例中看到了这两种方法。解决方案1是解决这个问题,解决方案2是避免死锁情况。

我更喜欢第二种方法,因为它不会遇到死锁的情况,总是正确的清洁代码。 谈到清洁,上述代码库仍然有一些问题,让我们来看看。

问题2

到目前为止,我们所做的始终是以相同的顺序更新余额,即,账户ID。1到账户ID。2.如果有一种情况,我们不知道更新的顺序,也就是说,可能是源头到目标,也可能是反过来。

让我们在psql中运行平行交易

交易1

begin
update
rollback

交易2

begin
update
rollback

让我们看看被阻塞的查询和阻塞它们的原因。再次运行与之前相同的查询。

blocked_pid|blocked_user|blocking_pid|blocking_user|blocked_statement                                                          |current_statement_in_blocking_process                                      |

也分析一下锁的类型。

datname|relation|transactionid|mode     |granted|usename  |query                                                                      |query_start        |pid |

如果我们在第二个事务中更新ID 1,就会发现死锁。

因为我们在第二个事务中执行了对ID 2的更新,而这个事务还没有提交。如果我们试图在另一个事务中更新相同的ID,那么就会检测到一个死锁。

以测试案例的形式实现同样的内容。在一个循环中启动10个并发传输。对于偶数索引,源和目标是相同的。对于奇数索引,它是相反的。

medium.com/media/99d30…

这就是测试案例的结果,一个死锁。

解决方案

死锁的原因是不同事务中的插入顺序,我们必须遵循事务中相同的更新/插入顺序。我已经修改了主go文件中的代码。 始终从较低的账户ID进行更新,然后再到较高的账户ID。

经验

在事务中,总是以相同的顺序实现插入/更新。这将有助于以一种干净的方式进行交易,并避免数据库获得不必要的锁。

请从这里找到源代码


DB死锁,使用GoLang的完整案例研究》最初发表在《Nerd For Tech》杂志上,人们通过强调和回应这个故事继续对话。