2013-12-08

Оптимизация страничной выборки со сложной фильтрацией

В продолжение темы о решении проблемы N+1 в Hibernate.

Постановка задачи

Нужно сформировать отчет, содержащий 100к строк и фильтрацию по нескольким полям. Выборка объективно не укладывается в отведенные рамки: годовая выборка - 30 секунд. Приходится грузить данные на клиента маленькими чанками по сто записей. При этом мы столкнулись с тем, что один чанк загружается в течении 2 секунд, помножив на 1000 чанков получаем более получаса на получение отчета. Вся выборка ведется по двум таблицам в отношении One-to-Many, причем Many часть почти на 100% состоит из 1 записи, но СЦУКО могут встречаться > 1.

Оптимизация выборки окна

Мы оптимизировали выборку предзагрузив необходимые данные и у нас нет коррелированных подзапросов. Но при добавлении в запрос содержащий LEFT JOIN FETCH окна query.setFirstResult(start).setMaxResults(100); Hibernate плевался ворнигом HHH000104: firstResult/maxResults specified with collection fetch; applying in memory! что, в общем-то, логично: из-за OUTER JOIN он не может рассчитать ни начало выборки, ни необходимое количество записей, поэтому он выбирал все данные, и только потом ограничивал окно.

На StackOverflow предложили оригинальное решение проблемы: так как окно небольшое, то сначала выбираем первичные ключи SELECT DISTINCT p.id FROM Parent p LEFT JOIN p.child c WHERE p = ... and c = ... ORDER BY p.timestamp, а затем выбираем данные фильтруя по полученным идентификаторам SELECT DISTINCT p FROM Parent p LEFT JOIN FETCH p.child c WHERE p.id IN (?) ORDER BY p.timestamp.

Bottleneck

Хак с выборкой первичных ключей помог, но все равно производительности сильно не хватало. Мы начали искать затык и, спустя какое-то время, удалось изолировать момент проседания производительности. Время запроса SELECT p.* FROM parent p LEFT JOIN child c on p.id = c.id WHERE p.timestamp BETWEEN ? AND ? ORDER BY p.timestamp отличался от аналогичного с DISTINCT более чем в 100 раз. Оптимизатор MariaDB во втором запросе терял индекс, начиная яростно фуллсканить.

Оптимизации

После серьезного мозгового штурма мы нашли способ укорить запрос еще в пять раз, отказавшись от небольшого допущения. Клиент останавливал загрузку, получив пустой чанк, но если бы он знал размер ожидаемой выборки, то он бы сам знал, когда остановиться. А это было просто, поскольку для отрисовки прогресса мы и так сначала запрашивали размер выборки.

Сначала мы выбираем только родителей фильтруя по признакам, содержащимся в родителях и ограничивая окном в сто записей: SELECT DISTINCT p.id FROM Parent p WHERE p = ... ORDER BY p.timestamp. Затем полученные идентификаторы м используем во втором запросе никак не ограничивая окно и используя FETCH: SELECT DISTINCT p FROM Parent p LEFT JOIN FETCH p.child c WHERE p.id IN (?) AND c = ... ORDER BY p.timestamp. Это дало нам прирост производительности в пять раз, сократив время формирования отчета до приемлимого уровня.

Песочница

А теперь началось самое интересное: я не смог воспроизвести bottleneck в песочнице и H2 вместо MariaDB. Для бенчмаркинга попробовал использовать профайлер от SLF4J. Исходники находятся в модуле paging. Там запускабельны два класса: PagingApp, замеряющая время выборки с ипользованием Hibernate и Explain выводящую план запроса с аналитикой. Модель состоит из родителя и потомка в отношении One-to-Many. База заполняется случайными данными в 100к родителей и 0-10 потомков у каждого.

