Postgres: как найти ближайший tsrange по метке времени за пределами диапазонов?

Я моделирую (в Postgres 9.6.1/postGIS 2.3.1) систему бронирования локальных услуг, предоставляемых поставщиками:

create table supplier (
    id                serial primary key,
    name              text not null check (char_length(title) < 280),
    type              service_type,
    duration          interval,
    ...
    geo_position      geography(POINT,4326)
    ...
);

Каждый поставщик ведет календарь с временными интервалами, когда он/она доступен для бронирования:

create table timeslot (
    id                 serial primary key,
    supplier_id        integer not null references supplier(id),
    slot               tstzrange not null,

    constraint supplier_overlapping_timeslot_not_allowed
    exclude using gist (supplier_id with =, slot with &&)
);

Когда клиент хочет знать, какие близлежащие поставщики доступны для бронирования в определенное время, я создаю представление и функцию:

create view supplier_slots as
    select
        supplier.name, supplier.type, supplier.geo_position, supplier.duration, ...
        timeslot.slot
    from
        supplier, timeslot
    where
        supplier.id = timeslot.supplier_id;


create function find_suppliers(wantedType service_type, near_latitude text, near_longitude text, at_time timestamptz)
returns setof supplier_slots as $$
declare
    nearpoint geography;
begin
    nearpoint := ST_GeographyFromText('SRID=4326;POINT(' || near_latitude || ' ' || near_longitude || ')');
    return query
        select * from supplier_slots
        where type = wantedType
            and tstzrange(at_time, at_time + duration) <@ slot
        order by ST_Distance( nearpoint, geo_position )
        limit 100;
end;
$$ language plpgsql;

Все это работает очень хорошо.

Теперь для поставщиков, у которых НЕ было доступного для бронирования временного интервала в запрошенное время, я хотел бы найти их ближайшие доступные временные интервалы до и после запрошенного at_time, также отсортированные по расстоянию.

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

Любые идеи о самом умном способе сделать это?


person Jesper We    schedule 18.12.2016    source источник
comment
Вам нужно в общей сложности 100 поставщиков (с доступными временными интервалами и без них) или всего 100 временных интервалов? Или 200? Определение должно быть четким, оно может привести к другому решению.   -  person Erwin Brandstetter    schedule 18.12.2016
comment
Очень актуальный вопрос. Это, вероятно, имеет наибольший смысл: из X ближайших поставщиков, которые предлагают правильный service_type, вернуть тех, у кого timeslot либо до, либо после at_time (или обоих), упорядоченных по временному интервалу между ближайшими timeslot и at_time.   -  person Jesper We    schedule 18.12.2016


Ответы (1)


Решение зависит от точности того, что вы хотите.

Схема

Я предлагаю эти слегка адаптированные определения таблиц, чтобы упростить задачу, обеспечить целостность и повысить производительность:

CREATE TABLE supplier (
   supplier_id  serial PRIMARY KEY,
   supplier     text NOT NULL CHECK (length(title) < 280),
   type         service_type,
   duration     interval,
   geo_position geography(POINT,4326)
);

CREATE TABLE timeslot (
   timeslot_id  serial PRIMARY KEY,
   supplier_id  integer NOT NULL -- references supplier(id),
   slot_a       timestamptz NOT NULL,
   slot_z       timestamptz NOT NULL,
   CONSTRAINT   timeslot_range_valid CHECK (slot_a < slot_z)
   CONSTRAINT   timeslot_no_overlapping
     EXCLUDE USING gist (supplier_id WITH =, tstzrange(slot_a, slot_z) WITH &&)
);

CREATE INDEX timeslot_slot_z ON timeslot (supplier_id, slot_z);
CREATE INDEX supplier_geo_position_gist ON supplier USING gist (geo_position);
  • Сохраните два столбца timestamptz slot_a и slot_z вместо столбца tstzrange slot и соответствующим образом измените ограничения. Теперь все диапазоны автоматически обрабатываются как включающие нижние и исключительные верхние границы по умолчанию, что позволяет избежать угловых ошибок и головной боли.

    Сопутствующее преимущество: всего 16 байтов для 2 timestamptz вместо 25 байтов (32 с дополнением) для tstzrange.

  • Все запросы, которые у вас могли быть на slot, продолжают работать с tstzrange(slot_a, slot_z) в качестве замены.

  • Добавьте индекс для (supplier_id, slot_z) для текущего запроса.
    И пространственный индекс для supplier.geo_position (который у вас, вероятно, уже есть).

    В зависимости от распределения данных в type, несколько частичных индексов для типов, распространенных в запросах, могут повысить производительность:

    CREATE INDEX supplier_geo_type_foo_gist ON supplier USING gist (geo_position)
    WHERE supplier = 'foo'::service_type;
    

Запрос/функция

