进阶阅读约 2 分钟

DynamoDB 更新表达式:SET、REMOVE、ADD、DELETE(含示例)

更新表达式告诉 UpdateItem 如何变更单个项目:要写入、递增、删除或折叠进集合的是哪些。这里没有多行的 UPDATE … SET … WHERE——你用完整的键点名一个项目,再用四个子句关键字描述变更。

DynamoDB 更新表达式如何工作?

DynamoDB 更新表达式用四个子句告诉 UpdateItem 如何变更某一个项目。SET 写入或覆盖一个ADD 原子地递增一个数字,或并入一个集合。REMOVE 删除一个属性或一个列表元素。DELETE 从一个集合中移除特定成员。一次调用可以同时携带全部四个。

  • SET 写入或覆盖一个属性——标量、文档,以及函数惯用法 if_not_existslist_append
  • ADD 在一次往返中完成原子的数字递增或集合并集,无需先读。
  • REMOVE 彻底删除一个属性(或按下标删除单个列表元素)。
  • DELETE 从一个集合中移除特定成员——而且只作用于集合。

从 SQL 过来时,陷阱是什么都拿 SET 去做。ADDDELETE 之所以存在,是因为对计数器或集合做读-改-写,在并发下是一场你必输的竞态。

按你要改动的内容来选子句

一次 UpdateItem 调用可以同时携带全部四个子句,顺序任意。每个关键字最多出现一次,并接受一个逗号分隔的动作列表。

子句作用于用它来做
SET任意属性写入/覆盖一个值或文档字段
ADD仅数字或集合原子递增,或并入一个集合
REMOVE任意属性或列表元素删除一个属性;去掉一个列表下标
DELETE仅集合从一个集合中移除特定成员

对字符串用 ADD、对标量用 DELETE 是验证错误,而不是空操作——DynamoDB 会拒绝整个调用。按照 AWS 更新表达式参考ADD 仅限于数字和集合,DELETE 仅限于集合。

实战示例:一个购物车

每个购物车一个项目,键为 CartPK = "CART#c-9f21"CartSK = "SUMMARY"。它跟踪一个累计的 OrderTotal、一个 LineItems 列表、一个 PromoCodes 字符串集合,以及一个 ItemCount

SET——写入标量和文档

SET 会覆盖原有内容。在同一次调用里往列表添加一个行项目并累加总额:

SET OrderTotal = :total,
LineItems = list_append(LineItems, :newItem),
UpdatedAt = :now

list_append(LineItems, :newItem) 追加到尾部;把参数调个位置——list_append(:newItem, LineItems)——就是前插。参数的顺序就是拼接的顺序,仅此而已。

那第一个调用里藏着一个坑:如果购物车是全新的,LineItems 还不存在,而在一个缺失的属性上做 list_append 会失败。用 if_not_exists 守卫它:

SET LineItems = list_append(if_not_exists(LineItems, :empty), :newItem)

if_not_exists(LineItems, :empty) 在列表存在时返回当前列表,否则返回后备值 :empty(一个空列表 [])。这就让第一次添加和之后每一次添加都使用同一个表达式——这些惯用法存在的真正理由。

ADD——原子地递增计数

要给 ItemCount 加一,不要先把它读出来、在你的代码里加一、再 SET 回去。那是一场丢失更新的竞态:两个并发的添加都读到 3,都写入 4,于是你丢了一个。ADD 在服务端完成算术:

ADD ItemCount :one

:one = 1 时,这就是一个原子计数器。并发调用会在该项目上串行化,所以两次添加最终落成 +2。传一个负数就是递减。如果 ItemCount 不存在,ADD 会先把它当作 0——所以你永远不必给计数器播种。

us-east-1 的按需模式下,每一次 ADD 都按写入后项大小每 KB 1 WCU 计费 (向上取整)。一个 1 KB 的购物车摘要项,带一个 ADD ItemCount :one,花 1 WCU —— 同一行如果改用读-改-写的 SET,WCU 一样,却会在并发下丢更新。用 项大小计算器给这个项定尺寸。

