All articles

Columnar SSTable Experiments in Apache Cassandra (Part 1)

We use Cassandra to store customer’s metrics data that we collect into a Cassandra cluster, and as you can imagine, the data volume is growing all the time. Of course, the costs have been increasing, and I was curious whether we could modify Cassandra to write SSTables in a columnar layout while keeping the same CQL queries and data model. My hypothesis was, by having a columnar structure SSTables, we could drastically reduce the data ondisk. I’ve been toying around with the idea for a while, and I took the plunge and modified the Cassandra code to add experimental columnar and Parquet SSTable readers and writers so we could test my hypothesis for our use case. My biggest concerns were - a) ingest performance degredations, b) query performance degredations. Well, read on to find out how it turned out!

This part 1 of a series of intended blogs covers those measurements on a single node using synthetic time-series data with one floating-point reading per timestamp.

Continue with Part 2

Part 2 compares six compression combinations on our Cassandra 6.0 fork with separate servers for Cassandra and the load client. It covers compression choices and row-group sizes alongside ingestion CPU and range-query performance. The different hardware means absolute results cannot be compared between the two posts.

We based this experiment on the cassandra-5.0-rustyrazorblade branch. Jon Haddad and Patrick McFadin are doing some excellent work on Cassandra performance in this fork and we’re grateful to be able to build on their efforts.

Our columnar format uses encodings chosen for time-series data while the parquet format writes SSTables that can also be read by external tools. We compared both with the row-based trie-indexed BTI format in the same fork.

Across 1.21 billion synthetic metric points our columnar format used 66% less table storage than BTI after compaction. The longer range reads were faster too but ingestion slowed by about 5% and total node CPU time increased by 17%. We’ll work through the data layout and the measurements to explain where those differences came from.

Our columnar changes are not public yet

Our columnar and Parquet changes have not been published and are not included in the public branch linked above. The formats and configuration options described here belong to our experimental implementation and are not available in a standard Apache Cassandra installation. The measurements were recorded on 16 October 2026.

This was one run per format on a single node using synthetic data. It is not a production sizing recommendation or a cluster-wide performance result.

The Metrics Table

This is the table used for all three formats with the storage and compression options omitted. The full experimental settings are shown under Table Configuration later in the article.

CREATE TABLE metrics.v2metrics5 (
    orgid text,
    clusterhash bigint,
    metricid bigint,
    time int,
    value float,
    PRIMARY KEY ((orgid, clusterhash, metricid), time)
) WITH CLUSTERING ORDER BY (time DESC);

Each partition holds one metric series identified by (orgid, clusterhash, metricid). The time column stores the measurement’s Unix timestamp in seconds and each row contains one 32-bit floating-point value with the newest readings first.

The benchmark generated readings every five seconds for this table. The storage savings and query results apply to this numerical time-series workload and don’t establish how the formats would perform with text payloads or wider tables. Those still need separate benchmarks.

Storage and Performance Results

The three runs loaded the same number of points through CQL before flushing the remaining memtables and waiting for compaction to finish.

MeasurementBTIColumnarParquet
Table storage after compaction18.17 GB6.19 GB7.76 GB
Bytes per point15.025.126.41
Peak table storage during loading and compaction21.29 GB8.28 GB10.71 GB
Ingestion rate901,000 points/s860,000 points/s720,000 points/s
Total node CPU time13,332 CPU-seconds15,553 CPU-seconds18,901 CPU-seconds

All three runs were CPU-limited during ingestion with Cassandra using roughly 10 to 11 of its 12 allocated cores. Columnar reduced both table storage and the amount of data processed by compaction but needed more CPU time to ingest the same points. Any reduction in node count would still need enough CPU capacity for the workload.

Cassandra’s SSTable Layout

Cassandra is described as a wide-column database because of its data model. That doesn’t mean its SSTables store each column separately in the way a columnar analytical format does.

With BIG and BTI the main Data.db component contains partitions ordered by token and rows ordered by clustering values within each partition. Each row includes its cells and the metadata needed to interpret them. Write timestamps and deletion information allow Cassandra to reconcile versions while TTL metadata determines when values expire.

