高级阅读约 3 分钟

DynamoDB 中的键重载

从 SQL 过来的你,一个列永远只表示一件事:orders.created_at 永远是日期,users.email 永远是邮箱。键重载把这套规则彻底抛弃。你给分区键和起通用的名字——pksk——让每种条目类型往里灌注不同的含义。一张表,多种实体,一套结构。

DynamoDB 中的键重载是什么?

键重载就是把多种实体类型用通用的键名(如 pk/sk)存进一张表,把类型编码进值里(USER#u_3001INVOICE#2026-0014)。属性名保持中性,于是用户、发票和事件共享同一个分区;值携带类型信息,而排序键前缀让一次 Query 就能通过 begins_with 切出每种实体。

  • 通用的键名,带类型的值。把键命名为 pk/sk,把实体类型放进值里:pk = "TENANT#acme"sk = "USER#u_3001"。名字是无意义的,值才携带类型。
  • 它是单表设计能跑起来的关键。没有重载,共享的一张表不过是个杂物抽屉。有了它,每个实体都落在一个你能 Query 的分区里。
  • begins_with 就是回报。排序键上的类型前缀让一次 Query 就能拉取整个实体,或它的一个切片,不需要 Scan,也不需要过滤。
  • 代价是:可读性。一份原始的 pk/sk 转储什么都告诉不了你。你需要一个能解码前缀的查看器,否则就只能眯着眼盯着一堆字符串。

为什么通用名字胜过真实名字

DynamoDB 每张表最多给你两个键属性,而一次 Query 只能针对单个分区键。所以如果你把键命名为 userId,那只有用户条目才能干净地存进这张表——其他一切都得伪造一个 userId,或者搬到自己单独的表里去。

重载规避了这个问题。像 pk 这样中性的名字不绑定任何实体,于是一个用户、一张发票、一条审计事件都能共享同一个键属性和同一张表。是、而不是属性名,说明了条目是什么。

正是这一招,把单表设计从理论变成了你真正能查询的东西。共享的表是容器;重载则是让不同实体在其中共存的机制。

一个多租户示例

假设你运营一个 SaaS 计费产品。每个租户都有成员、发票和一份审计轨迹。与其用三张表,不如全部放进一张表并重载键:

pkskattributes
TENANT#acmeMETAname="Acme Inc", plan="team"
TENANT#acmeUSER#u_3001email, role="admin"
TENANT#acmeUSER#u_3002email, role="member"
TENANT#acmeINVOICE#2026-0014amount_cents, status="paid"
TENANT#acmeINVOICE#2026-0015amount_cents, status="open"
TENANT#acmeEVENT#2026-06-23T09:12Zactor="u_3001", action="invite"

每一行都共享 pk = "TENANT#acme",因此它们构成一个——全部聚在一起,全部可在一次分区读取中触及。

分区:TENANT#acmesk: METAsk: USER#u_3001sk: INVOICE#2026-0015sk: EVENT#2026-06-23T09:12Z一次 Query

排序键前缀在干真正的活。它既给实体分组,给它们排序。

查询重载的集合

因为类型就存在排序键前缀里,begins_with 无需扫描任何东西就能按实体切分分区:

Query pk = "TENANT#acme"  -- the entire tenant, every type
Query pk = "TENANT#acme" AND begins_with(sk, "USER#")  -- just members
Query pk = "TENANT#acme" AND begins_with(sk, "INVOICE#")  -- just invoices

你只为条件匹配到的条目付费,而不是整个分区——这与带过滤的 Scan 恰好相反,后者你得为读取那些随后又被丢弃的行付费。AWS 把这叫作键条件;它在任何数据离开分区之前先作用在键上。

如果你手工构造那个 begins_with 条件,一定要把类型标签写对——一个写歪了的 USERS#(而非 USER#)会静默地返回空结果。表达式构建器会生成 KeyConditionExpressionExpressionAttributeValues 映射,让前缀与你真正写的内容一致。

索引也一起重载

同样的技巧适用于 。给它起通用的键名——gsi1pkgsi1sk——让每个实体写入它需要的任何内容。这样一个索引就能回答基表回答不了的模式。

pkskgsi1pkgsi1sk
TENANT#acmeINVOICE#2026-0015STATUS#open2026-06-30
TENANT#acmeINVOICE#2026-0014STATUS#paid2026-06-12
TENANT#betaINVOICE#2026-0099STATUS#open2026-06-25

现在 Query gsi1 WHERE gsi1pk = "STATUS#open" 会列出所有租户中每一张待处理的发票,并按到期日排序——这是一个跨分区视图,基表那些以租户为范围的键永远无法服务。另一个实体可以用它自己的含义复用 gsi1(比如 gsi1pk = "ROLE#admin"),所以一个索引就覆盖了好几种读取。只是要记住,GSI 是最终一致的——它的写入会滞后于基表。

在 DynoTable 中操作

原始的重载键读起来很不友好:INVOICE#2026-0015EVENT#2026-06-23T09:12Z 在扁平列表里混作一团。一个按分区分组、把前缀显式呈现的查看器,能把杂物抽屉重新变回一个个实体。

DynoTable 浏览一个租户的条目集合——META、USER、INVOICE 和 EVENT 条目分组在单个重载的分区键之下。
DynoTable 浏览一个租户的条目集合——META、USER、INVOICE 和 EVENT 条目分组在单个重载的分区键之下。

陷阱

  • 分隔符一次定好,永不更改。# 是约定俗成的选择。在不同实体间混用 #: 会以某种没有任何东西会警告你的方式破坏 begins_with
  • 别重载需要做范围数学的值。排序键 INVOICE#2026-0015 是按字典序排序的,不是按数值——给 id 并使用 ISO-8601 日期,好让字符串顺序与你想要的顺序一致。
  • 给前缀命名空间留出预留位。两个都以 USER 开头的实体类型(比如 USER#USERGROUP#)会在 begins_with(sk, "USER") 下发生冲突。要让前缀从第一个字符起就没有歧义。
  • 先规划读取,再定键。重载服务的是你已经枚举好的访问模式。如果你还不知道自己的读取需求,请先看单表设计——键是由查询衍生出来的。

先规划好一个分区,然后下载 DynoTable 去浏览你自己的重载键,看着一次 Query 把整个租户一次性拉回来。

Query 过载分区的成本

列出 TENANT#acme 下的每个成员 begins_with(sk, "USER#") 仅读取用户行 — 不读取发票或事件 — 因为关键条件在数据离开分区之前进行过滤。在一个租客身上有 200 个用户(每个 2 KB)和 5,000 个审核事件(每个 1 KB),该查询涉及约 400 KB(约 100 个最终一致的 RCU)。整个桌子上有Scan 要找到用户,就要对每个租户中的每个项目进行计量。将代表性的超载项目粘贴到 item-size calculator,然后估算列表查询pricing calculator

使用单表工具进行设计

输入实体(租户、用户、发票、事件)和访问模式(“列出用户对于租户”、“跨租户开具发票”) single-table design tool。它建议与您将使用的重载前缀匹配的 pk/sk 模板和 GSI 键在生产中 — 在您提交 CloudFormation 之前。

从模式发出查询

固定前缀后,在中构建关键条件 expression builder 并导出分页来自query builder的节目。前缀拼写错误 (USER# vs USERS#) 返回空集且没有错误 — 生成的表达式减少无声故障模式。

实体类型前缀注册表

维护一个简短的内表开发者可以参考:

实体排序前缀示例 SKQuery 切片
租户元METAMETA单品获取
用户USER#USER#u_3001begins_with(sk, "USER#")
发票INVOICE#INVOICE#2026-0015begins_with(sk, "INVOICE#")
活动EVENT#EVENT#2026-06-23T09:12Z具有降序读取的按时间顺序的尾部

新实体类型必须选择在 begins_with 下不发生冲突的前缀现有前缀 - USER#USERGROUP# 都匹配 begins_with(sk, "USER")除非你小心地延长或分隔。

更新于