如何在 DynamoDB 中建模数据
在 SQL 里,你先建模实体和关系,然后信任查询规划器日后替你把要的任何东西拼出来。DynamoDB 把这一切反过来。你建模的是你已经知道自己会做的那些读取,而键的存在就是为了服务它们。
这里没有连接引擎,没有规划器在运行时挑策略。一次 Query 沿一个键读取一个分区,这就是全部的性能契约。所以你为已知的访问模式设计键,而不是为一份整洁的 schema。
AWS 在它的最佳实践指南里说得直白:「在你知道 schema 需要回答哪些问题之前,就不该开始设计它。」
本指南在一个领域上把整个流程走一遍:一个多人游戏排行榜,跟踪玩家、他们打的对局,以及他们的赛季排名。我们从一份问题清单出发,走到一套可用的键 schema。
你该如何在 DynamoDB 中建模数据?
先建模读取,而不是表。列出应用会做的每一个查询,然后设计一个和,让每个问题都归结为单次 Query 或 GetItem。把一起读取的项目放在一处,把要范围遍历的值放进排序键,并为基表服务不了的任何访问模式加一个 GSI。
- 先列出读取,而不是表。 那些问题就是规格;那些名词是干扰。
- 每个问题都必须是单次
Query或GetItem。 如果一个问题需要Scan,模型就错了。 - 放在一处的项目共享一个;任何你要范围遍历的东西都进。
- 基表答不了的问题就配一个 ——绝不用带过滤的
Scan。
第 1 步——把问题框成问题,而不是表
抑制住画 players、matches、scores 表的冲动。那种本能是 SQL 习惯,在这里是错的。相反,写下应用实际执行的每一次读取。对我们的排行榜:
- 按 id 取一位玩家的 profile。
- 列出一位玩家最近的对局,最新的在前。
- 展示某赛季排名前 N 的玩家,按评分排序。
- 按公开的用户名查一位玩家(例如用于 profile URL)。
这四个问题——而不是那些名词——就是规格。每一个都必须归结为单次 Query(或 GetItem),因为那是 DynamoDB 在规模下唯一能廉价服务的访问形态。
如果一个问题只能靠扫描表来回答,那模型就错了,而你会在延迟和成本上感受到它——原因见 Query vs Scan,讲清为什么 Scan 是要避开的坑。
整套方法是一条你在每个领域上跑一遍的、简短而有序的流水线:
下面的每一步都对应其中一个方框:列出、枚举、设计键、为其余的加索引,然后验证。
第 2 步——弄懂你用来建模的那些原语
一张表有一个分区键(PK),决定项目住在哪个物理分区上,还有一个可选的排序键(SK),在那个分区_内部_给项目排序。
AWS 的核心组件文档把这一对称为项目的主键。一次 Query 总是瞄准恰好一个 PK 值,并能对 SK 做范围扫描或过滤——这就是全部工具。
正是这种单分区设计,让 DynamoDB 能交付 2007 年 Amazon Dynamo 论文里最早描述的那种可预测、低延迟、水平分区的读取。
有两条结论驱动着下面的每个决定:
- 一起读取的项目应该共享一个分区键,这样单次
Query就能在一次计费请求里把它们返回。 - 任何你想范围遍历的东西(最近的对局、最高的评分)都必须住在排序键里,因为那是
Query唯一能排序和界定的属性。
当一个问题需要一种_不同_于基表所提供的访问形态时,你就加一个全局二级索引——把表在一个不同的 PK/SK 下重新投影一份。
(关于 GSI 与本地二级索引的对比,见 GSI vs LSI。)
第 3 步——一次一个问题地设计键
我们用单张表配上通用的、重载的键属性——单表方法——因为一位玩家和他的对局是一起读取的。
自己发明前缀;这里 PLAYER#、MATCH# 和 SEASON# 在原本通用的键里标记实体类型。
问题 1 和 2(profile + 最近对局)共享一个分区,所以两者都挂在同一个 PK 上:
| partitionId | rangeId | attributes |
|---|---|---|
| PLAYER#u8231 | PROFILE | handle, region, createdAt |
| PLAYER#u8231 | MATCH#2026-06-23T14 | result=win, ratingDelta=+18, mapId |
| PLAYER#u8231 | MATCH#2026-06-23T11 | result=loss, ratingDelta=-15, mapId |
Query partitionId = "PLAYER#u8231" 在一次读取里返回 profile 和每一场对局。只要 profile,就用 GetItem。
对于最近对局,rangeId begins_with "MATCH#" 搭配 ScanIndexForward = false 会把它们最新在前地遍历——排序键里的时间戳免费完成了排序。
问题 3 和 4没法从那个分区回答——它们围绕赛季排名和用户名转,二者都不是基表的 PK。每个都配一个 GSI。
我们添加两对通用的索引属性——用于排名索引的 seasonPartition / seasonSort,以及用于用户名索引的 handlePartition / handleSort——填在同一个 profile 项目上(就是第 3 步里写入的那个,现在展示为已填好它的索引属性):
| partitionId | rangeId | seasonPartition | seasonSort | handlePartition | handleSort |
|---|---|---|---|---|---|
| PLAYER#u8231 | PROFILE | SEASON#2026-Q2 | RATING#1842 | HANDLE#nighthawk | PLAYER#u8231 |
现在对赛季索引 Query,WHERE seasonPartition = "SEASON#2026-Q2" 搭配 ScanIndexForward = false,返回按评分排名的玩家——那就是排行榜。
第二个索引以 handlePartition = "HANDLE#…" 为键,一次读取就把一个公开用户名解析成一个玩家 id。一张物理表,四种单次 Query 的访问模式。
关于
RATING#1842的一条提示:DynamoDB 对排序键按字典序排序,而不是按数值,所以评分必须零填充到固定宽度(RATING#01842),否则9会排在1000之后。这是一个经典的建模坑,值得提前搞对。
第 4 步——在 DynoTable 中验证模型
一套键 schema 只有当你亲眼看到一次真实的 Query 恰好返回你预期的项目、不多不少时,才赢得信任。
在 DynoTable 中打开这张表,对赛季索引运行排行榜查询,确认返回的分区是排好名且有界的——没有 Scan,没有客户端排序。

