1. 复现问题
慢查询的出现是常态还是偶尔?是否在业务允许范围内?
"不要过早优化,先 Make it work / right,再 Make it fast。"
建议先将查询语句及其触发条件记录下来,便于后续测试、分析和对比。
2. 定位问题
2.1 单机数据库: explain查询执行计划
数据库默认优化后的执行计划是否使用了合适的索引、是否走了全表扫描、排序是否使用了临时表等。然后我们可以进行一些手动优化。
2.2 分布式场景: 尾部延迟放大
任务分片后,部分节点可能由于数据倾斜、垃圾收集、网络丢包等各种原因导致“尾部慢任务”,拖累整体响应时间。这也要结合业务场景进行适当优化。DDIA中第三章有详细介绍。
3. 索引
索引未命中是常见的慢查询原因,当然前提是得有个索引。如果数据库还没有索引,就赶紧建立一个来加速读取吧。
3.1 加索引
索引背后的大致思想是:通过保存一下额外的“路标”,从而帮助你加速找到想要的数据。
索引是额外衍生的数据,它不会干扰数据系统本身的内容,只会影响查询的性能。
然鹅,索引不是越多越好。维护这些索引数据会产生额外的开销,尤其是在写入的时候。如果维护了多份索引,原本仅需一次对主数据的写入,就需要额外再附加多个对于索引的维护操作,造成了写放大,可能会大大影响性能。
另外,我们有各种数据结构可用来保存索引数据,如哈希表、B树族、跳表、红黑树等。尽管做业务的时候大概不需要我们去实现某种索引或是存储引擎,但是了解我们的业务场景与不同数据结构的优劣,并选取其中最合适的那个是相当重要的。
3.2 索引未命中场景与解决方案
LIKE '%xxx' 左或左右模糊匹配 → 使用 全文索引 或 建立倒排索引机制
函数包裹字段,如WHERE DATE(create_time) = '2024-01-01' → 使用 函数索引 或 冗余字段
复合索引未命中 → 遵循 最左前缀匹配原则
4. 业务场景:OLTP or OLAP?
OLTP(OnLine Transaction Processing,联机事务处理)和 OLAP(OnLine Analytical Processing,联机分析处理)是两类典型的数据处理场景:
OLTP:高并发、低延迟,面向单条或少量记录的精准读写(如用户下单、更新库存)。
OLAP:低并发、大吞吐,面向大批量数据的复杂分析查询(如销售趋势分析、用户画像)。
不同的业务场景会对数据库的存储方式提出不同需求,尤其是在 Join 操作频繁出现的 OLAP 场景下。
4.1 行存储(Row-Oriented Storage)
[1, "Alice", 20]|[2, "Bob", 21]
行存储中,数据库以整行为单位进行物理存储:同一行的所有字段在磁盘上是连续存放的。这使行存储方式很适合适合 OLTP:单行数据的读取与写入非常高效(信息局部性),符合“按主键查找”的常见模式。
然而,行存储的优势在大数据分析场景下却变成了劣势: 访问任意列时,都必须读取整行数据,这对于只需要部分列的分析型查询来说是一种浪费。比如行存储导致Join操作前不得不将整行加载进内存,即使只依赖部分字段作为连接条件。
4.2 列存储(Column-Oriented Storage)
[1|2],["Alice"|"Bob"],[20|21]
列存储就是为了解决上述痛点而产生,列存储将每一列单独存储为一段连续的数据。这使得它非常适合如下场景:
只查询部分字段,如 SELECT name FROM students;
列上的过滤操作,如 WHERE age > 30;
OLAP 场景下的大规模聚合和扫描;
Join 操作前只需读取连接键列,极大降低 I/O 成本。
在query执行内部阶段,列存储可以大大减少磁盘I/O,只读取参与查询的字段,避免读取非必需的数据,类似于某种“投影下推”。
而在query输出阶段,如果需要其他字段,可以通过如字段相对位置等方式来获取到同元组的其他字段。
当然,列存储也有缺点:比如当我们需要写入一行时,每个列page都需要被写入,这显然又增加了磁盘I/O,导致了写入放大,不适合OLTP场景。
4.3 混合存储 (Hybrid Storage)
计算机系统各部件(包括其子部件)的实现方案似乎都存在不同程度的权衡与取舍,“我全都要”似乎是工程师们最常见也最实用的策略:从段页式内存管理,到多层级Cache-RAM-SSD-HDD混合存储体系,再到数据库中的混合存储。
它试图融合 行存储(Row-Oriented)与列存储(Column-Oriented) 的优势。其核心理念是:
热数据行存,冷数据列存
—— 对频繁更新的数据采用行存储格式,对只读或历史数据转为列存储格式,提高分析性能。
具体也有各种实现方式吧,就不详细展开了。
4.4 冗余字段 / 反规范化 (Denormalization)
那可能有的朋友会说: "我没办法改数据库存储引擎啊,有没有行存储也能加速的方案?"。
有的兄弟,有的!
数据库设计中规范化(Normalization)指导着我们要削减冗余,能不重复存储的字段就不重复存储,而是拆成其他表,有需要时再Join起来。
虽然说Hash_Join等Join算法确实很快,但是大数据或业务要求下,我们可以尝试空间换时间,在表中存储一些冗余字段来避免大量Join操作。这就是反规范化(Denormalization)。
它适用于读多写少、Join操作很频繁、能容忍短时非一致性的业务场景。
4.5 OLTP中Online的含义
“Online” 指的是用户操作能实时生效,系统立刻响应,区别于早期那种离线批处理系统(Batch Processing)。
批处理:一天处理一次,延迟高但效率高
Online:实时响应,适合交互式应用,比如下单、转账、修改信息等
5. 读写分离
从一个数据库进行所有的读写操作,现在把它拆分成一个主库、两个从库,并且读写分离。这么做的好处有几点:存储容量增加了,有了备份,使得安全性增加了,读写分离使得读写效率得以提升(写要比读更加消耗资源,分开后互不干扰) —— 《淘宝技术这十年》
读写分离的好处很多,但是也带来了不一致性的问题,这是分布式的经典困境:
分布式追求高性能 -> 多服务器上Shard数据 -> 用复制方案实现Shard -> 一致性问题 -> 强一致性要求会导致低性能
因此还是需要结合业务场景来决定主从复制的同步细节问题。(实际是笔者菜了,写不来)
6. 连接池
数据库连接是昂贵资源,频繁地建立与释放会造成大量性能浪费。连接池的作用就是:创建/复用一批已打开的数据库连接,避免每次都重新连接数据库。
除此之外,连接池还可以限制连接数量上限。避免同时间连接数太多,数据库处理不过来,拖慢整体响应时间。
同理,线程池或者xx池也是类似的,都是为了复用连接 + 限流保护。
7. 分库分表
分库:单台数据库“生命不可承受之重”,需要把数据表拆到多个数据库实例上(通常部署在不同机器)
分表:某个单表中包含了大量数据,读写压力很大,需要拆分。
分库分表主要有两大问题:资源定位 + 跨库/表操作
7.1 资源拆分与定位
资源的拆分通常有两种:
按范围拆分(Range Sharding):用户ID小于100万的进user_1表,100万~200万进user_2表
按哈希拆分(Hash Sharding):user_id % 4 分到4张表
但是hash扩容时会导致大量数据的迁移,因此产生了一致性哈希 来避免该现象。
当上层query来了之后,我们的可以根据当前的资源拆分方式,将query发给持有该资源的服务器,即资源定位。
7.2 跨库/表操作
当资源分散在各数据库与数据表中,单台数据库是无法完成数据的Join与聚合等操作的。因此我们还需要加个抽象层,来个Middleware。
它就可以负责7.1的资源定位工作,还可以将结果获取到自身内存中,并在其内部执行Join与聚合等操作,再返回给上层。
8. Redis结果缓存
数据库查询慢,那就用缓存挡一挡,就如同:TLB 之于 页表,CPU Cache 之于 内存,浏览器http缓存 之于 应用服务器。
缓存的核心目标只有一个:用更小、更快的存储,拦住大部分热点,减少下层系统压力。
在Redis - 数据库缓冲机制中,除了仅仅缓存热点数据,我们甚至可以缓存热点query,只要有必要。
之于Redis-数据库的数据流向与一致性保证,其他博客已经讲得很好,这里就不赘述了。