Оценил по Сюю 500 запросов в секунду — замерил на ноутбуке: вот где сломалось
- среда, 28 октября 2026 г. в 00:00:15
Есть глава, которую читали все, кто готовился к секции по проектированию систем: «Приблизительные оценки» у Алекса Сюя. DAU делим на 86 400, умножаем на два для пика, прикидываем объём за год — и получаем «1 160 записей в секунду, 11 600 чтений, 365 терабайт». Я тоже умел это считать. А потом я задал себе простой вопрос: «И Postgres это выдержит?» — и понял, что не знаю. Я знаю, как посчитать нагрузку. Я не знаю, что на самом деле происходит с базой, когда эта нагрузка приходит.
Решил не гадать, а замерить. Взял систему из главы 8 той же книги — сокращатель ссылок, — собрал её в Docker на ноутбуке, посчитал по книге, что она должна выдержать, и запустил нагрузку. Ниже — расчёт, стенд, цифры и то, что я из них понял. Спойлер: оценка разошлась с замером не на проценты, а на три порядка, и это самое полезное, что случилось со мной за всё время, что я читал эту книгу.
Сокращатель ссылок из главы 8. Исходные допущения Сюя: 100 млн новых ссылок в день, соотношение чтений к записям 10:1, срок хранения 10 лет, запись около 100 байт.
Записи: 100 000 000 / 86 400 ≈ 1 160 в секунду.
Чтения: ×10 ≈ 11 600 в секунду.
Записей за 10 лет: 100 млн × 365 × 10 = 365 млрд.
Объём: 365 млрд × 100 байт = 36,5 ТБ.
Заметили? В книге в этом месте написано 365 ТБ — десятилетний период учтён дважды. Это не единственная опечатка: в примере с Twitter в главе 2 сказано, что медиа содержат 100% твитов, а в расчёте используется 10%; в главе 13 про автодополнение напечатано деление там, где результат 0,4 ГБ получается умножением. Я это говорю не чтобы придраться к хорошей книге, а потому что это первый урок: цифры в оценке надо перепроверять, даже если они напечатаны.
Но 11 600 чтений в секунду — это масштаб компании, а не ноутбука. Мне нужна была цель, которую можно проверить дома. Я взял 500 запросов в секунду на чтение — это примерно DAU 4 млн при одном переходе на пользователя в день, с пиковым коэффициентом 2. Нормальный средний сервис. И записал ожидание: при таком RPS p95 задержки должна быть около 20 мс — запрос по ключу в базу с миллионом записей, что тут может быть долгого.
Это важный момент, и я на нём настаиваю: ожидание надо записать до запуска. Иначе после замера мозг услужливо скажет «ну я примерно так и думал».
Стенд в Docker Compose — три постоянных контейнера плюс k6, который поднимается только на время прогона:
api — сервис на Go: POST /shorten принимает длинную ссылку и возвращает короткий код (base62 от id), GET /{code} ищет в базе и отдаёт 302. Пул соединений к базе — 10.
postgres — одна таблица urls(id, code, long_url, created_at).
redis — есть, но выключен (CACHE=off). Он понадобится позже.
k6 — генератор нагрузки, сценарий constant-arrival-rate: ровно N запросов в секунду, независимо от того, успевает ли сервер отвечать.
В таблицу залил миллион записей через COPY — 11,7 секунды, 102 МБ на диске. Из них случайно выбрал 20 кодов, по которым k6 бьёт GET-запросами.
Одна деталь, которую я сделал сознательно и о которой скажу сразу, чтобы не было ощущения фокуса: индекса по колонке code в таблице нет. Только первичный ключ по id. Почему — станет понятно из результата.
NAME SERVICE STATUS napkinlab-api-1 api Up 24 minutes (healthy) napkinlab-postgres-1 postgres Up 56 minutes (healthy) napkinlab-redis-1 redis Up 56 minutes (healthy)
Запускаю: 500 запросов в секунду, 60 секунд.
target_rps | фактический RPS | p95, мс | p99, мс | доля ошибок 500 | 24.17 | 22130.61 | 22808.33 | 0.00%
Перечитайте вторую колонку. Я просил 500 запросов в секунду — стенд обработал 24. Остальные не упали с ошибкой, они встали в очередь: p95 — 22 секунды. Не миллисекунды. Секунды. Ожидал 20 мс — получил в 1 100 раз больше.
Повторный прогон через пару часов, когда на ноутбуке стало меньше соседей, дал 72 RPS и p95 7,5 секунды — вот как это печатает make report:
Стадия 1 — оценка против замера (results/read_500rps_20261024T201101.json) target_rps | фактический RPS | p95, мс | p99, мс | доля ошибок -----------+-----------------+------------+------------+------------ 500 | 72.41 | 7477.01 | 7633.63 | 0.00% Ожидание: p95 ≤ 20 мс при 500 RPS — «выдержит: запрос простой, данных всего миллион» Пропущено итераций k6 (не хватило VU): 25162 Оценка vs факт: p95 7477.0 мс против ожидаемых 20 мс (в 374 раз больше); фактический RPS 72.4 ниже целевого 500 Оценка не сошлась. Почему — стадия 2 и 3.
Разброс между прогонами в три раза — это само по себе урок про замеры на ноутбуке. Но порядок величины тот же: просили 500, получили десятки, ждали секунды.
Если бы это была не лаборатория, а прод, то выглядело бы так: пользователи жмут на ссылку, страница крутится двадцать секунд, половина закрывает вкладку. При этом в логах — ноль ошибок. Мониторинг по ошибкам молчит. Это второй урок: «доля ошибок 0%» ничего не говорит о том, жива ли система.
Арифметика простая, и её можно было сделать до запуска, если знать, на что смотреть.
Запрос SELECT long_url FROM urls WHERE code = $1 без индекса по code — это последовательный проход по всей таблице: Postgres читает миллион строк, чтобы вернуть одну.
QUERY PLAN -------------------------------------------------------------------------------------------------------------------- Gather (cost=1000.00..16552.43 rows=1 width=31) (actual time=10.664..43.648 rows=1 loops=1) Workers Planned: 2 Workers Launched: 2 Buffers: shared hit=10344 -> Parallel Seq Scan on urls (cost=0.00..15552.33 rows=1 width=31) (actual time=26.482..35.769 rows=0 loops=3) Filter: (code = '3Gd3'::text) Rows Removed by Filter: 333333 Buffers: shared hit=10344 Planning: Buffers: shared hit=63 Planning Time: 0.450 ms Execution Time: 43.693 ms (12 rows)
Rows Removed by Filter: 333333 в каждом из трёх процессов — это и есть весь миллион, перебранный ради одной строки. В одиночку, на тёплом кэше, такой запрос занимает 44 мс. Но под нагрузкой их десять одновременно (пул соединений — 10), каждый запускает ещё двух параллельных воркеров, и все они делят одни и те же ядра: один запрос растягивается до сотен миллисекунд. Десять соединений по 150–400 мс — это потолок в несколько десятков запросов в секунду. Всё, что приходит сверх, ждёт свободного соединения. При 500 входящих в секунду очередь растёт на сотни запросов каждую секунду, и через минуту p95 — это время ожидания в очереди, а не время запроса.
Я проверил это отдельным прогоном: нагрузка ступенями 10 → 25 → 50 → 100 запросов в секунду, по 20 секунд на ступень. На 10 и 25 стенд ещё отвечает за миллисекунды, на 50 p95 переваливает за 200 мс, на 100 — уже 7 секунд. Предел — около 50 запросов в секунду, и он не зависит от того, бьют ли все запросы в 20 горячих ключей или размазаны по ста тысячам: без индекса каждый запрос одинаково дорогой, горячих ключей для Postgres не существует.
Диагностика это подтверждает: pg_stat_statements показывает один запрос, который съедает всё время; EXPLAIN (ANALYZE, BUFFERS) — Seq Scan по миллиону строк; Postgres загружает все ядра, которые ему дали, а Go-сервис при этом занят на 30% — он просто ждёт. В очереди к пулу в пике 219 запросов.
Теперь главное. Оценка по книге говорила: 500 запросов в секунду — это мало, любая база справится. И это правда — для базы с индексом. Расчёт нагрузки из главы 2 даёт число запросов. Он не даёт и не может дать стоимость одного запроса, потому что она зависит не от DAU, а от схемы, от индексов, от того, помещается ли рабочий набор в память. 500 RPS по 0,1 мс и 500 RPS по 300 мс — это одно число в оценке и две разные вселенные в замере.
Я это знал в теории. Разница между «знал» и «видел 22 секунды в p95 на своём ноутбуке» оказалась больше, чем я ожидал.
Очевидный ответ — добавить индекс, и он правильный. С индексом по code тот же ступенчатый прогон не нашёл предела до 1 000 запросов в секунду — я остановился, потому что ноутбук уже не позволял честно генерировать больше. p95 на 100 RPS — 1 мс вместо 7 секунд. Одна строчка CREATE INDEX — разница в три порядка.
Но я специально не остановился на этом, а проверил правку, которая приходит в голову первой у многих: кэш в Redis. И вот тут интереснее. На профиле с 20 горячими ключами кэш чинит всё так же хорошо, как индекс: предел выше 1 000 RPS, p95 — 1,4 мс. А на профиле со ста тысячами равномерных ключей — не чинит почти ничего: предел около 100 RPS, p95 на этой нагрузке — 2,3 секунды. Кэш попадает редко, каждый промах — тот же Seq Scan по миллиону строк. Индекс чинит оба профиля, кэш — только один, и который из них у вас в проде, оценка по главе 2 не скажет.
Стадия 5 — до правки / индекс / кэш (ступени 10 → 1000 RPS по 20 с; предел — первая ступень, где p95 > 200 мс) Профиль hot показатель | до правки | индекс | кэш --------------------+--------------+--------------+-------------- предел RPS | ~50 | > 1000 | > 1000 p95 @100 RPS, мс | 7252.25 | 0.96 | 1.38 p95 @500 RPS, мс * | 9671.82 | 1.34 | 0.85 ошибки | 0.00% | 0.00% | 0.00% Профиль uniform показатель | до правки | индекс | кэш --------------------+--------------+--------------+-------------- предел RPS | ~50 | > 1000 | ~100 p95 @100 RPS, мс | 5155.77 | 1.5 | 2265.03 p95 @500 RPS, мс * | 7828.10 | 1.28 | 7087.21 ошибки | 0.00% | 0.00% | 0.00% * ступени 500 нет: значение — линейная интерполяция между ступенями 400 и 700.
Оговорка про цифры: это ноутбук, рядом крутились другие контейнеры, между прогонами замеры шумят на 30–50%. Порядок величин от этого не меняется, но если у вас получится 40 RPS вместо 50 — это не ошибка.
Оценка по книге — это число запросов. Что система с ними сделает, оценка не говорит. Это другой навык, и ему учат не книги, а замер.
Записывайте ожидание до запуска. «Примерно так и думал» — это не знание.
Ноль ошибок — не признак здоровья. Смотрите на p95 и на очередь.
Цифры в книгах тоже надо проверять. 36,5 ТБ, а не 365.
Весь стенд — в открытом репозитории: docker compose up, make seed, make stage1 — и через десять минут вы увидите ту же таблицу на своём ноутбуке, со своими цифрами. Там же лист оценки по главе 2, чтобы записать ожидание до запуска. Если захотите пройти остальное руками — найти предел, поставить диагноз по pg_stat_statements, записать предсказание и проверить его замером для индекса, кэша, пула и 301, — это стадии 2–5, они описаны в README репозитория.
https://github.com/kamalov2000/napkinlab
Если у вас есть свои цифры с похожего стенда — особенно если они не сошлись с моими — расскажите в комментариях. Замер на ноутбуке не переносится на прод, и чем больше разных ноутбуков, тем честнее картина.