- 분야: DB
- 기술 스택: Cloudflare Workers · D1 · R2 · TypeScript
- 관련 기술: 기본키 keyset pagination · SHA-256 · 비공개 객체 저장 · 제한 병렬 I/O · 격리 SQLite 복원
문제와 원인
기존 백업은 모든 테이블을 한 객체로 직렬화해 원본 D1의 backup_chunks에 저장했다. 백업 크기만큼 메모리가 늘고, 원본 DB 장애 시 사용할 복구 본문도 같은 DB에 있었다.
본문을 R2로 분리한 첫 구현은 파트별 업로드·읽기 검증을 순차 대기했다. 운영 전체 백업 요청은 180,000ms 예산 직전인 179,975ms에 취소됐다.
수정 전 코드
createBackup()
좌우로 이동해 전체 내용을 확인하세요. 키보드: ←·→, Home·End
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() 업로드
좌우로 이동해 전체 내용을 확인하세요. 키보드: ←·→, Home·End
const receipt = await storage.put(objectKey, body);
await readAndVerifyBackupObject(storage, receipt);
parts.push({ ...receipt, table, partIndex, rowStart, rowCount: rows.length });
createExternalBackup() 실패 정리
좌우로 이동해 전체 내용을 확인하세요. 키보드: ←·→, Home·End
await removePrefix(storage, objectPrefix).catch(() => undefined);
수정 후 코드
기본키 keyset 페이지를 최대 100행씩 읽고 256KiB 이하 파트로 나눠 비공개 R2에 저장한다. D1에는 객체 위치·체크섬·완료 상태를 남기고, 매니페스트에는 크기·SHA-256·테이블·순서·행 범위를 기록한다.
업로드·읽기 검증을 최대 4개 병렬로 처리하되, 결과는 최초 배열 위치에 넣어 파트 순서를 보존했다.
파트·페이지 제한
좌우로 이동해 전체 내용을 확인하세요. 키보드: ←·→, Home·End
export const BACKUP_PART_MAX_BYTES = 256 * 1024;
export const BACKUP_PAGE_ROWS = 100;
scanStableTable()
좌우로 이동해 전체 내용을 확인하세요. 키보드: ←·→, Home·End
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() 업로드
좌우로 이동해 전체 내용을 확인하세요. 키보드: ←·→, Home·End
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()
좌우로 이동해 전체 내용을 확인하세요. 키보드: ←·→, Home·End
const MAX_UPLOADS = 4;
while (this.pending.size >= MAX_UPLOADS) await Promise.race(this.pending);
createExternalBackup() 실패 정리
모든 업로드 뒤 원본 재스캔이 일치해야 완료 상태를 기록한다. 실패하면 진행 중 쓰기를 기다린 뒤 해당 백업 UUID의 객체를 정리한다. 외부 백업 모드에는 R2 연결이 필수다.
좌우로 이동해 전체 내용을 확인하세요. 키보드: ←·→, Home·End
await uploads.settled();
await removePrefix(storage, objectPrefix).catch(() => undefined);
검증 결과
운영의 순차 R2 시도와 후속 병렬 생성 결과를 비교했다. 다운로드한 백업은 메모리 SQLite에서 복원했고, 업로드 대기 본문과 실패 정리는 로컬 회귀 결과다.
좌우로 이동해 전체 내용을 확인하세요. 키보드: ←·→, Home·End
| 운영 전체 백업 생성 | 순차 R2 처리 | 최대 4개 병렬 처리 |
|---|---|---|
| 요청 결과 | 179,975ms 후 취소 | 108,542ms·HTTP 200 |
| CPU 시간 | 729ms | 1,719ms |
| 180,000ms 요청 예산 대비 완료 여유 | — | 71,458ms |
좌우로 이동해 전체 내용을 확인하세요. 키보드: ←·→, Home·End
| 다운로드·복원 검사 | 결과 |
|---|---|
| 백업 데이터 | 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건이었다.
좌우로 이동해 전체 내용을 확인하세요. 키보드: ←·→, Home·End
| 로컬 업로드·실패 정리 검사 | 결과 |
|---|---|
| 로컬 업로드 대기 본문 | 최대 1MiB(4 × 256KiB) |
| 로컬 실패 정리 뒤 잔여 객체 | 0개 |