Fortgeschritten5 Min. Lesezeit

DynamoDB-Reference-Counts

Ein Reference-Count ist eine Zahl, die du auf einem Parent-Item speicherst und die nachverfolgt, wie viele Child-Items auf es zeigen — Likes auf einem Post, Mitglieder in einem Workspace, Antworten auf einem Kommentar. Du hältst sie, weil das Zählen der Children bei jedem Read zu teuer ist.

Wie pflegt man einen Count in DynamoDB?

Speichere die laufende Summe als Zahl auf dem Parent-Item und aktualisiere sie in demselben Write, der das Child erstellt. Ein sorgt dafür, dass beide landen oder keiner, und eine Bedingung auf dem Child-Write verhindert, dass Wiederholungen doppelt zählen — sodass ein einzelnes GetItem eine akkurate Zählung zurückgibt.

  • Zähle Children nicht zur Read-Zeit. Eine Query, um Likes zu zählen, zahlt für jedes Like-Item, das sie scannt. Speichere die Summe auf dem Post und lies stattdessen ein Item.
  • Pflege den Count dort, wo das Child geschrieben wird, nicht danach. Bump ihn in derselben Operation, die das Child erstellt, sodass die beiden nie auseinanderdriften.
  • Verwende eine Transaktion, wenn der Write und der Bump verschiedene Items berühren. Ein Like ist ein Item, der Count lebt auf einem anderen — TransactWriteItems sorgt dafür, dass beide landen oder keiner.
  • Das Footgun ist Doppelzählung. Ein wiederholter oder duplizierter Like, der das Inkrement erneut ausführt, bläht die Zahl auf. Sichere den Child-Write mit einer Bedingung ab.

Warum überhaupt zählen

Von SQL kommend würdest du nie eine Like-Anzahl speichern — du würdest SELECT COUNT(*) FROM likes WHERE post_id = ? machen und einen Index das billig machen lassen. DynamoDB hat kein COUNT(*), das das Lesen von Items überspringt.

Eine Query über die Likes eines Posts liest — und berechnet — jedes Like-Item in dieser Partition, selbst wenn du nur die Zahl willst. Bei einem viralen Post sind das tausende RCUs, um „wie viele Likes?" zu beantworten. Das ist das Read-Footgun, für das Reference-Counts existieren, um es zu töten.

Also denormalisierst du: Du speicherst die laufende Summe auf dem Post selbst. Den Count zu lesen wird ein einzelnes GetItem. Der Preis ist, dass du jetzt dafür verantwortlich bist, ihn akkurat zu halten.

Die Items modellieren

Zwei Item-Typen teilen sich eine Partition, sodass der Post und seine Likes in einer Item-Collection liegen. Erfundene Keys:

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

Das likeTally-Attribut auf dem META-Item ist der Reference-Count. Jedes LIKE#-Item ist ein Child. Beide unter PK = "POST#a91f" zu legen bedeutet, dass eine einzelne Query den Post und seine Liker zusammen abrufen kann, wenn du die Liste haben willst.

Den Count atomar bumpen

DynamoDB inkrementiert eine Zahl mit einer ADD- (oder SET x = x + :n-) Update-Expression — das ist ein Atomic Counter: DynamoDB wendet das Delta server-seitig an, ohne dass du zuvor den aktuellen Wert liest, sodass nebenläufige Inkremente sich nicht gegenseitig überschreiben. (AWS: Atomic Counters)

Das Problem: Einen Post zu liken sind zwei Writes auf zwei Items — das LIKE#-Item erstellen und 1 zu likeTally auf META addieren. Wenn der Like landet, aber der Bump scheitert, ist die Zählung für immer falsch. Du brauchst beide oder keinen.

Genau das garantiert TransactWriteItems — Alles-oder-nichts über mehrere Items, und es bricht die ganze Transaktion ab, wenn irgendein Item nebenläufig geändert wird (AWS: Pessimistic Locking mit Transaktionen):

