优化按联接表中的字段对结果进行分组的查询

时间:2018-02-26 09:26:31

标签: mysql sql join group-by query-optimization

我有一个非常简单的查询,必须按联接表中的字段对结果进行分组:

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)");

6 个答案:

答案 0 :(得分:2)

摘要表。

建立并维护一个表,该表每天汇总所有销售额。它将具有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; 。因此,该表应小于原始数据。

摘要表将类似于

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;

每晚(午夜之后)更新将是单个查询,如下所示。可能要花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;

用户的查询将类似于:

SELECT IF(IF(EXISTS (SELECT 1 FROM `df` t3 WHERE 1 = 1), @myVar :='one',0) = 'one',1,0);
SELECT @myVar;       

有关汇总表的更多讨论:http://mysql.rjweb.org/doc.php/summarytables

答案 1 :(得分:2)

请参阅您的以下评论,我认为按列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; 

然后,您可以使用dtm_truncated命令使用IN字段进行查询。但是,这当然有其自身的权衡,更长的范围将不起作用。 但是正如我上面粗体提到的那样,您可以做的是将新列用作函数输出,该函数为插入/更新时间内的可能搜索建立索引。

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;

最后一点:

实际上,“分区修剪”(参考:here)是在插入时对数据进行分区的概念。但是在MySql中,我不知道为什么,分区要求相关列位于主键中。我相信您不想在主键中添加dtm列。但是,如果可以的话,还可以对数据进行分区,并消除选择时间的日期范围检查开销。

答案 2 :(得分:1)

这里并没有真正提供答案,但是我认为这里的问题的核心是确定真正放缓的地方。 我不是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'

这样做的时候,您能告诉我们每个人花了多长时间?

答案 3 :(得分:1)

我已经在同一数据集上运行了总和测试查询。这是我的结果:

您的查询将在1.4秒内执行。 用{p>在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列的值。

注意:我删除了旧索引,因为它对于大多数查询来说都是多余的。 但是您可能有一些查询需要在id之后出现sales_id(PK)的索引。

您明确询问,如何摆脱“使用临时”。 但是,即使您找到了一种强制执行计划的方法(可以避免文件排序),您也不会赢得太多。 考虑以下查询:

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%)读取和计数60.4万行
  • JOIN(您无法避免)的时间为0.690秒(49%)
  • 第二次查询(可通过索引删除)为0.385秒(28%)
  • 使用文件排序的GROUP BY的时间为0.170秒(12%)(您尝试避免)

同样,在EXPLAIN结果中,“使用临时;使用文件排序”看起来很糟糕-但这不是您最大的问题。

测试环境:

带有innodb_buffer_pool_size = 1G的Windows 10 + MariaDB 10.3.13

已使用以下脚本生成了测试数据(在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;

答案 4 :(得分:1)

我有几次类似的问题。 通常,我希望通过

可以获得最佳结果
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)并将其与product表联接以在输出时获取名称;但是,如果这还不够的话,请使用(product_id, product_name, sales_date, sales_count)并定期更新product_name以通过从主表中读取名称来保持名称同步。由于sales_date现在是唯一的,并且您可以对其进行搜索,因此可以声明sales_date为主键,并根据销售年份对辅助表进行分区。

(一次或两次,当无法进行分区时,但我确信很少会越过“理想的”分区边界,我手动进行分区-即sales_2012,sales_2013,sales_2014-并以编程方式建立了两者的联合或三年的时间,然后是重新分组,度假和二次总计阶段。疯狂的三月野兔,是的,但是有效)。

答案 5 :(得分:0)

为什么idycs_products?似乎sales_id应该是该表的PRIMARY KEY

如果可行的话,它可以通过摆脱senape带来的问题来消除性能问题。

相反,如果每个sales_id有多行,那么将二级索引更改为此将有所帮助:

INDEX(sales_id, name)

要检查的另一件事是innodb_buffer_pool_size。它应该是可用 RAM的70%左右。这将提高数据和索引的可缓存性。

一周内真的有110万行吗?