Исследования12 октября 2026 г.9 мин чтения

Пагинация OFFSET и keyset в PostgreSQL 17: как время ответа зависит от глубины страницы

Коротко

На таблице из 1 000 000 строк с индексом (created_at, id) серверное время OFFSET росло вместе с числом пропущенных строк: медиана 5,845 мс на странице 1 000, 48,25 мс на странице 10 000 и 243,1 мс на последней, 50 000-й. Keyset на тех же страницах держался в пределах 0,034–0,059 мс по медиане и читал 23 буфера на любой глубине. Это одна машина, Docker на Windows и данные в кэше — форма зависимости и порядок величин, а не прогноз для вашего сервера.

Исследование InterLAB · R-01Замеры: 12 октября 2026 г.
СУБД
PostgreSQL 17.11 (Docker, postgres:17-alpine)
Данные
1 000 000 синтетических строк, индекс (created_at, id)
Замеры
3 прогона × 10 раундов: 300 замеров вариантов + 30 ping; 60 строк прогрева вне сводки
Среда
Одна машина, Docker Desktop (WSL2) на Windows, данные в кэше

Сырые данные и код стенда открыты · К файлам ↓

Содержание
  1. 01Вопрос и гипотеза
  2. 02Стенд и метод
  3. 03Результаты
  4. 04Интерпретация
  5. 05Ограничения
  6. 06Как повторить
  7. 07Что это значит для продукта
  8. 08Следующий шаг

Вопрос и гипотеза

В рецепте «Keyset-пагинация в SQL» один EXPLAIN на странице 25 001 показывает, почему OFFSET медленный: база перебирает все пропущенные строки. Одной точки мало, чтобы решить, где проходит граница «ещё нормально». Это исследование отвечает на другой вопрос: как время получения одной страницы из 20 строк зависит от её номера — для LIMIT … OFFSET и для keyset-пагинации по паре (created_at, id) на одной и той же таблице в PostgreSQL 17.

  • Вариант offset: ORDER BY created_at DESC, id DESC LIMIT 20 OFFSET $1, где $1 = (N − 1) × 20.
  • Вариант keyset: страница 1 — тот же запрос без OFFSET; дальше — WHERE (created_at, id) < ($1, $2) с тем же ORDER BY и LIMIT 20. Граница — последняя строка предыдущей страницы, её вычисляют один раз до замеров.
  • Глубины: страницы 1, 100, 1 000, 10 000 и 50 000. Страница 50 000 — последняя: перед ней 999 980 строк.

Критерии честного сравнения тоже зафиксированы заранее: одна таблица, один индекс, одно соединение и одинаковые настройки сервера; прогрев, который не входит в итог; перемешанный порядок глубин и чередование вариантов; проверка, что оба варианта вернули одни и те же 20 строк. Протокол, файлы стенда и сырые замеры (raw.csv) хранятся вместе; ниже — полный код, который получает такие же замеры заново.

Стенд и метод

Сервер

Значение
PostgreSQL 17.11, образ postgres:17-alpine, контейнер --cpus=2 --memory=2g

Настройки

Значение
shared_buffers 128MB, work_mem 4MB, random_page_cost 4, max_parallel_workers_per_gather 2 — по умолчанию; jit=off — единственное изменение

Клиент

Значение
Node.js 24.18.0, pg 8.23.1, одно соединение, параметры через $n

Машина

Значение
Intel Core i7-14700 (20 ядер, 28 потоков), 63,7 ГБ ОЗУ, Windows 11 Pro 10.0.26200, Docker 29.6.2 (Docker Desktop, WSL2)

Данные

Значение
1 000 000 заказов, таблица 65 MB, индекс (created_at, id) 30 MB; генерация с setseed(0.42), контрольная сумма одинакова во всех прогонах
окружение из env.txt; все значения настроек — из SHOW на работающем сервере

