Як я ефективно отримую "останній відповідний рядок"?


53

У мене є схема запитів, яка повинна бути дуже поширеною, але я не знаю, як написати ефективний запит для цього. Я хочу переглянути рядки таблиці, які відповідають "найновішій даті не після" рядків іншої таблиці.

inventoryСкажімо, у мене є таблиця, яка представляє інвентар, який я тримаю в певний день.

date       | good | quantity
------------------------------
2013-08-09 | egg  | 5
2013-08-09 | pear | 7
2013-08-02 | egg  | 1
2013-08-02 | pear | 2

і таблицю, "ціна" сказати, яка містить ціну товару в даний день

date       | good | price
--------------------------
2013-08-07 | egg  | 120
2013-08-06 | pear | 200
2013-08-01 | egg  | 110
2013-07-30 | pear | 220

Як я можу ефективно отримати «останню» ціну за кожен рядок таблиці інвентаризації, тобто

date       | pricing date | good | quantity | price
----------------------------------------------------
2013-08-09 | 2013-08-07   | egg  | 5        | 120
2013-08-09 | 2013-08-06   | pear | 7        | 200
2013-08-02 | 2013-08-01   | egg  | 1        | 110
2013-08-02 | 2013-07-30   | pear | 2        | 220

Я знаю один із способів цього:

select inventory.date, max(price.date) as pricing_date, good
from inventory, price
where inventory.date >= price.date
and inventory.good = price.good
group by inventory.date, good

а потім приєднатися цей запит знову до інвентарю. Для великих таблиць навіть виконання першого запиту (не приєднуючись знову до інвентаря) дуже повільно. Однак ця сама проблема швидко вирішується, якщо я просто використовую свою мову програмування, щоб видати один max(price.date) ... where price.date <= date_of_interest ... order by price.date desc limit 1запит на кожен date_of_interestз таблиці інвентаризації, тому я знаю, що немає обчислювальних перешкод. Однак я вважаю за краще вирішити всю проблему за допомогою одного SQL-запиту, оскільки це дозволить мені зробити подальшу обробку SQL за результатами запиту.

Чи є стандартний спосіб зробити це ефективно? Створюється враження, що він повинен часто виникати, і щоб бути способом написати швидкий запит на нього.

Я використовую Postgres, але загальна відповідь на SQL буде вдячна.


3
Проголосували за перехід на DBA.SE, оскільки це питання ефективності. Ми могли написати запит декількома різними способами, але це не зробить його набагато швидшим.
ypercubeᵀᴹ

5
Вам справді потрібні всі товари на всі дні з одного запиту? Здається, це навряд чи вимога? Частіше можна отримати ціни на конкретну дату або ціни на конкретну товар (на конкретну дату). Ці альтернативні запити могли набагато легше отримати користь від (відповідних) індексів. Нам також потрібно знати: кардинальності (скільки рядків у кожній таблиці?), Повне визначення таблиці в т.ч. типи даних, обмеження, індекси, ... (використання \d tblу psql), ваша версія Postgres та хв. / макс. кількість цін на товар.
Ервін Брандштеттер

@ErwinBrandstetter Ти просиш мене прийняти відповідь? Я насправді не кваліфікований, щоб знати, що найкраще, хоча, оскільки у вас найбільше відгуків, я з радістю приймаю це.
Том Елліс

Приймайте лише, якщо він відповідає на ваше запитання чи працює для вас. Ви навіть можете залишити коментар, як діяти, якщо це могло б допомогти пов'язаним справам. Якщо ви вважаєте, що ваше питання не відповідає, повідомте нас про це.
Ервін Брандстеттер

1
Тоді я повинен вибачитися, тому що, хоча я отримав чудові відповіді, я вже не працюю над проблемою, яка спровокувала це питання, тому я не можу судити про те, яка найкраща відповідь, чи, якщо справді, хтось із них дійсно підходять для мого використання (як це було). Якщо є якийсь етикет DBA.Stackexchange, я повинен дотримуватися в цьому випадку, будь ласка, повідомте мене про це.
Том Елліс

Відповіді:


42

Це дуже залежить від обставин та точних вимог. Розгляньте мій коментар до питання .

Просте рішення

З DISTINCT ONв Postgres:

SELECT DISTINCT ON (i.good, i.the_date)
       i.the_date, p.the_date AS pricing_date, i.good, p.price
