如果你有关系型数据库的使用经验,那么首先需要明白的一点是:DynamoDB的思维方式与关系型数据库完全不同。

DynamoDB并非关系型数据库,而是一种NoSQL类型的键值存储和文档存储系统。你不能针对数据编写任意的查询语句,而是需要根据应用程序的具体访问需求来设计数据结构。

DynamoDB的运行机制是由查询请求驱动的,而不是由数据本身决定的。

因此没有必要使用连接操作或进行复杂的规范化处理。为了获得DynamoDB所具备的高性能,你应该对数据进行建模,使其能够通过键(分区键和排序键)被高效地检索出来,而无需依赖全表扫描或复杂的查询逻辑。

如果你试图像使用SQL一样来操作DynamoDB,那么肯定会遇到麻烦,而且最终你会失败。

先决条件

为了最有效地学习DynamoDB,你需要准备一些必要的工具和具备一些基础知识:

AWS

  • 一个具有创建和修改DynamoDB权限的活跃AWS账户

  • 对AWS控制台有一定的了解(能够导航各种服务即可,无需深入掌握其功能)

C# / .NET

  • 熟练使用C#编程语言

  • 了解依赖注入机制

  • 熟悉NuGet包管理工具,需要安装AWSSDK.DynamoDBv2

数据库基础知识(概念性理解)

  • (可选)了解关系型数据库和SQL的基本原理会很有帮助——本文专门为具有这类背景的读者编写,会解释所需的思维转变方式

不需要准备的内容

  • 无需具备DynamoDB的使用经验,因为本文会从零开始讲解核心概念

  • 也不需要深入了解AWS的基础设施相关知识,例如IAM、VPC等

可选但有帮助的内容

  • 如果想直接使用AWS CLI示例,建议在本地安装AWS CLI工具
  • 如果选择通过Terraform来实现基础设施配置,那么具备Terraform相关经验会很有帮助(不过可以跳过这一部分内容而不影响学习效果)

目录

DynamoDB的核心概念

DynamoDB是建立在几个核心概念之上的,这些概念直接决定了你查询数据以及组织数据的方式。

请记住:DynamoDB的功能取决于你获取数据的方式,而不是数据本身的结构。

分区键

  • 每个查询都必需使用分区键

  • 创建表时也需要指定分区键——每张表都必须有分区键

  • 分区键决定了数据是如何分布的,以及数据项是如何存储的

DynamoDB中的数据会物理上分散存储在不同的分区中。分区键决定了某个数据项应该存储在哪个分区里,因此它对系统的性能和可扩展性至关重要。

你可以把分区键想象成文件柜抽屉上的标签:它告诉DynamoDB应该打开哪个抽屉,这样系统就能直接找到所需的数据,而无需逐一检查所有抽屉。

分区键通常是由字符串或数字组成的。

排序键(可选)

排序键也被称为范围键

  • 它使得范围查询成为可能(例如betweenbegins_with等操作)

当你添加了排序键之后,具有相同分区键的数据项会被归为一组,并根据排序键的顺序进行排列。对于字符串类型的排序键,排序是按照字典顺序进行的;而对于数字类型的排序键,则是按数值大小升序排列的。

在处理日期数据时,这一点尤为重要。如果你以非ISO格式将日期存储为字符串(例如DD/MM/YYYY),这些日期就不会按时间顺序排列。例如,排序后的结果可能会是这样的:

01/01/2026
01/02/2026
01/03/2026
08/01/2026
15/02/2026

在这里,所有以01开头的日期都被归为一组,尽管它们属于不同的月份。这种情况会破坏范围查询的功能,也会影响数据的排序顺序。

为避免这种问题,你可以选择以下方法之一:

  • 使用ISO 8601格式YYYY-MM-DD),这样日期就可以作为字符串正确地进行排序了

  • 使用Unix时间戳(通常是自1970年1月1日以来的毫秒数),这种方式可以确保数据按数值顺序正确排列

