Redis держит все данные в памяти и выполняет команды по одной в одном потоке поверх цикла событий (epoll в Linux), поэтому команда занимает микросекунды, без блокировок. Redis 6 добавил необязательные I/O-потоки: они параллельно пишут ответы (и читают запросы, если включить), а команды по-прежнему выполняет главный поток.
Память, а не диск: GET означает поиск в хеш-таблице и пару операций с указателями, поэтому обычно предел задаёт сетевой round trip, а не CPU. Одно ядро обслуживает больше 100 000 простых команд в секунду.
Один поток как преимущество: нет блокировок и борьбы за них, каждая команда атомарна бесплатно, а один цикл событий обслуживает тысячи клиентских сокетов.
Компактные кодировки: маленькие хеши и множества хранятся плоскими массивами listpack или intset, которые помещаются в кэш процессора.
I/O-потоки (io-threads 4, по умолчанию выключены) снимают с главного потока работу с сокетами: в Redis 6 и 7 только запись, а чтение лишь при io-threads-do-reads yes; в Redis 8 их переделали, и они читают, разбирают и пишут. Это помогает при большом числе клиентов или больших ответах. Порядок выполнения и атомарность не меняются.
Цена: одна медленная команда (KEYS *, огромный DEL, долгий Lua-скрипт) тормозит всех клиентов. Чтобы задействовать больше ядер, запускают больше инстансов, именно это делает Redis Cluster.
GoMiddle
Redis и кэширование
Типы данных, однопоточное исполнение, персистентность, вытеснение и TTL, pipelining и Lua, cache penetration и stampede, Sentinel и Cluster
Закрыто вопросов
0 / 17
- 0Повторить
Вопросы
Строки хранят закэшированные значения и счётчики, хеши хранят объекты, списки служат очередями и лентами последних элементов, множества держат уникальные элементы, сортированные множества ранжируют элементы по score, а стримы представляют собой лог событий, который читают группы консьюмеров. Подходящий тип позволяет Redis выполнить работу атомарно на сервере.
String (до 512 МБ): закэшированный JSON, счётчики через INCR, SET NX EX для блокировок и ключей идемпотентности.
Hash: сессия или корзина, где меняют одно поле (HINCRBY), а не переписывают весь JSON. С Redis 7.4 у поля может быть свой TTL (HEXPIRE).
List: O(1) с обоих концов: простая очередь (LPUSH, BRPOP) или последние N элементов (LPUSH плюс LTRIM). Доступ по индексу стоит O(N).
Set: теги, кто лайкнул пост; SINTER находит общие элементы прямо на сервере.
Sorted set: обновление по score за O(log N): лидерборды, rate limit со скользящим окном, отложенные задачи со временем запуска в качестве score.
Stream: лог только на добавление с группами консьюмеров и подтверждениями, для событий и задач, которые должны пережить падение консьюмера.
Специальные типы: HyperLogLog считает уникальных посетителей в 12 КБ с погрешностью около 0,81%, ещё есть битмапы и геоиндексы. В Redis 8 также входят JSON и фильтры Блума.
Memcached представляет собой многопоточный кэш простых значений в памяти, без персистентности и репликации. Redis добавляет структуры данных, персистентность, репликацию, скрипты и кластеризацию, но выполняет команды в одном потоке. Memcached по-прежнему подходит для большого кэша блобов, который должен использовать все ядра одной машины.
Потоки: Memcached использует все ядра в одном процессе; Redis использует одно ядро на инстанс и масштабируется горизонтально: больше инстансов или Cluster.
Модель данных: у Memcached есть get, set, инкремент и cas над значением целиком (по умолчанию до 1 МБ); Redis меняет одно поле хеша или один элемент сортированного множества на месте.
Сохранность данных: после рестарта Memcached пуст, реплик нет. Клиенты распределяют ключи консистентным хешированием, поэтому упавший узел означает лишь промахи. У Redis есть RDB, AOF, реплики, Sentinel и Cluster.
На практике: новые сервисы на Go обычно берут Redis (или Valkey, его форк под лицензией BSD), потому что одна система закрывает кэш, блокировки, rate limit и очереди. Memcached остаётся там, где он уже работает в большом масштабе.
Cache-aside: читаем Redis; при промахе читаем Postgres и кладём результат с TTL; при записи обновляем Postgres и удаляем ключ. Cache stampede (лавина промахов, тысячи запросов разом в базу) гасят через singleflight и случайный разброс TTL.
При записи удаляйте ключ, а не перезаписывайте: два писателя, каждый со своим SET, могут завершиться в неправильном порядке и оставить в кэше старое значение. Небольшая гонка остаётся (читатель может положить устаревшее значение прямо перед удалением), поэтому TTL ограничивает, насколько запись может устареть.
Write-through: запись в кэш вместе с базой держит чтения горячими, но привязывает каждую запись к Redis, и TTL всё равно нужен.
**singleflight.Group** (golang.org/x/sync) выполняет одну загрузку на ключ в процессе, а остальные вызывающие ждут её результата. Отвяжите загрузку от контекста первого вызвавшего, иначе его отмена уронит всех.
TTL с разбросом не даёт ключам, записанным одновременно, одновременно истечь. Для самых горячих ключей обновляйте значение заранее в фоне или берите короткую блокировку SET NX, чтобы перезагружал один экземпляр сервиса (под) на весь кластер.
Redis необязателен: при ошибке Redis залогируйте её и читайте базу; падение кэша не должно превращаться в падение сервиса.
go-redis сообщает об отсутствующем ключе ошибкой redis.Nil (её текст redis: nil), а не пустыми байтами, поэтому каждый промах уходит в ветку err != nil и возвращается как сбой. Ветка b == nil, которая должна загрузить товар из Postgres, не выполняется никогда.
Почему ошибка: GET по отсутствующему ключу получает null-ответ, и go-redis превращает его в sentinel-ошибку redis.Nil, чтобы промах отличался от ключа с пустой строкой (тот возвращает пустые байты и nil-ошибку).
Другие чтения ведут себя так же: HGet отсутствующего поля, LPop пустого списка, ZScore отсутствующего элемента, блокирующее чтение с истёкшим таймаутом. Исключение составляет MGet: отсутствующий ключ даёт элемент nil в результате.
Исправление: проверять errors.Is(err, redis.Nil) до общей проверки err != nil и считать это промахом. Любую другую ошибку (таймаут, отказ в соединении) логировать как предупреждение и читать базу, чтобы проблема с Redis стоила задержки, а не ответа 500.
Как это прошло ревью: в тестах кэш был моком и на промах возвращал (nil, nil), чего настоящий клиент никогда не делает. Фейк с реальной семантикой, например miniredis, или Redis в контейнере в тестах ловит такой баг.
Cache penetration означает трафик по ключам, которых не существует: база ничего не находит, в кэш ничего не попадает, и каждый повтор снова идёт в Postgres. Нужно кэшировать сам промах с коротким TTL, а когда id слишком случайные, чтобы закэшированные промахи повторялись, поставить перед кэшем фильтр Блума по существующим id.
Откуда берётся: скрейперы, перебирающие id, клиенты, запрашивающие удалённые записи, атакующие со случайными id. Hit ratio 99% на настоящих товарах это скрывает.
Сначала валидация: отклонять id, которые не могут существовать (не положительное целое, кривой UUID), и ограничивать частоту запросов на клиента.
Негативное кэширование (в коде): маркер «не найдено» с TTL около минуты. Путь создания должен его удалять, иначе новый товар останется невидимым, пока маркер не истечёт.
Фильтр Блума: битовый массив, где каждый id выставляет биты несколькими хешами. Ответ «нет» всегда верен, ответ «есть» ошибается с заданной вероятностью: около 10 бит на id дают 1%, так что 10 миллионов id занимают 12 МБ. Удалять из него нельзя, поэтому каждый id добавляют при создании, а фильтр время от времени перестраивают. BF.EXISTS из RedisBloom (входит в Redis 8) даёт всем подам один общий фильтр, но он отвечает 0 и для ключа фильтра, которого нет, поэтому рестарт, потерявший фильтр, без такой проверки превращает каждый id в 404.
Не путать со stampede: penetration означает промахи по ключам, которых никогда не было; stampede означает всплеск промахов по одному горячему ключу, который существует.
Каждый GET означает полный сетевой round trip, и цикл ждёт каждого ответа, прежде чем отправить следующий запрос: 500 × примерно 0,5 мс дают 250 мс, тогда как сам Redis тратит на команду микросекунды. MGET и пайплайн отправляют всю пачку за один round trip.
Замер: на локальном Redis в Docker с round trip 0,25 мс цикл занял 128 мс, а вариант с MGET 0,4 мс. Более быстрое железо под Redis не меняет ни одну из цифр.
**MGET**: одна команда и один ответ в порядке ключей. Отсутствующий ключ даёт элемент nil, а не redis.Nil.
Пайплайн: Pipelined буферизует любые команды, отправляет их разом и читает все ответы; у каждой команды остаются свой результат и своя ошибка. Заодно Redis экономит системные вызовы read и write на каждую команду.
Не транзакция: между командами пайплайна могут выполниться команды других клиентов. TxPipelined оборачивает пачку в MULTI/EXEC, когда она должна примениться целиком.
Ограничивайте пачку: Redis держит все ответы в памяти, пока не отправит их, поэтому 100 000 команд стоит делить на пачки от нескольких сотен до нескольких тысяч.
В Redis Cluster: все ключи одного MGET должны лежать в одном hash slot, а пайплайн ClusterClient сам разбивает по узлам.
По умолчанию go-redis не переносит дедлайн контекста на сокет: при ContextTimeoutEnabled, равном false, чтение ждёт до собственного ReadTimeout клиента, а это секунды. Нужно включить эту опцию, оставить ReadTimeout и WriteTimeout как страховку и перестать обращаться к Redis, пока он сбоит.
**Замер через CLIENT PAUSE**: клиент по умолчанию вернул ответ только после всей двухсекундной паузы, а при паузе в 8 секунд упал на ReadTimeout в 5 секунд, который стоит по умолчанию (в старых релизах v9 было 3 с). С включённой опцией вызов упал через 50 мс с context deadline exceeded.
Подвох: дедлайн, сработавший посреди ответа, оставляет непрочитанные байты на сокете, поэтому go-redis закрывает соединение, и следующий вызов платит за новый dial, TLS и AUTH. При подвисании такая текучка соединений раскручивает сама себя. В документации go-redis советуют фиксированные таймауты сокета и предостерегают от значений меньше секунды в облачных сетях.
Таймауты сокета не убирать: они по-прежнему ограничивают вызовы без дедлайна и фоновые проверки самого клиента. Ожидание свободного соединения из пула учитывает контекст в обоих режимах.
Решить, что значит таймаут: для кэша это промах, который считают в метриках, а дальше база или значение по умолчанию. Circuit breaker избавляет от платы в 50 мс и за новое соединение на каждом запросе, пока Redis лежит.
Двумя способами: лениво, когда команда обращается к ключу с истёкшим TTL, и активно, в фоновом цикле, который примерно десять раз в секунду проверяет выборку ключей с TTL. Ключ, который никто не читает, ждёт этой выборки, поэтому истёкшие ключи какое-то время занимают память.
Ленивое удаление: каждая команда сначала проверяет срок жизни ключа; истёкший ключ удаляется тут же, поэтому клиент никогда не прочитает истёкшее значение.
Активное удаление: hz раз в секунду (по умолчанию 10) Redis берёт выборку из 20 ключей с TTL, удаляет истёкшие и продолжает, пока истёкших в выборке больше 10%, тратя не больше примерно четверти процессорного времени. active-expire-effort снижает этот порог и ускоряет очистку ценой большего CPU.
Почему память не освобождается: когда миллионы ключей истекают быстрее, чем их убирает выборка, они продолжают учитываться в maxmemory и могут довести инстанс до вытеснения, пока место занимают мёртвые ключи.
Реплики не удаляют ключи по TTL сами: мастер присылает DEL, и все копии совпадают, а пока его нет, реплика уже отвечает так, будто ключа не существует.
Ловушки с TTL (в коде): SET без EX или KEEPTTL сбрасывает старый TTL, а INCR, HSET и LPUSH его сохраняют; TTL возвращает -1, если TTL нет, и -2, если нет ключа. Ключи, записанные вместе с одинаковым TTL, вместе и истекают, поэтому к TTL добавляют джиттер.
С политикой по умолчанию, noeviction, запись начинает падать с ошибкой OOM command not allowed when used memory > 'maxmemory', а чтение продолжает работать. Кэшу нужна allkeys-lru или allkeys-lfu, если стабильный набор горячих ключей должен пережить разовое чтение холодных.
Задайте лимит: maxmemory 0 по умолчанию означает отсутствие лимита, и Redis растёт, пока его не остановит OOM killer. Ставьте лимит ниже памяти контейнера, с запасом на fork при сохранении на диск и на клиентские буферы.
**Политики allkeys-* выбирают среди всех ключей, политики volatile-*** только среди ключей с TTL, а если таких нет, ведут себя как noeviction. volatile-ttl удаляет ключи, которым осталось жить меньше всего.
Приближённо: вместо точного LRU-списка Redis берёт выборку из maxmemory-samples ключей (по умолчанию 5) и вытесняет лучшего кандидата; 10 ключей в выборке дают результат ближе к точному LRU ценой CPU.
LRU или LFU: LRU смотрит только на последнее обращение, поэтому батч-джоба, один раз прочитавшая миллион холодных ключей, вытесняет горячий набор. LFU хранит на ключ маленький затухающий счётчик частоты, и часто используемые ключи остаются.
**Когда прав noeviction: Redis как хранилище, очередь или сервис блокировок, где отклонённую запись видно, а тихо вытесненную задачу нет. Такие данные стоит держать на отдельном инстансе, а не вместе с кэшем.
Следите**: рост evicted_keys в INFO stats при падающем hit ratio означает, что кэш слишком мал.
RDB записывает снапшот на момент времени из дочернего процесса после fork, поэтому при падении теряется всё, что изменилось после последнего снапшота, часто за минуты. AOF логирует каждую запись: при appendfsync everysec по умолчанию падение стоит примерно секунды данных, при always почти ничего, но с большой ценой для задержки записи.
RDB: BGSAVE или правило save делает fork, и дочерний процесс пишет файл, пока родитель обслуживает клиентов. Copy-on-write дублирует каждую страницу, изменённую за это время, поэтому инстансу с интенсивной записью может понадобиться почти вдвое больше памяти, а сам fork останавливает команды (latest_fork_usec). Файл компактный и быстро грузится: подходит для бэкапов и новых реплик.
fsync в AOF: always синхронизирует до ответа клиенту, everysec раз в секунду, no оставляет это ОС, которая в Linux обычно сбрасывает данные в пределах примерно 30 с.
Переписывание AOF: лог сжимается через fork. С Redis 7 AOF представляет собой каталог из базового файла и инкрементов, и при aof-use-rdb-preamble yes базовый файл пишется в формате RDB, поэтому грузится быстро. Если включено и то и другое, Redis восстанавливается из AOF как из более полного.
Для кэша: настройки по умолчанию (RDB включён, AOF выключен) или вообще ничего; тогда после рестарта кэш холодный, и на всплеск промахов нужно рассчитывать заранее.
Для данных, которые нужно сохранить: AOF с everysec, RDB-файлы для бэкапов и реплики. Репликация асинхронная, поэтому failover может потерять больше, чем обещает настройка fsync.
Атомарна каждая отдельная команда, потому что один поток выполняет команды по одной. MULTI/EXEC выполняет накопленную пачку без вклинивания чужих команд, но не может ветвиться по прочитанному, WATCH добавляет оптимистическую проверку, а Lua-скрипт читает, принимает решение и пишет за один шаг. Ни один из них не откатывается.
**MULTI/EXEC**: команды ставятся в очередь и выполняются вместе на EXEC, а ответы приходят только после него, поэтому прочитать значение внутри транзакции и решить, что записать, нельзя.
**WATCH**: наблюдать за ключами, прочитать их, затем MULTI/EXEC. Если другой клиент изменил наблюдаемый ключ, EXEC ничего не выполняет, и нужен повтор (redis.TxFailedErr в go-redis); на горячем ключе большинство попыток срывается.
Lua (EVAL или Functions начиная с Redis 7): скрипт выполняется до конца без других команд между шагами, поэтому «прочитать, проверить, записать» не требует повторов и стоит один round trip. Все ключи передаются в KEYS; в Cluster они должны быть в одном слоте, а долгий скрипт блокирует всех клиентов.
Отката нет: ошибка, найденная при постановке в очередь (неверное число аргументов, неизвестная команда), отменяет транзакцию с EXECABORT, но ошибка во время выполнения, например WRONGTYPE на одном ключе, ломает только эту команду, а остальные остаются применёнными. Lua-скрипт, упавший на середине, тоже сохраняет сделанные до этого записи. Redis считает такие ошибки ошибками программиста и не платит за журнал отката.
Классический баг: INCR, а потом EXPIRE двумя отдельными вызовами. Падение между ними оставляет счётчик без TTL навсегда; отправляйте обе команды через TxPipelined или скриптом.
Redis выполняет команды в одном потоке, поэтому команда с работой O(N) блокирует всех остальных клиентов, пока не закончит: KEYS обходит всё пространство ключей, а DEL огромного множества тут же освобождает миллионы элементов. SCAN обходит ключи маленькими шагами по курсору, а UNLINK освобождает память в фоновом потоке.
Замер на ноутбуке (Redis 8): KEYS по 10 миллионам ключей заблокировал Redis примерно на 0,5 с, DEL множества из двух миллионов элементов примерно на 90 мс, а UNLINK того же множества вернулся сразу. На более медленных продовых CPU хуже, и всё это время у каждого клиента тикает таймаут.
**SCAN** возвращает страницу ключей и следующий курсор, а обход заканчивается, когда курсор снова становится 0. Ключ, существовавший весь обход, вернётся хотя бы раз и может прийти дважды (тест, удалявший 5 000 ключей по ходу обхода, получил 5 003 имени). MATCH фильтрует уже полученную страницу, поэтому страница бывает пустой, а COUNT служит лишь подсказкой. HSCAN, SSCAN и ZSCAN обходят один большой ключ изнутри.
**UNLINK** сразу убирает ключ, а большое значение освобождает в фоне; lazyfree-lazy-user-del yes заставляет так же вести себя DEL.
В Cluster сканируют каждый мастер (ForEachMaster в go-redis): каждый узел хранит только свои слоты.
Найти и запретить: SLOWLOG GET показывает медленные команды, redis-cli --bigkeys находит большие ключи, а ACL с -@dangerous не даёт пользователю приложения вызывать KEYS и FLUSHALL.
Список, из которого читают через BRPOP, теряет сообщение, которое держал упавший консьюмер; Pub/Sub ничего не хранит и теряет сообщения для всех, кто не подключён; а стрим с группой консьюмеров держит каждую запись в ожидающих, пока её не подтвердят, поэтому работу упавшего консьюмера может забрать другой.
Список: LPUSH плюс BRPOP дают простейшую очередь. Для надёжной BLMOVE queue processing RIGHT LEFT (Redis 6.2) атомарно переносит каждый элемент в список обработки, LREM удаляет его после успеха, а отдельный процесс возвращает зависшие элементы обратно. Ни перечитывания, ни раздачи нескольким получателям.
Pub/Sub: доставляет тем, кто подписан в этот момент. Переподключающийся подписчик пропускает сообщения, а медленного отключают по client-output-buffer-limit pubsub 32mb 8mb 60. Подходит для сигналов инвалидации и живых уведомлений, но не для задач.
Стрим: XADD добавляет запись; XREADGROUP выдаёт каждую запись одному консьюмеру группы и держит её в списке ожидающих группы до XACK. XAUTOCLAIM передаёт записи, простоявшие дольше порога, живому консьюмеру, а счётчик доставок в XPENDING выдаёт ядовитое сообщение (poison message). Несколько групп читают весь стрим каждая; длину ограничивают через MAXLEN ~ в XADD.
Общее у всех трёх: данные лежат в RAM, а репликация асинхронная, поэтому failover может потерять самые свежие записи, и доставка в лучшем случае at least once, так что обработчики должны быть идемпотентными. Для большого объёма, долгого хранения или перечитывания с заданного времени лучше подходит Kafka.
Sentinel даёт одному мастеру автоматический failover: сентинелы сходятся во мнении, что он недоступен, и повышают реплику, но все данные по-прежнему живут на одном мастере. Cluster ещё и шардирует: ключ попадает в один из 16384 слотов по CRC16(key) mod 16384, каждый мастер владеет диапазоном слотов, а команда, затрагивающая несколько слотов, отклоняется.
Sentinel
Три или больше сентинелов на разных хостах. Кворум соглашается, что мастер недоступен, а сентинел-лидер повышает лучшую реплику (по приоритету, потом по replication offset) и переключает на неё остальные.
Клиенты спрашивают у сентинелов текущего мастера (NewFailoverClient с MasterName), поэтому при failover ничего не перенастраивают. Запись и память по-прежнему ограничены одной машиной.
Cluster
У каждого мастера свои реплики, узлы обмениваются состоянием через gossip, и большинство мастеров голосует за повышение реплики упавшего мастера. Сентинелы не нужны.
Клиент, обратившийся не к тому узлу, получает MOVED 3999 10.0.0.5:6379, а во время миграции слота ASK; ClusterClient кэширует карту слотов и следует обоим ответам. Существует только база 0.
CROSSSLOT
Команда с несколькими ключами (MGET, SUNIONSTORE, RENAME), MULTI/EXEC или скрипт должны работать с ключами одного слота, иначе падают с CROSSSLOT Keys in request don't hash to the same slot. Некоторые клиенты сами разбивают простой MGET по слотам, но транзакцию или скрипт не разобьёт никто.
Hash tags решают это на уровне дизайна: хешируется только часть внутри {}, поэтому {user:42}:cart и {user:42}:cart-version попадают в один слот. Группируют ключи одного пользователя или одного заказа, но не всё под одним тегом, иначе этот слот станет горячим шардом.
Почему 16384: битовая карта слотов передаётся в каждом heartbeat, а 16384 бита занимают 2 КБ, и этого хватает на примерно 1 000 узлов, на которые рассчитан Cluster.
Мастер отвечает раньше, чем запись получит хоть одна реплика, поэтому failover на отстающую реплику теряет то, что до неё не дошло, а изолированный мастер продолжает принимать записи, которые выбрасываются при его возвращении. min-replicas-to-write ограничивает это окно, а WAIT сужает его; ни то, ни другое не делает Redis консистентным.
Куда пропадают подтверждённые записи
Отставание: репликация отстаёт на миллисекунды, а под нагрузкой или при сетевом сбое на секунды. Sentinel и Cluster предпочитают реплику с наибольшим offset, но и у неё может не быть последних записей.
Split brain: старый мастер, отрезанный вместе с несколькими клиентами, продолжает отвечать OK, пока большинство повышает другой узел. Когда связь восстанавливается, он становится репликой, пересинхронизируется, и его записи за это окно исчезают.
Пустой мастер: мастер с выключенной персистентностью, быстро перезапущенный, возвращается пустым, и реплики добросовестно синхронизируют пустой датасет. Включайте персистентность на мастере или не давайте ему перезапускаться автоматически.
Что меняют настройки
**min-replicas-to-write 1 с min-replicas-max-lag 10**: мастер отклоняет запись ошибкой NOREPLICAS Not enough good replicas to write., если за 10 с ни одна реплика ничего не подтвердила, поэтому изолированный мастер перестаёт принимать запись примерно через это время. Цена: пока реплики лежат, запись падает.
**WAIT 1 100** блокирует клиента, пока одна реплика не подтвердит его предыдущие записи или не пройдёт 100 мс, и возвращает число подтвердивших. Таймаут не отменяет запись, а failover всё равно может выбрать реплику, которая её не получила. WAITAOF (Redis 7.2) дополнительно ждёт записи в AOF на диске.
Вывод для дизайна
Всё, что нельзя потерять, должно жить в Postgres. То, что живёт только в Redis (блокировки, счётчики, лимиты), должно переживать потерянную запись: fencing-токены для блокировок, собственная проверка базы для остатков на складе.
Postgres рассчитывали на несколько процентов чтений, которые промахиваются мимо кэша, поэтому потеря кэша умножает его нагрузку во много раз. Нужны кэш, который переключается на реплику, а не исчезает, быстрый отказ при ошибках Redis и лимит на число промахов, которые каждый под отправляет в Postgres, а избыток отбрасывается или обслуживается устаревшими данными.
Держать кэш живым
Реплики с Sentinel или Cluster превращают потерю узла в несколько секунд failover; RDB на узлах кэша или джоба прогрева не дают перезапущенному узлу вернуться пустым.
Падать быстро
Короткий дедлайн, который доходит до сокета, и circuit breaker вокруг Redis: пока он разомкнут, запросы обходят Redis, а не платят таймаут каждый. Измерьте, сколько ваш клиент тратит на отказ: go-redis v9.22 с настройками по умолчанию потратил 1,7 с на один вызов к порту, который отказывал в соединении, повторяя dial.
Защитить базу
Лимит на число одновременных загрузок в каждом поде (в коде): то, что тянет Postgres, делённое на число подов. Избыток он отбрасывает с 503 и Retry-After, а не ставит в очередь. SetMaxOpenConns по-прежнему важен, но он ставит вызывающих в очередь, а не отказывает им.
singleflight по ключу, чтобы одинаковые промахи схлопывались в один запрос на под.
Деградировать осознанно: кэш самых горячих ключей в памяти процесса, устаревшие значения при ошибке и некритичные функции, отключаемые флагом.
До и после
Знать, сколько выдерживает база при hit ratio 0%, и убить Redis на стейджинге под нагрузкой, похожей на продовую, чтобы увидеть, что происходит на самом деле.
После восстановления возвращать трафик постепенно или заранее прогреть самые горячие ключи, с джиттером в TTL, чтобы они снова не истекли одновременно.