Django + PostgreSQL best way to improve performance of slow summary aggregation?

Viewed 282

Context

I have a Django REST API using PostgreSQL database with millions of Items. These Items are processed by several systems and the processing details are sent back and stored in a Records table. The simplified models are:

class Item(models.Model):
    details = models.JSONField()


class Record(models.Model):
    items = models.ManyToManyField(Item)
    created = models.DateTimeField(auto_created=True)
    system = models.CharField(max_length=100)
    status = models.CharField(max_length=100)
    details = models.JSONField()

Goal

I would like to do arbitrary filters on the Items table and get a summary of various processing systems. This summary obtains the latest status for each selected Item for each system, and displays a count of each status. For example if I filter for 1055 items an example return is:

{
System_1: [running: 5, completed: 1000, error: 50],
System_2: [halted: 55, completed: 1000],
System_3: [submitted: 1055]
}

I currently have this working doing queries like below, which returns the count of processing statuses for System_1 and repeat for the other systems and package into a JSON return.

Item.objects.filter(....).annotate(
    system_1_status=Subquery(
        Record.objects.filter(
            system='System_1',
            items__id=OuterRef('pk')
        ).order_by('-created').values('status')[:1]
    )
).values('system_1_status').annotate(count=Count('system_1_status'))

This converts to sql query:

SELECT 
    "api_item"."id", 
    "api_item"."details", 
    (
        SELECT 
            U0."status" 
        FROM 
            "api_record" U0 
        INNER JOIN 
            "api_record_items" U1 
        ON 
            (U0."id" = U1."record_id") 
        WHERE 
            (U1."item_id" = ("api_item"."id") AND U0."system" = system_1) 
        ORDER BY 
            U0."created" DESC LIMIT 1
    ) AS "system_1_status" 
FROM "api_item"

We have millions of Items and Records and this works reasonably well if we select less than a thousand Items. Above this it takes minutes. Trying to do it for hundreds of thousands of items is catastrophic.

Questions

Can I improve the performance of this query? I can't see how, other than playing with indexes?

Alternatively would it be a bad idea to add a JSONField to the Item models which stores a cache of the latest statuses for each system for that Item? Whilst I don't like the idea of duplicating the data, doing an aggregation over a field already on the Item model should be very quick at querytime. I have DjangoQ which I can use scheduled functions to keep these fields up to date.

4 Answers

You are doing one subquery per item, try doing something like below and use aggregations.

from django.db.models import Case, When, Sum

