数据开发实时项目问题整理

Flink 通过 checkpoint 保存状态,通过 JobManager HA 保存任务元数据,故障后可以根据 checkpoint 恢复任务状态和 Kafka offset,从而继续处理。

Flink Cluster 是 JobManager + 一个或多个 TaskManager 共同组成的运行整体。

Checkpoint /Savepoint

Checkpoint 是 Flink 为"出事故"准备的自动存档;Savepoint 是人为"准备做操作"时创建的手动存档。

Checkpoint Savepoint
谁触发 Flink 自动周期触发 通常人为触发
主要目的 故障恢复 升级、迁移、维护
频率 很频繁 需要时才做
生命周期 系统管理为主 用户管理为主
类比 游戏自动存档 手动存档
典型情况 TaskManager 突然挂了 我要升级 Job

State:

当前正在计算的状态

Checkpoint:

把这些状态定期做可靠快照

运行中的 State

Shanghai count=200

GMV=50000

Kafka offset=8230

复制代码

Checkpoint

复制代码

可靠存储

TaskManager:故障

然后:

Checkpoint

恢复 State

恢复消费进度

继续运行

Flink 是一个分布式流处理计算引擎,主要用于对持续产生的数据流进行实时计算,也支持批处理。

Kafka 是消息/事件流平台,Flink 是计算引擎,两者职责不同。

JobManager 和 TaskManager

JobManager

控制层:

接收 Job;

调度任务;

协调资源;

管理 Checkpoint;

处理故障恢复。

TaskManager

执行层:

向 JobManager 注册;

提供 Slot;

真正执行 Subtask;

处理数据和维护运行时 State。

一句话:

JobManager 决定谁干什么,TaskManager 真正干活。

Job 到底怎么执行?

Client

│ submit

JobManager

│ 生成执行计划

Tasks

│ 根据 parallelism

Subtasks

│ 调度

TaskManager Slots

执行

Job、Task、Subtask、Slot是什么关系?

你可以说:

Job 是完整计算任务;Job 会形成多个执行 Task;Task 根据并行度产生多个 Subtask;Subtask 最终被调度到 TaskManager 提供的 Slot 上运行。

Parallelism

Parallelism(并行度)= 一个算子同时有多少个 Subtask 在执行。

高频问题:并行度越大越好吗?

不是。

因为:

CPU/内存有限;

Slot有限;

网络 Shuffle 增加;

数据量可能根本不需要;

Source 并行度还可能受到 Kafka Partition 数限制。

关于对算子的理解:

算子(Operator)= 你 SQL/代码里的一段"计算逻辑"。

比如:

-- 这段 SQL 里隐含了 3 个算子:

Kafka Source -- 算子 1:读数据

TUMBLE + GROUP BY -- 算子 2:窗口聚合

Print Sink -- 算子 3:写日志

每个算子就是"我要对数据做什么"的一段逻辑描述。

Window

因为 Kafka 数据是一直进行的,没有结束的点。

所以人为把无限的数据流切成有限范围:

每一个窗口单独统计。

三种高频 Window

Tumbling Window:不重叠。

Sliding Window

例如:

窗口长度5分钟,每1分钟滑一次:会重叠。

Session Window

例如用户30分钟无操作,就认为一个 Session 结束。

Processing Time 和 Event Time

Processing Time

Flink真正处理这条数据时,机器上的时间。

Event Time

事件本身真正发生的时间。

真实业务一般更关心 Event Time。

Watermark

乱序和迟到数据。

Watermark 可以先通俗理解成:

Flink 对"事件时间已经推进到哪里"的判断。

例如 Watermark 到:

10:05

大概是在告诉系统:

根据当前策略,我认为更早时间的数据基本已经到齐,可以推动相应窗口计算了。

Watermark 是 Flink 在事件时间模式下,用来告诉下游"某个时间点之前的数据已经到齐了,可以关窗口计算结果了"的一种特殊时间标记。它解决了"流数据乱序到达时,什么时候该触发窗口计算"的问题。

State

Flink需要记住:

计算过程中产生的中间状态。

这就是:State。

Checkpoint 和 Exactly Once

Flink怎么容错?:Checkpoint

Checkpoint保存什么?

State + 与 Source 消费进度等一致性相关的信息。

那怎么保证 Exactly Once?

Checkpoint Barrier

State Snapshot

Kafka Offset

Transactional / idempotent Sink

Barrier 是 Flink 在数据流中插入的一种特殊标记,用于划分 Checkpoint 前后的数据。Barrier 会随着数据从 Source 向下游传播。对于多输入算子,在 aligned checkpoint 下需要等待同一个 Checkpoint 的 Barrier 从所有输入到达,从而形成一个一致的逻辑快照边界。算子在这个边界保存 State,Source 的消费位置等也作为 Checkpoint 的一部分进行协调,因此故障恢复时可以恢复到同一个一致状态。

Barrier 怎么保证 Exactly Once?

Barrier 本身不是单独保证 Exactly Once,而是帮助 Checkpoint 获得一致性快照。故障后 Flink 回滚到最近成功的 Checkpoint,并从对应 Source 位置重新处理数据。虽然部分数据物理上可能再次执行,但之前未进入成功 Checkpoint 的状态会被丢弃,因此对 Flink 管理的状态只产生一次有效影响。要实现端到端 Exactly Once,还需要 Sink 支持事务、两阶段提交或幂等写入等机制。

幂等(Idempotent)

**幂等(Idempotent)**最简单的意思就是:

同一个操作执行一次和执行很多次,最终结果一样。

可以用:

业务唯一键

UPSERT / 覆盖写

实现幂等

来抵抗重复处理。

通过唯一业务 ID 去重。

幂等是一个性质:

同一个操作重复执行,最终结果不变。

幂等写入是指同一条数据或同一个操作重复执行多次,最终结果与执行一次相同。在 Flink 故障恢复过程中,Checkpoint 之后的数据可能被重新处理,因此 Sink 可能收到重复写入。实际可以利用业务唯一键、主键约束、UPSERT 或 request_id 去重等方式实现幂等,从而避免重复处理影响最终结果。

事务 / Two-Phase Commit