这两种方法都能保证你的数据被正确排序,并且能够高效地被查询。这种方法通常用于处理时间戳、版本控制信息,或者进行逻辑分组(例如ORDER#2024-01)。

全局二级索引

全局二级索引允许你使用与基础表不同的分区键和可选的排序键来查询数据。这样,你就可以在不修改主键结构的情况下,支持更多的数据访问方式。

例如,如果你的基础表是按照UserId进行分区的,但你也需要根据OrderId来查询数据,那么全局二级索引就能满足这一需求。

投影类型

全局二级索引并不一定需要包含基础表中的所有属性。在配置全局二级索引时,你可以选择只保留部分属性进行查询。

  • ALL — 所有属性都会被投影出来

  • KEYS_ONLY — 仅索引键和主键会被投影出来

  • INCLUDE — 仅选中的部分属性会被投影出来

选择合适的投影方式有助于降低存储和读取成本。例如,对于订单视图来说,你只需投影出orderNumberdateOrderedcost这些属性即可,而无需投影其他不必要的属性。

重要注意事项

额外成本:使用GSI会消耗额外的读写资源及存储空间,此外还会增加基础表的相关成本。如果将ProjectionType设置为INCLUDEKEYS_ONLY而非ALL,由于索引中复制的数据量会减少,因此GSI的存储成本也会随之降低,这部分节省的成本可以抵消额外的读写开销。

最终一致性:在DynamoDB中,当你直接向基础表写入数据时,可以立即执行强一致性的读取操作,从而确保获取到最新的数据;但GSI并不支持这种机制,其读取操作始终遵循最终一致性原则。

当客户下订单后,DynamoDB会将该订单信息写入主订单表,然后会在后台异步地将这一变更复制到所有关联的GSI中。在这段短暂的时间内(通常为几毫秒),通过GSI进行的查询可能还无法获取到新插入的订单记录。

以下是一个现实生活中的例子,说明这种情况可能会带来什么问题:

  1. 客户下订单,订单信息被写入订单表

  2. 用户被重定向到“您的订单”页面

  3. “您的订单”页面通过GSI查询该客户的全部订单记录

  4. 由于GSI的复制尚未完成,新订单还没有显示出来

  5. 50毫秒后,用户重新刷新页面

  6. 此时新订单已经显示出来了

对于大多数查询操作来说——比如浏览产品目录、查看订单历史记录或按状态筛选订单——这种延迟是完全可以接受的,用户也不会察觉到。但当应用程序在写入数据后立即通过GSI查询同一条记录时,就会出现问题。在这种情况下,你有以下几种解决方案:

  • 在写入数据后直接从基础表进行强一致性读取操作,而不是通过GSI

  • 将订单数据直接从写入响应中传递给用户界面,无需再进行任何查询——在大多数情况下,这是最简洁的解决方法

写操作放大效应:每次对基础表的写入操作都可能同时被复制到一个或多个GSI中。虽然GSI功能强大,但使用它们也需要付出相应的成本。过度使用GSI通常说明你的应用程序的主要访问模式在设计阶段就没有得到妥善规划。

注意:默认情况下,DynamoDB允许每个表最多使用20个GSI,不过通过申请AWS的服务限额增加这一限制也是可行的。

合成键——传统方法

在探讨多属性全局二级索引之前,了解它们所取代的旧机制是很有必要的——因为你在现有的DynamoDB代码库中肯定会遇到这种旧机制。

想象一下,如果你想根据订单的状态和创建日期来查询订单,比如查找过去30天内所有处于“待处理”状态的订单。在之前,DynamoDB的全局二级索引只支持将一个属性作为分区键,另一个属性作为排序键。如果要根据多个属性进行筛选,就必须将这些属性组合成一个人工构造的合成属性:

[DynamoDBTable("Orders")]
public class OrderDto
{
[DynamoDBHashKey("CustomerId")]
public string CustomerId { get; set; }

[DynamoDBRangeKey("createdAt")]
public long CreatedAt { get; set; }

[DynamoDBProperty("orderId")]
public string OrderId { get; set; }

[DynamoDBProperty("status")]
public string Status { get; set; }

// 合成键——在保存记录之前需要手动构造
[DynamoDBProperty("statusDate")]
public string StatusDate { get; set; } // 例如:"PENDING#2025-11-01"
}

在保存记录之前,需要先构造这个合成键:

order.StatusDate =($"{order.Status}#{orderCreatedAt:yyyy-MM-dd}";

然后可以创建一个以`statusDate`作为分区键的全局二级索引,这样就可以进行查询了:

var results = await _context.QueryAsync(
"PENDING#2025-11-01",
config // IndexName = "statusDate-index"
).GetRemainingAsync();

这种做法虽然可行,但也存在一些明显的缺点:

  • 脆弱性——任何向该表写入数据的开发人员都必须了解这个合成键的构成规则,并且必须正确地使用它。

  • 难以进行范围查询——如果要筛选某个日期范围内的所有待处理订单,就需要在拼接后的字符串上使用`begins_with`或`between`等条件来进行过滤,这操作相当繁琐。

  • 维护成本高——如果订单的状态发生变化,那么所有的现有记录都需要被更新。

  • 在数据库模式中无法直接看到这个合成键

    ——如果没有相应的文档说明,新来的开发人员根本不知道`statusDate`这个字段的含义。

  • 需要重新处理现有数据

    ——如果要在现有的表中添加新的全局二级索引,就需要通过脚本重新处理所有现有记录,以便为这些记录添加新的属性值。

多属性全局二级索引——全新的解决方案

2025年11月19日,AWS宣布支持使用多属性复合键来创建全局二级索引。现在你可以定义一个由最多4个属性组成的分区键或排序键——也就是说,总体来说,分区键和排序键可以共同包含8个属性

有几点需要注意:

  • 仅适用于多属性索引:这一规则仅适用于多属性索引——您的基表主键结构不会发生任何变化,仍然由一个分区键和一个可选的排序键组成。

  • DynamoDB会自动处理这些属性的组合:您无需自行将这些值连接起来。DynamoDB会将分区键中的各个属性进行哈希处理,从而实现数据的分布存储,并确保这些排序键按定义的顺序进行排序。

  • 严格的查询规则依然有效:在执行查询时,您必须为所有分区键属性指定相同的匹配条件。同时,排序键属性必须按照定义的顺序从左到右进行查询,不能跳过任何属性。

  • 无需进行额外的数据填充操作:当您向现有表中添加多属性索引时,DynamoDB会自动使用这些属性对所有现有数据进行索引处理。

  • 不会产生额外费用:添加多属性索引并不会增加除了标准索引定价之外的任何额外成本。

该模型结构简洁,无需使用任何合成属性:

[DynamoDBTable("Orders")]
public class OrderDto
{
    [DynamoDBHashKey("CustomerId")]
    public string CustomerId { get; set; }

    [DynamoDBRangeKey("createdAt")]
    public long CreatedAt { get; set; }

    [DynamoDBProperty("orderId")]
    public string OrderId { get; set; }

    [DynamoDBProperty("status")]
    public string Status { get; set; }

    [DynamoDBProperty("total")]
    public decimal Total { get; set; }
}

定义多属性GSI

您可以通过AWS控制台来创建GSI(按所需顺序选择相应的属性),也可以通过Terraform或AWS CLI来实现这一目标。

需要理解的关键点是:对于复合分区键,您需要提供多个HASH条目;而对于复合排序键,则需要提供多个RANGE条目,并且这些条目的顺序必须与它们在实际计算时被处理的顺序完全一致。DynamoDB在内部会将它们视为一个复合分区键和一个复合排序键。

以下是一个使用AWS CLI创建GSI的示例:该GSI针对“Orders”表,其复合分区键由CustomerIdstatus组成,而排序键则基于createdAt这一单一属性:

aws dynamodb update-table \
  --table-name Orders \
  --attribute-definitions \
    AttributeName=customerId,AttributeType=S \
    AttributeName=status,AttributeType=S \
    AttributeName=createdAt,AttributeType=N \
  --global-secondary-index-updates \
  "[{\"Create\":{
    \"IndexName\":\"customerStatus-createdAt-index\",\
    \"KeySchema\":[
      {\"AttributeName\":\"CustomerId\",\"KeyType\":\"HASH\"},\
      {\"AttributeName\":\"status\",\"KeyType\":\"HASH\"},\
      {\"AttributeName\":\"createdAt\",\"KeyType\":\"RANGE\"}
    ],\
    \"Projection\":{\"ProjectionType\":\"ALL\"}
  }}]"

