ClickHouse Keeps a Column Compressed Until Something Reads It
ColumnCompressed wraps a column in an LZ4 buffer and throws on every accessor until decompress() is called, so a stored block can sit in RAM compressed and only pay for the bytes a query actually touches.
TL;DR:
ColumnCompressedis a column whosegetDataAt,operator[],filter,sortand every other accessor throwLOGICAL_ERRORuntil somebody callsdecompress(). It holds the payload as one LZ4 buffer plus astd::functionthat rebuilds the original column. TheMemoryengine’scompress = truesetting, the query result cache and the nested-loop join’s build store all use it, so a block can sit in RAM at a fraction of its size and be inflated only for the columns a query actually reads.
Every in-memory table has the same problem. The data is in RAM, it is
readable, and therefore it is occupied. But “occupied” and “read” are not
the same event: a Memory table with a WHERE clause that matches 1% of the
rows pays for 100% of the bytes when the rows are inserted, and then reads
only the columns the predicate needs.
The obvious fix is to compress the data at rest in RAM and decompress it on read. The interesting part is not the compression — it is the shape of the wrapper. It is not a column that contains compressed data. It is a column that refuses to do anything until it has been decompressed:
// src/Columns/ColumnCompressed.h:158
[[noreturn]] static void throwMustBeDecompressed()
{
throw Exception(ErrorCodes::LOGICAL_ERROR, "ColumnCompressed must be decompressed before use");
}That single decision — fail loudly instead of silently inflating — is what
makes the rest of the design work. Because no code path can accidentally read
a compressed column, the compression can be pushed all the way down into the
storage layer without auditing every consumer of Block.
The class
The class documents its own contract:
// src/Columns/ColumnCompressed.h:19
/** Wrapper for compressed column data.
* The only supported operations are:
* - decompress (reconstruct the source column)
* - get the separately compressed parts of the source column, see `getParts`
* - get size in rows or bytes.
and, further down, the reason it exists at all:
// src/Columns/ColumnCompressed.h:28
* It's often beneficial to store compressed data in-memory and decompress on the fly
* because it allows to lower memory throughput. More specifically, if:
*
* decompression speed * num CPU cores >= memory read throughput
*
* Also in-memory compression allows to keep more data in RAM.The class is a COWHelper over IColumn, like every column, but it holds
three things and nothing else:
// src/Columns/ColumnCompressed.h:40
ColumnCompressed(size_t rows_, size_t bytes_, Lazy lazy_, Columns parts_ = {})
: rows(rows_), bytes(bytes_), lazy(lazy_), parts(std::move(parts_))
{
}where Lazy is just std::function<ColumnPtr()> — the recipe for rebuilding
the column. The three fields are the whole trick:
rows— how many rows the column has, so that size-sensitive logic (block row counts,Block::rows()) keeps working without inflating anything.bytes— the compressed size. This is whatbyteSize()reports, which is why theMemoryengine can use it as the weight of a column when it ordersPREWHEREconditions.lazy— the recipe for rebuilding the column.
And decompress() is one line:
// src/Columns/ColumnCompressed.h:51
ColumnPtr decompress() const override
{
return lazy();
}Note what is not there: no memoization. Calling decompress() twice runs
the LZ4 decompression twice and produces two distinct columns. The compressed
column is a recipe, not a cache. Every consumer that needs the data has to
be careful to decompress once and share the result — which is exactly what
the read path does.
The compression itself: raw LZ4, no codec pipeline
ClickHouse has a whole codec framework for on-disk columns — Delta,
Gorilla, ZSTD, CODEC(...) chains — and it is not what ColumnCompressed
uses. This is plain LZ4 over a flat byte buffer:
// src/Columns/ColumnCompressed.cpp:19
std::shared_ptr<Memory<>> ColumnCompressed::compressBuffer(const void * data, size_t data_size, bool force_compression)
{
size_t max_dest_size = LZ4_COMPRESSBOUND(data_size);
if (max_dest_size > std::numeric_limits<int>::max())
throw Exception(ErrorCodes::CANNOT_COMPRESS, "Cannot compress column of size {}", formatReadableSizeWithBinarySuffix(data_size));
Memory<> compressed(max_dest_size);
int compressed_size = LZ4_compress_default(
reinterpret_cast<const char *>(data),
compressed.data(),
static_cast<int>(data_size),
static_cast<int>(max_dest_size));
if (compressed_size <= 0)
throw Exception(ErrorCodes::CANNOT_COMPRESS, "Cannot compress column");
/// If compression is inefficient.
const size_t threshold = force_compression ? 1 : 2;
if (static_cast<size_t>(compressed_size) * threshold > data_size)
return {};
/// Shrink to fit.
auto shrank = std::make_shared<Memory<>>(compressed_size);
memcpy(shrank->data(), compressed.data(), compressed_size);
return shrank;
}Three details worth pausing on:
- The threshold. By default the compressed buffer must be at most half the
size of the original (
threshold = 2). Withforce_compressionthe bar drops to “not larger than the original” (threshold = 1). Anything that fails the bar returnsnullptr, and the caller falls back to storing the column uncompressed. - Shrink to fit. LZ4’s
COMPRESSBOUNDis a worst-case bound, typicallysize + size/255 + 16. The result is copied into an exactly-sized buffer so the RAM accounting is honest. This copy is the price of the “compressed size” claim. - No codec pipeline. The bytes handed to LZ4 are the raw in-memory
representation: for a
UInt64column, 65 409 eight-byte integers; for aStringcolumn, the char buffer. There is no Delta, no transposed layout. LZ4 finds the repeated bytes of a mostly-constant integer column just fine, and skipping the codec framework keeps the wrapper independent of the on-disk format.
The inverse is LZ4_decompress_safe — the safe variant, which validates the
output buffer length instead of trusting the compressed stream
(src/Columns/ColumnCompressed.cpp:50).
LZ4_decompress_safe — the safe variant, which validates the output buffer
length instead of trusting the compressed stream.
Who implements compress()
The entry point is on the column interface, and the default is “this column type cannot be compressed”:
// src/Columns/IColumn.h:816
/// Compress column in memory to some representation that allows to decompress it back.
/// Return itself if compression is not applicable for this column type.
/// The flag `force_compression` indicates that compression should be performed even if it's not efficient (if only compression factor < 1).
[[nodiscard]] virtual Ptr compress([[maybe_unused]] bool force_compression) const
{
/// No compression by default.
return getPtr();
}
/// If it's CompressedColumn, decompress it and return.
/// Otherwise return itself.
[[nodiscard]] virtual Ptr decompress() const
{
return getPtr();
}A flat numeric column is the easy case — one buffer, one LZ4 stream:
// src/Columns/ColumnVector.cpp:1331
template <typename T>
ColumnPtr ColumnVector<T>::compress(bool force_compression) const
{
const size_t data_size = data.size();
const size_t source_size = data_size * sizeof(T);
/// Don't compress small blocks.
if (source_size < 4096) /// A wild guess.
return ColumnCompressed::wrap(this->getPtr());
auto compressed = ColumnCompressed::compressBuffer(data.data(), source_size, force_compression);
if (!compressed)
return ColumnCompressed::wrap(this->getPtr());
const size_t compressed_size = compressed->size();
return ColumnCompressed::create(data_size, compressed_size,
[my_compressed = std::move(compressed), column_size = data_size]
{
auto res = ColumnVector<T>::create(column_size);
ColumnCompressed::decompressBuffer(
my_compressed->data(), res->getData().data(), my_compressed->size(), column_size * sizeof(T));
return res;
});
}Two thresholds show up here and they are different from the LZ4 threshold: a column smaller than 4096 bytes is not even attempted (the comment says “A wild guess”), and if LZ4 fails the 50% bar, the column is stored as-is.
ColumnCompressed::wrap is the “give up but stay uniform” path: it wraps the
uncompressed column in a ColumnCompressed whose lazy returns the original
pointer. Nothing is compressed, but the block’s shape is uniform — every
column in a compressed block is a ColumnCompressed, so the reader does not
need to branch on the type:
(ColumnCompressed::wrap, src/Columns/ColumnCompressed.h:65, wraps the
uncompressed column in a ColumnCompressed whose lazy returns the original
pointer.)
A String column is the interesting case, because it is two buffers — the
chars and the offsets — and they compress very differently. The chars are
text (compressible), the offsets are a monotonically increasing sequence of
UInt64 (usually not worth compressing at all):
// src/Columns/ColumnString.cpp:726
auto offsets_compressed = ColumnCompressed::compressBuffer(offsets.data(), source_offsets_size, force_compression);
const bool offsets_were_compressed = !!offsets_compressed;
/// Offsets are not compressible. Use the source data.
if (!offsets_compressed)
{
offsets_compressed = std::make_shared<Memory<>>(source_offsets_size);
memcpy(offsets_compressed->data(), offsets.data(), source_offsets_size);
}and the column keeps a part — the offsets alone — as a separately decompressible object:
// src/Columns/ColumnString.cpp:739
ColumnPtr offsets_part = ColumnCompressed::create(
source_offsets_elements,
offsets_compressed_size,
[my_offsets_compressed = offsets_compressed, source_offsets_elements, offsets_were_compressed]
{
auto res = ColumnUInt64::create(source_offsets_elements);
restoreOffsets(*my_offsets_compressed, offsets_were_compressed, res->getData());
return res;
});The whole column is then a second ColumnCompressed that holds both
compressed buffers, knows how to rebuild a ColumnString from them, and
carries the offsets part in its parts list.
The parts mechanism
getParts() is the reason a compressed column can answer some questions
without being fully inflated:
// src/Columns/ColumnCompressed.h:56
/// The parts of the source column, which share the compressed data (not counted in `byteSize`), or empty if it can be
/// decompressed only as a whole: `ColumnString` {offsets as `ColumnUInt64`}, `ColumnArray` {data, offsets},
/// `ColumnNullable` {nested, null map}, `ColumnTuple` {elements}, `ColumnMap` {nested array}.
const Columns & getParts() const { return parts; }A ColumnNullable is compressed by compressing its two sub-columns and
keeping them as parts; a ColumnArray does the same with its data and its
offsets (src/Columns/ColumnNullable.cpp:850,
src/Columns/ColumnArray.cpp:1444). The parts are the point: they are
independently decompressible.
A ColumnTuple keeps one part per element, and a ColumnMap one part for
its whole nested array.
Reading a compressed column: partial decompression
Here is where the parts pay off. The Memory engine’s reader has to answer
two different kinds of request: “give me the whole column” and “give me the
size of each array”. The second one needs only the offsets.
// src/Interpreters/getColumnFromBlock.cpp:26
const Columns * getCompressedParts(const IColumn & column, size_t expected)
{
const auto * compressed = typeid_cast<const ColumnCompressed *>(&column);
if (!compressed || compressed->getParts().size() != expected)
return nullptr;
return &compressed->getParts();
}and for arrays, the offsets part alone:
// src/Interpreters/getColumnFromBlock.cpp:103
ColumnPtr tryDecompressArrayOffsets(const IDataType & type, const IColumn & column)
{
if (!isArray(type))
return nullptr;
const auto * parts = getCompressedParts(column, 2);
return parts ? (*parts)[1]->decompress() : nullptr;
}So a query that reads only arr.size0 from a compressed Array column never
inflates the array data — it inflates the offsets (8 bytes per row) and
answers the question. That is the difference between “the column is
compressed” and “the column is compressed except for the part you need”.
The consumers
The Memory engine
The Memory engine has a compress setting:
// src/Storages/MemorySettings.cpp:19
DECLARE(Bool, compress, false, "Compress data in memory", 0) \
and the sink applies it to every column of every inserted block:
// src/Storages/StorageMemory.cpp:121
if ((*memory_settings)[MemorySetting::compress])
{
Block compressed_block;
for (const auto & elem : block)
compressed_block.insert({elem.column->compress(/*force_compression=*/true), elem.type, elem.name});
new_blocks.push_back(std::move(compressed_block));
}Note force_compression = true here: the Memory engine is storing the data
to be compressed, so even a column that only compresses to 95% of its
original size is worth wrapping — the wrapper’s byteSize() then reports the
compressed size, which is what the engine’s memory accounting uses.
The same file explains why byteSize() of a compressed column is the
compressed size, in a comment that is really a design note:
// src/Storages/StorageMemory.cpp:810
for (const auto & block : current_data->blocks)
{
for (const auto & elem : block)
{
/// For a table with `compress = true` the stored columns are `ColumnCompressed`,
/// whose `byteSize` is the compressed size - the amount of data a read of the
/// column has to decompress, which is exactly the weight the WHERE -> PREWHERE
/// optimization orders conditions by.
column_sizes[elem.name].data_compressed += elem.column->byteSize();
}
}This is the reason the wrapper reports the compressed size rather than the
uncompressed one: the optimizer sorts PREWHERE conditions by “how many bytes
will I have to touch to evaluate this predicate”, and for a compressed column
that is the compressed size.
The query result cache
The query result cache has a setting of its own,
query_cache_compress_entries (default true,
src/Core/Settings.cpp:7199), and the writer compresses every column of every
chunk on the way in:
// src/Interpreters/Cache/QueryResultCache.cpp:978
const Columns & columns = chunk.getColumns();
Columns compressed_columns;
for (const auto & column : columns)
{
auto compressed_column = column->compress(/*force_compression=*/false);
compressed_columns.push_back(compressed_column);
}Here force_compression is false: the cache holds many different result
sets, and a column that does not compress well should not be wrapped in a
wrapper that buys nothing.
The join build store
The nested-loop join keeps its build side in a store that may spill to disk. Before spilling it tries to compress the stored blocks, and it compares the sizes to decide whether the compression is worth anything:
// src/Processors/Transforms/BlockNestedLoopJoinData.cpp:113
std::optional<BuildBlock> compressBuildBlock(const BuildBlock & build_block)
{
Columns columns;
columns.reserve(build_block.columns.size());
bool any_compressed = false;
for (const auto & column : build_block.columns)
{
auto compressed_column = column->compress(/*force_compression=*/ false);
if (compressed_column->byteSize() >= column->byteSize())
compressed_column = column;
else
any_compressed = true;
columns.push_back(std::move(compressed_column));
}The comment is precise about the failure mode: a ColumnCompressed wrapper
around an uncompressible column is not free. Because decompress() is not
memoized, a reader that decompresses a block gets a fresh copy every time —
memory that is allocated, charged to the reader’s chunk, and thrown away.
The scattered block
The parallel hash join shuffles blocks between threads, and the shuffle
compresses them on the way (ScatteredBlock::compress() in
src/Interpreters/HashJoin/ScatteredBlock.h:250).
The read path: decompress once, share the result
Since decompress() is not memoized, the reader has to decompress once per
column and hand the result to everyone who needs it. The Memory engine’s
reader does exactly that, and it is careful to avoid decompressing a column
that is only needed for one of its subcolumns:
// src/Processors/QueryPlan/ReadFromMemoryStorageStep.cpp:249
std::optional<Block> decompressed;
for (const auto & column : columns_to_read)
{
if (column.isSubcolumn() || !parents_of_subcolumns.contains(column.name))
continue;
const auto * elem = src.findByName(column.name);
if (!elem || !elem->column)
continue;
auto decompressed_column = elem->column->decompress();
if (decompressed_column == elem->column)
continue;
if (!decompressed)
decompressed = src;
decompressed->getByName(column.name).column = std::move(decompressed_column);
}Two things to notice. First, decompressed_column == elem->column — the
ColumnCompressed::wrap case returns the same pointer, so nothing is
decompressed for an uncompressible column. Second, the decompressed block is
a copy of the source block (decompressed = src) whose columns have been
replaced; the original block still holds the compressed columns, so the
compressed copy stays in the store and can be decompressed again for the next
query.
That last point is the whole trick of the Memory engine: the store keeps the
compressed block, every query inflates its own copy, and the copy is
discarded when the query is done. The peak memory is
compressed_size + inflated_size_of_the_columns_this_query_touched.
What it costs
- No memoization.
decompress()runs the LZ4 expansion every time it is called. A block that is decompressed by three different consumers is decompressed three times unless the caller is careful. The read path is careful; third-party code has to be too. - LZ4 only, no codec pipeline. A column of slowly-varying integers does
not compress under LZ4; the codec framework’s
Deltawould, and it is not used here. That is a deliberate trade: the codec framework is tied to the on-disk format, andColumnCompressedmust work for arbitrary in-memory columns. - A flat buffer, so only “flat” columns benefit.
ColumnVector,ColumnFixedString,ColumnStringandColumnDecimalcompress their raw bytes; aColumnArraycompresses its data and offsets separately. - An extra copy on compression.
compressBuffercopies the LZ4 output into an exactly-sized buffer to make the size honest. - A wrapper that must be unwrapped. Every consumer of a
Blockhas to know aboutColumnCompressed; thethrowMustBeDecompresseddesign turns that into a loud error rather than a silent wrong answer, but it is still a cross-cutting concern.
The escape hatch
CREATE TABLE t (x UInt64, s String) ENGINE = Memory SETTINGS compress = false;
SELECT sum(x) FROM t; -- uncompressed: fast scans, more RAMand, for the query cache:
SELECT count() FROM hits SETTINGS query_cache_compress_entries = 0;Wrapping up
The one thing to remember: ColumnCompressed is not a column of
compressed data; it is a column that cannot be used until it is decompressed.
That distinction is what lets the compression live in the storage layer.
The part people get wrong: decompress() is not a getter that caches. Two
calls, two inflations, two copies. If you write code that touches a
compressed column from two places, you will pay twice and allocate twice.
What to go do about it: if you have a Memory table (a temporary table, a
dictionary source, a Materialized View target) that is much larger than
RAM allows, turn on compress = true and measure. The win is largest when
your queries are selective — a PREWHERE that touches 1% of the rows
inflates 1% of the columns. If your queries read everything, you are paying
LZ4 compression on write and LZ4 decompression on read for a memory saving
you may not need.