簡體   English   中英

優化按連接表中的字段對結果進行分組的查詢

[英]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 被迫使用臨時表。

雖然它不是最干凈的解決方案,以及我的最愛少一個方法,你可以添加一個新的指數,在雙方namesales_id列,如:

ALTER TABLE `yourdb`.`ycs_products` 
ADD INDEX `name_sales_id_idx` (`name` ASC, `sales_id` ASC);

強制查詢使用此索引,使用force indexuse 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_productsid 似乎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:0023: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 是查詢中開銷最大的部分。 你無法避免它。

執行時間的完整列表:

  • 0.155 秒 (11%) 讀取和計數 604K 行
  • JOIN 需要 0.690 秒 (49%)(您無法避免)
  • 0.385 秒 (28%) 用於第二次查找(可以通過索引刪除)
  • 使用文件排序的 GROUP BY 為 0.170 秒(12%)(您試圖避免)

再說一遍:“使用臨時;使用文件排序”在 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.

 
粵ICP備18138465號  © 2020-2024 STACKOOM.COM