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 на всю базу: промежутков между шагами нет.
BGSAVE: что копируется, когда родитель пишет
Что из этого следует
Для 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; шаги по страницам при работающем писателе могут не закончиться совсем.
Чек-лист
- Redis: проверь
rdb_last_cow_sizeиaof_last_cow_sizeпосле реальногоBGSAVEпод боевой записью и сравниanonконтейнера в cgroup сused_memory; лимит должен вмещать набор × 2. - MongoDB: делай
mongodump --oplogтолько с replica set и восстанавливай с--oplogReplay; после восстановления проверяй инвариант между коллекциями, а не только число документов. - ClickHouse: после каждой базы сравнивай
compressed_sizeинкремента вsystem.backupsс полной копией; рост до размера базы значит, что прошло слияние. - SQLite: используй
VACUUM INTOилиbackup(pages=-1), а затем открой копию и выполниPRAGMA integrity_check. - Любой бэкап проверяй восстановлением в отдельный контейнер и запросом к данным, а не кодом возврата команды.
Убрать стенды: docker rm -f -v $P-redis $P-mongo $P-ch, файлы SQLite — rm -f app.db* v.db b1.db b2.db.