DevToolBox免费
博客

SQL 优化指南:执行计划、索引策略、查询调优、分区与数据库扩展

22 分钟阅读作者 DevToolBox Team

SQL 性能可以决定一个应用的成败。一条慢查询可能级联导致超时、锁争用和用户体验下降。本指南涵盖 13 个核心 SQL 优化主题——从阅读 EXPLAIN 计划、设计索引到分区、连接池和数据库扩展策略。每个部分都包含可直接应用于 PostgreSQL、MySQL 或其他数据库的实用代码示例。

TL;DR

优化始终从阅读 EXPLAIN 输出理解查询计划开始。使用与 WHERE 和 ORDER BY 子句匹配的组合索引。对大数据集用游标分页替代 OFFSET 分页。通过预加载或批量查询解决 N+1 问题。用窗口函数替代关联子查询。对大表进行范围或哈希分区。连接池大小公式:连接数 = (CPU核心数 * 2) + 有效磁盘数。使用 pg_stat_statements 或 slow_query_log 监控慢查询,水平扩展优先使用读副本,然后再考虑分片。

关键要点

  • EXPLAIN ANALYZE 显示实际执行时间和行数估计——始终比较估计值和实际值以检测规划错误。
  • 组合索引应遵循最左前缀规则:等值列在前,范围列次之,排序列在后。
  • N+1 查询是最常见的 ORM 性能问题——通过查询日志检测,使用预加载或批量查询修复。
  • OFFSET 分页性能随页数线性下降。游标分页无论页码多大都保持恒定性能。
  • 窗口函数(ROW_NUMBER、RANK、LAG/LEAD)消除了昂贵的自连接和关联子查询。
  • 表分区启用分区裁剪,可将大表的查询扫描时间减少数个数量级。
  • 连接池应保守设置——过多连接导致上下文切换开销,反而降低总吞吐量。

1. EXPLAIN 与查询计划

每次优化都从理解数据库如何执行查询开始。EXPLAIN 显示优化器选择的查询计划,包括连接策略、索引使用和成本估计。EXPLAIN ANALYZE 实际执行查询并报告真实执行时间。

关键检查项:大表上的顺序扫描(缺少索引)、高成本估计、估计行数与实际行数差异大(统计信息过期)、大结果集上的嵌套循环、以及没有索引支持的排序操作。

在 PostgreSQL 中,EXPLAIN 输出包含多种节点类型:Seq Scan(全表扫描)、Index Scan(索引扫描)、Bitmap Heap Scan(位图堆扫描)、Nested Loop、Hash Join 和 Merge Join。每个节点显示估计成本(startup..total)、估计行数和宽度。EXPLAIN ANALYZE 增加实际时间和行数,帮助发现估计偏差。

-- Run EXPLAIN ANALYZE to see actual execution
EXPLAIN ANALYZE
SELECT u.name, COUNT(o.id) AS order_count
FROM users u
JOIN orders o ON o.user_id = u.id
WHERE u.created_at > '2025-01-01'
GROUP BY u.name
ORDER BY order_count DESC
LIMIT 20;

-- Key output fields to examine:
-- Seq Scan vs Index Scan (is an index being used?)
-- actual time=0.02..12.45 (startup..total time)
-- rows=1000 (estimated) vs actual rows=847
-- Sort Method: quicksort Memory: 128kB
提示: 在生产环境中运行 EXPLAIN ANALYZE 要谨慎——它会实际执行查询。对修改数据的语句(UPDATE/DELETE),将其包装在事务中并 ROLLBACK:BEGIN; EXPLAIN ANALYZE UPDATE ...; ROLLBACK;

2. 索引类型与策略

索引是最具影响力的优化工具。不同索引类型服务于不同查询模式。B-tree 索引处理等值和范围查询。Hash 索引仅优化等值查找。GIN 索引支持全文搜索和数组包含。GiST 索引处理几何和范围类型查询。部分索引只索引满足条件的行以减小索引大小。覆盖索引包含查询所需的所有列,实现仅索引扫描。

