Используя курсоры, вы сможете порционно получить из БД и обработать большое количество данных, не расходуя при этом память приложения. Уверен, перед каждым веб-разработчиком хотя бы раз вставала подобная задача, передо мной тоже — и не раз. В этой статье я расскажу, в каких задачах курсоры могут быть полезны, и дам готовый код по работе с ними из PHP + Doctrine на примере PostrgeSQL. Давайте представим, что у нас есть проект на PHP, большой и тяжелый. Наверняка, он написан у нас с помощью какого-нибудь фреймворка. Например, Symfony. Еще в нем используется база данных, например, PostgreSQL, а в базе данных есть табличка на 2 000 000 записей с информацией о заказах. А сам проект — это интерфейс к этим заказам, который умеет их отображать и фильтровать. И, позвольте заметить, справляется с этим весьма неплохо. Теперь нас попросили (вас еще не просили? Обязательно попросят) сделать выгрузку результата фильтрации заказов в Excel-файл. Давайте на скорую руку добавим кнопку со значком таблицы, которая будет выплевывать пользователю файл с заказами. Как делает программист, которому еще не встречалась такая задача? Он делает SELECT в базу, вычитывает результаты запроса, конвертирует ответ в Excel-файл и отдает его в браузер пользователя. Задача работает, тестирование пройдено, но в продакшине начинаются проблемы. Наверняка, у нас для PHP установлено ограничение памяти в какой-нибудь разумный (спорно) 1 Гб на процесс, и как только эти 2 млн строк перестают помещаться в этот гигабайт, все ломается. PHP падает с ошибкой “закончилась память”, а пользователи жалуются, что файл не выгружается. Происходит это потому, что мы выбрали довольно наивный способ выгрузить данные из базы — они все сначала перекладываются из памяти базы (и диска под ней) в оперативную память процесса PHP, потом обрабатываются и выгружаются в бразуер. Чтобы данные всегда помещались в память, нужно брать их из базы по кусочкам. Например, 10 000 записей вычитали, обработали, записали в файл, и так много раз. Хорошо, думает программист, которому наша задача встретилась в первый раз. Тогда я сделаю цикл и выкачаю результаты запроса кусками, указывая LIMIT и OFFSET. Срабатывает, но это очень дорогие операции для базы, и поэтому выгрузка отчета начинает занимать не 30 секунд, а 30 минут (еще не так уж и плохо!). Кстати, если кроме OFFSET в этот момент программисту ничего больше в голову не приходит, то вот еще много способов добиться того же, не насилуя базу данных. При этом у самой БД есть встроенная возможность поточно вычитывать из нее данные — курсоры. Курсор — это указатель на строку в результатах выполнения запроса, который живет в базе. При их использовании, мы можем выполнить SELECT не в режиме немедленного выкачивания данных, а открыть курсор с этим селектом. Далее мы начинаем получать из БД поток данных по мере продвижения курсора вперед. Это дает нам тот же результат: мы вычитываем данные порционно, но база не делает одну и ту же работу по поиску строки, с которой ей нужно начать, как в случае с OFFSET. Курсор открывается только внутри транзакции и живет до тех, пока транзакция жива (есть исключение, смотрите WITH HOLD). Это значит, что если мы медленно вычитываем много данных из базы, то у нас будет долгая транзакция. Это иногда плохо, надо понимать и принимать этот риск. Давайте попробуем реализовать работу с курсорами в Doctrine. Для начала, как выглядит запрос на открытие курсора? DECLARE создает и открывает курсор для заданного запроса SELECT. После создания курсора, из него можно начать читать данные: И так далее, пока FETCH не возвратит пустой список. Это будет означать, что проскроллили до конца. Набросаем класс, совместимый с Doctrine, который будет инкапсулировать работу с курсором. И, чтобы за 20% времени решить 80% проблемы, работать он будет только с Native Queries. Так его и назовем, PgSqlNativeQueryCursor. Конструктор: Здесь же я генерирую имя для будущего курсора. Так как в классе есть SQL-код, специфичный для PostgreSQL, то лучше поставить проверку на то, что наш драйвер — это именно PG. От класса нам нужно три вещи: Открываем курсор: Как я и говорил, курсоры открываются в транзакции. Поэтому здесь я проверяю, что мы не забыли разместить вызов этого метода внутри уже открытой транзакции. (Слава богу, прошло то время, когда меня потянуло бы открыть транзакцию прямо здесь!) Чтобы упростить себе задачу по созданию и инициализации нового NativeQuery, я просто клонирую тот, что был скормлен в конструктор, и оборачиваю его в DECLARE … CURSOR FOR (здесь_оригинальный_запрос). Выполняю его.  Сделаем метод getFetchQuery. Он будет возвращать не данные, а еще один запрос, который можно использовать как угодно, чтобы получить искомые данные заданными пачками. Это дает вызывающему коду больше свободы. У метода один параметр — это размер пачки, который станет частью запроса, возвращаемого этим методом. Применяю тот же трюк с клонированием запроса, затираю в нем параметры и заменяю SQL на конструкцию FETCH … FROM …;. Чтобы не забыть открыть курсор перед первым вызовом getFetchQuery() (вдруг я буду не выспавшийся), я сделаю неявное его открытие прямо в методе getFetchQuery(): А сам метод openCursor() сделаю private. Вообще не вижу кейсов, когда его нужно вызывать явно. Внутри getFetchQuery() я захардкодил FORWARD для движения курсора вперед на заданное количество строк. Но режимов вызова FETCH много разных. Давайте их тоже добавим? Половина из них принимают кол-во строк в параметре, а другая половина — нет. Вот, что у меня получилось: Закрываем курсор с помощью CLOSE, не обязательно дожидаться завершения транзакции: Деструктор: Вот весь класс полностью. Попробуем в действии? Я открываю какой-нибудь условный Writer в какой-нибудь условный XLSX. Здесь я получаю NativeQuery на вытаскивание списка заказов из базы. На основе этого запроса я объявляю курсор. И для него получаю запрос на получение данных пачками по 10000 строк. Итерирую, пока не получу пустой результат. В каждой итерации выполняю FETCH, обрабатываю результат и записываю в файл. Закрываю курсор и Writer. Сам файл я пишу на диск в директорию для временных файлов, и уже после окончания записи отдаю его в браузер, чтобы избежать, опять же, буферизации в памяти. ОТЧЕТ ГОТОВ! Мы использовали константное количество памяти из PHP при обработке всех данных и не замучали базу чередой тяжелых запросов. А сама выгрузка заняла по времени незначительно больше, чем потребовалось базе на выполнение запроса. Посмотрите, нет ли в ваших проектах мест, которые можно ускорить/сэкономить память при помощи курсора? Архитектор в IT