上周五晚上十一点,我盯着屏幕上的MySQL 8.0.33报错,差点把键盘摔了。事情是这样的——我在做一个用户画像系统,要频繁地从2000万条订单数据里按时间窗口做聚合查询。原本用的单机MySQL,表结构大概是这样的:
“`sql
CREATE TABLE orders (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
amount DECIMAL(10,2),
created_at DATETIME(3) NOT NULL,
INDEX idx_user_id (user_id),
INDEX idx_created_at (created_at)
) ENGINE=InnoDB;
“`
看起来挺正常对吧?索引也加了。但跑一个按用户ID和时间范围的分页查询,大概要2.3秒。业务要求是200毫秒以内。我一开始以为是MySQL的问题,心里想“换PostgreSQL吧”,但转念一想,换数据库不是换件衣服,代码、ORM映射、甚至一些存储过程都要改,这事儿得先排查清楚。
我打开了慢查询日志,发现了一个经典问题——filesort。具体来说,搜索条件是`WHERE user_id = ? AND created_at BETWEEN ? AND ?`,然后还要`ORDER BY created_at DESC LIMIT 10`。即使有idx_user_id和idx_created_at两个独立索引,MySQL在排序时依然会走filesort,因为两个索引没法协同工作。更深层的原因:MySQL的索引是B+树,单独索引只能搞定一个维度的排序。我试了加联合索引:`ALTER TABLE orders ADD INDEX idx_user_created (user_id, created_at DESC);`
建完这个索引之后,同样的查询直接从2.3秒降到了45毫秒。这个坑其实很经典,但很多人包括我在内,一开始会觉得“加索引就行了”,没仔细想索引的列顺序和排序方向。如果按`user_id`、`created_at`顺序建索引,InnoDB默认升序,但我的查询是降序,MySQL 8.0虽然支持降序索引,但如果你没显式声明DESC,它还是会做一次反向扫描,性能损失不大但也不够理想。
解决了这个,我又遇到了另一个问题。用户画像系统要算每个用户过去30天的累计消费金额,逻辑是`SUM(amount) GROUP BY user_id HAVING SUM(amount) > 5000`。这个查询直接全表扫描,跑了7秒多。我查了执行计划,发现MySQL压根没用到任何索引——因为`SUM`是聚合函数,没法用索引直接加速,除非建一个物化视图。MySQL本身不支持物化视图,但可以用触发器+汇总表来模拟。我建了一张`user_monthly_summary`表,结构这样:
“`sql
CREATE TABLE user_monthly_summary (
user_id INT PRIMARY KEY,
total_amount DECIMAL(15,2),
updated_at DATETIME
);
“`
然后在订单表的插入和更新操作上加触发器,每次插入或修改订单,就更新汇总表里的累计金额。这里要注意一个细节——触发器里不能用`SELECT … INTO`直接更新,因为同一张表的触发器会引起嵌套调用。我一开始写了个`AFTER INSERT`触发器,里面又查`orders`表,结果MySQL直接报错`ERROR 1442: Can’t update table ‘orders’ in stored function/trigger because it is already used by the statement`。解决方案是把汇总表的更新拆出来,用`NEW`关键字引用当前行的字段,不要再去查原表。
搞定触发器后,聚合查询从7秒变成了8毫秒,但代价是写入性能下降了大概15%。对于我这个业务场景可以接受,因为订单写入量每天大概5万条,不算高。
还有一个坑我栽得很惨——事务隔离级别。我的系统里有个功能:用户下单后立即能看到订单状态变化,但另一个服务会异步更新订单状态(比如从“待支付”变成“已支付”)。默认的READ COMMITTED级别下,用户看到的订单状态有时是旧的,因为另一个事务还没提交。我改成了REPEATABLE READ,结果又遇到了间隙锁引发的死锁。具体场景:服务A查询某个用户最近10条订单,用到了`SELECT … FOR UPDATE`;服务B同时更新该用户的某条订单状态。在REPEATABLE READ下,`SELECT … FOR UPDATE`会给索引范围加间隙锁,导致B的更新操作被阻塞,反过来B也可能持有其他锁,形成死锁。解决方法很蠢但有效——把`SELECT … FOR UPDATE`改成`SELECT … FOR UPDATE SKIP LOCKED`,或者干脆把隔离级别降回READ COMMITTED,然后加乐观锁(版本号字段)来处理并发更新。我选了后者,因为READ COMMITTED下间隙锁少很多,死锁概率直线下降。
最后说个关于连接池的教训。我用的HikariCP,默认maxPoolSize=10,线上有一次业务高峰,数据库连接被占满,新请求全部排队,接口响应时间飙升到30秒。我一开始以为是SQL慢,查了半天才发现是连接池太小。把maxPoolSize调到了50,但马上发现MySQL的max_connections默认151,连接数设置太大反而把数据库压崩了。最终调成了30,同时加了个连接池监控,用`/actuator/health`暴露HikariCP的指标,如果活跃连接数超过25就告警。
总结一下折腾数据库的几条经验吧。第一,索引不是越多越好,联合索引的顺序和排序方向要贴合查询模式。第二,聚合查询如果数据量大,别指望索引能搞定,物化视图或者汇总表才是正道。第三,隔离级别和锁机制一定要结合业务场景去选,别死记硬背“REPEATABLE READ就是安全的”。第四,连接池大小不是越大越好,数据库能扛的连接数有限,调优之前先搞清楚MySQL的max_connections配置。第五——也是最痛的——每次改数据库配置前,先在测试环境压一遍,别像我一样直接在生产库上改隔离级别,差点搞出线上事故。