选择正确的索引类型至关重要。B-tree 覆盖 90% 的场景。对 JSONB 列使用 GIN 索引。对地理空间数据使用 GiST 索引。部分索引通过只索引热数据显著减少索引大小和维护开销。记住每个索引都会减慢 INSERT/UPDATE 操作——只创建查询确实需要的索引。

-- B-tree index (default, most common)
CREATE INDEX idx_users_email ON users (email);

-- Partial index (only active users)
CREATE INDEX idx_active_users ON users (email)
  WHERE status = 'active';

-- GIN index for full-text search
CREATE INDEX idx_posts_search ON posts
  USING gin(to_tsvector('english', title || ' ' || body));

-- Covering index (includes columns for index-only scan)
CREATE INDEX idx_orders_covering ON orders (user_id)
  INCLUDE (total_amount, created_at);

-- Hash index (equality only, slightly faster)
CREATE INDEX idx_sessions_token ON sessions
  USING hash (session_token);
提示: 使用 pg_stat_user_indexes 定期审查索引使用情况。idx_scan = 0 的索引可能是浪费空间的候选——但在删除前确认它不是用于唯一约束或外键。

3. 查询优化

编写高效 SQL 需要理解优化器如何处理 WHERE 子句、JOIN 和子查询。避免在索引列上使用函数,因为它们会阻止索引使用。关联子查询优先使用 EXISTS 而非 IN。CTE 提高可读性,但在某些数据库中可能充当优化屏障。

JOIN 顺序很重要——优化器通常会重新排序连接,但复杂查询可能需要提示或显式排序。始终尽早过滤以减少中间结果集大小。

另一个常见陷阱是隐式类型转换。当 WHERE 子句中的列类型与比较值不匹配时(如将字符串与整数比较),数据库可能对整列应用类型转换函数,使索引失效。始终确保比较值的类型与列类型一致。

-- BAD: function on indexed column prevents index use
SELECT * FROM users
WHERE LOWER(email) = 'user@example.com';

-- GOOD: use expression index or store normalized
CREATE INDEX idx_users_email_lower
  ON users (LOWER(email));

-- BAD: SELECT * fetches unnecessary columns
SELECT * FROM orders WHERE user_id = 42;

-- GOOD: select only needed columns
SELECT id, total_amount, created_at
FROM orders WHERE user_id = 42;

-- Use EXISTS instead of IN for correlated subquery
SELECT * FROM users u
WHERE EXISTS (
  SELECT 1 FROM orders o
  WHERE o.user_id = u.id AND o.total > 100
);
提示: 使用 SELECT * 不仅浪费带宽,还会阻止覆盖索引(index-only scan)的使用。始终明确列出所需列。此外,对于只需要检查存在性的查询,使用 SELECT 1 配合 EXISTS 而非 SELECT COUNT(*)。

4. 索引设计模式

有效的索引设计需要理解查询工作负载。组合索引必须遵循最左前缀规则:(a, b, c) 上的索引可以满足 (a)、(a, b) 或 (a, b, c) 的查询,但不能满足仅 (b, c) 的查询。覆盖索引包含所有 SELECT 列,完全消除表查找。仅索引扫描是最快的访问路径。

设计组合索引时的经验法则(ESR 规则):先放 Equality 列(精确匹配),再放 Sort 列(ORDER BY),最后放 Range 列(范围扫描)。这个顺序让索引满足尽可能多的查询操作而无需额外排序或回表。使用 pg_stat_user_indexes 视图监控索引使用情况,删除未使用的索引。

-- Composite index: equality first, range second
-- Query: WHERE status = 'active' AND created_at > ?
CREATE INDEX idx_status_created
  ON orders (status, created_at);

-- This index supports ORDER BY as well
-- WHERE status = 'active' ORDER BY created_at DESC
CREATE INDEX idx_status_created_desc
  ON orders (status, created_at DESC);

-- Covering index for index-only scans
-- Query: SELECT email, name FROM users WHERE status = 'active'
CREATE INDEX idx_users_status_covering
  ON users (status) INCLUDE (email, name);

-- Verify index-only scan in EXPLAIN output:
-- "Index Only Scan using idx_users_status_covering"
提示: 使用 INCLUDE 子句(PostgreSQL 11+)创建覆盖索引比在组合索引中包含所有列更好——INCLUDE 列不参与索引排序和查找,只是附带存储以支持 index-only scan。这减少了索引维护开销同时保持了快速扫描能力。

