【问题标题】:Why does a missing primary key/unique key cause deadlock issues on upsert?为什么缺少主键/唯一键会导致 upsert 出现死锁问题?
【发布时间】:2014-01-20 04:46:38
【问题描述】:

我遇到了导致死锁问题的架构和 upsert 存储过程。我对为什么会导致死锁以及如何解决它有一个大致的了解。我可以重现它,但我不清楚导致它的步骤顺序。如果有人能清楚地解释为什么会导致死锁,那就太好了。

这是架构和存储过程。此代码正在 PostgreSQL 9.2.2 上执行。

CREATE TABLE counters (                                                                                                                                                                                                                       
  count_type INTEGER NOT NULL,
  count_id   INTEGER NOT NULL,
  count      INTEGER NOT NULL
);


CREATE TABLE primary_relation (
  id INTEGER PRIMARY KEY,
  a_counter INTEGER NOT NULL DEFAULT 0
);

INSERT INTO primary_relation
SELECT i FROM generate_series(1,5) AS i;

CREATE OR REPLACE FUNCTION increment_count(ctype integer, cid integer, i integer) RETURNS VOID
AS $$
BEGIN
    LOOP
        UPDATE counters
         SET count = count + i 
         WHERE count_type = ctype AND count_id = cid;
         IF FOUND THEN
            RETURN;
          END IF; 
        BEGIN
            INSERT INTO counters (count_type, count_id, count)
             VALUES (ctype, cid, i); 
            RETURN;
        EXCEPTION WHEN OTHERS THEN
        END;
    END LOOP;
END;
$$
LANGUAGE PLPGSQL;

CREATE OR REPLACE FUNCTION update_primary_a_count(ctype integer) RETURNS VOID
AS $$
  WITH deleted_counts_cte AS (
      DELETE
      FROM counters
      WHERE count_type = ctype
      RETURNING *
  ), rollup_cte AS (
      SELECT count_id, SUM(count) AS count
      FROM deleted_counts_cte
      GROUP BY count_id
      HAVING SUM(count) <> 0
  )
  UPDATE primary_relation
  SET a_counter = a_counter + rollup_cte.count
  FROM rollup_cte
  WHERE primary_relation.id = rollup_cte.count_id
$$ LANGUAGE SQL;

这是一个重现死锁的python脚本。

import os                                                                                                                                                                                                                                     
import random
import time
import psycopg2

COUNTERS = 5 
THREADS = 10
ITERATIONS = 500 

def increment():
  outf = open('synctest.out.%d' % os.getpid(), 'w')
  conn = psycopg2.connect(database="test")
  cur = conn.cursor()
  for i in range(0,ITERATIONS):
    time.sleep(random.random())
    start = time.time()
    cur.execute("SELECT increment_count(0, %s, 1)", [random.randint(1,COUNTERS)])
    conn.commit()
    outf.write("%f\n" % (time.time() - start))
  conn.close()
  outf.close()

def update(n):
  outf = open('synctest.update', 'w')
  conn = psycopg2.connect(database="test")
  cur = conn.cursor()
  for i in range(0,n):
    time.sleep(random.random())
    start = time.time()
    cur.execute("SELECT update_primary_a_count(0)")
    conn.commit()
    outf.write("%f\n" % (time.time() - start))
  conn.close()

pids = []
for i in range(THREADS):
  pid = os.fork()
  if pid != 0:
    print 'Process %d spawned' % pid 
    pids.append(pid)
  else:
    print 'Starting child %d' % os.getpid()
    increment()
    print 'Exiting child %d' % os.getpid()
    os._exit(0)

update(ITERATIONS)
for pid in pids:
  print "waiting on %d" % pid 
  os.waitpid(pid, 0)

# cleanup
update(1)

我认识到这样做的一个问题是 upsert 会产生重复的行(具有多个写入器),这可能会导致一些重复计算。但是为什么这会导致死锁呢?

我从 PostgreSQL 得到的错误如下:

process 91924 detected deadlock while waiting for ShareLock on transaction 4683083 after 100.559 ms",,,,,"SQL statement ""UPDATE counters

然后客户端吐出这样的东西:

psycopg2.extensions.TransactionRollbackError: deadlock detected
DETAIL:  Process 91924 waits for ShareLock on transaction 4683083; blocked by process 91933.
Process 91933 waits for ShareLock on transaction 4683079; blocked by process 91924.
HINT:  See server log for query details.CONTEXT:  SQL statement "UPDATE counters
         SET count = count + i
         WHERE count_type = ctype AND count_id = cid"
PL/pgSQL function increment_count(integer,integer,integer) line 4 at SQL statement

要解决此问题,您需要添加一个主键,如下所示:

ALTER TABLE counters ADD PRIMARY KEY (count_type, count_id);

任何见解将不胜感激。谢谢!

