Это третья статья из цикла. В прошлой части мы разбирали типы метрик, и уже там начали встречаться перцентили. Это полезная штука, но для неподготовленного читателя она выглядит просто как математическая дичь. Эта статья поможет разобраться в перцентилях по-инженерному, не заканчивая профильный факультет. Кстати, в следующей части будет подобное объяснение производных.

Лучше, конечно, объяснять эту тему вживую и с доской, но у нас будет простой python – потому что это то же самое, что писать математику вручную, только короче.

Оглавление цикла:

  1. Потерянное введение

  2. Prometheus

  3. Перцентили для чайников

  4. PromQL

Зачем?

Какую задачу решают перцентили, и зачем эти сложности? На самом деле, они нужны для того, чтобы посмотреть на массив данных и описать их одним числом. Например «большинство HTTP запросов обработались в пределах 5 секунд». Это нужно, чтобы было удобно смотреть на графике, как ведет себя приложение «в целом». ��ногда запросы залипают или завершаются раньше времени, но нам это не важно, если большинство запросов ведут себя предсказуемо. В общем, это способ сжать историю наблюдений до одного числа, при этом по пути потерять неудобные для нас данные, чтобы не мешали смотреть на общую картину.

Данные

Мы рассматриваем перцентили в контексте метрик. Напомню, метрика — это временной ряд, то есть набор значений во времени. Пока забудем про timestamp-ы. Тогда останутся данные вида [1,2,0,10,50,42,38]. Вот с такими данными мы и будем работать в этой статье: просто каким-то массивом чисел.

Подготовка

Для начала разберемся с тем, что часто встречается и проще всего для понимания: что такое «среднее арифметическое» и «медиана». Работать будем вот с этими массивами:

a = [1, 2, 0, 10, 50, 42, 38]
b = [1, 2, 0, 10, 50, 42, 38, 5]

Среднее арифметическое

Это сумма всех элементов, деленная на их количество:

sum(a)/len(a)
# 20.428571428571427 - среднее арифметическое массива a

sum(b)/len(b)
# 18.5 - среднее арифметическое массива b

Медиана

Чтобы не разглядывать весь массив, можно выбрать какой-то один хороший элемент
Чтобы не разглядывать весь массив, можно выбрать какой-то один хороший элемент

Медиана – это «средний элемент», то есть буквально в середине массива (если его упорядочить). Чтобы ее найти:

  1. Упорядочим массив

  2. Если длина массива нечетная, берем элемент посередине

  3. Если длина массива четная, берем два элемента посередине и считаем их среднее арифметическое

Случай с четной длиной — это просто такой математический костыль.

sort_a = sorted(a)
# [0, 1, 2, 10, 38, 42, 50]

len(sort_a)
# 7 - длина массива нечетная, элемент посередине - с индексом 3

sort_a[3]
# 10 - медиана массива a

Случай с массивом четной длины:

sort_b = sorted(b)
# [0, 1, 2, 5, 10, 38, 42, 50]

len(sort_b)
# 8 - длина массива четная, элементы посередине - с индексом 3 и 4

sort_b[3]
# 5

sort_b[4]
# 10

(sort_b[3] + sort_b[4])/2
# 7.5 - медиана массива b

Можно сказать, что это свойства нашего массива. Если мы поменяем в нем значения, то его свойства изменятся: будет другое среднее арифметическое и другая медиана. А какие еще есть полезные свойства?

max(a)
# 50

max(b)
# 50

min(a)
# 0

min(b)
# 0

Это нам пригодится чуть ниже.

Перцентили

Вообще, есть разные слова, которые обозначают похожие вещи: квантиль (quantile), перцентиль или процентиль (percentile), квартиль (quartile). Пока давайте забьем на все кроме перцентиля, остальное разберем в конце

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

Опять медиана

У медианы есть такая особенность: половина элементов массива больше либо равна ей, а другая половина элементов — меньше либо равна. Мы же достали ее из середины ��ортированного массива, логично? Остановитесь и подумайте над этим:

# a

[0, 1, 2, 10, 38, 42, 50]
'''
          ^^
          10
[   <=    ]
           [     >=     ]
'''

