进阶阅读约 3 分钟

DynamoDB 中的单例 Item

单例 item 是一行带有固定、硬编码 key 的记录,保存着你整个应用的状态——不是每个用户 或每个订单一条,而是 条,就这一条。功能开关、一份配置 blob、一个全局熔断开关: 这类东西在关系型应用里会放进一张单行设置表。

从 SQL 过来,你会想到一张 config 表,id = 1,再来个 SELECT * FROM config。在 DynamoDB 里你用一个硬编码的 partition key 做同样的事——而且因为你 总是知道那个 key,你用 GetItem读它,而不是QueryScan

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,挑一个固定前缀加一个固定值:

PKSKattributes
SETTINGS#APPFLAGS#V1signup_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,也永远不 ScanScan 会读取整张表再在 客户端过滤——经典的 Scan 自坑——而为了取一行你能直接寻址到的数据,它简直是荒唐的 杀鸡用牛刀。

SETTINGS#APP / FLAGS#V1 的一次 GetItem 在一次强一致或最终一致读取里返回这些 开关。AWS 对一个 ≤ 4 KB 的 item 的 GetItem 计费为最终一致 0.5 RCU 或强一致 1 RCU (AWS 读/写容量文档)。 保持单例小巧,这个成本就永远是平的。

读取路径就是:应用启动或一个请求到来,你对固定 key 做 GetItem,再缓存结果。流程如下。

应用 / 请求GetItem PK=SETTINGS#APPSK=FLAGS#V1找到 item?使用开关,本地缓存回退到安全默认值

固定 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按实体做 GetItemQuery
作用域整个应用单个实体
用于全局开关、配置、系统版本个人资料、订单、任何按 id 的东西

如果你发现自己想要同一种的 两个 单例,那你手上的就不是单例——而是一个按实体 item,而那 个实体正是你忘了拿来做 key 的东西(比如按租户的配置)。

坑与下一步

  • Scan 它。 你知道 key;直接寻址它。
  • 别对它读-改-写。 用 update + condition 表达式。
  • 别让它悄无声息地缺失。 缓存未命中时默认到安全值。
  • 别用高频写入压垮它。 那是分片计数器的活儿。

单例在 单表设计里安然自处——它只是你实体行旁边、又一个带固定 key 的 item 集合。

试用 DynoTable,浏览你的表,靠固定 key 找到单例那一行,并在你构建写入路径 时手动编辑开关。

更新于