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

1. 引言:为什么实时计算结果要存到 Redis?

在实时计算领域,Flink 作为计算引擎,处理完数据后往往需要将结果存储起来供下游查询。而 Redis,作为一款高性能的内存数据库,凭借其微秒级响应速度和丰富的数据结构,成为了 Flink 结果输出的首选之一。

典型场景包括:

  • 实时大屏:将聚合后的 PV/UV 数据存入 Redis,供前端 API 秒级查询。
  • 用户画像:将用户实时行为标签存入 Redis Hash,方便其他服务快速读取。
  • 黑名单/规则引擎:将 Flink 动态生成的规则缓存到 Redis,供在线服务实时加载。

然而,在实际生产中,很多开发者只是"能用"RedisSink,却不清楚:

  • 应该用 StringHash 还是 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 是唯一选择。

Flink 官方的 flink-connector-redis 采用 Mapper 设计模式。你不需要关心底层如何建立连接、如何执行命令,只需要实现 RedisMapper 接口,告诉框架三件事:

  1. 执行什么命令?getCommandDescription
  2. Key 是什么?getKeyFromData
  3. 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 中运行 sinkToRedisDemomain 方法。

步骤 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. 连接池大小如何设定?

FlinkJedisPoolConfigsetMaxTotal(最大连接数)是核心参数。它并非越大越好,而是取决于 下游 Sink 的并行度

  • 公式参考 :建议 MaxTotal = Sink 并行度 × 2。
  • 示例 :若 sink.setParallelism(4),则 MaxTotal 设为 8 即可。过多的连接会占用 Redis 的文件描述符,反而导致性能下降。

默认情况下,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 字符串

最后,送给大家三句话:

  1. 选对数据结构,事半功倍。 切勿所有场景都用 SET/GET。
  2. 连接池配置必须与并行度联动。 太多或太少都会拖累性能。
  3. 务必考虑 Redis 故障的影响。 在关键链路中,做好降级或多级缓存预案。

现在,去为你的实时计算任务挑选最合适的 Redis 存储模型吧!

相关推荐
TMT星球1 小时前
快手Q2总收入355亿元:月活近8亿,核心商业收入同比增长7.4%
大数据·人工智能
陈聪.2 小时前
企业级 NoSQL 数据库 Redis 核心知识整理
数据库·redis·nosql
智能运维指南2 小时前
智能体自治运维选型指南:2026年企业如何从“AI辅助”走向“AI自治”
大数据·运维·人工智能
迅易科技2 小时前
从数据可视化到运营闭环,制造企业如何构建卓越运营数字化体系?
大数据·制造·big data
Elastic 中国社区官方博客3 小时前
Elasticsearch:什么是向量数据库?
大数据·运维·数据库·人工智能·elasticsearch·搜索引擎·ai
久美子3 小时前
铁轨、迷宫与护栏:AI 数据工程的确定性骨架
大数据
旗开得胜马到成功3 小时前
AI编程智能体删库后,我把开发环境从每日备份换成中科热备CDP秒级回滚
大数据·elasticsearch·ai编程
guanchabao4 小时前
工程项目信息网站选择先避开三个误区,再对着清单挑
大数据
数智化管理手记4 小时前
数据质量无法保障?自动化数据质量规则校验如何搭建方案?
大数据·数据库·自动化