Solr搜索引擎优化实战:从MySQL慢查询到亿级数据毫秒级响应

Solr搜索引擎优化实战:从MySQL慢查询到亿级数据毫秒级响应 简介基于Apache Solr构建的大数据信息检索系统完整项目适合海量数据检索研发工程师、安全数据分析人员及相关专业学生。系统采用分布式架构支持姓名、身份证、手机号等关键信息的快速索引与多条件组合查询致力于解决大数据场景下高并发查询与索引性能瓶颈。整个压缩包包含2000个文件其中1959个HTML页面构成前端查询与展示界面TXT文档说明配置与使用CSS/JS完善页面交互DOC/PDF/MD等提供设计笔记与说明文档包体约157.48MB。已有35人学习下载。项目内含分布式部署配置、索引Schema设计、前端样式与交互逻辑以及Lucene底层索引文件便于开发者对照源码理解Solr核心机制可快速迁移到实际业务场景适合作为课程设计、毕业设计或企业快速原型搭建的参考资产。1. 当5000万条用户记录压在MySQL上查询响应从秒级退化到几十秒一次真实的业务改造发生在我的数据团队原本用MySQL存储约5000万条用户登记信息业务方要求按姓名、手机号、身份证任意条件快速检索并要求联合条件过滤。初期数据量在500万时MySQL的LIKE %关键词%还能勉强支撑等数据涨到5000万后单次模糊查询耗时超过30秒数据库CPU持续打满主从延迟从秒级扩大到分钟级。尝试过前缀索引、联合索引调整但中文分词和模糊匹配始终绕不开全表扫描。这类需求天生适合倒排索引。Apache Solr基于Lucene构建天然支持海量数据的索引与分布式检索。我们用Solr重做了信息检索层把原本30秒的查询压到毫秒级。本文记录从索引设计到分布式部署的完整过程重点讲清楚字段类型怎么选、分片怎么划、导入查询参数怎么调以及敏感字段如何做合规处理。整个方案基于Solr 8.x适合处理千万到亿级数据量的搜索场景。先立住一个结论Solr的索引空间换查询速度的收益比在数据库里堆索引高一个量级。2. 用Solr重新设计索引从倒排索引到docValues2.1 为什么是Solr而不是Elasticsearch团队里当时也有人提议直接用Elasticsearch但最终选择Solr有明确依据。第一我们已有Zookeeper集群SolrCloud原生依赖ZK做协调运维成本可控。第二业务要求大量结构化字段的精确匹配如身份证号、手机号Solr的字段类型设计和facet统计更直接。第三我们已经有一些基于Lucene的索引文件项目包里就有_8_Lucene50_0.doc这类文件说明底层索引结构可以复用。Elasticsearch的RESTful API更现代但Solr的Schema配置更集中在需要精细控制索引结构的数据检索场景下更容易调优。倒排索引解决的是“词到文档”的映射而DocValues解决的是“文档到字段值”的排序、聚合和随机访问。两者组合既能支持快速的全文匹配又能在亿级数据上做sort和facet。Solr在这一点上比MySQL成熟得多。例如在MySQL里要对身份证号做等值匹配通常建一个普通B-Tree索引但遇到“手机号模糊后四位”这种查询B-Tree几乎失效而Solr可以通过EdgeNgram或Wildcard查询直接命中倒排索引。2.2 schema.xml字段类型身份证、手机号、姓名怎么设计进入实战前先看核心字段的XML配置。managed-schema里我们定义了四个关键字段field nameid typestring indexedtrue storedtrue requiredtrue docValuestrue/ field namename typetext_ik indexedtrue storedtrue docValuesfalse/ field nameid_card typestring indexedtrue storedtrue docValuestrue/ field namemobile typestring indexedtrue storedtrue docValuestrue/ field nameaddress typetext_ik indexedtrue storedtrue docValuesfalse/这里id用string类型mobile、id_card也定义为string是因为手机号、身份证这类号码本质是标识符不需要分词必须用精确匹配。如果误用text_general会造成“18812345678”被拆成“188”、“1234”等Token查一个完整号码时会匹配到大量无关文档。name和address用了text_ik这是IK中文分词器支持词典维护适合姓名、地址这类文本检索。注意docValues属性对需要排序、聚合或用作filter的字段如id_card、mobile开启docValues后Solr会为这些字段建立一个列式存储排序时不再从倒排索引里取字段值速度提升明显。name和address开启docValues反而会占用大量磁盘所以保持false。2.2.1 精确匹配字段的细节身份证号可能存在数字字母混合string类型索引时不做大小写转换查询时也要求完全一致。为了兼容某些场景下的脱敏查询我们额外增加了一个id_card_last6字段专门存后6位用于“只知道尾号”的查询。field nameid_card_last6 typestring indexedtrue storedfalse docValuesfalse/这个字段在数据导入时通过脚本截取后6位写入。同理手机号如果支持“后四位”模糊查可以增加mobile_last4字段。这比MySQL的LIKE %1234%高效得多因为后4位在倒排索引中是完整的Term查询复杂度接近O(1)。2.2.2 中文分词与拼音检索姓名检索有一个特殊需求输入“李娜”和“li na”都希望能命中。Solr里可以通过配置拼音过滤器实现。IK分词器配合pinyinfilter可以达到这个效果。不过要注意pinyinfilter会显著增加索引体积我们的做法是仅对name字段启用全拼首字母索引不搞完整拼音分词。fieldType nametext_ik_pinyin classsolr.TextField analyzer typeindex tokenizer classorg.wltea.analyzer.lucene.IKTokenizer/ filter classcom.shentong.search.analyzers.PinyinTokenFilter originaltrue pinyintrue firstChartrue/ /analyzer analyzer typequery tokenizer classorg.wltea.analyzer.lucene.IKTokenizer/ /analyzer /fieldType图中originaltrue表示保留原始汉字Termpinyintrue生成全拼firstChartrue生成首字母。这样索引里同时存在“李娜”、“lina”、“ln”查询时输入任何一个都能命中。代价是索引空间增加约40%在我们这个场景下可接受。如果是大规模生产环境建议用firstChar就够了pinyin开在产品上对内存压力不小。2.3 索引构建时的内存与磁盘权衡Lucene底层用分段存储每个段是不可变的。索引写入时先写到内存中的Buffer达到一定阈值后flush成段。在Solr的solrconfig.xml里我们调整了以下几个参数参数值理由ramBufferSizeMB512增大内存buffer减少段数量maxBufferedDocs100000与内存buffer配合控制flush时机mergeFactor10段合并时的每层段数量调大后段更少但合并代价高nrtMode1开启近实时搜索提交后百毫秒内可见索引合并是大数据量下的隐形杀手。默认mergeFactor为10在亿级数据下段数量很多查询时会跨段检索响应变慢。我们最终设置为8实测在峰值写入每秒2000条时合并线程不阻塞查询。另外如果正在索引大量历史数据建议临时关掉自动合并等全量索引完成后再执行optimize把碎片变成一个段查询性能能达到峰值。3. 分布式架构SolrCloud分片与Zookeeper协调3.1 分片策略按什么路由单机扛不住5000万条数据时第一反应是上SolrCloud。SolrCloud中一个collection可以分成多个shard分片每个shard拥有独立索引。分片之间通过docValues字段的值哈希来决定文档落在哪个shard。我们使用默认的compositeId路由。创建集合时指定solrctl collection --create user_search -s 4 -r 2 -c config_set_user_v1-s 4表示4个分片-r 2表示每个分片2个副本。分片数量怎么定不是越多越好。我们总结的经验是每个shard承载的数据量不超过2000万条索引大小控制在50GB以内。5000万条数据4个分片每个1250万查询并发能力约是单机的4倍磁盘I/O也分散了。分片路由键我们选了mobile而不是id。原因在于业务查询大多数带手机号条件用compositeId把mobile哈希后同一个手机号的相关信息会被路由到同一分片后续如果要join用户扩展表可以在分片内完成。3.1.1 重分片与扩容陷阱SolrCloud目前不支持自动rehash分片。如果业务从4个分片扩展到8个没有现成命令常见做法是新建一个8分片的collection然后跑一次全量导出导入。我们踩过这个坑所以设计了双collection切换新数据写新集合并试跑验证后再切流量。整个过程涉及Zookeeper上配置集更新建议先在一台非生产节点演练。3.2 副本与故障转移每个shard的-r 2保证多数副本存活时数据不丢。SolrCloud通过Zookeeper监控节点状态当某个Solr节点宕机存活副本会接管查询。写入时leader负责协调并把日志写入事务日志。一个容易忽略的点是副本与分片应尽量分散在不同物理机。我们用以下JVM参数控制堆内存并显式关闭swap# solr.in.sh SOLR_JAVA_MEM-Xms16g -Xmx16g SOLR_OPTS$SOLR_OPTS -Xss1m -XX:MaxDirectMemorySize4gSolr的sort、facet操作会占用堆外内存。MaxDirectMemorySize设得太小高并发聚合时容易抛OutOfMemoryError: Direct buffer memory。我们线上32GB堆内存分片数4单节点正常。若单节点堆内存超过32GBGC压力会很大这时候应该加节点而不是加堆。3.3 从单机到集群的配置清单Zookeeper推荐3个节点Solr集群4个节点。创建collection后通过配置文件检查状态curl http://solr-node1:8983/solr/admin/collections?actionCLUSTERSTATUScollectionuser_search输出中每个shard会显示state: active同时能看到每个副本所在节点。如果state是recovering说明分片正在同步长时间卡在recovering要检查节点间的防火墙和Solr端口。我们实践中发现集群部署前先规划好Zookeeper的chroot路径避免多个环境共用一个ZK导致互相影响。比如生产环境chroot为/solr/prod测试环境为/solr/test这样同一个Zookeeper集群能隔离。4. 数据导入与查询实战常用参数和踩坑记录4.1 用DataImportHandler从MySQL拉取数据数据量大时全量导入需要分批。我们使用DataImportHandler时配置了增量导入dataConfig dataSource typeJdbcDataSource drivercom.mysql.jdbc.Driver urljdbc:mysql://10.0.0.3:3306/user_db?useSSLfalse usersolr_reader passwordxxx batchSize1000/ document entity nameuser_info querySELECT id, name, id_card, mobile, address, last_update_time FROM t_user WHERE $DIH.DELTA_QUERY deltaImportQuerySELECT id, name, id_card, mobile, address, last_update_time FROM t_user WHERE id${dih.delta.id} field columnid_card nameid_card/ field columnid_card_last6 nameid_card_last6 sourceColid_card regex(.{6})$ replaceWith$1/ /entity /document /dataConfig这里的regex和replaceWith用来截取身份证后6位。具体来说sourceCol指定来源字段regex(.{6})$表示匹配末尾的6个字符replaceWith$1取第一个捕获组。注意在DataImportHandler中regex替换是Java的Matcher.replaceAll如果字段是null会直接抛异常所以SQL里要用IFNULL。增量导入的时间戳字段必须建索引MySQL侧ALTER TABLE t_user ADD INDEX idx_last_update_time (last_update_time);否则每轮增量都会扫描全表。这算是大数据场景下的mysql索引典型应用数据库侧保留查询字段索引Solr侧做全文与精确检索两者职责分离。4.2 查询语法q、fq、fl、sort、hl参数解释Solr查询常用参数如下每一个都有实际踩坑经验。curl http://solr-node1:8983/solr/user_search/select?qname:%E6%9D%8E%E5%A8%9Cfqmobile:138*flid,name,mobilesortid%20deschltruehl.flnamestart0rows20q是主查询name:李娜会分词后匹配倒排索引。fq是过滤查询mobile:138*使用通配符前缀Solr对string类型字段的前缀查询会转为倒排索引范围扫描性能可接受。fl控制返回字段sort用id deschl开启高亮。一个关键坑hltrue时默认hl.requireFieldMatchfalse高亮片段可能来自其他字段。这里设置hl.flname即可保证高亮词来自name字段。fq与q不同fq会缓存过滤结果如果某个过滤条件经常复用性能提升明显。我们实际把“固定业务范围”的过滤条件都放在fq而不是拼到q里这样命中filterCache的概率更高。solrconfig.xml中filterCache默认initialSize4096对于9000万次查询命中率能到75%。4.2.1 分组查询与facet敏感信息检索往往要统计某个地区同名人数。用facet字段实现curl http://solr-node1:8983/solr/user_search/select?qname:%E5%BC%A0%E4%B8%89facettruefacet.fieldaddress_provincefacet.limit10address_province需要docValuestrue否则facet开销非常大。我们初期忘记给该字段开docValues导致一次facet查询消耗1.2秒开启后降到80ms。4.3 深度分页问题与游标方案业务方喜欢“点100页”看数据Solr默认start9000, rows10在亿级数据下会拖垮。因为Solr需要从每个分片获得前(startrows)条结果再在协调节点聚合。分片越多深分页越慢。标准解法是游标CursorMark基于_root_字段排序。语法如下curl http://solr-node1:8983/solr/user_search/select?q*:*sortid%20desccursorMark*rows1000第一次返回结果末尾带nextCursorMark下次请求把该值赋给cursorMark参数循环直到Marks不变。游标不适用于跳页只适合顺序扫描全量数据。我们导出全量数据给大数据分析平台时就用这个接口连续跑3天没有内存溢出。下表列出深度分页与游标的区别场景使用方式性能需求要跳页start/rows限制start1000010000以内可接受顺序抓取全量cursorMark稳定无深分页损耗筛选后导入数仓fqsortcursorMark推荐5. 敏感信息的合规处理与查询性能调优5.1 敏感字段加密与脱敏先声明一个硬性前提这个系统只能用来检索企业自有授权数据严禁采集、存储或查询未授权个人信息。我们做合规改造时做了三层处理存储层对身份证号、手机号使用AES-256加密后再写入Solr这样即使索引段被拖走也拿不到明文。检索层增加动态脱敏查询响应中默认只返回前3后4位。具体在Solr的updateRequestProcessorChain里写了一个自定义处理器对mobile写入时加密processor classcom.example.solr.CryptoFieldProcessor str namefieldmobile/str str namealgoAES/GCM/NoPadding/str /processor查询时flmobile返回的是密文再由查询网关统一解密并脱敏。直接访问Solr端口是拿不到明文的。这里需要警惕即使内部系统也不要把Decrypt逻辑放在Solr节点上否则一旦被攻破就会连带泄露。5.2 认证与权限控制Solr默认没有认证最简单的方式是开启Basic Authsolr auth enable -type basic -credentials admin:secret -prompt但Better方案是使用Jetty的HashLoginService并启用SSL。我们生产上是把Solr节点放在内网并通过防火墙限制8983端口只允许微服务网关访问。Zookeeper端口也绝不暴露。5.3 性能调优从缓存到索引合并大数据量下solrconfig.xml中的queryResultCache、filterCache要结合堆内存设置。我们的配置经验是参数分配说明filterCache512MB按fq条件缓存集合位图queryResultCache64MB只缓存文档id列表不缓存字段值documentCache128MB缓存stored字段对fl*查询有效缓存容量不是越大越好太大导致GC频繁。判断命中率可以通过Solr Admin的Plugins/Stats页看filterCache hitratio低于0.7说明缓存被反复清理应适当调大或检查fq条件是否过于分散。索引合并性能调优点在于mergePolicy。默认使用TieredMergePolicy对数据写入频繁的场景我们改为LogByteSizeMergePolicy并设置maxMergeSizeMB2048避免一次合并超大段阻塞查询。如果磁盘允许建议启用solr.hdfs.home把索引段放到HDFS上读取性能没有明显下降但合并时的I/O压力更平滑。5.4 一个实用排查技巧慢查询诊断当查询响应变慢先看是不是集群某个分片响应过慢。用Solr的/debug/segments接口curl http://solr-node1:8983/solr/user_search/debug/segments? 返回每个分片的segment数量、doc数、删除比例。如果某个分片删除比例超过30%说明大量更新操作累积了tombstones需要执行optimize强制合并。这个操作我通常在凌晨低峰跑curl -X POST http://solr-node1:8983/solr/user_search/update?optimizetruemaxSegments1waitFlushtrue对于5000万数据optimize大概耗时20到40分钟。跑完后segment数变为1查询RT下降20%以上。最后提供一个排查慢查询的脚本思路在Solr查询日志中把超过1秒的请求提取出来按字段归类。如果集中在某个string字段的前缀查询考虑加edge-ngram tokenizer如果集中在text_ik字段的短词查询考虑开启enableGraphFragment。项目包里如果带上了Lucene索引文件可以用luke工具离线查看每个字段的Term分布找出最长Term和异常高频Term这往往是查询慢的根源。本文还有配套的精品资源点击获取