进阶阅读约 3 分钟

DynamoDB 排序键策略:3 种模式及适用场景

一个 DynamoDB 主键由一个或两个属性组成:单独一个 ,或一个 分区键加上一个排序键。分区键决定哪个物理 分区持有一个项。

排序键决定该分区内部各项的顺序 —— 而正是那份 排序,让 Query 强大起来。

选错了排序键,你仍然能写入数据,但你会失去范围读取、 排序,以及同一个集合里的若干种访问模式。

从 SQL 过来,你会事后伸手去用一个 ORDER BY 或一个二级索引。 在 DynamoDB 里,你要事先把顺序烘焙进键里,否则就得不到它。

DynamoDB 排序键是如何工作的?

DynamoDB 排序键在一个分区内对各项排序,让 Query 能做范围读取 —— >=betweenbegins_with —— 而不是一次取一个项。字符串排序键按 UTF-8 字节排序(Number 类型按数值排序),所以要设计一个字符串键(一个 ISO-8601 时间戳、一个补零对齐的数字),让字节顺序等于你想要读取的顺序。

  • 排序键是你分区内的索引。 它在磁盘上对 排序,让 Query 能做范围读取(>=betweenbegins_with),而不是 一次 GetItem
  • 字符串排序键按 UTF-8 字节排序(Number 类型按数值排序)。 设计一个 字符串键,让字节顺序等于你想要读取的顺序 —— 一个 ISO-8601 时间戳、一个补零对齐的数字,绝不用裸露的 UUID 或 6/23/2026
  • 一个形态良好的排序键能服务多种访问模式。 一个EVT#<timestamp>)既是前缀又是范围 —— 无需 GSI。
  • 方向是免费的。 ScanIndexForward = false 以相同成本从最新往回读; 别为了假装做到这一点而存储反转的时间戳。

为什么排序键是那个杠杆

没有排序键,一个分区里的每个项都只能由它完整的 主键来寻址 —— 顶多一次 GetItem。加上一个排序键,DynamoDB 就会 在分区内按它排序地存储各项,从而解锁 Query

这意味着范围条件(>=between)、前缀匹配(begins_with), 以及一个用来升序或降序读取的 ScanIndexForward 标志。

按 AWS DynamoDB 开发者指南所述,所有共享一个分区键的项构成一个 项集合,在磁盘上按排序键排序。

所以排序键不只是第二个标识符。它是你在一个分区内部 所查询的那个索引。

那份排序是编码后排序键上的字节顺序:字符串按 UTF-8 字节比较,数字按数值比较。这一个事实驱动了下面几乎每一条 策略。

如果你想让范围查询有意义,字节顺序就必须匹配你想要读取的 顺序。

策略 1:让排序键可排序

最常见的错误是一个没有实际排序意义的排序键。一个随机的 UUID 给了你唯一性,却没有有用的范围查询 ——"给我最近 20 条" 变得不可能,因为字节顺序是任意的。

反过来,把你所排序和过滤的那个值编码排序键,采用一种 字节顺序等于其逻辑顺序的表示法。对于时间戳,这 意味着一种可按字典序排序的格式:一个 ISO-8601 字符串或一个补零的 纪元值。

ISO-8601 的设计初衷正是让字符串比较等于按时间的比较 —— 恰恰是范围查询所需要的。避免像 6/23/2026 这样的格式;月份一 翻篇它们就排错了。

如果你按数字排序(一个版本计数器、一个分数),请用 DynamoDB 原生的 Number 类型而不是字符串,这样 42 才会排在 9 之后,而不是在它 之前。

如果一个数字必须存在于一个复合字符串排序键里,就把它补零到一个固定的 宽度。

策略 2:用复合排序键表达层级

