Как Redis сохраняется на диск, не останавливаясь, и когда этот фокус не срабатывает
Redis хранит рабочий набор данных в оперативной памяти, но время от времени должен положить его копию на диск — иначе перезапуск процесса или падение сервера обнуляет всё, что не успело реплицироваться. Проблема в том, что честная сериализация датасета на диск — это долгая операция ввода-вывода, а Redis по своей природе однопоточный и обслуживает команды строго последовательно. Останавливать сервис на время сохранения гигабайт данных неприемлемо, и Redis эту паузу не делает — вместо неё он использует ровно тот же трюк с fork() и copy-on-write, что лежит в основе быстрого порождения процессов в Linux. В этой статье — как устроен этот механизм внутри и в какой момент он перестаёт быть бесплатным.
Содержание
- Зачем Redis вообще снимает слепок на диск
- Что происходит при вызове BGSAVE
- Почему родитель продолжает отвечать на запросы прямо во время сохранения
- Как наблюдать за процессом снапшота вживую
- Когда фокус не срабатывает: скачок памяти на фоне активной записи
- Как снизить риск скачка памяти во время сохранения
Зачем Redis вообще снимает слепок на диск
У Redis есть два независимых механизма персистентности, и путать их не стоит. RDB (Redis Database) — это бинарный снапшот всего датасета на конкретный момент времени, компактный файл, который быстро загружается при старте. AOF (Append Only File) — журнал команд на запись, который проигрывается заново при восстановении; он точнее по свежести данных, но крупнее по объёму и медленнее при загрузке. Эта статья — про RDB и про операцию BGSAVE, которая его создаёт, потому что именно здесь работает fork и copy-on-write; фоновая перезапись AOF (BGREWRITEAOF) устроена по тому же принципу, но пишет не полный дамп памяти в специальном формате, а компактную последовательность команд для восстановления того же состояния.
Снапшот нужен в нескольких сценариях: периодическое автосохранение по расписанию (save 900 1, save 300 10, save 60 10000 в конфиге — снапшот делается, если прошло 900 секунд и изменился хотя бы 1 ключ, либо 300 секунд и 10 ключей, и так далее), явный вызов перед плановым обслуживанием, подготовка файла для копирования на реплику при полной ресинхронизации, и просто резервная копия для восстановления после сбоя. Команда SAVE делает то же самое синхронно, в основном потоке — и блокирует обслуживание клиентов на всё время записи, поэтому в проде её почти никогда не используют напрямую. Практический интерес представляет именно BGSAVE — асинхронный вариант, который и получает выгоду от fork().
Что происходит при вызове BGSAVE
Когда Redis получает команду BGSAVE (или срабатывает автосохранение по точкам из save), процесс вызывает fork(). Как и с любым fork() в Linux, ядро не копирует физическую память процесса — оно копирует только таблицы отображения виртуальных адресов в физические страницы, а сами страницы остаются общими для родителя и потомка. Если раньше вы не разбирались, как это работает на уровне ядра, механизм подробно разобран в статье про fork и copy-on-write — здесь он используется практически без изменений, только Redis — один из самых наглядных практических примеров, для чего эта оптимизация вообще нужна.
Дочерний процесс, получившийся в результате форка, наследует полное, но замороженное на момент форка представление памяти родителя: весь датасет, все структуры данных, всё как было ровно в момент вызова fork(). Ребёнок дальше делает одну вещь — последовательно проходит по всем ключам и сериализует их в формат RDB, записывая результат во временный файл на диске, который по завершении атомарно переименовывается в целевой (обычно dump.rdb). После окончания записи дочерний процесс завершается, а родитель фиксирует в логах и в INFO persistence результат — успех или ошибку.
Ключевое свойство этой схемы: дочерний процесс не координируется с родителем во время работы, не блокирует его и не ждёт от него никаких сигналов, кроме исходного момента форка. Вся тяжёлая, потенциально медленная (диск — это всегда на порядки медленнее RAM) работа полностью изолирована в отдельном процессе.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверПочему родитель продолжает отвечать на запросы прямо во время сохранения
Вот здесь и начинается самое интересное. Пока дочерний процесс не спеша пишет снапшот на диск, родительский процесс Redis не стоит и не ждёт — он продолжает штатно принимать соединения и выполнять команды, включая SET, DEL, HSET и любые другие операции записи. С точки зрения клиента сервис ведёт себя так, будто никакого BGSAVE не происходит вовсе.
Это работает благодаря тому же copy-on-write, что описан выше, но здесь важно понимать роли. И родитель, и потомок изначально смотрят на одни и те же физические страницы памяти. Пока обе стороны только читают — потомок читает данные, чтобы сериализовать их в RDB, — разделение никак не проявляется. Но как только родитель выполняет запись, которая меняет страницу памяти, всё ещё используемую (то есть ещё прочитанную или предстоящую к прочтению) дочерним процессом, ядро перехватывает эту запись через page fault и материализует для родителя отдельную физическую копию именно этой страницы — 4 КБ, а не весь датасет. Родитель продолжает писать уже в свою приватную копию, а дочерний процесс как ни в чём не бывало продолжает видеть исходную, неизменную страницу — ту же самую, что была на момент форка.
Отсюда и главное гарантируемое свойство: снапшот, который в итоге пишет дочерний процесс, консистентен на момент форка, даже если родитель тысячи раз поменял данные за то время, пока шла запись на диск. Дочерний процесс физически не может увидеть эти изменения — у него просто нет пути к новым, «разошедшимся» страницам родителя, он продолжает держаться за старые. Это и есть тот самый фокус: сохранить целостный слепок состояния, при этом не замораживая обслуживание запросов ни на миллисекунду дольше, чем занимает сам вызов fork().
Стоит отдельно отметить: сам момент fork() — не абсолютно бесплатная операция. Она пропорциональна не объёму данных, а числу и сложности отображений памяти процесса (детали — в статье про copy-on-write), но при очень больших и фрагментированных адресных пространствах видимая задержка на самом форке всё же может ощущаться как короткий всплеск latency у клиентов — обычно на уровне единиц или десятков миллисекунд, но точная цифра сильно зависит от конкретной машины и профиля памяти, и её стоит смотреть в своей же метрике, а не ожидать по чужим цифрам.
Как наблюдать за процессом снапшота вживую
Redis отдаёт достаточно телеметрии, чтобы не гадать, что происходит с сохранением прямо сейчас:
redis-cli INFO persistence
В выводе стоит смотреть на несколько полей:
rdb_bgsave_in_progress— 1, если фоновое сохранение сейчас выполняется, 0 в остальное время;rdb_last_bgsave_status—okилиerrпо итогам последнего запуска;rdb_last_bgsave_time_sec— сколько заняло последнее завершённое сохранение;rdb_changes_since_last_save— сколько операций записи накопилось с последнего успешного снапшота (это именно то, что сравнивается с порогами изsave);latest_fork_usec— сколько микросекунд занял последний вызовfork(), полезно как индикатор, не начал ли форк заметно тормозить со временем.
В логах Redis сам сообщает о старте и завершении фонового сохранения текстом вида Background saving started by pid <N> и Background saving terminated with success. Если процесс упал во время записи — stop-writes-on-bgsave-error (по умолчанию включён) переводит сервер в режим отказа от записи до устранения проблемы, чтобы не накапливать данные, которые в случае реального сбоя всё равно негде будет восстановить с диска.
Разница в потреблении памяти между родителем и потомком, а также рост доли «разошедшихся» через COW страниц, видна на уровне ОС через /proc/<PID>/smaps_rollup — поле Private_Dirty у родительского процесса как раз показывает, сколько страниц уже скопировано из-за записи поверх общих с ребёнком.
Когда фокус не срабатывает: скачок памяти на фоне активной записи
Вся схема опирается на предположение, что доля данных, которые родитель успеет изменить за время работы дочернего процесса, невелика по сравнению с общим объёмом датасета. Пока это так, COW честно экономит память: копируются только реально изменившиеся страницы, а не весь снимок целиком.
Проблема начинается, когда совпадают два условия одновременно: датасет большой, а темп записи высокий именно в те секунды или минуты, пока идёт сохранение. Дочерний процесс пишет на диск последовательно и не мгновенно — чем больше данных и чем медленнее диск, тем дольше окно, в которое родитель успевает вносить изменения. Каждая запись в ещё не «отпущенную» дочерним процессом страницу порождает отдельную приватную копию у родителя. Если запись идёт широко (затрагивает много разных ключей по всему датасету, а не повторно одни и те же), а не узко, число разошедшихся страниц растёт почти пропорционально числу уникальных изменённых ключей — и в предельном случае, когда за время сохранения переписывается заметная доля всего датасета, суммарное потребление памяти двух процессов приближается к удвоению того объёма, что было общим на момент форка. Это не гипотетический край — это прямое следствие механики COW, разобранной выше: сохранённого «бесплатно» больше нет ровно там, где данные разошлись.
Здесь и кроется ловушка: этот рост памяти происходит не в момент вызова BGSAVE и не сразу заметен — он накапливается постепенно, по ходу записи снапшота, и совершенно не виден по тому, как быстро отработал сам fork(). Если в этот момент суммарная память процесса (родитель плюс дочерний процесс плюс уже скопированные COW-страницы) подходит близко к лимиту maxmemory, к лимиту cgroup контейнера или просто к физическому объёму RAM на сервере — сервер рискует упереться в границу именно во время сохранения, а не в спокойном режиме. Важный нюанс: maxmemory в Redis отслеживает логическое потребление, которое видит собственный аллокатор Redis, а не физический прирост RSS из-за COW-копий на уровне ядра — то есть штатный механизм отбраковки ключей по maxmemory-policy не «видит» этот рост напрямую и не срабатывает как защита от него. Реагирует уже операционная система: если совокупная память процесса упирается в системный лимит, за дело берётся OOM killer, и жертвой вполне может стать сам процесс Redis — родитель или ребёнок, в зависимости от того, чей oom_score_adj выше на момент решения.
Отдельно стоит настройка vm.overcommit_memory на уровне ядра Linux. Redis прямо рекомендует выставлять её в 1, потому что при значении по умолчанию (эвристический режим) ядро может отказать в fork() для большого процесса, посчитав, что системе не хватит памяти на «честную» копию — хотя реальный COW-форк почти ничего не стоит в момент вызова. Подробнее о том, как overcommit устроен и почему это не то же самое, что реальное резервирование памяти, — в статье про overcommit памяти в Linux.
Как снизить риск скачка памяти во время сохранения
Полностью убрать возможность COW-раздувания нельзя — это неотъемлемое свойство схемы форка. Но масштаб риска можно контролировать:
- Держать запас памяти сверх текущего
used_memory. Если сервер держит датасет впритык к физическому объёму RAM или к лимиту cgroup, любое фоновое сохранение при активной записи — риск. Запас нужен именно на случай пикового COW-роста, а не только на нормальную работу. - Настроить
vm.overcommit_memory=1, чтобы форк для большого процесса гарантированно не отклонялся ядром на ровном месте. - Разносить тяжёлую запись и плановое сохранение по времени, если нагрузка предсказуема — например, не планировать ручной
BGSAVEили агрессивную миграцию данных на пик записи. - Использовать реплики для резервного копирования, где это возможно: RDB-снапшот можно снимать с read-реплики, которая не принимает прямую запись от приложения (хотя она всё равно применяет поток репликации, так что полностью нагрузку не убирает, но обычно снижает).
- Следить за
rdb_last_bgsave_time_secи трендом этого значения. Рост времени сохранения при том же объёме данных — сигнал, что диск или подсистема ввода-вывода стали узким местом, а значит окно риска расширяется. - Проверить
stop-writes-on-bgsave-error— эта опция не про память напрямую, но про то, чтобы сбой сохранения не прошёл незамеченным, пока проблема с диском или памятью не решена. - Не игнорировать Transparent Huge Pages. Если THP включены на хосте, единица COW становится не 4 КБ, а обычно 2 МБ — при широкой записи после форка это делает скачок памяти заметно резче, чем с обычными страницами. Официальная рекомендация Redis для продакшена — отключать THP на хосте, где крутится Redis.
Ниже — сводная таблица, что стоит проверить перед тем, как полагаться на автосохранение в проде:
| Параметр | Зачем проверять | Где смотреть |
|---|---|---|
vm.overcommit_memory | Форк не должен отклоняться ядром | sysctl vm.overcommit_memory |
| Transparent Huge Pages | Единица COW не должна быть 2 МБ вместо 4 КБ | cat /sys/kernel/mm/transparent_hugepage/enabled |
Запас памяти над used_memory | Пространство для COW-роста без упора в лимит | redis-cli INFO memory |
maxmemory-policy | Поведение при упоре в лимит уже после COW-роста | redis-cli CONFIG GET maxmemory-policy |
Точки save | Не совпадают ли автосохранения с пиками записи | redis.conf, redis-cli CONFIG GET save |
rdb_last_bgsave_time_sec | Тренд длительности сохранения во времени | redis-cli INFO persistence |
Если после разбора причин потребление памяти Redis всё равно кажется завышенным не только в моменты сохранения, а системно — это уже отдельная тема, разобранная в статье про высокое потребление памяти Redis.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверНужны сами нейросети для контента?
Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.
Частые вопросы
Блокирует ли BGSAVE клиентов Redis хоть на какое-то время?
Напрямую — нет, обслуживание команд продолжается всё время, пока пишется снапшот. Единственная пауза — сам вызов fork(), обычно очень короткий, но на больших и фрагментированных адресных пространствах может дать заметный, хоть и небольшой, всплеск задержки.
Чем RDB через BGSAVE отличается от AOF с точки зрения этого механизма?
Оба используют один и тот же fork с COW для фоновой записи на диск — разница в формате того, что записывается: RDB пишет полный бинарный снимок данных, BGREWRITEAOF пишет компактную последовательность команд для восстановления того же состояния. Риск скачка памяти из-за COW одинаково актуален для обоих сценариев.
Можно ли полностью избежать COW-раздувания памяти во время сохранения?
Нет, это встроенное свойство схемы через fork. Снизить риск можно запасом памяти, отключением THP, разнесением тяжёлой записи и сохранения по времени — но не убрать механизм совсем, не отказавшись от самого fork-based снапшота.
Что произойдёт, если во время BGSAVE закончится память на сервере?
Как минимум — фоновое сохранение может завершиться ошибкой, и при включённом stop-writes-on-bgsave-error сервер перестанет принимать запись до исправления ситуации. Как максимум — сработает системный OOM killer, и под удар может попасть сам процесс Redis, что уже потенциальная потеря несохранённых на диск данных.
Стоит ли снимать снапшот с реплики вместо мастера?
Да, если цель — снизить риск COW-скачка на инстансе, который обслуживает прямую запись от приложения. Реплика всё равно применяет входящий поток репликации, так что полностью изолировать её от записи нельзя, но обычно поток репликации предсказуемее и легче пиковой прямой записи от множества клиентов.
Обсудить статью, задать вопрос или начать новую тему
Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →