进阶阅读约 3 分钟

DynamoDB 中的单例项

单例项是一个具有固定、硬编码键的单行,为你的 整个应用保存状态 —— 不是每个用户或每个订单一条记录,而是 条记录,仅此而已。特性开关、一个配置数据块、一个全局熔断开关: 就是那类关系式应用会放在一张单行设置表里的东西。

从 SQL 过来,你会伸手去用一张 config 表,id = 1,再来一句 SELECT * FROM config。在 DynamoDB 里你用一个硬编码的分区 键做同样的事 —— 而且因为你始终知道那个键,你用 GetItem 去读它,而不是 QueryScan

什么是 DynamoDB 中的单例项?

单例项是存储在一个固定、硬编码键下的单个 DynamoDB 行,它为你的整个应用保存全局状态 —— 特性开关、一个配置数据块、一个系统级版本号 —— 而不是每个用户或订单一条记录。因为你始终知道这个键,你用 GetItem 读它,用 加条件表达式更新它。

  • 单例是带有一个恒定键的单个项。 你在代码里硬编码 PK/SK (例如 CONFIG#GLOBAL),而不是模板化地填入某个用户或订单 id。
  • GetItem 读它,绝不用 Scan 你始终知道完整的键,所以一次 点读具有平稳、可预期的成本(对一个小项最多 1 RCU)—— 无过滤器,无全表遍历。
  • 它按定义就是一个 每个请求都可能触及同一个分区, 所以要缓存它并让项保持精简;别把它变成一个写入瓶颈。
  • 用更新表达式加条件表达式来安全地改动它,而不是在你的应用里做读-改-写 —— 那正是丢失更新的竞态藏身之处。

识别这个模式

当数据不隶属于任何单个实体时,你就有了全局状态。有几个信号:

  • 一个对所有人都相同的开关(signup_enabled = false)。
  • 一块你的应用在启动时读取的可调参数(限流阈值、默认配额)。
  • 一个针对整个系统、而非按行的计数器或版本号。

任何隶属于某个用户、租户或订单的东西都不是单例 —— 那是一个 以该实体 id 为键的普通项。单例是那个无处安放、剩下来的 全局切片。

给它一个恒定的键

整个模式都系于一个决定。键是一个字面量,而不是一个模板。 对于一个重载单表中的全局特性开关项,选一个固定的 前缀和一个固定的值:

PKSKattributes
SETTINGS#APPFLAGS#V1signup_enabled, maintenance_mode, ai_search_enabled

PK = "SETTINGS#APP"SK = "FLAGS#V1" 被烘焙进代码里。没有 用户 id,没有租户 id —— 应用每次都请求恰好这一个项。 那份可预测性正是要点所在:一个已知的键就是一次 GetItem,而一次 GetItem 是 DynamoDB 所能提供的最廉价、最一致的读取。

V1 后缀是刻意为之的。如果这个开关的 schema 日后改变了形态,你写入 一个 FLAGS#V2 项并把读取方切换过去,而不是原地改动那个正在使用的项。 给单例键加版本,为你买到一条干净的迁移接缝。

用 GetItem 读它

因为键是完全已知的,你从不对单例做 Query,也从不做 Scan。 一次 Scan 会读取整张表并在客户端过滤 —— 经典的 Scan 陷阱 —— 而为了取一个你可以直接寻址的行, 这荒唐地小题大做。

SETTINGS#APP / FLAGS#V1 做一次 GetItem,会在单次 强一致或最终一致读取中返回这些开关。对于一个 ≤ 4 KB 的项,AWS 把一次 GetItem 计为最终一致 0.5 RCU 或强一致 1 RCU (AWS 读/写容量文档)。 让单例保持精简,这个成本就永远保持平稳。

在读取路径上,应用启动或一个请求到来,你对固定 键做 GetItem,你缓存结果。这是它的流程。

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

固定的键把一次全局查找变成了一次带有内置默认路径的点读。

注意 分支:缺失的单例绝不应让你崩溃。默认回退到 安全的值(特性、维护),这样一次首发部署的空档或一个坏的 键就会安全失败,而不是危险敞开。

在无竞态的情况下更新它

陷阱在于用你应用里的读-改-写来更新单例:你 对开关做 GetItem,在内存里翻转一个,然后把整个东西 PutItem 写回。 两个并发的写入方都读到了旧项,第二个 Put 覆盖了第一个的 改动。丢失更新。

DynamoDB 的两个特性无需应用侧加锁就能消除这个竞态:

  • 在服务端改动一个属性,其余原封 不动。无需把整个项重新 Put
  • 让写入仅在项仍然如你所期望的 那样时才成功,因此一个陈旧的写入会被拒绝,并抛出 ConditionalCheckFailedExceptionAWS 条件表达式文档)。

要翻转一个开关,用 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 表达式构建器里搭建名称、值以及 这个确切的表达式形态。要更深入地了解这些运算符,参见 更新表达式惯用法指南。

留意热键

单例按构造就是一个热键 —— 你应用的每个部分都可能读取 同一个分区。若你有缓存,这对读取无妨,但它是这个模式唯一 真正的风险。

  • 积极地缓存。 每个进程(或每 N 秒)读取一次开关,而不是 每个请求都读。单例的值是最值得记忆化的东西。
  • 别让它成为一个写入热点。 一个管理员每天翻几次的开关 不算什么。而一个你在每个请求上都递增的单例则是一个分区吞吐量 瓶颈 —— 那是一个计数器问题,不是一个单例问题。
  • 让它保持精简。 读取成本按 4 KB 的块随项大小增长。一个臃肿的 配置数据块会让每次启动都比它本该有的更昂贵。

如果你确实需要一个高写入的全局计数器,那单例就是错误的 形态 —— 把它分片到 N 个项上,在读取时求和。那是另一个模式。

单例项与按实体项对比

界线只是_数据隶属于什么_。

单例项按实体项
硬编码常量(SETTINGS#APP用某个 id 模板化(USER#42
有多少个恰好一个每个用户/订单/租户一个
典型读取对已知键做 GetItem按实体做 GetItemQuery
作用域整个应用单个实体
用于全局开关、配置、系统版本资料、订单,任何按 id 的东西

如果你发现自己想要_两个_同类的单例,那你就没有一个 单例 —— 你有的是一个按实体项,而那个实体正是你忘了拿来 做键的东西(比如按租户的配置)。

陷阱与后续步骤

  • 别对它做 Scan 你知道键;直接寻址它。
  • 别对它做读-改-写。 用更新加条件表达式。
  • 别让它悄无声息地缺失。 缓存未命中时默认回退到安全的值。
  • 别用高频写入去重载它。 那是一个分片计数器的活儿。

单例在单表设计里安适地 栖身 —— 它无非是又一个带有固定键、与你的实体行并列的项集合。

试用 DynoTable,按固定键浏览你的表、找到那个单例行, 在你构建写入路径的同时手动编辑开关。

更新于