FROM   inventory  i
LEFT   JOIN price p ON i.good = p.good AND i.the_date >= p.the_date
ORDER  BY i.good, i.the_date, p.the_date DESC;

Впорядкований результат.

Або NOT EXISTSв стандартному SQL (працює з усіма мені відомими RDBMS):

SELECT i.the_date, p.the_date AS pricing_date, i.good, i.quantity, p.price
FROM   inventory  i
LEFT   JOIN price p ON p.good = i.good AND p.the_date <= i.the_date
WHERE  NOT EXISTS (
   SELECT 1 FROM price p1
   WHERE  p1.good = p.good
   AND p1.the_date <= i.the_date
   AND p1.the_date >  p.the_date
   );

Той самий результат, але з довільним порядком сортування - якщо ви не додасте ORDER BY.
Залежно від розподілу даних, точних вимог та показників, будь-який з них може бути швидшим.
Як правило, DISTINCT ONце переможець, і ви отримуєте відсортований результат поверх нього. Але для певних випадків інші методи запиту (набагато) швидші, але. Дивіться нижче.

Рішення з підзапросами для обчислення значень max / min, як правило, повільніше. Варіанти з CTE, як правило, повільніші, але.

Звичайні погляди (як запропоновано іншою відповіддю) зовсім не допомагають виконувати показники в Postgres.

SQL Fiddle.


Правильне рішення

Струни та зіставлення

Перш за все, ви страждаєте від неоптимальної компонування таблиці. Це може здатися тривіальним, але нормалізація вашої схеми може пройти довгий шлях.

Сортування по типам символів ( text, varchar, ...) має бути зроблено в відповідності з локаллю - в COLLATION зокрема. Швидше за все, ваша БД використовує якийсь локальний набір правил (наприклад, у моєму випадку de_AT.UTF-8:). Дізнайтеся за допомогою:

SHOW lc_collate;

Це робить сортування та пошук покажчиків повільнішими . Чим довше ваші струни (назви товарів), тим гірше. Якщо ви насправді не піклуєтесь про правила зіставлення у своєму висновку (або порядку сортування взагалі), це може бути швидше, якщо ви додасте COLLATE "C":

SELECT DISTINCT ON (i.good COLLATE "C", i.the_date)
       i.the_date, p.the_date AS pricing_date, i.good, p.price
FROM   inventory  i
LEFT   JOIN price p ON i.good = p.good AND i.the_date >= p.the_date
ORDER  BY i.good COLLATE "C", i.the_date, p.the_date DESC;

Зверніть увагу, як я додав порівняння у двох місцях.
Удвічі швидший у моєму тесті з 20-ти рядковими рядами і дуже базовими іменами ("good123").

Покажчик

Якщо ваш запит повинен використовувати індекс, стовпці з символьними даними повинні використовувати відповідне порівняння ( goodу прикладі):

CREATE INDEX inventory_good_date_desc_collate_c_idx
ON price(good COLLATE "C", the_date DESC);

Обов’язково прочитайте останні два глави цієї пов’язаної відповіді на ТА:

Ви можете навіть мати кілька індексів з різними порівняннями в одних і тих же стовпцях - якщо вам також потрібні товари, відсортовані за іншим (або за замовчуванням) порівнянням в інших запитах.

Нормалізувати