Схема и распределение времени — как в рецепте: по три заказа на каждую минуту, а порядок id не совпадает с порядком created_at. Значит, соседние по индексу строки лежат в разных страницах таблицы — так бывает, когда записи импортируют пачками. JIT выключен (jit=off) для страховки: JIT-компиляция включается по оценке стоимости запроса и добавила бы ко времени своё. В планах этого стенда самая высокая оценка — 63 748, ниже порога jit_above_cost по умолчанию (100 000), так что и с настройками по умолчанию JIT здесь бы не сработал.

  1. Свежий контейнер и данные

    Каждый прогон начинается с нового контейнера: setup.sql создаёт таблицу, индекс, выполняет VACUUM (ANALYZE) и печатает контрольную сумму.

  2. Границы и эталон

    До замеров скрипт находит граничные строки для keyset и запоминает эталонные id каждой страницы. Это не входит в измерение.

  3. Прогрев

    Каждый из десяти запросов (2 варианта × 5 глубин) выполняется дважды. Эти строки остаются в raw.csv с phase=warmup, но в сводку не входят.

  4. 10 раундов

    В начале раунда — SELECT 1 для оценки накладных расходов соединения. Порядок глубин перемешивается генератором с фиксированным seed, порядок вариантов чередуется от раунда к раунду.

  5. Две метрики на замер

    Сначала EXPLAIN (ANALYZE, BUFFERS, TIMING OFF, FORMAT JSON) — серверное время и буферы, затем тот же запрос без EXPLAIN — время в Node.js вокруг client.query() и сверка строк с эталоном.

  6. Три прогона

    Цикл повторён на трёх свежих контейнерах: 30 замеров на каждую точку. Сводка — медиана, p25/p75 и min/max.

TIMING OFF выбран намеренно. Документация EXPLAIN предупреждает, что поузловой замер времени заметно замедляет запрос на некоторых системах, а общее время выполнения измеряется и без него. Для OFFSET это важно: через узел проходит до миллиона строк. Серверное время не включает передачу результата клиенту — об этом прямо сказано в разделе об EXPLAIN ANALYZE. Клиентское время, наоборот, включает сеть между Windows и виртуальной машиной Docker, драйвер и разбор 20 строк.

Результаты

243,1 мс

медиана серверного времени OFFSET на странице 50 000 (999 980 пропущенных строк)

0,059 мс

медиана keyset на той же странице

23

буфера читает keyset на любой глубине; OFFSET на последней странице — 1 007 665

1

Пропущено строк
0
OFFSET
0,037 [0,033–0,043]
keyset
0,042 [0,036–0,049]
Буферы OFFSET
23
Буферы keyset
23

100

Пропущено строк
1 980
OFFSET
0,660 [0,587–0,986]
keyset
0,034 [0,029–0,040]
Буферы OFFSET
2 019
Буферы keyset
23

1 000

Пропущено строк
19 980
OFFSET
5,845 [5,414–7,148]
keyset
0,038 [0,033–0,049]
Буферы OFFSET
20 157
Буферы keyset
23

10 000

Пропущено строк
199 980
OFFSET
48,25 [42,78–71,58] (40,95–111,5)
keyset
0,044 [0,038–0,055]
Буферы OFFSET
201 535
Буферы keyset
23

50 000

Пропущено строк
999 980
OFFSET
243,1 [215,5–281,4] (200,5–503,3)
keyset
0,059 [0,040–0,066]
Буферы OFFSET
1 007 665
Буферы keyset
23
серверное время Execution Time, мс: медиана [p25–p75], для двух глубоких точек — также (min–max); 30 замеров на точку, данные в кэше (shared_read = 0 во всех замерах)

1

OFFSET
0,486 [0,415–0,587]
keyset
0,448 [0,412–0,559]

100

OFFSET
1,031 [0,961–1,402]
keyset
0,518 [0,476–0,604]

1 000

OFFSET
5,361 [4,990–6,814]
keyset
0,530 [0,505–0,582]

10 000

OFFSET
46,84 [43,20–58,75]
keyset
0,506 [0,465–0,678]

50 000

OFFSET
239,9 [211,9–307,9] (195,3–378,8)
keyset
0,601 [0,537–0,681]
клиентское время в Node.js (performance.now() вокруг client.query()), мс: медиана [p25–p75]

