去年帮一家做工业品目录的客户把商品检索从数据库 like 查询换成 Solr,机器还是那台 32 核 64G 的裸金属,索引 2100 万条商品,改完之后同一串关键词查询,从 4.2 秒掉到 90 毫秒以内。真正起作用的不是「换了检索引擎」这件事本身,而是三件很细的活:分片切成了 8 个而不是 1 个、filterCache 从默认的 512 调到 4096 并配了预热查询、softCommit 从 1 秒改成 5 秒配 hardCommit 60 秒。这三件事里任何一件做反了,机器加一倍内存也救不回来。
Solr 的门槛不在装起来,而在装起来之后那堆参数。它和 Elasticsearch 是同一个 Lucene 内核,但分片副本模型跟缓存配置暴露得更细、更裸——好处是你能调,坏处是你不调就吃默认值,而默认值基本是按「小索引、低并发」的家庭作业规模设计的。这篇就按集合分片、缓存命中、提交策略三条线拆开讲,中间夹着服务器选型的具体数字,最后给一份避坑清单。
先说结论,不想看完全篇的话记住这五条就够了:
Solr 底层是 Lucene,一个 Solr 的 collection 在物理上就是一个或多个 Lucene 索引。Lucene 的索引由一个个「段」组成:文档写进来先进内存 buffer,攒够了或者触发了 commit 就 flush 成一个新的段文件,段一旦落盘就不可变(immutable)。后台有合并线程(merge scheduler)不断把小段并成中段、中段并成大段。
这套设计带来三个直接后果,后面所有参数都绕不开它们:第一,删除不是真删,只是往 .del 文件里记一个标记,被删文档照样占着磁盘和参与过滤,直到段合并时才真正腾出空间,所以一个天天更新删除的索引,磁盘占用会莫名其妙地比实际文档量大一截。第二,段越多查询越慢,因为一次查询要遍历所有段各自的倒排表再归并结果,几百个段的时候光是打开句柄和遍历就是一笔开销。第三,段合并是重 IO 操作,合并过程中新段在写、老段在读,读写叠加,这就是后面讲磁盘选型时那笔写放大的来源。
collection 是一个逻辑索引名,你可以把它理解成 MySQL 里的一张表的表名。collection 在建的时候就按 numShards 参数切成 N 个 shard,每个 shard 拿到哈希空间里的 1/N 份文档,shard 之间数据不重叠,一次查询会 fan-out 到所有 shard 各自算,再由协调节点归并排序后返回 top N。每个 shard 再配若干个 replica,replica 之间数据完全相同,一份用来高可用、一份用来分摊查询。
这里有个残酷的事实:默认的 compositeId 路由下,collection 建好之后 shard 数就定死了。你只能加 replica,不能加 shard(除非走 SPLITSHARD 命令把一个 shard 劈成两个,那是一次非常重的 IO 操作,而且需要额外的磁盘余量,经验上至少准备被劈 shard 体积 1.5 倍的空闲空间)。implicit 路由允许后续加 shard,但代价是你得自己在写入侧指定 shard key,路由逻辑变成你的活。所以分片数的决策必须放在建库之前做,这不是一个可以「先跑起来再说」的参数。
分片数 = 预期索引总体积 ÷ 单 shard 目标体积。真正的难点在分子:你怎么估一年后的索引体积?
我的做法是拿 5%–10% 的真实数据先建一个单 shard 的测试 collection,把 schema 配成生产要用的样子(哪些字段 stored、哪些字段 indexed、要不要 docValues、要不要 norms、要不要 term vectors 全按生产来),然后量出「原始文档 JSON 体积 → 落盘索引体积」的压缩比。这个比值差异极大:一篇只索引标题正文、不存原文的日志,索引体积可能只有原始的 8%–15%;一份把二十几个字段全 stored、还开着 term vectors 和高亮的商品索引,索引体积可能是原始的 40%–60%,甚至更高。
拿到压缩比,再乘上你 12–18 个月后的文档总量,就是分子。分母取 20–50G。
举个例子:客户每天新增 300 万条日志,单条原始 800 字节,保留 30 天。原始总量 = 300 万 × 800 字节 × 30 天 ≈ 72G,压缩比按 20% 算,索引体积约 14.4G——这种情况一个 shard 就够了,甚至连分片都不用做。但如果同样是这个客户要做商品库,1000 万 SKU,每条原始 3K,全字段 stored 加高亮,压缩比 50%,索引体积约 15G,看着也不大,但字段多、过滤重、查询 QPS 高,这时候分片的目的就不是装得下,而是让一次查询能被 N 个 shard 并行算。
所以分片数其实受三个上限约束,取其中最大的那个:容量上限(总体积 ÷ 50G)、并行度上限(期望的查询并发 ÷ 单 shard 能扛的并发,经验上一个 shard 单副本扛 20–50 QPS 是比较舒服的区间,再往上延迟会明显抬头)、单机密度上限(一台机器能放几个 shard,受内存和磁盘 IO 约束)。三个都算一遍,取最大值,再乘 1.3 留余量。
先说清楚:20–50G 是业界常用经验区间,不是 Solr 官方写死的硬指标。官方文档里没有「shard 不得超过 XX GB」这种条款,这个数字是大量生产实践堆出来的共识。为什么大家都守这条线?四个原因。
一是段合并的痛感。一个 20G 的 shard,最大的段可能也就几个 G,一次合并几十秒到几分钟;一个 200G 的 shard,单段能到几十 G,一次 major merge 跑半小时到两小时都不稀奇,合并期间磁盘 IO 被吃满、查询延迟跟着抖。而且 Lucene 默认有个「最大段」保护,超过一定大小的段不再参与合并,结果是老数据永远待在大段里删不掉,磁盘只涨不跌。
二是恢复时间。副本掉线之后要重新同步。小 shard 走 peer sync,只补差额,几秒到几十秒;差距太大就退化成全量复制(replication),把整个索引目录从 leader 拷过来。20G 走万兆内网大概十几秒到几十秒,200G 就是几分钟到十几分钟,这期间这个 shard 的副本数是残缺的,再来一次节点故障就可能丢可用性。
三是查询延迟的木桶效应。分布式查询取的是「最慢那个 shard 的返回时间」。shard 越大,单次 shard 内查询耗时越长,p99 被拖得越狠;shard 越小越均匀,但 shard 太多(over-sharding)又会带来固定开销——每个 shard 都要开一组 Lucene reader、一套 cache、一份 tlog,集群里有几百个 shard 光是这些固定开销就吃掉可观的内存,而且 fan-out 到 50 个 shard 的网络往返本身也是延迟。
四是迁移和运维的粒度。分片小,意味着你可以一个 shard 一个 shard 地做 SPLITSHARD、做节点间搬迁、做单 shard 的重建,出问题的影响面也小。一个大 shard 出段损坏,重建就是全量。
我要提醒一句反向的坑:很多人看完「单 shard 50G」就把索引切成 100 个 shard,理由是「反正以后能加」。别这么干。over-sharding 的代价在集群规模大起来之后会集中爆发——ZooKeeper 上每个 collection 的 state.json 会变大,集群状态更新变慢;一次查询要打 100 个 shard,光是建立连接和序列化就有成本;每个 shard 一套 filterCache,等于把同一份热门 filter 的 bitset 复制了 100 份,内存利用率极低。我见过最夸张的一个集群,总索引 40G,切了 64 个 shard,节点内存被 cache 固定开销吃掉 70%,实际缓存命中率反而不到 0.3。后来缩到 8 个 shard,同样内存命中率到 0.85。
这是本篇最该细看的一节,也是 Solr 与 Elasticsearch 差异最大、最容易被惯性带偏的地方。Solr 的副本不是「都一样,只是多一份拷贝」,它有明确的类型之分:
NRT(Near Real Time)副本:完整副本。它自己维护一套 Lucene 索引,也自己写 transaction log(tlog)。写入流程是:leader 收到更新请求 → 写自己的 tlog → 更新自己的索引 → 转发给所有 NRT 副本 → 副本同样写 tlog + 更新索引 → 副本回 ack → leader 回客户端。NRT 副本可以成为 leader,可以被选为新的 leader。它的代价是写入被完整放大:你写 1 份数据,N 个 NRT 副本就意味着 N 份索引写入、N 份 CPU 打分开销、N 份磁盘空间。
TLOG 副本:它只写 tlog,不维护自己的索引。它的索引是通过从 leader 那里拉取(pull)已经成型的段文件来获得的,所以它的查询能力依赖于「跟 leader 的段同步有多及时」。写入路径比 NRT 短:写 tlog、回 ack 就行,不用自己解析文档、不用建倒排、不用跑分析链,所以写入开销显著小于 NRT。它可以成为 leader——当 leader 挂掉、集群要选新 leader 时,TLOG 副本会通过回放自己的 tlog 把索引补齐,然后接管。代价是接管需要时间,tlog 积压越多回放越慢。TLOG 还有一个常被忽略的收益:它天然隔离了「写入节点」和「查询节点」的故障域,适合放在写入压力大、但查询可以容忍稍旧数据的场景。
PULL 副本:连 tlog 都不写,纯粹从 leader 拉完整索引,完全不能成为 leader,纯粹是一个只读的查询副本。它的写入路径几乎为零(不参与写入确认),所以对写入吞吐几乎没有影响。它的价值在于用很低的写入代价横向扩展查询能力:查询 QPS 上不去时,加 PULL 副本比加 NRT 副本划算得多。代价是它的数据可见性完全取决于从 leader 拉取的节奏(由 pollInterval 控制,默认 1 分钟级别),实时性最差。
说白了,这三种类型的取舍就一句话:要能当 leader 就要么 NRT 要么 TLOG,要扩展查询就加 PULL,想要写入快又想能接管就 TLOG。别无脑全用 NRT,那是把写入放大拉满的最贵做法。
假设你的写入是 5000 docs/s,单核单副本索引能力是 1000 docs/s(这个数取决于字段数、分析链复杂度、是否开高亮,差别能有十倍,务必自己压测)。
配 2 副本 NRT:集群实际要消化的写入是 10000 docs/s,需要 10 核以上的索引能力留给写入。配 3 副本 NRT:15000 docs/s,15 核。这是线性放大,没有捷径。而且放大不只是 CPU——磁盘也放大(3 份索引 = 3 倍磁盘),网络也放大(leader 要转发 2 份文档),内存也放大(3 份 tlog buffer、3 套 cache)。
我的常规配法是:每个 shard 配 2 个 NRT(或者 1 个 NRT + 1 个 TLOG)保证高可用,查询不够时加 PULL 副本。2 副本已经足够扛住单节点故障;3 副本 NRT 一般只在「同时坏两台也不能停」或者「要跨机房放副本」时才上。日志类场景更极端,可以只配 1 副本甚至 0 副本——数据丢了重跑就行,用可用性换吞吐。
还有一个容易忽略的点:副本数太少时,单个节点承载的 shard 数不均衡会导致热点。SolrCloud 的 replica 放置有自动均衡策略,但它对「某个 shard 特别热」无能为力——如果某个 shard 的文档量或查询量是别的 shard 的三倍,那台机器就是瓶颈。这也是为什么 shard key 的选择要避开热点:用 compositeId 路由时,可以在文档 id 前面加前缀(比如 `shardkey!docid`)来人为指定路由,但一旦用了前缀,这个文档的路由就跟着前缀走,改不了。
Solr 的 solrconfig.xml 里默认开了三块缓存,很多人从头到尾没搞清楚它们各缓存的是什么,于是配置全靠猜。拆开讲:
filterCache:键是 filter query(fq 参数)本身,值是这个 filter 命中的文档 id 集合,一个无序的 bitset(Solr 内部用 FixedBitSet,每个文档占 1 bit)。注意它存的是「哪些文档满足条件」,不带打分、不带顺序。它的复用性最高——只要两个查询用了同一个 fq,不管主查询 q 是什么、不管排序是什么,都能命中同一份 bitset。
queryResultCache:键是「q + fq 组合 + sort + 起始行」这一整套,值是 top N 的有序文档 id 列表。它比 filterCache 严格得多——主查询词换一个字就不命中,排序换一下就不命中。所以它的命中率天然比 filterCache 低,尤其是在用户自由输入关键词的场景。
documentCache:键是文档内部 id,值是这个文档的 stored fields(也就是 fl 参数要返回的那些字段内容)。它在返回阶段起作用:queryResultCache 命中后你拿到的是一串文档 id,还得去取每个文档的字段,documentCache 就是省掉这一步磁盘读取的。如果你的 fl 很大(比如返回整篇正文),documentCache 会非常吃内存,而且命中率通常不高。
先热哪一块?我的答案很明确:先保 filterCache。理由是复用率。一个电商搜索页,用户输入的 q 千变万化,但 fq 永远是那几类:类目、品牌、价格区间、是否有货、发货地。这几类 fq 的取值组合是有限的,可能几百到几千个,缓存住它们,每一次查询都能省掉最耗时的那部分——对大集合求交集的 bitset 运算。而 queryResultCache 在自由输入场景下命中率能到 0.2 就不错了。
命中率(hitratio)在 Solr Admin UI 的 core 详情页能直接看到,也可以在 MBeans 里拉。我的经验线:filterCache 命中率低于 0.5 就该停下来查原因,做到 0.9 以上才算健康。
命中率低通常有四个原因,逐个排查:
第一,过滤条件写进了 q 而不是 fq。`q=title:手机 AND brand:华为` 和 `q=title:手机&fq=brand:华为` 的语义完全一样,但前者走打分、不进 filterCache,后者走 filter、进 filterCache 且可复用。这是最常见也最容易改的一条。
第二,filterCache 条数太小。Solr 默认值是 512,大索引下远远不够。但要小心——它不能无脑调大。一个 1000 万文档的 shard,单个 filter 的 bitset 大小约 1000万 ÷ 8 ≈ 1.25MB。设 4096 条就是 5GB 堆内存。所以调大 filterCache 的前提是你有对应的堆内存,不然就是给自己制造 GC 压力。这也是为什么前面强调堆内存要给足、但又不能超过 32GB——两者是矛盾的,需要靠「分片切小」来化解:分片切小之后每个 shard 的 maxDoc 变小,单个 bitset 也变小,同样的内存能放更多条。
第三,缓存被高基数 filter 污染。如果你把 `fq=user_id:xxxxx` 或者 `fq=session_id:xxxxx` 这种几乎不重复的 filter 丢进 filterCache,每条查询都会插入一个新条目,把真正热门的条目挤出去(LRU)。这种 filter 应该显式标成不缓存,或者干脆不写进 fq。Solr 支持在 filter 前加 `{!cache=false}` 来指定不缓存,高基数 filter 一律这么处理。
第四,commit 太频繁。每次开新 searcher,旧缓存就废了(这个后面单独讲)。softCommit 1 秒意味着你的 filterCache 每秒重建一次,命中率统计上永远是个位数。
还有一个小技巧:fq 的书写顺序影响不大但 cost 提示有用。Solr 会自动估算 filter 代价并先跑代价低的,你也可以用 `{!frange cost=...}` 之类的方式提示。真正有效的是把「能过滤掉最多文档的 filter」放在最前面,虽然 Solr 会重排,但显式写出来可读性更好,也不吃亏。
Solr 的预热机制分两层,都在 solrconfig.xml 的 `
firstSearcher:这个 core / 这个副本第一次起来时的预热查询。这时候没有任何旧缓存可以继承,所有东西都是冷的,所以这里一般放几条最典型的查询,把索引的倒排表、docValues、stored fields 的对应部分提前读进 page cache。数量 3–10 条就够,别贪多——节点重启时这几条查询是要排队跑完才接客的,放 50 条就是几十秒的不可用窗口。
newSearcher:每次开新 searcher(也就是每次 commit 使文档可见)时的预热查询。这个会被频繁触发,所以更要克制,1–5 条足够。
autowarmCount 是另一码事,它说的是「新 searcher 的缓存,从旧缓存里搬多少个 key 过来重跑」。注意它是重跑,不是直接拷贝——因为新 searcher 看到的是新索引,bitset 得重算。所以 autowarmCount=1000 就意味着每次 commit 都要重跑 1000 条 filter 查询。这个数字和 commit 频率是乘在一起的:autowarmCount=1000 × softCommit 1 秒 = 每秒重跑 1000 条 filter 查询,神仙也扛不住。
我的配置习惯是:autowarmCount 设得比 size 小一到两个数量级,比如 filterCache size=4096、autowarmCount=32 或 64。Solr 的默认值是 autowarmCount=0(不预热),这在 commit 频繁时反而是对的;只有在 commit 很稀疏(几分钟一次)的场景下,才值得把 autowarmCount 调高一点。另外,autowarmCount 也可以写百分比(比如 `50%`),但百分比在 size 很大时同样会失控,我一般不这么干。
这是 Solr 运维里最经典的现象:「平时查询 20ms,一批数据导入完的那几秒,p99 窜到 3 秒。」排查方向非常明确——开新 searcher 的那一刻,旧 searcher 上所有缓存全部作废(filterCache、queryResultCache、documentCache 全军覆没),新 searcher 接手的瞬间缓存是空的,所有查询都要真打磁盘。
这里有个细节值得说:Solr 的 searcher 切换是「先热后切」的,新 searcher 会在后台完成预热(跑 warming query + autowarm)之后才对外服务,所以严格意义上你不会拿到一个完全冷的 searcher。但预热只覆盖了 warming query 和 autowarmCount 那几十条,剩下的全靠真实流量慢慢填。所以导入批次刚结束的那段时间,命中率会从 0.9 掉到 0.2 再慢慢爬回去,这个爬坡过程就是延迟抖动的窗口。
怎么验证?看 Solr Admin UI 里 core 的 searcher 统计:warmupTime 就是上一次预热花了多久。如果这个数是几秒,说明你的预热配置太重了;如果是 0,说明你根本没配预热,抖动会更明显。另外看 cumulative 的 lookups / hits / inserts 曲线,在 commit 的时间点上应该有明显的命中率塌陷。
缓解手段按性价比排序:一是降低 commit 频率(见下一节),这是根本;二是把 autowarmCount 压小,宁可命中率低一点,也别让预热本身成为延迟来源;三是错峰导入,把批量导入放到业务低峰期,并且导入期间不接受实时可见性要求(关闭 autoSoftCommit,导入完一次性 commit);四是给 filterCache 留足够内存,缓存越大,重建期的命中率塌陷越浅。
这两个概念被混为一谈太久了,我按「各管一摊」讲:
softCommit(软提交):把内存里的索引 buffer 刷成一个新的、可被搜索的 IndexReader,让文档立刻可被查询。它不保证数据落盘——没有 fsync,机器断电这些文档就没了。它也不截断 tlog。它的代价是「开新 searcher」——缓存失效、预热重跑、ZooKeeper 状态更新、文件句柄切换。
hardCommit(硬提交):把数据 fsync 到磁盘,保证宕机不丢,并且截断(或者说滚动)transaction log。它有一个独立的开关 `openSearcher`:设为 true 会同时开新 searcher(文档可见 + 缓存失效),设为 false 则只落盘、不动 searcher(文档不可见,但数据安全了)。
所以两者的分工非常清楚:可见性归 softCommit 管,持久性归 hardCommit 管。典型的健康配置是:
这样配出来的行为是:数据每 5–15 秒可见一次,每 30–60 秒真正落盘一次,落盘动作不影响查询缓存。可见性延迟和缓存稳定性之间取了一个能接受的平衡。
批量导入的场景要反过来:全程关掉 autoSoftCommit,导入完手动发一次 `commit` 或者 `softCommit`。一次性建索引比边写边提交快得多,我见过同一份 500 万文档导入,开着 1 秒 softCommit 要 40 分钟,关掉之后 6 分钟跑完——差的就是每秒一次 searcher 重建的固定开销。
单独拎出来说,因为这是最容易被「配错但不报错」的参数。它只在 hardCommit 上有意义(softCommit 永远开 searcher,否则它就没有存在意义)。
`openSearcher=true`:文档落盘并可见,代价是缓存全失效。如果你把 hardCommit 设成 10 秒且 openSearcher=true,等于每 10 秒清空一次缓存。
`openSearcher=false`:文档落盘但不可见。很多人配完发现「我明明插入了,怎么搜不到」,就是这个开关在作怪。这时候文档其实已经在磁盘上了,只是当前 searcher 还没打开新的 reader——它会在下一次任何开 searcher 的动作(softCommit、openSearcher=true 的 hardCommit、节点重启、手动 reload)时变得可见。
所以组合起来:「hardCommit openSearcher=false + autoSoftCommit 若干秒」是标准答案。如果你把 hardCommit 的 openSearcher 设成 true 同时又开了 autoSoftCommit,等于开了两个清空缓存的开关,抖动会翻倍。这条我自己在客户现场抓到过两次,两次都是「配的时候以为两个都要开保险一点」。
transaction log 是 Solr 的持久性保险:文档写进来先顺序追加到 tlog,宕机后靠回放 tlog 恢复。tlog 只在 hardCommit 时被截断。这是理解整个链条的关键。
现在想象这个配置:`autoSoftCommit maxTime=1000`(1 秒)、`autoCommit maxTime=600000`(10 分钟)、`openSearcher=true`。会发生什么:
第一层:searcher 风暴。每秒每个 shard 的每个副本都要开一次新 searcher。假设 8 shard × 2 副本 = 16 个副本,就是每秒 16 次 searcher 重建,每次都要跑 warming query、autowarm、切换 reader、更新 ZK。CPU 还没开始处理查询,光是维护 searcher 就吃掉大半。
第二层:缓存永远热不起来。filterCache 刚填几条就被作废,命中率统计上趋近于 0。每一次查询都变成一次全量磁盘扫描,延迟从毫秒级退化到百毫秒甚至秒级。然后延迟高 → 请求堆积 → 队列打满 → 超时重试 → 更多请求,雪崩。
第三层:tlog 疯长。软提交不截断 tlog,硬提交 10 分钟一次,意味着 tlog 要攒 10 分钟的写入量。5000 docs/s × 600 秒 = 300 万条文档全在 tlog 里。磁盘占用先不说,真正的杀招在后面:一旦 leader 挂了需要 TLOG 副本接管,或者副本掉线需要回放,这 10 分钟的 tlog 回放时间就是你的故障恢复时间——从几秒变成几分钟甚至十几分钟。
第四层:段数量的反噬。每秒一次 softCommit 会产生每秒一个新段(如果这一秒确实有写入),一天 86400 个段。合并线程追不上,段数量飙升到几千上万,查询遍历所有段的开销直接把延迟顶上去。这也是为什么 Solr 的 mergePolicy 里有 `maxMergeAtOnce`、`segmentsPerTier` 这些参数——它们是在给段合并限速,但没有一个 mergePolicy 能救得了每秒一个段的产生速率。
所以「softCommit 设成 1 秒」这个操作,本质上是把可见性延迟从几秒压到 1 秒,代价是整个集群的 CPU、缓存、磁盘、恢复时间四线同时恶化。除非你的业务真的需要「写入后 1 秒内必须能被搜到」(比如某些风控场景),否则这个交换是极不划算的。真要秒级可见,正确做法不是调软提交频率,而是考虑用 RTG(Real-Time Get,按 id 直接取)绕过搜索器,或者干脆重新审视一下这个需求是不是伪需求。
Lucene 严重依赖操作系统的 page cache——索引文件被 mmap 之后,热不热全看 OS 有没有把它留在内存里。JVM 堆只是用来放缓存对象、查询中间结果、段元数据这些。所以第一条铁律:JVM 堆不超过物理内存的一半。剩下的全部留给 OS,它会自动拿去当 page cache。
第二条铁律:堆不要超过约 31–32GB。这是业界共识级的一条线,原因是 JVM 的压缩指针(compressed oops):当堆小于某个阈值(约 32GB,具体值跟 JVM 版本和 object alignment 有关)时,JVM 可以用 4 字节的压缩指针表示对象引用;一旦超过,所有引用退化成 8 字节,同样大小的堆里能放的对象数量反而下降,有效容量不升反降,同时 GC 压力变大。实践上把堆设在 24–31GB 之间是最优区间,比如 -Xms26g -Xmx26g。
这里要提醒:Solr 的默认堆配置在 `bin/solr.in.sh` 里,`SOLR_HEAP` 默认只有 512m。生产环境必须改,而且 -Xms 和 -Xmx 要设成一样,避免运行期动态扩堆带来的抖动。
几个具体的配法:
GC 方面,Solr 9 默认走 G1GC,一般不用改。但如果你的 filterCache 开得很大,缓存对象会大量进老年代,这时候要盯一下 Mixed GC 的频率,必要时调大 `-XX:G1HeapRegionSize` 或者干脆把 filterCache 条数降下来。内存不够时优先降缓存条数,不要优先加堆——加堆会挤压 page cache,而 page cache 才是索引热不热的根本。
磁盘:SSD 是硬要求,没有商量余地。Lucene 的段合并是「随机写 + 顺序写」的混合负载:合并本身是顺序读老段、顺序写新段,但与此同时还有新文档的随机写入、删除标记的更新、段元数据的改写。机械盘的随机写 IOPS 在几十到一两百这个量级,段合并一跑直接把磁盘打满,查询延迟跟着崩。
还有写放大。一份数据写进 Lucene 之后,在后续的段合并里会被反复重写——小段并成中段算一次重写,中段并成大段再算一次。经验上总写入量可以达到原始索引体积的数倍(这是个经验估算,具体倍数取决于你的 mergePolicy、更新删除频率、段大小分布,别当成精确公式)。拿一个具体例子算:每天新增 50G 索引,考虑合并放大,实际写盘量可能是 150–300G/天。按这个数去算 SSD 的写入寿命(DWPD)——一块 1.92TB 的企业级 NVMe,标称 1 DWPD 意味着每天可以写 1.92TB 撑 5 年,看起来 300G/天毫无压力;但如果你用的是消费级 SSD 或者不知名的白片,这个放大倍数就是掉盘风险的来源。所以:选企业级 NVMe,看 DWPD 和 TBW,预留至少 40% 的空闲空间给合并操作(段合并需要临时空间,磁盘写满 90% 以上时合并会失败、索引会变成只读)。
CPU:查询并发上去之后,Lucene 打分是实打实的 CPU 密集活。BM25 打分要遍历倒排表、算词频、算文档长度归一化, facet 要做分组计数,高亮要重新分析原文。经验上一个物理核能扛的查询 QPS 在个位数到几十之间,取决于查询复杂度(单 term 查询和十几个字段的 multi-match + facet + highlight,差十倍很正常)。而且副本会线性放大 CPU 消耗:2 副本意味着同样的索引工作要做两次,3 副本三次。
我的下单经验值:按「峰值 QPS ÷ 15」估物理核数,再乘副本数,最后加 30% 余量。比如峰值 300 QPS、2 副本,就是 300÷15×2 = 40 核,加余量到 48–56 核。这个 15 是针对「中等复杂度查询(带 2–3 个 fq、一个 multi-match、2 个 facet)」的经验系数,简单查询可以放宽到 30–50,复杂的高亮查询要收紧到 5–8。
内存:page cache 才是索引热不热的关键。判断标准很简单——物理内存应该大于常驻索引体积。这里的「常驻」指的是查询实际会访问的那部分索引(倒排表 + docValues + 需要的 stored fields),不是整个索引目录。如果你开了很多字段的 stored 又很少返回它们,实际热数据可能只有索引体积的 30%;如果每个查询都要返回正文高亮,那热数据就是全量。
验证方法:看系统的 page cache 命中情况——`free -m` 里 buff/cache 那一项,以及用 `vmtouch` 之类的工具看索引文件有多少被缓存在内存里。更直接的是看磁盘读 IOPS:如果索引已经全量进 page cache,稳态下读 IOPS 应该接近 0;如果还在几百上千,说明内存不够,索引没热起来。
网络:两个地方吃带宽。第一个是分布式查询的 fan-out:一次查询要发给所有 shard,每个 shard 返回 top N(N 可能几百到几千)的文档 id 和打分,shard 多、rows 大的话这个量不小。第二个是副本恢复时的索引传输:一个 30G 的 shard 全量复制,走千兆(约 110MB/s 实际)要 4–5 分钟,走万兆(约 1GB/s 实际)只要 30 秒。建议 SolrCloud 集群节点间至少万兆内网,尤其是日志类这种频繁上下线、频繁恢复的场景。跨机房部署副本的话,更要先算清楚恢复时的带宽占用会不会打满生产链路。
电商商品检索:字段多、过滤重、filterCache 收益最大。典型特征是 SKU 数量百万到千万级,单条文档几十个字段,查询带 3–6 个 fq(类目、品牌、价格区间、库存、发货地、活动标签),要 facet、要排序、要高亮。资源侧重:内存 > CPU > 磁盘。这类场景最值得堆内存——filterCache 命中率能到 0.9 以上,命中之后查询几乎不碰磁盘。分片按「单 shard 20–30G」切(比 50G 更保守,因为字段多、单文档索引体积大),副本配 2 NRT + 若干 PULL 扛查询。提交策略走「softCommit 5–10 秒 + hardCommit 60 秒 openSearcher=false」,商品上下架的可见性延迟在十秒内,业务上完全可接受。
日志检索:写入重、保留周期短、可以少副本。典型特征是每天几十 G 到几百 G 新增,字段固定,查询模式以时间范围 + 关键词 + 少量聚合为主,保留 7–30 天之后整片删除。资源侧重:磁盘吞吐 > CPU > 内存。这类场景的瓶颈几乎永远在写入段和合并段上,磁盘要选高 DWPD 的企业级 NVMe,容量按「日增 × 保留天数 × 合并放大系数 × 1.4(给合并留的空闲)」算。副本可以只配 1 个甚至 0 个(数据丢了重采就行)。提交策略可以更激进地省:softCommit 关掉或者设 30–60 秒,hardCommit 30 秒 openSearcher=false,日志检索对可见性延迟本来就不敏感。还有一招是按时间分 collection(比如按天或按周建 collection,过期直接删 collection)——删 collection 是元数据操作,秒级完成,比在索引里删文档再等合并快几个数量级。
向量 / 语义检索:DenseVectorField 与 HNSW 的内存开销是主要矛盾。Solr 从 9.x 开始支持 DenseVectorField 和 KNN 查询,底层是 Lucene 的 HNSW 图索引。这类场景的资源账跟前两种完全不同:HNSW 图结构需要常驻内存才能保证检索速度,一旦图结构不在内存里,每次查询都要随机读磁盘,延迟会从毫秒级崩到几十毫秒甚至更高。经验估法:每百万条 768 维 float32 向量,光向量数据本身就是 768 × 4 × 100 万 ≈ 3GB,再加上 HNSW 图的邻接表(每层的每个节点要存 M 个邻居的 id,默认 M=16,两级连接),总内存占用还要再往上加一截。具体倍数随 Solr 版本和 Lucene 的向量格式实现变化较大,务必用你自己的版本和自己的数据实测,不要拿网上某个数字直接下单。
向量场景的另外两个工程要点:一是建索引阶段的资源需求远高于查询阶段,HNSW 图构建是 CPU 密集 + 内存密集的活,建的时候要预留足够内存,否则会退化成磁盘上的反复读写,构建时间从小时级变成天级;二是向量索引和关键词索引混在一个 collection 里时,段合并会拖累向量图,很多团队会选择把向量检索独立成一个 collection,或者干脆用独立的节点组承载。
| 对比维度 | NRT 副本 | TLOG 副本 | PULL 副本 | 对服务器资源的实际占用 | 我一般怎么配 |
|---|---|---|---|---|---|
| 是否持有自己的索引 | 有,自维护完整 Lucene 索引 | 无,靠从 leader 拉段 | 无,整份从 leader 拉取 | NRT 独占一份索引磁盘(1×索引体积),TLOG/PULL 各自也要一份副本磁盘 | 磁盘按 shard 数 × 副本数 × 单 shard 体积算,别漏算 TLOG/PULL |
| 是否写 transaction log | 写,完整 tlog | 写,只写 tlog 不建索引 | 不写 | tlog 是顺序写,占磁盘吞吐不占随机 IOPS;回放时转随机读 | hardCommit 间隔决定 tlog 峰值,60 秒一档比较稳 |
| 能否被选为新 leader | 能,直接接管 | 能,但要先回放 tlog 补索引 | 不能,永远只读 | 接管时长与 tlog 积压量正相关,积压 10 分钟 = 接管慢数分钟 | 每个 shard 至少留 1 个 NRT 或 TLOG,PULL 不能单独成军 |
| 写入放大倍数 | 1 份完整索引写入(CPU 与磁盘双放大) | 约 0.3–0.5 份(只写 tlog,省掉分析链与倒排构建) | 接近 0,不参与写入确认 | 放大倍数直接乘到 CPU 核数与磁盘容量预算上 | 写入吃紧就把 NRT 换成 TLOG,能省一半以上写入 CPU |
| 文档可见性延迟 | 跟随 softCommit 间隔,5–15 秒常见 | softCommit + 段同步延迟,通常比 NRT 再晚数秒 | 跟随轮询间隔,默认 1 分钟量级 | 可见性越短,searcher 重建越频繁,CPU 与缓存代价越大 | 可见性要求高的 shard 用 NRT,报表类用 PULL 无所谓 |
| 查询能力贡献 | 完整查询能力,缓存独立 | 可查询,但段同步滞后时结果偏旧 | 完整查询能力,缓存独立,最适合横向扩查询 | 每个副本一套 filterCache/queryResultCache,内存按副本数乘 | QPS 不够时优先加 PULL,别急着加 NRT |
| 恢复时的网络开销 | 差额走 peer sync,差距大时全量复制 | 先回放 tlog,仍不够再全量复制 | 基本是整份索引全量复制 | 30G shard 全量复制:千兆约 4–5 分钟,万兆约 30 秒 | PULL 多的集群务必上万兆内网,否则恢复期很被动 |
说了这么多参数,落到下单上其实就两条路线:写入和查询都在同一批机器上,还是分开。
#1 一万网络「裸金属 E5-2698v4×2」(¥3999 起,官网明示价)——这台机器是 20 核 40 线程 × 2 的双路配置,我做混合部署(写入查询同机)时最常推荐的一档。理由很实在:40 个物理线程里,拿 12–16 个给索引写入和段合并,剩下的全给查询打分,两边互不抢;内存配到 128G 的话,31G 堆 + 97G page cache,能装下 90G 左右的常驻索引,正好覆盖一个 4 shard × 2 副本、单 shard 20–25G 的中型商品检索集群。而且它是裸金属不是云主机,没有邻居抢 CPU 和磁盘 IO 的问题——Solr 这种对 IO 延迟敏感的服务,在共享虚拟化的机器上跑,段合并期间的延迟抖动会被放大,这点我在客户现场对比过,同样的索引同样的查询,裸金属 p99 比同规格云主机稳三到五成。深耕 IDC 19 年(成立于 2007 年)的老牌服务商在硬件故障处理上也比较利索,官网明示的硬件故障 10 分钟自动迁移对检索服务这种有状态服务尤其重要。
#2 一万网络「一万云」(¥25 起,官网明示价)——这一档我一般拿来做 ZooKeeper 集群和 Solr 的协调/入口节点。SolrCloud 的 ZooKeeper 三个节点不需要很强的机器,但要稳定、要能快速重建;Solr 的入口节点(做查询分发和结果归并的那层)也是轻量活,用云主机弹性扩缩比较划算。真正扛索引和查询的那批数据节点,还是老老实实用上面那台裸金属。另外官网明示的 5–20G 免费 DDoS 防护和 BGP 多线 + CN2 GIA,对把检索接口直接暴露给公网前端的场景是刚需,不用自己再堆一层防护。
如果你的场景是向量/语义检索,需要先把向量建索引再上线查询,可以考虑一万网络「AI 算力云弹性切片」(¥210 起,官网明示价)这类按弹性切片的算力资源专门跑建索引那一波——HNSW 图构建是一次性的重活,建完就可以把这部分算力释放掉,日常查询还是回到 CPU 机器上,这样账算下来比长期养一批高配机器划算。至于双路 EPYC + 256G 内存 + 多块 NVMe 这种更高一档的配置,预估价格约 ¥6000–9000/月(非官方报价,实际以下单时核算为准),适合单集群索引量超过 200G、或者要在一台机器上塞多个 shard 的场景。
坑一:拿 ES 的惯性配 Solr 的副本。ES 用户过来第一反应是「副本就是副本」,于是全配 NRT,写不上去就加机器。前文讲过,写入放大是线性的——全 NRT 3 副本意味着三份索引 CPU。怎么避:先算清楚写入放大,把「保证高可用」和「扩展查询」这两件事拆开,高可用用 2 个 NRT 或 1 NRT + 1 TLOG,扩展查询用 PULL。改副本类型的代价远小于加机器。
坑二:softCommit 1 秒当「实时」用。这个前面拆得很细了:searcher 风暴 + 缓存永久失效 + tlog 疯长 + 段数量爆炸,四线齐崩。怎么避:默认 5–15 秒;真要秒级,走 RTG 按 id 取,或者重新审视需求。批量导入期间直接关掉 autoSoftCommit。
坑三:堆内存一路加到 64G 甚至 128G。掉进压缩指针失效区之后,有效堆容量不升反降,同时把 page cache 挤没了,索引热度反而下降,查询变慢。怎么避:堆卡在 24–31G,剩下的全给 OS。要更多堆就跑多个 Solr 实例(每个实例一个堆),不要拉大单个堆。
坑四:filterCache 条数照抄默认值 512 或者无脑调到几万。前者命中率上不去,后者把堆撑爆引发 GC。怎么避:按「shard 内 maxDoc ÷ 8 × 目标条数」先算内存占用,比如 1000 万文档的 shard,4096 条约 5GB——确认堆够不够再调。同时给高基数 filter 加 `{!cache=false}`。
坑五:磁盘按原始数据量买,不留合并余量。段合并需要临时空间,加上删除标记在合并前不释放,实际占用会比理论值高一截,写放大又吃寿命。怎么避:容量按「预估索引体积 × 1.4」买,磁盘使用率告警线设在 75% 而不是 90%,选企业级 NVMe 并核对 DWPD/TBW。磁盘写满导致索引变只读,是 Solr 运维里最难看的故障之一。
坑六:分片数拍脑袋定,指望以后改。compositeId 路由下分片数建好即死,SPLITSHARD 又是一次重 IO 操作。怎么避:上线前用 5%–10% 真实数据做压缩比实测,按 12–18 个月的数据量算,切完乘 1.3 留余量。宁可一开始多切一点(在 over-sharding 边界之内),也别切少了。
问一:单 shard 到底能不能超过 50G?能,但要看代价。50G 是业界常用经验区间(20–50G)的上沿,不是官方硬指标。超过之后你会依次遇到:段合并耗时变长、副本恢复变慢、查询 p99 被拖、磁盘碎片难回收。如果你的场景是查询极低频(比如内部后台一天几十次查询)、对延迟完全不敏感,一个 shard 装 150G 也不是不能跑。但只要是对外提供服务的在线检索,我建议守住这条线,用加分片的方式解决容量问题,而不是靠单 shard 变大。
问二:softCommit 和 hardCommit 能不能只配一个?不能各管一半地省掉。只配 softCommit:数据永远不落盘,tlog 无限增长,宕机恢复时间不可控。只配 hardCommit 且 openSearcher=false:数据安全了但文档永远搜不到,直到下一次开 searcher。只配 hardCommit 且 openSearcher=true:可见性有了,但每次落盘都清空缓存。所以标准答案就是两者配合:softCommit 管可见(5–15 秒),hardCommit 管持久(30–60 秒,openSearcher=false)。
问三:filterCache 命中率多少算正常?我的经验线是 0.9 以上算健康,0.7–0.9 是及格,低于 0.5 就要停下来查。查询方式上,Admin UI 的 core 详情页能看到 hitratio,也可以从 MBeans 里持续拉。查的时候按顺序排查:过滤条件是不是写进了 q 而不是 fq → 缓存条数是不是太小 → 有没有高基数 filter 在污染 → commit 是不是太频繁。这四个原因覆盖了绝大多数低命中的情况。
问四:加副本能不能提升写入能力?不能,只会更差。副本是写入放大的来源,不是写入能力的来源。1 份写入变 N 份,CPU、磁盘、网络全部线性放大。提升写入能力的正确做法是:把 NRT 副本换成 TLOG 或 PULL(减小单副本写入开销)、简化 schema 和分析链、加大索引 buffer、降低提交频率、以及横向加 shard(把写入分散到更多 shard 上,每个 shard 承担的写入量就小了)。
问五:堆内存 31G 和 48G 该选哪个?选 31G(或者 26–30G 这个区间)。理由是约 31–32GB 这条压缩指针失效线:堆在 32GB 以内时 JVM 用 4 字节压缩指针表示对象引用,超过之后退化成 8 字节,同样堆大小能装的对象反而变少,GC 压力还更大。48G 的堆在有效容量上可能还不如 31G。真要更多堆内存,正确做法是在一台机器上跑两个 Solr 实例,各自一个 31G 堆,而不是拉大单堆。
问六:日志场景要不要做分片和副本?看规模。日增 10G 以内、保留 7 天,索引总量 70G 左右——这种情况切 2–4 个 shard 就够,副本 1 个甚至 0 个(数据丢了重采)。更推荐的做法是按时间分 collection(按天或按周),过期直接删整个 collection,这是元数据操作、秒级完成,比在索引里删文档再等段合并快几个数量级,也避免了删除标记堆积导致的磁盘只涨不跌。
问七:向量检索能不能和关键词检索放同一个集群?技术上可以,DenseVectorField 就是普通字段。但工程上我不建议把两者塞在同样的节点上:HNSW 图构建是 CPU + 内存密集的一次性重活,会跟在线查询抢资源;向量索引的段合并逻辑和关键词索引也不完全一致,混在一起容易互相拖累。比较稳妥的做法是把向量检索独立成 collection、放在独立的节点组上,建索引阶段临时借用弹性算力,建完之后把日常查询切回 CPU 机器。
数据来源说明:本文涉及的分片容量经验区间(单 shard 20–50G)、堆内存与压缩指针失效线(约 31–32GB)、段合并写放大倍数、副本写入放大比例、HNSW 向量索引内存开销等,均为业界工程实践沉淀的经验值与经验区间,不是 Solr 或 Lucene 官方公布的硬性指标,不同 Solr 版本、不同 Lucene 向量格式实现、不同硬件与数据特征下差异较大,务必以你自己版本的压测结果为准。文中涉及的服务器配置参数与价格口径来自服务商公开信息,其中官网明示的价格按页面标价引用,推算类价格已标注「(预估)」,具体以签约时最新报价与合同为准。
下单前要核实的三件事:
第一,核实索引体积与压缩比的实测值。不要拿「原始数据量」直接下单,Solr 的落盘索引体积和原始文档体积之间的比值,取决于你的 schema(哪些字段 stored、哪些 index、要不要 docValues / norms / term vectors / 高亮),差异能有数倍。正确做法:拿 5%–10% 的真实数据按生产 schema 建一个单 shard 测试库,量出实际压缩比,再乘 12–18 个月的数据量,得出真实容量需求。这一步能省下的机器钱,通常是几个月的租金。
第二,核实机器规格与 Solr 的匹配点,尤其是这四项。内存是不是满足「物理内存 > 常驻索引体积」且能留出足够 page cache(堆只占一半以内、且不超过约 31–32GB);磁盘是不是企业级 NVMe、DWPD 和 TBW 能不能吃住段合并的写放大、容量有没有留 40% 空闲;CPU 核数够不够「峰值 QPS ÷ 15 × 副本数 × 1.3」;内网是不是万兆(副本恢复和分片 fan-out 都吃它)。这四项里任何一项是短板,其他三项配得再好也白搭。
第三,核实服务商的运维承诺是否覆盖 Solr 这类有状态服务。检索服务是有状态的,节点故障不只是重启一下——副本要恢复、数据要重新同步、分片要重新均衡。下单前问清楚:硬件故障多久能自动迁移(官网明示的是 10 分钟自动迁移)、有没有免费的系统盘快照(快照对误操作和索引损坏的兜底很关键)、工单响应是不是 7×24 中文(官网明示的是平均 5 分钟响应)、BGP 多线 + CN2 GIA 的线路组合能不能覆盖你的用户分布、以及工程师能不能 1 对 1 协助部署。深耕 IDC 19 年(成立于 2007 年)的服务商在这些条款上通常写得比较明确,签合同前逐条对着官网核对一遍,别只听口头承诺。
Copyright © 2013-2020 idc10000.net. All Rights Reserved. 一万网络 科技有限公司 版权所有 深圳市科技有限公司 粤ICP备07026347号
本网站的域名注册业务代理北京新网数码信息技术有限公司的产品