Intermedio6 min di lettura

Reference count in DynamoDB

Un reference count è un numero che memorizzi su un item genitore che traccia quanti item figli puntano a esso — like su un post, membri in un workspace, risposte a un commento. Lo mantieni perché contare i figli a ogni lettura è troppo costoso.

Come si mantiene un conteggio in DynamoDB?

Memorizza il totale corrente come numero sull'item genitore e aggiornalo nella stessa scrittura che crea il figlio. Una fa atterrare entrambi o nessuno, e una condizione sulla scrittura del figlio impedisce ai retry di contare due volte — così un singolo GetItem restituisce un conteggio accurato.

  • Non contare i figli in lettura. Una Query per contare i like paga per ogni item like che scansiona. Memorizza il totale sul post e leggi un solo item.
  • Mantieni il conteggio dove viene scritto il figlio, non dopo. Incrementalo nella stessa operazione che crea il figlio così che i due non divergano mai.
  • Usa una transazione quando la scrittura e l'incremento toccano item diversi. Un like è un item, il conteggio vive su un altro — TransactWriteItems fa atterrare entrambi o nessuno.
  • Il footgun è il doppio conteggio. Un like riprovato o duplicato che riesegue l'incremento gonfia il numero. Proteggi la scrittura del figlio con una condizione.

Perché contare affatto

Venendo da SQL, non memorizzeresti mai un conteggio di like — faresti SELECT COUNT(*) FROM likes WHERE post_id = ? e lasceresti che un index lo rendesse economico. DynamoDB non ha un COUNT(*) che salti la lettura degli item.

Una Query sui like di un post legge — e fattura — ogni item like in quella partizione, anche se vuoi solo il numero. Su un post virale sono migliaia di RCU per rispondere a "quanti like?" Quello è il footgun di lettura per uccidere il quale esistono i reference count.

Quindi denormalizzi: memorizzi il totale corrente sul post stesso. Leggere il conteggio diventa un singolo GetItem. Il costo è che ora sei tu a doverlo tenere accurato.

Modella gli item

Due tipi di item condividono una partizione così che il post e i suoi like stiano in un'unica item collection. Chiavi inventate:

Post item
PKSKattributes
POST#a91fMETAlikeTally (Number), body, authorId, createdAt
Like item
PKSKattributes
POST#a91fLIKE#USER#7c20likedAt

L'attributo likeTally sull'item META è il reference count. Ogni item LIKE# è un figlio. Mettere entrambi sotto PK = "POST#a91f" significa che una singola Query può recuperare il post e chi l'ha messo like insieme quando vuoi effettivamente la lista.

Incrementa il conteggio in modo atomico

DynamoDB incrementa un numero con una update expression ADD (o SET x = x + :n) — questo è un atomic counter: DynamoDB applica il delta lato server senza che tu legga prima il valore corrente, così gli incrementi concorrenti non si calpestano a vicenda. (AWS: atomic counter)

Il problema: mettere like a un post sono due scritture su due item — crea l'item LIKE# e aggiunge 1 a likeTally su META. Se il like atterra ma l'incremento fallisce, il conteggio è sbagliato per sempre. Servono entrambi o nessuno.

È ciò che TransactWriteItems garantisce — tutto-o-niente su più item, e annulla l'intera transazione se un qualsiasi item viene modificato in modo concorrente (AWS: pessimistic locking con le transazioni):

{
  "TransactItems": [
    {
      "Put": {
        "TableName": "Social",
        "Item": {
          "PK": {"S": "POST#a91f"},
          "SK": {"S": "LIKE#USER#7c20"},
          "likedAt": {"N": "1750636800"}
        },
        "ConditionExpression": "attribute_not_exists(SK)"
      }
    },
    {
      "Update": {
        "TableName": "Social",
        "Key": {
          "PK": {"S": "POST#a91f"},
          "SK": {"S": "META"}
        },
        "UpdateExpression": "ADD likeTally :one",
        "ExpressionAttributeValues": {":one": {"N": "1"}}
      }
    }
  ]
}

Il Put e l'Update vengono committati insieme. Se uno dei due fallisce, DynamoDB fa il rollback di entrambi e restituisce una TransactionCanceledException.

Proteggiti dal doppio conteggio

Il vero bug non è un like scritto a metà — la transazione lo previene. È lo stesso utente che mette like due volte, o un retry del client che rigioca la richiesta. Ogni replay aggiunge un altro 1, e likeTally deriva silenziosamente sopra il conteggio reale.

La ConditionExpression: attribute_not_exists(SK) sul Put è la protezione. Se l'item LIKE# di quell'utente esiste già, la condizione del Put fallisce, l'intera transazione viene annullata e — cosa cruciale — l'ADD non viene mai eseguito. Un like per utente, imposto dalla chiave.

Costruisci e copia queste update e condition expression — con i giusti ExpressionAttributeValues e la protezione attribute_not_exists — nell'Expression Builder per DynamoDB invece di assemblare il JSON a mano.

Rimuovere il like, e il costo

Rimuovere un like è l'immagine speculare: Delete dell'item LIKE# con ConditionExpression: attribute_exists(SK), e ADD likeTally :minusOne nella stessa transazione. La condizione impedisce a un doppio unlike di spingere il conteggio in negativo.

Conosci il prezzo. Una scrittura transazionale costa 2 WCU per item per item fino a 1 KB — una per preparare, una per committare — contro 1 WCU per una scrittura normale. Un like sono due item, quindi ogni like è circa quattro WCU. Economico per azione, ma vale la pena saperlo prima che un post di una celebrità prenda una tempesta di like.

Vedilo in DynoTable

Quando sospetti che un conteggio sia derivato, vuoi confrontare il likeTally memorizzato con il numero effettivo di figli LIKE# — senza eseguire una query di conteggio in produzione.

L'item META del post accanto ai suoi figli LIKE# in un'unica item collection, così puoi confrontare a occhio il conteggio memorizzato con il conteggio reale dei figli.
L'item META del post accanto ai suoi figli LIKE# in un'unica item collection, così puoi confrontare a occhio il conteggio memorizzato con il conteggio reale dei figli.

Per una vera riconciliazione su un insieme limitato di post — "quali conteggi non corrispondono ai conteggi dei loro figli?" — la SQL Workbench di DynoTable esegue il GROUP BY e la join lato client sulle righe che hai caricato, cosa che PartiQL puro non può esprimere.

Insidie e prossimi passi

  • Non mantenere il conteggio fuori banda (un Lambda che ricontano di notte). È un cerotto su un percorso di scrittura che avrebbe dovuto essere transazionale fin dall'inizio.
  • Attento alle hot partition. Un singolo post enormemente popolare concentra ogni like — e ogni incremento del conteggio — su un'unica partition key. Il conteggio è corretto; la partizione può comunque andare in throttling.
  • Riconcilia di rado, ripara chirurgicamente. La deriva dovrebbe essere quasi nulla se ogni mutazione è condizionata. Tratta una discrepanza come un bug da trovare, non come un numero da sovrascrivere.

Letture correlate: single-table design per capire perché il post e i like condividono una partizione, e Query vs Scan per capire perché contare i figli in lettura è il pattern che stai evitando.

Poi scarica DynoTable per ispezionare queste item collection e verificare i tuoi conteggi sulle tue tabelle.

Aggiornato