英文版刊载于Flexport Engineering Blog
简介
使用更多的索引并不一定能提升数据库性能。“少即是多”的原则也适用于数据库领域。本文将介绍我们通过少用索引使一个PostgreSQL查询提速几千倍,从分钟级提升到毫秒级的经验。
问题
Flexport平台让Flexport的客户、运营人员和其他用户能发送消息进行交流。这个功能的数据存储于AWS Aurora PostgreSQL上的一个消息表,此表的规模为数千万行级。
消息表上的主查询通常极快,但是近期它遇到了一些间歇的慢查询超时。慢查询不但影响了消息功能的用户体验,而且加大了整个系统的负荷,拖慢了其他功能的用户体验。
SELECT messages.* FROM messages
WHERE messages.deleted_at IS NULL AND messages.namespace = ?
AND (
jsonb_extract_path_text(context, 'topic') IN (?, ?)
OR jsonb_extract_path_text(context, 'topic') LIKE ?
)
AND ( context @> '{"involved_parties":[{"id":1,"type":1}]}'::jsonb )
ORDER BY messages.created_at ASC
它用了一些PostgreSQL特有的语法。那么这个查询是在干什么呢?
那个@>符号是PostgreSQL的“包含”操作符。请参考官方文档,简单来说它就是一种支持多层级的子集运算,判断左侧是否“包含”右侧(例如,某个大对象是否包含一个小对象)。这个操作符可以被用于一些结构化类型,例如数组(array)、范围(range)或JSONB(PostgreSQL中的JSON二进制表示格式)。
消息表有一个整数型的id列,一个JSONB型的context列,以及其他列。在context列中的JSONB对象包含了一条消息的描述性属性,例如,topic属性描述了此消息的主题,involved_parties属性描述了有权查看此消息的法人实体的列表。
在context列上有两个索引:
- context列上的GIN索引
- jsonb_extract_path_text(context, ‘topic’)表达式上的BTREE表达式索引
GIN是PostgreSQL提供的一款用于复杂值的索引引擎,一般用于数组、JSON或文本等的数据结构。GIN的设计用途是索引那些可对内部结构做细分的数据,这样就可以查找数据内部的子数据了。BTREE是PostgreSQL的默认索引引擎,能对简单值做相等性比较或范围查询。表达式索引是PostgreSQL提供的一种强力的索引类型,能对一个表达式(而不是一个列)做索引。JSONB类型一般只能用GIN这样的索引引擎,因为BTREE只支持标量类型(可以理解为“没有内部结构的简单值类型”)。因此,context列上的jsonb_extract_path_text(context, ‘topic’)表达式可以用BTREE索引,因为它返回字符串类型。不同于BTREE索引统一而一致的表示格式,GIN索引的内容可以因所用数据类型和操作符类型的不同而极为不同。而且考虑到查询参数的选择度有较高的多样性,GIN索引更适用于一些特定的查询,不像BTREE索引广泛适用于相等性比较和范围查询。
初调查
一个查询通常会先做索引扫描以初筛,再对筛选后的范围做表扫描(一个特例是,当索引扫描足以覆盖所需的所有数据列时,则无需表扫描)。为了最大化性能,索引要有较好的选择度来缩小范围,以减少甚至避免之后的表扫描。条件context @> ‘{“involved_parties”:[{“id”:1,”type”:1}]}’::jsonb能使用context列上的GIN索引,但是这并不是一个好选择,因为{“id”:1,”type”:1}这个值是存在于大多数行中的一个特殊值(这数字就很特殊,像管理员的号码)。因此,GIN索引对于这个条件的选择度很差。实际上,这个查询中的其他条件已能提供很好的选择度,所以永远不需要为这个条件使用索引。现在我们需要知道这个查询究竟有没有在这个条件上使用GIN索引。
我们可以在SQL控制台中执行形如“EXPLAIN ANALYZE {the query statement}”的语句来得到查询计划。这种语句会实际执行查询并返回所选的查询计划(其上标注有实际时间耗费)。
然而,我们只得到了一个快查询计划,它没有使用这个GIN索引。调查遇到了麻烦。
深入技术细节
首先,通过可视化表示来读懂这个快查询计划。
快查询计划
QUERY PLAN
------------------------------------------------------------------------------
Sort (cost=667.75..667.76 rows=3 width=911) (actual time=0.093..0.094 rows=7 loops=1)
Sort Key: created_at
Sort Method: quicksort Memory: 35kB
-> Bitmap Heap Scan on messages (cost=14.93..667.73 rows=3 width=911) (actual time=0.054..0.077 rows=7 loops=1)
Recheck Cond: ((jsonb_extract_path_text(context, VARIADIC '{topic}'::text[]) = ANY ('{?,?}'::text[])) OR (jsonb_extract_path_text(context, VARIADIC '{topic}'::text[]) ~~ '?%'::text))
Filter: ((deleted_at IS NULL) AND (context @> '{"involved_parties": [{"id": 1, "type": 1}]}'::jsonb) AND ((namespace)::text = '?'::text) AND ((jsonb_extract_path_text(context, VARIADIC '{topic}'::text[]) = ANY ('{?,?}'::text[])) OR (jsonb_extract_path_text(context, VARIADIC '{topic}'::text[]) ~~ '?%'::text)))
Heap Blocks: exact=7
-> BitmapOr (cost=14.93..14.93 rows=163 width=0) (actual time=0.037..0.037 rows=0 loops=1)
-> Bitmap Index Scan on index_messages_on_topic_key_string (cost=0.00..10.36 rows=163 width=0) (actual time=0.029..0.029 rows=4 loops=1)
Index Cond: (jsonb_extract_path_text(context, VARIADIC '{topic}'::text[]) = ANY ('{?,?}'::text[]))
-> Bitmap Index Scan on index_messages_on_topic_key_string (cost=0.00..4.57 rows=1 width=0) (actual time=0.007..0.007 rows=7 loops=1)
Index Cond: ((jsonb_extract_path_text(context, VARIADIC '{topic}'::text[]) ~>=~ '?'::text) AND (jsonb_extract_path_text(context, VARIADIC '{topic}'::text[]) ~<~ '?'::text))
Planning time: 0.313 ms
Execution time: 0.138 ms
快查询计划的可视化表示
如下图,查询计划就像一个调用栈。查询被自顶向下地展开成一棵树,然后这棵树被自底向上地求值。例如,两个叶节点(Bitmap Index Scan)先求值,其结果被父节点(BitmapOr)合并,再返回给祖节点(Bitmap Heap Scan),等等。请注意,叶节点中的index 1和index 2实为同一个BTREE表达式索引,只不过被用于两个不同条件的扫描。
一个PostgreSQL查询可以同时扫描多个索引再合并其结果,最慢的那个索引决定了索引扫描的总体性能。这通常会比单索引扫描更高效,因为多个索引通常能更大程度地筛选数据集,从而能少读磁盘(对筛选后的数据集作进一步扫描时,若数据不在内存缓存中,就要从磁盘读取)。有一步叫做Bitmap Heap Scan,能合并索引扫描的结果,所以能在查询计划中看到“BItmap Heap Scan”字样。
进一步调查
基于现有信息,主要关注这些要点:
- 不大可能是资源争用所致,因为争用应平等地影响所有的SQL语句,不只是SELECT语句,但只观测到慢的SELECT?
- 可能是同一查询即使用相同参数也偶尔会选择不同的计划。
- 也可能是GIN相关的问题。Gitlab曾遇到GIN相关的问题。
一个猜想是“数据和统计信息的更新使得查询计划器偶尔选择了一个使用GIN索引的慢查询计划”。
无论有没有猜想,对于这种情况,最好的行动都是提高可观察性。缺少一些数据吗?好的,收集它!我们用一些代码来记录查询执行时间,并只在查询真的慢时抓取慢查询计划。然后就证明了原因的确是不稳定的查询计划:查询计划器通常选择快查询计划,但偶尔会选择慢查询计划。
慢查询计划
QUERY PLAN
------------------------------------------------------------------------------
Sort (cost=540.08..540.09 rows=3 width=915)
Sort Key: created_at
-> Bitmap Heap Scan on messages (cost=536.03..540.06 rows=3 width=915)
Recheck Cond: (((jsonb_extract_path_text(context, VARIADIC '{topic}'::text[]) = ANY ('{?,?}'::text[])) OR (jsonb_extract_path_text(context, VARIADIC '{topic}'::text[]) ~~ '?%'::text)) AND (context @> '{"involved_parties": [{"id": 1, "type": 1}]}'::jsonb))
Filter: ((deleted_at IS NULL) AND ((namespace)::text = '?'::text) AND ((jsonb_extract_path_text(context, VARIADIC '{topic}'::text[]) = ANY ('{?,?}'::text[])) OR (jsonb_extract_path_text(context, VARIADIC '{topic}'::text[]) ~~ '?%'::text)))
-> BitmapAnd (cost=536.03..536.03 rows=1 width=0)
-> BitmapOr (cost=20.13..20.13 rows=249 width=0)
-> Bitmap Index Scan on index_messages_on_topic_key_string (cost=0.00..15.55 rows=249 width=0)
Index Cond: (jsonb_extract_path_text(context, VARIADIC '{topic}'::text[]) = ANY ('{?,?}'::text[]))
-> Bitmap Index Scan on index_messages_on_topic_key_string (cost=0.00..4.57 rows=1 width=0)
Index Cond: ((jsonb_extract_path_text(context, VARIADIC '{topic}'::text[]) ~>=~ '?'::text) AND (jsonb_extract_path_text(context, VARIADIC '{topic}'::text[]) ~<~ '?'::text))
-> Bitmap Index Scan on index_messages_on_context (cost=0.00..515.65 rows=29820 width=0)
Index Cond: (context @> '{"involved_parties": [{"id": 1, "type": 1}]}'::jsonb)
(这个查询计划来自EXPLAIN,由于EXPLAIN ANALYZE超时)
慢查询计划的可视化表示
如下图可见,这个慢查询计划比之前的快查询计划更复杂。它多了一个”BitmapAnd”和一个扫描index 3的”Bitmap Index Scan”节点(index 3是context列上的GIN索引)。若index 3低效率,总体性能就会降低。
当成本估计准确时,查询计划器工作得很好。但是JSONB上的GIN索引的成本估计是不准确的。由观测可见,它认为这个索引的选择度为0.001(这是一个硬编码的固定值),也就是说它假设任何相关的查询都会选择表中所有行的0.1%,但在我们这个场景它实际会选择90%的行,所以这个假设不成立。错误的假设使查询计划器低估了慢查询计划的成本。虽然JSONB类型的列也有一些统计信息,但好像没有起到作用。
解决方案
我们不能删除这个GIN索引,由于其他查询依赖它。但是,有一个简单的解决方案能避免这个查询用到GIN索引。一般地,若一个条件的操作符左侧不是简单的列名而是表达式,那么就不会用索引,除非有一个对应的表达式索引。例如,若对id列建有索引,条件”id = ?”就会使用索引,而条件”id+0 = ?”就不会使用索引。在我们的场景里,@>操作符的左侧是context 列,因此可以将其改为路径访问表达式”context->’involved_parties’”(参数值也相应地更新)。
原始的查询条件是
context @> ‘{“involved_parties”:[{“id”:1,“type”:1}]}’::jsonb
修改后的查询条件是
context->’involved_parties’ @> ‘[{“id”:1,”type”:1}]’::jsonb
实验
我们可以用一个简单的例子来测试这个有问题的条件。这个实验能稳定重现两种条件的不同效果,让我们有信心把这个优化推上线。
设计两个简单的查询语句:
优化前的语句1
SELECT * FROM messages WHERE context @> ‘{“involved_parties”:[{“id”:1,”type”:1}]}’::jsonb LIMIT 100
优化后的语句2
SELECT * FROM messages WHERE context->”involved_parties” @> ‘[{“id”:1,”type”:1}]’::jsonb LIMIT 100
因为语句2只做表扫描而不做索引扫描,所以用LIMIT 100来确保它在找到100个满足条件的行时就停下,而不用扫描整个表。而在语句1之中,GIN索引扫描总是无序的bitmap index scan,必须扫描整个索引而无法利用LIMIT 100。注意像10这样的LIMIT值会低于某个阈值从而只使用表扫描,只有高于这个阈值才会启用索引扫描,所以要用像100这样的较大LIMIT值(这个阈值取决于成本估计,据我们测试可能是20)。
得到的查询计划为:
QUERY PLAN ------------------------------------------------------------------------------ Limit (cost=2027.11..2399.74 rows=100 width=915) (actual time=6489.987..6490.102 rows=100 loops=1) -> Bitmap Heap Scan on messages (cost=2027.11..113145.85 rows=29820 width=915) (actual time=6489.986..6490.093 rows=100 loops=1) Recheck Cond: (context @> '{"involved_parties": [{"id": 1, "type": 1}]}'::jsonb) Heap Blocks: lossy=10 -> Bitmap Index Scan on index_messages_on_context (cost=0.00..2019.65 rows=29820 width=0) (actual time=6477.838..6477.839 rows="millions(confidential number)" loops=1) Index Cond: (context @> '{"involved_parties": [{"id": 1, "type": 1}]}'::jsonb) Planning time: 0.076 ms Execution time: 6490.920 ms
QUERY PLAN ------------------------------------------------------------------------------ Limit (cost=0.00..13700.25 rows=100 width=915) (actual time=0.013..0.114 rows=100 loops=1) -> Seq Scan on messages (cost=0.00..4085414.08 rows=29820 width=915) (actual time=0.013..0.106 rows=100 loops=1) Filter: ((context -> 'involved_parties'::text) @> '[{"id": 1, "type": 1}]'::jsonb) Planning time: 0.058 ms Execution time: 0.135 ms
对这种情况,优化后的查询快了5万倍。
生产环境的真实改善
以下图表展示了在生产环境上线后的显著改善。
每天的API请求数差不多 (每6小时时间窗有5~10k个请求)
12月19日(Dec 19)以来,错误显著减少
12月19日(Dec 19)以来,延迟显著减少
一些有用的原则
我们总结了数据库性能的一些原则(尤其适用于PostgreSQL)。
原则1: 少即是多
管理好索引
更多的索引并不意味着更好的性能。事实上,每增加一个索引都会降低写操作的性能。如果查询计划器选择了不高效的索引,那么查询仍然会很慢。
不要堆积索引(例如每一列都建索引就是不可取的)。试着尽可能删除一些索引吧。而且每改动一个索引都要监控其对性能的影响。
优选简单的数据库设计
RDBMS(关系型数据库系统)中的数据一般都宜用范式化设计。JSON或JSONB则是NoSQL风格的反范式化设计。
范式化和反范式化哪个更好呢?从业务的角度,要具体情况具体分析。从RDBMS的角度,范式化总是更简单更好,而反范式化则可以在某些情况作为补充。
建议1:考虑从DDD(领域驱动设计)的角度来设计数据模型。
- 实体总是可以建模为表,值对象总是可以嵌入保存在实体中(而有时为了性能,大型值对象也可以建模为表)。
- 某个关联的目标实体若为聚合根,就一定不能嵌入保存在别处(而要自成一表)。但如果关联的目标实体不是聚合根,并且关联的源实体是自包含的聚合根,那么目标实体就可以被嵌入保存。
建议2: 现代RDBMS中的可空列(nullable column)很高效,不用过于担心性能,如果多个可空列是对于可选属性(optional attribute)最简明的建模方式,就不要犹豫了,更别把JSONB当作对可空列的“优化”方式。
原则2: 统计信息要准确
PostgreSQL维护每一张表的统计信息,包括而不限于元组数(tuple number),页数(page number),最常见的值(most common values),柱状图界限(histogram bounds)和不同值的个数(number of distinct values, 可能相当于集的基数set cardinality)。有一些统计信息是采样得到的而不够准确。查询计划器会对查询生成多个可能的计划,根据统计信息和规则来估计成本,再选择最高效的那个计划。查询计划的质量取决于统计数据的准确性。准确的数据带来优秀的执行(这也是数据科学和数据驱动业务的一个好原则)。
正如所提到的,JSONB上的GIN的成本估计是不准确的。而标量类型上的BTREE的成本估计则准确得多。因此JSONB不适合某些情况。为了追求效率,作为变通方法,可以对JSONB的某个标量类型属性建一个BTREE表达式索引。来自ScaleGrid的这片文章很好地介绍了怎样高效使用JSONB和GIN。
建议:PostgreSQL有一些特性,如表达式索引和部分索引都是强大而有成本效益的。只要基于数据分析认为有效益,都值得选用之。
原则3: 提高可观察性
无论我们是否对问题的潜在根因有推测,提高可观察性都是最好的做法。查询日志能证明导致慢请求的是慢查询,而不是应用程序代码或连接等待。自动EXPLAIN能捕获慢查询所用的真实的查询计划。
像Datadog这样的APM(应用程序性能管理)也是一个重要的工具。它能提供很多洞察:
- 这个问题是由资源不足所致吗?不,资源不足应平等影响任何SQL CRUD语句,但我们只观察到慢的SELECT。
- 这个问题发生于每天的同一时间吗?不,它能发生于任何时间。
- 每一次发生是独立事件吗?不,会在某个小的时间窗聚集发生多个事件。那时一定是发生了什么事才导致这个问题。
致谢
感谢参与评阅的Vinod Kumar, Dylan Irlbeck, David Goussev, BJ Terry, Kyle Kinsey和其他Flexport同事。
参考
Understanding Postgres GIN Indexes: The Good and the Bad
Postgres Planner not using GIN index Occasionally
Gitlab once faced a GIN related issue
Understanding Postgres query planner behaviour on GIN index
Statistics used by the query planner
When To Avoid JSONB In A PostgreSQL Schema
Using JSONB in PostgreSQL: How to Effectively Store & Index JSON Data in PostgreSQL