планировщик запросов неточно выбирает вложенное соединение

У меня это от EXPLAIN ANALYZE

 ->  Nested Loop  (cost=2173.66..30075.48 rows=77 width=4)
                  (actual time=30.949..399.463 rows=95959 loops=1)

Таким образом, разница между ожидаемыми строками и фактическими строками составляет почти 3 порядка, и это приводит к очень медленному запросу.

Я увеличил default_statistics_target до 10000 и запустил VACUUM/ANALYZE, чтобы обновить планировщик запросов с учетом новой статистики. Как я могу заставить планировщик запросов выбрать лучшую стратегию соединения?

Я использую постгрес 9.3.1. Все константы стоимости моего планировщика по-прежнему установлены по умолчанию, поэтому:

seq_page_cost: 1
random_page_cost: 4
cpu_tuple_cost: .01
cpu_index_tuple_cost: .005
cpu_operator_cost: .0025
effective_cache_size: 128MB

Я установил enable_nested_loops = false, и запрос фактически не работал намного быстрее. Однако у меня сложилось впечатление, что большое расхождение в количестве строк, которое, по оценкам планировщика запросов, должно быть возвращено, и фактическое, вероятно, приведет к неоптимальному плану запроса.

Весь план запроса выглядит так:

Aggregate  (cost=30444.87..30444.88 rows=1 width=0) (actual time=535.077..535.077     rows=1 loops=1)
      ->  Nested Loop  (cost=2174.08..30444.68 rows=76 width=0) (actual time=23.208..527.062 rows=95451 loops=1)
        ->  Nested Loop  (cost=2173.66..30075.48 rows=77 width=4) (actual time=23.200..351.275 rows=95959 loops=1)
          ->  Hash Left Join  (cost=2173.24..28013.64 rows=401 width=4) (actual time=23.188..133.224 rows=103609 loops=1)
                Hash Cond: (access_rights.target_id = departments.id)
                Join Filter: ((access_rights.target_type)::text = 'Department'::text)
                Filter: ((((access_rights.target_type)::text = 'Company'::text) AND (access_rights.target_id = 173)) OR (((access_rights.target_type)::text = 'User'::text) AND (access_rights.target_id = 11654)) OR (((access_rights.target_type)::text = 'UserGroup'::text) AND (access_rights.target_id = 126)) OR (((access_rights.target_type)::text = 'Department'::text) AND (departments.lft <= 7) AND (departments.rgt >= 8)))
                Rows Removed by Filter: 59127
                ->  Bitmap Heap Scan on access_rights  (cost=2135.97..27236.01 rows=26221 width=14) (actual time=22.844..79.391 rows=162736 loops=1)
                      Recheck Cond: ((((target_type)::text = 'Company'::text) AND (target_id = 173) AND ((section)::text = 'shop'::text)) OR (((target_type)::text = 'User'::text) AND (target_id = 11654) AND ((section)::text = 'shop'::text)) OR (((target_type)::text = 'UserGroup'::text) AND (target_id = 126) AND ((section)::text = 'shop'::text)) OR ((target_type)::text = 'Department'::text))
                      Filter: (((section)::text = 'shop'::text) AND (((active_on IS NOT NULL) AND (active_on <= '2013-10-29'::date) AND ((inactive_on IS NULL) OR (inactive_on > '2013-10-29'::date)) AND (frozen_activation IS NULL)) OR ((frozen_activation)::text = 'active'::text)))
                      Rows Removed by Filter: 9294
                      ->  BitmapOr  (cost=2135.97..2135.97 rows=80823 width=0) (actual time=22.530..22.530 rows=0 loops=1)
                            ->  Bitmap Index Scan on index_access_rights_on_tt_ti_cfc_cfv_ti_s  (cost=0.00..643.10 rows=6861 width=0) (actual time=16.106..16.106 rows=96993 loops=1)
                                  Index Cond: (((target_type)::text = 'Company'::text) AND (target_id = 173) AND ((section)::text = 'shop'::text))
                            ->  Bitmap Index Scan on index_access_rights_on_tt_ti_cfc_cfv_ti_s  (cost=0.00..4.77 rows=12 width=0) (actual time=0.033..0.033 rows=0 loops=1)
                                  Index Cond: (((target_type)::text = 'User'::text) AND (target_id = 11654) AND ((section)::text = 'shop'::text))
                            ->  Bitmap Index Scan on index_access_rights_on_tt_ti_cfc_cfv_ti_s  (cost=0.00..11.68 rows=112 width=0) (actual time=0.238..0.238 rows=1200 loops=1)
                                  Index Cond: (((target_type)::text = 'UserGroup'::text) AND (target_id = 126) AND ((section)::text = 'shop'::text))
                            ->  Bitmap Index Scan on index_access_rights_on_target_type  (cost=0.00..1450.21 rows=73837 width=0) (actual time=6.148..6.148 rows=73837 loops=1)
                                  Index Cond: ((target_type)::text = 'Department'::text)
                ->  Hash  (cost=24.34..24.34 rows=1034 width=12) (actual time=0.331..0.331 rows=1034 loops=1)
                      Buckets: 1024  Batches: 1  Memory Usage: 45kB
                      ->  Seq Scan on departments  (cost=0.00..24.34 rows=1034 width=12) (actual time=0.004..0.179 rows=1034 loops=1)
          ->  Index Scan using tickets_pkey on tickets  (cost=0.42..5.13 rows=1 width=8) (actual time=0.002..0.002 rows=1 loops=103609)
                Index Cond: (id = access_rights.ticket_id)
                Filter: (((hold_until IS NULL) OR (hold_until <= '2013-10-29 00:00:00'::timestamp without time zone)) AND (company_id = 173))
                Rows Removed by Filter: 0
    ->  Index Scan using events_pkey on events  (cost=0.42..4.78 rows=1 width=4) (actual time=0.001..0.002 rows=1 loops=95959)
          Index Cond: (id = tickets.event_id)
          Filter: ((NOT activity) AND ((canceled_at IS NULL) OR (canceled_at > '2013-10-29 23:11:37.486572'::timestamp without time zone)))
          Rows Removed by Filter: 0
