DynamoDB 原子计数器:ADD 如何工作,以及何时失效
原子计数器是一个数值属性,你用单次
UpdateItem 调用就地增减它 —— 无需先读取,也没有读-改-写竞争。DynamoDB 按
到达顺序应用每一次增量,绝不让两个写入者互相覆盖对方的
计数。
什么是 DynamoDB 原子计数器?
DynamoDB 原子计数器是一个数值属性,你用单次 UpdateItem 调用、通过 ADD(或 SET x = x + :n)更新表达式就地对它做增量。DynamoDB 在服务器端读取、相加并写回该值,因此并发的写入者会串行化而不会丢失更新 —— 但它不具幂等性,因此一次被重试的调用会增量两次。
- 用
ADD(或SET x = x + :n)在一次调用中做增量。 DynamoDB 在 服务器端读取、相加并写回 —— 并发调用者串行化,无丢失更新。 - 无需先读取。 从 SQL 过来你会先
SELECT再UPDATE;这里你完全跳过 读取,且该操作在并发下依然安全。 - 原子计数器 不 具幂等性。 一次被重试的
UpdateItem会再次 增量。如果你无法容忍多计或少计,请使用。 ADD作用于一个不存在的属性时从 0 开始,因此第一次增量 就能正常工作 —— 无需播种写入。
读-改-写的问题
假设你追踪一个视频的观看次数。天真的直觉,直接来自 SQL,是:
GetItem,在你的应用里加一,把新的总数 PutItem 写回去。
两个观众同时点击播放。两者都读到 views = 41。两者都写入 42。你
只计了一次观看,而不是两次。那是一次丢失更新 —— 经典的并发
大坑,而且它直到你有了流量才会显现。
在 SQL 里你会用 UPDATE videos SET views = views + 1 来避开它,把
算术推进数据库。DynamoDB 有同样的招数,而且这正是
原子计数器的全部意义。
在一次调用中做增量
对每个视频的统计项目建模。分区键 VID#<id>,排序键 STATS#TOTAL,
带一个数值 play_count:
| PK | SK | play_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 与 SET +:选一个
两个表达式做同样的算术。AWS 推荐通用情况下用 SET,
因为它能与其他 SET 动作组合,且读起来更明确。(AWS:
使用更新表达式)
ADD play_count :one | SET 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 播种。
每次增量的写入成本
在 us-east-1 的按需模式下,每一次带 ADD 的 UpdateItem 都按写入后项大小
每 KB 1 WCU 计费(向上取整)。一行 900 字节的统计行,每登记一次播放要花
1 WCU;十次并发播放合起来仍然落成 10 WCU,而不是一次。把计数器分片到多个
分区上,挪动的是吞吐上限,并不改变按项计算的 WCU 账。用
项大小计算器给这一行定尺寸,再用
定价计算器按行费率算热路径。
在 DynoTable 中操作
打开统计项目以查看实时计数器,然后在 SQL Workbench 里用 SUM 和
GROUP BY 汇总一个分片计数器,看看跨每一个
STATS#TOTAL#0..N 行的总数。要草拟增量本身,用 Web 版的
DynamoDB 表达式构建器来组合
ADD UpdateItem 表达式,名称和值都包括在内。
陷阱:计数器不具幂等性
这里是在生产环境中会咬伤团队的部分。原子计数器每次
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, 每次调用一个项目。如果你必须原子地增减若干计数器,TransactWriteItems可以在一个请求中对最多 100 个项目运行 Update 动作, 写入成本约为双倍。 ADD仅限数字和集合。 它不会碰字符串或布尔值; 那是SET的活。参见 DynamoDB 数据类型了解完整的 属性模型。
后续步骤
原子计数器是一种写入模式;你如何 读回 聚合是一个建模
问题 —— 参见单表设计以把统计项目
保持在其父项目旁边,以及 Query 与 Scan,好让
汇总一个分片计数器仍是一次 Query。
在 DynamoDB 表达式构建器里草拟并复制增量,然后 试用 DynoTable,对你自己的表运行原子更新并 看着计数变化。