Skip to content
DevRecall
← Roadmap
32

Scenarios: Pagination, Export & Large Data

drafted

Mock interview tình huống production với dữ liệu lớn — pagination chậm ở trang sau, export/import hàng triệu row, xoá 10M row, backfill, query tự nhiên chậm, index vẫn chậm, 2 database, reporting trên OLTP, soft delete, partition & archive.

0/60 rated · mastery 0%

Bài học

6 bài · ~93 phút đọc. Học theo thứ tự, rồi làm câu hỏi.
  1. 01~17 phút· 12 câuverify

    Playbook pagination: OFFSET, keyset, cursor và total count

    Vì sao trang sâu chậm và scroll bị trùng/sót row, keyset với tie-breaker unique, cursor opaque có HMAC và filter hash, mixed direction, NULL, Relay connection, jump-to-page, total count, Elasticsearch deep paging và N+1 trong list endpoint. Có số đo thật trên 5M row Postgres 18.

  2. 02~16 phút· 12 câuverify

    Playbook export triệu row: async job, stream, COPY và Excel

    Từ 504/OOMKilled tới export platform: async job, stream có backpressure, cursor vs keyset vs COPY, snapshot nhất quán, Excel, CSV injection, resumable và per-tenant concurrency. Có số đo thật trên Postgres 18 + Node 24.

  3. 03~15 phút· 9 câuverify

    Playbook ghi hàng loạt: import, xoá 10M row và backfill

    Import bằng COPY + staging + upsert, giới hạn 65.535 bind parameter, DELETE theo batch (Postgres không có DELETE LIMIT), FK không index, replica lag/WAL, backfill resumable và vì sao backfill chậm dần. Có số đo thật trên Postgres 18.

  4. 04~14 phút· 7 câuverify

    Retention: partition, bloat, soft delete và erasure

    Xoá cả năm dữ liệu bằng DETACH/DROP, vì sao disk không giảm sau DELETE, copy-keepers + swap, chọn partition key, pruning, soft delete với partial unique index và GDPR erasure. Có số đo thật trên Postgres 18.

  5. 05~16 phút· 12 câuverify

    Playbook query chậm: tự nhiên chậm, index vẫn chậm

    Cây quyết định khi SQL không đổi mà chậm: pool, lock queue, idle in transaction, stats cũ, generic plan, đọc EXPLAIN, leftmost prefix, sargable, heap fetches và cột tương quan.

  6. 06~15 phút· 8 câuverify

    Reporting, hai database và guardrail khi dữ liệu lớn dần

    Dashboard trên OLTP, replica vs MV vs summary table vs warehouse, query dữ liệu nằm ở hai DB, read model đồng bộ bằng outbox/CDC, facet search, và guardrail cho bảng tăng 5x/năm.

Tóm tắt & cheat sheet của track

TL;DR

  • Gần như mọi scenario "data lớn" đều quy về 4 câu: đọc bao nhiêu byte/row để trả lời?, giữ lock/transaction bao lâu?, sinh ra bao nhiêu WAL/dead tuple?, ai khác (replica, vacuum, user) bị ảnh hưởng?
  • Pagination: OFFSET n là O(n) — DB vẫn phải đọc và bỏ n row. Keyset/cursor với khoá sort unique (created_at, id) + index khớp ORDER BY cho thời gian gần như hằng số ở mọi trang.
  • Export/import triệu row: async job + stream (DB cursor / COPY ... TO STDOUT → transform → gzip → S3 multipart) có backpressure; không bao giờ findMany() rồi join('\n'). Import: COPY FROM STDIN vào staging table rồi upsert.
  • Xoá / backfill hàng chục triệu row: batch nhỏ, transaction ngắn, resumable, throttle theo replica lag. PostgreSQL không có DELETE ... LIMIT. Xoá cả khoảng thời gian → partition + DETACH/DROP.
  • Query "tự nhiên chậm": query không đổi nhưng data, statistics, plan, lock, pool, cache đổi. Luôn bắt đầu bằng đo (pg_stat_statements, pg_stat_activity, EXPLAIN (ANALYZE, BUFFERS)), không đoán.

