10. Pagination, filtering, sorting
Пагинация, фильтрация и сортировка: производительность и дизайн API
«Преждевременная оптимизация — корень всех зол. И всё же нам не следует упускать шансы в тех самых критических трех процентах».
— Дональд Кнут
Введение: Цена выборки «Всего»
Каждый разработчик начинает свое знакомство с базами данных с обманчиво простого запроса: SELECT * FROM users. На тестовом стенде со ста пользователями этот код работает мгновенно. Однако по мере роста бизнеса таблицы разрастаются до сотен тысяч и десятков миллионов строк.
В архитектуре распределенных высоконагруженных систем действует непреложное правило: никогда не доверяйте клиенту (будь то внешний веб-фронтенд, мобильное приложение или партнерский микросервис). Если вы оставите эндпоинт GET /users без принудительного лимита на максимальное количество возвращаемых записей, первый же неосторожный запрос приведет к катастрофе на всех уровнях стека:
- База данных (Storage & I/O): Движок СУБД (PostgreSQL или MySQL) будет вынужден выполнить полное последовательное сканирование таблицы (Full Table Scan /
Seq Scan). Миллионы страниц данных загрузятся с диска в память, безжалостно вытеснив горячие данные из разделяемого буферного пула (Buffer Pool). Дисковая подсистема встанет в глухую очередь ввода-вывода (I/O Bottleneck). - Сеть (Bandwidth & Serialization): Сериализация 100 000 записей породит JSON-пейлоад размером в 50–100 мегабайт, забив сетевой канал между базой данных, бэкендом и клиентом.
- 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:
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 это превращается в поиск по индексу:
-- Предположим, курсор был расшифрован как 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.
// АНТИПАТТЕРН: Никогда так не делайте! Прямой путь к SQL Injection
query := "SELECT * FROM users WHERE 1=1 "
if role != "" {
query += "AND role = '" + role + "' " // Уязвимость!
}
Для сборки динамических фильтров в идиоматичном Go применяют Query Builders (например, пакет Masterminds/squirrel).
// Идиоматичный 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-сокета базы данных:
// 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 для связи со стандартами) с курсорной пагинацией выглядит так:
{
"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.