进阶阅读约 2 分钟

DynamoDB 中的复合排序键

复合主键是一个分区键加一个排序键。让它强大的诀窍在于你往排序键 放什么:把一个层级编码成一个带分隔符的字符串,单次 Query 就能按排序顺序读出整棵子树 —— 没有连接,没有递归,没有第二次往返。

复合排序键在 DynamoDB 中是如何工作的?

复合排序键把一个层级打包进一个带分隔符的字符串 —— root/photos/2026/ —— DynamoDB 按 UTF-8 字节顺序存放它。由于这个布局本就与树相符,单次配合 begins_with(SK, "root/photos/")Query 就能按路径顺序读出整棵子树。没有连接,没有递归,没有第二次往返 —— 只是对一段连续切片的前缀扫描。

  • 排序键是一个可排序的字符串,而不只是一个 ID。 把一条路径打包进去 —— root/photos/2026/ —— DynamoDB 就会自动按 UTF-8 字节顺序存放该分区的各项。
  • 一个分隔符把前缀匹配变成子树读取。 begins_with(SK, "root/photos/") 在一次查询里返回那个文件夹的每一个后代。
  • 排序键支持范围条件,而非任意筛选。 你能用 begins_withbetween>< —— 把键设计成你需要的读取是一个前缀或一个范围,而不是一次 Scan
  • 分隔符是承重的。 挑一个不可能出现在路径片段里的,否则两条不相干的分支会撞在一起。

为什么排序键是整盘棋

从 SQL 过来,你会用一个 parent_id 自连接来建模文件夹树,并递归地遍历它 —— 每层一次查询。在 DynamoDB 里,对着一个没有连接的键值存储,那是一个 N+1 暗坑。

DynamoDB 把每一个项存在某个分区键之下、按它的排序键排序,对字符串而言按 UTF-8 字节顺序(AWS:Query 键条件)。所以如果你的排序键 就是 路径,物理布局就已经与树相符了。一次读取变成对一段连续切片的前缀扫描 —— 而非一次图遍历。

转变就在这里:排序键不是一个你要精确匹配的标识符。它是一个可排序的地址。把它设计好,查询就免费地浮现出来了。

给文件系统树建模

假设你在存储按账户划分的文件树。每个账户一个盘是天然的分区;盘里面的路径是排序键。

PKSKnode_typebytes
DRIVE#a91root/folder-
DRIVE#a91root/photos/folder-
DRIVE#a91root/photos/2026/folder-
DRIVE#a91root/photos/2026/beach.jpgfile284910
DRIVE#a91root/photos/2026/sunset.jpgfile512004
DRIVE#a91root/docs/folder-
DRIVE#a91root/docs/taxes.pdffile88210

这里有两个原创约定在干活:

  • PK = DRIVE#<account> 把一个账户的整棵树保持在单一项集合里,于是任何子树读取都是一次单分区 Query
  • SK 是完整路径,文件夹带一个末尾 /。那个末尾斜杠是刻意的 —— 它让一个文件夹排在它自己的子项 之前,并让 root/photos/ 与一个名为 root/photos 的同级文件区分开。

一次查询读出一棵子树

列出 root/photos/ 之下的所有东西 —— 文件夹、子文件夹和文件,递归地:

Query
KeyConditionExpression = PK = :drive AND begins_with(SK, :prefix)
:drive   = "DRIVE#a91"
:prefix  = "root/photos/"

那会返回 root/photos/root/photos/2026/beach.jpgsunset.jpg —— 按路径顺序,在一次计费读取里。你只为那段切片里的项付费,而不是整个盘。

在 DynoTable 中,你直接对路径排序键执行这个 begins_with 查询,文件夹及其后代就会按路径顺序返回 —— 无需手写占位符语法。

需要为自己的代码准备原始的 KeyConditionExpression(名称、值和 begins_with)?在 DynamoDB 表达式构建器里构建并拷贝即可。

在 DynoTable 中对路径排序键运行一次 begins_with 查询,按路径顺序返回一个文件夹及其后代。
在 DynoTable 中对路径排序键运行一次 begins_with 查询,按路径顺序返回一个文件夹及其后代。

只列一层,而非整棵子树

begins_with 给你的是 递归 读取。要做一次非递归的目录列举 —— root/photos/ 的直接子项、不再往深 —— 就存一个深度属性,再加一个排序键范围外加一个筛选,或者把路径拆进一个 parent GSI。最简单的版本:保留一个 parent 属性(root/photos/),并建一个以它为键的 GSI。

要点是:排序键廉价地回答前缀范围问题。“仅直接子项”是另一个问题 —— 把它显式建模,而不是指望一个 FilterExpression 能让它高效。筛选在读取 之后 运行,而它丢弃的每一个项你都要付费。

谨慎挑选分隔符

分隔符是你数据契约的一部分。两条规则:

  • 它绝不能出现在某个路径片段内部。 如果文件名可以包含 /,那么 / 就是错误的分隔符 —— 一个名为 a/b 的文件与一个装着 b 的文件夹 a 无从分辨。挑一个保留字节(有些团队用 # 或一个控制字符),并在片段里禁用它。
  • 留意边界处的排序顺序。 /(0x2F)排在数字和字母之前,对树的顺序而言这通常正是你想要的。换了分隔符,你就改变了排序 —— 拿真实数据去验证它。

复合排序键 vs. 单独的排序属性

复合排序键(root/photos/2026/x纯 ID 排序键 + parent 属性
子树读取一次 begins_with 查询递归查询(N+1)或一次 GSI 遍历
排序路径顺序,免费必须加一个显式的排序属性
移动 / 重命名重写所有后代更新一个 parent 指针
直接子项列举需要深度属性或 GSI天然(parent = x

当读取是子树形且排序重要时,复合键胜出;当树持续变动时,扁平 ID 模型胜出。大多数读取密集型的层级 —— 文件树、分类树、组织架构图 —— 都倾向复合。

暗坑与下一步

  • 别把键塞得太满。 你编码进去的一切都是不可变的、且只能按前缀索引。你要按相等性查询的属性应当放在它们自己的字段里或一个 GSI 里,而不是硬塞进排序键。
  • 排序键做不了任意 WHERE 只有 begins_withbetween 和比较。如果你发现自己在伸手去够 FilterExpression,那你多半把键建错了 —— 参见 Query 对比 Scan
  • 更深入的键设计单表设计里;至于何时一次子树读取需要的是索引而非基表,参见 GSI 对比 LSI

表达式构建器构建那个 begins_with 键条件,然后下载 DynoTable,对着你自己的表运行这些前缀查询,看一棵子树按路径顺序回来。

更新于