Khái niệm cốt lõi

Khái niệmMột câuVí dụ ngắn
Offset paginationBỏ qua n row đầu rồi lấy k rowLIMIT 20 OFFSET 100000 đọc 100.020 row
Keyset / cursor paginationLấy tiếp từ giá trị sort của row cuốiWHERE (created_at, id) < ($1, $2) ORDER BY created_at DESC, id DESC LIMIT 21
Tie-breakerCột unique thêm vào cuối khoá sort để thứ tự là toàn phầncreated_at trùng → thêm id
Opaque cursorClient chỉ thấy chuỗi, không thấy cấu trúcbase64url(JSON{v:1,k:[ts,id],f:hash}) + HMAC
Server-side cursor / streamDB trả kết quả theo từng batch trên một connectionpg-query-stream với batchSize: 1000
COPY ... TO STDOUTServer stream CSV qua wire protocol về clientpg-copy-streams to(...)
BackpressureConsumer chậm thì producer dừng đọcpipeline(dbStream, csv, gzip, upload)
Staging tableBảng tạm để load thô rồi validate/mergeCOPY staging FROM STDIN → INSERT ... ON CONFLICT
Batched deleteXoá theo lô nhỏ, commit từng lôDELETE ... WHERE id IN (SELECT id ... LIMIT 5000)
Partition drop/detachBỏ cả một partition thay vì xoá từng rowALTER TABLE orders DETACH PARTITION orders_2022 CONCURRENTLY
BloatDead tuple/page trống sau DELETE/UPDATEXoá 60% row, file vẫn 800 GB
Generic vs custom planPlan cache của prepared statement trong PostgresSau 5 lần execute, planner có thể chuyển sang generic plan
Parameter sniffingSQL Server compile plan theo giá trị tham số đầu tiênPlan cho tenant nhỏ bị dùng cho tenant 10M row
Read model (CQRS)Bản denormalize phục vụ đọc, đồng bộ bằng eventDocument order_view trong Elasticsearch
Outbox / CDCGhi event cùng transaction / đọc WAL để phát thay đổioutbox table, Debezium logical slot

Cơ chế hoạt động

Vì sao OFFSET chậm dần còn keyset thì không

Export triệu row: async + stream + backpressure

Batched delete / backfill an toàn

Query tự nhiên chậm: cây quyết định

Khung trả lời

Dùng cho mọi câu "data lớn / query chậm" trong mock interview:

  1. Làm rõ và đo (30 giây): bao nhiêu row, tăng bao nhanh, SLA latency, đọc hay ghi, có replica không, DB engine/version. "Trước khi sửa tôi muốn thấy EXPLAIN (ANALYZE, BUFFERS) và pg_stat_statements."
  2. Chặn cháy trước: giảm blast radius (giới hạn page size, tắt filter nặng, chuyển job sang replica, statement_timeout, pause batch job).
  3. Nêu root cause bằng cơ chế, không bằng từ khoá: "OFFSET đọc n row rồi bỏ", "DELETE một phát tạo một transaction dài giữ xmin và sinh WAL cho từng row".
  4. Fix theo thứ tự ưu tiên: thay đổi rẻ nhất có tác dụng (index, rewrite query) → thay đổi thiết kế (keyset, async job, read model) → thay đổi hạ tầng (partition, replica, warehouse).
  5. Trade-off + con số: "keyset mất khả năng nhảy trang", "REPEATABLE READ 2 giờ giữ vacuum", "batch 5k row ~ 50–200 ms".
  6. Vận hành: resumable, idempotent, có metric/alert, có kế hoạch rollback, và làm sao để không lặp lại (lint, guardrail, runbook).

Trade-offs