这里的两个HASH条目是有效的。它们共同定义了一个复合分区键,其格式为(customerId, status)

以下是一个使用Terraform创建GSI的示例(要求使用AWS provider v6.29.0或更高版本):

global_secondary_index {
  name            = "customerStatus-createdAt-index"
  projection_type = "ALL"

  key_schema {
    attribute_name = "CustomerId"
    key_type       = "HASH"
  }

  key_schema {
    attribute_name = "status"
    key_type       = "HASH"
  }

  key_schema {
    attribute_name = "createdAt"
    key_type       = "RANGE"
  }
}

必须遵守的查询规则

虽然多属性GSI确实提供了更大的灵活性,但相关的查询约束与SQL有所不同。其中有两条规则尤为重要:

1. 分区键的所有属性都必须被提供,并且这些属性在查询时必须使用“等于”操作符进行比较。

对于键值为〈code>(customerId, status)〉的分区键:
有效的情况:

CustomerId = 'C123' AND status = 'PENDING'

无效的情况——缺少〈code>status字段:

CustomerId = 'C123'

无效的情况——分区键属性之间的逻辑关系不正确:

CustomerId = 'C123' AND status > 'P'

2. 分区键属性必须按从左到右的顺序进行查询,且逻辑关系不平等的条件应放在最后。

