Статьи

Повышение производительности медленных запросов с помощью параллельного выполнения запросов

[Эта статья была написана Александром Рубином]

MySQL и Scaling-up (с использованием более мощного оборудования) всегда были горячей темой. Первоначально MySQL плохо масштабировался с несколькими процессорами; были случаи, когда InnoDB работал хуже с  большим количеством   ядер ЦП, чем с меньшим количеством ядер ЦП. MySQL 5.6 может масштабироваться значительно лучше; однако есть еще одно большое ограничение: 1 SQL-запрос в конечном итоге будет использовать только одно ядро ​​ЦП (без параллелизма). Вот что я имею в виду: скажем, у нас есть сложный запрос, который должен будет сканировать миллион строк и, возможно, потребуется создать временную таблицу; в этом случае MySQL не сможет сканировать таблицу в несколько потоков ( даже  с разбиением), поэтому один запрос не будет быстрее на более мощном сервере. Напротив, сервер с  более медленным Процессоры будут показывать худшую производительность, чем сервер с меньшими (но более быстрыми) процессорами.

Для решения этой проблемы мы можем использовать параллельное выполнение запроса. Вадим написал об  асинхронных вызовах PHP для MySQL . Другим способом увеличения параллелизма будет использование подхода «шардинга», например, с  Shard Query . Я решил протестировать параллельное (асинхронное) выполнение запроса с относительно большой таблицей: я использовал базу данных о производительности полетов в  США , которая первоначально использовалась Вадимом в старом посте «  Анализ производительности воздушного движения» . Давайте посмотрим, как это может помочь нам повысить производительность сложных отчетов о запросах.

Пример параллельного запроса

Чтобы проиллюстрировать выполнение параллельного запроса с MySQL, я создал следующую таблицу:

CREATE TABLE `ontime` (
`YearD` year(4) NOT NULL,
`Quarter` tinyint(4) DEFAULT NULL,
`MonthD` tinyint(4) DEFAULT NULL,
`DayofMonth` tinyint(4) DEFAULT NULL,
`DayOfWeek` tinyint(4) DEFAULT NULL,
`FlightDate` date DEFAULT NULL,
`UniqueCarrier` char(7) DEFAULT NULL,
`AirlineID` int(11) DEFAULT NULL,
`Carrier` char(2) DEFAULT NULL,
`TailNum` varchar(50) DEFAULT NULL,
`FlightNum` varchar(10) DEFAULT NULL,
`OriginAirportID` int(11) DEFAULT NULL,
`OriginAirportSeqID` int(11) DEFAULT NULL,
`OriginCityMarketID` int(11) DEFAULT NULL,
`Origin` char(5) DEFAULT NULL,
`OriginCityName` varchar(100) DEFAULT NULL,
`OriginState` char(2) DEFAULT NULL,
`OriginStateFips` varchar(10) DEFAULT NULL,
`OriginStateName` varchar(100) DEFAULT NULL,
`OriginWac` int(11) DEFAULT NULL,
`DestAirportID` int(11) DEFAULT NULL,
`DestAirportSeqID` int(11) DEFAULT NULL,
`DestCityMarketID` int(11) DEFAULT NULL,
`Dest` char(5) DEFAULT NULL,
-- ... (removed number of fields)
`id` int(11) NOT NULL AUTO_INCREMENT,
PRIMARY KEY (`id`),
KEY `YearD` (`YearD`),
KEY `Carrier` (`Carrier`)
) ENGINE=InnoDB DEFAULT CHARSET=latin1;

И загрузил в него данные за 26 лет. Таблица 56G с ~ 152M строк.

Программное обеспечение: Percona 5.6.15-63.0. Аппаратное обеспечение: Supermicro; X8DTG-Д; 48 ГБ ОЗУ; 24 xIntel (X) Xeon (R) CPU L5639 с частотой 2,13 ГГц, 1xSSD (250 ГБ)

Итак, у нас 24 относительно медленных процессора

