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:
| PK | SK | attributes |
|---|---|---|
| POST#a91f | META | likeTally (Number), body, authorId, createdAt |
| PK | SK | attributes |
|---|---|---|
| POST#a91f | LIKE#USER#7c20 | likedAt |
META item 上的 likeTally 屬性就是引用計數。每個 LIKE# item 是一個子項。把兩者都
放在 PK = "POST#a91f" 下,意味著當你確實想要那個列表時,一次 Query 就能把帖子和它的
點贊者一起取出來。
原子地遞增計數
DynamoDB 用一個 ADD(或 SET x = x + :n)update 運算式來遞增一個數字——這是一個
原子計數器:DynamoDB 在伺服器端施加這個增量,你不用先讀當前值,所以並行的遞增不會互相
覆蓋。
(AWS:原子計數器)
問題在於:給帖子點贊是對 兩個 item 的 兩次 寫入——建立 LIKE# item,並給 META
上的 likeTally 加 1。如果點贊落地了但遞增失敗了,這個合計就永遠錯了。你需要兩者皆有
或兩者皆無。
這正是 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"}}
}
}
]
}Put 和 Update 一起提交。如果任一失敗,DynamoDB 把兩者都回滾,並返回一個
TransactionCanceledException。
防範重複計數
真正的 bug 不是一個寫了一半的點贊——事務把那個擋住了。它是 同一個使用者點了兩次,或者
一次用戶端重試重放了請求。每次重放都再加一個 1,likeTally 就悄悄漂移到真實計數之上。
Put 上的 ConditionExpression: attribute_not_exists(SK) 就是那道護欄。如果那個使用者的
LIKE# item 已經存在,Put 的 condition 就失敗,整個事務被取消,而且——關鍵地——那個
ADD 從不執行。每個使用者一個點贊,由 key 強制執行。
在
DynamoDB 運算式構建器裡構建並複製這些 update 和
condition 運算式——帶上正確的 ExpressionAttributeValues 和 attribute_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。

要在一組有界的帖子上做一次真正的對賬——"哪些合計和它們的子項數對不上?"——DynoTable 的
SQL Workbench 在你已載入的行上於用戶端跑 GROUP BY 和 join,而這正是普通 PartiQL 表達
不了的。
陷阱與後續步驟
- 別在帶外維護計數(一個每晚重數一遍的 Lambda)。那是給一條本應從一開始就用事務的寫入 路徑打的創可貼。
- 盯住熱分割區。 一篇紅得發紫的帖子會把每一個點贊——以及每一次合計遞增——都集中到一個 partition key 上。計數是對的;分割區可能仍然被限流。
- 少對賬,外科手術式地修。 如果每次改動都加了 condition,漂移應該接近零。把一次對不上 當作一個要去找的 bug,而不是一個要去覆蓋的數字。
延伸閱讀:單表設計瞭解為什麼帖子和點贊共享一個分割區,以及 Query vs Scan瞭解為什麼在讀取時去數子項正是你要避開的那個模式。
然後 下載 DynoTable,去檢查這些 item 集合,並對照你自己的表核驗你的合計。


