惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

T
Threatpost
aimingoo的专栏
aimingoo的专栏
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
T
Tailwind CSS Blog
J
Java Code Geeks
博客园_首页
Google Online Security Blog
Google Online Security Blog
Hugging Face - Blog
Hugging Face - Blog
C
CXSECURITY Database RSS Feed - CXSecurity.com
I
Intezer
P
Palo Alto Networks Blog
V
Vulnerabilities – Threatpost
雷峰网
雷峰网
O
OpenAI News
SecWiki News
SecWiki News
小众软件
小众软件
酷 壳 – CoolShell
酷 壳 – CoolShell
美团技术团队
N
News | PayPal Newsroom
Project Zero
Project Zero
Forbes - Security
Forbes - Security
IT之家
IT之家
A
Arctic Wolf
WordPress大学
WordPress大学
Jina AI
Jina AI
T
Tor Project blog
博客园 - 三生石上(FineUI控件)
S
Secure Thoughts
Google DeepMind News
Google DeepMind News
Attack and Defense Labs
Attack and Defense Labs
博客园 - 聂微东
Exploit-DB.com RSS Feed
Exploit-DB.com RSS Feed
P
Privacy International News Feed
Cloudbric
Cloudbric
G
GRAHAM CLULEY
博客园 - 叶小钗
H
Hacker News: Front Page
腾讯CDC
量子位
Help Net Security
Help Net Security
人人都是产品经理
人人都是产品经理
C
Cyber Attacks, Cyber Crime and Cyber Security
月光博客
月光博客
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
宝玉的分享
宝玉的分享
爱范儿
爱范儿
L
Lohrmann on Cybersecurity
Hacker News - Newest:
Hacker News - Newest: "LLM"
Recorded Future
Recorded Future
C
CERT Recently Published Vulnerability Notes

土法炼钢兴趣小组的算法知识备份

国密算法与国密 TLS 系列索引 【系统架构设计】架构质量属性:不只是"高可用高性能" 【系统架构设计百科】告警策略:如何避免"狼来了" 【系统架构设计】CQRS:读写分离的架构哲学 【系统架构设计】空间架构:极端扩展场景的解法 【系统架构设计】微服务架构深度审视:优势、代价与适用边界 【系统架构设计】扩展性原理:水平、垂直与对角扩展 【系统架构设计】无状态设计:扩展的第一步也是最难的一步 【系统架构设计】缓存架构:从本地到分布式的多级缓存体系 【系统架构设计】管道与过滤器:Unix 哲学的架构表达 【系统架构设计】复杂性管理:架构的核心战场 【系统架构设计】消息队列架构:异步解耦的设计与陷阱 【系统架构设计】CDN 架构:全球加速的设计原理 【系统架构设计】连接池设计:被忽视的性能杀手 【系统架构设计】弹性设计模式:熔断器、舱壁与超时 【系统架构设计】高可用设计模式:冗余、故障转移与仲裁 【系统架构设计】容量规划:从拍脑袋到数据驱动 【系统架构设计】数据库扩展:分库分表的工程实践与替代方案 【系统架构设计】SLO 工程:可靠性的量化管理 【系统架构设计】性能建模:用数学思维分析系统瓶颈 【系统架构设计】混沌工程:主动验证系统的韧性 【系统架构设计】零拷贝与内存映射:数据搬运的极致优化 【系统架构设计】线程模型:从 thread-per-request 到协程 【系统架构设计】容灾架构:多活与灾备设计 【系统架构设计】数据建模:从关系范式到文档模型的真实权衡 【系统架构设计】吞吐量优化:批处理、流水线与并发模型 【系统架构设计】流处理架构:从批处理到实时的范式迁移 【系统架构设计】搜索引擎架构:倒排索引之上的系统设计 【系统架构设计】时序数据架构:监控与 IoT 的存储设计 【系统架构设计】数据迁移与版本化:在线不停机的数据演进 【系统架构设计】数据湖与数据仓库:分析架构的演进路线 【系统架构设计】API 网关设计:入口层的职责边界 【系统架构设计】应用层数据一致性模式:在正确性与性能之间走钢丝 【系统架构设计】多模数据库选型:Polyglot Persistence 的工程实践 【系统架构设计】服务发现与注册:动态拓扑的基础设施 【系统架构设计】配置管理架构:从配置文件到配置中心 【系统架构设计】全链路压测:大规模系统的性能验证 【系统架构设计】幂等性设计:分布式环境下的安全重试 【系统架构设计】契约测试与 Schema 演进:服务间的信任协议 【系统架构设计】长连接与推送架构:WebSocket、SSE 与 MQTT 【系统架构设计】延迟分析:从 P50 到 P999 的全链路追踪 【系统架构设计百科】DDD 战术模式:聚合、实体与值对象 【系统架构设计百科】防腐层与开放主机服务:系统集成的 DDD 方案 【系统架构设计百科】领域事件与事件风暴:从业务到架构的桥梁 【系统架构设计百科】CQRS + Event Sourcing 完整实战:从领域建模到部署 【系统架构设计百科】DDD 与微服务:用领域模型划分服务边界 【系统架构设计】DDD 战略设计:限界上下文与上下文映射 【系统架构设计百科】认证架构:从 Session 到 JWT 到 OIDC 【系统架构设计】API 设计哲学:REST vs GraphQL vs gRPC 的真实权衡 排序算法专题:从 TimSort 到并行排序 【密码学百科】国密算法体系:SM2/SM3/SM4/SM9 全景解读 【密码学百科】承诺方案:Pedersen 承诺、向量承诺与多项式承诺 【密码学百科】不经意传输与隐私信息检索:OT、OT 扩展与 PIR 【密码学百科】门限密码学:门限签名、门限解密与分布式密钥生成 完美哈希:从理论到 gperf 实践 【密码学百科】安全多方计算:从 Yao 的混淆电路到实用 MPC 【密码学百科】同态加密:从 Paillier 到全同态加密(FHE) 【密码学百科】零知识证明系统:zk-SNARKs、zk-STARKs 与 Bulletproofs 【密码学百科】概率论与密码分析:生日攻击、差分分析与线性分析 【密码学百科】计算复杂性与归约:密码安全性证明的基石 【密码学百科】秘密共享:Shamir 方案、VSS 与安全多方计算入口 【密码学百科】椭圆曲线代数:Weierstrass 方程、点群运算与曲线选择 【密码学百科】离散对数与配对密码学:从 DLP 到 BLS 签名 【密码学百科】格密码数学基础:SVP、LWE 与格基约化 【密码学百科】抽象代数:群、环、域的密码学视角 【密码学百科】有限域算术:GF(2^n) 运算与在 AES/ECC 中的应用 【密码学百科】数论进阶:二次剩余、椭圆曲线上的 Weil 配对 【密码学百科】密码学简史:从凯撒密码到量子时代 【密码学百科】威胁模型与安全目标:CIA 三要素之外 【密码学百科】Kerckhoffs 原则与现代密码设计哲学 【密码学百科】随机性:密码学的基石 【密码学百科】信息论入门:熵、完美保密与 Shannon 定理 【密码学百科】分组密码原理:Feistel 网络与 SPN 结构 【密码学百科】AES 逐步拆解:SubBytes 到 MixColumns 的数学 【密码学百科】分组密码工作模式全览:ECB/CBC/CTR/OFB/CFB 【密码学百科】流密码:RC4 的兴衰与 ChaCha20 的崛起 【密码学百科】密码学哈希函数:MD5→SHA-2→SHA-3 的进化之路 【密码学百科】MAC 与 HMAC:消息认证的正确姿势 【密码学百科】认证加密(AEAD):GCM、ChaCha20-Poly1305 与 OCB 【密码学百科】密钥派生函数:HKDF、PBKDF2、Argon2 与密码存储 【密码学百科】公钥密码的数论基础:模运算、群、原根 【密码学百科】RSA 从原理到攻击:教科书 RSA 为什么不安全 【密码学百科】Diffie-Hellman 密钥交换与离散对数问题 【密码学百科】椭圆曲线密码学(ECC):从几何直觉到点群运算 【密码学百科】数字签名:ECDSA、EdDSA 与 Schnorr 签名 【密码学百科】现代密钥交换:X25519、ECDHE 与前向保密 【密码学百科】混合加密与 KEM/DEM 范式:ECIES 与 HPKE 【密码学百科】填充方案:PKCS#1 v1.5、OAEP 与 PSS 【密码学百科】TLS 协议全解析:从握手到 0-RTT 【密码学百科】PKI 与数字证书:信任链的构建与崩塌 【密码学百科】密码认证协议:从 SRP 到 OPAQUE 【密码学百科】零知识证明入门:如何证明你知道而不泄露 【密码学百科】安全信道构造:Noise 协议框架与 Signal 协议 【密码学百科】密钥管理工程:HSM、KMS 与密钥生命周期 【密码学百科】侧信道攻击:从时序攻击到功耗分析 【密码学百科】密码学实现陷阱:三层漏洞分类、审计工具链与系统性预防 密码敏捷性:如何设计可升级的密码系统 【密码学百科】OpenSSL/BoringSSL 架构剖析:ENGINE、Provider 与 FIPS 模块 【Linux 网络子系统深度拆解】内核网络调优方法论:从基准测试到生产验证 排序基准测试:用数据说话
【系统架构设计】数据库性能模式:索引、查询与连接管理
Liao Tonglang · 2026-04-13 · via 土法炼钢兴趣小组的算法知识备份