Vấn đềCáchĐượcMấtHợp khi
PaginationOFFSETNhảy trang, đơn giảnO(offset), trùng/sót khi data đổiDataset nhỏ, admin, page ≤ ~100
PaginationKeysetHằng số, ổn định khi insertKhông nhảy trang, cursor gắn với sortFeed, API public, infinite scroll
ExportSync stream qua HTTPKhông cần hạ tầng jobTimeout LB, retry = chạy lại từ đầu< ~100k row, vài giây
ExportAsync job + S3Resumable, giới hạn concurrencyPhức tạp hơn, cần notifyTriệu row, multi-tenant
Đọc dữ liệu exportCursor streamMột query, một snapshotGiữ connection + transaction suốt jobVài phút
Đọc dữ liệu exportKeyset batchesTransaction ngắn, resume dễKhông có snapshot chungHàng giờ, chấp nhận "gần đúng"
Xoá dữ liệu cũBatched DELETEKhông cần đổi schemaWAL + bloat, chậmMột lần, vài % bảng
Xoá dữ liệu cũPartition DROP/DETACHGần như tức thì, không bloatPhải thiết kế partition, PK chứa partition keyRetention theo thời gian, định kỳ
Xoá dữ liệu cũCopy keepers + swapBảng mới gọnCần catch-up + cutover, FK/view phức tạpGiữ lại < ~20%
ReportingReplicaRẻ, nhanh triển khaiLag, query conflict trên standbyBáo cáo gần real-time
ReportingMV / summary tableĐọc cực nhanhStale, chi phí refreshDashboard lặp lại
ReportingWarehouse + CDCTách hẳn OLTP, columnarHạ tầng, eventualAnalytics nặng, nhiều nguồn

Pitfalls

  • DELETE FROM t WHERE ... LIMIT 10000 trên Postgres → syntax error. Đó là MySQL; SQL Server là DELETE TOP (10000).
  • Keyset mà khoá sort không unique (ORDER BY created_at một mình) → sót row trùng timestamp ở biên trang.
  • Đổi filter/sort nhưng vẫn nhận cursor cũ → trang trả về vô nghĩa; cursor phải mang hash của filter/sort.
  • COPY table TO '/path/file.csv' từ app → file nằm trên máy DB, cần superuser/pg_write_server_files; managed DB (RDS) không cho.
  • Stream mà bỏ qua giá trị trả về của write() → không có backpressure → OOM chậm rãi.
  • Export "consistent" bằng một transaction REPEATABLE READ chạy 3 giờ trên primary → vacuum không dọn được gì trong 3 giờ.
  • Batched delete không có index trên predicate hoặc trên FK của bảng con → mỗi batch seq scan.
  • Xoá xong tưởng disk giảm: VACUUM thường không trả space cho OS (trừ trang cuối file).
  • Coi "parameter sniffing" là khái niệm Postgres, hoặc nhắc "MySQL query cache" (đã bị xoá ở MySQL 8.0).
  • Soft delete với UNIQUE(email) thường → user không đăng ký lại được; mọi query quên deleted_at IS NULL.

