Indexing internals & performance
|
This section documents the current Solr line (10.0; 9.10.x the maintained 9.x branch), written and verified against the Apache Solr Reference Guide. No specific patch version is pinned. Some capabilities (the Solr Operator on Kubernetes, the package-manager ecosystem, Learning To Rank model training, and expert plugin development) are linked, not documented in depth. This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. This section’s bibliography lists the reference material consulted while preparing these pages. |
Query and indexing latency in Solr almost always trace back to a handful of physical realities:
how many Lucene segments a core has to open and search, whether a document’s columnar values live
in docValues or have to be built on the fly, how much of the index the OS page cache can actually
hold, and how the JVM’s heap and garbage collector behave under load. This page is the tuning
counterpart to Core concepts & architecture
(which introduces commits and the segment model) and
Indexing & updates (which covers the /update handler,
autoCommit, and the transaction log itself) — here the focus is what to configure once documents
are already flowing, to keep both indexing throughput and query latency predictable at scale.
Runtime cache sizing (filter cache, query result cache, document cache) is
Configuration & caches; this page is about the
storage and merge machinery underneath those caches.
Segments and TieredMergePolicy
Every hard commit (and, in RAM, every soft commit) can add a new immutable Lucene segment — deletes only flag documents as removed in that segment, they do not rewrite it. Left unchecked, a long-running core accumulates many small segments, and a search has to consult every one of them, so Solr runs a background merge policy that folds smaller segments into fewer, larger ones.
The default is TieredMergePolicy, driven by a mergePolicyFactory in solrconfig.xml:
<indexConfig>
<mergePolicyFactory class="org.apache.solr.index.TieredMergePolicyFactory">
<int name="maxMergeAtOnce">10</int>
<int name="segmentsPerTier">10</int>
</mergePolicyFactory>
<!-- Runs merges on background threads instead of blocking the indexing thread. -->
<mergeScheduler class="org.apache.solr.update.ConcurrentMergeScheduler"/>
</indexConfig>
segmentsPerTier bounds how many segments of roughly the same size Solr tolerates before merging;
maxMergeAtOnce bounds how many of them one merge operation folds together. Lower values keep the
segment count (and so per-query overhead) down at the cost of more background merge I/O; higher
values do the opposite — favor indexing throughput. ConcurrentMergeScheduler runs merges on
separate threads sized from the number of CPU cores by default, so a merge does not stall the
indexing thread that triggered it, only the I/O and CPU it competes for. See
Index
Segments and Merging for the full parameter set, including useCompoundFile and per-merge-policy
CFS ratios.
forceMerge / optimize — and when not to
optimize=true on a commit (or the admin UI’s "Optimize" button, which calls the same
forceMerge) merges an entire core down to maxSegments segments, purging deleted documents and
giving the fastest possible query time against that core. It is also one of the most expensive
operations Solr can run — I/O- and CPU-heavy, and it briefly needs up to roughly double the core’s
disk space while old and new segments coexist.
# Collapse a read-only, no-longer-updated core to one segment.
curl "http://localhost:8983/solr/archive-2024-01/update?optimize=true&maxSegments=1"
# https://solr.apache.org/guide/solr/latest/configuration-guide/index-segments-merging.html
Reserve it for cores that have genuinely stopped changing — a rolled-over time-based collection, a
nightly-rebuilt catalog — the same pattern Elasticsearch’s
_forcemerge follows. Do not run it as a routine maintenance job against a core that keeps
receiving writes: a force-merged segment is excluded from `TieredMergePolicy’s normal size-based
selection until enough of it is deleted again, so once writes resume you are left with one
oversized segment that resists being merged away, working against the tiered policy rather than
with it.
Transaction-log durability
Indexing & updates introduces the transaction log (tlog) as what makes a hard commit’s fsync safe to defer and what a recovering replica replays. The durability knob that matters at scale is how often — and under what conditions — Solr issues a hard commit versus how much unflushed tlog content an unclean shutdown could lose:
<updateHandler class="solr.DirectUpdateHandler2">
<updateLog>
<str name="dir">${solr.ulog.dir:}</str>
<!-- Bound tlog replay time/size independently of how long autoCommit's interval is. -->
<int name="numRecordsToKeep">100</int>
<int name="maxNumLogsToKeep">10</int>
</updateLog>
<autoCommit>
<maxTime>60000</maxTime>
<openSearcher>false</openSearcher>
</autoCommit>
</updateHandler>
A shorter autoCommit.maxTime bounds how much the tlog can grow (and how much a crash could lose or
a recovering replica must replay) at the cost of more frequent fsyncs; a longer one favors indexing
throughput. numRecordsToKeep / maxNumLogsToKeep cap how much of the already-committed log Solr
retains for peer-sync after a hard commit truncates it, which trades replica-recovery speed
(peer-sync a few missed updates versus a full index replication) against disk. See
Commits
and Transaction Logs for the complete updateLog reference.
DirectoryFactory
DirectoryFactory in solrconfig.xml chooses the Lucene Directory implementation a core reads
and writes its index through — effectively how index files map onto the OS:
<directoryFactory name="DirectoryFactory" class="${solr.directoryFactory:solr.NRTCachingDirectoryFactory}"/>
NRTCachingDirectoryFactory (the default) wraps MMapDirectory and keeps small, recently-written
segments in memory briefly to smooth out the flurry of tiny files a soft commit produces, before
they are large enough to be worth memory-mapping directly. MMapDirectoryFactory maps index files
straight into the process’s virtual address space, letting the OS page cache — not the JVM heap — hold hot index pages, which is why Solr deployments run with a comparatively small heap next to a
large amount of free system RAM. RAMDirectoryFactory is non-persistent and unsuitable for anything
but throwaway tests. See
Index
Location and Format for the full set of factories and dataDir placement.
autoSoftCommit and NRT tuning
A soft commit opens a new searcher over segments already sitting in memory/OS cache without an
fsync, which is what makes newly-indexed documents visible in near real time. Tuning
autoSoftCommit is a direct trade between visibility latency and the cost of opening a searcher too
often — each new searcher discards warm entries in the query result cache and document cache and
(unless autowarmCount is configured) starts them cold:
<updateHandler class="solr.DirectUpdateHandler2">
<!-- Visible within ~1s: standard for a search-facing collection. -->
<autoSoftCommit>
<maxTime>1000</maxTime>
</autoSoftCommit>
</updateHandler>
A maxTime in the low seconds is typical for interactive search; a bulk-load pipeline that does not
need immediate visibility should lengthen it (or disable autoSoftCommit and issue one explicit
softCommit=true at the end) the same way an Elasticsearch bulk load raises refresh_interval — see Elasticsearch’s performance tuning page for
the equivalent trade-off. Never drive NRT visibility from a per-write commit=true: that forces a
full hard commit, complete with fsync, on every single request.
docValues vs. the field cache
Sorting, faceting, and function queries need a column of per-document values, not the inverted
(term-to-document) index. docValues="true" on a field builds that column at index time and
stores it on disk, memory-mapped through the same DirectoryFactory as everything else — cheap to
open, no heap cost. Without it, Solr falls back to the legacy field cache (fc in
facet.method, or automatically for sorting on a non-docValues field): it builds the same columnar
structure by un-inverting the index on the heap, lazily, the first time it is needed after each
searcher opens. That rebuild happens on every autoSoftCommit, and the structure can consume a
large share of the heap on a high-cardinality field, which is why any field you sort, facet, or run
function queries against should have docValues="true" in
the schema. See
DocValues for which field
types support it and the numeric-field caveat (precisionStep interacts with docValues sizing).
Stored-field vs. docValues retrieval
Returning a field’s value in the response.docs[] of a query can be satisfied two ways: from the
stored value (the original, row-oriented copy kept alongside the index) or, for a docValues
field, reconstructed from the columnar structure via useDocValuesAsStored (the schema-and-fields
page covers the flag itself). Stored fields are compressed together per document and cheap for
returning many fields of one matching document; docValues retrieval reads one column at a time and
is cheaper when few fields of many documents are returned, or when you want the field back without
paying for a separate stored copy. Marking a field docValues="true" stored="false" is the usual
way to get both sort/facet support and result output from a single on-disk structure, saving the
duplicate storage a docValues="true" stored="true" field would otherwise pay.
best_compression
The stored-fields format defaults to a BEST_SPEED compression mode (LZ4-family). Switching a
core’s codec to best_compression trades some CPU on write and on stored-field retrieval for a
smaller on-disk footprint — broadly the same trade-off as Elasticsearch’s index.codec:
best_compression:
<codecFactory class="solr.SchemaCodecFactory">
<str name="compressionMode">BEST_COMPRESSION</str>
</codecFactory>
It only affects segments written after the change, so pair it with a one-time forceMerge (above)
on an existing, read-only core to compress what is already on disk; leave a still-growing core to
pick it up gradually as normal merges rewrite its segments.
JVM, GC, and heap sizing
Because MMapDirectory lets the OS page cache hold the hot index, Solr’s own heap only needs to
cover query execution, caches (Configuration &
caches), and any field cache built for non-docValues fields (above) — oversizing it starves the
page cache of RAM it would otherwise use to keep index pages resident. bin/solr sets the heap via
SOLR_HEAP (or SOLR_JAVA_MEM for a raw -Xms/-Xmx pair) and its GC tuning via GC_TUNE in
solr.in.sh:
# solr.in.sh
SOLR_HEAP="8g"
GC_TUNE="-XX:+UseG1GC -XX:+PerfDisableSharedMem -XX:+ParallelRefProcEnabled \
-XX:MaxGCPauseMillis=250 -XX:InitiatingHeapOccupancyPercent=25"
G1GC (Solr’s shipped default) favors bounded pause times over raw throughput, which matters more for
query latency than a marginally higher indexing rate. Start from the shipped defaults, load test
(below) with production-shaped queries, and only grow the heap in response to observed
OutOfMemoryError`s or old-generation pressure in GC logs — a bigger heap you don’t need is RAM
taken directly from the page cache. See
Taking
Solr to Production for the full set of `bin/solr/solr.in.sh variables, including ulimits and
swap.
SSD and the OS page cache
Query latency on a cold core is dominated by reading segment files from disk; on a warm one it is
dominated by CPU, because the pages are already in the OS page cache thanks to MMapDirectory.
Concretely this means: leave enough free system RAM (outside the JVM heap) for the page cache to
hold the working set of your index, prefer SSD over spinning disk for anything that does not
comfortably fit in page cache, and expect the first queries after a node restart or a large merge to
be slow purely from page-cache misses rather than any configuration problem. autowarmCount on the
query result cache and filter cache mitigates the
Solr-level cold-cache cost of a new searcher; it does nothing for the OS-level page-cache cost of a
cold disk.
Load testing
Tune the settings above against realistic load, not synthetic single-query timing: replay a sample
of production queries (including the facet/sort/function-query shapes that actually exercise
docValues) at realistic concurrency while a separate process drives the indexing rate you expect in
production, and watch segment count, GC pause times, and query latency percentiles together — merge, commit, and GC activity all compete for the same CPU and I/O a concurrent query is trying to
use.
# Cheap concurrency smoke test: N parallel curl loops against a representative query mix.
for i in $(seq 1 20); do
curl -s "http://localhost:8983/solr/books/select?q=title:darkness&sort=year_i+desc&rows=20" \
-o /dev/null -w "%{http_code} %{time_total}s\n" &
done
wait
Watch \{!cache=false} in your own test scripts if you deliberately want to bypass Solr’s caches and
measure worst-case segment-read cost rather than a warm cache hit — see
Query parsers for local-params syntax in general. For a
heavier, repeatable load profile, drive the same query mix with a dedicated HTTP load-testing tool
(JMeter, k6, Gatling) pointed at /select and /update, ramping concurrency until latency
percentiles or error rate cross the threshold you actually care about in production.
See also
-
Indexing & updates — the
/updatehandler,autoCommit/autoSoftCommit, and the transaction log’s role in RealTime Get. -
Configuration & caches — filter cache, query result cache, and document cache sizing, and
autowarmCount. -
Schema & fields — the
docValues,stored, anduseDocValuesAsStoredfield flags in full.