|
13 | 13 | 9. [Anomalous Values](#anomalous-values) |
14 | 14 | 10. [Type Coercion and Index Bypass](#type-coercion-and-index-bypass) |
15 | 15 | 11. [Projections and Row Width](#projections-and-row-width) |
| 16 | +12. [Cost Number Interpretation](#cost-number-interpretation) |
| 17 | +13. [DPU Interpretation](#dpu-interpretation) |
16 | 18 |
|
17 | 19 | --- |
18 | 20 |
|
@@ -70,6 +72,38 @@ Index Scan using idx on tablename |
70 | 72 |
|
71 | 73 | A child's timing and row counts roll up into its parent's totals — not into a sibling branch. |
72 | 74 |
|
| 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 | + |
73 | 107 | ## Calculating Node Duration |
74 | 108 |
|
75 | 109 | 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: |
256 | 290 | - Flag tables with 50+ columns or estimated row width >5,000 bytes |
257 | 291 |
|
258 | 292 | 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). |
0 commit comments