CockroachDB旨在成为一个可扩展的、分布式的事务型数据库系统,能够满足高性能、高可用性和全球分布式应用的需求。然而,它并不是一个可以消除应用程序代码或架构设计中所有低效问题的"魔法盒"。开发人员和应用程序偶尔会向数据库发送SQL语句,这些语句的执行速度可能低于预期。因此,自关系数据库诞生以来,SQL调优就一直是数据库开发人员和管理员关注的重要问题。
在本章中,我们将解释CockroachDB的智能基于成本的优化器如何优化SQL语句,以及您如何帮助CockroachDB加速查询的执行。我们还将展示如何找到可能需要调优的查询,如何确定这些SQL语句是否已优化,并讨论如何让它们运行得更快。
查找慢SQL
假设我们已经设置了最新的、全面的查询统计信息,我们应该监控集群,识别任何有问题的SQL语句。
许多应用程序会记录SQL语句或逻辑事务时间,并提供关于性能不佳的SQL语句的洞察。然而,如果我们没有应用级别的追踪,CockroachDB本身也可以提供关于SQL执行时间的整体信息。
对于CockroachDB Cloud集群,最好的起始点如下:
Statements页面
通过cockroachlabs.cloud/cluster/YOU...访问,该页面列出了在服务器上执行的SQL语句以及基本的执行统计信息。我们可以通过平均语句时间或累积执行时间进行排序,以识别可能需要注意的SQL语句。点击特定的SQL语句将带我们进入语句详情页面。
Workload Insights页面
通过cockroachlabs.cloud/cluster/YOU...访问,该页面列出了语句和事务执行、它们的状态,以及是否遇到重试、执行缓慢或子优化计划。与Statements页面一样,Workload Insights也可以排序,点击某个洞察将打开详细的分解信息。
Schema Insights页面
通过cockroachlabs.cloud/cluster/YOU...访问,该页面列出了改进数据库架构对象的机会,突出显示了需要添加缺失索引、更新子优化索引或删除未使用索引的情况。
对于自托管集群,CockroachDB控制台(默认通过8080端口暴露)提供了丰富的洞察信息,包括CockroachDB Cloud集群中的洞察信息。
我们还有其他几种方法可以识别可能需要调优的SQL语句。SHOW STATEMENTS
命令显示当前正在执行的SQL语句,可能会显示当前占用资源的长时间运行查询。
通过将集群变量sql.log.slow_query.latency_threshold
设置为非零值,CockroachDB可以将"慢"查询记录到日志中。这样,超过阈值的查询将生成慢查询日志记录。日志记录将如下所示:
perl
I210809 07:47:09.663658 12467601 10@util/log/event_log.go:32 ⋮
[n1,client=‹192.168.0.245:57136›,hostnossl,user=root] 17
={"Timestamp":1628495229538628000,"EventType":"slow_query","Statement":
"‹SELECT city, id FROM ""."".vehicles WHERE city = $1›",
"User":"‹root›","ApplicationName":"‹movr›","PlaceholderValues":
["‹'amsterdam'›"],"ExecMode":"exec","NumRows":25,
"Age":125.039,"TxnCounter":11310}
在日志中识别运行缓慢的SQL语句并不简单。您需要以某种方式聚合这些条目,以识别重复的SQL语句,如果您没有访问服务器的权限(在CockroachDB Cloud中除非导出日志,否则无法访问),这些日志可能很难访问。
无论您如何操作,找到未能提供可接受性能的SQL是一个关键任务。现在我们已经找到了这些问题,让我们来看看如何进行调优。
解释和追踪SQL
当我们发现某个SQL语句表现不佳时,首先要做的是确定SQL的执行方式。这时,EXPLAIN
命令就派上用场了。
EXPLAIN
命令的语法如图8-1所示。
EXPLAIN
命令揭示了优化器如何决定SQL语句的执行计划。
让我们看一个简单的例子。下面是一个简单的SELECT
语句的EXPLAIN
,该语句涉及单一的表和单一的WHERE
条件:
ini
movr> EXPLAIN SELECT *
FROM rides
WHERE end_address = '66037 Belinda Plaza Apt.93';
输出结果:
sql
info
---------------------------------------------------------
distribution: full
vectorized: true
• filter
│ estimated row count: 1
│ filter: end_address = '66037 Belinda Plaza Apt. 93'
│
└── • scan
estimated row count: 20,000,063 (100% of the table;
stats collected 4 days ago)
table: rides@rides_pkey
spans: FULL SCAN
index recommendations: 1
1. type: index creation
SQL command: CREATE INDEX ON movr.public.rides (end_address) STORING
(vehicle_city, rider_id, vehicle_id, start_address, start_time,
end_time, revenue);
EXPLAIN
的输出通常是"从下到上"和"从内到外"地读取的。最深层次的语句(几乎总是位于计划的底部)首先被读取。因此,在上面的例子中,第一个操作是scan
,然后是filter
。
scan
步骤涉及对一个或多个表中的行进行读取。在此例中,表被表示为rides@rides_pkey
,即rides
表的基础表。spans: FULL SCAN
告诉我们,必须读取该表中的每一行。scan
步骤的输出会传递给filter
子句,去除任何不符合过滤条件(在此例中为end_address
)的行。
每个步骤都包括预估的行数------这些基于我们在前一部分讨论的优化器统计信息。
如例所示,CockroachDB会根据需要对查询提供索引建议,建议要索引的列以及需要存储(或"覆盖")的列。
稍后我们将讨论SQL调优的细节。现在,值得注意的是,对于非平凡大小的表,进行全表扫描通常是不理想的。因此,如果我们关注语句的执行时间,我们可能会通过创建索引来避免全表扫描。
接下来,让我们看一个使用索引的查询计划。示例8-1展示了一个涉及索引扫描的执行计划。
示例 8-1. 使用索引查询的EXPLAIN
ini
movr> EXPLAIN SELECT rider_id
FROM rides
WHERE vehicle_id = 'aaaaaaaa-aaaa-4800-8000-00000000000a'
AND vehicle_city = 'amsterdam'
AND end_address = '63002 Sheila Fall';
输出结果:
sql
info
-----------------------------------------------------------------------------------
distribution: local
vectorized: true
• filter
│ estimated row count: 1
│ filter: end_address = '63002 Sheila Fall'
│
└── • index join
│ estimated row count: 888
│ table: rides@rides_pkey
│
└── • scan
estimated row count: 888 (<0.01% of the table;
stats collected 2 hours ago)
table: rides@rides_auto_index_fk_vehicle_city_ref_vehicles
spans: [/'amsterdam'/'aaaaaaaa-aaaa-4800-8000-00000000000a'---
/'amsterdam'/'aaaaaaaa-aaaa-4800-8000-00000000000a']
index recommendations: 1
1. type: index creation
SQL command: CREATE INDEX ON movr.public.rides (vehicle_id,
vehicle_city, end_address) STORING (rider_id);
从示例8-1"从内到外"地看,我们可以看到三个步骤:
- 扫描步骤 ,这次"表"是
rides_auto_index_fk_vehicle_city_ref_vehicles
索引。spans
显示我们使用该索引来查找具有特定vehicle_city
和vehicle_id
组合的所有行。 - 索引连接 。索引连接将索引条目与基础表中的相应条目连接。在此例中,我们正在检索匹配的
vehicle_city
和vehicle_id
组合对应的行,以获得相应的rider_id
。 - 过滤条件 去除不匹配特定
end_address
值的行。
图8-2通过图示表示了这些步骤。
虽然这个计划使用了索引,但仍远未达到最优。索引只解决了部分问题,因为我们仍然需要从基础表中检索行,并过滤掉WHERE
子句中没有匹配的地址。通过为vehicle_city
、vehicle_id
、end_address
和rider_id
创建一个覆盖索引,这个查询将得到更好的优化,这正是CockroachDB在EXPLAIN
输出中所建议的。
例如,如果我们按照以下方式创建索引:
scss
movr> CREATE INDEX rides_vehicle_address_rider_ix1
ON rides(vehicle_city,vehicle_id,end_address)
STORING (rider_id);
执行计划将只显示一个单独的扫描操作:
sql
• scan
estimated row count: 1 (<0.01% of the table; stats collected 6 minutes ago)
table: rides@rides_vehicle_address_rider_ix1
spans: [/'amsterdam'/'aaaaaaaa-aaaa-...-00000000000a'/'63002 Sheila Fall'
- /'amsterdam'/'aaaaaaaa-aaaa-4800-8000-00000000000a'/'63002 Sheila Fall']
新的索引可以满足查询需求,而无需index_join
或filter
操作。现在让我们来看一个更复杂的例子。示例8-2展示了一个包含JOIN
和ORDER BY
的SQL语句。
示例 8-2. JOIN
操作的EXPLAIN
ini
movr> EXPLAIN SELECT *
FROM rides r
JOIN vehicles v ON
(r.vehicle_city = v.city
AND r.vehicle_id = v.id)
WHERE vehicle_id = 'aaaaaaaa-aaaa-4800-8000-00000000000a'
AND vehicle_city = 'amsterdam'
AND end_address = '63002 Sheila Fall'
ORDER BY start_address;
输出结果:
sql
info
-----------------------------------------------------------------------------------
distribution: full
vectorized: true
• sort
│ estimated row count: 1
│ order: +start_address
│
└── • lookup join
│ estimated row count: 1
│ table: rides@rides_pkey
│ equality: (city, id) = (city,id)
│ equality cols are key
│ pred: end_address = '63002 Sheila Fall'
│
└── • lookup join
│ estimated row count: 823
│ table: rides@rides_auto_index_fk_vehicle_city_ref_vehicles
│ equality: (city, id) = (vehicle_city,vehicle_id)
│ pred: (vehicle_id = 'aaaaaaaa-aaaa-4800-8000-00000000000a')
AND (vehicle_city = 'amsterdam')
│
└── • scan
estimated row count: 1 (<0.01% of the table;
stats collected 4 days ago)
table: vehicles@primary
spans: [/'amsterdam'/'aaaaaaaa-aaaa-4800-8000-00000000000a'---
/'amsterdam'/'aaaaaaaa-aaaa-4800-8000-00000000000a']
此查询中的四个步骤是:
- 使用
vehicles
表的主键索引来检索特定的城市/车辆位置。 - 将该
vehicles
行与rides
外键索引连接,获取使用该车辆的rides
主键。 - 然后,我们将连接到
rides
表本身,检索其余的RIDE
列。在这个步骤中,我们还会应用end_address
的过滤条件。 - 最后,我们按
start_address
对结果进行排序。
图8-3展示了执行计划的图示。
我们将在本章后面进一步阐述EXPLAIN计划的解读和优化。
EXPLAIN ANALYZE
EXPLAIN ANALYZE
是EXPLAIN
命令的更强大变体。EXPLAIN
告诉你优化器认为如果执行该命令会发生什么,而EXPLAIN ANALYZE
则执行该命令并告诉你实际发生了什么。
EXPLAIN ANALYZE
的语法如图8-4所示。
让我们看一个相对简单的SQL语句的EXPLAIN ANALYZE
输出:
ini
movr> EXPLAIN ANALYZE
SELECT *
FROM vehicles v
WHERE v.ext@> '{"brand":"Fuji"}'
AND v.city = 'paris'
AND v.status = 'in_use';
输出结果:
yaml
info
---------------------------------------------------------------------------
planning time: 568µs
execution time: 9ms
distribution: local
vectorized: true
rows read from KV: 2,240 (348 KiB)
cumulative time spent in KV: 6ms
maximum memory usage: 380 KiB
network usage: 0 B (0 messages)
regions: gcp-australia-southeast1
• filter
│ nodes: n7
│ regions: gcp-australia-southeast1
│ actual row count: 63
│ estimated row count: 136
│ filter: (ext @> '{"brand": "Fuji"}') AND (status = 'in_use')
│
└── • scan
nodes: n7
regions: gcp-australia-southeast1
actual row count: 2,240
KV rows read: 2,240
KV bytes read: 348 KiB
estimated row count: 2,220 (11% of the table; stats collected 5 days ago)
table: vehicles@primary
spans: [/'paris'---/'paris']
与EXPLAIN
一样,EXPLAIN ANALYZE
显示了执行计划,并对每个步骤提供了预估的行数。它还显示了实际的行数以及在存储层执行的操作数量。此外,还提供了查询的实际执行时间和内存使用情况。
实际的行数比预估的行数更有价值,实际经过的时间也非常有用。然而,EXPLAIN ANALYZE
的缺点是,它实际上执行了集群上的操作,这需要时间并产生实际负载。对于耗时较长的SQL语句,这可能并不理想。
EXPLAIN选项
EXPLAIN
命令有一些修饰符,可以增强或改变EXPLAIN
输出。你很少会使用这些修饰符,但它们仍然是调优工具箱中的重要工具。
VERBOSE
标志可以与EXPLAIN
一起使用,增加输出的详细程度,如下所示:
vbnet
movr> EXPLAIN (VERBOSE) SELECT *
FROM rides
WHERE end_address = '66037 Belinda Plaza Apt. 93';
输出结果:
sql
info
-----------------------------------------------------------------------
distribution: full
vectorized: true
• filter
│ columns: (id, city, vehicle_city, rider_id, vehicle_id,
start_address, end_address, start_time, end_time, revenue)
│ estimated row count: 0
│ filter: end_address = '66037 Belinda Plaza Apt. 93'
│
└── • scan
columns: (id, city, vehicle_city, rider_id, vehicle_id,
start_address, end_address, start_time, end_time, revenue)
estimated row count: 13,409 (100% of the table;
stats collected 9 days ago)
table: rides@rides_pkey
spans: FULL SCAN
OPT
选项显示由基于成本的优化器生成的查询计划树。单独使用时,这是EXPLAIN
的简化版本,但通过同时指定OPT
和VERBOSE
,EXPLAIN
将展示计划中使用的一些成本计算。要包括优化器使用的所有细节,包括统计信息,请使用OPT
和ENV
。此选项将生成一个URL,可以用来打印详细的优化器报告。
以下是一个OPT,VERBOSE
的EXPLAIN
示例,繁琐的直方图信息已被删除:
ini
movr> EXPLAIN (OPT,VERBOSE)
SELECT start_address
FROM rides
WHERE end_address = '63002 Sheila Fall';
输出结果:
sql
info
-----------------------------------------------
project
├── columns: start_address:6
├── stats: [rows=0.0468389869]
├── cost: 15167.4105
├── prune: (6)
└── select
├── columns: start_address:6 end_address:7
├── stats: [rows=0.0468389869, distinct(7)=0.0468389869, null(7)=0]
│ histogram(7)= 0 0.046839
│ <--- '63002 Sheila Fall'
├── cost: 15167.4
├── fd: ()-->(7)
├── scan rides
│ ├── columns: start_address:6 end_address:7
│ ├── stats: [rows=13409, distinct(7)=627, null(7)=12781]
│ │ histogram(7)= 0 12781
<--- NULL --- '10093 Julie Prairie' -----
'99954 Sarah Rapids'
│ ├── cost: 15033.29
│ └── prune: (6,7)
└── filters
└── end_address:7 = '63002 Sheila Fall' [outer=(7),
constraints=(/7: [/'63002 Sheila Fall
'---/'63002 Sheila Fall']; tight), fd=()-->(7)]
DISTSQL
选项生成一个URL,可以用来生成分布式SQL执行图。例如:
ini
movr> EXPLAIN (DISTSQL) SELECT *
FROM rides
WHERE end_address = '63002 Sheila Fall';
输出结果:
sql
info
----------------------------------------------
distribution: full
vectorized: true
• filter
│ estimated row count: 1
│ filter: end_address = '63002 Sheila Fall'
│
└── • scan
estimated row count: 20,000,063 (100% of the table;
stats collected 4 days ago)
table: rides@rides_pkey
spans: FULL SCAN
Diagram: https://cockroachdb.github.io/distsqlplan/decode.html#eJzE..._8GK5vg=
点击链接将生成如图8-5所示的图表。
分布式SQL图表可能有些难以解读,但图8-5中的图表的复杂性应该让我们深思。我们可以看到,在解析此查询时,涉及了五个"TableReader"节点。由于movr架构是地理分布式的(例如,特定城市的rides
位于特定节点上),那么为什么需要五个节点来检索单一地址的rides
呢?答案是,我们指定了end_address
列,但没有指定city
列。如果我们为一个包含city
列的查询生成一个分布式SQL图表:
ini
movr> EXPLAIN (DISTSQL) SELECT *
FROM rides
WHERE city='new york'
AND end_address = '63002 Sheila Fall';
我们将看到一个更加简单、且耗时较少的单节点操作,如图8-6所示。
请注意,这些图表的结构是特定于运行它们的分布式系统的。在单节点集群上生成的DISTSQL
图表通常会比在广泛分布的集群上运行的图表更简单。
EXPLAIN DEBUG
EXPLAIN ANALYZE
的DEBUG
选项会生成一个信息包,包含调优SQL语句时可能需要的几乎所有内容:
ini
movr> EXPLAIN ANALYZE (DEBUG)
SELECT *
FROM rides r
JOIN vehicles v ON
(r.vehicle_city=v.city and r.vehicle_id=v.id)
WHERE vehicle_id='aaaaaaaa-aaaa-4800-8000-00000000000a'
AND vehicle_city='amsterdam'
AND end_address='63002 Sheila Fall'
ORDER BY start_address;
输出结果:
bash
info
-----------------------------------------------------------------
Statement diagnostics bundle generated. Download from the Admin UI (Advanced
Debug -> Statement Diagnostics History), via the direct link below, or using
the command line.
Admin UI: https://guyharrison1-506-0:8080
link: https://guyharrison1-506-0:8080/_admin/v1/stmtbundle/682491029549164305
Command line: cockroach statement-diag list / download
可以通过EXPLAIN ANALYZE (DEBUG)
的输出中提供的URL获取调试包,或者使用cockroach statement-diag
命令来访问:
php
$ cockroach statement-diag list --url $CRDB_CLUSTER
Statement diagnostics bundles:
ID Collection time Statement
682648827880505350 2021-08-08 04:51 EXPLAIN ANALYZE (DEBUG) SELECT v.id...
667946704191422465 2021-06-17 06:32 INSERT INTO seq_cached(id, rnumber, ...
.
$ cockroach statement-diag download 682648827880505350 myExplainDebug.zip \
--url $CRDB_CLUSTER
$ unzip myExplainDebug.zip
Archive: myExplainDebug.zip
inflating: statement.txt
inflating: opt.txt
inflating: opt-v.txt
inflating: opt-vv.txt
inflating: plan.txt
inflating: distsql.html
inflating: trace.json
inflating: trace.txt
inflating: trace-jaeger.json
inflating: env.sql
inflating: schema.sql
inflating: stats-movr.public.vehicles.sql
inflating: stats-movr.public.rides.sql
ZIP文件包含了执行计划的文本表示、架构对象的定义以及我们之前查看的分布式SQL图表的URL。它还包括一个Jaeger兼容的追踪文件。Jaeger是一个流行的框架,用于可视化和分析分布式追踪信息。
Jaeger追踪文件显示了CockroachDB代码中各个部分在每个节点上消耗的时间。尽管这听起来很有用,但实际上,你通常更倾向于直接查看EXPLAIN
计划------有时信息少反而能更直接地引导你找到解决方案。然而,在复杂的情况下,Jaeger追踪文件可能正是解决问题的关键。
图8-7展示了Jaeger追踪的一个示例。
更改SQL执行
你已经确定一个SQL语句需要加速,并收集了EXPLAIN
信息以帮助理解当前的执行情况。那么,接下来该怎么办?
SQL性能的改善通常可以归结为以下几种选项:
更改或添加索引
索引主要用于提高性能,因此不出所料,你通常可以通过使用索引来提高性能。然而,需要小心的是,创建冗余或过多的索引可能会适得其反。
SQL重写
可能是你表达SQL的方式抑制了一个理想的执行计划。此外,你还可以使用提示来强制特定的执行路径。我们将在特定优化场景中讨论提示。
优化表查找
在我们能够进行连接、排序或以其他方式操作表数据之前,我们首先需要从至少一个表中读取数据。因此,优化表查找是一个至关重要的任务。
在某些SQL数据库中,索引和表的结构不同,并且有一个决策需要做出,即表的访问是基于索引还是基于全表扫描。然而,在CockroachDB中,表和索引的结构是相同的,因此问题不再是"使用索引还是表扫描?"而是"使用哪个索引?"
索引查找
在CockroachDB中,表访问的最佳解决方案是通过一个索引,该索引将所有WHERE子句中的谓词作为键的一部分,并将任何额外的SELECT列包含在STORING
子句中。
例如,考虑这个查询:
ini
movr> EXPLAIN
SELECT start_time, end_time
FROM rides
WHERE city = 'amsterdam'
AND start_address = '67104 Farrell Inlet'
AND end_address = '57998 Harvey Burg Suite 87';
输出结果:
sql
info
----------------------------------------------------------------------
distribution: local
vectorized: true
• filter
│ estimated row count: 0
│ filter: (start_address = '67104 Farrell Inlet')
AND (end_address = '57998 Harvey Burg Suite 87')
│
└── • scan
estimated row count: 2,257,435 (11% of the table;
stats collected 2 days ago)
table: rides@rides_pkey
spans: [/'amsterdam'---/'amsterdam']
index recommendations: 1
1. type: index creation
SQL command: CREATE INDEX ON movr.public.rides (city, start_address,
end_address) STORING (start_time, end_time);
这个查询从rides@rides_pkey
开始,表示rides
表的基础表和主键索引。在其他一些数据库中,主键索引是与表本身分开的结构;然而,在CockroachDB中,主键索引即是基础表。
如果我们按照CockroachDB的建议,创建一个包含WHERE子句中所有列的索引(注意本例中省略了STORING
子句):
scss
movr> CREATE INDEX rides_address_ix ON rides(city, start_address, end_address);
我们的执行计划现在看起来是这样的:
sql
• index join
│ estimated row count: 0
│ table: rides@rides_pkey
│
└── • scan
estimated row count: 0 (<0.01% of the table; stats collected 2 days ago)
table: rides@rides_address_ix
spans: [/'amsterdam'/'67104 Farrell Inlet'/'57998 Harvey Burg Suite 87'
---/'amsterdam'/'67104 Farrell Inlet'/'57998 Harvey Burg Suite 87']
我们现在使用了一个新索引,但出现了一个奇怪的索引连接步骤。查询中没有JOIN条件,那么为什么需要进行JOIN?
实际上,这个索引连接步骤表示我们正在将索引中的结果连接回基础表,以检索我们想要在SELECT列表中显示的列。换句话说,它表示从索引条目到基础表行的导航。
一个更好的索引应该是将这些列存储在索引中,这样就不必执行这个JOIN操作。这次,我们将按照CockroachDB的原始建议,创建一个覆盖索引:
scss
movr> CREATE INDEX rides_address_times_ix
ON rides(city, start_address, end_address)
STORING (start_time,end_time);
现在,我们的表访问已经优化,执行计划仅需要对索引进行一次扫描:
sql
• scan
estimated row count: 0 (<0.01% of the table; stats collected 9 minutes ago)
table: rides@rides_address_times_ix
spans: [/'amsterdam'/'67104 Farrell Inlet'/'57998 Harvey Burg Suite 87'---
/'amsterdam'/'67104 Farrell Inlet'/'57998 Harvey Burg Suite 87']
你可能还记得在第5章中我们提到过,可以使用串联索引来解决查询,只要在WHERE子句中指定了任何前导列。例如,我们刚刚创建的索引可以用于高效地解决这样的查询------其中前导列start_address
包含在WHERE子句中:
ini
movr> EXPLAIN
SELECT start_time, end_time
FROM rides
WHERE city = 'amsterdam'
AND start_address = '67104 Farrell Inlet';
输出结果:
sql
info
----------------------------------------------------------------------------
distribution: local
vectorized: true
• scan
estimated row count: 0 (<0.01% of the table; stats collected 10 minutes ago)
table: rides@rides_address_times_ix
spans: [/'amsterdam'/'67104 Farrell Inlet'---
/'amsterdam'/'67104 Farrell Inlet']
然而,索引不能用于优化如下查询,其中end_address
作为WHERE子句的尾随列:
ini
movr> EXPLAIN
SELECT start_time, end_time
FROM rides
WHERE city = 'amsterdam'
AND end_address = '57998 Harvey Burg Suite 87';
输出结果:
sql
info
--------------------------------------------------------------------------
distribution: local
vectorized: true
• index join
│ estimated row count: 1
│ table: rides@rides_pkey
│
└── • filter
│ estimated row count: 1
│ filter: end_address = '57998 Harvey Burg Suite 87'
│
└── • scan
estimated row count: 2,277,448
(11% of the table;
stats collected 10 minutes ago)
table: rides@rides_address_ix
spans: [/'amsterdam'---/'amsterdam']
请注意,这个查询展示了两个线索,表明可能可以进行索引优化:索引连接和过滤步骤。
提示
在执行计划中出现索引连接或过滤步骤,可能表明可以采取更好的索引解决方案。
索引合并
当一个查询在多个列上进行过滤,而这些列分别有独立的索引时,CockroachDB可能会执行索引合并------在EXPLAIN
输出中被标识为"zigzag join"(之字形连接)。
例如,假设有两个索引在一个名为iotData
的表上:
scss
CREATE INDEX iotState_ix ON iotData(state_code);
CREATE INDEX iotType_ix ON iotData(obs_type);
如果我们发出一个使用这两个列的查询,我们将看到之字形连接步骤:
ini
EXPLAIN SELECT avg(measurement)
FROM iotData
WHERE obs_type=10
AND state_code=10;
输出结果:
sql
info
---------------------------------------------------------
distribution: full
vectorized: true
• group (scalar)
│ estimated row count: 1
│
└── • lookup join
│ table: iotdata@primary
│ equality: (rowid) = (rowid)
│ equality cols are key
│
└── • zigzag join
estimated row count: 197
pred: (obs_type = 10) AND (state_code = 10)
left table: iotdata@iotstate_ix
left columns: (state_code, rowid)
left fixed values: 1 column
right table: iotdata@iottype_ix
right columns: (obs_type)
right fixed values: 1 column
之字形连接从其中一个索引开始读取,然后对于每个匹配条件的行,快速在第二个索引中查找匹配的主键和过滤条件的行。根据数据的分布,两个索引的之字形合并可能比仅使用其中一个索引更高效,几乎肯定比对基础表的扫描更高效。
图8-8展示了多种查询方法的性能。之字形合并的性能优于单个索引,并且远好于全表扫描。然而,正如以往一样,复合索引((state_code, obs_type) STORING(measurement)
)提供了最终的性能。
查询分发
EXPLAIN ANALYZE
输出包括每个步骤的nodes
属性,标识执行步骤中需要参与的节点,以及distribution
属性,标识哪些查询可以通过单个节点(即网关节点)解决,哪些查询需要转发到集群中的其他节点。
请记住,即使分发是"local",相关节点也可以从其他节点获取数据。然而,所有这些数据的SQL处理(如排序、连接、过滤)将发生在网关节点上。
对于查询来说,没有固定的"正确"或"错误"参与节点的数量。在聚合大量数据时,使用多个节点有助于并行处理并避免数据的网络传输------因为每个节点可以部分地聚合数据。另一方面,对于单行查找,我们希望并期望单个节点可以解决查询,如果看到多个节点参与,通常会感到担忧。
例如,考虑以下查询:
ini
movr> EXPLAIN ANALYZE
SELECT id
FROM rides r
WHERE start_address='81147 Samantha Manors';
输出结果:
yaml
info
---------------------------------------------------------------------------
planning time: 263µs
execution time: 13.1s
distribution: full
vectorized: true
rows read from KV: 20,012,724 (3.3 GiB)
cumulative time spent in KV: 25.4s
maximum memory usage: 450 KiB
network usage: 184 B (13 messages)
regions: gcp-australia-southeast1
• filter
│ nodes: n2, n3, n4, n5, n6, n8, n9
│ regions: gcp-australia-southeast1
│ actual row count: 1
│ estimated row count: 0
│ filter: start_address = '81147 Samantha Manors'
│
└── • scan
nodes: n2, n3, n4, n5, n6, n8, n9
regions: gcp-australia-southeast1
actual row count: 20,012,724
KV rows read: 20,012,724
KV bytes read: 3.3 GiB
estimated row count: 20,012,724 (100% of the table;
stats collected 2 days ago)
table: rides@rides_pkey
spans: FULL SCAN
全表扫描要求查询分发到七个节点。这并不令人惊讶,因为我们正在扫描整个表,并且数据分布在这些节点上。通过在过滤列上创建索引,查询的分发变为"local",仅涉及一个节点:
sql
movr> CREATE INDEX rides_start_add_ix ON rides(start_address);
CREATE INDEX
Time: 84.540s
movr> EXPLAIN ANALYZE
SELECT id
FROM rides r
WHERE start_address='81147 Samantha Manors';
输出结果:
yaml
info
---------------------------------------------------------------------------
planning time: 703µs
execution time: 10ms
distribution: local
vectorized: true
cumulative time spent in KV: 9ms
maximum memory usage: 10 KiB
network usage: 0 B (0 messages)
regions: gcp-australia-southeast1
• scan
nodes: n2
regions: gcp-australia-southeast1
actual row count: 0
KV rows read: 0
KV bytes read: 0 B
estimated row count: 0 (<0.01% of the table; stats collected 18 minutes ago)
table: rides@rides_start_add_ix
spans: [/'81147 Samantha Manors'---/'81147 Samantha Manors']
(18 rows)
一般来说,当你查找单行时,你希望只看到一个节点参与查找。
索引提示
如果我们希望强制使用特定的索引来访问表数据,可以通过在FROM
子句中指定索引名称来实现。例如,如果我们指定rides@rides_pkey
,那么我们将使用基础表(或主键索引)。带有OPT
选项的EXPLAIN
将显示我们有一个"force-index"操作:
ini
movr> EXPLAIN (OPT)
SELECT start_time, end_time
FROM rides@rides_pkey
WHERE city = 'amsterdam'
AND end_address = '57998 Harvey Burg Suite 87';
输出结果:
ini
info
project
└── select
├── scan rides
│ ├── constraint: /2/1: [/'amsterdam'---/'amsterdam']
│ └── flags: force-index=primary
└── filters
└── end_address = '57998 Harvey Burg Suite 87'
同样,我们可以强制使用特定的索引:
ini
movr> EXPLAIN (OPT)
SELECT start_time, end_time
FROM rides@rides_address_ix
WHERE city = 'amsterdam'
AND end_address = '57998 Harvey Burg Suite 87';
输出结果:
perl
info
project
└── index-join rides
└── select
├── scan rides@rides_address_ix
│ ├── constraint: /2/6/7/1: [/'amsterdam'---/'amsterdam']
│ └── flags: force-index=rides_address_ix
└── filters
└── end_address = '57998 Harvey Burg Suite 87'
注意事项
在FROM
子句中指定特定索引可以提高性能,如果优化器没有选择最佳索引。但是,这种做法有许多陷阱,应谨慎使用:
- 如果指定的索引被删除,SQL可能会失败。
- 如果将来创建了更好的索引,优化器将无法利用新索引。
- 表数据分布的变化可能会暗示将来可能有更好的计划,而索引提示将阻止这种优化。
建议将索引提示作为最后的手段,或用于在测试环境中评估不同索引的性能。
全表扫描
最臭名昭著的、不希望出现的查询步骤就是对大表进行全表扫描。在执行分析工作负载时,全表扫描是常见的现象。然而,在事务上下文中,它通常表示缺少索引或查询写法不正确。
以下是一个例子:
vbnet
movr> EXPLAIN
SELECT start_time, end_time
FROM rides
WHERE end_address = '57998 Harvey Burg Suite 87';
输出结果:
sql
info
----------------------------------------------------------------------------
distribution: full
vectorized: true
• filter
│ estimated row count: 0
│ filter: end_address = '57998 Harvey Burg Suite 87'
│
└── • scan
estimated row count: 20,012,724 (100% of the table;
stats collected 58 minutes ago)
table: rides@rides_pkey
spans: FULL SCAN
spans: FULL SCAN
条目告诉我们,表或索引中的每一行都被读取了。这通常是个坏现象------它可能表示你需要创建一个新的索引来支持查询,或者你没有在WHERE
子句中包含现有索引的前导列。在上面的例子中,是后者的错误:city
列应该与end_address
列一起包含。
CockroachDB的基于成本的优化器可能认为,FULL SCAN
比使用二级索引扫描和索引连接要更快。可以通过以下设置禁用这种行为:
ini
ALTER ROLE ALL SET disallow_full_table_scans = true;
应用此设置后,CockroachDB的基于成本的优化器将不会优先选择对大于large_full_scan_rows
变量定义的行数的表进行全表扫描。这个变量定义了CockroachDB认为对一个表进行FULL SCAN
的行数上限,以下是更新它的方法:
ini
-- 将默认值1,000增加到5,000。
SET large_full_scan_rows = 5000;
-- 完全禁用全表扫描。
SET large_full_scan_rows = 0;
提示
FULL SCAN
步骤可能表示缺少索引,或者没有包括现有索引的前导列。
全表扫描也可能发生在索引上,有时难以发现。例如,假设我们有一个索引来支持通过地址查找用户名:
scss
CREATE INDEX user_address_ix ON users(address) STORING (name);
当我们提供完整地址时,这个索引工作得很好:
sql
EXPLAIN SELECT name FROM users WHERE address='20069 Tara Cove';
info
-----------------------------------------------------------
distribution: local
vectorized: true
• scan
estimated row count: 0 (<0.01% of the table; stats collected 43 seconds ago)
table: users@user_address_ix
spans: [/'20069 Tara Cove'---/'20069 Tara Cove']
然而,如果我们不知道街道号码,提供一个通配符时,我们会发现访问了更多的行。尽管没有显示FULL SCAN
,spans: (/NULL---]
实际上是同样的事情;我们必须扫描整个索引来找到匹配的行:
sql
EXPLAIN SELECT name FROM users WHERE address LIKE '% Tara Cove';
info
---------------------------------------------------------------
distribution: local
vectorized: true
• filter
│ estimated row count: 269,875
│ filter: address LIKE '% Tara Cove'
│
└── • scan
estimated row count: 809,626 (100% of the table;
stats collected 43 seconds ago)
table: users@user_address_ix
spans: (/NULL---]
我们也可以通过对查询谓词应用操作不小心引发全表扫描。例如,如果我们不确定地址在数据库中的大小写,我们可能会尝试执行如下查询:
sql
movr> EXPLAIN SELECT name FROM users
WHERE LOWER(address)=LOWER('20069 Tara Cove');
info
---------------------------------------------------------------------------
distribution: full
vectorized: true
• filter
│ estimated row count: 33,333
│ filter: lower(address) = '20069 tara cove'
│
└── • scan
estimated row count: 100,000 (100% of the table;
table: users@users_pkey
spans: FULL SCAN
如你所见,通过在WHERE
子句中更改列的大小写,我们阻止了CockroachDB将这些值与索引条目匹配,导致了FULL SCAN
。可以创建一个表达式索引(见第5章)来支持这样的WHERE
子句。
计算列来拯救
解决这些问题的一个方法是为相关表达式创建带索引的计算列。在以下示例中,我们为去除街道号的地址创建了一个计算列,并在该列上创建了索引:
sql
movr>
movr> ALTER TABLE users ADD address_no_number STRING
AS (SUBSTR(address,POSITION(' ' IN address)+1) )
VIRTUAL;
ALTER TABLE
movr>
CREATE INDEX users_add_no_num_ix ON users(address_no_number)
STORING (name);
CREATE INDEX
现在,我们可以使用该索引来有效地查找没有街道号的地址:
sql
movr>
EXPLAIN SELECT name FROM users
WHERE address_no_number = 'Tara Cove';
info
-----------------------------------------------------------------------
distribution: local
vectorized: true
• scan
estimated row count: 2 (0.18% of the table; stats collected 13 seconds ago)
table: users@users_add_no_num_ix
spans: [/'Tara Cove'---/'Tara Cove']
类似的方法可以用于执行不区分大小写的搜索。我们将为大写的地址列创建一个计算列,并在该计算列上创建索引。
提示
带索引的计算列通常可以用于提供索引访问方法,当对SQL列进行操作(例如子字符串或不区分大小写的搜索)时,否则这些操作会抑制索引。
优化连接
连接操作会增加SQL查询的开销。每个连接都会涉及额外的存储引擎操作,在连接执行不当的情况下,开销可能会非常大。
此外,随着SQL中连接的增多,连接优化的复杂性会呈指数增长。在极端情况下,可能的计划数量将超过参与的表数的阶乘。例如,对于一个没有过滤条件的5表连接,可能的连接顺序数量可能高达5!(120种)。由于CockroachDB为每种顺序支持多种连接方法,因此可能的计划数可能达到数百种。
会话参数reorder_joins_limit
限制优化器将考虑的连接重新排序的次数。默认情况下,优化器仅会重新排序包含四个或更少连接的子树。对于执行时间较长的连接,增加此值可能使优化器找到更好的选项。
连接方法
大多数CockroachDB SQL连接会使用以下算法之一:
Lookup Join
CockroachDB对第一个(或"外")表中找到的每一行在第二个(或"内")表中执行查找。此类型的连接在内表对连接条件完全建立索引时最为有效,因为否则每次查找都需要执行完全或部分范围扫描。我们在本章前面看到的索引连接是查找连接的一种特例。
哈希连接(Hash Join)
CockroachDB从较小的表创建一个哈希表(如果可能的话在内存中;必要时在磁盘上),然后使用该哈希表作为即时索引,从较大的表中查找符合连接条件的行。当连接所有或大多数表的行,或者没有支持连接的索引时,哈希连接提供了可扩展的性能。
合并连接(Merge Join)
两个表必须在连接条件上有等效的索引。合并连接用于类似哈希连接的情况,但通常会比哈希连接更高效,因为不需要创建内存中的哈希表。
反向连接(Inverted Join)
这种算法较少使用。它发生在有连接条件依赖于JSONB或ARRAY中的值,并且只能通过对这些值使用反向索引来进行连接的情况(参见第5章讨论反向索引)。
让我们来看一些例子。
查找连接
对于大多数事务工作负载,你希望使用可以利用索引并且不需要扫描大量行的查找连接。
在这个例子中,我们通过用户名获取特定骑手的骑行标识符:
ini
movr> EXPLAIN
SELECT r.id FROM rides r
JOIN users u ON (u.city=r.city AND u.id=rider_id)
WHERE u.city='amsterdam'
AND u.name='Thomas Smith';
输出结果:
sql
info
---------------------------------------------------------------
distribution: full
vectorized: true
• lookup join
│ estimated row count: 1
│ table: rides@rides_auto_index_fk_city_ref_users
│ equality: (city, id) = (city,rider_id)
│ pred: city = 'amsterdam'
│
└── • scan
estimated row count: 0 (<0.01% of the table; )
table: users@user_names_idx
spans: [/'Thomas Smith'/'amsterdam'---/'Thomas Smith'/'amsterdam']
对于从users@user_names_idx
索引中检索的每一行,我们都会在rides@rides_auto_index_fk_city_ref_users
索引中执行查找(这是链接users
和RIDERS
的外键约束索引)。
正如我们在表访问部分看到的那样,你经常会看到"连接"发生在索引和它的基础表之间。例如,如果我们在WHERE
子句中投影用户的地址和骑行日期,我们会看到从索引到基础表的连接,以便检索这些列:
ini
movr> EXPLAIN
SELECT r.start_time ,u.address FROM rides r
JOIN users u ON (u.city=r.city AND u.id=rider_id)
WHERE u.city='amsterdam'
AND u.name='Thomas Smith';
输出结果:
sql
info
-----------------------------------------------------------------------
distribution: full
vectorized: true
• lookup join
│ table: rides@rides_pkey
│ equality: (city, id) = (city,id)
│ equality cols are key
│
└── • lookup join
│ estimated row count: 0
│ table: rides@rides_auto_index_fk_city_ref_users
│ equality: (city, id) = (city,rider_id)
│ pred: city = 'amsterdam'
│
└── • index join
│ estimated row count: 0
│ table: users@users_pkey
│
└── • scan
estimated row count: 0 (<0.01% of the table;
stats collected 6 minutes ago)
table: users@user_names_idx
spans: [/'Thomas Smith'/'amsterdam'---/'Thomas Smith'/'amsterdam']
这个两表三连接的计划对那些熟悉其他数据库的人来说可能会有些混乱。记住:在CockroachDB中,索引和表几乎是等效的结构。我们看到的连接首先是从users@user_names_idx
到users@users_pkey
表,再从那里连接到rides@rides_auto_index_fk_city_ref_users
外键索引,然后是连接到rides@rides_pkey
表。在像Oracle这样的数据库中,索引到表的"连接"会被描述为索引查找。
我们可以将这个执行计划用伪代码表示如下:
java
FOR 每个在 users@user_names_idx 中找到的匹配行:
在 users@users_pkey 中查找匹配的行
FOR 每个在 rides@fk 索引中找到的匹配 rider_id 的行
在 rides@rides_pkey 中查找匹配的行
将连接的行添加到结果集中。
看到索引连接和额外的查找连接并不需要担心。然而,考虑这个连接情况:
ini
movr>
EXPLAIN
SELECT r.start_time ,u.address FROM rides r
JOIN users u ON (u.city=r.city AND u.address=r.end_address)
WHERE u.city='amsterdam'
AND u.name='Thomas Smith';
输出结果:
sql
info
-----------------------------------------------------------------------------
distribution: full
vectorized: true
• lookup join
│ estimated row count: 1
│ table: rides@rides_pkey
│ equality: (city) = (city)
│ pred: (address = end_address) AND (city = 'amsterdam')
│
└── • index join
│ estimated row count: 0
│ table: users@users_pkey
│
└── • scan
estimated row count: 0 (<0.01% of the table;
stats collected 13 minutes ago)
table: users@user_names_idx
spans: [/'Thomas Smith'/'amsterdam'---/'Thomas Smith'/'amsterdam']
这个连接的"形状"与前一个例子类似,但效率较低。请注意,在最上面的查找连接中,等式条件只包括city
,而address
的比较出现在pred
(谓词)部分。这意味着,并非所有连接都可以通过索引来满足。
果然,如果我们执行EXPLAIN ANALYZE
,我们会看到在最终步骤中需要处理超过两百万行(所有位于阿姆斯特丹的骑行):
ini
EXPLAIN ANALYZE
SELECT r.start_time ,u.address FROM rides r
JOIN users u ON (u.city=r.city AND u.address=r.end_address)
WHERE u.city='amsterdam'
AND u.name='Thomas Smith';
输出结果:
yaml
info
------------------------------------------------------------------------------
planning time: 3ms
execution time: 12.8s
distribution: full
vectorized: true
rows read from KV: 2,223,731 (379 MiB)
cumulative time spent in KV: 5.3s
maximum memory usage: 1.8 MiB
network usage: 0 B (2 messages)
regions: gcp-australia-southeast1
• lookup join
│ nodes: n8
│ regions: gcp-australia-southeast1
│ actual row count: 0
│ KV rows read: 2,223,715
│ KV bytes read: 379 MiB
│ estimated row count: 1
│ table: rides@rides_pkey
│ equality: (city) = (city)
│ pred: (address = end_address) AND (city = 'amsterdam')
│
└── • index join
│ nodes: n8
│ regions: gcp-australia-southeast1
│ actual row count: 8
│ KV rows read: 8
│ KV bytes read: 911 B
│ estimated row count: 0
│ table: users@users_pkey
│
└── • scan
nodes: n8
regions: gcp-australia-southeast1
actual row count: 8
KV rows read: 8
KV bytes read: 628 B
estimated row count: 0 (<0.01% of the table;
stats collected 18 minutes ago)
table: users@user_names_idx
spans: [/'Thomas Smith'/'amsterdam'---/'Thomas Smith'/'amsterdam']
提示
如果连接计划中的等式条件没有包含连接条件中的所有列,那么可能表示该连接仅部分由索引支持。
哈希连接与合并连接
当没有索引可以支持连接时,CockroachDB将执行哈希连接,如以下示例所示:
sql
movr> EXPLAIN
SELECT COUNT(*)
FROM rides r
INNER JOIN vehicles v ON (v.id=r.vehicle_id)
;
输出结果:
sql
info
------------------------------------------------------------------------------
distribution: full
vectorized: true
• group (scalar)
│ estimated row count: 1
│
└── • hash join
│ estimated row count: 18,576,034
│ equality: (vehicle_id) = (id)
│
├── • scan
│ estimated row count: 20,012,724 (100% of the table;
stats collected 6 hours ago)
│ table: rides@rides_auto_index_fk_vehicle_city_ref_vehicles
│ spans: FULL SCAN
│
└── • scan
estimated row count: 20,429 (100% of the table;
stats collected 3 days ago)
table: vehicles@vehicles_auto_index_fk_city_ref_users
spans: FULL SCAN
哈希连接通常是连接所有或大多数两张表时的最佳解决方案。然而,如果有索引可用,我们也可能看到合并连接:
sql
movr> EXPLAIN
SELECT COUNT(*)
FROM rides r
INNER MERGE JOIN vehicles v ON (v.id=r.vehicle_id);
输出结果:
sql
info
----------------------------------------------------------------------------
distribution: full
vectorized: true
• group (scalar)
│ estimated row count: 1
│
└── • merge join
│ estimated row count: 2,064,004
│ equality: (vehicle_city, vehicle_id) = (city, id)
│ right cols are key
│
├── • scan
│ estimated row count: 20,012,724 (100% of the table;
stats collected 6 hours ago)
│ table: rides@rides_auto_index_fk_vehicle_city_ref_vehicles
│ spans: FULL SCAN
│
└── • scan
estimated row count: 20,429 (100% of the table;
stats collected 3 days ago)
table: vehicles@primary
spans: FULL SCAN
如果有适当的索引,我们还可以强制执行查找连接。在这种情况下(因为有支持索引),优化器倾向于使用查找连接:
sql
• group (scalar)
│ estimated row count: 1
│
└── • lookup join
│ estimated row count: 2,064,004
│ table: vehicles@primary
│ equality: (vehicle_city, vehicle_id) = (city,id)
│ equality cols are key
│
└── • scan
estimated row count: 20,012,724 (100% of the table;
stats collected 6 hours ago)
table: rides@rides_auto_index_fk_vehicle_city_ref_vehicles
spans: FULL SCAN
连接性能通常对数据分布非常敏感。然而,图8-9比较了在连接vehicles
和rides
表时哈希连接、合并连接和查找连接的性能。你会注意到,优化器选择使用查找连接可能不是最好的决策------哈希连接可能更快。优化器的决策基于启发式算法、算法和基数估算,因此不应认为它们是无懈可击的。
连接提示
我们可以在SQL语句中指定我们偏好的连接算法。这些"连接提示"在我们认为优化器选择了一个次优计划时非常有用。然而,它们具有我们之前讨论的索引提示的相同缺点。通过强制优化器,我们阻止了它适应未来索引或数据分布的变化。以下是强制使用三种连接方法的示例:
sql
SELECT COUNT(*)
FROM rides r
INNER MERGE JOIN vehicles v
ON (r.vehicle_city=v.city AND v.id=r.vehicle_id);
sql
SELECT COUNT(*)
FROM rides r
INNER LOOKUP JOIN vehicles v
ON (r.vehicle_city=v.city AND v.id=r.vehicle_id);
sql
SELECT COUNT(*)
FROM rides r
INNER HASH JOIN vehicles v
ON (r.vehicle_city=v.city AND v.id=r.vehicle_id);
当你指定连接方法时,实际上也是强制了一个特定的连接顺序。在之前的示例中,我们强制CockroachDB从rides
表开始,然后连接到vehicles
表。所以,当使用连接提示时,确保连接操作的顺序是你想要的。
外连接和反连接
到目前为止,我们只看了内连接类型。让我们简要看看外连接和反连接。
外连接使用与内连接相同的算法执行。然而,外连接限制了可能的连接顺序,因为内表必须在外表之前被访问。因此,查找连接算法不能与右外连接一起使用(因为你不能从"右"表中不存在的值开始查找)。
反连接是返回那些与另一个表中的行不匹配的行的连接。有几种方式可以在SQL中表示这一点。一种方法是执行一个NOT IN
子查询:
sql
movr> EXPLAIN
SELECT r.id FROM rides r
WHERE (city,rider_id) NOT IN
(SELECT city,user_id FROM user_promo_codes upc );
输出结果:
sql
info
-------------------------------------------------------------------------
distribution: full
vectorized: true
• cross join (anti)
│ estimated row count: 13,341,816
│ pred: (column20 = (city, rider_id)) IS NOT false
│
├── • scan
│ estimated row count: 20,012,724 (100% of the table;
stats collected 6 hours ago)
│ table: rides@rides_auto_index_fk_city_ref_users
│ spans: FULL SCAN
│
└── • render
│ estimated row count: 3,179
│
└── • scan
estimated row count: 3,179 (100% of the table;
stats collected 6 hours ago)
table: user_promo_codes@primary
spans: FULL SCAN
另一个选项是使用NOT EXISTS
:
sql
movr> EXPLAIN
SELECT r.id FROM rides r
WHERE NOT EXISTS (
SELECT city,user_id
FROM user_promo_codes upc
WHERE upc.city=r.city
AND upc.user_id=r.rider_id);
输出结果:
sql
info
--------------------------------------------------------------------------
distribution: full
vectorized: true
• merge join (anti)
│ estimated row count: 19,957,371
│ equality: (city, rider_id) = (city, user_id)
│
├── • scan
│ estimated row count: 20,012,724 (100% of the table;
stats collected 6 hours ago)
│ table: rides@rides_auto_index_fk_city_ref_users
│ spans: FULL SCAN
│
└── • scan
estimated row count: 3,179 (100% of the table;
stats collected 6 hours ago)
table: user_promo_codes@primary
spans: FULL SCAN
最后,我们可以执行外连接,并过滤那些没有匹配的行返回的NULL值:
sql
movr> EXPLAIN
SELECT r.id
FROM rides r
LEFT OUTER JOIN user_promo_codes upc
ON (upc.city=r.city
AND upc.user_id=r.rider_id)
WHERE upc.user_id IS NULL;
输出结果:
sql
info
-------------------------------------------------------------------------------
distribution: full
vectorized: true
• filter
│ estimated row count: 20,003,984
│ filter: user_id IS NULL
│
└── • merge join (left outer)
│ estimated row count: 20,012,724
│ equality: (city, rider_id) = (city, user_id)
│
├── • scan
│ estimated row count: 20,012,724 (100% of the table;
stats collected 6 hours ago)
│ table: rides@rides_auto_index_fk_city_ref_users
│ spans: FULL SCAN
│
└── • scan
estimated row count: 3,179 (100% of the table;
stats collected 6 hours ago)
table: user_promo_codes@primary
spans: FULL SCAN
这三种形式返回相同的数据,但执行计划却大不相同。通常,哪个执行计划是"最佳的"取决于数据的性质,特别是两张表的大小。在我们的例子中,user_promo_codes
的大小是rides
的一个小部分。然而,用来实现NOT IN
语法的交叉连接(anti)计划可能导致特别差的结果。图8-10展示了前述测试查询的结果。
连接操作指南总结
连接是最昂贵的SQL操作之一。对于CockroachDB通常遇到的事务性工作负载,建议遵循以下指南:
- 启用查找连接(Lookup Joins) :对于那些只连接少量行的查询,通过确保所有连接条件中的列上都有索引来启用查找连接。
- 尽量在连接之前减少行数 :确保任何非连接的
WHERE
子句条件也支持高效的索引。 - 仅在最后的手段下使用连接提示(Join Hints) ,并始终小心地测试替代的提示。记住,连接提示不仅强制指定方法,还会强制连接的顺序。
- 考虑替代方案而不是连接:第5章中描述的去规范化(Denormalization)通常是为了避免连接的开销。
优化排序和聚合
CockroachDB中典型的事务工作负载通常不涉及大规模的聚合,这种聚合通常在数据仓库数据库中更常见。然而,几乎总有一些报告或分析查询需要对非平凡的数据集进行聚合,因此,确保这些SQL语句不会不必要地对数据库造成负担非常重要。
在事务查询中,通常会检索"最新"或"下一个"行,尤其是在某些有序数据集的情况下,这就需要对数据进行排序。例如,我们可能想要选择特定城市中最近开始的骑行:
sql
movr> EXPLAIN
SELECT start_address
FROM rides
WHERE city = 'paris'
ORDER BY start_time DESC
LIMIT 10;
输出结果:
sql
info
-------------------------------------------------------------------------
distribution: full
vectorized: true
• limit
│ estimated row count: 10
│ count: 10
│
└── • sort
│ estimated row count: 2,279,450
│ order: -start_time
│
└── • scan
estimated row count: 2,279,450 (11% of the table;
stats collected 7 hours ago)
table: rides@rides_pkey
spans: [/'paris'---/'paris']
在这里,你可以看到我们扫描了rides
表中巴黎的条目,然后对结果进行排序------大约两百万行数据需要排序。
通常,最佳的解决方案是创建一个索引。索引不仅可以用于过滤行,还可以用于以特定顺序返回行。如果我们创建一个索引:
scss
movr> CREATE INDEX rides_start_time_address ON
rides(city, start_time) STORING (start_address);
我们可以以较低的开销按指定顺序检索行:
sql
movr> EXPLAIN
SELECT start_address
FROM rides
WHERE city = 'paris'
ORDER BY start_time DESC
LIMIT 10;
输出结果:
sql
info
---------------------------------------------------------------------------
distribution: local
vectorized: true
• revscan
estimated row count: 10 (<0.01% of the table;
stats collected 2 minutes ago)
table: rides@rides_start_time_address
spans: [/'paris'---/'paris']
limit: 10
在这里,索引的使用减少了排序的开销。
GROUP BY
和聚合查询通常处理较大的行集,但使用索引来减少开销仍然很重要。考虑这个查询,它按城市和车辆汇总收入:
sql
movr> EXPLAIN analyze
SELECT vehicle_city, vehicle_id, sum(revenue)
FROM rides
GROUP BY vehicle_city, vehicle_id
ORDER BY 3 DESC
LIMIT 10;
输出结果:
yaml
info
--------------------------------------------------------------------
planning time: 850µs
execution time: 29.7s
distribution: full
vectorized: true
rows read from KV: 20,012,724 (3.3 GiB)
cumulative time spent in KV: 43s
maximum memory usage: 30 MiB
network usage: 1.4 MiB (212 messages)
regions: gcp-australia-southeast1
• limit
│ nodes: n9
│ regions: gcp-australia-southeast1
│ actual row count: 10
│ estimated row count: 10
│ count: 10
│
└── • sort
│ nodes: n2, n5, n6, n8, n9
│ regions: gcp-australia-southeast1
│ actual row count: 50
│ estimated row count: 21,937
│ order: -sum
│
└── • group
│ nodes: n2, n5, n6, n8, n9
│ regions: gcp-australia-southeast1
│ actual row count: 21,972
│ estimated row count: 21,937
│ group by: vehicle_city, vehicle_id
│
└── • scan
nodes: n2, n5, n6, n8, n9
regions: gcp-australia-southeast1
actual row count: 20,012,724
KV rows read: 20,012,724
KV bytes read: 3.3 GiB
estimated row count: 20,012,724 (100% of the table;
stats collected 1 day ago)
table: rides@rides_pkey
spans: FULL SCAN
全表扫描后,执行了一个GROUP BY
和SORT
操作。如果对所有涉及的列存在索引,分组的开销将减少,因为可以按排序顺序消耗行------图8-11展示了性能的提升。
磁盘排序
当排序操作超出由sql.distsql.temp_storage.workmem
定义的阈值时,CockroachDB将在排序操作期间写入临时磁盘文件。默认情况下,限制为64 MB:
sql
show cluster setting sql.distsql.temp_storage.workmem;
sql.distsql.temp_storage.workmem
------------------------------------
64 MiB
64 MB并不是一个很大的内存量,虽然你可能不希望每个会话都同时消耗64 MB(尽管我们听过更糟的主意),但如果大的排序操作执行缓慢,你确实应该考虑增加这个值。
例如,考虑这个糟糕的查询,它在连接多个其他表后对rides
表中的所有行进行排序:
sql
movr> EXPLAIN ANALYZE
SELECT *
FROM rides r
INNER HASH JOIN users u
ON (r.city=u.city AND r.rider_id=u.id)
INNER HASH JOIN vehicles v
ON (v.city=r.vehicle_city AND v.id=r.vehicle_id)
LEFT OUTER HASH JOIN user_promo_codes upc
ON (upc.city=u.city AND upc.user_id=u.id)
LEFT OUTER HASH JOIN promo_codes pc
ON (upc.code=pc.code)
ORDER BY r.city,v.TYPE,u.address,pc.description LIMIT 10;
输出结果:
yaml
info
-----------------------------------------------------------------------------
planning time: 49ms
execution time: 7m29s
distribution: full
vectorized: true
rows read from KV: 20,854,691 (3.4 GiB)
cumulative time spent in KV: 1m23s
maximum memory usage: 204 MiB
network usage: 38 GiB (8,715,885 messages)
regions: gcp-australia-southeast1
如果我们增加sql.distsql.temp_storage.workmem
的值,我们可以将执行时间减少约40%(图8-12):
sql
movr> SET cluster setting sql.distsql.temp_storage.workmem='500 MiB';
SET CLUSTER SETTING
Time: 49ms
movr> EXPLAIN ANALYZE
SELECT *
FROM rides r
INNER HASH JOIN users u
ON (r.city=u.city AND r.rider_id=u.id)
INNER HASH JOIN vehicles v
ON (v.city=r.vehicle_city AND v.id=r.vehicle_id)
LEFT OUTER HASH JOIN user_promo_codes upc
ON (upc.city=u.city AND upc.user_id=u.id)
LEFT OUTER HASH JOIN promo_codes pc
ON (upc.code=pc.code)
ORDER BY r.city,v.TYPE,u.address,pc.description LIMIT 10;
输出结果:
yaml
info
------------------------------------------------------------------------
planning time: 2ms
execution time: 4m38s
distribution: full
vectorized: true
rows read from KV: 20,854,691 (3.4 GiB)
cumulative time spent in KV: 1m24s
maximum memory usage: 822 MiB
network usage: 33 GiB (5,982,233 messages)
regions: gcp-australia-southeast1
通过增加sql.distsql.temp_storage.workmem
的值,执行时间显著减少,表明增加内存限制有助于优化排序操作的性能。
磁盘排序可能由多种SQL操作引发,包括ORDER BY
、GROUP BY
、窗口函数、哈希连接和合并连接。每个节点在排序时可用的内存量也有限制,这些将在第14章中讨论。
优化DML
DML(数据操作语言)语句------INSERT
、UPDATE
、UPSERT
和 DELETE
------是事务系统的核心操作。然而,我们通常更关注查询优化,因为即使是在一个密集的在线事务处理(OLTP)系统中,查询的数量也超过DML,而DML优化的许多原则涉及到WHERE
子句的优化。
大多数DML语句包含一个查询组件------用来识别要处理的行或收集要插入的新行。例如,优化UPDATE
或DELETE
语句中的WHERE
子句,通常是DML优化的第一步。
索引主要是为了优化查询性能而存在,但对于DML语句来说,创建和维护索引的成本非常高。索引维护通常是DML性能的最大开销之一。因此,确保所有索引都是必需的。crdb_internal.index_usage_statistics
表包含有关索引使用的统计信息,可以查询该表找出"未使用"的索引。参照完整性约束也会增加DML的开销。
在优化带有索引的WHERE
子句时,尽量避免创建在更新过程中必须修改的索引。例如,考虑这个UPDATE
语句:
ini
movr> EXPLAIN ANALYZE
UPDATE users u
SET credit_card='9999804075'
WHERE city='rome'
AND name='Anna Massey'
AND address='75977 Donna Gateway Suite 52';
没有新的索引时,这个UPDATE
将不得不执行一个相当昂贵的全表扫描,遍历所有罗马用户并根据name
和address
进行过滤。根据我们反复推荐的做法,你可能会创建一个覆盖索引,如下所示:
scss
movr> CREATE INDEX users_city_name_add_cc_idx
ON users(city,name,address)
STORING(credit_card);
然而,这个索引中的STORING
子句实际上会影响性能。由于credit_card
号码正在变化,索引不仅需要更新,而且基础表也必须更新。优化更新的正确索引应该是:
scss
movr> CREATE INDEX users_city_name_add_idx
ON users(city,name,address);
因此,虽然在SELECT
列表中出现的列加上STORING
通常是一个好实践,但在SET
列表中使用STORING
可能适得其反。
正如许多索引决策一样,你需要权衡各种索引的成本与收益。虽然显示的STORING
子句对UPDATE
性能不利,但它可能对SELECT
语句有利。重要的是要记住,STORING
子句不应在没有明确了解其对SELECT
语句的正面影响与对UPDATE
语句的可能负面影响的情况下轻易添加到索引中。
为了减少SELECT
的开销而引入的去规范化------特别是避免连接------通常在更新操作中会产生一定成本。确保你没有维护无意义或无效的去规范化操作。我们在第5章中讨论了去规范化。
通过使用批量插入------在一个操作中插入多行------可以优化插入操作。第6章讨论了如何执行批量插入。
多语句DML的总执行时间可能会受到事务设计的强烈影响。请参考第6章讨论如何优化事务。
优化优化器
SQL是一种声明性语言------它指定了对数据的逻辑操作,而不定义该操作应如何执行。SQL的声明性特性使其相对容易理解,也是SQL广泛应用的主要原因之一。像所有SQL数据库系统一样,CockroachDB包含一个查询优化器,用于决定如何将SQL的逻辑请求转换为物理数据库操作。我们在第2章中介绍了SQL优化器。优化器做出的决策可以通过集群配置和表统计信息进行有利的影响。
在实践中,调优CockroachDB SQL时,通常不需要过多关注优化器的内部工作。大多数情况下,优化器会在可用的索引和SQL的约束条件下做出最优决策。
然而,优化器依赖于统计信息,这些统计信息能帮助它了解各个表和索引中数据的分布情况。你可以控制这些统计信息,在某些情况下,你可能需要对这些统计信息进行调整。
优化器统计信息
优化器的好坏取决于其输入的统计信息,因此,确保这些统计信息是最新的且全面是很重要的。CockroachDB会自动收集统计信息,大多数情况下,这些自动收集的统计信息就足够了。然而,你也可以选择调整统计信息的收集方式或手动收集统计信息。
查看统计信息
SHOW STATISTICS
命令允许我们查看为特定表收集的统计信息:
sql
movr> SHOW STATISTICS FOR table rides;
输出结果:
markdown
statistics_name| column_names | created | row_count | distinct_co | null_c|
---------------+-----------------+------------+-----------+-------------+-------+
__auto__ | {city} | 2021-08-03 | 20000000 | 9 | 0 |
__auto__ | {id} | 2021-08-03 | 20000000 | 31265268 | 0 |
__auto__ | {city,id} | 2021-08-03 | 20000000 | 47560228 | 0 |
__auto__ | {rider_id} | 2021-08-03 | 20000000 | 834396 | 0 |
__auto__ | {city,rider_id} | 2021-08-03 | 20000000 | 825716 | 0 |
__auto__ | {vehicle_city} | 2021-08-03 | 20000000 | 9 | 0 |
__auto__ | {vehicle_id} | 2021-08-03 | 20000000 | 20074 | 0 |
__auto__ | {vehicle_city,v | 2021-08-03 | 20000000 | 20033 | 0 |
__auto__ | {start_address} | 2021-08-03 | 20000000 | 48346139 | 0 |
默认情况下,会显示所有收集的统计信息,包括先前收集的结果。要查看最新的统计信息,可以执行如下查询:
sql
movr> WITH rides_statistics AS (
SELECT *
FROM [SHOW STATISTICS FOR TABLE rides])
SELECT column_names,row_count,distinct_count,null_count
FROM rides_statistics r
WHERE created =(
SELECT max(created)
FROM rides_statistics
WHERE column_names=r.column_names
);
输出结果:
bash
column_names | row_count | distinct_count | null_count
------------------------------+-----------+----------------+-------------
{city} | 20000566 | 9 | 0
{id} | 20000566 | 20121839 | 0
{city,id} | 20000566 | 19805157 | 0
{rider_id} | 20000566 | 805159 | 0
{city,rider_id} | 20000566 | 797137 | 0
{revenue} | 20000566 | 100 | 0
{vehicle_id} | 20000566 | 20136 | 0
{vehicle_city,vehicle_id} | 20000566 | 20108 | 0
{start_address} | 20000566 | 20001958 | 0
{end_address} | 20000566 | 19982168 | 461
{start_time} | 20000566 | 596 | 0
{end_time} | 20000566 | 862 | 461
{vehicle_city} | 20000566 | 9 | 0
{start_address,end_address} | 20000566 | 20385247 | 0
自动统计信息
统计信息在以下情况下会被自动收集:
- 表创建时
- 架构变更时
- 时间经过时
- 表的更改超过了一个阈值
SQL统计信息通过以下集群设置进行控制,可以通过SET CLUSTER SETTING
命令进行修改:
Cluster setting Description
sql.stats.automatic_collection.enabled
自动统计信息收集模式
sql.stats.automatic_collection.fraction_stale_rows
表中过时行的目标比例,用于触发统计信息刷新
sql.stats.automatic_collection.min_stale_rows
表中过时行的最小数量,用于触发统计信息刷新
sql.stats.histogram_collection.enabled
直方图收集模式
sql.stats.multi_column_collection.enabled
多列统计信息收集模式
手动收集统计信息
你可以通过执行CREATE STATISTICS
命令来为整个表创建或刷新统计信息:
sql
movr> CREATE STATISTICS manualStats FROM rides;
输出结果:
sql
CREATE STATISTICS
Time: 51.450s
你还可以为指定的列集创建统计信息。例如,下面为start_address
和end_address
创建统计信息:
vbnet
movr> CREATE STATISTICS city_addresses ON city, end_address
FROM movr.public.rides;
输出结果:
sql
CREATE STATISTICS
Time: 10.235s
这为优化器提供了有关这些列组合的基数信息。当两列在某种程度上相关,而优化器对此并不知情时,我们可以执行此操作。在这个例子中,我们直观地知道每个地址都位于一个城市内。除非我们收集这些统计信息,否则优化器会假设它们是独立的,从而高估了不同值的数量。
背景任务与统计信息收集
背景任务会被创建以收集统计信息。你可以通过SHOW JOBS
命令查看这些任务的状态。
有相对有限的情况需要更改统计信息。当表的20%的数据发生变化时,自动统计信息收集会被触发。在某些情况下,当特定列频繁变化时,这可能不足够。例如,如果你有一个时间戳列,且其值随时间增加,直方图通常不会显示最近的值,这可能使优化器选择该列上的索引,即使这是个不好的选择。另一个例子是一个"状态"列,表示某个任务是完成还是进行中。根据统计信息收集的时间,直方图可能显示没有进行中的任务。
总结
SQL调优是一个庞大的话题------关于它的书籍数不胜数,因此在本章中我们只能提供一个简要的介绍。
CockroachDB的查询优化器会尝试根据它所拥有的表统计信息和可用的访问路径,确定SQL语句的最佳执行计划。你可以通过确保统计信息是最新和相关的,来帮助优化器进行决策,但更重要的是,要确保存在一组最优的索引来支持将要执行的查询。
可以通过数据库控制台的Statements
页面或使用SHOW STATEMENTS
语句来找到可能需要调优的查询。EXPLAIN
命令可以用来揭示SQL语句将如何执行。任何严肃的SQL调优工作都不会忽视EXPLAIN
命令,特别是EXPLAIN ANALYZE
,它显示了实际的语句执行时间。
单表访问是更复杂SQL语句的构建块,因此确保通过使用适当的索引来优化这些访问,并避免意外的全表扫描。你可以通过使用索引"提示"强制使用索引,但提示应该是例外,而不是常规;提示可能会阻止优化器在未来形成更优的执行计划。
连接(Joins)通常通过确保连接条件上存在索引来优化,尽管对于完整表的连接,可能需要使用不依赖索引的哈希连接或合并连接。连接提示可以用来控制连接的类型和顺序,但同样,这些提示不应频繁使用。
当需要按特定顺序访问数据时,通常建议使用索引检索而不是排序操作。如果必须使用排序操作,请考虑调整可用于排序的内存量,以避免磁盘排序。
DML(数据操作语言)优化采用了查询优化的原则,尤其是在DML包含WHERE
子句时。避免过度索引和有效的事务设计也同样重要。
在下一章中,我们将探讨在规划生产环境中的CockroachDB集群时需要考虑的事项。