高级阅读约 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 也救不了你。GSI 是最终一致的,所以那个为你的写入把关的读取,可以按设计就是陈旧的。修复办法不是一次更快的检查;而是让写入本身拒绝落在一个被占的值上。

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

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

在真实账户项之外,为每一个被保护的属性写一个标记项。标记的分区键 就是 那个带命名空间的值。如果那个值被占了,键就存在,而一次被守护的 put 没法覆盖它。

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

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

账户自己的 PK 是一个生成的 id(ACCT#a1f9c3)—— 绝不是邮箱 —— 这样用户日后能更改邮箱而不必重写主键。锁项不携带任何资料数据;它们存在只为让自己的 被占用。

把三个原子地写入

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 拒绝、事务取消,而你的应用浮现出“邮箱已被使用”。

读那个失败,别去猜

当事务被取消时,DynamoDB 按位置返回一个 CancellationReasons 数组 —— 每个项一条,按请求顺序。槽位 1 里的一个 ConditionalCheckFailed 意味着邮箱被占了;槽位 2 意味着用户名被占了。把槽位映射回一个精确的、字段级的错误,而非一个笼统的“注册失败”。

在 DynoTable 中检视那些锁

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

在 DynoTable 里打开那张表,Query 那个 UNIQ# 前缀。账户和它的两个锁项坐在一起,所以一个卡住的注册(一次拙劣的删除留下的一个锁)一眼就看得出来。

DynoTable 扫描表 —— 账户项与其 UNIQ#EMAIL 和 UNIQ#HANDLE 锁项交织排列。
DynoTable 扫描表 —— 账户项与其 UNIQ#EMAIL 和 UNIQ#HANDLE 锁项交织排列。

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

锁不是一次性写完的。它们镜像那个活的值,所以生命周期必须让它们保持同步 —— 每一个触碰被保护属性的操作也是一次事务。

  • 更改邮箱。 一个事务:用 attribute_not_exists put 新的 UNIQ#EMAIL#… 锁、删除旧锁、更新账户。同样的全有或全无保证。
  • 删除账户。 在一个事务里删除账户项 两个锁项,否则你会搁置一个永远封住那个值的锁。
  • 安全重试。 传一个 ClientRequestToken,好让一次被重发的事务(在一次网络抖动之后)是幂等的,而非一次重复写入。

陷阱是把锁当作发了就不管。一个在注册时创建、却在账户移除时从未删除的锁,是一个再没人能复用的值 —— 而它要等到一个真实用户没法认领自己旧的用户名时才会现身。

下一步

唯一性标记是一种单表模式,所以它们自然地坐落在你其他项旁边 —— 键布局读单表设计,并读 Query 对比 Scan,好让你永远不去够一次 Scan 来检查一个锁。这个模式最早是在 AWS 的 re:Invent / AWS Summit 2018 DAT374 —— DynamoDB Transactions 专场里走通的。

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

更新于