5. N+1 查询问题

N+1 问题发生在代码获取 N 条记录后,对每条记录执行一次额外查询来加载关联数据。这导致 N+1 次查询而非 1 或 2 次。这是最常见的 ORM 相关性能问题,可能将 5ms 的页面加载变成 5 秒。

检测方法:启用查询日志,查找参数不同但结构相同的重复查询。修复方式:预加载(JOIN FETCH)、批量查询(WHERE id IN (...))或 dataloader 模式。

在 GraphQL 应用中,N+1 问题尤为严重,因为解析器按字段独立执行。Facebook 的 DataLoader 模式是标准解决方案:它收集一个事件循环周期内的所有加载请求,然后用一次批量查询满足所有请求。Node.js、Python、Ruby 和 Java 都有 DataLoader 实现。

-- N+1 Problem: 1 query for users + N queries for orders
-- Query 1: SELECT * FROM users LIMIT 50;
-- Query 2..51: SELECT * FROM orders WHERE user_id = ?;

-- FIX 1: JOIN (eager loading)
SELECT u.*, o.id AS order_id, o.total_amount
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
WHERE u.id IN (SELECT id FROM users LIMIT 50);

-- FIX 2: Batch query (two queries total)
-- Step 1: SELECT * FROM users LIMIT 50;
-- Step 2: collect user_ids, then:
SELECT * FROM orders
WHERE user_id IN (1, 2, 3, 4, 5, 7, 8, 12, 15, 20);

-- FIX 3: Django ORM example
-- BAD:  User.objects.all()[:50]  (triggers N+1)
-- GOOD: User.objects.prefetch_related('orders')[:50]
提示: 使用 Django 的 nplusone 包、Ruby 的 Bullet gem 或 Java 的 Hibernate SQL 日志(hibernate.show_sql=true)在开发阶段自动检测 N+1 查询。在 CI 管道中集成这些工具可以防止 N+1 回归。

6. 分页策略

OFFSET/LIMIT 分页简单但扩展性差——数据库必须扫描并丢弃偏移量之前的所有行。第 1000 页每页 20 行,数据库读取 20,000 行却丢弃 19,980 行。游标分页使用索引列上的 WHERE 子句直接跳到下一页,保持恒定性能。

游标分页的限制是不支持"跳转到第 N 页",只支持"下一页/上一页"导航。对于需要页码的 UI,可以用混合方案:前几页用 OFFSET(性能尚可),深层页面引导用户使用搜索或过滤来缩小结果集。API 分页几乎总应该使用游标方式。

-- BAD: OFFSET pagination (slow for deep pages)
SELECT * FROM products
ORDER BY id
LIMIT 20 OFFSET 10000;  -- scans 10020 rows!

-- GOOD: Keyset / cursor pagination
SELECT * FROM products
WHERE id > 50240  -- last seen ID from previous page
ORDER BY id
LIMIT 20;  -- always scans exactly 20 rows

-- For multi-column sorting:
SELECT * FROM products
WHERE (created_at, id) > ('2025-06-15', 9823)
ORDER BY created_at, id
LIMIT 20;

-- Encode cursor as base64 for API responses
-- cursor: eyJjcmVhdGVkX2F0IjoiMjAyNS0wNi0xNSIsImlkIjo5ODIzfQ==
提示: 对于 GraphQL API,Relay 规范定义了标准的游标分页接口(first/after/last/before),大多数 GraphQL 框架内置支持。REST API 可以在 Link header 或响应 body 中返回 next_cursor 字段。

7. 窗口函数

窗口函数在与当前行相关的一组行上执行计算,而不将其折叠为单一输出行。它们替代了许多之前需要自连接或关联子查询的模式。常用窗口函数包括 ROW_NUMBER、RANK、DENSE_RANK、LAG、LEAD、SUM 和 AVG 配合 OVER 子句。

窗口函数的 PARTITION BY 子句将行分组(类似 GROUP BY 但不折叠行),ORDER BY 定义窗口内的排序。帧子句(ROWS BETWEEN / RANGE BETWEEN)控制计算包含哪些行。默认帧是 RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW,这对运行总计很有用,但对移动平均需要显式指定帧范围。