items = Item.objects.filter(# your condition)
results = Record.objects.values("system").annotate(
    running=Sum(Case(When(status="running", then=1), default=0, 
        output_field=IntegerField())),
    completed=Sum(Case(When(status="completed", then=1), default=0, 
        output_field=IntegerField())),
    # add more status annotations
).order_by("system").filter(items__in=items)

The below solution reduces querytime from 5mins on my sample down to 20s.

from collections import Counter

items = Item.objects.filter(...)
{
    "System_1": Counter(
        items.
            filter(record__system='System_1').
            order_by('id', '-record__created').
            values_list('record__status', flat=True).
            distinct('id')),
    "System_2": Counter(
        items.
            filter(record__system='System_2').
            order_by('id', '-record__created').
            values_list('record__status', flat=True).
            distinct('id'))
}

The resulting PostgreSQL plan is given below, let me know if this can be improved:

SELECT DISTINCT ON ("api_item"."id") "api_record"."status" 
FROM "api_item" INNER JOIN "api_record_items" ON ("api_item"."id" = "api_record_items"."item_id") 
INNER JOIN "api_record" ON ("api_record_items"."record_id" = "api_record"."id") 
WHERE "api_record"."system" = System_1 ORDER BY "api_item"."id" ASC, "api_record"."created" DESC

I don't like that I need to pull all the values back from the DB to count them, however I've been unable to get aggregations to work with the distinct required to ensure only one record is counted per item.

In case anyone can improve this the queryplan is:

Unique  (cost=563327.33..1064477.67 rows=1010100 width=21) (actual time=16194.180..22301.422 rows=1010100 loops=1)
Planning Time: 0.492 ms
Execution Time: 22401.646 ms
  ->  Gather Merge  (cost=563327.33..1053946.33 rows=4212534 width=21) (actual time=16194.179..21165.116 rows=22200000 loops=1)
        Workers Planned: 2
        Workers Launched: 2
        ->  Sort  (cost=562327.31..566715.36 rows=1755222 width=21) (actual time=16140.068..17729.869 rows=7400000 loops=3)
              Worker 1:  Sort Method: external merge  Disk: 244080kB
              Worker 0:  Sort Method: external merge  Disk: 246880kB
              Sort Method: external merge  Disk: 247800kB
"              Sort Key: api_item.id, api_record.created DESC"
              ->  Parallel Hash Join  (cost=22117.30..308287.51 rows=1755222 width=21) (actual time=5719.348..8826.655 rows=7400000 loops=3)
                    Hash Cond: (api_record_items.item_id = api_item.id)
                    ->  Parallel Hash Join  (cost=2584.61..261932.34 rows=1755222 width=21) (actual time=17.042..3748.984 rows=7400000 loops=3)
                    ->  Parallel Hash  (cost=12626.75..12626.75 rows=420875 width=8) (actual time=180.939..180.940 rows=336700 loops=3)
                          Hash Cond: (api_record_items.record_id = api_record.id)
                          Buckets: 131072  Batches: 16  Memory Usage: 3520kB
                          ->  Parallel Seq Scan on api_record_items  (cost=0.00..234956.18 rows=9291718 width=16) (actual time=0.472..1557.711 rows=7433333 loops=3)
                          ->  Parallel Seq Scan on api_item  (cost=0.00..12626.75 rows=420875 width=8) (actual time=0.022..95.567 rows=336700 loops=3)
                          ->  Parallel Hash  (cost=2402.11..2402.11 rows=14600 width=21) (actual time=16.479..16.480 rows=10012 loops=3)
                                Buckets: 32768  Batches: 1  Memory Usage: 1952kB
                                ->  Parallel Seq Scan on api_record  (cost=0.00..2402.11 rows=14600 width=21) (actual time=6.063..13.094 rows=10012 loops=3)
                                      Rows Removed by Filter: 33340
                                      Filter: ((system)::text = 'system_1'::text)

I think a simple group by might be useful to get the status;

from django.db.models import Count
Item.objects.filter(record__system='System_1').values('record__status').annotate(c=Count('record__status')).values('record__status', 'c')

It looks like a classic top-n-per-group problem. You want to get the latest status for each item.

If you can't create a suitable index and if you have a lot of rows in api_item table, then most likely the most efficient method would be to use the ROW_NUMBER window function, like this. I re-wrote your query from the question to produce the same result, but hopefully more efficiently.

WITH
CTE_rn
AS
(
    SELECT
        U1."item_id"
        ,U0."status" AS "system_1_status"
        ,ROW_NUMBER() OVER (PARTITION BY U1."item_id" ORDER BY U0."created" DESC) AS rn
    FROM
        "api_record" U0 
        INNER JOIN "api_record_items" U1 ON U0."id" = U1."record_id"
    WHERE
        U0."system" = 'system_1'
)
,CTE_item_status
AS
(
    SELECT
        "item_id"
        ,"system_1_status"
    FROM
        CTE_rn
    WHERE
        rn = 1
)
SELECT
    "api_item"."id"
    ,"api_item"."details"
    ,CTE_item_status."system_1_status"
FROM
    "api_item"
    LEFT JOIN CTE_item_status ON CTE_item_status."item_id" = "api_item"."id"
;

If you know that there will never be api_items without any statuses, or if you don't want to see such items in the result set, then use INNER JOIN CTE_item_status in the main query instead of LEFT JOIN.

Related