某电商平台大促前夜,DBA 收到告警:订单列表接口 P99 延迟从 50ms 飙升至 3200ms。排查发现,一条看似简单的查询 SELECT * FROM orders WHERE user_id = ? AND status = 'paid' ORDER BY created_at DESC LIMIT 20 在 5000 万行的订单表上走了全表扫描。加了索引后延迟降到 8ms,但三天后同样的查询又慢了——原因是优化器的统计信息过期,选择了错误的执行计划。与此同时,连接池被大量慢查询占满,整个服务陷入雪崩。

这不是一个索引问题,也不是一个查询问题,而是一个涉及存储引擎、优化器、连接管理三层的架构级问题。本文将从 B+树(B+tree)的物理 I/O 模型出发,逐层拆解数据库性能的核心模式。

一、B+树索引的存储结构与 I/O 成本模型

为什么是 B+树

关系型数据库几乎都选择 B+树作为默认索引结构,根本原因在于磁盘 I/O 的物理特性。磁盘的最小读写单位是页(Page),通常为 4KB 或 8KB。一次随机 I/O(Random I/O)需要 5-10ms(机械磁盘)或 0.1ms(SSD),而顺序 I/O(Sequential I/O)的吞吐量可达随机 I/O 的 100 倍以上。

B+树的设计目标就是最小化查找过程中的磁盘 I/O 次数

B+树的物理结构

                        [Root Node]
                     /       |       \
              [Internal]  [Internal]  [Internal]
              /   |   \    /   |   \    /   |   \
           [Leaf]-[Leaf]-[Leaf]-[Leaf]-[Leaf]-[Leaf]
            ↕      ↕      ↕      ↕      ↕      ↕
           数据页  数据页  数据页  数据页  数据页  数据页

B+树的关键特征:

  • 所有数据存储在叶子节点(Leaf Node),内部节点只存储索引键和指针
  • 叶子节点通过双向链表连接,支持高效的范围扫描
  • 每个节点对应一个磁盘页,一次 I/O 读取一个节点

扇出率与树高计算

扇出率(Fan-out)是 B+树性能的核心参数,表示每个节点能容纳的子指针数量。

Fan-out = PageSize / (KeySize + PointerSize)

以 InnoDB 为例,页大小 16KB,假设索引键为 8 字节的 BIGINT,指针为 6 字节:

Fan-out = 16384 / (8 + 6) ≈ 1170

树高与可索引的行数关系:

树高 叶子节点数 可索引行数(每叶存 100 行) 查找 I/O 次数
2 1,170 11.7 万 2
3 1,368,900 1.37 亿 3
4 1,601,613,000 1601 亿 4

关键结论:对于绝大多数业务表(行数 < 1 亿),B+树的查找只需要 3 次磁盘 I/O。 根节点常驻内存后,实际只需 2 次 I/O。

I/O 成本分析

数据库的查询成本可以分解为三类 I/O:

总成本 = 索引查找成本 + 数据读取成本 + 排序/聚合成本

索引查找成本由树高决定,通常为 2-3 次随机 I/O。

数据读取成本取决于访问模式:

全表扫描成本 = 表页数 × 顺序 I/O 单价
索引范围扫描成本 = 匹配行数 × 随机 I/O 单价(回表场景)

这解释了一个反直觉的现象:当查询需要返回表中大比例数据时(通常 > 15-20%),全表扫描反而比索引扫描更快,因为顺序 I/O 的效率远高于大量随机 I/O。

graph LR
    subgraph 全表扫描["全表扫描<br/>Sequential I/O"]
        S1["Page 1"] --> S2["Page 2"] --> S3["Page 3"] --> S4["Page 4"] --> S5["..."]
    end
    subgraph 索引回表["索引回表<br/>Random I/O"]
        I1["Index Leaf"] --> D3["Page 3"]
        I1 --> D7["Page 7"]
        I1 --> D1["Page 1"]
        I2["Index Leaf"] --> D9["Page 9"]
        I2 --> D4["Page 4"]
    end
    style 全表扫描 fill:#3fb950,stroke:#3fb950,color:#ffffff
    style 索引回表 fill:#f0883e,stroke:#f0883e,color:#ffffff

即使在 NVMe SSD 上,随机 I/O 与顺序 I/O 仍有数倍的性能差距。在 HDD 上,这个差距超过 600 倍。

二、覆盖索引与仅索引扫描

回表的代价