Cheat sheet

  • Keyset: khoá sort unique (created_at, id), index cùng thứ tự/chiều, LIMIT n+1 để biết hasNext, cursor = base64url(JSON) + HMAC + version + filter hash.
  • Mixed direction (created_at DESC, id ASC) → không dùng được row value; viết OR mở rộng và index (created_at DESC, id ASC).
  • Total count: bỏ, hoặc EXPLAIN estimate, hoặc capped count (count(*) FROM (... LIMIT 10001) → "10.000+").
  • Elasticsearch deep paging: from + size ≤ index.max_result_window (mặc định 10.000) → dùng search_after + PIT.
  • Export: POST → 202 → worker → COPY (SELECT ...) TO STDOUT WITH (FORMAT csv, HEADER) → gzip → @aws-sdk/lib-storage Upload → presigned URL ngắn hạn. Chạy trên replica, giới hạn concurrency theo tenant.
  • Excel: tối đa 1.048.576 row / sheet → chia sheet/file hoặc đưa CSV. Chặn formula injection: ô bắt đầu bằng = + - @ tab CR → prefix '.
  • Import: stream parse → COPY staging FROM STDIN → validate bằng SQL → INSERT ... ON CONFLICT DO UPDATE theo batch; lưu file_sha256 để idempotent; trả error report CSV.
  • Bind parameter tối đa 65.535 mỗi statement ở Postgres (SQL Server 2.100) → batch size = floor(65535 / số cột) hoặc dùng unnest($1::int[], ...).
  • Delete lớn: WHERE id IN (SELECT id ... ORDER BY id LIMIT 5000) hoặc PK range; index trên predicate và FK con; sleep; throttle theo pg_stat_replication.replay_lag; sau đó VACUUM (ANALYZE).
  • Partition: DETACH PARTITION ... CONCURRENTLY (PG14+, ngoài transaction block) rồi DROP TABLE; PK/unique phải chứa partition key; query phải filter theo partition key để được pruning.
  • Query chậm: pg_stat_statements → pg_stat_activity (wait_event, state, xact_start) → pg_locks/pg_blocking_pids() → EXPLAIN (ANALYZE, BUFFERS) → auto_explain cho plan thật ở prod.
  • Prepared statement chậm từ lần 6: generic plan → plan_cache_mode = force_custom_plan cho query đó. SQL Server: OPTION (RECOMPILE), Query Store plan forcing.
  • Index vẫn chậm: leftmost prefix, ORDER BY không khớp, function/cast trên cột, selectivity thấp, heap fetches (→ INCLUDE + vacuum), estimate lệch (→ ANALYZE, CREATE STATISTICS).
  • idle_in_transaction_session_timeout, statement_timeout, lock_timeout là ba guardrail rẻ nhất.

References

Personal notes (Notion)

Ghi chú cá nhân — có thể sai hoặc lỗi thời, dùng để tham khảo.

Interview Questions

Showing 60 / 60

EXPLAIN (ANALYZE, BUFFERS)
SELECT id, created_at, total FROM orders
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 100000;
-- Limit (actual rows=20) -> Index Scan Backward ... (actual rows=100020)
--   Buffers: shared hit=81234 read=20311
// BUG
const rows = await sql`
  SELECT id, created_at FROM orders
  WHERE tenant_id = ${tenantId}
    AND (${cursor}::timestamptz IS NULL OR created_at < ${cursor})
  ORDER BY created_at DESC
  LIMIT ${limit}`;
const next = rows.at(-1)?.created_at;

// FIX
const rows = await sql`
  SELECT id, created_at FROM orders
  WHERE tenant_id = ${tenantId}
    AND (${c?.ts ?? null}::timestamptz IS NULL
         OR (created_at, id) < (${c?.ts}, ${c?.id}))
  ORDER BY created_at DESC, id DESC
  LIMIT ${limit + 1}`;
// index: CREATE INDEX CONCURRENTLY ON orders (tenant_id, created_at, id);
import { createHmac, timingSafeEqual } from "node:crypto";
type Cursor = { v: 1; k: [string, number]; f: string };
const sign = (p: string) => createHmac("sha256", KEY).update(p).digest("base64url");
export const encode = (c: Cursor) => {
  const p = Buffer.from(JSON.stringify(c)).toString("base64url");
  return `${p}.${sign(p)}`;
};
export const decode = (s: string, filterHash: string): Cursor => {
  const [p, sig] = s.split(".");
  if (!p || !sig || !timingSafeEqual(Buffer.from(sig), Buffer.from(sign(p)))) throw new BadRequest("invalid_cursor");
  const c = JSON.parse(Buffer.from(p, "base64url").toString()) as Cursor;
  if (c.v !== 1 || c.f !== filterHash) throw new BadRequest("invalid_cursor");
  return c;
};
-- BUG
WHERE (created_at, id) < ($1, $2)
ORDER BY created_at DESC, id ASC

-- FIX (PostgreSQL / SQL Server)
WHERE created_at < $1 OR (created_at = $1 AND id > $2)
ORDER BY created_at DESC, id ASC
-- CREATE INDEX CONCURRENTLY orders_created_desc_id_asc ON orders (created_at DESC, id ASC);
-- capped count
SELECT count(*) AS n FROM (
  SELECT 1 FROM orders WHERE tenant_id = $1 AND status = 'paid' LIMIT 10001
) s;                                  -- n = 10001  → UI hiển thị "10,000+"