Total runtime: 535.165 ms

У нас 17 гб оперативной памяти

Смысл этого запроса — найти события, на которые есть билеты, к которым у пользователя есть доступ в магазине. Доступ можно определить разными способами. Если пользователь является частью отдела, имеющего права доступа к данной заявке, если отдел пользователей является родителем отдела, у которого есть доступ (вложенный набор lft, rgt и т. д.). Пользователь может иметь доступ, если всей компании предоставлено право доступа к этим заявкам. Пользователь может быть частью группы пользователей, у которой есть доступ. Пользователю могут быть предоставлены индивидуальные права доступа к заявкам. Компания-пользователь должна владеть билетами. Билеты могут быть «заморожены» или «неактивны», и в этом случае у пользователя не будет доступа. Заявка неактивна, если «active_on» > «Сегодня» или «inactive_on» ‹ «Сегодня». Билеты недоступны, если они ticket.hold_until > Сегодня

Запрос, который я запускаю,

EXPLAIN ANALYZE
SELECT count(*) AS count_all
FROM "events"
INNER JOIN tickets ON events.id = tickets.event_id
INNER JOIN access_rights ON access_rights.ticket_id = tickets.id
LEFT OUTER JOIN departments ON departments.id = access_rights.target_id
     AND access_rights.target_type = 'Department'