对于键值为〈code>(tournamentRound, rank, matchId)〉的分区键:
有效的情况:

tournamentRound = 'SEMIFINALS'

tournamentRound = 'SEMIFINALS' AND rank = 'UPPER'

tournamentRound = 'SEMIFINALS' AND rank = 'UPPER' AND matchId = 'match-002'

tournamentRound = 'SEMIFINALS' AND rank = 'UPPER' AND matchId > 'match-001'

tournamentRound BETWEEN 'QUARTERFINALS' AND 'SEMIFINALS'

无效的情况——跳过了第一个属性:

rank = 'UPPER'

无效的情况——在分区键字段之间留有空隙(例如省略了〈code>bracket字段):

tournamentRound = 'SEMIFINALS' AND matchId = 'match-002'

无效的情况——在逻辑关系不平等的条件之后又添加了其他条件:

tournamentRound > 'QUARTERFINALS' AND rank = 'UPPER'

设计提示: 应将分区键属性按照从一般到具体的顺序进行排列,例如〈code>tournamentRound → rank → matchId)。这样可以使查询更加灵活,因为每个按从左到右的顺序排列的字段组合都能构成有效的查询条件。

深入探讨:

点击访问。

C# SDK选项

在C#环境中使用DynamoDB时,AWSSDK.DynamoDBv2 NuGet包提供了三种不同的方式来操作数据库表,每种方式都具有不同的抽象层次。

低级客户端

var client = new AmazonDynamoDBClient();

AmazonDynamoDBClient》允许您完全控制与DynamoDB交互的每一个细节。您需要手动构建请求,明确指定所有的属性、条件及配置参数。

var request = new QueryRequest
{
    TableName = "Orders",
    KeyConditionExpression = "CustomerId = :customerId",
    ExpressionAttributeValues = new Dictionary
    {
        { ":customerId", new AttributeValue { S = "customer-123" } }
    }
};

var response = await client.QueryAsync(request);

这是一种描述最为详尽的开发方式,但其中没有任何内容是隐藏起来的。你可以清楚地看到发送到 DynamoDB 的数据内容,这使得调试、优化代码变得更加容易,同时也能让你准确地了解自己消耗了多少读取容量单位(RCUs)。此外,这种开发方式也最具灵活性——DynamoDB 支持的任何操作,在这里都可以实现。

适用场景:
当你需要细粒度的控制,正在处理一些复杂的任务,或者希望完全了解自己的查询语句时,这种开发方式非常适用。

文档模型

var client = new AmazonDynamoDBClient();
var table = Table.LoadTable(client, "Orders");

