DynamoDB 中的单例项
单例项是一个具有固定、硬编码键的单行,为你的 整个应用保存状态 —— 不是每个用户或每个订单一条记录,而是 一条记录,仅此而已。特性开关、一个配置数据块、一个全局熔断开关: 就是那类关系式应用会放在一张单行设置表里的东西。
从 SQL 过来,你会伸手去用一张 config 表,id = 1,再来一句 SELECT * FROM config。在 DynamoDB 里你用一个硬编码的分区
键做同样的事 —— 而且因为你始终知道那个键,你用 GetItem 去读它,而不是
Query 或 Scan。
什么是 DynamoDB 中的单例项?
单例项是存储在一个固定、硬编码键下的单个 DynamoDB 行,它为你的整个应用保存全局状态 —— 特性开关、一个配置数据块、一个系统级版本号 —— 而不是每个用户或订单一条记录。因为你始终知道这个键,你用 GetItem 读它,用 加条件表达式更新它。
- 单例是带有一个恒定键的单个项。 你在代码里硬编码
PK/SK(例如CONFIG#GLOBAL),而不是模板化地填入某个用户或订单 id。 - 用
GetItem读它,绝不用Scan。 你始终知道完整的键,所以一次 点读具有平稳、可预期的成本(对一个小项最多 1 RCU)—— 无过滤器,无全表遍历。 - 它按定义就是一个。 每个请求都可能触及同一个分区, 所以要缓存它并让项保持精简;别把它变成一个写入瓶颈。
- 用更新表达式加条件表达式来安全地改动它,而不是在你的应用里做读-改-写 —— 那正是丢失更新的竞态藏身之处。
识别这个模式
当数据不隶属于任何单个实体时,你就有了全局状态。有几个信号:
- 一个对所有人都相同的开关(
signup_enabled = false)。 - 一块你的应用在启动时读取的可调参数(限流阈值、默认配额)。
- 一个针对整个系统、而非按行的计数器或版本号。
任何隶属于某个用户、租户或订单的东西都不是单例 —— 那是一个 以该实体 id 为键的普通项。单例是那个无处安放、剩下来的 全局切片。
给它一个恒定的键
整个模式都系于一个决定。键是一个字面量,而不是一个模板。 对于一个重载单表中的全局特性开关项,选一个固定的 前缀和一个固定的值:
| PK | SK | attributes |
|---|---|---|
| SETTINGS#APP | FLAGS#V1 | signup_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,在内存里翻转一个,然后把整个东西 PutItem 写回。
两个并发的写入方都读到了旧项,第二个 Put 覆盖了第一个的
改动。丢失更新。
DynamoDB 的两个特性无需应用侧加锁就能消除这个竞态:
- 在服务端改动一个属性,其余原封
不动。无需把整个项重新
Put。 - 让写入仅在项仍然如你所期望的
那样时才成功,因此一个陈旧的写入会被拒绝,并抛出
ConditionalCheckFailedException(AWS 条件表达式文档)。
要翻转一个开关,用 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 | 按实体做 GetItem 或 Query |
| 作用域 | 整个应用 | 单个实体 |
| 用于 | 全局开关、配置、系统版本 | 资料、订单,任何按 id 的东西 |
如果你发现自己想要_两个_同类的单例,那你就没有一个 单例 —— 你有的是一个按实体项,而那个实体正是你忘了拿来 做键的东西(比如按租户的配置)。
陷阱与后续步骤
- 别对它做
Scan。 你知道键;直接寻址它。 - 别对它做读-改-写。 用更新加条件表达式。
- 别让它悄无声息地缺失。 缓存未命中时默认回退到安全的值。
- 别用高频写入去重载它。 那是一个分片计数器的活儿。
单例在单表设计里安适地 栖身 —— 它无非是又一个带有固定键、与你的实体行并列的项集合。
试用 DynoTable,按固定键浏览你的表、找到那个单例行, 在你构建写入路径的同时手动编辑开关。