-- estimate for a filtered query (parse the JSON plan)
EXPLAIN (FORMAT JSON) SELECT 1 FROM orders WHERE tenant_id = $1 AND status = 'paid';
-- -> [0].Plan["Plan Rows"]
-- deferred join
SELECT o.* FROM orders o
JOIN (
  SELECT id FROM orders
  WHERE tenant_id = $1
  ORDER BY created_at DESC, id DESC
  LIMIT 50 OFFSET 24950            -- page 500
) p USING (id)
ORDER BY o.created_at DESC, o.id DESC;
-- cần index (tenant_id, created_at, id) để subquery là index-only scan
async function page(dir: "next" | "prev", c: [Date, number] | null, n: number) {
  const cmp = dir === "next" ? sql`<` : sql`>`;
  const ord = dir === "next" ? sql`DESC` : sql`ASC`;
  const rows = await sql`
    SELECT id, created_at FROM orders
    WHERE ${c ? sql`(created_at, id) ${cmp} (${c[0]}, ${c[1]})` : sql`TRUE`}
    ORDER BY created_at ${ord}, id ${ord}
    LIMIT ${n + 1}`;
  const more = rows.length > n;
  const items = rows.slice(0, n);
  if (dir === "prev") items.reverse();
  return { items, hasNext: dir === "next" ? more : true, hasPrev: dir === "prev" ? more : c !== null };
}
-- phase 1 (non-null)
WHERE shipped_at IS NOT NULL AND (shipped_at, id) < ($1, $2)
ORDER BY shipped_at DESC, id DESC LIMIT $3;
-- phase 2 (nulls), khi phase 1 hết
WHERE shipped_at IS NULL AND id < $2
ORDER BY id DESC LIMIT $3;
const pit = await es.openPointInTime({ index: "products", keep_alive: "2m" });
let after: unknown[] | undefined;
for (;;) {
  const r = await es.search({
    size: 5000, pit: { id: pit.id, keep_alive: "2m" },
    sort: [{ updated_at: "desc" }, { product_id: "asc" }],
    ...(after && { search_after: after }),
  });
  if (r.hits.hits.length === 0) break;
  await sink(r.hits.hits);
  after = r.hits.hits.at(-1)!.sort;
}
await es.closePointInTime({ id: pit.id });
// BUG
const rows = await prisma.order.findMany({ where: { tenantId } });
const csv = ["id,total,email", ...rows.map(r => `${r.id},${r.total},${r.email}`)].join("\n");
await s3.send(new PutObjectCommand({ Bucket, Key, Body: csv }));

// FIX
import { pipeline } from "node:stream/promises";
import { createGzip } from "node:zlib";
import { PassThrough } from "node:stream";
import QueryStream from "pg-query-stream";
import { stringify } from "csv-stringify";
import { Upload } from "@aws-sdk/lib-storage";

const client = await pool.connect();
try {
  const rows = client.query(new QueryStream(
    "SELECT id, total, email FROM orders WHERE tenant_id = $1 ORDER BY id", [tenantId], { batchSize: 1000 }));
  const body = new PassThrough();
  const upload = new Upload({ client: s3, params: { Bucket, Key, Body: body, ContentEncoding: "gzip" },
                              partSize: 8 * 1024 * 1024, queueSize: 4 });
  await Promise.all([
    pipeline(rows, stringify({ header: true, columns: ["id", "total", "email"] }), createGzip(), body),
    upload.done(),
  ]);
} finally {
  client.release();
}
// BUG
dbStream.on("data", (row) => gzip.write(toCsvLine(row)));
dbStream.on("end", () => gzip.end());
gzip.pipe(uploadBody);

