不停机的 DynamoDB 迁移
从 SQL 过来,一次迁移是一个 ALTER TABLE,它在重写每一行的同时锁住那张表。DynamoDB 没有模式可改 —— 项是无模式的,所以添加一个属性或一种新实体类型是免费的。
难的部分是新数据必须服务的那个访问模式,以及在不做一次停止世界的重写的前提下,重塑活数据去服务它。
如何在不停机的情况下迁移 DynamoDB 表?
DynamoDB 没有 ALTER TABLE,所以迁移永远不会锁表。你通过 UpdateTable 在线添加属性、新键形态或新 ,然后增量地重塑活数据:在读取时惰性回填旧项,或通过受控的扫荡来回填,并在过渡期内双写两种格式。不存在一次旗帜日切换。
- 没有
ALTER TABLE。 项是无模式的。一次”迁移”意味着添加属性、一种新键形态,或一个新索引 —— 绝非重写一组固定的列。 - 新写入容易;旧项是问题。 现有的行不携带新属性,所以任何新索引或查询都会悄悄漏掉它们,直到你回填。
- 在线添加索引,惰性回填。
UpdateTable在一张活表上构建一个 GSI;在读取时回填旧项(惰性),或者用一次受控的扫荡 —— 绝非一次旗帜日切换。 - 在过渡期内双写。 当两种形态共存时,把旧格式和新格式一起写,这样两条读取路径都不会变陈旧。
把它框定成一个访问模式,而非一列
假设你在一张表上运营一款 SaaS 工作区产品。项用 PK = "WS#<id>",而 SK 按实体重载:
| PK | SK | attributes |
|---|---|---|
| WS#a91 | META | name, tier |
| WS#a91 | DOC#2026-04-01#x7 | title, author, body |
| WS#a91 | DOC#2026-04-02#k2 | title, author, body |
现在产品想要文档上的评论,外加一个新读取:“按时间倒序列出某个成员跨工作区写下的每一条评论。” 最后那一句就是迁移。单单一种新实体类型微不足道;服务一个当前键回答不了的查询才是真正的活。
先添加新实体类型
评论无非是同一个分区里的新项 —— 没有迁移仪式,没有新表:
| PK | SK | attributes |
|---|---|---|
| WS#a91 | DOC#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,并把新属性写在新的评论项上:
| GSI1PK | GSI1SK |
|---|---|
| MEMBER#u44 | 2026-04-02T09:15:00Z |
Query GSI1 WHERE GSI1PK = "MEMBER#u44" 配 ScanIndexForward = false 给出每个成员的最新优先评论。
在线构建那个索引
UpdateTable 把一个 GSI 加到一张活表上而不停机。DynamoDB 在后台把现有项回填进索引;索引报告 CREATING/正在回填,直到完成,然后翻为 ACTIVE(管理 GSI)。
这里有两个陷阱。第一,AWS 警告添加一个 GSI 可能限流基表写入,如果新键分布不均 —— 在一个低流量窗口添加它,并盯住 CloudWatch。第二,即便在它变成 ACTIVE 之后,索引仍然是最终一致的;一次写入可能片刻之间在 GSI 上不可见。参见为什么 GSI 是最终一致的。
回填旧项
GSI 只索引拥有 GSI1PK/GSI1SK 的项。你那些迁移前的评论 —— 在那个属性存在之前写下的 —— 永远不出现,哪怕回填完成之后。在线 GSI 回填拷贝现有项,但它没法发明那些项上没有的属性。你得把那些值加上去。
两种策略:
| 策略 | 它如何工作 | 何时使用 |
|---|---|---|
| 惰性 | 在读取一个旧项时,把新属性写回去 | 旧项被频繁读取;把成本细水长流地摊开 |
| 扫荡 | 一次分页的 Scan 把每一个旧项更新一遍 | 你需要 GSI 在某个截止期前完整 |
对扫荡,用 Scan 翻页,并对每一条旧评论用一次条件 UpdateItem 加上索引属性,好让你永不覆盖一次并发写入。
那个条件守护在属性尚不存在上。用 DynamoDB 表达式构建器构建并拷贝那个精确的 ConditionExpression 和 UpdateExpression,而非手敲 attribute_not_exists(GSI1PK)。
在过渡期内双写
在每一个旧项都携带新属性之前,两种形态共存。写入路径必须在每一次写入上填充新格式 —— 新评论,以及对一条旧评论的任何更新 —— 这样那道缺口才只会缩小。
挑一个你能验证的回填结束条件:扫荡翻过了整张表,或者惰性路径已经跑得够久、未转换的项按设计已是陈旧的。只有到那时你才移除旧读取路径。跳过这一步,就是一次迁移在一小部分查询悄悄返回不完整结果的同时“完成”的方式。

暗坑
- 添加属性 ≠ 已回填。 一个新 GSI 对旧项一开始是空的。在你信任那个查询之前先验证覆盖。
- 就地改一个键不是迁移 —— 而是一次重写。 你没法变更一个项的
PK/SK;你在新键之下写一个新项并删除旧的。把它当作复制-再删除来规划,中间双读。 - 没有事务式切换。 没有一个整张表翻转的瞬间。把每一步都设计成在两种形态都活着时都安全。
下一步
在单表设计里对新键和重载集合做合理性检查,并通过翻页那张活表来确认回填完整。试用 DynoTable,去浏览你的表、发现未回填的项,并对着你自己的数据运行那些条件更新。


