Раздел 42 · Аналитика данных с нуля

Время между событиями

junior~15 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

Отто · операционный директор доставки «Гонец»

Курьеры пишут в чат, что весь день в мыле. А я смотрю на карту и вижу, что полсмены они стоят у кафе. Сколько времени курьер реально везёт заказ? И сколько из смены он вообще с заказом в руках?

В прошлых уроках мы разобрались с часовыми поясами, научились резать время на часы и дни и находить дыры в календаре. Всё это были вопросы про один момент: когда случился заказ. Отто спрашивает про другое: сколько прошло между двумя моментами. Сегодня считаем длительности.

Секундомер

Представь, что у каждого курьера на руле секундомер. Забрал пакет в кафе, нажал «старт». Отдал клиенту, нажал «стоп». Длительность это разница двух показаний.

В базе «Гонца» обе кнопки уже нажаты: picked_up_at это момент забора, delivered_at это момент доставки. Оба поля пишет приложение курьера, оба timestamptz. Раз пояс у них общий, их можно вычитать напрямую:

SELECT id, picked_up_at, delivered_at,
       delivered_at - picked_up_at AS ride
FROM orders
WHERE status = 'delivered'
ORDER BY id
LIMIT 3;
 id | picked_up_at           | delivered_at           | ride
----+------------------------+------------------------+----------
  1 | 2026-03-01 04:18:38+00 | 2026-03-01 04:34:21+00 | 00:15:43
  2 | 2026-03-01 04:14:10+00 | 2026-03-01 04:36:56+00 | 00:22:46
  3 | 2026-03-01 04:22:59+00 | 2026-03-01 04:47:22+00 | 00:24:23

Разность двух моментов это интервал: 15 минут 43 секунды. Первый заказ курьер вёз чуть больше четверти часа.

Вычитать можно только моменты из одного мира. created_at у заказа записан сайтом в местном времени Екатеринбурга, без пояса. Если вычесть его из delivered_at напрямую, PostgreSQL примет местное время за UTC, и каждый заказ «доставят» на пять часов раньше. Как это чинить через AT TIME ZONE, мы разбирали в уроке про пояса.

Из интервала в минуты

Интервалы можно складывать и усреднять: avg(delivered_at - picked_up_at) вернёт что-то вроде 00:17:12.4. Человеку так читать легко, а делить, округлять и сравнивать с порогом трудно. Поэтому интервал обычно переводят в число. extract(epoch FROM интервал) даёт длительность в секундах, делим на 60 и получаем минуты:

SELECT count(*) AS deliveries,
       round(avg(extract(epoch FROM delivered_at - picked_up_at) / 60), 1) AS avg_ride_min
FROM orders
WHERE status = 'delivered'
  AND delivered_at IS NOT NULL;
 deliveries | avg_ride_min
------------+--------------
      39343 |         17.2

В среднем курьер везёт заказ 17 минут. Но среднее, как мы помним по чекам в Пограничье, легко тянут вверх редкие длинные поездки. Самая долгая в базе длится 3 часа 43 минуты: пешком почти 18 км. Таким значениям нужна мера, которой они не страшны.

Медиана

Выстрой все поездки по длительности и возьми ту, что ровно посередине. Половина поездок короче неё, половина длиннее. Это медиана. Одна поездка на 3 часа сдвинет её на одну позицию, а не на минуты.

В PostgreSQL медиану считает агрегат percentile_cont(0.5). Синтаксис у него непривычный: что сортировать, пишут отдельно, в WITHIN GROUP (ORDER BY …).

SELECT c.transport,
       round(avg(extract(epoch FROM o.delivered_at - o.picked_up_at) / 60), 1) AS avg_min,
       round((percentile_cont(0.5) WITHIN GROUP (
         ORDER BY extract(epoch FROM o.delivered_at - o.picked_up_at) / 60))::numeric, 1) AS median_min
FROM orders o
JOIN couriers c ON c.id = o.courier_id
WHERE o.status = 'delivered'
  AND o.delivered_at IS NOT NULL
GROUP BY c.transport
ORDER BY c.transport;
 transport | avg_min | median_min
-----------+---------+------------
 bike      |    17.3 |       15.4
 car       |    11.8 |       10.5
 foot      |    27.0 |       22.4

percentile_cont возвращает double precision, а round с числом знаков работает только с numeric. Отсюда ::numeric перед округлением. Медиана везде ниже среднего: длинные поездки есть у всех, но их мало. Пеший курьер везёт заказ вдвое дольше машины, хотя диспетчер и так отдаёт ему короткие заказы.

урок из курса

Урок из курса «Аналитика данных с нуля». Курс покупается один раз и остаётся навсегда: 500 ₽.

Оплата пока не подключена: премиум и курсы открываются промокодом.