Справочник DBA: базы данных изнутри читается по порядку

Статья 9.6 · раздел 9 «Бэкапы и восстановление» · 69 из 109

Бэкапы Redis, MongoDB, ClickHouse и SQLite: COW при BGSAVE, mongodump --oplog, инкремент BACKUP и backup API

опубликовано
читать
≈ 11 мин
автор
среда и версия
Redis 8.10.3 (redis:8, --memory 1500m, 450 000 ключей, 483 МБ); MongoDB 8.3.13 и mongodump 100.20.0 (mongo:8, --memory 1g, replica set из одного узла, 400 000 документов, 193 МБ); ClickHouse 25.3.14.14 (clickhouse/clickhouse-server:25.3, --memory 1500m, 9 млн строк, 236 MiB); SQLite 3.50.2 из Python 3.14.7 (1,5 млн строк, 205 МБ); опыт выполнен 28 октября 2026
содержание решать задачи SQL на собеседовании

Redis с набором в 483 МБ при BGSAVE под записью занимает в контейнере почти вдвое больше: anon в cgroup вырос с 507 до 984 МБ, а used_memory и used_memory_rss в INFO не изменились ни на байт. Причина в fork(): потомок видит те же страницы, что родитель, и каждая запись родителя копирует страницу. (Полную синхронизацию реплики запускает тот же BGSAVE.) У остальных движков из заголовка тоже свой способ получить согласованную копию живой базы, и у каждого свой предел. MongoDB без --oplog складывает коллекции, снятые в разное время. ClickHouse копирует только новые неизменяемые файлы, пока слияние их не перепишет. Backup API SQLite начинает с первой страницы после каждого чужого коммита. Ниже каждый механизм измерен в своём контейнере; как выбирать RPO и RTO — в статье о стратегии бэкапов. Объёмы небольшие (200–500 МБ на движок), МБ здесь равен 10⁶ байт, а у ClickHouse размеры в MiB, как их печатает сам сервер.

Redis: fork и copy-on-write

Контейнер с лимитом 1500 МБ, набор из 450 000 ключей по 1000 байт загружается протоколом --pipe; gen.py n печатает команды SET для n случайных ключей из 450 000 (random.sample, значение из 1000 символов w):

P=dba-backups-redis-mongo-clickhouse-sqlite
docker run -d --name $P-redis --memory 1500m redis:8 redis-server --appendonly no --save ""
docker exec $P-redis redis-cli INFO server | grep redis_version
python3 gen.py 450000 | docker exec -i $P-redis redis-cli --pipe
docker exec $P-redis redis-cli INFO memory | grep -E '^(used_memory|used_memory_rss):'
docker exec $P-redis grep -E '^anon ' /sys/fs/cgroup/memory.stat
redis_version:8.10.3
used_memory:483356376
used_memory_rss:507117568
anon 507752448

Без паузы BGSAVE укладывается в секунду (rdb_last_bgsave_time_sec:0), и пик снимать не с чего, поэтому rdb-key-save-delay 10 вставляет usleep на каждом ключе (микросекунды, debugDelay в debug.c): сохранение растянулось на 28–29 секунд, как на медленном диске. Через секунду после старта BGSAVE родитель переписывает заданную долю ключей, ещё через три секунды снимаются показатели:

R="docker exec $P-redis redis-cli"; $R CONFIG SET rdb-key-save-delay 10
for n in 0 45000 135000 270000 450000; do
  $R BGSAVE > /dev/null; sleep 1
  [ $n -gt 0 ] && python3 gen.py $n | docker exec -i $P-redis redis-cli --pipe > /dev/null
  sleep 3; echo "rewritten=$n"
  $R INFO persistence | grep -E '^current_cow_size:'
  docker exec $P-redis sh -c 'grep -E "^anon " /sys/fs/cgroup/memory.stat; for f in $(grep -l "^PPid:.1$" /proc/[0-9]*/status); do echo "child $(grep VmRSS $f)"; done'
  until $R INFO persistence | grep -q '^rdb_bgsave_in_progress:0'; do sleep 2; done
  $R INFO persistence | grep -E '^rdb_last_cow_size:'
done

Вывод (из пяти прогонов подряд показаны первый, третий и последний):

rewritten=0
current_cow_size:1089536
anon 508977152
child VmRSS:	  495744 kB
rdb_last_cow_size:1105920
rewritten=135000
current_cow_size:365572096
anon 873451520
child VmRSS:	  495764 kB
rdb_last_cow_size:365596672
rewritten=450000
current_cow_size:476397568
anon 984272896
child VmRSS:	  495764 kB
rdb_last_cow_size:476418048

