进阶阅读约 2 分钟

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 表达式构建器构建并拷贝这个精确的表达式 —— 名称、值,以及全部四种子句类型。

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 表达式构建器里起草并拷贝那次自增,然后试用 DynoTable,对着你自己的表运行原子更新,看计数移动起来。

更新于