我尽量从机制上保证,只有和 Checkpoint 一致的那一次写入最终提交。协调提交,避免不一致的写入真正生效。

Doris

Doris到底是什么?

Doris 是一个特别擅长存储和快速查询大量分析数据的数据库。

大量数据 → 扫描 → 过滤 → 分组 → 聚合 → 统计

就是 Doris 的主场。

OLTP:Online Transaction Processing在线事务处理。

MySQL更偏:

INSERT 一条订单

UPDATE 一个订单

SELECT 一个用户

根据主键查订单

Doris则偏:

OLAP:Online Analytical Processing

在线分析处理。

Kafka:事件运输 + 缓冲 + 持久化日志

Doris:分析数据存储 + SQL查询

这才让 Flink 的计算结果真正变成可以被下游使用的数据资产。

Doris为什么查询这么快?

sql 复制代码
Doris
│
├── 列式存储
│
├── MPP 分布式计算
│
├── 分区 Partition
│
├── 分桶 Bucket
│
├── 向量化执行
│
├── 查询优化器
│
└── 针对分析场景的数据模型
MPP

Massively Parallel Processing

一份很大的查询,拆给很多机器一起算。

FE 和 BE
复制代码
          Doris Cluster

             FE
       Frontend
            │
    ┌───────┼───────┐
    ↓       ↓       ↓
   BE1     BE2     BE3
 Backend Backend Backend

FE更偏:

管理 + SQL大脑。

FE = 管理和规划。

sql 复制代码
收到SQL
 ↓
解析SQL
 ↓
生成查询计划
 ↓
优化查询
 ↓
决定怎么执行
 ↓
协调BE

此外还有元数据管理、集群管理等。

BE是真正:BE = 存储和执行。

存数据 + 干计算。

sql 复制代码
8030 → FE HTTP
8040 → BE HTTP
9030 → MySQL协议 / SQL连接

fenodes = doris:8030

benodes = doris:8040

简单理解:

Flink Connector首先:

Flink

FE

需要知道 Doris 集群、表、导入相关信息。

真正的数据导入过程中又涉及:

Flink

BE

所以 Connector需要能够找到对应的 BE。

Stream Load

Stream Load 是 Doris 提供的一种通过 HTTP 把数据导入表中的方式。Stream Load ≈ 一种"把数据送进Doris"的导入通道。Flink Doris Connector利用它把结果送进去。

Doris、Hive、ClickHouse、StarRocks是什么关系?

它们都跟分析有关,但定位有差异。

sql 复制代码
Hive
→ 传统离线数仓 / SQL on Hadoop
→ 大规模离线分析


Doris
→ 实时分析型数据库
→ SQL + 高并发低延迟分析


ClickHouse
→ 高性能列式OLAP
→ 分析查询非常强


StarRocks
→ MPP OLAP数据库
→ 与Doris历史上有渊源
→ 实时分析

为什么用了 Doris 而不是 Hive?

我的场景是 Kafka + Flink 的实时链路,需要将实时聚合结果持续写入一个能够提供低延迟 SQL 查询的 OLAP 系统。Hive更偏离线数仓场景,而 Doris 更适合作为实时分析结果的查询服务层。

doris知识树

sql 复制代码
Doris
│
├── 为什么存在
│   ├── OLTP vs OLAP
│   ├── MySQL vs Doris
│   ├── Kafka vs Doris
│   └── Flink vs Doris
│
├── 架构
│   ├── FE
│   ├── BE
│   └── MPP
│
├── 存储
│   ├── 列式存储
│   ├── Partition
│   └── Bucket
│
├── 数据模型
│   ├── Duplicate Key
│   ├── Unique Key ← 你的项目
│   └── Aggregate Key
│
├── 数据导入
│   ├── Stream Load ← 你的项目
│   └── Flink Doris Connector
│
└── 可靠性
    ├── Replication
    ├── Checkpoint
    ├── Sink提交
    └── Exactly Once / 幂等
    

Partition

sql 复制代码
Hive Partition
      和
Doris Partition

核心思想:

按照某个维度
把大数据集粗粒度切开

        ↓

查询时尽可能少扫描数据

Bucket

Partition内部继续切

sql 复制代码
DISTRIBUTED BY HASH(user_id) BUCKETS 4

根据 user_id 的 Hash 结果,把这个 Partition 里的数据分成4份。

于是数据被比较均匀地打散。

为什么需要 Bucket?

Partition主要考虑:

查询的时候我能不能少扫描?

Bucket还特别重要的一点是:

怎么把数据分散到不同 BE 上,从而并行存储、并行计算?

Tablet

Bucket 是"划分规则/逻辑概念";Tablet 是 Doris 实际管理数据的基本数据分片单位。

sql 复制代码
Table
 ↓
Partition
 ↓
Bucket
 ↓
Tablet

到了 Tablet,我们就越来越接近:

真正的数据到底在哪。

一张 Doris 表经过 Partition 和 Bucket 划分形成多个 Tablet,这些 Tablet 分布在各个 BE 上。

假设:

sql 复制代码
Partition p202608
+
BUCKETS 4
ruby 复制代码
Partition p202608

Bucket 0 → Tablet A
Bucket 1 → Tablet B
Bucket 2 → Tablet C
Bucket 3 → Tablet D

orders

sql 复制代码
Partition p202608

├── Tablet A
├── Tablet B
├── Tablet C
└── Tablet D
sql 复制代码
BE1
├── Tablet A
└── Tablet D

BE2
└── Tablet B

BE3
└── Tablet C

Replica(副本)

BE2挂了怎么办?

Replica:同一个 Tablet 保存多份

sql 复制代码
              Tablet A
                 │
       ┌─────────┼─────────┐
       ↓         ↓         ↓
 Replica A1  Replica A2  Replica A3
       ↓         ↓         ↓
      BE1       BE2       BE3

数据不会因为一台机器挂掉就彻底消失。

所以:

Tablet 是逻辑数据分片;Replica 是这个 Tablet 的物理副本。

sql 复制代码
TABLE
orders
  │
  ↓
PARTITION
p202608
  │
  ↓
BUCKET
Bucket 0
  │
  ↓