-- ROW_NUMBER: assign sequential numbers
SELECT name, department, salary,
  ROW_NUMBER() OVER (
    PARTITION BY department ORDER BY salary DESC
  ) AS dept_rank
FROM employees;

-- LAG / LEAD: access previous / next row values
SELECT date, revenue,
  revenue - LAG(revenue) OVER (ORDER BY date) AS daily_change,
  LEAD(revenue) OVER (ORDER BY date) AS next_day_revenue
FROM daily_sales;

-- Running total with SUM window function
SELECT date, amount,
  SUM(amount) OVER (
    ORDER BY date ROWS UNBOUNDED PRECEDING
  ) AS running_total
FROM transactions;
提示: 窗口函数比等效的自连接快得多,因为数据库只需扫描表一次。如果需要对同一数据应用多个窗口函数,在同一个 SELECT 中定义它们——数据库通常可以在一次扫描中计算多个窗口。使用 WINDOW 子句命名窗口定义以避免重复。

8. 公共表表达式

CTE(WITH 子句)通过将复杂逻辑分解为命名步骤来提高查询可读性。递归 CTE 可以遍历层级数据,如组织架构或分类树。在 PostgreSQL 12+ 中,非递归 CTE 被优化器内联(不再是优化屏障)。需要时使用 MATERIALIZED 提示强制物化以提升性能。

递归 CTE 的核心结构是:基础查询 UNION ALL 递归查询。递归查询引用 CTE 自身,每次迭代产生新行,直到没有新行产生为止。始终添加深度限制(WHERE depth < N)或使用 CYCLE 检测来防止无限循环。递归 CTE 是处理树形结构、图遍历和层级数据的标准方法。

-- Recursive CTE: traverse org chart hierarchy
WITH RECURSIVE org_tree AS (
  -- Base case: top-level managers
  SELECT id, name, manager_id, 1 AS depth
  FROM employees WHERE manager_id IS NULL

  UNION ALL

  -- Recursive step: find direct reports
  SELECT e.id, e.name, e.manager_id, t.depth + 1
  FROM employees e
  JOIN org_tree t ON e.manager_id = t.id
  WHERE t.depth < 10  -- prevent infinite loops
)
SELECT * FROM org_tree ORDER BY depth, name;

-- Materialized CTE (PostgreSQL 12+)
WITH expensive_calc AS MATERIALIZED (
  SELECT user_id, SUM(amount) AS total
  FROM orders GROUP BY user_id
)
SELECT * FROM expensive_calc WHERE total > 1000;
提示: 在 MySQL 8.0 中,CTE 默认会被物化(每次引用都重新计算)。如果 CTE 只被引用一次,MySQL 会自动内联。PostgreSQL 12+ 则默认内联非递归 CTE,除非使用 MATERIALIZED 关键字。了解你所用数据库的 CTE 行为差异对写出高效查询至关重要。

9. 分区

表分区将大表拆分为较小的物理部分,同时维护单一逻辑表。范围分区按日期或数值范围划分。列表分区按离散值划分(地区、状态)。哈希分区将行均匀分布到各分区。优化器裁剪查询不需要的分区,显著减少 I/O。

最佳分区候选:数亿行的表、基于日期查询的时序数据、以及定期归档或删除旧数据的表。

分区维护建议:为时序数据设置自动创建未来分区的 cron 任务。使用 pg_partman 扩展自动化分区管理。旧分区可以 DETACH 后移动到冷存储或直接 DROP,比 DELETE 百万行快几个数量级。PostgreSQL 14+ 支持分区表上的并行查询,进一步提升性能。

-- Range partitioning by date (PostgreSQL)
CREATE TABLE events (
  id         BIGSERIAL,
  event_type TEXT,
  payload    JSONB,
  created_at TIMESTAMPTZ NOT NULL
) PARTITION BY RANGE (created_at);

-- Create monthly partitions
CREATE TABLE events_2025_01 PARTITION OF events
  FOR VALUES FROM ('2025-01-01') TO ('2025-02-01');