WHERE ((("events"."activity" = 'f') AND (events.canceled_at IS NULL OR events.canceled_at > '2013-10-29 23:11:37.486572'))
AND ((((((access_rights.section = 'shop') AND (access_rights.target_type = 'Company'
AND access_rights.target_id = 173)) OR ((access_rights.section = 'shop')
AND (access_rights.target_type = 'User' AND access_rights.target_id = 11654)) OR ((access_rights.section = 'shop')
AND (access_rights.target_type = 'UserGroup'
AND access_rights.target_id IN ('126'))) OR ((access_rights.section = 'shop')
AND (access_rights.target_type = 'Department'
AND departments.lft <= 7 AND departments.rgt >= 8))) 
AND ((access_rights.section = 'shop')
AND ((((access_rights.section = 'shop')
AND (access_rights.active_on IS NOT NULL
AND access_rights.active_on <= '2013-10-29'
AND (access_rights.inactive_on IS NULL OR access_rights.inactive_on > '2013-10-29')))
AND (access_rights.frozen_activation IS NULL)) OR ((access_rights.section = 'shop')
AND (access_rights.frozen_activation = 'active')))))
AND (tickets.hold_until IS NULL OR tickets.hold_until <= '2013-10-29'))
AND (tickets.company_id = 173)));

Таблицы:

CREATE TABLE tickets (
    hold_until timestamp without time zone,
    event_id integer,
    id integer NOT NULL
 );

Indexes:
    "tickets_pkey" PRIMARY KEY, btree (id)
    "index_tickets_on_company_id" btree (company_id)
    "index_tickets_on_created_at" btree (created_at)
    "index_tickets_on_creation_id" btree (creation_id)
    "index_tickets_on_event_id" btree (event_id)
    "index_tickets_on_hold_until" btree (hold_until)

Foreign-key constraints:
    "tickets_attendee_id_fk" FOREIGN KEY (attendee_id) REFERENCES attendees(id)
    "tickets_company_id_fk" FOREIGN KEY (company_id) REFERENCES companies(id)
    "tickets_event_id_fk" FOREIGN KEY (event_id) REFERENCES events(id)

CREATE TABLE events (
     id integer NOT NULL,
     activity boolean DEFAULT false NOT NULL
 );

Indexes:
    "events_pkey" PRIMARY KEY, btree (id)
    "index_events_on_id_and_te_id" UNIQUE, btree (id, te_id)
    "index_events_on_activity" btree (activity)
    "index_events_on_canceled_at" btree (canceled_at)
    "index_events_on_company_id" btree (company_id)
    "index_events_on_name" btree (name)
    "index_events_on_occurs_at" btree (occurs_at)

Foreign-key constraints:
    "events_company_id_fk" FOREIGN KEY (company_id) REFERENCES companies(id)

CREATE TABLE departments (
   id integer NOT NULL,
   parent_id integer,
   lft integer NOT NULL,
   rgt integer NOT NULL
);

Indexes:
   "departments_pkey" PRIMARY KEY, btree (id)
   "index_departments_on_company_id_and_parent_id_and_name" UNIQUE, btree (company_id, parent_id, name)
   "index_departments_on_company_id" btree (company_id)
   "index_departments_on_lft" btree (lft)
   "index_departments_on_name" btree (name)
   "index_departments_on_parent_id" btree (parent_id)
   "index_departments_on_rgt" btree (rgt)

Foreign-key constraints:
   "departments_company_id_fk" FOREIGN KEY (company_id) REFERENCES companies(id)

CREATE TABLE access_rights (
   id integer NOT NULL,
   target_type character varying(255) NOT NULL,
   target_id integer NOT NULL,
   ticket_id integer NOT NULL,
   active_on date,
   visible boolean,
   inactive_on date,
   frozen_activation character varying(255)
);

Indexes:
   "access_rights_pkey" PRIMARY KEY, btree (id)
   "index_access_rights_on_tt_ti_cfc_cfv_ti_s" UNIQUE, btree (target_type, target_id, custom_field_condition, custom_field_value, ticket_id, section)
   "index_access_rights_on_active_on" btree (active_on)
   "index_access_rights_on_custom_field_value" btree (custom_field_value)
   "index_access_rights_on_frozen_activation" btree (frozen_activation)
   "index_access_rights_on_inactive_on" btree (inactive_on)
   "index_access_rights_on_section" btree (section)
   "index_access_rights_on_target_id" btree (target_id)
   "index_access_rights_on_target_type" btree (target_type)
   "index_access_rights_on_target_type_and_target_id" btree (target_type, target_id) CLUSTER
   "index_access_rights_on_ticket_id" btree (ticket_id)
   "index_access_rights_on_visible" btree (visible)

