文章目录
-
- 每日一句正能量
- [1. 背景与问题](#1. 背景与问题)
- [2. 环境与数据](#2. 环境与数据)
-
- [2.1 实验环境](#2.1 实验环境)
- [2.2 样例表](#2.2 样例表)
- [2.3 数据分布检查](#2.3 数据分布检查)
- [3. 复现过程](#3. 复现过程)
-
- [3.1 精确匹配:普通 B-tree 索引最擅长的场景](#3.1 精确匹配:普通 B-tree 索引最擅长的场景)
- [3.2 前缀匹配:不是所有环境都会自动使用普通索引](#3.2 前缀匹配:不是所有环境都会自动使用普通索引)
- [3.3 后缀匹配:普通索引顺序与查询方向相反](#3.3 后缀匹配:普通索引顺序与查询方向相反)
- [3.4 任意位置包含:`LIKE '%科技%'` 的根本边界](#3.4 任意位置包含:
LIKE '%科技%'的根本边界) - [3.5 全文检索不是 `%keyword%` 的等价替换](#3.5 全文检索不是
%keyword%的等价替换)
- [4. 方案实施](#4. 方案实施)
-
- [4.1 先拆分搜索语义,而不是让一条 SQL 承担所有需求](#4.1 先拆分搜索语义,而不是让一条 SQL 承担所有需求)
- [4.2 前缀查询方案](#4.2 前缀查询方案)
- [4.3 后缀查询方案](#4.3 后缀查询方案)
- [4.4 任意位置包含方案](#4.4 任意位置包含方案)
- [4.5 全文检索方案](#4.5 全文检索方案)
- [4.6 数据校验](#4.6 数据校验)
- [4.7 灰度发布与回退](#4.7 灰度发布与回退)
- [5. 结果对比](#5. 结果对比)
-
- [5.1 示例测试结果](#5.1 示例测试结果)
- [5.2 不能只比较执行时间](#5.2 不能只比较执行时间)
- [5.3 写入成本对比](#5.3 写入成本对比)
- [6. 风险与复盘](#6. 风险与复盘)
-
- [6.1 前缀匹配的风险](#6.1 前缀匹配的风险)
- [6.2 后缀匹配的风险](#6.2 后缀匹配的风险)
- [6.3 全文检索的风险](#6.3 全文检索的风险)
- [6.4 常见错误做法](#6.4 常见错误做法)
- [6.5 最终决策](#6.5 最终决策)

每日一句正能量
我们不曾走过他人的路,便难以体会其经历的全部重量。
每个人的人生轨迹、苦难和挣扎都是独特的,没有亲身经历过,就无法真正理解那种沉重。因此应保持谦卑和审慎。

1. 背景与问题
客户中心上线统一检索后,运营人员习惯在一个输入框中搜索客户名称。接口最初只有一条 SQL:
sql
SELECT customer_id, customer_name, customer_level, city_name
FROM crm_customer
WHERE customer_name LIKE '%' || :keyword || '%'
ORDER BY updated_at DESC
LIMIT 20;
业务上看,这条 SQL 很自然:输入"科技"可以查到"华北科技有限公司",输入"银行"可以查到"城市商业银行股份有限公司"。但随着客户表从几十万行增长到数百万行,接口 P95 从 120 ms 上升到 2 s 以上。数据库监控显示,该 SQL 调用频率不算最高,却长期位于共享块访问量和 CPU 消耗榜单前列。
问题并不只是"缺少索引"。客户名称搜索至少包含四种不同语义:
- 精确匹配:完整输入客户名称。
- 前缀匹配:输入"华北科",希望匹配"华北科技有限公司"。
- 后缀匹配:输入"有限公司",希望匹配所有以该词结尾的名称。
- 任意位置包含:输入"科技",名称中任何位置出现均应命中。
- 自然语言检索:将名称、简称、曾用名、经营标签作为一个文档检索,并按相关度排序。
这几类需求虽然都可以用 LIKE 表达,却不应使用同一种索引和同一条 SQL。若把所有搜索都压到 LIKE '%关键字%',数据库只能面对一个高频、低选择率、难以利用普通 B-tree 索引的通用查询。
本次优化的目标不是简单把一个顺序扫描改成索引扫描,而是回答三个问题:
- 前缀、后缀和任意位置包含查询,分别应该采用什么数据结构?
- 全文检索能否替代所有模糊搜索?
- 优化后是否同时改善延迟、I/O 与并发,而不是只让单次实验更快?
2. 环境与数据
2.1 实验环境
| 项目 | 示例配置 |
|---|---|
| 数据库 | KingbaseES V8/V9 兼容环境 |
| 表规模 | 500 万行客户数据 |
| 平均名称长度 | 18 个中文字符 |
| 名称唯一率 | 约 92% |
| 热点词 | "科技""集团""有限公司""银行" |
| 测试方式 | 冷缓存、热缓存各 10 轮 |
| 观察指标 | 执行时间、P95、共享块、实际扫描行数、索引体积、写入 TPS |
不同版本、兼容模式、排序规则和已安装扩展会影响语法及执行计划。本文给出的 SQL 需要先在测试环境确认。
2.2 样例表
sql
CREATE TABLE crm_customer (
customer_id BIGINT PRIMARY KEY,
tenant_id BIGINT NOT NULL,
customer_name VARCHAR(200) NOT NULL,
short_name VARCHAR(100),
former_name VARCHAR(200),
customer_level VARCHAR(20),
city_name VARCHAR(60),
search_document TSVECTOR,
updated_at TIMESTAMP NOT NULL
);
基础索引:
sql
CREATE INDEX idx_customer_name
ON crm_customer(customer_name);
CREATE INDEX idx_customer_tenant_update
ON crm_customer(tenant_id, updated_at DESC);
2.3 数据分布检查
优化前先确认关键词命中比例。低选择率词即使能使用索引,也可能需要访问大量索引项和表页。
sql
SELECT
COUNT(*) AS total_rows,
COUNT(*) FILTER (WHERE customer_name LIKE '华北科%') AS prefix_rows,
COUNT(*) FILTER (WHERE customer_name LIKE '%有限公司') AS suffix_rows,
COUNT(*) FILTER (WHERE customer_name LIKE '%科技%') AS contains_rows
FROM crm_customer;
再检查名称长度和高频词:
sql
SELECT
MIN(char_length(customer_name)) AS min_len,
MAX(char_length(customer_name)) AS max_len,
AVG(char_length(customer_name)) AS avg_len
FROM crm_customer;
SELECT customer_name, COUNT(*)
FROM crm_customer
GROUP BY customer_name
ORDER BY COUNT(*) DESC
LIMIT 20;
实验中,"华北科%"命中约 0.018%,"%有限公司"命中约 31%,"%科技%"命中约 4.6%。这三个比例决定了后续计划差异。
3. 复现过程
3.1 精确匹配:普通 B-tree 索引最擅长的场景
sql
EXPLAIN (ANALYZE, BUFFERS)
SELECT customer_id, customer_name
FROM crm_customer
WHERE customer_name = '华北科技有限公司';
典型计划:
text
Index Scan using idx_customer_name on crm_customer
Index Cond: (customer_name = '华北科技有限公司')
Buffers: shared hit=8
Execution Time: 0.18 ms
精确匹配条件可以直接定位 B-tree 中的键值范围,访问块少,计划稳定。
3.2 前缀匹配:不是所有环境都会自动使用普通索引
sql
EXPLAIN (ANALYZE, BUFFERS)
SELECT customer_id, customer_name
FROM crm_customer
WHERE customer_name LIKE '华北科%';
在合适排序规则和操作符类条件下,优化器可以把前缀匹配转换成一个有上下界的索引范围。但如果数据库使用非 C/POSIX 排序规则,普通文本索引未必能直接支持 LIKE 前缀范围,可能仍出现顺序扫描。
复现中基线计划为:
text
Seq Scan on crm_customer
Filter: (customer_name ~~ '华北科%')
Rows Removed by Filter: 4999102
Buffers: shared hit=18421 read=23170
Execution Time: 731.44 ms
这类问题容易被误判为"LIKE 前缀也无法用索引"。真正需要检查的是排序规则、操作符类、表达式和参数绑定。
可选改造:
sql
CREATE INDEX idx_customer_name_pattern
ON crm_customer (customer_name text_pattern_ops);
若当前 KingbaseES 版本支持对应操作符类,前缀计划可变为:
text
Index Scan using idx_customer_name_pattern on crm_customer
Index Cond: ((customer_name ~>=~ '华北科')
AND (customer_name ~<~ '华北秕'))
Filter: (customer_name ~~ '华北科%')
Buffers: shared hit=126
Execution Time: 4.62 ms
边界字符由数据库内部规则生成,不建议在业务 SQL 中自行拼接"下一个字符串"。
3.3 后缀匹配:普通索引顺序与查询方向相反
sql
EXPLAIN (ANALYZE, BUFFERS)
SELECT customer_id, customer_name
FROM crm_customer
WHERE customer_name LIKE '%有限公司';
B-tree 按字符串左侧开始排序,而后缀条件不知道字符串的起点,因此普通名称索引无法直接缩小扫描区间。计划通常为顺序扫描:
text
Seq Scan on crm_customer
Filter: (customer_name ~~ '%有限公司')
Actual Rows: 1550021
Buffers: shared hit=19106 read=22511
Execution Time: 1684.52 ms
即使创建了表达式反转索引,如果命中 31% 的数据,优化器仍可能判断顺序扫描更便宜。索引可用不等于索引一定更快。
后缀固定且高频时,可以建立反转列:
sql
ALTER TABLE crm_customer
ADD COLUMN customer_name_rev VARCHAR(200);
UPDATE crm_customer
SET customer_name_rev = reverse(customer_name);
CREATE INDEX idx_customer_name_rev
ON crm_customer(customer_name_rev text_pattern_ops);
查询改为:
sql
SELECT customer_id, customer_name
FROM crm_customer
WHERE customer_name_rev LIKE reverse(:suffix) || '%';
但此方案需要应用或触发器维护反转列,并且更适合命中比例较低、后缀检索确实是核心需求的场景。对"有限公司"这类超高频后缀,返回数据量本身已经很大,分页、附加过滤和业务口径收敛比单纯建索引更重要。
3.4 任意位置包含:LIKE '%科技%' 的根本边界
sql
EXPLAIN (ANALYZE, BUFFERS)
SELECT customer_id, customer_name
FROM crm_customer
WHERE customer_name LIKE '%科技%';
普通 B-tree 无法定位中间子串。基线计划:
text
Seq Scan on crm_customer
Filter: (customer_name ~~ '%科技%')
Actual Rows: 230418
Rows Removed by Filter: 4769582
Buffers: shared hit=18854 read=22772
Execution Time: 1412.77 ms
此时有三条路线:
- 对固定、有限关键词建设业务标签列。
- 使用支持子串相似度检索的扩展索引,例如 trigram/GIN(需确认版本与插件)。
- 将需求改造成全文检索,但必须接受"词项匹配"与"字面子串匹配"不是同一语义。
3.5 全文检索不是 %keyword% 的等价替换
全文检索通常将文档解析为词位,再通过 tsvector 与 tsquery 匹配。它擅长关键词组合、逻辑查询、相关度排序和多字段检索,但不天然保证任意连续字符子串命中。
构建搜索文档:
sql
UPDATE crm_customer
SET search_document =
to_tsvector(
'simple',
coalesce(customer_name, '') || ' ' ||
coalesce(short_name, '') || ' ' ||
coalesce(former_name, '')
);
CREATE INDEX idx_customer_search_document
ON crm_customer
USING GIN(search_document);
查询:
sql
EXPLAIN (ANALYZE, BUFFERS)
SELECT customer_id,
customer_name,
ts_rank(search_document, plainto_tsquery('simple', :keyword)) AS rank_score
FROM crm_customer
WHERE search_document @@ plainto_tsquery('simple', :keyword)
ORDER BY rank_score DESC
LIMIT 20;
典型计划可能包含:
text
Bitmap Heap Scan on crm_customer
Recheck Cond: (search_document @@ plainto_tsquery('simple', '科技'))
-> Bitmap Index Scan on idx_customer_search_document
Index Cond: (search_document @@ plainto_tsquery('simple', '科技'))
对于中文名称,使用哪种分词配置、词典或扩展必须根据实际版本验证。若使用 simple 配置,中文长字符串可能被当作一个整体词项,无法达到预期分词效果。正式方案应先用真实名称样本验证分词结果:
sql
SELECT to_tsvector('simple', '华北科技有限公司');
如果结果不能满足"科技"检索,就不能仅凭创建 GIN 索引宣称全文检索成功。

4. 方案实施
4.1 先拆分搜索语义,而不是让一条 SQL 承担所有需求
接口改造成明确的搜索模式:
text
EXACT 精确名称
PREFIX 名称前缀
SUFFIX 名称后缀
CONTAINS 任意位置包含
FULLTEXT 多字段关键词与相关度搜索
默认搜索建议采用"前缀优先 + 全文补充",而不是默认包含搜索。用户输入短于 2 个字符时,拒绝全库模糊查询,提示增加关键词或选择租户、城市等筛选条件。
4.2 前缀查询方案
SQL:
sql
SELECT customer_id, customer_name, customer_level, city_name
FROM crm_customer
WHERE tenant_id = :tenant_id
AND customer_name LIKE :prefix || '%'
ORDER BY customer_name
LIMIT :page_size;
候选索引:
sql
CREATE INDEX idx_customer_tenant_name_pattern
ON crm_customer(tenant_id, customer_name text_pattern_ops);
验证重点:
Index Cond是否同时包含租户和名称范围。- 是否仍存在大规模
Rows Removed by Filter。 - 排序是否可以利用索引顺序。
:prefix是否以字符串类型绑定。- 排序规则是否允许该操作符类工作。
4.3 后缀查询方案
仅当后缀检索是高价值且命中率可控时采用反转列。推荐生成列或应用层统一维护,具体语法需按版本验证。
sql
CREATE INDEX idx_customer_tenant_name_rev
ON crm_customer(tenant_id, customer_name_rev text_pattern_ops);
查询:
sql
SELECT customer_id, customer_name
FROM crm_customer
WHERE tenant_id = :tenant_id
AND customer_name_rev LIKE reverse(:suffix) || '%'
ORDER BY updated_at DESC
LIMIT 20;
必须同时处理:
- 新增、修改、批量导入时反转列一致性。
- 空值、空字符串、全角半角与大小写标准化。
- 既有数据回填。
- 反转索引新增的写放大。
- 高频后缀导致的低选择率。
4.4 任意位置包含方案
若 KingbaseES 环境已安装并支持 trigram 类扩展,可评估 GIN/GiST 子串索引。由于不同版本的扩展名称、操作符类和支持范围可能不同,不能把 PostgreSQL 示例直接复制到生产。
兼容性检查:
sql
SELECT *
FROM sys_available_extensions
WHERE name LIKE '%trgm%';
安装与语法应使用本环境官方手册确认。实施前必须验证:
- 中文字符如何切分。
- 两字、三字、英文、数字和符号的索引命中。
LIKE、ILIKE及相似度操作符是否使用索引。- 索引体积和更新成本。
- 短关键词是否退化为大范围扫描。
若没有合适扩展,则优先收敛业务需求:增加租户、地区、客户等级和状态过滤,限制最短关键词,或把高频类别词改为结构化标签。
4.5 全文检索方案
全文检索适合:
- 同时搜索客户名称、简称、曾用名、标签。
- 支持"科技 AND 北京"等组合条件。
- 需要按相关度排序。
- 可接受词项匹配而非任意字符子串。
维护方式可以是生成列、触发器或定时重建。示例触发器思路:
sql
CREATE OR REPLACE FUNCTION refresh_customer_search_document()
RETURNS TRIGGER AS $$
BEGIN
NEW.search_document :=
to_tsvector(
'simple',
coalesce(NEW.customer_name, '') || ' ' ||
coalesce(NEW.short_name, '') || ' ' ||
coalesce(NEW.former_name, '')
);
RETURN NEW;
END;
$$ LANGUAGE plsql;
函数语言及触发器语法需结合实际 KingbaseES 版本调整。
4.6 数据校验
新旧方案必须验证结果差异,而不是只看速度。
前缀结果双向差集:
sql
WITH old_result AS (
SELECT customer_id
FROM crm_customer
WHERE customer_name LIKE '华北科%'
),
new_result AS (
SELECT customer_id
FROM crm_customer
WHERE customer_name LIKE '华北科%'
)
SELECT 'old_minus_new' AS diff_type, customer_id
FROM old_result
EXCEPT
SELECT 'old_minus_new', customer_id
FROM new_result
UNION ALL
SELECT 'new_minus_old', customer_id
FROM new_result
EXCEPT
SELECT 'new_minus_old', customer_id
FROM old_result;
全文检索与包含搜索不能要求结果完全一致,应建立业务验收样本:
| 样本 | 期望 |
|---|---|
| 华北科技有限公司 / "华北科" | 前缀命中 |
| 华北科技有限公司 / "科技" | 包含命中;全文是否命中需按分词验证 |
| 城市商业银行股份有限公司 / "有限公司" | 后缀命中 |
| 北京中科智云 / "中科" | 包含命中 |
| 旧名:北方科技 / "北方科技" | 多字段全文命中 |
4.7 灰度发布与回退
- 新接口增加
search_mode,默认仍走旧 SQL。 - 影子流量同时执行新旧查询,只记录结果数量、耗时与差异,不返回新结果。
- 前缀查询先灰度 10%,观察 P95、共享块、CPU 和错误率。
- 后缀与全文检索独立开关,禁止一次性全部切换。
- 新索引保留观察周期,确认写入 TPS 与存储增长可接受。
- 发生异常时关闭路由开关,恢复旧 SQL;索引不立即删除。
- 若搜索文档维护出现积压,暂停全文路由并补偿重建。
5. 结果对比
5.1 示例测试结果
| 方案 | 关键词 | 计划 | 平均耗时 | P95 | 共享块 | 备注 |
|---|---|---|---|---|---|---|
| 普通索引精确匹配 | 完整名称 | Index Scan | 0.22 ms | 0.39 ms | 8 | 最稳定 |
| 普通索引前缀基线 | 华北科% | Seq Scan | 706 ms | 812 ms | 41,100 | 排序规则限制 |
| Pattern 前缀索引 | 华北科% | Index Scan | 4.8 ms | 7.1 ms | 132 | 命中率低 |
| 普通后缀查询 | %有限公司 | Seq Scan | 1,620 ms | 1,884 ms | 42,008 | 命中 31% |
| 反转列后缀索引 | %智云 | Index Scan | 7.6 ms | 11.3 ms | 246 | 低命中后缀有效 |
| 普通包含查询 | %科技% | Seq Scan | 1,398 ms | 1,603 ms | 41,626 | 扫描全表 |
| GIN 全文检索 | 科技 | Bitmap Heap Scan | 32 ms | 48 ms | 1,260 | 语义不完全等价 |

5.2 不能只比较执行时间
优化后的检查项包括:
sql
EXPLAIN (ANALYZE, BUFFERS, SETTINGS)
SELECT ...;
重点读以下指标:
Index Cond:索引是否真正缩小扫描范围。Filter:是否仍在索引后过滤大量数据。Rows Removed by Filter:候选索引是否匹配查询语义。actual rows:关键词是否属于高命中热点。Buffers:是否减少共享块访问,而非只受热缓存影响。Planning Time与Execution Time:动态生成复杂查询是否增加计划成本。SETTINGS:实验期间是否修改过成本参数或扫描开关。
生产监控还应关注:
sql
SELECT queryid,
calls,
total_exec_time,
mean_exec_time,
rows,
shared_blks_hit,
shared_blks_read,
temp_blks_read,
temp_blks_written
FROM sys_stat_statements
WHERE query LIKE '%crm_customer%'
ORDER BY total_exec_time DESC;
不同版本统计视图字段可能不同,需按实际环境调整。
5.3 写入成本对比
读性能提升会带来额外写入成本:
| 索引 | 读收益 | 写入影响 | 适用建议 |
|---|---|---|---|
| 名称 B-tree | 精确、部分前缀 | 低至中 | 常规保留 |
| Pattern 前缀索引 | 前缀范围 | 中 | 前缀高频时采用 |
| 反转名称索引 | 后缀 | 中 | 仅核心后缀需求 |
| Trigram GIN | 任意子串 | 高 | 搜索价值高、更新可控 |
| 全文 GIN | 多字段词项 | 高 | 复杂搜索与相关度 |
| 多套索引叠加 | 多模式 | 很高 | 避免无差别叠加 |
实验中增加两个 GIN 类索引后,批量导入 TPS 下降约 18%,客户名称更新延迟上升约 22%。因此最终方案只保留了前缀索引和全文索引,后缀搜索限制为"租户内、至少 3 个字符、最多返回 100 条",没有为高频"有限公司"建立独立优化路径。

6. 风险与复盘
6.1 前缀匹配的风险
- 非
C/POSIX排序规则下,普通索引可能无法支持LIKE。 lower(customer_name) LIKE lower(:prefix) || '%'会改变索引表达式,需要对应表达式索引。- 参数写成
LIKE '%' || :keyword || '%'后,即使关键词本身是前缀,也失去前缀范围条件。 - 极短前缀如"中%"命中率过高,索引优势会明显下降。
6.2 后缀匹配的风险
- 反转列增加存储、维护和一致性成本。
- 高频后缀返回大量数据,索引无法解决结果集过大的根本问题。
- 中文、英文、空格、括号和全角字符的标准化必须统一。
- 修改名称时若反转列未同步,可能出现漏查。
6.3 全文检索的风险
- 词项匹配与字符子串匹配不等价。
- 中文分词效果取决于词典或扩展,必须使用业务样本验证。
- GIN 索引体积和更新成本通常高于普通 B-tree。
- 搜索文档维护延迟会造成新客户暂时不可检索。
- 相关度排序可能受词频、字段权重和词典变化影响。
6.4 常见错误做法
- 看到顺序扫描就强制关闭
enable_seqscan。 - 为每一种
LIKE写法都增加一个索引。 - 将全文检索描述成
%keyword%的无损替代。 - 只测试一个关键词,不测试热点词、冷门词和短词。
- 只看热缓存单次耗时,不看共享块、P95 和写入影响。
- 忽略排序规则、大小写规则和中文分词。
- 在接口层不限制空字符串与单字符搜索。
6.5 最终决策
本项目最终采用以下组合:
- 精确名称:普通 B-tree。
- 租户内名称前缀:
tenant_id + pattern复合索引。 - 任意位置包含:仅对高权限运营入口开放,并限制最短字符与结果数。
- 多字段搜索:全文检索,单独维护搜索文档。
- 后缀查询:不默认开放;特定低命中后缀使用反转列试点。
- 所有模式:必须先带租户或组织范围,禁止无边界全库搜索。
这次优化最大的收获不是"给 LIKE 建了什么索引",而是把模糊搜索从一条通用 SQL 拆成了多个语义明确的查询产品。数据库可以优化确定的访问模式,却无法用一个索引同时解决精确、前缀、后缀、任意子串和自然语言搜索。
转载自:https://blog.csdn.net/u014727709/article/details/163211072
欢迎 👍点赞✍评论⭐收藏,欢迎指正