进阶阅读约 2 分钟

DynamoDB 条件表达式

条件表达式是 DynamoDB 在提交你的写入之前对现有项求值的一个谓词。如果谓词为假,写入被拒绝, 什么都不改变。它是 DynamoDB 对写入最接近 WHERE 子句的东西——也是强制不变量的唯一安全方式。

DynamoDB 条件表达式如何工作?

条件表达式是 DynamoDB 在提交一次写入之前、在服务端针对当前项求值的一个谓词。如果它为真,写入继续进行;如果为假,写入以 ConditionalCheckFailedException 被拒绝,什么都不改变。它把检查与变更折进同一个原子操作,因此并发的调用者无法竞争一次陈旧读取。

  • 它是守卫,不是过滤。 ConditionExpression 在服务端针对当前项运行;一个为假的结果以 ConditionalCheckFailedException 让写入失败。
  • 它取代读后写。 没有 SELECTUPDATE 的往返——检查和变更是一个原子操作,所以两个调用者 无法竞争。
  • 拒绝免费,运行不免费。 一次失败的条件写入仍然消耗写入容量。这个保证花费的成本与它阻止的那次 写入相同。

从 SQL 过来,你会读那一行,在应用代码里检查它,然后更新。在 DynamoDB 中,读与写之间那道缝隙是一个 等待并发调用者的数据损坏 bug。条件表达式合上了这道缝隙。

它们适用于哪里

你把一个 ConditionExpression 附加到 PutItemUpdateItemDeleteItem,以及 TransactWriteItems 内部的每个动作。它不是 QueryScan 的一部分——那些用 FilterExpression,那是读取路径上一个不同的东西。

那个区分会绊倒人,所以要精确:

ConditionExpressionFilterExpression
路径写入(Put/Update/Delete读取(Query/Scan
失败时的效果拒绝整个写入把该项从结果中丢弃
看到的当前项,写入前每个候选项,读取后
成本失败的写入仍然计费被过滤的项仍然为该读取计费

两者都在服务端运行。区别在于"假"做什么:一个条件中止一次变更;一个过滤只是隐藏一行你已经付费读取的。 (AWS: 条件表达式

你真正会用到的函数

条件语言很小。主力是:

  • attribute_exists(path) / attribute_not_exists(path) —— 这个属性在项上存在吗?经典的"仅当不存在 时创建" / "仅当存在时更新"惯用法。
  • 比较符 —— =<><<=>>= —— 对一个值或另一个属性。
  • attribute_typebegins_withcontainssize —— 类型与字符串/集合检查。
  • BETWEEN … AND …IN (…) —— 范围与成员资格。
  • ANDORNOT、括号 —— 组合上面这些。

分区键上的 attribute_not_exists 是让 PutItem 表现得像一个不会覆盖现有项的插入的标准做法 ——DynamoDB 没有单独的"插入"操作,所以条件就是插入语义。 (AWS: 比较运算符与函数参考

演练实例:守卫一个账本不被透支

拿一个银行账本。每个账户是一个项:

PK = "ACCT#a7f3"
SK = "BALANCE"
clearedCents = 50000
holdCents    = 0

不变量:一笔扣款绝不能把可用余额压到零以下,而且你绝不能扣一个不存在的账户。两条规则,都能在写入 本身里强制。

错误做法(那个坑)

GetItem ACCT#a7f3 / BALANCE     → clearedCents = 50000
if (50000 >= 30000) ...         ← app-side check
UpdateItem  SET clearedCents = 20000

GetItemUpdateItem 之间,第二笔扣款能读到同样的 50000、通过它自己的检查、然后也写入。 两者都成功;账户变成负数。这是一场读-改-写竞争,再多的应用侧校验也修不了它——检查和写入是分离的操作。

正确做法

把检查折进写入。扣 30000 分,条件是账户存在并且持有足够:

UpdateItem  ACCT#a7f3 / BALANCE
  SET clearedCents = clearedCents - :amt
  ConditionExpression:
    attribute_exists(PK) AND clearedCents >= :amt

:amt = 30000。如果余额太低,或者该项从未被创建,DynamoDB 以 ConditionalCheckFailedException 拒绝写入,余额不受触动。并发的那笔扣款要么看到原始余额并被对照它检查,要么看到更新后的——绝不会 是它据以行动的一个陈旧读取。

你可以用 DynamoDB 表达式构建器 构建并复制确切的表达式 ——名称、值,全都有——而不必手工拼装 ExpressionAttributeValues 映射。

在 DynoTable 中查看守卫

当一次条件写入失败时,你想看到项的真实状态,而不是去猜它。把账户项拉出来,直接读 clearedCents

DynoTable 中的账本集合 —— BALANCE 项在账户的交易项上方显示 clearedCents。
DynoTable 中的账本集合 —— BALANCE 项在账户的交易项上方显示 clearedCents。

读懂拒绝,别盲目重试

ConditionalCheckFailedException 不是一个瞬时错误——重试同一次写入什么也不改变。它意味着一条业务 规则触发了:资金不足、重复创建、版本陈旧。把它当作一个领域结果来呈现,而不是一次基础设施抖动。

有两样东西让失败可调试:

  • ReturnValuesOnConditionCheckFailure: ALL_OLD —— DynamoDB 在失败时连同返回当前项,于是你可以 显示"余额是 20000,你请求了 30000"而无需第二次读取。 (AWS: 处理项
  • 区分两种失败原因。 attribute_exists(PK) AND clearedCents >= :amt 把"没有账户"和"没有资金" 坍缩成一个异常。如果调用者需要把它们分开,就拆成两次写入,或检查返回的项。

乐观锁是同一个把戏

版本号模式不过是戴了另一顶帽子的条件表达式。存一个 version 属性;每次写入都断言你读到的版本并把它 加一:

UpdateItem  ACCT#a7f3 / BALANCE
  SET clearedCents = :new, version = :next
  ConditionExpression: version = :seen

如果另一个写入者先动了,version = :seen 为假,写入被拒绝,你重新读取并重试。这就是 DynamoDB 在没有 锁的情况下做并发控制的方式——断言你看到的,若它动了就失败。(AWS: 用版本号做乐观锁

陷阱与下一步

  • 与保留字冲突的名称。 statussizename 以及约 570 个其他词是保留的。用 ExpressionAttributeNames#s = status)给它们起别名,否则你的表达式会悄悄解析失败。
  • 一个条件无法引用另一个项。 它只看到正在被写入的那个项。跨项不变量需要 TransactWriteItems 配每个动作的 ConditionExpression,或对一个哨兵项做一次 ConditionCheck
  • 失败的写入仍然花 WCU。 一个 90% 的时候都拒绝的守卫,仍然为那些拒绝计费。便宜的保险,但不免费。

要建模这些守卫所针对的键,参见 单表设计Query 与 Scan。当你准备好对真实数据发起条件写入时,下载 DynoTable 并对你自己的表运行它们。

更新于