How replication works: parts, replicas, Keeper and mutations
The mental model behind ReplicatedMergeTree: what lives in Keeper, how an insert, a merge and a mutation travel between replicas, and which system tables show each stage.
Replication in ClickHouse looks like magic from the outside: insert on one node, query on another. The mechanism is simple once you see which parts are shared and which are not. This article builds the mental model and maps each piece to the system table where you can see it. The statements need a replicated cluster, so they were parsed with clickhouse format on 26.4 and the columns checked against that version’s system tables; they were not run against a live cluster.
What is and is not shared
A replicated table is many independent tables that agree on a shared log. There is no shared storage. Every replica has its own copy of the data parts on its own disk. What they share is a small amount of coordination state in ClickHouse Keeper (or ZooKeeper; the protocol is the same and Keeper is the recommended server).
CREATE TABLE analytics.events_local ON CLUSTER my_cluster
(
event_time DateTime, user_id UInt64, event_type LowCardinality(String),
url String, duration_ms UInt32, country LowCardinality(String)
)
ENGINE = ReplicatedMergeTree('/clickhouse/tables/{shard}/events_local', '{replica}')
PARTITION BY toYYYYMM(event_time)
ORDER BY (event_type, event_time);
The two engine arguments identify the table in Keeper: the first is the table path, the same on every replica of a shard; the second is the replica name, unique per replica. Both usually come from macros defined in each server’s config. Two replicas with the same table path belong to the same replicated table. Two shards must use different paths, which is what {shard} is for.
Keeper holds, under that table path, roughly:
| Keeper node | Contents |
|---|---|
log |
The shared, ordered log of actions all replicas must perform: new part, merge, mutation, drop |
replicas/<name>/queue |
Each replica’s private to-do list, copied from log |
replicas/<name>/parts |
The names (and checksums) of the parts each replica has |
block_numbers, blocks |
Block number allocation per partition and insert deduplication hashes |
mutations |
Pending and finished mutations |
leader_election |
Who may schedule merges |
You can browse it from SQL with system.zookeeper. The path condition is mandatory, and the table adds load on Keeper, so use it for diagnosis only:
SELECT name, value
FROM system.zookeeper
WHERE path = '/clickhouse/tables/01/events_local';
Keeper never stores your rows. It stores names: part names, log entries and counters. That is why a Keeper ensemble can serve thousands of tables with modest hardware, and why it is the first thing to look at when many tables stall at once.
Life of an INSERT
- The client inserts into replica A. A writes the block to a new part on its own disk, e.g.
202601_5_5_0. The block number comes from Keeper, so it is unique across replicas. - A records the new part under its
replicas/A/partsand adds aGET_PARTentry to the sharedlog. If the insert carries a deduplication hash that Keeper already holds, the insert is recognised as a retry and dropped. - Replica B, watching the log, copies the entry into its own queue.
- B executes the entry by fetching the part from A over HTTP. It does not redo the insert; it downloads the finished bytes and verifies the checksums.
After this, both replicas have a part with the same name and the same content. The name is the identity: replicas exchange names, not rows.
Life of a merge
Merges are decided once and executed everywhere. A replica that holds the right to schedule merges chooses source parts and writes a MERGE_PARTS entry into the log, naming the result part (say 202601_1_5_1). Each replica then executes that entry. If it already has all the source parts it merges them locally, which is cheaper than a download. If the result part already exists on another replica and a local merge would take too long or a source part is missing, it fetches the result instead. Either way, the end state is identical part names on every replica.
In current versions any replica can act as a scheduler, so you may see several replicas with is_leader = 1 at the same time. The setting replicated_can_become_leader = 0 keeps a replica from scheduling merges, but it still executes them.
Life of a mutation
A mutation is first recorded under mutations in Keeper with the block number it applies up to. Each replica then turns it into MUTATE_PART entries in its queue (this is what part_mutations_in_queue counts), one per part it must rewrite. The rewritten part gets a data-version suffix in its name (202601_1_5_1_9). Parts created by later inserts are not touched. A mutation is done on a replica when every relevant part has been rewritten, and is_done in system.mutations reports that for the replica you are asking. For the full story see mutations, lightweight DELETE and TTL.
SELECT mutation_id, command, parts_to_do, latest_fail_reason
FROM system.mutations
WHERE database = 'analytics' AND `table` = 'events_local' AND NOT is_done;
Reading system.replicas
Each Replicated* table on a server has one row in system.replicas: the local replica’s view of the table plus selected Keeper data.
SELECT * FROM system.replicas
WHERE database = 'analytics' AND `table` = 'events_local'
FORMAT Vertical;
Group the columns by question:
Is this replica able to work?
is_readonly: the replica refuses inserts. It is set when Keeper is unreachable, when the table’s metadata in Keeper is missing, or while the session is being re-established.is_session_expired: the Keeper session is gone. Usually transient, persistent if Keeper is down.zookeeper_exception: the last error when reading from Keeper.
What is it still supposed to do?
queue_size,inserts_in_queue,merges_in_queue,part_mutations_in_queue: how many log entries are waiting, split by kind. A queue that stays at zero or drains in seconds is healthy.queue_oldest_time,inserts_oldest_time,merges_oldest_time: when the oldest entry of each kind was queued. Age matters more than count.oldest_part_to_get,oldest_part_to_merge_to,oldest_part_to_mutate_to: the part names the oldest entries are producing.future_parts,parts_to_check: parts that will appear after queued actions, and parts being verified because something looks corrupt.
Is it keeping up with the log?
log_max_indexis the newest entry in the shared log;log_pointeris the next entry this replica will copy to its queue. A gap between them means the replica is not even reading the log.absolute_delayis the replica’s lag in seconds,last_queue_updatewhen it last refreshed its queue.
Who else is there?
total_replicasandactive_replicas: registered replicas versus those holding a live Keeper session.replica_is_active: the same as a map from replica name to 0 or 1, which tells you which replica is down.lost_part_count: parts that were lost on all replicas since table creation; it only ever grows.
Some columns require extra Keeper round trips (log_max_index, log_pointer, total_replicas, active_replicas and friends), so selecting * is slower than naming what you need. A ready-made health check built on these columns is in this snippet.
The queue itself
system.replication_queue lists the pending entries one by one. When queue_size is stuck, look here for the reason:
SELECT database, `table`, replica_name, type, new_part_name, source_replica,
create_time, num_tries, last_exception, num_postponed, postpone_reason
FROM system.replication_queue
WHERE database = 'analytics'
ORDER BY create_time
LIMIT 20;
type tells you what the entry is (GET_PART, MERGE_PARTS, MUTATE_PART, DROP_RANGE and others). last_exception explains a failure (typically a part that cannot be fetched because the source replica no longer has it), and postpone_reason explains why an entry is waiting rather than failing, for example because TTL merges hit their concurrency limit or because a needed part is not there yet.
Parts versus system.parts
system.parts is local: it lists the parts this server has. Comparing replicas is a matter of running the same query on all of them:
SELECT hostName() AS host, `table`, count() AS active_parts, sum(rows) AS rows
FROM clusterAllReplicas('my_cluster', system.parts)
WHERE database = 'analytics' AND `table` = 'events_local' AND active
GROUP BY host, `table`
ORDER BY host;
Matching active_parts and rows for the replicas of one shard means they converged. Row counts can differ transiently while inserts, merges and fetches are in flight. Part names can differ for a while too, since replicas may have merged different sources into different results before settling on the one chosen in the log.
Operations you will need
SYSTEM SYNC REPLICA analytics.events_localblocks until this replica has executed everything currently in its queue. Use it before comparing counts.SYSTEM RESTART REPLICAre-initialises a table’s Keeper session without restarting the server.SYSTEM RESTORE REPLICArebuilds a replica’s Keeper metadata from its local parts when the metadata was lost; the replica must be read-only for this to apply.SYSTEM DROP REPLICA 'name' FROM TABLE ...removes the Keeper metadata of a replica that no longer exists. It refuses to drop the replica you are connected to.detachedparts hold data ClickHouse refused to use; see dropping detached parts.
Failure modes in one table
| Symptom | Likely cause | Look at |
|---|---|---|
| Inserts fail with “table is in readonly mode” | Keeper session lost or metadata missing | is_readonly, zookeeper_exception, Keeper health |
absolute_delay grows, queue_size grows |
Fetches or merges slower than inserts | replication_queue, network, merge threads |
| Queue entry retried forever with “no replica has part” | The part was dropped or lost everywhere | last_exception, lost_part_count |
log_pointer far below log_max_index |
Replica is not reading the log | Keeper latency, last_queue_update_exception |
Replica missing from replica_is_active |
Its server is down or cut off from Keeper | That server’s log |
Reference: data replication in the docs and system.replicas.
Discussion
Comments live in GitHub Discussions. Sign in with GitHub to join in.