DynamoDB 的存储内部机制是如何工作的
DynamoDB 是一张哈希表,其值是一棵棵已排序的树,散布在三个 可用区中的多台机器上。两个数据结构承担了几乎全部的工作:一次对 的哈希挑出一台机器,一棵建立在上的 B 树在其 内部为各项排序。
DynamoDB 是如何存储数据的?
DynamoDB 把你的数据存储为一张巨大的分布式哈希表,其值是一棵棵已排序的 B 树。一次对的哈希挑出一个存储节点;一棵 B 树在其内部按排序键为各项排序。每一次写入都交给一个领导者,由它复制到跨三个可用区的两个对等节点,并在法定多数(三个副本中的两个)拿到它之后确认。
- 分区键是被哈希的,不是被搜索的。 DynamoDB 对你的
PK运行一个哈希 函数,以找到持有该分区的存储节点(或多个节点,一旦一个大集合发生 拆分)—— 一次 O(1) 的跳转,与表大小无关。 - 排序键存在一棵 B 树里。 在一个分区内,各项存储在一棵按
排序键排序的 B 树里 —— 字符串按 UTF-8 字节顺序,Number 键按数值
顺序 —— 这正是为什么范围读取(
begins_with、between)廉价而Scan不然。 - 每一次写入在法定多数确认后才应答。 一次写入交给一个领导者,由它
复制到其他可用区的两个对等节点,并在法定多数(三个
副本中的两个)拿到它之后确认 —— 持久性在你的
PutItem返回之前就已买单。 - 这正是访问模式规则存在的原因。 先哈希后走树,只有在你 按键读取时才快。没有键,就没有快速路径 —— 你会退化到扫描那棵树。
从数据结构说起,而不是从 API
从 SQL 过来,你把一张表想象成磁盘上的行,配一个挑选 索引的查询规划器。DynamoDB 没有规划器。存储布局_就是_那份契约 —— 什么 快、什么是陷阱,都直接从两个结构里掉出来。
设想一张巨大的分布式映射。键是你分区键的一个哈希。 值不是单个项 —— 而是一整棵共享那个分区键的项的 B 树,按排序键排序。
其他的一切 —— 查询语义、那些 10 GB 警告、为什么一个缺失的键会逼出
一次 Scan —— 都是那一句话的推论。
对分区键做哈希以找到节点
请求到达时,DynamoDB 对分区键的值应用一个内部哈希 函数。这个哈希确定性地映射到一个存储节点 —— 拥有那些项的物理分区。AWS 把这记录为 无论表大小如何都能实现常数时间键查找背后的机制。
那就是 O(1) 那一步。一张 10 TB 的表和一张 10 KB 的表,_定位_成本相同: 哈希、跳转、搞定。没有用来找节点的索引扫描,没有统计信息,没有计划。
难处在其反面。如果你不提供分区键,DynamoDB 就
没有节点可跳 —— 它只好走遍每一个分区。那是一次 Scan,也正是
O(1) 与读取整张表之间的差别。
请求哈希到恰好一个分区,然后沿着该分区的 排序键 B 树下降到那个项 —— 两个廉价步骤,而非一次全表遍历。
在每分区一棵 B 树里为各项排序
在单个分区内,各项不是一个堆。它们被保存在一棵以排序
键为键的 B 树里,按字典序排序。一次 B 树搜索是 O(log n),而
关键在于那个 n 是_一个_分区里的项数,不是整张表。
这正是排序键范围读取廉价的全部原因。设想一张机队遥测 表,每台设备的读数都归在一个分区键下:
| PK | SK |
|---|---|
| PK = DEVICE#a91 | SK = READING#2026-06-23T08:00Z |
| PK = DEVICE#a91 | SK = READING#2026-06-23T08:05Z |
| PK = DEVICE#a91 | SK = READING#2026-06-23T08:10Z |
因为 B 树是有序的,"08:00 到 09:00 之间的所有读数"就是一次 沿树下降到起始值再加一次顺序遍历 —— 而不是对该设备曾发送过的 每一条读数做一次过滤。你只读取匹配的那个范围。
那份排序也是为什么一个 begins_with(SK, "READING#2026-06-23") 查询快,
而对一个非键属性做过滤则不然。这棵树能按 SK 定位;它
无法按其他任何东西定位。要安全地组合那些键条件,请在
DynamoDB 表达式构建器里构建它们,而不是
手工拼接字符串:
KeyConditionExpression PK = :pk AND begins_with(SK, :day)
把每一次写入复制到三个可用区
一个分区不是一台机器。每一个都跨三个可用区里的三个 节点复制 —— 即 2022 年 USENIX ATC DynamoDB 论文里详述的那种基于领导者的 法定多数设计(2007 年 Amazon Dynamo 论文是命名与 理念上的祖先,而不是这个复制模型)。
一个节点是该分区的领导者。一次写入交给领导者,由它
在本地写入并复制到它的两个对等节点。领导者在一个
持久的法定多数节点拿到它之后确认写入 —— 所以跨可用区的持久性在
你的 PutItem 返回_之前_就已付清。
读取有得选。一次读取交给领导者,能看到 最新提交的写入。一次读取可以由 三个节点中的任意一个来提供,其中之一可能落后几毫秒 —— 那正是 你为更廉价、更高可用的读取所交换的滞后。
| 最终一致 | 强一致 | |
|---|---|---|
| 由谁提供 | 3 个节点中的任意一个 | 仅领导者节点 |
| 看到最新写入 | 也许(小滞后) | 总是 |
| RCU 成本 | 一半 | 全额 |
| 可用性 | 更高 | 更低(单个节点) |
在按需计费下,一次强一致读取一个 2 KB 的项要花 1 RCU;同样这次读取若是最终一致, 则花 0.5 RCU。用定价计算器把你的热路径按行费率算一遍。
同样的异步传播思路,也是为什么一次 GSI 读取可能读到旧值 —— 参见 GSI 是最终一致的。
从结构里读出规则
几乎每一条 DynamoDB "规则"都无非是存储物理学:
- 总是提供分区键。 没有键,就没有哈希目标 —— 你是在扫描 整张映射。这是 Query 与 Scan 对比的核心。
- 把你一起读取的东西共置在一个分区键之下,这样一次 哈希加树遍历就返回整个项集合。那是 单表设计的基石。
- 让分区保持有界。 一个分区就是有限一组节点上的一棵 B 树; 一个失控的热键或一个 LSI 的 10 GB 上限,都是那个物理 分区的限制。
一旦你看清了先哈希后 B 树的形态,那份访问模式的纪律就不再 显得任意 —— 你无非是在让每一次读取都保持在快速路径上。
后续步骤
用排序键策略 和单表设计来对齐结构地建模你的键,然后在 DynamoDB 表达式构建器里组装实际的表达式。 试用 DynoTable,看着这些读取针对你自己的表运行, 看清一个键条件究竟拉回哪些项。