Fondamenti di mutua esclusione per integrazione basata su file - Best practice per lock file e claim atomici

· Aggiornato il: · · File Integration, Locking, Design, Sviluppo Windows

La mutua esclusione in integrazione basata su file diventa un problema in quasi tutte le configurazioni con cartelle condivise, batch notturni o hand-off tra processi. Le domande più cercate sono: un lock file da solo basta? Come impedisco a più worker di prendere lo stesso file? Come evito di leggere file ancora in scrittura?

In questo articolo guardiamo la mutua esclusione per integrazione basata su file attraverso i lenti di lock file, claim atomici, temp -> rename e idempotenza.

Il codice di questo articolo è pubblicato su GitHub come set di esempi completo, compilabile ed eseguibile (una libreria, una demo che mostra contesa di claim tra due worker e takeover di lease, e unit test che riproducono contesa, corruzione e lock obsoleti).

file-integration-locking-best-practices-komurasoft-style - komurasoft-blog-samples (GitHub)

1. La conclusione prima di tutto (in una riga)

  • La cosa più importante nell’integrazione basata su file è assicurare che nel momento in cui il nome finale diventa visibile, il file sia “sicuro da leggere”
  • Esprimi gli stati generating / published / processing / processed attraverso nomi file e directory
  • Se ci sono più worker, prendere un claim atomicamente prima di leggere
  • Usa lock file e lock del SO come ausilio, e lascia che l’idempotenza raccolga ciò che passa

In breve, la sostanza reale dell’integrazione basata su file non è tanto la mutua esclusione quanto il design di un protocollo di hand-off. Non è mai semplice quanto chiamare una funzione di lock e concludere.

2. Pattern di race che si verificano nell’integrazione basata su file

2.1. Leggere un file a metà scrittura

Se inizi a scrivere direttamente sotto il nome finale, succede questo incidente. Un file JSON manca della graffa di chiusura, un CSV è corto di righe, e uno ZIP è semplicemente corrotto.

ReceiverCartella condivisaSenderReceiverCartella condivisaSenderAncora incompletoRighe mancanti / fallimento parsing / elaborazione parzialeCrea orders.csv col nome finaleScrive righe da 1 a 5000Rileva orders.csvInizia a leggere immediatamenteScrive il resto

2.2. Più worker prendono lo stesso file contemporaneamente

Con un flusso “elenca la directory, apri qualsiasi cosa non elaborata”, due worker possono afferrare lo stesso file. Da qui iniziano doppi conteggi e invii duplicati.

incomingWorker 2Worker 1incomingWorker 2Worker 1Lo stesso input viene elaborato due volteTrova a.csvTrova a.csvInizia a leggereInizia a leggere

2.3. Tutti si fermano su un lock obsoleto

Un design che si limita a piazzare un lock file tende ad incepparsi dopo una terminazione anomala. Se non riesci a dire di chi sia il lock, se il proprietario è ancora vivo, o per quanto tempo è valido, tutti quelli a valle aspettano per sempre.

Worker Block fileWorker AWorker Block fileWorker ACrash quiNon riesce a dire se è obsoleto — tutti si fermanoCrea il lockControlla se il lock esisteRinuncia a elaborareContinua ad aspettare

3. Anti-pattern

3.1. Il check a due fasi Exists -> Create

Il problema qui è che “verificare” e “acquisire” sono operazioni separate. Un altro processo può inserirsi in mezzo, quindi non è affatto mutua esclusione.

File systemProcesso BProcesso AFile systemProcesso BProcesso AEntrambi procedonoControlla che non esista lockControlla che non esista lockNessunoNessunoCrea il lockCrea il lock

L’esempio cattivo tipico appare così.

if (!File.Exists(lockPath))
{
    File.WriteAllText(lockPath, Environment.ProcessId.ToString());
    ProcessFile();
}

Quel che serve è rendere “crea se assente” un’operazione singola. In .NET significa la famiglia FileMode.CreateNew; su sistemi POSIX, creazione atomica come O_CREAT | O_EXCL.

3.2. Scrivere direttamente sul nome finale

