大数据核心处理场景 —— 生产落地指南

0 阅读23分钟

适用版本:Flink 1.18+ / Spark 3.5+ / Kafka 3.x / Hive 3.x / ClickHouse 24.x 更新日期:2026-03 聚焦:实时流处理、海量数据倾斜、湖仓一体、精准去重——四大最难场景的根本解法


目录

  1. 核心认知:大数据的本质难题
  2. 实时流处理:Flink 状态管理与精确一次
    • 2.1 流处理三大语义保证
    • 2.2 Checkpoint 机制与 Barrier 对齐
    • 2.3 状态后端选型(HashMapStateBackend vs RocksDB)
    • 2.4 Watermark 与乱序事件处理
    • 2.5 精确一次端到端实战(Kafka → Flink → MySQL)
  3. 数据倾斜:最难调优的性能杀手
    • 3.1 倾斜的根本原因与识别方法
    • 3.2 Spark 五大倾斜解决方案
    • 3.3 Flink KeyBy 倾斜处理
    • 3.4 Hive 大表 Join 倾斜实战
  4. 湖仓一体:数据湖 + 数仓的架构演进
    • 4.1 Lambda / Kappa / 湖仓架构对比
    • 4.2 Iceberg 核心特性与 ACID 实现
    • 4.3 增量读取与 CDC 入湖
    • 4.4 数仓分层设计(ODS/DWD/DWS/ADS)
  5. 海量数据精准去重与 TopN
    • 5.1 精确去重 vs 近似去重的取舍
    • 5.2 HyperLogLog 原理与误差控制
    • 5.3 Bitmap 亿级用户行为统计
    • 5.4 Flink SQL TopN 与流式排行榜
  6. ClickHouse 超高速 OLAP 实战
    • 6.1 MergeTree 家族引擎选型
    • 6.2 向量化执行与列式存储原理
    • 6.3 物化视图加速聚合查询
    • 6.4 生产避坑:不能做什么
  7. 常见生产事故与根本解法

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 OnceExactly 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)

端到端精确一次的三个条件:

  1. Source 可重放(Kafka offset 可回溯)
  2. Flink 内部状态有 Checkpoint
  3. 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 亿用户,每天登录记录

传统方法:7GROUP 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
├── 合并时自动执行聚合函数(summax 等)
└── 适合:预聚合物化视图

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 的数据已全部到达"的信号,触发窗口计算