公司某个项目上线后我们发现服务在某些时段会比较慢,打开监控看了下指标,在这个时段:

  • CPU: 96%
  • IO Wait: 持续偏高
  • Disk Read: 从平时 50次/s 涨到 3000次/s

100 并发就把数据库打到这个状态,不太正常。看这几个指标的组合,CPU 和 I/O 同时上涨,磁盘读取量激增,很可能是某个查询在做 Sequential Scan(全表扫描)。某个高频查询没走索引,正在一遍遍翻遍整张表。

找到元凶

打开 GCP Cloud SQL 的 Query Insights,按 Total Load 排序,一条 SQL 占了 80% 的负载:

SELECT
  id, employee_id, profile_snapshot, created_at, ...
FROM
  employee_snapshot_table
WHERE
  created_at >= $1::TIMESTAMP WITHOUT TIME ZONE
ORDER BY
  created_at DESC;

看起来很普通——按时间筛选、排序,典型的列表页查询。但就是它,正在杀死数据库。

跑一次 EXPLAIN (ANALYZE, BUFFERS) 验尸:

->  Parallel Seq Scan on employee_snapshot_table
      Filter: (created_at >= '2024-01-01'::timestamp)
      Buffers: shared hit=100 read=2415
->  Sort Method: external merge  Disk: 6848kB
      Sort Key: created_at DESC

三个致命信号:

  1. Parallel Seq Scan — 全表扫描,开了多线程也救不了
  2. external merge Disk: 6848kB — 内存放不下,数据被写到硬盘排序(性能杀手)
  3. Buffers: read=2415 — 单次查询读 19MB 数据,100 并发 = 每秒 1.9GB 磁盘读取

数据库在用硬盘干内存的活儿,能不崩吗?

加索引,起死回生

问题清楚了:created_at 既做筛选又做排序,但没有索引

生产环境,不能锁表,用 CONCURRENTLY 非阻塞创建:

CREATE INDEX CONCURRENTLY idx_snapshot_created_at 
ON employee_snapshot_table (created_at DESC);

索引建完,再跑一次压测,世界安静了。

指标BeforeAfter提升
查询方式全表扫描 + 硬盘排序Index Backward Scan算法级优化
单次耗时177 ms< 1 ms100倍+
磁盘读取2415 Blocks4 Blocks↓99.8%
CPU 负载96% (多线程)< 5% (单线程)彻底释放

Sort 节点消失了——索引本身就是有序的,不用排序。
Seq Scan 变成 Index Scan——不用翻遍全表,直接定位。

为什么会犯这个错?

复盘的时候发现,开发者写代码时关注的是功能能不能跑通,对性能方面的考虑不够仔细。这次故障暴露出我们在数据库设计上缺少统一的思考框架。

于是在 Code Review 环节,我们给团队列了一套规则:凡是涉及数据库的改动,都要对照下面的原则,思考讨论。

默认要加的三类索引

建表时就该考虑:

  1. Primary Key — 数据库会自动创建
  2. Unique Key — 像 emailusername 这类需要快速查找和去重的字段

需要预判的两类场景

  1. 列表页的排序与筛选

    • ORDER BY 用了哪个字段?考虑加索引(比如这次的 created_at
    • WHERE 条件查哪些字段?也要考虑(比如 statustype
  2. 组合查询 (Composite Index)

    • 如果查询总是 WHERE A = 1 AND B = 2 这样的组合条件,应该加 (A, B) 联合索引,而不是分别给 A 和 B 加

避坑指南

不要给低区分度字段加索引
gender (男/女)、is_deleted (true/false),加了也没用,只会浪费空间。

善用 Partial Index
如果表中 90% 是逻辑删除的数据,只需给”活着”的数据加索引:

CREATE INDEX idx_active_users ON users(email) WHERE deleted_at IS NULL;

写在最后

索引不是让查询变快,而是防止数据库被高并发 I/O 击垮,写 SQL 时,多要问问自己:数据库是在全表扫,还是在查目录(走索引), 另外养成 EXPLAIN 的习惯,防范于未然。