Все 300 замеров вариантов вернули ровно те же 20 строк, что и эталон. SELECT 1 в том же соединении занимал 0,427 мс по медиане [0,342–0,504] — это нижняя граница клиентского времени на этом стенде. Между прогонами абсолютные значения глубокого OFFSET заметно плавали: медиана на странице 50 000 — 222,4, 272,7 и 245,1 мс в прогонах 1, 2 и 3.

Интерпретация

Гипотеза подтвердилась по форме. Число пропущенных строк выросло в 10 раз между страницами 1 000 и 10 000 — медиана OFFSET выросла с 5,845 до 48,25 мс; ещё в 5 раз до страницы 50 000 — до 243,1 мс. Буферы растут ещё ровнее: 20 157 при 19 980 пропущенных строках, 201 535 при 199 980, 1 007 665 при 999 980 — примерно одно обращение к странице таблицы на каждую строку.

Причина видна в плане. Даже на последней странице планировщик выбирал Index Scan Backward по индексу (created_at, id): проходил индекс от конца и для каждой строки шёл в таблицу, чтобы потом выбросить её — в explain-r1-offset-p50000.txt узел сканирования вернул 1 000 000 строк ради последних 20. Документация PostgreSQL описывает ровно это: строки, пропущенные через OFFSET, всё равно вычисляются на сервере. Индекс избавляет от сортировки, но не от перебора.

Keyset работает иначе: условие (created_at, id) < ($1, $2) попадает в Index Cond, и B-tree сразу спускается к границе. Поэтому 23 буфера на странице 1 и на странице 50 000 одинаковы. На последней странице в прогонах 2 и 3 планировщик выбрал другой план — Bitmap Heap Scan с сортировкой 20 строк вместо Index Scan Backward в прогоне 1. Документация ANALYZE объясняет, откуда разница: статистика строится по случайной выборке и немного меняется от запуска к запуску, а в редких случаях меняет и план. На время это почти не повлияло: медианы прогонов — 0,031, 0,068 и 0,063 мс.

На клиенте картина другая. Для keyset время в Node.js (0,448–0,601 мс по медианам) почти целиком состоит из накладных расходов соединения: серверная часть — 0,034–0,059 мс против 0,427 мс у SELECT 1. Для OFFSET на страницах 1 000 и глубже доминирует работа базы, и клиентские медианы близки к серверным. На странице 100 разница между вариантами — 1,031 против 0,518 мс на клиенте: для одного запроса пользователь её не заметит.

Ограничения

  • Одна машина и Docker Desktop на Windows (WSL2). Параллельно работали другие контейнеры и dev-сервер сайта — фоновую нагрузку не останавливали, она отражена в разбросе.
  • Данные целиком в shared_buffers: после прогрева чтений с диска не было. Холодный кэш, медленный диск и таблицы больше памяти дадут другие абсолютные числа — это не измерялось.
  • Одно соединение без конкурентной нагрузки. Как ведут себя оба варианта, когда параллельно идут десятки запросов, этот замер не показывает.
  • Синтетическое распределение: порядок строк в таблице не совпадает с порядком индекса. При другом распределении, другой ширине строк или покрывающем индексе абсолютные числа OFFSET будут другими; линейный рост числа перебираемых строк от этого не исчезает — так устроен OFFSET.
  • 30 замеров на точку из трёх прогонов — это медиана и разброс, а не статистическая значимость. Медианы глубокого OFFSET заметно отличались между прогонами: на странице 50 000 — от 222,4 до 272,7 мс.
  • Числа из рецепта «Keyset-пагинация в SQL» (один EXPLAIN на странице 25 001) с этой таблицей напрямую не сравнивайте: там другой режим замера — поузловой TIMING включён — и другие условия запуска. Сравнима форма: OFFSET перебирает пропущенные строки, keyset — нет.
  • До финального цикла было два отладочных прогона (проверка скрипта); их результаты не публикуются. В raw.csv — все замеры трёх финальных прогонов, без отбора.

Как повторить

Нужны Docker, Node.js 24 и свободный порт 55417. Положите четыре файла ниже в одну папку, рядом — package.json с зависимостью "pg": "8.23.1" и "type": "module". Скрипт run.sh делает три прогона, каждый на свежем контейнере, и в конце печатает сводку:

terminal
$ cd paginaciya-offset-keyset-postgresql   # папка стенда
$ npm install                              # pg 8.23.1
$ bash run.sh                              # 3 прогона
NOTICE:  table "orders" does not exist, skipping
 1000000 | 48c42fd798489f22b2847d4427ff3f80 | 65 MB      | 30 MB
(1 row)

run 1: 2026-10-12T13:41:31Z
…
run 3: 2026-10-12T13:42:10Z
…
runs=1,2,3; rows measure=330, warmup=60; rows_match=0: 0
реальный вывод запуска 12.10.2026, середина сокращена; сводка целиком совпадает с таблицами выше
bash
docker run --rm -d --name kb-g-research --cpus=2 --memory=2g \
  -e POSTGRES_PASSWORD=bench -e POSTGRES_DB=bench -p 55417:5432 \
  postgres:17-alpine -c jit=off
docker exec kb-g-research pg_isready -U postgres -h 127.0.0.1
docker exec -i kb-g-research psql -U postgres -d bench -v ON_ERROR_STOP=1 < setup.sql
RUN=1 node bench.mjs > raw-1.csv
RUN=1 node bench.mjs --explain
node summarize.mjs raw-1.csv
docker stop kb-g-research
один прогон вручную — то же, что делает run.sh в цикле; в PowerShell перенаправление < setup.sql заменяет Get-Content setup.sql -Raw |
setup.sql
-- R-01 стенд: 1 000 000 синтетических заказов, индекс (created_at, id).
-- Запуск: docker exec -i kb-g-research psql -U postgres -d bench -v ON_ERROR_STOP=1 < setup.sql
-- Схема и формула created_at — как в рецепте recept-sql-paginaciya:
-- по три заказа на каждую минуту, порядок id не совпадает с порядком времени.

DROP TABLE IF EXISTS orders;

CREATE TABLE orders (
    id         bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    created_at timestamptz    NOT NULL,
    customer   text           NOT NULL,
    total      numeric(12, 2) NOT NULL
);

-- фиксированный seed: random() ниже даёт одинаковые значения при каждом запуске
SELECT setseed(0.42);

INSERT INTO orders (created_at, customer, total)
SELECT timestamptz '2026-01-01 00:00:00+00' + ((g::bigint * 7919) % 333334) * interval '1 minute',
       'customer-' || (floor(random() * 5000))::int,
       round((100 + random() * 30000)::numeric, 2)
FROM generate_series(1, 1000000) AS g;

CREATE INDEX orders_created_at_id_idx ON orders (created_at, id);

VACUUM (ANALYZE) orders;

-- контрольная сумма данных: одинакова при повторной генерации
SELECT count(*) AS rows,
       md5(string_agg(id || '|' || created_at::text || '|' || customer || '|' || total, ',' ORDER BY id)) AS checksum,
       pg_size_pretty(pg_relation_size('orders')) AS table_size,
       pg_size_pretty(pg_relation_size('orders_created_at_id_idx')) AS index_size
FROM orders;
bench.mjs
/*
  R-01 bench: OFFSET vs keyset в PostgreSQL 17 — время ответа по глубине страницы.

    npm install                                   # pg из package.json
    RUN=1 node bench.mjs          > raw-1.csv     # замеры одного прогона (прогрев + 10 раундов)
    RUN=1 node bench.mjs --explain                # explain-r1-*.txt для p10000 и p50000
    node bench.mjs --settings                     # версия сервера и настройки (в env.txt)
  Полный цикл из трёх прогонов на свежих контейнерах — run.sh.

  DATABASE_URL по умолчанию: postgres://postgres:bench@localhost:55417/bench
*/
import { writeFileSync } from "node:fs";
import { performance } from "node:perf_hooks";
import pg from "pg";

const DB_URL = process.env.DATABASE_URL ?? "postgres://postgres:bench@localhost:55417/bench";
const PAGE = 20;
const DEPTHS = [1, 100, 1000, 10000, 50000];
const ROUNDS = Number(process.env.ROUNDS ?? 10);
const WARMUP = 2;
const SEED = 20261012;
const RUN = Number(process.env.RUN ?? 1);