CREATE TABLE events_2025_02 PARTITION OF events
  FOR VALUES FROM ('2025-02-01') TO ('2025-03-01');

-- Queries automatically prune irrelevant partitions
SELECT * FROM events
WHERE created_at >= '2025-02-01'
  AND created_at < '2025-03-01';
-- Only scans events_2025_02 partition
提示: 创建分区表后记得在分区键上创建索引——分区表的索引不会自动继承。使用 CREATE INDEX ON events (event_type) 会自动在所有现有和未来分区上创建对应索引。

10. 连接池

数据库连接是昂贵的资源。每个 PostgreSQL 连接消耗约 10MB 内存和一个服务器端进程。连接池在应用请求之间复用固定数量的连接。流行的连接池包括 PgBouncer(外部、轻量级)和 HikariCP(JVM、嵌入式)。最佳池大小公式:连接数 = (CPU核心数 * 2) + 有效磁盘数。

常见错误是设置过大的连接池。16 核数据库的最佳总连接数约 33-35 个,而非数百个。过多连接导致过度上下文切换。

PgBouncer 支持三种池模式:session(连接绑定到整个会话,兼容性最好)、transaction(连接在事务结束后归还池,推荐用于大多数应用)、statement(每条语句后归还,不支持事务)。对于微服务架构,建议在每个服务前部署 PgBouncer 实例,然后服务器端再部署一个汇总层。

-- PgBouncer configuration (pgbouncer.ini)
-- [databases]
-- mydb = host=localhost port=5432 dbname=mydb
--
-- [pgbouncer]
-- listen_port = 6432
-- pool_mode = transaction
-- max_client_conn = 1000
-- default_pool_size = 20
-- min_pool_size = 5

-- Pool sizing formula:
-- connections = (core_count * 2) + spindle_count
-- 4-core SSD server: (4 * 2) + 1 = 9 connections

-- HikariCP (Java / Spring Boot)
-- spring.datasource.hikari.maximum-pool-size=10
-- spring.datasource.hikari.minimum-idle=5
-- spring.datasource.hikari.idle-timeout=300000
-- spring.datasource.hikari.connection-timeout=20000
-- spring.datasource.hikari.max-lifetime=1200000
提示: 监控连接池的关键指标:等待连接的请求数、平均获取连接的等待时间和活跃连接数。如果等待时间持续超过 100ms,可能需要略微增加池大小或优化慢查询以更快释放连接。

11. 锁与并发

PostgreSQL 使用 MVCC(多版本并发控制)——读不阻塞写,写不阻塞读。每个事务看到数据库的一个快照。UPDATE/DELETE 获取行级锁。理解锁模式和死锁预防对高并发应用至关重要。

悲观锁(SELECT FOR UPDATE)防止并发修改但降低吞吐量。乐观锁(版本列)允许并发读取并在写入时检测冲突。始终以一致的顺序访问表以防止死锁。

死锁预防的关键原则:所有事务以相同顺序访问表和行。保持事务尽量短小。避免在事务中进行用户交互或外部 API 调用。设置合理的 lock_timeout 和 statement_timeout。使用 pg_locks 视图和 log_lock_waits 参数监控锁争用。出现死锁时,PostgreSQL 会自动中止一个事务——应用层需要实现重试逻辑。

-- Pessimistic locking: SELECT FOR UPDATE
BEGIN;
SELECT * FROM inventory
WHERE product_id = 42
FOR UPDATE;  -- locks this row

UPDATE inventory
SET quantity = quantity - 1
WHERE product_id = 42;
COMMIT;

-- Optimistic locking: version column
UPDATE products
SET price = 29.99, version = version + 1
WHERE id = 42 AND version = 5;
-- If 0 rows updated -> conflict detected, retry

-- Advisory locks for application-level locking
SELECT pg_advisory_lock(hashtext('process-orders'));
-- ... do exclusive work ...
SELECT pg_advisory_unlock(hashtext('process-orders'));
提示: 对于高并发计数器场景(如库存扣减、余额更新),考虑使用 PostgreSQL 的 SELECT FOR UPDATE SKIP LOCKED 跳过已锁定行处理下一个,或使用 advisory lock 实现应用级互斥。避免在热点行上使用长事务。

