Keyset-пагинация (seek method) выбирает следующую страницу условием «всё, что после последней показанной строки»: WHERE (created_at, id) < ($1, $2) ORDER BY created_at DESC, id DESC LIMIT 21. С составным индексом по (created_at, id) база читает только строки страницы, поэтому скорость не зависит от глубины, а новые записи не сдвигают страницы. Цена — нельзя перейти сразу на страницу N: клиент получает курсор и листает от него.
После материала вы сможете
- заменить
LIMIT … OFFSETна условие(created_at, id) < ($1, $2)и подобрать под него индекс - отдавать в API непрозрачный курсор в base64url и проверять его до запроса к базе
- листать страницы вперёд и назад без дублей и пропусков, даже когда появляются новые строки
- измерить разницу через
EXPLAIN (ANALYZE, BUFFERS)на миллионе строк
Что знать заранее
Проверено на
PostgreSQL 17 (17.11, postgres:17-alpine)Node.js 24 LTS (24.18, 24.20)node-postgres (pg) 8.23.1
Сверено с документацией: 12 октября 2026 г.
Содержание
Задача: почему OFFSET тормозит и «прыгает»
Классическая пагинация — LIMIT 20 OFFSET 40: пропустить 40 строк и отдать 20. У неё две проблемы. Первая — скорость: документация PostgreSQL прямо предупреждает, что строки, пропущенные через OFFSET, всё равно вычисляются на сервере, поэтому большой OFFSET неэффективен. Чтобы отдать страницу 25 001, базе придётся пройти полмиллиона строк. Вторая — стабильность: если между запросами страниц появилась новая запись, всё съезжает на одну позицию, и пользователь видит на второй странице строки с первой.
Стенд — таблица заказов на миллион строк в PostgreSQL 17. По три заказа на каждую минуту: время не уникально, а id не совпадает с порядком по времени — как в реальных данных, где записи импортируют пачками.
CREATE TABLE orders (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
created_at timestamptz NOT NULL DEFAULT now(),
customer text NOT NULL,
total numeric(12, 2) NOT NULL
);
-- 1 млн заказов: по три на каждую минуту, порядок id не совпадает с порядком времени
INSERT INTO orders (created_at, customer, total)
SELECT timestamptz '2026-01-01 00:00:00+00' + ((g::bigint * 7919) % 333334) * interval '1 minute',
'customer-' || (g % 5000),
100 + (g % 90000) / 3.0
FROM generate_series(1, 1000000) AS g;
ANALYZE orders;Вот что делает OFFSET даже при подходящем индексе (он создаётся ниже): обратный проход по индексу читает 500 020 строк, чтобы 500 000 выбросить.
$ psql -d shop -f sql/02-offset.sql
QUERY PLAN
---------------------------------------------------------------------------------------------------------------------
Limit (actual time=191.505..191.520 rows=20 loops=1)
Buffers: shared hit=503855
-> Index Scan Backward using orders_created_at_id_idx on orders (actual time=0.014..181.588 rows=500020 loops=1)
Buffers: shared hit=503855
Planning:
Buffers: shared hit=103
Planning Time: 0.427 ms
Execution Time: 191.548 msНасколько велика разница, мы измерили отдельно на той же схеме: таблица из 1 000 000 строк, PostgreSQL 17, пять глубин страницы, три прогона по 10 раундов. Медиана серверного времени OFFSET на последней странице — 243,1 мс, keyset на той же странице — 0,059 мс; это одна машина и данные в кэше, поэтому важна форма зависимости, а не точные миллисекунды.
Готовый SQL: условие по паре (created_at, id) и индекс
Keyset-пагинация запоминает не номер страницы, а последнюю показанную строку, и просит «всё, что после неё». Сортировка должна быть однозначной: одного created_at мало, ведь у трёх заказов одно время. Поэтому добавляем уникальный хвост — id — и сравниваем пару целиком через сравнение конструкторов строк: (created_at, id) < ($1, $2). PostgreSQL сравнивает элементы слева направо и останавливается на первой неравной паре — ровно то, что нужно для порядка created_at DESC, id DESC.
-- один B-tree покрывает и ORDER BY created_at DESC, id DESC (обратный проход), и ASC
CREATE INDEX orders_created_at_id_idx ON orders (created_at, id);B-tree хранит записи по возрастанию и умеет проходить индекс в обратную сторону, поэтому один индекс по (created_at, id) обслуживает и ORDER BY created_at DESC, id DESC, и ORDER BY created_at, id. Та же страница 25 001 через keyset:
-- та же страница 25 001: всё, что после последней строки страницы 25 000
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF)
SELECT id, created_at, customer, total
FROM orders
WHERE (created_at, id) < ('2026-04-26 17:47:00+00', 166667)
ORDER BY created_at DESC, id DESC
LIMIT 21;LIMIT 21 вместо 20: лишняя строка показывает, есть ли следующая страница$ psql -d shop -f sql/03-keyset.sql
QUERY PLAN
---------------------------------------------------------------------------------------------------------------
Limit (actual time=0.030..0.150 rows=21 loops=1)
Buffers: shared hit=24
-> Index Scan Backward using orders_created_at_id_idx on orders (actual time=0.029..0.145 rows=21 loops=1)
Index Cond: (ROW(created_at, id) < ROW('2026-04-26 17:47:00+00'::timestamp with time zone, 166667))
Buffers: shared hit=24
Planning:
Buffers: shared hit=102
Planning Time: 0.449 ms
Execution Time: 0.195 msУсловие попало в Index Cond: база спускается по дереву сразу к границе и читает 21 строку. Замер на том же стенде (Docker на ноутбуке разработчика, данные в кэше — смотрите на порядок величин, а не на точные миллисекунды):
OFFSET 500000, без индекса
- План
- Parallel Seq Scan + сортировка на диске (external merge)
- Буферы
- 8 422 + временные файлы
- Время выполнения
- 179,7 мс
keyset, без индекса
- План
- Parallel Seq Scan + top-N heapsort
- Буферы
- 8 364
- Время выполнения
- 24,8 мс
OFFSET 500000, с индексом
- План
- Index Scan Backward, 500 020 строк
- Буферы
- 503 855
- Время выполнения
- 175,9–241,2 мс (3 прогона)
keyset, с индексом
- План
- Index Scan Backward, 21 строка
- Буферы
- 24
- Время выполнения
- 0,195–0,266 мс (3 прогона)
Индекс не спасает OFFSET: строки всё равно приходится перебрать. Keyset без индекса тоже не быстрый — нужен полный проход таблицы. Выигрывает только пара «keyset + индекс по ключу сортировки». Три запроса, которые нужны API: первая страница, следующая и предыдущая. Для предыдущей идём от первой строки текущей страницы вверх по возрастанию, а приложение потом разворачивает результат:
-- первая страница
SELECT id, created_at, customer, total FROM orders
ORDER BY created_at DESC, id DESC LIMIT $1;
-- следующая: всё, что ниже последней строки страницы
SELECT id, created_at, customer, total FROM orders
WHERE (created_at, id) < ($1::timestamptz, $2::bigint)
ORDER BY created_at DESC, id DESC LIMIT $3;
-- предыдущая: всё, что выше первой строки страницы; результат развернуть
SELECT id, created_at, customer, total FROM orders
WHERE (created_at, id) > ($1::timestamptz, $2::bigint)
ORDER BY created_at ASC, id ASC LIMIT $3;$1–$3 подставляет драйвер, $3 = limit + 1Курсор в API: обработчик на Node.js
Клиенту незачем знать, что внутри курсора. Обработчик кодирует [created_at, id] граничной строки в base64url и отдаёт next_cursor и prev_cursor; клиент передаёт их обратно в ?after= или ?before=. Формат курсора можно менять без новой версии API — об этом подробнее в уроке о проектировании API.
import http from "node:http";
import pg from "pg";
const COLUMNS = "id, created_at, customer, total";
const MAX_LIMIT = 100;
// timestamptz (OID 1184) оставляем строкой: в Date пропадут микросекунды, и курсор начнёт терять строки
const types = {
getTypeParser: (oid, format) => (oid === 1184 ? (value) => value : pg.types.getTypeParser(oid, format)),
};
const TIMESTAMP = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(\.\d{1,6})?[+-]\d{2}(:\d{2})?$/;
// курсор — непрозрачная строка: base64url от [created_at, id] граничной строки
export function encodeCursor(row) {
return Buffer.from(JSON.stringify([row.created_at, row.id])).toString("base64url");
}
export function decodeCursor(cursor) {
try {
const [createdAt, id] = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8"));
if (TIMESTAMP.test(createdAt) && /^\d{1,18}$/.test(id)) return { createdAt, id };
} catch {
// не base64url или не JSON — ниже вернём null
}
return null;
}
// новые заказы сверху: ORDER BY created_at DESC, id DESC
export async function listOrders(db, { limit = 20, after = null, before = null } = {}) {
let query;
if (after) {
query = {
text: `SELECT ${COLUMNS} FROM orders
WHERE (created_at, id) < ($1::timestamptz, $2::bigint)
ORDER BY created_at DESC, id DESC LIMIT $3`,
values: [after.createdAt, after.id, limit + 1],
};
} else if (before) {
// назад: идём от курсора вверх по возрастанию, потом разворачиваем
query = {
text: `SELECT ${COLUMNS} FROM orders
WHERE (created_at, id) > ($1::timestamptz, $2::bigint)
ORDER BY created_at ASC, id ASC LIMIT $3`,
values: [before.createdAt, before.id, limit + 1],
};
} else {
query = {
text: `SELECT ${COLUMNS} FROM orders ORDER BY created_at DESC, id DESC LIMIT $1`,
values: [limit + 1],
};
}
const { rows } = await db.query({ ...query, types });
const hasMore = rows.length > limit; // лишняя (limit + 1)-я строка — признак, что дальше есть ещё
const items = rows.slice(0, limit);
if (before) items.reverse();
const hasNext = before ? items.length > 0 : hasMore;
const hasPrev = before ? hasMore : Boolean(after) && items.length > 0;
return {
items,
next_cursor: hasNext ? encodeCursor(items.at(-1)) : null,
prev_cursor: hasPrev ? encodeCursor(items[0]) : null,
};
}
function problem(res, status, detail) {
res.writeHead(status, { "content-type": "application/problem+json" });
res.end(JSON.stringify({ title: http.STATUS_CODES[status], status, detail }));
}
export function createServer(db) {
return http.createServer(async (req, res) => {
const url = new URL(req.url, "http://localhost");
if (req.method !== "GET" || url.pathname !== "/api/orders") return problem(res, 404, "Нет такого адреса");
const params = url.searchParams;
const limit = Number(params.get("limit") ?? 20);
if (!Number.isInteger(limit) || limit < 1 || limit > MAX_LIMIT) {
return problem(res, 400, `limit — целое число от 1 до ${MAX_LIMIT}`);
}
if (params.has("after") && params.has("before")) return problem(res, 400, "Передайте after или before, не оба");
const raw = params.get("after") ?? params.get("before");
const cursor = raw === null ? null : decodeCursor(raw);
if (raw !== null && cursor === null) return problem(res, 400, "Курсор повреждён");
try {
const page = await listOrders(db, {
limit,
after: params.has("after") ? cursor : null,
before: params.has("before") ? cursor : null,
});
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify(page));
} catch (err) {
console.error(err);
problem(res, 500, "Внутренняя ошибка сервера");
}
});
}import pg from "pg";
import { createServer } from "./orders-api.js";
// DATABASE_URL=postgres://user:password@localhost:5432/shop node server.js
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
const port = Number(process.env.PORT ?? 3000);
createServer(pool).listen(port, () => console.log(`http://localhost:${port}/api/orders`));timestamptzвозвращается строкой, а неDate. Документация node-postgres предупреждает: в JavaScriptDateхранит миллисекунды, и микросекунды из PostgreSQL обрезаются. Курсор с обрезанным временем указывает не на ту строку: в нашем тесте сDateобход терял часть заказов.- Курсор проверяется до запроса: base64url → JSON → время по шаблону и
idиз цифр. Повреждённый курсор даёт 400, а значения уходят в запрос только параметрами$1,$2, а не склейкой строк. idтипаbigintиnumericдрайвер отдаёт строками — так JSON не теряет точность на больших числах.- Запрос просит
limit + 1строк. Если пришла лишняя, следующая страница есть; лишнюю строку клиенту не отдаём.
Как подключить
Выберите однозначный порядок
Ключ сортировки плюс уникальный столбец в конце:
(created_at, id),(total, id). Оба столбца —NOT NULL: приNULLсравнение строк даёт неизвестный результат, и строка выпадет из выборки.Создайте индекс по тем же столбцам
На живой базе —
CREATE INDEX CONCURRENTLY: обычное построение индекса блокирует запись в таблицу до конца, аCONCURRENTLY— нет, ценой более долгой работы.Перепишите запросы списка
Первая страница —
ORDER BY … LIMIT n + 1, дальше — условие(created_at, id) < ($1, $2)с тем жеORDER BY. Направление сравнения совпадает с направлением сортировки: дляDESC—<, дляASC—>.Поменяйте контракт API
Вместо
pageиoffset—limit,afterиbefore, в ответе —next_cursorиprev_cursor,nullна краях списка. Ограничьтеlimitсверху.Научите клиента листать по курсору
Бесконечная прокрутка и кнопки «вперёд/назад» работают сразу. Переход «на страницу 37» — нет: его придётся убрать или оставить на
OFFSETдля первых страниц.
Как проверить: тесты, запросы к API и вставка между страницами
Тест поднимает обработчик на свободном порту, заполняет тестовую базу 45 заказами — по три на одно время, с микросекундами — и проверяет главное: обход вперёд и назад даёт каждую строку один раз в правильном порядке, а новые заказы не сдвигают уже начатый обход.
import assert from "node:assert/strict";
import { once } from "node:events";
import { after, before, test } from "node:test";
import pg from "pg";
import { createServer, decodeCursor } from "./orders-api.js";
// отдельная тестовая база: TEST_DATABASE_URL=postgres://postgres:test@localhost:5432/shop_test
const pool = new pg.Pool({ connectionString: process.env.TEST_DATABASE_URL });
let server;
let baseUrl;
before(async () => {
await pool.query("DROP TABLE IF EXISTS orders");
await pool.query(`CREATE TABLE orders (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
created_at timestamptz NOT NULL DEFAULT now(),
customer text NOT NULL,
total numeric(12, 2) NOT NULL)`);
await pool.query("CREATE INDEX orders_created_at_id_idx ON orders (created_at, id)");
// 45 заказов: по три на одно время, время с микросекундами
await pool.query(`INSERT INTO orders (created_at, customer, total)
SELECT timestamptz '2026-10-01 12:00:00.123456+00' + (g % 15) * interval '1.000001 second',
'customer-' || g, 100 + g
FROM generate_series(1, 45) AS g`);
server = createServer(pool).listen(0);
await once(server, "listening");
baseUrl = `http://localhost:${server.address().port}/api/orders`;
});
after(async () => {
server.close();
await pool.end();
});
async function get(params) {
const res = await fetch(`${baseUrl}?${new URLSearchParams(params)}`);
return { status: res.status, body: await res.json() };
}
async function idsInOrder() {
const { rows } = await pool.query("SELECT id FROM orders ORDER BY created_at DESC, id DESC");
return rows.map((row) => row.id);
}
const ids = (page) => page.items.map((order) => order.id);
test("вперёд по next_cursor: каждая строка ровно один раз и по порядку", async () => {
const seen = [];
let page = (await get({ limit: 10 })).body;
seen.push(...ids(page));
while (page.next_cursor) {
page = (await get({ limit: 10, after: page.next_cursor })).body;
seen.push(...ids(page));
}
assert.deepEqual(seen, await idsInOrder());
assert.match(decodeCursor((await get({ limit: 10 })).body.next_cursor).createdAt, /\.\d{6}\+00$/);
});
test("назад по prev_cursor: те же страницы в обратном порядке", async () => {
const forward = [];
let page = (await get({ limit: 10 })).body;
forward.push(ids(page));
while (page.next_cursor) {
page = (await get({ limit: 10, after: page.next_cursor })).body;
forward.push(ids(page));
}
const backward = [];
while (page.prev_cursor) {
page = (await get({ limit: 10, before: page.prev_cursor })).body;
backward.unshift(ids(page));
}
assert.deepEqual(backward, forward.slice(0, -1));
assert.equal(page.prev_cursor, null); // дошли до первой страницы
});
test("новые заказы не сдвигают следующую страницу, а OFFSET даёт дубли", async () => {
const expected = await idsInOrder();
const first = (await get({ limit: 10 })).body;
await pool.query("INSERT INTO orders (customer, total) SELECT 'customer-new', 500 FROM generate_series(1, 5)");
const second = (await get({ limit: 10, after: first.next_cursor })).body;
assert.deepEqual(ids(second), expected.slice(10, 20));
const { rows } = await pool.query("SELECT id FROM orders ORDER BY created_at DESC, id DESC LIMIT 10 OFFSET 10");
const onFirstPage = new Set(ids(first));
assert.equal(rows.filter((row) => onFirstPage.has(row.id)).length, 5);
});
test("битый курсор и неверный limit — 400", async () => {
const forged = Buffer.from(JSON.stringify(["2026-10-01", "1; DROP TABLE orders"])).toString("base64url");
for (const params of [{ after: "abc" }, { before: forged }, { limit: 1000 }, { after: "a", before: "b" }]) {
const { status, body } = await get(params);
assert.equal(status, 400, JSON.stringify(params));
assert.equal(body.title, "Bad Request");
}
});$ TEST_DATABASE_URL=postgres://postgres:test@localhost:5432/shop_test node --test
✔ вперёд по next_cursor: каждая строка ровно один раз и по порядку (97.8309ms)
✔ назад по prev_cursor: те же страницы в обратном порядке (122.9019ms)
✔ новые заказы не сдвигают следующую страницу, а OFFSET даёт дубли (31.1297ms)
✔ битый курсор и неверный limit — 400 (60.7639ms)
ℹ tests 4
ℹ suites 0
ℹ pass 4
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 475.9961Запросы к работающему API на стенде с миллионом строк. Первая страница, следующая по next_cursor и битый курсор:
$ curl -s "localhost:3000/api/orders?limit=2"
{"items":[{"id":"964223","created_at":"2026-08-20 11:33:00+00","customer":"customer-4223","total":"21507.67"},{"id":"630889","created_at":"2026-08-20 11:33:00+00","customer":"customer-889","total":"396.33"}],"next_cursor":"WyIyMDI2LTA4LTIwIDExOjMzOjAwKzAwIiwiNjMwODg5Il0","prev_cursor":null}
$ curl -s "localhost:3000/api/orders?limit=2&after=WyIyMDI2LTA4LTIwIDExOjMzOjAwKzAwIiwiNjMwODg5Il0"
{"items":[{"id":"297555","created_at":"2026-08-20 11:33:00+00","customer":"customer-2555","total":"9285.00"},{"id":"928444","created_at":"2026-08-20 11:32:00+00","customer":"customer-3444","total":"9581.33"}],"next_cursor":"WyIyMDI2LTA4LTIwIDExOjMyOjAwKzAwIiwiOTI4NDQ0Il0","prev_cursor":"WyIyMDI2LTA4LTIwIDExOjMzOjAwKzAwIiwiMjk3NTU1Il0"}
$ curl -s -i "localhost:3000/api/orders?after=oops"
HTTP/1.1 400 Bad Request
content-type: application/problem+json
…
Transfer-Encoding: chunked
{"title":"Bad Request","status":400,"detail":"Курсор повреждён"}Тот же эффект со вставкой — прямо в SQL на миллионе строк. Транзакция в конце откатывается, данные стенда не меняются:
BEGIN;
-- страница 1, которую видит пользователь
CREATE TEMP TABLE page1 AS
SELECT id, created_at FROM orders ORDER BY created_at DESC, id DESC LIMIT 20;
-- пока он читает, появились 5 новых заказов
INSERT INTO orders (customer, total)
SELECT 'customer-new', 500 FROM generate_series(1, 5);
-- страница 2 через OFFSET: сколько строк повторяют страницу 1
SELECT count(*) AS offset_duplicates
FROM (SELECT id FROM orders ORDER BY created_at DESC, id DESC LIMIT 20 OFFSET 20) AS page2
WHERE id IN (SELECT id FROM page1);
-- страница 2 через keyset от последней строки страницы 1
SELECT count(*) AS keyset_duplicates
FROM (SELECT id FROM orders
WHERE (created_at, id) < (SELECT created_at, id FROM page1 ORDER BY created_at, id LIMIT 1)
ORDER BY created_at DESC, id DESC LIMIT 20) AS page2
WHERE id IN (SELECT id FROM page1);
ROLLBACK;$ psql -d shop -f sql/05-stability.sql
BEGIN
SELECT 20
INSERT 0 5
offset_duplicates
-------------------
5
(1 row)
keyset_duplicates
-------------------
0
(1 row)
ROLLBACKОграничения: когда OFFSET всё же нормален
`OFFSET` подходит
- Небольшие таблицы и неглубокие страницы: несколько сотен пропущенных строк база переберёт незаметно
- Нужен переход на произвольную страницу и номера страниц в интерфейсе
- Данные почти не меняются: справочники, архивы, отчёты за закрытый период
Нужен keyset
- Ленты, журналы, заказы, сообщения — данные растут, листают далеко
- Бесконечная прокрутка в мобильном приложении или на сайте
- Выгрузка всей таблицы пачками для интеграции или фоновой задачи
- Сортировка по другому полю — свой ключ и свой индекс: для «по сумме» это
(total, id), условие(total, id) > ($1, $2)и сумма в курсоре вместо даты. - Сравнение строк работает, когда все столбцы сортируются в одну сторону. Для смешанного порядка (
created_at DESC, id ASC) условие пишут развёрнуто —created_at < $1 OR (created_at = $1 AND id > $2)— и создают индекс с теми же направлениями. - Курсор не секретный: клиент может его раскодировать и подделать. Не кладите в него фильтры доступа или идентификатор пользователя без подписи — права проверяются в запросе на сервере.
- Если граничную строку удалили, курсор продолжает работать: условие сравнивает значения, а не ищет саму строку.
- Общее число строк keyset не даёт.
count(*)по большой таблице — отдельный дорогой запрос; часто достаточно признака «есть ещё».
Ссылки и следующий шаг
- «Индексы: как ускорить запрос и не замедлить запись» — как устроен B-tree и почему порядок столбцов в составном индексе важен.
- «Оптимизация запросов: EXPLAIN ANALYZE на практике» — как читать планы вроде тех, что выше, и находить медленные запросы.
- «Проектирование API: ресурсы, версии, ошибки, пагинация» — контракт курсорной пагинации, формат ошибок и описание в OpenAPI.
- Официальные источники — ниже: разделы документации PostgreSQL о
LIMIT/OFFSET, сравнении строк и индексах дляORDER BY, документация node-postgres и статья Маркуса Винанда о методе seek.
Как проверено5 · пройдено1 · частичносверено 12 октября 2026 г.
- запущено, работает
sql/01-schema.sql, 02-offset.sql, 03-keyset.sql, 04-index.sql и вывод EXPLAIN (ANALYZE, BUFFERS, COSTS OFF) в статье
docker postgres:17-alpine (17.11), контейнер kb-pg-recept-sql-paginaciya (--rm): 1 000 000 строк, 333 334 различных created_at; OFFSET и keyset выполнены до и после индекса, с индексом — по 3 прогона. Время: OFFSET без индекса 179,674 мс, keyset без индекса 24,751 мс, OFFSET с индексом 175,853 / 241,203 / 191,548 мс, keyset с индексом 0,266 / 0,220 / 0,195 мс. В статье — вывод третьего прогона без изменений, кроме строки «(N rows)». Скрипты подавались через `docker exec -i … psql < файл` (в статье показано как `psql -d shop -f`)
- запущено, работает
эквивалентность страниц keyset и OFFSET на 1 млн строк (следующая и предыдущая)
checks.sql: keyset после границы ('2026-04-26 17:47:00+00', 166667) = OFFSET 500000 LIMIT 20 по порядку (0 расхождений); «назад» от первой строки страницы 25 001 (> + ASC + разворот) = OFFSET 499980 LIMIT 20 (0 расхождений); план «назад» — Index Scan с Index Cond, 0,057 мс; таблица 65 MB, индекс 30 MB
- запущено, работает
sql/05-stability.sql (вставка между страницами)
на том же стенде: offset_duplicates = 5, keyset_duplicates = 0, транзакция откатывается; EN-версия скриптов (отличаются только комментарии) прогнана в отдельной базе shop_en — тот же результат (5 / 0), EXPLAIN с тем же планом
- запущено, работает
api/orders-api.js, server.js, orders-api.test.js — RU и EN-копия с английскими строками
Node.js 24.18.0 (хост), pg 8.23.1 (npm view pg version = 8.23.1): `node --test` против базы shop_test в том же контейнере — 4/4 (обход вперёд, назад, вставка между страницами + сравнение с OFFSET, 400 на битый курсор/limit) в RU и EN. Негативная проверка: копия с парсером Date по умолчанию — тест обхода вперёд падает (пропущены строки с одинаковым временем), что подтверждает пункт о микросекундах. В статье команда показана с портом 5432, в прогоне — проброшенный 55461
- запущено, работает
curl-сессия к работающему API на 1 млн строк
server.js в node:24-alpine (Node 24.20.0) в сетевом пространстве контейнера PostgreSQL, порт 3000; curl из curlimages/curl:8.17.0; RU и EN; строки Date и keep-alive заменены на «…»
- частично
решение упражнения (сортировка по total)
проверен только план: индекс (total, id) и `WHERE (total, id) > (5000.00, 12345) ORDER BY total, id LIMIT 21` — Index Scan using orders_total_id_idx, Index Cond как в статье; код обработчика с параметром sort не писался — решение дано текстом
«Запущено» — код выполнен во временном проекте или контейнере, вывод в статье взят из прогона. «Частично» — проверено с заглушками вместо внешних сервисов. «Не запускалось» — нужны устройство, аккаунт или ключ; шаги сверены с официальной документацией. Файлы проверочного проекта и логи запусков сохранены в архиве InterLAB.