
Какое-то время назад у меня возник инцидент с 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 — он синхронный и блокирующий, тут всё просто.
Однако, несмотря на всё это, я доволен, потому что из всего этого удалось извлечь общий принцип. Мы провели глубокий анализ и получили нечто полезное, пусть и детали довольно неточные.