The Report That Counted Twice
The system I work on stores sales receipts. Every cash register sends every receipt, each receipt arrives through Kafka as a document, and ClickHouse keeps them.
On top of the raw documents sit the reports people actually look at: totals per cashier shift, totals per hour and per day for a store, revenue grouped by a custom field the merchant puts on the receipt.
Sounds like the textbook use case for ClickHouse. Insert rows, aggregate, done.
It took us four attempts.
The thing that makes it hard is not the volume. It is three facts about the stream that every naive design trips over:
- Documents come twice. Kafka delivery is at-least-once. A batch fails halfway, the consumer retries, the same receipt is inserted again. Any total that is "the sum of what was inserted" is now wrong.
- Documents come late. Most receipts arrive within seconds, but a register that was offline for a week sends its backlog when it reconnects. A small but steady share of documents arrive more than a day late, and some a month late. A report for last Tuesday is never final.
- The pipeline stops. Whatever computes the aggregates will sometimes be down: a deploy, a node restart, a bug. When it comes back, nothing may be lost.
And the reports are read with real latency budgets: an interactive call should answer in a few seconds, a heavy report over hundreds of registers in under thirty.
Four Attempts
| # | The idea | What broke |
|---|---|---|
| 1 | Add it up on insert | a duplicate is counted twice, forever |
| 2 | Recompute from the wide table | read 46 million rows to use 508 thousand |
| 3 | Remember every key | the memory of keys outgrew the node |
| 4 | Don't store anything | 3.5 billion rows for one report |
1. Add It Up on Insert
The first version was the classic one: materialized views on insert, writing partial sums into a SummingMergeTree. Each insert adds its contribution, background merges fold the rows together, and reports read pre-summed rows.
It is fast and it is wrong.
A re-delivered receipt adds its sum a second time, and once two numbers have been added there is no way to subtract the right one later. The aggregate has no memory of where its value came from.
2. Recompute From the Wide Table
The second version fixed the double counting with recomputation: a refreshable materialized view periodically rebuilt the totals of recently touched shifts and hours from the documents themselves, with duplicates collapsed by document key.
Correct this time. But it read the main documents table, which is wide (payload, statuses, dozens of columns) and sorted by register and document key, not by time.
The recompute filtered by date, and here is the part that bit us: in ClickHouse a date filter on a table not sorted by date cuts away whole partitions, but not granules (more on granules in a moment). One pass read 46 million rows to use 508 thousand:
In production the passes started hitting the per-query memory limit and failed, again and again.
3. Remember Every Key
The third version went back to adding up on insert and tried to make duplicates impossible: before adding a receipt, check whether its key was seen before. The key lookup was implemented as a ClickHouse dictionary over a table of recent keys.
A dictionary lives in memory, and a key in it is not small: 161 bytes on our test cluster. A month of keys for every register did not fit, and from there it went in circles:
Documents started landing several times each.
The fix for duplicates became a generator of duplicates.
4. Don't Store Anything
The fourth version gave up on stored aggregates altogether: compute every report from the documents at read time, collapsing duplicates with a GROUP BY on the document key. No state, nothing to get out of sync, re-deliveries and late documents are handled for free.
It was correct, and it was slow in a way no tuning could fix. A report over 600 shifts read about 3.5 billion rows. On our test cluster, at the 99th percentile:
| Report | 1 client | 4 clients |
|---|---|---|
| 600 shifts by key | 23.4 s | 119 s |
| hourly retail, 150 registers, a month | 17.0 s | failed on memory and time limits |
| daily retail, 150 registers, a month | 14.3 s | timed out on every call |
What the Failures Had in Common
Looking at the three production failures side by side was the turning point. None of them was caused by storing an aggregate:
Attempt 4 removed storage and paid for it on every read, which put it on the same failure as attempt 2.
So the question became: can an aggregate be stored, survive duplicates without remembering keys, and be recomputed without reading the world?
A Short Detour: Granules
To see why the read volume matters so much, it helps to know how ClickHouse reads.
A MergeTree table is split into parts, and each part is split into granules of 8192 rows by default. The primary index is sparse: it stores the first key of every granule, not of every row.
When a query filters by the primary key, ClickHouse finds the granules that may contain matching rows and reads them whole. Skip indexes (for example minmax over a date column) can throw away more granules, but only if the values inside a granule are close to each other.
The consequence: what a query reads depends far more on how rows are laid out than on how many rows it needs. If the rows you want are scattered across the table, you read the granules around each of them:
In our compute-at-read attempt, the narrow retail table was sorted by hour first. Within an hour, receipts of thousands of registers were interleaved, so a report for 150 registers touched nearly every granule of the month.
We even had to rewrite our own acceptance criteria: a limit on "rows read" was impossible to meet in any query shape, because the boundary granules always contain someone else's rows.
The good news is the reverse also holds. If the rows of one unit of work sit next to each other, reading them costs a handful of granules. That turned out to be the key to the final design.
The Idea: An Aggregate Is a Function
The design we kept rests on one shift in thinking. An aggregate is not the running sum of what was inserted. It is a function of its documents.
Take the smallest useful unit of aggregation, a cell:
case class CellKey(register: RegisterId, shift: Int, hour: Instant) // hour in UTC
Every document belongs to exactly one cell, and the link is not stored anywhere. It is computed from three columns of the document:
The same in code:
def key(d: Document): CellKey =
CellKey(d.register, d.shift, d.date.truncatedTo(HOURS))
def docs(k: CellKey): Seq[Document] =
allDocuments.filter(d => key(d) == k)
def value(k: CellKey): Measures =
docs(k)
.groupBy(_.id)
.values
.map(_.maxBy(_.writtenAt)) // a re-delivered document collapses here
.foldLeft(Measures.empty)(_ + _) // sums, counts, min, max
The value of a cell can be computed again at any moment, because the documents are still there and the rule is the same. A new computation is written as a new row, versioned by the time of recomputation, and replaces the old one in a ReplacingMergeTree.
This gives three properties for free:
- A re-delivered document changes nothing. During recomputation the rows of one document are collapsed by document key (
argMaxby write time). Two deliveries become one row; the cell value stays the same. No dictionary, no key table, no memory of what was seen. - Recomputing twice is harmless. Recomputing a cell with no new documents produces the same value. On one node, two recomputations running at once both write a version, and the later one has seen at least as much, so it wins. Replicas are a different story (see What It Costs).
- A late document is not lost. A receipt that is a month late marks its month-old cell as dirty now, and that cell is recomputed in the next pass.
The second half of the idea is additivity. Every measure in a cell is a sum, a count, a min or a max. Every document is in exactly one cell. So any report over any set of cells is just the combination of their values, without double counting:
None of these needs a table of its own: a new report is a new query over the same cells. Even time zones work, as long as the offset from UTC is a whole number of hours, since a local day boundary is then still an hour boundary.
We checked the additivity the boring way: hours, days and shifts assembled from cells matched the same reports computed directly from documents on all 209 measures.
Not Only Receipts
Registers and receipts are just the example at hand. Swap the nouns and the pattern stays:
| In this article | In general |
|---|---|
| a register | a source of events: a device, an account, a sensor |
| a receipt, stored as a document | an event with a stable key of its own |
| a shift, an hour | natural windows the events fall into |
sums, counts, min, max | measures that add up |
It needs three things: every event carries everything needed to compute its cell key, that key does not change when the event is delivered again, and the measures add up, or at least merge. Where those hold, the aggregate can be a function of its events instead of a running sum.
Making It Work in ClickHouse
The idea is short. Most of the work was in the mechanics.
A Narrow Table With the Right Order
Recomputation never reads the wide documents table. A materialized view copies every document row into a narrow table with only the columns the measures need, sorted by (register, document number).
There is no time column in that key, and it does not need one. A register numbers its documents in the order it issues them, so the documents of one register in one hour sit next to each other in every part. Finding the documents of a cell is a short range scan. The densest cell in our test history, a little over 5 000 documents, reads 20 granules and finishes in 7 ms. Compared to reading the same minute of traffic from the wide table, the narrow one reads 30 to 37 times fewer rows.
A Journal Written in the Same Insert
A second materialized view on the same insert writes a dirty mark for every touched cell: the cell key and the time of the mark. No sums, just "this cell needs recomputing".
Both views run as part of the same insert as the document itself, so our code has no separate step between "document stored" and "cell marked". That is not a transaction, though. ClickHouse writes the source table and its views one after another, and if a view fails after the document is stored, the insert reports an error with the document already in place. Our consumer then retries the batch, which writes the mark again, and a repeated document is harmless here. How that retry meets insert deduplication on replicated tables, where a repeated block can be dropped before its views run, deserves a look of its own.
Status updates for documents arrive as separate rows without a date and shift, and the views simply skip them, so a status never triggers a recompute.
The journal is a ReplacingMergeTree keyed by the cell, so repeated marks of one cell collapse into one row with the latest time. Small inserts produced about 14 marks per cell per hour; after collapsing it was 1.16, and the main recompute pass became five times cheaper.
"Recomputed" Lives in the Data
A refreshable materialized view runs every 30 seconds and recomputes the cells that need it. The obvious way to track progress is a cursor, "processed up to time T". We did not keep one.
Instead, each pass compares two times taken from the data itself:
The same in code:
def lastMark(k: CellKey): Instant =
journal.marks(k).max
def lastRecompute(k: CellKey): Instant =
cells.versions(k).maxOption.getOrElse(Instant.EPOCH) // never computed yet
def dirty(k: CellKey): Boolean =
lastMark(k).isAfter(lastRecompute(k).minusSeconds(60))
def pass(): Seq[Cell] =
journal.keys
.filter(dirty)
.sortBy(lastMark)
.take(50_000)
.map(k => Cell(k, value(k), recomputedAt = Instant.now()))
A cell that was never computed has no version yet, so it counts as computed at the epoch and turns dirty with its first mark.
A cursor has to be moved atomically together with the write, and it breaks under retries, parallel runs and restarts. The comparison does not care about any of that. After any outage, the next pass sees exactly the cells that were marked after their last recompute. The 60-second overlap covers the fact that parts of one insert become visible at slightly different moments, so a mark can appear just before its document does. The price is an occasional extra recompute.
A pass reads only the documents of the cells it recomputes.
Two Windows That Must Not Overlap
The main pass looks at marks from the last two hours. A second, catch-up view runs every ten minutes and looks at marks between two hours and one day old. It exists for outages longer than two hours.
The windows must not overlap. In an early version they did, and after an outage both views grabbed the same cells at the same time: 26 seconds and two times 2.8 GB of memory on one node for work that should have been done once.
Version by Snapshot Time
The version of a cell row is the time of its recomputation, not the latest write time of its documents. We tried the latter first. With many documents written in the same second, and recomputations running close together, the order of versions became undefined. A later snapshot always sees at least as many documents as an earlier one, so snapshot time is a version that is always correct.
That "at least as many" holds for snapshots taken on the same node. Across replicas it does not, and that is the weakest point of the whole design.
Shifts From Cells, Not From Documents
A shift is assembled from its cells by a second refreshable view: take the latest row of every cell of the shift, sum the measures, take min and max of numbers and dates. A busy register can have 142 000 documents in a shift, and some shifts stay open for months. Rebuilding a shift from documents on every new receipt would reread it all; from cells it is one row per hour of the shift.
Reading Without FINAL
Readers take the latest row of each key with LIMIT 1 BY over the key, ordered by recomputation time, and run that subquery on every shard over its local tables. Everything belonging to one register (documents, marks, cells, shifts) lives on the same shard, so "the latest row" is decided correctly locally and nobody waits for background merges.
Things That Bit Us
A few lessons that are not obvious from the design and cost us a day or more each.
Backfill Floods the Shift View
When we filled cells for 77 days of history in one go, every filled cell looked "recently recomputed" to the shift view. It then spent two hours regrouping millions of cells and writing nothing new.
Backfilled rows now get a recomputation time two days in the past, outside both windows. The shift view went from 88 million rows and half a core per pass to 4 million rows and almost nothing.
Filter in a Subquery Inside Insert Views
On ClickHouse 26.3 we saw an insert view write column values shifted. Three things came together: output aliases that matched source column names, the filter in the same query, and blocks that mixed documents with status rows.
Moving the filter into a subquery fixed it, and we have a test for it:
-- values came out shifted
CREATE MATERIALIZED VIEW docs_mv TO docs AS
SELECT register_id, doc_number, ifNull(total, 0) AS total, ...
FROM transactions
WHERE type IS NOT NULL;
-- fixed
CREATE MATERIALIZED VIEW docs_mv TO docs AS
SELECT register_id, doc_number, ifNull(total, 0) AS total, ...
FROM (SELECT * FROM transactions WHERE type IS NOT NULL);
Granule Size Matters for Wide Rows
A cell has about 250 columns, and the retail report reads 189 of them. With the default granule of 8192 rows, a query for 150 registers decompressed about 1.1 GB to use 22 000 cells.
Smaller granules and smaller compressed blocks on the cell table:
SETTINGS index_granularity = 1024,
min_compress_block_size = 8192,
max_compress_block_size = 65536
The same query decompressed about 0.3 GB, and CPU dropped by about 30% for 9% more disk.
One Aggregate State per Document, Not per Column
Collapsing a document with a separate argMax per column builds dozens of aggregate states per document. One argMax over a tuple of the columns builds one:
-- one state per column
SELECT doc_key,
argMax(total, updated_at),
argMax(cash, updated_at),
argMax(card, updated_at),
...
FROM docs GROUP BY doc_key;
-- one state per document
SELECT doc_key,
argMax((total, cash, card, ...), updated_at) AS doc
FROM docs GROUP BY doc_key;
The Numbers
The test cluster is small on purpose: two shards on one machine, five cores per ClickHouse node, tens of thousands of registers, 77 days of history. Reports, 99th percentile:
The same numbers as a table
| Report | From documents, 1 client | Cells, 1 client | From documents, 4 clients | Cells, 4 clients |
|---|---|---|---|---|
| 600 shifts by key | 23.4 s | 2.1 s | 119 s | 2.7 s |
| hourly retail, 150 registers, a month | 17.0 s | 2.5 s | failed | 6.1 s |
| daily retail, 150 registers, a month | 14.3 s | 1.3 s | timed out | 3.7 s |
| revenue by custom field, 50 registers | 7.9 s | 0.6 s | 100 s | 1.6 s |
The 600-shift report reads up to 360 thousand rows instead of 3.5 billion. Every report call on the cells stays within twice the theoretical granule bound for the rows it needs.
Correctness was checked against an independent reference: an expected value for every day, shift, hour and month of 1 000 registers, computed by the load generator without the service.
Zero mismatches across 31 checks, after three hours of live traffic in steps of increasing load, the last at three times the first.
What It Costs
Nothing is free, and these are the trade-offs we accepted on purpose.
- Lag. A report sees a new document one or two passes later, usually 75 to 115 seconds after insert. We decided not to compute the pending tail at read time.
- Long outages need a hand. Marks older than a day are not picked up automatically. The journal keeps marks for seven days, so there is time to run a manual pass.
- Replicas recompute on their own, and that is a hole. Refreshable views run on each replica of a shard, and each replica sees its own copy of the documents and of the journal, which replicate separately. A replica can see a mark before the document behind it, recompute the cell without that document, and stamp the result later than the correct version from the other replica. The later stamp wins. If the gap outlasts the 60-second overlap, nothing marks the cell again until its next document, and for a late document that may be never. Our test cluster had one replica per shard, so it never showed up there; we found this one on paper, while writing this post. The fixes we see: let one replica per shard recompute, derive the version from the documents rather than the clock, or sweep yesterday's cells once a day regardless of their stamps.
- Only mergeable measures. Sums, counts,
minandmaxadd up across cells. Exact distinct counts and exact medians do not. ClickHouse can keep a mergeable aggregate state in a cell instead of a number:uniqStateandquantileStatecombine across cells withuniqMergeandquantileMerge, approximately and in little space. The exact variants merge too, but carry every value. Our reports need none of this today; it is the direction the cells would grow in, not a hole in them. - Backfill is a procedure. Cells for existing history are built by a separate script, about an hour and a half for our 77 test days.
Closing
The four attempts were not four bad ideas. Each one fixed the previous failure and ran into the next property of the stream: duplicates, scattered reads, memory for deduplication, and finally the cost of reading everything on every request.
None of it is new, and we could have known that sooner. Databases call it incremental view maintenance: record which parts of a materialized result a change touched, recompute only those parts, replace the old values. Oracle's partition change tracking does exactly that for materialized views, and a survey from 1995 had already catalogued the techniques. It took us four attempts to reach the first page of that survey. What was ours is only the choice of the cell, and keeping the mark, the version and the "recomputed" flag inside ClickHouse tables themselves.
What finally worked was not a new ClickHouse feature. It was changing what an aggregate means. As long as an aggregate is a sum of inserts, every duplicate and every late document is a correction you have to remember. When it is a function of its documents, you never correct it. You just compute it again.