PostgreSQL反模式:在负载下更新大表

如果您需要更新 “成百万个”活跃使用的PostgreSQL表中的大量记录 ,该怎么做(绝对不是)—初始化新字段的值或更正现有记录中的错误? 同时节省您的时间,不会因停机而损失公司资金。



准备测试数据:

CREATE TABLE tbl(k text, v integer); INSERT INTO tbl SELECT chr(ascii('a'::text) + (random() * 26)::integer) k , (random() * 100)::integer v FROM generate_series(1, 1000000) i; --  ,   ! CREATE INDEX ON tbl(k, v); 

假设我们只想对k在'q'..'z'范围内的所有记录将v的值增加1。

但是,在开始实验之前,我们将保存原始数据集,以便每次都能获得“干净”的结果:

 CREATE TABLE _tbl AS TABLE tbl; 

更新:一劳永逸,一劳永逸


立即想到的最简单的选择是“一次更新”进行所有操作:

 UPDATE tbl SET v = v + 1 WHERE k BETWEEN 'q' AND 'z'; 


[看explain.tensor.ru]

看起来,在完全“短”线上的操作相当简单,耗时超过2.5秒。 而且,如果您的表达方式更加复杂,则该行会更真实,记录更多,甚至会有一些触发器介入-时间可能不会增加到几分钟,而会增加到几个小时。 假设您准备好等待,并且系统的其余部分是否已连接到该基础(如果它具有活动的OLTP负载)?

问题在于,一旦UPDATE到达特定记录,它将立即阻止它直到执行结束 。 如果他希望与同一记录同时进行并行启动的“现场”更新,则他仍将“挂在钩子上”等待更新请求的块 ,并将下垂直到工作结束。


© wumo.com/wumo

最糟糕的情况是Web系统,其中需要根据需要创建与数据库的连接-毕竟,这种“悬而未决的”连接会累积起来,如果您不为此建立单独的防御机制,则会吞噬数据库和客户端的资源。

分割交易


通常,如果所有事情都在一个请求中完成,那么一切都不是很好。 是的,即使我们将一个大的UPDATE分成几个小的UPDATE,但让它们全部在一个事务中工作,但锁定的问题将保持不变,因为可变记录将被锁定直到整个事务结束。

因此,我们需要将一项大交易分成几笔。 为此,我们既可以使用外部手段,编写某种脚本来生成单独的事务,也可以利用数据库本身可以为我们提供的机会。

通话和交易管理


从PostgreSQL 11开始, 可以在程序代码内直接管理事务
在CALL命令调用的过程以及匿名代码块(在DO命令中)中,您可以通过执行COMMIT和ROLLBACK来完成事务。 通过这些命令完成事务后,将自动启动新的事务。
但是此版本并不是所有人都可以使用,因此使用CALL有其局限性。 因此,我们将尝试在没有外部手段的情况下解决问题,从而使其能够在所有当前版本上运行,甚至只需对服务器本身进行最少的更改即可-从而无需编译和重新启动任何程序。

出于同样的原因,我们将不考虑通过pg_background组织自主交易的选择

在“内部”管理连接


PostgreSQL过去一直使用不同的方法来模拟自主事务 ,并通过其他过程语言或标准dblink模块生成单独的其他连接。 后者的优点是,默认情况下,它包含在大多数发行版中,并且只需一个命令即可在数据库中激活它:

 CREATE EXTENSION dblink; 

“ ...带来了许多很多令人恶心的孩子”


但是,在创建dblink绑定之前,我们首先要弄清楚“常规开发人员”如何将需要更新的大型数据集分解为小的数据集。

天真LIMIT ... OFFSET


第一个想法是进行“分页”搜索:通过在每个新请求中增加OFFSET, “让我们每次都选择下一个千条记录

 UPDATE tbl T SET v = Tv + 1 FROM ( SELECT k , v FROM tbl WHERE k BETWEEN 'q' AND 'z' ORDER BY --       k, v --     ! LIMIT $1 OFFSET $2 * $1 ) S WHERE (Tk, Tv) = (Sk, Sv); 

在测试此解决方案的性能之前,我们将还原数据集:

 TRUNCATE TABLE tbl; INSERT INTO tbl TABLE _tbl; 

正如我们在上述计划中所看到的,我们将需要更新大约38.4万条记录。 因此,让我们立即看看如何在末尾进行更新- 在1000个条目的第300次迭代区域


[看explain.tensor.ru]

哦……在整个1K记录的末尾更新样本将花费我们几乎与整个原始版本一样的时间!

这不是我们的选择。 如果迭代次数少且偏移值较小,仍然可以使用它。 因为数据库的LIMIT X OFFSET Y等效于“ 减去/选择/形成第一个X + Y记录,然后将第一个Y扔到垃圾箱 ”,这对于大的Y值看起来是悲惨的。

实际上,这种方法根本不能应用 ! 我们不仅要依赖更新的值来进行选择,而且还冒着跳过部分记录的风险,如果具有相同键的块到达页面边界,则有可能更新另一部分两次:


在此示例中,我们更新了绿色记录两次,而从未更新红色记录。 仅仅因为排序键的值相同,在这样的块中记录本身的顺序就不固定。

