Flink 写入 Redis 实战:数据模型选型、连接池调优与常见坑

1. 引言:为什么实时计算结果要存到 Redis?
在实时计算领域,Flink 作为计算引擎,处理完数据后往往需要将结果存储起来供下游查询。而 Redis,作为一款高性能的内存数据库,凭借其微秒级响应速度和丰富的数据结构,成为了 Flink 结果输出的首选之一。
典型场景包括:
- 实时大屏:将聚合后的 PV/UV 数据存入 Redis,供前端 API 秒级查询。
- 用户画像:将用户实时行为标签存入 Redis Hash,方便其他服务快速读取。
- 黑名单/规则引擎:将 Flink 动态生成的规则缓存到 Redis,供在线服务实时加载。
然而,在实际生产中,很多开发者只是"能用"RedisSink,却不清楚:
- 应该用 String 、Hash 还是 Sorted Set 来存储我的数据?
- 连接池该怎么配置才能既不浪费资源又不超时?
- Redis 挂了,Flink 任务会怎样?如何优雅降级?
本文将从零开始,手把手带你构建一个完整的 Flink -> Redis 数据写入项目,并深入解答上述问题。读完本文,你将拥有一个可直接运行的 Demo,并掌握生产环境下的最佳实践。
2. 前置知识:Redis 数据结构与 Sink 编程模型
在写代码之前,我们需要建立两个核心认知。
2.1. 为何 Redis 的数据结构选择如此重要?
Redis 不是简单的"Key-Value"存储,它提供了 5 种核心数据结构。不同的数据结构,决定了后续查询的效率与灵活性:
| 数据结构 | 适合存储的数据类型 | 命令示例 | 对应 RedisMapper 用法 |
|---|---|---|---|
| String | 单个值、计数器、JSON 字符串 | SET user:1001 '{"name":"Mary"}' |
RedisCommand.SET |
| Hash | 对象的多个字段(如用户属性、点击记录) | HSET click Mary ./home |
RedisCommand.HSET |
| List | 消息队列、最新 N 条记录 | LPUSH logs "error..." |
RedisCommand.LPUSH |
| Set | 去重集合、标签系统 | SADD tags "sports" |
RedisCommand.SADD |
| Sorted Set | 排行榜、带权重的任务队列 | ZADD rank 100 "playerA" |
RedisCommand.ZADD |
选型原则 :如果你的数据是一个对象(如用户画像),且需要单独修改其中某个字段,Hash 是最佳选择。如果你只是存储一个计算结果(如总销售额),String 就够了。如果需要排序(如热榜),Sorted Set 是唯一选择。
2.2. Flink Redis Connector 的编程模型
Flink 官方的 flink-connector-redis 采用 Mapper 设计模式。你不需要关心底层如何建立连接、如何执行命令,只需要实现 RedisMapper 接口,告诉框架三件事:
- 执行什么命令? (
getCommandDescription) - Key 是什么? (
getKeyFromData) - Value 是什么? (
getValueFromData)
这种设计将"数据"与"存储逻辑"解耦,非常优雅。
3. 核心剖析:三种数据模型的 Mapper 实现对比
接下来,我们以用户点击事件 Event(user, url, timestamp) 为例,展示使用不同 Redis 数据结构时的 Mapper 实现差异。
3.1. 方案一:使用 Hash 存储(推荐用于用户画像)
目标 :将每个用户的点击记录存储为 Hash 中的一个 Field。例如:HSET user:clicks Mary ./home。
scala
class ClickHashMapper extends RedisMapper[Event] {
override def getCommandDescription: RedisCommandDescription =
new RedisCommandDescription(RedisCommand.HSET, "user:clicks") // 大Key
override def getKeyFromData(t: Event): String = t.user // 作为Hash的Field
override def getValueFromData(t: Event): String = t.url // 作为Value
}
优点 :可单独更新某个用户的点击记录,且 HGETALL user:clicks 可一次性拉取全量数据。
3.2. 方案二:使用 String 存储(推荐用于单值结果)
目标 :为每个用户单独存储一个 Key,Value 为其点击的 URL。例如:SET user:Mary ./home。
scala
class ClickStringMapper extends RedisMapper[Event] {
override def getCommandDescription: RedisCommandDescription =
new RedisCommandDescription(RedisCommand.SET) // 注意:无需额外Key
override def getKeyFromData(t: Event): String = s"user:${t.user}" // 完整的Key
override def getValueFromData(t: Event): String = t.url // Value
}
优点:读写性能最高(O(1)),适合高频单点查询。
3.3. 方案三:使用 List 存储(推荐用于最新 N 条记录)
目标 :将每个用户的点击存入一个 List。例如:LPUSH user:Mary:history ./home。
scala
class ClickListMapper extends RedisMapper[Event] {
override def getCommandDescription: RedisCommandDescription =
new RedisCommandDescription(RedisCommand.LPUSH) // 无需额外Key
override def getKeyFromData(t: Event): String = s"user:${t.user}:history"
override def getValueFromData(t: Event): String = t.url
}
优点 :可通过 LRANGE 快速获取用户最近的 N 条记录。
本节小结 :同一份数据,因查询需求不同,应选用不同的数据结构。
RedisMapper的设计足够灵活,能够支持所有主流 Redis 命令。
4. 手把手实操:完整可运行 Demo
现在我们整合所有部分,写一个完整的、可直接运行的 Flink 任务。
4.1. 环境依赖 (pom.xml)
首先,创建 Maven 项目,引入必要的依赖。请注意,flink-connector-redis 并非官方 Apache 顶级项目,而是由第三方维护(org.apache.bahir),但已被广泛使用。
xml
<properties>
<flink.version>1.13.6</flink.version>
<scala.binary.version>2.12</scala.binary.version>
</properties>
<dependencies>
<!-- Flink 核心依赖 -->
<dependency>
<groupId>org.apache.flink</groupId>
<artifactId>flink-streaming-scala_${scala.binary.version}</artifactId>
<version>${flink.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.apache.flink</groupId>
<artifactId>flink-clients_${scala.binary.version}</artifactId>
<version>${flink.version}</version>
<scope>provided</scope>
</dependency>
<!-- Redis Connector (由 Bahir 提供) -->
<dependency>
<groupId>org.apache.flink</groupId>
<artifactId>flink-connector-redis_${scala.binary.version}</artifactId>
<version>1.1.0</version>
</dependency>
<!-- Jedis (Redis Java Client) -->
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
<version>3.7.0</version>
</dependency>
<!-- 日志依赖 -->
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-log4j12</artifactId>
<version>1.7.32</version>
<scope>runtime</scope>
</dependency>
</dependencies>
4.2. 自定义数据源:ClickSource
为了让代码可运行,我们实现一个模拟的 ClickSource,它会不断生成随机的用户点击事件。
scala
package source
import org.apache.flink.streaming.api.functions.source.RichSourceFunction
import scala.util.Random
case class Event(user: String, url: String, timestamp: Long)
class ClickSource extends RichSourceFunction[Event] {
private var isRunning = true
private val users = Array("Mary", "Sum", "King", "Tony", "Anna")
private val urls = Array("./home", "./cart", "./prod", "./login", "./logout")
override def run(ctx: SourceFunction.SourceContext[Event]): Unit = {
while (isRunning) {
ctx.collect(
Event(
users(Random.nextInt(users.length)),
urls(Random.nextInt(urls.length)),
System.currentTimeMillis()
)
)
Thread.sleep(1000) // 每秒生成一条
}
}
override def cancel(): Unit = isRunning = false
}
4.3. 主程序:sinkToRedis
现在编写主程序,使用 Hash 结构存储数据。
scala
package sink
import org.apache.flink.streaming.api.scala._
import org.apache.flink.streaming.connectors.redis.RedisSink
import org.apache.flink.streaming.connectors.redis.common.config.FlinkJedisPoolConfig
import org.apache.flink.streaming.connectors.redis.common.mapper.{RedisCommand, RedisCommandDescription, RedisMapper}
import source.{ClickSource, Event}
object sinkToRedisDemo {
def main(args: Array[String]): Unit = {
// 1. 创建执行环境
val env = StreamExecutionEnvironment.getExecutionEnvironment
env.setParallelism(1) // 便于观察
// 2. 添加数据源
val dataStream: DataStream[Event] = env.addSource(new ClickSource)
// 3. 配置 Redis 连接池 (务必填写正确的 Redis 地址)
val conf: FlinkJedisPoolConfig = new FlinkJedisPoolConfig.Builder()
.setHost("localhost") // 如果 Redis 在 Docker 中,请填写宿主机 IP
.setPort(6379)
.setDatabase(0) // 默认数据库
.setMaxTotal(10) // 最大连接数
.setMaxIdle(5) // 最大空闲连接数
.setMinIdle(1) // 最小空闲连接数
.setTimeout(3000) // 连接超时 (毫秒)
.build()
// 4. 定义 RedisMapper (使用 HSET)
val mapper = new RedisMapper[Event] {
override def getCommandDescription: RedisCommandDescription =
new RedisCommandDescription(RedisCommand.HSET, "click_events") // 大 Key
override def getKeyFromData(t: Event): String = t.user // Field
override def getValueFromData(t: Event): String = t.url // Value
}
// 5. 添加 Redis Sink
dataStream.addSink(new RedisSink[Event](conf, mapper))
// 6. 执行任务
env.execute("Flink Redis Sink Demo")
}
}
4.4. 环境准备与运行验证
步骤 1:启动 Redis
本地启动 Redis(若无,可使用 Docker):
bash
docker run -d --name redis-test -p 6379:6379 redis:6.2
步骤 2:运行 Flink 任务
直接在 IDE 中运行 sinkToRedisDemo 的 main 方法。
步骤 3:验证写入结果
使用 Redis CLI 查看数据是否写入成功:
bash
# 进入 Redis 容器
docker exec -it redis-test redis-cli
# 查看 Hash 中所有 Field
HGETALL click_events
你会看到类似输出:
1) "Mary"
2) "./login"
3) "King"
4) "./prod"
5) "Sum"
6) "./cart"
说明:每条新数据会覆盖同用户的上一次记录(因为 HSET 对同一个 Field 再次赋值会覆盖)。这正是 Hash 结构的特性。
4.5. 常见错误与调试方法
| 错误现象 | 可能原因 | 解决方案 |
|---|---|---|
redis.clients.jedis.exceptions.JedisConnectionException: Could not get a resource from the pool |
Redis 未启动,或 setHost/setPort 配置错误 |
检查 Redis 进程:`ps -ef |
java.lang.NoClassDefFoundError: redis/clients/jedis/JedisPoolConfig |
缺少 jedis 依赖 |
在 pom.xml 中添加 <dependency><groupId>redis.clients</groupId><artifactId>jedis</artifactId>...</dependency>。 |
数据写入后,HGETALL 看不到任何数据 |
setDatabase 配置的数据库索引与 CLI 连接的不一致 |
确保 CLI 连接时指定数据库:redis-cli -n 0,或统一配置 setDatabase(0)。 |
Flink 任务启动后立即报错 RedisCommand 不支持 |
使用了错误的命令与数据结构组合(如对 List 使用 HSET) | 检查 RedisCommand 与业务场景是否匹配。 |
5. 进阶思考:连接池调优与容错策略
5.1. 连接池大小如何设定?
FlinkJedisPoolConfig 的 setMaxTotal(最大连接数)是核心参数。它并非越大越好,而是取决于 下游 Sink 的并行度。
- 公式参考 :建议
MaxTotal= Sink 并行度 × 2。 - 示例 :若
sink.setParallelism(4),则MaxTotal设为 8 即可。过多的连接会占用 Redis 的文件描述符,反而导致性能下降。
5.2. 如果 Redis 写失败了,Flink 任务会怎样?
默认情况下,RedisSink 没有内置重试机制。一旦 Jedis 抛出异常(如连接超时),该异常会向上抛出,导致 Flink 任务失败(Failover) 。如果你希望任务"容忍"Redis 的短暂抖动,可以自定义一个 RedisMapper 并捕获异常,或使用 Flink 的 sink 算子包装一层 recover 逻辑(但需注意数据一致性)。
更稳健的做法 :在 RedisSink 外包装一层异步 I/O,或使用 RichSinkFunction 自己实现带重试的写入逻辑。这超出了本文篇幅,但值得你作为课后延伸去研究。
5.3. 如何连接 Redis 集群(Cluster/Sentinel)?
连接集群只需修改配置构建方式:
scala
import org.apache.flink.streaming.connectors.redis.common.config.FlinkJedisClusterConfig
import redis.clients.jedis.HostAndPort
val clusterConf = new FlinkJedisClusterConfig.Builder()
.setNodes(new HostAndPort("node1", 6379), new HostAndPort("node2", 6379))
.setMaxRedirections(5)
.build()
6. 总结:数据模型选型决策树
面对一个实时写入 Redis 的需求,你可以根据以下决策树快速选择最佳方案:
#mermaid-svg-9gcX17wfmMVKwJUz{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-9gcX17wfmMVKwJUz .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-9gcX17wfmMVKwJUz .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-9gcX17wfmMVKwJUz .error-icon{fill:#552222;}#mermaid-svg-9gcX17wfmMVKwJUz .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-9gcX17wfmMVKwJUz .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-9gcX17wfmMVKwJUz .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-9gcX17wfmMVKwJUz .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-9gcX17wfmMVKwJUz .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-9gcX17wfmMVKwJUz .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-9gcX17wfmMVKwJUz .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-9gcX17wfmMVKwJUz .marker{fill:#333333;stroke:#333333;}#mermaid-svg-9gcX17wfmMVKwJUz .marker.cross{stroke:#333333;}#mermaid-svg-9gcX17wfmMVKwJUz svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-9gcX17wfmMVKwJUz p{margin:0;}#mermaid-svg-9gcX17wfmMVKwJUz .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-9gcX17wfmMVKwJUz .cluster-label text{fill:#333;}#mermaid-svg-9gcX17wfmMVKwJUz .cluster-label span{color:#333;}#mermaid-svg-9gcX17wfmMVKwJUz .cluster-label span p{background-color:transparent;}#mermaid-svg-9gcX17wfmMVKwJUz .label text,#mermaid-svg-9gcX17wfmMVKwJUz span{fill:#333;color:#333;}#mermaid-svg-9gcX17wfmMVKwJUz .node rect,#mermaid-svg-9gcX17wfmMVKwJUz .node circle,#mermaid-svg-9gcX17wfmMVKwJUz .node ellipse,#mermaid-svg-9gcX17wfmMVKwJUz .node polygon,#mermaid-svg-9gcX17wfmMVKwJUz .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-9gcX17wfmMVKwJUz .rough-node .label text,#mermaid-svg-9gcX17wfmMVKwJUz .node .label text,#mermaid-svg-9gcX17wfmMVKwJUz .image-shape .label,#mermaid-svg-9gcX17wfmMVKwJUz .icon-shape .label{text-anchor:middle;}#mermaid-svg-9gcX17wfmMVKwJUz .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-9gcX17wfmMVKwJUz .rough-node .label,#mermaid-svg-9gcX17wfmMVKwJUz .node .label,#mermaid-svg-9gcX17wfmMVKwJUz .image-shape .label,#mermaid-svg-9gcX17wfmMVKwJUz .icon-shape .label{text-align:center;}#mermaid-svg-9gcX17wfmMVKwJUz .node.clickable{cursor:pointer;}#mermaid-svg-9gcX17wfmMVKwJUz .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-9gcX17wfmMVKwJUz .arrowheadPath{fill:#333333;}#mermaid-svg-9gcX17wfmMVKwJUz .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-9gcX17wfmMVKwJUz .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-9gcX17wfmMVKwJUz .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-9gcX17wfmMVKwJUz .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-9gcX17wfmMVKwJUz .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-9gcX17wfmMVKwJUz .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-9gcX17wfmMVKwJUz .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-9gcX17wfmMVKwJUz .cluster text{fill:#333;}#mermaid-svg-9gcX17wfmMVKwJUz .cluster span{color:#333;}#mermaid-svg-9gcX17wfmMVKwJUz div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-9gcX17wfmMVKwJUz .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-9gcX17wfmMVKwJUz rect.text{fill:none;stroke-width:0;}#mermaid-svg-9gcX17wfmMVKwJUz .icon-shape,#mermaid-svg-9gcX17wfmMVKwJUz .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-9gcX17wfmMVKwJUz .icon-shape p,#mermaid-svg-9gcX17wfmMVKwJUz .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-9gcX17wfmMVKwJUz .icon-shape .label rect,#mermaid-svg-9gcX17wfmMVKwJUz .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-9gcX17wfmMVKwJUz .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-9gcX17wfmMVKwJUz .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-9gcX17wfmMVKwJUz :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 是
否
是
否
是
否
需要存储什么数据?
是对象的多字段属性吗?
使用 Hash
例: 用户画像, 点击记录
需要排序/按分数查询吗?
使用 Sorted Set
例: 排行榜, 延迟队列
需要存储列表/序列吗?
使用 List
例: 消息队列, 操作日志
使用 String
例: 计数器, JSON 字符串
最后,送给大家三句话:
- 选对数据结构,事半功倍。 切勿所有场景都用 SET/GET。
- 连接池配置必须与并行度联动。 太多或太少都会拖累性能。
- 务必考虑 Redis 故障的影响。 在关键链路中,做好降级或多级缓存预案。
现在,去为你的实时计算任务挑选最合适的 Redis 存储模型吧!