DynamoDB 中的键重载
从 SQL 过来的你,一个列永远只表示一件事:orders.created_at 永远是日期,users.email 永远是邮箱。键重载把这套规则彻底抛弃。你给分区键和起通用的名字——pk、sk——让每种条目类型往里灌注不同的含义。一张表,多种实体,一套结构。
DynamoDB 中的键重载是什么?
键重载就是把多种实体类型用通用的键名(如 pk/sk)存进一张表,把类型编码进值里(USER#u_3001、INVOICE#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 计费产品。每个租户都有成员、发票和一份审计轨迹。与其用三张表,不如全部放进一张表并重载键:
| pk | sk | attributes |
|---|---|---|
| TENANT#acme | META | name="Acme Inc", plan="team" |
| TENANT#acme | USER#u_3001 | email, role="admin" |
| TENANT#acme | USER#u_3002 | email, role="member" |
| TENANT#acme | INVOICE#2026-0014 | amount_cents, status="paid" |
| TENANT#acme | INVOICE#2026-0015 | amount_cents, status="open" |
| TENANT#acme | EVENT#2026-06-23T09:12Z | actor="u_3001", action="invite" |
每一行都共享 pk = "TENANT#acme",因此它们构成一个——全部聚在一起,全部可在一次分区读取中触及。
排序键前缀在干真正的活。它既给实体分组,又给它们排序。
查询重载的集合
因为类型就存在排序键前缀里,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#)会静默地返回空结果。表达式构建器会生成 KeyConditionExpression 和 ExpressionAttributeValues 映射,让前缀与你真正写的内容一致。
索引也一起重载
同样的技巧适用于 。给它起通用的键名——gsi1pk、gsi1sk——让每个实体写入它需要的任何内容。这样一个索引就能回答基表回答不了的模式。
| pk | sk | gsi1pk | gsi1sk |
|---|---|---|---|
| TENANT#acme | INVOICE#2026-0015 | STATUS#open | 2026-06-30 |
| TENANT#acme | INVOICE#2026-0014 | STATUS#paid | 2026-06-12 |
| TENANT#beta | INVOICE#2026-0099 | STATUS#open | 2026-06-25 |
现在 Query gsi1 WHERE gsi1pk = "STATUS#open" 会列出所有租户中每一张待处理的发票,并按到期日排序——这是一个跨分区视图,基表那些以租户为范围的键永远无法服务。另一个实体可以用它自己的含义复用 gsi1(比如 gsi1pk = "ROLE#admin"),所以一个索引就覆盖了好几种读取。只是要记住,GSI 是最终一致的——它的写入会滞后于基表。
在 DynoTable 中操作
原始的重载键读起来很不友好:INVOICE#2026-0015 和 EVENT#2026-06-23T09:12Z 在扁平列表里混作一团。一个按分区分组、把前缀显式呈现的查看器,能把杂物抽屉重新变回一个个实体。

陷阱
- 分隔符一次定好,永不更改。
#是约定俗成的选择。在不同实体间混用#和:会以某种没有任何东西会警告你的方式破坏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#) 返回空集且没有错误 — 生成的表达式减少无声故障模式。
实体类型前缀注册表
维护一个简短的内表开发者可以参考:
| 实体 | 排序前缀 | 示例 SK | Query 切片 |
|---|---|---|---|
| 租户元 | META | META | 单品获取 |
| 用户 | USER# | USER#u_3001 | begins_with(sk, "USER#") |
| 发票 | INVOICE# | INVOICE#2026-0015 | begins_with(sk, "INVOICE#") |
| 活动 | EVENT# | EVENT#2026-06-23T09:12Z | 具有降序读取的按时间顺序的尾部 |
新实体类型必须选择在 begins_with 下不发生冲突的前缀现有前缀 - USER# 和 USERGROUP# 都匹配 begins_with(sk, "USER")除非你小心地延长或分隔。


