Changes between Version 6 and Version 7 of OtherTopics


Ignore:
Timestamp:
08/08/26 15:03:50 (7 hours ago)
Author:
231175
Comment:

--

Legend:

Unmodified
Added
Removed
Modified
  • OtherTopics

    v6 v7  
    33== Анализа на перформанси ==
    44
    5 === Извештај за најпопуларни курсеви според оценка ===
    6 
    7 Анализата на перформанси се врши врз основа на моменталната состојба во базата, која има податоци кои служат само за тестирање. Базата содржи: 1000 курсеви, 2000 преводи (по 2 преводи по курс), 1000 верзии на курсот (по 1 верзија по курс), 5000 корисници, 200 експерти, 10000 enrollments, 7000 reviews
    8 
    9 '''SQL:'''
    10 {{{
    11 SELECT
    12     c.id AS course_id,
    13     ct.title_short AS course_title,
    14     cv.version_number AS course_version,
    15     cv.active AS is_active,
    16     COUNT(DISTINCT e.id) AS total_enrollments,
    17     SUM(r.rating) / COUNT(r.id) AS average_rating,
    18     COUNT(r.id) AS total_reviews
    19 FROM course c
    20 JOIN course_translate ct ON c.id = ct.course_id
    21 JOIN course_version cv ON c.id = cv.course_id
    22 JOIN enrollment e ON cv.id = e.course_version_id
    23 JOIN review r ON e.id = r.enrollment_id
    24 WHERE ct.language = :language
    25 GROUP BY c.id, ct.id, cv.id
    26 ORDER BY SUM(r.rating) / COUNT(r.id) DESC
    27 }}}
    28 
    29 '''Индекси:'''
    30 
    31 **1. course_translate - Unique (course_id, language)**
    32 {{{
    33 CREATE UNIQUE INDEX uk_course_translate_course_language
    34 ON course_translate(course_id, language);
    35 }}}
    36 Користење: JOIN на `ct.course_id` + WHERE филтер на `ct.language` 
    37 
    38 Подобрување: Index Scan наместо Seq Scan
    39 
    40 ---
    41 
    42 **2. course_version - Index (course_id)**
    43 {{{
    44 CREATE INDEX idx_course_version_course_id
    45 ON course_version(course_id);
    46 }}}
    47 Користење: JOIN на `cv.course_id` 
    48 
    49 Подобрување: Директен lookup
    50 
    51 ---
    52 
    53 **3. enrollment - Index (course_version_id)**
    54 {{{
    55 CREATE INDEX idx_enrollment_course_version_id
    56 ON enrollment(course_version_id);
    57 }}}
    58 Користење: JOIN на `e.course_version_id` 
    59 
    60 Подобрување: Избегнува full table scan
    61 
    62 ---
    63 
    64 **4. review - Unique (enrollment_id)**
    65 {{{
    66 CREATE UNIQUE INDEX uk_review_enrollment
    67 ON review(enrollment_id);
    68 }}}
    69 Користење: JOIN на `r.enrollment_id` 
    70 
    71 Подобрување: Unique scan
    72 
    73 ---
    74 
    75 **5. course - Primary Key (id)**
    76 {{{
    77 -- Автоматски креиран со PRIMARY KEY
    78 CREATE UNIQUE INDEX course_pkey
    79 ON course(id);
    80 }}}
    81 Користење: Примарна табела PK lookup
    82 
    83 ---
    84 
    85 SQL за анализа на брзината
    86 {{{
    87 CREATE OR REPLACE FUNCTION test_popular_courses()
    88 RETURNS void AS $$
    89 DECLARE
    90   start_time timestamptz;
    91   end_time timestamptz;
    92   duration int;
     5Овој дел ја анализира изведбата на четирите извештајни dashboard функции врз базата и испитува дали и колку индекси можат да ја подобрат.
     6
     7Кратко за резултатот: индексите донесоа помеѓу −5% и +4%, што за ниту една од четирите функции не е значајно подобрување. Причината не е во индексите, туку во тоа што овие функции агрегираат врз целата база без ниту еден `WHERE` услов, па индексот нема редови што би можел да прескокне. Сите тврдења подолу се поткрепени со мерења.
     8
     9=== Методологија ===
     10
     11`EXPLAIN ANALYZE SELECT * FROM dashboard_monthly_totals()` враќа само еден ред, `Function Scan on dashboard_monthly_totals`, со вкупно време и ништо повеќе. Планот на телото на функцијата останува скриен, бидејќи PL/pgSQL го планира внатрешниот `RETURN QUERY` посебно и не го изложува нанадвор. Затоа во оваа анализа телото на секоја функција е извадено како самостоен `SELECT` и `EXPLAIN ANALYZE` е пуштен врз него. Така се добива целото стебло на планот, со join-овите, sort-овите, scan-овите и времето по јазол, што е единствениот начин да се види каде навистина се троши времето.
     12
     13Секоја варијанта е мерена на истиот начин. Query-то прво се пушта еднаш без мерење, за да се загрее cache-от и да не се мери случајно диск I/O од првото читање. Потоа `EXPLAIN ANALYZE` се пушта три пати, се известува просекот од трите заедно со сите три поединечни времиња, а прикажан е планот од последното пуштање. Пред мерењата е пуштено `ANALYZE` врз целата база, за планерот да работи со свежа статистика.
     14
     15Помеѓу секоја функција базата се враќа на почетна состојба, односно се бришат сите не-unique индекси. Така секоја секција почнува од истата основа и бројките се споредливи.
     16
     17Големина на податоците:
     18
     19|| '''Табела''' || '''Редови''' || '''Големина (heap)''' ||
     20|| user_course_progress || 960.000 || 60 MB ||
     21|| user_tag || 900.000 || — ||
     22|| user_favorite_course || 900.000 || — ||
     23|| course_lecture_translate || 864.000 || — ||
     24|| payment || 800.000 || 52 MB ||
     25|| enrollment || 800.000 || 51 MB ||
     26|| account || 500.000 || 56 MB ||
     27|| user || 450.000 || — ||
     28|| review || 160.000 || 17 MB ||
     29|| course_version || 6.000 || — ||
     30|| course_translate || 6.000 || — ||
     31|| expert_course || 4.666 || — ||
     32|| course || 2.000 || — ||
     33|| language || 3 || — ||
     34
     35Релевантни поставки на серверот:
     36
     37|| '''Параметар''' || '''Вредност''' ||
     38|| `work_mem` || 4 MB ||
     39|| `shared_buffers` || 160 MB ||
     40|| `effective_cache_size` || 5 GB ||
     41|| `random_page_cost` || 4 ||
     42|| `max_parallel_workers_per_gather` || 2 ||
     43|| `jit` || on ||
     44
     45=== Постоечки индекси во базата ===
     46
     47Базата веќе има 40 индекси, но сите до еден се создадени автоматски од `PRIMARY KEY` и `UNIQUE` ограничувањата. Нема ниту еден рачно додаден индекс наменет за изведба.
     48
     49Ова е важно за читањето на резултатите подолу. Сите join-ови по primary key (`enrollment_id`, `course_id`, `course_version_id`, `payment_id`) веќе се покриени со индекс. Врските 1:1 како `payment → enrollment` преку `uq_payment_enrollment` и `review → enrollment` преку `uq_review_enrollment` исто така се покриени. Композитните UNIQUE индекси служат и како индекси за својот прв столб, па `uq_course_translate (course_id, language_id)` го покрива и барањето само по `course_id`. Реално единствениот непокриен foreign key е `enrollment.course_version_id`.
     50
     51Со други зборови, најголемиот дел од очигледните индекси веќе ги има, што директно објаснува зошто додавањето нови носи мал ефект.
     52
     53|| account || account_pkey ||
     54|| account || uq_account_email ||
     55|| course || course_pkey ||
     56|| course_content || course_content_pkey ||
     57|| course_content || uq_course_content_position ||
     58|| course_content_translate || course_content_translate_pkey ||
     59|| course_content_translate || uq_course_content_translate ||
     60|| course_lecture || course_lecture_pkey ||
     61|| course_lecture || uq_course_lecture_position ||
     62|| course_lecture_translate || course_lecture_translate_pkey ||
     63|| course_lecture_translate || uq_course_lecture_translate ||
     64|| course_tag || pk_course_tag ||
     65|| course_translate || course_translate_pkey ||
     66|| course_translate || uq_course_translate ||
     67|| course_version || course_version_pkey ||
     68|| course_version || uq_course_version_number ||
     69|| course_version || uq_course_version_one_active ||
     70|| enrollment || enrollment_pkey ||
     71|| enrollment || uq_enrollment_user_version ||
     72|| expert || expert_pkey ||
     73|| expert || uq_expert_account_id ||
     74|| expert_course || pk_expert_course ||
     75|| language || language_pkey ||
     76|| language || uq_language_value ||
     77|| meeting_email_reminder || meeting_email_reminder_pkey ||
     78|| meeting_email_reminder || uq_mer_link ||
     79|| payment || payment_pkey ||
     80|| payment || uq_payment_enrollment ||
     81|| review || review_pkey ||
     82|| review || uq_review_enrollment ||
     83|| tag || tag_pkey ||
     84|| tag_translate || tag_translate_pkey ||
     85|| tag_translate || uq_tag_translate ||
     86|| user || uq_user_account_id ||
     87|| user || user_pkey ||
     88|| user_course_progress || uq_ucp ||
     89|| user_course_progress || user_course_progress_pkey ||
     90|| user_favorite_course || pk_user_favorite_course ||
     91|| user_tag || pk_user_tag ||
     92|| verification_token || verification_token_pkey ||
     93
     94=== dashboard_monthly_totals() ===
     95
     96Вкупно запишувања и приход по месец. Резултатот има 30 реда, по еден за секој месец во податоците.
     97
     98==== Дефиниција ====
     99
     100{{{
     101-- Monthly total enrollments and revenue
     102CREATE OR REPLACE FUNCTION dashboard_monthly_totals()
     103    RETURNS TABLE (
     104                      year INTEGER,
     105                      month INTEGER,
     106                      total_enrollments BIGINT,
     107                      total_revenue NUMERIC
     108                  ) AS $$
     109    #variable_conflict use_column
    93110BEGIN
    94   start_time := clock_timestamp();
    95  
    96   PERFORM
    97     c.id AS course_id,
    98     ct.title_short AS course_title,
    99     cv.version_number AS course_version,
    100     cv.active AS is_active,
    101     COUNT(DISTINCT e.id) AS total_enrollments,
    102     SUM(r.rating)::numeric / COUNT(r.id) AS average_rating,
    103     COUNT(r.id) AS total_reviews
    104   FROM course c
    105   JOIN course_translate ct ON c.id = ct.course_id
    106   JOIN course_version cv ON c.id = cv.course_id
    107   JOIN enrollment e ON cv.id = e.course_version_id
    108   JOIN review r ON e.id = r.enrollment_id
    109   WHERE ct.language = 'en'
    110   GROUP BY c.id, ct.id, cv.id
    111   ORDER BY SUM(r.rating)::numeric / COUNT(r.id) DESC
    112   LIMIT 20;
    113  
    114   end_time := clock_timestamp();
    115   duration := round(1000 * (extract(epoch FROM end_time) - extract(epoch FROM start_time)));
    116  
    117   RAISE NOTICE 'Query executed in: % ms', duration;
     111    RETURN QUERY
     112        SELECT
     113            EXTRACT(YEAR FROM e.purchase_date)::INTEGER AS year,
     114            EXTRACT(MONTH FROM e.purchase_date)::INTEGER AS month,
     115            COUNT(e.enrollment_id)::BIGINT AS total_enrollments,
     116            COALESCE(SUM(p.amount), 0)::NUMERIC AS total_revenue
     117        FROM enrollment e
     118                 LEFT JOIN payment p
     119                           ON p.enrollment_id = e.enrollment_id
     120                               AND p.payment_status = 'completed'   -- само наплатените пари се промет
     121        GROUP BY
     122            EXTRACT(YEAR FROM e.purchase_date),
     123            EXTRACT(MONTH FROM e.purchase_date)
     124        ORDER BY year DESC, month DESC;
    118125END;
    119126$$ LANGUAGE plpgsql;
    120 
    121 DROP INDEX IF EXISTS uk_course_translate_course_language;
    122 DROP INDEX IF EXISTS idx_course_version_course_id;
    123 DROP INDEX IF EXISTS idx_enrollment_course_version_id;
    124 DROP INDEX IF EXISTS uk_review_enrollment;
    125 
    126 -- run 1: no indexes
    127 
    128 SELECT test_popular_courses();
    129 
    130 -- run 2: index uk_course_translate_course_language
    131 
    132 CREATE UNIQUE INDEX uk_course_translate_course_language
    133 ON course_translate(course_id, language);
    134 ANALYZE course_translate;
    135 
    136 SELECT test_popular_courses();
    137 
    138 -- run 3: index uk_course_translate_course_language + idx_course_version_course_id
    139 
    140 CREATE INDEX idx_course_version_course_id
    141 ON course_version(course_id);
    142 ANALYZE course_version;
    143 
    144 SELECT test_popular_courses();
    145 
    146 -- run 4: index uk_course_translate_course_language + idx_course_version_course_id + idx_enrollment_course_version_id
    147 
    148 CREATE INDEX idx_enrollment_course_version_id
    149 ON enrollment(course_version_id);
    150 ANALYZE enrollment;
    151 
    152 SELECT test_popular_courses();
    153 
    154 -- run 5: all indexes -> uk_course_translate_course_language + idx_course_version_course_id + idx_enrollment_course_version_id + uk_review_enrollment
    155 
    156 CREATE UNIQUE INDEX IF NOT EXISTS uk_review_enrollment
    157 ON review(enrollment_id);
    158 ANALYZE review;
    159 
    160 SELECT test_popular_courses();
    161 
    162 DROP FUNCTION test_popular_courses();
    163 }}}
    164 
    165 **Сумарно:**
    166 - **Без индекси:** Seq Scan на сите табели (бавно)
    167 - **Со индекси:** Index Scan на сите JOIN-ови и WHERE (брзо)
    168 - **Перформанси:**
    169  - Без индекси: 51ms
    170  - Со индекси: 23ms
    171 - **Забрзување: 2.2 пати**
    172 
     127}}}
     128
     129==== Почетна состојба ====
     130
     131{{{
     132 Sort  (cost=37127.27..37129.52 rows=900 width=112) (actual time=150.157..153.299 rows=30 loops=1)
     133   Sort Key: (((EXTRACT(year FROM e.purchase_date)))::integer) DESC, (((EXTRACT(month FROM e.purchase_date)))::integer) DESC
     134   Sort Method: quicksort  Memory: 26kB
     135   ->  Finalize GroupAggregate  (cost=36825.84..37083.11 rows=900 width=112) (actual time=150.115..153.287 rows=30 loops=1)
     136         Group Key: (EXTRACT(year FROM e.purchase_date)), (EXTRACT(month FROM e.purchase_date))
     137         ->  Gather Merge  (cost=36825.84..37035.86 rows=1800 width=108) (actual time=150.109..153.264 rows=90 loops=1)
     138               Workers Planned: 2
     139               Workers Launched: 2
     140               ->  Sort  (cost=35825.82..35828.07 rows=900 width=108) (actual time=148.317..148.320 rows=30 loops=3)
     141                     Sort Key: (EXTRACT(year FROM e.purchase_date)), (EXTRACT(month FROM e.purchase_date))
     142                     Sort Method: quicksort  Memory: 28kB
     143                     Worker 0:  Sort Method: quicksort  Memory: 28kB
     144                     Worker 1:  Sort Method: quicksort  Memory: 28kB
     145                     ->  Partial HashAggregate  (cost=35765.91..35781.66 rows=900 width=108) (actual time=148.294..148.302 rows=30 loops=3)
     146                           Group Key: EXTRACT(year FROM e.purchase_date), EXTRACT(month FROM e.purchase_date)
     147                           Batches: 1  Memory Usage: 57kB
     148                           Worker 0:  Batches: 1  Memory Usage: 57kB
     149                           Worker 1:  Batches: 1  Memory Usage: 57kB
     150                           ->  Parallel Hash Left Join  (cost=15468.58..32432.58 rows=333333 width=81) (actual time=64.563..117.679 rows=266667 loops=3)
     151                                 Hash Cond: (e.enrollment_id = p.enrollment_id)
     152                                 ->  Parallel Seq Scan on enrollment e  (cost=0.00..9864.33 rows=333333 width=12) (actual time=0.004..14.058 rows=266667 loops=3)
     153                                 ->  Parallel Hash  (cost=10833.67..10833.67 rows=266633 width=13) (actual time=30.498..30.498 rows=213333 loops=3)
     154                                       Buckets: 262144  Batches: 8  Memory Usage: 5856kB
     155                                       ->  Parallel Seq Scan on payment p  (cost=0.00..10833.67 rows=266633 width=13) (actual time=0.016..14.775 rows=213333 loops=3)
     156                                             Filter: (payment_status = 'completed'::payment_status)
     157                                             Rows Removed by Filter: 53333
     158 Planning Time: 0.268 ms
     159 Execution Time: 153.339 ms
     160}}}
     161
     162Време: '''153,8 ms''' (три пуштања: 157,7 / 150,3 / 153,3 ms).
     163
     164Од планот се гледа дека `Parallel Seq Scan on enrollment` чита сите 800.000 редови, а `Parallel Seq Scan on payment` чита целата табела и потоа отфрла околу 160.000 редови преку `Filter: payment_status = 'completed'`. Двете страни одат во `Parallel Hash Left Join`. Оттука произлегуваат две идеи: парцијален covering индекс врз `payment`, за да не се читаат воопшто редовите што не се `completed` и `amount` да се земе директно од индексот, и covering индекс врз `enrollment`, за да се овозможи `Index Only Scan` без пристап до heap-от.
     165
     166==== Чекор 1, парцијален covering индекс врз payment ====
     167
     168{{{
     169CREATE INDEX idx_payment_completed_covering\n    ON payment(enrollment_id) INCLUDE (amount)\n    WHERE payment_status = 'completed';
     170}}}
     171
     172{{{
     173 Sort  (cost=37116.74..37118.99 rows=900 width=112) (actual time=145.784..149.022 rows=30 loops=1)
     174   Sort Key: (((EXTRACT(year FROM e.purchase_date)))::integer) DESC, (((EXTRACT(month FROM e.purchase_date)))::integer) DESC
     175   Sort Method: quicksort  Memory: 26kB
     176   ->  Finalize GroupAggregate  (cost=36815.32..37072.58 rows=900 width=112) (actual time=145.743..149.011 rows=30 loops=1)
     177         Group Key: (EXTRACT(year FROM e.purchase_date)), (EXTRACT(month FROM e.purchase_date))
     178         ->  Gather Merge  (cost=36815.32..37025.33 rows=1800 width=108) (actual time=145.738..148.989 rows=90 loops=1)
     179               Workers Planned: 2
     180               Workers Launched: 2
     181               ->  Sort  (cost=35815.29..35817.54 rows=900 width=108) (actual time=144.522..144.524 rows=30 loops=3)
     182                     Sort Key: (EXTRACT(year FROM e.purchase_date)), (EXTRACT(month FROM e.purchase_date))
     183                     Sort Method: quicksort  Memory: 28kB
     184                     Worker 0:  Sort Method: quicksort  Memory: 28kB
     185                     Worker 1:  Sort Method: quicksort  Memory: 28kB
     186                     ->  Partial HashAggregate  (cost=35755.38..35771.13 rows=900 width=108) (actual time=144.496..144.505 rows=30 loops=3)
     187                           Group Key: EXTRACT(year FROM e.purchase_date), EXTRACT(month FROM e.purchase_date)
     188                           Batches: 1  Memory Usage: 57kB
     189                           Worker 0:  Batches: 1  Memory Usage: 57kB
     190                           Worker 1:  Batches: 1  Memory Usage: 57kB
     191                           ->  Parallel Hash Left Join  (cost=15460.05..32422.05 rows=333333 width=81) (actual time=60.990..113.876 rows=266667 loops=3)
     192                                 Hash Cond: (e.enrollment_id = p.enrollment_id)
     193                                 ->  Parallel Seq Scan on enrollment e  (cost=0.00..9864.33 rows=333333 width=12) (actual time=0.018..13.806 rows=266667 loops=3)
     194                                 ->  Parallel Hash  (cost=10833.67..10833.67 rows=266111 width=13) (actual time=28.989..28.989 rows=213333 loops=3)
     195                                       Buckets: 262144  Batches: 8  Memory Usage: 5856kB
     196                                       ->  Parallel Seq Scan on payment p  (cost=0.00..10833.67 rows=266111 width=13) (actual time=0.020..14.149 rows=213333 loops=3)
     197                                             Filter: (payment_status = 'completed'::payment_status)
     198                                             Rows Removed by Filter: 53333
     199 Planning Time: 0.327 ms
     200 Execution Time: 149.062 ms
     201}}}
     202
     203Време: '''153,0 ms''' (три пуштања: 156,5 / 153,5 / 149,1 ms), односно без ефект (+0,5%, во границите на мерната грешка).
     204
     205Планот е непроменет. Планерот го игнорира индексот и останува на `Parallel Seq Scan on payment`.
     206
     207==== Чекор 2, плус covering индекс врз enrollment ====
     208
     209{{{
     210CREATE INDEX idx_enrollment_purchase_date_cov\n    ON enrollment(purchase_date) INCLUDE (enrollment_id);
     211}}}
     212
     213{{{
     214 Sort  (cost=37116.74..37118.99 rows=900 width=112) (actual time=162.435..165.607 rows=30 loops=1)
     215   Sort Key: (((EXTRACT(year FROM e.purchase_date)))::integer) DESC, (((EXTRACT(month FROM e.purchase_date)))::integer) DESC
     216   Sort Method: quicksort  Memory: 26kB
     217   ->  Finalize GroupAggregate  (cost=36815.32..37072.58 rows=900 width=112) (actual time=162.379..165.593 rows=30 loops=1)
     218         Group Key: (EXTRACT(year FROM e.purchase_date)), (EXTRACT(month FROM e.purchase_date))
     219         ->  Gather Merge  (cost=36815.32..37025.33 rows=1800 width=108) (actual time=162.369..165.558 rows=90 loops=1)
     220               Workers Planned: 2
     221               Workers Launched: 2
     222               ->  Sort  (cost=35815.29..35817.54 rows=900 width=108) (actual time=161.332..161.335 rows=30 loops=3)
     223                     Sort Key: (EXTRACT(year FROM e.purchase_date)), (EXTRACT(month FROM e.purchase_date))
     224                     Sort Method: quicksort  Memory: 28kB
     225                     Worker 0:  Sort Method: quicksort  Memory: 28kB
     226                     Worker 1:  Sort Method: quicksort  Memory: 28kB
     227                     ->  Partial HashAggregate  (cost=35755.38..35771.13 rows=900 width=108) (actual time=161.296..161.307 rows=30 loops=3)
     228                           Group Key: EXTRACT(year FROM e.purchase_date), EXTRACT(month FROM e.purchase_date)
     229                           Batches: 1  Memory Usage: 57kB
     230                           Worker 0:  Batches: 1  Memory Usage: 57kB
     231                           Worker 1:  Batches: 1  Memory Usage: 57kB
     232                           ->  Parallel Hash Left Join  (cost=15460.05..32422.05 rows=333333 width=81) (actual time=69.057..128.267 rows=266667 loops=3)
     233                                 Hash Cond: (e.enrollment_id = p.enrollment_id)
     234                                 ->  Parallel Seq Scan on enrollment e  (cost=0.00..9864.33 rows=333333 width=12) (actual time=0.021..14.344 rows=266667 loops=3)
     235                                 ->  Parallel Hash  (cost=10833.67..10833.67 rows=266111 width=13) (actual time=31.942..31.943 rows=213333 loops=3)
     236                                       Buckets: 262144  Batches: 8  Memory Usage: 5856kB
     237                                       ->  Parallel Seq Scan on payment p  (cost=0.00..10833.67 rows=266111 width=13) (actual time=0.023..15.409 rows=213333 loops=3)
     238                                             Filter: (payment_status = 'completed'::payment_status)
     239                                             Rows Removed by Filter: 53333
     240 Planning Time: 0.315 ms
     241 Execution Time: 165.649 ms
     242}}}
     243
     244Време: '''161,7 ms''' (три пуштања: 162,2 / 157,2 / 165,6 ms), односно забавување од 5,1%.
     245
     246И вториот индекс е игнориран, и двата scan-а остануваат `Seq Scan`.
     247
     248==== Проверка со присилно користење на индексите ====
     249
     250За да се потврди дека планерот е во право, индексите се наметнати со `SET enable_seqscan = off`.
     251
     252{{{
     253 Sort  (cost=51799.37..51801.62 rows=900 width=112) (actual time=274.477..277.882 rows=30 loops=1)
     254   Sort Key: (((EXTRACT(year FROM e.purchase_date)))::integer) DESC, (((EXTRACT(month FROM e.purchase_date)))::integer) DESC
     255   Sort Method: quicksort  Memory: 26kB
     256   ->  Finalize GroupAggregate  (cost=51497.95..51755.21 rows=900 width=112) (actual time=274.376..277.815 rows=30 loops=1)
     257         Group Key: (EXTRACT(year FROM e.purchase_date)), (EXTRACT(month FROM e.purchase_date))
     258         ->  Gather Merge  (cost=51497.95..51707.96 rows=1800 width=108) (actual time=274.363..277.783 rows=90 loops=1)
     259               Workers Planned: 2
     260               Workers Launched: 2
     261               ->  Sort  (cost=50497.92..50500.17 rows=900 width=108) (actual time=271.312..271.315 rows=30 loops=3)
     262                     Sort Key: (EXTRACT(year FROM e.purchase_date)), (EXTRACT(month FROM e.purchase_date))
     263                     Sort Method: quicksort  Memory: 28kB
     264                     Worker 0:  Sort Method: quicksort  Memory: 28kB
     265                     Worker 1:  Sort Method: quicksort  Memory: 28kB
     266                     ->  Partial HashAggregate  (cost=50438.01..50453.76 rows=900 width=108) (actual time=271.276..271.285 rows=30 loops=3)
     267                           Group Key: EXTRACT(year FROM e.purchase_date), EXTRACT(month FROM e.purchase_date)
     268                           Batches: 1  Memory Usage: 57kB
     269                           Worker 0:  Batches: 1  Memory Usage: 57kB
     270                           Worker 1:  Batches: 1  Memory Usage: 57kB
     271                           ->  Parallel Hash Left Join  (cost=20337.69..47104.68 rows=333333 width=81) (actual time=181.814..237.345 rows=266667 loops=3)
     272                                 Hash Cond: (e.enrollment_id = p.enrollment_id)
     273                                 ->  Parallel Index Only Scan using idx_enrollment_purchase_date_cov on enrollment e  (cost=0.42..19669.76 rows=333333 width=12) (actual time=0.085..43.629 rows=266667 loops=3)
     274                                       Heap Fetches: 0
     275                                 ->  Parallel Hash  (cost=15710.87..15710.87 rows=266111 width=13) (actual time=101.237..101.239 rows=213333 loops=3)
     276                                       Buckets: 262144  Batches: 8  Memory Usage: 5856kB
     277                                       ->  Parallel Index Only Scan using idx_payment_completed_covering on payment p  (cost=0.42..15710.87 rows=266111 width=13) (actual time=0.101..74.900 rows=213333 loops=3)
     278                                             Heap Fetches: 0
     279 Planning Time: 1.117 ms
     280 Execution Time: 278.000 ms
     281}}}
     282
     283Време: 278,0 ms наспроти 153,8 ms, односно '''1,8 пати побавно'''. Планерот донесе точната одлука.
     284
     285==== Проверка со WHERE услов ====
     286
     287Тврдењето дека индекс нема што да прескокне вреди да се провери директно. Истото query, ограничено на последните околу три месеци, со обичен индекс врз `purchase_date`:
     288
     289{{{
     290CREATE INDEX idx_enrollment_purchase_date ON enrollment(purchase_date);
     291}}}
     292
     293{{{
     294WHERE e.purchase_date >= DATE '2025-04-01'
     295}}}
     296
     297Тоа зафаќа 70.152 од 800.000 реда, односно околу 8,8%.
     298
     299{{{
     300 Sort  (cost=21376.64..21378.89 rows=900 width=112) (actual time=36.787..36.950 rows=3 loops=1)
     301   Sort Key: (((EXTRACT(year FROM e.purchase_date)))::integer) DESC, (((EXTRACT(month FROM e.purchase_date)))::integer) DESC
     302   Sort Method: quicksort  Memory: 25kB
     303   ->  Finalize GroupAggregate  (cost=21075.21..21332.48 rows=900 width=112) (actual time=36.779..36.943 rows=3 loops=1)
     304         Group Key: (EXTRACT(year FROM e.purchase_date)), (EXTRACT(month FROM e.purchase_date))
     305         ->  Gather Merge  (cost=21075.21..21285.23 rows=1800 width=108) (actual time=36.774..36.938 rows=9 loops=1)
     306               Workers Planned: 2
     307               Workers Launched: 2
     308               ->  Sort  (cost=20075.19..20077.44 rows=900 width=108) (actual time=33.689..33.691 rows=3 loops=3)
     309                     Sort Key: (EXTRACT(year FROM e.purchase_date)), (EXTRACT(month FROM e.purchase_date))
     310                     Sort Method: quicksort  Memory: 25kB
     311                     Worker 0:  Sort Method: quicksort  Memory: 25kB
     312                     Worker 1:  Sort Method: quicksort  Memory: 25kB
     313                     ->  Partial HashAggregate  (cost=20015.28..20031.03 rows=900 width=108) (actual time=33.678..33.682 rows=3 loops=3)
     314                           Group Key: EXTRACT(year FROM e.purchase_date), EXTRACT(month FROM e.purchase_date)
     315                           Batches: 1  Memory Usage: 49kB
     316                           Worker 0:  Batches: 1  Memory Usage: 49kB
     317                           Worker 1:  Batches: 1  Memory Usage: 49kB
     318                           ->  Parallel Hash Right Join  (cost=8046.92..19724.57 rows=29071 width=81) (actual time=4.891..31.141 rows=23384 loops=3)
     319                                 Hash Cond: (p.enrollment_id = e.enrollment_id)
     320                                 ->  Parallel Seq Scan on payment p  (cost=0.00..10833.67 rows=266145 width=13) (actual time=0.023..12.330 rows=213333 loops=3)
     321                                       Filter: (payment_status = 'completed'::payment_status)
     322                                       Rows Removed by Filter: 53333
     323                                 ->  Parallel Hash  (cost=7683.53..7683.53 rows=29071 width=12) (actual time=4.454..4.455 rows=23384 loops=3)
     324                                       Buckets: 131072  Batches: 1  Memory Usage: 4384kB
     325                                       ->  Parallel Bitmap Heap Scan on enrollment e  (cost=789.14..7683.53 rows=29071 width=12) (actual time=0.812..2.932 rows=23384 loops=3)
     326                                             Recheck Cond: (purchase_date >= '2025-04-01'::date)
     327                                             Heap Blocks: exact=548
     328                                             ->  Bitmap Index Scan on idx_enrollment_purchase_date  (cost=0.00..771.70 rows=69770 width=0) (actual time=0.964..0.964 rows=70152 loops=1)
     329                                                   Index Cond: (purchase_date >= '2025-04-01'::date)
     330 Planning Time: 0.318 ms
     331 Execution Time: 37.000 ms
     332}}}
     333
     334Време: '''38,1 ms''' (три пуштања: 38,9 / 38,4 / 37,0 ms) наспроти 153,8 ms без филтер, односно '''4,0 пати побрзо'''.
     335
     336И овој пат планерот го искористи индексот:
     337
     338{{{
     339->  Bitmap Index Scan on idx_enrollment_purchase_date\n      (actual time=0.964..0.964 rows=70152 loops=1)
     340}}}
     341
     342Ова е клучната разлика. Истиот индекс што беше игнориран во чекор 1 и 2 сега се користи, и тоа со голема добивка, затоа што сега постои услов што исфрлува редови. Индексот не стана подобар, се промени query-то.
     343
     344|| '''Варијанта''' || '''Редови''' || '''Време''' ||
     345|| без `WHERE` (тековна функција) || 800.000 || 153,8 ms ||
     346|| со филтер по период и индекс || 70.152 || 38,1 ms ||
     347
     348==== Заклучок ====
     349
     350Индексите тука не помагаат, и не постои индекс што би помогнал. Причината е во самата природа на query-то, бидејќи тоа е агрегација врз целата табела без ниту еден `WHERE` услов. За да се пресмета збир по месец, мора да се допре секој ред од `enrollment` и `payment`.
     351
     352Индексот е корисен кога служи за да се прескокнат редови. Овде нема што да се прескокне, потребни се сите. Во таа ситуација секвенцијалното читање е оптимално, бидејќи чита цели блокови по редослед на дискот, додека index scan би додал индиректност, прво индекс па heap, без да заштеди ниту еден ред. Присилното мерење го докажува тоа емпириски, `Index Only Scan` е побавен од `Seq Scan` за оваа намена.
     353
     354Вреди да се забележи и дека ова query и онака е најбрзото од четирите. Тоа е така затоа што агрегира во само 30 групи и никогаш не прелева на диск, па тука едноставно нема проблем за решавање.
     355
     356Најважниот наод е мерењето со `WHERE` услов. Со филтер по период истиот индекс дава 4,0 пати забрзување, што значи дека проблемот не е во индексите, туку во тоа што функцијата секогаш пресметува сè. Ако извештајот во практика се гледа по период, најголемата добивка би дошла од додавање параметри:
     357
     358{{{
     359dashboard_monthly_totals(p_from DATE, p_to DATE)
     360}}}
     361
     362=== dashboard_monthly_courses() ===
     363
     364Приход и запишувања по месец, разложено по курс и верзија. Резултатот има 86.400 реда. Ова е најбавната од четирите функции.
     365
     366==== Дефиниција ====
     367
     368{{{
     369-- Monthly course-specific
     370CREATE OR REPLACE FUNCTION dashboard_monthly_courses()
     371    RETURNS TABLE (
     372                      year INTEGER,
     373                      month INTEGER,
     374                      course_id INTEGER,
     375                      course_name TEXT,
     376                      course_description TEXT,
     377                      course_difficulty TEXT,
     378                      course_price NUMERIC,
     379                      version_number INTEGER,
     380                      is_version_active BOOLEAN,
     381                      total_paid_enrollments BIGINT,
     382                      total_students BIGINT,
     383                      total_revenue NUMERIC,
     384                      total_reviews BIGINT,
     385                      average_rating NUMERIC
     386                  ) AS $$
     387    #variable_conflict use_column
     388BEGIN
     389    RETURN QUERY
     390        SELECT
     391            EXTRACT(YEAR FROM p.payment_date)::INTEGER AS year,
     392            EXTRACT(MONTH FROM p.payment_date)::INTEGER AS month,
     393            c.course_id::INTEGER AS course_id,
     394            ct.title_short::TEXT AS course_name,
     395            ct.description_short::TEXT AS course_description,
     396            c.difficulty::TEXT AS course_difficulty,
     397            c.price::NUMERIC AS course_price,
     398            cv.version_number::INTEGER AS version_number,
     399            cv.is_active::BOOLEAN AS is_version_active,
     400            COUNT(e.enrollment_id)::BIGINT AS total_paid_enrollments,
     401            COUNT(DISTINCT e.user_id)::BIGINT AS total_students,
     402            SUM(p.amount)::NUMERIC AS total_revenue,
     403            COUNT(r.review_id)::BIGINT AS total_reviews,
     404            COALESCE(AVG(r.rating), 0)::NUMERIC AS average_rating
     405        FROM course c
     406                 JOIN course_translate ct ON c.course_id = ct.course_id
     407                 JOIN language l ON l.id = ct.language_id
     408                 JOIN course_version cv ON c.course_id = cv.course_id
     409                 JOIN enrollment e ON cv.course_version_id = e.course_version_id
     410                 JOIN payment p ON e.enrollment_id = p.enrollment_id AND p.payment_status = 'completed'
     411                 LEFT JOIN review r ON e.enrollment_id = r.enrollment_id
     412        GROUP BY
     413            EXTRACT(YEAR FROM p.payment_date),
     414            EXTRACT(MONTH FROM p.payment_date),
     415            c.course_id, ct.course_translate_id, cv.course_version_id
     416        ORDER BY year DESC, month DESC, total_revenue DESC, total_students DESC;
     417END;
     418$$ LANGUAGE plpgsql;
     419}}}
     420
     421==== Почетна состојба ====
     422
     423{{{
     424 Sort  (cost=1216174.15..1220973.55 rows=1919760 width=331) (actual time=3403.219..3423.748 rows=86400 loops=1)
     425   Sort Key: (((EXTRACT(year FROM p.payment_date)))::integer) DESC, (((EXTRACT(month FROM p.payment_date)))::integer) DESC, (sum(p.amount)) DESC, (count(DISTINCT e.user_id)) DESC
     426   Sort Method: external merge  Disk: 14728kB
     427   ->  GroupAggregate  (cost=157773.34..425268.25 rows=1919760 width=331) (actual time=488.509..3344.444 rows=86400 loops=1)
     428         Group Key: cv.course_version_id, (EXTRACT(year FROM p.payment_date)), (EXTRACT(month FROM p.payment_date)), c.course_id, ct.course_translate_id
     429         ->  Incremental Sort  (cost=157773.34..305283.25 rows=1919760 width=192) (actual time=488.481..3119.040 rows=1920000 loops=1)
     430               Sort Key: cv.course_version_id, (EXTRACT(year FROM p.payment_date)), (EXTRACT(month FROM p.payment_date)), c.course_id, ct.course_translate_id, e.user_id
     431               Presorted Key: cv.course_version_id
     432               Full-sort Groups: 5400  Sort Method: quicksort  Average Memory: 35kB  Peak Memory: 35kB
     433               Pre-sorted Groups: 5400  Sort Method: quicksort  Average Memory: 67kB  Peak Memory: 68kB
     434               ->  Merge Join  (cost=157752.78..201287.46 rows=1919760 width=192) (actual time=488.170..857.077 rows=1920000 loops=1)
     435                     Merge Cond: (cv.course_version_id = e.course_version_id)
     436                     ->  Nested Loop  (cost=1.00..3496.80 rows=18000 width=91) (actual time=173.855..190.556 rows=18000 loops=1)
     437                           ->  Nested Loop  (cost=0.86..3091.22 rows=18000 width=99) (actual time=173.848..187.208 rows=18000 loops=1)
     438                                 Join Filter: (c.course_id = ct.course_id)
     439                                 ->  Nested Loop  (cost=0.57..1142.36 rows=6000 width=38) (actual time=173.836..178.986 rows=6000 loops=1)
     440                                       ->  Index Scan using course_version_pkey on course_version cv  (cost=0.28..355.58 rows=6000 width=21) (actual time=0.006..1.836 rows=6000 loops=1)
     441                                       ->  Memoize  (cost=0.29..0.33 rows=1 width=17) (actual time=0.029..0.029 rows=1 loops=6000)
     442                                             Cache Key: cv.course_id
     443                                             Cache Mode: logical
     444                                             Hits: 4000  Misses: 2000  Evictions: 0  Overflows: 0  Memory Usage: 237kB
     445                                             ->  Index Scan using course_pkey on course c  (cost=0.28..0.32 rows=1 width=17) (actual time=0.001..0.001 rows=1 loops=2000)
     446                                                   Index Cond: (course_id = cv.course_id)
     447                                 ->  Memoize  (cost=0.29..0.81 rows=3 width=77) (actual time=0.000..0.001 rows=3 loops=6000)
     448                                       Cache Key: cv.course_id
     449                                       Cache Mode: logical
     450                                       Hits: 4000  Misses: 2000  Evictions: 0  Overflows: 0  Memory Usage: 797kB
     451                                       ->  Index Scan using uq_course_translate on course_translate ct  (cost=0.28..0.80 rows=3 width=77) (actual time=0.001..0.002 rows=3 loops=2000)
     452                                             Index Cond: (course_id = cv.course_id)
     453                           ->  Memoize  (cost=0.14..0.16 rows=1 width=8) (actual time=0.000..0.000 rows=1 loops=18000)
     454                                 Cache Key: ct.language_id
     455                                 Cache Mode: logical
     456                                 Hits: 17997  Misses: 3  Evictions: 0  Overflows: 0  Memory Usage: 1kB
     457                                 ->  Index Only Scan using language_pkey on language l  (cost=0.13..0.15 rows=1 width=8) (actual time=0.002..0.002 rows=1 loops=3)
     458                                       Index Cond: (id = ct.language_id)
     459                                       Heap Fetches: 0
     460                     ->  Materialize  (cost=157751.13..160950.73 rows=639920 width=45) (actual time=314.289..406.363 rows=1919998 loops=1)
     461                           ->  Sort  (cost=157751.13..159350.93 rows=639920 width=45) (actual time=314.287..347.268 rows=640000 loops=1)
     462                                 Sort Key: e.course_version_id
     463                                 Sort Method: external merge  Disk: 29944kB
     464                                 ->  Merge Left Join  (cost=1.80..76351.25 rows=639920 width=45) (actual time=0.043..237.686 rows=640000 loops=1)
     465                                       Merge Cond: (e.enrollment_id = r.enrollment_id)
     466                                       ->  Merge Join  (cost=1.38..66767.19 rows=639920 width=33) (actual time=0.031..183.912 rows=640000 loops=1)
     467                                             Merge Cond: (e.enrollment_id = p.enrollment_id)
     468                                             ->  Index Scan using enrollment_pkey on enrollment e  (cost=0.42..27318.42 rows=800000 width=24) (actual time=0.017..55.353 rows=800000 loops=1)
     469                                             ->  Index Scan using uq_payment_enrollment on payment p  (cost=0.42..29454.42 rows=639920 width=17) (actual time=0.010..59.537 rows=640000 loops=1)
     470                                                   Filter: (payment_status = 'completed'::payment_status)
     471                                                   Rows Removed by Filter: 160000
     472                                       ->  Index Scan using uq_review_enrollment on review r  (cost=0.42..6305.42 rows=160000 width=20) (actual time=0.009..12.521 rows=160000 loops=1)
     473 Planning Time: 1.646 ms
     474 JIT:
     475   Functions: 53
     476   Options: Inlining true, Optimization true, Expressions true, Deforming true
     477   Timing: Generation 0.945 ms (Deform 0.511 ms), Inlining 30.437 ms, Optimization 75.801 ms, Emission 67.586 ms, Total 174.768 ms
     478 Execution Time: 3438.197 ms
     479}}}
     480
     481Време: '''3434,4 ms''' (три пуштања: 3446,3 / 3418,8 / 3438,2 ms).
     482
     483Планот покажува неколку работи. `Merge Join` враќа 1.920.000 реда, тројно повеќе од очекуваното. `Incremental Sort` трае од 488 ms до 3119 ms, што значи дека околу 2,6 секунди, односно приблизно 76% од целото време, се троши на едно сортирање. Сортирањата се прелеваат на диск, `external merge Disk: 29944kB` и `external merge Disk: 14728kB`. Во клучот за сортирање влегува и `e.user_id`, поради `COUNT(DISTINCT e.user_id)`. Конечно, `enrollment.course_version_id` нема индекс.
     484
     485Оттука произлегуваат три идеи: индекс врз непокриениот foreign key `enrollment.course_version_id`, композитен индекс `(course_version_id, user_id)` што би можел да го понуди редоследот што `Incremental Sort` го бара, и парцијален covering индекс врз `payment` за наплатените плаќања.
     486
     487==== Чекор 1, индекс врз непокриениот foreign key ====
     488
     489{{{
     490CREATE INDEX idx_enrollment_course_version_id\n    ON enrollment(course_version_id);
     491}}}
     492
     493{{{
     494 Sort  (cost=1213871.37..1218661.37 rows=1916001 width=331) (actual time=3384.601..3405.332 rows=86400 loops=1)
     495   Sort Key: (((EXTRACT(year FROM p.payment_date)))::integer) DESC, (((EXTRACT(month FROM p.payment_date)))::integer) DESC, (sum(p.amount)) DESC, (count(DISTINCT e.user_id)) DESC
     496   Sort Method: external merge  Disk: 14728kB
     497   ->  GroupAggregate  (cost=157587.52..424539.86 rows=1916001 width=331) (actual time=493.498..3324.347 rows=86400 loops=1)
     498         Group Key: cv.course_version_id, (EXTRACT(year FROM p.payment_date)), (EXTRACT(month FROM p.payment_date)), c.course_id, ct.course_translate_id
     499         ->  Incremental Sort  (cost=157587.52..304789.79 rows=1916001 width=192) (actual time=493.453..3100.893 rows=1920000 loops=1)
     500               Sort Key: cv.course_version_id, (EXTRACT(year FROM p.payment_date)), (EXTRACT(month FROM p.payment_date)), c.course_id, ct.course_translate_id, e.user_id
     501               Presorted Key: cv.course_version_id
     502               Full-sort Groups: 5400  Sort Method: quicksort  Average Memory: 35kB  Peak Memory: 35kB
     503               Pre-sorted Groups: 5400  Sort Method: quicksort  Average Memory: 67kB  Peak Memory: 68kB
     504               ->  Merge Join  (cost=157567.00..201024.48 rows=1916001 width=192) (actual time=493.176..843.628 rows=1920000 loops=1)
     505                     Merge Cond: (cv.course_version_id = e.course_version_id)
     506                     ->  Nested Loop  (cost=1.00..3496.80 rows=18000 width=91) (actual time=181.119..197.409 rows=18000 loops=1)
     507                           ->  Nested Loop  (cost=0.86..3091.22 rows=18000 width=99) (actual time=181.113..194.116 rows=18000 loops=1)
     508                                 Join Filter: (c.course_id = ct.course_id)
     509                                 ->  Nested Loop  (cost=0.57..1142.36 rows=6000 width=38) (actual time=181.097..186.273 rows=6000 loops=1)
     510                                       ->  Index Scan using course_version_pkey on course_version cv  (cost=0.28..355.58 rows=6000 width=21) (actual time=0.006..1.839 rows=6000 loops=1)
     511                                       ->  Memoize  (cost=0.29..0.33 rows=1 width=17) (actual time=0.031..0.031 rows=1 loops=6000)
     512                                             Cache Key: cv.course_id
     513                                             Cache Mode: logical
     514                                             Hits: 4000  Misses: 2000  Evictions: 0  Overflows: 0  Memory Usage: 237kB
     515                                             ->  Index Scan using course_pkey on course c  (cost=0.28..0.32 rows=1 width=17) (actual time=0.001..0.001 rows=1 loops=2000)
     516                                                   Index Cond: (course_id = cv.course_id)
     517                                 ->  Memoize  (cost=0.29..0.81 rows=3 width=77) (actual time=0.000..0.001 rows=3 loops=6000)
     518                                       Cache Key: cv.course_id
     519                                       Cache Mode: logical
     520                                       Hits: 4000  Misses: 2000  Evictions: 0  Overflows: 0  Memory Usage: 797kB
     521                                       ->  Index Scan using uq_course_translate on course_translate ct  (cost=0.28..0.80 rows=3 width=77) (actual time=0.001..0.002 rows=3 loops=2000)
     522                                             Index Cond: (course_id = cv.course_id)
     523                           ->  Memoize  (cost=0.14..0.16 rows=1 width=8) (actual time=0.000..0.000 rows=1 loops=18000)
     524                                 Cache Key: ct.language_id
     525                                 Cache Mode: logical
     526                                 Hits: 17997  Misses: 3  Evictions: 0  Overflows: 0  Memory Usage: 1kB
     527                                 ->  Index Only Scan using language_pkey on language l  (cost=0.13..0.15 rows=1 width=8) (actual time=0.002..0.002 rows=1 loops=3)
     528                                       Index Cond: (id = ct.language_id)
     529                                       Heap Fetches: 0
     530                     ->  Materialize  (cost=157565.99..160759.33 rows=638667 width=45) (actual time=312.034..404.507 rows=1919998 loops=1)
     531                           ->  Sort  (cost=157565.99..159162.66 rows=638667 width=45) (actual time=312.032..345.703 rows=640000 loops=1)
     532                                 Sort Key: e.course_version_id
     533                                 Sort Method: external merge  Disk: 29944kB
     534                                 ->  Merge Left Join  (cost=4.28..76334.47 rows=638667 width=45) (actual time=0.046..236.700 rows=640000 loops=1)
     535                                       Merge Cond: (e.enrollment_id = r.enrollment_id)
     536                                       ->  Merge Join  (cost=3.86..66755.26 rows=638667 width=33) (actual time=0.033..183.695 rows=640000 loops=1)
     537                                             Merge Cond: (e.enrollment_id = p.enrollment_id)
     538                                             ->  Index Scan using enrollment_pkey on enrollment e  (cost=0.42..27318.42 rows=800000 width=24) (actual time=0.018..55.760 rows=800000 loops=1)
     539                                             ->  Index Scan using uq_payment_enrollment on payment p  (cost=0.42..29454.42 rows=638667 width=17) (actual time=0.011..59.935 rows=640000 loops=1)
     540                                                   Filter: (payment_status = 'completed'::payment_status)
     541                                                   Rows Removed by Filter: 160000
     542                                       ->  Index Scan using uq_review_enrollment on review r  (cost=0.42..6305.42 rows=160000 width=20) (actual time=0.010..12.617 rows=160000 loops=1)
     543 Planning Time: 1.632 ms
     544 JIT:
     545   Functions: 53
     546   Options: Inlining true, Optimization true, Expressions true, Deforming true
     547   Timing: Generation 0.834 ms (Deform 0.414 ms), Inlining 34.467 ms, Optimization 77.756 ms, Emission 68.858 ms, Total 181.915 ms
     548 Execution Time: 3419.300 ms
     549}}}
     550
     551Време: '''3425,5 ms''' (три пуштања: 3435,3 / 3422,0 / 3419,3 ms), односно без ефект (+0,3%, во границите на мерната грешка).
     552
     553`external merge Disk: 29944kB` е сè уште тука, индексот не го отстрани сортирањето.
     554
     555==== Чекор 2, композитен индекс за редослед и парцијален индекс врз payment ====
     556
     557{{{
     558CREATE INDEX idx_enrollment_cv_user\n    ON enrollment(course_version_id, user_id) INCLUDE (enrollment_id);\n\nCREATE INDEX idx_payment_completed_cov2\n    ON payment(enrollment_id) INCLUDE (amount, payment_date)\n    WHERE payment_status = 'completed';
     559}}}
     560
     561{{{
     562 Sort  (cost=1210572.31..1215378.11 rows=1922319 width=331) (actual time=3346.915..3366.914 rows=86400 loops=1)
     563   Sort Key: (((EXTRACT(year FROM p.payment_date)))::integer) DESC, (((EXTRACT(month FROM p.payment_date)))::integer) DESC, (sum(p.amount)) DESC, (count(DISTINCT e.user_id)) DESC
     564   Sort Method: external merge  Disk: 14728kB
     565   ->  GroupAggregate  (cost=150730.70..418596.88 rows=1922319 width=331) (actual time=422.918..3285.159 rows=86400 loops=1)
     566         Group Key: cv.course_version_id, (EXTRACT(year FROM p.payment_date)), (EXTRACT(month FROM p.payment_date)), c.course_id, ct.course_translate_id
     567         ->  Incremental Sort  (cost=150730.70..298451.94 rows=1922319 width=192) (actual time=422.888..3057.450 rows=1920000 loops=1)
     568               Sort Key: cv.course_version_id, (EXTRACT(year FROM p.payment_date)), (EXTRACT(month FROM p.payment_date)), c.course_id, ct.course_translate_id, e.user_id
     569               Presorted Key: cv.course_version_id
     570               Full-sort Groups: 5400  Sort Method: quicksort  Average Memory: 35kB  Peak Memory: 35kB
     571               Pre-sorted Groups: 5400  Sort Method: quicksort  Average Memory: 67kB  Peak Memory: 68kB
     572               ->  Merge Join  (cost=150710.10..194299.21 rows=1922319 width=192) (actual time=422.594..786.566 rows=1920000 loops=1)
     573                     Merge Cond: (cv.course_version_id = e.course_version_id)
     574                     ->  Nested Loop  (cost=1.00..3496.80 rows=18000 width=91) (actual time=161.040..177.890 rows=18000 loops=1)
     575                           ->  Nested Loop  (cost=0.86..3091.22 rows=18000 width=99) (actual time=161.033..174.454 rows=18000 loops=1)
     576                                 Join Filter: (c.course_id = ct.course_id)
     577                                 ->  Nested Loop  (cost=0.57..1142.36 rows=6000 width=38) (actual time=161.022..166.381 rows=6000 loops=1)
     578                                       ->  Index Scan using course_version_pkey on course_version cv  (cost=0.28..355.58 rows=6000 width=21) (actual time=0.006..1.895 rows=6000 loops=1)
     579                                       ->  Memoize  (cost=0.29..0.33 rows=1 width=17) (actual time=0.027..0.027 rows=1 loops=6000)
     580                                             Cache Key: cv.course_id
     581                                             Cache Mode: logical
     582                                             Hits: 4000  Misses: 2000  Evictions: 0  Overflows: 0  Memory Usage: 237kB
     583                                             ->  Index Scan using course_pkey on course c  (cost=0.28..0.32 rows=1 width=17) (actual time=0.001..0.001 rows=1 loops=2000)
     584                                                   Index Cond: (course_id = cv.course_id)
     585                                 ->  Memoize  (cost=0.29..0.81 rows=3 width=77) (actual time=0.000..0.001 rows=3 loops=6000)
     586                                       Cache Key: cv.course_id
     587                                       Cache Mode: logical
     588                                       Hits: 4000  Misses: 2000  Evictions: 0  Overflows: 0  Memory Usage: 797kB
     589                                       ->  Index Scan using uq_course_translate on course_translate ct  (cost=0.28..0.80 rows=3 width=77) (actual time=0.001..0.002 rows=3 loops=2000)
     590                                             Index Cond: (course_id = cv.course_id)
     591                           ->  Memoize  (cost=0.14..0.16 rows=1 width=8) (actual time=0.000..0.000 rows=1 loops=18000)
     592                                 Cache Key: ct.language_id
     593                                 Cache Mode: logical
     594                                 Hits: 17997  Misses: 3  Evictions: 0  Overflows: 0  Memory Usage: 1kB
     595                                 ->  Index Only Scan using language_pkey on language l  (cost=0.13..0.15 rows=1 width=8) (actual time=0.002..0.002 rows=1 loops=3)
     596                                       Index Cond: (id = ct.language_id)
     597                                       Heap Fetches: 0
     598                     ->  Materialize  (cost=150709.09..153912.96 rows=640773 width=45) (actual time=261.530..354.982 rows=1919998 loops=1)
     599                           ->  Sort  (cost=150709.09..152311.03 rows=640773 width=45) (actual time=261.528..293.805 rows=640000 loops=1)
     600                                 Sort Key: e.course_version_id
     601                                 Sort Method: external merge  Disk: 29944kB
     602                                 ->  Merge Left Join  (cost=2.33..69196.29 rows=640773 width=45) (actual time=0.026..190.549 rows=640000 loops=1)
     603                                       Merge Cond: (e.enrollment_id = r.enrollment_id)
     604                                       ->  Merge Join  (cost=1.91..59607.51 rows=640773 width=33) (actual time=0.017..141.685 rows=640000 loops=1)
     605                                             Merge Cond: (e.enrollment_id = p.enrollment_id)
     606                                             ->  Index Scan using enrollment_pkey on enrollment e  (cost=0.42..27318.42 rows=800000 width=24) (actual time=0.008..44.448 rows=800000 loops=1)
     607                                             ->  Index Only Scan using idx_payment_completed_cov2 on payment p  (cost=0.42..22280.02 rows=640773 width=17) (actual time=0.006..27.571 rows=640000 loops=1)
     608                                                   Heap Fetches: 0
     609                                       ->  Index Scan using uq_review_enrollment on review r  (cost=0.42..6305.42 rows=160000 width=20) (actual time=0.006..9.342 rows=160000 loops=1)
     610 Planning Time: 1.767 ms
     611 JIT:
     612   Functions: 49
     613   Options: Inlining true, Optimization true, Expressions true, Deforming true
     614   Timing: Generation 0.946 ms (Deform 0.461 ms), Inlining 30.944 ms, Optimization 68.381 ms, Emission 61.678 ms, Total 161.949 ms
     615 Execution Time: 3381.984 ms
     616}}}
     617
     618Време: '''3360,9 ms''' (три пуштања: 3354,5 / 3346,1 / 3382,0 ms), односно маргинално забрзување од 2,1%.
     619
     620Овде има мал напредок и тој е видлив во планот, бидејќи парцијалниот индекс навистина се користи:
     621
     622{{{
     623->  Index Only Scan using idx_payment_completed_cov2 on payment p\n      (actual time=0.006..27.571 rows=640000 loops=1)
     624}}}
     625
     626Наспроти оригиналниот `Index Scan ... (actual time=0.010..59.537)`, тој јазол стана 2,2 пати побрз. Но бидејќи носи само околу 30 ms од вкупно околу 3400 ms, крајниот ефект е околу 2%. `Incremental Sort` и понатаму троши 3057 ms и останува недопрен.
     627
     628==== Проверка со work_mem ====
     629
     630Бидејќи сортирањата се прелеваат на диск, тестирано е и подигање на `work_mem` од 4 MB на 256 MB.
     631
     632{{{
     633Sort Method: quicksort  Memory: 18260kB     (наместо external merge Disk: 14728kB)\nSort Method: quicksort  Memory: 62077kB     (наместо external merge Disk: 29944kB)\nExecution Time: 3388.270 ms
     634}}}
     635
     636Прелевањето на диск целосно исчезна, но времето остана речиси исто, 3388 ms наспроти 3434,4 ms. Заклучокот е дека тесното грло не е диск I/O, бидејќи оперативниот систем и онака ги држеше тие привремени датотеки во page cache. Проблемот е чисто процесорски, сортирање и агрегирање на 1,92 милиони редови.
     637
     638==== Од каде доаѓаат 1.920.000 редови ====
     639
     640Query-то содржи join кон `language` без филтер по јазик:
     641
     642{{{
     643JOIN language l ON l.id = ct.language_id
     644}}}
     645
     646Во базата има 3 јазици, `mk`, `en` и `sq`, и точно 3 преводи по курс, па секое запишување се множи со 3. Од 640.000 платени запишувања се добиваат 1.920.000 реда во join-от, а излезот е 86.400 реда наместо 28.800. Со додаден филтер за јазик:
     647
     648{{{
     649JOIN language l ON l.id = ct.language_id AND l.value = 'en'
     650}}}
     651
     652времето паѓа на '''1055,3 ms''', односно '''3,3 пати побрзо''', и излезот станува 28.800 реда.
     653
     654Истото важи и за `dashboard_course_performance()`. И таа функција намерно враќа резултат за сите јазици, и таму трите јазика чинат приблизно двојно повеќе време.
     655
     656Ова не е предлог да се смени функцијата. Враќањето на сите јазици е намерна одлука, а филтрирањето би го променило резултатот, од 86.400 на 28.800 реда. Мерењето е наведено само за да се види каде оди времето. Ако некогаш затреба извештај само за еден јазик, најефтино е јазикот да биде параметар на функцијата, наместо филтрирање врз готовиот резултат, бидејќи тогаш трите пати повеќе редови никогаш не влегуваат во агрегацијата.
     657
     658==== Заклучок ====
     659
     660Индексите донесоа 2,1%, а вистинскиот проблем е на друго место. Тесното грло е `Incremental Sort` врз 1,92 милиони редови, кој сам по себе троши околу три четвртини од времето, и тоа сортирање не може да се замени со индекс поради две причини.
     661
     662Првата е што клучот за сортирање се состои од столбови од повеќе различни табели, `cv.course_version_id`, `c.course_id`, `ct.course_translate_id` и `e.user_id`, плус пресметани изрази со `EXTRACT(...)`. Индексот дава подреденост само во рамките на една табела, па не постои индекс што би го дал тој редослед врз резултатот од join-от. Втората е што `COUNT(DISTINCT e.user_id)` го присилува `user_id` да влезе во клучот за сортирање, бидејќи PostgreSQL мора да ги групира вредностите за да ги изброи уникатните.
     663
     664Затоа поредокот на ефикасност овде е следниот:
     665
     666|| '''Интервенција''' || '''Време''' || '''Добивка''' ||
     667|| почетна состојба || 3434,4 ms || — ||
     668|| индекси, чекор 1 и 2 || 3360,9 ms || 2,1% ||
     669|| `work_mem` 4 MB на 256 MB || 3388,3 ms || околу 1% ||
     670|| филтер по јазик, менува резултат || 1055,3 ms || 69% ||
     671
     672Со други зборови, сите индекси заедно донесоа околу 2%, додека бројот на редови што влегуваат во агрегацијата чини три пати побавно query. Кога планот покажува дека проблемот е бројот на редови, решението е да се намали тој број, а не да се додаваат индекси.
     673
     674=== dashboard_course_performance() ===
     675
     676Изведба на секој курс и секоја негова верзија, за сите времиња и за сите јазици. Функцијата намерно не филтрира по јазик. `course_translate` содржи по 3 преводи за секој курс, `mk`, `en` и `sq`, па резултатот содржи по еден ред за секој превод на секоја верзија на курс, вкупно 18.000 реда од 6.000 верзии.
     677
     678==== Дефиниција ====
     679
     680{{{
     681-- All time course specific, for each course version
     682CREATE OR REPLACE FUNCTION dashboard_course_performance()
     683    RETURNS TABLE (
     684                      course_id INTEGER,
     685                      course_name TEXT,
     686                      course_description TEXT,
     687                      course_difficulty TEXT,
     688                      course_price NUMERIC,
     689                      version_number INTEGER,
     690                      is_version_active BOOLEAN,
     691                      total_enrollments BIGINT,
     692                      paid_enrollments BIGINT,
     693                      trial_enrollments BIGINT,
     694                      total_completions BIGINT,
     695                      total_students BIGINT,
     696                      total_revenue NUMERIC,
     697                      average_rating NUMERIC,
     698                      total_reviews BIGINT,
     699                      avg_days_to_complete INTEGER,
     700                      completion_rate_percentage NUMERIC,
     701                      avg_lecture_completion_percentage NUMERIC
     702                  ) AS $$
     703    #variable_conflict use_column
     704BEGIN
     705    RETURN QUERY
     706        WITH lecture_progress AS (
     707            SELECT ucp.enrollment_id,
     708                   COUNT(*) FILTER (WHERE ucp.is_completed) * 100.0 / COUNT(*) AS completed_percentage
     709            FROM user_course_progress ucp
     710            GROUP BY ucp.enrollment_id
     711        )
     712        SELECT
     713            c.course_id::INTEGER AS course_id,
     714            ct.title_short::TEXT AS course_name,
     715            ct.description_short::TEXT AS course_description,
     716            c.difficulty::TEXT AS course_difficulty,
     717            c.price::NUMERIC AS course_price,
     718            cv.version_number::INTEGER AS version_number,
     719            cv.is_active::BOOLEAN AS is_version_active,
     720            COUNT(e.enrollment_id)::BIGINT AS total_enrollments,
     721            COUNT(CASE WHEN p.payment_status = 'completed' THEN e.enrollment_id END)::BIGINT AS paid_enrollments,
     722            COUNT(CASE WHEN p.payment_id IS NULL THEN e.enrollment_id END)::BIGINT AS trial_enrollments,
     723            COUNT(CASE WHEN e.completion_date IS NOT NULL THEN e.enrollment_id END)::BIGINT AS total_completions,
     724            COUNT(DISTINCT e.user_id)::BIGINT AS total_students,
     725            COALESCE(SUM(CASE WHEN p.payment_status = 'completed' THEN p.amount ELSE 0 END), 0)::NUMERIC AS total_revenue,
     726            COALESCE(AVG(r.rating), 0)::NUMERIC AS average_rating,
     727            COUNT(r.review_id)::BIGINT AS total_reviews,
     728            ROUND(AVG(CASE WHEN e.completion_date IS NOT NULL
     729                               THEN (e.completion_date - e.activation_date)
     730                END), 0)::INTEGER AS avg_days_to_complete,
     731            COALESCE(
     732                    ROUND(100.0 * COUNT(CASE WHEN e.completion_date IS NOT NULL THEN e.enrollment_id END)::NUMERIC
     733                              / NULLIF(COUNT(e.enrollment_id), 0), 2),
     734                    0)::NUMERIC AS completion_rate_percentage,
     735            COALESCE(ROUND(AVG(lp.completed_percentage), 2), 0)::NUMERIC AS avg_lecture_completion_percentage
     736        FROM course c
     737                 JOIN course_translate ct ON c.course_id = ct.course_id
     738                 JOIN language l ON l.id = ct.language_id
     739                 JOIN course_version cv ON c.course_id = cv.course_id
     740                 LEFT JOIN enrollment e ON cv.course_version_id = e.course_version_id   -- include versions with zero enrollments
     741                 LEFT JOIN payment p ON e.enrollment_id = p.enrollment_id               -- include enrollments without payments
     742                 LEFT JOIN review r ON e.enrollment_id = r.enrollment_id                -- include enrollments without reviews
     743                 LEFT JOIN lecture_progress lp ON lp.enrollment_id = e.enrollment_id    -- include enrollments with no progress rows
     744        GROUP BY c.course_id, ct.course_translate_id, cv.course_version_id
     745        ORDER BY total_revenue DESC, completion_rate_percentage DESC;
     746END;
     747$$ LANGUAGE plpgsql;
     748}}}
     749
     750==== Почетна состојба ====
     751
     752{{{
     753 Sort  (cost=1738812.79..1744812.79 rows=2400000 width=351) (actual time=2946.780..2947.192 rows=18000 loops=1)
     754   Sort Key: (COALESCE(sum(CASE WHEN (p.payment_status = 'completed'::payment_status) THEN p.amount ELSE '0'::numeric END), '0'::numeric)) DESC, (COALESCE(round(((100.0 * (count(CASE WHEN (e.completion_date IS NOT NULL) THEN e.enrollment_id ELSE NULL::bigint END))::numeric) / (NULLIF(count(e.enrollment_id), 0))::numeric), 2), '0'::numeric)) DESC
     755   Sort Method: quicksort  Memory: 4026kB
     756   ->  GroupAggregate  (cost=325503.28..713378.56 rows=2400000 width=351) (actual time=936.469..2937.854 rows=18000 loops=1)
     757         Group Key: cv.course_version_id, c.course_id, ct.course_translate_id
     758         ->  Incremental Sort  (cost=325503.28..497378.56 rows=2400000 width=176) (actual time=936.414..2723.983 rows=2400000 loops=1)
     759               Sort Key: cv.course_version_id, c.course_id, ct.course_translate_id, e.user_id
     760               Presorted Key: cv.course_version_id
     761               Full-sort Groups: 6000  Sort Method: quicksort  Average Memory: 35kB  Peak Memory: 35kB
     762               Pre-sorted Groups: 6000  Sort Method: quicksort  Average Memory: 89kB  Peak Memory: 89kB
     763               ->  Merge Left Join  (cost=325479.65..363532.28 rows=2400000 width=176) (actual time=936.128..1192.913 rows=2400000 loops=1)
     764                     Merge Cond: (cv.course_version_id = e.course_version_id)
     765                     ->  Sort  (cost=2523.56..2568.56 rows=18000 width=91) (actual time=236.097..236.814 rows=18000 loops=1)
     766                           Sort Key: cv.course_version_id
     767                           Sort Method: quicksort  Memory: 2692kB
     768                           ->  Hash Join  (cost=253.07..1251.35 rows=18000 width=91) (actual time=230.258..233.758 rows=18000 loops=1)
     769                                 Hash Cond: (c.course_id = cv.course_id)
     770                                 ->  Hash Join  (cost=73.07..853.85 rows=6000 width=86) (actual time=229.654..232.239 rows=6000 loops=1)
     771                                       Hash Cond: (ct.language_id = l.id)
     772                                       ->  Hash Join  (cost=72.00..814.78 rows=6000 width=94) (actual time=0.265..2.501 rows=6000 loops=1)
     773                                             Hash Cond: (ct.course_id = c.course_id)
     774                                             ->  Seq Scan on course_translate ct  (cost=0.00..727.00 rows=6000 width=77) (actual time=0.023..1.510 rows=6000 loops=1)
     775                                             ->  Hash  (cost=47.00..47.00 rows=2000 width=17) (actual time=0.235..0.235 rows=2000 loops=1)
     776                                                   Buckets: 2048  Batches: 1  Memory Usage: 112kB
     777                                                   ->  Seq Scan on course c  (cost=0.00..47.00 rows=2000 width=17) (actual time=0.009..0.133 rows=2000 loops=1)
     778                                       ->  Hash  (cost=1.03..1.03 rows=3 width=8) (actual time=229.383..229.383 rows=3 loops=1)
     779                                             Buckets: 1024  Batches: 1  Memory Usage: 9kB
     780                                             ->  Seq Scan on language l  (cost=0.00..1.03 rows=3 width=8) (actual time=229.370..229.373 rows=3 loops=1)
     781                                 ->  Hash  (cost=105.00..105.00 rows=6000 width=21) (actual time=0.599..0.599 rows=6000 loops=1)
     782                                       Buckets: 8192  Batches: 1  Memory Usage: 393kB
     783                                       ->  Seq Scan on course_version cv  (cost=0.00..105.00 rows=6000 width=21) (actual time=0.007..0.300 rows=6000 loops=1)
     784                     ->  Materialize  (cost=322955.29..326955.29 rows=800000 width=93) (actual time=700.004..814.939 rows=2399998 loops=1)
     785                           ->  Sort  (cost=322955.29..324955.29 rows=800000 width=93) (actual time=700.002..742.481 rows=800000 loops=1)
     786                                 Sort Key: e.course_version_id
     787                                 Sort Method: external merge  Disk: 63912kB
     788                                 ->  Merge Left Join  (cost=1.70..162483.72 rows=800000 width=93) (actual time=0.069..533.088 rows=800000 loops=1)
     789                                       Merge Cond: (e.enrollment_id = ucp.enrollment_id)
     790                                       ->  Merge Left Join  (cost=1.27..77078.27 rows=800000 width=61) (actual time=0.040..257.927 rows=800000 loops=1)
     791                                             Merge Cond: (e.enrollment_id = r.enrollment_id)
     792                                             ->  Merge Left Join  (cost=0.85..66772.85 rows=800000 width=49) (actual time=0.027..195.293 rows=800000 loops=1)
     793                                                   Merge Cond: (e.enrollment_id = p.enrollment_id)
     794                                                   ->  Index Scan using enrollment_pkey on enrollment e  (cost=0.42..27318.42 rows=800000 width=32) (actual time=0.014..54.881 rows=800000 loops=1)
     795                                                   ->  Index Scan using uq_payment_enrollment on payment p  (cost=0.42..27454.42 rows=800000 width=25) (actual time=0.009..54.968 rows=800000 loops=1)
     796                                             ->  Index Scan using uq_review_enrollment on review r  (cost=0.42..6305.42 rows=160000 width=20) (actual time=0.009..12.316 rows=160000 loops=1)
     797                                       ->  GroupAggregate  (cost=0.42..76660.31 rows=299784 width=40) (actual time=0.026..223.124 rows=320000 loops=1)
     798                                             Group Key: ucp.enrollment_id
     799                                             ->  Index Scan using uq_ucp on user_course_progress ucp  (cost=0.42..63464.63 rows=960000 width=9) (actual time=0.014..120.943 rows=960000 loops=1)
     800 Planning Time: 1.689 ms
     801 JIT:
     802   Functions: 58
     803   Options: Inlining true, Optimization true, Expressions true, Deforming true
     804   Timing: Generation 1.016 ms (Deform 0.503 ms), Inlining 31.592 ms, Optimization 104.539 ms, Emission 93.272 ms, Total 230.418 ms
     805 Execution Time: 2958.273 ms
     806}}}
     807
     808Време: '''2974,0 ms''' (три пуштања: 2960,8 / 3003,1 / 2958,3 ms).
     809
     810Од планот, `Incremental Sort` трае од 936 ms до 2724 ms, значи околу 1530 ms или приблизно 52% од целото време се троши на едно сортирање. `Merge Left Join` враќа 2.400.000 реда, а функцијата на крај враќа само 18.000. `Materialize` враќа 2.399.998 реда од 800.000 сортирани, што значи дека истите редови се препрочитуваат по еднаш за секој јазик. Сортирањето по `e.course_version_id` се прелева на диск со `external merge Disk: 63912kB`. Најскапиот поединечен scan е `Index Scan using uq_ucp on user_course_progress`, кој чита 960.000 редови за 120,9 ms. И тука `enrollment.course_version_id` нема индекс.
     811
     812Двете идеи се индекс врз `enrollment.course_version_id`, за да се избегне големото сортирање, и covering индекс врз `user_course_progress`, за CTE-то `lecture_progress` да може да работи преку `Index Only Scan` без пристап до heap-от.
     813
     814==== Чекор 1, индекс врз enrollment.course_version_id ====
     815
     816{{{
     817CREATE INDEX idx_enrollment_course_version_id\n    ON enrollment(course_version_id);
     818}}}
     819
     820{{{
     821 Sort  (cost=1738846.95..1744846.95 rows=2400000 width=351) (actual time=2943.828..2944.239 rows=18000 loops=1)
     822   Sort Key: (COALESCE(sum(CASE WHEN (p.payment_status = 'completed'::payment_status) THEN p.amount ELSE '0'::numeric END), '0'::numeric)) DESC, (COALESCE(round(((100.0 * (count(CASE WHEN (e.completion_date IS NOT NULL) THEN e.enrollment_id ELSE NULL::bigint END))::numeric) / (NULLIF(count(e.enrollment_id), 0))::numeric), 2), '0'::numeric)) DESC
     823   Sort Method: quicksort  Memory: 4026kB
     824   ->  GroupAggregate  (cost=325500.08..713412.72 rows=2400000 width=351) (actual time=931.922..2934.137 rows=18000 loops=1)
     825         Group Key: cv.course_version_id, c.course_id, ct.course_translate_id
     826         ->  Incremental Sort  (cost=325500.08..497412.72 rows=2400000 width=176) (actual time=931.861..2720.117 rows=2400000 loops=1)
     827               Sort Key: cv.course_version_id, c.course_id, ct.course_translate_id, e.user_id
     828               Presorted Key: cv.course_version_id
     829               Full-sort Groups: 6000  Sort Method: quicksort  Average Memory: 35kB  Peak Memory: 35kB
     830               Pre-sorted Groups: 6000  Sort Method: quicksort  Average Memory: 89kB  Peak Memory: 89kB
     831               ->  Merge Left Join  (cost=325476.44..363566.44 rows=2400000 width=176) (actual time=931.564..1184.598 rows=2400000 loops=1)
     832                     Merge Cond: (cv.course_version_id = e.course_version_id)
     833                     ->  Sort  (cost=2523.56..2568.56 rows=18000 width=91) (actual time=234.588..235.294 rows=18000 loops=1)
     834                           Sort Key: cv.course_version_id
     835                           Sort Method: quicksort  Memory: 2692kB
     836                           ->  Hash Join  (cost=253.07..1251.35 rows=18000 width=91) (actual time=228.752..232.321 rows=18000 loops=1)
     837                                 Hash Cond: (c.course_id = cv.course_id)
     838                                 ->  Hash Join  (cost=73.07..853.85 rows=6000 width=86) (actual time=228.156..230.803 rows=6000 loops=1)
     839                                       Hash Cond: (ct.language_id = l.id)
     840                                       ->  Hash Join  (cost=72.00..814.78 rows=6000 width=94) (actual time=0.261..2.581 rows=6000 loops=1)
     841                                             Hash Cond: (ct.course_id = c.course_id)
     842                                             ->  Seq Scan on course_translate ct  (cost=0.00..727.00 rows=6000 width=77) (actual time=0.023..1.592 rows=6000 loops=1)
     843                                             ->  Hash  (cost=47.00..47.00 rows=2000 width=17) (actual time=0.230..0.231 rows=2000 loops=1)
     844                                                   Buckets: 2048  Batches: 1  Memory Usage: 112kB
     845                                                   ->  Seq Scan on course c  (cost=0.00..47.00 rows=2000 width=17) (actual time=0.008..0.136 rows=2000 loops=1)
     846                                       ->  Hash  (cost=1.03..1.03 rows=3 width=8) (actual time=227.888..227.888 rows=3 loops=1)
     847                                             Buckets: 1024  Batches: 1  Memory Usage: 9kB
     848                                             ->  Seq Scan on language l  (cost=0.00..1.03 rows=3 width=8) (actual time=227.875..227.878 rows=3 loops=1)
     849                                 ->  Hash  (cost=105.00..105.00 rows=6000 width=21) (actual time=0.591..0.592 rows=6000 loops=1)
     850                                       Buckets: 8192  Batches: 1  Memory Usage: 393kB
     851                                       ->  Seq Scan on course_version cv  (cost=0.00..105.00 rows=6000 width=21) (actual time=0.006..0.295 rows=6000 loops=1)
     852                     ->  Materialize  (cost=322952.88..326952.88 rows=800000 width=93) (actual time=696.947..810.881 rows=2399998 loops=1)
     853                           ->  Sort  (cost=322952.88..324952.88 rows=800000 width=93) (actual time=696.944..738.560 rows=800000 loops=1)
     854                                 Sort Key: e.course_version_id
     855                                 Sort Method: external merge  Disk: 63912kB
     856                                 ->  Merge Left Join  (cost=1.70..162481.32 rows=800000 width=93) (actual time=0.071..528.730 rows=800000 loops=1)
     857                                       Merge Cond: (e.enrollment_id = ucp.enrollment_id)
     858                                       ->  Merge Left Join  (cost=1.27..77075.86 rows=800000 width=61) (actual time=0.040..258.573 rows=800000 loops=1)
     859                                             Merge Cond: (e.enrollment_id = r.enrollment_id)
     860                                             ->  Merge Left Join  (cost=0.85..66770.86 rows=800000 width=49) (actual time=0.028..195.985 rows=800000 loops=1)
     861                                                   Merge Cond: (e.enrollment_id = p.enrollment_id)
     862                                                   ->  Index Scan using enrollment_pkey on enrollment e  (cost=0.42..27318.42 rows=800000 width=32) (actual time=0.015..55.784 rows=800000 loops=1)
     863                                                   ->  Index Scan using uq_payment_enrollment on payment p  (cost=0.42..27454.42 rows=800000 width=25) (actual time=0.008..55.376 rows=800000 loops=1)
     864                                             ->  Index Scan using uq_review_enrollment on review r  (cost=0.42..6305.42 rows=160000 width=20) (actual time=0.008..12.441 rows=160000 loops=1)
     865                                       ->  GroupAggregate  (cost=0.42..76660.31 rows=299784 width=40) (actual time=0.028..219.215 rows=320000 loops=1)
     866                                             Group Key: ucp.enrollment_id
     867                                             ->  Index Scan using uq_ucp on user_course_progress ucp  (cost=0.42..63464.63 rows=960000 width=9) (actual time=0.013..117.947 rows=960000 loops=1)
     868 Planning Time: 1.627 ms
     869 JIT:
     870   Functions: 58
     871   Options: Inlining true, Optimization true, Expressions true, Deforming true
     872   Timing: Generation 1.100 ms (Deform 0.571 ms), Inlining 31.834 ms, Optimization 103.894 ms, Emission 92.180 ms, Total 229.007 ms
     873 Execution Time: 2955.216 ms
     874}}}
     875
     876Време: '''2949,2 ms''' (три пуштања: 2941,1 / 2951,3 / 2955,2 ms), односно без ефект (+0,8%, во границите на мерната грешка).
     877
     878Индексот не е искористен. Планерот останува на веригата merge join-ови по `enrollment_id`, бидејќи `payment`, `review` и `user_course_progress` сите се спојуваат по `enrollment_id` и тие индекси веќе постојат, па дури потоа сортира по `course_version_id`. `external merge Disk: 63912kB` останува.
     879
     880==== Чекор 2, covering индекс врз user_course_progress ====
     881
     882{{{
     883CREATE INDEX idx_ucp_enrollment_completed\n    ON user_course_progress(enrollment_id) INCLUDE (is_completed);
     884}}}
     885
     886{{{
     887 Sort  (cost=1705431.82..1711431.82 rows=2400000 width=351) (actual time=2850.269..2850.660 rows=18000 loops=1)
     888   Sort Key: (COALESCE(sum(CASE WHEN (p.payment_status = 'completed'::payment_status) THEN p.amount ELSE '0'::numeric END), '0'::numeric)) DESC, (COALESCE(round(((100.0 * (count(CASE WHEN (e.completion_date IS NOT NULL) THEN e.enrollment_id ELSE NULL::bigint END))::numeric) / (NULLIF(count(e.enrollment_id), 0))::numeric), 2), '0'::numeric)) DESC
     889   Sort Method: quicksort  Memory: 4026kB
     890   ->  GroupAggregate  (cost=292084.95..679997.59 rows=2400000 width=351) (actual time=821.100..2841.442 rows=18000 loops=1)
     891         Group Key: cv.course_version_id, c.course_id, ct.course_translate_id
     892         ->  Incremental Sort  (cost=292084.95..463997.59 rows=2400000 width=176) (actual time=821.042..2626.356 rows=2400000 loops=1)
     893               Sort Key: cv.course_version_id, c.course_id, ct.course_translate_id, e.user_id
     894               Presorted Key: cv.course_version_id
     895               Full-sort Groups: 6000  Sort Method: quicksort  Average Memory: 35kB  Peak Memory: 35kB
     896               Pre-sorted Groups: 6000  Sort Method: quicksort  Average Memory: 89kB  Peak Memory: 89kB
     897               ->  Merge Left Join  (cost=292061.31..330151.31 rows=2400000 width=176) (actual time=820.735..1077.264 rows=2400000 loops=1)
     898                     Merge Cond: (cv.course_version_id = e.course_version_id)
     899                     ->  Sort  (cost=2523.56..2568.56 rows=18000 width=91) (actual time=230.300..231.048 rows=18000 loops=1)
     900                           Sort Key: cv.course_version_id
     901                           Sort Method: quicksort  Memory: 2692kB
     902                           ->  Hash Join  (cost=253.07..1251.35 rows=18000 width=91) (actual time=224.575..228.017 rows=18000 loops=1)
     903                                 Hash Cond: (c.course_id = cv.course_id)
     904                                 ->  Hash Join  (cost=73.07..853.85 rows=6000 width=86) (actual time=223.945..226.495 rows=6000 loops=1)
     905                                       Hash Cond: (ct.language_id = l.id)
     906                                       ->  Hash Join  (cost=72.00..814.78 rows=6000 width=94) (actual time=0.288..2.497 rows=6000 loops=1)
     907                                             Hash Cond: (ct.course_id = c.course_id)
     908                                             ->  Seq Scan on course_translate ct  (cost=0.00..727.00 rows=6000 width=77) (actual time=0.027..1.526 rows=6000 loops=1)
     909                                             ->  Hash  (cost=47.00..47.00 rows=2000 width=17) (actual time=0.251..0.251 rows=2000 loops=1)
     910                                                   Buckets: 2048  Batches: 1  Memory Usage: 112kB
     911                                                   ->  Seq Scan on course c  (cost=0.00..47.00 rows=2000 width=17) (actual time=0.011..0.154 rows=2000 loops=1)
     912                                       ->  Hash  (cost=1.03..1.03 rows=3 width=8) (actual time=223.648..223.649 rows=3 loops=1)
     913                                             Buckets: 1024  Batches: 1  Memory Usage: 9kB
     914                                             ->  Seq Scan on language l  (cost=0.00..1.03 rows=3 width=8) (actual time=223.634..223.638 rows=3 loops=1)
     915                                 ->  Hash  (cost=105.00..105.00 rows=6000 width=21) (actual time=0.625..0.625 rows=6000 loops=1)
     916                                       Buckets: 8192  Batches: 1  Memory Usage: 393kB
     917                                       ->  Seq Scan on course_version cv  (cost=0.00..105.00 rows=6000 width=21) (actual time=0.008..0.305 rows=6000 loops=1)
     918                     ->  Materialize  (cost=289537.75..293537.75 rows=800000 width=93) (actual time=590.408..704.748 rows=2399998 loops=1)
     919                           ->  Sort  (cost=289537.75..291537.75 rows=800000 width=93) (actual time=590.404..631.143 rows=800000 loops=1)
     920                                 Sort Key: e.course_version_id
     921                                 Sort Method: external merge  Disk: 63912kB
     922                                 ->  Merge Left Join  (cost=1.70..129066.19 rows=800000 width=93) (actual time=0.074..427.971 rows=800000 loops=1)
     923                                       Merge Cond: (e.enrollment_id = ucp.enrollment_id)
     924                                       ->  Merge Left Join  (cost=1.27..77075.86 rows=800000 width=61) (actual time=0.037..255.216 rows=800000 loops=1)
     925                                             Merge Cond: (e.enrollment_id = r.enrollment_id)
     926                                             ->  Merge Left Join  (cost=0.85..66770.86 rows=800000 width=49) (actual time=0.025..192.972 rows=800000 loops=1)
     927                                                   Merge Cond: (e.enrollment_id = p.enrollment_id)
     928                                                   ->  Index Scan using enrollment_pkey on enrollment e  (cost=0.42..27318.42 rows=800000 width=32) (actual time=0.013..55.297 rows=800000 loops=1)
     929                                                   ->  Index Scan using uq_payment_enrollment on payment p  (cost=0.42..27454.42 rows=800000 width=25) (actual time=0.008..55.497 rows=800000 loops=1)
     930                                             ->  Index Scan using uq_review_enrollment on review r  (cost=0.42..6305.42 rows=160000 width=20) (actual time=0.008..12.381 rows=160000 loops=1)
     931                                       ->  GroupAggregate  (cost=0.42..42782.96 rows=320327 width=40) (actual time=0.034..122.050 rows=320000 loops=1)
     932                                             Group Key: ucp.enrollment_id
     933                                             ->  Index Only Scan using idx_ucp_enrollment_completed on user_course_progress ucp  (cost=0.42..29176.42 rows=960000 width=9) (actual time=0.028..46.170 rows=960000 loops=1)
     934                                                   Heap Fetches: 0
     935 Planning Time: 1.702 ms
     936 JIT:
     937   Functions: 56
     938   Options: Inlining true, Optimization true, Expressions true, Deforming true
     939   Timing: Generation 1.158 ms (Deform 0.626 ms), Inlining 32.279 ms, Optimization 102.678 ms, Emission 88.707 ms, Total 224.822 ms
     940 Execution Time: 2862.106 ms
     941}}}
     942
     943Време: '''2846,2 ms''' (три пуштања: 2832,2 / 2844,2 / 2862,1 ms), односно маргинално забрзување од 4,3%.
     944
     945Ова е најдобриот резултат од сите индекси во оваа анализа. Причината е видлива во планот, скапиот scan врз 960.000 редови се промени од:
     946
     947{{{
     948Index Scan using uq_ucp on user_course_progress ucp\n    (actual time=0.014..120.943 rows=960000 loops=1)
     949}}}
     950
     951во:
     952
     953{{{
     954Index Only Scan using idx_ucp_enrollment_completed on user_course_progress ucp\n    (actual time=0.028..46.170 rows=960000 loops=1)
     955}}}
     956
     957Тој јазол стана '''2,6 пати побрз''', од 120,9 ms на 46,2 ms, бидејќи `is_completed` сега се чита директно од индексот и воопшто не се пристапува до heap-от.
     958
     959==== Проверка со work_mem ====
     960
     961Со `work_mem = 256 MB` сортирањето од 63 MB престанува да се прелева на диск.
     962
     963{{{
     964Sort Method: quicksort  Memory: 95827kB\nExecution Time: 3117.918 ms
     965}}}
     966
     967Резултатот е побавен, 3117,9 ms наспроти 2974,0 ms, односно околу 5% позагубено. Причината е што `quicksort` врз околу 96 MB во меморија е поскап од `external merge sort`, кој е оптимизиран за големи количини и чии привремени датотеки и онака се во page cache. Ова е добар потсетник дека поголем `work_mem` не значи автоматски побрзо.
     968
     969==== Колку чини враќањето на сите јазици ====
     970
     971Функцијата намерно враќа резултат за сите три јазика. Вреди сепак да се измери колку чини таа одлука. Со додаден филтер за еден јазик:
     972
     973{{{
     974JOIN language l ON l.id = ct.language_id AND l.value = 'en'
     975}}}
     976
     977и со истите два индекси од чекор 1 и 2, времето паѓа на '''1666,2 ms''' (три пуштања: 1663,9 / 1669,7 / 1665,1 ms), а резултатот има 6.000 наместо 18.000 реда.
     978
     979|| '''Варијанта''' || '''Редови''' || '''Време''' ||
     980|| сите јазици, тековна || 18.000 || 2846,2 ms ||
     981|| само `en` || 6.000 || 1666,2 ms ||
     982
     983Тоа е '''1,71 пати побрзо''', односно 41% помалку време, што е многукратно повеќе од сето што донесоа индексите заедно.
     984
     985Ова не е предлог да се смени функцијата, бидејќи враќањето на сите јазици е намерна одлука и филтрирањето би го променило резултатот. Наведено е само за да се види каде реално оди времето, бидејќи три пати повеќе редови значат приблизно двојно повеќе време. Ако некогаш се појави потреба од извештај само за еден јазик, најефтино е јазикот да биде параметар на функцијата, наместо да се филтрира дополнително врз готовиот резултат.
     986
     987==== Заклучок ====
     988
     989Индексот помогна, но само 4,3%, и тоа е поучно само по себе. Covering индексот врз `user_course_progress` направи тој дел од планот да биде 2,6 пати побрз, но вкупното query забрза само за 4,3%. Ова е директна илустрација на Амдаловиот закон, бидејќи ако забрзаш дел што зафаќа само неколку проценти од времето, крајната добивка не може да надмине тие неколку проценти.
     990
     991Заштедените околу 75 ms се реални, но останатите околу 2850 ms се трошат на `Incremental Sort` врз 2,4 милиони редови, кој е најскапиот дел со околу 1530 ms, на веригата merge join-ови врз 800.000 редови, на сортирањето по `course_version_id` што се прелева на диск, и на `GroupAggregate` со `COUNT(DISTINCT e.user_id)`.
     992
     993Индексот врз `course_version_id` од чекор 1 не помогна воопшто, затоа што планерот има подобра алтернатива. Сите четири табели во веригата, `enrollment`, `payment`, `review` и `user_course_progress`, се спојуваат по `enrollment_id`, а по тој столб веќе постојат unique индекси. Планерот затоа чита сè подредено по `enrollment_id` со merge join-ови, што е поевтино од тоа да влезе преку `course_version_id` и потоа да прави случајни пристапи. Поуката е дека индекс се користи само ако планерот процени дека е поевтин од алтернативата, па создавањето индекс не значи дека тој ќе биде употребен.
     994
     995Вториот заклучок, од мерењето со филтер по јазик, е дека бројот на редови што влегуваат во агрегацијата е далеку поважен од индексите. Трите јазика ја прават функцијата 1,71 пати побавна, додека индексите донесоа 4,3%.
     996
     997=== dashboard_expert_performance() ===
     998
     999Збирна изведба по експерт. Резултатот има 50.000 реда, по еден за секој експерт.
     1000
     1001==== Дефиниција ====
     1002
     1003{{{
     1004-- Expert performance summary
     1005CREATE OR REPLACE FUNCTION dashboard_expert_performance()
     1006    RETURNS TABLE (
     1007                      expert_id INTEGER,
     1008                      expert_name TEXT,
     1009                      courses_created BIGINT,
     1010                      total_enrollments BIGINT,
     1011                      paid_enrollments BIGINT,
     1012                      trial_enrollments BIGINT,
     1013                      total_revenue NUMERIC,
     1014                      avg_rating NUMERIC,
     1015                      total_reviews BIGINT
     1016                  ) AS $$
     1017    #variable_conflict use_column
     1018BEGIN
     1019    RETURN QUERY
     1020        SELECT
     1021            ex.expert_id::INTEGER AS expert_id,
     1022            a.name::TEXT AS expert_name,
     1023            COUNT(DISTINCT c.course_id)::BIGINT AS courses_created,
     1024            COUNT(DISTINCT e.enrollment_id)::BIGINT AS total_enrollments,
     1025            COUNT(DISTINCT CASE WHEN p.payment_status = 'completed' THEN e.enrollment_id END)::BIGINT AS paid_enrollments,
     1026            COUNT(DISTINCT CASE WHEN p.payment_id IS NULL THEN e.enrollment_id END)::BIGINT AS trial_enrollments,
     1027            COALESCE(SUM(CASE WHEN p.payment_status = 'completed' THEN p.amount ELSE 0 END), 0)::NUMERIC AS total_revenue,
     1028            COALESCE(AVG(r.rating), 0)::NUMERIC AS avg_rating,
     1029            COUNT(r.review_id)::BIGINT AS total_reviews
     1030        FROM expert ex
     1031                 JOIN account a ON a.id = ex.account_id                                 -- expert name lives on account
     1032                 LEFT JOIN expert_course ec ON ex.expert_id = ec.expert_id              -- experts with no courses should be included
     1033                 LEFT JOIN course c ON ec.course_id = c.course_id                       -- experts with no courses should be included
     1034                 LEFT JOIN course_version cv ON c.course_id = cv.course_id              -- left join so experts without courses are not dropped
     1035                 LEFT JOIN enrollment e ON cv.course_version_id = e.course_version_id   -- left join to include courses with zero enrollments
     1036                 LEFT JOIN payment p ON e.enrollment_id = p.enrollment_id               -- left join to include enrollments without payments
     1037                 LEFT JOIN review r ON e.enrollment_id = r.enrollment_id                -- left join to include enrollments without reviews
     1038        GROUP BY ex.expert_id, a.id
     1039        ORDER BY total_revenue DESC NULLS LAST;
     1040END;
     1041$$ LANGUAGE plpgsql;
     1042}}}
     1043
     1044==== Почетна состојба ====
     1045
     1046{{{
     1047 Sort  (cost=9416816.80..9466816.80 rows=20000000 width=156) (actual time=1620.638..1623.502 rows=50000 loops=1)
     1048   Sort Key: (COALESCE(sum(CASE WHEN (p.payment_status = 'completed'::payment_status) THEN p.amount ELSE '0'::numeric END), '0'::numeric)) DESC NULLS LAST
     1049   Sort Method: external merge  Disk: 5216kB
     1050   ->  GroupAggregate  (cost=436066.06..2274667.63 rows=20000000 width=156) (actual time=841.856..1610.629 rows=50000 loops=1)
     1051         Group Key: ex.expert_id, a.id
     1052         ->  Incremental Sort  (cost=436066.06..1374667.63 rows=20000000 width=82) (actual time=841.225..1328.382 rows=1913735 loops=1)
     1053               Sort Key: ex.expert_id, a.id, c.course_id
     1054               Presorted Key: ex.expert_id, a.id
     1055               Full-sort Groups: 4073  Sort Method: quicksort  Average Memory: 31kB  Peak Memory: 31kB
     1056               Pre-sorted Groups: 4986  Sort Method: quicksort  Average Memory: 217kB  Peak Memory: 218kB
     1057               ->  Merge Left Join  (cost=436066.05..474667.63 rows=20000000 width=82) (actual time=840.824..1123.142 rows=1913735 loops=1)
     1058                     Merge Cond: (ex.expert_id = ec.expert_id)
     1059                     ->  Gather Merge  (cost=13663.64..19486.97 rows=50000 width=37) (actual time=179.596..182.605 rows=50000 loops=1)
     1060                           Workers Planned: 2
     1061                           Workers Launched: 2
     1062                           ->  Sort  (cost=12663.62..12715.70 rows=20833 width=37) (actual time=118.464..118.848 rows=16667 loops=3)
     1063                                 Sort Key: ex.expert_id, a.id
     1064                                 Sort Method: quicksort  Memory: 25kB
     1065                                 Worker 0:  Sort Method: quicksort  Memory: 2117kB
     1066                                 Worker 1:  Sort Method: quicksort  Memory: 2154kB
     1067                                 ->  Hash Join  (cost=1396.00..11169.21 rows=20833 width=37) (actual time=114.640..117.284 rows=16667 loops=3)
     1068                                       Hash Cond: (a.id = ex.account_id)
     1069                                       ->  Parallel Seq Scan on account a  (cost=0.00..9226.33 rows=208333 width=29) (actual time=0.015..8.180 rows=166667 loops=3)
     1070                                       ->  Hash  (cost=771.00..771.00 rows=50000 width=16) (actual time=98.396..98.396 rows=50000 loops=3)
     1071                                             Buckets: 65536  Batches: 1  Memory Usage: 2856kB
     1072                                             ->  Seq Scan on expert ex  (cost=0.00..771.00 rows=50000 width=16) (actual time=0.006..1.642 rows=50000 loops=3)
     1073                     ->  Materialize  (cost=422393.66..431725.66 rows=1866400 width=53) (actual time=661.199..836.447 rows=1866401 loops=1)
     1074                           ->  Sort  (cost=422393.66..427059.66 rows=1866400 width=53) (actual time=661.196..742.643 rows=1866401 loops=1)
     1075                                 Sort Key: ec.expert_id
     1076                                 Sort Method: external merge  Disk: 107416kB
     1077                                 ->  Hash Right Join  (cost=666.96..100402.06 rows=1866400 width=53) (actual time=3.098..377.129 rows=1866401 loops=1)
     1078                                       Hash Cond: (e.course_version_id = cv.course_version_id)
     1079                                       ->  Merge Left Join  (cost=1.75..77072.85 rows=800000 width=45) (actual time=0.040..266.632 rows=800000 loops=1)
     1080                                             Merge Cond: (e.enrollment_id = r.enrollment_id)
     1081                                             ->  Merge Left Join  (cost=1.33..66768.43 rows=800000 width=33) (actual time=0.028..203.477 rows=800000 loops=1)
     1082                                                   Merge Cond: (e.enrollment_id = p.enrollment_id)
     1083                                                   ->  Index Scan using enrollment_pkey on enrollment e  (cost=0.42..27318.42 rows=800000 width=16) (actual time=0.014..59.040 rows=800000 loops=1)
     1084                                                   ->  Index Scan using uq_payment_enrollment on payment p  (cost=0.42..27454.42 rows=800000 width=25) (actual time=0.009..58.879 rows=800000 loops=1)
     1085                                             ->  Index Scan using uq_review_enrollment on review r  (cost=0.42..6305.42 rows=160000 width=20) (actual time=0.008..13.251 rows=160000 loops=1)
     1086                                       ->  Hash  (cost=490.24..490.24 rows=13998 width=24) (actual time=3.017..3.019 rows=13998 loops=1)
     1087                                             Buckets: 16384  Batches: 1  Memory Usage: 894kB
     1088                                             ->  Hash Right Join  (cost=215.26..490.24 rows=13998 width=24) (actual time=1.085..2.207 rows=13998 loops=1)
     1089                                                   Hash Cond: (cv.course_id = c.course_id)
     1090                                                   ->  Seq Scan on course_version cv  (cost=0.00..105.00 rows=6000 width=16) (actual time=0.008..0.297 rows=6000 loops=1)
     1091                                                   ->  Hash  (cost=156.93..156.93 rows=4666 width=16) (actual time=1.057..1.058 rows=4666 loops=1)
     1092                                                         Buckets: 8192  Batches: 1  Memory Usage: 283kB
     1093                                                         ->  Hash Left Join  (cost=72.00..156.93 rows=4666 width=16) (actual time=0.259..0.779 rows=4666 loops=1)
     1094                                                               Hash Cond: (ec.course_id = c.course_id)
     1095                                                               ->  Seq Scan on expert_course ec  (cost=0.00..72.66 rows=4666 width=16) (actual time=0.010..0.199 rows=4666 loops=1)
     1096                                                               ->  Hash  (cost=47.00..47.00 rows=2000 width=8) (actual time=0.244..0.244 rows=2000 loops=1)
     1097                                                                     Buckets: 2048  Batches: 1  Memory Usage: 95kB
     1098                                                                     ->  Seq Scan on course c  (cost=0.00..47.00 rows=2000 width=8) (actual time=0.013..0.139 rows=2000 loops=1)
     1099 Planning Time: 1.235 ms
     1100 JIT:
     1101   Functions: 78
     1102   Options: Inlining true, Optimization true, Expressions true, Deforming true
     1103   Timing: Generation 1.617 ms (Deform 0.862 ms), Inlining 102.392 ms, Optimization 98.491 ms, Emission 82.286 ms, Total 284.785 ms
     1104 Execution Time: 1637.668 ms
     1105}}}
     1106
     1107Време: '''1677,6 ms''' (три пуштања: 1722,1 / 1673,0 / 1637,7 ms).
     1108
     1109Од планот, сортирањето по `ec.expert_id` се прелева на диск со `external merge Disk: 107416kB`, што е 107 MB и најголемото прелевање во целата анализа, и трае околу 661 ms. `Merge Left Join` враќа 1.913.735 реда, иако на крај се враќаат само 50.000. `Parallel Seq Scan on account` чита сите 500.000 сметки за да ги најде 50.000-те што припаѓаат на експерти. Освен тоа, `expert_course.course_id` нема индекс, бидејќи `pk_expert_course` е `(expert_id, course_id)` и покрива само `expert_id`.
     1110
     1111Идеите се индекси врз непокриените foreign key-ови, `enrollment.course_version_id` и `expert_course.course_id`, како и covering индекси врз `payment` и `review` за `Index Only Scan` во веригата join-ови.
     1112
     1113==== Чекор 1, индекси врз непокриените foreign key-ови ====
     1114
     1115{{{
     1116CREATE INDEX idx_enrollment_course_version_id ON enrollment(course_version_id);\nCREATE INDEX idx_expert_course_course_id      ON expert_course(course_id);
     1117}}}
     1118
     1119{{{
     1120 Sort  (cost=9416821.36..9466821.36 rows=20000000 width=156) (actual time=1605.269..1608.025 rows=50000 loops=1)
     1121   Sort Key: (COALESCE(sum(CASE WHEN (p.payment_status = 'completed'::payment_status) THEN p.amount ELSE '0'::numeric END), '0'::numeric)) DESC NULLS LAST
     1122   Sort Method: external merge  Disk: 5216kB
     1123   ->  GroupAggregate  (cost=436070.63..2274672.19 rows=20000000 width=156) (actual time=830.945..1594.989 rows=50000 loops=1)
     1124         Group Key: ex.expert_id, a.id
     1125         ->  Incremental Sort  (cost=436070.63..1374672.19 rows=20000000 width=82) (actual time=830.323..1315.718 rows=1913735 loops=1)
     1126               Sort Key: ex.expert_id, a.id, c.course_id
     1127               Presorted Key: ex.expert_id, a.id
     1128               Full-sort Groups: 4073  Sort Method: quicksort  Average Memory: 31kB  Peak Memory: 31kB
     1129               Pre-sorted Groups: 4986  Sort Method: quicksort  Average Memory: 217kB  Peak Memory: 218kB
     1130               ->  Merge Left Join  (cost=436070.61..474672.19 rows=20000000 width=82) (actual time=829.891..1113.685 rows=1913735 loops=1)
     1131                     Merge Cond: (ex.expert_id = ec.expert_id)
     1132                     ->  Gather Merge  (cost=13663.64..19486.97 rows=50000 width=37) (actual time=174.600..177.658 rows=50000 loops=1)
     1133                           Workers Planned: 2
     1134                           Workers Launched: 2
     1135                           ->  Sort  (cost=12663.62..12715.70 rows=20833 width=37) (actual time=114.172..114.547 rows=16667 loops=3)
     1136                                 Sort Key: ex.expert_id, a.id
     1137                                 Sort Method: quicksort  Memory: 25kB
     1138                                 Worker 0:  Sort Method: quicksort  Memory: 2150kB
     1139                                 Worker 1:  Sort Method: quicksort  Memory: 2121kB
     1140                                 ->  Hash Join  (cost=1396.00..11169.21 rows=20833 width=37) (actual time=111.012..113.206 rows=16667 loops=3)
     1141                                       Hash Cond: (a.id = ex.account_id)
     1142                                       ->  Parallel Seq Scan on account a  (cost=0.00..9226.33 rows=208333 width=29) (actual time=0.017..8.058 rows=166667 loops=3)
     1143                                       ->  Hash  (cost=771.00..771.00 rows=50000 width=16) (actual time=94.795..94.795 rows=50000 loops=3)
     1144                                             Buckets: 65536  Batches: 1  Memory Usage: 2856kB
     1145                                             ->  Seq Scan on expert ex  (cost=0.00..771.00 rows=50000 width=16) (actual time=0.006..1.703 rows=50000 loops=3)
     1146                     ->  Materialize  (cost=422398.23..431730.23 rows=1866400 width=53) (actual time=655.263..831.308 rows=1866401 loops=1)
     1147                           ->  Sort  (cost=422398.23..427064.23 rows=1866400 width=53) (actual time=655.260..738.876 rows=1866401 loops=1)
     1148                                 Sort Key: ec.expert_id
     1149                                 Sort Method: external merge  Disk: 107416kB
     1150                                 ->  Hash Right Join  (cost=667.77..100406.62 rows=1866400 width=53) (actual time=2.857..370.398 rows=1866401 loops=1)
     1151                                       Hash Cond: (e.course_version_id = cv.course_version_id)
     1152                                       ->  Merge Left Join  (cost=2.56..77077.41 rows=800000 width=45) (actual time=0.039..261.857 rows=800000 loops=1)
     1153                                             Merge Cond: (e.enrollment_id = r.enrollment_id)
     1154                                             ->  Merge Left Join  (cost=2.14..66772.11 rows=800000 width=33) (actual time=0.028..199.757 rows=800000 loops=1)
     1155                                                   Merge Cond: (e.enrollment_id = p.enrollment_id)
     1156                                                   ->  Index Scan using enrollment_pkey on enrollment e  (cost=0.42..27318.42 rows=800000 width=16) (actual time=0.015..57.657 rows=800000 loops=1)
     1157                                                   ->  Index Scan using uq_payment_enrollment on payment p  (cost=0.42..27454.42 rows=800000 width=25) (actual time=0.009..57.687 rows=800000 loops=1)
     1158                                             ->  Index Scan using uq_review_enrollment on review r  (cost=0.42..6305.42 rows=160000 width=20) (actual time=0.008..12.992 rows=160000 loops=1)
     1159                                       ->  Hash  (cost=490.24..490.24 rows=13998 width=24) (actual time=2.793..2.795 rows=13998 loops=1)
     1160                                             Buckets: 16384  Batches: 1  Memory Usage: 894kB
     1161                                             ->  Hash Right Join  (cost=215.26..490.24 rows=13998 width=24) (actual time=0.996..2.052 rows=13998 loops=1)
     1162                                                   Hash Cond: (cv.course_id = c.course_id)
     1163                                                   ->  Seq Scan on course_version cv  (cost=0.00..105.00 rows=6000 width=16) (actual time=0.007..0.296 rows=6000 loops=1)
     1164                                                   ->  Hash  (cost=156.93..156.93 rows=4666 width=16) (actual time=0.976..0.977 rows=4666 loops=1)
     1165                                                         Buckets: 8192  Batches: 1  Memory Usage: 283kB
     1166                                                         ->  Hash Left Join  (cost=72.00..156.93 rows=4666 width=16) (actual time=0.232..0.744 rows=4666 loops=1)
     1167                                                               Hash Cond: (ec.course_id = c.course_id)
     1168                                                               ->  Seq Scan on expert_course ec  (cost=0.00..72.66 rows=4666 width=16) (actual time=0.009..0.193 rows=4666 loops=1)
     1169                                                               ->  Hash  (cost=47.00..47.00 rows=2000 width=8) (actual time=0.219..0.220 rows=2000 loops=1)
     1170                                                                     Buckets: 2048  Batches: 1  Memory Usage: 95kB
     1171                                                                     ->  Seq Scan on course c  (cost=0.00..47.00 rows=2000 width=8) (actual time=0.009..0.132 rows=2000 loops=1)
     1172 Planning Time: 1.373 ms
     1173 JIT:
     1174   Functions: 78
     1175   Options: Inlining true, Optimization true, Expressions true, Deforming true
     1176   Timing: Generation 1.284 ms (Deform 0.701 ms), Inlining 94.140 ms, Optimization 97.362 ms, Emission 81.477 ms, Total 274.263 ms
     1177 Execution Time: 1621.652 ms
     1178}}}
     1179
     1180Време: '''1669,3 ms''' (три пуштања: 1666,7 / 1719,6 / 1621,7 ms), односно без ефект (+0,5%, во границите на мерната грешка).
     1181
     1182`external merge Disk: 107416kB` останува непроменето.
     1183
     1184==== Чекор 2, covering индекси врз payment и review ====
     1185
     1186{{{
     1187CREATE INDEX idx_payment_cov_all\n    ON payment(enrollment_id) INCLUDE (amount, payment_status, payment_id);\n\nCREATE INDEX idx_review_cov\n    ON review(enrollment_id) INCLUDE (rating, review_id);
     1188}}}
     1189
     1190{{{
     1191 Sort  (cost=9416093.11..9466093.11 rows=20000000 width=156) (actual time=1597.646..1600.382 rows=50000 loops=1)
     1192   Sort Key: (COALESCE(sum(CASE WHEN (p.payment_status = 'completed'::payment_status) THEN p.amount ELSE '0'::numeric END), '0'::numeric)) DESC NULLS LAST
     1193   Sort Method: external merge  Disk: 5216kB
     1194   ->  GroupAggregate  (cost=435342.38..2273943.94 rows=20000000 width=156) (actual time=822.516..1588.476 rows=50000 loops=1)
     1195         Group Key: ex.expert_id, a.id
     1196         ->  Incremental Sort  (cost=435342.38..1373943.94 rows=20000000 width=82) (actual time=821.886..1309.364 rows=1913735 loops=1)
     1197               Sort Key: ex.expert_id, a.id, c.course_id
     1198               Presorted Key: ex.expert_id, a.id
     1199               Full-sort Groups: 4073  Sort Method: quicksort  Average Memory: 31kB  Peak Memory: 31kB
     1200               Pre-sorted Groups: 4986  Sort Method: quicksort  Average Memory: 217kB  Peak Memory: 218kB
     1201               ->  Merge Left Join  (cost=435342.36..473943.94 rows=20000000 width=82) (actual time=821.466..1102.978 rows=1913735 loops=1)
     1202                     Merge Cond: (ex.expert_id = ec.expert_id)
     1203                     ->  Gather Merge  (cost=13663.64..19486.97 rows=50000 width=37) (actual time=166.177..169.143 rows=50000 loops=1)
     1204                           Workers Planned: 2
     1205                           Workers Launched: 2
     1206                           ->  Sort  (cost=12663.62..12715.70 rows=20833 width=37) (actual time=112.352..112.718 rows=16667 loops=3)
     1207                                 Sort Key: ex.expert_id, a.id
     1208                                 Sort Method: quicksort  Memory: 25kB
     1209                                 Worker 0:  Sort Method: quicksort  Memory: 2133kB
     1210                                 Worker 1:  Sort Method: quicksort  Memory: 2138kB
     1211                                 ->  Hash Join  (cost=1396.00..11169.21 rows=20833 width=37) (actual time=109.228..111.420 rows=16667 loops=3)
     1212                                       Hash Cond: (a.id = ex.account_id)
     1213                                       ->  Parallel Seq Scan on account a  (cost=0.00..9226.33 rows=208333 width=29) (actual time=0.017..8.065 rows=166667 loops=3)
     1214                                       ->  Hash  (cost=771.00..771.00 rows=50000 width=16) (actual time=93.237..93.237 rows=50000 loops=3)
     1215                                             Buckets: 65536  Batches: 1  Memory Usage: 2856kB
     1216                                             ->  Seq Scan on expert ex  (cost=0.00..771.00 rows=50000 width=16) (actual time=0.006..1.664 rows=50000 loops=3)
     1217                     ->  Materialize  (cost=421669.98..431001.98 rows=1866400 width=53) (actual time=655.259..830.777 rows=1866401 loops=1)
     1218                           ->  Sort  (cost=421669.98..426335.98 rows=1866400 width=53) (actual time=655.256..738.073 rows=1866401 loops=1)
     1219                                 Sort Key: ec.expert_id
     1220                                 Sort Method: external merge  Disk: 107416kB
     1221                                 ->  Hash Right Join  (cost=667.96..99678.37 rows=1866400 width=53) (actual time=3.019..372.200 rows=1866401 loops=1)
     1222                                       Hash Cond: (e.course_version_id = cv.course_version_id)
     1223                                       ->  Merge Left Join  (cost=2.75..76349.16 rows=800000 width=45) (actual time=0.117..259.226 rows=800000 loops=1)
     1224                                             Merge Cond: (e.enrollment_id = r.enrollment_id)
     1225                                             ->  Merge Left Join  (cost=2.10..66772.85 rows=800000 width=33) (actual time=0.103..204.048 rows=800000 loops=1)
     1226                                                   Merge Cond: (e.enrollment_id = p.enrollment_id)
     1227                                                   ->  Index Scan using enrollment_pkey on enrollment e  (cost=0.42..27318.42 rows=800000 width=16) (actual time=0.017..60.059 rows=800000 loops=1)
     1228                                                   ->  Index Scan using uq_payment_enrollment on payment p  (cost=0.42..27454.42 rows=800000 width=25) (actual time=0.073..60.156 rows=800000 loops=1)
     1229                                             ->  Index Only Scan using idx_review_cov on review r  (cost=0.42..5576.42 rows=160000 width=20) (actual time=0.009..8.833 rows=160000 loops=1)
     1230                                                   Heap Fetches: 0
     1231                                       ->  Hash  (cost=490.24..490.24 rows=13998 width=24) (actual time=2.872..2.874 rows=13998 loops=1)
     1232                                             Buckets: 16384  Batches: 1  Memory Usage: 894kB
     1233                                             ->  Hash Right Join  (cost=215.26..490.24 rows=13998 width=24) (actual time=0.992..2.084 rows=13998 loops=1)
     1234                                                   Hash Cond: (cv.course_id = c.course_id)
     1235                                                   ->  Seq Scan on course_version cv  (cost=0.00..105.00 rows=6000 width=16) (actual time=0.009..0.314 rows=6000 loops=1)
     1236                                                   ->  Hash  (cost=156.93..156.93 rows=4666 width=16) (actual time=0.969..0.970 rows=4666 loops=1)
     1237                                                         Buckets: 8192  Batches: 1  Memory Usage: 283kB
     1238                                                         ->  Hash Left Join  (cost=72.00..156.93 rows=4666 width=16) (actual time=0.240..0.742 rows=4666 loops=1)
     1239                                                               Hash Cond: (ec.course_id = c.course_id)
     1240                                                               ->  Seq Scan on expert_course ec  (cost=0.00..72.66 rows=4666 width=16) (actual time=0.015..0.202 rows=4666 loops=1)
     1241                                                               ->  Hash  (cost=47.00..47.00 rows=2000 width=8) (actual time=0.220..0.220 rows=2000 loops=1)
     1242                                                                     Buckets: 2048  Batches: 1  Memory Usage: 95kB
     1243                                                                     ->  Seq Scan on course c  (cost=0.00..47.00 rows=2000 width=8) (actual time=0.012..0.133 rows=2000 loops=1)
     1244 Planning Time: 1.353 ms
     1245 JIT:
     1246   Functions: 76
     1247   Options: Inlining true, Optimization true, Expressions true, Deforming true
     1248   Timing: Generation 1.455 ms (Deform 0.796 ms), Inlining 95.801 ms, Optimization 92.443 ms, Emission 80.145 ms, Total 269.843 ms
     1249 Execution Time: 1613.605 ms
     1250}}}
     1251
     1252Време: '''1605,8 ms''' (три пуштања: 1606,0 / 1597,8 / 1613,6 ms), односно маргинално забрзување од 4,3%.
     1253
     1254`review` премина на `Index Only Scan`, од 13,3 ms на 8,8 ms на тој јазол, но `payment` остана на обичен `Index Scan`, бидејќи планерот процени дека covering индексот од 38 MB не се исплати наспроти веќе постоечкиот `uq_payment_enrollment`.
     1255
     1256==== Заклучок ====
     1257
     1258Индексите донесоа 4,3%, бидејќи проблемот не е во пристапот до податоците. Најскапиот дел од планот е сортирањето на 1,86 милиони редови по `ec.expert_id`, со 107 MB прелевање на диск. Тоа сортирање се прави врз резултат од join, не врз базна табела, а индекс може да даде подреденост само на базна табела. Затоа не постои индекс што би го отстранил.
     1259
     1260Вистинскиот проблем е структурен. Query-то прави join од `expert` сè до `enrollment`, `payment` и `review`, при што бројот на редови расте на речиси 2 милиони, а потоа со `COUNT(DISTINCT ...)` се собира назад на 50.000. Со други зборови, се обработуваат околу 38 пати повеќе редови отколку што има во резултатот.
     1261
     1262Индексите не можат да го поправат тоа. Она што би можело е пред-агрегирање на `enrollment` и `payment` по `course_version_id` во подquery пред join-от со `expert`, така што множењето на редови никогаш не се случува, но тоа е надвор од опсегот на оваа анализа.
     1263
     1264=== Општ заклучок ===
     1265
     1266Збирни резултати:
     1267
     1268|| '''Функција''' || '''Почетна''' || '''Со индекси''' || '''Добивка''' ||
     1269|| `dashboard_monthly_totals()` || 153,8 ms || 161,7 ms || -5,1% ||
     1270|| `dashboard_monthly_courses()` || 3434,4 ms || 3360,9 ms || +2,1% ||
     1271|| `dashboard_course_performance()` || 2974,0 ms || 2846,2 ms || +4,3% ||
     1272|| `dashboard_expert_performance()` || 1677,6 ms || 1605,8 ms || +4,3% ||
     1273
     1274Добивката се движи од −5% до +4%, што за ниту една од четирите функции не претставува значајно подобрување. Ова не е неуспех на експериментот, туку очекуван резултат, и причините се четири.
     1275
     1276Првата е што индексот служи за да се прескокнат редови, а овие query-а не прескокнуваат ништо. Сите четири функции се извештаи што агрегираат врз целата база и немаат `WHERE` услов што ограничува на период, курс или корисник. Кога на query-то му требаат сите 800.000 редови, `Seq Scan` е оптималниот начин да се прочитаат, бидејќи чита цели блокови по редослед без индиректност. Кај `dashboard_monthly_totals()` присилното користење на индекси е дури 1,8 пати побавно од секвенцијалното читање. Индексите помагаат драматично доколку функциите примаа параметри, што не е претпоставка туку измерено: со филтер по период, 70.152 од 800.000 реда, и индекс врз `enrollment(purchase_date)`, функција 1 станува 4,0 пати побрза, од 153,8 ms на 38,1 ms, и планерот го користи индексот што претходно го игнорираше.
     1277
     1278Втората е што најскапите операции се сортирања врз резултат од join, а не пристап до податоци. Кај функциите 2, 3 и 4 доминантниот трошок е `Sort` или `Incremental Sort`, до 76% од времето кај `dashboard_monthly_courses()`. Тие сортирања се прават врз меѓурезултат од join, чиј клуч содржи столбови од повеќе табели и пресметани изрази. Индексот дава подреденост само во рамките на една базна табела, па таквото сортирање принципиелно не може да се елиминира со индекс.
     1279
     1280Третата е што најкорисните индекси веќе постојат. Сите join-ови во овие функции одат по `enrollment_id`, `course_id` и `course_version_id`, а тие се primary key-ови или unique constraint-и, што значи дека индексите веќе се создадени автоматски. Токму затоа новите индекси немаа што да придонесат, бидејќи работата што тие би ја вршеле веќе се вршеше.
     1281
     1282Четвртата е Амдаловиот закон. Единствениот индекс со мерлив ефект е `idx_ucp_enrollment_completed` кај `dashboard_course_performance()`. Тој го направи scan-от врз `user_course_progress` 2,6 пати побрз, од 120,9 на 46,2 ms, но бидејќи тој scan зафаќаше само околу 4% од вкупното време, крајната добивка е 4,3%. Забрзување на мал дел од работата дава мала вкупна добивка, колку и да е импресивен факторот на тој дел.
     1283
     1284Индексите не се бесплатни, бидејќи заземаат простор и го забавуваат секое `INSERT`, `UPDATE` и `DELETE`:
     1285
     1286|| '''Индекс''' || '''Големина''' || '''Табела''' || '''Однос''' ||
     1287|| `idx_payment_cov_all` || 38 MB || 52 MB || 73% ||
     1288|| `idx_ucp_enrollment_completed` || 29 MB || 60 MB || 48% ||
     1289|| `idx_review_cov` || 6,4 MB || 17 MB || 37% ||
     1290|| `idx_enrollment_course_version_id` || 5,4 MB || 51 MB || 11% ||
     1291|| `idx_expert_course_course_id` || 104 kB || — || — ||
     1292
     1293`idx_payment_cov_all` зафаќа 73% од големината на самата табела, а донесе околу 2%. Тоа е лоша размена, бидејќи секое ново плаќање ќе мора да го одржува и тој индекс.
     1294
     1295Од сите тестирани индекси, вредни за задржување се само два:
     1296
     1297{{{
     1298-- Мерлива добивка кај dashboard_course_performance()\nCREATE INDEX idx_ucp_enrollment_completed\n    ON user_course_progress(enrollment_id) INCLUDE (is_completed);\n\n-- Не помогна кај извештаите, но е единствениот непокриен foreign key.\n-- Вреди заради DELETE/UPDATE врз course_version и обични OLTP барања.\nCREATE INDEX idx_enrollment_course_version_id\n    ON enrollment(course_version_id);
     1299}}}
     1300
     1301Останатите, `idx_payment_cov_all`, `idx_review_cov`, `idx_payment_completed_covering`, `idx_enrollment_purchase_date_cov` и `idx_enrollment_cv_user`, не се препорачуваат, бидејќи цената во простор и во забавени записи е поголема од добивката од 1 до 2%.
     1302
     1303Мерењата покажаа дека надвор од индексите постојат значително поголеми добивки:
     1304
     1305|| '''Интервенција''' || '''Ефект''' || '''Забелешка''' ||
     1306|| Јазик како параметар на функција 2 || 3,3 пати побрзо || менува резултат, сите јазици се намерна одлука ||
     1307|| Јазик како параметар на функција 3 || 1,7 пати побрзо || исто така менува резултат ||
     1308|| Параметар за период кај функција 1 || 4,0 пати побрзо || измерено, ги прави индексите корисни ||
     1309|| Пред-агрегирање пред join кај функција 4 || потенцијално голем || го спречува множењето на редови ||
     1310|| `work_mem` 4 MB на 256 MB || 0% до −5% || не помогна, кај функција 3 дури штети ||
     1311|| `jit = off` || околу ±3% || без доследен ефект ||
     1312
     1313Најголемиот поединечен фактор во целата анализа не е индекс, туку бројот на редови што влегуваат во агрегацијата. Кај функциите 2 и 3 тој број е тројно поголем затоа што се враќаат сите три јазика, што е намерна одлука но со мерлива цена, бидејќи функција 2 е 3,3 пати побавна а функција 3 е 1,7 пати побавна од варијантите со еден јазик. Ако некогаш затреба извештај за еден јазик, најдобро е тоа да се направи преку параметар во функцијата, за филтерот да делува пред агрегацијата, бидејќи дополнително филтрирање врз готовиот резултат не носи никаква добивка кога целата работа веќе е завршена.
     1314
     1315Индексите се вредна алатка кога query-то бара мал дел од голема табела. Овие четири функции бараат сè, па индексот нема што да прескокне. Пред да се додаваат индекси, `EXPLAIN ANALYZE` треба да покаже дека времето навистина се троши на пристап до податоци, а овде тоа се троши на сортирање и агрегирање на редови што query-то само ги умножило.
    1731316
    1741317== Безбедност и заштита ==
     
    2141357Пасвордите на корисниците и експертите се чуваат во база во хеширана форма преку BCrypt, а не како plain text. Ова овозможува сигурно чување на пасвордите.
    2151358
    216 === SQL Injection Prevention (Spring JPA/JPQL)
     1359=== SQL Injection Prevention (Spring JPA/JPQL) ===
    2171360
    2181361Преку JPA/JPQL се спречува SQL Injection напад каде корисникот внесува злонамерен код за да манипулира со базата на податоци. Нападите се избегнуваат преку третирање на параметарот како plain data, а не команда.
     
    2281371    String query = "SELECT * FROM users WHERE email = '" + email + "'";
    2291372}}}
    230 
    2311373
    2321374=== CORS Configuration ===