Se l’interpretazione del ricevitore è “una volta visibile quel nome, è sicuro da leggere”, hai già perso nel momento in cui inizi a scrivere direttamente sotto il nome finale. La regola base è: non equiparare essere visibile ad essere sicuro da leggere.

Il nome finale diventa visibileIl ricevitore lo rilevaIl sender sta ancora scrivendoLettura di dati incompleti
using var writer = OpenForWrite(finalPath); // finalPath diventa visibile qui
foreach (var row in rows)
{
    writer.WriteLine(row);
}

Questo approccio invita personalmente l’incidente del punto 2.1.

3.3. Trattare un file come completato quando la sua dimensione smette di cambiare

Questo sembra comodo ma è piuttosto precario. Copie di rete, pause lato sender, buffering e retry lo fanno oscillare di routine.

ReceiverCartella condivisaSenderReceiverCartella condivisaSenderGiudica erroneamente come completoInizia a copiare data.zipSi ferma a metàDimensione invariata da 10 secondiInizia a leggereRiprende la copia
if (currentLength == lastLength && stableSeconds >= 10)
{
    return Ready;
}

Se determini il completamento indovinando, le condivisioni e i file grandi ti faranno inciampare. Il completamento è molto più stabile quando dichiarato esplicitamente tramite manifest o file done.

3.4. Tutti aggiornano un file condiviso

Un design in cui tutti leggono e aggiornano un singolo status.csv o counter.json di solito finisce in “last writer wins”. Quando l’integrazione basata su file inizia a essere usata come database improvvisato, è lì che inizia a far male.

status.csvBatch BBatch Astatus.csvBatch BBatch AL'aggiornamento di A viene persoLegge v1Legge v1Scrive v2-AScrive v2-B

Esiste la via di fuga append-only, ma la sua semantica oscilla a seconda del file system e del layout di deployment. Se aggiornamenti condivisi sono richiesti, è meglio non forzare l’integrazione basata su file in questo punto.

3.5. Credere che le API di lock siano onnipotenti

Le API di lock contano, ma funzionano solo quando ogni partecipante segue le stesse regole. In integrazione eterogenea di sistemi, è più sicuro non fidarsi eccessivamente.

Note aggiuntive:

  • flock su Linux è un advisory lock, quindi una parte che ignora l’accordo può scrivere comunque
  • I byte-range lock di Windows sono ignorati dai file memory-mapped
  • In altre parole, non fare affidamento solo sui lock del SO per il design del completamento e della proprietà

4. Best practice

4.1. Pubblica tramite temp -> close -> rename / replace

L’approccio classico. Tieni il file sotto un nome temporaneo mentre viene generato, e passa al nome finale solo dopo averlo chiuso. Il ricevitore osserva solo i nomi finali.

Crea un nome temp univocoScrivi il contenuto completo su tempFlush / closeRinomina / sostituisci col nome finale nella stessa directoryIl ricevitore osserva solo i nomi finali

Punti chiave:

  • Metti temp e final nella stessa directory — almeno nello stesso volume / file system
  • Su Windows / .NET vale la pena considerare la famiglia File.Replace
  • Stabilisci il contratto che una volta visibile il nome finale, il contenuto è completo

Se metti temp su un altro disco, il rename degenera in una semplice copia, oppure Replace fallisce. Questo prerequisito è poco appariscente ma molto importante.

4.2. Rendi esplicito il completamento con un file done / manifest

Oltre ai dati stessi, dichiarare esplicitamente “cosa è stato completato” in un file separato stabilizza il ricevitore. È particolarmente efficace nell’integrazione eterogenea di sistemi.

Genera data.tmpPubblica come data.csvCrea data.done / manifest.jsonIl ricevitore rileva il done file / manifestVerifica nome file, dimensione e hash

Elementi utili da mettere nel manifest:

  • Nome file target
  • Dimensione
  • Hash
  • Conteggio record
  • Integration ID / idempotency key
  • Timestamp di generazione

L’ordine conta anche qui. Se piazzi il done file prima di pubblicare il payload, non è un avviso di completamento — è un preavviso di incidente.

4.3. Il ricevitore prende un claim in modo atomico

