Re: Severe performance degradation with concurrent updates due to excessive EvalPlanQual (EPQ) re‑evaluation - Mailing list pgsql-hackers
| From | Osama Abdul Qader |
|---|---|
| Subject | Re: Severe performance degradation with concurrent updates due to excessive EvalPlanQual (EPQ) re‑evaluation |
| Date | |
| Msg-id | CAC+8b5jYit8c6kk+uHB+ou4a77i8AEpEx0j_-mLpt41pbMYYyw@mail.gmail.com Whole thread |
| In response to | Re: Severe performance degradation with concurrent updates due to excessive EvalPlanQual (EPQ) re‑evaluation (Osama Abdul Qader <osamaabdulqader.cs@gmail.com>) |
| List | pgsql-hackers |
Hi Wei,
I traced the concurrent UPDATE case into PostgreSQL's EvalPlanQual (EPQ) execution using GDB.
For the reproduced case:
UPDATE bond_deal_detail_sw
SET deal_price = 26915
WHERE deal_no IN ( SELECT deal_no FROM bond_deal_detail ORDER BY deal_no LIMIT 100000
);
After the concurrent update was committed, execution entered:
ExecUpdate() → EvalPlanQual() → EvalPlanQualNext() → ExecProcNode(epqstate->recheckplanstate).
The important finding is that the EPQ executor tree is the full planned tree:
EPQ
└── MergeJoinState ├── SortState │ └── SeqScanState │ └── bond_deal_detail_sw │ └── SubqueryScanState └── LimitState └── SortState └── SeqScanState └── bond_deal_detail
This matches the original EXPLAIN plan, whose top-level node is a Merge Semi Join.
At the actual EvalPlanQualNext() runtime state I observed:
recheckplanstate = T_MergeJoinState
lefttree = T_SortState
righttree = T_SubqueryScanState
root chgParam = NULL
For the right side:
SubqueryScanState -> T_LimitState -> T_SortState -> T_SeqScanState
The LimitState and its child SortState also had chgParam = NULL at this point.
So we now have concrete evidence that EPQ constructs and executes a recheck plan corresponding to the original UPDATE's plan, rather than reducing the recheck to just the target-table scan.
I have stopped the GDB investigation here rather than stepping through the entire executor. My next step is to inspect the relevant EPQ/executor code and see whether there is a safe optimization opportunity, while preserving the required READ COMMITTED recheck semantics.
I wanted to share these findings before making any code changes.
Best,
Osama
Hi Wei,Thanks for the clarification.I understand the distinction now. The behavior we were investigating is fundamentally tied to the EPQ recheck required by Read Committed, rather than the particular join strategy chosen by the planner. Since the same behavior occurs with Nested Loop after disabling Hash Join and Merge Join, continuing to trace the physical join selection would indeed be separate from the original issue.I'll stop pursuing the planner path as part of the EPQ investigation.The `ANY_SUBLINK` → `JOIN_SEMI` behavior I observed is interesting as a separate planner investigation, so I'll keep the findings documented separately rather than treating them as a solution to the original issue.Thanks again for the guidance, and no worries about the earlier suggestions. They helped me understand the problem and led me to investigate the planner behavior more closely.Best regards,OsamaOn Fri, 25 Sept, 2026, 2:07 pm Wei Sun, <936739278@qq.com> wrote:Hi again,
>One useful distinction I confirmed is thatconvert_ANY_sublink_to_join()itself creates the logicalJOIN_SEMI; it does not directly select the physical Hash Join shown byEXPLAIN. The >physical join implementation is selected later in planning.>My next step would be to trace the resulting
JOIN_SEMIfurther into the join-planning path and identify where the physical Hash Join is selected.>Sharing this as an investigation update in case there are particular planner functions you would recommend tracing next.
I think continuing to analyze this issue from the planner may deviate from our original intention,even if parameters enable_hashjoin and enable_mergejoin are turned off, the same problem will still occur when using Nestloop.It is necessary to determine whether a modified tuple still meets the filtering condition,and under this premise, reducing the cost of evaluation may be a feasible solution.But I did not come up with an effective solution to reduce costs. Instead,the cost of modifying SQL was much lower, such as adding a judgment in the filtering criteria thatthe current value is not equal to the value to be updated.At first, I thought this was a scenario of performance degradation,but later I realized that it was a necessary condition to ensure the isolation level of read committed.Therefore, only for the scenario I proposed, I think it may not be a scenario worth optimizing from the kernel.I'm sorry for providing you with impractical ideas in the early stages.Regarding the phenomenon you discovered in the planner,I think it may be discussed as a separate scenario.Best regards,Wei Sun原始邮件
发件时间:2026年9月25日 11:05主题:Re: Severe performance degradation with concurrent updates due to excessive EvalPlanQual (EPQ) re‑evaluationHi again,
I continued investigating the planner path for an
ANYSubLink and traced it with GDB.For the following query:
EXPLAIN (VERBOSE, COSTS) SELECT * FROM pg_class c WHERE c.oid = ANY ( SELECT oid FROM pg_namespace );the resulting plan is a Hash Join with:
Hash Cond: (c.oid = pg_namespace.oid)I placed a breakpoint in
convert_ANY_sublink_to_join()and confirmed that the query reaches this function through:standard_planner -> subquery_planner -> pull_up_sublinks -> pull_up_sublinks_jointree_recurse -> pull_up_sublinks_qual_recurse -> convert_ANY_sublink_to_joinAt the breakpoint:
sublink->subLinkType = ANY_SUBLINK under_not = falseThe function constructs a
JoinExprwith:jointype = JOIN_SEMIThe generated join has the pulled-up subquery as its
rarg, while the comparison is represented by anOpExpr.I also checked the operator OID from the generated
OpExpr:opno = 607and confirmed through
pg_operatorthat it is:607 | = | oid | oidSo the transformation at this stage is effectively:
c.oid = ANY (SELECT oid FROM pg_namespace) ↓ JOIN_SEMI condition: c.oid = pg_namespace.oidOne useful distinction I confirmed is that
convert_ANY_sublink_to_join()itself creates the logicalJOIN_SEMI; it does not directly select the physical Hash Join shown byEXPLAIN. The physical join implementation is selected later in planning.My next step would be to trace the resulting
JOIN_SEMIfurther into the join-planning path and identify where the physical Hash Join is selected.Sharing this as an investigation update in case there are particular planner functions you would recommend tracing next.
Regards,
OsamaOn Fri, Sep 25, 2026 at 6:53 AM Osama Abdul Qader <osamaabdulqader.cs@gmail.com> wrote:Hi Andres and WeiThanks for the additional context. That makes the use case much cleaner.I understand that the original optimization of skipping the EPQ recheck for constant assignments is not valid, and I’ll consider that direction closed.
I’ll continue investigating the implementation side instead: specifically, how expensive SubPlans are re-executed during repeated EPQ rechecks, whether they depend on the EPQ tuple, and whether any independent work can be safely reused while preserving the required Read Committed semantics.
I’ll also keep the broader batch-update use case in mind rather than optimizing only for the current reproducer.
Regards
Osama Abdul Qader.On Tue, Sep 22, 2026 at 7:45 AM Wei Sun <936739278@qq.com> wrote:Hi>This is obviously an intentionally bad query. Before we add even one more iota
>of additional complexity to EPQ, we need a *lot* more convincing use cases
>than this. What is the real scenario in which you are updating huge numbers
>of rows that also have been updated in another transaction, with a subquery
>not implemented as a join?The background of the issue is a business function in which developers use anUPDATE statement containing complex subqueries to perform batch data updates,and the performance of these subqueries is poor. This feature may be called concurrently,causing two updates to update the same part of the data, ultimately resulting in the scenarioI described.This problem can indeed be solved by rewriting SQL, I'm just not sure if there'sa better way to implement EPQ in this scenario.>I don't think that's correct in this case. The other session could very well
>have updated deal_no to not match
> deal_no IN (SELECT deal_no FROM bond_deal_detail ORDER BY deal_no LIMIT 100000)
>anymore.Your judgment was correct, and I later became aware of this issue and rejected this idea in subsequent emails.
Regards,Wei Sun原始邮件
发件时间:2026年9月22日 02:58主题:Re: Severe performance degradation with concurrent updates due to excessive EvalPlanQual (EPQ) re‑evaluationHi,
On 2026-09-15 16:05:46 +0800, Wei Sun wrote:
> I encountered a serious performance regression when running concurrent
> UPDATE statements targeting the same set of rows on PostgreSQL 18.1.
> The second update session runs extremely slow due to excessive
> EvalPlanQual (EPQ) re‑evaluation logic.
> [...]
> ## Test setup
> Create test table and populate 1000000 rows of mock bond trading data,
> no user‑defined indexes (only identity primary key on `id`).
> Then create a copy table `bond_deal_detail_sw` for concurrent update tests.
> This issue occurs when read committed isolation level.
> Session 1:
> EXPLAIN ANALYZE UPDATE bond_deal_detail_sw
> SET deal_price = 26915
> WHERE deal_no IN (SELECT deal_no FROM bond_deal_detail ORDER BY deal_no LIMIT 100000);
This is obviously an intentionally bad query. Before we add even one more iota
of additional complexity to EPQ, we need a *lot* more convincing use cases
than this. What is the real scenario in which you are updating huge numbers
of rows that also have been updated in another transaction, with a subquery
not implemented as a join?
> since the target update value is a constant and does not reference any column of the updated table,
> logically there is no need to recompute the target new value via EPQ for
> these rows.
I don't think that's correct in this case. The other session could very well
have updated deal_no to not match
deal_no IN (SELECT deal_no FROM bond_deal_detail ORDER BY deal_no LIMIT 100000)
anymore.
Greetings,
Andres Freund
pgsql-hackers by date:
Previous
From: ManuDate:
Subject: Re: ATTACH PARTITION cost grows linearly with pg_constraint size (seqscan in CloneFkReferenced), much worse since not-null constraints are in pg_constraint (PG 18)
Next
From: Amit KapilaDate:
Subject: Re: Proposal: Conflict log history table for Logical Replication