# b
[0, 1, 2, 5, 10, 38, 42, 50]
'''
            ^
           7.5
[     <=   ]
             [     >=      ]
'''

Вот возьмем ту часть, которая меньше либо равна. Можно поиграться терминами и перефразировать так:

50% элементов массива <= какого-то числа

Так исторически сложилось, что это «какое-то число» часто используется и имеет свое название, медиана. Еще часто используется вот такое значение:

Все элементы массива <= какого-то числа
или другими словами
100% элементов массива <= какого-то числа

Это, внезапно, максимальный элемент, то есть max(): все элементы массива меньше либо равны ему. Ну и наконец, часто встречается вот это:

Ни один элемент массива не < какого-то числа
или другими словами
0% элементов массива < какого-то числа

Тут опять втыкается математический костыль, потому что в данном случае оказалось удобно заменить <= на <, чтобы в качестве «какого-то числа» взять минимальный элемент, то есть min(). Не заморачивайтесь, почему и зачем, мы тут не за этим...

Поздравляю, теперь вы знаете три перцентиля: 0-й, 50-й и 100-й.

Обобщаем

Заметное свойство массива можно выбирать под свою задачу
Заметное свойство массива можно выбирать под свою задачу

А что если пошатать определение медианы и взять какой-то произвольный процент? Ну или как-то обобщить min, max и медиану?

N-й перцентиль - это такое число, что N% элементов массива меньше или равны этому числу
или чуть короче
X - это N-й перцентиль, если N% элементов массива <= X

Под это определение попадают медиана (N=50), max (N=100) и min (N=0), с небольшой поправкой на равенство в случае с min. Они описывают понятные вещи: средний элемент, максимальный, минимальный. А что описывает, например, 95-й перцентиль? Подставим его в определение:

95-й прецентиль — это такое число, что 95% элементов массива меньше или равны этому числу.

И что с этим делать? Оказывается, очень удобно использовать эту штуку, чтобы описать большинство элементов массива, при чем степень точности регулируется: 80%, 95%, 99%, 99.9%, ... Для чего бывает полезно описывать «большинство из массива»? Чтобы выбросить пики! Например, у вас есть HTTP-сервер, и вы считаете время обработки запросов. Иногда запросы зависают надолго, потому что сеть моргнула, нода перезапускалась, БД приуныла, пришел GC, клиент сам отвалился, ... В принципе это нормально, мы же не регистрируем сбой из-за каждого подвисшего запроса. Вы хотите знать, что в целом у вас запросы отрабатывают за какое-то разумное время. Вот в этой фразе «в целом» на математическом языке заменяется на «какой-то перцентиль», а «разумное время» — это его значение. Например:

95 перцентиль от времени обработки запросов = 5 секунд

То есть, большинство (95%) запросов мы обработали за 5 секунд или меньше. А остальные 5% обрабатывались дольше. Можно еще посчитать 99-й перцентиль и сравнить с 95-м, тогда станет понятно, что большинство запросов укладываются в 5 секунд, а подавляющее большинство, скажем, в 6 секунд, что вообще-то тоже неплохо. При этом максимум может оказаться каким-нибудь гигантским, скажем, 60 секунд и встречаться крайне редко (оставшийся 1%), поэтому нам он не интересен. А еще до кучи посчитаем медиану (50-й перцентиль), она может оказаться сильно меньше 95-го перцентиля (например 1 секунда). Это поможет понять, что в среднем запросы отрабатывают быстро и есть запас по производительности.

Перцентили позволяют нарезать слоями ваш массив и понять, на какие группы делятся элементы: в среднем они такие-то, в основном такие-то, в подавляющем большинстве такие-то и т.д

Мы не будем здесь разбирать, как именно считать произвольный перцентиль на произвольном массиве. Важно только заметить одну деталь: медиану мы иногда считаем как среднее арифметическое двух элементов, то есть мы не берем ее как элемент из массива, а высчитываем где-то посередине между элементами. По аналогии, перцентили тоже не обязательно берутся из элементов массива, а могут высчитываться где-то между ними, поэтому значение перцентиля может оказаться дробным даже для набора из целых чисел.

Для интересующихся, функция на чистом питоне, которая считает перцентиль из сортированного массива
# взято отсюда: https://stackoverflow.com/a/2753343

