Chiavi Idempotenti vs Chiavi Surrogate (UUID): Guida alla Scelta Architetturale

Nel disegno di un database o di un’architettura a microservizi, una delle decisioni fondamentali riguarda il modo in cui identifichiamo univocamente le risorse. Tra le opzioni più discusse ci sono le chiavi surrogate, spesso implementate tramite UUID, e le chiavi idempotenti, chiamate anche chiavi naturali composte o chiavi di business.

Scegliere l’approccio corretto non è solo una questione di stile, ma influisce direttamente sulle performance, sulla coerenza dei dati e sulla resilienza del sistema contro le richieste duplicate. 


Cosa sono le Chiavi Surrogate (e perché dominano gli UUID)

Una chiave surrogata è un identificativo univoco generato artificialmente dal sistema che non ha alcun significato di business. L’esempio più celebre è l’UUID (Universally Unique Identifier). 

Un UUID identifica una riga o un record in modo totalmente slegato dal contenuto del record stesso. Se inseriamo due volte lo stesso identico utente, il sistema genererà due UUID differenti, creando due record distinti nel database. 

I vantaggi principali degli UUID includono la facilità di generazione decentralizzata senza collisioni e l’anonimato del dato, che non espone logiche di business negli URL o nelle API. Tuttavia, presentano lo svantaggio di non impedire intrinsecamente i duplicati a livello applicativo se non protetti da vincoli aggiuntivi. 


Cosa sono le Chiavi Idempotenti (Idempotenza di Default)

Una chiave idempotente è un identificativo derivato direttamente dai dati di business stessi. Spesso si tratta di una chiave composta, formata dall’unione di più attributi che insieme definiscono l’univocità assoluta di un’operazione o di un’entità. 

Viene definita chiave idempotente perché garantisce l’idempotenza per sua natura, ovvero “by default”. Se un client invia una richiesta di creazione e la rete fallisce prima di ricevere la risposta, il client potrebbe riprovare. Se il sistema utilizza una chiave idempotente derivata dai dati della richiesta, il secondo tentativo colpirà lo stesso identico identificativo. Il database rifiuterà il duplicato (violazione del vincolo di unicità) o aggiornerà l’esistente, evitando la duplicazione dei dati. 


Quando usare le Chiavi Surrogate (UUID)

Le chiavi surrogate sono la scelta ideale quando l’entità ha un ciclo di vita lungo, mutabile e indipendente dalle singole azioni transazionali. 

Esempio 1: L’Anagrafica Utente

Immaginiamo un’entità Utente. I suoi attributi (email, telefono, cognome) possono cambiare nel tempo. Utilizzare l’email come chiave primaria è rischioso se l’utente decide di modificarla. Un UUID come chiave primaria garantisce che le relazioni con le altre tabelle (ordini, log, preferenze) rimangano stabili e immutate. 

Implementazione SQL: 

sql

