在PostgreSQL中如何进行大规模的非阻塞更新?

86

我想在PostgreSQL的表中进行大量更新,但我不需要在整个操作期间维护事务完整性,因为我知道在更新期间更改的列不会被写入或读取。我想知道是否有一种简单的方法在psql控制台中可以使这些类型的操作更快。

例如,假设我有一个名为“orders”的表,其中有3500万行数据,我想执行以下操作:

UPDATE orders SET status = null;
为避免偏题讨论,我们假设所有3500万个列的状态值都已设置为相同的(非空)值,因此使索引无效。
这种说法的问题在于它需要很长时间才能生效(仅因为锁定),并且在整个更新完成之前,所有更改的行都将被锁定。此更新可能需要5小时,而类似于...
UPDATE orders SET status = null WHERE (order_id > 0 and order_id < 1000000);

可能需要1分钟时间。对于超过3500万行的数据,在将其分为35个部分后,执行上述操作仅需要35分钟即可节省4小时25分钟。

我可以使用脚本进一步拆分(这里使用伪代码):

for (i = 0 to 3500) {
  db_operation ("UPDATE orders SET status = null
                 WHERE (order_id >" + (i*1000)"
             + " AND order_id <" + ((i+1)*1000) " +  ")");
}

这个操作可能只需要几分钟就能完成,而不是35分钟。

所以问题就在于我真正想问的是:我不想每次想要做一次大型单次更新时都要编写一个分解操作的脚本。有没有一种方法可以完全在SQL中实现我想要的?


我不是 PostgreSQL 的专家,但您是否尝试在状态列上设置索引? - Kirtan
在这种情况下,它并没有太大帮助,因为绝大部分时间都花在维护事务完整性上。我的例子可能有点误导人,相反,想象一下我只是想做到这一点:UPDATE orders SET status = null;我上面说的一切仍然适用(但显然在这里使用索引是没有帮助的)。 - S D
实际上,我刚刚更新了问题以反映这一点。 - S D
虽然所有更新的行都被锁定,但你仍然可以在运行时“选择”它们。 有关更多信息,请访问https://www.postgresql.org/docs/7.2/static/locking-tables.html。 - rogerdpack
9个回答

53

列 / 行

......我不需要在整个操作中维护事务完整性,因为我知道我正在更改的列在更新期间不会被写入或读取。

PostgreSQL的MVCC模型中的任何UPDATE都会写入整行的新版本。如果并发事务更改同一行的任何列,则会出现耗时的并发问题。详见手册。 知道并发事务不会触及相同的可以避免某些可能的复杂情况,但不能避免其他情况。

索引

为避免偏离主题的讨论,让我们假设35百万列的所有状态值当前都设置为相同的(非空)值,从而使索引无用。

当更新整个表(或其主要部分)时,Postgres从不使用索引。当需要读取所有或大多数行时,顺序扫描更快。相反:索引维护意味着对UPDATE的额外成本。

性能

例如,假设我有一个名为“orders”的表,其中包含3500万行,并且我想执行以下操作:

UPDATE orders SET status = null;

我知道你的目标是提供一个更通用的解决方案(见下文)。但是为了回答实际问题:无论表格大小,这可以在几毫秒内处理:

ALTER TABLE orders DROP column status
                 , ADD  column status text;

手册(至Postgres 10):

当使用 ADD COLUMN 添加列时,表中的所有现有行将使用该列的默认值进行初始化(如果未指定 DEFAULT 子句,则为 NULL)。 如果没有 DEFAULT 子句,那么这只是元数据更改 [...]

手册(自Postgres 11起):

当使用ADD COLUMN添加一个列,并且指定了一个非易失的DEFAULT时,该默认值在语句执行时进行计算,并将结果存储在表的元数据中。该值将用于所有现有行的列。如果未指定DEFAULT,则使用NULL。在这两种情况下,不需要重写表。
使用易失的DEFAULT添加列或更改现有列的类型将需要重新编写整个表及其索引。[...] DROP COLUMN形式并不会实际删除列,而只是使其对SQL操作不可见。表中的后续插入和更新操作将为该列存储空值。因此,删除列很快,但不会立即减少表的磁盘大小,因为已删除列所占用的空间不会被回收。随着现有行的更新,该空间将逐渐回收。

确保没有对象依赖于该列(外键约束、索引、视图等)。您需要删除/重新创建这些对象。除此之外,在系统目录表pg_attribute上进行微小的操作即可完成任务。需要对表进行独占锁定,这可能会对重负载产生问题。(正如Buurman在他的评论中强调的那样。)如果没有其他选择,该操作只需要几毫秒。

如果您有一个要保留的列默认值,请在单独的命令中添加它。在同一命令中执行会立即将其应用于所有行。请参见:

要实际应用默认值,请考虑分批处理:

通用解决方案

更新:自Postgres 11以来,您可以使用PROCEDURE来实现此目的,其中您可以包含事务控制语句,如COMMIT。请参见:


dblink已在另一个回答中提到。它允许在隐式独立连接中访问“远程”Postgres数据库。“远程”数据库可以是当前数据库,从而实现“自主事务”:函数在“远程”数据库中编写的内容已提交,不能回滚。

这使得可以运行单个函数,以小部分方式更新大表格,并且每个部分都会单独提交。避免为非常多的行建立事务开销,更重要的是,在每个部分之后释放锁定。这允许并发操作继续进行,几乎没有延迟,并使死锁的可能性更小。

如果您没有并发访问,则这几乎没有用 - 除了避免异常后的ROLLBACK。对于这种情况,请考虑SAVEPOINT

免责声明

首先,大量的小事务实际上是更昂贵的。这仅对大型表格有意义。最佳选择取决于许多因素。

如果您不确定自己在做什么:单个事务是安全的方法。为了使其正常工作,表上的并发操作必须相互配合。例如:并发写操作可能会将某行移动到已经处理过的分区中。或者并发读操作可能会看到不一致的中间状态。你已经被警告了。

逐步说明

首先需要安装附加模块 dblink:

使用 dblink 建立连接非常依赖于您的 DB 集群设置和现有的安全策略。这可能有些棘手。相关的后续答案提供了更多如何使用 dblink 进行连接的信息:

创建一个FOREIGN SERVER和一个USER MAPPING,按照指示进行操作,以简化和优化连接(除非您已经有了一个)。假设存在一个带有或不带有一些间隔的serial PRIMARY KEY
CREATE OR REPLACE FUNCTION f_update_in_steps()
  RETURNS void AS
$func$
DECLARE
   _step int;   -- size of step
   _cur  int;   -- current ID (starting with minimum)
   _max  int;   -- maximum ID
BEGIN
   SELECT INTO _cur, _max  min(order_id), max(order_id) FROM orders;
                                        -- 100 slices (steps) hard coded
   _step := ((_max - _cur) / 100) + 1;  -- rounded, possibly a bit too small
                                        -- +1 to avoid endless loop for 0
   PERFORM dblink_connect('myserver');  -- your foreign server as instructed above

   FOR i IN 0..200 LOOP                 -- 200 >> 100 to make sure we exceed _max
      PERFORM dblink_exec(
       $$UPDATE public.orders
         SET    status = 'foo'
         WHERE  order_id >= $$ || _cur || $$
         AND    order_id <  $$ || _cur + _step || $$
         AND    status IS DISTINCT FROM 'foo'$$);  -- avoid empty update

      _cur := _cur + _step;

      EXIT WHEN _cur > _max;            -- stop when done (never loop till 200)
   END LOOP;

   PERFORM dblink_disconnect();
END
$func$  LANGUAGE plpgsql;

调用:

SELECT f_update_in_steps();

您可以根据需要对任何部分进行参数化:表名、列名、值等,但一定要对标识符进行清理以避免 SQL 注入:

避免空 UPDATE:


2
请注意,根据答案中链接的文档(http://www.postgresql.org/docs/current/interactive/sql-altertable.html#AEN67134),包括ADD COLUMN在内的大多数ALTER TABLE操作都会对表进行独占锁定。这意味着,操作本身可能非常快,但如果足够多的其他线程正在持有(部分)表上的锁,则它可能会花费很长时间等待独占锁,在此过程中阻止其他(“更新”)访问操作。这意味着,尽管此操作很快,但仍有可能使您的应用程序挂起很长时间。 - Buurman

4
您应该将此列委派给另一个表格,如下所示:
create table order_status (
  order_id int not null references orders(order_id) primary key,
  status int not null
);

然后你设置status=NULL的操作将是即时的:

truncate order_status;

3

首先,您确定需要更新所有行吗?

也许有些行已经具有 status 为 NULL?

如果是这样的话,则:

UPDATE orders SET status = null WHERE status is not null;

关于分区更改-这在纯SQL中是不可能的。所有更新都在单个事务中。
在“纯SQL”中实现的一种可能的方法是安装dblink,使用dblink连接到相同的数据库,然后通过dblink发出大量更新,但对于这样一个简单的任务来说,这似乎有点过头了。
通常只需添加适当的where即可解决问题。如果不行-请手动分区。编写脚本太麻烦了-通常可以用一个简单的一行命令解决:
perl -e '
    for (my $i = 0; $i <= 3500000; $i += 1000) {
        printf "UPDATE orders SET status = null WHERE status is not null
                and order_id between %u and %u;\n",
        $i, $i+999
    }
'

为了易读性,我在这里换行了,通常它是一条单独的命令。上述命令的输出结果可直接输入到psql中:

perl -e '...' | psql -U ... -d ...

或者先将文件存储,然后再导入到psql中(以防以后需要使用该文件):

perl -e '...' > updates.partitioned.sql
psql -U ... -d ... -f updates.partitioned.sql

感谢您的回复,但它基本上与我在问题中提出的第三种解决方案基本相同;基本上,这就是我已经在做的。然而,编写这样的脚本需要5分钟时间,而我正试图找出一种在psql内部执行它的方法,因此可以在20秒或更短时间内完成(并消除潜在的拼写错误/漏洞)。这就是我正在问的问题。 - S D
我认为我已经回答了这个问题 - 除非使用诸如 dblink 之类的技巧,否则无法在 SQL 中完成。另一方面 - 我写的那个一行代码只用了大约30秒钟,所以看起来并不需要太多时间 :) 它绝对比您设想中的5分钟脚本编写更接近您的20秒目标。 - user80168
3
谢谢,但是当我说“SQL”时,我说错了。实际上我想问的是如何在 PostgreSQL 的 psql 控制台中执行此操作,使用任何可能的技巧,包括 plgpsql。像上面所写的那样编写脚本就是我现在正在做的事情。每次执行这些更新之一时,它需要超过30秒,因为您必须编写自定义的小型脚本,并且您必须进行查询以找出有多少行,而且还必须确保没有拼写错误等等。我想要做的是像这样:

select nonblocking_query('update orders set status=null');

这就是我想完成的事情。
- S D
这就是我已经回答了两次的内容:除非你使用dblink,否则不可能实现,但这比你不喜欢的那些一行代码更加复杂。 - user80168

3

因此,如果确实需要5小时,那么原因可能是不同的(例如,与您声称没有的并发写入相反)。


2
我上面引用的时间(5小时、35分钟、约3分钟)是针对我上述场景的准确时间。我并没有说数据库中没有其他写入操作;只是我知道在我进行更新时,没有人会写入(该列根本没有被系统使用,但行是读取/写入的)。换句话说,我不在乎这项工作是在一个巨大的事务中处理还是分成小块处理;我关心的是速度。我可以使用上述方法来提高速度,但它们很繁琐。 - S D
1
目前仍不清楚长时间运行的原因是由于锁定还是例如真空操作。在更新之前尝试获取表锁,锁定任何其他类型的操作。然后,您应该可以完成此更新而没有任何干扰。 - Martin v. Löwis
4
如果我锁定除此操作之外的所有操作,那么系统可能会因为要完成这个操作而被卡住。相反,我所提供的两个将时间缩短至35分钟/3分钟的解决方案并不会阻碍系统正常运行。我想要的是一种方法,在不编写脚本的情况下完成此操作(这将每次更新都节省5分钟时间)。 - S D

2

我并不是一名DBA,但是在需要频繁更新3500万行的数据库设计中可能会出现问题。

一个简单的WHERE status IS NOT NULL 可能会大大加快速度(前提是您已经在status上建立了索引)- 不知道实际用例是什么,我假设如果这个操作经常运行,那么3500万行中的很大一部分可能已经具有null状态。

然而,您可以通过LOOP语句在查询中创建循环。我只是举个小例子:

CREATE OR REPLACE FUNCTION nullstatus(count INTEGER) RETURNS integer AS $$
DECLARE
    i INTEGER := 0;
BEGIN
    FOR i IN 0..(count/1000 + 1) LOOP
        UPDATE orders SET status = null WHERE (order_id > (i*1000) and order_id <((i+1)*1000));
        RAISE NOTICE 'Count: % and i: %', count,i;
    END LOOP;
    RETURN 1;
END;
$$ LANGUAGE plpgsql;

接着可以运行类似以下的操作:

SELECT nullstatus(35000000);

你可能想选择行数,但要注意确切的行数可能需要很长时间。PostgreSQL维基百科有一篇关于缓慢计数及如何避免它的文章。

此外,RAISE NOTICE部分只是为了跟踪脚本的进度。如果您没有监视通知或不在意,最好将其省略。


1
这并没有帮助,因为函数调用将在单个事务中进行 - 因此,锁定问题仍然存在。 - user80168
嗯,我没有考虑到这一点 - 不过,我认为这比UPDATE orders SET status = null;更快,因为那会导致整个表的扫描。 - mikl
1
我理解使用索引可以让查询更快,但这并不是我的关注点,因为在某些情况下,列的每个值都相同,使索引变得无用。我真正关心的是将此查询作为一个操作运行(5小时)与分成几个部分运行的时间差异(3分钟),并希望在psql中实现此操作而无需每次编写脚本。我知道有关索引的知识,并且知道如何通过使用它们来进一步节省操作时间。 - S D
哦,回答你问题的第一部分:需要更新3500万行确实很少见。这主要是为了清理;例如,我们可能会决定,“为什么订单状态=‘a’表示订单表中的‘已接受’和发货表中的‘已取消’?我们应该使其一致!”因此我们需要更新代码并进行数据库大规模更新以清除不一致性。当然这只是一个抽象,因为我们实际上根本没有“订单”。 - S D
目前看来,在Postgres中处理嵌套事务的答案是“使用dblink”。 - rogerdpack

2

I would use CTAS:

begin;
create table T as select col1, col2, ..., <new value>, colN from orders;
drop table orders;
alter table T rename to orders;
commit;

只有在执行此操作期间不修改表的其他列时,这可能是最佳解决方案。 - fzzfzzfzz

2
你确定这是由于锁定引起的吗?我不这么认为,还有许多其他可能的原因。要找出原因,你可以尝试只进行锁定操作。请尝试以下操作: BEGIN; SELECT NOW(); SELECT * FROM order FOR UPDATE; SELECT NOW(); ROLLBACK;
要了解实际发生的情况,你应该先运行 EXPLAIN(EXPLAIN UPDATE orders SET status...)和/或 EXPLAIN ANALYZE。也许你会发现你没有足够的内存来有效地执行 UPDATE 操作。如果是这样,SET work_mem TO 'xxxMB'; 可能是一个简单的解决方案。
此外,请查看 PostgreSQL 日志以查看是否出现了一些与性能相关的问题。

1

1

还有一些未被提及的选项:

使用 新表 技巧。在您的情况下,您可能需要编写一些触发器来处理它,以便对原始表的更改也传播到您的表副本,类似于这样...(percona 是一个使用触发器的示例)。另一个选择可能是“创建一个新列,然后用它替换旧列”的 技巧,以避免锁定(不清楚是否有助于加速)。

可能要计算最大 ID,然后生成“您需要的所有查询”,并将它们作为单个查询传递,如 update X set Y = NULL where ID < 10000 and ID >= 0; update X set Y = NULL where ID < 20000 and ID > 10000; ... 然后它可能不会进行太多锁定,仍然全部是 SQL,尽管您需要额外的逻辑来完成它 :(


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接