TABLET
Tablet A
  │
  ├─────────┬─────────┐
  ↓         ↓         ↓
Replica   Replica   Replica
  ↓         ↓         ↓
 BE1       BE2       BE3
sql 复制代码
Table → Partition → Bucket → Tablet → Replica → BE

不是 Doris 表必须同时有 Partition 和 Bucket。

Partition是否设计,要根据数据量、查询模式、生命周期管理等决定。

Doris 分区分桶和 Hive 一样吗?

核心思想类似,但底层实现和作用范围不同。

Hive 和 Doris 的分区都是对数据进行粗粒度划分,从而实现分区裁剪、减少查询扫描范围;分桶也都可以基于 Hash 将分区内部数据进一步拆分。但 Hive 的分区分桶更多体现在 HDFS/对象存储上的目录和文件组织,而 Doris 是自身管理存储的分布式 OLAP 数据库,分桶之后会形成 Tablet,Tablet 的 Replica 会实际分布到不同 BE 节点,因此 Doris 的分桶还直接关系到数据分布、并行查询和集群负载。

关于Bucket 和 Tablet的理解

Bucket 是"怎么切数据"的逻辑规则/编号;Tablet 是"切完以后 Doris 实际管理的数据分片"。

区别在于它们描述的是不同层面的概念。

Bucket

= 数据应该按照什么规则被划到哪一份

Tablet

= Doris真正管理的那一份数据分片

同一个 Partition 内,一个 Bucket 对应一个 Tablet。

大体上可以理解为:Tablet 数量 = 各 Partition 的 Bucket 数量之和。

Tablet 数量 ≈ Partition数量 × Bucket数量

Bucket 回答"数据按规则应该被分到第几份";Tablet 是 Doris 为这份数据实际创建和管理的逻辑数据分片。

常见问题

分区怎么选:通常按时间字段,比如 dt、event_date、window_start。主要目的是分区裁剪、方便冷热数据和生命周期管理。

分桶字段怎么选:通常选分布较均匀、经常参与 join / group by / 查询过滤的字段,避免某些 bucket 特别大导致数据倾斜。

Bucket 数不是越多越好:太少会并行度不够,太多会产生大量 Tablet,增加元数据和调度开销。

Replica 的意义:不是为了并行,而是为了高可用和容错;一个 Tablet 可以有多个 Replica,放在不同 BE。

BE 扩容后发生什么:Doris 会通过数据均衡把 Tablet 副本重新分布到更多 BE,让存储和计算压力更均匀。

Partition 和 Bucket 的区别必须会说:Partition 是粗粒度数据切分,主要用于减少扫描范围和数据管理;Bucket 是 Partition 内进一步分布数据,直接影响 Tablet 分布和并行能力。

数据倾斜:如果你 HASH(city),但某个 city 占 90%,就可能导致 bucket 不均衡。真实项目里要注意分桶键的基数和分布。

三种数据模型:Duplicate Key / Unique Key / Aggregate Key

模型 核心思想
Duplicate Key 来什么存什么,不主动合并
Unique Key 同一个 Key,只保留一个逻辑上的最新结果
Aggregate Key 同一个 Key,把 Value 按规则聚合

Duplicate Key:原样保存

允许同 Key 的数据存在,不因为 Key 相同就做更新覆盖或聚合。

非常适合:

明细事实数据。"我要保留完整明细。"

Unique Key:同一个业务对象,只要一个最新状态

但假设我们的业务需求是:

我只想知道订单101现在是什么状态。

所以我们只需要最新的状态。

Unique Key 最核心的概念:UPSERT

sql 复制代码
INSERT
+
UPDATE
≈
UPSERT

根据 Key 更新一条业务记录的最新状态。

Aggregate Key:同一个 Key,直接帮我聚合

ruby 复制代码
AGGREGATE KEY(city)

为什么已经有 SQL GROUP BY,还需要 Aggregate Key?

那为什么 Aggregate Key 还要提前聚合?

因为数据量可能巨大。

如果数据能够提前按 Key 聚合,查询需要处理的数据量就可能显著减少。

其实这就是"明细表、状态表、聚合表"的区别。

sql 复制代码
数据模型:
Duplicate / Unique / Aggregate
回答:
相同Key的数据来了怎么办?

关于key的一些常见面试问题

复制代码
"Doris 有哪些数据模型?"

常见有 Duplicate Key、Unique Key 和 Aggregate Key。Duplicate Key 适合保留完整明细;Unique Key 适合根据业务 Key 保存最新状态或 Upsert;Aggregate Key 可以让相同 Key 的 Value 按指定聚合方式合并。

"Unique Key 和 Aggregate Key 有什么区别?"

Unique Key 表示相同 Key 对应同一条逻辑记录,新数据用于更新该记录;Aggregate Key 则表示相同 Key 的数据需要按照 SUM、MAX 等聚合规则进行合并。

"你的项目为什么用了 Unique Key?"

Flink 已经完成了一分钟窗口按城市的聚合,因此 Doris 接收到的是每个窗口、每个城市的一条最终聚合结果。window_start + window_end + city 可以唯一确定这个逻辑结果,所以使用 Unique Key 支持结果更新和重放时的覆盖语义,比再次使用 Aggregate Key 更符合业务模型。

"为什么不用 Duplicate Key?"

因为同一个窗口和城市如果因为任务恢复或写入重试再次产生结果,我不希望 Doris 中出现多条逻辑重复记录,否则后续再次 SUM 时可能造成重复统计。

Hot Data / Cold Data

bash 复制代码
🔥 热数据 Hot Data
最近的数据
访问频繁
可能还会发生更新
对查询速度要求高

🧊 冷数据 Cold Data
很久以前的数据
访问频率低
基本不会修改
主要为了历史留存
中间有时候还会叫 Warm Data(温数据),所以完整一点是:
热数据 → 温数据 → 冷数据
bash 复制代码
                 Partition
                     │
        ┌────────────┼────────────┐
        ↓            ↓            ↓
      查询优化       数据管理       生命周期管理
        │            │            │
   分区裁剪少扫描   大表按时间拆分   冷热分层/过期删除
为什么事实表经常按照日期分区?