Надлишки рядків (ім'я хорошого) також роздувають ваші таблиці та індекси, що робить все ще повільніше. При правильному розташуванні таблиці ви могли б уникнути більшості проблем. Може виглядати так:

CREATE TABLE good (
  good_id serial PRIMARY KEY
, good    text   NOT NULL
);

CREATE TABLE inventory (
  good_id  int  REFERENCES good (good_id)
, the_date date NOT NULL
, quantity int  NOT NULL
, PRIMARY KEY(good_id, the_date)
);

CREATE TABLE price (
  good_id  int     REFERENCES good (good_id)
, the_date date    NOT NULL
, price    numeric NOT NULL
, PRIMARY KEY(good_id, the_date));

Первинні ключі автоматично забезпечують (майже) всі необхідні нам індекси.
Залежно від відсутніх деталей, багатокольоровий індекс у priceпорядку зменшення порядку у другому стовпці може покращити продуктивність:

CREATE INDEX price_good_date_desc_idx ON price(good, the_date DESC);

Знову ж таки, порівняння має відповідати вашому запиту (див. Вище).

У Postgres 9.2 або новіших версіях «покриття індексів» для сканування лише для індексів може допомогти ще трохи - особливо, якщо ваші таблиці містять додаткові стовпці, що робить таблицю значно більшою, ніж індекс покриття.

Ці запити в результаті набагато швидше:

НЕ Є

SELECT i.the_date, p.the_date AS pricing_date, g.good, i.quantity, p.price
FROM   inventory  i
JOIN   good       g USING (good_id)
LEFT   JOIN price p ON p.good_id = i.good_id AND p.the_date <= i.the_date
AND    NOT EXISTS (
   SELECT 1 FROM price p1
   WHERE  p1.good_id = p.good_id
   AND    p1.the_date <= i.the_date
   AND    p1.the_date >  p.the_date
   );

ВИМКНЕНО

SELECT DISTINCT ON (i.the_date)
       i.the_date, p.the_date AS pricing_date, g.good, i.quantity, p.price
FROM   inventory  i
JOIN   good       g USING (good_id)
LEFT   JOIN price p ON p.good_id = i.good_id AND p.the_date <= i.the_date
ORDER  BY i.the_date, p.the_date DESC;

SQL Fiddle.


Швидші рішення

Якщо це все ще не досить швидко, можуть бути швидші рішення.

Рекурсивний CTE / JOIN LATERAL/ корельований підзапит

Особливо для розповсюдження даних із багатьма цінами за товар :

Матеріалізований вигляд

Якщо вам потрібно запускати це часто і швидко, я пропоную вам створити матеріалізований вигляд. Я думаю, що можна припустити, що ціни та запаси на минулі дати рідко змінюються. Обчисліть результат один раз і збережіть знімок як перегляд матеріалів.

Postgres 9.3+ має автоматизовану підтримку матеріалізованих представлень. Ви можете легко реалізувати базову версію в старих версіях.


3
price_good_date_desc_idxІндекс ви рекомендуєте значно поліпшили продуктивність аналогічного запиту шахти. Мій план запитів перейшов від вартості до 42374.01..42374.86меншої 0.00..37.12!
cimmanon

@cimmanon: Приємно! Яка основна функція запиту? НЕ Є? ВИМОГА? ГРУПИ ЗА?
Ервін Брандстеттер

Використання DISTINCT ON
cimmanon

6

FYI, я використовував mssql 2008, тому Postgres не буде мати показник "включати". Однак використання базової індексації, показаної нижче, зміниться від хеш-приєднань до злиття приєднань у Postgres: http://explain.depesz.com/s/eF6 (без індексу) http://explain.depesz.com/s/j9x ( з індексом критеріїв приєднання)

Я пропоную розбити ваш запит на дві частини. По-перше, представлення (не призначене для підвищення продуктивності), яке може бути використане в різних інших контекстах, що представляє взаємозв'язок дат інвентаризації та дати ціноутворення.

create view mostrecent_pricing_dates_per_good as
select i.good,i.date i_date,max(p.date)p_date
  from inventory i
  join price p on i.good = p.good and i.date >= p.date
 group by i.good,i.date;

Тоді ваш запит може стати простішим і простішим в маніпулюванні для інших видів, якщо запит (наприклад, використання лівих приєднань для пошуку рекламних ресурсів без останніх дат ціноутворення):

select i.good
       ,i.date inventory_date
       ,i.quantity
       ,p.date pricing_date
       ,p.price       
  from inventory i
  join price p on i.good = p.good
  join mostrecent_pricing_dates_per_good x 
    on i.good = x.good 
   and p.date = x.p_date
   and i.date = x.i_date

Це дає такий план виконання: http://sqlfiddle.com/#!3/24f23/1 відсутність індексації

... Усі сканування з повним видом. Зауважте, що ефективність хеш-матчів займає основну частину загальної вартості ... і ми знаємо, що сканування та сортування таблиці повільне (порівняно з метою: індекс шукає).

Тепер додайте базові індекси, щоб допомогти критеріям, які використовуються у вашому приєднанні (я не стверджую, що це оптимальні індекси, але вони ілюструють точку): http://sqlfiddle.com/#!3/5ec75/1 з базовою індексацією

Це свідчить про покращення. Вкладені операції циклу (внутрішнього з'єднання) більше не беруть на себе жодної відповідної загальної вартості запиту. Решта вартості зараз розподіляється серед пошукових запитів (сканування товарних запасів, оскільки ми тягнемо кожен інвентарний ряд). Але ми можемо все ж зробити краще, оскільки запит визначає кількість та ціну. Щоб отримати ці дані, після оцінки критерий приєднання необхідно виконати пошук.

Кінцева ітерація використовує "включити" на індекси, щоб полегшити план перекидання та отримання додатково запитуваних даних прямо з самого індексу. Тож пошуки пішли: http://sqlfiddle.com/#!3/5f143/1 введіть тут опис зображення

Тепер у нас є план запитів, де загальна вартість запиту розподіляється рівномірно між дуже швидкими операціями пошуку індексу. Це буде наближеним до того, як отримується добре. Безумовно, інші експерти можуть вдосконалити це далі, але рішення усуває пару основних проблем:

  1. Це створює зрозумілі структури даних у вашій базі даних, які простіше складати та повторно використовувати в інших областях програми.
  2. Усі найдорожчі оператори запитів були виведені з плану запитів за допомогою базової індексації.

3
Це добре (для SQL-сервера), але оптимізація для різних СУБД, хоча вона має схожість, має і серйозні відмінності.
ypercubeᵀᴹ

@ypercube це правда. Я додав кілька кваліфікацій про Postgres. Мій намір полягав у тому, що більшість представлених тут продуманих процесів застосовуватимуться незалежно від специфічних особливостей СУБД.
cocogorilla

Відповідь дуже глибока, тому знадобиться трохи часу, щоб спробувати. Я дам вам знати, як я можу дістатися.
Том Елліс

5

Якщо у вас трапляється PostgreSQL 9.3 (випущений сьогодні), то ви можете скористатися ЛІТЕРАЛЬНИМ ПРИЄДНАННЯМ.

Я не маю можливості тестувати це, і ніколи раніше його не використовував, але з того, що я можу сказати з документації, синтаксис був би таким:

SELECT  Inventory.Date,
        Inventory.Good,
        Inventory.Quantity,
        Price.Date,
        Price.Price
FROM    Inventory
        LATERAL
        (   SELECT  Date, Price
            FROM    Price
            WHERE   Price.Good = Inventory.Good
            AND     Price.Date <= Inventory.Date
            ORDER BY Price.Date DESC
            LIMIT 1
        ) p;

Це в основному еквівалент APPLY SQL-Server , і в демонстраційних цілях є робочий приклад цього в SQL-Fiddle .


5

Як зазначають Ервін та інші, ефективний запит залежить від безлічі змінних, і PostgreSQL дуже намагається оптимізувати виконання запитів на основі цих змінних. Як правило, ви хочете спершу написати для ясності, а потім змінити для продуктивності після того, як ви виявите вузькі місця.

Крім того, PostgreSQL має безліч хитрощів, якими ви можете скористатися, щоб зробити речі трохи ефективнішими (часткові індекси для одного), тому залежно від вантажу читання / запису, ви зможете дуже оптимізувати це, вивчивши ретельне індексування.

Перше, що потрібно спробувати, це просто зробити перегляд і приєднатися до нього:

CREATE VIEW most_recent_rows AS
SELECT good, max(date) as max_date
FROM inventory
GROUP BY good;

Це має бути добре, коли ви робите щось на зразок:

SELECT price 
  FROM inventory i
  JOIN goods g ON i.goods = g.description
  JOIN most_recent_rows r ON i.goods = r.goods
 WHERE g.id = 123;

Тоді ви можете приєднатися до цього. У результаті запит приєднається до поданої таблиці в нижній таблиці, але якщо припустити, що у вас є унікальний індекс (дата, добре в тому порядку ), вам слід пройти (оскільки це буде простий пошук кешу). Це буде дуже добре, якщо кілька рядків підняли голову, але будуть дуже неефективними, якщо ви намагаєтеся перетравити мільйони цін на товари.

Друге, що ви можете зробити, - це додати в таблицю інвентаря стовпець bool з найбільшою віддачею та

create unique index on inventory (good) where most_recent;

Тоді ви хочете використовувати тригери, щоб встановити значення most_recent як хибне, коли було вставлено новий рядок для товару. Це додає більшої складності та більших шансів на помилки, але це корисно.

Знову багато що залежить від відповідних індексів. У більшості останніх запитів щодо дати, ймовірно, ви повинні мати індекс за датою та можливий багато стовпчик, починаючи з дати та включаючи критерії приєднання.

Оновлення коментаря Пер Ервіна нижче, схоже, я це неправильно зрозумів. Перечитавши питання, я зовсім не впевнений, що мені задають. Я хочу зазначити в оновлення, яка потенційна проблема, яку я бачу, і чому це не залишає незрозумілим.

Пропонований дизайн бази даних не має реального використання IME з ERP та системами обліку. Він би працював у гіпотетичній ідеальній моделі ціноутворення, де все, що продається за певний день даного товару, має однакову ціну. Однак це не завжди так. Це навіть не так для речей, як обмін валют (хоча деякі моделі роблять вигляд, що це роблять). Якщо це надуманий приклад, незрозуміло. Якщо це справжній приклад, виникають більші проблеми з дизайном на рівні даних. Я збираюся припустити, що це справжній приклад.

Ви не можете припустити, що одна дата визначає ціну за дану товар. Ціни в будь-якому бізнесі можна домовитись від контрагента, а навіть іноді і за кожну операцію. З цієї причини ви дійсно повинні зберігати ціну в таблиці, яка фактично обробляє товарний запас, або в таблиці (інвентаризаційна таблиця). У такому випадку ваша таблиця дат / товарів / цін просто вказує базову ціну, яка може бути змінена на основі переговорів. У такому випадку ця проблема переходить від проблеми звітування до такої, яка є транзакційною та працює в одному рядку з кожної таблиці одночасно. Наприклад, ви можете шукати ціну за замовчуванням для даного товару на даний день як:

 SELECT price 
   FROM prices p
   JOIN goods g ON p.good = g.good
  WHERE g.id = 123 AND p."date" >= '2013-03-01'
  ORDER BY p."date" ASC LIMIT 1;

З індексом цін (добре, дата) це буде добре.

Я це надуманий приклад, можливо, допоможе щось ближче до того, над чим ви працюєте.


most_recentПідхід повинен добре працювати на самій останній ціною абсолютно . Здавалося б, ОП потребує найновішої ціни стосовно кожної дати інвентаризації.
Ервін Брандстеттер

Влучне зауваження. Перечитавши, хоча я помічаю деякі реальні практичні недоліки з запропонованими даними, але не можу сказати, чи це лише надуманий приклад. Як надуманий приклад, я не можу сказати, чого немає. Можливо, оновлення для цього також було б в порядку.
Кріс Траверс

@ChrisTravers: Це надуманий приклад, але я не маю права розміщувати фактичну схему, з якою працюю. Можливо, ви могли б трохи сказати про те, які практичні недоліки ви помітили.
Том Елліс

Я не думаю, що це повинно бути точним, але переживаю, що проблема втратиться в алегорії. Щось трохи ближче було б корисно. Проблема полягає в тому, що при ціноутворенні ціна в певний день, ймовірно, буде за замовчуванням, і, отже, ви не використовуєте її для звітування лише як за замовчуванням для введення транзакції, тому ваші цікаві запити, як правило, складають лише кілька рядків у час.
Кріс Траверс

3

Інший спосіб полягає у використанні функції вікна, lead()щоб отримати діапазон дат для кожного рядка в ціні таблиці, а потім використовувати betweenпід час приєднання до інвентаря. Я фактично використовував це в реальному житті, але головним чином тому, що це була моя перша ідея, як це вирішити.

with cte as (
  select
    good,
    price,
    date,
    coalesce(lead(date) over(partition by good order by date) - 1
            ,Now()::date) as ndate
  from
    price
)

select * from inventory i join cte on
  (i.good = cte.good and i.date between cte.date and cte.ndate)

SqlFiddle


1

Використовуйте приєднання з інвентарю для встановлення ціни з умовами приєднання, які обмежують кількість записів із табеля цін лише на ті, які є на даті інвентаризації або до неї, а потім витягніть максимальну дату, і коли дата є найвищою датою з цього підмножини

Отже, для вашої інвентарної ціни:

 Select i.date, p.Date pricingDate,
    i.good, quantity, price        
 from inventory I join price p 
    on p.good = i.good
        And p.Date = 
           (Select Max(Date from price
            where good = i.good
               and date <= i.Date)

Якщо ціна на будь-яку вказану позицію змінювалася більше одного разу в той же день, і у вас дійсно є лише дати і жоден раз у цих стовпцях, можливо, вам доведеться застосувати більше обмежень щодо об'єднань, щоб вибрати лише один із записів зміни ціни.


Це, на жаль, не пришвидшує справи.
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.