Опирается на правила:
R-JOOQ-PAG-1…R-JOOQ-PAG-5иR-JOOQ-PAG-X1…R-JOOQ-PAG-X2из jOOQ Style Guide → раздел 8. Пагинация.
Важно знать
PaginationView<T>— record вcore/domain/repository/с полямиitems,total,count,page,size.- Offset-based:
limit(size).offset(page * size)+ отдельныйfetchCountдля total. Простая, но дорогая на больших offset'ах.- Cursor-based (keyset):
WHERE created_at < ?+limit(size + 1)(для hasNext). Total не считаем — это часть контракта cursor-API.- Cursor на уровне API — opaque token (см.
R-QRY-5в REST API Style Guide). На уровне репозитория — composite-record(createdAt, id).- Total — через
.fetchCount(), не через получение всех записей и.size().fetchAll()без LIMIT в UI-эндпоинте — запрещён. На большой таблице — OOM или таймаут.- Page в репозитории 0-based, в публичном REST API — 1-based (см.
R-QRY-4). Конвертация — на handler-уровне.
Пагинация выглядит просто, пока не наступаешь на грабли: offset на 100k+ медленный (PG читает и выкидывает первые 100k строк), count(*) на большой таблице — sequential scan, cursor без +1 не знает про hasNext. Раскрытие правил R-JOOQ-PAG-* ниже.
PaginationView — domain-record
R-JOOQ-PAG-1: возвращаемый тип — record в core/, не в persistence.
// core/domain/repository/PaginationView.java
public record PaginationView<T>(
List<T> items,
long total,
int count,
int page,
int size
) {
public int getTotalPages() {
return size > 0 ? (int) Math.ceil((double) total / size) : 0;
}
public boolean hasNext() {
return (long) (page + 1) * size < total;
}
public static <T> PaginationView<T> empty(int page, int size) {
return new PaginationView<>(List.of(), 0L, 0, page, size);
}
}
count vs total — частая путаница:
count— сколько элементов в текущей странице (≤size). На последней неполной странице будет меньше.total— сколько элементов всего в выборке (с применённым фильтром). Нужен для подсчётаtotalPages.
Это domain-тип, потому что handler возвращает его в DTO (или мапит в REST-ответ). Persistence не знает про API-форму ответа.
Offset-based — простой случай
R-JOOQ-PAG-2: limit + offset + отдельный fetchCount.
@Override
public PaginationView<Order> findAll(OrderFilter filter, int page, int size, SelectMode mode) {
Condition where = conditionBuilder.build(filter);
// items
List<Order> items = dslContext
.selectFrom(orders)
.where(where)
.orderBy(toSortFields(filter.sort()))
.pipe(applyLock(mode))
.limit(size)
.offset((long) page * size)
.fetch()
.map(record -> mapper.toDomain(record.into(OrdersPojo.class)));
// total
long total = dslContext.fetchCount(orders, where);
return new PaginationView<>(items, total, items.size(), page, size);
}
Когда offset работает хорошо: «листание» каталога, страницы 1–50, размер 20–100. PG быстро выполняет LIMIT 20 OFFSET 200.
Когда плохо: страница 5000 (offset 100000). PG читает и выкидывает 100000 строк, чтобы вернуть 20. Это O(N) от offset'а. Если в API есть «прыгнуть на страницу 5000» — пагинация должна быть cursor-based.
Cursor-based (keyset) — для часто меняющихся данных
R-JOOQ-PAG-3: WHERE по предыдущему cursor + limit(size + 1).
public CursorView<Order> findFeed(OrderFilter filter, OrderCursor cursor, int size) {
Condition where = conditionBuilder.build(filter);
if (cursor != null) {
// WHERE (created_at, id) < (cursor.created_at, cursor.id)
where = where.and(
orders.CREATED_AT.lt(cursor.createdAt())
.or(orders.CREATED_AT.eq(cursor.createdAt())
.and(orders.ID.lt(cursor.id()))));
}
Result<OrdersRecord> records = dslContext
.selectFrom(orders)
.where(where)
.orderBy(orders.CREATED_AT.desc(), orders.ID.desc())
.limit(size + 1) // +1, чтобы понять hasNext
.fetch();
boolean hasNext = records.size() > size;
if (hasNext) records.remove(records.size() - 1); // отбросить лишнюю
List<Order> items = records.stream()
.map(r -> mapper.toDomain(r.into(OrdersPojo.class)))
.toList();
OrderCursor nextCursor = hasNext
? new OrderCursor(items.get(items.size() - 1).createdAt(), items.get(items.size() - 1).id())
: null;
return new CursorView<>(items, nextCursor);
}
Что важно:
- Composite cursor.
(createdAt, id)— потому что у двух заказов может совпастьcreatedAtс точностью до миллисекунды. Если бы cursor был толькоcreatedAt, мы бы пропускали или дублировали записи. ID — tie-breaker. limit(size + 1)— единственный надёжный способ узнать, есть ли следующая страница, без отдельного запроса.- Нет
total. Cursor-API не показывает «страница 5 из 1000». Только «следующая страница» / «лента закончилась». Это компромисс — total на больших фидах дорогой и не нужен пользователю.
Когда применять cursor:
- Часто меняющиеся данные (ленты, уведомления, журнал событий). Offset тут даёт «прыгающую» страницу: между запросами что-то добавилось/удалилось, и страница 2 уже не «та».
- Большая глубина листания (поиск, история). Cursor работает за
O(log N)по индексу, offset —O(offset). - API не показывает «прыжки на страницу N» — обычно мобильные/web-ленты.
Cursor как opaque token
R-JOOQ-PAG-4: cursor на уровне API — base64-encoded непрозрачный токен. На уровне репозитория — типизированный record.
// API-слой
public record CursorToken(String value) {
public OrderCursor decode() {
String[] parts = new String(Base64.getDecoder().decode(value)).split("\\|");
return new OrderCursor(OffsetDateTime.parse(parts[0]), Long.parseLong(parts[1]));
}
public static CursorToken encode(OrderCursor c) {
String raw = c.createdAt() + "|" + c.id();
return new CursorToken(Base64.getEncoder().encodeToString(raw.getBytes()));
}
}
// Repository-слой
public record OrderCursor(OffsetDateTime createdAt, Long id) {}
Почему opaque на API: клиент не знает структуру cursor'а, он просто возвращает то, что прислал сервер. Это даёт нам свободу менять внутреннюю структуру cursor'а без breaking change в API. См. R-QRY-5 в REST API Style Guide.
fetchCount, не size() на всём
R-JOOQ-PAG-5: total — отдельным запросом через fetchCount.
// ХОРОШО
long total = dslContext.fetchCount(orders, where); // SELECT count(*) FROM orders WHERE ...
// ПЛОХО
long total = dslContext.selectFrom(orders).where(where).fetch().size(); // тянем все записи!
На таблице с 10M строк второй вариант — OOM или таймаут даже на dev. Первый — PG считает count(*) через index или sequential scan; даже sequential scan — это просто проход без передачи данных в Java.
На очень больших таблицах sequential scan count(*) тоже медленный (минуты). Тогда:
- Use cursor-based pagination — total вообще не нужен.
- Approximate count — PG хранит оценку в
pg_class.reltuples. Не точная, но мгновенная:SELECT reltuples::bigint FROM pg_class WHERE relname = 'orders'. Подходит для отображения «примерно 5M записей».
Что запрещено
R-JOOQ-PAG-X1: fetchAll() без LIMIT в UI-эндпоинте.
// ПЛОХО
public List<Order> findAllForExport() {
return dslContext.selectFrom(orders).fetch().into(Order.class);
// 10M строк → OOM или таймаут
}
Что вместо:
- Для UI — пагинация (offset или cursor).
- Для экспорта — streaming через
dslContext.fetchStream(...)с явнымCursor<>и chunked-обработкой. Или background-задача, кладущая в файл.
R-JOOQ-PAG-X2: count(*) через query builder на горячем пути для больших таблиц.
// ПЛОХО на 50M строк
long total = dslContext.fetchCount(events, where);
// ХОРОШО (когда точность не критична)
long approximate = dslContext.fetchOne(
"SELECT reltuples::bigint FROM pg_class WHERE relname = ?", "events"
).get(0, Long.class);
Либо переходим на cursor-pagination без total.
Куда дальше
- jOOQ Style Guide → раздел 8. Пагинация — нормативные формулировки.
- REST API Style Guide → R-QRY-4/5 — page (1-based) и cursor на уровне API.
- Filter builders — как WHERE прокидывается в findAll.
- View-репозитории — пагинированные read-проекции отделены от write-агрегата.