做定时报表和数据处理的人,几乎每天都要面对一个问题:怎么判断一个字段里有没有某个数据。它在代码里只是一个布尔判断,到 SQL 里却藏着不少门道——字段是文本还是多值串、数据库是 MySQL 还是 PostgreSQL、数据量是百万行还是几万行,都会影响你该用哪种写法。最近我在处理订单备注匹配时,一个LIKE '%关键词%'直接把几百万行的表拖进了全表扫描,查一次要几十秒;换掉写法、补上索引之后,耗时降到毫秒级。这个经历让我决定把这几种判断字段包含数据的方法完整梳理一遍,把使用场景、坑点和优化思路一起写清楚。
所谓"字段包含一个数据",不是一句"用 LIKE 就行"能概括的。至少有三层语义:一是字段值是文本,判断其中有没有某个子串;二是字段值本身是多值结构,比如逗号分隔的标签或 JSON 数组,判断其中有没有某个成员;三是字段值是精确值,判断它是否归属某个候选集合。三种语义对应着完全不同的函数和写法,用错了一口钟。
这篇文章适合写业务 SQL 的后端开发、做数据清洗的数据分析师,以及准备数据库面试的候选人。我会按语义分类把方法讲透,每个方案都给出可复制的写法,同时对比 MySQL、PostgreSQL、SQL Server、Oracle 四个主流数据库的差异。
1. 先分清"包含"的三种语义:这一步错了后面全白写
1.1 文本子串匹配:判断字段值里有没有某段文字
最经典的场景是查订单备注、日志内容、商品描述。假如 order_table 的 remark 字段存着一行文本,业务要找出所有包含"退款"两个字的订单,那本质上是要判断一个字符串里是否存在某个子串。对应到 SQL,日常主力就是 LIKE,其次是定位类函数、正则表达式。
这个语义是大多数学过 SQL 的人第一时间想到的"包含"。但它有个隐含前提:字段值是文本,匹配目标是文本的一部分,而且访问者不关心它出现在开头、中间还是结尾。稍微复杂一点,需求会变成"包含数字""包含金额单位""包含退或者换"这类带模式的内容,这时候就要请正则出场。
1.2 集合成员匹配:字段值是多值结构,判断其中有没有某个成员
现实中还有一类"包含"——字段里存的是多个值拼起来的一个串,典型的是逗号分隔的标签,比如 user_tags = 'vip,premium,newuser'。业务说"查包含 premium 标签的用户",这里的"包含"其实是集合的成员判断,不是子串判断。
如果直接user_tags LIKE '%premium%',会把 premium_test、premium_plus 甚至 superpremium 这种不相干的数据一起捞出来,产生虚假命中。这类字段要用 FIND_IN_SET(MySQL)、数组拆分(PostgreSQL 的 string_to_array + ANY)或 JSON 函数来处理。
还有一种更隐蔽的情况,字段里存的是一大串 JSON 数组,数据内容同样是集合成员判断,只是结构从纯文本升级成了 JSON。这类字段如果继续用 LIKE 去碰,不仅要忍受全表扫描,还可能出现把"premium_plus"误判成"premium"的脏结果。
1.3 精确值归属匹配:字段的值是否落在某个候选集合里
第三种语义更像"是否等于列表里的某一种",比如 status 字段判断是否是 1、2、3 这三个状态之一。SQL 里直接用 IN:WHERE status IN (1, 2, 3)。
它虽然不叫"包含",但业务和面试中常常被放在一起讨论。"包含一个数"和"属于一组数"在自然语言里很容易混,代码里一个是判断目标在字段内,一个是判断字段在目标集内,方向是反的。把三种语义理清楚,后面几节的工具才不会用错。
| 语义 | 典型字段 | 推荐方向 | 代表函数 |
|---|---|---|---|
| 文本子串 | remark | LIKE 子串匹配 | LIKE、CHARINDEX、INSTR、STRPOS、REGEXP |
| 集合成员 | user_tags | 成员精确匹配 | FIND_IN_SET、string_to_array、JSON_CONTAINS、@> |
| 精确归属 | status | 枚举列表匹配 | IN、= ANY |
2. LIKE 系写法:最直觉,也最容易掉进通配符的坑
2.1 LIKE 的三种匹配位置与索引机会
LIKE 做"包含"判断,写法上通常是这样:
-- 任意位置包含"退款" SELECT * FROM order_table WHERE remark LIKE '%退款%'; -- 以"退款"开头(这个写法有可能走索引) SELECT * FROM order_table WHERE remark LIKE '退款%'; -- 以"退款"结尾 SELECT * FROM order_table WHERE remark LIKE '%退款';这里要特别提醒一句:LIKE '%退款%' 和 LIKE '退款%' 不只是匹配范围不同,性能差异也极大。'退款%' 这种前缀匹配在 B+ 树索引上可以做成范围扫描,只有 '%退款%' 和 '%退款' 必须扫描整列数据,这个问题我放到第 5 节详细说。
实际项目里我最常见的误用场景是:写报表的人为了图省事,把所有"包含"需求全部套%关键词%,结果一个简单的条件过滤拖出几十秒的查询。其实很多"包含"需求仔细问下去,业务方真正想要的是"以 XX 开头"或者"等于某个枚举值",压根不需要中段模糊。
2.2 下划线不是普通下划线:通配符转义
我刚接触 SQL 时在这里被坑过一次。用户表里有一个 account 字段,我需要查所有包含下划线的账号,下意识写:
SELECT * FROM user_table WHERE account LIKE '%_%';返回结果几乎包含了全表数据,当时还以为是权限问题,仔细一查才意识到:LIKE 里有两个通配符,% 代表任意数量字符,_ 代表任意单个字符。LIKE '%_%'的语义是"任意字符出现一次即可",基本上所有非空字符串都能命中,结果自然全错。正确写法必须转义:
SELECT * FROM user_table WHERE account LIKE '%\_%' ESCAPE '\';如果业务要匹配的字符本身就含有 % 或 _,就得用 ESCAPE 指定转义符。上面例子用反斜杠,也可以换成任意不会出现在数据里的字符,比如LIKE '%#_%' ESCAPE '#',转义符只要统一即可。
还有一点很多人不知道:当你要匹配的文本以通配符开头时,比如搜索包含 "10%" 的折扣说明,SQL 是:
WHERE remark LIKE '%10\%%' ESCAPE '\';第一个 % 是任意前缀,中间的 % 是字面意义百分号,最后两个 %% 属于通配符的一部分。这个写法容易乱,建议先在 SELECT 里单独跑一下不带 WHERE 的表达式,确认结果再套进业务查询。
2.3 正则版本的包含:REGEXP / REGEXP_LIKE / ~
LIKE 解决不了"包含数字""包含至少一位大写字母"这类模式需求,这时用正则。MySQL 里常见的写法:
-- MySQL 8.0 SELECT * FROM order_table WHERE REGEXP_LIKE(remark, '退款|退货'); -- 等价旧写法 SELECT * FROM order_table WHERE remark REGEXP '退款|退货';PostgreSQL 用 ~ 或者~*(忽略大小写):
SELECT * FROM order_table WHERE remark ~ '退款|退货';Oracle 用 REGEXP_LIKE:
SELECT * FROM order_table WHERE REGEXP_LIKE(remark, '退款|退货');SQL Server 原生不支持正则判断,所以大部分场景靠 LIKE 硬扛。需要真正做复杂模式匹配时,可以自己实现 CLR 函数或引入外部服务,但从我的实操经验看,能不用就别用,维护成本高,而且优化器通常拿不到很好的执行计划。先用 LIKE 做一层粗筛,再对候选集做正则过滤,往往更实际。
用正则判断"包含"时还有个惯性错误:很多人习惯写成'^退款',结果查不到中间包含"退款"的数据。如果你要做的是包含判断,正则以'退款'开头即可,^和$是特意限制位置才用的锚点。
大小写方面,MySQL 多数 collation 默认不区分大小写,PostgreSQL 的 ~ 区分,SQL Server 看排序规则。判断包含关键字时,如果业务不关心大小写,PostgreSQL 建议直接用~*,比LOWER(remark) ~ ...更合理,后者会让列上的索引彻底失效。
3. 定位函数系:CHARINDEX、INSTR、LOCATE、STRPOS 怎么选
3.1 四个数据库定位函数速查
除了 LIKE,判断文本子串"包含"还有一类定位函数:返回子串在字段中的位置,位置大于 0 就说明包含。不同数据库的函数名完全不同:
| 数据库 | 函数 | 正确用法 | 参数顺序 |
|---|---|---|---|
| MySQL | LOCATE | LOCATE('退款', remark) > 0 | 子串在前,字符串在后 |
| MySQL | INSTR | INSTR(remark, '退款') > 0 | 字符串在前,子串在后 |
| SQL Server | CHARINDEX | CHARINDEX('退款', remark) > 0 | 子串在前,字符串在后 |
| Oracle | INSTR | INSTR(remark, '退款') > 0 | 字符串在前,子串在后 |
| PostgreSQL | STRPOS | STRPOS(remark, '退款') > 0 | 字符串在前,子串在后 |
| PostgreSQL | POSITION | POSITION('退款' IN remark) > 0 | 用 IN 连接 |
注意看参数顺序,这是最离谱的坑。MySQL 的 LOCATE 是LOCATE(substr, str),而 MySQL 的 INSTR 是INSTR(str, substr),同一个数据库里两个函数的顺序居然是反的。Oracle 的 INSTR 和 MySQL 的 INSTR 表面同名,实际用法一致(str 在前),但很多人从 MySQL 转 Oracle 时会下意识迁移成INSTR('退款', remark),查出来恒等于 0,还不报错,这种代码审查极难发现。
PostgreSQL 的 POSITION 写法是POSITION('退款' IN remark),不是POSITION('退款', remark),函数签名和别的定位函数完全不是一个路子,迁移脚本里特别容易漏。
3.2 返回 0 还是 NULL:排查结果缺失的另一条线
定位函数没找到子串时统一返回 0,找到就返回从 1 开始的位置。但字段本身是 NULL 时,所有定位函数都返回 NULL,NULL 和任何数字比较结果都是 unknown,WHERE 条件会把它过滤掉。
所以查"不包含某个字"的反向查询要格外小心:
-- 这是错的:NOT LIKE 会排除 NULL 行,也可能排除业务需要保留的空值 WHERE remark NOT LIKE '%退款%'; -- 推荐:把 NULL 的语义显式表达出来 WHERE remark IS NULL OR remark NOT LIKE '%退款%';同理,正向查询里如果你写WHERE LOCATE('退款', remark) = 0来查不包含的数据,NULL 行一样不会出现。数据里有大把 NULL 的时候,报表结果会比预期少一截,这是实际项目里经常被忽略的差异。
3.3 定位函数和 LIKE 的性能与适用边界
定位函数和 LIKE '%xx%' 从执行计划上看同属无法利用普通索引的扫描型写法,性能量级差不多。定位函数的优势在于它返回一个数字位置,可以做进一步处理,比如判断子串出现在开头(返回 1)、取子串后面的内容:
-- 查找退款原因中"金额"出现的位置,并截取它之后 30 个字符 SELECT remark, SUBSTRING(remark, LOCATE('金额', remark), 30) AS around_amount FROM order_table WHERE LOCATE('金额', remark) > 0;这种能力 LIKE 给不了,LIKE 只能告诉你"有没有",不能告诉你"在哪"。所以如果你只是过滤,用哪个都行;如果要基于位置继续加工,定位函数是唯一合理选择。
我个人的习惯是:跨数据库的通用查询优先写 POSITION,虽然不同库的 POSITION 略有差异,但它比 CHARINDEX / INSTR 更接近 SQL 标准;如果是 MySQL 内部项目,直接 LOCATE;SQL Server 项目就用 CHARINDEX。选一个函数全项目统一,别跟数据库默认偏好较劲,后续维护的人会感谢你。
4. CSV 和 JSON 字段的"包含":集合成员查找的正确姿势
4.1 FIND_IN_SET:MySQL 的逗号分隔字符串专属
MySQL 提供 FIND_IN_SET,专门处理逗号分隔的字符串里有没有某个成员:
-- 返回 2,表示 'premium' 在第 2 个位置 SELECT FIND_IN_SET('premium', 'vip,premium,newuser'); SELECT * FROM user_table WHERE FIND_IN_SET('premium', user_tags) > 0;它的核心价值是精确的成员匹配。LIKE '%premium%'会把 'premium_plus' 一起匹配出来,FIND_IN_SET 不会,因为它按逗号边界逐项比较。
但别高兴太早,它有几个边界条件:
- 对空格敏感。
user_tags = 'vip, premium'这种数据里带着空格,FIND_IN_SET 匹配 'premium' 返回 0。写代码前先清理数据,或者查询时用 REPLACE 预处理。 - 分隔符只能是英文逗号。中文逗号、分号、竖线分隔的都不支持,需要其他手段。
- 字段为 NULL 时返回 NULL,空字符串时返回 0。
如果分隔符不是逗号,比如竖线分割的权限串,可以用 REPLACE 把分隔符转成逗号再 FIND_IN_SET,但要注意数据本身不能包含逗号,否则结果会失真。
PostgreSQL 没有 FIND_IN_SET,但可以用数组函数达到同样效果:
SELECT * FROM user_table WHERE 'premium' = ANY(string_to_array(user_tags, ','));SQL Server 没有 FIND_IN_SET,常见的替代是给字符串两边加逗号再用 LIKE:
WHERE ',' + user_tags + ',' LIKE '%,premium,%';这个写法的思路是人为制造边界,避免把 premium_test 误匹配进来。它仍然是一个 LIKE,性能不算好,但在数据量小、没法拆表的情况下是个务实方案。
4.2 JSON 数组:JSON_CONTAINS 与 Postgres 的 @> 运算符
如果字段是 JSON 数组,MySQL 5.7 以后可以这样:
-- tags_json 形如 ["premium","vip","newuser"] SELECT * FROM user_table WHERE JSON_CONTAINS(tags_json, '"premium"', '$');注意,第二个参数必须是合法的 JSON 字符串,所以字符串值要带双引号,是'"premium"'而不是'premium'。我第一次写完漏了引号,报错之后才意识到问题,这个细节面试里也很容易考到。
如果是数字成员,写JSON_CONTAINS(tags_json, '2', '$'),数字本身不需要额外引号。
PostgreSQL 用原生 jsonb 包含运算符,语义上更自然:
SELECT * FROM user_table WHERE tags_jsonb @> '["premium"]';@>表示右侧 JSON 是左侧的子集,所以左侧写完整字段,右侧写包含目标元素的数组。这是 jsonb 类型最常用的操作之一。要注意它匹配的是 JSON 值语义而不是字符串字面量,字符串 "2" 和数字 2 会被区分开,建索引和写查询时都得想清楚类型。
很多人都知道 JSON 结构方便灵活,但性能问题容易被忽略。MySQL 8.0 支持在 JSON 列上建多值索引,专门加速这类包含查询;PostgreSQL 则在 jsonb 上直接建 GIN 索引即可。没有索引时,千万行级 JSON 字段的包含判断就是逐行解析,慢到怀疑人生。
4.3 反范式字段的本质问题:能拆表就拆表
上面的函数再多,也只是在给"把多个值塞进一个字段"这种设计擦屁股。逗号分隔字符串有几个天生缺陷:
- 查询永远无法优雅地使用普通 B+ 树索引,只能靠函数或全文索引打补丁。
- 更新一个标签要读出整个字符串改完写回,并发场景容易互相覆盖。
- 一致性没法保证,可能出现 'vip,vip' 这种重复项或前后多加一个逗号的脏数据。
- 字符串长度有限,标签一多就存不下。
所以,如果标签是需要频繁按成员查询的维度,正确做法是拆成关联表:
-- 标签子表 CREATE TABLE user_tag ( user_id INT NOT NULL, tag_name VARCHAR(50) NOT NULL, PRIMARY KEY (user_id, tag_name) ); -- 查包含 premium 标签的用户 SELECT DISTINCT u.* FROM user_table u JOIN user_tag t ON t.user_id = u.user_id WHERE t.tag_name = 'premium';拆表之后,tag_name 直接走主键索引,千万行级 JOIN 通常都能在几十毫秒内完成,比起全表扫描简直是质变。我见过一个真实场景,某项目的用户标签匹配查询原来用 LIKE 全扫要十几秒,拆表后降到 20 毫秒左右,代价是写入时多一条 insert,但换来的是查询端的稳定和可预测,这笔账非常划算。
如果实在不想拆表,可以退而求其次用 JSON 类型加 JSON/GIN 索引,或者用 MySQL 的原生 SET 类型。但我的建议很直接:多值字段需要高频按值查询时,表结构设计阶段就应该往子表或 JSONB 方向走,而不是拿逗号字符串硬撑。
5. 性能工程:包含查询为什么慢,以及怎么救
5.1 前缀通配符与索引的缘分
一个常见的误解是"只要是等值或 LIKE 就一定能用索引"。实际上,B+ 树索引按字符串顺序存储键值,所以:
LIKE '退款%'可以转化为范围条件,索引可用。LIKE '%退款%'和LIKE '%退款'无法确定起点,只能全表扫描或扫全索引。
正则表达式和定位函数也一样,只要模式不是前缀确定型,普通索引都帮不上忙。
如果需要按"包含"查询,又不想全表扫,有几个变通方案:
- 前缀索引:
ALTER TABLE order_table ADD KEY idx_remark_prefix (remark(10));只能加速LIKE '退款%'这类前缀匹配,对中间包含没帮助。 - 冗余列:写入时额外维护一列
is_refund TINYINT,表示备注里是否含退款关键词,查询直接WHERE is_refund = 1。把计算从查询时挪到写入时,这是处理高频固定判断的常用手段。 - 反向设计:如果业务总在查"包含某个后缀"的模式,可以考虑存一个反转字符串列,把后缀查询变成前缀查询,从而用上索引。这个技巧听上去有点绕,但遇到特定场景确实很好用。
5.2 全文索引:处理真正的中段匹配
如果业务就是需要LIKE '%关键词%'这种中段包含,且数据量到了百万以上,普通索引已经没戏,就得上全文索引。
MySQL 的 InnoDB 全文索引配合 ngram 解析器可以处理中文:
ALTER TABLE order_table ADD FULLTEXT INDEX ft_remark (remark) WITH PARSER ngram; SELECT * FROM order_table WHERE MATCH(remark) AGAINST('退款' IN BOOLEAN MODE);要注意,全文索引的匹配语义和 LIKE 子串匹配不完全等价。它先分词再匹配,所以搜"退款"可能匹配到包含该词的文档,但分词粒度、停用词、最小 token 长度都会影响召回结果。目标关键词太短(小于 token 大小)时可能直接搜不到,需要先调整ngram_token_size这类参数。
PostgreSQL 有一个很讨喜的方案,pg_trgm 扩展能在普通 LIKE 模糊查询场景用 GIN 索引加速:
CREATE EXTENSION pg_trgm; CREATE INDEX idx_remark_trgm ON order_table USING GIN (remark gin_trgm_ops); -- 此时 LIKE '%退款%' 会走索引 SELECT * FROM order_table WHERE remark LIKE '%退款%';pg_trgm 的原理是提取连续三个字符的 trigram 做索引键,查询时把模式串也拆成 trigram 求交集。它对 3 个字符以上的关键词效果明显,对太短的词会退化成扫描。
全文索引不是免费的:写入变慢、索引占空间、查询计划可能不稳定。它适合"高频模糊搜索"场景,不适合一天只跑一次的临时分析,后者直接全扫反而省心。
5.3 数据模型级优化:拆表和冗余标记
当"包含"查询成为核心业务路径时,所有 SQL 层面的技巧都不如表结构设计来得彻底。
按前文说的,把多值字段拆成子表后,查询条件变成 tag_name 的等值匹配,这是数据库最擅长的事,索引和优化器都能发挥到极致。JSON 字段加 GIN 索引是妥协方案,保留灵活性的同时也能接受,但写入和校验成本比子表高。
还有一类查询,判断的维度是"某个关键词是否出现"这种布尔型语义。与其每次 LIKE,不如在写入时算好并落到一个布尔列,比如订单表的has_refund_flag。这个列上有索引,查询直接等值命中,耗时稳定在 1 毫秒左右。代价就是写入口要维护计算逻辑或者触发器,属于典型的空间换时间。
哪种方案适合你,可以从三个问题来判断:
- 判断结果是要做高频过滤,还是一次性分析?
- 匹配目标是否固定?关键词会不会经常变?
- 字段形态还能不能改?能不能拆表?
如果判断目标固定且高频,冗余布尔列最稳;如果关键词多变但查询高频,全文索引或 pg_trgm 合理;如果只是临时统计,LIKE 全扫也没关系,做好限流和 LIMIT 就行。最忌讳的是在千万级大表上,把LIKE '%关键词%'直接变成线上 API 的查询条件而不做任何加固。
6. 面试和选型实战中的应答逻辑
6.1 面试官问这个问题,其实想听分类讨论
"SQL 中判断一个字段是否包含一个数据的方法有哪些"这个问题,如果只回答"LIKE",在面试里只能算及格线。面试官真正想看的是:你能否意识到"包含"有不同语义,能否在不同数据库里挑出对应函数,能否说出性能差异。
一个好的回答结构大概是:先反问业务语义,是文本子串、多值成员还是精确归属;然后按语义给方案;最后补充性能边界——LIKE 通配符前缀会影响索引、中段包含要想到全文索引、多值字段优先拆表或用 JSON 索引。
这种结构化的回答,比背十个函数名更能体现项目经验。回答时主动提一句"不同数据库的函数名差异很大,迁移时要注意",基本就把自己的实战经验亮出来了。
6.2 一个可以抄作业的决策清单
以我实际项目里的习惯,面对"某字段是否包含某数据"时,会按下面这个顺序做决策:
| 场景 | 首选方案 | 备选方案 | 不建议 |
|---|---|---|---|
| 小表、低频过滤 | LIKE '%xx%' | 定位函数 | 上全文索引 |
| 大表、前缀匹配 | LIKE 'xx%' + 普通索引 | 前缀索引 | 裸 LIKE |
| 大表、中段匹配 | 全文索引 / pg_trgm | 冗余布尔列 | 裸 LIKE |
| 逗号分隔多值 | FIND_IN_SET | 拆子表 JOIN | LIKE '%x%' |
| JSON 多值 | jsonb / JSON_CONTAINS + 索引 | 拆子表 | LIKE 字符串 |
| 固定关键词高频判断 | 冗余布尔列 + 等值条件 | 触发器维护 | 每次 LIKE |
这个表是我多次踩坑总结出来的经验值,用在大多数业务系统里都成立。你也可以把它当成面试回答的提纲,按行展开讲,比零散列举函数强得多。
6.3 几个常见变体问题的思路
面试还经常出现一些变体,比如"查字段同时包含 A 和 B 的记录"。如果按子串理解,可以:
WHERE remark LIKE '%A%' AND remark LIKE '%B%';如果按正则,需要处理 AB 和 BA 两种顺序:
WHERE remark REGEXP 'A.*B|B.*A';再比如"查不包含某值的数据",核心坑是 NULL 处理。第 3 节已经说过定位函数返回 NULL 的场景,回答时主动提 NULL 会加分。
还有一个高频题:"如何优化 WHERE name LIKE '%abc%'"。除了全文索引、pg_trgm,还可以说数据量评估、前缀拆列、反向列、冗余标记等思路。把第 5 节的内容用自己的话讲一遍,这个问题基本就稳了。我在实际面试候选人的时候,也会特别关注对方能不能主动区分"文本包含"和"集合包含",能区分的人,项目经验通常都不会太差。