一个排序键可以通过用一个分隔符(最常见的是 #)拼接各段来编码一个层级。 一个 begins_with 条件便可选中整棵子树:

SK
EVENT#2026-06#01#login
EVENT#2026-06#03#export
EVENT#2026-07#02#login

begins_with(SK, "EVENT#2026-06#") 只返回 6 月的事件;更宽的 begins_with(SK, "EVENT#") 返回全部。

段的排列顺序是一个设计决定。由粗到细(年 → 月 → 日)能让 相关的项保持相邻,这样一次范围读取仍是一次廉价的查询,而不是 在分区里四处散读。

策略 3:用 ScanIndexForward 控制方向

DynamoDB 按升序排序键顺序存储各项,并默认按那个顺序 读取它们。要从最新往回读 ——活动流的天然顺序—— 就在 Query 上设置 ScanIndexForward = false

这是一个读取时的标志,不是一个 schema 决定:同一个集合以相同成本服务两个 方向。别为了得到降序读取而反转你的时间戳(存储一个"反向 纪元")。

一个项集合,只按升序存储一次,两个方向都能读:

ScanIndexForward = trueScanIndexForward = false项集合(一个 PK)SK EVT#09:00SK EVT#14:00SK EVT#next-day最旧在前最新在前

同样的项、同样的分区、同样的成本 —— 只有读取方向不同。

完整示例:一个按行为主体划分的审计日志

假设你在一个 SaaS 产品里记录由行为主体 —— 用户、服务、API 密钥 —— 产生的带时间戳的事件,而你有两种读取:

  1. 某个行为主体的活动流,最新事件优先。
  2. 某个行为主体在一个时间窗口内的事件(例如"两次部署之间的 一切"),用于一次调查。

两种读取都以单个行为主体为作用域,所以行为主体是分区键,而 事件时间是排序键。使用通用的键名,这样同一张表日后还能容纳其他 实体:

PKSKattributes
ACTOR#u_8814EVT#2026-06-23T09:12:04Zaction=login, ip, ua
ACTOR#u_8814EVT#2026-06-23T14:05:11Zaction=export, target
ACTOR#u_8814EVT#2026-06-24T08:40:55Zaction=login, ip, ua
ACTOR#svc_billingEVT#2026-06-23T00:00:00Zaction=invoice.run

EVT# 前缀加上一个 ISO-8601 时间戳给出一个可排序的排序键。读取 1 是 Query PK = "ACTOR#u_8814",配上 ScanIndexForward = false 以做到最新优先。读取 2 用排序键上的一个 between 条件收窄同一个分区:

Query
PK = "ACTOR#u_8814"
AND SK BETWEEN "EVT#2026-06-23T00:00:00Z"
AND "EVT#2026-06-23T23:59:59Z"

一个集合、两种访问模式、无需 GSI —— 因为排序键既是前缀 (EVT#)又是范围(时间戳)。降序读取和窗口读取是 相同顺序里的相同的项;只有参数不同。

手写那个键条件时,很容易在 between 的边界或 属性名上的保留字转义上出岔子。

DynamoDB 表达式构建器 会为一个 begins_withbetween 排序键条件生成 KeyConditionExpressionExpressionAttributeNamesExpressionAttributeValues

把它直接复制进你的 SDK 调用,而不是在运行时调试转义。

在 DynoTable 中操作

设计一个排序键是迭代式的:写入几个有代表性的项,运行范围 查询,检查行是否按你期望的顺序返回。在一个 GUI 里针对一张 活的表这么做,胜过在代码里来回折腾。

在 DynoTable 中用排序键上的 between 条件查询某个行为主体的审计日志集合,结果按最新优先排序。
在 DynoTable 中用排序键上的 between 条件查询某个行为主体的审计日志集合,结果按最新优先排序。

翻转排序方向、收紧 between 的边界,看着返回的 集合随之变化,无需写一行代码 —— 这是在你把一个排序键设计 定案之前确认它的最快方式。

陷阱与后续步骤

  • 排序键在一个分区内必须唯一。 如果两个事件可能共享一个 时间戳,就给排序键追加一个消歧项(一个序列号或短 id), 让复合键保持唯一。
  • 热分区绕不过排序。 如果一个行为主体产生的事件远 多于其余,排序键救不了你 —— 你需要一个能分散负载的分区键 设计。参见 单表设计
  • 第二种排序需要第二个索引。 基表的排序键给出 一种排序。要以不同方式(比如按事件类型)排序相同的项,就加一个 带有不同排序键的 GSI —— 并权衡 本地二级索引与全局二级索引之间的取舍。
  • 别为了"稍后再排序"而伸手去用 ScanScan 之后于客户端排序会 读取整张表并把排序丢弃;那正是 Scan 陷阱。把顺序推进排序键里才对。

一旦键条件对了,就试用 DynoTable来建模这个 集合,并排运行升序和降序查询,在把你的 排序键策略交付之前,用真实数据验证它。

更新于