CREATE TABLE utenti (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    nome VARCHAR(100) NOT NULL,
    cognome VARCHAR(100) NOT NULL,
    email VARCHAR(255) NOT NULL UNIQUE,
    data_creazione TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

Implementazione Java (JPA / Spring Boot): 

java

@Entity
@Table(name = "utenti")
public class Utente {
    
    @Id
    @GeneratedValue
    private UUID id;
    
    @Column(nullable = false)
    private String nome;
    
    @Column(nullable = false)
    private String cognome;
    
    @Column(nullable = false, unique = true)
    private String email;
    
    // Getter, Setter e Costruttori
}

Quando usare le Chiavi Idempotenti

Le chiavi idempotenti e composte eccellono nei sistemi transazionali, nell’integrazione di sistemi terzi e nelle architetture orientate agli eventi (Event-Driven). 

Esempio 2: Elaborazione dei Pagamenti (Evitare Doppi Addebiti)

Immaginiamo un sistema che riceve transazioni finanziarie. Una chiave idempotente ideale può essere costruita unendo l’ID dell’account, l’importo e un identificativo temporale o un token generato dal client (Idempotency-Key). 

Se usassimo un UUID generato dal server a ogni inserimento, un timeout di rete spingerebbe il client a riprovare, causando un doppio addebito. Con una chiave idempotente basata sui dati della transazione, il database blocca il secondo tentativo sul nascere. 

Implementazione SQL:
In questo caso usiamo l’UUID come chiave primaria tecnica, ma imponiamo un vincolo di unicità composto (UNIQUE) basato sui dati di business per garantire l’idempotenza. 

sql

CREATE TABLE pagamenti (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    account_id UUID NOT NULL,
    importo DECIMAL(10,2) NOT NULL,
    idempotency_hash VARCHAR(64) NOT NULL,
    data_transazione TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    CONSTRAINT uk_pagamento_idempotente UNIQUE (account_id, idempotency_hash)
);

Implementazione Java:
A livello applicativo, possiamo generare un hash deterministico basato sui dati della transazione (es. usando SHA-256) per popolare l’idempotency_hash, intercettando l’eccezione di violazione del vincolo per rispondere in modo corretto (HTTP 200 o 409 a seconda delle necessità) senza rieseguire il pagamento. 

java

import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Base64;

public class IdempotencyService {

    public String calcolaChiaveIdempotente(String accountId, double importo, String riferimentoClient) {
        try {
            // Uniamo i dati sensibili della transazione in una stringa deterministica
            String datiDaVerificare = accountId + ":" + importo + ":" + riferimentoClient;
            
            MessageDigest digest = MessageDigest.getInstance("SHA-256");
            byte[] hash = digest.digest(datiDaVerificare.getBytes(StandardCharsets.UTF_8));
            
            return Base64.getEncoder().encodeToString(hash);
        } catch (NoSuchAlgorithmException e) {
            throw new RuntimeException("Errore nel calcolo dell'hash di idempotenza", e);
        }
    }
}

java

@Service
public class PagamentoService {

    @Autowired
    private PagamentoRepository repository;

    @Transactional
    public Pagamento elaboraPagamento(PagamentoRequest request) {
        IdempotencyService service = new IdempotencyService();
        String hashIdempotente = service.calcolaChiaveIdempotente(
            request.getAccountId(), request.getImporto(), request.getRiferimentoClient()
        );

        // Controlliamo se esiste già per gestire il fallimento in modo pulito
        if (repository.existsByAccountIdAndIdempotencyHash(request.getAccountId(), hashIdempotente)) {
            return repository.findByAccountIdAndIdempotencyHash(request.getAccountId(), hashIdempotente);
        }

        try {
            Pagamento nuovoPagamento = new Pagamento();
            nuovoPagamento.setAccountId(UUID.fromString(request.getAccountId()));
            nuovoPagamento.setImporto(request.getImporto());
            nuovoPagamento.setIdempotencyHash(hashIdempotente);
            return repository.save(nuovoPagamento);
        } catch (DataIntegrityViolationException e) {
            // Intercettato in caso di concorrenza estrema (Race Condition)
            return repository.findByAccountIdAndIdempotencyHash(request.getAccountId(), hashIdempotente);
        }
    }
}

Esempio 3: Tabelle di Raccordo Many-to-Many

Nelle tabelle associative non serve un UUID surrogato. La combinazione delle due chiavi esterne costituisce una chiave primaria composta e per sua natura idempotente. 

Implementazione SQL: 

sql

CREATE TABLE ordine_prodotti (
    ordine_id UUID REFERENCES ordini(id),
    prodotto_id UUID REFERENCES prodotti(id),
    quantita INT NOT NULL,
    PRIMARY KEY (ordine_id, prodotto_id)
);

Conclusione: Come Scegliere

Non esiste una soluzione migliore in assoluto, ma esiste la chiave giusta per il contesto giusto. 

  • Si raccomanda di usare le chiavi surrogate (UUID) per le entità core di business stabili, i cui dati interni sono soggetti a modifiche nel tempo e che richiedono relazioni stabili tra tabelle (es. Utenti, Prodotti, Ordini). 
  • Si raccomanda di preferire le chiavi idempotenti (composte o naturali) per gestire transazioni, comandi API sensibili alla duplicazione, messaggi nei broker di eventi e tabelle di legame. 

Spesso, l’architettura perfetta combina entrambe: un UUID come chiave primaria tecnica per mantenere pulite le relazioni interne del database, e un vincolo di unicità composto idempotente per proteggere il sistema dagli imprevisti della rete. 

Lascia un commento

Il tuo indirizzo email non sarà pubblicato. I campi obbligatori sono contrassegnati *