大型事实表通常具有明显的时间属性,按日期分区后,一方面查询可以通过分区裁剪减少扫描范围;另一方面也方便按照时间管理数据生命周期,比如删除过期数据、历史归档以及冷热数据分层。

docker 常用命令

查看服务的状态

bash 复制代码
docker compose ps --all
bash 复制代码
Exited (0)

Exited (0) 表示正常退出。

bash 复制代码
NAME                                      IMAGE                           COMMAND                   SERVICE                CREATED        STATUS                      PORTS
mini-data-platform-doris                  apache/doris:all-in-one-4.1.3   "/usr/bin/tini -- /o..."   doris                  43 hours ago   Exited (0) 42 hours ago     
mini-data-platform-doris-connector-init   curlimages/curl:8.16.0          "/entrypoint.sh -fL ..."   doris-connector-init   43 hours ago   Exited (0) 43 hours ago     
mini-data-platform-doris-init             mysql:8.4.11                    "/bin/sh -c 'mysql -..."   doris-init             43 hours ago   Exited (0) 43 hours ago     
mini-data-platform-flink-connector-init   curlimages/curl:8.16.0          "/entrypoint.sh -fL ..."   flink-connector-init   3 days ago     Exited (0) 43 hours ago     
mini-data-platform-flink-job-submit       flink:2.2.1-java17              "/docker-entrypoint...."   flink-job-submit       43 hours ago   Exited (0) 43 hours ago     
mini-data-platform-flink-jobmanager       flink:2.2.1-java17              "/docker-entrypoint...."   jobmanager             3 days ago     Exited (143) 42 hours ago   
mini-data-platform-flink-taskmanager      flink:2.2.1-java17              "/docker-entrypoint...."   taskmanager            3 days ago     Exited (143) 42 hours ago   
mini-data-platform-kafka                  apache/kafka:4.3.1              "/__cacert_entrypoin..."   kafka                  3 days ago     Exited (143) 42 hours ago   
mini-data-platform-kafka-init             apache/kafka:4.3.1              "/bin/sh -c '/opt/ka..."   kafka-init             3 days ago     Exited (0) 43 hours ago     
mini-data-platform-mysql                  mysql:8.4.11                    "docker-entrypoint.s..."   mysql                  3 days ago     Exited (0) 42 hours ago   

这里的 143 通常是进程收到 SIGTERM 后退出,对你这种通过 docker compose stop 停服务的场景来说很常见,可以理解成:

Docker 正常通知这些长期进程"该下班了"。

重新启动已有服务

bash 复制代码
docker compose start

关掉服务 (非删除)

bash 复制代码
docker compose stop

查看 flink job

bash 复制代码
docker compose exec jobmanager /opt/flink/bin/flink list -m jobmanager:8081

如果job挂了就再次提交

bash 复制代码
docker compose up --force-recreate flink-job-submit

提交相关命令

查看修改其情况-检查当前工作区,它只查看,不修改任何东西。

我的项目相对于上一次 commit,现在发生了什么变化?

bash 复制代码
git status
bash 复制代码
modified: docker-compose.yml

这个文件 Git 以前已经认识,但你在上次 commit 之后修改过它。

bash 复制代码
Untracked files:
doris/init.sql

这是一个新文件,Git 目前还没有开始追踪它。

basbash

git add .

git status

复制代码
执行 `git add .` 后,再运行 `git status` 确认一下暂存区的状态,确保所有预期的修改都已正确加入。us

git add .把修改放进"待提交区"

把当前目录及其子目录下的修改加入暂存区,准备参加下一次 commit。

这里的 . 就是"当前目录"。

Git大概可以理解成三个区域:

bash 复制代码
工作区
Working Directory

你正在修改的文件
       │
       │ git add
       ↓

暂存区
Staging Area

"我准备把这些修改提交"
       │
       │ git commit
       ↓

Git Repository

正式的版本历史

为什么在add之后还要再检查一次状态?

执行:

bash 复制代码
git add .

以后:

bash 复制代码
git status

原本:

bash 复制代码
Changes not staged for commit

应该变成:

bash 复制代码
Changes to be committed
bash 复制代码
git commit -m "Comment(备注)"

正式创建一个版本节点

把暂存区里的内容保存成一个 Git commit。

-m 就是:message,也就是给这个 commit 写一句说明。

bash 复制代码
git status
git log --oneline -5

在提交之后再次检查状态,如果没有再次修改文件的话,就会出现:

bash 复制代码
nothing to commit, working tree clean

working tree clean意思是

bash 复制代码
你现在电脑里的项目
        =
最近一次commit记录的状态
没有尚未提交的修改。

git log --oneline -5 ------ 查看最近的提交历史
--oneline 每个 commit 只显示一行。
commit hash , 可以理解成这个 commit 的唯一 ID。 
bash 复制代码
git diff
-- 我到底具体改了什么?
-- 如果只想看某个文件的修改情况
git diff -- docker-compose.yml
bash 复制代码
git diff --stat
-- 它不把具体每一行都展示出来,只给你一个统计摘要: 

例如:

bash 复制代码
README.md                  | 57 +++++---
docker-compose.yml         | 76 +++++++++
docs/progress.md           | 64 ++++++++
flink/order_aggregation.sql| 30 +++++

总结:

bash 复制代码
Git 日常提交流程
===============================

1. git status
   查看当前项目状态
   → 哪些文件 modified / untracked

2. git diff --stat
   查看本次修改的大致规模

3. git diff
   查看具体修改内容

4. git add .
   把当前修改加入暂存区(Staging Area)

5. git status
   再次确认下一次 commit 会包含哪些文件

6. git commit -m "说明"
   创建一次正式 Git 提交/版本节点

7. git status
   确认:
   working tree clean

8. git log --oneline -5
   简洁查看最近5次 commit

Redis

一个以内存为核心、通过 Key 非常快速地取 Value 的数据存储系统。

这里把它当作实时最新指标的 Serving Layer。

bash 复制代码
Key                     Value
name        →           M
age         →           18
city        →           Beijing

Redis 经常用于:

bash 复制代码
缓存
Session
排行榜
计数器
验证码
限流
实时状态
最新指标

为什么redis快?