import math

def percentile(arr, n):
    k = (len(arr)-1) * n
    f = math.floor(k)
    c = math.ceil(k)
    if f==c:
        return arr[int(k)]
    d0 = arr[int(f)] * (c-k)
    d1 = arr[int(c)] * (k-f)
    return d0+d1

# пример:
# percentile(sort_a, 0.95)
# percentile(sort_b, 0.5)

Если все понятно с определением — отправляемся назад, в будущее!

Добавляем время

Историю изменений в массиве тоже можно наблюдать издалека
Историю изменений в массиве тоже можно наблюдать издалека

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

Большие массивы чисел сами по себе «в лоб» не понятно как вообще можно визуализировать, поэтому в метриках и используются перцентили

В самом начале мы считали медиану, min и max на небольших массивах. Если мы начнем записывать время обработки HTTP запросов, и не будем перезапускать приложение месяцами, у нас могут накопиться миллионы значений, которые еще нужно сортировать и обсчитывать, когда мы захотим узнать перцентили. Ну или как-то хитро хранить в сортированном виде и находить место для очередного значения... Короче, это все потенциально разрастается в бесконечность, поэтому так никто не делает.

Семплирование

Обычно для метрик мы семплируем данные, то есть из всего множества выбираем только некоторые элементы, чтобы из них уже считать перцентили и другие свойства. Как из бесконечного ряда даных выбрать конечное количество элементов? Придется чем-то пожертвовать, для этого есть разные алгоритмы. Мы храним, например, до 100 элементов, а когда надо записать новый, мы решаем, какой элемент нужно выкинуть:

  • Sliding Window: просто выбрасываем самый старый элемент. Очень просто в реализации и понимании

  • Forward Decay: выбрасываем случайный элемент, но чем он старее, тем больше вероятность, что он будет выброшен. Вероятность может расти как угодно, например, экспоненциально. Идея в том, что старые значения все еще чуть-чуть влияют на свойства массива, поэтому такая выборка немного точнее описывает наш бесконечный ряд данных

Сжатие

Можно считерить и вообще хранить не сами элементы, а «корзины» или «бакеты». Это просто счетчики того, сколько раз элемент попал в такой-то диапазон. На примере будет понятнее: берем время обработки HTTP запросов. Определимся, какое время считать хорошим, какое плохим (пример из прошлой части):

  • <= 0.1 сек - хороший запрос

  • <= 1 - сойдет

  • <= 5 - подозрительно

  • больше 5 - вообще плохо. Для однообразия можно сказать, что это <= infinity

Пришел запрос, померяли время X, и добавили +1 к тем бакетам, у которых подходят условия: X <= 0.1, X <= 1 и т.д.

А дальше в дело вступает маги��, которую не обязательно знать, и поэтому мы ее разбирать не будем, но из бакетов тоже можно посчитать перцентили. Конечно, если бакетов мало, мы теряем точность, зато экономим ресурсы! Алгоритм можно посмотреть в этой статье — там нет ничего сложного, тоже инженерно-алгоритмические фокусы, только более громоздкие.

Prometheus

С перцентилями можно столкнуться при использовании Histogram и Summary. Подробнее о том, как они выглядят, было в прошлой части. Гистограмма — это бакеты, которые вы считаете в своем приложении, а prometheus можно потом попросить посчитать из них перцентили. Причем если бакеты одинаковые, а теги разные (например, гистограмма с разных реплик или слоев), то можно без проблем их посчитать вместе. Summary — это перцентили, которые вы уже посчитали в приложении, а prometheus просто сохраняет их как числа. Обычно считается с помощью семплирования, поэтому точнее, чем бакеты. Ее можно просто в лоб вывести на график, но нельзя агрегировать! Причина, упрощенно, в том, что у вас разные выборки, поэтому результат агрегации будет отражать непонятно что. Вот показательный пример:

Не агрегируйте Summary

Пример, который демонстрирует, что с виду одинаковые Summary с двух реплик нельзя как-то сложить вместе: предположим у вас две реплики app1 и app2, они получают HTTP-запросы и записывают время обработки.