12. 慢查询分析

查找和修复慢查询需要系统性监控。PostgreSQL pg_stat_statements 跟踪所有查询的执行统计。MySQL slow_query_log 捕获超过时间阈值的查询。两者都提供总执行时间、调用次数和平均持续时间——关注总时间最高的查询,而非单次最慢的执行。

优化工作流程:首先按总执行时间排序识别高影响查询。然后对每个查询运行 EXPLAIN ANALYZE 理解执行计划。检查是否缺少索引、统计信息是否过期(运行 ANALYZE 更新)、查询是否可以重写。修复后验证改进并持续监控。auto_explain 扩展可以自动记录慢查询的执行计划,是诊断间歇性性能问题的利器。

-- Enable pg_stat_statements (postgresql.conf)
-- shared_preload_libraries = 'pg_stat_statements'
-- pg_stat_statements.track = all

-- Find top 10 slowest queries by total time
SELECT query,
  calls,
  round(total_exec_time::numeric, 2) AS total_ms,
  round(mean_exec_time::numeric, 2) AS avg_ms,
  rows
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 10;

-- MySQL: enable slow query log
-- SET GLOBAL slow_query_log = 'ON';
-- SET GLOBAL long_query_time = 1;  -- seconds
-- SET GLOBAL log_queries_not_using_indexes = 'ON';
提示: 定期运行 pg_stat_statements_reset() 重置统计信息,以获取最近时间窗口的准确数据。结合 pg_stat_statements 和 auto_explain,可以自动捕获慢查询的执行计划。设置 auto_explain.log_min_duration = 1000 记录超过 1 秒的查询计划。

13. 数据库扩展

当单台服务器无法承载负载时,扩展策略包括垂直扩展(更大的服务器)、读副本(分发读流量)和分片(将写流量分布到多个数据库)。读副本是最简单的扩展方式——将所有 SELECT 路由到副本,写操作发送到主库。分片增加显著的应用复杂性,应作为最后手段。

分片前先穷尽其他选项:优化查询、添加索引、实现缓存(Redis/Memcached)、使用读副本、表分区和归档旧数据。大多数应用永远不需要分片。

读副本的实现要点:配置流复制(streaming replication),应用层按查询类型路由(写到主库,读到副本)。注意副本存在复制延迟(通常毫秒级),对一致性要求极高的读操作仍需路由到主库。ProxySQL(MySQL)或 Pgpool-II(PostgreSQL)可以自动处理读写分离。分片策略包括按 ID 范围分片、按哈希分片和按租户分片(多租户 SaaS 最常用)。

-- Read replica routing (application level)
-- Primary: INSERT, UPDATE, DELETE
-- Replica: SELECT queries

-- PostgreSQL streaming replication setup:
-- primary postgresql.conf:
-- wal_level = replica
-- max_wal_senders = 10

-- replica recovery.conf:
-- primary_conninfo = 'host=primary port=5432'
-- standby_mode = on

-- Application-level read/write splitting
-- const db = {
--   primary: new Pool({ host: 'primary-db' }),
--   replica: new Pool({ host: 'replica-db' }),
-- };
-- const read = (sql) => db.replica.query(sql);
-- const write = (sql) => db.primary.query(sql);
提示: 在实施读写分离之前,先量化读写比例。大多数 Web 应用是 90%+ 读操作,一个读副本可能就足够了。使用 ProxySQL 或 Pgpool-II 可以透明地将查询路由到合适的节点,无需修改应用代码。

总结:SQL 优化检查清单

SQL 优化不是一次性工作,而是持续的过程。以下是一个实用的优化检查清单,按优先级排序:

  1. 监控优先启用 pg_stat_statements 或 slow_query_log,识别总执行时间最高的查询
  2. 分析查询计划对高影响查询运行 EXPLAIN ANALYZE,检查顺序扫描和行数估计偏差
  3. 添加缺失索引根据查询模式创建组合索引和覆盖索引,遵循 ESR 规则
  4. 修复 N+1 查询在开发环境启用查询检测工具,使用预加载或批量查询替代
  5. 优化分页将 OFFSET 分页迁移到游标分页,尤其是 API 端点
  6. 调整连接池使用公式设置合理的池大小,部署 PgBouncer 或 HikariCP
  7. 考虑分区对超过 1 亿行的表评估分区策略
  8. 最后才扩展先读副本、后缓存、最后再考虑分片

