[PATCH] Improving index selection for logical replication apply with replica identity full

First seen: 2026-05-22 17:18:12+00:00 · Messages: 3 · Participants: 2

Latest Update

2026-06-01 · claude-opus-4-6

Incremental Update: Patch v2 Addressing Non-Unique Index Handling

Summary of New Activity

Two new messages advance the discussion with a concrete patch revision. Masahiko Sawada (a committer with deep logical replication expertise) provided a +1 with a specific technical suggestion, and the patch author (Ethan Mertz) responded with an updated patch within ~15 hours.

Key Technical Development

Sawada's observation: The "prefer fewer columns" heuristic that works well for unique indexes (fewer columns = less overhead for a guaranteed single-match lookup) does not generalize to non-unique indexes. For non-unique indexes, more keys could actually narrow the search space better — a 3-column non-unique index might filter down to fewer candidate rows than a 1-column non-unique index, because the additional key columns add selectivity.

Patch v2 changes:

  1. The new heuristic (prefer unique, prefer fewer columns among unique) now applies only to unique indexes
  2. For non-unique indexes, the existing behavior (OID-ordered first-match) is preserved — acknowledging that without planner/statistics involvement, there's no reliable way to rank non-unique indexes
  3. A comment is added explaining this design decision and the reasoning

Future Direction Floated

Ethan Mertz suggests a potential future optimization: allowing users to selectively invoke the planner for logical apply processes, possibly via a new subscription-level configuration option. This acknowledges the fundamental tension in the design — the planner has the information needed to rank non-unique indexes (via pg_statistic), but its per-invocation cost is prohibitive for row-by-row apply. A user-controlled opt-in could let users who know they have problematic non-unique index scenarios pay the planning cost deliberately.

History (1 prior analysis)
2026-05-25 · claude-opus-4-6

Technical Analysis: Improving Index Selection for Logical Replication Apply with Replica Identity Full

Core Problem

When PostgreSQL performs logical replication with REPLICA IDENTITY FULL, UPDATE and DELETE operations on the subscriber must locate the target tuple to modify. Since PG14 (commits from the referenced threads), the apply worker can use existing indexes rather than always falling back to a sequential scan. However, the index selection algorithm is naive: it iterates through RelationGetIndexList() which returns indexes ordered by OID, and picks the first "suitable" index it finds.

This OID-ordering heuristic is essentially arbitrary from a performance perspective. If a low-cardinality index (e.g., on a boolean column) happens to have a lower OID than a high-cardinality unique index (e.g., on a UUID primary key), the apply worker will use the low-cardinality index. This results in index scans that return potentially hundreds of thousands of rows that must then be sequentially compared — performing no better (and possibly worse) than a sequential scan.

Why This Matters Architecturally

Logical Replication Apply Path

The logical replication apply worker (src/backend/replication/logical/worker.c) processes each change row-by-row. For each UPDATE/DELETE with REPLICA IDENTITY FULL:

  1. The full old tuple image is received from the publisher
  2. The subscriber must find the matching tuple in its local relation
  3. The code in RelationFindReplTupleByIndex() or RelationFindReplTupleSeq() performs the lookup
  4. The index selection happens in FindUsableIndexForReplicaIdentityFull() (or similar function depending on version)

The key issue is that this lookup happens per-row in a serial fashion. Unlike query execution where the planner can amortize planning cost over potentially millions of rows, each replicated row pays the cost of whatever index path is chosen. A 53-second vs. sub-1-second difference for 1,000 operations demonstrates that bad index selection causes O(n) scanning per row operation, turning what should be O(log n) lookups into O(n) or O(n * k) where k is the number of matching rows in the low-cardinality index.

Why REPLICA IDENTITY FULL Exists

Users sometimes cannot use REPLICA IDENTITY DEFAULT (primary key) or REPLICA IDENTITY USING INDEX because:

  • Downstream logical consumers (Debezium, custom CDC pipelines) require full before-images
  • Schema designs may not have stable unique identifiers suitable for replica identity
  • Multi-target replication where different subscribers have different schema constraints

Proposed Solution

The patch introduces a smarter heuristic for index selection that avoids invoking the full query planner (which would be too expensive per-row) while still making significantly better choices:

Selection Priority:

  1. Prefer unique indexes — A unique index guarantees at most one tuple match per lookup, eliminating the cardinality problem entirely. This transforms the lookup from "scan potentially thousands of matches" to "scan exactly 0 or 1 match."
  2. Among unique indexes, prefer fewer columns — Fewer columns means a narrower index with less comparison overhead and potentially better cache behavior. A single-column unique index on a UUID is the ideal case.

Design Tradeoffs

  • Not invoking the planner: Previous discussions (referenced threads) explicitly avoided calling the planner for index selection in this path. The planner requires statistics, catalog lookups, and significant per-invocation cost. For row-by-row apply, this overhead would be prohibitive.
  • Heuristic vs. optimal: The proposed heuristic is not globally optimal — a non-unique index with very high cardinality and good statistics might occasionally outperform a unique index with many columns. However, the unique guarantee provides a hard upper bound (1 tuple) that no statistical estimate on a non-unique index can match.
  • No statistics consultation: The patch deliberately avoids reading pg_statistic to assess index cardinality, keeping the selection logic simple and avoiding catalog access overhead during apply.

Correctness Considerations

The change is purely a performance optimization — any usable index will produce correct results since the apply worker already performs full tuple comparison after index lookup. The only question is efficiency of finding candidates.

Performance Impact

The benchmark demonstrates the pathological case:

  • Table: 1M rows with a boolean column (cardinality ~2, so ~500K rows per value) and a UUID column (cardinality = 1M)
  • The boolean index scans ~500K rows per lookup to find 1 match
  • The UUID unique index scans exactly 1 row per lookup
  • 1,000 operations × 500K row scans = catastrophic performance (53s)
  • 1,000 operations × 1 row scan = optimal performance (<1s)

This is a 53x improvement in the pathological case, with no regression in cases where the unique index would already be selected (i.e., when it has the lowest OID).

Relationship to Prior Work

The referenced threads [1] and [2] established the foundation for using indexes at all with REPLICA IDENTITY FULL (PG14). This patch is a natural evolution — once index usage was enabled, the question of which index to use when multiple are available becomes the next optimization target.