10. Pagination, filtering, sorting

Пагинация, фильтрация и сортировка: производительность и дизайн API

«Преждевременная оптимизация — корень всех зол. И всё же нам не следует упускать шансы в тех самых критических трех процентах».
— Дональд Кнут

Введение: Цена выборки «Всего»

Каждый разработчик начинает свое знакомство с базами данных с обманчиво простого запроса: SELECT * FROM users. На тестовом стенде со ста пользователями этот код работает мгновенно. Однако по мере роста бизнеса таблицы разрастаются до сотен тысяч и десятков миллионов строк.

В архитектуре распределенных высоконагруженных систем действует непреложное правило: никогда не доверяйте клиенту (будь то внешний веб-фронтенд, мобильное приложение или партнерский микросервис). Если вы оставите эндпоинт GET /users без принудительного лимита на максимальное количество возвращаемых записей, первый же неосторожный запрос приведет к катастрофе на всех уровнях стека:

  1. База данных (Storage & I/O): Движок СУБД (PostgreSQL или MySQL) будет вынужден выполнить полное последовательное сканирование таблицы (Full Table Scan / Seq Scan). Миллионы страниц данных загрузятся с диска в память, безжалостно вытеснив горячие данные из разделяемого буферного пула (Buffer Pool). Дисковая подсистема встанет в глухую очередь ввода-вывода (I/O Bottleneck).
  2. Сеть (Bandwidth & Serialization): Сериализация 100 000 записей породит JSON-пейлоад размером в 50–100 мегабайт, забив сетевой канал между базой данных, бэкендом и клиентом.
  3. Go-приложение (Mechanical Sympathy): Чтобы обработать 100 000 строк из database/sql, рантайм Go аллоцирует огромный срез (slice) структур в куче. Это вызовет всплеск потребления RAM (возможно, OOM) и заставит Garbage Collector "подавиться", останавливая мир (Stop-The-World паузы) для очистки этого мусора.

Чтобы защитить сервер от самострела, мы обязаны применять паттерны Пагинации (Pagination), Фильтрации (Filtering) и Сортировки (Sorting) на уровне API-контракта и транслировать их в оптимальные SQL-запросы.


Пагинация: Битва подходов

Существует два фундаментальных способа реализовать постраничный вывод. И выбор между ними — это классический маркер уровня инженера на собеседовании.

1. Offset / Limit (Подход Junior)

Самый интуитивный способ. Клиент передает номер страницы и размер страницы: GET /users?page=5&size=20.
Бэкенд переводит это в SQL:

SQL
SELECT id, name, created_at 
FROM users 
ORDER BY created_at DESC 
LIMIT 20 OFFSET 80;

Вторая ловушка — Offset Drift (Смещение данных). Пока клиент читал страницу 1, в систему добавилось 5 новых пользователей. Когда клиент запрашивает страницу 2 (со сдвигом 20), он увидит 5 дубликатов с первой страницы, потому что вся выборка "съехала" вниз. Точно так же, если запись удалена, пользователь пропустит элемент.

2. Keyset / Cursor Pagination (Выбор Senior-инженера)

Вместо того чтобы говорить базе "пропусти 100 строк", мы говорим: "Дай мне 20 строк, которые идут строго после вот этой конкретной строки".
Клиент делает первый запрос: GET /users?limit=20. Сервер возвращает 20 записей и специальный токен — Cursor.
Следующий запрос: GET /users?limit=20&cursor=dG9rZW4xMjM=.

На уровне SQL это превращается в поиск по индексу:

SQL
-- Предположим, курсор был расшифрован как created_at = '2023-10-01 12:00:00'
SELECT id, name, created_at 
FROM users 
WHERE created_at < '2023-10-01 12:00:00' 
ORDER BY created_at DESC 
LIMIT 20;
flowchart TD
    Req["Запрос GET /users"] --> Router{"Выбор стратегии"}

    Router -->|"offset/limit"| OffsetScan["DB Секвентальное чтение<br>Сложность O от N"]
    OffsetScan -->|"База читает 10020 строк<br>Отбрасывает 10000"| ResultOffset["Возврат 20 строк"]

    Router -->|"cursor"| IndexScan["DB Поиск по B-Tree<br>Сложность O от log N"]
    IndexScan -->|"Прямой прыжок к узлу<br>Чтение 20 строк"| ResultCursor["Возврат 20 строк"]

    classDef proc fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#e2e8f0;
    classDef slow fill:#1e293b,stroke:#f59e0b,stroke-width:1px,color:#e2e8f0;
    classDef fast fill:#1e293b,stroke:#10b981,stroke-width:1px,color:#e2e8f0;
    class Req,Router proc;
    class OffsetScan,ResultOffset slow;
    class IndexScan,ResultCursor fast;
  