Простой запрос

Теперь мы можем запустить несколько запросов. Первый запрос очень прост: найдите все рейсы за год (в США):

select yeard, count(*) from ontime group by yeard

Поскольку у нас есть индекс на YearD, запрос будет использовать индекс:

mysql> explain select yeard, count(*) from ontime group by yeardG
*************************** 1. row ***************************
id: 1
select_type: SIMPLE
table: ontime
type: index
possible_keys: YearD,comb1
key: YearD
key_len: 1
ref: NULL
rows: 148046200
Extra: Using index
1 row in set (0.00 sec)

Запрос прост, однако, он должен будет сканировать 150 миллионов строк. Вот результаты запроса (кэшированные):

mysql> select yeard, count(*) from ontime group by yeard;
+-------+----------+
| yeard | count(*) |
+-------+----------+
| 1988  | 5202096  |
| 1989  | 5041200  |
| 1990  | 5270893  |
| 1991  | 5076925  |
| 1992  | 5092157  |
| 1993  | 5070501  |
| 1994  | 5180048  |
| 1995  | 5327435  |
| 1996  | 5351983  |
| 1997  | 5411843  |
| 1998  | 5384721  |
| 1999  | 5527884  |
| 2000  | 5683047  |
| 2001  | 5967780  |
| 2002  | 5271359  |
| 2003  | 6488540  |
| 2004  | 7129270  |
| 2005  | 7140596  |
| 2006  | 7141922  |
| 2007  | 7455458  |
| 2008  | 7009726  |
| 2009  | 6450285  |
| 2010  | 6450117  |
| 2011  | 6085281  |
| 2012  | 6096762  |
| 2013  | 5349447  |
+-------+----------+
26 rows in set (54.10 sec)

Запрос занял 54 секунды и использовал только 1 ядро ​​процессора. Однако этот запрос идеально подходит для параллельной работы. Мы можем запустить 26 параллельных запросов, каждый будет считать свой год. Я использовал следующий скрипт для запуска запросов в фоновом режиме:

#!/bin/bash
date
for y in {1988..2013}
do
  sql="select yeard, count(*) from ontime where yeard=$y"
  mysql -vvv ontime -e "$sql" &>par_sql1/$y.log &
done
wait
date

Вот результаты:

par_sql1/1988.log:1 row in set (3.70 sec)
par_sql1/1989.log:1 row in set (4.08 sec)
par_sql1/1990.log:1 row in set (4.59 sec)
par_sql1/1991.log:1 row in set (4.26 sec)
par_sql1/1992.log:1 row in set (4.54 sec)
par_sql1/1993.log:1 row in set (2.78 sec)
par_sql1/1994.log:1 row in set (3.41 sec)
par_sql1/1995.log:1 row in set (4.87 sec)
par_sql1/1996.log:1 row in set (4.41 sec)
par_sql1/1997.log:1 row in set (3.69 sec)
par_sql1/1998.log:1 row in set (3.56 sec)
par_sql1/1999.log:1 row in set (4.47 sec)
par_sql1/2000.log:1 row in set (4.71 sec)
par_sql1/2001.log:1 row in set (4.81 sec)
par_sql1/2002.log:1 row in set (4.19 sec)
par_sql1/2003.log:1 row in set (4.04 sec)
par_sql1/2004.log:1 row in set (5.12 sec)
par_sql1/2005.log:1 row in set (5.10 sec)
par_sql1/2006.log:1 row in set (4.93 sec)
par_sql1/2007.log:1 row in set (5.29 sec)
par_sql1/2008.log:1 row in set (5.59 sec)
par_sql1/2009.log:1 row in set (4.44 sec)
par_sql1/2010.log:1 row in set (4.91 sec)
par_sql1/2011.log:1 row in set (5.08 sec)
par_sql1/2012.log:1 row in set (4.85 sec)
par_sql1/2013.log:1 row in set (4.56 sec)

Сложный запрос 

