一、先聊聊索引是啥
咱们先想一个场景:假如你去图书馆找一本叫《机器学习入门》的书,但图书馆没有目录卡片,也没有电脑检索。你只能沿着书架一排一排地看,从第一本翻到最后一本,运气好也许半小时找到,运气差可能一天都找不到。这就像数据库里没有索引的样子——你得把整张表从头到尾翻一遍,才能找到想要的数据。DynamoDB 里这个操作叫“全表扫描”(scan),它不仅慢,还很费钱。
那索引是什么?简单说,索引就是一本“目录”。它额外维护一份精简的清单,告诉你某个数据大概放在哪儿。有了这份目录,你就不用翻全馆的书,直接去对应的书架拿就行。在 DynamoDB 中,这个“直接拿”的动作叫查询(query),速度快很多,消耗的读写容量也少很多。可以说,索引优化是使用 DynamoDB 时最重要的一件事,没有之一。
二、DynamoDB 的索引到底长啥样
DynamoDB 提供了两种索引:本地二级索引(简称 LSI)和全局二级索引(简称 GSI)。名字听起来吓人,其实理解起来不难。
本地二级索引,是“在同一栋楼里换一种排书规则”。它和主表共享同一个分区键(也就是主表的主键中的第一个字段),只是排序键可以换成别的字段。举个例子,主表按用户 ID 分区,然后按注册时间排序;LSI 可以让你在同一个用户 ID 分区里,按用户的年龄排序。好处是查询代价低,坏处是只能在建表的时候定义,不能事后追加。
全局二级索引,则相当于“在另一个地方盖了一座图书馆”。它的分区键和排序键都可以完全自定义,不受主表的限制。比如主表用用户 ID 做分区键,但业务上经常要按用户的“状态”来查所有活跃用户,这时候就可以建一个 GSI,把“状态”作为分区键。GSI 可以随时创建,灵活很多,但代价是它自己会占用额外的存储和读写容量。
在真实项目里,大多数人最先接触的、也是用得最多的就是全局二级索引。毕竟业务查询的维度五花八门,主键是不可能提前覆盖所有场景的。
三、索引怎么影响查询性能
没有索引的时候,DynamoDB 只能做全表扫描。假设表里有 100 万条数据,你想找出状态是“active”的人,系统会把这 100 万条全读一遍,再挨个判断“你是不是 active”。这不仅慢,而且按读取容量计费时,要一次性把 100 万条的数据量全部计入,费用相当肉疼。
有了索引之后,情况就变了。索引本质上是一张经过“重新排列”的小表。当你想查 status 等于 active 的记录时,系统直接去这张小表里找,小表里的数据已经按 status 排好了,找到 active 就像是翻开字典看拼音索引一样,只读取匹配的那几项。数据量大的时候,性能差距可能是几十倍甚至上百倍。
但是,索引也不是白给的。每写一条主表数据,DynamoDB 都要同步更新对应的索引。写索引同样要消耗写入容量,还要占用存储空间。所以如果建了太多无用的索引,写的性能会下降,账单也会变贵。这里的关键是:用“空间”和“写入成本”来换“查询速度”,到底值不值,得看业务。
四、实战示例:用 Python 演示索引的威力
下面咱们用 Python 写一个完完整整的例子,看看没有索引和有索引的查询差别。请确保你的电脑上装了 boto3 这个库(AWS 的官方 Python 工具包),并且已经配置好了访问密钥。技术栈统一使用 Python 3 + boto3。
"""
技术栈:Python 3 + AWS SDK (boto3)
需要提前安装:pip install boto3
并且配置好 AWS 凭证(或使用环境变量)
"""
import boto3
import datetime
# 创建 DynamoDB 资源对象,请把区域换成你的区域
dynamodb = boto3.resource('dynamodb', region_name='cn-north-1')
def create_user_table():
"""创建一个用户表,并定义全局二级索引"""
table = dynamodb.create_table(
TableName='Users', # 表名
KeySchema=[
{'AttributeName': 'userId', 'KeyType': 'HASH'}, # 分区键
{'AttributeName': 'ts', 'KeyType': 'RANGE'} # 排序键
],
AttributeDefinitions=[
{'AttributeName': 'userId', 'AttributeType': 'S'}, # 字符串
{'AttributeName': 'ts', 'AttributeType': 'N'}, # 数字
{'AttributeName': 'status', 'AttributeType': 'S'} # 字符串,用于索引
],
GlobalSecondaryIndexes=[
{
'IndexName': 'status-ts-index', # 索引名
'KeySchema': [
{'AttributeName': 'status', 'KeyType': 'HASH'}, # 索引分区键
{'AttributeName': 'ts', 'KeyType': 'RANGE'} # 索引排序键
],
'Projection': {'ProjectionType': 'ALL'} # 复制所有属性到索引
}
],
BillingMode='PAY_PER_REQUEST' # 按量计费,入门更友好
)
table.wait_until_exists()
return table
def add_user(table, user_id, status, nickname):
"""向表中插入一条用户记录"""
# 使用当前毫秒时间戳作为排序键,保证唯一
ts = int(datetime.datetime.now().timestamp() * 1000)
table.put_item(
Item={
'userId': user_id,
'ts': ts,
'status': status,
'nickname': nickname
}
)
print(f'成功添加用户:{user_id},状态:{status}')
def insert_sample_data(table):
"""插入三条测试数据"""
sample = [
('u_1001', 'active', '小明'),
('u_1002', 'inactive', '小红'),
('u_1003', 'active', '小刚')
]
for uid, status, nick in sample:
add_user(table, uid, status, nick)
def scan_users(table, status):
"""不使用索引,全表扫描并过滤出目标状态"""
print('\n[没有索引时] 使用 scan 查询:')
response = table.scan(
FilterExpression='status = :status',
ExpressionAttributeValues={':status': status}
)
items = response['Items']
print(f'全表扫描找到 {len(items)} 个用户,注意数据量越大扫描越慢')
for item in items:
print(f" -> {item['userId']} / {item['nickname']}")
def query_users_by_index(table, status):
"""使用全局二级索引,直接查询目标状态"""
print('\n[有索引时] 使用 query 查询:')
response = table.query(
IndexName='status-ts-index',
KeyConditionExpression='status = :status',
ExpressionAttributeValues={':status': status}
)
items = response['Items']
print(f'索引查询找到 {len(items)} 个用户,速度稳定且消耗较少')
for item in items:
print(f" -> {item['userId']} / {item['nickname']}")
if __name__ == '__main__':
# 1. 建表
print('开始创建表...')
table = create_user_table()
print('表已创建完成!')
# 2. 插入示例数据
insert_sample_data(table)
# 3. 对比查询
scan_users(table, 'active')
query_users_by_index(table, 'active')
这段代码做的事情很简单:先建一张用户表,给状态字段建一个全局二级索引;然后插入三个用户;先用 scan 全表扫描查“active”用户,再用 query 通过索引查同样的用户。你可以把代码中的样例数据换成自己业务里的真实数据,感受一下性能差异。
在数据量小的时候,这两种方式看起来差不多,甚至 scan 有时候还显得挺快。但当你表里有几百万条数据时,scan 的消耗会直线上升,有一次我同事在一个大表上不小心跑了一次 scan,结果几分钟都没有响应,还烧掉了一大笔容量费。而 query 走索引,基本上不管表里有多少条,它都只读取匹配的那一部分,快得让人感动。
五、常见性能问题与优化策略
5.1 分区键设计要“散”
索引优化不只是建个索引就完事,第一步其实是分区键的设计。分区键就好比一栋楼的单元号,如果大家都住在一单元,那一单元就挤爆了,其他单元空着。DynamoDB 内部数据会按照分区键分到不同的物理分区,分区键取值越分散,负载越均衡。比如用 userId 做分区键就比较好,用 status 做分区键就很危险,因为“active”用户可能占 99%,所有请求都打到一个分区上,形成“热分区”,性能会大打折扣。
5.2 全局索引和本地索引怎么选
一个非常实际的判断标准:如果你要查询的条件和主表的分区键相同,只是排序方式不一样,优先考虑本地二级索引 LSI,因为它的写操作和主表一起,成本更低。但如果你需要跨分区、按另一个完全不同的字段来查,那就只能选择全局二级索引 GSI。记住,LSI 只能在建表时定义,一旦建好就改不了了;GSI 则可以在任何时候创建或删除,灵活得多。
5.3 投影字段别贪多
建索引的时候,有一个“投影”配置,意思是决定索引里要复制主表的哪些字段。很多新手都直接选 ALL,把所有字段都复制进索引里,这样查询的时候确实不用回主表,但索引体积会非常大,存储成本高,写入时也会更慢。更好的做法是只复制真正需要查询和展示的字段。比如只需要拿到用户 ID 和状态,那就别把几十个字节的大字段都塞进去,用 INCLUDE 或者 KEYS_ONLY 就能省很多钱。
5.4 用稀疏索引来控制范围
DynamoDB 有一个特别有意思的特性:如果一条数据没有索引分区键对应的属性,那它就不会出现在索引里。利用这一点,我们可以建“稀疏索引”。举个例子,你只需要查“已经注销的用户”,那就可以在写入“注销用户”时,给这条记录额外加一个字段,比如 deletionFlag 等于 yes,然后用这个字段作为 GSI 的分区键。这样普通用户不会进入索引,索引里只有注销用户,体积非常小,查询效率极高。这是一种非常高级且省钱的优化技巧。
5.5 查询尽量用 query 而不是 scan
在 DynamoDB 里,query 和 scan 是两个完全不同的 API。query 永远走主键或索引,返回的数据量精确可控;scan 则是把全表或索引的所有数据读一遍再过滤。做任何查询之前,先问自己:能不能设计一个索引,让这个需求变成 query?如果可以,就不要写 scan。这不仅是性能问题,也是成本问题。哪怕小表 scan 很轻松,到了生产环境大表也会出事。
六、应用场景、优缺点、注意事项
6.1 适合用索引的场景
索引最适合那些“查询条件固定、但数据量很大”的业务。比如订单系统里,经常按用户 ID 查订单,userId 自然就是分区键;如果一个后台管理系统需要按订单状态(待支付、已发货、已完成)来批量统计订单,那就为状态建一个 GSI。又比如排行榜系统,按积分排序取前一百名,可以建一个积分字段为主的 GSI。这些场景里,索引能让查询从“大海捞针”变成“按图索骥”。
6.2 索引的优点
最明显的优点就是查询性能高,用户无感知。其次,GSI 可以随时根据新业务需求动态添加,不用停机,也不需要改主表结构,非常灵活。另外,索引本身也是数据表,它可以拥有和主表完全不同的分区键和排序键,这意味着你可以把一张表“变出”多种数据视图,帮业务快速迭代。
6.3 索引的缺点
首先是成本。每一个索引都要占用额外的存储空间,而且每次写入主表,所有索引都要同步更新,写入次数会成倍增加。其次是延迟。DynamoDB 的索引更新是异步的,也就是说写主表成功后,索引可能不是立刻就能查到,通常需要几十毫秒才完全一致。对于强一致读的需求,索引就不太合适。第三,索引太多会让表结构变得复杂,别人维护起来很头疼,甚至你自己过几个月再看都会犯迷糊。
6.4 实际开发中的注意事项
一定要给索引起一个容易理解的名字,别用 aaa、bbb 这种,三个月后你肯定忘了它是干嘛的。还要注意,GSI 的分区键不能和主表的分区键完全一样,否则索引就没有存在的意义了。在查询 GSI 时,如果只需要索引中的字段,可以用投影来避免回表;如果需要主表里额外的大字段,那还是得回表读取,这时候性能会打折扣,最好还是把常用字段投影进索引里。最后,建议打开 CloudWatch 监控,定期看哪些索引在真实查询中被用到,哪些索引一直没人用,一直没人用的索引就应该删掉。
七、总结
DynamoDB 的索引优化,说到底就是一件事:用合理的成本和空间,换最快的查询速度。先想清楚业务会怎么查数据,再根据查询模式来设计分区键、排序键和索引类型。避免全表扫描,善于使用全局二级索引,精心设计投影,甚至可以用稀疏索引来缩小索引体积。没有银弹,每个优化策略都需要结合数据量、写入频率和查询频率来权衡。希望这篇文章能帮你把 DynamoDB 的索引用在刀刃上,让查询变得又快又便宜。
评论
围绕“DynamoDB索引优化策略及对查询性能的影响”参与讨论