Сортировка (Sorting): Опасность Filesort

В API согласно принципам 4. Resource oriented design, параметры сортировки передаются в Query-строке:
GET /users?sort=-created_at,name (минус означает DESC).

Бэкенд на Go должен распарсить эту строку, провалидировать имена полей по White-list (иначе клиент сможет вытащить данные или устроить SQL Injection) и передать в ORDER BY.


Фильтрация (Filtering): Динамический SQL в Go

Фильтрация позволяет клиенту делать выборки по критериям:
GET /users?role=admin&status=active&age_gt=18

Главная проблема при реализации этого в Go — генерация динамического SQL.

Go
// АНТИПАТТЕРН: Никогда так не делайте! Прямой путь к SQL Injection
query := "SELECT * FROM users WHERE 1=1 "
if role != "" {
    query += "AND role = '" + role + "' " // Уязвимость!
}

Для сборки динамических фильтров в идиоматичном Go применяют Query Builders (например, пакет Masterminds/squirrel).

Go
// Идиоматичный Go: использование параметризованных запросов
import "github.com/Masterminds/squirrel"

func BuildUserQuery(filter UserFilter) (string, []interface{}, error) {
    // Используем синтаксис плейсхолдеров Postgres ($1, $2)
    psql := squirrel.StatementBuilder.PlaceholderFormat(squirrel.Dollar)

    q := psql.Select("id", "name", "role").From("users")

    if filter.Role != "" {
        q = q.Where(squirrel.Eq{"role": filter.Role})
    }
    if filter.AgeGT > 0 {
        q = q.Where(squirrel.Gt{"age": filter.AgeGT})
    }

    q = q.OrderBy("created_at DESC").Limit(20)

    // ToSql возвращает строку с плейсхолдерами и слайс аргументов для database/sql
    return q.ToSql() 
}

Этот подход гарантирует безопасность: драйвер БД отправит параметры отдельно от запроса, исключая возможность SQL-инъекций.


Mechanical Sympathy: Потоковое чтение в рантайме Go

Когда мы настроили правильную пагинацию (LIMIT 20), мы защитили приложение от OOM. Но как правильно вычитывать эти данные в Go?

Интерфейс database/sql спроектирован так, чтобы минимизировать аллокации через потоковое чтение из TCP-сокета базы данных:

Go
// rows.Query отправляет запрос, но НЕ ВЫКАЧИВАЕТ все результаты сразу
rows, err := db.QueryContext(ctx, query, args...)
if err != nil {
    return nil, err
}
// ОБЯЗАТЕЛЬНО: гарантируем закрытие соединения
defer rows.Close() 

var users []User
// rows.Next() читает следующую порцию данных из буфера TCP сокета
for rows.Next() {
    var u User
    // Прямая запись данных из бинарного формата БД в структуру
    if err := rows.Scan(&u.ID, &u.Name, &u.Role); err != nil {
        return nil, err
    }
    users = append(users, u)
}

// Проверка ошибок, возникших во время итерации (например, обрыв сети)
if err := rows.Err(); err != nil {
    return nil, err
}

rows.Next() не аллоцирует огромные массивы. Данные конвертируются из бинарного формата драйвера БД напрямую в вашу структуру. Ограничив LIMIT 20, мы гарантируем, что слайс users запросит у рантайма Go ровно тот объем памяти, который нужен для 20 структур, а затем быстро будет собран GC после отправки HTTP-ответа.


Итог контракта

Идиоматичный JSON-ответ (см. 9. Error handling в API для связи со стандартами) с курсорной пагинацией выглядит так:

JSON
{
  "data": [
    { "id": 410, "price": 100, "name": "Ручка" },
    { "id": 452, "price": 100, "name": "Книга" }
  ],
  "meta": {
    "next_cursor": "eyJwcmljZSI6MTAwLCJpZCI6NDUyfQ==", 
    "has_next": true
  }
}
  • Клиенту не нужно понимать, что внутри next_cursor. Он просто отправляет его обратно в следующем запросе.
  • Сервер декодирует Base64-строку, достает JSON {"price":100, "id":452} и подставляет в WHERE (price, id) > ($1, $2).

Мы научились эффективно и безопасно запрашивать данные, не ломая сервер. Но что делать, если злой умысел или сломанный скрипт клиента начнет запрашивать первую страницу 10 000 раз в секунду? Сервер быстро исчерпает пулы соединений с БД. Чтобы защитить инфраструктуру от перегрузки, нам необходимо выстроить линию обороны на входе. Эту задачу решает паттерн, который мы разберем в следующей статье: 11. Rate limiting в API.