这才是关键所在。如果某个慢查询正在影响产品发布速度、降低测试用户注册量、或消磨用户耐心,请务必阅读本文。
简短、直接、实用。所有理论都经过实战检验。我将展示四种在生产环境中使用的模式,每种都配有简短的代码示例、架构示意图,以及来自可复现测试环境的实测基准数据。请根据你的读写特性选择合适的模式,并谨慎使用。这些模式将改变系统在负载下的行为表现。
为何要舍弃连接查询连接查询在数据建模方面非常优雅,但当它们导致跨文件的随机I/O、重复的堆查找,或在读取时产生重复的网络跳转时,就变得非常糟糕。我们的目标不是永久禁止连接查询,而是将它们从热门的读取路径中移除。读取延迟通常是表象问题。我们的目标是用能以更低成本提供相同读取语义的方案来替代连接查询。
按回车键或点击以全尺寸查看图片
测试环境(数据来源)
• 数据库:PostgreSQL 14,缓存已预热。• 计算机:16 GB 内存,4 vCPU。• 数据集:主表包含 1,000,000 行,相关参考表包含 10k–200k 行。• 负载:在稳定状态下测量单条查询。• 指标:p50 和 p99 延迟,单位为毫秒。以下所有数据均来自具有现实生产环境模式特征的同一环境。基准测试摘要(可复制)
模式工作负载优化前 p50 (ms)优化后 p50 (ms)p50 提升优化前 p99 (ms)优化后 p99 (ms)p99 提升物化视图按用户统计月度聚合5204511.6倍22009024.4倍反规范化 JSONB读取产品 + 分类信息4202815.0倍18006527.7倍覆盖索引高基数过滤 + 投影1201012.0倍6804515.1倍应用缓存 (Redis)请求路径中的用户查询3301818.3倍125012010.4倍
模式 1 — 物化读取模型(预计算复杂连接)问题
为了计算过去30天的总额,一个查询需要连接 orders、order_items、users 和 products 表。聚合操作迫使每次请求都要扫描大量行并重复执行连接。变更
将聚合结果预计算到一个物化视图中,并通过查询该视图来读取数据。按计划刷新或使用增量刷新逻辑。代码 (Postgres)
-- 创建物化聚合CREATE MATERIALIZED VIEW mv_user_monthly ASSELECT u.id AS user_id, sum(o.amount) AS totalFROM users uJOIN orders o ON o.user_id = u.idWHERE o.created_at >= current_date - interval '30 days'GROUP BY u.id;-- 需要时刷新REFRESH MATERIALIZED VIEW mv_user_monthly;读取
SELECT user_id, totalFROM mv_user_monthlyWHERE total > 100;结果
• 问题:每次请求都需要进行复杂的连接和聚合。• 变更:从预计算的表中读取。• 具体结果:p50 从 520 ms 降至 45 ms (提升 11.6倍)。p99 提升 24倍。权衡在于写入端的复杂性和数据新鲜度窗口。如果需要,可使用 REFRESH MATERIALIZED VIEW CONCURRENTLY,并为可接受的陈旧数据读取添加策略。架构(手绘 ASCII)Raw data (orders) ---> Batch job / trigger ---> Materialized view (mv_user_monthly) | | '---- (writes) --------------------------------'Read path:Client --> API --> mv_user_monthly适用场景
• 昂贵且能容忍一定刷新周期的聚合或连接查询。• 仪表盘、排行榜、账单摘要。避免场景• 必须立即反映每一个新写入的实时读取。模式 2 — 反规范化宽行(将热点引用数据存入行内)问题
读取产品信息时总是需要连接 category、supplier 和一个小型参考表。连接的成本在每次读取时都会重复。变更
将小型参考数据反规范化到产品行中,作为 jsonb 列或显式字段。如有必要,为提取的属性创建索引。代码 (Postgres)
ALTER TABLE products ADD COLUMN cat jsonb;UPDATE products pSET cat = jsonb_build_object('id', c.id, 'name', c.name)FROM categories cWHERE p.category_id = c.id;CREATE INDEX idx_products_cat_name ON products ((cat->>'name'));读取
SELECT id, cat->>'name' AS categoryFROM productsWHERE id = $1;结果
• 问题:每次读取产品时都需要重复连接分类表。• 变更:将分类对象存储在产品行中,并添加函数索引。• 具体结果:p50 从 420 ms 降至 28 ms (提升 15倍)。p99 降低幅度类似。写入路径现在需要在分类信息变更时更新反规范化的字段,或者接受最终一致性。架构(手绘 ASCII)categories \ \ (denormalize) --> products (id, name, cat: {id,name})Client -> API -> products (single table read)适用场景
• 很少更改的小型参考表。• 以读取为主的 API,且单行数据即可提供所需全部信息。避免场景• 会导致行数据膨胀的高基数属性,或者需要严格规范化以保证正确性的场景。模式 3 — 覆盖索引与索引仅扫描问题
一个查询通过 status 和 created_at 进行过滤,但为了获取基表中已有的数据,仍需连接其他表。查询规划器最终执行了随机的堆获取。变更
创建一个覆盖查询返回的所有列的索引,使得数据库可以仅通过索引来满足查询。代码 (Postgres)
-- 查询: SELECT id, total FROM orders WHERE status = 'paid' AND created_at >= '2025-01-01' LIMIT 50;CREATE INDEX idx_orders_cover ON orders (status, created_at) INCLUDE (id, total);读取
SELECT id, totalFROM ordersWHERE status = 'paid' AND created_at >= '2025-01-01'LIMIT 50;结果
• 问题:对每个匹配的行进行堆获取。• 变更:索引包含返回的列;规划器可以执行索引仅扫描。• 具体结果:p50 从 120 ms 降至 10 ms (提升 12倍)。p99 提升约 15倍。此模式风险较低。对于谓词稳定的查询而言,性价比很高。架构(手绘 ASCII)orders table | +-- index (status, created_at) + included columns (id, total)Client -> API -> index-only scan -> results适用场景
• 具有简单过滤条件和少量返回列的高频读取。• 表非常大且全表扫描成本过高时。避免场景• 如果查询返回的列很多,全部放入索引会导致索引严重膨胀。模式 4 — 应用端查询缓存(Redis 或内存)问题
每个请求都会从数据库中读取一小段频繁使用的参考数据行,例如用户资料或组织设置。数据库的连接或查询操作在每个请求中重复。变更
将这些小的参考数据缓存在 Redis 或进程内存中;在热路径中读取缓存,仅在缓存未命中时回退到数据库。代码 (Python + redis)
# minimal exampledef 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# later, use get_user to avoid joining users tableorders = db.all("SELECT id,total FROM orders WHERE user_id = %s", uid)结果
• 问题:为微小的参考数据重复进行数据库连接或查询。• 变更:缓存在快速的键值存储中。• 具体结果:p50 从 330 ms 降至 18 ms (提升 18.3倍)。p99 从 1250 ms 降至 120 ms。主要的权衡在于缓存失效策略。通过较短的 TTL 或发布/订阅失效机制来处理写入。架构(手绘 ASCII)Client -> API | +-> Redis cache (user:{uid}) -- hit -> fast | +-> DB (orders) -- main read pathWrites -> update DB -> invalidate Redis key适用场景
• 在写入之间保持稳定的、读多写少的小型表。• 当到 Redis 的网络往返成本远低于数据库连接或数据库冷查询时。避免场景• 对于每次读取都必须保持一致的敏感数据,除非存在严格的失效协议。如何在四者间选择
• 如果读取涉及聚合:使用物化视图。• 如果读取需要多个小型参考字段:使用反规范化或 JSONB。• 如果读取是包含重度过滤的瘦投影:使用覆盖索引。• 如果每次请求只需要一个小的参考数据:使用 Redis 缓存。实际落地检查清单(导师视角)
1. 从性能剖析开始。确认 EXPLAIN ANALYZE 显示连接查询是瓶颈。不要凭猜测操作。2. 一次只改一处。不要只测量平均值;要测量 p50 和 p99。3. 考虑运维复杂性:物化视图需要刷新逻辑,反规范化需要在写入时传播数据,缓存需要失效机制。4. 增加可观测性:跟踪缓存的命中率和物化视图的刷新时间。5. 记录数据不变性。如果团队更改了模式,反规范化的字段必须有明确的负责人。转载请注明来自海坡下载,本文标题:《数据库查询与优化(告别慢查询4大实战优化模式)》
京公网安备11000000000001号
京ICP备11000001号
还没有评论,来说两句吧...