普通索引查找的完整流程是:

  1. 在索引 B+树中定位到叶子节点,取得主键值
  2. 用主键值到聚簇索引(Clustered Index)中查找完整行数据

第二步称为回表(Table Lookup),每一行都需要一次额外的随机 I/O。当查询返回大量行时,回表成本可能远超索引查找本身。

覆盖索引消除回表

覆盖索引(Covering Index)的核心思想是:将查询需要的所有列都包含在索引中,使查询完全在索引上完成,不需要回表。

-- 原始查询:需要回表
SELECT user_id, status, created_at
FROM orders
WHERE user_id = 10086 AND status = 'paid'
ORDER BY created_at DESC;

-- 普通索引:只能加速查找,仍需回表取 created_at
CREATE INDEX idx_user_status ON orders(user_id, status);

-- 覆盖索引:包含所有查询列,消除回表
CREATE INDEX idx_user_status_created ON orders(user_id, status, created_at);

使用覆盖索引后,EXPLAIN 输出中会出现 Using index(MySQL)或 Index Only Scan(PostgreSQL)标记:

EXPLAIN SELECT user_id, status, created_at
FROM orders
WHERE user_id = 10086 AND status = 'paid'
ORDER BY created_at DESC;
+----+-------------+--------+------+--------------------------+---------+------+------+----------+--------------------------+
| id | select_type | table  | type | possible_keys            | key     | rows | Extra                    |
+----+-------------+--------+------+--------------------------+---------+------+------+----------+--------------------------+
|  1 | SIMPLE      | orders | ref  | idx_user_status_created  | idx_... |   42 | Using where; Using index |
+----+-------------+--------+------+--------------------------+---------+------+------+----------+--------------------------+

PostgreSQL 的仅索引扫描与可见性映射

PostgreSQL 的仅索引扫描(Index Only Scan)有一个额外约束:由于 MVCC 机制,索引中不包含行的可见性信息。PostgreSQL 需要检查可见性映射(Visibility Map)来确认行是否对当前事务可见。

Index Only Scan 流程:
1. 在索引中找到匹配的条目
2. 检查 Visibility Map:
   - 如果该页所有行都可见 → 直接从索引返回数据
   - 如果存在不可见行 → 回表检查行的可见性

实践建议: 在频繁更新的表上,定期执行 VACUUM 以更新可见性映射,确保仅索引扫描的效率。

-- 检查表的可见性映射覆盖率
SELECT
    relname,
    n_live_tup,
    n_dead_tup,
    round(100.0 * n_dead_tup / greatest(n_live_tup, 1), 2) AS dead_ratio_pct
FROM pg_stat_user_tables
WHERE schemaname = 'public'
ORDER BY n_dead_tup DESC
LIMIT 10;

三、复合索引的列顺序设计

最左前缀原则

复合索引(Composite Index)的列顺序至关重要。B+树按照索引列的定义顺序进行排序,查询只能利用从最左列开始的连续前缀。

CREATE INDEX idx_a_b_c ON t(a, b, c);

-- 可以使用索引的查询
WHERE a = 1                      -- 前缀 (a)
WHERE a = 1 AND b = 2            -- 前缀 (a, b)
WHERE a = 1 AND b = 2 AND c = 3  -- 完整匹配 (a, b, c)
WHERE a = 1 AND b > 5            -- 前缀 (a, b) 但 b 之后的列无法用于范围过滤
WHERE a = 1 ORDER BY b           -- 前缀 (a),b 用于排序

-- 无法使用索引的查询
WHERE b = 2                      -- 跳过了 a
WHERE b = 2 AND c = 3            -- 跳过了 a
WHERE a = 1 AND c = 3            -- 跳过了 b(a 可用,c 不可用)

列顺序选择策略

设计复合索引时,列顺序的选择遵循以下原则:

原则一:等值查询列在前,范围查询列在后

-- 查询模式:WHERE status = 'paid' AND created_at > '2026-01-01'
-- 正确:等值列在前
CREATE INDEX idx_status_created ON orders(status, created_at);
-- 错误:范围列在前,status 无法利用索引
CREATE INDEX idx_created_status ON orders(created_at, status);

原则二:高选择性列在前(有争议)

传统建议是将基数(Cardinality)高的列放在前面,但这并不总是最优解。关键要看实际查询模式

-- user_id 基数高(百万级),status 基数低(5 个值)
-- 如果查询总是同时指定 user_id 和 status,顺序影响不大
-- 如果有时只按 user_id 查询,user_id 应该在前

CREATE INDEX idx_user_status ON orders(user_id, status);

原则三:排序列的位置

-- 查询模式:WHERE user_id = ? ORDER BY created_at DESC LIMIT 20
-- created_at 放在 user_id 之后,可以利用索引排序,避免 filesort
CREATE INDEX idx_user_created ON orders(user_id, created_at);

索引设计的权衡

策略 优点 缺点 适用场景
单列索引 维护成本低,插入快 多条件查询效率低 单条件查询为主
复合索引 多条件查询高效 占用空间大,写入变慢 固定查询模式
覆盖索引 消除回表 索引体积膨胀 高频热点查询
前缀索引 减小索引体积 无法用于排序和覆盖 长字符串列
函数索引 支持表达式查询 仅特定查询可用 计算列查询

四、查询优化器内部机制

基于成本的优化

现代关系型数据库使用基于成本的优化器(Cost-Based Optimizer, CBO)来选择执行计划。优化器的核心工作是:

  1. 枚举候选计划:不同的连接顺序、连接算法、访问路径
  2. 估算每个计划的成本:I/O 成本 + CPU 成本
  3. 选择成本最低的计划
flowchart TD
    SQL["SQL 查询"] --> Parser["语法解析器<br/>Parser"]
    Parser --> Rewriter["查询重写器<br/>Query Rewriter"]
    Rewriter --> Optimizer["查询优化器<br/>Cost-Based Optimizer"]
    Optimizer --> PlanEnum["计划枚举"]
    Optimizer --> CostEst["成本估算"]
    Optimizer --> StatInfo["统计信息<br/>pg_statistics / INFORMATION_SCHEMA"]
    PlanEnum --> BestPlan["最优执行计划"]
    CostEst --> BestPlan
    StatInfo --> CostEst
    BestPlan --> Executor["执行引擎<br/>Executor"]
    style SQL fill:#388bfd,stroke:#388bfd,color:#ffffff
    style Optimizer fill:#f0883e,stroke:#f0883e,color:#ffffff
    style BestPlan fill:#3fb950,stroke:#3fb950,color:#ffffff
    style Executor fill:#a371f7,stroke:#a371f7,color:#ffffff

基数估算与选择率

优化器做出正确决策的前提是准确估算每一步操作的输出行数(基数,Cardinality)。选择率(Selectivity)是基数估算的核心概念:

选择率 = 满足条件的行数 / 总行数

基数 = 总行数 × 选择率

对于等值查询,假设数据均匀分布:

选择率(col = value) = 1 / NDV(col)

NDV = Number of Distinct Values(不同值的数量)

对于范围查询:

选择率(col > value) = (max - value) / (max - min)

直方图统计

均匀分布假设在真实数据中经常不成立。为了处理数据倾斜(Data Skew),数据库使用直方图(Histogram)来记录数据的实际分布。

PostgreSQL 使用两种直方图:

-- 查看 PostgreSQL 的统计信息
SELECT
    attname,
    n_distinct,
    most_common_vals,
    most_common_freqs,
    histogram_bounds
FROM pg_stats
WHERE tablename = 'orders' AND attname = 'status';

输出示例:

attname     | status
n_distinct  | 5
most_common_vals  | {pending,paid,shipped,delivered,cancelled}
most_common_freqs | {0.35,0.28,0.20,0.12,0.05}
histogram_bounds  | (NULL — 因为已用 MCV 覆盖所有值)
  • 最频繁值列表(Most Common Values, MCV):记录出现频率最高的值及其频率
  • 等高直方图(Equi-height Histogram):将剩余值划分为等频的桶

MySQL 8.0 也引入了直方图支持:

-- MySQL 8.0 创建直方图
ANALYZE TABLE orders UPDATE HISTOGRAM ON status, user_id WITH 100 BUCKETS;

-- 查看直方图信息
SELECT
    SCHEMA_NAME,
    TABLE_NAME,
    COLUMN_NAME,
    JSON_EXTRACT(HISTOGRAM, '$.histogram-type') AS hist_type,
    JSON_EXTRACT(HISTOGRAM, '$.number-of-buckets-specified') AS buckets
FROM INFORMATION_SCHEMA.COLUMN_STATISTICS
WHERE TABLE_NAME = 'orders';

PostgreSQL 查询规划器统计案例

以下是一个 PostgreSQL 中统计信息影响查询计划的真实场景:

-- 创建测试表,status 列严重倾斜
CREATE TABLE orders (
    id BIGSERIAL PRIMARY KEY,
    user_id BIGINT NOT NULL,
    status VARCHAR(20) NOT NULL,
    amount DECIMAL(10, 2),
    created_at TIMESTAMP NOT NULL DEFAULT NOW()
);

-- 插入 500 万行数据,95% 的订单状态为 'delivered'
INSERT INTO orders (user_id, status, amount, created_at)
SELECT
    (random() * 1000000)::BIGINT,
    CASE
        WHEN random() < 0.95 THEN 'delivered'
        WHEN random() < 0.97 THEN 'shipped'
        WHEN random() < 0.99 THEN 'paid'
        ELSE 'pending'
    END,
    (random() * 10000)::DECIMAL(10, 2),
    NOW() - (random() * INTERVAL '365 days')
FROM generate_series(1, 5000000);

CREATE INDEX idx_orders_status ON orders(status);

-- 收集统计信息
ANALYZE orders;

-- 查询少量数据(status = 'pending',约 0.5%)→ 走索引
EXPLAIN ANALYZE SELECT * FROM orders WHERE status = 'pending';
-- Index Scan using idx_orders_status on orders
-- rows=25012 (实际) vs rows=25000 (估算) → 估算准确

-- 查询大量数据(status = 'delivered',约 95%)→ 走全表扫描
EXPLAIN ANALYZE SELECT * FROM orders WHERE status = 'delivered';
-- Seq Scan on orders
-- rows=4750089 (实际) vs rows=4750000 (估算) → 估算准确,正确选择全表扫描

优化器根据统计信息正确区分了两种场景:少量匹配走索引,大量匹配走全表扫描。

五、优化器判断失误的场景

统计信息不可能完美反映数据的实时状态,以下场景容易导致优化器做出错误决策。

场景一:统计信息过期

大批量数据写入后,如果未及时更新统计信息,优化器基于旧的统计数据做决策:

-- 表原来有 100 万行,优化器知道这个信息
-- 批量导入 900 万行后,优化器仍然认为表只有 100 万行

-- 此时应该手动触发统计信息更新
ANALYZE orders;                    -- PostgreSQL
ANALYZE TABLE orders;              -- MySQL

PostgreSQL 的 autovacuum 进程会自动更新统计信息,但默认阈值是表的 20% 行数发生变化才触发。对于大表,这个阈值可能太高:

-- 调整特定表的自动分析阈值
ALTER TABLE orders SET (
    autovacuum_analyze_threshold = 1000,
    autovacuum_analyze_scale_factor = 0.01  -- 1% 变化就触发
);

场景二:数据倾斜

当查询使用参数化(Parameterized Query)时,优化器可能用一个”平均”选择率来生成通用计划,忽略不同参数值之间的巨大差异。

-- prepared statement
PREPARE order_by_status(text) AS
    SELECT * FROM orders WHERE status = $1;

-- status = 'pending' 只有 0.5% 的行 → 应该走索引
-- status = 'delivered' 有 95% 的行 → 应该走全表扫描
-- 但 generic plan 可能对两者使用相同的执行计划

PostgreSQL 的解决方案:前 5 次执行使用 custom plan(针对具体参数值优化),之后如果 custom plan 成本与 generic plan 接近,切换到 generic plan。

-- 强制使用 custom plan(PostgreSQL 12+)
SET plan_cache_mode = force_custom_plan;

场景三:相关列

优化器默认假设 WHERE 子句中的多个条件是独立的。当列之间存在相关性时,基数估算会严重偏差:

-- city 和 zipcode 高度相关
-- 优化器假设:P(city='Beijing' AND zipcode='100000') = P(city='Beijing') × P(zipcode='100000')
-- 实际上:P(city='Beijing' AND zipcode='100000') ≈ P(city='Beijing')

SELECT * FROM addresses
WHERE city = 'Beijing' AND zipcode = '100000';
-- 优化器可能严重低估匹配行数

PostgreSQL 11+ 引入了多列统计(Extended Statistics)来解决这个问题:

-- 创建多列统计
CREATE STATISTICS stat_city_zip (dependencies, ndistinct)
ON city, zipcode FROM addresses;

ANALYZE addresses;

场景四:复杂表达式

对列进行函数运算或表达式计算后,优化器无法使用该列的统计信息:

-- 优化器无法估算以下查询的选择率
SELECT * FROM orders WHERE YEAR(created_at) = 2026;

-- 改写为范围查询,优化器可以利用统计信息
SELECT * FROM orders
WHERE created_at >= '2026-01-01' AND created_at < '2027-01-01';

优化器失误诊断清单

症状 可能原因 诊断方法 解决方案
估算行数与实际行数差异 > 10 倍 统计信息过期 EXPLAIN ANALYZE 比较 estimated vs actual rows ANALYZE 更新统计信息
小结果集走全表扫描 NDV 估算偏低 检查 pg_stats.n_distinct 手动设置 ALTER COLUMN SET (n_distinct = ...)
大结果集走索引扫描 NDV 估算偏高 EXPLAIN (ANALYZE, BUFFERS) 检查 I/O 考虑 Hint 或调整统计采样率
多条件查询行数严重低估 列相关性 检查列间相关系数 创建多列统计
参数化查询对某些值慢 generic plan 不适用 pg_stat_statements 检查不同参数的执行时间 plan_cache_mode = force_custom_plan