Redis 的主要数据工作集放在内存 RAM 里。

bash 复制代码
传统数据库

请求
 ↓
数据库
 ↓
可能涉及磁盘 / Buffer Pool / 查询执行
 ↓
结果


Redis

请求
 ↓
内存中的 Key
 ↓
Value

内存访问本身非常快。

并且Redis 的操作通常很简单,例如:

bash 复制代码
GET key
HGET key field
SET key value
HSET key field value
bash 复制代码
Doris:
帮我从很多数据里"算出答案"。

Redis:
答案已经准备好了,你直接"把答案给我"。
bash 复制代码
                 Flink计算结果
                      │
             ┌────────┴────────┐
             ↓                 ↓
           Doris             Redis
             │                 │
          历史都要           只要最新
             │                 │
          分析查询           快速读取
             │                 │
       OLAP / BI           API / 实时页面

Flink 已经把答案算好了;Doris负责把历史答案留下来供分析,Redis负责把"最新答案"放在一个极快的位置供应用直接读取。

Connector = Flink 与外部系统之间的"适配器"。

redis 服务

bash 复制代码
docker compose exec redis redis-cli PING

其实发生了三层事情。

第一层,docker compose exec redis 的意思是:进入名为 redis 的容器,在里面执行后面的命令。

redis-server 是 Redis 本体。

redis-cli 只是 Redis 的客户端工具。

bash 复制代码
redis-cli
   │
   │ PING
   ↓
Redis Server
   │
   │ PONG
   ↓
redis-cli

因此 PONG 只能证明:

Redis Server 活着,而且客户端能正常连接并获得响应。

Redis Server 本身到底在干什么?

Redis 可以先理解成一台一直运行的:

内存 Key-Value 数据服务器。

Redis Server 是一个长期运行的内存数据服务,通过 Key 快速定位数据;客户端通过 Redis 协议发送 GET/HGET/HSET 等命令。项目中 Redis 用 Hash 保存每个城市最新的一分钟指标,Worker 用 HSET 不断覆盖 latest 值。数据主要在内存中提供高速访问,同时使用 AOF + Docker Volume 做持久化恢复。

那你项目为什么用 Hash?

因为你一个城市的 latest 指标不是一个值,而是一组字段:

Beijing 最新一分钟:

window_start

window_end

order_cnt

gmv

所以:

bash 复制代码
city:Beijing:latest -- 是一个key

-- 对应的 Value 使用 Redis Hash: 


city:Beijing:latest
│
├── window_start → 2026-08-27 13:06:00
├── window_end   → 2026-08-27 13:07:00
├── order_cnt    → 9
└── gmv          → 3294.89

于是当执行:

bash 复制代码
redis-cli HGETALL city:Beijing:latest

其实是在说:

Redis,把 city:Beijing:latest 这个 Hash 里的所有 field/value 都给我。

Redis返回:

bash 复制代码
window_start
2026-08-27 13:06:00
window_end
2026-08-27 13:07:00
order_cnt
9
gmv
3294.89

HSET 在干什么?

当收到一条新的记录的时候,会去更新这个最新的值。

为什么 Redis 会这么快?

最关键的一个原因就是:

核心数据主要在内存中。

Redis 最常见的 5 种基础数据类型

类型 你可以把它想成 典型用途
String 一个值 缓存、验证码、计数器
Hash 一个小对象 用户信息、你项目的城市指标
List 有顺序的队列 消息列表、最近记录
Set 不重复集合 标签、去重、共同好友
Sorted Set 带分数的有序集合 排行榜
最关键的是理解:
Redis 永远是 Key → Value,区别只是 Value 可以是什么数据结构。
1.String:最简单的 Key → Value
bash 复制代码
Key              Value
username    →    May

写入:

bash 复制代码
SET username May

读取:

bash 复制代码
GET username

返回:

bash 复制代码
May

String 还能直接做计数器

假设:

SET page_views 100

可以:

INCR page_views

结果:

101

再:

INCR page_views

变:

102

所以 Redis 特别适合计数:

文章浏览量

点赞数

API调用次数

库存计数(需要正确设计并发语义)

因为 Redis 可以提供原子性的计数操作。

2. Hash
bash 复制代码
city:Beijing:latest
│
├── window_start
├── window_end
├── order_cnt
└── gmv
3. List:有顺序,而且允许重复

List 可以想成:

一个有顺序的数组/队列。

bash 复制代码
notifications:May

[
  "订单已发货",
  "收到新消息",
  "优惠券到账"
]

可以从左边放:

bash 复制代码
LPUSH notifications:May "订单已发货"

也可以从右边放:

bash 复制代码
RPUSH notifications:May "收到新消息"

概念上:

bash 复制代码
LPUSH                         RPUSH
  ↓                             ↓
[new] [A] [B] [C]             [new]

读取范围:

bash 复制代码
LRANGE notifications:May 0 -1
bash 复制代码
Redis List
→ 简单轻量的队列需求

Kafka
→ 专业分布式事件流平台
4. Set
bash 复制代码
skills:1001
{
  Java,
  Python,
  SQL
}

这个时候就不能再往里面加入pyhthon/java/sql了,因为数据元素不可以重复

添加元素:

bash 复制代码
SADD skills:1001 Java Python SQL

查看:

bash 复制代码
SMEMBERS skills:1001

判断有没有:

bash 复制代码
SISMEMBER skills:1001 Python
set集合运算

假设:

bash 复制代码
May关注的人:

follow:May
{
 A,
 B,
 C
}


Alice关注的人:

follow:Alice
{
 B,
 C,
 D
}

求交集:

bash 复制代码
May ∩ Alice

=
{
 B,
 C
}

Redis可以直接做类似:

bash 复制代码
SINTER follow:May follow:Alice

所以可以拿来解决:

共同关注 / 共同好友。

还可以:

bash 复制代码
并集 UNION
差集 DIFFERENCE
交集 INTERSECTION
5.Sorted Set: Set + 排名

游戏排行榜

bash 复制代码
May      9800
Alice    7500
Bob      6200

每个人有一个score

Redis Sorted Set(ZSet)就是:

bash 复制代码
member       score
May          9800
Alice        7500
Bob          6200

