| 1 | Профилирање и оптимизација на прашалници |
| 2 | |
| 3 | Во базите на податоци претставуваат критична компонента за успешното функционирање на апликациите. Како што расте волуменот на податоците и комплексноста на прашалницте, перформансите на базата стануваат тесно грло кое има влијание врз генералните перформанси на апликацијата, оперативната ефикасност и најважно, врз кориснчкото искуство. |
| 4 | Еден од најчестите проблеми во администрацијата на базите се неефикасните SQL прашалници, особено кога се работи за комплексни аналитички прашалници што обработуваат големи количини на податоци и притоа прават комплексни пресметки и агрегации, и ова дополнително ги комплицира работите доколку ваквите аналитички прашалници се извршуваат врз оперативната база на податоци. Во продолжение е прикажан пример, како едноствна промена во начинот на калкулирање на еден сегмент од овој прашлник, доведе до драстично подобрување на времето на извршување. |
| 5 | |
| 6 | == Преглед на првичниот проблем |
| 7 | |
| 8 | Како пример за овој дел од проектот го земавме погледот за извесна статистичка анализа на резултатите на студентите по задача, `v_statistiki_vremenski_po_student_i_zadacha`. Иако навидум овој поглед сам по себе изгледа доста комплексен, проблем всушност прозилезе дека е во `v_activity_with_interval_and_payload_next`, каде што во потпрашалници се прави пресметка за времето на првата активност пред моментално разгледуваната и првата активност по моментално разгледуваната. Времето на извршување на прашалникот вклучувајќи ги вакви операции во просек надминуваше 3 минути врз табела со околу 500.000 записи. |
| 9 | |
| 10 | {{{#!sql |
| 11 | |
| 12 | (SELECT ait3.payload AS payload_next |
| 13 | |
| 14 | FROM activity_in_task ait3 |
| 15 | |
| 16 | WHERE ait3.person_id = pppppp.person_id AND ait3.task_in_test_instance_id = pppppp.task_in_test_instance_id AND |
| 17 | ait3.when_occured = pppppp.when_occured_next) AS payload_next |
| 18 | |
| 19 | (SELECT min(ait2.when_occured) AS min |
| 20 | |
| 21 | FROM activity_in_task ait2 |
| 22 | |
| 23 | WHERE ait2.person_id = ait.person_id AND ait2.task_in_test_instance_id = ait.task_in_test_instance_id AND |
| 24 | ait2.when_occured > ait.when_occured) AS when_occured_next |
| 25 | }}} |
| 26 | |
| 27 | |
| 28 | |
| 29 | == Како дојдовме до ваков заклучок? |
| 30 | |
| 31 | Детална анализа на Execution Plan |
| 32 | |
| 33 | Execution планот (план на извршување) е најмоќната алатка за да го разбереме "начинот на размислување" на оптимизаторот, и да најдеме потенцијални точки за подобрување. Со негова анализа може да се утврди: |
| 34 | - кои индекси (не) се користат, |
| 35 | - дали се прават целосни скенирања на табела (table scans), |
| 36 | - дали се прават непотребни сортирања или спојувања, |
| 37 | - колкави се проценетите и реалните броеви на редови што се процесираат. |
| 38 | |
| 39 | Основни концепти за читање и разбирање на Execution Plans |
| 40 | 1. Структура на планот |
| 41 | Execution планот е во форма на хиерархиска структура (дрво) каде што: |
| 42 | |
| 43 | Корен е конечниот резултат на прашачникот |
| 44 | Листови се основните табели или индекси |
| 45 | Внатрешни јазли се операциите (JOIN, WHERE, GROUP BY, итн.) |
| 46 | Текот на податоците е оздола нагоре |
| 47 | |
| 48 | 2. Клучни метрики во планот |
| 49 | Cost (Цена на извршување): |
| 50 | cost=0.43..156789.45 |
| 51 | |
| 52 | Првиот број (0.43) е startup cost - цена до враќање на првиот ред |
| 53 | Вториот број (156789.45) е total cost - цена по враќање на сите редови |
| 54 | Цената е релативна единица, не се мери во време |
| 55 | |
| 56 | Rows (Редови): |
| 57 | rows=1000 |
| 58 | |
| 59 | Проценет број на редови што ќе се вратат од оваа операција |
| 60 | Базиран на статистики на планерот |
| 61 | |
| 62 | Width (Ширина): |
| 63 | width=64 |
| 64 | |
| 65 | Просечна големина на редот во бајти |
| 66 | |
| 67 | Actual Time (Реално време): |
| 68 | actual time=180000.123..180456.789 |
| 69 | |
| 70 | Реалното време во милисекунди |
| 71 | Првиот број е време до првиот ред |
| 72 | Вториот број е вкупно време |
| 73 | |
| 74 | Loops (Циклуси): |
| 75 | loops=1000 |
| 76 | |
| 77 | Колку пати се извршила операцијата |
| 78 | Вкупното време = actual time × loops |
| 79 | |
| 80 | 3. Типови операции во планот |
| 81 | Scan операции: |
| 82 | |
| 83 | `Seq Scan` - Секвенцијално скенирање (ја чита цела табела) |
| 84 | `Index Scan` - Користи индекс за пронаоѓање на потребните редици |
| 85 | `Index Only Scan` - Сите потребни податоци се во индексот |
| 86 | `Bitmap Heap Scan` - Комбинира повеќе индекси |
| 87 | |
| 88 | `Join` операции: |
| 89 | |
| 90 | `Nested Loop` - За секој ред од левата табела, пребарува во десната (многу спора операција) |
| 91 | `Hash Join` - Создава hash табела и спојува по клучот |
| 92 | `Merge Join` - Спојува два сортирани сетови |
| 93 | |
| 94 | `Aggregate` операции: |
| 95 | |
| 96 | `Aggregate` - GROUP BY, COUNT, SUM, итн. |
| 97 | `WindowAgg` - Window функции (LEAD, LAG, ROW_NUMBER, итн.) |
| 98 | `HashAggregate` - GROUP BY со hash табела |
| 99 | |
| 100 | == Анализа на нашиот конкретен случај |
| 101 | |
| 102 | За визуелизација на execution plan-от ја користевме следната алатка [https://explain.dalibo.com/] |
| 103 | |
| 104 | За да имаме преглед и врз меморијата што се користи, ја извршивме следната команда за да генерираме execution plan |
| 105 | |
| 106 | {{{#!sql |
| 107 | |
| 108 | explain (analyze, buffers) |
| 109 | select * |
| 110 | from v_statistiki_vremenski_po_student_i_zadacha; |
| 111 | |
| 112 | }}} |
| 113 | |
| 114 | Очигледно беше дека операцијата `NestedLoopJoin` во рамки на која понатаму се прави `IndexScan` е онаа операција која е причина за лошите перформанси на прашалникот. На сликата подолу е видливо дека на овие операции практично отпаѓа целото време на извршување. Дополнително, можеме да воочиме дека естимацијата направена во однос на редови кои треба да се вратат е непрезицна и дека за секој ред од табелата ова скенирање се повторува. |
| 115 | |
| 116 | === Аплицирање на промената во прашалникот |
| 117 | |
| 118 | {{{#!sql |
| 119 | |
| 120 | lead(ait.when_occured) over ( |
| 121 | partition by ait.person_id, ait.task_in_test_instance_id |
| 122 | order by ait.when_occured |
| 123 | ) AS when_occured_next, |
| 124 | |
| 125 | lead(ait.payload) over ( |
| 126 | partition by ait.person_id, ait.task_in_test_instance_id |
| 127 | order by ait.when_occured |
| 128 | ) as payload_next |
| 129 | |
| 130 | }}} |
| 131 | |
| 132 | Главната оптимизација се состоеше во замена на корелираните потпрашалници со window функции во погледот `v_activity_with_interval_and_payload_next`. |
| 133 | |
| 134 | По аплицирање на оптимизацијата промената беше веднаш видлива и во планот на извршување. Претходно скапата операција на NestedLoop која траеше околу 3 минути, сега е заменета со WindowAgg која трае вкупно 144 ms. По ова веднаш беше видливо и подобрување во вкупното време на извршување на прашалникот кое драстично се намали на ~200ms. |
| 135 | |
| 136 | == Зошто Window функциите се толку поефикасни? |
| 137 | |
| 138 | 1. Еднократно скенирање на табелата: Window функциите поминуваат само еднаш низ податоците, додека корелираните потпрашалници го скенираат истиот сет на податоци повторно за секој ред |
| 139 | 2. Нема повторливи пребарувања: Корелираните потпрашалници се извршуваат N пати (каде што N е бројот на редови), што резултира со O(N²) сложеност |
| 140 | 3. Подобра оптимизација: Современите планери (PostgreSQL, SQL Server, Oracle) користат специјализирани алгоритми за оптимизација на window функции |
| 141 | 4. Намален броја на I/O операции: Наместо повеќе извршувања на потпрашалници со потенцијални пристапи до диск, сè се процесира во РАМ во една ефикасна операција |
| 142 | 5. Подобро искористување на индексите: Планерот може лесно да ги оптимизира window функциите, особено кога има индекси на колоните во `PARTITION BY` и `ORDER BY`, што најчесто ќе биде случај бидејќи тоа се колони на надворешни клучеви и посекако се користат за спојувања во обични прашалници. |