Cassandra already reduces some row metadata by storing write timestamps as variable-length differences from the timestamp baseline in the SSTable’s serialization header. The upstream Cassandra 5.0 implementation writes timestamp - stats.minTimestamp as an unsigned variable-length integer and its EncodingStats documentation explains how these baselines help reduce storage. This applies to Cassandra’s write-timestamp metadata and does not apply delta-of-delta encoding to the measurement times in our time clustering column.

BTI retains BIG’s data-file format as described in CEP-25 so both formats use this metadata encoding before block compression with a compressor such as LZ4 or Zstd. Our BTI benchmark used 16 KiB compression chunks with Zstd for compacted data.

This layout works well when a flush or compaction writes a sorted stream of rows. Reads can use the partition and row indexes to reach the relevant part of the file without scanning the whole SSTable.

For metrics we have another opportunity because consecutive rows often contain very similar data. A series collected every five seconds has a predictable sequence of timestamps. Its values might remain constant for long periods or move gradually between readings. Storing those sequences together lets us apply an encoding specifically suited to each one.

Generic compression can still find repeated patterns in interleaved rows. It just doesn’t perform the same type-aware transformations as a timestamp or floating-point encoder.

From Rows to Columns

Take four readings from a single series in the descending time order used by our metrics table. These values are illustrative so we can follow the transformation without looking at a full benchmark partition.

TimeValue
179176223042.17
179176222542.18
179176222042.18
179176221542.19

An SSTable’s Data.db component contains a byte stream with no physical rows or columns on the storage device. The difference between these layouts is the order in which the writer serializes the fields before compression.

In the row layout the time and metadata for reading 1 sit alongside its value before reading 2 begins. Our columnar writer buffers a group of rows and gathers the timestamps into one sequence. It does the same for each metadata field and the values so each sequence can use an encoding suited to its data.

Four readings are grouped into vertical time, metadata and value columns while retaining sample order. Below them a Data.db byte-stream strip shows the row-group header followed by a time chunk, metadata chunks and the value section before outer block compression.
M1 to M4 stand for each reading's metadata fields. The vertical columns show values before encoding and the strip below shows their section order in the byte stream. Headers are simplified and widths do not represent byte counts.

The columns in the diagram become consecutive sections inside one row group in Data.db. The clustering-time chunk comes before the row-metadata chunks and the section for the value column. Metadata has several chunks of its own for fields such as flags and liveness timestamps rather than one chunk of complete M1 to M4 records. The value section also carries the cell metadata needed to interpret its values.

The file stores encoded binary data rather than the decimal text shown in the diagram. Our timestamp encoding turns the regular five-second intervals into a first timestamp and interval followed by zero bits for unchanged intervals. The float values use a different encoding described below. Per-column compression can reduce those encoded chunks further before the outer table compressor turns the resulting stream into compressed blocks on disk. Our benchmark used 16 KiB uncompressed chunks for that outer compression so a row group can span several blocks and a block can contain bytes from more than one field.

This grouping follows the approach described in the PAX research by Anastassia Ailamaki and colleagues. Values are grouped by column within a bounded group of rows rather than putting an entire table column into one enormous file.

We keep those groups inside each Cassandra partition. A partition remains a contiguous section of the SSTable and the existing partition trie still finds its starting position. The row trie points to row groups so a time-range query can seek to the first group it needs.

Timestamp Encoding

The time clustering column decreases by five seconds between readings so taking the difference between those -5 intervals leaves zeros.

StepStored information
First timestamp1791762230
First interval-5 seconds
Next interval minus the previous interval-5 - (-5) = 0
Following interval minus the previous interval-5 - (-5) = 0

Our delta-of-delta encoding writes a single zero bit for each subsequent unchanged interval after the initial values. A missed collection or a change in spacing needs more bits but doesn’t require abandoning the encoding.

That one bit per regular interval describes the timestamp sequence after its initial values. It excludes chunk headers and the rest of the row metadata and should not be confused with the total storage per metric point.

This technique and the XOR encoding for floating-point values are described in the Gorilla paper by Tuomas Pelkonen and colleagues. Separating the fields lets us apply these encodings before the general-purpose compressor runs.

Floating-Point Encoding

