Diff v2 → v3

v2: bot legacy · 2026-07-23T07:50:25Z
v3: bot legacy · 2026-07-23T07:54:48Z
  # Push Compute to the Datastore
  
  > **When to use it:** designing or reviewing a backend query, endpoint, or service
  > and deciding **where a computation should live** — app code, Postgres, or Redis.
  > Reach for it to kill an N+1 or an app-side loop, fix slow (OFFSET) pagination, add
  > aggregation / search / ranking / queues / rate-limiting / caching, or push
  > integrity down where it can't be bypassed. Keyword bait: "offload to the database",
  > "thin backend", "N+1", "keyset pagination", "window function", "SKIP LOCKED",
  > "rate limit", "leaderboard", "cache-aside", "materialized view".
  
  The one-line model: **the application layer orchestrates business *policy*; the
  datastore does the *data work*.** Filtering, sorting, joining, aggregating, ranking,
  de-duping, paginating, uniqueness, integrity, top-N, tree-walks are set operations the
  database already does in one indexed pass. Every `SELECT *` followed by an app-side loop
  re-implements — slowly, over the wire — what the engine does natively.
  
  But **climb the ladder; don't leap to the bottom.** "Offload everything to Redis" is as
  wrong as "loop in app code." The win is putting each computation on the *lowest rung that
  already solves it*. Postgres is already in your stack and is transactional and durable —
  reach for it first; add Redis only for what Postgres can't do cheaply. And measure the
  access pattern before optimizing it: offloading a query you haven't `EXPLAIN`ed is
  guessing. This is the "prefer the platform primitive over hand-rolled app code" instinct[^ponytail]
  applied to the backend.
  
  ## The ladder — climb until a rung solves it
  
  ```
  0. Understand + measure the access pattern      (EXPLAIN first, don't guess)
  1. Integrity           → DB constraints          (UNIQUE, FK, CHECK, NOT NULL, DEFAULT)
  2. Find / sort / page  → indexes                 (composite, covering, partial, expression; KEYSET not OFFSET)
  3. Shape / compute     → set-based SQL           (aggregates+FILTER, window fns, DISTINCT ON, LATERAL, CTEs, ON CONFLICT…RETURNING, SKIP LOCKED)
  4. Specialized engines → DB-native features      (FTS, pg_trgm, pgvector, PostGIS, materialized views, advisory locks)
  5. Ephemeral / cross-process / hot-path → Redis  (counters, rate-limits, leaderboards, cache, HLL, streams, locks)
  6. Branchy policy / CPU-heavy / must-be-testable → app code   (last resort — keep it thin)
  7. Outgrown Postgres+Redis (measured) → a specialized store   (search / OLAP / stream / graph / vector — last, and rederivable)
  ```
  
  ## 1. Integrity → constraints, not app checks
  
  An invariant enforced in app code holds only until a second writer (a migration, a job,
  another service, a `psql` session) bypasses it. A constraint holds always.[^ponytail]
  
  ```sql
  email        text    NOT NULL UNIQUE,
  price_cents  int     NOT NULL CHECK (price_cents > 0),
  author_id    bigint  NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  id           uuid    DEFAULT gen_random_uuid(),
  created_at   timestamptz NOT NULL DEFAULT now()
  ```
  
  **Do** push uniqueness / FK / range / defaults into the schema. **Not** an
  `if exists?(email)` check before insert — it races, and the DB checks anyway. Pair
  `UNIQUE` with `INSERT … ON CONFLICT` (§3) so the app never round-trips to "check first".
  
  ## 2. Find / sort / paginate → the *right* indexes
  
  An index is the database's ability to *not* scan — but "it has an index" is not enough.[^luke]
  
  - **Composite column order: equality columns first, then the one range/sort column.**
    `WHERE tenant_id = ? AND created_at > ?` wants `(tenant_id, created_at)`. Reversed,
    `tenant_id` degrades from an *access predicate* (narrows the scan) to a *filter
    predicate* (scans, then discards). Everything after the first range/inequality column
    loses its narrowing power.[^luke]
  - **Never wrap an indexed column in a function** — `WHERE lower(email) = ?` can't use an
    index on `email`. Use an **expression index** `ON t (lower(email))` or a stored
    generated column.[^luke]
  - **Covering / index-only scans:** put read-only columns in the index so it never touches
    the heap — `CREATE INDEX … ON sales (subsidiary_id) INCLUDE (eur_value)`.[^luke]
  - **Partial indexes for hot subsets:** a queue table grows forever but you query only the
    unprocessed slice — `CREATE INDEX … ON jobs (created_at) WHERE status = 'pending'` stays
    small no matter how many `done` rows accumulate.[^luke]
  - **`ORDER BY` served by the index** (matching columns *and* direction) skips the sort step
    entirely — the biggest win with `LIMIT`.[^luke]
  
  ### 2b. Pagination: KEYSET (seek), never OFFSET at depth
  
  `LIMIT n OFFSET k` is defined as "produce the sorted result, then fetch and **discard** k
  rows." It is **O(k)** — page 1000 walks 10,000 rows to throw them away — *and* **incorrect**
  under concurrent inserts (a row added above the window shifts everything, so you skip or
  duplicate). Deep pagination that slows every page is this bug.[^luke]
  
  Keyset pagination passes the **last row's sort key** and asks for "rows after this",
  turning pagination into an indexed access predicate:
  
  ```sql
  -- index: (created_at DESC, id DESC)   ← unique tiebreaker (id) is mandatory for stable order
  -- first page:
  SELECT * FROM articles ORDER BY created_at DESC, id DESC FETCH FIRST 20 ROWS ONLY;
  -- next page: pass (created_at, id) of the LAST row shown
  SELECT * FROM articles
   WHERE (created_at, id) < (:last_created_at, :last_id)   -- row-value comparison = true access predicate
   ORDER BY created_at DESC, id DESC
   FETCH FIRST 20 ROWS ONLY;
  ```
  
  Trade-off to accept, not fight: you get next/prev, not "jump to page 47" — a bad UI anyway,
  and the wrong tool for infinite scroll / cursored APIs. (This is exactly why a cursored REST
  API encodes an opaque `sort_key:id` cursor instead of a page number.)
  
  ## 3. Shape / compute → one set-based query, not an app loop
  
  This rung kills the most app code. Each replaces "fetch rows, loop, tally":[^pg]
  
  | App-code impulse | Do this in SQL instead |
  |---|---|
  | Loop to bucket/sum by status; a 2nd query per bucket | `count(*) FILTER (WHERE status='refunded')`, `sum(x) FILTER (…)` in one pass |
  | Subtotals + grand total via 3 queries + UNION | `GROUP BY ROLLUP (a,b)` / `GROUPING SETS` / `CUBE` |
  | Sort rows in app for running total / rank / "vs previous row" | window fns: `sum(…) OVER (… ROWS UNBOUNDED PRECEDING)`, `rank() OVER(PARTITION BY …)`, `lag()` |
  | "Latest row per entity": pull all, keep first-seen per key | `SELECT DISTINCT ON (device_id) … ORDER BY device_id, recorded_at DESC` |
  | Top-3 per group (N+1 per parent) | `… CROSS JOIN LATERAL (SELECT … WHERE o.customer_id=c.id ORDER BY … LIMIT 3) o` |
  | Walk a tree/graph with a query per level (N+1) | `WITH RECURSIVE t AS (… UNION ALL …) SELECT * FROM t` |
  | SELECT-then-INSERT-or-UPDATE (races) + re-SELECT for the id | `INSERT … ON CONFLICT (k) DO UPDATE SET … RETURNING *` (atomic, one round trip) |
  | `for row in rows: UPDATE …` (N round trips) | set-based `UPDATE t SET … FROM other WHERE …` / `DELETE … USING …` |
  | A message broker just to hand one job to one worker | `SELECT id FROM jobs WHERE status='pending' ORDER BY … FOR UPDATE SKIP LOCKED LIMIT 1` then `UPDATE … RETURNING` |
  | Deserialize a JSON blob in app to filter/pluck a field | `WHERE payload @> '{"type":"signup"}'` with a `GIN` index; `payload->'user'->>'email'` |
  
  `RETURNING` on every write avoids the read-after-write round trip. CTE note (PG12+): a
  non-recursive CTE used once is inlined; add `AS MATERIALIZED` only to force the old
  optimization-fence behavior.[^pg]
  
  ## 4. Specialized engines → reach here before adding a new service
  
  Postgres has these built in; don't stand up Elasticsearch / a vector DB / a broker for
  moderate scale you haven't outgrown:[^pg]
  
  - **Full-text search:** a `tsvector` generated column + `GIN` index + `ts_rank`. Replaces
    `ILIKE '%…%'` scans and premature Elasticsearch.
  - **Fuzzy / typo-tolerant / substring:** `pg_trgm` (`gin_trgm_ops`, `similarity()`, `%`). Also
    the answer to "`LIKE '%foo'` can't use an index" — a leading wildcard has no access
    predicate; trigram indexing does.[^luke]
  - **Vector similarity (embeddings / RAG):** `pgvector` with an HNSW index (`<=>` cosine).
  - **Geo radius / proximity:** `PostGIS` (`ST_DWithin`) — not Haversine in a loop.
  - **Precomputed reports:** `MATERIALIZED VIEW` + `REFRESH MATERIALIZED VIEW CONCURRENTLY`
    (needs a `UNIQUE` index on the view). Replaces a cron job hand-writing a rollup table.
  - **Cross-process mutex without new infra:** `pg_advisory_xact_lock(hashtext('daily-report'))`.
  
  ## 4b. Match the storage model to the access pattern (row vs columnar)
  
  Postgres core is a **row store** (heap tables, MVCC): one page fetch returns every column
  of a row — ideal for OLTP point/range reads and high-churn single-row writes (helped by
  HOT updates + `fillfactor`; oversized values go out-of-line via TOAST; keep autovacuum
  healthy or dead-tuple bloat bites). But it's wrong for **analytics**: a `SELECT category,
  sum(amount) … GROUP BY category` over 50M rows still reads whole rows/pages to touch 2 of
  40 columns, evicting the OLTP working set from cache.[^storage]
  
  **Rule: "one row, all columns" → row store. "all rows, few columns, aggregated" →
  columnar.** Columnar stores each column contiguously — scans only the columns touched and
  compresses 3–10×.
  
  - **Columnar inside Postgres (maintained, 2026):** **Citus columnar** (`CREATE TABLE …
    USING columnar`, works single-node — but append-only: no `UPDATE`/`DELETE`);
    **TimescaleDB hypercore** (time-series: recent chunks stay row-store, old chunks compress
    to columnstore, + continuous aggregates); **pg_duckdb / pg_mooncake** (DuckDB's vectorized
    engine / Iceberg lakehouse for OLAP). Avoid the dead ends — `cstore_fdw`, ParadeDB
    `pg_analytics`, and Hydra are unmaintained.[^storage]
  - **BRIN index** for huge, naturally-ordered append-only tables (time-series): stores
    min/max per block range — megabytes where a B-tree is gigabytes. Useless on a column
    uncorrelated with physical order (UUIDs, randomly-updated status) — that's a B-tree's job.
  - **Partitioning** (range/list/hash) prunes whole partitions on the partition key (don't
    wrap it in a function, that defeats pruning) and makes retention cheap (`DROP TABLE` a
    partition, not a bulk `DELETE`). Composes with the above: partition by month → BRIN on the
    timestamp → compress cold partitions to columnar.
  
  **Route analytics off the OLTP primary.** A big aggregation steals shared buffer cache + CPU
  from transactional queries — the classic "random latency spike." In rising order of effort:
  **materialized view / continuous aggregate** (known recurring shape, staleness ok) →
  **columnar table/extension** (ad hoc aggregates over one append-mostly fact table) → **read
  replica** (live-ish full SQL without touching the primary's cache) → **dedicated warehouse**
  (ClickHouse / DuckDB / BigQuery / Snowflake) at org-scale BI.[^storage]
  
  ## 5. Redis → only for what Postgres can't do cheaply
  
  Redis is an **accelerant, never the system of record.** Reach for it when you need
  *ephemeral, cross-process state on the hot path* at a rate that would hammer Postgres:[^redis]
  
  | Need | Redis |
  |---|---|
  | Cross-instance counter / fixed-window rate limit | `INCR` + `EXPIRE … NX` (make it atomic) |
  | Sliding-window rate limit | sorted set: `ZREMRANGEBYSCORE` + `ZADD` + `ZCARD`, wrapped atomically |
  | Burst-tolerant limit (production default) | token bucket in a **Lua script** (read-decide-write must be atomic or tokens double-spend) |
  | Leaderboard / rank / top-N | sorted set: `ZADD`, `ZINCRBY`, `ZREVRANGE`, `ZREVRANK` (ordering is free) |
  | Priority queue across workers | sorted set + `BZPOPMIN` (atomic blocking pop) |
  | Unique visitors / huge cardinality | HyperLogLog `PFADD`/`PFCOUNT` — ~12 KB fixed, 0.81% error, vs a multi-GB `SET` |
  | Per-user daily flags / DAU / retention | bitmaps `SETBIT`/`BITCOUNT`/`BITOP AND` — 1 bit per user |
  | Event log / lightweight queue with acks & replay | Streams `XADD`/`XREADGROUP`/`XACK` + `XPENDING`/`XCLAIM` (at-least-once) |
  | Fire-and-forget broadcast | Pub/Sub (at-most-once; offline subscribers miss it — use Streams if that's not ok) |
  | Read cache in front of Postgres | cache-aside (see below) |
  
  **Redis rules that are easy to get fatally wrong:**[^redis]
  
  - **Multi-step = not atomic.** `ZADD` then `ZCARD` then decide is a race — two requests
    both pass the check. Wrap read-modify-write in a **Lua script** (`EVAL`; it blocks the
    server, so keep it tiny) or `MULTI/EXEC`. Token buckets and check-and-set *must* be Lua.
  - **Cache-aside:** read miss → load from Postgres → `SET … EX ttl`. On write, **`DEL` the
    key — do not `SET` it** (a `SET` races with an in-flight stale read that overwrites your
    fresh value). Always set a TTL even with explicit invalidation (TTL is the backstop). Add
    **jitter** so keys don't mass-expire, and guard recompute with a `SET NX PX` **mutex** so
    a popular expired key doesn't stampede the DB.
  - **Distributed lock:** `SET key <random-token> NX PX 30000` to acquire; release with a
    **Lua compare-token-then-DEL**, never a bare `DEL`. Redlock has real caveats (clock drift,
    GC-pause stalls — read Kleppmann vs antirez): fine as an *advisory* lock, but for
    correctness-critical exclusion add **fencing tokens** or use real consensus. Postgres
    advisory locks (§4) are often the simpler right answer.
  - **Not durable by default.** RDB loses seconds–minutes on crash; `MULTI/EXEC` has **no
    rollback**; memory is finite and eviction deletes data silently once `maxmemory` + an
    `lru/lfu/random` policy is set. Money, auth of record, and audit data live in Postgres;
    Redis holds a copy it can rebuild.
  
  ## 6. App code → the last rung, kept thin
  
  What genuinely belongs here: branchy **business policy** with many edge cases (pricing,
  eligibility, workflow) that needs unit tests and version-controlled diffs; **CPU-heavy**
  work (image/PDF/crypto/ML) that would steal CPU from the query workload; and anything a
  hard **portability** requirement forbids pushing into Postgres-specific SQL.[^pg] Rule of
  thumb: **offload set-based data operations; keep branchy policy and heavy compute in the app.**
  
  ## 7. Beyond Postgres/Redis → a specialized store, only when the pain is measured
  
  Don't add a datastore until you have **measured, current pain on a specific access pattern** —
  every new store is another backup/monitor/on-call/consistency surface and a second source of
  truth (a bug generator). There's almost always a cheaper step *inside* Postgres/Redis first, and
  you should run any specialized store as a **secondary, always-rederivable-from-Postgres** index,
  never the system of record. Signals you've genuinely outgrown it: search →
  Meilisearch/Typesense/Elasticsearch; OLAP → ClickHouse/DuckDB/warehouse; streaming →
  Kafka/Redpanda/NATS; graph → Neo4j (be skeptical — recursive CTEs go far); vector at scale →
- a dedicated vector DB. Details + thresholds in [[Redis Internals, Probabilistic Structures and Datastore Selection]].
+ a dedicated vector DB. Details + thresholds in [[redis-deep-dive-and-datastore-selection|Redis Internals & Datastore Selection]].
  
  ## Deep dives
  
  This is the operational hub. Each area has a companion when you need the depth:
  
- - [[Postgres Full-Text and Fuzzy Search]] — tsvector/`websearch_to_tsquery`, ranking, GIN/GiST/RUM, pg_trgm, hybrid FTS+vector (RRF), when to leave for Elasticsearch.
- - [[Postgres Query Planning and EXPLAIN]] — reading plans, statistics/extended stats, `work_mem` spills, the prepared-statement generic-plan trap.
- - [[Postgres Concurrency and Throughput]] — MVCC/vacuum/bloat, locking & deadlocks, isolation anomalies, connection pooling, COPY/`unnest` bulk writes, idempotency.
- - [[Postgres Scale-Out, Replication and Data Movement]] — replicas & read-your-writes, Citus sharding, CDC/outbox, FDW, partition-detach archival, denormalization.
- - [[Redis Internals, Probabilistic Structures and Datastore Selection]] — memory/encoding, Bloom/Count-Min/Top-K/t-digest, Cluster, and the specialized-store decision matrix.
+ - [[postgres-full-text-search|Postgres Full-Text & Fuzzy Search]] — tsvector/`websearch_to_tsquery`, ranking, GIN/GiST/RUM, pg_trgm, hybrid FTS+vector (RRF), when to leave for Elasticsearch.
+ - [[postgres-query-planning|Postgres Query Planning & EXPLAIN]] — reading plans, statistics/extended stats, `work_mem` spills, the prepared-statement generic-plan trap.
+ - [[postgres-concurrency-and-throughput|Postgres Concurrency & Throughput]] — MVCC/vacuum/bloat, locking & deadlocks, isolation anomalies, connection pooling, COPY/`unnest` bulk writes, idempotency.
+ - [[postgres-scale-out|Postgres Scale-Out & Replication]] — replicas & read-your-writes, Citus sharding, CDC/outbox, FDW, partition-detach archival, denormalization.
+ - [[redis-deep-dive-and-datastore-selection|Redis Internals & Datastore Selection]] — memory/encoding, Bloom/Count-Min/Top-K/t-digest, Cluster, and the specialized-store decision matrix.
  
  ## The escape valve — mark a deliberate non-offload
  
  Premature offloading is real. When you consciously leave compute in the app (or accept an
  N+1 at current scale), leave a breadcrumb with a trigger for when to revisit:[^ponytail]
  
  ```python
  # thin-backend: N+1 accepted — <100 rows per request. Switch to a LATERAL join if this
  # endpoint serves lists >1k, or if p95 crosses 200ms.
  ```
  
  ## Verify — offloading only wins if the DB does it efficiently
  
  - `EXPLAIN (ANALYZE, BUFFERS) <query>` — watch for a seq scan where you expected an index,
    `Rows Removed by Filter` ≫ rows returned, high `shared read` (I/O-bound), and
    actual-vs-estimated row blowups (stale stats → `ANALYZE`).[^pg]
  - `pg_stat_statements` — find the queries that are actually expensive in production.
  - Index cost is real: every index slows writes (one index can make an INSERT ~100× slower).
    Index what you query, drop speculative indexes, don't over-index write-heavy tables.[^luke]
  
  ## See also
  
  - [[Scaling Rails: Vertical & Horizontal]] — "the real bottleneck is almost always the
    database"; this skill is how you relieve it.
  - [[Clean Architecture]] — keep the datastore-specific SQL behind a boundary so the thin
    app layer stays testable and the offloading doesn't leak into business policy.
  
  [^ponytail]: Ponytail (DietrichGebert) — "the best code is the code you never wrote";
      prefer platform/DB primitives (`UNIQUE`/`FOREIGN KEY`/`CHECK`, window functions,
      recursive CTEs) over app-level re-implementations. <https://github.com/DietrichGebert/ponytail>
  [^luke]: Markus Winand, *Use The Index, Luke!* — B-tree access vs filter predicates,
      composite-index ordering (equality before range), expression/covering/partial indexes,
      and the OFFSET-vs-keyset pagination chapter. <https://use-the-index-luke.com/>
  [^pg]: PostgreSQL 16/17 docs — aggregate `FILTER`, window functions, `DISTINCT ON`,
      `LATERAL`, recursive CTEs, `INSERT … ON CONFLICT … RETURNING`, `FOR UPDATE SKIP LOCKED`,
      generated columns, materialized views, `EXPLAIN`. <https://www.postgresql.org/docs/current/>
  [^redis]: Redis docs — sorted sets, HyperLogLog, bitmaps, Streams, Lua/`EVAL` atomicity,
      cache-aside, distributed locks (+ Kleppmann/antirez Redlock debate), persistence &
      eviction caveats. <https://redis.io/docs/latest/>
  [^storage]: PostgreSQL storage internals (heap/TOAST/HOT, BRIN, declarative partitioning)
      <https://www.postgresql.org/docs/current/storage.html>; columnar landscape verified
      2026 — Citus columnar (`USING columnar`), TimescaleDB hypercore, pg_duckdb/pg_mooncake
      are maintained; cstore_fdw, ParadeDB pg_analytics, and Hydra are discontinued.