Analytics migration to ClickHouse
Stack
ClickHouse · Kafka Connect · Airflow
Text
The figures this page carried until 2026-09-03 (fourteen billion rows, a p95 falling from 9.4 seconds to 380 milliseconds, and a rollback executed at 02:40) were the handoff prototype's and are removed under erratum 7.19. No replacements are invented.
1. The constraint
Analytical queries on a transactional database work until they do not, and the transition is not gradual.
A row store fetches whole rows. An analytical query wants three columns out of forty across a year of history, so the engine reads roughly thirteen times the data it needs, and the cost grows with total table size rather than with the size of the answer. For a long time this is fine and the fix is an index. Then a dashboard that took two seconds takes forty, and the honest description is that the storage model stopped matching the question.
The migration itself is not the hard part. The hard part is that the analytics were already load-bearing. Reporting surfaces sit in front of customers, operators run their day from them, and regulated clients have reporting obligations that do not pause. So there is no window, no maintenance weekend, and no version of this where the numbers are allowed to be wrong for an afternoon while a cutover settles.
2. The decisions, and where each one is enforced
2.1. Dual write, and the old store stays authoritative. Both stores take every write from the beginning. The new one is not read from until reconciliation has been clean for longer than the longest reporting window, because a discrepancy that only appears in a monthly close is invisible for a month. Authority is enforced at the read path rather than agreed in a plan: the query layer’s default target is the row store, and moving a query is an explicit act.
2.2. Reads move one query at a time, behind a flag. Not one table, and never all at once. A dashboard is moved, watched against the old answer, and either kept or returned by flipping a flag with no deploy. This is slower than a cutover and it is the only version where “the numbers look wrong” has a same-minute answer.
2.3. The rollback is built first and exercised on a schedule. Before any read moves, the path back is written and run. Then it keeps being run, against production, on a timer, because a rollback that has not executed this month is a plan and not a capability. Failure 5.5 is what happens when this lapses, and it lapses by default.
2.4. The schema is designed for the query, not translated from the source. A columnar store rewards denormalisation, a sort key matching the dominant access pattern, and partitioning on the dimension that queries filter on first, usually time. Porting the normalised OLTP schema across is the fastest way to build something that is slower than what you left, and it is the most common way this migration fails.
2.5. Every load is idempotent on a natural key. This one property makes backfill resumable, makes stream and backfill safe to overlap, and makes a duplicated batch harmless. It costs a key design decision at the start and it removes an entire category of incident.
3. Why this is a product decision and not a storage one
Analytics that answer in under a second and analytics that answer in forty are different products, not the same product at different speeds.
For the customers this served, reporting is not a convenience feature. A telecom operator sizes staffing from it, a bank reconciles against it, and a regulated client has obligations that assume it returns. When a dashboard takes forty seconds, people stop opening it and start asking a person, which converts an infrastructure cost into a support cost and hides it.
The migration is also what makes the next thing possible. Query patterns that nobody proposes because they are known to be too expensive (cohorting across a full history, per-tenant breakdowns over a year) become ordinary requests once the storage model fits them. The measured benefit is the dashboards that got faster. The unmeasured one is the analysis that starts getting asked for.
4. Figures
This note reports none. The four that would matter are rows migrated, query latency at p95 before and against after on the same query shapes, the divergence rate observed during dual writes, and the elapsed time from first dual write to full cutover.
They exist for the deployment and are not published here. The figures this page did carry were the prototype’s, and erratum 7.19 removes them. Publishing a plausible substitute would be the same defect in better clothes, which is the argument erratum 7.13 made and this note inherits.
5. What I would do differently
Build the differential test harness before moving the first query. Failure 5.3 is the one that damages trust rather than uptime, and it is caught by running the same query against both stores and diffing the result, not by watching for errors. That harness is a day of work and it wants to exist before the first dashboard moves, not after the first argument about a number.
Keep a scheduled read from the old store for the whole dual-write period. Failure 5.5 is open here for the honest reason: the discipline is easy to describe and it is the first thing that lapses once the migration looks finished. A cron job that reads production from the old store, weekly, is what keeps the rollback real, and it costs nothing.
Decide the sort key with a query log, not a design meeting. The dominant access pattern is an empirical fact sitting in the existing database’s logs. It is routinely guessed at instead, and failure 5.4 is the bill for guessing.