Теперь мы можем попробовать более сложный запрос. Давайте представим, что мы хотим выяснить, какие авиакомпании имеют максимальные задержки для рейсов внутри континентальной части США в рабочие дни с 1988 по 2009 год (я пытался придумать сложный запрос с несколькими условиями в предложении where).

select
   min(yeard), max(yeard), Carrier, count(*) as cnt,
   sum(ArrDelayMinutes>30) as flights_delayed,
   round(sum(ArrDelayMinutes>30)/count(*),2) as rate
FROM ontime
WHERE
   DayOfWeek not in (6,7) and OriginState not in ('AK', 'HI', 'PR', 'VI')
   and DestState not in ('AK', 'HI', 'PR', 'VI')
   and flightdate < '2010-01-01'
GROUP by carrier
HAVING cnt > 100000 and max(yeard) > 1990
ORDER by rate DESC

Так как запрос имеет «group by» и «order by» плюс несколько диапазонов в предложении where, он должен будет создать временную таблицу:

id: 1
select_type: SIMPLE
table: ontime
type: index
possible_keys: comb1
key: comb1
key_len: 9
ref: NULL
rows: 148046200
Extra: Using where; Using temporary; Using filesort

(для этого запроса я создал комбинированный индекс: KEY  comb1 ( Carrier, YearD, ArrDelayMinutes) для повышения производительности)

Запрос выполняется через ~ 15 минут:

+------------+------------+---------+----------+-----------------+------+
| min(yeard) | max(yeard) | Carrier | cnt      | flights_delayed | rate |
+------------+------------+---------+----------+-----------------+------+
|       2003 |       2009 | EV      |  1454777 |          237698 | 0.16 |
|       2006 |       2009 | XE      |  1016010 |          152431 | 0.15 |
|       2006 |       2009 | YV      |   740608 |          110389 | 0.15 |
|       2003 |       2009 | B6      |   683874 |          103677 | 0.15 |
|       2003 |       2009 | FL      |  1082489 |          158748 | 0.15 |
|       2003 |       2005 | DH      |   501056 |           69833 | 0.14 |
|       2001 |       2009 | MQ      |  3238137 |          448037 | 0.14 |
|       2003 |       2006 | RU      |  1007248 |          126733 | 0.13 |
|       2004 |       2009 | OH      |  1195868 |          160071 | 0.13 |
|       2003 |       2006 | TZ      |   136735 |           16496 | 0.12 |
|       1988 |       2009 | UA      |  9593284 |         1197053 | 0.12 |
|       1988 |       2009 | AA      | 10600509 |         1185343 | 0.11 |
|       1988 |       2001 | TW      |  2659963 |          280741 | 0.11 |
|       1988 |       2009 | CO      |  6029149 |          673863 | 0.11 |
|       2007 |       2009 | 9E      |   577244 |           59440 | 0.10 |
|       1988 |       2009 | DL      | 11869471 |         1156267 | 0.10 |
|       1988 |       2009 | NW      |  7601727 |          725460 | 0.10 |
|       1988 |       2009 | AS      |  1506003 |          146920 | 0.10 |
|       2003 |       2009 | OO      |  2654259 |          257069 | 0.10 |
|       1988 |       2009 | US      | 10276941 |          991016 | 0.10 |
|       1988 |       1991 | PA      |   206841 |           19465 | 0.09 |
|       1988 |       2005 | HP      |  2607603 |          235675 | 0.09 |
|       1988 |       2009 | WN      | 12722174 |         1107840 | 0.09 |
|       2005 |       2009 | F9      |   307569 |           28679 | 0.09 |
+------------+------------+---------+----------+-----------------+------+
24 rows in set (15 min 56.40 sec)

Теперь мы можем разделить этот запрос и выполнить 31 запрос (= 31 отдельная авиакомпания в этой таблице) параллельно. Я использовал следующий скрипт:

date
for c in '9E' 'AA' 'AL' 'AQ' 'AS' 'B6' 'CO' 'DH' 'DL' 'EA' 'EV' 'F9' 'FL' 'HA' 'HP' 'ML' 'MQ' 'NW' 'OH' 'OO' 'PA' 'PI' 'PS' 'RU' 'TW' 'TZ' 'UA' 'US' 'WN' 'XE' 'YV'
do
   sql=" select min(yeard), max(yeard), Carrier, count(*) as cnt, sum(ArrDelayMinutes>30) as flights_delayed, round(sum(ArrDelayMinutes>30)/count(*),2) as rate FROM ontime WHERE DayOfWeek not in (6,7) and OriginState not in ('AK', 'HI', 'PR', 'VI') and DestState not in ('AK', 'HI', 'PR', 'VI') and flightdate < '2010-01-01' and carrier = '$c'"
   mysql -uroot -vvv ontime -e "$sql" &>par_sql_complex/$c.log &
done
wait
date

В этом случае мы также избегаем создания временной таблицы (так как у нас есть индекс, который начинается с носителя).

Результаты: общее время 5 минут 47 секунд (в 3 раза быстрее)

Start: 15:41:02 EST 2013
  End: 15:46:49 EST 2013

Статистика запросов:

par_sql_complex/9E.log:1 row in set (44.47 sec)
par_sql_complex/AA.log:1 row in set (5 min 41.13 sec)
par_sql_complex/AL.log:1 row in set (15.81 sec)
par_sql_complex/AQ.log:1 row in set (14.52 sec)
par_sql_complex/AS.log:1 row in set (2 min 43.01 sec)
par_sql_complex/B6.log:1 row in set (1 min 26.06 sec)
par_sql_complex/CO.log:1 row in set (3 min 58.07 sec)
par_sql_complex/DH.log:1 row in set (31.30 sec)
par_sql_complex/DL.log:1 row in set (5 min 47.07 sec)
par_sql_complex/EA.log:1 row in set (28.58 sec)
par_sql_complex/EV.log:1 row in set (2 min 6.87 sec)
par_sql_complex/F9.log:1 row in set (46.18 sec)
par_sql_complex/FL.log:1 row in set (1 min 30.83 sec)
par_sql_complex/HA.log:1 row in set (39.42 sec)
par_sql_complex/HP.log:1 row in set (2 min 45.57 sec)
par_sql_complex/ML.log:1 row in set (4.64 sec)
par_sql_complex/MQ.log:1 row in set (2 min 22.55 sec)
par_sql_complex/NW.log:1 row in set (4 min 26.67 sec)
par_sql_complex/OH.log:1 row in set (1 min 9.67 sec)
par_sql_complex/OO.log:1 row in set (2 min 14.97 sec)
par_sql_complex/PA.log:1 row in set (17.62 sec)
par_sql_complex/PI.log:1 row in set (14.52 sec)
par_sql_complex/PS.log:1 row in set (3.46 sec)
par_sql_complex/RU.log:1 row in set (40.14 sec)
par_sql_complex/TW.log:1 row in set (2 min 32.32 sec)
par_sql_complex/TZ.log:1 row in set (14.16 sec)
par_sql_complex/UA.log:1 row in set (4 min 55.18 sec)
par_sql_complex/US.log:1 row in set (4 min 38.08 sec)
par_sql_complex/WN.log:1 row in set (4 min 56.12 sec)
par_sql_complex/XE.log:1 row in set (24.21 sec)
par_sql_complex/YV.log:1 row in set (20.82 sec)

Как мы видим, есть крупные авиакомпании (такие как AA, UA, США, DL и т. Д.), Которые заняли большую часть времени. В этом случае нагрузка не будет распределена равномерно, как в предыдущем примере; однако, запустив запрос параллельно, мы получили в 3 раза лучшее время отклика на этом сервере.

Загрузка процессора:

Cpu3 : 22.0%us, 1.2%sy, 0.0%ni, 74.4%id, 2.4%wa, 0.0%hi, 0.0%si, 0.0%st
Cpu4 : 16.0%us, 0.0%sy, 0.0%ni, 84.0%id, 0.0%wa, 0.0%hi, 0.0%si, 0.0%st
Cpu5 : 39.0%us, 1.2%sy, 0.0%ni, 56.1%id, 3.7%wa, 0.0%hi, 0.0%si, 0.0%st
Cpu6 : 33.3%us, 0.0%sy, 0.0%ni, 51.9%id, 13.6%wa, 0.0%hi, 1.2%si, 0.0%st
Cpu7 : 33.3%us, 1.2%sy, 0.0%ni, 48.8%id, 16.7%wa, 0.0%hi, 0.0%si, 0.0%st
Cpu8 : 24.7%us, 0.0%sy, 0.0%ni, 60.5%id, 14.8%wa, 0.0%hi, 0.0%si, 0.0%st
Cpu9 : 24.4%us, 0.0%sy, 0.0%ni, 56.1%id, 19.5%wa, 0.0%hi, 0.0%si, 0.0%st
Cpu10 : 40.7%us, 0.0%sy, 0.0%ni, 56.8%id, 2.5%wa, 0.0%hi, 0.0%si, 0.0%st
Cpu11 : 19.5%us, 1.2%sy, 0.0%ni, 65.9%id, 12.2%wa, 0.0%hi, 1.2%si, 0.0%st
Cpu12 : 40.2%us, 1.2%sy, 0.0%ni, 56.1%id, 2.4%wa, 0.0%hi, 0.0%si, 0.0%st
Cpu13 : 82.7%us, 0.0%sy, 0.0%ni, 17.3%id, 0.0%wa, 0.0%hi, 0.0%si, 0.0%st
Cpu14 : 55.4%us, 0.0%sy, 0.0%ni, 43.4%id, 1.2%wa, 0.0%hi, 0.0%si, 0.0%st
Cpu15 : 86.6%us, 0.0%sy, 0.0%ni, 13.4%id, 0.0%wa, 0.0%hi, 0.0%si, 0.0%st
Cpu16 : 61.0%us, 1.2%sy, 0.0%ni, 37.8%id, 0.0%wa, 0.0%hi, 0.0%si, 0.0%st
Cpu17 : 29.3%us, 1.2%sy, 0.0%ni, 69.5%id, 0.0%wa, 0.0%hi, 0.0%si, 0.0%st
Cpu18 : 18.8%us, 0.0%sy, 0.0%ni, 52.5%id, 28.8%wa, 0.0%hi, 0.0%si, 0.0%st
Cpu19 : 14.3%us, 1.2%sy, 0.0%ni, 57.1%id, 27.4%wa, 0.0%hi, 0.0%si, 0.0%st
Cpu20 : 12.3%us, 0.0%sy, 0.0%ni, 59.3%id, 28.4%wa, 0.0%hi, 0.0%si, 0.0%st
Cpu21 : 10.7%us, 0.0%sy, 0.0%ni, 76.2%id, 11.9%wa, 0.0%hi, 1.2%si, 0.0%st
Cpu22 : 0.0%us, 0.0%sy, 0.0%ni,100.0%id, 0.0%wa, 0.0%hi, 0.0%si, 0.0%st
Cpu23 : 10.8%us, 2.4%sy, 0.0%ni, 71.1%id, 15.7%wa, 0.0%hi, 0.0%si, 0.0%st

Обратите внимание, что в случае «упорядочить по» нам нужно будет отсортировать результаты вручную, однако сортировка 10-100 строк будет быстрой.

Вывод

Разделение сложного отчета на несколько запросов и параллельное выполнение (асинхронно) может повысить производительность (от 3 до 10 раз в приведенном выше примере) и лучше использовать современное оборудование. Также возможно разделить запросы между несколькими серверами MySQL (т.е. подчиненными серверами MySQL) для дальнейшего увеличения масштабируемости (потребуется больше кодирования).