The decimal values in the example suggest an integer representation such as 4217 and 4218 with a scale factor. Floating-point storage is more complicated because the conversion must recover the original binary value exactly.

ALP by Afroozeh and colleagues uses scaling and integer encoding for suitable values with exceptions where needed. ALP-RD handles less suitable values by splitting their bit patterns and dictionary-encoding the high portion.

Our alp encoder chooses between those approaches for each chunk. The full-precision random-walk values in this benchmark selected ALP-RD for 94% of chunks. A gauge with a few decimal digits or long stretches of constant values may behave differently and needs a separate measurement.

The format is designed for bit-for-bit recovery including signed zero and unusual floating-point values. The implementation tests include round trips and fuzzing alongside fixed expected byte sequences.

The Columnar Format

Cassandra’s SSTableFormat interface gives the implementation somewhere to connect its readers and writers without changing the application’s queries. The writer receives rows in order and buffers one row group before encoding its fields. The reader reconstructs rows and range tombstone markers for Cassandra’s normal read and reconciliation paths.

The custom format defaults to 128 rows per group with a size target of roughly 64 KiB. Each group holds clustering values and row metadata followed by sections for the regular columns. Non-frozen collections and UDTs use cell blobs rather than the same scalar encoding used for the metric value.

Columnar Data.db structure with partition headers and row groups. An expanded row group shows its header, range tombstone markers, clustering chunks, row metadata chunks and the value section. Each encoded chunk contains an encoding ID, value count, body length and encoded body before outer block compression.
The structure of our experimental columnar format. The file view follows byte order within a partition and expands one row group. Zstd is an example of optional column compression. The 16 KiB outer compression setting is from this benchmark and the diagram is not drawn to byte scale.
EncodingSuitable dataRepresentation
plainAny supported scalar dataUnencoded values
rleRepeated valuesA value with its repetition count
dictionaryA small set of distinct valuesA dictionary with bit-packed references
deltaInteger and timestamp sequencesPacked differences between values
delta_of_deltaRegularly spaced timestampsDifferences between consecutive intervals
gorillaFloating-point sequencesXOR differences between successive values
alpFloat and double valuesALP or ALP-RD selected per chunk
autoData without a prescribed encodingThe smallest result from the candidate encodings tried for that chunk

Each chunk records its encoding identifier and length so readers can identify or skip it. A column can then use Zstd or another supported compressor on the encoded data. Our benchmark also retained Cassandra’s outer chunk compression for the custom columnar file.

Skipping an encoded column avoids decoding its values. It doesn’t necessarily avoid reading the underlying compressed block when that block also contains fields required by the query. The number of physical reads still depends on the layout and cache state.

SSTable componentRole in the custom format
Data.dbPartition headers followed by columnar row groups
Partitions.dbBTI-style trie locating each partition
Rows.dbRow trie locating groups within large partitions
CompressionInfo.dbOffsets for the outer compressed chunks
Filter.dbBloom filter for partition keys
Statistics.dbSSTable statistics and serialization metadata

Parquet SSTables

We also tested Parquet because it offers a columnar storage format that external analytical tools can read without exporting the data through CQL.

The experimental Parquet format keeps contiguous Cassandra partitions and puts a Parquet row group inside each Cassandra row group. Cassandra’s timestamps and deletion metadata sit beside the Parquet pages without being exposed as ordinary Parquet columns.

The Parquet footer records the locations of column chunks. An external reader follows those offsets to the values while Cassandra also reads the additional metadata it needs.

Parquet Data.db file order from the opening PAR1 marker through Cassandra partitions and row groups to the footer and closing PAR1. An expanded row group shows separate Cassandra metadata and Parquet column chunks with the benchmark encodings. Footer offsets locate column chunks but do not reference Cassandra's separate metadata.
The experimental Parquet SSTable layout with the encodings used in this benchmark. The first three Parquet columns repeat the partition key for external readers. Cassandra metadata remains outside the column chunks referenced by the footer. The diagram is simplified and not drawn to byte scale.

Parquet pages have their own compression so this format doesn’t apply Cassandra’s outer chunk compression. Its SSTable uses CRC.db instead of CompressionInfo.db.