文档模型位于低级客户端之上。与直接操作原始的 AttributeValue 类型不同,你在这里使用的是 Document 对象,这些对象的结构更类似于 JSON,对于大多数 .NET 开发者来说都非常熟悉。

var filter = new QueryFilter("CustomerId", QueryOperator.Equal, "customer-123");
var search = table.Query(filter);
var documents = await search.GetRemainingAsync();

// 访问数据
foreach (var doc in documents)
{
    Console.WriteLine(doc["orderId"].AsString());
    Console.WriteLine(doc["total"].AsDecimal());
}

与低级客户端相比,这种方式的代码量更少,但你仍然是在使用松类型的 Document 对象,而不是自己定义的 C# 类。目前还没有现成的方法可以将这些对象映射到强类型模型中。

适用场景:
当你处理动态或结构较为松散的数据时,如果不想定义固定的数据模型,或者需要快速编写工具和脚本,这种开发方式非常实用。

对象持久化模型

对象持久化模型是抽象层次最高的一种开发模式,对于典型的 .NET 开发来说也是最自然的选择。

[DynamoDBTable("Orders")]
public class OrderRecord
{
    [DynamoDBHashKey("CustomerId")]
    public string CustomerId { get; set; }

    [DynamoDBRangeKey("createdAt")]
    public long CreatedAt { get; set; }

    [DynamoDBProperty("orderId")]
    public string OrderId { get; set; }

    [DynamoDBProperty("total")]
    public decimal Total { get; set; }

    [DynamoDBProperty("status")]
    public string Status { get; set; }
}

使用这种模型进行查询时,代码显得更加简洁且类型安全:

var orders = await dbContext
.QueryAsync("customer-123")
.GetRemainingAsync();

这种抽象方式带来的弊端在于,它会隐藏一些重要的细节:你无法清楚地了解在底层究竟有哪些数据被发送到了 DynamoDB 中,而这会使得调试和性能优化变得更加困难。

设置上下文环境

在创建数据库上下文时,有几种可供选择的方案:

选项 1 — 使用依赖注入:

// Program.cs
builder.Services.AddSingleton();
builderservices.AddSingleton(sp => {
    var client = sp.GetRequiredService();
    return new DynamoDBContext(client);
});

// 在仓库或服务层中,注入 IDynamoDBContext
public class OrderRepository
{
    private readonly IDynamoDBContext _context;

    public OrderRepository(IDynamoDBContext context)
    {
        _context = context;
    }
}

选项 2 — 仅注册 AmazonDynamoDBClient,并在每次操作时重新创建上下文对象:

// Program.cs
builder.Services.AddSingleton();

相应的实现代码如下:

public class OrderRepository
{
    private readonly IAmazonDynamoDB _client;

    public OrderRepository(IAmazonDynamoDB client)
    {
        _client = client;
    }

    public async Task> GetOrdersAsync(string customerId)
    {
        var context = new DynamoDBContext(_client); // 创建上下文对象非常简单
        return await context.QueryAsync(customerId).GetRemainingAsync();
    }
}

哪一种方案更好呢?选项 1 更简洁,也更容易进行单元测试——因为在单元测试中可以轻松地模拟 IDynamoDBContext 对象。选项 2 也是可行的,因为创建 DynamoDBContext 对象的成本较低,但这样一来就无法方便地对它进行模拟了。

何时应该使用对象持久化技术?对于大多数 .NET 应用程序来说,这是一种推荐的做法。这种技术代码结构清晰、类型安全,并且能够自然地融入现有的 C# 代码库中。

从 C# 中查询多属性 GSI

在编写本文时,DynamoDBContext.QueryAsync 这个便捷的重载方法并不直接支持多属性 GSI 的键条件设置——你需要使用低级别的客户端接口 (IAmazonDynamoDB) 并传入一个 KeyConditionExpression 对象。不过,将查询结果反序列化为对应的类型化模型仍然非常简单。

以下是一个示例:这个查询针对的是一个 GSI,该 GSI 的复合分区键为 (customerId, status),排序键为 createdAt,因此它能够返回自指定日期以来该客户的所有未完成订单信息:

