'PostgreSQL - optimizing a part of a nested loop with heavy disk usage

I've uploaded the query (ORM-generated) and the EXPLAIN results into a plan visualizer here, also attaching them below:

Query

SELECT n.id, n.create_date, n.is_bookmarked, n.is_read, n.is_subscribed_sms, n.metadata, n.notification_id, n.update_date, n.user_login, n0.id, n0.actions, n0.available_since, n0.available_until, n0.channels_order, n0.create_date, n0.detailed_url, n0.expires_after, n0.external_entity_data, n0.external_entity_id, n0.external_entity_type_id, n0.mark_as_read_after, n0.notification_type_id, n0.preview, n0.searchable_text, n0.source_type, n0.title, n0.update_date, n0.urgent, n1.id, n1.code, n1.is_support_mobile, n1.title, n1.icon_background_color, n1.icon_color, n1.icon_name
FROM notification_user AS n
INNER JOIN notification AS n0 ON n.notification_id = n0.id
LEFT JOIN notification_type AS n1 ON n0.notification_type_id = n1.id
WHERE ((((((n.user_login = 'login') AND (n0.notification_type_id IS NOT NULL)) AND (n0.available_since <= '2022-03-11T10:41:19.7257456Z')) AND (n0.available_since >= '2020-01-31T21:00:00.0000000Z')) AND (n0.available_since < '2022-03-11T10:41:16.0790000Z')) AND (n1.id = ANY (ARRAY[8, 9, 10, 12, 2, 1, 11, 3]) OR ((n1.id IS NULL) AND (array_position(ARRAY[8, 9, 10, 12, 2, 1, 11, 3], NULL) IS NOT NULL)))) AND (((n0.expires_after IS NULL) OR (n0.expires_after >= '2022-03-11T10:41:19.7257456Z')) OR (n0.external_entity_type_id = 3))
ORDER BY n0.available_since DESC, n.id
LIMIT 20 OFFSET 20

EXPLAIN (ANALYZE, COSTS, VERBOSE, BUFFERS, FORMAT JSON) output