然后 Redis 会根据 score 维持排序。

bash 复制代码
ZADD game:ranking 9800 May
ZADD game:ranking 7500 Alice
ZADD game:ranking 6200 Bob

取排行榜:

bash 复制代码
ZREVRANGE game:ranking 0 9 WITHSCORES

就可以得到 Top 10。

bash 复制代码
Hash
→ 描述一个对象
Sorted Set
→ 描述一群对象的排名

真实业务里两者经常一起用:

bash 复制代码
city:Beijing:latest
        ↓
      Hash
保存北京详细信息


city:gmv:ranking
        ↓
    Sorted Set
保存城市GMV排名

查询排行榜:

bash 复制代码
Sorted Set
↓
发现 Beijing 第一
↓
Hash
↓
读取 Beijing 详细指标
bash 复制代码
                         Redis Value

                ┌────────────┼────────────┐
                │            │            │
              单值          对象          集合
                │            │             │
             String         Hash     ┌─────┼─────┐
                                     │     │     │
                                   List   Set   ZSet
                                     │     │     │
                                   有序   去重   去重+排序

Redis 的 Key 如何设计

开发者习惯使用:

bash 复制代码
业务:对象:ID:属性

来组织 Key。

比如:

bash 复制代码
user:1001:profile
user:1001:session

product:888:stock

city:Beijing:latest

order:20260828:count

可以理解成Key 命名空间约定。

redis 属于NoSQL 数据库/数据存储。

bash 复制代码
Redis
 ↓
Key
 ↓
Value / Data Structure

数据组织和访问模型不同。

redis 里面的DB是什么?

bash 复制代码
redis-cli DBSIZE

DBSIZE:当前 Redis logical database 里有多少个 Key。

cleanup.policy=compact

Kafka 有一种机制:

Log Compaction

也就是:cleanup.policy=compact

Compaction 是什么意思?对于相同 Key,Kafka最终可以只保留较新的 Value。

compact 不是"新消息来了立刻删除旧消息"。

Kafka 的 Log Compaction 是后台异步执行的。

在某个时刻可能会保存一个 key的多个值,

之后 Kafka Cleaner 慢慢做 compaction,最终倾向于保留:

compact 保证的是一种长期的"最新状态保留语义",不是实时去重。

这里我是用城市gmv聚合作为例子说明:为什么 Key 必须是 city?

因为 Kafka 怎么知道:

Beijing 13:01

和:

Beijing 13:02

属于同一个东西?

Kafka Message Key。

Flink Sink:

bash 复制代码
connector = upsert-kafka
key = city

概念上产生:

bash 复制代码
Kafka Record

key = Beijing

value = {
    window_start: ...,
    window_end: ...,
    order_cnt: 9,
    gmv: 3294.89
}

下一分钟:

bash 复制代码
key = Beijing

value = {
    window_start: ...,
    order_cnt: 12,
    gmv: 4500
}

这时Kafka:

这两个 Record 的 Key 都是 Beijing。

compaction 才知道:它们代表同一个逻辑实体的不同版本。

在city-metrics这个topic下面,他描述的是:现在是什么状态?而不是像order那样,保存历史事件

Kafka 不只是"消息队列"。

它还可以表达:

一个不断变化的状态日志。

upsert-kafka
bash 复制代码
UPSERT
=
UPDATE + INSERT

没有就插入,有了就更新。

Flink SQL 的 upsert-kafka 就是把这种:

动态表的更新语义转换成带 Key 的 Kafka Changelog。

所以:

bash 复制代码
Flink动态表

Beijing → 3000
       ↓
Beijing → 4000
       ↓
Beijing → 5000

通过:

bash 复制代码
upsert-kafka

变成:

bash 复制代码
Kafka

key=Beijing value=3000
key=Beijing value=4000
key=Beijing value=5000

然后:

bash 复制代码
cleanup.policy=compact

长期保留:

bash 复制代码
key=Beijing value=5000

所以整个流程:

bash 复制代码
Flink
  │
  │ 每个城市的最新聚合状态
  ↓
upsert-kafka
  │
  │ key = city
  ↓
city-metrics
  │
  │ compact
  ↓
每个城市最新状态

Python Redis Worker

长期运行的 Kafka Consumer Python 程序。

bash 复制代码
while True:
    message = 从Kafka拿一条消息

    解析message

    写入Redis

    如果成功:
        提交Kafka offset
redis /redis-sink

redis

运行:

Redis Server

负责:

HSET

HGET

AOF

内存数据

...

redis-sink

运行:

redis_sink.py

负责:

Kafka → Redis

所以:

bash 复制代码
┌─────────────────────────┐
│ redis-sink Container    │
│                         │
│ Python                  │
│ redis_sink.py           │
└───────────┬─────────────┘
            │
            │ HSET
            ↓
┌─────────────────────────┐
│ Redis Container         │
│                         │
│ redis-server            │
└─────────────────────────┘
Worker 收到 Kafka Message 后是什么样?

可能收到的信息的样子:

bash 复制代码
Kafka Record

key:
Beijing

value:
{
    "window_start": "2026-08-27 13:06:00",
    "window_end": "2026-08-27 13:07:00",
    "order_cnt": 9,
    "gmv": 3294.89
}

Worker先反序列化:

也就是把 Kafka里的 bytes / JSON 转成 Python 能操作的数据:

bash 复制代码
city = "Beijing"
order_cnt = 9
gmv = 3294.89

(反序列化(Deserialization)= 把"一串字节"变回"程序能直接用的对象/数据结构"的过程。

一句话先记住:

序列化 = 把对象变成字节(为了传输/存储)

反序列化 = 把字节变回对象(为了使用) )

然后构造 Redis Key:

bash 复制代码
redis_key = f"city:{city}:latest"

得到:

bash 复制代码
city:Beijing:latest
redis_sink.py
python 复制代码
import json
import os
import time

from kafka import KafkaConsumer
from redis import Redis