// timestamptz (OID 1184) — строкой: Date обрезает микросекунды, граница курсора съехала бы
const types = { getTypeParser: (oid, f) => (oid === 1184 ? (v) => v : pg.types.getTypeParser(oid, f)) };

const COLS = "id, created_at, customer, total";
const ORDER = "ORDER BY created_at DESC, id DESC";
const SQL = {
  offset: `SELECT ${COLS} FROM orders ${ORDER} LIMIT ${PAGE} OFFSET $1`,
  first: `SELECT ${COLS} FROM orders ${ORDER} LIMIT ${PAGE}`,
  keyset: `SELECT ${COLS} FROM orders WHERE (created_at, id) < ($1::timestamptz, $2::bigint) ${ORDER} LIMIT ${PAGE}`,
};

// запрос и параметры для варианта и глубины; граница keyset вычислена заранее
function query(variant, page, cursors) {
  if (variant === "offset") return [SQL.offset, [(page - 1) * PAGE]];
  if (page === 1) return [SQL.first, []];
  const c = cursors.get(page);
  return [SQL.keyset, [c.created_at, c.id]];
}

// детерминированный ГПСЧ для перемешивания порядка глубин (seed свой у каждого прогона)
function mulberry32(a) {
  return () => {
    a |= 0; a = (a + 0x6d2b79f5) | 0;
    let t = Math.imul(a ^ (a >>> 15), 1 | a);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}
function shuffled(arr, rnd) {
  const a = [...arr];
  for (let i = a.length - 1; i > 0; i--) {
    const j = Math.floor(rnd() * (i + 1));
    [a[i], a[j]] = [a[j], a[i]];
  }
  return a;
}

function scanNode(plan) {
  if (/Scan/.test(plan["Node Type"])) return [plan["Node Type"], plan["Scan Direction"], plan["Index Name"]].filter(Boolean).join(" ");
  for (const p of plan.Plans ?? []) {
    const s = scanNode(p);
    if (s) return s;
  }
  return "";
}

const client = new pg.Client({ connectionString: DB_URL, types });
await client.connect();

// граница для keyset: последняя строка страницы N−1; эталонные id страницы N (через OFFSET)
const cursors = new Map();
const reference = new Map();
for (const page of DEPTHS) {
  if (page > 1) {
    const { rows } = await client.query(`SELECT created_at, id FROM orders ${ORDER} LIMIT 1 OFFSET $1`, [(page - 1) * PAGE - 1]);
    cursors.set(page, rows[0]);
  }
  const { rows } = await client.query(SQL.offset, [(page - 1) * PAGE]);
  reference.set(page, rows.map((r) => r.id).join(","));
}

if (process.argv.includes("--settings")) {
  const out = [];
  out.push((await client.query("SELECT version()")).rows[0].version);
  for (const s of ["shared_buffers", "work_mem", "effective_cache_size", "random_page_cost", "jit", "max_parallel_workers_per_gather", "max_worker_processes", "TimeZone"]) {
    out.push(`${s} = ${Object.values((await client.query(`SHOW ${s}`)).rows[0])[0]}`);
  }
  const size = await client.query(`SELECT count(*) AS n, pg_size_pretty(pg_relation_size('orders')) AS t, pg_size_pretty(pg_relation_size('orders_created_at_id_idx')) AS i FROM orders`);
  out.push(`orders: ${size.rows[0].n} rows, table ${size.rows[0].t}, index (created_at, id) ${size.rows[0].i}`);
  for (const [p, c] of cursors) out.push(`keyset cursor for page ${p}: (${c.created_at}, ${c.id})`);
  console.log(out.join("\n"));
  await client.end();
  process.exit(0);
}

if (process.argv.includes("--explain")) {
  for (const page of [10000, 50000]) {
    for (const variant of ["offset", "keyset"]) {
      const [sql, params] = query(variant, page, cursors);
      const { rows } = await client.query(`EXPLAIN (ANALYZE, BUFFERS) ${sql}`, params);
      const text = [`-- ${variant}, page ${page} (page size ${PAGE})`, `-- ${sql}`, `-- params: ${JSON.stringify(params)}`, "", ...rows.map((r) => r["QUERY PLAN"])].join("\n");
      const file = `explain-r${RUN}-${variant}-p${page}.txt`;
      writeFileSync(new URL(file, import.meta.url), `-- run ${RUN}\n${text}\n`);
      console.error(file);
    }
  }
  await client.end();
  process.exit(0);
}

async function measure(variant, page) {
  const [sql, params] = query(variant, page, cursors);
  const ex = await client.query(`EXPLAIN (ANALYZE, BUFFERS, TIMING OFF, FORMAT JSON) ${sql}`, params);
  const plan = ex.rows[0]["QUERY PLAN"][0];
  const t0 = performance.now();
  const res = await client.query(sql, params);
  const clientMs = performance.now() - t0;
  return {
    server_exec_ms: plan["Execution Time"],
    planning_ms: plan["Planning Time"],
    shared_hit: plan.Plan["Shared Hit Blocks"],
    shared_read: plan.Plan["Shared Read Blocks"],
    client_ms: clientMs,
    rows: res.rows.length,
    rows_match: res.rows.map((r) => r.id).join(",") === reference.get(page) ? 1 : 0,
    scan_node: scanNode(plan.Plan),
  };
}

async function ping() {
  const t0 = performance.now();
  await client.query("SELECT 1");
  return performance.now() - t0;
}

const head = ["run", "phase", "round", "seq", "variant", "page", "offset_rows", "server_exec_ms", "planning_ms", "shared_hit", "shared_read", "client_ms", "rows", "rows_match", "scan_node"];
if (!process.env.NO_HEADER) console.log(head.join(","));
let seq = 0;
const emit = (phase, round, variant, page, m) =>
  console.log([RUN, phase, round, ++seq, variant, page, page === 0 ? 0 : (page - 1) * PAGE,
    m.server_exec_ms ?? "", m.planning_ms ?? "", m.shared_hit ?? "", m.shared_read ?? "",
    m.client_ms.toFixed(3), m.rows ?? "", m.rows_match ?? "", m.scan_node ? `"${m.scan_node}"` : ""].join(","));

// прогрев: каждый запрос по WARMUP раз, в медианы не входит
for (let w = 1; w <= WARMUP; w++) {
  for (const page of DEPTHS) for (const variant of ["offset", "keyset"]) emit("warmup", w, variant, page, await measure(variant, page));
}

const rnd = mulberry32(SEED + RUN);
for (let round = 1; round <= ROUNDS; round++) {
  emit("measure", round, "ping", 0, { client_ms: await ping() });
  const variants = round % 2 === 0 ? ["offset", "keyset"] : ["keyset", "offset"];
  for (const page of shuffled(DEPTHS, rnd)) {
    for (const variant of variants) emit("measure", round, variant, page, await measure(variant, page));
  }
}
await client.end();
summarize.mjs
/*
  Сводка по raw.csv:  node summarize.mjs raw.csv > summary.txt
  Только phase=measure. Перцентили — линейная интерполяция между соседними значениями (p25, p50 = медиана, p75).
*/
import { readFileSync } from "node:fs";

const [header, ...lines] = readFileSync(process.argv[2] ?? "raw.csv", "utf8").trim().split(/\r?\n/);
const keys = header.split(",");
const rows = lines.map((l) => Object.fromEntries(l.split(",").map((v, i) => [keys[i], v.replace(/^"|"$/g, "")])));
const m = rows.filter((r) => r.phase === "measure");

const q = (xs, p) => {
  const s = [...xs].sort((a, b) => a - b);
  const i = (s.length - 1) * p;
  const lo = Math.floor(i);
  return s[lo] + (s[Math.min(lo + 1, s.length - 1)] - s[lo]) * (i - lo);
};
const f = (x) => (x >= 100 ? x.toFixed(1) : x >= 10 ? x.toFixed(2) : x.toFixed(3));
const stat = (xs) => `${f(q(xs, 0.5))} [${f(q(xs, 0.25))}–${f(q(xs, 0.75))}] (${f(Math.min(...xs))}–${f(Math.max(...xs))})`;

const runs = [...new Set(m.map((r) => r.run))];
console.log(`runs=${runs.join(",")}; rows measure=${m.length}, warmup=${rows.length - m.length}; rows_match=0: ${m.filter((r) => r.rows_match === "0").length}`);
console.log("формат: медиана [p25–p75] (min–max), мс; буферы — медиана (min–max)\n");
const pings = m.filter((r) => r.variant === "ping").map((r) => +r.client_ms);
console.log(`ping (SELECT 1), client_ms, n=${pings.length}: ${stat(pings)}\n`);
console.log("variant | page | offset_rows | n | server_exec_ms | client_ms | planning_ms | shared_hit | shared_read | scan_node");
for (const page of [...new Set(m.filter((r) => r.variant !== "ping").map((r) => +r.page))].sort((a, b) => a - b)) {
  for (const variant of ["offset", "keyset"]) {
    const g = m.filter((r) => r.variant === variant && +r.page === page);
    if (!g.length) continue;
    const hit = g.map((r) => +r.shared_hit);
    const read = g.map((r) => +r.shared_read);
    console.log([variant, page, g[0].offset_rows, g.length,
      stat(g.map((r) => +r.server_exec_ms)), stat(g.map((r) => +r.client_ms)), stat(g.map((r) => +r.planning_ms)),
      `${q(hit, 0.5)} (${Math.min(...hit)}–${Math.max(...hit)})`, `${q(read, 0.5)} (${Math.min(...read)}–${Math.max(...read)})`,
      [...new Set(g.map((r) => r.scan_node))].join(" / ")].join(" | "));
  }
}

console.log("\nмедианы по прогонам (каждый прогон — свежий контейнер и свежая генерация), мс");
console.log("variant | page | " + runs.map((r) => `run ${r} server`).join(" | ") + " | " + runs.map((r) => `run ${r} client`).join(" | ") + " | scan_node по прогонам");
for (const page of [...new Set(m.filter((r) => r.variant !== "ping").map((r) => +r.page))].sort((a, b) => a - b)) {
  for (const variant of ["offset", "keyset"]) {
    const by = (run) => m.filter((r) => r.variant === variant && +r.page === page && r.run === run);
    console.log([variant, page,
      ...runs.map((run) => f(q(by(run).map((r) => +r.server_exec_ms), 0.5))),
      ...runs.map((run) => f(q(by(run).map((r) => +r.client_ms), 0.5))),
      runs.map((run) => [...new Set(by(run).map((r) => r.scan_node))].join("+")).join(" / ")].join(" | "));
  }
}
run.sh
#!/usr/bin/env bash
# R-01: три независимых прогона, каждый — на свежем контейнере со свежей генерацией данных.
# Запуск из папки исследования (Linux, macOS или Git Bash на Windows):  bash run.sh
# Нужны Docker и Node.js; перед первым запуском — npm install.
set -euo pipefail
cd "$(dirname "$0")"

IMAGE=postgres:17-alpine
NAME=kb-g-research
PORT=55417
RUNS=${RUNS:-3}

host_info() {
  if command -v powershell.exe > /dev/null; then
    powershell.exe -NoProfile -Command '[Console]::OutputEncoding = [Text.Encoding]::UTF8; $c = Get-CimInstance Win32_Processor; $s = Get-CimInstance Win32_ComputerSystem; $o = Get-CimInstance Win32_OperatingSystem; "CPU " + $c.Name + ", cores " + $c.NumberOfCores + ", logical " + $c.NumberOfLogicalProcessors; "RAM " + [math]::Round($s.TotalPhysicalMemory / 1GB, 1) + " GB"; $o.Caption + " " + $o.Version' | tr -d '\r'
  elif command -v lscpu > /dev/null; then
    lscpu | grep 'Model name'; free -g | head -2
  else
    sysctl -n machdep.cpu.brand_string hw.memsize
  fi
}

: > raw.csv
for RUN in $(seq 1 "$RUNS"); do
  docker run --rm -d --name "$NAME" --cpus=2 --memory=2g \
    -e POSTGRES_PASSWORD=bench -e POSTGRES_DB=bench -p "$PORT:5432" \
    "$IMAGE" -c jit=off > /dev/null
  # ждём финальный сервер (временный сервер инициализации слушает только сокет)
  for _ in $(seq 1 60); do
    docker exec "$NAME" pg_isready -U postgres -h 127.0.0.1 > /dev/null 2>&1 && break
    sleep 1
  done
  docker exec -i "$NAME" psql -U postgres -d bench -v ON_ERROR_STOP=1 < setup.sql | tail -n 3
  echo "run $RUN: $(date -u +%FT%TZ)" >&2
  if [ "$RUN" -eq 1 ]; then RUN=$RUN node bench.mjs >> raw.csv; else RUN=$RUN NO_HEADER=1 node bench.mjs >> raw.csv; fi
  RUN=$RUN node bench.mjs --explain
  if [ "$RUN" -eq 1 ]; then
    {
      echo "# R-01 environment, recorded $(date -u +%FT%TZ)"
      echo "## host"
      uname -a
      host_info
      echo "## runtime"
      echo "node $(node -v)"
      echo "pg $(node -p "require('./node_modules/pg/package.json').version")"
      docker version --format 'docker client {{.Client.Version}}, server {{.Server.Version}} ({{.Server.Os}}/{{.Server.Arch}})'
      docker info --format 'docker VM: {{.OperatingSystem}}, kernel {{.KernelVersion}}, {{.NCPU}} CPU, {{.MemTotal}} bytes'
      docker image inspect "$IMAGE" --format 'image {{index .RepoTags 0}} {{.Id}}'
      echo "container limits: --cpus=2 --memory=2g; server flag: -c jit=off"
      echo "## postgres"
      node bench.mjs --settings
    } > env.txt
  fi
  docker stop "$NAME" > /dev/null
done
node summarize.mjs raw.csv > summary.txt
cat summary.txt
для Windows: Git Bash; функция host_info записывает в env.txt процессор, память и ОС

bench.mjs --explain сохраняет текстовые планы EXPLAIN (ANALYZE, BUFFERS) для страниц 10 000 и 50 000, --settings — версию сервера, настройки и границы курсоров. Если контрольная сумма из setup.sql совпала с 48c42fd798489f22b2847d4427ff3f80, у вас те же данные, что и в этом замере, и сравнивать можно буферы один к одному.

Все файлы исследования — данные, код стенда, lock-файл зависимостей и 12 планов EXPLAIN — собраны с размерами в блоке «Файлы исследования» в конце статьи, там же текст для цитирования.

Что это значит для продукта

Если пользователи листают каталог или список заявок на несколько страниц вперёд, OFFSET здесь не узкое место: на странице 100 он стоил 0,660 мс серверного времени. Переделывать такую пагинацию ради скорости смысла нет — номера страниц и переход «на страницу 7» удобнее курсора.

Проблема начинается там, где листают глубоко: бесконечная лента, выгрузка всей таблицы страницами для интеграции или отчёта, публичные списки, которые обходят поисковые роботы и боты с ?page=40000. Каждая следующая страница через OFFSET дороже предыдущей, а один запрос к последней странице на этом стенде занимал сотни миллисекунд. На медленном диске, большей таблице или под нагрузкой число будет другим — его стоит измерить на своём стенде.

Оставить OFFSET

  • Список с номерами страниц, глубина ограничена (например, первые сотни страниц)
  • Небольшие таблицы и редко меняющиеся данные
  • Административные экраны, где важен переход на произвольную страницу

Перейти на keyset

  • Ленты и бесконечная прокрутка
  • Выгрузка всей таблицы пачками в фоне или для интеграции
  • Публичные списки, где глубокие страницы доступны любому

Как цитировать

Цитируйте с адресом и датой публикации. Цифры корректно приводить вместе с условиями: одна машина, Docker на Windows, данные в кэше. Нашли ошибку в методе или данных — напишите на hello@interlab.app.

InterLAB Research. R-01. Пагинация OFFSET и keyset в PostgreSQL 17: как время ответа зависит от глубины страницы. Опубликовано 12.10.2026. https://interlab.app/blog/paginaciya-offset-keyset-postgresql
Сырые данные и код стенда: https://interlab.app/research/paginaciya-offset-keyset-postgresql/raw.csv

Файлы исследования

Все исследования и протокол публикации →
Расскажите о задаче — мы изучим запрос и свяжемся с вами

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

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

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

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

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