中階閱讀時間 1 分鐘

DynamoDB 原子計數器

原子計數器是一個數字屬性,你用單一次 UpdateItem 呼叫就地遞增它——不必先讀取,沒有讀-改-寫的競態。DynamoDB 依抵達順序套用每一次遞增,永遠不讓兩個寫入者覆蓋彼此的計數。

什麼是 DynamoDB 原子計數器?

DynamoDB 原子計數器是一個數字屬性,你用單一次 UpdateItem 呼叫、搭配 ADD(或 SET x = x + :n)更新表達式就地遞增它。DynamoDB 會在伺服器端讀取、相加並寫入該值,所以並行的寫入者會序列化而不會發生遺失更新——但它並非冪等,因此一次重試的呼叫會遞增兩次。

  • ADD(或 SET x = x + :n)在一次呼叫裡遞增。 DynamoDB 在伺服器端讀取、相加、寫入——並行的呼叫者序列化,沒有遺失更新。
  • 不必先讀取。 從 SQL 過來你會先 SELECTUPDATE;在這裡你完全跳過讀取,而操作在並行下仍然安全。
  • 原子計數器不是冪等的。 一次重試的 UpdateItem 會再遞增一次。如果你無法容忍超計或漏計,就用條件式更新。
  • 對一個不存在的屬性做 ADD 會從 0 開始,所以最一開始的遞增就直接成立——不需要種子寫入。

讀-改-寫的問題

假設你追蹤一支影片的觀看數。那個天真的直覺,直接從 SQL 來,是:GetItem、在你的應用裡加一、把新的總數 PutItem 回去。

兩位觀眾同時按播放。兩者都讀到 views = 41。兩者都寫入 42。你計到了一次觀看,不是兩次。那是一次遺失更新——典型的並行地雷,而且要等你有了流量它才現身。

在 SQL 裡你會用 UPDATE videos SET views = views + 1 來閃過它,把算術推進資料庫。DynamoDB 有相同的招式,而那正是原子計數器的整個重點。

在一次呼叫裡遞增

建模一個每影片的統計項目。分割鍵 VID#<id>、排序鍵 STATS#TOTAL,帶一個數字 play_count

PKSKplay_count
"VID#9f3a""STATS#TOTAL"41

要登記一次播放,發送一次帶 ADD 子句的 UpdateItem

# UpdateItem
Key               PK = "VID#9f3a", SK = "STATS#TOTAL"
UpdateExpression  ADD play_count :one
Values            :one = 1

DynamoDB 在單一次伺服器端操作裡讀取 play_count、加 1、寫入結果。沒有讓另一個寫入者溜進來的窗口。十次並行播放每次都產生 +10——那就是「原子」替你買到的東西。

你可以用 DynamoDB Expression Builder 建立並複製這個確切的表達式——名稱、值與全部四種子句類型。

ADD 即使在 play_count 還不存在時也能運作:DynamoDB 把一個缺席的數字屬性當作 0,所以第一次播放就把它建立為 1。不必另外做種子寫入。(AWS:使用更新表達式

ADD vs SET +:選一個

兩個表達式做相同的算術。AWS 為一般用途推薦 SET,因為它能跟其他 SET 動作組合,而且讀起來更明確。(AWS:使用更新表達式

ADD play_count :oneSET play_count = play_count + :one
缺席的屬性建立它,從 0 開始報錯——需要 if_not_exists
資料型別只有數字和集合數字(以及更多)透過 SET
SET 結合獨立的子句一個 SET 子句,以逗號分隔
AWS 指引對計數器沒問題推薦的預設

如果屬性可能不存在而你想用 SET,就守衛它:SET play_count = if_not_exists(play_count, :zero) + :one。用 ADD 你就跳過那個——它免費地從 0 種起。

在 DynoTable 裡做

打開項目、編輯 play_count,你就能看著一次原子遞增落地,而不必手寫 JSON——更新面板替你產出 ADD 表達式,並在它提交的那一刻顯示新值。

陷阱:計數器不是冪等的

這就是在生產環境裡咬團隊的部分。一個原子計數器每一次 UpdateItem 執行都會遞增。(AWS:操作項目

想像一個網路抖動:你發送遞增,連線在回應回來之前斷了,而你不知道它有沒有落地。你重試。如果第一次呼叫確實成功了,你現在就把那次播放計了兩次。

對影片觀看數那沒問題——百萬次播放裡的幾次重複計數傷不了誰,而 AWS 正是把這個「追蹤訪客」的案例稱為原子計數器的典型用途。(AWS:操作項目

但對任何必須精確的東西它就不沒問題:你會超賣的庫存、你會重複花掉的點數、你會搞壞的餘額。在那裡,伸手去抓條件式更新。

當你需要精確性:條件式更新

一個條件式更新是冪等的——如果你以你正在改變的同一個屬性作為條件。把 play_count 遞增到 42,但只在它目前是 41 時:

# UpdateItem
Key                  PK = "VID#9f3a", SK = "STATS#TOTAL"
UpdateExpression     SET play_count = :next
ConditionExpression  play_count = :current
Values               :next = 42, :current = 41

現在重試是安全的:如果第一次寫入已經把 play_count 移到 42,那麼條件 play_count = 41 在第二次就失敗,什麼都不變。(AWS:操作項目

代價是並行。兩個寫入者在同一個條件上競爭,意味著一個贏、一個拿到一個 ConditionalCheckFailedException 去重試——你用無條件計數器的輸送量換來了正確性。對精確、有爭用的計數器,那是對的取捨。對觀看數,它是過度設計。

陷阱

  • 一個熱項目。 一個單一的計數器列是一個分割鍵。一支猛擊 VID#9f3a / STATS#TOTAL 的爆紅影片,可能撞上每分割的寫入上限。把它分片:把寫入散布到 STATS#TOTAL#0..N,並在讀取時加總。
  • 沒有批次遞增。 BatchWriteItem 只能 put/delete——它跑不了更新表達式。計數器走 UpdateItem,一次一個項目。
  • ADD 只有數字和集合。 它碰不了字串或布林值;那是 SET 的事。完整的屬性模型見 DynamoDB 資料型別

下一步

原子計數器是一種寫入模式;你怎麼把聚合回來是一個建模問題——見單表設計,把統計項目擺在它們父項目旁邊,以及 Query 與 Scan,好讓彙整一個分片計數器仍然是一次 Query

DynamoDB Expression Builder 裡草擬並複製那個遞增,然後試用 DynoTable,對你自己的表執行原子更新,看著計數移動。

已更新