DynamoDB — CodeRoadMapDynamoDB
这一课你将能做到
- 设计 DynamoDB 表结构(分区键 + 排序键),并解释为什么不能像 MySQL 那样随便查
- 创建 GSI(全局二级索引)支持多维度查询,并理解 GSI 和 LSI 的区别
- 用 DynamoDB Streams 实现数据变更事件,配合 Lambda 做异步处理
为什么要学
DynamoDB 是 AWS 的 Serverless NoSQL 数据库,单表可支撑百万级 QPS,无需管理服务器。本站如果迁到 AWS,用户行为日志、课程浏览记录、实时排行榜这类海量键值数据,用 DynamoDB 比 MySQL 更合适。面试中「DynamoDB 分区键设计」「GSI 和 LSI 区别」「DynamoDB 和 Redis 怎么选」是高频问题。
人话:DynamoDB 就是一个超大规模的「键值对仓库」,但它比 Redis 持久,比 MySQL 快。代价是:你不能随便 SELECT * FROM table WHERE xxx,必须提前设计好「怎么查」,用索引支持查询模式。
先建立图像
DynamoDB 数据模型 vs MySQL:
MySQL 思维(关系型):
┌─────────┬─────────┬─────────┐
│ user_id │ course_id│ progress│
├─────────┼─────────┼─────────┤
│ 1001 │ java-101│ 80% │
│ 1001 │ aws-101 │ 30% │
│ 1002 │ java-101│ 50% │
└─────────┴─────────┴─────────┘
查询:SELECT * WHERE user_id = 1001
DynamoDB 思维(键值型):
Partition Key (PK) Sort Key (SK) Attributes
─────────────────────────────────────────────────────
USER#1001 COURSE#java-101 {progress: 80%, last_access: ...}
USER#1001 COURSE#aws-101 {progress: 30%, last_access: ...}
USER#1002 COURSE#java-101 {progress: 50%, last_access: ...}
查询:GetItem(PK="USER#1001", SK="COURSE#java-101")
Query(PK="USER#1001") ← 查某用户的所有课程
GSI 与 LSI 对比:
| 特性 | LSI(本地二级索引) | GSI(全局二级索引) |
|---|
| 分区键 | 必须与基表相同 | 可以完全不同 |
| 创建时机 | 建表时创建,之后不能加 | 随时可加可删 |
| 容量 | 共享基表的 RCU/WCU | 独立配置 RCU/WCU |
| 大小限制 | 10GB(每分区键值) | 无限制 |
| 一致性 | 强一致或最终一致 | 只有最终一致 |
| 适用场景 | 同一分区内多维度查 | 完全不同的查询维度 |
跟着做
步骤 1:创建 DynamoDB 表
# 创建用户课程进度表
aws dynamodb create-table --table-name UserCourseProgress --attribute-definitions AttributeName=PK,AttributeType=S AttributeName=SK,AttributeType=S --key-schema AttributeName=PK,KeyType=HASH AttributeName=SK,KeyType=RANGE --billing-mode PAY_PER_REQUEST --tags Key=Project,Value=CodeNav
--billing-mode PAY_PER_REQUEST:按需计费,适合流量 unpredictable 的场景。预置模式(PROVISIONED)适合流量稳定、可预测的场景,更便宜。
步骤 2:写入数据
# 写入单条
aws dynamodb put-item --table-name UserCourseProgress --item '{"PK": {"S": "USER#1001"}, "SK": {"S": "COURSE#java-101"}, "progress": {"N": "80"}, "last_access": {"S": "2024-10-07T10:00:00Z"}}'
# 批量写入(BatchWriteItem,最多 25 条)
aws dynamodb batch-write-item --request-items file://batch-write.json
{
"UserCourseProgress": [
{
"PutRequest": {
"Item": {
"PK": {"S": "USER#1001"},
"SK": {"S": "COURSE#aws-101"},
"progress": {"N": "30"},
"last_access": {"S": "2024-10-07T11:00:00Z"}
}
}
}
]
}
步骤 3:查询数据
# 精确查询(GetItem)
aws dynamodb get-item --table-name UserCourseProgress --key '{"PK": {"S": "USER#1001"}, "SK": {"S": "COURSE#java-101"}}'
# 条件查询(Query)——查某用户的所有课程
aws dynamodb query --table-name UserCourseProgress --key-condition-expression "PK = :pk" --expression-attribute-values '{":pk": {"S": "USER#1001"}}'
# 前缀查询——查某用户的所有 java 课程
aws dynamodb query --table-name UserCourseProgress --key-condition-expression "PK = :pk AND begins_with(SK, :prefix)" --expression-attribute-values '{":pk": {"S": "USER#1001"}, ":prefix": {"S": "COURSE#java"}}'
步骤 4:创建 GSI 支持按课程查用户
aws dynamodb update-table --table-name UserCourseProgress --attribute-definitions AttributeName=SK,AttributeType=S AttributeName=PK,AttributeType=S --global-secondary-index-updates '[{"Create": {"IndexName": "CourseIndex", "KeySchema": [{"AttributeName": "SK", "KeyType": "HASH"}, {"AttributeName": "PK", "KeyType": "RANGE"}], "Projection": {"ProjectionType": "ALL"}, "BillingMode": "PAY_PER_REQUEST"}}]'
这个 GSI 把 SK(课程)当分区键,PK(用户)当排序键,实现「查某门课的所有学习者」。
步骤 5:启用 DynamoDB Streams
# 开启 Stream
aws dynamodb update-table --table-name UserCourseProgress --stream-specification StreamEnabled=true,StreamViewType=NEW_AND_OLD_IMAGES
# 查看 Stream ARN
aws dynamodb describe-table --table-name UserCourseProgress --query "Table.LatestStreamArn"
步骤 6:Lambda 触发器处理 Stream
# lambda_handler.py
import json
def lambda_handler(event, context):
for record in event['Records']:
if record['eventName'] == 'MODIFY':
new_image = record['dynamodb']['NewImage']
old_image = record['dynamodb']['OldImage']
# 如果进度从 <100% 变成 100%,发完成通知
if int(old_image['progress']['N']) < 100 and int(new_image['progress']['N']) == 100:
user_id = new_image['PK']['S']
course_id = new_image['SK']['S']
send_completion_notification(user_id, course_id)
return {'statusCode': 200}
讲清楚要点
- 分区键设计是灵魂:分区键决定数据物理分布。高基数的键(如用户 ID)分布均匀,低基数的键(如状态「active/inactive」)会造成热点。避免「热点分区」是 DynamoDB 设计的第一原则。
- Query 是高效的,Scan 是昂贵的:Query 用索引定位数据,按读取量计费。Scan 全表扫描,数据量越大越贵,生产环境禁用 Scan 或加并行限制。
- 项目(Projection)控制索引大小:GSI 的 Projection 决定哪些属性复制到索引。
KEYS_ONLY 最小,INCLUDE 适中,ALL 最大。索引越大,写成本越高(WCU 消耗)。
- RCU/WCU 计算:1 RCU = 4KB 强一致读/秒,或 8KB 最终一致读/秒。1 WCU = 1KB 写/秒。估算:1000 QPS 读 1KB 对象,最终一致约 125 RCU。
- DynamoDB 不是万能的:不支持 JOIN、不支持复杂事务(有事务 API 但限制多)、单条 400KB 上限。关系复杂、需要 SQL 灵活查询的场景,还是 RDS 合适。
你可能会懵的地方
-
DynamoDB 和 Redis 怎么选?
Redis 是内存缓存,微秒级延迟,适合热点数据、Session、排行榜。DynamoDB 是持久化 NoSQL,毫秒级延迟,适合海量数据、长期存储。两者常配合用:DynamoDB 做持久层,Redis/DAX 做缓存层。
-
DAX 是什么?
DynamoDB Accelerator,DynamoDB 的托管缓存。在应用和 DynamoDB 之间加一层内存缓存,读延迟降到微秒级。适合读多写少的场景。
-
GSI 更新是同步的吗?
不是。GSI 是异步更新的,基表写成功后,GSI 在毫秒级内同步。所以 GSI 只有最终一致性,不能做强一致查询。
-
单条 400KB 上限怎么突破?
大对象存 S3,DynamoDB 里只存 S3 的 key 和元数据。这是标准做法。
-
DynamoDB 有事务吗?
有,但限制多:最多 100 个 item,总大小 4MB,不支持跨 Region。复杂事务还是用 RDS。
错法对照
| 错法 | 问题 | 正解 |
|---|
| 用低基数键做分区键 | 热点分区,性能瓶颈 | 用高基数键,或加随机后缀打散 |
| 生产环境用 Scan | 全表扫描,贵且慢 | 设计好 PK/SK + GSI,用 Query |
| GSI Projection 用 ALL | 索引膨胀,写成本高 | 按需选 KEYS_ONLY 或 INCLUDE |
| 不考虑写容量就上线 | 流量突增时 Throttling | 用按需模式,或预置模式配自动扩缩容 |
| 把 DynamoDB 当 MySQL 用 | 想 JOIN、想灵活查询 | 提前设计查询模式,NoSQL 思维 |
卡点急救
- ProvisionedThroughputExceededException:预置容量不够。开启自动扩缩容,或临时调高 RCU/WCU,或改用按需模式。
- GSI 创建后查询没数据:GSI 是异步构建的,大表需要几分钟到几小时。用
DescribeTable 看 IndexStatus 是否 ACTIVE。
- 单分区超 10GB(LSI 场景):LSI 共享基表分区,每分区键值有 10GB 上限。改用 GSI,或重新设计分区键。
面试常问
-
DynamoDB 分区键设计原则?
高基数(值多)、均匀分布、避免热点。用户 ID 是好的分区键,日期/状态是不好的分区键。如果必须用低基数键,加随机后缀(write sharding)打散。
-
GSI 和 LSI 的区别?
LSI 分区键必须和基表相同,建表时创建,共享容量,支持强一致,有 10GB 分区限制。GSI 分区键可以不同,随时创建,独立容量,只有最终一致,无大小限制。绝大多数场景用 GSI。
-
DynamoDB 怎么实现分页?
用 LastEvaluatedKey。每次 Query 返回 LastEvaluatedKey,下次查询把它作为 ExclusiveStartKey 传入,实现游标式分页。不支持跳页(如直接跳第 100 页)。
自测三问
- 为什么「用户 ID」是好的分区键,「是否付费」是不好的分区键?
- GSI 为什么只有最终一致性?这对业务有什么影响?
- 如果一条数据有 500KB,DynamoDB 存不下,怎么办?
小结一句话
DynamoDB 是 Serverless NoSQL,分区键设计定生死,Query 高效 Scan 昂贵,GSI 扩展查询维度,Streams 驱动事件架构。
延伸