可悲的ORDER BY ... LIMIT


让我们稍微修改一下任务-添加一个新字段,将值v + 1写入其中:

 ALTER TABLE tbl ADD COLUMN x integer; 

请注意,这种设计几乎可以立即生效,而无需重写整个表格。 但是,如果您添加DEFAULT值,则-仅从第11版开始

已经经历了痛苦的经历,让我们立即创建一个索引,其中仅保留未初始化的条目:

 CREATE INDEX CONCURRENTLY ON tbl(k, v) WHERE x IS NULL; 

CONCURRENTLY索引不会阻止对表的读写工作,尽管它甚至会缓慢滚动到庞大的数据集上。

现在的想法是“让我们每次都只从头几千条记录中选择索引”

 UPDATE tbl T SET x = Tv + 1 FROM ( SELECT k, v FROM tbl WHERE k BETWEEN 'q' AND 'z' AND x IS NULL ORDER BY k, v LIMIT 1000 --   OFFSET! ) S WHERE (Tk, Tv) = (Sk, Sv) AND Tx IS NULL; 


[看explain.tensor.ru]

现在已经好多了-每笔交易的持续时间现在缩短了大约6倍。

但是,让我们再次看看第200次迭代的计划将变成什么:

 Update on tbl t (actual time=530.591..530.591 rows=0 loops=1) Buffers: shared hit=789337 read=1 dirtied=1 

时间再次恶化(尽管只有25%),缓冲区增加了-但是为什么呢?
事实是, PostgreSQL中的MVCC在索引中留下了“死灵”-已经更新的记录的版本,现在不再适合该索引。 也就是说,在第200次迭代中仅获取前1000条记录,我们仍然会扫描 ,即使稍后,我们也会丢弃之前已更改的199K版本的元组。

如果我们需要的迭代次数不是数百次,而是数十万次,那么每次执行后续查询时,降级都会更加明显。

按细分更新


实际上,为什么我们如此重视“ 1000条记录”的值? 毕竟,我们没有理由选择正好1000或其他特定数字。 我们只是想将整个数据集“切”为一些(不一定相等) 不相交的部分 -因此,让我们将现有索引用于其预期目的。

索引对(k,v)非常适合我们的任务。 让我们构建一个查询,以便它可以基于最后处理的对:

 WITH kv AS ( SELECT k, v FROM tbl WHERE (k, v) > ($1, $2) AND k BETWEEN 'q' AND 'z' AND x IS NULL ORDER BY k, v LIMIT 1 ) , upd AS ( UPDATE tbl T SET x = Tv + 1 WHERE (Tk, Tv) = (TABLE kv) AND Tx IS NULL RETURNING k, v ) TABLE upd LIMIT 1; 

在第一次迭代中,将查询参数设置为“零”值('',0)就足够了,对于下一次迭代,我们将获取上一个查询结果


[看explain.tensor.ru]

事务/锁定时间小于一毫秒,迭代次数不会降低性能,不需要对表中的所有数据进行全面的初步扫描。 太好了!

使用dblink放置最终版本
 DO $$ DECLARE k text = ''; v integer = 0; BEGIN PERFORM dblink_connect('dbname=' || current_database() || ' port=' || current_setting('port')); --  PREPARED STATEMENT,      PERFORM dblink($q$ PREPARE _q(text, integer) AS WITH kv AS ( SELECT k, v FROM tbl WHERE (k, v) > ($1, $2) AND k BETWEEN 'q' AND 'z' AND x IS NULL ORDER BY k, v LIMIT 1 ) , upd AS ( UPDATE tbl T SET x = Tv + 1 WHERE (Tk, Tv) = (TABLE kv) AND Tx IS NULL RETURNING k, v ) TABLE upd LIMIT 1; $q$); -- ,    LOOP SELECT * INTO k, v FROM dblink($p$EXECUTE _q('$p$ || k || $p$',$p$ || v || $p$)$p$) T(k text, v integer); RAISE NOTICE '(k,v) = (''%'',%)', k, v; --   ,     EXIT WHEN (k, v) IS NULL; END LOOP; PERFORM dblink_disconnect(); END; $$ LANGUAGE plpgsql; 


此方法的另一个优点是可以随时中断该脚本的执行,然后从所需位置恢复执行。

UPDATE中的复杂计算


当您需要从链接表中计算出一些值时,我将单独提到这种情况,即分配值的计算很困难。

花在计算上的时间也增加了交易的持续时间。 因此,最好的选择是在UPDATE之外进行计算这些值的过程

例如,我们要用具有相同值(k,v)的记录数填充新字段x。 让我们创建一个“临时”表,该表的生成没有施加其他锁:

 CREATE TABLE tmp AS SELECT k, v, count(*) x FROM tbl GROUP BY 1, 2; CREATE INDEX ON tmp(k, v); 

现在,我们可以根据此表根据上述模型进行迭代,以更新目标:

 UPDATE tbl T SET x = Sx FROM tmp S WHERE (Tk, Tv) = (Sk, Sv) AND (Sk, Sv) = ($1, $2); 

如您所见,不需要复杂的计算。

只记得稍后删除辅助表。

Source: https://habr.com/ru/post/zh-CN481610/


All Articles