从190s到1s:MySQL千万级数据查询优化

指尖上的架构 2024-06-15 08:00:31
首先要声明的就是,千万级数据对于MySQL来说就是不太合理的一个存在。 优化MySQL千万级数据策略还是比较多的: 分表分库创建中间表,汇总表修改为多个子查询 这里讨论的情况是在MySQL一张表的数据达到千万级别。表设计很烂,业务统计规则又不允许把sql拆成多个子查询。 在这样的情况下,开发者可以尝试通过优化SQL来达到查询的目的。 当MySQL一张表的数据达到千万级别,会出现一些特殊的情况。这里主要是讨论在比较极端的情况下SQL的优化策略。 先来个千万级数据 通过存储过程传递函数制造1000万条数据。 表结构如下: CREATE TABLE `orders` ( `order_id` int NOT NULL AUTO_INCREMENT, `user_id` int DEFAULT NULL, `order_date` date NOT NULL, `total_amount` decimal(10,2) NOT NULL, PRIMARY KEY (`order_id`), KEY `idx_user_id` (`user_id`) USING BTREE, KEY `idx_user_amount` (`user_id`,`total_amount`) USING BTREE) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;CREATE TABLE `users` ( `user_id` int NOT NULL AUTO_INCREMENT, `username` varchar(50) COLLATE utf8mb4_general_ci NOT NULL, `email` varchar(100) COLLATE utf8mb4_general_ci NOT NULL, `created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`user_id`), KEY `idx_user_id` (`user_id`) USING BTREE) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; 造数据的存储过程如下。 用户数据: -- 产生用户存储过程,1000个CREATE DEFINER=`root`@`localhost` PROCEDURE `create_users`()BEGIN DECLARE i INT DEFAULT 0; DECLARE total_users INT DEFAULT 1000; -- 调整用户数量 DECLARE rnd_username VARCHAR(50); DECLARE rnd_email VARCHAR(100); WHILE i < total_users DO -- 生成随机用户名和邮箱 SET rnd_username = CONCAT('User', FLOOR(1 + RAND() * 10000000)); -- 假设用户名唯一 SET rnd_email = CONCAT(rnd_username, '@example.com'); -- 假设邮箱唯一 -- 将数据插入用户表 INSERT INTO users (username, email) VALUES (rnd_username, rnd_email); SET i = i + 1; END WHILE;END 订单数据生成存储过程如下: CREATE DEFINER=`root`@`localhost` PROCEDURE `generate_orders`()BEGIN DECLARE i INT DEFAULT 0; DECLARE total_users INT DEFAULT 1000; -- 用户数量 DECLARE total_orders_per_user INT DEFAULT 1000; -- 每个用户的订单数量 DECLARE rnd_user_id INT; DECLARE rnd_order_date DATE; DECLARE rnd_total_amount DECIMAL(10, 2); DECLARE j INT DEFAULT 0; WHILE i < total_users DO -- 获取用户ID SELECT user_id INTO rnd_user_id FROM users LIMIT i, 1; WHILE j < total_orders_per_user DO -- 生成订单日期和总金额 SET rnd_order_date = DATE_ADD('2020-01-01', INTERVAL FLOOR(RAND() * 1096) DAY); -- 2020-01-01和2022-12-31之间的随机日期 SET rnd_total_amount = ROUND(RAND() * 1000, 2); -- 0到1000之间的随机总金额 -- 将数据插入订单表 INSERT INTO orders (user_id, order_date, total_amount) VALUES (rnd_user_id, rnd_order_date, rnd_total_amount); SET j = j + 1; END WHILE; SET j = 0; SET i = i + 1; END WHILE;END 将users和orders的数据生成分开,这样可以通过多次调用orders存储过程多线程参数数据。 调用一次call create_users(),然后开15个窗口调用orders存储过程call generate_orders()。 整个过程会产生1000个用户,15*1000*1000也就是1500万条订单数据。 原始SQL 这是一个很简单的sql,统计每个用户的订单总额。 在默认情况下,什么索引都没有创建,需要花费190+s的时间。 -- 第一个版本SELECT a.*,sum(b.total_amount) as total from users a left join orders b on a.user_id = b.user_idgroup by a.user_id; explain分析如下: idselect_typetablepartitionstypepossible_keyskeykey_lenrefrowsfilteredExtra1SIMPLEaALLPRIMARY1000100.0Using temporary1SIMPLEbALL13016086100.0Using where; Using join buffer (hash join) 可以看到什么索引也没使用,type为all,直接全表扫描。 用时191s。 第一次优化:普通索引 把查询条件用到的sql条件都创建索引。也就是where和join、sum涉及到的知道。 CREATE INDEX idx_orders_user_id ON orders (user_id);CREATE INDEX idx_orders_total_amount ON orders (total_amount);CREATE INDEX idx_users_user_id ON users (user_id); 查询sql仍然是第一个版本。 -- 第一个版本SELECT a.*,sum(b.total_amount) as total from users a left join orders b on a.user_id = b.user_idgroup by a.user_id; 先看看expalin的结果: idselect_typetablepartitionstypepossible_keyskeykey_lenrefrowsfilteredExtra1SIMPLEaindexPRIMARY,idx_users_user_idPRIMARY41100.01SIMPLEbrefidx_orders_user_ididx_orders_user_id5test2.a.user_id13003100.0 type为index或者ref,全部走的索引。 查询结果却让人失望,这次用的时间更多,用了460+s。也就是说查询变慢了。 推测是由于mysql的回表机制导致查询变得更慢了。所以接下来继续优化索引。 第二次优化:覆盖索引 覆盖索引是指一个索引包含了查询所需的所有列,从而可以满足查询的要求,而不需要访问实际的数据行。 通常情况下,数据库查询需要根据索引定位到对应的数据行,然后再从数据行中获取所需的列值。 而当索引中包含了查询所需的所有列时,数据库引擎可以直接通过索引就能够满足查询的要求,无需访问实际的数据行,这样就可以提高查询性能。 这也是普通索引添加了还是查询慢的原因,因为普通索引命中了还是会去找主键,通过主键找到关联字段的值做过滤。 -- 先不删除普通索引-- drop INDEX idx_orders_user_id ON orders;-- drop INDEX idx_orders_total_amount ON orders;CREATE INDEX idx_orders_total_amount_user_id ON orders (total_amount,user_id);CREATE INDEX idx_orders_user_id_total_amount ON orders (user_id,total_amount); 1500万数据创建索引就花费了300+s。所以创建索引得适度。 查询sql还是第一个版本。 -- 第一个版本SELECT a.*,sum(b.total_amount) as total from users a left join orders b on a.user_id = b.user_idgroup by a.user_id; 先看看expalin的结果: idselect_typetablepartitionstypepossible_keyskeykey_lenrefrowsfilteredExtra1SIMPLEaindexPRIMARY,idx_users_user_idPRIMARY41100.01SIMPLEbrefidx_orders_user_id,idx_orders_user_id_total_amountidx_orders_user_id_total_amount5test2.a.user_id874100.0Using index 可以看到orders表的type从index提升到了ref。 此时的查询时间为从460s+降低到10s了。 结果证明覆盖索引能提升查询速度。 问题就在于这次建的两个覆盖索引,只有 idx_orders_user_id_total_amount 降低了查询时间,而 idx_orders_total_amount_user_id没有。 这个和mysql的关键词执行顺序有一定关系(推测,没找到资料)。 mysql执行顺序如下: fromonjoinwheregroup byhavingselectdistinctunion (all)order bylimit 可以看到在覆盖索引使用过程先是where,再是到select的sum函数。这也是 idx_orders_user_id_total_amount 索引的创建顺序。 drop INDEX idx_orders_user_id ON orders;drop INDEX idx_orders_total_amount ON orders;drop INDEX idx_orders_total_amount_user_id ON orders; drop掉相关的多余索引可以发现执行查询时间没有变化,仍然为10s。 索引优化这块差不多就是通过覆盖索引来命中索引。 第三次优化:减少数据量 减少数据量在业务上来说就是移除不必要的数据,或者可以在架构设计这块做一些工作。 分表就是这个原则。 通过这个方式能把千万的数据量减少到百万甚至几十万的量。提升的查询速度是可以想象的。 -- 第三次优化:减少数据量SELECT a.*,sum(b.total_amount) as total from users a left join orders b on a.user_id = b.user_idwhere a.user_id > 1033group by a.user_id; expain结果如下: idselect_typetablepartitionstypepossible_keyskeykey_lenrefrowsfilteredExtra1SIMPLEarangePRIMARY,idx_users_user_idPRIMARY4685100.0Using where1SIMPLEbrefidx_orders_user_id_total_amountidx_orders_user_id_total_amount5test2.a.user_id874100.0Using index 可以看到users表的type为range。能过滤一部分数据量。 查询时间从10s降低到7s,减少数据量证明有效。 第四次优化:小表驱动大表 在 MySQL 中,通常情况下,优化器会根据查询条件和表的大小选择合适的驱动表(即主导表)。 小表驱动大表是一种优化策略,它指的是在连接查询中,优先选择小表作为驱动表,以减少连接操作所需的内存和处理时间。 在第三次优化的结果上,可以尝试使用小表驱动大表优化策略。 -- 第三个版本,小标驱动大表 没啥效果SELECT a.*,sum(b.total_amount) as total from users aleft join (select user_id,total_amount from orders c where c.user_id > 1033 ) b on a.user_id = b.user_idwhere a.user_id > 1033group by a.user_id; 将left join的表修改为子查询,能提前过滤一部分数据量。 expain结果如下: idselect_typetablepartitionstypepossible_keyskeykey_lenrefrowsfilteredExtra1SIMPLEarangePRIMARY,idx_users_user_idPRIMARY4685100.0Using where1SIMPLEcrefidx_orders_user_id_total_amountidx_orders_user_id_total_amount5test2.a.user_id874100.0Using where; Using index 可以看到explain没什么变化。实际执行效果也没啥变化。 小表驱动大表在这里无效,但是可以结合具体的业务进行优化sql。这个策略是没问题的。 第五次优化:强制索引 当 MySQL 中的 IN 子句用于查询千万级数据时,如果未正确设计和使用索引,可能导致索引失效,从而影响查询性能。 通常情况下,MySQL 的优化器会根据查询条件选择最优的执行计划,包括选择合适的索引。然而,对于大数据量的 IN 子句查询,MySQL 可能无法有效使用索引,从而导致全表扫描或索引失效。 查询sql如下,由于in的数据量不是很稀疏,实际查询强制索引和普通索引效果一致 -- 第五个版本,强制索引 SELECT a.*,sum(b.total_amount) as total from users a left join orders b force index (idx_orders_user_id_total_amount) on a.user_id = b.user_idwhere b.user_id in (1033,1034,1035,1036,1037,1038)group by a.user_id;-- 第五个版本,不走强制索引 SELECT a.*,sum(b.total_amount) as total from users a left join orders b on a.user_id = b.user_idwhere b.user_id in (1033,1034,1035,1036,1037,1038)group by a.user_id; 查询时间都是零点几秒。 笔者在实际业务中是遇到过这种场景的,业务sql更加复杂。这里由于临时创建的订单用户表没复现。 当你发现explain都是命中索引的,但是查询依然很慢。这个强制索引可以试试。 优化策略 提前命中索引,小表驱动大表千万级数据in索引失效,进行强制索引使用覆盖索引解决回表问题 下次该怎么优化SQL 数据接近千万级,需要分表,比如按照用户id取模分表。用汇总表代替子查询来命中索引,比如把小时表生成日表、月表汇总数据。关联字段冗余、直接放到一张表就是单表查询了。命中索引,空间换时间,这也是本文分析的场景。 关于命中索引核心点就是覆盖索引,再者是千万数据产生的特有场景需要走强制索引。 tips explain结果type的含义 在 MySQL 的 EXPLAIN 查询结果中,type 字段表示了查询使用的访问类型,即查询执行过程中的访问方法。 根据不同的访问类型,MySQL 查询优化器将选择不同的执行计划。以下是 type 字段可能的取值及其含义: system:这是最好的情况,表示查询只返回一行结果。这通常是通过直接访问表的 PRIMARY KEY 或唯一索引来完成的。const:表示 MySQL 在查询中找到了常量值,这是在连接的第一个表中进行的。由于这是常量条件,MySQL 只会读取一次表中的一行数据。例如,通过主键访问一行数据。eq_ref:类似于 const,但在使用了索引的情况下。此类型的查询是通过某个唯一索引来访问表的,对于每个索引键值,表中只有一行匹配。常见于使用主键或唯一索引进行连接操作。ref:表示此查询使用了非唯一索引来查找值。返回的是所有匹配某个单独值的行。该类型一般出现在联接操作中,使用了非唯一索引或者索引前缀。range:表示查询使用了索引来进行范围检索,通常出现在带有范围条件的查询语句中,例如 BETWEEN、IN()、>、
0 阅读:2

指尖上的架构

简介:感谢大家的关注