[
  {
    "Plan": {
      "Node Type": "Limit",
      "Parallel Aware": false,
      "Startup Cost": 14231.32,
      "Total Cost": 14231.37,
      "Plan Rows": 20,
      "Plan Width": 1495,
      "Actual Startup Time": 1849.524,
      "Actual Total Time": 1849.531,
      "Actual Rows": 20,
      "Actual Loops": 1,
      "Output": ["n.id", "n.create_date", "n.is_bookmarked", "n.is_read", "n.is_subscribed_sms", "n.metadata", "n.notification_id", "n.update_date", "n.user_login", "n0.id", "n0.actions", "n0.available_since", "n0.available_until", "n0.channels_order", "n0.create_date", "n0.detailed_url", "n0.expires_after", "n0.external_entity_data", "n0.external_entity_id", "n0.external_entity_type_id", "n0.mark_as_read_after", "n0.notification_type_id", "n0.preview", "n0.searchable_text", "n0.source_type", "n0.title", "n0.update_date", "n0.urgent", "n1.id", "n1.code", "n1.is_support_mobile", "n1.title", "n1.icon_background_color", "n1.icon_color", "n1.icon_name"],
      "Shared Hit Blocks": 16060,
      "Shared Read Blocks": 2412,
      "Shared Dirtied Blocks": 0,
      "Shared Written Blocks": 0,
      "Local Hit Blocks": 0,
      "Local Read Blocks": 0,
      "Local Dirtied Blocks": 0,
      "Local Written Blocks": 0,
      "Temp Read Blocks": 0,
      "Temp Written Blocks": 0,
      "I/O Read Time": 1735.434,
      "I/O Write Time": 0.000,
      "Plans": [
        {
          "Node Type": "Sort",
          "Parent Relationship": "Outer",
          "Parallel Aware": false,
          "Startup Cost": 14231.27,
          "Total Cost": 14234.07,
          "Plan Rows": 1122,
          "Plan Width": 1495,
          "Actual Startup Time": 1849.518,
          "Actual Total Time": 1849.526,
          "Actual Rows": 40,
          "Actual Loops": 1,
          "Output": ["n.id", "n.create_date", "n.is_bookmarked", "n.is_read", "n.is_subscribed_sms", "n.metadata", "n.notification_id", "n.update_date", "n.user_login", "n0.id", "n0.actions", "n0.available_since", "n0.available_until", "n0.channels_order", "n0.create_date", "n0.detailed_url", "n0.expires_after", "n0.external_entity_data", "n0.external_entity_id", "n0.external_entity_type_id", "n0.mark_as_read_after", "n0.notification_type_id", "n0.preview", "n0.searchable_text", "n0.source_type", "n0.title", "n0.update_date", "n0.urgent", "n1.id", "n1.code", "n1.is_support_mobile", "n1.title", "n1.icon_background_color", "n1.icon_color", "n1.icon_name"],
          "Sort Key": ["n0.available_since DESC", "n.id"],
          "Sort Method": "top-N heapsort",
          "Sort Space Used": 158,
          "Sort Space Type": "Memory",
          "Shared Hit Blocks": 16060,
          "Shared Read Blocks": 2412,
          "Shared Dirtied Blocks": 0,
          "Shared Written Blocks": 0,
          "Local Hit Blocks": 0,
          "Local Read Blocks": 0,
          "Local Dirtied Blocks": 0,
          "Local Written Blocks": 0,
          "Temp Read Blocks": 0,
          "Temp Written Blocks": 0,
          "I/O Read Time": 1735.434,
          "I/O Write Time": 0.000,
          "Plans": [
            {
              "Node Type": "Nested Loop",
              "Parent Relationship": "Outer",
              "Parallel Aware": false,
              "Join Type": "Inner",
              "Startup Cost": 1.00,
              "Total Cost": 14195.80,
              "Plan Rows": 1122,
              "Plan Width": 1495,
              "Actual Startup Time": 4.942,
              "Actual Total Time": 1838.010,
              "Actual Rows": 3917,
              "Actual Loops": 1,
              "Output": ["n.id", "n.create_date", "n.is_bookmarked", "n.is_read", "n.is_subscribed_sms", "n.metadata", "n.notification_id", "n.update_date", "n.user_login", "n0.id", "n0.actions", "n0.available_since", "n0.available_until", "n0.channels_order", "n0.create_date", "n0.detailed_url", "n0.expires_after", "n0.external_entity_data", "n0.external_entity_id", "n0.external_entity_type_id", "n0.mark_as_read_after", "n0.notification_type_id", "n0.preview", "n0.searchable_text", "n0.source_type", "n0.title", "n0.update_date", "n0.urgent", "n1.id", "n1.code", "n1.is_support_mobile", "n1.title", "n1.icon_background_color", "n1.icon_color", "n1.icon_name"],
              "Inner Unique": true,
              "Join Filter": "(n0.notification_type_id = n1.id)",
              "Rows Removed by Join Filter": 25709,
              "Shared Hit Blocks": 16054,
              "Shared Read Blocks": 2412,
              "Shared Dirtied Blocks": 0,
              "Shared Written Blocks": 0,
              "Local Hit Blocks": 0,
              "Local Read Blocks": 0,
              "Local Dirtied Blocks": 0,
              "Local Written Blocks": 0,
              "Temp Read Blocks": 0,
              "Temp Written Blocks": 0,
              "I/O Read Time": 1735.434,
              "I/O Write Time": 0.000,
              "Plans": [
                {
                  "Node Type": "Nested Loop",
                  "Parent Relationship": "Outer",
                  "Parallel Aware": false,
                  "Join Type": "Inner",
                  "Startup Cost": 1.00,
                  "Total Cost": 14073.05,
                  "Plan Rows": 1122,
                  "Plan Width": 1390,
                  "Actual Startup Time": 4.910,
                  "Actual Total Time": 1828.730,
                  "Actual Rows": 3917,
                  "Actual Loops": 1,
                  "Output": ["n.id", "n.create_date", "n.is_bookmarked", "n.is_read", "n.is_subscribed_sms", "n.metadata", "n.notification_id", "n.update_date", "n.user_login", "n0.id", "n0.actions", "n0.available_since", "n0.available_until", "n0.channels_order", "n0.create_date", "n0.detailed_url", "n0.expires_after", "n0.external_entity_data", "n0.external_entity_id", "n0.external_entity_type_id", "n0.mark_as_read_after", "n0.notification_type_id", "n0.preview", "n0.searchable_text", "n0.source_type", "n0.title", "n0.update_date", "n0.urgent"],
                  "Inner Unique": true,
                  "Shared Hit Blocks": 16053,
                  "Shared Read Blocks": 2412,
                  "Shared Dirtied Blocks": 0,
                  "Shared Written Blocks": 0,
                  "Local Hit Blocks": 0,
                  "Local Read Blocks": 0,
                  "Local Dirtied Blocks": 0,
                  "Local Written Blocks": 0,
                  "Temp Read Blocks": 0,
                  "Temp Written Blocks": 0,
                  "I/O Read Time": 1735.434,
                  "I/O Write Time": 0.000,
                  "Plans": [
                    {
                      "Node Type": "Index Scan",
                      "Parent Relationship": "Outer",
                      "Parallel Aware": false,
                      "Scan Direction": "Forward",
                      "Index Name": "notification_user__user_login_is_bookmarked__index",
                      "Relation Name": "notification_user",
                      "Schema": "public",
                      "Alias": "n",
                      "Startup Cost": 0.57,
                      "Total Cost": 4326.67,
                      "Plan Rows": 4236,
                      "Plan Width": 75,
                      "Actual Startup Time": 4.827,
                      "Actual Total Time": 1737.971,
                      "Actual Rows": 3979,
                      "Actual Loops": 1,
                      "Output": ["n.id", "n.create_date", "n.is_bookmarked", "n.is_read", "n.is_subscribed_sms", "n.metadata", "n.notification_id", "n.update_date", "n.user_login"],
                      "Index Cond": "(n.user_login = 'login'::text)",
                      "Rows Removed by Index Recheck": 0,
                      "Shared Hit Blocks": 142,
                      "Shared Read Blocks": 2404,
                      "Shared Dirtied Blocks": 0,
                      "Shared Written Blocks": 0,
                      "Local Hit Blocks": 0,
                      "Local Read Blocks": 0,
                      "Local Dirtied Blocks": 0,
                      "Local Written Blocks": 0,
                      "Temp Read Blocks": 0,
                      "Temp Written Blocks": 0,
                      "I/O Read Time": 1723.787,
                      "I/O Write Time": 0.000
                    },
                    {
                      "Node Type": "Index Scan",
                      "Parent Relationship": "Inner",
                      "Parallel Aware": false,
                      "Scan Direction": "Forward",
                      "Index Name": "notification__id__seq",
                      "Relation Name": "notification",
                      "Schema": "public",
                      "Alias": "n0",
                      "Startup Cost": 0.42,
                      "Total Cost": 2.30,
                      "Plan Rows": 1,
                      "Plan Width": 1315,
                      "Actual Startup Time": 0.021,
                      "Actual Total Time": 0.021,
                      "Actual Rows": 1,
                      "Actual Loops": 3979,
                      "Output": ["n0.id", "n0.actions", "n0.available_since", "n0.available_until", "n0.channels_order", "n0.create_date", "n0.detailed_url", "n0.expires_after", "n0.external_entity_data", "n0.external_entity_id", "n0.external_entity_type_id", "n0.mark_as_read_after", "n0.notification_type_id", "n0.preview", "n0.searchable_text", "n0.source_type", "n0.title", "n0.update_date", "n0.urgent"],
                      "Index Cond": "(n0.id = n.notification_id)",
                      "Rows Removed by Index Recheck": 0,
                      "Filter": "((n0.notification_type_id IS NOT NULL) AND (n0.available_since <= '2022-03-16 10:41:19.725746'::timestamp without time zone) AND (n0.available_since >= '2020-01-31 21:00:00'::timestamp without time zone) AND (n0.available_since < '2022-03-16 10:41:16.079'::timestamp without time zone) AND ((n0.expires_after IS NULL) OR (n0.expires_after >= '2022-03-15 10:45:19.725746'::timestamp without time zone) OR (n0.external_entity_type_id = 3)))",
                      "Rows Removed by Filter": 0,
                      "Shared Hit Blocks": 15911,
                      "Shared Read Blocks": 8,
                      "Shared Dirtied Blocks": 0,
                      "Shared Written Blocks": 0,
                      "Local Hit Blocks": 0,
                      "Local Read Blocks": 0,
                      "Local Dirtied Blocks": 0,
                      "Local Written Blocks": 0,
                      "Temp Read Blocks": 0,
                      "Temp Written Blocks": 0,
                      "I/O Read Time": 11.647,
                      "I/O Write Time": 0.000
                    }
                  ]
                },
                {
                  "Node Type": "Materialize",
                  "Parent Relationship": "Inner",
                  "Parallel Aware": false,
                  "Startup Cost": 0.00,
                  "Total Cost": 1.20,
                  "Plan Rows": 8,
                  "Plan Width": 105,
                  "Actual Startup Time": 0.000,
                  "Actual Total Time": 0.001,
                  "Actual Rows": 8,
                  "Actual Loops": 3917,
                  "Output": ["n1.id", "n1.code", "n1.is_support_mobile", "n1.title", "n1.icon_background_color", "n1.icon_color", "n1.icon_name"],
                  "Shared Hit Blocks": 1,
                  "Shared Read Blocks": 0,
                  "Shared Dirtied Blocks": 0,
                  "Shared Written Blocks": 0,
                  "Local Hit Blocks": 0,
                  "Local Read Blocks": 0,
                  "Local Dirtied Blocks": 0,
                  "Local Written Blocks": 0,
                  "Temp Read Blocks": 0,
                  "Temp Written Blocks": 0,
                  "I/O Read Time": 0.000,
                  "I/O Write Time": 0.000,
                  "Plans": [
                    {
                      "Node Type": "Seq Scan",
                      "Parent Relationship": "Outer",
                      "Parallel Aware": false,
                      "Relation Name": "notification_type",
                      "Schema": "public",
                      "Alias": "n1",
                      "Startup Cost": 0.00,
                      "Total Cost": 1.16,
                      "Plan Rows": 8,
                      "Plan Width": 105,
                      "Actual Startup Time": 0.013,
                      "Actual Total Time": 0.018,
                      "Actual Rows": 8,
                      "Actual Loops": 1,
                      "Output": ["n1.id", "n1.code", "n1.is_support_mobile", "n1.title", "n1.icon_background_color", "n1.icon_color", "n1.icon_name"],
                      "Filter": "(n1.id = ANY ('{8,9,10,12,2,1,11,3}'::integer[]))",
                      "Rows Removed by Filter": 0,
                      "Shared Hit Blocks": 1,
                      "Shared Read Blocks": 0,
                      "Shared Dirtied Blocks": 0,
                      "Shared Written Blocks": 0,
                      "Local Hit Blocks": 0,
                      "Local Read Blocks": 0,
                      "Local Dirtied Blocks": 0,
                      "Local Written Blocks": 0,
                      "Temp Read Blocks": 0,
                      "Temp Written Blocks": 0,
                      "I/O Read Time": 0.000,
                      "I/O Write Time": 0.000
                    }
                  ]
                }
              ]
            }
          ]
        }
      ]
    },
    "Planning Time": 110.488,
    "Triggers": [
    ],
    "Execution Time": 1849.726
  }
]

