Какое-то время назад у меня возник инцидент с IOPS в продакшене (я уже писал о нём). Однако у меня не было никакой возможности замерить происходившее. Так как EBS скрывает от меня все механизмы, я решил замерить поведение того запроса в контролируемой мной среде. План такой: я выполняю один и тот же запрос трижды, каждый раз замеряя показания (сначала со страницами в общих буферах, затем со страницами, которые находятся только в кэше страниц операционной системы и, наконец, при чтении всего с диска). После этого я сравню результаты с двумя дисками, скрытыми под облачными абстракциями: с томом EBS из инцидента и с сервером Hetzner, бенчмарк которого я уже проводил.

Система довольно проста: моя домашняя машина с Debian. У меня работает Postgres 17 в Docker с shared_buffers = 16MB, track_io_timing = on. В качестве накопителя используется локальный SSD NVMe с ext4. Я намеренно создал таблицу такого размера, чтобы она не умещалась в кэш:

postgres=# SELECT pg_size_pretty(pg_relation_size('leads')), pg_relation_size('leads')/8192;
 pg_size_pretty | ?column?
----------------+----------
 115 MB         |    14706

Смысл создания таблицы размером гораздо больше shared_buffers заключается в том, чтобы гарантировать, что полное чтение не уместится в собственный кэш.

Таблицу я смоделировал на основе той, которая поломалась в продакшене: в ней есть id, account_id и жирная нагрузка в jsonb. Также есть только обычный индекс в B-дереве для account_id. Два наихудших запроса в инциденте выполняли одно и то же действие: производили фильтрацию по account_id с индексом, а затем применяли второй фильтр для неиндексированного столбца после получения строк. Именно это я и воссоздаю. Для аккаунта 42:

postgres=# SELECT count(*), count(DISTINCT (ctid::text::point)[0]) AS heap_pages
postgres-#   FROM leads WHERE account_id = 42;
 count | heap_pages
-------+------------
   500 |        500

Итак, у нас есть 500 строк, разбросанных по 500 отдельным страницам кучи. Для чтения аккаунта 42 Postgres читает индекс, чтобы узнать местоположение строк, а затем получает из кучи 500 отдельных страниц. Выполняемый мной запрос добавляет фильтр по payload, который индекс не может рассчитать, поэтому загружаются и отбрасываются все 500 строк.

Index Scan using idx_account_id on leads
  Index Cond: (account_id = 42)
  Filter: (payload ~~ '%zzzz%'::text)
  Rows Removed by Filter: 500

Да, мы получили 4 МБ совершенно бесполезного ввода-вывода (см. строку Rows Removed by Filter). Теперь в этой системе мы выполним один и тот же запрос в трёх состояниях кэша, каждый раз считывая строку Buffers и наблюдая за происходящим.

Общие буферы

Давайте выполним запрос во второй раз, пока страницы всё ещё тёплые:

Index Scan using idx_account_id on leads (actual time=0.534..0.534 rows=0)
  Buffers: shared hit=504
Execution Time: 0.569 ms

shared hit=504 — каждая страница уже находится в памяти процесса, поэтому никаких системных вызовов нет. pg_buffercache подтверждает, что они резидентные:

postgres=# SELECT c.relname, count(*) FROM pg_buffercache b
postgres-#   JOIN pg_class c ON b.relfilenode = pg_relation_filenode(c.oid)
postgres-#   WHERE c.relname IN ('leads','idx_account_id') GROUP BY 1;
    relname     | count
----------------+-------
 leads          |   500
 idx_account_id |     5

0,57 мс. Хороший показатель.

Кэш страниц операционной системы

Теперь давайте удалим страницы из общих буферов — перезапуск стирает их, потому что общие буферы находятся в памяти процесса. По очевидным причинам мы не будем удалять кэш страниц операционной системы. При холодных общих буферах и тёплом кэше операционной системы результаты оказываются такими:

Index Scan using idx_account_id on leads (actual time=2.292..2.293 rows=0)
  Buffers: shared read=504
  I/O Timings: shared read=2.024
Execution Time: 2.316 ms

Вместо shared hit, который мы получили при первой попытке, теперь у нас есть shared read, то есть нам нужно отправить системный вызов read(2). Postgres не знает поведения системного вызова. С точки зрения Postgres, произошло 504 промаха: 504 вызова read(2). Но взгляните на тайминги — 2024 мс для всех 504 операций чтения, то есть примерно по 4 микросекунды на каждую.

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

Диск

Теперь мы очистим кэш страниц операционной системы и выполнять чтение можно будет только с SSD (разумеется, данные могут находиться в DRAM SSD, но я знаю, что у моей локальной модели нет DRAM). Для этого нужны два этапа: перезапустить Postgres для очистки её собственной памяти, а затем echo 3 > /proc/sys/vm/drop_caches для сброса кэша страниц операционной системы, после чего снова выполнить тот же запрос.

Index Scan using idx_account_id on leads (actual time=39.991..39.991 rows=0)
  Buffers: shared read=504
  I/O Timings: shared read=39.502
Execution Time: 40.276 ms

39,5 ms — примерно по 78 микросекунд на чтение, в двадцать раз медленнее, чем кэш страниц.

