Effiziente Dateideduplizierung mit SHA-256 und Node.js
Doppelte Dateien werden in modernen Anwendungen schnell zum Ärgernis: Sie verbrauchen Speicher und verlangsamen Abfragen. In diesem DevTip bauen wir einen kleinen Node.js-Server, der doppelte Uploads abweist, indem er für jede eingehende Datei einen SHA-256-Hash berechnet und ihn mit bereits bekannten Hashes vergleicht.
Warum nicht MD5?
MD5 war früher der Standardalgorithmus für schnelle Integritätsprüfungen, gilt aber nicht mehr als kollisionsresistent. Für ein Deduplizierungssystem benötigen Sie in der Regel keine vollständige kryptografische Stärke; wer jedoch einen gebrochenen Hash wählt, nimmt ein (kleines, aber reales) Risiko in Kauf, dass zwei unterschiedliche Dateien denselben Digest erzeugen. SHA-256 ist nach wie vor schnell, ist in Node.js enthalten und beseitigt diese Sorge – deshalb verwenden wir es im gesamten Artikel.
Inhaltsbasierte Deduplizierung verstehen
Inhaltsbasierte Deduplizierung, eine Form der Inhaltsadressierung, identifiziert identische Dateien anhand ihres binären Inhalts statt anhand ihrer Dateinamen oder Metadaten. Der Ablauf ist einfach:
- Nehmen Sie einen Upload entgegen.
- Streamen Sie die Bytes durch eine Hash-Funktion.
- Schlagen Sie diesen Hash in einem Datenspeicher nach.
- Weisen Sie die Datei ab, verwenden Sie sie erneut oder speichern Sie sie, je nachdem, ob der Hash bereits existiert.
Da Hashes eine feste Länge haben (256 Bit bei SHA-256), können Sie sie unabhängig von der ursprünglichen Dateigröße als leichtgewichtige Primärschlüssel verwenden.
Das Projekt einrichten
mkdir file-deduplication && cd $_
npm init -y
npm install express@^4.21.2 multer@^2.3.0 sqlite3@^5.1.7
Wir halten zusätzliche Abhängigkeiten auf ein Minimum: express für das Routing, multer für das
Parsen von Multipart-Daten und sqlite3 für eine winzige persistente Datenbank. path und crypto
sind in Node.js enthalten.
Einen lokalen Upload-Handler erstellen
Diese lokale Demonstration verfügt weder über Authentifizierung noch über eine Isolierung pro Benutzer. Binden Sie sie an localhost und ergänzen Sie beides, bevor Sie einen Upload-Dienst in Betrieb nehmen. Vom Client gelieferte MIME-Typen sind nur ein vorläufiger Filter; validieren Sie den tatsächlichen Dateiinhalt, bevor Sie Uploads verarbeiten oder ausliefern.
// index.js
const express = require('express')
const multer = require('multer')
const path = require('path')
const fs = require('fs')
const crypto = require('crypto')
const sqlite3 = require('sqlite3').verbose()
const app = express()
/* -------------------------------------------------------------------------- */
/* Database */
/* -------------------------------------------------------------------------- */
const db = new sqlite3.Database('deduplication.db')
const createFilesTable = `CREATE TABLE IF NOT EXISTS files (
hash TEXT PRIMARY KEY,
original_name TEXT,
filename TEXT,
file_path TEXT,
size INTEGER,
upload_date TEXT
)`
/* -------------------------------------------------------------------------- */
/* Multer configuration */
/* -------------------------------------------------------------------------- */
const storage = multer.diskStorage({
destination(req, file, cb) {
const dir = 'uploads'
fs.mkdirSync(dir, { recursive: true })
cb(null, dir)
},
filename(req, file, cb) {
const unique = Date.now() + '-' + Math.round(Math.random() * 1e9)
cb(null, `${file.fieldname}-${unique}${path.extname(file.originalname)}`)
},
})
const MAX_SIZE = 10 * 1024 * 1024 // 10 MB
const upload = multer({
storage,
limits: { fileSize: MAX_SIZE, files: 1 },
fileFilter(req, file, cb) {
const allowed = ['image/jpeg', 'image/png', 'image/gif', 'application/pdf']
cb(null, allowed.includes(file.mimetype))
},
})
/* -------------------------------------------------------------------------- */
/* Helper: stream hashing */
/* -------------------------------------------------------------------------- */
function hashFile(filePath) {
return new Promise((resolve, reject) => {
const hash = crypto.createHash('sha256')
const stream = fs.createReadStream(filePath)
stream.on('data', (chunk) => hash.update(chunk))
stream.on('end', () => resolve(hash.digest('hex')))
stream.on('error', reject)
})
}
function removeUpload(filePath) {
fs.unlink(filePath, (error) => {
if (error && error.code !== 'ENOENT') console.error('Upload cleanup failed:', error.code)
})
}
/* -------------------------------------------------------------------------- */
/* Routes */
/* -------------------------------------------------------------------------- */
app.post('/upload', (req, res) => {
upload.single('file')(req, res, async (err) => {
if (err instanceof multer.MulterError) {
return res.status(400).json({ error: 'Upload rejected by size or file-count limits' })
}
if (err) {
return res.status(500).json({ error: 'Upload failed' })
}
if (!req.file) {
return res.status(400).json({ error: 'No file uploaded' })
}
const { path: filePath, originalname, filename, size } = req.file
try {
const digest = await hashFile(filePath)
// The unique key arbitrates concurrent identical uploads atomically.
db.run(
`INSERT INTO files (hash, original_name, filename, file_path, size, upload_date)
VALUES (?, ?, ?, ?, ?, datetime('now')) ON CONFLICT(hash) DO NOTHING`,
[digest, originalname, filename, filePath, size],
function (insertErr) {
if (insertErr) {
removeUpload(filePath)
return res.status(500).json({ error: 'Could not store file metadata' })
}
if (this.changes === 0) {
removeUpload(filePath)
return res.status(409).json({ error: 'Duplicate file detected', hash: digest })
}
return res.json({ message: 'File uploaded successfully', hash: digest, size })
},
)
} catch (hashErr) {
removeUpload(filePath)
res.status(500).json({ error: 'Could not hash the uploaded file' })
}
})
})
app.get('/files', (req, res) => {
db.all('SELECT original_name as name, hash, size, upload_date FROM files', (err, rows) => {
if (err) return res.status(500).json({ error: 'Could not list files' })
res.json(rows)
})
})
/* -------------------------------------------------------------------------- */
/* Start server */
/* -------------------------------------------------------------------------- */
const PORT = process.env.PORT || 3000
db.exec(createFilesTable, (error) => {
if (error) {
console.error('Could not initialize the database')
db.close()
process.exitCode = 1
return
}
app.listen(PORT, '127.0.0.1', () => {
console.log(`Server listening on http://localhost:${PORT}`)
})
})
Große Dateien effizient verarbeiten
hashFile streamt Daten und hält seinen Arbeitspuffer begrenzt. Jeder Aufruf von hash.update() beansprucht
die CPU weiterhin synchron, während das Paket sqlite3 die Datenbankarbeit asynchron erledigt.
Für Workloads im Gigabyte-Bereich sollten Sie Hintergrund-Worker einsetzen und die Zahl gleichzeitiger
Uploads beschränken. Das Beispiel begrenzt jeden Upload bewusst auf 10 MB.
Tipps zu Performance, Storage und Sicherheit
- Legen Sie einen Index auf der Spalte
hashan, damit Abfragen schneller werden, wenn Sie zu PostgreSQL oder einer anderen vollwertigen Datenbank migrieren. - Bewahren Sie Ihr Upload-Verzeichnis außerhalb des Web-Roots auf und sichern Sie es getrennt von Ihrer Metadaten-Datenbank.
- Cachen Sie kürzlich verwendete Hashes in Redis, wenn Ihr Workload viele nahezu identische Dateien umfasst (zum Beispiel Benutzer-Avatare).
- Bereinigen Sie Dateinamen, um Path-Traversal-Schwachstellen zu verhindern, falls Sie von Benutzern bereitgestellte Namen direkt in Dateisystemoperationen verwenden (unser Beispiel nutzt allerdings einen generierten Dateinamen).
- Aktualisieren Sie Abhängigkeiten regelmäßig, um bekannte Schwachstellen zu schließen.
Fazit
Ein hash-basierter Ansatz zur Deduplizierung ist kompakt, präzise und lässt sich leicht an jeden bestehenden Upload-Endpunkt anfügen. Mit dem Wechsel zu SHA-256 und gestreamten Dateizugriffen erhalten Sie Kollisionsresistenz und konstanten Speicherverbrauch mit nur einer Handvoll Codezeilen.
Wenn Sie das Hashing lieber vollständig auslagern möchten, kann unser Robot 🤖 /file/hash im Service für Medienkatalogisierung SHA-256-Prüfsummen (und viele weitere) für Sie erzeugen – ganz ohne Serverwartung.