EDIT: added explain (analyze, format text, buffers) output

Limit  (cost=13935.44..13935.49 rows=20 width=1495) (actual time=1067.395..1067.401 rows=20 loops=1)
  Buffers: shared hit=16024 read=2482
  I/O Timings: read=966.699
  ->  Sort  (cost=13935.39..13938.10 rows=1085 width=1495) (actual time=1067.389..1067.397 rows=40 loops=1)
"        Sort Key: n0.available_since DESC, n.id"
        Sort Method: top-N heapsort  Memory: 150kB
        Buffers: shared hit=16024 read=2482
        I/O Timings: read=966.699
        ->  Nested Loop  (cost=1.00..13901.10 rows=1085 width=1495) (actual time=6.554..1057.998 rows=3891 loops=1)
              Join Filter: (n0.notification_type_id = n1.id)
              Rows Removed by Join Filter: 25533
              Buffers: shared hit=16024 read=2482
              I/O Timings: read=966.699
              ->  Nested Loop  (cost=1.00..13782.41 rows=1085 width=1390) (actual time=6.531..1051.030 rows=3891 loops=1)
                    Buffers: shared hit=16023 read=2482
                    I/O Timings: read=966.699
                    ->  Index Scan using notification_user__user_login_is_bookmarked__index on notification_user n  (cost=0.57..4234.98 rows=4146 width=75) (actual time=6.488..965.443 rows=3987 loops=1)
                          Index Cond: (user_login = 'login'::text)
                          Buffers: shared hit=86 read=2468
                          I/O Timings: read=955.017
                    ->  Index Scan using notification__id__seq on notification n0  (cost=0.42..2.30 rows=1 width=1315) (actual time=0.020..0.020 rows=1 loops=3987)
                          Index Cond: (id = n.notification_id)
                          Filter: ((notification_type_id IS NOT NULL) AND (available_since <= '2022-03-11 10:41:19.725746'::timestamp without time zone) AND (available_since >= '2020-01-31 21:00:00'::timestamp without time zone) AND (available_since < '2022-03-11 10:41:16.079'::timestamp without time zone) AND ((expires_after IS NULL) OR (expires_after >= '2022-03-11 10:41:19.725746'::timestamp without time zone) OR (external_entity_type_id = 3)))
                          Rows Removed by Filter: 0
                          Buffers: shared hit=15937 read=14
                          I/O Timings: read=11.682
              ->  Materialize  (cost=0.00..1.20 rows=8 width=105) (actual time=0.000..0.000 rows=8 loops=3891)
                    Buffers: shared hit=1
                    ->  Seq Scan on notification_type n1  (cost=0.00..1.16 rows=8 width=105) (actual time=0.013..0.015 rows=8 loops=1)