public async Task> GetOrdersByStatusSinceAsync(
string customerId,
string status,
long fromDate)
{
var request = new QueryRequest
{
TableName = "Orders",
IndexName = "customerStatus-createdAt-index",
KeyConditionExpression =
"customerId = :CustomerId " +
"AND #status = :status " + // 使用 "#status" 是因为 "status" 是一个保留字
"AND createdAt > :fromDate",
ExpressionAttributeNames = new Dictionary
{
{ "#status", "status" }
},
ExpressionAttributeValues = new Dictionary
{
{ ":customerId", new AttributeValue { S = customerId } },
{ ":status", new AttributeValue { S = status } },
{ ":fromDate", new AttributeValue { N = fromDate.ToString() } }
},
ScanIndexForward = false // 由于排序键是时间戳,因此需要反向排序
};

var response = await _client.QueryAsync(request);

// 使用 DynamoDBContext 将查询结果手动反序列化为 OrderDto 对象列表
return _context.FromDocuments(response.Items.Select(Document.FromAttributeMap)).ToList();
}

观察上面的代码,请注意以下几点:

  • 两个分区键属性(customerIdstatus)都使用了“等于”关系进行匹配——这是必须的。

  • 排序键(createdAt)使用了“大于”关系作为匹配条件,这也是允许的。

  • 没有使用任何合成字符串,也没有采用脆弱的格式化方式,更没有对现有记录进行补录操作。

如果你正在使用包含合成键的现有代码库,那么考虑是否将系统迁移到多属性GSI模型是很有意义的。以前导致迁移过程变得繁琐的补录问题已经不存在了,因为DynamoDb的多属性GSI模型能够自动处理这类问题。

查询与扫描

在使用DynamoDB时,理解这一概念至关重要——同时,这也是导致性能问题和成本问题的常见原因之一。

查询

Query操作会利用分区键来检索数据,并且还可以通过排序键进一步筛选结果。DynamoDB能够准确判断应该查找哪个分区,仅读取相关的数据,并高效地返回结果。

// 获取某位客户的所有订单
var orders = await _context
    .QueryAsync:>("customer-123")
    .GetRemainingAsync();

你还可以通过排序键条件来进一步缩小搜索范围——例如,只获取过去30天内下达的订单:

var thirtyDaysAgo = DateTimeOffset.UtcNow.AddDays(-30).ToUnixTimeMilliseconds();

var orders = await _context.QueryAsync(
    "customer-123",
    QueryOperator.GreaterThan,
    new List { thirtyDaysAgo }
).GetRemainingAsync();

查询操作速度很快,成本也很低——你只需要为实际被读取的记录支付RCU费用。

扫描

Scan操作会读取表中的所有数据,然后再进行筛选。它不使用键或索引,而是对所有数据进行逐条检查。

var conditions = new List
{
    new ScanCondition("status", ScanOperator.Equal, "pending")
};

var orders = await _context
    .ScanAsync(conditions)
    .GetRemainingAsync();

这种操作确实有效——但是对于包含1000万条记录的表格来说,DynamoDB会先读取全部1000万条数据,然后再筛选出那些状态为“pending”的订单。你需要为每一条被读取的数据支付RCU费用,而不仅仅是那些最终被返回的结果。

重要提示:对于大型表格而言,在生产环境中应尽量避免使用扫描操作。这种操作速度慢、成本高,并且随着表格规模的扩大,问题会变得更加严重。

这两种操作的差异如下所示:

查询:
表 [■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■]
       └── 直接跳转到“customer-123”这个分区
               └── 只读取这些数据——成本较低

扫描:
表 [■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■]
       └── 读取所有数据——成本较高
               └── 然后剔除不符合条件的数据

在什么情况下可以使用扫描操作?

扫描操作并不总是错误的——在某些情况下使用它是完全合理的:

  • 小型表格——对于包含50条记录的查找表来说,使用扫描操作是完全可行的。

  • 一次性数据迁移或管理脚本——这些操作并不面向用户,且执行频率较低。

  • 开发与调试过程——在本地进行扫描操作,或者针对小型数据集进行查询。

经验法则:如果是对一个不断增长的数据表进行查询操作,那么应该使用查询方式,而不是扫描方式。

由于开发者通常有SQL编程背景,因此他们往往会选择使用扫描操作。例如:

SELECT * FROM orders WHERE status = 'pending'

在拥有合适索引的SQL环境中,这种做法是可行的。但在DynamoDB中,如果status字段没有对应的GSI索引,那么每次进行查询时都会对整个表进行扫描。因此,正确的解决方案应该是根据实际的需求设计相应的GSI索引——这正是我们在前面关于GSI索引的部分所讨论的内容。

过滤表达式

无论是查询操作还是扫描操作,都支持使用FilterExpression——这种条件会在DynamoDB读取记录之后、但在将结果返回给用户之前被应用。从表面上看,它与SQL语言中的WHERE子句非常相似,但恰恰因此才容易产生误解。

var request = new QueryRequest
{
    TableName = "Orders",
    KeyConditionExpression = "CustomerId = :customerId",
    FilterExpression = "#status = :status",
    ExpressionAttributeNames = new Dictionary
    {
        { "#status", "status" }
    },
    ExpressionAttributeValues = new Dictionary
    {
        { ":customerId", new AttributeValue { S = "customer-123" } },
        { ":status",     new AttributeValue { S = "pending" } }
    }
};

需要重点理解的是:FilterExpression并不会降低查询的成本。DynamoDB仍然会先读取所有记录,然后对所有这些记录收取费用,只有那些不符合过滤条件的记录才会被舍弃。

对于查询操作来说,这意味着只有那些符合KeyConditionExpression条件的记录才会被纳入查询结果中;而对于扫描操作来说,则意味着表中的所有记录都会被包含在结果中。

使用FilterExpression》只是为了减少返回给用户的响应数据量,而不是为了提高查询效率。如果发现自己经常需要使用FilterExpression》来满足特定的访问需求,那么这说明你其实更需要使用GSI索引。

分页结果与用户界面

在DynamoDB中,分页功能往往是人们最容易误解的部分之一,尤其是对于那些有SQL编程背景的人来说。

在SQL中,你可能会这样写:

SELECT * FROM orders LIMIT 10 OFFSET 20

但DynamoDB的工作原理并非如此。它并不支持OFFSET或页码的概念。相反,DynamoDB是通过LastEvaluatedKey来实现基于游标的分页功能的。

DynamoDB的工作原理

DynamoDB每次请求最多返回1MB的数据。如果查询结果超过1MB,它会返回一个LastEvaluatedKey——这个键值对指明了读取操作的停止位置。你可以在下一次请求中使用这个键值对,从该位置继续进行数据检索。当没有LastEvaluatedKey被返回时,说明你已经查到了数据的结尾。

DynamoDB C# SDK中的实现方式

SDK的GetRemainingAsync()方法会自动处理分页操作,它会不断发送请求,直到没有LastEvaluatedKey为止,然后将所有查询结果合并成一个列表返回:

// 自动处理所有页面的分页——但会将所有数据加载到内存中
var orders = await _context
    .QueryAsync<OrderDto>("customer-123")
    .GetRemainingAsync();

这种方式虽然方便,但在处理大型数据集时却存在安全隐患。例如,如果某个客户有50,000笔订单记录,那么这50,000条记录都会被一次性加载到内存中。

手动分页——适用于用户界面的正确方法

对于那些具有“加载更多”或“上一页/下一页”导航功能的用户界面来说,可以使用GetNextSetAsync()方法来手动控制分页操作:

// ---- 分页模型 ----
public class PagedResult<T>>
{
    public List<T>> Items { get; set; }
    public string? PaginationToken { get; set; }
}

// ---- 数据库查询方法 ----
public async Task<PagedResult<OrderDto>>>> GetOrdersPageAsync(
    string customerId,
    string? paginationToken = null)
{
    var config = new DynamoDBOperationConfig
    {
        BackwardQuery = true // 如果排序键是时间戳,那么会按降序排列数据
    };

    var search = _context.QueryAsync<OrderDto>>(customerId, config);

    if (paginationToken != null)
        search.PaginationToken = paginationToken;

    var items = await search.GetNextSetAsync(25); // 只获取25条记录

    return new PagedResult<>OrderDto>>
    {
        Items = items,
        PaginationToken = search.PaginationToken // 如果没有更多页面,则此值为null
    };
}

