Effiziente Dateideduplizierung mit SHA-256 und Node.js
Nutzen Sie den SHA-256-Hash einer Datei als eindeutigen Datenbankschlüssel, um wiederholte Uploads abzulehnen, auch bei unterschiedlichen Dateinamen. Diese Anleitung erstellt einen lokalen Node.js-Dienst, sendet Dateien mit cURL und prüft, ob die Deduplizierungsdatensätze einen Neustart überstehen.
Inhaltsbasierte Deduplizierung verstehen
Der Server speichert jede eingehende Datei unter einem generierten Namen, verarbeitet ihre Bytes
als Stream mit SHA-256 und versucht, den Hash in SQLite einzufügen. Gelingt das Einfügen, bleibt
die Datei erhalten. Bei einem Konflikt wird die neue Kopie entfernt und HTTP
409 Conflict zurückgegeben.
Die entscheidende Prüfung erfolgt in einer einzigen Datenbankanweisung: INSERT … ON CONFLICT(hash) DO NOTHING.
Das UPSERT-Verhalten von SQLite lässt den eindeutigen Schlüssel
über gleichzeitige Uploads entscheiden. Würde der Hash zuerst nachgeschlagen und erst später
eingefügt, könnten zwei Anfragen beide feststellen, dass er fehlt. Der Primärschlüssel stellt die
Eindeutigkeit bereits sicher; ein zweiter Hash-Index ist unnötig.
Dieses Verfahren erkennt identische Bytes. Das Umbenennen einer Datei ändert ihren Hash nicht, ein erneutes Codieren eines Bildes oder das Ändern eingebetteter Metadaten hingegen möglicherweise schon. Visuell ähnliche Bilder erkennt es nicht.
Warum nicht MD5?
MD5 ist ungeeignet, wenn Kollisionsresistenz wichtig ist, auch wenn jemand absichtlich unterschiedliche Dateien mit demselben Hash hochladen könnte. SHA-256 bietet Kollisionsresistenz, aber keine mathematische Garantie, dass keine Kollisionen existieren können. Dieses Beispiel behandelt übereinstimmende Hashes als Duplikate. Falls Ihre Anwendung eine exakte Gleichheitsprüfung erfordert, vergleichen Sie die gespeicherten und eingehenden Bytes, bevor Sie einen Upload mit übereinstimmendem Hash verwerfen, und bieten Sie eine separate Möglichkeit, eine Kollision zu speichern.
Projekt einrichten
Verwenden Sie eine Linux-Shell, cURL, Node.js 24.15.0 und Corepack mit verfügbarem Yarn
4.12.0. Das Beispiel läuft auch unter Node.js 26.8.1. Die
integrierte TypeScript-Unterstützung von Node führt die gespeicherte
Datei .ts ohne Kompilierungsschritt aus; eine Typprüfung erfolgt nicht.
Fügen Sie Folgendes in ein Terminal ein. Es erstellt ein neues Projekt und kehrt in Ihr
ursprüngliches Verzeichnis zurück. Falls file-deduplication bereits existiert, wählen
Sie ein neues übergeordnetes Verzeichnis; die Befehle verweigern die Wiederverwendung.
(
mkdir file-deduplication &&
cd file-deduplication &&
printf '%s\n' '{"private":true,"type":"module","packageManager":"yarn@4.12.0"}' > package.json &&
printf '%s\n' 'nodeLinker: node-modules' 'enableScripts: true' > .yarnrc.yml &&
touch yarn.lock &&
corepack yarn add --exact express@5.2.1 multer@2.4.0 sqlite3@5.1.7
)
Warten Sie, bis die Installation erfolgreich abgeschlossen ist, bevor Sie fortfahren.
sqlite3 enthält eine native Anbindung. Falls keine vorkompilierte
Binärdatei für Ihre Plattform verfügbar ist, beschreibt die
Installationsanleitung die Build-Voraussetzungen. Die obigen
Versionen verwenden im getesteten Linux-Setup SQLite 3.44.2. Die lokale Datei
yarn.lock macht dies zu einem unabhängigen Projekt, und
node-modules ermöglicht es node, seine Pakete direkt
aufzulösen. Installationsskripte sind für die native SQLite-Anbindung aktiviert. Das Repository
sqlite3 ist inzwischen archiviert. Betrachten Sie dies daher als lokales
Beispiel mit festgelegten Versionen und wählen Sie einen gepflegten Datenbanktreiber, bevor Sie
es für einen neuen bereitgestellten Dienst anpassen.
Lokalen Upload-Handler erstellen
Speichern Sie den vollständigen Block als file-deduplication/server.ts. Dieser Dienst bindet an
127.0.0.1 und hat keine Authentifizierung. Er akzeptiert beliebige Dateibytes,
auch leere Dateien, und verarbeitet sie weder weiter noch liefert er sie aus. Betreiben Sie ihn
nur lokal; eine MIME-Filterung würde nicht sicherstellen, dass ein Upload sicher ist.
import { createHash } from 'node:crypto'
import { createReadStream } from 'node:fs'
import { rm } from 'node:fs/promises'
import express, { type ErrorRequestHandler } from 'express'
import multer from 'multer'
import sqlite3 from 'sqlite3'
async function hashFile(filePath: string): Promise<string> {
const hash = createHash('sha256')
for await (const chunk of createReadStream(filePath)) hash.update(chunk)
return hash.digest('hex')
}
async function storeFile(
db: sqlite3.Database,
filePath: string,
name: string,
size: number,
): Promise<{ hash: string; stored: boolean }> {
let stored = false
try {
const hash = await hashFile(filePath)
const changes = await new Promise<number>((resolve, reject) => {
db.run(
`INSERT INTO files (hash, original_name, file_path, size)
VALUES (?, ?, ?, ?) ON CONFLICT(hash) DO NOTHING`,
[hash, name, filePath, size],
function (error) {
if (error) reject(error)
else resolve(this.changes)
},
)
})
stored = changes === 1
return { hash, stored }
} finally {
// Finish cleanup before the route sends a duplicate or failure response.
if (!stored) await rm(filePath, { force: true })
}
}
async function main(): Promise<void> {
const port = Number(process.env.PORT ?? '3000')
if (!Number.isInteger(port) || port < 1 || port > 65535) {
console.error('PORT must be an integer from 1 to 65535')
process.exit(1)
}
const db = await new Promise<sqlite3.Database>((resolve, reject) => {
const database = new sqlite3.Database('deduplication.db', (error) => {
if (error) reject(error)
else resolve(database)
})
})
await new Promise<void>((resolve, reject) => {
db.exec(
`CREATE TABLE IF NOT EXISTS files (
hash TEXT NOT NULL PRIMARY KEY,
original_name TEXT NOT NULL,
file_path TEXT NOT NULL,
size INTEGER NOT NULL,
upload_date TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)`,
(error) => (error ? reject(error) : resolve()),
)
})
const app = express()
const upload = multer({
dest: 'uploads/',
limits: { fileSize: 10 * 1024 * 1024, files: 1, fields: 0 },
})
app.post('/upload', upload.single('file'), async (req, res) => {
if (!req.file) return res.status(400).json({ error: 'Send one file in the file field' })
const { path, originalname, size } = req.file
const { hash, stored } = await storeFile(db, path, originalname, size)
if (!stored) return res.status(409).json({ error: 'Duplicate file detected', hash })
return res.status(201).json({ message: 'File stored', hash, size })
})
app.get('/files', (_req, res, next) => {
db.all(
'SELECT original_name AS name, hash, size, upload_date FROM files ORDER BY hash',
(error, rows) => {
if (error) return next(error)
res.json(rows)
},
)
})
const handleError: ErrorRequestHandler = (error: unknown, _req, res, _next) => {
if (error instanceof multer.MulterError) {
const status = error.code === 'LIMIT_FILE_SIZE' ? 413 : 400
res.status(status).json({ error: 'Send one file of at most 10 MiB and no text fields' })
return
}
console.error('Request failed; check the database and upload directory')
res.status(500).json({ error: 'Could not complete the request' })
}
app.use(handleError)
await new Promise<void>((resolve, reject) => {
app.listen(port, '127.0.0.1', (error?: Error) => {
if (error) reject(error)
else resolve()
})
})
console.log(`Listening on http://127.0.0.1:${port}`)
}
main().catch((error: unknown) => {
const code = error instanceof Error && 'code' in error ? error.code : 'STARTUP_FAILED'
console.error(`Could not start server (${code}); check the port and storage permissions`)
process.exit(1)
})
Die Festplattenspeicherung von Multer vergibt einen generierten
Dateinamen, statt den Namen vom Client als Pfad zu verwenden. SQLite behält den zuerst akzeptierten
Namen als Metadatum bei. Der reguläre Callback function in
db.run ist beabsichtigt: this.changes gehört zu dieser
abgeschlossenen Anweisung und unterscheidet eine eingefügte Zeile
von einem Konflikt, bei dem nichts geändert wurde.
Server starten
Fügen Sie im selben übergeordneten Verzeichnis, in dem Sie die Einrichtung ausgeführt haben, Folgendes ein:
(
cd file-deduplication &&
node server.ts
)
Warten Sie auf Listening on http://127.0.0.1:3000. Falls EADDRINUSE erscheint,
stoppen Sie den bereits lauschenden Dienst oder setzen Sie PORT=3001 vor
node server.ts und verwenden Sie diesen Port in jeder folgenden Anfrage.
Der Start-Callback prüft auf Fehler, weil
Express 5 dort Fehler beim Binden meldet. Ein Berechtigungsfehler
bei der Datenbank oder einem Verzeichnis muss behoben werden, bevor der Server starten kann.
Hochladen, wiederholen und Bytes ändern
Laden Sie in einem zweiten Terminal sechs Bytes einschließlich des Zeilenumbruchs hoch:
printf 'hello\n' | curl -sS -i -F 'file=@-;filename=hello.txt' http://127.0.0.1:3000/upload
Die Antwort hat den Status 201 Created und diesen JSON-Body:
{"message":"File stored","hash":"5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03","size":6}
Laden Sie dieselben Bytes unter einem anderen Namen hoch:
printf 'hello\n' | curl -sS -i -F 'file=@-;filename=renamed.txt' http://127.0.0.1:3000/upload
Erwarten Sie 409 Conflict, wobei error auf
Duplicate file detected gesetzt ist und der Hash gleich bleibt. Die zuerst gespeicherte
Kopie bleibt erhalten. Diese Upload-Befehle lassen die cURL-Option -f
bewusst weg, damit Sie die erwartete Antwort 409 untersuchen können.
Ein erfolgreicher cURL-Abschluss allein bedeutet nicht, dass der Server eine Datei akzeptiert hat.
Senden Sie nun andere Bytes mit dem ersten Dateinamen und anschließend eine leere Datei:
printf 'different\n' | curl -sS -i -F 'file=@-;filename=hello.txt' http://127.0.0.1:3000/upload
curl -sS -i -F 'file=@/dev/null;filename=empty.bin' http://127.0.0.1:3000/upload
Beide Anfragen geben 201 mit den Größen
10 und 0 zurück. Bei Wiederholung einer
der beiden Anfragen wird 409 zurückgegeben. Es gibt jetzt drei Dateien
unter file-deduplication/uploads/ und drei Datenbankeinträge, obwohl zwei akzeptierte Dateien
denselben ursprünglichen Namen haben. Listen Sie diese Einträge mit folgendem Befehl auf:
curl -fsS http://127.0.0.1:3000/files
Nachdem alle Anfragen abgeschlossen sind, stoppen Sie den Server mit Strg+C, starten ihn mit
demselben Befehl erneut und wiederholen den ersten Upload. Er gibt weiterhin
409 zurück. Sowohl deduplication.db als auch
uploads/ liegen im Projektverzeichnis. Bewahren Sie beide zusammen auf und
starten Sie den Dienst aus diesem Verzeichnis neu. Das Beispiel fügt neue Inhalte hinzu und
behält bei wiederholten Inhalten die erste Kopie; ein Neustart leert keinen der beiden Speicher.
Bei überlappenden Uploads neuer identischer Inhalte lässt das eindeutige Einfügen eine Anfrage mit
201 zu. Die anderen erhalten 409, nachdem
ihre zusätzlichen Dateien entfernt wurden. Fehlende Dateien, falsche Feldnamen, zusätzliche
Dateien und Textfelder führen zu 400; Dateien über 10 MiB erhalten
413. Ein Speicherfehler gibt einen allgemeinen Fehler
500 zurück. Prüfen Sie daher das Serverterminal und das Dateisystem,
bevor Sie es erneut versuchen.
Große Dateien effizient verarbeiten
Die inkrementelle Hash-API ermöglicht es
hashFile, Daten blockweise zu lesen, statt die gesamte Datei zwischenzuspeichern.
Multer schreibt zunächst den vollständigen Upload auf die Festplatte, dann wird er zur
Hash-Berechnung erneut gelesen. Die Duplikaterkennung spart daher dauerhaft belegten Speicherplatz,
vermeidet aber weder die Upload-Bandbreite noch die vorübergehende Festplattennutzung.
Jeder Aufruf von hash.update() beansprucht CPU-Zeit im Hauptthread. Das Limit von
10 MiB pro Datei begrenzt die Eingabegröße dieses lokalen Beispiels, nicht aber die Anzahl
gleichzeitiger Anfragen oder die gesamte Festplattennutzung. Größere Workloads benötigen Grenzen
für Parallelität und Speicher, bevor dieses Limit erhöht wird.
Tipps zu Leistung, Speicher und Sicherheit
Das Einfügen in SQLite ist atomar, aber das Schreiben der Datei und das Einfügen in die Datenbank bilden keine gemeinsame Transaktion. Ein Absturz dazwischen kann eine nicht referenzierte Datei hinterlassen. Wird eine gespeicherte Datei gelöscht oder beschädigt, bleibt ihr Hash-Eintrag bestehen. Ein späterer Upload kann daher abgelehnt werden, obwohl die gespeicherten Bytes fehlen. Auch die Bereinigung kann fehlschlagen, wenn sich die Speicherberechtigungen ändern. Dieses Beispiel gleicht diese Fälle nicht ab, prüft gespeicherte Dateien nicht bei jedem Nachschlagen und verspricht keine dauerhafte Speicherung bei einem Stromausfall.
Sichern Sie Datenbank und Dateien als konsistentes Paar, während der Dienst gestoppt ist. Bevor Sie das Beispiel für öffentliche Uploads anpassen, ergänzen Sie Authentifizierung, Eigentumsprüfungen, Kontingente und einen Wiederherstellungsprozess, der Datensätze mit Dateien abgleicht. Eine globale Duplikatantwort kann offenlegen, dass jemand anderes bestimmte Inhalte hochgeladen hat. Wählen Sie den Geltungsbereich der Deduplizierung daher bewusst.
