Data Skew
Stream processing often encounters data skew. A common solution salts keys to redistribute records more evenly, then computes metrics through two-stage aggregation.
Translated from Chinese with AI · Read the original
Data Skew
Data skew is common in stream processing. A typical solution salts keys to spread data across more streams, then aggregates in two stages. Consider this real-time user-count query:
select city,count(distinct userId) from table group by cityIf a city has too many users, the data becomes skewed. We typically redistribute it using a salt with 1024 possible values:
select city,sum(e1)from (select city, userId % 1024, count(distinct userId) e1 from table group by city, userId % 1024) tblgroup by cityThat handles skew in userId. What if we also need distinct product-SKU counts and both userId and SKU are skewed? The SQL would require several more layers.
How to Optimize
Blink adds SplitAggregateRule to Calcite’s rules, supporting this scenario more abstractly. Here we consider only that rule, ignoring other optimizations such as converting Count Distinct into Group and Count.
How does the rule optimize userId skew?
select city,sum(e1)from (select city, userId % 1024, count(distinct userId) e1 from table group by city, userId % 1024) tblgroup by cityThe corresponding logical plan is:
FlinkLogicalAggregate(group=[{0}], agg#0=[$SUM0($2)])+- FlinkLogicalAggregate(group=[{0, 2}], agg#0=[COUNT(DISTINCT $1)]) +- FlinkLogicalCalc(select=[city, userId, MOD(HASH_CODE(userId), 1024) AS $f2]) +- FlinkLogicalCalc(select=[city, userId]) +- FlinkLogicalNativeTableScan(table=[[builtin, default, _DataStreamTable_0]])It adds MOD(HASH_CODE(userId), 1024) as a salt and computes count distinct in two stages. If both SKU and userId are skewed, two additional fields are needed:
- salt UserId: to redistribute UserId
- salt SKU: to redistribute SKU
+---------+--------+-----+-------------+----------+| City | UserId | SKU | Salt-UserId | Salt-SKU |+---------+--------+-----+-------------+----------+| Beijing | 1 | 1 | 1 | 1 || Jinan | 2 | 3 | 2 | 3 |+---------+--------+-----+-------------+----------+After grouping by (City, Salt-UserId, Salt-SKU), we cannot derive both Count(Distinct UserId) and Count(Distinct SKU) in the second stage. To solve this, trade space for time by expanding one row into multiple rows. This is not a Blink-specific optimization, but I include it for clarity. Add a Group field:
+---------+--------+-----+-------------+----------+-------+| City | UserId | SKU | Salt-UserId | Salt-SKU | Group |+---------+--------+-----+-------------+----------+-------+| Beijing | 1 | 1 | 1 | null | 1 || Beijing | 1 | 1 | null | 1 | 2 || Jinan | 2 | 3 | 2 | null | 1 || Jinan | 2 | 3 | null | 3 | 2 |+---------+--------+-----+-------------+----------+-------+Group identifies the aggregation: 1 aggregates Salt-UserId only, while 2 aggregates Salt-SKU only.
After grouping by (City, Salt-UserId, Salt-SKU), filter Group=1 and group by City to obtain Count(Distinct UserId). Filter Group=2 and group by City to obtain Count(Distinct SKU).
The final logical plan looks like this:
FlinkLogicalAggregate(group=[{0}], agg#0=[$SUM0($3)], agg#1=[$SUM0($4)])+- FlinkLogicalAggregate(group=[{0, 3, 4}], agg#0=[COUNT(DISTINCT $1) FILTER $5], agg#1=[COUNT(DISTINCT $2) FILTER $6]) +- FlinkLogicalCalc(select=[city, userId, sku, $f3, $f4, =($e, 1) AS $g_1, =($e, 2) AS $g_2]) +- FlinkLogicalExpand(projects=[{city=[$0], userId=[$1], sku=[$2], $f3=[$3], $f4=[null], $e=[1]}, {city=[$0], userId=[$1], sku=[$2], $f3=[null], $f4=[$4], $e=[2]}]) +- FlinkLogicalCalc(select=[city, userId, sku, MOD(HASH_CODE(userId), 1024) AS $f3, MOD(HASH_CODE(sku), 1024) AS $f4]) +- FlinkLogicalNativeTableScan(table=[[builtin, default, _DataStreamTable_0]])FlinkLogicalExpand expresses the expansion of one row into multiple rows:
{city=[$0], userId=[$1], sku=[$2], $f3=[$3], $f4=[null], $e=[1]}{city=[$0], userId=[$1], sku=[$2], $f3=[null], $f4=[$4], $e=[2]}The first three fields of these two rows are identical; e=[2] means Group=2.
Some Thoughts
This is useful only in certain scenarios. For small datasets it is unnecessary, especially because streaming data volumes can be orders of magnitude below batch volumes. Studying Blink’s rule also led me to explore Spark and Calcite’s Count Distinct implementations. This seemingly simple operator ranges from obvious map-based solutions to more advanced bitmaps and operator transformations, each with its strengths. I learned quite a lot.