英文版刊载于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列上有两个索引:

  1. context列上的GIN索引
  2. 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索引的慢查问打算”。

无论有没有猜测,对于这种状况,最好的口头都是进步可察看性。短少一些数据吗?好的,收集它!咱们用一些代码来记录查问执行工夫,并只在查问真的慢时抓取慢查问打算。而后就证实了起因确实是不稳固的查问打算:查问打算器通常抉择快查问打算,但偶然会抉择慢查问打算。

慢查问打算

这个GitHub链接有高亮,更适宜浏览

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. 优化前的语句1

    SELECT * FROM messagesWHERE context @> ‘{“involved_parties”:[{“id”:1,”type”:1}]}’::jsonb LIMIT 100
  2. 优化后的语句2

    SELECT * FROM messagesWHERE 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)。

失去的查问打算为:

  • 优化前的语句1

    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
  • 优化后的语句2

    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 msExecution 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(应用程序性能治理)也是一个重要的工具。它能提供很多洞察:

  1. 这个问题是由资源有余所致吗?不,资源有余应平等影响任何SQL CRUD语句,但咱们只察看到慢的SELECT。
  2. 这个问题产生于每天的同一时间吗?不,它能产生于任何工夫。
  3. 每一次产生是独立事件吗?不,会在某个小的工夫窗汇集产生多个事件。那时肯定是产生了什么事才导致这个问题。

致谢

感激参加评阅的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