你可以在 DynamoDB 表达式构建器里构建出这个一模一样的表达式——名称、带类型的值,以及编组后的请求——完全不必手工转义任何一个 #name:value 占位符。

编辑下面这个预设——一个 SET 加一个原子 ADD——然后看着 UpdateExpression 随你的操作实时重建:

构建你的请求
生成的代码
new UpdateItemCommand({
  "TableName": "AuditLog",
  "Key": {
    "pk": {
      "S": "TENANT#acme"
    },
    "sk": {
      "S": "CONFIG"
    }
  },
  "UpdateExpression": "SET #upd0 = :updValue0 ADD #upd1 :updValue1",
  "ExpressionAttributeNames": {
    "#upd0": "plan",
    "#upd1": "seats"
  },
  "ExpressionAttributeValues": {
    ":updValue0": {
      "S": "pro"
    },
    ":updValue1": {
      "N": "5"
    }
  }
})

REMOVE——删掉一个属性或一个行项目

REMOVE 是你彻底删除一个属性的方式(并没有「把它设为 null」这种事——那只会写入一个 NULL 类型)。在一次调用里清掉已应用的折扣、并去掉第三个行项目:

REMOVE AppliedDiscount, LineItems[2]

LineItems[2] 会移除下标 2 处的元素,并把它之后的一切往前挪——下标 3 变成 2,以此类推。如果你在一个表达式里 REMOVE 两个下标,两者都是针对原始列表求值的,所以把 [2][3] 一起移除,会如你所料地删掉第三和第四个元素。

DELETE——移除集合成员

PromoCodes 是一个字符串集合,所以顾客撤掉一个促销码时用的是 DELETE,而不是 REMOVEREMOVE PromoCodes 会把整个集合抹掉;DELETE 只减去指定的成员:

DELETE PromoCodes :pulled

:pulled = 集合 {"SAVE10"} 时,只有那个成员会被删掉。这里有两条规则会咬人:集合永远不能为空,所以删掉最后一个成员会把 PromoCodes 属性整个移除;而且值必须是与该属性匹配的集合类型——一个裸字符串是类型错误。

:pulled = 集合 {"SAVE10"} 时,只有该成员离开。两条规则咬合这里:集合永远不能为空,因此删除最后一个成员会删除 PromoCodes 完全属性;并且该值必须是与属性——裸字符串是类型错误。

把它们组合起来

一次「加入商品、应用促销、累加计数」的更新,就是横跨三个子句的一次调用:

SET LineItems = list_append(if_not_exists(LineItems, :empty), :newItem),
OrderTotal = OrderTotal + :price
ADD ItemCount :one
DELETE PromoCodes :expiredCode

注意 OrderTotal = OrderTotal + :price——SET 内部的算术是作用于现有值的。它和 ADD 一样原子、一样对竞态安全:DynamoDB 在服务端针对当前值求值 OrderTotal + :price,所以并发调用会在该项目上串行化,而不是绕经你的代码往返。

要避开的陷阱

  • 对一个你先读过的计数器用 SET 请用 ADD——读-改-写在并发下会丢失更新。这是最常见的购物车/库存 bug。
  • 在一个缺失的列表上用 list_append 把目标裹进 if_not_exists,否则第一次写入就会失败。
  • 混淆 REMOVEDELETE REMOVE 丢掉整个属性;DELETE 从一个集合里减去成员。混用它们会删掉比你本意更多的东西。
  • 忘了 UpdateItem 是一个 upsert。 如果键不存在,它会创建该项目。当你的本意是「只更新」时,请用一个 ConditionExpressionattribute_exists(CartPK))。

关于给这些表达式所作用的键建模,见单表设计;关于如何决定你要怎样把购物车读回来,见 query vs scan

表达式构建器里构建并复制以上任意一个,然后试用 DynoTable对着你自己的表运行它们,看着项目实时变化。

更新于