膨胀对表和索引的影响是众所周知的,并且不仅存在于 Postgres 中。 有一些现成的方法可以处理它,例如 VACUUM FULL 或 CLUSTER,但它们在操作期间锁定表,因此不能总是使用。
本文将包含一些关于膨胀如何发生、如何对抗它、延迟约束以及它们给使用 pg_repack 扩展带来的问题的一些理论。
本文是根据
为什么会出现浮肿呢?
Postgres 基于多版本模型(
显然,所有这些版本都需要存储。 Postgres 逐页处理内存,页是可以从磁盘读取或写入的最小数据量。 让我们看一个小例子来了解这是如何发生的。
假设我们有一个表,其中添加了几条记录。 新数据已出现在存储表的文件的第一页中。 这些是提交后可供其他事务使用的行的实时版本(为简单起见,我们假设隔离级别为已提交读)。
然后我们更新了其中一个条目,从而将旧版本标记为不再相关。
一步步更新和删除行版本,我们最终得到一个页面,其中大约一半的数据是“垃圾”。 该数据对于任何交易都是不可见的。
Postgres有一个机制
因此,在我们的示例中,在某个时间点,表将由四页组成,但其中只有一半包含实时数据。 结果,当访问表时,我们将读取比需要的更多的数据。
即使 VACUUM 现在删除所有不相关的行版本,情况也不会显着改善。 我们将在页面甚至整个页面中为新行提供可用空间,但我们仍然会读取超出必要的数据。
顺便说一句,如果文件末尾有一个完全空白的页面(我们示例中的第二个页面),那么 VACUUM 将能够修剪它。 但现在她夹在中间,也拿她没有办法。
当此类空页或高度稀疏页的数量变大(称为膨胀)时,它就会开始影响性能。
上面描述的一切都是表中发生膨胀的机制。 在索引中,这种情况的发生方式大致相同。
我有浮肿吗?
有多种方法可以确定您是否有浮肿。 第一个的想法是使用 Postgres 内部统计信息,其中包含有关表中行数、“活动”行数等的大概信息。您可以在互联网上找到许多现成脚本的变体。 我们以
另一种方法是使用扩展
我们认为较小的膨胀值(最多 20%)是可以接受的。 它可以被视为填充因子的类似物
对抗肿胀的方法
Postgres 有几种开箱即用的方法来处理膨胀,但它们并不总是适合每个人。
配置 AUTOVACUUM 以免发生膨胀。 或者更准确地说,将其保持在您可以接受的水平。 这看起来像是“队长”的建议,但实际上这并不总是那么容易实现。 例如,您正在进行积极的开发,定期更改数据模式,或者正在进行某种数据迁移。 因此,您的负载曲线可能会频繁变化,并且通常会因表而异。 这意味着您需要不断地提前工作并调整 AUTOVACUUM 以适应每个表不断变化的配置文件。 但显然这并不容易做到。
AUTOVACUUM 无法跟上表的另一个常见原因是,存在长时间运行的事务,导致它无法清理这些事务可用的数据。 这里的建议也很明显——摆脱“悬空”交易并最大限度地减少活跃交易的时间。 但是,如果应用程序的负载是 OLAP 和 OLTP 的混合体,那么您可以同时进行许多频繁的更新和简短的查询,以及长期操作 - 例如,构建报告。 在这种情况下,值得考虑将负载分散到不同的基地,这将允许对每个基地进行更多的微调。
另一个例子 - 即使配置文件是同构的,但数据库处于非常高的负载下,那么即使是最激进的 AUTOVACUUM 也可能无法应对,并且会发生膨胀。 缩放(垂直或水平)是唯一的解决方案。
如果您已设置 AUTOVACUUM,但膨胀仍在继续,该怎么办?
团队 真空满 重建表和索引的内容并仅在其中保留相关数据。 为了消除膨胀,它工作得很好,但在执行过程中会捕获表上的排它锁(AccessExclusiveLock),这将不允许在此表上执行查询,甚至选择。 如果您有能力停止服务或部分服务一段时间(从几十分钟到几个小时,具体取决于数据库和硬件的大小),那么此选项是最好的。 不幸的是,我们在计划维护期间没有时间运行 VACUUM FULL,因此这种方法不适合我们。
团队 田字形 以与 VACUUM FULL 相同的方式重建表的内容,但允许您指定一个索引,数据将根据该索引在磁盘上进行物理排序(但将来不保证新行的顺序)。 在某些情况下,这对于许多查询来说是一个很好的优化 - 通过索引读取多个记录。 该命令的缺点与 VACUUM FULL 相同 - 它在操作期间锁定表。
团队 重新索引 与前两者类似,但是重建表的特定索引或所有索引。 锁稍微弱一些:表上的 ShareLock(防止修改,但允许选择)和正在重建的索引上的 AccessExclusiveLock(阻止使用此索引的查询)。 然而,在Postgres的第12版中出现了一个参数
在 Postgres 的早期版本中,您可以使用以下命令获得类似于 REINDEX CONCURRENTLY 的结果
因此,如果索引有办法“即时”消除膨胀,那么表就没有办法。 这是各种外部扩展发挥作用的地方:
pg_repack 是如何工作的
假设我们有一个完全普通的表 - 有索引、限制,不幸的是,还有膨胀。 pg_repack 的第一步是创建一个日志表来存储运行时所有更改的数据。 触发器将为每次插入、更新和删除复制这些更改。 然后创建一张表,结构上与原来类似,但没有索引和限制,以免拖慢插入数据的过程。
接下来,pg_repack将数据从旧表传输到新表,自动过滤掉所有不相关的行,然后为新表创建索引。 在执行所有这些操作期间,更改会累积在日志表中。
下一步是将更改传输到新表。 迁移会进行多次迭代,当日志表中的条目少于 20 条时,pg_repack 会获取强锁,迁移最新数据,并用 Postgres 系统表中的新表替换旧表。 这是您无法使用桌子工作的唯一且非常短的时间。 此后,旧表和带有日志的表将被删除,并释放文件系统中的空间。 该过程已完成。
理论上一切看起来都很棒,但实践中会发生什么? 我们在无负载和负载下测试了 pg_repack,并检查了其在过早停止的情况下的操作(换句话说,使用 Ctrl+C)。 所有测试均呈阳性。
我们去了食品店——然后一切都没有按我们的预期进行。
第一个煎饼发售
在第一个集群上,我们收到了有关违反唯一约束的错误:
$ ./pg_repack -t tablename -o id
INFO: repacking table "tablename"
ERROR: query failed:
ERROR: duplicate key value violates unique constraint "index_16508"
DETAIL: Key (id, index)=(100500, 42) already exists.
这个限制有一个自动生成的名称index_16508 - 它是由pg_repack创建的。 根据其组成中包含的属性,我们确定了与其相对应的“我们的”约束。 问题是,这不是一个完全普通的限制,而是一个延迟的限制(
延迟约束:为什么需要它们以及它们如何工作
关于延迟限制的一些理论。
让我们考虑一个简单的例子:我们有一本汽车表参考书,它有两个属性 - 目录中汽车的名称和顺序。
create table cars
(
name text constraint pk_cars primary key,
ord integer not null constraint uk_cars unique
);
假设我们需要交换第一辆车和第二辆车。 最简单的解决方案是将第一个值更新为第二个值,将第二个值更新为第一个值:
begin;
update cars set ord = 2 where name = 'audi';
update cars set ord = 1 where name = 'bmw';
commit;
但是当我们运行这段代码时,我们预计会发生约束冲突,因为表中值的顺序是唯一的:
[23305] ERROR: duplicate key value violates unique constraint “uk_cars”
Detail: Key (ord)=(2) already exists.
我怎样才能做到不同呢? 选项一:为表中保证不存在的订单添加附加值替换,例如“-1”。 在编程中,这称为“通过第三个变量交换两个变量的值”。 此方法的唯一缺点是需要额外更新。
选项二:重新设计表以使用浮点数据类型而不是整数作为订单值。 然后,当将值从 1(例如)更新为 2.5 时,第一个条目将自动“站立”在第二个和第三个条目之间。 该解决方案有效,但有两个限制。 首先,如果该值在界面中的某个地方使用,它对您不起作用。 其次,根据数据类型的精度,在重新计算所有记录的值之前,可能的插入次数有限。
选项三:推迟约束,以便仅在提交时检查它:
create table cars
(
name text constraint pk_cars primary key,
ord integer not null constraint uk_cars unique deferrable initially deferred
);
由于我们初始请求的逻辑确保所有值在提交时都是唯一的,因此它会成功。
当然,上面讨论的例子是非常综合的,但它揭示了这个想法。 在我们的应用程序中,我们使用延迟约束来实现负责解决用户同时使用板上共享小部件对象时的冲突的逻辑。 使用这样的限制可以使我们的应用程序代码更简单一些。
一般来说,根据约束的类型,Postgres 具有三个级别的粒度来检查它们:行级别、事务级别和表达式级别。
来源:
CHECK 和 NOT NULL 始终在行级别进行检查;对于其他限制,从表中可以看出,有不同的选项。 您可以阅读更多内容
简而言之,在许多情况下,延迟约束提供了更易读的代码和更少的命令。 但是,您必须为此付出代价,使调试过程变得复杂,因为错误发生的那一刻和您发现错误的那一刻在时间上是分开的。 另一个可能的问题是,如果请求涉及延迟约束,则调度程序可能并不总是能够构建最佳计划。
pg_repack 的改进
我们已经介绍了什么是延迟约束,但它们与我们的问题有何关系? 让我们记住之前收到的错误:
$ ./pg_repack -t tablename -o id
INFO: repacking table "tablename"
ERROR: query failed:
ERROR: duplicate key value violates unique constraint "index_16508"
DETAIL: Key (id, index)=(100500, 42) already exists.
当数据从日志表复制到新表时会发生这种情况。 这看起来很奇怪,因为... 日志表中的数据与源表中的数据一起提交。 如果它们满足原始表的约束,它们怎么会违反新表中的相同约束呢?
事实证明,问题的根源在于pg_repack的上一步,它只创建了索引,但没有创建约束:旧表有唯一约束,而新表却创建了唯一索引。
这里需要注意的是,如果约束是普通的并且没有延迟,那么改为创建的唯一索引就相当于这个约束,因为Postgres 中的唯一约束是通过创建唯一索引来实现的。 但在延迟约束的情况下,行为并不相同,因为索引不能延迟,并且总是在执行 sql 命令时检查索引。
由此可见,问题的本质就在于检查的“延迟”:在原表中是在提交时发生的,而在新表中是在执行sql命令时发生的。 这意味着我们需要确保在两种情况下执行相同的检查:要么总是延迟,要么总是立即。
那么我们有什么想法呢?
创建类似于deferred的索引
第一个想法是在立即模式下执行这两项检查。 这可能会产生一些误报限制,但如果误报限制很少,这应该不会影响用户的工作,因为这种冲突对他们来说是正常情况。 例如,当两个用户同时开始编辑同一个小部件,并且第二个用户的客户端没有时间接收该小部件已被第一个用户阻止编辑的信息时,就会发生这种情况。 在这种情况下,服务器会拒绝第二个用户,并且其客户端会回滚更改并阻止该小部件。 稍后,当第一个用户完成编辑时,第二个用户将收到该小部件不再被阻止并且能够重复其操作的信息。
为了确保检查始终处于非延迟模式,我们创建了一个类似于原始延迟约束的新索引:
CREATE UNIQUE INDEX CONCURRENTLY uk_tablename__immediate ON tablename (id, index);
-- run pg_repack
DROP INDEX CONCURRENTLY uk_tablename__immediate;
在测试环境中,我们只收到了一些预期的错误。 成功! 我们在生产环境中再次运行 pg_repack,在一小时的工作时间内在第一个集群上出现了 5 个错误。 这是一个可以接受的结果。 然而,在第二个集群上,错误数量显着增加,我们不得不停止 pg_repack。
为什么会发生这样的事? 发生错误的可能性取决于有多少用户同时使用相同的小部件。 显然,此时第一个集群上存储的数据的竞争性变化比其他集群上存储的数据要少得多,即我们只是“幸运”。
这个想法行不通。 此时,我们看到了另外两个解决方案:重写我们的应用程序代码以消除延迟约束,或者“教导”pg_repack 使用它们。 我们选择了第二个。
将新表中的索引替换为原始表中的延迟约束
修订的目的很明显 - 如果原始表有延迟约束,那么对于新表,您需要创建这样的约束,而不是索引。
为了测试我们的更改,我们编写了一个简单的测试:
- 具有延迟约束和一条记录的表;
- 在与现有记录冲突的循环中插入数据;
- 进行更新——数据不再冲突;
- 提交更改。
create table test_table
(
id serial,
val int,
constraint uk_test_table__val unique (val) deferrable initially deferred
);
INSERT INTO test_table (val) VALUES (0);
FOR i IN 1..10000 LOOP
BEGIN
INSERT INTO test_table VALUES (0) RETURNING id INTO v_id;
UPDATE test_table set val = i where id = v_id;
COMMIT;
END;
END LOOP;
pg_repack 的原始版本总是在第一次插入时崩溃,修改后的版本可以正常工作。 伟大的。
我们进入生产环境,在将数据从日志表复制到新表的同一阶段再次出现错误:
$ ./pg_repack -t tablename -o id
INFO: repacking table "tablename"
ERROR: query failed:
ERROR: duplicate key value violates unique constraint "index_16508"
DETAIL: Key (id, index)=(100500, 42) already exists.
经典情况:在测试环境中一切正常,但在生产环境中却不行?!
APPLY_COUNT 和两个批次的交界处
我们开始逐行分析代码,发现了一个重要的点:数据从日志表批量转移到新的日志表中,APPLY_COUNT常量表示批量的大小:
for (;;)
{
num = apply_log(connection, table, APPLY_COUNT);
if (num > MIN_TUPLES_BEFORE_SWITCH)
continue; /* there might be still some tuples, repeat. */
...
}
问题在于,来自原始事务的数据(其中多个操作可能会违反约束)在传输时可能会出现在两个批次的交界处 - 一半的命令将在第一批中提交,另一半将在第一批中提交在第二。 在这里,取决于你的运气:如果团队在第一批中没有违反任何规定,那么一切都很好,但如果他们这样做了,就会出现错误。
APPLY_COUNT 等于 1000 条记录,这解释了为什么我们的测试成功 - 它们没有涵盖“批量连接”的情况。 我们使用了两个命令 - 插入和更新,因此两个命令的 500 个事务总是放置在一个批次中,并且我们没有遇到任何问题。 添加第二个更新后,我们的编辑停止工作:
FOR i IN 1..10000 LOOP
BEGIN
INSERT INTO test_table VALUES (1) RETURNING id INTO v_id;
UPDATE test_table set val = i where id = v_id;
UPDATE test_table set val = i where id = v_id; -- one more update
COMMIT;
END;
END LOOP;
因此,下一个任务是确保在一个事务中更改的原始表中的数据最终也会在一个事务内出现在新表中。
拒绝批处理
我们再次有两个解决方案。 第一:让我们完全放弃批量分区并在一个事务中传输数据。 这个解决方案的优点是它的简单性 - 所需的代码更改很少(顺便说一下,在旧版本中 pg_reorg 的工作方式完全一样)。 但有一个问题——我们正在创建一个长期运行的交易,正如之前所说,这对出现新的膨胀构成威胁。
第二种解决方案更复杂,但可能更正确:在日志表中创建一个列,其中包含向表中添加数据的事务的标识符。 然后,当我们复制数据时,我们可以通过这个属性对其进行分组,并确保相关的更改一起传输。 该批次将由多个事务(或一个大事务)组成,其大小将根据这些事务中更改的数据量而变化。 需要注意的是,由于来自不同事务的数据以随机顺序进入日志表,因此将不再可能像以前那样顺序读取它。 通过 tx_id 过滤的每个请求的 seqscan 成本太高,需要索引,但由于更新它的开销,它也会减慢该方法。 一般来说,一如既往,你需要牺牲一些东西。
因此,我们决定从第一个选项开始,因为它更简单。 首先,有必要了解长交易是否会成为一个真正的问题。 由于从旧表到新表的主要数据传输也发生在一个长事务中,因此问题转化为“我们将增加该事务多少?” 第一个事务的持续时间主要取决于表的大小。 新的持续时间取决于数据传输期间表中累积的更改数量,即关于负载的强度。 pg_repack 运行发生在服务负载最小的时期,并且与表的原始大小相比,更改量小得不成比例。 我们决定忽略新交易的时间(作为比较,平均为 1 小时 2-3 分钟)。
实验结果是积极的。 也开始生产。 为了清楚起见,下面是运行后其中一个数据库的大小的图片:
由于我们对这个解决方案完全满意,因此我们没有尝试实现第二个解决方案,但我们正在考虑与扩展开发人员讨论它的可能性。 不幸的是,我们当前的修订版尚未准备好发布,因为我们仅通过独特的延迟限制解决了问题,并且对于成熟的补丁,有必要为其他类型提供支持。 我们希望将来能够做到这一点。
也许你有一个问题,为什么我们甚至通过修改 pg_repack 来参与这个故事,而不是使用它的类似物? 在某些时候我们也考虑过这个问题,但是早期在没有延迟约束的表上使用它的积极经验激励我们尝试理解问题的本质并解决它。 此外,使用其他解决方案也需要时间进行测试,因此我们决定首先尝试修复其中的问题,如果我们意识到无法在合理的时间内做到这一点,那么我们将开始寻找类似物。
发现
根据我们自己的经验,我们可以推荐:
- 监测你的肿胀情况。 根据监控数据,您可以了解autovacuum的配置情况。
- 调整 AUTOVACUUM 以将膨胀保持在可接受的水平。
- 如果膨胀仍在增长,并且您无法使用开箱即用的工具来克服它,请不要害怕使用外部扩展。 最主要的是测试好一切。
- 不要害怕修改外部解决方案来满足您的需求 - 有时这可能比更改您自己的代码更有效,甚至更容易。
来源: habr.com