Этот запрос находит X ближайших поставщиков, которые предлагают правильные service_type (в примере 100), каждый с одним ближайшим совпадающим временным интервалом (определяемым временным расстоянием до начала слота). Я объединил это с фактически соответствующими слотами, которые могут быть, а могут и не быть тем, что вам нужно.

CREATE FUNCTION f_suppliers_nearby(_type service_type, _lat text, _lon text, at_time timestamptz)
  RETURNS TABLE (supplier_id  int
               , name         text
               , duration     interval
               , geo_position geography(POINT,4326)
               , distance     float 
               , timeslot_id  int
               , slot_a       timestamptz
               , slot_z       timestamptz
               , time_dist    interval
   ) AS
$func$
   WITH sup_nearby AS (  -- find matching or later slot
      SELECT s.id, s.name, s.duration, s.geo_position
           , ST_Distance(ST_GeographyFromText('SRID=4326;POINT(' || _lat || ' ' || _lon || ')')
                          , geo_position) AS distance
           , t.timeslot_id, t.slot_a, t.slot_z
           , CASE WHEN t.slot_a IS NOT NULL
                  THEN GREATEST(t.slot_a - at_time, interval '0') END AS time_dist
      FROM   supplier s
      LEFT   JOIN LATERAL (
         SELECT *
         FROM   timeslot
         WHERE  supplier_id = supplier_id
         AND    slot_z > at_time + s.duration  -- excl. upper bound
         ORDER  BY slot_z
         LIMIT  1
         ) t ON true
      WHERE  s.type = _type
      ORDER  BY s.distance
      LIMIT  100
      )
   SELECT *
   FROM  (
      SELECT DISTINCT ON (supplier_id) *  -- 1 slot per supplier
      FROM  (
         TABLE sup_nearby  -- matching or later slot

         UNION ALL         -- earlier slot
         SELECT s.id, s.name, s.duration, s.geo_position
              , s.distance
              , t.timeslot_id, t.slot_a, t.slot_z
              , GREATEST(at_time - t.slot_a, interval '0') AS time_dist
         FROM   sup_nearby s
         CROSS  JOIN LATERAL (  -- this time CROSS JOIN!
            SELECT *
            FROM   timeslot
            WHERE  supplier_id = s.supplier_id
            AND    slot_z <= at_time  -- excl. upper bound
            ORDER  BY slot_z DESC
            LIMIT  1
            ) t
         WHERE  s.time_dist IS DISTINCT FROM interval '0'  -- exact matches are done
         ) sub
      ORDER  BY supplier_id, time_dist  -- pick temporally closest slot per supplier
   ) sub
   ORDER  BY time_dist, distance;  -- matches first, ordered by distance; then misses, ordered by time distance

$func$  LANGUAGE sql;

Я не использовал ваше представление supplier_slots и вместо этого оптимизировал его для повышения производительности. Вид все еще может быть удобным. Вы можете включить tstzrange(slot_a, slot_z) AS slot для обратной совместимости.

Базовый запрос для поиска 100 ближайших поставщиков — это задача из учебника «K ближайших соседей». Для этого хорошо подходит индекс GiST. Связанный:

Дополнительную задачу (найти ближайший во времени слот) можно разбить на две задачи: найти следующую более высокую и следующую более низкую строку. Основная функция этого решения состоит в наличии двух подзапросов с ORDER BY slot_z LIMIT 1 и ORDER BY slot_z DESC LIMIT 1, что приводит к двум очень быстрым сканированиям индекса.

Я совместил первый с поиском фактических совпадений, что является (я думаю, умной) оптимизацией, но может отвлекать от фактического решения.

person Erwin Brandstetter    schedule 18.12.2016
comment
Довольно много! Позвольте мне немного переварить это. По крайней мере, я рад, что не задал глупый вопрос с очевидным ответом ;-) - person Jesper We; 18.12.2016
comment
@JesperWe: на самом деле я не проверял эту функцию. Но он должен быть настолько быстрым, насколько это возможно, только со сканированием индекса. - person Erwin Brandstetter; 19.12.2016
comment
Не беда, опечатки легко исправляются. Решение на самом деле не выполняет свою работу (вы меняете его, чтобы найти доступные и ближайшие слоты в одном и том же запросе, что мне нравится, потому что это то, о чем я думал в первую очередь, но я решил иметь два отдельных запроса. Если вы делаете это в одном запросе, вы должны учитывать ситуации, когда есть только частичное совпадение между time_at + duration и временным интервалом и т. д. Но в любом случае, вы указали мне путь, теперь я иду :-) - person Jesper We; 19.12.2016
comment
@JesperWe: Круто. Частичные индексы могут быть очень полезны для оптимизации производительности. Я добавил еще несколько пояснений. Если в коде все еще есть опечатки, не стесняйтесь исправлять. - person Erwin Brandstetter; 19.12.2016