Direct Parquet reads bypass Cassandra reconciliation

External Parquet readers do not automatically apply Cassandra's tombstones or TTL expiry or reconcile versions across SSTables and replicas. A closed TWCS window does not remove these requirements so direct file analysis still needs a stable file set and explicit handling of expiry and deletions as well as duplicate versions.

The optional node-local Iceberg mirror tracks live Parquet files after flushes and compactions but does not add Cassandra reconciliation or provide a cluster-wide snapshot.

Parquet Encoding Limits

Parquet does not provide the delta-of-delta encoding used by our custom columnar format. Our Parquet implementation uses DELTA_BINARY_PACKED for timestamps instead. It encodes differences between consecutive values and can still compress regular intervals efficiently by packing repeated deltas with zero-bit payloads after recording the block metadata.

ALP is now defined in the Parquet specification as a preview encoding but the reader versions used in our tests rejected those files. We therefore used BYTE_STREAM_SPLIT with Zstd for the Parquet benchmark so the files remained readable by those tools.

Parquet’s ALP specification does not yet define ALP-RD. Our custom format can choose ALP-RD for full-precision floating-point data and selected it for 94% of chunks in this benchmark. The Parquet format therefore could not use the same floating-point encoding that our custom format used for most of these values.

Parquet Storage Overhead

Keeping row groups inside Cassandra partitions creates many smaller groups than an analytical export might use. With 512 rows per group the one-day SSTable contained roughly 340,000 groups and a footer of about 112 MB.

Contents of the one-day Parquet SSTableApproximate size
Floating-point values451 MB
Clustering timestamps19 MB
Partition-key fields repeated as columns53 MB
Parquet footer112 MB
Cassandra metadata and layout information465 MB

The partition keys have to be present for external readers to identify the series. Cassandra’s metadata still needs to be retained for correct CQL reads. Together with the footer those requirements explain why choosing an analytical file format doesn’t give us the same storage result as our custom format.

Write Timestamps

Both experimental formats preserve Cassandra’s write timestamps so writetime(value) remains available through CQL whether the SSTable uses our columnar layout or Parquet. Cassandra uses these timestamps to reconcile cell versions while the time clustering column holds the measurement time supplied by the application.

In our columnar format the row write timestamps are stored in a separate row.ts chunk as delta-encoded INT64 values. A cell with the same timestamp as its row sets CELL_USES_ROW_TIMESTAMP while a cell with a different timestamp stores it separately. Every value cell in this benchmark reused its row timestamp so the separate cell-timestamp chunk was empty.

The row write timestamps still occupied a substantial share of the columnar row groups even without separate cell timestamps.

Row write timestamps in columnarMeasurement
Encoded row.ts chunk2.375 bytes per point
Share of row-group bytes before outer file compressionAbout 40%
Whole-day Zstd compression check on the timestampsAbout 2.32 bytes per point

The client’s random jitter of 0-100 ms introduced roughly 18 bits of noise per write timestamp and left little repetition for Zstd to compress. These write timestamps therefore cost much more to store than the regularly spaced measurement times encoded with delta-of-delta.

Parquet retains the write timestamps in the Cassandra metadata beside each row group’s Parquet pages together with TTL and expiry information and tombstones. External readers see only the partition-key fields plus time and value in this benchmark because the Parquet footer does not reference Cassandra’s internal metadata.

Exposing write timestamps to those readers would require an additional Parquet column such as _writetime with its own storage cost and we have not implemented that option.

Table Configuration

We added named SSTable configurations so a table can select a format without changing the node default for every other table. The following settings go in cassandra.yaml and are specific to our unpublished implementation.

storage_compatibility_mode: NONE

sstable:
  selected_format: bti
  configurations:
    columnar:
      format: columnar
    parquet_bss_512:
      format: parquet
      parameters:
        float_encoding: byte_stream_split
        row_group_rows: '512'
        iceberg_mirror: 'true'

The metrics schema keeps the same partition key and clustering column. This excerpt shows the columnar table definition with the format-specific options at the end.

