Flink 如何保证任务故障恢复?
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 是什么?
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
Flink 的 Checkpoint Barrier 是什么?
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连接
为什么 Flink 又要8030,又要8040?
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正确回退/保持