PostgreSQL Builtin Trigger Function to Speed Up Updates
PostgreSQL ships with an internal trigger function, namedsuppress_redundant_updates_trigger
that can be used to avoid idempotent updates on a table.
The online documentation explains very well how to use it, including the fact that the trigger should be fire as last in a trigger chain, and so the trigger name should be alphabetically the last one in natural sorting.
But is it worth using such function?
Let’s find out wth a very trivial example on well known
pgbench
database. First of all, let’s consider the initial setup:
pgbench=> SELECT count(*),
pg_size_pretty( pg_relation_size( 'pgbench_accounts' ) )
FROM pgbench_accounts;
count | pg_size_pretty
----------|----------------
10000000 | 1281 MB
(1 row)
Now, let’s execute an idempotet
UPDATE
, that is something that does not change anything, and monitor the timing:
pgbench=> \timing
Timing is on.
pgbench=> UPDATE pgbench_accounts SET filler = filler;
UPDATE 10000000
Time: 307939,763 ms (05:07,940)
pgbench=> SELECT pg_size_pretty( pg_relation_size( 'pgbench_accounts' ) );
pg_size_pretty
----------------
2561 MB
(1 row)
Time: 180,732 ms
Note how the table has doubled its size: this is because of bloating caused by every row being substituted by an exact copy of it.
Now, let’s create the trigger using the
suppress_redundant_updates_trigger
function, and let’s run the same update again, but after a server restart to clean up also the memory.
pgbench=> CREATE TRIGGER tr_avoid_idempotent_updates
BEFORE UPDATE ON pgbench_accounts
FOR EACH ROW
EXECUTE FUNCTION suppress_redundant_updates_trigger();
-- restart the server
pgbench=> \timing
Timing is on.
pgbench=> UPDATE pgbench_accounts SET filler = filler;
UPDATE 0
Time: 287588,607 ms (04:47,589)
pgbench=> SELECT pg_size_pretty( pg_relation_size( 'pgbench_accounts' ) );
pg_size_pretty
----------------
2561 MB
(1 row)
The total gain was about
20 secs
, that is a speed up of roughly 7%
, that is not too much at all.
However, note how the
UPDATE
reports zero tuples have been touched, therefore while the speed up gain is not really exciting, the bloating of the table remains the same as before the UPDATE
itself.
After a full vacuum, the speed up results a lot more, but this can be a counter effect of having in memory already some pages:
pgbench=> VACUUM FULL pgbench_accounts ;
VACUUM
Time: 222455,150 ms (03:42,455)
pgbench=> UPDATE pgbench_accounts SET filler = filler;
UPDATE 0
Time: 198104,981 ms (03:18,105)
However, even after a reboot of the server, the time remains lower:
pgbench=> UPDATE pgbench_accounts SET filler = filler;
UPDATE 0
Time: 184217,260 ms (03:04,217
So the gain on a not bloated table is around
67%
which is much more interesting!
Timing the trigger execution
How long does it take to execute the trigger function against every row? It is possible to get this information withEXPLAIN ANALYZE
:
pgbench=> EXPLAIN (FORMAT yaml, ANALYZE, VERBOSE, TIMING )
UPDATE pgbench_accounts SET filler = filler;
QUERY PLAN
---------------------------------------------------
- Plan: +
Node Type: "ModifyTable" +
Operation: "Update" +
Parallel Aware: false +
Relation Name: "pgbench_accounts" +
Schema: "public" +
Alias: "pgbench_accounts" +
Startup Cost: 0.00 +
Total Cost: 263935.00 +
Plan Rows: 10000000 +
Plan Width: 103 +
Actual Startup Time: 153053.980 +
Actual Total Time: 153377.845 +
Actual Rows: 0 +
Actual Loops: 1 +
Plans: +
- Node Type: "Seq Scan" +
Parent Relationship: "Member" +
Parallel Aware: false +
Relation Name: "pgbench_accounts" +
Schema: "public" +
Alias: "pgbench_accounts" +
Startup Cost: 0.00 +
Total Cost: 263935.00 +
Plan Rows: 10000000 +
Plan Width: 103 +
Actual Startup Time: 8.968 +
Actual Total Time: 44542.939 +
Actual Rows: 10000000 +
Actual Loops: 1 +
Output: +
- "aid" +
- "bid" +
- "abalance" +
- "filler" +
- "ctid" +
Planning Time: 24.475 +
Triggers: +
- Trigger Name: "tr_avoid_idempotent_updates"+
Relation: "pgbench_accounts" +
Time: 1510.272 +
Calls: 10000000 +
Execution Time: 159552.624
(1 row)
As you can see, running the trigger requires roughly
1.5 secs
for 10 million
tuples.
Assuming the timing is enough accurate and stable, it means
0.00015 msecs
for every tuple, that is not much overhead after all.
It is possible to provide another table to experiment against, in order to see if the timing for the trigger eecution depends on the data types and its content:
pgbench=> create table stuff( pk serial, t text );
pgbench=> INSERT INTO stuff( t ) SELECT repeat( 'abc', 1000 )
from generate_series( 1, 2000000 );
pgbench=> CREATE TRIGGER tr_avoid_idempotent_updates
BEFORE UPDATE ON stuff
FOR EACH ROW
EXECUTE FUNCTION suppress_redundant_updates_trigger();
pgbench=> EXPLAIN ( FORMAT yaml, ANALYZE, VERBOSE, TIMING )
UPDATE stuff SET t = t;
...
Triggers: +
- Trigger Name: "tr_avoid_idempotent_updates"+
Relation: "stuff" +
Time: 223.227 +
Calls: 2000000 +
Again, the mean execution time of the trigger is
0.00011 msecs
, and very similar (if not equal) results can be obtained with the pk
column, so I would say that the execution time of the trigger does not involves the specific type of the column(s) being updated.
The Black Behing the Triger Funtion
Thesuppress_redundant_updates_trigger
is defined in the file utils/adt/trigfuncs.c
, and the magic happens in the following piece of code:
/* if the tuple payload is the same ... */
if (newtuple->t_len == oldtuple->t_len &&
newheader->t_hoff == oldheader->t_hoff &&
(HeapTupleHeaderGetNatts(newheader) ==
HeapTupleHeaderGetNatts(oldheader)) &&
((newheader->t_infomask & ~HEAP_XACT_MASK) ==
(oldheader->t_infomask & ~HEAP_XACT_MASK)) &&
memcmp(((char *) newheader) + SizeofHeapTupleHeader,
((char *) oldheader) + SizeofHeapTupleHeader,
newtuple->t_len - SizeofHeapTupleHeader) == 0)
{
/* ... then suppress the update */
rettuple = NULL;
}
that essentially compares the old and the new tuple to see if they have the same headers, the same number of attributes, and of course the same content of the memory representation (by means of
memcpm(3)
).
Doing in plpgsql
It is possible to implement a basic function in plpgsql
by means of the IS DISTINCT FROM
operator:
CREATE OR REPLACE FUNCTION
f_avoid_idempotent_updates()
RETURNS TRIGGER
AS $CODE$
BEGIN
IF NEW.* IS DISTINCT FROM OLD.* THEN
RETURN NEW;
ELSE
RETURN NULL;
END IF;
END
$CODE$
LANGUAGE plpgsql;
and the execution with this trigger in place results in:
pgbench=> drop trigger tr_avoid_idempotent_updates on pgbench_accounts;
DROP TRIGGER
pgbench=> create trigger tr_avoid_idempotent_updates
before update on pgbench_accounts
for each row
execute function f_avoid_idempotent_updates();
CREATE TRIGGER
pgbench=> update pgbench_accounts set filler = filler;
UPDATE 0
Time: 167400,098 ms (02:47,400)
and if you track function executions:
pgbench=> select * from pg_stat_user_functions ;
-[ RECORD 1 ]--------------------------
funcid | 36672
schemaname | public
funcname | f_avoid_idempotent_updates
calls | 10000000
total_time | 21276.741
self_time | 21276.741
that indicates that
21 secs
are spent in doing the trigger analysis, so roughly 0,0021 msecs
spent for each tuple. This is by far much more expensive of the C default function (that was roughly 0.00015 msecs
).
Similar results are emphasized by the
EXPLAIN ANALYZE
output:
pgbench=> EXPLAIN (FORMAT yaml, ANALYZE, TIMING )
UPDATE pgbench SET filler = filler;
...
| Triggers: +
| - Trigger Name: "tr_avoid_idempotent_updates"+
| Relation: "pgbench_accounts" +
| Time: 23002.383 +
| Calls: 10000000 +
| Execution Time: 163343.183
Here the
Time
is around 23000 msecs
while with the C native function it was about 1500 msecs
.
Conclusions
The internalsuppress_redundant_updates_trigger
function can be useful for reducing both time and bloating against large batches of UPDATE
s.
The function is implemented in the C language and checks if the memory content of the tuples is the same or not, and this makes this approach really powerful and not so error prone as defining a custom trigger function by the user.