中階閱讀時間 2 分鐘

DynamoDB 引用計數

引用計數 是你存在父 item 上的一個數字,記錄有多少子 item 指向它——一篇帖子的點贊、 一個 workspace 的成員、一條 comment 的回覆。你之所以保留它,是因為每次讀取都去數一遍 子項太貴了。

如何在 DynamoDB 中維護一個計數?

將執行中的總數作為數字存在父 item 上,並在建立子項的同一次寫入中更新它。一個 保證兩者要麼都落地、要麼都不落,而子項寫入上的條件運算式能阻止重試導致的重複計數——這樣一次 GetItem 就能返回準確的計數。

  • 別在讀取時去數子項。 一次 Query 去數點贊,會為它掃到的每個點贊 item 付錢。把 總數存在帖子上,改為讀一個 item。
  • 在寫入子項的地方維護計數,而不是寫完之後。在建立子項的同一次操作裡把它加上,這樣 兩者永遠不會漂移。
  • 當寫入和遞增觸及不同 item 時,用事務。 一個點贊是一個 item,計數住在另一個上—— TransactWriteItems 讓兩者要麼都落地、要麼都不落。
  • 自坑是重複計數。 一個被重試或重複的點贊重新跑了遞增,就把數字撐大了。用一個 condition 給子項寫入加護欄。

為什麼要計數

從 SQL 過來,你絕不會去存一個點贊數——你會 SELECT COUNT(*) FROM likes WHERE post_id = ? 並讓索引把它變便宜。DynamoDB 沒有可以 跳過讀取 item 的 COUNT(*)

一次對帖子點讚的 Query 會讀取——並計費——那個分割區裡的每一個點贊 item,哪怕你只想要那個 數字。在一篇爆款帖上,回答"有多少點贊?"要花掉數千個 RCU。這就是引用計數存在要消滅的那個 讀取自坑。

所以你 非正規化:把執行中的總數存在帖子本身上。讀取計數就變成一次 GetItem。代價是 你現在要自己負責保持它準確。

給 item 建模

兩種 item 型別共享一個分割區,讓帖子和它的點贊坐在同一個 item 集合裡。杜撰的 key:

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

META item 上的 likeTally 屬性就是引用計數。每個 LIKE# item 是一個子項。把兩者都 放在 PK = "POST#a91f" 下,意味著當你確實想要那個列表時,一次 Query 就能把帖子和它的 點贊者一起取出來。

原子地遞增計數

DynamoDB 用一個 ADD(或 SET x = x + :n)update 運算式來遞增一個數字——這是一個 原子計數器:DynamoDB 在伺服器端施加這個增量,你不用先讀當前值,所以並行的遞增不會互相 覆蓋。 (AWS:原子計數器

問題在於:給帖子點贊是對 兩個 item 的 兩次 寫入——建立 LIKE# item,並給 META 上的 likeTally1。如果點贊落地了但遞增失敗了,這個合計就永遠錯了。你需要兩者皆有 或兩者皆無。

這正是 TransactWriteItems 所保證的——跨多個 item 全有或全無,並且只要任一 item 被並行 修改,它就取消整個事務 (AWS:用事務做悲觀鎖):

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

PutUpdate 一起提交。如果任一失敗,DynamoDB 把兩者都回滾,並返回一個 TransactionCanceledException

防範重複計數

真正的 bug 不是一個寫了一半的點贊——事務把那個擋住了。它是 同一個使用者點了兩次,或者 一次用戶端重試重放了請求。每次重放都再加一個 1likeTally 就悄悄漂移到真實計數之上。

Put 上的 ConditionExpression: attribute_not_exists(SK) 就是那道護欄。如果那個使用者的 LIKE# item 已經存在,Put 的 condition 就失敗,整個事務被取消,而且——關鍵地——那個 ADD 從不執行。每個使用者一個點贊,由 key 強制執行。

DynamoDB 運算式構建器裡構建並複製這些 update 和 condition 運算式——帶上正確的 ExpressionAttributeValuesattribute_not_exists 護欄——而不是手工拼那段 JSON。

取消點贊,以及它的成本

移除一個點贊是映象操作:Delete 那個 LIKE# item,帶 ConditionExpression: attribute_exists(SK),並在同一個事務裡 ADD likeTally :minusOne。 這個 condition 阻止一次雙重取消點贊把合計壓到負數。

要清楚價格。一次事務性寫入對最多 1 KB 的 item 花 每個 item 2 個 WCU——一個用於準備, 一個用於提交——相比之下一次普通寫入是 1 WCU。一個點贊是兩個 item,所以每個點贊大約是四個 WCU。每個動作很便宜,但在一篇名人帖招來點贊風暴之前值得知道。

在 DynoTable 中檢視

當你懷疑一個合計漂移了,你想把存著的 likeTally 和實際的 LIKE# 子項數量比一比——而不用 在生產環境跑一個計數 query。

帖子的 META item 與它的 LIKE# 子項在同一個 item 集合裡並排,這樣你就能把存著的合計和真實的子項數目用眼睛對上。
帖子的 META item 與它的 LIKE# 子項在同一個 item 集合裡並排,這樣你就能把存著的合計和真實的子項數目用眼睛對上。

要在一組有界的帖子上做一次真正的對賬——"哪些合計和它們的子項數對不上?"——DynoTable 的 SQL Workbench 在你已載入的行上於用戶端跑 GROUP BY 和 join,而這正是普通 PartiQL 表達 不了的。

陷阱與後續步驟

  • 別在帶外維護計數(一個每晚重數一遍的 Lambda)。那是給一條本應從一開始就用事務的寫入 路徑打的創可貼。
  • 盯住熱分割區。 一篇紅得發紫的帖子會把每一個點贊——以及每一次合計遞增——都集中到一個 partition key 上。計數是對的;分割區可能仍然被限流。
  • 少對賬,外科手術式地修。 如果每次改動都加了 condition,漂移應該接近零。把一次對不上 當作一個要去找的 bug,而不是一個要去覆蓋的數字。

延伸閱讀:單表設計瞭解為什麼帖子和點贊共享一個分割區,以及 Query vs Scan瞭解為什麼在讀取時去數子項正是你要避開的那個模式。

然後 下載 DynoTable,去檢查這些 item 集合,並對照你自己的表核驗你的合計。

已更新