Blog  /  Engineering

Speeding Up Object Store Queries

Three ways to speed up queries when data is backed by object stores.

Jagannath Timma  ยท  4 min read
Speeding Up Object Store Queries

Object stores are becoming the de facto storage layer for modern observability systems. This is for good reason as well:

  • They are infinitely scalable for all practical purposes.
  • Don’t have to explicitly handle for durability/availability because it’s handled by S3/GCS etc.

But keeping all data in object store also comes with its own challenges. Since we changed the performance characteristics of data access completely — local SSD vs network access from object store — we are bound to get a different set of challenges and trade-offs to solve.

Performance characteristicsLocal NVMe SSDObject storage
Sequential bandwidth~5–15 GB/s per device~50–100 MB/s per request
Concurrency for full bandwidthtens of outstanding I/Oshundreds of parallel requests
Latencysub-millisecondtens of ms before the first byte, regardless of request size
Efficient request sizekilobytesmegabytes
Reading 1 MiB vs 4 KiBroughly proportional~250× the data, under 2× the time
Storage cost per TB~4–7× object storagecheapest durable option
Figures rounded to the shape of the problem rather than to a specific device or provider. For measured numbers see Durner, Leis & Neumann, “Exploiting Cloud Object Storage for High-Performance Analytics”, PVLDB 16(11), 2023.
Request time equals time to first byte plus bytes divided by per-request bandwidth, roughly 30 ms plus 20 ms per MiB for object storage: 4 KiB takes 30 ms, 1 MiB 50 ms and 16 MiB 350 ms

At Kloudfuse we store every kind of observability data — metrics, logs, traces, events, RUM — in a homegrown columnar OLAP database. This database supports tiered storage and one of the tiers is pure object store. Users can configure to have most recent data on local disk — say the most recent day of data, which is gonna be used for alert evaluations, latency-sensitive queries. Rest can stay fully on object-store only. When queries are served, the database engine understands how to serve them from local and object-store segments.

To achieve good performance over object store, the query planner is object-store aware and behaves differently than for data that is on local disks.

Smarter query planning and selective prefetching

A columnar engine normally reads data blocks (or chunks) on demand, scans are typically also done in a data-stored-order to make efficient use of data locality and operating system page caches. The below figure demonstrates this.

A scan on local disk: reads arrive in storage order, and kernel readahead has the next 1 MiB chunks in the page cache before they are asked for, so nothing stalls between chunks

But if data is located on object store, this implementation would make N number of expensive object-store GET calls inline during query execution. Instead, what we do is insert “prefetching” phases after each filter stage.

The filters themselves are classified into categories based on their cost and they are sorted in increasing order of cost.

  • Local filters — can be served from local disk (more on this later)
  • Object-store index filters — index-based filters that reside on object store
  • Object-store scan filters (most expensive to fetch) — scan-based filters where we have to fetch and scan actual data.

After each filter stage, the “prefetch” phase fetches data for the next stage using range reading and concurrent threads to reduce latencies. Note that prefetching only fetches what is required after the previous stage has filtered out matching rows.

Rows surviving each predicate: 2,500,000 rows in the segment, 312,500 after the pinned timestamp range index, 34 after the text index, held in 22 of the 64 chunks in range, so 22 MiB of a 512 MiB log_line column is wanted

At this point, if a query needs multiple chunks that are “close” to each other in offsets, then we coalesce them into a single read. We end up potentially fetching more data, but this trade-off is better than making multiple GET calls.

22 wanted chunks merged across gaps of two chunks or less become 8 ranged GETs: 31 MiB issued together in about 0.19 s, against 22 on-demand GETs taking about 1.1 s

If multiple query worker threads need the same data then those fetch requests also get deduped so we do not incur redundant cost.

Four query workers all want chunk 341; an in-flight map keyed by chunk range issues a single GET to the object store, so it costs 1 GET and 31 MiB instead of 4 GETs and 124 MiB

Index pinning

All telemetry data has multiple different kinds of indexes that help speed up queries. Some of them are small and some are large — e.g., a text index is naturally larger than an inverted index on the timestamp column. There are bloom filters, range index, inverted indexes, our own proprietary labels index which is blazingly fast and also storage efficient, text index, etc., to name a few.

Some of these indexes are pinned to local disk so they are always cheaply accessible — the query planner knows this and uses this information while creating the physical plan. These pinned indexes are small in size and are always needed anyway — e.g., a time range filter is always present in any telemetry query.

Indexes pinned on local disk (timestamp range, inverted, bloom filters, labels and the offset map) and the offset map from column and index to offset and length, used to issue an exact byte-range GET against indexes offloaded to the object store

Caching

The database also caches what it fetches from object store in an addressable and efficient manner so it could be reused by subsequent queries.

How a cached chunk is addressed: a key of index type, column and big-endian block id resolves through a memory-mapped file, with no read syscall, to an entry addressable in memory

Notice above that the cache stores data in mmap’ed files, which is how non-object-store data is stored.

  • This allows us to do zero-copy optimizations in a lot of code paths.
  • We don’t have to explicitly cache in-memory because that is managed by the operating system page cache.

Conclusion

All of these techniques combined improve query performance by 5–10x compared to inline sequential fetching. Along with this, the Kloudfuse engine has many other optimizations that make low-latency query results possible at scale.


Keep reading

Ask harder questions of your production data.

See what Kloudfuse can uncover across your telemetry — without moving it outside your cloud.