"                          Filter: (id = ANY ('{8,9,10,12,2,1,11,3}'::integer[]))"
                          Buffers: shared hit=1
Planning Time: 0.806 ms
Execution Time: 1067.545 ms

notification_user table DDL

create table notification_user
(
    id bigserial not null
        constraint notification_user__id__seq
            primary key,
    notification_id integer not null
        constraint notification_user__notification_id__fkey
            references notification
                on delete cascade,
    user_login text not null,
    is_read boolean default false not null,
    is_subscribed_sms boolean default false not null,
    is_active boolean default true not null,
    metadata text,
    create_date timestamp not null,
    update_date timestamp not null,
    is_bookmarked boolean default false not null,
    constraint notification_user__notification_id__user_login__key
        unique (notification_id, user_login)
);

create index notification_user__notification_id__index
    on notification_user (notification_id);

create index notification_user__is_read_user_login_notification_id__index
    on notification_user (is_read desc, user_login desc, notification_id asc);

create index notification_user__create_date__index
    on notification_user (create_date desc);

create index notification_user__user_login_is_bookmarked__index
    on notification_user (user_login, is_bookmarked);

notification table DDL

create table notification
(
    id serial not null
        constraint notification__id__seq
            primary key,
    external_entity_type_id smallint not null
        constraint notification__external_entity_type_id__fkey
            references external_entity_type,
    external_entity_id text,
    external_entity_data text,
    title text,
    preview text,
    create_date timestamp not null,
    update_date timestamp not null,
    source_type smallint not null
        constraint notification__source_type_id__fkey
            references source_type,
    channels_order text not null,
    urgent boolean default false,
    available_until timestamp,
    available_since timestamp default now() not null,
    expires_after timestamp,
    notification_type_id integer
        constraint notification__notification_type_id__fkey
            references notification_type,
    detailed_url text,
    searchable_text text,
    mark_as_read_after timestamp default (now() + '30 days'::interval) not null,
    actions jsonb
);