Профайлинг показал, что FilterBoth+Lim в три раза быстрее, чем FilterBothSmart, хотя должно быть с точностью до наоборот, при этом скорость работы отличается от ожидаемой в десятки раз. Производились следующие замеры:
1-2. LJ+Order и LJ+Distinct+Order просто для демонстрации необходимости Distinct
3. Fetch+Limit демонстрирует ворнинг HHH000104
4. LJ+Distinct+Order+Lim просто выборка с окном без фильтрации, этого запроса было достаточно для демонстрации фуллскана
5. Filter+Lim выборка с окном и фильтрацией по родителю
6. FilterBoth+Lim выборка с окном и фильтрацией по родителю и потомку
7. FilterBothSmart сабжевая оптимизация
7.1 Prefetch предвыборка первичных ключей с окном
7.2 Fetch выборка данных

+ Profiler [Paging]
|-- elapsed time                   [init]    78,769  seconds.
|-- elapsed time               [LJ+Order]     7,554  seconds.
|-- elapsed time      [LJ+Distinct+Order]    12,422  seconds.
|-- elapsed time            [Fetch+Limit]    42,375  seconds.
|-- elapsed time  [LJ+Distinct+Order+Lim]     0,003  seconds.
|-- elapsed time             [Filter+Lim]     0,017  seconds.
|-- elapsed time         [FilterBoth+Lim]     0,011  seconds.
|---+ Profiler [FilterBothSmart]
    |-- elapsed time               [Prefetch]     0,008  seconds.
    |-- elapsed time                  [Fetch]     0,024  seconds.
    |-- Subtotal            [FilterBothSmart]     0,032  seconds.
|-- elapsed time        [FilterBothSmart]     0,032  seconds.
|-- Total                        [Paging]   141,194  seconds.

У H2 выводит план в совершенно вырвиглазном формате, но понять, в принципе, можно. Хитрожопая база догадывается, что ей достаточно индекса. При этом она и для остальных запросов использовала только индекс. Пришлось в в родителя добавить поле nonIndex, чтобы данные таки были прочитаны с диска - это сильно просадило запросы.