六、EXPLAIN 计划解读实战

MySQL EXPLAIN 关键字段

EXPLAIN SELECT o.id, o.amount, u.name
FROM orders o
JOIN users u ON o.user_id = u.id
WHERE o.status = 'paid'
  AND o.created_at > '2026-01-01'
ORDER BY o.created_at DESC
LIMIT 20;
+----+-------------+-------+------+---------------------+------------------+---------+-------+------+-----------------------------+
| id | select_type | table | type | possible_keys       | key              | key_len | ref   | rows | Extra                       |
+----+-------------+-------+------+---------------------+------------------+---------+-------+------+-----------------------------+
|  1 | SIMPLE      | o     | ref  | idx_status_created  | idx_status_creat | 82      | const |  520 | Using where; Using filesort |
|  1 | SIMPLE      | u     | eq_ref| PRIMARY            | PRIMARY          | 8       | o.uid |    1 | NULL                        |
+----+-------------+-------+------+---------------------+------------------+---------+-------+------+-----------------------------+

关键字段解读:

  • type:访问类型,从优到劣:system > const > eq_ref > ref > range > index > ALL
  • key:实际使用的索引
  • rows:优化器估算需要扫描的行数
  • Extra
    • Using index:覆盖索引,不需要回表
    • Using filesort:需要额外排序,通常是性能瓶颈
    • Using temporary:使用临时表,常见于 GROUP BY
    • Using where:在存储引擎返回行后,Server 层还需要过滤

PostgreSQL EXPLAIN ANALYZE 解读

EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT o.id, o.amount, u.name
FROM orders o
JOIN users u ON o.user_id = u.id
WHERE o.status = 'paid'
  AND o.created_at > '2026-01-01'
ORDER BY o.created_at DESC
LIMIT 20;
Limit  (cost=1520.45..1520.50 rows=20 width=52) (actual time=12.345..12.350 rows=20 loops=1)
  Buffers: shared hit=1842 read=156
  ->  Sort  (cost=1520.45..1532.78 rows=4932 width=52) (actual time=12.340..12.345 rows=20 loops=1)
        Sort Key: o.created_at DESC
        Sort Method: top-N heapsort  Memory: 27kB
        Buffers: shared hit=1842 read=156
        ->  Nested Loop  (cost=0.87..1380.22 rows=4932 width=52) (actual time=0.045..11.230 rows=4890 loops=1)
              Buffers: shared hit=1842 read=156
              ->  Index Scan using idx_status_created on orders o  (cost=0.43..685.20 rows=4932 width=28) (actual time=0.030..3.120 rows=4890 loops=1)
                    Index Cond: ((status)::text = 'paid'::text AND created_at > '2026-01-01'::timestamp)
                    Buffers: shared hit=952 read=86
              ->  Index Scan using users_pkey on users u  (cost=0.43..0.14 rows=1 width=24) (actual time=0.001..0.001 rows=1 loops=4890)
                    Index Cond: (id = o.user_id)
                    Buffers: shared hit=890 read=70
Planning Time: 0.285 ms
Execution Time: 12.410 ms

关键指标:

  • cost启动成本..总成本,单位是 PostgreSQL 内部成本单位
  • actual time启动时间..总时间,单位是毫秒
  • rowsestimated rows vs actual rows,偏差大说明统计信息有问题
  • Buffersshared hit(缓存命中)vs read(磁盘读取),衡量 I/O 效率
  • loops:该节点执行的次数,嵌套循环中内表会执行多次

七、连接池大小与数据库并发度

为什么需要连接池

数据库连接(Connection)是昂贵的资源:

  • 建立连接的成本:TCP 三次握手 + TLS 协商 + 认证 + 会话初始化,通常 20-50ms
  • 连接占用的内存:PostgreSQL 每个连接约 10MB(per-connection process),MySQL 每个连接约 1-8MB
  • 操作系统开销:文件描述符、线程/进程调度

连接池(Connection Pool)通过复用已建立的连接来摊薄连接建立的成本,同时限制并发连接数来保护数据库。

HikariCP 连接池大小公式

HikariCP 是 Java 生态最流行的数据库连接池,其作者给出了一个经典的连接池大小公式:

Pool Size = T_n × (C_m - 1) + 1

其中: - T_n = 最大同时执行的线程数 - C_m = 单个任务中最大同时持有的连接数

对于大多数 Web 应用,每个请求只使用一个连接,简化为:

Pool Size ≈ CPU 核心数 × 2 + 有效磁盘数

这个公式的核心洞察是:连接池不是越大越好。 过多的连接会导致:

  1. CPU 上下文切换开销增大:操作系统需要调度更多线程
  2. 锁竞争加剧:更多并发事务意味着更多锁等待
  3. 缓存效率下降:每个连接使用独立的工作内存,总内存需求增加

Little 定律与连接池

利特尔定律(Little’s Law)是排队论的基本定律,完美适用于连接池分析:

L = λ × W

L = 系统中的平均请求数(即需要的连接数)
λ = 请求到达率(QPS)
W = 每个请求的平均处理时间(秒)

案例计算:

QPS = 2000
平均查询耗时 = 5ms = 0.005s

L = 2000 × 0.005 = 10

理论上只需要 10 个连接就能处理 2000 QPS

加上安全余量(处理突发流量和慢查询):

推荐连接池大小 = L × 1.5 ~ 2.0 = 15 ~ 20

连接池配置示例

Java(HikariCP):

HikariConfig config = new HikariConfig();
config.setJdbcUrl("jdbc:postgresql://localhost:5432/mydb");
config.setUsername("app");
config.setPassword("***");
config.setMaximumPoolSize(20);          // 最大连接数
config.setMinimumIdle(5);              // 最小空闲连接数
config.setConnectionTimeout(3000);     // 获取连接超时(ms)
config.setIdleTimeout(600000);         // 空闲连接存活时间(ms)
config.setMaxLifetime(1800000);        // 连接最大存活时间(ms)

HikariDataSource dataSource = new HikariDataSource(config);

Go(database/sql + pgx):

package main

import (
    "context"
    "database/sql"
    "fmt"
    "time"

    _ "github.com/jackc/pgx/v5/stdlib"
)

func initDB() (*sql.DB, error) {
    dsn := "postgres://app:***@localhost:5432/mydb?sslmode=disable"
    db, err := sql.Open("pgx", dsn)
    if err != nil {
        return nil, fmt.Errorf("open db: %w", err)
    }

    // 连接池配置
    db.SetMaxOpenConns(20)               // 最大打开连接数
    db.SetMaxIdleConns(5)                // 最大空闲连接数
    db.SetConnMaxLifetime(30 * time.Minute) // 连接最大存活时间
    db.SetConnMaxIdleTime(10 * time.Minute) // 空闲连接最大存活时间

    // 验证连接
    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()
    if err := db.PingContext(ctx); err != nil {
        return nil, fmt.Errorf("ping db: %w", err)
    }

    return db, nil
}

