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
Scenarios: Pagination, Export & Large Data
draftedMock 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.- 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.
- 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.
- 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.
- 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.
- 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.
- 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 nlà O(n) — DB vẫn phải đọc và bỏ n row. Keyset/cursor với khoá sort unique(created_at, id)+ index khớpORDER BYcho 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ồijoin('\n'). Import:COPY FROM STDINvà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ệm | Một câu | Ví dụ ngắn |
|---|---|---|
| Offset pagination | Bỏ qua n row đầu rồi lấy k row | LIMIT 20 OFFSET 100000 đọc 100.020 row |
| Keyset / cursor pagination | Lấy tiếp từ giá trị sort của row cuối | WHERE (created_at, id) < ($1, $2) ORDER BY created_at DESC, id DESC LIMIT 21 |
| Tie-breaker | Cột unique thêm vào cuối khoá sort để thứ tự là toàn phần | created_at trùng → thêm id |
| Opaque cursor | Client chỉ thấy chuỗi, không thấy cấu trúc | base64url(JSON{v:1,k:[ts,id],f:hash}) + HMAC |
| Server-side cursor / stream | DB trả kết quả theo từng batch trên một connection | pg-query-stream với batchSize: 1000 |
COPY ... TO STDOUT | Server stream CSV qua wire protocol về client | pg-copy-streams to(...) |
| Backpressure | Consumer chậm thì producer dừng đọc | pipeline(dbStream, csv, gzip, upload) |
| Staging table | Bảng tạm để load thô rồi validate/merge | COPY staging FROM STDIN → INSERT ... ON CONFLICT |
| Batched delete | Xoá theo lô nhỏ, commit từng lô | DELETE ... WHERE id IN (SELECT id ... LIMIT 5000) |
| Partition drop/detach | Bỏ cả một partition thay vì xoá từng row | ALTER TABLE orders DETACH PARTITION orders_2022 CONCURRENTLY |
| Bloat | Dead tuple/page trống sau DELETE/UPDATE | Xoá 60% row, file vẫn 800 GB |
| Generic vs custom plan | Plan cache của prepared statement trong Postgres | Sau 5 lần execute, planner có thể chuyển sang generic plan |
| Parameter sniffing | SQL Server compile plan theo giá trị tham số đầu tiên | Plan cho tenant nhỏ bị dùng cho tenant 10M row |
| Read model (CQRS) | Bản denormalize phục vụ đọc, đồng bộ bằng event | Document order_view trong Elasticsearch |
| Outbox / CDC | Ghi event cùng transaction / đọc WAL để phát thay đổi | outbox 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:
- 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." - 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). - 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".
- 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).
- 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".
- 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 | Được | Mất | Hợp khi |
|---|---|---|---|---|
| Pagination | OFFSET | Nhảy trang, đơn giản | O(offset), trùng/sót khi data đổi | Dataset nhỏ, admin, page ≤ ~100 |
| Pagination | Keyset | Hằng số, ổn định khi insert | Không nhảy trang, cursor gắn với sort | Feed, API public, infinite scroll |
| Export | Sync stream qua HTTP | Không cần hạ tầng job | Timeout LB, retry = chạy lại từ đầu | < ~100k row, vài giây |
| Export | Async job + S3 | Resumable, giới hạn concurrency | Phức tạp hơn, cần notify | Triệu row, multi-tenant |
| Đọc dữ liệu export | Cursor stream | Một query, một snapshot | Giữ connection + transaction suốt job | Vài phút |
| Đọc dữ liệu export | Keyset batches | Transaction ngắn, resume dễ | Không có snapshot chung | Hàng giờ, chấp nhận "gần đúng" |
| Xoá dữ liệu cũ | Batched DELETE | Không cần đổi schema | WAL + bloat, chậm | Một lần, vài % bảng |
| Xoá dữ liệu cũ | Partition DROP/DETACH | Gần như tức thì, không bloat | Phải thiết kế partition, PK chứa partition key | Retention theo thời gian, định kỳ |
| Xoá dữ liệu cũ | Copy keepers + swap | Bảng mới gọn | Cần catch-up + cutover, FK/view phức tạp | Giữ lại < ~20% |
| Reporting | Replica | Rẻ, nhanh triển khai | Lag, query conflict trên standby | Báo cáo gần real-time |
| Reporting | MV / summary table | Đọc cực nhanh | Stale, chi phí refresh | Dashboard lặp lại |
| Reporting | Warehouse + CDC | Tách hẳn OLTP, columnar | Hạ tầng, eventual | Analytics nặng, nhiều nguồn |
Pitfalls
DELETE FROM t WHERE ... LIMIT 10000trên Postgres → syntax error. Đó là MySQL; SQL Server làDELETE TOP (10000).- Keyset mà khoá sort không unique (
ORDER BY created_atmộ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 READchạ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:
VACUUMthườ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êndeleted_at IS NULL.
Cheat sheet
- Keyset: khoá sort unique
(created_at, id), index cùng thứ tự/chiều,LIMIT n+1để biếthasNext, cursor = base64url(JSON) + HMAC + version + filter hash. - Mixed direction (
created_at DESC, id ASC) → không dùng được row value; viếtORmở rộng và index(created_at DESC, id ASC). - Total count: bỏ, hoặc
EXPLAINestimate, 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ùngsearch_after+ PIT. - Export:
POST→ 202 → worker →COPY (SELECT ...) TO STDOUT WITH (FORMAT csv, HEADER)→ gzip →@aws-sdk/lib-storageUpload→ 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 UPDATEtheo batch; lưufile_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 theopg_stat_replication.replay_lag; sau đóVACUUM (ANALYZE). - Partition:
DETACH PARTITION ... CONCURRENTLY(PG14+, ngoài transaction block) rồiDROP 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_explaincho plan thật ở prod. - Prepared statement chậm từ lần 6: generic plan →
plan_cache_mode = force_custom_plancho query đó. SQL Server:OPTION (RECOMPILE), Query Store plan forcing. - Index vẫn chậm: leftmost prefix,
ORDER BYkhô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_timeoutlà ba guardrail rẻ nhất.
References
- PostgreSQL docs — LIMIT and OFFSET ↗
- PostgreSQL docs — Row constructor comparison ↗
- PostgreSQL docs — DELETE ↗
- PostgreSQL docs — COPY ↗
- PostgreSQL docs — Populating a database ↗
- PostgreSQL docs — Table partitioning ↗
- PostgreSQL docs — ALTER TABLE (DETACH PARTITION) ↗
- PostgreSQL docs — Routine vacuuming ↗
- PostgreSQL docs — Using EXPLAIN ↗
- PostgreSQL docs — PREPARE (generic vs custom plans) ↗
- PostgreSQL docs — pg_stat_statements ↗
- PostgreSQL docs — auto_explain ↗
- PostgreSQL docs — Monitoring (pg_stat_activity, pg_locks) ↗
- PostgreSQL docs — Index-only scans and covering indexes ↗
- PostgreSQL docs — Multicolumn indexes ↗
- PostgreSQL docs — Extended statistics (CREATE STATISTICS) ↗
- PostgreSQL docs — Snapshot synchronization functions ↗
- PostgreSQL docs — Hot standby (query conflicts) ↗
- PostgreSQL docs — REFRESH MATERIALIZED VIEW ↗
- PostgreSQL docs — Client connection defaults (timeouts) ↗
- Node.js docs — Stream (pipeline, backpressure) ↗
- Node.js guide — Backpressuring in streams ↗
- AWS docs — Amazon S3 multipart upload ↗
- AWS docs — Sharing objects with presigned URLs ↗
- AWS docs — Exporting RDS for PostgreSQL data to S3 ↗
- OWASP — CSV Injection ↗
- Microsoft — Excel specifications and limits ↗
- Elasticsearch docs — Paginate search results (search_after, PIT) ↗
- GraphQL Cursor Connections Specification ↗
- Microsoft docs — Parameter Sensitive Plan optimization ↗
- MySQL 8.0 docs — What is new (query cache removed) ↗
- Debezium docs — PostgreSQL connector ↗
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
// 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;