PaginationToken实际上是SDK对LastEvaluatedKey的序列化表示形式——你可以将其直接以字符串的形式传递给客户端,在下一次请求时再接收回来。

“跳转到第7页”的导航功能怎么办?

DynamoDB不支持这种直接跳转页面的功能。LastEvaluatedKey只是一个用于指示当前查询位置的标记,要想跳到第7页,你首先需要依次查看第1页到第6页的数据,才能获得正确的查询位置信息。

对于大多数现代用户界面来说,这并不是什么问题。无限滚动和“加载更多”这样的设计模式本身就非常适合与基于游标的分页机制结合使用。

FilterExpression带来的问题

我们已经知道,FilterExpression并不能替代设计良好的GSI(全局索引结构)。在分页操作中,使用FilterExpression不仅会导致资源浪费,还会导致系统出现故障。

当涉及到FilterExpression时,DynamoDB的分页机制分为两个阶段来执行:

  1. 首先读取记录,直到达到1MB的读取限制。

  2. 然后应用FilterExpression,剔除不符合条件的记录。

LastEvaluatedKey是在第一步之后、过滤操作之前生成的。因此,即使经过过滤后只返回了少量记录,DynamoDB仍然可以返回,从而表明还存在其他结果。

以1,000条订单为例,其中只有50条是“待处理”状态:

第1页:读取200条记录 → 应用过滤条件 → 返回3条“待处理”订单 + LastEvaluatedKey

第2页:读取200条记录 → 应用过滤条件 → 返回1条“待处理”订单 + LastEvaluatedKey

第3页:读取200条记录 → 应用过滤条件 → 不返回任何“待处理”订单 + LastEvaluatedKey

……依此类推,直到读取完全部1,000条记录

重要提示:您需要为每一条被读取的记录支付费用,而不是为每一条被返回的记录付费。

Limit参数在这里并不能起到帮助作用。GetNextSetAsync(25)限制的是过滤之前的读取记录数,而非最终返回的记录数。即使您只读取了25条记录,但应用过滤条件后只返回了3条“待处理”订单,DynamoDB仍然会返回LastEvaluatedKey,这意味着“每页25条记录”的限制实际上会导致返回0到25条不等的记录数,且结果具有不确定性。

真正的解决办法并不是采用更智能的分页策略,而是完全去除FilterExpression。您应该根据需要过滤的属性来设计GSI(例如,以status作为分区键),这样DynamoDB就会直接读取符合条件的记录,Limit参数也能准确控制最终返回的结果数量,从而使分页功能变得可预测。

最后的思考与结论

DynamoDB会奖励那些事先根据自身访问模式来设计数据结构的用户,但会惩罚那些试图将其视为传统SQL数据库的用户。与传统关系型数据库相比,最大的两个变化在于:

  1. 查询语句本身就是数据结构。您需要根据实际要执行的查询来设计表结构、键以及GSI,而不能事后再进行规范化处理或重新设计查询逻辑。

  2. 键在数据访问中起着关键作用。查询操作之所以快速且高效,正是因为它利用了分区键直接定位到目标数据。而全量扫描则会读取所有数据,而且随着数据量的增加,扫描效率会大幅下降。

2025年11月发布的多属性GSI功能确实是一项非常有益的改进,它有效解决了DynamoDB中一些令人困扰的问题,比如合成键的创建及数据填充过程,同时也没有削弱那些让DynamoDB保持高效性的设计机制。

查询规则依然没有改变:所有分区键属性都必须提供,排序键属性则按从左到右的顺序进行查询。您所获得的是更加清晰、结构化的数据模型,并且能够为现有表添加新的访问方式,而无需进行数据迁移操作。

<对于新项目,我的建议是默认使用多属性的全局唯一标识符。而对于那些基于合成键构建的现有代码库,需要评估是否适合进行迁移——不过如今,进行这类迁移所面临的困难已经不复存在了。

<如果你们想进一步讨论这个话题,或者想了解我写的其他文章,请通过'X'与我联系。

Comments are closed.