连接池与 max_connections 的关系

                ┌──────────────────────────────────┐
                │       数据库服务器                 │
                │   max_connections = 200           │
                │                                  │
  ┌─────────┐   │   ┌─────────────────────┐        │
  │ App-1   │───│──▶│ 连接池-1 (size=20)   │        │
  │ (10实例) │   │   │ 实际使用: 10×20=200  │        │
  └─────────┘   │   └─────────────────────┘        │
                │                                  │
  ┌─────────┐   │   ┌─────────────────────┐        │
  │ App-2   │───│──▶│ 连接池-2 (size=15)   │        │
  │ (5实例)  │   │   │ 实际使用: 5×15=75    │        │
  └─────────┘   │   └─────────────────────┘        │
                │                                  │
                │   总连接需求: 200 + 75 = 275      │
                │   超过 max_connections! 需要调整   │
                └──────────────────────────────────┘

关键公式:

max_connections ≥ Σ(每个应用实例数 × 该应用连接池大小) + 管理连接预留

管理连接预留通常为 5-10 个(用于监控、DBA 操作等)

当应用实例数动态伸缩时,考虑使用连接池中间件:

  • PgBouncer:PostgreSQL 的轻量级连接池代理
  • ProxySQL:MySQL 的高性能代理

连接池大小权衡表

连接池过小 连接池过大
请求排队等待连接,延迟增加 CPU 上下文切换开销增加
突发流量时容易超时 数据库内存压力增大
资源利用率可能不足 锁竞争加剧,TPS 反而下降
监控指标:连接等待时间长 监控指标:数据库 CPU 使用率高但 TPS 低

八、预编译语句与计划缓存

Prepared Statement 的三重收益

预编译语句(Prepared Statement)不只是防 SQL 注入的工具,它在性能上有三重收益:

  1. 避免重复解析:SQL 文本只解析一次,后续执行直接使用解析好的语法树
  2. 计划缓存:执行计划只生成一次(或按需更新),避免重复优化
  3. 二进制协议传输:参数以二进制格式传输,减少序列化/反序列化开销
-- PostgreSQL 的 Prepared Statement
PREPARE get_order(bigint) AS
    SELECT id, user_id, status, amount, created_at
    FROM orders
    WHERE id = $1;

EXECUTE get_order(12345);
EXECUTE get_order(67890);

-- 查看已缓存的执行计划
SELECT name, statement, prepare_time, generic_plans, custom_plans
FROM pg_prepared_statements;

计划缓存的陷阱

PostgreSQL 对 Prepared Statement 使用以下策略:

前 5 次执行:custom plan(使用实际参数值生成计划)
第 6 次起:比较 custom plan 和 generic plan 的成本
  - 如果 generic plan 成本 ≤ 平均 custom plan 成本 × 1.1
    → 使用 generic plan
  - 否则 → 继续使用 custom plan

这个策略在大多数场景下工作良好,但当参数值的数据分布差异很大时,generic plan 可能对某些参数值严重不适用(如前文的 status 例子)。

Java 中的 Prepared Statement 最佳实践

// 推荐:使用 PreparedStatement
public List<Order> findByUserId(Connection conn, long userId) throws SQLException {
    String sql = "SELECT id, amount, status, created_at " +
                 "FROM orders WHERE user_id = ? AND status = ? " +
                 "ORDER BY created_at DESC LIMIT ?";

    try (PreparedStatement pstmt = conn.prepareStatement(sql)) {
        pstmt.setLong(1, userId);
        pstmt.setString(2, "paid");
        pstmt.setInt(3, 20);

        try (ResultSet rs = pstmt.executeQuery()) {
            List<Order> orders = new ArrayList<>();
            while (rs.next()) {
                orders.add(mapRow(rs));
            }
            return orders;
        }
    }
}

// 避免:字符串拼接(SQL 注入风险 + 无法复用计划)
public List<Order> findByUserIdBad(Connection conn, long userId) throws SQLException {
    String sql = "SELECT * FROM orders WHERE user_id = " + userId; // 危险!
    // ...
}

Go 中的 Prepared Statement

func findOrdersByUserID(ctx context.Context, db *sql.DB, userID int64) ([]Order, error) {
    query := `SELECT id, amount, status, created_at
              FROM orders
              WHERE user_id = $1 AND status = $2
              ORDER BY created_at DESC
              LIMIT $3`

    rows, err := db.QueryContext(ctx, query, userID, "paid", 20)
    if err != nil {
        return nil, fmt.Errorf("query orders: %w", err)
    }
    defer rows.Close()

    var orders []Order
    for rows.Next() {
        var o Order
        if err := rows.Scan(&o.ID, &o.Amount, &o.Status, &o.CreatedAt); err != nil {
            return nil, fmt.Errorf("scan order: %w", err)
        }
        orders = append(orders, o)
    }
    return orders, rows.Err()
}

Go 的 database/sql 包在内部自动管理 Prepared Statement 的缓存。调用 db.QueryContext 时,驱动程序会根据连接和 SQL 文本复用已有的 Prepared Statement。

九、N+1 查询问题与批量操作

N+1 问题的本质

N+1 查询是 ORM 场景下最常见的性能反模式。其本质是将一次批量操作拆解为 N 次单独操作,每次操作都承担完整的网络往返和查询解析开销。

1 次查询获取 100 个订单
+ 100 次查询分别获取每个订单的用户信息
= 101 次数据库往返(N+1)

网络往返成本:101 × 0.5ms = 50.5ms
vs 2 次查询:2 × 0.5ms = 1ms

解决方案一:JOIN 查询

-- N+1 方式
SELECT * FROM orders WHERE created_at > '2026-01-01';  -- 返回 100 行
SELECT * FROM users WHERE id = 1001;  -- 第 1 次
SELECT * FROM users WHERE id = 1002;  -- 第 2 次
-- ... 重复 100 次

-- JOIN 方式:1 次查询
SELECT o.id, o.amount, o.status, o.created_at, u.name, u.email
FROM orders o
JOIN users u ON o.user_id = u.id
WHERE o.created_at > '2026-01-01';

解决方案二:IN 批量查询

-- 先获取订单
SELECT * FROM orders WHERE created_at > '2026-01-01';

-- 收集所有 user_id,一次性查询
SELECT * FROM users WHERE id IN (1001, 1002, 1003, ..., 1100);

Go 实现:

func findOrdersWithUsers(ctx context.Context, db *sql.DB, since time.Time) ([]OrderWithUser, error) {
    // 第一步:查询订单
    orderQuery := `SELECT id, user_id, amount, status, created_at
                   FROM orders WHERE created_at > $1`
    rows, err := db.QueryContext(ctx, orderQuery, since)
    if err != nil {
        return nil, err
    }
    defer rows.Close()

    var orders []Order
    userIDs := make(map[int64]struct{})
    for rows.Next() {
        var o Order
        if err := rows.Scan(&o.ID, &o.UserID, &o.Amount, &o.Status, &o.CreatedAt); err != nil {
            return nil, err
        }
        orders = append(orders, o)
        userIDs[o.UserID] = struct{}{}
    }
    if err := rows.Err(); err != nil {
        return nil, err
    }

    // 第二步:收集所有 user_id,批量查询用户
    ids := make([]int64, 0, len(userIDs))
    for id := range userIDs {
        ids = append(ids, id)
    }
    userMap, err := findUsersByIDs(ctx, db, ids)
    if err != nil {
        return nil, err
    }

    // 第三步:组装结果
    results := make([]OrderWithUser, 0, len(orders))
    for _, o := range orders {
        results = append(results, OrderWithUser{Order: o, User: userMap[o.UserID]})
    }
    return results, nil
}

解决方案三:批量插入与更新

单条插入 vs 批量插入的性能差距巨大:

-- 单条插入:1000 次网络往返 + 1000 次事务提交
INSERT INTO order_items (order_id, product_id, qty) VALUES (1, 101, 2);
INSERT INTO order_items (order_id, product_id, qty) VALUES (1, 102, 1);
-- ... 重复 1000 次

-- 批量插入:1 次网络往返(MySQL)
INSERT INTO order_items (order_id, product_id, qty) VALUES
    (1, 101, 2),
    (1, 102, 1),
    (1, 103, 3),
    -- ... 
    (1, 1100, 1);

-- PostgreSQL COPY 命令:最高效的批量导入
COPY order_items (order_id, product_id, qty)
FROM STDIN WITH (FORMAT csv);

Java 批量插入(JDBC Batch):

public void batchInsertItems(Connection conn, List<OrderItem> items) throws SQLException {
    String sql = "INSERT INTO order_items (order_id, product_id, qty) VALUES (?, ?, ?)";

    try (PreparedStatement pstmt = conn.prepareStatement(sql)) {
        conn.setAutoCommit(false);
        int count = 0;

        for (OrderItem item : items) {
            pstmt.setLong(1, item.getOrderId());
            pstmt.setLong(2, item.getProductId());
            pstmt.setInt(3, item.getQuantity());
            pstmt.addBatch();

            if (++count % 1000 == 0) {
                pstmt.executeBatch();
            }
        }
        pstmt.executeBatch();
        conn.commit();
    }
}

批量操作的注意事项

维度 建议 原因
批次大小 500-5000 行/批 太小则网络往返多,太大则占用内存和长事务风险
事务大小 每 1000-10000 行提交一次 避免 undo log / WAL 膨胀
索引维护 大批量导入前考虑先删索引,导入后重建 B+树插入时需要页分裂,批量时开销显著
约束检查 批量导入时可临时关闭外键检查 减少每行的约束验证开销
并发控制 批量操作使用单独连接,设置合理超时 避免阻塞在线事务

十、工程案例:电商订单系统的数据库性能优化

背景

某中型电商平台,日均订单量 50 万,订单表 orders 累计 8000 万行。系统在一次促销活动中出现了严重的性能问题:

  • 订单列表接口 P99 延迟从 45ms 上升到 4200ms
  • 数据库 CPU 使用率持续 95% 以上
  • 应用日志出现大量 Connection pool exhausted 错误

第一步:慢查询定位

-- PostgreSQL 启用 pg_stat_statements
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;

-- 查找最耗时的查询
SELECT
    query,
    calls,
    round(total_exec_time::numeric, 2) AS total_time_ms,
    round(mean_exec_time::numeric, 2) AS mean_time_ms,
    round(stddev_exec_time::numeric, 2) AS stddev_ms,
    rows
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 10;

发现排名第一的慢查询:

SELECT o.*, u.name, u.phone
FROM orders o
LEFT JOIN users u ON o.user_id = u.id
WHERE o.merchant_id = $1
  AND o.status = $2
  AND o.created_at BETWEEN $3 AND $4
ORDER BY o.created_at DESC
LIMIT 20 OFFSET $5;

这条查询的问题:

  1. merchant_id + status + created_at 没有合适的复合索引
  2. 使用 OFFSET 进行分页,深翻页时性能急剧下降
  3. SELECT * 导致无法使用覆盖索引

第二步:索引优化

-- 创建复合索引,列顺序按查询模式设计
-- merchant_id: 等值查询(高选择性)
-- status: 等值查询(低选择性,但与 merchant_id 组合后选择性提升)
-- created_at: 范围查询 + 排序
CREATE INDEX CONCURRENTLY idx_orders_merchant_status_created
ON orders(merchant_id, status, created_at DESC);

-- 验证索引效果
EXPLAIN ANALYZE
SELECT o.id, o.amount, o.status, o.created_at, u.name, u.phone
FROM orders o
LEFT JOIN users u ON o.user_id = u.id
WHERE o.merchant_id = 10086
  AND o.status = 'paid'
  AND o.created_at BETWEEN '2026-04-01' AND '2026-04-13'
ORDER BY o.created_at DESC
LIMIT 20;

优化前后对比:

优化前:Seq Scan on orders → 全表扫描 8000 万行,耗时 3800ms
优化后:Index Scan using idx_orders_merchant_status_created → 扫描 42 行,耗时 0.8ms

第三步:分页优化

OFFSET 分页改为游标分页(Cursor-based Pagination):

-- 传统 OFFSET 分页(第 100 页)
SELECT * FROM orders
WHERE merchant_id = 10086 AND status = 'paid'
ORDER BY created_at DESC
LIMIT 20 OFFSET 1980;
-- 需要扫描 2000 行,丢弃前 1980 行

-- 游标分页:使用上一页最后一条记录的 created_at 作为游标
SELECT * FROM orders
WHERE merchant_id = 10086
  AND status = 'paid'
  AND created_at < '2026-04-10 15:30:00'  -- 上一页最后一条的 created_at
ORDER BY created_at DESC
LIMIT 20;
-- 直接从索引定位,只扫描 20 行

第四步:连接池调优

应用实例数:20 个,每个实例连接池 maxPoolSize = 50。总连接需求:20 × 50 = 1000,远超数据库 max_connections = 200

根据 Little 定律重新计算:

QPS = 3000(峰值)
平均查询耗时 = 2ms(索引优化后)
L = 3000 × 0.002 = 6(理论需要 6 个连接)

加上安全余量:每实例 maxPoolSize = 10
总连接数 = 20 × 10 = 200 = max_connections

预留管理连接:将 max_connections 调整为 210

优化效果

指标 优化前 优化后 提升
P99 延迟 4200ms 12ms 350×
数据库 CPU 95% 25% 3.8×
连接池等待 频繁超时 无等待
单次查询扫描行数 8000 万 42 190 万×