Прогоны на 45 000 и 270 000 ключей дали rdb_last_cow_size 174 694 400 и 464 420 864 и anon 682 573 824 и 972 271 616. После всех пяти прогонов INFO memory показывает прежние used_memory:483356376 и used_memory_rss:507117568. Ключи переписаны значениями того же размера, объём данных не менялся, и счётчики родителя остались прежними. VmRSS потомка в каждом прогоне 495 744–495 764 kB: он считает и общие страницы, и разницы между прогонами нет. Копию показывают только current_cow_size/rdb_last_cow_size и контейнерный anon. Потомок отправляет размер родителю в rdbSaveBackground, тег 8.10.3; до этого redisFork уже разделил адресное пространство:

    if ((childpid = redisFork(CHILD_TYPE_RDB)) == 0) {
        int retval;

        /* Child */
        redisSetProcTitle("redis-rdb-bgsave");
        redisSetCpuAffinity(server.bgsave_cpulist);
        retval = rdbSave(req, filename,rsi,rdbflags);
        if (retval == C_OK) {
            sendChildCowInfo(CHILD_INFO_TYPE_RDB_COW_SIZE, "RDB");
        }
        exitFromChild((retval == C_OK) ? 0 : 1, 0);
    } else {

COW растёт не с числом записей, а с долей затронутых страниц. Ключ занимает около 1 КБ, значит на странице 4 КБ лежит порядка четырёх ключей, и страница копируется, если переписан хоть один из них. Доля страниц 1 − (1 − f)⁴ при f = 10 %, 30 %, 60 % даёт 34 %, 76 %, 97 %; измеренный rdb_last_cow_size / used_memory — 36 %, 76 %, 96 %. Переписав всего 10 % ключей, ты платишь памятью за треть набора, а на 100 % COW (476 МБ) почти равен самому набору (483 МБ). Лимит контейнера для Redis с BGSAVE и записью по всему набору считается как набор × 2, а не набор + запас в 10 %.

AOF: BGREWRITEAOF

AOF растёт, пока его не перепишут: BGREWRITEAOF форкает потомка тем же redisFork (rewriteAppendOnlyFileBackground, CHILD_TYPE_AOF) и пишет новый базовый файл по текущему состоянию. Набор заполняется заново случайными hex-значениями (gen2.py отличается от gen.py только значением os.urandom(500).hex(): повторяющиеся символы RDB сжимает LZF), auto-aof-rewrite-percentage 0 отключает автоматическое переписывание, два прохода SET по всем 450 000 ключам накапливают журнал:

$R CONFIG SET appendonly yes; $R CONFIG SET auto-aof-rewrite-percentage 0
for s in 1 2; do python3 gen2.py 450000 $s | docker exec -i $P-redis redis-cli --pipe | grep replies; done
docker exec $P-redis sh -c 'cd /data/appendonlydir && ls -l | grep "appendonly.aof.[0-9]"'
$R BGREWRITEAOF   # и через 2 секунды
docker exec $P-redis sh -c 'cd /data/appendonlydir && ls -l | grep "appendonly.aof.[0-9]"'
$R INFO persistence | grep -E '^(aof_rewrite_in_progress|aof_last_rewrite_time_sec|aof_current_size|aof_base_size|aof_last_cow_size)'

Вывод до и после:

-rw-------. 1 redis redis        89 Oct 28 00:43 appendonly.aof.5.base.rdb
-rw-------. 1 redis redis 899829807 Oct 28 00:43 appendonly.aof.5.incr.aof
Background append only file rewriting started
-rw-------. 1 redis redis 453044814 Oct 28 00:43 appendonly.aof.6.base.rdb
-rw-------. 1 redis redis         0 Oct 28 00:43 appendonly.aof.6.incr.aof
aof_rewrite_in_progress:0
aof_last_rewrite_time_sec:2
aof_current_size:453044814
aof_base_size:453044814
aof_last_cow_size:1400832

Те же 450 000 ключей до переписывания занимали 900 МБ (журнал хранит каждую запись), после — 453 МБ (по одной на ключ), за 2 секунды. aof_last_cow_size здесь 1,4 МБ только потому, что во время переписывания запись не шла; под записью потомок AOF по документации копирует страницы так же, как потомок BGSAVE (документация: «Log rewriting uses the same copy-on-write trick already in use for snapshotting»).

MongoDB: mongodump —oplog и снимок тома

mongodump --oplog на одиночном mongod без replica set не работает: oplog не ведётся.

docker run -d --name $P-mongo --memory 1g mongo:8 mongod --wiredTigerCacheSizeGB 0.25
docker exec $P-mongo mongodump --oplog --archive=/tmp/x.archive
Failed: error getting oplog start: error getting recent oplog entry: mongo: no documents in result

Нужна replica set из одного узла. Контейнер пересоздан с --replSet rs0, rs.initiate({_id:"rs0",members:[{_id:0,host:"localhost:27017"}]}); oplog (local.oplog.rs) получил maxSize 1 336 887 552 байт (устройство oplog). В базе app три коллекции: b_big — 400 000 документов (193 МБ), a_ctr с одним документом-счётчиком и c_events. Писатель writer.js в цикле 40 секунд вставляет {_id: i} в c_events и затем ставит a_ctr.n = i: в любой момент числа событий и n отличаются не больше чем на 1 (events = n + 1 между вставкой и обновлением). Из дампа, снятого за один момент, это должно выходить.

docker exec -d $P-mongo sh -c 'mongosh --quiet /tmp/writer.js > /tmp/writer.log'; sleep 2
docker exec $P-mongo mongodump --oplog --numParallelCollections 1 --out /tmp/dump

Вывод (отобраны строки):

done dumping `app.c_events` (327 documents)
done dumping `app.b_big` (400000 documents)
done dumping `app.a_ctr` (1 document)
	dumped 170 oplog entries

События сняты первыми (327 документов), счётчик последним: пока шёл b_big (0,5 секунды по меткам времени лога), писатель сделал ещё 81 пару операций (327 → 408). oplog.bson — 51 850 байт, 170 записей. Восстановление без и с воспроизведением oplog; проверка events=… ctr.n=…:

-- mongorestore --drop --nsInclude 'app.*' /tmp/dump
events=327 ctr.n=408 big=400000
-- mongorestore --drop --oplogReplay --nsInclude 'app.*' /tmp/dump
Failed: cannot use --oplogReplay with includes specified
-- dropDatabase app, затем mongorestore --oplogReplay /tmp/dump
applied 170 oplog entries
events=410 ctr.n=409 big=400000

Без воспроизведения получилось events=327, n=408: счётчик говорит о 408 событиях, которых в базе нет, состояние, которого никогда не было. С --oplogReplay дамп доводится по oplog до момента окончания mongodump: 410 и 409, разница 1, инвариант цел. Фильтр --nsInclude с --oplogReplay несовместим, поэтому на живом узле восстановление делается полным дампом после удаления базы. Второй способ — снимок тома (LVM, EBS): по документации он корректен, если включён журнал и журнал лежит на том же томе, что и файлы данных; на стенде это не снималось.

ClickHouse: BACKUP и инкремент по base_backup

Диск backups объявлен конфигом, который кладётся в config.d до старта сервера:

<clickhouse>
    <storage_configuration><disks><backups><type>local</type><path>/backups/</path></backups></disks></storage_configuration>
    <backups><allowed_disk>backups</allowed_disk><allowed_path>/backups/</allowed_path></backups>
</clickhouse>

Таблица app.events (MergeTree, в итоге 9 млн строк) собрана из восьми вставок по миллиону строк; SYSTEM STOP MERGES останавливает слияния на время измерения, но шесть первых частей слились в all_1_6_1 ещё до неё. Полная копия, затем вставка ещё миллиона строк и инкремент:

C="docker exec -i $P-ch clickhouse-client -q"
$C "CREATE TABLE app.events (id UInt64, ts DateTime, user UInt32, payload String) ENGINE=MergeTree ORDER BY (user, ts)"
# восемь INSERT по миллиону строк, как следующий, с number + k*10000000 для k = 1..8
$C "SYSTEM STOP MERGES app.events"
$C "BACKUP TABLE app.events TO Disk('backups', 'full_1')"
$C "INSERT INTO app.events SELECT number + 90000000, now(), rand()%100000, hex(rand64())||hex(rand64())||hex(rand64())||hex(rand64()) FROM numbers(1000000) SETTINGS max_threads=2, max_block_size=65536"
$C "BACKUP TABLE app.events TO Disk('backups', 'incr_1') SETTINGS base_backup = Disk('backups', 'full_1')"
$C "SELECT name, base_backup_name, num_files, formatReadableSize(total_size) AS total, formatReadableSize(compressed_size) AS compressed FROM system.backups ORDER BY start_time FORMAT Vertical"
docker exec $P-ch sh -c 'du -sh /backups/*; ls /backups/incr_1/data/app/events'
name:             Disk('backups', 'full_1')
base_backup_name: 
num_files:        46
total:            232.53 MiB
compressed:       232.53 MiB
name:             Disk('backups', 'incr_1')
base_backup_name: Disk('backups', 'full_1')
num_files:        61
total:            262.00 MiB
compressed:       29.48 MiB

233M	/backups/full_1
30M	/backups/incr_1
all_9_9_0

Инкремент логически содержит 262 MiB (total_size), а записал 29,5 MiB (compressed_size, на диске 30M): только новую часть all_9_9_0, на остальные файлы он ссылается из full_1. Решение принимается по файлу: в buildFileInfoForBackupEntry (тег v25.3.14.14-lts) считается контрольная сумма файла, и при совпадении размера и суммы с файлом из базового бэкапа в запись попадает base_size, а не содержимое:

            auto checksums = calculateNewEntryChecksumsIfNeeded(backup_entry, 0, read_settings);
            info.checksum = checksums.full_checksum;

            if (info.checksum == base_backup_file_info->second)
            {
                LOG_TRACE(log, "Found whole file {} in base backup", adjusted_path);
                assert(check_base == CheckBackupResult::HasFull);
                assert(info.size == base_backup_file_info->first);

                info.base_size = base_backup_file_info->first;
                info.base_checksum = base_backup_file_info->second;

Это работает, потому что часть после записи не меняется (part’ы неизменяемы): файлы старой части байт в байт те же. Слияние ломает такой расчёт. После SYSTEM START MERGES и OPTIMIZE TABLE app.events FINAL все части слились в all_1_9_2, и второй инкремент к тому же full_1 записал почти весь объём заново:

name:             Disk('backups', 'incr_2')
base_backup_name: Disk('backups', 'full_1')
total:            259.06 MiB
written:          259.06 MiB
233M	/backups/full_1
30M	/backups/incr_1
260M	/backups/incr_2

Инкремент после крупного слияния стоит столько же, сколько вся таблица: 260 MiB против 233 MiB у полной копии на 8 млн строк. Восстановление: DROP TABLE app.events SYNC, затем RESTORE TABLE app.events FROM Disk('backups', 'incr_1') вернул таблицу с count() = 9000000 и uniqExact(id) = 9000000, id от 10 000 000 до 90 999 999. Инкремент ссылается на файлы full_1, поэтому полную копию удалять нельзя. В S3 та же команда пишет по адресу BACKUP TABLE ... TO S3('<S3 endpoint>/<path>', '<Access key ID>', '<Secret access key>') (документация); на стенде S3 не было.

SQLite: VACUUM INTO и backup API при писателе в WAL

База app.db в режиме WAL, таблица events на 1 500 000 строк занимает 205 324 288 байт. Писатель — отдельный процесс: по 50 вставок на коммит и пауза 5 мс (около 190 коммитов в секунду). Без паузы в первой пробе он за 75 секунд раздул файл до 8,5 ГБ, поэтому пауза нужна.

Параллельно другой процесс (src открыт на app.db) последовательно делает три копии и пишет времена окон:

src.execute("VACUUM INTO 'v.db'")            # окно 1
src.backup(sqlite3.connect("b1.db"))        # окно 2, pages=-1: за один шаг
src.backup(sqlite3.connect("b2.db"), pages=1000, progress=prog)   # окно 3, шагами по 1000 страниц

В prog считаются вызовы и случаи, когда remaining вырос, и через 20 секунд бросается исключение. Вывод:

VACUUM INTO: 0.20 s, 218505216 bytes
backup(pages=-1): 0.11 s, 218755072 bytes
backup(pages=1000): RuntimeError: give up after 20 s; remaining=57860 of 59860
  steps=9196 restarts=3839
commits/s during idle(writer only)    193  (963 commits in 5.00 s)
commits/s during VACUUM INTO          195  (38 commits in 0.20 s)
commits/s during backup pages=-1      197  (22 commits in 0.11 s)
commits/s during backup pages=1000    192  (3840 commits in 20.00 s)
v.db ('ok',) (1596250, 10096250) ('delete',)
b1.db ('ok',) (1598100, 10098100) ('wal',)

VACUUM INTO и backup за один шаг скопировали базу за 0,20 и 0,11 секунды (файл в кэше ОС; на холодном диске время другое, не измерялось), integrity_check обеих копий — ok. Писатель не остановился: 195 и 197 коммитов в секунду против 193 без копии (потолок задаёт его собственная пауза 5 мс, так что видно лишь отсутствие блокировки). В WAL читатель видит снимок на момент начала чтения и не мешает писателю (режимы журнала). Различия копий: v.db после VACUUM INTO в режиме delete, b1.db после backup остался в wal.

Шаги по 1000 страниц так и не закончили: за 20 секунд 3 839 перезапусков при 3 840 коммитах писателя, remaining застрял на 57 860 из 59 860. Между шагами источник не заблокирован, и каждый коммит другого соединения перезапускает копирование. В коде sqlite3BackupRestart (тег version-3.50.2) возвращает указатель на первую страницу:

void sqlite3BackupRestart(sqlite3_backup *pBackup){
  sqlite3_backup *p;                   /* Iterator variable */
  for(p=pBackup; p; p=p->pNext){
    assert( sqlite3_mutex_held(p->pSrc->pBt->mutex) );
    p->iNext = 1;
  }
}

Из Python pages=-1 (значение по умолчанию) — один вызов sqlite3_backup_step на всю базу: промежутков между шагами нет.

DBA / 01

BGSAVE: что копируется, когда родитель пишет

BGSAVE: что копируется, когда родитель пишетРодитель redis-server и потомок redis-rdb-bgsave после fork делят 483 МБ страниц. При переписывании 0, 10, 30, 60 и 100 процентов ключей размер копии rdb_last_cow_size растёт до 1,1, 174,7, 365,6, 464,4 и 476,4 МБ.родительпишет ключипотомок после forkпишет RDBобщие страницы: used_memory 483 356 376 байт, у обоих одни и те жепервая запись родителя копирует страницу: rdb_last_cow_size по доле переписанных ключей0 % ключей1,1 МБ10 % (45 000)174,7 МБ30 % (135 000)365,6 МБ60 % (270 000)464,4 МБ100 % (450 000)476,4 МБпунктир: 483 МБ, весь набор. INFO memory родителя при этом не менялся: used_memory_rss 507 117 568
Копия страниц растёт с долей переписанных ключей: 10 % ключей уже копируют треть набора, а запись по всему набору почти удваивает память контейнера (507 → 984 МБ). Счётчики родителя этого не показывают.

Что из этого следует

Для Redis лимит памяти считается как набор, умноженный на долю страниц, которую успеют переписать, пока идёт сохранение. Худший случай — набор × 2, и на нём BGSAVE или BGREWRITEAOF может убить контейнер по OOM, а INFO memory об этом заранее не скажет. Следи за rdb_last_cow_size и aof_last_cow_size и за anon контейнера. Дамп MongoDB без --oplog собирает коллекции в разное время, и несогласованность видна только по проверке инвариантов, как events и ctr.n выше. В ClickHouse инкремент дёшев, пока старые части не переписаны слиянием: если compressed_size инкремента близок к размеру таблицы, прошло крупное слияние, и пора снимать новую полную копию. Для SQLite копию снимай backup за один шаг или VACUUM INTO; шаги по страницам при работающем писателе могут не закончиться совсем.

Чек-лист

  1. Redis: проверь rdb_last_cow_size и aof_last_cow_size после реального BGSAVE под боевой записью и сравни anon контейнера в cgroup с used_memory; лимит должен вмещать набор × 2.
  2. MongoDB: делай mongodump --oplog только с replica set и восстанавливай с --oplogReplay; после восстановления проверяй инвариант между коллекциями, а не только число документов.
  3. ClickHouse: после каждой базы сравнивай compressed_size инкремента в system.backups с полной копией; рост до размера базы значит, что прошло слияние.
  4. SQLite: используй VACUUM INTO или backup(pages=-1), а затем открой копию и выполни PRAGMA integrity_check.
  5. Любой бэкап проверяй восстановлением в отдельный контейнер и запросом к данным, а не кодом возврата команды.

Убрать стенды: docker rm -f -v $P-redis $P-mongo $P-ch, файлы SQLite — rm -f app.db* v.db b1.db b2.db.

Источники

Выжимки статей в Telegram: одна ловушка, код с выводом и ссылка на полный разбор. t.me/koddo_ru