当你为这些查询构建条件表达式时——那个 begins_with、那个 seasonPartition = :p、那个占位符 :p 绑定——让 DynamoDB 表达式构建器来做。
它会生成 KeyConditionExpression、ExpressionAttributeNames 和 ExpressionAttributeValues,这样像 result 这样的保留字或一个打错的占位符就永远不会悄悄弄坏一次读取。
第 5 步——坑点与后续步骤
在你交付这个模型之前,有几个陷阱要核查:
- 别去建模你从不一起读取的关系。 每个问题一个 GSI 是便宜的;一个白费的 GSI 是持续的成本。从问题清单出发添加索引,而不是投机性地加。
- 留意分区热度。 如果一个 PK(一位明星玩家、单个火爆赛季)吸走了大部分流量,那个分区就会限流。当一个键被证实很热时,用一个后缀分片把写入摊开——AWS 在分区键设计下讲到了这一点。
- 对排序键里一切数值或时间的东西做零填充和 ISO-8601,这样字典序排序就与你所指的顺序一致。
- 一个新问题 = 一个新键或索引,绝不是一次
Scan。 当日后真的冒出一个全新的访问模式时,扩展键;别用一个过滤把它糊过去。
先建模问题,设计键让每个问题都是单次 Query,然后验证它。
要为中间那一步抢个先手,免费的单表设计工具能把一份像这样的访问模式清单变成一个 PK/SK/GSI 方案,附带示例项和成本提示。
试用 DynoTable 去浏览你的表,对基表和各个 GSI 并排运行这些查询,看着你设计的那些访问模式恰好返回你所规划的东西。而对于你 没有 建模的那个问题,它的 SQL Workbench 会在客户端运行真正的 JOIN、GROUP BY 和聚合。


