索引、查询计划与数据演进:先看访问方式,再设计表
同一个查询在一百行数据时很快,到了几百万行就开始拖慢页面。添加索引可能有帮助,但索引并不是“给每列加一份就会快”:它占空间,写入时要维护,也可能根本没被查询使用。
1. 索引提供另一条访问路径
可以把 B-tree 索引理解成按键组织的查找结构。数据库利用它定位候选记录,再按需要读取表数据。它的价值取决于筛选性、排序需求、返回行数和统计信息;返回大部分表时,顺序扫描未必差。
假设订单页经常查询某租户最近的订单,可以围绕 tenant、创建时间和唯一 ID 设计组合索引,而不是只为每一列分别建立索引。
-- 已有 orders(tenant_id, id, created_at, total_minor) 表时使用。
CREATE INDEX orders_tenant_time_id
ON orders (tenant_id, created_at DESC, id DESC);
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, created_at, total_minor
FROM orders
WHERE tenant_id = 'demo'
ORDER BY created_at DESC, id DESC
LIMIT 20;
列顺序要与具体过滤、排序和数据分布结合。PostgreSQL 18 的 B-tree 在合适条件下还可能采用 skip scan,因此“不含最左列就绝对用不上索引”不是准确的通用规则。是否划算由查询计划和实际测量回答。
2. 怎样读执行计划
先看估计行数和实际行数差多少,再看扫描方式、连接方式、排序、缓冲命中和临时文件。估计严重失真可能来自统计信息或数据相关性;不应看到 Seq Scan 就认定有问题。
EXPLAIN ANALYZE 会真实执行语句。对 INSERT、UPDATE、DELETE 尤其要谨慎,即使外包事务回滚,触发器对外部系统的副作用也未必被撤销。学习时在可重建的测试数据上测量。
3. N+1 查询怎样发生
先查询 20 个订单,然后循环对每个订单再查询用户信息,就得到 1+20 次往返。ORM 的懒加载会让这种行为藏在属性访问后面。改善方式包括合适的 JOIN、批量查询和明确的投影,但一次把大量关联全部 JOIN 起来又可能放大行数。
记录每个请求的 SQL 数量和总耗时,比只测一个 SQL 更贴近页面体验。分页、批量大小和字段选择也要明确,避免“查询所有再在内存切片”。
4. 规范化与读模型
规范化减少某类重复和更新异常。例如订单行保存商品 ID,同时保存成交单价快照,后者是历史事实,不是可以随时由当前商品价格替代的冗余。设计时先说明字段的业务时间语义,再讨论重复是否合理。
为读性能增加汇总表或搜索投影,可以接受一定重复,但要定义谁是事实来源、如何重建、延迟多久、失败如何修复。不要让两套存储都自认为最终权威。
5. 安全地演进表结构
新增字段可先允许旧程序继续运行,再部署写入和读取逻辑,分批回填,最后收紧约束或删除旧字段。这是 expand-and-contract 思路。回填要限速、可重复、可中断,避免长事务锁住线上业务。
具体 DDL 是否锁表、是否重写表、能否并发建立索引,按数据库和版本确认。表很大时,开发库几毫秒完成的变更并不能证明生产可直接执行。
练习与参考
生成不同规模和不同租户分布的数据,比较有无组合索引时的查询计划。记录索引空间和写入成本,再决定是否保留。给一个新增必填字段写出新旧程序共存时的完整发布顺序。