Давайте сравним этот показатель и baremetal-устройство при помощи fio и произвольного чтения 8 КБ при помощи --direct=1 для обхода кэша страниц.

frn@debian:~$ fio --name=randread --filename=/home/frn/.fio_test --size=1G \
              --bs=8k --rw=randread --direct=1 --ioengine=psync --iodepth=1 \
              --runtime=8 --time_based
...
read: IOPS=13.2k
clat (usec): min=61, avg=75.70, 99.00th=[84]

76 микросекунд на чтение; Postgres замерила 78, так что они согласуются. Это подтверждает, что затраты связаны с самим SSD.

Кроме того, я поэкспериментировал и с чтением тех же самых 500 холодных страниц в отсортированном порядке вместо порядка по индексу. Затраты оказались равны 2,8 мс. Это меня сильно удивило, однако ядро замечает последовательный паттерн и загружает следующие страницы в кэш страниц ещё до того, как их запросит Postgres. Если страницы поступают в произвольном порядке, ядро не может предугадать, какая будет следующей, поэтому упреждающее чтение ничего не делает и каждая операция чтения ждёт NVMe.

Но ниже есть и ещё один слой. В продакшене диск был не локальным SSD, а подключенным по сети AWS EBS. План, обрушивший наши IOPS, был таким:

Index Scan using idx_account_id on leads
  Filter: ((some_column IS NULL) AND (jsonb_condition))
  Rows Removed by Filter: 39811
  Buffers: shared hit=10871 read=27841 dirtied=3
  I/O Timings: shared/local read=13838.335

27841 операция чтения: их операционная система не может обслужить из кэша. 13838 мс времени на ввод-вывода, примерно по 497 микросекунд на каждую операцию чтения. Если сравнить, затраты на считывание одной и той же страницы на 8 КБ чётко снижаются. Страница в общих буферах стоит одну операцию чтения из памяти. Затраты на страницу из кэша страниц ядра повышается примерно до 4 микросекунд. Холодные считывания с локальных NVMe стоят 78 микросекунд. А в продакшене при получении из EBS по сети — примерно 497 микросекунд. Каждый шаг вниз стоит примерно одного порядка величин: EBS где-то в 6 раз медленнее локального SSD и в 124 раз медленнее, чем кэш страниц (по крайней мере, в примере).


Ещё один облачный диск

Также мне хотелось проверить, проблема ли это EBS или она связана с сетью. В процессе написания этого поста я вспомнил, что пару месяцев назад выбирал сервер для кластера Postgres — Hetzner CCX33: 8 vCPU, 32 ГБ ОЗУ, 240 ГБ NVMe; на нём работал fio. Стоит сравнить его показатели с показателями EB.

Сначала произвольное чтение по 8 КБ с бОльшим давлением, чем при домашнем тесте (--iodepth=32 --numjobs=4 и тот же --direct=1):

read: IOPS=325k
clat: p1=668ns, p50=123us, p99=1.3ms

325 тысяч IOPS и p50 в 123 микросекунд; теоретически, быстрее, чем домашный NVMe. Однако p1 равен 668 наносекундам, а NVMe дома никогда не отвечал быстрее, чем за 61 микросекунду: это время, которое требуется самой флэш-памяти. Скорости меньше микросекунды — это ОЗУ. --direct=1 минует кэш страниц VM, но на облачном сервере между VM и накопителем есть гипервизор, у которого имеется собственный кэш. Часть из этих операций чтения не выходит за пределы ОЗУ хоста, поэтому тест измеряет и кэши, и диск.

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

root@ubuntu-32gb-hil-1:~$ fio --name=randwrite-fsync --rw=randwrite --bs=8k --fsync=1 \
              --iodepth=32 --numjobs=4 --runtime=120 --ioengine=libaio \
              --direct=1 --group_reporting --time_based --ramp_time=10 \
              --size=20G --filename=/mnt/bench/test.bin
...
write: IOPS=10.2k
clat: p50=12.1ms, p99=16.2ms

12 миллисекунд на то, чтобы 8 КБ записались на накопитель. Чтение — это не запись, так что сравнение с числами выше неточное, но смысл здесь в масштабе. Домашний NVMe отвечает за десятки микросекунд. Чтение в EBS занимает примерно 500. Здесь запись на накопитель занимает 12 тысяч. Что бы ни находилось под этой VM, оно ведёт себя как том EBS, а не как локальный NVMe.


Тонкости

Теперь я почти доволен, но полного ответа на вопрос нет. Показатель EBS в сравнении с показателем моего локального NVMe — это лишь наиболее точная догадка. Значения взяты из инцидента в продакшене, поэтому среда была неодинаковой. На них влияет многое: ввод-вывод, очистка общих буферов, другие запросы, соединения и так далее.

Показатели Hetzner — это тоже аппроксимация. Я использовал для них разные ioengine и глубины. При бенчмаркинге машины Hetzner я пытался проверить, действительно ли это локальный диск, поэтому протестировал общую дисковую производительность при помощи libaio и с iodepth=32. Для сравнения: при бенчмаркинге локального NVMe использовался psync — он синхронный и блокирующий, тут всё просто.

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

Комментарии (0)