[英]Optimize a query that group results by a field from the joined table
我有一個非常簡單的查詢,它必須按連接表中的字段對結果進行分組:
SELECT SQL_NO_CACHE p.name, COUNT(1) FROM ycs_sales s
INNER JOIN ycs_products p ON s.id = p.sales_id
WHERE s.dtm BETWEEN '2018-02-16 00:00:00' AND '2018-02-22 23:59:59'
GROUP BY p.name
表 ycs_products 實際上是 sales_products,列出了每個銷售中的產品。 我想查看一段時間內每種產品的銷售份額。
當前的查詢速度是 2 秒,這對於用戶交互來說太多了。 我需要讓這個查詢快速運行。 有沒有辦法在沒有非規范化的情況下擺脫Using temporary
?
連接順序至關重要,兩個表中都有大量數據,按日期限制記錄數是毋庸置疑的先決條件。
這是解釋結果
*************************** 1. row ***************************
id: 1
select_type: SIMPLE
table: s
type: range
possible_keys: PRIMARY,dtm
key: dtm
key_len: 6
ref: NULL
rows: 1164728
Extra: Using where; Using index; Using temporary; Using filesort
*************************** 2. row ***************************
id: 1
select_type: SIMPLE
table: p
type: ref
possible_keys: sales_id
key: sales_id
key_len: 5
ref: test.s.id
rows: 1
Extra:
2 rows in set (0.00 sec)
和 json 一樣
EXPLAIN: {
"query_block": {
"select_id": 1,
"filesort": {
"sort_key": "p.`name`",
"temporary_table": {
"table": {
"table_name": "s",
"access_type": "range",
"possible_keys": ["PRIMARY", "dtm"],
"key": "dtm",
"key_length": "6",
"used_key_parts": ["dtm"],
"rows": 1164728,
"filtered": 100,
"attached_condition": "s.dtm between '2018-02-16 00:00:00' and '2018-02-22 23:59:59'",
"using_index": true
},
"table": {
"table_name": "p",
"access_type": "ref",
"possible_keys": ["sales_id"],
"key": "sales_id",
"key_length": "5",
"used_key_parts": ["sales_id"],
"ref": ["test.s.id"],
"rows": 1,
"filtered": 100
}
}
}
}
}
以及創建表,雖然我覺得它沒有必要
CREATE TABLE `ycs_sales` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`dtm` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `dtm` (`dtm`)
) ENGINE=InnoDB AUTO_INCREMENT=2332802 DEFAULT CHARSET=latin1
CREATE TABLE `ycs_products` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`sales_id` int(11) DEFAULT NULL,
`name` varchar(255) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `sales_id` (`sales_id`)
) ENGINE=InnoDB AUTO_INCREMENT=2332802 DEFAULT CHARSET=latin1
還有一個PHP代碼來復制測試環境
#$pdo->query("set global innodb_flush_log_at_trx_commit = 2");
$pdo->query("create table ycs_sales (id int auto_increment primary key, dtm datetime)");
$stmt = $pdo->prepare("insert into ycs_sales values (null, ?)");
foreach (range(mktime(0,0,0,2,1,2018), mktime(0,0,0,2,28,2018)) as $stamp){
$stmt->execute([date("Y-m-d", $stamp)]);
}
$max_id = $pdo->lastInsertId();
$pdo->query("alter table ycs_sales add key(dtm)");
$pdo->query("create table ycs_products (id int auto_increment primary key, sales_id int, name varchar(255))");
$stmt = $pdo->prepare("insert into ycs_products values (null, ?, ?)");
$products = ['food', 'drink', 'vape'];
foreach (range(1, $max_id) as $id){
$stmt->execute([$id, $products[rand(0,2)]]);
}
$pdo->query("alter table ycs_products add key(sales_id)");
問題是按name
分組會使您丟失sales_id
信息,因此 MySQL 被迫使用臨時表。
雖然它不是最干凈的解決方案,以及我的最愛少一個方法,你可以添加一個新的指數,在雙方name
和sales_id
列,如:
ALTER TABLE `yourdb`.`ycs_products`
ADD INDEX `name_sales_id_idx` (`name` ASC, `sales_id` ASC);
並強制查詢使用此索引,使用force index
或use index
:
SELECT SQL_NO_CACHE p.name, COUNT(1) FROM ycs_sales s
INNER JOIN ycs_products p use index(name_sales_id_idx) ON s.id = p.sales_id
WHERE s.dtm BETWEEN '2018-02-16 00:00:00' AND '2018-02-22 23:59:59'
GROUP BY p.name;
我的執行只報告了表 p 上的“使用位置;使用索引”和表 s 上的“使用位置”。
無論如何,我強烈建議您重新考慮您的架構,因為您可能會為這兩個表找到更好的設計。 另一方面,如果這不是您的應用程序的關鍵部分,您可以處理“強制”索引。
由於很明顯問題出在設計中,我建議將關系繪制為多對多。 如果您有機會在測試環境中驗證它,我會這樣做:
1)創建一個臨時表來存儲產品的名稱和ID:
create temporary table tmp_prods
select min(id) id, name
from ycs_products
group by name;
2) 從臨時表開始,加入 sales 表來創建ycs_product
的替換:
create table ycs_products_new
select * from tmp_prods;
ALTER TABLE `poc`.`ycs_products_new`
CHANGE COLUMN `id` `id` INT(11) NOT NULL ,
ADD PRIMARY KEY (`id`);
3)創建連接表:
CREATE TABLE `prod_sale` (
`prod_id` INT(11) NOT NULL,
`sale_id` INT(11) NOT NULL,
PRIMARY KEY (`prod_id`, `sale_id`),
INDEX `sale_fk_idx` (`sale_id` ASC),
CONSTRAINT `prod_fk`
FOREIGN KEY (`prod_id`)
REFERENCES ycs_products_new (`id`)
ON DELETE NO ACTION
ON UPDATE NO ACTION,
CONSTRAINT `sale_fk`
FOREIGN KEY (`sale_id`)
REFERENCES ycs_sales (`id`)
ON DELETE NO ACTION
ON UPDATE NO ACTION);
並用現有值填充它:
insert into prod_sale (prod_id, sale_id)
select tmp_prods.id, sales_id from ycs_sales s
inner join ycs_products p
on p.sales_id=s.id
inner join tmp_prods on tmp_prods.name=p.name;
最后,連接查詢:
select name, count(name) from ycs_products_new p
inner join prod_sale ps on ps.prod_id=p.id
inner join ycs_sales s on s.id=ps.sale_id
WHERE s.dtm BETWEEN '2018-02-16 00:00:00' AND '2018-02-22 23:59:59'
group by p.id;
請注意,分組依據是主鍵,而不是名稱。
解釋輸出:
explain select name, count(name) from ycs_products_new p inner join prod_sale ps on ps.prod_id=p.id inner join ycs_sales s on s.id=ps.sale_id WHERE s.dtm BETWEEN '2018-02-16 00:00:00' AND '2018-02-22 23:59:59' group by p.id;
+------+-------------+-------+--------+---------------------+---------+---------+-----------------+------+-------------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+------+-------------+-------+--------+---------------------+---------+---------+-----------------+------+-------------+
| 1 | SIMPLE | p | index | PRIMARY | PRIMARY | 4 | NULL | 3 | |
| 1 | SIMPLE | ps | ref | PRIMARY,sale_fk_idx | PRIMARY | 4 | test.p.id | 1 | Using index |
| 1 | SIMPLE | s | eq_ref | PRIMARY,dtm | PRIMARY | 4 | test.ps.sale_id | 1 | Using where |
+------+-------------+-------+--------+---------------------+---------+---------+-----------------+------+-------------+
為什么有ycs_products
的id
? 似乎sales_id
應該是該表的PRIMARY KEY
?
如果可能的話,它會通過擺脫 senape 帶來的問題來消除性能問題。
相反,如果每個sales_id
有多個行,那么將二級索引更改為這樣會有所幫助:
INDEX(sales_id, name)
要檢查的另一件事是innodb_buffer_pool_size
。 它應該是可用RAM 的 70% 左右。 這將提高數據和索引的可緩存性。
那一周真的有 110 萬行嗎?
匯總表。
建立並維護一個每天匯總所有銷售額的表格。 它將具有name
(非規范化)和date
。 因此該表應該小於原始數據。
匯總表類似於
CREATE TABLE sales_summary (
dy DATE NOT NULL,
name varchar(255) NOT NULL,
daily_count SMALLINT UNSIGNED NOT NULL,
PRIMARY KEY(dy, name),
INDEX(name, dy) -- (You might need this for other queries)
) ENGINE=InnoDB;
每晚(午夜之后)更新將是一個類似於以下內容的查詢。 可能需要 2 秒以上,但沒有用戶在等待。
INSERT INTO sales_summary (dy, name, one_day_count)
ON DUPLICATE KEY UPDATE
daily_count = daily_count + VALUES(one_day_count)
SELECT DATE(s.dtm) AS dy,
p.name,
COUNT(*) AS one_day_count
FROM ycs_sales s
JOIN ycs_products p ON s.id = p.sales_id
WHERE s.dtm >= CURDATE() - INTERVAL 1 DAY
AND s.dtm < CURDATE()
GROUP BY 1, 2;
用戶的查詢將類似於:
SELECT SQL_NO_CACHE
name,
SUM(one_day_count)
FROM sales_summary
WHERE dy >= '2018-02-16'
AND dy < '2018-02-16' + INTERVAL 7 DAY
GROUP BY name;
匯總表的更多討論: http : //mysql.rjweb.org/doc.php/summarytables
參考您的以下評論,我認為按列s.dtm
過濾是不可避免的。
連接順序至關重要,兩個表中都有大量數據,按日期限制記錄數是毋庸置疑的先決條件。
您可以采取的最重要的行動是觀察頻繁的搜索模式。
例如,如果您對 dtm 的搜索條件通常是檢索全天的數據,即幾天的數據(比如少於 15 天)以及所有這些天的00:00:00
和23:59:59
之間,您可以使用這些信息卸載了你在搜索時間到插入時間的開銷。
這樣做的方法; 您可以在表中添加一個新列來保存截斷的日期數據,並且您可以散列索引該新列。 (在 Mysql 中沒有像 Oracle 中那樣的功能索引這樣的概念。這就是為什么我們需要添加一個新列來模仿該功能)。 類似的東西:
alter table ycs_sales add dtm_truncated date;
delimiter //
create trigger dtm_truncater_insert
before insert on ycs_sales
for each row
set new.dtm_truncated = date(new.dtm);
//
delimiter //
create trigger dtm_truncater_update
before update on ycs_sales
for each row
set new.dtm_truncated = date(new.dtm);
//
create index index_ycs_sales_dtm_truncated on ycs_sales(dtm_truncated) using hash;
# execute the trigger for existing rows, bypass the safe update mode by id > -1
update ycs_sales set dtm = date(dtm) where id > -1;
然后您可以使用帶有IN
命令的dtm_truncated
字段進行查詢。 但是當然這有其自身的權衡,更長的范圍將不起作用。 但正如我上面粗體提到的,您可以做的是將新列用作函數輸出,在插入/更新時間索引可能的搜索。
SELECT SQL_NO_CACHE p.name, COUNT(1) FROM ycs_sales s
INNER JOIN ycs_products p ON s.id = p.sales_id
WHERE s.dtm_truncated in ( '2018-02-16', '2018-02-17', '2018-02-18', '2018-02-19', '2018-02-20', '2018-02-21', '2018-02-22')
GROUP BY p.name
另外確保您在dtm
上的密鑰是 BTREE 密鑰。 (如果是哈希鍵,那么 InnoDB 需要遍歷所有鍵。)生成 BTREE 語法是:
create index index_ycs_sales_dtm on ycs_sales(dtm) using btree;
最后一點:
實際上,“分區修剪”(參考: 此處)是在插入時對數據進行分區的概念。 但是在MySql中,我不知道為什么,分區需要相關列在主鍵中。 我相信您不想將dtm
列添加到主鍵中。 但是,如果您可以這樣做,那么您還可以對數據進行分區並在選定時間擺脫日期范圍檢查開銷。
這里沒有真正提供答案,但我相信這里問題的核心是確定真正放緩的地方。 我不是 MySQL 專家,但我會嘗試運行以下查詢:
SELECT SQL_NO_CACHE name, count(*) FROM (
SELECT p.name FROM ycs_sales s INNER JOIN ycs_products p ON s.id = p.sales_id
WHERE s.dtm BETWEEN '2018-02-16 00:00:00' AND '2018-02-22 23:59:59')
GROUP BY name
SELECT SQL_NO_CACHE COUNT(*) FROM (
SELECT SQL_NO_CACHE name, count(*) FROM (
SELECT SQL_NO_CACHE p.name FROM ycs_sales s INNER JOIN ycs_products p ON s.id = p.sales_id
WHERE s.dtm BETWEEN '2018-02-16 00:00:00' AND '2018-02-22 23:59:59')
GROUP BY name
)
SELECT SQL_NO_CACHE s.* FROM ycs_sales s
WHERE s.dtm BETWEEN '2018-02-16 00:00:00' AND '2018-02-22 23:59:59'
SELECT SQL_NO_CACHE COUNT(*) FROM ycs_sales s
WHERE s.dtm BETWEEN '2018-02-16 00:00:00' AND '2018-02-22 23:59:59'
當你這樣做的時候,你能告訴我們每一個花了多長時間嗎?
我在同一個數據集上運行了 sum 測試查詢。 這是我的結果:
您的查詢在 1.4 秒內執行。 在ycs_products(sales_id, name)
上添加覆蓋索引后
ALTER TABLE `ycs_products`
DROP INDEX `sales_id`,
ADD INDEX `sales_id_name` (`sales_id`, `name`)
執行時間下降到 1.0 秒。 我仍然在 EXPLAIN 結果中看到“使用臨時;使用文件排序”。 但是現在還有“使用索引”——這意味着不需要查找聚集索引來獲取name
列的值。
注意:我刪除了舊索引,因為它對於大多數查詢來說都是多余的。 但是您可能有一些查詢需要在sales_id
之后sales_id
使用id
(PK) 的sales_id
。
您明確詢問,如何擺脫“使用臨時”。 但是,即使您找到了一種強制執行計划的方法,這將避免文件排序,您也不會贏得太多。 考慮以下查詢:
SELECT SQL_NO_CACHE COUNT(1) FROM ycs_sales s
INNER JOIN ycs_products p ON s.id = p.sales_id
WHERE s.dtm BETWEEN '2018-02-16 00:00:00' AND '2018-02-22 23:59:59'
這個需要 0.855 秒。 由於沒有 GROUP BY 子句,因此不執行文件排序。 它不會返回您想要的結果 - 但問題是:這是您可以獲得的下限,無需存儲和維護冗余數據。
如果您想知道引擎花費最多時間的地方 - 刪除 JOIN:
SELECT SQL_NO_CACHE COUNT(1) FROM ycs_sales s
WHERE s.dtm BETWEEN '2018-02-16 00:00:00' AND '2018-02-22 23:59:59'
它在 0.155 秒內執行。 所以我們可以得出結論:JOIN 是查詢中開銷最大的部分。 你無法避免它。
執行時間的完整列表:
再說一遍:“使用臨時;使用文件排序”在 EXPLAIN 結果中看起來很糟糕 - 但這不是您最大的問題。
Windows 10 + MariaDB 10.3.13 innodb_buffer_pool_size = 1G
已使用以下腳本生成測試數據(在 HDD 上需要 1 到 2 分鍾):
drop table if exists ids;
create table ids(id mediumint unsigned auto_increment primary key);
insert into ids(id)
select null as id
from information_schema.COLUMNS c1
, information_schema.COLUMNS c2
, information_schema.COLUMNS c3
limit 2332801 -- 60*60*24*27 + 1;
drop table if exists ycs_sales;
CREATE TABLE `ycs_sales` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`dtm` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `dtm` (`dtm`)
) ENGINE=InnoDB DEFAULT CHARSET=latin1;
insert into ycs_sales(id, dtm) select id, date('2018-02-01' + interval (id-1) second) from ids;
drop table if exists ycs_products;
CREATE TABLE `ycs_products` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`sales_id` int(11) DEFAULT NULL,
`name` varchar(255) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `sales_id` (`sales_id`)
) ENGINE=InnoDB DEFAULT CHARSET=latin1;
insert into ycs_products(id, sales_id, name)
select id
, id as sales_id
, case floor(rand(1)*3)
when 0 then 'food'
when 1 then 'drink'
when 2 then 'vape'
end as name
from ids;
我遇到過幾次類似的問題。 通常,我希望獲得最好的結果
CREATE INDEX s_date ON ycs_sales(dtm, id)
-- Add a covering index
CREATE INDEX p_name ON ycs_products(sales_id, name);
這應該擺脫“表非常大”的問題,因為現在需要的所有信息都包含在兩個索引中。 實際上我似乎記得如果后者是主鍵,則第一個索引不需要id
。
如果這還不夠,因為兩個表太大,那么你別無選擇——你必須避免 JOIN 。 它已經在盡可能快地進行,如果這還不夠,那么它必須去。
我相信你可以用幾個TRIGGER
來維護一個輔助的每日銷售報告表(如果你從來沒有退回過產品,那么在銷售中插入 INSERT 的一個觸發器就足夠了) - 嘗試只使用(product_id, sales_date, sales_count)
並將其與產品表 JOIN 以在輸出時獲取名稱; 但是,如果這還不夠,那么使用(product_id, product_name, sales_date, sales_count)
並定期更新product_name
以通過從主表中讀取名稱來保持名稱同步。 由於sales_date
現在是唯一的並且您對其運行搜索,因此您可以將sales_date
聲明為主鍵並根據銷售年份對輔助表進行分區。
(一兩次,當分區是不可能的但我相信我只會很少跨越“理想”分區邊界時,我手動分區 - 即 sales_2012、sales_2013、sales_2014 - 並以編程方式構建了兩三年的 UNION參與,然后是重組、恢復和二次匯總階段。像三月野兔一樣瘋狂,是的,但它奏效了)。
聲明:本站的技術帖子網頁,遵循CC BY-SA 4.0協議,如果您需要轉載,請注明本站網址或者原文地址。任何問題請咨詢:yoyou2525@163.com.