一条 SQL 加完索引还是慢,多半是瓶颈不在索引。执行计划一拉,四个表 JOIN 加 GROUP BY,数据量一上来磁盘随机 I/O 把延迟拉爆。这时再加索引没用,瓶颈在 JOIN 本身。要想降低读延迟,办法是把 JOIN 从高频查询里拿掉,用更轻量的方式拿到同样数据。这里有四种模式,按查询特征对号入座。
先理解 JOIN 为什么是瓶颈
JOIN 在小数据量下很快,但数据涨到百万千万级,每次 JOIN 就是跨文件的随机 I/O、重复的堆查找、多次网络跳转。规范化是写入时的好设计,读取路径上 JOIN 的成本实实在在。四种模式的共同思路:要么把结果提前算好,要么把引用数据塞进主表,要么让索引直接返回结果,要么把热点数据挪出数据库。
四种模式怎么选
先看一张基准对比(测试环境实测,量级供参考):
| 模式 | 适用场景 | 优化前 p50 | 优化后 p50 | 提升 |
|---|---|---|---|---|
| 物化视图 | 聚合报表、排行榜 | 520ms | 45ms | 11.6 倍 |
| 反规范化 JSONB | 读取主表+小参考表 | 420ms | 28ms | 15 倍 |
| 覆盖索引 | 高基数过滤+少量返回列 | 120ms | 10ms | 12 倍 |
| Redis 缓存 | 每次请求都要的小参考数据 | 330ms | 18ms | 18.3 倍 |
物化视图:聚合结果提前算好
一个查询关联四张表再 SUM 聚合(算过去 30 天用户消费总额),每次请求都走一遍,p99 飙到 2 秒以上。加索引没用——瓶颈在聚合和 JOIN。把结果提前算好存进物化视图,读取直接查这张小表:
CREATE MATERIALIZED VIEW mv_user_monthly AS
SELECT u.id AS user_id, sum(o.amount) AS total
FROM users u JOIN orders o ON o.user_id = u.id
WHERE o.created_at >= current_date - interval '30 days'
GROUP BY u.id;
REFRESH MATERIALIZED VIEW mv_user_monthly;
读取变成 SELECT user_id, total FROM mv_user_monthly WHERE total > 100;。p50 从 520ms 降到 45ms,p99 从 2200ms 降到 90ms。
坑在刷新方式。第一次没带 CONCURRENTLY,刷新期间视图被锁、读取直接卡住,线上告警响了。生产环境必须用 REFRESH MATERIALIZED VIEW CONCURRENTLY,或在业务低峰刷新。物化视图不是实时的,刷新周期内查到的是上次结果,业务能容忍几秒到几分钟延迟就没问题。适合仪表盘、排行榜、账单摘要;不适合写入后必须立即可读的场景。
反规范化 JSONB:把引用数据塞进主表
每次读产品信息都要 JOIN category 和 supplier 两张小表,数据量不大变更不频繁,但每次都要关联一次。把分类信息直接塞进产品表,用 JSONB 存:
ALTER TABLE products ADD COLUMN cat jsonb;
UPDATE products p
SET cat = jsonb_build_object('id', c.id, 'name', c.name)
FROM categories c WHERE p.category_id = c.id;
CREATE INDEX idx_products_cat_name ON products ((cat->>'name'));
读取 SELECT id, cat->>'name' FROM products WHERE id = $1; 一张表搞定。p50 从 420ms 降到 28ms。代价是分类变更时要同步更新反规范化字段(写触发器或业务层处理),或接受最终一致性。适合变更频率低的小参考表(分类、地区、供应商);高基数属性别往里塞(用户评论会把 JSONB 撑爆),严格一致性场景也不适合。
覆盖索引:索引直接返回结果
按 status 和 created_at 过滤、返回 id 和 total 的查询,加了索引还慢,大概率是回表问题——索引定位到行后还要回堆表取数据产生随机 I/O。把返回列也塞进索引,数据库不用回表:
CREATE INDEX idx_orders_cover
ON orders (status, created_at) INCLUDE (id, total);
p50 从 120ms 降到 10ms,p99 从 680ms 降到 45ms。风险低、性价比高。前提是返回列不能太多——见过把十几个字段全塞进索引的,索引比表还大,写入性能也跟着遭殃。
Redis 缓存:热点数据挪出数据库
每个请求都查一次用户资料或组织设置,数据量小查询频率高,每次都要走数据库连接,属于「为一个字段跑一趟数据库」。热点参考数据放 Redis,miss 再查库并回写:
def get_user(uid):
key = f"user:{uid}"
data = r.hgetall(key)
if not data:
data = db.one("SELECT id,name,region FROM users WHERE id=%s", uid)
r.hset(key, mapping=data)
return data
p50 从 330ms 降到 18ms,p99 从 1250ms 降到 120ms。难点不在读,在保证数据不过期太久:短 TTL(30 秒到 5 分钟,接受短暂不一致)、写穿透(变更时主动更新或删 key)、发布订阅(数据库变更事件广播做精确失效)。
怎么搭配
看查询特征:聚合计算多、报表类用物化视图;读取时要好几个小参考字段用反规范化 JSONB;查询条件固定、返回列少的高频读用覆盖索引;每次捞一个小数据(用户资料)直接上 Redis。实际项目里常混着用——高频 API 用 Redis 缓存用户信息,订单查询走覆盖索引,报表走物化视图。没有哪种方案能包打天下。
避坑清单
- 别上来就加索引或上缓存:先跑
EXPLAIN ANALYZE看执行计划,定位瓶颈到底在哪。索引加一堆结果瓶颈不在那里,白忙活。 - 反规范化字段要有明确同步机制:见过分类表改了名字、产品表 JSONB 没人更新,前端显示的分类名和实际对不上。写触发器或在业务层处理,别靠人记。
- 物化视图刷新策略上线前想清楚:用 CONCURRENTLY 还是低峰刷,别等线上告警教。
- 覆盖索引返回列别塞太多:索引膨胀会拖垮写入性能。
- 缓存失效比缓存读取更重要:选对失效策略,否则脏数据比慢查询更难查。
读性能优化这件事,先确认瓶颈在不在 JOIN,再按查询特征选模式,最后盯着执行计划和监控数据验证效果。盲目加索引和上缓存,往往钱花了速度没提。