CREATE TABLE metrics.v2metrics5 (
    orgid text,
    clusterhash bigint,
    metricid bigint,
    time int,
    value float,
    PRIMARY KEY ((orgid, clusterhash, metricid), time)
) WITH CLUSTERING ORDER BY (time DESC)
    AND compaction = {
        'class': 'TimeWindowCompactionStrategy',
        'compaction_window_size': '1',
        'compaction_window_unit': 'DAYS'
    }
    AND compression = {
        'class': 'ZstdCompressor',
        'chunk_length_in_kb': '16'
    }
    AND default_time_to_live = 2592000
    AND sstable_format = 'columnar'
    AND column_encodings = {'time': 'delta_of_delta', 'value': 'alp'}
    AND column_compression = {'time': 'zstd', 'value': 'zstd'};

The benchmark additionally enabled unsafe_aggressive_sstable_expiration. That expiration option skips overlap safety checks and is not a general recommendation. It is omitted from the illustrative definition above and must be accounted for when reproducing the measurements.

An ALTER TABLE changes the format selected for new files. Existing SSTables remain in their current format until they are rewritten. Changing a setting therefore doesn’t immediately deliver the compacted storage figures shown here.

The three new table options are stored under reserved keys in the existing system_schema.tables.extensions map. Their names and values remain in schema while each node’s configuration defines the actual format behind a name. This allows the experimental implementation to select different formats in different datacentres.

Repair and Other Operations

The format has to preserve Cassandra’s deletion and reconciliation rules through maintenance work as well as ordinary queries. The implementation report describes tests of the following operations.

AreaBehaviour exercised by our implementation
ReadsForward and reversed iteration with row and range-tombstone reconstruction
CompactionIterator-based compaction with STCS and LCS alongside TWCS and UCS
StreamingPartition streaming rewrites rows into the receiver’s selected format. Entire-SSTable streaming requires a compatible receiver and format selection.
RepairValidation over reconstructed rows with full and incremental repair plus preview repair and mixed-format tests
Topology changesBootstrap and decommission tests with both streaming modes
Schema and upgradesFormat-support advertisement before enabling the new table options and upgrade tests against stock Cassandra 5.0
Maintenance and toolsRewriters and offline tooling alongside snapshot and import paths
Secondary indexesSAI and SASI consume reconstructed rows rather than the encoded column chunks
Unsupported tablesCounter tables are not supported by the new formats

The reported rollback test resets the table’s format selection and rewrites its SSTables before returning to stock 5.0. An unmodified Cassandra node cannot be expected to open these new formats. Successful development tests are not a substitute for a rehearsed rollback that covers every file and table in a real cluster.

The BTI baseline in this fork also uses cursor compaction while the columnar formats still use the iterator path. This is a comparison of the implementations available in the fork rather than an isolated measurement of layout alone. Our cursor compaction article explains the allocation savings that motivate extending that path.

Benchmark Setup

The test loaded seven days of data for 10,000 series with a point every five seconds. That produces 1,209,600,000 points with full-precision random-walk values rather than captured production metrics.

SettingBenchmark configuration
HostVM with 16 AMD EPYC Genoa vCPUs and 30 GB RAM on a virtual non-rotational disk
CPU allocationCassandra pinned to 12 cores and the client to the remaining 4
JVMJDK 21 with G1 and an 8 GB heap
ClientJava client using DataStax driver 3.12 over CQL
WritesUnlogged batches of 12 points within one partition with 256 requests in flight
RetentionEach point had its own write timestamp and a TTL of 30 days minus its age
Background reads20 last-hour queries per second during loading
CompactionTWCS with one-day windows and a final flush followed by completion of pending compactions
CompressionFast flush compression using LZ4 where applicable and table-level Zstd for compaction. Parquet used its own page compression.
Row groups128 rows for columnar and 512 for Parquet
Read measurementsConcurrency 1 and 16 with 10 seconds of warm-up and 30 seconds of measurement per query

Read measurements ran after compaction on an otherwise idle node. The recorded checks found the expected row counts with no query errors. They do not establish cold-cache performance or behaviour during concurrent repair and streaming.

Disk Usage