03:01:19,708 DEBUG [main] com.blazer.paging.Explain:16 - LJ+Distinct+Order+Lim
03:01:19,725 DEBUG [main] com.blazer.paging.Explain:43 - [SELECT DISTINCT
    PARENTFILT0_.ID AS ID1_9_,
    PARENTFILT0_.NONINDEX AS NONINDEX2_9_,
    PARENTFILT0_.VALUE AS VALUE3_9_
FROM PUBLIC.PARENT_FILTER PARENTFILT0_
    /* PUBLIC.PARENT_FILTER_VALUE_IDX */
    /* scanCount: 10 */
LEFT OUTER JOIN PUBLIC.CHILD_FILTER CHILD1_
    /* PUBLIC.FK_MXDQ9TND71UF79IQSE2I11ML5_INDEX_4: PARENT_ID = PARENTFILT0_.ID */
    ON PARENTFILT0_.ID = CHILD1_.PARENT_ID
    /* scanCount: 34 */
ORDER BY 3
LIMIT ?1
/* index sorted */
/*
total: 21
CHILD_FILTER.FK_MXDQ9TND71UF79IQSE2I11ML5_INDEX_4 read: 10 (47%)
PARENT_FILTER.PARENT_FILTER_DATA read: 10 (47%)
PARENT_FILTER.PARENT_FILTER_VALUE_IDX read: 1 (4%)
*/]
03:01:19,725 DEBUG [main] com.blazer.paging.Explain:18 - Filter+Lim
03:01:19,728 DEBUG [main] com.blazer.paging.Explain:43 - [SELECT DISTINCT
    PARENTFILT0_.ID AS ID1_9_,
    PARENTFILT0_.NONINDEX AS NONINDEX2_9_,
    PARENTFILT0_.VALUE AS VALUE3_9_
FROM PUBLIC.PARENT_FILTER PARENTFILT0_
    /* PUBLIC.PARENT_FILTER_VALUE_IDX: VALUE >= ?2
        AND VALUE <= ?3
     */
    /* WHERE (PARENTFILT0_.VALUE >= ?2)
        AND (PARENTFILT0_.VALUE <= ?3)
    */
    /* scanCount: 10 */
LEFT OUTER JOIN PUBLIC.CHILD_FILTER CHILD1_
    /* PUBLIC.FK_MXDQ9TND71UF79IQSE2I11ML5_INDEX_4: PARENT_ID = PARENTFILT0_.ID */
    ON PARENTFILT0_.ID = CHILD1_.PARENT_ID
    /* scanCount: 34 */
WHERE (PARENTFILT0_.VALUE >= ?2)
    AND (PARENTFILT0_.VALUE <= ?3)
ORDER BY 3
LIMIT ?1
/* index sorted */]
03:01:19,728 DEBUG [main] com.blazer.paging.Explain:20 - FilterBoth+Lim
03:01:19,732 DEBUG [main] com.blazer.paging.Explain:43 - [SELECT DISTINCT
    PARENTFILT0_.ID AS ID1_9_,
    PARENTFILT0_.NONINDEX AS NONINDEX2_9_,
    PARENTFILT0_.VALUE AS VALUE3_9_
FROM PUBLIC.PARENT_FILTER PARENTFILT0_
    /* PUBLIC.PARENT_FILTER_VALUE_IDX: VALUE >= ?2
        AND VALUE <= ?3
     */
    /* WHERE (PARENTFILT0_.VALUE >= ?2)
        AND (PARENTFILT0_.VALUE <= ?3)
    */
    /* scanCount: 10 */
LEFT OUTER JOIN PUBLIC.CHILD_FILTER CHILD1_
    /* PUBLIC.FK_MXDQ9TND71UF79IQSE2I11ML5_INDEX_4: PARENT_ID = PARENTFILT0_.ID */
    ON PARENTFILT0_.ID = CHILD1_.PARENT_ID
    /* scanCount: 34 */
WHERE ((PARENTFILT0_.VALUE >= ?2)
    AND (PARENTFILT0_.VALUE <= ?3))
    AND ((CHILD1_.VALUE >= ?4)
    AND (CHILD1_.VALUE <= ?5))
ORDER BY 3
LIMIT ?1
/* index sorted */
/*
total: 11
CHILD_FILTER.CHILD_FILTER_DATA read: 11 (100%)
*/]
03:01:19,732 DEBUG [main] com.blazer.paging.Explain:22 - FilterBothSmart Prefetch
03:01:19,736 DEBUG [main] com.blazer.paging.Explain:43 - [SELECT
    PARENTFILT0_.ID AS COL_0_0_,
    PARENTFILT0_.VALUE AS COL_1_0_
FROM PUBLIC.PARENT_FILTER PARENTFILT0_
    /* PUBLIC.PARENT_FILTER_VALUE_IDX: VALUE >= ?2
        AND VALUE <= ?3
     */
    /* scanCount: 10 */
WHERE (PARENTFILT0_.VALUE >= ?2)
    AND (PARENTFILT0_.VALUE <= ?3)
ORDER BY 2
LIMIT ?1
/* index sorted */]
03:01:19,736 DEBUG [main] com.blazer.paging.Explain:24 - FilterBothSmart Fetch
03:01:19,740 DEBUG [main] com.blazer.paging.Explain:43 - [SELECT DISTINCT
    PARENTFILT0_.ID AS ID1_9_,
    PARENTFILT0_.NONINDEX AS NONINDEX2_9_,
    PARENTFILT0_.VALUE AS VALUE3_9_
FROM PUBLIC.PARENT_FILTER PARENTFILT0_
    /* PUBLIC.PRIMARY_KEY_6: ID IN(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10) */
    /* WHERE PARENTFILT0_.ID IN(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)
    */
    /* scanCount: 11 */
LEFT OUTER JOIN PUBLIC.CHILD_FILTER CHILD1_
    /* PUBLIC.FK_MXDQ9TND71UF79IQSE2I11ML5_INDEX_4: PARENT_ID = PARENTFILT0_.ID */
    ON PARENTFILT0_.ID = CHILD1_.PARENT_ID
    /* scanCount: 35 */
WHERE (PARENTFILT0_.ID IN(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10))
    AND ((CHILD1_.VALUE >= ?11)
    AND (CHILD1_.VALUE <= ?12))
ORDER BY 3
/*
total: 1
PARENT_FILTER.PARENT_FILTER_DATA read: 1 (100%)
*/]

Итог: странная ситуация и в песочнице не хватает какого-то ключевого фактора либо методические ошибки: может мешает кэш, но как его просто отключить я не вкурил.