// FIX
import { pipeline } from "node:stream/promises";
import { Transform } from "node:stream";
const toCsv = new Transform({
  writableObjectMode: true,
  transform(row, _enc, cb) { cb(null, toCsvLine(row)); },
});
await pipeline(dbStream, toCsv, createGzip(), uploadBody);
import { to as copyTo } from "pg-copy-streams";
const client = await pool.connect();
try {
  const src = client.query(copyTo(
    "COPY (SELECT id, total, created_at FROM orders WHERE tenant_id = 42) TO STDOUT WITH (FORMAT csv, HEADER)"));
  await pipeline(src, createGzip(), uploadBody);
} finally { client.release(); }
-- coordinator
BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY;
SELECT pg_export_snapshot();          -- '00000003-0000001B-1'
-- worker N (while coordinator txn stays open)
BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY;
SET TRANSACTION SNAPSHOT '00000003-0000001B-1';
COPY (SELECT ... WHERE id BETWEEN 1 AND 10000000) TO STDOUT WITH (FORMAT csv);
import ExcelJS from "exceljs";
const wb = new ExcelJS.stream.xlsx.WorkbookWriter({ stream: uploadBody, useSharedStrings: false });
let ws = wb.addWorksheet("orders-1"); let n = 0; let sheet = 1;
for await (const row of rows) {
  if (++n > 1_048_575) { await ws.commit(); ws = wb.addWorksheet(`orders-${++sheet}`); n = 1; }
  ws.addRow([row.id, row.total, safeCell(row.note)]).commit();
}
await ws.commit(); await wb.commit();
const DANGER = /^[=+\-@\t\r]/;
export const safeCell = (v: unknown) =>
  typeof v === "string" && DANGER.test(v) ? `'${v}` : v;
// then let the CSV library quote/escape:  "'=HYPERLINK(""http://..."")"
CREATE TABLE export_jobs (
  id uuid PRIMARY KEY, tenant_id bigint NOT NULL, idempotency_key text NOT NULL,
  status text NOT NULL, as_of timestamptz NOT NULL,
  s3_upload_id text, last_key bigint, parts jsonb NOT NULL DEFAULT '[]',
  rows_done bigint NOT NULL DEFAULT 0, lease_until timestamptz,
  UNIQUE (tenant_id, idempotency_key)
);
import { from as copyFrom } from "pg-copy-streams";
const client = await pool.connect();
try {
  await client.query("CREATE TEMP TABLE staging (LIKE supplier_items_raw) ON COMMIT DROP");
  await client.query("BEGIN");
  await pipeline(fs.createReadStream(path), client.query(copyFrom(
    "COPY staging (sku, name, price, qty) FROM STDIN WITH (FORMAT csv, HEADER)")));
  await client.query(`
    INSERT INTO supplier_items (sku, name, price, qty)
    SELECT sku, name, price::numeric, qty::int FROM staging
    ON CONFLICT (sku) DO UPDATE SET name = EXCLUDED.name, price = EXCLUDED.price, qty = EXCLUDED.qty
    WHERE (supplier_items.name, supplier_items.price, supplier_items.qty)
          IS DISTINCT FROM (EXCLUDED.name, EXCLUDED.price, EXCLUDED.qty)`);
  await client.query("COMMIT");
} finally { client.release(); }
// BUG: 10_000 rows × 8 cols = 80_000 parameters
await db.insertInto("items").values(rows).execute();

// FIX: 8 parameters regardless of row count
await sql`
  INSERT INTO items (sku, name, price, qty, a, b, c, d)
  SELECT * FROM unnest(
    ${skus}::text[], ${names}::text[], ${prices}::numeric[], ${qtys}::int[],
    ${as}::text[], ${bs}::text[], ${cs}::text[], ${ds}::text[])
  ON CONFLICT (sku) DO NOTHING`;
