Tiering Service Deep Dive Part 2: Tuning

Part 1 built the mental model. What tiering is, who does what, how the round runs end-to-end.
This part adds the dials. Buckets and splits determine how a round parallelizes. Log and PK tables behave so differently on round one that the difference deserves its own treatment. The freshness setting, the one knob most users actually touch, does two different jobs that share the same value. Once a single job is handling many tables, queue position starts to dominate effective freshness more than any per-table setting. And once that happens, you have a deployment-shape decision: stay with one job, or scale out. By the end, you'll know which levers matter most and how to use them.
Tiering Service Deep Dive, 3-parts:
- Part 1 - The Mental Model: how one tiering round actually works, from timer fire to lake commit.
- Part 2 - Tuning: per-table dials, multi-table dynamics, and scaling out.
- Part 3 - In Production: failure modes, design pitfalls, and monitoring.
Buckets, Splits, And How The Work Gets Divided
In Part 1, we had a table with 4 buckets that ended up with 4 splits. That's not accidental: the rule is one split per bucket. Let's slow down and look at the parallelism story end-to-end, because this is where bucket count actually starts to matter.
The mental model is straightforward: buckets are how the data is physically partitioned, splits are the chunks of work the tiering job hands out, and readers are the workers that consume splits. The number of buckets is fixed at table creation time. The number of readers is your Flink job parallelism. The number of splits per round equals the number of buckets.

What happens when the number of readers doesn't match the number of buckets? Two cases:
More readers than buckets. A table with 4 buckets and 16 readers means only 4 readers can work on that table. Bucket count caps how many readers a single table's round can use. The other 12 don't necessarily sit idle, though: if other tables are waiting in the queue, the enumerator hands those spare readers the next table's splits, and they start reading ahead (we'll see exactly how in the multi-table section). They only truly idle when there's no other work queued.
Fewer readers than buckets. A table with 16 buckets and 4 readers means each reader will process 4 splits sequentially. Reader 0 takes one split, finishes it, asks for the next, takes another, and so on. The whole round takes roughly 4× longer than it would with 16 readers. This is the common production case, and it's fine, just slower.
One more thing: the splits are shuffled (randomized) before being assigned. This is a small but important detail. Without it, reader 0 would always get bucket 0, reader 1 would always get bucket 1, and so on. If bucket 0 is consistently the heaviest (because of a bad key distribution), reader 0 would consistently be the bottleneck. Shuffling spreads the bad luck around: over many rounds, every reader gets stuck with the heavy bucket about equally often.
The Slow-Bucket Effect
In the normal commit path, the commit operator waits for every split of a table before committing. If 3 readers finish quickly and the 4th is still processing a much larger bucket, the table's commit can't happen until that last bucket lands. The 3 fast readers move on to other queued tables (or idle, if there's nothing else queued), but this table's round isn't done until its slowest bucket is. The lake commit itself is always atomic. Readers can't see a half-written snapshot, and most of the time every bucket of a round lands in the same commit.
The end result: the slowest bucket determines how long a round takes. Skewed bucket keys can hurt you here. (There's one exception: when freshness fires and the round force-finishes, whatever each bucket has tiered so far gets committed, and the unread remainder rolls to the next round. We'll cover this in the freshness section.)
Two Kinds of Tables: Log vs. Primary-Key Tables
Log Tables: The Simple Case
A log table is exactly what it sounds like: an append-only stream. Records arrive, they get assigned an offset, and they sit there. You can't update or delete a record; you can only append. Examples: a clickstream, an event bus, an audit log.
Tiering a log table is straightforward. Every round, the Flink job reads "everything since the last commit" (just the new offsets) and writes those records as Parquet files in the lake. The lake table grows monotonically. The work per round is proportional to how much you wrote since the last round.
Primary-Key Tables: The Trickier Case
A primary-key (PK) table is like a row-keyed materialized view. Each record has a primary key and the latest value for that key replaces any previous value. Internally, Fluss stores PK tables in two parts: a changelog (a log of all the upserts and deletes that have happened) and a KV state (the current value for each key, derived from the changelog). It's similar to how a database has a write-ahead log plus the actual table.
The changelog records every event using four operation codes you'll see throughout the Flink and Fluss world: +I for an insert, -U and +U together for an update (the old row going out, the new row coming in), and -D for a delete.
Apply all of those to an empty state in order and you get the current row for every key; that's the KV state. Here's a small example to make this concrete:

Now think about what "tier this to the lake" means for a PK table. The lake needs the current state of every row, not just the log of what changed. So the very first round of a PK table has to copy the entire KV state to the lake. That can be huge: think 200 GB if you have a big customer-profile table. After that first round, subsequent rounds only need the changelog records since the last snapshot offset, because the lake's merge engine knows how to apply upserts and deletes to existing rows.

The numbers here are illustrative. A 200 GB PK table might take 10+ minutes to read on its first round (because all 200 GB must be copied). The second round, where only the upserts and deletes from the last few minutes need to be applied, might take 30 seconds. This is an important difference, and the single most important thing to understand about PK tiering: the first round is in a different category from every round that follows.
The Summary Table
| Aspect | Log Table | Primary-Key Table |
|---|---|---|
| Round 1 reads | Whatever offsets exist so far | Full KV state, up to 100s of GB |
| Round 2+ reads | New offsets since round 1 | Changelog since round 1's snapshot offset |
| What the lake stores | Append-only Parquet | Snapshot rows + applied upserts/deletes |
| Cost stability | Roughly constant per round | Round 1 huge, rest tiny |
| Partial progress useful? | Yes: committed buckets advance the lake; the rest roll to the next round | Round 1 snapshot: no (force-finish is ignored for snapshot splits, so they run to completion rather than committing partially). Round 2+: yes |
The Freshness Knob
Every table that's enabled for tiering has a setting called table.datalake.freshness.
It's a duration, something like 5min or 30s, and the default is 3 minutes.
This single number is the most common source of confusion in the tiering service, because it does two different jobs that share the same value.