高级阅读约 3 分钟

不停机的 DynamoDB 迁移

从 SQL 过来,一次迁移是一个 ALTER TABLE,它在重写每一行的同时锁住那张表。DynamoDB 没有模式可改 —— 项是无模式的,所以添加一个属性或一种新实体类型是免费的。

难的部分是新数据必须服务的那个访问模式,以及在不做一次停止世界的重写的前提下,重塑活数据去服务它。

如何在不停机的情况下迁移 DynamoDB 表?

DynamoDB 没有 ALTER TABLE,所以迁移永远不会锁表。你通过 UpdateTable 在线添加属性、新键形态或新 ,然后增量地重塑活数据:在读取时惰性回填旧项,或通过受控的扫荡来回填,并在过渡期内双写两种格式。不存在一次旗帜日切换。

  • 没有 ALTER TABLE 项是无模式的。一次”迁移”意味着添加属性、一种新键形态,或一个新索引 —— 绝非重写一组固定的列。
  • 新写入容易;旧项是问题。 现有的行不携带新属性,所以任何新索引或查询都会悄悄漏掉它们,直到你回填。
  • 在线添加索引,惰性回填。 UpdateTable 在一张活表上构建一个 GSI;在读取时回填旧项(惰性),或者用一次受控的扫荡 —— 绝非一次旗帜日切换。
  • 在过渡期内双写。 当两种形态共存时,把旧格式和新格式一起写,这样两条读取路径都不会变陈旧。

把它框定成一个访问模式,而非一列

假设你在一张表上运营一款 SaaS 工作区产品。项用 PK = "WS#<id>",而 SK 按实体重载:

PKSKattributes
WS#a91METAname, tier
WS#a91DOC#2026-04-01#x7title, author, body
WS#a91DOC#2026-04-02#k2title, author, body

现在产品想要文档上的评论,外加一个新读取:“按时间倒序列出某个成员跨工作区写下的每一条评论。” 最后那一句就是迁移。单单一种新实体类型微不足道;服务一个当前键回答不了的查询才是真正的活。

先添加新实体类型

评论无非是同一个分区里的新项 —— 没有迁移仪式,没有新表:

PKSKattributes
WS#a91DOC#2026-04-01#x7#CMT#01HZ...author, text, createdAt

一次 Query PK = "WS#a91"SK begins_with "DOC#2026-04-01#x7#CMT#" 就已经列出一篇文档的评论了。现有文档原封不动。这一半在第一天就上线 —— 至于为什么同一个分区同时容纳两者,参见项集合与重载键

新查询需要一个 GSI

“某个成员的所有评论,最新优先”没法由基表服务 —— memberId 既不是 PK 也不是某个 SK 前缀。那是一个新索引,而正确地选它本身是一个决定:参见 GSI 对比 LSI(一个 LSI 必须在建表时就存在,所以对一张活表上的迁移来说,GSI 是你唯一的选项)。

添加一个通用的 GSI1,并把新属性写在新的评论项上:

GSI1PKGSI1SK
MEMBER#u442026-04-02T09:15:00Z

Query GSI1 WHERE GSI1PK = "MEMBER#u44"ScanIndexForward = false 给出每个成员的最新优先评论。

在线构建那个索引

UpdateTable 把一个 GSI 加到一张活表上而不停机。DynamoDB 在后台把现有项回填进索引;索引报告 CREATING/正在回填,直到完成,然后翻为 ACTIVE管理 GSI)。

UpdateTable:添加 GSI1索引状态:CREATING回填现有项状态:ACTIVE查询 GSI1 安全

这里有两个陷阱。第一,AWS 警告添加一个 GSI 可能限流基表写入,如果新键分布不均 —— 在一个低流量窗口添加它,并盯住 CloudWatch。第二,即便在它变成 ACTIVE 之后,索引仍然是最终一致的;一次写入可能片刻之间在 GSI 上不可见。参见为什么 GSI 是最终一致的

回填旧项

GSI 只索引拥有 GSI1PK/GSI1SK 的项。你那些迁移前的评论 —— 在那个属性存在之前写下的 —— 永远不出现,哪怕回填完成之后。在线 GSI 回填拷贝现有项,但它没法发明那些项上没有的属性。你得把那些值加上去。

两种策略:

策略它如何工作何时使用
惰性在读取一个旧项时,把新属性写回去旧项被频繁读取;把成本细水长流地摊开
扫荡一次分页的 Scan 把每一个旧项更新一遍你需要 GSI 在某个截止期前完整

对扫荡,用 Scan 翻页,并对每一条旧评论用一次条件 UpdateItem 加上索引属性,好让你永不覆盖一次并发写入。

那个条件守护在属性尚不存在上。用 DynamoDB 表达式构建器构建并拷贝那个精确的 ConditionExpressionUpdateExpression,而非手敲 attribute_not_exists(GSI1PK)

在过渡期内双写

在每一个旧项都携带新属性之前,两种形态共存。写入路径必须在每一次写入上填充新格式 —— 新评论,以及对一条旧评论的任何更新 —— 这样那道缺口才只会缩小。

挑一个你能验证的回填结束条件:扫荡翻过了整张表,或者惰性路径已经跑得够久、未转换的项按设计已是陈旧的。只有到那时你才移除旧读取路径。跳过这一步,就是一次迁移在一小部分查询悄悄返回不完整结果的同时“完成”的方式。

在 DynoTable 中翻页一张表,以在回填期间发现缺少新索引属性的项。
在 DynoTable 中翻页一张表,以在回填期间发现缺少新索引属性的项。

暗坑

  • 添加属性 ≠ 已回填。 一个新 GSI 对旧项一开始是空的。在你信任那个查询之前先验证覆盖。
  • 就地改一个键不是迁移 —— 而是一次重写。 你没法变更一个项的 PK/SK;你在新键之下写一个新项并删除旧的。把它当作复制-再删除来规划,中间双读。
  • 没有事务式切换。 没有一个整张表翻转的瞬间。把每一步都设计成在两种形态都活着时都安全。

下一步

单表设计里对新键和重载集合做合理性检查,并通过翻页那张活表来确认回填完整。试用 DynoTable,去浏览你的表、发现未回填的项,并对着你自己的数据运行那些条件更新。

更新于