Se più worker osservano lo stesso incoming, “spostalo nella tua area prima di leggere” è l’approccio più chiaro. Solo il worker il cui rename da incoming a processing/<worker>/ ha successo può elaborare il file.

processingincomingWorker 2Worker 1processingincomingWorker 2Worker 1Solo chi riesce per primo prende la proprietàTrova a.csvTrova a.csvRinomina a.csvRinomina a.csv

A livello operativo, separare le directory rende anche le cose più tracciabili.

pubblicaclaimsuccessfailuretempincomingprocessingarchiveerror

Anche il rename di claim deve avvenire sullo stesso file system — questo è un prerequisito.

4.4. Se ti affidi a lock file, falli diventare lease

Se usi lock file, falli record di proprietà con scadenza, non semplici file vuoti. Un lock il cui proprietario è sconosciuto causerà inevitabilmente dispute in seguito.

lock.jsonownerIdhostpidacquiredAtexpiresAtheartbeatAt

Punti chiave:

  • Crealo in modo atomico
  • Usa la cessazione degli aggiornamenti come evidenza di giudizio di obsolescenza
  • La cancellazione, di regola, è eseguita solo dal creatore
  • Assumi che i rilasci falliranno, e decidi in anticipo la procedura di recovery

Un lock file è, in fondo, un token di cooperazione. Cercare di garantire la piena consistenza solo con quel foglietto di carta di solito finisce male.

4.5. Assumi idempotenza

La mutua esclusione conta, ma in operazione reale non puoi mai ridurre a zero “arriva occasionalmente due volte” o “rieseguito a metà”. Alla fine, un design che non si rompe quando riceve lo stesso input è ciò che salva.

SiNoInput + idempotency keyGià elaborato?Considera successo senza rieseguireEsegui l'elaborazioneRegistra nel processed ledger

Ad esempio, assegna a ogni file ricevuto un integration ID e registralo in un processed ledger. Se il design assicura che i risultati non vengano mai contati due volte anche quando l’esclusione viene forzata una volta, le operazioni diventano considerevolmente più semplici.

5. Pseudocodice (stralcio)

5.1. Il pattern di fallimento tipico

var lockPath = finalPath + ".lock";

if (!File.Exists(lockPath))
{
    File.WriteAllText(lockPath, "");
    using var writer = OpenForWrite(finalPath); // Scrive direttamente sul nome finale
    WritePayload(writer);

    File.Delete(lockPath);
}

Ci sono tre problemi.

  • Exists e WriteAllText sono operazioni separate
  • finalPath diventa visibile mentre è ancora in scrittura
  • Il lock resta in caso di terminazione anomala

5.2. Un esempio nella giusta direzione (grossolanamente abbozzato)

var tempPath = MakeTempPathSameDirectory(finalPath);
WritePayload(tempPath);
FlushAndClose(tempPath);

PublishByRenameOrReplace(tempPath, finalPath); // Assume stesso FS / stesso volume
PublishDoneFile(finalPath + ".done", new
{
    FileName = Path.GetFileName(finalPath),
    Size = GetFileSize(finalPath),
    Hash = ComputeHash(finalPath),
    IdempotencyKey = integrationId
});
if (!TryClaimBundleByRename(baseName, incomingDir, processingDir))
{
    return; // Un altro worker l'ha preso per primo
}

var manifest = ReadDoneFile(Path.Combine(processingDir, baseName + ".done"));
VerifyPayload(Path.Combine(processingDir, baseName), manifest);

if (AlreadyProcessed(manifest.IdempotencyKey))
{
    MoveBundle(processingDir, archiveDir, baseName);
    return;
}

Process(Path.Combine(processingDir, baseName));
RecordProcessed(manifest.IdempotencyKey);
MoveBundle(processingDir, archiveDir, baseName);

Quel che conta qui è l’ordine piuttosto che i dettagli implementativi. Tenere separati “scrittura”, “pubblicazione”, “presa in carico” e “registrazione come elaborato” rende molto più difficile rompere le cose.