app1 = [9]*5  # пять запросов по 9 секунд
app2 = [1]*100  # сто запросов по секунде

percentile(app1, 0.95)
# 9 - 95й перцентиль app1

percentile(app2, 0.95)
# 1 - 95й перцентиль app2

Каждая пишет в prometheus метрику типа Summary с 95-м перцентилем и с тегом, чтобы различать с какой реплики сняты данные.

http_time{quantile="0.95", instance="app1"} 9
http_time{quantile="0.95", instance="app2"} 1

Вы думаете, что неплохо бы нарисовать один общий график «95й перцентиль от времени обработки HTTP» и делаете такой запрос к prometheus:

avg(http_time{quantile="0.95"}) without (instance)

avg — потому что нужна же какая-то функция, чтобы сложить вместе два значения, почему бы не взять среднее арфиметическое между ними? Не сумму же. В результате получается (9+1)/2 = 5, вроде бы все нормально, да? А теперь давайте по-честному посчитаем 95 перцентиль на общих данных от app1 и app2:

all = sorted(app1+app2)
percentile(all, 0.95)
# 1

Честный 95-й перцентиль равен 1, а avg от двух метрик получился 5, то есть вы видите что-то на графике, но это совершенно бессмысленное значение. Если вам нужно агрегировать summary, подумайте, для чего вы на них смотрите. Например, в случае со временем обработки HTTP, скорее всего вас интересует, не превышает ли 95-й перцентиль какой-то порог. Поэтому можно взять максимум из метрик вместо avg:

max(http_time{quantile="0.95"}) without (instance)

Вы будете видеть один график, и если какая-то из реплик работает в основном медленно, то это будет видно. Конечно, решение не идеальное. Например, может привести к false-positive алертам: тормозит только одна реплика, а не все приложение, но вам уже звонят посреди ночи.

Нужно понимать, какие данные вы хотите видеть и для каких целей, и выбирать Histogram, Summary, бакеты, перцентили, способы семплирования и агрегации аккуратно, понимая их ограничения.

Рецепты

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

  • Для начала попробуйте сделать гистограмму.

  • Сделайте бакетов пять, вроде «отлично/хорошо/сойдет/плохо/недопустимо».

  • Если вообще не представляете, какие нужны бакеты, придется взять Summary.

  • Вам скорее всего нужен 95-й перцентиль (вне зависимости от типа метрики).

  • Сравните визуально 95-й перцентиль и медиану. Медиана покажет что-то максимально усредненное, 95-й перцентиль покажет изменения с большей точностью, но спрячет выбросы.

  • Если у вас Summary, не агрегируйте ее или делайте очень осторожно, никакого avg()!

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

Вариации на тему

Короткий словарик похожих терминов, как и обещано:

  • Перцентиль мы уже знаем. Это число N от 0 до 100, такое, что N% элементов массива меньше него.

  • Процентиль это другой способ перевести percentile на русский язык. То есть синоним слова перцентиль.

  • Квартиль это четверти: 25%, 50%, 75%, 100%. То есть бывает первый, второй третий и четвертый квартиль. И еще иногда используют нулевой.

  • Квантиль - это, условно, перцентиль без процентов. Используется в статистике, где бывает удобно указывать абсолютную вероятность, а не в процентах

  • Еще можно встретить дециль — это 10%, 20% и т.д.

Есть еще разночтения самого определения, которое, по сути, гнется и шатается под вашу задачу, например:

  • Можно определить перцентили через вероятности, как это сделано в матстатистике. Этот способ точнее описывает суть, но гораздо сложнее для понимания, поэтому сойдет и так.

  • Можно в определении использовать < > >= вместо <= и это все можно тоже называть перцентилями, например «95% элементов больше X».

  • Можно считать перцентиль так, чтобы он всегда был элементом массива, это нужно в некоторых задачах (как и медиану не считать средним арифметическим, а брать ближайший больший/меньший элемент).

В целом все это создает путаницу, особенно для тех, кто еще не знаком с самим понятием и не переваривает сложные математические определения. Поэтому здесь разобран один вариант, наиболее близкий к тому, как работает prometheus, чтобы можно было как-то пользоваться Histogram, Summary и понимать, что вообще происходит.

Бонус

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


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