适用版本:Flink 1.18+ / Spark 3.5+ / Kafka 3.x / Hive 3.x / ClickHouse 24.x 更新日期:2026-03 聚焦:实时流处理、海量数据倾斜、湖仓一体、精准去重——四大最难场景的根本解法
目录
- 核心认知:大数据的本质难题
- 实时流处理:Flink 状态管理与精确一次
- 2.1 流处理三大语义保证
- 2.2 Checkpoint 机制与 Barrier 对齐
- 2.3 状态后端选型(HashMapStateBackend vs RocksDB)
- 2.4 Watermark 与乱序事件处理
- 2.5 精确一次端到端实战(Kafka → Flink → MySQL)
- 数据倾斜:最难调优的性能杀手
- 3.1 倾斜的根本原因与识别方法
- 3.2 Spark 五大倾斜解决方案
- 3.3 Flink KeyBy 倾斜处理
- 3.4 Hive 大表 Join 倾斜实战
- 湖仓一体:数据湖 + 数仓的架构演进
- 4.1 Lambda / Kappa / 湖仓架构对比
- 4.2 Iceberg 核心特性与 ACID 实现
- 4.3 增量读取与 CDC 入湖
- 4.4 数仓分层设计(ODS/DWD/DWS/ADS)
- 海量数据精准去重与 TopN
- 5.1 精确去重 vs 近似去重的取舍
- 5.2 HyperLogLog 原理与误差控制
- 5.3 Bitmap 亿级用户行为统计
- 5.4 Flink SQL TopN 与流式排行榜
- ClickHouse 超高速 OLAP 实战
- 6.1 MergeTree 家族引擎选型
- 6.2 向量化执行与列式存储原理
- 6.3 物化视图加速聚合查询
- 6.4 生产避坑:不能做什么
- 常见生产事故与根本解法
1. 核心认知:大数据的本质难题
三个核心矛盾
数据量 × 实时性 × 精确性 —— 三者不可同时完美
| 取舍方向 | 典型场景 | 代表方案 |
|---|---|---|
| 牺牲实时性,保量和精确 | 离线报表、账单对账 | Hive + Spark Batch |
| 牺牲精确性,保量和实时 | 实时 UV、热点话题 | HyperLogLog + Flink |
| 牺牲数据量,保实时和精确 | 实时风控、库存扣减 | Flink + 精确状态 |
为什么大数据问题这么难?
单机时代: 一台机器,内存放不下就写磁盘,CPU 不够就排队,问题都是局部的。
分布式时代: 数据分布在 100 台机器上,产生了:
- 网络分区:节点间通信可能失败
- 时钟不同步:事件顺序难以确定(乱序问题)
- 数据倾斜:某些 Key 的数据量是其他 Key 的 1000 倍
- 状态一致性:机器宕机后,处理进度如何恢复?
单机程序崩溃 → 重启就好
分布式程序崩溃 → 哪些数据处理了?哪些没处理?会不会重复?
这就是大数据难的根本原因:在规模和速度的压力下,保证正确性。
2. 实时流处理:Flink 状态管理与精确一次
2.1 流处理三大语义保证
At Most Once —— 最多一次,可能丢数据(性能最好)
At Least Once —— 至少一次,可能重复处理(常见)
Exactly Once —— 精确一次,既不丢也不重(最难,但生产必须)
为什么"至少一次"不够用?
场景:统计订单金额
消息:订单 #1001,金额 ¥200
网络抖动 → 消息重传 → 订单 #1001 被处理两次 → 统计金额多了 ¥200
金融、电商场景下,重复计算直接导致资金损失。
2.2 Checkpoint 机制与 Barrier 对齐
Flink 实现精确一次的核心是 分布式快照(Chandy-Lamport 算法)。
Barrier 机制图解
Kafka Source
│
├── 数据流:[e1][e2][e3][BARRIER-1][e4][e5][BARRIER-2]
│
▼
算子 A(Map)
│
├── 收到 BARRIER-1 → 暂停处理 → 保存本地状态快照 → 继续
│
▼
算子 B(KeyBy + Aggregate)
│
├── 收到所有上游的 BARRIER-1 → 保存状态 → 确认 Checkpoint 完成
│
▼
Sink(写 MySQL)
│
└── 收到 BARRIER-1 → 提交事务(两阶段提交)
Barrier 对齐 vs 非对齐 Checkpoint
| Barrier 对齐 | 非对齐 Checkpoint (Unaligned) | |
|---|---|---|
| 实现 | 等所有上游 Barrier 到达后才保存 | Barrier 到达即保存,不等其他上游 |
| 语义 | Exactly Once | Exactly Once |
| 适用场景 | 反压不严重 | 严重反压场景 |
| 状态大小 | 较小 | 较大(包含 in-flight 数据) |
| Flink 版本 | 一直支持 | 1.11+ |
Checkpoint 配置实战
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
// 开启 Checkpoint,间隔 60 秒
env.enableCheckpointing(60_000);
CheckpointConfig config = env.getCheckpointConfig();
// 精确一次语义
config.setCheckpointingMode(CheckpointingMode.EXACTLY_ONCE);
// Checkpoint 超时时间
config.setCheckpointTimeout(120_000);
// 两次 Checkpoint 之间最少间隔(防止 Checkpoint 风暴)
config.setMinPauseBetweenCheckpoints(30_000);
// 最多同时进行几个 Checkpoint
config.setMaxConcurrentCheckpoints(1);
// 作业取消后保留 Checkpoint(方便恢复)
config.setExternalizedCheckpointCleanup(
ExternalizedCheckpointCleanup.RETAIN_ON_CANCELLATION
);
// 使用 RocksDB 状态后端(大状态必选)
env.setStateBackend(new EmbeddedRocksDBStateBackend(true)); // true = 增量 Checkpoint
// 存储路径(生产用 HDFS/S3)
env.getCheckpointConfig().setCheckpointStorage("hdfs://namenode:9000/flink/checkpoints");
2.3 状态后端选型
HashMapStateBackend(内存)
├── 状态存储位置:TaskManager JVM 堆内存
├── 读写速度:极快
├── 缺点:状态大小受堆内存限制,GC 压力大
└── 适合:状态量小(< 1GB),低延迟场景
EmbeddedRocksDBStateBackend(RocksDB)
├── 状态存储位置:TaskManager 本地磁盘(RocksDB)
├── 读写速度:比内存慢 3-10 倍(但支持 SSD 优化)
├── 优点:状态可以远超内存大小,增量 Checkpoint
└── 适合:大状态(几十 GB 甚至 TB 级),生产首选
RocksDB 调优关键参数:
# flink-conf.yaml
state.backend.rocksdb.memory.managed: true # 使用 Flink 管理的内存
state.backend.rocksdb.memory.fixed-per-slot: 512mb # 每个 Slot 的 RocksDB 内存上限
state.backend.rocksdb.block.cache-size: 128mb # Block Cache 大小
state.backend.rocksdb.writebuffer.size: 64mb # Write Buffer 大小
state.backend.rocksdb.compaction.level.use-dynamic-size-bytes: true
2.4 Watermark 与乱序事件处理
为什么会有乱序?
事件发生时间(Event Time):用户 10:00:01 点击了按钮
消息到达时间(Processing Time):Flink 10:00:05 才收到这条消息
网络延迟、消息队列积压、移动端离线上报 → 事件顺序被打乱
Watermark 的本质:
Watermark(t) 表示:时间戳 ≤ t 的所有事件,我已经收到了(或认为不会再来了)
// 有界乱序 Watermark(允许最多 5 秒的乱序)
WatermarkStrategy<OrderEvent> strategy = WatermarkStrategy
.<OrderEvent>forBoundedOutOfOrderness(Duration.ofSeconds(5))
.withTimestampAssigner((event, timestamp) -> event.getEventTime())
.withIdleness(Duration.ofMinutes(1)); // 超过 1 分钟无数据,认为该分区空闲
DataStream<OrderEvent> stream = source.assignTimestampsAndWatermarks(strategy);
// 开窗统计:每分钟的订单量
stream
.keyBy(OrderEvent::getUserId)
.window(TumblingEventTimeWindows.of(Time.minutes(1)))
.allowedLateness(Time.seconds(30)) // 窗口关闭后,还允许 30 秒的迟到数据
.sideOutputLateData(lateOutputTag) // 超过 30 秒的迟到数据,输出到侧输出流
.aggregate(new OrderCountAgg());
处理极端迟到数据的完整链路:
正常数据 → 窗口计算 → 输出结果
迟到 ≤ 30s → 触发窗口重算 → 更新结果(发出修正消息)
迟到 > 30s → 侧输出流 → 人工处理或补偿逻辑
2.5 精确一次端到端实战(Kafka → Flink → MySQL)
端到端精确一次的三个条件:
- Source 可重放(Kafka offset 可回溯)
- Flink 内部状态有 Checkpoint
- Sink 支持幂等写入或两阶段提交
// Kafka Source(自动管理 offset)
KafkaSource<String> kafkaSource = KafkaSource.<String>builder()
.setBootstrapServers("kafka:9092")
.setTopics("orders")
.setGroupId("flink-order-processor")
.setStartingOffsets(OffsetsInitializer.committedOffsets(OffsetResetStrategy.EARLIEST))
.setValueOnlyDeserializer(new SimpleStringSchema())
.build();
// MySQL Sink(两阶段提交,需要 MySQL 支持事务)
SinkFunction<ProcessedOrder> mysqlSink = JdbcSink.exactlyOnceSink(
"INSERT INTO processed_orders (order_id, amount, process_time) VALUES (?, ?, ?) " +
"ON DUPLICATE KEY UPDATE amount = VALUES(amount)",
(stmt, order) -> {
stmt.setString(1, order.getOrderId());
stmt.setBigDecimal(2, order.getAmount());
stmt.setTimestamp(3, new Timestamp(order.getProcessTime()));
},
JdbcExactlyOnceOptions.defaults(),
() -> {
// XA 数据源配置(支持两阶段提交)
MysqlXADataSource xaDataSource = new MysqlXADataSource();
xaDataSource.setUrl("jdbc:mysql://mysql:3306/orders?useXATransaction=true");
xaDataSource.setUser("root");
xaDataSource.setPassword("password");
return xaDataSource;
}
);
3. 数据倾斜:最难调优的性能杀手
3.1 倾斜的根本原因与识别方法
什么是数据倾斜?
理想状态:100 个分区,每个分区 100 万条数据,各处理 1 秒 → 总耗时 1 秒
倾斜状态:100 个分区,其中 1 个分区有 9000 万条,其余 99 个各 10 万条
→ 99 个分区 0.1 秒完成,1 个分区需要 90 秒 → 总耗时 90 秒
常见原因:
| 原因 | 举例 |
|---|---|
| Key 分布不均 | 按省份分组,广东用户是西藏的 100 倍 |
| 热点 Key | 某个大 V 的行为数据占全量 30% |
| Null 值聚集 | 大量用户没有填城市,NULL Key 全去同一分区 |
| 数据放大 | 大表 Join 小表,小表有重复 Key |
Spark UI 识别方法:
Stages 页面 → 找到耗时最长的 Stage → 查看 Tasks 分布
如果某个 Task 的数据量是平均值的 5 倍以上,就是倾斜
Flink 识别方法:
Flink Web UI → 找到 Processing 最慢的 SubTask
查看 Records In/Out 的差异
BackPressure 显示 HIGH 的算子通常是倾斜点
3.2 Spark 五大倾斜解决方案
方案一:过滤空值或异常 Key
// 最简单,但治标不治本
val filtered = df.filter(col("user_id").isNotNull && col("user_id") =!= "NULL")
方案二:加盐打散(最通用)
// 问题:按 city 聚合,"北京" Key 有 5000 万条
// 解法:给 Key 加随机前缀,分散到多个分区,最后再合并
val saltedDf = df.withColumn(
"salted_city",
concat(col("city"), lit("_"), (rand() * 100).cast("int")) // 加 0-99 的随机盐
)
val partialResult = saltedDf
.groupBy("salted_city")
.agg(sum("amount").as("partial_sum"), count("*").as("partial_count"))
// 去掉盐,做第二次聚合
val finalResult = partialResult
.withColumn("city", regexp_replace(col("salted_city"), "_\\d+$", ""))
.groupBy("city")
.agg(sum("partial_sum").as("total_amount"), sum("partial_count").as("total_count"))
方案三:广播 Join(大表 Join 小表)
// 小表(< 200MB)广播到每个 Executor,避免 Shuffle
import org.apache.spark.sql.functions.broadcast
val result = bigTable.join(
broadcast(smallTable), // 强制广播
bigTable("city_id") === smallTable("id"),
"left"
)
// Spark 配置:自动广播阈值
spark.conf.set("spark.sql.autoBroadcastJoinThreshold", "200mb")
方案四:热点 Key 单独处理
// 统计各 Key 频次,找出热点 Key
val hotKeys = df.groupBy("city").count()
.filter(col("count") > 1_000_000)
.select("city").collect().map(_.getString(0)).toSet
val hotKeysBroadcast = spark.sparkContext.broadcast(hotKeys)
// 热点数据和非热点数据分开处理
val (hotDf, normalDf) = (
df.filter(col("city").isin(hotKeys.toSeq: _*)),
df.filter(!col("city").isin(hotKeys.toSeq: _*))
)
val hotResult = hotDf
.withColumn("salted_city", concat(col("city"), lit("_"), (rand() * 200).cast("int")))
.groupBy("salted_city")
.agg(sum("amount"))
// ... 去盐合并
val normalResult = normalDf.groupBy("city").agg(sum("amount"))
hotResult.union(normalResult)
方案五:Spark AQE(自适应查询执行)
// Spark 3.0+ 推荐开启,自动处理 Join 倾斜
spark.conf.set("spark.sql.adaptive.enabled", "true")
spark.conf.set("spark.sql.adaptive.skewJoin.enabled", "true")
spark.conf.set("spark.sql.adaptive.skewJoin.skewedPartitionFactor", "5") // 某分区是中位数的 5 倍认为倾斜
spark.conf.set("spark.sql.adaptive.skewJoin.skewedPartitionThresholdInBytes", "256mb")
spark.conf.set("spark.sql.adaptive.coalescePartitions.enabled", "true") // 自动合并小分区
3.3 Flink KeyBy 倾斜处理
// 热点 Key 两阶段聚合(Local + Global)
public class TwoPhaseAggregation {
// 第一阶段:Local 预聚合(打散到多个子任务)
public DataStream<Tuple2<String, Long>> localAgg(DataStream<Order> input) {
return input
.map(order -> {
// 给热点 Key 加盐
String saltedKey = isHotKey(order.getCityId())
? order.getCityId() + "_" + (int)(Math.random() * 128)
: order.getCityId();
return Tuple2.of(saltedKey, order.getAmount());
})
.keyBy(t -> t.f0)
.timeWindow(Time.seconds(10))
.reduce((a, b) -> Tuple2.of(a.f0, a.f1 + b.f1));
}
// 第二阶段:Global 最终聚合(去盐合并)
public DataStream<Tuple2<String, Long>> globalAgg(DataStream<Tuple2<String, Long>> localResult) {
return localResult
.map(t -> Tuple2.of(t.f0.replaceAll("_\\d+$", ""), t.f1))
.keyBy(t -> t.f0)
.timeWindow(Time.seconds(10))
.reduce((a, b) -> Tuple2.of(a.f0, a.f1 + b.f1));
}
}
3.4 Hive 大表 Join 倾斜实战
-- 方法一:MapJoin 提示(小表 < 256MB)
SELECT /*+ MAPJOIN(b) */
a.user_id,
a.amount,
b.city_name
FROM orders a
JOIN city_dict b ON a.city_id = b.id;
-- 方法二:倾斜 Join 优化(Hive 配置)
SET hive.optimize.skewjoin=true;
SET hive.skewjoin.key=100000; -- 某 Key 超过 10 万条认为倾斜
-- 方法三:两步 Join
-- 第一步:对热点 Key 加盐处理
CREATE TEMPORARY TABLE orders_salted AS
SELECT
user_id,
CASE WHEN city_id IN ('北京', '上海', '广州')
THEN CONCAT(city_id, '_', CAST(FLOOR(RAND() * 100) AS STRING))
ELSE city_id
END AS salted_city_id,
amount
FROM orders;
-- 第二步:Join 时对小表也做相应扩展(replicate)
CREATE TEMPORARY TABLE city_dict_expanded AS
SELECT
CONCAT(id, '_', seq) AS salted_id,
city_name
FROM city_dict
LATERAL VIEW posexplode(split(space(99), ' ')) pe AS seq, dummy
WHERE id IN ('北京', '上海', '广州')
UNION ALL
SELECT id, city_name FROM city_dict WHERE id NOT IN ('北京', '上海', '广州');
4. 湖仓一体:数据湖 + 数仓的架构演进
4.1 三大架构对比
Lambda 架构(2011年提出,正在被淘汰)
┌─────────────────────────────────────────────┐
│ Kafka → Spark Streaming → 实时层(HBase) │
│ Kafka → HDFS → Hive批处理 → 离线层 │
│ 查询时:合并实时层 + 离线层结果 │
└─────────────────────────────────────────────┘
问题:两套代码逻辑,维护成本极高,结果容易不一致
Kappa 架构(解决 Lambda 双写问题)
┌─────────────────────────────────────────────┐
│ Kafka(保存全量历史)→ Flink → 实时存储 │
│ 历史重算:修改代码 → 重放 Kafka → 覆盖结果 │
└─────────────────────────────────────────────┘
问题:Kafka 存全量数据成本高,重算耗时,存储层性能有限
湖仓一体(Data Lakehouse,当前主流)
┌─────────────────────────────────────────────┐
│ 底层:数据湖(S3/HDFS,低成本存储) │
│ 格式:Iceberg/Delta Lake(ACID + 列式) │
│ 上层:直接用 SQL 做批流统一查询 │
│ 既有数据湖的低成本,又有数仓的查询性能 │
└─────────────────────────────────────────────┘
4.2 Iceberg 核心特性与 ACID 实现
Iceberg 解决了什么问题?
Hive 的痛点:
- 无 ACID:多写并发导致数据损坏
- 全量扫描:分区列以外无法过滤,小文件问题严重
- 无 Schema 演进:加列操作风险极高
- 无时间旅行:数据误删无法恢复
Iceberg 全解决了以上问题
Iceberg 文件结构:
s3://data-lake/warehouse/db/orders/
├── metadata/
│ ├── v1.metadata.json ← 快照 1 的元数据
│ ├── v2.metadata.json ← 快照 2 的元数据(当前)
│ └── snap-xxx.avro ← 清单列表文件
├── data/
│ ├── 00000-0-xxx.parquet ← 数据文件
│ └── 00001-0-xxx.parquet
ACID 实现原理:
写操作:
1. 写新的 Parquet 数据文件(不覆盖旧文件)
2. 写新的清单文件,指向新数据文件
3. 原子性地更新 metadata 指针(CAS 操作)
→ 成功:新快照生效
→ 失败:旧快照仍然有效,回滚零代价
读操作:
- 始终读取某个快照版本
- 不受并发写入影响(快照隔离)
时间旅行查询:
-- Spark SQL 查询历史快照
SELECT * FROM db.orders TIMESTAMP AS OF '2026-01-01 00:00:00';
SELECT * FROM db.orders VERSION AS OF 12345; -- 快照 ID
-- 查询增量数据(CDC 场景)
SELECT * FROM db.orders
CHANGES BETWEEN SNAPSHOT 12345 AND 12360;
4.3 增量读取与 CDC 入湖
Flink CDC + Iceberg 实时入湖架构:
// Flink CDC 读取 MySQL binlog
MySqlSource<String> mySqlSource = MySqlSource.<String>builder()
.hostname("mysql-host")
.port(3306)
.databaseList("orders_db")
.tableList("orders_db.orders")
.username("root")
.password("password")
.deserializer(new JsonDebeziumDeserializationSchema())
.startupOptions(StartupOptions.initial()) // 全量 + 增量
.build();
// 写入 Iceberg(自动处理 INSERT/UPDATE/DELETE)
TableLoader tableLoader = TableLoader.fromHadoopTable("hdfs://namenode/warehouse/orders");
FlinkSink.forRowData(stream)
.tableLoader(tableLoader)
.upsert(true) // 开启 upsert 模式(UPDATE/DELETE 语义)
.equalityFieldColumns(Arrays.asList("order_id")) // 主键列
.build();
4.4 数仓分层设计
ODS(操作数据层):原始数据,不做任何转换,保留 30 天
│ 命名:ods_orders_di(di = daily input)
│ 来源:Kafka 消费 + CDC 入湖
│
▼
DWD(数据明细层):清洗、标准化、关联维表
│ 命名:dwd_order_detail_di
│ 操作:去重、空值处理、字段标准化、维表 Join
│
▼
DWS(数据服务层):按主题聚合,轻度汇总
│ 命名:dws_user_order_1d(1d = 按天聚合)
│ 操作:按用户/商品/地域聚合指标
│
▼
ADS(应用数据层):面向具体业务的结果表
命名:ads_user_order_repurchase_rate
操作:复购率、漏斗转化等复杂指标
服务:直接对接 API、报表工具
为什么要分层?
不分层的痛点:
- 每个报表都从原始数据算,重复计算,浪费资源
- 逻辑散落在各处,一处改动影响全局
- 数据质量问题追溯困难
分层的价值:
- 复用:DWS 的聚合结果被多个 ADS 共用
- 隔离:ODS 变化只影响 DWD,不影响 ADS
- 追溯:数据问题可以逐层排查
5. 海量数据精准去重与 TopN
5.1 精确去重 vs 近似去重的取舍
精确去重(DISTINCT COUNT)
├── 方法:HashSet、位图(Bitmap)
├── 精确度:100%
├── 资源消耗:高(亿级数据需要几 GB 内存)
└── 适合:账单对账、用户 ID 精确统计
近似去重(HyperLogLog)
├── 方法:概率算法
├── 精确度:99.27%(标准误差 0.81%)
├── 资源消耗:极低(无论多少数据,只需 12KB)
└── 适合:UV 统计、实时大屏、误差可接受场景
5.2 HyperLogLog 原理与误差控制
核心思想(极简版):
观察:随机数据中,连续出现 k 个 0 的概率约为 1/2^k
推论:如果我见过最长的前导 0 序列长度为 k,那么数据集大约有 2^k 个不同元素
HLL 做的更精确:
1. 把所有元素哈希为 64 位整数
2. 用前 b 位决定分配到哪个桶(2^b 个桶)
3. 每个桶记录后续位的最大前导 0 个数
4. 用调和平均数合并所有桶的估计值
Redis HyperLogLog 实战:
// Redis HLL 操作(误差 0.81%,只占 12KB)
Jedis jedis = new Jedis("localhost");
// 记录 UV
jedis.pfadd("page_uv:2026-03-12", userId);
// 查询 UV(近似值)
long approxUV = jedis.pfcount("page_uv:2026-03-12");
// 合并多天 UV(7 日去重 UV)
jedis.pfmerge(
"page_uv:7days", // 目标 key
"page_uv:2026-03-06", "page_uv:2026-03-07",
"page_uv:2026-03-08", "page_uv:2026-03-09",
"page_uv:2026-03-10", "page_uv:2026-03-11",
"page_uv:2026-03-12"
);
long weeklyUV = jedis.pfcount("page_uv:7days");
Flink SQL 中使用 HLL(ClickHouse / Doris):
-- ClickHouse:精确去重(慢但精确)
SELECT uniq(user_id) FROM events WHERE date = today();
-- ClickHouse:近似去重(快 10 倍)
SELECT uniqHLL12(user_id) FROM events WHERE date = today();
-- Doris HLL 预聚合(物化视图加速)
CREATE MATERIALIZED VIEW mv_daily_uv AS
SELECT
date_trunc('day', event_time) AS stat_date,
page_id,
hll_union(hll_hash(user_id)) AS uv_hll
FROM events
GROUP BY 1, 2;
-- 查询时使用物化视图
SELECT stat_date, page_id, hll_cardinality(hll_union_agg(uv_hll)) AS approx_uv
FROM mv_daily_uv
GROUP BY 1, 2;
5.3 Bitmap 亿级用户行为统计
适用场景:用户 ID 是整数且连续分布
需求:统计"连续登录 7 天"的用户数
数据:1 亿用户,每天登录记录
传统方法:7 次 GROUP BY + JOIN,耗时分钟级
Bitmap 方法:7 个 Bitmap AND 操作,耗时毫秒级
// RoaringBitmap:压缩位图,亿级 ID 只需几 MB
import org.roaringbitmap.RoaringBitmap;
// 每天构建登录用户 Bitmap
RoaringBitmap day1 = new RoaringBitmap();
RoaringBitmap day2 = new RoaringBitmap();
// ... 从 Redis/HBase 加载
// 连续 7 天登录:AND 操作
RoaringBitmap consecutive7Days = RoaringBitmap.and(day1, day2);
for (RoaringBitmap dayBitmap : Arrays.asList(day3, day4, day5, day6, day7)) {
consecutive7Days.and(dayBitmap);
}
// 统计人数
long count = consecutive7Days.getLongCardinality();
// 序列化存储(可存 Redis/HBase)
byte[] bytes = consecutive7Days.serialize();
Redis Bitmap 实现签到统计:
# 用户 1001 在 2026-03-12 签到
SETBIT sign:1001:202603 11 1 # 第 11 位代表 12 号(0-indexed)
# 统计本月签到天数
BITCOUNT sign:1001:202603
# 查找本月第一次签到日期
BITPOS sign:1001:202603 1 # 找第一个 1 的位置
# 统计 2026-03-12 全站签到人数(需要每用户一个 key 反转设计)
# 设计:sign:20260312 每一位对应一个用户 ID
SETBIT sign:20260312 1001 1
BITCOUNT sign:20260312 # 全站当天签到人数
5.4 Flink SQL TopN 与流式排行榜
实时热搜榜需求:每 10 分钟更新一次,实时 Top100 搜索词
-- Flink SQL 实现流式 TopN
-- 第一步:按搜索词聚合计数
CREATE VIEW search_count AS
SELECT
search_word,
COUNT(*) AS cnt,
TUMBLE_START(rowtime, INTERVAL '10' MINUTE) AS window_start
FROM search_events
GROUP BY
search_word,
TUMBLE(rowtime, INTERVAL '10' MINUTE);
-- 第二步:用 ROW_NUMBER 取 Top100
SELECT
window_start,
search_word,
cnt,
rk
FROM (
SELECT
window_start,
search_word,
cnt,
ROW_NUMBER() OVER (
PARTITION BY window_start
ORDER BY cnt DESC
) AS rk
FROM search_count
)
WHERE rk <= 100;
注意:流式 TopN 的更新机制
问题:数据是持续流入的,排名会不断变化
Flink 解决方案:
1. 维护一个 minHeap(最小堆),大小为 N
2. 新数据进来:如果比堆顶大,替换堆顶
3. 每次窗口触发:输出整个堆
4. Retract 模式:支持更新消息(+I 插入,-U/-D 撤回旧值)
下游 Redis ZSet 实时更新排行榜:
ZADD hot_search_rank 9876 "周杰伦"
ZREVRANGE hot_search_rank 0 99 WITHSCORES # 取 Top100
6. ClickHouse 超高速 OLAP 实战
6.1 MergeTree 家族引擎选型
MergeTree(基础引擎)
├── 按主键排序存储,支持范围查询
├── 后台异步合并文件(解决小文件问题)
└── 适合:大多数 OLAP 场景
ReplacingMergeTree
├── 同主键数据合并时,保留最新版本
├── 注意:合并是异步的!查询时需要 FINAL 关键字
└── 适合:有更新需求的维表(用户信息表)
AggregatingMergeTree
├── 合并时自动执行聚合函数(sum、max 等)
└── 适合:预聚合物化视图
SummingMergeTree
├── 合并时自动对数值列求和
└── 适合:计数器、累加类指标
CollapsingMergeTree / VersionedCollapsingMergeTree
├── 通过 sign 列(+1/-1)实现数据修改和删除
└── 适合:CDC 数据写入
-- 生产建表示例(订单事实表)
CREATE TABLE orders (
order_id UInt64,
user_id UInt64,
city_id UInt32,
amount Decimal(18, 2),
status Enum8('pending'=1, 'paid'=2, 'cancelled'=3),
created_at DateTime,
-- 分区键:按月分区(不能太细,每个分区至少有 1GB 数据)
dt Date
) ENGINE = MergeTree()
PARTITION BY toYYYYMM(dt) -- 按月分区
ORDER BY (city_id, user_id, order_id) -- 主键(排序键),高基数列放后面
SETTINGS
index_granularity = 8192, -- 默认每 8192 行一个索引标记
min_bytes_for_wide_part = 104857600; -- 100MB 以下用 Compact 格式
-- ReplacingMergeTree 查询时去重(异步合并未完成时需要 FINAL)
SELECT * FROM user_profile FINAL WHERE user_id = 1001;
-- 或者用 GROUP BY + argMax 代替 FINAL(性能更好)
SELECT
user_id,
argMax(username, updated_at) AS username,
argMax(city, updated_at) AS city
FROM user_profile
GROUP BY user_id;
6.2 向量化执行与列式存储原理
为什么 ClickHouse 比 MySQL 快 1000 倍?
MySQL(行式存储)查询 SELECT avg(amount) FROM orders WHERE city = '北京':
读取路径:遍历每一行 → 读取整行(包含不需要的列)→ 提取 amount 列
IO 放大:假设每行 200 字节,amount 列 8 字节,放大 25 倍
ClickHouse(列式存储)同样查询:
读取路径:只读取 city 列找到符合条件的行号 → 只读取 amount 列
IO 节省:只读需要的列,压缩比高(同类型数据压缩效率更高)
压缩效果:amount 列(Decimal),1 亿行约 800MB,压缩后约 80MB
向量化执行(SIMD 指令):
传统执行:for (row : rows) { result += row.amount; } // 一次处理 1 行
向量化执行:用 CPU SIMD 指令(AVX-512)一次处理 16 个 float64
等效于:一条 CPU 指令完成 16 次加法
性能提升:在聚合密集计算场景提升 4-16 倍
6.3 物化视图加速聚合查询
-- 原始查询(每次都全表扫描)
SELECT
toStartOfHour(created_at) AS hour,
city_id,
count() AS order_cnt,
sum(amount) AS total_amount
FROM orders
WHERE created_at >= now() - INTERVAL 7 DAY
GROUP BY 1, 2;
-- 创建聚合物化视图(写入时自动维护)
CREATE MATERIALIZED VIEW mv_orders_hourly_agg
ENGINE = AggregatingMergeTree()
PARTITION BY toYYYYMMDD(hour)
ORDER BY (hour, city_id)
AS SELECT
toStartOfHour(created_at) AS hour,
city_id,
countState() AS order_cnt_state, -- 使用 State 函数
sumState(amount) AS amount_state
FROM orders
GROUP BY 1, 2;
-- 查询物化视图(毫秒级响应)
SELECT
hour,
city_id,
countMerge(order_cnt_state) AS order_cnt, -- 使用 Merge 函数
sumMerge(amount_state) AS total_amount
FROM mv_orders_hourly_agg
WHERE hour >= now() - INTERVAL 7 DAY
GROUP BY 1, 2
ORDER BY 1, 2;
6.4 生产避坑:不能做什么
❌ 高并发点查(SELECT * WHERE id = ?)
ClickHouse 不是 OLTP 数据库,主键查询也需要扫描 8192 行
解决:点查走 MySQL/Redis,ClickHouse 只做分析
❌ 频繁的 UPDATE/DELETE
MergeTree 不支持真正的行级更新,ALTER UPDATE 是异步重写文件
解决:用 CollapsingMergeTree 或接受最终一致性
❌ 小批量频繁写入(每秒写几条)
每次 INSERT 都创建新文件,产生大量小文件,后台合并压力极大
解决:批量写入(每批至少 1 万行),或通过 Kafka 缓冲
❌ JOIN 大表(两个超大表互 JOIN)
ClickHouse 的 JOIN 实现:右表全量加载到内存(Broadcast Join)
解决:提前做宽表(在写入时 JOIN),或用 Spark 做好再写入 CH
❌ 太细的分区(按小时、按分钟分区)
分区过多:元数据压力大,查询需要打开太多分区文件
解决:按月或按天分区,结合主键排序做数据跳跃索引
7. 常见生产事故与根本解法
事故一:Flink 作业 OOM,Checkpoint 超时
现象: Flink 作业运行 2 天后,TaskManager 频繁 OOM,Checkpoint 越来越慢最终超时,作业重启后又陷入 OOM 循环。
根本原因:
状态无限增长:
- 窗口状态没有设置 TTL,历史数据永远不清理
- RocksDB 配置不当,Write Buffer 积累,后台 Compaction 跟不上写入速度
解法:
// 1. 为所有状态设置 TTL
StateTtlConfig ttlConfig = StateTtlConfig.newBuilder(Time.days(7))
.setUpdateType(StateTtlConfig.UpdateType.OnCreateAndWrite)
.setStateVisibility(StateTtlConfig.StateVisibility.NeverReturnExpired)
.cleanupInBackground() // 后台自动清理过期状态
.build();
ValueStateDescriptor<UserSession> stateDesc = new ValueStateDescriptor<>("session", UserSession.class);
stateDesc.enableTimeToLive(ttlConfig);
// 2. 调整 RocksDB 参数
// flink-conf.yaml
// state.backend.rocksdb.compaction.level.max-size-level-base: 256mb
// state.backend.rocksdb.writebuffer.count: 4
// state.backend.rocksdb.thread.num: 4
事故二:Kafka 消费严重积压,无法追上
现象: 突发流量导致 Kafka 消费 lag 达到 1 亿,正常消费速度无法追上。
根本原因:
消费能力 < 生产速度
下游处理(数据库写入)成为瓶颈,背压传导到 Kafka 消费
解法:
1. 临时扩容:增加 Flink 并行度(需要 Kafka 分区数 ≥ Flink 并行度)
2. 下游瓶颈优化:
- 数据库写入改为批量写入(100-1000 条一批)
- 改用异步 IO(AsyncDataStream),不阻塞主线程
3. 限制追数据期间的处理逻辑:
- 追数据时跳过非关键路径(如发送通知)
- 追上后再补发
4. 预防:提前做容量规划,Kafka 消费 lag > 50 万触发告警
事故三:数据倾斜导致单个 Task 跑 8 小时
现象: Spark 作业 99 个 Task 10 分钟跑完,最后 1 个 Task 跑了 8 小时,Key 是 NULL。
根本原因:
SELECT city, count(*) FROM orders GROUP BY city
表中 10% 的订单没有填写城市,city 为 NULL
NULL 被 hash 到同一个分区
解法:
-- 方法一:过滤 NULL(如果业务允许)
SELECT city, count(*) FROM orders WHERE city IS NOT NULL GROUP BY city;
-- 方法二:将 NULL 替换为随机值(如果需要统计 NULL)
SELECT
COALESCE(city, CONCAT('__NULL__', CAST(RAND() * 100 AS INT))) AS city,
count(*)
FROM orders
GROUP BY 1;
-- 然后在结果中合并所有 __NULL__ 开头的行
事故四:ClickHouse 写入导致大量小文件,查询急剧变慢
现象: ClickHouse 接入实时数据后,每秒写入几百条,1 天后查询从 100ms 变成 10 秒。
根本原因:
频繁写入 → 大量 Part 文件(小文件)→ 后台合并压力巨大
查询时需要扫描的 Part 数量从 10 个变成 10000 个
解法:
短期:手动触发合并
OPTIMIZE TABLE orders FINAL; -- 强制合并(生产慎用,会锁表阻塞写入)
长期:改造写入架构
Kafka → Flink 缓冲(批量积累 1 万条)→ ClickHouse
或
使用 ClickHouse 官方 Buffer 引擎作为缓冲层
-- Buffer 引擎(写入缓冲,达到条件后自动 flush 到目标表)
CREATE TABLE orders_buffer AS orders
ENGINE = Buffer(
default, -- 数据库
orders, -- 目标表
8, -- 分片数
10, 60, -- flush 时间:最少 10 秒,最多 60 秒
10000, 1000000, -- flush 行数:最少 1 万行,最多 100 万行
1000000, 100000000 -- flush 字节:最少 1MB,最多 100MB
);
-- 应用写入 Buffer 表,由 Buffer 引擎自动管理 flush
INSERT INTO orders_buffer VALUES (...);
事故五:流处理结果不一致——同一指标批流结果相差 5%
现象: 每天 UV 指标,流式计算是 800 万,离线批计算是 840 万,相差 5%。
根本原因:
流式计算(Flink):
- 用 HyperLogLog 做近似去重(本身有 ~1% 误差)
- Watermark 设置为 5 秒,超过 5 秒的迟到数据丢弃
离线批计算(Spark):
- 用精确 COUNT DISTINCT
- 处理全量数据,包括所有迟到数据
解法:
1. 流式计算也改为精确去重(如果数据量不太大)
使用 Flink 的 State 存储当天的用户 ID Set
2. 统一算法:批流都用 HyperLogLog,误差一致
3. 增大 Watermark 容忍时间:从 5 秒改为 30 分钟
代价:实时性降低 30 分钟
4. 批流对账机制:每天自动比对,差异超阈值告警并用离线结果覆盖
核心知识点总结
┌────────────────────────────────────────────────────────┐
│ 大数据核心挑战与解法 │
├──────────────┬─────────────────────────────────────────┤
│ 实时性与准确 │ Watermark 容忍乱序 │
│ │ Exactly Once 三条件 │
│ │ Checkpoint + 两阶段提交 │
├──────────────┼─────────────────────────────────────────┤
│ 数据倾斜 │ 加盐打散热点 Key │
│ │ 本地聚合 + 全局聚合 │
│ │ Spark AQE 自适应 │
│ │ 广播 Join 小表 │
├──────────────┼─────────────────────────────────────────┤
│ 去重与统计 │ 精确:RoaringBitmap(整数 ID) │
│ │ 近似:HyperLogLog(12KB 搞定亿级 UV) │
│ │ 行为分析:Redis Bitmap 签到统计 │
├──────────────┼─────────────────────────────────────────┤
│ 存储选型 │ 实时写 + OLAP 查:ClickHouse │
│ │ 湖仓统一:Iceberg + Flink CDC │
│ │ 大状态流处理:RocksDB StateBackend │
├──────────────┼─────────────────────────────────────────┤
│ 性能调优 │ 批量写入(万条级别) │
│ │ 列式存储 + 物化视图预聚合 │
│ │ 状态设置 TTL 防 OOM │
│ │ 合理分区(月级 > 天级 > 小时级) │
└──────────────┴─────────────────────────────────────────┘
面试高频问题速查
- Flink 如何保证精确一次? → Checkpoint + Source 可重放 + Sink 幂等/两阶段提交
- 数据倾斜怎么解决? → 找到热点 Key → 加盐打散 → 两阶段聚合 → AQE
- HyperLogLog 为什么只需要 12KB? → 概率算法,用桶内最长前导零估计基数
- Iceberg 如何实现 ACID? → 不可变文件 + 原子性快照指针切换(CAS)
- ClickHouse 为什么快? → 列式存储(只读需要的列)+ 向量化 SIMD 执行
- Watermark 是什么? → 告诉系统"时间戳 ≤ t 的数据已全部到达"的信号,触发窗口计算