DynamoDB 中的单例 Item
单例 item 是一行带有固定、硬编码 key 的记录,保存着你整个应用的状态——不是每个用户 或每个订单一条,而是 一 条,就这一条。功能开关、一份配置 blob、一个全局熔断开关: 这类东西在关系型应用里会放进一张单行设置表。
从 SQL 过来,你会想到一张 config 表,id = 1,再来个 SELECT * FROM config。在 DynamoDB 里你用一个硬编码的 partition key 做同样的事——而且因为你
总是知道那个 key,你用 GetItem读它,而不是Query或Scan。
DynamoDB 中的单例 item 是什么?
单例 item 是 DynamoDB 中存储在一个固定、硬编码 key 下的单行记录,用于保存整个应用的全局状态——功能开关、配置 blob、系统级版本——而非每个用户或订单一条记录。由于你始终知道这个 key,可以用 GetItem 读取它,并通过 配合条件表达式来更新它。
- 单例是一个带常量 key 的 item。 你把
PK/SK硬编码进代码(例如CONFIG#GLOBAL),而不是用用户或订单 id 套模板。 - 用
GetItem读它,绝不用Scan。 你总是知道完整的 key,所以一次点读就是一个 一致、可预测的 RCU——没有过滤,不走全表。 - 它天生就是热 key。 每个请求都可能碰到同一个分区,所以缓存它、保持 item 小巧; 别让它变成写入瓶颈。
- 用 update + condition 表达式安全地改它,而不是在你的应用里做读-改-写——丢失更新 的竞态就藏在那里。
认出这个模式
当数据不归属于任何单一实体时,你手上就有全局状态了。几个信号:
- 一个对所有人都相同的开关(
signup_enabled = false)。 - 你的应用启动时读取的一团可调参数(限流值、默认配额)。
- 一个面向整个系统而非按行的计数器或版本号。
任何归属于某个用户、租户或订单的东西 都不是 单例——那是一个以该实体 id 为 key 的 普通 item。单例是那块剩下的、无处安放的全局切片。
给它一个常量 key
整个模式就系在一个决定上:key 是字面量,不是模板。对于一个重载单表里的全局功能开关 item,挑一个固定前缀加一个固定值:
| PK | SK | attributes |
|---|---|---|
| SETTINGS#APP | FLAGS#V1 | signup_enabled, maintenance_mode, ai_search_enabled |
PK = "SETTINGS#APP" 和 SK = "FLAGS#V1" 都烤进了代码里。没有用户 id,没有租户
id——应用每次都精确地索要这一个 item。这种可预测性正是关键:一个已知的 key 是一次
GetItem,而 GetItem 是 DynamoDB 提供的最便宜、最一致的读取。
V1 后缀是刻意的。如果开关 schema 之后改了形态,你写一个 FLAGS#V2 item 并把读取端
切过去,而不是就地改动那个在线的。给单例 key 加版本,为你买来一个干净的迁移接缝。
用 GetItem 读它
因为 key 完全已知,对单例你永远不 Query,也永远不 Scan。Scan 会读取整张表再在
客户端过滤——经典的
Scan 自坑——而为了取一行你能直接寻址到的数据,它简直是荒唐的
杀鸡用牛刀。
对 SETTINGS#APP / FLAGS#V1 的一次 GetItem 在一次强一致或最终一致读取里返回这些
开关。AWS 对一个 ≤ 4 KB 的 item 的 GetItem 计费为最终一致 0.5 RCU 或强一致 1 RCU
(AWS 读/写容量文档)。
保持单例小巧,这个成本就永远是平的。
读取路径就是:应用启动或一个请求到来,你对固定 key 做 GetItem,再缓存结果。流程如下。
固定 key 把一次全局查找变成一次点读,并内置了一条默认路径。
注意那条 否 分支:缺失的单例绝不该让你崩溃。默认到安全值(功能 关、维护 开),
这样首次部署的空档或一个坏 key 就会失败关闭,而不是失败打开。
在没有竞态的情况下更新它
陷阱是在你的应用里用读-改-写来更新单例:你 GetItem 取出开关,在内存里翻一个,然后把
整个东西 PutItem 写回去。两个并发写入者都读到旧 item,第二个 Put 覆盖了第一个的
改动。丢失更新。
DynamoDB 的两个特性在不靠应用端加锁的情况下杀掉这个竞态:
- Update 表达式 在服务端改动一个属性,其余的原封不动。不需要重新
Put整个 item。 - Condition 表达式 让写入只在 item 仍然符合你预期的样子时才成功,所以一个陈旧的写入
会被以
ConditionalCheckFailedException拒绝 (AWS condition 表达式文档)。
要翻转一个开关,就用 SET 只瞄准那个属性,并用一次版本递增来加护栏,这样并发写入者就
不能互相践踏:
# UpdateItem
Key PK=SETTINGS#APP SK=FLAGS#V1
UpdateExpression SET signup_enabled = :on, schema_version = :next
ConditionExpression schema_version = :current
如果两个写入者竞争,第二个的 schema_version = :current 检查失败,它就对刷新后的值
重试。你可以在
DynamoDB 表达式构建器里先把 names、values 和这个
确切的表达式形态搭好,再接进代码。想更深入了解这些运算符,见
update 表达式惯用法指南。
留意热 key
单例从构造上就是一个 热 key——你应用的每一部分都可能读取同一个分区。只要你缓存,对 读取来说这没问题,但它是这个模式唯一真正的风险。
- 激进地缓存。 每个进程(或每 N 秒)读一次开关,而不是每个请求都读。单例的值是最 值得记忆化的东西。
- 别把它变成写入热点。 一个管理员一天翻几次的开关不算什么。一个你每个请求都递增的 单例就是一个分区吞吐量瓶颈——那是计数器问题,不是单例。
- 保持它小巧。 读取成本按 4 KB 块随 item 大小增长。一团臃肿的配置 blob 会让每次 启动都比它需要的更贵。
如果你确实需要一个高写入的全局计数器,单例就是错的形态——把它分片到 N 个 item 上,读取 时求和。那是另一个模式了。
单例 vs 按实体 item
界线就在 数据归属于什么。
| 单例 item | 按实体 item | |
|---|---|---|
| Key | 硬编码常量(SETTINGS#APP) | 用 id 套模板(USER#42) |
| 有几个 | 恰好一个 | 每个用户 / 订单 / 租户一个 |
| 典型读取 | 对已知 key 做 GetItem | 按实体做 GetItem 或 Query |
| 作用域 | 整个应用 | 单个实体 |
| 用于 | 全局开关、配置、系统版本 | 个人资料、订单、任何按 id 的东西 |
如果你发现自己想要同一种的 两个 单例,那你手上的就不是单例——而是一个按实体 item,而那 个实体正是你忘了拿来做 key 的东西(比如按租户的配置)。
坑与下一步
- 别
Scan它。 你知道 key;直接寻址它。 - 别对它读-改-写。 用 update + condition 表达式。
- 别让它悄无声息地缺失。 缓存未命中时默认到安全值。
- 别用高频写入压垮它。 那是分片计数器的活儿。
单例在 单表设计里安然自处——它只是你实体行旁边、又一个带固定 key 的 item 集合。
试用 DynoTable,浏览你的表,靠固定 key 找到单例那一行,并在你构建写入路径 时手动编辑开关。