在构建基于AWS DynamoDB的应用时,数据读取路径的设计直接决定了系统延迟与成本。很多团队在初期表结构规划阶段只关注写入,等到业务上线后发现某些接口越跑越慢,账单里的读容量费用也失控,根源往往出在混淆了Scan与Query这两种读取方式。DynamoDB作为一款托管的NoSQL键值及文档数据库,其访问模式高度依赖主键,理解这两种操作的本质差异是用好它的前提。
底层存储与访问路径的差异
DynamoDB在物理层面将数据按分区键的哈希值分布到多个存储分区中,每个分区内部再按排序键有序排列。Query操作要求调用方必须指定分区键的等值条件,引擎据此直接定位到对应的物理分区,然后在该分区内利用排序键的B树结构做范围过滤。这种访问方式本质上是一种索引命中,无论表总共有多少数据,单次Query只触碰目标分区内的相关条目,因此读取容量消耗与返回结果集大小成正比。
Scan操作则完全不同,它不从主键索引切入,而是按照内部存储顺序从表的第一条记录开始向后遍历,依次读取每一个分区中的每一行,再在客户端或服务端侧应用FilterExpression做过滤。即便你只想要某个用户的最近十条记录,Scan也会先把全表翻一遍。在LSM树存储引擎下,这种全表遍历会触发大量的SSTable块读取,产生极高的RCU(读容量单位)开销,并且随着表规模增长,延迟和成本呈线性甚至超线性恶化。
从一致性模型看,Query和Scan都支持最终一致性与强一致性读取,但Scan在并行扫描时可以将表切分为多个分段由多个线程同时读取以提升吞吐。不过并行Scan会成倍放大RCU消耗,在预留模式下表很容易因为突发扫描导致限流。因此在大表场景下,Scan只适合离线分析或偶尔的运维核查,绝不能放在线上核心链路。
语法与参数层面的具体区别
在SDK调用上,Query必须提供KeyConditionExpression且至少包含分区键的等值判断,例如PK = :pk,排序键条件可选。Scan则不需要任何键条件,仅需表名即可发起。下面是一段Node.js中Query的示例,展示了如何按用户ID查最近订单:
const params = {
TableName: 'Orders',
KeyConditionExpression: 'userId = :uid AND orderTime > :t',
ExpressionAttributeValues: {
':uid': { S: 'user_123' },
':t': { S: '2023-01-01' }
}
};
// 调用 documentClient.query(params) 即可
对应的Scan调用极其简单,但往往配合FilterExpression在服务端过滤,注意这种过滤发生在读取之后,RCU仍按读取总量计算:
const scanParams = {
TableName: 'Orders',
FilterExpression: 'userId = :uid',
ExpressionAttributeValues: {
':uid': { S: 'user_123' }
}
};
// 调用 documentClient.scan(scanParams) 拉全表再过滤
二者在分页机制上也有细微差别。Query返回LastEvaluatedKey用于在同一分区内继续翻页,而Scan的翻页可能跨越多个物理分区,因此单次Scan若设了Limit,返回的记录数可能少于Limit值,因为过滤在Limit截断后才应用。开发者若误以为Limit能控制扫描条数,就会写出错误的数据导出脚本。
成本、性能与选型实践建议
从账单角度分析,DynamoDB按读取的字节数折算RCU,Query因为命中索引,其读取字节数接近真实返回数据;Scan则不论过滤掉多少,都按扫描过的原始行计费。一张有五亿条记录、平均每条1KB的表,全表Scan一次约消耗五千多万RCU,在按需模式下可能带来数百美元支出。而按用户ID的Query通常只需几十RCU。
在性能上,Query的延迟一般稳定在个位数到两位数毫秒,并且可以通过稀疏索引、GSI(全局二级索引)将访问模式进一步细化。Scan即使是并行方式,单线程延迟也会随数据量上升,且容易引发节流异常ProvisionedThroughputExceededException。对于需要聚合分析的场景,正确做法是将数据流式同步到Redshift或OpenSearch,而不是在DynamoDB上跑Scan。
实际选型时可遵循一条原则:任何能用分区键收敛的访问,都用Query;只有表级元数据检查、全量备份导出等低频任务才用Scan。若现有表结构无法支撑Query,应回头调整主键设计,例如引入倒排时间戳作为排序键,或建立GSI将查询维度前置。只有把访问模式当作一等公民,DynamoDB的弹性和成本优势才能真正发挥。