{
  "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"}}
      }
    }
  ]
}

Das Put und das Update committen zusammen. Wenn eines scheitert, rollt DynamoDB beide zurück und gibt eine TransactionCanceledException zurück.

Gegen Doppelzählung absichern

Der eigentliche Bug ist kein halb geschriebener Like — die Transaktion verhindert das. Es ist derselbe Nutzer, der zweimal liked, oder ein Client-Retry, der den Request erneut abspielt. Jede Wiederholung addiert eine weitere 1, und likeTally driftet still über die wahre Zählung hinaus.

Die ConditionExpression: attribute_not_exists(SK) auf dem Put ist die Absicherung. Wenn das LIKE#-Item dieses Nutzers bereits existiert, scheitert die Bedingung des Put, die ganze Transaktion wird abgebrochen und — entscheidend — das ADD läuft nie. Ein Like pro Nutzer, durch den Key erzwungen.

Baue und kopiere diese Update- und Condition-Expressions — mit den richtigen ExpressionAttributeValues und der attribute_not_exists-Absicherung — im DynamoDB Expression Builder, statt das JSON von Hand zusammenzusetzen.

Unlike, und die Kosten

Einen Like zu entfernen ist das Spiegelbild: Delete das LIKE#-Item mit ConditionExpression: attribute_exists(SK) und ADD likeTally :minusOne in derselben Transaktion. Die Bedingung stoppt, dass ein doppeltes Unlike die Zählung ins Negative treibt.

Kenne den Preis. Ein transaktionaler Write kostet 2 WCUs pro Item für Items bis 1 KB — eine zum Vorbereiten, eine zum Committen — gegenüber 1 WCU für einen einfachen Write. Ein Like sind zwei Items, also ist jeder Like ungefähr vier WCUs. Günstig pro Aktion, aber gut zu wissen, bevor ein Promi-Post einen Like-Sturm abbekommt.

In DynoTable ansehen

Wenn du vermutest, dass eine Zählung gedriftet ist, willst du das gespeicherte likeTally gegen die tatsächliche Anzahl der LIKE#-Children vergleichen — ohne eine Zähl-Query in Prod laufen zu lassen.

Das Post-META-Item neben seinen LIKE#-Children in einer Item-Collection, sodass du die gespeicherte Zählung gegen die echte Child-Anzahl abschätzen kannst.
Das Post-META-Item neben seinen LIKE#-Children in einer Item-Collection, sodass du die gespeicherte Zählung gegen die echte Child-Anzahl abschätzen kannst.

Für eine echte Abstimmung über eine begrenzte Menge von Posts — „welche Zählungen stimmen nicht mit ihren Child-Anzahlen überein?" — führt die SQL-Workbench von DynoTable das GROUP BY und den Join client-seitig über die geladenen Zeilen aus, was reines PartiQL nicht ausdrücken kann.

Fallstricke und nächste Schritte

  • Pflege den Count nicht out-of-band (ein Lambda, der nächtlich neu zählt). Das ist ein Pflaster über einem Schreibpfad, der von Anfang an transaktional hätte sein sollen.
  • Achte auf Hot Partitions. Ein einzelner wild populärer Post konzentriert jeden Like — und jeden Zähl-Bump — auf einen Partition Key. Die Zählung ist korrekt; die Partition kann trotzdem throtteln.
  • Stimme selten ab, repariere chirurgisch. Drift sollte nahe null sein, wenn jede Mutation konditioniert ist. Behandle eine Abweichung als Bug zum Finden, nicht als Zahl zum Überschreiben.

Verwandte Lektüre: Single-Table-Design dafür, warum sich der Post und die Likes eine Partition teilen, und Query vs Scan dafür, warum das Zählen von Children zur Read-Zeit das Muster ist, das du vermeidest.

Dann lade DynoTable herunter, um diese Item-Collections zu inspizieren und deine Zählungen gegen deine eigenen Tabellen zu verifizieren.

Aktualisiert