高级阅读约 2 分钟

在 DynamoDB 中对多个属性强制唯一性

DynamoDB 只对一样东西保证唯一:。没有 UNIQUE (email) 约束,没有 UNIQUE (username),也没有任何能跨越两个属性的东西。从 SQL 过来的人,这个缺失是头一个意外——也是人们悄悄埋下竞态条件的头一个地方。

在 DynamoDB 中,如何对多个属性强制唯一性约束?

DynamoDB 除了之外没有任何 UNIQUE 约束,所以唯一性要靠你自己来强制:把每个受保护的值建模成它自己的标记项,让键_就是_那个值,然后在一次 TransactWriteItems 里把记录和每个标记一起写入,每个 put 都由 attribute_not_exists 守卫。引擎本来就已强制的那种冲突,就成了你的约束。

  • 不存在唯一性约束——只有主键被引擎强制唯一。其他每一个「必须唯一」的属性都是你自己的活。
  • 把每条唯一性规则建模成它自己的项目。 一个专门的标记项,其键_就是_你要保护的那个值,把「这个邮箱被占用了吗?」变成一次引擎本就强制的键冲突。
  • TransactWriteItems 原子地写入它们。 一个,每个 put 都由 attribute_not_exists 守卫,于是所有标记和真正的记录要么一起提交,要么都不提交。
  • 不要先查再写。 先读后插是教科书级的竞态;两个并发注册都读到「空闲」,然后都写了进去。

为什么那个显而易见的做法是错的

直觉是对邮箱做 Query(更糟的是 Scan),什么都没查到,然后 PutItem 写入新账户。这是一个先查后动的竞态。

两个人在同一毫秒注册 ada@lovelace.io。两次读取都返回空。两次写入都成功了。你现在有两个账户共用一个邮箱——而表里没有任何东西把它标记出来。

email 上建一个也救不了你。GSI 是最终一致的,所以为你的写入把关的那次读取,天生就可能是陈旧的。修复之道不是更快的检查,而是让写入本身拒绝落到一个已被占用的值上。

把每条约束建模成一个标记项

引擎已经免费强制了一条唯一性规则:你不能写入两个键相同的项目。所以把每条唯一性规则都编码成一个键。

在真正的账户项目之外,为每个受保护的属性写入一个标记项。标记项的分区键_就是_加了命名空间的那个值。如果该值被占用了,键就存在,那么一个带守卫的 put 就无法覆盖它。

对于一次必须让 emailusername 都唯一的注册,有三个项目一起移动——在单表布局中建键(见单表设计):

项目PKSK用途
账户记录ACCT#a1f9c3PROFILE真正的账户
邮箱锁UNIQ#EMAIL#ada@lovelace.ioLOCK预留该邮箱
用户名锁UNIQ#HANDLE#adaLOCK预留该用户名

账户自己的 PK 是一个生成的 id(ACCT#a1f9c3)——永远不是邮箱——这样用户日后能改邮箱而不必重写主键。锁项目不携带任何 profile 数据;它们存在的唯一目的就是让自己的_键_被占用。

原子地写入全部三个

TransactWriteItems 把最多 100 个写入作为一个「全有或全无」的单元来施加。用 attribute_not_exists(PK) 守卫每个 put,这样一旦该键已存在它就会失败。

只要有任何一个条件失败——邮箱锁、用户名锁,或账户本身——DynamoDB 就会把整个事务回滚,并抛出 TransactionCanceledException。没有半成品注册,没有孤儿锁。

{
  "TransactItems": [
    {
      "Put": {
        "TableName": "accounts",
        "Item": {
          "PK": {"S": "ACCT#a1f9c3"},
          "SK": {"S": "PROFILE"},
          "email": {"S": "ada@lovelace.io"},
          "username": {"S": "ada"}
        },
        "ConditionExpression": "attribute_not_exists(PK)"
      }
    },
    {
      "Put": {
        "TableName": "accounts",
        "Item": {
          "PK": {"S": "UNIQ#EMAIL#ada@lovelace.io"},
          "SK": {"S": "LOCK"}
        },
        "ConditionExpression": "attribute_not_exists(PK)"
      }
    },
    {
      "Put": {
        "TableName": "accounts",
        "Item": {
          "PK": {"S": "UNIQ#HANDLE#ada"},
          "SK": {"S": "LOCK"}
        },
        "ConditionExpression": "attribute_not_exists(PK)"
      }
    }
  ]
}

那个条件就是整套机制。没有 attribute_not_exists,第二次用相同邮箱的注册会悄悄覆盖第一个锁。有了它,put 会拒绝、事务取消,你的应用便能呈现「邮箱已被使用」。

手写 ConditionExpression 和值映射正是打字错误潜入的地方。DynamoDB 表达式构建器会为每个 put 生成条件和带类型的 Item,让你能把一个正确的事务直接粘进你的 SDK 调用里。

读懂失败原因,别去猜

当事务被取消时,DynamoDB 会按位置返回一个 CancellationReasons 数组——每个项目一个条目,顺序与请求一致。位置 1 上出现 ConditionalCheckFailed 意味着邮箱被占用;位置 2 意味着用户名被占用。把位置映射回一个精确的、字段级别的错误,而不是一句笼统的「注册失败」。

在 DynoTable 中检视这些锁

标记项在你应用的 UI 里是不可见的——它们是管道。当一次注册莫名其妙地失败时,你需要看到锁到底是否存在。

在 DynoTable 中打开这张表,对 UNIQ# 前缀做 Query。账户和它的两个锁项目会挨在一起,于是一次卡住的注册(一次失败的删除留下的残余锁)一眼便知。

DynoTable 扫描该表——账户项目与它们的 UNIQ#EMAIL 和 UNIQ#HANDLE 锁项目交错排列。
DynoTable 扫描该表——账户项目与它们的 UNIQ#EMAIL 和 UNIQ#HANDLE 锁项目交错排列。

在变更和删除时让锁保持诚实

锁不是一次写死的。它们镜像着实时的值,所以整个生命周期必须让它们保持同步——每一次触及受保护属性的操作都同样是一个事务。

  • 改邮箱。 一个事务:用 attribute_not_exists put 新的 UNIQ#EMAIL#… 锁、删除旧锁、更新账户。同样的「全有或全无」保证。
  • 删账户。 在一个事务里删除账户项目_以及_两个锁项目,否则你会留下一个永远阻塞该值的锁。
  • 安全地重试。 传一个 ClientRequestToken,让重发的事务(在网络抖动之后)是幂等的,而不是一次双重写入。

陷阱在于把锁当作「发了就不管」。一个在注册时创建、却在删除账户时从未删掉的锁,是一个再没有人能重用的值——而它不会暴露出来,直到某个真实用户拿不回自己旧的用户名。

后续步骤

唯一性标记是一种单表模式,所以它们很自然地紧挨着你的其他项目——读一读单表设计了解键的布局,以及 Query vs Scan,好让你永远不至于为了检查一个锁去动用 Scan。这个模式最早是在 AWS 的 re:Invent / AWS Summit 2018 DAT374 — DynamoDB Transactions 演讲中被完整讲解的。

DynamoDB 表达式构建器起草那些带条件守卫的 put,然后试用 DynoTable对着你自己的表检视这些锁项目。

更新于