-- PostgreSQL
DELETE FROM orders
WHERE id IN (
  SELECT id FROM orders
  WHERE created_at < '2023-01-01'
  ORDER BY id
  LIMIT 10000
);
-- SQL Server
DELETE TOP (10000) FROM orders WHERE created_at < '2023-01-01';
-- MySQL
DELETE FROM orders WHERE created_at < '2023-01-01' ORDER BY id LIMIT 10000;
EXPLAIN (ANALYZE) DELETE FROM orders
WHERE id IN (SELECT id FROM orders WHERE created_at < '2023-01-01' ORDER BY id LIMIT 5000);
--  Delete on orders (actual time=412.3..412.3 rows=0)
--  ...
--  Trigger for constraint order_items_order_id_fkey: time=39871.2 calls=5000
--  Trigger audit_orders_delete: time=911.4 calls=5000
async function waitForReplicas(maxLagMs = 5000) {
  for (;;) {
    const { rows } = await primary.query(
      "SELECT coalesce(max(extract(epoch FROM replay_lag)) * 1000, 0) AS lag_ms FROM pg_stat_replication");
    if (Number(rows[0].lag_ms) < maxLagMs) return;
    await sleep(2000);
  }
}
SET lock_timeout = '3s';
ALTER TABLE orders DETACH PARTITION orders_2022 CONCURRENTLY;
-- archive: COPY orders_2022 TO STDOUT ... → S3, rồi:
DROP TABLE orders_2022;
let { last_id: lastId } = await getCheckpoint("backfill_region");
let size = 5000;
for (;;) {
  const t0 = Date.now();
  const { rows } = await db.query(`
    WITH b AS (SELECT id FROM orders WHERE id > $1 ORDER BY id LIMIT $2)
    UPDATE orders o SET region_code = r.code
    FROM b JOIN addresses a ON a.order_id = b.id JOIN regions r ON r.zip = a.zip
    WHERE o.id = b.id AND o.region_code IS NULL
    RETURNING (SELECT max(id) FROM b) AS max_id`, [lastId, size]);
  const maxId = rows[0]?.max_id ?? (await maxIdAfter(lastId, size));
  if (maxId == null) break;               // no more rows
  lastId = maxId;
  await saveCheckpoint("backfill_region", lastId);
  const ms = Date.now() - t0;
  size = ms > 500 ? Math.max(500, size / 2) : ms < 100 ? Math.min(20000, size * 2) : size;
  await waitForReplicas(5000);
  await sleep(100);
}
-- BUG
UPDATE orders SET region_code = compute_region(zip)
WHERE id IN (SELECT id FROM orders WHERE region_code IS NULL LIMIT 5000);

-- FIX
UPDATE orders SET region_code = compute_region(zip)
WHERE id > $1 AND id <= $1 + 5000 AND region_code IS NULL;
// pg: a `name` makes it a server-side prepared statement cached per connection
await client.query({
  name: "orders-by-tenant",
  text: "SELECT * FROM orders WHERE tenant_id = $1 AND status = $2 ORDER BY created_at DESC LIMIT 50",
  values: [tenantId, status],
});
PREPARE q(bigint, text) AS SELECT ... WHERE tenant_id = $1 AND status = $2 ...;
EXECUTE q(7, 'open');  -- ×6
EXPLAIN EXECUTE q(42, 'open');  -- "Index Cond: (tenant_id = $1)" → generic plan
SELECT relname, n_mod_since_analyze, last_autoanalyze, last_analyze
FROM pg_stat_user_tables WHERE relname = 'orders';

ALTER TABLE orders SET (autovacuum_analyze_scale_factor = 0.005, autovacuum_analyze_threshold = 10000);
CREATE STATISTICS orders_country_city (dependencies, mcv) ON country, city FROM orders;
ANALYZE orders;
SELECT pid, usename, application_name, state, xact_start, backend_xmin,
       now() - xact_start AS age, left(query, 80) AS last_query
FROM pg_stat_activity
WHERE state LIKE 'idle in transaction%'
ORDER BY xact_start;
ALTER ROLE app SET idle_in_transaction_session_timeout = '60s';
Limit (actual time=5210.4..5210.5 rows=50 loops=1)
  Buffers: shared hit=1033 read=412871
  -> Sort (actual rows=50) Sort Key: created_at DESC  Sort Method: top-N heapsort  Memory: 39kB
    -> Bitmap Heap Scan on orders (cost=.. rows=50 ..) (actual rows=1902114 loops=1)
          Recheck Cond: (tenant_id = 42)
          Filter: (status = 'open')
          Rows Removed by Filter: 1899870
          -> Bitmap Index Scan on orders_tenant_id_idx (actual rows=3801984)
