D1 내부 백업을 외부로 분리하고 전체 백업 생성 중단 해결

이 글의 목차 12개 항목
  • 분야: DB
  • 기술 스택: Cloudflare Workers · D1 · R2 · TypeScript
  • 관련 기술: 기본키 keyset pagination · SHA-256 · 비공개 객체 저장 · 제한 병렬 I/O · 격리 SQLite 복원

문제와 원인

기존 백업은 모든 테이블을 한 객체로 직렬화해 원본 D1의 backup_chunks에 저장했다. 백업 크기만큼 메모리가 늘고, 원본 DB 장애 시 사용할 복구 본문도 같은 DB에 있었다.

본문을 R2로 분리한 첫 구현은 파트별 업로드·읽기 검증을 순차 대기했다. 운영 전체 백업 요청은 180,000ms 예산 직전인 179,975ms에 취소됐다.

수정 전 코드

createBackup()

const data: BackupEnvelope["data"] = {};
for (const table of tables) {
  const rows = await allRows(`SELECT * FROM ${table}`);
  data[table] = rows;
}

const serialized = JSON.stringify(envelope);
await writeBackupPayload(id, serialized);

createExternalBackup() 업로드

const receipt = await storage.put(objectKey, body);
await readAndVerifyBackupObject(storage, receipt);
parts.push({ ...receipt, table, partIndex, rowStart, rowCount: rows.length });

createExternalBackup() 실패 정리

await removePrefix(storage, objectPrefix).catch(() => undefined);

수정 후 코드

기본키 keyset 페이지를 최대 100행씩 읽고 256KiB 이하 파트로 나눠 비공개 R2에 저장한다. D1에는 객체 위치·체크섬·완료 상태를 남기고, 매니페스트에는 크기·SHA-256·테이블·순서·행 범위를 기록한다.

업로드·읽기 검증을 최대 4개 병렬로 처리하되, 결과는 최초 배열 위치에 넣어 파트 순서를 보존했다.

파트·페이지 제한

export const BACKUP_PART_MAX_BYTES = 256 * 1024;
export const BACKUP_PAGE_ROWS = 100;

scanStableTable()

async function scanStableTable(
  table: string,
  visit: (rows: D1Row[], partIndex: number, rowStart: number) => Promise<void>,
) {
  let cursor: D1Row | null = null;
  let partIndex = 0;
  let rowStart = 0;
  while (true) {
    const query = stablePageQuery(table, cursor);
    const page = await allRows(query.sql, query.values);
    if (!page.length) break;
    for (const row of page) validateRowShape(table, row);
    for (const rows of backupPartGroups(page)) {
      await visit(rows, partIndex, rowStart);
      partIndex += 1;
      rowStart += rows.length;
    }
    cursor = page.at(-1) ?? null;
    if (page.length < BACKUP_PAGE_ROWS) break;
  }
  return { partCount: partIndex, rowCount: rowStart };
}

createExternalBackup() 업로드

const position = parts.length;
const metadata = { table, partIndex, rowStart, rowCount: rows.length };
parts.push({ ...metadata, objectKey, byteSize: body.byteLength, checksum: "" });

await uploads.add(body.byteLength, async () => {
  const receipt = await storage.put(objectKey, body);
  await readAndVerifyBackupObject(storage, receipt);
  parts[position] = { ...receipt, ...metadata };
});

BackupUploadQueue.add()

const MAX_UPLOADS = 4;

while (this.pending.size >= MAX_UPLOADS) await Promise.race(this.pending);

createExternalBackup() 실패 정리

모든 업로드 뒤 원본 재스캔이 일치해야 완료 상태를 기록한다. 실패하면 진행 중 쓰기를 기다린 뒤 해당 백업 UUID의 객체를 정리한다. 외부 백업 모드에는 R2 연결이 필수다.

await uploads.settled();
await removePrefix(storage, objectPrefix).catch(() => undefined);

검증 결과

운영의 순차 R2 시도와 후속 병렬 생성 결과를 비교했다. 다운로드한 백업은 메모리 SQLite에서 복원했고, 업로드 대기 본문과 실패 정리는 로컬 회귀 결과다.

운영 전체 백업 생성순차 R2 처리최대 4개 병렬 처리
요청 결과179,975ms 후 취소108,542ms·HTTP 200
CPU 시간729ms1,719ms
180,000ms 요청 예산 대비 완료 여유—71,458ms
다운로드·복원 검사결과
백업 데이터20개 테이블·14,636행
다운로드 파일28,561,166B·외부 187파트·무결성 검증 통과
메모리 SQLite 복원SQL 210개·1,066ms
백업 원문과 복원 결과차이 0행
SQLite 무결성·외래키·학습 데이터 정합성통과
백업 당시 릴리스와 이론 본문기존 차이 9행 보존

다운로드한 동일 백업을 원래 복원 함수로 격리 SQLite에 다시 복원한 뒤, DBeaver에서 원문과 모든 컬럼을 양방향 EXCEPT로 비교했다. 20개 테이블·14,636행이 일치했고, 차이 0행·SQLite 무결성 ok·외래키 오류 0건이었다.

DBeaver에서 조회한 백업 크기·복원 행 수·차이·무결성 결과원본 보기 (새 창)

DBeaver에서 조회한 백업 크기·복원 행 수·차이·무결성 결과

100%원본 보기 (새 창)

확대한 이미지는 상하좌우로 이동할 수 있습니다. 키보드는 이미지 영역에 초점을 두고 방향키를 사용하세요. Esc로 닫습니다.

로컬 업로드·실패 정리 검사결과
로컬 업로드 대기 본문최대 1MiB(4 × 256KiB)
로컬 실패 정리 뒤 잔여 객체0개