Foreign-key constraints:
   "access_rights_ticket_id_fk" FOREIGN KEY (ticket_id) REFERENCES tickets(id)

Я знаю, что это много, спасибо, что нашли время, чтобы просмотреть это


person jvans    schedule 31.10.2013    source источник
comment
Вам нужно будет предоставить гораздо больше информации. Очевидно, ваша версия Postgres, ваше определение таблицы, ваш запрос, соответствующие настройки в postgres.conf: настройки стоимости планировщика для начала. Рассмотрим инструкции в теге postgresql-performance. Для отладки попробуйте SET enable_nestloop = FALSE в своем сеансе и запустите снова.   -  person Erwin Brandstetter    schedule 01.11.2013
comment
Вложенный цикл: для танго нужны двое. И в самом простом случае один из них небольшой, а другой имеет пригодный для использования PK, FK или вторичный индекс.   -  person wildplasser    schedule 01.11.2013
comment
Определения таблиц, количество элементов, запрос, оперативная память?   -  person Erwin Brandstetter    schedule 01.11.2013
comment
несвязанный: LEFT OUTER JOIN departments ... AND departments.lft <= 7 AND departments.rgt >= 8))) :: ваше левое соединение превращается в прямое соединение. Ваш ORM, вероятно, не делает то, что вы хотите. Кстати: предоставленный вами DDL бесполезен. Лучше используйте фрагмент из pg_dump --schema-only ...   -  person wildplasser    schedule 01.11.2013
comment
Простота является необходимым условием надежности... SQL не сложен, если сделать его сложным... можете ли вы объяснить миру простым английским языком, что должен делать этот запрос? я не люблю сам расшифровывать скобки..   -  person Raymond Nijland    schedule 02.11.2013
comment
да, это справедливо, в этом запросе происходит тонна. Я добавил английский перевод   -  person jvans    schedule 02.11.2013
comment
Не могли бы вы также добавить к вопросу все ограничения PK/FK и вторичные индексы? Без них жизнь не имеет смысла.   -  person wildplasser    schedule 02.11.2013
comment
После многократного редактирования ваши данные по-прежнему несовместимы. access_rights.section упоминается во многих местах, но отсутствует в определении таблицы.   -  person Erwin Brandstetter    schedule 05.11.2013


Ответы (1)


Конфигурация сервера

Это ясно: настройки по умолчанию очень консервативны и предназначены для работы в небольших установках с ограниченными ресурсами. Для выделенного сервера БД некоторые настройки по умолчанию просто неадекватны. Вы должны настроить свои параметры.

Для начала, если у вас достаточно оперативной памяти для кэширования всей или большей части вашей БД, установите random_page_cost значительно ниже. И увеличить относительную стоимость операций ЦП. Что-то вроде (это чистая догадка!):

seq_page_cost: 1
random_page_cost: 1.2
cpu_tuple_cost: .02
cpu_index_tuple_cost: .02
cpu_operator_cost: .005

И effective_cache_size часто бывает слишком низким. Для выделенного сервера БД это может достигать трех четвертей от общего объема оперативной памяти.

@Craig собрал длинный список советов по настройке производительности:
Оптимизировать PostgreSQL. для быстрого тестирования

В Postgres Wiki есть еще больше.

Запрос

Слишком много лишних скобок, слишком трудно читать. Используйте псевдонимы и формат таблицы перед попыткой отладки, а тем более не представляя ее широкой публике. После распутывания:

SELECT count(*) AS count_all
FROM   events           e
JOIN   tickets          t ON t.event_id = e.id
JOIN   access_rights    a ON a.ticket_id = t.id
LEFT   JOIN departments d ON d.id = a.target_id
                         AND a.target_type = 'Department'
WHERE  e.activity = 'f'
AND   (e.canceled_at IS NULL OR e.canceled_at > '2013-10-29 23:11:37')

AND   (t.hold_until IS NULL OR t.hold_until <= '2013-10-29')
AND    t.company_id = 173;

AND    a.section = 'shop'
AND   (a.target_type = 'Company'   AND a.target_id = 173
   OR  a.target_type = 'User'      AND a.target_id = 11654
   OR  a.target_type = 'UserGroup' AND a.target_id IN (126)
   OR                                  d.lft <= 7 AND d.rgt >= 8
    -- a.target_type = 'Department' is redundant
) 
AND   (a.frozen_activation = 'active'
   OR     a.active_on <= '2013-10-29'
     AND (a.inactive_on IS NULL OR a.inactive_on > '2013-10-29')
     AND  a.frozen_activation IS NULL
)

Основные моменты

  • Лишнее: AND a.active_on IS NOT NULL, так как у вас также есть AND a.active_on <= '2013-10-29'

  • AND a.target_id IN ('126') должно быть AND a.target_id = 126 или хотя бы AND a.target_id IN (126) (числовая константа).

  • a.target_type = 'Department' является избыточным, так как он уже находится в LEFT JOIN

  • AND a.section = 'shop' многократно повторяется.

  • target_type_id скорее всего должен быть enum или integer со ссылкой на таблицу target_type вместо varchar(255).

    CREATE TABLE access_rights (
       ...
      ,target_type_id integer NOT NULL REFERENCES target_type(target_type_id)
       ...
    );
    

    Аналогично для a.frozen_activation и a.section.

Это также сделало бы индекс, который я собираюсь предложить, более эффективным.

Индексы

Добавьте несколько многоколоночных/частичных индексов. Портите сами, я не знаю мощностей и распределения данных. Обратите внимание на предложения DESC в стратегических местах.

CREATE INDEX e_idx ON events (company_id, event_id, hold_until)
WHERE activity = FALSE;

CREATE INDEX t_idx ON tickets (company_id, event_id, hold_until DESC);

CREATE INDEX a_idx1 ON access_rights (target_type_id, target_id)
WHERE section = 'shop';

CREATE INDEX a_idx2 ON access_rights
                   (frozen_activation, active_on DESC, inactive_on)
WHERE section = 'shop';

CREATE INDEX d_idx ON departments (target_type, lft DESC, rgt);

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

Подробнее о том, как настроить эти индексы, см. соответствующий ответ на dba.SE:

person Erwin Brandstetter    schedule 01.11.2013
comment
Спасибо за вашу помощь в этом. Почему кэширование большей части вашей базы данных удорожает работу процессора? - person jvans; 01.11.2013
comment
@jvans: это не так. Поиск страниц становится дешевле. Это всего лишь относительные числа без абсолютного значения. Операции ЦП становятся относительно более дорогими. - person Erwin Brandstetter; 01.11.2013
comment
Круто, что имеет большой смысл. Так справедливо ли будет сказать, что Effective_cache_size обратно пропорционален оценке стоимости процессора? Всякий раз, когда вы увеличиваете кеш, стоимость процессора становится относительно дороже, и вам следует увеличить оценку стоимости процессора_*? - person jvans; 02.11.2013
comment
@jvans: Обычно да. Но имейте в виду, что effective_cache_size на самом деле не меняет размер кэша. Он только сообщает Postgres, какой эффективный размер кэша следует учитывать при планировании запросов. Postgres использует собственную выделенную оперативную память, а также системный кеш. - person Erwin Brandstetter; 03.11.2013
comment
Действительно потрясающая помощь в этом. Очень полезно. - person Binary Logic; 12.11.2013