进阶阅读约 2 分钟

DynamoDB 更新表达式

一个更新表达式告诉 UpdateItem 如何变更单个项:写入、自增、删除哪些属性,或把哪些折进一个集合。 这里没有 UPDATE … SET … WHERE——你用键来命名项,并用四个子句关键字描述变更。

DynamoDB 更新表达式是如何工作的?

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

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

从 SQL 过来,陷阱是凡事都动用 SETADDDELETE 存在的原因是:对一个计数器或一个集合做读-改-写 是一场你在并发下会输掉的竞争。

按你要改的东西挑子句

一次 UpdateItem 调用可以同时承载全部四个子句,顺序为 SET … REMOVE … ADD … DELETE。每个关键字 最多出现一次,并接受一个逗号分隔的动作列表。

子句作用于用它来
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——所以你永远不需要给计数器播种。

你可以在 DynamoDB 表达式构建器 里构建这个确切的表达式 ——名称、有类型的值,以及编排好的请求——而无需手工转义哪怕一个 #name:value 占位符。

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 属性;而且值必须是与该属性匹配的集合类型——一个裸字符串是 一个类型错误。

把它们拼起来

一次"加项、应用一个促销、加上计数"的更新是横跨三个子句的一次调用:

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

注意 OrderTotal = OrderTotal + :price——SET 内部的算术作用于现有值。它不像 ADD 那样对竞争安全 而原子,但它在服务端读取当前总额,而不是把它通过你的代码来回往返。

要避开的陷阱

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

要建模这些表达式所针对的键,参见 单表设计;要决定你将如何把购物车读回来,参见 Query 与 Scan

表达式构建器里构建并复制这些中的任何一个,然后 试试 DynoTable 对你自己的表运行它们,看着项实时变化。

更新于