ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

MySQL全库搜索慢?全文索引、正则清洗与生成列的实战优化

MySQL全库搜索慢?全文索引、正则清洗与生成列的实战优化 上个月接手一套医疗病历库的时候我差点在会议室里直接道歉。两亿多条文本记录业务方提了两个需求第一把近半年内所有包含“咳嗽伴发热”的病例全捞出来做统计第二清洗历史数据里的电话号码和身份证号给敏感信息打码。我当时第一反应就是LIKE %咳嗽伴发热%扫全表结果单条查询跑了快40秒生产库的CPU直接飙到90%以上。全库搜索慢这个问题几乎每个做MySQL文本处理的人都踩过而且远不是加个索引就能蒙混过去的。这篇内容不是给你念官方文档而是把我在实战里反复验证过的三个方法整理出来全文索引的落地姿势、正则清洗的正确用法以及生成列这类“旁路思路”怎么帮你弯道超车。适合的人群很明确——被慢查询折磨的CRUD工程师、要处理海量非结构化文本的数据开发、以及所有绕不开数据库里脏数据的业务方。文中的案例全部基于MySQL 5.7和8.0实测存储引擎以InnoDB为准。1. 全库搜索为什么慢先定位瓶颈再谈优化很多人的第一反应是“给字段加索引”但加完发现EXPLAIN里还是typeALL甚至索引根本用不上。原因得从MySQL的索引结构说起。1.1 被忽略的索引失效场景最常见的坑是LIKE %关键词%。BTree索引天生适合前缀匹配比如LIKE 咳嗽%能走索引因为引擎可以在索引树上按“咳”字定位。一旦你把百分号放在关键词前面变成了“包含”语义MySQL就无法利用前缀匹配只能退化成全表扫描。数据量在百万级别的时候还能忍一旦到了千万、亿级一次全扫描意味着要读多少个数据页你可以自己算假设一行记录平均500字节一亿行就是50GB就算用SSD顺序读这个IO量也足够让业务超时。更隐蔽的是字符集和排序规则的问题。如果表的字符集是utf8mb4而字段又带着utf8mb4_general_ci的排序规则某些查询条件下索引可能会失效或者优化器认为用索引的代价比全表扫描还高。我见过一个案例一张表的主键索引只有3个字段其中一个文本字段明明可以走覆盖索引但优化器愣是选择了全表扫描原因就是查询里带了LEFT()函数和UPPER()函数导致索引列被计算索引直接失效。这类问题不通过EXPLAIN你根本察觉不到。1.2 数据量、字符集与正则表达式的三重叠加全库搜索慢的第二个瓶颈在数据量的线性增长和正则表达式的叠加效应上。MySQL的REGEXP操作符走的是全表扫描逐行匹配正则完全没有索引支持。一个正则表达式如果写得稍微复杂一点比如带了贪婪匹配、回溯分支单行匹配可能就要消耗几十毫秒。一亿行就是几十万秒哪怕并行拆成100个线程也要跑大半天。我排查时一般分三步做基线SHOW TABLE STATUS看行数、数据长度和Avg_row_length判断物理体量。EXPLAIN看查询计划确认是否走了索引、扫描行数是多少。用PROFILING或performance_schema看耗时分布区分是IO瓶颈还是CPU在跑正则。只有先明确瓶颈在哪后面的优化手段才不会白费。2. 方法一FULLTEXT全文索引毫秒级搜索的落地姿势把全表LIKE扫成毫秒级查询首选就是全文索引。MySQL从5.7开始对中文有了比较好的支持——内置了ngram分词器8.0版本里默认解析器也支持中文分词不需要额外装第三方插件。这一点比很多人的认知要先进他们总以为MySQL全文索引只能处理英文。2.1 创建索引前必须知道的限制创建全文索引不是“加个索引”那么轻巧先看限制全文索引只能建立在CHAR、VARCHAR、TEXT类型的字段上。一个表可以建多个全文索引但每个全文索引只能对应一组字段。ngram分词器需要指定token_size默认是2也就是按2个字符切词。这个参数直接决定中文搜索的粒度太小会切出大量无意义词太大会漏掉短关键词。全文索引和普通索引不能同时建在同一组字段上如果你既要精确匹配又要全文搜索可以考虑分开字段。我建索引的语法通常是这样的ALTER TABLE medical_records ADD FULLTEXT INDEX idx_content (chief_complaint) WITH PARSER ngram;注意WITH PARSER ngram这句如果不加MySQL会用默认空格分词器对中文来说等于没分查“咳嗽”可能匹配不到。2.2 三种搜索模式怎么选全文索引的查询语法是MATCH(col) AGAINST(关键词 [search_modifier])但三种模式的使用场景完全不同。自然语言模式默认SELECT record_id, chief_complaint FROM medical_records WHERE MATCH(chief_complaint) AGAINST(咳嗽 发热);这种模式适合把多个词做相关性排序返回。它会按照词频计算权重但注意它默认做的是“或”逻辑不是AND语义。也就是说你搜“咳嗽 发热”它可能把只含“发热”不含“咳嗽”的记录也拉出来只是相关性低。如果业务要求必须同时包含两个词得用布尔模式。布尔模式最常用SELECT record_id, chief_complaint FROM medical_records WHERE MATCH(chief_complaint) AGAINST(咳嗽 发热 IN BOOLEAN MODE);号表示必须包含-号表示排除*号可以做前缀匹配。布尔模式还支持引号做短语匹配比如反复咳嗽 发热只匹配包含“反复咳嗽”完整短语的记录。我们做病历搜索的最终方案就是全用布尔模式业务语义更精确不会出现缺词误召回。查询扩展模式SELECT record_id, chief_complaint FROM medical_records WHERE MATCH(chief_complaint) AGAINST(咳嗽 WITH QUERY EXPANSION);这个模式自动做“搜索-扩展-再搜索”本质是两轮查询把相关词也带出来。召回率很高但噪音也大适用于模糊调研场景不适合在线接口。2.3 全文索引最常见的坑第一坑ngram的token_size选错了。默认2切出来的词会有大量单字比如“肺结节”“甲状腺”这类医学词如果医嘱里写“右肺下叶结节”默认切词是“右肺|肺下|下叶|叶结|结节”你搜“肺结节”反而匹配不上因为“肺节”这个词根本不存在。后来我把ngram_token_size改成了2且配合innodb_ft_min_token_size调整并把关键词同步做了分词处理在业务层把“肺结节”拆成“肺结”和“结节”去搜才解决了问题。第二坑停用词。MySQL默认的全文索引停用词表是从InnoDB内置配置读的里面包含了上百个常见词比如a、an、the以及中文里的一些高频字比如“了”“的”“是”。如果病历里频繁出现“无明显异常”而“明显”这个词在默认停用词表里被过滤掉就可能造成漏检。解决方法是自定义innodb_ft_server_stopword_table把停用词表替换掉或者建全文索引时指定自定义的停用词表。第三坑全文索引同步和磁盘占用。全文索引的辅助表存储在mysql.innodb_ft_*目录下内容会随着DML操作实时变更。大批量更新数据时辅助表的碎片会很严重导致后续查询变慢。我一般会在批量ETL完成后执行OPTIMIZE TABLE重建全文索引而不是依赖在线小步更新。实战下来全文索引能把千万级的LIKE %咳嗽%从20秒降到0.1秒以内但前提是你得理解分词、停用词和模式选择。如果业务没法接受任何召回偏差那就要组合下面第二个方法。3. 方法二REGEXP_REPLACE直接怼正则清洗但别硬刚正则清洗的全库处理难点不在正则本身而在于如何在MySQL里高效地把“脏数据”洗成“净数据”。MySQL 8.0提供了REGEXP_REPLACE()函数但这并不是银弹。3.1 正则清洗的典型场景我遇到的真实场景基本可以归成三类敏感信息打码、格式统一、字符净化。敏感信息打码UPDATE medical_records SET patient_phone REGEXP_REPLACE(patient_phone, ([0-9]{3})[0-9]{4}([0-9]{4}), \\1****\\2) WHERE patient_phone REGEXP [0-9]{11};这条SQL会把13812345678变成138****5678。关键点在正则的捕获组()和替换串中的\1、\2。MySQL的反斜杠转义比较麻烦在SQL里你要写\\1而不是\1很多人第一次写都会栽在这上面。格式统一医疗文本里常见的全半角混用比如“咳嗽\n发热”、“咳嗽 发热”多次空格。我写清洗SQL时会把连续空白符压成单空格UPDATE medical_records SET chief_complaint REGEXP_REPLACE(chief_complaint, [[:space:]], );POSIX字符类[[:space:]]比直接写\s更安全原因是在MySQL的正则引擎里\s的兼容性不如POSIX表达式稳定。字符净化常见做法是剔除不可见控制字符或乱码。业务里出现过从外部导入的病历夹带\x00和\x01导致报表系统崩溃的情况UPDATE medical_records SET chief_complaint REGEXP_REPLACE(chief_complaint, [\\x00-\\x1F], ) WHERE chief_complaint REGEXP [\\x00-\\x1F];3.2 性能与安全边界REGEXP_REPLACE最大的问题是它逐行扫描、逐行计算完全没有索引可用。对一张千万级表做全量UPDATE清洗光IO就能把生产库拖垮。我把这套清洗分为四步走先查后改先用SELECT COUNT(*)配合WHERE条件确定需要清洗的行数而不是直接跑UPDATE。分批提交按主键范围或时间字段分批处理每批LIMIT几百到几千行避免长事务持有锁导致主从延迟。备份保护清洗前用CREATE TABLE ... AS SELECT或者mysqldump备份原表至少保证能回滚。先查后改每批更新后用ROW_COUNT()确认影响行数防止误匹配。正则表达式还有一种隐蔽的性能杀手回溯。如果写成([0-9])这种嵌套量词遇上很长的数字串匹配会爆炸式消耗CPU。这种问题在线上表现为UPDATE语句长时间跑不完SHOW PROCESSLIST看STATE一直卡在Updating。所以在生产环境跑正则清洗之前我会在测试机上用一段小的数据集跑BENCHMARK()测试或者直接在查询后加LIMIT看执行计划。正则表达式并不是越通用越好能用前置条件过滤掉的脏数据就不要让正则去全表兜底。3.3 清洗流程的工程化规范我把自己的清洗流程总结成了一套模板项目里直接复用清洗前用SHOW CREATE TABLE确认字符集避免清洗后的数据在新字段中出现???。清洗脚本统一用存储过程包装包含异常捕获和日志表插入方便出问题时看到底是哪一批数据出了问题。对大批量清洗优先考虑把数据导出到文件用Python或Shell做预处理再通过LOAD DATA INFILE批量回导。因为正则清洗本身就是CPU密集型操作让数据库做这种活效率远不如专业脚本语言。这里很容易被忽略的一点是正则替换的结果字段很可能需要回写到一个新字段而不是覆盖原字段。比如我把raw_phone_old保留一份清洗结果写入phone_masked两个字段同时存在。这样既满足监管要求留痕也为后续数据对比留了余地。4. 方法三用生成列 函数索引在MySQL内部完成预计算第三个方法适合“清洗完了还要持续搜索”的场景。它的核心思路是把正则清洗的结果落成一个独立的列并对这个列建索引让查询走索引而不是每次全表清洗。4.1 为什么想到生成列这个思路的来源是我做医疗数据标准化时的一个痛点原始表里存的是非结构化文本但业务又要持续按清洗后的标准值去过滤。比如主诉里经常出现“咳嗽3天余”“咳嗽三天”这类口语表达如果每次都先REGEXP_REPLACE再比较查询就不可能用索引。生成列让我们可以把“清洗后的值”物化为一个虚拟列建索引后查询直接命中。MySQL从5.7开始支持生成列Generated Column语法是ALTER TABLE medical_records ADD COLUMN chief_complaint_clean VARCHAR(500) GENERATED ALWAYS AS (REGEXP_REPLACE(chief_complaint, 天余, 天)) STORED;注意STORED关键字的两种选项VIRTUAL不占用磁盘空间每次查询时实时计算适合计算量小的场景。STORED物理存储占磁盘空间但查询时不需要重新计算适合频繁查询的清洗字段。对于正则清洗这类CPU密集型操作我建议用STORED。虽然多占了磁盘但清洗结果稳定不变查询性能收益远大于存储开销。4.2 实操演示把生成列和索引用起来继续用病历表的例子。假设我们要把主诉中的“天余”统一替换为“天”同时提取出年龄字段用于后续搜索ALTER TABLE medical_records ADD COLUMN age_text VARCHAR(10) GENERATED ALWAYS AS (REGEXP_SUBSTR(chief_complaint, [0-9]{1,3}岁)) STORED; ALTER TABLE medical_records ADD INDEX idx_age_text (age_text);建完索引后查询就可以直接按这个清洗结果过滤SELECT record_id, chief_complaint FROM medical_records WHERE age_text 68岁 AND MATCH(chief_complaint) AGAINST(咳嗽 IN BOOLEAN MODE);这条SQL的age_text能走普通索引全文搜索走全文索引二者结合性能表现比实时正则清洗至少快10倍。有一次我在线上优化一个“按主诉关键词年龄范围”筛选的接口原来要3秒多改成生成列方案后压到了80毫秒基本就是走索引的极限速度。4.3 生成列的边界与取舍生成列并不是想建就能建的有几个硬性前提生成表达式必须是“确定的”同样的输入必须产生同样输出。像NOW()、UUID()这类函数不能用于生成列。REGEXP_REPLACE和REGEXP_SUBSTR在MySQL 8.0里是确定性函数可以用但在MySQL 5.7里它们不是默认允许的生成列表达式需要斟酌兼容性。生成列上建索引有额外限制如果生成列表达式用到了全文索引字段不能再在生成列上建全文索引普通索引没问题。另外一个取舍是不要在生成列里堆太多逻辑。生成列的可读性和维护成本其实不小如果清洗逻辑过于复杂不如在应用层清洗完再落地一张新表MySQL只承担存储和索引的角色。生成列适合的是清洗规则相对稳定、逻辑评分简单的场景。我给的选型建议是清洗逻辑少于3步、表达式短用生成列逻辑复杂到需要判断分支就别硬上生成列老老实实做ETL后写宽表。5. 三种方法怎么选实测对比与完整结论方法讲完了回到最开头的问题全库搜索慢、正则清洗难到底该用哪个我把三种方法的核心区别和适用场景做了个对比。对比项FULLTEXT全文索引REGEXP_REPLACE清洗生成列 索引搜索延迟量级毫秒级秒级全表扫描毫秒级走索引中文分词支持需要ngram分词器与分词无关与分词无关是否需要清洗不需要但召回率受分词影响清洗本身就是目的需要先定义清洗表达式适用查询场景关键词、短语、相关性排序一次性历史数据整治按清洗后的字段高频过滤维护成本中高分词器和停用词调优复杂低跑完即止中存储占用上升表达式变更需重建列典型例子搜索含“咳嗽伴发热”的病历全场打码手机号、身份证号按“年龄主诉关键词”组合查询三种方法不是互相排斥的。我的做法一般是先跑一次REGEXP_REPLACE做历史数据清洗把脏数据清成规范文本然后对规范文本建FULLTEXT全文索引应对关键词模糊搜索如果业务有按清洗结果的高频过滤需求再把清洗结果落成生成列建索引。三管齐下搜索又准又快。真实的性能测试数据我留了一份在单表2000万行、每行主诉文本平均200字的环境中LIKE %咳嗽%耗时23秒FULLTEXT自然语言模式0.08秒布尔模式0.12秒。加上语气词过滤和ngram分词调优后召回率从原来的61%提升到了88%误召回率保持在3%以下。这套组合拳让我在一周内把整个医疗病历检索模块的性能拉回到了可用的水平。5.1 踩过的坑提前告诉你最后说一些实操中的细节免得你重走弯路。坑一全文索引对“只改不动”的大表好用但对频繁UPDATE的表维护成本极高。每次修改chief_complaint字段全文索引的辅助表都要同步更新写入性能会明显下降。如果业务是写入频繁、搜索低频全文索引的收益会被写放大抵消。这种情况我会换成全文索引之外的路子——老老实实建一张独立的“搜索表”写入时同步更新查询只走搜索表。坑二ngram分词器对中文短词的研究不够的时候不要迷信默认配置。token_size2对大部分通用场景可以但医学领域有很多三字词、四字词比如“肺源性心脏病”默认2字切分会把词切碎导致搜索“肺心”时匹配不到“肺源性”。这种情况下要么在应用层做分词并拼接布尔短语要么考虑引入jieba分词后把结果存成单独的分词字段用FULLTEXT索引分词字段。前者适合少量关键词后者适合大规模复杂语义搜索。坑三用REGEXP_REPLACE做清洗时默认的正则引擎对中文处理不够友善。中文文本里经常混着全角括号“”和英文半角括号“()”POSIX字符类里只有[[:alpha:]]和[[:blank:]]能统一识别但无法区分全半角。我建议在清洗阶段先用REPLACE(REPLACE(col,,(),,))这类朴素替换再做正则省得正则表达式被全角字符搞得分崩离析。坑四生成列会随着原表变更自动重算这个特性有好处也有风险。有次我在生产环境给一个在线表加STORED生成列清洗表达式里带了一个递归正则结果是MySQL在转换过程中锁了整表线上写入直接卡了五分钟。加生成列之前务必在预发环境测过表达式性能和索引建立时间再挑业务低峰期操作。这三个方法里没有绝对的“最优”全看业务形态。全库搜索慢不是无解的正则清洗难也不是必须硬啃的关键是找到那个让你不再每次写LIKE都瑟瑟发抖的组合。我个人现在做同类项目时的默认流程是线上库从来不直接LIKE搜索一律走搜索表或全文索引涉及敏感字段的历史数据先离线清洗再落地持续性的过滤条件能用生成列就用生成列没有适合字段就建宽表。这套思路在医疗、日志分析、内容管理系统里都验证过多次至少帮你把“慢”和“难”变成“可控”。如果你也正在处理MySQL文本数据这3个方法完全可以照着试一遍。
返回列表