城南图书馆的管理员小陈,守着十二面墙的借阅卡。每张卡对应一本书,记着书名、作者姓氏、出版年份和书架位置。读者报一个需求,小陈就得到卡片墙里翻。一开始卡片乱挂,找一本《MySQL 修炼之道》要翻几百张,读者排起长队;把卡片按书名排好后,报书名的查询瞬间完成,可读者又改问“姓王的人 2007 年出版的书”。他这才意识到:卡片怎么排、按哪些字段排、谁在前谁在后,决定了找书到底快不快。数据库里的索引选择性,就是卡片对目标的区分能力;联合索引设计,则是给卡片设计一套既实用又省力的多级排序规则。这篇文章,就跟着小陈一起把这面卡片墙重新排一遍——学会用选择性这把尺子,为高频查询设计出高性价比的联合索引。
掌握选择性计算与最左前缀设计原则,你就能从“会建索引”升级为“会设计索引”,让复杂查询从秒级降到毫秒级——这是后端开发与 DBA 进阶的核心技能。
可视化演示
展示索引选择性计算、联合索引B+树最左前缀匹配及列顺序设计策略对查询效率的影响。
本节配有交互动效(进入视口后自动加载,约 3KB)。
索引选择性 选择性=区分度/总行数越高越好 邮箱列95%区分度适合做索引 性别列仅2值选择性极低不推荐
选择性概念
小陈第一次优化卡片墙时,把卡片按「书名」排。每本书名不同,读者报一个书名,他顺着字母一卡定位,几乎不用看第二张。后来读者改问“有没有姓王的作者写的书”,他只能按「作者姓氏」翻:姓王的有 40 张卡,还是要翻一阵。他发现,同样是排卡片,有些信息一张卡就能区分到底,有些信息会把好多张卡归在一起。
这个“区分到底”的能力,在数据库里叫索引选择性。它回答一个问题:当查询以这一列为条件时,索引能把它快速缩小到多小的范围。定义很朴素:这一列有多少种不同的取值,占整张表行数的比例。取值种类越多、重复越少,选择性越高;反之,一列若只有两三种取值,选择性就低,过滤效果形同虚设。
小陈很快就明白:书名卡的区分能力接近满分,而作者姓氏卡只有一半。他决定把“区分能力强”的字段作为排序的第一依据——但这只是第一步,因为读者往往要同时报出多个条件。选择性本身不决定一切,但它决定了每一刀下去能砍掉多少无关卡片。
- 分母 N(总卡片数)$N = \text{COUNT}(*)$,统计整张表有多少行记录。在小陈的例子里,它就是卡片墙上的卡片总数。
- 分子 D(不同取值数)$D = \text{COUNT}(\text{DISTINCT}\ x)$,数一数列 $x$ 一共出现过多少种不同的值。书名列几乎一本书一个值,$D$ 接近 $N$;作者姓氏列只有几个姓,$D$ 很小。
- 比值 S(区分能力)$S = D / N$。$S$ 越大,每个取值对应的行数越少,一次定位越准。
选择性计算
为了让决策不靠感觉,小陈做了一张统计表。他数了数:12 张演示卡片里,作者姓氏只有 5 种(曹、李、王、张、赵),每种姓大约 2 张卡;而出版年份有 10 种,每种大约 1 张卡。显然年份这一列区分能力更强。在数据库里,这个“数一数”的过程就是一条 SQL 统计,把每列的不同取值数除以总行数,就得到该列的选择性。
把统计结果贴在卡片墙旁边,小陈每次设计新的排列规则前,都会先算一算每列的区分度。这个习惯,就是索引设计的第一性原理:先量化,再决策。数值一出来,哪个字段该当“第一排序级”,答案已经写在了墙上。
- 统计姓氏的区分度把 12 行里的姓氏去重,得到 5 种,用
COUNT(DISTINCT 姓氏)一条 SQL 即可完成。 - 统计年份的区分度年份去重后得到 10 种,比姓氏多一倍。
- 分别除以总行数$5 / 12 \approx 0.42$,$10 / 12 \approx 0.83$,数值越高代表过滤后候选集越小。
联合索引结构
单一条件优化完,读者开始组合查询:“我要姓王的、2007 年出版的书”。小陈如果把卡片按姓氏排,姓王仍有 3 张卡,还得在这 3 张里翻年份。他决定改造卡片墙:把「作者姓氏」和「出版年份」做成两级排序——第一级按姓氏,第二级在同一个姓氏内部按年份从小到大排。这样报出“王 + 2007”,他先跳到姓王区间,再在区间内二分定位年份,两步合成一步。
这就是联合索引(复合索引)。它不是一个“包含多个列”的普通记号,而是把所有列拼成一个组合键,按照 $(A, B)$ 的顺序整体排序。B+ 树里任何相邻的组合键都满足:先比 $A$,$A$ 相同再比 $B$。这个“天然有序”的特性,让联合索引不仅能过滤,还能直接支撑 ORDER BY 与范围查询。
小陈的卡片墙从此有了“区—排—架”三级坐标,找书快了一倍。但新问题来了:两级排序确实好,读者却可能跳过第一级直接问“有没有 2007 年出版的书”,这时候这套卡片墙还管用吗?答案,藏在最左前缀原则里。
- 组合键去重计数把 $(A, B)$ 两列当作一个整体去重,数出有多少种不同的组合,它记录的是“两级排序到底产生了多少个互不相同的坐标”。
- 除以总行数得到组合选择性组合越独特,$S_{AB}$ 越接近 1。
- 与单列选择性的关系组合键包含了 A 的全部信息,它至少和 A 一样独特,所以 $S_{AB} \ge S_A$ 且 $S_{AB} \ge S_B$。
最左前缀原则
小陈试了一周发现问题:【姓氏+年份】排好的卡片,读者只报年份时,他完全不知道从哪一段开始翻——因为“年份”是第二排序级,必须知道第一个姓才能进入对的区间。读者只问年份,他就得把 12 张卡全看一遍。这就是联合索引最核心的约束——最左前缀匹配。
反过来,前缀还能复用:有了 $(A,B,C)$,查询只带 $A$ 或只带 $A,B$ 也能命中同一棵索引树,所以不需要再为这些前缀单独建索引。小陈不怪读者,他开始思考另一件事:如果读者最常问的是“年份+姓氏”,当初把卡片按 (年份,姓氏) 排是不是更好?这便引出了字段顺序的策略问题。
- 前缀 = 从左往右连续的列联合索引匹配严格从第一列开始,可以使用前 1 列、前 2 列或全部列,这对应查询条件里提供了前几个等值列。
- 跳列 = 失去有序性跳过 $A$ 直接用 $B$ 或 $C$,等价于在一本先按姓氏后按年份的电话簿里只按年份查找,只能全量扫描。
字段顺序策略
小陈拿两组实验对比:按 (姓氏,年份) 排,报出“姓王”后还剩 3 张卡要翻;按 (年份,姓氏) 排,报出“2007 年”后只剩 1 张卡。原因是年份选择性 0.83 远高于姓氏的 0.42。结论很直观:高选择性的字段放前面,第一刀就能砍掉更多卡片。数据库里同理——联合索引 $(A, B)$ 对 WHERE A = ? AND B = ? 的过滤分两步:先按 $A$ 定位候选,再按 $B$ 缩小,所以 $A$ 的选择性越高,第一步的候选越小,整体越高效。
小陈还定下另一条经验:等值优先于范围。= 条件能精确跳转,>、<、BETWEEN 一旦开始,后面的列就无法再用于索引内的精确定位。所以他把等值列排在前、范围列排在后,两条经验合起来,就是联合索引字段顺序的黄金法则。
- 第一步候选数
WHERE A = ?命中约 $N / D_A$ 行;$D_A$ 越大(选择性越高),第一刀砍掉越多。 - 两步后的候选数继续用 $B$ 过滤后约剩 $N / (D_A \times D_B)$ 行。
- 顺序判断依据把选择性大的列放在 $D_A$ 的位置,第一步基数就小,整体更省扫描。
覆盖索引优化
有的读者只问“这本书在哪个区”,有的读者还要“看下作者和年份”。小陈发现,如果卡片背面顺手记上书的年份,那么回答“有没有 2007 年的书”时,他看卡片就知道答案了,根本不用走到书架去翻实体书——省下了一趟“回架”的路。这个“不用回书架”的优化,在数据库里叫覆盖索引。
与覆盖索引搭配的还有索引下推(ICP):把 WHERE 里能判断的条件推到存储引擎的索引层提前过滤,减少回表行数,相当于小陈在卡片墙前就替读者排除掉不符合年份的卡片,而不必先跑一趟书架再回来。小陈尝到了“用索引空间换查询时间”的甜头,也意识到——凡是好事都有代价。
- 命中行数 N$N_{\text{命中}}$ 是索引过滤后剩余的行数,也是回表的次数。
- 单次回表成本$C_{\text{回表}}$ 是一次按主键随机读数据的成本,远高于顺序读索引页。
- 总回表代价命中越少、回表次数越少越好;覆盖索引直接把该项清零。
设计权衡
小陈的卡片墙越来越多:按书名排、按姓氏+年份排、按年份+姓氏排。读者找书快了,但他发现每天上架新书时,要把每张新卡写进每一面墙,写卡工作量成倍增加。偶尔还会因为哪面墙没同步,出现“卡片找得到、书架找不到”的尴尬。这正是索引设计的核心权衡:读优化与写放大。
半年后,小陈的卡片墙稳定成了三面,每面都服务一批高频问题,新书上架节奏也恢复了。他总结出挂在墙上的三条法则:选择性决定过滤强度、最左前缀决定能服务哪些查询、真实负载决定到底建几个索引。这面墙,就是数据库索引设计最好的寓言。
- 主表写入成本$C_{\text{行}}$ 是写入主表一条记录本身的开销,无论如何都省不掉。
- 每个索引的更新成本每维护一个索引都要付出 $C_{\text{索引}}$,可能触发页分裂、排序调整。
- 写放大的含义索引个数 $K$ 越大,写入越慢。应通过前缀复用让一个索引服务多个查询,控制冗余索引。
AI 应用场景
电商订单中心查询优化
阶段:数据存储与检索优化
解决的问题:订单表几千万行,订单列表页需要 WHERE user_id = ? AND status = ? ORDER BY created_at DESC。没有联合索引时,user_id 过滤后仍可能剩几千行参与排序,接口经常超时。AI 分析慢查询日志后,按选择性计算:user_id 选择性接近 1、status 仅 0.2、created_at 接近 1,最终设计联合索引 (user_id, created_at) 并让 status 走索引下推,一个索引同时服务“只看某用户”和“某用户某状态”两类查询。
具体案例:某电商大促后慢查询 TOP1 是订单列表页,SQL 扫描 80 万行耗时 1.8 秒。DBA 按“高选择性在前、等值优先范围”重排字段为 (user_id, created_at, status),扫描行数骤降到 200 行,耗时降为 8 毫秒,P99 从 2.4 秒降到 60 毫秒。
日志检索平台查询提速
阶段:数据管道与监控告警优化
解决的问题:日志表每天新增数亿行,检索条件是 level = 'ERROR' AND app_id = ? AND ts > ?。level 只有 5 个取值、选择性低,直觉上不该放第一列;但 90% 的查询都带 level 等值条件,将其放在首位配合 app_id 等值与 ts 范围,恰好契合最左前缀,且 ERROR 判断在索引层下推完成,大幅减少回表。
具体案例:某监控平台用 AI 聚类历史查询模式,发现高频查询集中在“级别 + 应用 + 时间区间”三元组,于是引导建 (level, app_id, ts) 联合索引,重放压测后 P99 延迟从 620 毫秒降到 95 毫秒,索引数量反而从 9 个减到 4 个。
用户画像特征宽表设计
阶段:特征存储与训练数据准备
解决的问题:特征宽表有 500 列,AI 模型训练要按 (user_id, feature_date) 拉取全量特征。性别、城市等标签列单独看选择性极低,每列建索引会带来巨大写放大。正确做法是先计算多列组合选择性,确认 (user_id, feature_date) 组合几乎唯一后,只建这一个联合索引。
具体案例:某推荐系统特征表,开发先用 Python 脚本对 10 万行样本统计各列选择性与组合选择性,确认组合值为 1.0 后落地联合索引,特征回刷耗时下降 70%,写入吞吐提升 3 倍。
Python 演示
# -*- coding: utf-8 -*-"""模拟索引选择性与联合索引的最左前缀匹配"""import random def selectivity(values): """计算某列的选择性:不同取值数 / 总行数""" n = len(values) if n == 0: return 0.0 d = len(set(values)) return round(d / n, 4) def simulate_records(num=2000): """生成图书借阅记录,凑出两两不同的 (author, year) 组合""" surnames = ['曹', '李', '王', '张', '赵', '刘', '陈', '杨', '黄', '周', '吴', '徐', '孙', '马', '朱', '胡', '郭', '何', '林', '高'] years = list(range(1950, 2050)) records = [] seen = set() while len(records) < num: pair = (random.choice(surnames), random.choice(years)) if pair in seen: continue seen.add(pair) records.append({ 'author': pair[0], 'year': pair[1], 'title': 'book_%d' % len(records) }) return records def build_composite_index(records, keys): """模拟联合索引:组合键 -> 行号列表""" index = {} for pos, rec in enumerate(records): key = tuple(rec[k] for k in keys) index.setdefault(key, []).append(pos) return index def leftmost_match(index, keys, conditions): """最左前缀匹配:条件从左到右,None 表示该列不限""" matched = [] for key, positions in index.items(): ok = True for i, cond in enumerate(conditions): if cond is not None and key[i] != cond: ok = False break if ok: matched.extend(positions) return sorted(matched) def main(): random.seed(42) records = simulate_records(2000) a_sel = selectivity([r['author'] for r in records]) y_sel = selectivity([r['year'] for r in records]) pair_sel = selectivity([(r['author'], r['year']) for r in records]) print('总行数 N =', len(records)) print('author 列选择性 =', a_sel) print('year 列选择性 =', y_sel) print('author+year 组合选择性 =', pair_sel) idx_ab = build_composite_index(records, ['author', 'year']) rows_a = leftmost_match(idx_ab, ['author', 'year'], ['王', None]) sample = None for rec in records: if rec['author'] == '王': sample = rec['year'] break rows_ab = leftmost_match(idx_ab, ['author', 'year'], ['王', sample]) print('仅按 author 匹配 =', len(rows_a), '行') print('王 + %d 年 匹配 = %d 行' % (sample, len(rows_ab))) if __name__ == '__main__': main()
d = len(set(values))用集合去重得到这一列有多少种不同取值,即选择性公式的分子。return round(d / n, 4)不同取值数除以总行数,得到 0 到 1 之间的选择性。pair = (random.choice(surnames), random.choice(years))一次生成一条记录的两个索引列。if pair in seen: continue保证 (author, year) 组合不重复,对应组合选择性为 1。key = tuple(rec[k] for k in keys)把多列拼成一个组合键,模拟联合索引的一条索引项。index.setdefault(key, []).append(pos)相同组合键挂上多个行号,等价于索引叶子页里的行指针。if cond is not None and key[i] != cond:从最左列开始逐个比对条件,一列不满足则整个键不命中。rows_ab = leftmost_match(idx_ab, ['author', 'year'], ['王', sample])用完整 (A, B) 前缀精确命中目标行。
常见误区
误区我以为只要把选择性最高的列放在联合索引第一位,这个索引就一定最高效,哪怕这列从不出现在 WHERE 条件里。
正解选择性高只代表“单卡定位能力强”,还要看真实查询负载。一个高选择性列若从不出现在查询谓词中,放在前面只会白白消耗维护成本;反过来,低选择性但高频过滤的列(如 is_deleted)配合高频查询仍然值得设计。小陈不会因为“年份”卡最有区分度就把所有卡都按年份排——他先问读者最常怎么问。
为何常见:只盯着“区分度数字大小”这一单一指标,忽略了查询频率与访问模式才是索引设计的出发点和落脚点。
误区我建了 `(a, b, c)` 联合索引,那遇到 `WHERE b = 1` 或 `WHERE c = 2` 应该也能加速吧?毕竟索引里“包含”这些列。
正解最左前缀原则明确:(a, b, c) 只保证以 a 开头的连续前缀可用,即 (a)、(a,b)、(a,b,c)。跳过 a 直接查 b 或 c,索引的有序链无从发挥,只能全表扫描。就像卡片按“姓氏+年份”排好,只报年份时小陈不知道从哪一段开始翻。
为何常见:把“建了索引”错误地等价于“所有列都被索引”,忽略了 B+ 树从左到右的有序依赖关系。
误区`WHERE a = 1 AND b > 10` 里 `b` 是范围,能筛掉更多行,所以把 `b` 放前面建 `(b, a)` 更合理。
正解范围条件(>、<、BETWEEN)一旦开始,其后的列无法再在索引内精确定位。因此等值列应放在范围列之前:(a, b) 中 a 先精确定位,b 在缩小的区间内做范围扫描;若建 (b, a),b 的范围先扫出一大片,a 只能在区间内逐行判断,效率差很多。
为何常见:凭直觉认为“能筛掉更多行的条件放前面”,但没有理解 B+ 树对等值与范围两种谓词的匹配机制完全不同。
误区既然要建索引,就把 SELECT 里出现的所有列都塞进联合索引,一次覆盖所有查询,岂不完美。
正解每个索引都有写放大与存储成本:每次 INSERT、UPDATE、DELETE 都要同步维护每个 B+ 树,页分裂和排序都会拖慢写入。盲目堆列会制造巨大冗余索引。正确的做法是为高频查询加“恰好够用”的列,并通过前缀复用一个索引服务多个查询。
为何常见:只盯着读路径的收益,忽略了写路径与维护成本,这正是小陈“卡片墙越来越多、上架越来越慢”的教训。
误区性别列只有两个取值,选择性才 0.5,建索引肯定没用,直接放弃。
正解低选择性列单独用效果差,但在联合索引里作第二列或与高选择性列组合依然有用;当查询条件能配合覆盖索引时,低选择性列也可能把扫描范围从全表缩小到一小段。关键看“实际需要扫描的行数”,而非选择性数字本身。小陈的“年份”列覆盖了查询后,连低选择性的 level 也敢放到联合索引首位了。
为何常见:把“选择性低”绝对化,忽略了查询返回行数、索引下推、覆盖索引等综合效果对性能的决定作用。
面试题
Q1. 索引选择性 S = D / N 中,D 表示什么?
- A.表中总行数
- B.该列不同取值数← 正确答案
- C.该列重复值个数
- D.索引叶子页数量
答案:B。D 是 COUNT(DISTINCT 列) 的结果,表示这一列有多少种不同的取值;N 才是总行数。就像小陈数“姓氏卡”有几种姓,而不是数卡的总数。A 是分母 N 的含义,C、D 与公式无关。
Q2. 选择性越接近哪个数时,索引的过滤效果越强?
- A.0(所有值都相同)
- B.1(每个值都独特)← 正确答案
- C.-1(完全不相关)
- D.100(值域非常大)
答案:B。S = D / N,当每个值都独特时 D ≈ N,S 接近 1,索引一步定位;S 接近 0 表示大量行共享同一个值,过滤后候选集依然庞大。就像书名卡每张指向一本书,而“教材类别”卡一翻就是几百张。
Q3. 联合索引 (a, b) 能充分利用索引的查询是?
- A.WHERE b = 5(只过滤 b)
- B.WHERE a = 1 AND b = 2← 正确答案
- C.WHERE b > 5 AND b < 10
- D.WHERE a > 1 AND b = 2
答案:B。B 同时提供 a、b 两个等值条件,完整命中联合索引。A、C 跳过了最左列 a 直接过滤 b,无法利用索引有序性;D 中 a 是范围条件,范围开始后 b = 2 无法再精确定位,只能作 residual 过滤。
Q4. 设计联合索引字段顺序时,第一原则是什么?
- A.高选择性字段放在最前← 正确答案
- B.低选择性字段放在最前
- C.按字段名长度排序
- D.按建表时的先后排序
答案:A。高选择性字段放前面,第一步就能砍掉最多的候选行。小陈的实验里 (年份,姓氏) 优于 (姓氏,年份),正是因为年份选择性 0.83 高于姓氏 0.42。B 会放大中间结果,C、D 与查询效率无关。
Q5. 关于“回表”,下列说法正确的是?
- A.通过索引找到主键后回主表取整行← 正确答案
- B.查询执行失败后自动重试
- C.存储引擎重启后的崩溃恢复
- D.把改变的数据刷回磁盘
答案:A。回表是二级索引的固有代价:索引叶子只存索引键和主键,要取其他列必须按主键再读一次数据行,属于随机 I/O。小陈“看完卡片还要跑一趟书架”就是回表。B、C、D 描述的是重试、恢复与刷盘,与索引查找无关。
Q6. 联合索引 (a, b, c),下列哪些查询可命中该索引?
- A.WHERE a = 1(只带 a 条件)← 正确答案
- B.WHERE b = 2(只带 b 条件)
- C.WHERE a = 1 AND b = 2← 正确答案
- D.WHERE b = 2 AND c = 3
答案:AC。A 使用前缀 (a),C 使用前缀 (a, b),都在可用前缀集合内。B 和 D 都跳过了最左列 a,无法进入索引的有序区间,只能全表扫描,违反最左前缀原则。
Q7. 某列选择性仅为 0.05,下列描述正确的是?
- A.能快速定位到唯一一行
- B.能过滤掉绝大部分记录
- C.过滤后候选集依然很大← 正确答案
- D.该列无法创建任何索引
答案:C。选择性 0.05 意味着平均每个取值对应 20 行记录,过滤后候选集依然庞大,索引的“精确制导”作用大打折扣。就像只有两种姓的卡片墙,报出姓氏后还要在一堆卡里继续翻。D 是常识性错误,低选择性列同样可以建索引,只是单独用效果差。
Q8. 下列哪些手段能有效减少回表带来的开销?
- A.使用覆盖索引直接取数← 正确答案
- B.索引下推在索引层过滤← 正确答案
- C.把所有二级索引全部删除
- D.强制查询走全表扫描
答案:AB。覆盖索引让查询所需列都在索引页中,回表次数归零;索引下推把 WHERE 条件提前到存储引擎的索引层判断,减少回表行数。C、D 删除索引或强行走全表,只会让扫描行数爆炸,回表问题不仅没解决反而更糟。
Q9. 一张 1 万行的表,某列有 2500 种不同取值,其选择性约为?
- A.0.25← 正确答案
- B.0.75
- C.2.50
- D.4.00
答案:A。S = 2500 ÷ 10000 = 0.25。选择性取值恒在 0 到 1 之间,所以 C、D 直接排除;0.75 是把除数和被除数弄反了(7500 才是 0.75)。这个数值说明该列平均 4 行共享一个取值,过滤能力中等偏弱。
Q10. 联合索引 (a, b) 中 a 选择性 0.9、b 选择性 0.5,下列说法正确的是?
- A.通常 (a, b) 优于 (b, a)← 正确答案
- B.仅查 b 也能命中 (a, b)
- C.组合选择性不低于 a← 正确答案
- D.高频查 b 时宜用 (b, a)← 正确答案
答案:ACD。高选择性在前是通用法则,A 正确;组合键包含 a 的全部信息,S_ab ≥ S_a,C 正确;若真实负载大量按 b 单独过滤,D 把 b 放首位以贴合最左前缀,正确。B 违反最左前缀,单独查 b 无法命中 (a, b),错误。这道题提醒我们:选择性要结合真实查询模式一起看。