【问题讨论】:

  • 什么是 upsert?我在您的代码中没有看到它,也不知道这个非英语单词..
  • @Tomas upsert 是“更新或插入”的通用术语。我道歉。对于某些但不是所有 RDMS 或 SQL 标准,它可能是文档中的行话。上面的increment_count 函数是PostgreSQL 中一个相当典型的“upsert”存储过程。有关 PG 文档中“upsert”的示例,请参阅此处的 merge_db()postgresql.org/docs/9.2/static/plpgsql-control-structures.html
  • 为什么increment_count中有一个LOOP?并不是说它与这个问题有任何关系。
  • @Jayadevan:使用 PK,您可能有两个线程无法更新并同时尝试插入,其中一个失败。该循环允许再次尝试更新。 (我最好的猜测是这也是为什么没有 PK 时它也会陷入僵局,但我不确定为什么……)
  • 如果没有唯一键,数据库必须锁定页面以进行更新。您正在运行 10 个线程的 500 次迭代。一旦表足够大以跨越多个页面并且更新需要多个页面,就会出现两个特定更新“同时”需要两个页面但以相反顺序锁定它们的可能性 - 死锁。表和函数必须根据您想要的并发程度进行更改。主键很关键,但如果将线程增加到 2000 并将迭代增加到 10k,则可能还不够。

标签: sql database postgresql concurrency relational-database


【解决方案1】:

因为有主键,所以这张表的行数总是

当您删除主键时,一些线程会滞后并且行数会增加,同时行会重复。当行重复时,更新时间会更长,并且 2 个或更多线程会尝试更新相同的行。

打开一个新终端并输入:

watch --interval 1 "psql -tc \"select count(*) from counters\" test"

尝试使用和不使用主键。当您遇到第一个死锁时,请查看上面的查询结果。就我而言,这就是我在柜台上剩下的东西:

test=# select * from counters order by 2;
 count_type | count_id | count 
------------+----------+-------
          0 |        1 |   735
          0 |        1 |   733
          0 |        1 |   735
          0 |        1 |   735
          0 |        2 |   916
          0 |        2 |   914
          0 |        2 |   914
          0 |        3 |   882
          0 |        4 |   999
          0 |        5 |   691
          0 |        5 |   692
(11 rows)

【讨论】:

  • 您的回答陈述了有关问题的事实,但没有解释为什么会发生死锁。我们知道它会发生。缺少的是精确的解释和一系列步骤,这些步骤将导致容易重现的死锁。
  • 很抱歉,出现死锁的原因有很多。 1. Number of updated 2. Number of deletes 3. Autovacuum/vacuum 4. Postgres MVCC (multi version concurrency control) 每次更新或删除,postgres 实际上并没有删除该行,它“复制”它到表的末尾,禁用更新/删除的行。真空将这些行标记为可重用。更新/删除越多,表越大,自动清空需要的时间越长,每笔交易都会减慢一点。
  • 并且表格大小永远不会减小,因此您更新/删除的行越多,表格就会越大。重新创建这个问题并不难,禁用 autovacuum,几分钟后你会看到表会有几 MB 大小,即使没有行,并且对该表的任何操作都会花费越来越长的时间。跨度>
【解决方案2】:

您的代码是竞争条件(多线程、随机休眠)的完美配方。 问题很可能是由于锁定问题,因为您没有提及锁定模式,我将假设这是基于页面的锁定,因此您会遇到以下情况:

  1. 线程 1 启动,它开始插入记录,假设它锁定了第 1 页,应该锁定第 2 页。
  2. 线程 2 与 1 同时启动,但它锁定了第 2 页,接下来应该锁定第 1 页。
  3. 两个线程现在都在等待对方完成,因此出现了死锁。

现在,为什么要 PK 来修复它?

因为锁定首先是通过索引完成的,所以竞争条件得到缓解,因为插入时 PK 是唯一的,所以所有线程都等待索引,并且在更新时访问是通过索引完成的,因此记录被锁定基于它的PK。

【讨论】:

    【解决方案3】:

    在某些时候,一个用户正在等待另一个用户拥有的锁,而第一个用户拥有第二个用户想要的锁。这就是导致死锁的原因。

    猜测,这是因为当您在增量 sp 中更新计数器时没有主键(或实际上任何键),它必须读取整个表。与 primary_relation 表相同。这将使锁四处散落,并为僵局开辟道路。我不是 Postgres 用户,所以我不知道它何时会放置锁的详细信息,但我很确定这就是正在发生的事情。

    在计数器上放置 PK 可以使数据库能够准确地定位它读取的行并放置最少数量的锁。你也应该对 primary_relation 进行 PK!

    【讨论】:

    • 这不能回答问题。 OP 想知道 为什么 这组特定的函数会死锁,而不是死锁是什么(他知道)或如何避免死锁(他已经发现)。并且已经在 primary_relation 上进行了 PK。
    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 2015-03-09
    • 2019-12-27
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2016-12-26
    • 1970-01-01
    相关资源
    最近更新 更多