十一、数据库性能监控体系

建立持续的监控体系比一次性优化更重要。

查询层与索引监控

-- PostgreSQL:慢查询日志配置
ALTER SYSTEM SET log_min_duration_statement = 100;  -- 记录超过 100ms 的查询
SELECT pg_reload_conf();

-- PostgreSQL:检测未使用的索引
SELECT
    schemaname || '.' || relname AS table_name,
    indexrelname AS index_name,
    pg_size_pretty(pg_relation_size(indexrelid)) AS index_size,
    idx_scan AS index_scans
FROM pg_stat_user_indexes
WHERE idx_scan = 0
  AND indexrelid NOT IN (
      SELECT conindid FROM pg_constraint WHERE contype = 'p'
  )
ORDER BY pg_relation_size(indexrelid) DESC;

-- PostgreSQL:检测缺失索引(高频顺序扫描)
SELECT
    schemaname || '.' || relname AS table_name,
    seq_scan,
    idx_scan,
    CASE WHEN seq_scan > 0
        THEN round(seq_tup_read::numeric / seq_scan, 0)
        ELSE 0
    END AS avg_rows_per_seq_scan
FROM pg_stat_user_tables
WHERE seq_scan > 100
  AND seq_tup_read / greatest(seq_scan, 1) > 10000
ORDER BY seq_tup_read DESC
LIMIT 20;

监控告警阈值

指标 警告阈值 严重阈值 说明
P99 查询延迟 > 100ms > 500ms 单次查询耗时
连接池等待线程 > 0 > poolSize × 0.5 池大小可能不足
连接池超时次数 > 0/min > 10/min 必须立即扩容或优化
表顺序扫描占比 > 30% > 60% 可能缺少必要索引
死元组比例 > 10% > 30% 需要 VACUUM
缓存命中率 < 99% < 95% 需要增加 shared_buffers

十二、总结

数据库性能优化不是零散的技巧集合,而是一个涉及存储引擎、优化器、连接管理三个层次的系统工程。

存储层:理解 B+树的 I/O 成本模型是索引设计的基础。扇出率决定树高,树高决定查找的 I/O 次数。覆盖索引通过消除回表将随机 I/O 降到最低。复合索引的列顺序需要根据实际查询模式精心设计。

优化器层:基于成本的优化器依赖统计信息做决策。统计信息过期、数据倾斜、列相关性都可能导致优化器选错执行计划。通过 EXPLAIN ANALYZE 比较估算行数与实际行数是诊断问题的第一步。

连接管理层:连接池不是越大越好。Little 定律为连接池大小提供了理论依据。应用实例数 × 连接池大小必须小于数据库的 max_connections。预编译语句在安全性和性能上都应该是默认选择。

应用层:N+1 查询是最容易被忽视也最容易修复的性能问题。批量操作在网络往返、事务开销和索引维护三个维度都优于逐行操作。

这些层次之间相互影响。索引设计影响优化器的计划选择,优化器的错误决策导致慢查询,慢查询耗尽连接池,连接池耗尽导致整个服务不可用。性能优化的目标不是让每个查询都跑到极致,而是建立一个可观测、可预测、可持续运行的系统。

下一篇将讨论负载测试——在生产环境出问题之前,如何系统性地发现性能瓶颈。


导航

上一篇:零拷贝

下一篇:负载测试


参考资料

书籍

  • Markus Winand, SQL Performance Explained, 2012. 从索引结构到执行计划的系统性讲解,附带可视化网站 use-the-index-luke.com。
  • Baron Schwartz et al., High Performance MySQL, 4th Edition, O’Reilly, 2021. MySQL 性能优化的权威参考。
  • Gregory Smith, PostgreSQL 14 Internals, Postgres Professional, 2023. 深入 PostgreSQL 内核实现,包括查询优化器和缓存管理。

论文与技术报告

  • Goetz Graefe, “Modern B-Tree Techniques”, Foundations and Trends in Databases, 3(4):203-402, 2011. B 树家族的全面综述,涵盖并发控制、日志恢复和现代变体。
  • Viktor Leis et al., “How Good Are Query Optimizers, Really?”, PVLDB, 9(3):204-215, 2015. 对主流数据库优化器基数估算准确性的系统性评测。

官方文档

  • PostgreSQL Documentation, “Chapter 14. Performance Tips”, postgresql.org/docs/current/performance-tips.html. PostgreSQL 性能调优官方指南,包括 EXPLAIN 用法和统计信息管理。
  • MySQL Documentation, “Chapter 8. Optimization”, dev.mysql.com/doc/refman/8.0/en/optimization.html. MySQL 优化参考,覆盖索引、查询优化器和服务器配置。
  • HikariCP Wiki, “About Pool Sizing”, github.com/brettwooldridge/HikariCP/wiki/About-Pool-Sizing. HikariCP 连接池大小计算的原始文档,包含 PostgreSQL 基准测试数据。

博客与在线资源

  • Markus Winand, “Use The Index, Luke”, use-the-index-luke.com. 交互式 B+树可视化与索引教程。
  • Brandur Leach, “Connections Are Not Free”, brandur.org/connections. PostgreSQL 连接的真实成本分析。

工具

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。

2026-04-13 · architecture

【系统架构设计】连接池设计:被忽视的性能杀手

每一次网络请求的背后,都隐藏着建立连接的成本。当应用服务器需要与数据库通信时,一次完整的连接建立过程可能消耗数十毫秒;在高并发场景下,频繁创建和销毁连接会迅速耗尽系统资源,成为整个架构中最容易被忽视的性能瓶颈。连接池(Connection Pool)技术通过预先创建并复用连接,将单次连接获取的时间从毫秒级压缩到微秒级,…

2026-04-13 · architecture

【系统架构设计】什么是软件架构:从代码结构到系统决策

软件架构的定义至少有三种主流理解,每种指向不同的关注点。本文对比 Booch、Fowler、Johnson 三家定义,分析架构决策的不可逆性特征,拆解 Krutchten 4+1 视图模型,回答一个核心问题:架构和设计的边界到底在哪里。

2026-04-13 · architecture

【系统架构设计】架构质量属性:不只是"高可用高性能"

需求评审时写下的'高可用、高性能、高并发',到了架构设计阶段几乎无法落地——因为它们不是可执行的需求。本文从 SEI/CMU 的质量属性理论出发,用 stimulus-response 场景模型把模糊需求变成可量化、可验证的架构约束,并拆解属性之间的冲突与联动关系。

2026-04-13 · architecture

【系统架构设计】架构决策与 ADR:如何做出可追溯的技术决策

口头约定的架构决策会在人员流动中丢失,会在争论中反复翻车。ADR(Architecture Decision Records)用一种轻量的文档格式,把每一个关键技术决策的背景、选项、理由和代价写下来,跟着代码一起版本管理。本文从 ADR 的三种主流格式讲到 Git 仓库中的实操管理,再拆解 Spotify 和 Uber 的工业实践。