今日关键词慢查询优化、物化视图、反规范化、覆盖索引、Redis缓存、JOIN优化、读性能、数据库优化大家好我是数据库小学妹 上周在优化一个慢查询。一条SQL跑了500多毫秒。执行计划一拉四个表JOIN加GROUP BY聚合。当时第一反应是加索引加完确实快了一点但也就快了那么一点。心里就犯嘀咕索引都加上了怎么还是慢后来仔细看执行计划才发现瓶颈不在索引。在JOIN本身。四张表一关联数据量一上来磁盘随机I/O直接把延迟拉爆了。当时我就想有没有办法把JOIN从读路径里拿掉研究了一段时间找到了四种模式。每种都在测试环境跑过基准今天把思路和踩过的坑整理出来分享给同样在和慢查询较劲的朋友。为什么JOIN会成为瓶颈JOIN在小数据量下没问题很快。但数据涨到百万、千万级别每次JOIN就是跨文件的随机I/O磁盘寻址开销大重复的堆查找同一批数据被反复扫描多次网络跳转延迟一层层叠加规范化是写入时的好设计。但读取路径上JOIN的成本实实在在。要想降低读延迟一个办法就是把JOIN从高频查询中拿掉。用更轻量的方式拿到同样的数据。四种模式总览模式适用场景优化前优化后提升物化视图聚合报表、排行榜520ms45ms11.6倍反规范化JSONB读取产品分类等关联信息420ms28ms15.0倍覆盖索引高基数过滤少量返回列120ms10ms12.0倍应用缓存(Redis)每次请求都需要的小参考数据330ms18ms18.3倍模式一物化视图——把聚合结果提前算好什么场景适合一个查询要关联四张表再做SUM聚合。算过去30天的用户消费总额。每次请求都走一遍这个流程p99飙到2秒以上。这种查询加索引没用。瓶颈在聚合和JOIN不在查找。把聚合结果提前算好存到一张物化视图里。读取时直接查这张小表。原始数据碰都不碰。-- 创建物化聚合CREATEMATERIALIZEDVIEWmv_user_monthlyASSELECTu.idASuser_id,sum(o.amount)AStotalFROMusers uJOINorders oONo.user_idu.idWHEREo.created_atcurrent_date-interval30 daysGROUPBYu.id;-- 按需刷新REFRESH MATERIALIZEDVIEWmv_user_monthly;读取就变成了SELECTuser_id,totalFROMmv_user_monthlyWHEREtotal100;结果p50从520ms降到45ms提升了11.6倍。p99从2200ms降到90ms提升24.4倍。有个坑差点没注意到物化视图不是实时的。刷新周期内你查到的是上一次的结果。业务能容忍几秒到几分钟的延迟完全没问题。但刷新方式要选对。我第一次用REFRESH MATERIALIZED VIEW没带CONCURRENTLY。刷新期间视图被锁读取直接卡住。线上告警响了。生产环境一定要用REFRESH MATERIALIZED VIEW CONCURRENTLY。或者在业务低峰期刷新。适合场景仪表盘、排行榜、账单摘要。不适合写入后必须立即能读到的场景。模式二反规范化宽行——把引用数据塞进表里什么场景适合每次读取产品信息都要JOIN category和supplier两张小表。数据量不大变更也不频繁。但每次读取都要关联一次白白重复。解决办法把分类信息直接塞进产品表里用JSONB字段存。查询时一张表搞定不用再JOIN。ALTERTABLEproductsADDCOLUMNcat jsonb;UPDATEproducts pSETcatjsonb_build_object(id,c.id,name,c.name)FROMcategories cWHEREp.category_idc.id;CREATEINDEXidx_products_cat_nameONproducts((cat-name));读取SELECTid,cat-nameAScategoryFROMproductsWHEREid$1;结果p50从420ms降到28ms提升15倍。写入端的代价是分类信息变更时要同步更新反规范化的字段。或者接受最终一致性。这个模式适合变更频率低的小参考表。分类、地区、供应商这种。高基数属性别往里塞。比如用户评论JSONB字段会撑爆。业务上要求严格一致性的场景也不适合用这个。模式三覆盖索引——索引直接返回结果什么场景适合一个查询按status和created_at过滤返回id和total。加了索引但还是慢大概率是回表问题。索引定位到行之后还要回到堆表里取数据。产生随机I/O。把返回的列也塞进索引数据库就不用回表了直接从索引里拿数据。CREATEINDEXidx_orders_coverONorders(status,created_at)INCLUDE(id,total);查询SELECTid,totalFROMordersWHEREstatuspaidANDcreated_at2025-01-01LIMIT50;结果p50从120ms降到10ms提升12倍。p99从680ms降到45ms提升15倍。这个模式风险低投入产出比不错。但有个前提返回的列不能太多。我见过有人把十几个字段全塞进索引。索引本身比表还大写入性能也跟着遭殃。模式四应用端缓存——热点数据放Redis什么场景适合每个请求都要查一次用户资料或组织设置。数据量小查询频率高。但每次都要走数据库连接。说白了就是为一个字段跑一趟数据库用缓存最合适。热点参考数据放Redis。读取时先查缓存miss了再查数据库查到后回写缓存。defget_user(uid):keyfuser:{uid}datar.hgetall(key)ifnotdata:datadb.one(SELECT id,name,region FROM users WHERE id%s,uid)r.hset(key,mappingdata)returndata# 热路径直接用缓存ordersdb.all(SELECT id,total FROM orders WHERE user_id%s,uid)结果p50从330ms降到18ms提升18倍。p99从1250ms降到120ms。核心难点在缓存失效缓存的难点不是怎么读。而是怎么保证数据不过期太久。常见的策略短TTL设30秒到5分钟的过期时间接受短暂不一致写穿透数据变更时主动更新或删除缓存key发布/订阅数据库变更事件广播给缓存服务。做精确失效。怎么选其实就看你的查询特征。聚合计算多、报表类的用物化视图。读取时要好几个小参考字段的用反规范化JSONB。查询条件固定、返回列少的高频读覆盖索引最合适。每次请求都捞一个小数据比如用户资料直接上Redis。实际项目里这几种模式经常混着用。高频API用Redis缓存用户信息订单查询走覆盖索引报表走物化视图。没有哪种方案能包打天下。避坑清单上来就加索引或者上缓存之前先跑EXPLAIN ANALYZE看执行计划。我之前就犯过这个错。索引加了一堆结果瓶颈根本不在那里白忙活。反规范化的字段要有明确的同步机制。我在一个项目里见过分类表改了名字。产品表里JSONB字段没人更新前端显示的分类名和实际对不上。排查了半天才发现问题出在反规范化没同步。写个触发器或者在业务层处理别靠人记。物化视图的刷新策略上线前一定要想清楚。别问我怎么知道的线上告警教过我一次了。你在项目里有没有遇到过慢查询优化的场景用了哪种方案效果怎么样欢迎评论区聊聊。说不定你的经验能帮到其他朋友。我是数据库小学妹咱们下篇见