Component after compactionBTIColumnarParquet
Data.db18.08 GB6.09 GB7.73 GB
Rows.db71 MB95 MB24 MB
CompressionInfo.db14.2 MB3.5 MBNot used
Partition indexes and filters with statistics1.0 MB0.95 MB1.1 MB
Iceberg metadataNot usedNot used0.18 MB
Total table storage18.17 GB6.19 GB7.76 GB
SSTable count101011

The total of 5.12 bytes per point for columnar is smaller than the eight bytes occupied by an uncompressed four-byte timestamp and four-byte value. It still includes the additional information Cassandra needs because both the values and the metadata can be compressed across rows.

Columnar reduced the measured table-storage peak by about 61% while Parquet reduced it by about 50%. Those figures are separate from the 66% and 57% reductions after compaction. They exclude additional deployment requirements such as retained snapshots and the commit log so they are not complete disk-capacity targets.

Compaction I/O

Compactions processed 55.17 GB of SSTable input with BTI compared with 21.72 GB for columnar over the complete run. That is about 61% less input for the same number of ingested points. Parquet processed 25.14 GB.

Compaction measurementBTIColumnarParquet
Compactions completed95105106
Total input across all compactions55.17 GB21.72 GB25.14 GB
Total output across all compactions51.62 GB21.42 GB27.62 GB
Input bytes per point ingested45.618.020.8
Whole-day output compared with inputAbout 1% largerAbout 1% smallerAbout 10% larger

The whole-day figures cover the six day-closing merges in each run. The totals also include compactions within the active window where LZ4-compressed flush output can be rewritten with Zstd. This is why BTI’s total compaction output can be smaller than its total input even though its whole-day merges grew by about 1%.

Parquet’s whole-day compactions produced about 10% more output than input while columnar’s output shrank by about 1%. The Parquet growth remains unexplained and needs further investigation.

These input and output totals come from compaction history and describe SSTable bytes processed. They do not measure physical device reads separately from data served by caches. The lower input volume also did not make ingestion faster because the runs were CPU-limited.

Ingestion and CPU

MeasurementBTIColumnarParquet
Ingestion rate901,000 points/s860,000 points/s720,000 points/s
CPU cores used during sustained loadingAbout 10 of 12About 11 of 12About 11 of 12
Write latency p500.44 ms1.05 ms1.84 ms
Write latency p997.98 ms12.4 ms31.0 ms
Write latency p99.969.5 ms63.3 ms84.4 ms
Maximum write latency181 ms195 ms612 ms
CPU time during loading13,265 CPU-seconds15,161 CPU-seconds18,309 CPU-seconds
Final compaction elapsed time38 s214 s238 s
Final compaction CPU time67 CPU-seconds392 CPU-seconds592 CPU-seconds
Total node CPU time13,332 CPU-seconds15,553 CPU-seconds18,901 CPU-seconds
Total GC pause time86 s107 s227 s
Longest GC pause146 ms168 ms568 ms

Write latencies refer to the submitted batch requests. The core-use figures come from sampled process CPU time divided by elapsed time and describe sustained loading before the client finished. CPU-seconds measure accumulated CPU time across cores over the run rather than an instantaneous utilisation percentage.

The CPU samples show that the node remained busy throughout ingestion in all three runs. The commit-log and memtable write path is unchanged but the extra encoding work in flushes and compactions competes with incoming requests for CPU. The higher CPU cost per point therefore helps explain why the columnar formats ingested fewer points per second despite processing fewer compaction bytes. Columnar’s p99 write latency was about 55% higher than BTI’s while its ingestion rate fell by about 5%.

Parquet’s p99 was about 3.9 times the BTI result and its total CPU time was about 42% higher. Its throughput fell during the load while the process continued to use around 11 cores.

We haven’t measured how much of the extra columnar CPU can be recovered through cursor compaction or other encoding changes. The final-compaction timings also depend on the files remaining at the end of each load and should not be treated as a fixed per-row cost ratio.

Read Latency

The latency figures below were measured at concurrency 1. Each query returns the specified number of rows from a single series.