常见问题

如何判断 SQL 查询是否需要优化?

对查询运行 EXPLAIN ANALYZE,检查大表上的顺序扫描、高实际执行时间、估计行数与实际行数的显著差异以及无序数据上的排序操作。同时监控 pg_stat_statements 或 slow_query_log 中总执行时间或调用频率高的查询。

B-tree 索引和 Hash 索引有什么区别?

B-tree 索引支持等值(=)、范围(<、>、BETWEEN)和 ORDER BY 操作,是默认且最通用的索引类型。Hash 索引仅支持等值比较,但精确查找可能略快。在 PostgreSQL 中,几乎总是首选 B-tree 索引,因为它覆盖更多查询模式且支持 WAL 日志以确保崩溃安全。

如何修复 ORM 中的 N+1 查询问题?

启用查询日志检测 N+1 模式(重复的相似查询)。使用预加载修复(如 JPA fetch join、Django select_related/prefetch_related、ActiveRecord includes)。或者使用批量查询在单个 IN 子句中加载所有关联记录,或为 GraphQL 解析器实现 dataloader 模式。

什么时候应该使用表分区?

当表超过数亿行时进行分区,尤其是按日期范围过滤查询的时序数据。分区支持分区裁剪(跳过无关数据)、更快的批量删除(DROP PARTITION 代替 DELETE)和跨分区的并行查询执行。避免对小表分区,因为开销大于收益。

数据库连接池的最佳大小是多少?

使用公式:连接数 = (CPU核心数 * 2) + 有效磁盘数。4 核 SSD 服务器约 10 个连接起步。反直觉的是,较小的池通常优于较大的池,因为过多连接导致上下文切换开销。根据工作负载测试并监控等待时间。

PostgreSQL 中的 MVCC 如何工作?

MVCC(多版本并发控制)为每次 UPDATE 创建行的新版本而非覆盖。每个事务根据开始时间看到一致的快照。这意味着读不阻塞写,写不阻塞读。旧行版本由 VACUUM 进程清理。MVCC 实现了无读锁的高并发。

应该使用 OFFSET 还是游标分页?

对任何可能增长的数据集使用游标(keyset)分页。OFFSET 分页需要扫描所有前面的行,深层页面非常慢。游标分页使用 WHERE id > last_seen_id ORDER BY id LIMIT N,无论页深度如何都执行简单的索引范围扫描。OFFSET 仅适用于小型有界结果集。

什么时候应该考虑数据库分片?

分片应是穷尽所有其他优化后的最后手段:查询调优、索引、缓存、读副本、分区和归档旧数据。仅当写吞吐量超过单台主服务器承载能力,或数据集大小超过单服务器存储容量时才考虑分片。分片为连接、事务和模式迁移增加了显著复杂性。

𝕏 Twitterin LinkedIn
这篇文章有帮助吗?

保持更新

获取每周开发技巧和新工具通知。

无垃圾邮件,随时退订。

试试这些相关工具

SQLSQL Formatter{ }JSON Formatter.*Regex Tester

相关文章

数据库优化指南:索引策略、查询调优、Schema 设计与大规模性能优化

完整的数据库优化指南,涵盖索引策略、EXPLAIN 查询优化、Schema 设计、连接池、Redis 缓存、PostgreSQL/MySQL/MongoDB 调优、监控和扩展策略。

Redis完整指南:缓存、发布订阅、流和生产模式

掌握Redis的完整指南。含数据类型、Node.js ioredis、缓存模式、会话存储、发布订阅、流、Python redis-py、速率限制、事务和生产环境配置。

微服务模式指南:Saga、CQRS、事件溯源、服务网格与领域驱动设计

完整的微服务模式指南,涵盖 Saga 模式、CQRS、事件溯源、Istio 服务网格、API 网关模式、断路器、分布式追踪、领域驱动设计和微服务测试策略。