Как делать12 октября 2026 г.6 мин чтения
SQL и PostgreSQLШаг 9 из 9

Рецепт: keyset-пагинация в SQL

Коротко

Keyset-пагинация (seek method) выбирает следующую страницу условием «всё, что после последней показанной строки»: WHERE (created_at, id) < ($1, $2) ORDER BY created_at DESC, id DESC LIMIT 21. С составным индексом по (created_at, id) база читает только строки страницы, поэтому скорость не зависит от глубины, а новые записи не сдвигают страницы. Цена — нельзя перейти сразу на страницу N: клиент получает курсор и листает от него.

Рецепт кодаУровень: СреднийРецепты для баз данныхПрактика: ~20–40 мин

После материала вы сможете

  • заменить 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 г.

Содержание
  1. 01Задача: почему OFFSET тормозит и «прыгает»
  2. 02Готовый SQL: условие по паре (created_at, id) и индекс
  3. 03Курсор в API: обработчик на Node.js
  4. 04Как подключить
  5. 05Как проверить: тесты, запросы к API и вставка между страницами
  6. 06Ограничения: когда OFFSET всё же нормален
  7. 07Ссылки и следующий шаг

Задача: почему OFFSET тормозит и «прыгает»

Классическая пагинация — LIMIT 20 OFFSET 40: пропустить 40 строк и отдать 20. У неё две проблемы. Первая — скорость: документация PostgreSQL прямо предупреждает, что строки, пропущенные через OFFSET, всё равно вычисляются на сервере, поэтому большой OFFSET неэффективен. Чтобы отдать страницу 25 001, базе придётся пройти полмиллиона строк. Вторая — стабильность: если между запросами страниц появилась новая запись, всё съезжает на одну позицию, и пользователь видит на второй странице строки с первой.

Стенд — таблица заказов на миллион строк в PostgreSQL 17. По три заказа на каждую минуту: время не уникально, а id не совпадает с порядком по времени — как в реальных данных, где записи импортируют пачками.

sql/01-schema.sql
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 выбросить.

terminal
$ 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
реальный вывод; COSTS OFF убирает оценки планировщика, чтобы план было проще читать

Насколько велика разница, мы измерили отдельно на той же схеме: таблица из 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.

sql/04-index.sql
-- один 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:

sql/03-keyset.sql
-- та же страница 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;
граница — последняя строка страницы 25 000; LIMIT 21 вместо 20: лишняя строка показывает, есть ли следующая страница
terminal
$ 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: первая страница, следующая и предыдущая. Для предыдущей идём от первой строки текущей страницы вверх по возрастанию, а приложение потом разворачивает результат:

SQL
-- первая страница
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.

orders-api.js
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, "Внутренняя ошибка сервера");
    }
  });
}
server.js
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 предупреждает: в JavaScript Date хранит миллисекунды, и микросекунды из PostgreSQL обрезаются. Курсор с обрезанным временем указывает не на ту строку: в нашем тесте с Date обход терял часть заказов.
  • Курсор проверяется до запроса: base64url → JSON → время по шаблону и id из цифр. Повреждённый курсор даёт 400, а значения уходят в запрос только параметрами $1, $2, а не склейкой строк.
  • id типа bigint и numeric драйвер отдаёт строками — так JSON не теряет точность на больших числах.
  • Запрос просит limit + 1 строк. Если пришла лишняя, следующая страница есть; лишнюю строку клиенту не отдаём.

Как подключить

  1. Выберите однозначный порядок

    Ключ сортировки плюс уникальный столбец в конце: (created_at, id), (total, id). Оба столбца — NOT NULL: при NULL сравнение строк даёт неизвестный результат, и строка выпадет из выборки.

  2. Создайте индекс по тем же столбцам

    На живой базе — CREATE INDEX CONCURRENTLY: обычное построение индекса блокирует запись в таблицу до конца, а CONCURRENTLY — нет, ценой более долгой работы.

  3. Перепишите запросы списка

    Первая страница — ORDER BY … LIMIT n + 1, дальше — условие (created_at, id) < ($1, $2) с тем же ORDER BY. Направление сравнения совпадает с направлением сортировки: для DESC — <, для ASC — >.

  4. Поменяйте контракт API

    Вместо page и offset — limit, after и before, в ответе — next_cursor и prev_cursor, null на краях списка. Ограничьте limit сверху.

  5. Научите клиента листать по курсору

    Бесконечная прокрутка и кнопки «вперёд/назад» работают сразу. Переход «на страницу 37» — нет: его придётся убрать или оставить на OFFSET для первых страниц.

Как проверить: тесты, запросы к API и вставка между страницами

Тест поднимает обработчик на свободном порту, заполняет тестовую базу 45 заказами — по три на одно время, с микросекундами — и проверяет главное: обход вперёд и назад даёт каждую строку один раз в правильном порядке, а новые заказы не сдвигают уже начатый обход.

orders-api.test.js
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");
  }
});
terminal
$ 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
встроенный тест-раннер Node.js; база — postgres:17-alpine в Docker

Запросы к работающему API на стенде с миллионом строк. Первая страница, следующая по next_cursor и битый курсор:

terminal
$ 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 на миллионе строк. Транзакция в конце откатывается, данные стенда не меняются:

sql/05-stability.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;
terminal
$ 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 повторил на второй странице 5 строк с первой; keyset — ни одной

Ограничения: когда 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.

Расскажите о задаче — мы изучим запрос и свяжемся с вами

Есть задача?
Обсудим и оценим — бесплатно.

Расскажите о продукте или проблеме. Предложим архитектуру, срок и стоимость первой версии после изучения задачи.

  • Оценка объёма и рисков до старта
  • NDA — по вашему или нашему шаблону
  • Старт работ в течение недели

Не присылайте через форму пароли, ключи доступа и исходный код — безопасную передачу доступа обсудим отдельно.

Нажимая кнопку, вы соглашаетесь с обработкой персональных данных.