6. Una guida approssimativa alla scelta

  • Single writer / single reader / stesso host: già temp -> rename ti porta abbastanza lontano
  • Consumatori multipli: aggiungi il rename di claim incoming -> processing
  • Sistemi eterogenei, NAS, cartelle condivise: più sicuro andare fino a manifest / done file e idempotenza
  • Writer multipli che aggiornano lo stesso stato logico: non forzare l’integrazione basata su file — considera anche un DB o una coda
  • I lock del SO sono efficaci entro un insieme omogeneo di app che condividono le stesse assunzioni, ma non sostituiscono un protocollo di hand-off

Quest’ultimo punto è anche un criterio di ritiro. Alcuni problemi diventano genuinamente dolorosi se fatti con i file.

7. Conclusione

La mutua esclusione nell’integrazione basata su file non riguarda chiamare una funzione di lock, ma definire transizioni di stato. Questo è il backbone dell’articolo. Esprimi generating / published / processing / processed attraverso nomi e directory, ed evita il check a due fasi Exists -> Create, scritture dirette sul nome finale, attese di stabilità dimensionale, aggiornamenti mutui di file condivisi e eccessivo affidamento sulle API di lock. Sopra a questo, combinare temp -> close -> rename / replace, file done / manifest, rename di claim, lease e idempotenza previene la maggior parte degli incidenti nelle cartelle condivise.

Il trucco nell’integrazione basata su file è non equiparare “può essere letto” con “può essere letto”. Solo separare queste due riduce drasticamente il tipo di incidente che capita nel mezzo della notte.

8. Riferimenti

Articoli recenti con gli stessi tag per approfondire argomenti vicini.

Queste pagine collocano l’argomento in un contesto più ampio di servizi e decisioni.

L’articolo è direttamente collegato ai servizi seguenti.

Domande frequenti

Domande che ricorrono nelle consulenze sull’argomento dell’articolo.

Un file lock da solo è sufficiente per un'integrazione basata su file sicura?
No. Le API di lock funzionano solo quando ogni partecipante segue le stesse regole: flock su Linux è un advisory lock che una parte non cooperante può semplicemente ignorare, e i byte-range lock di Windows sono ignorati dai file memory-mapped. La sostanza reale dell'integrazione basata su file è un protocollo di hand-off che esprima stati generating, published, processing e processed attraverso nomi file e directory, usando i lock del SO solo come ausilio e lasciando che l'idempotenza raccolga ciò che passa.
Come evito di leggere file che sono ancora in scrittura?
Pubblicare tramite temp -> close -> rename/replace: scrivi il contenuto completo con un nome temporaneo, chiudi il file, e solo dopo rinominalo con il nome finale nella stessa directory. Il ricevitore osserva solo i nomi finali, quindi nel momento in cui un nome finale diventa visibile, il contenuto è completo. Mantieni i percorsi temp e final sullo stesso volume, perché una rename tra dischi degenera in una copia. Per maggiore sicurezza, posiziona un file done o un manifest con nome file, dimensione e hash dopo aver pubblicato il payload.
Come impedisco a più worker di prendere lo stesso file?
Fai prendere a ciascun worker un claim in modo atomico prima di leggere: rinomina il file dalla directory incoming nella propria area di processing, ad esempio processing/<worker>/. Solo il worker il cui rename ha successo può elaborare il file, poiché un rename sullo stesso file system è atomico. Separare directory come incoming, processing, archive ed error rende anche le operazioni più tracciabili.
Perché controllare File.Exists prima di creare un lock file non è sicuro?
Perché verificare e acquisire sono due operazioni separate; un altro processo può inserirsi in mezzo, quindi non è affatto mutua esclusione: entrambi i processi possono finire per procedere. Ciò che serve è rendere create-if-absent un'operazione singola atomica: in .NET la famiglia FileMode.CreateNew, e su sistemi POSIX creazione atomica con O_CREAT | O_EXCL. Se ti affidi a lock file, falli diventare lease con owner ID e scadenza invece di semplici file vuoti.

Profilo dell’autore

Pagina di presentazione dell’autore dell’articolo.

Go Komura

Rappresentante di KomuraSoft LLC

Specializzato nello sviluppo di software Windows, nella consulenza tecnica e nell’analisi dei malfunzionamenti, soprattutto nei progetti con sistemi esistenti e guasti difficili da riprodurre.

Torna al blog