Planning Time: 0.3 ms
Execution Time: 5211.0 ms
-- migration (BUG: no lock_timeout)
ALTER TABLE orders ADD COLUMN region_code text;

-- FIX
SET lock_timeout = '3s';
ALTER TABLE orders ADD COLUMN region_code text;   -- retry on lock_not_available (55P03)

-- who blocks whom
SELECT pid, pg_blocking_pids(pid) AS blocked_by, wait_event_type, state, left(query, 60)
FROM pg_stat_activity WHERE datname = current_database() AND wait_event_type = 'Lock';
CREATE INDEX idx_orders ON orders (status, created_at);
SELECT id, total FROM orders
WHERE created_at >= now() - interval '7 days'
ORDER BY created_at DESC
LIMIT 100;
-- FIX
CREATE INDEX CONCURRENTLY orders_created_at_idx ON orders (created_at);
CREATE INDEX CONCURRENTLY orders_tenant_created_incl
  ON orders (tenant_id, created_at DESC) INCLUDE (status, total);
VACUUM (ANALYZE) orders;
EXPLAIN (ANALYZE, BUFFERS)
SELECT created_at, status, total FROM orders WHERE tenant_id = 42 ORDER BY created_at DESC LIMIT 50;
-- Index Only Scan ... Heap Fetches: 0
Nested Loop (cost=1.1..950.2 rows=12) (actual time=0.1..184220.7 rows=2210034 loops=1)
  -> Index Scan using customers_city_idx on customers (rows=3) (actual rows=48210)
        Index Cond: (city = 'Hanoi')  Filter: (country = 'VN')
  -> Index Scan using orders_customer_id_idx on orders (rows=4) (actual rows=46 loops=48210)
CREATE STATISTICS customers_country_city (dependencies, mcv) ON country, city FROM customers;
ANALYZE customers;
CREATE UNIQUE INDEX ON sales_mv (tenant_id, day);
REFRESH MATERIALIZED VIEW CONCURRENTLY sales_mv;

-- incremental alternative
INSERT INTO daily_sales (tenant_id, day, revenue, orders)
SELECT tenant_id, created_at::date, sum(total), count(*)
FROM orders WHERE created_at >= current_date - 1
GROUP BY 1, 2
ON CONFLICT (tenant_id, day) DO UPDATE SET revenue = EXCLUDED.revenue, orders = EXCLUDED.orders;
-- BUG
ALTER TABLE users ADD CONSTRAINT users_email_key UNIQUE (email);
-- FIX
ALTER TABLE users DROP CONSTRAINT users_email_key;
CREATE UNIQUE INDEX CONCURRENTLY users_email_active_uq ON users (lower(email)) WHERE deleted_at IS NULL;
CREATE VIEW active_users AS SELECT * FROM users WHERE deleted_at IS NULL;
-- scans all partitions
SELECT * FROM events WHERE user_id = 42 ORDER BY created_at DESC LIMIT 20;
SELECT * FROM events WHERE created_at::date = '2026-09-01';
-- prunes
SELECT * FROM events
WHERE user_id = 42 AND created_at >= now() - interval '30 days'
ORDER BY created_at DESC LIMIT 20;
// BUG 1: N+1
const orders = await db.query("SELECT * FROM orders WHERE tenant_id=$1 ORDER BY id DESC LIMIT 20", [t]);
for (const o of orders.rows) o.items = (await db.query("SELECT * FROM order_items WHERE order_id=$1", [o.id])).rows;

// BUG 2: LIMIT counts joined rows
SELECT o.*, i.* FROM orders o JOIN order_items i ON i.order_id = o.id
WHERE o.tenant_id = $1 ORDER BY o.id DESC LIMIT 20;

// FIX
SELECT o.*, coalesce(it.items, '[]') AS items
FROM (SELECT * FROM orders WHERE tenant_id = $1 AND id < $2 ORDER BY id DESC LIMIT 20) o
LEFT JOIN LATERAL (
  SELECT json_agg(i ORDER BY i.id) AS items FROM order_items i WHERE i.order_id = o.id
) it ON true
ORDER BY o.id DESC;