Skip to content

Commit f98901d

Browse files
committed
feat(dsql): add blog learnings — filter model, DPU, CTE rewrite
- Add Three-Layer Filter Model (Index Cond / Storage Filter / Query Processor Filter) with optimization table to plan-interpretation.md - Add Fixing Storage Lookups guidance (INCLUDE columns) with example - Add Cost Number Interpretation (startup ~100 is normal in DSQL) - Add DPU Interpretation (Read DPU as primary signal, optimization loop) - Add CTE late materialization as DSQL-specific rewrite pattern (defer Storage Lookups past LIMIT) - Update workflow.md Phase 1: recommend plain EXPLAIN first for expensive queries before EXPLAIN ANALYZE VERBOSE
1 parent 1d39000 commit f98901d

4 files changed

Lines changed: 111 additions & 5 deletions

File tree

plugins/databases-on-aws/skills/dsql/references/query-plan/plan-interpretation.md

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
9. [Anomalous Values](#anomalous-values)
1414
10. [Type Coercion and Index Bypass](#type-coercion-and-index-bypass)
1515
11. [Projections and Row Width](#projections-and-row-width)
16+
12. [Cost Number Interpretation](#cost-number-interpretation)
17+
13. [DPU Interpretation](#dpu-interpretation)
1618

1719
---
1820

@@ -70,6 +72,38 @@ Index Scan using idx on tablename
7072

7173
A child's timing and row counts roll up into its parent's totals — not into a sibling branch.
7274

75+
### Three-Layer Filter Model
76+
77+
Every predicate is evaluated at one of three layers. The layer determines how much data crosses the network between storage and compute — the primary lever for DSQL optimization.
78+
79+
| Level | Filter Type | Where it appears in EXPLAIN | Data Movement | How to push predicates here |
80+
| ------------ | ---------------------- | ------------------------------------------------------------ | ------------------------------------------------------- | --------------------------------------------------------------------- |
81+
| 1 (best) | Index Condition | `Index Cond:` on scan node | Minimized — only matching index entries read | Equality/range on indexed key columns; most selective column leftmost |
82+
| 2 (moderate) | Storage Filter | `Filters:` inside `Storage Scan` or `Storage Lookup` node | Reduced — applied at storage before transfer | Add filter columns to index INCLUDE clause |
83+
| 3 (worst) | Query Processor Filter | `Filter:` above `Storage Scan` (at the scan-type node level) | Maximum — all data transferred before predicate applied | Requires new index, restructured query, or schema change |
84+
85+
**Optimization goal:** Move predicates from Level 3 → Level 2 → Level 1. Each step reduces network transfer between storage and compute, directly reducing latency and DPU.
86+
87+
### Fixing Storage Lookups (INCLUDE columns)
88+
89+
When a Storage Lookup node appears, the index satisfied the filter but not all projected columns. The fix: add missing columns to the index's INCLUDE clause.
90+
91+
```
92+
-- Before: Storage Lookup fetches created_at from base table
93+
Index Scan using idx1 on account
94+
-> Storage Scan on idx1
95+
-> Storage Lookup on account ← extra round trip
96+
Projections: created_at
97+
98+
-- Fix: CREATE INDEX ASYNC idx2 ON account (customer_id) INCLUDE (balance, status, created_at)
99+
-- After: Index Only Scan, no Storage Lookup
100+
Index Only Scan using idx2 on account
101+
-> Storage Scan on idx2
102+
Projections: customer_id, balance, status, created_at
103+
```
104+
105+
**Trade-off:** INCLUDE columns are copied into every index entry, increasing index size. Only include columns that your most-queried paths actually need.
106+
73107
## Calculating Node Duration
74108

75109
DSQL follows the standard PostgreSQL EXPLAIN convention: `actual time` is reported **per iteration**, not cumulative. The node's total wall-clock time is:
@@ -256,3 +290,36 @@ Assess row width overhead:
256290
- Flag tables with 50+ columns or estimated row width >5,000 bytes
257291

258292
Wide projections increase I/O on Storage Lookups and memory usage in Hash Joins. Impact scales with result set size.
293+
294+
## Cost Number Interpretation
295+
296+
DSQL cost numbers appear much higher than equivalent PostgreSQL plans. This is expected — the cost model accounts for distributed round-trips.
297+
298+
**Format:** `startup_cost..total_cost` (e.g., `100.28..208.29`)
299+
300+
- **Startup cost ~100** is normal — reflects fixed overhead of initiating a storage round-trip
301+
- **Total cost** includes incremental per-row processing, network transfer, and page access
302+
303+
**MUST NOT** compare cost numbers across queries to determine which is "better." Cost units are internal to the optimizer and non-comparable. Use DPU estimates instead.
304+
305+
## DPU Interpretation
306+
307+
`EXPLAIN ANALYZE VERBOSE` appends a `Statement DPU Estimate` block:
308+
309+
```
310+
Statement DPU Estimate:
311+
Compute: 0.01724 DPU
312+
Read: 0.01202 DPU
313+
Write: 0.00000 DPU
314+
Total: 0.02926 DPU
315+
```
316+
317+
**Read DPU** is the primary optimization signal for read-heavy queries. High Read DPU with selective filters means those filters aren't pushed down far enough (Level 3 or 2 when they could be Level 1).
318+
319+
**Optimization loop:**
320+
321+
1. Run `EXPLAIN ANALYZE VERBOSE` on the unoptimized query — note Total DPU
322+
2. Apply fix (add index, add INCLUDE columns, restructure query)
323+
3. Re-run — compare DPU delta
324+
325+
**MUST** use DPU as the before/after comparison metric, not cost numbers or execution time (which varies with load).

plugins/databases-on-aws/skills/dsql/references/query-plan/query-rewrites-dsql-specific.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@ SQL rewrites that address Aurora DSQL-specific behaviors and optimizer constrain
44

55
## Available Rewrites
66

7-
| Pattern Detected | Reference File |
8-
| ------------------------------- | ------------------------------------------------------------- |
9-
| COUNT(*) timeout on large table | [reltuples-estimate.md](query-rewrites/reltuples-estimate.md) |
10-
| Join count exceeds DP threshold | [split-large-joins.md](query-rewrites/split-large-joins.md) |
7+
| Pattern Detected | Reference File |
8+
| ------------------------------------------------- | ------------------------------------------------------------------------- |
9+
| COUNT(*) timeout on large table | [reltuples-estimate.md](query-rewrites/reltuples-estimate.md) |
10+
| Join count exceeds DP threshold | [split-large-joins.md](query-rewrites/split-large-joins.md) |
11+
| Storage Lookup with high loops + LIMIT discarding | [cte-late-materialization.md](query-rewrites/cte-late-materialization.md) |
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# Rewrite: CTE Late Materialization to Defer Storage Lookups (DSQL-Specific)
2+
3+
When a query combines filtering, ordering, and LIMIT with columns not fully covered by an index, DSQL performs a Storage Lookup for every matching row — including rows discarded by LIMIT. Use a CTE to narrow first using only indexed columns, then join back for remaining columns on only the final rows.
4+
5+
**SHOULD apply when:** The query has a LIMIT that returns far fewer rows than the filter matches, and the EXPLAIN plan shows a Storage Lookup with a high loop count relative to the final row count.
6+
7+
**SHOULD skip when:** The filter is already highly selective (matching close to the LIMIT count), or all projected columns are in the index.
8+
9+
```sql
10+
-- Before: Storage Lookup on every matching row, LIMIT discards most
11+
SELECT customer_id, balance, status, created_at
12+
FROM account
13+
WHERE status = 'active'
14+
ORDER BY created_at DESC
15+
LIMIT 10;
16+
17+
-- After: CTE narrows to 10 rows using indexed columns, then fetches remaining
18+
WITH candidates AS (
19+
SELECT customer_id, created_at
20+
FROM account
21+
WHERE status = 'active'
22+
ORDER BY created_at DESC
23+
LIMIT 10
24+
)
25+
SELECT a.customer_id, a.balance, a.status, a.created_at
26+
FROM candidates c
27+
JOIN account a ON a.customer_id = c.customer_id;
28+
```
29+
30+
```sql
31+
-- Not applicable: filter already selective (returns ~10 rows)
32+
SELECT customer_id, balance
33+
FROM account
34+
WHERE customer_id = '4b18a761-5870-4d7c-95ce-0a48eca3fceb'::uuid
35+
LIMIT 10;
36+
```

plugins/databases-on-aws/skills/dsql/references/query-plan/workflow.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,9 @@ SHOULD also load these index files to identify applicable rewrites at Phase 2:
7575

7676
## Phase 1: Capture the Plan
7777

78-
**ALWAYS** run `readonly_query("EXPLAIN ANALYZE VERBOSE …")` on the user's query verbatim (SELECT form) — **ALWAYS** capture a fresh plan from the cluster, even when the user describes the plan or reports an anomaly. **MAY** leverage `get_schema` or `information_schema` for schema sanity checks.
78+
For queries the user reports as expensive or slow (execution time >30s, high DPU, or timeout), start with plain `EXPLAIN` (without ANALYZE) to see the optimizer's plan without executing the query. Then run `EXPLAIN ANALYZE VERBOSE` to get actual row counts and DPU.
79+
80+
For all other queries, run `readonly_query("EXPLAIN ANALYZE VERBOSE …")` directly on the user's query verbatim (SELECT form) — **ALWAYS** capture a fresh plan from the cluster, even when the user describes the plan or reports an anomaly. **MAY** leverage `get_schema` or `information_schema` for schema sanity checks.
7981

8082
When EXPLAIN errors (`relation does not exist`, `column does not exist`), **MUST** report the error verbatim — **MUST NOT** invent DSQL-specific semantics (e.g., case sensitivity, identifier quoting) as the root cause.
8183

0 commit comments

Comments
 (0)