Query (rows returned)BTI p50 / p99Columnar p50 / p99Parquet p50 / p99
Latest point (1)0.40 / 0.76 ms0.42 / 0.81 ms0.37 / 0.78 ms
Last five minutes (60)0.59 / 1.06 ms0.57 / 1.06 ms0.58 / 1.06 ms
Last hour (720)1.69 / 4.10 ms1.32 / 3.11 ms1.43 / 3.29 ms
Last day (17,280)33.1 / 65.5 ms23.6 / 52.3 ms26.5 / 52.1 ms
Last seven days (120,960)235 / 434 ms185 / 347 ms201 / 345 ms

Single-request latency for the latest point was close across the three runs. For longer ranges the columnar formats had lower measured latency with the custom format giving the lowest median in each case.

Read Throughput

These rates were measured separately at concurrency 16 and are expressed as completed queries per second.

QueryBTIColumnarParquet
Latest point57,90050,00049,700
Last five minutes36,60034,40029,200
Last hour7,2209,0107,550
Last day406498447
Last seven days577061

Columnar completed around 22% to 25% more queries per second for the hour-to-week ranges. The latest-point query had around 14% lower throughput even though its single-request latency was similar. Decoding a group to return one row is a plausible contributor but needs profiling before we attribute the difference to it.

These are end-to-end CQL measurements including serialization and delivery of the returned rows. We haven’t isolated storage I/O from decoding and network costs or measured whether server-side aggregation changes the comparison.

Further Testing

For our metrics workload the custom format has produced enough storage savings to justify more development. The current benchmark uses an int clustering column and one float value column alongside the partition key so we still need to test tables with more columns and different data types. We also need to establish whether the savings hold with production-like data and whether the extra CPU cost can be reduced without changing the returned values.

  • Real metric samples will show how the encoders handle counters and gauges with repeated or low-precision values. The synthetic random walk cannot tell us the storage ratio for the full AxonOps workload.
  • Wider tables need comparisons across BTI and both experimental formats as the number of regular columns increases. We should measure storage per row alongside CPU and memory use during writes and compaction. Comparing queries that fetch a few columns with queries that fetch complete rows will also show whether selective decoding reduces the work done.
  • Mixed data types need tests of integer and floating-point columns alongside text and timestamps with identifiers such as UUIDs. These should include boolean values and nullable fields as well as collections and UDTs with both repeated and highly variable data.
  • Repeated runs and cache-controlled reads will help separate persistent differences from variation between runs. The current measurements have no confidence intervals.
  • Cursor compaction support will let us compare the custom format without the current difference in merge implementation.
  • Tiered formats can keep new data in BTI and convert closed TWCS windows to columnar. Our implementation includes the configuration but the combined write and read behaviour still needs benchmarking.
  • Metadata encoding needs more work because write timestamps and TTL information occupy a substantial share of the compressed files.
  • Multi-node tests need to measure repair and streaming throughput alongside topology changes and reads under concurrent maintenance.
  • Long-running expiry tests need to exercise late writes and overlapping SSTables with safe expiration settings over the full retention period.
  • Parquet investigation needs to explain the 10% growth in whole-day compaction output and quantify writer memory use and external-reader compatibility.

Smaller SSTables could reduce the bytes held in backups and transferred during compatible whole-file streaming. Partition streaming reconstructs rows so its network savings cannot be inferred from the on-disk compression ratio. Neither can we assume that a smaller file means the heap or decoded caches shrink by the same amount.

Parquet gives us a useful route for external analysis but it was more expensive than the custom format in both storage and CPU in this experiment. A separate export or analytical copy is worth investigating once the version and expiry rules are handled explicitly.

We’re building on Cassandra’s existing storage interfaces and the work of the people who developed BTI and its maintenance paths. The PAX and Gorilla research alongside the ALP and Parquet projects gave us concrete techniques to test. I’m grateful for that work and for the testing and review that make it possible to investigate a change like this without starting again with the rest of the database.

For AxonOps the aim is to retain detailed monitoring history at a lower storage cost while keeping the Cassandra behaviour our applications depend on. The first measurements show lower table storage and compaction input alongside faster longer-range reads. Ingestion was CPU-limited in every run so we still need to test both CPU and storage requirements under production workloads before recommending smaller deployments.

Demo Sandbox

Explore AxonOps for Apache Cassandra and Kafka in a live demo environment.

Try it now
All articles