KAFKA_BOOTSTRAP_SERVERS = os.getenv("KAFKA_BOOTSTRAP_SERVERS", "kafka:9092")
# 先尝试从环境变量 KAFKA_BOOTSTRAP_SERVERS 获取 Kafka 地址;如果没有设置,就默认用 kafka:9092
# 为什么不是写死: 因为使用环境变量可以让程序和部署配置分离。
KAFKA_TOPIC = os.getenv("KAFKA_TOPIC", "city-metrics")
KAFKA_GROUP_ID = os.getenv("KAFKA_GROUP_ID", "redis-latest-city-metrics")
REDIS_HOST = os.getenv("REDIS_HOST", "redis")
REDIS_PORT = int(os.getenv("REDIS_PORT", "6379"))


def connect_to_redis(): # 不断尝试连接 Redis,直到成功。
    while True:
        try:
            client = Redis(host=REDIS_HOST, port=REDIS_PORT, decode_responses=True) # 这里创建 Redis 客户端。 
            # decode_responses=True Redis 底层返回的数据通常是 bytes。
            client.ping()
            print(f"Connected to Redis at {REDIS_HOST}:{REDIS_PORT}")
            return client # 把已经连接好的 Redis Client 返回给调用方。 
        except Exception as exc:
            print(f"Redis is not ready ({exc}); retrying in 2 seconds...")
            time.sleep(2)


def connect_to_kafka():
    while True:
        try:
            consumer = KafkaConsumer(
                KAFKA_TOPIC,
                bootstrap_servers=KAFKA_BOOTSTRAP_SERVERS,
                # 这里 bootstrap_servers 不代表 Kafka 永远只有这一台。 意思更接近:先给我一个能找到 Kafka 集群的入口地址。Consumer连进去以后,会获取 Broker / Topic / Partition 等元数据。
                group_id=KAFKA_GROUP_ID,
                auto_offset_reset="earliest",
                # 如果这个 Consumer Group 当前没有可用的已提交 offset,那么从哪里开始? earliest 从最早还能读取到的消息开始。 
                enable_auto_commit=False,# Kafka客户端不要自动帮我提交 offset。 不用 因为我们想自己控制  只有 Redis HSET 成功,才算这条 Kafka 消息处理成功。
                value_deserializer=lambda value: json.loads(value.decode("utf-8")),
            )
            print(f"Connected to Kafka topic {KAFKA_TOPIC} at {KAFKA_BOOTSTRAP_SERVERS}")
            return consumer
        except Exception as exc:
            print(f"Kafka is not ready ({exc}); retrying in 2 seconds...")
            time.sleep(2)


def main():
    redis_client = connect_to_redis()
    consumer = connect_to_kafka()

    for message in consumer:
        metric = message.value
        city = metric["city"]
        key = f"city:{city}:latest"
        redis_client.hset(
            key,
            mapping={
                "window_start": metric["window_start"],
                "window_end": metric["window_end"],
                "order_cnt": str(metric["order_cnt"]),
                "gmv": str(metric["gmv"]),
            },
        )
        consumer.commit()
        print(f"Updated {key}: {metric}")


if __name__ == "__main__":
    main()
python 复制代码
redis_sink.py

启动
 ↓
读取环境变量
 ↓
连接 Redis
 ↓
连接 Kafka
 ↓
不断消费 city-metrics
 ↓
取出 city
 ↓
拼出 Redis Key
 ↓
HSET 写入 Hash
 ↓
Redis 成功
 ↓
commit Kafka offset
 ↓
继续下一条
python 复制代码
# 读取环境变量。
os.getenv(...)
python 复制代码
# 连接失败以后等两秒再重试。
time.sleep(2)

读取 Kafka 和 Redis 的连接配置。不断尝试连接 Redis 和 Kafka,直到成功。加入 redis-latest-city-metrics 消费者组并消费 city-metrics Topic,关闭自动 Offset 提交。每收到一条城市指标消息,就解析其中的城市和窗口指标,构造 city::latest Redis Key,通过 HSET 更新这个城市的最新指标。只有 Redis 写入成功后才提交 Kafka Offset,然后继续处理下一条数据。

Redis 常用命令
命令 作用 例子
PING 检查 Redis 是否活着 PING
SET 写 String SET name May
GET 读 String GET name
DEL 删除 Key DEL name
EXISTS 判断 Key 是否存在 EXISTS name
DBSIZE 当前 DB 有多少 Key DBSIZE
SCAN 遍历/查找 Key SCAN 0 MATCH city:*
HSET 写 Hash 字段 HSET user:1 name May
HGET 读一个 Hash 字段 HGET user:1 name
HGETALL 读整个 Hash HGETALL user:1
HDEL 删除 Hash 字段 HDEL user:1 age
EXPIRE 给 Key 设置 TTL EXPIRE name 60
TTL 查看剩余过期时间 TTL name

关于redis的常见面试题

RDB & AOF 持久化方式

RDB = Redis Database

AOF = Append Only File

它们解决的是同一个问题:

Redis 主要把数据放在内存里,速度很快,但进程重启、机器断电时,内存数据会丢。那怎么把数据保存下来?

Redis 提供的两种主要持久化方式,就是 RDB 和 AOF。

RDB:Redis Database

RDB 可以理解成:

在某个时间点,把 Redis 当前内存里的完整数据状态拍一张"快照"。

比如现在 Redis 里有:

python 复制代码
city:Beijing:latest
city:Shanghai:latest
city:Hangzhou:latest

到了某个时间点,Redis 做一次 RDB:

python 复制代码
10:00:00
内存状态
   ↓
拍快照
   ↓
dump.rdb

这个 dump.rdb 保存的是:

"10:00:00 这一刻,Redis 数据长什么样。"

它不是记录你之前执行了哪些命令,而是保存最终数据状态。

RDB 是怎么产生的?Redis 可以按规则周期性生成 RDB。

常用命令:

python 复制代码
SAVE
BGSAVE

SAVE

会让 Redis 主进程直接做快照。

python 复制代码
Redis正在服务请求
 ↓
SAVE
 ↓
我要先忙着生成RDB
 ↓
其他请求被阻塞

BGSAVE

BGSAVE = Background Save

python 复制代码
Redis主进程
   ↓
fork 子进程
   ↓
子进程负责生成 RDB
   ↓
主进程继续处理请求

RDB 的优点

最大的优点之一是:

文件紧凑,恢复速度通常比较快。

因为它保存的是最终状态。

恢复的时候也比较直接:

读 dump.rdb

直接构建内存数据

所以大型 Redis 数据集恢复时,RDB 往往很有优势。

RDB 最大的问题:可能丢一段数据

这是 RDB 最重要的缺点。

RDB 本质上是:

间隔式快照。

快照之间发生的修改,如果还没等到下一次快照,可能丢。

AOF:Append Only File

"Redis 每次执行了什么写操作?"

只追加文件。

AOF 怎么恢复?

Redis 重启的时候:

读取 AOF

重新执行里面的写命令

把内存状态重新构建出来

所以可以把 AOF 理解成:

操作日志重放。

AOF 为什么数据丢得更少?

你项目现在配置:

appendonly yes

appendfsync everysec

appendonly yes 意思就是开启 AOF。

appendfsync everysec 大约每秒把 AOF 数据同步到磁盘一次。理论上风险通常只是最近很短的一段数据,而不是像 RDB 那样可能损失几分钟甚至更久。

fsync

程序执行:

write()

并不一定意味着数据已经真正安全落在磁盘介质上。

操作系统可能先把数据放在:

Page Cache

所以:

Redis

写AOF

OS缓存

如果机器突然断电,OS缓存里的东西可能还没真正落盘。

fsync 的作用可以粗略理解:

要求操作系统把这些数据同步到持久存储。

所以 AOF 常见三种策略:

python 复制代码
appendfsync always
appendfsync everysec
appendfsync no

三种 AOF fsync 策略

always

每次写命令都 fsync。

SET

立刻刷盘

HSET

立刻刷盘

优点:

数据最安全。

缺点:

磁盘 I/O 太频繁,性能明显下降。

everysec

你项目用的。

写操作不断发生

大约每秒统一 fsync

优点:

性能和可靠性平衡得很好。

风险:

极端故障时可能丢失大约最近一秒级的数据。

这是非常常见的配置。

no

Redis 自己不主动控制 fsync 频率,更多交给操作系统决定。

优点:

性能好。

缺点:

数据持久性控制更弱,故障时可能损失更多。

AOF 有个明显问题:文件会越来越大

AOF Rewrite

根据当前最终数据状态,重新生成一份更精简的 AOF。

例如原 AOF:

SET count 1

SET count 2

SET count 3

SET count 4

重写后:

SET count 4

因为前面那些操作已经没有必要再重放。

所以 AOF Rewrite 可以显著缩小文件。

RDB 和 AOF 最大的思想区别

python 复制代码
RDB
=
保存"现在是什么样"

AOF
=
保存"我是怎么一步步变成这样的"
对比项 RDB AOF
全称 Redis Database Append Only File
保存内容 某时刻完整数据快照 写命令日志
数据安全性 快照间可能丢较多 通常丢得更少
文件体积 通常较小 通常较大
恢复速度 通常更快 需要重放日志,通常更慢
运行开销 周期性快照开销 持续追加写日志
典型用途 备份、快速恢复 更高持久性要求

那 Redis 可以同时开 RDB 和 AOF 吗?

可以的

因为它们优势互补。

python 复制代码
RDB
→ 快照备份、恢复快


AOF
→ 更细粒度记录、数据丢失更少

那如果两个都开,重启优先用谁?

一般来说,如果启用了 AOF,Redis 重启时会优先使用 AOF 来恢复,因为:

AOF 通常包含比 RDB 更新的数据。

Redis 的 RDB 和 AOF 有什么区别?

可以答:

RDB,全称 Redis Database,是周期性保存 Redis 内存数据完整快照的持久化方式,文件较紧凑、恢复速度通常较快,但两次快照之间的数据在故障时可能丢失。AOF,全称 Append Only File,会持续记录 Redis 的写操作,并在重启时通过重放日志恢复数据,因此数据持久性通常更好,但文件更大、恢复和持续写入成本也更高。AOF 可以通过 rewrite 压缩历史冗余操作,常见的 appendfsync everysec 是性能和持久性之间的折中。

AOF everysec 会不会完全不丢数据?

不能保证绝对零丢失,极端情况下仍可能损失最近非常短的一段、通常约秒级的数据。

python 复制代码
RDB:
拍照片

AOF:
记流水账


RDB:
恢复快
可能丢得多一点


AOF:
记录细
文件更大
恢复更慢
通常丢得少

RDB 记录状态,AOF 记录变化。

phase 6 验证

bash 复制代码
INSERT 一条受控订单
        ↓
Debezium捕获 INSERT
        ↓
Kafka
        ↓
Flink产生聚合
        ↓
Doris + Redis出现结果
        ↓
UPDATE 这条订单
        ↓
观察同一个窗口被修正
        ↓
DELETE 这条唯一订单
        ↓
观察:
Doris聚合行消失
Redis window Hash消失
ZSET member消失
latest正确回退/保持
相关推荐
bbq粉刷匠1 小时前
数据库设计实践
数据库
java_logo1 小时前
Docker 部署 ClickHouse Server:轻松搭建列式 OLAP 分析数据库平台
数据库·clickhouse·docker·私有化部署·olap·列式数据库·轩辕镜像
盛世宏博智慧档案1 小时前
高速 ETC 门架外场机房温湿度远程监测解决方案
服务器·数据库·php·高度·高速·温湿度
—Miss. Z—1 小时前
计算机三级数据库技术—应用题2️⃣
数据库·mysql
PrudentWoo2 小时前
PostgreSQL 12 登录失败:role “postgres“ is not permitted to log in 的排查与修复
数据库·postgresql
这个DBA有点耶2 小时前
临时表从4.7秒到0.12秒:不是所有Using temporary都需要优化
数据库·mysql·代码规范
天衍四九-2 小时前
排查慢SQL用explain分析执行计划,主要关注哪些字段?
数据库·sql
Meta392 小时前
PostgreSQL报SELECT rule‘s target entry X has different type from column “XXX“
数据库·postgresql·dubbo
洋不写bug3 小时前
二叉树(四)经典算法题目解析,公共祖先,二叉树构造,非递归遍历
数据库·算法·二叉树·二叉树构造·公共祖先