简介:基于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重新设计索引:从倒排索引到docValues
2.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 name="id" type="string" indexed="true" stored="true" required="true" docValues="true"/> <field name="name" type="text_ik" indexed="true" stored="true" docValues="false"/> <field name="id_card" type="string" indexed="true" stored="true" docValues="true"/> <field name="mobile" type="string" indexed="true" stored="true" docValues="true"/> <field name="address" type="text_ik" indexed="true" stored="true" docValues="false"/>这里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 name="id_card_last6" type="string" indexed="true" stored="false" docValues="false"/>这个字段在数据导入时通过脚本截取后6位写入。同理,手机号如果支持“后四位”模糊查,可以增加mobile_last4字段。这比MySQL的LIKE '%1234%'高效得多,因为后4位在倒排索引中是完整的Term,查询复杂度接近O(1)。
2.2.2 中文分词与拼音检索
姓名检索有一个特殊需求:输入“李娜”和“li na”都希望能命中。Solr里可以通过配置拼音过滤器实现。IK分词器配合pinyinfilter可以达到这个效果。不过要注意pinyinfilter会显著增加索引体积,我们的做法是仅对name字段启用全拼首字母索引,不搞完整拼音分词。
<fieldType name="text_ik_pinyin" class="solr.TextField"> <analyzer type="index"> <tokenizer class="org.wltea.analyzer.lucene.IKTokenizer"/> <filter class="com.shentong.search.analyzers.PinyinTokenFilter" original="true" pinyin="true" firstChar="true"/> </analyzer> <analyzer type="query"> <tokenizer class="org.wltea.analyzer.lucene.IKTokenizer"/> </analyzer> </fieldType>图中original="true"表示保留原始汉字Term,pinyin="true"生成全拼,firstChar="true"生成首字母。这样索引里同时存在“李娜”、“lina”、“ln”,查询时输入任何一个都能命中。代价是索引空间增加约40%,在我们这个场景下可接受。如果是大规模生产环境,建议用firstChar就够了,pinyin开在产品上对内存压力不小。
2.3 索引构建时的内存与磁盘权衡
Lucene底层用分段存储,每个段是不可变的。索引写入时先写到内存中的Buffer,达到一定阈值后flush成段。在Solr的solrconfig.xml里,我们调整了以下几个参数:
| 参数 | 值 | 理由 |
|---|---|---|
ramBufferSizeMB | 512 | 增大内存buffer,减少段数量 |
maxBufferedDocs | 100000 | 与内存buffer配合控制flush时机 |
mergeFactor | 10 | 段合并时的每层段数量,调大后段更少,但合并代价高 |
nrtMode | 1 | 开启近实时搜索,提交后百毫秒内可见 |
索引合并是大数据量下的隐形杀手。默认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:MaxDirectMemorySize=4g"Solr的sort、facet操作会占用堆外内存。MaxDirectMemorySize设得太小,高并发聚合时容易抛OutOfMemoryError: Direct buffer memory。我们线上32GB堆内存,分片数4,单节点正常。若单节点堆内存超过32GB,GC压力会很大,这时候应该加节点而不是加堆。
3.3 从单机到集群的配置清单
Zookeeper推荐3个节点,Solr集群4个节点。创建collection后,通过配置文件检查状态:
curl "http://solr-node1:8983/solr/admin/collections?action=CLUSTERSTATUS&collection=user_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 type="JdbcDataSource" driver="com.mysql.jdbc.Driver" url="jdbc:mysql://10.0.0.3:3306/user_db?useSSL=false" user="solr_reader" password="xxx" batchSize="1000"/> <document> <entity name="user_info" query="SELECT id, name, id_card, mobile, address, last_update_time FROM t_user WHERE $DIH.DELTA_QUERY" deltaImportQuery="SELECT id, name, id_card, mobile, address, last_update_time FROM t_user WHERE id='${dih.delta.id}'"> <field column="id_card" name="id_card"/> <field column="id_card_last6" name="id_card_last6" sourceCol="id_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?q=name:%E6%9D%8E%E5%A8%9C&fq=mobile:138*&fl=id,name,mobile&sort=id%20desc&hl=true&hl.fl=name&start=0&rows=20"q是主查询,name:李娜会分词后匹配倒排索引。fq是过滤查询,mobile:138*使用通配符前缀,Solr对string类型字段的前缀查询会转为倒排索引范围扫描,性能可接受。fl控制返回字段,sort用id desc,hl开启高亮。
一个关键坑:hl=true时,默认hl.requireFieldMatch=false,高亮片段可能来自其他字段。这里设置hl.fl=name即可保证高亮词来自name字段。
fq与q不同,fq会缓存过滤结果,如果某个过滤条件经常复用,性能提升明显。我们实际把“固定业务范围”的过滤条件都放在fq,而不是拼到q里,这样命中filterCache的概率更高。solrconfig.xml中filterCache默认initialSize=4096,对于9000万次查询,命中率能到75%。
4.2.1 分组查询与facet
敏感信息检索往往要统计某个地区同名人数。用facet字段实现:
curl "http://solr-node1:8983/solr/user_search/select?q=name:%E5%BC%A0%E4%B8%89&facet=true&facet.field=address_province&facet.limit=10"address_province需要docValues=true,否则facet开销非常大。我们初期忘记给该字段开docValues,导致一次facet查询消耗1.2秒,开启后降到80ms。
4.3 深度分页问题与游标方案
业务方喜欢“点100页”看数据,Solr默认start=9000, rows=10在亿级数据下会拖垮。因为Solr需要从每个分片获得前(start+rows)条结果,再在协调节点聚合。分片越多,深分页越慢。
标准解法是游标(CursorMark),基于_root_字段排序。语法如下:
curl "http://solr-node1:8983/solr/user_search/select?q=*:*&sort=id%20desc&cursorMark=*&rows=1000"第一次返回结果末尾带nextCursorMark,下次请求把该值赋给cursorMark参数,循环直到Marks不变。游标不适用于跳页,只适合顺序扫描全量数据。我们导出全量数据给大数据分析平台时就用这个接口,连续跑3天没有内存溢出。
下表列出深度分页与游标的区别:
| 场景 | 使用方式 | 性能 |
|---|---|---|
| 需求要跳页 | start/rows,限制start<10000 | 10000以内可接受 |
| 顺序抓取全量 | cursorMark | 稳定,无深分页损耗 |
| 筛选后导入数仓 | fq+sort+cursorMark | 推荐 |
5. 敏感信息的合规处理与查询性能调优
5.1 敏感字段加密与脱敏
先声明一个硬性前提:这个系统只能用来检索企业自有授权数据,严禁采集、存储或查询未授权个人信息。我们做合规改造时做了三层处理:存储层对身份证号、手机号使用AES-256加密后再写入Solr,这样即使索引段被拖走也拿不到明文。检索层增加动态脱敏,查询响应中默认只返回前3后4位。具体在Solr的updateRequestProcessorChain里写了一个自定义处理器,对mobile写入时加密:
<processor class="com.example.solr.CryptoFieldProcessor"> <str name="field">mobile</str> <str name="algo">AES/GCM/NoPadding</str> </processor>查询时,fl=mobile返回的是密文,再由查询网关统一解密并脱敏。直接访问Solr端口是拿不到明文的。这里需要警惕,即使内部系统,也不要把Decrypt逻辑放在Solr节点上,否则一旦被攻破就会连带泄露。
5.2 认证与权限控制
Solr默认没有认证,最简单的方式是开启Basic Auth:
solr auth enable -type basic -credentials admin:secret -prompt但Better方案是使用Jetty的HashLoginService并启用SSL。我们生产上是把Solr节点放在内网,并通过防火墙限制8983端口只允许微服务网关访问。Zookeeper端口也绝不暴露。
5.3 性能调优:从缓存到索引合并
大数据量下solrconfig.xml中的queryResultCache、filterCache要结合堆内存设置。我们的配置经验是:
| 参数 | 分配 | 说明 |
|---|---|---|
filterCache | 512MB | 按fq条件缓存集合位图 |
queryResultCache | 64MB | 只缓存文档id列表,不缓存字段值 |
documentCache | 128MB | 缓存stored字段,对fl=*查询有效 |
缓存容量不是越大越好,太大导致GC频繁。判断命中率可以通过Solr Admin的Plugins/Stats页看,filterCache hitratio低于0.7说明缓存被反复清理,应适当调大或检查fq条件是否过于分散。
索引合并性能调优点在于mergePolicy。默认使用TieredMergePolicy,对数据写入频繁的场景,我们改为LogByteSizeMergePolicy,并设置maxMergeSizeMB=2048,避免一次合并超大段阻塞查询。如果磁盘允许,建议启用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?optimize=true&maxSegments=1&waitFlush=true"对于5000万数据,optimize大概耗时20到40分钟。跑完后segment数变为1,查询RT下降20%以上。
最后提供一个排查慢查询的脚本思路:在Solr查询日志中,把超过1秒的请求提取出来,按字段归类。如果集中在某个string字段的前缀查询,考虑加edge-ngram tokenizer;如果集中在text_ik字段的短词查询,考虑开启enableGraphFragment。项目包里如果带上了Lucene索引文件,可以用luke工具离线查看每个字段的Term分布,找出最长Term和异常高频Term,这往往是查询慢的根源。
本文还有配套的精品资源,点击获取