When I am in a position to establish a SQL guide, I prohibit the creation of user-defined functions in a database because I have encountered many performance issues.
It is very difficult to debug and maintain a UDF. Troubleshooting issues within UDFs requires specialized knowledge and debugging techniques. If you are not familiar with the business requirements, you will struggle to debug a UDF.
However, when we join a project, there are many database systems that have already UDFs in them. Recently, I came across a performance issue related to a UDF function.
In this note, I will explain how I improved a query that utilized a UDF.
I will start with a simple script to generate a sample data set:
CREATE TABLE emp (
empno integer NOT NULL,
ename character varying(10),
job character varying(9),
hiredate timestamp without time zone,
INSERT INTO EMP VALUES
(7934,'MILLER','CLERK',7782,to_date('23-1-1982','dd-mm-yyyy'),1300,NULL,10);I have also created an idiotic UDF in order to demonstrate my point.
CREATE OR REPLACE FUNCTION f_get(p_empno in INT)
SELECT COUNT(*) INTO n1 FROM EMP WHERE EMPNO = p_empno;
IF n1 = 0 THEN
;Note that the EMP table is a tiny table, occupying just 1 block.
Here is the query in question:
--Before running the query, I have set the track_functions parameter to 'all' to enable tracking of function call counts and time used.
set track_functions to 'all';
WHERE F_GET(EMPNO) = 'Y';And here is the execution plan:
Aggregate (actual time=0.134..0.135 rows=1 loops=1)
Buffers: shared hit=15
-> Seq Scan on scott.emp (actual time=0.038..0.130 rows=14 loops=1)
Output: empno, ename, job, mgr, hiredate, sal, comm, deptno
Filter: ((f_get(emp.empno))::text = 'Y'::text)
Buffers: shared hit=15
Query Identifier: 1659856310246027018
Planning Time: 0.040 ms
Execution Time: 0.150 msselect * from pg_stat_user_functions;
26140|scott |f_get | 14| 0.13| 0.13|
The following is a brief interpretation of the plan:
1) The Aggregate node calles the Seq Scan node
2) The Seq Scan node performs a sequential scan on the EMP table.
The "rows=14" indicates that this scan retrieves 14 rows from the EMP table.
We can deduce that the UDF f_get is executed 14 times because 14 EMPNOs are supposed to be passed as input parameters.
3) The Seq Scan node passes the 14 rows up to the Aggregate node.
The Aggregate node does the aggregation operation on the COMM column, outputting the value 4.
Can you spot any inefficiencies in the plan?
The pain point in the plan is that the f_get funtion is being executed 14 times, resulting in 14 block I/Os. Addtionally, the sequential scan on the EMP table incurs one buffer block I/O. Therefore, the total number of block I/Os is 15.
So how will we be able to decrease the block I/O?
If you know the answer, I dare to say, you are an intermediate level developer.
The answer is to reduce the number of the UDF calls by adding a predicate in the WHERE clause. Here I have introduced an extra predicate COMM IS NOT NULL, since we are counting non-null entries of the COMM column.
Before executing the modified query, I have reset the statistics for the f_get function to zero.
The modified query is as follows:
WHERE F_GET(EMPNO) = 'Y'
AND COMM IS NOT NULL;The new execution plan is:
Aggregate (actual time=0.066..0.066 rows=1 loops=1)
Buffers: shared hit=5
-> Seq Scan on scott.emp (actual time=0.038..0.064 rows=4 loops=1)
Output: empno, ename, job, mgr, hiredate, sal, comm, deptno Filter
: ((emp.comm IS NOT NULL
) AND ((f_get(emp.empno))::text = 'Y'::text))
Rows Removed by Filter: 10
Buffers: shared hit=5
Query Identifier: 7377752709162974183
Planning Time: 0.044 ms
Execution Time: 0.081 msselect * from pg_stat_user_functions;
|26,140|scott |f_get |4 |0.217 |0.217 |
As ever you have to look at the Filter operation. PostgreSQL called the function when the COMM column is not null. The f_get function was executed only 4 times.
As you can see, the shared hit has decreased from 15 to 5 (mainly because the number of function calls has dropped from 14 to 4), and the run time has dropped from 0.150 ms to 0.081 ms.
In essence, while a UDF is an immensely powerful tool in a database, it requires careful usage to ensure it remains a help, not a hindrance. It is important to reduce the number of function calls. One way to achieve this is by adding a not-null predicate for nullable columns. The more information you give to PostgreSQL, the more efficient your queries are going to be.
If you perform the same test on Oracle 19c, Oracle automatically adds the not-null predicate when the COMM column is nullable. You need to take a hands on approach in PostgreSQL.