create index notification__available_until__index
    on notification (available_until);

create index notification__external_entity_id__entity_type_id__key
    on notification (external_entity_type_id, external_entity_id);

create index notification__external_entity_type_id_available_since__index
    on notification (notification_type_id asc, available_since desc, available_until desc);

create index notification__notification_type_id_available_since_expires_afte
    on notification (notification_type_id asc, available_since desc, expires_after desc);

create unique index notification__notnull_external_entity_id__entity_type_id__idx
    on notification (external_entity_id, external_entity_type_id)
    where ((external_entity_id IS NOT NULL) AND (external_entity_type_id <> 2));

create index notification__mark_as_read_after__index
    on notification (mark_as_read_after desc);

notification_type table DDL

create table notification_type
(
    id serial not null
        constraint notification_type__id__seq
            primary key,
    title text not null
        constraint notification_type__title__unique
            unique,
    icon_name text,
    icon_color text,
    icon_background_color text,
    code text default ''::text not null
        constraint notification_type__code__unique
            unique,
    is_support_mobile boolean default false not null
);

Retrieving 3979 rows in the outer part of the nested loop takes forever, and that's because the required records are all over the place, resulting in a lot of disk reads. Moreover, the required rows from notification_user are almost never found in buffers and have to be retrieved from disk.

In this query, LIMIT and OFFSET are used for paging. The output of the query is only 20 rows after applying LIMIT, however, all the 3979 (or more, depending on the user in question) rows have to be read from disk.

So far, I have two ideas how to optimize the performance of this query:

  1. Cluster notification_user by user_login, so that the records for the outer part of the nested loop take fewer disk reads to retrieve.
  2. Denormalize data to remove the joins of notification_user on notification and notification_type. If I understand correctly, it would then be possible to create an index supporting both the WHERE and the ORDER BY clause, so that during execution the rows would be fetched in index order, stopping after the LIMIT condition is satisfied, without reading all the 3979 rows from disk.

Are my ideas valid? Maybe I'm missing an easier way of optimizing the query?



Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source